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
+40 -24
View File
@@ -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
}) })
+12 -9
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 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">
+110 -165
View File
@@ -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
View File
@@ -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()
+10 -4
View File
@@ -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(