feat: 报告填写页新增服务过程中区块,支持照片+视频上传;报告展示页新增过程中媒体展示;前后端mediaType字段支持

This commit is contained in:
MaDaLei 2026-04-16 21:38:09 +08:00
parent a1b3780ab9
commit 525223be7f
3 changed files with 352 additions and 49 deletions

BIN
.DS_Store vendored

Binary file not shown.

View File

@ -142,6 +142,53 @@
</view> </view>
</view> </view>
<!-- 服务过程中照片+视频 -->
<view v-if="duringMedia.length > 0" class="section-gap">
<view class="during-section">
<view class="during-title">
<view class="during-title-dot"></view>
<text>服务过程中</text>
</view>
<!-- 照片 -->
<view v-if="duringPhotoItems.length > 0" class="during-group">
<view class="during-group-label">照片 {{ duringPhotoItems.length }} </view>
<view class="during-grid">
<view
v-for="(url, idx) in duringPhotoItems"
:key="'dp-' + idx"
class="during-thumb"
@click="previewPhotos('during-photo', idx)"
>
<image :src="imgUrl(url)" class="during-thumb-img" mode="aspectFill" />
</view>
</view>
</view>
<!-- 视频 -->
<view v-if="duringVideoItems.length > 0" class="during-group">
<view class="during-group-label">视频 {{ duringVideoItems.length }} </view>
<view class="during-grid">
<view
v-for="(url, idx) in duringVideoItems"
:key="'dv-' + idx"
class="during-thumb during-thumb--video"
@click="playVideo(imgUrl(url))"
>
<video
:src="imgUrl(url)"
class="during-thumb-img"
:show-center-play-btn="true"
:show-play-btn="true"
objectFit="cover"
:enable-progress-gesture="false"
/>
</view>
</view>
</view>
</view>
</view>
<!-- 宠主问候 --> <!-- 宠主问候 -->
<view v-if="!isStaff" class="greeting-card"> <view v-if="!isStaff" class="greeting-card">
<text class="greeting-text"> <text class="greeting-text">
@ -235,12 +282,24 @@ const isStaff = computed(() => isLoggedIn())
const beforePhotos = computed(() => { const beforePhotos = computed(() => {
if (!reportData.value?.beforePhotos) return [] 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(() => { const afterPhotos = computed(() => {
if (!reportData.value?.afterPhotos) return [] 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 beforePhotoUrls = computed(() => beforePhotos.value.map((item) => imgUrl(item)))
const afterPhotoUrls = computed(() => afterPhotos.value.map((item) => imgUrl(item))) const afterPhotoUrls = computed(() => afterPhotos.value.map((item) => imgUrl(item)))
const beforeMorePhotos = computed(() => beforePhotos.value.slice(1)) const beforeMorePhotos = computed(() => beforePhotos.value.slice(1))
@ -282,6 +341,13 @@ const loadReport = async () => {
else notFound.value = true 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 goHome = () => uni.reLaunch({ url: '/pages/home/Home' })
const callStore = () => { const callStore = () => {
@ -304,8 +370,12 @@ const navToStore = () => {
} }
const previewPhotos = (group, index = 0) => { const previewPhotos = (group, index = 0) => {
const urls = group === 'before' ? beforePhotoUrls.value : afterPhotoUrls.value let urls
if (!urls.length) return 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))] const current = urls[Math.max(0, Math.min(index, urls.length - 1))]
uni.previewImage({ uni.previewImage({
urls, urls,
@ -993,4 +1063,55 @@ onMounted(() => loadReport())
margin-top: 4px; margin-top: 4px;
letter-spacing: 0.5px; letter-spacing: 0.5px;
} }
/* 过程中区块 */
.section-gap {
padding: 0 16px;
}
.during-section {
background: #fff;
border-radius: 16px;
padding: 18px 18px 4px;
}
.during-title {
display: flex;
align-items: center;
gap: 8px;
font-size: 15px;
font-weight: 700;
color: #1a1a1a;
margin-bottom: 14px;
}
.during-title-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: #7c5ce9;
}
.during-group {
margin-bottom: 14px;
}
.during-group-label {
font-size: 12px;
font-weight: 600;
color: #999993;
margin-bottom: 8px;
}
.during-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 8px;
}
.during-thumb {
position: relative;
aspect-ratio: 1;
border-radius: 10px;
overflow: hidden;
background: #f0f0ee;
}
.during-thumb-img {
width: 100%;
height: 100%;
display: block;
}
</style> </style>

View File

@ -62,11 +62,11 @@
<text>服务前</text> <text>服务前</text>
</view> </view>
<view class="photo-grid"> <view class="photo-grid">
<view v-for="(img, idx) in report.before" :key="'b'+idx" class="photo-thumb"> <view v-for="(item, idx) in report.beforeMedia" :key="'b'+idx" class="photo-thumb">
<image :src="img" class="thumb-img" mode="aspectFill" /> <image :src="item.url" class="thumb-img" mode="aspectFill" />
<view class="thumb-del" @click="removePhoto('before', idx)"></view> <view class="thumb-del" @click="removeMedia('beforeMedia', idx)"></view>
</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" /> <AppIcon name="camera" :size="20" color="#bbbbb5" />
</view> </view>
</view> </view>
@ -80,11 +80,11 @@
<text>服务后</text> <text>服务后</text>
</view> </view>
<view class="photo-grid"> <view class="photo-grid">
<view v-for="(img, idx) in report.after" :key="'a'+idx" class="photo-thumb"> <view v-for="(item, idx) in report.afterMedia" :key="'a'+idx" class="photo-thumb">
<image :src="img" class="thumb-img" mode="aspectFill" /> <image :src="item.url" class="thumb-img" mode="aspectFill" />
<view class="thumb-del" @click="removePhoto('after', idx)"></view> <view class="thumb-del" @click="removeMedia('afterMedia', idx)"></view>
</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" /> <AppIcon name="camera" :size="20" color="#bbbbb5" />
</view> </view>
</view> </view>
@ -92,6 +92,48 @@
</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-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">
<view class="section-head"> <view class="section-head">
@ -145,7 +187,17 @@ const navSafeStyle = (() => {
return `padding-top:${statusBarHeight}px;height:${navHeight}px;` 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 serviceTypes = ref([])
const currentAppointmentId = ref(null) const currentAppointmentId = ref(null)
const submitting = ref(false) const submitting = ref(false)
@ -160,43 +212,103 @@ const loadServiceTypes = async () => {
if (res.code === 200) serviceTypes.value = res.data 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' }) * @param {string} field - beforeMedia | afterMedia | duringMedia
uni.chooseImage({ * @param {string} mediaType - photo | video
count: 4 - currentCount, */
success: (res) => { const uploadMedia = (field, mediaType) => {
const tempPaths = res.tempFilePaths const maxTotal = 12 // 12
uni.showLoading({ title: '上传中...' }) if (report.value[field].length >= maxTotal) {
let failCount = 0 return uni.showToast({ title: `最多上传${maxTotal}`, icon: 'none' })
const tasks = tempPaths.map(tempPath => { }
return new Promise((resolve) => { const remain = maxTotal - report.value[field].length
uni.uploadFile({
url: `${BASE_URL}/upload/image`, if (mediaType === 'video') {
filePath: tempPath, // /
name: 'file', uni.chooseMedia({
success: (uploadRes) => { count: Math.min(remain, 4),
try { mediaType: ['video'],
const data = JSON.parse(uploadRes.data) success: (res) => {
if (data.code === 200) { const tempFiles = res.tempFiles
report.value[field].push(imgUrl(data.data.url)) uni.showLoading({ title: '上传中...' })
} else { failCount++ } let failCount = 0
} catch (e) { failCount++ } 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() resolve()
}, return
fail: () => { failCount++; resolve() } }
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(() => {
Promise.all(tasks).then(() => { uni.hideLoading()
uni.hideLoading() if (failCount > 0) uni.showToast({ title: `${failCount}个上传失败`, icon: 'none' })
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) => { const onServiceConfirm = (e) => {
report.value.serviceType = serviceColumns.value[e.detail.value].value 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.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' })
submitting.value = true submitting.value = true
// //
const images = [] const images = []
report.value.before.forEach((url, i) => { report.value.beforeMedia.forEach((item, i) => {
images.push({ photoUrl: url, photoType: 'before', sortOrder: i }) images.push({ photoUrl: item.url, photoType: 'before', mediaType: item.mediaType || 'photo', sortOrder: i })
}) })
report.value.after.forEach((url, i) => { report.value.afterMedia.forEach((item, i) => {
images.push({ photoUrl: url, photoType: 'after', sortOrder: 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 = { const payload = {
appointmentId: currentAppointmentId.value || null, appointmentId: currentAppointmentId.value || null,
userId: userInfo.id, userId: userInfo.id,
@ -255,13 +372,14 @@ const submitReport = async () => {
remark: report.value.remark, remark: report.value.remark,
images images
} }
const res = await createReport(payload) const res = await createReport(payload)
submitting.value = false submitting.value = false
if (res.code === 200) { if (res.code === 200) {
const token = res.data.reportToken const token = res.data.reportToken
uni.showToast({ title: '已生成报告', icon: 'success' }) uni.showToast({ title: '已生成报告', icon: 'success' })
setTimeout(() => { setTimeout(() => {
report.value = { petName: '', serviceType: '', appointmentTime: '', before: [], after: [], remark: '' } report.value = { petName: '', serviceType: '', appointmentTime: '', beforeMedia: [], afterMedia: [], duringMedia: [], remark: '' }
currentAppointmentId.value = null currentAppointmentId.value = null
uni.navigateTo({ url: `/pages/report-view/reportView?token=${token}` }) uni.navigateTo({ url: `/pages/report-view/reportView?token=${token}` })
}, 800) }, 800)
@ -423,6 +541,7 @@ onMounted(async () => {
aspect-ratio: 1; aspect-ratio: 1;
border-radius: 10px; border-radius: 10px;
overflow: hidden; overflow: hidden;
background: #f0f0ee;
} }
.thumb-img { .thumb-img {
width: 100%; width: 100%;
@ -453,6 +572,69 @@ onMounted(async () => {
background: #fafaf8; 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 { .form-textarea {
background: #f5f5f2; background: #f5f5f2;