vue/src/App.vue
liRQ d9f87f72b5 壮举:添加验证码生成和绘图实用程序
-实现了`generateCaptcha`函数来创建随机验证码。

-添加了`drawCaptcha`函数,在画布上渲染带有视觉噪声的验证码。

feat:引入数据操作实用程序

-为数据处理创建了“normalizeText”、“filterRows”、“attachSheetName”、“flatenRows”和“buildDuplicates”函数。

-添加了用于记录管理的“createLoginLog”、“createRecord”、“mapMovieToRecord”和“createFolder”函数。

-实现了“buildTree”函数来构建分层数据结构。

feat:实现滚动锁定机制

-添加了“lockScroll”和“unlockScrolls”功能,以管理模式交互期间的后台滚动行为。

feat:创建存储实用程序函数

-引入了从本地存储中读取、写入和删除项目的功能。

chore:设置Vite配置

-配置了使用Vue进行开发的Vite,包括服务器设置和API代理。
2026-06-29 10:19:00 +08:00

3090 lines
106 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<template>
<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
// 获取 movieIdnode.id 就是电影ID
const movieId = node.id
// 调用后端接口查询/触发刮削状态
fetchScrapeStatus(movieId).then(result => {
scrapeStatusLoading.value = false
if (result?.code === 200 && result?.data) {
const data = result.data
scrapeStatusData.value = {
movieId: data.movieId,
title: data.title || node.title,
status: data.status,
message: data.message,
posterUrl: data.posterUrl,
backdropUrl: data.backdropUrl,
rating: data.rating,
category: data.category,
tags: data.tags,
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>