957 lines
26 KiB
Vue
957 lines
26 KiB
Vue
<template>
|
||
<div class="dubbing-page">
|
||
<PageHeader title="绘本配音" subtitle="为每一页添加AI语音或人工配音" :showBack="true" />
|
||
|
||
<div v-if="loading" class="loading-state">
|
||
<div class="loading-emojis">
|
||
<span class="loading-emoji e1">🎙️</span>
|
||
<span class="loading-emoji e2">🎵</span>
|
||
<span class="loading-emoji e3">✨</span>
|
||
</div>
|
||
<div class="loading-text">正在加载绘本...</div>
|
||
</div>
|
||
|
||
<template v-else>
|
||
<div class="content">
|
||
<!-- 当前页展示 -->
|
||
<div class="page-display">
|
||
<div class="page-image-wrap">
|
||
<div class="page-badge-pill">{{ currentPage.pageNum === 0 ? '封面' : 'P' + currentPage.pageNum }}</div>
|
||
<img v-if="currentPage.imageUrl" :src="currentPage.imageUrl" class="page-image" />
|
||
<div v-else class="page-image placeholder">📖</div>
|
||
</div>
|
||
<div v-if="currentPage.text" class="page-text-card">
|
||
<div class="text-quote-mark">"</div>
|
||
<div class="page-text-content">{{ currentPage.text }}</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 音频控制 -->
|
||
<div class="audio-controls">
|
||
<template v-if="currentAudioSrc">
|
||
<div class="audio-row">
|
||
<button class="play-btn" @click="togglePlay">
|
||
<span v-if="isPlaying">⏸</span>
|
||
<span v-else>▶️</span>
|
||
</button>
|
||
<div class="audio-progress">
|
||
<div class="audio-bar">
|
||
<div class="audio-bar-fill" :style="{ width: playProgress + '%' }" />
|
||
</div>
|
||
<span class="audio-time">{{ formatTime(playCurrentTime) }} / {{ formatTime(playDuration) }}</span>
|
||
</div>
|
||
<div class="audio-source-tag" :class="currentPage.localBlob ? 'local' : currentPage.isAiVoice === false ? 'human' : 'ai'">
|
||
{{ currentPage.localBlob ? '🎤 本地' : currentPage.isAiVoice === false ? '🎤 人工' : '🤖 AI' }}
|
||
</div>
|
||
</div>
|
||
</template>
|
||
<div v-else class="audio-empty">
|
||
<span class="empty-icon">🔇</span>
|
||
<span>暂未配音</span>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 配音按钮 -->
|
||
<div class="voice-actions">
|
||
<button
|
||
class="record-btn"
|
||
:class="{ recording: isRecording }"
|
||
:disabled="voicingSingle || voicingAll"
|
||
@mousedown.prevent="startRecording"
|
||
@mouseup.prevent="stopRecording"
|
||
@mouseleave="isRecording && stopRecording()"
|
||
@touchstart.prevent="startRecording"
|
||
@touchend.prevent="stopRecording"
|
||
@touchcancel="isRecording && stopRecording()"
|
||
>
|
||
<span class="record-icon">{{ isRecording ? '🔴' : '🎤' }}</span>
|
||
<span class="record-text">{{ isRecording ? '录音中... 松开结束' : '按住录音' }}</span>
|
||
</button>
|
||
<div class="ai-btns">
|
||
<button
|
||
class="ai-btn single"
|
||
:disabled="voicingSingle || voicingAll || isRecording"
|
||
@click="voiceSingle"
|
||
>
|
||
<span>🤖</span>
|
||
{{ voicingSingle ? '配音中...' : 'AI配音' }}
|
||
</button>
|
||
<button
|
||
class="ai-btn all"
|
||
:disabled="voicingSingle || voicingAll || isRecording"
|
||
@click="voiceAllConfirm"
|
||
>
|
||
<span>✨</span>
|
||
{{ voicingAll ? '配音中...' : '全部AI' }}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 页面缩略图横向列表 -->
|
||
<div class="thumb-section">
|
||
<div class="thumb-header">
|
||
<span class="thumb-title">配音进度</span>
|
||
<span class="thumb-count">{{ voicedCount }}/{{ pages.length }} 已完成</span>
|
||
</div>
|
||
<div class="thumb-strip">
|
||
<div
|
||
v-for="(p, i) in pages" :key="i"
|
||
class="thumb-item" :class="{ active: i === idx, voiced: p.audioUrl || p.localBlob }"
|
||
@click="switchPage(i)"
|
||
>
|
||
<img v-if="p.imageUrl" :src="p.imageUrl" class="thumb-img" />
|
||
<div v-else class="thumb-placeholder">{{ p.pageNum === 0 ? '封' : p.pageNum }}</div>
|
||
<div class="thumb-status">
|
||
<span v-if="p.localBlob" class="status-dot local-dot" />
|
||
<span v-else-if="p.audioUrl" class="status-dot ai-dot" />
|
||
<span v-else class="status-dot empty-dot" />
|
||
</div>
|
||
<div v-if="p.audioUrl || p.localBlob" class="thumb-voice-icon">🔊</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 底部 -->
|
||
<div class="bottom-bar safe-bottom">
|
||
<button class="btn-primary finish-btn" :disabled="submitting" @click="finish">
|
||
{{ submitting ? '提交中...' : '完成配音 →' }}
|
||
</button>
|
||
<div v-if="localCount > 0" class="local-hint">🎤 {{ localCount }}页本地录音将在提交时上传</div>
|
||
</div>
|
||
</template>
|
||
|
||
<!-- Toast -->
|
||
<Transition name="fade">
|
||
<div v-if="toast" class="toast">{{ toast }}</div>
|
||
</Transition>
|
||
|
||
<!-- 确认弹窗(替代原生 confirm) -->
|
||
<div v-if="confirmVisible" class="confirm-overlay" @click.self="handleConfirmCancel">
|
||
<div class="confirm-modal">
|
||
<div class="confirm-title">{{ confirmTitle }}</div>
|
||
<div class="confirm-content">{{ confirmContent }}</div>
|
||
<div class="confirm-actions">
|
||
<button class="confirm-btn cancel" @click="handleConfirmCancel">{{ confirmCancelText }}</button>
|
||
<button class="confirm-btn ok" @click="handleConfirmOk">{{ confirmOkText }}</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
|
||
<script setup>
|
||
import { ref, computed, onMounted, onBeforeUnmount, watch } from 'vue'
|
||
import { useRouter, useRoute } from 'vue-router'
|
||
import PageHeader from '@/components/PageHeader.vue'
|
||
import { getWorkDetail, voicePage, ossUpload, batchUpdateAudio, finishDubbing } from '@/api'
|
||
import { store } from '@/utils/store'
|
||
import { STATUS, getRouteByStatus } from '@/utils/status'
|
||
|
||
const router = useRouter()
|
||
const route = useRoute()
|
||
const workId = computed(() => route.params.workId || store.workId)
|
||
|
||
const loading = ref(true)
|
||
const submitting = ref(false)
|
||
const pages = ref([])
|
||
const idx = ref(0)
|
||
const toast = ref('')
|
||
|
||
// Voice state
|
||
const voicingSingle = ref(false)
|
||
const voicingAll = ref(false)
|
||
|
||
// Recording state
|
||
const isRecording = ref(false)
|
||
let mediaRecorder = null
|
||
let recordedChunks = []
|
||
|
||
// Audio playback state
|
||
let audioEl = null
|
||
const isPlaying = ref(false)
|
||
const playProgress = ref(0)
|
||
const playCurrentTime = ref(0)
|
||
const playDuration = ref(0)
|
||
|
||
const currentPage = computed(() => pages.value[idx.value] || {})
|
||
|
||
// 当前页的播放源:优先本地 blob,其次服务端 audioUrl
|
||
const currentAudioSrc = computed(() => {
|
||
const p = currentPage.value
|
||
if (p.localBlob) return URL.createObjectURL(p.localBlob)
|
||
return p.audioUrl || null
|
||
})
|
||
|
||
const voicedCount = computed(() => pages.value.filter(p => p.audioUrl || p.localBlob).length)
|
||
const localCount = computed(() => pages.value.filter(p => p.localBlob).length)
|
||
|
||
function showToast(msg) {
|
||
toast.value = msg
|
||
setTimeout(() => { toast.value = '' }, 2500)
|
||
}
|
||
|
||
// ── 确认弹窗(替代原生 confirm) ──
|
||
const confirmVisible = ref(false)
|
||
const confirmTitle = ref('')
|
||
const confirmContent = ref('')
|
||
const confirmOkText = ref('确认')
|
||
const confirmCancelText = ref('取消')
|
||
let confirmResolve = null
|
||
|
||
/**
|
||
* 显示确认弹窗(替代原生 confirm)
|
||
* @returns {Promise<boolean>}
|
||
*/
|
||
function showConfirm(content, options = {}) {
|
||
confirmTitle.value = options.title || '确认操作'
|
||
confirmContent.value = content
|
||
confirmOkText.value = options.okText || '确认'
|
||
confirmCancelText.value = options.cancelText || '取消'
|
||
confirmVisible.value = true
|
||
return new Promise((resolve) => { confirmResolve = resolve })
|
||
}
|
||
|
||
function handleConfirmOk() {
|
||
confirmVisible.value = false
|
||
confirmResolve?.(true)
|
||
confirmResolve = null
|
||
}
|
||
|
||
function handleConfirmCancel() {
|
||
confirmVisible.value = false
|
||
confirmResolve?.(false)
|
||
confirmResolve = null
|
||
}
|
||
|
||
function formatTime(sec) {
|
||
if (!sec || isNaN(sec)) return '0:00'
|
||
const m = Math.floor(sec / 60)
|
||
const s = Math.floor(sec % 60)
|
||
return m + ':' + (s < 10 ? '0' : '') + s
|
||
}
|
||
|
||
// ─── Audio Playback ───
|
||
|
||
function stopAudio() {
|
||
if (audioEl) {
|
||
audioEl.pause()
|
||
audioEl.onerror = null // 先清回调,再清 src,防止空 src 触发 onerror
|
||
audioEl.onended = null
|
||
audioEl.ontimeupdate = null
|
||
audioEl.src = ''
|
||
audioEl = null
|
||
}
|
||
isPlaying.value = false
|
||
playProgress.value = 0
|
||
playCurrentTime.value = 0
|
||
playDuration.value = 0
|
||
}
|
||
|
||
function togglePlay() {
|
||
const src = currentAudioSrc.value
|
||
if (!src) return
|
||
|
||
if (isPlaying.value) {
|
||
audioEl?.pause()
|
||
isPlaying.value = false
|
||
return
|
||
}
|
||
|
||
if (!audioEl) audioEl = new Audio()
|
||
|
||
// 每次用最新的 src(blob URL 可能变化)
|
||
audioEl.src = src
|
||
audioEl.load()
|
||
|
||
audioEl.ontimeupdate = () => {
|
||
playCurrentTime.value = audioEl.currentTime
|
||
playDuration.value = audioEl.duration || 0
|
||
playProgress.value = audioEl.duration ? (audioEl.currentTime / audioEl.duration * 100) : 0
|
||
}
|
||
audioEl.onended = () => { isPlaying.value = false; playProgress.value = 100 }
|
||
audioEl.onerror = () => { isPlaying.value = false; showToast('播放失败') }
|
||
|
||
audioEl.play().then(() => { isPlaying.value = true }).catch(() => { showToast('播放失败,请点击重试') })
|
||
}
|
||
|
||
function switchPage(i) {
|
||
stopAudio()
|
||
idx.value = i
|
||
}
|
||
|
||
watch(idx, () => { stopAudio() })
|
||
|
||
// ─── Manual Recording (按住录音) ───
|
||
|
||
async function startRecording() {
|
||
if (isRecording.value || voicingSingle.value || voicingAll.value) return
|
||
|
||
stopAudio() // 停止播放
|
||
|
||
try {
|
||
if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
|
||
showToast('请使用 HTTPS 访问以启用录音功能')
|
||
return
|
||
}
|
||
const stream = await navigator.mediaDevices.getUserMedia({ audio: true })
|
||
recordedChunks = []
|
||
mediaRecorder = new MediaRecorder(stream, { mimeType: getSupportedMimeType() })
|
||
|
||
mediaRecorder.ondataavailable = (e) => {
|
||
if (e.data.size > 0) recordedChunks.push(e.data)
|
||
}
|
||
|
||
mediaRecorder.onstop = () => {
|
||
// 释放麦克风
|
||
stream.getTracks().forEach(t => t.stop())
|
||
|
||
if (recordedChunks.length === 0) return
|
||
|
||
const blob = new Blob(recordedChunks, { type: mediaRecorder.mimeType })
|
||
|
||
// 检查录音大小(太短的过滤掉)
|
||
if (blob.size < 1000) {
|
||
showToast('录音太短,请重新录制')
|
||
return
|
||
}
|
||
|
||
// 存入当前页的 localBlob
|
||
const p = pages.value[idx.value]
|
||
if (p) {
|
||
p.localBlob = blob
|
||
p.isAiVoice = false
|
||
showToast('录音完成!')
|
||
// 自动跳到下一个未配音的页面
|
||
autoAdvance()
|
||
}
|
||
}
|
||
|
||
mediaRecorder.start()
|
||
isRecording.value = true
|
||
} catch (e) {
|
||
if (e.name === 'NotAllowedError') {
|
||
showToast('请允许麦克风权限后重试')
|
||
} else {
|
||
showToast('无法启动录音: ' + (e.message || '未知错误'))
|
||
}
|
||
}
|
||
}
|
||
|
||
function stopRecording() {
|
||
if (!isRecording.value || !mediaRecorder) return
|
||
isRecording.value = false
|
||
if (mediaRecorder.state === 'recording') {
|
||
mediaRecorder.stop()
|
||
}
|
||
}
|
||
|
||
function getSupportedMimeType() {
|
||
const types = ['audio/webm;codecs=opus', 'audio/webm', 'audio/mp4', 'audio/ogg']
|
||
for (const t of types) {
|
||
if (MediaRecorder.isTypeSupported(t)) return t
|
||
}
|
||
return ''
|
||
}
|
||
|
||
// 自动跳到下一个未配音的页
|
||
function autoAdvance() {
|
||
for (let i = idx.value + 1; i < pages.value.length; i++) {
|
||
if (!pages.value[i].audioUrl && !pages.value[i].localBlob) {
|
||
idx.value = i
|
||
return
|
||
}
|
||
}
|
||
// 都配完了,不跳
|
||
}
|
||
|
||
// ─── AI Voice ───
|
||
|
||
async function voiceSingle() {
|
||
voicingSingle.value = true
|
||
try {
|
||
const res = await voicePage({ workId: workId.value, voiceAll: false, pageNum: currentPage.value.pageNum })
|
||
const data = res.data
|
||
if (data.voicedPages?.length) {
|
||
for (const vp of data.voicedPages) {
|
||
const p = pages.value.find(x => x.pageNum === vp.pageNum)
|
||
if (p) {
|
||
p.audioUrl = vp.audioUrl
|
||
p.localBlob = null // AI覆盖本地录音
|
||
p.isAiVoice = true
|
||
}
|
||
}
|
||
showToast('AI配音成功')
|
||
} else {
|
||
showToast('配音失败,请重试')
|
||
}
|
||
} catch (e) {
|
||
showToast(e.message || '配音失败')
|
||
} finally {
|
||
voicingSingle.value = false
|
||
}
|
||
}
|
||
|
||
async function voiceAllConfirm() {
|
||
const confirmed = await showConfirm('将为所有未配音的页面生成AI语音,预计需要30-60秒,确认继续?', {
|
||
title: 'AI 配音',
|
||
okText: '开始生成',
|
||
cancelText: '再想想',
|
||
})
|
||
if (!confirmed) return
|
||
voicingAll.value = true
|
||
try {
|
||
const res = await voicePage({ workId: workId.value, voiceAll: true })
|
||
const data = res.data
|
||
if (data.voicedPages) {
|
||
for (const vp of data.voicedPages) {
|
||
const p = pages.value.find(x => x.pageNum === vp.pageNum)
|
||
if (p) {
|
||
p.audioUrl = vp.audioUrl
|
||
p.localBlob = null
|
||
p.isAiVoice = true
|
||
}
|
||
}
|
||
}
|
||
const failed = data.failedPages?.length || 0
|
||
showToast(failed > 0 ? `${data.totalSucceeded}页成功,${failed}页失败` : '全部AI配音完成!')
|
||
} catch (e) {
|
||
showToast(e.message || '配音失败')
|
||
} finally {
|
||
voicingAll.value = false
|
||
}
|
||
}
|
||
|
||
// ─── 完成配音 ───
|
||
|
||
async function finish() {
|
||
submitting.value = true
|
||
try {
|
||
const pendingLocal = pages.value.filter(p => p.localBlob)
|
||
|
||
if (pendingLocal.length > 0) {
|
||
// 有本地录音:上传到 OSS,然后通过 batchUpdateAudio 更新DB并推进状态
|
||
const audioPages = []
|
||
for (let i = 0; i < pendingLocal.length; i++) {
|
||
const p = pendingLocal[i]
|
||
showToast(`上传录音 ${i + 1}/${pendingLocal.length}...`)
|
||
const ext = p.localBlob.type?.includes('webm') ? 'webm' : 'm4a'
|
||
const ossUrl = await ossUpload(p.localBlob, { type: 'aud', ext })
|
||
audioPages.push({ pageNum: p.pageNum, audioUrl: ossUrl })
|
||
p.audioUrl = ossUrl
|
||
p.localBlob = null
|
||
}
|
||
showToast('保存配音...')
|
||
// batchUpdateAudio (C2) 会同时更新音频URL并推进状态 CATALOGED→DUBBED
|
||
await batchUpdateAudio(workId.value, audioPages)
|
||
} else {
|
||
// 无本地录音:仍需调用 C2 API 推进状态 CATALOGED(4)→DUBBED(5)
|
||
// C2 允许空 pages,调用后状态即推进
|
||
showToast('完成配音...')
|
||
await finishDubbing(workId.value)
|
||
}
|
||
|
||
store.workDetail = null // 清除缓存
|
||
showToast('配音完成!')
|
||
setTimeout(() => router.push(`/read/${workId.value}`), 800)
|
||
} catch (e) {
|
||
// 容错:即使报错也检查实际状态,可能请求已经成功但重试触发了CAS失败
|
||
try {
|
||
const check = await getWorkDetail(workId.value)
|
||
if (check?.data?.status >= 5) {
|
||
store.workDetail = null
|
||
showToast('配音已完成')
|
||
setTimeout(() => router.push(`/read/${workId.value}`), 800)
|
||
return
|
||
}
|
||
} catch { /* ignore check error */ }
|
||
showToast('提交失败:' + (e.message || '请重试'))
|
||
} finally {
|
||
submitting.value = false
|
||
}
|
||
}
|
||
|
||
// ─── Load ───
|
||
|
||
async function loadWork() {
|
||
loading.value = true
|
||
try {
|
||
if (!store.workDetail || store.workDetail.workId !== workId.value) {
|
||
store.workDetail = null
|
||
const res = await getWorkDetail(workId.value)
|
||
store.workDetail = res.data
|
||
}
|
||
const w = store.workDetail
|
||
|
||
// 如果作品已完成配音(DUBBED),直接跳到阅读页
|
||
if (w.status >= STATUS.DUBBED) {
|
||
const route = getRouteByStatus(w.status, w.workId)
|
||
if (route) { router.replace(route); return }
|
||
}
|
||
|
||
pages.value = (w.pageList || []).map(p => ({
|
||
pageNum: p.pageNum,
|
||
text: p.text,
|
||
imageUrl: p.imageUrl,
|
||
audioUrl: p.audioUrl || null,
|
||
localBlob: null, // 本地录音 Blob
|
||
isAiVoice: p.audioUrl ? true : null // 区分来源
|
||
}))
|
||
} catch { /* fallback empty */ }
|
||
loading.value = false
|
||
}
|
||
|
||
onMounted(loadWork)
|
||
onBeforeUnmount(() => {
|
||
stopAudio()
|
||
if (isRecording.value && mediaRecorder?.state === 'recording') {
|
||
mediaRecorder.stop()
|
||
}
|
||
})
|
||
</script>
|
||
|
||
<style lang="scss" scoped>
|
||
.dubbing-page {
|
||
height: 100vh;
|
||
height: 100dvh; /* 动态视口高度,兼容移动端地址栏 */
|
||
background: linear-gradient(180deg, #F0F8FF 0%, #FFF5F0 40%, #FFF8E7 70%, #FFFDF7 100%);
|
||
display: flex;
|
||
flex-direction: column;
|
||
overflow: hidden;
|
||
}
|
||
.loading-state {
|
||
flex: 1;
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
justify-content: center;
|
||
}
|
||
.loading-emojis {
|
||
display: flex;
|
||
gap: 16px;
|
||
margin-bottom: 12px;
|
||
}
|
||
.loading-emoji {
|
||
font-size: 36px;
|
||
display: inline-block;
|
||
animation: emojiPop 1.8s ease-in-out infinite;
|
||
&.e1 { animation-delay: 0s; }
|
||
&.e2 { animation-delay: 0.4s; }
|
||
&.e3 { animation-delay: 0.8s; }
|
||
}
|
||
@keyframes emojiPop {
|
||
0%, 100% { transform: scale(1) rotate(0deg); opacity: 0.5; }
|
||
50% { transform: scale(1.3) rotate(10deg); opacity: 1; }
|
||
}
|
||
.loading-text { font-size: 16px; color: var(--text-sub); font-weight: 600; }
|
||
|
||
.content {
|
||
flex: 1;
|
||
padding: 12px 16px;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 12px;
|
||
overflow-y: auto;
|
||
-webkit-overflow-scrolling: touch;
|
||
padding-bottom: 16px;
|
||
}
|
||
|
||
/* 页面展示 */
|
||
.page-display { display: flex; flex-direction: column; gap: 10px; }
|
||
.page-image-wrap {
|
||
position: relative;
|
||
border-radius: 20px;
|
||
overflow: hidden;
|
||
background: linear-gradient(135deg, #F8F6F0, #F0EDE8);
|
||
box-shadow: 0 4px 20px rgba(0,0,0,0.08);
|
||
}
|
||
.page-image {
|
||
width: 100%;
|
||
display: block;
|
||
object-fit: contain;
|
||
max-height: 38vh;
|
||
}
|
||
.placeholder {
|
||
height: 180px;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
font-size: 48px;
|
||
}
|
||
.page-badge-pill {
|
||
position: absolute;
|
||
top: 10px;
|
||
left: 10px;
|
||
background: linear-gradient(135deg, rgba(255,107,53,0.9), rgba(255,140,66,0.9));
|
||
color: #fff;
|
||
font-size: 12px;
|
||
font-weight: 700;
|
||
padding: 4px 14px;
|
||
border-radius: 20px;
|
||
backdrop-filter: blur(4px);
|
||
box-shadow: 0 2px 8px rgba(255,107,53,0.3);
|
||
}
|
||
|
||
.page-text-card {
|
||
background: rgba(255,255,255,0.92);
|
||
border-radius: 18px;
|
||
padding: 16px 20px;
|
||
box-shadow: 0 4px 16px rgba(0,0,0,0.05);
|
||
position: relative;
|
||
border-left: 4px solid #FFD166;
|
||
}
|
||
.text-quote-mark {
|
||
position: absolute;
|
||
top: 4px;
|
||
left: 12px;
|
||
font-size: 32px;
|
||
color: #FFD166;
|
||
opacity: 0.3;
|
||
font-family: serif;
|
||
font-weight: 900;
|
||
line-height: 1;
|
||
}
|
||
.page-text-content {
|
||
font-size: 15px;
|
||
line-height: 1.8;
|
||
color: var(--text);
|
||
text-align: center;
|
||
padding-top: 4px;
|
||
}
|
||
|
||
/* 音频控制 */
|
||
.audio-controls {
|
||
background: rgba(255,255,255,0.92);
|
||
border-radius: 18px;
|
||
padding: 14px 16px;
|
||
box-shadow: 0 2px 12px rgba(0,0,0,0.04);
|
||
}
|
||
.audio-row {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 12px;
|
||
}
|
||
.play-btn {
|
||
width: 44px;
|
||
height: 44px;
|
||
border-radius: 50%;
|
||
border: none;
|
||
background: linear-gradient(135deg, #FF8C42, #FF6B35);
|
||
color: #fff;
|
||
font-size: 18px;
|
||
cursor: pointer;
|
||
flex-shrink: 0;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
box-shadow: 0 3px 12px rgba(255,107,53,0.3);
|
||
transition: transform 0.2s;
|
||
&:active { transform: scale(0.9); }
|
||
}
|
||
.audio-progress { flex: 1; }
|
||
.audio-bar {
|
||
height: 6px;
|
||
background: #F0EDE8;
|
||
border-radius: 3px;
|
||
overflow: hidden;
|
||
}
|
||
.audio-bar-fill {
|
||
height: 100%;
|
||
background: linear-gradient(90deg, #FF8C42, #FF6B35);
|
||
border-radius: 3px;
|
||
transition: width 0.3s;
|
||
}
|
||
.audio-time {
|
||
font-size: 11px;
|
||
color: var(--text-sub);
|
||
margin-top: 4px;
|
||
display: block;
|
||
}
|
||
.audio-source-tag {
|
||
font-size: 11px;
|
||
font-weight: 700;
|
||
padding: 3px 10px;
|
||
border-radius: 10px;
|
||
white-space: nowrap;
|
||
flex-shrink: 0;
|
||
&.ai { background: #FFF0E8; color: var(--primary); }
|
||
&.local { background: #FEF2F2; color: #EF4444; }
|
||
&.human { background: #F0F8FF; color: #3B82F6; }
|
||
}
|
||
|
||
.audio-empty {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
gap: 8px;
|
||
padding: 8px 0;
|
||
font-size: 14px;
|
||
color: var(--text-sub);
|
||
font-weight: 500;
|
||
}
|
||
.empty-icon { font-size: 20px; }
|
||
|
||
/* 配音按钮 */
|
||
.voice-actions {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 10px;
|
||
}
|
||
|
||
/* 录音大按钮 */
|
||
.record-btn {
|
||
width: 100%;
|
||
padding: 16px;
|
||
border-radius: 22px;
|
||
border: 2.5px solid #EF4444;
|
||
background: linear-gradient(135deg, #FEF2F2, #FFF5F5);
|
||
color: #EF4444;
|
||
font-weight: 700;
|
||
font-size: 16px;
|
||
cursor: pointer;
|
||
transition: all 0.2s;
|
||
user-select: none;
|
||
-webkit-user-select: none;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
gap: 10px;
|
||
box-shadow: 0 3px 12px rgba(239,68,68,0.12);
|
||
|
||
&:active, &.recording {
|
||
background: linear-gradient(135deg, #EF4444, #DC2626);
|
||
color: #fff;
|
||
transform: scale(1.02);
|
||
box-shadow: 0 6px 24px rgba(239,68,68,0.3);
|
||
border-color: #DC2626;
|
||
}
|
||
&:disabled {
|
||
opacity: 0.4;
|
||
pointer-events: none;
|
||
}
|
||
}
|
||
.record-icon { font-size: 22px; }
|
||
.record-text { font-size: 16px; }
|
||
|
||
.ai-btns {
|
||
display: flex;
|
||
gap: 10px;
|
||
}
|
||
.ai-btn {
|
||
flex: 1;
|
||
padding: 14px 8px;
|
||
font-size: 14px;
|
||
font-weight: 700;
|
||
border-radius: 18px;
|
||
border: none;
|
||
cursor: pointer;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
gap: 6px;
|
||
transition: all 0.2s;
|
||
|
||
&.single {
|
||
background: linear-gradient(135deg, #FF8C42, #FF6B35);
|
||
color: #fff;
|
||
box-shadow: 0 3px 12px rgba(255,107,53,0.25);
|
||
}
|
||
&.all {
|
||
background: rgba(255,255,255,0.9);
|
||
color: var(--primary);
|
||
border: 2px solid #FFE4D0;
|
||
box-shadow: 0 2px 8px rgba(0,0,0,0.04);
|
||
}
|
||
&:active { transform: scale(0.97); }
|
||
&:disabled { opacity: 0.4; pointer-events: none; }
|
||
}
|
||
|
||
/* 缩略图横向滚动列表 */
|
||
.thumb-section { margin-top: 4px; }
|
||
.thumb-header {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
margin-bottom: 10px;
|
||
}
|
||
.thumb-title { font-size: 15px; font-weight: 800; color: var(--text); }
|
||
.thumb-count { font-size: 13px; color: var(--primary); font-weight: 700; }
|
||
|
||
.thumb-strip {
|
||
display: flex;
|
||
gap: 10px;
|
||
overflow-x: auto;
|
||
padding: 6px 2px 12px;
|
||
scrollbar-width: none;
|
||
&::-webkit-scrollbar { display: none; }
|
||
}
|
||
|
||
.thumb-item {
|
||
flex-shrink: 0;
|
||
width: 64px;
|
||
height: 80px;
|
||
border-radius: 12px;
|
||
overflow: hidden;
|
||
border: 2.5px solid transparent;
|
||
cursor: pointer;
|
||
transition: all 0.2s;
|
||
position: relative;
|
||
background: rgba(255,255,255,0.8);
|
||
box-shadow: 0 2px 8px rgba(0,0,0,0.05);
|
||
|
||
&.active {
|
||
border-color: var(--primary);
|
||
box-shadow: 0 4px 16px rgba(255,107,53,0.25);
|
||
transform: scale(1.08);
|
||
}
|
||
&.voiced { border-color: #C8E6C9; }
|
||
&.active.voiced { border-color: var(--primary); }
|
||
}
|
||
.thumb-img {
|
||
width: 100%;
|
||
height: 100%;
|
||
object-fit: cover;
|
||
}
|
||
.thumb-placeholder {
|
||
width: 100%;
|
||
height: 100%;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
background: #F5F0E8;
|
||
font-size: 12px;
|
||
font-weight: 600;
|
||
color: var(--text-sub);
|
||
}
|
||
.thumb-status {
|
||
position: absolute;
|
||
bottom: 4px;
|
||
left: 50%;
|
||
transform: translateX(-50%);
|
||
}
|
||
.status-dot {
|
||
display: block;
|
||
width: 8px;
|
||
height: 8px;
|
||
border-radius: 50%;
|
||
&.ai-dot { background: #2EC4B6; box-shadow: 0 0 4px rgba(46,196,182,0.5); }
|
||
&.local-dot { background: #EF4444; box-shadow: 0 0 4px rgba(239,68,68,0.5); }
|
||
&.empty-dot { background: #D1D5DB; }
|
||
}
|
||
.thumb-voice-icon {
|
||
position: absolute;
|
||
top: 2px;
|
||
right: 3px;
|
||
font-size: 10px;
|
||
opacity: 0.7;
|
||
}
|
||
|
||
/* 底部 */
|
||
.bottom-bar {
|
||
flex-shrink: 0;
|
||
padding: 14px 20px 20px;
|
||
padding-bottom: calc(20px + env(safe-area-inset-bottom, 0px));
|
||
background: linear-gradient(transparent, rgba(255,253,247,0.95) 25%);
|
||
}
|
||
.finish-btn {
|
||
font-size: 17px !important;
|
||
padding: 16px 0 !important;
|
||
border-radius: 28px !important;
|
||
background: linear-gradient(135deg, #FF8C42, #FF6B35) !important;
|
||
box-shadow: 0 6px 24px rgba(255,107,53,0.3) !important;
|
||
}
|
||
.local-hint {
|
||
text-align: center;
|
||
font-size: 12px;
|
||
color: var(--text-sub);
|
||
margin-top: 8px;
|
||
font-weight: 500;
|
||
}
|
||
|
||
/* Toast */
|
||
.toast {
|
||
position: fixed;
|
||
top: 60px;
|
||
left: 50%;
|
||
transform: translateX(-50%);
|
||
background: rgba(45,45,63,0.88);
|
||
color: #fff;
|
||
padding: 12px 28px;
|
||
border-radius: 24px;
|
||
font-size: 14px;
|
||
font-weight: 600;
|
||
z-index: 999;
|
||
max-width: 80%;
|
||
text-align: center;
|
||
box-shadow: 0 4px 16px rgba(0,0,0,0.15);
|
||
backdrop-filter: blur(8px);
|
||
}
|
||
.fade-enter-active, .fade-leave-active { transition: opacity 0.3s; }
|
||
.fade-enter-from, .fade-leave-to { opacity: 0; }
|
||
|
||
/* ── 确认弹窗(Ant Design 风格) ── */
|
||
.confirm-overlay {
|
||
position: fixed;
|
||
top: 0; left: 0; right: 0; bottom: 0;
|
||
background: rgba(0, 0, 0, 0.45);
|
||
z-index: 1100;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
padding: 16px;
|
||
}
|
||
.confirm-modal {
|
||
background: #fff;
|
||
border-radius: 8px;
|
||
padding: 24px;
|
||
min-width: 280px;
|
||
max-width: 400px;
|
||
box-shadow: 0 6px 30px rgba(0, 0, 0, 0.12);
|
||
animation: confirmFadeIn 0.2s ease;
|
||
}
|
||
@keyframes confirmFadeIn {
|
||
from { opacity: 0; transform: scale(0.95); }
|
||
to { opacity: 1; transform: scale(1); }
|
||
}
|
||
.confirm-title {
|
||
font-size: 16px;
|
||
font-weight: 600;
|
||
color: #1f2937;
|
||
margin-bottom: 8px;
|
||
}
|
||
.confirm-content {
|
||
font-size: 14px;
|
||
color: #6b7280;
|
||
line-height: 1.6;
|
||
margin-bottom: 24px;
|
||
}
|
||
.confirm-actions {
|
||
display: flex;
|
||
gap: 8px;
|
||
justify-content: flex-end;
|
||
}
|
||
.confirm-btn {
|
||
padding: 5px 16px;
|
||
border-radius: 6px;
|
||
font-size: 14px;
|
||
cursor: pointer;
|
||
height: 32px;
|
||
line-height: 1;
|
||
transition: all 0.2s;
|
||
|
||
&.cancel {
|
||
border: 1px solid #d1d5db;
|
||
background: #fff;
|
||
color: #374151;
|
||
&:hover { border-color: #6366f1; color: #6366f1; }
|
||
}
|
||
&.ok {
|
||
border: none;
|
||
background: #6366f1;
|
||
color: #fff;
|
||
font-weight: 500;
|
||
&:hover { background: #4f46e5; }
|
||
}
|
||
}
|
||
</style>
|