功能:新增全局加载覆盖层及批量抓取功能

引入带有动画GIF的全局初始加载遮罩层,提升应用启动时的用户体验。新增文件夹树操作的多选模式,支持批量删除和重新抓取选定项。增强NodeCard组件,增加抓取状态指示器和勾选框选择功能。
This commit is contained in:
liRQ 2026-06-25 18:27:13 +08:00
parent bacbe1871c
commit 69494434e9
6 changed files with 1107 additions and 24 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 MiB

View File

@ -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
// movieIdnode.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(),

View File

@ -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),
}
}

View File

@ -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>

View File

@ -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) {
// IDbuildTree
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>

View File

@ -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;
}