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:
jxxghp
2026-05-09 08:32:14 +08:00
parent 2931f5df46
commit dbeea6afcc
24 changed files with 1224 additions and 248 deletions

View File

@@ -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",

View File

@@ -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
*/

View File

@@ -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(

View File

@@ -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)
},
)

View File

@@ -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
}
}
// 查询订阅弹窗规则

View File

@@ -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>

View 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>

View 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>

View File

@@ -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
},
}
}

View File

@@ -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
}
}

View File

@@ -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" />

View File

@@ -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', () => {

View File

@@ -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
View File

@@ -0,0 +1 @@
declare module '@/@iconify/icons-bundle'

40
src/utils/apexCharts.ts Normal file
View 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)
}
}

View 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)
}

View File

@@ -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()
}
}

View File

@@ -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)
}
}

View File

@@ -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"

View File

@@ -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">

View File

@@ -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"

View File

@@ -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>

View File

@@ -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,

View File

@@ -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==