mirror of
https://github.com/jxxghp/MoviePilot-Frontend.git
synced 2026-06-25 17:44:13 +08:00
perf: reduce frontend memory pressure and startup cost
Limit long-lived page and component retention while virtualizing large card views to keep runtime memory lower. Defer heavy editor, chart, workflow, calendar, and icon code so the app loads less JavaScript up front.
This commit is contained in:
@@ -76,6 +76,7 @@
|
||||
"@iconify-json/lucide": "^1.2.85",
|
||||
"@iconify-json/material-symbols": "^1.2.51",
|
||||
"@iconify-json/mdi": "^1.1.52",
|
||||
"@iconify-json/tabler": "^1.2.23",
|
||||
"@iconify/tools": "^4.0.4",
|
||||
"@iconify/vue": "^4.3.0",
|
||||
"@intlify/unplugin-vue-i18n": "^6.0.3",
|
||||
|
||||
@@ -17,6 +17,7 @@ import { createRequire } from 'node:module'
|
||||
|
||||
// Get current directory
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url))
|
||||
const projectSrcDir = join(__dirname, '..')
|
||||
|
||||
// Create require function for importing JSON files in ESM
|
||||
const require = createRequire(import.meta.url)
|
||||
@@ -86,36 +87,12 @@ const sources: BundleScriptConfig = {
|
||||
],
|
||||
|
||||
icons: [
|
||||
// 'mdi:home',
|
||||
// 'mdi:account',
|
||||
// 'mdi:login',
|
||||
// 'mdi:logout',
|
||||
// 'octicon:book-24',
|
||||
// 'octicon:code-square-24',
|
||||
'lucide:sparkles',
|
||||
'material-symbols:passkey',
|
||||
'line-md:loading-twotone-loop',
|
||||
],
|
||||
|
||||
json: [
|
||||
// Custom JSON file
|
||||
// 'json/gg.json',
|
||||
|
||||
// Iconify JSON file (@iconify/json is a package name, /json/ is directory where files are, then filename)
|
||||
require.resolve('@iconify-json/mdi/icons.json'),
|
||||
|
||||
// Custom file with only few icons
|
||||
// {
|
||||
// filename: require.resolve('@iconify-json/line-md/icons.json'),
|
||||
// icons: [
|
||||
// 'home-twotone-alt',
|
||||
// 'github',
|
||||
// 'document-list',
|
||||
// 'document-code',
|
||||
// 'image-twotone',
|
||||
// ],
|
||||
// },
|
||||
],
|
||||
json: [],
|
||||
}
|
||||
|
||||
// Iconify component (this changes import statement in generated file)
|
||||
@@ -133,6 +110,15 @@ const target = join(__dirname, 'icons-bundle.js');
|
||||
*/
|
||||
// eslint-disable-next-line sonarjs/cognitive-complexity
|
||||
(async function () {
|
||||
const scannedIcons = await collectUsedIcons(projectSrcDir)
|
||||
|
||||
if (sources.icons) {
|
||||
sources.icons.push(...scannedIcons)
|
||||
sources.icons = Array.from(new Set(sources.icons)).sort()
|
||||
} else {
|
||||
sources.icons = scannedIcons
|
||||
}
|
||||
|
||||
let bundle = commonJS
|
||||
? `const { addCollection } = require('${component}');\n\n`
|
||||
: `import { addCollection } from '${component}';\n\n`
|
||||
@@ -280,6 +266,56 @@ const target = join(__dirname, 'icons-bundle.js');
|
||||
console.error(err)
|
||||
})
|
||||
|
||||
async function collectUsedIcons(rootDir: string): Promise<string[]> {
|
||||
const icons = new Set<string>()
|
||||
const files = await walkDirectory(rootDir)
|
||||
const sourceFiles = files.filter(file => /\.(vue|ts|js|tsx|jsx)$/.test(file))
|
||||
|
||||
for (const file of sourceFiles) {
|
||||
if (file.includes('/@iconify/')) {
|
||||
continue
|
||||
}
|
||||
|
||||
const content = await fs.readFile(file, 'utf8')
|
||||
|
||||
for (const match of content.matchAll(/\b(lucide|material-symbols|line-md|tabler):([a-z0-9-]+)\b/g)) {
|
||||
icons.add(`${match[1]}:${match[2]}`)
|
||||
}
|
||||
|
||||
for (const match of content.matchAll(/\bmdi:([a-z0-9-]+)\b/g)) {
|
||||
icons.add(`mdi:${match[1]}`)
|
||||
}
|
||||
|
||||
for (const match of content.matchAll(/\btabler-([a-z0-9-]+)\b/g)) {
|
||||
icons.add(`tabler:${match[1]}`)
|
||||
}
|
||||
|
||||
for (const match of content.matchAll(/\bmdi-([a-z0-9-]+)\b/g)) {
|
||||
icons.add(`mdi:${match[1]}`)
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(icons).sort()
|
||||
}
|
||||
|
||||
async function walkDirectory(dir: string): Promise<string[]> {
|
||||
const entries = await fs.readdir(dir, { withFileTypes: true })
|
||||
const files: string[] = []
|
||||
|
||||
for (const entry of entries) {
|
||||
const fullPath = join(dir, entry.name)
|
||||
|
||||
if (entry.isDirectory()) {
|
||||
files.push(...(await walkDirectory(fullPath)))
|
||||
continue
|
||||
}
|
||||
|
||||
files.push(fullPath)
|
||||
}
|
||||
|
||||
return files
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove metadata from icon set
|
||||
*/
|
||||
|
||||
@@ -19,6 +19,11 @@ export default defineComponent({
|
||||
const scrollDistance = ref(window.scrollY)
|
||||
const isDialogOpen = ref(false)
|
||||
const wasScrolledBeforeDialog = ref(false)
|
||||
let dialogObserver: MutationObserver | null = null
|
||||
|
||||
const handleScroll = () => {
|
||||
scrollDistance.value = window.scrollY
|
||||
}
|
||||
|
||||
// 监听弹窗状态变化
|
||||
const checkDialogState = () => {
|
||||
@@ -32,21 +37,25 @@ export default defineComponent({
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
window.addEventListener('scroll', () => {
|
||||
scrollDistance.value = window.scrollY
|
||||
})
|
||||
window.addEventListener('scroll', handleScroll)
|
||||
|
||||
// 初始检查弹窗状态
|
||||
checkDialogState()
|
||||
|
||||
// 监听 DOM 变化以检测弹窗状态
|
||||
const observer = new MutationObserver(checkDialogState)
|
||||
observer.observe(document.documentElement, {
|
||||
dialogObserver = new MutationObserver(checkDialogState)
|
||||
dialogObserver.observe(document.documentElement, {
|
||||
attributes: true,
|
||||
attributeFilter: ['class'],
|
||||
})
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener('scroll', handleScroll)
|
||||
dialogObserver?.disconnect()
|
||||
dialogObserver = null
|
||||
})
|
||||
|
||||
return () => {
|
||||
// 👉 Vertical nav
|
||||
const verticalNav = h(
|
||||
|
||||
50
src/App.vue
50
src/App.vue
@@ -12,6 +12,7 @@ import { globalLoadingStateManager } from '@/utils/loadingStateManager'
|
||||
import { addBackgroundTimer, removeBackgroundTimer } from '@/utils/backgroundManager'
|
||||
import PWAInstallPrompt from '@/components/PWAInstallPrompt.vue'
|
||||
import { themeManager } from '@/utils/themeManager'
|
||||
import { configureApexChartsTheme } from '@/utils/apexCharts'
|
||||
|
||||
// 生效主题
|
||||
const { global: globalTheme } = useTheme()
|
||||
@@ -41,13 +42,6 @@ const isTransparentTheme = computed(() => globalTheme.name.value === 'transparen
|
||||
// 心跳检测
|
||||
let heartbeatInterval: number | null = null
|
||||
|
||||
// ApexCharts 全局配置
|
||||
declare global {
|
||||
interface Window {
|
||||
Apex: any
|
||||
}
|
||||
}
|
||||
|
||||
// 启动心跳
|
||||
const startHeartbeat = () => {
|
||||
// 如果已经有心跳,则先停止
|
||||
@@ -75,44 +69,6 @@ const stopHeartbeat = () => {
|
||||
}
|
||||
}
|
||||
|
||||
// 配置 ApexCharts 全局选项
|
||||
function configureApexCharts() {
|
||||
if (typeof window !== 'undefined' && window.Apex) {
|
||||
try {
|
||||
// 获取当前主题
|
||||
const currentTheme = globalTheme.name.value
|
||||
const isDark = currentTheme === 'dark' || currentTheme === 'transparent'
|
||||
|
||||
// 数据标签
|
||||
window.Apex.dataLabels = {
|
||||
formatter: function (_: number, { seriesIndex, w }: { seriesIndex: number; w: any }) {
|
||||
// 如果有小数点,保留两位小数,否则保留整数
|
||||
const data = w.config.series[seriesIndex]
|
||||
return data.toFixed(data % 1 === 0 ? 0 : 1)
|
||||
},
|
||||
}
|
||||
// 图例
|
||||
window.Apex.legend = {
|
||||
labels: {
|
||||
useSeriesColors: true,
|
||||
},
|
||||
}
|
||||
// 标题
|
||||
window.Apex.title = {
|
||||
style: {
|
||||
color: 'rgba(var(--v-theme-on-surface), var(--v-high-emphasis-opacity))',
|
||||
},
|
||||
}
|
||||
// 鼠标悬浮提示
|
||||
window.Apex.tooltip = {
|
||||
theme: isDark ? 'dark' : 'light',
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('ApexCharts 全局配置失败:', error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 更新data-theme属性以便CSS选择器能正确匹配
|
||||
function updateHtmlThemeAttribute(themeName: string) {
|
||||
document.documentElement.setAttribute('data-theme', themeName)
|
||||
@@ -250,7 +206,7 @@ onMounted(async () => {
|
||||
}
|
||||
|
||||
// 配置 ApexCharts
|
||||
configureApexCharts()
|
||||
configureApexChartsTheme(globalTheme.name.value)
|
||||
|
||||
// 初始化data-theme属性
|
||||
updateHtmlThemeAttribute(globalTheme.name.value)
|
||||
@@ -265,7 +221,7 @@ onMounted(async () => {
|
||||
// 更新HTML主题属性
|
||||
updateHtmlThemeAttribute(newTheme)
|
||||
// 重新配置ApexCharts以适应新主题
|
||||
configureApexCharts()
|
||||
configureApexChartsTheme(newTheme)
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@@ -14,6 +14,12 @@ import SubscribeSeasonDialog from '../dialog/SubscribeSeasonDialog.vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { mediaTypeDict } from '@/api/constants'
|
||||
import { hasPermission } from '@/utils/permission'
|
||||
import {
|
||||
getCachedMediaExistsStatus,
|
||||
getCachedMediaSubscribeStatus,
|
||||
setCachedMediaExistsStatus,
|
||||
setCachedMediaSubscribeStatus,
|
||||
} from '@/utils/mediaStatusCache'
|
||||
|
||||
// 国际化
|
||||
const { t } = useI18n()
|
||||
@@ -123,6 +129,22 @@ function getMediaId() {
|
||||
else return `${props.media?.mediaid_prefix}:${props.media?.media_id}`
|
||||
}
|
||||
|
||||
function getSubscribeStatusKey(season: number | null = props.media?.season ?? null) {
|
||||
return `${getMediaId()}::${season ?? 'all'}`
|
||||
}
|
||||
|
||||
function getExistsStatusKey() {
|
||||
return [
|
||||
props.media?.tmdb_id ?? '',
|
||||
props.media?.title ?? '',
|
||||
props.media?.year ?? '',
|
||||
props.media?.season ?? '',
|
||||
props.media?.type ?? '',
|
||||
props.media?.mediaid_prefix ?? '',
|
||||
props.media?.media_id ?? '',
|
||||
].join('::')
|
||||
}
|
||||
|
||||
// 角标颜色
|
||||
function getChipColor(type: string) {
|
||||
if (type === '电影') return 'border-blue-500 bg-blue-600'
|
||||
@@ -167,6 +189,7 @@ async function addSubscribe(season: number | null = null, best_version: number =
|
||||
if (result.success) {
|
||||
// 订阅成功
|
||||
isSubscribed.value = true
|
||||
setCachedMediaSubscribeStatus(getSubscribeStatusKey(season), true)
|
||||
}
|
||||
|
||||
// 提示
|
||||
@@ -213,6 +236,7 @@ async function removeSubscribe() {
|
||||
|
||||
if (result.success) {
|
||||
isSubscribed.value = false
|
||||
setCachedMediaSubscribeStatus(getSubscribeStatusKey(props.media?.season ?? null), false)
|
||||
$toast.success(`${props.media?.title} ${t('subscribe.cancelSuccess')}`)
|
||||
} else {
|
||||
$toast.error(`${props.media?.title} ${t('subscribe.cancelFailed', { message: result.message })}`)
|
||||
@@ -227,8 +251,10 @@ async function removeSubscribe() {
|
||||
// 查询当前媒体是否已订阅
|
||||
async function handleCheckSubscribe() {
|
||||
try {
|
||||
const result = await checkSubscribe(props.media?.season ?? null)
|
||||
if (result) isSubscribed.value = true
|
||||
const subscribed = await getCachedMediaSubscribeStatus(getSubscribeStatusKey(props.media?.season ?? null), () =>
|
||||
checkSubscribe(props.media?.season ?? null),
|
||||
)
|
||||
isSubscribed.value = subscribed
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
}
|
||||
@@ -237,17 +263,22 @@ async function handleCheckSubscribe() {
|
||||
// 查询当前媒体是否已入库
|
||||
async function handleCheckExists() {
|
||||
try {
|
||||
const result: { [key: string]: any } = await api.get('mediaserver/exists', {
|
||||
params: {
|
||||
tmdbid: props.media?.tmdb_id,
|
||||
title: props.media?.title,
|
||||
year: props.media?.year,
|
||||
season: props.media?.season,
|
||||
mtype: props.media?.type,
|
||||
},
|
||||
const exists = await getCachedMediaExistsStatus(getExistsStatusKey(), async () => {
|
||||
const result: { [key: string]: any } = await api.get('mediaserver/exists', {
|
||||
params: {
|
||||
tmdbid: props.media?.tmdb_id,
|
||||
title: props.media?.title,
|
||||
year: props.media?.year,
|
||||
season: props.media?.season,
|
||||
mtype: props.media?.type,
|
||||
},
|
||||
})
|
||||
|
||||
return Boolean(result.success)
|
||||
})
|
||||
|
||||
if (result.success) isExists.value = true
|
||||
isExists.value = exists
|
||||
setCachedMediaExistsStatus(getExistsStatusKey(), exists)
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
}
|
||||
@@ -265,12 +296,14 @@ async function checkSubscribe(season: number | null) {
|
||||
},
|
||||
})
|
||||
|
||||
return result.id || null
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
}
|
||||
return Boolean(result.id)
|
||||
} catch (error: any) {
|
||||
if (error?.response?.status === 404) {
|
||||
return false
|
||||
}
|
||||
|
||||
return null
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
// 查询订阅弹窗规则
|
||||
|
||||
@@ -148,7 +148,12 @@ const transferItems = ref<FileItem[]>([])
|
||||
// 当前图片地址
|
||||
const currentImgLink = ref('')
|
||||
|
||||
function revokeCurrentImgLink() {
|
||||
if (!currentImgLink.value) return
|
||||
|
||||
URL.revokeObjectURL(currentImgLink.value)
|
||||
currentImgLink.value = ''
|
||||
}
|
||||
|
||||
// 是否为图片文件
|
||||
const isImage = computed(() => {
|
||||
@@ -287,6 +292,9 @@ async function download(item: FileItem) {
|
||||
if (result) {
|
||||
const downloadUrl = URL.createObjectURL(result)
|
||||
window.open(downloadUrl, '_blank')
|
||||
setTimeout(() => {
|
||||
URL.revokeObjectURL(downloadUrl)
|
||||
}, 60000)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -304,6 +312,7 @@ async function getImgLink(item: FileItem) {
|
||||
const result: Blob = (await inProps.axios.request<Blob, Blob>(config))
|
||||
if (result) {
|
||||
// 创建图片地址
|
||||
revokeCurrentImgLink()
|
||||
currentImgLink.value = URL.createObjectURL(result)
|
||||
}
|
||||
}
|
||||
@@ -314,7 +323,10 @@ watch(
|
||||
async () => {
|
||||
if (isImage.value && isFile.value) {
|
||||
await getImgLink(inProps.item)
|
||||
return
|
||||
}
|
||||
|
||||
revokeCurrentImgLink()
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
@@ -597,6 +609,11 @@ function stopLoadingProgress() {
|
||||
onMounted(() => {
|
||||
list_files()
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
revokeCurrentImgLink()
|
||||
stopLoadingProgress()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
184
src/components/misc/VirtualCardGrid.vue
Normal file
184
src/components/misc/VirtualCardGrid.vue
Normal file
@@ -0,0 +1,184 @@
|
||||
<script setup lang="ts">
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
items: any[]
|
||||
minItemWidth?: number
|
||||
itemAspectRatio?: number
|
||||
gap?: number
|
||||
overscanRows?: number
|
||||
getItemKey?: (item: any, index: number) => string | number
|
||||
}>(),
|
||||
{
|
||||
minItemWidth: 144,
|
||||
itemAspectRatio: 1.5,
|
||||
gap: 16,
|
||||
overscanRows: 4,
|
||||
getItemKey: undefined,
|
||||
},
|
||||
)
|
||||
|
||||
const containerRef = ref<HTMLElement | null>(null)
|
||||
const columnCount = ref(1)
|
||||
const itemWidth = ref(props.minItemWidth)
|
||||
const itemHeight = ref(props.minItemWidth * props.itemAspectRatio)
|
||||
const startIndex = ref(0)
|
||||
const endIndex = ref(0)
|
||||
|
||||
let resizeObserver: ResizeObserver | null = null
|
||||
let animationFrameId: number | null = null
|
||||
|
||||
const rowStep = computed(() => itemHeight.value + props.gap)
|
||||
const totalRows = computed(() => Math.ceil(props.items.length / columnCount.value))
|
||||
|
||||
const visibleItems = computed(() => props.items.slice(startIndex.value, endIndex.value))
|
||||
|
||||
const renderedRowCount = computed(() => {
|
||||
if (!visibleItems.value.length) {
|
||||
return 0
|
||||
}
|
||||
|
||||
return Math.ceil(visibleItems.value.length / columnCount.value)
|
||||
})
|
||||
|
||||
const totalContentHeight = computed(() => {
|
||||
if (!totalRows.value) {
|
||||
return 0
|
||||
}
|
||||
|
||||
return totalRows.value * rowStep.value - props.gap
|
||||
})
|
||||
|
||||
const topPadding = computed(() => {
|
||||
if (!startIndex.value) {
|
||||
return 0
|
||||
}
|
||||
|
||||
return Math.floor(startIndex.value / columnCount.value) * rowStep.value
|
||||
})
|
||||
|
||||
const renderedHeight = computed(() => {
|
||||
if (!renderedRowCount.value) {
|
||||
return 0
|
||||
}
|
||||
|
||||
return renderedRowCount.value * rowStep.value - props.gap
|
||||
})
|
||||
|
||||
const bottomPadding = computed(() => {
|
||||
return Math.max(totalContentHeight.value - topPadding.value - renderedHeight.value, 0)
|
||||
})
|
||||
|
||||
const gridStyle = computed(() => ({
|
||||
columnGap: `${props.gap}px`,
|
||||
gridTemplateColumns: `repeat(${columnCount.value}, minmax(0, 1fr))`,
|
||||
paddingBottom: `${bottomPadding.value}px`,
|
||||
paddingTop: `${topPadding.value}px`,
|
||||
rowGap: `${props.gap}px`,
|
||||
}))
|
||||
|
||||
function resolveItemKey(item: any, index: number) {
|
||||
if (props.getItemKey) {
|
||||
return props.getItemKey(item, startIndex.value + index)
|
||||
}
|
||||
|
||||
return startIndex.value + index
|
||||
}
|
||||
|
||||
function syncVisibleRange() {
|
||||
if (typeof window === 'undefined') {
|
||||
return
|
||||
}
|
||||
|
||||
const container = containerRef.value
|
||||
if (!container || props.items.length === 0) {
|
||||
startIndex.value = 0
|
||||
endIndex.value = 0
|
||||
return
|
||||
}
|
||||
|
||||
const containerWidth = container.clientWidth
|
||||
if (!containerWidth) {
|
||||
return
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
const rowHeight = rowStep.value || 1
|
||||
const containerTop = window.scrollY + container.getBoundingClientRect().top
|
||||
const viewportTop = window.scrollY - containerTop
|
||||
const viewportBottom = viewportTop + window.innerHeight
|
||||
const startRow = Math.max(0, Math.floor(viewportTop / rowHeight) - props.overscanRows)
|
||||
const endRow = Math.min(totalRows.value, Math.ceil(viewportBottom / rowHeight) + props.overscanRows)
|
||||
const endRowExclusive = Math.max(startRow + 1, endRow)
|
||||
|
||||
startIndex.value = Math.min(props.items.length, startRow * columns)
|
||||
endIndex.value = Math.min(props.items.length, endRowExclusive * columns)
|
||||
}
|
||||
|
||||
function queueSyncVisibleRange() {
|
||||
if (typeof window === 'undefined' || animationFrameId !== null) {
|
||||
return
|
||||
}
|
||||
|
||||
animationFrameId = window.requestAnimationFrame(() => {
|
||||
animationFrameId = null
|
||||
syncVisibleRange()
|
||||
})
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
queueSyncVisibleRange()
|
||||
window.addEventListener('scroll', queueSyncVisibleRange, { passive: true })
|
||||
|
||||
resizeObserver = new ResizeObserver(() => {
|
||||
queueSyncVisibleRange()
|
||||
})
|
||||
|
||||
if (containerRef.value) {
|
||||
resizeObserver.observe(containerRef.value)
|
||||
}
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (typeof window !== 'undefined') {
|
||||
window.removeEventListener('scroll', queueSyncVisibleRange)
|
||||
|
||||
if (animationFrameId !== null) {
|
||||
window.cancelAnimationFrame(animationFrameId)
|
||||
animationFrameId = null
|
||||
}
|
||||
}
|
||||
|
||||
resizeObserver?.disconnect()
|
||||
resizeObserver = null
|
||||
})
|
||||
|
||||
watch(
|
||||
() => props.items.length,
|
||||
() => {
|
||||
nextTick(() => {
|
||||
queueSyncVisibleRange()
|
||||
})
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div ref="containerRef" class="virtual-card-grid">
|
||||
<div class="grid" :style="gridStyle">
|
||||
<template v-for="(item, index) in visibleItems" :key="resolveItemKey(item, index)">
|
||||
<slot :item="item" :index="startIndex + index" />
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.virtual-card-grid {
|
||||
inline-size: 100%;
|
||||
}
|
||||
</style>
|
||||
414
src/components/slide/VirtualSlideView.vue
Normal file
414
src/components/slide/VirtualSlideView.vue
Normal file
@@ -0,0 +1,414 @@
|
||||
<script lang="ts" setup>
|
||||
import SlideViewTitle from '@/components/slide/SlideViewTitle.vue'
|
||||
import { useDisplay } from 'vuetify'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
items: any[]
|
||||
itemWidth?: number
|
||||
itemGap?: number
|
||||
overscanItems?: number
|
||||
getItemKey?: (item: any, index: number) => string | number
|
||||
}>(),
|
||||
{
|
||||
itemWidth: 144,
|
||||
itemGap: 16,
|
||||
overscanItems: 4,
|
||||
getItemKey: undefined,
|
||||
},
|
||||
)
|
||||
|
||||
const display = useDisplay()
|
||||
const isTouch = computed(() => display.mobile.value)
|
||||
const injectedProps: any = inject('rankingPropsKey', { linkurl: '', title: '' })
|
||||
|
||||
const slideContentRef = ref<HTMLElement | null>(null)
|
||||
const disabled = ref(0)
|
||||
const slideScrollLeft = ref(0)
|
||||
const isScrolling = ref(false)
|
||||
const startIndex = ref(0)
|
||||
const endIndex = ref(0)
|
||||
|
||||
let resizeObserver: ResizeObserver | null = null
|
||||
let scrollTimeout: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
const scrollTimeoutDuration = 1500
|
||||
const itemStep = computed(() => props.itemWidth + props.itemGap)
|
||||
const visibleItems = computed(() => props.items.slice(startIndex.value, endIndex.value))
|
||||
|
||||
const leadingSpaceWidth = computed(() => startIndex.value * itemStep.value)
|
||||
|
||||
const visibleItemsWidth = computed(() => {
|
||||
if (!visibleItems.value.length) {
|
||||
return 0
|
||||
}
|
||||
|
||||
return visibleItems.value.length * props.itemWidth + Math.max(visibleItems.value.length - 1, 0) * props.itemGap
|
||||
})
|
||||
|
||||
const totalContentWidth = computed(() => {
|
||||
if (!props.items.length) {
|
||||
return 0
|
||||
}
|
||||
|
||||
return props.items.length * props.itemWidth + Math.max(props.items.length - 1, 0) * props.itemGap
|
||||
})
|
||||
|
||||
const trailingSpaceWidth = computed(() => {
|
||||
return Math.max(totalContentWidth.value - leadingSpaceWidth.value - visibleItemsWidth.value, 0)
|
||||
})
|
||||
|
||||
function resolveItemKey(item: any, index: number) {
|
||||
if (props.getItemKey) {
|
||||
return props.getItemKey(item, startIndex.value + index)
|
||||
}
|
||||
|
||||
return startIndex.value + index
|
||||
}
|
||||
|
||||
function resetScrollIndicatorTimer() {
|
||||
isScrolling.value = true
|
||||
if (scrollTimeout) {
|
||||
clearTimeout(scrollTimeout)
|
||||
}
|
||||
|
||||
scrollTimeout = setTimeout(() => {
|
||||
isScrolling.value = false
|
||||
}, scrollTimeoutDuration)
|
||||
}
|
||||
|
||||
function updateVisibleRange() {
|
||||
const element = slideContentRef.value
|
||||
if (!element) {
|
||||
startIndex.value = 0
|
||||
endIndex.value = 0
|
||||
return
|
||||
}
|
||||
|
||||
const viewportWidth = element.clientWidth
|
||||
if (!viewportWidth || !props.items.length) {
|
||||
startIndex.value = 0
|
||||
endIndex.value = Math.min(props.items.length, props.overscanItems)
|
||||
return
|
||||
}
|
||||
|
||||
const firstVisible = Math.max(0, Math.floor(element.scrollLeft / itemStep.value) - props.overscanItems)
|
||||
const lastVisible = Math.min(
|
||||
props.items.length,
|
||||
Math.ceil((element.scrollLeft + viewportWidth) / itemStep.value) + props.overscanItems,
|
||||
)
|
||||
|
||||
startIndex.value = firstVisible
|
||||
endIndex.value = Math.max(firstVisible + 1, lastVisible)
|
||||
}
|
||||
|
||||
function updateDisabledState() {
|
||||
const element = slideContentRef.value
|
||||
if (!element) return
|
||||
|
||||
slideScrollLeft.value = element.scrollLeft
|
||||
|
||||
if (!props.items.length || totalContentWidth.value <= element.clientWidth) {
|
||||
disabled.value = 3
|
||||
} else if (element.scrollLeft === 0) {
|
||||
disabled.value = 0
|
||||
} else if (element.scrollLeft >= element.scrollWidth - element.clientWidth - 2) {
|
||||
disabled.value = 2
|
||||
} else {
|
||||
disabled.value = 1
|
||||
}
|
||||
}
|
||||
|
||||
function syncLayoutState() {
|
||||
updateVisibleRange()
|
||||
updateDisabledState()
|
||||
}
|
||||
|
||||
function slideNext(next: boolean) {
|
||||
const element = slideContentRef.value
|
||||
if (!element) return
|
||||
|
||||
const visibleCount = Math.max(1, Math.trunc(element.clientWidth / itemStep.value))
|
||||
const currentIndex = element.scrollLeft === 0 ? 0 : Math.trunc((element.scrollLeft + itemStep.value / 2) / itemStep.value)
|
||||
let targetLeft = 0
|
||||
|
||||
if (next) {
|
||||
targetLeft = Math.min((currentIndex + visibleCount) * itemStep.value, element.scrollWidth - element.clientWidth)
|
||||
} else {
|
||||
targetLeft = Math.max((currentIndex - visibleCount) * itemStep.value, 0)
|
||||
}
|
||||
|
||||
element.scrollTo({
|
||||
behavior: 'smooth',
|
||||
left: targetLeft,
|
||||
top: 0,
|
||||
})
|
||||
|
||||
resetScrollIndicatorTimer()
|
||||
}
|
||||
|
||||
function handleContentScroll() {
|
||||
syncLayoutState()
|
||||
resetScrollIndicatorTimer()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
syncLayoutState()
|
||||
|
||||
resizeObserver = new ResizeObserver(() => {
|
||||
syncLayoutState()
|
||||
})
|
||||
|
||||
if (slideContentRef.value) {
|
||||
resizeObserver.observe(slideContentRef.value)
|
||||
}
|
||||
|
||||
window.addEventListener('resize', syncLayoutState)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (scrollTimeout) {
|
||||
clearTimeout(scrollTimeout)
|
||||
scrollTimeout = null
|
||||
}
|
||||
|
||||
window.removeEventListener('resize', syncLayoutState)
|
||||
resizeObserver?.disconnect()
|
||||
resizeObserver = null
|
||||
})
|
||||
|
||||
onActivated(() => {
|
||||
if (slideContentRef.value && slideScrollLeft.value !== 0) {
|
||||
slideContentRef.value.scrollLeft = slideScrollLeft.value
|
||||
}
|
||||
|
||||
nextTick(syncLayoutState)
|
||||
})
|
||||
|
||||
watch(
|
||||
() => props.items.length,
|
||||
() => {
|
||||
nextTick(syncLayoutState)
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="slider-container" :class="{ 'is-scrolling': isScrolling }">
|
||||
<div class="slider-header">
|
||||
<slot name="title">
|
||||
<SlideViewTitle />
|
||||
</slot>
|
||||
<RouterLink v-if="injectedProps.linkurl" :to="injectedProps.linkurl" class="view-all-button">
|
||||
<span>更多</span>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" class="arrow-svg">
|
||||
<path d="M8.59,16.58L13.17,12L8.59,7.41L10,6L16,12L10,18L8.59,16.58Z" />
|
||||
</svg>
|
||||
</RouterLink>
|
||||
</div>
|
||||
|
||||
<div class="slider-content-wrapper">
|
||||
<div class="slider-content-container">
|
||||
<div ref="slideContentRef" class="slider-content" tabindex="0" @scroll="handleContentScroll">
|
||||
<div class="virtual-track" :style="{ width: `${totalContentWidth}px` }">
|
||||
<div v-if="leadingSpaceWidth > 0" class="virtual-spacer" :style="{ width: `${leadingSpaceWidth}px` }" />
|
||||
|
||||
<template v-for="(item, index) in visibleItems" :key="resolveItemKey(item, index)">
|
||||
<div
|
||||
class="virtual-slide-item"
|
||||
:style="{
|
||||
marginInlineEnd: index === visibleItems.length - 1 ? '0px' : `${itemGap}px`,
|
||||
width: `${itemWidth}px`,
|
||||
}"
|
||||
>
|
||||
<slot name="item" :item="item" :index="startIndex + index" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div v-if="trailingSpaceWidth > 0" class="virtual-spacer" :style="{ width: `${trailingSpaceWidth}px` }" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<VBtn
|
||||
v-show="disabled !== 0 && disabled !== 3 && !isTouch"
|
||||
class="nav-button nav-button-left"
|
||||
variant="text"
|
||||
icon
|
||||
color="secondary"
|
||||
@click.stop="slideNext(false)"
|
||||
>
|
||||
<svg width="24" height="24" viewBox="0 0 24 24">
|
||||
<path d="M15.41,16.58L10.83,12L15.41,7.41L14,6L8,12L14,18L15.41,16.58Z" />
|
||||
</svg>
|
||||
</VBtn>
|
||||
|
||||
<VBtn
|
||||
v-show="disabled !== 2 && disabled !== 3 && !isTouch"
|
||||
class="nav-button nav-button-right"
|
||||
variant="text"
|
||||
icon
|
||||
color="secondary"
|
||||
@click.stop="slideNext(true)"
|
||||
>
|
||||
<svg width="24" height="24" viewBox="0 0 24 24">
|
||||
<path d="M8.59,16.58L13.17,12L8.59,7.41L10,6L16,12L10,18L8.59,16.58Z" />
|
||||
</svg>
|
||||
</VBtn>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.slider-container {
|
||||
position: relative;
|
||||
margin-block-end: 8px;
|
||||
}
|
||||
|
||||
.slider-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
margin-block-end: 8px;
|
||||
padding-block: 0;
|
||||
padding-inline: 8px;
|
||||
|
||||
& > :first-child {
|
||||
flex-grow: 1;
|
||||
min-inline-size: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.view-all-button {
|
||||
display: inline-flex;
|
||||
flex-shrink: 0;
|
||||
align-items: center;
|
||||
border-radius: 8px;
|
||||
background-color: transparent;
|
||||
color: rgb(var(--v-theme-primary));
|
||||
font-size: 0.85rem;
|
||||
font-weight: 500;
|
||||
padding-block: 5px;
|
||||
padding-inline: 12px;
|
||||
text-decoration: none;
|
||||
transition: all 0.25s ease;
|
||||
|
||||
.arrow-svg {
|
||||
fill: currentcolor;
|
||||
margin-inline-start: 2px;
|
||||
transition: transform 0.3s ease;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
border-color: rgba(var(--v-theme-primary), 0.5);
|
||||
background-color: rgba(var(--v-theme-primary), 0.08);
|
||||
transform: translateY(-1px);
|
||||
|
||||
.arrow-svg {
|
||||
transform: translateX(3px);
|
||||
}
|
||||
}
|
||||
|
||||
span {
|
||||
margin-inline-end: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
.slider-content-wrapper {
|
||||
position: relative;
|
||||
inline-size: 100%;
|
||||
}
|
||||
|
||||
.slider-content-container {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
inline-size: 100%;
|
||||
}
|
||||
|
||||
.slider-content {
|
||||
overflow: scroll hidden !important;
|
||||
-ms-overflow-style: none !important;
|
||||
overscroll-behavior-x: contain !important;
|
||||
padding-block: 8px;
|
||||
padding-inline: 12px;
|
||||
scroll-behavior: smooth;
|
||||
scrollbar-width: none !important;
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.virtual-track {
|
||||
display: flex;
|
||||
inline-size: max-content;
|
||||
}
|
||||
|
||||
.virtual-slide-item,
|
||||
.virtual-spacer {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.nav-button {
|
||||
position: absolute;
|
||||
z-index: 20;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0;
|
||||
border-radius: 50%;
|
||||
backdrop-filter: blur(8px);
|
||||
background-color: rgba(var(--v-theme-background), 0.3);
|
||||
block-size: 36px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 8%);
|
||||
cursor: pointer;
|
||||
inline-size: 36px;
|
||||
inset-block-start: 50%;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
text-shadow: 0 1px 2px rgba(0, 0, 0, 10%);
|
||||
transform: translateY(-50%);
|
||||
transition: opacity 0.3s ease, transform 0.3s cubic-bezier(0.25, 0.8, 0.25, 1), background-color 0.3s ease,
|
||||
box-shadow 0.3s ease, border-color 0.3s ease;
|
||||
|
||||
svg {
|
||||
block-size: 22px;
|
||||
fill: currentcolor;
|
||||
filter: none;
|
||||
inline-size: 22px;
|
||||
opacity: 0.7;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
color: rgb(var(--v-theme-primary));
|
||||
transform: translateY(-50%) scale(1.05);
|
||||
|
||||
svg {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.nav-button-left {
|
||||
inset-inline-start: 8px;
|
||||
}
|
||||
|
||||
.nav-button-right {
|
||||
inset-inline-end: 8px;
|
||||
}
|
||||
|
||||
.slider-container.is-scrolling .nav-button {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
@media (hover: hover) {
|
||||
.slider-container:hover .nav-button {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -28,11 +28,24 @@ export function useBackgroundOptimization() {
|
||||
// 使用独立的SSE管理器,确保每个监听器都有独立的连接
|
||||
const manager = sseManagerSingleton.getIndependentManager(url, listenerId, options)
|
||||
const isConnected = ref(false)
|
||||
let connectTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
const cleanup = () => {
|
||||
if (connectTimer) {
|
||||
clearTimeout(connectTimer)
|
||||
connectTimer = null
|
||||
}
|
||||
|
||||
manager.removeMessageListener(listenerId)
|
||||
sseManagerSingleton.closeIndependentManager(url, listenerId)
|
||||
isConnected.value = false
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
// 延迟建立连接,确保组件完全挂载
|
||||
const connectDelay = options?.connectDelay || 100
|
||||
setTimeout(() => {
|
||||
connectTimer = setTimeout(() => {
|
||||
connectTimer = null
|
||||
try {
|
||||
manager.addMessageListener(listenerId, event => {
|
||||
messageHandler(event)
|
||||
@@ -44,15 +57,12 @@ export function useBackgroundOptimization() {
|
||||
}, connectDelay)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
manager.removeMessageListener(listenerId)
|
||||
isConnected.value = false
|
||||
})
|
||||
onUnmounted(cleanup)
|
||||
|
||||
return {
|
||||
manager,
|
||||
readyState: () => manager.readyState,
|
||||
close: () => manager.removeMessageListener(listenerId),
|
||||
close: cleanup,
|
||||
isConnected,
|
||||
forceReconnect: () => manager.forceReconnect(),
|
||||
}
|
||||
@@ -104,21 +114,31 @@ export function useBackgroundOptimization() {
|
||||
) => {
|
||||
// 使用独立的SSE管理器,确保每个监听器都有独立的连接
|
||||
const manager = sseManagerSingleton.getIndependentManager(url, listenerId, options)
|
||||
let connectTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
const cleanup = () => {
|
||||
if (connectTimer) {
|
||||
clearTimeout(connectTimer)
|
||||
connectTimer = null
|
||||
}
|
||||
|
||||
manager.removeMessageListener(listenerId)
|
||||
sseManagerSingleton.closeIndependentManager(url, listenerId)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
setTimeout(() => {
|
||||
connectTimer = setTimeout(() => {
|
||||
connectTimer = null
|
||||
manager.addMessageListener(listenerId, messageHandler)
|
||||
}, delay)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
manager.removeMessageListener(listenerId)
|
||||
})
|
||||
onUnmounted(cleanup)
|
||||
|
||||
return {
|
||||
manager,
|
||||
readyState: () => manager.readyState,
|
||||
close: () => manager.removeMessageListener(listenerId),
|
||||
close: cleanup,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -135,31 +155,50 @@ export function useBackgroundOptimization() {
|
||||
listenerId: string,
|
||||
isActive: Ref<boolean>,
|
||||
) => {
|
||||
// 使用独立的SSE管理器,确保每个监听器都有独立的连接
|
||||
const manager = sseManagerSingleton.getIndependentManager(url, listenerId, {
|
||||
backgroundCloseDelay: 1000, // 进度SSE更快关闭
|
||||
reconnectDelay: 1000,
|
||||
maxReconnectAttempts: 5,
|
||||
})
|
||||
const getManager = () =>
|
||||
sseManagerSingleton.getIndependentManager(url, listenerId, {
|
||||
backgroundCloseDelay: 1000, // 进度SSE更快关闭
|
||||
reconnectDelay: 1000,
|
||||
maxReconnectAttempts: 5,
|
||||
})
|
||||
|
||||
let manager: ReturnType<typeof getManager> | null = null
|
||||
let isListening = false
|
||||
|
||||
const startProgress = () => {
|
||||
if (isActive.value) {
|
||||
manager.addMessageListener(listenerId, messageHandler)
|
||||
}
|
||||
if (!isActive.value || isListening) return
|
||||
|
||||
manager ??= getManager()
|
||||
manager.addMessageListener(listenerId, messageHandler)
|
||||
isListening = true
|
||||
}
|
||||
|
||||
const stopProgress = () => {
|
||||
const stopProgress = (destroyManager = true) => {
|
||||
if (!manager) {
|
||||
isListening = false
|
||||
return
|
||||
}
|
||||
|
||||
manager.removeMessageListener(listenerId)
|
||||
|
||||
if (destroyManager) {
|
||||
sseManagerSingleton.closeIndependentManager(url, listenerId)
|
||||
manager = null
|
||||
}
|
||||
|
||||
isListening = false
|
||||
}
|
||||
|
||||
onUnmounted(() => {
|
||||
stopProgress()
|
||||
stopProgress(true)
|
||||
})
|
||||
|
||||
return {
|
||||
start: startProgress,
|
||||
stop: stopProgress,
|
||||
manager,
|
||||
get manager() {
|
||||
return manager
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ const hasNewMessage = ref(false)
|
||||
|
||||
// 通知列表
|
||||
const notificationList = ref<SystemNotification[]>([])
|
||||
const MAX_NOTIFICATIONS = 100
|
||||
|
||||
// 弹窗
|
||||
const appsMenu = ref(false)
|
||||
@@ -31,6 +32,9 @@ function handleMessage(event: MessageEvent) {
|
||||
if (event.data) {
|
||||
const noti: SystemNotification = JSON.parse(event.data)
|
||||
notificationList.value.unshift(noti)
|
||||
if (notificationList.value.length > MAX_NOTIFICATIONS) {
|
||||
notificationList.value.length = MAX_NOTIFICATIONS
|
||||
}
|
||||
hasNewMessage.value = true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ const route = useRoute()
|
||||
<template>
|
||||
<DefaultLayout>
|
||||
<router-view v-slot="{ Component }">
|
||||
<keep-alive>
|
||||
<keep-alive :max="12">
|
||||
<component :is="Component" v-if="route.meta.keepAlive" :key="route.fullPath" />
|
||||
</keep-alive>
|
||||
<component :is="Component" v-if="!route.meta.keepAlive" :key="route.fullPath" />
|
||||
|
||||
68
src/main.ts
68
src/main.ts
@@ -1,11 +1,9 @@
|
||||
// 1. 配置与兼容性
|
||||
import './ace-config'
|
||||
import '@/@core/utils/compatibility'
|
||||
import '@/@iconify/icons-bundle'
|
||||
import '@/plugins/webfontloader'
|
||||
|
||||
// 2. 核心插件和 UI 框架
|
||||
import { createApp } from 'vue'
|
||||
import { createApp, defineAsyncComponent } from 'vue'
|
||||
import vuetify from '@/plugins/vuetify'
|
||||
import router from '@/router'
|
||||
import pinia from '@/stores/index'
|
||||
@@ -13,9 +11,7 @@ import i18n from '@/plugins/i18n'
|
||||
|
||||
// 3. 全局组件
|
||||
import App from '@/App.vue'
|
||||
import { VAceEditor } from 'vue3-ace-editor'
|
||||
import { PerfectScrollbarPlugin } from 'vue3-perfect-scrollbar'
|
||||
import { CronVuetify } from '@vue-js-cron/vuetify'
|
||||
|
||||
// 4. 工具函数和其他辅助模块
|
||||
import { loadRemoteComponents } from './utils/federationLoader'
|
||||
@@ -23,22 +19,12 @@ import { loadRemoteComponents } from './utils/federationLoader'
|
||||
// 5. 其他插件和功能模块
|
||||
import Toast from 'vue-toastification'
|
||||
import ConfirmDialog from '@/composables/useConfirm'
|
||||
import VueApexCharts from 'vue3-apexcharts'
|
||||
import { configureApexChartsTheme } from '@/utils/apexCharts'
|
||||
|
||||
// 6. 注册自定义组件
|
||||
import DialogCloseBtn from '@/@core/components/DialogCloseBtn.vue'
|
||||
import ScrollToTopBtn from '@/@core/components/ScrollToTopBtn.vue'
|
||||
import PageContentTitle from './@core/components/PageContentTitle.vue'
|
||||
import MediaCard from './components/cards/MediaCard.vue'
|
||||
import PosterCard from './components/cards/PosterCard.vue'
|
||||
import BackdropCard from './components/cards/BackdropCard.vue'
|
||||
import PersonCard from './components/cards/PersonCard.vue'
|
||||
import MediaInfoCard from './components/cards/MediaInfoCard.vue'
|
||||
import TorrentCard from './components/cards/TorrentCard.vue'
|
||||
import MediaIdSelector from './components/misc/MediaIdSelector.vue'
|
||||
import CronField from './components/field/CronField.vue'
|
||||
import PathField from './components/field/PathField.vue'
|
||||
import HeaderTab from './layouts/components/HeaderTab.vue'
|
||||
|
||||
// 7. 样式文件 - 合并为单一导入
|
||||
import '@/styles/main.scss'
|
||||
@@ -50,6 +36,34 @@ import stateRestorePlugin from '@/plugins/stateRestore'
|
||||
import { backgroundManager } from '@/utils/backgroundManager'
|
||||
import { sseManagerSingleton } from '@/utils/sseManager'
|
||||
|
||||
const iconBundlePromise = import('@/@iconify/icons-bundle').catch(error => {
|
||||
console.error('Failed to load icon bundle', error)
|
||||
})
|
||||
|
||||
const AsyncAceEditor = defineAsyncComponent(async () => {
|
||||
await import('./ace-config')
|
||||
return (await import('vue3-ace-editor')).VAceEditor
|
||||
})
|
||||
|
||||
const AsyncApexChart = defineAsyncComponent(async () => {
|
||||
const component = (await import('vue3-apexcharts')).default
|
||||
const themeName = document.documentElement.getAttribute('data-theme') || localStorage.getItem('theme') || 'light'
|
||||
configureApexChartsTheme(themeName)
|
||||
return component
|
||||
})
|
||||
|
||||
const AsyncCronVuetify = defineAsyncComponent(async () => {
|
||||
return (await import('@vue-js-cron/vuetify')).CronVuetify
|
||||
})
|
||||
|
||||
const AsyncCronField = defineAsyncComponent(async () => {
|
||||
return (await import('./components/field/CronField.vue')).default
|
||||
})
|
||||
|
||||
const AsyncPathField = defineAsyncComponent(async () => {
|
||||
return (await import('./components/field/PathField.vue')).default
|
||||
})
|
||||
|
||||
// 创建Vue实例
|
||||
const app = createApp(App)
|
||||
|
||||
@@ -72,21 +86,13 @@ app.use(stateRestorePlugin)
|
||||
|
||||
// 5. 注册全局组件
|
||||
app
|
||||
.component('VAceEditor', VAceEditor)
|
||||
.component('VApexChart', VueApexCharts)
|
||||
.component('VCronVuetify', CronVuetify)
|
||||
.component('VAceEditor', AsyncAceEditor)
|
||||
.component('VApexChart', AsyncApexChart)
|
||||
.component('VCronVuetify', AsyncCronVuetify)
|
||||
.component('VDialogCloseBtn', DialogCloseBtn)
|
||||
.component('VScrollToTopBtn', ScrollToTopBtn)
|
||||
.component('VMediaCard', MediaCard)
|
||||
.component('VPosterCard', PosterCard)
|
||||
.component('VBackdropCard', BackdropCard)
|
||||
.component('VPersonCard', PersonCard)
|
||||
.component('VMediaInfoCard', MediaInfoCard)
|
||||
.component('VTorrentCard', TorrentCard)
|
||||
.component('VMediaIdSelector', MediaIdSelector)
|
||||
.component('VCronField', CronField)
|
||||
.component('VPathField', PathField)
|
||||
.component('VHeaderTab', HeaderTab)
|
||||
.component('VCronField', AsyncCronField)
|
||||
.component('VPathField', AsyncPathField)
|
||||
.component('VPageContentTitle', PageContentTitle)
|
||||
|
||||
// 6. 注册其他插件
|
||||
@@ -98,7 +104,9 @@ app
|
||||
})
|
||||
.use(ConfirmDialog)
|
||||
.use(i18n)
|
||||
.mount('#app')
|
||||
|
||||
await iconBundlePromise
|
||||
app.mount('#app')
|
||||
|
||||
// 页面卸载时清理后台管理器
|
||||
window.addEventListener('beforeunload', () => {
|
||||
|
||||
@@ -145,7 +145,6 @@ const router = createRouter({
|
||||
name: 'plugin-app',
|
||||
component: () => import('../pages/plugin-app.vue'),
|
||||
meta: {
|
||||
keepAlive: true,
|
||||
requiresAuth: true,
|
||||
},
|
||||
},
|
||||
@@ -161,7 +160,6 @@ const router = createRouter({
|
||||
component: () => import('../pages/browse.vue'),
|
||||
props: true,
|
||||
meta: {
|
||||
keepAlive: true,
|
||||
requiresAuth: true,
|
||||
},
|
||||
},
|
||||
@@ -170,7 +168,6 @@ const router = createRouter({
|
||||
component: () => import('../pages/credits.vue'),
|
||||
props: true,
|
||||
meta: {
|
||||
keepAlive: true,
|
||||
requiresAuth: true,
|
||||
},
|
||||
},
|
||||
@@ -179,7 +176,6 @@ const router = createRouter({
|
||||
component: () => import('../pages/person.vue'),
|
||||
props: true,
|
||||
meta: {
|
||||
keepAlive: true,
|
||||
requiresAuth: true,
|
||||
},
|
||||
},
|
||||
@@ -187,7 +183,6 @@ const router = createRouter({
|
||||
path: '/media',
|
||||
component: () => import('../pages/media.vue'),
|
||||
meta: {
|
||||
keepAlive: true,
|
||||
requiresAuth: true,
|
||||
},
|
||||
},
|
||||
@@ -195,7 +190,6 @@ const router = createRouter({
|
||||
path: '/filemanager',
|
||||
component: () => import('../pages/filemanager.vue'),
|
||||
meta: {
|
||||
keepAlive: true,
|
||||
requiresAuth: true,
|
||||
hideFooter: true,
|
||||
},
|
||||
|
||||
1
src/types/iconify-bundle.d.ts
vendored
Normal file
1
src/types/iconify-bundle.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
||||
declare module '@/@iconify/icons-bundle'
|
||||
40
src/utils/apexCharts.ts
Normal file
40
src/utils/apexCharts.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
declare global {
|
||||
interface Window {
|
||||
Apex: any
|
||||
}
|
||||
}
|
||||
|
||||
export function configureApexChartsTheme(themeName: string) {
|
||||
if (typeof window === 'undefined' || !window.Apex) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const isDark = themeName === 'dark' || themeName === 'transparent'
|
||||
|
||||
window.Apex.dataLabels = {
|
||||
formatter: function (_: number, { seriesIndex, w }: { seriesIndex: number; w: any }) {
|
||||
const data = w.config.series[seriesIndex]
|
||||
return data.toFixed(data % 1 === 0 ? 0 : 1)
|
||||
},
|
||||
}
|
||||
|
||||
window.Apex.legend = {
|
||||
labels: {
|
||||
useSeriesColors: true,
|
||||
},
|
||||
}
|
||||
|
||||
window.Apex.title = {
|
||||
style: {
|
||||
color: 'rgba(var(--v-theme-on-surface), var(--v-high-emphasis-opacity))',
|
||||
},
|
||||
}
|
||||
|
||||
window.Apex.tooltip = {
|
||||
theme: isDark ? 'dark' : 'light',
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('ApexCharts 全局配置失败:', error)
|
||||
}
|
||||
}
|
||||
77
src/utils/mediaStatusCache.ts
Normal file
77
src/utils/mediaStatusCache.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
type StatusCacheEntry = {
|
||||
expiresAt: number
|
||||
value: boolean
|
||||
}
|
||||
|
||||
const STATUS_CACHE_TTL = 3 * 60 * 1000
|
||||
|
||||
const existsStatusCache = new Map<string, StatusCacheEntry>()
|
||||
const existsStatusRequests = new Map<string, Promise<boolean>>()
|
||||
const subscribeStatusCache = new Map<string, StatusCacheEntry>()
|
||||
const subscribeStatusRequests = new Map<string, Promise<boolean>>()
|
||||
|
||||
function getCachedValue(cache: Map<string, StatusCacheEntry>, key: string): boolean | undefined {
|
||||
const entry = cache.get(key)
|
||||
if (!entry) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
if (entry.expiresAt <= Date.now()) {
|
||||
cache.delete(key)
|
||||
return undefined
|
||||
}
|
||||
|
||||
return entry.value
|
||||
}
|
||||
|
||||
function setCachedValue(cache: Map<string, StatusCacheEntry>, key: string, value: boolean) {
|
||||
cache.set(key, {
|
||||
expiresAt: Date.now() + STATUS_CACHE_TTL,
|
||||
value,
|
||||
})
|
||||
}
|
||||
|
||||
async function resolveCachedStatus(
|
||||
cache: Map<string, StatusCacheEntry>,
|
||||
requests: Map<string, Promise<boolean>>,
|
||||
key: string,
|
||||
loader: () => Promise<boolean>,
|
||||
): Promise<boolean> {
|
||||
const cachedValue = getCachedValue(cache, key)
|
||||
if (cachedValue !== undefined) {
|
||||
return cachedValue
|
||||
}
|
||||
|
||||
const currentRequest = requests.get(key)
|
||||
if (currentRequest) {
|
||||
return currentRequest
|
||||
}
|
||||
|
||||
const request = loader()
|
||||
.then(value => {
|
||||
setCachedValue(cache, key, value)
|
||||
return value
|
||||
})
|
||||
.finally(() => {
|
||||
requests.delete(key)
|
||||
})
|
||||
|
||||
requests.set(key, request)
|
||||
return request
|
||||
}
|
||||
|
||||
export function getCachedMediaExistsStatus(key: string, loader: () => Promise<boolean>) {
|
||||
return resolveCachedStatus(existsStatusCache, existsStatusRequests, key, loader)
|
||||
}
|
||||
|
||||
export function setCachedMediaExistsStatus(key: string, value: boolean) {
|
||||
setCachedValue(existsStatusCache, key, value)
|
||||
}
|
||||
|
||||
export function getCachedMediaSubscribeStatus(key: string, loader: () => Promise<boolean>) {
|
||||
return resolveCachedStatus(subscribeStatusCache, subscribeStatusRequests, key, loader)
|
||||
}
|
||||
|
||||
export function setCachedMediaSubscribeStatus(key: string, value: boolean) {
|
||||
setCachedValue(subscribeStatusCache, key, value)
|
||||
}
|
||||
@@ -16,6 +16,16 @@ export class SSEManager {
|
||||
}
|
||||
private reconnectAttempts = 0
|
||||
private isConnecting = false
|
||||
private readonly handleVisibilityChange = () => {
|
||||
if (document.hidden) {
|
||||
this.handleBackground()
|
||||
} else {
|
||||
this.handleForeground()
|
||||
}
|
||||
}
|
||||
private readonly handleBeforeUnload = () => {
|
||||
this.destroy()
|
||||
}
|
||||
|
||||
constructor(url: string, options: Partial<typeof SSEManager.prototype.options> = {}) {
|
||||
this.url = url
|
||||
@@ -30,18 +40,13 @@ export class SSEManager {
|
||||
}
|
||||
|
||||
private setupVisibilityListener() {
|
||||
document.addEventListener('visibilitychange', () => {
|
||||
if (document.hidden) {
|
||||
this.handleBackground()
|
||||
} else {
|
||||
this.handleForeground()
|
||||
}
|
||||
})
|
||||
document.addEventListener('visibilitychange', this.handleVisibilityChange)
|
||||
window.addEventListener('beforeunload', this.handleBeforeUnload)
|
||||
}
|
||||
|
||||
// 页面卸载时关闭连接
|
||||
window.addEventListener('beforeunload', () => {
|
||||
this.close()
|
||||
})
|
||||
private removeVisibilityListener() {
|
||||
document.removeEventListener('visibilitychange', this.handleVisibilityChange)
|
||||
window.removeEventListener('beforeunload', this.handleBeforeUnload)
|
||||
}
|
||||
|
||||
private handleBackground() {
|
||||
@@ -172,6 +177,18 @@ export class SSEManager {
|
||||
* 关闭连接
|
||||
*/
|
||||
close() {
|
||||
this.resetConnectionState()
|
||||
}
|
||||
|
||||
/**
|
||||
* 销毁管理器并清理所有引用
|
||||
*/
|
||||
destroy() {
|
||||
this.resetConnectionState(true)
|
||||
this.removeVisibilityListener()
|
||||
}
|
||||
|
||||
private resetConnectionState(clearListeners = false) {
|
||||
if (this.eventSource) {
|
||||
this.eventSource.close()
|
||||
this.eventSource = null
|
||||
@@ -187,7 +204,10 @@ export class SSEManager {
|
||||
this.backgroundCloseTimer = null
|
||||
}
|
||||
|
||||
this.listeners.clear()
|
||||
if (clearListeners) {
|
||||
this.listeners.clear()
|
||||
}
|
||||
|
||||
this.isConnecting = false
|
||||
this.reconnectAttempts = 0
|
||||
}
|
||||
@@ -210,8 +230,9 @@ export class SSEManager {
|
||||
* 强制重新连接
|
||||
*/
|
||||
forceReconnect() {
|
||||
const hasActiveListeners = this.listeners.size > 0
|
||||
this.close()
|
||||
if (!this.isBackground && this.listeners.size > 0) {
|
||||
if (!this.isBackground && hasActiveListeners) {
|
||||
this.reconnectSSE()
|
||||
}
|
||||
}
|
||||
@@ -244,6 +265,10 @@ export class SSEManager {
|
||||
class SSEManagerSingleton {
|
||||
private managers: Map<string, SSEManager> = new Map()
|
||||
|
||||
private getIndependentManagerKey(url: string, listenerId: string): string {
|
||||
return `${url}::${listenerId}`
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取或创建SSE管理器
|
||||
* @param url SSE连接URL
|
||||
@@ -285,16 +310,28 @@ class SSEManagerSingleton {
|
||||
closeManager(url: string) {
|
||||
const manager = this.managers.get(url)
|
||||
if (manager) {
|
||||
manager.close()
|
||||
manager.destroy()
|
||||
this.managers.delete(url)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 关闭独立管理器
|
||||
*/
|
||||
closeIndependentManager(url: string, listenerId: string) {
|
||||
const managerKey = this.getIndependentManagerKey(url, listenerId)
|
||||
const manager = this.managers.get(managerKey)
|
||||
if (manager) {
|
||||
manager.destroy()
|
||||
this.managers.delete(managerKey)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 关闭所有管理器
|
||||
*/
|
||||
closeAllManagers() {
|
||||
this.managers.forEach(manager => manager.close())
|
||||
this.managers.forEach(manager => manager.destroy())
|
||||
this.managers.clear()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ class ThemeManager {
|
||||
private themes: Map<string, ThemeConfig> = new Map()
|
||||
private currentTheme: string = 'default'
|
||||
private loadedLinks: Map<string, HTMLLinkElement> = new Map()
|
||||
private themeListeners: Map<(theme: string) => void, EventListener> = new Map()
|
||||
|
||||
constructor() {
|
||||
// 注册所有可用主题
|
||||
@@ -190,18 +191,29 @@ class ThemeManager {
|
||||
* 监听主题变更事件
|
||||
*/
|
||||
onThemeChange(callback: (theme: string) => void): void {
|
||||
document.addEventListener('themechange', (event: any) => {
|
||||
callback(event.detail.theme)
|
||||
})
|
||||
if (this.themeListeners.has(callback)) {
|
||||
return
|
||||
}
|
||||
|
||||
const listener: EventListener = event => {
|
||||
callback((event as CustomEvent<{ theme: string }>).detail.theme)
|
||||
}
|
||||
|
||||
this.themeListeners.set(callback, listener)
|
||||
document.addEventListener('themechange', listener)
|
||||
}
|
||||
|
||||
/**
|
||||
* 移除主题变更监听器
|
||||
*/
|
||||
offThemeChange(callback: (theme: string) => void): void {
|
||||
document.removeEventListener('themechange', (event: any) => {
|
||||
callback(event.detail.theme)
|
||||
})
|
||||
const listener = this.themeListeners.get(callback)
|
||||
if (!listener) {
|
||||
return
|
||||
}
|
||||
|
||||
document.removeEventListener('themechange', listener)
|
||||
this.themeListeners.delete(callback)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import api from '@/api'
|
||||
import type { MediaInfo } from '@/api/types'
|
||||
import MediaCard from '@/components/cards/MediaCard.vue'
|
||||
import VirtualCardGrid from '@/components/misc/VirtualCardGrid.vue'
|
||||
import NoDataFound from '@/components/NoDataFound.vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
@@ -27,12 +28,11 @@ const loading = ref(false)
|
||||
// 是否加载完成
|
||||
const isRefreshed = ref(false)
|
||||
|
||||
// 数据列表
|
||||
const dataList = ref<MediaInfo[]>([])
|
||||
const currData = ref<MediaInfo[]>([])
|
||||
// 使用 shallowRef 避免长列表中的深层代理开销
|
||||
const dataList = shallowRef<MediaInfo[]>([])
|
||||
|
||||
// 用于保存已处理过的 key
|
||||
const seenKeys = ref<Set<string>>(new Set<string>())
|
||||
const seenKeys = new Set<string>()
|
||||
|
||||
// 拼装参数
|
||||
function getParams() {
|
||||
@@ -46,27 +46,42 @@ function getParams() {
|
||||
|
||||
// MediaInfo 去重的字段
|
||||
const dedupFields = [
|
||||
"source",
|
||||
"type",
|
||||
"season",
|
||||
"tmdb_id",
|
||||
"imdb_id",
|
||||
"tvdb_id",
|
||||
"douban_id",
|
||||
"bangumi_id",
|
||||
"mediaid_prefix",
|
||||
"media_id",
|
||||
] as const;
|
||||
'source',
|
||||
'type',
|
||||
'season',
|
||||
'tmdb_id',
|
||||
'imdb_id',
|
||||
'tvdb_id',
|
||||
'douban_id',
|
||||
'bangumi_id',
|
||||
'mediaid_prefix',
|
||||
'media_id',
|
||||
] as const
|
||||
|
||||
function deduplicate(items: MediaInfo[]): MediaInfo[] {
|
||||
return items.filter(item => {
|
||||
const key = dedupFields.map(field => String(item[field])).join('~');
|
||||
if (seenKeys.value.has(key)) {
|
||||
return false;
|
||||
const key = dedupFields.map(field => String(item[field])).join('~')
|
||||
if (seenKeys.has(key)) {
|
||||
return false
|
||||
}
|
||||
seenKeys.value.add(key);
|
||||
return true;
|
||||
});
|
||||
seenKeys.add(key)
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
function appendData(items: MediaInfo[]) {
|
||||
dataList.value = dataList.value.concat(items)
|
||||
}
|
||||
|
||||
async function loadPageData() {
|
||||
const rawData: MediaInfo[] = await api.get(props.apipath!, {
|
||||
params: getParams(),
|
||||
})
|
||||
|
||||
return {
|
||||
rawCount: rawData.length,
|
||||
uniqueData: deduplicate(rawData),
|
||||
}
|
||||
}
|
||||
|
||||
// 获取列表数据
|
||||
@@ -87,22 +102,18 @@ async function fetchData({ done }: { done: any }) {
|
||||
// 设置加载中
|
||||
loading.value = true
|
||||
// 请求API
|
||||
currData.value = await api.get(props.apipath, {
|
||||
params: getParams(),
|
||||
})
|
||||
const { rawCount, uniqueData } = await loadPageData()
|
||||
// 取消加载中
|
||||
loading.value = false
|
||||
// 标计为已请求完成
|
||||
isRefreshed.value = true
|
||||
if (currData.value.length === 0) {
|
||||
if (rawCount === 0) {
|
||||
// 如果没有数据,跳出
|
||||
done('empty')
|
||||
return
|
||||
}
|
||||
// 去重
|
||||
currData.value = deduplicate(currData.value)
|
||||
// 合并数据
|
||||
dataList.value.push(...currData.value)
|
||||
appendData(uniqueData)
|
||||
// 页码+1
|
||||
page.value++
|
||||
// 返回加载成功
|
||||
@@ -113,19 +124,15 @@ async function fetchData({ done }: { done: any }) {
|
||||
// 设置加载中
|
||||
loading.value = true
|
||||
// 请求API
|
||||
currData.value = await api.get(props.apipath, {
|
||||
params: getParams(),
|
||||
})
|
||||
const { rawCount, uniqueData } = await loadPageData()
|
||||
// 标计为已请求完成
|
||||
isRefreshed.value = true
|
||||
if (currData.value.length === 0) {
|
||||
if (rawCount === 0) {
|
||||
// 如果没有数据,跳出
|
||||
done('empty')
|
||||
} else {
|
||||
// 去重
|
||||
currData.value = deduplicate(currData.value)
|
||||
// 合并数据
|
||||
dataList.value.push(...currData.value)
|
||||
appendData(uniqueData)
|
||||
// 页码+1
|
||||
page.value++
|
||||
// 返回加载成功
|
||||
@@ -147,9 +154,16 @@ async function fetchData({ done }: { done: any }) {
|
||||
<VInfiniteScroll mode="intersect" side="end" :items="dataList" class="overflow-visible pt-3 px-2" @load="fetchData">
|
||||
<template #loading />
|
||||
<template #empty />
|
||||
<div v-if="dataList.length > 0" class="grid gap-4 grid-media-card" tabindex="0">
|
||||
<MediaCard v-for="data in dataList" :key="data.tmdb_id || data.douban_id" :media="data" />
|
||||
</div>
|
||||
<VirtualCardGrid
|
||||
v-if="dataList.length > 0"
|
||||
:items="dataList"
|
||||
:get-item-key="item => item.tmdb_id || item.douban_id || item.bangumi_id || item.media_id || item.title"
|
||||
tabindex="0"
|
||||
>
|
||||
<template #default="{ item }">
|
||||
<MediaCard :media="item" />
|
||||
</template>
|
||||
</VirtualCardGrid>
|
||||
<NoDataFound
|
||||
v-if="dataList.length === 0 && isRefreshed"
|
||||
error-code="404"
|
||||
|
||||
@@ -3,6 +3,7 @@ import api from '@/api'
|
||||
import type { MediaInfo } from '@/api/types'
|
||||
import MediaCard from '@/components/cards/MediaCard.vue'
|
||||
import SlideView from '@/components/slide/SlideView.vue'
|
||||
import VirtualSlideView from '@/components/slide/VirtualSlideView.vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useIntersectionObserver, until } from '@vueuse/core'
|
||||
|
||||
@@ -27,8 +28,8 @@ const componentLoaded = ref(false)
|
||||
// 是否已尝试加载
|
||||
const hasTriedLoading = ref(false)
|
||||
|
||||
// 数据列表
|
||||
const dataList = ref<MediaInfo[]>([])
|
||||
// 使用 shallowRef 避免横向卡片区的大数组深层代理
|
||||
const dataList = shallowRef<MediaInfo[]>([])
|
||||
|
||||
// 容器引用
|
||||
const containerRef = ref<HTMLElement | null>(null)
|
||||
@@ -74,13 +75,15 @@ onActivated(() => {
|
||||
|
||||
<template>
|
||||
<div ref="containerRef">
|
||||
<SlideView v-if="componentLoaded">
|
||||
<template #content>
|
||||
<template v-for="data in dataList" :key="data.tmdb_id || data.douban_id || data.bangumi_id">
|
||||
<MediaCard :media="data" width="9rem" />
|
||||
</template>
|
||||
<VirtualSlideView
|
||||
v-if="componentLoaded"
|
||||
:items="dataList"
|
||||
:get-item-key="item => item.tmdb_id || item.douban_id || item.bangumi_id || item.media_id || item.title"
|
||||
>
|
||||
<template #item="{ item }">
|
||||
<MediaCard :media="item" width="9rem" />
|
||||
</template>
|
||||
</SlideView>
|
||||
</VirtualSlideView>
|
||||
<SlideView v-else-if="!componentLoaded">
|
||||
<template #content>
|
||||
<div v-for="i in 10" :key="i" style="width: 9rem">
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
<script lang="ts" setup>
|
||||
import api from '@/api'
|
||||
import type { Person } from '@/api/types'
|
||||
import PersonCard from '@/components/cards/PersonCard.vue'
|
||||
import VirtualCardGrid from '@/components/misc/VirtualCardGrid.vue'
|
||||
import NoDataFound from '@/components/NoDataFound.vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
@@ -27,9 +29,18 @@ const loading = ref(false)
|
||||
// 是否加载完成
|
||||
const isRefreshed = ref(false)
|
||||
|
||||
// 数据列表
|
||||
const dataList = ref<any>([])
|
||||
const currData = ref<any>([])
|
||||
// 使用 shallowRef 避免长列表中的深层代理开销
|
||||
const dataList = shallowRef<Person[]>([])
|
||||
|
||||
function appendData(items: Person[]) {
|
||||
dataList.value = dataList.value.concat(items)
|
||||
}
|
||||
|
||||
async function loadPageData() {
|
||||
return api.get(props.apipath!, {
|
||||
params: getParams(),
|
||||
}) as Promise<Person[]>
|
||||
}
|
||||
|
||||
// 拼装参数
|
||||
function getParams() {
|
||||
@@ -59,20 +70,18 @@ async function fetchData({ done }: { done: any }) {
|
||||
// 设置加载中
|
||||
loading.value = true
|
||||
// 请求API
|
||||
currData.value = await api.get(props.apipath, {
|
||||
params: getParams(),
|
||||
})
|
||||
const currentData = await loadPageData()
|
||||
// 取消加载中
|
||||
loading.value = false
|
||||
// 标计为已请求完成
|
||||
isRefreshed.value = true
|
||||
if (currData.value.length === 0) {
|
||||
if (currentData.length === 0) {
|
||||
// 如果没有数据,跳出
|
||||
done('empty')
|
||||
return
|
||||
} else {
|
||||
// 合并数据
|
||||
dataList.value = [...dataList.value, ...currData.value]
|
||||
appendData(currentData)
|
||||
// 页码+1
|
||||
page.value++
|
||||
// 返回加载成功
|
||||
@@ -84,17 +93,15 @@ async function fetchData({ done }: { done: any }) {
|
||||
// 设置加载中
|
||||
loading.value = true
|
||||
// 请求API
|
||||
currData.value = await api.get(props.apipath, {
|
||||
params: getParams(),
|
||||
})
|
||||
const currentData = await loadPageData()
|
||||
// 标计为已请求完成
|
||||
isRefreshed.value = true
|
||||
if (currData.value.length === 0) {
|
||||
if (currentData.length === 0) {
|
||||
// 如果没有数据,跳出
|
||||
done('empty')
|
||||
} else {
|
||||
// 合并数据
|
||||
dataList.value = [...dataList.value, ...currData.value]
|
||||
appendData(currentData)
|
||||
// 页码+1
|
||||
page.value++
|
||||
// 返回加载成功
|
||||
@@ -116,9 +123,11 @@ async function fetchData({ done }: { done: any }) {
|
||||
<VInfiniteScroll mode="intersect" side="end" :items="dataList" class="overflow-visible px-3" @load="fetchData">
|
||||
<template #loading />
|
||||
<template #empty />
|
||||
<div v-if="dataList.length > 0" class="grid gap-4 grid-media-card" tabindex="0">
|
||||
<PersonCard v-for="data in dataList" :key="data.id" :person="data" />
|
||||
</div>
|
||||
<VirtualCardGrid v-if="dataList.length > 0" :items="dataList" :get-item-key="item => item.id" tabindex="0">
|
||||
<template #default="{ item }">
|
||||
<PersonCard :person="item" />
|
||||
</template>
|
||||
</VirtualCardGrid>
|
||||
<NoDataFound
|
||||
v-if="dataList.length === 0 && isRefreshed"
|
||||
error-code="404"
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
<script lang="ts" setup>
|
||||
import PersonCard from '@/components/cards/PersonCard.vue'
|
||||
import type { Person } from '@/api/types'
|
||||
import api from '@/api'
|
||||
import SlideView from '@/components/slide/SlideView.vue'
|
||||
import VirtualSlideView from '@/components/slide/VirtualSlideView.vue'
|
||||
import { useIntersectionObserver } from '@vueuse/core'
|
||||
|
||||
// 输入参数
|
||||
const props = defineProps({
|
||||
@@ -16,8 +19,14 @@ provide('rankingPropsKey', reactive({ ...props }))
|
||||
// 组件加载完成
|
||||
const componentLoaded = ref(false)
|
||||
|
||||
// 是否已尝试加载
|
||||
const hasTriedLoading = ref(false)
|
||||
|
||||
// 数据列表
|
||||
const dataList = ref<any>([])
|
||||
const dataList = shallowRef<Person[]>([])
|
||||
|
||||
// 容器引用
|
||||
const containerRef = ref<HTMLElement | null>(null)
|
||||
|
||||
// 获取订阅列表数据
|
||||
async function fetchData() {
|
||||
@@ -25,22 +34,49 @@ async function fetchData() {
|
||||
if (!props.apipath) return
|
||||
|
||||
dataList.value = await api.get(props.apipath)
|
||||
if (dataList.value.length > 0) componentLoaded.value = true
|
||||
componentLoaded.value = true
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
} finally {
|
||||
hasTriedLoading.value = true
|
||||
}
|
||||
}
|
||||
|
||||
// 加载时获取数据
|
||||
onMounted(fetchData)
|
||||
const { stop } = useIntersectionObserver(
|
||||
containerRef,
|
||||
([{ isIntersecting }]) => {
|
||||
if (isIntersecting) {
|
||||
fetchData()
|
||||
stop()
|
||||
}
|
||||
},
|
||||
{
|
||||
rootMargin: '300px',
|
||||
},
|
||||
)
|
||||
|
||||
onActivated(() => {
|
||||
if (dataList.value.length === 0 && hasTriedLoading.value) {
|
||||
fetchData()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<SlideView v-if="componentLoaded">
|
||||
<template #content>
|
||||
<template v-for="data in dataList" :key="data.id">
|
||||
<PersonCard :person="data" width="9rem" />
|
||||
<div ref="containerRef">
|
||||
<VirtualSlideView v-if="componentLoaded" :items="dataList" :get-item-key="item => item.id">
|
||||
<template #item="{ item }">
|
||||
<PersonCard :person="item" width="9rem" />
|
||||
</template>
|
||||
</template>
|
||||
</SlideView>
|
||||
</VirtualSlideView>
|
||||
<SlideView v-else>
|
||||
<template #content>
|
||||
<div v-for="i in 10" :key="i" style="width: 9rem">
|
||||
<VCard class="outline-none overflow-hidden">
|
||||
<div style="padding-bottom: 150%"></div>
|
||||
</VCard>
|
||||
</div>
|
||||
</template>
|
||||
</SlideView>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -16,6 +16,30 @@ import { readFileSync } from 'node:fs'
|
||||
const packageJson = JSON.parse(readFileSync('./package.json', 'utf-8'))
|
||||
const buildTime = new Date().getTime().toString()
|
||||
|
||||
function getManualChunk(id: string) {
|
||||
if (id.includes('ace-builds') || id.includes('vue3-ace-editor')) {
|
||||
return 'vendor-ace'
|
||||
}
|
||||
|
||||
if (id.includes('apexcharts') || id.includes('vue3-apexcharts')) {
|
||||
return 'vendor-charts'
|
||||
}
|
||||
|
||||
if (id.includes('@fullcalendar')) {
|
||||
return 'vendor-calendar'
|
||||
}
|
||||
|
||||
if (id.includes('@vue-flow')) {
|
||||
return 'vendor-workflow'
|
||||
}
|
||||
|
||||
if (id.includes('@vue-js-cron')) {
|
||||
return 'vendor-cron'
|
||||
}
|
||||
|
||||
return undefined
|
||||
}
|
||||
|
||||
// https://vitejs.dev/config/
|
||||
export default defineConfig({
|
||||
base: './',
|
||||
@@ -207,6 +231,11 @@ export default defineConfig({
|
||||
build: {
|
||||
target: 'esnext',
|
||||
minify: 'terser',
|
||||
rollupOptions: {
|
||||
output: {
|
||||
manualChunks: getManualChunk,
|
||||
},
|
||||
},
|
||||
terserOptions: {
|
||||
compress: {
|
||||
drop_console: true,
|
||||
|
||||
27
yarn.lock
27
yarn.lock
@@ -1173,6 +1173,13 @@
|
||||
dependencies:
|
||||
"@iconify/types" "*"
|
||||
|
||||
"@iconify-json/tabler@^1.2.23":
|
||||
version "1.2.34"
|
||||
resolved "https://registry.yarnpkg.com/@iconify-json/tabler/-/tabler-1.2.34.tgz#5c61bf336911c289aaaf218e0c6b78a34a27bc88"
|
||||
integrity sha512-WSlE5QrptidM57sCnXkpxZKcrk+oue6OlSJD5+gw8rIjuovOeNlejL/zABBM5kASsxLjoSy738Q8hmKrVzODuA==
|
||||
dependencies:
|
||||
"@iconify/types" "*"
|
||||
|
||||
"@iconify/tools@^4.0.4":
|
||||
version "4.1.2"
|
||||
resolved "https://registry.npmjs.org/@iconify/tools/-/tools-4.1.2.tgz"
|
||||
@@ -6995,7 +7002,16 @@ std-env@^3.9.0:
|
||||
resolved "https://registry.npmjs.org/std-env/-/std-env-3.9.0.tgz"
|
||||
integrity sha512-UGvjygr6F6tpH7o2qyqR6QYpwraIjKSdtzyBdyytFOHmPZY917kwdwLG0RbOjWOnKmnm3PeHjaoLLMie7kPLQw==
|
||||
|
||||
"string-width-cjs@npm:string-width@^4.2.0", string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3:
|
||||
"string-width-cjs@npm:string-width@^4.2.0":
|
||||
version "4.2.3"
|
||||
resolved "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz"
|
||||
integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==
|
||||
dependencies:
|
||||
emoji-regex "^8.0.0"
|
||||
is-fullwidth-code-point "^3.0.0"
|
||||
strip-ansi "^6.0.1"
|
||||
|
||||
string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3:
|
||||
version "4.2.3"
|
||||
resolved "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz"
|
||||
integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==
|
||||
@@ -7080,7 +7096,14 @@ stringify-object@^3.3.0:
|
||||
is-obj "^1.0.1"
|
||||
is-regexp "^1.0.0"
|
||||
|
||||
"strip-ansi-cjs@npm:strip-ansi@^6.0.1", strip-ansi@^6.0.0, strip-ansi@^6.0.1:
|
||||
"strip-ansi-cjs@npm:strip-ansi@^6.0.1":
|
||||
version "6.0.1"
|
||||
resolved "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz"
|
||||
integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==
|
||||
dependencies:
|
||||
ansi-regex "^5.0.1"
|
||||
|
||||
strip-ansi@^6.0.0, strip-ansi@^6.0.1:
|
||||
version "6.0.1"
|
||||
resolved "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz"
|
||||
integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==
|
||||
|
||||
Reference in New Issue
Block a user