功能:新增全局加载覆盖层及批量抓取功能
引入带有动画GIF的全局初始加载遮罩层,提升应用启动时的用户体验。新增文件夹树操作的多选模式,支持批量删除和重新抓取选定项。增强NodeCard组件,增加抓取状态指示器和勾选框选择功能。
This commit is contained in:
parent
bacbe1871c
commit
69494434e9
BIN
vue/public/全局加载动画.gif
Normal file
BIN
vue/public/全局加载动画.gif
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 4.5 MiB |
526
vue/src/App.vue
526
vue/src/App.vue
@ -1,6 +1,16 @@
|
||||
<template>
|
||||
<div class="app-wrapper">
|
||||
<LoginView v-if="!isAuthenticated" :notice="authNotice" @success="handleLoginSuccess" @clear-notice="clearAuthNotice" />
|
||||
<!-- 全局初始加载遮罩层 - 在数据完全加载前显示 -->
|
||||
<Transition name="global-overlay-fade">
|
||||
<div v-if="isInitialLoading" class="global-loading-overlay">
|
||||
<div class="global-loading-overlay__content">
|
||||
<img src="/全局加载动画.gif" alt="加载中" class="global-loading-overlay__gif" />
|
||||
<p class="global-loading-overlay__text">正在加载数据,请稍候...</p>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
|
||||
<LoginView v-if="!isAuthenticated" :notice="authNotice" @success="handleLoginSuccess" @clear-notice="clearAuthNotice" />
|
||||
|
||||
<template v-else>
|
||||
<!-- 全局请求加载条(顶部) -->
|
||||
@ -345,6 +355,36 @@
|
||||
<button type="button" class="folder-tree-actions__btn" @click="handleAddFolder(null)">
|
||||
+ 创建根文件夹
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="folder-tree-actions__btn folder-tree-actions__btn--multi"
|
||||
:class="{ active: multiSelectMode }"
|
||||
@click="toggleMultiSelectMode"
|
||||
>
|
||||
☑️ 多选删除
|
||||
</button>
|
||||
<button type="button" class="folder-tree-actions__btn" @click="handleOpenScraperLogDialog">
|
||||
📋 刮削日志
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="folder-tree-actions__btn"
|
||||
@click="handleRescrapeAll"
|
||||
title="重新刮削所有电影文件"
|
||||
>
|
||||
🔄 重新刮削
|
||||
</button>
|
||||
<button
|
||||
v-if="multiSelectMode"
|
||||
type="button"
|
||||
class="folder-tree-actions__btn folder-tree-actions__btn--multi"
|
||||
:class="{ active: multiSelectMode }"
|
||||
:disabled="selectedIds.size === 0"
|
||||
@click="handleBatchScrape"
|
||||
title="批量刮削选中的电影文件"
|
||||
>
|
||||
🎬 批量刮削
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 搜索结果为空时的提示 -->
|
||||
@ -363,11 +403,17 @@
|
||||
v-else
|
||||
:tree-data="folderTreeData"
|
||||
:expanded-folder-ids="expandedFolderIds"
|
||||
:multi-select-mode="multiSelectMode"
|
||||
:selected-ids="selectedIds"
|
||||
@add-folder="handleAddFolder"
|
||||
@add-file="handleAddFile"
|
||||
@expand="handleExpandFolder"
|
||||
@edit="openEditRecord"
|
||||
@delete="handleDeleteNode"
|
||||
@toggle-select="handleToggleSelect"
|
||||
@batch-delete="handleBatchDelete"
|
||||
@toggle-multi-select-exit="toggleMultiSelectMode"
|
||||
@scrape-status="handleScrapeStatus"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
@ -390,17 +436,50 @@
|
||||
</aside>
|
||||
</section>
|
||||
|
||||
<!-- 网盘操作全局加载遮罩层 -->
|
||||
<!-- 在添加/删除/重命名网盘时显示,覆盖整个管理区域防止重复操作 -->
|
||||
<Transition name="loading-fade">
|
||||
<div v-if="driveLoading" class="drive-loading-overlay">
|
||||
<div class="drive-loading-spinner">
|
||||
<!-- CSS旋转动画加载指示器 -->
|
||||
<div class="drive-spinner"></div>
|
||||
<p class="drive-loading-text">正在处理,请稍候...</p>
|
||||
</div>
|
||||
<!-- 网盘操作全局加载遮罩层 -->
|
||||
<!-- 在添加/删除/重命名网盘时显示,覆盖整个管理区域防止重复操作 -->
|
||||
<Transition name="loading-fade">
|
||||
<div v-if="driveLoading" class="drive-loading-overlay">
|
||||
<div class="drive-loading-spinner">
|
||||
<!-- CSS旋转动画加载指示器 -->
|
||||
<div class="drive-spinner"></div>
|
||||
<p class="drive-loading-text">正在处理,请稍候...</p>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
</Transition>
|
||||
|
||||
<!-- 刮削状态对话框 -->
|
||||
<SimpleDialog :open="showScrapeStatusDialog" title="刮削状态" @close="showScrapeStatusDialog = false">
|
||||
<div v-if="scrapeStatusData" class="scrape-status-panel">
|
||||
<div class="scrape-status-header">
|
||||
<h3>{{ scrapeStatusData.title }}</h3>
|
||||
<span class="scrape-status-badge" :class="`scrape-status-badge--${scrapeStatusData.status}`">
|
||||
{{ getStatusText(scrapeStatusData.status) }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="scrape-status-message">
|
||||
{{ scrapeStatusData.message }}
|
||||
</div>
|
||||
<div v-if="scrapeStatusData.posterUrl" class="scrape-status-poster">
|
||||
<img :src="scrapeStatusData.posterUrl" alt="海报" />
|
||||
</div>
|
||||
<div v-if="scrapeStatusData.backdropUrl" class="scrape-status-backdrop">
|
||||
<img :src="scrapeStatusData.backdropUrl" alt="背景图" />
|
||||
</div>
|
||||
<div v-if="scrapeStatusData.rating" class="scrape-status-meta">
|
||||
<span>评分: {{ scrapeStatusData.rating }}</span>
|
||||
</div>
|
||||
<div v-if="scrapeStatusData.category" class="scrape-status-meta">
|
||||
<span>分类: {{ scrapeStatusData.category }}</span>
|
||||
</div>
|
||||
<div v-if="scrapeStatusData.tags" class="scrape-status-tags">
|
||||
<span v-for="tag in (typeof scrapeStatusData.tags === 'string' ? scrapeStatusData.tags.split(',') : scrapeStatusData.tags)" :key="tag">{{ tag }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="scrape-status-loading">
|
||||
正在查询刮削状态...
|
||||
</div>
|
||||
</SimpleDialog>
|
||||
</template>
|
||||
|
||||
<template v-else-if="!isLoading && currentView === 'poster' && !hasSearched && !isSearchFocused">
|
||||
@ -468,6 +547,51 @@
|
||||
@delete="handleDeleteRecord"
|
||||
/>
|
||||
|
||||
<!-- 刮削日志对话框 -->
|
||||
<SimpleDialog :open="showScraperLogDialog" title="刮削日志" @close="showScraperLogDialog = false">
|
||||
<div class="log-panel">
|
||||
<div v-if="loadingScraperLogs" class="log-loading">
|
||||
正在加载刮削日志...
|
||||
</div>
|
||||
<div v-else-if="scraperLogs.length === 0" class="log-empty">
|
||||
暂无刮削日志
|
||||
</div>
|
||||
<template v-else>
|
||||
<div class="scraper-log-list">
|
||||
<div v-for="log in scraperLogs" :key="log.id" class="log-entry">
|
||||
<div class="log-entry__header">
|
||||
<div>
|
||||
<div class="log-time">{{ log.createTime || log.create_time }}</div>
|
||||
</div>
|
||||
<div class="log-stats">
|
||||
<span class="stat-total">总共: {{ log.totalProcessed || log.total_processed || 0 }}</span>
|
||||
<span class="stat-success">成功: {{ log.totalSuccess || log.total_success || 0 }}</span>
|
||||
<span class="stat-fail">失败: {{ log.totalFailed || log.total_failed || 0 }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 分页信息和加载更多按钮 -->
|
||||
<div v-if="scraperLogPagination.total > 0" class="log-pagination">
|
||||
<div class="pagination-info">
|
||||
已加载 {{ scraperLogs.length }} / {{ scraperLogPagination.total }} 条
|
||||
</div>
|
||||
<button
|
||||
v-if="scraperLogPagination.hasMore"
|
||||
type="button"
|
||||
class="load-more-btn"
|
||||
@click="handleLoadMoreScraperLogs"
|
||||
:disabled="loadingScraperLogs"
|
||||
>
|
||||
{{ loadingScraperLogs ? '加载中...' : '加载更多' }}
|
||||
</button>
|
||||
<div v-else class="no-more">—— 已加载全部 ——</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</SimpleDialog>
|
||||
|
||||
<SimpleDialog :open="showLogDialog" title="登录日志" @close="showLogDialog = false">
|
||||
<div class="log-panel">
|
||||
<!-- 筛选区域 -->
|
||||
@ -648,6 +772,8 @@ import DriveManageDialog from './components/DriveManageDialog.vue'
|
||||
import ConfirmDialog from './components/ConfirmDialog.vue'
|
||||
import IconManageDialog from './components/IconManageDialog.vue'
|
||||
import {
|
||||
batchScrapeAll,
|
||||
batchScrapeMovies,
|
||||
createDrive,
|
||||
createFolderInDrive,
|
||||
createMovieInDrive,
|
||||
@ -656,6 +782,7 @@ import {
|
||||
deleteMovieInDrive,
|
||||
deleteRecord,
|
||||
exportData,
|
||||
fetchBatchScrapeStatus,
|
||||
fetchDashboardData,
|
||||
fetchDriveList,
|
||||
fetchFolderMovies,
|
||||
@ -664,6 +791,8 @@ import {
|
||||
fetchLoginLogs,
|
||||
fetchMovieDuplicates,
|
||||
fetchRecommendMovies,
|
||||
fetchScrapeStatus,
|
||||
fetchScraperLogs,
|
||||
fetchTmdbConfig,
|
||||
requestState,
|
||||
resetData,
|
||||
@ -690,6 +819,7 @@ const sessionWarning = ref('')
|
||||
const rawData = ref(null)
|
||||
const isLoading = ref(false)
|
||||
const driveLoading = ref(false) // 网盘操作加载状态(添加/删除/重命名)
|
||||
const isInitialLoading = ref(true) // 全局初始加载状态
|
||||
|
||||
/** 是否有API请求正在进行(来自全局请求计数器) */
|
||||
const hasPendingRequest = computed(() => requestState.pendingCount > 0)
|
||||
@ -705,6 +835,8 @@ const folderDialogParentId = ref(0) // 新建时的父文件夹ID
|
||||
const folderTreeContainerRef = ref(null) // 文件夹树容器引用(用于保持滚动位置)
|
||||
const quickIconsRef = ref(null) // 快捷入口区域引用(用于滚动定位)
|
||||
const expandedFolderIds = reactive(new Set()) // 全局展开状态追踪,防止 buildTree 重建丢失展开
|
||||
const multiSelectMode = ref(false) // 多选模式开关
|
||||
const selectedIds = reactive(new Set()) // 多选选中的ID集合
|
||||
|
||||
// 确认弹窗状态
|
||||
const showConfirmDialog = ref(false)
|
||||
@ -737,6 +869,18 @@ const showTmdbDialog = ref(false)
|
||||
const showUserManageDialog = ref(false) // 用户管理对话框显示状态(仅管理员)
|
||||
const showDriveManageDialog = ref(false) // 网盘管理对话框显示状态
|
||||
const showIconManageDialog = ref(false) // 图标管理对话框显示状态
|
||||
const showScraperLogDialog = ref(false) // 刮削日志对话框显示状态
|
||||
const loadingScraperLogs = ref(false) // 刮削日志加载状态
|
||||
const scraperLogs = ref([]) // 刮削日志列表
|
||||
const showScrapeStatusDialog = ref(false) // 刮削状态对话框显示状态
|
||||
const scrapeStatusData = ref(null) // 刮削状态数据
|
||||
const scrapeStatusLoading = ref(false) // 刮削状态加载状态
|
||||
const scraperLogPagination = reactive({
|
||||
pageNo: 1,
|
||||
pageSize: 20,
|
||||
total: 0,
|
||||
hasMore: true,
|
||||
}) // 刮削日志分页参数
|
||||
const iconList = ref([]) // 快捷图标列表
|
||||
const networkMode = ref('external') // 网络模式:'external' | 'internal'
|
||||
const showUserMenu = ref(false)
|
||||
@ -1014,6 +1158,8 @@ async function loadDashboard() {
|
||||
syncActiveSheet()
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
// 数据加载完成后隐藏全局遮罩层
|
||||
isInitialLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
@ -1099,7 +1245,7 @@ async function loadDuplicateItems() {
|
||||
}
|
||||
|
||||
/**
|
||||
* 加载某个文件夹层级下的直接子文件夹和电影文件
|
||||
* 加载某个文件夹层级下的直接子文件夹和电影文件,并批量获取刮削状态
|
||||
*/
|
||||
async function fetchFolderLevelItems(driveId, folderId) {
|
||||
const targetSheet = rawData.value?.sheets?.find((sheet) => sheet.id === driveId)
|
||||
@ -1115,7 +1261,7 @@ async function fetchFolderLevelItems(driveId, folderId) {
|
||||
const folderItems = folderList.map((folder) => ({
|
||||
id: folder.id,
|
||||
title: folder.name || folder.title || '',
|
||||
parentId: folder.parentId || null,
|
||||
parentId: folderId || null, // 强制使用请求的 folderId 作为 parentId,确保子项正确连接到当前文件夹下
|
||||
description: folder.description || '',
|
||||
sortWeight: folder.sortWeight ?? 0,
|
||||
type: 'folder',
|
||||
@ -1125,12 +1271,45 @@ async function fetchFolderLevelItems(driveId, folderId) {
|
||||
isLoading: false,
|
||||
}))
|
||||
|
||||
// 提取电影ID列表,用于批量查询刮削状态
|
||||
const movieIds = movieList.map(movie => movie.id).filter(Boolean)
|
||||
|
||||
const movieItems = movieList.map((movie) => mapMovieToRecord({
|
||||
...movie,
|
||||
driveId,
|
||||
folderId,
|
||||
}, sheetName))
|
||||
|
||||
// 批量获取刮削状态
|
||||
if (movieIds.length > 0) {
|
||||
try {
|
||||
const scrapeStatusRes = await fetchBatchScrapeStatus(movieIds)
|
||||
const scrapeStatusMap = new Map()
|
||||
|
||||
if (scrapeStatusRes?.code === 200 && Array.isArray(scrapeStatusRes?.data)) {
|
||||
scrapeStatusRes.data.forEach(status => {
|
||||
scrapeStatusMap.set(status.movieId, status)
|
||||
})
|
||||
}
|
||||
|
||||
// 将刮削状态附加到电影节点上
|
||||
movieItems.forEach(item => {
|
||||
const statusData = scrapeStatusMap.get(item.id)
|
||||
if (statusData) {
|
||||
item.scrapeStatus = statusData.status || 'unknown'
|
||||
item.scrapeMessage = statusData.message || ''
|
||||
item.scrapePosterUrl = statusData.posterUrl || ''
|
||||
item.scrapeBackdropUrl = statusData.backdropUrl || ''
|
||||
item.scrapeRating = statusData.rating
|
||||
item.scrapeCategory = statusData.category
|
||||
item.scrapeTags = statusData.tags
|
||||
}
|
||||
})
|
||||
} catch (err) {
|
||||
console.warn('批量获取刮削状态失败,但不影响文件夹加载', err)
|
||||
}
|
||||
}
|
||||
|
||||
return [...folderItems, ...movieItems]
|
||||
}
|
||||
|
||||
@ -1186,6 +1365,9 @@ async function handleExpandFolder(node) {
|
||||
const targetSheet = rawData.value?.sheets?.find((sheet) => sheet.id === driveId)
|
||||
if (!targetSheet) return
|
||||
|
||||
// 先将当前文件夹加入全局展开状态,确保buildTree重建时能保持展开
|
||||
expandedFolderIds.add(node.id)
|
||||
|
||||
const rows = targetSheet.rows || []
|
||||
const descendantIds = collectDescendantIds(rows, node.id)
|
||||
const remainingRows = rows.filter((item) => !descendantIds.has(item.id))
|
||||
@ -1199,9 +1381,6 @@ async function handleExpandFolder(node) {
|
||||
...childrenItems,
|
||||
]
|
||||
|
||||
// 将当前文件夹加入全局展开状态
|
||||
expandedFolderIds.add(node.id)
|
||||
|
||||
// 恢复滚动位置(等待 DOM 更新后)
|
||||
await nextTick()
|
||||
if (folderTreeContainerRef.value) {
|
||||
@ -1450,6 +1629,72 @@ function resetLogFilter() {
|
||||
applyLogFilter()
|
||||
}
|
||||
|
||||
// 打开刮削日志对话框并加载最新20条
|
||||
async function handleOpenScraperLogDialog() {
|
||||
showScraperLogDialog.value = true
|
||||
|
||||
// 重置分页参数
|
||||
scraperLogPagination.pageNo = 1
|
||||
scraperLogPagination.pageSize = 20
|
||||
scraperLogPagination.total = 0
|
||||
scraperLogPagination.hasMore = true
|
||||
|
||||
// 清空之前的日志
|
||||
scraperLogs.value = []
|
||||
|
||||
// 加载第一页数据
|
||||
await loadMoreScraperLogs()
|
||||
}
|
||||
|
||||
// 加载更多刮削日志(分页加载)
|
||||
async function loadMoreScraperLogs() {
|
||||
if (loadingScraperLogs.value || !scraperLogPagination.hasMore) return
|
||||
|
||||
loadingScraperLogs.value = true
|
||||
|
||||
try {
|
||||
const params = {
|
||||
pageNo: scraperLogPagination.pageNo,
|
||||
pageSize: scraperLogPagination.pageSize
|
||||
}
|
||||
|
||||
const logsRes = await fetchScraperLogs(params)
|
||||
|
||||
if (logsRes?.code === 200 && Array.isArray(logsRes?.data?.list)) {
|
||||
const newLogs = logsRes.data.list
|
||||
|
||||
// 追加到现有日志列表
|
||||
scraperLogs.value.push(...newLogs)
|
||||
|
||||
// 更新分页信息
|
||||
scraperLogPagination.total = logsRes.data.total || 0
|
||||
scraperLogPagination.hasMore = scraperLogs.value.length < scraperLogPagination.total
|
||||
|
||||
console.log(`成功加载第 ${scraperLogPagination.pageNo} 页,共 ${newLogs.length} 条日志,总计 ${scraperLogPagination.total} 条`)
|
||||
} else {
|
||||
console.warn('刮削日志返回数据异常', logsRes)
|
||||
scraperLogPagination.hasMore = false
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('加载刮削日志失败', err)
|
||||
setErrorMessage(err?.message || '加载刮削日志失败')
|
||||
scraperLogPagination.hasMore = false
|
||||
} finally {
|
||||
loadingScraperLogs.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 处理加载更多按钮点击
|
||||
function handleLoadMoreScraperLogs() {
|
||||
if (!scraperLogPagination.hasMore || loadingScraperLogs.value) return
|
||||
|
||||
// 页码+1
|
||||
scraperLogPagination.pageNo++
|
||||
|
||||
// 加载下一页
|
||||
loadMoreScraperLogs()
|
||||
}
|
||||
|
||||
// TMDB 配置
|
||||
function handleTmdbConfig() {
|
||||
showUserMenu.value = false
|
||||
@ -2220,6 +2465,255 @@ function preloadImage(url) {
|
||||
img.src = url
|
||||
}
|
||||
|
||||
// 刮削状态查询
|
||||
function handleScrapeStatus(node) {
|
||||
// 只有文件类型才显示刮削状态
|
||||
if (node.type !== 'file') {
|
||||
setErrorMessage('只有电影文件才能查询刮削状态')
|
||||
return
|
||||
}
|
||||
|
||||
showScrapeStatusDialog.value = true
|
||||
scrapeStatusData.value = null
|
||||
scrapeStatusLoading.value = true
|
||||
|
||||
// 获取 movieId(node.id 就是电影ID)
|
||||
const movieId = node.id
|
||||
|
||||
// 调用后端接口查询/触发刮削状态
|
||||
fetchScrapeStatus(movieId).then(result => {
|
||||
scrapeStatusLoading.value = false
|
||||
if (result?.code === 200 && result?.data) {
|
||||
const data = result.data
|
||||
scrapeStatusData.value = {
|
||||
movieId: data.movieId,
|
||||
title: data.title || node.title,
|
||||
status: data.status,
|
||||
message: data.message,
|
||||
posterUrl: data.posterUrl,
|
||||
backdropUrl: data.backdropUrl,
|
||||
rating: data.rating,
|
||||
category: data.category,
|
||||
tags: data.tags,
|
||||
error: data.error,
|
||||
}
|
||||
} else {
|
||||
setErrorMessage(result?.message || '查询刮削状态失败')
|
||||
showScrapeStatusDialog.value = false
|
||||
}
|
||||
}).catch(err => {
|
||||
scrapeStatusLoading.value = false
|
||||
console.error('查询刮削状态失败', err)
|
||||
setErrorMessage(err?.message || '查询刮削状态失败')
|
||||
showScrapeStatusDialog.value = false
|
||||
})
|
||||
}
|
||||
|
||||
function getStatusText(status) {
|
||||
const statusMap = {
|
||||
scraping: '刮削中',
|
||||
success: '刮削成功',
|
||||
failed: '刮削失败',
|
||||
}
|
||||
return statusMap[status] || '未知状态'
|
||||
}
|
||||
|
||||
// 多选模式相关函数
|
||||
function toggleMultiSelectMode() {
|
||||
multiSelectMode.value = !multiSelectMode.value
|
||||
if (!multiSelectMode.value) {
|
||||
// 退出多选模式时清空选中状态
|
||||
selectedIds.clear()
|
||||
}
|
||||
}
|
||||
|
||||
function handleToggleSelect(nodeId) {
|
||||
if (selectedIds.has(nodeId)) {
|
||||
selectedIds.delete(nodeId)
|
||||
} else {
|
||||
selectedIds.add(nodeId)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 重新刮削所有电影文件
|
||||
*/
|
||||
function handleRescrapeAll() {
|
||||
Object.assign(confirmConfig, {
|
||||
title: '确认重新刮削',
|
||||
message: '确定要重新刮削当前网盘下的所有电影文件吗?',
|
||||
hint: '注意:这将重新发起对所有电影文件的 TMDB 海报刮削请求,可能需要较长时间。',
|
||||
icon: '🔄',
|
||||
confirmText: '确认刮削',
|
||||
cancelText: '取消',
|
||||
danger: false,
|
||||
onConfirm: () => doBatchScrapeAll(),
|
||||
})
|
||||
showConfirmDialog.value = true
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行批量刮削所有电影
|
||||
*/
|
||||
async function doBatchScrapeAll() {
|
||||
driveLoading.value = true
|
||||
try {
|
||||
const result = await batchScrapeAll()
|
||||
|
||||
if (result?.code === 200 && result?.data) {
|
||||
const { successCount = 0, failCount = 0, total = 0 } = result.data
|
||||
setSuccessMessage(`重新刮削完成:成功 ${successCount} 个,失败 ${failCount} 个,共 ${total} 个`)
|
||||
|
||||
// 刮削完成后重新加载数据
|
||||
await loadDriveFolders(activeSheetId.value)
|
||||
await loadDuplicateItems()
|
||||
} else {
|
||||
setErrorMessage(result?.message || '重新刮削失败')
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('重新刮削失败', err)
|
||||
setErrorMessage(err?.payload?.message || err?.message || '重新刮削失败')
|
||||
} finally {
|
||||
driveLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量刮削选中的电影文件
|
||||
*/
|
||||
async function handleBatchScrape() {
|
||||
if (selectedIds.size === 0) {
|
||||
setErrorMessage('请先选中要刮削的电影文件')
|
||||
return
|
||||
}
|
||||
|
||||
// 过滤出电影文件(排除文件夹)
|
||||
const movieIds = []
|
||||
const targetSheet = rawData.value?.sheets?.find(s => s.id === activeSheetId.value)
|
||||
const allItems = targetSheet?.rows || []
|
||||
|
||||
for (const id of selectedIds) {
|
||||
const item = allItems.find(r => r.id === id)
|
||||
if (item && item.type === 'file') {
|
||||
movieIds.push(id)
|
||||
}
|
||||
}
|
||||
|
||||
if (movieIds.length === 0) {
|
||||
setErrorMessage('选中的项目不是电影文件,无法刮削')
|
||||
return
|
||||
}
|
||||
|
||||
driveLoading.value = true
|
||||
try {
|
||||
const result = await batchScrapeMovies(movieIds)
|
||||
|
||||
if (result?.code === 200 && result?.data) {
|
||||
const { successCount = 0, failCount = 0, total = 0 } = result.data
|
||||
setSuccessMessage(`批量刮削完成:成功 ${successCount} 个,失败 ${failCount} 个,共 ${total} 个`)
|
||||
|
||||
// 刮削完成后重新加载数据
|
||||
await loadDriveFolders(activeSheetId.value)
|
||||
await loadDuplicateItems()
|
||||
|
||||
// 清空选中状态并退出多选模式
|
||||
selectedIds.clear()
|
||||
multiSelectMode.value = false
|
||||
} else {
|
||||
setErrorMessage(result?.message || '批量刮削失败')
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('批量刮削失败', err)
|
||||
setErrorMessage(err?.payload?.message || err?.message || '批量刮削失败')
|
||||
} finally {
|
||||
driveLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleBatchDelete(selectedIdSet) {
|
||||
if (!selectedIdSet || selectedIdSet.size === 0) {
|
||||
setErrorMessage('没有选中任何项目')
|
||||
return
|
||||
}
|
||||
|
||||
const count = selectedIdSet.size
|
||||
Object.assign(confirmConfig, {
|
||||
title: '确认批量删除',
|
||||
message: `确定要删除选中的 <strong>${count}</strong> 个项目吗?`,
|
||||
hint: '注意:选中的所有文件夹和文件都将被永久删除!此操作不可恢复。',
|
||||
icon: '⚠️',
|
||||
confirmText: '确认删除',
|
||||
cancelText: '取消',
|
||||
danger: true,
|
||||
onConfirm: () => doBatchDelete(selectedIdSet),
|
||||
})
|
||||
showConfirmDialog.value = true
|
||||
}
|
||||
|
||||
async function doBatchDelete(selectedIdSet) {
|
||||
driveLoading.value = true
|
||||
const driveId = activeSheetId.value
|
||||
if (!driveId) {
|
||||
setErrorMessage('请先选择一个网盘')
|
||||
driveLoading.value = false
|
||||
return
|
||||
}
|
||||
|
||||
let successCount = 0
|
||||
let failCount = 0
|
||||
|
||||
try {
|
||||
// 逐个删除选中的项目
|
||||
for (const id of selectedIdSet) {
|
||||
// 查找该项目
|
||||
const targetSheet = rawData.value?.sheets?.find(s => s.id === driveId)
|
||||
const item = targetSheet?.rows?.find(r => r.id === id)
|
||||
|
||||
if (!item) {
|
||||
failCount++
|
||||
continue
|
||||
}
|
||||
|
||||
try {
|
||||
let result
|
||||
if (item.type === 'folder') {
|
||||
// 删除文件夹
|
||||
result = await deleteFolderInDrive(driveId, id)
|
||||
} else {
|
||||
// 删除文件
|
||||
result = await deleteMovieInDrive(driveId, id)
|
||||
}
|
||||
|
||||
if (result?.code === 200) {
|
||||
successCount++
|
||||
} else {
|
||||
failCount++
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`删除项目[id=${id}]失败`, err)
|
||||
failCount++
|
||||
}
|
||||
}
|
||||
|
||||
// 删除完成后重新加载数据
|
||||
if (successCount > 0) {
|
||||
await loadDriveFolders(driveId)
|
||||
await loadDuplicateItems()
|
||||
// 清空选中状态并退出多选模式
|
||||
selectedIds.clear()
|
||||
multiSelectMode.value = false
|
||||
setSuccessMessage(`成功删除 ${successCount} 个项目${failCount > 0 ? `,${failCount} 个失败` : ''}`)
|
||||
} else {
|
||||
setErrorMessage('删除失败,请重试')
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('批量删除失败', err)
|
||||
setErrorMessage(err?.message || '批量删除失败')
|
||||
} finally {
|
||||
driveLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => rawData.value?.sheets,
|
||||
() => syncActiveSheet(),
|
||||
|
||||
@ -699,3 +699,95 @@ export function deleteUser(userId) {
|
||||
method: 'DELETE',
|
||||
})
|
||||
}
|
||||
|
||||
// ==================== 刮削日志 API ====================
|
||||
|
||||
/**
|
||||
* 查询刮削日志列表
|
||||
* @param {Object} params - 查询参数
|
||||
* @param {number} [params.pageNo] - 页码,默认1
|
||||
* @param {number} [params.pageSize] - 每页条数,默认20
|
||||
* @returns {Promise} 返回格式:{ code: 200, data: { total, list: [{ id, scanTime, totalMovies, successCount, failCount }], pageNo, pageSize } }
|
||||
*/
|
||||
export function fetchScraperLogs(params = {}) {
|
||||
const searchParams = new URLSearchParams()
|
||||
if (params.pageNo) searchParams.set('pageNo', String(params.pageNo))
|
||||
if (params.pageSize) searchParams.set('pageSize', String(params.pageSize))
|
||||
|
||||
const query = searchParams.toString()
|
||||
return request(`${API_BASE_URL}/tmdb-scraper/scraper-logs${query ? `?${query}` : ''}`, {
|
||||
method: 'GET',
|
||||
})
|
||||
}
|
||||
|
||||
// ==================== 批量刮削 API ====================
|
||||
|
||||
/**
|
||||
* 批量刮削电影
|
||||
* @param {number[]} movieIds - 电影ID数组
|
||||
* @returns {Promise} 返回格式:{ code: 200, message: "批量刮削完成", data: { successCount, failCount, total } }
|
||||
*/
|
||||
export function batchScrapeMovies(movieIds) {
|
||||
return request(`${API_BASE_URL}/tmdb-scraper/batch-scrape`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
movieIds: movieIds,
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 重新刮削所有电影(不指定ID,刮削全部)
|
||||
* @returns {Promise} 返回格式:{ code: 200, message: "批量刮削完成", data: { successCount, failCount, total } }
|
||||
*/
|
||||
export function batchScrapeAll() {
|
||||
return request(`${API_BASE_URL}/tmdb-scraper/batch-scrape`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({}),
|
||||
})
|
||||
}
|
||||
|
||||
// ==================== 刮削状态 API ====================
|
||||
|
||||
/**
|
||||
* 查询/触发电影刮削状态
|
||||
* @param {string|number} movieId - 电影ID
|
||||
* @returns {Promise} 返回格式:{ code: 200, data: { movieId, title, status, message, posterUrl?, backdropUrl?, rating?, category?, tags? } }
|
||||
* status: scraping=刮削中, success=刮削成功, failed=刮削失败
|
||||
*/
|
||||
export function fetchScrapeStatus(movieId) {
|
||||
return request(`${API_BASE_URL}/movies/${movieId}/scrape-status`, {
|
||||
method: 'GET',
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量查询电影刮削状态(逐个查询后合并结果)
|
||||
* @param {number[]} movieIds - 电影ID数组
|
||||
* @returns {Promise} 返回格式:{ code: 200, data: [{ movieId, title, status, message, posterUrl?, backdropUrl?, rating?, category?, tags? }] }
|
||||
*/
|
||||
export async function fetchBatchScrapeStatus(movieIds) {
|
||||
if (!movieIds || movieIds.length === 0) {
|
||||
return { code: 200, data: [] }
|
||||
}
|
||||
|
||||
// 并发查询每个电影的刮削状态
|
||||
const promises = movieIds.map(async (movieId) => {
|
||||
try {
|
||||
const result = await fetchScrapeStatus(movieId)
|
||||
if (result?.code === 200 && result?.data) {
|
||||
return result.data
|
||||
}
|
||||
return null
|
||||
} catch (err) {
|
||||
console.warn(`查询电影[movieId=${movieId}]刮削状态失败`, err)
|
||||
return null
|
||||
}
|
||||
})
|
||||
|
||||
const results = await Promise.all(promises)
|
||||
return {
|
||||
code: 200,
|
||||
data: results.filter(Boolean),
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,15 +1,31 @@
|
||||
<template>
|
||||
<div class="folder-tree">
|
||||
<!-- 多选模式工具栏 -->
|
||||
<div v-if="multiSelectMode" class="multi-select-toolbar">
|
||||
<span class="multi-select-toolbar__count">已选择 {{ selectedIds.size }} 项</span>
|
||||
<button type="button" class="multi-select-toolbar__btn multi-select-toolbar__btn--danger" @click="handleBatchDelete">
|
||||
🗑️ 批量删除
|
||||
</button>
|
||||
<button type="button" class="multi-select-toolbar__btn" @click="exitMultiSelect">
|
||||
✕ 退出多选
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 根节点列表 -->
|
||||
<div v-for="node in treeData" :key="node.id" class="tree-node">
|
||||
<TreeNode
|
||||
:node="node"
|
||||
:depth="0"
|
||||
:expanded-folder-ids="expandedFolderIds"
|
||||
:selected-ids="selectedIds"
|
||||
:multi-select-mode="multiSelectMode"
|
||||
@toggle-select="handleToggleSelect"
|
||||
@add-folder="handleAddFolder"
|
||||
@add-file="handleAddFile"
|
||||
@expand="handleExpand"
|
||||
@edit="handleEdit"
|
||||
@delete="handleDelete"
|
||||
@scrape-status="handleScrapeStatus"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@ -18,7 +34,7 @@
|
||||
<script setup>
|
||||
import TreeNode from './TreeNode.vue'
|
||||
|
||||
defineProps({
|
||||
const props = defineProps({
|
||||
treeData: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
@ -27,9 +43,17 @@ defineProps({
|
||||
type: Set,
|
||||
default: () => new Set(),
|
||||
},
|
||||
multiSelectMode: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
selectedIds: {
|
||||
type: Set,
|
||||
default: () => new Set(),
|
||||
},
|
||||
})
|
||||
|
||||
const emit = defineEmits(['add-folder', 'add-file', 'expand', 'edit', 'delete'])
|
||||
const emit = defineEmits(['add-folder', 'add-file', 'expand', 'edit', 'delete', 'toggle-select', 'batch-delete', 'toggle-multi-select-exit', 'scrape-status'])
|
||||
|
||||
function handleAddFolder(parentId) {
|
||||
emit('add-folder', parentId)
|
||||
@ -50,6 +74,22 @@ function handleExpand(node) {
|
||||
function handleDelete(node) {
|
||||
emit('delete', node)
|
||||
}
|
||||
|
||||
function handleScrapeStatus(node) {
|
||||
emit('scrape-status', node)
|
||||
}
|
||||
|
||||
function handleToggleSelect(nodeId) {
|
||||
emit('toggle-select', nodeId)
|
||||
}
|
||||
|
||||
function handleBatchDelete() {
|
||||
emit('batch-delete', props.selectedIds)
|
||||
}
|
||||
|
||||
function exitMultiSelect() {
|
||||
emit('toggle-multi-select-exit')
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@ -60,4 +100,51 @@ function handleDelete(node) {
|
||||
.tree-node {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
/* 多选模式工具栏样式 */
|
||||
.multi-select-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 12px 16px;
|
||||
margin-bottom: 16px;
|
||||
background: linear-gradient(135deg, rgba(99, 102, 241, 0.08), rgba(139, 92, 246, 0.08));
|
||||
border: 1px solid rgba(99, 102, 241, 0.2);
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.multi-select-toolbar__count {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #6366f1;
|
||||
}
|
||||
|
||||
.multi-select-toolbar__btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 6px 12px;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
background: rgba(99, 102, 241, 0.1);
|
||||
color: #6366f1;
|
||||
}
|
||||
|
||||
.multi-select-toolbar__btn:hover {
|
||||
background: rgba(99, 102, 241, 0.2);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.multi-select-toolbar__btn--danger {
|
||||
background: rgba(239, 68, 68, 0.1);
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
.multi-select-toolbar__btn--danger:hover {
|
||||
background: rgba(239, 68, 68, 0.2);
|
||||
}
|
||||
</style>
|
||||
@ -2,17 +2,38 @@
|
||||
<div class="tree-item" :style="{ marginLeft: depth * 24 + 'px' }">
|
||||
<div
|
||||
class="tree-item__header"
|
||||
:class="{ 'is-folder': node.type === 'folder' }"
|
||||
@click="toggleExpand"
|
||||
:class="{ 'is-folder': node.type === 'folder', 'is-selected': isSelected }"
|
||||
@click="handleClick"
|
||||
@mouseenter="onHeaderMouseEnter"
|
||||
@mouseleave="onHeaderMouseLeave"
|
||||
>
|
||||
<span v-if="node.type === 'folder'" class="tree-icon">
|
||||
<!-- 多选模式下的复选框 -->
|
||||
<input
|
||||
v-if="multiSelectMode"
|
||||
type="checkbox"
|
||||
class="tree-checkbox"
|
||||
:checked="isSelected"
|
||||
@click.stop
|
||||
@change="handleCheckboxChange"
|
||||
/>
|
||||
<!-- 文件夹/文件图标 -->
|
||||
<span v-if="multiSelectMode" class="tree-icon tree-icon--checkbox"></span>
|
||||
<span v-else-if="node.type === 'folder'" class="tree-icon">
|
||||
{{ isExpanded ? '📂' : '📁' }}
|
||||
</span>
|
||||
<span v-else class="tree-icon">📄</span>
|
||||
<span v-if="node.type === 'folder' && node.isLoading" class="tree-loading">加载中...</span>
|
||||
|
||||
<!-- 刮削状态徽章(仅文件类型) -->
|
||||
<span
|
||||
v-if="node.type === 'file' && node.scrapeStatus"
|
||||
class="tree-scrape-badge"
|
||||
:class="`tree-scrape-badge--${node.scrapeStatus}`"
|
||||
:title="getScrapeStatusTitle(node)"
|
||||
>
|
||||
{{ getScrapeStatusText(node.scrapeStatus) }}
|
||||
</span>
|
||||
|
||||
<!-- 排序权重标签 -->
|
||||
<span v-if="node.type === 'folder'" class="tree-sort-badge" :title="`排序权重:${node.sortWeight ?? 0}`">
|
||||
{{ node.sortWeight ?? 0 }}
|
||||
@ -28,6 +49,13 @@
|
||||
>ℹ️</span>
|
||||
|
||||
<div class="tree-actions" @click.stop>
|
||||
<!-- 刮削进度条(刮削中时显示) -->
|
||||
<div
|
||||
v-if="node.type === 'file' && node.scrapeStatus === 'scraping'"
|
||||
class="tree-scrape-progress"
|
||||
>
|
||||
<div class="tree-scrape-progress__bar"></div>
|
||||
</div>
|
||||
<button
|
||||
v-if="node.type === 'folder'"
|
||||
type="button"
|
||||
@ -46,6 +74,15 @@
|
||||
>
|
||||
+📄
|
||||
</button>
|
||||
<button
|
||||
v-if="node.type === 'file'"
|
||||
type="button"
|
||||
class="action-btn action-btn--scrape"
|
||||
title="查询刮削状态"
|
||||
@click="handleScrapeStatus"
|
||||
>
|
||||
🔍
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="action-btn"
|
||||
@ -98,11 +135,15 @@
|
||||
:node="child"
|
||||
:depth="depth + 1"
|
||||
:expanded-folder-ids="expandedFolderIds"
|
||||
:multi-select-mode="multiSelectMode"
|
||||
:selected-ids="selectedIds"
|
||||
@toggle-select="$emit('toggle-select', $event)"
|
||||
@add-folder="$emit('add-folder', $event)"
|
||||
@add-file="$emit('add-file', $event)"
|
||||
@expand="$emit('expand', $event)"
|
||||
@edit="$emit('edit', $event)"
|
||||
@delete="$emit('delete', $event)"
|
||||
@scrape-status="$emit('scrape-status', $event)"
|
||||
/>
|
||||
</div>
|
||||
</Transition>
|
||||
@ -125,9 +166,19 @@ const props = defineProps({
|
||||
type: Set,
|
||||
default: () => new Set(),
|
||||
},
|
||||
multiSelectMode: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
selectedIds: {
|
||||
type: Set,
|
||||
default: () => new Set(),
|
||||
},
|
||||
})
|
||||
|
||||
const emit = defineEmits(['add-folder', 'add-file', 'expand', 'edit', 'delete'])
|
||||
const emit = defineEmits(['add-folder', 'add-file', 'expand', 'edit', 'delete', 'toggle-select', 'scrape-status'])
|
||||
|
||||
const isSelected = computed(() => props.selectedIds.has(props.node.id))
|
||||
|
||||
const isExpanded = ref(false)
|
||||
const showTooltip = ref(false)
|
||||
@ -171,11 +222,26 @@ function onHeaderMouseLeave() {
|
||||
showTooltip.value = false
|
||||
}
|
||||
|
||||
function handleClick(event) {
|
||||
// 多选模式下,点击复选框触发选择/取消选择
|
||||
if (props.multiSelectMode && event.target.type !== 'checkbox') {
|
||||
emit('toggle-select', props.node.id)
|
||||
}
|
||||
// 文件夹展开逻辑
|
||||
toggleExpand()
|
||||
}
|
||||
|
||||
function handleCheckboxChange(event) {
|
||||
emit('toggle-select', props.node.id)
|
||||
}
|
||||
|
||||
function toggleExpand() {
|
||||
if (props.node.type === 'folder') {
|
||||
const nextExpanded = !isExpanded.value
|
||||
isExpanded.value = nextExpanded
|
||||
if (nextExpanded) {
|
||||
// 展开时立即将文件夹ID加入全局展开状态,确保buildTree重建时能保持展开
|
||||
props.expandedFolderIds.add(props.node.id)
|
||||
emit('expand', props.node)
|
||||
} else {
|
||||
// 收起时从全局展开状态中移除
|
||||
@ -199,6 +265,27 @@ function handleEdit() {
|
||||
function handleDelete() {
|
||||
emit('delete', props.node)
|
||||
}
|
||||
|
||||
function handleScrapeStatus() {
|
||||
emit('scrape-status', props.node)
|
||||
}
|
||||
|
||||
function getScrapeStatusText(status) {
|
||||
const statusMap = {
|
||||
scraping: '刮削中',
|
||||
success: '成功',
|
||||
failed: '失败',
|
||||
}
|
||||
return statusMap[status] || ''
|
||||
}
|
||||
|
||||
function getScrapeStatusTitle(node) {
|
||||
const parts = []
|
||||
if (node.scrapeMessage) parts.push(node.scrapeMessage)
|
||||
if (node.scrapeRating) parts.push(`评分: ${node.scrapeRating}`)
|
||||
if (node.scrapeCategory) parts.push(`分类: ${node.scrapeCategory}`)
|
||||
return parts.length > 0 ? parts.join(' | ') : node.scrapeStatus
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@ -230,6 +317,22 @@ function handleDelete() {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.tree-icon--checkbox {
|
||||
width: 18px;
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
.tree-checkbox {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.tree-item__header.is-selected {
|
||||
background: rgba(99, 102, 241, 0.2);
|
||||
}
|
||||
|
||||
.tree-loading {
|
||||
flex-shrink: 0;
|
||||
font-size: 12px;
|
||||
@ -315,4 +418,71 @@ function handleDelete() {
|
||||
max-height: 0;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
/* ===== 刮削状态徽章样式 ===== */
|
||||
.tree-scrape-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 36px;
|
||||
height: 20px;
|
||||
padding: 0 6px;
|
||||
border-radius: 10px;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
flex-shrink: 0;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.tree-scrape-badge--scraping {
|
||||
background: rgba(59, 130, 246, 0.15);
|
||||
color: #3b82f6;
|
||||
animation: scrape-pulse 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.tree-scrape-badge--success {
|
||||
background: rgba(34, 197, 94, 0.15);
|
||||
color: #22c55e;
|
||||
}
|
||||
|
||||
.tree-scrape-badge--failed {
|
||||
background: rgba(239, 68, 68, 0.15);
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
@keyframes scrape-pulse {
|
||||
0%, 100% {
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.6;
|
||||
}
|
||||
}
|
||||
|
||||
/* 刮削进度条动画 */
|
||||
.tree-scrape-progress {
|
||||
width: 40px;
|
||||
height: 3px;
|
||||
background: rgba(59, 130, 246, 0.1);
|
||||
border-radius: 2px;
|
||||
overflow: hidden;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.tree-scrape-progress__bar {
|
||||
width: 40%;
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, #3b82f6, #60a5fa);
|
||||
border-radius: 2px;
|
||||
animation: scrape-progress-slide 1.2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes scrape-progress-slide {
|
||||
0% {
|
||||
transform: translateX(-100%);
|
||||
}
|
||||
100% {
|
||||
transform: translateX(350%);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@ -1614,6 +1614,10 @@ canvas {
|
||||
margin-bottom: 16px;
|
||||
padding-bottom: 16px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.folder-tree-actions__btn {
|
||||
@ -1625,7 +1629,8 @@ canvas {
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
width: 100%;
|
||||
width: auto;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.folder-tree-actions__btn:hover {
|
||||
@ -2117,6 +2122,61 @@ canvas {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* ===== 刮削日志样式 ===== */
|
||||
.scraper-log-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.scraper-log-list .log-entry {
|
||||
background: linear-gradient(135deg, rgba(99, 102, 241, 0.02), rgba(139, 92, 246, 0.02));
|
||||
border-color: rgba(99, 102, 241, 0.15);
|
||||
}
|
||||
|
||||
.scraper-log-list .log-entry:hover {
|
||||
background: linear-gradient(135deg, rgba(99, 102, 241, 0.05), rgba(139, 92, 246, 0.05));
|
||||
}
|
||||
|
||||
.log-stats {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.stat-total,
|
||||
.stat-success,
|
||||
.stat-fail {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 4px 10px;
|
||||
border-radius: 999px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.stat-total {
|
||||
background: rgba(100, 116, 139, 0.1);
|
||||
color: #475569;
|
||||
}
|
||||
|
||||
.stat-success {
|
||||
background: rgba(34, 197, 94, 0.1);
|
||||
color: #15803d;
|
||||
}
|
||||
|
||||
.stat-fail {
|
||||
background: rgba(239, 68, 68, 0.1);
|
||||
color: #b91c1c;
|
||||
}
|
||||
|
||||
.log-time {
|
||||
font-weight: 600;
|
||||
color: var(--ink);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.column-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@ -3133,6 +3193,84 @@ canvas {
|
||||
}
|
||||
}
|
||||
|
||||
/* ====== 全局初始加载遮罩层 ====== */
|
||||
/* 覆盖整个应用,在数据完全加载前显示 */
|
||||
.global-loading-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.85); /* 深色半透明背景 */
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 100000; /* 确保在所有内容包括登录页之上 */
|
||||
backdrop-filter: blur(4px); /* 毛玻璃效果 */
|
||||
}
|
||||
|
||||
.global-loading-overlay__content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 24px;
|
||||
padding: 40px;
|
||||
}
|
||||
|
||||
.global-loading-overlay__gif {
|
||||
width: 180px;
|
||||
height: 180px;
|
||||
object-fit: contain;
|
||||
animation: global-loading-pulse 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.global-loading-overlay__text {
|
||||
color: #fff;
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
margin: 0;
|
||||
letter-spacing: 1px;
|
||||
text-shadow: 0 2px 8px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
@keyframes global-loading-pulse {
|
||||
0%, 100% {
|
||||
transform: scale(1);
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
transform: scale(1.05);
|
||||
opacity: 0.9;
|
||||
}
|
||||
}
|
||||
|
||||
/* 全局加载遮罩层淡入淡出过渡 */
|
||||
.global-overlay-fade-enter-active {
|
||||
animation: global-overlay-in 0.3s ease-out;
|
||||
}
|
||||
|
||||
.global-overlay-fade-leave-active {
|
||||
animation: global-overlay-out 0.3s ease-in;
|
||||
}
|
||||
|
||||
@keyframes global-overlay-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes global-overlay-out {
|
||||
from {
|
||||
opacity: 1;
|
||||
}
|
||||
to {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* ====== 网盘操作加载遮罩层 ====== */
|
||||
/* 覆盖整个管理区域,显示旋转动画和提示文字 */
|
||||
.drive-loading-overlay {
|
||||
@ -3183,3 +3321,105 @@ canvas {
|
||||
.loading-fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
/* ====== 刮削状态面板样式 ====== */
|
||||
.scrape-status-panel {
|
||||
padding: 16px 0;
|
||||
}
|
||||
|
||||
.scrape-status-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.scrape-status-header h3 {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: #1f2937;
|
||||
}
|
||||
|
||||
.scrape-status-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 4px 12px;
|
||||
border-radius: 12px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.scrape-status-badge--scraping {
|
||||
background: rgba(59, 130, 246, 0.1);
|
||||
color: #3b82f6;
|
||||
}
|
||||
|
||||
.scrape-status-badge--success {
|
||||
background: rgba(34, 197, 94, 0.1);
|
||||
color: #22c55e;
|
||||
}
|
||||
|
||||
.scrape-status-badge--failed {
|
||||
background: rgba(239, 68, 68, 0.1);
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
.scrape-status-message {
|
||||
padding: 12px;
|
||||
background: #f9fafb;
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
color: #4b5563;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.scrape-status-poster,
|
||||
.scrape-status-backdrop {
|
||||
margin-bottom: 16px;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
max-height: 300px;
|
||||
}
|
||||
|
||||
.scrape-status-poster img,
|
||||
.scrape-status-backdrop img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.scrape-status-poster {
|
||||
max-height: 400px;
|
||||
}
|
||||
|
||||
.scrape-status-meta {
|
||||
font-size: 14px;
|
||||
color: #6b7280;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.scrape-status-tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.scrape-status-tags span {
|
||||
display: inline-block;
|
||||
padding: 4px 12px;
|
||||
background: rgba(99, 102, 241, 0.1);
|
||||
color: #6366f1;
|
||||
border-radius: 12px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.scrape-status-loading {
|
||||
text-align: center;
|
||||
padding: 40px 20px;
|
||||
color: #6b7280;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user