feat: 报告填写页新增服务过程中区块,支持照片+视频上传;报告展示页新增过程中媒体展示;前后端mediaType字段支持
This commit is contained in:
parent
a1b3780ab9
commit
525223be7f
@ -142,6 +142,53 @@
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 服务过程中(照片+视频) -->
|
||||
<view v-if="duringMedia.length > 0" class="section-gap">
|
||||
<view class="during-section">
|
||||
<view class="during-title">
|
||||
<view class="during-title-dot"></view>
|
||||
<text>服务过程中</text>
|
||||
</view>
|
||||
|
||||
<!-- 照片 -->
|
||||
<view v-if="duringPhotoItems.length > 0" class="during-group">
|
||||
<view class="during-group-label">照片 {{ duringPhotoItems.length }} 张</view>
|
||||
<view class="during-grid">
|
||||
<view
|
||||
v-for="(url, idx) in duringPhotoItems"
|
||||
:key="'dp-' + idx"
|
||||
class="during-thumb"
|
||||
@click="previewPhotos('during-photo', idx)"
|
||||
>
|
||||
<image :src="imgUrl(url)" class="during-thumb-img" mode="aspectFill" />
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 视频 -->
|
||||
<view v-if="duringVideoItems.length > 0" class="during-group">
|
||||
<view class="during-group-label">视频 {{ duringVideoItems.length }} 个</view>
|
||||
<view class="during-grid">
|
||||
<view
|
||||
v-for="(url, idx) in duringVideoItems"
|
||||
:key="'dv-' + idx"
|
||||
class="during-thumb during-thumb--video"
|
||||
@click="playVideo(imgUrl(url))"
|
||||
>
|
||||
<video
|
||||
:src="imgUrl(url)"
|
||||
class="during-thumb-img"
|
||||
:show-center-play-btn="true"
|
||||
:show-play-btn="true"
|
||||
objectFit="cover"
|
||||
:enable-progress-gesture="false"
|
||||
/>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 宠主问候 -->
|
||||
<view v-if="!isStaff" class="greeting-card">
|
||||
<text class="greeting-text">
|
||||
@ -235,12 +282,24 @@ const isStaff = computed(() => isLoggedIn())
|
||||
|
||||
const beforePhotos = computed(() => {
|
||||
if (!reportData.value?.beforePhotos) return []
|
||||
return reportData.value.beforePhotos
|
||||
// 支持新旧两种格式:旧版是 string[],新版是 {url, mediaType}[]
|
||||
const raw = reportData.value.beforePhotos
|
||||
if (raw.length === 0) return []
|
||||
if (typeof raw[0] === 'string') return raw
|
||||
return raw.map(item => item.url)
|
||||
})
|
||||
const afterPhotos = computed(() => {
|
||||
if (!reportData.value?.afterPhotos) return []
|
||||
return reportData.value.afterPhotos
|
||||
const raw = reportData.value.afterPhotos
|
||||
if (raw.length === 0) return []
|
||||
if (typeof raw[0] === 'string') return raw
|
||||
return raw.map(item => item.url)
|
||||
})
|
||||
const duringMedia = computed(() => {
|
||||
return reportData.value?.duringMedia || []
|
||||
})
|
||||
const duringPhotoItems = computed(() => duringMedia.value.filter(i => i.mediaType !== 'video').map(i => i.url))
|
||||
const duringVideoItems = computed(() => duringMedia.value.filter(i => i.mediaType === 'video').map(i => i.url))
|
||||
const beforePhotoUrls = computed(() => beforePhotos.value.map((item) => imgUrl(item)))
|
||||
const afterPhotoUrls = computed(() => afterPhotos.value.map((item) => imgUrl(item)))
|
||||
const beforeMorePhotos = computed(() => beforePhotos.value.slice(1))
|
||||
@ -282,6 +341,13 @@ const loadReport = async () => {
|
||||
else notFound.value = true
|
||||
}
|
||||
|
||||
const playVideo = (url) => {
|
||||
if (!url) return
|
||||
uni.navigateTo({
|
||||
url: `/pages/video-player/videoPlayer?url=${encodeURIComponent(url)}`}
|
||||
})
|
||||
}
|
||||
|
||||
const goHome = () => uni.reLaunch({ url: '/pages/home/Home' })
|
||||
|
||||
const callStore = () => {
|
||||
@ -304,8 +370,12 @@ const navToStore = () => {
|
||||
}
|
||||
|
||||
const previewPhotos = (group, index = 0) => {
|
||||
const urls = group === 'before' ? beforePhotoUrls.value : afterPhotoUrls.value
|
||||
if (!urls.length) return
|
||||
let urls
|
||||
if (group === 'before') urls = beforePhotoUrls.value
|
||||
else if (group === 'after') urls = afterPhotoUrls.value
|
||||
else if (group === 'during-photo') urls = duringPhotoItems.value.map(imgUrl)
|
||||
else if (group === 'during-video') urls = duringVideoItems.value.map(imgUrl)
|
||||
if (!urls || !urls.length) return
|
||||
const current = urls[Math.max(0, Math.min(index, urls.length - 1))]
|
||||
uni.previewImage({
|
||||
urls,
|
||||
@ -993,4 +1063,55 @@ onMounted(() => loadReport())
|
||||
margin-top: 4px;
|
||||
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>
|
||||
|
||||
@ -62,11 +62,11 @@
|
||||
<text>服务前</text>
|
||||
</view>
|
||||
<view class="photo-grid">
|
||||
<view v-for="(img, idx) in report.before" :key="'b'+idx" class="photo-thumb">
|
||||
<image :src="img" class="thumb-img" mode="aspectFill" />
|
||||
<view class="thumb-del" @click="removePhoto('before', idx)">✕</view>
|
||||
<view v-for="(item, idx) in report.beforeMedia" :key="'b'+idx" class="photo-thumb">
|
||||
<image :src="item.url" class="thumb-img" mode="aspectFill" />
|
||||
<view class="thumb-del" @click="removeMedia('beforeMedia', idx)">✕</view>
|
||||
</view>
|
||||
<view v-if="report.before.length < 4" class="photo-add" @click="uploadPhoto('before')">
|
||||
<view v-if="report.beforeMedia.length < 4" class="photo-add" @click="uploadMedia('beforeMedia', 'photo')">
|
||||
<AppIcon name="camera" :size="20" color="#bbbbb5" />
|
||||
</view>
|
||||
</view>
|
||||
@ -80,11 +80,11 @@
|
||||
<text>服务后</text>
|
||||
</view>
|
||||
<view class="photo-grid">
|
||||
<view v-for="(img, idx) in report.after" :key="'a'+idx" class="photo-thumb">
|
||||
<image :src="img" class="thumb-img" mode="aspectFill" />
|
||||
<view class="thumb-del" @click="removePhoto('after', idx)">✕</view>
|
||||
<view v-for="(item, idx) in report.afterMedia" :key="'a'+idx" class="photo-thumb">
|
||||
<image :src="item.url" class="thumb-img" mode="aspectFill" />
|
||||
<view class="thumb-del" @click="removeMedia('afterMedia', idx)">✕</view>
|
||||
</view>
|
||||
<view v-if="report.after.length < 4" class="photo-add" @click="uploadPhoto('after')">
|
||||
<view v-if="report.afterMedia.length < 4" class="photo-add" @click="uploadMedia('afterMedia', 'photo')">
|
||||
<AppIcon name="camera" :size="20" color="#bbbbb5" />
|
||||
</view>
|
||||
</view>
|
||||
@ -92,6 +92,48 @@
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 服务过程中 -->
|
||||
<view class="section">
|
||||
<view class="section-head">
|
||||
<text class="section-label">服务过程中</text>
|
||||
<text class="section-hint">照片或视频,记录服务细节</text>
|
||||
</view>
|
||||
|
||||
<view class="during-actions">
|
||||
<view class="during-action-btn" @click="uploadMedia('duringMedia', 'photo')">
|
||||
<AppIcon name="camera" :size="18" color="#666660" />
|
||||
<text>上传照片</text>
|
||||
</view>
|
||||
<view class="during-action-btn during-action-btn--video" @click="uploadMedia('duringMedia', 'video')">
|
||||
<AppIcon name="video" :size="18" color="#666660" />
|
||||
<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 class="thumb-del" @click="removeMedia('duringMedia', idx)">✕</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view v-if="report.duringMedia.length === 0" class="during-empty">
|
||||
<text>暂无过程中记录</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 备注 -->
|
||||
<view class="section">
|
||||
<view class="section-head">
|
||||
@ -145,7 +187,17 @@ const navSafeStyle = (() => {
|
||||
return `padding-top:${statusBarHeight}px;height:${navHeight}px;`
|
||||
})()
|
||||
|
||||
const report = ref({ petName: '', serviceType: '', appointmentTime: '', before: [], after: [], remark: '' })
|
||||
// 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)
|
||||
@ -160,43 +212,103 @@ const loadServiceTypes = async () => {
|
||||
if (res.code === 200) serviceTypes.value = res.data
|
||||
}
|
||||
|
||||
const uploadPhoto = (field) => {
|
||||
const currentCount = report.value[field].length
|
||||
if (currentCount >= 4) return uni.showToast({ title: '最多上传4张', icon: 'none' })
|
||||
uni.chooseImage({
|
||||
count: 4 - currentCount,
|
||||
success: (res) => {
|
||||
const tempPaths = res.tempFilePaths
|
||||
uni.showLoading({ title: '上传中...' })
|
||||
let failCount = 0
|
||||
const tasks = tempPaths.map(tempPath => {
|
||||
return new Promise((resolve) => {
|
||||
uni.uploadFile({
|
||||
url: `${BASE_URL}/upload/image`,
|
||||
filePath: tempPath,
|
||||
name: 'file',
|
||||
success: (uploadRes) => {
|
||||
try {
|
||||
const data = JSON.parse(uploadRes.data)
|
||||
if (data.code === 200) {
|
||||
report.value[field].push(imgUrl(data.data.url))
|
||||
} else { failCount++ }
|
||||
} catch (e) { failCount++ }
|
||||
/**
|
||||
* 上传媒体文件
|
||||
* @param {string} field - beforeMedia | afterMedia | duringMedia
|
||||
* @param {string} mediaType - photo | video
|
||||
*/
|
||||
const uploadMedia = (field, mediaType) => {
|
||||
const maxTotal = 12 // 全局最多12条
|
||||
if (report.value[field].length >= maxTotal) {
|
||||
return uni.showToast({ title: `最多上传${maxTotal}个`, icon: 'none' })
|
||||
}
|
||||
const remain = maxTotal - report.value[field].length
|
||||
|
||||
if (mediaType === 'video') {
|
||||
// 拍摄/选择视频
|
||||
uni.chooseMedia({
|
||||
count: Math.min(remain, 4),
|
||||
mediaType: ['video'],
|
||||
success: (res) => {
|
||||
const tempFiles = res.tempFiles
|
||||
uni.showLoading({ title: '上传中...' })
|
||||
let failCount = 0
|
||||
const tasks = tempFiles.map(f => {
|
||||
return new Promise((resolve) => {
|
||||
const sizeMB = (f.size || 0) / 1024 / 1024
|
||||
if (sizeMB > 100) {
|
||||
failCount++
|
||||
uni.showToast({ title: '视频不能超过100MB', icon: 'none' })
|
||||
resolve()
|
||||
},
|
||||
fail: () => { failCount++; resolve() }
|
||||
return
|
||||
}
|
||||
uni.uploadFile({
|
||||
url: `${BASE_URL}/upload/image`,
|
||||
filePath: f.tempFilePath,
|
||||
name: 'file',
|
||||
success: (uploadRes) => {
|
||||
try {
|
||||
const data = JSON.parse(uploadRes.data)
|
||||
if (data.code === 200) {
|
||||
report.value[field].push({ url: imgUrl(data.data.url), mediaType: 'video' })
|
||||
} else {
|
||||
failCount++
|
||||
}
|
||||
} catch (e) { failCount++ }
|
||||
resolve()
|
||||
},
|
||||
fail: () => { failCount++; resolve() }
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
Promise.all(tasks).then(() => {
|
||||
uni.hideLoading()
|
||||
if (failCount > 0) uni.showToast({ title: `${failCount}张上传失败`, icon: 'none' })
|
||||
})
|
||||
Promise.all(tasks).then(() => {
|
||||
uni.hideLoading()
|
||||
if (failCount > 0) uni.showToast({ title: `${failCount}个上传失败`, icon: 'none' })
|
||||
})
|
||||
}
|
||||
})
|
||||
} else {
|
||||
// 上传照片
|
||||
const fieldMax = 4
|
||||
if (report.value[field].length >= fieldMax) {
|
||||
return uni.showToast({ title: '最多上传4张', icon: 'none' })
|
||||
}
|
||||
})
|
||||
const count = Math.min(fieldMax - report.value[field].length, remain, 4)
|
||||
uni.chooseImage({
|
||||
count,
|
||||
success: (res) => {
|
||||
const tempPaths = res.tempFilePaths
|
||||
uni.showLoading({ title: '上传中...' })
|
||||
let failCount = 0
|
||||
const tasks = tempPaths.map(tempPath => {
|
||||
return new Promise((resolve) => {
|
||||
uni.uploadFile({
|
||||
url: `${BASE_URL}/upload/image`,
|
||||
filePath: tempPath,
|
||||
name: 'file',
|
||||
success: (uploadRes) => {
|
||||
try {
|
||||
const data = JSON.parse(uploadRes.data)
|
||||
if (data.code === 200) {
|
||||
report.value[field].push({ url: imgUrl(data.data.url), mediaType: 'photo' })
|
||||
} else { failCount++ }
|
||||
} catch (e) { failCount++ }
|
||||
resolve()
|
||||
},
|
||||
fail: () => { failCount++; resolve() }
|
||||
})
|
||||
})
|
||||
})
|
||||
Promise.all(tasks).then(() => {
|
||||
uni.hideLoading()
|
||||
if (failCount > 0) uni.showToast({ title: `${failCount}张上传失败`, icon: 'none' })
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const removePhoto = (field, idx) => { report.value[field].splice(idx, 1) }
|
||||
const removeMedia = (field, idx) => { report.value[field].splice(idx, 1) }
|
||||
|
||||
const onServiceConfirm = (e) => {
|
||||
report.value.serviceType = serviceColumns.value[e.detail.value].value
|
||||
@ -238,14 +350,19 @@ const submitReport = async () => {
|
||||
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.before.forEach((url, i) => {
|
||||
images.push({ photoUrl: url, photoType: 'before', sortOrder: i })
|
||||
report.value.beforeMedia.forEach((item, i) => {
|
||||
images.push({ photoUrl: item.url, photoType: 'before', mediaType: item.mediaType || 'photo', sortOrder: i })
|
||||
})
|
||||
report.value.after.forEach((url, i) => {
|
||||
images.push({ photoUrl: url, photoType: 'after', sortOrder: i })
|
||||
report.value.afterMedia.forEach((item, i) => {
|
||||
images.push({ photoUrl: item.url, photoType: 'after', mediaType: item.mediaType || 'photo', sortOrder: i })
|
||||
})
|
||||
report.value.duringMedia.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,
|
||||
@ -255,13 +372,14 @@ const submitReport = async () => {
|
||||
remark: report.value.remark,
|
||||
images
|
||||
}
|
||||
|
||||
const res = await createReport(payload)
|
||||
submitting.value = false
|
||||
if (res.code === 200) {
|
||||
const token = res.data.reportToken
|
||||
uni.showToast({ title: '已生成报告', icon: 'success' })
|
||||
setTimeout(() => {
|
||||
report.value = { petName: '', serviceType: '', appointmentTime: '', before: [], after: [], remark: '' }
|
||||
report.value = { petName: '', serviceType: '', appointmentTime: '', beforeMedia: [], afterMedia: [], duringMedia: [], remark: '' }
|
||||
currentAppointmentId.value = null
|
||||
uni.navigateTo({ url: `/pages/report-view/reportView?token=${token}` })
|
||||
}, 800)
|
||||
@ -423,6 +541,7 @@ onMounted(async () => {
|
||||
aspect-ratio: 1;
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
background: #f0f0ee;
|
||||
}
|
||||
.thumb-img {
|
||||
width: 100%;
|
||||
@ -453,6 +572,69 @@ onMounted(async () => {
|
||||
background: #fafaf8;
|
||||
}
|
||||
|
||||
/* 过程中区块 */
|
||||
.during-actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
.during-action-btn {
|
||||
flex: 1;
|
||||
height: 42px;
|
||||
border-radius: 12px;
|
||||
border: 1.5px solid #ebebea;
|
||||
background: #fafaf8;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #666660;
|
||||
}
|
||||
.during-action-btn:active {
|
||||
background: #f0f0ee;
|
||||
}
|
||||
.during-action-btn--video {
|
||||
border-color: #e0d8f5;
|
||||
background: #f8f5ff;
|
||||
}
|
||||
.during-action-btn--video:active {
|
||||
background: #f0ecff;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
.during-empty {
|
||||
padding: 16px 0 4px;
|
||||
text-align: center;
|
||||
font-size: 13px;
|
||||
color: #bbbbb5;
|
||||
}
|
||||
|
||||
/* 备注 */
|
||||
.form-textarea {
|
||||
background: #f5f5f2;
|
||||
|
||||
Loading…
Reference in New Issue
Block a user