petstore-frontend/src/pages/report/Report.vue
2026-04-19 14:54:38 +08:00

1073 lines
32 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<template>
<view class="page-shell report-page">
<view class="report-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 style="width:32px"></view>
</view>
<view class="report-body">
<!-- 基础信息 -->
<view class="section">
<view class="section-head section-head--clickable" @click="basicInfoCollapsed = !basicInfoCollapsed">
<text class="section-label">基础信息</text>
<text class="section-toggle">{{ basicInfoCollapsed ? '展开' : '收起' }}</text>
</view>
<view v-show="!basicInfoCollapsed">
<view class="form-row">
<text class="app-form-label">宠物名字</text>
<input v-model="report.petName" class="app-form-input" placeholder="请输入宠物名字" />
</view>
<view class="form-row">
<text class="app-form-label">服务类型</text>
<picker mode="selector" :range="serviceColumns" range-key="label" @change="onServiceConfirm">
<view class="app-form-input app-form-picker">
<text :class="{ 'app-form-placeholder': !report.serviceType }">{{ report.serviceType || '请选择' }}</text>
<text class="app-form-arrow"></text>
</view>
</picker>
</view>
<view class="form-row">
<text class="app-form-label">服务时间</text>
<view class="time-row">
<picker mode="date" :value="serviceDate" @change="onServiceDateChange" class="time-picker">
<view class="app-form-input app-form-picker">
<text :class="{ 'app-form-placeholder': !serviceDate }">{{ serviceDate || '日期' }}</text>
</view>
</picker>
<picker
mode="selector"
:range="HALF_HOUR_TIME_SLOTS"
:value="halfHourServiceTimeIndex"
@change="onServiceTimeChange"
class="time-picker"
>
<view class="app-form-input app-form-picker">
<text :class="{ 'app-form-placeholder': !serviceTimePart }">{{ serviceTimePart || '时间' }}</text>
</view>
</picker>
</view>
</view>
</view>
</view>
<!-- 服务照片 — 核心区域 -->
<view class="section">
<view class="section-head">
<text class="section-label">服务前后对比</text>
<text class="section-hint">上传照片让宠主看到变化</text>
</view>
<view class="photo-compare">
<view class="photo-side">
<view class="photo-side-label">
<view class="dot dot-before"></view>
<text>服务前</text>
</view>
<view class="photo-grid">
<view v-for="(item, idx) in report.beforeMedia" :key="'b'+idx" class="photo-thumb">
<image :src="item.url" class="thumb-img" mode="aspectFill" />
<view v-if="item.uploading" class="media-mask">
<text class="media-mask-text">上传中…</text>
</view>
<view v-else-if="item.failed" class="media-mask media-mask--error" @click="retryUpload('beforeMedia', idx)">
<text class="media-mask-text">上传失败,点此重试</text>
</view>
<view class="thumb-del" @click.stop="removeMedia('beforeMedia', idx)">✕</view>
</view>
<view v-if="report.beforeMedia.length < 4" class="photo-add" @click="uploadPhoto('beforeMedia')">
<AppIcon name="camera" :size="20" color="#bbbbb5" />
</view>
</view>
</view>
<view class="photo-divider"></view>
<view class="photo-side">
<view class="photo-side-label">
<view class="dot dot-after"></view>
<text>服务后</text>
</view>
<view class="photo-grid">
<view v-for="(item, idx) in report.afterMedia" :key="'a'+idx" class="photo-thumb">
<image :src="item.url" class="thumb-img" mode="aspectFill" />
<view v-if="item.uploading" class="media-mask">
<text class="media-mask-text">上传中…</text>
</view>
<view v-else-if="item.failed" class="media-mask media-mask--error" @click="retryUpload('afterMedia', idx)">
<text class="media-mask-text">上传失败,点此重试</text>
</view>
<view class="thumb-del" @click.stop="removeMedia('afterMedia', idx)">✕</view>
</view>
<view v-if="report.afterMedia.length < 4" class="photo-add" @click="uploadPhoto('afterMedia')">
<AppIcon name="camera" :size="20" color="#bbbbb5" />
</view>
</view>
</view>
</view>
</view>
<!-- 服务过程中 -->
<view class="section">
<view class="section-head">
<text class="section-label">服务过程中</text>
<text class="section-hint">照片或视频,记录服务细节</text>
</view>
<view class="during-add-row">
<view class="during-add-btn" @click="uploadDuring()">
<AppIcon name="camera" :size="18" color="#bbbbb5" />
<text>添加照片或视频</text>
</view>
</view>
<view v-if="report.duringMedia.length > 0" class="during-grid">
<view v-for="(item, idx) in report.duringMedia" :key="'d'+idx" class="photo-thumb">
<image v-if="item.mediaType === 'photo'" :src="item.url" class="thumb-img" mode="aspectFill" />
<video
v-else
:src="item.url"
class="thumb-img"
:show-center-play-btn="false"
:show-play-btn="false"
objectFit="cover"
/>
<view v-if="item.mediaType === 'video'" class="video-badge">
<text>视频</text>
</view>
<view v-if="item.uploading" class="media-mask">
<text class="media-mask-text">上传中…</text>
</view>
<view v-else-if="item.failed" class="media-mask media-mask--error" @click="retryUpload('duringMedia', idx)">
<text class="media-mask-text">上传失败,点此重试</text>
</view>
<view class="thumb-del" @click.stop="removeMedia('duringMedia', idx)">✕</view>
</view>
</view>
</view>
<!-- 备注 -->
<view class="section">
<view class="section-head">
<text class="section-label">备注</text>
</view>
<textarea
v-model="report.remark"
class="app-form-textarea"
placeholder="服务细节、注意事项(可选)"
@focus="onRemarkFocus"
@blur="onRemarkBlur"
/>
</view>
</view>
<view
class="submit-fixed"
:class="{ 'submit-fixed--scrolled': hasScrolled }"
:style="submitFixedStyle"
>
<view class="report-lock-hint">
<text>提交后不可修改,请确认照片与备注无误。</text>
</view>
<button class="app-btn-main app-btn-primary submit-btn--fixed" :disabled="submitting" @click="submitReport">
{{ submitting ? '提交中…' : '生成报告' }}
</button>
</view>
<view v-if="submitting" class="loading-mask">
<view class="loading-box">
<view class="loading-spinner"></view>
<text>正在生成</text>
</view>
</view>
<ReportShareModal
:show="shareModalOpen"
:report-token="shareToken"
:pet-name="sharePetName"
:store-name="shareStoreName"
:service-type="shareServiceType"
@close="onShareModalClose"
@preview="onSharePreview"
/>
</view>
</template>
<script setup>
import { ref, computed, onMounted, onUnmounted, nextTick } from 'vue'
import { onPageScroll } from '@dcloudio/uni-app'
import { createReport, getServiceTypeList, imgUrl, BASE_URL } from '../../api/index.js'
import AppIcon from '../../components/AppIcon.vue'
import ReportShareModal from '../../components/report/ReportShareModal.vue'
import { getUserSession, getStoreSession } from '../../utils/session.js'
import { useNavigator } from '../../composables/useNavigator.js'
import {
HALF_HOUR_TIME_SLOTS,
DEFAULT_HALF_HOUR_TIME,
normalizeToHalfHour,
halfHourPickerIndex,
suggestHalfHourForYMD
} from '../../utils/halfHourTime.js'
const userInfo = getUserSession()
const storeInfo = getStoreSession()
const { navigateTo } = useNavigator()
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;`
})()
// beforeMedia / afterMedia: [{url, mediaType}] mediaType 固定为 "photo"
// duringMedia: [{url, mediaType}] mediaType 可以是 "photo" 或 "video"
const report = ref({
petName: '',
serviceType: '',
appointmentTime: '',
beforeMedia: [],
afterMedia: [],
duringMedia: [],
remark: ''
})
const serviceTypes = ref([])
const currentAppointmentId = ref(null)
const submitting = ref(false)
const shareModalOpen = ref(false)
const shareToken = ref('')
const sharePetName = ref('')
const shareStoreName = ref('')
const shareServiceType = ref('')
const basicInfoCollapsed = ref(false)
const hasScrolled = ref(false)
const keyboardHeight = ref(0)
const remarkFocused = ref(false)
const autoCollapsedByKeyboard = ref(false)
let keyboardChangeHandler = null
const submitFixedStyle = computed(() => {
const bottom = keyboardHeight.value > 0 ? `${keyboardHeight.value}px` : '0px'
return `bottom:${bottom};`
})
const allMediaItems = computed(() => ([
...report.value.beforeMedia,
...report.value.afterMedia,
...report.value.duringMedia
]))
const hasUploadingMedia = computed(() => allMediaItems.value.some((i) => !!i.uploading))
const hasFailedMedia = computed(() => allMediaItems.value.some((i) => !!i.failed))
const serviceColumns = computed(() =>
serviceTypes.value.map(s => ({ label: s.name, value: s.name }))
)
const loadServiceTypes = async () => {
if (!storeInfo.id) return
const res = await getServiceTypeList(storeInfo.id)
if (res.code === 200) serviceTypes.value = res.data
}
/**
* 上传照片(用于服务前/后)
*/
const friendlyUploadError = (rawMsg) => {
const msg = (rawMsg || '').toString()
if (!msg) return '上传失败'
if (msg.includes('request:fail')) return '网络异常,请重试'
if (msg.includes('timeout')) return '上传超时,请重试'
if (msg.includes('只能上传图片或视频')) return '格式不支持'
if (msg.includes('413') || msg.includes('Too Large') || msg.includes('HTTP 413')) {
return '文件过大可先压缩视频仍失败请把网关上传上限调到≥200MB'
}
if (msg.includes('HTTP 502') || msg.includes('HTTP 503') || msg.includes('HTTP 504')) return '服务暂不可用'
if (/HTTP\s(4|5)\d{2}/.test(msg)) return '上传失败,请重试'
return msg
}
/** 大视频上传较慢,单独加长超时(毫秒) */
const UPLOAD_TIMEOUT_MS = 300000
/** 与后端 max-file-size 对齐,前端提前提示,避免长时间上传后 413 */
const MAX_VIDEO_MB = 190
const buildPendingItem = (localPath, mediaType) => ({
url: localPath,
localPath,
mediaType,
uploading: true,
failed: false,
error: ''
})
const setUploadSuccess = (item, uploadedUrl) => {
try {
item.url = imgUrl(uploadedUrl)
} catch (_) {
item.url = uploadedUrl || ''
}
item.uploading = false
item.failed = false
item.error = ''
}
const setUploadFail = (item, reason) => {
item.uploading = false
item.failed = true
item.error = reason || '上传失败'
}
/**
* 微信小程序:异步上传回调里改嵌套字段后,界面偶发不刷新,点别处才更新。
* nextTick + 浅拷贝换数组引用,强制重绘缩略图上的遮罩。
*/
async function pokeMediaList(field) {
if (!field || !Array.isArray(report.value[field])) return
await nextTick()
report.value[field] = report.value[field].map((row) => ({ ...row }))
await nextTick()
await new Promise((r) => setTimeout(r, 16))
}
/** 微信端 uploadFilestatusCode 可能为字符串或缺失;须按 2xx 判断 */
function uploadHttpOk(sc) {
if (sc == null || sc === '') return true
const n = Number(sc)
if (!Number.isNaN(n)) return n >= 200 && n < 300
return String(sc) === '200'
}
function parseUploadJson(raw) {
if (raw == null || raw === '') return null
if (typeof raw === 'object') {
if (Array.isArray(raw)) return null
if (typeof ArrayBuffer !== 'undefined' && raw instanceof ArrayBuffer) {
const s = new TextDecoder('utf-8', { fatal: false }).decode(raw)
return parseUploadJson(s)
}
if (typeof Uint8Array !== 'undefined' && raw instanceof Uint8Array) {
const s = new TextDecoder('utf-8', { fatal: false }).decode(raw)
return parseUploadJson(s)
}
return raw
}
const s = String(raw).replace(/^\uFEFF/, '').trim()
if (!s) return null
try {
return JSON.parse(s)
} catch {
return null
}
}
/** 编码异常或解析失败时,用正则从纯 ASCII 字段恢复 url不依赖 message */
function parseUploadFallback(raw) {
const s =
typeof raw === 'string'
? raw
: typeof raw === 'object' && raw !== null && typeof raw.toString === 'function'
? raw.toString()
: ''
const codeM = s.match(/"code"\s*:\s*(\d+)/)
const urlM = s.match(/"url"\s*:\s*"(\/api\/upload\/image\/[^"]*)"/)
if (codeM && Number(codeM[1]) === 200 && urlM) {
return { code: 200, data: { url: urlM[1] } }
}
return null
}
function extractUploadedUrl(data) {
if (!data || typeof data !== 'object') return ''
const inner = data.data
if (inner && typeof inner === 'object' && inner.url) return String(inner.url)
if (typeof inner === 'string' && inner.startsWith('/')) return inner
if (data.url) return String(data.url)
return ''
}
function isBizSuccess(data) {
const c = data?.code
return c === 200 || c === '200'
}
const uploadSingleFile = (filePath) =>
new Promise((resolve) => {
let settled = false
const done = (r) => {
if (settled) return
settled = true
resolve(r)
}
const timer = setTimeout(() => {
done({ ok: false, message: '上传超时,请重试' })
}, UPLOAD_TIMEOUT_MS)
uni.uploadFile({
url: `${BASE_URL}/upload/image`,
filePath,
name: 'file',
timeout: UPLOAD_TIMEOUT_MS,
success: (uploadRes) => {
try {
clearTimeout(timer)
if (!uploadHttpOk(uploadRes.statusCode)) {
done({ ok: false, message: friendlyUploadError(`HTTP ${uploadRes.statusCode}`) })
return
}
let data = parseUploadJson(uploadRes.data)
let serverUrl = extractUploadedUrl(data)
if (!data || !isBizSuccess(data) || !serverUrl) {
const fb = parseUploadFallback(uploadRes.data)
if (fb) {
data = fb
serverUrl = extractUploadedUrl(data)
}
}
if (isBizSuccess(data) && serverUrl) {
done({ ok: true, url: serverUrl })
return
}
const msg = data?.message || data?.msg || ''
done({
ok: false,
message: msg ? friendlyUploadError(msg) : '上传返回异常,请重试'
})
} catch (e) {
done({ ok: false, message: '处理上传结果失败,请重试' })
}
},
fail: (err) => {
clearTimeout(timer)
done({ ok: false, message: friendlyUploadError(err?.errMsg) })
}
})
})
const showBatchUploadFail = (count, firstReason) => {
uni.showToast({
title: `${count}个上传失败:${firstReason || '请重试'}`.slice(0, 36),
icon: 'none',
duration: 2800
})
}
const retryUpload = async (field, idx) => {
const item = report.value[field]?.[idx]
if (!item || item.uploading) return
const filePath = item.localPath || item.url
if (!filePath) {
uni.showToast({ title: '缺少本地文件,无法重试', icon: 'none' })
return
}
item.uploading = true
item.failed = false
item.error = ''
uni.showLoading({ title: '重试上传...' })
const r = await uploadSingleFile(filePath)
uni.hideLoading()
if (r.ok) {
setUploadSuccess(item, r.url)
uni.showToast({ title: '重试成功', icon: 'success' })
} else {
setUploadFail(item, r.message)
uni.showToast({ title: `重试失败:${r.message}`.slice(0, 36), icon: 'none' })
}
await pokeMediaList(field)
}
const uploadPhoto = (field) => {
const fieldMax = 4
const maxTotal = 12
if (report.value[field].length >= maxTotal) {
return uni.showToast({ title: `最多上传${maxTotal}`, icon: 'none' })
}
const count = Math.min(fieldMax - report.value[field].length, maxTotal - report.value[field].length, 4)
uni.chooseImage({
count,
success: async (res) => {
const tempPaths = res.tempFilePaths
if (!tempPaths?.length) return
uni.showLoading({ title: '上传中...' })
let failCount = 0
let firstReason = ''
for (const tempPath of tempPaths) {
const item = buildPendingItem(tempPath, 'photo')
report.value[field].push(item)
const r = await uploadSingleFile(tempPath)
if (r.ok) {
setUploadSuccess(item, r.url)
} else {
failCount++
if (!firstReason) firstReason = r.message
setUploadFail(item, r.message)
}
await pokeMediaList(field)
}
uni.hideLoading()
if (failCount > 0) showBatchUploadFail(failCount, firstReason)
},
fail: (err) => {
const msg = err?.errMsg || ''
if (msg.includes('cancel')) return
uni.showToast({ title: friendlyUploadError(msg), icon: 'none' })
},
})
}
/**
* 上传视频(用于过程中)
*/
const uploadVideo = (field) => {
const maxTotal = 12
if (report.value[field].length >= maxTotal) {
return uni.showToast({ title: `最多上传${maxTotal}`, icon: 'none' })
}
uni.chooseMedia({
count: 1,
mediaType: ['video'],
success: async (res) => {
const tempFiles = res.tempFiles
if (!tempFiles?.length) return
uni.showLoading({ title: '上传中...' })
let failCount = 0
let firstReason = ''
for (const f of tempFiles) {
const item = buildPendingItem(f.tempFilePath, 'video')
report.value[field].push(item)
const sizeMB = (f.size || 0) / 1024 / 1024
if (sizeMB > MAX_VIDEO_MB) {
failCount++
if (!firstReason) firstReason = `视频请不超过${MAX_VIDEO_MB}MB`
setUploadFail(item, `视频请不超过${MAX_VIDEO_MB}MB`)
await pokeMediaList(field)
continue
}
const r = await uploadSingleFile(f.tempFilePath)
if (r.ok) {
setUploadSuccess(item, r.url)
} else {
failCount++
if (!firstReason) firstReason = r.message
setUploadFail(item, r.message)
}
await pokeMediaList(field)
}
uni.hideLoading()
if (failCount > 0) showBatchUploadFail(failCount, firstReason)
},
fail: (err) => {
const msg = err?.errMsg || ''
if (msg.includes('cancel')) return
uni.showToast({ title: friendlyUploadError(msg), icon: 'none' })
},
})
}
/**
* 上传过程中媒体(一次选择,支持照片和视频混选)
*/
const uploadDuring = () => {
const maxTotal = 12
if (report.value.duringMedia.length >= maxTotal) {
return uni.showToast({ title: `最多上传${maxTotal}`, icon: 'none' })
}
const remain = maxTotal - report.value.duringMedia.length
uni.chooseMedia({
count: Math.min(remain, 9),
mediaType: ['image', 'video'],
success: async (res) => {
const tempFiles = res.tempFiles || []
if (!tempFiles.length) return
uni.showLoading({ title: '上传中...' })
let failCount = 0
let firstReason = ''
for (const f of tempFiles) {
const path = f.tempFilePath || ''
const lowerPath = path.toLowerCase()
const isVideo =
f.fileType === 'video' ||
lowerPath.endsWith('.mp4') ||
lowerPath.endsWith('.mov') ||
lowerPath.endsWith('.m4v') ||
lowerPath.endsWith('.webm') ||
lowerPath.endsWith('.avi')
const mediaType = isVideo ? 'video' : 'photo'
const item = buildPendingItem(path, mediaType)
report.value.duringMedia.push(item)
if (isVideo) {
const sizeMB = (f.size || 0) / 1024 / 1024
if (sizeMB > MAX_VIDEO_MB) {
failCount++
if (!firstReason) firstReason = `视频请不超过${MAX_VIDEO_MB}MB`
setUploadFail(item, `视频请不超过${MAX_VIDEO_MB}MB`)
await pokeMediaList('duringMedia')
continue
}
}
const r = await uploadSingleFile(path)
if (r.ok) {
setUploadSuccess(item, r.url)
} else {
failCount++
if (!firstReason) firstReason = r.message
setUploadFail(item, r.message)
}
await pokeMediaList('duringMedia')
}
uni.hideLoading()
if (failCount > 0) showBatchUploadFail(failCount, firstReason)
},
fail: (err) => {
const msg = err?.errMsg || ''
if (msg.includes('cancel')) return
uni.showToast({ title: friendlyUploadError(msg), icon: 'none' })
},
})
}
const removeMedia = (field, idx) => { report.value[field].splice(idx, 1) }
const onServiceConfirm = (e) => {
report.value.serviceType = serviceColumns.value[e.detail.value].value
}
const serviceDate = computed(() => {
const raw = report.value.appointmentTime || ''
return raw.includes('T') ? raw.split('T')[0] : ''
})
const serviceTimePart = computed(() => {
const raw = report.value.appointmentTime || ''
if (!raw.includes('T')) return ''
const part = (raw.split('T')[1] || '').slice(0, 5)
return normalizeToHalfHour(part)
})
const halfHourServiceTimeIndex = computed(() => halfHourPickerIndex(serviceTimePart.value))
const onServiceDateChange = (e) => {
const date = e?.detail?.value || ''
if (!date) {
report.value.appointmentTime = ''
return
}
const time = normalizeToHalfHour(suggestHalfHourForYMD(date))
report.value.appointmentTime = `${date}T${time}`
}
const onServiceTimeChange = (e) => {
const idx = Number(e.detail.value)
const time = HALF_HOUR_TIME_SLOTS[idx]
const date = serviceDate.value
if (!date) { uni.showToast({ title: '请先选择日期', icon: 'none' }); return }
report.value.appointmentTime = `${date}T${time}`
}
const submitReport = async () => {
if (hasUploadingMedia.value) return uni.showToast({ title: '还有素材上传中,请稍候', icon: 'none' })
if (hasFailedMedia.value) {
return uni.showToast({ title: '请先点缩略图右上角✕删除失败项', icon: 'none', duration: 2800 })
}
if (!report.value.petName) return uni.showToast({ title: '请输入宠物名字', icon: 'none' })
if (!report.value.serviceType) return uni.showToast({ title: '请选择服务类型', icon: 'none' })
if (!report.value.appointmentTime) return uni.showToast({ title: '请选择服务时间', icon: 'none' })
submitting.value = true
// 构建图片列表
const images = []
report.value.beforeMedia.filter((item) => !item.uploading && !item.failed).forEach((item, i) => {
images.push({ photoUrl: item.url, photoType: 'before', mediaType: item.mediaType || 'photo', sortOrder: i })
})
report.value.afterMedia.filter((item) => !item.uploading && !item.failed).forEach((item, i) => {
images.push({ photoUrl: item.url, photoType: 'after', mediaType: item.mediaType || 'photo', sortOrder: i })
})
report.value.duringMedia.filter((item) => !item.uploading && !item.failed).forEach((item, i) => {
images.push({ photoUrl: item.url, photoType: 'during', mediaType: item.mediaType || 'photo', sortOrder: i })
})
const payload = {
appointmentId: currentAppointmentId.value || null,
userId: userInfo.id,
petName: report.value.petName,
serviceType: report.value.serviceType,
appointmentTime: report.value.appointmentTime || null,
remark: report.value.remark,
images
}
const res = await createReport(payload)
submitting.value = false
if (res.code === 200) {
const token = res.data.reportToken
shareToken.value = token
sharePetName.value = report.value.petName || ''
shareStoreName.value = storeInfo?.name || ''
shareServiceType.value = report.value.serviceType || ''
report.value = { petName: '', serviceType: '', appointmentTime: '', beforeMedia: [], afterMedia: [], duringMedia: [], remark: '' }
currentAppointmentId.value = null
basicInfoCollapsed.value = false
uni.showToast({ title: '已生成报告', icon: 'success' })
shareModalOpen.value = true
} else {
uni.showToast({ title: res.message || '提交失败', icon: 'none' })
}
}
const onShareModalClose = () => {
shareModalOpen.value = false
shareToken.value = ''
sharePetName.value = ''
shareStoreName.value = ''
shareServiceType.value = ''
}
const onSharePreview = () => {
const t = shareToken.value
shareModalOpen.value = false
shareToken.value = ''
sharePetName.value = ''
shareStoreName.value = ''
shareServiceType.value = ''
if (t) {
uni.navigateTo({ url: `/pages/report-view/reportView?token=${encodeURIComponent(t)}` })
}
}
const goBack = () => {
const pages = getCurrentPages()
if (pages.length > 1) {
uni.navigateBack()
return
}
navigateTo('report')
}
onMounted(async () => {
if (typeof uni.onKeyboardHeightChange === 'function') {
keyboardChangeHandler = (res) => {
const h = Math.max(0, Number(res?.height || 0))
keyboardHeight.value = h
// 仅在备注输入态自动折叠基础信息,减少输入时视觉干扰。
if (h > 0 && remarkFocused.value && !basicInfoCollapsed.value) {
basicInfoCollapsed.value = true
autoCollapsedByKeyboard.value = true
}
// 仅恢复由本逻辑触发的折叠,避免覆盖用户手动操作。
if (h === 0 && autoCollapsedByKeyboard.value) {
basicInfoCollapsed.value = false
autoCollapsedByKeyboard.value = false
}
}
uni.onKeyboardHeightChange(keyboardChangeHandler)
}
await loadServiceTypes()
const prefill = JSON.parse(uni.getStorageSync('petstore_report_prefill') || 'null')
if (prefill) {
currentAppointmentId.value = prefill.appointmentId
report.value.petName = prefill.petName || ''
report.value.serviceType = prefill.serviceType || ''
if (prefill.appointmentTime) {
const raw = String(prefill.appointmentTime)
if (raw.includes('T')) {
const [d, rest] = raw.split('T')
const hm = (rest || '').match(/^(\d{1,2}:\d{2})/)?.[1] || DEFAULT_HALF_HOUR_TIME
report.value.appointmentTime = `${d}T${normalizeToHalfHour(hm)}`
}
}
// 预约数据带入,默认折叠基础信息
basicInfoCollapsed.value = true
uni.removeStorageSync('petstore_report_prefill')
}
})
onUnmounted(() => {
if (typeof uni.offKeyboardHeightChange === 'function' && keyboardChangeHandler) {
uni.offKeyboardHeightChange(keyboardChangeHandler)
}
})
onPageScroll((e) => {
hasScrolled.value = (e?.scrollTop || 0) > 8
})
const onRemarkFocus = () => {
remarkFocused.value = true
}
const onRemarkBlur = () => {
remarkFocused.value = false
}
</script>
<style scoped>
.report-page {
padding-bottom: calc(96px + env(safe-area-inset-bottom));
background: #fafaf8;
}
.report-nav {
padding: 0 16px 12px;
display: flex;
align-items: center;
justify-content: space-between;
position: sticky;
top: 0;
z-index: 10;
}
.report-body {
padding: 12px 16px 20px;
}
/* 区块 */
.section {
background: #fff;
border-radius: 16px;
padding: 20px;
margin-bottom: 12px;
}
.section-head {
margin-bottom: 16px;
}
.section-head--clickable {
display: flex;
align-items: center;
justify-content: space-between;
}
.section-toggle {
font-size: 13px;
font-weight: 600;
color: var(--c-brand);
}
.section-label {
font-size: 16px;
font-weight: 700;
color: #1a1a1a;
}
.section-hint {
display: block;
font-size: 12px;
color: var(--c-text-3);
margin-top: 2px;
}
/* 表单 */
.form-row {
margin-bottom: 14px;
}
.form-row:last-child { margin-bottom: 0; }
.time-row {
display: flex;
gap: 10px;
}
.time-picker { flex: 1; }
/* 照片对比区 */
.photo-compare {
display: flex;
gap: 0;
}
.photo-side {
flex: 1;
min-width: 0;
}
.photo-divider {
width: 1px;
background: #ebebea;
margin: 0 12px;
align-self: stretch;
}
.photo-side-label {
display: flex;
align-items: center;
gap: 6px;
margin-bottom: 10px;
font-size: 13px;
font-weight: 600;
color: #666660;
}
.dot {
width: 8px;
height: 8px;
border-radius: 50%;
}
.dot-before { background: #f5913e; }
.dot-after { background: var(--c-brand); }
.photo-grid {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.photo-thumb {
position: relative;
width: calc(50% - 4px);
aspect-ratio: 1;
border-radius: 10px;
overflow: hidden;
background: #f0f0ee;
}
.thumb-img {
width: 100%;
height: 100%;
}
.thumb-del {
position: absolute;
top: 4px;
right: 4px;
z-index: 10;
width: 22px;
height: 22px;
background: rgba(0,0,0,0.55);
color: #fff;
border-radius: 50%;
font-size: 11px;
display: flex;
align-items: center;
justify-content: center;
}
.media-mask {
position: absolute;
inset: 0;
z-index: 2;
background: rgba(0, 0, 0, 0.42);
display: flex;
align-items: center;
justify-content: center;
padding: 8px;
box-sizing: border-box;
}
.media-mask--error {
background: rgba(188, 46, 46, 0.72);
}
.media-mask-text {
color: #fff;
font-size: 11px;
font-weight: 600;
text-align: center;
line-height: 1.3;
}
.photo-add {
width: calc(50% - 4px);
aspect-ratio: 1;
border: 1.5px dashed #ddddd8;
border-radius: 10px;
display: flex;
align-items: center;
justify-content: center;
background: #fafaf8;
}
/* 过程中区块 */
.during-add-row {
margin-bottom: 14px;
}
.during-add-btn {
height: 44px;
border-radius: 12px;
border: 1.5px dashed #ddddd8;
background: #fafaf8;
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
font-size: 14px;
color: var(--c-text-3);
}
.during-add-btn:active {
background: #f0f0ee;
}
.during-grid {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.video-badge {
position: absolute;
bottom: 5px;
left: 5px;
height: 18px;
padding: 0 6px;
border-radius: 4px;
background: rgba(0,0,0,0.55);
display: flex;
align-items: center;
justify-content: center;
}
.video-badge text {
font-size: 10px;
font-weight: 700;
color: #fff;
letter-spacing: 0.3px;
}
/* 备注 */
.submit-fixed {
position: fixed;
left: 0;
right: 0;
padding: 10px 16px calc(10px + env(safe-area-inset-bottom));
background: rgba(250, 250, 248, 0.96);
border-top: 1px solid #efefeb;
backdrop-filter: blur(2px);
z-index: 50;
transition: box-shadow 0.2s ease, background 0.2s ease, bottom 0.2s ease;
}
.submit-fixed--scrolled {
box-shadow: 0 -8px 24px rgba(0, 0, 0, 0.08);
background: rgba(250, 250, 248, 0.985);
}
.report-lock-hint {
font-size: 12px;
color: #8a8a84;
line-height: 1.45;
text-align: center;
margin-bottom: 10px;
padding: 0 8px;
}
.submit-btn--fixed {
box-shadow: 0 5px 16px rgba(0, 0, 0, 0.1);
}
/* Loading */
.loading-mask {
position: fixed;
top: 0; left: 0; right: 0; bottom: 0;
background: rgba(0,0,0,0.3);
display: flex;
align-items: center;
justify-content: center;
z-index: 999;
}
.loading-box {
background: #fff;
padding: 24px 36px;
border-radius: 16px;
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
font-size: 14px;
color: #666660;
}
.loading-spinner {
width: 28px;
height: 28px;
border: 2.5px solid #ebebea;
border-top-color: var(--c-brand);
border-radius: 50%;
animation: spin 0.7s linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }
</style>