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)
|
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 })
|
||||||
|
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user