mirror of
https://github.com/jxxghp/MoviePilot-Frontend.git
synced 2026-05-11 18:10:49 +08:00
Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
090b9d735d | ||
|
|
dbeea6afcc | ||
|
|
2931f5df46 | ||
|
|
e14c81d178 | ||
|
|
a9403c9c34 | ||
|
|
dc4914e3ca | ||
|
|
f3dbc4afad |
@@ -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",
|
||||||
|
|||||||
@@ -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
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
50
src/App.vue
50
src/App.vue
@@ -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)
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 查询订阅弹窗规则
|
// 查询订阅弹窗规则
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
184
src/components/misc/VirtualCardGrid.vue
Normal file
184
src/components/misc/VirtualCardGrid.vue
Normal file
@@ -0,0 +1,184 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
const props = withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
items: any[]
|
||||||
|
minItemWidth?: number
|
||||||
|
itemAspectRatio?: number
|
||||||
|
gap?: number
|
||||||
|
overscanRows?: number
|
||||||
|
getItemKey?: (item: any, index: number) => string | number
|
||||||
|
}>(),
|
||||||
|
{
|
||||||
|
minItemWidth: 144,
|
||||||
|
itemAspectRatio: 1.5,
|
||||||
|
gap: 16,
|
||||||
|
overscanRows: 4,
|
||||||
|
getItemKey: undefined,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
const containerRef = ref<HTMLElement | null>(null)
|
||||||
|
const columnCount = ref(1)
|
||||||
|
const itemWidth = ref(props.minItemWidth)
|
||||||
|
const itemHeight = ref(props.minItemWidth * props.itemAspectRatio)
|
||||||
|
const startIndex = ref(0)
|
||||||
|
const endIndex = ref(0)
|
||||||
|
|
||||||
|
let resizeObserver: ResizeObserver | null = null
|
||||||
|
let animationFrameId: number | null = null
|
||||||
|
|
||||||
|
const rowStep = computed(() => itemHeight.value + props.gap)
|
||||||
|
const totalRows = computed(() => Math.ceil(props.items.length / columnCount.value))
|
||||||
|
|
||||||
|
const visibleItems = computed(() => props.items.slice(startIndex.value, endIndex.value))
|
||||||
|
|
||||||
|
const renderedRowCount = computed(() => {
|
||||||
|
if (!visibleItems.value.length) {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
return Math.ceil(visibleItems.value.length / columnCount.value)
|
||||||
|
})
|
||||||
|
|
||||||
|
const totalContentHeight = computed(() => {
|
||||||
|
if (!totalRows.value) {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
return totalRows.value * rowStep.value - props.gap
|
||||||
|
})
|
||||||
|
|
||||||
|
const topPadding = computed(() => {
|
||||||
|
if (!startIndex.value) {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
return Math.floor(startIndex.value / columnCount.value) * rowStep.value
|
||||||
|
})
|
||||||
|
|
||||||
|
const renderedHeight = computed(() => {
|
||||||
|
if (!renderedRowCount.value) {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
return renderedRowCount.value * rowStep.value - props.gap
|
||||||
|
})
|
||||||
|
|
||||||
|
const bottomPadding = computed(() => {
|
||||||
|
return Math.max(totalContentHeight.value - topPadding.value - renderedHeight.value, 0)
|
||||||
|
})
|
||||||
|
|
||||||
|
const gridStyle = computed(() => ({
|
||||||
|
columnGap: `${props.gap}px`,
|
||||||
|
gridTemplateColumns: `repeat(${columnCount.value}, minmax(0, 1fr))`,
|
||||||
|
paddingBottom: `${bottomPadding.value}px`,
|
||||||
|
paddingTop: `${topPadding.value}px`,
|
||||||
|
rowGap: `${props.gap}px`,
|
||||||
|
}))
|
||||||
|
|
||||||
|
function resolveItemKey(item: any, index: number) {
|
||||||
|
if (props.getItemKey) {
|
||||||
|
return props.getItemKey(item, startIndex.value + index)
|
||||||
|
}
|
||||||
|
|
||||||
|
return startIndex.value + index
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncVisibleRange() {
|
||||||
|
if (typeof window === 'undefined') {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const container = containerRef.value
|
||||||
|
if (!container || props.items.length === 0) {
|
||||||
|
startIndex.value = 0
|
||||||
|
endIndex.value = 0
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const containerWidth = container.clientWidth
|
||||||
|
if (!containerWidth) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const columns = Math.max(1, Math.floor((containerWidth + props.gap) / (props.minItemWidth + props.gap)))
|
||||||
|
columnCount.value = columns
|
||||||
|
itemWidth.value = (containerWidth - props.gap * (columns - 1)) / columns
|
||||||
|
itemHeight.value = itemWidth.value * props.itemAspectRatio
|
||||||
|
|
||||||
|
const rowHeight = rowStep.value || 1
|
||||||
|
const containerTop = window.scrollY + container.getBoundingClientRect().top
|
||||||
|
const viewportTop = window.scrollY - containerTop
|
||||||
|
const viewportBottom = viewportTop + window.innerHeight
|
||||||
|
const startRow = Math.max(0, Math.floor(viewportTop / rowHeight) - props.overscanRows)
|
||||||
|
const endRow = Math.min(totalRows.value, Math.ceil(viewportBottom / rowHeight) + props.overscanRows)
|
||||||
|
const endRowExclusive = Math.max(startRow + 1, endRow)
|
||||||
|
|
||||||
|
startIndex.value = Math.min(props.items.length, startRow * columns)
|
||||||
|
endIndex.value = Math.min(props.items.length, endRowExclusive * columns)
|
||||||
|
}
|
||||||
|
|
||||||
|
function queueSyncVisibleRange() {
|
||||||
|
if (typeof window === 'undefined' || animationFrameId !== null) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
animationFrameId = window.requestAnimationFrame(() => {
|
||||||
|
animationFrameId = null
|
||||||
|
syncVisibleRange()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
queueSyncVisibleRange()
|
||||||
|
window.addEventListener('scroll', queueSyncVisibleRange, { passive: true })
|
||||||
|
|
||||||
|
resizeObserver = new ResizeObserver(() => {
|
||||||
|
queueSyncVisibleRange()
|
||||||
|
})
|
||||||
|
|
||||||
|
if (containerRef.value) {
|
||||||
|
resizeObserver.observe(containerRef.value)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
window.removeEventListener('scroll', queueSyncVisibleRange)
|
||||||
|
|
||||||
|
if (animationFrameId !== null) {
|
||||||
|
window.cancelAnimationFrame(animationFrameId)
|
||||||
|
animationFrameId = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
resizeObserver?.disconnect()
|
||||||
|
resizeObserver = null
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.items.length,
|
||||||
|
() => {
|
||||||
|
nextTick(() => {
|
||||||
|
queueSyncVisibleRange()
|
||||||
|
})
|
||||||
|
},
|
||||||
|
{ immediate: true },
|
||||||
|
)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div ref="containerRef" class="virtual-card-grid">
|
||||||
|
<div class="grid" :style="gridStyle">
|
||||||
|
<template v-for="(item, index) in visibleItems" :key="resolveItemKey(item, index)">
|
||||||
|
<slot :item="item" :index="startIndex + index" />
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.virtual-card-grid {
|
||||||
|
inline-size: 100%;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
414
src/components/slide/VirtualSlideView.vue
Normal file
414
src/components/slide/VirtualSlideView.vue
Normal file
@@ -0,0 +1,414 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
import SlideViewTitle from '@/components/slide/SlideViewTitle.vue'
|
||||||
|
import { useDisplay } from 'vuetify'
|
||||||
|
|
||||||
|
const props = withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
items: any[]
|
||||||
|
itemWidth?: number
|
||||||
|
itemGap?: number
|
||||||
|
overscanItems?: number
|
||||||
|
getItemKey?: (item: any, index: number) => string | number
|
||||||
|
}>(),
|
||||||
|
{
|
||||||
|
itemWidth: 144,
|
||||||
|
itemGap: 16,
|
||||||
|
overscanItems: 4,
|
||||||
|
getItemKey: undefined,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
const display = useDisplay()
|
||||||
|
const isTouch = computed(() => display.mobile.value)
|
||||||
|
const injectedProps: any = inject('rankingPropsKey', { linkurl: '', title: '' })
|
||||||
|
|
||||||
|
const slideContentRef = ref<HTMLElement | null>(null)
|
||||||
|
const disabled = ref(0)
|
||||||
|
const slideScrollLeft = ref(0)
|
||||||
|
const isScrolling = ref(false)
|
||||||
|
const startIndex = ref(0)
|
||||||
|
const endIndex = ref(0)
|
||||||
|
|
||||||
|
let resizeObserver: ResizeObserver | null = null
|
||||||
|
let scrollTimeout: ReturnType<typeof setTimeout> | null = null
|
||||||
|
|
||||||
|
const scrollTimeoutDuration = 1500
|
||||||
|
const itemStep = computed(() => props.itemWidth + props.itemGap)
|
||||||
|
const visibleItems = computed(() => props.items.slice(startIndex.value, endIndex.value))
|
||||||
|
|
||||||
|
const leadingSpaceWidth = computed(() => startIndex.value * itemStep.value)
|
||||||
|
|
||||||
|
const visibleItemsWidth = computed(() => {
|
||||||
|
if (!visibleItems.value.length) {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
return visibleItems.value.length * props.itemWidth + Math.max(visibleItems.value.length - 1, 0) * props.itemGap
|
||||||
|
})
|
||||||
|
|
||||||
|
const totalContentWidth = computed(() => {
|
||||||
|
if (!props.items.length) {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
return props.items.length * props.itemWidth + Math.max(props.items.length - 1, 0) * props.itemGap
|
||||||
|
})
|
||||||
|
|
||||||
|
const trailingSpaceWidth = computed(() => {
|
||||||
|
return Math.max(totalContentWidth.value - leadingSpaceWidth.value - visibleItemsWidth.value, 0)
|
||||||
|
})
|
||||||
|
|
||||||
|
function resolveItemKey(item: any, index: number) {
|
||||||
|
if (props.getItemKey) {
|
||||||
|
return props.getItemKey(item, startIndex.value + index)
|
||||||
|
}
|
||||||
|
|
||||||
|
return startIndex.value + index
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetScrollIndicatorTimer() {
|
||||||
|
isScrolling.value = true
|
||||||
|
if (scrollTimeout) {
|
||||||
|
clearTimeout(scrollTimeout)
|
||||||
|
}
|
||||||
|
|
||||||
|
scrollTimeout = setTimeout(() => {
|
||||||
|
isScrolling.value = false
|
||||||
|
}, scrollTimeoutDuration)
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateVisibleRange() {
|
||||||
|
const element = slideContentRef.value
|
||||||
|
if (!element) {
|
||||||
|
startIndex.value = 0
|
||||||
|
endIndex.value = 0
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const viewportWidth = element.clientWidth
|
||||||
|
if (!viewportWidth || !props.items.length) {
|
||||||
|
startIndex.value = 0
|
||||||
|
endIndex.value = Math.min(props.items.length, props.overscanItems)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const firstVisible = Math.max(0, Math.floor(element.scrollLeft / itemStep.value) - props.overscanItems)
|
||||||
|
const lastVisible = Math.min(
|
||||||
|
props.items.length,
|
||||||
|
Math.ceil((element.scrollLeft + viewportWidth) / itemStep.value) + props.overscanItems,
|
||||||
|
)
|
||||||
|
|
||||||
|
startIndex.value = firstVisible
|
||||||
|
endIndex.value = Math.max(firstVisible + 1, lastVisible)
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateDisabledState() {
|
||||||
|
const element = slideContentRef.value
|
||||||
|
if (!element) return
|
||||||
|
|
||||||
|
slideScrollLeft.value = element.scrollLeft
|
||||||
|
|
||||||
|
if (!props.items.length || totalContentWidth.value <= element.clientWidth) {
|
||||||
|
disabled.value = 3
|
||||||
|
} else if (element.scrollLeft === 0) {
|
||||||
|
disabled.value = 0
|
||||||
|
} else if (element.scrollLeft >= element.scrollWidth - element.clientWidth - 2) {
|
||||||
|
disabled.value = 2
|
||||||
|
} else {
|
||||||
|
disabled.value = 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncLayoutState() {
|
||||||
|
updateVisibleRange()
|
||||||
|
updateDisabledState()
|
||||||
|
}
|
||||||
|
|
||||||
|
function slideNext(next: boolean) {
|
||||||
|
const element = slideContentRef.value
|
||||||
|
if (!element) return
|
||||||
|
|
||||||
|
const visibleCount = Math.max(1, Math.trunc(element.clientWidth / itemStep.value))
|
||||||
|
const currentIndex = element.scrollLeft === 0 ? 0 : Math.trunc((element.scrollLeft + itemStep.value / 2) / itemStep.value)
|
||||||
|
let targetLeft = 0
|
||||||
|
|
||||||
|
if (next) {
|
||||||
|
targetLeft = Math.min((currentIndex + visibleCount) * itemStep.value, element.scrollWidth - element.clientWidth)
|
||||||
|
} else {
|
||||||
|
targetLeft = Math.max((currentIndex - visibleCount) * itemStep.value, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
element.scrollTo({
|
||||||
|
behavior: 'smooth',
|
||||||
|
left: targetLeft,
|
||||||
|
top: 0,
|
||||||
|
})
|
||||||
|
|
||||||
|
resetScrollIndicatorTimer()
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleContentScroll() {
|
||||||
|
syncLayoutState()
|
||||||
|
resetScrollIndicatorTimer()
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
syncLayoutState()
|
||||||
|
|
||||||
|
resizeObserver = new ResizeObserver(() => {
|
||||||
|
syncLayoutState()
|
||||||
|
})
|
||||||
|
|
||||||
|
if (slideContentRef.value) {
|
||||||
|
resizeObserver.observe(slideContentRef.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener('resize', syncLayoutState)
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
if (scrollTimeout) {
|
||||||
|
clearTimeout(scrollTimeout)
|
||||||
|
scrollTimeout = null
|
||||||
|
}
|
||||||
|
|
||||||
|
window.removeEventListener('resize', syncLayoutState)
|
||||||
|
resizeObserver?.disconnect()
|
||||||
|
resizeObserver = null
|
||||||
|
})
|
||||||
|
|
||||||
|
onActivated(() => {
|
||||||
|
if (slideContentRef.value && slideScrollLeft.value !== 0) {
|
||||||
|
slideContentRef.value.scrollLeft = slideScrollLeft.value
|
||||||
|
}
|
||||||
|
|
||||||
|
nextTick(syncLayoutState)
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.items.length,
|
||||||
|
() => {
|
||||||
|
nextTick(syncLayoutState)
|
||||||
|
},
|
||||||
|
{ immediate: true },
|
||||||
|
)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="slider-container" :class="{ 'is-scrolling': isScrolling }">
|
||||||
|
<div class="slider-header">
|
||||||
|
<slot name="title">
|
||||||
|
<SlideViewTitle />
|
||||||
|
</slot>
|
||||||
|
<RouterLink v-if="injectedProps.linkurl" :to="injectedProps.linkurl" class="view-all-button">
|
||||||
|
<span>更多</span>
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" class="arrow-svg">
|
||||||
|
<path d="M8.59,16.58L13.17,12L8.59,7.41L10,6L16,12L10,18L8.59,16.58Z" />
|
||||||
|
</svg>
|
||||||
|
</RouterLink>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="slider-content-wrapper">
|
||||||
|
<div class="slider-content-container">
|
||||||
|
<div ref="slideContentRef" class="slider-content" tabindex="0" @scroll="handleContentScroll">
|
||||||
|
<div class="virtual-track" :style="{ width: `${totalContentWidth}px` }">
|
||||||
|
<div v-if="leadingSpaceWidth > 0" class="virtual-spacer" :style="{ width: `${leadingSpaceWidth}px` }" />
|
||||||
|
|
||||||
|
<template v-for="(item, index) in visibleItems" :key="resolveItemKey(item, index)">
|
||||||
|
<div
|
||||||
|
class="virtual-slide-item"
|
||||||
|
:style="{
|
||||||
|
marginInlineEnd: index === visibleItems.length - 1 ? '0px' : `${itemGap}px`,
|
||||||
|
width: `${itemWidth}px`,
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<slot name="item" :item="item" :index="startIndex + index" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<div v-if="trailingSpaceWidth > 0" class="virtual-spacer" :style="{ width: `${trailingSpaceWidth}px` }" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<VBtn
|
||||||
|
v-show="disabled !== 0 && disabled !== 3 && !isTouch"
|
||||||
|
class="nav-button nav-button-left"
|
||||||
|
variant="text"
|
||||||
|
icon
|
||||||
|
color="secondary"
|
||||||
|
@click.stop="slideNext(false)"
|
||||||
|
>
|
||||||
|
<svg width="24" height="24" viewBox="0 0 24 24">
|
||||||
|
<path d="M15.41,16.58L10.83,12L15.41,7.41L14,6L8,12L14,18L15.41,16.58Z" />
|
||||||
|
</svg>
|
||||||
|
</VBtn>
|
||||||
|
|
||||||
|
<VBtn
|
||||||
|
v-show="disabled !== 2 && disabled !== 3 && !isTouch"
|
||||||
|
class="nav-button nav-button-right"
|
||||||
|
variant="text"
|
||||||
|
icon
|
||||||
|
color="secondary"
|
||||||
|
@click.stop="slideNext(true)"
|
||||||
|
>
|
||||||
|
<svg width="24" height="24" viewBox="0 0 24 24">
|
||||||
|
<path d="M8.59,16.58L13.17,12L8.59,7.41L10,6L16,12L10,18L8.59,16.58Z" />
|
||||||
|
</svg>
|
||||||
|
</VBtn>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.slider-container {
|
||||||
|
position: relative;
|
||||||
|
margin-block-end: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slider-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 8px;
|
||||||
|
margin-block-end: 8px;
|
||||||
|
padding-block: 0;
|
||||||
|
padding-inline: 8px;
|
||||||
|
|
||||||
|
& > :first-child {
|
||||||
|
flex-grow: 1;
|
||||||
|
min-inline-size: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.view-all-button {
|
||||||
|
display: inline-flex;
|
||||||
|
flex-shrink: 0;
|
||||||
|
align-items: center;
|
||||||
|
border-radius: 8px;
|
||||||
|
background-color: transparent;
|
||||||
|
color: rgb(var(--v-theme-primary));
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 500;
|
||||||
|
padding-block: 5px;
|
||||||
|
padding-inline: 12px;
|
||||||
|
text-decoration: none;
|
||||||
|
transition: all 0.25s ease;
|
||||||
|
|
||||||
|
.arrow-svg {
|
||||||
|
fill: currentcolor;
|
||||||
|
margin-inline-start: 2px;
|
||||||
|
transition: transform 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
border-color: rgba(var(--v-theme-primary), 0.5);
|
||||||
|
background-color: rgba(var(--v-theme-primary), 0.08);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
|
||||||
|
.arrow-svg {
|
||||||
|
transform: translateX(3px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
span {
|
||||||
|
margin-inline-end: 4px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.slider-content-wrapper {
|
||||||
|
position: relative;
|
||||||
|
inline-size: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slider-content-container {
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
inline-size: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slider-content {
|
||||||
|
overflow: scroll hidden !important;
|
||||||
|
-ms-overflow-style: none !important;
|
||||||
|
overscroll-behavior-x: contain !important;
|
||||||
|
padding-block: 8px;
|
||||||
|
padding-inline: 12px;
|
||||||
|
scroll-behavior: smooth;
|
||||||
|
scrollbar-width: none !important;
|
||||||
|
|
||||||
|
&::-webkit-scrollbar {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.virtual-track {
|
||||||
|
display: flex;
|
||||||
|
inline-size: max-content;
|
||||||
|
}
|
||||||
|
|
||||||
|
.virtual-slide-item,
|
||||||
|
.virtual-spacer {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-button {
|
||||||
|
position: absolute;
|
||||||
|
z-index: 20;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 0;
|
||||||
|
border-radius: 50%;
|
||||||
|
backdrop-filter: blur(8px);
|
||||||
|
background-color: rgba(var(--v-theme-background), 0.3);
|
||||||
|
block-size: 36px;
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 8%);
|
||||||
|
cursor: pointer;
|
||||||
|
inline-size: 36px;
|
||||||
|
inset-block-start: 50%;
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
text-shadow: 0 1px 2px rgba(0, 0, 0, 10%);
|
||||||
|
transform: translateY(-50%);
|
||||||
|
transition: opacity 0.3s ease, transform 0.3s cubic-bezier(0.25, 0.8, 0.25, 1), background-color 0.3s ease,
|
||||||
|
box-shadow 0.3s ease, border-color 0.3s ease;
|
||||||
|
|
||||||
|
svg {
|
||||||
|
block-size: 22px;
|
||||||
|
fill: currentcolor;
|
||||||
|
filter: none;
|
||||||
|
inline-size: 22px;
|
||||||
|
opacity: 0.7;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: rgb(var(--v-theme-primary));
|
||||||
|
transform: translateY(-50%) scale(1.05);
|
||||||
|
|
||||||
|
svg {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-button-left {
|
||||||
|
inset-inline-start: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-button-right {
|
||||||
|
inset-inline-end: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slider-container.is-scrolling .nav-button {
|
||||||
|
opacity: 1;
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (hover: hover) {
|
||||||
|
.slider-container:hover .nav-button {
|
||||||
|
opacity: 1;
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -28,11 +28,24 @@ export function useBackgroundOptimization() {
|
|||||||
// 使用独立的SSE管理器,确保每个监听器都有独立的连接
|
// 使用独立的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
|
||||||
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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 || ''
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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" />
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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: '强制继续',
|
||||||
|
|||||||
@@ -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: '強制繼續',
|
||||||
|
|||||||
68
src/main.ts
68
src/main.ts
@@ -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', () => {
|
||||||
|
|||||||
@@ -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
1
src/types/iconify-bundle.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
declare module '@/@iconify/icons-bundle'
|
||||||
40
src/utils/apexCharts.ts
Normal file
40
src/utils/apexCharts.ts
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
declare global {
|
||||||
|
interface Window {
|
||||||
|
Apex: any
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function configureApexChartsTheme(themeName: string) {
|
||||||
|
if (typeof window === 'undefined' || !window.Apex) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const isDark = themeName === 'dark' || themeName === 'transparent'
|
||||||
|
|
||||||
|
window.Apex.dataLabels = {
|
||||||
|
formatter: function (_: number, { seriesIndex, w }: { seriesIndex: number; w: any }) {
|
||||||
|
const data = w.config.series[seriesIndex]
|
||||||
|
return data.toFixed(data % 1 === 0 ? 0 : 1)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
window.Apex.legend = {
|
||||||
|
labels: {
|
||||||
|
useSeriesColors: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
window.Apex.title = {
|
||||||
|
style: {
|
||||||
|
color: 'rgba(var(--v-theme-on-surface), var(--v-high-emphasis-opacity))',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
window.Apex.tooltip = {
|
||||||
|
theme: isDark ? 'dark' : 'light',
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('ApexCharts 全局配置失败:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
77
src/utils/mediaStatusCache.ts
Normal file
77
src/utils/mediaStatusCache.ts
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
type StatusCacheEntry = {
|
||||||
|
expiresAt: number
|
||||||
|
value: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const STATUS_CACHE_TTL = 3 * 60 * 1000
|
||||||
|
|
||||||
|
const existsStatusCache = new Map<string, StatusCacheEntry>()
|
||||||
|
const existsStatusRequests = new Map<string, Promise<boolean>>()
|
||||||
|
const subscribeStatusCache = new Map<string, StatusCacheEntry>()
|
||||||
|
const subscribeStatusRequests = new Map<string, Promise<boolean>>()
|
||||||
|
|
||||||
|
function getCachedValue(cache: Map<string, StatusCacheEntry>, key: string): boolean | undefined {
|
||||||
|
const entry = cache.get(key)
|
||||||
|
if (!entry) {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
if (entry.expiresAt <= Date.now()) {
|
||||||
|
cache.delete(key)
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
return entry.value
|
||||||
|
}
|
||||||
|
|
||||||
|
function setCachedValue(cache: Map<string, StatusCacheEntry>, key: string, value: boolean) {
|
||||||
|
cache.set(key, {
|
||||||
|
expiresAt: Date.now() + STATUS_CACHE_TTL,
|
||||||
|
value,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resolveCachedStatus(
|
||||||
|
cache: Map<string, StatusCacheEntry>,
|
||||||
|
requests: Map<string, Promise<boolean>>,
|
||||||
|
key: string,
|
||||||
|
loader: () => Promise<boolean>,
|
||||||
|
): Promise<boolean> {
|
||||||
|
const cachedValue = getCachedValue(cache, key)
|
||||||
|
if (cachedValue !== undefined) {
|
||||||
|
return cachedValue
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentRequest = requests.get(key)
|
||||||
|
if (currentRequest) {
|
||||||
|
return currentRequest
|
||||||
|
}
|
||||||
|
|
||||||
|
const request = loader()
|
||||||
|
.then(value => {
|
||||||
|
setCachedValue(cache, key, value)
|
||||||
|
return value
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
requests.delete(key)
|
||||||
|
})
|
||||||
|
|
||||||
|
requests.set(key, request)
|
||||||
|
return request
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getCachedMediaExistsStatus(key: string, loader: () => Promise<boolean>) {
|
||||||
|
return resolveCachedStatus(existsStatusCache, existsStatusRequests, key, loader)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setCachedMediaExistsStatus(key: string, value: boolean) {
|
||||||
|
setCachedValue(existsStatusCache, key, value)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getCachedMediaSubscribeStatus(key: string, loader: () => Promise<boolean>) {
|
||||||
|
return resolveCachedStatus(subscribeStatusCache, subscribeStatusRequests, key, loader)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setCachedMediaSubscribeStatus(key: string, value: boolean) {
|
||||||
|
setCachedValue(subscribeStatusCache, key, value)
|
||||||
|
}
|
||||||
@@ -16,6 +16,16 @@ export class SSEManager {
|
|||||||
}
|
}
|
||||||
private reconnectAttempts = 0
|
private 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()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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')"
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
27
yarn.lock
27
yarn.lock
@@ -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==
|
||||||
|
|||||||
Reference in New Issue
Block a user