-实现了`generateCaptcha`函数来创建随机验证码。 -添加了`drawCaptcha`函数,在画布上渲染带有视觉噪声的验证码。 feat:引入数据操作实用程序 -为数据处理创建了“normalizeText”、“filterRows”、“attachSheetName”、“flatenRows”和“buildDuplicates”函数。 -添加了用于记录管理的“createLoginLog”、“createRecord”、“mapMovieToRecord”和“createFolder”函数。 -实现了“buildTree”函数来构建分层数据结构。 feat:实现滚动锁定机制 -添加了“lockScroll”和“unlockScrolls”功能,以管理模式交互期间的后台滚动行为。 feat:创建存储实用程序函数 -引入了从本地存储中读取、写入和删除项目的功能。 chore:设置Vite配置 -配置了使用Vue进行开发的Vite,包括服务器设置和API代理。
3090 lines
106 KiB
Vue
3090 lines
106 KiB
Vue
<template>
|
||
<div class="app-wrapper">
|
||
<!-- 全局初始加载遮罩层 - 在数据完全加载前显示 -->
|
||
<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>
|
||
<!-- 全局请求加载条(顶部) -->
|
||
<Transition name="topbar-fade">
|
||
<div v-if="hasPendingRequest" class="global-loading-bar">
|
||
<div class="global-loading-bar__progress"></div>
|
||
</div>
|
||
</Transition>
|
||
<Transition name="notice">
|
||
<div v-if="sessionWarning" class="global-notice global-notice--warning" role="status">
|
||
{{ sessionWarning }}
|
||
</div>
|
||
</Transition>
|
||
<Transition name="notice">
|
||
<div v-if="forbiddenNotice" class="global-notice global-notice--danger" role="alert">
|
||
{{ forbiddenNotice }}
|
||
</div>
|
||
</Transition>
|
||
<Transition name="notice">
|
||
<div v-if="errorMessage" class="global-notice global-notice--error" role="alert">
|
||
{{ errorMessage }}
|
||
</div>
|
||
</Transition>
|
||
<Transition name="notice">
|
||
<div v-if="successMessage" class="global-notice global-notice--success" role="status">
|
||
{{ successMessage }}
|
||
</div>
|
||
</Transition>
|
||
<section class="search-topbar">
|
||
<div class="search-topbar__input">
|
||
<input
|
||
v-model="searchKeyword"
|
||
type="search"
|
||
placeholder="🔍 输入关键词搜索..."
|
||
@focus="isSearchFocused = true"
|
||
@blur="isSearchFocused = false"
|
||
@keydown.enter="handleHeroSearch"
|
||
/>
|
||
</div>
|
||
|
||
<!-- 黑夜/白天模式切换按钮 -->
|
||
<button
|
||
type="button"
|
||
class="theme-toggle-btn"
|
||
:class="{ 'theme-toggle-btn--dark': isDarkMode }"
|
||
@click="toggleDarkMode"
|
||
:title="isDarkMode ? '切换到白天模式' : '切换到黑夜模式'"
|
||
>
|
||
<svg class="theme-toggle-btn__icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||
<g v-if="!isDarkMode" class="theme-icon--sun">
|
||
<circle cx="12" cy="12" r="5"></circle>
|
||
<line x1="12" y1="1" x2="12" y2="3"></line>
|
||
<line x1="12" y1="21" x2="12" y2="23"></line>
|
||
<line x1="4.22" y1="4.22" x2="5.64" y2="5.64"></line>
|
||
<line x1="18.36" y1="18.36" x2="19.78" y2="19.78"></line>
|
||
<line x1="1" y1="12" x2="3" y2="12"></line>
|
||
<line x1="21" y1="12" x2="23" y2="12"></line>
|
||
<line x1="4.22" y1="19.78" x2="5.64" y2="18.36"></line>
|
||
<line x1="18.36" y1="5.64" x2="19.78" y2="4.22"></line>
|
||
</g>
|
||
<g v-if="isDarkMode" class="theme-icon--moon">
|
||
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"></path>
|
||
</g>
|
||
</svg>
|
||
</button>
|
||
|
||
<!-- 快捷入口按钮和悬浮面板 -->
|
||
<div class="quick-access-wrapper" ref="quickAccessWrapperRef" @mouseenter="handleQuickAccessEnter" @mouseleave="handleQuickAccessWrapperLeave">
|
||
<button
|
||
type="button"
|
||
class="quick-access-btn"
|
||
:class="{ active: showQuickAccessPanel }"
|
||
@click.stop="toggleQuickAccessPanel"
|
||
title="快捷入口"
|
||
>
|
||
<svg class="quick-access-btn__icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||
<rect x="3" y="3" width="7" height="7" rx="1"/>
|
||
<rect x="14" y="3" width="7" height="7" rx="1"/>
|
||
<rect x="3" y="14" width="7" height="7" rx="1"/>
|
||
<rect x="14" y="14" width="7" height="7" rx="1"/>
|
||
</svg>
|
||
</button>
|
||
|
||
<!-- 快捷入口悬浮面板 -->
|
||
<Transition name="quick-access-fade">
|
||
<div v-if="showQuickAccessPanel" class="quick-access-panel" @mouseenter="handleQuickAccessPanelEnter" @mouseleave="handleQuickAccessPanelLeave" @click.stop>
|
||
<div class="quick-access-panel__header">
|
||
<h3>快捷入口</h3>
|
||
<div class="quick-icons__actions">
|
||
<!-- 内外网切换 -->
|
||
<div class="network-toggle" title="切换内外网链接">
|
||
<button
|
||
type="button"
|
||
:class="['network-toggle__btn', { 'network-toggle__btn--active': networkMode === 'external' }]"
|
||
@click="networkMode = 'external'"
|
||
>外网</button>
|
||
<button
|
||
type="button"
|
||
:class="['network-toggle__btn', { 'network-toggle__btn--active': networkMode === 'internal' }]"
|
||
@click="networkMode = 'internal'"
|
||
>内网</button>
|
||
</div>
|
||
<button v-if="!hasSearched" type="button" class="quick-icons__manage" @click="showIconManageDialog = true" title="管理图标">⚙️</button>
|
||
</div>
|
||
</div>
|
||
<div v-if="iconList.length" class="quick-icons__grid">
|
||
<a
|
||
v-for="icon in iconList"
|
||
:key="icon.id"
|
||
:href="getIconLink(icon)"
|
||
target="_blank"
|
||
rel="noopener noreferrer"
|
||
class="quick-icons__item"
|
||
:title="icon.description || icon.name"
|
||
>
|
||
<div class="quick-icons__thumb">
|
||
<img
|
||
:src="icon.logoUrl || getIconFavicon(icon.url)"
|
||
:alt="icon.name"
|
||
@error="(e) => { e.target.style.display = 'none'; e.target.nextElementSibling.style.display = 'flex' }"
|
||
/>
|
||
<span class="quick-icons__fallback" style="display:none">{{ (icon.name || '?')[0] }}</span>
|
||
</div>
|
||
<span class="quick-icons__label">{{ icon.name }}</span>
|
||
</a>
|
||
</div>
|
||
<div v-else class="quick-icons__empty">
|
||
<span>暂无快捷图标,点击 ⚙️ 添加</span>
|
||
</div>
|
||
</div>
|
||
</Transition>
|
||
</div>
|
||
|
||
<!-- 个人中心下拉菜单 -->
|
||
<div class="user-menu" ref="userMenuRef">
|
||
<button type="button" class="user-menu__trigger" @click="showUserMenu = !showUserMenu">
|
||
<img
|
||
v-if="currentUser.avatar"
|
||
:src="currentUser.avatar"
|
||
:alt="currentUser.username"
|
||
class="user-menu__avatar"
|
||
/>
|
||
<div v-else class="user-menu__avatar user-menu__avatar--fallback">
|
||
{{ currentUser.username?.charAt(0)?.toUpperCase() || 'U' }}
|
||
</div>
|
||
<span class="user-menu__name">{{ currentUser.username || '用户' }}</span>
|
||
<span class="user-menu__arrow">▼</span>
|
||
</button>
|
||
|
||
<div v-if="showUserMenu" class="user-menu__dropdown">
|
||
<div class="user-menu__header">
|
||
<img
|
||
v-if="currentUser.avatar"
|
||
:src="currentUser.avatar"
|
||
:alt="currentUser.username"
|
||
class="user-menu__avatar--lg"
|
||
/>
|
||
<div v-else class="user-menu__avatar user-menu__avatar--fallback user-menu__avatar--lg">
|
||
{{ currentUser.username?.charAt(0)?.toUpperCase() || 'U' }}
|
||
</div>
|
||
<div class="user-menu__info">
|
||
<div class="user-menu__username">{{ currentUser.username }}</div>
|
||
</div>
|
||
<button type="button" class="user-menu__edit-avatar" @click="handleChangeAvatar" title="修改头像">
|
||
📷
|
||
</button>
|
||
</div>
|
||
<div class="user-menu__divider"></div>
|
||
<button type="button" class="user-menu__item" @click="switchToPoster">
|
||
<span class="user-menu__icon">🎬</span>
|
||
<span>海报展示</span>
|
||
</button>
|
||
<button type="button" class="user-menu__item" @click="handleSwitchToManage">
|
||
<span class="user-menu__icon">📋</span>
|
||
<span>控制台</span>
|
||
</button>
|
||
<button type="button" class="user-menu__item" @click="handleTmdbConfig">
|
||
<span class="user-menu__icon">⚙️</span>
|
||
<span>TMDB 配置</span>
|
||
</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">
|
||
<span class="user-menu__icon">📤</span>
|
||
<span>导出数据</span>
|
||
</button>
|
||
<button type="button" class="user-menu__item" @click="handleReset">
|
||
<span class="user-menu__icon">🔄</span>
|
||
<span>恢复初始数据</span>
|
||
</button>
|
||
<div class="user-menu__divider"></div>
|
||
<button type="button" class="user-menu__item user-menu__item--danger" @click="handleLogout">
|
||
<span class="user-menu__icon">🚪</span>
|
||
<span>退出登录</span>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
|
||
<header class="topbar dashboard-topbar">
|
||
<div class="dashboard-brand">
|
||
<strong>{{ currentView === 'manage' ? '控制台' : '海报展示' }}</strong>
|
||
<span>{{ currentView === 'manage' ? '管理网盘资源与账号数据' : '根据网盘内容进行 TMDB 海报刮削展示' }}</span>
|
||
</div>
|
||
</header>
|
||
|
||
<section v-if="currentView === 'poster' && !hasSearched && !isSearchFocused" class="hero-carousel" aria-label="每日推荐海报">
|
||
<!-- 主海报横幅 -->
|
||
<Transition name="hero-fade" mode="out-in">
|
||
<article
|
||
v-if="featuredPosters.length"
|
||
:key="activeFeaturedPoster.id"
|
||
class="hero-main"
|
||
:class="{
|
||
'hero-loading': !heroImageLoaded,
|
||
'hero-loaded': heroImageLoaded,
|
||
'hero-wide': isWidePoster(activeFeaturedPoster.posterUrl)
|
||
}"
|
||
:style="buildHeroStyle(activeFeaturedPoster)"
|
||
tabindex="0"
|
||
@mouseenter="pauseFeaturedAutoplay"
|
||
@mouseleave="resumeFeaturedAutoplay"
|
||
@focusin="pauseFeaturedAutoplay"
|
||
@focusout="resumeFeaturedAutoplay"
|
||
@keydown.left.prevent="showPrevFeaturedPoster"
|
||
@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__badge">每日推荐</div>
|
||
<div class="hero-main__info">
|
||
<h2 class="hero-main__title">{{ activeFeaturedPoster.title }}</h2>
|
||
<p class="hero-main__desc">{{ activeFeaturedPoster.subtitle || activeFeaturedPoster.description || '根据网盘资源内容自动匹配海报展示。' }}</p>
|
||
<div class="hero-main__meta">
|
||
<span v-if="activeFeaturedPoster.accountName" class="hero-main__meta-item">📁 {{ activeFeaturedPoster.accountName }}</span>
|
||
<span v-if="activeFeaturedPoster.owner" class="hero-main__meta-item">👤 {{ activeFeaturedPoster.owner }}</span>
|
||
<span v-if="activeFeaturedPoster.status" class="hero-main__meta-item">{{ activeFeaturedPoster.status === '已删除' ? '🗑️' : '✅' }} {{ activeFeaturedPoster.status }}</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</article>
|
||
</Transition>
|
||
|
||
<!-- 小海报卡片 - 悬浮于大海报右下角 -->
|
||
<div class="hero-sub" :class="{ 'hero-sub--above': featuredPosters.length }">
|
||
<div
|
||
v-for="(poster, index) in featuredPosters"
|
||
:key="poster.id"
|
||
class="hero-sub__card"
|
||
:class="{ active: activeFeaturedPoster.id === poster.id }"
|
||
@click="setFeaturedPoster(index)"
|
||
>
|
||
<div class="hero-sub__thumb" :style="buildSubThumbStyle(poster.posterUrl)">
|
||
<span class="hero-sub__title">{{ poster.title }}</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
|
||
<section v-if="isLoading" class="loading-panel">
|
||
<div class="loading-card">
|
||
<div class="loading-spinner"></div>
|
||
<span>正在加载数据…</span>
|
||
</div>
|
||
</section>
|
||
|
||
<!-- 搜索聚焦时的提示 -->
|
||
<Transition name="fade" mode="out-in">
|
||
<section v-if="isSearchFocused && !hasSearched" class="search-focus-hint" key="search-hint">
|
||
<div class="search-focus-content">
|
||
<div class="search-focus-icon">✨</div>
|
||
<h3>输入搜索关键词</h3>
|
||
<p>搜索标题、标签、资源库或维护人</p>
|
||
</div>
|
||
</section>
|
||
</Transition>
|
||
|
||
|
||
<template v-if="!isLoading && currentView === 'manage' && !isSearchFocused">
|
||
<!-- 快捷图标区域 -->
|
||
<section ref="quickIconsRef" v-if="iconList.length || !hasSearched" class="quick-icons">
|
||
<div class="quick-icons__header">
|
||
<h3>快捷入口</h3>
|
||
<div class="quick-icons__actions">
|
||
<!-- 内外网切换 -->
|
||
<div class="network-toggle" title="切换内外网链接">
|
||
<button
|
||
type="button"
|
||
:class="['network-toggle__btn', { 'network-toggle__btn--active': networkMode === 'external' }]"
|
||
@click="networkMode = 'external'"
|
||
>外网</button>
|
||
<button
|
||
type="button"
|
||
:class="['network-toggle__btn', { 'network-toggle__btn--active': networkMode === 'internal' }]"
|
||
@click="networkMode = 'internal'"
|
||
>内网</button>
|
||
</div>
|
||
<button v-if="!hasSearched" type="button" class="quick-icons__manage" @click="showIconManageDialog = true" title="管理图标">⚙️ 管理</button>
|
||
</div>
|
||
</div>
|
||
<div v-if="iconList.length" class="quick-icons__grid">
|
||
<a
|
||
v-for="icon in iconList"
|
||
:key="icon.id"
|
||
:href="getIconLink(icon)"
|
||
target="_blank"
|
||
rel="noopener noreferrer"
|
||
class="quick-icons__item"
|
||
:title="icon.description || icon.name"
|
||
>
|
||
<div class="quick-icons__thumb">
|
||
<img
|
||
:src="icon.logoUrl || getIconFavicon(icon.url)"
|
||
:alt="icon.name"
|
||
@error="(e) => { e.target.style.display = 'none'; e.target.nextElementSibling.style.display = 'flex' }"
|
||
/>
|
||
<span class="quick-icons__fallback" style="display:none">{{ (icon.name || '?')[0] }}</span>
|
||
</div>
|
||
<span class="quick-icons__label">{{ icon.name }}</span>
|
||
</a>
|
||
</div>
|
||
<div v-else-if="!hasSearched" class="quick-icons__empty">
|
||
<span>暂无快捷图标,点击 ⚙️ 添加</span>
|
||
</div>
|
||
</section>
|
||
|
||
<section class="manage-layout">
|
||
<!-- 左侧:网盘管理 -->
|
||
<section class="manage-main" aria-label="网盘管理内容">
|
||
<div class="content-toolbar">
|
||
<div>
|
||
<h2 v-if="!hasSearched">网盘管理</h2>
|
||
<h2 v-else>搜索结果</h2>
|
||
<p v-if="!hasSearched">维护资源标题、资源库、海报地址和 TMDB 刮削所需内容。</p>
|
||
<p v-else>找到 {{ filteredRows.length }} 个匹配“{{ searchKeyword }}”的结果</p>
|
||
</div>
|
||
<div class="top-actions content-actions">
|
||
<button v-if="!hasSearched" type="button" class="secondary" @click="handleOpenLogDialog">登录日志</button>
|
||
<!-- 只有管理员(userType=2)才能看到用户管理按钮 -->
|
||
<button v-if="!hasSearched && currentUser.userType === 2" type="button" class="secondary" @click="showUserManageDialog = true">用户管理</button>
|
||
<!-- 网盘管理按钮:统一的增删改查入口 -->
|
||
<button v-if="!hasSearched" type="button" class="secondary" @click="showDriveManageDialog = true">网盘管理</button>
|
||
<button v-else type="button" class="secondary" @click="hasSearched = false; searchKeyword = ''">清空搜索</button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 网盘标签页 -->
|
||
<div v-if="!hasSearched" class="drive-tabs">
|
||
<div
|
||
v-for="sheet in displaySheets"
|
||
:key="sheet.id"
|
||
class="drive-tab"
|
||
:class="{ active: activeSheetId === sheet.id }"
|
||
@click="activeSheetId = sheet.id"
|
||
@mouseenter="(e) => showTabTooltip(sheet.id, e)"
|
||
@mouseleave="hideTabTooltip"
|
||
>
|
||
<!-- 网盘Logo -->
|
||
<img
|
||
v-if="sheet.icon"
|
||
:src="sheet.icon"
|
||
:alt="sheet.name"
|
||
class="drive-tab__logo"
|
||
/>
|
||
<div v-else class="drive-tab__logo drive-tab__logo--fallback">📁</div>
|
||
<span class="drive-tab__name">{{ sheet.name }}</span>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 文件夹树形展示 -->
|
||
<div ref="folderTreeContainerRef" class="folder-tree-container">
|
||
<!-- 创建根级文件夹按钮 -->
|
||
<div v-if="!hasSearched" class="folder-tree-actions">
|
||
<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 folder-tree-actions__btn--refresh"
|
||
@click="handleRefreshFolders"
|
||
title="刷新当前网盘文件夹列表"
|
||
>
|
||
🗂️ 刷新文件夹
|
||
</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>
|
||
|
||
<!-- 搜索结果为空时的提示 -->
|
||
<div v-if="hasSearched && filteredRows.length === 0" class="empty-search-result">
|
||
<div class="empty-search-content">
|
||
<div class="empty-search-icon">🔍</div>
|
||
<h3>空空如也</h3>
|
||
<p>没有找到匹配“{{ searchKeyword }}”的内容</p>
|
||
<button type="button" class="secondary" @click="hasSearched = false; searchKeyword = ''">
|
||
清空搜索
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<FolderTree
|
||
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"
|
||
@rescrape="handleScrapeMovie"
|
||
/>
|
||
</div>
|
||
</section>
|
||
|
||
<!-- 右侧:重复内容 -->
|
||
<aside class="duplicate-panel" aria-label="重复内容">
|
||
<header>
|
||
<h2>重复内容</h2>
|
||
<span>{{ duplicateItems.length }}</span>
|
||
</header>
|
||
<div v-if="duplicateItems.length" class="duplicate-list">
|
||
<div v-for="dup in duplicateItems" :key="dup.id" class="duplicate-item">
|
||
<strong>{{ dup.title }}</strong>
|
||
<ul>
|
||
<li v-for="loc in dup.locations" :key="loc">{{ loc }}</li>
|
||
</ul>
|
||
</div>
|
||
</div>
|
||
<div v-else class="empty-note">当前没有检测到重复标题。</div>
|
||
</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>
|
||
</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 v-if="scrapeStatusData.message" class="scrape-status-message">
|
||
{{ scrapeStatusData.message }}
|
||
</div>
|
||
<!-- 失败时显示详细错误信息 -->
|
||
<div v-if="scrapeStatusData.status === 'failed' && scrapeStatusData.error" class="scrape-status-error">
|
||
<strong>失败原因:</strong>{{ scrapeStatusData.error }}
|
||
</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 v-if="scrapeStatusData.overview" class="scrape-status-overview">
|
||
<span class="scrape-status-label">简介:</span>
|
||
<span class="scrape-status-value">{{ scrapeStatusData.overview }}</span>
|
||
</div>
|
||
<!-- 导演 -->
|
||
<div v-if="scrapeStatusData.directors" class="scrape-status-meta">
|
||
<span>导演:{{ scrapeStatusData.directors }}</span>
|
||
</div>
|
||
<!-- 主演 -->
|
||
<div v-if="scrapeStatusData.casts" class="scrape-status-meta">
|
||
<span>主演:{{ scrapeStatusData.casts }}</span>
|
||
</div>
|
||
</div>
|
||
<div v-else class="scrape-status-loading">
|
||
正在查询刮削状态...
|
||
</div>
|
||
</SimpleDialog>
|
||
</template>
|
||
|
||
<template v-else-if="!isLoading && currentView === 'poster' && !hasSearched && !isSearchFocused">
|
||
<section class="poster-showcase">
|
||
<div class="content-toolbar">
|
||
<div>
|
||
<h2>海报展示</h2>
|
||
<p>根据网盘管理中的标题与海报地址进行 TMDB 匹配展示,可作为刮削结果预览。</p>
|
||
</div>
|
||
</div>
|
||
<div v-if="loadingPosterShowcase" class="poster-list-loading">
|
||
正在加载海报数据...
|
||
</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" />
|
||
</div>
|
||
</div>
|
||
</section>
|
||
</template>
|
||
|
||
<!-- 海报展示的搜索结果 -->
|
||
<Transition name="fade" mode="out-in">
|
||
<template v-if="hasSearched && currentView === 'poster'">
|
||
<section class="poster-showcase" key="search-result">
|
||
<div class="content-toolbar">
|
||
<div>
|
||
<h2>搜索结果</h2>
|
||
<p>找到 {{ filteredPosterShowcaseData.length }} 个匹配“{{ searchKeyword }}”的结果</p>
|
||
</div>
|
||
<button type="button" class="secondary" @click="hasSearched = false; searchKeyword = ''">
|
||
清空搜索
|
||
</button>
|
||
</div>
|
||
<div v-if="filteredPosterShowcaseData.length > 0" class="poster-list poster-list--showcase">
|
||
<div v-for="item in filteredPosterShowcaseData" :key="item.id" class="poster-card-wrap">
|
||
<PosterCard :item="item" :readonly="true" />
|
||
</div>
|
||
</div>
|
||
<div v-else class="empty-search-result">
|
||
<div class="empty-search-content">
|
||
<div class="empty-search-icon">🔍</div>
|
||
<h3>空空如也</h3>
|
||
<p>没有找到匹配“{{ searchKeyword }}”的内容</p>
|
||
<button type="button" class="secondary" @click="hasSearched = false; searchKeyword = ''">
|
||
清空搜索
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
</template>
|
||
</Transition>
|
||
|
||
<FolderDialog
|
||
:open="showFolderDialog"
|
||
:folder="editingFolder"
|
||
@close="closeFolderDialog"
|
||
@save="handleFolderDialogSave"
|
||
/>
|
||
|
||
<RecordDialog
|
||
:open="showRecordDialog"
|
||
:editing-record="editingRecord"
|
||
:sheets="displaySheets"
|
||
:active-sheet-id="activeSheetId"
|
||
@close="closeRecordDialog"
|
||
@save="handleSaveRecord"
|
||
@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 log-entry--expandable">
|
||
<!-- 日志头部(可点击展开) -->
|
||
<div class="log-entry__header" @click="toggleLogDetail(log.id)">
|
||
<div class="log-entry__summary">
|
||
<span class="log-entry__toggle">{{ log.showDetail ? '▾' : '▸' }}</span>
|
||
<span class="log-entry__time">{{ log.scanTime || '-' }}</span>
|
||
<span class="log-entry__desc">{{ getStatusText(log.status) || '刮削任务执行记录' }}</span>
|
||
</div>
|
||
<div class="log-entry__stats">
|
||
<span class="stat-total">总共: {{ log.totalMovies || 0 }}</span>
|
||
<span class="stat-success">成功: {{ log.successCount || 0 }}</span>
|
||
<span class="stat-fail">失败: {{ log.failCount || 0 }}</span>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 详细日志(可展开) -->
|
||
<Transition name="slide-down">
|
||
<div v-if="log.showDetail && log.detailList && log.detailList.length > 0" class="log-entry__detail">
|
||
<template v-for="(item, idx) in log.detailList" :key="idx">
|
||
<div class="detail-item detail-item--user">
|
||
<span class="detail-item__name">👤 {{ item.username || '用户 ' + (item.userId || idx) }}</span>
|
||
<span class="detail-item__status detail-item__status--info">
|
||
处理: {{ item.processed || 0 }} | 成功: {{ item.success || 0 }} | 失败: {{ item.failed || 0 }} | 跳过: {{ item.skipped || 0 }}
|
||
</span>
|
||
</div>
|
||
<!-- 显示具体的消息列表 -->
|
||
<div v-if="item.messages && item.messages.length > 0" class="detail-item__messages">
|
||
<div v-for="(msg, msgIdx) in item.messages" :key="msgIdx" class="message-item">
|
||
<span :class="['message-item__indicator', getMessageStatus(msg)]">
|
||
{{ getMessageStatus(msg) === 'success' ? '✓' : getMessageStatus(msg) === 'failed' ? '✗' : '⊘' }}
|
||
</span>
|
||
<span class="message-item__text">{{ msg }}</span>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
</div>
|
||
</Transition>
|
||
</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">
|
||
<!-- 筛选区域 -->
|
||
<div class="log-filter">
|
||
<div class="filter-row">
|
||
<input
|
||
v-model="logFilter.username"
|
||
type="text"
|
||
placeholder="搜索用户名"
|
||
class="filter-input"
|
||
/>
|
||
<select v-model="logFilter.result" class="filter-select">
|
||
<option value="">全部结果</option>
|
||
<option value="success">成功</option>
|
||
<option value="fail">失败</option>
|
||
</select>
|
||
<button type="button" class="filter-search-btn" @click="applyLogFilter">搜索</button>
|
||
</div>
|
||
<div class="filter-row">
|
||
<input
|
||
v-model="logFilter.startDate"
|
||
type="date"
|
||
class="filter-input"
|
||
placeholder="开始日期"
|
||
/>
|
||
<input
|
||
v-model="logFilter.endDate"
|
||
type="date"
|
||
class="filter-input"
|
||
placeholder="结束日期"
|
||
/>
|
||
<button type="button" class="filter-reset-btn" @click="resetLogFilter">重置</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="log-integrity" :class="{ ok: logIntegrity }">
|
||
{{ logIntegrity ? '日志完整性校验通过' : '日志完整性校验失败' }}
|
||
</div>
|
||
<div v-if="loadingLogs" class="log-loading">
|
||
正在加载日志...
|
||
</div>
|
||
<div v-else-if="loginLogs.length === 0" class="log-empty">
|
||
{{ logPagination.pageNo === 1 ? '暂无登录日志' : '没有符合筛选条件的日志' }}
|
||
</div>
|
||
<template v-else>
|
||
<div class="login-log-list">
|
||
<div v-for="log in loginLogs" :key="log.id" class="log-entry">
|
||
<div class="log-entry__header">
|
||
<div>
|
||
<div class="log-time">{{ log.time }}</div>
|
||
<div class="log-account">账号:{{ log.username }}</div>
|
||
</div>
|
||
<span class="log-result" :class="`log-result--${log.result}`">
|
||
{{ log.result === 'success' ? '成功' : '失败' }}
|
||
</span>
|
||
</div>
|
||
<div class="log-detail">IP:{{ log.ip }}</div>
|
||
<div class="log-detail">设备:{{ log.device }}</div>
|
||
<div v-if="log.failReason" class="log-detail log-detail--fail">
|
||
失败原因:{{ log.failReason }}
|
||
</div>
|
||
<div class="log-detail log-detail--ua">UA:{{ log.rawUserAgent }}</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 分页信息和加载更多按钮 -->
|
||
<div v-if="logPagination.total > 0" class="log-pagination">
|
||
<div class="pagination-info">
|
||
已加载 {{ loginLogs.length }} / {{ logPagination.total }} 条
|
||
</div>
|
||
<button
|
||
v-if="logPagination.hasMore"
|
||
type="button"
|
||
class="load-more-btn"
|
||
@click="handleLoadMoreLogs"
|
||
:disabled="loadingLogs"
|
||
>
|
||
{{ loadingLogs ? '加载中...' : '加载更多' }}
|
||
</button>
|
||
<div v-else class="no-more">—— 已加载全部 ——</div>
|
||
</div>
|
||
</template>
|
||
</div>
|
||
</SimpleDialog>
|
||
|
||
<SimpleDialog :open="showTmdbDialog" title="TMDB 配置" @close="showTmdbDialog = false">
|
||
<div class="tmdb-config">
|
||
<label>
|
||
<span>接口密钥</span>
|
||
<input
|
||
v-model="tmdbConfig.apiKey"
|
||
type="text"
|
||
autocomplete="off"
|
||
placeholder="请输入 TMDB API Key"
|
||
/>
|
||
</label>
|
||
<p>接口密钥保存在后端,登录成功后会自动同步当前账号的 TMDB Key。</p>
|
||
<div class="tmdb-config__status" :class="{ ready: tmdbConfig.hasKey }">
|
||
{{ tmdbConfig.hasKey ? '当前账号已绑定 TMDB API Key' : '当前账号尚未绑定 TMDB API Key' }}
|
||
</div>
|
||
</div>
|
||
<template #footer>
|
||
<button type="button" class="secondary" @click="showTmdbDialog = false">取消</button>
|
||
<button type="button" @click="persistTmdbConfig">保存</button>
|
||
</template>
|
||
</SimpleDialog>
|
||
|
||
<!-- 用户管理对话框(仅管理员可见) -->
|
||
<UserManageDialog
|
||
:open="showUserManageDialog"
|
||
@close="showUserManageDialog = false"
|
||
@success="handleUserManageSuccess"
|
||
@error="setErrorMessage"
|
||
/>
|
||
|
||
<!-- 图标管理对话框 -->
|
||
<IconManageDialog
|
||
:open="showIconManageDialog"
|
||
@close="showIconManageDialog = false"
|
||
@success="setSuccessMessage"
|
||
@error="setErrorMessage"
|
||
@refresh="loadIconList"
|
||
/>
|
||
|
||
<!-- 网盘管理对话框:统一的增删改查入口 -->
|
||
<DriveManageDialog
|
||
:open="showDriveManageDialog"
|
||
@close="showDriveManageDialog = false"
|
||
@success="handleDriveManageSuccess"
|
||
@refresh="handleDriveListRefresh"
|
||
@error="setErrorMessage"
|
||
/>
|
||
|
||
<!-- 统一确认弹窗 -->
|
||
<ConfirmDialog
|
||
:open="showConfirmDialog"
|
||
:title="confirmConfig.title"
|
||
:message="confirmConfig.message"
|
||
:hint="confirmConfig.hint"
|
||
:icon="confirmConfig.icon"
|
||
:confirm-text="confirmConfig.confirmText"
|
||
:cancel-text="confirmConfig.cancelText"
|
||
:danger="confirmConfig.danger"
|
||
@confirm="handleConfirmDialogConfirm"
|
||
@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 to="body">
|
||
<Transition name="tooltip-fade">
|
||
<div v-if="activeTabTooltipId && activeTabTooltipSheet" class="app-tooltip" :style="tabTooltipStyle">
|
||
<div class="app-tooltip__header">
|
||
<span class="app-tooltip__icon">💾</span>
|
||
<span class="app-tooltip__title">{{ activeTabTooltipSheet.name }}</span>
|
||
</div>
|
||
<div class="app-tooltip__body">
|
||
<p v-if="activeTabTooltipSheet.description" class="app-tooltip__desc">{{ activeTabTooltipSheet.description }}</p>
|
||
<span v-else class="app-tooltip__empty">暂无描述</span>
|
||
</div>
|
||
<div class="app-tooltip__arrow"></div>
|
||
</div>
|
||
</Transition>
|
||
</Teleport>
|
||
</template>
|
||
</div>
|
||
</template>
|
||
|
||
<script setup>
|
||
import { computed, nextTick, onBeforeUnmount, onMounted, reactive, ref, watch, onUnmounted } from 'vue'
|
||
import LoginView from './components/LoginView.vue'
|
||
import PosterCard from './components/PosterCard.vue'
|
||
import RecordDialog from './components/RecordDialog.vue'
|
||
import FolderDialog from './components/FolderDialog.vue'
|
||
import FolderTree from './components/FolderTree.vue'
|
||
import SimpleDialog from './components/SimpleDialog.vue'
|
||
import UserManageDialog from './components/UserManageDialog.vue'
|
||
import DriveManageDialog from './components/DriveManageDialog.vue'
|
||
import ConfirmDialog from './components/ConfirmDialog.vue'
|
||
import IconManageDialog from './components/IconManageDialog.vue'
|
||
import SettingsView from './components/SettingsView.vue'
|
||
import {
|
||
batchScrapeAll,
|
||
batchScrapeMovies,
|
||
createDrive,
|
||
createFolderInDrive,
|
||
createMovieInDrive,
|
||
deleteDrive,
|
||
deleteFolderInDrive,
|
||
deleteMovieInDrive,
|
||
deleteRecord,
|
||
exportData,
|
||
fetchAllMovies,
|
||
fetchBatchScrapeStatus,
|
||
fetchDashboardData,
|
||
fetchDriveList,
|
||
fetchFolderMovies,
|
||
fetchFolders,
|
||
fetchIconList,
|
||
fetchLoginLogs,
|
||
fetchMovieDuplicates,
|
||
fetchRecommendMovies,
|
||
fetchScrapeStatus,
|
||
fetchScraperLogs,
|
||
fetchTmdbConfig,
|
||
requestState,
|
||
resetData,
|
||
saveTmdbConfig as apiSaveTmdbConfig,
|
||
scrapeMovie,
|
||
updateDrive,
|
||
updateFolderInDrive,
|
||
updateMovieInDrive,
|
||
} from './api'
|
||
import { attachSheetName, buildTree, createLoginLog, createRecord, filterRows, flattenRows, mapMovieToRecord } from './utils/data'
|
||
import {
|
||
AUTH_SESSION_STORAGE_KEY,
|
||
AUTH_STORAGE_KEY,
|
||
DASHBOARD_STORAGE_KEY,
|
||
readStorage,
|
||
removeStorage,
|
||
writeStorage,
|
||
} from './utils/storage'
|
||
|
||
const authSession = readStorage(AUTH_SESSION_STORAGE_KEY, null)
|
||
const isAuthenticated = ref(Boolean(authSession?.token || readStorage(AUTH_STORAGE_KEY, false)))
|
||
const authNotice = ref('')
|
||
const forbiddenNotice = ref('')
|
||
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)
|
||
const searchKeyword = ref('')
|
||
const isSearchFocused = ref(false)
|
||
const hasSearched = ref(false)
|
||
const currentView = ref('poster')
|
||
const isDarkMode = ref(false) // 黑夜模式开关
|
||
const activeSheetId = ref('')
|
||
const showRecordDialog = ref(false)
|
||
const showFolderDialog = ref(false)
|
||
const editingFolder = ref(null) // 编辑时传入的文件夹对象,null 表示新建
|
||
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)
|
||
const confirmConfig = reactive({
|
||
title: '确认操作',
|
||
message: '',
|
||
hint: '',
|
||
icon: '⚠️',
|
||
confirmText: '确认',
|
||
cancelText: '取消',
|
||
danger: false,
|
||
onConfirm: () => {},
|
||
})
|
||
const showLogDialog = ref(false)
|
||
const loadingLogs = ref(false)
|
||
const logFilter = reactive({
|
||
username: '', // 用户名筛选
|
||
result: '', // 结果筛选:success/fail(留空表示全部)
|
||
startDate: '', // 开始日期
|
||
endDate: '', // 结束日期
|
||
})
|
||
// 登录日志分页参数
|
||
const logPagination = reactive({
|
||
pageNo: 1,
|
||
pageSize: 20, // 默认每页20条
|
||
total: 0, // 总记录数
|
||
hasMore: true, // 是否还有更多数据
|
||
})
|
||
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)
|
||
const userMenuRef = ref(null)
|
||
const showQuickAccessPanel = ref(false)
|
||
const quickAccessWrapperRef = ref(null)
|
||
let quickAccessTimer = null // 悬浮延迟定时器(显示面板)
|
||
let quickAccessCloseTimer = null // 面板关闭延迟定时器
|
||
const editingRecord = ref(null)
|
||
const featuredPosterIndex = ref(0)
|
||
|
||
// 网盘标签页悬浮提示状态
|
||
const activeTabTooltipId = ref(null)
|
||
const tabTooltipPos = ref({ top: 0, left: 0 })
|
||
let tabTooltipTimer = null
|
||
|
||
// 当前悬浮的网盘数据(用于显示提示框内容)
|
||
const activeTabTooltipSheet = computed(() =>
|
||
displaySheets.value.find(s => s.id === activeTabTooltipId.value) || null
|
||
)
|
||
|
||
// 提示框定位样式(position: fixed)
|
||
const tabTooltipStyle = computed(() => ({
|
||
position: 'fixed',
|
||
top: `${tabTooltipPos.value.top}px`,
|
||
left: `${tabTooltipPos.value.left}px`,
|
||
transform: 'translateX(-50%)',
|
||
zIndex: 99999,
|
||
}))
|
||
|
||
function showTabTooltip(sheetId, event) {
|
||
if (tabTooltipTimer) clearTimeout(tabTooltipTimer)
|
||
// 立即获取元素位置(在 mouseenter 时 event.currentTarget 可用)
|
||
const el = event.currentTarget
|
||
const rect = el.getBoundingClientRect()
|
||
tabTooltipPos.value = {
|
||
top: rect.bottom + 10,
|
||
left: rect.left + rect.width / 2,
|
||
}
|
||
tabTooltipTimer = setTimeout(() => {
|
||
activeTabTooltipId.value = sheetId
|
||
}, 200)
|
||
}
|
||
|
||
function hideTabTooltip() {
|
||
if (tabTooltipTimer) {
|
||
clearTimeout(tabTooltipTimer)
|
||
tabTooltipTimer = null
|
||
}
|
||
activeTabTooltipId.value = null
|
||
}
|
||
const featuredAutoplayDelay = 5000
|
||
let featuredAutoplayTimer = 0
|
||
const successMessage = ref('')
|
||
const errorMessage = ref('')
|
||
|
||
// 每日推荐电影数据(从后端接口获取)
|
||
const recommendMovies = ref([])
|
||
const loadingRecommend = ref(false)
|
||
|
||
// 海报图片加载状态
|
||
const heroImageLoaded = ref(false)
|
||
let heroImagePreloadTimer = null
|
||
|
||
// 当前用户信息
|
||
const currentUser = reactive({
|
||
username: authSession?.username || '',
|
||
userId: authSession?.userId || null,
|
||
avatar: authSession?.avatar || '',
|
||
userType: authSession?.userType || 0, // 0: 普通用户, 1: VIP, 2: 管理员
|
||
email: authSession?.email || '',
|
||
phone: authSession?.phone || '',
|
||
realName: authSession?.realName || '',
|
||
hasTmdbKey: authSession?.hasTmdbKey || false,
|
||
})
|
||
|
||
const tmdbConfig = reactive({
|
||
apiKey: '',
|
||
hasKey: false,
|
||
})
|
||
|
||
const displaySheets = computed(() => attachSheetName(rawData.value?.sheets || []))
|
||
const activeSheet = computed(() => displaySheets.value.find((s) => s.id === activeSheetId.value) || displaySheets.value[0])
|
||
const allRows = computed(() => activeSheet.value?.rows || [])
|
||
const totalRows = computed(() => allRows.value.length)
|
||
const filteredRows = computed(() => filterRows(allRows.value, searchKeyword.value))
|
||
const duplicateItems = ref([])
|
||
|
||
// 文件夹树数据(将当前 sheet 的数据转换为树形结构)
|
||
const folderTreeData = computed(() => {
|
||
// 如果已搜索,使用过滤后的数据
|
||
const rows = hasSearched.value ? filteredRows.value : (allRows.value || [])
|
||
return buildTree(rows)
|
||
})
|
||
|
||
// 海报展示数据(从后端获取,支持排序和分页)
|
||
const posterShowcaseData = ref([]) // 从后端获取的海报展示数据
|
||
const loadingPosterShowcase = ref(false) // 海报展示加载状态
|
||
|
||
// 过滤后的海报展示数据(用于搜索)
|
||
const filteredPosterShowcaseData = computed(() => {
|
||
if (!searchKeyword.value.trim()) {
|
||
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 logIntegrity = computed(() => loginLogs.value.length > 0)
|
||
|
||
// 每日推荐海报池(优先使用后端返回的推荐电影数据)
|
||
const posterPool = computed(() => {
|
||
// 如果有后端返回的推荐电影数据,使用它
|
||
if (recommendMovies.value && recommendMovies.value.length > 0) {
|
||
return recommendMovies.value
|
||
}
|
||
// 否则使用降级默认数据(包含 posterUrl 字段以确保 buildHeroStyle 能正确判断)
|
||
return [{
|
||
id: 'default-poster',
|
||
title: '每日推荐',
|
||
subtitle: '暂无新片上映',
|
||
description: '当前没有可推荐的海报,请稍后再试。',
|
||
posterUrl: '', // 空字符串会触发 buildHeroStyle 显示默认海报
|
||
status: '正常',
|
||
}]
|
||
})
|
||
|
||
// 精选海报(从 posterPool 中取前 3 条)
|
||
const featuredPosters = computed(() => {
|
||
const rows = posterPool.value
|
||
// 如果只有默认数据(1条),直接返回
|
||
if (rows.length <= 1) {
|
||
return rows
|
||
}
|
||
// 从后端数据中取前 3 条
|
||
return rows.slice(0, 3)
|
||
})
|
||
const activeFeaturedPoster = computed(() => featuredPosters.value[featuredPosterIndex.value] || featuredPosters.value[0] || createRecord())
|
||
|
||
// 判断是否有有效的海报数据
|
||
const hasValidFeaturedPosters = computed(() => {
|
||
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 包含 wide、horizontal、landscape 等关键字
|
||
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) {
|
||
// 返回空对象,因为图片现在由 <img> 标签处理
|
||
return {}
|
||
}
|
||
|
||
function buildSubThumbStyle(poster) {
|
||
// 当没有有效海报数据时,使用默认海报
|
||
if (!poster || poster.trim() === '') {
|
||
return {
|
||
backgroundImage: `linear-gradient(180deg, rgba(8, 15, 29, 0.3), rgba(8, 15, 29, 0.7)), url('/暂无新片上映海报生成.png')`,
|
||
backgroundSize: 'cover',
|
||
backgroundPosition: 'center',
|
||
}
|
||
}
|
||
return {
|
||
backgroundImage: `linear-gradient(180deg, rgba(8, 15, 29, 0.3), rgba(8, 15, 29, 0.7)), url(${poster})`,
|
||
backgroundSize: 'cover',
|
||
backgroundPosition: 'center',
|
||
}
|
||
}
|
||
|
||
function showPrevFeaturedPoster() {
|
||
if (!featuredPosters.value.length) return
|
||
featuredPosterIndex.value = (featuredPosterIndex.value - 1 + featuredPosters.value.length) % featuredPosters.value.length
|
||
restartFeaturedAutoplay()
|
||
}
|
||
|
||
function showNextFeaturedPoster() {
|
||
if (!featuredPosters.value.length) return
|
||
featuredPosterIndex.value = (featuredPosterIndex.value + 1) % featuredPosters.value.length
|
||
restartFeaturedAutoplay()
|
||
}
|
||
|
||
function setFeaturedPoster(index) {
|
||
featuredPosterIndex.value = index
|
||
restartFeaturedAutoplay()
|
||
}
|
||
|
||
function pauseFeaturedAutoplay() {
|
||
window.clearInterval(featuredAutoplayTimer)
|
||
}
|
||
|
||
function resumeFeaturedAutoplay() {
|
||
if (featuredPosters.value.length <= 1) return
|
||
window.clearInterval(featuredAutoplayTimer)
|
||
featuredAutoplayTimer = window.setInterval(() => {
|
||
featuredPosterIndex.value = (featuredPosterIndex.value + 1) % featuredPosters.value.length
|
||
}, featuredAutoplayDelay)
|
||
}
|
||
|
||
function restartFeaturedAutoplay() {
|
||
pauseFeaturedAutoplay()
|
||
resumeFeaturedAutoplay()
|
||
}
|
||
|
||
function setSuccessMessage(message) {
|
||
successMessage.value = message
|
||
window.clearTimeout(setSuccessMessage.timer)
|
||
setSuccessMessage.timer = window.setTimeout(() => {
|
||
successMessage.value = ''
|
||
}, 1500)
|
||
}
|
||
setSuccessMessage.timer = 0
|
||
|
||
function setErrorMessage(message) {
|
||
errorMessage.value = message
|
||
window.clearTimeout(setErrorMessage.timer)
|
||
setErrorMessage.timer = window.setTimeout(() => {
|
||
errorMessage.value = ''
|
||
}, 1500)
|
||
}
|
||
setErrorMessage.timer = 0
|
||
|
||
function setForbiddenNotice(message) {
|
||
forbiddenNotice.value = message
|
||
window.clearTimeout(setForbiddenNotice.timer)
|
||
setForbiddenNotice.timer = window.setTimeout(() => {
|
||
forbiddenNotice.value = ''
|
||
}, 3200)
|
||
}
|
||
setForbiddenNotice.timer = 0
|
||
|
||
function setSessionWarning(message) {
|
||
sessionWarning.value = message
|
||
window.clearTimeout(setSessionWarning.timer)
|
||
setSessionWarning.timer = window.setTimeout(() => {
|
||
sessionWarning.value = ''
|
||
}, 4200)
|
||
}
|
||
setSessionWarning.timer = 0
|
||
|
||
function syncActiveSheet() {
|
||
if (!displaySheets.value.length) {
|
||
activeSheetId.value = ''
|
||
return
|
||
}
|
||
const exists = displaySheets.value.some((sheet) => sheet.id === activeSheetId.value)
|
||
if (!exists) {
|
||
activeSheetId.value = displaySheets.value[0].id
|
||
}
|
||
}
|
||
|
||
async function loadDashboard() {
|
||
isLoading.value = true
|
||
try {
|
||
// 先从后端加载网盘列表(优先使用后端数据)
|
||
await Promise.all([
|
||
loadDriveList(),
|
||
loadIconList(),
|
||
])
|
||
|
||
// 加载 TMDB 配置
|
||
syncTmdbConfigFromServer()
|
||
syncActiveSheet()
|
||
|
||
// 如果在管理视图,加载当前网盘的文件夹
|
||
if (currentView.value === 'manage' && activeSheetId.value) {
|
||
await loadDriveFolders(activeSheetId.value)
|
||
}
|
||
} catch (err) {
|
||
console.error('加载数据失败', err)
|
||
// 直接显示后端错误,不使用降级数据
|
||
setErrorMessage(err?.message || '加载数据失败,请检查网络连接')
|
||
// 初始化为空数据
|
||
rawData.value = { sheets: [] }
|
||
syncActiveSheet()
|
||
} finally {
|
||
isLoading.value = false
|
||
// 数据加载完成后隐藏全局遮罩层
|
||
isInitialLoading.value = false
|
||
}
|
||
}
|
||
|
||
// 加载网盘列表
|
||
async function loadDriveList() {
|
||
try {
|
||
const result = await fetchDriveList({ pageNo: 1, pageSize: 100 })
|
||
|
||
if (result?.code === 200 && result?.data) {
|
||
// 后端返回的网盘列表格式可能是 { list: [...] } 或直接是数组
|
||
const driveList = Array.isArray(result.data) ? result.data : (result.data.list || [])
|
||
|
||
// 将后端返回的网盘数据转换为前端需要的格式
|
||
// 兼容后端返回 logoData / icon / logo / logoUrl 等字段名
|
||
const sheets = driveList.map(drive => ({
|
||
id: drive.id,
|
||
name: drive.name,
|
||
description: drive.description || drive.desc || '',
|
||
icon: drive.logoData || drive.icon || drive.logo || drive.logoUrl || '',
|
||
rows: [], // 网盘下的文件列表,后续需要从其他接口获取
|
||
}))
|
||
|
||
// 初始化 rawData(如果还没有)
|
||
if (!rawData.value) {
|
||
rawData.value = { sheets: [] }
|
||
}
|
||
|
||
// 更新 sheets 数据
|
||
rawData.value.sheets = sheets
|
||
|
||
console.log(`成功加载 ${sheets.length} 个网盘`)
|
||
|
||
// 同步激活的网盘 ID
|
||
syncActiveSheet()
|
||
} else {
|
||
console.warn('网盘列表返回数据异常', result)
|
||
throw new Error(result?.message || '获取网盘列表失败')
|
||
}
|
||
} catch (err) {
|
||
console.error('加载网盘列表失败', err)
|
||
throw err // 向上抛出错误,让 loadDashboard 处理降级逻辑
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 加载当前网盘下的所有文件夹(从后端,递归加载子文件夹)
|
||
* 加载后更新对应 sheet 的 rows,保持树形显示最新
|
||
*/
|
||
async function loadDriveFolders(driveId) {
|
||
if (!driveId) return
|
||
try {
|
||
const rootItems = await fetchFolderLevelItems(driveId, 0)
|
||
|
||
if (!rawData.value) rawData.value = { sheets: [] }
|
||
const targetSheet = rawData.value.sheets.find(s => s.id === driveId)
|
||
if (targetSheet) {
|
||
targetSheet.rows = rootItems
|
||
}
|
||
|
||
console.log(`成功加载网盘[${driveId}]根目录,共 ${rootItems.length} 条`)
|
||
} catch (err) {
|
||
console.error('加载文件夹列表失败', err)
|
||
}
|
||
}
|
||
|
||
async function loadDuplicateItems() {
|
||
try {
|
||
const res = await fetchMovieDuplicates()
|
||
const groups = res?.data?.duplicateGroups || []
|
||
duplicateItems.value = groups.map((group, index) => ({
|
||
id: `duplicate-${group.title || index}-${index}`,
|
||
title: group.title || '未命名电影',
|
||
locations: Array.isArray(group.movies)
|
||
? group.movies
|
||
.map((movie) => movie?.folderPath || '未提供路径')
|
||
.filter(Boolean)
|
||
: [],
|
||
}))
|
||
} catch (err) {
|
||
console.error('加载重复内容失败', err)
|
||
duplicateItems.value = []
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 加载某个文件夹层级下的直接子文件夹和电影文件,并批量获取刮削状态
|
||
*/
|
||
async function fetchFolderLevelItems(driveId, folderId) {
|
||
const targetSheet = rawData.value?.sheets?.find((sheet) => sheet.id === driveId)
|
||
const sheetName = targetSheet?.name || activeSheet.value?.name || ''
|
||
const [foldersRes, moviesRes] = await Promise.all([
|
||
fetchFolders(driveId, { parentId: folderId }),
|
||
fetchFolderMovies(driveId, folderId, { pageNo: 1, pageSize: 100 }),
|
||
])
|
||
|
||
const folderList = normalizeListData(foldersRes?.data)
|
||
const movieList = normalizeListData(moviesRes?.data)
|
||
|
||
const folderItems = folderList.map((folder) => ({
|
||
id: folder.id,
|
||
title: folder.name || folder.title || '',
|
||
parentId: folderId || null, // 强制使用请求的 folderId 作为 parentId,确保子项正确连接到当前文件夹下
|
||
description: folder.description || '',
|
||
sortWeight: folder.sortWeight ?? 0,
|
||
type: 'folder',
|
||
createdAt: folder.createdAt || folder.createTime || '',
|
||
sheetName,
|
||
childrenLoaded: false,
|
||
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]
|
||
}
|
||
|
||
function normalizeListData(data) {
|
||
if (Array.isArray(data)) return data
|
||
return data?.list || data?.records || []
|
||
}
|
||
|
||
function collectDescendantIds(rows, parentId) {
|
||
const descendantIds = new Set()
|
||
const queue = [parentId]
|
||
|
||
while (queue.length) {
|
||
const currentParentId = queue.shift()
|
||
const children = rows.filter((item) => item.parentId === currentParentId)
|
||
children.forEach((child) => {
|
||
if (!descendantIds.has(child.id)) {
|
||
descendantIds.add(child.id)
|
||
if (child.type === 'folder') {
|
||
queue.push(child.id)
|
||
}
|
||
}
|
||
})
|
||
}
|
||
|
||
return descendantIds
|
||
}
|
||
|
||
function setFolderLoadingState(driveId, folderId, isLoading) {
|
||
const targetSheet = rawData.value?.sheets?.find((sheet) => sheet.id === driveId)
|
||
if (!targetSheet || !Array.isArray(targetSheet.rows)) return
|
||
|
||
targetSheet.rows = targetSheet.rows.map((item) =>
|
||
item.id === folderId && item.type === 'folder'
|
||
? { ...item, isLoading }
|
||
: item,
|
||
)
|
||
}
|
||
|
||
async function handleExpandFolder(node) {
|
||
if (!node || node.type !== 'folder' || node.childrenLoaded || node.isLoading) return
|
||
|
||
const driveId = activeSheetId.value
|
||
if (!driveId) return
|
||
|
||
// 保存当前滚动位置
|
||
const scrollTop = folderTreeContainerRef.value?.scrollTop || 0
|
||
|
||
setFolderLoadingState(driveId, node.id, true)
|
||
|
||
try {
|
||
const childrenItems = await fetchFolderLevelItems(driveId, node.id)
|
||
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))
|
||
|
||
targetSheet.rows = [
|
||
...remainingRows.map((item) =>
|
||
item.id === node.id && item.type === 'folder'
|
||
? { ...item, isLoading: false, childrenLoaded: true }
|
||
: item,
|
||
),
|
||
...childrenItems,
|
||
]
|
||
|
||
// 恢复滚动位置(等待 DOM 更新后)
|
||
await nextTick()
|
||
if (folderTreeContainerRef.value) {
|
||
folderTreeContainerRef.value.scrollTop = scrollTop
|
||
}
|
||
} catch (err) {
|
||
setFolderLoadingState(driveId, node.id, false)
|
||
console.error(`加载文件夹[${node.title}]内容失败`, err)
|
||
setErrorMessage(err?.payload?.message || err?.message || '加载文件夹内容失败')
|
||
}
|
||
}
|
||
|
||
function persistDashboard(message = '已自动保存本地数据') {
|
||
if (!rawData.value) return
|
||
writeStorage(DASHBOARD_STORAGE_KEY, rawData.value)
|
||
setSuccessMessage(message)
|
||
}
|
||
|
||
function clearAuthState(notice = '') {
|
||
isAuthenticated.value = false
|
||
authNotice.value = notice
|
||
forbiddenNotice.value = ''
|
||
sessionWarning.value = ''
|
||
searchKeyword.value = ''
|
||
writeStorage(AUTH_STORAGE_KEY, false)
|
||
removeStorage(AUTH_SESSION_STORAGE_KEY)
|
||
}
|
||
|
||
function clearAuthNotice() {
|
||
authNotice.value = ''
|
||
}
|
||
|
||
function getSessionExpiresAt(expireIn) {
|
||
const seconds = Number(expireIn || 0)
|
||
if (!seconds || Number.isNaN(seconds) || seconds <= 0) return null
|
||
return Date.now() + seconds * 1000
|
||
}
|
||
|
||
function handleUnauthorized(event) {
|
||
clearAuthState(event?.detail?.reason || '登录状态已失效,请重新登录')
|
||
}
|
||
|
||
function handleForbidden(event) {
|
||
setForbiddenNotice(event?.detail?.reason || '当前账号无权执行此操作')
|
||
}
|
||
|
||
function handleSessionExpiring(event) {
|
||
setSessionWarning(event?.detail?.reason || '登录状态将在 10 分钟内过期,请注意及时重新登录')
|
||
}
|
||
|
||
function handleLoginSuccess(payload) {
|
||
// 适配新的后端响应结构:token等字段在 data 对象内
|
||
const userData = payload?.data || payload
|
||
|
||
const session = {
|
||
token: userData?.token || '',
|
||
tokenType: userData?.tokenType || 'Bearer',
|
||
expireIn: userData?.expireIn || 0,
|
||
expiresAt: getSessionExpiresAt(userData?.expireIn),
|
||
username: userData?.username || '',
|
||
userId: userData?.id || null,
|
||
avatar: userData?.avatarUrl || '',
|
||
userType: userData?.userType || 0, // 0: 普通用户, 1: VIP, 2: 管理员
|
||
email: userData?.email || '',
|
||
phone: userData?.phone || '',
|
||
realName: userData?.realName || '',
|
||
hasTmdbKey: userData?.hasTmdbKey || false,
|
||
}
|
||
|
||
authNotice.value = ''
|
||
isAuthenticated.value = Boolean(session.token)
|
||
writeStorage(AUTH_STORAGE_KEY, isAuthenticated.value)
|
||
writeStorage(AUTH_SESSION_STORAGE_KEY, session)
|
||
|
||
// 更新当前用户信息
|
||
currentUser.username = session.username
|
||
currentUser.userId = session.userId
|
||
currentUser.avatar = session.avatar
|
||
currentUser.userType = session.userType
|
||
|
||
syncTmdbConfigFromServer()
|
||
const currentLogs = rawData.value?.loginLogs || []
|
||
if (rawData.value) {
|
||
rawData.value.loginLogs = [createLoginLog(navigator.userAgent, location.hostname || '127.0.0.1'), ...currentLogs]
|
||
persistDashboard('登录成功,已记录日志')
|
||
}
|
||
}
|
||
|
||
function handleLogout() {
|
||
showUserMenu.value = false
|
||
clearAuthState('')
|
||
}
|
||
|
||
function handleHeroSearch() {
|
||
hasSearched.value = true
|
||
if (!searchKeyword.value.trim()) {
|
||
setErrorMessage('请输入搜索关键词')
|
||
hasSearched.value = false
|
||
return
|
||
}
|
||
setSuccessMessage(`已搜索:${searchKeyword.value}`)
|
||
}
|
||
|
||
/**
|
||
* 用户管理操作成功回调
|
||
* @param {string} message - 成功消息
|
||
*/
|
||
function handleUserManageSuccess(message = '操作成功') {
|
||
setSuccessMessage(message)
|
||
}
|
||
|
||
/**
|
||
* 网盘管理操作成功回调
|
||
* @param {string} message - 成功提示消息
|
||
*/
|
||
function handleDriveManageSuccess(message = '操作成功') {
|
||
setSuccessMessage(message)
|
||
}
|
||
|
||
/**
|
||
* 网盘列表刷新回调
|
||
* 当在网盘管理对话框中增删改网盘后,从后端重新加载最新的网盘列表数据
|
||
*/
|
||
async function handleDriveListRefresh() {
|
||
try {
|
||
await loadDriveList()
|
||
} catch (err) {
|
||
console.error('刷新网盘列表失败', err)
|
||
setErrorMessage('刷新网盘列表失败')
|
||
}
|
||
}
|
||
|
||
// 打开登录日志对话框并加载最新20条
|
||
async function handleOpenLogDialog() {
|
||
showLogDialog.value = true
|
||
|
||
// 重置分页参数
|
||
logPagination.pageNo = 1
|
||
logPagination.pageSize = 20
|
||
logPagination.total = 0
|
||
logPagination.hasMore = true
|
||
|
||
// 清空之前的日志
|
||
rawData.value.loginLogs = []
|
||
|
||
// 加载第一页数据
|
||
await loadMoreLogs()
|
||
}
|
||
|
||
// 加载更多日志(分页加载)
|
||
async function loadMoreLogs() {
|
||
if (loadingLogs.value || !logPagination.hasMore) return
|
||
|
||
loadingLogs.value = true
|
||
|
||
try {
|
||
// 从后端加载日志
|
||
const params = {
|
||
pageNo: logPagination.pageNo,
|
||
pageSize: logPagination.pageSize
|
||
}
|
||
|
||
// 只有管理员(userType=2)才能查询所有日志
|
||
// 普通用户只能查看自己的日志
|
||
if (currentUser.userType !== 2) {
|
||
params.userId = currentUser.userId
|
||
}
|
||
|
||
// 添加筛选条件
|
||
if (logFilter.username.trim()) {
|
||
params.username = logFilter.username.trim()
|
||
}
|
||
// result字段:success=成功, fail=失败, 留空=全部
|
||
if (logFilter.result) {
|
||
params.result = logFilter.result
|
||
}
|
||
if (logFilter.startDate) {
|
||
params.startDate = logFilter.startDate
|
||
}
|
||
if (logFilter.endDate) {
|
||
params.endDate = logFilter.endDate
|
||
}
|
||
|
||
const logsRes = await fetchLoginLogs(params)
|
||
|
||
if (Array.isArray(logsRes?.data?.list)) {
|
||
const newLogs = logsRes.data.list
|
||
|
||
// 追加到现有日志列表
|
||
if (rawData.value?.loginLogs) {
|
||
rawData.value.loginLogs.push(...newLogs)
|
||
} else {
|
||
rawData.value.loginLogs = newLogs
|
||
}
|
||
|
||
// 更新分页信息
|
||
logPagination.total = logsRes.data.total || 0
|
||
logPagination.hasMore = rawData.value.loginLogs.length < logPagination.total
|
||
|
||
console.log(`成功加载第 ${logPagination.pageNo} 页,共 ${newLogs.length} 条日志,总计 ${logPagination.total} 条`)
|
||
} else {
|
||
console.warn('登录日志返回数据异常', logsRes)
|
||
logPagination.hasMore = false
|
||
}
|
||
} catch (err) {
|
||
console.error('加载登录日志失败', err)
|
||
setErrorMessage(err?.message || '加载登录日志失败')
|
||
logPagination.hasMore = false
|
||
} finally {
|
||
loadingLogs.value = false
|
||
}
|
||
}
|
||
|
||
// 应用筛选条件(重新从后端搜索)
|
||
async function applyLogFilter() {
|
||
// 重置分页
|
||
logPagination.pageNo = 1
|
||
logPagination.hasMore = true
|
||
|
||
// 清空当前日志
|
||
rawData.value.loginLogs = []
|
||
|
||
// 重新加载
|
||
await loadMoreLogs()
|
||
}
|
||
|
||
// 处理加载更多按钮点击
|
||
function handleLoadMoreLogs() {
|
||
if (!logPagination.hasMore || loadingLogs.value) return
|
||
|
||
// 页码+1
|
||
logPagination.pageNo++
|
||
|
||
// 加载下一页
|
||
loadMoreLogs()
|
||
}
|
||
|
||
// 重置登录日志筛选条件
|
||
function resetLogFilter() {
|
||
logFilter.username = ''
|
||
logFilter.result = ''
|
||
logFilter.startDate = ''
|
||
logFilter.endDate = ''
|
||
|
||
// 重新加载数据
|
||
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)
|
||
|
||
console.log('[刮削日志] 后端返回原始数据:', logsRes)
|
||
|
||
if (logsRes?.code === 200 && Array.isArray(logsRes?.data?.list)) {
|
||
const newLogs = logsRes.data.list
|
||
|
||
console.log('[刮削日志] 日志列表数据:', newLogs)
|
||
console.log('[刮削日志] 第一条日志的字段:', Object.keys(newLogs[0] || {}))
|
||
if (newLogs[0]) {
|
||
console.log('[刮削日志] 第一条日志完整数据:', JSON.stringify(newLogs[0], null, 2))
|
||
}
|
||
|
||
// 标准化日志数据字段(兼容不同的字段名)
|
||
const normalizedLogs = newLogs.map(log => {
|
||
// 解析 detail_json 字符串为数组
|
||
let detailList = []
|
||
if (log.detail_json) {
|
||
try {
|
||
detailList = typeof log.detail_json === 'string'
|
||
? JSON.parse(log.detail_json)
|
||
: log.detail_json
|
||
} catch (e) {
|
||
console.warn('解析 detail_json 失败:', e)
|
||
}
|
||
}
|
||
|
||
return {
|
||
...log,
|
||
// 确保 showDetail 属性可设置
|
||
showDetail: false,
|
||
// 后端字段映射(使用实际的后端字段名)
|
||
totalMovies: log.total_processed ?? log.totalMovies ?? log.total ?? 0,
|
||
successCount: log.total_success ?? log.successCount ?? log.success ?? 0,
|
||
failCount: log.total_failed ?? log.failCount ?? log.failed ?? 0,
|
||
totalSkipped: log.total_skipped ?? 0,
|
||
scanTime: log.start_time ?? log.create_time ?? log.createTime ?? '-',
|
||
endTime: log.end_time ?? '',
|
||
durationMs: log.duration_ms ?? 0,
|
||
status: log.status ?? 'unknown',
|
||
triggerType: log.trigger_type ?? 'manual',
|
||
// 解析后的详细信息列表
|
||
detailList: detailList,
|
||
}
|
||
})
|
||
|
||
// 追加到现有日志列表
|
||
scraperLogs.value.push(...normalizedLogs)
|
||
|
||
// 更新分页信息
|
||
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()
|
||
}
|
||
|
||
// 切换日志详情展开/收起
|
||
function toggleLogDetail(logId) {
|
||
const log = scraperLogs.value.find(l => l.id === logId)
|
||
if (log) {
|
||
log.showDetail = !log.showDetail
|
||
}
|
||
}
|
||
|
||
// TMDB 配置
|
||
function handleTmdbConfig() {
|
||
showUserMenu.value = false
|
||
showTmdbDialog.value = true
|
||
}
|
||
|
||
// 设置页面
|
||
const showSettingsView = ref(false)
|
||
|
||
function handleSettings() {
|
||
showUserMenu.value = false
|
||
showSettingsView.value = true
|
||
}
|
||
|
||
// 修改头像
|
||
function handleChangeAvatar() {
|
||
showUserMenu.value = false
|
||
const input = document.createElement('input')
|
||
input.type = 'file'
|
||
input.accept = 'image/*'
|
||
input.onchange = (e) => {
|
||
const file = e.target.files[0]
|
||
if (!file) return
|
||
|
||
// 这里可以上传到后端,目前仅本地预览
|
||
const reader = new FileReader()
|
||
reader.onload = (event) => {
|
||
currentUser.avatar = event.target.result
|
||
// 更新 storage
|
||
const session = readStorage(AUTH_SESSION_STORAGE_KEY, {})
|
||
session.avatar = currentUser.avatar
|
||
writeStorage(AUTH_SESSION_STORAGE_KEY, session)
|
||
setSuccessMessage('头像已更新')
|
||
}
|
||
reader.readAsDataURL(file)
|
||
}
|
||
input.click()
|
||
}
|
||
|
||
// 点击外部关闭菜单
|
||
function handleClickOutside(event) {
|
||
if (userMenuRef.value && !userMenuRef.value.contains(event.target)) {
|
||
showUserMenu.value = false
|
||
}
|
||
// 点击外部关闭快捷入口面板
|
||
if (quickAccessWrapperRef.value && !quickAccessWrapperRef.value.contains(event.target)) {
|
||
showQuickAccessPanel.value = false
|
||
clearQuickAccessTimer()
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 切换快捷入口面板显示/隐藏
|
||
*/
|
||
function toggleQuickAccessPanel() {
|
||
showQuickAccessPanel.value = !showQuickAccessPanel.value
|
||
clearQuickAccessTimers()
|
||
}
|
||
|
||
/**
|
||
* 鼠标悬浮进入快捷入口区域 - 1秒后显示面板
|
||
*/
|
||
function handleQuickAccessEnter() {
|
||
clearQuickAccessTimers()
|
||
quickAccessTimer = window.setTimeout(() => {
|
||
showQuickAccessPanel.value = true
|
||
}, 1000)
|
||
}
|
||
|
||
/**
|
||
* 鼠标悬浮进入面板 - 清除关闭定时器,保持面板显示
|
||
*/
|
||
function handleQuickAccessPanelEnter() {
|
||
clearQuickAccessCloseTimer()
|
||
}
|
||
|
||
/**
|
||
* 鼠标悬浮离开面板 - 延迟关闭面板
|
||
*/
|
||
function handleQuickAccessPanelLeave() {
|
||
quickAccessCloseTimer = window.setTimeout(() => {
|
||
showQuickAccessPanel.value = false
|
||
}, 2000) // 2秒后关闭
|
||
}
|
||
|
||
/**
|
||
* 鼠标离开整个包装器(按钮+面板)- 延迟关闭面板
|
||
*/
|
||
function handleQuickAccessWrapperLeave() {
|
||
// 只有当面板正在显示时才启动关闭定时器
|
||
if (showQuickAccessPanel.value) {
|
||
quickAccessCloseTimer = window.setTimeout(() => {
|
||
showQuickAccessPanel.value = false
|
||
}, 2000) // 2秒后关闭
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 清除快捷入口定时器(显示和关闭)
|
||
*/
|
||
function clearQuickAccessTimers() {
|
||
clearQuickAccessTimer()
|
||
clearQuickAccessCloseTimer()
|
||
}
|
||
|
||
/**
|
||
* 清除快捷入口关闭定时器
|
||
*/
|
||
function clearQuickAccessCloseTimer() {
|
||
if (quickAccessCloseTimer) {
|
||
window.clearTimeout(quickAccessCloseTimer)
|
||
quickAccessCloseTimer = null
|
||
}
|
||
}
|
||
|
||
function openAddRecord() {
|
||
editingRecord.value = null
|
||
showRecordDialog.value = true
|
||
}
|
||
|
||
function openEditRecord(item) {
|
||
if (item.type === 'folder') {
|
||
// 文件夹:弹出编辑对话框
|
||
editingFolder.value = item
|
||
showFolderDialog.value = true
|
||
return
|
||
}
|
||
|
||
// 文件:打开编辑对话框
|
||
editingRecord.value = {
|
||
id: item.id,
|
||
title: item.title || '',
|
||
originalName: item.originalName || '',
|
||
description: item.description || '',
|
||
category: item.category || '',
|
||
directors: item.directors || '',
|
||
casts: item.casts || '',
|
||
updatedAt: item.updatedAt || '',
|
||
poster: item.poster || '',
|
||
tags: [...(item.tags || [])],
|
||
score: item.score || 0,
|
||
sheetId: item.driveId || displaySheets.value.find((sheet) => sheet.name === item.sheetName)?.id || activeSheetId.value,
|
||
parentId: item.parentId || null,
|
||
type: item.type || 'file',
|
||
}
|
||
showRecordDialog.value = true
|
||
}
|
||
|
||
// 打开文件夹对话框(新建)
|
||
function handleAddFolder(parentId) {
|
||
if (!displaySheets.value || displaySheets.value.length === 0) {
|
||
setErrorMessage('请先创建网盘,再创建文件夹')
|
||
return
|
||
}
|
||
if (!activeSheet.value) {
|
||
setErrorMessage('请先选择一个网盘')
|
||
return
|
||
}
|
||
editingFolder.value = null
|
||
folderDialogParentId.value = parentId || 0
|
||
showFolderDialog.value = true
|
||
}
|
||
|
||
function closeFolderDialog() {
|
||
showFolderDialog.value = false
|
||
editingFolder.value = null
|
||
}
|
||
|
||
// 文件夹对话框保存(新建 or 编辑)
|
||
async function handleFolderDialogSave(formData) {
|
||
const isEdit = !!editingFolder.value
|
||
driveLoading.value = true
|
||
try {
|
||
let result
|
||
if (isEdit) {
|
||
result = await updateFolderInDrive(activeSheetId.value, editingFolder.value.id, {
|
||
name: formData.name,
|
||
description: formData.description,
|
||
sortWeight: formData.sortWeight,
|
||
})
|
||
} else {
|
||
result = await createFolderInDrive(activeSheetId.value, {
|
||
name: formData.name,
|
||
parentId: folderDialogParentId.value,
|
||
description: formData.description,
|
||
sortWeight: formData.sortWeight,
|
||
})
|
||
}
|
||
|
||
if (result?.code === 200) {
|
||
setSuccessMessage(isEdit ? `文件夹已更新为“${formData.name}”` : `文件夹“${formData.name}”创建成功`)
|
||
await loadDriveFolders(activeSheetId.value)
|
||
await loadDuplicateItems()
|
||
} else {
|
||
setErrorMessage(result?.message || (isEdit ? '更新文件夹失败' : '创建文件夹失败'))
|
||
}
|
||
} catch (err) {
|
||
console.error(isEdit ? '更新文件夹失败' : '创建文件夹失败', err)
|
||
const serverMessage = err?.payload?.message || err?.message || ''
|
||
setErrorMessage(serverMessage || (isEdit ? '更新文件夹失败' : '创建文件夹失败'))
|
||
} finally {
|
||
driveLoading.value = false
|
||
closeFolderDialog()
|
||
}
|
||
}
|
||
|
||
// 添加文件(记录)
|
||
function handleAddFile(parentId) {
|
||
editingRecord.value = {
|
||
id: null,
|
||
title: '',
|
||
tags: [],
|
||
score: 0,
|
||
originalName: '',
|
||
description: '',
|
||
category: '',
|
||
directors: '',
|
||
casts: '',
|
||
sheetId: activeSheetId.value,
|
||
parentId: parentId || null,
|
||
type: 'file',
|
||
}
|
||
showRecordDialog.value = true
|
||
}
|
||
|
||
// 删除节点(文件夹调用后端 API,文件目前保持本地操作)
|
||
function handleConfirmDialogConfirm() {
|
||
showConfirmDialog.value = false
|
||
if (typeof confirmConfig.onConfirm === 'function') {
|
||
confirmConfig.onConfirm()
|
||
}
|
||
}
|
||
|
||
function handleDeleteNode(node) {
|
||
const isFolder = node.type === 'folder'
|
||
Object.assign(confirmConfig, {
|
||
title: '确认删除',
|
||
message: `确定要删除 <strong>“${node.title}”</strong> 吗?`,
|
||
hint: isFolder ? '注意:该文件夹下的所有子文件夹和文件都将被永久删除!此操作不可恢复。' : '',
|
||
icon: '⚠️',
|
||
confirmText: '确认删除',
|
||
cancelText: '取消',
|
||
danger: true,
|
||
onConfirm: () => doDeleteNode(node),
|
||
})
|
||
showConfirmDialog.value = true
|
||
}
|
||
|
||
async function doDeleteNode(node) {
|
||
if (node.type === 'folder') {
|
||
// 文件夹删除:调用后端 API
|
||
driveLoading.value = true
|
||
try {
|
||
const result = await deleteFolderInDrive(activeSheetId.value, node.id)
|
||
|
||
if (result?.code === 200) {
|
||
setSuccessMessage(`文件夹“${node.title}”已删除`)
|
||
await loadDriveFolders(activeSheetId.value)
|
||
await loadDuplicateItems()
|
||
} else {
|
||
setErrorMessage(result?.message || '删除文件夹失败')
|
||
}
|
||
} catch (err) {
|
||
console.error('删除文件夹失败', err)
|
||
const serverMessage = err?.payload?.message || err?.message || ''
|
||
setErrorMessage(serverMessage || '删除文件夹失败')
|
||
} finally {
|
||
driveLoading.value = false
|
||
}
|
||
} else {
|
||
// 文件删除:调用后端 API
|
||
driveLoading.value = true
|
||
try {
|
||
const result = await deleteMovieInDrive(activeSheetId.value, node.id)
|
||
if (result?.code === 200) {
|
||
rawData.value.sheets = rawData.value.sheets.map((sheet) => ({
|
||
...sheet,
|
||
rows: (sheet.rows || []).filter((item) => item.id !== node.id),
|
||
}))
|
||
setSuccessMessage(result?.message || `电影“${node.title}”已删除`)
|
||
await loadDuplicateItems()
|
||
} else {
|
||
setErrorMessage(result?.message || '删除电影失败')
|
||
}
|
||
} catch (err) {
|
||
console.error('删除电影失败', err)
|
||
setErrorMessage(err?.payload?.message || err?.message || '删除电影失败')
|
||
} finally {
|
||
driveLoading.value = false
|
||
}
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 切换到海报展示视图
|
||
*/
|
||
async function switchToPoster() {
|
||
showUserMenu.value = false
|
||
currentView.value = 'poster'
|
||
// 从后端加载海报展示数据
|
||
await loadPosterShowcase()
|
||
}
|
||
|
||
/**
|
||
* 切换海报展示/控制台视图
|
||
*/
|
||
function toggleView() {
|
||
currentView.value = currentView.value === 'poster' ? 'manage' : 'poster'
|
||
// 如果切换到控制台,需要加载数据
|
||
if (currentView.value === 'manage') {
|
||
handleSwitchToManage()
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 切换到网盘管理视图
|
||
* 每次切换时都从后端重新加载最新的网盘列表数据
|
||
* 避免使用缓存数据导致与真实数据不一致
|
||
*/
|
||
async function handleSwitchToManage() {
|
||
// 切换视图
|
||
currentView.value = 'manage'
|
||
|
||
// 从后端重新加载网盘列表(确保数据最新)
|
||
try {
|
||
await Promise.all([
|
||
loadDriveList(),
|
||
loadIconList(),
|
||
])
|
||
// 加载当前网盘下的文件夹
|
||
if (activeSheetId.value) {
|
||
await loadDriveFolders(activeSheetId.value)
|
||
}
|
||
await loadDuplicateItems()
|
||
} catch (err) {
|
||
console.error('刷新网盘列表失败', err)
|
||
setErrorMessage('刷新网盘列表失败,请稍后重试')
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 加载快捷图标列表
|
||
*/
|
||
async function loadIconList() {
|
||
try {
|
||
const res = await fetchIconList()
|
||
if (res?.code === 200 && res?.data) {
|
||
const list = Array.isArray(res.data) ? res.data : (res.data.list || res.data.records || [])
|
||
iconList.value = list.sort((a, b) => (a.sortOrder ?? 0) - (b.sortOrder ?? 0))
|
||
} else {
|
||
iconList.value = []
|
||
}
|
||
} catch (err) {
|
||
console.error('加载图标列表失败', err)
|
||
iconList.value = []
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 获取网站 favicon
|
||
*/
|
||
function getIconFavicon(url) {
|
||
try {
|
||
const u = new URL(url)
|
||
return `https://www.google.com/s2/favicons?domain=${u.hostname}&sz=64`
|
||
} catch {
|
||
return ''
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 根据当前网络模式返回对应的链接地址
|
||
* @param {Object} icon - 图标对象
|
||
*/
|
||
function getIconLink(icon) {
|
||
if (networkMode.value === 'internal' && icon.internalUrl) {
|
||
return icon.internalUrl
|
||
}
|
||
return icon.url
|
||
}
|
||
|
||
// 添加新网盘(带加载状态)
|
||
async function handleAddSheet() {
|
||
const sheetName = prompt('请输入网盘名称:')
|
||
if (!sheetName || !sheetName.trim()) return
|
||
|
||
// 设置加载状态,防止重复提交并显示加载动画
|
||
driveLoading.value = true
|
||
|
||
try {
|
||
// 调用后端 API 创建网盘
|
||
const result = await createDrive({
|
||
name: sheetName.trim(),
|
||
description: '',
|
||
logoData: '',
|
||
})
|
||
|
||
if (result?.code === 200 && result?.data) {
|
||
// 从后端获取最新的网盘列表
|
||
await loadDriveList()
|
||
setSuccessMessage(`网盘“${sheetName.trim()}”创建成功`)
|
||
} else {
|
||
setErrorMessage(result?.message || '创建网盘失败')
|
||
}
|
||
} catch (err) {
|
||
console.error('创建网盘失败', err)
|
||
|
||
// 智能错误信息展示:优先使用后端返回的消息
|
||
const serverMessage = err?.payload?.message || err?.message || ''
|
||
let errorMsg = serverMessage || '创建网盘失败,请稍后重试'
|
||
|
||
// 根据消息内容进行智能匹配(与用户管理组件保持一致)
|
||
if (serverMessage.includes('已存在') || serverMessage.includes('已被') || serverMessage.includes('已占用')) {
|
||
errorMsg = serverMessage
|
||
} else if (serverMessage.includes('不能为空') || serverMessage.includes('必填')) {
|
||
errorMsg = `参数有误:${serverMessage}`
|
||
} else if (serverMessage.includes('权限') || serverMessage.includes('无权')) {
|
||
errorMsg = serverMessage
|
||
}
|
||
|
||
setErrorMessage(errorMsg)
|
||
} finally {
|
||
// 无论成功失败,都关闭加载状态
|
||
driveLoading.value = false
|
||
}
|
||
}
|
||
|
||
// 删除网盘
|
||
function handleDeleteSheet(sheet) {
|
||
Object.assign(confirmConfig, {
|
||
title: '确认删除',
|
||
message: `确定要删除网盘 <strong>“${sheet.name}”</strong> 吗?`,
|
||
hint: '注意:该网盘下的所有文件和文件夹都将被永久删除!此操作不可恢复。',
|
||
icon: '⚠️',
|
||
confirmText: '确认删除',
|
||
cancelText: '取消',
|
||
danger: true,
|
||
onConfirm: () => doDeleteSheet(sheet),
|
||
})
|
||
showConfirmDialog.value = true
|
||
}
|
||
|
||
async function doDeleteSheet(sheet) {
|
||
// 设置加载状态
|
||
driveLoading.value = true
|
||
|
||
try {
|
||
// 调用后端 API 删除网盘
|
||
const result = await deleteDrive(sheet.id)
|
||
|
||
if (result?.code === 200) {
|
||
// 从后端获取最新的网盘列表
|
||
await loadDriveList()
|
||
setSuccessMessage(`网盘“${sheet.name}”已删除`)
|
||
} else {
|
||
setErrorMessage(result?.message || '删除网盘失败')
|
||
}
|
||
} catch (err) {
|
||
console.error('删除网盘失败', err)
|
||
|
||
// 智能错误信息展示
|
||
const serverMessage = err?.payload?.message || err?.message || ''
|
||
let errorMsg = serverMessage || '删除网盘失败,请稍后重试'
|
||
|
||
if (serverMessage.includes('不存在') || serverMessage.includes('已被删除')) {
|
||
errorMsg = serverMessage
|
||
} else if (serverMessage.includes('权限') || serverMessage.includes('无权')) {
|
||
errorMsg = serverMessage
|
||
}
|
||
|
||
setErrorMessage(errorMsg)
|
||
} finally {
|
||
driveLoading.value = false
|
||
}
|
||
}
|
||
|
||
// 重命名网盘(带加载状态)
|
||
async function handleRenameSheet(sheet) {
|
||
const newName = prompt('请输入新的网盘名称:', sheet.name)
|
||
if (!newName || !newName.trim() || newName.trim() === sheet.name) return
|
||
|
||
// 设置加载状态
|
||
driveLoading.value = true
|
||
|
||
try {
|
||
// 调用后端 API 更新网盘
|
||
const result = await updateDrive(sheet.id, {
|
||
name: newName.trim(),
|
||
description: sheet.description,
|
||
logoData: sheet.icon,
|
||
})
|
||
|
||
if (result?.code === 200) {
|
||
// 从后端获取最新的网盘列表
|
||
await loadDriveList()
|
||
setSuccessMessage('网盘名称已更新')
|
||
} else {
|
||
setErrorMessage(result?.message || '更新网盘失败')
|
||
}
|
||
} catch (err) {
|
||
console.error('更新网盘失败', err)
|
||
|
||
// 智能错误信息展示
|
||
const serverMessage = err?.payload?.message || err?.message || ''
|
||
let errorMsg = serverMessage || '更新网盘失败,请稍后重试'
|
||
|
||
if (serverMessage.includes('不存在') || serverMessage.includes('已被删除')) {
|
||
errorMsg = serverMessage
|
||
} else if (serverMessage.includes('权限') || serverMessage.includes('无权')) {
|
||
errorMsg = serverMessage
|
||
}
|
||
|
||
setErrorMessage(errorMsg)
|
||
} finally {
|
||
driveLoading.value = false
|
||
}
|
||
}
|
||
|
||
function closeRecordDialog() {
|
||
showRecordDialog.value = false
|
||
editingRecord.value = null
|
||
}
|
||
|
||
function upsertRecordInSheets(record) {
|
||
const nextRecord = createRecord(record, activeSheet.value?.name)
|
||
const targetSheetId = record.sheetId || activeSheetId.value
|
||
|
||
rawData.value.sheets = rawData.value.sheets.map((sheet) => {
|
||
const rows = [...(sheet.rows || [])]
|
||
const filtered = rows.filter((item) => item.id !== nextRecord.id)
|
||
if (sheet.id === targetSheetId) {
|
||
filtered.unshift({ ...nextRecord, sheetName: undefined })
|
||
}
|
||
return { ...sheet, rows: filtered }
|
||
})
|
||
}
|
||
|
||
async function handleSaveRecord(record) {
|
||
try {
|
||
if (record.id) {
|
||
const driveId = record.sheetId || activeSheetId.value
|
||
const result = await updateMovieInDrive(driveId, record.id, {
|
||
title: record.title,
|
||
originalName: record.originalName,
|
||
description: record.description,
|
||
rating: record.score,
|
||
tags: Array.isArray(record.tags) ? record.tags.join(',') : '',
|
||
category: record.category,
|
||
directors: record.directors,
|
||
casts: record.casts,
|
||
})
|
||
|
||
const updatedData = result?.data || {}
|
||
const nextRecord = {
|
||
...record,
|
||
title: updatedData.title || record.title,
|
||
originalName: updatedData.originalName || record.originalName,
|
||
description: updatedData.description ?? record.description,
|
||
category: updatedData.category ?? record.category,
|
||
directors: updatedData.directors ?? record.directors,
|
||
casts: updatedData.casts ?? record.casts,
|
||
score: Number(updatedData.rating ?? record.score ?? 0),
|
||
tags: updatedData.tags ?? record.tags ?? [],
|
||
updatedAt: updatedData.updateTime || updatedData.createTime || record.updatedAt || new Date().toLocaleString('zh-CN', { hour12: false }),
|
||
}
|
||
upsertRecordInSheets(nextRecord)
|
||
closeRecordDialog()
|
||
persistDashboard(result?.message || '电影信息已更新')
|
||
await loadDuplicateItems()
|
||
} else {
|
||
const driveId = record.sheetId || activeSheetId.value
|
||
const result = await createMovieInDrive(driveId, {
|
||
title: record.title,
|
||
folderId: record.parentId ?? 0,
|
||
})
|
||
|
||
const createdData = result?.data || {}
|
||
const nextRecord = {
|
||
...record,
|
||
id: createdData.id || `record-${Date.now()}`,
|
||
title: createdData.title || record.title,
|
||
originalName: createdData.originalName || record.originalName || record.title,
|
||
updatedAt: createdData.updateTime || createdData.createTime || new Date().toLocaleString('zh-CN', { hour12: false }),
|
||
poster: createdData.posterUrl || record.poster || '',
|
||
score: Number(createdData.rating ?? record.score ?? 0),
|
||
tags: createdData.tags ?? record.tags ?? [],
|
||
category: createdData.category || record.category || '',
|
||
description: createdData.description || record.description || '',
|
||
parentId: createdData.folderId ?? record.parentId ?? null,
|
||
}
|
||
upsertRecordInSheets(nextRecord)
|
||
closeRecordDialog()
|
||
persistDashboard(result?.message || '电影创建成功')
|
||
await loadDuplicateItems()
|
||
}
|
||
} catch (err) {
|
||
console.error('保存失败', err)
|
||
setErrorMessage(err?.payload?.message || err?.message || '保存失败')
|
||
}
|
||
}
|
||
|
||
async function handleDeleteRecord(record) {
|
||
try {
|
||
const driveId = record.sheetId || activeSheetId.value
|
||
const result = await deleteMovieInDrive(driveId, record.id)
|
||
if (result?.code !== 200) {
|
||
setErrorMessage(result?.message || '删除电影失败')
|
||
return
|
||
}
|
||
|
||
rawData.value.sheets = rawData.value.sheets.map((sheet) => ({
|
||
...sheet,
|
||
rows: (sheet.rows || []).filter((item) => item.id !== record.id),
|
||
}))
|
||
closeRecordDialog()
|
||
setSuccessMessage(result?.message || '电影已删除')
|
||
await loadDuplicateItems()
|
||
} catch (err) {
|
||
console.error('删除电影失败', err)
|
||
setErrorMessage(err?.payload?.message || err?.message || '删除电影失败')
|
||
}
|
||
}
|
||
|
||
async function handleExport() {
|
||
showUserMenu.value = false
|
||
try {
|
||
await exportData()
|
||
const blob = new Blob([JSON.stringify(rawData.value, null, 2)], { type: 'application/json;charset=utf-8' })
|
||
const url = URL.createObjectURL(blob)
|
||
const link = document.createElement('a')
|
||
link.href = url
|
||
link.download = `wangpan-data-${Date.now()}.json`
|
||
link.click()
|
||
URL.revokeObjectURL(url)
|
||
setSuccessMessage('已导出 JSON 文件')
|
||
} catch (err) {
|
||
console.error('导出失败', err)
|
||
}
|
||
}
|
||
|
||
async function handleReset() {
|
||
showUserMenu.value = false
|
||
try {
|
||
const res = await resetData()
|
||
rawData.value = res?.data || []
|
||
removeStorage(DASHBOARD_STORAGE_KEY)
|
||
writeStorage(DASHBOARD_STORAGE_KEY, rawData.value)
|
||
syncActiveSheet()
|
||
setSuccessMessage('已恢复初始数据')
|
||
} catch (err) {
|
||
console.error('重置失败', err)
|
||
}
|
||
}
|
||
|
||
async function persistTmdbConfig() {
|
||
try {
|
||
const payload = {
|
||
tmdbApiKey: tmdbConfig.apiKey,
|
||
}
|
||
const result = await apiSaveTmdbConfig(payload)
|
||
|
||
// 保存成功后,重新从后端获取最新的 TMDB 配置
|
||
await syncTmdbConfigFromServer()
|
||
|
||
// 展示后端返回的结果(优先使用后端返回的消息)
|
||
const message = result?.message || (result?.success ? 'TMDB 配置保存成功' : 'TMDB 配置保存失败')
|
||
if (result?.success) {
|
||
setSuccessMessage(message)
|
||
} else {
|
||
setErrorMessage(message)
|
||
}
|
||
|
||
// 停留在配置页面,不关闭对话框,让用户可以看到更新后的 API Key
|
||
// showTmdbDialog.value = false // 注释掉,保持对话框打开
|
||
} catch (err) {
|
||
console.error('保存 TMDB 配置失败', err)
|
||
setErrorMessage(err?.message || '保存 TMDB 配置失败')
|
||
}
|
||
}
|
||
|
||
async function syncTmdbConfigFromServer() {
|
||
try {
|
||
const result = await fetchTmdbConfig()
|
||
tmdbConfig.apiKey = result?.data?.tmdbApiKey || ''
|
||
tmdbConfig.hasKey = Boolean(result?.data?.hasKey ?? tmdbConfig.apiKey)
|
||
} catch (err) {
|
||
console.error('同步 TMDB 配置失败', err)
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 加载每日推荐电影(从后端接口获取)
|
||
*/
|
||
async function loadRecommendMovies() {
|
||
loadingRecommend.value = true
|
||
try {
|
||
const result = await fetchRecommendMovies()
|
||
|
||
if (result?.code === 200 && result?.data?.list && result.data.list.length > 0) {
|
||
// 将后端返回的数据转换为前端使用的格式
|
||
recommendMovies.value = result.data.list.map(movie => ({
|
||
id: movie.id,
|
||
title: movie.title || '未知标题',
|
||
description: movie.description || '',
|
||
posterUrl: movie.posterUrl || '',
|
||
rating: movie.rating || 0,
|
||
category: movie.category || '',
|
||
tags: typeof movie.tags === 'string' ? movie.tags.split(',') : movie.tags || [],
|
||
createTime: movie.createTime || '',
|
||
}))
|
||
console.log(`成功加载 ${recommendMovies.value.length} 条每日推荐`)
|
||
} else {
|
||
console.warn('每日推荐返回数据异常', result)
|
||
// 后端无数据,保持空数组,使用默认海报
|
||
recommendMovies.value = []
|
||
}
|
||
} catch (err) {
|
||
console.error('加载每日推荐失败', err)
|
||
// 接口报错,保持空数组,使用默认海报
|
||
recommendMovies.value = []
|
||
setErrorMessage(err?.message || '加载每日推荐失败')
|
||
} finally {
|
||
loadingRecommend.value = false
|
||
}
|
||
}
|
||
|
||
watch(
|
||
featuredPosters,
|
||
(value) => {
|
||
if (!value.length) {
|
||
featuredPosterIndex.value = 0
|
||
pauseFeaturedAutoplay()
|
||
return
|
||
}
|
||
if (featuredPosterIndex.value >= value.length) {
|
||
featuredPosterIndex.value = 0
|
||
}
|
||
resumeFeaturedAutoplay()
|
||
},
|
||
{ immediate: true },
|
||
)
|
||
|
||
// 监听当前选中的海报变化,预加载图片
|
||
watch(
|
||
() => activeFeaturedPoster.value?.posterUrl,
|
||
(newPosterUrl) => {
|
||
if (heroImagePreloadTimer) clearTimeout(heroImagePreloadTimer)
|
||
|
||
// 重置加载状态
|
||
heroImageLoaded.value = false
|
||
|
||
// 如果没有海报URL,立即显示默认图片
|
||
if (!newPosterUrl || newPosterUrl.trim() === '') {
|
||
heroImageLoaded.value = true
|
||
return
|
||
}
|
||
|
||
// 延迟一小段时间再加载,避免频繁切换时闪烁
|
||
heroImagePreloadTimer = setTimeout(() => {
|
||
preloadImage(newPosterUrl)
|
||
}, 100)
|
||
},
|
||
{ immediate: true },
|
||
)
|
||
|
||
// 预加载图片函数
|
||
function preloadImage(url) {
|
||
if (!url || url.trim() === '') {
|
||
heroImageLoaded.value = true
|
||
return
|
||
}
|
||
|
||
const img = new Image()
|
||
img.onload = () => {
|
||
heroImageLoaded.value = true
|
||
}
|
||
img.onerror = () => {
|
||
// 图片加载失败,显示默认海报
|
||
heroImageLoaded.value = true
|
||
}
|
||
img.src = url
|
||
}
|
||
|
||
/**
|
||
* 刷新当前网盘文件夹列表
|
||
*/
|
||
async function handleRefreshFolders() {
|
||
const driveId = activeSheetId.value
|
||
if (!driveId) {
|
||
setErrorMessage('请先选择一个网盘')
|
||
return
|
||
}
|
||
|
||
driveLoading.value = true
|
||
try {
|
||
await loadDriveFolders(driveId)
|
||
setSuccessMessage('文件夹列表已刷新')
|
||
} catch (err) {
|
||
console.error('刷新文件夹列表失败', err)
|
||
setErrorMessage(err?.message || '刷新文件夹列表失败')
|
||
} finally {
|
||
driveLoading.value = false
|
||
}
|
||
}
|
||
|
||
// 刮削状态查询
|
||
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,
|
||
overview: data.overview || '',
|
||
casts: data.casts || '',
|
||
directors: data.directors || '',
|
||
error: data.error || data.message,
|
||
}
|
||
} 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: '刮削失败',
|
||
partial: '部分成功',
|
||
skipped: '已跳过',
|
||
}
|
||
return statusMap[status] || '未知状态'
|
||
}
|
||
|
||
/**
|
||
* 判断刮削消息的状态
|
||
* @param {string} msg - 消息文本
|
||
* @returns {string} 'success' | 'failed' | 'skipped'
|
||
*/
|
||
function getMessageStatus(msg) {
|
||
if (!msg) return 'skipped'
|
||
|
||
// 成功的标志
|
||
if (msg.includes('刮削成功') || msg.includes('成功') || msg.includes('Successfully')) {
|
||
return 'success'
|
||
}
|
||
|
||
// 失败的标志
|
||
if (msg.includes('刮削失败') || msg.includes('失败') ||
|
||
msg.includes('未找到') || msg.includes('错误') ||
|
||
msg.includes('Error') || msg.includes('Failed') ||
|
||
msg.includes('不存在') || msg.includes('跳过')) {
|
||
return 'failed'
|
||
}
|
||
|
||
// 默认视为跳过(如:已存在、无需处理等)
|
||
return 'skipped'
|
||
}
|
||
|
||
// 多选模式相关函数
|
||
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 handleScrapeMovie(node) {
|
||
if (!node || node.type !== 'file') {
|
||
setErrorMessage('只有电影文件才能重新刮削')
|
||
return
|
||
}
|
||
|
||
driveLoading.value = true
|
||
try {
|
||
const result = await scrapeMovie(node.id)
|
||
|
||
if (result?.code === 200) {
|
||
setSuccessMessage(`电影“${node.title}”刮削请求已提交`)
|
||
|
||
// 刮削完成后重新加载数据
|
||
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(),
|
||
{ deep: true },
|
||
)
|
||
|
||
// 切换网盘时自动加载该网盘下的文件夹
|
||
watch(activeSheetId, (newId) => {
|
||
expandedFolderIds.clear() // 切换网盘时清空展开状态
|
||
if (newId && currentView.value === 'manage') {
|
||
loadDriveFolders(newId)
|
||
}
|
||
})
|
||
|
||
onMounted(() => {
|
||
const session = readStorage(AUTH_SESSION_STORAGE_KEY, null)
|
||
if (session?.expiresAt && Date.now() >= session.expiresAt) {
|
||
clearAuthState('登录已过期,请重新登录')
|
||
}
|
||
window.addEventListener('wangpan:unauthorized', handleUnauthorized)
|
||
window.addEventListener('wangpan:forbidden', handleForbidden)
|
||
window.addEventListener('wangpan:session-expiring', handleSessionExpiring)
|
||
document.addEventListener('click', handleClickOutside)
|
||
|
||
// 并行加载仪表盘数据、每日推荐和海报展示数据
|
||
Promise.all([
|
||
loadDashboard(),
|
||
loadRecommendMovies(),
|
||
loadPosterShowcase(),
|
||
]).catch(err => {
|
||
console.error('初始化加载失败', err)
|
||
})
|
||
|
||
resumeFeaturedAutoplay()
|
||
})
|
||
|
||
// 黑夜/白天模式切换函数
|
||
function toggleDarkMode() {
|
||
isDarkMode.value = !isDarkMode.value
|
||
// 更新 body 的 class
|
||
document.body.classList.toggle('dark-mode', isDarkMode.value)
|
||
// 保存到 localStorage
|
||
localStorage.setItem('darkMode', isDarkMode.value ? '1' : '0')
|
||
}
|
||
|
||
// 从 localStorage 读取黑夜模式偏好
|
||
function initDarkMode() {
|
||
const savedMode = localStorage.getItem('darkMode')
|
||
if (savedMode === '1') {
|
||
isDarkMode.value = true
|
||
document.body.classList.add('dark-mode')
|
||
}
|
||
}
|
||
|
||
// 在 onMounted 之前调用初始化
|
||
initDarkMode()
|
||
|
||
onBeforeUnmount(() => {
|
||
window.removeEventListener('wangpan:unauthorized', handleUnauthorized)
|
||
window.removeEventListener('wangpan:forbidden', handleForbidden)
|
||
window.removeEventListener('wangpan:session-expiring', handleSessionExpiring)
|
||
document.removeEventListener('click', handleClickOutside)
|
||
pauseFeaturedAutoplay()
|
||
})
|
||
</script>
|