Compare commits

...

7 Commits

Author SHA1 Message Date
jxxghp
090b9d735d feat: add media recognition sharing setting and update system settings UI layout 2026-05-09 08:33:29 +08:00
jxxghp
dbeea6afcc 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.
2026-05-09 08:32:14 +08:00
jxxghp
2931f5df46 更新 package.json 2026-05-08 11:21:47 +08:00
jxxghp
e14c81d178 feat(settings): persist LLM base URL presets 2026-05-08 10:52:30 +08:00
jxxghp
a9403c9c34 chore: bump version to 2.10.11 2026-05-07 08:23:20 +08:00
jxxghp
dc4914e3ca style: adjust downloader card API key field to span full width 2026-05-07 08:22:39 +08:00
jxxghp
f3dbc4afad feat: add qBittorrent API key setup support
Expose qBittorrent WebUI API Key fields in settings and setup so 5.2 users can connect without requiring username/password.

Refs jxxghp/MoviePilot#5724
2026-05-07 07:41:05 +08:00
33 changed files with 1384 additions and 283 deletions

View File

@@ -1,6 +1,6 @@
{ {
"name": "moviepilot", "name": "moviepilot",
"version": "2.10.10", "version": "2.10.12",
"private": true, "private": true,
"type": "module", "type": "module",
"bin": "dist/service.js", "bin": "dist/service.js",
@@ -76,6 +76,7 @@
"@iconify-json/lucide": "^1.2.85", "@iconify-json/lucide": "^1.2.85",
"@iconify-json/material-symbols": "^1.2.51", "@iconify-json/material-symbols": "^1.2.51",
"@iconify-json/mdi": "^1.1.52", "@iconify-json/mdi": "^1.1.52",
"@iconify-json/tabler": "^1.2.23",
"@iconify/tools": "^4.0.4", "@iconify/tools": "^4.0.4",
"@iconify/vue": "^4.3.0", "@iconify/vue": "^4.3.0",
"@intlify/unplugin-vue-i18n": "^6.0.3", "@intlify/unplugin-vue-i18n": "^6.0.3",

View File

@@ -17,6 +17,7 @@ import { createRequire } from 'node:module'
// Get current directory // Get current directory
const __dirname = dirname(fileURLToPath(import.meta.url)) const __dirname = dirname(fileURLToPath(import.meta.url))
const projectSrcDir = join(__dirname, '..')
// Create require function for importing JSON files in ESM // Create require function for importing JSON files in ESM
const require = createRequire(import.meta.url) const require = createRequire(import.meta.url)
@@ -86,36 +87,12 @@ const sources: BundleScriptConfig = {
], ],
icons: [ icons: [
// 'mdi:home',
// 'mdi:account',
// 'mdi:login',
// 'mdi:logout',
// 'octicon:book-24',
// 'octicon:code-square-24',
'lucide:sparkles', 'lucide:sparkles',
'material-symbols:passkey', 'material-symbols:passkey',
'line-md:loading-twotone-loop', 'line-md:loading-twotone-loop',
], ],
json: [ 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',
// ],
// },
],
} }
// Iconify component (this changes import statement in generated file) // 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 // eslint-disable-next-line sonarjs/cognitive-complexity
(async function () { (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 let bundle = commonJS
? `const { addCollection } = require('${component}');\n\n` ? `const { addCollection } = require('${component}');\n\n`
: `import { addCollection } from '${component}';\n\n` : `import { addCollection } from '${component}';\n\n`
@@ -280,6 +266,56 @@ const target = join(__dirname, 'icons-bundle.js');
console.error(err) 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 * Remove metadata from icon set
*/ */

View File

@@ -19,6 +19,11 @@ export default defineComponent({
const scrollDistance = ref(window.scrollY) const scrollDistance = ref(window.scrollY)
const isDialogOpen = ref(false) const isDialogOpen = ref(false)
const wasScrolledBeforeDialog = ref(false) const wasScrolledBeforeDialog = ref(false)
let dialogObserver: MutationObserver | null = null
const handleScroll = () => {
scrollDistance.value = window.scrollY
}
// 监听弹窗状态变化 // 监听弹窗状态变化
const checkDialogState = () => { const checkDialogState = () => {
@@ -32,21 +37,25 @@ export default defineComponent({
} }
onMounted(() => { onMounted(() => {
window.addEventListener('scroll', () => { window.addEventListener('scroll', handleScroll)
scrollDistance.value = window.scrollY
})
// 初始检查弹窗状态 // 初始检查弹窗状态
checkDialogState() checkDialogState()
// 监听 DOM 变化以检测弹窗状态 // 监听 DOM 变化以检测弹窗状态
const observer = new MutationObserver(checkDialogState) dialogObserver = new MutationObserver(checkDialogState)
observer.observe(document.documentElement, { dialogObserver.observe(document.documentElement, {
attributes: true, attributes: true,
attributeFilter: ['class'], attributeFilter: ['class'],
}) })
}) })
onBeforeUnmount(() => {
window.removeEventListener('scroll', handleScroll)
dialogObserver?.disconnect()
dialogObserver = null
})
return () => { return () => {
// 👉 Vertical nav // 👉 Vertical nav
const verticalNav = h( const verticalNav = h(

View File

@@ -12,6 +12,7 @@ import { globalLoadingStateManager } from '@/utils/loadingStateManager'
import { addBackgroundTimer, removeBackgroundTimer } from '@/utils/backgroundManager' import { addBackgroundTimer, removeBackgroundTimer } from '@/utils/backgroundManager'
import PWAInstallPrompt from '@/components/PWAInstallPrompt.vue' import PWAInstallPrompt from '@/components/PWAInstallPrompt.vue'
import { themeManager } from '@/utils/themeManager' import { themeManager } from '@/utils/themeManager'
import { configureApexChartsTheme } from '@/utils/apexCharts'
// 生效主题 // 生效主题
const { global: globalTheme } = useTheme() const { global: globalTheme } = useTheme()
@@ -41,13 +42,6 @@ const isTransparentTheme = computed(() => globalTheme.name.value === 'transparen
// 心跳检测 // 心跳检测
let heartbeatInterval: number | null = null let heartbeatInterval: number | null = null
// ApexCharts 全局配置
declare global {
interface Window {
Apex: any
}
}
// 启动心跳 // 启动心跳
const startHeartbeat = () => { 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选择器能正确匹配 // 更新data-theme属性以便CSS选择器能正确匹配
function updateHtmlThemeAttribute(themeName: string) { function updateHtmlThemeAttribute(themeName: string) {
document.documentElement.setAttribute('data-theme', themeName) document.documentElement.setAttribute('data-theme', themeName)
@@ -250,7 +206,7 @@ onMounted(async () => {
} }
// 配置 ApexCharts // 配置 ApexCharts
configureApexCharts() configureApexChartsTheme(globalTheme.name.value)
// 初始化data-theme属性 // 初始化data-theme属性
updateHtmlThemeAttribute(globalTheme.name.value) updateHtmlThemeAttribute(globalTheme.name.value)
@@ -265,7 +221,7 @@ onMounted(async () => {
// 更新HTML主题属性 // 更新HTML主题属性
updateHtmlThemeAttribute(newTheme) updateHtmlThemeAttribute(newTheme)
// 重新配置ApexCharts以适应新主题 // 重新配置ApexCharts以适应新主题
configureApexCharts() configureApexChartsTheme(newTheme)
}, },
) )

View File

@@ -346,11 +346,23 @@ onUnmounted(() => {
prepend-inner-icon="mdi-server" prepend-inner-icon="mdi-server"
/> />
</VCol> </VCol>
<VCol cols="12">
<VTextField
v-model="downloaderInfo.config.apikey"
type="password"
:label="t('downloader.apiKey')"
:hint="t('downloader.qbittorrentApiKeyHint')"
persistent-hint
active
prepend-inner-icon="mdi-key-variant"
/>
</VCol>
<VCol cols="12" md="6"> <VCol cols="12" md="6">
<VTextField <VTextField
v-model="downloaderInfo.config.username" v-model="downloaderInfo.config.username"
:label="t('downloader.username')" :label="t('downloader.username')"
:hint="t('downloader.username')" :hint="t('downloader.username')"
:disabled="!!downloaderInfo.config.apikey"
persistent-hint persistent-hint
active active
prepend-inner-icon="mdi-account" prepend-inner-icon="mdi-account"
@@ -362,6 +374,7 @@ onUnmounted(() => {
type="password" type="password"
:label="t('downloader.password')" :label="t('downloader.password')"
:hint="t('downloader.password')" :hint="t('downloader.password')"
:disabled="!!downloaderInfo.config.apikey"
persistent-hint persistent-hint
active active
prepend-inner-icon="mdi-lock" prepend-inner-icon="mdi-lock"

View File

@@ -14,6 +14,12 @@ import SubscribeSeasonDialog from '../dialog/SubscribeSeasonDialog.vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { mediaTypeDict } from '@/api/constants' import { mediaTypeDict } from '@/api/constants'
import { hasPermission } from '@/utils/permission' import { hasPermission } from '@/utils/permission'
import {
getCachedMediaExistsStatus,
getCachedMediaSubscribeStatus,
setCachedMediaExistsStatus,
setCachedMediaSubscribeStatus,
} from '@/utils/mediaStatusCache'
// 国际化 // 国际化
const { t } = useI18n() const { t } = useI18n()
@@ -123,6 +129,22 @@ function getMediaId() {
else return `${props.media?.mediaid_prefix}:${props.media?.media_id}` 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) { function getChipColor(type: string) {
if (type === '电影') return 'border-blue-500 bg-blue-600' 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) { if (result.success) {
// 订阅成功 // 订阅成功
isSubscribed.value = true isSubscribed.value = true
setCachedMediaSubscribeStatus(getSubscribeStatusKey(season), true)
} }
// 提示 // 提示
@@ -213,6 +236,7 @@ async function removeSubscribe() {
if (result.success) { if (result.success) {
isSubscribed.value = false isSubscribed.value = false
setCachedMediaSubscribeStatus(getSubscribeStatusKey(props.media?.season ?? null), false)
$toast.success(`${props.media?.title} ${t('subscribe.cancelSuccess')}`) $toast.success(`${props.media?.title} ${t('subscribe.cancelSuccess')}`)
} else { } else {
$toast.error(`${props.media?.title} ${t('subscribe.cancelFailed', { message: result.message })}`) $toast.error(`${props.media?.title} ${t('subscribe.cancelFailed', { message: result.message })}`)
@@ -227,8 +251,10 @@ async function removeSubscribe() {
// 查询当前媒体是否已订阅 // 查询当前媒体是否已订阅
async function handleCheckSubscribe() { async function handleCheckSubscribe() {
try { try {
const result = await checkSubscribe(props.media?.season ?? null) const subscribed = await getCachedMediaSubscribeStatus(getSubscribeStatusKey(props.media?.season ?? null), () =>
if (result) isSubscribed.value = true checkSubscribe(props.media?.season ?? null),
)
isSubscribed.value = subscribed
} catch (error) { } catch (error) {
console.error(error) console.error(error)
} }
@@ -237,17 +263,22 @@ async function handleCheckSubscribe() {
// 查询当前媒体是否已入库 // 查询当前媒体是否已入库
async function handleCheckExists() { async function handleCheckExists() {
try { try {
const result: { [key: string]: any } = await api.get('mediaserver/exists', { const exists = await getCachedMediaExistsStatus(getExistsStatusKey(), async () => {
params: { const result: { [key: string]: any } = await api.get('mediaserver/exists', {
tmdbid: props.media?.tmdb_id, params: {
title: props.media?.title, tmdbid: props.media?.tmdb_id,
year: props.media?.year, title: props.media?.title,
season: props.media?.season, year: props.media?.year,
mtype: props.media?.type, 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) { } catch (error) {
console.error(error) console.error(error)
} }
@@ -265,12 +296,14 @@ async function checkSubscribe(season: number | null) {
}, },
}) })
return result.id || null return Boolean(result.id)
} catch (error) { } catch (error: any) {
console.error(error) if (error?.response?.status === 404) {
} return false
}
return null throw error
}
} }
// 查询订阅弹窗规则 // 查询订阅弹窗规则

View File

@@ -148,7 +148,12 @@ const transferItems = ref<FileItem[]>([])
// 当前图片地址 // 当前图片地址
const currentImgLink = ref('') const currentImgLink = ref('')
function revokeCurrentImgLink() {
if (!currentImgLink.value) return
URL.revokeObjectURL(currentImgLink.value)
currentImgLink.value = ''
}
// 是否为图片文件 // 是否为图片文件
const isImage = computed(() => { const isImage = computed(() => {
@@ -287,6 +292,9 @@ async function download(item: FileItem) {
if (result) { if (result) {
const downloadUrl = URL.createObjectURL(result) const downloadUrl = URL.createObjectURL(result)
window.open(downloadUrl, '_blank') 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)) const result: Blob = (await inProps.axios.request<Blob, Blob>(config))
if (result) { if (result) {
// 创建图片地址 // 创建图片地址
revokeCurrentImgLink()
currentImgLink.value = URL.createObjectURL(result) currentImgLink.value = URL.createObjectURL(result)
} }
} }
@@ -314,7 +323,10 @@ watch(
async () => { async () => {
if (isImage.value && isFile.value) { if (isImage.value && isFile.value) {
await getImgLink(inProps.item) await getImgLink(inProps.item)
return
} }
revokeCurrentImgLink()
}, },
{ immediate: true }, { immediate: true },
) )
@@ -597,6 +609,11 @@ function stopLoadingProgress() {
onMounted(() => { onMounted(() => {
list_files() list_files()
}) })
onUnmounted(() => {
revokeCurrentImgLink()
stopLoadingProgress()
})
</script> </script>
<style scoped> <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管理器确保每个监听器都有独立的连接 // 使用独立的SSE管理器确保每个监听器都有独立的连接
const manager = sseManagerSingleton.getIndependentManager(url, listenerId, options) const manager = sseManagerSingleton.getIndependentManager(url, listenerId, options)
const isConnected = ref(false) 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(() => { onMounted(() => {
// 延迟建立连接,确保组件完全挂载 // 延迟建立连接,确保组件完全挂载
const connectDelay = options?.connectDelay || 100 const connectDelay = options?.connectDelay || 100
setTimeout(() => { connectTimer = setTimeout(() => {
connectTimer = null
try { try {
manager.addMessageListener(listenerId, event => { manager.addMessageListener(listenerId, event => {
messageHandler(event) messageHandler(event)
@@ -44,15 +57,12 @@ export function useBackgroundOptimization() {
}, connectDelay) }, connectDelay)
}) })
onUnmounted(() => { onUnmounted(cleanup)
manager.removeMessageListener(listenerId)
isConnected.value = false
})
return { return {
manager, manager,
readyState: () => manager.readyState, readyState: () => manager.readyState,
close: () => manager.removeMessageListener(listenerId), close: cleanup,
isConnected, isConnected,
forceReconnect: () => manager.forceReconnect(), forceReconnect: () => manager.forceReconnect(),
} }
@@ -104,21 +114,31 @@ export function useBackgroundOptimization() {
) => { ) => {
// 使用独立的SSE管理器确保每个监听器都有独立的连接 // 使用独立的SSE管理器确保每个监听器都有独立的连接
const manager = sseManagerSingleton.getIndependentManager(url, listenerId, options) 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(() => { onMounted(() => {
setTimeout(() => { connectTimer = setTimeout(() => {
connectTimer = null
manager.addMessageListener(listenerId, messageHandler) manager.addMessageListener(listenerId, messageHandler)
}, delay) }, delay)
}) })
onUnmounted(() => { onUnmounted(cleanup)
manager.removeMessageListener(listenerId)
})
return { return {
manager, manager,
readyState: () => manager.readyState, readyState: () => manager.readyState,
close: () => manager.removeMessageListener(listenerId), close: cleanup,
} }
} }
@@ -135,31 +155,50 @@ export function useBackgroundOptimization() {
listenerId: string, listenerId: string,
isActive: Ref<boolean>, isActive: Ref<boolean>,
) => { ) => {
// 使用独立的SSE管理器确保每个监听器都有独立的连接 const getManager = () =>
const manager = sseManagerSingleton.getIndependentManager(url, listenerId, { sseManagerSingleton.getIndependentManager(url, listenerId, {
backgroundCloseDelay: 1000, // 进度SSE更快关闭 backgroundCloseDelay: 1000, // 进度SSE更快关闭
reconnectDelay: 1000, reconnectDelay: 1000,
maxReconnectAttempts: 5, maxReconnectAttempts: 5,
}) })
let manager: ReturnType<typeof getManager> | null = null
let isListening = false
const startProgress = () => { const startProgress = () => {
if (isActive.value) { if (!isActive.value || isListening) return
manager.addMessageListener(listenerId, messageHandler)
} manager ??= getManager()
manager.addMessageListener(listenerId, messageHandler)
isListening = true
} }
const stopProgress = () => { const stopProgress = (destroyManager = true) => {
if (!manager) {
isListening = false
return
}
manager.removeMessageListener(listenerId) manager.removeMessageListener(listenerId)
if (destroyManager) {
sseManagerSingleton.closeIndependentManager(url, listenerId)
manager = null
}
isListening = false
} }
onUnmounted(() => { onUnmounted(() => {
stopProgress() stopProgress(true)
}) })
return { return {
start: startProgress, start: startProgress,
stop: stopProgress, stop: stopProgress,
manager, get manager() {
return manager
},
} }
} }

View File

@@ -17,11 +17,13 @@ export interface LlmProviderAuthStatus {
} }
export interface LlmProviderUrlPreset { export interface LlmProviderUrlPreset {
id: string
label: string label: string
value: string value: string
} }
export interface LlmProviderUrlPresetItem { export interface LlmProviderUrlPresetItem {
id: string
title: string title: string
value: string value: string
subtitle?: string subtitle?: string
@@ -80,6 +82,7 @@ interface UseLlmProviderDirectoryOptions {
provider: Ref<string> provider: Ref<string>
apiKey: Ref<string> apiKey: Ref<string>
baseUrl: Ref<string> baseUrl: Ref<string>
baseUrlPreset?: Ref<string>
model: Ref<string> model: Ref<string>
maxContextTokens?: Ref<number> maxContextTokens?: Ref<number>
authConnected?: Ref<boolean> authConnected?: Ref<boolean>
@@ -110,6 +113,7 @@ export function useLlmProviderDirectory(options: UseLlmProviderDirectoryOptions)
const providerItems = computed(() => providers.value.map(item => ({ title: item.name, value: item.id }))) const providerItems = computed(() => providers.value.map(item => ({ title: item.name, value: item.id })))
const baseUrlPresetItems = computed<LlmProviderUrlPresetItem[]>(() => const baseUrlPresetItems = computed<LlmProviderUrlPresetItem[]>(() =>
(selectedProvider.value?.base_url_presets || []).map(item => ({ (selectedProvider.value?.base_url_presets || []).map(item => ({
id: item.id,
title: item.value, title: item.value,
value: item.value, value: item.value,
subtitle: item.label, subtitle: item.label,
@@ -150,14 +154,37 @@ export function useLlmProviderDirectory(options: UseLlmProviderDirectoryOptions)
const currentBaseUrl = normalizeValue(options.baseUrl.value) const currentBaseUrl = normalizeValue(options.baseUrl.value)
const defaultBaseUrl = provider.default_base_url || '' const defaultBaseUrl = provider.default_base_url || ''
const defaultPresetId = normalizeValue(provider.base_url_presets?.[0]?.id)
if (reset) { if (reset) {
options.baseUrl.value = defaultBaseUrl options.baseUrl.value = defaultBaseUrl
if (options.baseUrlPreset) {
options.baseUrlPreset.value = defaultPresetId
}
return return
} }
if (!currentBaseUrl && defaultBaseUrl) { if (!currentBaseUrl && defaultBaseUrl) {
options.baseUrl.value = defaultBaseUrl options.baseUrl.value = defaultBaseUrl
} }
if (!options.baseUrlPreset) return
const currentPresetId = normalizeValue(options.baseUrlPreset.value)
if (currentPresetId) return
const matchedPreset = (provider.base_url_presets || []).find(
item => normalizeValue(item.value) === normalizeValue(options.baseUrl.value),
)
options.baseUrlPreset.value = matchedPreset?.id || defaultPresetId
}
function setBaseUrlPreset(presetId?: string, presetValue?: string) {
if (!options.baseUrlPreset) return
options.baseUrlPreset.value = normalizeValue(presetId)
if (presetValue !== undefined) {
options.baseUrl.value = presetValue || ''
}
} }
function handleProviderSelection(resetBaseUrl = true) { function handleProviderSelection(resetBaseUrl = true) {
@@ -225,6 +252,7 @@ export function useLlmProviderDirectory(options: UseLlmProviderDirectoryOptions)
provider: normalizeValue(options.provider.value), provider: normalizeValue(options.provider.value),
api_key: normalizeValue(options.apiKey.value) || undefined, api_key: normalizeValue(options.apiKey.value) || undefined,
base_url: normalizeValue(options.baseUrl.value) || undefined, base_url: normalizeValue(options.baseUrl.value) || undefined,
base_url_preset: normalizeValue(options.baseUrlPreset?.value) || undefined,
force_refresh: forceRefresh, force_refresh: forceRefresh,
}, },
}) })
@@ -363,6 +391,7 @@ export function useLlmProviderDirectory(options: UseLlmProviderDirectoryOptions)
showApiKeyField, showApiKeyField,
hasUsableCredential, hasUsableCredential,
canRefreshModels, canRefreshModels,
setBaseUrlPreset,
authDialogVisible, authDialogVisible,
authPolling, authPolling,
authPopupBlocked, authPopupBlocked,

View File

@@ -60,6 +60,7 @@ export interface WizardData {
supportAudioInputOutput: boolean supportAudioInputOutput: boolean
apiKey: string apiKey: string
baseUrl: string baseUrl: string
baseUrlPreset: string
maxContextTokens: number maxContextTokens: number
voiceApiKey: string voiceApiKey: string
voiceBaseUrl: string voiceBaseUrl: string
@@ -107,6 +108,7 @@ export interface ValidationErrorState {
downloader: { downloader: {
name: boolean name: boolean
host: boolean host: boolean
apikey: boolean
username: boolean username: boolean
password: boolean password: boolean
} }
@@ -239,6 +241,7 @@ const wizardData = ref<WizardData>({
supportAudioInputOutput: false, supportAudioInputOutput: false,
apiKey: '', apiKey: '',
baseUrl: 'https://api.deepseek.com', baseUrl: 'https://api.deepseek.com',
baseUrlPreset: '',
maxContextTokens: 64, maxContextTokens: 64,
voiceApiKey: '', voiceApiKey: '',
voiceBaseUrl: '', voiceBaseUrl: '',
@@ -277,6 +280,7 @@ const validationErrors = ref<ValidationErrorState>({
downloader: { downloader: {
name: false, name: false,
host: false, host: false,
apikey: false,
username: false, username: false,
password: false, password: false,
}, },
@@ -466,6 +470,7 @@ export function useSetupWizard() {
validationErrors.value.downloader = { validationErrors.value.downloader = {
name: false, name: false,
host: false, host: false,
apikey: false,
username: false, username: false,
password: false, password: false,
} }
@@ -548,9 +553,18 @@ export function useSetupWizard() {
} }
// 根据下载器类型验证其他必输项 // 根据下载器类型验证其他必输项
if ( if (wizardData.value.downloader.type === 'qbittorrent') {
wizardData.value.downloader.type === 'qbittorrent' const hasApiKey = !!wizardData.value.downloader.config?.apikey?.trim()
|| wizardData.value.downloader.type === 'transmission' if (!hasApiKey && !wizardData.value.downloader.config?.username?.trim()) {
errors.push(t('downloader.usernameRequired'))
validationErrors.value.downloader.username = true
}
if (!hasApiKey && !wizardData.value.downloader.config?.password?.trim()) {
errors.push(t('downloader.passwordRequired'))
validationErrors.value.downloader.password = true
}
} else if (
wizardData.value.downloader.type === 'transmission'
|| wizardData.value.downloader.type === 'rtorrent' || wizardData.value.downloader.type === 'rtorrent'
) { ) {
if (!wizardData.value.downloader.config?.username?.trim()) { if (!wizardData.value.downloader.config?.username?.trim()) {
@@ -1384,6 +1398,7 @@ export function useSetupWizard() {
LLM_SUPPORT_AUDIO_INPUT_OUTPUT: wizardData.value.agent.supportAudioInputOutput, LLM_SUPPORT_AUDIO_INPUT_OUTPUT: wizardData.value.agent.supportAudioInputOutput,
LLM_API_KEY: wizardData.value.agent.apiKey, LLM_API_KEY: wizardData.value.agent.apiKey,
LLM_BASE_URL: wizardData.value.agent.baseUrl || null, LLM_BASE_URL: wizardData.value.agent.baseUrl || null,
LLM_BASE_URL_PRESET: wizardData.value.agent.baseUrlPreset || null,
LLM_MAX_CONTEXT_TOKENS: wizardData.value.agent.maxContextTokens, LLM_MAX_CONTEXT_TOKENS: wizardData.value.agent.maxContextTokens,
AI_VOICE_API_KEY: wizardData.value.agent.voiceApiKey || null, AI_VOICE_API_KEY: wizardData.value.agent.voiceApiKey || null,
AI_VOICE_BASE_URL: wizardData.value.agent.voiceBaseUrl || null, AI_VOICE_BASE_URL: wizardData.value.agent.voiceBaseUrl || null,
@@ -1491,6 +1506,7 @@ export function useSetupWizard() {
wizardData.value.agent.supportAudioInputOutput = Boolean(result.data.LLM_SUPPORT_AUDIO_INPUT_OUTPUT) wizardData.value.agent.supportAudioInputOutput = Boolean(result.data.LLM_SUPPORT_AUDIO_INPUT_OUTPUT)
wizardData.value.agent.apiKey = result.data.LLM_API_KEY || '' wizardData.value.agent.apiKey = result.data.LLM_API_KEY || ''
wizardData.value.agent.baseUrl = result.data.LLM_BASE_URL || '' wizardData.value.agent.baseUrl = result.data.LLM_BASE_URL || ''
wizardData.value.agent.baseUrlPreset = result.data.LLM_BASE_URL_PRESET || ''
wizardData.value.agent.maxContextTokens = result.data.LLM_MAX_CONTEXT_TOKENS || 64 wizardData.value.agent.maxContextTokens = result.data.LLM_MAX_CONTEXT_TOKENS || 64
wizardData.value.agent.voiceApiKey = result.data.AI_VOICE_API_KEY || '' wizardData.value.agent.voiceApiKey = result.data.AI_VOICE_API_KEY || ''
wizardData.value.agent.voiceBaseUrl = result.data.AI_VOICE_BASE_URL || '' wizardData.value.agent.voiceBaseUrl = result.data.AI_VOICE_BASE_URL || ''

View File

@@ -12,6 +12,7 @@ const hasNewMessage = ref(false)
// 通知列表 // 通知列表
const notificationList = ref<SystemNotification[]>([]) const notificationList = ref<SystemNotification[]>([])
const MAX_NOTIFICATIONS = 100
// 弹窗 // 弹窗
const appsMenu = ref(false) const appsMenu = ref(false)
@@ -31,6 +32,9 @@ function handleMessage(event: MessageEvent) {
if (event.data) { if (event.data) {
const noti: SystemNotification = JSON.parse(event.data) const noti: SystemNotification = JSON.parse(event.data)
notificationList.value.unshift(noti) notificationList.value.unshift(noti)
if (notificationList.value.length > MAX_NOTIFICATIONS) {
notificationList.value.length = MAX_NOTIFICATIONS
}
hasNewMessage.value = true hasNewMessage.value = true
} }
} }

View File

@@ -7,7 +7,7 @@ const route = useRoute()
<template> <template>
<DefaultLayout> <DefaultLayout>
<router-view v-slot="{ Component }"> <router-view v-slot="{ Component }">
<keep-alive> <keep-alive :max="12">
<component :is="Component" v-if="route.meta.keepAlive" :key="route.fullPath" /> <component :is="Component" v-if="route.meta.keepAlive" :key="route.fullPath" />
</keep-alive> </keep-alive>
<component :is="Component" v-if="!route.meta.keepAlive" :key="route.fullPath" /> <component :is="Component" v-if="!route.meta.keepAlive" :key="route.fullPath" />

View File

@@ -1504,6 +1504,9 @@ export default {
recognizePluginFirst: 'Prioritize Plugin Recognition', recognizePluginFirst: 'Prioritize Plugin Recognition',
recognizePluginFirstHint: recognizePluginFirstHint:
'Prioritize calling plugins for media recognition. If a plugin matches, native recognition will be skipped', 'Prioritize calling plugins for media recognition. If a plugin matches, native recognition will be skipped',
mediaRecognizeShare: 'Use Shared Media Recognition',
mediaRecognizeShareHint:
'Report successful keyword to media ID mappings and reuse shared recognition results when local recognition fails',
githubProxy: 'Github Acceleration Proxy', githubProxy: 'Github Acceleration Proxy',
githubProxyPlaceholder: 'Leave empty for no proxy', githubProxyPlaceholder: 'Leave empty for no proxy',
githubProxyHint: 'Use proxy to accelerate Github access speed', githubProxyHint: 'Use proxy to accelerate Github access speed',
@@ -2933,8 +2936,10 @@ export default {
rtorrentHostHint: 'HTTP: http://ip:port/RPC2 or SCGI: scgi://ip:port', rtorrentHostHint: 'HTTP: http://ip:port/RPC2 or SCGI: scgi://ip:port',
default: 'Default', default: 'Default',
host: 'Host', host: 'Host',
apiKey: 'API Key',
username: 'Username', username: 'Username',
password: 'Password', password: 'Password',
qbittorrentApiKeyHint: 'For qBittorrent 5.2+, you can use the WebUI API Key directly. When set, API Key auth is preferred.',
category: 'Auto Category Management', category: 'Auto Category Management',
sequentail: 'Sequential Download', sequentail: 'Sequential Download',
force_resume: 'Force Resume', force_resume: 'Force Resume',

View File

@@ -1486,6 +1486,8 @@ export default {
fanartLangHint: '设置Fanart图片的语言偏好多选时按优先级顺序排列', fanartLangHint: '设置Fanart图片的语言偏好多选时按优先级顺序排列',
recognizePluginFirst: "优先使用插件识别", recognizePluginFirst: "优先使用插件识别",
recognizePluginFirstHint: "优先调用插件识别媒体信息,若插件命中则不再调用原生识别", recognizePluginFirstHint: "优先调用插件识别媒体信息,若插件命中则不再调用原生识别",
mediaRecognizeShare: '共享使用媒体识别数据',
mediaRecognizeShareHint: '识别成功后上报关键字与媒体ID识别失败时优先回查共享识别结果',
githubProxy: 'Github加速代理', githubProxy: 'Github加速代理',
githubProxyPlaceholder: '留空表示不使用代理', githubProxyPlaceholder: '留空表示不使用代理',
githubProxyHint: '使用代理加速Github访问速度', githubProxyHint: '使用代理加速Github访问速度',
@@ -2886,8 +2888,10 @@ export default {
rtorrentHostHint: 'HTTP: http://ip:port/RPC2 或 SCGI: scgi://ip:port', rtorrentHostHint: 'HTTP: http://ip:port/RPC2 或 SCGI: scgi://ip:port',
default: '默认', default: '默认',
host: '地址', host: '地址',
apiKey: 'API Key',
username: '用户名', username: '用户名',
password: '密码', password: '密码',
qbittorrentApiKeyHint: 'qBittorrent 5.2+ 可直接使用 WebUI API Key填写后将优先使用 API Key 登录。',
category: '自动分类管理', category: '自动分类管理',
sequentail: '顺序下载', sequentail: '顺序下载',
force_resume: '强制继续', force_resume: '强制继续',

View File

@@ -1488,6 +1488,8 @@ export default {
fanartLangHint: '設定Fanart圖片的語言偏好多選時按優先級順序排列', fanartLangHint: '設定Fanart圖片的語言偏好多選時按優先級順序排列',
recognizePluginFirst: '優先使用插件識別', recognizePluginFirst: '優先使用插件識別',
recognizePluginFirstHint: '優先調用插件識別媒體信息,若插件命中則不再調用原生識別', recognizePluginFirstHint: '優先調用插件識別媒體信息,若插件命中則不再調用原生識別',
mediaRecognizeShare: '共享使用媒體識別數據',
mediaRecognizeShareHint: '識別成功後上報關鍵字與媒體ID識別失敗時優先回查共享識別結果',
githubProxy: 'Github加速代理', githubProxy: 'Github加速代理',
githubProxyPlaceholder: '留空表示不使用代理', githubProxyPlaceholder: '留空表示不使用代理',
githubProxyHint: '使用代理加速Github訪問速度', githubProxyHint: '使用代理加速Github訪問速度',
@@ -2888,8 +2890,10 @@ export default {
enabled: '啟用', enabled: '啟用',
default: '預設', default: '預設',
host: '地址', host: '地址',
apiKey: 'API Key',
username: '用戶名', username: '用戶名',
password: '密碼', password: '密碼',
qbittorrentApiKeyHint: 'qBittorrent 5.2+ 可直接使用 WebUI API Key填寫後將優先使用 API Key 登入。',
category: '自動分類管理', category: '自動分類管理',
sequentail: '順序下載', sequentail: '順序下載',
force_resume: '強制繼續', force_resume: '強制繼續',

View File

@@ -1,11 +1,9 @@
// 1. 配置与兼容性 // 1. 配置与兼容性
import './ace-config'
import '@/@core/utils/compatibility' import '@/@core/utils/compatibility'
import '@/@iconify/icons-bundle'
import '@/plugins/webfontloader' import '@/plugins/webfontloader'
// 2. 核心插件和 UI 框架 // 2. 核心插件和 UI 框架
import { createApp } from 'vue' import { createApp, defineAsyncComponent } from 'vue'
import vuetify from '@/plugins/vuetify' import vuetify from '@/plugins/vuetify'
import router from '@/router' import router from '@/router'
import pinia from '@/stores/index' import pinia from '@/stores/index'
@@ -13,9 +11,7 @@ import i18n from '@/plugins/i18n'
// 3. 全局组件 // 3. 全局组件
import App from '@/App.vue' import App from '@/App.vue'
import { VAceEditor } from 'vue3-ace-editor'
import { PerfectScrollbarPlugin } from 'vue3-perfect-scrollbar' import { PerfectScrollbarPlugin } from 'vue3-perfect-scrollbar'
import { CronVuetify } from '@vue-js-cron/vuetify'
// 4. 工具函数和其他辅助模块 // 4. 工具函数和其他辅助模块
import { loadRemoteComponents } from './utils/federationLoader' import { loadRemoteComponents } from './utils/federationLoader'
@@ -23,22 +19,12 @@ import { loadRemoteComponents } from './utils/federationLoader'
// 5. 其他插件和功能模块 // 5. 其他插件和功能模块
import Toast from 'vue-toastification' import Toast from 'vue-toastification'
import ConfirmDialog from '@/composables/useConfirm' import ConfirmDialog from '@/composables/useConfirm'
import VueApexCharts from 'vue3-apexcharts' import { configureApexChartsTheme } from '@/utils/apexCharts'
// 6. 注册自定义组件 // 6. 注册自定义组件
import DialogCloseBtn from '@/@core/components/DialogCloseBtn.vue' import DialogCloseBtn from '@/@core/components/DialogCloseBtn.vue'
import ScrollToTopBtn from '@/@core/components/ScrollToTopBtn.vue' import ScrollToTopBtn from '@/@core/components/ScrollToTopBtn.vue'
import PageContentTitle from './@core/components/PageContentTitle.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. 样式文件 - 合并为单一导入 // 7. 样式文件 - 合并为单一导入
import '@/styles/main.scss' import '@/styles/main.scss'
@@ -50,6 +36,34 @@ import stateRestorePlugin from '@/plugins/stateRestore'
import { backgroundManager } from '@/utils/backgroundManager' import { backgroundManager } from '@/utils/backgroundManager'
import { sseManagerSingleton } from '@/utils/sseManager' 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实例 // 创建Vue实例
const app = createApp(App) const app = createApp(App)
@@ -72,21 +86,13 @@ app.use(stateRestorePlugin)
// 5. 注册全局组件 // 5. 注册全局组件
app app
.component('VAceEditor', VAceEditor) .component('VAceEditor', AsyncAceEditor)
.component('VApexChart', VueApexCharts) .component('VApexChart', AsyncApexChart)
.component('VCronVuetify', CronVuetify) .component('VCronVuetify', AsyncCronVuetify)
.component('VDialogCloseBtn', DialogCloseBtn) .component('VDialogCloseBtn', DialogCloseBtn)
.component('VScrollToTopBtn', ScrollToTopBtn) .component('VScrollToTopBtn', ScrollToTopBtn)
.component('VMediaCard', MediaCard) .component('VCronField', AsyncCronField)
.component('VPosterCard', PosterCard) .component('VPathField', AsyncPathField)
.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('VPageContentTitle', PageContentTitle) .component('VPageContentTitle', PageContentTitle)
// 6. 注册其他插件 // 6. 注册其他插件
@@ -98,7 +104,9 @@ app
}) })
.use(ConfirmDialog) .use(ConfirmDialog)
.use(i18n) .use(i18n)
.mount('#app')
await iconBundlePromise
app.mount('#app')
// 页面卸载时清理后台管理器 // 页面卸载时清理后台管理器
window.addEventListener('beforeunload', () => { window.addEventListener('beforeunload', () => {

View File

@@ -145,7 +145,6 @@ const router = createRouter({
name: 'plugin-app', name: 'plugin-app',
component: () => import('../pages/plugin-app.vue'), component: () => import('../pages/plugin-app.vue'),
meta: { meta: {
keepAlive: true,
requiresAuth: true, requiresAuth: true,
}, },
}, },
@@ -161,7 +160,6 @@ const router = createRouter({
component: () => import('../pages/browse.vue'), component: () => import('../pages/browse.vue'),
props: true, props: true,
meta: { meta: {
keepAlive: true,
requiresAuth: true, requiresAuth: true,
}, },
}, },
@@ -170,7 +168,6 @@ const router = createRouter({
component: () => import('../pages/credits.vue'), component: () => import('../pages/credits.vue'),
props: true, props: true,
meta: { meta: {
keepAlive: true,
requiresAuth: true, requiresAuth: true,
}, },
}, },
@@ -179,7 +176,6 @@ const router = createRouter({
component: () => import('../pages/person.vue'), component: () => import('../pages/person.vue'),
props: true, props: true,
meta: { meta: {
keepAlive: true,
requiresAuth: true, requiresAuth: true,
}, },
}, },
@@ -187,7 +183,6 @@ const router = createRouter({
path: '/media', path: '/media',
component: () => import('../pages/media.vue'), component: () => import('../pages/media.vue'),
meta: { meta: {
keepAlive: true,
requiresAuth: true, requiresAuth: true,
}, },
}, },
@@ -195,7 +190,6 @@ const router = createRouter({
path: '/filemanager', path: '/filemanager',
component: () => import('../pages/filemanager.vue'), component: () => import('../pages/filemanager.vue'),
meta: { meta: {
keepAlive: true,
requiresAuth: true, requiresAuth: true,
hideFooter: 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 reconnectAttempts = 0
private isConnecting = false 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> = {}) { constructor(url: string, options: Partial<typeof SSEManager.prototype.options> = {}) {
this.url = url this.url = url
@@ -30,18 +40,13 @@ export class SSEManager {
} }
private setupVisibilityListener() { private setupVisibilityListener() {
document.addEventListener('visibilitychange', () => { document.addEventListener('visibilitychange', this.handleVisibilityChange)
if (document.hidden) { window.addEventListener('beforeunload', this.handleBeforeUnload)
this.handleBackground() }
} else {
this.handleForeground()
}
})
// 页面卸载时关闭连接 private removeVisibilityListener() {
window.addEventListener('beforeunload', () => { document.removeEventListener('visibilitychange', this.handleVisibilityChange)
this.close() window.removeEventListener('beforeunload', this.handleBeforeUnload)
})
} }
private handleBackground() { private handleBackground() {
@@ -172,6 +177,18 @@ export class SSEManager {
* 关闭连接 * 关闭连接
*/ */
close() { close() {
this.resetConnectionState()
}
/**
* 销毁管理器并清理所有引用
*/
destroy() {
this.resetConnectionState(true)
this.removeVisibilityListener()
}
private resetConnectionState(clearListeners = false) {
if (this.eventSource) { if (this.eventSource) {
this.eventSource.close() this.eventSource.close()
this.eventSource = null this.eventSource = null
@@ -187,7 +204,10 @@ export class SSEManager {
this.backgroundCloseTimer = null this.backgroundCloseTimer = null
} }
this.listeners.clear() if (clearListeners) {
this.listeners.clear()
}
this.isConnecting = false this.isConnecting = false
this.reconnectAttempts = 0 this.reconnectAttempts = 0
} }
@@ -210,8 +230,9 @@ export class SSEManager {
* 强制重新连接 * 强制重新连接
*/ */
forceReconnect() { forceReconnect() {
const hasActiveListeners = this.listeners.size > 0
this.close() this.close()
if (!this.isBackground && this.listeners.size > 0) { if (!this.isBackground && hasActiveListeners) {
this.reconnectSSE() this.reconnectSSE()
} }
} }
@@ -244,6 +265,10 @@ export class SSEManager {
class SSEManagerSingleton { class SSEManagerSingleton {
private managers: Map<string, SSEManager> = new Map() private managers: Map<string, SSEManager> = new Map()
private getIndependentManagerKey(url: string, listenerId: string): string {
return `${url}::${listenerId}`
}
/** /**
* 获取或创建SSE管理器 * 获取或创建SSE管理器
* @param url SSE连接URL * @param url SSE连接URL
@@ -285,16 +310,28 @@ class SSEManagerSingleton {
closeManager(url: string) { closeManager(url: string) {
const manager = this.managers.get(url) const manager = this.managers.get(url)
if (manager) { if (manager) {
manager.close() manager.destroy()
this.managers.delete(url) 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() { closeAllManagers() {
this.managers.forEach(manager => manager.close()) this.managers.forEach(manager => manager.destroy())
this.managers.clear() this.managers.clear()
} }
} }

View File

@@ -9,6 +9,7 @@ class ThemeManager {
private themes: Map<string, ThemeConfig> = new Map() private themes: Map<string, ThemeConfig> = new Map()
private currentTheme: string = 'default' private currentTheme: string = 'default'
private loadedLinks: Map<string, HTMLLinkElement> = new Map() private loadedLinks: Map<string, HTMLLinkElement> = new Map()
private themeListeners: Map<(theme: string) => void, EventListener> = new Map()
constructor() { constructor() {
// 注册所有可用主题 // 注册所有可用主题
@@ -190,18 +191,29 @@ class ThemeManager {
* 监听主题变更事件 * 监听主题变更事件
*/ */
onThemeChange(callback: (theme: string) => void): void { onThemeChange(callback: (theme: string) => void): void {
document.addEventListener('themechange', (event: any) => { if (this.themeListeners.has(callback)) {
callback(event.detail.theme) 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 { offThemeChange(callback: (theme: string) => void): void {
document.removeEventListener('themechange', (event: any) => { const listener = this.themeListeners.get(callback)
callback(event.detail.theme) if (!listener) {
}) return
}
document.removeEventListener('themechange', listener)
this.themeListeners.delete(callback)
} }
} }

View File

@@ -2,6 +2,7 @@
import api from '@/api' import api from '@/api'
import type { MediaInfo } from '@/api/types' import type { MediaInfo } from '@/api/types'
import MediaCard from '@/components/cards/MediaCard.vue' import MediaCard from '@/components/cards/MediaCard.vue'
import VirtualCardGrid from '@/components/misc/VirtualCardGrid.vue'
import NoDataFound from '@/components/NoDataFound.vue' import NoDataFound from '@/components/NoDataFound.vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
@@ -27,12 +28,11 @@ const loading = ref(false)
// 是否加载完成 // 是否加载完成
const isRefreshed = ref(false) const isRefreshed = ref(false)
// 数据列表 // 使用 shallowRef 避免长列表中的深层代理开销
const dataList = ref<MediaInfo[]>([]) const dataList = shallowRef<MediaInfo[]>([])
const currData = ref<MediaInfo[]>([])
// 用于保存已处理过的 key // 用于保存已处理过的 key
const seenKeys = ref<Set<string>>(new Set<string>()) const seenKeys = new Set<string>()
// 拼装参数 // 拼装参数
function getParams() { function getParams() {
@@ -46,27 +46,42 @@ function getParams() {
// MediaInfo 去重的字段 // MediaInfo 去重的字段
const dedupFields = [ const dedupFields = [
"source", 'source',
"type", 'type',
"season", 'season',
"tmdb_id", 'tmdb_id',
"imdb_id", 'imdb_id',
"tvdb_id", 'tvdb_id',
"douban_id", 'douban_id',
"bangumi_id", 'bangumi_id',
"mediaid_prefix", 'mediaid_prefix',
"media_id", 'media_id',
] as const; ] as const
function deduplicate(items: MediaInfo[]): MediaInfo[] { function deduplicate(items: MediaInfo[]): MediaInfo[] {
return items.filter(item => { return items.filter(item => {
const key = dedupFields.map(field => String(item[field])).join('~'); const key = dedupFields.map(field => String(item[field])).join('~')
if (seenKeys.value.has(key)) { if (seenKeys.has(key)) {
return false; return false
} }
seenKeys.value.add(key); seenKeys.add(key)
return true; 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 loading.value = true
// 请求API // 请求API
currData.value = await api.get(props.apipath, { const { rawCount, uniqueData } = await loadPageData()
params: getParams(),
})
// 取消加载中 // 取消加载中
loading.value = false loading.value = false
// 标计为已请求完成 // 标计为已请求完成
isRefreshed.value = true isRefreshed.value = true
if (currData.value.length === 0) { if (rawCount === 0) {
// 如果没有数据,跳出 // 如果没有数据,跳出
done('empty') done('empty')
return return
} }
// 去重
currData.value = deduplicate(currData.value)
// 合并数据 // 合并数据
dataList.value.push(...currData.value) appendData(uniqueData)
// 页码+1 // 页码+1
page.value++ page.value++
// 返回加载成功 // 返回加载成功
@@ -113,19 +124,15 @@ async function fetchData({ done }: { done: any }) {
// 设置加载中 // 设置加载中
loading.value = true loading.value = true
// 请求API // 请求API
currData.value = await api.get(props.apipath, { const { rawCount, uniqueData } = await loadPageData()
params: getParams(),
})
// 标计为已请求完成 // 标计为已请求完成
isRefreshed.value = true isRefreshed.value = true
if (currData.value.length === 0) { if (rawCount === 0) {
// 如果没有数据,跳出 // 如果没有数据,跳出
done('empty') done('empty')
} else { } else {
// 去重
currData.value = deduplicate(currData.value)
// 合并数据 // 合并数据
dataList.value.push(...currData.value) appendData(uniqueData)
// 页码+1 // 页码+1
page.value++ 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"> <VInfiniteScroll mode="intersect" side="end" :items="dataList" class="overflow-visible pt-3 px-2" @load="fetchData">
<template #loading /> <template #loading />
<template #empty /> <template #empty />
<div v-if="dataList.length > 0" class="grid gap-4 grid-media-card" tabindex="0"> <VirtualCardGrid
<MediaCard v-for="data in dataList" :key="data.tmdb_id || data.douban_id" :media="data" /> v-if="dataList.length > 0"
</div> :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 <NoDataFound
v-if="dataList.length === 0 && isRefreshed" v-if="dataList.length === 0 && isRefreshed"
error-code="404" error-code="404"

View File

@@ -3,6 +3,7 @@ import api from '@/api'
import type { MediaInfo } from '@/api/types' import type { MediaInfo } from '@/api/types'
import MediaCard from '@/components/cards/MediaCard.vue' import MediaCard from '@/components/cards/MediaCard.vue'
import SlideView from '@/components/slide/SlideView.vue' import SlideView from '@/components/slide/SlideView.vue'
import VirtualSlideView from '@/components/slide/VirtualSlideView.vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { useIntersectionObserver, until } from '@vueuse/core' import { useIntersectionObserver, until } from '@vueuse/core'
@@ -27,8 +28,8 @@ const componentLoaded = ref(false)
// 是否已尝试加载 // 是否已尝试加载
const hasTriedLoading = ref(false) const hasTriedLoading = ref(false)
// 数据列表 // 使用 shallowRef 避免横向卡片区的大数组深层代理
const dataList = ref<MediaInfo[]>([]) const dataList = shallowRef<MediaInfo[]>([])
// 容器引用 // 容器引用
const containerRef = ref<HTMLElement | null>(null) const containerRef = ref<HTMLElement | null>(null)
@@ -74,13 +75,15 @@ onActivated(() => {
<template> <template>
<div ref="containerRef"> <div ref="containerRef">
<SlideView v-if="componentLoaded"> <VirtualSlideView
<template #content> v-if="componentLoaded"
<template v-for="data in dataList" :key="data.tmdb_id || data.douban_id || data.bangumi_id"> :items="dataList"
<MediaCard :media="data" width="9rem" /> :get-item-key="item => item.tmdb_id || item.douban_id || item.bangumi_id || item.media_id || item.title"
</template> >
<template #item="{ item }">
<MediaCard :media="item" width="9rem" />
</template> </template>
</SlideView> </VirtualSlideView>
<SlideView v-else-if="!componentLoaded"> <SlideView v-else-if="!componentLoaded">
<template #content> <template #content>
<div v-for="i in 10" :key="i" style="width: 9rem"> <div v-for="i in 10" :key="i" style="width: 9rem">

View File

@@ -1,6 +1,8 @@
<script lang="ts" setup> <script lang="ts" setup>
import api from '@/api' import api from '@/api'
import type { Person } from '@/api/types'
import PersonCard from '@/components/cards/PersonCard.vue' import PersonCard from '@/components/cards/PersonCard.vue'
import VirtualCardGrid from '@/components/misc/VirtualCardGrid.vue'
import NoDataFound from '@/components/NoDataFound.vue' import NoDataFound from '@/components/NoDataFound.vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
@@ -27,9 +29,18 @@ const loading = ref(false)
// 是否加载完成 // 是否加载完成
const isRefreshed = ref(false) const isRefreshed = ref(false)
// 数据列表 // 使用 shallowRef 避免长列表中的深层代理开销
const dataList = ref<any>([]) const dataList = shallowRef<Person[]>([])
const currData = ref<any>([])
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() { function getParams() {
@@ -59,20 +70,18 @@ async function fetchData({ done }: { done: any }) {
// 设置加载中 // 设置加载中
loading.value = true loading.value = true
// 请求API // 请求API
currData.value = await api.get(props.apipath, { const currentData = await loadPageData()
params: getParams(),
})
// 取消加载中 // 取消加载中
loading.value = false loading.value = false
// 标计为已请求完成 // 标计为已请求完成
isRefreshed.value = true isRefreshed.value = true
if (currData.value.length === 0) { if (currentData.length === 0) {
// 如果没有数据,跳出 // 如果没有数据,跳出
done('empty') done('empty')
return return
} else { } else {
// 合并数据 // 合并数据
dataList.value = [...dataList.value, ...currData.value] appendData(currentData)
// 页码+1 // 页码+1
page.value++ page.value++
// 返回加载成功 // 返回加载成功
@@ -84,17 +93,15 @@ async function fetchData({ done }: { done: any }) {
// 设置加载中 // 设置加载中
loading.value = true loading.value = true
// 请求API // 请求API
currData.value = await api.get(props.apipath, { const currentData = await loadPageData()
params: getParams(),
})
// 标计为已请求完成 // 标计为已请求完成
isRefreshed.value = true isRefreshed.value = true
if (currData.value.length === 0) { if (currentData.length === 0) {
// 如果没有数据,跳出 // 如果没有数据,跳出
done('empty') done('empty')
} else { } else {
// 合并数据 // 合并数据
dataList.value = [...dataList.value, ...currData.value] appendData(currentData)
// 页码+1 // 页码+1
page.value++ 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"> <VInfiniteScroll mode="intersect" side="end" :items="dataList" class="overflow-visible px-3" @load="fetchData">
<template #loading /> <template #loading />
<template #empty /> <template #empty />
<div v-if="dataList.length > 0" class="grid gap-4 grid-media-card" tabindex="0"> <VirtualCardGrid v-if="dataList.length > 0" :items="dataList" :get-item-key="item => item.id" tabindex="0">
<PersonCard v-for="data in dataList" :key="data.id" :person="data" /> <template #default="{ item }">
</div> <PersonCard :person="item" />
</template>
</VirtualCardGrid>
<NoDataFound <NoDataFound
v-if="dataList.length === 0 && isRefreshed" v-if="dataList.length === 0 && isRefreshed"
error-code="404" error-code="404"

View File

@@ -1,7 +1,10 @@
<script lang="ts" setup> <script lang="ts" setup>
import PersonCard from '@/components/cards/PersonCard.vue' import PersonCard from '@/components/cards/PersonCard.vue'
import type { Person } from '@/api/types'
import api from '@/api' import api from '@/api'
import SlideView from '@/components/slide/SlideView.vue' import SlideView from '@/components/slide/SlideView.vue'
import VirtualSlideView from '@/components/slide/VirtualSlideView.vue'
import { useIntersectionObserver } from '@vueuse/core'
// 输入参数 // 输入参数
const props = defineProps({ const props = defineProps({
@@ -16,8 +19,14 @@ provide('rankingPropsKey', reactive({ ...props }))
// 组件加载完成 // 组件加载完成
const componentLoaded = ref(false) 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() { async function fetchData() {
@@ -25,22 +34,49 @@ async function fetchData() {
if (!props.apipath) return if (!props.apipath) return
dataList.value = await api.get(props.apipath) dataList.value = await api.get(props.apipath)
if (dataList.value.length > 0) componentLoaded.value = true componentLoaded.value = true
} catch (error) { } catch (error) {
console.error(error) console.error(error)
} finally {
hasTriedLoading.value = true
} }
} }
// 加载时获取数据 const { stop } = useIntersectionObserver(
onMounted(fetchData) containerRef,
([{ isIntersecting }]) => {
if (isIntersecting) {
fetchData()
stop()
}
},
{
rootMargin: '300px',
},
)
onActivated(() => {
if (dataList.value.length === 0 && hasTriedLoading.value) {
fetchData()
}
})
</script> </script>
<template> <template>
<SlideView v-if="componentLoaded"> <div ref="containerRef">
<template #content> <VirtualSlideView v-if="componentLoaded" :items="dataList" :get-item-key="item => item.id">
<template v-for="data in dataList" :key="data.id"> <template #item="{ item }">
<PersonCard :person="data" width="9rem" /> <PersonCard :person="item" width="9rem" />
</template> </template>
</template> </VirtualSlideView>
</SlideView> <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> </template>

View File

@@ -46,6 +46,7 @@ const SystemSettings = ref<any>({
LLM_SUPPORT_AUDIO_INPUT_OUTPUT: false, LLM_SUPPORT_AUDIO_INPUT_OUTPUT: false,
LLM_API_KEY: null, LLM_API_KEY: null,
LLM_BASE_URL: 'https://api.deepseek.com', LLM_BASE_URL: 'https://api.deepseek.com',
LLM_BASE_URL_PRESET: null,
AI_VOICE_API_KEY: null, AI_VOICE_API_KEY: null,
AI_VOICE_BASE_URL: null, AI_VOICE_BASE_URL: null,
AI_VOICE_STT_MODEL: 'gpt-4o-mini-transcribe', AI_VOICE_STT_MODEL: 'gpt-4o-mini-transcribe',
@@ -73,6 +74,7 @@ const SystemSettings = ref<any>({
MOVIEPILOT_AUTO_UPDATE: false, MOVIEPILOT_AUTO_UPDATE: false,
// 媒体 // 媒体
RECOGNIZE_PLUGIN_FIRST: false, RECOGNIZE_PLUGIN_FIRST: false,
MEDIA_RECOGNIZE_SHARE: true,
TMDB_API_DOMAIN: null, TMDB_API_DOMAIN: null,
TMDB_IMAGE_DOMAIN: null, TMDB_IMAGE_DOMAIN: null,
TMDB_LOCALE: null, TMDB_LOCALE: null,
@@ -179,6 +181,7 @@ type LlmSettingsSnapshot = {
LLM_THINKING_LEVEL: string LLM_THINKING_LEVEL: string
LLM_API_KEY: string LLM_API_KEY: string
LLM_BASE_URL: string LLM_BASE_URL: string
LLM_BASE_URL_PRESET: string
} }
let llmTestRequestId = 0 let llmTestRequestId = 0
@@ -205,6 +208,13 @@ const llmBaseUrlRef = computed({
}, },
}) })
const llmBaseUrlPresetRef = computed({
get: () => String(SystemSettings.value.Basic.LLM_BASE_URL_PRESET ?? ''),
set: value => {
SystemSettings.value.Basic.LLM_BASE_URL_PRESET = value || ''
},
})
const llmModelRef = computed({ const llmModelRef = computed({
get: () => String(SystemSettings.value.Basic.LLM_MODEL ?? ''), get: () => String(SystemSettings.value.Basic.LLM_MODEL ?? ''),
set: value => { set: value => {
@@ -231,6 +241,7 @@ const {
showBaseUrlField, showBaseUrlField,
showApiKeyField, showApiKeyField,
canRefreshModels, canRefreshModels,
setBaseUrlPreset,
authDialogVisible, authDialogVisible,
authPolling, authPolling,
authPopupBlocked, authPopupBlocked,
@@ -248,6 +259,7 @@ const {
provider: llmProviderRef, provider: llmProviderRef,
apiKey: llmApiKeyRef, apiKey: llmApiKeyRef,
baseUrl: llmBaseUrlRef, baseUrl: llmBaseUrlRef,
baseUrlPreset: llmBaseUrlPresetRef,
model: llmModelRef, model: llmModelRef,
maxContextTokens: llmMaxContextRef, maxContextTokens: llmMaxContextRef,
}) })
@@ -260,6 +272,7 @@ function buildLlmSnapshot(): LlmSettingsSnapshot {
LLM_THINKING_LEVEL: String(SystemSettings.value.Basic.LLM_THINKING_LEVEL ?? 'off'), LLM_THINKING_LEVEL: String(SystemSettings.value.Basic.LLM_THINKING_LEVEL ?? 'off'),
LLM_API_KEY: String(SystemSettings.value.Basic.LLM_API_KEY ?? ''), LLM_API_KEY: String(SystemSettings.value.Basic.LLM_API_KEY ?? ''),
LLM_BASE_URL: String(SystemSettings.value.Basic.LLM_BASE_URL ?? ''), LLM_BASE_URL: String(SystemSettings.value.Basic.LLM_BASE_URL ?? ''),
LLM_BASE_URL_PRESET: String(SystemSettings.value.Basic.LLM_BASE_URL_PRESET ?? ''),
} }
} }
@@ -275,6 +288,7 @@ function buildLlmTestPayload(snapshot: LlmSettingsSnapshot) {
thinking_level: snapshot.LLM_THINKING_LEVEL.trim(), thinking_level: snapshot.LLM_THINKING_LEVEL.trim(),
api_key: snapshot.LLM_API_KEY.trim(), api_key: snapshot.LLM_API_KEY.trim(),
base_url: snapshot.LLM_BASE_URL.trim(), base_url: snapshot.LLM_BASE_URL.trim(),
base_url_preset: snapshot.LLM_BASE_URL_PRESET.trim(),
} }
} }
@@ -1015,9 +1029,15 @@ watch(currentLlmSnapshotKey, (snapshotKey, previousSnapshotKey) => {
<VCol v-if="SystemSettings.Basic.AI_AGENT_ENABLE && showBaseUrlField" cols="12" md="6"> <VCol v-if="SystemSettings.Basic.AI_AGENT_ENABLE && showBaseUrlField" cols="12" md="6">
<VCombobox <VCombobox
:model-value="SystemSettings.Basic.LLM_BASE_URL" :model-value="SystemSettings.Basic.LLM_BASE_URL"
@update:model-value="(value: any) => { @update:model-value="
SystemSettings.Basic.LLM_BASE_URL = typeof value === 'object' && value !== null ? value.value : (value || ''); (value: any) => {
}" if (typeof value === 'object' && value !== null) {
setBaseUrlPreset(value.id, value.value)
} else {
setBaseUrlPreset('', value || '')
}
}
"
:label="t('setting.system.llmBaseUrl')" :label="t('setting.system.llmBaseUrl')"
:hint="t('setting.system.llmBaseUrlHint')" :hint="t('setting.system.llmBaseUrlHint')"
:placeholder="selectedLlmProvider?.default_base_url || 'https://api.deepseek.com'" :placeholder="selectedLlmProvider?.default_base_url || 'https://api.deepseek.com'"
@@ -1043,10 +1063,7 @@ watch(currentLlmSnapshotKey, (snapshotKey, previousSnapshotKey) => {
prepend-inner-icon="mdi-key-variant" prepend-inner-icon="mdi-key-variant"
/> />
</VCol> </VCol>
<VCol <VCol v-if="SystemSettings.Basic.AI_AGENT_ENABLE && llmProviderAuthMethods.length > 0" cols="12">
v-if="SystemSettings.Basic.AI_AGENT_ENABLE && llmProviderAuthMethods.length > 0"
cols="12"
>
<VAlert type="info" variant="tonal"> <VAlert type="info" variant="tonal">
<div class="d-flex flex-column flex-md-row justify-space-between ga-3"> <div class="d-flex flex-column flex-md-row justify-space-between ga-3">
<div> <div>
@@ -1055,7 +1072,11 @@ watch(currentLlmSnapshotKey, (snapshotKey, previousSnapshotKey) => {
{{ selectedLlmProvider?.description || t('setting.system.llmProviderAuthHint') }} {{ selectedLlmProvider?.description || t('setting.system.llmProviderAuthHint') }}
</div> </div>
<div v-if="providerConnected" class="text-body-2 mt-2"> <div v-if="providerConnected" class="text-body-2 mt-2">
{{ t('setting.system.llmProviderConnectedAs', { label: llmProviderAuthLabel || selectedLlmProvider?.name }) }} {{
t('setting.system.llmProviderConnectedAs', {
label: llmProviderAuthLabel || selectedLlmProvider?.name,
})
}}
</div> </div>
</div> </div>
@@ -1088,10 +1109,12 @@ watch(currentLlmSnapshotKey, (snapshotKey, previousSnapshotKey) => {
<div> <div>
<VCombobox <VCombobox
:model-value="SystemSettings.Basic.LLM_MODEL" :model-value="SystemSettings.Basic.LLM_MODEL"
@update:model-value="(val: any) => { @update:model-value="
SystemSettings.Basic.LLM_MODEL = typeof val === 'object' && val !== null ? val.id : val; (val: any) => {
handleLlmModelChanged(); SystemSettings.Basic.LLM_MODEL = typeof val === 'object' && val !== null ? val.id : val
}" handleLlmModelChanged()
}
"
:label="t('setting.system.llmModel')" :label="t('setting.system.llmModel')"
:hint="t('setting.system.llmModelHint')" :hint="t('setting.system.llmModelHint')"
:placeholder="t('setting.system.llmModelHint')" :placeholder="t('setting.system.llmModelHint')"
@@ -1652,6 +1675,26 @@ watch(currentLlmSnapshotKey, (snapshotKey, previousSnapshotKey) => {
persistent-hint persistent-hint
/> />
</VCol> </VCol>
</VRow>
<VRow>
<VCol cols="12" md="6">
<VSwitch
v-model="SystemSettings.Advanced.RECOGNIZE_PLUGIN_FIRST"
:label="t('setting.system.recognizePluginFirst')"
:hint="t('setting.system.recognizePluginFirstHint')"
persistent-hint
/>
</VCol>
<VCol cols="12" md="6">
<VSwitch
v-model="SystemSettings.Advanced.MEDIA_RECOGNIZE_SHARE"
:label="t('setting.system.mediaRecognizeShare')"
:hint="t('setting.system.mediaRecognizeShareHint')"
persistent-hint
/>
</VCol>
</VRow>
<VRow>
<VCol cols="12" md="6"> <VCol cols="12" md="6">
<VSwitch <VSwitch
v-model="SystemSettings.Advanced.FANART_ENABLE" v-model="SystemSettings.Advanced.FANART_ENABLE"
@@ -1674,16 +1717,6 @@ watch(currentLlmSnapshotKey, (snapshotKey, previousSnapshotKey) => {
/> />
</VCol> </VCol>
</VRow> </VRow>
<VRow>
<VCol cols="12" md="6">
<VSwitch
v-model="SystemSettings.Advanced.RECOGNIZE_PLUGIN_FIRST"
:label="t('setting.system.recognizePluginFirst')"
:hint="t('setting.system.recognizePluginFirstHint')"
persistent-hint
/>
</VCol>
</VRow>
<!-- 刮削开关设置 --> <!-- 刮削开关设置 -->
<VRow class="mt-4"> <VRow class="mt-4">
@@ -2021,12 +2054,7 @@ watch(currentLlmSnapshotKey, (snapshotKey, previousSnapshotKey) => {
<VBtn color="primary" prepend-icon="mdi-open-in-new" @click="openAuthPage"> <VBtn color="primary" prepend-icon="mdi-open-in-new" @click="openAuthPage">
{{ t('setting.system.llmProviderOpenAuthPage') }} {{ t('setting.system.llmProviderOpenAuthPage') }}
</VBtn> </VBtn>
<VBtn <VBtn variant="tonal" prepend-icon="mdi-refresh" :loading="authPolling" @click="pollAuthSession">
variant="tonal"
prepend-icon="mdi-refresh"
:loading="authPolling"
@click="pollAuthSession"
>
{{ t('setting.system.llmProviderCheckAuthStatus') }} {{ t('setting.system.llmProviderCheckAuthStatus') }}
</VBtn> </VBtn>
</div> </div>

View File

@@ -30,6 +30,13 @@ const baseUrlRef = computed({
}, },
}) })
const baseUrlPresetRef = computed({
get: () => wizardData.value.agent.baseUrlPreset,
set: value => {
wizardData.value.agent.baseUrlPreset = value || ''
},
})
const modelRef = computed({ const modelRef = computed({
get: () => wizardData.value.agent.model, get: () => wizardData.value.agent.model,
set: value => { set: value => {
@@ -63,6 +70,7 @@ const {
showBaseUrlField, showBaseUrlField,
showApiKeyField, showApiKeyField,
canRefreshModels, canRefreshModels,
setBaseUrlPreset,
authDialogVisible, authDialogVisible,
authPolling, authPolling,
authPopupBlocked, authPopupBlocked,
@@ -80,6 +88,7 @@ const {
provider: providerRef, provider: providerRef,
apiKey: apiKeyRef, apiKey: apiKeyRef,
baseUrl: baseUrlRef, baseUrl: baseUrlRef,
baseUrlPreset: baseUrlPresetRef,
model: modelRef, model: modelRef,
maxContextTokens: maxContextTokensRef, maxContextTokens: maxContextTokensRef,
authConnected: authConnectedRef, authConnected: authConnectedRef,
@@ -232,7 +241,11 @@ onMounted(async () => {
<VCombobox <VCombobox
:model-value="wizardData.agent.baseUrl" :model-value="wizardData.agent.baseUrl"
@update:model-value="(value: any) => { @update:model-value="(value: any) => {
wizardData.agent.baseUrl = typeof value === 'object' && value !== null ? value.value : (value || ''); if (typeof value === 'object' && value !== null) {
setBaseUrlPreset(value.id, value.value);
} else {
setBaseUrlPreset('', value || '');
}
}" }"
:label="t('setting.system.llmBaseUrl')" :label="t('setting.system.llmBaseUrl')"
:hint="t('setting.system.llmBaseUrlHint')" :hint="t('setting.system.llmBaseUrlHint')"

View File

@@ -104,6 +104,17 @@ const { wizardData, selectDownloader, validationErrors } = useSetupWizard()
required required
/> />
</VCol> </VCol>
<VCol cols="12" md="6">
<VTextField
v-model="wizardData.downloader.config.apikey"
type="password"
:label="t('downloader.apiKey')"
:hint="t('downloader.qbittorrentApiKeyHint')"
persistent-hint
active
prepend-inner-icon="mdi-key-variant"
/>
</VCol>
<VCol cols="12" md="6"> <VCol cols="12" md="6">
<VTextField <VTextField
v-model="wizardData.downloader.config.username" v-model="wizardData.downloader.config.username"
@@ -111,10 +122,11 @@ const { wizardData, selectDownloader, validationErrors } = useSetupWizard()
:hint="t('downloader.username')" :hint="t('downloader.username')"
:error="validationErrors.downloader.username" :error="validationErrors.downloader.username"
:error-messages="validationErrors.downloader.username ? [t('downloader.usernameRequired')] : []" :error-messages="validationErrors.downloader.username ? [t('downloader.usernameRequired')] : []"
:disabled="!!wizardData.downloader.config.apikey"
persistent-hint persistent-hint
active active
prepend-inner-icon="mdi-account" prepend-inner-icon="mdi-account"
required :required="!wizardData.downloader.config.apikey"
/> />
</VCol> </VCol>
<VCol cols="12" md="6"> <VCol cols="12" md="6">
@@ -125,10 +137,11 @@ const { wizardData, selectDownloader, validationErrors } = useSetupWizard()
:hint="t('downloader.password')" :hint="t('downloader.password')"
:error="validationErrors.downloader.password" :error="validationErrors.downloader.password"
:error-messages="validationErrors.downloader.password ? [t('downloader.passwordRequired')] : []" :error-messages="validationErrors.downloader.password ? [t('downloader.passwordRequired')] : []"
:disabled="!!wizardData.downloader.config.apikey"
persistent-hint persistent-hint
active active
prepend-inner-icon="mdi-lock" prepend-inner-icon="mdi-lock"
required :required="!wizardData.downloader.config.apikey"
/> />
</VCol> </VCol>
<VCol cols="12" md="6"> <VCol cols="12" md="6">

View File

@@ -16,6 +16,30 @@ import { readFileSync } from 'node:fs'
const packageJson = JSON.parse(readFileSync('./package.json', 'utf-8')) const packageJson = JSON.parse(readFileSync('./package.json', 'utf-8'))
const buildTime = new Date().getTime().toString() 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/ // https://vitejs.dev/config/
export default defineConfig({ export default defineConfig({
base: './', base: './',
@@ -207,6 +231,11 @@ export default defineConfig({
build: { build: {
target: 'esnext', target: 'esnext',
minify: 'terser', minify: 'terser',
rollupOptions: {
output: {
manualChunks: getManualChunk,
},
},
terserOptions: { terserOptions: {
compress: { compress: {
drop_console: true, drop_console: true,

View File

@@ -1173,6 +1173,13 @@
dependencies: dependencies:
"@iconify/types" "*" "@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": "@iconify/tools@^4.0.4":
version "4.1.2" version "4.1.2"
resolved "https://registry.npmjs.org/@iconify/tools/-/tools-4.1.2.tgz" 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" resolved "https://registry.npmjs.org/std-env/-/std-env-3.9.0.tgz"
integrity sha512-UGvjygr6F6tpH7o2qyqR6QYpwraIjKSdtzyBdyytFOHmPZY917kwdwLG0RbOjWOnKmnm3PeHjaoLLMie7kPLQw== 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" version "4.2.3"
resolved "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz" resolved "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz"
integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==
@@ -7080,7 +7096,14 @@ stringify-object@^3.3.0:
is-obj "^1.0.1" is-obj "^1.0.1"
is-regexp "^1.0.0" 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" version "6.0.1"
resolved "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz" resolved "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz"
integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==