library-picturebook-activity/lesingle-aicreate-client/src/views/Dubbing.vue

957 lines
26 KiB
Vue
Raw Normal View History

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'
import { getWorkDetail, voicePage, ossUpload, batchUpdateAudio, finishDubbing } from '@/api'
2026-04-03 20:55:51 +08:00
import { store } from '@/utils/store'
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()
// 每次用最新的 srcblob 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 {
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
}
store.workDetail = null // 清除缓存
showToast('配音完成!')
2026-04-03 20:55:51 +08:00
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 || '请重试'))
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
}
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 {
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;
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;
-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 {
flex-shrink: 0;
2026-04-03 20:55:51 +08:00
padding: 14px 20px 20px;
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>