feat: 报告页上传进度与重试,预约页宠物自动匹配优化
This commit is contained in:
parent
9ae9cdb08e
commit
92f1077025
@ -29,22 +29,25 @@
|
|||||||
</picker>
|
</picker>
|
||||||
<text v-if="storeHintLine" class="c-store-hint">{{ storeHintLine }}</text>
|
<text v-if="storeHintLine" class="c-store-hint">{{ storeHintLine }}</text>
|
||||||
</view>
|
</view>
|
||||||
<view v-if="myPets.length" class="c-field">
|
|
||||||
<text class="app-form-label">使用档案(可选)</text>
|
|
||||||
<picker mode="selector" :range="petPickerOptions" range-key="label" @change="onPickMyPet">
|
|
||||||
<view class="app-form-input app-form-picker">
|
|
||||||
<text :class="{ 'app-form-placeholder': selectedPetIndex === 0 }">{{ petPickerLabel }}</text>
|
|
||||||
<text class="app-form-arrow">›</text>
|
|
||||||
</view>
|
|
||||||
</picker>
|
|
||||||
</view>
|
|
||||||
<view class="c-field">
|
<view class="c-field">
|
||||||
<text class="app-form-label">宠物名字</text>
|
<text class="app-form-label">宠物名字</text>
|
||||||
<input v-model="newAppt.petName" class="app-form-input" placeholder="请输入宠物名字" />
|
<view class="pet-name-row">
|
||||||
|
<input
|
||||||
|
v-model="newAppt.petName"
|
||||||
|
class="app-form-input pet-name-input"
|
||||||
|
placeholder="请输入宠物名字"
|
||||||
|
@input="onPetNameInput"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
v-if="myPets.length > 1"
|
||||||
|
class="app-btn-sm app-btn-soft pet-switch-btn"
|
||||||
|
@click="openPetSwitcher"
|
||||||
|
>切换宠物</button>
|
||||||
|
</view>
|
||||||
</view>
|
</view>
|
||||||
<view class="c-field">
|
<view class="c-field">
|
||||||
<text class="app-form-label">宠物类型</text>
|
<text class="app-form-label">宠物类型</text>
|
||||||
<picker mode="selector" :range="petTypes" range-key="label" @change="e => newAppt.petType = petTypes[e.detail.value].value">
|
<picker mode="selector" :range="petTypes" range-key="label" @change="onPetTypeChange">
|
||||||
<view class="app-form-input app-form-picker">
|
<view class="app-form-input app-form-picker">
|
||||||
<text :class="{ 'app-form-placeholder': !newAppt.petType }">{{ newAppt.petType || '请选择' }}</text>
|
<text :class="{ 'app-form-placeholder': !newAppt.petType }">{{ newAppt.petType || '请选择' }}</text>
|
||||||
<text class="app-form-arrow">›</text>
|
<text class="app-form-arrow">›</text>
|
||||||
@ -92,7 +95,7 @@
|
|||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, computed, onMounted } from 'vue'
|
import { ref, computed, onMounted } from 'vue'
|
||||||
import { createAppointment, getServiceTypeList, getPetList, getStoreList } from '../../api/index.js'
|
import { createAppointment, getServiceTypeList, getPetList, getStoreList, createPet } from '../../api/index.js'
|
||||||
import AppIcon from '../../components/AppIcon.vue'
|
import AppIcon from '../../components/AppIcon.vue'
|
||||||
import { getUserSession, getStoreSession, setStoreSession } from '../../utils/session.js'
|
import { getUserSession, getStoreSession, setStoreSession } from '../../utils/session.js'
|
||||||
import { haversineKm } from '../../utils/geo.js'
|
import { haversineKm } from '../../utils/geo.js'
|
||||||
@ -105,6 +108,7 @@ import {
|
|||||||
|
|
||||||
const userInfo = getUserSession()
|
const userInfo = getUserSession()
|
||||||
const storeInfo = getStoreSession()
|
const storeInfo = getStoreSession()
|
||||||
|
const LAST_SUBMITTED_PET_KEY = `petstore_last_submitted_pet_${userInfo.id || 'guest'}`
|
||||||
|
|
||||||
const BOOKING_STORE_ID_KEY = 'petstore_booking_store_id'
|
const BOOKING_STORE_ID_KEY = 'petstore_booking_store_id'
|
||||||
|
|
||||||
@ -223,24 +227,56 @@ const onStorePickerChange = (e) => {
|
|||||||
loadServiceTypesForSelected()
|
loadServiceTypesForSelected()
|
||||||
}
|
}
|
||||||
const myPets = ref([])
|
const myPets = ref([])
|
||||||
const selectedPetIndex = ref(0)
|
const selectedPetId = ref(null)
|
||||||
const newAppt = ref({ petName: '', petType: '', serviceType: '', appointmentTime: '', remark: '' })
|
const newAppt = ref({ petName: '', petType: '', serviceType: '', appointmentTime: '', remark: '' })
|
||||||
const creatingAppt = ref(false)
|
const creatingAppt = ref(false)
|
||||||
|
|
||||||
const petPickerOptions = computed(() => {
|
const normalizeText = (s) => (s || '').toString().trim().toLowerCase()
|
||||||
const head = [{ label: '手动填写,不关联档案', pet: null }]
|
|
||||||
return head.concat(myPets.value.map((p) => ({ label: `${p.name}(${p.petType})`, pet: p })))
|
|
||||||
})
|
|
||||||
|
|
||||||
const petPickerLabel = computed(() => petPickerOptions.value[selectedPetIndex.value]?.label || '请选择')
|
const findMatchedPet = (name, petType) =>
|
||||||
|
myPets.value.find(
|
||||||
|
(p) => normalizeText(p.name) === normalizeText(name) && normalizeText(p.petType) === normalizeText(petType)
|
||||||
|
)
|
||||||
|
|
||||||
const onPickMyPet = (e) => {
|
const applyPetToForm = (pet) => {
|
||||||
|
if (!pet) return
|
||||||
|
selectedPetId.value = pet.id || null
|
||||||
|
newAppt.value.petName = pet.name || ''
|
||||||
|
newAppt.value.petType = pet.petType || ''
|
||||||
|
}
|
||||||
|
|
||||||
|
const openPetSwitcher = () => {
|
||||||
|
if (myPets.value.length <= 1) return
|
||||||
|
uni.showActionSheet({
|
||||||
|
itemList: myPets.value.map((p) => `${p.name}(${p.petType})`),
|
||||||
|
success: (res) => {
|
||||||
|
const pet = myPets.value[res.tapIndex]
|
||||||
|
if (pet) applyPetToForm(pet)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const onPetNameInput = (e) => {
|
||||||
|
const currentName = e?.detail?.value ?? newAppt.value.petName
|
||||||
|
if (!selectedPetId.value) return
|
||||||
|
const cur = myPets.value.find((p) => p.id === selectedPetId.value)
|
||||||
|
if (!cur) {
|
||||||
|
selectedPetId.value = null
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (normalizeText(currentName) !== normalizeText(cur.name)) {
|
||||||
|
selectedPetId.value = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const onPetTypeChange = (e) => {
|
||||||
const i = Number(e.detail.value)
|
const i = Number(e.detail.value)
|
||||||
selectedPetIndex.value = i
|
const nextType = petTypes[i]?.value || ''
|
||||||
const opt = petPickerOptions.value[i]
|
newAppt.value.petType = nextType
|
||||||
if (opt?.pet) {
|
if (!selectedPetId.value) return
|
||||||
newAppt.value.petName = opt.pet.name
|
const cur = myPets.value.find((p) => p.id === selectedPetId.value)
|
||||||
newAppt.value.petType = opt.pet.petType
|
if (!cur || normalizeText(nextType) !== normalizeText(cur.petType)) {
|
||||||
|
selectedPetId.value = null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -284,6 +320,56 @@ const loadServiceTypesForSelected = async () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const hydrateDefaultPet = () => {
|
||||||
|
if (!myPets.value.length) return
|
||||||
|
let picked = null
|
||||||
|
try {
|
||||||
|
const saved = JSON.parse(uni.getStorageSync(LAST_SUBMITTED_PET_KEY) || 'null')
|
||||||
|
if (saved) {
|
||||||
|
if (saved.petId) picked = myPets.value.find((p) => String(p.id) === String(saved.petId)) || null
|
||||||
|
if (!picked && saved.name && saved.petType) picked = findMatchedPet(saved.name, saved.petType) || null
|
||||||
|
}
|
||||||
|
} catch (_) {}
|
||||||
|
if (!picked) picked = myPets.value[0]
|
||||||
|
applyPetToForm(picked)
|
||||||
|
}
|
||||||
|
|
||||||
|
const rememberLastSubmittedPet = (pet) => {
|
||||||
|
if (!pet) return
|
||||||
|
uni.setStorageSync(
|
||||||
|
LAST_SUBMITTED_PET_KEY,
|
||||||
|
JSON.stringify({ petId: pet.id || null, name: pet.name || '', petType: pet.petType || '' })
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const ensurePetProfile = async (name, petType) => {
|
||||||
|
const current = myPets.value.find((p) => String(p.id) === String(selectedPetId.value))
|
||||||
|
if (
|
||||||
|
current &&
|
||||||
|
normalizeText(current.name) === normalizeText(name) &&
|
||||||
|
normalizeText(current.petType) === normalizeText(petType)
|
||||||
|
) {
|
||||||
|
return current
|
||||||
|
}
|
||||||
|
const matched = findMatchedPet(name, petType)
|
||||||
|
if (matched) {
|
||||||
|
selectedPetId.value = matched.id || null
|
||||||
|
return matched
|
||||||
|
}
|
||||||
|
const res = await createPet({
|
||||||
|
ownerUserId: userInfo.id,
|
||||||
|
name: name.trim(),
|
||||||
|
petType: petType
|
||||||
|
})
|
||||||
|
if (res.code !== 200 || !res.data) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
const created = res.data
|
||||||
|
myPets.value.unshift(created)
|
||||||
|
selectedPetId.value = created.id || null
|
||||||
|
return created
|
||||||
|
}
|
||||||
|
|
||||||
const confirmNewAppt = async () => {
|
const confirmNewAppt = async () => {
|
||||||
const a = newAppt.value
|
const a = newAppt.value
|
||||||
if (!a.petName) { uni.showToast({ title: '请输入宠物名字', icon: 'none' }); return }
|
if (!a.petName) { uni.showToast({ title: '请输入宠物名字', icon: 'none' }); return }
|
||||||
@ -297,12 +383,18 @@ const confirmNewAppt = async () => {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
creatingAppt.value = true
|
creatingAppt.value = true
|
||||||
|
const ensuredPet = await ensurePetProfile(a.petName, a.petType)
|
||||||
|
if (!ensuredPet?.id) {
|
||||||
|
creatingAppt.value = false
|
||||||
|
uni.showToast({ title: '宠物档案保存失败,请重试', icon: 'none' })
|
||||||
|
return
|
||||||
|
}
|
||||||
const payload = { ...a, storeId, userId: userInfo.id }
|
const payload = { ...a, storeId, userId: userInfo.id }
|
||||||
const opt = petPickerOptions.value[selectedPetIndex.value]
|
payload.petId = ensuredPet.id
|
||||||
if (opt?.pet?.id) payload.petId = opt.pet.id
|
|
||||||
const res = await createAppointment(payload)
|
const res = await createAppointment(payload)
|
||||||
creatingAppt.value = false
|
creatingAppt.value = false
|
||||||
if (res.code === 200) {
|
if (res.code === 200) {
|
||||||
|
rememberLastSubmittedPet(ensuredPet)
|
||||||
uni.showToast({ title: '预约成功', icon: 'success' })
|
uni.showToast({ title: '预约成功', icon: 'success' })
|
||||||
setTimeout(() => uni.navigateBack(), 600)
|
setTimeout(() => uni.navigateBack(), 600)
|
||||||
} else {
|
} else {
|
||||||
@ -318,7 +410,10 @@ const goBack = () => {
|
|||||||
|
|
||||||
const loadMyPets = async () => {
|
const loadMyPets = async () => {
|
||||||
const res = await getPetList({ ownerUserId: userInfo.id })
|
const res = await getPetList({ ownerUserId: userInfo.id })
|
||||||
if (res.code === 200) myPets.value = res.data || []
|
if (res.code === 200) {
|
||||||
|
myPets.value = res.data || []
|
||||||
|
hydrateDefaultPet()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
@ -372,6 +467,21 @@ onMounted(async () => {
|
|||||||
.c-field { margin-bottom: 16px; }
|
.c-field { margin-bottom: 16px; }
|
||||||
.c-time-row { display: flex; gap: 10px; }
|
.c-time-row { display: flex; gap: 10px; }
|
||||||
.c-time-half { flex: 1; }
|
.c-time-half { flex: 1; }
|
||||||
|
.pet-name-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.pet-name-input {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.pet-switch-btn {
|
||||||
|
width: 68px !important;
|
||||||
|
padding: 0 !important;
|
||||||
|
color: #2db96d !important;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
.c-store-hint {
|
.c-store-hint {
|
||||||
display: block;
|
display: block;
|
||||||
margin-top: 6px;
|
margin-top: 6px;
|
||||||
|
|||||||
@ -67,6 +67,12 @@
|
|||||||
<view class="photo-grid">
|
<view class="photo-grid">
|
||||||
<view v-for="(item, idx) in report.beforeMedia" :key="'b'+idx" class="photo-thumb">
|
<view v-for="(item, idx) in report.beforeMedia" :key="'b'+idx" class="photo-thumb">
|
||||||
<image :src="item.url" class="thumb-img" mode="aspectFill" />
|
<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="removeMedia('beforeMedia', idx)">✕</view>
|
<view class="thumb-del" @click="removeMedia('beforeMedia', idx)">✕</view>
|
||||||
</view>
|
</view>
|
||||||
<view v-if="report.beforeMedia.length < 4" class="photo-add" @click="uploadPhoto('beforeMedia')">
|
<view v-if="report.beforeMedia.length < 4" class="photo-add" @click="uploadPhoto('beforeMedia')">
|
||||||
@ -85,6 +91,12 @@
|
|||||||
<view class="photo-grid">
|
<view class="photo-grid">
|
||||||
<view v-for="(item, idx) in report.afterMedia" :key="'a'+idx" class="photo-thumb">
|
<view v-for="(item, idx) in report.afterMedia" :key="'a'+idx" class="photo-thumb">
|
||||||
<image :src="item.url" class="thumb-img" mode="aspectFill" />
|
<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="removeMedia('afterMedia', idx)">✕</view>
|
<view class="thumb-del" @click="removeMedia('afterMedia', idx)">✕</view>
|
||||||
</view>
|
</view>
|
||||||
<view v-if="report.afterMedia.length < 4" class="photo-add" @click="uploadPhoto('afterMedia')">
|
<view v-if="report.afterMedia.length < 4" class="photo-add" @click="uploadPhoto('afterMedia')">
|
||||||
@ -123,6 +135,12 @@
|
|||||||
<view v-if="item.mediaType === 'video'" class="video-badge">
|
<view v-if="item.mediaType === 'video'" class="video-badge">
|
||||||
<text>视频</text>
|
<text>视频</text>
|
||||||
</view>
|
</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="removeMedia('duringMedia', idx)">✕</view>
|
<view class="thumb-del" @click="removeMedia('duringMedia', idx)">✕</view>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
@ -219,6 +237,15 @@ const submitFixedStyle = computed(() => {
|
|||||||
return `bottom:${bottom};`
|
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(() =>
|
const serviceColumns = computed(() =>
|
||||||
serviceTypes.value.map(s => ({ label: s.name, value: s.name }))
|
serviceTypes.value.map(s => ({ label: s.name, value: s.name }))
|
||||||
)
|
)
|
||||||
@ -232,6 +259,90 @@ const loadServiceTypes = async () => {
|
|||||||
/**
|
/**
|
||||||
* 上传照片(用于服务前/后)
|
* 上传照片(用于服务前/后)
|
||||||
*/
|
*/
|
||||||
|
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')) return '文件过大'
|
||||||
|
return msg
|
||||||
|
}
|
||||||
|
|
||||||
|
const buildPendingItem = (localPath, mediaType) => ({
|
||||||
|
url: localPath,
|
||||||
|
localPath,
|
||||||
|
mediaType,
|
||||||
|
uploading: true,
|
||||||
|
failed: false,
|
||||||
|
error: ''
|
||||||
|
})
|
||||||
|
|
||||||
|
const setUploadSuccess = (item, uploadedUrl) => {
|
||||||
|
item.url = imgUrl(uploadedUrl)
|
||||||
|
item.uploading = false
|
||||||
|
item.failed = false
|
||||||
|
item.error = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
const setUploadFail = (item, reason) => {
|
||||||
|
item.uploading = false
|
||||||
|
item.failed = true
|
||||||
|
item.error = reason || '上传失败'
|
||||||
|
}
|
||||||
|
|
||||||
|
const uploadSingleFile = (filePath) =>
|
||||||
|
new Promise((resolve) => {
|
||||||
|
uni.uploadFile({
|
||||||
|
url: `${BASE_URL}/upload/image`,
|
||||||
|
filePath,
|
||||||
|
name: 'file',
|
||||||
|
success: (uploadRes) => {
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(uploadRes.data || '{}')
|
||||||
|
if (data.code === 200 && data.data?.url) {
|
||||||
|
resolve({ ok: true, url: data.data.url })
|
||||||
|
} else {
|
||||||
|
resolve({ ok: false, message: friendlyUploadError(data.message) })
|
||||||
|
}
|
||||||
|
} catch (_) {
|
||||||
|
resolve({ ok: false, message: '返回解析失败' })
|
||||||
|
}
|
||||||
|
},
|
||||||
|
fail: (err) => resolve({ ok: false, message: friendlyUploadError(err?.errMsg) })
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
const showBatchUploadFail = (count, firstReason) => {
|
||||||
|
uni.showToast({
|
||||||
|
title: `${count}个上传失败:${firstReason || '请重试'}`.slice(0, 20),
|
||||||
|
icon: 'none'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
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, 20), icon: 'none' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const uploadPhoto = (field) => {
|
const uploadPhoto = (field) => {
|
||||||
const fieldMax = 4
|
const fieldMax = 4
|
||||||
const maxTotal = 12
|
const maxTotal = 12
|
||||||
@ -241,34 +352,32 @@ const uploadPhoto = (field) => {
|
|||||||
const count = Math.min(fieldMax - report.value[field].length, maxTotal - report.value[field].length, 4)
|
const count = Math.min(fieldMax - report.value[field].length, maxTotal - report.value[field].length, 4)
|
||||||
uni.chooseImage({
|
uni.chooseImage({
|
||||||
count,
|
count,
|
||||||
success: (res) => {
|
success: async (res) => {
|
||||||
const tempPaths = res.tempFilePaths
|
const tempPaths = res.tempFilePaths
|
||||||
|
if (!tempPaths?.length) return
|
||||||
uni.showLoading({ title: '上传中...' })
|
uni.showLoading({ title: '上传中...' })
|
||||||
let failCount = 0
|
let failCount = 0
|
||||||
const tasks = tempPaths.map(tempPath => {
|
let firstReason = ''
|
||||||
return new Promise((resolve) => {
|
for (const tempPath of tempPaths) {
|
||||||
uni.uploadFile({
|
const item = buildPendingItem(tempPath, 'photo')
|
||||||
url: `${BASE_URL}/upload/image`,
|
report.value[field].push(item)
|
||||||
filePath: tempPath,
|
const r = await uploadSingleFile(tempPath)
|
||||||
name: 'file',
|
if (r.ok) {
|
||||||
success: (uploadRes) => {
|
setUploadSuccess(item, r.url)
|
||||||
try {
|
} else {
|
||||||
const data = JSON.parse(uploadRes.data)
|
failCount++
|
||||||
if (data.code === 200) {
|
if (!firstReason) firstReason = r.message
|
||||||
report.value[field].push({ url: imgUrl(data.data.url), mediaType: 'photo' })
|
setUploadFail(item, r.message)
|
||||||
} else { failCount++ }
|
}
|
||||||
} catch (e) { failCount++ }
|
}
|
||||||
resolve()
|
uni.hideLoading()
|
||||||
},
|
if (failCount > 0) showBatchUploadFail(failCount, firstReason)
|
||||||
fail: () => { failCount++; resolve() }
|
},
|
||||||
})
|
fail: (err) => {
|
||||||
})
|
const msg = err?.errMsg || ''
|
||||||
})
|
if (msg.includes('cancel')) return
|
||||||
Promise.all(tasks).then(() => {
|
uni.showToast({ title: friendlyUploadError(msg), icon: 'none' })
|
||||||
uni.hideLoading()
|
},
|
||||||
if (failCount > 0) uni.showToast({ title: `${failCount}张上传失败`, icon: 'none' })
|
|
||||||
})
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -283,61 +392,101 @@ const uploadVideo = (field) => {
|
|||||||
uni.chooseMedia({
|
uni.chooseMedia({
|
||||||
count: 1,
|
count: 1,
|
||||||
mediaType: ['video'],
|
mediaType: ['video'],
|
||||||
success: (res) => {
|
success: async (res) => {
|
||||||
const tempFiles = res.tempFiles
|
const tempFiles = res.tempFiles
|
||||||
|
if (!tempFiles?.length) return
|
||||||
uni.showLoading({ title: '上传中...' })
|
uni.showLoading({ title: '上传中...' })
|
||||||
let failCount = 0
|
let failCount = 0
|
||||||
const tasks = tempFiles.map(f => {
|
let firstReason = ''
|
||||||
return new Promise((resolve) => {
|
for (const f of tempFiles) {
|
||||||
const sizeMB = (f.size || 0) / 1024 / 1024
|
const item = buildPendingItem(f.tempFilePath, 'video')
|
||||||
if (sizeMB > 100) {
|
report.value[field].push(item)
|
||||||
failCount++
|
const sizeMB = (f.size || 0) / 1024 / 1024
|
||||||
uni.showToast({ title: '视频不能超过100MB', icon: 'none' })
|
if (sizeMB > 100) {
|
||||||
resolve()
|
failCount++
|
||||||
return
|
if (!firstReason) firstReason = '视频不能超过100MB'
|
||||||
}
|
setUploadFail(item, '视频不能超过100MB')
|
||||||
uni.uploadFile({
|
continue
|
||||||
url: `${BASE_URL}/upload/image`,
|
}
|
||||||
filePath: f.tempFilePath,
|
const r = await uploadSingleFile(f.tempFilePath)
|
||||||
name: 'file',
|
if (r.ok) {
|
||||||
success: (uploadRes) => {
|
setUploadSuccess(item, r.url)
|
||||||
try {
|
} else {
|
||||||
const data = JSON.parse(uploadRes.data)
|
failCount++
|
||||||
if (data.code === 200) {
|
if (!firstReason) firstReason = r.message
|
||||||
report.value[field].push({ url: imgUrl(data.data.url), mediaType: 'video' })
|
setUploadFail(item, r.message)
|
||||||
} else { failCount++ }
|
}
|
||||||
} catch (e) { failCount++ }
|
}
|
||||||
resolve()
|
uni.hideLoading()
|
||||||
},
|
if (failCount > 0) showBatchUploadFail(failCount, firstReason)
|
||||||
fail: () => { failCount++; resolve() }
|
},
|
||||||
})
|
fail: (err) => {
|
||||||
})
|
const msg = err?.errMsg || ''
|
||||||
})
|
if (msg.includes('cancel')) return
|
||||||
Promise.all(tasks).then(() => {
|
uni.showToast({ title: friendlyUploadError(msg), icon: 'none' })
|
||||||
uni.hideLoading()
|
},
|
||||||
if (failCount > 0) uni.showToast({ title: `${failCount}个上传失败`, icon: 'none' })
|
|
||||||
})
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 上传过程中媒体(照片或视频,弹窗选择)
|
* 上传过程中媒体(一次选择,支持照片和视频混选)
|
||||||
*/
|
*/
|
||||||
const uploadDuring = () => {
|
const uploadDuring = () => {
|
||||||
const maxTotal = 12
|
const maxTotal = 12
|
||||||
if (report.value.duringMedia.length >= maxTotal) {
|
if (report.value.duringMedia.length >= maxTotal) {
|
||||||
return uni.showToast({ title: `最多上传${maxTotal}个`, icon: 'none' })
|
return uni.showToast({ title: `最多上传${maxTotal}个`, icon: 'none' })
|
||||||
}
|
}
|
||||||
uni.showActionSheet({
|
const remain = maxTotal - report.value.duringMedia.length
|
||||||
itemList: ['照片', '视频'],
|
uni.chooseMedia({
|
||||||
success: (res) => {
|
count: Math.min(remain, 9),
|
||||||
if (res.tapIndex === 0) {
|
mediaType: ['image', 'video'],
|
||||||
uploadPhoto('duringMedia')
|
success: async (res) => {
|
||||||
} else {
|
const tempFiles = res.tempFiles || []
|
||||||
uploadVideo('duringMedia')
|
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 > 100) {
|
||||||
|
failCount++
|
||||||
|
if (!firstReason) firstReason = '视频不能超过100MB'
|
||||||
|
setUploadFail(item, '视频不能超过100MB')
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const r = await uploadSingleFile(path)
|
||||||
|
if (r.ok) {
|
||||||
|
setUploadSuccess(item, r.url)
|
||||||
|
} else {
|
||||||
|
failCount++
|
||||||
|
if (!firstReason) firstReason = r.message
|
||||||
|
setUploadFail(item, r.message)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
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' })
|
||||||
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -379,6 +528,8 @@ const onServiceTimeChange = (e) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const submitReport = async () => {
|
const submitReport = async () => {
|
||||||
|
if (hasUploadingMedia.value) return uni.showToast({ title: '还有素材上传中,请稍候', icon: 'none' })
|
||||||
|
if (hasFailedMedia.value) return uni.showToast({ title: '有上传失败项,请重试或删除', icon: 'none' })
|
||||||
if (!report.value.petName) return uni.showToast({ title: '请输入宠物名字', icon: 'none' })
|
if (!report.value.petName) return uni.showToast({ title: '请输入宠物名字', icon: 'none' })
|
||||||
if (!report.value.serviceType) 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' })
|
if (!report.value.appointmentTime) return uni.showToast({ title: '请选择服务时间', icon: 'none' })
|
||||||
@ -386,13 +537,13 @@ const submitReport = async () => {
|
|||||||
|
|
||||||
// 构建图片列表
|
// 构建图片列表
|
||||||
const images = []
|
const images = []
|
||||||
report.value.beforeMedia.forEach((item, i) => {
|
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 })
|
images.push({ photoUrl: item.url, photoType: 'before', mediaType: item.mediaType || 'photo', sortOrder: i })
|
||||||
})
|
})
|
||||||
report.value.afterMedia.forEach((item, 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 })
|
images.push({ photoUrl: item.url, photoType: 'after', mediaType: item.mediaType || 'photo', sortOrder: i })
|
||||||
})
|
})
|
||||||
report.value.duringMedia.forEach((item, 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 })
|
images.push({ photoUrl: item.url, photoType: 'during', mediaType: item.mediaType || 'photo', sortOrder: i })
|
||||||
})
|
})
|
||||||
|
|
||||||
@ -615,6 +766,27 @@ const onRemarkBlur = () => {
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: 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 {
|
.photo-add {
|
||||||
width: calc(50% - 4px);
|
width: calc(50% - 4px);
|
||||||
aspect-ratio: 1;
|
aspect-ratio: 1;
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user