feat: 服务回顾短片功能上线

This commit is contained in:
MaDaLei 2026-04-17 18:58:04 +08:00
parent 0a41ea62cd
commit 26da03f739
2 changed files with 229 additions and 11 deletions

View File

@ -83,6 +83,9 @@ export const cancelAppointment = (id) => put(`/appointment/status?id=${id}&statu
// 提交报告 // 提交报告
export const createReport = (data) => post('/report/create', data) export const createReport = (data) => post('/report/create', data)
/** 触发服务回顾短片生成异步reportId + 操作者durationSec: 15 | 30 */
export const postReportHighlightStart = (data) => post('/report/highlight/start', data)
// 获取报告通过token // 获取报告通过token
export const getReportByToken = (token) => get('/report/get', { token }) export const getReportByToken = (token) => get('/report/get', { token })

View File

@ -223,6 +223,47 @@
<text class="remark-text">{{ reportData.remark }}</text> <text class="remark-text">{{ reportData.remark }}</text>
</view> </view>
<!-- 服务回顾短片素材智能剪辑后端可扩展接入生成式 AI 视频 -->
<view v-if="hasHighlightSource" class="highlight-card">
<view class="highlight-head">
<text class="highlight-title">服务回顾短片</text>
<text class="highlight-badge">智能剪辑</text>
</view>
<text class="highlight-desc">
服务前 过程 服务后顺序将照片与短视频自动合成竖屏 9:16 成片15 秒或 30 门店服务器需已安装 ffmpeg若需大模型生成式成片可在后端接入对应云 API
</text>
<view v-if="highlightStatus === 'processing'" class="highlight-status highlight-status--busy">
<text>短片生成中请稍候</text>
</view>
<view v-else-if="highlightStatus === 'failed'" class="highlight-status highlight-status--err">
<text>{{ reportData.highlightVideoError || '生成失败' }}</text>
</view>
<video
v-if="highlightReady"
class="highlight-player"
:src="imgUrl(reportData.highlightVideoUrl)"
controls
:show-center-play-btn="true"
objectFit="contain"
/>
<view v-if="canTriggerHighlight" class="highlight-actions">
<button
class="highlight-btn"
:disabled="highlightStatus === 'processing'"
@click="startHighlight(15)"
>
生成 15
</button>
<button
class="highlight-btn highlight-btn--secondary"
:disabled="highlightStatus === 'processing'"
@click="startHighlight(30)"
>
生成 30
</button>
</view>
</view>
<!-- 操作区 --> <!-- 操作区 -->
<view class="actions"> <view class="actions">
<template v-if="isStaff"> <template v-if="isStaff">
@ -264,13 +305,13 @@
</template> </template>
<script setup> <script setup>
import { ref, computed, onMounted, getCurrentInstance, nextTick } from 'vue' import { ref, computed, onMounted, onUnmounted, getCurrentInstance, nextTick } from 'vue'
import { onLoad, onShareAppMessage, onShareTimeline } from '@dcloudio/uni-app' import { onLoad, onShareAppMessage, onShareTimeline } from '@dcloudio/uni-app'
import { getReportByToken, imgUrl } from '../../api/index.js' import { getReportByToken, imgUrl, postReportHighlightStart } from '../../api/index.js'
import { formatDateTimeYMDHM } from '../../utils/datetime.js' import { formatDateTimeYMDHM } from '../../utils/datetime.js'
import { drawReportPoster, POSTER_W, computePosterLogicalHeight } from '../../utils/reportPosterDraw.js' import { drawReportPoster, POSTER_W, computePosterLogicalHeight } from '../../utils/reportPosterDraw.js'
import AppIcon from '../../components/AppIcon.vue' import AppIcon from '../../components/AppIcon.vue'
import { isLoggedIn } from '../../utils/session.js' import { getUserSession, isLoggedIn } from '../../utils/session.js'
const loading = ref(true) const loading = ref(true)
const notFound = ref(false) const notFound = ref(false)
@ -280,6 +321,96 @@ const routeToken = ref('')
const isStaff = computed(() => isLoggedIn()) const isStaff = computed(() => isLoggedIn())
const hasHighlightSource = computed(() => {
const r = reportData.value
if (!r) return false
const b = r.beforePhotos?.length || 0
const a = r.afterPhotos?.length || 0
const d = r.duringMedia?.length || 0
return b + a + d > 0
})
const highlightStatus = computed(() => reportData.value?.highlightVideoStatus || '')
const highlightReady = computed(
() => highlightStatus.value === 'done' && !!reportData.value?.highlightVideoUrl
)
/** 仅店长/员工在登录态下可发起合成(宠主仅查看已生成短片) */
const canTriggerHighlight = computed(() => {
const u = getUserSession()
const role = u?.role
return !!(u?.id && (role === 'boss' || role === 'staff'))
})
let highlightPollTimer = null
const stopHighlightPoll = () => {
if (highlightPollTimer != null) {
clearInterval(highlightPollTimer)
highlightPollTimer = null
}
}
const getRouteToken = () => {
let token = routeToken.value
// #ifdef H5
token = new URLSearchParams(window.location.search).get('token') || token
// #endif
return token || ''
}
const maybeStartHighlightPoll = () => {
stopHighlightPoll()
if (reportData.value?.highlightVideoStatus !== 'processing') return
highlightPollTimer = setInterval(async () => {
const t = getRouteToken()
if (!t) {
stopHighlightPoll()
return
}
const res = await getReportByToken(t)
if (res.code === 200 && res.data) {
reportData.value = res.data
if (res.data.highlightVideoStatus !== 'processing') {
stopHighlightPoll()
if (res.data.highlightVideoStatus === 'done') {
uni.showToast({ title: '短片已生成', icon: 'success' })
}
}
}
}, 2500)
}
const startHighlight = async (durationSec) => {
const u = getUserSession()
if (!reportData.value?.id || !u?.id) {
uni.showToast({ title: '请先登录门店账号', icon: 'none' })
return
}
uni.showLoading({ title: '提交中' })
const res = await postReportHighlightStart({
reportId: reportData.value.id,
durationSec,
operatorUserId: u.id,
role: u.role || 'staff'
})
uni.hideLoading()
if (res.code === 200) {
uni.showToast({ title: res.message || '已开始生成', icon: 'none' })
reportData.value = { ...reportData.value, highlightVideoStatus: 'processing' }
maybeStartHighlightPoll()
} else if (res.code === 503) {
uni.showModal({
title: '暂无法生成',
content: res.message || '服务器未配置 ffmpeg',
showCancel: false
})
} else {
uni.showToast({ title: res.message || '发起失败', icon: 'none' })
}
}
const beforePhotos = computed(() => { const beforePhotos = computed(() => {
if (!reportData.value?.beforePhotos) return [] if (!reportData.value?.beforePhotos) return []
// string[] {url, mediaType}[] // string[] {url, mediaType}[]
@ -327,17 +458,22 @@ const brandBarSafe = (() => {
const formatTime = (time) => formatDateTimeYMDHM(time) const formatTime = (time) => formatDateTimeYMDHM(time)
const loadReport = async () => { const loadReport = async () => {
let token = routeToken.value const token = getRouteToken()
// #ifdef H5 if (!token) {
token = new URLSearchParams(window.location.search).get('token') notFound.value = true
// #endif loading.value = false
if (!token) { notFound.value = true; loading.value = false; return } return
}
uni.showLoading({ title: '加载中' }) uni.showLoading({ title: '加载中' })
const res = await getReportByToken(token) const res = await getReportByToken(token)
uni.hideLoading() uni.hideLoading()
loading.value = false loading.value = false
if (res.code === 200) reportData.value = res.data if (res.code === 200) {
else notFound.value = true reportData.value = res.data
maybeStartHighlightPoll()
} else {
notFound.value = true
}
} }
const goHome = () => uni.reLaunch({ url: '/pages/home/Home' }) const goHome = () => uni.reLaunch({ url: '/pages/home/Home' })
@ -645,8 +781,11 @@ const generatePoster = () => {
// #endif // #endif
// #endif // #endif
onLoad((options) => { routeToken.value = options?.token || '' }) onLoad((options) => {
routeToken.value = options?.token || ''
})
onMounted(() => loadReport()) onMounted(() => loadReport())
onUnmounted(() => stopHighlightPoll())
</script> </script>
<style scoped> <style scoped>
@ -1016,6 +1155,82 @@ onMounted(() => loadReport())
line-height: 1.6; line-height: 1.6;
} }
/* 服务回顾短片 */
.highlight-card {
margin: 0 16px 16px;
padding: 16px;
background: linear-gradient(145deg, #f3fff9 0%, #eef8ff 100%);
border-radius: 14px;
border: 1px solid #dcefe3;
}
.highlight-head {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 8px;
}
.highlight-title {
font-size: 16px;
font-weight: 700;
color: #14532d;
}
.highlight-badge {
font-size: 11px;
font-weight: 700;
color: #0f766e;
background: rgba(45, 185, 109, 0.15);
padding: 3px 10px;
border-radius: 999px;
}
.highlight-desc {
display: block;
font-size: 12px;
color: #4b5563;
line-height: 1.55;
margin-bottom: 12px;
}
.highlight-status {
padding: 10px 12px;
border-radius: 10px;
font-size: 13px;
margin-bottom: 10px;
}
.highlight-status--busy {
background: rgba(45, 185, 109, 0.12);
color: #166534;
}
.highlight-status--err {
background: rgba(239, 68, 68, 0.1);
color: #b91c1c;
}
.highlight-player {
width: 100%;
max-height: 420px;
border-radius: 12px;
background: #000;
margin-bottom: 12px;
}
.highlight-actions {
display: flex;
gap: 10px;
}
.highlight-btn {
flex: 1;
font-size: 14px;
font-weight: 600;
padding: 10px 8px;
border-radius: 10px;
border: none;
background: #2db96d;
color: #fff;
}
.highlight-btn--secondary {
background: #0d9488;
}
.highlight-btn[disabled] {
opacity: 0.55;
}
/* 操作按钮 */ /* 操作按钮 */
.actions { .actions {
padding: 0 16px 16px; padding: 0 16px 16px;