Files
happy-life-star/mini-program/src/pages/main/ScriptLibraryView.vue
T
peanut dd11cfae73 feat: 人生事件和剧本收藏从 localStorage 切换为真实 API
- 后端:新增 t_event_favorite 表(UNIQUE KEY + MyBatis-Plus Entity)
- 后端:LifeEventController 新增 /favorite/toggle、/favorite/page、/favorite/check 三个真实端点
- 后端:EventFavoriteService 完整 CRUD,含 toggle/分页/批量查询/状态检查
- 后端:删除旧的 favorite-placeholder mock 端点
- 前端:life-event/detail.vue 从 localStorage 假收藏切换为真实 API + localStorage 缓存防闪烁
- 前端:ScriptLibraryView.vue 从 localFavorites 切换为 checkFavoriteScript 真实 API
- 前端:stores/app.js 新增 toggleEventFavorite / checkEventFavorite 方法
- 修复:审核发现的 UNIQUE 约束缺失、未使用 watch 导入、Controller 参数注解遗漏、Controller 委托 Service 构造 Response

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-19 23:23:40 +08:00

1028 lines
24 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>
<view class="script-library" @click="closeScriptMenu">
<view class="page-head">
<view class="back-title" @click="backToScript">
<text class="back-arrow"></text>
<text class="back-text">返回</text>
</view>
<view class="head-actions">
<view class="circle-btn" @click="openSearch">
<view class="search-icon"></view>
</view>
<view class="circle-btn" @click="openMoreMenu">
<view class="more-icon">
<view></view>
<view></view>
<view></view>
</view>
</view>
</view>
</view>
<view class="type-tabs">
<text
v-for="tab in typeTabs"
:key="tab.value"
class="type-tab"
:class="{ active: activeType === tab.value }"
@click="activeType = tab.value"
>{{ tab.label }}</text>
<view class="new-script" @click="createScript">
<text class="plus"></text>
<text>新建剧本</text>
</view>
</view>
<view class="filter-bar">
<scroll-view class="status-scroll" scroll-x :show-scrollbar="false">
<view class="status-row">
<text
v-for="filter in statusFilters"
:key="filter.value"
class="status-chip"
:class="{ active: activeStatus === filter.value }"
@click="activeStatus = filter.value"
>{{ filter.label }}</text>
</view>
</scroll-view>
<view class="sort-tools">
<text class="sort-text" @click="toggleSort">{{ sortLabel }}</text>
<view class="grid-icon" :class="{ active: viewMode === 'grid' }" @click="toggleViewMode">
<view v-for="i in 4" :key="i"></view>
</view>
</view>
</view>
<view v-if="visibleScripts.length" class="script-list" :class="{ grid: viewMode === 'grid' }">
<view
v-for="(script, index) in visibleScripts"
:key="script.id || index"
class="script-card"
@click="viewScript(script)"
>
<view class="cover" :class="'cover-' + (index % 6)">
<text>{{ getInitial(script) }}</text>
</view>
<view class="card-main">
<view class="card-top">
<view class="title-wrap">
<text class="script-title">{{ script.title }}</text>
<text class="length-badge">{{ getLengthLabel(script.length) }}</text>
</view>
<view class="right-state">
<text class="state-pill" :class="'state-' + getStatus(script)">{{ getStatusLabel(script) }}</text>
<view class="menu-wrap">
<text class="ellipsis" :class="{ active: activeMenuId === String(script.id) }" @click.stop="toggleScriptMenu(script)"></text>
<view v-if="activeMenuId === String(script.id)" class="card-menu" @click.stop>
<view class="menu-action" @click="toggleFavorite(script)">
<text class="menu-dot"></text>
<text>{{ isFavorite(script) ? '取消收藏' : '收藏剧本' }}</text>
</view>
<view class="menu-action" @click="continueScript(script)">
<text class="menu-dot"></text>
<text>继续生成</text>
</view>
<view class="menu-action" @click="openScriptDetailFromMenu(script)">
<text class="menu-dot"></text>
<text>查看详情</text>
</view>
<view class="menu-action danger" @click="requestDeleteScript(script)">
<text class="menu-dot"></text>
<text>删除剧本</text>
</view>
</view>
</view>
</view>
</view>
<view class="tag-row">
<text v-for="tag in getTags(script)" :key="tag" class="tag">{{ tag }}</text>
</view>
<text class="summary">{{ script.summary || script.content || '一段正在生成中的平行人生剧本。' }}</text>
<view class="meta-row">
<text>{{ getChapterCount(script) }}</text>
<text>|</text>
<text>{{ getWordCount(script) }}</text>
<text>|</text>
<text>{{ getDateText(script) }}</text>
</view>
<view v-if="getStatus(script) === 'progress'" class="progress-row">
<view class="progress-track">
<view class="progress-fill" :style="{ width: getProgress(script) + '%' }"></view>
</view>
<text>{{ getProgress(script) }}%</text>
</view>
<view v-else-if="isFavorite(script)" class="favorite-row">
<text class="favorite-star"></text>
<text>已收藏</text>
</view>
</view>
</view>
</view>
<view v-else class="empty-card">
<view class="empty-book">
<view></view>
<view></view>
</view>
<text class="empty-title">还没有人生剧本</text>
<text class="empty-text">去爽文生成页写下一句灵感生成你的第一段平行人生</text>
<view class="empty-action" @click="createScript">新建剧本</view>
</view>
<view v-if="deleteTarget" class="confirm-mask" @click.stop="cancelDeleteScript">
<view class="confirm-panel" @click.stop>
<text class="confirm-title">删除剧本</text>
<text class="confirm-copy">确定删除{{ deleteTarget.title || '未命名剧本' }}删除后将无法在历史剧本中查看</text>
<view class="confirm-actions">
<view class="confirm-btn secondary" @click="cancelDeleteScript">取消</view>
<view class="confirm-btn danger" :class="{ disabled: deletingScript }" @click="confirmDeleteScript">{{ deletingScript ? '删除中' : '确认删除' }}</view>
</view>
</view>
</view>
</view>
</template>
<script setup>
import { computed, ref, watch } from 'vue'
import { useAppStore } from '../../stores/app.js'
import { toggleFavoriteScript, checkFavoriteScript } from '../../services/epicScript.js'
const store = useAppStore()
const activeType = ref('long')
const activeStatus = ref('all')
const keyword = ref('')
const sortMode = ref('updated')
const viewMode = ref('list')
const favoriteStatus = ref({})
const activeMenuId = ref('')
const deleteTarget = ref(null)
const deletingScript = ref(false)
const typeTabs = [
{ label: '长篇', value: 'long' },
{ label: '短篇', value: 'short' },
{ label: '风格', value: 'style' }
]
const statusFilters = [
{ label: '全部', value: 'all' },
{ label: '进行中', value: 'progress' },
{ label: '已完成', value: 'done' },
{ label: '草稿箱', value: 'draft' },
{ label: '收藏夹', value: 'favorite' }
]
const scripts = computed(() => store.scripts || [])
const visibleScripts = computed(() => {
const filtered = scripts.value.filter(script => {
const status = getStatus(script)
if (keyword.value) {
const haystack = [script.title, script.summary, script.content, script.style, ...(script.tags || [])].join(' ')
if (!haystack.includes(keyword.value)) return false
}
if (activeStatus.value === 'favorite') return isFavorite(script)
if (activeStatus.value !== 'all' && status !== activeStatus.value) return false
if (activeType.value === 'short') return script.length === 'short'
if (activeType.value === 'long') return script.length !== 'short'
return true
})
return [...filtered].sort((a, b) => {
if (sortMode.value === 'words') return Number(b.wordCount || 0) - Number(a.wordCount || 0)
if (sortMode.value === 'progress') return getProgress(b) - getProgress(a)
return String(b.updateTime || b.updatedAt || b.createTime || b.date || '').localeCompare(String(a.updateTime || a.updatedAt || a.createTime || a.date || ''))
})
})
// 列表加载后批量检查收藏状态
const loadFavoriteStatus = async () => {
const ids = scripts.value.map(s => String(s.id)).filter(id => !id.startsWith('demo-'))
if (!ids.length) return
const status = {}
await Promise.all(ids.map(async (id) => {
try {
status[id] = await checkFavoriteScript(id)
} catch {
status[id] = false
}
}))
favoriteStatus.value = status
}
// 监听 scripts 变化时重新加载收藏状态
watch(scripts, () => {
loadFavoriteStatus()
}, { immediate: true })
const sortLabel = computed(() => {
const map = { updated: '最近更新⌄', words: '字数最多⌄', progress: '进度最高⌄' }
return map[sortMode.value] || '最近更新⌄'
})
const getStatus = (script) => {
if (script.status) return script.status
if (script.isDraft) return 'draft'
if (script.isCompleted || script.completedAt) return 'done'
return script.progress ? 'progress' : 'done'
}
const getStatusLabel = (script) => {
const map = { progress: '进行中', done: '已完成', draft: '草稿' }
return map[getStatus(script)] || '已完成'
}
const getLengthLabel = (length) => {
return length === 'short' ? '短篇' : '长篇'
}
const getTags = (script) => {
if (Array.isArray(script.tags) && script.tags.length) return script.tags.slice(0, 4)
return []
}
const getChapterCount = (script) => script.chapterCount || script.chapters || 0
const getWordCount = (script) => {
const count = Number(script.wordCount || 0)
if (count >= 10000) return `${(count / 10000).toFixed(1)}万字`
return `${count}`
}
const getDateText = (script) => {
if (getStatus(script) === 'done') return `完成于:${script.completedAt || script.date || ''}`
if (getStatus(script) === 'draft') return `创建于:${script.createdAt || script.date || ''}`
return `最近更新:${script.updatedAt || script.date || ''}`
}
const getProgress = (script) => Math.max(0, Math.min(99, Number(script.progress || 0)))
const getInitial = (script) => (script.title || '剧').slice(0, 1)
const isFavorite = (script) => {
return Boolean(favoriteStatus.value[String(script.id)])
}
const openScriptChat = (script) => {
if (!script?.id || String(script.id).startsWith('demo-')) return
uni.setStorageSync('pending_open_script_chat', {
id: script.id
})
uni.$emit('switchTab', 'script')
setTimeout(() => {
uni.$emit('openScriptChat', { id: script.id, script })
}, 80)
}
const viewScript = (script) => {
openScriptChat(script)
}
const openScriptDetail = (script) => {
if (!script?.id || String(script.id).startsWith('demo-')) return
uni.navigateTo({ url: `/pages/main/ScriptDetailView?id=${script.id}` })
}
const createScript = () => {
uni.$emit('switchTab', 'script')
}
const backToScript = () => {
uni.$emit('switchTab', 'script')
}
const openSearch = () => {
uni.showModal({
title: '搜索剧本',
editable: true,
placeholderText: '输入标题、标签或关键词',
success: (res) => {
if (res.confirm) keyword.value = String(res.content || '').trim()
}
})
}
const openMoreMenu = () => {
uni.showActionSheet({
itemList: ['清空搜索', '只看收藏', '查看全部'],
success: ({ tapIndex }) => {
if (tapIndex === 0) keyword.value = ''
if (tapIndex === 1) activeStatus.value = 'favorite'
if (tapIndex === 2) {
keyword.value = ''
activeStatus.value = 'all'
}
}
})
}
const toggleSort = () => {
const order = ['updated', 'words', 'progress']
sortMode.value = order[(order.indexOf(sortMode.value) + 1) % order.length]
}
const toggleViewMode = () => {
viewMode.value = viewMode.value === 'list' ? 'grid' : 'list'
}
const closeScriptMenu = () => {
activeMenuId.value = ''
}
const toggleScriptMenu = (script) => {
const id = String(script?.id || '')
activeMenuId.value = activeMenuId.value === id ? '' : id
}
const toggleFavorite = async (script) => {
const wasFav = isFavorite(script)
// 乐观更新:先改内存状态
const next = { ...favoriteStatus.value }
if (wasFav) {
delete next[String(script.id)]
} else {
next[String(script.id)] = true
}
favoriteStatus.value = next
closeScriptMenu()
try {
const result = await toggleFavoriteScript(script.id)
uni.showToast({ title: result.isFavorited ? '已收藏' : '已取消收藏', icon: 'success' })
} catch (error) {
// 回滚乐观更新
const restored = { ...favoriteStatus.value }
if (wasFav) {
restored[String(script.id)] = true
} else {
delete restored[String(script.id)]
}
favoriteStatus.value = restored
uni.showToast({ title: error?.message || '操作失败', icon: 'none' })
}
}
const continueScript = (script) => {
closeScriptMenu()
viewScript(script)
}
const openScriptDetailFromMenu = (script) => {
closeScriptMenu()
openScriptDetail(script)
}
const requestDeleteScript = (script) => {
closeScriptMenu()
if (!script?.id || String(script.id).startsWith('demo-')) {
uni.showToast({ title: '示例剧本不可删除', icon: 'none' })
return
}
deleteTarget.value = script
}
const cancelDeleteScript = () => {
if (deletingScript.value) return
deleteTarget.value = null
}
const confirmDeleteScript = async () => {
if (!deleteTarget.value?.id || deletingScript.value) return
deletingScript.value = true
const id = String(deleteTarget.value.id)
const res = await store.deleteScript(id)
deletingScript.value = false
if (!res.success) {
uni.showToast({ title: res.error || '删除失败', icon: 'none' })
return
}
const next = { ...favoriteStatus.value }
delete next[id]
favoriteStatus.value = next
const conversationId = deleteTarget.value.conversationId || deleteTarget.value.plotJson?.conversationId
if (conversationId) uni.removeStorageSync(`script_conversation_history_${conversationId}`)
uni.removeStorageSync(`script_chat_history_${id}`)
const pending = uni.getStorageSync('pending_open_script_chat')
if (pending?.id === id) uni.removeStorageSync('pending_open_script_chat')
deleteTarget.value = null
uni.showToast({ title: '已删除剧本', icon: 'success' })
}
</script>
<style scoped>
.script-library {
display: flex;
flex-direction: column;
gap: 24rpx;
padding-bottom: 26rpx;
}
.page-head,
.back-title,
.head-actions,
.type-tabs,
.filter-bar,
.sort-tools,
.card-top,
.title-wrap,
.right-state,
.meta-row,
.progress-row,
.favorite-row {
display: flex;
align-items: center;
}
.page-head {
justify-content: space-between;
}
.back-title {
height: 60rpx;
gap: 10rpx;
color: rgba(255, 255, 255, 0.94);
font-weight: 900;
}
.back-arrow {
font-size: 58rpx;
line-height: 1;
transform: translateY(-2rpx);
}
.back-text {
font-size: 32rpx;
}
.head-actions {
gap: 20rpx;
}
.circle-btn {
width: 58rpx;
height: 58rpx;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
border: 1rpx solid rgba(142, 105, 255, 0.36);
background: rgba(10, 11, 38, 0.72);
}
.search-icon {
width: 25rpx;
height: 25rpx;
border: 4rpx solid #fff;
border-radius: 50%;
position: relative;
}
.search-icon::after {
content: '';
position: absolute;
right: -9rpx;
bottom: -8rpx;
width: 14rpx;
height: 4rpx;
border-radius: 999rpx;
background: #fff;
transform: rotate(45deg);
}
.more-icon {
display: flex;
gap: 5rpx;
}
.more-icon view {
width: 6rpx;
height: 6rpx;
border-radius: 50%;
background: #fff;
}
.type-tabs {
justify-content: space-between;
border-bottom: 1rpx solid rgba(126, 87, 255, 0.18);
padding-bottom: 16rpx;
}
.type-tab {
position: relative;
color: rgba(224, 214, 243, 0.7);
font-size: 31rpx;
font-weight: 900;
padding: 0 20rpx 14rpx;
}
.type-tab.active {
color: #fff;
}
.type-tab.active::after {
content: '';
position: absolute;
left: 20rpx;
right: 20rpx;
bottom: -17rpx;
height: 5rpx;
border-radius: 999rpx;
background: #b246ff;
box-shadow: 0 0 18rpx rgba(178, 70, 255, 0.8);
}
.new-script {
margin-left: auto;
height: 64rpx;
padding: 0 24rpx;
border-radius: 999rpx;
display: flex;
align-items: center;
gap: 8rpx;
color: #fff;
font-size: 24rpx;
font-weight: 800;
background: linear-gradient(135deg, #b346ff, #7330ff);
box-shadow: 0 0 26rpx rgba(168, 85, 247, 0.54);
}
.plus {
font-size: 32rpx;
line-height: 1;
}
.filter-bar {
gap: 14rpx;
}
.status-scroll {
flex: 1;
min-width: 0;
white-space: nowrap;
}
.status-row {
display: inline-flex;
gap: 16rpx;
}
.status-chip {
height: 52rpx;
min-width: 88rpx;
padding: 0 24rpx;
border-radius: 999rpx;
display: inline-flex;
align-items: center;
justify-content: center;
color: rgba(224, 214, 243, 0.78);
font-size: 23rpx;
border: 1rpx solid rgba(151, 111, 255, 0.42);
background: rgba(255, 255, 255, 0.02);
}
.status-chip.active {
color: #fff;
border-color: rgba(206, 82, 255, 0.92);
background: rgba(130, 48, 220, 0.42);
box-shadow: 0 0 18rpx rgba(168, 67, 255, 0.46);
}
.sort-tools {
gap: 14rpx;
flex-shrink: 0;
}
.sort-text {
color: #c99fff;
font-size: 23rpx;
}
.grid-icon {
width: 48rpx;
height: 48rpx;
border-radius: 18rpx;
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 6rpx;
padding: 11rpx;
box-sizing: border-box;
border: 1rpx solid rgba(151, 111, 255, 0.32);
}
.grid-icon view {
border: 2rpx solid rgba(230, 222, 250, 0.78);
border-radius: 3rpx;
}
.script-list {
display: flex;
flex-direction: column;
gap: 18rpx;
}
.script-list.grid {
display: grid;
grid-template-columns: 1fr 1fr;
}
.script-list.grid .script-card {
grid-template-columns: 1fr;
}
.script-list.grid .cover {
width: 100%;
}
.grid-icon.active {
border-color: rgba(206, 82, 255, 0.9);
background: rgba(130, 48, 220, 0.28);
}
.script-card {
display: grid;
grid-template-columns: 150rpx 1fr;
gap: 22rpx;
min-height: 196rpx;
padding: 20rpx;
border-radius: 24rpx;
border: 1rpx solid rgba(105, 79, 210, 0.34);
background:
radial-gradient(circle at 100% 0%, rgba(112, 72, 255, 0.14), transparent 38%),
rgba(9, 12, 42, 0.72);
box-shadow: inset 0 0 28rpx rgba(92, 57, 197, 0.08);
}
.cover {
width: 150rpx;
height: 150rpx;
border-radius: 20rpx;
display: flex;
align-items: center;
justify-content: center;
color: #fff;
font-size: 54rpx;
font-weight: 900;
overflow: hidden;
background: linear-gradient(135deg, #3b1a90, #d65cff);
}
.cover-0 { background: linear-gradient(135deg, #29135f, #9037ff 48%, #191b5e); }
.cover-1 { background: linear-gradient(135deg, #3c1c4a, #f2b3cc 48%, #16143b); }
.cover-2 { background: linear-gradient(135deg, #1a225f, #7d4cff 48%, #0a0f2c); }
.cover-3 { background: linear-gradient(135deg, #2f240b, #f7b44a 48%, #0d0a16); }
.cover-4 { background: linear-gradient(135deg, #3f2417, #d8b58a 48%, #17101d); }
.cover-5 { background: linear-gradient(135deg, #141451, #cc46ff 48%, #0c0b28); }
.card-main {
min-width: 0;
}
.card-top {
justify-content: space-between;
gap: 14rpx;
}
.title-wrap {
min-width: 0;
gap: 10rpx;
}
.script-title {
color: #fff;
font-size: 27rpx;
line-height: 1.25;
font-weight: 900;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.length-badge {
flex-shrink: 0;
padding: 4rpx 9rpx;
border-radius: 8rpx;
color: #c985ff;
font-size: 18rpx;
border: 1rpx solid rgba(182, 92, 255, 0.5);
background: rgba(128, 55, 204, 0.22);
}
.right-state {
position: relative;
flex-shrink: 0;
gap: 14rpx;
}
.state-pill {
height: 34rpx;
padding: 0 14rpx;
border-radius: 999rpx;
display: flex;
align-items: center;
font-size: 19rpx;
}
.state-progress {
color: #ffbf4c;
background: rgba(170, 103, 20, 0.22);
}
.state-done {
color: #79e6a9;
background: rgba(44, 146, 88, 0.2);
}
.state-draft {
color: rgba(224, 214, 243, 0.76);
background: rgba(255, 255, 255, 0.06);
}
.ellipsis {
min-width: 48rpx;
height: 44rpx;
display: flex;
align-items: center;
justify-content: center;
border-radius: 18rpx;
color: rgba(224, 214, 243, 0.66);
font-size: 24rpx;
letter-spacing: 3rpx;
}
.ellipsis.active {
color: #fff;
background: rgba(149, 55, 255, 0.22);
box-shadow: 0 0 20rpx rgba(168, 67, 255, 0.28);
}
.menu-wrap {
position: relative;
}
.card-menu {
position: absolute;
top: 52rpx;
right: 0;
z-index: 30;
width: 202rpx;
padding: 10rpx;
border-radius: 22rpx;
border: 1rpx solid rgba(192, 132, 252, 0.32);
background:
radial-gradient(circle at 90% 0%, rgba(168, 85, 247, 0.22), transparent 36%),
rgba(12, 8, 34, 0.96);
box-shadow:
inset 0 1rpx 0 rgba(255, 255, 255, 0.08),
0 18rpx 42rpx rgba(0, 0, 0, 0.34);
backdrop-filter: blur(22rpx);
-webkit-backdrop-filter: blur(22rpx);
}
.card-menu::before {
content: '';
position: absolute;
right: 22rpx;
top: -10rpx;
width: 18rpx;
height: 18rpx;
border-left: 1rpx solid rgba(192, 132, 252, 0.3);
border-top: 1rpx solid rgba(192, 132, 252, 0.3);
background: rgba(12, 8, 34, 0.96);
transform: rotate(45deg);
}
.menu-action {
position: relative;
z-index: 1;
height: 58rpx;
padding: 0 14rpx;
display: flex;
align-items: center;
gap: 12rpx;
border-radius: 16rpx;
color: rgba(240, 232, 255, 0.9);
font-size: 23rpx;
font-weight: 800;
}
.menu-action:active {
background: rgba(149, 55, 255, 0.18);
}
.menu-action.danger {
color: #ff8fa3;
}
.menu-dot {
width: 8rpx;
height: 8rpx;
border-radius: 50%;
background: currentColor;
opacity: 0.72;
}
.tag-row {
display: flex;
flex-wrap: wrap;
gap: 12rpx;
margin-top: 16rpx;
}
.tag {
height: 34rpx;
padding: 0 14rpx;
border-radius: 999rpx;
display: flex;
align-items: center;
color: #d49cff;
font-size: 19rpx;
background: rgba(149, 55, 255, 0.2);
}
.summary {
display: -webkit-box;
margin-top: 14rpx;
color: rgba(226, 215, 246, 0.72);
font-size: 22rpx;
line-height: 1.55;
overflow: hidden;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
}
.meta-row {
gap: 14rpx;
margin-top: 14rpx;
color: rgba(218, 204, 243, 0.66);
font-size: 21rpx;
}
.progress-row {
justify-content: flex-end;
gap: 14rpx;
margin-top: 14rpx;
color: #bd72ff;
font-size: 22rpx;
font-weight: 800;
}
.progress-track {
width: 118rpx;
height: 6rpx;
border-radius: 999rpx;
background: rgba(173, 160, 210, 0.18);
overflow: hidden;
}
.progress-fill {
height: 100%;
border-radius: inherit;
background: linear-gradient(90deg, #b246ff, #d878ff);
}
.favorite-row {
justify-content: flex-end;
gap: 8rpx;
margin-top: 14rpx;
color: #b768ff;
font-size: 23rpx;
font-weight: 800;
}
.favorite-star {
font-size: 28rpx;
}
.empty-card {
margin-top: 30rpx;
border-radius: 26rpx;
padding: 44rpx 30rpx;
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
border: 1rpx solid rgba(105, 79, 210, 0.34);
background: rgba(9, 12, 42, 0.72);
}
.empty-book {
display: flex;
gap: 6rpx;
margin-bottom: 18rpx;
}
.empty-book view {
width: 32rpx;
height: 46rpx;
border: 4rpx solid #b768ff;
border-radius: 8rpx 4rpx 4rpx 8rpx;
}
.empty-title {
color: #fff;
font-size: 28rpx;
font-weight: 900;
}
.empty-text {
margin-top: 12rpx;
color: rgba(226, 215, 246, 0.68);
font-size: 22rpx;
line-height: 1.5;
}
.empty-action {
margin-top: 22rpx;
height: 56rpx;
padding: 0 30rpx;
border-radius: 999rpx;
display: flex;
align-items: center;
color: #fff;
font-size: 23rpx;
font-weight: 800;
background: linear-gradient(135deg, #b346ff, #7330ff);
}
.confirm-mask {
position: fixed;
inset: 0;
z-index: 80;
display: flex;
align-items: center;
justify-content: center;
padding: 0 48rpx;
background: rgba(3, 2, 13, 0.62);
backdrop-filter: blur(12rpx);
-webkit-backdrop-filter: blur(12rpx);
}
.confirm-panel {
width: 100%;
box-sizing: border-box;
padding: 34rpx 30rpx 28rpx;
border-radius: 28rpx;
border: 1rpx solid rgba(192, 132, 252, 0.34);
background:
radial-gradient(circle at 92% 0%, rgba(168, 85, 247, 0.2), transparent 36%),
linear-gradient(145deg, rgba(22, 13, 58, 0.98), rgba(8, 7, 26, 0.98));
box-shadow: 0 24rpx 72rpx rgba(0, 0, 0, 0.42);
}
.confirm-title {
display: block;
color: #fff;
font-size: 32rpx;
font-weight: 900;
text-align: center;
}
.confirm-copy {
display: block;
margin-top: 18rpx;
color: rgba(229, 219, 247, 0.76);
font-size: 24rpx;
line-height: 1.55;
text-align: center;
}
.confirm-actions {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 18rpx;
margin-top: 30rpx;
}
.confirm-btn {
height: 70rpx;
border-radius: 24rpx;
display: flex;
align-items: center;
justify-content: center;
font-size: 25rpx;
font-weight: 900;
}
.confirm-btn.secondary {
color: #e8ccff;
border: 1rpx solid rgba(192, 132, 252, 0.3);
background: rgba(88, 28, 135, 0.2);
}
.confirm-btn.danger {
color: #fff;
background: linear-gradient(145deg, #e05276, #8d2444);
box-shadow: 0 12rpx 30rpx rgba(224, 82, 118, 0.24);
}
.confirm-btn.disabled {
opacity: 0.55;
}
</style>