Compare commits

..

77 Commits

Author SHA1 Message Date
jxxghp
30a4c55050 refactor: enable scroll-to-top button globally in browse page by removing path restriction 2026-05-17 19:27:04 +08:00
jxxghp
dee5d9d213 feat: replace Playwright with CloakBrowser for site emulation and update related translations 2026-05-17 15:50:47 +08:00
jxxghp
c5e2b1349f refactor: implement lazy-loaded tab components with silent background data refresh for settings pages 2026-05-17 14:17:50 +08:00
jxxghp
0e005c3c7e refactor: optimize Keep-Alive component rendering and data synchronization by introducing silent refresh states and fallback layout calculations. 2026-05-17 14:06:05 +08:00
jxxghp
348ae6b313 refactor: enhance scroll locking and touch event handling in QuickAccess component to prevent unwanted background scrolling 2026-05-17 12:48:31 +08:00
jxxghp
122ecc82fd 搜索中隐藏资源结果抬头 2026-05-17 12:48:06 +08:00
jxxghp
88fad5b764 优化资源搜索结果页抬头布局 2026-05-17 11:14:05 +08:00
jxxghp
f01971ee3a refactor: migrate page-specific action buttons to dynamic FABs for PWA mode compatibility 2026-05-17 11:01:47 +08:00
jxxghp
5e8489c620 refactor: standardize keep-alive data refreshing using useKeepAliveRefresh composable across views and dashboards 2026-05-17 10:04:30 +08:00
jxxghp
6900042cf7 chore: bump project version to 2.12.0 2026-05-17 08:36:00 +08:00
jxxghp
75862c026a refactor: replace useBackgroundOptimization with unified useBackground composable and update Nginx SSE route configuration 2026-05-17 08:32:17 +08:00
jxxghp
bbe3368c69 feat: introduce useKeepAliveRefresh composable to manage tab data synchronization and lifecycle refresh logic 2026-05-17 07:43:42 +08:00
jxxghp
587f06eb9f perf: safely optimize list loading 2026-05-15 23:15:14 +08:00
jxxghp
7114c63e8f Revert "perf: optimize infinite list loading"
This reverts commit 2a6f9e3cc0.
2026-05-15 23:08:56 +08:00
jxxghp
2a6f9e3cc0 perf: optimize infinite list loading 2026-05-15 22:59:00 +08:00
jxxghp
00d37d7bda feat: add context recovery and search parameter persistence logic for resource page refresh 2026-05-15 21:45:47 +08:00
jxxghp
546af84dab Revert "Feat/virtualizarefactor: virtualization rework — unify Virtual components, fix memory leaks, migrate 15+ consumerstion rework (#472)"
This reverts commit 5953496d84.
2026-05-15 21:42:43 +08:00
Aqr-K
5953496d84 Feat/virtualizarefactor: virtualization rework — unify Virtual components, fix memory leaks, migrate 15+ consumerstion rework (#472) 2026-05-15 21:15:30 +08:00
jxxghp
0fda7c70de fix: scroll message list container to latest 2026-05-15 18:56:37 +08:00
jxxghp
48546e1999 fix: auto scroll message center to latest 2026-05-15 18:44:16 +08:00
jxxghp
06355ff91d fix: prevent event propagation on card menu buttons and implement virtualization locking for overlays in ProgressiveCardGrid 2026-05-15 18:27:56 +08:00
jxxghp
523f8c4cc8 feat: add force scroll option to intelligent message scrolling in ShortcutBar and MessageView 2026-05-15 17:47:50 +08:00
jxxghp
73f6e7482f refactor: constrain dialog heights, standardize code formatting, and update CSS logical properties 2026-05-15 17:44:21 +08:00
InfinityPacer
81ab3f9da8 fix(subscribe): show best version mode tag (#471) 2026-05-15 06:51:03 +08:00
Album
d520645a8b fix: keep manual reorganize preview visible on partial failures (#470) 2026-05-14 23:05:41 +08:00
jxxghp
af67fddce0 fix: ensure clear cache reloads page 2026-05-14 22:45:23 +08:00
Album
6d89dad8de fix: prevent duplicate manual reorganize requests in filtered directories (#469) 2026-05-14 21:13:46 +08:00
jxxghp
f3ab2a8eff feat: add collapsible section for AI agent settings to reduce visual clutter 2026-05-14 20:27:28 +08:00
jxxghp
74c980c7a5 feat: split agent audio input and output settings 2026-05-14 19:37:46 +08:00
jxxghp
52fc2557ec fix: refresh transfer history on activation 2026-05-14 18:15:08 +08:00
jxxghp
34124418f8 perf: optimize initial load by implementing lazy loading for modules and fine-tuning authentication/resource initialization logic. 2026-05-14 13:19:48 +08:00
jxxghp
e2d36da299 refactor: invert background poster opacity logic to represent transparency percentage 2026-05-13 22:53:15 +08:00
jxxghp
9965428bae feat: add configurable opacity and blur settings for the transparent theme background 2026-05-13 22:34:12 +08:00
jxxghp
e62a0b5a8d refactor: optimize performance by centralizing state calculations and stabilizing virtual list data refs 2026-05-13 22:01:13 +08:00
Album
3c926f7485 refactor: remove redundant path cards from reorganize preview panel (#468) 2026-05-13 21:32:31 +08:00
DDSRem
de3f4e6374 feat: add wildcard glob support to file manager filter (#467) 2026-05-13 21:15:07 +08:00
jxxghp
2e22f6ae86 feat: virtualize media server dashboard grids 2026-05-13 21:08:59 +08:00
jxxghp
99665c7d79 feat: virtualize card grids 2026-05-13 19:07:46 +08:00
jxxghp
a4a00586c7 更新 package.json 2026-05-13 17:08:11 +08:00
jxxghp
cf59a07d4b feat: add full season upgrade option to TV subscription edit dialog 2026-05-13 15:55:50 +08:00
jxxghp
8a362d0740 fix: prevent SubscribeCard overflow by adding truncation and flex constraints to username and progress display 2026-05-13 14:51:13 +08:00
jxxghp
b49385af29 chore: bump package version to 2.11.2 2026-05-12 22:33:04 +08:00
jxxghp
b227412c96 chore: update feishu logo image asset 2026-05-12 22:32:42 +08:00
jxxghp
b3c8faab70 feat: add Feishu notification configuration UI 2026-05-12 21:42:17 +08:00
jxxghp
9a480dd803 refactor: simplify ReorganizeDialog UI by removing redundant background and border styles 2026-05-12 20:56:00 +08:00
jxxghp
847fd13982 refactor: implement collapsible side-by-side preview panel in ReorganizeDialog 2026-05-12 20:47:25 +08:00
album
7fa4f4a2f0 feat: add reorganize preview panel and optimize dialog layout
- Add reorganize result preview panel on the right side of ReorganizeDialog
- Add preview types: ManualTransferPayload, ManualTransferPreviewSummary, ManualTransferPreviewItem, ManualTransferPreviewData
- Add preview-related locale keys for zh-CN, zh-TW, en-US
- Optimize dialog width, split ratios, and button positions
- Support horizontal scroll for before/after file name columns
- Auto-calculate pagination via ResizeObserver with fixed row height
- Display media info, stats, and season/episode counts in preview header
- Support parallel preview requests with per-item error handling
- Replace setTimeout with nextTick for DOM-dependent operations
2026-05-12 17:32:08 +08:00
jxxghp
4207a70716 feat: add support for ZSpace media server integration including UI configuration and logo assets 2026-05-11 18:09:29 +08:00
jxxghp
c97247b92b refactor: optimize initial loading view and viewport synchronization logic for iOS standalone mode 2026-05-11 12:45:20 +08:00
jxxghp
e9bed7ff8a feat: update loading shell transition and add exit animation transform 2026-05-11 12:35:42 +08:00
jxxghp
f25a619f13 refactor: optimize initial loading screen layout and theme handling for improved PWA startup experience 2026-05-11 12:25:31 +08:00
jxxghp
2065b05143 refactor: remove manual chunk splitting configuration in vite build settings 2026-05-10 23:46:04 +08:00
jxxghp
eec1f2d7b3 style: update loading background to cover full viewport using dynamic units 2026-05-10 22:58:02 +08:00
jxxghp
17a343392c refactor: replace mobile device check with touch capability detection for tab scroll controls 2026-05-10 22:48:49 +08:00
jxxghp
a2b2e8cd94 feat: implement automatic refresh logic for expired WeChat Claw Bot QR codes 2026-05-10 22:45:21 +08:00
jxxghp
9703b2dbee 更新 package.json 2026-05-10 22:12:21 +08:00
jxxghp
310a501380 feat: implement QR code generation for WechatClawBot status display 2026-05-10 22:10:30 +08:00
jxxghp
30bf895ae1 fix: preserve wechat clawbot login state across renames 2026-05-10 21:50:33 +08:00
jxxghp
4f9dce70d3 feat: add wechat clawbot notification setup UI 2026-05-10 21:47:35 +08:00
jxxghp
f495e13667 style: add horizontal padding to overlay list content in common styles 2026-05-10 09:40:19 +08:00
jxxghp
f293681588 refactor: implement search parameter state management and prevent API caching for search requests 2026-05-09 23:02:17 +08:00
jxxghp
2f1a356e65 fix: replace virtual card grid with progressive loading 2026-05-09 22:23:45 +08:00
jxxghp
5909d2423c fix: stabilize virtual card grid during fast scrolling 2026-05-09 21:50:32 +08:00
jxxghp
42f7df8f4a fix: refine data cleanup settings tab
Move the data tab before log, hide retention fields until cleanup is enabled, and remove the extra download files prompt to keep the advanced settings flow focused.
2026-05-09 21:40:35 +08:00
jxxghp
abaa40d819 feat: add data cleanup settings tab
Expose the cleanup switch and per-table retention periods in advanced settings so administrators can manage data cleanup from the UI.
2026-05-09 21:22:02 +08:00
jxxghp
0d05a104c4 refactor: migrate site management actions to dynamic floating menu and update sort mode exit buttons 2026-05-09 18:37:16 +08:00
jxxghp
e8708f8de7 fix: add exit action for drag sort mode 2026-05-09 18:10:56 +08:00
jxxghp
7918b21b5b fix sites title align 2026-05-09 18:05:52 +08:00
jxxghp
088db67089 fix: make sort mode drag-only across cards 2026-05-09 18:04:10 +08:00
jxxghp
62e0d8e9dc perf: virtualize remaining long result views
Reduce DOM growth across resource, history, workflow, share, downloading, and message views so large datasets stay responsive while scrolling.
2026-05-09 17:28:23 +08:00
jxxghp
96d655155a perf: virtualize management lists and make drag sorting opt-in 2026-05-09 16:07:28 +08:00
jxxghp
a475085d7b refactor: implement buffered streaming updates and disable keep-alive for resource 2026-05-09 13:22:40 +08:00
jxxghp
58fdb77b37 更新 index.ts 2026-05-09 12:25:18 +08:00
jxxghp
8a25c6578d 更新 index.ts 2026-05-09 12:15:08 +08:00
jxxghp
ef62bd6e98 fix: restore horizontal slide loading placeholders
Wrap VirtualSlideView loading content in a horizontal track so media and person slide skeletons keep their original full-width carousel layout and title presentation during loading.
2026-05-09 09:02:40 +08:00
jxxghp
876a46607b chore: bump version to 2.11.0 2026-05-09 08:51:59 +08:00
jxxghp
107f70abde refactor: unify horizontal card virtualization
Replace the remaining slide loading fallbacks with VirtualSlideView so horizontal media and person carousels use a single rendering path. Remove the now-unused SlideView component to keep the slide system smaller and easier to maintain.
2026-05-09 08:48:49 +08:00
115 changed files with 8330 additions and 3070 deletions

5
env.d.ts vendored
View File

@@ -4,8 +4,13 @@ declare module 'vue-router' {
interface RouteMeta {
action?: string
subject?: string
keepAlive?: boolean
keepAliveKey?: string
layoutWrapperClasses?: string
navActiveLink?: RouteLocationRaw
requiresAuth?: boolean
subType?: string
hideFooter?: boolean
}
}

View File

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

View File

@@ -1,6 +1,6 @@
{
"name": "moviepilot",
"version": "2.10.12",
"version": "2.12.0",
"private": true,
"type": "module",
"bin": "dist/service.js",
@@ -128,4 +128,4 @@
"workbox-window": "^7.3.0"
},
"packageManager": "yarn@1.22.18"
}
}

View File

@@ -49,7 +49,7 @@ http {
root html;
}
location ~ ^/api/v1/system/(message|progress/) {
location ~ ^/api/v1/(system/(message|progress/|logging)|search/.*/stream$) {
# SSE MIME类型设置
default_type text/event-stream;

View File

@@ -1,5 +1,15 @@
import ColorThief from 'colorthief'
const DEFAULT_DOMINANT_COLOR = '#28A9E1'
const DOMINANT_COLOR_CACHE_LIMIT = 100
const colorThief = new ColorThief()
const dominantColorCache = new Map<string, Promise<string>>()
interface DominantColorOptions {
fallback?: string
quality?: number
}
// 将 RGB 转换为十六进制
function rgbStringToHex(rgbArray: number[]): string {
if (rgbArray.length !== 3 || rgbArray.some(isNaN)) throw new Error('Invalid RGB string format')
@@ -14,11 +24,46 @@ function rgbStringToHex(rgbArray: number[]): string {
return `#${toHex(r)}${toHex(g)}${toHex(b)}`
}
function getImageCacheKey(image: HTMLImageElement) {
return image.currentSrc || image.src || ''
}
function rememberDominantColor(key: string, colorPromise: Promise<string>) {
if (!key) return colorPromise
if (dominantColorCache.size >= DOMINANT_COLOR_CACHE_LIMIT) {
const firstKey = dominantColorCache.keys().next().value
if (firstKey) dominantColorCache.delete(firstKey)
}
dominantColorCache.set(key, colorPromise)
return colorPromise
}
// 提取主要颜色
export async function getDominantColor(image: HTMLImageElement): Promise<string> {
const colorThief = new ColorThief()
const dominantColor = colorThief.getColor(image)
return rgbStringToHex(dominantColor)
export async function getDominantColor(
image: HTMLImageElement | undefined | null,
options: DominantColorOptions = {},
): Promise<string> {
const fallback = options.fallback ?? DEFAULT_DOMINANT_COLOR
if (!image) return fallback
const cacheKey = getImageCacheKey(image)
const cachedColor = cacheKey ? dominantColorCache.get(cacheKey) : undefined
if (cachedColor) return cachedColor
const colorPromise = Promise.resolve()
.then(() => {
const dominantColor = colorThief.getColor(image, options.quality ?? 20)
return rgbStringToHex(dominantColor)
})
.catch(error => {
console.warn('Failed to extract dominant color:', error)
return fallback
})
return rememberDominantColor(cacheKey, colorPromise)
}
// 预加载图片

View File

@@ -264,6 +264,8 @@ const target = join(__dirname, 'icons-bundle.js');
console.log(`Saved ${target} (${bundle.length} bytes)`)
})().catch((err) => {
console.error(err)
// 构建图标失败时必须终止构建,避免继续发布上一次遗留的超大 icons-bundle。
process.exitCode = 1
})
async function collectUsedIcons(rootDir: string): Promise<string[]> {

View File

@@ -14,12 +14,24 @@ import PWAInstallPrompt from '@/components/PWAInstallPrompt.vue'
import { themeManager } from '@/utils/themeManager'
import { configureApexChartsTheme } from '@/utils/apexCharts'
const LOGIN_WALLPAPER_ROUTE = '/login'
// 生效主题
const { global: globalTheme } = useTheme()
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)
@@ -27,6 +39,7 @@ setI18nLanguage(localeValue as SupportedLocale)
// 检查是否登录
const authStore = useAuthStore()
const isLogin = computed(() => authStore.token)
const route = useRoute()
// 全局设置store
const globalSettingsStore = useGlobalSettingsStore()
@@ -38,6 +51,32 @@ const loginStateKey = computed(() => (isLogin.value ? 'logged-in' : 'logged-out'
const backgroundImages = ref<string[]>([])
const activeImageIndex = ref(0)
const isTransparentTheme = computed(() => globalTheme.name.value === 'transparent')
const shouldLoadBackgroundImages = computed(
() => (!isLogin.value && route.path === LOGIN_WALLPAPER_ROUTE) || (Boolean(isLogin.value) && isTransparentTheme.value),
)
let backgroundRetryTimer: number | null = null
let backgroundRequestController: AbortController | null = null
let authenticatedStateTimer: number | null = null
function getStoredNumber(key: string, fallback: number, min: number, max: number) {
const parsed = Number.parseFloat(localStorage.getItem(key) || '')
if (!Number.isFinite(parsed)) return fallback
return Math.min(max, Math.max(min, parsed))
}
function applyTransparentBackgroundSettings() {
document.documentElement.style.setProperty(
'--transparent-background-poster-opacity',
(1 - getStoredNumber('transparency-background-poster-opacity', 0, 0, 1)).toString(),
)
document.documentElement.style.setProperty(
'--transparent-background-blur',
`${getStoredNumber('transparency-background-blur', 16, 0, 30)}px`,
)
}
applyTransparentBackgroundSettings()
// 心跳检测
let heartbeatInterval: number | null = null
@@ -73,14 +112,16 @@ const stopHeartbeat = () => {
function updateHtmlThemeAttribute(themeName: string) {
document.documentElement.setAttribute('data-theme', themeName)
document.body.setAttribute('data-theme', themeName)
syncRootLaunchPalette()
}
// 获取背景图片
async function fetchBackgroundImages() {
try {
const controller = new AbortController()
backgroundRequestController?.abort()
backgroundRequestController = new AbortController()
backgroundImages.value = await api.get(`/login/wallpapers`, {
signal: controller.signal,
signal: backgroundRequestController.signal,
})
activeImageIndex.value = 0
} catch (e) {
@@ -122,12 +163,56 @@ function startBackgroundRotation() {
}
}
function stopBackgroundLoading() {
backgroundRequestController?.abort()
backgroundRequestController = null
if (backgroundRetryTimer) {
window.clearTimeout(backgroundRetryTimer)
backgroundRetryTimer = null
}
removeBackgroundTimer('background-rotation')
}
async function initializeAuthenticatedState() {
if (!isLogin.value) return
try {
globalLoadingStateManager.setLoadingState('global-settings', true)
await globalSettingsStore.initialize()
await globalSettingsStore.loadUserSettings()
} finally {
globalLoadingStateManager.setLoadingState('global-settings', false)
}
}
function scheduleAuthenticatedStateInitialization() {
if (authenticatedStateTimer) {
window.clearTimeout(authenticatedStateTimer)
}
// 登录后会立刻发生路由切换,稍后再拉取设置可避开导航中止请求。
authenticatedStateTimer = window.setTimeout(() => {
authenticatedStateTimer = null
initializeAuthenticatedState()
}, 150)
}
// 添加logo动画效果并延迟移除加载界面
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)
}
}
@@ -136,8 +221,6 @@ async function removeLoadingWithStateCheck() {
try {
// 设置各个组件的加载状态
globalLoadingStateManager.setLoadingState('pwa-state', true)
globalLoadingStateManager.setLoadingState('global-settings', true)
globalLoadingStateManager.setLoadingState('background-images', true)
// 静默检查PWA状态恢复
const pwaController = (window as any).pwaStateController
@@ -146,22 +229,7 @@ async function removeLoadingWithStateCheck() {
}
globalLoadingStateManager.setLoadingState('pwa-state', false)
// 并行加载关键资源
await Promise.all([
globalSettingsStore.initialize().then(async () => {
// 如果已登录,加载用户相关设置
if (isLogin.value) {
await globalSettingsStore.loadUserSettings()
}
globalLoadingStateManager.setLoadingState('global-settings', false)
}),
new Promise(resolve => {
setTimeout(() => {
globalLoadingStateManager.setLoadingState('background-images', false)
resolve(void 0)
}, 50)
}),
])
await initializeAuthenticatedState()
// 等待所有加载完成
await globalLoadingStateManager.waitForAllComplete()
@@ -170,7 +238,9 @@ async function removeLoadingWithStateCheck() {
animateAndRemoveLoader()
// 检查未读消息
checkAndEmitUnreadMessages()
if (isLogin.value) {
checkAndEmitUnreadMessages()
}
} catch (error) {
// 即使出错也要移除加载界面
globalLoadingStateManager.reset()
@@ -189,7 +259,8 @@ async function loadBackgroundImages(retryCount = 0) {
if (retryCount < maxRetries) {
const baseDelay = isAbortError ? 1000 : 3000
const retryDelay = Math.min(baseDelay * Math.pow(2, retryCount), 10000)
setTimeout(() => {
backgroundRetryTimer = window.setTimeout(() => {
backgroundRetryTimer = null
loadBackgroundImages(retryCount + 1)
}, retryDelay)
}
@@ -225,20 +296,51 @@ onMounted(async () => {
},
)
// 加载背景图片
loadBackgroundImages()
// 登录页壁纸仅在未登录登录页需要,避免其他首屏额外发起图片列表请求。
watch(
shouldLoadBackgroundImages,
shouldLoad => {
stopBackgroundLoading()
if (shouldLoad) {
loadBackgroundImages()
} else if (!isTransparentTheme.value) {
backgroundImages.value = []
}
},
{ immediate: true },
)
// 使用优化后的加载界面移除逻辑
ensureRenderComplete(() => {
nextTick(removeLoadingWithStateCheck)
})
// 启动心跳
startHeartbeat()
if (isLogin.value) {
startHeartbeat()
}
// 登录状态可能在当前单页会话中变化,这里按需补齐登录后初始化和心跳。
watch(isLogin, loggedIn => {
if (loggedIn) {
startHeartbeat()
scheduleAuthenticatedStateInitialization()
} else {
if (authenticatedStateTimer) {
window.clearTimeout(authenticatedStateTimer)
authenticatedStateTimer = null
}
stopHeartbeat()
}
})
})
onUnmounted(() => {
// 清除背景轮换定时器
removeBackgroundTimer('background-rotation')
stopBackgroundLoading()
if (authenticatedStateTimer) {
window.clearTimeout(authenticatedStateTimer)
authenticatedStateTimer = null
}
// 停止心跳
stopHeartbeat()
})
@@ -247,7 +349,11 @@ onUnmounted(() => {
<template>
<div class="app-wrapper">
<!-- 透明主题背景 -->
<div v-if="backgroundImages.length > 0 && (isTransparentTheme || !isLogin)" class="background-container">
<div
v-if="backgroundImages.length > 0 && (isTransparentTheme || !isLogin)"
class="background-container"
:class="{ 'is-transparent-theme': isTransparentTheme && isLogin }"
>
<div
v-for="(imageUrl, index) in backgroundImages"
:key="`bg-${index}-${loginStateKey}`"
@@ -312,11 +418,15 @@ onUnmounted(() => {
}
}
.background-container.is-transparent-theme .background-image.active {
opacity: var(--transparent-background-poster-opacity, 1);
}
/* 全局磨砂层 */
.global-blur-layer {
position: absolute;
z-index: 1;
backdrop-filter: blur(16px);
backdrop-filter: blur(var(--transparent-background-blur, 16px));
background-color: rgba(128, 128, 128, 30%);
block-size: 100%;
inline-size: 100%;

View File

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

View File

@@ -58,6 +58,8 @@ export interface Subscribe {
sites: number[]
// 是否洗版数字或者boolean
best_version: any
// 是否只洗全集整包数字或者boolean
best_version_full?: any
// 使用 imdbid 搜索
search_imdbid?: any
// 当前优先级
@@ -1145,7 +1147,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 +1313,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
}
// 整理队列

Binary file not shown.

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.2 KiB

View File

@@ -31,6 +31,10 @@ const props = defineProps({
type: Array as PropType<FileItem[]>,
default: () => [],
},
active: {
type: Boolean,
default: true,
},
})
// 对外事件
@@ -308,6 +312,7 @@ function stopDrag() {
:refreshpending="refreshPending"
:sort="sort"
:showTree="showDirTree"
:active="active"
:style="{ flex: 1 }"
@pathchanged="pathChanged"
@loading="loadingChanged"

View File

@@ -9,14 +9,14 @@ import { cloneDeep } from 'lodash-es'
import { useI18n } from 'vue-i18n'
import { downloaderDict, storageAttributes } from '@/api/constants'
import { useDisplay } from 'vuetify'
import { useBackgroundOptimization } from '@/composables/useBackgroundOptimization'
import { useBackground } from '@/composables/useBackground'
// 显示器宽度
const display = useDisplay()
// 获取i18n实例
const { t } = useI18n()
const { useConditionalDataRefresh } = useBackgroundOptimization()
const { useConditionalDataRefresh } = useBackground()
// 定义输入
const props = defineProps({

View File

@@ -1,10 +1,8 @@
<script lang="ts" setup>
import draggable from 'vuedraggable'
import { copyToClipboard } from '@/@core/utils/navigator'
import { CustomRule, FilterRuleGroup } from '@/api/types'
import FilterRuleCard from '@/components/cards/FilterRuleCard.vue'
import { useToast } from 'vue-toastification'
import ImportCodeDialog from '@/components/dialog/ImportCodeDialog.vue'
import filter_group_svg from '@images/svg/filter-group.svg'
import { cloneDeep } from 'lodash-es'
import { useI18n } from 'vue-i18n'
@@ -16,6 +14,10 @@ const display = useDisplay()
// 获取i18n实例
const { t } = useI18n()
// 规则组详情弹窗内才需要拖拽和导入代码,避免规则组卡片列表首屏带入重交互依赖。
const Draggable = defineAsyncComponent(() => import('vuedraggable').then(module => module.default))
const ImportCodeDialog = defineAsyncComponent(() => import('@/components/dialog/ImportCodeDialog.vue'))
// 输入参数
const props = defineProps({
// 单个规则组
@@ -273,7 +275,7 @@ function onClose() {
</VRow>
</VCardItem>
<VCardText>
<draggable
<Draggable
v-model="filterRuleCards"
handle=".cursor-move"
item-key="pri"
@@ -291,7 +293,7 @@ function onClose() {
@close="filterCardClose(element.pri)"
/>
</template>
</draggable>
</Draggable>
<div class="text-center" v-if="filterRuleCards.length == 0">{{ t('filterRule.add') }}</div>
</VCardText>
<VCardActions class="pt-3">

View File

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

View File

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

View File

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

View File

@@ -252,7 +252,7 @@ const dropdownItems = ref([
</div>
</div>
<div class="absolute bottom-0 right-0">
<IconBtn>
<IconBtn @click.stop>
<VIcon size="small" icon="mdi-dots-vertical" />
<VMenu activator="parent" close-on-content-click>
<VList>
@@ -273,7 +273,7 @@ const dropdownItems = ref([
<!-- 安装插件进度框 -->
<ProgressDialog v-if="progressDialog" v-model="progressDialog" :text="progressText" />
<!-- 更新日志 -->
<VDialog v-if="releaseDialog" v-model="releaseDialog" width="600" scrollable>
<VDialog v-if="releaseDialog" v-model="releaseDialog" width="600" max-height="85vh" scrollable>
<VCard :title="t('plugin.updateHistoryTitle', { name: props.plugin?.plugin_name })">
<VDialogCloseBtn @click="releaseDialog = false" />
<VDivider />

View File

@@ -11,13 +11,15 @@ import VersionHistory from '@/components/misc/VersionHistory.vue'
import ProgressDialog from '../dialog/ProgressDialog.vue'
import PluginConfigDialog from '../dialog/PluginConfigDialog.vue'
import PluginDataDialog from '../dialog/PluginDataDialog.vue'
import LoggingView from '@/views/system/LoggingView.vue'
import { useI18n } from 'vue-i18n'
import { useDisplay } from 'vuetify'
// 显示器宽度
const display = useDisplay()
// 插件日志面板只有点击“查看日志”时才需要,延后加载可减轻插件列表首屏。
const LoggingView = defineAsyncComponent(() => import('@/views/system/LoggingView.vue'))
// 输入参数
const props = defineProps({
plugin: Object as PropType<Plugin>,
@@ -25,6 +27,10 @@ const props = defineProps({
action: Boolean, // 动作标识
width: String,
height: String,
sortable: {
type: Boolean,
default: false,
},
})
// 定义触发的自定义事件
@@ -269,6 +275,14 @@ function openPluginDetail() {
else showPluginConfig()
}
function handleCardClick() {
if (props.sortable) {
return
}
openPluginDetail()
}
// 配置完成
function configDone() {
pluginConfigDialog.value = false
@@ -420,6 +434,7 @@ watch(
(newOpenState, _) => {
if (newOpenState) openPluginDetail()
},
{ immediate: true },
)
</script>
@@ -433,11 +448,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 +475,10 @@ 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 +502,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,8 +520,8 @@ watch(
<span class="text-sm">{{ formatDownloadCount(props.count) }}</span>
</span>
</div>
<div class="absolute bottom-0 right-0">
<IconBtn>
<div v-if="!props.sortable" class="absolute bottom-0 right-0">
<IconBtn @click.stop>
<VIcon icon="mdi-dots-vertical" />
<VMenu v-model="menuVisible" activator="parent" close-on-content-click>
<VList>
@@ -548,7 +572,7 @@ watch(
<ProgressDialog v-if="progressDialog" v-model="progressDialog" :text="progressText" />
<!-- 更新日志 -->
<VDialog v-if="releaseDialog" v-model="releaseDialog" width="600" scrollable :fullscreen="!display.mdAndUp.value">
<VDialog v-if="releaseDialog" v-model="releaseDialog" width="600" scrollable max-height="85vh">
<VCard :title="t('plugin.updateHistoryTitle', { name: props.plugin?.plugin_name })">
<VDialogCloseBtn @click="releaseDialog = false" />
<VDivider />
@@ -566,13 +590,13 @@ watch(
</VDialog>
<!-- 实时日志弹窗 -->
<VDialog
v-if="loggingDialog"
v-model="loggingDialog"
scrollable
max-width="72rem"
: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>

View File

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

View File

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

View File

@@ -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
@@ -333,7 +386,7 @@ onMounted(() => {
</VBtn>
<!-- 更多选项按钮 -->
<VBtn icon variant="text" class="mt-auto" size="36">
<VBtn icon variant="text" class="mt-auto" size="36" @click.stop>
<VIcon icon="mdi-dots-vertical" size="20" />
<VMenu :activator="'parent'" :close-on-content-click="true" :location="'left'">
<VList>
@@ -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;
}

View File

@@ -29,6 +29,10 @@ const props = defineProps({
type: Boolean,
default: false,
},
sortable: {
type: Boolean,
default: false,
},
})
// 从 provide 中获取全局设置
@@ -63,6 +67,25 @@ const subscribeState = ref<string>(props.media?.state ?? 'P')
// 上一次更新时间
const lastUpdateText = computed(() => (props.media?.last_update ? formatDateDifference(props.media.last_update) : ''))
// 判断后端数字/布尔开关是否启用
function isEnabledFlag(value: any) {
return value === true || value === 1 || value === '1'
}
// 订阅列表接口通常返回中文媒体类型,插件或缓存数据可能只保留剧集字段
function isTvSubscribe(media?: Subscribe) {
return media?.type === '电视剧' || media?.type === 'tv' || !!media?.season || !!media?.total_episode
}
// TV 洗版订阅在卡片上展示分集或全集短标签
const bestVersionModeLabel = computed(() => {
if (!isEnabledFlag(props.media?.best_version) || !isTvSubscribe(props.media)) return ''
return isEnabledFlag(props.media?.best_version_full)
? t('subscribe.bestVersionWholeShort')
: t('subscribe.bestVersionEpisodeShort')
})
// 图片加载完成响应
function imageLoadHandler() {
imageLoaded.value = true
@@ -266,6 +289,7 @@ watch(
(newOpenState, _) => {
if (newOpenState) editSubscribeDialog()
},
{ immediate: true },
)
// 监听订阅状态
@@ -308,6 +332,10 @@ function onSubscribeEditRemove() {
// 处理卡片点击事件
function handleCardClick() {
if (props.sortable) {
return
}
if (props.batchMode) {
// 批量模式下触发选择事件
emit('select')
@@ -325,7 +353,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,14 +364,15 @@ 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">
<IconBtn>
<div v-if="!props.sortable" class="me-n3 absolute top-1 right-4">
<IconBtn @click.stop>
<VIcon icon="mdi-dots-vertical" color="white" />
<VMenu activator="parent" close-on-content-click>
<VList>
@@ -380,7 +409,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>
@@ -398,21 +427,39 @@ function handleCardClick() {
</div>
</div>
</VCardText>
<VCardText class="flex justify-space-between align-center flex-wrap px-3">
<div class="flex align-center">
<VCardText class="flex min-w-0 justify-space-between align-center flex-wrap px-3">
<div class="flex min-w-0 max-w-full 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"
color="white"
/>
<div v-if="props.media?.season" class="text-subtitle-2 me-2 text-white">
<div v-if="props.media?.season" class="flex-shrink-0 text-subtitle-2 me-2 text-white">
{{ (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" />
<span v-if="props.media?.username" class="text-subtitle-2 text-white">
<VChip
v-if="bestVersionModeLabel"
size="x-small"
color="primary"
variant="flat"
class="me-2 flex-shrink-0"
>
{{ bestVersionModeLabel }}
</VChip>
<VIcon v-if="props.media?.username && props.sortable" icon="mdi-account" size="small" color="white" class="flex-shrink-0 me-1" />
<IconBtn v-else-if="props.media?.username" icon="mdi-account" size="small" color="white" class="flex-shrink-0" />
<!-- 用户名过长时限制在卡片宽度内并用省略号展示剩余内容 -->
<span v-if="props.media?.username" class="min-w-0 truncate text-subtitle-2 text-white" :title="props.media?.username">
{{ props.media?.username }}
</span>
</div>

View File

@@ -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
>
<!-- 优惠标签 -->

View File

@@ -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"
>
<!-- 优惠标签 -->

View File

@@ -123,10 +123,10 @@ onMounted(() => {
'transition-transform duration-300 hover:-translate-y-1',
!props.user.is_active ? 'opacity-85 bg-surface-lighten-1' : '',
]"
class="flex flex-column"
class="user-card flex flex-column h-full"
@click="userEditDialog = true"
>
<div class="flex-grow">
<div class="user-card__body flex-grow flex-grow-1">
<!-- 用户头像和基本信息 -->
<VCardItem :class="[user.is_superuser ? 'admin-header' : '']">
<template v-slot:prepend>
@@ -247,7 +247,7 @@ onMounted(() => {
</div>
<!-- 独立的邮箱显示 -->
<VDivider class="mx-4" />
<div>
<div class="user-card__footer">
<VCardText class="d-flex align-center py-2 px-4 text-medium-emphasis">
<VIcon icon="mdi-email-outline" size="small" color="primary" class="mr-2 opacity-70" />
<span class="text-body-2 truncate">{{ user.email || t('user.noEmail') }}</span>
@@ -308,6 +308,16 @@ onMounted(() => {
</template>
<style scoped>
.user-card {
block-size: 100%;
}
/* 让邮箱和订阅统计固定在卡片底部,保证同一行用户卡片视觉等高。 */
.user-card__footer {
flex-shrink: 0;
margin-block-start: auto;
}
.admin-decoration {
position: absolute;
z-index: 1;

View File

@@ -1,7 +1,7 @@
<script lang="ts" setup>
import { formatDateDifference } from '@/@core/utils/formatters'
import api from '@/api'
import { clearCachesAndServiceWorker, reloadWithTimestamp } from '@/composables/useVersionChecker'
import { clearCacheAndReload } from '@/composables/useVersionChecker'
import MarkdownIt from 'markdown-it'
import mdLinkAttributes from 'markdown-it-link-attributes'
import { useI18n } from 'vue-i18n'
@@ -138,9 +138,7 @@ function releaseTime(releaseDate: string) {
// 强制清除缓存
async function clearCache() {
await clearCachesAndServiceWorker()
// 刷新页面,添加时间戳参数以强制更新
reloadWithTimestamp()
await clearCacheAndReload()
}
onMounted(() => {
@@ -204,12 +202,7 @@ onMounted(() => {
<dd class="flex text-sm sm:col-span-2 sm:mt-0">
<span class="flex-grow flex flex-row items-center truncate">
<code class="truncate">{{ appVersion }}</code>
<VBtn
size="x-small"
variant="tonal"
class="ms-2"
@click="clearCache"
>
<VBtn size="x-small" variant="tonal" class="ms-2" @click="clearCache">
<template #prepend>
<VIcon icon="mdi-refresh" size="14" />
</template>
@@ -404,7 +397,7 @@ onMounted(() => {
</div>
</VCardText>
</VCard>
<VDialog v-if="releaseDialog" v-model="releaseDialog" width="600" scrollable>
<VDialog v-if="releaseDialog" v-model="releaseDialog" width="600" scrollable max-height="85vh">
<VCard>
<VCardItem>
<VDialogCloseBtn @click="releaseDialog = false" />
@@ -432,8 +425,8 @@ onMounted(() => {
.markdown-body :deep(h1),
.markdown-body :deep(h2),
.markdown-body :deep(h3) {
margin-block: 0.5rem;
font-weight: 600;
margin-block: 0.5rem;
}
.markdown-body :deep(h1) {
@@ -450,8 +443,8 @@ onMounted(() => {
.markdown-body :deep(ul),
.markdown-body :deep(ol) {
padding-inline-start: 1.5rem;
margin-block: 0.5rem;
padding-inline-start: 1.5rem;
}
.markdown-body :deep(li) {
@@ -472,18 +465,20 @@ onMounted(() => {
}
.markdown-body :deep(code) {
padding: 0.15rem 0.4rem;
border-radius: 0.25rem;
background-color: rgba(127, 127, 127, 15%);
font-size: 0.875em;
background-color: rgba(127, 127, 127, 0.15);
padding-block: 0.15rem;
padding-inline: 0.4rem;
}
.markdown-body :deep(pre) {
padding: 0.75rem 1rem;
border-radius: 0.375rem;
background-color: rgba(127, 127, 127, 15%);
margin-block: 0.5rem;
overflow-x: auto;
border-radius: 0.375rem;
background-color: rgba(127, 127, 127, 0.15);
padding-block: 0.75rem;
padding-inline: 1rem;
}
.markdown-body :deep(pre code) {
@@ -492,37 +487,38 @@ onMounted(() => {
}
.markdown-body :deep(blockquote) {
padding-inline-start: 1rem;
border-inline-start: 3px solid rgba(127, 127, 127, 40%);
color: rgba(127, 127, 127, 80%);
margin-block: 0.5rem;
border-inline-start: 3px solid rgba(127, 127, 127, 0.4);
color: rgba(127, 127, 127, 0.8);
padding-inline-start: 1rem;
}
.markdown-body :deep(hr) {
margin-block: 1rem;
border: none;
border-block-start: 1px solid rgba(127, 127, 127, 0.3);
border-block-start: 1px solid rgba(127, 127, 127, 30%);
margin-block: 1rem;
}
.markdown-body :deep(table) {
width: 100%;
margin-block: 0.5rem;
border-collapse: collapse;
inline-size: 100%;
margin-block: 0.5rem;
}
.markdown-body :deep(th),
.markdown-body :deep(td) {
padding: 0.4rem 0.75rem;
border: 1px solid rgba(127, 127, 127, 0.3);
border: 1px solid rgba(127, 127, 127, 30%);
padding-block: 0.4rem;
padding-inline: 0.75rem;
}
.markdown-body :deep(th) {
background-color: rgba(127, 127, 127, 10%);
font-weight: 600;
background-color: rgba(127, 127, 127, 0.1);
}
.markdown-body :deep(img) {
max-width: 100%;
height: auto;
block-size: auto;
max-inline-size: 100%;
}
</style>

File diff suppressed because it is too large Load Diff

View File

@@ -4,6 +4,7 @@ import type { Site, TorrentInfo, SiteCategory } from '@/api/types'
import { formatFileSize } from '@core/utils/formatters'
import { useDisplay } from 'vuetify'
import AddDownloadDialog from '../dialog/AddDownloadDialog.vue'
import ProgressiveCardGrid from '@/components/misc/ProgressiveCardGrid.vue'
import { useI18n } from 'vue-i18n'
// 国际化
@@ -94,6 +95,10 @@ const isMobileLayout = computed(() => display.smAndDown.value)
// 移动端分页数据
const mobileResourceList = computed(() => resourceDataList.value)
function getResourceItemKey(item: TorrentInfo, index: number) {
return item.page_url || item.enclosure || `${item.title}-${item.pubdate || ''}-${index}`
}
// 打开种子详情页面
function openTorrentDetail(page_url: string) {
if (!page_url) return
@@ -465,98 +470,115 @@ onMounted(() => {
</div>
</div>
<div v-else-if="mobileResourceList.length > 0" class="px-3 pb-4">
<VCard
v-for="(item, index) in mobileResourceList"
:key="item.page_url || item.enclosure || `${item.title}-${index}`"
class="mb-3"
<div v-else-if="mobileResourceList.length > 0" class="site-resource-mobile__list px-3 pb-4">
<ProgressiveCardGrid
:items="mobileResourceList"
:columns="1"
:gap="12"
:estimated-item-height="320"
:overscan-rows="5"
:get-item-key="getResourceItemKey"
>
<VCardText class="pa-4">
<button type="button" class="site-resource-title-btn text-start" @click="addDownload(item)">
<div class="text-body-1 font-weight-medium text-high-emphasis">
{{ item.title }}
</div>
<div
v-if="item.description"
class="site-resource-card__description mt-2 text-body-2 text-medium-emphasis"
>
{{ item.description }}
</div>
</button>
<template #default="{ item }">
<VCard>
<VCardText class="pa-4">
<button type="button" class="site-resource-title-btn text-start" @click="addDownload(item)">
<div class="text-body-1 font-weight-medium text-high-emphasis">
{{ item.title }}
</div>
<div
v-if="item.description"
class="site-resource-card__description mt-2 text-body-2 text-medium-emphasis"
>
{{ item.description }}
</div>
</button>
<div class="mt-3">
<VChip v-if="item.hit_and_run" variant="elevated" size="small" class="me-1 mb-1 text-white bg-black">
H&amp;R
</VChip>
<VChip v-if="item.freedate_diff" variant="elevated" color="secondary" size="small" class="me-1 mb-1">
{{ item.freedate_diff }}
</VChip>
<VChip
v-for="(label, chipIndex) in item.labels"
:key="chipIndex"
variant="elevated"
size="small"
color="primary"
class="me-1 mb-1"
>
{{ label }}
</VChip>
<VChip
v-if="item.downloadvolumefactor !== 1 || item.uploadvolumefactor !== 1"
:class="getVolumeFactorClass(item.downloadvolumefactor, item.uploadvolumefactor)"
variant="elevated"
size="small"
class="me-1 mb-1"
>
{{ item.volume_factor }}
</VChip>
</div>
<div class="mt-3">
<VChip
v-if="item.hit_and_run"
variant="elevated"
size="small"
class="me-1 mb-1 text-white bg-black"
>
H&amp;R
</VChip>
<VChip
v-if="item.freedate_diff"
variant="elevated"
color="secondary"
size="small"
class="me-1 mb-1"
>
{{ item.freedate_diff }}
</VChip>
<VChip
v-for="(label, chipIndex) in item.labels"
:key="chipIndex"
variant="elevated"
size="small"
color="primary"
class="me-1 mb-1"
>
{{ label }}
</VChip>
<VChip
v-if="item.downloadvolumefactor !== 1 || item.uploadvolumefactor !== 1"
:class="getVolumeFactorClass(item.downloadvolumefactor, item.uploadvolumefactor)"
variant="elevated"
size="small"
class="me-1 mb-1"
>
{{ item.volume_factor }}
</VChip>
</div>
<div class="site-resource-card__meta mt-4">
<div class="site-resource-card__meta-item">
<div class="text-caption text-medium-emphasis">{{ t('dialog.siteResource.timeColumn') }}</div>
<div class="text-body-2 font-weight-medium">{{ item.date_elapsed || item.pubdate || '-' }}</div>
<div v-if="item.pubdate" class="text-caption text-medium-emphasis mt-1">{{ item.pubdate }}</div>
</div>
<div class="site-resource-card__meta-item">
<div class="text-caption text-medium-emphasis">{{ t('dialog.siteResource.sizeColumn') }}</div>
<div class="text-body-2 font-weight-medium">{{ formatFileSize(item.size) }}</div>
</div>
<div class="site-resource-card__meta-item">
<div class="text-caption text-medium-emphasis">{{ t('dialog.siteResource.seedersColumn') }}</div>
<div class="text-body-2 font-weight-medium">{{ item.seeders }}</div>
</div>
<div class="site-resource-card__meta-item">
<div class="text-caption text-medium-emphasis">{{ t('dialog.siteResource.peersColumn') }}</div>
<div class="text-body-2 font-weight-medium">{{ item.peers }}</div>
</div>
</div>
<div class="site-resource-card__actions mt-4">
<VBtn color="primary" variant="flat" block prepend-icon="mdi-download" @click="addDownload(item)">
{{ t('actionStep.addDownload') }}
</VBtn>
<div class="site-resource-card__secondary-actions mt-2">
<VBtn
variant="tonal"
prepend-icon="mdi-open-in-new"
@click="openTorrentDetail(item.page_url || '')"
>
{{ t('common.viewDetails') }}
</VBtn>
<VBtn
v-if="item.enclosure?.startsWith('http')"
variant="tonal"
prepend-icon="mdi-tray-arrow-down"
@click="downloadTorrentFile(item.enclosure)"
>
{{ t('dialog.siteResource.downloadTorrent') }}
</VBtn>
</div>
</div>
</VCardText>
</VCard>
<div class="site-resource-card__meta mt-4">
<div class="site-resource-card__meta-item">
<div class="text-caption text-medium-emphasis">{{ t('dialog.siteResource.timeColumn') }}</div>
<div class="text-body-2 font-weight-medium">{{ item.date_elapsed || item.pubdate || '-' }}</div>
<div v-if="item.pubdate" class="text-caption text-medium-emphasis mt-1">{{ item.pubdate }}</div>
</div>
<div class="site-resource-card__meta-item">
<div class="text-caption text-medium-emphasis">{{ t('dialog.siteResource.sizeColumn') }}</div>
<div class="text-body-2 font-weight-medium">{{ formatFileSize(item.size) }}</div>
</div>
<div class="site-resource-card__meta-item">
<div class="text-caption text-medium-emphasis">{{ t('dialog.siteResource.seedersColumn') }}</div>
<div class="text-body-2 font-weight-medium">{{ item.seeders }}</div>
</div>
<div class="site-resource-card__meta-item">
<div class="text-caption text-medium-emphasis">{{ t('dialog.siteResource.peersColumn') }}</div>
<div class="text-body-2 font-weight-medium">{{ item.peers }}</div>
</div>
</div>
<div class="site-resource-card__actions mt-4">
<VBtn color="primary" variant="flat" block prepend-icon="mdi-download" @click="addDownload(item)">
{{ t('actionStep.addDownload') }}
</VBtn>
<div class="site-resource-card__secondary-actions mt-2">
<VBtn
variant="tonal"
prepend-icon="mdi-open-in-new"
@click="openTorrentDetail(item.page_url || '')"
>
{{ t('common.viewDetails') }}
</VBtn>
<VBtn
v-if="item.enclosure?.startsWith('http')"
variant="tonal"
prepend-icon="mdi-tray-arrow-down"
@click="downloadTorrentFile(item.enclosure)"
>
{{ t('dialog.siteResource.downloadTorrent') }}
</VBtn>
</div>
</div>
</VCardText>
</VCard>
</template>
</ProgressiveCardGrid>
</div>
<div v-else class="px-4 py-10 text-center text-medium-emphasis">
@@ -669,6 +691,15 @@ onMounted(() => {
flex: 0 0 auto;
}
.site-resource-mobile {
overflow-y: auto;
block-size: 100%;
}
.site-resource-mobile__list {
min-block-size: 100%;
}
.v-table th {
white-space: nowrap;
}

View File

@@ -52,6 +52,7 @@ const subscribeForm = ref<Subscribe>({
username: '',
sites: [],
best_version: undefined,
best_version_full: undefined,
current_priority: 0,
downloader: '',
date: '',
@@ -226,6 +227,7 @@ async function getSubscribeInfo() {
const result: Subscribe = await api.get(`subscribe/${props.subid}`)
subscribeForm.value = result
subscribeForm.value.best_version = subscribeForm.value.best_version === 1
subscribeForm.value.best_version_full = subscribeForm.value.best_version_full === 1
subscribeForm.value.search_imdbid = subscribeForm.value.search_imdbid === 1
// 加载剧集组
if (subscribeForm.value.type == '电视剧') getEpisodeGroups()
@@ -273,6 +275,16 @@ const targetDirectories = computed(() => {
return downloadDirectories.value.map(item => item.download_path)
})
// 仅电视剧订阅支持全集洗版,电影保持原有洗版逻辑
const isTvSubscribe = computed(() => props.type === '电视剧' || subscribeForm.value.type === '电视剧')
watch(
() => subscribeForm.value.best_version,
bestVersion => {
if (!bestVersion) subscribeForm.value.best_version_full = false
},
)
onMounted(() => {
queryFilterRuleGroups()
loadDownloadDirectories()
@@ -426,6 +438,14 @@ onMounted(() => {
persistent-hint
/>
</VCol>
<VCol v-if="isTvSubscribe && subscribeForm.best_version" cols="12" md="4">
<VSwitch
v-model="subscribeForm.best_version_full"
:label="t('dialog.subscribeEdit.bestVersionFull')"
:hint="t('dialog.subscribeEdit.bestVersionFullHint')"
persistent-hint
/>
</VCol>
<VCol cols="12" md="4">
<VSwitch
v-model="subscribeForm.search_imdbid"

View File

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

View File

@@ -5,12 +5,22 @@ import api from '@/api'
import { FileItem, TransferQueue } from '@/api/types'
import { useDisplay } from 'vuetify'
import { useI18n } from 'vue-i18n'
import { useBackgroundOptimization } from '@/composables/useBackgroundOptimization'
import { useBackground } from '@/composables/useBackground'
import CryptoJS from 'crypto-js'
type TransferTask = TransferQueue['tasks'][number]
interface MediaTaskGroup {
media: TransferQueue['media']
titleYear: string
tasks: TransferTask[]
total: number
completed: number
}
// 多语言支持
const { t } = useI18n()
const { useProgressSSE } = useBackgroundOptimization()
const { useProgressSSE } = useBackground()
// 显示器宽度
const display = useDisplay()
@@ -29,9 +39,6 @@ const overallProgress = ref({
// 文件进度映射
const fileProgressMap = ref<Map<string, { enable: boolean; value: number }>>(new Map())
// 数据可刷新标志
const refreshFlag = ref(false)
// 进度是否激活
const progressActive = ref(false)
@@ -58,49 +65,58 @@ function getStateColor(state: string) {
else return 'error'
}
// 从dataList中提取所有的媒体信息合并相同title_year的记录
const mediaList = computed(() => {
const mediaMap = new Map<string, any>()
// 按媒体聚合队列,避免模板中按 tab 重复扫描 dataList
const mediaTaskGroups = computed<MediaTaskGroup[]>(() => {
const groupMap = new Map<string, MediaTaskGroup>()
dataList.value.forEach(item => {
const titleYear = item.media.title_year || ''
if (!mediaMap.has(titleYear)) {
mediaMap.set(titleYear, item.media)
let group = groupMap.get(titleYear)
if (!group) {
group = {
media: item.media,
titleYear,
tasks: [],
total: 0,
completed: 0,
}
groupMap.set(titleYear, group)
}
group.tasks.push(...item.tasks)
group.total += item.tasks.length
group.completed += item.tasks.filter(task => task.state === 'completed').length
})
return Array.from(mediaMap.values())
return Array.from(groupMap.values())
})
// 从dataList中提取所有的媒体信息合并相同title_year的记录
const mediaList = computed(() => {
return mediaTaskGroups.value.map(group => group.media)
})
// 按media计算总数和完成数返回 x/x
function getMediaCount(title_year: string) {
// 按title_year查询出所有media列表
const medias = dataList.value.filter(item => item.media.title_year === title_year)
// 计算media下任务的总数
const total = medias.reduce((acc, cur) => acc + cur.tasks.length, 0)
// 计算media下任务的完成数
const completed = medias.reduce((acc, cur) => acc + cur.tasks.filter(task => task.state === 'completed').length, 0)
return `${completed} / ${total}`
const group = mediaTaskGroups.value.find(item => item.titleYear === title_year)
return `${group?.completed ?? 0} / ${group?.total ?? 0}`
}
// 根据媒体信息获取对应的整理任务合并相同title_year的所有任务
const activeTasks = computed(() => {
const tasks = dataList.value.filter(item => item.media.title_year === activeTab.value).flatMap(item => item.tasks)
return tasks
return mediaTaskGroups.value.find(item => item.titleYear === activeTab.value)?.tasks ?? []
})
// 根据媒体title_year获取对应的任务列表
function getTasksByMedia(title_year: string) {
return dataList.value.filter(item => item.media.title_year === title_year).flatMap(item => item.tasks)
return mediaTaskGroups.value.find(item => item.titleYear === title_year)?.tasks ?? []
}
// 计算整体进度
const overallProgressComputed = computed(() => {
if (dataList.value.length === 0) return 0
const allTasks = dataList.value.flatMap(item => item.tasks)
const totalTasks = allTasks.length
const completedTasks = allTasks.filter(task => task.state === 'completed').length
const totalTasks = mediaTaskGroups.value.reduce((total, group) => total + group.total, 0)
const completedTasks = mediaTaskGroups.value.reduce((total, group) => total + group.completed, 0)
return totalTasks > 0 ? (completedTasks / totalTasks) * 100 : 0
})

View File

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

View File

@@ -11,13 +11,14 @@ import ProgressDialog from '../dialog/ProgressDialog.vue'
import { useDisplay } from 'vuetify'
import MediaInfoDialog from '../dialog/MediaInfoDialog.vue'
import { useI18n } from 'vue-i18n'
import { useBackgroundOptimization } from '@/composables/useBackgroundOptimization'
import { useBackground } from '@/composables/useBackground'
import { usePWA } from '@/composables/usePWA'
import { useAvailableHeight } from '@/composables/useAvailableHeight'
import { useKeepAliveRefresh, type KeepAliveRefreshContext } from '@/composables/useKeepAliveRefresh'
// 国际化
const { t } = useI18n()
const { useProgressSSE } = useBackgroundOptimization()
const { useProgressSSE } = useBackground()
// 显示器宽度
const display = useDisplay()
@@ -43,6 +44,10 @@ const inProps = defineProps({
},
sort: String,
showTree: Boolean,
active: {
type: Boolean,
default: true,
},
})
// 对外事件
@@ -107,6 +112,47 @@ const currentItem = ref<FileItem>()
// 选中的项目
const selected = ref<FileItem[]>([])
function getFileItemKey(item?: FileItem) {
return [item?.storage ?? inProps.item.storage ?? '', item?.type ?? '', item?.path ?? ''].join('|')
}
function dedupeFileItems(fileItems: FileItem[]) {
const uniqueItems = new Map<string, FileItem>()
fileItems.forEach(item => {
uniqueItems.set(getFileItemKey(item), item)
})
return Array.from(uniqueItems.values())
}
function syncSelectedItems(nextItems: FileItem[] = items.value) {
if (!selected.value.length) return
const currentItemMap = new Map(nextItems.map(item => [getFileItemKey(item), item]))
selected.value = dedupeFileItems(selected.value)
.map(item => currentItemMap.get(getFileItemKey(item)))
.filter((item): item is FileItem => !!item)
}
const selectedKeys = computed(() => new Set(selected.value.map(item => getFileItemKey(item))))
function isSelected(item: FileItem) {
return selectedKeys.value.has(getFileItemKey(item))
}
function setItemSelected(item: FileItem, checked: boolean) {
const itemKey = getFileItemKey(item)
if (checked) {
if (!selectedKeys.value.has(itemKey)) {
selected.value = [...selected.value, item]
}
return
}
selected.value = selected.value.filter(selectedItem => getFileItemKey(selectedItem) !== itemKey)
}
// 识别结果
const nameTestResult = ref<Context>()
@@ -119,26 +165,46 @@ const dropdownItems = ref<{ [key: string]: any }[]>([])
// 进度是否激活
const progressActive = ref(false)
// 通用过滤
const getFilteredItems = (type: 'dir' | 'file') => {
const filterValue = filter.value
if (!filterValue) {
return items.value.filter(item => item.type === type)
}
if (ignoreCase.value) {
const lowerCaseFilter = filterValue.toLowerCase()
return items.value.filter(item => item.type === type && item.name.toLowerCase().includes(lowerCaseFilter))
} else {
return items.value.filter(item => item.type === type && item.name.includes(filterValue))
}
// 将 glob 模式转换为正则表达式
function globToRegex(pattern: string, flags: string = ''): RegExp {
const regexStr = pattern
.replace(/[.+^${}()|[\]\\]/g, '\\$&')
.replace(/\*/g, '.*')
.replace(/\?/g, '.')
return new RegExp(`^${regexStr}$`, flags)
}
// 通用过滤
const filteredItems = computed(() => {
const filterValue = filter.value
if (!filterValue) {
return items.value
}
// 通配符模式
if (filterValue.includes('*') || filterValue.includes('?')) {
const flags = ignoreCase.value ? 'i' : ''
const regex = globToRegex(filterValue, flags)
return items.value.filter(item => regex.test(item.name ?? ''))
}
// 子字符串模式
if (ignoreCase.value) {
const lowerCaseFilter = filterValue.toLowerCase()
return items.value.filter(item => (item.name ?? '').toLowerCase().includes(lowerCaseFilter))
} else {
return items.value.filter(item => (item.name ?? '').includes(filterValue))
}
})
// 目录过滤
const dirs = computed(() => getFilteredItems('dir'))
const dirs = computed(() => filteredItems.value.filter(item => item.type === 'dir'))
// 文件过滤
const files = computed(() => getFilteredItems('file'))
const files = computed(() => filteredItems.value.filter(item => item.type === 'file'))
// 虚拟列表数据,保持引用稳定,避免模板内联展开数组导致虚拟列表重算。
const displayItems = computed(() => [...dirs.value, ...files.value])
// 是否文件
const isFile = computed(() => inProps.item.type == 'file')
@@ -168,33 +234,45 @@ function changeSelectMode() {
}
// 调API加载文件夹内的内容
async function list_files() {
loading.value = true
const takeURISnapshot = () => [inProps.item.storage, inProps.item.path].join(':/');
const prevURI = takeURISnapshot();
emit('loading', true)
async function list_files(context: KeepAliveRefreshContext = {}) {
const silentRefresh = Boolean(context.silent && items.value.length > 0)
const takeURISnapshot = () => [inProps.item.storage, inProps.item.path].join(':/')
const prevURI = takeURISnapshot()
// 参数
const url = inProps.endpoints?.list.url.replace(/{sort}/g, inProps.sort || 'name')
const config: AxiosRequestConfig<FileItem> = {
url,
method: inProps.endpoints?.list.method || 'get',
data: inProps.item,
if (!silentRefresh) {
loading.value = true
emit('loading', true)
}
// 加载数据
const data = ((await inProps.axios.request<FileItem[], FileItem[]>(config))) ?? []
// 如果当前路径已经变化,则放弃此次加载结果
if (prevURI !== takeURISnapshot()) {
return;
}
items.value = data
emit('loading', false)
loading.value = false
try {
// 参数
const url = inProps.endpoints?.list.url.replace(/{sort}/g, inProps.sort || 'name')
// 通知父组件文件列表更新
emit('items-updated', items.value)
const config: AxiosRequestConfig<FileItem> = {
url,
method: inProps.endpoints?.list.method || 'get',
data: inProps.item,
}
// 加载数据
const data = ((await inProps.axios.request<FileItem[], FileItem[]>(config))) ?? []
// 如果当前路径已经变化,则放弃此次加载结果
if (prevURI !== takeURISnapshot()) {
return
}
items.value = data
syncSelectedItems(data)
// 通知父组件文件列表更新
emit('items-updated', items.value)
} catch (error) {
console.error(error)
} finally {
if (!silentRefresh) {
emit('loading', false)
loading.value = false
}
}
}
// 删除项目
@@ -265,13 +343,7 @@ function changePath(item: FileItem) {
// 点击列表项
function listItemClick(item: FileItem) {
if (selectMode.value) {
if (selected.value.includes(item)) {
selected.value = selected.value.filter(i => i !== item)
} else {
selected.value.push(item)
}
// 去重
selected.value = Array.from(new Set(selected.value))
setItemSelected(item, !isSelected(item))
return false
}
changePath(item)
@@ -416,7 +488,7 @@ function showTransfer(item: FileItem) {
// 显示批量整理对话框
function showBatchTransfer() {
transferItems.value = selected.value
transferItems.value = dedupeFileItems(selected.value)
transferPopper.value = true
}
@@ -453,6 +525,7 @@ watch(
async () => {
// 清空列表
items.value = []
selected.value = []
// 关闭弹窗
nameTestResult.value = undefined
nameTestDialog.value = false
@@ -585,7 +658,7 @@ function handleProgressMessage(event: MessageEvent) {
}
}
// 使用优化的进度SSE连接
// 使用进度SSE连接
const progressSSE = useProgressSSE(
`${import.meta.env.VITE_API_BASE_URL}system/progress/batchrename`,
handleProgressMessage,
@@ -606,8 +679,8 @@ function stopLoadingProgress() {
progressSSE.stop()
}
onMounted(() => {
list_files()
useKeepAliveRefresh(list_files, {
active: computed(() => inProps.active),
})
onUnmounted(() => {
@@ -639,8 +712,8 @@ onUnmounted(() => {
flat
density="compact"
variant="plain"
:placeholder="t('common.search')"
prepend-inner-icon="mdi-filter-outline"
:placeholder="t('file.filterPlaceholder')"
:prepend-inner-icon="(filter.includes('*') || filter.includes('?')) ? 'mdi-asterisk' : 'mdi-filter-outline'"
class="mx-2"
rounded
/>
@@ -699,14 +772,18 @@ onUnmounted(() => {
class="text-high-emphasis file-list-container"
:style="{ height: `${listAvailableHeight}px`, maxHeight: `${listAvailableHeight}px` }"
>
<VVirtualScroll :items="[...dirs, ...files]" style="block-size: 100%">
<VVirtualScroll :items="displayItems" style="block-size: 100%">
<template #default="{ item }">
<VHover>
<template #default="hover">
<VListItem v-bind="hover.props" class="px-3 pe-1" @click="listItemClick(item)">
<template #prepend>
<VListItemAction v-if="selectMode">
<VCheckbox v-model="selected" :value="item" />
<VCheckbox
:model-value="isSelected(item)"
@update:model-value="setItemSelected(item, !!$event)"
@click.stop
/>
</VListItemAction>
<template v-else>
<VIcon

View File

@@ -14,6 +14,11 @@ const display = useDisplay()
const { appMode } = usePWA()
type TreeRow =
| { type: 'root'; key: string; level: number }
| { type: 'loading'; key: string; path: string; level: number }
| { type: 'directory'; key: string; dir: FileItem; level: number }
// 计算列表可用高度
// componentOffset = FileToolbar(48) = 48
const { availableHeight } = useAvailableHeight(48, 300)
@@ -132,37 +137,6 @@ async function loadRootDirectories() {
await loadSubdirectories('/')
}
// 检索所有目录节点
function getAllDirectories() {
const allDirs: { dir: FileItem; level: number; parentPath: string }[] = []
// 添加根目录的子目录
if (treeCache.value['/']) {
treeCache.value['/'].forEach(dir => {
allDirs.push({ dir, level: 0, parentPath: '/' })
addSubdirectories(dir.path || '', 1, allDirs)
})
}
return allDirs
}
// 递归添加子目录
function addSubdirectories(
parentPath: string,
level: number,
result: { dir: FileItem; level: number; parentPath: string }[],
) {
if (treeCache.value[parentPath]) {
treeCache.value[parentPath].forEach(dir => {
result.push({ dir, level, parentPath })
if (isFolderExpanded(dir.path || '')) {
addSubdirectories(dir.path || '', level + 1, result)
}
})
}
}
// 监听当前路径变化,自动展开当前路径
watch(
() => props.currentPath,
@@ -224,38 +198,51 @@ const rootDirectories = computed(() => {
return treeCache.value['/'] || []
})
// 扁平化的目录树
const flattenedDirectories = computed(() => {
return getAllDirectories()
})
// 只生成当前可见的目录行,避免折叠/隐藏节点继续留在 DOM 中
const visibleTreeRows = computed<TreeRow[]>(() => {
const rows: TreeRow[] = [{ type: 'root', key: 'root', level: 0 }]
// 检查路径是否为指定目录的子目录或后代
function isChildOrDescendant(path: string, ancestorPath: string) {
if (!path || !ancestorPath) return false
if (ancestorPath === '/') return true
// 确保路径以斜杠结尾,便于比较
const normalizedPath = path.endsWith('/') ? path : path + '/'
const normalizedAncestorPath = ancestorPath.endsWith('/') ? ancestorPath : ancestorPath + '/'
// 检查路径是否以祖先路径开头,但不是祖先路径本身
return normalizedPath.startsWith(normalizedAncestorPath) && normalizedPath !== normalizedAncestorPath
}
// 计算目录相对于其祖先的缩进级别
function getIndentLevel(path: string, ancestorPath: string) {
if (!path || !ancestorPath) return 0
// 根目录特殊处理
if (ancestorPath === '/') {
return path.split('/').filter(p => p).length - 1
if (loading.value['/']) {
rows.push({ type: 'loading', key: 'loading:/', path: '/', level: 0 })
return rows
}
// 计算路径中斜杠的数量差异
const pathParts = path.split('/').filter(p => p).length
const ancestorParts = ancestorPath.split('/').filter(p => p).length
rootDirectories.value.forEach(dir => addVisibleDirectoryRows(dir, 0, rows))
return pathParts - ancestorParts
return rows
})
function addVisibleDirectoryRows(dir: FileItem, level: number, rows: TreeRow[]) {
const path = dir.path || ''
rows.push({
type: 'directory',
key: path || `${level}:${dir.name}`,
dir,
level,
})
if (!path || !isFolderExpanded(path)) {
return
}
if (loading.value[path]) {
rows.push({
type: 'loading',
key: `loading:${path}`,
path,
level: level + 1,
})
return
}
treeCache.value[path]?.forEach(child => addVisibleDirectoryRows(child, level + 1, rows))
}
function getTreeRowStyle(level: number) {
return {
paddingInlineStart: level > 0 ? `${16 + level * 12}px` : undefined,
}
}
// 组件挂载时初始加载
@@ -267,117 +254,75 @@ onMounted(async () => {
<template>
<VCard class="file-navigator rounded-e-0 rounded-t-0" v-if="!isMobile" :height="`${availableHeight}px`">
<div class="tree-container">
<!-- 根目录项 -->
<div
class="tree-item root-item"
:class="{ 'active': currentPath === '/' }"
@click="
handleFolderClick({
storage: storage,
type: 'dir',
name: '/',
path: '/',
})
"
>
<div class="folder-content">
<VIcon icon="mdi-home" class="me-2" color="primary" />
<span>{{ t('file.rootDirectory') }}</span>
</div>
</div>
<!-- 加载根目录 -->
<div v-if="loading['/']" class="tree-loading">
<VProgressCircular indeterminate size="24" color="primary" class="ma-2" />
<span>{{ t('file.loadingDirectoryStructure') }}</span>
</div>
<!-- 目录树结构 -->
<template v-else>
<!-- 一级目录(根目录下的目录) -->
<div v-for="directory in rootDirectories" :key="directory.path" class="tree-item-container">
<!-- 目录项 -->
<div class="tree-item" :class="{ 'active': currentPath === directory.path }">
<div class="folder-toggle" @click.stop="toggleFolder(directory.path || '')">
<VProgressCircular
v-if="loading[directory.path || '']"
indeterminate
size="14"
width="2"
color="primary"
/>
<VIcon
v-else
size="small"
:icon="isFolderExpanded(directory.path || '') ? 'mdi-chevron-down' : 'mdi-chevron-right'"
/>
</div>
<div class="folder-content" @click.stop="handleFolderClick(directory)">
<VIcon
size="small"
:icon="renderFolderIcon(isFolderExpanded(directory.path || ''))"
:color="currentPath === directory.path ? 'primary' : 'amber-darken-1'"
class="me-1"
/>
<span class="folder-name">
{{ directory.name }}
</span>
</div>
<VVirtualScroll :items="visibleTreeRows" :item-height="32" class="tree-container">
<template #default="{ item }">
<div
v-if="item.type === 'root'"
:key="item.key"
class="tree-item root-item"
:class="{ 'active': currentPath === '/' }"
@click="
handleFolderClick({
storage: storage,
type: 'dir',
name: '/',
path: '/',
})
"
>
<div class="folder-content">
<VIcon icon="mdi-home" class="me-2" color="primary" />
<span>{{ t('file.rootDirectory') }}</span>
</div>
</div>
<!-- 子目录容器 - 如果该目录被展开显示其所有子目录 -->
<div v-if="isFolderExpanded(directory.path || '')">
<!-- 加载中状态 -->
<div v-if="loading[directory.path || '']" class="tree-loading pl-8">
<VProgressCircular indeterminate size="14" color="primary" class="ma-2" />
<span class="text-caption">{{ t('common.loading') }}</span>
</div>
<div
v-else-if="item.type === 'loading'"
:key="item.key"
class="tree-loading"
:style="getTreeRowStyle(item.level)"
>
<VProgressCircular indeterminate size="14" color="primary" class="ma-2" />
<span class="text-caption">
{{ item.path === '/' ? t('file.loadingDirectoryStructure') : t('common.loading') }}
</span>
</div>
<!-- 所有层级的子目录列表 -->
<div v-else>
<!-- 遍历所有扁平化的目录列表查找对应层级的目录 -->
<div
v-for="item in flattenedDirectories"
:key="item.dir.path"
v-show="isChildOrDescendant(item.dir.path || '', directory.path || '')"
class="tree-item"
:class="{ 'active': currentPath === item.dir.path }"
:style="{ paddingLeft: 16 + getIndentLevel(item.dir.path || '', directory.path || '') * 12 + 'px' }"
>
<!-- 展开/折叠按钮 -->
<div class="folder-toggle" @click.stop="toggleFolder(item.dir.path || '')">
<VProgressCircular
v-if="loading[item.dir.path || '']"
indeterminate
size="14"
width="2"
color="primary"
/>
<VIcon
v-else
size="small"
:icon="isFolderExpanded(item.dir.path || '') ? 'mdi-chevron-down' : 'mdi-chevron-right'"
/>
</div>
<!-- 文件夹图标和名称 -->
<div class="folder-content" @click.stop="handleFolderClick(item.dir)">
<VIcon
size="small"
:icon="renderFolderIcon(isFolderExpanded(item.dir.path || ''))"
:color="currentPath === item.dir.path ? 'primary' : 'amber-darken-1'"
class="me-1"
/>
<span class="folder-name">
{{ item.dir.name }}
</span>
</div>
</div>
</div>
<div
v-else
:key="item.key"
class="tree-item"
:class="{ 'active': currentPath === item.dir.path }"
:style="getTreeRowStyle(item.level)"
>
<div class="folder-toggle" @click.stop="toggleFolder(item.dir.path || '')">
<VProgressCircular
v-if="loading[item.dir.path || '']"
indeterminate
size="14"
width="2"
color="primary"
/>
<VIcon
v-else
size="small"
:icon="isFolderExpanded(item.dir.path || '') ? 'mdi-chevron-down' : 'mdi-chevron-right'"
/>
</div>
<div class="folder-content" @click.stop="handleFolderClick(item.dir)">
<VIcon
size="small"
:icon="renderFolderIcon(isFolderExpanded(item.dir.path || ''))"
:color="currentPath === item.dir.path ? 'primary' : 'amber-darken-1'"
class="me-1"
/>
<span class="folder-name">
{{ item.dir.name }}
</span>
</div>
</div>
</template>
</div>
</VVirtualScroll>
</VCard>
</template>
@@ -402,8 +347,8 @@ onMounted(async () => {
}
.tree-container {
overflow: hidden auto;
flex: 1;
min-block-size: 0;
}
.tree-item-container {

View File

@@ -218,7 +218,7 @@ onMounted(() => {
<template>
<!-- PC端头部和筛选栏 -->
<div class="search-header d-none d-sm-block">
<VCard class="view-header mb-3">
<VCard class="view-header filter-toolbar-card mb-3" elevation="0">
<div class="d-flex align-center pa-3">
<!-- 固定位置资源数量和排序 -->
<div class="d-flex align-center flex-shrink-0">
@@ -405,7 +405,7 @@ onMounted(() => {
</div>
<!-- 移动端头部和筛选区域 -->
<VCard class="d-block d-sm-none search-header-mobile mb-3">
<VCard class="d-block d-sm-none search-header-mobile filter-toolbar-card mb-3" elevation="0">
<div class="view-header">
<div class="d-flex align-center flex-wrap pa-2">
<div class="d-flex align-center w-100">
@@ -664,6 +664,13 @@ onMounted(() => {
overflow: hidden;
}
.filter-toolbar-card {
overflow: hidden;
border: 1px solid rgba(var(--v-theme-on-surface), 0.08);
border-radius: 8px;
background: rgba(var(--v-theme-surface), 0.82);
}
.search-count {
font-weight: 500;
}
@@ -695,7 +702,7 @@ onMounted(() => {
display: flex;
flex-wrap: nowrap;
align-items: center;
gap: 4px;
gap: 6px;
overflow-x: auto;
flex: 1;
width: 0;
@@ -722,6 +729,7 @@ onMounted(() => {
.filter-btn {
min-inline-size: 0;
background: rgba(var(--v-theme-surface-variant), 0.1);
transition: opacity 0.2s;
}
@@ -770,8 +778,9 @@ onMounted(() => {
.selected-filters {
overflow: hidden;
background-color: rgba(var(--v-theme-surface-variant), 0.08);
padding-block: 8px;
border-block-start: 1px solid rgba(var(--v-theme-on-surface), 0.08);
background-color: rgba(var(--v-theme-surface-variant), 0.05);
padding-block: 7px;
padding-inline: 12px;
}
@@ -788,7 +797,7 @@ onMounted(() => {
justify-content: center;
border: 1px solid rgba(var(--v-theme-on-surface), 0.08);
border-radius: 8px;
background-color: rgba(var(--v-theme-surface), 0.5);
background-color: rgba(var(--v-theme-surface-variant), 0.08);
block-size: auto;
min-block-size: 48px;
padding-block: 4px;
@@ -814,4 +823,21 @@ onMounted(() => {
.filter-section {
background-color: rgba(var(--v-theme-surface-variant), 0.08);
}
@media (width <= 600px) {
.filter-toolbar-card {
border-radius: 8px;
}
.filter-buttons-grid {
gap: 6px;
}
.filter-label {
overflow: hidden;
max-inline-size: 100%;
text-overflow: ellipsis;
white-space: nowrap;
}
}
</style>

View File

@@ -1,21 +1,70 @@
<script setup lang="ts">
import { h, resolveComponent } from 'vue'
import api from '@/api'
import { DashboardItem } from '@/api/types'
import AnalyticsMediaStatistic from '@/views/dashboard/AnalyticsMediaStatistic.vue'
import AnalyticsScheduler from '@/views/dashboard/AnalyticsScheduler.vue'
import AnalyticsSpeed from '@/views/dashboard/AnalyticsSpeed.vue'
import AnalyticsStorage from '@/views/dashboard/AnalyticsStorage.vue'
import AnalyticsWeeklyOverview from '@/views/dashboard/AnalyticsWeeklyOverview.vue'
import AnalyticsCpu from '@/views/dashboard/AnalyticsCpu.vue'
import AnalyticsMemory from '@/views/dashboard/AnalyticsMemory.vue'
import AnalyticsNetwork from '@/views/dashboard/AnalyticsNetwork.vue'
import MediaServerLatest from '@/views/dashboard/MediaServerLatest.vue'
import MediaServerLibrary from '@/views/dashboard/MediaServerLibrary.vue'
import MediaServerPlaying from '@/views/dashboard/MediaServerPlaying.vue'
import DashboardRender from '@/components/render/DashboardRender.vue'
import { isNullOrEmptyObject } from '@/@core/utils'
import { loadRemoteComponent } from '@/utils/federationLoader'
const DashboardSkeleton = {
setup() {
const SkeletonLoader = resolveComponent('VSkeletonLoader')
// 用 render 函数避免 runtime-only Vue 为异步 loadingComponent 解析模板。
return () => h(SkeletonLoader, { type: 'card' })
},
}
const asyncDashboardOptions = {
loadingComponent: DashboardSkeleton,
}
// 内置仪表盘按需加载,关闭的卡片不再挤进 dashboard 首屏 chunk。
const AnalyticsStorage = defineAsyncComponent({
loader: () => import('@/views/dashboard/AnalyticsStorage.vue'),
...asyncDashboardOptions,
})
const AnalyticsMediaStatistic = defineAsyncComponent({
loader: () => import('@/views/dashboard/AnalyticsMediaStatistic.vue'),
...asyncDashboardOptions,
})
const AnalyticsWeeklyOverview = defineAsyncComponent({
loader: () => import('@/views/dashboard/AnalyticsWeeklyOverview.vue'),
...asyncDashboardOptions,
})
const AnalyticsSpeed = defineAsyncComponent({
loader: () => import('@/views/dashboard/AnalyticsSpeed.vue'),
...asyncDashboardOptions,
})
const AnalyticsScheduler = defineAsyncComponent({
loader: () => import('@/views/dashboard/AnalyticsScheduler.vue'),
...asyncDashboardOptions,
})
const AnalyticsCpu = defineAsyncComponent({
loader: () => import('@/views/dashboard/AnalyticsCpu.vue'),
...asyncDashboardOptions,
})
const AnalyticsMemory = defineAsyncComponent({
loader: () => import('@/views/dashboard/AnalyticsMemory.vue'),
...asyncDashboardOptions,
})
const AnalyticsNetwork = defineAsyncComponent({
loader: () => import('@/views/dashboard/AnalyticsNetwork.vue'),
...asyncDashboardOptions,
})
const MediaServerLibrary = defineAsyncComponent({
loader: () => import('@/views/dashboard/MediaServerLibrary.vue'),
...asyncDashboardOptions,
})
const MediaServerPlaying = defineAsyncComponent({
loader: () => import('@/views/dashboard/MediaServerPlaying.vue'),
...asyncDashboardOptions,
})
const MediaServerLatest = defineAsyncComponent({
loader: () => import('@/views/dashboard/MediaServerLatest.vue'),
...asyncDashboardOptions,
})
// 输入参数
const props = defineProps({
// 仪表板配置
@@ -53,9 +102,7 @@ const dynamicPluginComponent = defineAsyncComponent({
}
},
// 加载中显示的组件
loadingComponent: {
template: '<VSkeletonLoader type="card"></VSkeletonLoader>',
},
loadingComponent: DashboardSkeleton,
// 添加错误处理
errorComponent: {
template: `

View File

@@ -0,0 +1,959 @@
<script setup lang="ts">
import type { ComponentPublicInstance } from 'vue'
type ItemKey = string | number
type ScrollTarget = Window | HTMLElement
const props = withDefaults(
defineProps<{
items: any[]
minItemWidth?: number
itemAspectRatio?: number
estimatedItemHeight?: number
scrollToIndex?: number
gap?: number
columns?: 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,
columns: undefined,
initialCount: 24,
batchSize: 24,
overscanRows: 4,
getItemKey: undefined,
},
)
interface VirtualCell {
item: any
index: number
key: ItemKey
}
interface VirtualRange {
endIndex: number
endRow: number
startIndex: number
startRow: number
}
const containerRef = ref<HTMLElement | null>(null)
const trackRef = ref<HTMLElement | null>(null)
const layoutWidth = ref(0)
const viewportTop = ref(0)
const viewportBottom = ref(0)
const heightVersion = ref(0)
const frozenVisibleRange = ref<VirtualRange | null>(null)
const isOverlayGrid = ref(false)
const itemHeights = new Map<ItemKey, number>()
const observedElements = new Map<HTMLElement, ItemKey>()
const keyElements = new Map<ItemKey, HTMLElement>()
const itemRefCallbacks = new Map<ItemKey, (element: Element | ComponentPublicInstance | null) => void>()
let resizeObserver: ResizeObserver | null = null
let itemResizeObserver: ResizeObserver | null = null
let overlayLockObserver: MutationObserver | null = null
let scrollTarget: ScrollTarget | null = null
let layoutFrameId: number | null = null
let scrollFrameId: number | null = null
let mounted = false
let pendingRevealIndex: number | null = null
let lastMeasuredColumnCount = 0
let lastMeasuredColumnWidth = 0
const safeGap = computed(() => Math.max(0, props.gap))
const safeMinItemWidth = computed(() => Math.max(1, props.minItemWidth))
const safeOverscanRows = computed(() => Math.max(1, props.overscanRows))
const columnCount = computed(() => {
if (props.columns && props.columns > 0) {
return Math.max(1, Math.floor(props.columns))
}
if (!layoutWidth.value) {
return 1
}
return Math.max(1, Math.floor((layoutWidth.value + safeGap.value) / (safeMinItemWidth.value + safeGap.value)))
})
const columnWidth = computed(() => {
const columns = columnCount.value
const width = layoutWidth.value || safeMinItemWidth.value
return Math.max(1, (width - safeGap.value * (columns - 1)) / columns)
})
const estimatedHeight = computed(() => {
if (props.estimatedItemHeight && props.estimatedItemHeight > 0) {
return props.estimatedItemHeight
}
return Math.max(1, columnWidth.value * props.itemAspectRatio)
})
const itemKeys = computed(() => props.items.map((item, index) => getComparableKey(item, index)))
const keyIndexMap = computed(() => {
const map = new Map<ItemKey, number>()
itemKeys.value.forEach((key, index) => {
map.set(key, index)
})
return map
})
const rowMetrics = computed(() => {
heightVersion.value
const rows = Math.ceil(props.items.length / columnCount.value)
const heights: number[] = []
const measuredRows: boolean[] = []
const offsets: number[] = [0]
for (let row = 0; row < rows; row += 1) {
const startIndex = row * columnCount.value
const endIndex = Math.min(startIndex + columnCount.value, props.items.length)
let rowHeight = 0
let hasUnmeasuredItem = false
for (let index = startIndex; index < endIndex; index += 1) {
const height = itemHeights.get(itemKeys.value[index])
if (height && height > 0) {
rowHeight = Math.max(rowHeight, height)
} else {
hasUnmeasuredItem = true
}
}
if (hasUnmeasuredItem) {
rowHeight = Math.max(rowHeight, estimatedHeight.value)
} else {
rowHeight = Math.max(rowHeight, 1)
}
heights.push(rowHeight)
measuredRows.push(!hasUnmeasuredItem)
offsets.push(offsets[row] + rowHeight + (row < rows - 1 ? safeGap.value : 0))
}
return {
heights,
measuredRows,
offsets,
rowCount: rows,
totalHeight: offsets[rows] ?? 0,
}
})
const totalHeight = computed(() => rowMetrics.value.totalHeight)
const calculatedVisibleRange = computed<VirtualRange>(() => {
if (isOverlayGrid.value) {
const rowCount = Math.max(1, Math.ceil(props.items.length / columnCount.value))
return {
endIndex: props.items.length,
endRow: rowCount - 1,
startIndex: 0,
startRow: 0,
}
}
const { heights, offsets, rowCount } = rowMetrics.value
if (!props.items.length || rowCount === 0) {
return {
endIndex: 0,
endRow: 0,
startIndex: 0,
startRow: 0,
}
}
const top = Math.max(0, Math.min(viewportTop.value, totalHeight.value))
const bottom = Math.max(top, Math.min(viewportBottom.value, totalHeight.value))
const firstVisibleRow = findFirstRowAtOrAfterOffset(offsets, heights, top)
const lastVisibleRow = findLastRowAtOrBeforeOffset(offsets, rowCount, bottom)
const startRow = clamp(firstVisibleRow - safeOverscanRows.value, 0, rowCount - 1)
const endRow = clamp(lastVisibleRow + safeOverscanRows.value, startRow, rowCount - 1)
return {
endIndex: Math.min(props.items.length, (endRow + 1) * columnCount.value),
endRow,
startIndex: startRow * columnCount.value,
startRow,
}
})
const visibleRange = computed(() => frozenVisibleRange.value ?? calculatedVisibleRange.value)
const visibleCells = computed<VirtualCell[]>(() => {
const cells: VirtualCell[] = []
for (let index = visibleRange.value.startIndex; index < visibleRange.value.endIndex; index += 1) {
cells.push({
item: props.items[index],
index,
key: itemKeys.value[index],
})
}
return cells
})
const topSpacerHeight = computed(() => {
if (isOverlayGrid.value) {
return 0
}
return rowMetrics.value.offsets[visibleRange.value.startRow] ?? 0
})
const visibleBlockHeight = computed(() => {
if (!props.items.length || visibleRange.value.endIndex <= visibleRange.value.startIndex) {
return 0
}
return Math.max(
(rowMetrics.value.offsets[visibleRange.value.endRow] ?? 0) +
(rowMetrics.value.heights[visibleRange.value.endRow] ?? 0) -
(rowMetrics.value.offsets[visibleRange.value.startRow] ?? 0),
0,
)
})
const bottomSpacerHeight = computed(() => {
if (isOverlayGrid.value) {
return 0
}
return Math.max(totalHeight.value - topSpacerHeight.value - visibleBlockHeight.value, 0)
})
const gridStyle = computed(() => ({
columnGap: `${safeGap.value}px`,
gridTemplateColumns: `repeat(${columnCount.value}, minmax(0, 1fr))`,
rowGap: `${safeGap.value}px`,
}))
function clamp(value: number, min: number, max: number) {
return Math.min(Math.max(value, min), max)
}
function getComparableKey(item: any, index: number): ItemKey {
if (props.getItemKey) {
return props.getItemKey(item, index)
}
return index
}
function getFallbackLayoutWidth() {
if (typeof window === 'undefined') {
return safeMinItemWidth.value
}
// keep-alive 激活首帧可能还拿不到网格宽度,先用视口宽度兜底,避免只渲染一小列。
return Math.max(document.documentElement.clientWidth || window.innerWidth || 0, safeMinItemWidth.value)
}
function findFirstRowAtOrAfterOffset(offsets: number[], heights: number[], offset: number) {
let low = 0
let high = heights.length - 1
let answer = 0
while (low <= high) {
const mid = Math.floor((low + high) / 2)
const rowEnd = offsets[mid] + heights[mid]
if (rowEnd >= offset) {
answer = mid
high = mid - 1
} else {
low = mid + 1
}
}
return answer
}
function findLastRowAtOrBeforeOffset(offsets: number[], rowCount: number, offset: number) {
let low = 0
let high = rowCount - 1
let answer = 0
while (low <= high) {
const mid = Math.floor((low + high) / 2)
if (offsets[mid] <= offset) {
answer = mid
low = mid + 1
} else {
high = mid - 1
}
}
return answer
}
function isDocumentOverlayLocked() {
return typeof document !== 'undefined' && document.documentElement.classList.contains('v-overlay-scroll-blocked')
}
function isGridInsideOverlay() {
return Boolean(containerRef.value?.closest('.v-overlay, .v-overlay__content'))
}
function syncOverlayGridState() {
isOverlayGrid.value = isGridInsideOverlay()
}
function shouldPauseVirtualSync() {
return isDocumentOverlayLocked() && !isOverlayGrid.value
}
function freezeVisibleRange() {
if (frozenVisibleRange.value) {
return
}
// 弹窗打开期间固定当前渲染窗口,防止 body 锁滚动造成坐标跳变并卸载触发弹窗的卡片。
frozenVisibleRange.value = { ...calculatedVisibleRange.value }
}
function releaseVisibleRange() {
frozenVisibleRange.value = null
}
function handleOverlayLockChange() {
if (shouldPauseVirtualSync()) {
freezeVisibleRange()
return
}
releaseVisibleRange()
queueLayoutSync()
}
function getElementFromRef(element: Element | ComponentPublicInstance | null): HTMLElement | null {
if (!element || typeof HTMLElement === 'undefined') {
return null
}
if (element instanceof HTMLElement) {
return element
}
if (!('$el' in element)) {
return null
}
const componentElement = element.$el
return componentElement instanceof HTMLElement ? componentElement : null
}
function getRowHeight(row: number) {
const startIndex = row * columnCount.value
const endIndex = Math.min(startIndex + columnCount.value, props.items.length)
let rowHeight = 0
let hasUnmeasuredItem = false
for (let index = startIndex; index < endIndex; index += 1) {
const height = itemHeights.get(itemKeys.value[index])
if (height && height > 0) {
rowHeight = Math.max(rowHeight, height)
} else {
hasUnmeasuredItem = true
}
}
if (hasUnmeasuredItem) {
return Math.max(rowHeight, estimatedHeight.value)
}
return Math.max(rowHeight, 1)
}
function ensureItemResizeObserver() {
if (itemResizeObserver || typeof ResizeObserver === 'undefined') {
return
}
itemResizeObserver = new ResizeObserver(entries => {
if (shouldPauseVirtualSync()) {
freezeVisibleRange()
return
}
let shouldUpdate = false
let scrollAdjustment = 0
const currentViewportTop = viewportTop.value
const currentOffsets = rowMetrics.value.offsets
entries.forEach(entry => {
const element = entry.target
if (!(element instanceof HTMLElement)) {
return
}
const key = observedElements.get(element)
const index = key === undefined ? undefined : keyIndexMap.value.get(key)
if (key === undefined || index === undefined) {
return
}
const nextHeight = getResizeEntryHeight(entry)
const previousHeight = itemHeights.get(key)
if (!nextHeight || Math.abs((previousHeight ?? 0) - nextHeight) < 0.5) {
return
}
const row = Math.floor(index / columnCount.value)
const rowWasFullyMeasured = rowMetrics.value.measuredRows[row]
const previousRowHeight = getRowHeight(row)
const previousRowBottom = (currentOffsets[row] ?? 0) + previousRowHeight
if (
rowWasFullyMeasured &&
previousHeight !== undefined &&
previousHeight < previousRowHeight - 0.5 &&
nextHeight <= previousRowHeight + 0.5
) {
return
}
itemHeights.set(key, nextHeight)
const nextRowHeight = getRowHeight(row)
const delta = nextRowHeight - previousRowHeight
if (Math.abs(delta) >= 0.5 && previousRowBottom < currentViewportTop) {
scrollAdjustment += delta
}
shouldUpdate = true
})
if (!shouldUpdate) {
return
}
heightVersion.value += 1
if (Math.abs(scrollAdjustment) >= 0.5) {
adjustScrollTop(scrollAdjustment)
}
queueViewportSync()
})
}
function getResizeEntryHeight(entry: ResizeObserverEntry) {
const borderSize = Array.isArray(entry.borderBoxSize) ? entry.borderBoxSize[0] : entry.borderBoxSize
return borderSize?.blockSize || entry.contentRect.height
}
function setItemRef(element: Element | ComponentPublicInstance | null, key: ItemKey) {
const htmlElement = getElementFromRef(element)
const previousElement = keyElements.get(key)
if (!htmlElement) {
if (previousElement) {
itemResizeObserver?.unobserve(previousElement)
observedElements.delete(previousElement)
keyElements.delete(key)
}
return
}
if (previousElement === htmlElement) {
return
}
ensureItemResizeObserver()
if (previousElement) {
itemResizeObserver?.unobserve(previousElement)
observedElements.delete(previousElement)
}
observedElements.set(htmlElement, key)
keyElements.set(key, htmlElement)
itemResizeObserver?.observe(htmlElement)
}
function getItemRef(key: ItemKey) {
const existingCallback = itemRefCallbacks.get(key)
if (existingCallback) {
return existingCallback
}
const callback = (element: Element | ComponentPublicInstance | null) => setItemRef(element, key)
itemRefCallbacks.set(key, callback)
return callback
}
function findScrollTarget(): ScrollTarget {
let parent = containerRef.value?.parentElement ?? null
while (parent && parent !== document.body && parent !== document.documentElement) {
const overflowY = window.getComputedStyle(parent).overflowY
if (overflowY === 'auto' || overflowY === 'scroll' || overflowY === 'overlay') {
return parent
}
parent = parent.parentElement
}
return window
}
function addScrollListener(target: ScrollTarget) {
target.addEventListener('scroll', queueViewportSync, { passive: true })
}
function removeScrollListener(target: ScrollTarget | null) {
target?.removeEventListener('scroll', queueViewportSync)
}
function refreshScrollTarget() {
if (!mounted) {
return
}
const nextTarget = findScrollTarget()
if (scrollTarget === nextTarget) {
return
}
removeScrollListener(scrollTarget)
scrollTarget = nextTarget
addScrollListener(scrollTarget)
}
function syncLayoutWidth() {
const element = trackRef.value
if (!element) {
if (layoutWidth.value <= 0) {
layoutWidth.value = getFallbackLayoutWidth()
}
return
}
const nextWidth = element.clientWidth
if (nextWidth > 0) {
layoutWidth.value = nextWidth
return
}
if (layoutWidth.value <= 0) {
layoutWidth.value = getFallbackLayoutWidth()
}
}
function syncViewport() {
const element = trackRef.value
if (!element) {
if (viewportBottom.value <= viewportTop.value) {
viewportTop.value = 0
viewportBottom.value = typeof window === 'undefined' ? 0 : window.innerHeight
}
return
}
const trackRect = element.getBoundingClientRect()
const viewportRect =
scrollTarget && scrollTarget !== window
? (scrollTarget as HTMLElement).getBoundingClientRect()
: {
bottom: window.innerHeight,
top: 0,
}
const nextViewportTop = viewportRect.top - trackRect.top
const nextViewportBottom = viewportRect.bottom - trackRect.top
if (nextViewportBottom > nextViewportTop) {
viewportTop.value = nextViewportTop
viewportBottom.value = nextViewportBottom
}
}
function queueLayoutSync() {
if (typeof window === 'undefined' || layoutFrameId !== null) {
return
}
layoutFrameId = window.requestAnimationFrame(() => {
layoutFrameId = null
if (shouldPauseVirtualSync()) {
freezeVisibleRange()
return
}
// 弹窗内容已经由 overlay 限定生命周期,直接完整渲染可避免弹窗内交互被虚拟回收打断。
syncOverlayGridState()
releaseVisibleRange()
syncLayoutWidth()
refreshScrollTarget()
syncViewport()
flushPendingReveal()
})
}
function queueViewportSync() {
if (typeof window === 'undefined' || scrollFrameId !== null) {
return
}
scrollFrameId = window.requestAnimationFrame(() => {
scrollFrameId = null
if (shouldPauseVirtualSync()) {
freezeVisibleRange()
return
}
releaseVisibleRange()
syncViewport()
})
}
function getTrackScrollTop() {
const element = trackRef.value
if (!element || !scrollTarget || scrollTarget === window) {
return (element?.getBoundingClientRect().top ?? 0) + window.scrollY
}
const scrollElement = scrollTarget as HTMLElement
const trackRect = element.getBoundingClientRect()
const scrollRect = scrollElement.getBoundingClientRect()
return trackRect.top - scrollRect.top + scrollElement.scrollTop
}
function adjustScrollTop(delta: number) {
if (!scrollTarget || Math.abs(delta) < 0.5) {
return
}
if (scrollTarget === window) {
window.scrollBy({
behavior: 'auto',
top: delta,
})
} else {
const scrollElement = scrollTarget as HTMLElement
scrollElement.scrollTop += delta
}
}
function scrollToRelativeTop(top: number) {
if (!scrollTarget) {
return
}
const targetTop = getTrackScrollTop() + top
if (scrollTarget === window) {
window.scrollTo({
behavior: 'auto',
top: targetTop,
})
} else {
;(scrollTarget as HTMLElement).scrollTo({
behavior: 'auto',
top: targetTop,
})
}
queueViewportSync()
}
async function revealItem(index: number) {
if (typeof window === 'undefined' || index < 0 || index >= props.items.length) {
return
}
await nextTick()
const row = Math.floor(index / columnCount.value)
const top = rowMetrics.value.offsets[row] ?? 0
scrollToRelativeTop(top)
}
function requestRevealItem(index: number) {
pendingRevealIndex = index
if (!mounted) {
return
}
queueLayoutSync()
}
function flushPendingReveal() {
if (pendingRevealIndex === null || !mounted || !scrollTarget || layoutWidth.value <= 0) {
return
}
const index = pendingRevealIndex
pendingRevealIndex = null
void revealItem(index)
}
function pruneMeasurements() {
const keys = new Set(itemKeys.value)
let changed = false
Array.from(itemHeights.keys()).forEach(key => {
if (!keys.has(key)) {
itemHeights.delete(key)
changed = true
}
})
Array.from(keyElements.entries()).forEach(([key, element]) => {
if (!keys.has(key)) {
itemResizeObserver?.unobserve(element)
observedElements.delete(element)
keyElements.delete(key)
}
})
Array.from(itemRefCallbacks.keys()).forEach(key => {
if (!keys.has(key)) {
itemRefCallbacks.delete(key)
}
})
if (changed) {
heightVersion.value += 1
}
}
function didKeysAppend(nextKeys: ItemKey[], previousKeys: ItemKey[] = []) {
if (!previousKeys.length || nextKeys.length < previousKeys.length) {
return false
}
return previousKeys.every((key, index) => key === nextKeys[index])
}
function syncMeasurementsForItems(nextKeys: ItemKey[], previousKeys: ItemKey[] = []) {
if (!didKeysAppend(nextKeys, previousKeys) && itemHeights.size) {
itemHeights.clear()
heightVersion.value += 1
}
pruneMeasurements()
}
function invalidateMeasurementsForLayoutChange() {
const nextColumnCount = columnCount.value
const nextColumnWidth = columnWidth.value
if (
lastMeasuredColumnCount === nextColumnCount &&
Math.abs(lastMeasuredColumnWidth - nextColumnWidth) < 1
) {
return
}
lastMeasuredColumnCount = nextColumnCount
lastMeasuredColumnWidth = nextColumnWidth
if (!itemHeights.size) {
return
}
itemHeights.clear()
heightVersion.value += 1
}
onMounted(() => {
mounted = true
syncOverlayGridState()
scrollTarget = findScrollTarget()
addScrollListener(scrollTarget)
resizeObserver = new ResizeObserver(queueLayoutSync)
if (trackRef.value) {
resizeObserver.observe(trackRef.value)
}
if (typeof MutationObserver !== 'undefined') {
overlayLockObserver = new MutationObserver(handleOverlayLockChange)
overlayLockObserver.observe(document.documentElement, {
attributes: true,
attributeFilter: ['class'],
})
}
window.addEventListener('resize', queueLayoutSync, { passive: true })
queueLayoutSync()
})
onActivated(() => {
mounted = true
refreshScrollTarget()
queueLayoutSync()
requestAnimationFrame(queueLayoutSync)
})
onDeactivated(() => {
mounted = false
removeScrollListener(scrollTarget)
scrollTarget = null
})
onUnmounted(() => {
mounted = false
removeScrollListener(scrollTarget)
scrollTarget = null
window.removeEventListener('resize', queueLayoutSync)
resizeObserver?.disconnect()
resizeObserver = null
itemResizeObserver?.disconnect()
itemResizeObserver = null
overlayLockObserver?.disconnect()
overlayLockObserver = null
if (layoutFrameId !== null) {
window.cancelAnimationFrame(layoutFrameId)
layoutFrameId = null
}
if (scrollFrameId !== null) {
window.cancelAnimationFrame(scrollFrameId)
scrollFrameId = null
}
})
watch(
itemKeys,
(nextKeys, previousKeys) => {
syncMeasurementsForItems(nextKeys, previousKeys)
queueLayoutSync()
},
{ immediate: true },
)
watch(
[
() => props.minItemWidth,
() => props.gap,
() => props.estimatedItemHeight,
() => props.itemAspectRatio,
() => props.columns,
],
() => {
queueLayoutSync()
},
)
watch(
[columnCount, columnWidth],
() => {
invalidateMeasurementsForLayoutChange()
queueViewportSync()
},
)
watch(
[() => props.scrollToIndex, () => props.items.length, columnCount],
([scrollToIndex]) => {
if (scrollToIndex === undefined || scrollToIndex < 0 || scrollToIndex >= props.items.length) {
return
}
requestRevealItem(scrollToIndex)
},
{ immediate: true },
)
</script>
<template>
<div ref="containerRef" class="progressive-card-grid">
<div ref="trackRef" class="progressive-card-grid__track">
<div
v-if="topSpacerHeight > 0"
class="progressive-card-grid__spacer"
:style="{ blockSize: `${topSpacerHeight}px` }"
aria-hidden="true"
/>
<div v-if="visibleCells.length > 0" class="progressive-card-grid__grid" :style="gridStyle">
<div
v-for="cell in visibleCells"
:key="cell.key"
:ref="getItemRef(cell.key)"
class="progressive-card-grid__item"
:data-progressive-grid-index="cell.index"
>
<slot :item="cell.item" :index="cell.index" />
</div>
</div>
<div
v-if="bottomSpacerHeight > 0"
class="progressive-card-grid__spacer"
:style="{ blockSize: `${bottomSpacerHeight}px` }"
aria-hidden="true"
/>
</div>
</div>
</template>
<style scoped>
.progressive-card-grid {
inline-size: 100%;
}
.progressive-card-grid__track {
inline-size: 100%;
min-block-size: 1px;
overflow-anchor: none;
}
.progressive-card-grid__grid {
display: grid;
}
.progressive-card-grid__item {
inline-size: 100%;
min-inline-size: 0;
}
.progressive-card-grid__item > :deep(*) {
block-size: 100%;
inline-size: 100%;
}
</style>

View File

@@ -1,184 +0,0 @@
<script setup lang="ts">
const props = withDefaults(
defineProps<{
items: any[]
minItemWidth?: number
itemAspectRatio?: number
gap?: number
overscanRows?: number
getItemKey?: (item: any, index: number) => string | number
}>(),
{
minItemWidth: 144,
itemAspectRatio: 1.5,
gap: 16,
overscanRows: 4,
getItemKey: undefined,
},
)
const containerRef = ref<HTMLElement | null>(null)
const columnCount = ref(1)
const itemWidth = ref(props.minItemWidth)
const itemHeight = ref(props.minItemWidth * props.itemAspectRatio)
const startIndex = ref(0)
const endIndex = ref(0)
let resizeObserver: ResizeObserver | null = null
let animationFrameId: number | null = null
const rowStep = computed(() => itemHeight.value + props.gap)
const totalRows = computed(() => Math.ceil(props.items.length / columnCount.value))
const visibleItems = computed(() => props.items.slice(startIndex.value, endIndex.value))
const renderedRowCount = computed(() => {
if (!visibleItems.value.length) {
return 0
}
return Math.ceil(visibleItems.value.length / columnCount.value)
})
const totalContentHeight = computed(() => {
if (!totalRows.value) {
return 0
}
return totalRows.value * rowStep.value - props.gap
})
const topPadding = computed(() => {
if (!startIndex.value) {
return 0
}
return Math.floor(startIndex.value / columnCount.value) * rowStep.value
})
const renderedHeight = computed(() => {
if (!renderedRowCount.value) {
return 0
}
return renderedRowCount.value * rowStep.value - props.gap
})
const bottomPadding = computed(() => {
return Math.max(totalContentHeight.value - topPadding.value - renderedHeight.value, 0)
})
const gridStyle = computed(() => ({
columnGap: `${props.gap}px`,
gridTemplateColumns: `repeat(${columnCount.value}, minmax(0, 1fr))`,
paddingBottom: `${bottomPadding.value}px`,
paddingTop: `${topPadding.value}px`,
rowGap: `${props.gap}px`,
}))
function resolveItemKey(item: any, index: number) {
if (props.getItemKey) {
return props.getItemKey(item, startIndex.value + index)
}
return startIndex.value + index
}
function syncVisibleRange() {
if (typeof window === 'undefined') {
return
}
const container = containerRef.value
if (!container || props.items.length === 0) {
startIndex.value = 0
endIndex.value = 0
return
}
const containerWidth = container.clientWidth
if (!containerWidth) {
return
}
const columns = Math.max(1, Math.floor((containerWidth + props.gap) / (props.minItemWidth + props.gap)))
columnCount.value = columns
itemWidth.value = (containerWidth - props.gap * (columns - 1)) / columns
itemHeight.value = itemWidth.value * props.itemAspectRatio
const rowHeight = rowStep.value || 1
const containerTop = window.scrollY + container.getBoundingClientRect().top
const viewportTop = window.scrollY - containerTop
const viewportBottom = viewportTop + window.innerHeight
const startRow = Math.max(0, Math.floor(viewportTop / rowHeight) - props.overscanRows)
const endRow = Math.min(totalRows.value, Math.ceil(viewportBottom / rowHeight) + props.overscanRows)
const endRowExclusive = Math.max(startRow + 1, endRow)
startIndex.value = Math.min(props.items.length, startRow * columns)
endIndex.value = Math.min(props.items.length, endRowExclusive * columns)
}
function queueSyncVisibleRange() {
if (typeof window === 'undefined' || animationFrameId !== null) {
return
}
animationFrameId = window.requestAnimationFrame(() => {
animationFrameId = null
syncVisibleRange()
})
}
onMounted(() => {
queueSyncVisibleRange()
window.addEventListener('scroll', queueSyncVisibleRange, { passive: true })
resizeObserver = new ResizeObserver(() => {
queueSyncVisibleRange()
})
if (containerRef.value) {
resizeObserver.observe(containerRef.value)
}
})
onUnmounted(() => {
if (typeof window !== 'undefined') {
window.removeEventListener('scroll', queueSyncVisibleRange)
if (animationFrameId !== null) {
window.cancelAnimationFrame(animationFrameId)
animationFrameId = null
}
}
resizeObserver?.disconnect()
resizeObserver = null
})
watch(
() => props.items.length,
() => {
nextTick(() => {
queueSyncVisibleRange()
})
},
{ immediate: true },
)
</script>
<template>
<div ref="containerRef" class="virtual-card-grid">
<div class="grid" :style="gridStyle">
<template v-for="(item, index) in visibleItems" :key="resolveItemKey(item, index)">
<slot :item="item" :index="startIndex + index" />
</template>
</div>
</div>
</template>
<style scoped>
.virtual-card-grid {
inline-size: 100%;
}
</style>

View File

@@ -6,17 +6,10 @@ import { type PropType } from 'vue'
const elementProps = defineProps({
config: Object as PropType<RenderProps>,
})
// key
const componentKey = ref(0)
onActivated(() => {
componentKey.value++
})
</script>
<template>
<Component
:key="componentKey"
:is="elementProps.config?.component"
v-if="!elementProps.config?.html"
v-bind="elementProps.config?.props"
@@ -34,7 +27,6 @@ onActivated(() => {
/>
</Component>
<Component
:key="componentKey"
:is="elementProps.config?.component"
v-if="elementProps.config?.html"
v-bind="elementProps.config?.props"

View File

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

View File

@@ -9,12 +9,14 @@ const props = withDefaults(
itemGap?: number
overscanItems?: number
getItemKey?: (item: any, index: number) => string | number
loading?: boolean
}>(),
{
itemWidth: 144,
itemGap: 16,
overscanItems: 4,
getItemKey: undefined,
loading: false,
},
)
@@ -58,6 +60,15 @@ const trailingSpaceWidth = computed(() => {
return Math.max(totalContentWidth.value - leadingSpaceWidth.value - visibleItemsWidth.value, 0)
})
function getFallbackViewportWidth() {
if (typeof window === 'undefined') {
return itemStep.value * Math.max(props.overscanItems, 1)
}
// keep-alive 激活的首帧偶尔测不到容器宽度,先按视口宽度渲染一屏,避免右侧短暂空白。
return Math.max(window.innerWidth, itemStep.value * Math.max(props.overscanItems, 1))
}
function resolveItemKey(item: any, index: number) {
if (props.getItemKey) {
return props.getItemKey(item, startIndex.value + index)
@@ -85,7 +96,7 @@ function updateVisibleRange() {
return
}
const viewportWidth = element.clientWidth
const viewportWidth = element.clientWidth || getFallbackViewportWidth()
if (!viewportWidth || !props.items.length) {
startIndex.value = 0
endIndex.value = Math.min(props.items.length, props.overscanItems)
@@ -183,6 +194,7 @@ onActivated(() => {
}
nextTick(syncLayoutState)
requestAnimationFrame(syncLayoutState)
})
watch(
@@ -211,23 +223,33 @@ watch(
<div class="slider-content-wrapper">
<div class="slider-content-container">
<div ref="slideContentRef" class="slider-content" tabindex="0" @scroll="handleContentScroll">
<div class="virtual-track" :style="{ width: `${totalContentWidth}px` }">
<div v-if="leadingSpaceWidth > 0" class="virtual-spacer" :style="{ width: `${leadingSpaceWidth}px` }" />
<template v-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>
<template v-for="(item, index) in visibleItems" :key="resolveItemKey(item, index)">
<div
class="virtual-slide-item"
:style="{
marginInlineEnd: index === visibleItems.length - 1 ? '0px' : `${itemGap}px`,
width: `${itemWidth}px`,
}"
>
<slot name="item" :item="item" :index="startIndex + index" />
</div>
</template>
<div v-if="trailingSpaceWidth > 0" class="virtual-spacer" :style="{ width: `${trailingSpaceWidth}px` }" />
</div>
<div v-if="trailingSpaceWidth > 0" class="virtual-spacer" :style="{ width: `${trailingSpaceWidth}px` }" />
</div>
</template>
<template v-else>
<slot name="empty" />
</template>
</div>
</div>
@@ -346,8 +368,15 @@ watch(
inline-size: max-content;
}
.loading-track {
display: flex;
inline-size: max-content;
min-inline-size: 100%;
}
.virtual-slide-item,
.virtual-spacer {
.virtual-spacer,
.loading-track > * {
flex: 0 0 auto;
}

View File

@@ -1,14 +1,18 @@
import { onMounted, onUnmounted, ref, type Ref } from 'vue'
import { sseManagerSingleton } from '@/utils/sseManager'
import { getCurrentInstance, onMounted, onUnmounted, ref, type Ref } from 'vue'
import { sseManagerSingleton, type SSEManagerOptions } from '@/utils/sseManager'
import { addBackgroundTimer, removeBackgroundTimer } from '@/utils/backgroundManager'
type UseSSEOptions = Partial<SSEManagerOptions> & {
connectDelay?: number
}
/**
*
* SSE连接和定时器iOS后台性能
*
* SSE连接和定时器
*/
export function useBackgroundOptimization() {
export function useBackground() {
/**
* 使SSE连接
* 使SSE连接
* @param url SSE连接地址
* @param messageHandler
* @param listenerId ID
@@ -18,24 +22,30 @@ export function useBackgroundOptimization() {
url: string,
messageHandler: (event: MessageEvent) => void,
listenerId: string,
options?: {
backgroundCloseDelay?: number
reconnectDelay?: number
maxReconnectAttempts?: number
connectDelay?: number // 新增:连接延迟
},
options?: UseSSEOptions,
) => {
// 使用独立的SSE管理器确保每个监听器都有独立的连接
const manager = sseManagerSingleton.getIndependentManager(url, listenerId, options)
const isConnected = ref(false)
let connectTimer: ReturnType<typeof setTimeout> | null = null
let isClosed = false
const statusListenerId = `${listenerId}:status`
manager.addStatusListener(statusListenerId, status => {
isConnected.value = status === 'open'
})
const cleanup = () => {
if (isClosed) return
isClosed = true
if (connectTimer) {
clearTimeout(connectTimer)
connectTimer = null
}
manager.removeStatusListener(statusListenerId)
manager.removeMessageListener(listenerId)
sseManagerSingleton.closeIndependentManager(url, listenerId)
isConnected.value = false
@@ -46,11 +56,10 @@ export function useBackgroundOptimization() {
const connectDelay = options?.connectDelay || 100
connectTimer = setTimeout(() => {
connectTimer = null
if (isClosed) return
try {
manager.addMessageListener(listenerId, event => {
messageHandler(event)
isConnected.value = true
})
manager.addMessageListener(listenerId, messageHandler)
} catch (error) {
console.error('SSE连接建立失败:', error)
}
@@ -69,7 +78,7 @@ export function useBackgroundOptimization() {
}
/**
* 使
* 使
* @param id ID
* @param callback
* @param interval
@@ -110,25 +119,40 @@ export function useBackgroundOptimization() {
messageHandler: (event: MessageEvent) => void,
listenerId: string,
delay: number = 3000,
options?: Parameters<typeof useSSE>[3],
options?: UseSSEOptions,
) => {
// 使用独立的SSE管理器确保每个监听器都有独立的连接
const manager = sseManagerSingleton.getIndependentManager(url, listenerId, options)
const isConnected = ref(false)
let connectTimer: ReturnType<typeof setTimeout> | null = null
let isClosed = false
const statusListenerId = `${listenerId}:status`
manager.addStatusListener(statusListenerId, status => {
isConnected.value = status === 'open'
})
const cleanup = () => {
if (isClosed) return
isClosed = true
if (connectTimer) {
clearTimeout(connectTimer)
connectTimer = null
}
manager.removeStatusListener(statusListenerId)
manager.removeMessageListener(listenerId)
sseManagerSingleton.closeIndependentManager(url, listenerId)
isConnected.value = false
}
onMounted(() => {
connectTimer = setTimeout(() => {
connectTimer = null
if (isClosed) return
manager.addMessageListener(listenerId, messageHandler)
}, delay)
})
@@ -139,6 +163,7 @@ export function useBackgroundOptimization() {
manager,
readyState: () => manager.readyState,
close: cleanup,
isConnected,
}
}
@@ -189,9 +214,12 @@ export function useBackgroundOptimization() {
isListening = false
}
onUnmounted(() => {
stopProgress(true)
})
// 进度监听有些场景会在用户操作后动态创建;只有 setup 阶段创建时才注册自动卸载钩子。
if (getCurrentInstance()) {
onUnmounted(() => {
stopProgress(true)
})
}
return {
start: startProgress,

View File

@@ -0,0 +1,98 @@
import { nextTick, onActivated, onMounted, toValue, watch, type MaybeRefOrGetter } from 'vue'
export interface KeepAliveRefreshContext {
/** 重新进入页面时已有旧内容可用,刷新应尽量避免切换主 loading 或清空列表。 */
silent?: boolean
source?: 'activated' | 'tab' | 'manual'
}
type RefreshHandler = (context?: KeepAliveRefreshContext) => void | Promise<void>
interface KeepAliveRefreshOptions {
/**
* 当前内容是否处于可见状态。
* keep-alive 会激活整棵缓存树tab 内组件需要用它避免后台标签页也刷新。
*/
active?: MaybeRefOrGetter<boolean>
/** 是否在 keep-alive 页面重新进入时刷新。 */
refreshOnActivated?: boolean
/** 是否在 tab 从隐藏切回可见时刷新。 */
refreshOnTabActivated?: boolean
}
/**
* keep-alive 页面复用实例时不会重新 mounted这里统一补上重新进入和重新选中 tab 的刷新。
*/
export function useKeepAliveRefresh(refresh: RefreshHandler, options: KeepAliveRefreshOptions = {}) {
let mounted = false
let activatedCount = 0
let refreshing = false
let pendingRefresh = false
let refreshScheduled = false
const isActive = () => options.active === undefined || Boolean(toValue(options.active))
async function runRefresh(context: KeepAliveRefreshContext = { silent: true, source: 'manual' }) {
if (!isActive()) return
// 避免路由激活和 tab 激活在同一轮里叠加出并发请求。
if (refreshing) {
pendingRefresh = true
return
}
refreshing = true
try {
await refresh(context)
} finally {
refreshing = false
if (pendingRefresh) {
pendingRefresh = false
await runRefresh(context)
}
}
}
function requestRefresh(source: KeepAliveRefreshContext['source']) {
// 同一轮激活里可能同时触发路由激活和 tab 激活,合并成一次静默刷新。
if (refreshScheduled) return
refreshScheduled = true
void nextTick(async () => {
refreshScheduled = false
await runRefresh({ silent: true, source })
})
}
onMounted(() => {
mounted = true
})
if (options.refreshOnActivated !== false) {
onActivated(() => {
activatedCount += 1
// KeepAlive 首次挂载也会触发 activated初始加载交给页面自己的 mounted 逻辑。
if (activatedCount === 1) return
requestRefresh('activated')
})
}
if (options.active !== undefined && options.refreshOnTabActivated !== false) {
watch(
() => Boolean(toValue(options.active)),
(active, oldActive) => {
if (!mounted || !active || oldActive !== false) return
requestRefresh('tab')
},
{ flush: 'post' },
)
}
return {
refresh: runRefresh,
}
}

View File

@@ -57,18 +57,23 @@ export interface WizardData {
model: string
thinkingLevel: string
supportImageInput: boolean
supportAudioInputOutput: boolean
supportAudioInput: boolean
supportAudioOutput: boolean
apiKey: string
baseUrl: string
baseUrlPreset: string
maxContextTokens: number
voiceApiKey: string
voiceBaseUrl: string
voiceSttModel: string
voiceTtsModel: string
voiceTtsVoice: string
voiceLanguage: string
voiceReplyWithText: boolean
audioInputProvider: string
audioInputApiKey: string
audioInputBaseUrl: string
audioInputModel: string
audioInputLanguage: string
audioOutputProvider: string
audioOutputApiKey: string
audioOutputBaseUrl: string
audioOutputModel: string
audioOutputVoice: string
audioOutputIncludeText: boolean
jobInterval: number
retryTransfer: boolean
recommendEnabled: boolean
@@ -238,18 +243,23 @@ const wizardData = ref<WizardData>({
model: 'deepseek-chat',
thinkingLevel: 'off',
supportImageInput: true,
supportAudioInputOutput: false,
supportAudioInput: false,
supportAudioOutput: false,
apiKey: '',
baseUrl: 'https://api.deepseek.com',
baseUrlPreset: '',
maxContextTokens: 64,
voiceApiKey: '',
voiceBaseUrl: '',
voiceSttModel: 'gpt-4o-mini-transcribe',
voiceTtsModel: 'gpt-4o-mini-tts',
voiceTtsVoice: 'alloy',
voiceLanguage: 'zh',
voiceReplyWithText: false,
audioInputProvider: 'openai',
audioInputApiKey: '',
audioInputBaseUrl: '',
audioInputModel: 'gpt-4o-mini-transcribe',
audioInputLanguage: 'zh',
audioOutputProvider: 'openai',
audioOutputApiKey: '',
audioOutputBaseUrl: '',
audioOutputModel: 'gpt-4o-mini-tts',
audioOutputVoice: 'alloy',
audioOutputIncludeText: false,
jobInterval: 0,
retryTransfer: false,
recommendEnabled: false,
@@ -320,6 +330,7 @@ export function useSetupWizard() {
// 媒体服务器映射
mediaServer: {
'emby': 'EmbyModule',
'zspace': 'ZSpaceModule',
'jellyfin': 'JellyfinModule',
'plex': 'PlexModule',
'trimemedia': 'TrimeMediaModule',
@@ -327,8 +338,10 @@ export function useSetupWizard() {
},
// 通知映射
notification: {
'feishu': 'FeishuModule',
'telegram': 'TelegramModule',
'wechat': 'WechatModule',
'wechatclawbot': 'WechatClawBotModule',
'slack': 'SlackModule',
'synologychat': 'SynologyChatModule',
'qqbot': 'QQBotModule',
@@ -423,7 +436,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保留用户已输入的值
@@ -606,6 +630,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'))
@@ -656,6 +689,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'))
@@ -854,7 +899,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
}
@@ -1395,18 +1440,23 @@ export function useSetupWizard() {
LLM_MODEL: wizardData.value.agent.model,
LLM_THINKING_LEVEL: wizardData.value.agent.thinkingLevel,
LLM_SUPPORT_IMAGE_INPUT: wizardData.value.agent.supportImageInput,
LLM_SUPPORT_AUDIO_INPUT_OUTPUT: wizardData.value.agent.supportAudioInputOutput,
LLM_SUPPORT_AUDIO_INPUT: wizardData.value.agent.supportAudioInput,
LLM_SUPPORT_AUDIO_OUTPUT: wizardData.value.agent.supportAudioOutput,
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,
AI_VOICE_STT_MODEL: wizardData.value.agent.voiceSttModel,
AI_VOICE_TTS_MODEL: wizardData.value.agent.voiceTtsModel,
AI_VOICE_TTS_VOICE: wizardData.value.agent.voiceTtsVoice,
AI_VOICE_LANGUAGE: wizardData.value.agent.voiceLanguage,
AI_VOICE_REPLY_WITH_TEXT: wizardData.value.agent.voiceReplyWithText,
AUDIO_INPUT_PROVIDER: wizardData.value.agent.audioInputProvider || 'openai',
AUDIO_INPUT_API_KEY: wizardData.value.agent.audioInputApiKey || null,
AUDIO_INPUT_BASE_URL: wizardData.value.agent.audioInputBaseUrl || null,
AUDIO_INPUT_MODEL: wizardData.value.agent.audioInputModel,
AUDIO_INPUT_LANGUAGE: wizardData.value.agent.audioInputLanguage,
AUDIO_OUTPUT_PROVIDER: wizardData.value.agent.audioOutputProvider || 'openai',
AUDIO_OUTPUT_API_KEY: wizardData.value.agent.audioOutputApiKey || null,
AUDIO_OUTPUT_BASE_URL: wizardData.value.agent.audioOutputBaseUrl || null,
AUDIO_OUTPUT_MODEL: wizardData.value.agent.audioOutputModel,
AUDIO_OUTPUT_VOICE: wizardData.value.agent.audioOutputVoice,
AUDIO_OUTPUT_INCLUDE_TEXT: wizardData.value.agent.audioOutputIncludeText,
AI_AGENT_JOB_INTERVAL: wizardData.value.agent.enabled ? wizardData.value.agent.jobInterval : 0,
AI_AGENT_RETRY_TRANSFER: wizardData.value.agent.enabled ? wizardData.value.agent.retryTransfer : false,
AI_RECOMMEND_ENABLED:
@@ -1503,18 +1553,23 @@ export function useSetupWizard() {
wizardData.value.agent.model = result.data.LLM_MODEL || ''
wizardData.value.agent.thinkingLevel = resolveThinkingLevelValue(result.data)
wizardData.value.agent.supportImageInput = result.data.LLM_SUPPORT_IMAGE_INPUT ?? true
wizardData.value.agent.supportAudioInputOutput = Boolean(result.data.LLM_SUPPORT_AUDIO_INPUT_OUTPUT)
wizardData.value.agent.supportAudioInput = Boolean(result.data.LLM_SUPPORT_AUDIO_INPUT)
wizardData.value.agent.supportAudioOutput = Boolean(result.data.LLM_SUPPORT_AUDIO_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 || ''
wizardData.value.agent.voiceSttModel = result.data.AI_VOICE_STT_MODEL || 'gpt-4o-mini-transcribe'
wizardData.value.agent.voiceTtsModel = result.data.AI_VOICE_TTS_MODEL || 'gpt-4o-mini-tts'
wizardData.value.agent.voiceTtsVoice = result.data.AI_VOICE_TTS_VOICE || 'alloy'
wizardData.value.agent.voiceLanguage = result.data.AI_VOICE_LANGUAGE || 'zh'
wizardData.value.agent.voiceReplyWithText = Boolean(result.data.AI_VOICE_REPLY_WITH_TEXT)
wizardData.value.agent.audioInputProvider = result.data.AUDIO_INPUT_PROVIDER || 'openai'
wizardData.value.agent.audioInputApiKey = result.data.AUDIO_INPUT_API_KEY || ''
wizardData.value.agent.audioInputBaseUrl = result.data.AUDIO_INPUT_BASE_URL || ''
wizardData.value.agent.audioInputModel = result.data.AUDIO_INPUT_MODEL || 'gpt-4o-mini-transcribe'
wizardData.value.agent.audioInputLanguage = result.data.AUDIO_INPUT_LANGUAGE || 'zh'
wizardData.value.agent.audioOutputProvider = result.data.AUDIO_OUTPUT_PROVIDER || 'openai'
wizardData.value.agent.audioOutputApiKey = result.data.AUDIO_OUTPUT_API_KEY || ''
wizardData.value.agent.audioOutputBaseUrl = result.data.AUDIO_OUTPUT_BASE_URL || ''
wizardData.value.agent.audioOutputModel = result.data.AUDIO_OUTPUT_MODEL || 'gpt-4o-mini-tts'
wizardData.value.agent.audioOutputVoice = result.data.AUDIO_OUTPUT_VOICE || 'alloy'
wizardData.value.agent.audioOutputIncludeText = Boolean(result.data.AUDIO_OUTPUT_INCLUDE_TEXT)
wizardData.value.agent.jobInterval = result.data.AI_AGENT_JOB_INTERVAL || 0
wizardData.value.agent.retryTransfer = Boolean(result.data.AI_AGENT_RETRY_TRANSFER)
wizardData.value.agent.recommendEnabled = Boolean(result.data.AI_RECOMMEND_ENABLED)

View File

@@ -0,0 +1,33 @@
import { type MaybeRefOrGetter, toValue } from 'vue'
import { useKeepAliveRefresh, type KeepAliveRefreshContext } from '@/composables/useKeepAliveRefresh'
type RefreshHandler = (context?: KeepAliveRefreshContext) => void | Promise<void>
interface SilentSettingRefreshOptions {
active?: MaybeRefOrGetter<boolean>
}
function isEditingFormField() {
if (typeof document === 'undefined') return false
const element = document.activeElement
if (!(element instanceof HTMLElement)) return false
// 设置页大多是可编辑表单,正在输入时跳过静默刷新,避免覆盖用户未保存内容。
return Boolean(element.closest('input, textarea, select, [contenteditable="true"], .ace_text-input'))
}
/**
* 设置面板重新可见时静默刷新数据;如果用户正在编辑表单,则本轮刷新让路给输入体验。
*/
export function useSilentSettingRefresh(refresh: RefreshHandler, options: SilentSettingRefreshOptions = {}) {
return useKeepAliveRefresh(
async context => {
if (context?.silent && isEditingFormField()) return
await refresh(context)
},
{
active: options.active === undefined ? undefined : () => Boolean(toValue(options.active)),
},
)
}

View File

@@ -51,9 +51,25 @@ export const clearCachesAndServiceWorker = async (): Promise<void> => {
/**
* 清除缓存并刷新
*/
const clearCacheAndReload = async (): Promise<void> => {
await clearCachesAndServiceWorker()
reloadWithTimestamp()
export const clearCacheAndReload = async (): Promise<void> => {
let isReloading = false
const reload = () => {
if (isReloading) return
isReloading = true
reloadWithTimestamp()
}
const reloadTimer = window.setTimeout(reload, 3000)
try {
await Promise.race([
clearCachesAndServiceWorker(),
new Promise(resolve => window.setTimeout(resolve, 2500)),
])
} finally {
window.clearTimeout(reloadTimer)
reload()
}
}
/**

View File

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

View File

@@ -64,11 +64,20 @@ const lastY = ref(0)
const lastTime = ref(0)
const velocity = ref(0)
const startedFromBottomArea = ref(false)
const quickAccessRef = ref<HTMLElement | { $el?: HTMLElement } | null>(null)
// 插件弹窗相关状态
const showPluginDataDialog = ref(false)
const currentPlugin = ref<Plugin | null>(null)
// Vuetify 组件 ref 在不同构建下可能返回组件实例,这里统一解析为真实 DOM 节点。
function getQuickAccessElement() {
const element = quickAccessRef.value
if (!element) return null
return element instanceof HTMLElement ? element : element.$el ?? null
}
// 计算显示状态
const isVisible = computed(() => {
return props.visible
@@ -211,20 +220,27 @@ function manageScrollLock() {
if (isVisible.value) {
// 使用 nextTick 确保 DOM 已经更新
nextTick(() => {
// 先恢复之前的锁定状态,避免重复锁定
const scrollableElement = document.querySelector('.all-plugins-grid')
if (scrollableElement) {
// 确保元素存在且可见
if ((scrollableElement as HTMLElement).offsetHeight > 0) {
disableBodyScroll(scrollableElement as HTMLElement)
}
const panelElement = getQuickAccessElement()
if (!panelElement) return
// 锁定整层快捷入口,只有插件列表内部允许惯性滚动,避免底部手势漏给首页背景。
disableBodyScroll(panelElement, {
allowTouchMove: el => Boolean((el as HTMLElement).closest('.quick-access-scroll')),
})
if (typeof document !== 'undefined') {
document.documentElement.classList.add('quick-access-scroll-locked')
}
})
} else {
// 恢复背景滚动
const scrollableElement = document.querySelector('.all-plugins-grid')
if (scrollableElement) {
enableBodyScroll(scrollableElement as HTMLElement)
const panelElement = getQuickAccessElement()
if (panelElement) {
enableBodyScroll(panelElement)
}
if (typeof document !== 'undefined') {
document.documentElement.classList.remove('quick-access-scroll-locked')
}
}
}
@@ -254,9 +270,13 @@ onMounted(() => {
// 组件卸载时确保恢复背景滚动
onUnmounted(() => {
const scrollableElement = document.querySelector('.all-plugins-grid')
if (scrollableElement) {
enableBodyScroll(scrollableElement as HTMLElement)
const panelElement = getQuickAccessElement()
if (panelElement) {
enableBodyScroll(panelElement)
}
if (typeof document !== 'undefined') {
document.documentElement.classList.remove('quick-access-scroll-locked')
}
})
@@ -297,6 +317,10 @@ function handleTouchMove(event: TouchEvent) {
// 只有从 bottom-drag-area 开始的触摸才处理上滑关闭
if (!startedFromBottomArea.value) return
// 底部关闭手势从第一帧开始接管,防止 iOS 将早期位移传递给背景页面滚动。
event.preventDefault()
event.stopPropagation()
// 检查当前触摸是否在插件网格内,如果是则不处理拖拽关闭
const target = event.target as HTMLElement
if (target.closest('.plugin-grid')) {
@@ -319,7 +343,6 @@ function handleTouchMove(event: TouchEvent) {
if (deltaY >= 0) {
// 向上拖拽,更新偏移量
dragOffset.value = Math.min(deltaY, SWIPE_CONFIG.MAX_DRAG_DISTANCE)
event.preventDefault()
} else {
// 向下拖拽,停止拖拽
isDraggingToClose.value = false
@@ -330,7 +353,6 @@ function handleTouchMove(event: TouchEvent) {
if (deltaY > SWIPE_CONFIG.START_THRESHOLD) {
isDraggingToClose.value = true
dragOffset.value = Math.min(deltaY, SWIPE_CONFIG.MAX_DRAG_DISTANCE)
event.preventDefault()
}
}
@@ -366,6 +388,27 @@ function handleTouchEnd() {
startedFromBottomArea.value = false
}
// 底部手势区域不参与页面滚动,从触摸开始就阻止事件冒泡到全局下拉监听。
function handleBottomTouchStart(event: TouchEvent) {
if (!props.visible) return
event.stopPropagation()
handleTouchStart(event)
}
function handleBottomTouchMove(event: TouchEvent) {
if (!props.visible) return
handleTouchMove(event)
}
function handleBottomTouchEnd(event: TouchEvent) {
if (!props.visible) return
event.stopPropagation()
handleTouchEnd()
}
// 点击底部空白区域关闭
function handleBackdropClick(event: MouseEvent) {
const target = event.target as HTMLElement
@@ -383,6 +426,7 @@ function handleBackdropClick(event: MouseEvent) {
<template>
<VCard
ref="quickAccessRef"
:ripple="false"
class="plugin-quick-access"
:class="{ 'visible': isVisible }"
@@ -408,7 +452,7 @@ function handleBackdropClick(event: MouseEvent) {
</div>
<!-- 插件网格 -->
<div class="plugin-grid">
<div class="plugin-grid quick-access-scroll">
<!-- 加载状态 -->
<LoadingBanner v-if="loading" />
@@ -457,7 +501,7 @@ function handleBackdropClick(event: MouseEvent) {
</div>
<div v-if="pluginsWithPage.length > 0" class="all-plugins-container">
<div class="all-plugins-grid">
<div class="all-plugins-grid quick-access-scroll">
<div
v-for="plugin in pluginsWithPage"
:key="plugin.id"
@@ -500,7 +544,14 @@ function handleBackdropClick(event: MouseEvent) {
</div>
<!-- 底部拖动区域 -->
<div class="bottom-drag-area" @click="handleBackdropClick">
<div
class="bottom-drag-area"
@click="handleBackdropClick"
@touchstart.stop="handleBottomTouchStart"
@touchmove.prevent.stop="handleBottomTouchMove"
@touchend.stop="handleBottomTouchEnd"
@touchcancel.stop="handleBottomTouchEnd"
>
<!-- 底部指示器 -->
<div class="bottom-indicator">
<div
@@ -767,6 +818,15 @@ function handleBackdropClick(event: MouseEvent) {
cursor: pointer;
padding-block: 8px 0;
padding-inline: 20px;
touch-action: none;
user-select: none;
-webkit-user-select: none;
}
:global(html.quick-access-scroll-locked),
:global(html.quick-access-scroll-locked body) {
overflow: hidden !important;
overscroll-behavior: none;
}
@media (hover: none) and (pointer: coarse) {

View File

@@ -1,13 +1,4 @@
<script lang="ts" setup>
import NameTestView from '@/views/system/NameTestView.vue'
import NetTestView from '@/views/system/NetTestView.vue'
import LoggingView from '@/views/system/LoggingView.vue'
import RuleTestView from '@/views/system/RuleTestView.vue'
import ModuleTestView from '@/views/system/ModuleTestView.vue'
import MessageView from '@/views/system/MessageView.vue'
import WordsView from '@/views/system/WordsView.vue'
import CacheView from '@/views/system/CacheView.vue'
import AccountSettingService from '@/views/system/ServiceView.vue'
import api from '@/api'
import { useDisplay } from 'vuetify'
import { getQueryValue } from '@/@core/utils'
@@ -18,6 +9,7 @@ type MessageViewExpose = {
pauseSSE?: () => void
resumeSSE?: () => void
refreshLatestMessages?: () => Promise<void> | void
forceScrollToEnd?: () => void
}
// 国际化
@@ -26,6 +18,17 @@ const { t } = useI18n()
// 显示器宽度
const display = useDisplay()
// 快捷工具只在弹窗打开时使用,按需加载避免默认布局首屏带上所有 system 视图。
const NameTestView = defineAsyncComponent(() => import('@/views/system/NameTestView.vue'))
const NetTestView = defineAsyncComponent(() => import('@/views/system/NetTestView.vue'))
const LoggingView = defineAsyncComponent(() => import('@/views/system/LoggingView.vue'))
const RuleTestView = defineAsyncComponent(() => import('@/views/system/RuleTestView.vue'))
const ModuleTestView = defineAsyncComponent(() => import('@/views/system/ModuleTestView.vue'))
const MessageView = defineAsyncComponent(() => import('@/views/system/MessageView.vue'))
const WordsView = defineAsyncComponent(() => import('@/views/system/WordsView.vue'))
const CacheView = defineAsyncComponent(() => import('@/views/system/CacheView.vue'))
const AccountSettingService = defineAsyncComponent(() => import('@/views/system/ServiceView.vue'))
// App捷径
const appsMenu = ref(false)
@@ -65,15 +68,9 @@ const user_message = ref('')
// 发送按钮是否可用
const sendButtonDisabled = ref(false)
// 消息对话框引用
const messageDialogRef = ref<any>(null)
// 消息视图引用
const messageViewRef = ref<MessageViewExpose | null>(null)
// 滚动容器引用
const messageContentRef = ref<any>()
// 定义捷径列表
const shortcuts = [
{
@@ -146,58 +143,9 @@ function openDialog(dialogRef: any) {
dialogRef.value = true
}
// 打开消息弹窗并清除徽章
async function openMessageDialog() {
// 打开消息弹窗
function openMessageDialog() {
messageDialog.value = true
// 延迟清除徽章,确保对话框已经打开
setTimeout(async () => {
await clearAppBadge()
}, 500)
// 延迟滚动到底部,确保弹窗完全打开
setTimeout(() => {
forceScrollToEnd()
}, 600)
// 等待对话框打开后恢复SSE连接
nextTick(() => {
messageViewRef.value?.resumeSSE?.()
})
}
// 智能滚动到底部(只有用户在底部附近时才滚动)
function scrollMessageToEnd() {
// 使用更长的延迟确保DOM已更新
setTimeout(() => {
try {
// 查找消息弹窗的滚动容器
const cardText = document.querySelector('.v-dialog .v-card-text')
if (cardText) {
const { scrollTop, scrollHeight, clientHeight } = cardText
// 计算距离底部的距离
const distanceFromBottom = scrollHeight - scrollTop - clientHeight
// 如果用户距离底部小于1/3屏幕高度认为用户在底部附近执行自动滚动
if (distanceFromBottom <= clientHeight / 3) {
cardText.scrollTop = cardText.scrollHeight
}
}
} catch (error) {
console.error(error)
}
}, 500) // 增加延迟时间
}
// 强制滚动到底部(用于发送消息后)
function forceScrollToEnd() {
setTimeout(() => {
try {
// 查找消息弹窗的滚动容器
const cardText = document.querySelector('.v-dialog .v-card-text')
if (cardText) {
cardText.scrollTop = cardText.scrollHeight
}
} catch (error) {
console.error(error)
}
}, 500)
}
// 拼接全部日志url
@@ -219,7 +167,7 @@ async function sendMessage() {
// 发送成功后主动同步最新一页消息避免SSE短暂断流时界面停留在旧状态。
// await messageViewRef.value?.refreshLatestMessages?.()
forceScrollToEnd() // 发送消息后强制滚动到底部
messageViewRef.value?.forceScrollToEnd?.()
} catch (error) {
console.error(error)
} finally {
@@ -238,8 +186,20 @@ defineExpose({
})
// 监听消息对话框状态变化
watch(messageDialog, newValue => {
if (!newValue && messageViewRef.value?.pauseSSE) {
watch(messageDialog, async newValue => {
if (newValue) {
await nextTick()
messageViewRef.value?.resumeSSE?.()
messageViewRef.value?.forceScrollToEnd?.()
window.setTimeout(() => {
void clearAppBadge()
}, 500)
return
}
if (messageViewRef.value?.pauseSSE) {
// 对话框关闭时暂停SSE连接
messageViewRef.value.pauseSSE()
}
@@ -473,7 +433,7 @@ onMounted(() => {
scrollable
:fullscreen="!display.mdAndUp.value"
>
<VCard>
<VCard class="system-health-dialog-card">
<VCardItem>
<VCardTitle>
<VIcon icon="mdi-cog" class="me-2" />
@@ -482,7 +442,7 @@ onMounted(() => {
<VDialogCloseBtn @click="systemTestDialog = false" />
</VCardItem>
<VDivider />
<VCardText class="pa-0">
<VCardText class="system-health-dialog-body pa-0">
<ModuleTestView />
</VCardText>
</VCard>
@@ -494,7 +454,6 @@ onMounted(() => {
max-width="50rem"
scrollable
:fullscreen="!display.mdAndUp.value"
ref="messageDialogRef"
>
<VCard>
<VCardItem>
@@ -505,8 +464,8 @@ onMounted(() => {
<VDialogCloseBtn @click="messageDialog = false" />
</VCardItem>
<VDivider />
<VCardText ref="messageContentRef">
<MessageView ref="messageViewRef" @scroll="scrollMessageToEnd" />
<VCardText>
<MessageView ref="messageViewRef" />
</VCardText>
<VDivider />
<VCardActions class="pa-4">
@@ -533,3 +492,24 @@ onMounted(() => {
</VCard>
</VDialog>
</template>
<style scoped>
.system-health-dialog-card {
display: flex;
flex-direction: column;
overflow: hidden;
}
.system-health-dialog-body {
/* 弹窗正文本身不滚动,滚动只交给健康检查结果列表。 */
display: flex;
flex: 1 1 auto;
block-size: min(42rem, calc(100dvh - 8rem - env(safe-area-inset-top) - env(safe-area-inset-bottom)));
min-block-size: 0;
overflow: hidden !important;
}
:global(.v-dialog--fullscreen) .system-health-dialog-body {
block-size: auto;
}
</style>

View File

@@ -2,10 +2,10 @@
import { formatDateDifference } from '@core/utils/formatters'
import { SystemNotification } from '@/api/types'
import { useI18n } from 'vue-i18n'
import { useBackgroundOptimization } from '@/composables/useBackgroundOptimization'
import { useBackground } from '@/composables/useBackground'
const { t } = useI18n()
const { useDelayedSSE } = useBackgroundOptimization()
const { useDelayedSSE } = useBackground()
// 是否有新消息
const hasNewMessage = ref(false)
@@ -39,7 +39,7 @@ function handleMessage(event: MessageEvent) {
}
}
// 使用优化的SSE连接延迟3秒启动避免认证问题
// 延迟3秒启动SSE连接避免认证信息尚未准备好。
useDelayedSSE(
`${import.meta.env.VITE_API_BASE_URL}system/message`,
handleMessage,

View File

@@ -58,6 +58,8 @@ const customCSS = ref('')
// 透明度相关
const transparencyOpacity = ref(parseFloat(localStorage.getItem('transparency-opacity') || '0.3'))
const transparencyBlur = ref(parseFloat(localStorage.getItem('transparency-blur') || '10'))
const backgroundPosterOpacity = ref(parseFloat(localStorage.getItem('transparency-background-poster-opacity') || '0'))
const backgroundBlur = ref(parseFloat(localStorage.getItem('transparency-background-blur') || '16'))
const transparencyLevel = ref(localStorage.getItem('transparency-level') || 'medium')
const isTransparentTheme = computed(() => currentThemeName.value === 'transparent')
const showTransparencyDialog = ref(false)
@@ -383,6 +385,15 @@ async function saveCustomCSS() {
function applyTransparencySettings() {
const root = document.documentElement
if (!Number.isFinite(backgroundPosterOpacity.value)) {
backgroundPosterOpacity.value = 1
}
backgroundPosterOpacity.value = Math.min(1, Math.max(0, backgroundPosterOpacity.value))
if (!Number.isFinite(backgroundBlur.value)) {
backgroundBlur.value = 16
}
backgroundBlur.value = Math.min(30, Math.max(0, backgroundBlur.value))
// 设置CSS变量
root.style.setProperty('--transparent-opacity', transparencyOpacity.value.toString())
root.style.setProperty('--transparent-opacity-light', (transparencyOpacity.value * 0.67).toString())
@@ -390,10 +401,14 @@ function applyTransparencySettings() {
root.style.setProperty('--transparent-blur', `${transparencyBlur.value}px`)
root.style.setProperty('--transparent-blur-light', `${transparencyBlur.value * 0.6}px`)
root.style.setProperty('--transparent-blur-heavy', `${transparencyBlur.value * 1.6}px`)
root.style.setProperty('--transparent-background-poster-opacity', (1 - backgroundPosterOpacity.value).toString())
root.style.setProperty('--transparent-background-blur', `${backgroundBlur.value}px`)
// 保存到本地存储
localStorage.setItem('transparency-opacity', transparencyOpacity.value.toString())
localStorage.setItem('transparency-blur', transparencyBlur.value.toString())
localStorage.setItem('transparency-background-poster-opacity', backgroundPosterOpacity.value.toString())
localStorage.setItem('transparency-background-blur', backgroundBlur.value.toString())
}
// 调整透明度预设
@@ -434,10 +449,22 @@ function onBlurChange() {
transparencyLevel.value = ''
}
// 背景海报透明度变化处理
function onBackgroundPosterOpacityChange() {
applyTransparencySettings()
}
// 背景磨砂变化处理
function onBackgroundBlurChange() {
applyTransparencySettings()
}
// 重置透明度设置
function resetTransparencySettings() {
transparencyOpacity.value = 0.3
transparencyBlur.value = 10
backgroundPosterOpacity.value = 0
backgroundBlur.value = 16
transparencyLevel.value = 'medium'
applyTransparencySettings()
}
@@ -821,6 +848,38 @@ onUnmounted(() => {
/>
</div>
<!-- 背景海报透明度滑动条 -->
<div>
<div class="d-flex align-center justify-space-between mb-2">
<span class="text-body-2">{{ t('theme.backgroundPosterOpacity') }}</span>
<span class="text-caption">{{ Math.round(backgroundPosterOpacity * 100) }}%</span>
</div>
<VSlider
v-model="backgroundPosterOpacity"
:min="0"
:max="1"
:step="0.01"
color="primary"
@update:model-value="onBackgroundPosterOpacityChange"
/>
</div>
<!-- 背景磨砂滑动条 -->
<div>
<div class="d-flex align-center justify-space-between mb-2">
<span class="text-body-2">{{ t('theme.backgroundBlur') }}</span>
<span class="text-caption">{{ backgroundBlur }}px</span>
</div>
<VSlider
v-model="backgroundBlur"
:min="0"
:max="30"
:step="1"
color="primary"
@update:model-value="onBackgroundBlurChange"
/>
</div>
<!-- 预设按钮 -->
<div>
<span class="text-body-2 d-block mb-2">{{ t('common.preset') }}</span>

View File

@@ -2,13 +2,16 @@
import DefaultLayout from './components/DefaultLayout.vue'
const route = useRoute()
// keep-alive 缓存按页面身份命中,避免 query 变化导致同一页面反复新建实例。
const routeCacheKey = computed(() => route.meta.keepAliveKey?.toString() || route.path)
</script>
<template>
<DefaultLayout>
<router-view v-slot="{ Component }">
<keep-alive :max="12">
<component :is="Component" v-if="route.meta.keepAlive" :key="route.fullPath" />
<keep-alive :max="24">
<component :is="Component" v-if="route.meta.keepAlive" :key="routeCacheKey" />
</keep-alive>
<component :is="Component" v-if="!route.meta.keepAlive" :key="route.fullPath" />
</router-view>

View File

@@ -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',
@@ -146,6 +149,8 @@ export default {
transparencyAdjust: 'Transparency Adjustment',
transparencyOpacity: 'Opacity',
transparencyBlur: 'Blur',
backgroundPosterOpacity: 'Background Opacity',
backgroundBlur: 'Background Frosted Blur',
transparencyReset: 'Reset',
transparencyLow: 'Low Transparency',
transparencyMedium: 'Medium Transparency',
@@ -318,7 +323,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 +355,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 +475,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 +944,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',
@@ -923,6 +984,8 @@ export default {
ranking: 'Ranking',
noStatisticsData: 'No share statistics data available',
bestVersion: 'Version Upgrading',
bestVersionEpisodeShort: 'Episode',
bestVersionWholeShort: 'Full',
completed: 'Completed',
subscribing: 'Subscribing',
notStarted: 'Not Started',
@@ -968,7 +1031,7 @@ export default {
doubanGlobalTVRankings: 'Douban Global TV Rankings',
noCategoryContent: 'No content to display in current category',
configureContent: 'Configure Display Content',
customizeContent: 'Customize Content',
customizeContent: 'Customize Recommendations',
selectContentToDisplay: 'Select content you want to display on the page',
selectAll: 'Select All',
selectNone: 'Select None',
@@ -1357,9 +1420,11 @@ export default {
llmSupportImageInput: 'Model Supports Image Input',
llmSupportImageInputHint:
'When enabled, message images are sent to the LLM as multimodal image input. When disabled, images are saved locally as attachments and only the file path is passed to the AI assistant.',
llmSupportAudioInputOutput: 'Support Audio Input and Output',
llmSupportAudioInputOutputHint:
'When enabled, the AI assistant can transcribe incoming audio messages and reply with voice on supported channels.',
llmSupportAudioInput: 'Support Audio Input',
llmSupportAudioInputHint:
'When enabled, incoming audio messages are transcribed before being handled by the AI assistant.',
llmSupportAudioOutput: 'Support Audio Output',
llmSupportAudioOutputHint: 'When enabled, the AI assistant can send voice replies on supported channels.',
llmMaxContextTokens: 'LLM Max Context Tokens (K)',
llmMaxContextTokensHint:
'Set the maximum number of context tokens (in thousands) for the LLM. Exceeding this limit will trigger context trimming.',
@@ -1380,23 +1445,36 @@ export default {
llmProviderDeviceCode: 'Device Code',
llmProviderOpenAuthPage: 'Open Authorization Page',
llmProviderCheckAuthStatus: 'Check Authorization Status',
aiVoiceApiKey: 'Audio API Key',
aiVoiceApiKeyHint:
'API key used for audio transcription and speech synthesis. Falls back to the current LLM API key when left blank.',
aiVoiceBaseUrl: 'Audio Base URL',
aiVoiceBaseUrlHint:
'Base URL used for audio transcription and speech synthesis. Falls back to the current LLM base URL when left blank.',
aiVoiceSttModel: 'Audio Transcription Model',
aiVoiceSttModelHint: 'Model name used to convert audio content into text.',
aiVoiceTtsModel: 'Speech Synthesis Model',
aiVoiceTtsModelHint: 'Model name used to convert text content into speech.',
aiVoiceTtsVoice: 'Voice Preset',
aiVoiceTtsVoiceHint: 'Speaker or voice preset used for speech synthesis.',
aiVoiceLanguage: 'Recognition Language',
aiVoiceLanguageHint:
audioInputProvider: 'Audio Input Provider',
audioInputProviderHint:
'Service used to transcribe incoming audio messages. Supports OpenAI audio, Chat Audio compatible APIs, and Xiaomi MiMo.',
audioProviderOpenAiAudio: 'OpenAI Audio Compatible',
audioProviderChatAudio: 'Chat Audio Compatible',
audioProviderMimo: 'Xiaomi MiMo',
audioInputApiKey: 'Audio Input API Key',
audioInputApiKeyHint: 'API key used for audio transcription.',
audioInputBaseUrl: 'Audio Input Base URL',
audioInputBaseUrlHint:
'Base URL for audio input. Use the matching compatible endpoint for Chat Audio services; MiMo defaults to https://api.xiaomimimo.com/v1.',
audioInputModel: 'Audio Input Model',
audioInputModelHint: 'Model name used to convert audio content into text.',
audioInputLanguage: 'Recognition Language',
audioInputLanguageHint:
'Default language for audio transcription, such as zh or en. Leave blank to use the backend default.',
aiVoiceReplyWithText: 'Include Text with Voice Replies',
aiVoiceReplyWithTextHint: 'When sending a voice reply, also include the text version of the response.',
audioOutputProvider: 'Audio Output Provider',
audioOutputProviderHint:
'Service used to generate voice replies. Supports OpenAI audio, Chat Audio compatible APIs, and Xiaomi MiMo.',
audioOutputApiKey: 'Audio Output API Key',
audioOutputApiKeyHint: 'API key used for speech synthesis.',
audioOutputBaseUrl: 'Audio Output Base URL',
audioOutputBaseUrlHint:
'Base URL for audio output. Use the matching compatible endpoint for Chat Audio services; MiMo defaults to https://api.xiaomimimo.com/v1.',
audioOutputModel: 'Audio Output Model',
audioOutputModelHint: 'Model name used to convert text content into speech.',
audioOutputVoice: 'Voice Preset',
audioOutputVoiceHint: 'Speaker or voice preset used for speech synthesis.',
audioOutputIncludeText: 'Include Text with Voice Replies',
audioOutputIncludeTextHint: 'When sending a voice reply, also include the text version of the response.',
llmTestAction: 'Test Call',
llmTestSuccessToast: 'LLM test call succeeded',
llmTestFailedToast: 'LLM test call failed',
@@ -1439,6 +1517,7 @@ export default {
media: 'Media',
network: 'Network',
log: 'Log',
data: 'Data',
lab: 'Lab',
downloaderSaveSuccess: 'Downloader settings saved successfully',
downloaderSaveFailed: 'Failed to save downloader settings!',
@@ -1456,6 +1535,7 @@ export default {
transmission: 'Transmission',
rtorrent: 'rTorrent',
emby: 'Emby',
zspace: 'ZSpace',
jellyfin: 'Jellyfin',
plex: 'Plex',
ugreen: 'Ugreen',
@@ -1536,6 +1616,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',
@@ -1582,6 +1677,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.',
@@ -1652,7 +1748,7 @@ export default {
userAgent: 'Browser User-Agent',
userAgentHint: 'User-Agent of the browser with CookieCloud plugin',
browserEmulation: 'Browser Emulation',
browserEmulationHint: 'Choose how to emulate browser when accessing sites (Playwright or FlareSolverr)',
browserEmulationHint: 'Choose how to emulate browser when accessing sites (CloakBrowser or FlareSolverr)',
flaresolverrUrl: 'FlareSolverr URL',
flaresolverrUrlHint: 'Required when using FlareSolverr, e.g. http://127.0.0.1:8191',
siteDataRefresh: 'Site Data Refresh',
@@ -1712,7 +1808,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',
@@ -1796,7 +1894,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',
@@ -2041,7 +2139,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',
@@ -2437,6 +2536,29 @@ 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}',
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',
@@ -2471,6 +2593,8 @@ export default {
savePathHint: 'Specify download save path for this subscription, leave empty to use default download directory',
bestVersion: 'Version Upgrade',
bestVersionHint: 'Perform version upgrade subscription based on upgrade priorities',
bestVersionFull: 'Full Season Upgrade',
bestVersionFullHint: 'Only download full-season packs and do not split packs by episode',
searchImdbid: 'Search Using ImdbID',
searchImdbidHint: 'Use ImdbID for precise resource searching',
showEditDialog: 'Edit More Rules When Subscribing',
@@ -2620,6 +2744,7 @@ export default {
close: 'Close',
loadingDirectoryStructure: 'Loading directory structure...',
reorganize: 'Reorganize',
filterPlaceholder: 'Filter (supports * ? wildcards)',
},
person: {
alias: 'Also Known As:',
@@ -2767,7 +2892,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',
@@ -2831,7 +2958,7 @@ export default {
},
transferHistory: {
title: 'Transfer History',
searchPlaceholder: 'Search transfer records',
searchPlaceholder: 'Search (supports * ? wildcards)',
titleColumn: 'Title',
pathColumn: 'Path',
modeColumn: 'Mode',
@@ -2939,7 +3066,8 @@ export default {
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.',
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',
@@ -3336,12 +3464,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',
@@ -3357,6 +3486,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',

View File

@@ -74,6 +74,9 @@ export default {
descending: '降序',
versionMismatch: '浏览器缓存版本与服务端版本不一致,请尝试清除缓存',
clearCache: '清除缓存',
sortMode: '排序模式',
sortModeHint: '已进入拖拽排序模式',
exit: '退出',
},
mediaType: {
movie: '电影',
@@ -146,6 +149,8 @@ export default {
transparencyAdjust: '透明度调整',
transparencyOpacity: '透明度',
transparencyBlur: '模糊度',
backgroundPosterOpacity: '背景透明度',
backgroundBlur: '背景磨砂效果',
transparencyReset: '重置',
transparencyLow: '低透明度',
transparencyMedium: '中等透明度',
@@ -316,7 +321,8 @@ export default {
settingTabs: {
system: {
title: '系统',
description: '基础设置、下载器Qbittorrent、Transmission、媒体服务器Emby、Jellyfin、Plex、飞牛影视、绿联影视',
description:
'基础设置、下载器Qbittorrent、Transmission、媒体服务器Emby、极影视、Jellyfin、Plex、飞牛影视、绿联影视',
},
directory: {
title: '存储 & 目录',
@@ -348,7 +354,7 @@ export default {
},
notification: {
title: '通知',
description: '通知渠道(微信、Telegram、Slack、SynologyChat、VoceChat、WebPush、消息发送范围',
description: '通知渠道(企业微信、微信 ClawBot、Telegram、Slack、SynologyChat、VoceChat、WebPush、消息发送范围',
},
about: {
title: '关于',
@@ -465,6 +471,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 +940,7 @@ export default {
plex: 'Plex',
jellyfin: 'Jellyfin',
emby: 'Emby',
zspace: '极影视',
appLaunchFailed: 'APP启动失败正在跳转到网页版',
appNotInstalled: '未检测到APP正在跳转到网页版',
downloadApp: '下载APP',
@@ -918,6 +979,8 @@ export default {
ranking: '排名',
noStatisticsData: '暂无分享统计数据',
bestVersion: '洗版中',
bestVersionEpisodeShort: '分集',
bestVersionWholeShort: '全集',
completed: '订阅完成',
subscribing: '订阅中',
notStarted: '未开始',
@@ -963,7 +1026,7 @@ export default {
doubanGlobalTVRankings: '豆瓣全球剧集榜',
noCategoryContent: '当前分类下没有可显示的内容',
configureContent: '设置显示内容',
customizeContent: '自定义内容',
customizeContent: '自定义推荐',
selectContentToDisplay: '选择您想在页面显示的内容',
selectAll: '全选',
selectNone: '全不选',
@@ -1350,9 +1413,10 @@ export default {
llmSupportImageInput: '模型支持图片输入',
llmSupportImageInputHint:
'启用后,消息中的图片会按多模态图片发送给 LLM关闭后图片会作为附件保存到本地并将文件路径提供给智能助手处理',
llmSupportAudioInputOutput: '支持音频输入输出',
llmSupportAudioInputOutputHint:
'启用后,智能助手可以转写用户发送的音频消息,并在支持的渠道上回复语音',
llmSupportAudioInput: '支持音频输入',
llmSupportAudioInputHint: '启用后,智能助手会将用户发送的音频消息转写为文字再处理',
llmSupportAudioOutput: '支持音频输出',
llmSupportAudioOutputHint: '启用后,智能助手可以在支持的渠道上发送语音回复',
llmMaxContextTokens: 'LLM 最大上下文 Token 数量 (K)',
llmMaxContextTokensHint:
'设定 LLM 记录会话历史的最大 Token 数量上限(千),超出后将自动修整历史记录以节省 Token 消耗及防止超出 LLM 限制',
@@ -1371,20 +1435,33 @@ export default {
llmProviderDeviceCode: '设备码',
llmProviderOpenAuthPage: '打开授权页面',
llmProviderCheckAuthStatus: '检查授权状态',
aiVoiceApiKey: '音频 API密钥',
aiVoiceApiKeyHint: '音频转写与语音合成使用的 API 密钥,留空时回退到当前 LLM API 密钥',
aiVoiceBaseUrl: '音频基础URL',
aiVoiceBaseUrlHint: '音频转写与语音合成接口的基础URL留空时回退到当前 LLM 基础 URL',
aiVoiceSttModel: '音频转写模型',
aiVoiceSttModelHint: '用于将音频内容转换为文字的模型名称',
aiVoiceTtsModel: '语音合成模型',
aiVoiceTtsModelHint: '用于将文字内容转换为语音的模型名称',
aiVoiceTtsVoice: '语音音色',
aiVoiceTtsVoiceHint: '语音合成使用的发音人或音色标识',
aiVoiceLanguage: '识别语言',
aiVoiceLanguageHint: '音频转写默认语言,例如 zh、en留空时按后端默认处理',
aiVoiceReplyWithText: '语音回复附带文字',
aiVoiceReplyWithTextHint: '发送语音回复时,同时附带一份文字内容',
audioInputProvider: '音频输入提供商',
audioInputProviderHint: '用于识别用户音频消息的服务,支持 OpenAI 音频接口、Chat Audio 兼容接口和 Xiaomi MiMo',
audioProviderOpenAiAudio: 'OpenAI Audio 兼容',
audioProviderChatAudio: 'Chat Audio 兼容',
audioProviderMimo: '小米 MiMo',
audioInputApiKey: '音频输入 API密钥',
audioInputApiKeyHint: '音频输入转写使用的 API 密钥',
audioInputBaseUrl: '音频输入基础URL',
audioInputBaseUrlHint:
'音频输入接口基础URLChat Audio 类服务可填写对应兼容地址MiMo 默认 https://api.xiaomimimo.com/v1',
audioInputModel: '音频输入模型',
audioInputModelHint: '用于将音频内容转换为文字的模型名称',
audioInputLanguage: '识别语言',
audioInputLanguageHint: '音频转写默认语言,例如 zh、en留空时按后端默认处理',
audioOutputProvider: '音频输出提供商',
audioOutputProviderHint: '用于生成语音回复的服务,支持 OpenAI 音频接口、Chat Audio 兼容接口和 Xiaomi MiMo',
audioOutputApiKey: '音频输出 API密钥',
audioOutputApiKeyHint: '文字转语音使用的 API 密钥',
audioOutputBaseUrl: '音频输出基础URL',
audioOutputBaseUrlHint:
'音频输出接口基础URLChat Audio 类服务可填写对应兼容地址MiMo 默认 https://api.xiaomimimo.com/v1',
audioOutputModel: '音频输出模型',
audioOutputModelHint: '用于将文字内容转换为语音的模型名称',
audioOutputVoice: '语音音色',
audioOutputVoiceHint: '语音合成使用的发音人或音色标识',
audioOutputIncludeText: '语音回复附带文字',
audioOutputIncludeTextHint: '发送语音回复时,同时附带一份文字内容',
llmTestAction: '测试调用',
llmTestSuccessToast: 'LLM 调用测试成功',
llmTestFailedToast: 'LLM 调用测试失败',
@@ -1425,6 +1502,7 @@ export default {
media: '媒体',
network: '网络',
log: '日志',
data: '数据',
lab: '实验室',
downloaderSaveSuccess: '下载器设置保存成功',
downloaderSaveFailed: '下载器设置保存失败!',
@@ -1442,6 +1520,7 @@ export default {
transmission: 'Transmission',
rtorrent: 'rTorrent',
emby: 'Emby',
zspace: '极影视',
jellyfin: 'Jellyfin',
plex: 'Plex',
ugreen: '绿联影视',
@@ -1484,8 +1563,8 @@ export default {
fanartEnableHint: '使用 fanart.tv 的图片数据',
fanartLang: 'Fanart语言',
fanartLangHint: '设置Fanart图片的语言偏好多选时按优先级顺序排列',
recognizePluginFirst: "优先使用插件识别",
recognizePluginFirstHint: "优先调用插件识别媒体信息,若插件命中则不再调用原生识别",
recognizePluginFirst: '优先使用插件识别',
recognizePluginFirstHint: '优先调用插件识别媒体信息,若插件命中则不再调用原生识别',
mediaRecognizeShare: '共享使用媒体识别数据',
mediaRecognizeShareHint: '识别成功后上报关键字与媒体ID识别失败时优先回查共享识别结果',
githubProxy: 'Github加速代理',
@@ -1516,6 +1595,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: '本地插件仓库路径',
@@ -1559,6 +1651,7 @@ export default {
},
mb: 'MB',
hour: '小时',
day: '天',
customizeWallpaperApi: '自定义壁纸API地址',
customizeWallpaperApiHint: '会获取API返回内容中所有允许的安全域名地址的图片需要同步设置安全域名地址',
customizeWallpaperApiRequired: '必填项请输入自定义壁纸API',
@@ -1604,7 +1697,7 @@ export default {
skipDesc: '跳过刮削,不生成该文件',
missingOnlyDesc: '仅在缺失时刮削,已存在则保持不变',
overwriteDesc: '始终刮削,已存在则覆盖',
}
},
},
site: {
siteSync: '站点同步',
@@ -1628,7 +1721,7 @@ export default {
siteDataRefresh: '站点数据刷新',
siteOptions: '站点选项',
browserEmulation: '浏览器仿真',
browserEmulationHint: '站点访问仿真方式,支持 Playwright 或 FlareSolverr',
browserEmulationHint: '站点访问仿真方式,支持 CloakBrowser 或 FlareSolverr',
flaresolverrUrl: 'FlareSolverr 服务地址',
flaresolverrUrlHint: '当仿真方式为 FlareSolverr 时生效例如http://127.0.0.1:8191',
siteDataRefreshInterval: '站点数据刷新间隔',
@@ -1685,7 +1778,9 @@ export default {
timeSaveSuccess: '通知发送时间保存成功',
timeSaveFailed: '通知发送时间保存失败!',
channel: '通知',
wechat: '微信',
wechat: '企业微信',
wechatClawBot: '微信 ClawBot',
feishu: '飞书',
resourceDownload: '资源下载',
mediaImport: '整理入库',
subscription: '订阅',
@@ -1763,7 +1858,7 @@ export default {
tvCategory: '电视剧',
animeCategory: '动漫',
downloadUser: '远程搜索自动下载用户',
downloadUserHint: '使用Telegram、微信等搜索时是否自动下载使用逗号分割设置为 all 代表所有用户自动择优下载',
downloadUserHint: '使用Telegram、企业微信等搜索时是否自动下载,使用逗号分割,设置为 all 代表所有用户自动择优下载',
multipleNameSearch: '多名称资源搜索',
multipleNameSearchHint: '使用多个名称(中文、英文等)搜索站点资源并合并搜索结果,会增加站点访问频率',
downloadSubtitle: '下载站点字幕',
@@ -2003,7 +2098,8 @@ export default {
resetDefaultAvatar: '重置默认头像',
restoreCurrentAvatar: '还原当前头像',
notifications: '通知',
wechat: '微信ID',
wechat: '企业微信ID',
wechatClawBot: '微信 ClawBot ID',
telegram: 'Telegram ID',
slack: 'Slack ID',
discord: 'Discord ID',
@@ -2395,6 +2491,29 @@ export default {
scrapeHint: '整理完成后自动刮削元数据',
fromHistoryOption: '复用历史识别信息',
fromHistoryHint: '使用历史整理记录中已识别的媒体信息',
previewTitle: '整理结果预览',
previewSubtitle: '点击“预览”后可查看本次整理的预计入库结果,不会实际改动文件',
previewResult: '预览',
previewLoading: '正在生成预览结果...',
previewRequestFailed: '预览请求失败',
previewTotal: '总数 {count}',
previewSuccess: '成功 {count}',
previewFailed: '失败 {count}',
previewMediaInfo: '媒体信息',
previewMediaName: '名称',
previewMediaType: '类型',
previewSeasonInfo: '季信息',
previewSeasonLabel: '季',
previewEpisodeCount: '总集数',
previewAfterColumn: '整理后',
previewBeforeColumn: '整理前',
previewFileNameColumn: '文件名',
previewEmptyTitle: '尚未生成预览',
previewEmptyDescription: '点击“预览”按钮后,在这里查看整理结果预览。',
noPreviewData: '暂无预览结果',
noFailedPreviewData: '当前没有失败项',
copySuccess: '路径已复制',
copyFailed: '复制失败',
addToQueue: '加入整理队列',
reorganizeNow: '立即整理',
auto: '自动',
@@ -2429,8 +2548,10 @@ export default {
savePathHint: '指定该订阅的下载保存路径,留空自动使用设定的下载目录',
bestVersion: '洗版',
bestVersionHint: '根据洗版优先级进行洗版订阅',
bestVersionFull: '全集洗版',
bestVersionFullHint: '只下载覆盖全集的整包资源,不按单集拆包下载',
searchImdbid: '使用 ImdbID 搜索',
searchImdbidHint: '开使用 ImdbID 精确搜索资源',
searchImdbidHint: '开启后使用 ImdbID 精确搜索资源',
showEditDialog: '订阅时编辑更多规则',
showEditDialogHint: '添加订阅时显示此编辑订阅对话框',
include: '包含(关键字、正则式)',
@@ -2578,6 +2699,7 @@ export default {
close: '关闭',
loadingDirectoryStructure: '加载目录结构...',
reorganize: '整理',
filterPlaceholder: '搜索(支持 * ? 通配符)',
},
person: {
alias: '别名:',
@@ -2722,7 +2844,9 @@ export default {
nickname: '昵称',
nicknamePlaceholder: '显示昵称,优先于用户名显示',
accountBinding: '账号绑定',
wechatUser: '微信用户',
wechatUser: '企业微信用户',
wechatClawBotUser: '微信 ClawBot 用户',
feishuUser: '飞书用户',
telegramUser: 'Telegram用户',
slackUser: 'Slack用户',
discordUser: 'Discord用户',
@@ -2783,7 +2907,7 @@ export default {
},
transferHistory: {
title: '转移历史',
searchPlaceholder: '搜索转移记录',
searchPlaceholder: '搜索(支持 * ? 通配符)',
titleColumn: '标题',
pathColumn: '路径',
modeColumn: '转移方式',
@@ -3284,12 +3408,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: '服务器地址',
@@ -3305,6 +3430,7 @@ export default {
typeHint: '选择要使用的通知渠道类型',
name: '通知名称',
nameHint: '为通知渠道设置一个名称',
feishuConfig: '飞书配置',
telegramConfig: 'Telegram 配置',
emailConfig: '邮件配置',
botToken: '机器人令牌',

View File

@@ -74,6 +74,9 @@ export default {
descending: '降序',
versionMismatch: '瀏覽器快取版本與服務端版本不一致,請嘗試清除快取',
clearCache: '清除快取',
sortMode: '排序模式',
sortModeHint: '已進入拖拽排序模式',
exit: '退出',
},
mediaType: {
movie: '電影',
@@ -146,6 +149,8 @@ export default {
transparencyAdjust: '透明度調整',
transparencyOpacity: '透明度',
transparencyBlur: '模糊度',
backgroundPosterOpacity: '背景透明度',
backgroundBlur: '背景磨砂效果',
transparencyReset: '重置',
transparencyLow: '低透明度',
transparencyMedium: '中等透明度',
@@ -317,7 +322,7 @@ export default {
system: {
title: '系統',
description:
'基礎設置、下載器Qbittorrent、Transmission、媒體服務器Emby、Jellyfin、Plex、飛牛影視、綠聯影視',
'基礎設置、下載器Qbittorrent、Transmission、媒體服務器Emby、極影視、Jellyfin、Plex、飛牛影視、綠聯影視',
},
directory: {
title: '存儲 & 目錄',
@@ -349,7 +354,7 @@ export default {
},
notification: {
title: '通知',
description: '通知渠道(微信、Telegram、Slack、SynologyChat、VoceChat、WebPush、消息發送範圍',
description: '通知渠道(企業微信、微信 ClawBot、Telegram、Slack、SynologyChat、VoceChat、WebPush、消息發送範圍',
},
about: {
title: '關於',
@@ -466,6 +471,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 +940,7 @@ export default {
plex: 'Plex',
jellyfin: 'Jellyfin',
emby: 'Emby',
zspace: '極影視',
appLaunchFailed: 'APP啟動失敗正在跳轉到網頁版',
appNotInstalled: '未檢測到APP正在跳轉到網頁版',
downloadApp: '下載APP',
@@ -919,6 +979,8 @@ export default {
ranking: '排名',
noStatisticsData: '暫無分享統計數據',
bestVersion: '洗版中',
bestVersionEpisodeShort: '分集',
bestVersionWholeShort: '全集',
completed: '訂閱完成',
subscribing: '訂閱中',
notStarted: '未開始',
@@ -964,7 +1026,7 @@ export default {
doubanGlobalTVRankings: '豆瓣全球劇集榜',
noCategoryContent: '當前分類下沒有可顯示的內容',
configureContent: '設置顯示內容',
customizeContent: '自定義內容',
customizeContent: '自定義推薦',
selectContentToDisplay: '選擇您想在頁面顯示的內容',
selectAll: '全選',
selectNone: '全不選',
@@ -1352,9 +1414,10 @@ export default {
llmSupportImageInput: '模型支援圖片輸入',
llmSupportImageInputHint:
'啟用後,消息中的圖片會按多模態圖片發送給 LLM關閉後圖片會作為附件保存到本地並將檔案路徑提供給智能助手處理',
llmSupportAudioInputOutput: '支援音頻輸入輸出',
llmSupportAudioInputOutputHint:
'啟用後,智能助手可以轉寫用戶發送的音頻消息,並在支援的渠道上回覆語音',
llmSupportAudioInput: '支援音頻輸入',
llmSupportAudioInputHint: '啟用後,智能助手會將用戶發送的音頻消息轉寫為文字再處理',
llmSupportAudioOutput: '支援音頻輸出',
llmSupportAudioOutputHint: '啟用後,智能助手可以在支援的渠道上發送語音回覆',
llmMaxContextTokens: 'LLM 最大上下文 Token 數量 (K)',
llmMaxContextTokensHint:
'設定 LLM 記錄會話歷史的最大 Token 數量上限(千),超出後將自動修整歷史記錄以節省 Token 消耗及防止超出 LLM 限制',
@@ -1373,20 +1436,33 @@ export default {
llmProviderDeviceCode: '設備碼',
llmProviderOpenAuthPage: '開啟授權頁面',
llmProviderCheckAuthStatus: '檢查授權狀態',
aiVoiceApiKey: '音頻 API密鑰',
aiVoiceApiKeyHint: '音頻轉寫與語音合成使用的 API 密鑰,留空時回退到當前 LLM API 密鑰',
aiVoiceBaseUrl: '音頻基礎URL',
aiVoiceBaseUrlHint: '音頻轉寫與語音合成接口的基礎URL留空時回退到當前 LLM 基礎 URL',
aiVoiceSttModel: '音頻轉寫模型',
aiVoiceSttModelHint: '用於將音頻內容轉換為文字的模型名稱',
aiVoiceTtsModel: '語音合成模型',
aiVoiceTtsModelHint: '用於將文字內容轉換為語音的模型名稱',
aiVoiceTtsVoice: '語音音色',
aiVoiceTtsVoiceHint: '語音合成使用的發音人或音色標識',
aiVoiceLanguage: '識別語言',
aiVoiceLanguageHint: '音頻轉寫預設語言,例如 zh、en留空時按後端預設處理',
aiVoiceReplyWithText: '語音回覆附帶文字',
aiVoiceReplyWithTextHint: '發送語音回覆時,同時附帶一份文字內容',
audioInputProvider: '音頻輸入提供商',
audioInputProviderHint: '用於識別用戶音頻消息的服務,支援 OpenAI 音頻接口、Chat Audio 兼容接口和 Xiaomi MiMo',
audioProviderOpenAiAudio: 'OpenAI Audio 兼容',
audioProviderChatAudio: 'Chat Audio 兼容',
audioProviderMimo: '小米 MiMo',
audioInputApiKey: '音頻輸入 API密鑰',
audioInputApiKeyHint: '音頻輸入轉寫使用的 API 密鑰',
audioInputBaseUrl: '音頻輸入基礎URL',
audioInputBaseUrlHint:
'音頻輸入接口基礎URLChat Audio 類服務可填寫對應兼容地址MiMo 預設 https://api.xiaomimimo.com/v1',
audioInputModel: '音頻輸入模型',
audioInputModelHint: '用於將音頻內容轉換為文字的模型名稱',
audioInputLanguage: '識別語言',
audioInputLanguageHint: '音頻轉寫預設語言,例如 zh、en留空時按後端預設處理',
audioOutputProvider: '音頻輸出提供商',
audioOutputProviderHint: '用於生成語音回覆的服務,支援 OpenAI 音頻接口、Chat Audio 兼容接口和 Xiaomi MiMo',
audioOutputApiKey: '音頻輸出 API密鑰',
audioOutputApiKeyHint: '文字轉語音使用的 API 密鑰',
audioOutputBaseUrl: '音頻輸出基礎URL',
audioOutputBaseUrlHint:
'音頻輸出接口基礎URLChat Audio 類服務可填寫對應兼容地址MiMo 預設 https://api.xiaomimimo.com/v1',
audioOutputModel: '音頻輸出模型',
audioOutputModelHint: '用於將文字內容轉換為語音的模型名稱',
audioOutputVoice: '語音音色',
audioOutputVoiceHint: '語音合成使用的發音人或音色標識',
audioOutputIncludeText: '語音回覆附帶文字',
audioOutputIncludeTextHint: '發送語音回覆時,同時附帶一份文字內容',
llmTestAction: '測試調用',
llmTestSuccessToast: 'LLM 調用測試成功',
llmTestFailedToast: 'LLM 調用測試失敗',
@@ -1427,6 +1503,7 @@ export default {
media: '媒體',
network: '網絡',
log: '日誌',
data: '數據',
lab: '實驗室',
downloaderSaveSuccess: '下載器設置保存成功',
downloaderSaveFailed: '下載器設置保存失敗!',
@@ -1444,6 +1521,7 @@ export default {
transmission: 'Transmission',
rtorrent: 'rTorrent',
emby: 'Emby',
zspace: '極影視',
jellyfin: 'Jellyfin',
plex: 'Plex',
ugreen: '綠聯影視',
@@ -1518,6 +1596,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: '本地插件倉庫路徑',
@@ -1561,6 +1652,7 @@ export default {
},
mb: 'MB',
hour: '小時',
day: '天',
customizeWallpaperApi: '自定義壁紙API',
customizeWallpaperApiHint: '會獲取 API 返回內容中所有安全設置中允許的圖片地址,需要設置安全域名白名單',
customizeWallpaperApiRequired: '必填項請輸出自定義壁紙API',
@@ -1630,7 +1722,7 @@ export default {
siteDataRefresh: '站點數據刷新',
siteOptions: '站點選項',
browserEmulation: '瀏覽器仿真',
browserEmulationHint: '站點訪問仿真方式,支援 Playwright 或 FlareSolverr',
browserEmulationHint: '站點訪問仿真方式,支援 CloakBrowser 或 FlareSolverr',
flaresolverrUrl: 'FlareSolverr 服務地址',
flaresolverrUrlHint: '當仿真方式為 FlareSolverr 時生效例如http://127.0.0.1:8191',
siteDataRefreshInterval: '站點數據刷新間隔',
@@ -1687,7 +1779,9 @@ export default {
timeSaveSuccess: '通知發送時間保存成功',
timeSaveFailed: '通知發送時間保存失敗!',
channel: '通知',
wechat: '微信',
wechat: '企業微信',
wechatClawBot: '微信 ClawBot',
feishu: '飛書',
resourceDownload: '資源下載',
mediaImport: '整理入庫',
subscription: '訂閱',
@@ -1773,7 +1867,7 @@ export default {
mediaSourceHint: '搜索媒體信息時使用的數據源以及排序',
filterRuleGroupHint: '搜索媒體信息時按選定的過濾規則組對結果進行過濾',
downloadUserPlaceholder: '用戶ID1,用戶ID2',
downloadUserHint: '使用Telegram、微信等搜索時是否自動下載使用逗號分割設置為 all 代表所有用戶自動擇優下載',
downloadUserHint: '使用Telegram、企業微信等搜索時是否自動下載,使用逗號分割,設置為 all 代表所有用戶自動擇優下載',
downloadLabelPlaceholder: 'MOVIEPILOT',
},
directory: {
@@ -2005,7 +2099,8 @@ export default {
resetDefaultAvatar: '重置默認頭像',
restoreCurrentAvatar: '還原當前頭像',
notifications: '通知',
wechat: '微信UserID',
wechat: '企業微信 UserID',
wechatClawBot: '微信 ClawBot ID',
telegram: 'Telegram UserID',
slack: 'Slack UserID',
discord: 'Discord UserID',
@@ -2397,6 +2492,29 @@ export default {
scrapeHint: '整理完成後自動刮削元數據',
fromHistoryOption: '復用歷史識別資訊',
fromHistoryHint: '使用歷史整理記錄中已識別的媒體資訊',
previewTitle: '整理結果預覽',
previewSubtitle: '點擊「預覽」後可查看本次整理的預計入庫結果,不會實際改動文件',
previewResult: '預覽',
previewLoading: '正在生成預覽結果...',
previewRequestFailed: '預覽請求失敗',
previewTotal: '總數 {count}',
previewSuccess: '成功 {count}',
previewFailed: '失敗 {count}',
previewMediaInfo: '媒體資訊',
previewMediaName: '名稱',
previewMediaType: '類型',
previewSeasonInfo: '季資訊',
previewSeasonLabel: '季',
previewEpisodeCount: '總集數',
previewAfterColumn: '整理後',
previewBeforeColumn: '整理前',
previewFileNameColumn: '文件名',
previewEmptyTitle: '尚未生成預覽',
previewEmptyDescription: '點擊「預覽」按鈕後,在這裡查看整理結果預覽。',
noPreviewData: '暫無預覽結果',
noFailedPreviewData: '目前沒有失敗項',
copySuccess: '路徑已複製',
copyFailed: '複製失敗',
addToQueue: '加入整理隊列',
reorganizeNow: '立即整理',
auto: '自動',
@@ -2431,6 +2549,8 @@ export default {
savePathHint: '指定該訂閱的下載儲存路徑,留空自動使用設定的下載目錄',
bestVersion: '洗版',
bestVersionHint: '根據洗版優先級進行洗版訂閱',
bestVersionFull: '全集洗版',
bestVersionFullHint: '只下載覆蓋全集的整包資源,不按單集拆包下載',
searchImdbid: '使用 ImdbID 搜索',
searchImdbidHint: '開使用 ImdbID 精確搜索資源',
showEditDialog: '訂閱時編輯更多規則',
@@ -2580,6 +2700,7 @@ export default {
close: '關閉',
loadingDirectoryStructure: '加載目錄結構...',
reorganize: '整理',
filterPlaceholder: '搜尋(支援 * ? 萬用字元)',
},
person: {
alias: '別名:',
@@ -2724,7 +2845,9 @@ export default {
nickname: '暱稱',
nicknamePlaceholder: '顯示暱稱,優先於用戶名顯示',
accountBinding: '賬號綁定',
wechatUser: '微信用戶',
wechatUser: '企業微信用戶',
wechatClawBotUser: '微信 ClawBot 用戶',
feishuUser: '飛書用戶',
telegramUser: 'Telegram用戶',
slackUser: 'Slack用戶',
discordUser: 'Discord用戶',
@@ -2785,7 +2908,7 @@ export default {
},
transferHistory: {
title: '轉移歷史',
searchPlaceholder: '搜索轉移記錄',
searchPlaceholder: '搜索(支援 * ? 萬用字元)',
titleColumn: '標題',
pathColumn: '路徑',
modeColumn: '轉移方式',
@@ -3286,12 +3409,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: '伺服器位址',
@@ -3307,6 +3431,7 @@ export default {
typeHint: '選擇要使用的通知管道類型',
name: '通知名稱',
nameHint: '為通知管道設定一個名稱',
feishuConfig: '飛書設定',
telegramConfig: 'Telegram 設定',
emailConfig: '郵件設定',
botToken: '機器人權杖',

View File

@@ -13,32 +13,47 @@ import i18n from '@/plugins/i18n'
import App from '@/App.vue'
import { PerfectScrollbarPlugin } from 'vue3-perfect-scrollbar'
// 4. 工具函数和其他辅助模块
import { loadRemoteComponents } from './utils/federationLoader'
// 5. 其他插件和功能模块
// 4. 其他插件和功能模块
import Toast from 'vue-toastification'
import ConfirmDialog from '@/composables/useConfirm'
import { configureApexChartsTheme } from '@/utils/apexCharts'
// 6. 注册自定义组件
// 5. 注册自定义组件
import DialogCloseBtn from '@/@core/components/DialogCloseBtn.vue'
import ScrollToTopBtn from '@/@core/components/ScrollToTopBtn.vue'
import PageContentTitle from './@core/components/PageContentTitle.vue'
// 7. 样式文件 - 合并为单一导入
// 6. 样式文件 - 合并为单一导入
import '@/styles/main.scss'
// 8. 状态恢复插件
// 7. 状态恢复插件
import stateRestorePlugin from '@/plugins/stateRestore'
// 9. 后台优化工具
import { backgroundManager } from '@/utils/backgroundManager'
import { sseManagerSingleton } from '@/utils/sseManager'
function runWhenBrowserIdle(callback: () => void, timeout = 1500) {
const requestIdle = globalThis.requestIdleCallback
if (requestIdle) {
requestIdle(callback, { timeout })
return
}
const iconBundlePromise = import('@/@iconify/icons-bundle').catch(error => {
console.error('Failed to load icon bundle', error)
})
globalThis.setTimeout(callback, 0)
}
function loadIconBundle() {
import('@/@iconify/icons-bundle').catch(error => {
console.error('Failed to load icon bundle', error)
})
}
function loadRemoteComponentsAfterLogin() {
import('./utils/federationLoader')
.then(({ loadRemoteComponents }) => loadRemoteComponents())
.catch(error => {
console.error('Failed to load remote components', error)
})
}
let remoteComponentsInitialized = false
const AsyncAceEditor = defineAsyncComponent(async () => {
await import('./ace-config')
@@ -70,11 +85,6 @@ const app = createApp(App)
// 1. 注册pinia
app.use(pinia)
// 异步加载远程组件(不阻塞启动)
loadRemoteComponents().catch(error => {
console.error('Failed to load remote components', error)
})
// 2. 注册 UI 框架
app.use(vuetify)
@@ -105,11 +115,20 @@ app
.use(ConfirmDialog)
.use(i18n)
await iconBundlePromise
app.mount('#app')
// 页面卸载时清理后台管理器
window.addEventListener('beforeunload', () => {
backgroundManager.destroy()
sseManagerSingleton.closeAllManagers()
// 图标全集很大,延后到首屏挂载后的空闲时间加载,避免阻塞登录页首次渲染。
runWhenBrowserIdle(loadIconBundle)
// 插件远程入口只在登录后有用,延后初始化可以减少未登录首屏请求和解析成本。
router.isReady().then(() => {
const loadIfAuthenticated = () => {
if (!remoteComponentsInitialized && pinia.state.value.auth?.token) {
remoteComponentsInitialized = true
runWhenBrowserIdle(loadRemoteComponentsAfterLogin)
}
}
loadIfAuthenticated()
router.afterEach(loadIfAuthenticated)
})

View File

@@ -34,7 +34,7 @@ function getApiPath(paths: string[] | string) {
<VPageContentTitle :title="title" />
<PersonCardListView v-if="type === 'person'" :apipath="getApiPath(props.paths || '')" :params="route.query" />
<MediaCardListView v-else :apipath="getApiPath(props.paths || '')" :params="route.query" />
<Teleport to="body" v-if="route.path === '/browse'">
<Teleport to="body">
<VScrollToTopBtn />
</Teleport>
</div>

View File

@@ -280,6 +280,40 @@ async function getPluginDashboardMeta() {
}
}
function clearPluginDashboardTimer(pluginDashboardId: string) {
if (!refreshTimers.value[pluginDashboardId]) return
clearTimeout(refreshTimers.value[pluginDashboardId])
delete refreshTimers.value[pluginDashboardId]
}
function schedulePluginDashboardRefresh(item: DashboardItem) {
const pluginDashboardId = buildPluginDashboardId(item.id, item.key)
clearPluginDashboardTimer(pluginDashboardId)
if (
item.attrs?.refresh &&
pluginDashboardRefreshStatus.value[pluginDashboardId] &&
enableConfig.value[pluginDashboardId] &&
isRequest.value
) {
refreshTimers.value[pluginDashboardId] = setTimeout(() => {
getPluginDashboard(item.id, item.key)
}, item.attrs.refresh * 1000)
}
}
function refreshEnabledPluginDashboards() {
if (!superUser || isNullOrEmptyObject(pluginDashboardMeta.value)) return
pluginDashboardMeta.value.forEach((pluginDashboard: { id: string; key: string }) => {
const pluginDashboardId = buildPluginDashboardId(pluginDashboard.id, pluginDashboard.key)
if (enableConfig.value[pluginDashboardId]) {
getPluginDashboard(pluginDashboard.id, pluginDashboard.key)
}
})
}
// 获取一个插件的仪表板配置项
async function getPluginDashboard(id: string, key: string) {
try {
@@ -309,22 +343,7 @@ async function getPluginDashboard(id: string, key: string) {
}
const pluginDashboardId = buildPluginDashboardId(id, key)
// 定时刷新
if (
res.attrs?.refresh &&
pluginDashboardRefreshStatus.value[pluginDashboardId] &&
enableConfig.value[pluginDashboardId] &&
isRequest.value
) {
// 清除之前的定时器
if (refreshTimers.value[pluginDashboardId]) {
clearTimeout(refreshTimers.value[pluginDashboardId])
}
// 设置新的定时器
let timer = setTimeout(() => {
getPluginDashboard(id, key)
}, res.attrs.refresh * 1000)
refreshTimers.value[pluginDashboardId] = timer
}
schedulePluginDashboardRefresh(res)
}
})
} catch (error) {
@@ -346,10 +365,12 @@ onBeforeMount(async () => {
onActivated(() => {
isRequest.value = true
refreshEnabledPluginDashboards()
})
onDeactivated(() => {
isRequest.value = false
Object.keys(refreshTimers.value).forEach(clearPluginDashboardTimer)
})
</script>

View File

@@ -5,6 +5,7 @@ import DownloadingListView from '@/views/reorganize/DownloadingListView.vue'
import NoDataFound from '@/components/NoDataFound.vue'
import { useI18n } from 'vue-i18n'
import { useDynamicHeaderTab } from '@/composables/useDynamicHeaderTab'
import { useKeepAliveRefresh } from '@/composables/useKeepAliveRefresh'
// 国际化
const { t } = useI18n()
@@ -52,7 +53,7 @@ onMounted(async () => {
registerTabs()
})
onActivated(async () => {
useKeepAliveRefresh(async () => {
await loadDownloaderSetting()
registerTabs()
})
@@ -61,10 +62,10 @@ onActivated(async () => {
<template>
<div v-if="downloaders.length > 0">
<VWindow v-model="activeTab" class="disable-tab-transition" :touch="false">
<VWindowItem v-for="item in downloaders" :value="item.name">
<VWindowItem v-for="item in downloaders" :key="item.name" :value="item.name">
<transition name="fade-slide" appear>
<div>
<DownloadingListView :name="item.name" />
<DownloadingListView :name="item.name" :active="activeTab === item.name" />
</div>
</transition>
</VWindowItem>

View File

@@ -1,6 +1,6 @@
<script setup lang="ts">
import { VForm } from 'vuetify/components/VForm'
import { useAuthStore, useUserStore, useGlobalSettingsStore } from '@/stores'
import { useAuthStore, useUserStore } from '@/stores'
import { authState, userState } from '@/stores/types'
import { requiredValidator } from '@/@validators'
import api from '@/api'
@@ -20,9 +20,6 @@ const { t } = useI18n()
const authStore = useAuthStore()
//用户 Store
const userStore = useUserStore()
// 全局设置 Store
const globalSettingsStore = useGlobalSettingsStore()
// 获取有权限的菜单
const navMenus = computed(() => getNavMenus(t))
@@ -373,9 +370,6 @@ async function handleLoginSuccess(response: any) {
authStore.login(authPayLoad)
userStore.loginUser(userPayload)
// 登录后加载用户相关的全局设置
await globalSettingsStore.loadUserSettings()
await afterLogin(userPayload.superUser, userPayload, filteredMenus)
}

View File

@@ -5,9 +5,12 @@ import MediaCardSlideView from '@/views/discover/MediaCardSlideView.vue'
import { useI18n } from 'vue-i18n'
import { useDisplay } from 'vuetify'
import { useDynamicHeaderTab } from '@/composables/useDynamicHeaderTab'
import { useDynamicButton } from '@/composables/useDynamicButton'
import { usePWA } from '@/composables/usePWA'
import { getItemColor, initializeItemColors } from '@/utils/colorUtils'
const display = useDisplay()
const { appMode } = usePWA()
// 国际化
const { t } = useI18n()
@@ -21,6 +24,10 @@ const currentCategory = ref(t('recommend.all'))
// 使用动态标签页
const { registerHeaderTab } = useDynamicHeaderTab()
function openRecommendSettings() {
dialog.value = true
}
const viewList = reactive<{ apipath: string; linkurl: string; title: string; type: string }[]>([
{
apipath: 'recommend/tmdb_trending',
@@ -218,17 +225,12 @@ const categoryItems = computed(() => [
registerHeaderTab({
items: categoryItems,
modelValue: currentCategory,
appendButtons: [
{
icon: 'mdi-tune',
variant: 'text',
color: 'grey',
class: 'settings-icon-button',
action: () => {
dialog.value = true
},
},
],
})
useDynamicButton({
icon: 'mdi-tune',
onClick: openRecommendSettings,
show: computed(() => appMode.value),
})
// 页面是否准备就绪
@@ -346,7 +348,19 @@ onActivated(async () => {
<!-- 快速滚动到顶部按钮 -->
<Teleport to="body" v-if="route.path === '/recommend'">
<VScrollToTopBtn />
<div v-if="!appMode" class="compact-fab-stack">
<VFab
icon="mdi-tune"
color="primary"
appear
class="compact-fab compact-fab--primary"
@click="openRecommendSettings"
/>
</div>
</Teleport>
<Teleport to="body" v-if="route.path === '/recommend'">
<VScrollToTopBtn :offset-fab="!appMode" />
</Teleport>
</div>
</template>

File diff suppressed because it is too large Load Diff

View File

@@ -1,13 +1,6 @@
<script lang="ts" setup>
import { useRoute } from 'vue-router'
import router from '@/router'
import AccountSettingNotification from '@/views/setting/AccountSettingNotification.vue'
import AccountSettingSite from '@/views/setting/AccountSettingSite.vue'
import AccountSettingSearch from '@/views/setting/AccountSettingSearch.vue'
import AccountSettingSubscribe from '@/views/setting/AccountSettingSubscribe.vue'
import AccountSettingSystem from '@/views/setting/AccountSettingSystem.vue'
import AccountSettingDirectory from '@/views/setting/AccountSettingDirectory.vue'
import AccountSettingRule from '@/views/setting/AccountSettingRule.vue'
import { getSettingTabs } from '@/router/i18n-menu'
import { useDynamicHeaderTab } from '@/composables/useDynamicHeaderTab'
@@ -17,6 +10,35 @@ const route = useRoute()
const activeTab = ref((route.query.tab as string) || '')
const settingTabs = computed(() => getSettingTabs(t))
// 设置页的每个大类都很重,按标签页拆包,避免进入设置时一次性下载全部配置面板。
const AccountSettingSystem = defineAsyncComponent(() => import('@/views/setting/AccountSettingSystem.vue'))
const AccountSettingDirectory = defineAsyncComponent(() => import('@/views/setting/AccountSettingDirectory.vue'))
const AccountSettingSite = defineAsyncComponent(() => import('@/views/setting/AccountSettingSite.vue'))
const AccountSettingRule = defineAsyncComponent(() => import('@/views/setting/AccountSettingRule.vue'))
const AccountSettingSearch = defineAsyncComponent(() => import('@/views/setting/AccountSettingSearch.vue'))
const AccountSettingSubscribe = defineAsyncComponent(() => import('@/views/setting/AccountSettingSubscribe.vue'))
const AccountSettingNotification = defineAsyncComponent(() => import('@/views/setting/AccountSettingNotification.vue'))
const visitedTabs = ref(new Set<string>())
const settingTabComponents = [
{ value: 'system', component: AccountSettingSystem },
{ value: 'directory', component: AccountSettingDirectory },
{ value: 'site', component: AccountSettingSite },
{ value: 'rule', component: AccountSettingRule },
{ value: 'search', component: AccountSettingSearch },
{ value: 'subscribe', component: AccountSettingSubscribe },
{ value: 'notification', component: AccountSettingNotification },
]
function markTabVisited(tab: string) {
if (!tab) return
const nextTabs = new Set(visitedTabs.value)
nextTabs.add(tab)
visitedTabs.value = nextTabs
}
// 使用动态标签页
const { registerHeaderTab } = useDynamicHeaderTab()
@@ -32,71 +54,23 @@ onMounted(() => {
if (!activeTab.value && settingTabs.value.length > 0) {
activeTab.value = settingTabs.value[0].tab
}
markTabVisited(activeTab.value)
})
watch(activeTab, markTabVisited, { immediate: true })
</script>
<template>
<div>
<VWindow v-model="activeTab" class="disable-tab-transition" :touch="false">
<!-- 系统 -->
<VWindowItem value="system">
<VWindowItem v-for="item in settingTabComponents" :key="item.value" :value="item.value">
<transition name="fade-slide" appear>
<div>
<AccountSettingSystem />
</div>
</transition>
</VWindowItem>
<!-- 目录 -->
<VWindowItem value="directory">
<transition name="fade-slide" appear>
<div>
<AccountSettingDirectory />
</div>
</transition>
</VWindowItem>
<!-- 站点 -->
<VWindowItem value="site">
<transition name="fade-slide" appear>
<div>
<AccountSettingSite />
</div>
</transition>
</VWindowItem>
<!-- 规则 -->
<VWindowItem value="rule">
<transition name="fade-slide" appear>
<div>
<AccountSettingRule />
</div>
</transition>
</VWindowItem>
<!-- 搜索 -->
<VWindowItem value="search">
<transition name="fade-slide" appear>
<div>
<AccountSettingSearch />
</div>
</transition>
</VWindowItem>
<!-- 订阅 -->
<VWindowItem value="subscribe">
<transition name="fade-slide" appear>
<div>
<AccountSettingSubscribe />
</div>
</transition>
</VWindowItem>
<!-- 通知 -->
<VWindowItem value="notification">
<transition name="fade-slide" appear>
<div>
<AccountSettingNotification />
<component
:is="item.component"
v-if="visitedTabs.has(item.value)"
:active="activeTab === item.value"
/>
</div>
</transition>
</VWindowItem>

View File

@@ -1,10 +1,6 @@
<script setup lang="ts">
import { debounce } from 'lodash-es'
import SubscribeListView from '@/views/subscribe/SubscribeListView.vue'
import SubscribePopularView from '@/views/subscribe/SubscribePopularView.vue'
import SubscribeShareView from '@/views/subscribe/SubscribeShareView.vue'
import SubscribeEditDialog from '@/components/dialog/SubscribeEditDialog.vue'
import SubscribeShareStatisticsDialog from '@/components/dialog/SubscribeShareStatisticsDialog.vue'
import { useI18n } from 'vue-i18n'
import { useDynamicHeaderTab } from '@/composables/useDynamicHeaderTab'
import { useDynamicButton } from '@/composables/useDynamicButton'
@@ -20,6 +16,14 @@ const route = useRoute()
const userStore = useUserStore()
const { appMode } = usePWA()
// 非默认标签页和弹窗按需加载,避免进入订阅列表时同步下载分享/统计相关代码。
const SubscribePopularView = defineAsyncComponent(() => import('@/views/subscribe/SubscribePopularView.vue'))
const SubscribeShareView = defineAsyncComponent(() => import('@/views/subscribe/SubscribeShareView.vue'))
const SubscribeEditDialog = defineAsyncComponent(() => import('@/components/dialog/SubscribeEditDialog.vue'))
const SubscribeShareStatisticsDialog = defineAsyncComponent(
() => import('@/components/dialog/SubscribeShareStatisticsDialog.vue'),
)
const subType = route.meta.subType?.toString()
const subId = ref(route.query.id as string)
const activeTab = ref((route.query.tab as string) || '')
@@ -46,6 +50,9 @@ const searchShareDialog = ref(false)
// 订阅分享统计弹窗
const shareStatisticsDialog = ref(false)
// 排序模式
const subscribeSortMode = ref(false)
// 订阅过滤词
const subscribeFilter = ref('')
@@ -122,6 +129,10 @@ function openShareStatisticsDialog() {
shareStatisticsDialog.value = true
}
function toggleSubscribeSortMode() {
subscribeSortMode.value = !subscribeSortMode.value
}
const shareKeywordUpdater = debounce((keyword: string) => {
shareKeyword.value = keyword.trim()
}, 300)
@@ -220,6 +231,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 +286,9 @@ onMounted(() => {
:subid="subId"
:keyword="subscribeFilter"
:status-filter="subscribeStatusFilter ?? ''"
:sort-mode="subscribeSortMode"
:active="activeTab === 'mysub'"
@update:sort-mode="subscribeSortMode = $event"
/>
</div>
</transition>

View File

@@ -15,7 +15,6 @@ const route = useRoute()
const { appMode } = usePWA()
const activeTab = ref((route.query.tab as string) || 'list')
const listViewKey = ref(0)
const workflowListViewRef = ref<InstanceType<typeof WorkflowListView> | null>(null)
// 获取标签页
@@ -37,6 +36,10 @@ function openAddWorkflowDialog() {
workflowListViewRef.value?.openAddDialog()
}
function refreshWorkflowList() {
workflowListViewRef.value?.refresh()
}
const shareKeywordUpdater = debounce((keyword: string) => {
shareKeyword.value = keyword.trim()
}, 300)
@@ -98,14 +101,14 @@ onMounted(() => {
<VWindowItem value="list">
<transition name="fade-slide" appear>
<div>
<WorkflowListView ref="workflowListViewRef" :key="listViewKey" />
<WorkflowListView ref="workflowListViewRef" />
</div>
</transition>
</VWindowItem>
<VWindowItem value="share">
<transition name="fade-slide" appear>
<div>
<WorkflowShareView :keyword="shareKeyword" @update="listViewKey++" />
<WorkflowShareView :keyword="shareKeyword" @update="refreshWorkflowList" />
</div>
</transition>
</VWindowItem>

View File

@@ -48,6 +48,7 @@ const router = createRouter({
path: '/resource',
component: () => import('../pages/resource.vue'),
meta: {
keepAlive: true,
requiresAuth: true,
},
},
@@ -56,6 +57,7 @@ const router = createRouter({
component: () => import('../pages/subscribe.vue'),
meta: {
keepAlive: true,
keepAliveKey: 'subscribe-movie',
requiresAuth: true,
subType: '电影',
},
@@ -65,6 +67,7 @@ const router = createRouter({
component: () => import('../pages/subscribe.vue'),
meta: {
keepAlive: true,
keepAliveKey: 'subscribe-tv',
requiresAuth: true,
subType: '电视剧',
},
@@ -73,7 +76,6 @@ const router = createRouter({
path: '/subscribe-share',
component: () => import('../pages/subscribe-share.vue'),
meta: {
keepAlive: true,
requiresAuth: true,
},
},
@@ -97,6 +99,7 @@ const router = createRouter({
path: '/downloading',
component: () => import('../pages/downloading.vue'),
meta: {
keepAlive: true,
requiresAuth: true,
},
},
@@ -104,6 +107,7 @@ const router = createRouter({
path: '/history',
component: () => import('../pages/history.vue'),
meta: {
keepAlive: true,
requiresAuth: true,
hideFooter: true,
},
@@ -152,6 +156,7 @@ const router = createRouter({
path: '/setting',
component: () => import('../pages/setting.vue'),
meta: {
keepAlive: true,
requiresAuth: true,
},
},
@@ -190,6 +195,7 @@ const router = createRouter({
path: '/filemanager',
component: () => import('../pages/filemanager.vue'),
meta: {
keepAlive: true,
requiresAuth: true,
hideFooter: true,
},
@@ -198,6 +204,7 @@ const router = createRouter({
path: '/apps',
component: () => import('../pages/appcenter.vue'),
meta: {
keepAlive: true,
requiresAuth: true,
},
},

View File

@@ -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实时日志流

View File

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

View File

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

View File

@@ -11,56 +11,61 @@ export class BackgroundManager {
runInBackground?: boolean
}> = new Map()
private readonly activityEvents = ['mousedown', 'mousemove', 'keypress', 'scroll', 'touchstart', 'click']
private readonly handleVisibilityChange = () => {
const wasBackground = this.isBackground
this.isBackground = document.hidden
if (this.isBackground && !wasBackground) {
console.log('Background: 进入后台,暂停定时器')
this.pauseAllTimers()
} else if (!this.isBackground && wasBackground) {
console.log('Background: 回到前台,恢复定时器')
this.resumeAllTimers()
}
}
private readonly handleBeforeUnload = () => {
this.destroy()
}
private readonly updateActivity = () => {
this.lastActivityTime = Date.now()
}
private isBackground = false
private isDestroyed = false
private lastActivityTime = Date.now()
private activityTimer: ReturnType<typeof setInterval> | null = null
private isInitialized = false
constructor() {
private ensureInitialized() {
if (this.isInitialized || this.isDestroyed) return
this.isInitialized = true
this.isBackground = document.hidden
this.setupVisibilityListener()
this.setupActivityTracking()
}
private setupVisibilityListener() {
document.addEventListener('visibilitychange', () => {
const wasBackground = this.isBackground
this.isBackground = document.hidden
if (this.isBackground && !wasBackground) {
console.log('Background: 进入后台,暂停定时器')
this.pauseAllTimers()
} else if (!this.isBackground && wasBackground) {
console.log('Background: 回到前台,恢复定时器')
this.resumeAllTimers()
}
})
// 页面卸载时清理
window.addEventListener('beforeunload', () => {
this.destroy()
})
document.addEventListener('visibilitychange', this.handleVisibilityChange)
window.addEventListener('beforeunload', this.handleBeforeUnload)
}
private setupActivityTracking() {
// 跟踪用户活动
const events = ['mousedown', 'mousemove', 'keypress', 'scroll', 'touchstart', 'click']
const updateActivity = () => {
this.lastActivityTime = Date.now()
}
events.forEach(event => {
document.addEventListener(event, updateActivity, { passive: true })
// 按需跟踪用户活动,避免应用启动时就注册一批全局监听。
this.activityEvents.forEach(event => {
document.addEventListener(event, this.updateActivity, { passive: true })
})
}
// 定期更新活动状态
this.activityTimer = setInterval(() => {
// 如果超过5分钟没有活动可以考虑减少后台活动
const inactiveTime = Date.now() - this.lastActivityTime
if (inactiveTime > 5 * 60 * 1000) {
console.log('Background: 用户长时间不活跃')
}
}, 60000) // 每分钟检查一次
private removeLifecycleListeners() {
if (!this.isInitialized) return
document.removeEventListener('visibilitychange', this.handleVisibilityChange)
window.removeEventListener('beforeunload', this.handleBeforeUnload)
this.activityEvents.forEach(event => {
document.removeEventListener(event, this.updateActivity)
})
this.isInitialized = false
}
/**
@@ -76,6 +81,9 @@ export class BackgroundManager {
} = {}
) {
const { runInBackground = false, skipInitialRun = false } = options
if (this.isDestroyed) return
this.ensureInitialized()
this.removeTimer(id)
@@ -122,6 +130,11 @@ export class BackgroundManager {
}
this.timers.delete(id)
console.log(`Background: 移除定时器 ${id}`)
// 没有任务时释放监听,首屏只导入模块不会产生常驻开销。
if (this.timers.size === 0) {
this.removeLifecycleListeners()
}
}
}
@@ -237,11 +250,8 @@ export class BackgroundManager {
})
this.timers.clear()
// 清理活动跟踪定时器
if (this.activityTimer) {
clearInterval(this.activityTimer)
this.activityTimer = null
}
// 清理按需注册的生命周期与活动监听
this.removeLifecycleListeners()
console.log('Background: 管理器已销毁')
}
@@ -273,4 +283,4 @@ export function removeBackgroundTimer(id: string) {
export function getBackgroundTimerStatus(id: string) {
return backgroundManager.getTimerStatus(id)
}
}

View File

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

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

View File

@@ -1,21 +1,33 @@
export type SSEConnectionStatus = 'idle' | 'connecting' | 'open' | 'error' | 'closed'
export interface SSEManagerOptions {
backgroundCloseDelay: number
reconnectDelay: number
maxReconnectAttempts: number
reconnectBackoffMultiplier: number
maxReconnectDelay: number
}
type SSEMessageListener = (event: MessageEvent) => void
type SSEStatusListener = (status: SSEConnectionStatus) => void
/**
* SSE连接管理器
* 优化后台SSE连接减少iOS系统杀掉应用的概率
* 统一收口 EventSource 生命周期,避免后台常驻连接和重复重连。
*/
export class SSEManager {
private eventSource: EventSource | null = null
private url: string
private isBackground = false
private isBackground = document.hidden
private reconnectTimer: number | null = null
private backgroundCloseTimer: number | null = null
private listeners: Map<string, (event: MessageEvent) => void> = new Map()
private options: {
backgroundCloseDelay: number
reconnectDelay: number
maxReconnectAttempts: number
}
private listeners: Map<string, SSEMessageListener> = new Map()
private statusListeners: Map<string, SSEStatusListener> = new Map()
private options: SSEManagerOptions
private reconnectAttempts = 0
private isConnecting = false
private isDestroyed = false
private connectionStatus: SSEConnectionStatus = 'idle'
private readonly handleVisibilityChange = () => {
if (document.hidden) {
this.handleBackground()
@@ -27,12 +39,14 @@ export class SSEManager {
this.destroy()
}
constructor(url: string, options: Partial<typeof SSEManager.prototype.options> = {}) {
constructor(url: string, options: Partial<SSEManagerOptions> = {}) {
this.url = url
this.options = {
backgroundCloseDelay: 5000, // 5秒后关闭后台连接
reconnectDelay: 3000, // 3秒后重连
maxReconnectAttempts: 3,
reconnectBackoffMultiplier: 1.5,
maxReconnectDelay: 30_000,
...options,
}
@@ -50,38 +64,44 @@ export class SSEManager {
}
private handleBackground() {
if (this.isDestroyed) return
this.isBackground = true
this.clearReconnectTimer()
// 延迟关闭SSE连接避免频繁切换
if (this.backgroundCloseTimer) {
clearTimeout(this.backgroundCloseTimer)
}
this.clearBackgroundCloseTimer()
this.backgroundCloseTimer = window.setTimeout(() => {
if (this.isBackground && this.eventSource) {
this.eventSource.close()
this.eventSource = null
this.closeCurrentEventSource()
this.setConnectionStatus('closed')
}
}, this.options.backgroundCloseDelay)
}
private handleForeground() {
if (this.isDestroyed) return
this.isBackground = false
// 清除后台关闭定时器
if (this.backgroundCloseTimer) {
clearTimeout(this.backgroundCloseTimer)
this.backgroundCloseTimer = null
}
this.clearBackgroundCloseTimer()
// 只有在有活跃监听器时才重新建立连接
if (this.listeners.size > 0 && (!this.eventSource || this.eventSource.readyState === EventSource.CLOSED)) {
this.reconnectSSE()
this.reconnectSSE(0)
}
}
private reconnectSSE(attemptCount = 0) {
if (attemptCount >= this.options.maxReconnectAttempts) {
if (this.isDestroyed || this.isBackground || this.listeners.size === 0) {
return
}
if (attemptCount > this.options.maxReconnectAttempts) {
this.reconnectAttempts = this.options.maxReconnectAttempts
this.setConnectionStatus('closed')
return
}
@@ -89,40 +109,38 @@ export class SSEManager {
return
}
// 如果没有活跃的监听器,不进行重连
if (this.listeners.size === 0) {
return
}
this.clearReconnectTimer()
this.closeCurrentEventSource()
this.isConnecting = true
this.reconnectAttempts = attemptCount
this.setConnectionStatus('connecting')
try {
this.eventSource = new EventSource(this.url)
const source = new EventSource(this.url)
this.eventSource = source
this.eventSource.onopen = () => {
source.onopen = () => {
if (source !== this.eventSource) return
this.isConnecting = false
this.reconnectAttempts = 0
this.setConnectionStatus('open')
}
this.eventSource.onerror = error => {
source.onerror = () => {
if (source !== this.eventSource) return
this.isConnecting = false
this.setConnectionStatus('error')
if (this.eventSource?.readyState === EventSource.CLOSED) {
// 连接已关闭,尝试重连
if (this.reconnectTimer) {
clearTimeout(this.reconnectTimer)
}
this.reconnectTimer = window.setTimeout(() => {
if (!this.isBackground && this.listeners.size > 0) {
this.reconnectSSE(this.reconnectAttempts + 1)
}
}, this.options.reconnectDelay)
if (source.readyState === EventSource.CLOSED) {
this.closeCurrentEventSource()
this.scheduleReconnect(this.reconnectAttempts + 1)
}
}
this.eventSource.onmessage = event => {
source.onmessage = event => {
if (source !== this.eventSource || this.isDestroyed) return
// 分发消息给所有监听器
this.listeners.forEach((listener, listenerId) => {
try {
@@ -135,29 +153,95 @@ export class SSEManager {
}
} catch (error) {
this.isConnecting = false
this.setConnectionStatus('error')
// 连接创建失败,尝试重连
if (this.reconnectTimer) {
clearTimeout(this.reconnectTimer)
}
this.reconnectTimer = window.setTimeout(() => {
if (!this.isBackground && this.listeners.size > 0) {
this.reconnectSSE(this.reconnectAttempts + 1)
}
}, this.options.reconnectDelay)
this.scheduleReconnect(this.reconnectAttempts + 1)
console.error('SSE: 连接创建失败', error)
}
}
private scheduleReconnect(attemptCount: number) {
if (this.isDestroyed || this.isBackground || this.listeners.size === 0) {
return
}
if (attemptCount > this.options.maxReconnectAttempts) {
this.reconnectAttempts = this.options.maxReconnectAttempts
this.setConnectionStatus('closed')
return
}
this.clearReconnectTimer()
this.reconnectAttempts = attemptCount
// 失败越多等待越久,避免网络波动时短时间内打满连接。
const reconnectDelay = Math.min(
this.options.reconnectDelay * this.options.reconnectBackoffMultiplier ** Math.max(0, attemptCount - 1),
this.options.maxReconnectDelay,
)
this.reconnectTimer = window.setTimeout(() => {
this.reconnectTimer = null
this.reconnectSSE(attemptCount)
}, reconnectDelay)
}
private closeCurrentEventSource() {
if (!this.eventSource) {
return
}
this.eventSource.onopen = null
this.eventSource.onerror = null
this.eventSource.onmessage = null
this.eventSource.close()
this.eventSource = null
this.isConnecting = false
}
private clearReconnectTimer() {
if (!this.reconnectTimer) return
clearTimeout(this.reconnectTimer)
this.reconnectTimer = null
}
private clearBackgroundCloseTimer() {
if (!this.backgroundCloseTimer) return
clearTimeout(this.backgroundCloseTimer)
this.backgroundCloseTimer = null
}
private setConnectionStatus(status: SSEConnectionStatus) {
if (this.connectionStatus === status) return
this.connectionStatus = status
this.statusListeners.forEach((listener, listenerId) => {
try {
listener(status)
} catch (error) {
console.error(`SSE: 状态监听器错误 [${listenerId}]`, error)
}
})
}
/**
* 添加消息监听器
*/
addMessageListener(id: string, listener: (event: MessageEvent) => void) {
addMessageListener(id: string, listener: SSEMessageListener) {
if (this.isDestroyed) return
this.listeners.set(id, listener)
// 如果还没有连接且不在后台,现在建立连接
if (!this.eventSource && !this.isBackground && !this.isConnecting) {
this.reconnectSSE()
if (
!this.isBackground &&
!this.isConnecting &&
(!this.eventSource || this.eventSource.readyState === EventSource.CLOSED)
) {
this.reconnectSSE(0)
}
}
@@ -165,6 +249,8 @@ export class SSEManager {
* 移除消息监听器
*/
removeMessageListener(id: string) {
if (this.isDestroyed) return
this.listeners.delete(id)
// 如果没有监听器了,关闭连接
@@ -184,25 +270,17 @@ export class SSEManager {
* 销毁管理器并清理所有引用
*/
destroy() {
if (this.isDestroyed) return
this.isDestroyed = true
this.resetConnectionState(true)
this.removeVisibilityListener()
}
private resetConnectionState(clearListeners = false) {
if (this.eventSource) {
this.eventSource.close()
this.eventSource = null
}
if (this.reconnectTimer) {
clearTimeout(this.reconnectTimer)
this.reconnectTimer = null
}
if (this.backgroundCloseTimer) {
clearTimeout(this.backgroundCloseTimer)
this.backgroundCloseTimer = null
}
this.closeCurrentEventSource()
this.clearReconnectTimer()
this.clearBackgroundCloseTimer()
if (clearListeners) {
this.listeners.clear()
@@ -210,6 +288,31 @@ export class SSEManager {
this.isConnecting = false
this.reconnectAttempts = 0
this.setConnectionStatus(this.listeners.size > 0 ? 'closed' : 'idle')
if (clearListeners) {
this.statusListeners.clear()
}
}
/**
* 添加连接状态监听器
*/
addStatusListener(id: string, listener: SSEStatusListener, emitCurrent = true) {
if (this.isDestroyed) return
this.statusListeners.set(id, listener)
if (emitCurrent) {
listener(this.connectionStatus)
}
}
/**
* 移除连接状态监听器
*/
removeStatusListener(id: string) {
this.statusListeners.delete(id)
}
/**
@@ -219,6 +322,13 @@ export class SSEManager {
return this.eventSource?.readyState ?? EventSource.CLOSED
}
/**
* 获取内部连接状态
*/
get status(): SSEConnectionStatus {
return this.connectionStatus
}
/**
* 获取连接URL
*/
@@ -230,10 +340,12 @@ export class SSEManager {
* 强制重新连接
*/
forceReconnect() {
if (this.isDestroyed) return
const hasActiveListeners = this.listeners.size > 0
this.close()
if (!this.isBackground && hasActiveListeners) {
this.reconnectSSE()
this.reconnectSSE(0)
}
}

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

View File

@@ -3,11 +3,12 @@ import { useTheme } from 'vuetify'
import { hexToRgb } from '@layouts/utils'
import api from '@/api'
import { useI18n } from 'vue-i18n'
import { useBackgroundOptimization } from '@/composables/useBackgroundOptimization'
import { useBackground } from '@/composables/useBackground'
import { useKeepAliveRefresh } from '@/composables/useKeepAliveRefresh'
// 国际化
const { t } = useI18n()
const { useDataRefresh } = useBackgroundOptimization()
const { useDataRefresh } = useBackground()
// 输入参数
const props = defineProps({
@@ -29,8 +30,6 @@ const variableTheme = controlledComputed(
() => vuetifyTheme.current.value.variables,
)
const chartKey = ref(0)
// 时间序列
const series = ref([
{
@@ -122,19 +121,15 @@ async function loadCpuData() {
}
}
// 使用优化的数据刷新定时器
const { loading } = useDataRefresh(
// 使用数据刷新定时器
const { loading, refresh } = useDataRefresh(
'analytics-cpu',
loadCpuData,
2000, // 2秒间隔
true // 立即执行
)
onActivated(() => {
nextTick(() => {
chartKey.value += 1
})
})
useKeepAliveRefresh(refresh)
</script>
<template>
@@ -148,7 +143,7 @@ onActivated(() => {
<VCardTitle>CPU</VCardTitle>
</VCardItem>
<VCardText>
<VApexChart :key="chartKey" type="line" :options="chartOptions" :series="series" :height="150" />
<VApexChart type="line" :options="chartOptions" :series="series" :height="150" />
<p class="text-center font-weight-medium mb-0">{{ t('dashboard.current') }}{{ current }}%</p>
</VCardText>
</VCard>

View File

@@ -4,11 +4,12 @@ import { hexToRgb } from '@layouts/utils'
import api from '@/api'
import { formatBytes } from '@/@core/utils/formatters'
import { useI18n } from 'vue-i18n'
import { useBackgroundOptimization } from '@/composables/useBackgroundOptimization'
import { useBackground } from '@/composables/useBackground'
import { useKeepAliveRefresh } from '@/composables/useKeepAliveRefresh'
// 国际化
const { t } = useI18n()
const { useDataRefresh } = useBackgroundOptimization()
const { useDataRefresh } = useBackground()
// 输入参数
const props = defineProps({
@@ -30,8 +31,6 @@ const variableTheme = controlledComputed(
() => vuetifyTheme.current.value.variables,
)
const chartKey = ref(0)
// 时间序列
const series = ref([
{
@@ -127,20 +126,15 @@ async function loadMemoryData() {
}
}
// 使用优化的数据刷新定时器
const { loading } = useDataRefresh(
// 使用数据刷新定时器
const { loading, refresh } = useDataRefresh(
'analytics-memory',
loadMemoryData,
3000, // 3秒间隔
true // 立即执行
)
onActivated(() => {
// 使用nextTick确保DOM准备完成后再更新chartKey
nextTick(() => {
chartKey.value += 1
})
})
useKeepAliveRefresh(refresh)
</script>
<template>
@@ -154,7 +148,7 @@ onActivated(() => {
<VCardTitle>{{ t('dashboard.memory') }}</VCardTitle>
</VCardItem>
<VCardText>
<VApexChart :key="chartKey" type="area" :options="chartOptions" :series="series" :height="150" />
<VApexChart type="area" :options="chartOptions" :series="series" :height="150" />
<p class="text-center font-weight-medium mb-0">{{ t('dashboard.current') }}{{ formatBytes(usedMemory) }}</p>
</VCardText>
</VCard>

View File

@@ -3,11 +3,12 @@ import { useTheme } from 'vuetify'
import { hexToRgb } from '@layouts/utils'
import api from '@/api'
import { useI18n } from 'vue-i18n'
import { useBackgroundOptimization } from '@/composables/useBackgroundOptimization'
import { useBackground } from '@/composables/useBackground'
import { useKeepAliveRefresh } from '@/composables/useKeepAliveRefresh'
// 国际化
const { t } = useI18n()
const { useDataRefresh } = useBackgroundOptimization()
const { useDataRefresh } = useBackground()
// 输入参数
const props = defineProps({
@@ -29,8 +30,6 @@ const variableTheme = controlledComputed(
() => vuetifyTheme.current.value.variables,
)
const chartKey = ref(0)
// 时间序列 - 上行和下行流量
const series = ref([
{
@@ -160,19 +159,15 @@ async function getNetworkUsage() {
}
}
// 使用优化的数据刷新定时器
useDataRefresh(
// 使用数据刷新定时器
const { refresh } = useDataRefresh(
'dashboard-network',
getNetworkUsage,
2000, // 2秒间隔
true // 立即执行
)
onActivated(() => {
nextTick(() => {
chartKey.value += 1
})
})
useKeepAliveRefresh(refresh)
</script>
<template>
@@ -186,7 +181,7 @@ onActivated(() => {
<VCardTitle>{{ t('dashboard.network') }}</VCardTitle>
</VCardItem>
<VCardText>
<VApexChart :key="chartKey" type="line" :options="chartOptions" :series="series" :height="150" />
<VApexChart type="line" :options="chartOptions" :series="series" :height="150" />
<div class="d-flex justify-space-between">
<p class="text-center font-weight-medium mb-0">
<span class="text-warning">{{ t('dashboard.upload') }}</span

View File

@@ -3,11 +3,11 @@ import { formatSeconds } from '@/@core/utils/formatters'
import api from '@/api'
import type { Process } from '@/api/types'
import { useI18n } from 'vue-i18n'
import { useBackgroundOptimization } from '@/composables/useBackgroundOptimization'
import { useBackground } from '@/composables/useBackground'
// 国际化
const { t } = useI18n()
const { useDataRefresh } = useBackgroundOptimization()
const { useDataRefresh } = useBackground()
// 表头
const headers = [
@@ -31,7 +31,7 @@ async function loadProcessList() {
}
}
// 使用优化的数据刷新定时器
// 使用数据刷新定时器
useDataRefresh(
'dashboard-processes',
loadProcessList,

View File

@@ -2,11 +2,11 @@
import api from '@/api'
import type { ScheduleInfo } from '@/api/types'
import { useI18n } from 'vue-i18n'
import { useBackgroundOptimization } from '@/composables/useBackgroundOptimization'
import { useBackground } from '@/composables/useBackground'
// 国际化
const { t } = useI18n()
const { useDataRefresh } = useBackgroundOptimization()
const { useDataRefresh } = useBackground()
// 输入参数
const props = defineProps({
@@ -34,7 +34,7 @@ async function loadSchedulerList() {
}
}
// 使用优化的数据刷新定时器
// 使用数据刷新定时器
useDataRefresh(
'dashboard-scheduler',
loadSchedulerList,

View File

@@ -3,11 +3,11 @@ import { formatFileSize } from '@/@core/utils/formatters'
import api from '@/api'
import type { DownloaderInfo } from '@/api/types'
import { useI18n } from 'vue-i18n'
import { useBackgroundOptimization } from '@/composables/useBackgroundOptimization'
import { useBackground } from '@/composables/useBackground'
// 国际化
const { t } = useI18n()
const { useDataRefresh } = useBackgroundOptimization()
const { useDataRefresh } = useBackground()
// 输入参数
const props = defineProps({
@@ -77,7 +77,7 @@ async function loadDownloaderInfo() {
}
}
// 使用优化的数据刷新定时器
// 使用数据刷新定时器
const { loading } = useDataRefresh(
'analytics-speed',
loadDownloaderInfo,

View File

@@ -3,6 +3,7 @@ import { ref, onMounted } from 'vue'
import api from '@/api'
import type { MediaServerConf, MediaServerPlayItem } from '@/api/types'
import PosterCard from '@/components/cards/PosterCard.vue'
import ProgressiveCardGrid from '@/components/misc/ProgressiveCardGrid.vue'
import { useI18n } from 'vue-i18n'
// 国际化
@@ -67,9 +68,18 @@ onActivated(() => {
<VCardTitle>{{ t('dashboard.latest') }} - {{ name }}</VCardTitle>
</VCardItem>
<div class="grid gap-4 grid-media-card mx-3 mb-3" tabindex="0">
<PosterCard v-for="item in data" :key="item.id" :media="item" />
</div>
<ProgressiveCardGrid
:items="data"
:get-item-key="item => item.id || item.link || item.title"
:min-item-width="144"
:item-aspect-ratio="1.5"
class="mx-3 mb-3"
tabindex="0"
>
<template #default="{ item }">
<PosterCard :media="item" />
</template>
</ProgressiveCardGrid>
</VCard>
</template>
</VHover>

View File

@@ -2,6 +2,7 @@
import api from '@/api'
import type { MediaServerConf, MediaServerLibrary } from '@/api/types'
import LibraryCard from '@/components/cards/LibraryCard.vue'
import ProgressiveCardGrid from '@/components/misc/ProgressiveCardGrid.vue'
import { useI18n } from 'vue-i18n'
// 国际化
@@ -69,9 +70,18 @@ onActivated(() => {
</template>
<VCardTitle>{{ t('dashboard.library') }}</VCardTitle>
</VCardItem>
<div class="grid gap-4 grid-backdrop-card mx-3 mb-3" tabindex="0">
<LibraryCard v-for="item in libraryList" :key="item.id" :media="item" height="10rem" />
</div>
<ProgressiveCardGrid
:items="libraryList"
:get-item-key="item => item.id || item.name"
:min-item-width="240"
:estimated-item-height="160"
class="mx-3 mb-3"
tabindex="0"
>
<template #default="{ item }">
<LibraryCard :media="item" height="10rem" />
</template>
</ProgressiveCardGrid>
</VCard>
</template>
</VHover>

View File

@@ -2,6 +2,7 @@
import api from '@/api'
import type { MediaServerConf, MediaServerPlayItem } from '@/api/types'
import BackdropCard from '@/components/cards/BackdropCard.vue'
import ProgressiveCardGrid from '@/components/misc/ProgressiveCardGrid.vue'
import { useI18n } from 'vue-i18n'
// 国际化
@@ -70,9 +71,18 @@ onActivated(() => {
<VCardTitle>{{ t('dashboard.playing') }}</VCardTitle>
</VCardItem>
<div class="grid gap-4 grid-backdrop-card mx-3 mb-3" tabindex="0">
<BackdropCard v-for="item in playingList" :key="item.id" :media="item" height="10rem" />
</div>
<ProgressiveCardGrid
:items="playingList"
:get-item-key="item => item.id || item.link || item.title"
:min-item-width="240"
:estimated-item-height="160"
class="mx-3 mb-3"
tabindex="0"
>
<template #default="{ item }">
<BackdropCard :media="item" height="10rem" />
</template>
</ProgressiveCardGrid>
</VCard>
</template>
</VHover>

View File

@@ -2,7 +2,7 @@
import api from '@/api'
import type { MediaInfo } from '@/api/types'
import MediaCard from '@/components/cards/MediaCard.vue'
import VirtualCardGrid from '@/components/misc/VirtualCardGrid.vue'
import ProgressiveCardGrid from '@/components/misc/ProgressiveCardGrid.vue'
import NoDataFound from '@/components/NoDataFound.vue'
import { useI18n } from 'vue-i18n'
@@ -118,6 +118,7 @@ async function fetchData({ done }: { done: any }) {
page.value++
// 返回加载成功
done('ok')
await nextTick()
}
} else {
// 加载一次
@@ -154,16 +155,17 @@ 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 />
<VirtualCardGrid
<ProgressiveCardGrid
v-if="dataList.length > 0"
:items="dataList"
:item-aspect-ratio="1.5"
:get-item-key="item => item.tmdb_id || item.douban_id || item.bangumi_id || item.media_id || item.title"
tabindex="0"
>
<template #default="{ item }">
<MediaCard :media="item" />
</template>
</VirtualCardGrid>
</ProgressiveCardGrid>
<NoDataFound
v-if="dataList.length === 0 && isRefreshed"
error-code="404"

View File

@@ -2,7 +2,6 @@
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'
@@ -76,22 +75,20 @@ onActivated(() => {
<template>
<div ref="containerRef">
<VirtualSlideView
v-if="componentLoaded"
: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>
</VirtualSlideView>
<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>

View File

@@ -2,7 +2,7 @@
import api from '@/api'
import type { Person } from '@/api/types'
import PersonCard from '@/components/cards/PersonCard.vue'
import VirtualCardGrid from '@/components/misc/VirtualCardGrid.vue'
import ProgressiveCardGrid from '@/components/misc/ProgressiveCardGrid.vue'
import NoDataFound from '@/components/NoDataFound.vue'
import { useI18n } from 'vue-i18n'
@@ -86,6 +86,7 @@ async function fetchData({ done }: { done: any }) {
page.value++
// 返回加载成功
done('ok')
await nextTick()
}
}
} else {
@@ -123,11 +124,17 @@ async function fetchData({ done }: { done: any }) {
<VInfiniteScroll mode="intersect" side="end" :items="dataList" class="overflow-visible px-3" @load="fetchData">
<template #loading />
<template #empty />
<VirtualCardGrid v-if="dataList.length > 0" :items="dataList" :get-item-key="item => item.id" tabindex="0">
<ProgressiveCardGrid
v-if="dataList.length > 0"
:items="dataList"
:item-aspect-ratio="1.5"
:get-item-key="item => item.id"
tabindex="0"
>
<template #default="{ item }">
<PersonCard :person="item" />
</template>
</VirtualCardGrid>
</ProgressiveCardGrid>
<NoDataFound
v-if="dataList.length === 0 && isRefreshed"
error-code="404"

View File

@@ -2,7 +2,6 @@
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'
@@ -64,19 +63,17 @@ onActivated(() => {
<template>
<div ref="containerRef">
<VirtualSlideView v-if="componentLoaded" :items="dataList" :get-item-key="item => item.id">
<VirtualSlideView :items="dataList" :loading="!componentLoaded" :get-item-key="item => item.id">
<template #item="{ item }">
<PersonCard :person="item" width="9rem" />
</template>
</VirtualSlideView>
<SlideView v-else>
<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>

View File

@@ -1,26 +1,30 @@
<script lang="ts" setup>
import draggable from 'vuedraggable'
import { useToast } from 'vue-toastification'
import api from '@/api'
import type { Plugin } from '@/api/types'
import NoDataFound from '@/components/NoDataFound.vue'
import PluginAppCard from '@/components/cards/PluginAppCard.vue'
import { getLogoUrl } from '@/utils/imageUtils'
import { useDisplay } from 'vuetify'
import { isNullOrEmptyObject } from '@/@core/utils'
import { getPluginTabs } from '@/router/i18n-menu'
import PluginMarketSettingDialog from '@/components/dialog/PluginMarketSettingDialog.vue'
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'
import { useKeepAliveRefresh, type KeepAliveRefreshContext } from '@/composables/useKeepAliveRefresh'
// 国际化
const { t } = useI18n()
const route = useRoute()
// 市场卡片、拖拽排序和市场设置只在对应标签/操作中需要,延迟到真正使用时加载。
const Draggable = defineAsyncComponent(() => import('vuedraggable').then(module => module.default))
const PluginAppCard = defineAsyncComponent(() => import('@/components/cards/PluginAppCard.vue'))
const PluginMarketSettingDialog = defineAsyncComponent(() => import('@/components/dialog/PluginMarketSettingDialog.vue'))
// 显示器宽度
const display = useDisplay()
@@ -30,6 +34,7 @@ const { appMode } = usePWA()
// 当前标签
const activeTab = ref('installed')
const sortMode = ref(false)
// 获取插件标签页
const pluginTabs = computed(() => getPluginTabs(t))
@@ -58,6 +63,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 +264,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 +307,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 +338,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 +410,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 +506,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 +549,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)
}
@@ -696,9 +740,13 @@ const filterPlugins = computed(() => {
})
// 获取插件列表数据
async function fetchInstalledPlugins() {
async function fetchInstalledPlugins(context: KeepAliveRefreshContext = {}) {
const showLoading = !context.silent || !isRefreshed.value
try {
loading.value = true
if (showLoading) {
loading.value = true
}
dataList.value = await api.get('plugin/', {
params: {
state: 'installed',
@@ -706,17 +754,24 @@ async function fetchInstalledPlugins() {
})
// 排序
sortPluginOrder()
loading.value = false
isRefreshed.value = true
} catch (error) {
console.error(error)
} finally {
if (showLoading) {
loading.value = false
}
}
}
// 获取未安装插件列表数据
async function fetchUninstalledPlugins(force: boolean = false) {
async function fetchUninstalledPlugins(force: boolean = false, context: KeepAliveRefreshContext = {}) {
const showLoading = !context.silent || !isAppMarketLoaded.value
try {
loading.value = true
if (showLoading) {
loading.value = true
}
uninstalledList.value = await api.get('plugin/', {
params: {
state: 'market',
@@ -733,7 +788,6 @@ async function fetchUninstalledPlugins(force: boolean = false) {
}
}
}
loading.value = false
isRefreshed.value = true
// 更新插件市场列表
// 排除已安装且有更新的,上面的问题在于"本地存在未安装的旧版本插件且云端有更新时"不会在插件市场展示
@@ -745,6 +799,10 @@ async function fetchUninstalledPlugins(force: boolean = false) {
isAppMarketLoaded.value = true
} catch (error) {
console.error(error)
} finally {
if (showLoading) {
loading.value = false
}
}
}
@@ -758,10 +816,10 @@ async function getPluginStatistics() {
}
// 加载所有数据
async function refreshData() {
await fetchInstalledPlugins()
await fetchUninstalledPlugins()
getPluginStatistics()
async function refreshData(context: KeepAliveRefreshContext = {}) {
await fetchInstalledPlugins(context)
await fetchUninstalledPlugins(false, context)
await getPluginStatistics()
// 重新加载文件夹配置,确保分身插件能正确显示在文件夹中
await loadPluginFolders()
}
@@ -830,17 +888,37 @@ function marketSettingDone() {
// 手动刷新插件市场
async function refreshMarket() {
isMarketRefreshing.value = true
const showMarketLoading = !isAppMarketLoaded.value
if (showMarketLoading) {
isMarketRefreshing.value = true
}
try {
await fetchUninstalledPlugins(true)
getPluginStatistics()
await fetchUninstalledPlugins(true, { silent: isAppMarketLoaded.value, source: 'manual' })
await getPluginStatistics()
} catch (error) {
console.error(error)
} finally {
isMarketRefreshing.value = false
if (showMarketLoading) {
isMarketRefreshing.value = false
}
}
}
async function refreshActiveTabData(context: KeepAliveRefreshContext = {}) {
if (sortMode.value || isDraggingSortMode.value) return
if (activeTab.value === 'market') {
await fetchUninstalledPlugins(false, context)
await getPluginStatistics()
return
}
await fetchInstalledPlugins(context)
await getPluginStatistics()
// 文件夹配置可能在其它入口被插件操作改变,重新进入时同步一次。
await loadPluginFolders()
}
function parseLocalRepoPath(repoUrl: string | undefined) {
if (!repoUrl?.startsWith('local://')) return ''
@@ -880,6 +958,11 @@ watch([dataList, installedFilter, hasUpdateFilter, enabledFilter], () => {
function loadMarketMore({ done }: { done: any }) {
// 从 dataList 中获取最前面的 20 个元素
const itemsToMove = sortedUninstalledList.value.splice(0, 20)
if (itemsToMove.length === 0) {
done('empty')
return
}
displayUninstalledList.value.push(...itemsToMove)
done('ok')
}
@@ -899,6 +982,14 @@ onMounted(async () => {
}
})
const { refresh: refreshKeepAliveData } = useKeepAliveRefresh(refreshActiveTabData)
watch(activeTab, (newTab, oldTab) => {
if (!oldTab || newTab === oldTab) return
refreshKeepAliveData({ silent: true, source: 'tab' })
})
function openPluginSearchDialog() {
SearchDialog.value = true
}
@@ -994,12 +1085,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 +1315,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,17 +1572,25 @@ 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">
<!-- 混合排序列表文件夹和插件 -->
<template v-if="!currentFolder">
<!-- 主列表使用draggable进行混合排序 -->
<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 +1601,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)"
@@ -1519,16 +1615,44 @@ function onDragStartPlugin(evt: any) {
@drop-to-folder="(event, folderName) => handleDropToFolder(event, folderName)"
/>
</template>
</draggable>
</Draggable>
<ProgressiveCardGrid
v-else-if="shouldVirtualizeInstalledMainList"
:items="mixedSortList"
:get-item-key="item => `${item.type}:${item.id}`"
:min-item-width="256"
:estimated-item-height="180"
: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
<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 +1663,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="
@@ -1549,7 +1674,31 @@ function onDragStartPlugin(evt: any) {
@remove-from-folder="removeFromFolder"
/>
</template>
</draggable>
</Draggable>
<ProgressiveCardGrid
v-else-if="shouldVirtualizeInstalledFolderList"
:items="draggableFolderPlugins"
:get-item-key="item => item.id"
:min-item-width="256"
:estimated-item-height="180"
>
<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>
@@ -1568,10 +1717,13 @@ function onDragStartPlugin(evt: any) {
<VWindowItem value="market">
<transition name="fade-slide" appear>
<div>
<LoadingBanner v-if="!isAppMarketLoaded || isMarketRefreshing" class="mt-12" />
<LoadingBanner
v-if="!isAppMarketLoaded || (isMarketRefreshing && displayUninstalledList.length === 0)"
class="mt-12"
/>
<!-- 资源列表 -->
<VInfiniteScroll
v-if="isAppMarketLoaded && !isMarketRefreshing"
v-if="isAppMarketLoaded && !(isMarketRefreshing && displayUninstalledList.length === 0)"
mode="intersect"
side="end"
:items="displayUninstalledList"
@@ -1580,14 +1732,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"

View File

@@ -4,17 +4,20 @@ 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'
import { useBackground } from '@/composables/useBackground'
import { useKeepAliveRefresh, type KeepAliveRefreshContext } from '@/composables/useKeepAliveRefresh'
// 国际化
const { t } = useI18n()
const { useDataRefresh } = useBackgroundOptimization()
const { useDataRefresh } = useBackground()
// 定义输入参数
const props = defineProps<{
name: string
active?: boolean
}>()
// 用户 Store
@@ -27,7 +30,7 @@ const dataList = ref<DownloadingInfo[]>([])
const isRefreshed = ref(false)
// 获取订阅列表数据
async function fetchData() {
async function fetchData(_context: KeepAliveRefreshContext = {}) {
try {
dataList.value = await api.get('download/', { params: { name: props.name } })
isRefreshed.value = true
@@ -42,8 +45,9 @@ const loading = ref(false)
// 下拉刷新
function onRefresh() {
loading.value = true
fetchData()
loading.value = false
void fetchData().finally(() => {
loading.value = false
})
}
// 过滤数据,管理员用户显示全部,非管理员只显示自己的订阅
@@ -55,26 +59,35 @@ const filteredDataList = computed(() => {
else return dataList.value.filter(data => data.userid === userName || data.username === userName)
})
// 使用优化的数据刷新定时器
// 使用数据刷新定时器
const { loading: dataLoading } = useDataRefresh(
'downloading-list',
fetchData,
3000, // 3秒间隔
true // 立即执行
false // 初始加载交给 keep-alive 页面自身,避免同时发起两次请求
)
onMounted(fetchData)
useKeepAliveRefresh(fetchData, {
active: computed(() => props.active !== false),
})
</script>
<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"

View File

@@ -14,7 +14,7 @@ import { useI18n } from 'vue-i18n'
import { usePWA } from '@/composables/usePWA'
import { useDynamicButton } from '@/composables/useDynamicButton'
import { useAvailableHeight } from '@/composables/useAvailableHeight'
import { useBackgroundOptimization } from '@/composables/useBackgroundOptimization'
import { useBackground } from '@/composables/useBackground'
import { useGlobalSettingsStore } from '@/stores'
// i18n
@@ -27,7 +27,7 @@ const globalSettingsStore = useGlobalSettingsStore()
const display = useDisplay()
// PWA模式检测
const { appMode } = usePWA()
const { useProgressSSE } = useBackgroundOptimization()
const { useProgressSSE } = useBackground()
// 计算列表可用高度
// componentOffset = VCardItem搜索栏(68) + VDivider(1) + 分页栏(40) + VCard边距(2) = 111
@@ -180,9 +180,10 @@ const pageRange = [
{ title: '100', value: 100 },
{ title: '500', value: 500 },
{ title: '1000', value: 1000 },
{ title: 'All', value: -1 },
]
const pageRangeValues = pageRange.map(item => item.value)
// 数据列表
const dataList = ref<TransferHistory[]>([])
@@ -209,7 +210,7 @@ const groupBy = ref<any>([
])
// 每页条数
const itemsPerPage = ref<number>(ensureNumber(route.query.itemsPerPage, 50))
const itemsPerPage = ref<number>(ensurePageSize(route.query.itemsPerPage, 50))
// 当前页码
const currentPage = ref<number>(ensureNumber(route.query.currentPage, 1))
@@ -226,6 +227,9 @@ const progressValue = ref(0)
// 是否已刷新
const isRefreshed = ref(false)
// 是否已完成首次激活
const hasActivatedOnce = ref(false)
// 删除确认对话框
const deleteConfirmDialog = ref(false)
@@ -270,7 +274,7 @@ const TransferDict: { [key: string]: string } = {
// 分页提示
const pageTip = computed(() => {
const begin = itemsPerPage.value * (currentPage.value - 1) + 1
const end = itemsPerPage.value * currentPage.value === -1 ? 'ALL' : itemsPerPage.value * currentPage.value
const end = Math.min(itemsPerPage.value * currentPage.value, totalItems.value)
return {
begin,
end,
@@ -280,7 +284,7 @@ const pageTip = computed(() => {
// 分页总数
const totalPage = computed(() => {
const total = Math.ceil(totalItems.value / itemsPerPage.value)
return total
return Math.max(1, total)
})
// 切换页签
@@ -302,9 +306,11 @@ watch(
}, 1000),
)
// 获取订阅列表数据
async function fetchData(page = currentPage.value, count = itemsPerPage.value) {
loading.value = true
// 获取历史记录数据keep-alive 重新进入时可静默刷新,避免表格出现重新加载感。
async function fetchData(page = currentPage.value, count = itemsPerPage.value, options: { silent?: boolean } = {}) {
if (!options.silent) {
loading.value = true
}
try {
const result: { [key: string]: any } = await api.get('history/transfer', {
@@ -322,8 +328,11 @@ async function fetchData(page = currentPage.value, count = itemsPerPage.value) {
)
} catch (error) {
console.error(error)
} finally {
if (!options.silent) {
loading.value = false
}
}
loading.value = false
}
// 根据 type 返回不同的图标
@@ -663,6 +672,11 @@ function ensureNumber(value: any, defaultValue: number = 0) {
return value
}
function ensurePageSize(value: any, defaultValue: number = 50) {
const pageSize = ensureNumber(value, defaultValue)
return pageRangeValues.includes(pageSize) ? pageSize : defaultValue
}
// 按标题分组后的选中数量统计,键为标题,值为对应分组的选中数
const selectedCountsGroupedByTitle = computed(() => {
return selected.value.reduce(
@@ -745,6 +759,17 @@ onMounted(() => {
fetchData()
})
onActivated(() => {
if (!hasActivatedOnce.value) {
hasActivatedOnce.value = true
return
}
if (!loading.value) {
fetchData(currentPage.value, itemsPerPage.value, { silent: true })
}
})
onUnmounted(() => {
stopAiRedoProgress()
})

View File

@@ -1,21 +1,30 @@
<!-- eslint-disable sonarjs/no-duplicate-string -->
<script lang="ts" setup>
import { useToast } from 'vue-toastification'
import draggable from 'vuedraggable'
import { VRow } from 'vuetify/lib/components/index.mjs'
import api from '@/api'
import { TransferDirectoryConf, StorageConf } from '@/api/types'
import DirectoryCard from '@/components/cards/DirectoryCard.vue'
import StorageCard from '@/components/cards/StorageCard.vue'
import ProgressDialog from '@/components/dialog/ProgressDialog.vue'
import CategoryEditDialog from '@/components/dialog/CategoryEditDialog.vue'
import { useI18n } from 'vue-i18n'
import { useTheme } from 'vuetify'
import { storageAttributes } from '@/api/constants'
import { useSilentSettingRefresh } from '@/composables/useSilentSettingRefresh'
const { t } = useI18n()
const { global: globalTheme } = useTheme()
const props = defineProps({
active: {
type: Boolean,
default: true,
},
})
// 拖拽排序和分类编辑弹窗按需加载,避免设置框架预加载目录页时带上这些交互依赖。
const Draggable = defineAsyncComponent(() => import('vuedraggable').then(module => module.default))
const ProgressDialog = defineAsyncComponent(() => import('@/components/dialog/ProgressDialog.vue'))
const CategoryEditDialog = defineAsyncComponent(() => import('@/components/dialog/CategoryEditDialog.vue'))
// 所有下载目录
const directories = ref<TransferDirectoryConf[]>([])
@@ -246,12 +255,17 @@ async function saveSystemSettings(value: any) {
}
}
async function loadPageData() {
await Promise.all([loadDirectories(), loadStorages(), loadMediaCategories(), loadSystemSettings()])
}
// 加载数据
onMounted(() => {
loadDirectories()
loadStorages()
loadMediaCategories()
loadSystemSettings()
loadPageData()
})
useSilentSettingRefresh(loadPageData, {
active: computed(() => props.active),
})
</script>
@@ -264,7 +278,7 @@ onMounted(() => {
<VCardSubtitle>{{ t('setting.directory.storageDesc') }}</VCardSubtitle>
</VCardItem>
<VCardText>
<draggable
<Draggable
v-model="storages"
handle=".cursor-move"
item-key="name"
@@ -274,7 +288,7 @@ onMounted(() => {
<template #item="{ element }">
<StorageCard :storage="element" @close="removeStorage(element)" @done="loadStorages" />
</template>
</draggable>
</Draggable>
</VCardText>
<VCardText>
<VForm @submit.prevent="() => {}">
@@ -309,7 +323,7 @@ onMounted(() => {
<VCardSubtitle>{{ t('setting.directory.directoryDesc') }}</VCardSubtitle>
</VCardItem>
<VCardText>
<draggable
<Draggable
v-model="directories"
handle=".cursor-move"
item-key="pri"
@@ -331,7 +345,7 @@ onMounted(() => {
@close="removeDirectory(element)"
/>
</template>
</draggable>
</Draggable>
</VCardText>
<VCardText>
<VForm @submit.prevent="() => {}">

View File

@@ -1,13 +1,12 @@
<script lang="ts" setup>
import { useToast } from 'vue-toastification'
import api from '@/api'
import draggable from 'vuedraggable'
import type { NotificationConf, NotificationSwitchConf } from '@/api/types'
import NotificationChannelCard from '@/components/cards/NotificationChannelCard.vue'
import ProgressDialog from '@/components/dialog/ProgressDialog.vue'
import { useI18n } from 'vue-i18n'
import { notificationSwitchDict } from '@/api/constants'
import { useTheme, useDisplay } from 'vuetify'
import { useSilentSettingRefresh } from '@/composables/useSilentSettingRefresh'
// 显示器宽度
const display = useDisplay()
@@ -15,6 +14,17 @@ const display = useDisplay()
// 国际化
const { t } = useI18n()
const props = defineProps({
active: {
type: Boolean,
default: true,
},
})
// 通知渠道排序和进度弹窗按需加载,避免通知设置 chunk 直接包含拖拽库。
const Draggable = defineAsyncComponent(() => import('vuedraggable').then(module => module.default))
const ProgressDialog = defineAsyncComponent(() => import('@/components/dialog/ProgressDialog.vue'))
// 初始化模板配置字典
const templateConfigs = ref<Record<string, string>>({
organizeSuccess: '{}',
@@ -107,6 +117,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 +139,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 +240,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 +267,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)
}
}
}
// 加载消息类型开关
@@ -254,12 +316,22 @@ function getNotificationSwitchText(type: string | undefined) {
return notificationSwitchDict[type]
}
async function loadPageData() {
await Promise.all([
loadNotificationSetting(),
loadNotificationSwitchs(),
loadNotificationTime(),
loadTemplateConfigs(),
])
}
// 加载数据
onMounted(() => {
loadNotificationSetting()
loadNotificationSwitchs()
loadNotificationTime()
loadTemplateConfigs()
loadPageData()
})
useSilentSettingRefresh(loadPageData, {
active: computed(() => props.active && !editorVisible.value),
})
</script>
@@ -272,7 +344,7 @@ onMounted(() => {
<VCardSubtitle>{{ t('setting.notification.channelsDesc') }}</VCardSubtitle>
</VCardItem>
<VCardText>
<draggable
<Draggable
v-model="notifications"
handle=".cursor-move"
item-key="name"
@@ -287,7 +359,7 @@ onMounted(() => {
@close="removeNotification(element)"
/>
</template>
</draggable>
</Draggable>
</VCardText>
<VCardText>
<VForm @submit.prevent="() => {}">
@@ -299,12 +371,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>

View File

@@ -2,17 +2,27 @@
<script lang="ts" setup>
import { useToast } from 'vue-toastification'
import { copyToClipboard } from '@/@core/utils/navigator'
import draggable from 'vuedraggable'
import api from '@/api'
import { CustomRule, FilterRuleGroup } from '@/api/types'
import CustomerRuleCard from '@/components/cards/CustomRuleCard.vue'
import FilterRuleGroupCard from '@/components/cards/FilterRuleGroupCard.vue'
import ImportCodeDialog from '@/components/dialog/ImportCodeDialog.vue'
import { useI18n } from 'vue-i18n'
import { useSilentSettingRefresh } from '@/composables/useSilentSettingRefresh'
// 国际化
const { t } = useI18n()
const props = defineProps({
active: {
type: Boolean,
default: true,
},
})
// 拖拽库和导入弹窗只在规则编辑交互中需要,拆出设置页入口 chunk。
const Draggable = defineAsyncComponent(() => import('vuedraggable').then(module => module.default))
const ImportCodeDialog = defineAsyncComponent(() => import('@/components/dialog/ImportCodeDialog.vue'))
// 自定义规则列表
const customRules = ref<CustomRule[]>([])
@@ -363,12 +373,17 @@ async function saveTorrentPriority() {
}
}
async function loadPageData() {
await Promise.all([loadMediaCategories(), queryCustomRules(), queryFilterRuleGroups(), queryTorrentPriority()])
}
// 加载数据
onMounted(() => {
loadMediaCategories()
queryCustomRules()
queryFilterRuleGroups()
queryTorrentPriority()
loadPageData()
})
useSilentSettingRefresh(loadPageData, {
active: computed(() => props.active),
})
</script>
@@ -381,7 +396,7 @@ onMounted(() => {
<VCardSubtitle>{{ t('setting.rule.customRulesDesc') }}</VCardSubtitle>
</VCardItem>
<VCardText>
<draggable
<Draggable
v-model="customRules"
handle=".cursor-move"
item-key="name"
@@ -396,7 +411,7 @@ onMounted(() => {
@change="onRuleChange"
/>
</template>
</draggable>
</Draggable>
</VCardText>
<VCardText>
<VForm @submit.prevent="() => {}">
@@ -432,7 +447,7 @@ onMounted(() => {
<VCardSubtitle>{{ t('setting.rule.priorityRuleGroupsDesc') }}</VCardSubtitle>
</VCardItem>
<VCardText>
<draggable
<Draggable
v-model="filterRuleGroups"
handle=".cursor-move"
item-key="name"
@@ -449,7 +464,7 @@ onMounted(() => {
@change="changeRuleGroup"
/>
</template>
</draggable>
</Draggable>
</VCardText>
<VCardText>
<VForm @submit.prevent="() => {}">

View File

@@ -3,10 +3,18 @@ import { useToast } from 'vue-toastification'
import api from '@/api'
import type { FilterRuleGroup, Site } from '@/api/types'
import { useI18n } from 'vue-i18n'
import { useSilentSettingRefresh } from '@/composables/useSilentSettingRefresh'
// 国际化
const { t } = useI18n()
const props = defineProps({
active: {
type: Boolean,
default: true,
},
})
// 提示框
const $toast = useToast()
@@ -176,12 +184,16 @@ async function loadSystemSettings() {
}
}
async function loadPageData() {
await Promise.all([querySites(), queryFilterRuleGroups(), querySelectedSites(), loadSearchSetting(), loadSystemSettings()])
}
onMounted(() => {
querySites()
queryFilterRuleGroups()
querySelectedSites()
loadSearchSetting()
loadSystemSettings()
loadPageData()
})
useSilentSettingRefresh(loadPageData, {
active: computed(() => props.active),
})
</script>

View File

@@ -3,10 +3,18 @@ import { useToast } from 'vue-toastification'
import api from '@/api'
import ProgressDialog from '@/components/dialog/ProgressDialog.vue'
import { useI18n } from 'vue-i18n'
import { useSilentSettingRefresh } from '@/composables/useSilentSettingRefresh'
// 国际化
const { t } = useI18n()
const props = defineProps({
active: {
type: Boolean,
default: true,
},
})
// 提示框
const $toast = useToast()
@@ -37,7 +45,7 @@ const siteSetting = ref<any>({
Site: {
SITEDATA_REFRESH_INTERVAL: 0,
SITE_MESSAGE: false,
BROWSER_EMULATION: 'playwright',
BROWSER_EMULATION: 'cloakbrowser',
FLARESOLVERR_URL: '',
},
})
@@ -65,7 +73,7 @@ const SiteDataRefreshIntervalItems = [
// 站点访问仿真方式
const BrowserEmulationItems = [
{ title: 'Playwright', value: 'playwright' },
{ title: 'CloakBrowser', value: 'cloakbrowser' },
{ title: 'FlareSolverr', value: 'flaresolverr' },
]
@@ -122,6 +130,10 @@ async function saveSiteSetting(value: { [key: string]: any }) {
onMounted(() => {
loadSiteSettings()
})
useSilentSettingRefresh(loadSiteSettings, {
active: computed(() => props.active),
})
</script>
<template>

View File

@@ -4,10 +4,18 @@ import api from '@/api'
import type { FilterRuleGroup, Site } from '@/api/types'
import ProgressDialog from '@/components/dialog/ProgressDialog.vue'
import { useI18n } from 'vue-i18n'
import { useSilentSettingRefresh } from '@/composables/useSilentSettingRefresh'
// 国际化
const { t } = useI18n()
const props = defineProps({
active: {
type: Boolean,
default: true,
},
})
// 提示框
const $toast = useToast()
@@ -184,12 +192,22 @@ async function saveSubscribeSetting() {
}
}
async function loadPageData() {
await Promise.all([
querySites(),
queryFilterRuleGroups(),
querySelectedRssSites(),
querySubscribeRules(),
loadSystemSettings(),
])
}
onMounted(() => {
querySites()
queryFilterRuleGroups()
querySelectedRssSites()
querySubscribeRules()
loadSystemSettings()
loadPageData()
})
useSilentSettingRefresh(loadPageData, {
active: computed(() => props.active),
})
</script>

File diff suppressed because it is too large Load Diff

View File

@@ -116,6 +116,12 @@ const thinkingLevelItems = computed(() => [
{ title: t('setting.system.llmThinkingLevelXhigh'), value: 'xhigh' },
])
const audioProviderItems = computed(() => [
{ title: t('setting.system.audioProviderOpenAiAudio'), value: 'openai' },
{ title: t('setting.system.audioProviderChatAudio'), value: 'openai_chat_audio' },
{ title: t('setting.system.audioProviderMimo'), value: 'mimo' },
])
const providerAuthMethods = computed(() => selectedProvider.value?.oauth_methods || [])
const providerAuthLabel = computed(() => selectedProvider.value?.auth_status?.label || '')
const selectedModelInfo = computed(() => {
@@ -390,20 +396,41 @@ onMounted(async () => {
<VCol cols="12">
<VSwitch
v-model="wizardData.agent.supportAudioInputOutput"
:label="t('setting.system.llmSupportAudioInputOutput')"
:hint="t('setting.system.llmSupportAudioInputOutputHint')"
v-model="wizardData.agent.supportAudioInput"
:label="t('setting.system.llmSupportAudioInput')"
:hint="t('setting.system.llmSupportAudioInputHint')"
persistent-hint
color="primary"
/>
</VCol>
<template v-if="wizardData.agent.supportAudioInputOutput">
<template v-if="wizardData.agent.supportAudioInput">
<VCol cols="12" md="6">
<VSelect
v-model="wizardData.agent.audioInputProvider"
:label="t('setting.system.audioInputProvider')"
:hint="t('setting.system.audioInputProviderHint')"
:items="audioProviderItems"
persistent-hint
prepend-inner-icon="mdi-microphone-message"
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="wizardData.agent.voiceApiKey"
:label="t('setting.system.aiVoiceApiKey')"
:hint="t('setting.system.aiVoiceApiKeyHint')"
v-model="wizardData.agent.audioInputModel"
:label="t('setting.system.audioInputModel')"
:hint="t('setting.system.audioInputModelHint')"
persistent-hint
prepend-inner-icon="mdi-waveform"
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="wizardData.agent.audioInputApiKey"
:label="t('setting.system.audioInputApiKey')"
:hint="t('setting.system.audioInputApiKeyHint')"
persistent-hint
prepend-inner-icon="mdi-key-variant"
type="password"
@@ -412,9 +439,9 @@ onMounted(async () => {
<VCol cols="12" md="6">
<VTextField
v-model="wizardData.agent.voiceBaseUrl"
:label="t('setting.system.aiVoiceBaseUrl')"
:hint="t('setting.system.aiVoiceBaseUrlHint')"
v-model="wizardData.agent.audioInputBaseUrl"
:label="t('setting.system.audioInputBaseUrl')"
:hint="t('setting.system.audioInputBaseUrlHint')"
persistent-hint
prepend-inner-icon="mdi-link-variant"
/>
@@ -422,29 +449,32 @@ onMounted(async () => {
<VCol cols="12" md="6">
<VTextField
v-model="wizardData.agent.voiceSttModel"
:label="t('setting.system.aiVoiceSttModel')"
:hint="t('setting.system.aiVoiceSttModelHint')"
v-model="wizardData.agent.audioInputLanguage"
:label="t('setting.system.audioInputLanguage')"
:hint="t('setting.system.audioInputLanguageHint')"
persistent-hint
prepend-inner-icon="mdi-waveform"
prepend-inner-icon="mdi-translate"
/>
</VCol>
</template>
<VCol cols="12" md="6">
<VTextField
v-model="wizardData.agent.voiceTtsModel"
:label="t('setting.system.aiVoiceTtsModel')"
:hint="t('setting.system.aiVoiceTtsModelHint')"
persistent-hint
prepend-inner-icon="mdi-waveform"
/>
</VCol>
<VCol cols="12">
<VSwitch
v-model="wizardData.agent.supportAudioOutput"
:label="t('setting.system.llmSupportAudioOutput')"
:hint="t('setting.system.llmSupportAudioOutputHint')"
persistent-hint
color="primary"
/>
</VCol>
<template v-if="wizardData.agent.supportAudioOutput">
<VCol cols="12" md="6">
<VTextField
v-model="wizardData.agent.voiceTtsVoice"
:label="t('setting.system.aiVoiceTtsVoice')"
:hint="t('setting.system.aiVoiceTtsVoiceHint')"
<VSelect
v-model="wizardData.agent.audioOutputProvider"
:label="t('setting.system.audioOutputProvider')"
:hint="t('setting.system.audioOutputProviderHint')"
:items="audioProviderItems"
persistent-hint
prepend-inner-icon="mdi-account-voice"
/>
@@ -452,19 +482,50 @@ onMounted(async () => {
<VCol cols="12" md="6">
<VTextField
v-model="wizardData.agent.voiceLanguage"
:label="t('setting.system.aiVoiceLanguage')"
:hint="t('setting.system.aiVoiceLanguageHint')"
v-model="wizardData.agent.audioOutputModel"
:label="t('setting.system.audioOutputModel')"
:hint="t('setting.system.audioOutputModelHint')"
persistent-hint
prepend-inner-icon="mdi-translate"
prepend-inner-icon="mdi-waveform"
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="wizardData.agent.audioOutputApiKey"
:label="t('setting.system.audioOutputApiKey')"
:hint="t('setting.system.audioOutputApiKeyHint')"
persistent-hint
prepend-inner-icon="mdi-key-variant"
type="password"
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="wizardData.agent.audioOutputBaseUrl"
:label="t('setting.system.audioOutputBaseUrl')"
:hint="t('setting.system.audioOutputBaseUrlHint')"
persistent-hint
prepend-inner-icon="mdi-link-variant"
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="wizardData.agent.audioOutputVoice"
:label="t('setting.system.audioOutputVoice')"
:hint="t('setting.system.audioOutputVoiceHint')"
persistent-hint
prepend-inner-icon="mdi-account-voice"
/>
</VCol>
<VCol cols="12">
<VSwitch
v-model="wizardData.agent.voiceReplyWithText"
:label="t('setting.system.aiVoiceReplyWithText')"
:hint="t('setting.system.aiVoiceReplyWithTextHint')"
v-model="wizardData.agent.audioOutputIncludeText"
:label="t('setting.system.audioOutputIncludeText')"
:hint="t('setting.system.audioOutputIncludeTextHint')"
persistent-hint
color="primary"
/>

Some files were not shown because too many files have changed in this diff Show More