refactor: optimize performance by centralizing state calculations and stabilizing virtual list data refs

This commit is contained in:
jxxghp
2026-05-13 22:01:13 +08:00
parent 3c926f7485
commit e62a0b5a8d
5 changed files with 234 additions and 210 deletions

View File

@@ -8,6 +8,16 @@ import { useI18n } from 'vue-i18n'
import { useBackgroundOptimization } from '@/composables/useBackgroundOptimization'
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 { useProgressSSE } = useBackgroundOptimization()
@@ -29,9 +39,6 @@ const overallProgress = ref({
// 文件进度映射
const fileProgressMap = ref<Map<string, { enable: boolean; value: number }>>(new Map())
// 数据可刷新标志
const refreshFlag = ref(false)
// 进度是否激活
const progressActive = ref(false)
@@ -58,49 +65,58 @@ function getStateColor(state: string) {
else return 'error'
}
// 从dataList中提取所有的媒体信息合并相同title_year的记录
const mediaList = computed(() => {
const mediaMap = new Map<string, any>()
// 按媒体聚合队列,避免模板中按 tab 重复扫描 dataList
const mediaTaskGroups = computed<MediaTaskGroup[]>(() => {
const groupMap = new Map<string, MediaTaskGroup>()
dataList.value.forEach(item => {
const titleYear = item.media.title_year || ''
if (!mediaMap.has(titleYear)) {
mediaMap.set(titleYear, item.media)
let group = groupMap.get(titleYear)
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
function getMediaCount(title_year: string) {
// 按title_year查询出所有media列表
const medias = dataList.value.filter(item => item.media.title_year === title_year)
// 计算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}`
const group = mediaTaskGroups.value.find(item => item.titleYear === title_year)
return `${group?.completed ?? 0} / ${group?.total ?? 0}`
}
// 根据媒体信息获取对应的整理任务合并相同title_year的所有任务
const activeTasks = computed(() => {
const tasks = dataList.value.filter(item => item.media.title_year === activeTab.value).flatMap(item => item.tasks)
return tasks
return mediaTaskGroups.value.find(item => item.titleYear === activeTab.value)?.tasks ?? []
})
// 根据媒体title_year获取对应的任务列表
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(() => {
if (dataList.value.length === 0) return 0
const allTasks = dataList.value.flatMap(item => item.tasks)
const totalTasks = allTasks.length
const completedTasks = allTasks.filter(task => task.state === 'completed').length
const totalTasks = mediaTaskGroups.value.reduce((total, group) => total + group.total, 0)
const completedTasks = mediaTaskGroups.value.reduce((total, group) => total + group.completed, 0)
return totalTasks > 0 ? (completedTasks / totalTasks) * 100 : 0
})

View File

@@ -129,33 +129,36 @@ function globToRegex(pattern: string, flags: string = ''): RegExp {
}
// 通用过滤
const getFilteredItems = (type: 'dir' | 'file') => {
const filteredItems = computed(() => {
const filterValue = filter.value
if (!filterValue) {
return items.value.filter(item => item.type === type)
return items.value
}
// 通配符模式
if (filterValue.includes('*') || filterValue.includes('?')) {
const flags = ignoreCase.value ? 'i' : ''
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) {
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 {
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')
@@ -716,7 +719,7 @@ onUnmounted(() => {
class="text-high-emphasis file-list-container"
: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 }">
<VHover>
<template #default="hover">

View File

@@ -14,6 +14,11 @@ const display = useDisplay()
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
const { availableHeight } = useAvailableHeight(48, 300)
@@ -132,37 +137,6 @@ async function loadRootDirectories() {
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(
() => props.currentPath,
@@ -224,38 +198,51 @@ const rootDirectories = computed(() => {
return treeCache.value['/'] || []
})
// 扁平化的目录树
const flattenedDirectories = computed(() => {
return getAllDirectories()
})
// 只生成当前可见的目录行,避免折叠/隐藏节点继续留在 DOM 中
const visibleTreeRows = computed<TreeRow[]>(() => {
const rows: TreeRow[] = [{ type: 'root', key: 'root', level: 0 }]
// 检查路径是否为指定目录的子目录或后代
function isChildOrDescendant(path: string, ancestorPath: string) {
if (!path || !ancestorPath) return false
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
if (loading.value['/']) {
rows.push({ type: 'loading', key: 'loading:/', path: '/', level: 0 })
return rows
}
// 计算路径中斜杠的数量差异
const pathParts = path.split('/').filter(p => p).length
const ancestorParts = ancestorPath.split('/').filter(p => p).length
rootDirectories.value.forEach(dir => addVisibleDirectoryRows(dir, 0, rows))
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>
<VCard class="file-navigator rounded-e-0 rounded-t-0" v-if="!isMobile" :height="`${availableHeight}px`">
<div class="tree-container">
<!-- 根目录项 -->
<div
class="tree-item root-item"
:class="{ 'active': currentPath === '/' }"
@click="
handleFolderClick({
storage: storage,
type: 'dir',
name: '/',
path: '/',
})
"
>
<div class="folder-content">
<VIcon icon="mdi-home" class="me-2" color="primary" />
<span>{{ t('file.rootDirectory') }}</span>
</div>
</div>
<!-- 加载根目录 -->
<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>
<VVirtualScroll :items="visibleTreeRows" :item-height="32" class="tree-container">
<template #default="{ item }">
<div
v-if="item.type === 'root'"
:key="item.key"
class="tree-item root-item"
:class="{ 'active': currentPath === '/' }"
@click="
handleFolderClick({
storage: storage,
type: 'dir',
name: '/',
path: '/',
})
"
>
<div class="folder-content">
<VIcon icon="mdi-home" class="me-2" color="primary" />
<span>{{ t('file.rootDirectory') }}</span>
</div>
</div>
<!-- 子目录容器 - 如果该目录被展开显示其所有子目录 -->
<div v-if="isFolderExpanded(directory.path || '')">
<!-- 加载中状态 -->
<div v-if="loading[directory.path || '']" class="tree-loading pl-8">
<VProgressCircular indeterminate size="14" color="primary" class="ma-2" />
<span class="text-caption">{{ t('common.loading') }}</span>
</div>
<div
v-else-if="item.type === 'loading'"
:key="item.key"
class="tree-loading"
:style="getTreeRowStyle(item.level)"
>
<VProgressCircular indeterminate size="14" color="primary" class="ma-2" />
<span class="text-caption">
{{ item.path === '/' ? t('file.loadingDirectoryStructure') : t('common.loading') }}
</span>
</div>
<!-- 所有层级的子目录列表 -->
<div v-else>
<!-- 遍历所有扁平化的目录列表查找对应层级的目录 -->
<div
v-for="item in flattenedDirectories"
:key="item.dir.path"
v-show="isChildOrDescendant(item.dir.path || '', directory.path || '')"
class="tree-item"
:class="{ 'active': currentPath === item.dir.path }"
:style="{ paddingLeft: 16 + getIndentLevel(item.dir.path || '', directory.path || '') * 12 + 'px' }"
>
<!-- 展开/折叠按钮 -->
<div class="folder-toggle" @click.stop="toggleFolder(item.dir.path || '')">
<VProgressCircular
v-if="loading[item.dir.path || '']"
indeterminate
size="14"
width="2"
color="primary"
/>
<VIcon
v-else
size="small"
:icon="isFolderExpanded(item.dir.path || '') ? 'mdi-chevron-down' : 'mdi-chevron-right'"
/>
</div>
<!-- 文件夹图标和名称 -->
<div class="folder-content" @click.stop="handleFolderClick(item.dir)">
<VIcon
size="small"
: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
v-else
:key="item.key"
class="tree-item"
:class="{ 'active': currentPath === item.dir.path }"
:style="getTreeRowStyle(item.level)"
>
<div class="folder-toggle" @click.stop="toggleFolder(item.dir.path || '')">
<VProgressCircular
v-if="loading[item.dir.path || '']"
indeterminate
size="14"
width="2"
color="primary"
/>
<VIcon
v-else
size="small"
:icon="isFolderExpanded(item.dir.path || '') ? 'mdi-chevron-down' : 'mdi-chevron-right'"
/>
</div>
<div class="folder-content" @click.stop="handleFolderClick(item.dir)">
<VIcon
size="small"
: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>
</template>
</div>
</VVirtualScroll>
</VCard>
</template>
@@ -402,8 +347,8 @@ onMounted(async () => {
}
.tree-container {
overflow: hidden auto;
flex: 1;
min-block-size: 0;
}
.tree-item-container {

View File

@@ -39,6 +39,8 @@ interface SearchParams {
sites: string
}
const resourceSearchParamsStorageKey = 'MP_ResourceSearchParams'
function createSearchParams(query: LocationQuery): SearchParams {
return {
keyword: query?.keyword?.toString() ?? '',
@@ -51,15 +53,62 @@ function createSearchParams(query: LocationQuery): SearchParams {
}
}
function getSearchParamsKey(params: SearchParams): string {
return JSON.stringify(params)
function normalizeSearchParams(params?: Partial<SearchParams> | null): SearchParams {
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 {
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或标题
const keyword = computed(() => activeSearchParams.value.keyword)
@@ -552,13 +601,17 @@ function changeViewType(newType: string) {
}
// 获取搜索列表数据
async function fetchData(options: { force?: boolean } = {}) {
const currentSearchParams = { ...activeSearchParams.value }
async function fetchData(options: { force?: boolean; params?: SearchParams } = {}) {
const currentSearchParams = { ...(options.params ?? activeSearchParams.value) }
if (hasSearchKeyword(currentSearchParams)) {
activeSearchParams.value = { ...currentSearchParams }
rememberSearchParams(currentSearchParams)
}
const requestToken = options.force || Boolean(currentSearchParams.keyword) ? createSearchRequestToken() : undefined
try {
enableFilterAnimation.value = true
if (!currentSearchParams.keyword) {
if (!hasSearchKeyword(currentSearchParams)) {
// 查询上次搜索结果
const results = await api.get('search/last', {
params: requestToken ? { _ts: requestToken } : undefined,
@@ -593,11 +646,12 @@ async function fetchData(options: { force?: boolean } = {}) {
// 重新搜索(使用相同参数重新触发搜索)
async function refreshSearch() {
if (isRefreshing.value || progressActive.value) return
const refreshParams = lastSearchParams.value ?? activeSearchParams.value
isRefreshing.value = true
try {
// 重新搜索时退出 AI 视图,其余状态由 fetchData 内部重置
showingAiResults.value = false
await fetchData({ force: true })
await fetchData({ force: true, params: refreshParams })
} catch (error) {
console.error('重新搜索失败:', error)
} finally {
@@ -885,7 +939,7 @@ watch(
if (Object.keys(query).length === 0) return
const nextSearchParams = createSearchParams(query)
if (getSearchParamsKey(nextSearchParams) === getSearchParamsKey(activeSearchParams.value)) return
if (!hasSearchKeyword(nextSearchParams)) return
activeSearchParams.value = nextSearchParams
void fetchData()

View File

@@ -180,9 +180,10 @@ const pageRange = [
{ title: '100', value: 100 },
{ title: '500', value: 500 },
{ title: '1000', value: 1000 },
{ title: 'All', value: -1 },
]
const pageRangeValues = pageRange.map(item => item.value)
// 数据列表
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))
@@ -270,7 +271,7 @@ const TransferDict: { [key: string]: string } = {
// 分页提示
const pageTip = computed(() => {
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 {
begin,
end,
@@ -280,7 +281,7 @@ const pageTip = computed(() => {
// 分页总数
const totalPage = computed(() => {
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
}
function ensurePageSize(value: any, defaultValue: number = 50) {
const pageSize = ensureNumber(value, defaultValue)
return pageRangeValues.includes(pageSize) ? pageSize : defaultValue
}
// 按标题分组后的选中数量统计,键为标题,值为对应分组的选中数
const selectedCountsGroupedByTitle = computed(() => {
return selected.value.reduce(