mirror of
https://github.com/jxxghp/MoviePilot-Frontend.git
synced 2026-09-04 23:18:44 +08:00
refactor: optimize performance by centralizing state calculations and stabilizing virtual list data refs
This commit is contained in:
@@ -8,6 +8,16 @@ import { useI18n } from 'vue-i18n'
|
|||||||
import { useBackgroundOptimization } from '@/composables/useBackgroundOptimization'
|
import { useBackgroundOptimization } from '@/composables/useBackgroundOptimization'
|
||||||
import CryptoJS from 'crypto-js'
|
import CryptoJS from 'crypto-js'
|
||||||
|
|
||||||
|
type TransferTask = TransferQueue['tasks'][number]
|
||||||
|
|
||||||
|
interface MediaTaskGroup {
|
||||||
|
media: TransferQueue['media']
|
||||||
|
titleYear: string
|
||||||
|
tasks: TransferTask[]
|
||||||
|
total: number
|
||||||
|
completed: number
|
||||||
|
}
|
||||||
|
|
||||||
// 多语言支持
|
// 多语言支持
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
const { useProgressSSE } = useBackgroundOptimization()
|
const { useProgressSSE } = useBackgroundOptimization()
|
||||||
@@ -29,9 +39,6 @@ const overallProgress = ref({
|
|||||||
// 文件进度映射
|
// 文件进度映射
|
||||||
const fileProgressMap = ref<Map<string, { enable: boolean; value: number }>>(new Map())
|
const fileProgressMap = ref<Map<string, { enable: boolean; value: number }>>(new Map())
|
||||||
|
|
||||||
// 数据可刷新标志
|
|
||||||
const refreshFlag = ref(false)
|
|
||||||
|
|
||||||
// 进度是否激活
|
// 进度是否激活
|
||||||
const progressActive = ref(false)
|
const progressActive = ref(false)
|
||||||
|
|
||||||
@@ -58,49 +65,58 @@ function getStateColor(state: string) {
|
|||||||
else return 'error'
|
else return 'error'
|
||||||
}
|
}
|
||||||
|
|
||||||
// 从dataList中提取所有的媒体信息,合并相同title_year的记录
|
// 按媒体聚合队列,避免模板中按 tab 重复扫描 dataList
|
||||||
const mediaList = computed(() => {
|
const mediaTaskGroups = computed<MediaTaskGroup[]>(() => {
|
||||||
const mediaMap = new Map<string, any>()
|
const groupMap = new Map<string, MediaTaskGroup>()
|
||||||
|
|
||||||
dataList.value.forEach(item => {
|
dataList.value.forEach(item => {
|
||||||
const titleYear = item.media.title_year || ''
|
const titleYear = item.media.title_year || ''
|
||||||
if (!mediaMap.has(titleYear)) {
|
let group = groupMap.get(titleYear)
|
||||||
mediaMap.set(titleYear, item.media)
|
|
||||||
|
if (!group) {
|
||||||
|
group = {
|
||||||
|
media: item.media,
|
||||||
|
titleYear,
|
||||||
|
tasks: [],
|
||||||
|
total: 0,
|
||||||
|
completed: 0,
|
||||||
|
}
|
||||||
|
groupMap.set(titleYear, group)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
group.tasks.push(...item.tasks)
|
||||||
|
group.total += item.tasks.length
|
||||||
|
group.completed += item.tasks.filter(task => task.state === 'completed').length
|
||||||
})
|
})
|
||||||
|
|
||||||
return Array.from(mediaMap.values())
|
return Array.from(groupMap.values())
|
||||||
|
})
|
||||||
|
|
||||||
|
// 从dataList中提取所有的媒体信息,合并相同title_year的记录
|
||||||
|
const mediaList = computed(() => {
|
||||||
|
return mediaTaskGroups.value.map(group => group.media)
|
||||||
})
|
})
|
||||||
|
|
||||||
// 按media计算总数和完成数,返回 x/x
|
// 按media计算总数和完成数,返回 x/x
|
||||||
function getMediaCount(title_year: string) {
|
function getMediaCount(title_year: string) {
|
||||||
// 按title_year查询出所有media列表
|
const group = mediaTaskGroups.value.find(item => item.titleYear === title_year)
|
||||||
const medias = dataList.value.filter(item => item.media.title_year === title_year)
|
return `${group?.completed ?? 0} / ${group?.total ?? 0}`
|
||||||
// 计算media下任务的总数
|
|
||||||
const total = medias.reduce((acc, cur) => acc + cur.tasks.length, 0)
|
|
||||||
// 计算media下任务的完成数
|
|
||||||
const completed = medias.reduce((acc, cur) => acc + cur.tasks.filter(task => task.state === 'completed').length, 0)
|
|
||||||
return `${completed} / ${total}`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 根据媒体信息获取对应的整理任务,合并相同title_year的所有任务
|
// 根据媒体信息获取对应的整理任务,合并相同title_year的所有任务
|
||||||
const activeTasks = computed(() => {
|
const activeTasks = computed(() => {
|
||||||
const tasks = dataList.value.filter(item => item.media.title_year === activeTab.value).flatMap(item => item.tasks)
|
return mediaTaskGroups.value.find(item => item.titleYear === activeTab.value)?.tasks ?? []
|
||||||
return tasks
|
|
||||||
})
|
})
|
||||||
|
|
||||||
// 根据媒体title_year获取对应的任务列表
|
// 根据媒体title_year获取对应的任务列表
|
||||||
function getTasksByMedia(title_year: string) {
|
function getTasksByMedia(title_year: string) {
|
||||||
return dataList.value.filter(item => item.media.title_year === title_year).flatMap(item => item.tasks)
|
return mediaTaskGroups.value.find(item => item.titleYear === title_year)?.tasks ?? []
|
||||||
}
|
}
|
||||||
|
|
||||||
// 计算整体进度
|
// 计算整体进度
|
||||||
const overallProgressComputed = computed(() => {
|
const overallProgressComputed = computed(() => {
|
||||||
if (dataList.value.length === 0) return 0
|
const totalTasks = mediaTaskGroups.value.reduce((total, group) => total + group.total, 0)
|
||||||
|
const completedTasks = mediaTaskGroups.value.reduce((total, group) => total + group.completed, 0)
|
||||||
const allTasks = dataList.value.flatMap(item => item.tasks)
|
|
||||||
const totalTasks = allTasks.length
|
|
||||||
const completedTasks = allTasks.filter(task => task.state === 'completed').length
|
|
||||||
|
|
||||||
return totalTasks > 0 ? (completedTasks / totalTasks) * 100 : 0
|
return totalTasks > 0 ? (completedTasks / totalTasks) * 100 : 0
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -129,33 +129,36 @@ function globToRegex(pattern: string, flags: string = ''): RegExp {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 通用过滤
|
// 通用过滤
|
||||||
const getFilteredItems = (type: 'dir' | 'file') => {
|
const filteredItems = computed(() => {
|
||||||
const filterValue = filter.value
|
const filterValue = filter.value
|
||||||
if (!filterValue) {
|
if (!filterValue) {
|
||||||
return items.value.filter(item => item.type === type)
|
return items.value
|
||||||
}
|
}
|
||||||
|
|
||||||
// 通配符模式
|
// 通配符模式
|
||||||
if (filterValue.includes('*') || filterValue.includes('?')) {
|
if (filterValue.includes('*') || filterValue.includes('?')) {
|
||||||
const flags = ignoreCase.value ? 'i' : ''
|
const flags = ignoreCase.value ? 'i' : ''
|
||||||
const regex = globToRegex(filterValue, flags)
|
const regex = globToRegex(filterValue, flags)
|
||||||
return items.value.filter(item => item.type === type && regex.test(item.name ?? ''))
|
return items.value.filter(item => regex.test(item.name ?? ''))
|
||||||
}
|
}
|
||||||
|
|
||||||
// 子字符串模式
|
// 子字符串模式
|
||||||
if (ignoreCase.value) {
|
if (ignoreCase.value) {
|
||||||
const lowerCaseFilter = filterValue.toLowerCase()
|
const lowerCaseFilter = filterValue.toLowerCase()
|
||||||
return items.value.filter(item => item.type === type && (item.name ?? '').toLowerCase().includes(lowerCaseFilter))
|
return items.value.filter(item => (item.name ?? '').toLowerCase().includes(lowerCaseFilter))
|
||||||
} else {
|
} else {
|
||||||
return items.value.filter(item => item.type === type && (item.name ?? '').includes(filterValue))
|
return items.value.filter(item => (item.name ?? '').includes(filterValue))
|
||||||
}
|
}
|
||||||
}
|
})
|
||||||
|
|
||||||
// 目录过滤
|
// 目录过滤
|
||||||
const dirs = computed(() => getFilteredItems('dir'))
|
const dirs = computed(() => filteredItems.value.filter(item => item.type === 'dir'))
|
||||||
|
|
||||||
// 文件过滤
|
// 文件过滤
|
||||||
const files = computed(() => getFilteredItems('file'))
|
const files = computed(() => filteredItems.value.filter(item => item.type === 'file'))
|
||||||
|
|
||||||
|
// 虚拟列表数据,保持引用稳定,避免模板内联展开数组导致虚拟列表重算。
|
||||||
|
const displayItems = computed(() => [...dirs.value, ...files.value])
|
||||||
// 是否文件
|
// 是否文件
|
||||||
const isFile = computed(() => inProps.item.type == 'file')
|
const isFile = computed(() => inProps.item.type == 'file')
|
||||||
|
|
||||||
@@ -716,7 +719,7 @@ onUnmounted(() => {
|
|||||||
class="text-high-emphasis file-list-container"
|
class="text-high-emphasis file-list-container"
|
||||||
:style="{ height: `${listAvailableHeight}px`, maxHeight: `${listAvailableHeight}px` }"
|
:style="{ height: `${listAvailableHeight}px`, maxHeight: `${listAvailableHeight}px` }"
|
||||||
>
|
>
|
||||||
<VVirtualScroll :items="[...dirs, ...files]" style="block-size: 100%">
|
<VVirtualScroll :items="displayItems" style="block-size: 100%">
|
||||||
<template #default="{ item }">
|
<template #default="{ item }">
|
||||||
<VHover>
|
<VHover>
|
||||||
<template #default="hover">
|
<template #default="hover">
|
||||||
|
|||||||
@@ -14,6 +14,11 @@ const display = useDisplay()
|
|||||||
|
|
||||||
const { appMode } = usePWA()
|
const { appMode } = usePWA()
|
||||||
|
|
||||||
|
type TreeRow =
|
||||||
|
| { type: 'root'; key: string; level: number }
|
||||||
|
| { type: 'loading'; key: string; path: string; level: number }
|
||||||
|
| { type: 'directory'; key: string; dir: FileItem; level: number }
|
||||||
|
|
||||||
// 计算列表可用高度
|
// 计算列表可用高度
|
||||||
// componentOffset = FileToolbar(48) = 48
|
// componentOffset = FileToolbar(48) = 48
|
||||||
const { availableHeight } = useAvailableHeight(48, 300)
|
const { availableHeight } = useAvailableHeight(48, 300)
|
||||||
@@ -132,37 +137,6 @@ async function loadRootDirectories() {
|
|||||||
await loadSubdirectories('/')
|
await loadSubdirectories('/')
|
||||||
}
|
}
|
||||||
|
|
||||||
// 检索所有目录节点
|
|
||||||
function getAllDirectories() {
|
|
||||||
const allDirs: { dir: FileItem; level: number; parentPath: string }[] = []
|
|
||||||
|
|
||||||
// 添加根目录的子目录
|
|
||||||
if (treeCache.value['/']) {
|
|
||||||
treeCache.value['/'].forEach(dir => {
|
|
||||||
allDirs.push({ dir, level: 0, parentPath: '/' })
|
|
||||||
addSubdirectories(dir.path || '', 1, allDirs)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
return allDirs
|
|
||||||
}
|
|
||||||
|
|
||||||
// 递归添加子目录
|
|
||||||
function addSubdirectories(
|
|
||||||
parentPath: string,
|
|
||||||
level: number,
|
|
||||||
result: { dir: FileItem; level: number; parentPath: string }[],
|
|
||||||
) {
|
|
||||||
if (treeCache.value[parentPath]) {
|
|
||||||
treeCache.value[parentPath].forEach(dir => {
|
|
||||||
result.push({ dir, level, parentPath })
|
|
||||||
if (isFolderExpanded(dir.path || '')) {
|
|
||||||
addSubdirectories(dir.path || '', level + 1, result)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 监听当前路径变化,自动展开当前路径
|
// 监听当前路径变化,自动展开当前路径
|
||||||
watch(
|
watch(
|
||||||
() => props.currentPath,
|
() => props.currentPath,
|
||||||
@@ -224,38 +198,51 @@ const rootDirectories = computed(() => {
|
|||||||
return treeCache.value['/'] || []
|
return treeCache.value['/'] || []
|
||||||
})
|
})
|
||||||
|
|
||||||
// 扁平化的目录树
|
// 只生成当前可见的目录行,避免折叠/隐藏节点继续留在 DOM 中
|
||||||
const flattenedDirectories = computed(() => {
|
const visibleTreeRows = computed<TreeRow[]>(() => {
|
||||||
return getAllDirectories()
|
const rows: TreeRow[] = [{ type: 'root', key: 'root', level: 0 }]
|
||||||
})
|
|
||||||
|
|
||||||
// 检查路径是否为指定目录的子目录或后代
|
if (loading.value['/']) {
|
||||||
function isChildOrDescendant(path: string, ancestorPath: string) {
|
rows.push({ type: 'loading', key: 'loading:/', path: '/', level: 0 })
|
||||||
if (!path || !ancestorPath) return false
|
return rows
|
||||||
if (ancestorPath === '/') return true
|
|
||||||
|
|
||||||
// 确保路径以斜杠结尾,便于比较
|
|
||||||
const normalizedPath = path.endsWith('/') ? path : path + '/'
|
|
||||||
const normalizedAncestorPath = ancestorPath.endsWith('/') ? ancestorPath : ancestorPath + '/'
|
|
||||||
|
|
||||||
// 检查路径是否以祖先路径开头,但不是祖先路径本身
|
|
||||||
return normalizedPath.startsWith(normalizedAncestorPath) && normalizedPath !== normalizedAncestorPath
|
|
||||||
}
|
|
||||||
|
|
||||||
// 计算目录相对于其祖先的缩进级别
|
|
||||||
function getIndentLevel(path: string, ancestorPath: string) {
|
|
||||||
if (!path || !ancestorPath) return 0
|
|
||||||
|
|
||||||
// 根目录特殊处理
|
|
||||||
if (ancestorPath === '/') {
|
|
||||||
return path.split('/').filter(p => p).length - 1
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 计算路径中斜杠的数量差异
|
rootDirectories.value.forEach(dir => addVisibleDirectoryRows(dir, 0, rows))
|
||||||
const pathParts = path.split('/').filter(p => p).length
|
|
||||||
const ancestorParts = ancestorPath.split('/').filter(p => p).length
|
|
||||||
|
|
||||||
return pathParts - ancestorParts
|
return rows
|
||||||
|
})
|
||||||
|
|
||||||
|
function addVisibleDirectoryRows(dir: FileItem, level: number, rows: TreeRow[]) {
|
||||||
|
const path = dir.path || ''
|
||||||
|
|
||||||
|
rows.push({
|
||||||
|
type: 'directory',
|
||||||
|
key: path || `${level}:${dir.name}`,
|
||||||
|
dir,
|
||||||
|
level,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!path || !isFolderExpanded(path)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading.value[path]) {
|
||||||
|
rows.push({
|
||||||
|
type: 'loading',
|
||||||
|
key: `loading:${path}`,
|
||||||
|
path,
|
||||||
|
level: level + 1,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
treeCache.value[path]?.forEach(child => addVisibleDirectoryRows(child, level + 1, rows))
|
||||||
|
}
|
||||||
|
|
||||||
|
function getTreeRowStyle(level: number) {
|
||||||
|
return {
|
||||||
|
paddingInlineStart: level > 0 ? `${16 + level * 12}px` : undefined,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 组件挂载时初始加载
|
// 组件挂载时初始加载
|
||||||
@@ -267,117 +254,75 @@ onMounted(async () => {
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<VCard class="file-navigator rounded-e-0 rounded-t-0" v-if="!isMobile" :height="`${availableHeight}px`">
|
<VCard class="file-navigator rounded-e-0 rounded-t-0" v-if="!isMobile" :height="`${availableHeight}px`">
|
||||||
<div class="tree-container">
|
<VVirtualScroll :items="visibleTreeRows" :item-height="32" class="tree-container">
|
||||||
<!-- 根目录项 -->
|
<template #default="{ item }">
|
||||||
<div
|
<div
|
||||||
class="tree-item root-item"
|
v-if="item.type === 'root'"
|
||||||
:class="{ 'active': currentPath === '/' }"
|
:key="item.key"
|
||||||
@click="
|
class="tree-item root-item"
|
||||||
handleFolderClick({
|
:class="{ 'active': currentPath === '/' }"
|
||||||
storage: storage,
|
@click="
|
||||||
type: 'dir',
|
handleFolderClick({
|
||||||
name: '/',
|
storage: storage,
|
||||||
path: '/',
|
type: 'dir',
|
||||||
})
|
name: '/',
|
||||||
"
|
path: '/',
|
||||||
>
|
})
|
||||||
<div class="folder-content">
|
"
|
||||||
<VIcon icon="mdi-home" class="me-2" color="primary" />
|
>
|
||||||
<span>{{ t('file.rootDirectory') }}</span>
|
<div class="folder-content">
|
||||||
</div>
|
<VIcon icon="mdi-home" class="me-2" color="primary" />
|
||||||
</div>
|
<span>{{ t('file.rootDirectory') }}</span>
|
||||||
<!-- 加载根目录 -->
|
|
||||||
<div v-if="loading['/']" class="tree-loading">
|
|
||||||
<VProgressCircular indeterminate size="24" color="primary" class="ma-2" />
|
|
||||||
<span>{{ t('file.loadingDirectoryStructure') }}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 目录树结构 -->
|
|
||||||
<template v-else>
|
|
||||||
<!-- 一级目录(根目录下的目录) -->
|
|
||||||
<div v-for="directory in rootDirectories" :key="directory.path" class="tree-item-container">
|
|
||||||
<!-- 目录项 -->
|
|
||||||
<div class="tree-item" :class="{ 'active': currentPath === directory.path }">
|
|
||||||
<div class="folder-toggle" @click.stop="toggleFolder(directory.path || '')">
|
|
||||||
<VProgressCircular
|
|
||||||
v-if="loading[directory.path || '']"
|
|
||||||
indeterminate
|
|
||||||
size="14"
|
|
||||||
width="2"
|
|
||||||
color="primary"
|
|
||||||
/>
|
|
||||||
<VIcon
|
|
||||||
v-else
|
|
||||||
size="small"
|
|
||||||
:icon="isFolderExpanded(directory.path || '') ? 'mdi-chevron-down' : 'mdi-chevron-right'"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div class="folder-content" @click.stop="handleFolderClick(directory)">
|
|
||||||
<VIcon
|
|
||||||
size="small"
|
|
||||||
:icon="renderFolderIcon(isFolderExpanded(directory.path || ''))"
|
|
||||||
:color="currentPath === directory.path ? 'primary' : 'amber-darken-1'"
|
|
||||||
class="me-1"
|
|
||||||
/>
|
|
||||||
<span class="folder-name">
|
|
||||||
{{ directory.name }}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- 子目录容器 - 如果该目录被展开,显示其所有子目录 -->
|
<div
|
||||||
<div v-if="isFolderExpanded(directory.path || '')">
|
v-else-if="item.type === 'loading'"
|
||||||
<!-- 加载中状态 -->
|
:key="item.key"
|
||||||
<div v-if="loading[directory.path || '']" class="tree-loading pl-8">
|
class="tree-loading"
|
||||||
<VProgressCircular indeterminate size="14" color="primary" class="ma-2" />
|
:style="getTreeRowStyle(item.level)"
|
||||||
<span class="text-caption">{{ t('common.loading') }}</span>
|
>
|
||||||
</div>
|
<VProgressCircular indeterminate size="14" color="primary" class="ma-2" />
|
||||||
|
<span class="text-caption">
|
||||||
|
{{ item.path === '/' ? t('file.loadingDirectoryStructure') : t('common.loading') }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- 所有层级的子目录列表 -->
|
<div
|
||||||
<div v-else>
|
v-else
|
||||||
<!-- 遍历所有扁平化的目录列表,查找对应层级的目录 -->
|
:key="item.key"
|
||||||
<div
|
class="tree-item"
|
||||||
v-for="item in flattenedDirectories"
|
:class="{ 'active': currentPath === item.dir.path }"
|
||||||
:key="item.dir.path"
|
:style="getTreeRowStyle(item.level)"
|
||||||
v-show="isChildOrDescendant(item.dir.path || '', directory.path || '')"
|
>
|
||||||
class="tree-item"
|
<div class="folder-toggle" @click.stop="toggleFolder(item.dir.path || '')">
|
||||||
:class="{ 'active': currentPath === item.dir.path }"
|
<VProgressCircular
|
||||||
:style="{ paddingLeft: 16 + getIndentLevel(item.dir.path || '', directory.path || '') * 12 + 'px' }"
|
v-if="loading[item.dir.path || '']"
|
||||||
>
|
indeterminate
|
||||||
<!-- 展开/折叠按钮 -->
|
size="14"
|
||||||
<div class="folder-toggle" @click.stop="toggleFolder(item.dir.path || '')">
|
width="2"
|
||||||
<VProgressCircular
|
color="primary"
|
||||||
v-if="loading[item.dir.path || '']"
|
/>
|
||||||
indeterminate
|
<VIcon
|
||||||
size="14"
|
v-else
|
||||||
width="2"
|
size="small"
|
||||||
color="primary"
|
:icon="isFolderExpanded(item.dir.path || '') ? 'mdi-chevron-down' : 'mdi-chevron-right'"
|
||||||
/>
|
/>
|
||||||
<VIcon
|
</div>
|
||||||
v-else
|
<div class="folder-content" @click.stop="handleFolderClick(item.dir)">
|
||||||
size="small"
|
<VIcon
|
||||||
:icon="isFolderExpanded(item.dir.path || '') ? 'mdi-chevron-down' : 'mdi-chevron-right'"
|
size="small"
|
||||||
/>
|
:icon="renderFolderIcon(isFolderExpanded(item.dir.path || ''))"
|
||||||
</div>
|
:color="currentPath === item.dir.path ? 'primary' : 'amber-darken-1'"
|
||||||
|
class="me-1"
|
||||||
<!-- 文件夹图标和名称 -->
|
/>
|
||||||
<div class="folder-content" @click.stop="handleFolderClick(item.dir)">
|
<span class="folder-name">
|
||||||
<VIcon
|
{{ item.dir.name }}
|
||||||
size="small"
|
</span>
|
||||||
:icon="renderFolderIcon(isFolderExpanded(item.dir.path || ''))"
|
|
||||||
:color="currentPath === item.dir.path ? 'primary' : 'amber-darken-1'"
|
|
||||||
class="me-1"
|
|
||||||
/>
|
|
||||||
<span class="folder-name">
|
|
||||||
{{ item.dir.name }}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</VVirtualScroll>
|
||||||
</VCard>
|
</VCard>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -402,8 +347,8 @@ onMounted(async () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.tree-container {
|
.tree-container {
|
||||||
overflow: hidden auto;
|
|
||||||
flex: 1;
|
flex: 1;
|
||||||
|
min-block-size: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tree-item-container {
|
.tree-item-container {
|
||||||
|
|||||||
+62
-8
@@ -39,6 +39,8 @@ interface SearchParams {
|
|||||||
sites: string
|
sites: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const resourceSearchParamsStorageKey = 'MP_ResourceSearchParams'
|
||||||
|
|
||||||
function createSearchParams(query: LocationQuery): SearchParams {
|
function createSearchParams(query: LocationQuery): SearchParams {
|
||||||
return {
|
return {
|
||||||
keyword: query?.keyword?.toString() ?? '',
|
keyword: query?.keyword?.toString() ?? '',
|
||||||
@@ -51,15 +53,62 @@ function createSearchParams(query: LocationQuery): SearchParams {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function getSearchParamsKey(params: SearchParams): string {
|
function normalizeSearchParams(params?: Partial<SearchParams> | null): SearchParams {
|
||||||
return JSON.stringify(params)
|
return {
|
||||||
|
keyword: params?.keyword?.toString() ?? '',
|
||||||
|
type: params?.type?.toString() ?? '',
|
||||||
|
area: params?.area?.toString() ?? '',
|
||||||
|
title: params?.title?.toString() ?? '',
|
||||||
|
year: params?.year?.toString() ?? '',
|
||||||
|
season: params?.season?.toString() ?? '',
|
||||||
|
sites: params?.sites?.toString() ?? '',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasSearchKeyword(params: SearchParams): boolean {
|
||||||
|
return params.keyword.trim().length > 0
|
||||||
}
|
}
|
||||||
|
|
||||||
function createSearchRequestToken(): string {
|
function createSearchRequestToken(): string {
|
||||||
return `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
|
return `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
|
||||||
}
|
}
|
||||||
|
|
||||||
const activeSearchParams = ref<SearchParams>(createSearchParams(route.query))
|
function loadStoredSearchParams(): SearchParams | null {
|
||||||
|
try {
|
||||||
|
const rawParams = localStorage.getItem(resourceSearchParamsStorageKey)
|
||||||
|
if (!rawParams) return null
|
||||||
|
|
||||||
|
const params = normalizeSearchParams(JSON.parse(rawParams) as Partial<SearchParams>)
|
||||||
|
return hasSearchKeyword(params) ? params : null
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('读取资源搜索参数失败:', error)
|
||||||
|
localStorage.removeItem(resourceSearchParamsStorageKey)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveStoredSearchParams(params: SearchParams) {
|
||||||
|
if (!hasSearchKeyword(params)) return
|
||||||
|
localStorage.setItem(resourceSearchParamsStorageKey, JSON.stringify(params))
|
||||||
|
}
|
||||||
|
|
||||||
|
const initialSearchParams = createSearchParams(route.query)
|
||||||
|
const activeSearchParams = ref<SearchParams>(initialSearchParams)
|
||||||
|
const lastSearchParams = ref<SearchParams | null>(
|
||||||
|
hasSearchKeyword(initialSearchParams) ? { ...initialSearchParams } : loadStoredSearchParams(),
|
||||||
|
)
|
||||||
|
|
||||||
|
function rememberSearchParams(params: SearchParams) {
|
||||||
|
if (!hasSearchKeyword(params)) return
|
||||||
|
|
||||||
|
const nextParams = { ...params }
|
||||||
|
lastSearchParams.value = nextParams
|
||||||
|
saveStoredSearchParams(nextParams)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasSearchKeyword(initialSearchParams)) {
|
||||||
|
rememberSearchParams(initialSearchParams)
|
||||||
|
}
|
||||||
|
|
||||||
// 查询TMDBID或标题
|
// 查询TMDBID或标题
|
||||||
const keyword = computed(() => activeSearchParams.value.keyword)
|
const keyword = computed(() => activeSearchParams.value.keyword)
|
||||||
@@ -552,13 +601,17 @@ function changeViewType(newType: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 获取搜索列表数据
|
// 获取搜索列表数据
|
||||||
async function fetchData(options: { force?: boolean } = {}) {
|
async function fetchData(options: { force?: boolean; params?: SearchParams } = {}) {
|
||||||
const currentSearchParams = { ...activeSearchParams.value }
|
const currentSearchParams = { ...(options.params ?? activeSearchParams.value) }
|
||||||
|
if (hasSearchKeyword(currentSearchParams)) {
|
||||||
|
activeSearchParams.value = { ...currentSearchParams }
|
||||||
|
rememberSearchParams(currentSearchParams)
|
||||||
|
}
|
||||||
const requestToken = options.force || Boolean(currentSearchParams.keyword) ? createSearchRequestToken() : undefined
|
const requestToken = options.force || Boolean(currentSearchParams.keyword) ? createSearchRequestToken() : undefined
|
||||||
|
|
||||||
try {
|
try {
|
||||||
enableFilterAnimation.value = true
|
enableFilterAnimation.value = true
|
||||||
if (!currentSearchParams.keyword) {
|
if (!hasSearchKeyword(currentSearchParams)) {
|
||||||
// 查询上次搜索结果
|
// 查询上次搜索结果
|
||||||
const results = await api.get('search/last', {
|
const results = await api.get('search/last', {
|
||||||
params: requestToken ? { _ts: requestToken } : undefined,
|
params: requestToken ? { _ts: requestToken } : undefined,
|
||||||
@@ -593,11 +646,12 @@ async function fetchData(options: { force?: boolean } = {}) {
|
|||||||
// 重新搜索(使用相同参数重新触发搜索)
|
// 重新搜索(使用相同参数重新触发搜索)
|
||||||
async function refreshSearch() {
|
async function refreshSearch() {
|
||||||
if (isRefreshing.value || progressActive.value) return
|
if (isRefreshing.value || progressActive.value) return
|
||||||
|
const refreshParams = lastSearchParams.value ?? activeSearchParams.value
|
||||||
isRefreshing.value = true
|
isRefreshing.value = true
|
||||||
try {
|
try {
|
||||||
// 重新搜索时退出 AI 视图,其余状态由 fetchData 内部重置
|
// 重新搜索时退出 AI 视图,其余状态由 fetchData 内部重置
|
||||||
showingAiResults.value = false
|
showingAiResults.value = false
|
||||||
await fetchData({ force: true })
|
await fetchData({ force: true, params: refreshParams })
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('重新搜索失败:', error)
|
console.error('重新搜索失败:', error)
|
||||||
} finally {
|
} finally {
|
||||||
@@ -885,7 +939,7 @@ watch(
|
|||||||
if (Object.keys(query).length === 0) return
|
if (Object.keys(query).length === 0) return
|
||||||
|
|
||||||
const nextSearchParams = createSearchParams(query)
|
const nextSearchParams = createSearchParams(query)
|
||||||
if (getSearchParamsKey(nextSearchParams) === getSearchParamsKey(activeSearchParams.value)) return
|
if (!hasSearchKeyword(nextSearchParams)) return
|
||||||
|
|
||||||
activeSearchParams.value = nextSearchParams
|
activeSearchParams.value = nextSearchParams
|
||||||
void fetchData()
|
void fetchData()
|
||||||
|
|||||||
@@ -180,9 +180,10 @@ const pageRange = [
|
|||||||
{ title: '100', value: 100 },
|
{ title: '100', value: 100 },
|
||||||
{ title: '500', value: 500 },
|
{ title: '500', value: 500 },
|
||||||
{ title: '1000', value: 1000 },
|
{ title: '1000', value: 1000 },
|
||||||
{ title: 'All', value: -1 },
|
|
||||||
]
|
]
|
||||||
|
|
||||||
|
const pageRangeValues = pageRange.map(item => item.value)
|
||||||
|
|
||||||
// 数据列表
|
// 数据列表
|
||||||
const dataList = ref<TransferHistory[]>([])
|
const dataList = ref<TransferHistory[]>([])
|
||||||
|
|
||||||
@@ -209,7 +210,7 @@ const groupBy = ref<any>([
|
|||||||
])
|
])
|
||||||
|
|
||||||
// 每页条数
|
// 每页条数
|
||||||
const itemsPerPage = ref<number>(ensureNumber(route.query.itemsPerPage, 50))
|
const itemsPerPage = ref<number>(ensurePageSize(route.query.itemsPerPage, 50))
|
||||||
|
|
||||||
// 当前页码
|
// 当前页码
|
||||||
const currentPage = ref<number>(ensureNumber(route.query.currentPage, 1))
|
const currentPage = ref<number>(ensureNumber(route.query.currentPage, 1))
|
||||||
@@ -270,7 +271,7 @@ const TransferDict: { [key: string]: string } = {
|
|||||||
// 分页提示
|
// 分页提示
|
||||||
const pageTip = computed(() => {
|
const pageTip = computed(() => {
|
||||||
const begin = itemsPerPage.value * (currentPage.value - 1) + 1
|
const begin = itemsPerPage.value * (currentPage.value - 1) + 1
|
||||||
const end = itemsPerPage.value * currentPage.value === -1 ? 'ALL' : itemsPerPage.value * currentPage.value
|
const end = Math.min(itemsPerPage.value * currentPage.value, totalItems.value)
|
||||||
return {
|
return {
|
||||||
begin,
|
begin,
|
||||||
end,
|
end,
|
||||||
@@ -280,7 +281,7 @@ const pageTip = computed(() => {
|
|||||||
// 分页总数
|
// 分页总数
|
||||||
const totalPage = computed(() => {
|
const totalPage = computed(() => {
|
||||||
const total = Math.ceil(totalItems.value / itemsPerPage.value)
|
const total = Math.ceil(totalItems.value / itemsPerPage.value)
|
||||||
return total
|
return Math.max(1, total)
|
||||||
})
|
})
|
||||||
|
|
||||||
// 切换页签
|
// 切换页签
|
||||||
@@ -663,6 +664,11 @@ function ensureNumber(value: any, defaultValue: number = 0) {
|
|||||||
return value
|
return value
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function ensurePageSize(value: any, defaultValue: number = 50) {
|
||||||
|
const pageSize = ensureNumber(value, defaultValue)
|
||||||
|
return pageRangeValues.includes(pageSize) ? pageSize : defaultValue
|
||||||
|
}
|
||||||
|
|
||||||
// 按标题分组后的选中数量统计,键为标题,值为对应分组的选中数
|
// 按标题分组后的选中数量统计,键为标题,值为对应分组的选中数
|
||||||
const selectedCountsGroupedByTitle = computed(() => {
|
const selectedCountsGroupedByTitle = computed(() => {
|
||||||
return selected.value.reduce(
|
return selected.value.reduce(
|
||||||
|
|||||||
Reference in New Issue
Block a user