1073 lines
32 KiB
Vue
1073 lines
32 KiB
Vue
<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))
|
||
}
|
||
|
||
/** 微信端 uploadFile:statusCode 可能为字符串或缺失;须按 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>
|