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/login/login | Tab切换:员工登录/老板登录/注册老板/注册员工 |
|
||||||
| 首页 | /pages/home/home | 预约列表 Tab(待确认/进行中/已完成)+ 新建预约 |
|
| 首页 | /pages/home/home | 预约列表 Tab(待开始/进行中/已完成)+ 新建预约 |
|
||||||
| 洗美报告 | /pages/report/report | 填写报告 + 提交后显示链接+二维码 |
|
| 洗美报告 | /pages/report/report | 填写报告 + 提交后显示链接+二维码 |
|
||||||
| 报告页 | /pages/report-view/reportView | 独立访问的报告页(token=xxx) |
|
| 报告页 | /pages/report-view/reportView | 独立访问的报告页(token=xxx) |
|
||||||
| 我的 | /pages/mine/mine | 个人中心,老板有额外菜单 |
|
| 我的 | /pages/mine/mine | 个人中心,老板有额外菜单 |
|
||||||
|
|||||||
@ -4,6 +4,9 @@
|
|||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
|
||||||
<title>服务报告 - 宠伴生活馆</title>
|
<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>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="app"></div>
|
<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 })
|
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)
|
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) =>
|
export const deletePet = (id, operatorUserId, role) =>
|
||||||
del(`/pet/delete?id=${id}&operatorUserId=${operatorUserId}&role=${encodeURIComponent(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 = {}) =>
|
export const getAppointmentList = (userId, storeId, options = {}) =>
|
||||||
get('/appointment/list', { 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 getReportSuggestion = (token) => get(`/report/${encodeURIComponent(token)}/suggestion`)
|
||||||
|
|
||||||
// 报告页:宠主留资(下次提醒)
|
// 报告页:宠主留资(下次提醒);小程序可传 loginCode(uni.login)沉淀 openid/unionid
|
||||||
export const submitReportReminder = (token, phone) =>
|
export const submitReportReminder = (token, phone, loginCode) =>
|
||||||
post(`/report/${encodeURIComponent(token)}/reminder`, { phone, consent: true })
|
post(`/report/${encodeURIComponent(token)}/reminder`, {
|
||||||
|
phone,
|
||||||
|
consent: true,
|
||||||
|
loginCode: loginCode || ''
|
||||||
|
})
|
||||||
|
|
||||||
// 报告页:一键退订
|
// 报告页:一键退订
|
||||||
export const unsubscribeReportReminder = (unsubscribeToken) =>
|
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() {
|
async function onSubmit() {
|
||||||
if (!canSubmit.value) return
|
if (!canSubmit.value) return
|
||||||
errorMsg.value = ''
|
errorMsg.value = ''
|
||||||
submitting.value = true
|
submitting.value = true
|
||||||
try {
|
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) {
|
if (res && res.code === 200 && res.data && res.data.ok) {
|
||||||
savedDate.value = res.data.remindDate || suggestion.value.remindDate
|
savedDate.value = res.data.remindDate || suggestion.value.remindDate
|
||||||
submitted.value = true
|
submitted.value = true
|
||||||
|
|||||||
@ -13,6 +13,7 @@
|
|||||||
{"path": "pages/mine/MyReports"},
|
{"path": "pages/mine/MyReports"},
|
||||||
{"path": "pages/mine/MyOrders"},
|
{"path": "pages/mine/MyOrders"},
|
||||||
{"path": "pages/mine/MyPets"},
|
{"path": "pages/mine/MyPets"},
|
||||||
|
{"path": "pages/mine/PetHistory"},
|
||||||
{"path": "pages/mine/Profile"},
|
{"path": "pages/mine/Profile"},
|
||||||
{"path": "pages/video-player/videoPlayer"},
|
{"path": "pages/video-player/videoPlayer"},
|
||||||
{"path": "pages/report-view/reportView"},
|
{"path": "pages/report-view/reportView"},
|
||||||
|
|||||||
@ -6,9 +6,18 @@
|
|||||||
<view class="nav-placeholder"></view>
|
<view class="nav-placeholder"></view>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<view v-if="loading" class="page-section loading-hint"><text>加载中…</text></view>
|
<AppPageState
|
||||||
|
class="detail-state"
|
||||||
<view v-else-if="detail" class="page-section detail-body">
|
: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-card">
|
||||||
<view class="detail-row head-row">
|
<view class="detail-row head-row">
|
||||||
<view class="pet-line">
|
<view class="pet-line">
|
||||||
@ -41,9 +50,8 @@
|
|||||||
<text v-if="detail.id">预约编号 {{ detail.id }}</text>
|
<text v-if="detail.id">预约编号 {{ detail.id }}</text>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
|
</AppPageState>
|
||||||
<view v-else class="page-section empty"><text>未找到该预约</text></view>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@ -51,12 +59,16 @@
|
|||||||
import { ref, computed } from 'vue'
|
import { ref, computed } from 'vue'
|
||||||
import { onLoad } from '@dcloudio/uni-app'
|
import { onLoad } from '@dcloudio/uni-app'
|
||||||
import AppIcon from '../../components/AppIcon.vue'
|
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 { getAppointmentDetail } from '../../api/index.js'
|
||||||
import { formatDateTimeCN } from '../../utils/datetime.js'
|
import { formatDateTimeCN } from '../../utils/datetime.js'
|
||||||
import { getAppointmentStatusText, getAppointmentTagClass } from '../../utils/appointment.js'
|
import { getAppointmentStatusText, getAppointmentTagClass } from '../../utils/appointment.js'
|
||||||
|
|
||||||
const loading = ref(true)
|
const loading = ref(true)
|
||||||
|
const loadError = ref(null)
|
||||||
const detail = ref(null)
|
const detail = ref(null)
|
||||||
|
const routeId = ref('')
|
||||||
|
|
||||||
const navSafeStyle = (() => {
|
const navSafeStyle = (() => {
|
||||||
const statusBarHeight = uni.getSystemInfoSync?.().statusBarHeight || 20
|
const statusBarHeight = uni.getSystemInfoSync?.().statusBarHeight || 20
|
||||||
@ -78,23 +90,38 @@ const tagClass = (status) => getAppointmentTagClass(status)
|
|||||||
const goBack = () => uni.navigateBack()
|
const goBack = () => uni.navigateBack()
|
||||||
|
|
||||||
const load = async (id) => {
|
const load = async (id) => {
|
||||||
|
loadError.value = null
|
||||||
if (!id) {
|
if (!id) {
|
||||||
|
detail.value = null
|
||||||
loading.value = false
|
loading.value = false
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
loading.value = true
|
loading.value = true
|
||||||
const res = await getAppointmentDetail(Number(id))
|
try {
|
||||||
loading.value = false
|
const res = await getAppointmentDetail(Number(id))
|
||||||
if (res.code === 200 && res.data) {
|
if (res && res.code === 200 && res.data) {
|
||||||
detail.value = res.data
|
detail.value = res.data
|
||||||
} else {
|
} else {
|
||||||
|
detail.value = null
|
||||||
|
if (res && res.code === 404) {
|
||||||
|
loadError.value = null
|
||||||
|
} else {
|
||||||
|
loadError.value = messageFromApi(res, '加载失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
detail.value = null
|
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) => {
|
onLoad((options) => {
|
||||||
load(options.id)
|
routeId.value = options.id || ''
|
||||||
|
load(routeId.value)
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@ -11,12 +11,37 @@
|
|||||||
<text class="c-hero-sub">查看预约进度,点击右下角加号新建预约</text>
|
<text class="c-hero-sub">查看预约进度,点击右下角加号新建预约</text>
|
||||||
</view>
|
</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
|
<view
|
||||||
v-for="tab in statusTabs"
|
v-for="tab in statusTabs"
|
||||||
:key="tab.name"
|
:key="tab.name"
|
||||||
:class="['tab', { 'tab-active': currentStatus === tab.name }]"
|
:class="['tab', { 'tab-active': currentStatus === tab.name }]"
|
||||||
@click="currentStatus = tab.name"
|
@click="selectTab(tab.name)"
|
||||||
>
|
>
|
||||||
<text class="tab-text">{{ tab.title }}</text>
|
<text class="tab-text">{{ tab.title }}</text>
|
||||||
<view
|
<view
|
||||||
@ -30,27 +55,36 @@
|
|||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<view class="order-list">
|
<AppPageState
|
||||||
<view
|
class="home-list-state"
|
||||||
v-for="item in filteredOrders"
|
:loading="appointmentsLoading"
|
||||||
:key="item.id"
|
:error="appointmentsError"
|
||||||
class="order-card"
|
:empty="showListPlaceholder"
|
||||||
@click="goAppointmentDetail(item)"
|
:empty-title="listEmptyTitle"
|
||||||
>
|
:empty-hint="listEmptyHint"
|
||||||
<view class="order-top">
|
@retry="fetchAppointments"
|
||||||
<view class="order-pet">
|
>
|
||||||
<text class="order-pet-name">{{ item.petName }}</text>
|
<view class="order-list">
|
||||||
<text class="order-svc">{{ item.serviceType }}</text>
|
<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>
|
||||||
<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>
|
</view>
|
||||||
<view v-if="filteredOrders.length === 0" class="empty"><text>暂无预约</text></view>
|
</AppPageState>
|
||||||
</view>
|
|
||||||
|
|
||||||
<view class="fab-appt" @click="openCustCreateAppt" hover-class="fab-appt--hover">
|
<view class="fab-appt" @click="openCustCreateAppt" hover-class="fab-appt--hover">
|
||||||
<text class="fab-appt-icon">+</text>
|
<text class="fab-appt-icon">+</text>
|
||||||
@ -63,13 +97,38 @@
|
|||||||
<text class="nav-title">{{ storeInfo.name || '宠伴生活馆' }}</text>
|
<text class="nav-title">{{ storeInfo.name || '宠伴生活馆' }}</text>
|
||||||
</view>
|
</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 -->
|
<!-- 状态 Tab -->
|
||||||
<view class="tabs-wrap">
|
<view v-else class="tabs-wrap">
|
||||||
<view
|
<view
|
||||||
v-for="tab in statusTabs"
|
v-for="tab in statusTabs"
|
||||||
:key="tab.name"
|
:key="tab.name"
|
||||||
:class="['tab', { 'tab-active': currentStatus === tab.name }]"
|
:class="['tab', { 'tab-active': currentStatus === tab.name }]"
|
||||||
@click="currentStatus = tab.name"
|
@click="selectTab(tab.name)"
|
||||||
>
|
>
|
||||||
<text class="tab-text">{{ tab.title }}</text>
|
<text class="tab-text">{{ tab.title }}</text>
|
||||||
<view
|
<view
|
||||||
@ -84,28 +143,37 @@
|
|||||||
</view>
|
</view>
|
||||||
|
|
||||||
<!-- 列表 -->
|
<!-- 列表 -->
|
||||||
<view class="order-list">
|
<AppPageState
|
||||||
<view v-for="item in filteredOrders" :key="item.id" class="order-card" @click="goAppointmentDetail(item)">
|
class="home-list-state"
|
||||||
<view class="order-top">
|
:loading="appointmentsLoading"
|
||||||
<view class="order-pet">
|
:error="appointmentsError"
|
||||||
<text class="order-pet-name">{{ item.petName }}</text>
|
:empty="showListPlaceholder"
|
||||||
<text class="order-svc">{{ item.serviceType }}</text>
|
: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>
|
||||||
<view :class="['order-status', `status-${item.status}`]">{{ item.statusText }}</view>
|
<view class="order-time">{{ item.time }}</view>
|
||||||
</view>
|
<view class="order-actions">
|
||||||
<view class="order-time">{{ item.time }}</view>
|
<view v-if="item.status === 'new'" class="order-btns">
|
||||||
<view class="order-actions">
|
<button class="app-btn-sm app-btn-primary" @click.stop="startService(item)">开始服务</button>
|
||||||
<view v-if="item.status === 'new'" class="order-btns">
|
<button class="app-btn-sm app-btn-outline" @click.stop="cancelService(item)">取消</button>
|
||||||
<button class="app-btn-sm app-btn-primary" @click.stop="startService(item)">开始服务</button>
|
</view>
|
||||||
<button class="app-btn-sm app-btn-outline" @click.stop="cancelService(item)">取消</button>
|
<view v-else-if="item.status === 'doing'" class="order-btns">
|
||||||
</view>
|
<button class="app-btn-sm app-btn-primary" @click.stop="goReport(item)">填写报告</button>
|
||||||
<view v-else-if="item.status === 'doing'" class="order-btns">
|
</view>
|
||||||
<button class="app-btn-sm app-btn-primary" @click.stop="goReport(item)">填写报告</button>
|
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
<view v-if="filteredOrders.length === 0" class="empty"><text>暂无数据</text></view>
|
</AppPageState>
|
||||||
</view>
|
|
||||||
|
|
||||||
<view class="fab-appt" @click="openStaffNewApptDialog" hover-class="fab-appt--hover">
|
<view class="fab-appt" @click="openStaffNewApptDialog" hover-class="fab-appt--hover">
|
||||||
<text class="fab-appt-icon">+</text>
|
<text class="fab-appt-icon">+</text>
|
||||||
@ -195,12 +263,36 @@ import {
|
|||||||
getAppointmentAvailableSlots
|
getAppointmentAvailableSlots
|
||||||
} from '../../api/index.js'
|
} from '../../api/index.js'
|
||||||
import TabBar from '../../components/TabBar.vue'
|
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 { getUserSession, getStoreSession } from '../../utils/session.js'
|
||||||
import { useNavigator } from '../../composables/useNavigator.js'
|
import { useNavigator } from '../../composables/useNavigator.js'
|
||||||
import { formatDateTimeCN } from '../../utils/datetime.js'
|
import { formatDateTimeCN } from '../../utils/datetime.js'
|
||||||
import { getAppointmentStatusText } from '../../utils/appointment.js'
|
import { getAppointmentStatusText } from '../../utils/appointment.js'
|
||||||
import { normalizeToHalfHour, ymdLocal, formatHalfHourSlotRange } from '../../utils/halfHourTime.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 userInfo = getUserSession()
|
||||||
const storeInfo = getStoreSession()
|
const storeInfo = getStoreSession()
|
||||||
const { goPage } = useNavigator()
|
const { goPage } = useNavigator()
|
||||||
@ -217,12 +309,41 @@ const navSafeStyle = (() => {
|
|||||||
|
|
||||||
const currentStatus = ref(userInfo.role === 'customer' ? 'new' : 'doing')
|
const currentStatus = ref(userInfo.role === 'customer' ? 'new' : 'doing')
|
||||||
const statusTabs = [
|
const statusTabs = [
|
||||||
{ title: '待确认', name: 'new' },
|
{ title: '待开始', name: 'new' },
|
||||||
{ title: '进行中', name: 'doing' },
|
{ title: '进行中', name: 'doing' },
|
||||||
{ title: '已完成', name: 'done' }
|
{ title: '已完成', name: 'done' }
|
||||||
]
|
]
|
||||||
|
|
||||||
const orders = ref([])
|
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 newApptCount = computed(() => orders.value.filter(o => o.status === 'new').length)
|
||||||
const doingApptCount = computed(() => orders.value.filter(o => o.status === 'doing').length)
|
const doingApptCount = computed(() => orders.value.filter(o => o.status === 'doing').length)
|
||||||
const serviceTypes = ref([])
|
const serviceTypes = ref([])
|
||||||
@ -269,6 +390,65 @@ const filteredOrders = computed(() => orders.value.filter(o => {
|
|||||||
return true
|
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 () => {
|
const loadStaffAvailableSlots = async () => {
|
||||||
if (!storeInfo?.id) {
|
if (!storeInfo?.id) {
|
||||||
staffAvailableTimes.value = []
|
staffAvailableTimes.value = []
|
||||||
@ -337,11 +517,47 @@ const onStaffApptTimeChange = (e) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const fetchAppointments = async () => {
|
const fetchAppointments = async () => {
|
||||||
|
appointmentsError.value = null
|
||||||
if (userInfo.role === 'customer') {
|
if (userInfo.role === 'customer') {
|
||||||
if (!userInfo.id) return
|
if (!userInfo.id) {
|
||||||
const res = await getAppointmentList(userInfo.id)
|
appointmentsLoading.value = false
|
||||||
if (res.code === 200) {
|
return
|
||||||
orders.value = res.data.map(appt => ({
|
}
|
||||||
|
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,
|
id: appt.id,
|
||||||
title: appt.serviceType || '洗澡美容预约',
|
title: appt.serviceType || '洗澡美容预约',
|
||||||
desc: `${appt.petType || ''} - ${appt.petName || ''}`,
|
desc: `${appt.petType || ''} - ${appt.petName || ''}`,
|
||||||
@ -353,26 +569,13 @@ const fetchAppointments = async () => {
|
|||||||
serviceType: appt.serviceType,
|
serviceType: appt.serviceType,
|
||||||
appointmentTime: appt.appointmentTime
|
appointmentTime: appt.appointmentTime
|
||||||
}))
|
}))
|
||||||
|
} else {
|
||||||
|
appointmentsError.value = messageFromApi(res, '加载失败')
|
||||||
}
|
}
|
||||||
return
|
} catch (e) {
|
||||||
}
|
appointmentsError.value = NETWORK_ERROR_MSG
|
||||||
if (!storeInfo.id) return
|
} finally {
|
||||||
const res = await getAppointmentList(null, storeInfo.id, { page: 1, pageSize: 50 })
|
appointmentsLoading.value = false
|
||||||
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' })
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -555,6 +758,81 @@ onShow(() => {
|
|||||||
}
|
}
|
||||||
.home-remark-textarea { min-height: 80px; line-height: 1.5; }
|
.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 */
|
/* B端 Tab */
|
||||||
.tabs-wrap {
|
.tabs-wrap {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
@ -160,6 +160,20 @@ const backToLoginForm = () => { view.value = 'login' }
|
|||||||
|
|
||||||
const showToast = (msg) => uni.showToast({ title: msg, icon: 'none' })
|
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 () => {
|
const handleSendSms = async () => {
|
||||||
if (!loginForm.phone || loginForm.phone.length !== 11) return showToast('请输入正确的手机号')
|
if (!loginForm.phone || loginForm.phone.length !== 11) return showToast('请输入正确的手机号')
|
||||||
const res = await sendSms(loginForm.phone)
|
const res = await sendSms(loginForm.phone)
|
||||||
@ -253,7 +267,8 @@ const onWxGetPhoneNumber = async (e) => {
|
|||||||
}
|
}
|
||||||
loginLoading.value = true
|
loginLoading.value = true
|
||||||
try {
|
try {
|
||||||
const res = await wxPhoneLogin(code)
|
const loginCode = await fetchWxLoginCode()
|
||||||
|
const res = await wxPhoneLogin(code, loginCode)
|
||||||
if (res.code === 200) {
|
if (res.code === 200) {
|
||||||
setUserSession(res.data.user)
|
setUserSession(res.data.user)
|
||||||
setStoreSession(res.data.store)
|
setStoreSession(res.data.store)
|
||||||
@ -277,7 +292,8 @@ const onWxGetPhoneNumberBoss = async (e) => {
|
|||||||
if (!code) { showToast('未获取到授权,请手动输入'); return }
|
if (!code) { showToast('未获取到授权,请手动输入'); return }
|
||||||
loginLoading.value = true
|
loginLoading.value = true
|
||||||
try {
|
try {
|
||||||
const res = await wxPhoneLogin(code)
|
const loginCode = await fetchWxLoginCode()
|
||||||
|
const res = await wxPhoneLogin(code, loginCode)
|
||||||
const phone = getWxLoginPhone(res)
|
const phone = getWxLoginPhone(res)
|
||||||
if (phone) {
|
if (phone) {
|
||||||
bossForm.phone = phone
|
bossForm.phone = phone
|
||||||
@ -302,7 +318,8 @@ const onWxGetPhoneNumberStaff = async (e) => {
|
|||||||
if (!code) { showToast('未获取到授权,请手动输入'); return }
|
if (!code) { showToast('未获取到授权,请手动输入'); return }
|
||||||
loginLoading.value = true
|
loginLoading.value = true
|
||||||
try {
|
try {
|
||||||
const res = await wxPhoneLogin(code)
|
const loginCode = await fetchWxLoginCode()
|
||||||
|
const res = await wxPhoneLogin(code, loginCode)
|
||||||
const phone = getWxLoginPhone(res)
|
const phone = getWxLoginPhone(res)
|
||||||
if (phone) {
|
if (phone) {
|
||||||
staffForm.phone = phone
|
staffForm.phone = phone
|
||||||
|
|||||||
@ -37,16 +37,17 @@
|
|||||||
</view>
|
</view>
|
||||||
|
|
||||||
<!-- 列表 -->
|
<!-- 列表 -->
|
||||||
<view v-if="loading" class="state-wrap">
|
<AppPageState
|
||||||
<view class="spinner"></view>
|
class="leads-state"
|
||||||
<text class="state-text">加载中…</text>
|
:loading="loading"
|
||||||
</view>
|
:error="loadError"
|
||||||
|
:empty="!loading && !loadError && list.length === 0"
|
||||||
<view v-else-if="list.length === 0" class="empty">
|
:empty-title="emptyTitle"
|
||||||
<text>{{ tab === 'pending' ? '暂无待回访,今天可以喘口气' : '还没有宠主留资' }}</text>
|
:empty-hint="emptyHint"
|
||||||
</view>
|
empty-emoji="📝"
|
||||||
|
@retry="loadList"
|
||||||
<view v-else class="list">
|
>
|
||||||
|
<view class="list">
|
||||||
<view
|
<view
|
||||||
v-for="item in list"
|
v-for="item in list"
|
||||||
:key="item.id"
|
:key="item.id"
|
||||||
@ -75,6 +76,14 @@
|
|||||||
<text class="row-label">手机号</text>
|
<text class="row-label">手机号</text>
|
||||||
<text class="row-value phone">{{ item.phone }}</text>
|
<text class="row-value phone">{{ item.phone }}</text>
|
||||||
</view>
|
</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">
|
<view class="lead-row">
|
||||||
<text class="row-label">留资时间</text>
|
<text class="row-label">留资时间</text>
|
||||||
<text class="row-value">{{ formatDateTime(item.createTime) }}</text>
|
<text class="row-value">{{ formatDateTime(item.createTime) }}</text>
|
||||||
@ -90,7 +99,8 @@
|
|||||||
</button>
|
</button>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
|
</AppPageState>
|
||||||
</view>
|
</view>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@ -98,6 +108,8 @@
|
|||||||
import { ref, computed, onMounted } from 'vue'
|
import { ref, computed, onMounted } from 'vue'
|
||||||
import { onShow } from '@dcloudio/uni-app'
|
import { onShow } from '@dcloudio/uni-app'
|
||||||
import AppIcon from '../../components/AppIcon.vue'
|
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 { getReportLeads } from '../../api/index.js'
|
||||||
import { getStoreSession, getUserSession } from '../../utils/session.js'
|
import { getStoreSession, getUserSession } from '../../utils/session.js'
|
||||||
import { formatDateTimeYMDHM } from '../../utils/datetime.js'
|
import { formatDateTimeYMDHM } from '../../utils/datetime.js'
|
||||||
@ -107,7 +119,15 @@ const storeInfo = getStoreSession()
|
|||||||
|
|
||||||
const tab = ref('pending')
|
const tab = ref('pending')
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
|
const loadError = ref(null)
|
||||||
const list = ref([])
|
const list = ref([])
|
||||||
|
|
||||||
|
const emptyTitle = computed(() =>
|
||||||
|
tab.value === 'pending' ? '暂无待回访' : '还没有宠主留资'
|
||||||
|
)
|
||||||
|
const emptyHint = computed(() =>
|
||||||
|
tab.value === 'pending' ? '今天可以喘口气,或切换「全部留资」' : '宠主在报告页留资后将出现在这里'
|
||||||
|
)
|
||||||
const pendingCount = ref(0)
|
const pendingCount = ref(0)
|
||||||
|
|
||||||
const navSafeStyle = (() => {
|
const navSafeStyle = (() => {
|
||||||
@ -129,6 +149,7 @@ const switchTab = (t) => {
|
|||||||
|
|
||||||
const loadList = async () => {
|
const loadList = async () => {
|
||||||
if (!storeInfo.id) return
|
if (!storeInfo.id) return
|
||||||
|
loadError.value = null
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
const res = await getReportLeads(storeInfo.id, tab.value)
|
const res = await getReportLeads(storeInfo.id, tab.value)
|
||||||
@ -137,7 +158,11 @@ const loadList = async () => {
|
|||||||
if (tab.value === 'pending') {
|
if (tab.value === 'pending') {
|
||||||
pendingCount.value = list.value.length
|
pendingCount.value = list.value.length
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
loadError.value = messageFromApi(res, '加载失败')
|
||||||
}
|
}
|
||||||
|
} catch (e) {
|
||||||
|
loadError.value = NETWORK_ERROR_MSG
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false
|
loading.value = false
|
||||||
}
|
}
|
||||||
@ -306,20 +331,6 @@ onShow(() => {
|
|||||||
color: #16a34a;
|
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; }
|
.list { padding: 0 16px; }
|
||||||
|
|
||||||
.lead-card {
|
.lead-card {
|
||||||
|
|||||||
@ -10,7 +10,7 @@
|
|||||||
<view class="page-section orders-hero">
|
<view class="page-section orders-hero">
|
||||||
<view class="hero-title">服务进度一目了然</view>
|
<view class="hero-title">服务进度一目了然</view>
|
||||||
<view v-if="userInfo.role === 'customer'" class="hero-sub">查看您的预约状态,随时掌握服务进度。</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>
|
</view>
|
||||||
|
|
||||||
<!-- 状态 Tab -->
|
<!-- 状态 Tab -->
|
||||||
@ -25,43 +25,53 @@
|
|||||||
|
|
||||||
<!-- 订单列表 -->
|
<!-- 订单列表 -->
|
||||||
<view class="page-section section-gap">
|
<view class="page-section section-gap">
|
||||||
<div v-for="item in filteredOrders" :key="item.id" class="order-item" @click="goAppointmentDetail(item)">
|
<AppPageState
|
||||||
<div class="order-head">
|
:loading="loading"
|
||||||
<div class="order-title">{{ item.title }}</div>
|
:error="loadError"
|
||||||
<view :class="`van-tag van-tag--${statusTagBg(item.status)}`">{{ item.statusText }}</view>
|
:empty="!loading && !loadError && filteredOrders.length === 0"
|
||||||
</div>
|
empty-title="暂无符合条件的订单"
|
||||||
<div class="order-desc">
|
empty-hint="切换上方状态或返回首页查看"
|
||||||
<span class="desc-icon"><AppIcon name="profile" :size="12" /></span>
|
empty-emoji="📋"
|
||||||
<span>{{ item.desc }}</span>
|
@retry="fetchOrders"
|
||||||
</div>
|
>
|
||||||
<div class="order-footer">
|
<div v-for="item in filteredOrders" :key="item.id" class="order-item" @click="goAppointmentDetail(item)">
|
||||||
<span class="order-time">
|
<div class="order-head">
|
||||||
<AppIcon name="orders" :size="12" color="#94a3b8" />
|
<div class="order-title">{{ item.title }}</div>
|
||||||
<text>{{ item.time }}</text>
|
<view :class="`van-tag van-tag--${statusTagBg(item.status)}`">{{ item.statusText }}</view>
|
||||||
</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>
|
</div>
|
||||||
<button v-else-if="item.status === 'doing'" class="van-button van-button--small btn-mt" @click.stop="goReport(item)">填写报告</button>
|
<div class="order-desc">
|
||||||
</div>
|
<span class="desc-icon"><AppIcon name="profile" :size="12" /></span>
|
||||||
<div v-else>
|
<span>{{ item.desc }}</span>
|
||||||
<div v-if="item.status === 'new'" class="action-btns">
|
</div>
|
||||||
<button class="van-button van-button--small" @click.stop="cancelService(item)">取消预约</button>
|
<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>
|
</div>
|
||||||
</div>
|
</AppPageState>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<view v-if="filteredOrders.length === 0" class="empty"><text>暂无数据</text></view>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, computed, onMounted } from 'vue'
|
import { ref, computed, onMounted } from 'vue'
|
||||||
import AppIcon from '../../components/AppIcon.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 { navigateTo } from '../../utils/globalState.js'
|
||||||
import { getAppointmentList, startAppointment, cancelAppointment } from '../../api/index.js'
|
import { getAppointmentList, startAppointment, cancelAppointment } from '../../api/index.js'
|
||||||
import { getUserSession } from '../../utils/session.js'
|
import { getUserSession } from '../../utils/session.js'
|
||||||
@ -93,9 +103,11 @@ const navSafeStyle = (() => {
|
|||||||
|
|
||||||
const currentStatus = ref('new')
|
const currentStatus = ref('new')
|
||||||
const orders = ref([])
|
const orders = ref([])
|
||||||
|
const loading = ref(true)
|
||||||
|
const loadError = ref(null)
|
||||||
|
|
||||||
const statusTabs = [
|
const statusTabs = [
|
||||||
{ title: '待确认', name: 'new' },
|
{ title: '待开始', name: 'new' },
|
||||||
{ title: '进行中', name: 'doing' },
|
{ title: '进行中', name: 'doing' },
|
||||||
{ title: '已完成', name: 'done' }
|
{ title: '已完成', name: 'done' }
|
||||||
]
|
]
|
||||||
@ -112,21 +124,34 @@ const statusTagBg = (status) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const fetchOrders = async () => {
|
const fetchOrders = async () => {
|
||||||
if (!currentUserId) return
|
if (!currentUserId) {
|
||||||
const res = await getAppointmentList(currentUserId, null, { page: 1, pageSize: 50 })
|
loading.value = false
|
||||||
if (res.code === 200) {
|
return
|
||||||
orders.value = res.data.map(appt => ({
|
}
|
||||||
id: appt.id,
|
loadError.value = null
|
||||||
title: appt.serviceType || '洗澡美容预约',
|
loading.value = true
|
||||||
desc: `${appt.petType || ''} - ${appt.petName || ''}`,
|
try {
|
||||||
time: formatDateTimeCN(appt.appointmentTime),
|
const res = await getAppointmentList(currentUserId, null, { page: 1, pageSize: 50 })
|
||||||
status: appt.status || 'new',
|
if (res && res.code === 200) {
|
||||||
statusText: getAppointmentStatusText(appt.status),
|
orders.value = res.data.map((appt) => ({
|
||||||
petName: appt.petName,
|
id: appt.id,
|
||||||
petType: appt.petType,
|
title: appt.serviceType || '洗澡美容预约',
|
||||||
serviceType: appt.serviceType,
|
desc: `${appt.petType || ''} - ${appt.petName || ''}`,
|
||||||
appointmentTime: appt.appointmentTime
|
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>
|
<text class="hero-sub">{{ heroSub }}</text>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<view v-if="loading" class="loading-hint"><text>加载中…</text></view>
|
<AppPageState
|
||||||
|
:loading="loading"
|
||||||
<view v-else class="pet-list">
|
: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">
|
<template v-for="p in petList" :key="p.id">
|
||||||
<view
|
<view
|
||||||
v-if="isCustomer"
|
v-if="isCustomer"
|
||||||
@ -37,6 +44,7 @@
|
|||||||
<text class="pet-name">{{ p.name }}</text>
|
<text class="pet-name">{{ p.name }}</text>
|
||||||
<text class="pet-meta">{{ p.petType }}{{ p.breed ? ' · ' + p.breed : '' }}</text>
|
<text class="pet-meta">{{ p.petType }}{{ p.breed ? ' · ' + p.breed : '' }}</text>
|
||||||
<text v-if="p.remark" class="pet-remark">{{ p.remark }}</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>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
@ -51,13 +59,13 @@
|
|||||||
<text class="pet-name">{{ p.name }}</text>
|
<text class="pet-name">{{ p.name }}</text>
|
||||||
<text class="pet-meta">{{ p.petType }}{{ p.breed ? ' · ' + p.breed : '' }}</text>
|
<text class="pet-meta">{{ p.petType }}{{ p.breed ? ' · ' + p.breed : '' }}</text>
|
||||||
<text v-if="p.remark" class="pet-remark">{{ p.remark }}</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>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
</template>
|
</template>
|
||||||
</view>
|
</view>
|
||||||
|
</AppPageState>
|
||||||
<view v-if="!loading && petList.length === 0" class="empty"><text>暂无宠物档案</text></view>
|
|
||||||
|
|
||||||
<view
|
<view
|
||||||
v-if="isCustomer"
|
v-if="isCustomer"
|
||||||
@ -117,6 +125,8 @@
|
|||||||
import { ref, computed, onMounted } from 'vue'
|
import { ref, computed, onMounted } from 'vue'
|
||||||
import { onShow } from '@dcloudio/uni-app'
|
import { onShow } from '@dcloudio/uni-app'
|
||||||
import AppIcon from '../../components/AppIcon.vue'
|
import AppIcon from '../../components/AppIcon.vue'
|
||||||
|
import AppPageState from '../../components/AppPageState.vue'
|
||||||
|
import { messageFromApi, NETWORK_ERROR_MSG } from '../../utils/apiResult.js'
|
||||||
import {
|
import {
|
||||||
getPetList,
|
getPetList,
|
||||||
createPet,
|
createPet,
|
||||||
@ -149,6 +159,7 @@ const navSafeStyle = (() => {
|
|||||||
})()
|
})()
|
||||||
|
|
||||||
const loading = ref(true)
|
const loading = ref(true)
|
||||||
|
const loadError = ref(null)
|
||||||
const petList = ref([])
|
const petList = ref([])
|
||||||
const swipedId = ref(null)
|
const swipedId = ref(null)
|
||||||
const touchStartX = ref(0)
|
const touchStartX = ref(0)
|
||||||
@ -173,20 +184,30 @@ const petTypes = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
const loadPets = async () => {
|
const loadPets = async () => {
|
||||||
|
loadError.value = null
|
||||||
loading.value = true
|
loading.value = true
|
||||||
let res
|
try {
|
||||||
if (isCustomer.value) {
|
let res
|
||||||
res = await getPetList({ ownerUserId: userInfo.id })
|
if (isCustomer.value) {
|
||||||
} else {
|
res = await getPetList({ ownerUserId: userInfo.id })
|
||||||
if (!storeInfo?.id) {
|
} else {
|
||||||
petList.value = []
|
if (!storeInfo?.id) {
|
||||||
loading.value = false
|
petList.value = []
|
||||||
return
|
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 = () => {
|
const goBack = () => {
|
||||||
@ -195,6 +216,12 @@ const goBack = () => {
|
|||||||
else navigateTo('mine')
|
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 = () => {
|
const openCreate = () => {
|
||||||
editingId.value = null
|
editingId.value = null
|
||||||
form.value = { name: '', petType: '', breed: '', remark: '', avatar: '', avatarDisplay: '' }
|
form.value = { name: '', petType: '', breed: '', remark: '', avatar: '', avatarDisplay: '' }
|
||||||
@ -393,7 +420,6 @@ onShow(() => loadPets())
|
|||||||
line-height: 1.45;
|
line-height: 1.45;
|
||||||
}
|
}
|
||||||
|
|
||||||
.loading-hint { text-align: center; padding: 24px; color: #94a3b8; font-size: 14px; }
|
|
||||||
|
|
||||||
.pet-list { padding: 0 16px 80px; }
|
.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-name { font-size: 16px; font-weight: 700; color: #1a1a1a; display: block; }
|
||||||
.pet-meta { font-size: 12px; color: #999993; display: block; margin-top: 4px; }
|
.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-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 {
|
.fab-pet {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
|
|||||||
@ -9,41 +9,49 @@
|
|||||||
|
|
||||||
<view class="page-section reports-hero">
|
<view class="page-section reports-hero">
|
||||||
<view class="hero-title">服务成果回顾</view>
|
<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>
|
</view>
|
||||||
|
|
||||||
<!-- 网格相册 -->
|
<AppPageState
|
||||||
<div v-if="reportList.length > 0" class="page-section section-gap gallery-grid">
|
class="reports-state"
|
||||||
<div
|
:loading="loading"
|
||||||
v-for="r in reportList"
|
:error="loadError"
|
||||||
:key="r.id"
|
:empty="!loading && !loadError && reportList.length === 0"
|
||||||
class="gallery-item"
|
empty-title="暂无报告"
|
||||||
@click="viewReport(r)"
|
empty-hint="完成服务并填写报告后将显示在这里"
|
||||||
>
|
empty-emoji="📄"
|
||||||
<div class="gallery-cover">
|
@retry="loadReports"
|
||||||
<img
|
>
|
||||||
v-if="r.beforePhotos && r.beforePhotos.length > 0"
|
<div class="page-section section-gap gallery-grid">
|
||||||
:src="imgUrl(r.beforePhotos[0])"
|
<div
|
||||||
class="cover-img"
|
v-for="r in reportList"
|
||||||
/>
|
:key="r.id"
|
||||||
<div v-else class="cover-placeholder">
|
class="gallery-item"
|
||||||
<span class="placeholder-icon"><AppIcon name="camera" :size="18" color="#94a3b8" /></span>
|
@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>
|
||||||
<div class="gallery-overlay">
|
<div class="gallery-meta">
|
||||||
<div class="overlay-name">{{ r.petName }}</div>
|
<span class="meta-chip">
|
||||||
<div class="overlay-service">{{ r.serviceType }}</div>
|
<AppIcon name="report" :size="11" color="#64748b" />
|
||||||
|
<text>查看报告</text>
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="gallery-meta">
|
|
||||||
<span class="meta-chip">
|
|
||||||
<AppIcon name="report" :size="11" color="#64748b" />
|
|
||||||
<text>查看报告</text>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</AppPageState>
|
||||||
|
|
||||||
<view v-if="!loading && reportList.length === 0" class="empty"><text>暂无报告</text></view>
|
|
||||||
|
|
||||||
<!-- 仅商家/员工可新建报告;客户仅查看列表 -->
|
<!-- 仅商家/员工可新建报告;客户仅查看列表 -->
|
||||||
<view
|
<view
|
||||||
@ -63,6 +71,8 @@
|
|||||||
import { ref, onMounted } from 'vue'
|
import { ref, onMounted } from 'vue'
|
||||||
import { onShow } from '@dcloudio/uni-app'
|
import { onShow } from '@dcloudio/uni-app'
|
||||||
import AppIcon from '../../components/AppIcon.vue'
|
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 TabBar from '../../components/TabBar.vue'
|
||||||
import { useNavigator } from '../../composables/useNavigator.js'
|
import { useNavigator } from '../../composables/useNavigator.js'
|
||||||
import { getReportList, imgUrl, API_ORIGIN } from '../../api/index.js'
|
import { getReportList, imgUrl, API_ORIGIN } from '../../api/index.js'
|
||||||
@ -83,17 +93,28 @@ const navSafeStyle = (() => {
|
|||||||
}
|
}
|
||||||
return `padding-top:${statusBarHeight}px;height:${navHeight}px;`
|
return `padding-top:${statusBarHeight}px;height:${navHeight}px;`
|
||||||
})()
|
})()
|
||||||
const loading = ref(false)
|
const loading = ref(true)
|
||||||
|
const loadError = ref(null)
|
||||||
const reportList = ref([])
|
const reportList = ref([])
|
||||||
|
|
||||||
const loadReports = async () => {
|
const loadReports = async () => {
|
||||||
|
loadError.value = null
|
||||||
loading.value = true
|
loading.value = true
|
||||||
const params = userInfo.role === 'boss'
|
try {
|
||||||
? { storeId: storeInfo.id, page: 1, pageSize: 50 }
|
const params = userInfo.role === 'boss'
|
||||||
: { userId: userInfo.id, page: 1, pageSize: 50 }
|
? { storeId: storeInfo.id, page: 1, pageSize: 50 }
|
||||||
const res = await getReportList(params)
|
: { userId: userInfo.id, page: 1, pageSize: 50 }
|
||||||
loading.value = false
|
const res = await getReportList(params)
|
||||||
if (res.code === 200) reportList.value = res.data
|
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 = () => {
|
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>
|
<template>
|
||||||
<view class="report-view">
|
<view class="report-view">
|
||||||
<view v-if="loading" class="state-wrap">
|
<AppPageState
|
||||||
<view class="state-spinner"></view>
|
class="report-top-state"
|
||||||
<text class="state-text">加载中</text>
|
:loading="loading"
|
||||||
</view>
|
:error="loadError"
|
||||||
|
:empty="!loading && !loadError && notFound"
|
||||||
<view v-else-if="notFound" class="state-wrap">
|
empty-title="报告不存在或链接已失效"
|
||||||
<text class="state-emoji">🐾</text>
|
empty-hint="请向门店索取最新分享链接"
|
||||||
<text class="state-text">报告不存在或链接已失效</text>
|
empty-emoji="🐾"
|
||||||
</view>
|
:show-retry="!!loadError"
|
||||||
|
@retry="loadReport"
|
||||||
<view v-else-if="reportData" class="report-content">
|
>
|
||||||
<!-- 顶部品牌 -->
|
<view v-if="reportData" class="report-content">
|
||||||
|
<!-- 顶部品牌(与 H5 对齐:Logo + 店名 + 服务时间) -->
|
||||||
<view class="brand-bar nav-gradient" :style="brandBarSafe">
|
<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">
|
<view class="brand-home" @click="goBackOrHome">
|
||||||
<AppIcon name="back" :size="16" color="#ffffff" />
|
<AppIcon name="back" :size="16" color="#ffffff" />
|
||||||
</view>
|
</view>
|
||||||
<text class="brand-name">{{ reportData.store?.name || '宠伴生活馆' }}</text>
|
<view class="brand-main">
|
||||||
<view style="width:32px"></view>
|
<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>
|
</view>
|
||||||
|
|
||||||
<!-- 主视觉:照片对比 -->
|
<!-- 技师情感卡片(与 H5 对齐) -->
|
||||||
<view class="hero-stage">
|
<view v-if="reportData.staffName || staffAvatarDisplay" class="staff-card-mp">
|
||||||
<view class="hero-photos">
|
<image
|
||||||
<view class="hero-before">
|
v-if="staffAvatarDisplay"
|
||||||
<image
|
class="staff-avatar-mp"
|
||||||
v-if="beforePhotos.length > 0"
|
:src="staffAvatarDisplay"
|
||||||
:src="imgUrl(beforePhotos[0])"
|
mode="aspectFill"
|
||||||
class="hero-img"
|
/>
|
||||||
mode="aspectFill"
|
<view v-else class="staff-avatar-mp staff-avatar-mp--ph">
|
||||||
@click="previewPhotos('before', 0)"
|
<text class="staff-avatar-letter-mp">{{ staffInitial }}</text>
|
||||||
/>
|
|
||||||
<view v-else class="hero-empty">
|
|
||||||
<text class="hero-empty-text">暂无</text>
|
|
||||||
</view>
|
|
||||||
<view v-if="beforePhotos.length > 0" class="hero-seq hero-seq-before">
|
|
||||||
<text>1 / {{ beforePhotos.length }}</text>
|
|
||||||
</view>
|
|
||||||
<view class="hero-tag tag-before">Before</view>
|
|
||||||
</view>
|
|
||||||
<view class="hero-after">
|
|
||||||
<image
|
|
||||||
v-if="afterPhotos.length > 0"
|
|
||||||
:src="imgUrl(afterPhotos[0])"
|
|
||||||
class="hero-img"
|
|
||||||
mode="aspectFill"
|
|
||||||
@click="previewPhotos('after', 0)"
|
|
||||||
/>
|
|
||||||
<view v-else class="hero-empty">
|
|
||||||
<text class="hero-empty-text">暂无</text>
|
|
||||||
</view>
|
|
||||||
<view v-if="afterPhotos.length > 0" class="hero-seq hero-seq-after">
|
|
||||||
<text>1 / {{ afterPhotos.length }}</text>
|
|
||||||
</view>
|
|
||||||
<view class="hero-tag tag-after">After</view>
|
|
||||||
</view>
|
|
||||||
</view>
|
</view>
|
||||||
<view class="hero-divider">
|
<view class="staff-copy-mp">
|
||||||
<text class="hero-divider-text">前后对比</text>
|
<text class="staff-sign-mp">本次由 {{ reportData.staffName || '技师' }} 为您服务</text>
|
||||||
|
<text class="staff-subline-mp">感谢信任,愿毛孩子健康可爱每一天</text>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<view class="hero-meta">
|
<!-- 服务信息(不含技师行) -->
|
||||||
<view class="hero-meta-chip hero-meta-chip-before">
|
<view class="info-card">
|
||||||
<text>服务前 {{ beforePhotos.length }} 张</text>
|
<view class="info-title">服务详情</view>
|
||||||
|
<view class="info-row">
|
||||||
|
<text class="info-key">宠物</text>
|
||||||
|
<text class="info-val">{{ reportData.petName }}</text>
|
||||||
</view>
|
</view>
|
||||||
<view class="hero-meta-chip hero-meta-chip-after">
|
<view class="info-row">
|
||||||
<text>服务后 {{ afterPhotos.length }} 张</text>
|
<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>
|
</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>
|
||||||
<view v-if="beforePhotos.length > 0 || afterPhotos.length > 0" class="hero-actions">
|
|
||||||
<view
|
<!-- 过程素材九宫格(与 H5 对齐) -->
|
||||||
v-if="beforePhotos.length > 0"
|
<view v-if="duringListForGrid.length" class="during-grid-wrap">
|
||||||
class="hero-action-chip hero-action-chip-before"
|
<DuringMediaGrid :items="duringListForGrid" />
|
||||||
@click="previewPhotos('before', 0)"
|
|
||||||
>
|
|
||||||
<text>查看服务前全部照片</text>
|
|
||||||
</view>
|
|
||||||
<view
|
|
||||||
v-if="afterPhotos.length > 0"
|
|
||||||
class="hero-action-chip hero-action-chip-after"
|
|
||||||
@click="previewPhotos('after', 0)"
|
|
||||||
>
|
|
||||||
<text>查看服务后全部照片</text>
|
|
||||||
</view>
|
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<!-- 补充照片:前后分组,不混排 -->
|
<!-- 补充照片:前后分组,不混排 -->
|
||||||
@ -142,53 +136,6 @@
|
|||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<!-- 服务过程中(照片+视频) -->
|
|
||||||
<view v-if="duringMedia.length > 0" class="section-gap">
|
|
||||||
<view class="during-section">
|
|
||||||
<view class="during-title">
|
|
||||||
<view class="during-title-dot"></view>
|
|
||||||
<text>服务过程中</text>
|
|
||||||
</view>
|
|
||||||
|
|
||||||
<!-- 照片 -->
|
|
||||||
<view v-if="duringPhotoItems.length > 0" class="during-group">
|
|
||||||
<view class="during-group-label">照片 {{ duringPhotoItems.length }} 张</view>
|
|
||||||
<view class="during-grid">
|
|
||||||
<view
|
|
||||||
v-for="(url, idx) in duringPhotoItems"
|
|
||||||
:key="'dp-' + idx"
|
|
||||||
class="during-thumb"
|
|
||||||
@click="previewPhotos('during-photo', idx)"
|
|
||||||
>
|
|
||||||
<image :src="imgUrl(url)" class="during-thumb-img" mode="aspectFill" />
|
|
||||||
</view>
|
|
||||||
</view>
|
|
||||||
</view>
|
|
||||||
|
|
||||||
<!-- 视频 -->
|
|
||||||
<view v-if="duringVideoItems.length > 0" class="during-group">
|
|
||||||
<view class="during-group-label">视频 {{ duringVideoItems.length }} 个</view>
|
|
||||||
<view class="during-grid">
|
|
||||||
<view
|
|
||||||
v-for="(url, idx) in duringVideoItems"
|
|
||||||
:key="'dv-' + idx"
|
|
||||||
class="during-thumb during-thumb--video"
|
|
||||||
@click="playVideo(imgUrl(url))"
|
|
||||||
>
|
|
||||||
<video
|
|
||||||
:src="imgUrl(url)"
|
|
||||||
class="during-thumb-img"
|
|
||||||
:show-center-play-btn="true"
|
|
||||||
:show-play-btn="true"
|
|
||||||
objectFit="cover"
|
|
||||||
:enable-progress-gesture="false"
|
|
||||||
/>
|
|
||||||
</view>
|
|
||||||
</view>
|
|
||||||
</view>
|
|
||||||
</view>
|
|
||||||
</view>
|
|
||||||
|
|
||||||
<!-- 宠主问候 -->
|
<!-- 宠主问候 -->
|
||||||
<view v-if="!isStaff" class="greeting-card">
|
<view v-if="!isStaff" class="greeting-card">
|
||||||
<text class="greeting-text">
|
<text class="greeting-text">
|
||||||
@ -196,27 +143,6 @@
|
|||||||
</text>
|
</text>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<!-- 服务信息 -->
|
|
||||||
<view class="info-card">
|
|
||||||
<view class="info-title">服务详情</view>
|
|
||||||
<view class="info-row">
|
|
||||||
<text class="info-key">宠物</text>
|
|
||||||
<text class="info-val">{{ reportData.petName }}</text>
|
|
||||||
</view>
|
|
||||||
<view class="info-row">
|
|
||||||
<text class="info-key">项目</text>
|
|
||||||
<text class="info-val">{{ reportData.serviceType }}</text>
|
|
||||||
</view>
|
|
||||||
<view class="info-row">
|
|
||||||
<text class="info-key">时间</text>
|
|
||||||
<text class="info-val">{{ formatTime(reportData.appointmentTime) }}</text>
|
|
||||||
</view>
|
|
||||||
<view 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">
|
<view v-if="reportData.remark" class="remark-card">
|
||||||
<text class="remark-label">备注</text>
|
<text class="remark-label">备注</text>
|
||||||
@ -230,7 +156,7 @@
|
|||||||
<text class="highlight-badge">智能剪辑</text>
|
<text class="highlight-badge">智能剪辑</text>
|
||||||
</view>
|
</view>
|
||||||
<text class="highlight-desc">
|
<text class="highlight-desc">
|
||||||
按「服务前 → 过程 → 服务后」顺序,将照片与短视频自动合成竖屏 9:16 成片(15 秒或 30 秒)。门店服务器需已安装 ffmpeg;若需大模型生成式成片,可在后端接入对应云 API。
|
先按「服务前 → 过程 → 服务后」整理素材,再将照片与小视频穿插编排(类相册成片),图片带轻微推拉镜头,竖屏 9:16,可选 BGM。总时长 15 秒或 30 秒;服务器需 ffmpeg(建议安装 ffprobe)。配乐请将商用授权文件配置在后端 HIGHLIGHT_BGM_PATH。
|
||||||
</text>
|
</text>
|
||||||
<view v-if="highlightStatus === 'processing'" class="highlight-status highlight-status--busy">
|
<view v-if="highlightStatus === 'processing'" class="highlight-status highlight-status--busy">
|
||||||
<text>短片生成中,请稍候…</text>
|
<text>短片生成中,请稍候…</text>
|
||||||
@ -294,9 +220,10 @@
|
|||||||
<text class="footer-sub">宠物服务 · 让爱更专业</text>
|
<text class="footer-sub">宠物服务 · 让爱更专业</text>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
|
</AppPageState>
|
||||||
|
|
||||||
<TestimonialModal
|
<TestimonialModal
|
||||||
v-if="isStaff"
|
v-if="isStaff && reportData"
|
||||||
v-model="posterModalOpen"
|
v-model="posterModalOpen"
|
||||||
:pet-name="reportData?.petName"
|
:pet-name="reportData?.petName"
|
||||||
@skip="onTestimonialSkip"
|
@skip="onTestimonialSkip"
|
||||||
@ -319,10 +246,15 @@ import { getReportByToken, imgUrl, postReportHighlightStart, submitReportTestimo
|
|||||||
import { formatDateTimeYMDHM } from '../../utils/datetime.js'
|
import { formatDateTimeYMDHM } from '../../utils/datetime.js'
|
||||||
import { drawReportPoster, POSTER_W, computePosterLogicalHeight } from '../../utils/reportPosterDraw.js'
|
import { drawReportPoster, POSTER_W, computePosterLogicalHeight } from '../../utils/reportPosterDraw.js'
|
||||||
import AppIcon from '../../components/AppIcon.vue'
|
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 TestimonialModal from '../../components/report/TestimonialModal.vue'
|
||||||
import { getUserSession, isLoggedIn } from '../../utils/session.js'
|
import { getUserSession, isLoggedIn } from '../../utils/session.js'
|
||||||
|
|
||||||
const loading = ref(true)
|
const loading = ref(true)
|
||||||
|
const loadError = ref(null)
|
||||||
const notFound = ref(false)
|
const notFound = ref(false)
|
||||||
const reportData = ref(null)
|
const reportData = ref(null)
|
||||||
const posterCanvas = 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 beforeMorePhotos = computed(() => beforePhotos.value.slice(1))
|
||||||
const afterMorePhotos = computed(() => afterPhotos.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 shareTitle = computed(() => {
|
||||||
const r = reportData.value
|
const r = reportData.value
|
||||||
if (!r) return '洗护前后对比'
|
if (!r) return '洗护前后对比'
|
||||||
@ -468,21 +427,33 @@ const brandBarSafe = (() => {
|
|||||||
const formatTime = (time) => formatDateTimeYMDHM(time)
|
const formatTime = (time) => formatDateTimeYMDHM(time)
|
||||||
|
|
||||||
const loadReport = async () => {
|
const loadReport = async () => {
|
||||||
|
loadError.value = null
|
||||||
|
notFound.value = false
|
||||||
const token = getRouteToken()
|
const token = getRouteToken()
|
||||||
if (!token) {
|
if (!token) {
|
||||||
notFound.value = true
|
notFound.value = true
|
||||||
|
reportData.value = null
|
||||||
loading.value = false
|
loading.value = false
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
uni.showLoading({ title: '加载中' })
|
loading.value = true
|
||||||
const res = await getReportByToken(token)
|
try {
|
||||||
uni.hideLoading()
|
const res = await getReportByToken(token)
|
||||||
loading.value = false
|
if (res && res.code === 200 && res.data) {
|
||||||
if (res.code === 200) {
|
reportData.value = res.data
|
||||||
reportData.value = res.data
|
maybeStartHighlightPoll()
|
||||||
maybeStartHighlightPoll()
|
} else if (res && res.code === 404) {
|
||||||
} else {
|
reportData.value = null
|
||||||
notFound.value = true
|
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(() => {
|
onShareAppMessage(() => {
|
||||||
const q = routeToken.value ? `token=${encodeURIComponent(routeToken.value)}` : ''
|
const q = routeToken.value ? `token=${encodeURIComponent(routeToken.value)}` : ''
|
||||||
return {
|
return {
|
||||||
@ -840,6 +804,8 @@ onUnmounted(() => stopHighlightPoll())
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
/* 小程序 WXSS 禁止 @import 外链字体;手写感用系统楷体栈,H5 可在页面级引入马善政体 */
|
||||||
|
|
||||||
.report-view {
|
.report-view {
|
||||||
background: #fafaf8;
|
background: #fafaf8;
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
@ -853,25 +819,7 @@ onUnmounted(() => stopHighlightPoll())
|
|||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 加载/空状态 */
|
.report-top-state { min-height: 48vh; }
|
||||||
.state-wrap {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
min-height: 100vh;
|
|
||||||
gap: 12px;
|
|
||||||
}
|
|
||||||
.state-spinner {
|
|
||||||
width: 28px; height: 28px;
|
|
||||||
border: 2.5px solid #ebebea;
|
|
||||||
border-top-color: #2db96d;
|
|
||||||
border-radius: 50%;
|
|
||||||
animation: spin 0.7s linear infinite;
|
|
||||||
}
|
|
||||||
@keyframes spin { to { transform: rotate(360deg); } }
|
|
||||||
.state-text { font-size: 14px; color: #999993; }
|
|
||||||
.state-emoji { font-size: 36px; }
|
|
||||||
|
|
||||||
/* 内容 */
|
/* 内容 */
|
||||||
.report-content {
|
.report-content {
|
||||||
@ -881,7 +829,7 @@ onUnmounted(() => stopHighlightPoll())
|
|||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 品牌栏 */
|
/* 品牌栏(含 Logo,与 H5 对齐) */
|
||||||
.brand-bar {
|
.brand-bar {
|
||||||
background: #2db96d;
|
background: #2db96d;
|
||||||
padding: 12px 16px 14px;
|
padding: 12px 16px 14px;
|
||||||
@ -893,6 +841,9 @@ onUnmounted(() => stopHighlightPoll())
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
}
|
}
|
||||||
|
.brand-bar-inner--row {
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
.brand-home {
|
.brand-home {
|
||||||
width: 32px; height: 32px;
|
width: 32px; height: 32px;
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
@ -900,178 +851,131 @@ onUnmounted(() => stopHighlightPoll())
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: 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-size: 16px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: #fff;
|
color: #fff;
|
||||||
letter-spacing: 0.5px;
|
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 {
|
.staff-card-mp {
|
||||||
position: relative;
|
|
||||||
margin: 12px 16px 0;
|
margin: 12px 16px 0;
|
||||||
border-radius: 18px;
|
padding: 14px 14px;
|
||||||
overflow: hidden;
|
display: flex;
|
||||||
background: #f5f5f2;
|
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);
|
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.04);
|
||||||
}
|
}
|
||||||
.hero-photos {
|
.compare-h2 {
|
||||||
display: flex;
|
|
||||||
height: 280px;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
.hero-before, .hero-after {
|
|
||||||
flex: 1;
|
|
||||||
position: relative;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
.hero-before { border-right: 1.5px solid #fff; }
|
|
||||||
.hero-after { border-left: 1.5px solid #fff; }
|
|
||||||
.hero-img {
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
display: block;
|
display: block;
|
||||||
}
|
font-size: 15px;
|
||||||
.hero-empty {
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
background: #f0f0ee;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
.hero-empty-text {
|
|
||||||
font-size: 13px;
|
|
||||||
color: #bbbbb5;
|
|
||||||
}
|
|
||||||
.hero-seq {
|
|
||||||
position: absolute;
|
|
||||||
top: 10px;
|
|
||||||
right: 10px;
|
|
||||||
min-width: 44px;
|
|
||||||
height: 24px;
|
|
||||||
padding: 0 8px;
|
|
||||||
border-radius: 999px;
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
font-size: 11px;
|
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
z-index: 2;
|
color: #1a1a1a;
|
||||||
|
margin-bottom: 10px;
|
||||||
}
|
}
|
||||||
.hero-seq-before {
|
.compare-more-hint {
|
||||||
background: rgba(245, 145, 62, 0.9);
|
margin-top: 10px;
|
||||||
color: #fff;
|
|
||||||
}
|
|
||||||
.hero-seq-after {
|
|
||||||
background: rgba(45, 185, 109, 0.9);
|
|
||||||
color: #fff;
|
|
||||||
}
|
|
||||||
.hero-tag {
|
|
||||||
position: absolute;
|
|
||||||
bottom: 10px;
|
|
||||||
left: 10px;
|
|
||||||
padding: 3px 10px;
|
|
||||||
border-radius: 6px;
|
|
||||||
font-size: 11px;
|
|
||||||
font-weight: 700;
|
|
||||||
letter-spacing: 0.5px;
|
|
||||||
text-transform: uppercase;
|
|
||||||
}
|
|
||||||
.tag-before {
|
|
||||||
background: rgba(245, 145, 62, 0.9);
|
|
||||||
color: #fff;
|
|
||||||
}
|
|
||||||
.tag-after {
|
|
||||||
background: rgba(45, 185, 109, 0.9);
|
|
||||||
color: #fff;
|
|
||||||
}
|
|
||||||
.hero-divider {
|
|
||||||
position: absolute;
|
|
||||||
left: 50%;
|
|
||||||
top: 50%;
|
|
||||||
transform: translate(-50%, -50%);
|
|
||||||
z-index: 3;
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
.hero-divider-text {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
min-width: 58px;
|
|
||||||
height: 28px;
|
|
||||||
padding: 0 12px;
|
|
||||||
border-radius: 999px;
|
|
||||||
background: rgba(26, 26, 26, 0.82);
|
|
||||||
color: #fff;
|
|
||||||
font-size: 11px;
|
|
||||||
font-weight: 700;
|
|
||||||
letter-spacing: 0.5px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 照片信息与补充分组 */
|
|
||||||
.hero-meta {
|
|
||||||
display: flex;
|
|
||||||
gap: 8px;
|
|
||||||
padding: 10px 16px 0;
|
|
||||||
}
|
|
||||||
.hero-tip {
|
|
||||||
padding: 8px 16px 0;
|
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
.hero-tip text {
|
.compare-more-hint text {
|
||||||
font-size: 12px;
|
font-size: 11px;
|
||||||
color: #bbbbb5;
|
color: #bbbbb5;
|
||||||
letter-spacing: 0.2px;
|
|
||||||
}
|
}
|
||||||
.hero-actions {
|
|
||||||
display: flex;
|
.during-grid-wrap {
|
||||||
gap: 8px;
|
margin: 12px 16px 0;
|
||||||
padding: 10px 16px 0;
|
padding: 12px 10px 10px;
|
||||||
}
|
background: #fff;
|
||||||
.hero-action-chip {
|
border-radius: 14px;
|
||||||
flex: 1;
|
border: 1px solid #f0f0ee;
|
||||||
min-width: 0;
|
|
||||||
height: 40px;
|
|
||||||
border-radius: 12px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
font-size: 12px;
|
|
||||||
font-weight: 700;
|
|
||||||
letter-spacing: 0.2px;
|
|
||||||
}
|
|
||||||
.hero-action-chip-before {
|
|
||||||
background: #fff7f0;
|
|
||||||
color: #f5913e;
|
|
||||||
border: 1px solid rgba(245, 145, 62, 0.18);
|
|
||||||
}
|
|
||||||
.hero-action-chip-after {
|
|
||||||
background: #f3fbf6;
|
|
||||||
color: #2db96d;
|
|
||||||
border: 1px solid rgba(45, 185, 109, 0.18);
|
|
||||||
}
|
|
||||||
.hero-meta-chip {
|
|
||||||
flex: 1;
|
|
||||||
min-width: 0;
|
|
||||||
height: 36px;
|
|
||||||
border-radius: 10px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
font-size: 12px;
|
|
||||||
font-weight: 700;
|
|
||||||
}
|
|
||||||
.hero-meta-chip-before {
|
|
||||||
background: #fef5ec;
|
|
||||||
color: #f5913e;
|
|
||||||
}
|
|
||||||
.hero-meta-chip-after {
|
|
||||||
background: #eaf8f0;
|
|
||||||
color: #2db96d;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.photo-groups {
|
.photo-groups {
|
||||||
padding: 12px 16px 0;
|
padding: 12px 16px 0;
|
||||||
display: flex;
|
display: flex;
|
||||||
@ -1152,7 +1056,7 @@ onUnmounted(() => stopHighlightPoll())
|
|||||||
|
|
||||||
/* 信息卡 */
|
/* 信息卡 */
|
||||||
.info-card {
|
.info-card {
|
||||||
margin: 16px;
|
margin: 12px 16px 0;
|
||||||
padding: 20px;
|
padding: 20px;
|
||||||
background: #fafaf8;
|
background: #fafaf8;
|
||||||
border-radius: 14px;
|
border-radius: 14px;
|
||||||
@ -1324,54 +1228,4 @@ onUnmounted(() => stopHighlightPoll())
|
|||||||
letter-spacing: 0.5px;
|
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>
|
</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 = {
|
export const APPOINTMENT_STATUS_TEXT = {
|
||||||
new: '待确认',
|
new: '待开始',
|
||||||
doing: '服务中',
|
doing: '服务中',
|
||||||
done: '已完成',
|
done: '已完成',
|
||||||
cancel: '已取消'
|
cancel: '已取消'
|
||||||
|
|||||||
@ -1,18 +1,17 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="report-view">
|
<div class="report-view">
|
||||||
<!-- 加载中 -->
|
<AppPageState
|
||||||
<div v-if="loading" class="loading-wrap">
|
class="report-h5-state"
|
||||||
<view class="loading-spinner"></view>
|
:loading="loading"
|
||||||
<span>加载报告中...</span>
|
:error="loadError"
|
||||||
</div>
|
:empty="!loading && !loadError && notFound"
|
||||||
|
empty-title="报告不存在或链接已失效"
|
||||||
<!-- 未找到 -->
|
empty-hint="请向门店索取最新分享链接"
|
||||||
<div v-else-if="notFound" class="not-found">
|
empty-emoji="🐾"
|
||||||
<view class="empty"><text>报告不存在或链接已失效</text></view>
|
:show-retry="!!loadError"
|
||||||
</div>
|
@retry="loadReport"
|
||||||
|
>
|
||||||
<!-- 报告内容 -->
|
<div v-if="reportData" class="report-content">
|
||||||
<div v-else-if="reportData" class="report-content">
|
|
||||||
<!-- 品牌头部 -->
|
<!-- 品牌头部 -->
|
||||||
<div class="brand-header" :style="brandHeaderSafeStyle">
|
<div class="brand-header" :style="brandHeaderSafeStyle">
|
||||||
<div class="header-actions">
|
<div class="header-actions">
|
||||||
@ -21,8 +20,18 @@
|
|||||||
</view>
|
</view>
|
||||||
<view class="header-placeholder"></view>
|
<view class="header-placeholder"></view>
|
||||||
</div>
|
</div>
|
||||||
<div class="brand-logo">{{ reportData.store?.name || '宠伴生活馆' }}</div>
|
<div class="brand-row" :class="{ 'brand-row--solo': !storeLogoUrl }">
|
||||||
<div class="brand-sub">宠物服务,让爱更专业</div>
|
<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">
|
<div class="brand-contact">
|
||||||
<span v-if="reportData.store?.phone">电话:{{ reportData.store.phone }}</span>
|
<span v-if="reportData.store?.phone">电话:{{ reportData.store.phone }}</span>
|
||||||
<span v-if="reportData.store?.address">地址:{{ reportData.store.address }}</span>
|
<span v-if="reportData.store?.address">地址:{{ reportData.store.address }}</span>
|
||||||
@ -35,7 +44,24 @@
|
|||||||
<div class="report-time">{{ formatTime(reportData.appointmentTime) }}</div>
|
<div class="report-time">{{ formatTime(reportData.appointmentTime) }}</div>
|
||||||
</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-group service-info">
|
||||||
<view class="van-cell">
|
<view class="van-cell">
|
||||||
<view class="van-cell__title">宠物名字</view>
|
<view class="van-cell__title">宠物名字</view>
|
||||||
@ -49,31 +75,17 @@
|
|||||||
<view class="van-cell__title">服务时间</view>
|
<view class="van-cell__title">服务时间</view>
|
||||||
<view class="van-cell__value">{{ formatTime(reportData.appointmentTime) }}</view>
|
<view class="van-cell__value">{{ formatTime(reportData.appointmentTime) }}</view>
|
||||||
</view>
|
</view>
|
||||||
<view class="van-cell">
|
|
||||||
<view class="van-cell__title">服务技师</view>
|
|
||||||
<view class="van-cell__value">{{ reportData.staffName || '-' }}</view>
|
|
||||||
</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="section-label">服务前后对比</div>
|
||||||
<div class="photo-grid">
|
<BeforeAfterSlider :before-src="beforeMainUrl" :after-src="afterMainUrl" />
|
||||||
<image
|
</div>
|
||||||
v-if="reportData.beforePhoto"
|
|
||||||
:src="imgUrl(reportData.beforePhoto)"
|
<!-- 过程素材九宫格 -->
|
||||||
class="photo-image"
|
<div v-if="duringList.length" class="section-card section-card--during">
|
||||||
mode="aspectFill"
|
<DuringMediaGrid :items="duringList" />
|
||||||
/>
|
|
||||||
<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>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 备注 -->
|
<!-- 备注 -->
|
||||||
@ -95,6 +107,7 @@
|
|||||||
<button class="van-button van-button--primary van-button--round van-button--block" @click="openPosterModal">生成图片分享朋友圈</button>
|
<button class="van-button van-button--primary van-button--round van-button--block" @click="openPosterModal">生成图片分享朋友圈</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</AppPageState>
|
||||||
|
|
||||||
<!-- #ifdef H5 -->
|
<!-- #ifdef H5 -->
|
||||||
<TestimonialModal
|
<TestimonialModal
|
||||||
@ -105,7 +118,6 @@
|
|||||||
/>
|
/>
|
||||||
<!-- #endif -->
|
<!-- #endif -->
|
||||||
|
|
||||||
<!-- Canvas海报(隐藏,仅 H5) -->
|
|
||||||
<!-- #ifdef H5 -->
|
<!-- #ifdef H5 -->
|
||||||
<canvas ref="posterCanvas" style="position:fixed;top:-9999px;left:-9999px;" />
|
<canvas ref="posterCanvas" style="position:fixed;top:-9999px;left:-9999px;" />
|
||||||
<!-- #endif -->
|
<!-- #endif -->
|
||||||
@ -113,19 +125,24 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, onMounted } from 'vue'
|
import { ref, computed, onMounted } from 'vue'
|
||||||
import { onLoad } from '@dcloudio/uni-app'
|
import { onLoad } from '@dcloudio/uni-app'
|
||||||
import { getReportByToken, imgUrl, submitReportTestimonial } from '../api/index.js'
|
import { getReportByToken, imgUrl, submitReportTestimonial } from '../api/index.js'
|
||||||
import { drawReportPoster, POSTER_W, computePosterLogicalHeight } from '../utils/reportPosterDraw.js'
|
import { drawReportPoster, POSTER_W, computePosterLogicalHeight } from '../utils/reportPosterDraw.js'
|
||||||
import { formatDateTimeYMDHM } from '../utils/datetime.js'
|
import { formatDateTimeYMDHM } from '../utils/datetime.js'
|
||||||
import AppIcon from '../components/AppIcon.vue'
|
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 ReminderCard from '../components/report/ReminderCard.vue'
|
||||||
|
import BeforeAfterSlider from '../components/report/BeforeAfterSlider.vue'
|
||||||
|
import DuringMediaGrid from '../components/report/DuringMediaGrid.vue'
|
||||||
// #ifdef H5
|
// #ifdef H5
|
||||||
import TestimonialModal from '../components/report/TestimonialModal.vue'
|
import TestimonialModal from '../components/report/TestimonialModal.vue'
|
||||||
// #endif
|
// #endif
|
||||||
import { navigateTo } from '../utils/globalState.js'
|
import { navigateTo } from '../utils/globalState.js'
|
||||||
|
|
||||||
const loading = ref(true)
|
const loading = ref(true)
|
||||||
|
const loadError = ref(null)
|
||||||
const notFound = ref(false)
|
const notFound = ref(false)
|
||||||
const reportData = ref(null)
|
const reportData = ref(null)
|
||||||
const posterCanvas = ref(null)
|
const posterCanvas = ref(null)
|
||||||
@ -138,21 +155,84 @@ const brandHeaderSafeStyle = (() => {
|
|||||||
|
|
||||||
const formatTime = (time) => formatDateTimeYMDHM(time)
|
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 () => {
|
const loadReport = async () => {
|
||||||
|
loadError.value = null
|
||||||
|
notFound.value = false
|
||||||
let token = routeToken.value
|
let token = routeToken.value
|
||||||
// #ifdef H5
|
// #ifdef H5
|
||||||
token = new URLSearchParams(window.location.search).get('token')
|
token = new URLSearchParams(window.location.search).get('token')
|
||||||
// #endif
|
// #endif
|
||||||
if (!token) { notFound.value = true; loading.value = false; return }
|
if (!token) {
|
||||||
|
|
||||||
uni.showLoading({ title: '加载中...' })
|
|
||||||
const res = await getReportByToken(token)
|
|
||||||
uni.hideLoading()
|
|
||||||
loading.value = false
|
|
||||||
if (res.code === 200) {
|
|
||||||
reportData.value = res.data
|
|
||||||
} else {
|
|
||||||
notFound.value = true
|
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),
|
beforeUrl ? loadImage(beforeUrl) : Promise.resolve(null),
|
||||||
afterUrl ? loadImage(afterUrl) : Promise.resolve(null)
|
afterUrl ? loadImage(afterUrl) : Promise.resolve(null)
|
||||||
])
|
])
|
||||||
|
const dpr = Math.min(window.devicePixelRatio || 1, 3)
|
||||||
const posterH = computePosterLogicalHeight(false, testimonial || '')
|
const posterH = computePosterLogicalHeight(false, testimonial || '')
|
||||||
canvas.width = POSTER_W
|
canvas.width = POSTER_W * dpr
|
||||||
canvas.height = posterH
|
canvas.height = posterH * dpr
|
||||||
|
ctx.scale(dpr, dpr)
|
||||||
drawReportPoster(ctx, {
|
drawReportPoster(ctx, {
|
||||||
storeName: r.store?.name || '',
|
storeName: r.store?.name || '',
|
||||||
petName: r.petName || '',
|
petName: r.petName || '',
|
||||||
@ -246,20 +328,10 @@ onMounted(() => loadReport())
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
/* 手写标题:H5 由 report.html 引入马善政体后,可匹配 "Ma Shan Zheng";此处栈兼容无网络字体场景 */
|
||||||
|
|
||||||
.report-view { background: #f5f7fb; min-height: 100vh; }
|
.report-view { background: #f5f7fb; min-height: 100vh; }
|
||||||
.loading-wrap {
|
.report-h5-state { min-height: 50vh; }
|
||||||
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-content { max-width: 430px; margin: 0 auto; background: #f8fafc; min-height: 100vh; box-shadow: 0 10px 30px rgba(15, 23, 42, 0.06); }
|
.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 {
|
.brand-header {
|
||||||
background: linear-gradient(135deg, #22c55e 0%, #16a34a 100%);
|
background: linear-gradient(135deg, #22c55e 0%, #16a34a 100%);
|
||||||
@ -289,12 +361,79 @@ onMounted(() => loadReport())
|
|||||||
width: 30px;
|
width: 30px;
|
||||||
height: 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-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; }
|
.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-wrap { text-align: center; padding: 20px 20px 14px; }
|
||||||
.report-title { font-size: 22px; font-weight: 700; color: #333; }
|
.report-title { font-size: 22px; font-weight: 700; color: #333; }
|
||||||
.report-time { font-size: 13px; color: #999; margin-top: 6px; }
|
.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; }
|
.service-info { margin: 0 16px 12px; border-radius: 14px !important; }
|
||||||
.section-card {
|
.section-card {
|
||||||
margin: 0 16px 12px;
|
margin: 0 16px 12px;
|
||||||
@ -304,22 +443,13 @@ onMounted(() => loadReport())
|
|||||||
padding: 12px;
|
padding: 12px;
|
||||||
box-shadow: 0 6px 16px rgba(15, 23, 42, 0.04);
|
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; }
|
.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; }
|
.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; }
|
.action-section { margin: 0 16px 24px; }
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user