library-picturebook-activity/lesingle-aicreate-client/src/views/Dubbing.vue
2026-04-08 09:36:42 +08:00

957 lines
26 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

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

<template>
<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()
// 每次用最新的 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() {
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>