2026-04-03 20:55:51 +08:00
|
|
|
|
<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>
|
2026-04-08 09:36:42 +08:00
|
|
|
|
|
|
|
|
|
|
<!-- 确认弹窗(替代原生 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>
|
2026-04-03 20:55:51 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
</template>
|
|
|
|
|
|
|
|
|
|
|
|
<script setup>
|
|
|
|
|
|
import { ref, computed, onMounted, onBeforeUnmount, watch } from 'vue'
|
|
|
|
|
|
import { useRouter, useRoute } from 'vue-router'
|
|
|
|
|
|
import PageHeader from '@/components/PageHeader.vue'
|
2026-04-07 12:11:15 +08:00
|
|
|
|
import { getWorkDetail, voicePage, ossUpload, batchUpdateAudio, finishDubbing } from '@/api'
|
2026-04-03 20:55:51 +08:00
|
|
|
|
import { store } from '@/utils/store'
|
2026-04-07 12:11:15 +08:00
|
|
|
|
import { STATUS, getRouteByStatus } from '@/utils/status'
|
2026-04-03 20:55:51 +08:00
|
|
|
|
|
|
|
|
|
|
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)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-08 09:36:42 +08:00
|
|
|
|
// ── 确认弹窗(替代原生 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
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-03 20:55:51 +08:00
|
|
|
|
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() {
|
2026-04-08 09:36:42 +08:00
|
|
|
|
const confirmed = await showConfirm('将为所有未配音的页面生成AI语音,预计需要30-60秒,确认继续?', {
|
|
|
|
|
|
title: 'AI 配音',
|
|
|
|
|
|
okText: '开始生成',
|
|
|
|
|
|
cancelText: '再想想',
|
|
|
|
|
|
})
|
|
|
|
|
|
if (!confirmed) return
|
2026-04-03 20:55:51 +08:00
|
|
|
|
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 {
|
2026-04-07 12:11:15 +08:00
|
|
|
|
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)
|
2026-04-03 20:55:51 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-07 12:11:15 +08:00
|
|
|
|
store.workDetail = null // 清除缓存
|
|
|
|
|
|
showToast('配音完成!')
|
2026-04-03 20:55:51 +08:00
|
|
|
|
setTimeout(() => router.push(`/read/${workId.value}`), 800)
|
|
|
|
|
|
} catch (e) {
|
2026-04-07 12:11:15 +08:00
|
|
|
|
// 容错:即使报错也检查实际状态,可能请求已经成功但重试触发了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 || '请重试'))
|
2026-04-03 20:55:51 +08:00
|
|
|
|
} 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
|
|
|
|
|
|
}
|
2026-04-07 12:11:15 +08:00
|
|
|
|
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 => ({
|
2026-04-03 20:55:51 +08:00
|
|
|
|
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 {
|
2026-04-07 12:11:15 +08:00
|
|
|
|
height: 100vh;
|
|
|
|
|
|
height: 100dvh; /* 动态视口高度,兼容移动端地址栏 */
|
2026-04-03 20:55:51 +08:00
|
|
|
|
background: linear-gradient(180deg, #F0F8FF 0%, #FFF5F0 40%, #FFF8E7 70%, #FFFDF7 100%);
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
flex-direction: column;
|
2026-04-07 12:11:15 +08:00
|
|
|
|
overflow: hidden;
|
2026-04-03 20:55:51 +08:00
|
|
|
|
}
|
|
|
|
|
|
.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;
|
2026-04-07 12:11:15 +08:00
|
|
|
|
-webkit-overflow-scrolling: touch;
|
|
|
|
|
|
padding-bottom: 16px;
|
2026-04-03 20:55:51 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/* 页面展示 */
|
|
|
|
|
|
.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 {
|
2026-04-07 12:11:15 +08:00
|
|
|
|
flex-shrink: 0;
|
2026-04-03 20:55:51 +08:00
|
|
|
|
padding: 14px 20px 20px;
|
2026-04-07 12:11:15 +08:00
|
|
|
|
padding-bottom: calc(20px + env(safe-area-inset-bottom, 0px));
|
2026-04-03 20:55:51 +08:00
|
|
|
|
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; }
|
2026-04-08 09:36:42 +08:00
|
|
|
|
|
|
|
|
|
|
/* ── 确认弹窗(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; }
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-04-03 20:55:51 +08:00
|
|
|
|
</style>
|