diff --git a/src/components/cards/SubscribeCard.vue b/src/components/cards/SubscribeCard.vue
index 89337343..c0290805 100644
--- a/src/components/cards/SubscribeCard.vue
+++ b/src/components/cards/SubscribeCard.vue
@@ -29,6 +29,10 @@ const props = defineProps({
type: Boolean,
default: false,
},
+ sortable: {
+ type: Boolean,
+ default: false,
+ },
})
// 从 provide 中获取全局设置
@@ -266,6 +270,7 @@ watch(
(newOpenState, _) => {
if (newOpenState) editSubscribeDialog()
},
+ { immediate: true },
)
// 监听订阅状态
@@ -380,7 +385,7 @@ function handleCardClick() {
diff --git a/src/components/misc/VirtualCardGrid.vue b/src/components/misc/VirtualCardGrid.vue
index e12b5c8c..c149eb23 100644
--- a/src/components/misc/VirtualCardGrid.vue
+++ b/src/components/misc/VirtualCardGrid.vue
@@ -4,6 +4,8 @@ const props = withDefaults(
items: any[]
minItemWidth?: number
itemAspectRatio?: number
+ estimatedItemHeight?: number
+ scrollToIndex?: number
gap?: number
overscanRows?: number
getItemKey?: (item: any, index: number) => string | number
@@ -11,6 +13,8 @@ const props = withDefaults(
{
minItemWidth: 144,
itemAspectRatio: 1.5,
+ estimatedItemHeight: undefined,
+ scrollToIndex: undefined,
gap: 16,
overscanRows: 4,
getItemKey: undefined,
@@ -27,7 +31,8 @@ const endIndex = ref(0)
let resizeObserver: ResizeObserver | null = null
let animationFrameId: number | null = null
-const rowStep = computed(() => itemHeight.value + props.gap)
+const effectiveItemHeight = computed(() => props.estimatedItemHeight ?? itemHeight.value)
+const rowStep = computed(() => effectiveItemHeight.value + props.gap)
const totalRows = computed(() => Math.ceil(props.items.length / columnCount.value))
const visibleItems = computed(() => props.items.slice(startIndex.value, endIndex.value))
@@ -104,7 +109,7 @@ function syncVisibleRange() {
const columns = Math.max(1, Math.floor((containerWidth + props.gap) / (props.minItemWidth + props.gap)))
columnCount.value = columns
itemWidth.value = (containerWidth - props.gap * (columns - 1)) / columns
- itemHeight.value = itemWidth.value * props.itemAspectRatio
+ itemHeight.value = props.estimatedItemHeight ?? itemWidth.value * props.itemAspectRatio
const rowHeight = rowStep.value || 1
const containerTop = window.scrollY + container.getBoundingClientRect().top
@@ -129,6 +134,30 @@ function queueSyncVisibleRange() {
})
}
+function scrollToItemIndex(index: number) {
+ if (typeof window === 'undefined') {
+ return
+ }
+
+ const container = containerRef.value
+ if (!container || props.items.length === 0 || index < 0) {
+ return
+ }
+
+ syncVisibleRange()
+
+ const safeIndex = Math.min(index, props.items.length - 1)
+ const targetRow = Math.floor(safeIndex / columnCount.value)
+ const targetTop = window.scrollY + container.getBoundingClientRect().top + targetRow * rowStep.value
+
+ window.scrollTo({
+ top: Math.max(targetTop - props.gap, 0),
+ behavior: 'auto',
+ })
+
+ queueSyncVisibleRange()
+}
+
onMounted(() => {
queueSyncVisibleRange()
window.addEventListener('scroll', queueSyncVisibleRange, { passive: true })
@@ -157,7 +186,13 @@ onUnmounted(() => {
})
watch(
- () => props.items.length,
+ [
+ () => props.items.length,
+ () => props.minItemWidth,
+ () => props.itemAspectRatio,
+ () => props.estimatedItemHeight,
+ () => props.gap,
+ ],
() => {
nextTick(() => {
queueSyncVisibleRange()
@@ -165,6 +200,20 @@ watch(
},
{ immediate: true },
)
+
+watch(
+ [() => props.scrollToIndex, () => props.items.length],
+ ([scrollToIndex]) => {
+ if (scrollToIndex === undefined || scrollToIndex < 0) {
+ return
+ }
+
+ nextTick(() => {
+ scrollToItemIndex(scrollToIndex)
+ })
+ },
+ { immediate: true },
+)
diff --git a/src/locales/en-US.ts b/src/locales/en-US.ts
index 0d25aede..db1bd3ae 100644
--- a/src/locales/en-US.ts
+++ b/src/locales/en-US.ts
@@ -74,6 +74,8 @@ export default {
descending: 'Descending',
versionMismatch: 'The browser cache version is inconsistent with the server version, please try to clear the cache',
clearCache: 'Clear Cache',
+ sortMode: 'Sort Mode',
+ sortModeHint: 'Virtualization is disabled for large lists to allow drag sorting, which may reduce smoothness.',
},
mediaType: {
movie: 'Movie',
diff --git a/src/locales/zh-CN.ts b/src/locales/zh-CN.ts
index 704f36ea..9051ffd6 100644
--- a/src/locales/zh-CN.ts
+++ b/src/locales/zh-CN.ts
@@ -74,6 +74,8 @@ export default {
descending: '降序',
versionMismatch: '浏览器缓存版本与服务端版本不一致,请尝试清除缓存',
clearCache: '清除缓存',
+ sortMode: '排序模式',
+ sortModeHint: '已关闭大列表虚拟渲染,方便拖拽排序,但页面流畅度可能下降。',
},
mediaType: {
movie: '电影',
diff --git a/src/locales/zh-TW.ts b/src/locales/zh-TW.ts
index 88133e12..d87b011e 100644
--- a/src/locales/zh-TW.ts
+++ b/src/locales/zh-TW.ts
@@ -74,6 +74,8 @@ export default {
descending: '降序',
versionMismatch: '瀏覽器快取版本與服務端版本不一致,請嘗試清除快取',
clearCache: '清除快取',
+ sortMode: '排序模式',
+ sortModeHint: '已關閉大列表虛擬渲染,方便拖拽排序,但頁面流暢度可能下降。',
},
mediaType: {
movie: '電影',
diff --git a/src/pages/subscribe.vue b/src/pages/subscribe.vue
index 68e8b721..cc137b18 100644
--- a/src/pages/subscribe.vue
+++ b/src/pages/subscribe.vue
@@ -46,6 +46,9 @@ const searchShareDialog = ref(false)
// 订阅分享统计弹窗
const shareStatisticsDialog = ref(false)
+// 排序模式
+const subscribeSortMode = ref(false)
+
// 订阅过滤词
const subscribeFilter = ref('')
@@ -122,6 +125,10 @@ function openShareStatisticsDialog() {
shareStatisticsDialog.value = true
}
+function toggleSubscribeSortMode() {
+ subscribeSortMode.value = !subscribeSortMode.value
+}
+
const shareKeywordUpdater = debounce((keyword: string) => {
shareKeyword.value = keyword.trim()
}, 300)
@@ -220,6 +227,14 @@ registerHeaderTab({
},
show: computed(() => activeTab.value === 'mysub'),
},
+ {
+ icon: 'mdi-sort-variant',
+ variant: 'text',
+ color: computed(() => (subscribeSortMode.value ? 'warning' : 'gray')),
+ class: 'settings-icon-button',
+ action: toggleSubscribeSortMode,
+ show: computed(() => activeTab.value === 'mysub'),
+ },
{
icon: 'mdi-checkbox-multiple-marked-outline',
variant: 'text',
@@ -267,6 +282,8 @@ onMounted(() => {
:subid="subId"
:keyword="subscribeFilter"
:status-filter="subscribeStatusFilter ?? ''"
+ :sort-mode="subscribeSortMode"
+ @update:sort-mode="subscribeSortMode = $event"
/>
diff --git a/src/utils/siteIconCache.ts b/src/utils/siteIconCache.ts
new file mode 100644
index 00000000..e02b1cbc
--- /dev/null
+++ b/src/utils/siteIconCache.ts
@@ -0,0 +1,52 @@
+type SiteIconCacheEntry = {
+ expiresAt: number
+ value: string
+}
+
+const SITE_ICON_CACHE_TTL = 10 * 60 * 1000
+const siteIconCache = new Map()
+const siteIconRequests = new Map>()
+
+function readCachedSiteIcon(key: string): string | undefined {
+ const entry = siteIconCache.get(key)
+ if (!entry) {
+ return undefined
+ }
+
+ if (entry.expiresAt <= Date.now()) {
+ siteIconCache.delete(key)
+ return undefined
+ }
+
+ return entry.value
+}
+
+export async function getCachedSiteIcon(siteId: string | number, loader: () => Promise): Promise {
+ const cacheKey = String(siteId)
+ const cachedIcon = readCachedSiteIcon(cacheKey)
+ if (cachedIcon !== undefined) {
+ return cachedIcon
+ }
+
+ const currentRequest = siteIconRequests.get(cacheKey)
+ if (currentRequest) {
+ return currentRequest
+ }
+
+ const request = loader()
+ .then(icon => {
+ siteIconCache.set(cacheKey, {
+ expiresAt: Date.now() + SITE_ICON_CACHE_TTL,
+ value: icon,
+ })
+
+ return icon
+ })
+ .finally(() => {
+ siteIconRequests.delete(cacheKey)
+ })
+
+ siteIconRequests.set(cacheKey, request)
+
+ return request
+}
diff --git a/src/views/plugin/PluginCardListView.vue b/src/views/plugin/PluginCardListView.vue
index 9e12bad0..1385c3a7 100644
--- a/src/views/plugin/PluginCardListView.vue
+++ b/src/views/plugin/PluginCardListView.vue
@@ -13,6 +13,7 @@ import PluginMarketSettingDialog from '@/components/dialog/PluginMarketSettingDi
import { useDynamicButton } from '@/composables/useDynamicButton'
import { useI18n } from 'vue-i18n'
import PluginMixedSortCard from '@/components/cards/PluginMixedSortCard.vue'
+import VirtualCardGrid from '@/components/misc/VirtualCardGrid.vue'
import { usePWA } from '@/composables/usePWA'
import { useDynamicHeaderTab } from '@/composables/useDynamicHeaderTab'
@@ -30,6 +31,7 @@ const { appMode } = usePWA()
// 当前标签
const activeTab = ref('installed')
+const sortMode = ref(false)
// 获取插件标签页
const pluginTabs = computed(() => getPluginTabs(t))
@@ -58,6 +60,16 @@ registerHeaderTab({
},
show: computed(() => activeTab.value === 'installed'),
},
+ {
+ icon: 'mdi-sort-variant',
+ variant: 'text',
+ color: computed(() => (sortMode.value ? 'warning' : 'gray')),
+ class: 'settings-icon-button',
+ action: () => {
+ sortMode.value = !sortMode.value
+ },
+ show: computed(() => activeTab.value === 'installed'),
+ },
{
icon: 'mdi-filter-multiple-outline',
variant: 'text',
@@ -249,6 +261,41 @@ const newFolderDialog = ref(false)
// 新文件夹名称
const newFolderName = ref('')
+const pluginByIdMap = computed(() => new Map(dataList.value.map(plugin => [plugin.id, plugin])))
+const orderValueMap = computed(() => {
+ const map = new Map()
+
+ orderConfig.value.forEach((item, index) => {
+ map.set(`${item.type || 'plugin'}:${item.id}`, item.order ?? index)
+ })
+
+ return map
+})
+
+const folderedPluginIds = computed(() => {
+ const pluginIds = new Set()
+
+ Object.values(pluginFolders.value).forEach(folderData => {
+ const plugins = Array.isArray(folderData) ? folderData : folderData.plugins || []
+ plugins.forEach((pluginId: string) => pluginIds.add(pluginId))
+ })
+
+ return pluginIds
+})
+
+const canDragSort = computed(() => sortMode.value && activeTab.value === 'installed')
+const shouldVirtualizeInstalledMainList = computed(() => !sortMode.value && !currentFolder.value)
+const shouldVirtualizeInstalledFolderList = computed(() => !sortMode.value && !!currentFolder.value)
+const installedScrollToIndex = computed(() => {
+ if (sortMode.value || currentFolder.value || !pluginId.value) {
+ return undefined
+ }
+
+ const targetIndex = mixedSortList.value.findIndex(item => item.type === 'plugin' && item.id === pluginId.value)
+
+ return targetIndex >= 0 ? targetIndex : undefined
+})
+
// 获取文件夹内筛选后的插件
const getFilteredFolderPlugins = (folderName: string) => {
const folderData = pluginFolders.value[folderName]
@@ -257,7 +304,7 @@ const getFilteredFolderPlugins = (folderName: string) => {
// 获取文件夹内的插件并应用筛选条件
const folderPlugins: Plugin[] = []
folderPluginIds.forEach((pluginId: string) => {
- const plugin = dataList.value.find(p => p.id === pluginId)
+ const plugin = pluginByIdMap.value.get(pluginId)
if (plugin) {
folderPlugins.push(plugin)
}
@@ -288,12 +335,7 @@ const getFilteredFolderPlugins = (folderName: string) => {
const displayedPlugins = computed(() => {
if (!currentFolder.value) {
// 主列表:显示未归类的插件
- const folderedPluginIds = new Set()
- Object.values(pluginFolders.value).forEach(folderData => {
- const plugins = Array.isArray(folderData) ? folderData : folderData.plugins || []
- plugins.forEach((pid: string) => folderedPluginIds.add(pid))
- })
- return filteredDataList.value.filter(plugin => !folderedPluginIds.has(plugin.id))
+ return filteredDataList.value.filter(plugin => !folderedPluginIds.value.has(plugin.id))
} else {
// 文件夹内:返回筛选后的插件
return getFilteredFolderPlugins(currentFolder.value)
@@ -365,23 +407,21 @@ function updateMixedSortList() {
// 添加文件夹项目
displayedFolders.value.forEach(folder => {
- const orderItem = orderConfig.value.find((item: any) => item.type === 'folder' && item.id === folder.name)
allItems.push({
type: 'folder',
id: folder.name,
data: folder,
- order: orderItem?.order ?? 999,
+ order: orderValueMap.value.get(`folder:${folder.name}`) ?? 999,
})
})
// 添加插件项目
displayedPlugins.value.forEach(plugin => {
- const orderItem = orderConfig.value.find((item: any) => item.type === 'plugin' && item.id === plugin.id)
allItems.push({
type: 'plugin',
id: plugin.id || '',
data: plugin,
- order: orderItem?.order ?? 999,
+ order: orderValueMap.value.get(`plugin:${plugin.id}`) ?? 999,
})
})
@@ -463,9 +503,10 @@ function sortPluginOrder() {
return
}
dataList.value.sort((a, b) => {
- const aIndex = orderConfig.value.findIndex((item: { id: string }) => item.id === a.id)
- const bIndex = orderConfig.value.findIndex((item: { id: string }) => item.id === b.id)
- return (aIndex === -1 ? 999 : aIndex) - (bIndex === -1 ? 999 : bIndex)
+ const aIndex = orderValueMap.value.get(`plugin:${a.id}`) ?? Number.MAX_SAFE_INTEGER
+ const bIndex = orderValueMap.value.get(`plugin:${b.id}`) ?? Number.MAX_SAFE_INTEGER
+
+ return aIndex - bIndex
})
}
@@ -505,7 +546,7 @@ async function saveMixedSortOrder() {
Object.values(pluginFolders.value).forEach(folderData => {
const plugins = Array.isArray(folderData) ? folderData : folderData.plugins || []
plugins.forEach((id: string) => {
- const folderPlugin = dataList.value.find(p => p.id === id)
+ const folderPlugin = pluginByIdMap.value.get(id)
if (folderPlugin && !newPluginOrder.find(p => p.id === id)) {
newPluginOrder.push(folderPlugin)
}
@@ -994,12 +1035,8 @@ async function loadPluginFolders() {
// 设置文件夹排序 - 使用全局排序配置
const folderNames = Object.keys(processedFolders)
folderOrder.value = folderNames.sort((a, b) => {
- // 从全局排序配置中查找文件夹的order
- const aOrderItem = orderConfig.value.find((item: any) => item.type === 'folder' && item.id === a)
- const bOrderItem = orderConfig.value.find((item: any) => item.type === 'folder' && item.id === b)
-
- const aOrder = aOrderItem?.order ?? processedFolders[a].order ?? 999
- const bOrder = bOrderItem?.order ?? processedFolders[b].order ?? 999
+ const aOrder = orderValueMap.value.get(`folder:${a}`) ?? processedFolders[a].order ?? 999
+ const bOrder = orderValueMap.value.get(`folder:${b}`) ?? processedFolders[b].order ?? 999
return aOrder - bOrder
})
@@ -1228,7 +1265,7 @@ async function handleDropToFolder(event: DragEvent, folderName: string) {
}
// 验证插件ID
- const plugin = filteredDataList.value.find(p => p.id === pluginId)
+ const plugin = pluginByIdMap.value.get(pluginId)
if (!plugin) {
return
@@ -1485,6 +1522,9 @@ function onDragStartPlugin(evt: any) {
+
+ {{ t('common.sortModeHint') }}
+
@@ -1492,6 +1532,7 @@ function onDragStartPlugin(evt: any) {
renameFolder(oldName, newName)"
@@ -1520,11 +1562,40 @@ function onDragStartPlugin(evt: any) {
/>
+
+
+ renameFolder(oldName, newName)"
+ @update-folder-config="(folderName, config) => updateFolderConfig(folderName, config)"
+ @refresh-data="refreshData"
+ @action-done="
+ pluginId => {
+ pluginActions[pluginId] = false
+ }
+ "
+ @drop-to-folder="(event, folderName) => handleDropToFolder(event, folderName)"
+ />
+
+
+
+
+ {
+ pluginActions[pluginId] = false
+ }
+ "
+ @remove-from-folder="removeFromFolder"
+ />
+
+
@@ -1580,14 +1676,17 @@ function onDragStartPlugin(evt: any) {
>
-
+
>(() => {
+ const map: Record = {}
+
+ userDataList.value.forEach(userData => {
+ if (userData.domain) {
+ map[userData.domain] = userData
+ }
+ })
+
+ return map
+})
+
+const canDragSort = computed(() => sortMode.value && filterOption.value === 'all')
+const shouldVirtualizeList = computed(() => !sortMode.value)
+
// 当前筛选选项的显示信息
const currentFilter = computed(() => {
return filterOptions.value.find(option => option.value === filterOption.value)
@@ -106,12 +123,13 @@ async function fetchData() {
try {
loading.value = true
siteList.value = await api.get('site/')
- loading.value = false
isRefreshed.value = true
// 获取站点列表后,获取统计数据
await fetchSiteStats()
} catch (error) {
console.error(error)
+ } finally {
+ loading.value = false
}
}
@@ -182,16 +200,6 @@ async function savaSitesPriority() {
}
}
-// 根据站点ID获取站点数据
-function getUserData(domain: string) {
- return userDataList.value.find(userData => userData.domain === domain)
-}
-
-// 根据站点域名获取统计数据
-function getSiteStats(domain: string) {
- return siteStatsList.value[domain] || {}
-}
-
// 处理站点统计数据刷新请求
async function handleRefreshStats(domain?: string) {
if (domain) {
@@ -220,6 +228,10 @@ function selectFilter(value: string) {
filterMenu.value = false
}
+function toggleSortMode() {
+ sortMode.value = !sortMode.value
+}
+
// 导出站点数据
async function exportSites() {
try {
@@ -284,6 +296,16 @@ onActivated(() => {
}
})
+watch(
+ () => filterOption.value,
+ value => {
+ if (value !== 'all' && sortMode.value) {
+ sortMode.value = false
+ }
+ },
+ { immediate: true },
+)
+
// 使用动态按钮钩子
useDynamicButton({
icon: 'mdi-web-plus',
@@ -321,6 +343,17 @@ useDynamicButton({
{{ t('site.statistics') }}
+
+
+
+ {{ t('common.sortMode') }}
+
+
@@ -362,28 +395,52 @@ useDynamicButton({