petstore-frontend/src/components/report/ReportHighlightBlock.vue
2026-04-18 18:44:41 +08:00

526 lines
15 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<template>
<view class="highlight-card">
<view class="highlight-head">
<text class="highlight-title">服务回顾短片</text>
<text class="highlight-badge">智能剪辑</text>
</view>
<text class="highlight-desc">
将服务前过程服务后素材自动剪成竖屏 9:16 短片<text class="highlight-em">成片时长随素材量变化</text>并受门店配置的总时长上限约束<text class="highlight-em">不提供固定 15 / 30 选项</text>请先在下方选择编排方式再点生成短片
</text>
<text v-if="canTriggerHighlight" class="highlight-desc-ops">
「编排」指素材拼接顺序:预设顺序含对比封面与收尾;穿插成片在阶段间均匀穿插照片与小视频。可选配乐由门店在服务端配置。
</text>
<view v-if="highlightStatus === 'processing'" class="highlight-status highlight-status--busy">
<text>短片生成中,请稍候…</text>
<text class="highlight-processing-hint">可离开本页,稍后重新进入即可查看结果。</text>
</view>
<view v-else-if="highlightStatus === 'failed'" class="highlight-status highlight-status--err">
<text v-if="modelValue.highlightFailReasonLabel" class="highlight-err-type">{{
modelValue.highlightFailReasonLabel
}}</text>
<text class="highlight-err-msg">{{ highlightErrorUserText }}</text>
<text v-if="canTriggerHighlight" class="highlight-fail-hint">可调整编排方式后重试;若仍失败请联系门店或管理员。</text>
</view>
<video
v-if="highlightReady"
class="highlight-player"
:src="imgUrl(modelValue.highlightVideoUrl)"
controls
:show-center-play-btn="true"
objectFit="contain"
/>
<text v-if="highlightReady && modelValue.highlightDurationSec" class="highlight-meta">
约 {{ modelValue.highlightDurationSec }} 秒(实际时长随素材变化)
<text v-if="highlightComposeLabel"> · {{ highlightComposeLabel }}</text>
</text>
<!-- #ifdef MP-WEIXIN -->
<view v-if="highlightReady" class="highlight-mp-bar">
<button
class="highlight-btn highlight-btn--album highlight-btn--block"
:disabled="savingHighlightVideo"
@click="saveHighlightVideoToAlbum"
>
{{ savingHighlightVideo ? '保存中…' : '保存视频' }}
</button>
<text class="highlight-mp-hint">
保存后可在系统相册中转发到微信好友或其它应用;分享本报告小程序卡片请用页面底部「转发给宠主」。
</text>
<text class="highlight-mp-tip">也可点右上角「···」将本页转发给好友。</text>
</view>
<!-- #endif -->
<!-- #ifdef H5 -->
<view v-if="highlightReady" class="highlight-h5-bar">
<button
class="highlight-btn highlight-btn--album highlight-btn--block"
:disabled="savingHighlightVideo"
@click="downloadHighlightVideoH5"
>
{{ savingHighlightVideo ? '处理中…' : '下载短片' }}
</button>
<text class="highlight-mp-hint">
将回顾短片保存到本机下载目录;若浏览器拦截,将尝试复制链接。也可使用浏览器菜单分享本页。
</text>
</view>
<!-- #endif -->
<view v-if="canTriggerHighlight" class="highlight-actions">
<view class="highlight-mode-head">
<text class="highlight-mode-title">编排方式</text>
<text class="highlight-mode-sub">(剪辑顺序,不是选时长)</text>
</view>
<view class="highlight-mode">
<view
:class="['highlight-mode-item', highlightComposeMode === 'preset' && 'is-on']"
@click="highlightComposeMode = 'preset'"
>
预设顺序
</view>
<view
:class="['highlight-mode-item', highlightComposeMode === 'interleave' && 'is-on']"
@click="highlightComposeMode = 'interleave'"
>
穿插成片
</view>
</view>
<button
class="highlight-btn highlight-btn--block"
:disabled="highlightStatus === 'processing'"
@click="startHighlight"
>
生成短片
</button>
</view>
</view>
</template>
<script setup>
import { ref, computed, watch, onUnmounted } from 'vue'
import { getReportByToken, imgUrl, postReportHighlightStart } from '../../api/index.js'
import { getUserSession } from '../../utils/session.js'
const props = defineProps({
modelValue: { type: Object, required: true }
})
const emit = defineEmits(['update:modelValue'])
const savingHighlightVideo = ref(false)
const highlightComposeMode = ref('preset')
const highlightStatus = computed(() => props.modelValue?.highlightVideoStatus || '')
const highlightReady = computed(
() => highlightStatus.value === 'done' && !!props.modelValue?.highlightVideoUrl
)
const highlightComposeLabel = computed(() => {
const m = props.modelValue?.highlightComposeMode
if (m === 'interleave') return '穿插成片'
if (m === 'preset') return '预设顺序'
return ''
})
const highlightErrorUserText = computed(() => {
const raw = props.modelValue?.highlightVideoError
const hasCode = !!props.modelValue?.highlightFailReason
if (!raw || !String(raw).trim()) {
return '生成失败,请稍后重试'
}
const s = String(raw).trim()
if (hasCode) return s
if (/ffmpeg|exit\s*code|IllegalState|Exception|stack|0x|at\s+com\./i.test(s)) {
return '短片生成遇到问题,请稍后重试或联系门店。'
}
return s
})
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 resolveToken = () => {
let t = props.modelValue?.reportToken || ''
// #ifdef H5
if (!t && typeof window !== 'undefined') {
t = new URLSearchParams(window.location.search).get('token') || ''
}
// #endif
return t || ''
}
const maybeStartHighlightPoll = () => {
stopHighlightPoll()
if (props.modelValue?.highlightVideoStatus !== 'processing') return
highlightPollTimer = setInterval(async () => {
const t = resolveToken()
if (!t) {
stopHighlightPoll()
return
}
const res = await getReportByToken(t)
if (res.code === 200 && res.data) {
emit('update:modelValue', res.data)
const m = res.data.highlightComposeMode
if (m === 'preset' || m === 'interleave') {
highlightComposeMode.value = m
}
if (res.data.highlightVideoStatus !== 'processing') {
stopHighlightPoll()
if (res.data.highlightVideoStatus === 'done') {
uni.showToast({ title: '短片已生成', icon: 'success' })
}
}
}
}, 2500)
}
watch(
() => props.modelValue?.highlightVideoStatus,
(s) => {
if (s === 'processing') maybeStartHighlightPoll()
else stopHighlightPoll()
},
{ immediate: true }
)
watch(
() => props.modelValue?.highlightComposeMode,
(m) => {
if (m === 'preset' || m === 'interleave') highlightComposeMode.value = m
},
{ immediate: true }
)
const startHighlight = async () => {
const u = getUserSession()
if (!props.modelValue?.id || !u?.id) {
uni.showToast({ title: '请先登录门店账号', icon: 'none' })
return
}
uni.showLoading({ title: '提交中' })
const res = await postReportHighlightStart({
reportId: props.modelValue.id,
composeMode: highlightComposeMode.value,
operatorUserId: u.id,
role: u.role || 'staff'
})
uni.hideLoading()
if (res.code === 200) {
uni.showToast({ title: res.message || '已开始生成', icon: 'none' })
emit('update:modelValue', { ...props.modelValue, highlightVideoStatus: 'processing' })
maybeStartHighlightPoll()
} else if (res.code === 503) {
uni.showModal({
title: '暂无法生成',
content:
(res.message || '服务器未安装或未配置 ffmpeg') +
'\n\n请在主机安装 ffmpeg及 ffprobe保证运行后端的用户能执行「ffmpeg -version」Docker 需在镜像内安装或挂载二进制,必要时在配置中指定 ffmpeg 绝对路径并重启服务。',
showCancel: false
})
} else {
uni.showToast({ title: res.message || '发起失败', icon: 'none' })
}
}
const saveHighlightVideoToAlbum = async () => {
// #ifdef MP-WEIXIN
const path = props.modelValue?.highlightVideoUrl
if (!path) return
const fullUrl = imgUrl(path)
savingHighlightVideo.value = true
uni.showLoading({ title: '下载中', mask: true })
try {
const tempPath = await new Promise((resolve, reject) => {
uni.downloadFile({
url: fullUrl,
success: (res) => {
if (res.statusCode === 200 && res.tempFilePath) {
resolve(res.tempFilePath)
} else {
reject(new Error(res.errMsg || `下载失败(${res.statusCode})`))
}
},
fail: (err) => reject(err || new Error('downloadFile fail'))
})
})
await new Promise((resolve, reject) => {
uni.saveVideoToPhotosAlbum({
filePath: tempPath,
success: () => resolve(),
fail: (err) => reject(err || new Error('saveVideo fail'))
})
})
uni.showToast({ title: '已保存到相册', icon: 'success' })
} catch (e) {
const msg = e && (e.errMsg || e.message) ? String(e.errMsg || e.message) : ''
if (msg.includes('auth deny') || msg.includes('authorize') || msg.includes('auth')) {
uni.showModal({
title: '需要相册权限',
content: '保存视频需授权写入系统相册(用于留存服务回顾短片),请在设置中开启。',
confirmText: '去设置',
success: (m) => {
if (m.confirm) uni.openSetting({})
}
})
} else if (
msg.includes('downloadFile:fail') ||
msg.includes('domain') ||
msg.includes('不在以下 downloadFile')
) {
uni.showModal({
title: '无法下载短片',
content:
'请登录微信公众平台 → 小程序 → 开发管理 → 开发设置,将视频文件所在域名(与接口域名一致,如 https://api.xxx.com加入「downloadFile 合法域名」。',
showCancel: false
})
} else {
uni.showToast({ title: msg.length > 0 && msg.length < 52 ? msg : '保存失败', icon: 'none' })
}
} finally {
savingHighlightVideo.value = false
uni.hideLoading()
}
// #endif
}
const downloadHighlightVideoH5 = async () => {
// #ifdef H5
const path = props.modelValue?.highlightVideoUrl
if (!path) return
const fullUrl = imgUrl(path)
const rawName = (props.modelValue?.petName || '宠物').trim() || '宠物'
const safeName = rawName.replace(/[\\/:*?"<>|]/g, '_').slice(0, 32)
savingHighlightVideo.value = true
uni.showLoading({ title: '准备下载', mask: true })
try {
const res = await fetch(fullUrl, { mode: 'cors', credentials: 'omit' })
if (!res.ok) throw new Error(`HTTP ${res.status}`)
const blob = await res.blob()
const objectUrl = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = objectUrl
a.download = `服务回顾_${safeName}.mp4`
a.rel = 'noopener'
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
URL.revokeObjectURL(objectUrl)
uni.showToast({ title: '已开始下载', icon: 'success' })
} catch {
try {
await new Promise((resolve, reject) => {
uni.setClipboardData({
data: fullUrl,
success: () => resolve(),
fail: (err) => reject(err)
})
})
uni.showModal({
title: '无法直接下载',
content:
'可能受跨域或浏览器策略限制。已将视频链接复制到剪贴板,可粘贴到地址栏打开;或使用右上角菜单分享本页。',
showCancel: false
})
} catch {
uni.showModal({
title: '下载失败',
content: '请稍后重试,或复制报告链接在系统浏览器中打开。',
showCancel: false
})
}
} finally {
savingHighlightVideo.value = false
uni.hideLoading()
}
// #endif
}
onUnmounted(() => {
stopHighlightPoll()
})
</script>
<style scoped>
.highlight-card {
margin: 4px 16px 16px;
background: linear-gradient(140deg, #ecfdf5 0%, #ffffff 55%);
border: 1px solid #bbf7d0;
border-radius: 16px;
padding: 18px 16px 16px;
box-shadow: 0 10px 24px rgba(22, 163, 74, 0.1);
}
.highlight-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
margin-bottom: 8px;
}
.highlight-title {
font-size: 16px;
font-weight: 700;
color: #166534;
}
.highlight-badge {
font-size: 11px;
color: #15803d;
background: rgba(45, 185, 109, 0.15);
padding: 2px 8px;
border-radius: 999px;
}
.highlight-desc {
font-size: 12px;
color: #475569;
line-height: 1.55;
display: block;
}
.highlight-em {
color: #166534;
font-weight: 600;
}
.highlight-desc-ops {
margin-top: 8px;
font-size: 11px;
color: #64748b;
line-height: 1.45;
display: block;
}
.highlight-fail-hint {
display: block;
margin-top: 8px;
font-size: 11px;
color: #b45309;
line-height: 1.45;
}
.highlight-status {
margin: 12px 0;
padding: 12px;
border-radius: 10px;
font-size: 13px;
line-height: 1.5;
}
.highlight-status--busy {
background: #f0fdf4;
border: 1px solid #86efac;
color: #166534;
}
.highlight-processing-hint {
display: block;
margin-top: 6px;
font-size: 11px;
color: #15803d;
opacity: 0.9;
}
.highlight-status--err {
background: #fff7ed;
border: 1px solid #fdba74;
color: #9a3412;
}
.highlight-err-type {
display: block;
font-weight: 700;
margin-bottom: 4px;
}
.highlight-err-msg {
display: block;
font-size: 12px;
line-height: 1.45;
}
.highlight-meta {
display: block;
margin-top: 8px;
font-size: 12px;
color: #64748b;
text-align: center;
}
.highlight-mp-bar {
margin-top: 12px;
}
.highlight-btn--album {
background: linear-gradient(135deg, #22c55e, #16a34a);
color: #fff;
}
.highlight-mp-hint {
display: block;
margin-top: 8px;
font-size: 11px;
color: #64748b;
line-height: 1.45;
}
.highlight-mp-tip {
display: block;
margin-top: 4px;
font-size: 11px;
color: #94a3b8;
}
.highlight-h5-bar {
margin-top: 12px;
}
.highlight-mode-head {
margin-bottom: 8px;
}
.highlight-mode-title {
font-size: 13px;
font-weight: 700;
color: #166534;
}
.highlight-mode-sub {
font-size: 11px;
color: #64748b;
margin-left: 4px;
}
.highlight-mode {
display: flex;
gap: 10px;
margin-bottom: 12px;
}
.highlight-mode-item {
flex: 1;
text-align: center;
padding: 10px 8px;
border-radius: 10px;
border: 1.5px solid #cbd5e1;
background: #fff;
font-size: 13px;
font-weight: 600;
color: #475569;
}
.highlight-mode-item.is-on {
border-color: #16a34a;
background: #ecfdf5;
color: #166534;
}
.highlight-player {
width: 100%;
max-height: 420px;
border-radius: 12px;
margin-top: 8px;
background: #0f172a;
}
.highlight-actions {
margin-top: 14px;
}
.highlight-btn--block {
width: 100%;
}
.highlight-btn {
border: none;
border-radius: 12px;
padding: 12px 16px;
font-size: 15px;
font-weight: 600;
}
.highlight-btn[disabled] {
opacity: 0.55;
}
</style>