功能:新增设置页面和海报卡片的导演、主演及简介展示,优化海报加载样式

This commit is contained in:
liRQ 2026-06-27 16:02:19 +08:00
parent a9066b9046
commit 3fb7f45789
5 changed files with 852 additions and 55 deletions

View File

@ -191,6 +191,10 @@
<span class="user-menu__icon"></span> <span class="user-menu__icon"></span>
<span>TMDB 配置</span> <span>TMDB 配置</span>
</button> </button>
<button type="button" class="user-menu__item" @click="handleSettings">
<span class="user-menu__icon">🛠</span>
<span>设置</span>
</button>
<button type="button" class="user-menu__item" @click="handleExport"> <button type="button" class="user-menu__item" @click="handleExport">
<span class="user-menu__icon">📤</span> <span class="user-menu__icon">📤</span>
<span>导出数据</span> <span>导出数据</span>
@ -222,8 +226,12 @@
v-if="featuredPosters.length" v-if="featuredPosters.length"
:key="activeFeaturedPoster.id" :key="activeFeaturedPoster.id"
class="hero-main" class="hero-main"
:class="{ 'hero-loading': !heroImageLoaded, 'hero-loaded': heroImageLoaded }" :class="{
:style="buildHeroStyle(activeFeaturedPoster.posterUrl)" 'hero-loading': !heroImageLoaded,
'hero-loaded': heroImageLoaded,
'hero-wide': isWidePoster(activeFeaturedPoster.posterUrl)
}"
:style="buildHeroStyle(activeFeaturedPoster)"
tabindex="0" tabindex="0"
@mouseenter="pauseFeaturedAutoplay" @mouseenter="pauseFeaturedAutoplay"
@mouseleave="resumeFeaturedAutoplay" @mouseleave="resumeFeaturedAutoplay"
@ -232,6 +240,15 @@
@keydown.left.prevent="showPrevFeaturedPoster" @keydown.left.prevent="showPrevFeaturedPoster"
@keydown.right.prevent="showNextFeaturedPoster" @keydown.right.prevent="showNextFeaturedPoster"
> >
<!-- 海报图片层 -->
<img
v-if="activeFeaturedPoster.posterUrl"
:src="activeFeaturedPoster.posterUrl"
:alt="activeFeaturedPoster.title"
class="hero-bg-image"
@load="heroImageLoaded = true"
/>
<div class="hero-main__overlay"> <div class="hero-main__overlay">
<div class="hero-main__badge">每日推荐</div> <div class="hero-main__badge">每日推荐</div>
<div class="hero-main__info"> <div class="hero-main__info">
@ -541,8 +558,11 @@
<p>根据网盘管理中的标题与海报地址进行 TMDB 匹配展示可作为刮削结果预览</p> <p>根据网盘管理中的标题与海报地址进行 TMDB 匹配展示可作为刮削结果预览</p>
</div> </div>
</div> </div>
<div class="poster-list poster-list--showcase"> <div v-if="loadingPosterShowcase" class="poster-list-loading">
<div v-for="item in posterShowcaseRows" :key="item.id" class="poster-card-wrap"> 正在加载海报数据...
</div>
<div v-else class="poster-list poster-list--showcase">
<div v-for="item in posterShowcaseData" :key="item.id" class="poster-card-wrap">
<PosterCard :item="item" :readonly="true" /> <PosterCard :item="item" :readonly="true" />
</div> </div>
</div> </div>
@ -556,14 +576,14 @@
<div class="content-toolbar"> <div class="content-toolbar">
<div> <div>
<h2>搜索结果</h2> <h2>搜索结果</h2>
<p>找到 {{ posterShowcaseRows.length }} 个匹配{{ searchKeyword }}的结果</p> <p>找到 {{ filteredPosterShowcaseData.length }} 个匹配{{ searchKeyword }}的结果</p>
</div> </div>
<button type="button" class="secondary" @click="hasSearched = false; searchKeyword = ''"> <button type="button" class="secondary" @click="hasSearched = false; searchKeyword = ''">
清空搜索 清空搜索
</button> </button>
</div> </div>
<div v-if="posterShowcaseRows.length > 0" class="poster-list poster-list--showcase"> <div v-if="filteredPosterShowcaseData.length > 0" class="poster-list poster-list--showcase">
<div v-for="item in posterShowcaseRows" :key="item.id" class="poster-card-wrap"> <div v-for="item in filteredPosterShowcaseData" :key="item.id" class="poster-card-wrap">
<PosterCard :item="item" :readonly="true" /> <PosterCard :item="item" :readonly="true" />
</div> </div>
</div> </div>
@ -816,6 +836,16 @@
@cancel="showConfirmDialog = false" @cancel="showConfirmDialog = false"
/> />
<!-- 设置页面 -->
<Transition name="fade">
<div v-if="showSettingsView" class="settings-overlay" @click.self="showSettingsView = false">
<div class="settings-container">
<button type="button" class="settings-close" @click="showSettingsView = false" title="关闭设置"></button>
<SettingsView />
</div>
</div>
</Transition>
<!-- 网盘标签页悬浮描述提示框Teleport到body避免被父容器overflow裁剪 --> <!-- 网盘标签页悬浮描述提示框Teleport到body避免被父容器overflow裁剪 -->
<Teleport to="body"> <Teleport to="body">
<Transition name="tooltip-fade"> <Transition name="tooltip-fade">
@ -848,6 +878,7 @@ import UserManageDialog from './components/UserManageDialog.vue'
import DriveManageDialog from './components/DriveManageDialog.vue' import DriveManageDialog from './components/DriveManageDialog.vue'
import ConfirmDialog from './components/ConfirmDialog.vue' import ConfirmDialog from './components/ConfirmDialog.vue'
import IconManageDialog from './components/IconManageDialog.vue' import IconManageDialog from './components/IconManageDialog.vue'
import SettingsView from './components/SettingsView.vue'
import { import {
batchScrapeAll, batchScrapeAll,
batchScrapeMovies, batchScrapeMovies,
@ -859,6 +890,7 @@ import {
deleteMovieInDrive, deleteMovieInDrive,
deleteRecord, deleteRecord,
exportData, exportData,
fetchAllMovies,
fetchBatchScrapeStatus, fetchBatchScrapeStatus,
fetchDashboardData, fetchDashboardData,
fetchDriveList, fetchDriveList,
@ -1055,15 +1087,48 @@ const folderTreeData = computed(() => {
return buildTree(rows) return buildTree(rows)
}) })
// //
const posterShowcaseRows = computed(() => { const posterShowcaseData = ref([]) //
// const loadingPosterShowcase = ref(false) //
const allSheetsRows = flattenRows(rawData.value?.sheets || [])
// //
const filtered = filterRows(allSheetsRows, searchKeyword.value) const filteredPosterShowcaseData = computed(() => {
// if (!searchKeyword.value.trim()) {
return filtered.filter(item => item.type !== 'folder') return posterShowcaseData.value
}
const keyword = searchKeyword.value.toLowerCase()
return posterShowcaseData.value.filter(item => {
const title = (item.title || '').toLowerCase()
const originalName = (item.originalName || '').toLowerCase()
const description = (item.description || '').toLowerCase()
return title.includes(keyword) || originalName.includes(keyword) || description.includes(keyword)
})
}) })
/**
* 加载海报展示数据从后端获取所有电影
* @param {string} sortBy - 排序字段createTime/updateTime/rating/title
* @param {string} sortOrder - 排序方式asc/desc
*/
async function loadPosterShowcase(sortBy = 'updateTime', sortOrder = 'desc') {
loadingPosterShowcase.value = true
try {
const result = await fetchAllMovies({ pageNo: 1, pageSize: 50, sortBy, sortOrder })
if (result?.code === 200 && Array.isArray(result?.data?.list)) {
posterShowcaseData.value = result.data.list
console.log(`海报展示加载成功,共 ${result.data.list.length}`)
} else {
console.warn('海报展示返回数据异常', result)
posterShowcaseData.value = []
}
} catch (err) {
console.error('加载海报展示失败', err)
posterShowcaseData.value = []
} finally {
loadingPosterShowcase.value = false
}
}
const loginLogs = computed(() => rawData.value?.loginLogs || []) const loginLogs = computed(() => rawData.value?.loginLogs || [])
const logIntegrity = computed(() => loginLogs.value.length > 0) const logIntegrity = computed(() => loginLogs.value.length > 0)
@ -1102,16 +1167,36 @@ const hasValidFeaturedPosters = computed(() => {
return featuredPosters.value.length > 0 && featuredPosters.value.some(p => p.posterUrl && p.posterUrl.trim() !== '') return featuredPosters.value.length > 0 && featuredPosters.value.some(p => p.posterUrl && p.posterUrl.trim() !== '')
}) })
/**
* 判断海报是否为横向图片基于 URL 特征或宽高比
* @param {string|object} poster - 海报 URL 或海报对象
* @returns {boolean}
*/
function isWidePoster(poster) {
// posterUrl
const url = typeof poster === 'object' ? poster?.posterUrl : poster
// URL
if (!url || url.trim() === '') return false
// URL backdrop
if (url.toLowerCase().includes('backdrop')) return true
// URL widehorizontallandscape
if (url.toLowerCase().match(/wide|horizontal|landscape|banner/)) return true
//
return false
}
/**
* 构建大海报样式不再使用 background-image改用 img 标签
* @param {object} poster - 海报对象 { id, title, posterUrl, ... }
* @returns {object} Vue 样式对象
*/
function buildHeroStyle(poster) { function buildHeroStyle(poster) {
// 使"" // <img>
if (!poster || poster.trim() === '') { return {}
return {
backgroundImage: `url('/暂无新片上映海报生成.png')`,
}
}
return {
backgroundImage: `linear-gradient(180deg, rgba(8, 15, 29, 0.1), rgba(8, 15, 29, 0.82)), url(${poster})`,
}
} }
function buildSubThumbStyle(poster) { function buildSubThumbStyle(poster) {
@ -1829,6 +1914,14 @@ function handleTmdbConfig() {
showTmdbDialog.value = true showTmdbDialog.value = true
} }
//
const showSettingsView = ref(false)
function handleSettings() {
showUserMenu.value = false
showSettingsView.value = true
}
// //
function handleChangeAvatar() { function handleChangeAvatar() {
showUserMenu.value = false showUserMenu.value = false
@ -2111,9 +2204,11 @@ async function doDeleteNode(node) {
/** /**
* 切换到海报展示视图 * 切换到海报展示视图
*/ */
function switchToPoster() { async function switchToPoster() {
showUserMenu.value = false showUserMenu.value = false
currentView.value = 'poster' currentView.value = 'poster'
//
await loadPosterShowcase()
} }
/** /**
@ -2951,10 +3046,11 @@ onMounted(() => {
window.addEventListener('wangpan:session-expiring', handleSessionExpiring) window.addEventListener('wangpan:session-expiring', handleSessionExpiring)
document.addEventListener('click', handleClickOutside) document.addEventListener('click', handleClickOutside)
// //
Promise.all([ Promise.all([
loadDashboard(), loadDashboard(),
loadRecommendMovies(), loadRecommendMovies(),
loadPosterShowcase(),
]).catch(err => { ]).catch(err => {
console.error('初始化加载失败', err) console.error('初始化加载失败', err)
}) })

View File

@ -475,7 +475,7 @@ export function createMovieInDrive(driveId, payload) {
* 更新电影信息 * 更新电影信息
* @param {string|number} driveId - 网盘ID * @param {string|number} driveId - 网盘ID
* @param {string|number} movieId - 电影ID * @param {string|number} movieId - 电影ID
* @param {Object} payload - 可选字段title, originalName, subtitle, description, rating, tags, category * @param {Object} payload - 可选字段title, originalName, subtitle, description, rating, tags, category, directors, casts
* @returns {Promise} * @returns {Promise}
*/ */
export function updateMovieInDrive(driveId, movieId, payload) { export function updateMovieInDrive(driveId, movieId, payload) {
@ -487,6 +487,8 @@ export function updateMovieInDrive(driveId, movieId, payload) {
if (payload.rating !== undefined) body.rating = payload.rating if (payload.rating !== undefined) body.rating = payload.rating
if (payload.tags !== undefined) body.tags = payload.tags if (payload.tags !== undefined) body.tags = payload.tags
if (payload.category !== undefined) body.category = payload.category if (payload.category !== undefined) body.category = payload.category
if (payload.directors !== undefined) body.directors = payload.directors
if (payload.casts !== undefined) body.casts = payload.casts
return request(`${API_BASE_URL}/drive/${driveId}/movies/${movieId}`, { return request(`${API_BASE_URL}/drive/${driveId}/movies/${movieId}`, {
method: 'PUT', method: 'PUT',
@ -720,6 +722,30 @@ export function fetchScraperLogs(params = {}) {
}) })
} }
// ==================== 获取所有电影(海报展示用) ====================
/**
* 获取所有电影列表用于海报展示页
* @param {Object} params - { pageNo, pageSize, sortBy, sortOrder }
* @param {number} [params.pageNo=1] - 页码
* @param {number} [params.pageSize=50] - 每页条数最多50
* @param {string} [params.sortBy=createTime] - 排序字段createTime/updateTime/rating/title
* @param {string} [params.sortOrder=desc] - 排序方式asc/desc
* @returns {Promise} 返回格式{ code: 200, data: { total, list: [{ id, title, posterUrl, ... }], pageNo, pageSize } }
*/
export function fetchAllMovies(params = {}) {
const searchParams = new URLSearchParams()
if (params.pageNo) searchParams.set('pageNo', String(params.pageNo))
if (params.pageSize) searchParams.set('pageSize', String(Math.min(params.pageSize, 50))) // 最多50条
if (params.sortBy) searchParams.set('sortBy', params.sortBy)
if (params.sortOrder) searchParams.set('sortOrder', params.sortOrder)
const query = searchParams.toString()
return request(`${API_BASE_URL}/movies/all${query ? `?${query}` : ''}`, {
method: 'GET',
})
}
// ==================== 批量刮削 API ==================== // ==================== 批量刮削 API ====================
/** /**
@ -804,3 +830,51 @@ export async function fetchBatchScrapeStatus(movieIds) {
data: results.filter(Boolean), data: results.filter(Boolean),
} }
} }
// ==================== OpenList 绑定 API ====================
/**
* 查询 OpenList 绑定信息
* @returns {Promise} 返回格式{ code: 200, data: { id, openlistUrl, openlistUsername, storageId, mountPath, status, tokenValid, createTime, updateTime } } null
*/
export function getOpenListInfo() {
return request(`${API_BASE_URL}/openlist/info`, {
method: 'GET',
})
}
/**
* 绑定 OpenList 后端接口
* @param {Object} payload - { url, username, password }
* @returns {Promise} 返回格式{ code: 200, message: "绑定成功" }
*/
export function bindOpenList(payload) {
return request(`${API_BASE_URL}/openlist/bind`, {
method: 'POST',
body: JSON.stringify({
url: payload.url,
username: payload.username,
password: payload.password,
}),
})
}
/**
* 查询 OpenList 绑定状态
* @returns {Promise} 返回格式{ code: 200, data: { bound: boolean, url?: string, username?: string, boundAt?: string } }
*/
export function fetchOpenListStatus() {
return request(`${API_BASE_URL}/openlist/status`, {
method: 'GET',
})
}
/**
* 解除 OpenList 绑定
* @returns {Promise} 返回格式{ code: 200, message: "已解除绑定" }
*/
export function unbindOpenList() {
return request(`${API_BASE_URL}/openlist/unbind`, {
method: 'POST',
})
}

View File

@ -1,31 +1,92 @@
<template> <template>
<article class="poster-card" :class="{ 'is-readonly': readonly }" @click="handleClick"> <article class="poster-card" :class="{ 'is-readonly': readonly }" @click="handleClick">
<div class="poster-thumb"> <div class="poster-thumb">
<img :src="item.poster" :alt="item.title" loading="lazy" /> <img :src="posterImageUrl" :alt="item.title" loading="lazy" />
<span class="score-badge">{{ item.score }}</span> <span class="score-badge">{{ displayScore }}</span>
</div> </div>
<div class="poster-body"> <div class="poster-body">
<div class="poster-meta-row"> <div class="poster-meta-row">
<span class="status-pill" :class="statusClass">{{ item.status }}</span> <span class="poster-time">{{ displayFormattedDate }}</span>
<span class="poster-time">{{ item.updatedAt }}</span>
</div> </div>
<h3>{{ item.title }}</h3> <h3>{{ item.title }}</h3>
<p>{{ item.subtitle }}</p> <p v-if="item.originalName">{{ item.originalName }}</p>
<div class="poster-owner">
<span class="poster-sheet">{{ item.sheetName }}</span> <!-- 导演和主演 -->
<span>{{ item.accountName }}</span> <div v-if="item.directors" class="poster-director">
<small>{{ item.owner }}</small> 🎬 {{ item.directors }}
</div> </div>
<div v-if="item.casts" class="poster-casts">
🎭 {{ item.casts }}
</div>
<!-- 简介按钮点击打开弹窗 -->
<div v-if="item.description" class="poster-description">
<button
type="button"
class="description-toggle"
@click.stop="showDescriptionDialog = true"
>
📝 查看详情
</button>
</div>
<div class="tag-list"> <div class="tag-list">
<span v-for="tag in item.tags" :key="tag">{{ tag }}</span> <span v-for="tag in displayTags" :key="tag">{{ tag }}</span>
</div> </div>
</div> </div>
<!-- 简介弹窗 -->
<Teleport to="body">
<Transition name="dialog-fade">
<div v-if="showDescriptionDialog" class="description-overlay" @click.self="showDescriptionDialog = false">
<div class="description-dialog">
<div class="description-dialog__header">
<h3>{{ item.title }} - 详情</h3>
<button type="button" class="description-dialog__close" @click="showDescriptionDialog = false"></button>
</div>
<div class="description-dialog__body">
<!-- 基本信息 -->
<div v-if="item.description" class="detail-section">
<h4>📖 简介</h4>
<p class="detail-text">{{ item.description }}</p>
</div>
<div v-if="item.directors" class="detail-section">
<h4>🎬 导演</h4>
<p class="detail-text">{{ item.directors }}</p>
</div>
<div v-if="item.casts" class="detail-section">
<h4>🎭 主演</h4>
<p class="detail-text">{{ item.casts }}</p>
</div>
<div v-if="item.tags" class="detail-section">
<h4>🏷 标签</h4>
<div class="detail-tags">
<span v-for="tag in displayTags" :key="tag" class="detail-tag">{{ tag }}</span>
</div>
</div>
<div v-if="item.score || item.rating" class="detail-section">
<h4> 评分</h4>
<p class="detail-text">{{ displayScore }}</p>
</div>
<div v-if="item.category" class="detail-section">
<h4>📂 分类</h4>
<p class="detail-text">{{ item.category }}</p>
</div>
<div v-if="item.status" class="detail-section">
<h4>📌 状态</h4>
<p class="detail-text">{{ displayStatus }}</p>
</div>
</div>
</div>
</div>
</Transition>
</Teleport>
</article> </article>
</template> </template>
<script setup> <script setup>
import { computed } from 'vue' import { computed, ref } from 'vue'
const props = defineProps({ const props = defineProps({
item: { item: {
@ -40,10 +101,46 @@ const props = defineProps({
const emit = defineEmits(['edit']) const emit = defineEmits(['edit'])
const statusClass = computed(() => { //
if (props.item.status === '正常') return 'is-normal' const showDescriptionDialog = ref(false)
if (props.item.status === '待复核') return 'is-warning'
return 'is-danger' // URL
const posterImageUrl = computed(() => {
// poster, posterUrl, backdropUrl
return props.item.poster || props.item.posterUrl || props.item.backdropUrl || ''
})
//
const displayScore = computed(() => {
const score = props.item.score ?? props.item.rating ?? 0
return score ? `${score}` : '暂无评分'
})
// ISO 8601
const displayFormattedDate = computed(() => {
const raw = props.item.updatedAt || props.item.updateTime || props.item.createTime || ''
if (!raw) return ''
// T 2026-06-26T15:55:42 2026-06-26 15:55:42
let formatted = raw.replace('T', ' ')
// .000Z +08:00
formatted = formatted.replace(/\.\d+(Z|[+-]\d{2}:\d{2})?$/, '')
return formatted
})
//
const displayStatus = computed(() => {
return props.item.status || '正常'
})
//
const displayTags = computed(() => {
const tags = props.item.tags
if (Array.isArray(tags)) return tags
if (typeof tags === 'string' && tags.trim()) return tags.split(',').map(t => t.trim())
return []
}) })
function handleClick() { function handleClick() {
@ -51,4 +148,4 @@ function handleClick() {
emit('edit', props.item) emit('edit', props.item)
} }
} }
</script> </script>

View File

@ -121,8 +121,28 @@
<span class="bound-info__value">{{ boundInfo.username }}</span> <span class="bound-info__value">{{ boundInfo.username }}</span>
</div> </div>
<div class="bound-info__item"> <div class="bound-info__item">
<span class="bound-info__label">绑定时间</span> <span class="bound-info__label">存储ID</span>
<span class="bound-info__value">{{ boundInfo.boundAt }}</span> <span class="bound-info__value">{{ boundInfo.storageId || '-' }}</span>
</div>
<div class="bound-info__item">
<span class="bound-info__label">挂载路径</span>
<span class="bound-info__value">{{ boundInfo.mountPath || '-' }}</span>
</div>
<div class="bound-info__item">
<span class="bound-info__label">状态</span>
<span class="bound-info__value" :class="{ 'status-active': boundInfo.status === 'active' }">{{ boundInfo.status === 'active' ? '正常' : (boundInfo.status || '-') }}</span>
</div>
<div class="bound-info__item">
<span class="bound-info__label">Token有效</span>
<span class="bound-info__value">{{ boundInfo.tokenValid ? '✅ 是' : '❌ 否' }}</span>
</div>
<div class="bound-info__item">
<span class="bound-info__label">创建时间</span>
<span class="bound-info__value">{{ formatTime(boundInfo.createTime) }}</span>
</div>
<div class="bound-info__item">
<span class="bound-info__label">更新时间</span>
<span class="bound-info__value">{{ formatTime(boundInfo.updateTime) }}</span>
</div> </div>
</div> </div>
<button <button
@ -140,7 +160,7 @@
<script setup> <script setup>
import { ref, reactive, onMounted } from 'vue' import { ref, reactive, onMounted } from 'vue'
import { bindOpenList, unbindOpenList, fetchOpenListStatus } from '../api' import { bindOpenList, unbindOpenList, getOpenListInfo } from '../api'
// //
const formData = reactive({ const formData = reactive({
@ -157,19 +177,34 @@ const boundInfo = ref(null)
/** /**
* 加载已绑定的 OpenList 信息 * 加载已绑定的 OpenList 信息
* 后端返回格式
* - null 或未绑定返回 null
* - 已绑定返回 { code: 200, data: { id, openlistUrl, openlistUsername, storageId, mountPath, status, tokenValid, createTime, updateTime } }
*/ */
async function loadBoundInfo() { async function loadBoundInfo() {
try { try {
const result = await fetchOpenListStatus() const result = await getOpenListInfo()
if (result?.code === 200 && result?.data?.bound) { // result null { code: 200, data: {...} }
if (result?.data) {
const d = result.data
boundInfo.value = { boundInfo.value = {
url: result.data.url || '', id: d.id,
username: result.data.username || '', url: d.openlistUrl || '',
boundAt: result.data.boundAt || '-', username: d.openlistUsername || '',
storageId: d.storageId || '',
mountPath: d.mountPath || '',
status: d.status || '',
tokenValid: d.tokenValid,
createTime: d.createTime || '',
updateTime: d.updateTime || '',
} }
} else {
// null
boundInfo.value = null
} }
} catch (err) { } catch (err) {
console.warn('加载绑定信息失败:', err) console.warn('加载绑定信息失败:', err)
boundInfo.value = null
} }
} }
@ -259,6 +294,26 @@ function resetForm() {
setBindStatus(null, '') setBindStatus(null, '')
} }
/**
* 格式化时间
*/
function formatTime(timeStr) {
if (!timeStr) return '-'
try {
const date = new Date(timeStr)
return date.toLocaleString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
})
} catch {
return timeStr
}
}
/** /**
* 设置绑定状态提示 * 设置绑定状态提示
*/ */

View File

@ -896,16 +896,44 @@ canvas {
min-height: 520px; min-height: 520px;
border-radius: 24px; border-radius: 24px;
overflow: hidden; overflow: hidden;
background-size: cover;
background-position: center;
background-repeat: no-repeat;
outline: none; outline: none;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.1); box-shadow: 0 4px 20px rgba(0, 0, 0, 0.1);
/* 图片加载完成的过渡效果 */ /* 图片加载完成的过渡效果 */
transition: background-image 0.5s ease, opacity 0.6s ease; transition: opacity 0.6s ease;
opacity: 1; opacity: 1;
} }
/* 海报图片层 */
.hero-bg-image {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
object-fit: cover;
object-position: center;
z-index: 0;
}
/* 横向海报适配 - 使用独立容器确保图片完整显示 */
.hero-main.hero-wide {
overflow: visible;
min-height: 200px;
max-height: none;
}
.hero-main.hero-wide .hero-bg-image {
position: relative;
object-fit: contain;
object-position: center;
width: 100%;
height: auto;
max-height: 500px;
display: block;
margin: 0 auto;
background-color: #0f172a;
}
/* 海报加载状态 - 渐变动画模拟视频加载 */ /* 海报加载状态 - 渐变动画模拟视频加载 */
.hero-main.hero-loading { .hero-main.hero-loading {
background: linear-gradient(110deg, #1e293b 30%, #334155 50%, #1e293b 70%); background: linear-gradient(110deg, #1e293b 30%, #334155 50%, #1e293b 70%);
@ -999,6 +1027,79 @@ canvas {
gap: 10px; gap: 10px;
} }
/* ==================== 设置页面遮罩层 ==================== */
.settings-overlay {
position: fixed;
inset: 0;
background: rgba(2, 6, 23, 0.58);
backdrop-filter: blur(4px);
z-index: 100000;
display: flex;
align-items: flex-start;
justify-content: center;
padding: 40px 12px;
overflow-y: auto;
}
.settings-container {
position: relative;
width: min(760px, calc(100vw - 24px));
animation: settingsSlideIn 0.3s ease;
}
@keyframes settingsSlideIn {
from {
opacity: 0;
transform: translateY(-20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.settings-card {
border-radius: 28px;
background: #fff;
box-shadow: 0 40px 100px rgba(15, 23, 42, 0.28);
overflow: hidden;
}
.settings-close {
position: absolute;
top: 16px;
right: 16px;
width: 36px;
height: 36px;
border-radius: 50%;
background: #eff5fb;
border: none;
font-size: 18px;
color: #243247;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s;
flex-shrink: 0;
}
.settings-close:hover {
background: #dfe7f3;
color: #1a2639;
}
/* 设置页面过渡动画 */
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.3s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
.hero-main__meta-item { .hero-main__meta-item {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
@ -4618,6 +4719,22 @@ body.dark-mode .tmdb-config__status.ready {
border: 1px solid rgba(34, 197, 94, 0.3); border: 1px solid rgba(34, 197, 94, 0.3);
} }
/* ===== 黑夜模式:设置页面优化 ===== */
body.dark-mode .settings-card {
background: linear-gradient(135deg, #1e293b 0%, #0f172a 100%);
border: 1px solid rgba(51, 65, 85, 0.5);
}
body.dark-mode .settings-close {
background: rgba(51, 65, 85, 0.5);
color: #94a3b8;
}
body.dark-mode .settings-close:hover {
background: rgba(71, 85, 105, 0.7);
color: #e2e8f0;
}
/* ===== 黑夜模式:通用对话框优化 ===== */ /* ===== 黑夜模式:通用对话框优化 ===== */
body.dark-mode .simple-dialog, body.dark-mode .simple-dialog,
body.dark-mode .app-dialog { body.dark-mode .app-dialog {
@ -4753,6 +4870,55 @@ body.dark-mode .quick-access-panel__header {
background: rgba(51, 65, 85, 0.5); background: rgba(51, 65, 85, 0.5);
} }
/* ===== 黑夜模式:快捷入口悬浮面板优化 ===== */
body.dark-mode .quick-access-panel {
background: linear-gradient(135deg, #1e293b 0%, #0f172a 100%);
border: 1px solid rgba(51, 65, 85, 0.5);
box-shadow: 0 12px 48px rgba(0, 0, 0, 0.4), 0 4px 16px rgba(0, 0, 0, 0.2);
color: #e2e8f0;
}
body.dark-mode .quick-access-panel__header h3 {
color: #e2e8f0;
}
body.dark-mode .quick-access-panel .quick-icons__item {
color: #cbd5e1;
}
body.dark-mode .quick-access-panel .quick-icons__item:hover {
background: rgba(99, 102, 241, 0.15);
}
body.dark-mode .quick-access-panel .quick-icons__thumb {
background: linear-gradient(135deg, #1e293b 0%, #334155 100%);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
}
body.dark-mode .quick-access-panel .quick-icons__item:hover .quick-icons__thumb {
box-shadow: 0 4px 16px rgba(99, 102, 241, 0.3);
}
body.dark-mode .quick-access-panel .quick-icons__label {
color: #cbd5e1;
}
body.dark-mode .quick-access-panel .quick-icons__empty {
color: #64748b;
}
body.dark-mode .quick-access-panel .quick-icons__manage {
background: linear-gradient(135deg, #38bdf8 0%, #818cf8 100%);
}
body.dark-mode .quick-access-panel .quick-icons__manage:hover {
background: linear-gradient(135deg, #0ea5e9 0%, #6366f1 100%);
}
body.dark-mode .quick-access-panel .quick-icons__header h3 {
color: #e2e8f0;
}
/* ===== 黑夜模式:滚动条全局优化 ===== */ /* ===== 黑夜模式:滚动条全局优化 ===== */
body.dark-mode ::-webkit-scrollbar { body.dark-mode ::-webkit-scrollbar {
width: 10px; width: 10px;
@ -4902,3 +5068,312 @@ body.dark-mode .btn-export {
body.dark-mode .btn-export:hover { body.dark-mode .btn-export:hover {
background: linear-gradient(135deg, #38bdf8, #0ea5e9); background: linear-gradient(135deg, #38bdf8, #0ea5e9);
} }
/* ===== 海报卡片新增字段样式(导演、主演、简介) ===== */
.poster-director {
margin-top: 8px;
font-size: 13px;
color: var(--muted);
font-weight: 500;
}
.poster-casts {
margin-top: 4px;
font-size: 13px;
color: var(--muted);
}
.poster-description {
margin-top: 10px;
}
.description-toggle {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 4px 10px;
font-size: 12px;
color: #6366f1;
background: rgba(99, 102, 241, 0.08);
border: 1px solid rgba(99, 102, 241, 0.2);
border-radius: 6px;
cursor: pointer;
transition: all 0.15s ease;
}
.description-toggle:hover {
background: rgba(99, 102, 241, 0.15);
border-color: #6366f1;
}
.description-text {
margin-top: 8px;
padding: 10px;
background: rgba(0, 0, 0, 0.03);
border-radius: 8px;
font-size: 13px;
line-height: 1.6;
color: #475569;
}
/* 简介展开/收起过渡动画 */
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.2s ease, max-height 0.2s ease;
overflow: hidden;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
max-height: 0;
}
.fade-enter-to,
.fade-leave-from {
opacity: 1;
max-height: 200px;
}
/* ===== 黑夜模式:海报卡片新增字段样式 ===== */
body.dark-mode .poster-director,
body.dark-mode .poster-casts {
color: #94a3b8;
}
body.dark-mode .description-toggle {
color: #a5b4fc;
background: rgba(99, 102, 241, 0.15);
border-color: rgba(99, 102, 241, 0.3);
}
body.dark-mode .description-toggle:hover {
background: rgba(99, 102, 241, 0.25);
border-color: #6366f1;
}
body.dark-mode .description-text {
background: rgba(15, 23, 42, 0.4);
color: #cbd5e1;
}
/* ===== 简介弹窗样式 ===== */
.description-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.6);
backdrop-filter: blur(4px);
display: flex;
align-items: center;
justify-content: center;
z-index: 100001;
padding: 20px;
}
.description-dialog {
width: min(600px, calc(100% - 40px));
max-height: 80vh;
background: #fff;
border-radius: 20px;
box-shadow: 0 24px 80px rgba(0, 0, 0, 0.25);
display: flex;
flex-direction: column;
overflow: hidden;
animation: dialogSlideIn 0.3s ease;
}
@keyframes dialogSlideIn {
from {
opacity: 0;
transform: translateY(20px) scale(0.95);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}
.description-dialog__header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 20px 24px;
border-bottom: 1px solid #e5e7eb;
flex-shrink: 0;
}
.description-dialog__header h3 {
margin: 0;
font-size: 18px;
font-weight: 700;
color: #1f2937;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.description-dialog__close {
width: 32px;
height: 32px;
border-radius: 50%;
background: #f3f4f6;
border: none;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
font-size: 16px;
color: #6b7280;
transition: all 0.2s;
flex-shrink: 0;
}
.description-dialog__close:hover {
background: #e5e7eb;
color: #1f2937;
}
.description-dialog__body {
padding: 24px;
overflow-y: auto;
flex: 1;
}
.detail-section {
margin-bottom: 20px;
}
.detail-section:last-child {
margin-bottom: 0;
}
.detail-section h4 {
margin: 0 0 10px;
font-size: 14px;
font-weight: 600;
color: #374151;
}
.detail-text {
margin: 0;
font-size: 14px;
line-height: 1.8;
color: #4b5563;
white-space: pre-wrap;
word-break: break-word;
}
.detail-tags {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.detail-tag {
display: inline-flex;
align-items: center;
padding: 4px 12px;
background: rgba(99, 102, 241, 0.08);
color: #6366f1;
border-radius: 12px;
font-size: 13px;
font-weight: 500;
}
/* 弹窗滚动条样式 */
.description-dialog__body::-webkit-scrollbar {
width: 6px;
}
.description-dialog__body::-webkit-scrollbar-track {
background: transparent;
}
.description-dialog__body::-webkit-scrollbar-thumb {
background: #d1d5db;
border-radius: 3px;
}
.description-dialog__body::-webkit-scrollbar-thumb:hover {
background: #9ca3af;
}
/* 弹窗过渡动画 */
.dialog-fade-enter-active {
animation: dialogFadeIn 0.3s ease;
}
.dialog-fade-leave-active {
animation: dialogFadeOut 0.2s ease;
}
@keyframes dialogFadeIn {
from {
opacity: 0;
transform: translateY(20px) scale(0.95);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}
@keyframes dialogFadeOut {
from {
opacity: 1;
transform: translateY(0) scale(1);
}
to {
opacity: 0;
transform: translateY(20px) scale(0.95);
}
}
/* ===== 黑夜模式:简介弹窗样式 ===== */
body.dark-mode .description-overlay {
background: rgba(0, 0, 0, 0.75);
}
body.dark-mode .description-dialog {
background: linear-gradient(135deg, #1e293b 0%, #0f172a 100%);
border: 1px solid rgba(51, 65, 85, 0.5);
}
body.dark-mode .description-dialog__header {
border-bottom-color: rgba(51, 65, 85, 0.5);
}
body.dark-mode .description-dialog__header h3 {
color: #e2e8f0;
}
body.dark-mode .description-dialog__close {
background: rgba(51, 65, 85, 0.5);
color: #94a3b8;
}
body.dark-mode .description-dialog__close:hover {
background: rgba(71, 85, 105, 0.7);
color: #e2e8f0;
}
body.dark-mode .detail-section h4 {
color: #cbd5e1;
}
body.dark-mode .detail-text {
color: #94a3b8;
}
body.dark-mode .detail-tag {
background: rgba(99, 102, 241, 0.15);
color: #a5b4fc;
}
body.dark-mode .description-dialog__body::-webkit-scrollbar-thumb {
background: #475569;
}
body.dark-mode .description-dialog__body::-webkit-scrollbar-thumb:hover {
background: #64748b;
}