更新 2026-04-18 18:44
This commit is contained in:
parent
e56e54561c
commit
757811081d
525
src/components/report/ReportHighlightBlock.vue
Normal file
525
src/components/report/ReportHighlightBlock.vue
Normal file
@ -0,0 +1,525 @@
|
||||
<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>
|
||||
@ -156,94 +156,8 @@
|
||||
:pet-name="reportData.petName || ''"
|
||||
/>
|
||||
|
||||
<!-- 服务回顾短片:素材智能剪辑(后端可扩展接入生成式 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 短片。成片时长随素材量变化,并受门店服务端配置的总时长上限约束,不承诺固定秒数。请选择「预设顺序」或「穿插成片」后,再点下方「生成短片」。
|
||||
</text>
|
||||
<text v-if="canTriggerHighlight" class="highlight-desc-ops">
|
||||
预设顺序含对比封面与收尾镜头;穿插成片在阶段间均匀穿插照片与小视频。若生成失败并提示服务器环境,一般由未安装 ffmpeg/ffprobe 或未加入 PATH,需运维安装后重启应用;可选配乐由门店在服务端配置。
|
||||
</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="reportData.highlightFailReasonLabel" class="highlight-err-type">{{
|
||||
reportData.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(reportData.highlightVideoUrl)"
|
||||
controls
|
||||
:show-center-play-btn="true"
|
||||
objectFit="contain"
|
||||
/>
|
||||
<text v-if="highlightReady && reportData.highlightDurationSec" class="highlight-meta">
|
||||
约 {{ reportData.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">
|
||||
<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>
|
||||
<!-- 服务回顾短片:编排方式 + 可变时长(见 ReportHighlightBlock) -->
|
||||
<ReportHighlightBlock v-if="reportData && hasHighlightSource" v-model="reportData" />
|
||||
|
||||
<!-- 操作区 -->
|
||||
<view class="actions">
|
||||
@ -295,9 +209,9 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, onUnmounted, getCurrentInstance, nextTick } from 'vue'
|
||||
import { ref, computed, onMounted, getCurrentInstance, nextTick } from 'vue'
|
||||
import { onLoad, onShareAppMessage, onShareTimeline } from '@dcloudio/uni-app'
|
||||
import { getReportByToken, imgUrl, postReportHighlightStart, submitReportTestimonial } from '../../api/index.js'
|
||||
import { getReportByToken, imgUrl, submitReportTestimonial } 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'
|
||||
@ -307,7 +221,8 @@ import BeforeAfterSlider from '../../components/report/BeforeAfterSlider.vue'
|
||||
import DuringMediaGrid from '../../components/report/DuringMediaGrid.vue'
|
||||
import TestimonialModal from '../../components/report/TestimonialModal.vue'
|
||||
import ReminderCard from '../../components/report/ReminderCard.vue'
|
||||
import { getUserSession, isLoggedIn } from '../../utils/session.js'
|
||||
import ReportHighlightBlock from '../../components/report/ReportHighlightBlock.vue'
|
||||
import { isLoggedIn } from '../../utils/session.js'
|
||||
|
||||
const loading = ref(true)
|
||||
const loadError = ref(null)
|
||||
@ -316,9 +231,6 @@ const reportData = ref(null)
|
||||
const posterCanvas = ref(null)
|
||||
const posterModalOpen = ref(false)
|
||||
const routeToken = ref('')
|
||||
/** 小程序:保存成片视频到相册 */
|
||||
const savingHighlightVideo = ref(false)
|
||||
|
||||
const isStaff = computed(() => isLoggedIn())
|
||||
|
||||
const hasHighlightSource = computed(() => {
|
||||
@ -330,52 +242,6 @@ const hasHighlightSource = computed(() => {
|
||||
return b + a + d > 0
|
||||
})
|
||||
|
||||
const highlightStatus = computed(() => reportData.value?.highlightVideoStatus || '')
|
||||
|
||||
const highlightReady = computed(
|
||||
() => highlightStatus.value === 'done' && !!reportData.value?.highlightVideoUrl
|
||||
)
|
||||
|
||||
const highlightComposeMode = ref('preset')
|
||||
|
||||
const highlightComposeLabel = computed(() => {
|
||||
const m = reportData.value?.highlightComposeMode
|
||||
if (m === 'interleave') return '穿插成片'
|
||||
if (m === 'preset') return '预设顺序'
|
||||
return ''
|
||||
})
|
||||
|
||||
/** 宠主可读说明:新数据由后端归类中文;旧数据若含技术英文则降级为通用句 */
|
||||
const highlightErrorUserText = computed(() => {
|
||||
const raw = reportData.value?.highlightVideoError
|
||||
const hasCode = !!reportData.value?.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 getRouteToken = () => {
|
||||
let token = routeToken.value
|
||||
// #ifdef H5
|
||||
@ -387,176 +253,6 @@ const getRouteToken = () => {
|
||||
/** 报告 token:接口字段优先,兼容 H5 地址栏 */
|
||||
const reminderToken = computed(() => reportData.value?.reportToken || getRouteToken())
|
||||
|
||||
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
|
||||
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)
|
||||
}
|
||||
|
||||
const startHighlight = async () => {
|
||||
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,
|
||||
composeMode: highlightComposeMode.value,
|
||||
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') +
|
||||
'\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 = reportData.value?.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 = reportData.value?.highlightVideoUrl
|
||||
if (!path) return
|
||||
const fullUrl = imgUrl(path)
|
||||
const rawName = (reportData.value?.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
|
||||
}
|
||||
|
||||
const beforePhotos = computed(() => {
|
||||
if (!reportData.value?.beforePhotos) return []
|
||||
// 支持新旧两种格式:旧版是 string[],新版是 {url, mediaType}[]
|
||||
@ -648,11 +344,6 @@ const loadReport = async () => {
|
||||
const res = await getReportByToken(token)
|
||||
if (res && res.code === 200 && res.data) {
|
||||
reportData.value = res.data
|
||||
const m = res.data.highlightComposeMode
|
||||
if (m === 'preset' || m === 'interleave') {
|
||||
highlightComposeMode.value = m
|
||||
}
|
||||
maybeStartHighlightPoll()
|
||||
} else if (res && res.code === 404) {
|
||||
reportData.value = null
|
||||
notFound.value = true
|
||||
@ -1011,7 +702,6 @@ onLoad((options) => {
|
||||
routeToken.value = options?.token || ''
|
||||
})
|
||||
onMounted(() => loadReport())
|
||||
onUnmounted(() => stopHighlightPoll())
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@ -1321,178 +1011,6 @@ onUnmounted(() => stopHighlightPoll())
|
||||
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-desc-ops {
|
||||
display: block;
|
||||
font-size: 11px;
|
||||
color: #94a3b8;
|
||||
line-height: 1.5;
|
||||
margin: -4px 0 12px;
|
||||
}
|
||||
.highlight-fail-hint {
|
||||
display: block;
|
||||
margin-top: 8px;
|
||||
font-size: 12px;
|
||||
color: #b45309;
|
||||
line-height: 1.45;
|
||||
}
|
||||
.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-processing-hint {
|
||||
display: block;
|
||||
margin-top: 12rpx;
|
||||
font-size: 22rpx;
|
||||
color: #64748b;
|
||||
font-weight: 400;
|
||||
line-height: 1.45;
|
||||
}
|
||||
.highlight-status--err {
|
||||
background: rgba(239, 68, 68, 0.1);
|
||||
color: #b91c1c;
|
||||
}
|
||||
.highlight-err-type {
|
||||
display: block;
|
||||
font-size: 26rpx;
|
||||
font-weight: 700;
|
||||
margin-bottom: 8rpx;
|
||||
color: #991b1b;
|
||||
}
|
||||
.highlight-err-msg {
|
||||
display: block;
|
||||
font-size: 24rpx;
|
||||
line-height: 1.5;
|
||||
color: #7f1d1d;
|
||||
font-weight: 400;
|
||||
}
|
||||
.highlight-meta {
|
||||
display: block;
|
||||
font-size: 24rpx;
|
||||
color: #888;
|
||||
margin-top: 12rpx;
|
||||
margin-bottom: 8rpx;
|
||||
}
|
||||
|
||||
.highlight-mp-bar {
|
||||
margin-bottom: 20rpx;
|
||||
}
|
||||
.highlight-btn--album {
|
||||
background: linear-gradient(135deg, #0d9488 0%, #0f766e 100%);
|
||||
color: #fff;
|
||||
}
|
||||
.highlight-mp-hint {
|
||||
display: block;
|
||||
margin-top: 16rpx;
|
||||
font-size: 22rpx;
|
||||
color: #94a3b8;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.highlight-mp-tip {
|
||||
display: block;
|
||||
margin-top: 12rpx;
|
||||
font-size: 22rpx;
|
||||
color: #64748b;
|
||||
line-height: 1.45;
|
||||
}
|
||||
.highlight-h5-bar {
|
||||
margin-bottom: 20rpx;
|
||||
}
|
||||
|
||||
.highlight-mode {
|
||||
display: flex;
|
||||
gap: 16rpx;
|
||||
margin-bottom: 20rpx;
|
||||
}
|
||||
|
||||
.highlight-mode-item {
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
padding: 20rpx 16rpx;
|
||||
font-size: 26rpx;
|
||||
border-radius: 12rpx;
|
||||
background: #f3f4f6;
|
||||
color: #6b7280;
|
||||
border: 2rpx solid transparent;
|
||||
}
|
||||
|
||||
.highlight-mode-item.is-on {
|
||||
background: #ecfdf5;
|
||||
color: #047857;
|
||||
border-color: #6ee7b7;
|
||||
}
|
||||
|
||||
.highlight-player {
|
||||
width: 100%;
|
||||
max-height: 420px;
|
||||
border-radius: 12px;
|
||||
background: #000;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.highlight-actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
}
|
||||
.highlight-btn--block {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.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;
|
||||
|
||||
@ -88,6 +88,12 @@
|
||||
<DuringMediaGrid :items="duringList" />
|
||||
</div>
|
||||
|
||||
<!-- 服务回顾短片:编排方式(预设顺序 / 穿插成片),时长由素材决定,与小程序报告页一致 -->
|
||||
<ReportHighlightBlock
|
||||
v-if="reportData && hasHighlightSource"
|
||||
v-model="reportData"
|
||||
/>
|
||||
|
||||
<!-- 备注 -->
|
||||
<div class="remark-section section-card">
|
||||
<div class="section-label">备注</div>
|
||||
@ -134,6 +140,7 @@ import AppIcon from '../components/AppIcon.vue'
|
||||
import AppPageState from '../components/AppPageState.vue'
|
||||
import { messageFromApi, NETWORK_ERROR_MSG } from '../utils/apiResult.js'
|
||||
import ReminderCard from '../components/report/ReminderCard.vue'
|
||||
import ReportHighlightBlock from '../components/report/ReportHighlightBlock.vue'
|
||||
import BeforeAfterSlider from '../components/report/BeforeAfterSlider.vue'
|
||||
import DuringMediaGrid from '../components/report/DuringMediaGrid.vue'
|
||||
// #ifdef H5
|
||||
@ -186,6 +193,16 @@ const duringList = computed(() => {
|
||||
}))
|
||||
})
|
||||
|
||||
/** 有成片素材(与小程序 reportView 一致) */
|
||||
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 staffAvatarDisplay = computed(() => {
|
||||
const r = reportData.value
|
||||
if (!r?.staffAvatar) return ''
|
||||
|
||||
Loading…
Reference in New Issue
Block a user