mirror of
https://github.com/jxxghp/MoviePilot-Frontend.git
synced 2026-06-22 16:13:47 +08:00
Compare commits
54 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b49385af29 | ||
|
|
b227412c96 | ||
|
|
b3c8faab70 | ||
|
|
9a480dd803 | ||
|
|
847fd13982 | ||
|
|
7fa4f4a2f0 | ||
|
|
4207a70716 | ||
|
|
c97247b92b | ||
|
|
e9bed7ff8a | ||
|
|
f25a619f13 | ||
|
|
2065b05143 | ||
|
|
eec1f2d7b3 | ||
|
|
17a343392c | ||
|
|
a2b2e8cd94 | ||
|
|
9703b2dbee | ||
|
|
310a501380 | ||
|
|
30bf895ae1 | ||
|
|
4f9dce70d3 | ||
|
|
f495e13667 | ||
|
|
f293681588 | ||
|
|
2f1a356e65 | ||
|
|
5909d2423c | ||
|
|
42f7df8f4a | ||
|
|
abaa40d819 | ||
|
|
0d05a104c4 | ||
|
|
e8708f8de7 | ||
|
|
7918b21b5b | ||
|
|
088db67089 | ||
|
|
62e0d8e9dc | ||
|
|
96d655155a | ||
|
|
a475085d7b | ||
|
|
58fdb77b37 | ||
|
|
8a25c6578d | ||
|
|
ef62bd6e98 | ||
|
|
876a46607b | ||
|
|
107f70abde | ||
|
|
090b9d735d | ||
|
|
dbeea6afcc | ||
|
|
2931f5df46 | ||
|
|
e14c81d178 | ||
|
|
a9403c9c34 | ||
|
|
dc4914e3ca | ||
|
|
f3dbc4afad | ||
|
|
e3e22aebd9 | ||
|
|
0ca2f20b24 | ||
|
|
14279c773d | ||
|
|
8372f63eb6 | ||
|
|
b7b62d7922 | ||
|
|
162cce1f50 | ||
|
|
aa49c6ccbc | ||
|
|
a40e52079f | ||
|
|
c29e329548 | ||
|
|
e2d26f6a25 | ||
|
|
1752256868 |
193
index.html
193
index.html
@@ -1,11 +1,14 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN" style="
|
||||
overflow: hidden auto;
|
||||
min-block-size: 100vh;
|
||||
min-block-size: 100dvh;
|
||||
<html lang="zh-CN" data-launch-loading="true" style="
|
||||
overflow: hidden;
|
||||
--safe-area-inset-bottom: env(safe-area-inset-bottom);
|
||||
--safe-area-inset-top: env(safe-area-inset-top);
|
||||
background: var(--initial-loader-bg, #fff);
|
||||
--initial-loader-bg: #0E1116;
|
||||
--initial-loader-color: #9155FD;
|
||||
--initial-loader-height: 100svh;
|
||||
--initial-loader-width: 100vw;
|
||||
background: var(--initial-loader-bg, #0E1116);
|
||||
background-color: var(--initial-loader-bg, #0E1116);
|
||||
">
|
||||
|
||||
<head>
|
||||
@@ -92,50 +95,95 @@
|
||||
<link rel="preconnect" href="https://cdn.jsdelivr.net" crossorigin />
|
||||
|
||||
<style>
|
||||
#app {
|
||||
min-block-size: 100%;
|
||||
html,
|
||||
body {
|
||||
background: var(--initial-loader-bg, #0E1116);
|
||||
background-color: var(--initial-loader-bg, #0E1116);
|
||||
}
|
||||
|
||||
html[data-launch-loading="true"],
|
||||
html[data-launch-loading="true"] body {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
html[data-launch-loading="true"] body {
|
||||
min-block-size: var(--initial-loader-height, 100svh);
|
||||
}
|
||||
|
||||
html[data-launch-loading="true"] #app {
|
||||
min-block-size: var(--initial-loader-height, 100svh);
|
||||
background: var(--initial-loader-bg, #0E1116);
|
||||
background-color: var(--initial-loader-bg, #0E1116);
|
||||
-webkit-overflow-scrolling: touch;
|
||||
overscroll-behavior: contain;
|
||||
}
|
||||
|
||||
#loading-bg {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 99999;
|
||||
display: block;
|
||||
background: var(--initial-loader-bg, #fff);
|
||||
block-size: 100vh;
|
||||
inline-size: 100vw;
|
||||
transition: opacity 0.8s ease, transform 0.8s ease, filter 0.8s ease;
|
||||
overflow: hidden;
|
||||
background: var(--initial-loader-bg, #0E1116);
|
||||
background-color: var(--initial-loader-bg, #0E1116);
|
||||
}
|
||||
|
||||
.loading-shell {
|
||||
box-sizing: border-box;
|
||||
display: grid;
|
||||
grid-template-rows: minmax(0, 1fr) auto;
|
||||
block-size: var(--initial-loader-height, 100svh);
|
||||
inline-size: 100%;
|
||||
min-block-size: var(--initial-loader-height, 100svh);
|
||||
transition: opacity 0.12s ease-out, transform 0.12s ease-out;
|
||||
padding:
|
||||
calc(env(safe-area-inset-top, 0px) + 24px)
|
||||
24px
|
||||
calc(env(safe-area-inset-bottom, 0px) + 48px);
|
||||
}
|
||||
|
||||
.loading-main {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-block-size: 0;
|
||||
}
|
||||
|
||||
.loading-logo {
|
||||
position: absolute;
|
||||
inset-block-start: 35%;
|
||||
inset-inline-start: calc(50% - 5rem);
|
||||
transition: opacity 0.8s ease, transform 0.8s ease, filter 0.8s ease;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
inline-size: min(160px, 36vw);
|
||||
transform: translate3d(0, 0, 0);
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
.loading-complete .loading-logo {
|
||||
filter: blur(10px);
|
||||
opacity: 0;
|
||||
transform: scale(1.5);
|
||||
.loading-logo img {
|
||||
display: block;
|
||||
block-size: auto;
|
||||
inline-size: 100%;
|
||||
}
|
||||
|
||||
.loading-complete {
|
||||
filter: blur(15px);
|
||||
.loading-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-block-size: clamp(72px, 14vh, 120px);
|
||||
}
|
||||
|
||||
.loading-complete .loading-shell,
|
||||
.loading-complete #loading-timeout {
|
||||
opacity: 0;
|
||||
transform: scale(1.2);
|
||||
transform: translate3d(0, 6px, 0);
|
||||
}
|
||||
|
||||
.loading {
|
||||
position: absolute;
|
||||
position: relative;
|
||||
box-sizing: border-box;
|
||||
border: 3px solid transparent;
|
||||
border-radius: 50%;
|
||||
block-size: 55px;
|
||||
inline-size: 55px;
|
||||
inset-block-start: 80%;
|
||||
inset-inline-start: calc(50% - 27.5px);
|
||||
block-size: 46px;
|
||||
inline-size: 46px;
|
||||
transition: opacity 0.6s ease;
|
||||
}
|
||||
|
||||
@@ -198,7 +246,7 @@
|
||||
position: absolute;
|
||||
z-index: 2500;
|
||||
display: none;
|
||||
inset-block-end: 20px;
|
||||
inset-block-end: calc(env(safe-area-inset-bottom, 0px) + 24px);
|
||||
inset-inline-start: 50%;
|
||||
transform: translateX(-50%);
|
||||
background: rgba(0, 0, 0, 0.8);
|
||||
@@ -209,7 +257,8 @@
|
||||
font-family: sans-serif;
|
||||
text-align: center;
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4);
|
||||
white-space: nowrap;
|
||||
max-inline-size: calc(100% - 32px);
|
||||
white-space: normal;
|
||||
backdrop-filter: blur(4px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
@@ -233,25 +282,65 @@
|
||||
}
|
||||
}
|
||||
|
||||
// 主题色彩初始化
|
||||
let loaderColor = localStorage.getItem('materio-initial-loader-bg')
|
||||
let primaryColor = localStorage.getItem('materio-initial-loader-color')
|
||||
|
||||
// 检查主题设置
|
||||
const savedTheme = localStorage.getItem('theme') || 'auto'
|
||||
const isAutoTheme = savedTheme === 'auto'
|
||||
|
||||
// 如果是自动主题或者没有保存的背景色,根据系统主题设置背景色
|
||||
if (isAutoTheme || !loaderColor) {
|
||||
loaderColor = checkPrefersColorSchemeIsDark() ? '#0E1116' : '#FFFFFF'
|
||||
// 根据当前主题提前确定启动屏色彩,避免 iOS PWA 从原生启动图切到网页时露出默认白底。
|
||||
const launchThemeBackgrounds = {
|
||||
light: '#F4F5FA',
|
||||
dark: '#0E1116',
|
||||
purple: '#28243D',
|
||||
transparent: '#1C1C1C',
|
||||
default: '#F4F5FA',
|
||||
}
|
||||
|
||||
const savedTheme = localStorage.getItem('theme') || 'auto'
|
||||
const resolvedLaunchTheme = savedTheme === 'auto'
|
||||
? (checkPrefersColorSchemeIsDark() ? 'dark' : 'light')
|
||||
: savedTheme
|
||||
|
||||
let loaderColor = localStorage.getItem('materio-initial-loader-bg')
|
||||
|| launchThemeBackgrounds[resolvedLaunchTheme]
|
||||
|| launchThemeBackgrounds.light
|
||||
|
||||
let primaryColor = localStorage.getItem('materio-initial-loader-color')
|
||||
if (!primaryColor) {
|
||||
primaryColor = '#9155FD'
|
||||
}
|
||||
|
||||
// 在应用脚本接管前锁定一次启动层内容高度,避免 iOS 独立模式首次重算 safe area 时把 logo 顶下去。
|
||||
function syncInitialViewport(force) {
|
||||
const viewport = window.visualViewport
|
||||
const nextHeight = Math.round(viewport?.height || window.innerHeight || document.documentElement.clientHeight || 0)
|
||||
const nextWidth = Math.round(viewport?.width || window.innerWidth || document.documentElement.clientWidth || 0)
|
||||
const currentHeight = parseInt(
|
||||
document.documentElement.style.getPropertyValue('--initial-loader-height') || '0',
|
||||
10,
|
||||
)
|
||||
|
||||
if (!nextHeight || !nextWidth) {
|
||||
return
|
||||
}
|
||||
|
||||
if (!force && currentHeight && Math.abs(nextHeight - currentHeight) < 120) {
|
||||
return
|
||||
}
|
||||
|
||||
document.documentElement.style.setProperty('--initial-loader-height', `${nextHeight}px`)
|
||||
document.documentElement.style.setProperty('--initial-loader-width', `${nextWidth}px`)
|
||||
}
|
||||
|
||||
// 应用主题色彩
|
||||
document.documentElement.setAttribute('data-launch-theme', resolvedLaunchTheme)
|
||||
document.documentElement.style.setProperty('--initial-loader-bg', loaderColor)
|
||||
document.documentElement.style.setProperty('--initial-loader-color', primaryColor)
|
||||
document.documentElement.style.backgroundColor = loaderColor
|
||||
syncInitialViewport(true)
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
document.body.style.backgroundColor = loaderColor
|
||||
})
|
||||
|
||||
window.addEventListener('orientationchange', () => {
|
||||
window.setTimeout(() => syncInitialViewport(true), 160)
|
||||
})
|
||||
|
||||
// 状态栏适配
|
||||
if (window.navigator.standalone) {
|
||||
@@ -343,14 +432,20 @@
|
||||
|
||||
<body style="margin: 0; overflow: hidden; overscroll-behavior: none; -webkit-overflow-scrolling: touch">
|
||||
<div id="loading-bg">
|
||||
<div class="loading-logo">
|
||||
<!-- Logo -->
|
||||
<img src="/logo.svg" alt="MoviePilot" width="160px" height="160px" />
|
||||
</div>
|
||||
<div class="loading">
|
||||
<div class="effect-1 effects"></div>
|
||||
<div class="effect-2 effects"></div>
|
||||
<div class="effect-3 effects"></div>
|
||||
<div class="loading-shell">
|
||||
<div class="loading-main">
|
||||
<div class="loading-logo">
|
||||
<!-- Logo -->
|
||||
<img src="/logo.svg" alt="MoviePilot" width="160" height="160" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="loading-footer">
|
||||
<div class="loading">
|
||||
<div class="effect-1 effects"></div>
|
||||
<div class="effect-2 effects"></div>
|
||||
<div class="effect-3 effects"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 超时提示 - 默认隐藏 -->
|
||||
<div id="loading-timeout"></div>
|
||||
@@ -359,4 +454,4 @@
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
</html>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "moviepilot",
|
||||
"version": "2.10.9",
|
||||
"version": "2.11.2",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"bin": "dist/service.js",
|
||||
@@ -76,6 +76,7 @@
|
||||
"@iconify-json/lucide": "^1.2.85",
|
||||
"@iconify-json/material-symbols": "^1.2.51",
|
||||
"@iconify-json/mdi": "^1.1.52",
|
||||
"@iconify-json/tabler": "^1.2.23",
|
||||
"@iconify/tools": "^4.0.4",
|
||||
"@iconify/vue": "^4.3.0",
|
||||
"@intlify/unplugin-vue-i18n": "^6.0.3",
|
||||
|
||||
@@ -17,6 +17,7 @@ import { createRequire } from 'node:module'
|
||||
|
||||
// Get current directory
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url))
|
||||
const projectSrcDir = join(__dirname, '..')
|
||||
|
||||
// Create require function for importing JSON files in ESM
|
||||
const require = createRequire(import.meta.url)
|
||||
@@ -86,36 +87,12 @@ const sources: BundleScriptConfig = {
|
||||
],
|
||||
|
||||
icons: [
|
||||
// 'mdi:home',
|
||||
// 'mdi:account',
|
||||
// 'mdi:login',
|
||||
// 'mdi:logout',
|
||||
// 'octicon:book-24',
|
||||
// 'octicon:code-square-24',
|
||||
'lucide:sparkles',
|
||||
'material-symbols:passkey',
|
||||
'line-md:loading-twotone-loop',
|
||||
],
|
||||
|
||||
json: [
|
||||
// Custom JSON file
|
||||
// 'json/gg.json',
|
||||
|
||||
// Iconify JSON file (@iconify/json is a package name, /json/ is directory where files are, then filename)
|
||||
require.resolve('@iconify-json/mdi/icons.json'),
|
||||
|
||||
// Custom file with only few icons
|
||||
// {
|
||||
// filename: require.resolve('@iconify-json/line-md/icons.json'),
|
||||
// icons: [
|
||||
// 'home-twotone-alt',
|
||||
// 'github',
|
||||
// 'document-list',
|
||||
// 'document-code',
|
||||
// 'image-twotone',
|
||||
// ],
|
||||
// },
|
||||
],
|
||||
json: [],
|
||||
}
|
||||
|
||||
// Iconify component (this changes import statement in generated file)
|
||||
@@ -133,6 +110,15 @@ const target = join(__dirname, 'icons-bundle.js');
|
||||
*/
|
||||
// eslint-disable-next-line sonarjs/cognitive-complexity
|
||||
(async function () {
|
||||
const scannedIcons = await collectUsedIcons(projectSrcDir)
|
||||
|
||||
if (sources.icons) {
|
||||
sources.icons.push(...scannedIcons)
|
||||
sources.icons = Array.from(new Set(sources.icons)).sort()
|
||||
} else {
|
||||
sources.icons = scannedIcons
|
||||
}
|
||||
|
||||
let bundle = commonJS
|
||||
? `const { addCollection } = require('${component}');\n\n`
|
||||
: `import { addCollection } from '${component}';\n\n`
|
||||
@@ -280,6 +266,56 @@ const target = join(__dirname, 'icons-bundle.js');
|
||||
console.error(err)
|
||||
})
|
||||
|
||||
async function collectUsedIcons(rootDir: string): Promise<string[]> {
|
||||
const icons = new Set<string>()
|
||||
const files = await walkDirectory(rootDir)
|
||||
const sourceFiles = files.filter(file => /\.(vue|ts|js|tsx|jsx)$/.test(file))
|
||||
|
||||
for (const file of sourceFiles) {
|
||||
if (file.includes('/@iconify/')) {
|
||||
continue
|
||||
}
|
||||
|
||||
const content = await fs.readFile(file, 'utf8')
|
||||
|
||||
for (const match of content.matchAll(/\b(lucide|material-symbols|line-md|tabler):([a-z0-9-]+)\b/g)) {
|
||||
icons.add(`${match[1]}:${match[2]}`)
|
||||
}
|
||||
|
||||
for (const match of content.matchAll(/\bmdi:([a-z0-9-]+)\b/g)) {
|
||||
icons.add(`mdi:${match[1]}`)
|
||||
}
|
||||
|
||||
for (const match of content.matchAll(/\btabler-([a-z0-9-]+)\b/g)) {
|
||||
icons.add(`tabler:${match[1]}`)
|
||||
}
|
||||
|
||||
for (const match of content.matchAll(/\bmdi-([a-z0-9-]+)\b/g)) {
|
||||
icons.add(`mdi:${match[1]}`)
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(icons).sort()
|
||||
}
|
||||
|
||||
async function walkDirectory(dir: string): Promise<string[]> {
|
||||
const entries = await fs.readdir(dir, { withFileTypes: true })
|
||||
const files: string[] = []
|
||||
|
||||
for (const entry of entries) {
|
||||
const fullPath = join(dir, entry.name)
|
||||
|
||||
if (entry.isDirectory()) {
|
||||
files.push(...(await walkDirectory(fullPath)))
|
||||
continue
|
||||
}
|
||||
|
||||
files.push(fullPath)
|
||||
}
|
||||
|
||||
return files
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove metadata from icon set
|
||||
*/
|
||||
|
||||
@@ -19,6 +19,11 @@ export default defineComponent({
|
||||
const scrollDistance = ref(window.scrollY)
|
||||
const isDialogOpen = ref(false)
|
||||
const wasScrolledBeforeDialog = ref(false)
|
||||
let dialogObserver: MutationObserver | null = null
|
||||
|
||||
const handleScroll = () => {
|
||||
scrollDistance.value = window.scrollY
|
||||
}
|
||||
|
||||
// 监听弹窗状态变化
|
||||
const checkDialogState = () => {
|
||||
@@ -32,21 +37,25 @@ export default defineComponent({
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
window.addEventListener('scroll', () => {
|
||||
scrollDistance.value = window.scrollY
|
||||
})
|
||||
window.addEventListener('scroll', handleScroll)
|
||||
|
||||
// 初始检查弹窗状态
|
||||
checkDialogState()
|
||||
|
||||
// 监听 DOM 变化以检测弹窗状态
|
||||
const observer = new MutationObserver(checkDialogState)
|
||||
observer.observe(document.documentElement, {
|
||||
dialogObserver = new MutationObserver(checkDialogState)
|
||||
dialogObserver.observe(document.documentElement, {
|
||||
attributes: true,
|
||||
attributeFilter: ['class'],
|
||||
})
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener('scroll', handleScroll)
|
||||
dialogObserver?.disconnect()
|
||||
dialogObserver = null
|
||||
})
|
||||
|
||||
return () => {
|
||||
// 👉 Vertical nav
|
||||
const verticalNav = h(
|
||||
|
||||
73
src/App.vue
73
src/App.vue
@@ -12,6 +12,7 @@ import { globalLoadingStateManager } from '@/utils/loadingStateManager'
|
||||
import { addBackgroundTimer, removeBackgroundTimer } from '@/utils/backgroundManager'
|
||||
import PWAInstallPrompt from '@/components/PWAInstallPrompt.vue'
|
||||
import { themeManager } from '@/utils/themeManager'
|
||||
import { configureApexChartsTheme } from '@/utils/apexCharts'
|
||||
|
||||
// 生效主题
|
||||
const { global: globalTheme } = useTheme()
|
||||
@@ -19,6 +20,16 @@ let themeValue = localStorage.getItem('theme') || 'auto'
|
||||
const autoTheme = checkPrefersColorSchemeIsDark() ? 'dark' : 'light'
|
||||
globalTheme.name.value = themeValue === 'auto' ? autoTheme : themeValue
|
||||
|
||||
// 启动屏和 iOS safe area 在同一层显示,根节点底色需要尽早和当前主题保持一致。
|
||||
function syncRootLaunchPalette() {
|
||||
const { background, primary } = globalTheme.current.value.colors
|
||||
|
||||
document.documentElement.style.setProperty('--initial-loader-bg', background)
|
||||
document.documentElement.style.setProperty('--initial-loader-color', primary)
|
||||
document.documentElement.style.backgroundColor = background
|
||||
document.body.style.backgroundColor = background
|
||||
}
|
||||
|
||||
// 生效语言
|
||||
const localeValue = getBrowserLocale()
|
||||
setI18nLanguage(localeValue as SupportedLocale)
|
||||
@@ -41,13 +52,6 @@ const isTransparentTheme = computed(() => globalTheme.name.value === 'transparen
|
||||
// 心跳检测
|
||||
let heartbeatInterval: number | null = null
|
||||
|
||||
// ApexCharts 全局配置
|
||||
declare global {
|
||||
interface Window {
|
||||
Apex: any
|
||||
}
|
||||
}
|
||||
|
||||
// 启动心跳
|
||||
const startHeartbeat = () => {
|
||||
// 如果已经有心跳,则先停止
|
||||
@@ -75,48 +79,11 @@ const stopHeartbeat = () => {
|
||||
}
|
||||
}
|
||||
|
||||
// 配置 ApexCharts 全局选项
|
||||
function configureApexCharts() {
|
||||
if (typeof window !== 'undefined' && window.Apex) {
|
||||
try {
|
||||
// 获取当前主题
|
||||
const currentTheme = globalTheme.name.value
|
||||
const isDark = currentTheme === 'dark' || currentTheme === 'transparent'
|
||||
|
||||
// 数据标签
|
||||
window.Apex.dataLabels = {
|
||||
formatter: function (_: number, { seriesIndex, w }: { seriesIndex: number; w: any }) {
|
||||
// 如果有小数点,保留两位小数,否则保留整数
|
||||
const data = w.config.series[seriesIndex]
|
||||
return data.toFixed(data % 1 === 0 ? 0 : 1)
|
||||
},
|
||||
}
|
||||
// 图例
|
||||
window.Apex.legend = {
|
||||
labels: {
|
||||
useSeriesColors: true,
|
||||
},
|
||||
}
|
||||
// 标题
|
||||
window.Apex.title = {
|
||||
style: {
|
||||
color: 'rgba(var(--v-theme-on-surface), var(--v-high-emphasis-opacity))',
|
||||
},
|
||||
}
|
||||
// 鼠标悬浮提示
|
||||
window.Apex.tooltip = {
|
||||
theme: isDark ? 'dark' : 'light',
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('ApexCharts 全局配置失败:', error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 更新data-theme属性以便CSS选择器能正确匹配
|
||||
function updateHtmlThemeAttribute(themeName: string) {
|
||||
document.documentElement.setAttribute('data-theme', themeName)
|
||||
document.body.setAttribute('data-theme', themeName)
|
||||
syncRootLaunchPalette()
|
||||
}
|
||||
|
||||
// 获取背景图片
|
||||
@@ -170,8 +137,16 @@ function startBackgroundRotation() {
|
||||
function animateAndRemoveLoader() {
|
||||
const loadingBg = document.querySelector('#loading-bg') as HTMLElement
|
||||
if (loadingBg) {
|
||||
removeEl('#loading-bg')
|
||||
document.documentElement.style.removeProperty('background')
|
||||
// 只收掉启动内容,背景层保持实色直到节点被移除,避免底部 safe area 先透出页面内容。
|
||||
loadingBg.classList.add('loading-complete')
|
||||
window.setTimeout(() => {
|
||||
removeEl('#loading-bg')
|
||||
|
||||
// 启动阶段的根节点锁定只在 loader 存在时生效,移除后恢复正常页面与弹窗布局。
|
||||
document.documentElement.removeAttribute('data-launch-loading')
|
||||
document.documentElement.style.removeProperty('overflow')
|
||||
document.body.style.removeProperty('overflow')
|
||||
}, 120)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -250,7 +225,7 @@ onMounted(async () => {
|
||||
}
|
||||
|
||||
// 配置 ApexCharts
|
||||
configureApexCharts()
|
||||
configureApexChartsTheme(globalTheme.name.value)
|
||||
|
||||
// 初始化data-theme属性
|
||||
updateHtmlThemeAttribute(globalTheme.name.value)
|
||||
@@ -265,7 +240,7 @@ onMounted(async () => {
|
||||
// 更新HTML主题属性
|
||||
updateHtmlThemeAttribute(newTheme)
|
||||
// 重新配置ApexCharts以适应新主题
|
||||
configureApexCharts()
|
||||
configureApexChartsTheme(newTheme)
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@@ -68,6 +68,10 @@ export const mediaServerOptions = [
|
||||
value: 'emby',
|
||||
title: i18n.global.t('setting.system.emby'),
|
||||
},
|
||||
{
|
||||
value: 'zspace',
|
||||
title: i18n.global.t('setting.system.zspace'),
|
||||
},
|
||||
{
|
||||
value: 'jellyfin',
|
||||
title: i18n.global.t('setting.system.jellyfin'),
|
||||
|
||||
@@ -1145,7 +1145,7 @@ export interface StorageConf {
|
||||
export interface MediaServerConf {
|
||||
// 名称
|
||||
name: string
|
||||
// 类型 emby/jellyfin/plex/trimemedia/ugreen
|
||||
// 类型 emby/zspace/jellyfin/plex/trimemedia/ugreen
|
||||
type: string
|
||||
// 配置
|
||||
config: { [key: string]: any }
|
||||
@@ -1311,6 +1311,57 @@ export interface TransferForm {
|
||||
library_category_folder?: boolean
|
||||
// 剧集组编号
|
||||
episode_group?: string
|
||||
// 预览模式
|
||||
preview?: boolean
|
||||
}
|
||||
|
||||
// 手动整理请求
|
||||
export interface ManualTransferPayload extends TransferForm {}
|
||||
|
||||
// 手动整理预览统计
|
||||
export interface ManualTransferPreviewSummary {
|
||||
// 总数
|
||||
total: number
|
||||
// 成功数
|
||||
success: number
|
||||
// 失败数
|
||||
failed: number
|
||||
}
|
||||
|
||||
// 手动整理预览项
|
||||
export interface ManualTransferPreviewItem {
|
||||
// 原始路径
|
||||
source?: string
|
||||
// 目标路径
|
||||
target?: string
|
||||
// 目标目录
|
||||
target_dir?: string
|
||||
// 是否成功
|
||||
success?: boolean
|
||||
// 提示信息
|
||||
message?: string
|
||||
// 媒体类型
|
||||
type?: string
|
||||
// 媒体标题
|
||||
title?: string
|
||||
// 季
|
||||
season?: number | string
|
||||
// 开始集
|
||||
episode?: number | string
|
||||
// 结束集
|
||||
episode_end?: number | string
|
||||
// Part
|
||||
part?: string
|
||||
}
|
||||
|
||||
// 手动整理预览数据
|
||||
export interface ManualTransferPreviewData {
|
||||
// 统计信息
|
||||
summary: ManualTransferPreviewSummary
|
||||
// 预览结果
|
||||
items: ManualTransferPreviewItem[]
|
||||
// 额外消息
|
||||
message?: string
|
||||
}
|
||||
|
||||
// 整理队列
|
||||
|
||||
BIN
src/assets/images/logos/clawbot.png
Normal file
BIN
src/assets/images/logos/clawbot.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 63 KiB |
BIN
src/assets/images/logos/feishu.png
Normal file
BIN
src/assets/images/logos/feishu.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 6.1 KiB |
BIN
src/assets/images/logos/zspace.webp
Normal file
BIN
src/assets/images/logos/zspace.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 7.2 KiB |
@@ -346,11 +346,23 @@ onUnmounted(() => {
|
||||
prepend-inner-icon="mdi-server"
|
||||
/>
|
||||
</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">
|
||||
<VTextField
|
||||
v-model="downloaderInfo.config.username"
|
||||
:label="t('downloader.username')"
|
||||
:hint="t('downloader.username')"
|
||||
:disabled="!!downloaderInfo.config.apikey"
|
||||
persistent-hint
|
||||
active
|
||||
prepend-inner-icon="mdi-account"
|
||||
@@ -362,6 +374,7 @@ onUnmounted(() => {
|
||||
type="password"
|
||||
:label="t('downloader.password')"
|
||||
:hint="t('downloader.password')"
|
||||
:disabled="!!downloaderInfo.config.apikey"
|
||||
persistent-hint
|
||||
active
|
||||
prepend-inner-icon="mdi-lock"
|
||||
|
||||
@@ -40,6 +40,7 @@ function imageErrorHandler() {
|
||||
function getDefaultImage() {
|
||||
if (props.media?.server_type === 'plex') return plex
|
||||
else if (props.media?.server_type === 'emby') return emby
|
||||
else if (props.media?.server_type === 'zspace') return getLogoUrl('zspace')
|
||||
else if (props.media?.server_type === 'jellyfin') return jellyfin
|
||||
else if (props.media?.server_type === 'trimemedia') return getLogoUrl('trimemedia')
|
||||
else if (props.media?.server_type === 'ugreen') return getLogoUrl('ugreen')
|
||||
|
||||
@@ -14,6 +14,12 @@ import SubscribeSeasonDialog from '../dialog/SubscribeSeasonDialog.vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { mediaTypeDict } from '@/api/constants'
|
||||
import { hasPermission } from '@/utils/permission'
|
||||
import {
|
||||
getCachedMediaExistsStatus,
|
||||
getCachedMediaSubscribeStatus,
|
||||
setCachedMediaExistsStatus,
|
||||
setCachedMediaSubscribeStatus,
|
||||
} from '@/utils/mediaStatusCache'
|
||||
|
||||
// 国际化
|
||||
const { t } = useI18n()
|
||||
@@ -123,6 +129,22 @@ function getMediaId() {
|
||||
else return `${props.media?.mediaid_prefix}:${props.media?.media_id}`
|
||||
}
|
||||
|
||||
function getSubscribeStatusKey(season: number | null = props.media?.season ?? null) {
|
||||
return `${getMediaId()}::${season ?? 'all'}`
|
||||
}
|
||||
|
||||
function getExistsStatusKey() {
|
||||
return [
|
||||
props.media?.tmdb_id ?? '',
|
||||
props.media?.title ?? '',
|
||||
props.media?.year ?? '',
|
||||
props.media?.season ?? '',
|
||||
props.media?.type ?? '',
|
||||
props.media?.mediaid_prefix ?? '',
|
||||
props.media?.media_id ?? '',
|
||||
].join('::')
|
||||
}
|
||||
|
||||
// 角标颜色
|
||||
function getChipColor(type: string) {
|
||||
if (type === '电影') return 'border-blue-500 bg-blue-600'
|
||||
@@ -167,6 +189,7 @@ async function addSubscribe(season: number | null = null, best_version: number =
|
||||
if (result.success) {
|
||||
// 订阅成功
|
||||
isSubscribed.value = true
|
||||
setCachedMediaSubscribeStatus(getSubscribeStatusKey(season), true)
|
||||
}
|
||||
|
||||
// 提示
|
||||
@@ -213,6 +236,7 @@ async function removeSubscribe() {
|
||||
|
||||
if (result.success) {
|
||||
isSubscribed.value = false
|
||||
setCachedMediaSubscribeStatus(getSubscribeStatusKey(props.media?.season ?? null), false)
|
||||
$toast.success(`${props.media?.title} ${t('subscribe.cancelSuccess')}`)
|
||||
} else {
|
||||
$toast.error(`${props.media?.title} ${t('subscribe.cancelFailed', { message: result.message })}`)
|
||||
@@ -227,8 +251,10 @@ async function removeSubscribe() {
|
||||
// 查询当前媒体是否已订阅
|
||||
async function handleCheckSubscribe() {
|
||||
try {
|
||||
const result = await checkSubscribe(props.media?.season ?? null)
|
||||
if (result) isSubscribed.value = true
|
||||
const subscribed = await getCachedMediaSubscribeStatus(getSubscribeStatusKey(props.media?.season ?? null), () =>
|
||||
checkSubscribe(props.media?.season ?? null),
|
||||
)
|
||||
isSubscribed.value = subscribed
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
}
|
||||
@@ -237,17 +263,22 @@ async function handleCheckSubscribe() {
|
||||
// 查询当前媒体是否已入库
|
||||
async function handleCheckExists() {
|
||||
try {
|
||||
const result: { [key: string]: any } = await api.get('mediaserver/exists', {
|
||||
params: {
|
||||
tmdbid: props.media?.tmdb_id,
|
||||
title: props.media?.title,
|
||||
year: props.media?.year,
|
||||
season: props.media?.season,
|
||||
mtype: props.media?.type,
|
||||
},
|
||||
const exists = await getCachedMediaExistsStatus(getExistsStatusKey(), async () => {
|
||||
const result: { [key: string]: any } = await api.get('mediaserver/exists', {
|
||||
params: {
|
||||
tmdbid: props.media?.tmdb_id,
|
||||
title: props.media?.title,
|
||||
year: props.media?.year,
|
||||
season: props.media?.season,
|
||||
mtype: props.media?.type,
|
||||
},
|
||||
})
|
||||
|
||||
return Boolean(result.success)
|
||||
})
|
||||
|
||||
if (result.success) isExists.value = true
|
||||
isExists.value = exists
|
||||
setCachedMediaExistsStatus(getExistsStatusKey(), exists)
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
}
|
||||
@@ -265,12 +296,14 @@ async function checkSubscribe(season: number | null) {
|
||||
},
|
||||
})
|
||||
|
||||
return result.id || null
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
}
|
||||
return Boolean(result.id)
|
||||
} catch (error: any) {
|
||||
if (error?.response?.status === 404) {
|
||||
return false
|
||||
}
|
||||
|
||||
return null
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
// 查询订阅弹窗规则
|
||||
|
||||
@@ -121,6 +121,8 @@ const getIcon = computed(() => {
|
||||
switch (props.mediaserver.type) {
|
||||
case 'emby':
|
||||
return getLogoUrl('emby')
|
||||
case 'zspace':
|
||||
return getLogoUrl('zspace')
|
||||
case 'jellyfin':
|
||||
return getLogoUrl('jellyfin')
|
||||
case 'trimemedia':
|
||||
@@ -318,6 +320,77 @@ onMounted(() => {
|
||||
/>
|
||||
</VCol>
|
||||
</VRow>
|
||||
<VRow v-else-if="mediaServerInfo.type == 'zspace'">
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="mediaServerInfo.name"
|
||||
:label="t('common.name')"
|
||||
:placeholder="t('mediaserver.nameRequired')"
|
||||
:hint="t('mediaserver.serverAlias')"
|
||||
persistent-hint
|
||||
active
|
||||
prepend-inner-icon="mdi-label"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="mediaServerInfo.config.host"
|
||||
:label="t('mediaserver.host')"
|
||||
:placeholder="t('mediaserver.hostPlaceholder')"
|
||||
:hint="t('mediaserver.hostHint')"
|
||||
persistent-hint
|
||||
active
|
||||
prepend-inner-icon="mdi-server"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12">
|
||||
<VTextField
|
||||
v-model="mediaServerInfo.config.play_host"
|
||||
:label="t('mediaserver.playHost')"
|
||||
:placeholder="t('mediaserver.playHostPlaceholder')"
|
||||
:hint="t('mediaserver.playHostHint')"
|
||||
persistent-hint
|
||||
active
|
||||
prepend-inner-icon="mdi-play-network"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="mediaServerInfo.config.username"
|
||||
:label="t('mediaserver.username')"
|
||||
:hint="t('mediaserver.usernameHint')"
|
||||
persistent-hint
|
||||
active
|
||||
prepend-inner-icon="mdi-account"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
type="password"
|
||||
v-model="mediaServerInfo.config.password"
|
||||
:label="t('mediaserver.password')"
|
||||
persistent-hint
|
||||
active
|
||||
prepend-inner-icon="mdi-lock"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12">
|
||||
<VAutocomplete
|
||||
v-model="mediaServerInfo.sync_libraries"
|
||||
:label="t('mediaserver.syncLibraries')"
|
||||
:items="librariesOptions"
|
||||
chips
|
||||
multiple
|
||||
clearable
|
||||
:hint="t('mediaserver.syncLibrariesHint')"
|
||||
persistent-hint
|
||||
active
|
||||
append-inner-icon="mdi-refresh"
|
||||
prepend-inner-icon="mdi-library"
|
||||
@click:append-inner="loadLibrary(mediaServerInfo.name)"
|
||||
/>
|
||||
</VCol>
|
||||
</VRow>
|
||||
<VRow v-else-if="mediaServerInfo.type == 'jellyfin'">
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
<script setup lang="ts">
|
||||
import api from '@/api'
|
||||
import { NotificationConf } from '@/api/types'
|
||||
import { getLogoUrl } from '@/utils/imageUtils'
|
||||
import { useToast } from 'vue-toastification'
|
||||
import { cloneDeep } from 'lodash-es'
|
||||
import QRCode from 'qrcode'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useDisplay } from 'vuetify'
|
||||
|
||||
@@ -45,6 +47,8 @@ const notificationInfo = ref<NotificationConf>({
|
||||
// 各通知类型的名称字典
|
||||
const notificationTypeNames: { [key: string]: string } = {
|
||||
wechat: t('notification.wechat.name'),
|
||||
feishu: t('notification.feishu.name'),
|
||||
wechatclawbot: t('notification.wechatclawbot.name'),
|
||||
telegram: t('notification.telegram.name'),
|
||||
qqbot: t('notification.qqbot.name'),
|
||||
vocechat: t('notification.vocechat.name'),
|
||||
@@ -68,6 +72,30 @@ const notificationTypes = [
|
||||
{ value: '其它', title: t('notificationSwitch.other') },
|
||||
]
|
||||
|
||||
interface WechatClawBotStatus {
|
||||
connected?: boolean
|
||||
account_id?: string | null
|
||||
qrcode?: string | null
|
||||
qrcode_url?: string | null
|
||||
qrcode_status?: string | null
|
||||
qrcode_updated_at?: number | null
|
||||
known_targets?: Array<{ userid: string; username: string; last_active?: number | null }>
|
||||
default_target?: string | null
|
||||
base_url?: string | null
|
||||
}
|
||||
|
||||
interface WechatClawBotStatusFetchOptions {
|
||||
autoGenerateQrcode?: boolean
|
||||
silent?: boolean
|
||||
autoRefreshExpired?: boolean
|
||||
showErrorToast?: boolean
|
||||
}
|
||||
|
||||
interface WechatClawBotRefreshOptions {
|
||||
silent?: boolean
|
||||
showToast?: boolean
|
||||
}
|
||||
|
||||
function ensureWechatConfigDefaults(notification: NotificationConf) {
|
||||
if (notification.type !== 'wechat') {
|
||||
return
|
||||
@@ -83,6 +111,89 @@ function ensureWechatConfigDefaults(notification: NotificationConf) {
|
||||
}
|
||||
}
|
||||
|
||||
function ensureWechatClawBotConfigDefaults(notification: NotificationConf) {
|
||||
if (notification.type !== 'wechatclawbot') {
|
||||
return
|
||||
}
|
||||
if (!notification.config) {
|
||||
notification.config = {}
|
||||
}
|
||||
if (!notification.config.WECHATCLAWBOT_BASE_URL) {
|
||||
notification.config.WECHATCLAWBOT_BASE_URL = 'https://ilinkai.weixin.qq.com'
|
||||
}
|
||||
if (!notification.config.WECHATCLAWBOT_POLL_TIMEOUT) {
|
||||
notification.config.WECHATCLAWBOT_POLL_TIMEOUT = 25
|
||||
}
|
||||
}
|
||||
|
||||
const wechatClawBotLoading = ref(false)
|
||||
const wechatClawBotActionLoading = ref(false)
|
||||
const wechatClawBotStatus = ref<WechatClawBotStatus | null>(null)
|
||||
const wechatClawBotQrImage = ref('')
|
||||
const wechatClawBotExpiredRefreshAttempted = ref(false)
|
||||
let wechatClawBotTimer: number | null = null
|
||||
|
||||
function isImageSource(value?: string | null) {
|
||||
if (!value) {
|
||||
return false
|
||||
}
|
||||
const raw = value.trim()
|
||||
if (!raw) {
|
||||
return false
|
||||
}
|
||||
if (raw.toLowerCase().startsWith('data:image/')) {
|
||||
return true
|
||||
}
|
||||
return /\.(png|jpe?g|gif|webp|svg)(\?|$)/i.test(raw)
|
||||
}
|
||||
|
||||
function getWechatClawBotQrText(status?: WechatClawBotStatus | null) {
|
||||
const directUrl = status?.qrcode_url?.trim()
|
||||
if (directUrl) {
|
||||
return directUrl
|
||||
}
|
||||
const qrcode = status?.qrcode?.trim()
|
||||
if (!qrcode) {
|
||||
return ''
|
||||
}
|
||||
return `https://liteapp.weixin.qq.com/q/7GiQu1?qrcode=${encodeURIComponent(qrcode)}&bot_type=3`
|
||||
}
|
||||
|
||||
async function updateWechatClawBotQrImage(status?: WechatClawBotStatus | null) {
|
||||
const directUrl = status?.qrcode_url?.trim()
|
||||
if (isImageSource(directUrl)) {
|
||||
wechatClawBotQrImage.value = directUrl || ''
|
||||
return
|
||||
}
|
||||
const qrText = getWechatClawBotQrText(status)
|
||||
if (!qrText) {
|
||||
wechatClawBotQrImage.value = ''
|
||||
return
|
||||
}
|
||||
try {
|
||||
wechatClawBotQrImage.value = await QRCode.toDataURL(qrText, {
|
||||
width: 220,
|
||||
margin: 1,
|
||||
})
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
wechatClawBotQrImage.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
function getWechatClawBotRequestParams(extraParams: Record<string, any> = {}) {
|
||||
const config = notificationInfo.value.config || {}
|
||||
return {
|
||||
source: notificationInfo.value.name,
|
||||
fallback_source: props.notification.name,
|
||||
WECHATCLAWBOT_BASE_URL: config.WECHATCLAWBOT_BASE_URL,
|
||||
WECHATCLAWBOT_DEFAULT_TARGET: config.WECHATCLAWBOT_DEFAULT_TARGET,
|
||||
WECHATCLAWBOT_ADMINS: config.WECHATCLAWBOT_ADMINS,
|
||||
WECHATCLAWBOT_POLL_TIMEOUT: config.WECHATCLAWBOT_POLL_TIMEOUT,
|
||||
...extraParams,
|
||||
}
|
||||
}
|
||||
|
||||
const isWechatBotMode = computed({
|
||||
get: () => notificationInfo.value.config?.WECHAT_MODE === 'bot',
|
||||
set: value => {
|
||||
@@ -101,7 +212,14 @@ function openNotificationInfoDialog() {
|
||||
// 替换成深复制,避免修改时影响原数据
|
||||
notificationInfo.value = cloneDeep(props.notification)
|
||||
ensureWechatConfigDefaults(notificationInfo.value)
|
||||
ensureWechatClawBotConfigDefaults(notificationInfo.value)
|
||||
notificationInfoDialog.value = true
|
||||
if (notificationInfo.value.type === 'wechatclawbot') {
|
||||
fetchWechatClawBotStatus({
|
||||
autoGenerateQrcode: true,
|
||||
autoRefreshExpired: true,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 保存详情数据
|
||||
@@ -117,16 +235,191 @@ function saveNotificationInfo() {
|
||||
return
|
||||
}
|
||||
ensureWechatConfigDefaults(notificationInfo.value)
|
||||
ensureWechatClawBotConfigDefaults(notificationInfo.value)
|
||||
notificationInfoDialog.value = false
|
||||
emit('change', notificationInfo.value, props.notification.name)
|
||||
emit('done')
|
||||
}
|
||||
|
||||
function clearWechatClawBotTimer() {
|
||||
if (wechatClawBotTimer) {
|
||||
window.clearTimeout(wechatClawBotTimer)
|
||||
wechatClawBotTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleWechatClawBotRefresh() {
|
||||
clearWechatClawBotTimer()
|
||||
if (!notificationInfoDialog.value || notificationInfo.value.type !== 'wechatclawbot') {
|
||||
return
|
||||
}
|
||||
const connected = wechatClawBotStatus.value?.connected
|
||||
const pendingStatus = ['waiting', 'scanned'].includes((wechatClawBotStatus.value?.qrcode_status || '').toLowerCase())
|
||||
if (connected || pendingStatus) {
|
||||
wechatClawBotTimer = window.setTimeout(() => {
|
||||
fetchWechatClawBotStatus({
|
||||
silent: true,
|
||||
autoRefreshExpired: true,
|
||||
})
|
||||
}, connected ? 10000 : 3000)
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchWechatClawBotStatus(options: WechatClawBotStatusFetchOptions = {}) {
|
||||
const {
|
||||
autoGenerateQrcode = false,
|
||||
silent = false,
|
||||
autoRefreshExpired = false,
|
||||
showErrorToast = true,
|
||||
} = options
|
||||
if (notificationInfo.value.type !== 'wechatclawbot' || !notificationInfo.value.name) {
|
||||
return
|
||||
}
|
||||
if (!silent) {
|
||||
wechatClawBotLoading.value = true
|
||||
}
|
||||
try {
|
||||
const result: { [key: string]: any } = await api.get('notification/wechatclawbot/status', {
|
||||
params: getWechatClawBotRequestParams({ auto_generate_qrcode: autoGenerateQrcode }),
|
||||
})
|
||||
if (result.success) {
|
||||
wechatClawBotStatus.value = result.data
|
||||
await updateWechatClawBotQrImage(result.data)
|
||||
const status = (result.data?.qrcode_status || '').toLowerCase()
|
||||
if (status !== 'expired') {
|
||||
wechatClawBotExpiredRefreshAttempted.value = false
|
||||
}
|
||||
if (
|
||||
autoRefreshExpired &&
|
||||
!result.data?.connected &&
|
||||
status === 'expired' &&
|
||||
!wechatClawBotExpiredRefreshAttempted.value
|
||||
) {
|
||||
wechatClawBotExpiredRefreshAttempted.value = true
|
||||
await refreshWechatClawBotQrcode({
|
||||
silent: true,
|
||||
showToast: false,
|
||||
})
|
||||
return
|
||||
}
|
||||
scheduleWechatClawBotRefresh()
|
||||
} else {
|
||||
wechatClawBotStatus.value = null
|
||||
wechatClawBotQrImage.value = ''
|
||||
clearWechatClawBotTimer()
|
||||
if (showErrorToast) {
|
||||
$toast.error(result.message || t('notification.wechatclawbot.statusLoadFailed'))
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
clearWechatClawBotTimer()
|
||||
if (showErrorToast) {
|
||||
$toast.error(t('notification.wechatclawbot.statusLoadFailed'))
|
||||
}
|
||||
} finally {
|
||||
if (!silent) {
|
||||
wechatClawBotLoading.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshWechatClawBotQrcode(options: WechatClawBotRefreshOptions = {}) {
|
||||
const { silent = false, showToast = true } = options
|
||||
if (!notificationInfo.value.name) {
|
||||
return
|
||||
}
|
||||
if (!silent) {
|
||||
wechatClawBotActionLoading.value = true
|
||||
}
|
||||
try {
|
||||
const result: { [key: string]: any } = await api.post('notification/wechatclawbot/refresh', null, {
|
||||
params: getWechatClawBotRequestParams(),
|
||||
})
|
||||
if (result.success) {
|
||||
wechatClawBotStatus.value = result.data
|
||||
await updateWechatClawBotQrImage(result.data)
|
||||
wechatClawBotExpiredRefreshAttempted.value = false
|
||||
scheduleWechatClawBotRefresh()
|
||||
if (showToast) {
|
||||
$toast.success(t('notification.wechatclawbot.qrcodeRefreshSuccess'))
|
||||
}
|
||||
} else {
|
||||
if (showToast) {
|
||||
$toast.error(result.message || t('notification.wechatclawbot.qrcodeRefreshFailed'))
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
if (showToast) {
|
||||
$toast.error(t('notification.wechatclawbot.qrcodeRefreshFailed'))
|
||||
}
|
||||
} finally {
|
||||
if (!silent) {
|
||||
wechatClawBotActionLoading.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function logoutWechatClawBot() {
|
||||
if (!notificationInfo.value.name) {
|
||||
return
|
||||
}
|
||||
wechatClawBotActionLoading.value = true
|
||||
try {
|
||||
const result: { [key: string]: any } = await api.post('notification/wechatclawbot/logout', null, {
|
||||
params: getWechatClawBotRequestParams(),
|
||||
})
|
||||
if (result.success) {
|
||||
$toast.success(result.message || t('notification.wechatclawbot.logoutSuccess'))
|
||||
await fetchWechatClawBotStatus({
|
||||
autoGenerateQrcode: true,
|
||||
autoRefreshExpired: true,
|
||||
})
|
||||
} else {
|
||||
$toast.error(result.message || t('notification.wechatclawbot.logoutFailed'))
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
$toast.error(t('notification.wechatclawbot.logoutFailed'))
|
||||
} finally {
|
||||
wechatClawBotActionLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function formatWechatClawBotTime(timestamp?: number | null) {
|
||||
if (!timestamp) {
|
||||
return ''
|
||||
}
|
||||
return new Date(timestamp * 1000).toLocaleString()
|
||||
}
|
||||
|
||||
const wechatClawBotStatusText = computed(() => {
|
||||
const status = (wechatClawBotStatus.value?.qrcode_status || '').toLowerCase()
|
||||
if (wechatClawBotStatus.value?.connected) {
|
||||
return t('notification.wechatclawbot.connected')
|
||||
}
|
||||
if (status === 'scanned') {
|
||||
return t('notification.wechatclawbot.scanned')
|
||||
}
|
||||
if (status === 'expired') {
|
||||
return t('notification.wechatclawbot.expired')
|
||||
}
|
||||
if (status === 'confirmed') {
|
||||
return t('notification.wechatclawbot.confirmed')
|
||||
}
|
||||
return t('notification.wechatclawbot.waiting')
|
||||
})
|
||||
|
||||
// 根据存储类型选择图标
|
||||
const getIcon = computed(() => {
|
||||
switch (props.notification.type) {
|
||||
case 'wechat':
|
||||
return getLogoUrl('wechat')
|
||||
case 'wechatclawbot':
|
||||
return getLogoUrl('wechatclawbot')
|
||||
case 'feishu':
|
||||
return getLogoUrl('feishu')
|
||||
case 'telegram':
|
||||
return getLogoUrl('telegram')
|
||||
case 'qqbot':
|
||||
@@ -148,8 +441,17 @@ const getIcon = computed(() => {
|
||||
|
||||
// 按钮点击
|
||||
function onClose() {
|
||||
clearWechatClawBotTimer()
|
||||
emit('close')
|
||||
}
|
||||
|
||||
watch(notificationInfoDialog, value => {
|
||||
if (!value) {
|
||||
clearWechatClawBotTimer()
|
||||
wechatClawBotQrImage.value = ''
|
||||
wechatClawBotExpiredRefreshAttempted.value = false
|
||||
}
|
||||
})
|
||||
</script>
|
||||
<template>
|
||||
<div>
|
||||
@@ -347,6 +649,215 @@ function onClose() {
|
||||
</VCol>
|
||||
</template>
|
||||
</VRow>
|
||||
<VRow v-else-if="notificationInfo.type == 'wechatclawbot'">
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="notificationInfo.name"
|
||||
:label="t('notification.name')"
|
||||
:placeholder="t('notification.name')"
|
||||
:hint="t('notification.nameHint')"
|
||||
persistent-hint
|
||||
prepend-inner-icon="mdi-label"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="notificationInfo.config.WECHATCLAWBOT_BASE_URL"
|
||||
:label="t('notification.wechatclawbot.baseUrl')"
|
||||
:hint="t('notification.wechatclawbot.baseUrlHint')"
|
||||
persistent-hint
|
||||
prepend-inner-icon="mdi-web"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="notificationInfo.config.WECHATCLAWBOT_DEFAULT_TARGET"
|
||||
:label="t('notification.wechatclawbot.defaultTarget')"
|
||||
:placeholder="t('notification.wechatclawbot.defaultTargetPlaceholder')"
|
||||
:hint="t('notification.wechatclawbot.defaultTargetHint')"
|
||||
persistent-hint
|
||||
prepend-inner-icon="mdi-account-arrow-right"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="notificationInfo.config.WECHATCLAWBOT_ADMINS"
|
||||
:label="t('notification.wechatclawbot.admins')"
|
||||
:placeholder="t('notification.wechatclawbot.adminsPlaceholder')"
|
||||
:hint="t('notification.wechatclawbot.adminsHint')"
|
||||
persistent-hint
|
||||
prepend-inner-icon="mdi-account-supervisor"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="notificationInfo.config.WECHATCLAWBOT_POLL_TIMEOUT"
|
||||
:label="t('notification.wechatclawbot.pollTimeout')"
|
||||
:hint="t('notification.wechatclawbot.pollTimeoutHint')"
|
||||
persistent-hint
|
||||
type="number"
|
||||
prepend-inner-icon="mdi-timer-outline"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12">
|
||||
<VCard variant="tonal" class="pa-4">
|
||||
<div class="d-flex flex-wrap align-center justify-space-between gap-3 mb-3">
|
||||
<div>
|
||||
<div class="text-subtitle-1 font-weight-medium">{{ t('notification.wechatclawbot.loginStatus') }}</div>
|
||||
<div class="text-body-2 text-medium-emphasis">{{ wechatClawBotStatusText }}</div>
|
||||
</div>
|
||||
<div class="d-flex flex-wrap gap-2">
|
||||
<VBtn
|
||||
size="small"
|
||||
variant="tonal"
|
||||
:loading="wechatClawBotLoading"
|
||||
@click.stop="fetchWechatClawBotStatus({ autoGenerateQrcode: true, autoRefreshExpired: true })"
|
||||
>
|
||||
{{ t('common.refresh') }}
|
||||
</VBtn>
|
||||
<VBtn
|
||||
size="small"
|
||||
color="primary"
|
||||
variant="tonal"
|
||||
:loading="wechatClawBotActionLoading"
|
||||
@click.stop="refreshWechatClawBotQrcode"
|
||||
>
|
||||
{{ t('notification.wechatclawbot.refreshQrcode') }}
|
||||
</VBtn>
|
||||
<VBtn
|
||||
size="small"
|
||||
color="error"
|
||||
variant="tonal"
|
||||
:loading="wechatClawBotActionLoading"
|
||||
:disabled="!wechatClawBotStatus?.connected"
|
||||
@click.stop="logoutWechatClawBot"
|
||||
>
|
||||
{{ t('notification.wechatclawbot.logout') }}
|
||||
</VBtn>
|
||||
</div>
|
||||
</div>
|
||||
<VRow>
|
||||
<VCol cols="12" md="5">
|
||||
<div class="rounded text-center p-3 border h-100 d-flex align-center justify-center min-h-[16rem]">
|
||||
<VImg
|
||||
v-if="wechatClawBotQrImage"
|
||||
:src="wechatClawBotQrImage"
|
||||
width="220"
|
||||
height="220"
|
||||
class="mx-auto"
|
||||
/>
|
||||
<VProgressCircular v-else-if="wechatClawBotLoading" indeterminate color="primary" />
|
||||
<div v-else class="text-body-2 text-medium-emphasis">
|
||||
{{ t('notification.wechatclawbot.noQrcode') }}
|
||||
</div>
|
||||
</div>
|
||||
</VCol>
|
||||
<VCol cols="12" md="7">
|
||||
<VAlert variant="tonal" :type="wechatClawBotStatus?.connected ? 'success' : 'info'" class="mb-3">
|
||||
<div class="text-body-2">{{ t('notification.wechatclawbot.scanHint') }}</div>
|
||||
<div v-if="wechatClawBotStatus?.account_id" class="mt-2">
|
||||
{{ t('notification.wechatclawbot.accountId') }}: {{ wechatClawBotStatus.account_id }}
|
||||
</div>
|
||||
<div v-if="wechatClawBotStatus?.qrcode_updated_at" class="mt-2">
|
||||
{{ t('notification.wechatclawbot.qrcodeUpdatedAt') }}:
|
||||
{{ formatWechatClawBotTime(wechatClawBotStatus.qrcode_updated_at) }}
|
||||
</div>
|
||||
</VAlert>
|
||||
<div class="text-subtitle-2 mb-2">{{ t('notification.wechatclawbot.knownTargets') }}</div>
|
||||
<VList v-if="wechatClawBotStatus?.known_targets?.length" density="compact" class="border rounded">
|
||||
<VListItem
|
||||
v-for="item in wechatClawBotStatus.known_targets"
|
||||
:key="item.userid"
|
||||
:title="item.username || item.userid"
|
||||
:subtitle="`${item.userid}${item.last_active ? ` · ${formatWechatClawBotTime(item.last_active)}` : ''}`"
|
||||
/>
|
||||
</VList>
|
||||
<div v-else class="text-body-2 text-medium-emphasis">
|
||||
{{ t('notification.wechatclawbot.noKnownTargets') }}
|
||||
</div>
|
||||
</VCol>
|
||||
</VRow>
|
||||
</VCard>
|
||||
</VCol>
|
||||
</VRow>
|
||||
<VRow v-else-if="notificationInfo.type == 'feishu'">
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="notificationInfo.name"
|
||||
:label="t('notification.name')"
|
||||
:placeholder="t('notification.name')"
|
||||
:hint="t('notification.nameHint')"
|
||||
persistent-hint
|
||||
prepend-inner-icon="mdi-label"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="notificationInfo.config.FEISHU_APP_ID"
|
||||
:label="t('notification.feishu.appId')"
|
||||
:hint="t('notification.feishu.appIdHint')"
|
||||
persistent-hint
|
||||
prepend-inner-icon="mdi-application"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="notificationInfo.config.FEISHU_APP_SECRET"
|
||||
:label="t('notification.feishu.appSecret')"
|
||||
:hint="t('notification.feishu.appSecretHint')"
|
||||
persistent-hint
|
||||
prepend-inner-icon="mdi-key"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="notificationInfo.config.FEISHU_OPEN_ID"
|
||||
:label="t('notification.feishu.openId')"
|
||||
:placeholder="t('notification.feishu.openIdPlaceholder')"
|
||||
:hint="t('notification.feishu.openIdHint')"
|
||||
persistent-hint
|
||||
prepend-inner-icon="mdi-account"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="notificationInfo.config.FEISHU_CHAT_ID"
|
||||
:label="t('notification.feishu.chatId')"
|
||||
:placeholder="t('notification.feishu.chatIdPlaceholder')"
|
||||
:hint="t('notification.feishu.chatIdHint')"
|
||||
persistent-hint
|
||||
prepend-inner-icon="mdi-chat-processing"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="notificationInfo.config.FEISHU_ADMINS"
|
||||
:label="t('notification.feishu.admins')"
|
||||
:placeholder="t('notification.feishu.adminsPlaceholder')"
|
||||
:hint="t('notification.feishu.adminsHint')"
|
||||
persistent-hint
|
||||
prepend-inner-icon="mdi-account-supervisor"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="notificationInfo.config.FEISHU_VERIFICATION_TOKEN"
|
||||
:label="t('notification.feishu.verificationToken')"
|
||||
:hint="t('notification.feishu.verificationTokenHint')"
|
||||
persistent-hint
|
||||
prepend-inner-icon="mdi-shield-key"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="notificationInfo.config.FEISHU_ENCRYPT_KEY"
|
||||
:label="t('notification.feishu.encryptKey')"
|
||||
:hint="t('notification.feishu.encryptKeyHint')"
|
||||
persistent-hint
|
||||
prepend-inner-icon="mdi-lock"
|
||||
/>
|
||||
</VCol>
|
||||
</VRow>
|
||||
<VRow v-else-if="notificationInfo.type == 'telegram'">
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
|
||||
@@ -25,6 +25,10 @@ const props = defineProps({
|
||||
action: Boolean, // 动作标识
|
||||
width: String,
|
||||
height: String,
|
||||
sortable: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
})
|
||||
|
||||
// 定义触发的自定义事件
|
||||
@@ -269,6 +273,14 @@ function openPluginDetail() {
|
||||
else showPluginConfig()
|
||||
}
|
||||
|
||||
function handleCardClick() {
|
||||
if (props.sortable) {
|
||||
return
|
||||
}
|
||||
|
||||
openPluginDetail()
|
||||
}
|
||||
|
||||
// 配置完成
|
||||
function configDone() {
|
||||
pluginConfigDialog.value = false
|
||||
@@ -420,6 +432,7 @@ watch(
|
||||
(newOpenState, _) => {
|
||||
if (newOpenState) openPluginDetail()
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
</script>
|
||||
|
||||
@@ -433,11 +446,13 @@ watch(
|
||||
v-bind="hover.props"
|
||||
:width="props.width"
|
||||
:height="props.height"
|
||||
@click="openPluginDetail"
|
||||
@click="handleCardClick"
|
||||
class="flex flex-col h-full"
|
||||
:class="{
|
||||
'transition transform-cpu duration-300 -translate-y-1': hover.isHovering,
|
||||
'transition transform-cpu duration-300 -translate-y-1': hover.isHovering && !props.sortable,
|
||||
'cursor-move': props.sortable,
|
||||
}"
|
||||
:ripple="!props.sortable"
|
||||
>
|
||||
<div
|
||||
class="flex-grow"
|
||||
@@ -458,7 +473,7 @@ watch(
|
||||
{{ props.plugin?.plugin_desc }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="relative flex-shrink-0 self-center pb-3" :class="{ 'cursor-move': display.mdAndUp.value }">
|
||||
<div class="relative flex-shrink-0 self-center pb-3" :class="{ 'cursor-move': props.sortable && display.mdAndUp.value }">
|
||||
<VAvatar size="48">
|
||||
<VImg
|
||||
ref="imageRef"
|
||||
@@ -482,7 +497,11 @@ watch(
|
||||
<VIcon v-if="!isAvatarLoaded" size="small" icon="mdi-github" class="me-1" />
|
||||
</template>
|
||||
</VImg>
|
||||
<span v-if="props.sortable" class="overflow-hidden text-ellipsis whitespace-nowrap">
|
||||
{{ props.plugin?.plugin_author }}
|
||||
</span>
|
||||
<a
|
||||
v-else
|
||||
:href="props.plugin?.author_url"
|
||||
target="_blank"
|
||||
@click.stop
|
||||
@@ -496,7 +515,7 @@ watch(
|
||||
<span class="text-sm">{{ formatDownloadCount(props.count) }}</span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="absolute bottom-0 right-0">
|
||||
<div v-if="!props.sortable" class="absolute bottom-0 right-0">
|
||||
<IconBtn>
|
||||
<VIcon icon="mdi-dots-vertical" />
|
||||
<VMenu v-model="menuVisible" activator="parent" close-on-content-click>
|
||||
@@ -566,13 +585,13 @@ watch(
|
||||
</VDialog>
|
||||
|
||||
<!-- 实时日志弹窗 -->
|
||||
<VDialog
|
||||
v-if="loggingDialog"
|
||||
v-model="loggingDialog"
|
||||
scrollable
|
||||
max-width="60rem"
|
||||
:fullscreen="!display.mdAndUp.value"
|
||||
>
|
||||
<VDialog
|
||||
v-if="loggingDialog"
|
||||
v-model="loggingDialog"
|
||||
scrollable
|
||||
max-width="72rem"
|
||||
:fullscreen="!display.mdAndUp.value"
|
||||
>
|
||||
<VCard>
|
||||
<VDialogCloseBtn @click="loggingDialog = false" />
|
||||
<VCardItem>
|
||||
@@ -588,7 +607,7 @@ watch(
|
||||
</VCardTitle>
|
||||
</VCardItem>
|
||||
<VDivider />
|
||||
<VCardText>
|
||||
<VCardText class="pa-0">
|
||||
<LoggingView :logfile="`plugins/${props.plugin?.id?.toLowerCase()}.log`" />
|
||||
</VCardText>
|
||||
</VCard>
|
||||
|
||||
@@ -25,6 +25,10 @@ const props = defineProps({
|
||||
},
|
||||
width: String,
|
||||
height: String,
|
||||
sortable: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
})
|
||||
|
||||
// 定义触发的自定义事件
|
||||
@@ -165,6 +169,14 @@ function openFolder() {
|
||||
emit('open', props.folderName)
|
||||
}
|
||||
|
||||
function handleCardClick() {
|
||||
if (props.sortable) {
|
||||
return
|
||||
}
|
||||
|
||||
openFolder()
|
||||
}
|
||||
|
||||
// 重命名文件夹
|
||||
function showRenameDialog() {
|
||||
newFolderName.value = props.folderName || ''
|
||||
@@ -275,11 +287,12 @@ const dropdownItems = ref([
|
||||
:width="props.width"
|
||||
:height="props.height"
|
||||
min-height="8.5rem"
|
||||
@click="openFolder"
|
||||
@click="handleCardClick"
|
||||
class="plugin-folder-card h-full"
|
||||
:class="{
|
||||
'plugin-folder-card--mobile': display.mobile,
|
||||
'plugin-folder-card--hover': hover.isHovering,
|
||||
'plugin-folder-card--hover': hover.isHovering && !props.sortable,
|
||||
'plugin-folder-card--sortable': props.sortable,
|
||||
}"
|
||||
>
|
||||
<template v-if="backgroundImage" #image>
|
||||
@@ -302,14 +315,14 @@ const dropdownItems = ref([
|
||||
:icon="folderIcon"
|
||||
:size="display.mobile ? 56 : 72"
|
||||
:color="iconColor"
|
||||
:class="{ 'cursor-move': display.mdAndUp.value }"
|
||||
:class="{ 'cursor-move': props.sortable && display.mdAndUp.value }"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 文件夹信息 -->
|
||||
<div
|
||||
class="plugin-folder-card__info"
|
||||
:class="{ 'cursor-move': display.mdAndUp.value, 'plugin-folder-card__info--no-icon': !shouldShowIcon }"
|
||||
:class="{ 'cursor-move': props.sortable && display.mdAndUp.value, 'plugin-folder-card__info--no-icon': !shouldShowIcon }"
|
||||
>
|
||||
<!-- 文件夹名称 -->
|
||||
<h3 class="plugin-folder-card__name">
|
||||
@@ -321,7 +334,7 @@ const dropdownItems = ref([
|
||||
</div>
|
||||
|
||||
<!-- 更多菜单按钮 - 右下角 -->
|
||||
<div class="absolute top-0 right-0">
|
||||
<div v-if="!props.sortable" class="absolute top-0 right-0">
|
||||
<VMenu v-model="menuVisible" location="top end" :close-on-content-click="true">
|
||||
<template #activator="{ props: menuProps }">
|
||||
<IconBtn v-bind="menuProps" @click.stop>
|
||||
@@ -491,6 +504,10 @@ const dropdownItems = ref([
|
||||
cursor: pointer;
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
|
||||
&--sortable {
|
||||
cursor: move;
|
||||
}
|
||||
|
||||
&--hover {
|
||||
transform: translateY(-4px);
|
||||
}
|
||||
|
||||
@@ -14,12 +14,14 @@ interface Props {
|
||||
pluginStatistics?: { [key: string]: number }
|
||||
pluginActions?: { [key: string]: boolean }
|
||||
showRemoveButton?: boolean
|
||||
sortable?: boolean
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
pluginStatistics: () => ({}),
|
||||
pluginActions: () => ({}),
|
||||
showRemoveButton: false,
|
||||
sortable: false,
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -36,7 +38,7 @@ const emit = defineEmits<{
|
||||
// 拖拽事件处理
|
||||
function handleDragOver(event: DragEvent) {
|
||||
// 只有当拖拽的是插件时才允许放入文件夹
|
||||
if (props.item.type === 'folder') {
|
||||
if (props.sortable && props.item.type === 'folder') {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
event.dataTransfer!.dropEffect = 'move'
|
||||
@@ -46,14 +48,14 @@ function handleDragOver(event: DragEvent) {
|
||||
}
|
||||
|
||||
function handleDragEnter(event: DragEvent) {
|
||||
if (props.item.type === 'folder') {
|
||||
if (props.sortable && props.item.type === 'folder') {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
}
|
||||
}
|
||||
|
||||
function handleDragLeave(event: DragEvent) {
|
||||
if (props.item.type === 'folder') {
|
||||
if (props.sortable && props.item.type === 'folder') {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
const target = event.currentTarget as HTMLElement
|
||||
@@ -62,7 +64,7 @@ function handleDragLeave(event: DragEvent) {
|
||||
}
|
||||
|
||||
function handleDropToFolder(event: DragEvent) {
|
||||
if (props.item.type === 'folder') {
|
||||
if (props.sortable && props.item.type === 'folder') {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
const target = event.currentTarget as HTMLElement
|
||||
@@ -89,6 +91,7 @@ function handleDropToFolder(event: DragEvent) {
|
||||
:folder-name="item.data.name"
|
||||
:plugin-count="item.data.pluginCount"
|
||||
:folder-config="item.data.config"
|
||||
:sortable="sortable"
|
||||
@open="$emit('openFolder', item.id)"
|
||||
@delete="$emit('deleteFolder', item.id)"
|
||||
@rename="(oldName, newName) => $emit('renameFolder', oldName, newName)"
|
||||
@@ -102,6 +105,7 @@ function handleDropToFolder(event: DragEvent) {
|
||||
:count="pluginStatistics[item.id] || 0"
|
||||
:plugin="item.data"
|
||||
:action="pluginActions[item.id] || false"
|
||||
:sortable="sortable"
|
||||
@remove="$emit('refreshData')"
|
||||
@save="$emit('refreshData')"
|
||||
@action-done="$emit('actionDone', item.id)"
|
||||
@@ -109,7 +113,7 @@ function handleDropToFolder(event: DragEvent) {
|
||||
|
||||
<!-- 移出文件夹按钮(仅在文件夹内显示) -->
|
||||
<VBtn
|
||||
v-if="showRemoveButton"
|
||||
v-if="showRemoveButton && !sortable"
|
||||
icon="mdi-folder-remove"
|
||||
variant="text"
|
||||
color="warning"
|
||||
|
||||
@@ -12,6 +12,7 @@ import type { Site, SiteStatistic, SiteUserData } from '@/api/types'
|
||||
import { isNullOrEmptyObject } from '@/@core/utils'
|
||||
import { formatFileSize } from '@/@core/utils/formatters'
|
||||
import { useConfirm } from '@/composables/useConfirm'
|
||||
import { getCachedSiteIcon } from '@/utils/siteIconCache'
|
||||
import { useDisplay } from 'vuetify'
|
||||
|
||||
// 显示器宽度
|
||||
@@ -25,6 +26,10 @@ const cardProps = defineProps({
|
||||
site: Object as PropType<Site>,
|
||||
data: Object as PropType<SiteUserData>,
|
||||
stats: Object as PropType<SiteStatistic>,
|
||||
sortable: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
})
|
||||
|
||||
// 定义触发的自定义事件
|
||||
@@ -34,7 +39,8 @@ const emit = defineEmits(['update', 'remove', 'refresh-stats'])
|
||||
const createConfirm = useConfirm()
|
||||
|
||||
// 图标
|
||||
const siteIcon = ref<string>('')
|
||||
const defaultSiteIcon = getLogoUrl('site')
|
||||
const siteIcon = ref<string>(defaultSiteIcon)
|
||||
|
||||
// 提示框
|
||||
const $toast = useToast()
|
||||
@@ -59,12 +65,20 @@ const siteUserDataDialog = ref(false)
|
||||
|
||||
// 查询站点图标
|
||||
async function getSiteIcon() {
|
||||
const siteId = cardProps.site?.id
|
||||
if (!siteId) {
|
||||
siteIcon.value = defaultSiteIcon
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
siteIcon.value = (await api.get(`site/icon/${cardProps.site?.id}`)).data.icon
|
||||
if (!siteIcon.value) {
|
||||
siteIcon.value = getLogoUrl('site')
|
||||
}
|
||||
siteIcon.value = await getCachedSiteIcon(siteId, async () => {
|
||||
const response = await api.get(`site/icon/${siteId}`)
|
||||
|
||||
return response?.data?.icon || defaultSiteIcon
|
||||
})
|
||||
} catch (error) {
|
||||
siteIcon.value = defaultSiteIcon
|
||||
console.error(error)
|
||||
}
|
||||
}
|
||||
@@ -109,6 +123,22 @@ function openSitePage() {
|
||||
window.open(cardProps.site?.url, '_blank')
|
||||
}
|
||||
|
||||
function handleCardClick() {
|
||||
if (cardProps.sortable) {
|
||||
return
|
||||
}
|
||||
|
||||
handleResourceBrowse()
|
||||
}
|
||||
|
||||
function handleSiteUrlClick() {
|
||||
if (cardProps.sortable) {
|
||||
return
|
||||
}
|
||||
|
||||
openSitePage()
|
||||
}
|
||||
|
||||
// 调用API删除站点信息
|
||||
async function deleteSiteInfo() {
|
||||
const isConfirmed = await createConfirm({
|
||||
@@ -196,21 +226,24 @@ onMounted(() => {
|
||||
<template>
|
||||
<div>
|
||||
<VCard
|
||||
class="site-card relative h-full flex flex-col overflow-hidden group transition-all duration-300 cursor-pointer hover:-translate-y-1"
|
||||
class="site-card relative h-full flex flex-col overflow-hidden group transition-all duration-300"
|
||||
:class="[
|
||||
cardProps.site?.is_active ? '' : 'opacity-70',
|
||||
{
|
||||
'border-error': statColor === 'error',
|
||||
'border-warning': statColor === 'warning',
|
||||
'border-success': statColor === 'success',
|
||||
'cursor-pointer hover:-translate-y-1': !cardProps.sortable,
|
||||
'cursor-move': cardProps.sortable,
|
||||
'site-card--sortable': cardProps.sortable,
|
||||
},
|
||||
]"
|
||||
:ripple="false"
|
||||
variant="flat"
|
||||
elevation="0"
|
||||
rounded="lg"
|
||||
hover
|
||||
@click="handleResourceBrowse"
|
||||
:hover="!cardProps.sortable"
|
||||
@click="handleCardClick"
|
||||
>
|
||||
<!-- 装饰性状态指示器 -->
|
||||
<div v-if="cardProps.site?.is_active" class="site-status-indicator" :class="statColor"></div>
|
||||
@@ -225,7 +258,7 @@ onMounted(() => {
|
||||
rounded="lg"
|
||||
size="32"
|
||||
class="shrink-0"
|
||||
:class="{ 'cursor-move': display.mdAndUp.value }"
|
||||
:class="{ 'cursor-move': cardProps.sortable && display.mdAndUp.value }"
|
||||
>
|
||||
<VImg :src="siteIcon" class="w-full h-full" :alt="cardProps.site?.name" cover>
|
||||
<template #placeholder>
|
||||
@@ -242,17 +275,37 @@ onMounted(() => {
|
||||
|
||||
<!-- 站点特性图标 -->
|
||||
<div class="ml-auto flex shrink-0 items-center gap-2">
|
||||
<div v-if="cardProps.site?.limit_interval" class="hover:bg-primary/8 transition-colors">
|
||||
<VIcon icon="mdi-speedometer" size="16" color="primary" class="opacity-85 hover:opacity-100" />
|
||||
<div v-if="cardProps.site?.limit_interval" :class="cardProps.sortable ? '' : 'hover:bg-primary/8 transition-colors'">
|
||||
<VIcon
|
||||
icon="mdi-speedometer"
|
||||
size="16"
|
||||
color="primary"
|
||||
:class="cardProps.sortable ? 'opacity-85' : 'opacity-85 hover:opacity-100'"
|
||||
/>
|
||||
</div>
|
||||
<div v-if="cardProps.site?.proxy" class="hover:bg-primary/8 transition-colors">
|
||||
<VIcon icon="mdi-network-outline" size="16" color="primary" class="opacity-85 hover:opacity-100" />
|
||||
<div v-if="cardProps.site?.proxy" :class="cardProps.sortable ? '' : 'hover:bg-primary/8 transition-colors'">
|
||||
<VIcon
|
||||
icon="mdi-network-outline"
|
||||
size="16"
|
||||
color="primary"
|
||||
:class="cardProps.sortable ? 'opacity-85' : 'opacity-85 hover:opacity-100'"
|
||||
/>
|
||||
</div>
|
||||
<div v-if="cardProps.site?.render" class="hover:bg-primary/8 transition-colors">
|
||||
<VIcon icon="mdi-apple-safari" size="16" color="primary" class="opacity-85 hover:opacity-100" />
|
||||
<div v-if="cardProps.site?.render" :class="cardProps.sortable ? '' : 'hover:bg-primary/8 transition-colors'">
|
||||
<VIcon
|
||||
icon="mdi-apple-safari"
|
||||
size="16"
|
||||
color="primary"
|
||||
:class="cardProps.sortable ? 'opacity-85' : 'opacity-85 hover:opacity-100'"
|
||||
/>
|
||||
</div>
|
||||
<div v-if="cardProps.site?.filter" class="hover:bg-primary/8 transition-colors">
|
||||
<VIcon icon="mdi-filter-cog-outline" size="16" color="primary" class="opacity-85 hover:opacity-100" />
|
||||
<div v-if="cardProps.site?.filter" :class="cardProps.sortable ? '' : 'hover:bg-primary/8 transition-colors'">
|
||||
<VIcon
|
||||
icon="mdi-filter-cog-outline"
|
||||
size="16"
|
||||
color="primary"
|
||||
:class="cardProps.sortable ? 'opacity-85' : 'opacity-85 hover:opacity-100'"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -260,10 +313,10 @@ onMounted(() => {
|
||||
|
||||
<!-- 中间部分:网址 -->
|
||||
<div class="my-3">
|
||||
<div class="min-w-0 truncate text-sm text-medium-emphasis" @click.stop="openSitePage">
|
||||
{{ cardProps.site?.url }}
|
||||
<div class="min-w-0 truncate text-sm text-medium-emphasis" @click.stop="handleSiteUrlClick">
|
||||
{{ cardProps.site?.url }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 底部:数据统计 -->
|
||||
<div class="flex-1 flex flex-col justify-end">
|
||||
@@ -295,7 +348,7 @@ onMounted(() => {
|
||||
</div>
|
||||
|
||||
<!-- 右侧操作按钮区 -->
|
||||
<VSheet class="site-card-actions absolute inset-y-0 right-0 z-20 flex flex-col py-2 px-1">
|
||||
<VSheet v-if="!cardProps.sortable" class="site-card-actions absolute inset-y-0 right-0 z-20 flex flex-col py-2 px-1">
|
||||
<!-- 测试按钮 -->
|
||||
<VBtn
|
||||
icon
|
||||
@@ -418,7 +471,7 @@ onMounted(() => {
|
||||
}
|
||||
|
||||
/* 站点卡片悬停时状态指示器变化 */
|
||||
.site-card:hover .site-status-indicator {
|
||||
.site-card:not(.site-card--sortable):hover .site-status-indicator {
|
||||
block-size: 2px;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
@@ -29,6 +29,10 @@ const props = defineProps({
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
sortable: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
})
|
||||
|
||||
// 从 provide 中获取全局设置
|
||||
@@ -266,6 +270,7 @@ watch(
|
||||
(newOpenState, _) => {
|
||||
if (newOpenState) editSubscribeDialog()
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
// 监听订阅状态
|
||||
@@ -308,6 +313,10 @@ function onSubscribeEditRemove() {
|
||||
|
||||
// 处理卡片点击事件
|
||||
function handleCardClick() {
|
||||
if (props.sortable) {
|
||||
return
|
||||
}
|
||||
|
||||
if (props.batchMode) {
|
||||
// 批量模式下触发选择事件
|
||||
emit('select')
|
||||
@@ -325,7 +334,7 @@ function handleCardClick() {
|
||||
<div
|
||||
class="w-full h-full rounded-lg overflow-hidden"
|
||||
:class="{
|
||||
'transition transform-cpu duration-300 -translate-y-1': hover.isHovering,
|
||||
'transition transform-cpu duration-300 -translate-y-1': hover.isHovering && !props.sortable,
|
||||
'outline-dashed outline-1': props.media?.best_version && imageLoaded,
|
||||
'outline-dotted outline-pink-500 outline-2': props.batchMode && props.selected,
|
||||
}"
|
||||
@@ -336,13 +345,14 @@ function handleCardClick() {
|
||||
class="flex flex-col h-full"
|
||||
:class="{
|
||||
'opacity-70': subscribeState === 'S',
|
||||
'cursor-move': props.sortable,
|
||||
}"
|
||||
rounded="0"
|
||||
min-height="150"
|
||||
@click="handleCardClick"
|
||||
:ripple="!props.batchMode"
|
||||
:ripple="!props.batchMode && !props.sortable"
|
||||
>
|
||||
<div class="me-n3 absolute top-1 right-4">
|
||||
<div v-if="!props.sortable" class="me-n3 absolute top-1 right-4">
|
||||
<IconBtn>
|
||||
<VIcon icon="mdi-dots-vertical" color="white" />
|
||||
<VMenu activator="parent" close-on-content-click>
|
||||
@@ -380,7 +390,7 @@ function handleCardClick() {
|
||||
<div
|
||||
class="h-auto w-12 flex-shrink-0 overflow-hidden rounded-md"
|
||||
v-if="imageLoaded"
|
||||
:class="{ 'cursor-move': display.mdAndUp.value }"
|
||||
:class="{ 'cursor-move': props.sortable && display.mdAndUp.value }"
|
||||
>
|
||||
<VImg :src="posterUrl" aspect-ratio="2/3" cover>
|
||||
<template #placeholder>
|
||||
@@ -400,8 +410,15 @@ function handleCardClick() {
|
||||
</VCardText>
|
||||
<VCardText class="flex justify-space-between align-center flex-wrap px-3">
|
||||
<div class="flex align-center">
|
||||
<VIcon
|
||||
v-if="props.media?.total_episode && props.sortable"
|
||||
icon="mdi-progress-download"
|
||||
size="small"
|
||||
color="white"
|
||||
class="me-1"
|
||||
/>
|
||||
<IconBtn
|
||||
v-if="props.media?.total_episode"
|
||||
v-else-if="props.media?.total_episode"
|
||||
size="small"
|
||||
v-bind="props"
|
||||
icon="mdi-progress-download"
|
||||
@@ -411,7 +428,8 @@ function handleCardClick() {
|
||||
{{ (props.media?.total_episode || 0) - (props.media?.lack_episode || 0) }} /
|
||||
{{ props.media?.total_episode }}
|
||||
</div>
|
||||
<IconBtn v-if="props.media?.username" icon="mdi-account" size="small" color="white" />
|
||||
<VIcon v-if="props.media?.username && props.sortable" icon="mdi-account" size="small" color="white" class="me-1" />
|
||||
<IconBtn v-else-if="props.media?.username" icon="mdi-account" size="small" color="white" />
|
||||
<span v-if="props.media?.username" class="text-subtitle-2 text-white">
|
||||
{{ props.media?.username }}
|
||||
</span>
|
||||
|
||||
@@ -5,6 +5,8 @@ import api from '@/api'
|
||||
import type { Context } from '@/api/types'
|
||||
import AddDownloadDialog from '../dialog/AddDownloadDialog.vue'
|
||||
import { isNullOrEmptyObject } from '@/@core/utils'
|
||||
import { getCachedSiteIcon } from '@/utils/siteIconCache'
|
||||
import { downloadedTorrentMap, markTorrentDownloaded } from '@/utils/torrentDownloadCache'
|
||||
|
||||
// 输入参数
|
||||
const props = defineProps({
|
||||
@@ -32,8 +34,7 @@ const downloadItem = ref(props.torrent)
|
||||
// 站点图标
|
||||
const siteIcons = ref<Record<number, string>>({})
|
||||
|
||||
// 存储是否已经下载过的记录
|
||||
const downloaded = ref<string[]>([])
|
||||
const isDownloaded = computed(() => Boolean(torrent.value?.enclosure && downloadedTorrentMap[torrent.value.enclosure]))
|
||||
|
||||
// 添加下载对话框
|
||||
const addDownloadDialog = ref(false)
|
||||
@@ -41,8 +42,7 @@ const addDownloadDialog = ref(false)
|
||||
// 添加下载成功
|
||||
function addDownloadSuccess(url: string) {
|
||||
addDownloadDialog.value = false
|
||||
// 添加下载成功
|
||||
downloaded.value.push(url)
|
||||
markTorrentDownloaded(url)
|
||||
}
|
||||
|
||||
// 添加下载失败
|
||||
@@ -53,10 +53,21 @@ function addDownloadError(error: string) {
|
||||
// 查询站点图标
|
||||
async function getSiteIcon(site: number | undefined) {
|
||||
if (!site) return
|
||||
|
||||
try {
|
||||
siteIcons.value[site] = (await api.get(`site/icon/${site}`)).data.icon
|
||||
siteIcons.value[site] = await getCachedSiteIcon(site, async () => {
|
||||
try {
|
||||
const response = await api.get(`site/icon/${site}`)
|
||||
|
||||
return response?.data?.icon || ''
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
return ''
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
siteIcons.value[site] = ''
|
||||
}
|
||||
}
|
||||
|
||||
@@ -109,20 +120,27 @@ async function openMoreTorrentsDialog() {
|
||||
showMoreTorrents.value = true
|
||||
}
|
||||
|
||||
// 装载时查询站点图标
|
||||
onMounted(() => {
|
||||
getSiteIcon(props.torrent?.torrent_info?.site)
|
||||
})
|
||||
watch(
|
||||
() => props.torrent,
|
||||
value => {
|
||||
torrent.value = value?.torrent_info
|
||||
media.value = value?.media_info
|
||||
meta.value = value?.meta_info
|
||||
downloadItem.value = value
|
||||
getSiteIcon(value?.torrent_info?.site)
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="h-full">
|
||||
<VCard
|
||||
:width="props.width || '100%'"
|
||||
:variant="downloaded.includes(torrent?.enclosure || '') ? 'outlined' : 'flat'"
|
||||
:variant="isDownloaded ? 'outlined' : 'flat'"
|
||||
@click="handleAddDownload(props.torrent)"
|
||||
class="h-full cursor-pointer transition-transform hover:-translate-y-1 duration-300 d-flex flex-column overflow-hidden torrent-card"
|
||||
:class="{ 'border-success border-2 opacity-85': downloaded.includes(torrent?.enclosure || '') }"
|
||||
:class="{ 'border-success border-2 opacity-85': isDownloaded }"
|
||||
hover
|
||||
>
|
||||
<!-- 优惠标签 -->
|
||||
|
||||
@@ -4,6 +4,8 @@ import { formatFileSize, formatDateDifference } from '@/@core/utils/formatters'
|
||||
import api from '@/api'
|
||||
import type { Context } from '@/api/types'
|
||||
import AddDownloadDialog from '../dialog/AddDownloadDialog.vue'
|
||||
import { getCachedSiteIcon } from '@/utils/siteIconCache'
|
||||
import { downloadedTorrentMap, markTorrentDownloaded } from '@/utils/torrentDownloadCache'
|
||||
|
||||
// 输入参数
|
||||
const props = defineProps({
|
||||
@@ -22,37 +24,31 @@ const meta = ref(props.torrent?.meta_info)
|
||||
// 站点图标
|
||||
const siteIcon = ref('')
|
||||
|
||||
// 站点图标加载状态
|
||||
const iconLoading = ref(false)
|
||||
const iconError = ref(false)
|
||||
|
||||
// 存储是否已经下载过的记录
|
||||
const downloaded = ref<string[]>([])
|
||||
const isDownloaded = computed(() => Boolean(torrent.value?.enclosure && downloadedTorrentMap[torrent.value.enclosure]))
|
||||
|
||||
// 添加下载对话框
|
||||
const addDownloadDialog = ref(false)
|
||||
|
||||
// 查询站点图标
|
||||
async function getSiteIcon() {
|
||||
if (!torrent?.value?.site || iconLoading.value) {
|
||||
if (!torrent?.value?.site) {
|
||||
return
|
||||
}
|
||||
|
||||
iconLoading.value = true
|
||||
iconError.value = false
|
||||
|
||||
try {
|
||||
const response = await api.get(`site/icon/${torrent.value.site}`)
|
||||
if (response && response.data && response.data.icon) {
|
||||
siteIcon.value = response.data.icon
|
||||
} else {
|
||||
iconError.value = true
|
||||
}
|
||||
siteIcon.value = await getCachedSiteIcon(torrent.value.site, async () => {
|
||||
try {
|
||||
const response = await api.get(`site/icon/${torrent.value?.site}`)
|
||||
|
||||
return response?.data?.icon || ''
|
||||
} catch (error) {
|
||||
console.error('Failed to load site icon:', error)
|
||||
return ''
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Failed to load site icon:', error)
|
||||
iconError.value = true
|
||||
} finally {
|
||||
iconLoading.value = false
|
||||
siteIcon.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
@@ -83,8 +79,7 @@ async function handleAddDownload() {
|
||||
// 添加下载成功
|
||||
function addDownloadSuccess(url: string) {
|
||||
addDownloadDialog.value = false
|
||||
// 添加下载成功
|
||||
downloaded.value.push(url)
|
||||
markTorrentDownloaded(url)
|
||||
}
|
||||
|
||||
// 添加下载失败
|
||||
@@ -97,10 +92,16 @@ function openTorrentDetail() {
|
||||
window.open(torrent.value?.page_url, '_blank')
|
||||
}
|
||||
|
||||
// 装载时查询站点图标
|
||||
onMounted(() => {
|
||||
getSiteIcon()
|
||||
})
|
||||
watch(
|
||||
() => props.torrent,
|
||||
value => {
|
||||
torrent.value = value?.torrent_info
|
||||
media.value = value?.media_info
|
||||
meta.value = value?.meta_info
|
||||
getSiteIcon()
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -108,7 +109,7 @@ onMounted(() => {
|
||||
<VListItem
|
||||
:value="props.torrent?.torrent_info?.enclosure"
|
||||
class="pa-3 mb-2 rounded torrent-item transition-all duration-300 hover:-translate-y-1 overflow-hidden"
|
||||
:class="{ 'border-start border-success border-3 opacity-85': downloaded.includes(torrent?.enclosure || '') }"
|
||||
:class="{ 'border-start border-success border-3 opacity-85': isDownloaded }"
|
||||
@click="handleAddDownload"
|
||||
>
|
||||
<!-- 优惠标签 -->
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -76,12 +76,12 @@ async function loadHistory({ done }: { done: any }) {
|
||||
// 返回加载成功
|
||||
done('ok')
|
||||
}
|
||||
// 取消加载中
|
||||
loading.value = false
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
// 返回加载失败
|
||||
done('error')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -153,65 +153,67 @@ function getMediaTypeText(type: string | undefined) {
|
||||
</VCardItem>
|
||||
<VDivider />
|
||||
<VDialogCloseBtn @click="emit('close')" />
|
||||
<VList lines="two">
|
||||
<VInfiniteScroll mode="intersect" side="end" :items="historyList" class="overflow-visible" @load="loadHistory">
|
||||
<VList lines="two" class="flex-grow-1 min-h-0 py-0">
|
||||
<VInfiniteScroll mode="intersect" side="end" :items="historyList" class="h-100" @load="loadHistory">
|
||||
<template #loading>
|
||||
<LoadingBanner />
|
||||
</template>
|
||||
<template #empty />
|
||||
<template v-if="historyList.length > 0">
|
||||
<template v-for="(item, i) in historyList" :key="i">
|
||||
<VListItem>
|
||||
<template #prepend>
|
||||
<VImg
|
||||
height="75"
|
||||
width="50"
|
||||
:src="item.poster"
|
||||
aspect-ratio="2/3"
|
||||
class="object-cover rounded ring-gray-500 me-3"
|
||||
cover
|
||||
>
|
||||
<template #placeholder>
|
||||
<div class="w-full h-full">
|
||||
<VSkeletonLoader class="object-cover aspect-w-2 aspect-h-3" />
|
||||
</div>
|
||||
</template>
|
||||
</VImg>
|
||||
</template>
|
||||
<VListItemTitle v-if="item.type == '电视剧'">
|
||||
{{ item.name }}
|
||||
<span class="text-sm">{{ t('dialog.subscribeHistory.season', { season: item.season }) }}</span>
|
||||
</VListItemTitle>
|
||||
<VListItemTitle v-else>
|
||||
{{ item.name }}
|
||||
</VListItemTitle>
|
||||
<VListItemSubtitle class="mt-2">{{ formatDateDifference(item.date) }}</VListItemSubtitle>
|
||||
<VListItemSubtitle class="mt-2">{{ item.description }}</VListItemSubtitle>
|
||||
<template #append>
|
||||
<div class="me-n3">
|
||||
<IconBtn>
|
||||
<VIcon icon="mdi-dots-vertical" />
|
||||
<VMenu activator="parent" close-on-content-click>
|
||||
<VList>
|
||||
<VListItem
|
||||
v-for="(menu, i) in dropdownItems"
|
||||
:key="i"
|
||||
:base-color="menu.color"
|
||||
@click="menu.props.click(item)"
|
||||
>
|
||||
<template #prepend>
|
||||
<VIcon :icon="menu.props.prependIcon" />
|
||||
</template>
|
||||
<VListItemTitle v-text="menu.title" />
|
||||
</VListItem>
|
||||
</VList>
|
||||
</VMenu>
|
||||
</IconBtn>
|
||||
</div>
|
||||
</template>
|
||||
</VListItem>
|
||||
<VVirtualScroll v-if="historyList.length > 0" renderless :items="historyList" :item-height="104">
|
||||
<template #default="{ item, itemRef }">
|
||||
<div :ref="itemRef">
|
||||
<VListItem>
|
||||
<template #prepend>
|
||||
<VImg
|
||||
height="75"
|
||||
width="50"
|
||||
:src="item.poster"
|
||||
aspect-ratio="2/3"
|
||||
class="object-cover rounded ring-gray-500 me-3"
|
||||
cover
|
||||
>
|
||||
<template #placeholder>
|
||||
<div class="w-full h-full">
|
||||
<VSkeletonLoader class="object-cover aspect-w-2 aspect-h-3" />
|
||||
</div>
|
||||
</template>
|
||||
</VImg>
|
||||
</template>
|
||||
<VListItemTitle v-if="item.type == '电视剧'">
|
||||
{{ item.name }}
|
||||
<span class="text-sm">{{ t('dialog.subscribeHistory.season', { season: item.season }) }}</span>
|
||||
</VListItemTitle>
|
||||
<VListItemTitle v-else>
|
||||
{{ item.name }}
|
||||
</VListItemTitle>
|
||||
<VListItemSubtitle class="mt-2">{{ formatDateDifference(item.date) }}</VListItemSubtitle>
|
||||
<VListItemSubtitle class="mt-2">{{ item.description }}</VListItemSubtitle>
|
||||
<template #append>
|
||||
<div class="me-n3">
|
||||
<IconBtn>
|
||||
<VIcon icon="mdi-dots-vertical" />
|
||||
<VMenu activator="parent" close-on-content-click>
|
||||
<VList>
|
||||
<VListItem
|
||||
v-for="(menu, i) in dropdownItems"
|
||||
:key="i"
|
||||
:base-color="menu.color"
|
||||
@click="menu.props.click(item)"
|
||||
>
|
||||
<template #prepend>
|
||||
<VIcon :icon="menu.props.prependIcon" />
|
||||
</template>
|
||||
<VListItemTitle v-text="menu.title" />
|
||||
</VListItem>
|
||||
</VList>
|
||||
</VMenu>
|
||||
</IconBtn>
|
||||
</div>
|
||||
</template>
|
||||
</VListItem>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
</VVirtualScroll>
|
||||
</VInfiniteScroll>
|
||||
</VList>
|
||||
<VCardText v-if="historyList.length === 0 && isRefreshed" class="text-center">{{
|
||||
|
||||
@@ -91,6 +91,7 @@ const userForm = ref<ExtendedUser>({
|
||||
},
|
||||
settings: {
|
||||
wechat_userid: null,
|
||||
wechatclawbot_userid: null,
|
||||
telegram_userid: null,
|
||||
slack_userid: null,
|
||||
discord_userid: null,
|
||||
@@ -503,6 +504,15 @@ onMounted(() => {
|
||||
prepend-inner-icon="mdi-wechat"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="userForm.settings.wechatclawbot_userid"
|
||||
density="comfortable"
|
||||
clearable
|
||||
:label="t('dialog.userAddEdit.wechatClawBot')"
|
||||
prepend-inner-icon="mdi-robot-happy-outline"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="userForm.settings.telegram_userid"
|
||||
|
||||
@@ -148,7 +148,12 @@ const transferItems = ref<FileItem[]>([])
|
||||
// 当前图片地址
|
||||
const currentImgLink = ref('')
|
||||
|
||||
function revokeCurrentImgLink() {
|
||||
if (!currentImgLink.value) return
|
||||
|
||||
URL.revokeObjectURL(currentImgLink.value)
|
||||
currentImgLink.value = ''
|
||||
}
|
||||
|
||||
// 是否为图片文件
|
||||
const isImage = computed(() => {
|
||||
@@ -287,6 +292,9 @@ async function download(item: FileItem) {
|
||||
if (result) {
|
||||
const downloadUrl = URL.createObjectURL(result)
|
||||
window.open(downloadUrl, '_blank')
|
||||
setTimeout(() => {
|
||||
URL.revokeObjectURL(downloadUrl)
|
||||
}, 60000)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -304,6 +312,7 @@ async function getImgLink(item: FileItem) {
|
||||
const result: Blob = (await inProps.axios.request<Blob, Blob>(config))
|
||||
if (result) {
|
||||
// 创建图片地址
|
||||
revokeCurrentImgLink()
|
||||
currentImgLink.value = URL.createObjectURL(result)
|
||||
}
|
||||
}
|
||||
@@ -314,7 +323,10 @@ watch(
|
||||
async () => {
|
||||
if (isImage.value && isFile.value) {
|
||||
await getImgLink(inProps.item)
|
||||
return
|
||||
}
|
||||
|
||||
revokeCurrentImgLink()
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
@@ -597,6 +609,11 @@ function stopLoadingProgress() {
|
||||
onMounted(() => {
|
||||
list_files()
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
revokeCurrentImgLink()
|
||||
stopLoadingProgress()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
252
src/components/misc/ProgressiveCardGrid.vue
Normal file
252
src/components/misc/ProgressiveCardGrid.vue
Normal file
@@ -0,0 +1,252 @@
|
||||
<script setup lang="ts">
|
||||
import { useIntersectionObserver } from '@vueuse/core'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
items: any[]
|
||||
minItemWidth?: number
|
||||
itemAspectRatio?: number
|
||||
estimatedItemHeight?: number
|
||||
scrollToIndex?: number
|
||||
gap?: number
|
||||
initialCount?: number
|
||||
batchSize?: number
|
||||
overscanRows?: number
|
||||
getItemKey?: (item: any, index: number) => string | number
|
||||
}>(),
|
||||
{
|
||||
minItemWidth: 144,
|
||||
itemAspectRatio: 1.5,
|
||||
estimatedItemHeight: undefined,
|
||||
scrollToIndex: undefined,
|
||||
gap: 16,
|
||||
initialCount: 24,
|
||||
batchSize: 24,
|
||||
overscanRows: 4,
|
||||
getItemKey: undefined,
|
||||
},
|
||||
)
|
||||
|
||||
const containerRef = ref<HTMLElement | null>(null)
|
||||
const sentinelRef = ref<HTMLElement | null>(null)
|
||||
const renderedCount = ref(0)
|
||||
|
||||
let animationFrameId: number | null = null
|
||||
|
||||
const safeInitialCount = computed(() => Math.max(1, props.initialCount))
|
||||
const safeBatchSize = computed(() => Math.max(1, props.batchSize))
|
||||
const hasMoreItems = computed(() => renderedCount.value < props.items.length)
|
||||
const visibleItems = computed(() => props.items.slice(0, renderedCount.value))
|
||||
|
||||
const gridStyle = computed(() => ({
|
||||
columnGap: `${props.gap}px`,
|
||||
gridTemplateColumns: `repeat(auto-fill, minmax(${props.minItemWidth}px, 1fr))`,
|
||||
rowGap: `${props.gap}px`,
|
||||
}))
|
||||
|
||||
function getComparableKey(item: any, index: number) {
|
||||
if (props.getItemKey) {
|
||||
return props.getItemKey(item, index)
|
||||
}
|
||||
|
||||
return index
|
||||
}
|
||||
|
||||
function resolveItemKey(item: any, index: number) {
|
||||
return getComparableKey(item, index)
|
||||
}
|
||||
|
||||
function appendNextBatch() {
|
||||
renderedCount.value = Math.min(props.items.length, renderedCount.value + safeBatchSize.value)
|
||||
}
|
||||
|
||||
function hasPageScroll() {
|
||||
if (typeof window === 'undefined') {
|
||||
return true
|
||||
}
|
||||
|
||||
const scrollHeight = Math.max(document.body.scrollHeight, document.documentElement.scrollHeight)
|
||||
|
||||
return scrollHeight - (window.innerHeight || document.documentElement.clientHeight) > 2
|
||||
}
|
||||
|
||||
async function fillViewport() {
|
||||
if (typeof window === 'undefined') {
|
||||
return
|
||||
}
|
||||
|
||||
const maxIterations = Math.ceil(props.items.length / safeBatchSize.value)
|
||||
let iterations = 0
|
||||
|
||||
while (!hasPageScroll() && hasMoreItems.value && iterations < maxIterations) {
|
||||
appendNextBatch()
|
||||
iterations += 1
|
||||
await nextTick()
|
||||
}
|
||||
}
|
||||
|
||||
function queueFillViewport() {
|
||||
if (typeof window === 'undefined' || animationFrameId !== null) {
|
||||
return
|
||||
}
|
||||
|
||||
animationFrameId = window.requestAnimationFrame(() => {
|
||||
animationFrameId = null
|
||||
void fillViewport()
|
||||
})
|
||||
}
|
||||
|
||||
async function revealItem(index: number) {
|
||||
if (typeof window === 'undefined' || index < 0 || index >= props.items.length) {
|
||||
return
|
||||
}
|
||||
|
||||
const minRenderedCount = Math.ceil((index + 1) / safeBatchSize.value) * safeBatchSize.value
|
||||
renderedCount.value = Math.min(props.items.length, Math.max(renderedCount.value, minRenderedCount))
|
||||
|
||||
await nextTick()
|
||||
|
||||
const target = containerRef.value?.querySelector(`[data-progressive-grid-index="${index}"]`)
|
||||
if (target instanceof HTMLElement) {
|
||||
target.scrollIntoView({
|
||||
behavior: 'auto',
|
||||
block: 'start',
|
||||
inline: 'nearest',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function resetVisibleItems() {
|
||||
renderedCount.value = Math.min(props.items.length, safeInitialCount.value)
|
||||
|
||||
nextTick(() => {
|
||||
if (props.scrollToIndex !== undefined && props.scrollToIndex >= 0) {
|
||||
void revealItem(props.scrollToIndex)
|
||||
return
|
||||
}
|
||||
|
||||
queueFillViewport()
|
||||
})
|
||||
}
|
||||
|
||||
function didItemsAppend(nextItems: any[], previousItems: any[]) {
|
||||
if (!previousItems.length || nextItems.length < previousItems.length) {
|
||||
return false
|
||||
}
|
||||
|
||||
return previousItems.every((item, index) => getComparableKey(item, index) === getComparableKey(nextItems[index], index))
|
||||
}
|
||||
|
||||
function syncVisibleItems(nextItems: any[], previousItems: any[] = []) {
|
||||
if (didItemsAppend(nextItems, previousItems)) {
|
||||
renderedCount.value = Math.min(nextItems.length, Math.max(renderedCount.value, previousItems.length))
|
||||
|
||||
nextTick(() => {
|
||||
if (props.scrollToIndex !== undefined && props.scrollToIndex >= 0) {
|
||||
void revealItem(props.scrollToIndex)
|
||||
return
|
||||
}
|
||||
|
||||
queueFillViewport()
|
||||
})
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
resetVisibleItems()
|
||||
}
|
||||
|
||||
const { stop } = useIntersectionObserver(
|
||||
sentinelRef,
|
||||
([entry]) => {
|
||||
if (!entry?.isIntersecting || !hasMoreItems.value) {
|
||||
return
|
||||
}
|
||||
|
||||
appendNextBatch()
|
||||
queueFillViewport()
|
||||
},
|
||||
{
|
||||
rootMargin: '1200px 0px',
|
||||
},
|
||||
)
|
||||
|
||||
onMounted(() => {
|
||||
window.addEventListener('resize', queueFillViewport, { passive: true })
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
stop()
|
||||
window.removeEventListener('resize', queueFillViewport)
|
||||
|
||||
if (animationFrameId !== null) {
|
||||
window.cancelAnimationFrame(animationFrameId)
|
||||
animationFrameId = null
|
||||
}
|
||||
})
|
||||
|
||||
watch(
|
||||
[
|
||||
() => props.minItemWidth,
|
||||
() => props.initialCount,
|
||||
() => props.batchSize,
|
||||
],
|
||||
() => {
|
||||
queueFillViewport()
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
watch(
|
||||
() => props.items,
|
||||
(nextItems, previousItems) => {
|
||||
syncVisibleItems(nextItems, previousItems)
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
watch(
|
||||
[() => props.scrollToIndex, () => props.items.length],
|
||||
([scrollToIndex]) => {
|
||||
if (scrollToIndex === undefined || scrollToIndex < 0) {
|
||||
return
|
||||
}
|
||||
|
||||
nextTick(() => {
|
||||
void revealItem(scrollToIndex)
|
||||
})
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div ref="containerRef" class="progressive-card-grid">
|
||||
<div class="grid" :style="gridStyle">
|
||||
<div
|
||||
v-for="(item, index) in visibleItems"
|
||||
:key="resolveItemKey(item, index)"
|
||||
class="progressive-card-grid__item"
|
||||
:data-progressive-grid-index="index"
|
||||
>
|
||||
<slot :item="item" :index="index" />
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="hasMoreItems" ref="sentinelRef" class="progressive-card-grid__sentinel" aria-hidden="true" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.progressive-card-grid {
|
||||
inline-size: 100%;
|
||||
}
|
||||
|
||||
.progressive-card-grid__item {
|
||||
min-inline-size: 0;
|
||||
}
|
||||
|
||||
.progressive-card-grid__sentinel {
|
||||
block-size: 1px;
|
||||
inline-size: 100%;
|
||||
}
|
||||
</style>
|
||||
@@ -1,335 +0,0 @@
|
||||
<script lang="ts" setup>
|
||||
import SlideViewTitle from '@/components/slide/SlideViewTitle.vue'
|
||||
import { useDisplay } from 'vuetify'
|
||||
|
||||
// 判断是否可以触摸
|
||||
const display = useDisplay()
|
||||
const isTouch = computed(() => display.mobile.value)
|
||||
|
||||
// 元素
|
||||
const slideview_content = ref<HTMLElement | null>(null)
|
||||
const sliderContainer = ref<HTMLElement | null>(null)
|
||||
// 分页切换状态: 0-左边不可用 1-两边可用 2-右边不可用 3-两边都不可用
|
||||
const disabled = ref(0)
|
||||
// 记录滚动值
|
||||
const slideview_scrollLeft = ref(0)
|
||||
// 所有卡片数量
|
||||
let slide_card_length: number
|
||||
// 卡片间距
|
||||
let slide_gap_px: number
|
||||
// 卡片宽度
|
||||
let card_width: number
|
||||
// 容器最多显示N张卡片
|
||||
let card_max: number
|
||||
// 当前定位
|
||||
let card_current: number
|
||||
// 获取传入的链接地址
|
||||
const props: any = inject('rankingPropsKey', { linkurl: '', title: '' })
|
||||
const isScrolling = ref(false)
|
||||
let scrollTimeout: ReturnType<typeof setTimeout> | null = null
|
||||
const scrollTimeoutDuration = 1500 // 滚动停止后延迟时间 (ms)
|
||||
|
||||
// 分页切换
|
||||
function slideNext(next: boolean) {
|
||||
let run_to_left_px
|
||||
if (next) {
|
||||
const card_index = card_current + card_max
|
||||
run_to_left_px = card_index * card_width
|
||||
if (run_to_left_px >= slideview_content.value!.scrollWidth - slideview_content.value!.clientWidth)
|
||||
run_to_left_px = slideview_content.value!.scrollWidth - slideview_content.value!.clientWidth
|
||||
} else {
|
||||
const card_index = card_current - card_max
|
||||
run_to_left_px = card_index * card_width
|
||||
if (run_to_left_px <= 0) run_to_left_px = 0
|
||||
}
|
||||
slideview_content.value!.scrollTo({
|
||||
top: 0,
|
||||
left: run_to_left_px,
|
||||
behavior: 'smooth',
|
||||
})
|
||||
|
||||
// 点击后强制显示并重置计时器
|
||||
isScrolling.value = true
|
||||
if (scrollTimeout) {
|
||||
clearTimeout(scrollTimeout)
|
||||
}
|
||||
scrollTimeout = setTimeout(() => {
|
||||
isScrolling.value = false
|
||||
}, scrollTimeoutDuration)
|
||||
}
|
||||
|
||||
// 计算最大显示数量
|
||||
function countMaxNumber() {
|
||||
if (!slideview_content.value || !slideview_content.value.firstElementChild) return
|
||||
slide_card_length = slideview_content.value.children.length
|
||||
card_width = slideview_content.value.firstElementChild.getBoundingClientRect().width
|
||||
slide_gap_px = slideview_content.value.scrollWidth / slide_card_length - card_width
|
||||
card_width += slide_gap_px
|
||||
card_max = Math.trunc(slideview_content.value.clientWidth / card_width)
|
||||
countDisabled()
|
||||
}
|
||||
|
||||
// 修改分页切换按钮状态 & 处理滚动状态
|
||||
function handleContentScroll() {
|
||||
if (!slideview_content.value) return
|
||||
// 更新按钮禁用状态
|
||||
countDisabled()
|
||||
|
||||
// 更新滚动状态并重置计时器
|
||||
isScrolling.value = true
|
||||
if (scrollTimeout) {
|
||||
clearTimeout(scrollTimeout)
|
||||
}
|
||||
scrollTimeout = setTimeout(() => {
|
||||
isScrolling.value = false
|
||||
}, scrollTimeoutDuration) // 使用常量
|
||||
}
|
||||
|
||||
// 原始的 countDisabled 逻辑,现在由 handleContentScroll 调用
|
||||
function countDisabled() {
|
||||
if (!slideview_content.value) return
|
||||
slideview_scrollLeft.value = slideview_content.value.scrollLeft
|
||||
card_current =
|
||||
slideview_content.value.scrollLeft === 0
|
||||
? 0
|
||||
: Math.trunc((slideview_content.value.scrollLeft + card_width / 2) / card_width)
|
||||
if (slide_card_length * card_width <= slideview_content.value.clientWidth) disabled.value = 3
|
||||
else if (slideview_content.value.scrollLeft === 0) disabled.value = 0
|
||||
else if (
|
||||
slideview_content.value.scrollLeft >=
|
||||
slideview_content.value.scrollWidth - slideview_content.value.clientWidth - 2
|
||||
)
|
||||
disabled.value = 2
|
||||
else disabled.value = 1
|
||||
}
|
||||
|
||||
// 组件加载完成
|
||||
onMounted(() => {
|
||||
// 初次获取元素参数
|
||||
countMaxNumber()
|
||||
// 窗口大小发生改变时
|
||||
window.addEventListener('resize', countMaxNumber)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
// 卸载事件
|
||||
window.removeEventListener('resize', countMaxNumber)
|
||||
})
|
||||
|
||||
onActivated(() => {
|
||||
if (slideview_scrollLeft.value !== 0) {
|
||||
slideview_content.value!.scrollLeft = slideview_scrollLeft.value
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div ref="sliderContainer" class="slider-container" :class="{ 'is-scrolling': isScrolling }">
|
||||
<div class="slider-header">
|
||||
<slot name="title">
|
||||
<SlideViewTitle />
|
||||
</slot>
|
||||
<!-- 查看全部按钮 -->
|
||||
<RouterLink v-if="props.linkurl" :to="props.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="slideview_content" class="slider-content" tabindex="0" @scroll="handleContentScroll">
|
||||
<slot name="content" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 左侧导航按钮 -->
|
||||
<VBtn
|
||||
class="nav-button nav-button-left"
|
||||
@click.stop="slideNext(false)"
|
||||
v-show="disabled !== 0 && disabled !== 3 && !isTouch"
|
||||
variant="text"
|
||||
icon
|
||||
color="secondary"
|
||||
>
|
||||
<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
|
||||
class="nav-button nav-button-right"
|
||||
@click.stop="slideNext(true)"
|
||||
v-show="disabled !== 2 && disabled !== 3 && !isTouch"
|
||||
variant="text"
|
||||
icon
|
||||
color="secondary"
|
||||
>
|
||||
<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 {
|
||||
.arrow-svg {
|
||||
fill: currentcolor;
|
||||
margin-inline-start: 2px;
|
||||
transition: transform 0.3s ease;
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
&: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%;
|
||||
}
|
||||
|
||||
.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-content {
|
||||
display: grid;
|
||||
overflow: scroll hidden !important;
|
||||
justify-content: start;
|
||||
gap: 16px;
|
||||
grid-auto-flow: column;
|
||||
grid-template-rows: 1fr;
|
||||
-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;
|
||||
}
|
||||
}
|
||||
|
||||
// 触摸设备:滚动时显示 (通过 JS 添加的类控制)
|
||||
// 这个规则会在不支持 hover 的设备上生效
|
||||
.slider-container.is-scrolling .nav-button {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
// 桌面设备:悬停时显示
|
||||
@media (hover: hover) {
|
||||
.slider-container:hover .nav-button {
|
||||
// 这个规则会覆盖 .is-scrolling 的效果 (如果同时存在)
|
||||
// 或者在非 scrolling 状态下,hover 时也能显示
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
// 在 hover 设备上,即使在滚动,如果鼠标不悬停,按钮也应该隐藏
|
||||
// 因此,基础 .nav-button 的 opacity: 0 规则在这里仍然是必要的
|
||||
// (之前错误地以为 hover 会完全覆盖,但滚动时 class 和 hover 可能同时存在)
|
||||
// .nav-button { opacity: 0; pointer-events: none; } // 这行其实不需要重复,默认就是这样
|
||||
}
|
||||
</style>
|
||||
433
src/components/slide/VirtualSlideView.vue
Normal file
433
src/components/slide/VirtualSlideView.vue
Normal file
@@ -0,0 +1,433 @@
|
||||
<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
|
||||
loading?: boolean
|
||||
}>(),
|
||||
{
|
||||
itemWidth: 144,
|
||||
itemGap: 16,
|
||||
overscanItems: 4,
|
||||
getItemKey: undefined,
|
||||
loading: false,
|
||||
},
|
||||
)
|
||||
|
||||
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">
|
||||
<template v-if="loading">
|
||||
<div class="loading-track" :style="{ gap: `${itemGap}px` }">
|
||||
<slot name="loading" />
|
||||
</div>
|
||||
</template>
|
||||
<template v-else-if="items.length > 0">
|
||||
<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>
|
||||
</template>
|
||||
<template v-else>
|
||||
<slot name="empty" />
|
||||
</template>
|
||||
</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;
|
||||
}
|
||||
|
||||
.loading-track {
|
||||
display: flex;
|
||||
inline-size: max-content;
|
||||
min-inline-size: 100%;
|
||||
}
|
||||
|
||||
.virtual-slide-item,
|
||||
.virtual-spacer,
|
||||
.loading-track > * {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.nav-button {
|
||||
position: absolute;
|
||||
z-index: 20;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0;
|
||||
border-radius: 50%;
|
||||
backdrop-filter: blur(8px);
|
||||
background-color: rgba(var(--v-theme-background), 0.3);
|
||||
block-size: 36px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 8%);
|
||||
cursor: pointer;
|
||||
inline-size: 36px;
|
||||
inset-block-start: 50%;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
text-shadow: 0 1px 2px rgba(0, 0, 0, 10%);
|
||||
transform: translateY(-50%);
|
||||
transition: opacity 0.3s ease, transform 0.3s cubic-bezier(0.25, 0.8, 0.25, 1), background-color 0.3s ease,
|
||||
box-shadow 0.3s ease, border-color 0.3s ease;
|
||||
|
||||
svg {
|
||||
block-size: 22px;
|
||||
fill: currentcolor;
|
||||
filter: none;
|
||||
inline-size: 22px;
|
||||
opacity: 0.7;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
color: rgb(var(--v-theme-primary));
|
||||
transform: translateY(-50%) scale(1.05);
|
||||
|
||||
svg {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.nav-button-left {
|
||||
inset-inline-start: 8px;
|
||||
}
|
||||
|
||||
.nav-button-right {
|
||||
inset-inline-end: 8px;
|
||||
}
|
||||
|
||||
.slider-container.is-scrolling .nav-button {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
@media (hover: hover) {
|
||||
.slider-container:hover .nav-button {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -28,11 +28,24 @@ export function useBackgroundOptimization() {
|
||||
// 使用独立的SSE管理器,确保每个监听器都有独立的连接
|
||||
const manager = sseManagerSingleton.getIndependentManager(url, listenerId, options)
|
||||
const isConnected = ref(false)
|
||||
let connectTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
const cleanup = () => {
|
||||
if (connectTimer) {
|
||||
clearTimeout(connectTimer)
|
||||
connectTimer = null
|
||||
}
|
||||
|
||||
manager.removeMessageListener(listenerId)
|
||||
sseManagerSingleton.closeIndependentManager(url, listenerId)
|
||||
isConnected.value = false
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
// 延迟建立连接,确保组件完全挂载
|
||||
const connectDelay = options?.connectDelay || 100
|
||||
setTimeout(() => {
|
||||
connectTimer = setTimeout(() => {
|
||||
connectTimer = null
|
||||
try {
|
||||
manager.addMessageListener(listenerId, event => {
|
||||
messageHandler(event)
|
||||
@@ -44,15 +57,12 @@ export function useBackgroundOptimization() {
|
||||
}, connectDelay)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
manager.removeMessageListener(listenerId)
|
||||
isConnected.value = false
|
||||
})
|
||||
onUnmounted(cleanup)
|
||||
|
||||
return {
|
||||
manager,
|
||||
readyState: () => manager.readyState,
|
||||
close: () => manager.removeMessageListener(listenerId),
|
||||
close: cleanup,
|
||||
isConnected,
|
||||
forceReconnect: () => manager.forceReconnect(),
|
||||
}
|
||||
@@ -104,21 +114,31 @@ export function useBackgroundOptimization() {
|
||||
) => {
|
||||
// 使用独立的SSE管理器,确保每个监听器都有独立的连接
|
||||
const manager = sseManagerSingleton.getIndependentManager(url, listenerId, options)
|
||||
let connectTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
const cleanup = () => {
|
||||
if (connectTimer) {
|
||||
clearTimeout(connectTimer)
|
||||
connectTimer = null
|
||||
}
|
||||
|
||||
manager.removeMessageListener(listenerId)
|
||||
sseManagerSingleton.closeIndependentManager(url, listenerId)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
setTimeout(() => {
|
||||
connectTimer = setTimeout(() => {
|
||||
connectTimer = null
|
||||
manager.addMessageListener(listenerId, messageHandler)
|
||||
}, delay)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
manager.removeMessageListener(listenerId)
|
||||
})
|
||||
onUnmounted(cleanup)
|
||||
|
||||
return {
|
||||
manager,
|
||||
readyState: () => manager.readyState,
|
||||
close: () => manager.removeMessageListener(listenerId),
|
||||
close: cleanup,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -135,31 +155,50 @@ export function useBackgroundOptimization() {
|
||||
listenerId: string,
|
||||
isActive: Ref<boolean>,
|
||||
) => {
|
||||
// 使用独立的SSE管理器,确保每个监听器都有独立的连接
|
||||
const manager = sseManagerSingleton.getIndependentManager(url, listenerId, {
|
||||
backgroundCloseDelay: 1000, // 进度SSE更快关闭
|
||||
reconnectDelay: 1000,
|
||||
maxReconnectAttempts: 5,
|
||||
})
|
||||
const getManager = () =>
|
||||
sseManagerSingleton.getIndependentManager(url, listenerId, {
|
||||
backgroundCloseDelay: 1000, // 进度SSE更快关闭
|
||||
reconnectDelay: 1000,
|
||||
maxReconnectAttempts: 5,
|
||||
})
|
||||
|
||||
let manager: ReturnType<typeof getManager> | null = null
|
||||
let isListening = false
|
||||
|
||||
const startProgress = () => {
|
||||
if (isActive.value) {
|
||||
manager.addMessageListener(listenerId, messageHandler)
|
||||
}
|
||||
if (!isActive.value || isListening) return
|
||||
|
||||
manager ??= getManager()
|
||||
manager.addMessageListener(listenerId, messageHandler)
|
||||
isListening = true
|
||||
}
|
||||
|
||||
const stopProgress = () => {
|
||||
const stopProgress = (destroyManager = true) => {
|
||||
if (!manager) {
|
||||
isListening = false
|
||||
return
|
||||
}
|
||||
|
||||
manager.removeMessageListener(listenerId)
|
||||
|
||||
if (destroyManager) {
|
||||
sseManagerSingleton.closeIndependentManager(url, listenerId)
|
||||
manager = null
|
||||
}
|
||||
|
||||
isListening = false
|
||||
}
|
||||
|
||||
onUnmounted(() => {
|
||||
stopProgress()
|
||||
stopProgress(true)
|
||||
})
|
||||
|
||||
return {
|
||||
start: startProgress,
|
||||
stop: stopProgress,
|
||||
manager,
|
||||
get manager() {
|
||||
return manager
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -16,11 +16,25 @@ export interface LlmProviderAuthStatus {
|
||||
updated_at?: number | null
|
||||
}
|
||||
|
||||
export interface LlmProviderUrlPreset {
|
||||
id: string
|
||||
label: string
|
||||
value: string
|
||||
}
|
||||
|
||||
export interface LlmProviderUrlPresetItem {
|
||||
id: string
|
||||
title: string
|
||||
value: string
|
||||
subtitle?: string
|
||||
}
|
||||
|
||||
export interface LlmProvider {
|
||||
id: string
|
||||
name: string
|
||||
runtime: string
|
||||
default_base_url: string
|
||||
base_url_presets?: LlmProviderUrlPreset[]
|
||||
base_url_editable: boolean
|
||||
requires_base_url: boolean
|
||||
supports_api_key: boolean
|
||||
@@ -68,6 +82,7 @@ interface UseLlmProviderDirectoryOptions {
|
||||
provider: Ref<string>
|
||||
apiKey: Ref<string>
|
||||
baseUrl: Ref<string>
|
||||
baseUrlPreset?: Ref<string>
|
||||
model: Ref<string>
|
||||
maxContextTokens?: Ref<number>
|
||||
authConnected?: Ref<boolean>
|
||||
@@ -96,9 +111,17 @@ export function useLlmProviderDirectory(options: UseLlmProviderDirectoryOptions)
|
||||
() => models.value.find(item => item.id === normalizeValue(options.model.value)) || null,
|
||||
)
|
||||
const providerItems = computed(() => providers.value.map(item => ({ title: item.name, value: item.id })))
|
||||
const baseUrlPresetItems = computed<LlmProviderUrlPresetItem[]>(() =>
|
||||
(selectedProvider.value?.base_url_presets || []).map(item => ({
|
||||
id: item.id,
|
||||
title: item.value,
|
||||
value: item.value,
|
||||
subtitle: item.label,
|
||||
})),
|
||||
)
|
||||
const providerConnected = computed(() => Boolean(selectedProvider.value?.auth_status?.connected))
|
||||
const showBaseUrlField = computed(
|
||||
() => Boolean(selectedProvider.value?.requires_base_url || selectedProvider.value?.base_url_editable),
|
||||
() => Boolean(selectedProvider.value && (selectedProvider.value.oauth_methods || []).length === 0),
|
||||
)
|
||||
const showApiKeyField = computed(() => selectedProvider.value?.supports_api_key !== false)
|
||||
const hasUsableCredential = computed(() => {
|
||||
@@ -131,14 +154,37 @@ export function useLlmProviderDirectory(options: UseLlmProviderDirectoryOptions)
|
||||
|
||||
const currentBaseUrl = normalizeValue(options.baseUrl.value)
|
||||
const defaultBaseUrl = provider.default_base_url || ''
|
||||
const defaultPresetId = normalizeValue(provider.base_url_presets?.[0]?.id)
|
||||
if (reset) {
|
||||
options.baseUrl.value = defaultBaseUrl
|
||||
if (options.baseUrlPreset) {
|
||||
options.baseUrlPreset.value = defaultPresetId
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (!currentBaseUrl && 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) {
|
||||
@@ -206,6 +252,7 @@ export function useLlmProviderDirectory(options: UseLlmProviderDirectoryOptions)
|
||||
provider: normalizeValue(options.provider.value),
|
||||
api_key: normalizeValue(options.apiKey.value) || undefined,
|
||||
base_url: normalizeValue(options.baseUrl.value) || undefined,
|
||||
base_url_preset: normalizeValue(options.baseUrlPreset?.value) || undefined,
|
||||
force_refresh: forceRefresh,
|
||||
},
|
||||
})
|
||||
@@ -333,6 +380,7 @@ export function useLlmProviderDirectory(options: UseLlmProviderDirectoryOptions)
|
||||
return {
|
||||
providers,
|
||||
providerItems,
|
||||
baseUrlPresetItems,
|
||||
models,
|
||||
selectedProvider,
|
||||
selectedModel,
|
||||
@@ -343,6 +391,7 @@ export function useLlmProviderDirectory(options: UseLlmProviderDirectoryOptions)
|
||||
showApiKeyField,
|
||||
hasUsableCredential,
|
||||
canRefreshModels,
|
||||
setBaseUrlPreset,
|
||||
authDialogVisible,
|
||||
authPolling,
|
||||
authPopupBlocked,
|
||||
|
||||
@@ -60,6 +60,7 @@ export interface WizardData {
|
||||
supportAudioInputOutput: boolean
|
||||
apiKey: string
|
||||
baseUrl: string
|
||||
baseUrlPreset: string
|
||||
maxContextTokens: number
|
||||
voiceApiKey: string
|
||||
voiceBaseUrl: string
|
||||
@@ -107,6 +108,7 @@ export interface ValidationErrorState {
|
||||
downloader: {
|
||||
name: boolean
|
||||
host: boolean
|
||||
apikey: boolean
|
||||
username: boolean
|
||||
password: boolean
|
||||
}
|
||||
@@ -239,6 +241,7 @@ const wizardData = ref<WizardData>({
|
||||
supportAudioInputOutput: false,
|
||||
apiKey: '',
|
||||
baseUrl: 'https://api.deepseek.com',
|
||||
baseUrlPreset: '',
|
||||
maxContextTokens: 64,
|
||||
voiceApiKey: '',
|
||||
voiceBaseUrl: '',
|
||||
@@ -277,6 +280,7 @@ const validationErrors = ref<ValidationErrorState>({
|
||||
downloader: {
|
||||
name: false,
|
||||
host: false,
|
||||
apikey: false,
|
||||
username: false,
|
||||
password: false,
|
||||
},
|
||||
@@ -316,6 +320,7 @@ export function useSetupWizard() {
|
||||
// 媒体服务器映射
|
||||
mediaServer: {
|
||||
'emby': 'EmbyModule',
|
||||
'zspace': 'ZSpaceModule',
|
||||
'jellyfin': 'JellyfinModule',
|
||||
'plex': 'PlexModule',
|
||||
'trimemedia': 'TrimeMediaModule',
|
||||
@@ -323,8 +328,10 @@ export function useSetupWizard() {
|
||||
},
|
||||
// 通知映射
|
||||
notification: {
|
||||
'feishu': 'FeishuModule',
|
||||
'telegram': 'TelegramModule',
|
||||
'wechat': 'WechatModule',
|
||||
'wechatclawbot': 'WechatClawBotModule',
|
||||
'slack': 'SlackModule',
|
||||
'synologychat': 'SynologyChatModule',
|
||||
'qqbot': 'QQBotModule',
|
||||
@@ -419,7 +426,18 @@ export function useSetupWizard() {
|
||||
wizardData.value.notification.type = type
|
||||
// 如果名称为空或为默认名称,则设置默认名称
|
||||
if (!wizardData.value.notification.name || wizardData.value.notification.name.includes('通知')) {
|
||||
wizardData.value.notification.name = `${type} 通知`
|
||||
const displayNameMap: Record<string, string> = {
|
||||
wechat: '企业微信',
|
||||
feishu: '飞书',
|
||||
wechatclawbot: '微信 ClawBot',
|
||||
telegram: 'Telegram',
|
||||
slack: 'Slack',
|
||||
synologychat: 'SynologyChat',
|
||||
qqbot: 'QQ',
|
||||
vocechat: 'VoceChat',
|
||||
webpush: 'WebPush',
|
||||
}
|
||||
wizardData.value.notification.name = `${displayNameMap[type] || type} 通知`
|
||||
}
|
||||
wizardData.value.notification.enabled = true
|
||||
// 不清空config和switchs,保留用户已输入的值
|
||||
@@ -466,6 +484,7 @@ export function useSetupWizard() {
|
||||
validationErrors.value.downloader = {
|
||||
name: false,
|
||||
host: false,
|
||||
apikey: false,
|
||||
username: false,
|
||||
password: false,
|
||||
}
|
||||
@@ -548,9 +567,18 @@ export function useSetupWizard() {
|
||||
}
|
||||
|
||||
// 根据下载器类型验证其他必输项
|
||||
if (
|
||||
wizardData.value.downloader.type === 'qbittorrent'
|
||||
|| wizardData.value.downloader.type === 'transmission'
|
||||
if (wizardData.value.downloader.type === 'qbittorrent') {
|
||||
const hasApiKey = !!wizardData.value.downloader.config?.apikey?.trim()
|
||||
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'
|
||||
) {
|
||||
if (!wizardData.value.downloader.config?.username?.trim()) {
|
||||
@@ -592,6 +620,15 @@ export function useSetupWizard() {
|
||||
errors.push(t('mediaserver.apiKeyRequired'))
|
||||
validationErrors.value.mediaServer.apikey = true
|
||||
}
|
||||
} else if (wizardData.value.mediaServer.type === 'zspace') {
|
||||
if (!wizardData.value.mediaServer.config?.username?.trim()) {
|
||||
errors.push(t('mediaserver.usernameRequired'))
|
||||
validationErrors.value.mediaServer.username = true
|
||||
}
|
||||
if (!wizardData.value.mediaServer.config?.password?.trim()) {
|
||||
errors.push(t('mediaserver.passwordRequired'))
|
||||
validationErrors.value.mediaServer.password = true
|
||||
}
|
||||
} else if (wizardData.value.mediaServer.type === 'plex') {
|
||||
if (!wizardData.value.mediaServer.config?.token?.trim()) {
|
||||
errors.push(t('mediaserver.tokenRequired'))
|
||||
@@ -642,6 +679,18 @@ export function useSetupWizard() {
|
||||
validationErrors.value.notification.WECHAT_APP_SECRET = true
|
||||
}
|
||||
break
|
||||
case 'wechatclawbot':
|
||||
break
|
||||
case 'feishu':
|
||||
if (!config.FEISHU_APP_ID?.trim()) {
|
||||
errors.push(t('notification.feishu.appIdRequired'))
|
||||
validationErrors.value.notification.FEISHU_APP_ID = true
|
||||
}
|
||||
if (!config.FEISHU_APP_SECRET?.trim()) {
|
||||
errors.push(t('notification.feishu.appSecretRequired'))
|
||||
validationErrors.value.notification.FEISHU_APP_SECRET = true
|
||||
}
|
||||
break
|
||||
case 'telegram':
|
||||
if (!config.TELEGRAM_TOKEN?.trim()) {
|
||||
errors.push(t('notification.telegram.tokenRequired'))
|
||||
@@ -840,7 +889,7 @@ export function useSetupWizard() {
|
||||
case 5: // 媒体服务器测试 - 只有选择了媒体服务器才测试
|
||||
return !!wizardData.value.mediaServer.type
|
||||
case 6: // 消息通知测试 - 只有选择了通知才测试
|
||||
return !!wizardData.value.notification.type
|
||||
return !!wizardData.value.notification.type && wizardData.value.notification.type !== 'wechatclawbot'
|
||||
default:
|
||||
return false
|
||||
}
|
||||
@@ -1384,6 +1433,7 @@ export function useSetupWizard() {
|
||||
LLM_SUPPORT_AUDIO_INPUT_OUTPUT: wizardData.value.agent.supportAudioInputOutput,
|
||||
LLM_API_KEY: wizardData.value.agent.apiKey,
|
||||
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,
|
||||
AI_VOICE_API_KEY: wizardData.value.agent.voiceApiKey || null,
|
||||
AI_VOICE_BASE_URL: wizardData.value.agent.voiceBaseUrl || null,
|
||||
@@ -1491,6 +1541,7 @@ export function useSetupWizard() {
|
||||
wizardData.value.agent.supportAudioInputOutput = Boolean(result.data.LLM_SUPPORT_AUDIO_INPUT_OUTPUT)
|
||||
wizardData.value.agent.apiKey = result.data.LLM_API_KEY || ''
|
||||
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.voiceApiKey = result.data.AI_VOICE_API_KEY || ''
|
||||
wizardData.value.agent.voiceBaseUrl = result.data.AI_VOICE_BASE_URL || ''
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { useTabStateRestore } from '@/composables/useStateRestore'
|
||||
import { isMobileDevice } from '@/@core/utils/navigator'
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
@@ -65,6 +64,10 @@ const showTabsScrollIndicator = ref(false)
|
||||
const showLeftButton = ref(false)
|
||||
const showRightButton = ref(false)
|
||||
|
||||
const isTouchDevice = () => {
|
||||
return window.matchMedia('(hover: none) and (pointer: coarse)').matches || navigator.maxTouchPoints > 0
|
||||
}
|
||||
|
||||
// Function to scroll the tabs
|
||||
const scrollTabs = (direction: 'left' | 'right') => {
|
||||
const el = tabsContainerRef.value
|
||||
@@ -90,17 +93,17 @@ const updateTabsIndicator = () => {
|
||||
const el = tabsContainerRef.value
|
||||
if (!el) return
|
||||
|
||||
// 在移动端不显示滚动指示器
|
||||
const isMobile = isMobileDevice()
|
||||
// 仅在触摸设备上隐藏按钮,非触摸小屏设备仍需支持横向切换
|
||||
const shouldHideScrollControls = isTouchDevice()
|
||||
|
||||
const tolerance = 1 // Allow 1px tolerance
|
||||
const hasOverflow = el.scrollWidth > el.clientWidth + tolerance
|
||||
const isScrolledToEnd = el.scrollLeft + el.clientWidth >= el.scrollWidth - tolerance
|
||||
const isScrolledToStart = el.scrollLeft <= tolerance
|
||||
|
||||
showTabsScrollIndicator.value = hasOverflow && !isScrolledToEnd && !isMobile
|
||||
showLeftButton.value = hasOverflow && !isScrolledToStart && !isMobile
|
||||
showRightButton.value = hasOverflow && !isScrolledToEnd && !isMobile
|
||||
showTabsScrollIndicator.value = hasOverflow && !isScrolledToEnd && !shouldHideScrollControls
|
||||
showLeftButton.value = hasOverflow && !isScrolledToStart && !shouldHideScrollControls
|
||||
showRightButton.value = hasOverflow && !isScrolledToEnd && !shouldHideScrollControls
|
||||
}
|
||||
|
||||
// Debounce resize handler
|
||||
@@ -185,8 +188,8 @@ onUnmounted(() => {
|
||||
margin-inline-start: 6px;
|
||||
}
|
||||
|
||||
// 在移动端隐藏滚动按钮
|
||||
@media (width <= 768px) {
|
||||
// 触摸设备支持手势横向滚动,无需额外按钮
|
||||
@media (hover: none) and (pointer: coarse) {
|
||||
display: none !important;
|
||||
}
|
||||
}
|
||||
@@ -231,8 +234,8 @@ onUnmounted(() => {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
// 在移动端隐藏渐变指示器
|
||||
@media (width <= 768px) {
|
||||
// 触摸设备支持手势横向滚动,无需额外指示器
|
||||
@media (hover: none) and (pointer: coarse) {
|
||||
&::after {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
@@ -361,13 +361,13 @@ onMounted(() => {
|
||||
</VCard>
|
||||
</VDialog>
|
||||
<!-- 实时日志弹窗 -->
|
||||
<VDialog
|
||||
v-if="loggingDialog"
|
||||
v-model="loggingDialog"
|
||||
scrollable
|
||||
max-width="70rem"
|
||||
:fullscreen="!display.mdAndUp.value"
|
||||
>
|
||||
<VDialog
|
||||
v-if="loggingDialog"
|
||||
v-model="loggingDialog"
|
||||
scrollable
|
||||
max-width="80rem"
|
||||
:fullscreen="!display.mdAndUp.value"
|
||||
>
|
||||
<VCard>
|
||||
<VDialogCloseBtn @click="loggingDialog = false" />
|
||||
<VCardItem>
|
||||
@@ -383,7 +383,7 @@ onMounted(() => {
|
||||
</VCardTitle>
|
||||
</VCardItem>
|
||||
<VDivider />
|
||||
<VCardText>
|
||||
<VCardText class="pa-0">
|
||||
<LoggingView logfile="moviepilot.log" />
|
||||
</VCardText>
|
||||
</VCard>
|
||||
|
||||
@@ -12,6 +12,7 @@ const hasNewMessage = ref(false)
|
||||
|
||||
// 通知列表
|
||||
const notificationList = ref<SystemNotification[]>([])
|
||||
const MAX_NOTIFICATIONS = 100
|
||||
|
||||
// 弹窗
|
||||
const appsMenu = ref(false)
|
||||
@@ -31,6 +32,9 @@ function handleMessage(event: MessageEvent) {
|
||||
if (event.data) {
|
||||
const noti: SystemNotification = JSON.parse(event.data)
|
||||
notificationList.value.unshift(noti)
|
||||
if (notificationList.value.length > MAX_NOTIFICATIONS) {
|
||||
notificationList.value.length = MAX_NOTIFICATIONS
|
||||
}
|
||||
hasNewMessage.value = true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ const route = useRoute()
|
||||
<template>
|
||||
<DefaultLayout>
|
||||
<router-view v-slot="{ Component }">
|
||||
<keep-alive>
|
||||
<keep-alive :max="12">
|
||||
<component :is="Component" v-if="route.meta.keepAlive" :key="route.fullPath" />
|
||||
</keep-alive>
|
||||
<component :is="Component" v-if="!route.meta.keepAlive" :key="route.fullPath" />
|
||||
|
||||
@@ -74,6 +74,9 @@ export default {
|
||||
descending: 'Descending',
|
||||
versionMismatch: 'The browser cache version is inconsistent with the server version, please try to clear the cache',
|
||||
clearCache: 'Clear Cache',
|
||||
sortMode: 'Sort Mode',
|
||||
sortModeHint: 'Drag sorting mode is active',
|
||||
exit: 'Exit',
|
||||
},
|
||||
mediaType: {
|
||||
movie: 'Movie',
|
||||
@@ -318,7 +321,7 @@ export default {
|
||||
system: {
|
||||
title: 'System',
|
||||
description:
|
||||
'Basic settings, downloaders (Qbittorrent, Transmission), media servers (Emby, Jellyfin, Plex, TrimeMedia, Ugreen)',
|
||||
'Basic settings, downloaders (Qbittorrent, Transmission), media servers (Emby, ZSpace, Jellyfin, Plex, TrimeMedia, Ugreen)',
|
||||
},
|
||||
directory: {
|
||||
title: 'Storage & Directories',
|
||||
@@ -350,7 +353,8 @@ export default {
|
||||
},
|
||||
notification: {
|
||||
title: 'Notifications',
|
||||
description: 'Notification channels (WeChat, Telegram, Slack, SynologyChat, VoceChat, WebPush), message scope',
|
||||
description:
|
||||
'Notification channels (WeChat Work, WeChat ClawBot, Telegram, Slack, SynologyChat, VoceChat, WebPush), message scope',
|
||||
},
|
||||
about: {
|
||||
title: 'About',
|
||||
@@ -469,6 +473,60 @@ export default {
|
||||
adminsHint: 'User IDs that can use admin menu and commands, separated by commas',
|
||||
adminsPlaceholder: 'User IDs list, separated by commas',
|
||||
},
|
||||
wechatclawbot: {
|
||||
name: 'WeChat ClawBot',
|
||||
baseUrl: 'iLink Base URL',
|
||||
baseUrlHint: 'iLink service URL for WeChat ClawBot, keep default in most cases',
|
||||
defaultTarget: 'Default Target',
|
||||
defaultTargetHint: 'Optional target userid; leave empty to notify interacted users',
|
||||
defaultTargetPlaceholder: 'userid (optional)',
|
||||
admins: 'Admin Whitelist',
|
||||
adminsHint: 'User IDs allowed to run slash commands, separated by commas',
|
||||
adminsPlaceholder: 'User IDs list, separated by commas',
|
||||
pollTimeout: 'Poll Timeout (seconds)',
|
||||
pollTimeoutHint: 'Long polling timeout, recommended 20-30 seconds',
|
||||
loginStatus: 'Login Status',
|
||||
connected: 'Connected',
|
||||
waiting: 'Waiting for QR scan',
|
||||
scanned: 'Scanned, waiting for confirmation',
|
||||
confirmed: 'Confirmed, establishing connection',
|
||||
expired: 'QR code expired',
|
||||
refreshQrcode: 'Refresh QR Code',
|
||||
logout: 'Logout',
|
||||
noQrcode: 'No QR code yet. Refresh or save config first.',
|
||||
scanHint: 'Scan with WeChat to bind. Save and enable this channel before first use.',
|
||||
accountId: 'Account ID',
|
||||
qrcodeUpdatedAt: 'QR Updated At',
|
||||
knownTargets: 'Recent Interacted Users',
|
||||
noKnownTargets: 'No interaction records yet',
|
||||
statusLoadFailed: 'Failed to load WeChat ClawBot status',
|
||||
qrcodeRefreshSuccess: 'WeChat ClawBot QR code refreshed',
|
||||
qrcodeRefreshFailed: 'Failed to refresh WeChat ClawBot QR code',
|
||||
logoutSuccess: 'WeChat ClawBot logged out',
|
||||
logoutFailed: 'Failed to logout WeChat ClawBot',
|
||||
},
|
||||
feishu: {
|
||||
name: 'Feishu',
|
||||
appId: 'App ID',
|
||||
appIdHint: 'App ID of the Feishu Open Platform application',
|
||||
appIdRequired: 'App ID cannot be empty',
|
||||
appSecret: 'App Secret',
|
||||
appSecretHint: 'App Secret of the Feishu Open Platform application',
|
||||
appSecretRequired: 'App Secret cannot be empty',
|
||||
openId: 'Default User Open ID',
|
||||
openIdHint: 'Default recipient user Open ID; leave empty to prefer recent interacted users',
|
||||
openIdPlaceholder: 'ou_xxx',
|
||||
chatId: 'Default Group Chat ID',
|
||||
chatIdHint: 'Default recipient group chat ID; either this or Open ID is enough',
|
||||
chatIdPlaceholder: 'oc_xxx',
|
||||
admins: 'Admin Whitelist',
|
||||
adminsHint: 'Open IDs allowed to run commands and admin actions, separated by commas',
|
||||
adminsPlaceholder: 'Open ID list, separated by commas',
|
||||
verificationToken: 'Verification Token',
|
||||
verificationTokenHint: 'Verification Token for Feishu event subscription, required when validation is enabled',
|
||||
encryptKey: 'Encrypt Key',
|
||||
encryptKeyHint: 'Encrypt Key for Feishu event subscription, required when encryption is enabled',
|
||||
},
|
||||
telegram: {
|
||||
name: 'Telegram',
|
||||
token: 'Bot Token',
|
||||
@@ -884,6 +942,7 @@ export default {
|
||||
plex: 'Plex',
|
||||
jellyfin: 'Jellyfin',
|
||||
emby: 'Emby',
|
||||
zspace: 'ZSpace',
|
||||
appLaunchFailed: 'App launch failed, redirecting to web version',
|
||||
appNotInstalled: 'App not detected, redirecting to web version',
|
||||
downloadApp: 'Download App',
|
||||
@@ -998,6 +1057,7 @@ export default {
|
||||
aiRecommend: 'AI Recommendation',
|
||||
reRecommend: 'Regenerate Recommendation',
|
||||
aiRecommendError: 'AI Recommendation Failed',
|
||||
refreshSearch: 'Re-search',
|
||||
},
|
||||
browse: {
|
||||
actor: 'Actor',
|
||||
@@ -1233,6 +1293,17 @@ export default {
|
||||
content: 'Content',
|
||||
refreshing: 'Refreshing',
|
||||
initializing: 'Initializing',
|
||||
searchPlaceholder: 'Search logs',
|
||||
allLevels: 'All Levels',
|
||||
followTail: 'Follow latest logs',
|
||||
wrapLines: 'Wrap lines',
|
||||
pauseStream: 'Pause stream',
|
||||
resumeStream: 'Resume stream',
|
||||
waitingForLogs: 'Waiting for logs...',
|
||||
paused: 'Paused',
|
||||
connected: 'Live',
|
||||
lineCount: 'Showing {visible}/{total} lines',
|
||||
jumpToLatest: 'Jump to latest ({count})',
|
||||
},
|
||||
moduleTest: {
|
||||
normal: 'Normal',
|
||||
@@ -1427,6 +1498,7 @@ export default {
|
||||
media: 'Media',
|
||||
network: 'Network',
|
||||
log: 'Log',
|
||||
data: 'Data',
|
||||
lab: 'Lab',
|
||||
downloaderSaveSuccess: 'Downloader settings saved successfully',
|
||||
downloaderSaveFailed: 'Failed to save downloader settings!',
|
||||
@@ -1444,6 +1516,7 @@ export default {
|
||||
transmission: 'Transmission',
|
||||
rtorrent: 'rTorrent',
|
||||
emby: 'Emby',
|
||||
zspace: 'ZSpace',
|
||||
jellyfin: 'Jellyfin',
|
||||
plex: 'Plex',
|
||||
ugreen: 'Ugreen',
|
||||
@@ -1492,6 +1565,9 @@ export default {
|
||||
recognizePluginFirst: 'Prioritize Plugin Recognition',
|
||||
recognizePluginFirstHint:
|
||||
'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',
|
||||
githubProxyPlaceholder: 'Leave empty for no proxy',
|
||||
githubProxyHint: 'Use proxy to accelerate Github access speed',
|
||||
@@ -1521,6 +1597,21 @@ export default {
|
||||
logBackupCountMin: 'Maximum number of log file backups must be greater than or equal to 1',
|
||||
logFileFormat: 'Log File Format',
|
||||
logFileFormatHint: 'Set the output format of log files to customize the displayed content of logs',
|
||||
dataCleanupEnable: 'Enable Data Cleanup',
|
||||
dataCleanupEnableHint: 'When disabled, scheduled data cleanup tasks will be skipped',
|
||||
dataCleanupDaysRequired: 'Please enter a cleanup retention period',
|
||||
dataCleanupDaysMin: 'Cleanup retention period must be greater than or equal to 0',
|
||||
dataCleanupMessageDays: 'Message Retention Days',
|
||||
dataCleanupMessageDaysHint: 'Unit: days. Set to 0 to skip cleanup for the message table',
|
||||
dataCleanupDownloadHistoryDays: 'Download History Retention Days',
|
||||
dataCleanupDownloadHistoryDaysHint:
|
||||
'Unit: days. Set to 0 to skip cleanup for download history and its related orphaned download file records',
|
||||
dataCleanupSiteUserDataDays: 'Site User Data Retention Days',
|
||||
dataCleanupSiteUserDataDaysHint: 'Unit: days. Set to 0 to skip cleanup for the site user data table',
|
||||
dataCleanupTransferHistoryDays: 'Transfer History Retention Days',
|
||||
dataCleanupTransferHistoryDaysHint: 'Unit: days. Set to 0 to skip cleanup for the transfer history table',
|
||||
downloadFilesCleanupNotice:
|
||||
'The download files table has no independent timestamp field. Its orphan record cleanup follows the retention period of download history.',
|
||||
pluginAutoReload: 'Plugin Hot Reload',
|
||||
pluginAutoReloadHint: 'Automatically reload after modifying plugin files, used when developing plugins',
|
||||
pluginLocalRepoPaths: 'Local Plugin Repository Paths',
|
||||
@@ -1567,6 +1658,7 @@ export default {
|
||||
},
|
||||
mb: 'MB',
|
||||
hour: 'hour',
|
||||
day: 'day',
|
||||
customizeWallpaperApi: 'Customize Wallpaper Api',
|
||||
customizeWallpaperApiHint:
|
||||
'It will get the image file extension format images that are allowed in settings in the content returned by the API.',
|
||||
@@ -1697,7 +1789,9 @@ export default {
|
||||
timeSaveSuccess: 'Notification send time saved successfully',
|
||||
timeSaveFailed: 'Failed to save notification send time!',
|
||||
channel: 'Notification',
|
||||
wechat: 'WeChat',
|
||||
wechat: 'WeChat Work',
|
||||
wechatClawBot: 'WeChat ClawBot',
|
||||
feishu: 'Feishu',
|
||||
resourceDownload: 'Resource Download',
|
||||
mediaImport: 'Media Import',
|
||||
subscription: 'Subscription',
|
||||
@@ -1781,7 +1875,7 @@ export default {
|
||||
animeCategory: 'Anime',
|
||||
downloadUser: 'Remote Search Auto Download User List',
|
||||
downloadUserHint:
|
||||
'Whether to automatically download when searching with Telegram, WeChat, etc., comma separated, set to all to represent all users auto-download',
|
||||
'Whether to auto-download when searching with Telegram, WeChat Work, etc., comma separated, set to all for all users',
|
||||
multipleNameSearch: 'Multiple Name Resource Search',
|
||||
multipleNameSearchHint:
|
||||
'Search site resources using multiple names (Chinese, English, etc.) and merge search results, will increase site access frequency',
|
||||
@@ -2026,7 +2120,8 @@ export default {
|
||||
resetDefaultAvatar: 'Reset Default Avatar',
|
||||
restoreCurrentAvatar: 'Restore Current Avatar',
|
||||
notifications: 'Notifications',
|
||||
wechat: 'WeChat UserID',
|
||||
wechat: 'WeChat Work UserID',
|
||||
wechatClawBot: 'WeChat ClawBot UserID',
|
||||
telegram: 'Telegram UserID',
|
||||
slack: 'Slack UserID',
|
||||
discord: 'Discord UserID',
|
||||
@@ -2422,6 +2517,31 @@ export default {
|
||||
scrapeHint: 'Automatically scrape metadata after organization',
|
||||
fromHistoryOption: 'Reuse Historical Recognition Info',
|
||||
fromHistoryHint: 'Use media info already recognized in historical organization records',
|
||||
previewTitle: 'Preview Result',
|
||||
previewSubtitle: 'Click "Preview" to inspect the expected organization result without changing files.',
|
||||
previewResult: 'Preview',
|
||||
previewLoading: 'Generating preview result...',
|
||||
previewRequestFailed: 'Preview request failed',
|
||||
previewTotal: 'Total {count}',
|
||||
previewSuccess: 'Success {count}',
|
||||
previewFailed: 'Failed {count}',
|
||||
previewSourcePath: 'Source Path',
|
||||
previewTargetPath: 'Target Path',
|
||||
previewMediaInfo: 'Media',
|
||||
previewMediaName: 'Name',
|
||||
previewMediaType: 'Type',
|
||||
previewSeasonInfo: 'Season',
|
||||
previewSeasonLabel: 'Season',
|
||||
previewEpisodeCount: 'Episodes',
|
||||
previewAfterColumn: 'After',
|
||||
previewBeforeColumn: 'Before',
|
||||
previewFileNameColumn: 'Filename',
|
||||
previewEmptyTitle: 'No preview yet',
|
||||
previewEmptyDescription: 'Click "Preview" to inspect the organization result here.',
|
||||
noPreviewData: 'No preview data',
|
||||
noFailedPreviewData: 'No failed items',
|
||||
copySuccess: 'Path copied',
|
||||
copyFailed: 'Copy failed',
|
||||
addToQueue: 'Add to Organization Queue',
|
||||
reorganizeNow: 'Organize Now',
|
||||
auto: 'Auto',
|
||||
@@ -2752,7 +2872,9 @@ export default {
|
||||
nickname: 'Nickname',
|
||||
nicknamePlaceholder: 'Display nickname, takes precedence over username',
|
||||
accountBinding: 'Account Binding',
|
||||
wechatUser: 'WeChat User',
|
||||
wechatUser: 'WeChat Work User',
|
||||
wechatClawBotUser: 'WeChat ClawBot User',
|
||||
feishuUser: 'Feishu User',
|
||||
telegramUser: 'Telegram User',
|
||||
slackUser: 'Slack User',
|
||||
discordUser: 'Discord User',
|
||||
@@ -2921,8 +3043,10 @@ export default {
|
||||
rtorrentHostHint: 'HTTP: http://ip:port/RPC2 or SCGI: scgi://ip:port',
|
||||
default: 'Default',
|
||||
host: 'Host',
|
||||
apiKey: 'API Key',
|
||||
username: 'Username',
|
||||
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',
|
||||
sequentail: 'Sequential Download',
|
||||
force_resume: 'Force Resume',
|
||||
@@ -3319,12 +3443,13 @@ export default {
|
||||
description: 'Configure media server',
|
||||
info: 'Media Server Configuration',
|
||||
infoDesc:
|
||||
'Configure media server for media library management, can choose Emby, Jellyfin, Plex, TrimeMedia or Ugreen.',
|
||||
'Configure media server for media library management, can choose Emby, ZSpace, Jellyfin, Plex, TrimeMedia or Ugreen.',
|
||||
type: 'Media Server Type',
|
||||
typeHint: 'Select the type of media server to use',
|
||||
name: 'Server Name',
|
||||
nameHint: 'Set a name for the media server',
|
||||
embyConfig: 'Emby Configuration',
|
||||
zspaceConfig: 'ZSpace Configuration',
|
||||
jellyfinConfig: 'Jellyfin Configuration',
|
||||
plexConfig: 'Plex Configuration',
|
||||
host: 'Server Address',
|
||||
@@ -3340,6 +3465,7 @@ export default {
|
||||
typeHint: 'Select the type of notification channel to use',
|
||||
name: 'Notification Name',
|
||||
nameHint: 'Set a name for the notification channel',
|
||||
feishuConfig: 'Feishu Configuration',
|
||||
telegramConfig: 'Telegram Configuration',
|
||||
emailConfig: 'Email Configuration',
|
||||
botToken: 'Bot Token',
|
||||
|
||||
@@ -74,6 +74,9 @@ export default {
|
||||
descending: '降序',
|
||||
versionMismatch: '浏览器缓存版本与服务端版本不一致,请尝试清除缓存',
|
||||
clearCache: '清除缓存',
|
||||
sortMode: '排序模式',
|
||||
sortModeHint: '已进入拖拽排序模式',
|
||||
exit: '退出',
|
||||
},
|
||||
mediaType: {
|
||||
movie: '电影',
|
||||
@@ -316,7 +319,7 @@ export default {
|
||||
settingTabs: {
|
||||
system: {
|
||||
title: '系统',
|
||||
description: '基础设置、下载器(Qbittorrent、Transmission)、媒体服务器(Emby、Jellyfin、Plex、飞牛影视、绿联影视)',
|
||||
description: '基础设置、下载器(Qbittorrent、Transmission)、媒体服务器(Emby、极影视、Jellyfin、Plex、飞牛影视、绿联影视)',
|
||||
},
|
||||
directory: {
|
||||
title: '存储 & 目录',
|
||||
@@ -348,7 +351,7 @@ export default {
|
||||
},
|
||||
notification: {
|
||||
title: '通知',
|
||||
description: '通知渠道(微信、Telegram、Slack、SynologyChat、VoceChat、WebPush)、消息发送范围',
|
||||
description: '通知渠道(企业微信、微信 ClawBot、Telegram、Slack、SynologyChat、VoceChat、WebPush)、消息发送范围',
|
||||
},
|
||||
about: {
|
||||
title: '关于',
|
||||
@@ -465,6 +468,60 @@ export default {
|
||||
adminsHint: '可使用管理菜单及命令的用户ID列表,多个ID使用,分隔',
|
||||
adminsPlaceholder: '用户ID列表,多个ID使用,分隔',
|
||||
},
|
||||
wechatclawbot: {
|
||||
name: '微信 ClawBot',
|
||||
baseUrl: 'iLink 地址',
|
||||
baseUrlHint: '微信 ClawBot iLink 服务地址,通常使用默认值',
|
||||
defaultTarget: '默认通知目标',
|
||||
defaultTargetHint: '可填写用户 userid;不填则默认发给已互动用户',
|
||||
defaultTargetPlaceholder: '用户 userid(可选)',
|
||||
admins: '管理员白名单',
|
||||
adminsHint: '允许执行斜杠命令的用户ID列表,多个ID使用,分隔',
|
||||
adminsPlaceholder: '用户ID列表,多个ID使用,分隔',
|
||||
pollTimeout: '轮询超时(秒)',
|
||||
pollTimeoutHint: '长轮询请求超时时间,建议 20-30 秒',
|
||||
loginStatus: '登录状态',
|
||||
connected: '已连接',
|
||||
waiting: '等待扫码',
|
||||
scanned: '已扫码,待确认',
|
||||
confirmed: '已确认,正在建立连接',
|
||||
expired: '二维码已过期',
|
||||
refreshQrcode: '刷新二维码',
|
||||
logout: '退出登录',
|
||||
noQrcode: '暂无二维码,请先刷新或保存配置后重试',
|
||||
scanHint: '使用微信扫码绑定后,状态会自动刷新。首次使用请先保存并启用该通知渠道。',
|
||||
accountId: '账号ID',
|
||||
qrcodeUpdatedAt: '二维码更新时间',
|
||||
knownTargets: '最近互动用户',
|
||||
noKnownTargets: '暂无互动用户记录',
|
||||
statusLoadFailed: '获取微信 ClawBot 状态失败',
|
||||
qrcodeRefreshSuccess: '微信 ClawBot 二维码已刷新',
|
||||
qrcodeRefreshFailed: '刷新微信 ClawBot 二维码失败',
|
||||
logoutSuccess: '微信 ClawBot 已退出登录',
|
||||
logoutFailed: '微信 ClawBot 退出登录失败',
|
||||
},
|
||||
feishu: {
|
||||
name: '飞书',
|
||||
appId: 'App ID',
|
||||
appIdHint: '飞书开放平台应用的 App ID',
|
||||
appIdRequired: 'App ID 不能为空',
|
||||
appSecret: 'App Secret',
|
||||
appSecretHint: '飞书开放平台应用的 App Secret',
|
||||
appSecretRequired: 'App Secret 不能为空',
|
||||
openId: '默认用户 Open ID',
|
||||
openIdHint: '默认通知接收用户的 Open ID,留空则优先使用互动用户',
|
||||
openIdPlaceholder: 'ou_xxx',
|
||||
chatId: '默认群聊 Chat ID',
|
||||
chatIdHint: '默认通知接收群聊的 Chat ID,和 Open ID 二选一即可',
|
||||
chatIdPlaceholder: 'oc_xxx',
|
||||
admins: '管理员白名单',
|
||||
adminsHint: '允许执行命令和管理操作的 Open ID 列表,多个使用 , 分隔',
|
||||
adminsPlaceholder: 'Open ID 列表,多个使用 , 分隔',
|
||||
verificationToken: 'Verification Token',
|
||||
verificationTokenHint: '飞书事件订阅的 Verification Token,启用事件校验时填写',
|
||||
encryptKey: 'Encrypt Key',
|
||||
encryptKeyHint: '飞书事件订阅的 Encrypt Key,启用消息加密时填写',
|
||||
},
|
||||
telegram: {
|
||||
name: 'Telegram',
|
||||
token: 'Bot Token',
|
||||
@@ -880,6 +937,7 @@ export default {
|
||||
plex: 'Plex',
|
||||
jellyfin: 'Jellyfin',
|
||||
emby: 'Emby',
|
||||
zspace: '极影视',
|
||||
appLaunchFailed: 'APP启动失败,正在跳转到网页版',
|
||||
appNotInstalled: '未检测到APP,正在跳转到网页版',
|
||||
downloadApp: '下载APP',
|
||||
@@ -993,6 +1051,7 @@ export default {
|
||||
aiRecommend: '智能推荐',
|
||||
reRecommend: '重新生成推荐',
|
||||
aiRecommendError: '智能推荐失败',
|
||||
refreshSearch: '重新搜索',
|
||||
},
|
||||
browse: {
|
||||
actor: '演员',
|
||||
@@ -1228,6 +1287,17 @@ export default {
|
||||
content: '内容',
|
||||
refreshing: '正在刷新',
|
||||
initializing: '正在初始化',
|
||||
searchPlaceholder: '搜索日志内容',
|
||||
allLevels: '全部级别',
|
||||
followTail: '跟随最新日志',
|
||||
wrapLines: '自动换行',
|
||||
pauseStream: '暂停日志流',
|
||||
resumeStream: '恢复日志流',
|
||||
waitingForLogs: '等待日志输出...',
|
||||
paused: '已暂停',
|
||||
connected: '实时更新中',
|
||||
lineCount: '显示 {visible}/{total} 行',
|
||||
jumpToLatest: '查看最新 ({count})',
|
||||
},
|
||||
moduleTest: {
|
||||
normal: '正常',
|
||||
@@ -1413,6 +1483,7 @@ export default {
|
||||
media: '媒体',
|
||||
network: '网络',
|
||||
log: '日志',
|
||||
data: '数据',
|
||||
lab: '实验室',
|
||||
downloaderSaveSuccess: '下载器设置保存成功',
|
||||
downloaderSaveFailed: '下载器设置保存失败!',
|
||||
@@ -1430,6 +1501,7 @@ export default {
|
||||
transmission: 'Transmission',
|
||||
rtorrent: 'rTorrent',
|
||||
emby: 'Emby',
|
||||
zspace: '极影视',
|
||||
jellyfin: 'Jellyfin',
|
||||
plex: 'Plex',
|
||||
ugreen: '绿联影视',
|
||||
@@ -1474,6 +1546,8 @@ export default {
|
||||
fanartLangHint: '设置Fanart图片的语言偏好,多选时按优先级顺序排列',
|
||||
recognizePluginFirst: "优先使用插件识别",
|
||||
recognizePluginFirstHint: "优先调用插件识别媒体信息,若插件命中则不再调用原生识别",
|
||||
mediaRecognizeShare: '共享使用媒体识别数据',
|
||||
mediaRecognizeShareHint: '识别成功后上报关键字与媒体ID,识别失败时优先回查共享识别结果',
|
||||
githubProxy: 'Github加速代理',
|
||||
githubProxyPlaceholder: '留空表示不使用代理',
|
||||
githubProxyHint: '使用代理加速Github访问速度',
|
||||
@@ -1502,6 +1576,19 @@ export default {
|
||||
logBackupCountMin: '日志文件最大备份数量必须大于等于1',
|
||||
logFileFormat: '日志文件格式',
|
||||
logFileFormatHint: '设置日志文件的输出格式,用于自定义日志的显示内容',
|
||||
dataCleanupEnable: '启用数据清理',
|
||||
dataCleanupEnableHint: '总开关关闭时将跳过定时数据清理任务',
|
||||
dataCleanupDaysRequired: '请输入清理周期',
|
||||
dataCleanupDaysMin: '清理周期必须大于等于0',
|
||||
dataCleanupMessageDays: '消息表保留天数',
|
||||
dataCleanupMessageDaysHint: '单位:天,0 表示不清理消息表数据',
|
||||
dataCleanupDownloadHistoryDays: '下载历史表保留天数',
|
||||
dataCleanupDownloadHistoryDaysHint: '单位:天,0 表示不清理下载历史及其关联的下载文件孤儿记录',
|
||||
dataCleanupSiteUserDataDays: '站点数据表保留天数',
|
||||
dataCleanupSiteUserDataDaysHint: '单位:天,0 表示不清理站点用户数据表',
|
||||
dataCleanupTransferHistoryDays: '整理历史表保留天数',
|
||||
dataCleanupTransferHistoryDaysHint: '单位:天,0 表示不清理整理历史表',
|
||||
downloadFilesCleanupNotice: '下载文件表没有独立时间字段,会跟随下载历史表的保留周期清理其孤儿记录。',
|
||||
pluginAutoReload: '插件热加载',
|
||||
pluginAutoReloadHint: '修改插件文件后自动重新加载,开发插件时使用',
|
||||
pluginLocalRepoPaths: '本地插件仓库路径',
|
||||
@@ -1545,6 +1632,7 @@ export default {
|
||||
},
|
||||
mb: 'MB',
|
||||
hour: '小时',
|
||||
day: '天',
|
||||
customizeWallpaperApi: '自定义壁纸API地址',
|
||||
customizeWallpaperApiHint: '会获取API返回内容中所有允许的安全域名地址的图片,需要同步设置安全域名地址',
|
||||
customizeWallpaperApiRequired: '必填项;请输入自定义壁纸API',
|
||||
@@ -1671,7 +1759,9 @@ export default {
|
||||
timeSaveSuccess: '通知发送时间保存成功',
|
||||
timeSaveFailed: '通知发送时间保存失败!',
|
||||
channel: '通知',
|
||||
wechat: '微信',
|
||||
wechat: '企业微信',
|
||||
wechatClawBot: '微信 ClawBot',
|
||||
feishu: '飞书',
|
||||
resourceDownload: '资源下载',
|
||||
mediaImport: '整理入库',
|
||||
subscription: '订阅',
|
||||
@@ -1749,7 +1839,7 @@ export default {
|
||||
tvCategory: '电视剧',
|
||||
animeCategory: '动漫',
|
||||
downloadUser: '远程搜索自动下载用户',
|
||||
downloadUserHint: '使用Telegram、微信等搜索时是否自动下载,使用逗号分割,设置为 all 代表所有用户自动择优下载',
|
||||
downloadUserHint: '使用Telegram、企业微信等搜索时是否自动下载,使用逗号分割,设置为 all 代表所有用户自动择优下载',
|
||||
multipleNameSearch: '多名称资源搜索',
|
||||
multipleNameSearchHint: '使用多个名称(中文、英文等)搜索站点资源并合并搜索结果,会增加站点访问频率',
|
||||
downloadSubtitle: '下载站点字幕',
|
||||
@@ -1989,7 +2079,8 @@ export default {
|
||||
resetDefaultAvatar: '重置默认头像',
|
||||
restoreCurrentAvatar: '还原当前头像',
|
||||
notifications: '通知',
|
||||
wechat: '微信ID',
|
||||
wechat: '企业微信ID',
|
||||
wechatClawBot: '微信 ClawBot ID',
|
||||
telegram: 'Telegram ID',
|
||||
slack: 'Slack ID',
|
||||
discord: 'Discord ID',
|
||||
@@ -2381,6 +2472,31 @@ export default {
|
||||
scrapeHint: '整理完成后自动刮削元数据',
|
||||
fromHistoryOption: '复用历史识别信息',
|
||||
fromHistoryHint: '使用历史整理记录中已识别的媒体信息',
|
||||
previewTitle: '整理结果预览',
|
||||
previewSubtitle: '点击“预览”后可查看本次整理的预计入库结果,不会实际改动文件',
|
||||
previewResult: '预览',
|
||||
previewLoading: '正在生成预览结果...',
|
||||
previewRequestFailed: '预览请求失败',
|
||||
previewTotal: '总数 {count}',
|
||||
previewSuccess: '成功 {count}',
|
||||
previewFailed: '失败 {count}',
|
||||
previewSourcePath: '原始路径',
|
||||
previewTargetPath: '目的路径',
|
||||
previewMediaInfo: '媒体信息',
|
||||
previewMediaName: '名称',
|
||||
previewMediaType: '类型',
|
||||
previewSeasonInfo: '季信息',
|
||||
previewSeasonLabel: '季',
|
||||
previewEpisodeCount: '总集数',
|
||||
previewAfterColumn: '整理后',
|
||||
previewBeforeColumn: '整理前',
|
||||
previewFileNameColumn: '文件名',
|
||||
previewEmptyTitle: '尚未生成预览',
|
||||
previewEmptyDescription: '点击“预览”按钮后,在这里查看整理结果预览。',
|
||||
noPreviewData: '暂无预览结果',
|
||||
noFailedPreviewData: '当前没有失败项',
|
||||
copySuccess: '路径已复制',
|
||||
copyFailed: '复制失败',
|
||||
addToQueue: '加入整理队列',
|
||||
reorganizeNow: '立即整理',
|
||||
auto: '自动',
|
||||
@@ -2708,7 +2824,9 @@ export default {
|
||||
nickname: '昵称',
|
||||
nicknamePlaceholder: '显示昵称,优先于用户名显示',
|
||||
accountBinding: '账号绑定',
|
||||
wechatUser: '微信用户',
|
||||
wechatUser: '企业微信用户',
|
||||
wechatClawBotUser: '微信 ClawBot 用户',
|
||||
feishuUser: '飞书用户',
|
||||
telegramUser: 'Telegram用户',
|
||||
slackUser: 'Slack用户',
|
||||
discordUser: 'Discord用户',
|
||||
@@ -2874,8 +2992,10 @@ export default {
|
||||
rtorrentHostHint: 'HTTP: http://ip:port/RPC2 或 SCGI: scgi://ip:port',
|
||||
default: '默认',
|
||||
host: '地址',
|
||||
apiKey: 'API Key',
|
||||
username: '用户名',
|
||||
password: '密码',
|
||||
qbittorrentApiKeyHint: 'qBittorrent 5.2+ 可直接使用 WebUI API Key;填写后将优先使用 API Key 登录。',
|
||||
category: '自动分类管理',
|
||||
sequentail: '顺序下载',
|
||||
force_resume: '强制继续',
|
||||
@@ -3268,12 +3388,13 @@ export default {
|
||||
title: '媒体服务器',
|
||||
description: '配置媒体服务器',
|
||||
info: '媒体服务器配置说明',
|
||||
infoDesc: '配置媒体服务器用于媒体库管理,可选择Emby、Jellyfin、Plex、飞牛影视或绿联影视',
|
||||
infoDesc: '配置媒体服务器用于媒体库管理,可选择Emby、极影视、Jellyfin、Plex、飞牛影视或绿联影视',
|
||||
type: '媒体服务器类型',
|
||||
typeHint: '选择要使用的媒体服务器类型',
|
||||
name: '服务器名称',
|
||||
nameHint: '为媒体服务器设置一个名称',
|
||||
embyConfig: 'Emby 配置',
|
||||
zspaceConfig: '极影视 配置',
|
||||
jellyfinConfig: 'Jellyfin 配置',
|
||||
plexConfig: 'Plex 配置',
|
||||
host: '服务器地址',
|
||||
@@ -3289,6 +3410,7 @@ export default {
|
||||
typeHint: '选择要使用的通知渠道类型',
|
||||
name: '通知名称',
|
||||
nameHint: '为通知渠道设置一个名称',
|
||||
feishuConfig: '飞书配置',
|
||||
telegramConfig: 'Telegram 配置',
|
||||
emailConfig: '邮件配置',
|
||||
botToken: '机器人令牌',
|
||||
|
||||
@@ -74,6 +74,9 @@ export default {
|
||||
descending: '降序',
|
||||
versionMismatch: '瀏覽器快取版本與服務端版本不一致,請嘗試清除快取',
|
||||
clearCache: '清除快取',
|
||||
sortMode: '排序模式',
|
||||
sortModeHint: '已進入拖拽排序模式',
|
||||
exit: '退出',
|
||||
},
|
||||
mediaType: {
|
||||
movie: '電影',
|
||||
@@ -317,7 +320,7 @@ export default {
|
||||
system: {
|
||||
title: '系統',
|
||||
description:
|
||||
'基礎設置、下載器(Qbittorrent、Transmission)、媒體服務器(Emby、Jellyfin、Plex、飛牛影視、綠聯影視)',
|
||||
'基礎設置、下載器(Qbittorrent、Transmission)、媒體服務器(Emby、極影視、Jellyfin、Plex、飛牛影視、綠聯影視)',
|
||||
},
|
||||
directory: {
|
||||
title: '存儲 & 目錄',
|
||||
@@ -349,7 +352,7 @@ export default {
|
||||
},
|
||||
notification: {
|
||||
title: '通知',
|
||||
description: '通知渠道(微信、Telegram、Slack、SynologyChat、VoceChat、WebPush)、消息發送範圍',
|
||||
description: '通知渠道(企業微信、微信 ClawBot、Telegram、Slack、SynologyChat、VoceChat、WebPush)、消息發送範圍',
|
||||
},
|
||||
about: {
|
||||
title: '關於',
|
||||
@@ -466,6 +469,60 @@ export default {
|
||||
adminsHint: '可使用管理菜單及命令的用戶ID列表,多個ID使用,分隔',
|
||||
adminsPlaceholder: '用戶ID列表,多個ID使用,分隔',
|
||||
},
|
||||
wechatclawbot: {
|
||||
name: '微信 ClawBot',
|
||||
baseUrl: 'iLink 地址',
|
||||
baseUrlHint: '微信 ClawBot iLink 服務地址,通常使用預設值',
|
||||
defaultTarget: '預設通知目標',
|
||||
defaultTargetHint: '可填寫使用者 userid;不填則預設發給已互動使用者',
|
||||
defaultTargetPlaceholder: '使用者 userid(可選)',
|
||||
admins: '管理員白名單',
|
||||
adminsHint: '允許執行斜線命令的用戶ID列表,多個ID使用,分隔',
|
||||
adminsPlaceholder: '用戶ID列表,多個ID使用,分隔',
|
||||
pollTimeout: '輪詢超時(秒)',
|
||||
pollTimeoutHint: '長輪詢請求超時時間,建議 20-30 秒',
|
||||
loginStatus: '登入狀態',
|
||||
connected: '已連線',
|
||||
waiting: '等待掃碼',
|
||||
scanned: '已掃碼,待確認',
|
||||
confirmed: '已確認,正在建立連線',
|
||||
expired: '二維碼已過期',
|
||||
refreshQrcode: '刷新二維碼',
|
||||
logout: '退出登入',
|
||||
noQrcode: '暫無二維碼,請先刷新或保存配置後再試',
|
||||
scanHint: '使用微信掃碼綁定後,狀態會自動刷新。首次使用請先保存並啟用該通知渠道。',
|
||||
accountId: '帳號ID',
|
||||
qrcodeUpdatedAt: '二維碼更新時間',
|
||||
knownTargets: '最近互動用戶',
|
||||
noKnownTargets: '暫無互動用戶記錄',
|
||||
statusLoadFailed: '獲取微信 ClawBot 狀態失敗',
|
||||
qrcodeRefreshSuccess: '微信 ClawBot 二維碼已刷新',
|
||||
qrcodeRefreshFailed: '刷新微信 ClawBot 二維碼失敗',
|
||||
logoutSuccess: '微信 ClawBot 已退出登入',
|
||||
logoutFailed: '微信 ClawBot 退出登入失敗',
|
||||
},
|
||||
feishu: {
|
||||
name: '飛書',
|
||||
appId: 'App ID',
|
||||
appIdHint: '飛書開放平台應用的 App ID',
|
||||
appIdRequired: 'App ID 不能為空',
|
||||
appSecret: 'App Secret',
|
||||
appSecretHint: '飛書開放平台應用的 App Secret',
|
||||
appSecretRequired: 'App Secret 不能為空',
|
||||
openId: '預設用戶 Open ID',
|
||||
openIdHint: '預設通知接收用戶的 Open ID,留空則優先使用互動用戶',
|
||||
openIdPlaceholder: 'ou_xxx',
|
||||
chatId: '預設群聊 Chat ID',
|
||||
chatIdHint: '預設通知接收群聊的 Chat ID,和 Open ID 二選一即可',
|
||||
chatIdPlaceholder: 'oc_xxx',
|
||||
admins: '管理員白名單',
|
||||
adminsHint: '允許執行命令與管理操作的 Open ID 列表,多個使用 , 分隔',
|
||||
adminsPlaceholder: 'Open ID 列表,多個使用 , 分隔',
|
||||
verificationToken: 'Verification Token',
|
||||
verificationTokenHint: '飛書事件訂閱的 Verification Token,啟用事件校驗時填寫',
|
||||
encryptKey: 'Encrypt Key',
|
||||
encryptKeyHint: '飛書事件訂閱的 Encrypt Key,啟用消息加密時填寫',
|
||||
},
|
||||
telegram: {
|
||||
name: 'Telegram',
|
||||
token: 'Bot Token',
|
||||
@@ -881,6 +938,7 @@ export default {
|
||||
plex: 'Plex',
|
||||
jellyfin: 'Jellyfin',
|
||||
emby: 'Emby',
|
||||
zspace: '極影視',
|
||||
appLaunchFailed: 'APP啟動失敗,正在跳轉到網頁版',
|
||||
appNotInstalled: '未檢測到APP,正在跳轉到網頁版',
|
||||
downloadApp: '下載APP',
|
||||
@@ -994,6 +1052,7 @@ export default {
|
||||
aiRecommend: '智能推薦',
|
||||
reRecommend: '重新生成推薦',
|
||||
aiRecommendError: '智能推薦失敗',
|
||||
refreshSearch: '重新搜尋',
|
||||
},
|
||||
browse: {
|
||||
actor: '演員',
|
||||
@@ -1230,6 +1289,17 @@ export default {
|
||||
content: '內容',
|
||||
refreshing: '正在刷新',
|
||||
initializing: '正在初始化',
|
||||
searchPlaceholder: '搜索日誌內容',
|
||||
allLevels: '全部級別',
|
||||
followTail: '跟隨最新日誌',
|
||||
wrapLines: '自動換行',
|
||||
pauseStream: '暫停日誌流',
|
||||
resumeStream: '恢復日誌流',
|
||||
waitingForLogs: '等待日誌輸出...',
|
||||
paused: '已暫停',
|
||||
connected: '實時更新中',
|
||||
lineCount: '顯示 {visible}/{total} 行',
|
||||
jumpToLatest: '查看最新 ({count})',
|
||||
},
|
||||
moduleTest: {
|
||||
normal: '正常',
|
||||
@@ -1415,6 +1485,7 @@ export default {
|
||||
media: '媒體',
|
||||
network: '網絡',
|
||||
log: '日誌',
|
||||
data: '數據',
|
||||
lab: '實驗室',
|
||||
downloaderSaveSuccess: '下載器設置保存成功',
|
||||
downloaderSaveFailed: '下載器設置保存失敗!',
|
||||
@@ -1432,6 +1503,7 @@ export default {
|
||||
transmission: 'Transmission',
|
||||
rtorrent: 'rTorrent',
|
||||
emby: 'Emby',
|
||||
zspace: '極影視',
|
||||
jellyfin: 'Jellyfin',
|
||||
plex: 'Plex',
|
||||
ugreen: '綠聯影視',
|
||||
@@ -1476,6 +1548,8 @@ export default {
|
||||
fanartLangHint: '設定Fanart圖片的語言偏好,多選時按優先級順序排列',
|
||||
recognizePluginFirst: '優先使用插件識別',
|
||||
recognizePluginFirstHint: '優先調用插件識別媒體信息,若插件命中則不再調用原生識別',
|
||||
mediaRecognizeShare: '共享使用媒體識別數據',
|
||||
mediaRecognizeShareHint: '識別成功後上報關鍵字與媒體ID,識別失敗時優先回查共享識別結果',
|
||||
githubProxy: 'Github加速代理',
|
||||
githubProxyPlaceholder: '留空表示不使用代理',
|
||||
githubProxyHint: '使用代理加速Github訪問速度',
|
||||
@@ -1504,6 +1578,19 @@ export default {
|
||||
logBackupCountMin: '日誌文件最大備份數量必須大於等於1',
|
||||
logFileFormat: '日誌文件格式',
|
||||
logFileFormatHint: '設置日誌文件的輸出格式,用於自定義日誌的顯示內容',
|
||||
dataCleanupEnable: '啟用數據清理',
|
||||
dataCleanupEnableHint: '總開關關閉時將跳過定時數據清理任務',
|
||||
dataCleanupDaysRequired: '請輸入清理週期',
|
||||
dataCleanupDaysMin: '清理週期必須大於等於0',
|
||||
dataCleanupMessageDays: '消息表保留天數',
|
||||
dataCleanupMessageDaysHint: '單位:天,0 表示不清理消息表數據',
|
||||
dataCleanupDownloadHistoryDays: '下載歷史表保留天數',
|
||||
dataCleanupDownloadHistoryDaysHint: '單位:天,0 表示不清理下載歷史及其關聯的下載文件孤兒記錄',
|
||||
dataCleanupSiteUserDataDays: '站點數據表保留天數',
|
||||
dataCleanupSiteUserDataDaysHint: '單位:天,0 表示不清理站點用戶數據表',
|
||||
dataCleanupTransferHistoryDays: '整理歷史表保留天數',
|
||||
dataCleanupTransferHistoryDaysHint: '單位:天,0 表示不清理整理歷史表',
|
||||
downloadFilesCleanupNotice: '下載文件表沒有獨立時間欄位,會跟隨下載歷史表的保留週期清理其孤兒記錄。',
|
||||
pluginAutoReload: '插件熱加載',
|
||||
pluginAutoReloadHint: '修改插件文件後自動重新加載,開發插件時使用',
|
||||
pluginLocalRepoPaths: '本地插件倉庫路徑',
|
||||
@@ -1547,6 +1634,7 @@ export default {
|
||||
},
|
||||
mb: 'MB',
|
||||
hour: '小時',
|
||||
day: '天',
|
||||
customizeWallpaperApi: '自定義壁紙API',
|
||||
customizeWallpaperApiHint: '會獲取 API 返回內容中所有安全設置中允許的圖片地址,需要設置安全域名白名單',
|
||||
customizeWallpaperApiRequired: '必填項;請輸出自定義壁紙API',
|
||||
@@ -1673,7 +1761,9 @@ export default {
|
||||
timeSaveSuccess: '通知發送時間保存成功',
|
||||
timeSaveFailed: '通知發送時間保存失敗!',
|
||||
channel: '通知',
|
||||
wechat: '微信',
|
||||
wechat: '企業微信',
|
||||
wechatClawBot: '微信 ClawBot',
|
||||
feishu: '飛書',
|
||||
resourceDownload: '資源下載',
|
||||
mediaImport: '整理入庫',
|
||||
subscription: '訂閱',
|
||||
@@ -1759,7 +1849,7 @@ export default {
|
||||
mediaSourceHint: '搜索媒體信息時使用的數據源以及排序',
|
||||
filterRuleGroupHint: '搜索媒體信息時按選定的過濾規則組對結果進行過濾',
|
||||
downloadUserPlaceholder: '用戶ID1,用戶ID2',
|
||||
downloadUserHint: '使用Telegram、微信等搜索時是否自動下載,使用逗號分割,設置為 all 代表所有用戶自動擇優下載',
|
||||
downloadUserHint: '使用Telegram、企業微信等搜索時是否自動下載,使用逗號分割,設置為 all 代表所有用戶自動擇優下載',
|
||||
downloadLabelPlaceholder: 'MOVIEPILOT',
|
||||
},
|
||||
directory: {
|
||||
@@ -1991,7 +2081,8 @@ export default {
|
||||
resetDefaultAvatar: '重置默認頭像',
|
||||
restoreCurrentAvatar: '還原當前頭像',
|
||||
notifications: '通知',
|
||||
wechat: '微信UserID',
|
||||
wechat: '企業微信 UserID',
|
||||
wechatClawBot: '微信 ClawBot ID',
|
||||
telegram: 'Telegram UserID',
|
||||
slack: 'Slack UserID',
|
||||
discord: 'Discord UserID',
|
||||
@@ -2383,6 +2474,31 @@ export default {
|
||||
scrapeHint: '整理完成後自動刮削元數據',
|
||||
fromHistoryOption: '復用歷史識別資訊',
|
||||
fromHistoryHint: '使用歷史整理記錄中已識別的媒體資訊',
|
||||
previewTitle: '整理結果預覽',
|
||||
previewSubtitle: '點擊「預覽」後可查看本次整理的預計入庫結果,不會實際改動文件',
|
||||
previewResult: '預覽',
|
||||
previewLoading: '正在生成預覽結果...',
|
||||
previewRequestFailed: '預覽請求失敗',
|
||||
previewTotal: '總數 {count}',
|
||||
previewSuccess: '成功 {count}',
|
||||
previewFailed: '失敗 {count}',
|
||||
previewSourcePath: '原始路徑',
|
||||
previewTargetPath: '目的路徑',
|
||||
previewMediaInfo: '媒體資訊',
|
||||
previewMediaName: '名稱',
|
||||
previewMediaType: '類型',
|
||||
previewSeasonInfo: '季資訊',
|
||||
previewSeasonLabel: '季',
|
||||
previewEpisodeCount: '總集數',
|
||||
previewAfterColumn: '整理後',
|
||||
previewBeforeColumn: '整理前',
|
||||
previewFileNameColumn: '文件名',
|
||||
previewEmptyTitle: '尚未生成預覽',
|
||||
previewEmptyDescription: '點擊「預覽」按鈕後,在這裡查看整理結果預覽。',
|
||||
noPreviewData: '暫無預覽結果',
|
||||
noFailedPreviewData: '目前沒有失敗項',
|
||||
copySuccess: '路徑已複製',
|
||||
copyFailed: '複製失敗',
|
||||
addToQueue: '加入整理隊列',
|
||||
reorganizeNow: '立即整理',
|
||||
auto: '自動',
|
||||
@@ -2710,7 +2826,9 @@ export default {
|
||||
nickname: '暱稱',
|
||||
nicknamePlaceholder: '顯示暱稱,優先於用戶名顯示',
|
||||
accountBinding: '賬號綁定',
|
||||
wechatUser: '微信用戶',
|
||||
wechatUser: '企業微信用戶',
|
||||
wechatClawBotUser: '微信 ClawBot 用戶',
|
||||
feishuUser: '飛書用戶',
|
||||
telegramUser: 'Telegram用戶',
|
||||
slackUser: 'Slack用戶',
|
||||
discordUser: 'Discord用戶',
|
||||
@@ -2876,8 +2994,10 @@ export default {
|
||||
enabled: '啟用',
|
||||
default: '預設',
|
||||
host: '地址',
|
||||
apiKey: 'API Key',
|
||||
username: '用戶名',
|
||||
password: '密碼',
|
||||
qbittorrentApiKeyHint: 'qBittorrent 5.2+ 可直接使用 WebUI API Key;填寫後將優先使用 API Key 登入。',
|
||||
category: '自動分類管理',
|
||||
sequentail: '順序下載',
|
||||
force_resume: '強制繼續',
|
||||
@@ -3270,12 +3390,13 @@ export default {
|
||||
title: '媒體伺服器',
|
||||
description: '設定媒體伺服器',
|
||||
info: '媒體伺服器設定說明',
|
||||
infoDesc: '設定媒體伺服器用於媒體庫管理,可選擇Emby、Jellyfin、Plex、飛牛影視或綠聯影視',
|
||||
infoDesc: '設定媒體伺服器用於媒體庫管理,可選擇Emby、極影視、Jellyfin、Plex、飛牛影視或綠聯影視',
|
||||
type: '媒體伺服器類型',
|
||||
typeHint: '選擇要使用的媒體伺服器類型',
|
||||
name: '伺服器名稱',
|
||||
nameHint: '為媒體伺服器設定一個名稱',
|
||||
embyConfig: 'Emby 設定',
|
||||
zspaceConfig: '極影視 設定',
|
||||
jellyfinConfig: 'Jellyfin 設定',
|
||||
plexConfig: 'Plex 設定',
|
||||
host: '伺服器位址',
|
||||
@@ -3291,6 +3412,7 @@ export default {
|
||||
typeHint: '選擇要使用的通知管道類型',
|
||||
name: '通知名稱',
|
||||
nameHint: '為通知管道設定一個名稱',
|
||||
feishuConfig: '飛書設定',
|
||||
telegramConfig: 'Telegram 設定',
|
||||
emailConfig: '郵件設定',
|
||||
botToken: '機器人權杖',
|
||||
|
||||
68
src/main.ts
68
src/main.ts
@@ -1,11 +1,9 @@
|
||||
// 1. 配置与兼容性
|
||||
import './ace-config'
|
||||
import '@/@core/utils/compatibility'
|
||||
import '@/@iconify/icons-bundle'
|
||||
import '@/plugins/webfontloader'
|
||||
|
||||
// 2. 核心插件和 UI 框架
|
||||
import { createApp } from 'vue'
|
||||
import { createApp, defineAsyncComponent } from 'vue'
|
||||
import vuetify from '@/plugins/vuetify'
|
||||
import router from '@/router'
|
||||
import pinia from '@/stores/index'
|
||||
@@ -13,9 +11,7 @@ import i18n from '@/plugins/i18n'
|
||||
|
||||
// 3. 全局组件
|
||||
import App from '@/App.vue'
|
||||
import { VAceEditor } from 'vue3-ace-editor'
|
||||
import { PerfectScrollbarPlugin } from 'vue3-perfect-scrollbar'
|
||||
import { CronVuetify } from '@vue-js-cron/vuetify'
|
||||
|
||||
// 4. 工具函数和其他辅助模块
|
||||
import { loadRemoteComponents } from './utils/federationLoader'
|
||||
@@ -23,22 +19,12 @@ import { loadRemoteComponents } from './utils/federationLoader'
|
||||
// 5. 其他插件和功能模块
|
||||
import Toast from 'vue-toastification'
|
||||
import ConfirmDialog from '@/composables/useConfirm'
|
||||
import VueApexCharts from 'vue3-apexcharts'
|
||||
import { configureApexChartsTheme } from '@/utils/apexCharts'
|
||||
|
||||
// 6. 注册自定义组件
|
||||
import DialogCloseBtn from '@/@core/components/DialogCloseBtn.vue'
|
||||
import ScrollToTopBtn from '@/@core/components/ScrollToTopBtn.vue'
|
||||
import PageContentTitle from './@core/components/PageContentTitle.vue'
|
||||
import MediaCard from './components/cards/MediaCard.vue'
|
||||
import PosterCard from './components/cards/PosterCard.vue'
|
||||
import BackdropCard from './components/cards/BackdropCard.vue'
|
||||
import PersonCard from './components/cards/PersonCard.vue'
|
||||
import MediaInfoCard from './components/cards/MediaInfoCard.vue'
|
||||
import TorrentCard from './components/cards/TorrentCard.vue'
|
||||
import MediaIdSelector from './components/misc/MediaIdSelector.vue'
|
||||
import CronField from './components/field/CronField.vue'
|
||||
import PathField from './components/field/PathField.vue'
|
||||
import HeaderTab from './layouts/components/HeaderTab.vue'
|
||||
|
||||
// 7. 样式文件 - 合并为单一导入
|
||||
import '@/styles/main.scss'
|
||||
@@ -50,6 +36,34 @@ import stateRestorePlugin from '@/plugins/stateRestore'
|
||||
import { backgroundManager } from '@/utils/backgroundManager'
|
||||
import { sseManagerSingleton } from '@/utils/sseManager'
|
||||
|
||||
const iconBundlePromise = import('@/@iconify/icons-bundle').catch(error => {
|
||||
console.error('Failed to load icon bundle', error)
|
||||
})
|
||||
|
||||
const AsyncAceEditor = defineAsyncComponent(async () => {
|
||||
await import('./ace-config')
|
||||
return (await import('vue3-ace-editor')).VAceEditor
|
||||
})
|
||||
|
||||
const AsyncApexChart = defineAsyncComponent(async () => {
|
||||
const component = (await import('vue3-apexcharts')).default
|
||||
const themeName = document.documentElement.getAttribute('data-theme') || localStorage.getItem('theme') || 'light'
|
||||
configureApexChartsTheme(themeName)
|
||||
return component
|
||||
})
|
||||
|
||||
const AsyncCronVuetify = defineAsyncComponent(async () => {
|
||||
return (await import('@vue-js-cron/vuetify')).CronVuetify
|
||||
})
|
||||
|
||||
const AsyncCronField = defineAsyncComponent(async () => {
|
||||
return (await import('./components/field/CronField.vue')).default
|
||||
})
|
||||
|
||||
const AsyncPathField = defineAsyncComponent(async () => {
|
||||
return (await import('./components/field/PathField.vue')).default
|
||||
})
|
||||
|
||||
// 创建Vue实例
|
||||
const app = createApp(App)
|
||||
|
||||
@@ -72,21 +86,13 @@ app.use(stateRestorePlugin)
|
||||
|
||||
// 5. 注册全局组件
|
||||
app
|
||||
.component('VAceEditor', VAceEditor)
|
||||
.component('VApexChart', VueApexCharts)
|
||||
.component('VCronVuetify', CronVuetify)
|
||||
.component('VAceEditor', AsyncAceEditor)
|
||||
.component('VApexChart', AsyncApexChart)
|
||||
.component('VCronVuetify', AsyncCronVuetify)
|
||||
.component('VDialogCloseBtn', DialogCloseBtn)
|
||||
.component('VScrollToTopBtn', ScrollToTopBtn)
|
||||
.component('VMediaCard', MediaCard)
|
||||
.component('VPosterCard', PosterCard)
|
||||
.component('VBackdropCard', BackdropCard)
|
||||
.component('VPersonCard', PersonCard)
|
||||
.component('VMediaInfoCard', MediaInfoCard)
|
||||
.component('VTorrentCard', TorrentCard)
|
||||
.component('VMediaIdSelector', MediaIdSelector)
|
||||
.component('VCronField', CronField)
|
||||
.component('VPathField', PathField)
|
||||
.component('VHeaderTab', HeaderTab)
|
||||
.component('VCronField', AsyncCronField)
|
||||
.component('VPathField', AsyncPathField)
|
||||
.component('VPageContentTitle', PageContentTitle)
|
||||
|
||||
// 6. 注册其他插件
|
||||
@@ -98,7 +104,9 @@ app
|
||||
})
|
||||
.use(ConfirmDialog)
|
||||
.use(i18n)
|
||||
.mount('#app')
|
||||
|
||||
await iconBundlePromise
|
||||
app.mount('#app')
|
||||
|
||||
// 页面卸载时清理后台管理器
|
||||
window.addEventListener('beforeunload', () => {
|
||||
|
||||
@@ -1,15 +1,16 @@
|
||||
<script setup lang="ts">
|
||||
import { debounce } from 'lodash-es'
|
||||
import type { LocationQuery } from 'vue-router'
|
||||
import NoDataFound from '@/components/NoDataFound.vue'
|
||||
import api from '@/api'
|
||||
import type { Context } from '@/api/types'
|
||||
import TorrentCard from '@/components/cards/TorrentCard.vue'
|
||||
import TorrentItem from '@/components/cards/TorrentItem.vue'
|
||||
import ProgressiveCardGrid from '@/components/misc/ProgressiveCardGrid.vue'
|
||||
import TorrentFilterBar from '@/components/filter/TorrentFilterBar.vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useGlobalSettingsStore } from '@/stores/global'
|
||||
import { useTorrentFilter, type FilterState } from '@/composables/useTorrentFilter'
|
||||
import { useInfiniteScroll } from '@/composables/useInfiniteScroll'
|
||||
import { useToast } from 'vue-toastification'
|
||||
|
||||
// 国际化
|
||||
@@ -26,27 +27,60 @@ const torrentFilter = useTorrentFilter()
|
||||
|
||||
// 路由参数
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
interface SearchParams {
|
||||
keyword: string
|
||||
type: string
|
||||
area: string
|
||||
title: string
|
||||
year: string
|
||||
season: string
|
||||
sites: string
|
||||
}
|
||||
|
||||
function createSearchParams(query: LocationQuery): SearchParams {
|
||||
return {
|
||||
keyword: query?.keyword?.toString() ?? '',
|
||||
type: query?.type?.toString() ?? '',
|
||||
area: query?.area?.toString() ?? '',
|
||||
title: query?.title?.toString() ?? '',
|
||||
year: query?.year?.toString() ?? '',
|
||||
season: query?.season?.toString() ?? '',
|
||||
sites: query?.sites?.toString() ?? '',
|
||||
}
|
||||
}
|
||||
|
||||
function getSearchParamsKey(params: SearchParams): string {
|
||||
return JSON.stringify(params)
|
||||
}
|
||||
|
||||
function createSearchRequestToken(): string {
|
||||
return `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
|
||||
}
|
||||
|
||||
const activeSearchParams = ref<SearchParams>(createSearchParams(route.query))
|
||||
|
||||
// 查询TMDBID或标题
|
||||
const keyword = route.query?.keyword?.toString() ?? ''
|
||||
const keyword = computed(() => activeSearchParams.value.keyword)
|
||||
|
||||
// 查询类型
|
||||
const type = route.query?.type?.toString() ?? ''
|
||||
const type = computed(() => activeSearchParams.value.type)
|
||||
|
||||
// 搜索字段
|
||||
const area = route.query?.area?.toString() ?? ''
|
||||
const area = computed(() => activeSearchParams.value.area)
|
||||
|
||||
// 搜索标题
|
||||
const title = route.query?.title?.toString() ?? ''
|
||||
const title = computed(() => activeSearchParams.value.title)
|
||||
|
||||
// 搜索年份
|
||||
const year = route.query?.year
|
||||
const year = computed(() => activeSearchParams.value.year)
|
||||
|
||||
// 搜索季
|
||||
const season = route.query?.season?.toString() ?? ''
|
||||
const season = computed(() => activeSearchParams.value.season)
|
||||
|
||||
// 搜索站点,以,分离多个
|
||||
const sites = route.query?.sites?.toString() ?? ''
|
||||
const sites = computed(() => activeSearchParams.value.sites)
|
||||
|
||||
// 视图类型,从localStorage中读取
|
||||
const viewType = ref<string>(localStorage.getItem('MPTorrentsViewType') ?? 'card')
|
||||
@@ -68,7 +102,7 @@ let aiStatusCheckInterval: ReturnType<typeof setInterval> | null = null // AI状
|
||||
|
||||
// 是否有搜索标签
|
||||
const hasSearchTags = computed(() => {
|
||||
return !!(keyword || title || year || season)
|
||||
return !!(keyword.value || title.value || year.value || season.value)
|
||||
})
|
||||
|
||||
// 是否启用筛选栏动画
|
||||
@@ -86,15 +120,12 @@ interface SearchTorrent extends Context {
|
||||
}
|
||||
const filteredCardDataList = ref<Array<SearchTorrent>>([])
|
||||
|
||||
// 使用无限滚动 composable(行视图)
|
||||
const rowScroll = useInfiniteScroll(filteredRowDataList)
|
||||
|
||||
// 使用无限滚动 composable(卡片视图)
|
||||
const cardScroll = useInfiniteScroll(filteredCardDataList)
|
||||
|
||||
// 是否刷新过
|
||||
const isRefreshed = ref(false)
|
||||
|
||||
// 是否正在重新搜索
|
||||
const isRefreshing = ref(false)
|
||||
|
||||
// 加载进度文本
|
||||
const progressText = ref(t('common.pleaseWait'))
|
||||
|
||||
@@ -137,13 +168,23 @@ const errorDescription = ref(t('resource.noResourceFound'))
|
||||
let searchEventSource: EventSource | null = null
|
||||
|
||||
const streamPreviewLimit = 24
|
||||
const streamUiFlushDelay = 1000
|
||||
const streamPreviewBufferLimit = streamPreviewLimit * 4
|
||||
|
||||
const streamTotalCount = ref(0)
|
||||
const streamPreviewDataList = ref<Array<Context>>([])
|
||||
|
||||
const displayResourceCount = computed(() =>
|
||||
progressActive.value ? streamTotalCount.value : torrentFilter.totalFilteredCount.value,
|
||||
)
|
||||
|
||||
let pendingStreamItems: Array<Context> = []
|
||||
let streamFlushTimer: ReturnType<typeof setTimeout> | null = null
|
||||
let streamFinalResultApplied = false
|
||||
let pendingProgressText: string | null = null
|
||||
let pendingProgressValue: number | null = null
|
||||
let pendingStreamTotalCount: number | null = null
|
||||
|
||||
// 监听筛选条件变化,重新筛选数据
|
||||
watch(
|
||||
[() => torrentFilter.filterForm, () => torrentFilter.sortField.value, () => torrentFilter.sortType.value],
|
||||
@@ -228,6 +269,58 @@ function closeSearchEventSource() {
|
||||
}
|
||||
}
|
||||
|
||||
// 渐进式搜索期间只保留有限预览数据,避免每个批次都触发完整筛选和分组计算。
|
||||
function clearStreamFlushTimer() {
|
||||
if (streamFlushTimer) {
|
||||
clearTimeout(streamFlushTimer)
|
||||
streamFlushTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
function clearStreamPreviewState(resetFinalState: boolean = false) {
|
||||
clearStreamFlushTimer()
|
||||
pendingStreamItems = []
|
||||
pendingProgressText = null
|
||||
pendingProgressValue = null
|
||||
pendingStreamTotalCount = null
|
||||
streamPreviewDataList.value = []
|
||||
if (resetFinalState) {
|
||||
streamFinalResultApplied = false
|
||||
}
|
||||
}
|
||||
|
||||
// 将进度和预览列表放到同一个节奏刷新,避免 SSE 到来时多处 UI 各自抖动。
|
||||
function flushBufferedStreamState() {
|
||||
clearStreamFlushTimer()
|
||||
|
||||
if (pendingProgressText !== null) {
|
||||
progressText.value = pendingProgressText
|
||||
}
|
||||
if (pendingProgressValue !== null) {
|
||||
progressValue.value = pendingProgressValue
|
||||
}
|
||||
if (pendingStreamTotalCount !== null) {
|
||||
streamTotalCount.value = pendingStreamTotalCount
|
||||
}
|
||||
|
||||
pendingProgressText = null
|
||||
pendingProgressValue = null
|
||||
pendingStreamTotalCount = null
|
||||
|
||||
if (!pendingStreamItems.length) return
|
||||
|
||||
streamPreviewDataList.value = [...pendingStreamItems, ...streamPreviewDataList.value].slice(0, streamPreviewLimit)
|
||||
pendingStreamItems = []
|
||||
isRefreshed.value = true
|
||||
}
|
||||
|
||||
function scheduleStreamFlush() {
|
||||
if (streamFlushTimer) return
|
||||
streamFlushTimer = setTimeout(() => {
|
||||
flushBufferedStreamState()
|
||||
}, streamUiFlushDelay)
|
||||
}
|
||||
|
||||
// 获取API URL
|
||||
function getApiUrl(path: string) {
|
||||
const apiBaseUrl = import.meta.env.VITE_API_BASE_URL
|
||||
@@ -246,20 +339,24 @@ function setSearchParam(params: URLSearchParams, key: string, value: unknown) {
|
||||
}
|
||||
|
||||
// 构建搜索流URL
|
||||
function buildSearchStreamUrl() {
|
||||
const isMediaSearch = /^[a-zA-Z]+:/.test(keyword)
|
||||
const url = getApiUrl(isMediaSearch ? `search/media/${encodeURIComponent(keyword)}/stream` : 'search/title/stream')
|
||||
function buildSearchStreamUrl(params: SearchParams, requestToken?: string) {
|
||||
const isMediaSearch = /^[a-zA-Z]+:/.test(params.keyword)
|
||||
const url = getApiUrl(isMediaSearch ? `search/media/${encodeURIComponent(params.keyword)}/stream` : 'search/title/stream')
|
||||
|
||||
if (isMediaSearch) {
|
||||
setSearchParam(url.searchParams, 'mtype', type)
|
||||
setSearchParam(url.searchParams, 'area', area)
|
||||
setSearchParam(url.searchParams, 'title', title)
|
||||
setSearchParam(url.searchParams, 'year', year)
|
||||
setSearchParam(url.searchParams, 'season', season)
|
||||
setSearchParam(url.searchParams, 'sites', sites)
|
||||
setSearchParam(url.searchParams, 'mtype', params.type)
|
||||
setSearchParam(url.searchParams, 'area', params.area)
|
||||
setSearchParam(url.searchParams, 'title', params.title)
|
||||
setSearchParam(url.searchParams, 'year', params.year)
|
||||
setSearchParam(url.searchParams, 'season', params.season)
|
||||
setSearchParam(url.searchParams, 'sites', params.sites)
|
||||
} else {
|
||||
setSearchParam(url.searchParams, 'keyword', keyword)
|
||||
setSearchParam(url.searchParams, 'sites', sites)
|
||||
setSearchParam(url.searchParams, 'keyword', params.keyword)
|
||||
setSearchParam(url.searchParams, 'sites', params.sites)
|
||||
}
|
||||
|
||||
if (requestToken) {
|
||||
setSearchParam(url.searchParams, '_ts', requestToken)
|
||||
}
|
||||
|
||||
return url.toString()
|
||||
@@ -267,6 +364,7 @@ function buildSearchStreamUrl() {
|
||||
|
||||
// 重置搜索结果
|
||||
function resetSearchResults() {
|
||||
clearStreamPreviewState(true)
|
||||
rawDataList.value = []
|
||||
originalDataList.value = []
|
||||
streamTotalCount.value = 0
|
||||
@@ -280,21 +378,28 @@ function resetSearchResults() {
|
||||
}
|
||||
|
||||
// 更新搜索进度
|
||||
function updateSearchProgress(eventData: { [key: string]: any }) {
|
||||
function updateSearchProgress(eventData: { [key: string]: any }, flushNow: boolean = false) {
|
||||
if (eventData.text) {
|
||||
progressText.value = eventData.text
|
||||
pendingProgressText = eventData.text
|
||||
}
|
||||
if (typeof eventData.value === 'number') {
|
||||
progressValue.value = eventData.value
|
||||
pendingProgressValue = eventData.value
|
||||
}
|
||||
if (typeof eventData.total_items === 'number') {
|
||||
streamTotalCount.value = eventData.total_items
|
||||
pendingStreamTotalCount = eventData.total_items
|
||||
}
|
||||
progressEnabled.value = true
|
||||
|
||||
if (flushNow) {
|
||||
flushBufferedStreamState()
|
||||
} else {
|
||||
scheduleStreamFlush()
|
||||
}
|
||||
}
|
||||
|
||||
// 设置流式搜索结果
|
||||
function setStreamResults(items: Context[]) {
|
||||
clearStreamPreviewState()
|
||||
rawDataList.value = items
|
||||
originalDataList.value = items
|
||||
if (!progressActive.value) {
|
||||
@@ -304,12 +409,21 @@ function setStreamResults(items: Context[]) {
|
||||
applyFilter()
|
||||
}
|
||||
|
||||
// 追加流式搜索结果
|
||||
// 追加流式搜索预览结果
|
||||
function appendStreamResults(items: Context[]) {
|
||||
if (!items.length) return
|
||||
|
||||
const nextItems = [...items, ...rawDataList.value]
|
||||
setStreamResults(progressActive.value ? nextItems.slice(0, streamPreviewLimit) : nextItems)
|
||||
pendingStreamItems.unshift(...items)
|
||||
if (pendingStreamItems.length > streamPreviewBufferLimit) {
|
||||
pendingStreamItems = pendingStreamItems.slice(0, streamPreviewBufferLimit)
|
||||
}
|
||||
scheduleStreamFlush()
|
||||
}
|
||||
|
||||
function applyFinalStreamResults(items: Context[]) {
|
||||
streamFinalResultApplied = true
|
||||
flushBufferedStreamState()
|
||||
setStreamResults(items)
|
||||
}
|
||||
|
||||
// 获取磁力链接的key
|
||||
@@ -324,42 +438,50 @@ function getTorrentItemKey(item: Context, index: number) {
|
||||
|
||||
// 处理搜索流消息
|
||||
function handleSearchStreamMessage(eventData: { [key: string]: any }) {
|
||||
updateSearchProgress(eventData)
|
||||
|
||||
if (eventData.type === 'error') {
|
||||
updateSearchProgress(eventData, true)
|
||||
errorDescription.value = eventData.message || t('resource.noResourceFound')
|
||||
return
|
||||
}
|
||||
|
||||
const items = Array.isArray(eventData.items) ? (eventData.items as Context[]) : []
|
||||
if (eventData.type === 'append') {
|
||||
updateSearchProgress(eventData)
|
||||
appendStreamResults(items)
|
||||
} else if (eventData.type === 'replace' || eventData.type === 'done') {
|
||||
setStreamResults(items)
|
||||
} else if (eventData.type === 'replace') {
|
||||
updateSearchProgress(eventData, true)
|
||||
applyFinalStreamResults(items)
|
||||
} else if (eventData.type === 'done' && items.length > 0 && !streamFinalResultApplied) {
|
||||
updateSearchProgress(eventData, true)
|
||||
applyFinalStreamResults(items)
|
||||
} else {
|
||||
updateSearchProgress(eventData)
|
||||
}
|
||||
}
|
||||
|
||||
// 按请求搜索
|
||||
async function searchByRequest() {
|
||||
async function searchByRequest(params: SearchParams, requestToken?: string) {
|
||||
let result: { [key: string]: any }
|
||||
// 如果keyword的格式是 xxxx:xxxxx 且:前面的xxxx为字符,则按照媒体ID格式搜索
|
||||
if (/^[a-zA-Z]+:/.test(keyword)) {
|
||||
result = await api.get(`search/media/${keyword}`, {
|
||||
if (/^[a-zA-Z]+:/.test(params.keyword)) {
|
||||
result = await api.get(`search/media/${params.keyword}`, {
|
||||
params: {
|
||||
mtype: type,
|
||||
area,
|
||||
title,
|
||||
year,
|
||||
season,
|
||||
sites,
|
||||
mtype: params.type,
|
||||
area: params.area,
|
||||
title: params.title,
|
||||
year: params.year,
|
||||
season: params.season,
|
||||
sites: params.sites,
|
||||
_ts: requestToken,
|
||||
},
|
||||
})
|
||||
} else {
|
||||
// 按标题模糊查询
|
||||
result = await api.get(`search/title`, {
|
||||
params: {
|
||||
keyword,
|
||||
sites,
|
||||
keyword: params.keyword,
|
||||
sites: params.sites,
|
||||
_ts: requestToken,
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -375,12 +497,12 @@ async function searchByRequest() {
|
||||
}
|
||||
|
||||
// 按流搜索
|
||||
function searchByStream() {
|
||||
function searchByStream(params: SearchParams, requestToken?: string) {
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
closeSearchEventSource()
|
||||
|
||||
let settled = false
|
||||
const source = new EventSource(buildSearchStreamUrl())
|
||||
const source = new EventSource(buildSearchStreamUrl(params, requestToken))
|
||||
searchEventSource = source
|
||||
|
||||
source.onmessage = event => {
|
||||
@@ -430,29 +552,33 @@ function changeViewType(newType: string) {
|
||||
}
|
||||
|
||||
// 获取搜索列表数据
|
||||
async function fetchData() {
|
||||
async function fetchData(options: { force?: boolean } = {}) {
|
||||
const currentSearchParams = { ...activeSearchParams.value }
|
||||
const requestToken = options.force || Boolean(currentSearchParams.keyword) ? createSearchRequestToken() : undefined
|
||||
|
||||
try {
|
||||
enableFilterAnimation.value = true
|
||||
if (!keyword) {
|
||||
if (!currentSearchParams.keyword) {
|
||||
// 查询上次搜索结果
|
||||
const results = await api.get('search/last')
|
||||
rawDataList.value = (results as unknown as Context[]) || []
|
||||
originalDataList.value = (results as unknown as Context[]) || []
|
||||
const results = await api.get('search/last', {
|
||||
params: requestToken ? { _ts: requestToken } : undefined,
|
||||
})
|
||||
setStreamResults((results as unknown as Context[]) || [])
|
||||
} else {
|
||||
resetSearchResults()
|
||||
startLoadingProgress()
|
||||
try {
|
||||
await searchByStream()
|
||||
await searchByStream(currentSearchParams, requestToken)
|
||||
} catch (error) {
|
||||
console.warn('渐进式搜索连接失败,回退到普通搜索:', error)
|
||||
await searchByRequest()
|
||||
await searchByRequest(currentSearchParams, requestToken)
|
||||
}
|
||||
stopLoadingProgress()
|
||||
// 从浏览器历史中删除当前搜索
|
||||
window.history.replaceState(null, '', window.location.pathname)
|
||||
// 搜索完成后移除地址栏参数,避免分享/刷新残留搜索条件
|
||||
if (Object.keys(route.query).length > 0) {
|
||||
await router.replace({ path: route.path, query: {} })
|
||||
}
|
||||
}
|
||||
// 应用筛选
|
||||
applyFilter()
|
||||
// 标记已刷新
|
||||
isRefreshed.value = true
|
||||
} catch (error) {
|
||||
@@ -464,6 +590,21 @@ async function fetchData() {
|
||||
}
|
||||
}
|
||||
|
||||
// 重新搜索(使用相同参数重新触发搜索)
|
||||
async function refreshSearch() {
|
||||
if (isRefreshing.value || progressActive.value) return
|
||||
isRefreshing.value = true
|
||||
try {
|
||||
// 重新搜索时退出 AI 视图,其余状态由 fetchData 内部重置
|
||||
showingAiResults.value = false
|
||||
await fetchData({ force: true })
|
||||
} catch (error) {
|
||||
console.error('重新搜索失败:', error)
|
||||
} finally {
|
||||
isRefreshing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 切换到智能推荐结果(自动保存筛选条件)
|
||||
async function switchToAiResults() {
|
||||
if (showingAiResults.value) {
|
||||
@@ -713,6 +854,10 @@ async function checkAiRecommendStatus() {
|
||||
|
||||
// 计算当前显示的数据是否有数据
|
||||
const hasData = computed(() => {
|
||||
if (progressActive.value) {
|
||||
return streamPreviewDataList.value.length > 0 || rawDataList.value.length > 0
|
||||
}
|
||||
|
||||
if (viewType.value === 'row') {
|
||||
return filteredRowDataList.value.length > 0 || rawDataList.value.length > 0
|
||||
} else {
|
||||
@@ -730,13 +875,27 @@ watchEffect(() => {
|
||||
!progressActive.value &&
|
||||
!aiStatusChecked.value
|
||||
) {
|
||||
checkAiRecommendStatus()
|
||||
void checkAiRecommendStatus()
|
||||
}
|
||||
})
|
||||
|
||||
watch(
|
||||
() => route.query,
|
||||
query => {
|
||||
if (Object.keys(query).length === 0) return
|
||||
|
||||
const nextSearchParams = createSearchParams(query)
|
||||
if (getSearchParamsKey(nextSearchParams) === getSearchParamsKey(activeSearchParams.value)) return
|
||||
|
||||
activeSearchParams.value = nextSearchParams
|
||||
void fetchData()
|
||||
},
|
||||
{ deep: true },
|
||||
)
|
||||
|
||||
// 加载数据
|
||||
onMounted(async () => {
|
||||
fetchData()
|
||||
void fetchData()
|
||||
})
|
||||
|
||||
// 卸载时停止轮询
|
||||
@@ -744,6 +903,7 @@ onUnmounted(() => {
|
||||
closeSearchEventSource()
|
||||
stopLoadingProgress()
|
||||
stopAiRecommendPolling()
|
||||
clearStreamPreviewState()
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -751,7 +911,11 @@ onUnmounted(() => {
|
||||
<div>
|
||||
<!-- 搜索加载状态 -->
|
||||
<VFadeTransition>
|
||||
<div v-if="isSearchProgressVisible" class="search-loading-state mb-3" :class="{ 'is-empty-loading': isSearchLoading }">
|
||||
<div
|
||||
v-if="isSearchProgressVisible"
|
||||
class="search-loading-state mb-3"
|
||||
:class="{ 'is-empty-loading': isSearchLoading }"
|
||||
>
|
||||
<VCard elevation="0" class="search-progress-card">
|
||||
<div class="progress-header">
|
||||
<div class="progress-icon-wrap">
|
||||
@@ -808,8 +972,8 @@ onUnmounted(() => {
|
||||
</div>
|
||||
</VFadeTransition>
|
||||
|
||||
<!-- 精简标题栏 -->
|
||||
<VCard v-if="isRefreshed && !progressActive" class="search-header d-flex align-center mb-3">
|
||||
<!-- 精简标题栏:搜索过后保持挂载,加载中由按钮 :disabled / :loading 表达状态 -->
|
||||
<VCard v-if="isRefreshed" class="search-header d-flex align-center mb-3">
|
||||
<div class="search-info-container">
|
||||
<div class="search-title text-moviepilot">
|
||||
<span class="d-none d-sm-inline">{{ t('resource.searchResults') }}</span>
|
||||
@@ -833,6 +997,22 @@ onUnmounted(() => {
|
||||
|
||||
<VSpacer />
|
||||
|
||||
<!-- 重新搜索按钮 -->
|
||||
<VBtn
|
||||
variant="text"
|
||||
size="small"
|
||||
icon
|
||||
class="me-2 refresh-search-btn"
|
||||
:loading="isRefreshing"
|
||||
:disabled="isRefreshing || progressActive"
|
||||
@click="refreshSearch"
|
||||
>
|
||||
<VIcon icon="mdi-refresh" size="20" />
|
||||
<VTooltip activator="parent" location="top">
|
||||
{{ t('resource.refreshSearch') }}
|
||||
</VTooltip>
|
||||
</VBtn>
|
||||
|
||||
<!-- AI操作按钮组 -->
|
||||
<div v-if="aiRecommendEnabled && originalDataList.length > 0" class="ai-toggle-container me-2">
|
||||
<div class="ai-toggle-buttons">
|
||||
@@ -921,28 +1101,30 @@ onUnmounted(() => {
|
||||
<VFadeTransition mode="out-in">
|
||||
<!-- 卡片视图模式 -->
|
||||
<div v-if="viewType === 'card'" key="card">
|
||||
<!-- 资源列表 -->
|
||||
<VInfiniteScroll
|
||||
mode="intersect"
|
||||
side="end"
|
||||
:items="cardScroll.displayDataList.value"
|
||||
class="overflow-visible"
|
||||
@load="cardScroll.loadMore"
|
||||
<div
|
||||
v-if="progressActive && streamPreviewDataList.length > 0"
|
||||
class="grid gap-4 grid-torrent-card items-start"
|
||||
>
|
||||
<template #loading />
|
||||
<template #empty />
|
||||
<div class="grid gap-4 grid-torrent-card items-start">
|
||||
<TorrentCard
|
||||
v-for="(item, index) in cardScroll.displayDataList.value"
|
||||
:key="getTorrentItemKey(item, index)"
|
||||
:torrent="item"
|
||||
:more="item.more"
|
||||
class="stream-result-item"
|
||||
/>
|
||||
</div>
|
||||
</VInfiniteScroll>
|
||||
<TorrentCard
|
||||
v-for="(item, index) in streamPreviewDataList"
|
||||
:key="getTorrentItemKey(item, index)"
|
||||
:torrent="item"
|
||||
class="stream-result-item"
|
||||
/>
|
||||
</div>
|
||||
<ProgressiveCardGrid
|
||||
v-else-if="filteredCardDataList.length > 0"
|
||||
:items="filteredCardDataList"
|
||||
:get-item-key="getTorrentItemKey"
|
||||
:min-item-width="300"
|
||||
:estimated-item-height="400"
|
||||
>
|
||||
<template #default="{ item }">
|
||||
<TorrentCard :torrent="item" :more="item.more" />
|
||||
</template>
|
||||
</ProgressiveCardGrid>
|
||||
<!-- 无结果时显示 -->
|
||||
<div v-if="cardScroll.displayDataList.value.length === 0" class="no-results">
|
||||
<div v-if="!progressActive && filteredCardDataList.length === 0" class="no-results">
|
||||
<VIcon icon="mdi-file-search-outline" size="64" color="grey-lighten-1" />
|
||||
<div class="text-h6 text-grey mt-4">{{ t('torrent.noResults') }}</div>
|
||||
</div>
|
||||
@@ -952,30 +1134,30 @@ onUnmounted(() => {
|
||||
<div v-else-if="viewType === 'row'" key="row">
|
||||
<VCard class="resource-list-container">
|
||||
<!-- 无结果时显示 -->
|
||||
<div v-if="rowScroll.displayDataList.value.length === 0" class="no-results">
|
||||
<div v-if="!progressActive && filteredRowDataList.length === 0" class="no-results">
|
||||
<VIcon icon="mdi-file-search-outline" size="64" color="grey-lighten-1" />
|
||||
<div class="text-h6 text-grey mt-4">{{ t('torrent.noResults') }}</div>
|
||||
</div>
|
||||
<!-- 资源列表 -->
|
||||
<VInfiniteScroll
|
||||
v-else
|
||||
mode="intersect"
|
||||
side="end"
|
||||
:items="rowScroll.displayDataList.value"
|
||||
class="resource-list overflow-visible"
|
||||
@load="rowScroll.loadMore"
|
||||
>
|
||||
<template #loading />
|
||||
<template #empty />
|
||||
<div v-else-if="progressActive && streamPreviewDataList.length > 0" class="resource-list overflow-visible">
|
||||
<div
|
||||
v-for="(item, index) in rowScroll.displayDataList.value"
|
||||
v-for="(item, index) in streamPreviewDataList"
|
||||
:key="getTorrentItemKey(item, index)"
|
||||
class="stream-result-item"
|
||||
>
|
||||
<TorrentItem :torrent="item" />
|
||||
<VDivider v-if="index < rowScroll.displayDataList.value.length - 1" class="my-2" />
|
||||
<VDivider v-if="index < streamPreviewDataList.length - 1" class="my-2" />
|
||||
</div>
|
||||
</VInfiniteScroll>
|
||||
</div>
|
||||
<div v-else-if="filteredRowDataList.length > 0" class="resource-list">
|
||||
<VVirtualScroll renderless :items="filteredRowDataList" :item-height="240">
|
||||
<template #default="{ item, index, itemRef }">
|
||||
<div :ref="itemRef" :key="getTorrentItemKey(item, index)">
|
||||
<TorrentItem :torrent="item" />
|
||||
<VDivider v-if="index < filteredRowDataList.length - 1" class="my-2" />
|
||||
</div>
|
||||
</template>
|
||||
</VVirtualScroll>
|
||||
</div>
|
||||
</VCard>
|
||||
</div>
|
||||
</VFadeTransition>
|
||||
@@ -1180,6 +1362,14 @@ onUnmounted(() => {
|
||||
background-color: rgba(var(--v-theme-primary), 0.05);
|
||||
}
|
||||
|
||||
/* 重新搜索按钮 */
|
||||
.refresh-search-btn {
|
||||
border-radius: 8px !important;
|
||||
background-color: rgba(var(--v-theme-surface-variant), 0.1);
|
||||
block-size: 44px !important;
|
||||
inline-size: 44px !important;
|
||||
}
|
||||
|
||||
/* AI按钮组样式 */
|
||||
.ai-toggle-container {
|
||||
position: relative;
|
||||
@@ -1267,9 +1457,7 @@ onUnmounted(() => {
|
||||
}
|
||||
|
||||
.resource-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* 无结果提示 */
|
||||
@@ -1371,6 +1559,11 @@ onUnmounted(() => {
|
||||
inline-size: 36px;
|
||||
}
|
||||
|
||||
.refresh-search-btn {
|
||||
block-size: 36px !important;
|
||||
inline-size: 36px !important;
|
||||
}
|
||||
|
||||
.ai-toggle-buttons {
|
||||
block-size: 36px;
|
||||
}
|
||||
|
||||
@@ -46,6 +46,9 @@ const searchShareDialog = ref(false)
|
||||
// 订阅分享统计弹窗
|
||||
const shareStatisticsDialog = ref(false)
|
||||
|
||||
// 排序模式
|
||||
const subscribeSortMode = ref(false)
|
||||
|
||||
// 订阅过滤词
|
||||
const subscribeFilter = ref('')
|
||||
|
||||
@@ -122,6 +125,10 @@ function openShareStatisticsDialog() {
|
||||
shareStatisticsDialog.value = true
|
||||
}
|
||||
|
||||
function toggleSubscribeSortMode() {
|
||||
subscribeSortMode.value = !subscribeSortMode.value
|
||||
}
|
||||
|
||||
const shareKeywordUpdater = debounce((keyword: string) => {
|
||||
shareKeyword.value = keyword.trim()
|
||||
}, 300)
|
||||
@@ -220,6 +227,14 @@ registerHeaderTab({
|
||||
},
|
||||
show: computed(() => activeTab.value === 'mysub'),
|
||||
},
|
||||
{
|
||||
icon: 'mdi-sort-variant',
|
||||
variant: 'text',
|
||||
color: computed(() => (subscribeSortMode.value ? 'warning' : 'gray')),
|
||||
class: 'settings-icon-button',
|
||||
action: toggleSubscribeSortMode,
|
||||
show: computed(() => activeTab.value === 'mysub'),
|
||||
},
|
||||
{
|
||||
icon: 'mdi-checkbox-multiple-marked-outline',
|
||||
variant: 'text',
|
||||
@@ -267,6 +282,8 @@ onMounted(() => {
|
||||
:subid="subId"
|
||||
:keyword="subscribeFilter"
|
||||
:status-filter="subscribeStatusFilter ?? ''"
|
||||
:sort-mode="subscribeSortMode"
|
||||
@update:sort-mode="subscribeSortMode = $event"
|
||||
/>
|
||||
</div>
|
||||
</transition>
|
||||
|
||||
@@ -73,7 +73,6 @@ const router = createRouter({
|
||||
path: '/subscribe-share',
|
||||
component: () => import('../pages/subscribe-share.vue'),
|
||||
meta: {
|
||||
keepAlive: true,
|
||||
requiresAuth: true,
|
||||
},
|
||||
},
|
||||
@@ -97,6 +96,7 @@ const router = createRouter({
|
||||
path: '/downloading',
|
||||
component: () => import('../pages/downloading.vue'),
|
||||
meta: {
|
||||
keepAlive: true,
|
||||
requiresAuth: true,
|
||||
},
|
||||
},
|
||||
@@ -104,6 +104,7 @@ const router = createRouter({
|
||||
path: '/history',
|
||||
component: () => import('../pages/history.vue'),
|
||||
meta: {
|
||||
keepAlive: true,
|
||||
requiresAuth: true,
|
||||
hideFooter: true,
|
||||
},
|
||||
@@ -145,7 +146,6 @@ const router = createRouter({
|
||||
name: 'plugin-app',
|
||||
component: () => import('../pages/plugin-app.vue'),
|
||||
meta: {
|
||||
keepAlive: true,
|
||||
requiresAuth: true,
|
||||
},
|
||||
},
|
||||
@@ -161,7 +161,6 @@ const router = createRouter({
|
||||
component: () => import('../pages/browse.vue'),
|
||||
props: true,
|
||||
meta: {
|
||||
keepAlive: true,
|
||||
requiresAuth: true,
|
||||
},
|
||||
},
|
||||
@@ -170,7 +169,6 @@ const router = createRouter({
|
||||
component: () => import('../pages/credits.vue'),
|
||||
props: true,
|
||||
meta: {
|
||||
keepAlive: true,
|
||||
requiresAuth: true,
|
||||
},
|
||||
},
|
||||
@@ -179,7 +177,6 @@ const router = createRouter({
|
||||
component: () => import('../pages/person.vue'),
|
||||
props: true,
|
||||
meta: {
|
||||
keepAlive: true,
|
||||
requiresAuth: true,
|
||||
},
|
||||
},
|
||||
@@ -187,7 +184,6 @@ const router = createRouter({
|
||||
path: '/media',
|
||||
component: () => import('../pages/media.vue'),
|
||||
meta: {
|
||||
keepAlive: true,
|
||||
requiresAuth: true,
|
||||
},
|
||||
},
|
||||
@@ -204,6 +200,7 @@ const router = createRouter({
|
||||
path: '/apps',
|
||||
component: () => import('../pages/appcenter.vue'),
|
||||
meta: {
|
||||
keepAlive: true,
|
||||
requiresAuth: true,
|
||||
},
|
||||
},
|
||||
|
||||
@@ -147,6 +147,7 @@ registerRoute(
|
||||
({ url, request }) =>
|
||||
url.pathname.includes('/api/v1/') &&
|
||||
request.method === 'GET' &&
|
||||
!url.pathname.includes('/api/v1/search/') && // 搜索接口结果动态变化,避免缓存导致重复搜索失效
|
||||
!url.pathname.includes('/api/v1/system/message') && // SSE实时消息流
|
||||
!url.pathname.includes('/api/v1/system/progress/') && // SSE实时进度流
|
||||
!url.pathname.includes('/api/v1/system/logging') && // SSE实时日志流
|
||||
|
||||
@@ -515,6 +515,7 @@ html.v-overlay-scroll-blocked body {
|
||||
.v-overlay__content .v-list{
|
||||
backdrop-filter: blur(6px);
|
||||
background-color: rgb(var(--v-theme-surface), 0.9) !important;
|
||||
padding-inline: 0.5rem !important;
|
||||
}
|
||||
|
||||
.v-overlay__content .v-card:not(.bg-primary){
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* 通用APP深度链接工具类
|
||||
* 支持媒体服务器(Plex、Jellyfin、Emby)和豆瓣的APP跳转和网页跳转
|
||||
* 支持媒体服务器(Plex、Jellyfin、Emby、极影视、飞牛影视)和豆瓣的APP跳转和网页跳转
|
||||
*
|
||||
* 深度链接格式参考:
|
||||
* - Plex: https://forums.plex.tv/t/plex-mobile-app-deep-linking/123456
|
||||
@@ -12,7 +12,7 @@
|
||||
import { isMobileDevice, isIOSDevice, isAndroidDevice } from '@/@core/utils'
|
||||
|
||||
// APP类型
|
||||
export type AppType = 'plex' | 'jellyfin' | 'emby' | 'trimemedia' | 'douban'
|
||||
export type AppType = 'plex' | 'jellyfin' | 'emby' | 'zspace' | 'trimemedia' | 'douban'
|
||||
|
||||
// 深度链接配置
|
||||
interface DeepLinkConfig {
|
||||
@@ -38,6 +38,11 @@ const DEEP_LINK_CONFIGS: Record<AppType, DeepLinkConfig> = {
|
||||
webUrl: 'https://emby.media',
|
||||
timeout: 2000,
|
||||
},
|
||||
zspace: {
|
||||
appScheme: 'emby://',
|
||||
webUrl: 'https://www.zspace.com.cn',
|
||||
timeout: 2000,
|
||||
},
|
||||
trimemedia: {
|
||||
appScheme: 'trimemedia://',
|
||||
webUrl: 'https://trimemedia.com',
|
||||
@@ -135,6 +140,9 @@ function buildDeepLinkUrl(appType: AppType, params: string | DoubanAppParams): s
|
||||
case 'emby':
|
||||
return buildEmbyDeepLink(params as string)
|
||||
|
||||
case 'zspace':
|
||||
return buildEmbyDeepLink(params as string)
|
||||
|
||||
case 'trimemedia':
|
||||
return buildTrimemediaDeepLink(params as string)
|
||||
|
||||
@@ -634,7 +642,7 @@ export async function openMediaServerWithAutoDetect(
|
||||
// 优先使用传入的 serverType 参数
|
||||
if (serverType) {
|
||||
const type = serverType.toLowerCase()
|
||||
if (type === 'plex' || type === 'jellyfin' || type === 'emby' || type === 'trimemedia') {
|
||||
if (type === 'plex' || type === 'jellyfin' || type === 'emby' || type === 'zspace' || type === 'trimemedia') {
|
||||
detectedServerType = type as AppType
|
||||
}
|
||||
}
|
||||
@@ -649,6 +657,8 @@ export async function openMediaServerWithAutoDetect(
|
||||
detectedServerType = 'jellyfin'
|
||||
} else if (url.includes('emby')) {
|
||||
detectedServerType = 'emby'
|
||||
} else if (url.includes('zspace')) {
|
||||
detectedServerType = 'zspace'
|
||||
}
|
||||
}
|
||||
|
||||
@@ -698,6 +708,8 @@ export function getAppDownloadUrl(appType: AppType): string {
|
||||
return 'https://jellyfin.org/downloads/'
|
||||
case 'emby':
|
||||
return 'https://emby.media/download.html'
|
||||
case 'zspace':
|
||||
return 'https://www.zspace.com.cn/'
|
||||
case 'trimemedia':
|
||||
return 'https://trimemedia.com/download'
|
||||
case 'douban':
|
||||
|
||||
@@ -8,11 +8,14 @@ import qbittorrentLogo from '@/assets/images/logos/qbittorrent.png'
|
||||
import transmissionLogo from '@/assets/images/logos/transmission.png'
|
||||
import rtorrentLogo from '@/assets/images/logos/rtorrent.png'
|
||||
import embyLogo from '@/assets/images/logos/emby.png'
|
||||
import zspaceLogo from '@/assets/images/logos/zspace.webp'
|
||||
import jellyfinLogo from '@/assets/images/logos/jellyfin.png'
|
||||
import plexLogo from '@/assets/images/logos/plex.png'
|
||||
import trimemediaLogo from '@/assets/images/logos/trimemedia.png'
|
||||
import ugreenLogo from '@/assets/images/logos/ugreen.png'
|
||||
import wechatLogo from '@/assets/images/logos/wechat.png'
|
||||
import feishuLogo from '@/assets/images/logos/feishu.png'
|
||||
import clawbotLogo from '@/assets/images/logos/clawbot.png'
|
||||
import telegramLogo from '@/assets/images/logos/telegram.webp'
|
||||
import slackLogo from '@/assets/images/logos/slack.webp'
|
||||
import discordLogo from '@/assets/images/logos/discord.png'
|
||||
@@ -39,11 +42,14 @@ const logoMap: Record<string, string> = {
|
||||
transmission: transmissionLogo,
|
||||
rtorrent: rtorrentLogo,
|
||||
emby: embyLogo,
|
||||
zspace: zspaceLogo,
|
||||
jellyfin: jellyfinLogo,
|
||||
plex: plexLogo,
|
||||
trimemedia: trimemediaLogo,
|
||||
ugreen: ugreenLogo,
|
||||
wechat: wechatLogo,
|
||||
feishu: feishuLogo,
|
||||
wechatclawbot: clawbotLogo,
|
||||
telegram: telegramLogo,
|
||||
slack: slackLogo,
|
||||
discord: discordLogo,
|
||||
|
||||
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)
|
||||
}
|
||||
52
src/utils/siteIconCache.ts
Normal file
52
src/utils/siteIconCache.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
type SiteIconCacheEntry = {
|
||||
expiresAt: number
|
||||
value: string
|
||||
}
|
||||
|
||||
const SITE_ICON_CACHE_TTL = 10 * 60 * 1000
|
||||
const siteIconCache = new Map<string, SiteIconCacheEntry>()
|
||||
const siteIconRequests = new Map<string, Promise<string>>()
|
||||
|
||||
function readCachedSiteIcon(key: string): string | undefined {
|
||||
const entry = siteIconCache.get(key)
|
||||
if (!entry) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
if (entry.expiresAt <= Date.now()) {
|
||||
siteIconCache.delete(key)
|
||||
return undefined
|
||||
}
|
||||
|
||||
return entry.value
|
||||
}
|
||||
|
||||
export async function getCachedSiteIcon(siteId: string | number, loader: () => Promise<string>): Promise<string> {
|
||||
const cacheKey = String(siteId)
|
||||
const cachedIcon = readCachedSiteIcon(cacheKey)
|
||||
if (cachedIcon !== undefined) {
|
||||
return cachedIcon
|
||||
}
|
||||
|
||||
const currentRequest = siteIconRequests.get(cacheKey)
|
||||
if (currentRequest) {
|
||||
return currentRequest
|
||||
}
|
||||
|
||||
const request = loader()
|
||||
.then(icon => {
|
||||
siteIconCache.set(cacheKey, {
|
||||
expiresAt: Date.now() + SITE_ICON_CACHE_TTL,
|
||||
value: icon,
|
||||
})
|
||||
|
||||
return icon
|
||||
})
|
||||
.finally(() => {
|
||||
siteIconRequests.delete(cacheKey)
|
||||
})
|
||||
|
||||
siteIconRequests.set(cacheKey, request)
|
||||
|
||||
return request
|
||||
}
|
||||
@@ -16,6 +16,16 @@ export class SSEManager {
|
||||
}
|
||||
private reconnectAttempts = 0
|
||||
private isConnecting = false
|
||||
private readonly handleVisibilityChange = () => {
|
||||
if (document.hidden) {
|
||||
this.handleBackground()
|
||||
} else {
|
||||
this.handleForeground()
|
||||
}
|
||||
}
|
||||
private readonly handleBeforeUnload = () => {
|
||||
this.destroy()
|
||||
}
|
||||
|
||||
constructor(url: string, options: Partial<typeof SSEManager.prototype.options> = {}) {
|
||||
this.url = url
|
||||
@@ -30,18 +40,13 @@ export class SSEManager {
|
||||
}
|
||||
|
||||
private setupVisibilityListener() {
|
||||
document.addEventListener('visibilitychange', () => {
|
||||
if (document.hidden) {
|
||||
this.handleBackground()
|
||||
} else {
|
||||
this.handleForeground()
|
||||
}
|
||||
})
|
||||
document.addEventListener('visibilitychange', this.handleVisibilityChange)
|
||||
window.addEventListener('beforeunload', this.handleBeforeUnload)
|
||||
}
|
||||
|
||||
// 页面卸载时关闭连接
|
||||
window.addEventListener('beforeunload', () => {
|
||||
this.close()
|
||||
})
|
||||
private removeVisibilityListener() {
|
||||
document.removeEventListener('visibilitychange', this.handleVisibilityChange)
|
||||
window.removeEventListener('beforeunload', this.handleBeforeUnload)
|
||||
}
|
||||
|
||||
private handleBackground() {
|
||||
@@ -172,6 +177,18 @@ export class SSEManager {
|
||||
* 关闭连接
|
||||
*/
|
||||
close() {
|
||||
this.resetConnectionState()
|
||||
}
|
||||
|
||||
/**
|
||||
* 销毁管理器并清理所有引用
|
||||
*/
|
||||
destroy() {
|
||||
this.resetConnectionState(true)
|
||||
this.removeVisibilityListener()
|
||||
}
|
||||
|
||||
private resetConnectionState(clearListeners = false) {
|
||||
if (this.eventSource) {
|
||||
this.eventSource.close()
|
||||
this.eventSource = null
|
||||
@@ -187,7 +204,10 @@ export class SSEManager {
|
||||
this.backgroundCloseTimer = null
|
||||
}
|
||||
|
||||
this.listeners.clear()
|
||||
if (clearListeners) {
|
||||
this.listeners.clear()
|
||||
}
|
||||
|
||||
this.isConnecting = false
|
||||
this.reconnectAttempts = 0
|
||||
}
|
||||
@@ -210,8 +230,9 @@ export class SSEManager {
|
||||
* 强制重新连接
|
||||
*/
|
||||
forceReconnect() {
|
||||
const hasActiveListeners = this.listeners.size > 0
|
||||
this.close()
|
||||
if (!this.isBackground && this.listeners.size > 0) {
|
||||
if (!this.isBackground && hasActiveListeners) {
|
||||
this.reconnectSSE()
|
||||
}
|
||||
}
|
||||
@@ -244,6 +265,10 @@ export class SSEManager {
|
||||
class SSEManagerSingleton {
|
||||
private managers: Map<string, SSEManager> = new Map()
|
||||
|
||||
private getIndependentManagerKey(url: string, listenerId: string): string {
|
||||
return `${url}::${listenerId}`
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取或创建SSE管理器
|
||||
* @param url SSE连接URL
|
||||
@@ -285,16 +310,28 @@ class SSEManagerSingleton {
|
||||
closeManager(url: string) {
|
||||
const manager = this.managers.get(url)
|
||||
if (manager) {
|
||||
manager.close()
|
||||
manager.destroy()
|
||||
this.managers.delete(url)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 关闭独立管理器
|
||||
*/
|
||||
closeIndependentManager(url: string, listenerId: string) {
|
||||
const managerKey = this.getIndependentManagerKey(url, listenerId)
|
||||
const manager = this.managers.get(managerKey)
|
||||
if (manager) {
|
||||
manager.destroy()
|
||||
this.managers.delete(managerKey)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 关闭所有管理器
|
||||
*/
|
||||
closeAllManagers() {
|
||||
this.managers.forEach(manager => manager.close())
|
||||
this.managers.forEach(manager => manager.destroy())
|
||||
this.managers.clear()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ class ThemeManager {
|
||||
private themes: Map<string, ThemeConfig> = new Map()
|
||||
private currentTheme: string = 'default'
|
||||
private loadedLinks: Map<string, HTMLLinkElement> = new Map()
|
||||
private themeListeners: Map<(theme: string) => void, EventListener> = new Map()
|
||||
|
||||
constructor() {
|
||||
// 注册所有可用主题
|
||||
@@ -190,18 +191,29 @@ class ThemeManager {
|
||||
* 监听主题变更事件
|
||||
*/
|
||||
onThemeChange(callback: (theme: string) => void): void {
|
||||
document.addEventListener('themechange', (event: any) => {
|
||||
callback(event.detail.theme)
|
||||
})
|
||||
if (this.themeListeners.has(callback)) {
|
||||
return
|
||||
}
|
||||
|
||||
const listener: EventListener = event => {
|
||||
callback((event as CustomEvent<{ theme: string }>).detail.theme)
|
||||
}
|
||||
|
||||
this.themeListeners.set(callback, listener)
|
||||
document.addEventListener('themechange', listener)
|
||||
}
|
||||
|
||||
/**
|
||||
* 移除主题变更监听器
|
||||
*/
|
||||
offThemeChange(callback: (theme: string) => void): void {
|
||||
document.removeEventListener('themechange', (event: any) => {
|
||||
callback(event.detail.theme)
|
||||
})
|
||||
const listener = this.themeListeners.get(callback)
|
||||
if (!listener) {
|
||||
return
|
||||
}
|
||||
|
||||
document.removeEventListener('themechange', listener)
|
||||
this.themeListeners.delete(callback)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
13
src/utils/torrentDownloadCache.ts
Normal file
13
src/utils/torrentDownloadCache.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { reactive } from 'vue'
|
||||
|
||||
const downloadedTorrentMap = reactive<Record<string, boolean>>({})
|
||||
|
||||
export function markTorrentDownloaded(url?: string | null) {
|
||||
if (!url) {
|
||||
return
|
||||
}
|
||||
|
||||
downloadedTorrentMap[url] = true
|
||||
}
|
||||
|
||||
export { downloadedTorrentMap }
|
||||
@@ -2,6 +2,7 @@
|
||||
import api from '@/api'
|
||||
import type { MediaInfo } from '@/api/types'
|
||||
import MediaCard from '@/components/cards/MediaCard.vue'
|
||||
import ProgressiveCardGrid from '@/components/misc/ProgressiveCardGrid.vue'
|
||||
import NoDataFound from '@/components/NoDataFound.vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
@@ -27,12 +28,11 @@ const loading = ref(false)
|
||||
// 是否加载完成
|
||||
const isRefreshed = ref(false)
|
||||
|
||||
// 数据列表
|
||||
const dataList = ref<MediaInfo[]>([])
|
||||
const currData = ref<MediaInfo[]>([])
|
||||
// 使用 shallowRef 避免长列表中的深层代理开销
|
||||
const dataList = shallowRef<MediaInfo[]>([])
|
||||
|
||||
// 用于保存已处理过的 key
|
||||
const seenKeys = ref<Set<string>>(new Set<string>())
|
||||
const seenKeys = new Set<string>()
|
||||
|
||||
// 拼装参数
|
||||
function getParams() {
|
||||
@@ -46,27 +46,42 @@ function getParams() {
|
||||
|
||||
// MediaInfo 去重的字段
|
||||
const dedupFields = [
|
||||
"source",
|
||||
"type",
|
||||
"season",
|
||||
"tmdb_id",
|
||||
"imdb_id",
|
||||
"tvdb_id",
|
||||
"douban_id",
|
||||
"bangumi_id",
|
||||
"mediaid_prefix",
|
||||
"media_id",
|
||||
] as const;
|
||||
'source',
|
||||
'type',
|
||||
'season',
|
||||
'tmdb_id',
|
||||
'imdb_id',
|
||||
'tvdb_id',
|
||||
'douban_id',
|
||||
'bangumi_id',
|
||||
'mediaid_prefix',
|
||||
'media_id',
|
||||
] as const
|
||||
|
||||
function deduplicate(items: MediaInfo[]): MediaInfo[] {
|
||||
return items.filter(item => {
|
||||
const key = dedupFields.map(field => String(item[field])).join('~');
|
||||
if (seenKeys.value.has(key)) {
|
||||
return false;
|
||||
const key = dedupFields.map(field => String(item[field])).join('~')
|
||||
if (seenKeys.has(key)) {
|
||||
return false
|
||||
}
|
||||
seenKeys.value.add(key);
|
||||
return true;
|
||||
});
|
||||
seenKeys.add(key)
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
function appendData(items: MediaInfo[]) {
|
||||
dataList.value = dataList.value.concat(items)
|
||||
}
|
||||
|
||||
async function loadPageData() {
|
||||
const rawData: MediaInfo[] = await api.get(props.apipath!, {
|
||||
params: getParams(),
|
||||
})
|
||||
|
||||
return {
|
||||
rawCount: rawData.length,
|
||||
uniqueData: deduplicate(rawData),
|
||||
}
|
||||
}
|
||||
|
||||
// 获取列表数据
|
||||
@@ -87,22 +102,18 @@ async function fetchData({ done }: { done: any }) {
|
||||
// 设置加载中
|
||||
loading.value = true
|
||||
// 请求API
|
||||
currData.value = await api.get(props.apipath, {
|
||||
params: getParams(),
|
||||
})
|
||||
const { rawCount, uniqueData } = await loadPageData()
|
||||
// 取消加载中
|
||||
loading.value = false
|
||||
// 标计为已请求完成
|
||||
isRefreshed.value = true
|
||||
if (currData.value.length === 0) {
|
||||
if (rawCount === 0) {
|
||||
// 如果没有数据,跳出
|
||||
done('empty')
|
||||
return
|
||||
}
|
||||
// 去重
|
||||
currData.value = deduplicate(currData.value)
|
||||
// 合并数据
|
||||
dataList.value.push(...currData.value)
|
||||
appendData(uniqueData)
|
||||
// 页码+1
|
||||
page.value++
|
||||
// 返回加载成功
|
||||
@@ -113,19 +124,15 @@ async function fetchData({ done }: { done: any }) {
|
||||
// 设置加载中
|
||||
loading.value = true
|
||||
// 请求API
|
||||
currData.value = await api.get(props.apipath, {
|
||||
params: getParams(),
|
||||
})
|
||||
const { rawCount, uniqueData } = await loadPageData()
|
||||
// 标计为已请求完成
|
||||
isRefreshed.value = true
|
||||
if (currData.value.length === 0) {
|
||||
if (rawCount === 0) {
|
||||
// 如果没有数据,跳出
|
||||
done('empty')
|
||||
} else {
|
||||
// 去重
|
||||
currData.value = deduplicate(currData.value)
|
||||
// 合并数据
|
||||
dataList.value.push(...currData.value)
|
||||
appendData(uniqueData)
|
||||
// 页码+1
|
||||
page.value++
|
||||
// 返回加载成功
|
||||
@@ -147,9 +154,16 @@ async function fetchData({ done }: { done: any }) {
|
||||
<VInfiniteScroll mode="intersect" side="end" :items="dataList" class="overflow-visible pt-3 px-2" @load="fetchData">
|
||||
<template #loading />
|
||||
<template #empty />
|
||||
<div v-if="dataList.length > 0" class="grid gap-4 grid-media-card" tabindex="0">
|
||||
<MediaCard v-for="data in dataList" :key="data.tmdb_id || data.douban_id" :media="data" />
|
||||
</div>
|
||||
<ProgressiveCardGrid
|
||||
v-if="dataList.length > 0"
|
||||
:items="dataList"
|
||||
:get-item-key="item => item.tmdb_id || item.douban_id || item.bangumi_id || item.media_id || item.title"
|
||||
tabindex="0"
|
||||
>
|
||||
<template #default="{ item }">
|
||||
<MediaCard :media="item" />
|
||||
</template>
|
||||
</ProgressiveCardGrid>
|
||||
<NoDataFound
|
||||
v-if="dataList.length === 0 && isRefreshed"
|
||||
error-code="404"
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import api from '@/api'
|
||||
import type { MediaInfo } from '@/api/types'
|
||||
import MediaCard from '@/components/cards/MediaCard.vue'
|
||||
import SlideView from '@/components/slide/SlideView.vue'
|
||||
import VirtualSlideView from '@/components/slide/VirtualSlideView.vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useIntersectionObserver, until } from '@vueuse/core'
|
||||
|
||||
@@ -27,8 +27,8 @@ const componentLoaded = ref(false)
|
||||
// 是否已尝试加载
|
||||
const hasTriedLoading = ref(false)
|
||||
|
||||
// 数据列表
|
||||
const dataList = ref<MediaInfo[]>([])
|
||||
// 使用 shallowRef 避免横向卡片区的大数组深层代理
|
||||
const dataList = shallowRef<MediaInfo[]>([])
|
||||
|
||||
// 容器引用
|
||||
const containerRef = ref<HTMLElement | null>(null)
|
||||
@@ -74,21 +74,21 @@ onActivated(() => {
|
||||
|
||||
<template>
|
||||
<div ref="containerRef">
|
||||
<SlideView v-if="componentLoaded">
|
||||
<template #content>
|
||||
<template v-for="data in dataList" :key="data.tmdb_id || data.douban_id || data.bangumi_id">
|
||||
<MediaCard :media="data" width="9rem" />
|
||||
</template>
|
||||
<VirtualSlideView
|
||||
:items="dataList"
|
||||
:loading="!componentLoaded"
|
||||
:get-item-key="item => item.tmdb_id || item.douban_id || item.bangumi_id || item.media_id || item.title"
|
||||
>
|
||||
<template #item="{ item }">
|
||||
<MediaCard :media="item" width="9rem" />
|
||||
</template>
|
||||
</SlideView>
|
||||
<SlideView v-else-if="!componentLoaded">
|
||||
<template #content>
|
||||
<template #loading>
|
||||
<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>
|
||||
</VirtualSlideView>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
<script lang="ts" setup>
|
||||
import api from '@/api'
|
||||
import type { Person } from '@/api/types'
|
||||
import PersonCard from '@/components/cards/PersonCard.vue'
|
||||
import ProgressiveCardGrid from '@/components/misc/ProgressiveCardGrid.vue'
|
||||
import NoDataFound from '@/components/NoDataFound.vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
@@ -27,9 +29,18 @@ const loading = ref(false)
|
||||
// 是否加载完成
|
||||
const isRefreshed = ref(false)
|
||||
|
||||
// 数据列表
|
||||
const dataList = ref<any>([])
|
||||
const currData = ref<any>([])
|
||||
// 使用 shallowRef 避免长列表中的深层代理开销
|
||||
const dataList = shallowRef<Person[]>([])
|
||||
|
||||
function appendData(items: Person[]) {
|
||||
dataList.value = dataList.value.concat(items)
|
||||
}
|
||||
|
||||
async function loadPageData() {
|
||||
return api.get(props.apipath!, {
|
||||
params: getParams(),
|
||||
}) as Promise<Person[]>
|
||||
}
|
||||
|
||||
// 拼装参数
|
||||
function getParams() {
|
||||
@@ -59,20 +70,18 @@ async function fetchData({ done }: { done: any }) {
|
||||
// 设置加载中
|
||||
loading.value = true
|
||||
// 请求API
|
||||
currData.value = await api.get(props.apipath, {
|
||||
params: getParams(),
|
||||
})
|
||||
const currentData = await loadPageData()
|
||||
// 取消加载中
|
||||
loading.value = false
|
||||
// 标计为已请求完成
|
||||
isRefreshed.value = true
|
||||
if (currData.value.length === 0) {
|
||||
if (currentData.length === 0) {
|
||||
// 如果没有数据,跳出
|
||||
done('empty')
|
||||
return
|
||||
} else {
|
||||
// 合并数据
|
||||
dataList.value = [...dataList.value, ...currData.value]
|
||||
appendData(currentData)
|
||||
// 页码+1
|
||||
page.value++
|
||||
// 返回加载成功
|
||||
@@ -84,17 +93,15 @@ async function fetchData({ done }: { done: any }) {
|
||||
// 设置加载中
|
||||
loading.value = true
|
||||
// 请求API
|
||||
currData.value = await api.get(props.apipath, {
|
||||
params: getParams(),
|
||||
})
|
||||
const currentData = await loadPageData()
|
||||
// 标计为已请求完成
|
||||
isRefreshed.value = true
|
||||
if (currData.value.length === 0) {
|
||||
if (currentData.length === 0) {
|
||||
// 如果没有数据,跳出
|
||||
done('empty')
|
||||
} else {
|
||||
// 合并数据
|
||||
dataList.value = [...dataList.value, ...currData.value]
|
||||
appendData(currentData)
|
||||
// 页码+1
|
||||
page.value++
|
||||
// 返回加载成功
|
||||
@@ -116,9 +123,11 @@ async function fetchData({ done }: { done: any }) {
|
||||
<VInfiniteScroll mode="intersect" side="end" :items="dataList" class="overflow-visible px-3" @load="fetchData">
|
||||
<template #loading />
|
||||
<template #empty />
|
||||
<div v-if="dataList.length > 0" class="grid gap-4 grid-media-card" tabindex="0">
|
||||
<PersonCard v-for="data in dataList" :key="data.id" :person="data" />
|
||||
</div>
|
||||
<ProgressiveCardGrid v-if="dataList.length > 0" :items="dataList" :get-item-key="item => item.id" tabindex="0">
|
||||
<template #default="{ item }">
|
||||
<PersonCard :person="item" />
|
||||
</template>
|
||||
</ProgressiveCardGrid>
|
||||
<NoDataFound
|
||||
v-if="dataList.length === 0 && isRefreshed"
|
||||
error-code="404"
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
<script lang="ts" setup>
|
||||
import PersonCard from '@/components/cards/PersonCard.vue'
|
||||
import type { Person } from '@/api/types'
|
||||
import api from '@/api'
|
||||
import SlideView from '@/components/slide/SlideView.vue'
|
||||
import VirtualSlideView from '@/components/slide/VirtualSlideView.vue'
|
||||
import { useIntersectionObserver } from '@vueuse/core'
|
||||
|
||||
// 输入参数
|
||||
const props = defineProps({
|
||||
@@ -16,8 +18,14 @@ provide('rankingPropsKey', reactive({ ...props }))
|
||||
// 组件加载完成
|
||||
const componentLoaded = ref(false)
|
||||
|
||||
// 是否已尝试加载
|
||||
const hasTriedLoading = ref(false)
|
||||
|
||||
// 数据列表
|
||||
const dataList = ref<any>([])
|
||||
const dataList = shallowRef<Person[]>([])
|
||||
|
||||
// 容器引用
|
||||
const containerRef = ref<HTMLElement | null>(null)
|
||||
|
||||
// 获取订阅列表数据
|
||||
async function fetchData() {
|
||||
@@ -25,22 +33,47 @@ async function fetchData() {
|
||||
if (!props.apipath) return
|
||||
|
||||
dataList.value = await api.get(props.apipath)
|
||||
if (dataList.value.length > 0) componentLoaded.value = true
|
||||
componentLoaded.value = true
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
} finally {
|
||||
hasTriedLoading.value = true
|
||||
}
|
||||
}
|
||||
|
||||
// 加载时获取数据
|
||||
onMounted(fetchData)
|
||||
const { stop } = useIntersectionObserver(
|
||||
containerRef,
|
||||
([{ isIntersecting }]) => {
|
||||
if (isIntersecting) {
|
||||
fetchData()
|
||||
stop()
|
||||
}
|
||||
},
|
||||
{
|
||||
rootMargin: '300px',
|
||||
},
|
||||
)
|
||||
|
||||
onActivated(() => {
|
||||
if (dataList.value.length === 0 && hasTriedLoading.value) {
|
||||
fetchData()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<SlideView v-if="componentLoaded">
|
||||
<template #content>
|
||||
<template v-for="data in dataList" :key="data.id">
|
||||
<PersonCard :person="data" width="9rem" />
|
||||
<div ref="containerRef">
|
||||
<VirtualSlideView :items="dataList" :loading="!componentLoaded" :get-item-key="item => item.id">
|
||||
<template #item="{ item }">
|
||||
<PersonCard :person="item" width="9rem" />
|
||||
</template>
|
||||
</template>
|
||||
</SlideView>
|
||||
<template #loading>
|
||||
<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>
|
||||
</VirtualSlideView>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -13,6 +13,7 @@ import PluginMarketSettingDialog from '@/components/dialog/PluginMarketSettingDi
|
||||
import { useDynamicButton } from '@/composables/useDynamicButton'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import PluginMixedSortCard from '@/components/cards/PluginMixedSortCard.vue'
|
||||
import ProgressiveCardGrid from '@/components/misc/ProgressiveCardGrid.vue'
|
||||
import { usePWA } from '@/composables/usePWA'
|
||||
import { useDynamicHeaderTab } from '@/composables/useDynamicHeaderTab'
|
||||
|
||||
@@ -30,6 +31,7 @@ const { appMode } = usePWA()
|
||||
|
||||
// 当前标签
|
||||
const activeTab = ref('installed')
|
||||
const sortMode = ref(false)
|
||||
|
||||
// 获取插件标签页
|
||||
const pluginTabs = computed(() => getPluginTabs(t))
|
||||
@@ -58,6 +60,16 @@ registerHeaderTab({
|
||||
},
|
||||
show: computed(() => activeTab.value === 'installed'),
|
||||
},
|
||||
{
|
||||
icon: 'mdi-sort-variant',
|
||||
variant: 'text',
|
||||
color: computed(() => (sortMode.value ? 'warning' : 'gray')),
|
||||
class: 'settings-icon-button',
|
||||
action: () => {
|
||||
sortMode.value = !sortMode.value
|
||||
},
|
||||
show: computed(() => activeTab.value === 'installed'),
|
||||
},
|
||||
{
|
||||
icon: 'mdi-filter-multiple-outline',
|
||||
variant: 'text',
|
||||
@@ -249,6 +261,41 @@ const newFolderDialog = ref(false)
|
||||
// 新文件夹名称
|
||||
const newFolderName = ref('')
|
||||
|
||||
const pluginByIdMap = computed(() => new Map(dataList.value.map(plugin => [plugin.id, plugin])))
|
||||
const orderValueMap = computed(() => {
|
||||
const map = new Map<string, number>()
|
||||
|
||||
orderConfig.value.forEach((item, index) => {
|
||||
map.set(`${item.type || 'plugin'}:${item.id}`, item.order ?? index)
|
||||
})
|
||||
|
||||
return map
|
||||
})
|
||||
|
||||
const folderedPluginIds = computed(() => {
|
||||
const pluginIds = new Set<string>()
|
||||
|
||||
Object.values(pluginFolders.value).forEach(folderData => {
|
||||
const plugins = Array.isArray(folderData) ? folderData : folderData.plugins || []
|
||||
plugins.forEach((pluginId: string) => pluginIds.add(pluginId))
|
||||
})
|
||||
|
||||
return pluginIds
|
||||
})
|
||||
|
||||
const canDragSort = computed(() => sortMode.value && activeTab.value === 'installed')
|
||||
const shouldVirtualizeInstalledMainList = computed(() => !sortMode.value && !currentFolder.value)
|
||||
const shouldVirtualizeInstalledFolderList = computed(() => !sortMode.value && !!currentFolder.value)
|
||||
const installedScrollToIndex = computed(() => {
|
||||
if (sortMode.value || currentFolder.value || !pluginId.value) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const targetIndex = mixedSortList.value.findIndex(item => item.type === 'plugin' && item.id === pluginId.value)
|
||||
|
||||
return targetIndex >= 0 ? targetIndex : undefined
|
||||
})
|
||||
|
||||
// 获取文件夹内筛选后的插件
|
||||
const getFilteredFolderPlugins = (folderName: string) => {
|
||||
const folderData = pluginFolders.value[folderName]
|
||||
@@ -257,7 +304,7 @@ const getFilteredFolderPlugins = (folderName: string) => {
|
||||
// 获取文件夹内的插件并应用筛选条件
|
||||
const folderPlugins: Plugin[] = []
|
||||
folderPluginIds.forEach((pluginId: string) => {
|
||||
const plugin = dataList.value.find(p => p.id === pluginId)
|
||||
const plugin = pluginByIdMap.value.get(pluginId)
|
||||
if (plugin) {
|
||||
folderPlugins.push(plugin)
|
||||
}
|
||||
@@ -288,12 +335,7 @@ const getFilteredFolderPlugins = (folderName: string) => {
|
||||
const displayedPlugins = computed(() => {
|
||||
if (!currentFolder.value) {
|
||||
// 主列表:显示未归类的插件
|
||||
const folderedPluginIds = new Set()
|
||||
Object.values(pluginFolders.value).forEach(folderData => {
|
||||
const plugins = Array.isArray(folderData) ? folderData : folderData.plugins || []
|
||||
plugins.forEach((pid: string) => folderedPluginIds.add(pid))
|
||||
})
|
||||
return filteredDataList.value.filter(plugin => !folderedPluginIds.has(plugin.id))
|
||||
return filteredDataList.value.filter(plugin => !folderedPluginIds.value.has(plugin.id))
|
||||
} else {
|
||||
// 文件夹内:返回筛选后的插件
|
||||
return getFilteredFolderPlugins(currentFolder.value)
|
||||
@@ -365,23 +407,21 @@ function updateMixedSortList() {
|
||||
|
||||
// 添加文件夹项目
|
||||
displayedFolders.value.forEach(folder => {
|
||||
const orderItem = orderConfig.value.find((item: any) => item.type === 'folder' && item.id === folder.name)
|
||||
allItems.push({
|
||||
type: 'folder',
|
||||
id: folder.name,
|
||||
data: folder,
|
||||
order: orderItem?.order ?? 999,
|
||||
order: orderValueMap.value.get(`folder:${folder.name}`) ?? 999,
|
||||
})
|
||||
})
|
||||
|
||||
// 添加插件项目
|
||||
displayedPlugins.value.forEach(plugin => {
|
||||
const orderItem = orderConfig.value.find((item: any) => item.type === 'plugin' && item.id === plugin.id)
|
||||
allItems.push({
|
||||
type: 'plugin',
|
||||
id: plugin.id || '',
|
||||
data: plugin,
|
||||
order: orderItem?.order ?? 999,
|
||||
order: orderValueMap.value.get(`plugin:${plugin.id}`) ?? 999,
|
||||
})
|
||||
})
|
||||
|
||||
@@ -463,9 +503,10 @@ function sortPluginOrder() {
|
||||
return
|
||||
}
|
||||
dataList.value.sort((a, b) => {
|
||||
const aIndex = orderConfig.value.findIndex((item: { id: string }) => item.id === a.id)
|
||||
const bIndex = orderConfig.value.findIndex((item: { id: string }) => item.id === b.id)
|
||||
return (aIndex === -1 ? 999 : aIndex) - (bIndex === -1 ? 999 : bIndex)
|
||||
const aIndex = orderValueMap.value.get(`plugin:${a.id}`) ?? Number.MAX_SAFE_INTEGER
|
||||
const bIndex = orderValueMap.value.get(`plugin:${b.id}`) ?? Number.MAX_SAFE_INTEGER
|
||||
|
||||
return aIndex - bIndex
|
||||
})
|
||||
}
|
||||
|
||||
@@ -505,7 +546,7 @@ async function saveMixedSortOrder() {
|
||||
Object.values(pluginFolders.value).forEach(folderData => {
|
||||
const plugins = Array.isArray(folderData) ? folderData : folderData.plugins || []
|
||||
plugins.forEach((id: string) => {
|
||||
const folderPlugin = dataList.value.find(p => p.id === id)
|
||||
const folderPlugin = pluginByIdMap.value.get(id)
|
||||
if (folderPlugin && !newPluginOrder.find(p => p.id === id)) {
|
||||
newPluginOrder.push(folderPlugin)
|
||||
}
|
||||
@@ -994,12 +1035,8 @@ async function loadPluginFolders() {
|
||||
// 设置文件夹排序 - 使用全局排序配置
|
||||
const folderNames = Object.keys(processedFolders)
|
||||
folderOrder.value = folderNames.sort((a, b) => {
|
||||
// 从全局排序配置中查找文件夹的order
|
||||
const aOrderItem = orderConfig.value.find((item: any) => item.type === 'folder' && item.id === a)
|
||||
const bOrderItem = orderConfig.value.find((item: any) => item.type === 'folder' && item.id === b)
|
||||
|
||||
const aOrder = aOrderItem?.order ?? processedFolders[a].order ?? 999
|
||||
const bOrder = bOrderItem?.order ?? processedFolders[b].order ?? 999
|
||||
const aOrder = orderValueMap.value.get(`folder:${a}`) ?? processedFolders[a].order ?? 999
|
||||
const bOrder = orderValueMap.value.get(`folder:${b}`) ?? processedFolders[b].order ?? 999
|
||||
|
||||
return aOrder - bOrder
|
||||
})
|
||||
@@ -1228,7 +1265,7 @@ async function handleDropToFolder(event: DragEvent, folderName: string) {
|
||||
}
|
||||
|
||||
// 验证插件ID
|
||||
const plugin = filteredDataList.value.find(p => p.id === pluginId)
|
||||
const plugin = pluginByIdMap.value.get(pluginId)
|
||||
|
||||
if (!plugin) {
|
||||
return
|
||||
@@ -1485,6 +1522,14 @@ function onDragStartPlugin(evt: any) {
|
||||
<div>
|
||||
<VPageContentTitle v-if="installedFilter" :title="t('plugin.filter', { name: installedFilter })" />
|
||||
<LoadingBanner v-if="!isRefreshed" class="mt-12" />
|
||||
<VAlert v-if="sortMode" color="warning" variant="tonal" class="mb-4">
|
||||
<div class="d-flex flex-wrap align-center justify-space-between gap-2">
|
||||
<span>{{ t('common.sortModeHint') }}</span>
|
||||
<VBtn variant="tonal" color="error" @click="sortMode = false">
|
||||
{{ t('common.exit') }}
|
||||
</VBtn>
|
||||
</div>
|
||||
</VAlert>
|
||||
|
||||
<!-- 文件夹和插件网格 -->
|
||||
<div v-if="(mixedSortList.length > 0 || displayedPlugins.length > 0) && isRefreshed">
|
||||
@@ -1492,10 +1537,10 @@ function onDragStartPlugin(evt: any) {
|
||||
<template v-if="!currentFolder">
|
||||
<!-- 主列表:使用draggable进行混合排序 -->
|
||||
<draggable
|
||||
v-if="canDragSort"
|
||||
v-model="mixedSortList"
|
||||
@end="saveMixedSortOrder"
|
||||
@start="onDragStartPlugin"
|
||||
handle=".cursor-move"
|
||||
item-key="id"
|
||||
tag="div"
|
||||
class="grid gap-4 grid-plugin-card"
|
||||
@@ -1506,6 +1551,7 @@ function onDragStartPlugin(evt: any) {
|
||||
:item="element"
|
||||
:plugin-statistics="PluginStatistics"
|
||||
:plugin-actions="pluginActions"
|
||||
:sortable="true"
|
||||
@open-folder="openFolder"
|
||||
@delete-folder="deleteFolder"
|
||||
@rename-folder="(oldName, newName) => renameFolder(oldName, newName)"
|
||||
@@ -1520,15 +1566,42 @@ function onDragStartPlugin(evt: any) {
|
||||
/>
|
||||
</template>
|
||||
</draggable>
|
||||
<ProgressiveCardGrid
|
||||
v-else-if="shouldVirtualizeInstalledMainList"
|
||||
:items="mixedSortList"
|
||||
:get-item-key="item => `${item.type}:${item.id}`"
|
||||
:min-item-width="256"
|
||||
:scroll-to-index="installedScrollToIndex"
|
||||
>
|
||||
<template #default="{ item }">
|
||||
<PluginMixedSortCard
|
||||
:item="item"
|
||||
:plugin-statistics="PluginStatistics"
|
||||
:plugin-actions="pluginActions"
|
||||
:sortable="false"
|
||||
@open-folder="openFolder"
|
||||
@delete-folder="deleteFolder"
|
||||
@rename-folder="(oldName, newName) => renameFolder(oldName, newName)"
|
||||
@update-folder-config="(folderName, config) => updateFolderConfig(folderName, config)"
|
||||
@refresh-data="refreshData"
|
||||
@action-done="
|
||||
pluginId => {
|
||||
pluginActions[pluginId] = false
|
||||
}
|
||||
"
|
||||
@drop-to-folder="(event, folderName) => handleDropToFolder(event, folderName)"
|
||||
/>
|
||||
</template>
|
||||
</ProgressiveCardGrid>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<!-- 文件夹内:使用draggable排序 + 移出按钮 -->
|
||||
<draggable
|
||||
v-if="canDragSort"
|
||||
v-model="draggableFolderPlugins"
|
||||
@end="saveFolderPluginOrder"
|
||||
@start="onDragStartPlugin"
|
||||
handle=".cursor-move"
|
||||
item-key="id"
|
||||
tag="div"
|
||||
class="grid gap-4 grid-plugin-card"
|
||||
@@ -1539,6 +1612,7 @@ function onDragStartPlugin(evt: any) {
|
||||
:item="{ type: 'plugin', id: element.id, data: element, order: 0 }"
|
||||
:plugin-statistics="PluginStatistics"
|
||||
:plugin-actions="pluginActions"
|
||||
:sortable="true"
|
||||
:show-remove-button="true"
|
||||
@refresh-data="refreshData"
|
||||
@action-done="
|
||||
@@ -1550,6 +1624,29 @@ function onDragStartPlugin(evt: any) {
|
||||
/>
|
||||
</template>
|
||||
</draggable>
|
||||
<ProgressiveCardGrid
|
||||
v-else-if="shouldVirtualizeInstalledFolderList"
|
||||
:items="draggableFolderPlugins"
|
||||
:get-item-key="item => item.id"
|
||||
:min-item-width="256"
|
||||
>
|
||||
<template #default="{ item }">
|
||||
<PluginMixedSortCard
|
||||
:item="{ type: 'plugin', id: item.id, data: item, order: 0 }"
|
||||
:plugin-statistics="PluginStatistics"
|
||||
:plugin-actions="pluginActions"
|
||||
:sortable="false"
|
||||
:show-remove-button="true"
|
||||
@refresh-data="refreshData"
|
||||
@action-done="
|
||||
pluginId => {
|
||||
pluginActions[pluginId] = false
|
||||
}
|
||||
"
|
||||
@remove-from-folder="removeFromFolder"
|
||||
/>
|
||||
</template>
|
||||
</ProgressiveCardGrid>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
@@ -1580,14 +1677,17 @@ function onDragStartPlugin(evt: any) {
|
||||
>
|
||||
<template #loading />
|
||||
<template #empty />
|
||||
<div class="grid gap-4 grid-plugin-card">
|
||||
<template
|
||||
v-for="(data, index) in displayUninstalledList"
|
||||
:key="`${data.id}_v${data.plugin_version}_${index}`"
|
||||
>
|
||||
<PluginAppCard :plugin="data" :count="PluginStatistics[data.id || '0']" @install="pluginInstalled" />
|
||||
<ProgressiveCardGrid
|
||||
v-if="displayUninstalledList.length > 0"
|
||||
:items="displayUninstalledList"
|
||||
:get-item-key="item => `${item.id}_v${item.plugin_version}`"
|
||||
:min-item-width="256"
|
||||
:estimated-item-height="260"
|
||||
>
|
||||
<template #default="{ item }">
|
||||
<PluginAppCard :plugin="item" :count="PluginStatistics[item.id || '0']" @install="pluginInstalled" />
|
||||
</template>
|
||||
</div>
|
||||
</ProgressiveCardGrid>
|
||||
</VInfiniteScroll>
|
||||
<NoDataFound
|
||||
v-if="displayUninstalledList.length === 0 && isAppMarketLoaded"
|
||||
|
||||
@@ -4,6 +4,7 @@ import api from '@/api'
|
||||
import type { DownloadingInfo } from '@/api/types'
|
||||
import NoDataFound from '@/components/NoDataFound.vue'
|
||||
import DownloadingCard from '@/components/cards/DownloadingCard.vue'
|
||||
import ProgressiveCardGrid from '@/components/misc/ProgressiveCardGrid.vue'
|
||||
import { useUserStore } from '@/stores'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useBackgroundOptimization } from '@/composables/useBackgroundOptimization'
|
||||
@@ -67,14 +68,17 @@ const { loading: dataLoading } = useDataRefresh(
|
||||
<template>
|
||||
<LoadingBanner v-if="!isRefreshed" class="mt-12" />
|
||||
<VPullToRefresh v-model="loading" @load="onRefresh" :pull-down-threshold="64">
|
||||
<div v-if="filteredDataList.length > 0" class="grid gap-4 grid-downloading-card">
|
||||
<DownloadingCard
|
||||
v-for="data in filteredDataList"
|
||||
:key="data.hash"
|
||||
:info="data"
|
||||
:downloader-name="props.name"
|
||||
/>
|
||||
</div>
|
||||
<ProgressiveCardGrid
|
||||
v-if="filteredDataList.length > 0"
|
||||
:items="filteredDataList"
|
||||
:get-item-key="item => item.hash || item.name"
|
||||
:min-item-width="320"
|
||||
:estimated-item-height="230"
|
||||
>
|
||||
<template #default="{ item }">
|
||||
<DownloadingCard :info="item" :downloader-name="props.name" />
|
||||
</template>
|
||||
</ProgressiveCardGrid>
|
||||
<NoDataFound
|
||||
v-if="filteredDataList.length === 0 && isRefreshed"
|
||||
error-code="404"
|
||||
|
||||
@@ -107,6 +107,8 @@ const notificationTime = ref({
|
||||
end: '23:59',
|
||||
})
|
||||
|
||||
const wechatClawBotRenameMap = ref<Record<string, string>>({})
|
||||
|
||||
// 添加通知渠道
|
||||
function addNotification(notification: string) {
|
||||
let name = `${t('setting.notification.channel')}${notifications.value.length + 1}`
|
||||
@@ -127,11 +129,52 @@ function removeNotification(notification: NotificationConf) {
|
||||
if (index > -1) notifications.value.splice(index, 1)
|
||||
}
|
||||
|
||||
function trackWechatClawBotRename(oldName: string, newName: string) {
|
||||
if (!oldName || !newName || oldName === newName) {
|
||||
return
|
||||
}
|
||||
const renameMap = { ...wechatClawBotRenameMap.value }
|
||||
for (const [source, target] of Object.entries(renameMap)) {
|
||||
if (target === oldName) {
|
||||
renameMap[source] = newName
|
||||
}
|
||||
}
|
||||
if (renameMap[oldName]) {
|
||||
renameMap[oldName] = newName
|
||||
} else {
|
||||
renameMap[oldName] = newName
|
||||
}
|
||||
wechatClawBotRenameMap.value = Object.fromEntries(
|
||||
Object.entries(renameMap).filter(([source, target]) => source && target && source !== target),
|
||||
)
|
||||
}
|
||||
|
||||
async function migrateWechatClawBotRenames() {
|
||||
const activeWechatClawBotNames = new Set(
|
||||
notifications.value.filter(item => item.type === 'wechatclawbot').map(item => item.name),
|
||||
)
|
||||
const renameEntries = Object.entries(wechatClawBotRenameMap.value).filter(
|
||||
([oldName, newName]) => oldName && newName && oldName !== newName && activeWechatClawBotNames.has(newName),
|
||||
)
|
||||
for (const [oldName, newName] of renameEntries) {
|
||||
const result: { [key: string]: any } = await api.post('notification/wechatclawbot/migrate', null, {
|
||||
params: {
|
||||
old_source: oldName,
|
||||
new_source: newName,
|
||||
},
|
||||
})
|
||||
if (!result.success) {
|
||||
throw new Error(result.message || `failed to migrate ${oldName} -> ${newName}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 调用API查询通知渠道设置
|
||||
async function loadNotificationSetting() {
|
||||
try {
|
||||
const result: { [key: string]: any } = await api.get('system/setting/Notifications')
|
||||
notifications.value = result.data?.value ?? []
|
||||
wechatClawBotRenameMap.value = {}
|
||||
} catch (error) {
|
||||
console.log(error)
|
||||
}
|
||||
@@ -187,12 +230,15 @@ async function loadNotificationTime() {
|
||||
// 调用API保存通知设置
|
||||
async function saveNotificationSetting() {
|
||||
try {
|
||||
await migrateWechatClawBotRenames()
|
||||
const result: { [key: string]: any } = await api.post('system/setting/Notifications', notifications.value)
|
||||
if (result.success) {
|
||||
wechatClawBotRenameMap.value = {}
|
||||
$toast.success(t('setting.notification.saveSuccess'))
|
||||
} else $toast.error(t('setting.notification.saveFailed'))
|
||||
} catch (error) {
|
||||
console.log(error)
|
||||
$toast.error(t('setting.notification.saveFailed'))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -211,7 +257,13 @@ async function saveNotificationTime() {
|
||||
// 通知渠道设置变化时赋值
|
||||
function changNotificationSetting(notification: NotificationConf, name: string) {
|
||||
const index = notifications.value.findIndex(item => item.name === name)
|
||||
if (index !== -1) notifications.value[index] = notification
|
||||
if (index !== -1) {
|
||||
const previous = notifications.value[index]
|
||||
notifications.value[index] = notification
|
||||
if (previous?.type === 'wechatclawbot' && previous.name !== notification.name) {
|
||||
trackWechatClawBotRename(previous.name, notification.name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 加载消息类型开关
|
||||
@@ -299,12 +351,18 @@ onMounted(() => {
|
||||
<VIcon icon="mdi-plus" />
|
||||
<VMenu :activator="'parent'" :close-on-content-click="true">
|
||||
<VList>
|
||||
<VListItem @click="addNotification('wechat')">
|
||||
<VListItemTitle>{{ t('setting.notification.wechat') }}</VListItemTitle>
|
||||
</VListItem>
|
||||
<VListItem @click="addNotification('telegram')">
|
||||
<VListItemTitle>{{ t('setting.notification.telegram') }}</VListItemTitle>
|
||||
</VListItem>
|
||||
<VListItem @click="addNotification('wechat')">
|
||||
<VListItemTitle>{{ t('setting.notification.wechat') }}</VListItemTitle>
|
||||
</VListItem>
|
||||
<VListItem @click="addNotification('wechatclawbot')">
|
||||
<VListItemTitle>{{ t('setting.notification.wechatClawBot') }}</VListItemTitle>
|
||||
</VListItem>
|
||||
<VListItem @click="addNotification('feishu')">
|
||||
<VListItemTitle>{{ t('setting.notification.feishu') }}</VListItemTitle>
|
||||
</VListItem>
|
||||
<VListItem @click="addNotification('telegram')">
|
||||
<VListItemTitle>{{ t('setting.notification.telegram') }}</VListItemTitle>
|
||||
</VListItem>
|
||||
<VListItem @click="addNotification('slack')">
|
||||
<VListItemTitle>{{ t('setting.notification.slack') }}</VListItemTitle>
|
||||
</VListItem>
|
||||
|
||||
@@ -46,6 +46,7 @@ const SystemSettings = ref<any>({
|
||||
LLM_SUPPORT_AUDIO_INPUT_OUTPUT: false,
|
||||
LLM_API_KEY: null,
|
||||
LLM_BASE_URL: 'https://api.deepseek.com',
|
||||
LLM_BASE_URL_PRESET: null,
|
||||
AI_VOICE_API_KEY: null,
|
||||
AI_VOICE_BASE_URL: null,
|
||||
AI_VOICE_STT_MODEL: 'gpt-4o-mini-transcribe',
|
||||
@@ -71,8 +72,14 @@ const SystemSettings = ref<any>({
|
||||
DB_WAL_ENABLE: false,
|
||||
AUTO_UPDATE_RESOURCE: true,
|
||||
MOVIEPILOT_AUTO_UPDATE: false,
|
||||
DATA_CLEANUP_ENABLE: false,
|
||||
DATA_CLEANUP_MESSAGE_DAYS: 90,
|
||||
DATA_CLEANUP_DOWNLOAD_HISTORY_DAYS: 180,
|
||||
DATA_CLEANUP_SITE_USERDATA_DAYS: 180,
|
||||
DATA_CLEANUP_TRANSFER_HISTORY_DAYS: 365 * 3,
|
||||
// 媒体
|
||||
RECOGNIZE_PLUGIN_FIRST: false,
|
||||
MEDIA_RECOGNIZE_SHARE: true,
|
||||
TMDB_API_DOMAIN: null,
|
||||
TMDB_IMAGE_DOMAIN: null,
|
||||
TMDB_LOCALE: null,
|
||||
@@ -179,6 +186,7 @@ type LlmSettingsSnapshot = {
|
||||
LLM_THINKING_LEVEL: string
|
||||
LLM_API_KEY: string
|
||||
LLM_BASE_URL: string
|
||||
LLM_BASE_URL_PRESET: string
|
||||
}
|
||||
|
||||
let llmTestRequestId = 0
|
||||
@@ -205,6 +213,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({
|
||||
get: () => String(SystemSettings.value.Basic.LLM_MODEL ?? ''),
|
||||
set: value => {
|
||||
@@ -221,6 +236,7 @@ const llmMaxContextRef = computed({
|
||||
|
||||
const {
|
||||
providerItems: llmProviderItems,
|
||||
baseUrlPresetItems: llmBaseUrlPresetItems,
|
||||
models: llmModels,
|
||||
selectedProvider: selectedLlmProvider,
|
||||
selectedModel: selectedLlmModel,
|
||||
@@ -230,6 +246,7 @@ const {
|
||||
showBaseUrlField,
|
||||
showApiKeyField,
|
||||
canRefreshModels,
|
||||
setBaseUrlPreset,
|
||||
authDialogVisible,
|
||||
authPolling,
|
||||
authPopupBlocked,
|
||||
@@ -247,6 +264,7 @@ const {
|
||||
provider: llmProviderRef,
|
||||
apiKey: llmApiKeyRef,
|
||||
baseUrl: llmBaseUrlRef,
|
||||
baseUrlPreset: llmBaseUrlPresetRef,
|
||||
model: llmModelRef,
|
||||
maxContextTokens: llmMaxContextRef,
|
||||
})
|
||||
@@ -259,6 +277,7 @@ function buildLlmSnapshot(): LlmSettingsSnapshot {
|
||||
LLM_THINKING_LEVEL: String(SystemSettings.value.Basic.LLM_THINKING_LEVEL ?? 'off'),
|
||||
LLM_API_KEY: String(SystemSettings.value.Basic.LLM_API_KEY ?? ''),
|
||||
LLM_BASE_URL: String(SystemSettings.value.Basic.LLM_BASE_URL ?? ''),
|
||||
LLM_BASE_URL_PRESET: String(SystemSettings.value.Basic.LLM_BASE_URL_PRESET ?? ''),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -274,6 +293,7 @@ function buildLlmTestPayload(snapshot: LlmSettingsSnapshot) {
|
||||
thinking_level: snapshot.LLM_THINKING_LEVEL.trim(),
|
||||
api_key: snapshot.LLM_API_KEY.trim(),
|
||||
base_url: snapshot.LLM_BASE_URL.trim(),
|
||||
base_url_preset: snapshot.LLM_BASE_URL_PRESET.trim(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -391,6 +411,11 @@ const logLevelItems = [
|
||||
{ title: t('setting.system.logLevelItems.critical'), value: 'CRITICAL' },
|
||||
]
|
||||
|
||||
const dataCleanupFieldRules = [
|
||||
(v: any) => v === 0 || !!v || t('setting.system.dataCleanupDaysRequired'),
|
||||
(v: any) => v >= 0 || t('setting.system.dataCleanupDaysMin'),
|
||||
]
|
||||
|
||||
// 安全域名添加变量
|
||||
const newSecurityDomain = ref('')
|
||||
|
||||
@@ -525,9 +550,6 @@ async function loadSystemSettings() {
|
||||
}
|
||||
SystemSettings.value.Basic.LLM_THINKING_LEVEL = resolveThinkingLevelValue(result.data)
|
||||
await loadLlmProviders()
|
||||
if (SystemSettings.value.Basic.AI_AGENT_ENABLE && canRefreshModels.value) {
|
||||
await refreshLlmModels(false)
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.log(error)
|
||||
@@ -1003,7 +1025,7 @@ watch(currentLlmSnapshotKey, (snapshotKey, previousSnapshotKey) => {
|
||||
/>
|
||||
</VCol>
|
||||
<VCol v-if="SystemSettings.Basic.AI_AGENT_ENABLE" cols="12" md="6">
|
||||
<VSelect
|
||||
<VAutocomplete
|
||||
v-model="SystemSettings.Basic.LLM_PROVIDER"
|
||||
:label="t('setting.system.llmProvider')"
|
||||
:hint="t('setting.system.llmProviderHint')"
|
||||
@@ -1015,14 +1037,30 @@ watch(currentLlmSnapshotKey, (snapshotKey, previousSnapshotKey) => {
|
||||
/>
|
||||
</VCol>
|
||||
<VCol v-if="SystemSettings.Basic.AI_AGENT_ENABLE && showBaseUrlField" cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="SystemSettings.Basic.LLM_BASE_URL"
|
||||
<VCombobox
|
||||
:model-value="SystemSettings.Basic.LLM_BASE_URL"
|
||||
@update:model-value="
|
||||
(value: any) => {
|
||||
if (typeof value === 'object' && value !== null) {
|
||||
setBaseUrlPreset(value.id, value.value)
|
||||
} else {
|
||||
setBaseUrlPreset('', value || '')
|
||||
}
|
||||
}
|
||||
"
|
||||
:label="t('setting.system.llmBaseUrl')"
|
||||
:hint="t('setting.system.llmBaseUrlHint')"
|
||||
placeholder="https://api.deepseek.com"
|
||||
:placeholder="selectedLlmProvider?.default_base_url || 'https://api.deepseek.com'"
|
||||
:items="llmBaseUrlPresetItems"
|
||||
item-title="title"
|
||||
item-value="value"
|
||||
persistent-hint
|
||||
prepend-inner-icon="mdi-link"
|
||||
/>
|
||||
>
|
||||
<template #item="{ props, item }">
|
||||
<VListItem v-bind="props" :subtitle="item.raw.subtitle" />
|
||||
</template>
|
||||
</VCombobox>
|
||||
</VCol>
|
||||
<VCol v-if="SystemSettings.Basic.AI_AGENT_ENABLE && showApiKeyField" cols="12" md="6">
|
||||
<VTextField
|
||||
@@ -1035,10 +1073,7 @@ watch(currentLlmSnapshotKey, (snapshotKey, previousSnapshotKey) => {
|
||||
prepend-inner-icon="mdi-key-variant"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol
|
||||
v-if="SystemSettings.Basic.AI_AGENT_ENABLE && llmProviderAuthMethods.length > 0"
|
||||
cols="12"
|
||||
>
|
||||
<VCol v-if="SystemSettings.Basic.AI_AGENT_ENABLE && llmProviderAuthMethods.length > 0" cols="12">
|
||||
<VAlert type="info" variant="tonal">
|
||||
<div class="d-flex flex-column flex-md-row justify-space-between ga-3">
|
||||
<div>
|
||||
@@ -1047,7 +1082,11 @@ watch(currentLlmSnapshotKey, (snapshotKey, previousSnapshotKey) => {
|
||||
{{ selectedLlmProvider?.description || t('setting.system.llmProviderAuthHint') }}
|
||||
</div>
|
||||
<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>
|
||||
|
||||
@@ -1080,10 +1119,12 @@ watch(currentLlmSnapshotKey, (snapshotKey, previousSnapshotKey) => {
|
||||
<div>
|
||||
<VCombobox
|
||||
:model-value="SystemSettings.Basic.LLM_MODEL"
|
||||
@update:model-value="(val: any) => {
|
||||
SystemSettings.Basic.LLM_MODEL = typeof val === 'object' && val !== null ? val.id : val;
|
||||
handleLlmModelChanged();
|
||||
}"
|
||||
@update:model-value="
|
||||
(val: any) => {
|
||||
SystemSettings.Basic.LLM_MODEL = typeof val === 'object' && val !== null ? val.id : val
|
||||
handleLlmModelChanged()
|
||||
}
|
||||
"
|
||||
:label="t('setting.system.llmModel')"
|
||||
:hint="t('setting.system.llmModelHint')"
|
||||
:placeholder="t('setting.system.llmModelHint')"
|
||||
@@ -1486,6 +1527,9 @@ watch(currentLlmSnapshotKey, (snapshotKey, previousSnapshotKey) => {
|
||||
<VTab value="network">
|
||||
<div>{{ t('setting.system.network') }}</div>
|
||||
</VTab>
|
||||
<VTab value="data">
|
||||
<div>{{ t('setting.system.data') }}</div>
|
||||
</VTab>
|
||||
<VTab value="log">
|
||||
<div>{{ t('setting.system.log') }}</div>
|
||||
</VTab>
|
||||
@@ -1644,6 +1688,26 @@ watch(currentLlmSnapshotKey, (snapshotKey, previousSnapshotKey) => {
|
||||
persistent-hint
|
||||
/>
|
||||
</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">
|
||||
<VSwitch
|
||||
v-model="SystemSettings.Advanced.FANART_ENABLE"
|
||||
@@ -1666,16 +1730,6 @@ watch(currentLlmSnapshotKey, (snapshotKey, previousSnapshotKey) => {
|
||||
/>
|
||||
</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>
|
||||
</VRow>
|
||||
|
||||
<!-- 刮削开关设置 -->
|
||||
<VRow class="mt-4">
|
||||
@@ -1867,6 +1921,74 @@ watch(currentLlmSnapshotKey, (snapshotKey, previousSnapshotKey) => {
|
||||
</VRow>
|
||||
</div>
|
||||
</VWindowItem>
|
||||
<VWindowItem value="data">
|
||||
<div>
|
||||
<VRow>
|
||||
<VCol cols="12">
|
||||
<VSwitch
|
||||
v-model="SystemSettings.Advanced.DATA_CLEANUP_ENABLE"
|
||||
:label="t('setting.system.dataCleanupEnable')"
|
||||
:hint="t('setting.system.dataCleanupEnableHint')"
|
||||
persistent-hint
|
||||
/>
|
||||
</VCol>
|
||||
<template v-if="SystemSettings.Advanced.DATA_CLEANUP_ENABLE">
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model.number="SystemSettings.Advanced.DATA_CLEANUP_MESSAGE_DAYS"
|
||||
:label="t('setting.system.dataCleanupMessageDays')"
|
||||
:hint="t('setting.system.dataCleanupMessageDaysHint')"
|
||||
persistent-hint
|
||||
min="0"
|
||||
type="number"
|
||||
:suffix="t('setting.system.day')"
|
||||
:rules="dataCleanupFieldRules"
|
||||
prepend-inner-icon="mdi-email-outline"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model.number="SystemSettings.Advanced.DATA_CLEANUP_DOWNLOAD_HISTORY_DAYS"
|
||||
:label="t('setting.system.dataCleanupDownloadHistoryDays')"
|
||||
:hint="t('setting.system.dataCleanupDownloadHistoryDaysHint')"
|
||||
persistent-hint
|
||||
min="0"
|
||||
type="number"
|
||||
:suffix="t('setting.system.day')"
|
||||
:rules="dataCleanupFieldRules"
|
||||
prepend-inner-icon="mdi-download-circle-outline"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model.number="SystemSettings.Advanced.DATA_CLEANUP_SITE_USERDATA_DAYS"
|
||||
:label="t('setting.system.dataCleanupSiteUserDataDays')"
|
||||
:hint="t('setting.system.dataCleanupSiteUserDataDaysHint')"
|
||||
persistent-hint
|
||||
min="0"
|
||||
type="number"
|
||||
:suffix="t('setting.system.day')"
|
||||
:rules="dataCleanupFieldRules"
|
||||
prepend-inner-icon="mdi-chart-line"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model.number="SystemSettings.Advanced.DATA_CLEANUP_TRANSFER_HISTORY_DAYS"
|
||||
:label="t('setting.system.dataCleanupTransferHistoryDays')"
|
||||
:hint="t('setting.system.dataCleanupTransferHistoryDaysHint')"
|
||||
persistent-hint
|
||||
min="0"
|
||||
type="number"
|
||||
:suffix="t('setting.system.day')"
|
||||
:rules="dataCleanupFieldRules"
|
||||
prepend-inner-icon="mdi-swap-horizontal"
|
||||
/>
|
||||
</VCol>
|
||||
</template>
|
||||
</VRow>
|
||||
</div>
|
||||
</VWindowItem>
|
||||
<VWindowItem value="log">
|
||||
<div>
|
||||
<VRow>
|
||||
@@ -2013,12 +2135,7 @@ watch(currentLlmSnapshotKey, (snapshotKey, previousSnapshotKey) => {
|
||||
<VBtn color="primary" prepend-icon="mdi-open-in-new" @click="openAuthPage">
|
||||
{{ t('setting.system.llmProviderOpenAuthPage') }}
|
||||
</VBtn>
|
||||
<VBtn
|
||||
variant="tonal"
|
||||
prepend-icon="mdi-refresh"
|
||||
:loading="authPolling"
|
||||
@click="pollAuthSession"
|
||||
>
|
||||
<VBtn variant="tonal" prepend-icon="mdi-refresh" :loading="authPolling" @click="pollAuthSession">
|
||||
{{ t('setting.system.llmProviderCheckAuthStatus') }}
|
||||
</VBtn>
|
||||
</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({
|
||||
get: () => wizardData.value.agent.model,
|
||||
set: value => {
|
||||
@@ -53,6 +60,7 @@ const authConnectedRef = computed({
|
||||
|
||||
const {
|
||||
providerItems,
|
||||
baseUrlPresetItems,
|
||||
models: llmModels,
|
||||
selectedProvider,
|
||||
selectedModel,
|
||||
@@ -62,6 +70,7 @@ const {
|
||||
showBaseUrlField,
|
||||
showApiKeyField,
|
||||
canRefreshModels,
|
||||
setBaseUrlPreset,
|
||||
authDialogVisible,
|
||||
authPolling,
|
||||
authPopupBlocked,
|
||||
@@ -79,6 +88,7 @@ const {
|
||||
provider: providerRef,
|
||||
apiKey: apiKeyRef,
|
||||
baseUrl: baseUrlRef,
|
||||
baseUrlPreset: baseUrlPresetRef,
|
||||
model: modelRef,
|
||||
maxContextTokens: maxContextTokensRef,
|
||||
authConnected: authConnectedRef,
|
||||
@@ -213,7 +223,7 @@ onMounted(async () => {
|
||||
</VCol>
|
||||
|
||||
<VCol cols="12" md="6">
|
||||
<VSelect
|
||||
<VAutocomplete
|
||||
v-model="wizardData.agent.provider"
|
||||
:label="t('setting.system.llmProvider')"
|
||||
:hint="t('setting.system.llmProviderHint')"
|
||||
@@ -228,14 +238,28 @@ onMounted(async () => {
|
||||
</VCol>
|
||||
|
||||
<VCol v-if="showBaseUrlField" cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="wizardData.agent.baseUrl"
|
||||
<VCombobox
|
||||
:model-value="wizardData.agent.baseUrl"
|
||||
@update:model-value="(value: any) => {
|
||||
if (typeof value === 'object' && value !== null) {
|
||||
setBaseUrlPreset(value.id, value.value);
|
||||
} else {
|
||||
setBaseUrlPreset('', value || '');
|
||||
}
|
||||
}"
|
||||
:label="t('setting.system.llmBaseUrl')"
|
||||
:hint="t('setting.system.llmBaseUrlHint')"
|
||||
placeholder="https://api.deepseek.com"
|
||||
:placeholder="selectedProvider?.default_base_url || 'https://api.deepseek.com'"
|
||||
:items="baseUrlPresetItems"
|
||||
item-title="title"
|
||||
item-value="value"
|
||||
persistent-hint
|
||||
prepend-inner-icon="mdi-link-variant"
|
||||
/>
|
||||
>
|
||||
<template #item="{ props, item }">
|
||||
<VListItem v-bind="props" :subtitle="item.raw.subtitle" />
|
||||
</template>
|
||||
</VCombobox>
|
||||
</VCol>
|
||||
|
||||
<VCol v-if="showApiKeyField" cols="12" md="6">
|
||||
|
||||
@@ -104,6 +104,17 @@ const { wizardData, selectDownloader, validationErrors } = useSetupWizard()
|
||||
required
|
||||
/>
|
||||
</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">
|
||||
<VTextField
|
||||
v-model="wizardData.downloader.config.username"
|
||||
@@ -111,10 +122,11 @@ const { wizardData, selectDownloader, validationErrors } = useSetupWizard()
|
||||
:hint="t('downloader.username')"
|
||||
:error="validationErrors.downloader.username"
|
||||
:error-messages="validationErrors.downloader.username ? [t('downloader.usernameRequired')] : []"
|
||||
:disabled="!!wizardData.downloader.config.apikey"
|
||||
persistent-hint
|
||||
active
|
||||
prepend-inner-icon="mdi-account"
|
||||
required
|
||||
:required="!wizardData.downloader.config.apikey"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
@@ -125,10 +137,11 @@ const { wizardData, selectDownloader, validationErrors } = useSetupWizard()
|
||||
:hint="t('downloader.password')"
|
||||
:error="validationErrors.downloader.password"
|
||||
:error-messages="validationErrors.downloader.password ? [t('downloader.passwordRequired')] : []"
|
||||
:disabled="!!wizardData.downloader.config.apikey"
|
||||
persistent-hint
|
||||
active
|
||||
prepend-inner-icon="mdi-lock"
|
||||
required
|
||||
:required="!wizardData.downloader.config.apikey"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
|
||||
@@ -122,6 +122,19 @@ watch(
|
||||
</VCardText>
|
||||
</VCard>
|
||||
</VCol>
|
||||
<VCol cols="12" md="3">
|
||||
<VCard
|
||||
:color="wizardData.mediaServer.type === 'zspace' ? 'primary' : 'default'"
|
||||
:variant="wizardData.mediaServer.type === 'zspace' ? 'tonal' : 'outlined'"
|
||||
class="cursor-pointer"
|
||||
@click="selectMediaServerWithLibrary('zspace')"
|
||||
>
|
||||
<VCardText class="text-center">
|
||||
<VImg :src="getLogoUrl('zspace')" height="48" width="48" class="mx-auto mb-2" />
|
||||
<div class="text-h6">极影视</div>
|
||||
</VCardText>
|
||||
</VCard>
|
||||
</VCol>
|
||||
<VCol cols="12" md="3">
|
||||
<VCard
|
||||
:color="wizardData.mediaServer.type === 'jellyfin' ? 'primary' : 'default'"
|
||||
@@ -263,6 +276,89 @@ watch(
|
||||
/>
|
||||
</VCol>
|
||||
</VRow>
|
||||
<VRow v-else-if="wizardData.mediaServer.type === 'zspace'">
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="wizardData.mediaServer.name"
|
||||
:label="t('common.name')"
|
||||
:placeholder="t('mediaserver.nameRequired')"
|
||||
:hint="t('mediaserver.serverAlias')"
|
||||
:error="validationErrors.mediaServer.name"
|
||||
:error-messages="validationErrors.mediaServer.name ? [t('mediaserver.nameRequired')] : []"
|
||||
persistent-hint
|
||||
active
|
||||
prepend-inner-icon="mdi-label"
|
||||
required
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="wizardData.mediaServer.config.host"
|
||||
:label="t('mediaserver.host')"
|
||||
:placeholder="t('mediaserver.hostPlaceholder')"
|
||||
:hint="t('mediaserver.hostHint')"
|
||||
:error="validationErrors.mediaServer.host"
|
||||
:error-messages="validationErrors.mediaServer.host ? [t('mediaserver.hostRequired')] : []"
|
||||
persistent-hint
|
||||
active
|
||||
prepend-inner-icon="mdi-server"
|
||||
required
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12">
|
||||
<VTextField
|
||||
v-model="wizardData.mediaServer.config.play_host"
|
||||
:label="t('mediaserver.playHost')"
|
||||
:placeholder="t('mediaserver.playHostPlaceholder')"
|
||||
:hint="t('mediaserver.playHostHint')"
|
||||
persistent-hint
|
||||
active
|
||||
prepend-inner-icon="mdi-play-network"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="wizardData.mediaServer.config.username"
|
||||
:label="t('mediaserver.username')"
|
||||
:hint="t('mediaserver.usernameHint')"
|
||||
:error="validationErrors.mediaServer.username"
|
||||
:error-messages="validationErrors.mediaServer.username ? [t('mediaserver.usernameRequired')] : []"
|
||||
persistent-hint
|
||||
active
|
||||
prepend-inner-icon="mdi-account"
|
||||
required
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
type="password"
|
||||
v-model="wizardData.mediaServer.config.password"
|
||||
:label="t('mediaserver.password')"
|
||||
:error="validationErrors.mediaServer.password"
|
||||
:error-messages="validationErrors.mediaServer.password ? [t('mediaserver.passwordRequired')] : []"
|
||||
persistent-hint
|
||||
active
|
||||
prepend-inner-icon="mdi-lock"
|
||||
required
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12">
|
||||
<VAutocomplete
|
||||
v-model="wizardData.mediaServer.sync_libraries"
|
||||
:label="t('mediaserver.syncLibraries')"
|
||||
:items="librariesOptions"
|
||||
chips
|
||||
multiple
|
||||
clearable
|
||||
:hint="t('mediaserver.syncLibrariesHint')"
|
||||
persistent-hint
|
||||
active
|
||||
append-inner-icon="mdi-refresh"
|
||||
prepend-inner-icon="mdi-library"
|
||||
@click:append-inner="loadLibrary(wizardData.mediaServer.name)"
|
||||
/>
|
||||
</VCol>
|
||||
</VRow>
|
||||
<VRow v-else-if="wizardData.mediaServer.type === 'jellyfin'">
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
|
||||
@@ -49,7 +49,33 @@ const notificationTypes = [
|
||||
>
|
||||
<VCardText class="text-center">
|
||||
<VImg :src="getLogoUrl('wechat')" height="48" width="48" class="mx-auto mb-2" />
|
||||
<div class="text-h6">微信</div>
|
||||
<div class="text-h6">企业微信</div>
|
||||
</VCardText>
|
||||
</VCard>
|
||||
</VCol>
|
||||
<VCol cols="12" md="3">
|
||||
<VCard
|
||||
:color="wizardData.notification.type === 'wechatclawbot' ? 'primary' : 'default'"
|
||||
:variant="wizardData.notification.type === 'wechatclawbot' ? 'tonal' : 'outlined'"
|
||||
class="cursor-pointer"
|
||||
@click="selectNotification('wechatclawbot')"
|
||||
>
|
||||
<VCardText class="text-center">
|
||||
<VImg :src="getLogoUrl('wechatclawbot')" height="48" width="48" class="mx-auto mb-2" />
|
||||
<div class="text-h6">WeChat ClawBot</div>
|
||||
</VCardText>
|
||||
</VCard>
|
||||
</VCol>
|
||||
<VCol cols="12" md="3">
|
||||
<VCard
|
||||
:color="wizardData.notification.type === 'feishu' ? 'primary' : 'default'"
|
||||
:variant="wizardData.notification.type === 'feishu' ? 'tonal' : 'outlined'"
|
||||
class="cursor-pointer"
|
||||
@click="selectNotification('feishu')"
|
||||
>
|
||||
<VCardText class="text-center">
|
||||
<VImg :src="getLogoUrl('feishu')" height="48" width="48" class="mx-auto mb-2" />
|
||||
<div class="text-h6">飞书</div>
|
||||
</VCardText>
|
||||
</VCard>
|
||||
</VCol>
|
||||
@@ -251,6 +277,137 @@ const notificationTypes = [
|
||||
/>
|
||||
</VCol>
|
||||
</VRow>
|
||||
<VRow v-else-if="wizardData.notification.type === 'wechatclawbot'">
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="wizardData.notification.name"
|
||||
:label="t('notification.name')"
|
||||
:placeholder="t('notification.name')"
|
||||
:hint="t('notification.nameHint')"
|
||||
:error="validationErrors.notification.name"
|
||||
:error-messages="validationErrors.notification.name ? [t('notification.nameRequired')] : []"
|
||||
persistent-hint
|
||||
prepend-inner-icon="mdi-label"
|
||||
required
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="wizardData.notification.config.WECHATCLAWBOT_BASE_URL"
|
||||
:label="t('notification.wechatclawbot.baseUrl')"
|
||||
:hint="t('notification.wechatclawbot.baseUrlHint')"
|
||||
persistent-hint
|
||||
prepend-inner-icon="mdi-web"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="wizardData.notification.config.WECHATCLAWBOT_DEFAULT_TARGET"
|
||||
:label="t('notification.wechatclawbot.defaultTarget')"
|
||||
:placeholder="t('notification.wechatclawbot.defaultTargetPlaceholder')"
|
||||
:hint="t('notification.wechatclawbot.defaultTargetHint')"
|
||||
persistent-hint
|
||||
prepend-inner-icon="mdi-account-arrow-right"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="wizardData.notification.config.WECHATCLAWBOT_ADMINS"
|
||||
:label="t('notification.wechatclawbot.admins')"
|
||||
:placeholder="t('notification.wechatclawbot.adminsPlaceholder')"
|
||||
:hint="t('notification.wechatclawbot.adminsHint')"
|
||||
persistent-hint
|
||||
prepend-inner-icon="mdi-account-supervisor"
|
||||
/>
|
||||
</VCol>
|
||||
</VRow>
|
||||
<VRow v-else-if="wizardData.notification.type === 'feishu'">
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="wizardData.notification.name"
|
||||
:label="t('notification.name')"
|
||||
:placeholder="t('notification.name')"
|
||||
:hint="t('notification.nameHint')"
|
||||
:error="validationErrors.notification.name"
|
||||
:error-messages="validationErrors.notification.name ? [t('notification.nameRequired')] : []"
|
||||
persistent-hint
|
||||
prepend-inner-icon="mdi-label"
|
||||
required
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="wizardData.notification.config.FEISHU_APP_ID"
|
||||
:label="t('notification.feishu.appId')"
|
||||
:hint="t('notification.feishu.appIdHint')"
|
||||
:error="validationErrors.notification.FEISHU_APP_ID"
|
||||
:error-messages="validationErrors.notification.FEISHU_APP_ID ? [t('notification.feishu.appIdRequired')] : []"
|
||||
persistent-hint
|
||||
prepend-inner-icon="mdi-application"
|
||||
required
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="wizardData.notification.config.FEISHU_APP_SECRET"
|
||||
:label="t('notification.feishu.appSecret')"
|
||||
:hint="t('notification.feishu.appSecretHint')"
|
||||
:error="validationErrors.notification.FEISHU_APP_SECRET"
|
||||
:error-messages="validationErrors.notification.FEISHU_APP_SECRET ? [t('notification.feishu.appSecretRequired')] : []"
|
||||
persistent-hint
|
||||
prepend-inner-icon="mdi-key"
|
||||
required
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="wizardData.notification.config.FEISHU_OPEN_ID"
|
||||
:label="t('notification.feishu.openId')"
|
||||
:placeholder="t('notification.feishu.openIdPlaceholder')"
|
||||
:hint="t('notification.feishu.openIdHint')"
|
||||
persistent-hint
|
||||
prepend-inner-icon="mdi-account"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="wizardData.notification.config.FEISHU_CHAT_ID"
|
||||
:label="t('notification.feishu.chatId')"
|
||||
:placeholder="t('notification.feishu.chatIdPlaceholder')"
|
||||
:hint="t('notification.feishu.chatIdHint')"
|
||||
persistent-hint
|
||||
prepend-inner-icon="mdi-chat-processing"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="wizardData.notification.config.FEISHU_ADMINS"
|
||||
:label="t('notification.feishu.admins')"
|
||||
:placeholder="t('notification.feishu.adminsPlaceholder')"
|
||||
:hint="t('notification.feishu.adminsHint')"
|
||||
persistent-hint
|
||||
prepend-inner-icon="mdi-account-supervisor"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="wizardData.notification.config.FEISHU_VERIFICATION_TOKEN"
|
||||
:label="t('notification.feishu.verificationToken')"
|
||||
:hint="t('notification.feishu.verificationTokenHint')"
|
||||
persistent-hint
|
||||
prepend-inner-icon="mdi-shield-key"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="wizardData.notification.config.FEISHU_ENCRYPT_KEY"
|
||||
:label="t('notification.feishu.encryptKey')"
|
||||
:hint="t('notification.feishu.encryptKeyHint')"
|
||||
persistent-hint
|
||||
prepend-inner-icon="mdi-lock"
|
||||
/>
|
||||
</VCol>
|
||||
</VRow>
|
||||
<VRow v-else-if="wizardData.notification.type === 'telegram'">
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
|
||||
@@ -7,7 +7,7 @@ import NoDataFound from '@/components/NoDataFound.vue'
|
||||
import SiteAddEditDialog from '@/components/dialog/SiteAddEditDialog.vue'
|
||||
import SiteStatisticsDialog from '@/components/dialog/SiteStatisticsDialog.vue'
|
||||
import SiteImportDialog from '@/components/dialog/SiteImportDialog.vue'
|
||||
import { useDisplay } from 'vuetify'
|
||||
import ProgressiveCardGrid from '@/components/misc/ProgressiveCardGrid.vue'
|
||||
import { useDynamicButton } from '@/composables/useDynamicButton'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { usePWA } from '@/composables/usePWA'
|
||||
@@ -22,9 +22,7 @@ const $toast = useToast()
|
||||
// 路由
|
||||
const route = useRoute()
|
||||
|
||||
// APP
|
||||
const display = useDisplay()
|
||||
// PWA模式检测
|
||||
// APP 模式检测
|
||||
const { appMode } = usePWA()
|
||||
|
||||
// 站点列表
|
||||
@@ -50,6 +48,7 @@ const siteStatsDialog = ref(false)
|
||||
|
||||
// 导入站点对话框
|
||||
const siteImportDialog = ref(false)
|
||||
const sortMode = ref(false)
|
||||
|
||||
// 筛选相关
|
||||
const filterMenu = ref(false)
|
||||
@@ -96,6 +95,21 @@ const draggableSiteList = computed({
|
||||
},
|
||||
})
|
||||
|
||||
const siteUserDataMap = computed<Record<string, SiteUserData | undefined>>(() => {
|
||||
const map: Record<string, SiteUserData | undefined> = {}
|
||||
|
||||
userDataList.value.forEach(userData => {
|
||||
if (userData.domain) {
|
||||
map[userData.domain] = userData
|
||||
}
|
||||
})
|
||||
|
||||
return map
|
||||
})
|
||||
|
||||
const canDragSort = computed(() => sortMode.value && filterOption.value === 'all')
|
||||
const shouldVirtualizeList = computed(() => !sortMode.value)
|
||||
|
||||
// 当前筛选选项的显示信息
|
||||
const currentFilter = computed(() => {
|
||||
return filterOptions.value.find(option => option.value === filterOption.value)
|
||||
@@ -106,12 +120,13 @@ async function fetchData() {
|
||||
try {
|
||||
loading.value = true
|
||||
siteList.value = await api.get('site/')
|
||||
loading.value = false
|
||||
isRefreshed.value = true
|
||||
// 获取站点列表后,获取统计数据
|
||||
await fetchSiteStats()
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -182,16 +197,6 @@ async function savaSitesPriority() {
|
||||
}
|
||||
}
|
||||
|
||||
// 根据站点ID获取站点数据
|
||||
function getUserData(domain: string) {
|
||||
return userDataList.value.find(userData => userData.domain === domain)
|
||||
}
|
||||
|
||||
// 根据站点域名获取统计数据
|
||||
function getSiteStats(domain: string) {
|
||||
return siteStatsList.value[domain] || {}
|
||||
}
|
||||
|
||||
// 处理站点统计数据刷新请求
|
||||
async function handleRefreshStats(domain?: string) {
|
||||
if (domain) {
|
||||
@@ -220,6 +225,22 @@ function selectFilter(value: string) {
|
||||
filterMenu.value = false
|
||||
}
|
||||
|
||||
function toggleSortMode() {
|
||||
sortMode.value = !sortMode.value
|
||||
}
|
||||
|
||||
function openSiteAddDialog() {
|
||||
siteAddDialog.value = true
|
||||
}
|
||||
|
||||
function openSiteImportDialog() {
|
||||
siteImportDialog.value = true
|
||||
}
|
||||
|
||||
function openSiteStatisticsDialog() {
|
||||
siteStatsDialog.value = true
|
||||
}
|
||||
|
||||
// 导出站点数据
|
||||
async function exportSites() {
|
||||
try {
|
||||
@@ -284,60 +305,65 @@ onActivated(() => {
|
||||
}
|
||||
})
|
||||
|
||||
watch(
|
||||
() => filterOption.value,
|
||||
value => {
|
||||
if (value !== 'all' && sortMode.value) {
|
||||
sortMode.value = false
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
const shouldShowFloatingActions = computed(() => route.path === '/site' && isRefreshed.value)
|
||||
|
||||
// App 模式下将站点操作收纳到 Footer 动态菜单中,和插件页保持一致。
|
||||
const siteDynamicMenuItems = computed(() => [
|
||||
{
|
||||
titleKey: 'site.actions.add',
|
||||
icon: 'mdi-web-plus',
|
||||
action: openSiteAddDialog,
|
||||
},
|
||||
{
|
||||
titleKey: 'site.actions.import',
|
||||
icon: 'mdi-import',
|
||||
action: openSiteImportDialog,
|
||||
},
|
||||
{
|
||||
titleKey: 'site.actions.export',
|
||||
icon: 'mdi-export',
|
||||
action: exportSites,
|
||||
},
|
||||
{
|
||||
titleKey: 'site.statistics',
|
||||
icon: 'mdi-chart-line',
|
||||
action: openSiteStatisticsDialog,
|
||||
},
|
||||
])
|
||||
|
||||
// 使用动态按钮钩子
|
||||
useDynamicButton({
|
||||
icon: 'mdi-web-plus',
|
||||
onClick: () => {
|
||||
siteAddDialog.value = true
|
||||
},
|
||||
onClick: openSiteAddDialog,
|
||||
menuItems: siteDynamicMenuItems,
|
||||
show: computed(() => appMode.value && shouldShowFloatingActions.value),
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="card-list-container">
|
||||
<!-- 页面标题和筛选按钮 -->
|
||||
<!-- 页面标题和筛选/排序按钮 -->
|
||||
<div class="d-flex justify-space-between align-center mb-4">
|
||||
<VPageContentTitle :title="t('navItems.siteManager')" class="mb-0" />
|
||||
<!-- 右侧按钮组 -->
|
||||
<div class="d-flex align-center gap-2">
|
||||
<!-- 导入按钮 -->
|
||||
<VBtn :icon="display.smAndDown.value" variant="text" color="success" @click="siteImportDialog = true">
|
||||
<VIcon icon="mdi-import" />
|
||||
<span v-if="!display.smAndDown.value" class="ml-2">
|
||||
{{ t('site.actions.import') }}
|
||||
</span>
|
||||
</VBtn>
|
||||
<!-- 导出按钮 -->
|
||||
<VBtn :icon="display.smAndDown.value" variant="text" color="warning" @click="exportSites">
|
||||
<VIcon icon="mdi-export" />
|
||||
<span v-if="!display.smAndDown.value" class="ml-2">
|
||||
{{ t('site.actions.export') }}
|
||||
</span>
|
||||
</VBtn>
|
||||
<!-- 统计信息按钮 -->
|
||||
<VBtn :icon="display.smAndDown.value" variant="text" color="info" @click="siteStatsDialog = true">
|
||||
<VIcon icon="mdi-chart-line" />
|
||||
<span v-if="!display.smAndDown.value" class="ml-2">
|
||||
{{ t('site.statistics') }}
|
||||
</span>
|
||||
</VBtn>
|
||||
<VPageContentTitle :title="t('navItems.siteManager')" class="my-0" style="margin-block: 0" />
|
||||
<!-- 右侧按钮组:保留筛选和排序,其他页面动作移到 FAB -->
|
||||
<div class="d-flex align-center gap-1">
|
||||
<!-- 筛选按钮 -->
|
||||
<VMenu v-model="filterMenu" offset-y :close-on-content-click="false" location="bottom end">
|
||||
<template #activator="{ props }">
|
||||
<VBtn
|
||||
v-bind="props"
|
||||
:icon="display.smAndDown.value"
|
||||
:variant="filterOption === 'all' ? 'text' : 'tonal'"
|
||||
:color="currentFilter?.color"
|
||||
>
|
||||
<IconBtn v-bind="props" :variant="filterOption === 'all' ? 'text' : 'tonal'" :color="currentFilter?.color">
|
||||
<VIcon :icon="currentFilter?.icon || 'mdi-filter'" />
|
||||
<span v-if="!display.smAndDown.value" class="ml-2">
|
||||
{{ currentFilter?.label }}
|
||||
</span>
|
||||
<VIcon v-if="!display.smAndDown.value" icon="mdi-chevron-down" class="ml-1" />
|
||||
</VBtn>
|
||||
</IconBtn>
|
||||
</template>
|
||||
|
||||
<!-- 筛选菜单 -->
|
||||
<VCard min-width="200">
|
||||
<VList class="px-2">
|
||||
@@ -359,31 +385,62 @@ useDynamicButton({
|
||||
</VList>
|
||||
</VCard>
|
||||
</VMenu>
|
||||
<!-- 排序按钮 -->
|
||||
<IconBtn variant="text" :color="sortMode ? 'warning' : 'gray'" @click="toggleSortMode">
|
||||
<VIcon icon="mdi-sort-variant" />
|
||||
</IconBtn>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<VAlert v-if="sortMode" color="warning" variant="tonal" class="mb-4">
|
||||
<div class="d-flex flex-wrap align-center justify-space-between gap-2">
|
||||
<span>{{ t('common.sortModeHint') }}</span>
|
||||
<VBtn variant="tonal" color="error" @click="sortMode = false">
|
||||
{{ t('common.exit') }}
|
||||
</VBtn>
|
||||
</div>
|
||||
</VAlert>
|
||||
|
||||
<LoadingBanner v-if="!isRefreshed" class="mt-12" />
|
||||
<draggable
|
||||
v-if="draggableSiteList.length > 0"
|
||||
v-if="draggableSiteList.length > 0 && canDragSort"
|
||||
v-model="draggableSiteList"
|
||||
@end="savaSitesPriority"
|
||||
handle=".cursor-move"
|
||||
item-key="id"
|
||||
tag="div"
|
||||
:component-data="{ 'class': 'grid gap-4 grid-site-card px-2' }"
|
||||
:disabled="filterOption !== 'all'"
|
||||
>
|
||||
<template #item="{ element }">
|
||||
<SiteCard
|
||||
:site="element"
|
||||
:data="getUserData(element.domain)"
|
||||
:stats="getSiteStats(element.domain)"
|
||||
:data="siteUserDataMap[element.domain]"
|
||||
:stats="siteStatsList[element.domain] || {}"
|
||||
:sortable="true"
|
||||
@remove="fetchData"
|
||||
@update="fetchData"
|
||||
@refresh-stats="handleRefreshStats"
|
||||
/>
|
||||
</template>
|
||||
</draggable>
|
||||
<ProgressiveCardGrid
|
||||
v-else-if="draggableSiteList.length > 0 && shouldVirtualizeList"
|
||||
:items="draggableSiteList"
|
||||
:get-item-key="item => item.id"
|
||||
:min-item-width="256"
|
||||
class="px-2"
|
||||
>
|
||||
<template #default="{ item }">
|
||||
<SiteCard
|
||||
:site="item"
|
||||
:data="siteUserDataMap[item.domain]"
|
||||
:stats="siteStatsList[item.domain] || {}"
|
||||
:sortable="false"
|
||||
@remove="fetchData"
|
||||
@update="fetchData"
|
||||
@refresh-stats="handleRefreshStats"
|
||||
/>
|
||||
</template>
|
||||
</ProgressiveCardGrid>
|
||||
</div>
|
||||
<NoDataFound
|
||||
v-if="draggableSiteList.length === 0 && isRefreshed"
|
||||
@@ -393,13 +450,37 @@ useDynamicButton({
|
||||
/>
|
||||
<!-- 新增站点按钮 -->
|
||||
<Teleport to="body" v-if="route.path === '/site'">
|
||||
<div v-if="isRefreshed && !appMode" class="compact-fab-stack">
|
||||
<div v-if="shouldShowFloatingActions && !appMode" class="compact-fab-stack">
|
||||
<VFab
|
||||
icon="mdi-chart-line"
|
||||
color="info"
|
||||
variant="tonal"
|
||||
appear
|
||||
class="compact-fab compact-fab--secondary"
|
||||
@click="openSiteStatisticsDialog"
|
||||
/>
|
||||
<VFab
|
||||
icon="mdi-export"
|
||||
color="warning"
|
||||
variant="tonal"
|
||||
appear
|
||||
class="compact-fab compact-fab--secondary"
|
||||
@click="exportSites"
|
||||
/>
|
||||
<VFab
|
||||
icon="mdi-import"
|
||||
color="success"
|
||||
variant="tonal"
|
||||
appear
|
||||
class="compact-fab compact-fab--secondary"
|
||||
@click="openSiteImportDialog"
|
||||
/>
|
||||
<VFab
|
||||
icon="mdi-web-plus"
|
||||
color="primary"
|
||||
appear
|
||||
class="compact-fab compact-fab--primary"
|
||||
@click="siteAddDialog = true"
|
||||
@click="openSiteAddDialog"
|
||||
/>
|
||||
</div>
|
||||
</Teleport>
|
||||
|
||||
@@ -5,6 +5,7 @@ import type { Subscribe } from '@/api/types'
|
||||
import NoDataFound from '@/components/NoDataFound.vue'
|
||||
import SubscribeCard from '@/components/cards/SubscribeCard.vue'
|
||||
import SubscribeHistoryDialog from '@/components/dialog/SubscribeHistoryDialog.vue'
|
||||
import ProgressiveCardGrid from '@/components/misc/ProgressiveCardGrid.vue'
|
||||
import { useUserStore } from '@/stores'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useToast } from 'vue-toastification'
|
||||
@@ -32,8 +33,16 @@ const props = defineProps({
|
||||
subid: String,
|
||||
keyword: String,
|
||||
statusFilter: String,
|
||||
sortMode: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:sortMode': [value: boolean]
|
||||
}>()
|
||||
|
||||
// 是否刷新过
|
||||
let isRefreshed = ref(false)
|
||||
|
||||
@@ -56,6 +65,27 @@ const displayList = ref<Subscribe[]>([])
|
||||
const isBatchMode = ref(false)
|
||||
const selectedSubscribes = ref<number[]>([])
|
||||
|
||||
const normalizedKeyword = computed(() => props.keyword?.trim().toLowerCase() || '')
|
||||
const selectedSubscribesSet = computed(() => new Set(selectedSubscribes.value))
|
||||
const canSortContext = computed(
|
||||
() => !normalizedKeyword.value && (!props.statusFilter || props.statusFilter === 'all') && !isBatchMode.value,
|
||||
)
|
||||
const sortMode = computed({
|
||||
get: () => props.sortMode,
|
||||
set: value => emit('update:sortMode', value),
|
||||
})
|
||||
const canDragSort = computed(() => sortMode.value && canSortContext.value)
|
||||
const shouldVirtualizeList = computed(() => !sortMode.value)
|
||||
const scrollToIndex = computed(() => {
|
||||
if (!props.subid || sortMode.value) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const targetIndex = displayList.value.findIndex(item => item.id.toString() === props.subid?.toString())
|
||||
|
||||
return targetIndex >= 0 ? targetIndex : undefined
|
||||
})
|
||||
|
||||
// 根据订阅数据判断订阅状态
|
||||
function getSubscribeStatus(subscribe: Subscribe) {
|
||||
// 洗版中
|
||||
@@ -95,26 +125,52 @@ function getSubscribeStatus(subscribe: Subscribe) {
|
||||
// API请求键值(计算属性)
|
||||
const orderRequestKey = computed(() => (props.type === '电影' ? 'SubscribeMovieOrder' : 'SubscribeTvOrder'))
|
||||
|
||||
// 监听dataList变化,同步更新displayList
|
||||
watch([dataList, () => props.keyword, () => props.statusFilter], () => {
|
||||
if (superUser)
|
||||
displayList.value = dataList.value.filter(
|
||||
data =>
|
||||
data.type === props.type &&
|
||||
(!props.keyword || data.name.toLowerCase().includes(props.keyword.toLowerCase())) &&
|
||||
(!props.statusFilter || props.statusFilter === 'all' || getSubscribeStatus(data) === props.statusFilter),
|
||||
)
|
||||
else
|
||||
displayList.value = dataList.value.filter(
|
||||
data =>
|
||||
data.type === props.type &&
|
||||
data.username === userName &&
|
||||
(!props.keyword || data.name.toLowerCase().includes(props.keyword.toLowerCase())) &&
|
||||
(!props.statusFilter || props.statusFilter === 'all' || getSubscribeStatus(data) === props.statusFilter),
|
||||
)
|
||||
// 排序
|
||||
sortSubscribeOrder()
|
||||
})
|
||||
// 监听数据和筛选变化,同步更新显示列表
|
||||
watch(
|
||||
[dataList, normalizedKeyword, () => props.statusFilter, orderConfig],
|
||||
() => {
|
||||
const orderIndexMap = new Map(orderConfig.value.map((item, index) => [item.id, index]))
|
||||
const nextDisplayList = dataList.value.filter(data => {
|
||||
if (data.type !== props.type) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (!superUser && data.username !== userName) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (normalizedKeyword.value && !data.name?.toLowerCase().includes(normalizedKeyword.value)) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (props.statusFilter && props.statusFilter !== 'all' && getSubscribeStatus(data) !== props.statusFilter) {
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
})
|
||||
|
||||
nextDisplayList.sort((a, b) => {
|
||||
const aIndex = orderIndexMap.get(a.id) ?? Number.MAX_SAFE_INTEGER
|
||||
const bIndex = orderIndexMap.get(b.id) ?? Number.MAX_SAFE_INTEGER
|
||||
|
||||
return aIndex - bIndex
|
||||
})
|
||||
|
||||
displayList.value = nextDisplayList
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
watch(
|
||||
canSortContext,
|
||||
canSort => {
|
||||
if (!canSort && sortMode.value) {
|
||||
sortMode.value = false
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
// 加载顺序
|
||||
async function loadSubscribeOrderConfig() {
|
||||
@@ -129,22 +185,6 @@ async function loadSubscribeOrderConfig() {
|
||||
}
|
||||
}
|
||||
|
||||
// 按order的顺序排序
|
||||
async function sortSubscribeOrder() {
|
||||
if (!orderConfig.value) {
|
||||
return
|
||||
}
|
||||
if (displayList.value.length === 0) {
|
||||
return
|
||||
}
|
||||
await loadSubscribeOrderConfig()
|
||||
displayList.value.sort((a, b) => {
|
||||
const aIndex = orderConfig.value.findIndex((item: { id: number }) => item.id === a.id)
|
||||
const bIndex = orderConfig.value.findIndex((item: { id: number }) => item.id === b.id)
|
||||
return (aIndex === -1 ? 999 : aIndex) - (bIndex === -1 ? 999 : bIndex)
|
||||
})
|
||||
}
|
||||
|
||||
// 保存顺序设置
|
||||
async function saveSubscribeOrder() {
|
||||
// 顺序配置
|
||||
@@ -164,10 +204,11 @@ async function fetchData() {
|
||||
try {
|
||||
loading.value = true
|
||||
dataList.value = await api.get('subscribe/')
|
||||
loading.value = false
|
||||
isRefreshed.value = true
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -352,6 +393,7 @@ const errorTitle = computed(() => {
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
await loadSubscribeOrderConfig()
|
||||
await fetchData()
|
||||
if (props.subid) {
|
||||
// 找到这个订阅
|
||||
@@ -441,28 +483,57 @@ defineExpose({
|
||||
</VCard>
|
||||
</div>
|
||||
|
||||
<VAlert v-if="sortMode" color="warning" variant="tonal" class="mb-4 mx-2">
|
||||
<div class="d-flex flex-wrap align-center justify-space-between gap-2">
|
||||
<span>{{ t('common.sortModeHint') }}</span>
|
||||
<VBtn variant="tonal" color="error" @click="sortMode = false">
|
||||
{{ t('common.exit') }}
|
||||
</VBtn>
|
||||
</div>
|
||||
</VAlert>
|
||||
|
||||
<draggable
|
||||
v-if="displayList.length > 0"
|
||||
v-if="displayList.length > 0 && canDragSort"
|
||||
v-model="displayList"
|
||||
@end="saveSubscribeOrder"
|
||||
handle=".cursor-move"
|
||||
item-key="id"
|
||||
tag="div"
|
||||
:component-data="{ class: 'grid gap-4 grid-subscribe-card px-2' }"
|
||||
:disabled="props.keyword || (props.statusFilter && props.statusFilter !== 'all') || isBatchMode"
|
||||
>
|
||||
<template #item="{ element }">
|
||||
<SubscribeCard
|
||||
:key="element.id"
|
||||
:media="element"
|
||||
:batch-mode="isBatchMode"
|
||||
:selected="selectedSubscribes.includes(element.id)"
|
||||
:selected="selectedSubscribesSet.has(element.id)"
|
||||
:sortable="true"
|
||||
@remove="fetchData"
|
||||
@save="fetchData"
|
||||
@select="toggleSelectSubscribe(element.id)"
|
||||
/>
|
||||
</template>
|
||||
</draggable>
|
||||
<ProgressiveCardGrid
|
||||
v-else-if="displayList.length > 0 && shouldVirtualizeList"
|
||||
:items="displayList"
|
||||
:get-item-key="item => item.id"
|
||||
:min-item-width="240"
|
||||
:scroll-to-index="scrollToIndex"
|
||||
class="px-2"
|
||||
>
|
||||
<template #default="{ item }">
|
||||
<SubscribeCard
|
||||
:key="item.id"
|
||||
:media="item"
|
||||
:batch-mode="isBatchMode"
|
||||
:selected="selectedSubscribesSet.has(item.id)"
|
||||
:sortable="false"
|
||||
@remove="fetchData"
|
||||
@save="fetchData"
|
||||
@select="toggleSelectSubscribe(item.id)"
|
||||
/>
|
||||
</template>
|
||||
</ProgressiveCardGrid>
|
||||
<NoDataFound
|
||||
v-if="displayList.length === 0 && isRefreshed"
|
||||
error-code="404"
|
||||
|
||||
@@ -3,6 +3,7 @@ import api from '@/api'
|
||||
import type { MediaInfo } from '@/api/types'
|
||||
import MediaCard from '@/components/cards/MediaCard.vue'
|
||||
import NoDataFound from '@/components/NoDataFound.vue'
|
||||
import ProgressiveCardGrid from '@/components/misc/ProgressiveCardGrid.vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
// 国际化
|
||||
@@ -274,15 +275,24 @@ async function fetchData({ done }: { done: any }) {
|
||||
>
|
||||
<template #loading />
|
||||
<template #empty />
|
||||
<div v-if="dataList.length > 0" class="grid gap-4 grid-media-card" tabindex="0">
|
||||
<div v-for="data in dataList" :key="data.tmdb_id || data.douban_id">
|
||||
<MediaCard :media="data" />
|
||||
<div v-if="data.popularity" class="mt-2 flex flex-row justify-center align-center text-subtitle-2">
|
||||
<VIcon icon="mdi-fire" color="error" />
|
||||
<span> {{ data.popularity.toLocaleString() }}</span>
|
||||
<ProgressiveCardGrid
|
||||
v-if="dataList.length > 0"
|
||||
:items="dataList"
|
||||
:get-item-key="item => item.tmdb_id || item.douban_id || item.bangumi_id || item.media_id || item.title"
|
||||
:min-item-width="144"
|
||||
:estimated-item-height="320"
|
||||
tabindex="0"
|
||||
>
|
||||
<template #default="{ item }">
|
||||
<div>
|
||||
<MediaCard :media="item" />
|
||||
<div v-if="item.popularity" class="mt-2 flex flex-row justify-center align-center text-subtitle-2">
|
||||
<VIcon icon="mdi-fire" color="error" />
|
||||
<span> {{ item.popularity.toLocaleString() }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</ProgressiveCardGrid>
|
||||
<NoDataFound
|
||||
v-if="dataList.length === 0 && isRefreshed"
|
||||
error-code="404"
|
||||
|
||||
@@ -3,6 +3,7 @@ import api from '@/api'
|
||||
import type { SubscribeShare } from '@/api/types'
|
||||
import NoDataFound from '@/components/NoDataFound.vue'
|
||||
import SubscribeShareCard from '@/components/cards/SubscribeShareCard.vue'
|
||||
import ProgressiveCardGrid from '@/components/misc/ProgressiveCardGrid.vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
// 国际化
|
||||
@@ -294,11 +295,18 @@ function removeData(id: number) {
|
||||
>
|
||||
<template #loading />
|
||||
<template #empty />
|
||||
<div v-if="dataList.length > 0" class="grid gap-4 grid-subscribe-card" tabindex="0">
|
||||
<div v-for="data in dataList" :key="data.id">
|
||||
<SubscribeShareCard :media="data" @delete="removeData(data.id || 0)" />
|
||||
</div>
|
||||
</div>
|
||||
<ProgressiveCardGrid
|
||||
v-if="dataList.length > 0"
|
||||
:items="dataList"
|
||||
:get-item-key="item => item.id || `${item.tmdbid || item.doubanid || item.name}-${item.share_user}`"
|
||||
:min-item-width="240"
|
||||
:estimated-item-height="260"
|
||||
tabindex="0"
|
||||
>
|
||||
<template #default="{ item }">
|
||||
<SubscribeShareCard :media="item" @delete="removeData(item.id || 0)" />
|
||||
</template>
|
||||
</ProgressiveCardGrid>
|
||||
<NoDataFound
|
||||
v-if="dataList.length === 0 && isRefreshed"
|
||||
error-code="404"
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -149,12 +149,11 @@ async function loadMessages({ done }: { done: any }) {
|
||||
// 没有新数据
|
||||
done('empty')
|
||||
}
|
||||
// 取消加载中
|
||||
loading.value = false
|
||||
} catch (error) {
|
||||
console.error('加载消息失败:', error)
|
||||
loading.value = false
|
||||
done('error')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -256,17 +255,19 @@ onMounted(() => {
|
||||
<LoadingBanner />
|
||||
</template>
|
||||
<template #empty> {{ t('message.noMoreData') }} </template>
|
||||
<div>
|
||||
<div
|
||||
v-for="(msg, index) in messages"
|
||||
:key="getMessageKey(msg) || index"
|
||||
class="chat-group d-flex mt-5 mb-8"
|
||||
:class="msg.action == 1 ? 'flex-row align-start' : 'flex-row-reverse align-end'"
|
||||
>
|
||||
<div class="d-inline-flex flex-column" :class="msg.action == 1 ? 'align-start' : 'align-end'">
|
||||
<MessageCard :message="msg" @imageload="handleImageLoad" />
|
||||
<VVirtualScroll renderless :items="messages" :item-height="160">
|
||||
<template #default="{ item, index, itemRef }">
|
||||
<div
|
||||
:ref="itemRef"
|
||||
:key="getMessageKey(item) || index"
|
||||
class="chat-group d-flex mt-5 mb-8"
|
||||
:class="item.action == 1 ? 'flex-row align-start' : 'flex-row-reverse align-end'"
|
||||
>
|
||||
<div class="d-inline-flex flex-column" :class="item.action == 1 ? 'align-start' : 'align-end'">
|
||||
<MessageCard :message="item" @imageload="handleImageLoad" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</VVirtualScroll>
|
||||
</VInfiniteScroll>
|
||||
</template>
|
||||
|
||||
@@ -436,6 +436,24 @@ watch(
|
||||
prepend-inner-icon="mdi-wechat"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="accountInfo.settings.wechatclawbot_userid"
|
||||
density="comfortable"
|
||||
clearable
|
||||
:label="t('profile.wechatClawBotUser')"
|
||||
prepend-inner-icon="mdi-robot-happy-outline"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="accountInfo.settings.feishu_openid"
|
||||
density="comfortable"
|
||||
clearable
|
||||
:label="t('profile.feishuUser')"
|
||||
prepend-inner-icon="mdi-message-badge-outline"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="accountInfo.settings.telegram_userid"
|
||||
|
||||
@@ -4,6 +4,7 @@ import { Workflow } from '@/api/types'
|
||||
import WorkflowAddEditDialog from '@/components/dialog/WorkflowAddEditDialog.vue'
|
||||
import WorkflowTaskCard from '@/components/cards/WorkflowTaskCard.vue'
|
||||
import NoDataFound from '@/components/NoDataFound.vue'
|
||||
import ProgressiveCardGrid from '@/components/misc/ProgressiveCardGrid.vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
// 国际化
|
||||
@@ -66,9 +67,18 @@ defineExpose({
|
||||
<template>
|
||||
<div>
|
||||
<LoadingBanner v-if="!isRefreshed" class="mt-12" />
|
||||
<div v-if="workflowList.length > 0 && isRefreshed" class="grid gap-4 grid-workflow-card px-2">
|
||||
<WorkflowTaskCard v-for="item in workflowList" :key="item.id" :workflow="item" :event-types="eventTypes" @refresh="fetchData" />
|
||||
</div>
|
||||
<ProgressiveCardGrid
|
||||
v-if="workflowList.length > 0 && isRefreshed"
|
||||
:items="workflowList"
|
||||
:get-item-key="item => item.id"
|
||||
:min-item-width="288"
|
||||
:estimated-item-height="420"
|
||||
class="px-2"
|
||||
>
|
||||
<template #default="{ item }">
|
||||
<WorkflowTaskCard :workflow="item" :event-types="eventTypes" @refresh="fetchData" />
|
||||
</template>
|
||||
</ProgressiveCardGrid>
|
||||
<NoDataFound
|
||||
v-if="workflowList.length === 0 && isRefreshed"
|
||||
error-code="404"
|
||||
|
||||
@@ -3,6 +3,7 @@ import api from '@/api'
|
||||
import type { WorkflowShare } from '@/api/types'
|
||||
import NoDataFound from '@/components/NoDataFound.vue'
|
||||
import WorkflowShareCard from '@/components/cards/WorkflowShareCard.vue'
|
||||
import ProgressiveCardGrid from '@/components/misc/ProgressiveCardGrid.vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
// 国际化
|
||||
@@ -156,16 +157,23 @@ onActivated(() => {
|
||||
<VInfiniteScroll mode="intersect" side="end" :items="dataList" class="overflow-visible px-2" @load="fetchData" :key="currentKey">
|
||||
<template #loading />
|
||||
<template #empty />
|
||||
<div v-if="dataList.length > 0" class="grid gap-4 grid-workflow-share-card" tabindex="0">
|
||||
<div v-for="data in dataList" :key="data.id">
|
||||
<ProgressiveCardGrid
|
||||
v-if="dataList.length > 0"
|
||||
:items="dataList"
|
||||
:get-item-key="item => item.id"
|
||||
:min-item-width="288"
|
||||
:estimated-item-height="220"
|
||||
tabindex="0"
|
||||
>
|
||||
<template #default="{ item }">
|
||||
<WorkflowShareCard
|
||||
:workflow="data"
|
||||
:workflow="item"
|
||||
:event-types="eventTypes"
|
||||
@delete="removeData(data.id || '')"
|
||||
@delete="removeData(item.id || '')"
|
||||
@update="emit('update')"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</ProgressiveCardGrid>
|
||||
<NoDataFound
|
||||
v-if="dataList.length === 0 && isRefreshed"
|
||||
error-code="404"
|
||||
|
||||
27
yarn.lock
27
yarn.lock
@@ -1173,6 +1173,13 @@
|
||||
dependencies:
|
||||
"@iconify/types" "*"
|
||||
|
||||
"@iconify-json/tabler@^1.2.23":
|
||||
version "1.2.34"
|
||||
resolved "https://registry.yarnpkg.com/@iconify-json/tabler/-/tabler-1.2.34.tgz#5c61bf336911c289aaaf218e0c6b78a34a27bc88"
|
||||
integrity sha512-WSlE5QrptidM57sCnXkpxZKcrk+oue6OlSJD5+gw8rIjuovOeNlejL/zABBM5kASsxLjoSy738Q8hmKrVzODuA==
|
||||
dependencies:
|
||||
"@iconify/types" "*"
|
||||
|
||||
"@iconify/tools@^4.0.4":
|
||||
version "4.1.2"
|
||||
resolved "https://registry.npmjs.org/@iconify/tools/-/tools-4.1.2.tgz"
|
||||
@@ -6995,7 +7002,16 @@ std-env@^3.9.0:
|
||||
resolved "https://registry.npmjs.org/std-env/-/std-env-3.9.0.tgz"
|
||||
integrity sha512-UGvjygr6F6tpH7o2qyqR6QYpwraIjKSdtzyBdyytFOHmPZY917kwdwLG0RbOjWOnKmnm3PeHjaoLLMie7kPLQw==
|
||||
|
||||
"string-width-cjs@npm:string-width@^4.2.0", string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3:
|
||||
"string-width-cjs@npm:string-width@^4.2.0":
|
||||
version "4.2.3"
|
||||
resolved "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz"
|
||||
integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==
|
||||
dependencies:
|
||||
emoji-regex "^8.0.0"
|
||||
is-fullwidth-code-point "^3.0.0"
|
||||
strip-ansi "^6.0.1"
|
||||
|
||||
string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3:
|
||||
version "4.2.3"
|
||||
resolved "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz"
|
||||
integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==
|
||||
@@ -7080,7 +7096,14 @@ stringify-object@^3.3.0:
|
||||
is-obj "^1.0.1"
|
||||
is-regexp "^1.0.0"
|
||||
|
||||
"strip-ansi-cjs@npm:strip-ansi@^6.0.1", strip-ansi@^6.0.0, strip-ansi@^6.0.1:
|
||||
"strip-ansi-cjs@npm:strip-ansi@^6.0.1":
|
||||
version "6.0.1"
|
||||
resolved "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz"
|
||||
integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==
|
||||
dependencies:
|
||||
ansi-regex "^5.0.1"
|
||||
|
||||
strip-ansi@^6.0.0, strip-ansi@^6.0.1:
|
||||
version "6.0.1"
|
||||
resolved "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz"
|
||||
integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==
|
||||
|
||||
Reference in New Issue
Block a user