feat: 服务回顾短片功能上线
This commit is contained in:
parent
0a41ea62cd
commit
26da03f739
@ -83,6 +83,9 @@ export const cancelAppointment = (id) => put(`/appointment/status?id=${id}&statu
|
||||
// 提交报告
|
||||
export const createReport = (data) => post('/report/create', data)
|
||||
|
||||
/** 触发服务回顾短片生成(异步;reportId + 操作者)durationSec: 15 | 30 */
|
||||
export const postReportHighlightStart = (data) => post('/report/highlight/start', data)
|
||||
|
||||
// 获取报告(通过token)
|
||||
export const getReportByToken = (token) => get('/report/get', { token })
|
||||
|
||||
|
||||
@ -223,6 +223,47 @@
|
||||
<text class="remark-text">{{ reportData.remark }}</text>
|
||||
</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">
|
||||
<template v-if="isStaff">
|
||||
@ -264,13 +305,13 @@
|
||||
</template>
|
||||
|
||||
<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 { getReportByToken, imgUrl } from '../../api/index.js'
|
||||
import { getReportByToken, imgUrl, postReportHighlightStart } from '../../api/index.js'
|
||||
import { formatDateTimeYMDHM } from '../../utils/datetime.js'
|
||||
import { drawReportPoster, POSTER_W, computePosterLogicalHeight } from '../../utils/reportPosterDraw.js'
|
||||
import AppIcon from '../../components/AppIcon.vue'
|
||||
import { isLoggedIn } from '../../utils/session.js'
|
||||
import { getUserSession, isLoggedIn } from '../../utils/session.js'
|
||||
|
||||
const loading = ref(true)
|
||||
const notFound = ref(false)
|
||||
@ -280,6 +321,96 @@ const routeToken = ref('')
|
||||
|
||||
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(() => {
|
||||
if (!reportData.value?.beforePhotos) return []
|
||||
// 支持新旧两种格式:旧版是 string[],新版是 {url, mediaType}[]
|
||||
@ -327,17 +458,22 @@ const brandBarSafe = (() => {
|
||||
const formatTime = (time) => formatDateTimeYMDHM(time)
|
||||
|
||||
const loadReport = async () => {
|
||||
let token = routeToken.value
|
||||
// #ifdef H5
|
||||
token = new URLSearchParams(window.location.search).get('token')
|
||||
// #endif
|
||||
if (!token) { notFound.value = true; loading.value = false; return }
|
||||
const token = getRouteToken()
|
||||
if (!token) {
|
||||
notFound.value = true
|
||||
loading.value = false
|
||||
return
|
||||
}
|
||||
uni.showLoading({ title: '加载中' })
|
||||
const res = await getReportByToken(token)
|
||||
uni.hideLoading()
|
||||
loading.value = false
|
||||
if (res.code === 200) reportData.value = res.data
|
||||
else notFound.value = true
|
||||
if (res.code === 200) {
|
||||
reportData.value = res.data
|
||||
maybeStartHighlightPoll()
|
||||
} else {
|
||||
notFound.value = true
|
||||
}
|
||||
}
|
||||
|
||||
const goHome = () => uni.reLaunch({ url: '/pages/home/Home' })
|
||||
@ -645,8 +781,11 @@ const generatePoster = () => {
|
||||
// #endif
|
||||
// #endif
|
||||
|
||||
onLoad((options) => { routeToken.value = options?.token || '' })
|
||||
onLoad((options) => {
|
||||
routeToken.value = options?.token || ''
|
||||
})
|
||||
onMounted(() => loadReport())
|
||||
onUnmounted(() => stopHighlightPoll())
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@ -1016,6 +1155,82 @@ onMounted(() => loadReport())
|
||||
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 {
|
||||
padding: 0 16px 16px;
|
||||
|
||||
Loading…
Reference in New Issue
Block a user