Compare commits

..

96 Commits

Author SHA1 Message Date
jxxghp
19a3213be0 fix: 插件页面再次进入时不显示新版本提示 2026-05-25 14:32:20 +08:00
InfinityPacer
f5c8a463fa feat(settings): expose image proxy private ranges (#479) 2026-05-25 14:17:27 +08:00
jxxghp
ff3b5b4232 fix: hide episode sort for movie subscriptions 2026-05-25 11:40:06 +08:00
jxxghp
6da0aae362 feat: add subscription sort options 2026-05-25 11:30:42 +08:00
InfinityPacer
abbce2644a fix(subscribe-card): i18n paused/pending state labels (#478) 2026-05-25 10:47:23 +08:00
InfinityPacer
1c5773444e feat(subscribe-card): style paused/pending states with frosted shimmer badge (#477) 2026-05-25 10:14:08 +08:00
jxxghp
1674f15d7c fix: use null for empty episode group selection 2026-05-25 05:33:26 +08:00
jxxghp
c6981e9955 feat: 添加剧集组功能,支持自动查询和手动输入剧集组编号 2026-05-24 23:33:17 +08:00
jxxghp
96d3426d0c fix: 优化插件市场刷新按钮状态 2026-05-24 20:28:45 +08:00
jxxghp
c88b2abcce fix: 修复 Rust 加速可用性标志并调整插件本地仓库路径和传输线程设置的布局 2026-05-23 21:10:48 +08:00
jxxghp
42fe928155 fix: 调整插件本地仓库路径输入框的位置 2026-05-23 20:55:19 +08:00
jxxghp
4cc455b948 feat: add Rust acceleration configuration option to system settings 2026-05-23 20:41:51 +08:00
jxxghp
bce073ebe0 fix: adjust file manager selection toolbar 2026-05-23 13:30:46 +08:00
jxxghp
c27167097e 更新 package.json 2026-05-23 09:25:12 +08:00
Album
44d23480a3 feat: 支持多文件整理预览与模板智能生成 (#476) 2026-05-23 09:24:49 +08:00
jxxghp
01796b3dc5 更新 package.json 2026-05-22 21:52:03 +08:00
InfinityPacer
dcf0924c73 feat(subscribe-card): render progress with backend completed_episode (#475) 2026-05-22 19:39:07 +08:00
jxxghp
cc8d5cf931 style: lighten setting card accents 2026-05-21 22:05:46 +08:00
jxxghp
6083887675 fix: load registered passkeys on dialog open 2026-05-21 07:06:51 +08:00
jxxghp
beb0506b0c feat: show plugin system version compatibility 2026-05-20 19:56:21 +08:00
InfinityPacer
0f906f791a fix(filter-rule): keep custom rule chip titles in sync with props (#474) 2026-05-20 17:29:46 +08:00
jxxghp
7614696e61 fix: 修复智能推荐按钮初始不可用 2026-05-20 12:36:41 +08:00
jxxghp
4235d3687c feat: add tmdb api key setting 2026-05-20 11:28:29 +08:00
jxxghp
2960e7cfde fix: keep mobile navbar blur above progress dialog 2026-05-20 06:08:15 +08:00
jxxghp
e0ebc35178 fix: 补充媒体详情订阅成功提示 2026-05-20 05:56:08 +08:00
jxxghp
07c9442ac8 fix: 移除集数定位规则启用开关的文字标签
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 13:36:05 +08:00
jxxghp
ccc820e8d2 fix: 优化集数定位规则UI,新增按钮改为绿色,启用改为Switch开关
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 13:31:13 +08:00
jxxghp
68bb568400 fix: 移除集数定位规则删除确认 2026-05-19 08:43:38 +08:00
jxxghp
13cd214e6d fix: 优化集数定位规则响应式布局 2026-05-19 08:29:52 +08:00
jxxghp
311880bcd3 fix: align downloader path mapping fields 2026-05-19 08:13:03 +08:00
jxxghp
088ebbe0bb fix: adjust downloader path mapping UI 2026-05-19 08:10:55 +08:00
Album
de3523056a feat: 增加手动整理集数定位规则配置并支持智能生成集数定位模板 (#473) 2026-05-19 07:20:23 +08:00
jxxghp
cf139a938e style: 移除资源搜索结果副标题 2026-05-18 19:11:54 +08:00
jxxghp
be2f4d0170 style: 调整智能推荐重试图标 2026-05-18 14:24:18 +08:00
jxxghp
79493665c1 style: 优化资源搜索结果智能推荐按钮 2026-05-18 14:17:06 +08:00
jxxghp
106062da82 style: 统一资源搜索结果抬头按钮样式 2026-05-18 13:58:00 +08:00
jxxghp
50e54e943d 更新 resource.vue 2026-05-18 13:47:12 +08:00
jxxghp
6b811f2250 style: 优化资源搜索结果抬头 2026-05-18 13:26:37 +08:00
jxxghp
fa7f2a6c7c fix: adapt notification template dialog height 2026-05-18 12:13:07 +08:00
jxxghp
e362f3cbdd fix: adapt custom css dialog height on small screens 2026-05-18 11:55:10 +08:00
jxxghp
f4c4d7495f refactor: optimize storage card accent colors and clean up unused directory UI logic 2026-05-18 11:25:00 +08:00
jxxghp
5b850d9464 chore: bump version to 2.12.2 2026-05-18 11:21:36 +08:00
jxxghp
d7f74a3a8a feat: implement dynamic accent color extraction and styling for UI cards with standardized shadow removal 2026-05-18 11:20:58 +08:00
jxxghp
91dbf065db feat: update Ace editor themes to follow system color scheme and standardize dialog UI layouts 2026-05-18 10:07:22 +08:00
jxxghp
1759e666ba feat: add search resource pages setting 2026-05-18 09:48:43 +08:00
jxxghp
65230f1ae8 fix: normalize history search empty value 2026-05-17 23:24:32 +08:00
jxxghp
508cf5d08f fix: reset history table loading state 2026-05-17 23:15:03 +08:00
jxxghp
0e9ddc9da2 refactor: update search input to use placeholder and aria-label instead of label 2026-05-17 23:08:34 +08:00
jxxghp
48e6fc4466 refactor: migrate all dialogs to a centralized shared dialog management system using useSharedDialog composable 2026-05-17 22:54:17 +08:00
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
158 changed files with 15922 additions and 8456 deletions

2
.gitignore vendored
View File

@@ -35,3 +35,5 @@ package-lock.json
# iconify dist files
src/@iconify/*.js
public/plugin_icon/**
docs-lock/
.trae/

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,6 +1,6 @@
{
"name": "moviepilot",
"version": "2.11.1",
"version": "2.13.0",
"private": true,
"type": "module",
"bin": "dist/service.js",

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

@@ -22,6 +22,7 @@ code {
%blurry-bg {
position: relative;
isolation: isolate;
box-shadow: 0 1px 3px rgba(0, 0, 0, 4%), 0 1px 2px rgba(0, 0, 0, 2%);
@media (width >= 1280px) and (hover: hover) {

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

@@ -11,9 +11,12 @@ import { preloadImage } from './@core/utils/image'
import { globalLoadingStateManager } from '@/utils/loadingStateManager'
import { addBackgroundTimer, removeBackgroundTimer } from '@/utils/backgroundManager'
import PWAInstallPrompt from '@/components/PWAInstallPrompt.vue'
import SharedDialogHost from '@/components/dialog/SharedDialogHost.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'
@@ -37,6 +40,7 @@ setI18nLanguage(localeValue as SupportedLocale)
// 检查是否登录
const authStore = useAuthStore()
const isLogin = computed(() => authStore.token)
const route = useRoute()
// 全局设置store
const globalSettingsStore = useGlobalSettingsStore()
@@ -48,6 +52,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
@@ -89,9 +119,10 @@ function updateHtmlThemeAttribute(themeName: string) {
// 获取背景图片
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) {
@@ -133,6 +164,42 @@ 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
@@ -155,8 +222,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
@@ -165,22 +230,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()
@@ -189,7 +239,9 @@ async function removeLoadingWithStateCheck() {
animateAndRemoveLoader()
// 检查未读消息
checkAndEmitUnreadMessages()
if (isLogin.value) {
checkAndEmitUnreadMessages()
}
} catch (error) {
// 即使出错也要移除加载界面
globalLoadingStateManager.reset()
@@ -208,7 +260,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)
}
@@ -244,20 +297,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()
})
@@ -266,7 +350,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}`"
@@ -280,6 +368,8 @@ onUnmounted(() => {
<!-- 页面内容 -->
<VApp>
<RouterView />
<!-- 全局共享弹窗入口列表与卡片按需在这里挂载业务弹窗 -->
<SharedDialogHost />
<!-- PWA安装提示 -->
<PWAInstallPrompt />
</VApp>
@@ -331,11 +421,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

@@ -14,6 +14,10 @@ import modeIniUrl from 'ace-builds/src-noconflict/mode-ini?url'
import themeGithubUrl from 'ace-builds/src-noconflict/theme-github?url'
import themeGithubDarkUrl from 'ace-builds/src-noconflict/theme-github_dark?url'
import themeGithubLightDefaultUrl from 'ace-builds/src-noconflict/theme-github_light_default?url'
import themeChromeUrl from 'ace-builds/src-noconflict/theme-chrome?url'
import themeMonokaiUrl from 'ace-builds/src-noconflict/theme-monokai?url'
@@ -533,6 +537,8 @@ ace.config.setModuleUrl('ace/mode/yaml', modeYamlUrl)
ace.config.setModuleUrl('ace/mode/css', modeCssUrl)
ace.config.setModuleUrl('ace/mode/ini', modeIniUrl)
ace.config.setModuleUrl('ace/theme/github', themeGithubUrl)
ace.config.setModuleUrl('ace/theme/github_dark', themeGithubDarkUrl)
ace.config.setModuleUrl('ace/theme/github_light_default', themeGithubLightDefaultUrl)
ace.config.setModuleUrl('ace/theme/chrome', themeChromeUrl)
ace.config.setModuleUrl('ace/theme/monokai', themeMonokaiUrl)
ace.config.setModuleUrl('ace/mode/base', workerBaseUrl)

View File

@@ -46,6 +46,8 @@ export interface Subscribe {
start_episode?: number
// 缺失集数
lack_episode?: number
// 已完成集数(普通订阅 = 已入库集数,洗版订阅 = 起始集前 + [start, total] 范围内 priority==100 命中数)
completed_episode?: number
// 附加信息
note?: string
// 状态N-新建 R-订阅中 P-待定 S-暂停
@@ -58,10 +60,14 @@ export interface Subscribe {
sites: number[]
// 是否洗版数字或者boolean
best_version: any
// 是否只洗全集整包数字或者boolean
best_version_full?: any
// 使用 imdbid 搜索
search_imdbid?: any
// 当前优先级
current_priority: number
// 洗版时已下载剧集的优先级状态
episode_priority?: Record<string, number>
// 保存目录
save_path?: string
// 时间
@@ -644,6 +650,12 @@ export interface Plugin {
has_page?: boolean
// 是否有新版本
has_update?: boolean
// 主系统版本是否兼容
system_version_compatible?: boolean
// 主系统版本兼容提示
system_version_message?: string
// 主系统版本限定范围
system_version?: string
// 是否本地插件
is_local?: boolean
// 插件仓库地址
@@ -1310,7 +1322,63 @@ export interface TransferForm {
// 媒体库类别子目录
library_category_folder?: boolean
// 剧集组编号
episode_group?: string
episode_group?: string | null
// 预览模式
preview?: boolean
}
// 手动整理请求
export interface ManualTransferPayload extends Omit<TransferForm, 'fileitem'> {
// 文件项
fileitem?: FileItem
// 多选文件批量请求
fileitems?: FileItem[]
}
// 手动整理预览统计
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: 6.1 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

@@ -1,14 +1,11 @@
<script lang="ts" setup>
import { CustomRule } from '@/api/types'
import { useToast } from 'vue-toastification'
import type { CustomRule } from '@/api/types'
import filter_svg from '@images/svg/filter.svg'
import { cloneDeep } from 'lodash-es'
import { innerFilterRules } from '@/api/constants'
import { useI18n } from 'vue-i18n'
import { useDisplay } from 'vuetify'
import { openSharedDialog } from '@/composables/useSharedDialog'
import { useCardAccentColor } from '@/composables/useCardAccentColor'
// 显示器宽度
const display = useDisplay()
const CustomRuleInfoDialog = defineAsyncComponent(() => import('@/components/dialog/CustomRuleInfoDialog.vue'))
const { accentRgb, imageRef, updateAccentColor } = useCardAccentColor('#8A8D93')
// 输入参数
const props = defineProps({
@@ -24,206 +21,52 @@ const props = defineProps({
},
})
// 提示框
const $toast = useToast()
const { t } = useI18n()
// 定义触发的自定义事件
const emit = defineEmits(['close', 'change', 'done'])
// 规则详情弹窗
const ruleInfoDialog = ref(false)
// 规则详情
const ruleInfo = ref<CustomRule>({
id: '',
name: '',
include: '',
exclude: '',
size_range: '',
seeders: '',
publish_time: '',
})
// 打开详情弹窗
/** 打开共享自定义规则配置弹窗。 */
function openRuleInfoDialog() {
// 深复制
ruleInfo.value = cloneDeep(props.rule)
ruleInfoDialog.value = true
openSharedDialog(
CustomRuleInfoDialog,
{
rule: props.rule,
rules: props.rules,
},
{
change: (...args: unknown[]) => emit('change', ...args),
done: () => emit('done'),
},
{ closeOn: ['close', 'update:modelValue'] },
)
}
// 保存详情数据
function saveRuleInfo() {
// 有空值
if (!ruleInfo.value.id || !ruleInfo.value.name) {
if (!ruleInfo.value.id && !ruleInfo.value.name) {
$toast.error(t('customRule.error.emptyIdName'))
}
return
}
// 检查ID是否在内置的规则中
if (innerFilterRules.find(option => option.value === ruleInfo.value.id)) {
$toast.error(t('customRule.error.idOccupied'))
return
}
// 检查规则名称是否在内置的规则中
if (innerFilterRules.find(option => option.title === ruleInfo.value.name)) {
$toast.error(t('customRule.error.nameOccupied'))
return
}
// ID已存在
if (ruleInfo.value.id !== props.rule.id && props.rules.find(rule => rule.id === ruleInfo.value.id)) {
$toast.error(t('customRule.error.idExists', { id: ruleInfo.value.id }))
return
}
// 规则名称已存在
if (ruleInfo.value.name !== props.rule.name && props.rules.find(rule => rule.name === ruleInfo.value.name)) {
$toast.error(t('customRule.error.nameExists', { name: ruleInfo.value.name }))
return
}
// 保存数据
ruleInfoDialog.value = false
emit('change', ruleInfo.value, props.rule.id)
emit('done')
}
// 验证规则ID输入
function validateRuleId() {
// 只允许英文和数字,不允许空格
ruleInfo.value.id = ruleInfo.value.id.replace(/[^a-zA-Z0-9]/g, '')
}
// 按钮点击
/** 关闭自定义规则卡片。 */
function onClose() {
emit('close')
}
</script>
<template>
<div>
<VCard variant="tonal" class="app-card-shell" @click="openRuleInfoDialog">
<span class="app-card-top-action absolute top-3 right-12">
<IconBtn @click.stop>
<VIcon class="cursor-move" icon="mdi-drag" />
</IconBtn>
</span>
<VDialogCloseBtn @click="onClose" />
<VCardText class="app-card-summary app-card-summary--double-action app-card-summary--title-subtitle">
<div class="app-card-summary__content">
<h5 class="app-card-summary__title text-h6">{{ props.rule.name }}</h5>
<div class="app-card-summary__subtitle text-body-1">{{ props.rule.id }}</div>
</div>
<div class="app-card-summary__media" aria-hidden="true">
<VImg :src="filter_svg" contain class="app-card-summary__image" />
</div>
</VCardText>
</VCard>
<VDialog
v-if="ruleInfoDialog"
v-model="ruleInfoDialog"
scrollable
max-width="40rem"
:fullscreen="!display.mdAndUp.value"
>
<VCard>
<VCardItem>
<template #prepend>
<VIcon icon="mdi-filter-outline" class="me-2" />
</template>
<VCardTitle>{{ t('customRule.title', { id: props.rule.id }) }}</VCardTitle>
</VCardItem>
<VDialogCloseBtn v-model="ruleInfoDialog" />
<VDivider />
<VCardText>
<VForm>
<VRow>
<VCol cols="12" md="6">
<VTextField
v-model="ruleInfo.id"
:label="t('customRule.field.ruleId')"
:placeholder="t('customRule.placeholder.ruleId')"
:hint="t('customRule.hint.ruleId')"
persistent-hint
active
prepend-inner-icon="mdi-identifier"
@input="validateRuleId"
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="ruleInfo.name"
:label="t('customRule.field.ruleName')"
:placeholder="t('customRule.placeholder.ruleName')"
:hint="t('customRule.hint.ruleName')"
persistent-hint
active
prepend-inner-icon="mdi-label"
/>
</VCol>
<VCol cols="12">
<VTextField
v-model="ruleInfo.include"
:label="t('customRule.field.include')"
:placeholder="t('customRule.placeholder.include')"
:hint="t('customRule.hint.include')"
persistent-hint
active
prepend-inner-icon="mdi-plus-circle"
/>
</VCol>
<VCol cols="12">
<VTextField
v-model="ruleInfo.exclude"
:label="t('customRule.field.exclude')"
:placeholder="t('customRule.placeholder.exclude')"
:hint="t('customRule.hint.exclude')"
persistent-hint
active
prepend-inner-icon="mdi-minus-circle"
/>
</VCol>
<VCol cols="6">
<VTextField
v-model="ruleInfo.size_range"
:label="t('customRule.field.sizeRange')"
:placeholder="t('customRule.placeholder.sizeRange')"
:hint="t('customRule.hint.sizeRange')"
persistent-hint
active
prepend-inner-icon="mdi-harddisk"
/>
</VCol>
<VCol cols="6">
<VTextField
v-model="ruleInfo.seeders"
:label="t('customRule.field.seeders')"
:placeholder="t('customRule.placeholder.seeders')"
:hint="t('customRule.hint.seeders')"
persistent-hint
active
prepend-inner-icon="mdi-account-group"
/>
</VCol>
<VCol cols="6">
<VTextField
v-model="ruleInfo.publish_time"
:label="t('customRule.field.publishTime')"
:placeholder="t('customRule.placeholder.publishTime')"
:hint="t('customRule.hint.publishTime')"
persistent-hint
active
prepend-inner-icon="mdi-calendar-clock"
/>
</VCol>
</VRow>
</VForm>
</VCardText>
<VCardActions class="pt-3">
<VBtn @click="saveRuleInfo" prepend-icon="mdi-content-save" class="px-5">{{
t('customRule.action.confirm')
}}</VBtn>
</VCardActions>
</VCard>
</VDialog>
</div>
<VCard
variant="tonal"
class="app-card-shell app-card-colorful"
:style="{ '--app-card-accent-rgb': accentRgb }"
@click="openRuleInfoDialog"
>
<span class="app-card-top-action absolute top-3 right-12">
<IconBtn @click.stop>
<VIcon class="cursor-move" icon="mdi-drag" />
</IconBtn>
</span>
<VDialogCloseBtn @click="onClose" />
<VCardText class="app-card-summary app-card-summary--double-action app-card-summary--title-subtitle">
<div class="app-card-summary__content">
<h5 class="app-card-summary__title text-h6">{{ props.rule.name }}</h5>
<div class="app-card-summary__subtitle text-body-1">{{ props.rule.id }}</div>
</div>
<div class="app-card-summary__media" aria-hidden="true">
<VImg ref="imageRef" :src="filter_svg" contain class="app-card-summary__image" @load="updateAccentColor" />
</div>
</VCardText>
</VCard>
</template>

View File

@@ -5,8 +5,20 @@ import { nextTick } from 'vue'
import { useI18n } from 'vue-i18n'
import { storageRemoteDict } from '@/api/constants'
const DEFAULT_DIRECTORY_ACCENT_RGB = '145, 85, 253'
const STORAGE_ACCENT_COLOR_MAP = {
local: '#FFB400',
alipan: '#00A7F2',
u115: '#17B26A',
rclone: '#6675FF',
alist: '#12B8D7',
smb: '#3B82F6',
}
// 国际化
const { t } = useI18n()
const downloadAccentRgb = ref(DEFAULT_DIRECTORY_ACCENT_RGB)
const libraryAccentRgb = ref(DEFAULT_DIRECTORY_ACCENT_RGB)
// 输入参数
const props = defineProps({
@@ -63,6 +75,47 @@ const transferSourceItems = computed(() => [
{ title: t('directory.manualTransfer'), value: 'manual' },
])
function hasKnownStorageType(storageType?: string): storageType is keyof typeof STORAGE_ACCENT_COLOR_MAP {
return !!storageType && Object.prototype.hasOwnProperty.call(STORAGE_ACCENT_COLOR_MAP, storageType)
}
function hexToRgbString(hexColor: string) {
const normalizedColor = hexColor.replace('#', '')
const colorValue = Number.parseInt(normalizedColor, 16)
if (Number.isNaN(colorValue) || normalizedColor.length !== 6) return DEFAULT_DIRECTORY_ACCENT_RGB
return `${(colorValue >> 16) & 255}, ${(colorValue >> 8) & 255}, ${colorValue & 255}`
}
function getCustomStoragePaletteColor(storageType?: string) {
const customStorageIndex = Math.max(Number(storageType?.match(/\d+$/)?.[0] ?? 1) - 1, 0)
const customStorageColors = ['#F97316', '#8B5CF6', '#06B6D4', '#84CC16', '#EC4899', '#14B8A6']
return customStorageColors[customStorageIndex % customStorageColors.length]
}
function getStorageAccentColor(storageType?: string) {
if (hasKnownStorageType(storageType)) return STORAGE_ACCENT_COLOR_MAP[storageType]
// 自定义存储没有固定品牌图标,使用离散调色板,保证连续 custom1/custom2 也能明显区分。
return getCustomStoragePaletteColor(storageType)
}
// 目录卡片用下载存储和媒体库存储两端的图标主色生成轻渐变,体现整理链路的两个存储端点。
const directoryAccentStyle = computed(() => ({
'--app-card-accent-rgb': downloadAccentRgb.value,
'--app-card-accent-end-rgb': libraryAccentRgb.value,
}))
function updateDirectoryAccentColors() {
const downloadStorage = props.directory.storage
const libraryStorage = props.directory.library_storage || props.directory.storage
downloadAccentRgb.value = hexToRgbString(getStorageAccentColor(downloadStorage))
libraryAccentRgb.value = hexToRgbString(getStorageAccentColor(libraryStorage))
}
// 监控模式下拉字典
const MonitorModeItems = computed(() => [
{ title: t('directory.performanceMode'), value: 'fast' },
@@ -168,6 +221,15 @@ watch(
{ immediate: true },
)
// 存储类型切换后主动重新提取图标色,避免图片缓存导致 load 事件不触发。
watch(
[() => props.directory.storage, () => props.directory.library_storage],
() => {
updateDirectoryAccentColors()
},
{ immediate: true },
)
// 媒体类别和类型变更非空时将按类型分类和按类别分类置为false
watch(
[() => props.directory.media_type, () => props.directory.media_category],
@@ -195,7 +257,13 @@ watch(
</script>
<template>
<VCard variant="tonal" class="app-card-shell" :width="props.width" :height="props.height">
<VCard
variant="tonal"
class="app-card-shell app-card-colorful"
:style="directoryAccentStyle"
:width="props.width"
:height="props.height"
>
<VDialogCloseBtn @click="onClose" />
<VCardItem>
<VTextField

View File

@@ -1,22 +1,20 @@
<script setup lang="ts">
import api from '@/api'
import { formatFileSize } from '@/@core/utils/formatters'
import { DownloaderConf } from '@/api/types'
import { useToast } from 'vue-toastification'
import type { DownloaderInfo } from '@/api/types'
import type { DownloaderConf, DownloaderInfo } from '@/api/types'
import { getLogoUrl } from '@/utils/imageUtils'
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 { downloaderDict } from '@/api/constants'
import { useBackground } from '@/composables/useBackground'
import { openSharedDialog } from '@/composables/useSharedDialog'
import { useCardAccentColor } from '@/composables/useCardAccentColor'
// 显示器宽度
const display = useDisplay()
const DownloaderInfoDialog = defineAsyncComponent(() => import('@/components/dialog/DownloaderInfoDialog.vue'))
// 获取i18n实例
const { t } = useI18n()
const { useConditionalDataRefresh } = useBackgroundOptimization()
const { useConditionalDataRefresh } = useBackground()
const { accentRgb, imageRef, updateAccentColor } = useCardAccentColor()
// 定义输入
const props = defineProps({
@@ -40,98 +38,18 @@ const props = defineProps({
// 定义触发的自定义事件
const emit = defineEmits(['close', 'done', 'change'])
// 提示框
const $toast = useToast()
// 上传速率
const upload_rate = ref(0)
// 下载速度
const download_rate = ref(0)
// 下载器详情弹窗
const downloaderInfoDialog = ref(false)
// 表单
const downloaderForm = ref()
// 路径前缀选项
const prefixOptions = computed(() => {
return storageAttributes.map(item => ({
title: t(`storage.${item.type}`),
value: item.type,
}))
})
function getStorageType(path: string) {
if (!path) return 'local'
// 查找匹配的存储类型
const storage = storageAttributes.find(s => s.type !== 'local' && path.startsWith(`${s.type}:`))
return storage?.type || 'local'
}
function storage2Prefix(storage: string) {
return storage === 'local' ? '' : storage + ':'
}
// 获取存储路径前后缀
function parseStoragePath(path: string): [prefix: string, suffix: string] {
if (!path) return ['', '']
const storage = getStorageType(path)
const prefix = storage2Prefix(storage)
return [prefix, path.slice(prefix.length)]
}
// 更新存储路径前缀
function updateStoragePrefix(row: PathMappingRow, storage: string) {
const [, currentSuffix] = parseStoragePath(row.storage)
const prefix = storage2Prefix(storage)
row.storage = prefix + currentSuffix
}
// 更新存储路径后缀
function updateStorageSuffix(row: PathMappingRow, suffix: string) {
const [currentPrefix] = parseStoragePath(row.storage)
row.storage = currentPrefix + suffix
}
const pathValidationRules = [
(v: string) => !!v || t('downloader.pathMappingRequired'),
(v: string) => v.startsWith('/') || t('downloader.pathMappingError'),
]
// 下载器详情
const downloaderInfo = ref<DownloaderConf>({
name: '',
type: '',
default: false,
enabled: false,
config: {},
path_mapping: [],
})
// 路径映射行定义
interface PathMappingRow {
id: string
storage: string
download: string
}
// 路径映射行数据
const pathMappingRows = ref<PathMappingRow[]>([])
// 生成随机ID
function generateId() {
return Math.random().toString(36).substring(2, 9)
}
// 下载器是否应该刷新数据的计算属性
const shouldRefresh = computed(() => props.allowRefresh && props.downloader.enabled)
// 调用API查询下载器数据
/** 调用 API 查询下载器实时速率数据。 */
async function loadDownloaderInfo() {
if (!shouldRefresh.value) {
// 当下载器被禁用时,重置速率数据
upload_rate.value = 0
download_rate.value = 0
return
@@ -152,51 +70,20 @@ async function loadDownloaderInfo() {
}
}
// 打开详情弹窗
/** 打开共享下载器配置弹窗。 */
function openDownloaderInfoDialog() {
// 深复制
downloaderInfo.value = cloneDeep(props.downloader)
// 初始化路径映射行数据
pathMappingRows.value = (downloaderInfo.value.path_mapping || []).map(item => ({
id: generateId(),
storage: item[0],
download: item[1],
}))
downloaderInfoDialog.value = true
}
// 保存详情数据
async function saveDownloaderInfo() {
// 表单校验
const { valid } = await downloaderForm.value?.validate()
if (!valid) return
// 同步路径映射数据
downloaderInfo.value.path_mapping = pathMappingRows.value.map(row => [row.storage, row.download])
// 为空不保存,跳出警告框
if (!downloaderInfo.value.name) {
$toast.error(t('downloader.nameRequired'))
return
}
// 重名判断
if (props.downloaders.some(item => item.name === downloaderInfo.value.name && item !== props.downloader)) {
$toast.error(t('downloader.nameDuplicate'))
return
}
// 默认下载器去重
if (downloaderInfo.value.default) {
props.downloaders.forEach(item => {
if (item.default && item !== props.downloader) {
item.default = false
$toast.info(t('downloader.defaultChanged'))
}
})
}
// 执行保存
downloaderInfoDialog.value = false
emit('change', downloaderInfo.value, props.downloader.name)
emit('done')
openSharedDialog(
DownloaderInfoDialog,
{
downloader: props.downloader,
downloaders: props.downloaders,
},
{
change: (...args: unknown[]) => emit('change', ...args),
done: () => emit('done'),
},
{ closeOn: ['close', 'update:modelValue'] },
)
}
// 根据存储类型选择图标
@@ -213,21 +100,7 @@ const getIcon = computed(() => {
}
})
// 添加路径映射
function addPathMapping() {
pathMappingRows.value.push({
id: generateId(),
storage: '',
download: '',
})
}
// 移除路径映射
function removePathMapping(index: number) {
pathMappingRows.value.splice(index, 1)
}
// 按钮点击
/** 关闭下载器卡片。 */
function onClose() {
emit('close')
}
@@ -236,9 +109,9 @@ function onClose() {
const { stop: stopRefresh } = useConditionalDataRefresh(
`downloader-${props.downloader.name}`,
loadDownloaderInfo,
shouldRefresh, // 响应式条件只有当allowRefresh为true且downloader启用时才运行
3000, // 3秒间隔
true, // 立即执行一次
shouldRefresh,
3000,
true,
)
onUnmounted(() => {
@@ -247,379 +120,44 @@ onUnmounted(() => {
</script>
<template>
<div>
<VHover v-slot="hover">
<VCard
v-bind="hover.props"
variant="tonal"
class="app-card-shell"
@click="openDownloaderInfoDialog"
:class="{ 'transition transform-cpu duration-300 -translate-y-1': hover.isHovering }"
>
<VDialogCloseBtn @click="onClose" />
<span class="app-card-top-action absolute top-3 right-12">
<IconBtn @click.stop>
<VIcon class="cursor-move" icon="mdi-drag" />
</IconBtn>
</span>
<VCardText class="app-card-summary app-card-summary--double-action">
<div class="app-card-summary__content">
<div class="app-card-summary__title-row">
<VBadge
v-if="props.downloader.default && props.downloader.enabled"
dot
inline
color="success"
class="me-1"
/>
<span class="app-card-summary__title text-h6">{{ downloader.name }}</span>
</div>
<div
v-if="downloaderDict[downloader.type] && props.downloader.enabled"
class="app-card-summary__meta text-sm"
>
<span class="app-card-summary__meta-item">{{ `${formatFileSize(upload_rate, 1)}/s` }}</span>
<span class="app-card-summary__meta-item">{{ `${formatFileSize(download_rate, 1)}/s` }}</span>
</div>
<div v-else-if="!downloaderDict[downloader.type]" class="app-card-summary__subtitle text-sm">
自定义下载器
</div>
</div>
<div class="app-card-summary__media" aria-hidden="true">
<VImg :src="getIcon" contain class="app-card-summary__image" />
</div>
</VCardText>
</VCard>
</VHover>
<VDialog
v-if="downloaderInfoDialog"
v-model="downloaderInfoDialog"
scrollable
max-width="40rem"
:fullscreen="!display.mdAndUp.value"
<VHover v-slot="hover">
<VCard
v-bind="hover.props"
variant="tonal"
class="app-card-shell app-card-colorful"
:style="{ '--app-card-accent-rgb': accentRgb }"
@click="openDownloaderInfoDialog"
>
<VCard>
<VCardItem class="py-2">
<template #prepend>
<VIcon icon="mdi-download" class="me-2" />
</template>
<VCardTitle>{{ t('common.config') }}</VCardTitle>
<VCardSubtitle>{{ props.downloader.name }}</VCardSubtitle>
</VCardItem>
<VDialogCloseBtn v-model="downloaderInfoDialog" />
<VDivider />
<VCardText>
<VForm ref="downloaderForm">
<VRow>
<VCol cols="12" md="6">
<VSwitch v-model="downloaderInfo.enabled" :label="t('downloader.enabled')" />
</VCol>
<VCol cols="12" md="6">
<VSwitch
v-model="downloaderInfo.default"
:label="t('downloader.default')"
:disabled="!downloaderInfo.enabled"
/>
</VCol>
</VRow>
<VRow v-if="downloaderInfo.type == 'qbittorrent'">
<VCol cols="12" md="6">
<VTextField
v-model="downloaderInfo.name"
:label="t('downloader.name')"
:placeholder="t('downloader.nameRequired')"
:hint="t('downloader.name')"
persistent-hint
active
prepend-inner-icon="mdi-label"
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="downloaderInfo.config.host"
:label="t('downloader.host')"
placeholder="http(s)://ip:port"
:hint="t('downloader.host')"
persistent-hint
active
prepend-inner-icon="mdi-server"
/>
</VCol>
<VCol cols="12">
<VTextField
v-model="downloaderInfo.config.apikey"
type="password"
:label="t('downloader.apiKey')"
:hint="t('downloader.qbittorrentApiKeyHint')"
persistent-hint
active
prepend-inner-icon="mdi-key-variant"
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="downloaderInfo.config.username"
:label="t('downloader.username')"
:hint="t('downloader.username')"
:disabled="!!downloaderInfo.config.apikey"
persistent-hint
active
prepend-inner-icon="mdi-account"
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="downloaderInfo.config.password"
type="password"
:label="t('downloader.password')"
:hint="t('downloader.password')"
:disabled="!!downloaderInfo.config.apikey"
persistent-hint
active
prepend-inner-icon="mdi-lock"
/>
</VCol>
<VCol cols="12" md="6">
<VSwitch
v-model="downloaderInfo.config.category"
:label="t('downloader.category')"
:hint="t('downloader.category')"
persistent-hint
active
/>
</VCol>
<VCol cols="12" md="6">
<VSwitch
v-model="downloaderInfo.config.sequentail"
:label="t('downloader.sequentail')"
:hint="t('downloader.sequentail')"
persistent-hint
active
/>
</VCol>
<VCol cols="12" md="6">
<VSwitch
v-model="downloaderInfo.config.force_resume"
:label="t('downloader.force_resume')"
:hint="t('downloader.force_resume')"
persistent-hint
active
/>
</VCol>
<VCol cols="12" md="6">
<VSwitch
v-model="downloaderInfo.config.first_last_piece"
:label="t('downloader.first_last_piece')"
:hint="t('downloader.first_last_piece')"
persistent-hint
active
/>
</VCol>
</VRow>
<VRow v-else-if="downloaderInfo.type == 'transmission'">
<VCol cols="12" md="6">
<VTextField
v-model="downloaderInfo.name"
:label="t('downloader.name')"
:placeholder="t('downloader.nameRequired')"
:hint="t('downloader.name')"
persistent-hint
active
prepend-inner-icon="mdi-label"
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="downloaderInfo.config.host"
:label="t('downloader.host')"
placeholder="http(s)://ip:port"
:hint="t('downloader.host')"
persistent-hint
active
prepend-inner-icon="mdi-server"
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="downloaderInfo.config.username"
:label="t('downloader.username')"
:hint="t('downloader.username')"
persistent-hint
active
prepend-inner-icon="mdi-account"
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="downloaderInfo.config.password"
type="password"
:label="t('downloader.password')"
:hint="t('downloader.password')"
persistent-hint
active
prepend-inner-icon="mdi-lock"
/>
</VCol>
</VRow>
<VRow v-else-if="downloaderInfo.type == 'rtorrent'">
<VCol cols="12" md="6">
<VTextField
v-model="downloaderInfo.name"
:label="t('downloader.name')"
:placeholder="t('downloader.nameRequired')"
:hint="t('downloader.name')"
persistent-hint
active
prepend-inner-icon="mdi-label"
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="downloaderInfo.config.host"
:label="t('downloader.host')"
placeholder="http(s)://ip:port/RPC2"
:hint="t('downloader.rtorrentHostHint')"
persistent-hint
active
prepend-inner-icon="mdi-server"
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="downloaderInfo.config.username"
:label="t('downloader.username')"
:hint="t('downloader.username')"
persistent-hint
active
prepend-inner-icon="mdi-account"
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="downloaderInfo.config.password"
type="password"
:label="t('downloader.password')"
:hint="t('downloader.password')"
persistent-hint
active
prepend-inner-icon="mdi-lock"
/>
</VCol>
</VRow>
<VRow v-else>
<VCol cols="12" md="6">
<VTextField
v-model="downloaderInfo.type"
:label="t('downloader.type')"
:hint="t('downloader.customTypeHint')"
persistent-hint
active
prepend-inner-icon="mdi-cog"
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="downloaderInfo.name"
:label="t('downloader.name')"
:hint="t('downloader.nameRequired')"
persistent-hint
active
prepend-inner-icon="mdi-label"
/>
</VCol>
</VRow>
<VRow>
<VCol cols="12">
<VDivider class="my-2">
<span class="text-body-1 font-weight-medium">{{ t('downloader.pathMapping') }}</span>
</VDivider>
<div v-if="pathMappingRows.length === 0" class="text-center py-2">
<VIcon icon="mdi-folder-network" size="48" class="text-disabled mb-1" />
<div class="text-body-2 text-disabled">{{ t('common.noData') }}</div>
</div>
<VCard v-for="(row, index) in pathMappingRows" :key="row.id" variant="outlined" class="my-2">
<VCardText class="pa-3">
<VRow align="center" no-gutters>
<VCol cols="12" class="mb-2">
<div class="d-flex align-center mb-1">
<VIcon icon="mdi-folder-outline" size="18" class="me-1 text-primary" />
<span class="text-caption text-medium-emphasis">{{ t('downloader.storagePath') }}</span>
</div>
<VRow no-gutters>
<VCol cols="12" sm="4" class="pe-2">
<VSelect
:model-value="getStorageType(row.storage)"
:items="prefixOptions"
density="compact"
variant="outlined"
hide-details
@update:model-value="v => updateStoragePrefix(row, v)"
/>
</VCol>
<VCol cols="12" sm="8">
<VTextField
:model-value="parseStoragePath(row.storage)[1]"
:placeholder="'/path/to/storage'"
density="compact"
variant="outlined"
hide-details="auto"
:rules="pathValidationRules"
@update:model-value="v => updateStorageSuffix(row, v)"
/>
</VCol>
</VRow>
</VCol>
<VCol cols="12" class="mb-1">
<div class="d-flex align-center justify-center my-1">
<VIcon icon="mdi-arrow-down" size="18" class="text-medium-emphasis" />
</div>
<div class="d-flex align-center mb-1">
<VIcon icon="mdi-download-outline" size="18" class="me-1 text-success" />
<span class="text-caption text-medium-emphasis">{{ t('downloader.downloadPath') }}</span>
</div>
<VTextField
v-model="row.download"
:placeholder="'/path/to/download'"
density="compact"
variant="outlined"
hide-details="auto"
:rules="pathValidationRules"
/>
</VCol>
<VCol cols="12" class="d-flex justify-end pt-1">
<IconBtn variant="text" color="error" size="small" @click="removePathMapping(index)">
<VIcon icon="mdi-delete-outline" />
</IconBtn>
</VCol>
</VRow>
</VCardText>
</VCard>
<VBtn
variant="tonal"
color="primary"
prepend-icon="mdi-plus-circle-outline"
@click="addPathMapping"
class="mt-1"
size="small"
>
{{ t('common.add') }} {{ t('downloader.pathMapping') }}
</VBtn>
</VCol>
</VRow>
</VForm>
</VCardText>
<VCardActions class="pt-3">
<VBtn @click="saveDownloaderInfo" prepend-icon="mdi-content-save" class="px-5">
{{ t('common.save') }}
</VBtn>
</VCardActions>
</VCard>
</VDialog>
</div>
<VDialogCloseBtn @click="onClose" />
<span class="app-card-top-action absolute top-3 right-12">
<IconBtn @click.stop>
<VIcon class="cursor-move" icon="mdi-drag" />
</IconBtn>
</span>
<VCardText class="app-card-summary app-card-summary--double-action">
<div class="app-card-summary__content">
<div class="app-card-summary__title-row">
<VBadge
v-if="props.downloader.default && props.downloader.enabled"
dot
inline
color="success"
class="me-1"
/>
<span class="app-card-summary__title text-h6">{{ downloader.name }}</span>
</div>
<div v-if="downloaderDict[downloader.type] && props.downloader.enabled" class="app-card-summary__meta text-sm">
<span class="app-card-summary__meta-item">{{ `${formatFileSize(upload_rate, 1)}/s` }}</span>
<span class="app-card-summary__meta-item">{{ `${formatFileSize(download_rate, 1)}/s` }}</span>
</div>
<div v-else-if="!downloaderDict[downloader.type]" class="app-card-summary__subtitle text-sm">
{{ t('setting.system.custom') }}
</div>
</div>
<div class="app-card-summary__media" aria-hidden="true">
<VImg ref="imageRef" :src="getIcon" contain class="app-card-summary__image" @load="updateAccentColor" />
</div>
</VCardText>
</VCard>
</VHover>
</template>

View File

@@ -28,19 +28,18 @@ function filtersChanged(value: string[]) {
}
// 过滤规则下拉框
const selectFilterOptions = ref<{ [key: string]: string }[]>([])
onMounted(() => {
selectFilterOptions.value = cloneDeep(innerFilterRules)
if (props.custom_rules) {
console.log(props.custom_rules)
props.custom_rules.map(rule => {
selectFilterOptions.value.push({
title: rule.name,
value: rule.id,
})
// 同时包含内置规则与用户自定义规则;使用 computed 而非 onMounted 一次性赋值,
// 是为了在父组件异步加载完 custom_rules 或后续新增/删除规则时,
// 选项与已选 chip 的显示名title能跟随刷新避免回退到原始 ID如 "zhong")。
const selectFilterOptions = computed<{ [key: string]: string }[]>(() => {
const options = cloneDeep(innerFilterRules)
props.custom_rules?.forEach(rule => {
options.push({
title: rule.name,
value: rule.id,
})
}
})
return options
})
</script>

View File

@@ -1,20 +1,15 @@
<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 type { CustomRule, FilterRuleGroup } from '@/api/types'
import filter_group_svg from '@images/svg/filter-group.svg'
import { cloneDeep } from 'lodash-es'
import { useI18n } from 'vue-i18n'
import { useDisplay } from 'vuetify'
import { openSharedDialog } from '@/composables/useSharedDialog'
import { useCardAccentColor } from '@/composables/useCardAccentColor'
// 显示器宽度
const display = useDisplay()
const FilterRuleGroupInfoDialog = defineAsyncComponent(() => import('@/components/dialog/FilterRuleGroupInfoDialog.vue'))
// 获取i18n实例
const { t } = useI18n()
const { accentRgb, imageRef, updateAccentColor } = useCardAccentColor('#8A8D93')
// 输入参数
const props = defineProps({
@@ -37,287 +32,57 @@ const props = defineProps({
custom_rules: Array as PropType<CustomRule[]>,
})
// 规则卡片类型
interface FilterCard {
// 优先级
pri: string
// 已选规则
rules: string[]
}
// 提示框
const $toast = useToast()
// 定义触发的自定义事件
const emit = defineEmits(['close', 'change', 'done'])
// 规则详情弹窗
const groupInfoDialog = ref(false)
// 规则详情
const groupInfo = ref<FilterRuleGroup>({
name: props.group?.name ?? '',
rule_string: props.group?.rule_string ?? '',
media_type: props.group?.media_type ?? '',
category: props.group?.category ?? '',
})
// 媒体类型字典
const mediaTypeItems = [
{ title: t('common.all'), value: '' },
{ title: t('mediaType.movie'), value: '电影' },
{ title: t('mediaType.tv'), value: '电视剧' },
]
// 根据选中的媒体类型,获取对应的媒体类别
const getCategories = computed(() => {
const default_value = [{ title: t('common.all'), value: '' }]
if (!props.categories || !groupInfo.value.media_type || !props.categories[groupInfo.value.media_type]) {
return default_value
}
return default_value.concat(props.categories[groupInfo.value.media_type] || [])
})
// 规则组规则卡片列表
const filterRuleCards = ref<FilterCard[]>([])
// 导入代码弹窗
const importCodeDialog = ref(false)
// 导入代码类型
const importCodeType = ref('')
// 更新规则卡片的值
function updateFilterCardValue(pri: string, rules: string[]) {
const card = filterRuleCards.value.find(card => card.pri === pri)
if (card && Array.isArray(rules)) card.rules = rules
/** 打开共享过滤规则组配置弹窗。 */
function openGroupInfoDialog() {
openSharedDialog(
FilterRuleGroupInfoDialog,
{
group: props.group,
groups: props.groups,
categories: props.categories,
custom_rules: props.custom_rules,
},
{
change: (...args: unknown[]) => emit('change', ...args),
done: () => emit('done'),
},
{ closeOn: ['close', 'update:modelValue'] },
)
}
// 移除卡片
function filterCardClose(pri: string) {
filterRuleCards.value = filterRuleCards.value
.filter(card => card.pri !== pri)
.map((card, index) => {
card.pri = (index + 1).toString()
return card
})
}
// 分享规则
async function shareRules() {
if (filterRuleCards.value.length === 0) return
const value = filterRuleCards.value
.filter(card => Array.isArray(card.rules) && card.rules.length > 0)
.map(card => card.rules.join('&'))
.join('>')
try {
let success
success = copyToClipboard(value)
if (await success) $toast.success(t('filterRule.shareSuccess'))
else $toast.error(t('filterRule.shareFailed'))
} catch (error) {
$toast.error(t('filterRule.shareFailed'))
console.error(error)
}
}
// 导入规则
async function importRules(ruleType: string) {
importCodeType.value = ruleType
importCodeDialog.value = true
}
// 保存导入的代码,直接覆盖原有值
function saveCodeString(type: string, code: any) {
try {
code = code.value
if (type === 'priority') {
// 解析值
if (!code) return
// 首尾增加空格
if (!code.startsWith(' ')) code = ` ${code}`
if (!code.endsWith(' ')) code = `${code} `
const groups = code.split('>')
filterRuleCards.value = groups.map((group: string, index: number) => ({
pri: (index + 1).toString(),
rules: group.split('&').filter(rule => rule),
}))
}
} catch (error) {
$toast.error(t('filterRule.importFailed'))
console.error(error)
}
}
// 增加卡片
function addFilterCard() {
const pri = (filterRuleCards.value.length + 1).toString()
const newCard: FilterCard = { pri, rules: [] }
filterRuleCards.value.push(newCard)
}
// 根据列表的拖动顺序更新优先级
function dragOrderEnd() {
filterRuleCards.value.forEach((card, index) => {
card.pri = (index + 1).toString()
})
}
// 打开详情弹窗
function opengroupInfoDialog() {
groupInfo.value = cloneDeep(props.group)
if (props.group.rule_string) {
filterRuleCards.value = props.group.rule_string.split('>').map((group: string, index: number) => ({
pri: (index + 1).toString(),
rules: group.split('&').filter(rule => rule),
}))
}
groupInfoDialog.value = true
}
// 保存详情数据
function saveGroupInfo() {
if (!groupInfo.value.name.trim()) {
$toast.error(t('filterRule.nameRequired'))
return
}
if (props.groups.some(item => item.name === groupInfo.value.name && item !== props.group)) {
$toast.error(t('filterRule.nameDuplicate'))
return
}
groupInfoDialog.value = false
groupInfo.value.rule_string = filterRuleCards.value
.filter(card => Array.isArray(card.rules) && card.rules.length > 0)
.map(card => card.rules.join('&'))
.join('>')
emit('change', groupInfo.value, props.group.name)
emit('done')
}
// 按钮点击
/** 关闭过滤规则组卡片。 */
function onClose() {
emit('close')
}
</script>
<template>
<div>
<VCard variant="tonal" class="app-card-shell" @click="opengroupInfoDialog">
<span class="app-card-top-action absolute top-3 right-12">
<IconBtn @click.stop>
<VIcon class="cursor-move" icon="mdi-drag" />
</IconBtn>
</span>
<VDialogCloseBtn @click="onClose" />
<VCardText class="app-card-summary app-card-summary--double-action app-card-summary--title-subtitle">
<div class="app-card-summary__content">
<h5 class="app-card-summary__title text-h6">{{ props.group.name }}</h5>
<div class="app-card-summary__subtitle text-body-1">
<span v-if="!props.group.category">{{ props.group.media_type || t('common.all') }}</span>
<span v-else>{{ props.group.category }}</span>
</div>
<VCard
variant="tonal"
class="app-card-shell app-card-colorful"
:style="{ '--app-card-accent-rgb': accentRgb }"
@click="openGroupInfoDialog"
>
<span class="app-card-top-action absolute top-3 right-12">
<IconBtn @click.stop>
<VIcon class="cursor-move" icon="mdi-drag" />
</IconBtn>
</span>
<VDialogCloseBtn @click="onClose" />
<VCardText class="app-card-summary app-card-summary--double-action app-card-summary--title-subtitle">
<div class="app-card-summary__content">
<h5 class="app-card-summary__title text-h6">{{ props.group.name }}</h5>
<div class="app-card-summary__subtitle text-body-1">
<span v-if="!props.group.category">{{ props.group.media_type || t('common.all') }}</span>
<span v-else>{{ props.group.category }}</span>
</div>
<div class="app-card-summary__media" aria-hidden="true">
<VImg :src="filter_group_svg" contain class="app-card-summary__image" />
</div>
</VCardText>
</VCard>
<VDialog
v-if="groupInfoDialog"
v-model="groupInfoDialog"
scrollable
max-width="80rem"
:fullscreen="!display.mdAndUp.value"
>
<VCard :title="`${props.group.name} - ${t('filterRule.title')}`">
<VDialogCloseBtn v-model="groupInfoDialog" />
<VDivider />
<VCardItem class="pt-1">
<VRow class="mt-1">
<VCol cols="12" md="6">
<VTextField
v-model="groupInfo.name"
:label="t('filterRule.groupName')"
:placeholder="t('filterRule.nameRequired')"
:hint="t('filterRule.groupName')"
persistent-hint
active
prepend-inner-icon="mdi-label"
/>
</VCol>
<VCol cols="6" md="3">
<VAutocomplete
v-model="groupInfo.media_type"
:label="t('filterRule.mediaType')"
:items="mediaTypeItems"
:hint="t('filterRule.mediaType')"
persistent-hint
active
prepend-inner-icon="mdi-movie-open"
/>
</VCol>
<VCol cols="6" md="3">
<VAutocomplete
v-model="groupInfo.category"
:items="getCategories"
:label="t('filterRule.category')"
:hint="t('filterRule.category')"
persistent-hint
active
prepend-inner-icon="mdi-folder-open"
/>
</VCol>
</VRow>
</VCardItem>
<VCardText>
<draggable
v-model="filterRuleCards"
handle=".cursor-move"
item-key="pri"
tag="div"
@end="dragOrderEnd"
:component-data="{ 'class': 'grid gap-3 grid-filterrule-card' }"
>
<template #item="{ element }">
<FilterRuleCard
:pri="element.pri"
:maxpri="filterRuleCards.length.toString()"
:rules="element.rules"
:custom_rules="props.custom_rules"
@changed="updateFilterCardValue"
@close="filterCardClose(element.pri)"
/>
</template>
</draggable>
<div class="text-center" v-if="filterRuleCards.length == 0">{{ t('filterRule.add') }}</div>
</VCardText>
<VCardActions class="pt-3">
<VBtn color="primary" @click="addFilterCard">
<VIcon icon="mdi-plus" />
</VBtn>
<VBtn color="success" @click="importRules('priority')">
<VIcon icon="mdi-import" />
</VBtn>
<VBtn color="info" @click="shareRules">
<VIcon icon="mdi-share" />
</VBtn>
<VSpacer />
<VBtn @click="saveGroupInfo" prepend-icon="mdi-content-save" class="px-5">
{{ t('common.save') }}
</VBtn>
</VCardActions>
</VCard>
</VDialog>
<ImportCodeDialog
v-if="importCodeDialog"
v-model="importCodeDialog"
:title="t('filterRule.import')"
:dataType="importCodeType"
@close="importCodeDialog = false"
@save="saveCodeString"
/>
</div>
</div>
<div class="app-card-summary__media" aria-hidden="true">
<VImg ref="imageRef" :src="filter_group_svg" contain class="app-card-summary__image" @load="updateAccentColor" />
</div>
</VCardText>
</VCard>
</template>

View File

@@ -8,12 +8,10 @@ import { doneNProgress, startNProgress } from '@/api/nprogress'
import type { MediaInfo, Subscribe, MediaSeason, Site } from '@/api/types'
import router from '@/router'
import { useUserStore, useGlobalSettingsStore } from '@/stores'
import SubscribeEditDialog from '../dialog/SubscribeEditDialog.vue'
import SearchSiteDialog from '@/components/dialog/SearchSiteDialog.vue'
import SubscribeSeasonDialog from '../dialog/SubscribeSeasonDialog.vue'
import { useI18n } from 'vue-i18n'
import { mediaTypeDict } from '@/api/constants'
import { hasPermission } from '@/utils/permission'
import { openSharedDialog } from '@/composables/useSharedDialog'
import {
getCachedMediaExistsStatus,
getCachedMediaSubscribeStatus,
@@ -21,6 +19,10 @@ import {
setCachedMediaSubscribeStatus,
} from '@/utils/mediaStatusCache'
const SearchSiteDialog = defineAsyncComponent(() => import('@/components/dialog/SearchSiteDialog.vue'))
const SubscribeEditDialog = defineAsyncComponent(() => import('../dialog/SubscribeEditDialog.vue'))
const SubscribeSeasonDialog = defineAsyncComponent(() => import('../dialog/SubscribeSeasonDialog.vue'))
// 国际化
const { t } = useI18n()
@@ -59,15 +61,6 @@ const isSubscribed = ref(false)
// 本地存在状态
const isExists = ref(false)
// 订阅季弹窗
const subscribeSeasonDialog = ref(false)
// 订阅编辑弹窗
const subscribeEditDialog = ref(false)
// 订阅ID
const subscribeId = ref<number>()
// 选中的订阅季
const seasonsSelected = ref<MediaSeason[]>([])
@@ -93,12 +86,48 @@ const selectedSites = ref<number[]>([])
// 搜索菜单显示状态
const searchMenuShow = ref(false)
// 选择站点对话框
const chooseSiteDialog = ref(false)
// 选择的剧集组
const episodeGroup = ref('')
// 打开订阅季选择弹窗,避免每个媒体卡片都持有弹窗实例。
function openSubscribeSeasonDialog() {
openSharedDialog(
SubscribeSeasonDialog,
{ media: props.media },
{
subscribe: subscribeSeasons,
},
{ closeOn: ['close', 'subscribe'] },
)
}
// 打开订阅编辑弹窗,保存、关闭或删除时释放共享弹窗实例。
function openSubscribeEditDialog(subid: number) {
openSharedDialog(
SubscribeEditDialog,
{ subid },
{
remove: onRemoveSubscribe,
},
{ closeOn: ['close', 'save', 'remove'] },
)
}
// 打开站点选择弹窗,并把选择结果交回当前媒体卡片继续搜索。
function openSearchSiteDialog() {
openSharedDialog(
SearchSiteDialog,
{
sites: allSites.value,
selected: selectedSites.value,
},
{
search: searchSites,
},
{ closeOn: ['close', 'search'] },
)
}
// 查询所有站点
async function querySites() {
try {
@@ -157,7 +186,7 @@ async function handleAddSubscribe() {
if (props.media?.type === '电视剧') {
// 弹出季选择列表,支持多选
seasonsSelected.value = []
subscribeSeasonDialog.value = true
openSubscribeSeasonDialog()
} else {
// 电影
addSubscribe()
@@ -199,8 +228,7 @@ async function addSubscribe(season: number | null = null, best_version: number =
if (result.success && seasonsSelected.value.length <= 1) {
const show_edit_dialog = await queryDefaultSubscribeConfig()
if (show_edit_dialog) {
subscribeId.value = result.data.id
subscribeEditDialog.value = true
openSubscribeEditDialog(result.data.id)
}
}
} catch (error) {
@@ -330,7 +358,6 @@ function handleSubscribe() {
// 订阅多季
function subscribeSeasons(seasons: MediaSeason[], seasonNoExists: { [key: number]: number }, groudId: string) {
subscribeSeasonDialog.value = false
episodeGroup.value = groudId
seasonsSelected.value = seasons || []
seasonsSelected.value.forEach(season => {
@@ -375,7 +402,7 @@ async function clickSearch() {
await querySelectedSites()
}
if (allSites.value?.length > 0) {
chooseSiteDialog.value = true
openSearchSiteDialog()
} else {
handleSearch()
}
@@ -399,7 +426,6 @@ function handleSearch() {
// 搜索多站点
function searchSites(sites: number[]) {
chooseSiteDialog.value = false
selectedSites.value = sites
handleSearch()
}
@@ -449,7 +475,7 @@ const getImgUrl: Ref<string> = computed(() => {
// 移除订阅
function onRemoveSubscribe() {
subscribeEditDialog.value = false
isSubscribed.value = false
}
// 获取媒体类型文本
@@ -565,32 +591,6 @@ onBeforeUnmount(() => {
</div>
</template>
</VHover>
<!-- 订阅季弹窗 -->
<subscribeSeasonDialog
v-if="subscribeSeasonDialog"
v-model="subscribeSeasonDialog"
:media="media"
@subscribe="subscribeSeasons"
@close="subscribeSeasonDialog = false"
/>
<!-- 订阅编辑弹窗 -->
<SubscribeEditDialog
v-if="subscribeEditDialog"
v-model="subscribeEditDialog"
:subid="subscribeId"
@close="subscribeEditDialog = false"
@save="subscribeEditDialog = false"
@remove="onRemoveSubscribe"
/>
<!-- 站点选择对话框 -->
<SearchSiteDialog
v-if="chooseSiteDialog"
v-model="chooseSiteDialog"
:sites="allSites"
:selected="selectedSites"
@search="searchSites"
@close="chooseSiteDialog = false"
/>
</template>
<style scoped>
.media-card-title {

View File

@@ -1,18 +1,17 @@
<script setup lang="ts">
import { MediaServerConf, MediaServerLibrary, MediaStatistic } from '@/api/types'
import { useToast } from 'vue-toastification'
import { getLogoUrl } from '@/utils/imageUtils'
import api from '@/api'
import { cloneDeep } from 'lodash-es'
import type { MediaServerConf, MediaStatistic } from '@/api/types'
import { getLogoUrl } from '@/utils/imageUtils'
import { useI18n } from 'vue-i18n'
import { mediaServerDict } from '@/api/constants'
import { useDisplay } from 'vuetify'
import { openSharedDialog } from '@/composables/useSharedDialog'
import { useCardAccentColor } from '@/composables/useCardAccentColor'
// 显示器宽度
const display = useDisplay()
const MediaServerInfoDialog = defineAsyncComponent(() => import('@/components/dialog/MediaServerInfoDialog.vue'))
// 获取i18n实例
const { t } = useI18n()
const { accentRgb, imageRef, updateAccentColor } = useCardAccentColor('#56CA00')
// 定义输入
const props = defineProps({
@@ -28,9 +27,6 @@ const props = defineProps({
},
})
// 提示框
const $toast = useToast()
// 定义触发的自定义事件
const emit = defineEmits(['close', 'done', 'change'])
@@ -53,67 +49,20 @@ const infoItems = ref([
},
])
// 同步媒体库选项
const librariesOptions = ref<{ title: string; value: string | undefined }[]>([
{
title: t('common.all'),
value: 'all',
},
])
const ugreenScanModeOptions = computed(() => [
{ title: t('mediaserver.scanModeOptions.newAndModified'), value: 'new_and_modified' },
{ title: t('mediaserver.scanModeOptions.supplementMissing'), value: 'supplement_missing' },
{ title: t('mediaserver.scanModeOptions.fullOverride'), value: 'full_override' },
])
// 媒体服务器详情弹窗
const mediaServerInfoDialog = ref(false)
// 媒体服务器详情
const mediaServerInfo = ref<MediaServerConf>({
name: '',
type: '',
enabled: false,
config: {},
})
// 打开详情弹窗
/** 打开共享媒体服务器配置弹窗。 */
function openMediaServerInfoDialog() {
loadLibrary(props.mediaserver.name)
// 深复制
mediaServerInfo.value = cloneDeep(props.mediaserver)
if (mediaServerInfo.value.type === 'ugreen') {
mediaServerInfo.value.config = mediaServerInfo.value.config || {}
if (!mediaServerInfo.value.config.scan_mode) {
mediaServerInfo.value.config.scan_mode = 'supplement_missing'
}
if (mediaServerInfo.value.config.verify_ssl === undefined) {
mediaServerInfo.value.config.verify_ssl = true
}
}
mediaServerInfoDialog.value = true
if (!props.mediaserver.sync_libraries) {
mediaServerInfo.value.sync_libraries = ['all']
}
}
// 保存详情数据
function saveMediaServerInfo() {
// 为空不保存,跳出警告框
if (!mediaServerInfo.value.name) {
$toast.error(t('common.nameRequired'))
return
}
// 重名判断
if (props.mediaservers.some(item => item.name === mediaServerInfo.value.name && item !== props.mediaserver)) {
$toast.error(t('common.nameExists', { name: mediaServerInfo.value.name }))
return
}
// 执行保存
mediaServerInfoDialog.value = false
emit('change', mediaServerInfo.value, props.mediaserver.name)
emit('done')
openSharedDialog(
MediaServerInfoDialog,
{
mediaserver: props.mediaserver,
mediaservers: props.mediaservers,
},
{
change: (...args: unknown[]) => emit('change', ...args),
done: () => emit('done'),
},
{ closeOn: ['close', 'update:modelValue'] },
)
}
// 根据存储类型选择图标
@@ -136,12 +85,12 @@ const getIcon = computed(() => {
}
})
// 按钮点击
/** 关闭媒体服务器卡片。 */
function onClose() {
emit('close')
}
// 调用API加载媒体统计数据
/** 调用 API 加载媒体服务器统计数据。 */
async function loadMediaStatistic() {
try {
const res: MediaStatistic = await api.get('dashboard/statistic', {
@@ -174,529 +123,38 @@ async function loadMediaStatistic() {
}
}
// 调用API查询媒体库
async function loadLibrary(server: string) {
try {
const result: MediaServerLibrary[] = await api.get('mediaserver/library', { params: { server } })
if (result && result.length > 0) {
librariesOptions.value = result.map(item => ({
title: item.name,
value: item.id?.toString(),
}))
} else {
librariesOptions.value = []
}
librariesOptions.value.unshift({
title: t('common.all'),
value: 'all',
})
} catch (e) {
console.log(e)
}
}
onMounted(() => {
loadMediaStatistic()
})
</script>
<template>
<div>
<VCard variant="tonal" class="app-card-shell" @click="openMediaServerInfoDialog">
<VDialogCloseBtn @click="onClose" />
<VCardText class="app-card-summary app-card-summary--single-action">
<div class="app-card-summary__content">
<div class="app-card-summary__title text-h6">{{ mediaserver.name }}</div>
<div
v-if="mediaServerDict[mediaserver.type] && mediaserver.enabled"
class="grid min-h-6 grid-cols-3 gap-2 text-sm text-medium-emphasis"
>
<span v-for="item in infoItems" :key="item.title" class="flex min-w-0 items-center">
<VIcon rounded :icon="item.avatar" class="me-1 shrink-0" />
<span class="truncate">{{ item.amount }}</span>
</span>
</div>
<div v-else-if="!mediaServerDict[mediaserver.type]" class="app-card-summary__subtitle text-sm">
自定义媒体服务器
</div>
</div>
<div class="app-card-summary__media" aria-hidden="true">
<VImg :src="getIcon" contain class="app-card-summary__image" />
</div>
</VCardText>
</VCard>
<VDialog
v-if="mediaServerInfoDialog"
v-model="mediaServerInfoDialog"
scrollable
max-width="40rem"
:fullscreen="!display.mdAndUp.value"
>
<VCard>
<VCardItem class="py-2">
<template #prepend>
<VIcon icon="mdi-cog" class="me-2" />
</template>
<VCardTitle>{{ t('common.config') }}</VCardTitle>
<VCardSubtitle>{{ props.mediaserver.name }}</VCardSubtitle>
</VCardItem>
<VDialogCloseBtn v-model="mediaServerInfoDialog" />
<VDivider />
<VCardText>
<VForm>
<VRow>
<VCol cols="12" md="6">
<VSwitch v-model="mediaServerInfo.enabled" :label="t('mediaserver.enableMediaServer')" />
</VCol>
</VRow>
<VRow v-if="mediaServerInfo.type == 'emby'">
<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" md="6">
<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
v-model="mediaServerInfo.config.apikey"
:label="t('mediaserver.apiKey')"
:hint="t('mediaserver.embyApiKeyHint')"
persistent-hint
active
prepend-inner-icon="mdi-key"
/>
</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 == '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
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" md="6">
<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.apikey"
:label="t('mediaserver.apiKey')"
:hint="t('mediaserver.jellyfinApiKeyHint')"
persistent-hint
active
prepend-inner-icon="mdi-key"
/>
</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 == 'trimemedia'">
<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')"
active
prepend-inner-icon="mdi-account"
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
type="password"
v-model="mediaServerInfo.config.password"
:label="t('mediaserver.password')"
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 == 'ugreen'">
<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')"
active
prepend-inner-icon="mdi-account"
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
type="password"
v-model="mediaServerInfo.config.password"
:label="t('mediaserver.password')"
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>
<VCol cols="12" md="6">
<VSelect
v-model="mediaServerInfo.config.scan_mode"
:label="t('mediaserver.scanMode')"
:items="ugreenScanModeOptions"
:hint="t('mediaserver.scanModeHint')"
persistent-hint
active
prepend-inner-icon="mdi-radar"
/>
</VCol>
<VCol cols="12" md="6">
<VSwitch
v-model="mediaServerInfo.config.verify_ssl"
:label="t('mediaserver.verifySsl')"
:hint="t('mediaserver.verifySslHint')"
persistent-hint
color="primary"
inset
/>
</VCol>
</VRow>
<VRow v-else-if="mediaServerInfo.type == 'plex'">
<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" md="6">
<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.token"
:label="t('mediaserver.plexToken')"
:hint="t('mediaserver.plexTokenHint')"
persistent-hint
active
prepend-inner-icon="mdi-key"
/>
</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>
<VCol cols="12" md="6">
<VTextField
v-model="mediaServerInfo.type"
:label="t('mediaserver.type')"
:hint="t('mediaserver.customTypeHint')"
persistent-hint
prepend-inner-icon="mdi-cog"
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
:label="t('common.name')"
:hint="t('mediaserver.nameRequired')"
persistent-hint
prepend-inner-icon="mdi-label"
/>
</VCol>
</VRow>
</VForm>
</VCardText>
<VCardActions class="pt-3">
<VBtn @click="saveMediaServerInfo" prepend-icon="mdi-content-save" class="px-5">
{{ t('common.confirm') }}
</VBtn>
</VCardActions>
</VCard>
</VDialog>
</div>
<template>
<VCard
variant="tonal"
class="app-card-shell app-card-colorful"
:style="{ '--app-card-accent-rgb': accentRgb }"
@click="openMediaServerInfoDialog"
>
<VDialogCloseBtn @click="onClose" />
<VCardText class="app-card-summary app-card-summary--single-action">
<div class="app-card-summary__content">
<div class="app-card-summary__title text-h6">{{ mediaserver.name }}</div>
<div
v-if="mediaServerDict[mediaserver.type] && mediaserver.enabled"
class="grid min-h-6 grid-cols-3 gap-2 text-sm text-medium-emphasis"
>
<span v-for="item in infoItems" :key="item.title" class="flex min-w-0 items-center">
<VIcon rounded :icon="item.avatar" class="me-1 shrink-0" />
<span class="truncate">{{ item.amount }}</span>
</span>
</div>
<div v-else-if="!mediaServerDict[mediaserver.type]" class="app-card-summary__subtitle text-sm">
{{ t('setting.system.custom') }}
</div>
</div>
<div class="app-card-summary__media" aria-hidden="true">
<VImg ref="imageRef" :src="getIcon" contain class="app-card-summary__image" @load="updateAccentColor" />
</div>
</VCardText>
</VCard>
</template>

File diff suppressed because it is too large Load Diff

View File

@@ -1,14 +1,14 @@
<script lang="ts" setup>
import { useToast } from 'vue-toastification'
import VersionHistory from '../misc/VersionHistory.vue'
import api from '@/api'
import type { Plugin } from '@/api/types'
import { getLogoUrl } from '@/utils/imageUtils'
import { getDominantColor } from '@/@core/utils/image'
import { isNullOrEmptyObject } from '@/@core/utils'
import { formatDownloadCount } from '@/@core/utils/formatters'
import ProgressDialog from '@/components/dialog/ProgressDialog.vue'
import { useI18n } from 'vue-i18n'
import { openSharedDialog } from '@/composables/useSharedDialog'
const PluginMarketDetailDialog = defineAsyncComponent(() => import('@/components/dialog/PluginMarketDetailDialog.vue'))
const PluginVersionHistoryDialog = defineAsyncComponent(() => import('@/components/dialog/PluginVersionHistoryDialog.vue'))
// 输入参数
const props = defineProps({
@@ -30,15 +30,6 @@ const backgroundColor = ref('#28A9E1')
// 图片对象
const imageRef = ref<any>()
// 提示框
const $toast = useToast()
// 进度框
const progressDialog = ref(false)
// 进度框文本
const progressText = ref('')
// 获取当前插件的标签
const pluginLabels = computed(() => {
if (!props.plugin?.plugin_label) return []
@@ -55,12 +46,6 @@ const isImageLoaded = ref(false)
// 图片是否加载失败
const imageLoadError = ref(false)
// 更新日志弹窗
const releaseDialog = ref(false)
// 插件详情弹窗
const detailDialog = ref(false)
// 图片加载完成
async function imageLoaded() {
isImageLoaded.value = true
@@ -69,39 +54,6 @@ async function imageLoaded() {
backgroundColor.value = await getDominantColor(imageElement)
}
// 安装插件
async function installPlugin() {
try {
// 显示等待提示框
progressDialog.value = true
progressText.value = t('plugin.installing', {
name: props.plugin?.plugin_name,
version: props?.plugin?.plugin_version,
})
const result: { [key: string]: any } = await api.get(`plugin/install/${props.plugin?.id}`, {
params: {
repo_url: props.plugin?.repo_url,
force: props.plugin?.has_update,
},
})
// 隐藏等待提示框
progressDialog.value = false
if (result.success) {
$toast.success(t('plugin.installSuccess', { name: props.plugin?.plugin_name }))
detailDialog.value = false
// 通知父组件刷新
emit('install')
} else {
$toast.error(t('plugin.installFailed', { name: props.plugin?.plugin_name, message: result.message }))
}
} catch (error) {
console.error(error)
}
}
// 计算图标路径
const iconPath: Ref<string> = computed(() => {
if (imageLoadError.value) return getLogoUrl('plugin')
@@ -142,7 +94,27 @@ function visitPluginPage() {
// 显示更新日志
function showUpdateHistory() {
releaseDialog.value = true
openSharedDialog(
PluginVersionHistoryDialog,
{ plugin: props.plugin },
{},
{ closeOn: ['close', 'update:modelValue'] },
)
}
/** 打开共享插件市场详情弹窗。 */
function showPluginDetail() {
openSharedDialog(
PluginMarketDetailDialog,
{
plugin: props.plugin,
count: props.count,
},
{
install: () => emit('install'),
},
{ closeOn: ['close', 'install', 'update:modelValue'] },
)
}
// 弹出菜单
@@ -166,6 +138,7 @@ const dropdownItems = ref([
},
},
])
</script>
<template>
@@ -176,7 +149,7 @@ const dropdownItems = ref([
v-bind="hover.props"
:width="props.width"
:height="props.height"
@click="detailDialog = true"
@click="showPluginDetail"
class="flex flex-col h-full"
:class="{
'transition transform-cpu duration-300 -translate-y-1': hover.isHovering,
@@ -252,7 +225,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>
@@ -270,77 +243,5 @@ const dropdownItems = ref([
</VCard>
</template>
</VHover>
<!-- 安装插件进度框 -->
<ProgressDialog v-if="progressDialog" v-model="progressDialog" :text="progressText" />
<!-- 更新日志 -->
<VDialog v-if="releaseDialog" v-model="releaseDialog" width="600" scrollable>
<VCard :title="t('plugin.updateHistoryTitle', { name: props.plugin?.plugin_name })">
<VDialogCloseBtn @click="releaseDialog = false" />
<VDivider />
<VersionHistory :history="props.plugin?.history" />
</VCard>
</VDialog>
<!-- 插件详情-->
<VDialog v-if="detailDialog" v-model="detailDialog" max-width="30rem">
<VCard>
<VDialogCloseBtn @click="detailDialog = false" />
<VCardText>
<VCol>
<div class="d-flex justify-space-between flex-wrap flex-md-nowrap flex-column flex-md-row">
<div class="mx-auto mt-5">
<VAvatar size="64">
<VImg
ref="imageRef"
:src="iconPath"
aspect-ratio="4/3"
cover
@load="imageLoaded"
@error="imageLoadError = true"
/>
</VAvatar>
</div>
<div class="flex-grow">
<VCardItem>
<VCardTitle class="text-center text-md-left">
{{ props.plugin?.plugin_name }}
</VCardTitle>
<VCardSubtitle
class="text-center text-md-left break-words whitespace-break-spaces line-clamp-4 overflow-hidden text-ellipsis ..."
>
{{ props.plugin?.plugin_desc }}
</VCardSubtitle>
<VList lines="one">
<VListItem class="ps-0">
<VListItemTitle class="text-center text-md-left">
<span class="font-weight-medium">{{ t('common.version') }}</span>
<span class="text-body-1"> v{{ props.plugin?.plugin_version }}</span>
</VListItemTitle>
</VListItem>
<VListItem class="ps-0">
<VListItemTitle class="text-center text-md-left">
<span class="font-weight-medium">{{ t('common.author') }}</span>
<span class="text-body-1 cursor-pointer" @click="visitPluginPage">
{{ props.plugin?.plugin_author }}
</span>
</VListItemTitle>
</VListItem>
</VList>
<div class="text-center text-md-left">
<VBtn color="primary" @click="installPlugin" prepend-icon="mdi-download">{{
t('plugin.installToLocal')
}}</VBtn>
<div class="text-xs mt-2" v-if="props.count">
<VIcon icon="mdi-fire" />{{
t('plugin.totalDownloads', { count: formatDownloadCount(props.count) })
}}
</div>
</div>
</VCardItem>
</div>
</div>
</VCol>
</VCardText>
</VCard>
</VDialog>
</div>
</template>

View File

@@ -7,16 +7,17 @@ import { isNullOrEmptyObject } from '@core/utils'
import { getLogoUrl } from '@/utils/imageUtils'
import { getDominantColor } from '@/@core/utils/image'
import { formatDownloadCount } from '@/@core/utils/formatters'
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'
import { useI18n } from 'vue-i18n'
import { openSharedDialog } from '@/composables/useSharedDialog'
// 显示器宽度
const display = useDisplay()
// 插件日志面板只有点击“查看日志”时才需要,延后加载可减轻插件列表首屏。
const PluginConfigDialog = defineAsyncComponent(() => import('../dialog/PluginConfigDialog.vue'))
const PluginDataDialog = defineAsyncComponent(() => import('../dialog/PluginDataDialog.vue'))
const ProgressDialog = defineAsyncComponent(() => import('../dialog/ProgressDialog.vue'))
const PluginCloneDialog = defineAsyncComponent(() => import('../dialog/PluginCloneDialog.vue'))
const PluginLogDialog = defineAsyncComponent(() => import('../dialog/PluginLogDialog.vue'))
const PluginVersionHistoryDialog = defineAsyncComponent(() => import('../dialog/PluginVersionHistoryDialog.vue'))
// 输入参数
const props = defineProps({
@@ -37,6 +38,9 @@ const emit = defineEmits(['remove', 'save', 'actionDone'])
// 多语言
const { t } = useI18n()
// 显示器宽度
const display = useDisplay()
// 背景颜色
const backgroundColor = ref('#28A9E1')
@@ -52,24 +56,9 @@ const createConfirm = useConfirm()
// 本身是否可见
const isVisible = ref(true)
// 插件配置页面
const pluginConfigDialog = ref(false)
// 菜单显示状态
const menuVisible = ref(false)
// 进度框
const progressDialog = ref(false)
// 插件数据页面
const pluginInfoDialog = ref(false)
// 实时日志弹窗
const loggingDialog = ref(false)
// 进度框文本
const progressText = ref('正在更新插件...')
// 用户头像是否加载完成
const isAvatarLoaded = ref(false)
@@ -79,20 +68,20 @@ const isImageLoaded = ref(false)
// 图片是否加载失败
const imageLoadError = ref(false)
// 更新日志弹窗
const releaseDialog = ref(false)
let progressDialogController: ReturnType<typeof openSharedDialog> | null = null
let cloneDialogController: ReturnType<typeof openSharedDialog> | null = null
// 插件分身对话框
const pluginCloneDialog = ref(false)
/** 打开插件操作进度弹窗,插件卡片自身不再持有进度弹窗实例。 */
function showPluginProgress(text: string) {
progressDialogController?.close()
progressDialogController = openSharedDialog(ProgressDialog, { text }, {}, { closeOn: false })
}
// 插件分身表单
const cloneForm = ref({
suffix: '',
name: '',
description: '',
version: '',
icon: '',
})
/** 关闭当前插件操作进度弹窗。 */
function closePluginProgress() {
progressDialogController?.close()
progressDialogController = null
}
// 监听动作标识如为true则打开详情
watch(
@@ -119,7 +108,12 @@ function showUpdateHistory() {
if (isNullOrEmptyObject(props.plugin?.history)) {
updatePlugin()
} else {
releaseDialog.value = true
openSharedDialog(
PluginVersionHistoryDialog,
{ plugin: props.plugin, showUpdateAction: true },
{ update: updatePlugin },
{ closeOn: ['close', 'update', 'update:modelValue'] },
)
}
}
@@ -134,11 +128,10 @@ async function uninstallPlugin() {
try {
// 显示等待提示框
progressDialog.value = true
progressText.value = t('plugin.uninstalling', { name: props.plugin?.plugin_name })
showPluginProgress(t('plugin.uninstalling', { name: props.plugin?.plugin_name }))
const result: { [key: string]: any } = await api.delete(`plugin/${props.plugin?.id}`)
// 隐藏等待提示框
progressDialog.value = false
closePluginProgress()
if (result.success) {
$toast.success(t('plugin.uninstallSuccess', { name: props.plugin?.plugin_name }))
@@ -153,21 +146,34 @@ async function uninstallPlugin() {
)
}
} catch (error) {
closePluginProgress()
console.error(error)
}
}
// 显示插件数据
async function showPluginInfo() {
pluginConfigDialog.value = false
pluginInfoDialog.value = true
openSharedDialog(
PluginDataDialog,
{ plugin: props.plugin },
{
switch: showPluginConfig,
},
{ closeOn: ['close', 'switch'] },
)
}
// 显示插件配置
async function showPluginConfig() {
// 显示对话框
pluginInfoDialog.value = false
pluginConfigDialog.value = true
openSharedDialog(
PluginConfigDialog,
{ plugin: props.plugin },
{
save: configDone,
switch: showPluginInfo,
},
{ closeOn: ['close', 'save', 'switch'] },
)
}
// 计算图标路径
@@ -220,11 +226,14 @@ async function resetPlugin() {
// 更新插件
async function updatePlugin() {
if (props.plugin?.system_version_compatible === false) {
$toast.error(props.plugin?.system_version_message || t('plugin.incompatibleSystemVersion'))
return
}
try {
releaseDialog.value = false
// 显示等待提示框
progressDialog.value = true
progressText.value = t('plugin.updating', { name: props.plugin?.plugin_name })
showPluginProgress(t('plugin.updating', { name: props.plugin?.plugin_name }))
const result: { [key: string]: any } = await api.get(`plugin/install/${props.plugin?.id}`, {
params: {
@@ -234,7 +243,7 @@ async function updatePlugin() {
})
// 隐藏等待提示框
progressDialog.value = false
closePluginProgress()
if (result.success) {
$toast.success(t('plugin.updateSuccess', { name: props.plugin?.plugin_name }))
@@ -250,6 +259,7 @@ async function updatePlugin() {
)
}
} catch (error) {
closePluginProgress()
console.error(error)
}
}
@@ -259,14 +269,6 @@ function visitAuthorPage() {
window.open(props.plugin?.author_url, '_blank')
}
// 查看日志URL
function openLoggerWindow() {
const url = `${
import.meta.env.VITE_API_BASE_URL
}system/logging?length=-1&logfile=plugins/${props.plugin?.id?.toLowerCase()}.log`
window.open(url, '_blank')
}
// 打开插件详情
function openPluginDetail() {
if (props.plugin?.has_page) showPluginInfo()
@@ -283,58 +285,61 @@ function handleCardClick() {
// 配置完成
function configDone() {
pluginConfigDialog.value = false
emit('save')
}
// 显示插件分身对话框
/** 显示插件分身共享弹窗。 */
function showPluginClone() {
cloneForm.value = {
suffix: '',
name: t('plugin.cloneDefaultName', { name: props.plugin?.plugin_name }),
description: t('plugin.cloneDefaultDescription', { description: props.plugin?.plugin_desc }),
version: props.plugin?.plugin_version || '1.0',
icon: props.plugin?.plugin_icon || '',
}
pluginCloneDialog.value = true
cloneDialogController?.close()
cloneDialogController = openSharedDialog(
PluginCloneDialog,
{ plugin: props.plugin },
{ clone: executePluginClone },
{ closeOn: ['close', 'update:modelValue'] },
)
}
// 执行插件分身
async function executePluginClone() {
if (!cloneForm.value.suffix.trim()) {
async function executePluginClone(cloneForm: { suffix: string; name: string; description: string; version: string; icon: string }) {
if (!cloneForm.suffix.trim()) {
$toast.error(t('plugin.suffixRequired'))
return
}
try {
progressDialog.value = true
progressText.value = t('plugin.cloning', { name: props.plugin?.plugin_name })
showPluginProgress(t('plugin.cloning', { name: props.plugin?.plugin_name }))
const result: { [key: string]: any } = await api.post(`plugin/clone/${props.plugin?.id}`, {
suffix: cloneForm.value.suffix.trim(),
name: cloneForm.value.name.trim(),
description: cloneForm.value.description.trim(),
version: cloneForm.value.version.trim(),
icon: cloneForm.value.icon.trim(),
suffix: cloneForm.suffix.trim(),
name: cloneForm.name.trim(),
description: cloneForm.description.trim(),
version: cloneForm.version.trim(),
icon: cloneForm.icon.trim(),
})
progressDialog.value = false
closePluginProgress()
if (result.success) {
$toast.success(t('plugin.cloneSuccess', { name: cloneForm.value.name }))
pluginCloneDialog.value = false
$toast.success(t('plugin.cloneSuccess', { name: cloneForm.name }))
cloneDialogController?.close()
cloneDialogController = null
// 通知父组件刷新
emit('remove')
} else {
$toast.error(t('plugin.cloneFailed', { message: result.message }))
}
} catch (error) {
progressDialog.value = false
closePluginProgress()
$toast.error(t('plugin.cloneFailedGeneral'))
console.error(error)
}
}
onUnmounted(() => {
closePluginProgress()
cloneDialogController?.close()
})
// 弹出菜单
const dropdownItems = ref([
{
@@ -402,7 +407,7 @@ const dropdownItems = ref([
props: {
prependIcon: 'mdi-file-document-outline',
click: () => {
loggingDialog.value = true
openSharedDialog(PluginLogDialog, { plugin: props.plugin }, {}, { closeOn: ['close', 'update:modelValue'] })
},
},
},
@@ -473,7 +478,10 @@ watch(
{{ props.plugin?.plugin_desc }}
</div>
</div>
<div class="relative flex-shrink-0 self-center pb-3" :class="{ 'cursor-move': props.sortable && 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"
@@ -516,7 +524,7 @@ watch(
</span>
</div>
<div v-if="!props.sortable" class="absolute bottom-0 right-0">
<IconBtn>
<IconBtn @click.stop>
<VIcon icon="mdi-dots-vertical" />
<VMenu v-model="menuVisible" activator="parent" close-on-content-click>
<VList>
@@ -544,183 +552,6 @@ watch(
</template>
</VHover>
<!-- 插件配置页面 -->
<PluginConfigDialog
v-if="pluginConfigDialog"
v-model="pluginConfigDialog"
:plugin="props.plugin"
@save="configDone"
@close="pluginConfigDialog = false"
@switch="showPluginInfo"
/>
<!-- 插件数据页面 -->
<PluginDataDialog
v-if="pluginInfoDialog"
v-model="pluginInfoDialog"
:plugin="props.plugin"
@close="pluginInfoDialog = false"
@switch="showPluginConfig"
/>
<!-- 进度框 -->
<ProgressDialog v-if="progressDialog" v-model="progressDialog" :text="progressText" />
<!-- 更新日志 -->
<VDialog v-if="releaseDialog" v-model="releaseDialog" width="600" scrollable :fullscreen="!display.mdAndUp.value">
<VCard :title="t('plugin.updateHistoryTitle', { name: props.plugin?.plugin_name })">
<VDialogCloseBtn @click="releaseDialog = false" />
<VDivider />
<VersionHistory :history="props.plugin?.history" />
<VDivider />
<VCardItem>
<VBtn @click="updatePlugin" block>
<template #prepend>
<VIcon icon="mdi-arrow-up-circle-outline" />
</template>
{{ t('plugin.updateToLatest') }}
</VBtn>
</VCardItem>
</VCard>
</VDialog>
<!-- 实时日志弹窗 -->
<VDialog
v-if="loggingDialog"
v-model="loggingDialog"
scrollable
max-width="72rem"
:fullscreen="!display.mdAndUp.value"
>
<VCard>
<VDialogCloseBtn @click="loggingDialog = false" />
<VCardItem>
<VCardTitle class="d-inline-flex">
<VIcon icon="mdi-file-document" class="me-2" />
{{ t('plugin.logTitle') }}
<a class="mx-2 d-inline-flex align-center cursor-pointer" @click="openLoggerWindow">
<VChip color="grey-darken-1" size="small" class="ml-2">
<VIcon icon="mdi-open-in-new" size="small" start />
{{ t('common.openInNewWindow') }}
</VChip>
</a>
</VCardTitle>
</VCardItem>
<VDivider />
<VCardText class="pa-0">
<LoggingView :logfile="`plugins/${props.plugin?.id?.toLowerCase()}.log`" />
</VCardText>
</VCard>
</VDialog>
<!-- 插件分身对话框 -->
<VDialog
v-if="pluginCloneDialog"
v-model="pluginCloneDialog"
width="600"
scrollable
:fullscreen="!display.mdAndUp.value"
>
<VCard>
<VCardItem class="py-2">
<template #prepend>
<VIcon icon="mdi-content-copy" class="me-2" />
</template>
<VCardTitle>{{ t('plugin.cloneTitle') }}</VCardTitle>
<VCardSubtitle>{{ t('plugin.cloneSubtitle', { name: props.plugin?.plugin_name }) }}</VCardSubtitle>
</VCardItem>
<VDialogCloseBtn @click="pluginCloneDialog = false" />
<VDivider />
<VCardText>
<VForm>
<VRow>
<VCol cols="12" md="6">
<VTextField
v-model="cloneForm.suffix"
:label="t('plugin.suffix') + ' *'"
:placeholder="t('plugin.suffixPlaceholder')"
:hint="t('plugin.suffixHint')"
persistent-hint
:rules="[
v => !!v || t('plugin.suffixRequired'),
v => /^[a-zA-Z0-9]+$/.test(v) || t('plugin.suffixFormatError'),
v => v.length <= 20 || t('plugin.suffixLengthError'),
]"
required
prepend-inner-icon="mdi-tag"
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="cloneForm.name"
:label="t('plugin.cloneName')"
:placeholder="t('plugin.cloneNamePlaceholder')"
:hint="t('plugin.cloneNameHint')"
persistent-hint
prepend-inner-icon="mdi-rename-box"
/>
</VCol>
<VCol cols="12">
<VTextField
v-model="cloneForm.description"
:label="t('plugin.cloneDescriptionLabel')"
:placeholder="t('plugin.cloneDescriptionPlaceholder')"
:hint="t('plugin.cloneDescriptionHint')"
persistent-hint
prepend-inner-icon="mdi-text"
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="cloneForm.version"
:label="t('plugin.cloneVersion')"
:placeholder="t('plugin.cloneVersionPlaceholder')"
:hint="t('plugin.cloneVersionHint')"
persistent-hint
prepend-inner-icon="mdi-numeric"
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="cloneForm.icon"
:label="t('plugin.cloneIcon')"
:placeholder="t('plugin.cloneIconPlaceholder')"
:hint="t('plugin.cloneIconHint')"
persistent-hint
prepend-inner-icon="mdi-image"
/>
</VCol>
<!-- 重要提醒 -->
<VCol cols="12">
<VAlert type="warning" variant="tonal" density="compact" class="mt-2" icon="mdi-alert-circle-outline">
<div class="text-body-2">
<strong>{{ t('common.notice') }}</strong
>{{ t('plugin.cloneNotice') }}
</div>
</VAlert>
</VCol>
</VRow>
</VForm>
</VCardText>
<VCardActions class="pt-3">
<VSpacer />
<VBtn
color="primary"
@click="executePluginClone"
prepend-icon="mdi-content-copy"
class="px-5"
:disabled="!cloneForm.suffix.trim()"
>
{{ t('plugin.createClone') }}
</VBtn>
</VCardActions>
</VCard>
</VDialog>
</div>
</template>

View File

@@ -3,6 +3,10 @@ import { useToast } from 'vue-toastification'
import { useConfirm } from '@/composables/useConfirm'
import { useI18n } from 'vue-i18n'
import { useDisplay } from 'vuetify'
import { openSharedDialog } from '@/composables/useSharedDialog'
const PluginFolderRenameDialog = defineAsyncComponent(() => import('@/components/dialog/PluginFolderRenameDialog.vue'))
const PluginFolderSettingsDialog = defineAsyncComponent(() => import('@/components/dialog/PluginFolderSettingsDialog.vue'))
// 文件夹配置接口
interface FolderConfig {
@@ -48,15 +52,7 @@ const createConfirm = useConfirm()
// 菜单显示状态
const menuVisible = ref(false)
// 重命名对话框
const renameDialog = ref(false)
// 设置对话框
const settingDialog = ref(false)
// 新名称
const newFolderName = ref('')
let renameDialogController: ReturnType<typeof openSharedDialog> | null = null
// 默认颜色
const defaultColor = '#2196F3'
@@ -66,104 +62,35 @@ const defaultIcon = 'mdi-folder'
const defaultGradient =
'linear-gradient(rgba(0, 0, 0, 0.6) 0%, rgba(0, 0, 0, 0.5) 100%), linear-gradient(135deg, rgba(33, 150, 243, 0.7) 0%, rgba(33, 150, 243, 0.8s) 100%)'
// 文件夹设置
const folderSettings = ref<FolderConfig>({
background: '',
icon: defaultIcon,
color: defaultColor,
gradient: defaultGradient,
showIcon: true,
})
// 计算背景图片
const backgroundImage = computed(() => {
return props.folderConfig.background || folderSettings.value.background
return props.folderConfig.background
})
// 预设图标选项
const iconOptions = [
'mdi-folder',
'mdi-folder-star',
'mdi-folder-heart',
'mdi-folder-cog',
'mdi-folder-music',
'mdi-folder-image',
'mdi-folder-video',
'mdi-folder-download',
'mdi-folder-network',
'mdi-folder-special',
]
// 预设颜色选项
const colorOptions = [
'#2196F3', // 蓝色
'#4CAF50', // 绿色
'#FF9800', // 橙色
'#9C27B0', // 紫色
'#F44336', // 红色
'#607D8B', // 蓝灰色
'#795548', // 棕色
'#E91E63', // 粉色
]
// 预设渐变选项
const gradientOptions = [
'linear-gradient(rgba(0, 0, 0, 0.6) 0%, rgba(0, 0, 0, 0.4) 100%), linear-gradient(135deg, rgba(33, 150, 243, 0.7) 0%, rgba(33, 150, 243, 0.8) 100%)',
'linear-gradient(rgba(0, 0, 0, 0.6) 0%, rgba(0, 0, 0, 0.4) 100%), linear-gradient(135deg, rgba(76, 175, 80, 0.7) 0%, rgba(76, 175, 80, 0.8) 100%)',
'linear-gradient(rgba(0, 0, 0, 0.6) 0%, rgba(0, 0, 0, 0.4) 100%), linear-gradient(135deg, rgba(255, 152, 0, 0.7) 0%, rgba(255, 152, 0, 0.8) 100%)',
'linear-gradient(rgba(0, 0, 0, 0.6) 0%, rgba(0, 0, 0, 0.4) 100%), linear-gradient(135deg, rgba(156, 39, 176, 0.7) 0%, rgba(156, 39, 176, 0.8) 100%)',
'linear-gradient(rgba(0, 0, 0, 0.6) 0%, rgba(0, 0, 0, 0.4) 100%), linear-gradient(135deg, rgba(244, 67, 54, 0.7) 0%, rgba(244, 67, 54, 0.8) 100%)',
'linear-gradient(rgba(0, 0, 0, 0.6) 0%, rgba(0, 0, 0, 0.4) 100%), linear-gradient(135deg, rgba(96, 125, 139, 0.7) 0%, rgba(96, 125, 139, 0.8) 100%)',
'linear-gradient(rgba(0, 0, 0, 0.6) 0%, rgba(0, 0, 0, 0.4) 100%), linear-gradient(135deg, rgba(233, 30, 99, 0.7) 0%, rgba(233, 30, 99, 0.8) 100%)',
'linear-gradient(rgba(0, 0, 0, 0.6) 0%, rgba(0, 0, 0, 0.4) 100%), linear-gradient(135deg, rgba(63, 81, 181, 0.7) 0%, rgba(156, 39, 176, 0.8) 100%)',
]
// 计算背景渐变
const backgroundGradient = computed(() => {
const config = props.folderConfig || {}
const settings = folderSettings.value
return config.gradient || settings.gradient || gradientOptions[0]
return config.gradient || defaultGradient
})
// 计算图标
const folderIcon = computed(() => {
const config = props.folderConfig || {}
const settings = folderSettings.value
return config.icon || settings.icon || defaultIcon
return config.icon || defaultIcon
})
// 计算图标颜色
const iconColor = computed(() => {
const config = props.folderConfig || {}
const settings = folderSettings.value
return config.color || settings.color || defaultColor
return config.color || defaultColor
})
// 计算是否显示图标
const shouldShowIcon = computed(() => {
const config = props.folderConfig || {}
const settings = folderSettings.value
return config.showIcon !== undefined ? config.showIcon : settings.showIcon !== undefined ? settings.showIcon : true
return config.showIcon !== undefined ? config.showIcon : true
})
// 监听props变化更新本地设置
watch(
() => props.folderConfig,
newConfig => {
if (newConfig) {
folderSettings.value = {
...folderSettings.value,
...newConfig,
}
}
},
{ deep: true, immediate: true },
)
// 打开文件夹
function openFolder() {
emit('open', props.folderName)
@@ -177,27 +104,34 @@ function handleCardClick() {
openFolder()
}
// 重命名文件夹
/** 打开文件夹重命名共享弹窗。 */
function showRenameDialog() {
newFolderName.value = props.folderName || ''
renameDialog.value = true
renameDialogController?.close()
renameDialogController = openSharedDialog(
PluginFolderRenameDialog,
{ folderName: props.folderName },
{ rename: confirmRename },
{ closeOn: ['close', 'update:modelValue'] },
)
}
// 确认重命名
async function confirmRename() {
if (!newFolderName.value.trim()) {
async function confirmRename(newFolderName: string) {
if (!newFolderName.trim()) {
$toast.error(t('folder.folderNameCannotBeEmpty'))
return
}
if (newFolderName.value === props.folderName) {
renameDialog.value = false
if (newFolderName === props.folderName) {
renameDialogController?.close()
renameDialogController = null
return
}
try {
emit('rename', props.folderName, newFolderName.value)
renameDialog.value = false
emit('rename', props.folderName, newFolderName)
renameDialogController?.close()
renameDialogController = null
} catch (error) {
console.error(error)
}
@@ -221,28 +155,24 @@ async function deleteFolder() {
// 显示设置对话框
function showSettingDialog() {
folderSettings.value = {
background: props.folderConfig?.background || '',
icon: props.folderConfig?.icon || defaultIcon,
color: props.folderConfig?.color || defaultColor,
gradient: props.folderConfig?.gradient || gradientOptions[0],
showIcon: props.folderConfig?.showIcon !== undefined ? props.folderConfig.showIcon : true,
}
settingDialog.value = true
openSharedDialog(
PluginFolderSettingsDialog,
{ folderConfig: props.folderConfig },
{ save: saveSettings },
{ closeOn: ['close', 'save', 'update:modelValue'] },
)
}
// 保存设置
function saveSettings() {
const config = {
...props.folderConfig,
...folderSettings.value,
}
function saveSettings(config: FolderConfig) {
emit('update-config', props.folderName, config)
settingDialog.value = false
$toast.success(t('folder.folderSettingsSaved'))
}
onUnmounted(() => {
renameDialogController?.close()
})
// 弹出菜单
const dropdownItems = ref([
{
@@ -361,139 +291,6 @@ const dropdownItems = ref([
</VCard>
</template>
</VHover>
<!-- 重命名对话框 -->
<VDialog v-if="renameDialog" v-model="renameDialog" max-width="400">
<VCard>
<VCardItem>
<template #prepend>
<VIcon icon="mdi-pencil" class="me-2" />
</template>
<VCardTitle>{{ t('folder.renameFolder') }}</VCardTitle>
</VCardItem>
<VDialogCloseBtn @click="renameDialog = false" />
<VDivider />
<VCardText>
<VTextField
v-model="newFolderName"
:label="t('folder.folderName')"
variant="outlined"
autofocus
@keyup.enter="confirmRename"
/>
</VCardText>
<VCardActions>
<VSpacer />
<VBtn color="primary" prepend-icon="mdi-check" class="px-5" @click="confirmRename">确认</VBtn>
</VCardActions>
</VCard>
</VDialog>
<!-- 设置对话框 -->
<VDialog
v-if="settingDialog"
v-model="settingDialog"
max-width="600"
scrollable
:fullscreen="!display.mdAndUp.value"
>
<VCard>
<VDialogCloseBtn @click="settingDialog = false" />
<VCardItem>
<VCardTitle>
<VIcon icon="mdi-palette" class="mr-2" />
{{ t('folder.folderAppearanceSettings') }}
</VCardTitle>
</VCardItem>
<VDivider />
<VCardText>
<VRow>
<!-- 显示图标开关 -->
<VCol cols="12">
<VSwitch
v-model="folderSettings.showIcon"
:label="t('folder.showFolderIcon')"
color="primary"
hide-details
/>
</VCol>
<!-- 图标选择 -->
<VCol v-if="folderSettings.showIcon" cols="12" md="6">
<VCardSubtitle class="pa-0 mb-2">{{ t('folder.icon') }}</VCardSubtitle>
<div class="icon-grid">
<VBtn
v-for="icon in iconOptions"
icon
:key="icon"
:variant="folderSettings.icon === icon ? 'tonal' : 'text'"
:color="folderSettings.icon === icon ? 'primary' : 'default'"
size="large"
class="ma-1"
@click="folderSettings.icon = icon"
>
<VIcon :icon="icon" size="24" />
</VBtn>
</div>
</VCol>
<!-- 颜色选择 -->
<VCol v-if="folderSettings.showIcon" cols="12" md="6">
<VCardSubtitle class="pa-0 mb-2">{{ t('folder.iconColor') }}</VCardSubtitle>
<div class="color-grid">
<VBtn
v-for="color in colorOptions"
:key="color"
:variant="folderSettings.color === color ? 'tonal' : 'text'"
:color="color"
size="large"
class="ma-1 color-btn"
:style="{ backgroundColor: color }"
@click="folderSettings.color = color"
>
<VIcon v-if="folderSettings.color === color" icon="mdi-check" color="white" />
</VBtn>
</div>
</VCol>
<!-- 渐变背景选择 -->
<VCol cols="12">
<VCardSubtitle class="pa-0 mb-2">{{ t('folder.backgroundGradient') }}</VCardSubtitle>
<div class="gradient-grid">
<VBtn
v-for="(gradient, index) in gradientOptions"
:key="index"
:variant="folderSettings.gradient === gradient ? 'tonal' : 'text'"
class="ma-1 gradient-btn"
:style="{ background: gradient }"
size="large"
@click="folderSettings.gradient = gradient"
>
<VIcon v-if="folderSettings.gradient === gradient" icon="mdi-check" color="white" />
</VBtn>
</div>
</VCol>
<!-- 自定义背景图片 -->
<VCol cols="12">
<VTextField
v-model="folderSettings.background"
:label="t('folder.customBackgroundImageURL')"
placeholder="https://example.com/image.jpg"
variant="outlined"
:hint="t('folder.customBackgroundImageHint')"
persistent-hint
prepend-inner-icon="mdi-image"
/>
</VCol>
</VRow>
</VCardText>
<VCardActions>
<VSpacer />
<VBtn color="primary" prepend-icon="mdi-content-save" class="px-5" @click="saveSettings">保存</VBtn>
</VCardActions>
</VCard>
</VDialog>
</div>
</template>

View File

@@ -3,10 +3,6 @@ import type { PropType } from 'vue'
import { getLogoUrl } from '@/utils/imageUtils'
import { useToast } from 'vue-toastification'
import { useI18n } from 'vue-i18n'
import SiteAddEditDialog from '../dialog/SiteAddEditDialog.vue'
import SiteUserDataDialog from '../dialog/SiteUserDataDialog.vue'
import SiteResourceDialog from '../dialog/SiteResourceDialog.vue'
import SiteCookieUpdateDialog from '../dialog/SiteCookieUpdateDialog.vue'
import api from '@/api'
import type { Site, SiteStatistic, SiteUserData } from '@/api/types'
import { isNullOrEmptyObject } from '@/@core/utils'
@@ -14,6 +10,12 @@ import { formatFileSize } from '@/@core/utils/formatters'
import { useConfirm } from '@/composables/useConfirm'
import { getCachedSiteIcon } from '@/utils/siteIconCache'
import { useDisplay } from 'vuetify'
import { openSharedDialog } from '@/composables/useSharedDialog'
const SiteAddEditDialog = defineAsyncComponent(() => import('../dialog/SiteAddEditDialog.vue'))
const SiteCookieUpdateDialog = defineAsyncComponent(() => import('../dialog/SiteCookieUpdateDialog.vue'))
const SiteResourceDialog = defineAsyncComponent(() => import('../dialog/SiteResourceDialog.vue'))
const SiteUserDataDialog = defineAsyncComponent(() => import('../dialog/SiteUserDataDialog.vue'))
// 显示器宽度
const display = useDisplay()
@@ -51,18 +53,6 @@ const testButtonText = ref(t('site.testConnectivity'))
// 测试按钮可用性
const testButtonDisable = ref(false)
// 更新站点Cookie UA弹窗
const siteCookieDialog = ref(false)
// 站点编辑弹窗
const siteEditDialog = ref(false)
// 资源浏览弹窗
const resourceDialog = ref(false)
// 用户数据弹窗
const siteUserDataDialog = ref(false)
// 查询站点图标
async function getSiteIcon() {
const siteId = cardProps.site?.id
@@ -105,17 +95,44 @@ async function testSite() {
// 打开更新站点Cookie UA弹窗
async function handleSiteUpdate() {
siteCookieDialog.value = true
openSharedDialog(
SiteCookieUpdateDialog,
{ site: cardProps.site },
{
done: onSiteCookieUpdated,
},
{ closeOn: ['close', 'done'] },
)
}
// 打开资源浏览弹窗
async function handleResourceBrowse() {
resourceDialog.value = true
openSharedDialog(
SiteResourceDialog,
{ site: cardProps.site },
{
close: onSiteResourceDone,
},
{ closeOn: ['close'] },
)
}
// 打开站点用户数据弹窗
async function handleSiteUserData() {
siteUserDataDialog.value = true
openSharedDialog(SiteUserDataDialog, { site: cardProps.site }, {}, { closeOn: ['close'] })
}
// 打开站点编辑弹窗
function handleSiteEdit() {
openSharedDialog(
SiteAddEditDialog,
{ siteid: cardProps.site?.id },
{
save: saveSite,
remove: () => emit('remove'),
},
{ closeOn: ['close', 'save', 'remove'] },
)
}
// 打开站点页面
@@ -199,20 +216,17 @@ const getDownloadPercent = computed(() => {
// 保存站点
function saveSite() {
siteEditDialog.value = false
emit('update')
}
// 更新站点Cookie UA后的回调
function onSiteCookieUpdated() {
siteCookieDialog.value = false
// Cookie更新后刷新统计数据
emit('refresh-stats', cardProps.site?.domain)
}
// 资源浏览弹窗关闭后的回调
function onSiteResourceDone() {
resourceDialog.value = false
// 资源操作完成后刷新统计数据
emit('refresh-stats', cardProps.site?.domain)
}
@@ -386,11 +400,11 @@ 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>
<VListItem @click="siteEditDialog = true" base-color="info">
<VListItem @click="handleSiteEdit" base-color="info">
<template #prepend>
<VIcon icon="mdi-file-edit-outline" size="20" />
</template>
@@ -407,35 +421,6 @@ onMounted(() => {
</VBtn>
</VSheet>
</VCard>
<!-- 对话框组件 -->
<SiteCookieUpdateDialog
v-if="siteCookieDialog"
v-model="siteCookieDialog"
:site="cardProps.site"
@close="siteCookieDialog = false"
@done="onSiteCookieUpdated"
/>
<SiteAddEditDialog
v-if="siteEditDialog"
v-model="siteEditDialog"
:siteid="cardProps.site?.id"
@save="saveSite"
@remove="emit('remove')"
@close="siteEditDialog = false"
/>
<SiteUserDataDialog
v-if="siteUserDataDialog"
v-model="siteUserDataDialog"
:site="cardProps.site"
@close="siteUserDataDialog = false"
/>
<SiteResourceDialog
v-if="resourceDialog"
v-model="resourceDialog"
:site="cardProps.site"
@close="onSiteResourceDone"
/>
</div>
</template>

View File

@@ -1,5 +1,5 @@
<script setup lang="ts">
import { StorageConf } from '@/api/types'
import type { StorageConf } from '@/api/types'
import { formatBytes } from '@core/utils/formatters'
import storage_png from '@images/misc/storage.png'
import alipan_png from '@images/misc/alipan.webp'
@@ -9,21 +9,22 @@ import alist_png from '@images/misc/openlist.svg'
import custom_png from '@images/misc/database.png'
import smb_png from '@images/misc/smb.png'
import api from '@/api'
import AliyunAuthDialog from '../dialog/AliyunAuthDialog.vue'
import U115AuthDialog from '../dialog/U115AuthDialog.vue'
import RcloneConfigDialog from '../dialog/RcloneConfigDialog.vue'
import AlistConfigDialog from '../dialog/AlistConfigDialog.vue'
import SmbConfigDialog from '../dialog/SmbConfigDialog.vue'
import { useToast } from 'vue-toastification'
import { isNullOrEmptyObject } from '@/@core/utils'
import { useI18n } from 'vue-i18n'
import { useDisplay } from 'vuetify'
import { openSharedDialog } from '@/composables/useSharedDialog'
import { useCardAccentColor } from '@/composables/useCardAccentColor'
// 显示器宽度
const display = useDisplay()
const AliyunAuthDialog = defineAsyncComponent(() => import('../dialog/AliyunAuthDialog.vue'))
const U115AuthDialog = defineAsyncComponent(() => import('../dialog/U115AuthDialog.vue'))
const RcloneConfigDialog = defineAsyncComponent(() => import('../dialog/RcloneConfigDialog.vue'))
const AlistConfigDialog = defineAsyncComponent(() => import('../dialog/AlistConfigDialog.vue'))
const SmbConfigDialog = defineAsyncComponent(() => import('../dialog/SmbConfigDialog.vue'))
const StorageCustomConfigDialog = defineAsyncComponent(() => import('../dialog/StorageCustomConfigDialog.vue'))
// 国际化
const { t } = useI18n()
const { accentRgb, imageRef, updateAccentColor } = useCardAccentColor('#FFB400')
// 定义输入
const props = defineProps({
@@ -50,53 +51,34 @@ const used = computed(() => {
return total.value - available.value
})
// 存储
const storage_ref = ref(props.storage)
// 自定义存储名称
const customName = ref(props.storage.name)
// 自定义存储类型
const storageType = ref(props.storage.type)
// 阿里云盘认证对话框
const aliyunAuthDialog = ref(false)
// 115网盘认证对话框
const u115AuthDialog = ref(false)
// Rclone配置对话框
const rcloneConfigDialog = ref(false)
// AList配置对话框
const aListConfigDialog = ref(false)
// SMB配置对话框
const smbConfigDialog = ref(false)
// 自定义存储配置对话框
const customConfigDialog = ref(false)
// 打开存储对话框
/** 打开指定类型的共享存储配置弹窗。 */
function openStorageDialog() {
switch (props.storage.type) {
case 'alipan':
aliyunAuthDialog.value = true
break
case 'u115':
u115AuthDialog.value = true
break
case 'rclone':
rcloneConfigDialog.value = true
break
case 'alist':
aListConfigDialog.value = true
break
case 'smb':
smbConfigDialog.value = true
break
case 'local':
$toast.info(t('storage.noConfigNeeded'))
break
default:
customConfigDialog.value = true
break
const dialogMap: Record<string, Component> = {
alipan: AliyunAuthDialog,
u115: U115AuthDialog,
rclone: RcloneConfigDialog,
alist: AlistConfigDialog,
smb: SmbConfigDialog,
}
if (props.storage.type === 'local') {
$toast.info(t('storage.noConfigNeeded'))
return
}
const dialog = dialogMap[props.storage.type] || StorageCustomConfigDialog
const dialogProps = dialog === StorageCustomConfigDialog
? { storage: props.storage }
: { conf: props.storage.config || {} }
openSharedDialog(
dialog,
dialogProps,
{
done: handleDone,
},
{ closeOn: ['close', 'done', 'update:modelValue'] },
)
}
// 根据存储类型选择图标
@@ -135,7 +117,7 @@ const usage = computed(() => {
return Math.round((used.value / (total.value || 1)) * 1000) / 10
})
// 查询存储信息
/** 查询存储空间使用信息。 */
async function queryStorage() {
try {
const data: { total: number; available: number } = await api.get(`storage/usage/${props.storage.type}`)
@@ -146,123 +128,47 @@ async function queryStorage() {
}
}
// 完成配置后的处理
function handleDone() {
aliyunAuthDialog.value = false
u115AuthDialog.value = false
rcloneConfigDialog.value = false
aListConfigDialog.value = false
smbConfigDialog.value = false
customConfigDialog.value = false
// 更新存储
storage_ref.value.name = customName.value
storage_ref.value.type = storageType.value
emit('done', storage_ref.value)
/** 完成配置后的处理并通知父级刷新。 */
function handleDone(storage?: StorageConf) {
emit('done', storage || props.storage)
}
onMounted(() => {
queryStorage()
})
// 关闭
/** 关闭存储卡片。 */
function onClose() {
emit('close')
}
</script>
<template>
<div>
<VCard variant="tonal" @click="openStorageDialog">
<VDialogCloseBtn @click="onClose" class="absolute top-1 right-1" />
<VCardText class="flex justify-space-between align-center gap-3">
<div class="align-self-start flex-1">
<h5 class="text-h6 mb-1">{{ storage.name }}</h5>
<div class="mb-3 text-sm" v-if="total">{{ formatBytes(used, 1) }} / {{ formatBytes(total, 1) }}</div>
<div v-else-if="isNullOrEmptyObject(storage.config)">{{ t('storage.notConfigured') }}</div>
</div>
<VImg :src="getIcon" cover class="mt-8" max-width="3rem" min-width="3rem" />
</VCardText>
<div class="w-full absolute bottom-0">
<VProgressLinear v-if="usage > 0" :model-value="usage" :bg-color="progressColor" :color="progressColor" />
<VCard
variant="tonal"
class="app-card-shell app-card-colorful"
:style="{ '--app-card-accent-rgb': accentRgb }"
@click="openStorageDialog"
>
<VDialogCloseBtn @click="onClose" />
<VCardText class="flex justify-space-between align-center gap-3">
<div class="align-self-start flex-1">
<h5 class="text-h6 mb-1">{{ storage.name }}</h5>
<div class="mb-3 text-sm" v-if="total">{{ formatBytes(used, 1) }} / {{ formatBytes(total, 1) }}</div>
<div v-else-if="isNullOrEmptyObject(storage.config)">{{ t('storage.notConfigured') }}</div>
</div>
</VCard>
<AliyunAuthDialog
v-if="aliyunAuthDialog"
v-model="aliyunAuthDialog"
:conf="props.storage.config || {}"
@close="aliyunAuthDialog = false"
@done="handleDone"
/>
<U115AuthDialog
v-if="u115AuthDialog"
v-model="u115AuthDialog"
:conf="props.storage.config || {}"
@close="u115AuthDialog = false"
@done="handleDone"
/>
<RcloneConfigDialog
v-if="rcloneConfigDialog"
v-model="rcloneConfigDialog"
:conf="props.storage.config || {}"
@close="rcloneConfigDialog = false"
@done="handleDone"
/>
<AlistConfigDialog
v-if="aListConfigDialog"
v-model="aListConfigDialog"
:conf="props.storage.config || {}"
@close="aListConfigDialog = false"
@done="handleDone"
/>
<SmbConfigDialog
v-if="smbConfigDialog"
v-model="smbConfigDialog"
:conf="props.storage.config || {}"
@close="smbConfigDialog = false"
@done="handleDone"
/>
<VDialog
v-if="customConfigDialog"
v-model="customConfigDialog"
scrollable
max-width="30rem"
:fullscreen="!display.mdAndUp.value"
>
<VCard>
<VCardItem>
<template #prepend>
<VIcon icon="mdi-cog" />
</template>
<VCardTitle>{{ t('storage.custom') }}</VCardTitle>
<VDialogCloseBtn v-model="customConfigDialog" />
</VCardItem>
<VDivider />
<VCardText>
<VRow>
<VCol cols="12" md="6">
<VTextField
v-model="storageType"
:label="t('storage.type')"
:hint="t('storage.customTypeHint')"
persistent-hint
prepend-inner-icon="mdi-database"
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="customName"
:label="t('storage.name')"
persistent-hint
prepend-inner-icon="mdi-label"
/>
</VCol>
</VRow>
</VCardText>
<VCardActions class="pt-3">
<VBtn @click="handleDone" prepend-icon="mdi-content-save" class="px-5">
{{ t('common.save') }}
</VBtn>
</VCardActions>
</VCard>
</VDialog>
</div>
<VImg
ref="imageRef"
:src="getIcon"
cover
class="mt-8"
max-width="3rem"
min-width="3rem"
@load="updateAccentColor"
/>
</VCardText>
<div class="w-full absolute bottom-0">
<VProgressLinear v-if="usage > 0" :model-value="usage" :bg-color="progressColor" :color="progressColor" />
</div>
</VCard>
</template>

View File

@@ -1,9 +1,6 @@
<script lang="ts" setup>
import { useToast } from 'vue-toastification'
import { useConfirm } from '@/composables/useConfirm'
import SubscribeEditDialog from '../dialog/SubscribeEditDialog.vue'
import SubscribeFilesDialog from '../dialog/SubscribeFilesDialog.vue'
import SubscribeShareDialog from '../dialog/SubscribeShareDialog.vue'
import { formatDateDifference, formatSeason } from '@/@core/utils/formatters'
import api from '@/api'
import type { Subscribe } from '@/api/types'
@@ -11,6 +8,11 @@ import router from '@/router'
import { useI18n } from 'vue-i18n'
import { useDisplay } from 'vuetify'
import { useGlobalSettingsStore } from '@/stores'
import { openSharedDialog } from '@/composables/useSharedDialog'
const SubscribeEditDialog = defineAsyncComponent(() => import('../dialog/SubscribeEditDialog.vue'))
const SubscribeFilesDialog = defineAsyncComponent(() => import('../dialog/SubscribeFilesDialog.vue'))
const SubscribeShareDialog = defineAsyncComponent(() => import('../dialog/SubscribeShareDialog.vue'))
// 显示器宽度
const display = useDisplay()
@@ -52,33 +54,100 @@ const $toast = useToast()
// 图片是否加载完成
const imageLoaded = ref(false)
// 订阅弹窗
const subscribeEditDialog = ref(false)
// 订阅文件信息弹窗
const subscribeFilesDialog = ref(false)
// 分享订阅弹窗
const subscribeShareDialog = ref(false)
// 当前的订阅状态
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
}
// 已下载集数total_episode - lack_episode
const downloadedEpisode = computed(() => {
const total = props.media?.total_episode || 0
if (!total) return 0
return Math.min(Math.max(total - (props.media?.lack_episode || 0), 0), total)
})
// 是否为洗版订阅(影响进度条与 tooltip 的展示分支)
const isBestVersion = computed(() => isEnabledFlag(props.media?.best_version) && isTvSubscribe(props.media))
const rightBottomStateDisplay = computed(() => {
if (subscribeState.value === 'S') {
return { icon: 'mdi-pause-circle', label: t('subscribe.cardStatePaused') }
}
if (subscribeState.value === 'P') {
return { icon: 'mdi-clock', label: t('subscribe.cardStatePending') }
}
return null
})
// 洗版徽标:共用 mdi-shimmer 图标,分集 / 全集 由 full 标记区分背景
const bestVersionBadge = computed(() => {
if (!isEnabledFlag(props.media?.best_version)) return null
return {
icon: 'mdi-shimmer',
full: isEnabledFlag(props.media?.best_version_full),
}
})
// 已洗版集数:取后端派生字段 completed_episode
const completedEpisode = computed(() => {
const total = props.media?.total_episode || 0
return Math.min(Math.max(props.media?.completed_episode ?? 0, 0), total)
})
// 卡片主文案:已下载集数 / 总集数
const subscribeProgressText = computed(() => {
const total = props.media?.total_episode || 0
if (!total) return ''
return `${downloadedEpisode.value} / ${total}`
})
// 订阅卡片 hover 文案:
// - 普通订阅:「已下载 X · 共 Y 集」
// - 洗版订阅:「已下载 X · 已洗版 N · 共 Y 集」
const subscribeProgressTooltip = computed(() => {
const total = props.media?.total_episode || 0
if (!total) return ''
if (isBestVersion.value) {
return t('subscribe.bestVersionEpisodeProgressTooltip', {
completed: completedEpisode.value,
downloaded: downloadedEpisode.value,
total,
})
}
return t('subscribe.subscribeProgressTooltip', { downloaded: downloadedEpisode.value, total })
})
// 图片加载完成响应
function imageLoadHandler() {
imageLoaded.value = true
}
// 计算百分
// 进度条 model 段百分比:洗版订阅表示"已洗版"占比(亮段),普通订阅表示"已下载"占
function getPercentage() {
if (props.media?.total_episode === 0) return 0
const total = props.media?.total_episode || 0
if (!total) return 0
const value = isBestVersion.value ? completedEpisode.value : downloadedEpisode.value
return Math.round((value / total) * 100)
}
return Math.round(
(((props.media?.total_episode ?? 0) - (props.media?.lack_episode ?? 0)) / (props.media?.total_episode ?? 1)) * 100,
)
// 洗版进度条的 buffer 段百分比:表示"已下载"占比,仅在洗版场景被模板调用
function getBufferPercentage() {
const total = props.media?.total_episode || 0
if (!isBestVersion.value || !total) return 0
return Math.round((downloadedEpisode.value / total) * 100)
}
// 删除订阅
@@ -157,12 +226,22 @@ async function resetSubscribe() {
// 分享订阅
async function shareSubscribe() {
subscribeShareDialog.value = true
if (!props.media) return
openSharedDialog(SubscribeShareDialog, { sub: props.media }, {}, { closeOn: ['close'] })
}
// 编辑订阅响应
async function editSubscribeDialog() {
subscribeEditDialog.value = true
openSharedDialog(
SubscribeEditDialog,
{ subid: props.media?.id },
{
remove: onSubscribeEditRemove,
save: onSubscribeEditSave,
},
{ closeOn: ['close', 'save', 'remove'] },
)
}
// 获得mediaid
@@ -188,7 +267,7 @@ async function viewMediaDetail() {
// 查看文件详情
async function viewSubscribeFiles() {
subscribeFilesDialog.value = true
openSharedDialog(SubscribeFilesDialog, { subid: props.media?.id }, {}, { closeOn: ['close'] })
}
// 弹出菜单
@@ -301,13 +380,11 @@ const posterUrl = computed(() => {
// 订阅编辑保存
function onSubscribeEditSave() {
subscribeEditDialog.value = false
emit('save')
}
// 订阅编辑取消
function onSubscribeEditRemove() {
subscribeEditDialog.value = false
emit('remove')
}
@@ -332,11 +409,11 @@ function handleCardClick() {
<VHover>
<template #default="hover">
<div
class="w-full h-full rounded-lg overflow-hidden"
class="w-full h-full rounded-lg overflow-hidden relative"
:class="{
'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,
'subscribe-card-pending-tint': subscribeState === 'P',
}"
>
<VCard
@@ -344,7 +421,7 @@ function handleCardClick() {
:key="props.media?.id"
class="flex flex-col h-full"
:class="{
'opacity-70': subscribeState === 'S',
'subscribe-card-paused': subscribeState === 'S',
'cursor-move': props.sortable,
}"
rounded="0"
@@ -352,8 +429,15 @@ function handleCardClick() {
@click="handleCardClick"
:ripple="!props.batchMode && !props.sortable"
>
<div
v-if="bestVersionBadge && imageLoaded"
class="best-version-badge"
:class="{ 'best-version-badge-full': bestVersionBadge.full }"
>
<VIcon :icon="bestVersionBadge.icon" color="white" size="16" />
</div>
<div v-if="!props.sortable" class="me-n3 absolute top-1 right-4">
<IconBtn>
<IconBtn @click.stop>
<VIcon icon="mdi-dots-vertical" color="white" />
<VMenu activator="parent" close-on-content-click>
<VList>
@@ -380,15 +464,11 @@ function handleCardClick() {
<div class="absolute inset-0 outline-none subscribe-card-background"></div>
</template>
</VImg>
<div
v-if="subscribeState === 'P'"
class="absolute inset-0 bg-yellow-900 opacity-80 pointer-events-none"
/>
</template>
<div>
<VCardText class="flex items-center pt-3 pb-2">
<div
class="h-auto w-12 flex-shrink-0 overflow-hidden rounded-md"
class="h-auto w-12 flex-shrink-0 overflow-hidden rounded-md relative"
v-if="imageLoaded"
:class="{ 'cursor-move': props.sortable && display.mdAndUp.value }"
>
@@ -408,8 +488,8 @@ 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"
@@ -424,27 +504,53 @@ function handleCardClick() {
icon="mdi-progress-download"
color="white"
/>
<div v-if="props.media?.season" class="text-subtitle-2 me-2 text-white">
{{ (props.media?.total_episode || 0) - (props.media?.lack_episode || 0) }} /
{{ props.media?.total_episode }}
<!-- 守卫改用 total_episode电视剧订阅可能不带 season 字段旧数据或自定义来源仍应展示集数进度 -->
<div v-if="props.media?.total_episode" class="flex-shrink-0 text-subtitle-2 me-2 text-white">
{{ subscribeProgressText }}
<VTooltip v-if="subscribeProgressTooltip" activator="parent" location="top">
{{ subscribeProgressTooltip }}
</VTooltip>
</div>
<VIcon v-if="props.media?.username && props.sortable" icon="mdi-account" size="small" color="white" class="me-1" />
<IconBtn v-else-if="props.media?.username" icon="mdi-account" size="small" color="white" />
<span v-if="props.media?.username" class="text-subtitle-2 text-white">
<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>
</VCardText>
<!-- 右下角元数据暂停 / 待定时替换"x 天前"为状态文案 -->
<VCardText
v-if="lastUpdateText"
v-if="rightBottomStateDisplay"
class="absolute right-0 bottom-0 d-flex align-center p-2 text-gray-300 text-xs"
>
<VIcon :icon="rightBottomStateDisplay.icon" class="me-1" />
{{ rightBottomStateDisplay.label }}
</VCardText>
<VCardText
v-else-if="lastUpdateText"
class="absolute right-0 bottom-0 d-flex align-center p-2 text-gray-300 text-xs"
>
<VIcon icon="mdi-download" class="me-1" />
{{ lastUpdateText }}
</VCardText>
<div class="w-full absolute bottom-0">
<!--
分集洗版模式底色保持深绿buffer 段显示"已下载未洗版"为浅绿model 段显示"已洗版完成"为亮绿
形成两段语义其余订阅维持原有单段进度条
-->
<VProgressLinear
v-if="getPercentage() > 0"
v-if="isBestVersion && getBufferPercentage() > 0"
:model-value="getPercentage()"
:buffer-value="getBufferPercentage()"
bg-color="success"
bg-opacity="0.25"
color="success"
buffer-color="success"
buffer-opacity="0.55"
/>
<VProgressLinear
v-else-if="getPercentage() > 0"
:model-value="getPercentage()"
bg-color="success"
color="success"
@@ -455,34 +561,60 @@ function handleCardClick() {
</div>
</template>
</VHover>
<!-- 订阅编辑弹窗 -->
<SubscribeEditDialog
v-if="subscribeEditDialog"
v-model="subscribeEditDialog"
:subid="props.media?.id"
@remove="onSubscribeEditRemove"
@save="onSubscribeEditSave"
@close="subscribeEditDialog = false"
/>
<!-- 订阅文件信息弹窗 -->
<SubscribeFilesDialog
v-if="subscribeFilesDialog"
v-model="subscribeFilesDialog"
:subid="props.media?.id"
@close="subscribeFilesDialog = false"
/>
<!-- 分享订阅弹窗 -->
<SubscribeShareDialog
v-if="subscribeShareDialog"
v-model="subscribeShareDialog"
:sub="props.media"
@close="subscribeShareDialog = false"
/>
</div>
</template>
<style lang="scss" scoped>
.subscribe-card-background {
background-image: linear-gradient(180deg, rgba(31, 41, 55, 47%) 0%, rgb(31, 41, 55) 100%);
}
/**
* 暂停:降低不透明度表达"已停止活动"
*/
.subscribe-card-paused {
opacity: 0.65;
transition: opacity 0.2s ease;
}
/**
* 待定:用 ::after 浮层在 VCard 之上渲染 sky 漫反射式内发光
*/
.subscribe-card-pending-tint {
position: relative;
}
.subscribe-card-pending-tint::after {
content: '';
position: absolute;
inset: 0;
pointer-events: none;
border-radius: 8px;
box-shadow: inset 0 0 48px rgba(56, 189, 248, 0.4); // sky-400
z-index: 3;
}
/**
* 洗版标识:卡片左上角 24x24 圆形徽标
* 分集:深色半透底 + 模糊
* 全集:磨砂玻璃半透白底 + 大模糊
*/
.best-version-badge {
position: absolute;
top: 6px;
left: 8px;
width: 24px;
height: 24px;
border-radius: 50%;
background: rgba(0, 0, 0, 0.75);
display: flex;
align-items: center;
justify-content: center;
z-index: 4;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.5);
backdrop-filter: blur(6px);
}
.best-version-badge-full {
background: rgba(255, 255, 255, 0.22);
backdrop-filter: blur(10px);
box-shadow: 0 2px 8px rgba(255, 255, 255, 0.15);
}
</style>

View File

@@ -2,9 +2,11 @@
import { formatDateDifference } from '@/@core/utils/formatters'
import type { SubscribeShare } from '@/api/types'
import router from '@/router'
import SubscribeEditDialog from '../dialog/SubscribeEditDialog.vue'
import ForkSubscribeDialog from '../dialog/ForkSubscribeDialog.vue'
import { useGlobalSettingsStore } from '@/stores'
import { openSharedDialog } from '@/composables/useSharedDialog'
const ForkSubscribeDialog = defineAsyncComponent(() => import('../dialog/ForkSubscribeDialog.vue'))
const SubscribeEditDialog = defineAsyncComponent(() => import('../dialog/SubscribeEditDialog.vue'))
// 输入参数
const props = defineProps({
@@ -22,15 +24,6 @@ const globalSettings = globalSettingsStore.globalSettings
// 图片是否加载完成
const imageLoaded = ref(false)
// 订阅编辑弹窗
const subscribeEditDialog = ref(false)
// 复用订阅弹窗
const forkSubscribeDialog = ref(false)
// 订阅ID
const subscribeId = ref<number>()
// 图片加载完成响应
function imageLoadHandler() {
imageLoaded.value = true
@@ -78,19 +71,24 @@ async function viewMediaDetail() {
// 复用订阅
function showForkSubscribe() {
forkSubscribeDialog.value = true
openSharedDialog(
ForkSubscribeDialog,
{ media: props.media },
{
fork: finishForkSubscribe,
delete: doDelete,
},
{ closeOn: ['close', 'fork', 'delete'] },
)
}
// 完成复用订阅
function finishForkSubscribe(subid: number) {
subscribeId.value = subid
forkSubscribeDialog.value = false
subscribeEditDialog.value = true
openSharedDialog(SubscribeEditDialog, { subid }, {}, { closeOn: ['close', 'save', 'remove'] })
}
// 删除订阅分享时处理
function doDelete() {
forkSubscribeDialog.value = false
// 通知父组件刷新
emit('delete')
}
@@ -167,24 +165,6 @@ function doDelete() {
</div>
</template>
</VHover>
<!-- 订阅编辑弹窗 -->
<SubscribeEditDialog
v-if="subscribeEditDialog"
v-model="subscribeEditDialog"
:subid="subscribeId"
@close="subscribeEditDialog = false"
@save="subscribeEditDialog = false"
@remove="subscribeEditDialog = false"
/>
<!-- 复用订阅弹窗 -->
<ForkSubscribeDialog
v-if="forkSubscribeDialog"
v-model="forkSubscribeDialog"
:media="props.media"
@close="forkSubscribeDialog = false"
@fork="finishForkSubscribe"
@delete="doDelete"
/>
</div>
</template>
<style lang="scss" scoped>

View File

@@ -3,10 +3,13 @@ import type { PropType } from 'vue'
import { formatFileSize, formatDateDifference } from '@/@core/utils/formatters'
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'
import { openSharedDialog } from '@/composables/useSharedDialog'
const AddDownloadDialog = defineAsyncComponent(() => import('../dialog/AddDownloadDialog.vue'))
const TorrentMoreSourcesDialog = defineAsyncComponent(() => import('../dialog/TorrentMoreSourcesDialog.vue'))
// 输入参数
const props = defineProps({
@@ -16,9 +19,6 @@ const props = defineProps({
height: String,
})
// 更多来源界面
const showMoreTorrents = ref(false)
// 种子信息
const torrent = ref(props.torrent?.torrent_info)
@@ -36,18 +36,14 @@ const siteIcons = ref<Record<number, string>>({})
const isDownloaded = computed(() => Boolean(torrent.value?.enclosure && downloadedTorrentMap[torrent.value.enclosure]))
// 添加下载对话框
const addDownloadDialog = ref(false)
// 添加下载成功
function addDownloadSuccess(url: string) {
addDownloadDialog.value = false
markTorrentDownloaded(url)
}
// 添加下载失败
function addDownloadError(error: string) {
addDownloadDialog.value = false
console.error(error)
}
// 查询站点图标
@@ -77,7 +73,21 @@ async function handleAddDownload(item: Context | null = null) {
downloadItem.value = item
}
// 打开下载对话框
addDownloadDialog.value = true
openSharedDialog(
AddDownloadDialog,
{
title: `${downloadItem.value?.media_info?.title_year || downloadItem.value?.meta_info?.name} ${
downloadItem.value?.meta_info?.season_episode
}`,
media: downloadItem.value?.media_info,
torrent: downloadItem.value?.torrent_info,
},
{
done: addDownloadSuccess,
error: addDownloadError,
},
{ closeOn: ['close', 'done', 'error'] },
)
}
// 打开种子详情页面
@@ -103,21 +113,23 @@ function getPromotionClass(downloadVolumeFactor: number | undefined, uploadVolum
else return ''
}
// 获取优惠标签类
function getPromotionChipClass(downloadVolumeFactor: number | undefined, uploadVolumeFactor: number | undefined) {
if (!downloadVolumeFactor) return 'chip-free'
if (downloadVolumeFactor === 0) return 'chip-free'
else if (downloadVolumeFactor < 1) return 'chip-discount'
else if (uploadVolumeFactor !== undefined && uploadVolumeFactor > 1) return 'chip-bonus'
else return ''
}
// 打开更多来源对话框
async function openMoreTorrentsDialog() {
props.more?.forEach(t => {
return getSiteIcon(t.torrent_info?.site)
})
showMoreTorrents.value = true
openSharedDialog(
TorrentMoreSourcesDialog,
{
items: props.more || [],
siteIcons: siteIcons.value,
},
{
download: handleAddDownload,
detail: openTorrentDetail,
},
{ closeOn: ['close', 'update:modelValue'] },
)
}
watch(
@@ -276,7 +288,7 @@ watch(
class="pa-1 d-flex align-center"
@click.stop="openMoreTorrentsDialog"
>
<VIcon :icon="showMoreTorrents ? 'mdi-chevron-up' : 'mdi-chevron-down'" size="small" class="mr-1"></VIcon>
<VIcon icon="mdi-chevron-down" size="small" class="mr-1"></VIcon>
更多来源 ({{ props.more.length }})
</VBtn>
</div>
@@ -294,105 +306,6 @@ watch(
</div>
</VCardActions>
</VCard>
<!-- 更多来源对话框 -->
<VDialog v-model="showMoreTorrents" max-width="25rem" location="center">
<VCard>
<VCardTitle class="py-3 d-flex align-center">
<span>其他来源</span>
<VSpacer />
<VBtn variant="text" size="small" icon="mdi-close" @click.stop="showMoreTorrents = false"></VBtn>
</VCardTitle>
<VDivider />
<VCardText class="more-sources-content pa-0">
<VList lines="one" density="compact">
<VListItem
v-for="(item, index) in props.more"
:key="index"
@click.stop="handleAddDownload(item)"
class="hover:bg-primary-lighten-5"
>
<template v-slot:prepend>
<div class="d-flex align-center gap-1">
<VImg
v-if="siteIcons[item.torrent_info?.site || 0]"
:src="siteIcons[item.torrent_info?.site || 0]"
:alt="item.torrent_info?.site_name"
width="16"
height="16"
class="rounded"
/>
<VAvatar v-else size="16" class="text-caption bg-surface-variant">
{{ item.torrent_info?.site_name?.substring(0, 1) }}
</VAvatar>
<span class="text-body-2 font-weight-bold">{{ item.torrent_info.site_name }}</span>
<VChip
v-if="item.meta_info?.season_episode"
class="chip-season rounded-sm ml-1"
size="x-small"
variant="elevated"
>
{{ item.meta_info.season_episode }}
</VChip>
<VChip
v-if="item.torrent_info?.downloadvolumefactor !== 1 || item.torrent_info?.uploadvolumefactor !== 1"
:class="
getPromotionChipClass(
item.torrent_info?.downloadvolumefactor,
item.torrent_info?.uploadvolumefactor,
)
"
size="x-small"
variant="elevated"
class="rounded-sm ml-1"
>
{{ item.torrent_info?.volume_factor }}
</VChip>
</div>
</template>
<template v-slot:append>
<div class="d-flex align-center gap-2">
<span class="text-caption font-weight-bold text-primary">
{{ formatFileSize(item.torrent_info?.size) }}
</span>
<span class="d-flex align-center text-caption font-weight-bold">
<VIcon size="small" color="success" icon="mdi-arrow-up" class="mr-1"></VIcon>
{{ item.torrent_info?.seeders }}
</span>
<span>
<VIcon
@click.stop="openTorrentDetail(item)"
size="small"
color="secondary"
icon="mdi-arrow-top-right"
class="mr-1"
></VIcon>
</span>
</div>
</template>
</VListItem>
</VList>
</VCardText>
</VCard>
</VDialog>
<AddDownloadDialog
v-if="addDownloadDialog"
v-model="addDownloadDialog"
:title="`${downloadItem?.media_info?.title_year || downloadItem?.meta_info?.name} ${
downloadItem?.meta_info?.season_episode
}`"
:media="downloadItem?.media_info"
:torrent="downloadItem?.torrent_info"
@done="addDownloadSuccess"
@error="addDownloadError"
@close="addDownloadDialog = false"
/>
</div>
</template>
@@ -403,11 +316,6 @@ watch(
inset-inline-end: 0;
}
.more-sources-content {
max-block-size: 60vh;
overflow-y: auto;
}
/* 卡片悬停效果 */
.torrent-card {
border: 1px solid transparent;

View File

@@ -3,9 +3,11 @@ import type { PropType } from 'vue'
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'
import { openSharedDialog } from '@/composables/useSharedDialog'
const AddDownloadDialog = defineAsyncComponent(() => import('../dialog/AddDownloadDialog.vue'))
// 输入参数
const props = defineProps({
@@ -26,9 +28,6 @@ const siteIcon = ref('')
const isDownloaded = computed(() => Boolean(torrent.value?.enclosure && downloadedTorrentMap[torrent.value.enclosure]))
// 添加下载对话框
const addDownloadDialog = ref(false)
// 查询站点图标
async function getSiteIcon() {
if (!torrent?.value?.site) {
@@ -73,18 +72,29 @@ function getPromotionChipClass(downloadVolumeFactor: number | undefined, uploadV
// 询问并添加下载
async function handleAddDownload() {
// 打开下载对话框
addDownloadDialog.value = true
openSharedDialog(
AddDownloadDialog,
{
title: `${media.value?.title_year || meta.value?.name} ${meta.value?.season_episode || ''}`,
media: media.value,
torrent: torrent.value,
},
{
done: addDownloadSuccess,
error: addDownloadError,
},
{ closeOn: ['close', 'done', 'error'] },
)
}
// 添加下载成功
function addDownloadSuccess(url: string) {
addDownloadDialog.value = false
markTorrentDownloaded(url)
}
// 添加下载失败
function addDownloadError(error: string) {
addDownloadDialog.value = false
console.error(error)
}
// 打开种子详情页面
@@ -241,17 +251,6 @@ watch(
</div>
</template>
</VListItem>
<AddDownloadDialog
v-if="addDownloadDialog"
v-model="addDownloadDialog"
:title="`${media?.title_year || meta?.name} ${meta?.season_episode || ''}`"
:media="media"
:torrent="torrent"
@done="addDownloadSuccess"
@error="addDownloadError"
@close="addDownloadDialog = false"
/>
</div>
</template>

View File

@@ -5,9 +5,11 @@ import { useUserStore } from '@/stores'
import avatar1 from '@images/avatars/avatar-1.png'
import { useToast } from 'vue-toastification'
import { useConfirm } from '@/composables/useConfirm'
import UserAddEditDialog from '@/components/dialog/UserAddEditDialog.vue'
import { useDisplay } from 'vuetify'
import { useI18n } from 'vue-i18n'
import { openSharedDialog } from '@/composables/useSharedDialog'
const UserAddEditDialog = defineAsyncComponent(() => import('@/components/dialog/UserAddEditDialog.vue'))
// 国际化
const { t } = useI18n()
@@ -46,9 +48,6 @@ const emit = defineEmits(['remove', 'save'])
// 确认框
const createConfirm = useConfirm()
// 用户信息弹窗
const userEditDialog = ref(false)
// 提示框
const $toast = useToast()
@@ -104,12 +103,22 @@ async function removeUser() {
// 编辑用户
function editUser() {
userEditDialog.value = true
openSharedDialog(
UserAddEditDialog,
{
username: props.user?.name,
usernames: props.users.map(item => item.name),
oper: 'edit',
},
{
save: onUserUpdate,
},
{ closeOn: ['close', 'save'] },
)
}
// 用户更新完成时
function onUserUpdate() {
userEditDialog.value = false
emit('save')
}
@@ -123,10 +132,10 @@ onMounted(() => {
'transition-transform duration-300 hover:-translate-y-1',
!props.user.is_active ? 'opacity-85 bg-surface-lighten-1' : '',
]"
class="flex flex-column"
@click="userEditDialog = true"
class="user-card flex flex-column h-full"
@click="editUser"
>
<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 +256,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>
@@ -294,20 +303,19 @@ onMounted(() => {
</VCardText>
</div>
</VCard>
<!-- 用户编辑弹窗 -->
<UserAddEditDialog
v-if="userEditDialog"
v-model="userEditDialog"
:username="props.user?.name"
:usernames="props.users.map(item => item.name)"
oper="edit"
@save="onUserUpdate"
@close="userEditDialog = false"
/>
</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,9 @@
<script lang="ts" setup>
import { formatDateDifference } from '@/@core/utils/formatters'
import type { WorkflowShare } from '@/api/types'
import ForkWorkflowDialog from '../dialog/ForkWorkflowDialog.vue'
import { openSharedDialog } from '@/composables/useSharedDialog'
const ForkWorkflowDialog = defineAsyncComponent(() => import('../dialog/ForkWorkflowDialog.vue'))
// 输入参数
const props = defineProps({
@@ -15,9 +17,6 @@ const props = defineProps({
// 定义删除事件
const emit = defineEmits(['delete', 'update'])
// 复用工作流弹窗
const forkWorkflowDialog = ref(false)
// 工作流ID
const workflowId = ref<string>()
@@ -65,19 +64,28 @@ onMounted(() => {
// 复用工作流
function showForkWorkflow() {
forkWorkflowDialog.value = true
openSharedDialog(
ForkWorkflowDialog,
{
workflow: props.workflow,
eventTypes: props.eventTypes,
},
{
fork: finishForkWorkflow,
delete: doDelete,
},
{ closeOn: ['close', 'fork', 'delete'] },
)
}
// 完成复用工作流
function finishForkWorkflow(wid: string) {
workflowId.value = wid
forkWorkflowDialog.value = false
emit('update')
}
// 删除工作流分享时处理
function doDelete() {
forkWorkflowDialog.value = false
// 通知父组件刷新
emit('delete')
}
@@ -134,15 +142,5 @@ function doDelete() {
</div>
</template>
</VHover>
<!-- 复用工作流弹窗 -->
<ForkWorkflowDialog
v-if="forkWorkflowDialog"
v-model="forkWorkflowDialog"
:workflow="props.workflow"
:event-types="props.eventTypes"
@close="forkWorkflowDialog = false"
@fork="finishForkWorkflow"
@delete="doDelete"
/>
</div>
</template>

View File

@@ -2,11 +2,13 @@
import { Workflow } from '@/api/types'
import { useToast } from 'vue-toastification'
import { useConfirm } from '@/composables/useConfirm'
import WorkflowAddEditDialog from '@/components/dialog/WorkflowAddEditDialog.vue'
import WorkflowActionsDialog from '@/components/dialog/WorkflowActionsDialog.vue'
import WorkflowShareDialog from '@/components/dialog/WorkflowShareDialog.vue'
import api from '@/api'
import { useI18n } from 'vue-i18n'
import { openSharedDialog } from '@/composables/useSharedDialog'
const WorkflowActionsDialog = defineAsyncComponent(() => import('@/components/dialog/WorkflowActionsDialog.vue'))
const WorkflowAddEditDialog = defineAsyncComponent(() => import('@/components/dialog/WorkflowAddEditDialog.vue'))
const WorkflowShareDialog = defineAsyncComponent(() => import('@/components/dialog/WorkflowShareDialog.vue'))
const { t } = useI18n()
@@ -31,15 +33,6 @@ const $toast = useToast()
// 确认框
const createConfirm = useConfirm()
// 编辑对话框
const editDialog = ref(false)
// 流程对话框
const flowDialog = ref(false)
// 分享对话框
const shareDialog = ref(false)
// 加载中
const loading = ref(false)
@@ -51,24 +44,35 @@ const getEventTypeText = (eventTypeValue: string) => {
// 编辑任务
function handleEdit(item: Workflow) {
editDialog.value = true
openSharedDialog(
WorkflowAddEditDialog,
{ workflow: item },
{
save: editDone,
},
{ closeOn: ['close', 'save'] },
)
}
// 编辑流程
function handleFlow(item: Workflow) {
flowDialog.value = true
openSharedDialog(
WorkflowActionsDialog,
{ workflow: item },
{
save: editDone,
},
{ closeOn: ['close', 'save'] },
)
}
// 分享工作流
function handleShare(item: Workflow) {
shareDialog.value = true
openSharedDialog(WorkflowShareDialog, { workflow: item }, {}, { closeOn: ['close'] })
}
// 编辑完成
function editDone() {
editDialog.value = false
flowDialog.value = false
shareDialog.value = false
emit('refresh')
}
@@ -365,23 +369,5 @@ const resolveProgress = (item: Workflow) => {
</VCardText>
</VCard>
</VHover>
<!-- 流程对话框 -->
<WorkflowActionsDialog
v-if="flowDialog"
v-model="flowDialog"
@close="flowDialog = false"
@save="editDone"
:workflow="workflow"
/>
<!-- 编辑对话框 -->
<WorkflowAddEditDialog
v-if="editDialog"
v-model="editDialog"
@close="editDialog = false"
@save="editDone"
:workflow="workflow"
/>
<!-- 分享对话框 -->
<WorkflowShareDialog v-if="shareDialog" v-model="shareDialog" :workflow="workflow" @close="shareDialog = false" />
</div>
</template>

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>

View File

@@ -0,0 +1,95 @@
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
const { t } = useI18n()
const props = withDefaults(
defineProps<{
itemTitle?: string
loading?: boolean
modelValue?: boolean
recognizeSource?: string
}>(),
{
itemTitle: '',
loading: false,
modelValue: true,
recognizeSource: '',
},
)
const emit = defineEmits<{
(event: 'close'): void
(event: 'confirm', payload: { doubanId?: string; tmdbId?: number }): void
(event: 'update:modelValue', value: boolean): void
}>()
const tmdbId = ref<number | undefined>()
const doubanId = ref<string | undefined>()
const visible = computed({
get: () => props.modelValue,
set: value => {
emit('update:modelValue', value)
if (!value) emit('close')
},
})
// 提交重新识别参数给缓存页执行接口调用。
function submitReidentify() {
emit('confirm', {
doubanId: doubanId.value,
tmdbId: tmdbId.value,
})
}
</script>
<template>
<VDialog v-if="visible" v-model="visible" scrollable max-width="35rem">
<VCard>
<VCardItem class="py-2">
<template #prepend>
<VIcon>mdi-text-recognition</VIcon>
</template>
<VCardTitle>{{ t('setting.cache.reidentifyDialog.title') }}</VCardTitle>
<VCardSubtitle>{{ props.itemTitle }}</VCardSubtitle>
</VCardItem>
<VDialogCloseBtn v-model="visible" />
<VDivider />
<VCardText>
<VRow>
<VCol cols="12">
<VTextField
v-if="props.recognizeSource === 'themoviedb'"
v-model="tmdbId"
:label="t('setting.cache.reidentifyDialog.tmdbId')"
:hint="t('setting.cache.reidentifyDialog.tmdbIdHint')"
clearable
prepend-inner-icon="mdi-id-card"
persistent-hint
/>
<VTextField
v-else
v-model="doubanId"
:label="t('setting.cache.reidentifyDialog.doubanId')"
:hint="t('setting.cache.reidentifyDialog.doubanIdHint')"
clearable
prepend-inner-icon="mdi-id-card"
persistent-hint
/>
</VCol>
</VRow>
<VAlert type="info" variant="tonal" class="mt-4">
{{ t('setting.cache.reidentifyDialog.autoHint') }}
</VAlert>
</VCardText>
<VCardActions>
<VSpacer />
<VBtn color="primary" :loading="props.loading" prepend-icon="mdi-check" @click="submitReidentify">
{{ t('setting.cache.reidentifyDialog.confirm') }}
</VBtn>
</VCardActions>
</VCard>
</VDialog>
</template>

View File

@@ -0,0 +1,237 @@
<script setup lang="ts">
import { useDisplay } from 'vuetify'
import { useI18n } from 'vue-i18n'
const { t } = useI18n()
const display = useDisplay()
type UnknownRecord = Record<string, any>
const props = withDefaults(
defineProps<{
colors?: Record<string, string>
enabled: Record<string, boolean>
elevated?: boolean
hint: string
items: UnknownRecord[]
labelGetter?: (item: UnknownRecord) => string
modelValue?: boolean
selectAllText?: string
selectNoneText?: string
showBulkActions?: boolean
switchLabel?: string
title: string
valueGetter?: (item: UnknownRecord) => string
}>(),
{
colors: () => ({}),
elevated: false,
labelGetter: undefined,
modelValue: true,
selectAllText: '',
selectNoneText: '',
showBulkActions: false,
switchLabel: '',
valueGetter: undefined,
},
)
const emit = defineEmits<{
(event: 'close'): void
(event: 'save', payload: { elevated: boolean; enabled: Record<string, boolean> }): void
(event: 'update:elevated', value: boolean): void
(event: 'update:modelValue', value: boolean): void
}>()
const localEnabled = ref<Record<string, boolean>>({})
const localElevated = ref(props.elevated)
const visible = computed({
get: () => props.modelValue,
set: value => {
emit('update:modelValue', value)
if (!value) emit('close')
},
})
const elevatedValue = computed({
get: () => localElevated.value,
set: value => {
localElevated.value = value
emit('update:elevated', value)
},
})
watch(
() => [props.enabled, props.elevated, props.items],
() => {
resetLocalSettings()
},
{ deep: true, immediate: true },
)
// 重置弹窗内部设置副本,避免直接修改父级 props。
function resetLocalSettings() {
localEnabled.value = { ...props.enabled }
localElevated.value = props.elevated
}
// 获取设置项的稳定键值。
function getItemValue(item: UnknownRecord) {
return props.valueGetter?.(item) ?? String(item.id ?? item.title ?? item.name ?? '')
}
// 获取设置项展示名称。
function getItemLabel(item: UnknownRecord) {
return props.labelGetter?.(item) ?? String(item.attrs?.title ?? item.name ?? item.title ?? '')
}
// 切换单个设置项的启用状态。
function toggleItem(item: UnknownRecord) {
const key = getItemValue(item)
localEnabled.value[key] = !localEnabled.value[key]
}
// 批量设置所有项目启用状态。
function setAllItems(value: boolean) {
props.items.forEach(item => {
localEnabled.value[getItemValue(item)] = value
})
}
// 提交通用内容开关设置。
function submitSettings() {
emit('save', {
elevated: localElevated.value,
enabled: { ...localEnabled.value },
})
}
</script>
<template>
<VDialog v-if="visible" v-model="visible" width="35rem" class="settings-dialog" scrollable :fullscreen="!display.mdAndUp.value">
<VCard class="settings-card">
<VCardItem class="settings-card-header">
<VCardTitle>
<VIcon icon="mdi-tune" size="small" class="me-2" />
{{ props.title }}
</VCardTitle>
<VDialogCloseBtn v-model="visible" />
</VCardItem>
<VDivider />
<VCardText>
<p class="settings-hint">{{ props.hint }}</p>
<div class="settings-grid">
<div
v-for="item in props.items"
:key="getItemValue(item)"
class="setting-item"
:class="{ 'enabled': localEnabled[getItemValue(item)] }"
:style="{ '--item-color': props.colors[getItemValue(item)] }"
@click="toggleItem(item)"
>
<div class="setting-item-inner">
<div class="setting-check">
<VIcon
:icon="localEnabled[getItemValue(item)] ? 'mdi-check-circle' : 'mdi-circle-outline'"
:color="localEnabled[getItemValue(item)] ? 'primary' : undefined"
size="small"
/>
</div>
<span class="setting-label">{{ getItemLabel(item) }}</span>
</div>
</div>
</div>
<p v-if="props.switchLabel" class="mt-3">
<VSwitch v-model="elevatedValue" :label="props.switchLabel" />
</p>
</VCardText>
<VCardActions class="pt-3">
<VBtn v-if="props.showBulkActions" variant="text" @click="setAllItems(true)">
{{ props.selectAllText }}
</VBtn>
<VBtn v-if="props.showBulkActions" variant="text" @click="setAllItems(false)">
{{ props.selectNoneText }}
</VBtn>
<VSpacer />
<VBtn color="primary" class="px-5" @click="submitSettings">
<template #prepend>
<VIcon icon="mdi-content-save" />
</template>
{{ t('common.save') }}
</VBtn>
</VCardActions>
</VCard>
</VDialog>
</template>
<style scoped>
.settings-card-header {
padding-block: 16px;
padding-inline: 20px;
}
.settings-hint {
color: rgba(var(--v-theme-on-surface), 0.7);
font-size: 0.9rem;
margin-block-end: 16px;
}
.settings-grid {
display: grid;
gap: 12px;
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
}
.setting-label {
flex: 1;
color: rgba(var(--v-theme-on-surface), 0.8);
font-size: 0.9rem;
font-weight: 500;
line-height: 1.2;
transition: color 0.2s ease;
}
.setting-item {
position: relative;
overflow: hidden;
border: 1px solid rgba(var(--v-theme-on-surface), 0.1);
border-radius: 8px;
background-color: rgba(var(--v-theme-surface-variant), 0.3);
cursor: pointer;
padding-block: 10px;
padding-inline: 12px;
transition: all 0.2s ease;
}
.setting-item::before {
position: absolute;
background: linear-gradient(90deg, var(--item-color, rgb(var(--v-theme-primary))) 0%, transparent 100%);
content: '';
inline-size: 3px;
inset-block: 0;
inset-inline-start: 0;
opacity: 0.3;
transition: opacity 0.2s ease;
}
.setting-item.enabled {
border-color: rgba(var(--v-theme-primary), 0.4);
background-color: rgba(var(--v-theme-primary), 0.08);
}
.setting-item.enabled::before {
opacity: 1;
}
.setting-item-inner {
display: flex;
align-items: center;
gap: 10px;
}
.setting-check {
display: flex;
flex-shrink: 0;
}
</style>

View File

@@ -0,0 +1,151 @@
<script setup lang="ts">
import { useDisplay } from 'vuetify'
import { useI18n } from 'vue-i18n'
// 国际化
const { t } = useI18n()
// 显示器宽度
const display = useDisplay()
// 输入参数
const props = withDefaults(
defineProps<{
css?: string
editorTheme?: string
modelValue?: boolean
}>(),
{
css: '',
editorTheme: 'monokai',
modelValue: true,
},
)
// 定义触发的自定义事件
const emit = defineEmits<{
(e: 'close'): void
(e: 'save', css: string): void
(e: 'update:modelValue', value: boolean): void
}>()
// 弹窗显示状态
const visible = computed({
get: () => props.modelValue,
set: value => {
emit('update:modelValue', value)
if (!value) emit('close')
},
})
// 正在编辑的 CSS 内容
const editableCSS = ref(props.css)
const editorOptions = {
displayIndentGuides: true,
fontSize: 14,
highlightActiveLine: true,
scrollPastEnd: 0.2,
showPrintMargin: false,
tabSize: 2,
}
watch(
() => props.css,
value => {
editableCSS.value = value
},
)
/** 提交当前 CSS 内容给调用方保存。 */
function submitCustomCSS() {
emit('save', editableCSS.value)
}
</script>
<template>
<VDialog v-if="visible" v-model="visible" max-width="50rem" :fullscreen="!display.mdAndUp.value">
<VCard class="custom-css-dialog">
<VCardItem class="custom-css-header py-3">
<template #prepend>
<VAvatar color="primary" variant="tonal" rounded size="40" class="me-2">
<VIcon icon="mdi-palette" size="22" />
</VAvatar>
</template>
<VCardTitle>
{{ t('theme.custom') }}
</VCardTitle>
<VDialogCloseBtn v-model="visible" />
</VCardItem>
<div class="custom-css-editor-body">
<VAceEditor
v-model:value="editableCSS"
lang="css"
:theme="props.editorTheme"
:options="editorOptions"
wrap
class="custom-css-editor"
/>
</div>
<VCardActions class="custom-css-actions">
<VBtn color="primary" prepend-icon="mdi-content-save" class="px-5" @click="submitCustomCSS">
{{ t('common.save') }}
</VBtn>
</VCardActions>
</VCard>
</VDialog>
</template>
<style scoped>
.custom-css-dialog {
display: flex;
flex-direction: column;
max-block-size: calc(100dvh - 2rem);
overflow: hidden;
}
.custom-css-header {
flex: 0 0 auto;
border-block-end: 1px solid rgba(var(--v-theme-on-surface), 0.08);
}
.custom-css-editor-body {
flex: 1 1 auto;
min-block-size: 0;
}
.custom-css-editor {
overflow: hidden;
background: rgb(var(--v-theme-surface));
block-size: min(62vh, 34rem);
inline-size: 100%;
}
.custom-css-actions {
flex: 0 0 auto;
border-block-start: 1px solid rgba(var(--v-theme-on-surface), 0.08);
padding-block: 0.875rem;
padding-inline: 1rem;
}
@media (width <= 960px) {
.custom-css-dialog {
block-size: 100dvh;
max-block-size: 100dvh;
}
.custom-css-editor-body {
display: flex;
flex-direction: column;
}
.custom-css-editor {
flex: 1 1 auto;
min-block-size: 0;
block-size: auto;
}
.custom-css-actions {
padding-block-end: max(0.875rem, calc(env(safe-area-inset-bottom) + 0.75rem));
}
}
</style>

View File

@@ -0,0 +1,209 @@
<script lang="ts" setup>
import { innerFilterRules } from '@/api/constants'
import type { CustomRule } from '@/api/types'
import { cloneDeep } from 'lodash-es'
import { useToast } from 'vue-toastification'
import { useI18n } from 'vue-i18n'
import { useDisplay } from 'vuetify'
// 显示器宽度
const display = useDisplay()
// 输入参数
const props = defineProps({
modelValue: {
type: Boolean,
default: true,
},
// 单条规则
rule: {
type: Object as PropType<CustomRule>,
required: true,
},
// 所有规则
rules: {
type: Array as PropType<CustomRule[]>,
required: true,
},
})
// 提示框
const $toast = useToast()
const { t } = useI18n()
// 定义触发的自定义事件
const emit = defineEmits(['update:modelValue', 'close', 'change', 'done'])
// 规则详情弹窗
const ruleInfoDialog = computed({
get: () => props.modelValue,
set: value => {
emit('update:modelValue', value)
if (!value) emit('close')
},
})
// 规则详情
const ruleInfo = ref<CustomRule>({
id: '',
name: '',
include: '',
exclude: '',
size_range: '',
seeders: '',
publish_time: '',
})
/** 初始化规则编辑表单数据。 */
function initializeRuleInfo() {
ruleInfo.value = cloneDeep(props.rule)
}
/** 保存规则编辑结果并通知父级刷新。 */
function saveRuleInfo() {
if (!ruleInfo.value.id || !ruleInfo.value.name) {
if (!ruleInfo.value.id && !ruleInfo.value.name) {
$toast.error(t('customRule.error.emptyIdName'))
}
return
}
if (innerFilterRules.find(option => option.value === ruleInfo.value.id)) {
$toast.error(t('customRule.error.idOccupied'))
return
}
if (innerFilterRules.find(option => option.title === ruleInfo.value.name)) {
$toast.error(t('customRule.error.nameOccupied'))
return
}
if (ruleInfo.value.id !== props.rule.id && props.rules.find(rule => rule.id === ruleInfo.value.id)) {
$toast.error(t('customRule.error.idExists', { id: ruleInfo.value.id }))
return
}
if (ruleInfo.value.name !== props.rule.name && props.rules.find(rule => rule.name === ruleInfo.value.name)) {
$toast.error(t('customRule.error.nameExists', { name: ruleInfo.value.name }))
return
}
ruleInfoDialog.value = false
emit('change', ruleInfo.value, props.rule.id)
emit('done')
}
/** 规范化规则 ID 输入,只保留英文和数字。 */
function validateRuleId() {
ruleInfo.value.id = ruleInfo.value.id.replace(/[^a-zA-Z0-9]/g, '')
}
onMounted(() => {
initializeRuleInfo()
})
</script>
<template>
<VDialog
v-if="ruleInfoDialog"
v-model="ruleInfoDialog"
scrollable
max-width="40rem"
:fullscreen="!display.mdAndUp.value"
>
<VCard>
<VCardItem>
<template #prepend>
<VIcon icon="mdi-filter-outline" class="me-2" />
</template>
<VCardTitle>{{ t('customRule.title', { id: props.rule.id }) }}</VCardTitle>
</VCardItem>
<VDialogCloseBtn v-model="ruleInfoDialog" />
<VDivider />
<VCardText>
<VForm>
<VRow>
<VCol cols="12" md="6">
<VTextField
v-model="ruleInfo.id"
:label="t('customRule.field.ruleId')"
:placeholder="t('customRule.placeholder.ruleId')"
:hint="t('customRule.hint.ruleId')"
persistent-hint
active
prepend-inner-icon="mdi-identifier"
@input="validateRuleId"
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="ruleInfo.name"
:label="t('customRule.field.ruleName')"
:placeholder="t('customRule.placeholder.ruleName')"
:hint="t('customRule.hint.ruleName')"
persistent-hint
active
prepend-inner-icon="mdi-label"
/>
</VCol>
<VCol cols="12">
<VTextField
v-model="ruleInfo.include"
:label="t('customRule.field.include')"
:placeholder="t('customRule.placeholder.include')"
:hint="t('customRule.hint.include')"
persistent-hint
active
prepend-inner-icon="mdi-plus-circle"
/>
</VCol>
<VCol cols="12">
<VTextField
v-model="ruleInfo.exclude"
:label="t('customRule.field.exclude')"
:placeholder="t('customRule.placeholder.exclude')"
:hint="t('customRule.hint.exclude')"
persistent-hint
active
prepend-inner-icon="mdi-minus-circle"
/>
</VCol>
<VCol cols="6">
<VTextField
v-model="ruleInfo.size_range"
:label="t('customRule.field.sizeRange')"
:placeholder="t('customRule.placeholder.sizeRange')"
:hint="t('customRule.hint.sizeRange')"
persistent-hint
active
prepend-inner-icon="mdi-harddisk"
/>
</VCol>
<VCol cols="6">
<VTextField
v-model="ruleInfo.seeders"
:label="t('customRule.field.seeders')"
:placeholder="t('customRule.placeholder.seeders')"
:hint="t('customRule.hint.seeders')"
persistent-hint
active
prepend-inner-icon="mdi-account-group"
/>
</VCol>
<VCol cols="6">
<VTextField
v-model="ruleInfo.publish_time"
:label="t('customRule.field.publishTime')"
:placeholder="t('customRule.placeholder.publishTime')"
:hint="t('customRule.hint.publishTime')"
persistent-hint
active
prepend-inner-icon="mdi-calendar-clock"
/>
</VCol>
</VRow>
</VForm>
</VCardText>
<VCardActions class="pt-3">
<VBtn @click="saveRuleInfo" prepend-icon="mdi-content-save" class="px-5">
{{ t('customRule.action.confirm') }}
</VBtn>
</VCardActions>
</VCard>
</VDialog>
</template>

View File

@@ -0,0 +1,161 @@
<script setup lang="ts">
import draggable from 'vuedraggable'
import type { DiscoverSource } from '@/api/types'
import { useDisplay } from 'vuetify'
import { useI18n } from 'vue-i18n'
const { t } = useI18n()
const display = useDisplay()
const props = withDefaults(
defineProps<{
colors?: Record<string, string>
modelValue?: boolean
tabs: DiscoverSource[]
}>(),
{
colors: () => ({}),
modelValue: true,
},
)
const emit = defineEmits<{
(event: 'close'): void
(event: 'save', tabs: DiscoverSource[]): void
(event: 'update:modelValue', value: boolean): void
}>()
const localTabs = ref<DiscoverSource[]>([])
const visible = computed({
get: () => props.modelValue,
set: value => {
emit('update:modelValue', value)
if (!value) emit('close')
},
})
watch(
() => props.tabs,
() => {
resetLocalTabs()
},
{ deep: true, immediate: true },
)
// 重置弹窗内部排序副本。
function resetLocalTabs() {
localTabs.value = props.tabs.map(item => ({ ...item }))
}
// 保存当前拖拽后的发现标签顺序。
function submitOrder() {
emit('save', localTabs.value)
}
</script>
<template>
<VDialog v-if="visible" v-model="visible" max-width="35rem" scrollable :fullscreen="!display.mdAndUp.value">
<VCard>
<VCardItem>
<VCardTitle>
<VIcon icon="mdi-order-alphabetical-ascending" size="small" class="me-2" />
{{ t('discover.setTabOrder') }}
</VCardTitle>
<VDialogCloseBtn v-model="visible" />
</VCardItem>
<VDivider />
<VCardText>
<p class="settings-hint">{{ t('discover.dragToReorder') }}</p>
<draggable
v-model="localTabs"
handle=".cursor-move"
item-key="mediaid_prefix"
tag="div"
:component-data="{ 'class': 'settings-grid' }"
>
<template #item="{ element }">
<VCard
variant="text"
class="setting-item enabled"
:style="{ '--item-color': props.colors[element.mediaid_prefix] }"
>
<div class="setting-item-inner">
<span class="setting-label">{{ element.name }}</span>
<VIcon icon="mdi-drag" class="drag-icon cursor-move" />
</div>
</VCard>
</template>
</draggable>
</VCardText>
<VCardActions class="pt-3">
<VSpacer />
<VBtn @click="submitOrder">
<template #prepend>
<VIcon icon="mdi-content-save" />
</template>
{{ t('common.save') }}
</VBtn>
</VCardActions>
</VCard>
</VDialog>
</template>
<style scoped>
.settings-hint {
color: rgba(var(--v-theme-on-surface), 0.7);
font-size: 0.9rem;
margin-block-end: 16px;
}
.settings-grid {
display: grid;
gap: 12px;
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
}
.setting-item {
position: relative;
overflow: hidden;
border: 1px solid rgba(var(--v-theme-primary), 0.3);
border-radius: 8px;
background-color: rgba(var(--v-theme-primary), 0.08);
cursor: grab;
padding-block: 10px;
padding-inline: 12px;
}
.setting-item::before {
position: absolute;
background-color: var(--item-color, #4caf50);
block-size: 100%;
content: '';
inline-size: 4px;
inset-block-start: 0;
inset-inline-start: 0;
}
.setting-item-inner {
display: flex;
align-items: center;
gap: 8px;
}
.setting-label {
flex: 1;
color: rgba(var(--v-theme-primary), 0.9);
font-size: 0.9rem;
font-weight: 500;
line-height: 1.2;
}
.drag-icon {
opacity: 0.5;
}
@media (width <= 600px) {
.settings-grid {
grid-template-columns: repeat(2, 1fr);
}
}
</style>

View File

@@ -0,0 +1,529 @@
<script setup lang="ts">
import type { DownloaderConf } from '@/api/types'
import { storageAttributes } from '@/api/constants'
import { cloneDeep } from 'lodash-es'
import { useToast } from 'vue-toastification'
import { useI18n } from 'vue-i18n'
import { useDisplay } from 'vuetify'
// 显示器宽度
const display = useDisplay()
// 获取i18n实例
const { t } = useI18n()
// 定义输入
const props = defineProps({
modelValue: {
type: Boolean,
default: true,
},
downloader: {
type: Object as PropType<DownloaderConf>,
required: true,
},
downloaders: {
type: Array as PropType<DownloaderConf[]>,
required: true,
},
})
// 定义触发的自定义事件
const emit = defineEmits(['update:modelValue', 'close', 'change', 'done'])
// 提示框
const $toast = useToast()
// 表单
const downloaderForm = ref()
// 下载器详情弹窗
const downloaderInfoDialog = computed({
get: () => props.modelValue,
set: value => {
emit('update:modelValue', value)
if (!value) emit('close')
},
})
// 下载器详情
const downloaderInfo = ref<DownloaderConf>({
name: '',
type: '',
default: false,
enabled: false,
config: {},
path_mapping: [],
})
// 路径映射行定义
interface PathMappingRow {
id: string
storage: string
download: string
}
// 路径映射行数据
const pathMappingRows = ref<PathMappingRow[]>([])
// 路径前缀选项
const prefixOptions = computed(() => {
return storageAttributes.map(item => ({
title: t(`storage.${item.type}`),
value: item.type,
}))
})
/** 获取路径所属的存储类型。 */
function getStorageType(path: string) {
if (!path) return 'local'
const storage = storageAttributes.find(s => s.type !== 'local' && path.startsWith(`${s.type}:`))
return storage?.type || 'local'
}
/** 将存储类型转换为路径前缀。 */
function storage2Prefix(storage: string) {
return storage === 'local' ? '' : storage + ':'
}
/** 拆分存储路径的前缀和真实路径。 */
function parseStoragePath(path: string): [prefix: string, suffix: string] {
if (!path) return ['', '']
const storage = getStorageType(path)
const prefix = storage2Prefix(storage)
return [prefix, path.slice(prefix.length)]
}
/** 更新单行路径映射的存储前缀。 */
function updateStoragePrefix(row: PathMappingRow, storage: string) {
const [, currentSuffix] = parseStoragePath(row.storage)
const prefix = storage2Prefix(storage)
row.storage = prefix + currentSuffix
}
/** 更新单行路径映射的存储路径主体。 */
function updateStorageSuffix(row: PathMappingRow, suffix: string) {
const [currentPrefix] = parseStoragePath(row.storage)
row.storage = currentPrefix + suffix
}
const pathValidationRules = [
(v: string) => !!v || t('downloader.pathMappingRequired'),
(v: string) => v.startsWith('/') || t('downloader.pathMappingError'),
]
/** 生成路径映射行使用的临时唯一 ID。 */
function generateId() {
return Math.random().toString(36).substring(2, 9)
}
/** 初始化下载器编辑表单数据。 */
function initializeDownloaderInfo() {
downloaderInfo.value = cloneDeep(props.downloader)
pathMappingRows.value = (downloaderInfo.value.path_mapping || []).map(item => ({
id: generateId(),
storage: item[0],
download: item[1],
}))
}
/** 保存下载器编辑结果并通知父级刷新。 */
async function saveDownloaderInfo() {
const { valid } = (await downloaderForm.value?.validate()) || { valid: true }
if (!valid) return
downloaderInfo.value.path_mapping = pathMappingRows.value.map(row => [row.storage, row.download])
if (!downloaderInfo.value.name) {
$toast.error(t('downloader.nameRequired'))
return
}
if (props.downloaders.some(item => item.name === downloaderInfo.value.name && item !== props.downloader)) {
$toast.error(t('downloader.nameDuplicate'))
return
}
if (downloaderInfo.value.default) {
props.downloaders.forEach(item => {
if (item.default && item !== props.downloader) {
item.default = false
$toast.info(t('downloader.defaultChanged'))
}
})
}
downloaderInfoDialog.value = false
emit('change', downloaderInfo.value, props.downloader.name)
emit('done')
}
/** 新增一行路径映射。 */
function addPathMapping() {
pathMappingRows.value.push({
id: generateId(),
storage: '',
download: '',
})
}
/** 移除指定位置的路径映射。 */
function removePathMapping(index: number) {
pathMappingRows.value.splice(index, 1)
}
onMounted(() => {
initializeDownloaderInfo()
})
</script>
<template>
<VDialog
v-if="downloaderInfoDialog"
v-model="downloaderInfoDialog"
scrollable
max-width="40rem"
:fullscreen="!display.mdAndUp.value"
>
<VCard>
<VCardItem class="py-2">
<template #prepend>
<VIcon icon="mdi-download" class="me-2" />
</template>
<VCardTitle>{{ t('common.config') }}</VCardTitle>
<VCardSubtitle>{{ props.downloader.name }}</VCardSubtitle>
</VCardItem>
<VDialogCloseBtn v-model="downloaderInfoDialog" />
<VDivider />
<VCardText>
<VForm ref="downloaderForm">
<VRow>
<VCol cols="12" md="6">
<VSwitch v-model="downloaderInfo.enabled" :label="t('downloader.enabled')" />
</VCol>
<VCol cols="12" md="6">
<VSwitch
v-model="downloaderInfo.default"
:label="t('downloader.default')"
:disabled="!downloaderInfo.enabled"
/>
</VCol>
</VRow>
<VRow v-if="downloaderInfo.type == 'qbittorrent'">
<VCol cols="12" md="6">
<VTextField
v-model="downloaderInfo.name"
:label="t('downloader.name')"
:placeholder="t('downloader.nameRequired')"
:hint="t('downloader.name')"
persistent-hint
active
prepend-inner-icon="mdi-label"
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="downloaderInfo.config.host"
:label="t('downloader.host')"
placeholder="http(s)://ip:port"
:hint="t('downloader.host')"
persistent-hint
active
prepend-inner-icon="mdi-server"
/>
</VCol>
<VCol cols="12">
<VTextField
v-model="downloaderInfo.config.apikey"
type="password"
:label="t('downloader.apiKey')"
:hint="t('downloader.qbittorrentApiKeyHint')"
persistent-hint
active
prepend-inner-icon="mdi-key-variant"
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="downloaderInfo.config.username"
:label="t('downloader.username')"
:hint="t('downloader.username')"
:disabled="!!downloaderInfo.config.apikey"
persistent-hint
active
prepend-inner-icon="mdi-account"
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="downloaderInfo.config.password"
type="password"
:label="t('downloader.password')"
:hint="t('downloader.password')"
:disabled="!!downloaderInfo.config.apikey"
persistent-hint
active
prepend-inner-icon="mdi-lock"
/>
</VCol>
<VCol cols="12" md="6">
<VSwitch
v-model="downloaderInfo.config.category"
:label="t('downloader.category')"
:hint="t('downloader.category')"
persistent-hint
active
/>
</VCol>
<VCol cols="12" md="6">
<VSwitch
v-model="downloaderInfo.config.sequentail"
:label="t('downloader.sequentail')"
:hint="t('downloader.sequentail')"
persistent-hint
active
/>
</VCol>
<VCol cols="12" md="6">
<VSwitch
v-model="downloaderInfo.config.force_resume"
:label="t('downloader.force_resume')"
:hint="t('downloader.force_resume')"
persistent-hint
active
/>
</VCol>
<VCol cols="12" md="6">
<VSwitch
v-model="downloaderInfo.config.first_last_piece"
:label="t('downloader.first_last_piece')"
:hint="t('downloader.first_last_piece')"
persistent-hint
active
/>
</VCol>
</VRow>
<VRow v-else-if="downloaderInfo.type == 'transmission'">
<VCol cols="12" md="6">
<VTextField
v-model="downloaderInfo.name"
:label="t('downloader.name')"
:placeholder="t('downloader.nameRequired')"
:hint="t('downloader.name')"
persistent-hint
active
prepend-inner-icon="mdi-label"
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="downloaderInfo.config.host"
:label="t('downloader.host')"
placeholder="http(s)://ip:port"
:hint="t('downloader.host')"
persistent-hint
active
prepend-inner-icon="mdi-server"
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="downloaderInfo.config.username"
:label="t('downloader.username')"
:hint="t('downloader.username')"
persistent-hint
active
prepend-inner-icon="mdi-account"
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="downloaderInfo.config.password"
type="password"
:label="t('downloader.password')"
:hint="t('downloader.password')"
persistent-hint
active
prepend-inner-icon="mdi-lock"
/>
</VCol>
</VRow>
<VRow v-else-if="downloaderInfo.type == 'rtorrent'">
<VCol cols="12" md="6">
<VTextField
v-model="downloaderInfo.name"
:label="t('downloader.name')"
:placeholder="t('downloader.nameRequired')"
:hint="t('downloader.name')"
persistent-hint
active
prepend-inner-icon="mdi-label"
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="downloaderInfo.config.host"
:label="t('downloader.host')"
placeholder="http(s)://ip:port/RPC2"
:hint="t('downloader.rtorrentHostHint')"
persistent-hint
active
prepend-inner-icon="mdi-server"
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="downloaderInfo.config.username"
:label="t('downloader.username')"
:hint="t('downloader.username')"
persistent-hint
active
prepend-inner-icon="mdi-account"
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="downloaderInfo.config.password"
type="password"
:label="t('downloader.password')"
:hint="t('downloader.password')"
persistent-hint
active
prepend-inner-icon="mdi-lock"
/>
</VCol>
</VRow>
<VRow v-else>
<VCol cols="12" md="6">
<VTextField
v-model="downloaderInfo.type"
:label="t('downloader.type')"
:hint="t('downloader.customTypeHint')"
persistent-hint
active
prepend-inner-icon="mdi-cog"
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="downloaderInfo.name"
:label="t('downloader.name')"
:hint="t('downloader.nameRequired')"
persistent-hint
active
prepend-inner-icon="mdi-label"
/>
</VCol>
</VRow>
<VRow>
<VCol cols="12">
<VDivider class="my-2">
<span class="text-body-1 font-weight-medium">{{ t('downloader.pathMapping') }}</span>
</VDivider>
<div v-if="pathMappingRows.length === 0" class="text-center py-2">
<VIcon icon="mdi-folder-network" size="48" class="text-disabled mb-1" />
<div class="text-body-2 text-disabled">{{ t('common.noData') }}</div>
</div>
<VCard
v-for="(row, index) in pathMappingRows"
:key="row.id"
variant="outlined"
class="path-mapping-card my-2"
>
<VCardText class="pa-3">
<VRow align="center" no-gutters>
<VCol cols="12" class="mb-2">
<div class="d-flex align-center mb-1">
<VIcon icon="mdi-folder-outline" size="18" class="me-1 text-primary" />
<span class="text-caption text-medium-emphasis">{{ t('downloader.storagePath') }}</span>
</div>
<VRow no-gutters>
<VCol cols="12" sm="4" class="path-storage-select-col pe-sm-2">
<VSelect
:model-value="getStorageType(row.storage)"
:items="prefixOptions"
density="compact"
variant="outlined"
hide-details
@update:model-value="v => updateStoragePrefix(row, v)"
/>
</VCol>
<VCol cols="12" sm="8">
<VTextField
:model-value="parseStoragePath(row.storage)[1]"
:placeholder="'/path/to/storage'"
density="compact"
variant="outlined"
hide-details="auto"
:rules="pathValidationRules"
@update:model-value="v => updateStorageSuffix(row, v)"
/>
</VCol>
</VRow>
</VCol>
<VCol cols="12" class="mb-1">
<div class="d-flex align-center justify-center my-1">
<VIcon icon="mdi-arrow-down" size="18" class="text-medium-emphasis" />
</div>
<div class="d-flex align-center mb-1">
<VIcon icon="mdi-download-outline" size="18" class="me-1 text-success" />
<span class="text-caption text-medium-emphasis">{{ t('downloader.downloadPath') }}</span>
</div>
<VRow no-gutters>
<VCol cols="12" sm="4" class="d-none d-sm-block" />
<VCol cols="12" sm="8">
<VTextField
v-model="row.download"
:placeholder="'/path/to/download'"
density="compact"
variant="outlined"
hide-details="auto"
:rules="pathValidationRules"
/>
</VCol>
</VRow>
</VCol>
<VCol cols="12" class="d-flex justify-end pt-1">
<IconBtn variant="text" color="error" size="small" @click="removePathMapping(index)">
<VIcon icon="mdi-delete-outline" />
</IconBtn>
</VCol>
</VRow>
</VCardText>
</VCard>
<VBtn
variant="tonal"
color="primary"
prepend-icon="mdi-plus-circle-outline"
@click="addPathMapping"
class="mt-1"
size="small"
>
{{ t('common.add') }} {{ t('downloader.pathMapping') }}
</VBtn>
</VCol>
</VRow>
</VForm>
</VCardText>
<VCardActions class="pt-3">
<VBtn @click="saveDownloaderInfo" prepend-icon="mdi-content-save" class="px-5">
{{ t('common.save') }}
</VBtn>
</VCardActions>
</VCard>
</VDialog>
</template>
<style scoped>
.path-mapping-card {
border-color: rgba(var(--v-border-color), 0.08) !important;
}
@media (max-width: 599.98px) {
.path-storage-select-col {
margin-block-end: 8px;
}
}
</style>

View File

@@ -0,0 +1,63 @@
<script lang="ts" setup>
import { useI18n } from 'vue-i18n'
const props = defineProps({
modelValue: {
type: Boolean,
default: true,
},
name: {
type: String,
default: '',
},
})
const emit = defineEmits<{
(event: 'close'): void
(event: 'create'): void
(event: 'update:modelValue', value: boolean): void
(event: 'update:name', value: string): void
}>()
const { t } = useI18n()
const dialogVisible = computed({
get: () => props.modelValue,
set: value => emit('update:modelValue', value),
})
const folderName = computed({
get: () => props.name,
set: value => emit('update:name', value),
})
// 关闭新建目录弹窗并通知共享弹窗 Host 回收实例。
function closeDialog() {
emit('close')
emit('update:modelValue', false)
}
</script>
<template>
<VDialog v-model="dialogVisible" max-width="35rem">
<VCard>
<VCardItem>
<template #prepend>
<VIcon icon="mdi-folder-plus-outline" class="me-2" />
</template>
<VCardTitle>{{ t('file.newFolder') }}</VCardTitle>
</VCardItem>
<VDialogCloseBtn @click="closeDialog" />
<VDivider />
<VCardText>
<VTextField v-model="folderName" :label="t('common.name')" prepend-inner-icon="mdi-format-text" />
</VCardText>
<VCardActions>
<div class="flex-grow-1" />
<VBtn :disabled="!folderName" prepend-icon="mdi-folder-plus" class="px-5 me-3" @click="emit('create')">
{{ t('common.create') }}
</VBtn>
</VCardActions>
</VCard>
</VDialog>
</template>

View File

@@ -0,0 +1,94 @@
<script lang="ts" setup>
import type { FileItem } from '@/api/types'
import { useI18n } from 'vue-i18n'
const props = defineProps({
item: Object as PropType<FileItem>,
loading: {
type: Boolean,
default: false,
},
modelValue: {
type: Boolean,
default: true,
},
name: {
type: String,
default: '',
},
recursive: {
type: Boolean,
default: false,
},
})
const emit = defineEmits<{
(event: 'auto-name'): void
(event: 'close'): void
(event: 'rename'): void
(event: 'update:modelValue', value: boolean): void
(event: 'update:name', value: string): void
(event: 'update:recursive', value: boolean): void
}>()
const { t } = useI18n()
const dialogVisible = computed({
get: () => props.modelValue,
set: value => emit('update:modelValue', value),
})
const renameName = computed({
get: () => props.name,
set: value => emit('update:name', value),
})
const includeSubfolders = computed({
get: () => props.recursive,
set: value => emit('update:recursive', value),
})
// 关闭弹窗并通知共享弹窗 Host 回收当前实例。
function closeDialog() {
emit('close')
emit('update:modelValue', false)
}
</script>
<template>
<VDialog v-model="dialogVisible" max-width="35rem">
<VCard>
<VCardItem>
<template #prepend>
<VIcon icon="mdi-pencil" class="me-2" />
</template>
<VCardTitle>{{ t('file.rename') }}</VCardTitle>
</VCardItem>
<VDialogCloseBtn @click="closeDialog" />
<VDivider />
<VCardText>
<VRow>
<VCol cols="12">
<VTextField
v-model="renameName"
:label="t('file.newName')"
:loading="loading"
prepend-inner-icon="mdi-format-text"
/>
</VCol>
<VCol v-if="item && item.type == 'dir'" cols="12">
<VSwitch v-model="includeSubfolders" :label="t('file.includeSubfolders')" />
</VCol>
</VRow>
</VCardText>
<VCardActions>
<VBtn color="success" prepend-icon="mdi-magic" class="px-5 me-3" @click="emit('auto-name')">
{{ t('file.autoRecognizeName') }}
</VBtn>
<VBtn :disabled="!renameName" prepend-icon="mdi-check" class="px-5 me-3" @click="emit('rename')">
{{ t('common.confirm') }}
</VBtn>
</VCardActions>
</VCard>
</VDialog>
</template>

View File

@@ -0,0 +1,314 @@
<script lang="ts" setup>
import { copyToClipboard } from '@/@core/utils/navigator'
import { CustomRule, FilterRuleGroup } from '@/api/types'
import FilterRuleCard from '@/components/cards/FilterRuleCard.vue'
import { openSharedDialog } from '@/composables/useSharedDialog'
import { useToast } from 'vue-toastification'
import { cloneDeep } from 'lodash-es'
import { useI18n } from 'vue-i18n'
import { useDisplay } from 'vuetify'
// 显示器宽度
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({
modelValue: {
type: Boolean,
default: true,
},
// 单个规则组
group: {
type: Object as PropType<FilterRuleGroup>,
required: true,
},
// 所有规则组
groups: {
type: Array as PropType<FilterRuleGroup[]>,
required: true,
},
// 媒体类型字典
categories: {
type: Object as PropType<{ [key: string]: any }>,
required: true,
},
// 自定义规则列表
custom_rules: Array as PropType<CustomRule[]>,
})
// 规则卡片类型
interface FilterCard {
// 优先级
pri: string
// 已选规则
rules: string[]
}
// 提示框
const $toast = useToast()
// 定义触发的自定义事件
const emit = defineEmits(['update:modelValue', 'close', 'change', 'done'])
// 规则详情弹窗
const groupInfoDialog = computed({
get: () => props.modelValue,
set: value => {
emit('update:modelValue', value)
if (!value) emit('close')
},
})
// 规则详情
const groupInfo = ref<FilterRuleGroup>({
name: props.group?.name ?? '',
rule_string: props.group?.rule_string ?? '',
media_type: props.group?.media_type ?? '',
category: props.group?.category ?? '',
})
// 媒体类型字典
const mediaTypeItems = [
{ title: t('common.all'), value: '' },
{ title: t('mediaType.movie'), value: '电影' },
{ title: t('mediaType.tv'), value: '电视剧' },
]
// 根据选中的媒体类型,获取对应的媒体类别
const getCategories = computed(() => {
const default_value = [{ title: t('common.all'), value: '' }]
if (!props.categories || !groupInfo.value.media_type || !props.categories[groupInfo.value.media_type]) {
return default_value
}
return default_value.concat(props.categories[groupInfo.value.media_type] || [])
})
// 规则组规则卡片列表
const filterRuleCards = ref<FilterCard[]>([])
/** 更新指定优先级规则卡片的选中规则。 */
function updateFilterCardValue(pri: string, rules: string[]) {
const card = filterRuleCards.value.find(card => card.pri === pri)
if (card && Array.isArray(rules)) card.rules = rules
}
/** 移除指定优先级规则卡片并重排优先级。 */
function filterCardClose(pri: string) {
filterRuleCards.value = filterRuleCards.value
.filter(card => card.pri !== pri)
.map((card, index) => {
card.pri = (index + 1).toString()
return card
})
}
/** 将当前规则组规则串复制到剪贴板。 */
async function shareRules() {
if (filterRuleCards.value.length === 0) return
const value = filterRuleCards.value
.filter(card => Array.isArray(card.rules) && card.rules.length > 0)
.map(card => card.rules.join('&'))
.join('>')
try {
let success
success = copyToClipboard(value)
if (await success) $toast.success(t('filterRule.shareSuccess'))
else $toast.error(t('filterRule.shareFailed'))
} catch (error) {
$toast.error(t('filterRule.shareFailed'))
console.error(error)
}
}
/** 打开共享导入弹窗并导入规则串。 */
async function importRules(ruleType: string) {
openSharedDialog(
ImportCodeDialog,
{
title: t('filterRule.import'),
dataType: ruleType,
},
{
save: saveCodeString,
},
{ closeOn: ['close', 'save'] },
)
}
/** 保存导入的规则代码并覆盖当前规则卡片。 */
function saveCodeString(type: string, code: any) {
try {
code = code.value
if (type === 'priority') {
// 解析值
if (!code) return
// 首尾增加空格
if (!code.startsWith(' ')) code = ` ${code}`
if (!code.endsWith(' ')) code = `${code} `
const groups = code.split('>')
filterRuleCards.value = groups.map((group: string, index: number) => ({
pri: (index + 1).toString(),
rules: group.split('&').filter(rule => rule),
}))
}
} catch (error) {
$toast.error(t('filterRule.importFailed'))
console.error(error)
}
}
/** 新增一个空的规则优先级卡片。 */
function addFilterCard() {
const pri = (filterRuleCards.value.length + 1).toString()
const newCard: FilterCard = { pri, rules: [] }
filterRuleCards.value.push(newCard)
}
/** 根据列表的拖动顺序更新优先级。 */
function dragOrderEnd() {
filterRuleCards.value.forEach((card, index) => {
card.pri = (index + 1).toString()
})
}
/** 初始化规则组编辑数据。 */
function opengroupInfoDialog() {
groupInfo.value = cloneDeep(props.group)
if (props.group.rule_string) {
filterRuleCards.value = props.group.rule_string.split('>').map((group: string, index: number) => ({
pri: (index + 1).toString(),
rules: group.split('&').filter(rule => rule),
}))
}
groupInfoDialog.value = true
}
/** 保存规则组编辑结果并通知父级刷新。 */
function saveGroupInfo() {
if (!groupInfo.value.name.trim()) {
$toast.error(t('filterRule.nameRequired'))
return
}
if (props.groups.some(item => item.name === groupInfo.value.name && item !== props.group)) {
$toast.error(t('filterRule.nameDuplicate'))
return
}
groupInfoDialog.value = false
groupInfo.value.rule_string = filterRuleCards.value
.filter(card => Array.isArray(card.rules) && card.rules.length > 0)
.map(card => card.rules.join('&'))
.join('>')
emit('change', groupInfo.value, props.group.name)
emit('done')
}
/** 关闭规则组编辑弹窗。 */
function onClose() {
emit('close')
}
onMounted(() => {
opengroupInfoDialog()
})
</script>
<template>
<VDialog
v-if="groupInfoDialog"
v-model="groupInfoDialog"
scrollable
max-width="80rem"
:fullscreen="!display.mdAndUp.value"
>
<VCard :title="`${props.group.name} - ${t('filterRule.title')}`">
<VDialogCloseBtn v-model="groupInfoDialog" />
<VDivider />
<VCardItem class="pt-1">
<VRow class="mt-1">
<VCol cols="12" md="6">
<VTextField
v-model="groupInfo.name"
:label="t('filterRule.groupName')"
:placeholder="t('filterRule.nameRequired')"
:hint="t('filterRule.groupName')"
persistent-hint
active
prepend-inner-icon="mdi-label"
/>
</VCol>
<VCol cols="6" md="3">
<VAutocomplete
v-model="groupInfo.media_type"
:label="t('filterRule.mediaType')"
:items="mediaTypeItems"
:hint="t('filterRule.mediaType')"
persistent-hint
active
prepend-inner-icon="mdi-movie-open"
/>
</VCol>
<VCol cols="6" md="3">
<VAutocomplete
v-model="groupInfo.category"
:items="getCategories"
:label="t('filterRule.category')"
:hint="t('filterRule.category')"
persistent-hint
active
prepend-inner-icon="mdi-folder-open"
/>
</VCol>
</VRow>
</VCardItem>
<VCardText>
<Draggable
v-model="filterRuleCards"
handle=".cursor-move"
item-key="pri"
tag="div"
@end="dragOrderEnd"
:component-data="{ 'class': 'grid gap-3 grid-filterrule-card' }"
>
<template #item="{ element }">
<FilterRuleCard
:pri="element.pri"
:maxpri="filterRuleCards.length.toString()"
:rules="element.rules"
:custom_rules="props.custom_rules"
@changed="updateFilterCardValue"
@close="filterCardClose(element.pri)"
/>
</template>
</Draggable>
<div class="text-center" v-if="filterRuleCards.length == 0">{{ t('filterRule.add') }}</div>
</VCardText>
<VCardActions class="pt-3">
<VBtn color="primary" @click="addFilterCard">
<VIcon icon="mdi-plus" />
</VBtn>
<VBtn color="success" @click="importRules('priority')">
<VIcon icon="mdi-import" />
</VBtn>
<VBtn color="info" @click="shareRules">
<VIcon icon="mdi-share" />
</VBtn>
<VSpacer />
<VBtn @click="saveGroupInfo" prepend-icon="mdi-content-save" class="px-5">
{{ t('common.save') }}
</VBtn>
</VCardActions>
</VCard>
</VDialog>
</template>

View File

@@ -0,0 +1,82 @@
<script setup lang="ts">
import type { LlmProviderAuthSession } from '@/composables/useLlmProviderDirectory'
import { useI18n } from 'vue-i18n'
const { t } = useI18n()
const props = withDefaults(
defineProps<{
authSession?: LlmProviderAuthSession | null
modelValue?: boolean
polling?: boolean
popupBlocked?: boolean
}>(),
{
authSession: null,
modelValue: true,
polling: false,
popupBlocked: false,
},
)
const emit = defineEmits<{
(event: 'close'): void
(event: 'openAuthPage'): void
(event: 'poll'): void
(event: 'update:modelValue', value: boolean): void
}>()
const visible = computed({
get: () => props.modelValue,
set: value => {
emit('update:modelValue', value)
if (!value) emit('close')
},
})
// 关闭授权弹窗并通知调用方停止轮询。
function closeDialog() {
visible.value = false
}
</script>
<template>
<VDialog v-if="visible" v-model="visible" max-width="560">
<VCard>
<VCardTitle>{{ t('setting.system.llmProviderAuthDialogTitle') }}</VCardTitle>
<VCardText class="d-flex flex-column ga-4">
<VAlert v-if="props.authSession?.instructions" type="info" variant="tonal">
{{ props.authSession.instructions }}
</VAlert>
<VAlert v-if="props.popupBlocked" type="warning" variant="tonal">
{{ t('setting.system.llmProviderPopupBlocked') }}
</VAlert>
<div v-if="props.authSession?.user_code">
<div class="text-caption text-medium-emphasis mb-1">{{ t('setting.system.llmProviderDeviceCode') }}</div>
<div class="text-h5 font-weight-bold">{{ props.authSession.user_code }}</div>
</div>
<div v-if="props.authSession?.message" class="text-body-2">
{{ props.authSession.message }}
</div>
<div class="d-flex flex-wrap ga-2">
<VBtn color="primary" prepend-icon="mdi-open-in-new" @click="emit('openAuthPage')">
{{ t('setting.system.llmProviderOpenAuthPage') }}
</VBtn>
<VBtn variant="tonal" prepend-icon="mdi-refresh" :loading="props.polling" @click="emit('poll')">
{{ t('setting.system.llmProviderCheckAuthStatus') }}
</VBtn>
</div>
</VCardText>
<VCardActions>
<VSpacer />
<VBtn variant="text" @click="closeDialog">
{{ t('common.close') }}
</VBtn>
</VCardActions>
</VCard>
</VDialog>
</template>

View File

@@ -0,0 +1,102 @@
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
const { t } = useI18n()
const props = withDefaults(
defineProps<{
errorMessage?: string
modelValue?: boolean
otpPassword?: string
passkeyLoading?: boolean
}>(),
{
errorMessage: '',
modelValue: true,
otpPassword: '',
passkeyLoading: false,
},
)
const emit = defineEmits<{
(event: 'close'): void
(event: 'otp'): void
(event: 'passkey'): void
(event: 'update:modelValue', value: boolean): void
(event: 'update:otpPassword', value: string): void
}>()
const visible = computed({
get: () => props.modelValue,
set: value => {
emit('update:modelValue', value)
if (!value) emit('close')
},
})
const otpValue = computed({
get: () => props.otpPassword,
set: value => emit('update:otpPassword', value),
})
// 提交 OTP 登录请求。
function submitOtp() {
emit('otp')
}
</script>
<template>
<VDialog v-if="visible" v-model="visible" max-width="400" persistent>
<VCard>
<VCardTitle class="text-h5 text-center mt-4 pb-2">{{ t('login.secondaryVerification') }}</VCardTitle>
<VCardText class="pt-0">
<p class="text-center mb-4">{{ t('login.mfa.selectVerificationMethod') }}</p>
<VCard variant="tonal" class="mb-3">
<VCardText>
<VForm @submit.prevent="submitOtp">
<VTextField
v-model="otpValue"
:label="t('login.otpCode')"
:placeholder="t('login.otpPlaceholder')"
type="text"
name="otp"
id="otp"
autocomplete="one-time-code"
inputmode="numeric"
prepend-inner-icon="mdi-shield-key"
class="mb-2"
/>
<VBtn block type="submit" color="primary" :disabled="!otpValue">
{{ t('login.loginWithOtp') }}
</VBtn>
</VForm>
</VCardText>
</VCard>
<VCard variant="tonal">
<VCardText>
<p class="text-body-2 mb-2">{{ t('login.orUsePasskey') }}</p>
<VBtn
block
variant="tonal"
color="success"
class="passkey-btn"
prepend-icon="material-symbols:passkey"
:loading="props.passkeyLoading"
@click="emit('passkey')"
>
{{ t('login.verifyWithPasskey') }}
</VBtn>
</VCardText>
</VCard>
<VAlert v-if="props.errorMessage" type="error" variant="tonal" class="mt-3">
{{ props.errorMessage }}
</VAlert>
<VBtn block variant="text" class="mt-4" @click="visible = false">{{ t('common.cancel') }}</VBtn>
</VCardText>
</VCard>
</VDialog>
</template>

View File

@@ -0,0 +1,601 @@
<script setup lang="ts">
import api from '@/api'
import type { MediaServerConf, MediaServerLibrary } from '@/api/types'
import { cloneDeep } from 'lodash-es'
import { useToast } from 'vue-toastification'
import { useI18n } from 'vue-i18n'
import { useDisplay } from 'vuetify'
// 显示器宽度
const display = useDisplay()
// 获取i18n实例
const { t } = useI18n()
// 定义输入
const props = defineProps({
modelValue: {
type: Boolean,
default: true,
},
mediaserver: {
type: Object as PropType<MediaServerConf>,
required: true,
},
mediaservers: {
type: Array as PropType<MediaServerConf[]>,
required: true,
},
})
// 定义触发的自定义事件
const emit = defineEmits(['update:modelValue', 'close', 'done', 'change'])
// 提示框
const $toast = useToast()
// 媒体服务器详情弹窗
const mediaServerInfoDialog = computed({
get: () => props.modelValue,
set: value => {
emit('update:modelValue', value)
if (!value) emit('close')
},
})
// 媒体服务器详情
const mediaServerInfo = ref<MediaServerConf>({
name: '',
type: '',
enabled: false,
config: {},
})
// 同步媒体库选项
const librariesOptions = ref<{ title: string; value: string | undefined }[]>([
{
title: t('common.all'),
value: 'all',
},
])
const ugreenScanModeOptions = computed(() => [
{ title: t('mediaserver.scanModeOptions.newAndModified'), value: 'new_and_modified' },
{ title: t('mediaserver.scanModeOptions.supplementMissing'), value: 'supplement_missing' },
{ title: t('mediaserver.scanModeOptions.fullOverride'), value: 'full_override' },
])
/** 初始化媒体服务器编辑表单数据。 */
function initializeMediaServerInfo() {
loadLibrary(props.mediaserver.name)
mediaServerInfo.value = cloneDeep(props.mediaserver)
if (mediaServerInfo.value.type === 'ugreen') {
mediaServerInfo.value.config = mediaServerInfo.value.config || {}
if (!mediaServerInfo.value.config.scan_mode) {
mediaServerInfo.value.config.scan_mode = 'supplement_missing'
}
if (mediaServerInfo.value.config.verify_ssl === undefined) {
mediaServerInfo.value.config.verify_ssl = true
}
}
if (!props.mediaserver.sync_libraries) {
mediaServerInfo.value.sync_libraries = ['all']
}
}
/** 保存媒体服务器编辑结果并通知父级刷新。 */
function saveMediaServerInfo() {
if (!mediaServerInfo.value.name) {
$toast.error(t('common.nameRequired'))
return
}
if (props.mediaservers.some(item => item.name === mediaServerInfo.value.name && item !== props.mediaserver)) {
$toast.error(t('common.nameExists', { name: mediaServerInfo.value.name }))
return
}
mediaServerInfoDialog.value = false
emit('change', mediaServerInfo.value, props.mediaserver.name)
emit('done')
}
/** 调用 API 查询指定媒体服务器的媒体库列表。 */
async function loadLibrary(server: string) {
try {
const result: MediaServerLibrary[] = await api.get('mediaserver/library', { params: { server } })
if (result && result.length > 0) {
librariesOptions.value = result.map(item => ({
title: item.name,
value: item.id?.toString(),
}))
} else {
librariesOptions.value = []
}
librariesOptions.value.unshift({
title: t('common.all'),
value: 'all',
})
} catch (e) {
console.log(e)
}
}
onMounted(() => {
initializeMediaServerInfo()
})
</script>
<template>
<VDialog
v-if="mediaServerInfoDialog"
v-model="mediaServerInfoDialog"
scrollable
max-width="40rem"
:fullscreen="!display.mdAndUp.value"
>
<VCard>
<VCardItem class="py-2">
<template #prepend>
<VIcon icon="mdi-cog" class="me-2" />
</template>
<VCardTitle>{{ t('common.config') }}</VCardTitle>
<VCardSubtitle>{{ props.mediaserver.name }}</VCardSubtitle>
</VCardItem>
<VDialogCloseBtn v-model="mediaServerInfoDialog" />
<VDivider />
<VCardText>
<VForm>
<VRow>
<VCol cols="12" md="6">
<VSwitch v-model="mediaServerInfo.enabled" :label="t('mediaserver.enableMediaServer')" />
</VCol>
</VRow>
<VRow v-if="mediaServerInfo.type == 'emby'">
<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" md="6">
<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
v-model="mediaServerInfo.config.apikey"
:label="t('mediaserver.apiKey')"
:hint="t('mediaserver.embyApiKeyHint')"
persistent-hint
active
prepend-inner-icon="mdi-key"
/>
</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 == '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
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" md="6">
<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.apikey"
:label="t('mediaserver.apiKey')"
:hint="t('mediaserver.jellyfinApiKeyHint')"
persistent-hint
active
prepend-inner-icon="mdi-key"
/>
</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 == 'trimemedia'">
<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')"
active
prepend-inner-icon="mdi-account"
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
type="password"
v-model="mediaServerInfo.config.password"
:label="t('mediaserver.password')"
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 == 'ugreen'">
<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')"
active
prepend-inner-icon="mdi-account"
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
type="password"
v-model="mediaServerInfo.config.password"
:label="t('mediaserver.password')"
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>
<VCol cols="12" md="6">
<VSelect
v-model="mediaServerInfo.config.scan_mode"
:label="t('mediaserver.scanMode')"
:items="ugreenScanModeOptions"
:hint="t('mediaserver.scanModeHint')"
persistent-hint
active
prepend-inner-icon="mdi-radar"
/>
</VCol>
<VCol cols="12" md="6">
<VSwitch
v-model="mediaServerInfo.config.verify_ssl"
:label="t('mediaserver.verifySsl')"
:hint="t('mediaserver.verifySslHint')"
persistent-hint
color="primary"
inset
/>
</VCol>
</VRow>
<VRow v-else-if="mediaServerInfo.type == 'plex'">
<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" md="6">
<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.token"
:label="t('mediaserver.plexToken')"
:hint="t('mediaserver.plexTokenHint')"
persistent-hint
active
prepend-inner-icon="mdi-key"
/>
</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>
<VCol cols="12" md="6">
<VTextField
v-model="mediaServerInfo.type"
:label="t('mediaserver.type')"
:hint="t('mediaserver.customTypeHint')"
persistent-hint
prepend-inner-icon="mdi-cog"
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
:label="t('common.name')"
:hint="t('mediaserver.nameRequired')"
persistent-hint
prepend-inner-icon="mdi-label"
/>
</VCol>
</VRow>
</VForm>
</VCardText>
<VCardActions class="pt-3">
<VBtn @click="saveMediaServerInfo" prepend-icon="mdi-content-save" class="px-5">
{{ t('common.confirm') }}
</VBtn>
</VCardActions>
</VCard>
</VDialog>
</template>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,157 @@
<script setup lang="ts">
import { useDisplay } from 'vuetify'
import { useI18n } from 'vue-i18n'
const { t } = useI18n()
const display = useDisplay()
const props = withDefaults(
defineProps<{
content?: string
editorTheme?: string
modelValue?: boolean
subtitle?: string
templateType?: string
}>(),
{
content: '{}',
editorTheme: 'monokai',
modelValue: true,
subtitle: '',
templateType: '',
},
)
const emit = defineEmits<{
(event: 'close'): void
(event: 'save', value: string): void
(event: 'update:content', value: string): void
(event: 'update:modelValue', value: boolean): void
}>()
const visible = computed({
get: () => props.modelValue,
set: value => {
emit('update:modelValue', value)
if (!value) emit('close')
},
})
const editableContent = ref(props.content)
const editorOptions = {
displayIndentGuides: true,
fontSize: 14,
highlightActiveLine: true,
scrollPastEnd: 0.2,
showPrintMargin: false,
tabSize: 2,
}
watch(
() => props.content,
value => {
editableContent.value = value
},
)
watch(editableContent, value => {
emit('update:content', value)
})
// 提交通知模板内容,由调用方负责保存到后端。
function submitTemplate() {
emit('save', editableContent.value)
}
</script>
<template>
<VDialog v-if="visible" v-model="visible" max-width="50rem" :fullscreen="!display.mdAndUp.value">
<VCard class="notification-template-editor-dialog">
<VCardItem class="template-editor-header py-3">
<template #prepend>
<VAvatar color="primary" variant="tonal" rounded size="40" class="me-2">
<VIcon icon="mdi-code-json" size="22" />
</VAvatar>
</template>
<VCardTitle>
{{ t('setting.notification.templateConfigTitle') }}
</VCardTitle>
<VCardSubtitle>
{{ props.subtitle }}
</VCardSubtitle>
<VDialogCloseBtn v-model="visible" />
</VCardItem>
<div class="template-editor-body">
<VAceEditor
:key="`${props.templateType}-jinja2-json`"
v-model:value="editableContent"
lang="jinja2_json"
:theme="props.editorTheme"
:options="editorOptions"
wrap
class="template-ace-editor"
/>
</div>
<VCardActions class="template-editor-actions">
<VBtn color="primary" prepend-icon="mdi-content-save" class="px-5" @click="submitTemplate">
{{ t('common.save') }}
</VBtn>
</VCardActions>
</VCard>
</VDialog>
</template>
<style scoped>
.notification-template-editor-dialog {
display: flex;
flex-direction: column;
max-block-size: calc(100dvh - 2rem);
overflow: hidden;
}
.template-editor-header {
flex: 0 0 auto;
border-block-end: 1px solid rgba(var(--v-theme-on-surface), 0.08);
}
.template-editor-body {
flex: 1 1 auto;
min-block-size: 0;
}
.template-ace-editor {
overflow: hidden;
background: rgb(var(--v-theme-surface));
block-size: min(62vh, 34rem);
inline-size: 100%;
}
.template-editor-actions {
flex: 0 0 auto;
border-block-start: 1px solid rgba(var(--v-theme-on-surface), 0.08);
padding-block: 0.875rem;
padding-inline: 1rem;
}
@media (width <= 960px) {
.notification-template-editor-dialog {
block-size: 100dvh;
max-block-size: 100dvh;
}
.template-editor-body {
display: flex;
flex-direction: column;
}
.template-ace-editor {
flex: 1 1 auto;
min-block-size: 0;
block-size: auto;
}
.template-editor-actions {
padding-block-end: max(0.875rem, calc(env(safe-area-inset-bottom) + 0.75rem));
}
}
</style>

View File

@@ -0,0 +1,175 @@
<script setup lang="ts">
import { useGlobalOfflineStatus } from '@/composables/useOfflineStatus'
const props = withDefaults(
defineProps<{
modelValue?: boolean
type?: 'offline' | 'online'
}>(),
{
modelValue: true,
type: 'offline',
},
)
const { t } = useI18n()
const { isOnline, canPerformNetworkAction, getOfflineMessage } = useGlobalOfflineStatus()
// 重试连接
const retrying = ref(false)
/** 尝试请求静态资源来触发网络状态重新检测。 */
async function handleRetry() {
if (retrying.value) return
retrying.value = true
try {
await fetch('/favicon.ico?' + new Date().getTime(), {
method: 'HEAD',
cache: 'no-cache',
})
setTimeout(() => {
retrying.value = false
}, 1000)
} catch (error) {
retrying.value = false
}
}
// 状态文本
const statusText = computed(() => {
if (props.type === 'online') {
return t('app.onlineMessage')
}
return getOfflineMessage()
})
// 图标
const statusIcon = computed(() => {
return props.type === 'online' ? 'mdi-wifi' : 'mdi-wifi-off'
})
// 颜色主题
const colorTheme = computed(() => {
return props.type === 'online' ? 'success' : 'error'
})
</script>
<template>
<VDialog :model-value="props.modelValue" persistent max-width="420" scrollable>
<VCard class="offline-dialog">
<div class="status-icon-wrapper">
<div class="status-icon-bg">
<VIcon :icon="statusIcon" size="48" :color="colorTheme" />
</div>
</div>
<VCardText class="text-center">
<h2 class="offline-title mb-4">
{{ props.type === 'online' ? t('app.online') : t('app.offline') }}
</h2>
<p class="offline-message mb-6">
{{ statusText }}
</p>
<div class="action-section mb-6">
<VBtn
v-if="props.type === 'offline'"
:loading="retrying"
:color="colorTheme"
size="default"
variant="flat"
@click="handleRetry"
>
<VIcon icon="mdi-refresh" class="me-2" />
{{ retrying ? t('common.checking') : t('common.retry') }}
</VBtn>
</div>
<div class="status-indicators">
<VChip
:color="isOnline ? 'success' : 'error'"
:prepend-icon="isOnline ? 'mdi-wifi' : 'mdi-wifi-off'"
variant="tonal"
size="small"
class="me-2"
>
{{ isOnline ? t('common.networkOnline') : t('common.networkOffline') }}
</VChip>
<VChip
:color="canPerformNetworkAction ? 'success' : 'warning'"
:prepend-icon="canPerformNetworkAction ? 'mdi-check-circle' : 'mdi-alert-circle'"
variant="tonal"
size="small"
>
{{ canPerformNetworkAction ? t('common.serviceAvailable') : t('common.serviceUnavailable') }}
</VChip>
</div>
</VCardText>
</VCard>
</VDialog>
</template>
<style scoped>
.offline-dialog {
border-radius: 16px;
}
.status-icon-wrapper {
padding-block: 24px 0;
padding-inline: 24px;
text-align: center;
}
.status-icon-bg {
position: relative;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
animation: icon-pulse 3s ease-in-out infinite;
background: rgba(var(--v-theme-surface-variant), 0.5);
block-size: 80px;
inline-size: 80px;
margin-block: 0;
margin-inline: auto;
}
.status-icon-bg::before {
position: absolute;
z-index: -1;
border-radius: 50%;
animation: icon-glow 2s ease-in-out infinite alternate;
background: linear-gradient(45deg, rgb(var(--v-theme-primary)), rgb(var(--v-theme-secondary)));
content: '';
inset: -3px;
opacity: 0.1;
}
@keyframes icon-pulse {
0%,
100% {
transform: scale(1);
}
50% {
transform: scale(1.05);
}
}
@keyframes icon-glow {
0% {
opacity: 0.1;
transform: scale(1);
}
100% {
opacity: 0.3;
transform: scale(1.1);
}
}
</style>

View File

@@ -206,6 +206,7 @@ watch(
passkeyList.value = []
}
},
{ immediate: true },
)
</script>

View File

@@ -0,0 +1,172 @@
<script setup lang="ts">
import type { Plugin } from '@/api/types'
import { useI18n } from 'vue-i18n'
import { useDisplay } from 'vuetify'
// 多语言
const { t } = useI18n()
// 显示器宽度
const display = useDisplay()
// 输入参数
const props = defineProps({
modelValue: {
type: Boolean,
default: true,
},
plugin: {
type: Object as PropType<Plugin>,
required: true,
},
loading: {
type: Boolean,
default: false,
},
})
// 定义触发的自定义事件
const emit = defineEmits(['update:modelValue', 'close', 'clone'])
// 弹窗显示状态
const visible = computed({
get: () => props.modelValue,
set: value => {
emit('update:modelValue', value)
if (!value) emit('close')
},
})
// 插件分身表单
const cloneForm = ref({
suffix: '',
name: '',
description: '',
version: '',
icon: '',
})
/** 初始化插件分身表单。 */
function initializeCloneForm() {
cloneForm.value = {
suffix: '',
name: t('plugin.cloneDefaultName', { name: props.plugin?.plugin_name }),
description: t('plugin.cloneDefaultDescription', { description: props.plugin?.plugin_desc }),
version: props.plugin?.plugin_version || '1.0',
icon: props.plugin?.plugin_icon || '',
}
}
/** 提交插件分身表单。 */
function submitClone() {
emit('clone', { ...cloneForm.value })
}
onMounted(() => {
initializeCloneForm()
})
</script>
<template>
<VDialog v-if="visible" v-model="visible" width="600" scrollable :fullscreen="!display.mdAndUp.value">
<VCard>
<VCardItem class="py-2">
<template #prepend>
<VIcon icon="mdi-content-copy" class="me-2" />
</template>
<VCardTitle>{{ t('plugin.cloneTitle') }}</VCardTitle>
<VCardSubtitle>{{ t('plugin.cloneSubtitle', { name: props.plugin?.plugin_name }) }}</VCardSubtitle>
</VCardItem>
<VDialogCloseBtn v-model="visible" />
<VDivider />
<VCardText>
<VForm>
<VRow>
<VCol cols="12" md="6">
<VTextField
v-model="cloneForm.suffix"
:label="t('plugin.suffix') + ' *'"
:placeholder="t('plugin.suffixPlaceholder')"
:hint="t('plugin.suffixHint')"
persistent-hint
:rules="[
v => !!v || t('plugin.suffixRequired'),
v => /^[a-zA-Z0-9]+$/.test(v) || t('plugin.suffixFormatError'),
v => v.length <= 20 || t('plugin.suffixLengthError'),
]"
required
prepend-inner-icon="mdi-tag"
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="cloneForm.name"
:label="t('plugin.cloneName')"
:placeholder="t('plugin.cloneNamePlaceholder')"
:hint="t('plugin.cloneNameHint')"
persistent-hint
prepend-inner-icon="mdi-rename-box"
/>
</VCol>
<VCol cols="12">
<VTextField
v-model="cloneForm.description"
:label="t('plugin.cloneDescriptionLabel')"
:placeholder="t('plugin.cloneDescriptionPlaceholder')"
:hint="t('plugin.cloneDescriptionHint')"
persistent-hint
prepend-inner-icon="mdi-text"
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="cloneForm.version"
:label="t('plugin.cloneVersion')"
:placeholder="t('plugin.cloneVersionPlaceholder')"
:hint="t('plugin.cloneVersionHint')"
persistent-hint
prepend-inner-icon="mdi-numeric"
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="cloneForm.icon"
:label="t('plugin.cloneIcon')"
:placeholder="t('plugin.cloneIconPlaceholder')"
:hint="t('plugin.cloneIconHint')"
persistent-hint
prepend-inner-icon="mdi-image"
/>
</VCol>
<VCol cols="12">
<VAlert type="warning" variant="tonal" density="compact" class="mt-2" icon="mdi-alert-circle-outline">
<div class="text-body-2">
<strong>{{ t('common.notice') }}</strong
>{{ t('plugin.cloneNotice') }}
</div>
</VAlert>
</VCol>
</VRow>
</VForm>
</VCardText>
<VCardActions class="pt-3">
<VSpacer />
<VBtn
color="primary"
@click="submitClone"
prepend-icon="mdi-content-copy"
class="px-5"
:disabled="!cloneForm.suffix.trim()"
:loading="props.loading"
>
{{ t('plugin.createClone') }}
</VBtn>
</VCardActions>
</VCard>
</VDialog>
</template>

View File

@@ -0,0 +1,65 @@
<script lang="ts" setup>
import { useI18n } from 'vue-i18n'
const props = defineProps({
modelValue: {
type: Boolean,
default: true,
},
name: {
type: String,
default: '',
},
})
const emit = defineEmits<{
(event: 'close'): void
(event: 'create'): void
(event: 'update:modelValue', value: boolean): void
(event: 'update:name', value: string): void
}>()
const { t } = useI18n()
const dialogVisible = computed({
get: () => props.modelValue,
set: value => emit('update:modelValue', value),
})
const folderName = computed({
get: () => props.name,
set: value => emit('update:name', value),
})
// 关闭插件文件夹新建弹窗。
function closeDialog() {
emit('close')
emit('update:modelValue', false)
}
</script>
<template>
<VDialog v-model="dialogVisible" max-width="400">
<VCard>
<VDialogCloseBtn @click="closeDialog" />
<VCardItem>
<VCardTitle>{{ t('plugin.newFolder') }}</VCardTitle>
</VCardItem>
<VDivider />
<VCardText>
<VTextField
v-model="folderName"
:label="t('plugin.folderName')"
variant="outlined"
@keyup.enter="emit('create')"
/>
</VCardText>
<VCardActions>
<VSpacer />
<VBtn color="primary" prepend-icon="mdi-folder-plus" class="px-5" @click="emit('create')">
{{ t('plugin.create') }}
</VBtn>
</VCardActions>
</VCard>
</VDialog>
</template>

View File

@@ -0,0 +1,66 @@
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
// 多语言
const { t } = useI18n()
// 输入参数
const props = defineProps({
modelValue: {
type: Boolean,
default: true,
},
folderName: {
type: String,
default: '',
},
})
// 定义触发的自定义事件
const emit = defineEmits(['update:modelValue', 'close', 'rename'])
// 新名称
const newFolderName = ref(props.folderName)
// 弹窗显示状态
const visible = computed({
get: () => props.modelValue,
set: value => {
emit('update:modelValue', value)
if (!value) emit('close')
},
})
/** 提交文件夹重命名。 */
function confirmRename() {
emit('rename', newFolderName.value)
}
</script>
<template>
<VDialog v-if="visible" v-model="visible" max-width="400">
<VCard>
<VCardItem>
<template #prepend>
<VIcon icon="mdi-pencil" class="me-2" />
</template>
<VCardTitle>{{ t('folder.renameFolder') }}</VCardTitle>
</VCardItem>
<VDialogCloseBtn v-model="visible" />
<VDivider />
<VCardText>
<VTextField
v-model="newFolderName"
:label="t('folder.folderName')"
variant="outlined"
autofocus
@keyup.enter="confirmRename"
/>
</VCardText>
<VCardActions>
<VSpacer />
<VBtn color="primary" prepend-icon="mdi-check" class="px-5" @click="confirmRename">确认</VBtn>
</VCardActions>
</VCard>
</VDialog>
</template>

View File

@@ -0,0 +1,210 @@
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
import { useDisplay } from 'vuetify'
interface FolderConfig {
plugins?: string[]
order?: number
background?: string
icon?: string
color?: string
gradient?: string
showIcon?: boolean
}
// 多语言
const { t } = useI18n()
// 响应式显示
const display = useDisplay()
// 默认颜色
const defaultColor = '#2196F3'
// 默认图标
const defaultIcon = 'mdi-folder'
// 预设图标选项
const iconOptions = [
'mdi-folder',
'mdi-folder-star',
'mdi-folder-heart',
'mdi-folder-cog',
'mdi-folder-music',
'mdi-folder-image',
'mdi-folder-video',
'mdi-folder-download',
'mdi-folder-network',
'mdi-folder-special',
]
// 预设颜色选项
const colorOptions = [
'#2196F3',
'#4CAF50',
'#FF9800',
'#9C27B0',
'#F44336',
'#607D8B',
'#795548',
'#E91E63',
]
// 预设渐变选项
const gradientOptions = [
'linear-gradient(rgba(0, 0, 0, 0.6) 0%, rgba(0, 0, 0, 0.4) 100%), linear-gradient(135deg, rgba(33, 150, 243, 0.7) 0%, rgba(33, 150, 243, 0.8) 100%)',
'linear-gradient(rgba(0, 0, 0, 0.6) 0%, rgba(0, 0, 0, 0.4) 100%), linear-gradient(135deg, rgba(76, 175, 80, 0.7) 0%, rgba(76, 175, 80, 0.8) 100%)',
'linear-gradient(rgba(0, 0, 0, 0.6) 0%, rgba(0, 0, 0, 0.4) 100%), linear-gradient(135deg, rgba(255, 152, 0, 0.7) 0%, rgba(255, 152, 0, 0.8) 100%)',
'linear-gradient(rgba(0, 0, 0, 0.6) 0%, rgba(0, 0, 0, 0.4) 100%), linear-gradient(135deg, rgba(156, 39, 176, 0.7) 0%, rgba(156, 39, 176, 0.8) 100%)',
'linear-gradient(rgba(0, 0, 0, 0.6) 0%, rgba(0, 0, 0, 0.4) 100%), linear-gradient(135deg, rgba(244, 67, 54, 0.7) 0%, rgba(244, 67, 54, 0.8) 100%)',
'linear-gradient(rgba(0, 0, 0, 0.6) 0%, rgba(0, 0, 0, 0.4) 100%), linear-gradient(135deg, rgba(96, 125, 139, 0.7) 0%, rgba(96, 125, 139, 0.8) 100%)',
'linear-gradient(rgba(0, 0, 0, 0.6) 0%, rgba(0, 0, 0, 0.4) 100%), linear-gradient(135deg, rgba(233, 30, 99, 0.7) 0%, rgba(233, 30, 99, 0.8) 100%)',
'linear-gradient(rgba(0, 0, 0, 0.6) 0%, rgba(0, 0, 0, 0.4) 100%), linear-gradient(135deg, rgba(63, 81, 181, 0.7) 0%, rgba(156, 39, 176, 0.8) 100%)',
]
// 输入参数
const props = defineProps({
modelValue: {
type: Boolean,
default: true,
},
folderConfig: {
type: Object as PropType<FolderConfig>,
default: () => ({}),
},
})
// 定义触发的自定义事件
const emit = defineEmits(['update:modelValue', 'close', 'save'])
// 文件夹设置
const folderSettings = ref<FolderConfig>({
background: '',
icon: defaultIcon,
color: defaultColor,
gradient: gradientOptions[0],
showIcon: true,
})
// 设置对话框
const visible = computed({
get: () => props.modelValue,
set: value => {
emit('update:modelValue', value)
if (!value) emit('close')
},
})
/** 初始化文件夹外观设置。 */
function initializeSettings() {
folderSettings.value = {
background: props.folderConfig?.background || '',
icon: props.folderConfig?.icon || defaultIcon,
color: props.folderConfig?.color || defaultColor,
gradient: props.folderConfig?.gradient || gradientOptions[0],
showIcon: props.folderConfig?.showIcon !== undefined ? props.folderConfig.showIcon : true,
}
}
/** 保存文件夹外观设置。 */
function saveSettings() {
emit('save', {
...props.folderConfig,
...folderSettings.value,
})
}
onMounted(() => {
initializeSettings()
})
</script>
<template>
<VDialog v-if="visible" v-model="visible" max-width="600" scrollable :fullscreen="!display.mdAndUp.value">
<VCard>
<VDialogCloseBtn v-model="visible" />
<VCardItem>
<VCardTitle>
<VIcon icon="mdi-palette" class="mr-2" />
{{ t('folder.folderAppearanceSettings') }}
</VCardTitle>
</VCardItem>
<VDivider />
<VCardText>
<VRow>
<VCol cols="12">
<VSwitch v-model="folderSettings.showIcon" :label="t('folder.showFolderIcon')" color="primary" hide-details />
</VCol>
<VCol v-if="folderSettings.showIcon" cols="12" md="6">
<VCardSubtitle class="pa-0 mb-2">{{ t('folder.icon') }}</VCardSubtitle>
<div class="icon-grid">
<VBtn
v-for="icon in iconOptions"
icon
:key="icon"
:variant="folderSettings.icon === icon ? 'tonal' : 'text'"
:color="folderSettings.icon === icon ? 'primary' : 'default'"
size="large"
class="ma-1"
@click="folderSettings.icon = icon"
>
<VIcon :icon="icon" size="24" />
</VBtn>
</div>
</VCol>
<VCol v-if="folderSettings.showIcon" cols="12" md="6">
<VCardSubtitle class="pa-0 mb-2">{{ t('folder.iconColor') }}</VCardSubtitle>
<div class="color-grid">
<VBtn
v-for="color in colorOptions"
:key="color"
:variant="folderSettings.color === color ? 'tonal' : 'text'"
:color="color"
size="large"
class="ma-1 color-btn"
:style="{ backgroundColor: color }"
@click="folderSettings.color = color"
>
<VIcon v-if="folderSettings.color === color" icon="mdi-check" color="white" />
</VBtn>
</div>
</VCol>
<VCol cols="12">
<VCardSubtitle class="pa-0 mb-2">{{ t('folder.backgroundGradient') }}</VCardSubtitle>
<div class="gradient-grid">
<VBtn
v-for="(gradient, index) in gradientOptions"
:key="index"
:variant="folderSettings.gradient === gradient ? 'tonal' : 'text'"
class="ma-1 gradient-btn"
:style="{ background: gradient }"
size="large"
@click="folderSettings.gradient = gradient"
>
<VIcon v-if="folderSettings.gradient === gradient" icon="mdi-check" color="white" />
</VBtn>
</div>
</VCol>
<VCol cols="12">
<VTextField
v-model="folderSettings.background"
:label="t('folder.customBackgroundImageURL')"
placeholder="https://example.com/image.jpg"
variant="outlined"
:hint="t('folder.customBackgroundImageHint')"
persistent-hint
prepend-inner-icon="mdi-image"
/>
</VCol>
</VRow>
</VCardText>
<VCardActions>
<VSpacer />
<VBtn color="primary" prepend-icon="mdi-content-save" class="px-5" @click="saveSettings">保存</VBtn>
</VCardActions>
</VCard>
</VDialog>
</template>

View File

@@ -0,0 +1,69 @@
<script setup lang="ts">
import type { Plugin } from '@/api/types'
import { useI18n } from 'vue-i18n'
import { useDisplay } from 'vuetify'
const LoggingView = defineAsyncComponent(() => import('@/views/system/LoggingView.vue'))
// 多语言
const { t } = useI18n()
// 显示器宽度
const display = useDisplay()
// 输入参数
const props = defineProps({
modelValue: {
type: Boolean,
default: true,
},
plugin: {
type: Object as PropType<Plugin>,
required: true,
},
})
// 定义触发的自定义事件
const emit = defineEmits(['update:modelValue', 'close'])
// 弹窗显示状态
const visible = computed({
get: () => props.modelValue,
set: value => {
emit('update:modelValue', value)
if (!value) emit('close')
},
})
/** 打开当前插件日志的新窗口。 */
function openLoggerWindow() {
const url = `${
import.meta.env.VITE_API_BASE_URL
}system/logging?length=-1&logfile=plugins/${props.plugin?.id?.toLowerCase()}.log`
window.open(url, '_blank')
}
</script>
<template>
<VDialog v-if="visible" v-model="visible" scrollable max-width="72rem" :fullscreen="!display.mdAndUp.value">
<VCard>
<VDialogCloseBtn v-model="visible" />
<VCardItem>
<VCardTitle class="d-inline-flex">
<VIcon icon="mdi-file-document" class="me-2" />
{{ t('plugin.logTitle') }}
<a class="mx-2 d-inline-flex align-center cursor-pointer" @click="openLoggerWindow">
<VChip color="grey-darken-1" size="small" class="ml-2">
<VIcon icon="mdi-open-in-new" size="small" start />
{{ t('common.openInNewWindow') }}
</VChip>
</a>
</VCardTitle>
</VCardItem>
<VDivider />
<VCardText class="pa-0">
<LoggingView :logfile="`plugins/${props.plugin?.id?.toLowerCase()}.log`" />
</VCardText>
</VCard>
</VDialog>
</template>

View File

@@ -0,0 +1,220 @@
<script lang="ts" setup>
import api from '@/api'
import type { Plugin } from '@/api/types'
import { formatDownloadCount } from '@/@core/utils/formatters'
import { getLogoUrl } from '@/utils/imageUtils'
import { useToast } from 'vue-toastification'
import { useI18n } from 'vue-i18n'
import { openSharedDialog } from '@/composables/useSharedDialog'
const ProgressDialog = defineAsyncComponent(() => import('@/components/dialog/ProgressDialog.vue'))
// 多语言
const { t } = useI18n()
// 提示框
const $toast = useToast()
// 输入参数
const props = defineProps({
modelValue: {
type: Boolean,
default: true,
},
plugin: {
type: Object as PropType<Plugin>,
required: true,
},
count: Number,
})
// 定义触发的自定义事件
const emit = defineEmits(['update:modelValue', 'close', 'install'])
// 弹窗显示状态
const visible = computed({
get: () => props.modelValue,
set: value => {
emit('update:modelValue', value)
if (!value) emit('close')
},
})
// 图片对象
const imageRef = ref<any>()
// 图片是否加载失败
const imageLoadError = ref(false)
let progressDialogController: ReturnType<typeof openSharedDialog> | null = null
/** 打开插件安装进度弹窗。 */
function showInstallProgress(text: string) {
progressDialogController?.close()
progressDialogController = openSharedDialog(ProgressDialog, { text }, {}, { closeOn: false })
}
/** 关闭插件安装进度弹窗。 */
function closeInstallProgress() {
progressDialogController?.close()
progressDialogController = null
}
/** 计算插件图标路径。 */
function pluginIconPath() {
if (imageLoadError.value) return getLogoUrl('plugin')
if (props.plugin?.plugin_icon?.startsWith('http'))
return `${import.meta.env.VITE_API_BASE_URL}system/img/1?imgurl=${encodeURIComponent(
props.plugin?.plugin_icon,
)}&cache=true`
return `./plugin_icon/${props.plugin?.plugin_icon}`
}
/** 访问插件项目或作者页面。 */
function visitPluginPage() {
let repoUrl = props.plugin?.repo_url
if (props.plugin?.is_local || repoUrl?.startsWith('local://')) {
repoUrl = props.plugin?.author_url
}
if (repoUrl) {
if (repoUrl.includes('raw.githubusercontent.com')) {
if (!repoUrl.endsWith('/')) repoUrl += '/'
if (repoUrl.split('/').length < 6) repoUrl = `${repoUrl}main/`
try {
const [user, repo] = repoUrl.split('/').slice(-4, -2)
repoUrl = `https://github.com/${user}/${repo}`
} catch (error) {
return
}
}
} else {
repoUrl = props.plugin?.author_url
}
window.open(repoUrl, '_blank')
}
/** 安装插件并通知父级刷新市场列表。 */
async function installPlugin() {
if (props.plugin?.system_version_compatible === false) {
$toast.error(props.plugin?.system_version_message || t('plugin.incompatibleSystemVersion'))
return
}
try {
showInstallProgress(
t('plugin.installing', {
name: props.plugin?.plugin_name,
version: props?.plugin?.plugin_version,
}),
)
const result: { [key: string]: any } = await api.get(`plugin/install/${props.plugin?.id}`, {
params: {
repo_url: props.plugin?.repo_url,
force: props.plugin?.has_update,
},
})
closeInstallProgress()
if (result.success) {
$toast.success(t('plugin.installSuccess', { name: props.plugin?.plugin_name }))
visible.value = false
emit('install')
} else {
$toast.error(t('plugin.installFailed', { name: props.plugin?.plugin_name, message: result.message }))
}
} catch (error) {
closeInstallProgress()
console.error(error)
}
}
onUnmounted(() => {
closeInstallProgress()
})
</script>
<template>
<VDialog v-if="visible" v-model="visible" max-width="30rem">
<VCard>
<VDialogCloseBtn v-model="visible" />
<VCardText>
<VCol>
<div class="d-flex justify-space-between flex-wrap flex-md-nowrap flex-column flex-md-row">
<div class="mx-auto mt-5">
<VAvatar size="64">
<VImg
ref="imageRef"
:src="pluginIconPath()"
aspect-ratio="4/3"
cover
@error="imageLoadError = true"
/>
</VAvatar>
</div>
<div class="flex-grow">
<VCardItem>
<VCardTitle class="text-center text-md-left">
{{ props.plugin?.plugin_name }}
</VCardTitle>
<VCardSubtitle
class="text-center text-md-left break-words whitespace-break-spaces line-clamp-4 overflow-hidden text-ellipsis ..."
>
{{ props.plugin?.plugin_desc }}
</VCardSubtitle>
<VList lines="one">
<VListItem class="ps-0">
<VListItemTitle class="text-center text-md-left">
<span class="font-weight-medium">{{ t('common.version') }}</span>
<span class="text-body-1"> v{{ props.plugin?.plugin_version }}</span>
</VListItemTitle>
</VListItem>
<VListItem class="ps-0">
<VListItemTitle class="text-center text-md-left">
<span class="font-weight-medium">{{ t('common.author') }}</span>
<span class="text-body-1 cursor-pointer" @click="visitPluginPage">
{{ props.plugin?.plugin_author }}
</span>
</VListItemTitle>
</VListItem>
<VListItem v-if="props.plugin?.system_version" class="ps-0">
<VListItemTitle class="text-center text-md-left">
<span class="font-weight-medium">{{ t('plugin.systemVersion') }}</span>
<span class="text-body-1">{{ props.plugin?.system_version }}</span>
</VListItemTitle>
</VListItem>
</VList>
<VAlert
v-if="props.plugin?.system_version_compatible === false"
type="warning"
variant="tonal"
density="compact"
class="mb-3"
:text="props.plugin?.system_version_message || t('plugin.incompatibleSystemVersion')"
/>
<div class="text-center text-md-left">
<VBtn
color="primary"
@click="installPlugin"
prepend-icon="mdi-download"
:disabled="props.plugin?.system_version_compatible === false"
>
{{ t('plugin.installToLocal') }}
</VBtn>
<div class="text-xs mt-2" v-if="props.count">
<VIcon icon="mdi-fire" />
{{ t('plugin.totalDownloads', { count: formatDownloadCount(props.count) }) }}
</div>
</div>
</VCardItem>
</div>
</div>
</VCol>
</VCardText>
</VCard>
</VDialog>
</template>

View File

@@ -0,0 +1,133 @@
<script lang="ts" setup>
import { getLogoUrl } from '@/utils/imageUtils'
import type { Plugin } from '@/api/types'
import { useDisplay } from 'vuetify'
import { useI18n } from 'vue-i18n'
const props = defineProps({
keyword: {
type: String,
default: '',
},
modelValue: {
type: Boolean,
default: true,
},
plugins: {
type: Array as PropType<Plugin[]>,
default: () => [],
},
})
const emit = defineEmits<{
(event: 'close'): void
(event: 'open-plugin', plugin: Plugin): void
(event: 'update:keyword', value: string): void
(event: 'update:modelValue', value: boolean): void
}>()
const { t } = useI18n()
const display = useDisplay()
const pluginIconLoaded = ref<Record<string, boolean>>({})
const dialogVisible = computed({
get: () => props.modelValue,
set: value => emit('update:modelValue', value),
})
const searchKeyword = computed({
get: () => props.keyword,
set: value => emit('update:keyword', value),
})
// 返回插件图标地址,并在远程图标失败后回退到默认图标。
function pluginIcon(item: Plugin) {
if (pluginIconLoaded.value[item.id || '0'] === false) return getLogoUrl('plugin')
if (item?.plugin_icon?.startsWith('http')) {
return `${import.meta.env.VITE_API_BASE_URL}system/img/1?imgurl=${encodeURIComponent(item?.plugin_icon)}&cache=true`
}
return `./plugin_icon/${item?.plugin_icon}`
}
// 标记指定插件图标加载失败。
function pluginIconError(item: Plugin) {
pluginIconLoaded.value[item.id || '0'] = false
}
// 获取插件标签列表。
function pluginLabels(label: string | undefined) {
if (!label) return []
return label.split(',')
}
// 关闭搜索弹窗并通知共享弹窗 Host 回收实例。
function closeDialog() {
emit('close')
emit('update:modelValue', false)
}
</script>
<template>
<VDialog
v-model="dialogVisible"
scrollable
max-width="40rem"
:max-height="!display.mdAndUp.value ? '' : '85vh'"
:fullscreen="!display.mdAndUp.value"
>
<VCard class="mx-auto" width="100%">
<VToolbar flat class="p-0">
<VTextField
v-model="searchKeyword"
:label="t('plugin.searchPlugins')"
single-line
:placeholder="t('plugin.searchPlaceholder')"
variant="solo"
prepend-inner-icon="mdi-magnify"
flat
class="mx-1"
/>
</VToolbar>
<VDialogCloseBtn @click="closeDialog" />
<VList v-if="plugins.length > 0" lines="two">
<VVirtualScroll :items="plugins">
<template #default="{ item }">
<VListItem @click="emit('open-plugin', item)">
<template #prepend>
<VAvatar>
<VImg :src="pluginIcon(item)" @error="pluginIconError(item)">
<template #placeholder>
<div class="w-full h-full">
<VSkeletonLoader class="object-cover aspect-w-1 aspect-h-1" />
</div>
</template>
</VImg>
</VAvatar>
</template>
<VListItemTitle>
{{ item.plugin_name }}<span class="text-sm ms-2 mt-1 text-gray-500">v{{ item?.plugin_version }}</span>
<VIcon v-if="item.installed" color="success" icon="mdi-check-circle" class="ms-2" size="small" />
</VListItemTitle>
<VListItemSubtitle>
<VChip
v-for="label in pluginLabels(item.plugin_label)"
:key="label"
variant="tonal"
size="small"
class="me-1 my-1"
color="info"
label
>
{{ label }}
</VChip>
{{ item.plugin_desc }}
</VListItemSubtitle>
</VListItem>
</template>
</VVirtualScroll>
</VList>
</VCard>
</VDialog>
</template>

View File

@@ -0,0 +1,70 @@
<script setup lang="ts">
import type { Plugin } from '@/api/types'
import VersionHistory from '@/components/misc/VersionHistory.vue'
import { useI18n } from 'vue-i18n'
// 多语言
const { t } = useI18n()
// 输入参数
const props = defineProps({
modelValue: {
type: Boolean,
default: true,
},
plugin: {
type: Object as PropType<Plugin>,
required: true,
},
showUpdateAction: {
type: Boolean,
default: false,
},
})
// 定义触发的自定义事件
const emit = defineEmits(['update:modelValue', 'close', 'update'])
// 弹窗显示状态
const visible = computed({
get: () => props.modelValue,
set: value => {
emit('update:modelValue', value)
if (!value) emit('close')
},
})
/** 触发插件更新操作。 */
function handleUpdate() {
emit('update')
}
</script>
<template>
<VDialog v-if="visible" v-model="visible" width="600" max-height="85vh" scrollable>
<VCard :title="t('plugin.updateHistoryTitle', { name: props.plugin?.plugin_name })">
<VDialogCloseBtn v-model="visible" />
<VDivider />
<VersionHistory :history="props.plugin?.history" />
<template v-if="props.showUpdateAction">
<VDivider />
<VCardItem>
<VAlert
v-if="props.plugin?.system_version_compatible === false"
type="warning"
variant="tonal"
density="compact"
class="mb-3"
:text="props.plugin?.system_version_message || t('plugin.incompatibleSystemVersion')"
/>
<VBtn @click="handleUpdate" block :disabled="props.plugin?.system_version_compatible === false">
<template #prepend>
<VIcon icon="mdi-arrow-up-circle-outline" />
</template>
{{ t('plugin.updateToLatest') }}
</VBtn>
</VCardItem>
</template>
</VCard>
</VDialog>
</template>

View File

@@ -7,6 +7,9 @@ const props = defineProps({
value: Number,
text: String,
})
// 有明确进度值时显示确定进度,否则显示不确定进度条。
const hasProgressValue = computed(() => typeof props.value === 'number' && Number.isFinite(props.value))
</script>
<template>
<!-- Progress Dialog -->
@@ -14,7 +17,12 @@ const props = defineProps({
<VCard elevation="3" color="primary">
<VCardText class="text-center">
{{ props.text || t('dialog.progress.processing') }}
<VProgressLinear color="white" class="mb-0 mt-1" :model-value="props.value" indeterminate />
<VProgressLinear
color="white"
class="mb-0 mt-1"
:model-value="hasProgressValue ? props.value : undefined"
:indeterminate="!hasProgressValue"
/>
</VCardText>
</VCard>
</VDialog>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,64 @@
<script lang="ts" setup>
import type { SharedDialogEntry } from '@/composables/useSharedDialog'
import { closeSharedDialog, useSharedDialog } from '@/composables/useSharedDialog'
const { dialogs } = useSharedDialog()
type ReadonlySharedDialogEntry = Readonly<SharedDialogEntry> & {
readonly closeOn: readonly string[]
readonly events: Readonly<SharedDialogEntry['events']>
readonly props: Readonly<SharedDialogEntry['props']>
}
// 关闭弹窗并同步组件自身的 v-model 状态。
function closeEntry(entry: ReadonlySharedDialogEntry) {
closeSharedDialog(entry.id)
}
// 处理弹窗内部 v-model 变化,用户点击遮罩或返回键关闭时也能释放实例。
function handleModelUpdate(entry: ReadonlySharedDialogEntry, value: boolean) {
if (!value) closeSharedDialog(entry.id)
}
// 转发业务事件给调用方,并按配置自动关闭当前弹窗。
function handleDialogEvent(entry: ReadonlySharedDialogEntry, eventName: string, args: any[]) {
entry.events[eventName]?.(...args)
if (entry.closeOn.includes(eventName) && (eventName !== 'update:modelValue' || args[0] === false)) {
closeEntry(entry)
}
}
// 生成动态组件事件监听器,让不同业务弹窗复用同一个 Host。
function createDialogListeners(entry: ReadonlySharedDialogEntry) {
const listeners: Record<string, (...args: any[]) => void> = {}
listeners['update:modelValue'] = value => {
handleModelUpdate(entry, Boolean(value))
entry.events['update:modelValue']?.(value)
}
Object.keys(entry.events).forEach(eventName => {
if (eventName === 'update:modelValue') return
listeners[eventName] = (...args: any[]) => handleDialogEvent(entry, eventName, args)
})
entry.closeOn.forEach(eventName => {
if (!listeners[eventName]) {
listeners[eventName] = (...args: any[]) => handleDialogEvent(entry, eventName, args)
}
})
return listeners
}
</script>
<template>
<Component
:is="entry.component"
v-for="entry in dialogs"
:key="entry.id"
v-bind="{ ...entry.props, modelValue: entry.visible }"
v-on="createDialogListeners(entry)"
/>
</template>

View File

@@ -0,0 +1,61 @@
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
import { useDisplay } from 'vuetify'
const LoggingView = defineAsyncComponent(() => import('@/views/system/LoggingView.vue'))
// 国际化
const { t } = useI18n()
// 显示器宽度
const display = useDisplay()
// 输入参数
const props = defineProps({
modelValue: {
type: Boolean,
default: true,
},
})
// 定义触发的自定义事件
const emit = defineEmits(['update:modelValue', 'close'])
// 弹窗显示状态
const visible = computed({
get: () => props.modelValue,
set: value => {
emit('update:modelValue', value)
if (!value) emit('close')
},
})
/** 拼接全部日志 URL。 */
function allLoggingUrl() {
return `${import.meta.env.VITE_API_BASE_URL}system/logging?length=-1`
}
</script>
<template>
<VDialog v-if="visible" v-model="visible" scrollable max-width="80rem" :fullscreen="!display.mdAndUp.value">
<VCard>
<VDialogCloseBtn v-model="visible" />
<VCardItem>
<VCardTitle class="d-inline-flex">
<VIcon icon="mdi-file-document" class="me-2" />
{{ t('shortcut.log.subtitle') }}
<a class="mx-2 d-inline-flex align-center" :href="allLoggingUrl()" target="_blank">
<VChip color="grey-darken-1" size="small" class="ml-2">
<VIcon icon="mdi-open-in-new" size="small" start />
{{ t('common.openInNewWindow') }}
</VChip>
</a>
</VCardTitle>
</VCardItem>
<VDivider />
<VCardText class="pa-0">
<LoggingView logfile="moviepilot.log" />
</VCardText>
</VCard>
</VDialog>
</template>

View File

@@ -0,0 +1,139 @@
<script setup lang="ts">
import api from '@/api'
import { clearAppBadge } from '@/utils/badge'
import { useI18n } from 'vue-i18n'
import { useDisplay } from 'vuetify'
const MessageView = defineAsyncComponent(() => import('@/views/system/MessageView.vue'))
type MessageViewExpose = {
pauseSSE?: () => void
resumeSSE?: () => void
refreshLatestMessages?: () => Promise<void> | void
forceScrollToEnd?: () => void
}
// 国际化
const { t } = useI18n()
// 显示器宽度
const display = useDisplay()
// 输入参数
const props = defineProps({
modelValue: {
type: Boolean,
default: true,
},
})
// 定义触发的自定义事件
const emit = defineEmits(['update:modelValue', 'close'])
// 弹窗显示状态
const visible = computed({
get: () => props.modelValue,
set: value => {
emit('update:modelValue', value)
if (!value) emit('close')
},
})
// 输入消息
const user_message = ref('')
// 发送按钮是否可用
const sendButtonDisabled = ref(false)
// 消息视图引用
const messageViewRef = ref<MessageViewExpose | null>(null)
/** 发送 Web 消息。 */
async function sendMessage() {
const messageText = user_message.value.trim()
if (!messageText) {
return
}
try {
sendButtonDisabled.value = true
await api.post(`message/web?text=${encodeURIComponent(messageText)}`)
user_message.value = ''
messageViewRef.value?.forceScrollToEnd?.()
} catch (error) {
console.error(error)
} finally {
sendButtonDisabled.value = false
}
}
watch(visible, async newValue => {
if (newValue) {
await nextTick()
messageViewRef.value?.resumeSSE?.()
messageViewRef.value?.forceScrollToEnd?.()
window.setTimeout(() => {
void clearAppBadge()
}, 500)
return
}
messageViewRef.value?.pauseSSE?.()
})
onMounted(async () => {
await nextTick()
messageViewRef.value?.resumeSSE?.()
messageViewRef.value?.forceScrollToEnd?.()
window.setTimeout(() => {
void clearAppBadge()
}, 500)
})
onUnmounted(() => {
messageViewRef.value?.pauseSSE?.()
})
</script>
<template>
<VDialog v-if="visible" v-model="visible" max-width="50rem" scrollable :fullscreen="!display.mdAndUp.value">
<VCard>
<VCardItem>
<VCardTitle>
<VIcon icon="mdi-message" class="me-2" />
{{ t('shortcut.message.subtitle') }}
</VCardTitle>
<VDialogCloseBtn v-model="visible" />
</VCardItem>
<VDivider />
<VCardText>
<MessageView ref="messageViewRef" />
</VCardText>
<VDivider />
<VCardActions class="pa-4">
<div class="d-flex w-100 gap-2">
<VTextField
v-model="user_message"
variant="outlined"
hide-details
density="compact"
:placeholder="t('common.inputMessage')"
@keyup.enter="sendMessage"
/>
<VBtn
variant="elevated"
:disabled="sendButtonDisabled"
@click="sendMessage"
:loading="sendButtonDisabled"
color="primary"
prepend-icon="mdi-send"
>{{ t('common.send') }}
</VBtn>
</div>
</VCardActions>
</VCard>
</VDialog>
</template>

View File

@@ -0,0 +1,82 @@
<script setup lang="ts">
import type { Component } from 'vue'
import { useDisplay } from 'vuetify'
// 显示器宽度
const display = useDisplay()
// 输入参数
const props = withDefaults(
defineProps<{
bodyClass?: string
cardClass?: string
icon?: string
maxWidth?: string
modelValue?: boolean
subtitle?: string
title: string
view: Component
viewProps?: Record<string, unknown>
}>(),
{
bodyClass: '',
cardClass: '',
icon: 'mdi-cog',
maxWidth: '35rem',
modelValue: true,
viewProps: () => ({}),
},
)
// 定义触发的自定义事件
const emit = defineEmits(['update:modelValue', 'close'])
// 弹窗显示状态
const visible = computed({
get: () => props.modelValue,
set: value => {
emit('update:modelValue', value)
if (!value) emit('close')
},
})
</script>
<template>
<VDialog v-if="visible" v-model="visible" :max-width="props.maxWidth" scrollable :fullscreen="!display.mdAndUp.value">
<VCard :class="props.cardClass">
<VCardItem>
<VCardTitle>
<VIcon :icon="props.icon" class="me-2" />
{{ props.title }}
</VCardTitle>
<VCardSubtitle v-if="props.subtitle">{{ props.subtitle }}</VCardSubtitle>
<VDialogCloseBtn v-model="visible" />
</VCardItem>
<VDivider />
<VCardText :class="props.bodyClass">
<Component :is="props.view" v-bind="props.viewProps" />
</VCardText>
</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

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

@@ -0,0 +1,99 @@
<script setup lang="ts">
import type { StorageConf } from '@/api/types'
import { useI18n } from 'vue-i18n'
import { useDisplay } from 'vuetify'
// 显示器宽度
const display = useDisplay()
// 国际化
const { t } = useI18n()
// 定义输入
const props = defineProps({
modelValue: {
type: Boolean,
default: true,
},
storage: {
type: Object as PropType<StorageConf>,
required: true,
},
})
// 定义事件
const emit = defineEmits(['update:modelValue', 'close', 'done'])
// 自定义存储名称
const customName = ref(props.storage.name)
// 自定义存储类型
const storageType = ref(props.storage.type)
// 自定义存储配置对话框
const customConfigDialog = computed({
get: () => props.modelValue,
set: value => {
emit('update:modelValue', value)
if (!value) emit('close')
},
})
/** 保存自定义存储基础信息并通知父级刷新。 */
function handleDone() {
const nextStorage = {
...props.storage,
name: customName.value,
type: storageType.value,
}
customConfigDialog.value = false
emit('done', nextStorage)
}
</script>
<template>
<VDialog
v-if="customConfigDialog"
v-model="customConfigDialog"
scrollable
max-width="30rem"
:fullscreen="!display.mdAndUp.value"
>
<VCard>
<VCardItem>
<template #prepend>
<VIcon icon="mdi-cog" />
</template>
<VCardTitle>{{ t('storage.custom') }}</VCardTitle>
<VDialogCloseBtn v-model="customConfigDialog" />
</VCardItem>
<VDivider />
<VCardText>
<VRow>
<VCol cols="12" md="6">
<VTextField
v-model="storageType"
:label="t('storage.type')"
:hint="t('storage.customTypeHint')"
persistent-hint
prepend-inner-icon="mdi-database"
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="customName"
:label="t('storage.name')"
persistent-hint
prepend-inner-icon="mdi-label"
/>
</VCol>
</VRow>
</VCardText>
<VCardActions class="pt-3">
<VBtn @click="handleDone" prepend-icon="mdi-content-save" class="px-5">
{{ t('common.save') }}
</VBtn>
</VCardActions>
</VCard>
</VDialog>
</template>

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

@@ -0,0 +1,144 @@
<script setup lang="ts">
import { useDisplay } from 'vuetify'
import { useI18n } from 'vue-i18n'
const { t } = useI18n()
const display = useDisplay()
const props = withDefaults(
defineProps<{
filterForm: Record<string, string[]>
filterOptions: Record<string, string[]>
filterTitles: Record<string, string>
modelValue?: boolean
}>(),
{
modelValue: true,
},
)
const emit = defineEmits<{
(event: 'clearAllFilters'): void
(event: 'clearFilter', key: string): void
(event: 'close'): void
(event: 'selectAll', key: string): void
(event: 'update:filterForm', key: string, values: string[]): void
(event: 'update:modelValue', value: boolean): void
}>()
const visible = computed({
get: () => props.modelValue,
set: value => {
emit('update:modelValue', value)
if (!value) emit('close')
},
})
const selectedCount = computed(() => {
return Object.values(props.filterForm).reduce((count, values) => count + values.length, 0)
})
// 给定过滤类型返回不同图标。
function getFilterIcon(key: string) {
const icons: Record<string, string> = {
site: 'mdi-server-network',
season: 'mdi-television-classic',
freeState: 'mdi-gift-outline',
resolution: 'mdi-monitor-screenshot',
videoCode: 'mdi-video-vintage',
edition: 'mdi-quality-high',
releaseGroup: 'mdi-account-group-outline',
}
return icons[key] || 'mdi-filter-variant'
}
// 将筛选值变化回传给过滤条。
function updateFilter(key: string, values: string[]) {
emit('update:filterForm', key, values)
}
</script>
<template>
<VDialog v-if="visible" v-model="visible" max-width="50rem" location="center" scrollable :fullscreen="!display.mdAndUp.value">
<VCard>
<VDialogCloseBtn v-model="visible" />
<VCardTitle class="py-3 d-flex align-center">
<VIcon icon="mdi-filter-variant" class="me-2"></VIcon>
<span>{{ t('torrent.allFilters') }}</span>
<VSpacer />
<VBtn
v-if="selectedCount > 0"
class="me-10"
variant="text"
size="small"
color="error"
@click="emit('clearAllFilters')"
>
{{ t('torrent.clearAll') }}
</VBtn>
</VCardTitle>
<VDivider />
<VCardText>
<div class="all-filters-grid">
<VCard
v-for="(title, key) in props.filterTitles"
:key="key"
v-show="props.filterOptions[key].length > 0"
variant="tonal"
class="filter-section"
>
<VCardItem class="py-2">
<template #prepend>
<VIcon :icon="getFilterIcon(String(key))" class="me-2"></VIcon>
</template>
<VCardTitle>{{ title }}</VCardTitle>
<template #append>
<VBtn variant="text" size="small" color="primary" @click="emit('selectAll', String(key))">
{{ t('torrent.selectAll') }}
</VBtn>
<VBtn
v-if="props.filterForm[key].length > 0"
variant="text"
size="small"
color="error"
@click="emit('clearFilter', String(key))"
>
{{ t('torrent.clear') }}
</VBtn>
</template>
</VCardItem>
<VCardText>
<VChipGroup
:model-value="props.filterForm[key]"
column
multiple
class="filter-options"
@update:model-value="(val: string[]) => updateFilter(String(key), val)"
>
<VChip
v-for="option in props.filterOptions[key]"
:key="option"
:value="option"
filter
variant="elevated"
class="ma-1 filter-chip"
size="small"
>
{{ option }}
</VChip>
</VChipGroup>
</VCardText>
</VCard>
</div>
</VCardText>
</VCard>
</VDialog>
</template>
<style scoped>
.all-filters-grid {
display: grid;
gap: 1rem;
grid-template-columns: repeat(auto-fit, minmax(18rem, 1fr));
}
</style>

View File

@@ -0,0 +1,145 @@
<script setup lang="ts">
import type { Context } from '@/api/types'
import { formatFileSize } from '@/@core/utils/formatters'
// 输入参数
const props = defineProps({
modelValue: {
type: Boolean,
default: true,
},
items: {
type: Array as PropType<Context[]>,
default: () => [],
},
siteIcons: {
type: Object as PropType<Record<number, string>>,
default: () => ({}),
},
})
// 定义触发的自定义事件
const emit = defineEmits(['update:modelValue', 'close', 'download', 'detail'])
// 弹窗显示状态
const visible = computed({
get: () => props.modelValue,
set: value => {
emit('update:modelValue', value)
if (!value) emit('close')
},
})
/** 获取优惠标签类。 */
function getPromotionChipClass(downloadVolumeFactor: number | undefined, uploadVolumeFactor: number | undefined) {
if (!downloadVolumeFactor) return 'chip-free'
if (downloadVolumeFactor === 0) return 'chip-free'
else if (downloadVolumeFactor < 1) return 'chip-discount'
else if (uploadVolumeFactor !== undefined && uploadVolumeFactor > 1) return 'chip-bonus'
else return ''
}
/** 选择更多来源进行下载。 */
function handleDownload(item: Context) {
emit('download', item)
}
/** 打开种子详情页。 */
function handleDetail(item: Context) {
emit('detail', item)
}
</script>
<template>
<VDialog v-if="visible" v-model="visible" max-width="25rem" location="center">
<VCard>
<VCardTitle class="py-3 d-flex align-center">
<span>其他来源</span>
<VSpacer />
<VBtn variant="text" size="small" icon="mdi-close" @click.stop="visible = false"></VBtn>
</VCardTitle>
<VDivider />
<VCardText class="more-sources-content pa-0">
<VList lines="one" density="compact">
<VListItem
v-for="(item, index) in props.items"
:key="index"
@click.stop="handleDownload(item)"
class="hover:bg-primary-lighten-5"
>
<template v-slot:prepend>
<div class="d-flex align-center gap-1">
<VImg
v-if="props.siteIcons[item.torrent_info?.site || 0]"
:src="props.siteIcons[item.torrent_info?.site || 0]"
:alt="item.torrent_info?.site_name"
width="16"
height="16"
class="rounded"
/>
<VAvatar v-else size="16" class="text-caption bg-surface-variant">
{{ item.torrent_info?.site_name?.substring(0, 1) }}
</VAvatar>
<span class="text-body-2 font-weight-bold">{{ item.torrent_info.site_name }}</span>
<VChip
v-if="item.meta_info?.season_episode"
class="chip-season rounded-sm ml-1"
size="x-small"
variant="elevated"
>
{{ item.meta_info.season_episode }}
</VChip>
<VChip
v-if="item.torrent_info?.downloadvolumefactor !== 1 || item.torrent_info?.uploadvolumefactor !== 1"
:class="
getPromotionChipClass(
item.torrent_info?.downloadvolumefactor,
item.torrent_info?.uploadvolumefactor,
)
"
size="x-small"
variant="elevated"
class="rounded-sm ml-1"
>
{{ item.torrent_info?.volume_factor }}
</VChip>
</div>
</template>
<template v-slot:append>
<div class="d-flex align-center gap-2">
<span class="text-caption font-weight-bold text-primary">
{{ formatFileSize(item.torrent_info?.size) }}
</span>
<span class="d-flex align-center text-caption font-weight-bold">
<VIcon size="small" color="success" icon="mdi-arrow-up" class="mr-1"></VIcon>
{{ item.torrent_info?.seeders }}
</span>
<span>
<VIcon
@click.stop="handleDetail(item)"
size="small"
color="secondary"
icon="mdi-arrow-top-right"
class="mr-1"
></VIcon>
</span>
</div>
</template>
</VListItem>
</VList>
</VCardText>
</VCard>
</VDialog>
</template>
<style scoped>
.more-sources-content {
max-block-size: 60vh;
overflow-y: auto;
}
</style>

View File

@@ -0,0 +1,108 @@
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
const { t } = useI18n()
const props = withDefaults(
defineProps<{
filterForm: Record<string, string[]>
filterKey: string
filterOptions: Record<string, string[]>
filterTitle: string
modelValue?: boolean
}>(),
{
modelValue: true,
},
)
const emit = defineEmits<{
(event: 'clearFilter', key: string): void
(event: 'close'): void
(event: 'selectAll', key: string): void
(event: 'update:filterForm', key: string, values: string[]): void
(event: 'update:modelValue', value: boolean): void
}>()
const visible = computed({
get: () => props.modelValue,
set: value => {
emit('update:modelValue', value)
if (!value) emit('close')
},
})
const filterValues = computed(() => props.filterForm[props.filterKey] ?? [])
const options = computed(() => props.filterOptions[props.filterKey] ?? [])
// 给定过滤类型返回不同图标。
function getFilterIcon(key: string) {
const icons: Record<string, string> = {
site: 'mdi-server-network',
season: 'mdi-television-classic',
freeState: 'mdi-gift-outline',
resolution: 'mdi-monitor-screenshot',
videoCode: 'mdi-video-vintage',
edition: 'mdi-quality-high',
releaseGroup: 'mdi-account-group-outline',
}
return icons[key] || 'mdi-filter-variant'
}
// 将当前筛选值变化回传给过滤条。
function updateFilter(values: string[]) {
emit('update:filterForm', props.filterKey, values)
}
</script>
<template>
<VDialog v-if="visible" v-model="visible" max-width="25rem" max-height="85vh" location="center" scrollable>
<VCard>
<VCardTitle class="py-3 d-flex align-center">
<VIcon :icon="getFilterIcon(props.filterKey)" class="me-2"></VIcon>
<span>{{ props.filterTitle }}</span>
<VSpacer />
<VBtn
v-if="filterValues.length > 0"
variant="text"
size="small"
color="error"
@click="emit('clearFilter', props.filterKey)"
>
{{ t('torrent.clear') }}
</VBtn>
<VBtn variant="text" size="small" color="primary" @click="emit('selectAll', props.filterKey)">
{{ t('torrent.selectAll') }}
</VBtn>
</VCardTitle>
<VDivider />
<VCardText>
<VChipGroup
:model-value="filterValues"
column
multiple
class="filter-options"
@update:model-value="updateFilter"
>
<VChip
v-for="option in options"
:key="option"
:value="option"
filter
variant="elevated"
class="ma-1 filter-chip"
size="small"
>
{{ option }}
</VChip>
</VChipGroup>
</VCardText>
<VCardActions>
<VSpacer />
<VBtn color="primary" prepend-icon="mdi-check" class="px-5" @click="visible = false">
{{ t('torrent.confirm') }}
</VBtn>
</VCardActions>
</VCard>
</VDialog>
</template>

View File

@@ -0,0 +1,56 @@
<script setup lang="ts">
const props = withDefaults(
defineProps<{
modelValue?: boolean
title?: string
}>(),
{
modelValue: true,
title: '',
},
)
const emit = defineEmits<{
(event: 'close'): void
(event: 'delete', deleteSrc: boolean, deleteDest: boolean): void
(event: 'update:modelValue', value: boolean): void
}>()
const visible = computed({
get: () => props.modelValue,
set: value => {
emit('update:modelValue', value)
if (!value) emit('close')
},
})
// 选择删除范围并通知历史列表执行实际删除。
function selectDeleteMode(deleteSrc: boolean, deleteDest: boolean) {
emit('delete', deleteSrc, deleteDest)
}
</script>
<template>
<VBottomSheet v-if="visible" v-model="visible" inset>
<VCard class="text-center">
<VDialogCloseBtn v-model="visible" />
<VCardTitle class="pe-10">
{{ props.title }}
</VCardTitle>
<div class="d-flex flex-column flex-lg-row justify-center my-3">
<VBtn color="primary" class="mb-2 mx-2" @click="selectDeleteMode(false, false)">
{{ $t('transferHistory.deleteRecordOnly') }}
</VBtn>
<VBtn color="warning" class="mb-2 mx-2" @click="selectDeleteMode(true, false)">
{{ $t('transferHistory.deleteSourceOnly') }}
</VBtn>
<VBtn color="info" class="mb-2 mx-2" @click="selectDeleteMode(false, true)">
{{ $t('transferHistory.deleteDestOnly') }}
</VBtn>
<VBtn color="error" class="mb-2 mx-2" @click="selectDeleteMode(true, true)">
{{ $t('transferHistory.deleteAll') }}
</VBtn>
</div>
</VCard>
</VBottomSheet>
</template>

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

@@ -0,0 +1,166 @@
<script setup lang="ts">
import { useTransparencySettings } from '@/composables/useTransparencySettings'
import { useI18n } from 'vue-i18n'
// 国际化
const { t } = useI18n()
// 输入参数
const props = withDefaults(
defineProps<{
modelValue?: boolean
}>(),
{
modelValue: true,
},
)
// 定义触发的自定义事件
const emit = defineEmits<{
(e: 'close'): void
(e: 'update:modelValue', value: boolean): void
}>()
// 弹窗显示状态
const visible = computed({
get: () => props.modelValue,
set: value => {
emit('update:modelValue', value)
if (!value) emit('close')
},
})
const {
adjustTransparency,
backgroundBlur,
backgroundPosterOpacity,
currentPresetLevel,
onBackgroundBlurChange,
onBackgroundPosterOpacityChange,
onBlurChange,
onOpacityChange,
resetTransparencySettings,
transparencyBlur,
transparencyOpacity,
} = useTransparencySettings()
</script>
<template>
<VDialog v-if="visible" v-model="visible" max-width="30rem">
<VCard>
<VCardItem>
<VCardTitle>
<VIcon icon="mdi-opacity" class="me-2" />
{{ t('theme.transparencyAdjust') }}
</VCardTitle>
<VDialogCloseBtn v-model="visible" />
</VCardItem>
<VDivider />
<VCardText>
<div class="space-y-6">
<div>
<div class="d-flex align-center justify-space-between mb-2">
<span class="text-body-2">{{ t('theme.transparencyOpacity') }}</span>
<span class="text-caption">{{ Math.round(transparencyOpacity * 100) }}%</span>
</div>
<VSlider
v-model="transparencyOpacity"
:min="0"
:max="1"
:step="0.01"
color="primary"
@update:model-value="onOpacityChange"
/>
</div>
<div>
<div class="d-flex align-center justify-space-between mb-2">
<span class="text-body-2">{{ t('theme.transparencyBlur') }}</span>
<span class="text-caption">{{ transparencyBlur }}px</span>
</div>
<VSlider
v-model="transparencyBlur"
:min="0"
:max="30"
:step="1"
color="primary"
@update:model-value="onBlurChange"
/>
</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>
<VBtnGroup density="compact" variant="outlined" class="w-full">
<VBtn
size="small"
:color="currentPresetLevel === 'low' ? 'primary' : undefined"
@click="adjustTransparency('low')"
class="flex-1"
>
{{ t('theme.transparencyLow') }}
</VBtn>
<VBtn
size="small"
:color="currentPresetLevel === 'medium' ? 'primary' : undefined"
@click="adjustTransparency('medium')"
class="flex-1"
>
{{ t('theme.transparencyMedium') }}
</VBtn>
<VBtn
size="small"
:color="currentPresetLevel === 'high' ? 'primary' : undefined"
@click="adjustTransparency('high')"
class="flex-1"
>
{{ t('theme.transparencyHigh') }}
</VBtn>
</VBtnGroup>
</div>
</div>
</VCardText>
<VDivider />
<VCardText class="text-center">
<VBtn @click="resetTransparencySettings" variant="outlined" class="me-2">
<template #prepend>
<VIcon icon="mdi-refresh" />
</template>
{{ t('theme.transparencyReset') }}
</VBtn>
<VBtn @click="visible = false" color="primary">
{{ t('common.confirm') }}
</VBtn>
</VCardText>
</VCard>
</VDialog>
</template>

View File

@@ -0,0 +1,71 @@
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
const { t } = useI18n()
const props = withDefaults(
defineProps<{
modelValue?: boolean
text?: string
title?: string
}>(),
{
modelValue: true,
text: '',
title: '',
},
)
const emit = defineEmits<{
(event: 'close'): void
(event: 'confirm', password: string): void
(event: 'update:modelValue', value: boolean): void
}>()
const password = ref('')
const passwordVisible = ref(false)
const visible = computed({
get: () => props.modelValue,
set: value => {
emit('update:modelValue', value)
if (!value) emit('close')
},
})
// 提交当前输入的密码给调用方继续业务验证。
function submitPassword() {
emit('confirm', password.value)
}
</script>
<template>
<VDialog v-if="visible" v-model="visible" max-width="30rem">
<VCard>
<VCardTitle class="text-h5 text-center mt-4">{{ props.title }}</VCardTitle>
<VCardText>
<p class="mb-4">{{ props.text }}</p>
<VForm @submit.prevent="submitPassword">
<VTextField
v-model="password"
:type="passwordVisible ? 'text' : 'password'"
:label="t('user.password')"
:append-inner-icon="passwordVisible ? 'mdi-eye-off-outline' : 'mdi-eye-outline'"
variant="outlined"
prepend-inner-icon="mdi-lock"
autocomplete="current-password"
@click:append-inner="passwordVisible = !passwordVisible"
/>
<div class="d-flex justify-end gap-4 mt-4">
<VBtn variant="outlined" color="secondary" @click="visible = false">
{{ t('common.cancel') }}
</VBtn>
<VBtn type="submit" color="primary">
{{ t('common.confirm') }}
</VBtn>
</div>
</VForm>
</VCardText>
</VCard>
</VDialog>
</template>

View File

@@ -3,21 +3,25 @@ import type { AxiosRequestConfig, AxiosInstance } from 'axios'
import type { PropType } from 'vue'
import { useConfirm } from '@/composables/useConfirm'
import { useToast } from 'vue-toastification'
import ReorganizeDialog from '../dialog/ReorganizeDialog.vue'
import { formatBytes } from '@core/utils/formatters'
import type { Context, EndPoints, FileItem } from '@/api/types'
import api from '@/api'
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'
import { openSharedDialog } from '@/composables/useSharedDialog'
const FileRenameDialog = defineAsyncComponent(() => import('../dialog/FileRenameDialog.vue'))
const MediaInfoDialog = defineAsyncComponent(() => import('../dialog/MediaInfoDialog.vue'))
const ProgressDialog = defineAsyncComponent(() => import('../dialog/ProgressDialog.vue'))
const ReorganizeDialog = defineAsyncComponent(() => import('../dialog/ReorganizeDialog.vue'))
// 国际化
const { t } = useI18n()
const { useProgressSSE } = useBackgroundOptimization()
const { useProgressSSE } = useBackground()
// 显示器宽度
const display = useDisplay()
@@ -43,6 +47,10 @@ const inProps = defineProps({
},
sort: String,
showTree: Boolean,
active: {
type: Boolean,
default: true,
},
})
// 对外事件
@@ -71,9 +79,6 @@ const loading = ref(true)
// 重命名loading
const renameLoading = ref(false)
// 识别进度条
const progressDialog = ref(false)
// 识别进度文本
const progressText = ref(t('common.pleaseWait'))
@@ -89,12 +94,6 @@ const filter = ref('')
// 是否忽略大小写
const ignoreCase = ref(true)
// 重命名弹窗
const renamePopper = ref(false)
// 整理弹窗
const transferPopper = ref(false)
// 新名称
const newName = ref('')
@@ -107,11 +106,64 @@ 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>()
// 识别结果对话框
const nameTestDialog = ref(false)
let renameDialogController: ReturnType<typeof openSharedDialog> | null = null
let progressDialogController: ReturnType<typeof openSharedDialog> | null = null
// 打开共享进度弹窗并记录控制器,方便 SSE 更新文本和进度值。
function openProgressDialog(text = progressText.value, value = progressValue.value) {
progressDialogController?.close()
progressDialogController = openSharedDialog(ProgressDialog, { text, value }, {}, { closeOn: false })
}
// 关闭当前共享进度弹窗。
function closeProgressDialog() {
progressDialogController?.close()
progressDialogController = null
}
// 弹出菜单
const dropdownItems = ref<{ [key: string]: any }[]>([])
@@ -119,26 +171,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 +240,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
}
}
}
// 删除项目
@@ -240,17 +324,18 @@ async function batchDelete() {
if (!confirmed) return
// 显示进度条
progressDialog.value = true
progressValue.value = 0
openProgressDialog(progressText.value, progressValue.value)
// 删除选中的项目
selected.value.every(async item => {
progressText.value = t('file.deleting', { name: item.name })
progressDialogController?.updateProps({ text: progressText.value })
await deleteItem(item, false)
})
// 关闭进度条
progressDialog.value = false
closeProgressDialog()
// 重新加载
list_files()
@@ -265,13 +350,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)
@@ -336,12 +415,39 @@ function showRenmae(item: FileItem) {
currentItem.value = item
newName.value = item.name
renameAll.value = false
renamePopper.value = true
openRenameDialog()
}
// 打开共享重命名弹窗,并双向同步当前文件名和递归选项。
function openRenameDialog() {
renameDialogController = openSharedDialog(
FileRenameDialog,
{
item: currentItem.value,
loading: renameLoading.value,
name: newName.value,
recursive: renameAll.value,
},
{
'auto-name': get_recommend_name,
rename,
'update:name': (value: string) => {
newName.value = value
renameDialogController?.updateProps({ name: value })
},
'update:recursive': (value: boolean) => {
renameAll.value = value
renameDialogController?.updateProps({ recursive: value })
},
},
{ closeOn: ['close'] },
)
}
// 调用API获取新名称
async function get_recommend_name() {
renameLoading.value = true
renameDialogController?.updateProps({ loading: true })
try {
const result: { [key: string]: any } = await api.get('transfer/name', {
params: {
@@ -358,23 +464,21 @@ async function get_recommend_name() {
console.error(error)
}
renameLoading.value = false
renameDialogController?.updateProps({ loading: false, name: newName.value })
}
// 重命名
async function rename() {
emit('loading', true)
// 关闭弹窗
renamePopper.value = false
// 显示进度条
progressDialog.value = true
progressValue.value = 0
if (renameAll.value) {
progressText.value = t('file.renamingAll', { path: currentItem.value?.path })
} else {
progressText.value = t('file.renaming', { name: currentItem.value?.name })
}
openProgressDialog(progressText.value, progressValue.value)
if (renameAll.value) {
startLoadingProgress()
}
@@ -399,11 +503,13 @@ async function rename() {
if (renameAll.value) {
stopLoadingProgress()
}
progressDialog.value = false
closeProgressDialog()
// 通知重新加载
newName.value = ''
renameAll.value = false
renameDialogController?.close()
renameDialogController = null
emit('loading', false)
emit('renamed')
}
@@ -411,21 +517,35 @@ async function rename() {
// 显示整理对话框
function showTransfer(item: FileItem) {
transferItems.value = [item]
transferPopper.value = true
openTransferDialog()
}
// 显示批量整理对话框
function showBatchTransfer() {
transferItems.value = selected.value
transferPopper.value = true
transferItems.value = dedupeFileItems(selected.value)
openTransferDialog()
}
// 整理完成
function transferDone() {
transferPopper.value = false
list_files()
}
// 打开共享文件整理弹窗,整理完成后刷新当前目录。
function openTransferDialog() {
openSharedDialog(
ReorganizeDialog,
{
items: transferItems.value,
target_storage: inProps.item.storage,
},
{
done: transferDone,
},
{ closeOn: ['close', 'done'] },
)
}
// 将文件修改时间timestape转换为本地时间
function formatTime(timestape: number) {
return new Date(timestape * 1000).toLocaleString()
@@ -453,9 +573,9 @@ watch(
async () => {
// 清空列表
items.value = []
selected.value = []
// 关闭弹窗
nameTestResult.value = undefined
nameTestDialog.value = false
// 重置菜单
dropdownItems.value = [
{
@@ -518,19 +638,22 @@ watch(
async function recognize(path: string) {
try {
// 显示进度条
progressDialog.value = true
progressText.value = t('file.recognizing', { path })
progressValue.value = 0
openProgressDialog(progressText.value, progressValue.value)
nameTestResult.value = await api.get('media/recognize_file', {
params: {
path,
},
})
// 关闭进度条
progressDialog.value = false
closeProgressDialog()
if (!nameTestResult.value) $toast.error(t('file.recognizeFailed', { path }))
nameTestDialog.value = !!nameTestResult.value?.meta_info?.name
if (nameTestResult.value?.meta_info?.name) {
openSharedDialog(MediaInfoDialog, { context: nameTestResult.value }, {}, { closeOn: ['close'] })
}
} catch (error) {
closeProgressDialog()
console.error(error)
}
}
@@ -548,16 +671,17 @@ async function scrape(item: FileItem, confirm: boolean = true) {
}
// 显示进度条
progressDialog.value = true
progressText.value = t('file.scraping', { path: item.path })
openProgressDialog(progressText.value)
const result: { [key: string]: any } = await api.post(`media/scrape/${inProps.item.storage}`, item)
// 关闭进度条
progressDialog.value = false
closeProgressDialog()
if (!result.success) $toast.error(result.message)
else $toast.success(t('file.scrapeCompleted', { path: item.path }))
} catch (error) {
closeProgressDialog()
console.error(error)
}
}
@@ -582,10 +706,11 @@ function handleProgressMessage(event: MessageEvent) {
if (progress) {
progressText.value = progress.text
progressValue.value = progress.value
progressDialogController?.updateProps({ text: progressText.value, value: progressValue.value })
}
}
// 使用优化的进度SSE连接
// 使用进度SSE连接
const progressSSE = useProgressSSE(
`${import.meta.env.VITE_API_BASE_URL}system/progress/batchrename`,
handleProgressMessage,
@@ -606,13 +731,15 @@ function stopLoadingProgress() {
progressSSE.stop()
}
onMounted(() => {
list_files()
useKeepAliveRefresh(list_files, {
active: computed(() => inProps.active),
})
onUnmounted(() => {
revokeCurrentImgLink()
stopLoadingProgress()
closeProgressDialog()
renameDialogController?.close()
})
</script>
@@ -639,25 +766,22 @@ 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
/>
<VSpacer v-if="isFile" />
<IconBtn v-if="!isFile" @click="ignoreCase = !ignoreCase">
<IconBtn v-if="!isFile && !selectMode" @click="ignoreCase = !ignoreCase">
<VIcon :color="ignoreCase ? 'primary' : 'error'" icon="mdi-format-letter-case" />
</IconBtn>
<IconBtn v-if="!isFile" @click="changeSelectMode">
<VIcon color="primary" :icon="selectMode ? 'mdi-selection-remove' : 'mdi-select'" />
</IconBtn>
<IconBtn v-if="isFile" @click="recognize(inProps.item.path || '')">
<VIcon color="primary"> mdi-text-recognition </VIcon>
</IconBtn>
<IconBtn v-if="isFile && items.length > 0" @click="download(items[0])">
<VIcon color="primary"> mdi-download </VIcon>
</IconBtn>
<IconBtn v-if="!isFile" @click="list_files">
<IconBtn v-if="!isFile && !selectMode" @click="list_files">
<VIcon color="primary"> mdi-refresh </VIcon>
</IconBtn>
<!-- 批量操作按钮 -->
@@ -672,6 +796,9 @@ onUnmounted(() => {
<VIcon icon="mdi-delete-outline" color="error" />
</IconBtn>
</span>
<IconBtn v-if="!isFile" @click="changeSelectMode">
<VIcon color="primary" :icon="selectMode ? 'mdi-selection-remove' : 'mdi-select'" />
</IconBtn>
</div>
<LoadingBanner v-if="loading" />
<!-- 文件详情 -->
@@ -699,14 +826,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
@@ -773,59 +904,5 @@ onUnmounted(() => {
{{ t('file.emptyDirectory') }}
</VCardText>
</VCard>
<!-- 重命名弹窗 -->
<VDialog v-if="renamePopper" v-model="renamePopper" max-width="35rem">
<VCard>
<VCardItem>
<template #prepend>
<VIcon icon="mdi-pencil" class="me-2" />
</template>
<VCardTitle>{{ t('file.rename') }}</VCardTitle>
</VCardItem>
<VDialogCloseBtn @click="renamePopper = false" />
<VDivider />
<VCardText>
<VRow>
<VCol cols="12">
<VTextField
v-model="newName"
:label="t('file.newName')"
:loading="renameLoading"
prepend-inner-icon="mdi-format-text"
/>
</VCol>
<VCol cols="12" v-if="currentItem && currentItem.type == 'dir'">
<VSwitch v-model="renameAll" :label="t('file.includeSubfolders')" />
</VCol>
</VRow>
</VCardText>
<VCardActions>
<VBtn color="success" @click="get_recommend_name" prepend-icon="mdi-magic" class="px-5 me-3">
{{ t('file.autoRecognizeName') }}
</VBtn>
<VBtn :disabled="!newName" @click="rename" prepend-icon="mdi-check" class="px-5 me-3">
{{ t('common.confirm') }}
</VBtn>
</VCardActions>
</VCard>
</VDialog>
<!-- 文件整理弹窗 -->
<ReorganizeDialog
v-if="transferPopper"
v-model="transferPopper"
:items="transferItems"
:target_storage="inProps.item.storage"
@done="transferDone"
@close="transferPopper = false"
/>
<!-- 进度框 -->
<ProgressDialog v-if="progressDialog" v-model="progressDialog" :text="progressText" :value="progressValue" />
<!-- 识别结果对话框 -->
<MediaInfoDialog
v-if="nameTestDialog"
v-model="nameTestDialog"
:context="nameTestResult"
@close="nameTestDialog = false"
/>
</div>
</template>

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

@@ -3,6 +3,9 @@ import type { AxiosRequestConfig, AxiosInstance } from 'axios'
import type { EndPoints, FileItem } from '@/api/types'
import { useDisplay } from 'vuetify'
import { useI18n } from 'vue-i18n'
import { openSharedDialog } from '@/composables/useSharedDialog'
const FileNewFolderDialog = defineAsyncComponent(() => import('../dialog/FileNewFolderDialog.vue'))
// 国际化
const { t } = useI18n()
@@ -39,11 +42,9 @@ const inProps = defineProps({
// 对外事件
const emit = defineEmits(['storagechanged', 'pathchanged', 'loading', 'foldercreated', 'sortchanged'])
// 新建文件夹名称
const newFolderPopper = ref(false)
// 新建文件名称
const newFolderName = ref('')
let newFolderDialogController: ReturnType<typeof openSharedDialog> | null = null
// 调整排序方式
function changeSort() {
@@ -105,7 +106,8 @@ async function mkdir() {
// 调API
await inProps.axios.request(config)
newFolderPopper.value = false
newFolderDialogController?.close()
newFolderDialogController = null
newFolderName.value = ''
emit('loading', false)
@@ -115,7 +117,18 @@ async function mkdir() {
function openNewFolderDialog() {
newFolderName.value = ''
newFolderPopper.value = true
newFolderDialogController = openSharedDialog(
FileNewFolderDialog,
{ name: newFolderName.value },
{
create: mkdir,
'update:name': (value: string) => {
newFolderName.value = value
newFolderDialogController?.updateProps({ name: value })
},
},
{ closeOn: ['close'] },
)
}
// 计算排序图标
@@ -124,6 +137,10 @@ const sortIcon = computed(() => {
else return 'mdi-sort-alphabetical-ascending'
})
onUnmounted(() => {
newFolderDialogController?.close()
})
defineExpose({
openNewFolderDialog,
})
@@ -176,32 +193,8 @@ defineExpose({
<IconBtn v-if="pathSegments.length > 0" @click="goUp">
<VIcon icon="mdi-arrow-up-bold-outline" />
</IconBtn>
<!-- 新建文件夹 -->
<VDialog v-model="newFolderPopper" max-width="35rem">
<template v-if="showNewFolderButton" #activator="{ props }">
<IconBtn v-bind="props">
<VIcon icon="mdi-folder-plus-outline" />
</IconBtn>
</template>
<VCard>
<VCardItem>
<template #prepend>
<VIcon icon="mdi-folder-plus-outline" class="me-2" />
</template>
<VCardTitle>{{ t('file.newFolder') }}</VCardTitle>
</VCardItem>
<VDialogCloseBtn @click="newFolderPopper = false" />
<VDivider />
<VCardText>
<VTextField v-model="newFolderName" :label="t('common.name')" prepend-inner-icon="mdi-format-text" />
</VCardText>
<VCardActions>
<div class="flex-grow-1" />
<VBtn :disabled="!newFolderName" @click="mkdir" prepend-icon="mdi-folder-plus" class="px-5 me-3">
{{ t('common.create') }}
</VBtn>
</VCardActions>
</VCard>
</VDialog>
<IconBtn v-if="showNewFolderButton" @click="openNewFolderDialog">
<VIcon icon="mdi-folder-plus-outline" />
</IconBtn>
</VToolbar>
</template>

View File

@@ -1,10 +1,10 @@
<script lang="ts" setup>
import { useI18n } from 'vue-i18n'
import { useDisplay } from 'vuetify'
import { useEventListener } from '@vueuse/core'
import { openSharedDialog } from '@/composables/useSharedDialog'
// 显示器宽度
const display = useDisplay()
const TorrentAllFiltersDialog = defineAsyncComponent(() => import('@/components/dialog/TorrentAllFiltersDialog.vue'))
const TorrentSingleFilterDialog = defineAsyncComponent(() => import('@/components/dialog/TorrentSingleFilterDialog.vue'))
// 国际化
const { t } = useI18n()
@@ -41,15 +41,11 @@ const emit = defineEmits<{
}>()
// 过滤菜单相关
const filterMenuOpen = ref(false)
const currentFilter = ref('site')
const currentFilterTitle = computed(() => props.filterTitles[currentFilter.value])
const currentFilterOptions = computed(() => {
return props.filterOptions[currentFilter.value]
})
// 添加全部筛选菜单相关
const allFilterMenuOpen = ref(false)
let allFilterDialogController: ReturnType<typeof openSharedDialog> | null = null
let filterDialogController: ReturnType<typeof openSharedDialog> | null = null
// 计算已选择的过滤条件数量
const getFilterCount = computed(() => {
@@ -85,18 +81,97 @@ function getFilterIcon(key: string) {
return icons[key] || 'mdi-filter-variant'
}
// 开关全部筛选菜单
function toggleAllFilterMenu() {
allFilterMenuOpen.value = !allFilterMenuOpen.value
// 生成全部筛选共享弹窗的最新参数。
function getAllFiltersDialogProps() {
return {
filterForm: props.filterForm,
filterOptions: props.filterOptions,
filterTitles: props.filterTitles,
}
}
// 添加toggleFilterMenu函数
// 生成单项筛选共享弹窗的最新参数。
function getSingleFilterDialogProps() {
return {
filterForm: props.filterForm,
filterKey: currentFilter.value,
filterOptions: props.filterOptions,
filterTitle: currentFilterTitle.value,
}
}
// 关闭全部筛选共享弹窗。
function closeAllFilterDialog() {
allFilterDialogController?.close()
allFilterDialogController = null
}
// 关闭单项筛选共享弹窗。
function closeFilterDialog() {
filterDialogController?.close()
filterDialogController = null
}
// 打开全部筛选共享弹窗。
function openAllFilterDialog() {
allFilterDialogController?.close()
allFilterDialogController = openSharedDialog(
TorrentAllFiltersDialog,
getAllFiltersDialogProps(),
{
clearAllFilters,
clearFilter,
close: () => {
allFilterDialogController = null
},
selectAll,
'update:filterForm': handleFilterChange,
'update:modelValue': (value: boolean) => {
if (!value) allFilterDialogController = null
},
},
{ closeOn: ['close', 'update:modelValue'] },
)
}
// 打开单项筛选共享弹窗。
function openFilterDialog() {
if (filterDialogController) {
filterDialogController.updateProps(getSingleFilterDialogProps())
return
}
filterDialogController = openSharedDialog(
TorrentSingleFilterDialog,
getSingleFilterDialogProps(),
{
clearFilter,
close: () => {
filterDialogController = null
},
selectAll,
'update:filterForm': handleFilterChange,
'update:modelValue': (value: boolean) => {
if (!value) filterDialogController = null
},
},
{ closeOn: ['close', 'update:modelValue'] },
)
}
// 开关全部筛选菜单。
function toggleAllFilterMenu() {
if (allFilterDialogController) closeAllFilterDialog()
else openAllFilterDialog()
}
// 切换单项筛选共享弹窗。
function toggleFilterMenu(key: string) {
if (currentFilter.value === key && filterMenuOpen.value) {
filterMenuOpen.value = false
if (currentFilter.value === key && filterDialogController) {
closeFilterDialog()
} else {
currentFilter.value = key
filterMenuOpen.value = true
openFilterDialog()
}
}
@@ -218,7 +293,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 +480,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">
@@ -519,138 +594,6 @@ onMounted(() => {
</div>
</VCard>
<!-- 全部筛选弹窗 -->
<VDialog
v-model="allFilterMenuOpen"
max-width="50rem"
location="center"
scrollable
:fullscreen="!display.mdAndUp.value"
>
<VCard>
<VDialogCloseBtn @click="allFilterMenuOpen = false" />
<VCardTitle class="py-3 d-flex align-center">
<VIcon icon="mdi-filter-variant" class="me-2"></VIcon>
<span>{{ t('torrent.allFilters') }}</span>
<VSpacer />
<VBtn
v-if="getFilterCount > 0"
class="me-10"
variant="text"
size="small"
color="error"
@click="clearAllFilters"
>
{{ t('torrent.clearAll') }}
</VBtn>
</VCardTitle>
<VDivider />
<VCardText>
<div class="all-filters-grid">
<VCard
v-for="(title, key) in filterTitles"
variant="tonal"
:key="key"
class="filter-section"
v-show="filterOptions[key].length > 0"
>
<VCardItem class="py-2">
<template #prepend>
<VIcon :icon="getFilterIcon(key)" class="me-2"></VIcon>
</template>
<VCardTitle>{{ title }}</VCardTitle>
<template #append>
<VBtn variant="text" size="small" color="primary" @click="selectAll(key)">
{{ t('torrent.selectAll') }}
</VBtn>
<VBtn
v-if="filterForm[key].length > 0"
variant="text"
size="small"
color="error"
@click="clearFilter(key)"
>
{{ t('torrent.clear') }}
</VBtn>
</template>
</VCardItem>
<VCardText>
<VChipGroup
:model-value="filterForm[key]"
@update:model-value="(val: string[]) => handleFilterChange(key, val)"
column
multiple
class="filter-options"
>
<VChip
v-for="option in filterOptions[key]"
:key="option"
:value="option"
filter
variant="elevated"
class="ma-1 filter-chip"
size="small"
>
{{ option }}
</VChip>
</VChipGroup>
</VCardText>
</VCard>
</div>
</VCardText>
</VCard>
</VDialog>
<!-- 筛选弹窗 -->
<VDialog v-model="filterMenuOpen" max-width="25rem" max-height="85vh" location="center" scrollable>
<VCard>
<VCardTitle class="py-3 d-flex align-center">
<VIcon :icon="getFilterIcon(currentFilter)" class="me-2"></VIcon>
<span>{{ currentFilterTitle }}</span>
<VSpacer />
<VBtn
v-if="filterForm[currentFilter].length > 0"
variant="text"
size="small"
color="error"
@click="clearFilter(currentFilter)"
>
{{ t('torrent.clear') }}
</VBtn>
<VBtn variant="text" size="small" color="primary" @click="selectAll(currentFilter)">
{{ t('torrent.selectAll') }}
</VBtn>
</VCardTitle>
<VDivider />
<VCardText>
<VChipGroup
:model-value="filterForm[currentFilter]"
@update:model-value="(val: string[]) => handleFilterChange(currentFilter, val)"
column
multiple
class="filter-options"
>
<VChip
v-for="option in currentFilterOptions"
:key="option"
:value="option"
filter
variant="elevated"
class="ma-1 filter-chip"
size="small"
>
{{ option }}
</VChip>
</VChipGroup>
</VCardText>
<VCardActions>
<VSpacer />
<VBtn color="primary" prepend-icon="mdi-check" class="px-5" @click="filterMenuOpen = false">
{{ t('torrent.confirm') }}
</VBtn>
</VCardActions>
</VCard>
</VDialog>
</template>
<style scoped>
@@ -664,6 +607,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 +645,7 @@ onMounted(() => {
display: flex;
flex-wrap: nowrap;
align-items: center;
gap: 4px;
gap: 6px;
overflow-x: auto;
flex: 1;
width: 0;
@@ -722,6 +672,7 @@ onMounted(() => {
.filter-btn {
min-inline-size: 0;
background: rgba(var(--v-theme-surface-variant), 0.1);
transition: opacity 0.2s;
}
@@ -770,8 +721,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 +740,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;
@@ -805,13 +757,20 @@ onMounted(() => {
text-align: center;
}
.all-filters-grid {
display: grid;
gap: 24px;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
}
@media (width <= 600px) {
.filter-toolbar-card {
border-radius: 8px;
}
.filter-section {
background-color: rgba(var(--v-theme-surface-variant), 0.08);
.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: `

File diff suppressed because it is too large Load Diff

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

@@ -2,8 +2,10 @@
import { isNullOrEmptyObject } from '@/@core/utils'
import api from '@/api'
import { type PropType } from 'vue'
import ProgressDialog from '../dialog/ProgressDialog.vue'
import { RenderProps } from '@/api/types'
import { openSharedDialog } from '@/composables/useSharedDialog'
const ProgressDialog = defineAsyncComponent(() => import('../dialog/ProgressDialog.vue'))
// 定议外部事件
const emit = defineEmits(['action'])
@@ -13,16 +15,27 @@ const props = defineProps({
config: Object as PropType<RenderProps>,
})
// 进度框
const progressDialog = ref(false)
// 进度框文本
const progressText = ref('正在处理...')
let progressDialogController: ReturnType<typeof openSharedDialog> | null = null
// 打开共享进度弹窗,避免渲染节点直接持有弹窗实例。
function openProgressDialog() {
progressDialogController?.close()
progressDialogController = openSharedDialog(ProgressDialog, { text: progressText.value }, {}, { closeOn: false })
}
// 关闭当前共享进度弹窗。
function closeProgressDialog() {
progressDialogController?.close()
progressDialogController = null
}
// 元素API事件响应
async function commonAction(api_path: string, method: string, params = {}) {
if (!api_path || !method) return
progressDialog.value = true
openProgressDialog()
try {
if (method.toUpperCase() === 'GET') {
await api.get(api_path, {
@@ -34,8 +47,9 @@ async function commonAction(api_path: string, method: string, params = {}) {
emit('action')
} catch (error) {
console.error(error)
} finally {
closeProgressDialog()
}
progressDialog.value = false
}
// 组装事件
@@ -70,6 +84,4 @@ watchEffect(() => {
v-html="config?.html"
v-on="componentEvents"
/>
<!-- 进度框 -->
<ProgressDialog v-if="progressDialog" v-model="progressDialog" :text="progressText" />
</template>

View File

@@ -60,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)
@@ -87,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)
@@ -185,6 +194,7 @@ onActivated(() => {
}
nextTick(syncLayoutState)
requestAnimationFrame(syncLayoutState)
})
watch(

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,38 @@
import { getDominantColor } from '@/@core/utils/image'
const DEFAULT_ACCENT_RGB = '145, 85, 253'
/** 将图标主色转换为卡片 CSS 变量可直接使用的 RGB 字符串。 */
function hexToRgbString(hexColor: string) {
const normalizedColor = hexColor.replace('#', '')
const colorValue = Number.parseInt(normalizedColor, 16)
if (Number.isNaN(colorValue) || normalizedColor.length !== 6) return DEFAULT_ACCENT_RGB
return `${(colorValue >> 16) & 255}, ${(colorValue >> 8) & 255}, ${colorValue & 255}`
}
/** 从指定图片中提取卡片强调色,返回 CSS 变量可直接使用的 RGB 字符串。 */
export async function getCardAccentRgbFromImage(image: HTMLImageElement | undefined | null, fallback = '#9155FD') {
const dominantColor = await getDominantColor(image, { fallback })
return hexToRgbString(dominantColor)
}
/** 从卡片图标中提取强调色,保证设置页卡片颜色跟随各自图标。 */
export function useCardAccentColor(fallback = '#9155FD') {
const accentRgb = ref(DEFAULT_ACCENT_RGB)
const imageRef = ref<any>()
async function updateAccentColor() {
const imageElement = imageRef.value?.$el?.querySelector('img') as HTMLImageElement | undefined
accentRgb.value = await getCardAccentRgbFromImage(imageElement, fallback)
}
return {
accentRgb,
imageRef,
updateAccentColor,
}
}

View File

@@ -10,6 +10,7 @@ interface DynamicHeaderTabButton {
class?: string
action?: () => void
show?: boolean | ComputedRef<boolean>
loading?: boolean | ComputedRef<boolean>
dataAttr?: string // 用于VMenu定位的data属性
}

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,
@@ -328,6 +338,7 @@ export function useSetupWizard() {
},
// 通知映射
notification: {
'feishu': 'FeishuModule',
'telegram': 'TelegramModule',
'wechat': 'WechatModule',
'wechatclawbot': 'WechatClawBotModule',
@@ -427,6 +438,7 @@ export function useSetupWizard() {
if (!wizardData.value.notification.name || wizardData.value.notification.name.includes('通知')) {
const displayNameMap: Record<string, string> = {
wechat: '企业微信',
feishu: '飞书',
wechatclawbot: '微信 ClawBot',
telegram: 'Telegram',
slack: 'Slack',
@@ -679,6 +691,16 @@ export function useSetupWizard() {
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'))
@@ -1418,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:
@@ -1526,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,96 @@
import { markRaw, shallowRef, type Component } from 'vue'
export type SharedDialogEventHandler = (...args: any[]) => unknown
export interface SharedDialogOpenOptions {
closeOn?: string[] | false
events?: Record<string, SharedDialogEventHandler>
props?: Record<string, unknown>
replace?: boolean
}
export interface SharedDialogEntry {
closeOn: string[]
component: Component
events: Record<string, SharedDialogEventHandler>
id: number
props: Record<string, unknown>
visible: boolean
}
const DEFAULT_CLOSE_EVENTS = ['close']
const dialogStack = shallowRef<SharedDialogEntry[]>([])
let dialogSeed = 0
// 规范化弹窗关闭事件,避免每个调用方重复处理关闭约定。
function normalizeCloseEvents(closeOn: SharedDialogOpenOptions['closeOn']) {
if (closeOn === false) return []
return closeOn ?? DEFAULT_CLOSE_EVENTS
}
// 更新弹窗栈引用,确保 Host 能响应数组内容变化。
function setDialogStack(entries: SharedDialogEntry[]) {
dialogStack.value = entries
}
// 打开一个共享弹窗,并返回当前弹窗的控制器。
export function openSharedDialog(
component: Component,
props: Record<string, unknown> = {},
events: Record<string, SharedDialogEventHandler> = {},
options: Omit<SharedDialogOpenOptions, 'props' | 'events'> = {},
) {
const id = ++dialogSeed
const entry: SharedDialogEntry = {
closeOn: normalizeCloseEvents(options.closeOn),
component: markRaw(component),
events,
id,
props,
visible: true,
}
setDialogStack(options.replace ? [entry] : [...dialogStack.value, entry])
return {
id,
close: () => closeSharedDialog(id),
updateProps: (nextProps: Record<string, unknown>) => updateSharedDialogProps(id, nextProps),
}
}
// 使用对象参数打开共享弹窗,适合调用方需要传入更多选项的场景。
export function openSharedDialogWithOptions(component: Component, options: SharedDialogOpenOptions = {}) {
return openSharedDialog(component, options.props ?? {}, options.events ?? {}, {
closeOn: options.closeOn,
replace: options.replace,
})
}
// 关闭指定弹窗;未传 id 时关闭最上层弹窗。
export function closeSharedDialog(id?: number) {
if (id === undefined) {
setDialogStack(dialogStack.value.slice(0, -1))
return
}
setDialogStack(dialogStack.value.filter(entry => entry.id !== id))
}
// 合并更新指定弹窗的 props供进度弹窗等需要刷新内容的场景使用。
export function updateSharedDialogProps(id: number, props: Record<string, unknown>) {
setDialogStack(
dialogStack.value.map(entry => (entry.id === id ? { ...entry, props: { ...entry.props, ...props } } : entry)),
)
}
// 提供共享弹窗的响应式状态和命令式操作方法。
export function useSharedDialog() {
return {
dialogs: dialogStack,
openDialog: openSharedDialog,
openDialogWithOptions: openSharedDialogWithOptions,
closeDialog: closeSharedDialog,
updateDialogProps: updateSharedDialogProps,
}
}

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

@@ -0,0 +1,177 @@
import { computed, ref } from 'vue'
export interface TransparencySettings {
backgroundBlur: number
backgroundPosterOpacity: number
blur: number
level: string
opacity: number
}
export const transparencyPresets = {
low: { opacity: 0.1, blur: 5 },
medium: { opacity: 0.3, blur: 10 },
high: { opacity: 0.6, blur: 15 },
}
/** 将数值限制在指定范围内。 */
function clamp(value: number, min: number, max: number) {
return Math.min(max, Math.max(min, value))
}
/** 从本地存储读取透明主题设置。 */
export function readTransparencySettings(): TransparencySettings {
return {
opacity: parseFloat(localStorage.getItem('transparency-opacity') || '0.3'),
blur: parseFloat(localStorage.getItem('transparency-blur') || '10'),
backgroundPosterOpacity: parseFloat(localStorage.getItem('transparency-background-poster-opacity') || '0'),
backgroundBlur: parseFloat(localStorage.getItem('transparency-background-blur') || '16'),
level: localStorage.getItem('transparency-level') || 'medium',
}
}
/** 应用透明主题设置并写入本地存储。 */
export function applyTransparencySettings(settings: TransparencySettings) {
const normalized: TransparencySettings = {
opacity: Number.isFinite(settings.opacity) ? clamp(settings.opacity, 0, 1) : 0.3,
blur: Number.isFinite(settings.blur) ? clamp(settings.blur, 0, 30) : 10,
backgroundPosterOpacity: Number.isFinite(settings.backgroundPosterOpacity)
? clamp(settings.backgroundPosterOpacity, 0, 1)
: 0,
backgroundBlur: Number.isFinite(settings.backgroundBlur) ? clamp(settings.backgroundBlur, 0, 30) : 16,
level: settings.level,
}
const root = document.documentElement
root.style.setProperty('--transparent-opacity', normalized.opacity.toString())
root.style.setProperty('--transparent-opacity-light', (normalized.opacity * 0.67).toString())
root.style.setProperty('--transparent-opacity-heavy', (normalized.opacity * 1.67).toString())
root.style.setProperty('--transparent-blur', `${normalized.blur}px`)
root.style.setProperty('--transparent-blur-light', `${normalized.blur * 0.6}px`)
root.style.setProperty('--transparent-blur-heavy', `${normalized.blur * 1.6}px`)
root.style.setProperty('--transparent-background-poster-opacity', (1 - normalized.backgroundPosterOpacity).toString())
root.style.setProperty('--transparent-background-blur', `${normalized.backgroundBlur}px`)
localStorage.setItem('transparency-opacity', normalized.opacity.toString())
localStorage.setItem('transparency-blur', normalized.blur.toString())
localStorage.setItem('transparency-background-poster-opacity', normalized.backgroundPosterOpacity.toString())
localStorage.setItem('transparency-background-blur', normalized.backgroundBlur.toString())
localStorage.setItem('transparency-level', normalized.level)
return normalized
}
/** 按本地存储中的最新值应用透明主题设置。 */
export function applyStoredTransparencySettings() {
return applyTransparencySettings(readTransparencySettings())
}
/** 提供透明主题设置的响应式状态和操作方法。 */
export function useTransparencySettings() {
const storedSettings = readTransparencySettings()
const transparencyOpacity = ref(storedSettings.opacity)
const transparencyBlur = ref(storedSettings.blur)
const backgroundPosterOpacity = ref(storedSettings.backgroundPosterOpacity)
const backgroundBlur = ref(storedSettings.backgroundBlur)
const transparencyLevel = ref(storedSettings.level)
const currentPresetLevel = computed(() => {
for (const [level, preset] of Object.entries(transparencyPresets)) {
if (
Math.abs(transparencyOpacity.value - preset.opacity) < 0.01 &&
Math.abs(transparencyBlur.value - preset.blur) < 0.1
) {
return level
}
}
return null
})
/** 同步当前响应式状态到 CSS 变量和本地存储。 */
function syncTransparencySettings() {
const normalized = applyTransparencySettings({
opacity: transparencyOpacity.value,
blur: transparencyBlur.value,
backgroundPosterOpacity: backgroundPosterOpacity.value,
backgroundBlur: backgroundBlur.value,
level: transparencyLevel.value,
})
transparencyOpacity.value = normalized.opacity
transparencyBlur.value = normalized.blur
backgroundPosterOpacity.value = normalized.backgroundPosterOpacity
backgroundBlur.value = normalized.backgroundBlur
transparencyLevel.value = normalized.level
}
/** 按预设级别调整透明度和模糊度。 */
function adjustTransparency(level: string) {
transparencyLevel.value = level
switch (level) {
case 'low':
transparencyOpacity.value = transparencyPresets.low.opacity
transparencyBlur.value = transparencyPresets.low.blur
break
case 'medium':
transparencyOpacity.value = transparencyPresets.medium.opacity
transparencyBlur.value = transparencyPresets.medium.blur
break
case 'high':
transparencyOpacity.value = transparencyPresets.high.opacity
transparencyBlur.value = transparencyPresets.high.blur
break
}
syncTransparencySettings()
}
/** 处理手动调整面板透明度。 */
function onOpacityChange() {
transparencyLevel.value = ''
syncTransparencySettings()
}
/** 处理手动调整面板模糊度。 */
function onBlurChange() {
transparencyLevel.value = ''
syncTransparencySettings()
}
/** 处理背景海报透明度变化。 */
function onBackgroundPosterOpacityChange() {
syncTransparencySettings()
}
/** 处理背景磨砂变化。 */
function onBackgroundBlurChange() {
syncTransparencySettings()
}
/** 重置透明主题设置为默认值。 */
function resetTransparencySettings() {
transparencyOpacity.value = transparencyPresets.medium.opacity
transparencyBlur.value = transparencyPresets.medium.blur
backgroundPosterOpacity.value = 0
backgroundBlur.value = 16
transparencyLevel.value = 'medium'
syncTransparencySettings()
}
return {
adjustTransparency,
backgroundBlur,
backgroundPosterOpacity,
currentPresetLevel,
onBackgroundBlurChange,
onBackgroundPosterOpacityChange,
onBlurChange,
onOpacityChange,
resetTransparencySettings,
syncTransparencySettings,
transparencyBlur,
transparencyOpacity,
transparencyLevel,
}
}

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

@@ -79,6 +79,7 @@ interface DynamicHeaderTab {
class?: string
action?: () => void
show?: boolean | ComputedRef<boolean>
loading?: boolean | ComputedRef<boolean>
dataAttr?: string
}>
routePath?: string // 用于标识哪个路由注册的
@@ -395,6 +396,7 @@ onMounted(async () => {
:color="typeof button.color === 'string' ? button.color : (button.color as any)?.value || 'gray'"
:size="button.size || 'default'"
:class="button.class || 'settings-icon-button'"
:loading="typeof button.loading === 'boolean' ? button.loading : (button.loading as any)?.value || false"
:data-menu-activator="button.dataAttr"
@click="button.action"
/>

View File

@@ -1,6 +1,9 @@
<script setup lang="ts">
import { openSharedDialog } from '@/composables/useSharedDialog'
import { useGlobalOfflineStatus } from '@/composables/useOfflineStatus'
const OfflineStatusDialog = defineAsyncComponent(() => import('@/components/dialog/OfflineStatusDialog.vue'))
interface Props {
type?: 'offline' | 'online'
}
@@ -9,214 +12,55 @@ const props = withDefaults(defineProps<Props>(), {
type: 'offline',
})
const { t } = useI18n()
const { isOnline, canPerformNetworkAction, getOfflineMessage } = useGlobalOfflineStatus()
const { canPerformNetworkAction } = useGlobalOfflineStatus()
let offlineDialogController: ReturnType<typeof openSharedDialog> | null = null
// 重试连接
const retrying = ref(false)
const handleRetry = async () => {
if (retrying.value) return
retrying.value = true
try {
// 尝试发送一个简单的请求来检测网络
await fetch('/favicon.ico?' + new Date().getTime(), {
method: 'HEAD',
cache: 'no-cache',
})
// 如果成功,等待一下让状态更新
setTimeout(() => {
retrying.value = false
}, 1000)
} catch (error) {
retrying.value = false
/** 打开离线状态共享弹窗。 */
function showOfflineDialog() {
if (offlineDialogController) {
offlineDialogController.updateProps({ type: props.type })
return
}
offlineDialogController = openSharedDialog(
OfflineStatusDialog,
{
type: props.type,
},
{},
{ closeOn: false },
)
}
// 当网络恢复时自动隐藏页面
const shouldShow = computed(() => {
return !canPerformNetworkAction.value
})
/** 关闭离线状态共享弹窗。 */
function closeOfflineDialog() {
offlineDialogController?.close()
offlineDialogController = null
}
// 状态文本
const statusText = computed(() => {
if (props.type === 'online') {
return t('app.onlineMessage')
}
return getOfflineMessage()
})
watch(
() => canPerformNetworkAction.value,
canPerform => {
if (canPerform) {
closeOfflineDialog()
return
}
// 图标
const statusIcon = computed(() => {
return props.type === 'online' ? 'mdi-wifi' : 'mdi-wifi-off'
})
showOfflineDialog()
},
{ immediate: true },
)
// 颜色主题
const colorTheme = computed(() => {
return props.type === 'online' ? 'success' : 'error'
watch(
() => props.type,
() => {
offlineDialogController?.updateProps({ type: props.type })
},
)
onUnmounted(() => {
closeOfflineDialog()
})
</script>
<template>
<VDialog :model-value="shouldShow" persistent max-width="420" scrollable>
<VCard class="offline-dialog">
<!-- 状态图标 -->
<div class="status-icon-wrapper">
<div class="status-icon-bg">
<VIcon :icon="statusIcon" size="48" :color="colorTheme" />
</div>
</div>
<!-- 主要信息 -->
<VCardText class="text-center">
<h2 class="offline-title mb-4">
{{ props.type === 'online' ? t('app.online') : t('app.offline') }}
</h2>
<p class="offline-message mb-6">
{{ statusText }}
</p>
<!-- 重试按钮 -->
<div class="action-section mb-6">
<VBtn
v-if="props.type === 'offline'"
:loading="retrying"
:color="colorTheme"
size="default"
variant="flat"
@click="handleRetry"
>
<VIcon icon="mdi-refresh" class="me-2" />
{{ retrying ? t('common.checking') : t('common.retry') }}
</VBtn>
</div>
<!-- 状态指示器 -->
<div class="status-indicators">
<VChip
:color="isOnline ? 'success' : 'error'"
:prepend-icon="isOnline ? 'mdi-wifi' : 'mdi-wifi-off'"
variant="tonal"
size="small"
class="me-2"
>
{{ isOnline ? t('common.networkOnline') : t('common.networkOffline') }}
</VChip>
<VChip
:color="canPerformNetworkAction ? 'success' : 'warning'"
:prepend-icon="canPerformNetworkAction ? 'mdi-check-circle' : 'mdi-alert-circle'"
variant="tonal"
size="small"
>
{{ canPerformNetworkAction ? t('common.serviceAvailable') : t('common.serviceUnavailable') }}
</VChip>
</div>
</VCardText>
</VCard>
</VDialog>
</template>
<style scoped>
.offline-dialog {
border-radius: 16px;
}
.status-icon-wrapper {
padding-block: 24px 0;
padding-inline: 24px;
text-align: center;
}
.status-icon-bg {
position: relative;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
animation: icon-pulse 3s ease-in-out infinite;
background: rgba(var(--v-theme-surface-variant), 0.5);
block-size: 80px;
inline-size: 80px;
margin-block: 0;
margin-inline: auto;
}
.status-icon-bg::before {
position: absolute;
z-index: -1;
border-radius: 50%;
animation: icon-glow 2s ease-in-out infinite alternate;
background: linear-gradient(45deg, rgb(var(--v-theme-primary)), rgb(var(--v-theme-secondary)));
content: '';
inset: -3px;
opacity: 0.1;
}
@keyframes icon-pulse {
0%,
100% {
transform: scale(1);
}
50% {
transform: scale(1.05);
}
}
@keyframes icon-glow {
0% {
opacity: 0.1;
transform: scale(1);
}
100% {
opacity: 0.3;
transform: scale(1.1);
}
}
.offline-title {
color: rgb(var(--v-theme-on-surface));
font-size: 1.5rem;
font-weight: 600;
}
.offline-message {
color: rgb(var(--v-theme-on-surface));
font-size: 1rem;
line-height: 1.5;
opacity: 0.7;
}
.status-indicators {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 8px;
}
/* 移动端优化 */
@media (width <= 600px) {
.status-icon-bg {
block-size: 70px;
inline-size: 70px;
}
.offline-title {
font-size: 1.25rem;
}
.offline-message {
font-size: 0.9rem;
}
.status-indicators {
flex-direction: column;
align-items: center;
}
}
</style>
<template></template>

View File

@@ -4,6 +4,7 @@ import type { Plugin } from '@/api/types'
import { getLogoUrl } from '@/utils/imageUtils'
import { useI18n } from 'vue-i18n'
import { useRecentPlugins } from '@/composables/useRecentPlugins'
import { openSharedDialog } from '@/composables/useSharedDialog'
import PluginDataDialog from '@/components/dialog/PluginDataDialog.vue'
import { VCard } from 'vuetify/components'
import { getDominantColor } from '@/@core/utils/image'
@@ -64,10 +65,15 @@ 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(() => {
@@ -190,9 +196,15 @@ function handlePluginClick(plugin: Plugin) {
emit('plugin-click', plugin)
// 设置当前插件并显示数据弹窗
currentPlugin.value = plugin
showPluginDataDialog.value = true
openSharedDialog(
PluginDataDialog,
{
plugin,
show_switch: false,
},
{},
{ closeOn: ['close', 'update:modelValue'] },
)
}
// 关闭面板
@@ -200,31 +212,32 @@ function handleClose() {
emit('close')
}
// 关闭插件数据弹窗
function handleClosePluginDataDialog() {
showPluginDataDialog.value = false
currentPlugin.value = null
}
// 管理滚动状态
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 +267,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 +314,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 +340,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 +350,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 +385,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 +423,7 @@ function handleBackdropClick(event: MouseEvent) {
<template>
<VCard
ref="quickAccessRef"
:ripple="false"
class="plugin-quick-access"
:class="{ 'visible': isVisible }"
@@ -408,7 +449,7 @@ function handleBackdropClick(event: MouseEvent) {
</div>
<!-- 插件网格 -->
<div class="plugin-grid">
<div class="plugin-grid quick-access-scroll">
<!-- 加载状态 -->
<LoadingBanner v-if="loading" />
@@ -457,7 +498,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 +541,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
@@ -520,15 +568,6 @@ function handleBackdropClick(event: MouseEvent) {
</div>
</div>
</VCard>
<!-- 插件数据弹窗 -->
<PluginDataDialog
v-if="showPluginDataDialog && currentPlugin"
v-model="showPluginDataDialog"
:plugin="currentPlugin"
:show_switch="false"
@close="handleClosePluginDataDialog"
/>
</template>
<style lang="scss" scoped>
@@ -767,6 +806,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,24 +1,23 @@
<script lang="ts" setup>
import * as Mousetrap from 'mousetrap'
import SearchBarDialog from '@/components/dialog/SearchBarDialog.vue'
import { openSharedDialog } from '@/composables/useSharedDialog'
import { useDisplay } from 'vuetify'
import { useI18n } from 'vue-i18n'
const display = useDisplay()
const { t } = useI18n()
const searchDialog = ref(false)
// 注册快捷键
Mousetrap.bind(['command+k', 'ctrl+k'], openSearchDialog)
// 打开搜索弹窗
/** 打开全局共享搜索弹窗。 */
function openSearchDialog() {
searchDialog.value = true
openSharedDialog(SearchBarDialog, {}, {}, { closeOn: ['close', 'update:modelValue'] })
return false
}
// 检测操作系统是否是Mac
/** 检测操作系统是否是 Mac。 */
function isMac() {
return navigator.platform.toUpperCase().indexOf('MAC') >= 0
}
@@ -38,9 +37,6 @@ const metaKey = computed(() => (isMac() ? '⌘+K' : 'Ctrl+K'))
<span class="search-trigger-text">{{ t('common.search') }}</span>
<kbd class="search-trigger-kbd">{{ metaKey }}</kbd>
</div>
<!-- 搜索弹窗 -->
<SearchBarDialog v-model="searchDialog" v-if="searchDialog" @close="searchDialog = false" />
</template>
<style scoped>

View File

@@ -1,30 +1,37 @@
<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 type { Component } from 'vue'
import { getQueryValue } from '@/@core/utils'
import { openSharedDialog } from '@/composables/useSharedDialog'
import { useI18n } from 'vue-i18n'
import { clearAppBadge } from '@/utils/badge'
type MessageViewExpose = {
pauseSSE?: () => void
resumeSSE?: () => void
refreshLatestMessages?: () => Promise<void> | void
}
// 国际化
const { t } = useI18n()
// 显示器宽度
const display = useDisplay()
// 快捷工具只在弹窗打开时使用,按需加载避免默认布局首屏带上所有 system 视图。
const NameTestView = defineAsyncComponent(() => import('@/views/system/NameTestView.vue'))
const NetTestView = defineAsyncComponent(() => import('@/views/system/NetTestView.vue'))
const RuleTestView = defineAsyncComponent(() => import('@/views/system/RuleTestView.vue'))
const ModuleTestView = defineAsyncComponent(() => import('@/views/system/ModuleTestView.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'))
const ShortcutLogDialog = defineAsyncComponent(() => import('@/components/dialog/ShortcutLogDialog.vue'))
const ShortcutMessageDialog = defineAsyncComponent(() => import('@/components/dialog/ShortcutMessageDialog.vue'))
const ShortcutToolDialog = defineAsyncComponent(() => import('@/components/dialog/ShortcutToolDialog.vue'))
type ShortcutItem = {
bodyClass?: string
cardClass?: string
component?: Component
customDialog?: Component
dialog: string
dialogSubtitle?: string
icon: string
maxWidth?: string
subtitle: string
title: string
titleText?: string
}
// App捷径
const appsMenu = ref(false)
@@ -32,204 +39,119 @@ const appsMenu = ref(false)
// 菜单最大宽度
const menuMaxWidth = ref(420)
// 名称测试弹窗
const nameTestDialog = ref(false)
// 网络测试弹窗
const netTestDialog = ref(false)
// 实时日志弹窗
const loggingDialog = ref(false)
// 过滤规则弹窗
const ruleTestDialog = ref(false)
// 系统健康检查弹窗
const systemTestDialog = ref(false)
// 消息中心弹窗
const messageDialog = ref(false)
// 词表设置弹窗
const wordsDialog = ref(false)
// 缓存管理弹窗
const cacheDialog = ref(false)
// 定时服务弹窗
const schedulerDialog = ref(false)
// 输入消息
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 = [
const shortcuts: ShortcutItem[] = [
{
title: t('shortcut.recognition.title'),
subtitle: t('shortcut.recognition.subtitle'),
icon: 'mdi-text-recognition',
dialog: 'nameTest',
dialogRef: nameTestDialog,
component: NameTestView,
maxWidth: '45rem',
titleText: t('shortcut.recognition.title'),
},
{
title: t('shortcut.rule.title'),
subtitle: t('shortcut.rule.subtitle'),
icon: 'mdi-filter-cog',
dialog: 'ruleTest',
dialogRef: ruleTestDialog,
component: RuleTestView,
titleText: t('shortcut.rule.subtitle'),
},
{
title: t('shortcut.log.title'),
subtitle: t('shortcut.log.subtitle'),
icon: 'mdi-file-document',
dialog: 'logging',
dialogRef: loggingDialog,
customDialog: ShortcutLogDialog,
},
{
title: t('shortcut.network.title'),
subtitle: t('shortcut.network.subtitle'),
icon: 'mdi-network',
dialog: 'netTest',
dialogRef: netTestDialog,
component: NetTestView,
titleText: t('shortcut.network.subtitle'),
},
{
title: t('shortcut.words.title'),
subtitle: t('shortcut.words.subtitle'),
icon: 'mdi-file-word-box',
dialog: 'words',
dialogRef: wordsDialog,
component: WordsView,
maxWidth: '60rem',
titleText: t('shortcut.words.subtitle'),
},
{
title: t('shortcut.cache.title'),
subtitle: t('shortcut.cache.subtitle'),
icon: 'mdi-database',
dialog: 'cache',
dialogRef: cacheDialog,
component: CacheView,
maxWidth: '90rem',
titleText: t('shortcut.cache.subtitle'),
},
{
title: t('shortcut.scheduler.title'),
subtitle: t('shortcut.scheduler.subtitle'),
icon: 'mdi-list-box',
dialog: 'scheduler',
dialogRef: schedulerDialog,
bodyClass: 'pa-0',
component: AccountSettingService,
maxWidth: '60rem',
titleText: t('shortcut.scheduler.subtitle'),
dialogSubtitle: t('setting.scheduler.subtitle'),
},
{
title: t('shortcut.system.title'),
subtitle: t('shortcut.system.subtitle'),
icon: 'mdi-cog',
dialog: 'systemTest',
dialogRef: systemTestDialog,
bodyClass: 'system-health-dialog-body pa-0',
cardClass: 'system-health-dialog-card',
component: ModuleTestView,
titleText: t('shortcut.system.subtitle'),
},
{
title: t('shortcut.message.title'),
subtitle: t('shortcut.message.subtitle'),
icon: 'mdi-message',
dialog: 'message',
dialogRef: messageDialog,
customDialog: ShortcutMessageDialog,
},
]
// 打开对话框
function openDialog(dialogRef: any) {
dialogRef.value = true
}
/** 打开快捷工具对应的共享弹窗。 */
function openShortcutDialog(item: (typeof shortcuts)[number]) {
appsMenu.value = false
// 打开消息弹窗并清除徽章
async 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
function allLoggingUrl() {
return `${import.meta.env.VITE_API_BASE_URL}system/logging?length=-1`
}
// 发送消息
async function sendMessage() {
const messageText = user_message.value.trim()
if (!messageText) {
if (item.customDialog) {
openSharedDialog(item.customDialog, {}, {}, { closeOn: ['close', 'update:modelValue'] })
return
}
try {
sendButtonDisabled.value = true
await api.post(`message/web?text=${encodeURIComponent(messageText)}`)
user_message.value = ''
if (!item.component) return
// 发送成功后主动同步最新一页消息避免SSE短暂断流时界面停留在旧状态。
// await messageViewRef.value?.refreshLatestMessages?.()
forceScrollToEnd() // 发送消息后强制滚动到底部
} catch (error) {
console.error(error)
} finally {
sendButtonDisabled.value = false
}
openSharedDialog(
ShortcutToolDialog,
{
bodyClass: item.bodyClass,
cardClass: item.cardClass,
icon: item.icon,
maxWidth: item.maxWidth ?? '35rem',
subtitle: item.dialogSubtitle,
title: item.titleText ?? item.title,
view: item.component,
},
{},
{ closeOn: ['close', 'update:modelValue'] },
)
}
// 供外部调用的打开消息弹窗方法
/** 供外部调用的打开消息弹窗方法。 */
function openMessageDialogFromExternal() {
openMessageDialog()
const messageShortcut = shortcuts.find(item => item.dialog === 'message')
if (messageShortcut) openShortcutDialog(messageShortcut)
}
// 暴露方法给父组件
@@ -237,20 +159,12 @@ defineExpose({
openMessageDialog: openMessageDialogFromExternal,
})
// 监听消息对话框状态变化
watch(messageDialog, newValue => {
if (!newValue && messageViewRef.value?.pauseSSE) {
// 对话框关闭时暂停SSE连接
messageViewRef.value.pauseSSE()
}
})
onMounted(() => {
const shortcut = getQueryValue('shortcut')
if (shortcut) {
const found = shortcuts.find(item => item.dialog === shortcut)
if (found) {
found.dialogRef.value = true
openShortcutDialog(found)
}
}
})
@@ -293,15 +207,7 @@ onMounted(() => {
flat
class="pa-2 d-flex align-center cursor-pointer transition-transform duration-300 hover:-translate-y-1 border h-full"
hover
@click="
item.dialog === 'message'
? openMessageDialog()
: item.dialog === 'words'
? openDialog(item.dialogRef)
: item.dialog === 'cache'
? openDialog(item.dialogRef)
: openDialog(item.dialogRef)
"
@click="openShortcutDialog(item)"
>
<VAvatar variant="text" size="48" rounded="lg">
<VIcon color="primary" :icon="item.icon" size="24" />
@@ -316,220 +222,4 @@ onMounted(() => {
</div>
</VCard>
</VMenu>
<!-- 名称测试弹窗 -->
<VDialog
v-if="nameTestDialog"
v-model="nameTestDialog"
max-width="45rem"
scrollable
:fullscreen="!display.mdAndUp.value"
>
<VCard>
<VCardItem>
<VCardTitle>
<VIcon icon="mdi-text-recognition" class="me-2" />
{{ t('shortcut.recognition.title') }}
</VCardTitle>
<VDialogCloseBtn @click="nameTestDialog = false" />
</VCardItem>
<VDivider />
<VCardText>
<NameTestView />
</VCardText>
</VCard>
</VDialog>
<!-- 网络测试弹窗 -->
<VDialog
v-if="netTestDialog"
v-model="netTestDialog"
max-width="35rem"
scrollable
:fullscreen="!display.mdAndUp.value"
>
<VCard>
<VCardItem>
<VCardTitle>
<VIcon icon="mdi-network" class="me-2" />
{{ t('shortcut.network.subtitle') }}
</VCardTitle>
<VDialogCloseBtn @click="netTestDialog = false" />
</VCardItem>
<VDivider />
<VCardText>
<NetTestView />
</VCardText>
</VCard>
</VDialog>
<!-- 实时日志弹窗 -->
<VDialog
v-if="loggingDialog"
v-model="loggingDialog"
scrollable
max-width="80rem"
:fullscreen="!display.mdAndUp.value"
>
<VCard>
<VDialogCloseBtn @click="loggingDialog = false" />
<VCardItem>
<VCardTitle class="d-inline-flex">
<VIcon icon="mdi-file-document" class="me-2" />
{{ t('shortcut.log.subtitle') }}
<a class="mx-2 d-inline-flex align-center" :href="allLoggingUrl()" target="_blank">
<VChip color="grey-darken-1" size="small" class="ml-2">
<VIcon icon="mdi-open-in-new" size="small" start />
{{ t('common.openInNewWindow') }}
</VChip>
</a>
</VCardTitle>
</VCardItem>
<VDivider />
<VCardText class="pa-0">
<LoggingView logfile="moviepilot.log" />
</VCardText>
</VCard>
</VDialog>
<!-- 过滤规则弹窗 -->
<VDialog
v-if="ruleTestDialog"
v-model="ruleTestDialog"
max-width="35rem"
scrollable
:fullscreen="!display.mdAndUp.value"
>
<VCard>
<VCardItem>
<VCardTitle>
<VIcon icon="mdi-filter-cog" class="me-2" />
{{ t('shortcut.rule.subtitle') }}
</VCardTitle>
<VDialogCloseBtn @click="ruleTestDialog = false" />
</VCardItem>
<VDivider />
<VCardText>
<RuleTestView />
</VCardText>
</VCard>
</VDialog>
<!-- 词表设置弹窗 -->
<VDialog v-if="wordsDialog" v-model="wordsDialog" max-width="60rem" scrollable :fullscreen="!display.mdAndUp.value">
<VCard>
<VCardItem>
<VCardTitle>
<VIcon icon="mdi-file-word-box" class="me-2" />
{{ t('shortcut.words.subtitle') }}
</VCardTitle>
<VDialogCloseBtn @click="wordsDialog = false" />
</VCardItem>
<VDivider />
<VCardText>
<WordsView />
</VCardText>
</VCard>
</VDialog>
<!-- 缓存管理弹窗 -->
<VDialog v-if="cacheDialog" v-model="cacheDialog" max-width="90rem" scrollable :fullscreen="!display.mdAndUp.value">
<VCard>
<VCardItem>
<VCardTitle>
<VIcon icon="mdi-database" class="me-2" />
{{ t('shortcut.cache.subtitle') }}
</VCardTitle>
<VDialogCloseBtn @click="cacheDialog = false" />
</VCardItem>
<VDivider />
<VCardText>
<CacheView />
</VCardText>
</VCard>
</VDialog>
<!-- 定时服务弹窗 -->
<VDialog
v-if="schedulerDialog"
v-model="schedulerDialog"
max-width="60rem"
scrollable
:fullscreen="!display.mdAndUp.value"
>
<VCard>
<VCardItem class="py-2">
<VCardTitle>
<VIcon icon="mdi-list-box" class="me-2" />
{{ t('shortcut.scheduler.subtitle') }}
</VCardTitle>
<VCardSubtitle>{{ t('setting.scheduler.subtitle') }}</VCardSubtitle>
<VDialogCloseBtn @click="schedulerDialog = false" />
</VCardItem>
<VDivider />
<VCardText class="pa-0">
<AccountSettingService />
</VCardText>
</VCard>
</VDialog>
<!-- 系统健康检查弹窗 -->
<VDialog
v-if="systemTestDialog"
v-model="systemTestDialog"
max-width="35rem"
scrollable
:fullscreen="!display.mdAndUp.value"
>
<VCard>
<VCardItem>
<VCardTitle>
<VIcon icon="mdi-cog" class="me-2" />
{{ t('shortcut.system.subtitle') }}
</VCardTitle>
<VDialogCloseBtn @click="systemTestDialog = false" />
</VCardItem>
<VDivider />
<VCardText class="pa-0">
<ModuleTestView />
</VCardText>
</VCard>
</VDialog>
<!-- 消息中心弹窗 -->
<VDialog
v-if="messageDialog"
v-model="messageDialog"
max-width="50rem"
scrollable
:fullscreen="!display.mdAndUp.value"
ref="messageDialogRef"
>
<VCard>
<VCardItem>
<VCardTitle>
<VIcon icon="mdi-message" class="me-2" />
{{ t('shortcut.message.subtitle') }}
</VCardTitle>
<VDialogCloseBtn @click="messageDialog = false" />
</VCardItem>
<VDivider />
<VCardText ref="messageContentRef">
<MessageView ref="messageViewRef" @scroll="scrollMessageToEnd" />
</VCardText>
<VDivider />
<VCardActions class="pa-4">
<div class="d-flex w-100 gap-2">
<VTextField
v-model="user_message"
variant="outlined"
hide-details
density="compact"
:placeholder="t('common.inputMessage')"
@keyup.enter="sendMessage"
/>
<VBtn
variant="elevated"
:disabled="sendButtonDisabled"
@click="sendMessage"
:loading="sendButtonDisabled"
color="primary"
prepend-icon="mdi-send"
>{{ t('common.send') }}
</VBtn>
</div>
</VCardActions>
</VCard>
</VDialog>
</template>

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

@@ -3,12 +3,10 @@ import { useToast } from 'vue-toastification'
import router from '@/router'
import avatar1 from '@images/avatars/avatar-1.png'
import api from '@/api'
import ProgressDialog from '@/components/dialog/ProgressDialog.vue'
import UserAuthDialog from '@/components/dialog/UserAuthDialog.vue'
import AboutDialog from '@/components/dialog/AboutDialog.vue'
import { openSharedDialog } from '@/composables/useSharedDialog'
import { useAuthStore, useUserStore, useGlobalSettingsStore } from '@/stores'
import { useI18n } from 'vue-i18n'
import { useDisplay, useTheme } from 'vuetify'
import { useTheme } from 'vuetify'
import { SUPPORTED_LOCALES, SupportedLocale } from '@/types/i18n'
import { checkPrefersColorSchemeIsDark } from '@/@core/utils'
import { getCurrentLocale, setI18nLanguage } from '@/plugins/i18n'
@@ -17,6 +15,13 @@ import type { ThemeSwitcherTheme } from '@layouts/types'
import { useConfirm } from '@/composables/useConfirm'
import { themeManager } from '@/utils/themeManager'
import { usePWA, type UIMode } from '@/composables/usePWA'
import { applyStoredTransparencySettings } from '@/composables/useTransparencySettings'
const AboutDialog = defineAsyncComponent(() => import('@/components/dialog/AboutDialog.vue'))
const CustomCssDialog = defineAsyncComponent(() => import('@/components/dialog/CustomCssDialog.vue'))
const ProgressDialog = defineAsyncComponent(() => import('@/components/dialog/ProgressDialog.vue'))
const TransparencySettingsDialog = defineAsyncComponent(() => import('@/components/dialog/TransparencySettingsDialog.vue'))
const UserAuthDialog = defineAsyncComponent(() => import('@/components/dialog/UserAuthDialog.vue'))
// 认证 Store
const authStore = useAuthStore()
@@ -26,23 +31,12 @@ const userStore = useUserStore()
const globalSettingsStore = useGlobalSettingsStore()
// 国际化
const { t } = useI18n()
// 显示器
const display = useDisplay()
// PWA
const { uiMode, setUIMode } = usePWA()
// 提示框
const $toast = useToast()
// 进度框
const progressDialog = ref(false)
// 站点认证对话框
const siteAuthDialog = ref(false)
// 自定义CSS弹窗
const cssDialog = ref(false)
// UI模式菜单是否显示
const showUIModeMenu = ref(false)
@@ -55,39 +49,14 @@ const showLanguageMenu = ref(false)
// 自定义CSS
const customCSS = ref('')
// 透明度相关
const transparencyOpacity = ref(parseFloat(localStorage.getItem('transparency-opacity') || '0.3'))
const transparencyBlur = ref(parseFloat(localStorage.getItem('transparency-blur') || '10'))
const transparencyLevel = ref(localStorage.getItem('transparency-level') || 'medium')
const isTransparentTheme = computed(() => currentThemeName.value === 'transparent')
const showTransparencyDialog = ref(false)
// 关于对话框
const aboutDialog = ref(false)
// 预设值配置
const transparencyPresets = {
low: { opacity: 0.1, blur: 5 },
medium: { opacity: 0.3, blur: 10 },
high: { opacity: 0.6, blur: 15 },
}
// 判断当前值是否匹配预设值
const currentPresetLevel = computed(() => {
for (const [level, preset] of Object.entries(transparencyPresets)) {
if (
Math.abs(transparencyOpacity.value - preset.opacity) < 0.01 &&
Math.abs(transparencyBlur.value - preset.blur) < 0.1
) {
return level
}
}
return null
})
// 重启轮询控制标识
const restartPollingId = ref<number | null>(null)
const isRestarting = ref(false)
let progressDialogController: ReturnType<typeof openSharedDialog> | null = null
let siteAuthDialogController: ReturnType<typeof openSharedDialog> | null = null
let customCssDialogController: ReturnType<typeof openSharedDialog> | null = null
// 确认框
const { createConfirm } = useConfirm()
@@ -108,6 +77,18 @@ function logout() {
router.push('/login')
}
/** 打开重启进度共享弹窗。 */
function showRestartProgress() {
progressDialogController?.close()
progressDialogController = openSharedDialog(ProgressDialog, { text: t('app.restarting') }, {}, { closeOn: false })
}
/** 关闭重启进度共享弹窗。 */
function closeRestartProgress() {
progressDialogController?.close()
progressDialogController = null
}
// 检测服务状态
async function checkServiceStatus(): Promise<boolean> {
try {
@@ -142,7 +123,7 @@ async function pollServiceStatus() {
if (isServiceUp) {
// 服务已恢复,清理状态并执行注销
isRestarting.value = false
progressDialog.value = false
closeRestartProgress()
restartPollingId.value = null
setTimeout(() => {
@@ -154,7 +135,7 @@ async function pollServiceStatus() {
if (retryCount >= maxRetries) {
// 超时未恢复,清理状态并提示用户
isRestarting.value = false
progressDialog.value = false
closeRestartProgress()
restartPollingId.value = null
$toast.error(t('app.restartTimeout'))
return
@@ -176,19 +157,19 @@ async function restart() {
// 调用API重启
try {
// 显示等待框
progressDialog.value = true
showRestartProgress()
const result: { [key: string]: any } = await api.get('system/restart')
if (!result?.success) {
// 重启失败,清理状态
isRestarting.value = false
progressDialog.value = false
closeRestartProgress()
$toast.error(result.message)
return
}
} catch (error) {
// 重启失败,清理状态
isRestarting.value = false
progressDialog.value = false
closeRestartProgress()
console.error(error)
return
}
@@ -212,19 +193,28 @@ async function showRestartDialog() {
await restart()
}
// 显示站点认证对话框
/** 显示站点认证共享弹窗。 */
function showSiteAuthDialog() {
siteAuthDialog.value = true
siteAuthDialogController?.close()
siteAuthDialogController = openSharedDialog(
UserAuthDialog,
{},
{
done: siteAuthDone,
},
{ closeOn: ['close', 'update:modelValue'] },
)
}
// 显示关于对话框
/** 显示关于共享弹窗。 */
function showAboutDialog() {
aboutDialog.value = true
openSharedDialog(AboutDialog, {}, {}, { closeOn: ['close', 'update:modelValue'] })
}
// 用户站点认证成功
/** 用户站点认证成功后关闭弹窗并退出登录。 */
function siteAuthDone() {
siteAuthDialog.value = false
siteAuthDialogController?.close()
siteAuthDialogController = null
logout()
}
@@ -303,8 +293,8 @@ const themes: ThemeSwitcherTheme[] = [
},
]
// 编辑器主题
const editorTheme = computed(() => (currentThemeName.value === 'light' ? 'github' : 'monokai'))
// Ace 跟随 Vuetify 当前生效主题,避免 auto 模式或弹窗打开后切主题时颜色不同步。
const editorTheme = computed(() => (globalTheme.current.value.dark ? 'github_dark' : 'github_light_default'))
// 更新主题
async function updateTheme() {
@@ -333,7 +323,7 @@ async function changeTheme(theme: string) {
// 如果是透明主题,应用透明度设置
if (theme === 'transparent') {
applyTransparencySettings()
applyStoredTransparencySettings()
}
// 保存主题到服务端
@@ -363,85 +353,52 @@ async function getCustomCSS() {
}
}
// 保存自定义 CSS
async function saveCustomCSS() {
cssDialog.value = false
/** 打开自定义 CSS 共享弹窗。 */
function showCustomCssDialog() {
customCssDialogController?.close()
customCssDialogController = openSharedDialog(
CustomCssDialog,
{
css: customCSS.value,
editorTheme: editorTheme.value,
},
{
save: saveCustomCSS,
},
{ closeOn: ['close', 'update:modelValue'] },
)
}
// 共享弹窗打开后也要同步主题变化,否则 Ace 会停留在打开时的配色。
watch(editorTheme, theme => {
customCssDialogController?.updateProps({ editorTheme: theme })
})
/** 打开透明主题设置共享弹窗。 */
function showTransparencySettingsDialog() {
openSharedDialog(TransparencySettingsDialog, {}, {}, { closeOn: ['close', 'update:modelValue'] })
}
/** 保存自定义 CSS。 */
async function saveCustomCSS(css: string) {
customCSS.value = css
try {
const result: { [key: string]: any } = await api.post('system/setting/UserCustomCSS', customCSS.value, {
const result: { [key: string]: any } = await api.post('system/setting/UserCustomCSS', css, {
headers: {
'Content-Type': 'text/plain',
},
})
if (result.success) $toast.success(t('theme.customCssSaveSuccess'))
if (result.success) {
customCssDialogController?.close()
customCssDialogController = null
$toast.success(t('theme.customCssSaveSuccess'))
}
} catch (e) {
console.error(t('theme.customCssSaveFailed'))
}
}
// 应用透明度设置
function applyTransparencySettings() {
const root = document.documentElement
// 设置CSS变量
root.style.setProperty('--transparent-opacity', transparencyOpacity.value.toString())
root.style.setProperty('--transparent-opacity-light', (transparencyOpacity.value * 0.67).toString())
root.style.setProperty('--transparent-opacity-heavy', (transparencyOpacity.value * 1.67).toString())
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`)
// 保存到本地存储
localStorage.setItem('transparency-opacity', transparencyOpacity.value.toString())
localStorage.setItem('transparency-blur', transparencyBlur.value.toString())
}
// 调整透明度预设
function adjustTransparency(level: string) {
transparencyLevel.value = level
localStorage.setItem('transparency-level', level)
// 设置预设值
switch (level) {
case 'low':
transparencyOpacity.value = 0.1
transparencyBlur.value = 5
break
case 'medium':
transparencyOpacity.value = 0.3
transparencyBlur.value = 10
break
case 'high':
transparencyOpacity.value = 0.6
transparencyBlur.value = 15
break
}
applyTransparencySettings()
}
// 透明度变化处理
function onOpacityChange() {
applyTransparencySettings()
// 清除预设级别,因为用户手动调整了
transparencyLevel.value = ''
}
// 模糊度变化处理
function onBlurChange() {
applyTransparencySettings()
// 清除预设级别,因为用户手动调整了
transparencyLevel.value = ''
}
// 重置透明度设置
function resetTransparencySettings() {
transparencyOpacity.value = 0.3
transparencyBlur.value = 10
transparencyLevel.value = 'medium'
applyTransparencySettings()
}
// 监听主题变化
watch(
() => currentThemeName.value,
@@ -450,7 +407,7 @@ watch(
// 如果切换到透明主题,应用透明度设置
if (currentThemeName.value === 'transparent') {
applyTransparencySettings()
applyStoredTransparencySettings()
}
},
)
@@ -507,7 +464,7 @@ onMounted(() => {
// 初始化透明度设置
if (isTransparentTheme.value) {
applyTransparencySettings()
applyStoredTransparencySettings()
}
})
@@ -519,6 +476,9 @@ onUnmounted(() => {
restartPollingId.value = null
}
isRestarting.value = false
closeRestartProgress()
siteAuthDialogController?.close()
customCssDialogController?.close()
})
</script>
@@ -650,7 +610,7 @@ onUnmounted(() => {
<VIcon icon="mdi-check" color="primary" size="small" />
</template>
</VListItem>
<VListItem @click="cssDialog = true">
<VListItem @click="showCustomCssDialog">
<template #prepend>
<VIcon icon="mdi-palette" />
</template>
@@ -660,7 +620,7 @@ onUnmounted(() => {
<!-- 透明度调整 - 仅在透明主题下显示 -->
<template v-if="isTransparentTheme">
<VDivider class="my-2" />
<VListItem @click="showTransparencyDialog = true">
<VListItem @click="showTransparencySettingsDialog">
<template #prepend>
<VIcon icon="mdi-opacity" />
</template>
@@ -747,129 +707,6 @@ onUnmounted(() => {
</VMenu>
<!-- !SECTION -->
</VAvatar>
<!-- 重启进度框 -->
<ProgressDialog v-if="progressDialog" v-model="progressDialog" :text="t('app.restarting')" />
<!-- 用户认证对话框 -->
<UserAuthDialog v-if="siteAuthDialog" v-model="siteAuthDialog" @done="siteAuthDone" @close="siteAuthDialog = false" />
<!-- 自定义 CSS -->
<VDialog v-if="cssDialog" v-model="cssDialog" max-width="50rem" scrollable :fullscreen="!display.mdAndUp.value">
<VCard>
<VCardItem>
<VCardTitle>
<VIcon icon="mdi-palette" class="me-2" />
{{ t('theme.custom') }}
</VCardTitle>
<VDialogCloseBtn @click="cssDialog = false" />
</VCardItem>
<VDivider />
<VAceEditor v-model:value="customCSS" lang="css" :theme="editorTheme" class="w-full min-h-[30rem]" />
<VDivider />
<VCardText class="text-center">
<VBtn @click="saveCustomCSS" class="w-1/2">
<template #prepend>
<VIcon icon="mdi-content-save" />
</template>
{{ t('common.save') }}
</VBtn>
</VCardText>
</VCard>
</VDialog>
<!-- 透明度调整对话框 -->
<VDialog v-if="showTransparencyDialog" v-model="showTransparencyDialog" max-width="30rem">
<VCard>
<VCardItem>
<VCardTitle>
<VIcon icon="mdi-opacity" class="me-2" />
{{ t('theme.transparencyAdjust') }}
</VCardTitle>
<VDialogCloseBtn @click="showTransparencyDialog = false" />
</VCardItem>
<VDivider />
<VCardText>
<div class="space-y-6">
<!-- 透明度滑动条 -->
<div>
<div class="d-flex align-center justify-space-between mb-2">
<span class="text-body-2">{{ t('theme.transparencyOpacity') }}</span>
<span class="text-caption">{{ Math.round(transparencyOpacity * 100) }}%</span>
</div>
<VSlider
v-model="transparencyOpacity"
:min="0"
:max="1"
:step="0.01"
color="primary"
@update:model-value="onOpacityChange"
/>
</div>
<!-- 模糊度滑动条 -->
<div>
<div class="d-flex align-center justify-space-between mb-2">
<span class="text-body-2">{{ t('theme.transparencyBlur') }}</span>
<span class="text-caption">{{ transparencyBlur }}px</span>
</div>
<VSlider
v-model="transparencyBlur"
:min="0"
:max="30"
:step="1"
color="primary"
@update:model-value="onBlurChange"
/>
</div>
<!-- 预设按钮 -->
<div>
<span class="text-body-2 d-block mb-2">{{ t('common.preset') }}</span>
<VBtnGroup density="compact" variant="outlined" class="w-full">
<VBtn
size="small"
:color="currentPresetLevel === 'low' ? 'primary' : undefined"
@click="adjustTransparency('low')"
class="flex-1"
>
{{ t('theme.transparencyLow') }}
</VBtn>
<VBtn
size="small"
:color="currentPresetLevel === 'medium' ? 'primary' : undefined"
@click="adjustTransparency('medium')"
class="flex-1"
>
{{ t('theme.transparencyMedium') }}
</VBtn>
<VBtn
size="small"
:color="currentPresetLevel === 'high' ? 'primary' : undefined"
@click="adjustTransparency('high')"
class="flex-1"
>
{{ t('theme.transparencyHigh') }}
</VBtn>
</VBtnGroup>
</div>
</div>
</VCardText>
<VDivider />
<VCardText class="text-center">
<VBtn @click="resetTransparencySettings" variant="outlined" class="me-2">
<template #prepend>
<VIcon icon="mdi-refresh" />
</template>
{{ t('theme.transparencyReset') }}
</VBtn>
<VBtn @click="showTransparencyDialog = false" color="primary">
{{ t('common.confirm') }}
</VBtn>
</VCardText>
</VCard>
</VDialog>
<!-- 关于对话框 -->
<AboutDialog v-if="aboutDialog" v-model="aboutDialog" @close="aboutDialog = false" />
</template>
<style lang="scss" scoped>

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

@@ -149,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',
@@ -505,6 +507,28 @@ export default {
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',
@@ -960,11 +984,24 @@ export default {
ranking: 'Ranking',
noStatisticsData: 'No share statistics data available',
bestVersion: 'Version Upgrading',
bestVersionEpisodeShort: 'Episode',
bestVersionWholeShort: 'Full',
bestVersionEpisodeProgressTooltip: 'Upgraded {completed} · Downloaded {downloaded} · Total {total}',
subscribeProgressTooltip: 'Downloaded {downloaded} · Total {total}',
completed: 'Completed',
subscribing: 'Subscribing',
notStarted: 'Not Started',
pending: 'Pending',
paused: 'Paused',
cardStatePaused: 'Paused',
cardStatePending: 'Pending',
sortTitle: 'Sort',
sort: {
custom: 'Custom',
lastUpdate: 'Last Updated',
addTime: 'Added Time',
lackEpisode: 'Missing Episodes',
},
selectedCount: 'Selected {count}/{total} items',
noSelectedItems: 'Please select subscriptions to operate',
batchEnable: 'Batch Enable',
@@ -1005,7 +1042,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',
@@ -1394,9 +1431,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.',
@@ -1417,23 +1456,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',
@@ -1518,15 +1570,19 @@ export default {
'Can improve read/write concurrency performance, but may increase the risk of data loss in exceptional cases, requires restart to take effect',
tmdbApiDomain: 'TMDB API Service Address',
tmdbApiDomainPlaceholder: 'api.themoviedb.org',
tmdbApiDomainHint: 'Customize themoviedb API domain or proxy address',
tmdbApiDomainHint: 'Customize TheMovieDb API domain or proxy address',
tmdbApiDomainRequired: 'Please enter TMDB API domain',
tmdbApiKey: 'TMDB API Key',
tmdbApiKeyPlaceholder: 'Please enter TMDB API Key',
tmdbApiKeyHint: 'Set TheMovieDb API Key',
tmdbApiKeyRequired: 'Please enter TMDB API Key',
tmdbImageDomain: 'TMDB Image Service Address',
tmdbImageDomainPlaceholder: 'image.tmdb.org',
tmdbImageDomainHint: 'Customize themoviedb image service domain or proxy address',
tmdbImageDomainHint: 'Customize TheMovieDb image service domain or proxy address',
tmdbImageDomainRequired: 'Please enter image service domain',
tmdbLocale: 'TMDB Metadata Language',
tmdbLocalePlaceholder: 'en',
tmdbLocaleHint: 'Customize themoviedb metadata language',
tmdbLocaleHint: 'Customize TheMovieDb metadata language',
metaCacheExpire: 'Media Metadata Cache Expiration Time',
metaCacheExpireHint: 'Recognition metadata local cache time, use built-in default value when set to 0',
metaCacheExpireRequired: 'Please enter metadata cache time',
@@ -1535,7 +1591,7 @@ export default {
scrapFollowTmdbHint:
'When turned off, organization history will be used (if available) to avoid TMDB data changes during subscription',
scrapOriginalImage: 'Scrap TheMovieDb Original Language Image',
scrapOriginalImageHint: 'Scrap original language image from themoviedb, otherwise scrap metadata language image',
scrapOriginalImageHint: 'Scrap original language image from TheMovieDb, otherwise scrap metadata language image',
fanartEnable: 'Fanart Image Data Source',
fanartEnableHint: 'Use image data from fanart.tv',
fanartLang: 'Fanart Language',
@@ -1598,6 +1654,9 @@ export default {
encodingDetectionPerformanceMode: 'Encoding Detection Performance Mode',
encodingDetectionPerformanceModeHint:
'Prioritize detection efficiency, but may reduce encoding detection accuracy',
rustAccel: 'Rust Acceleration',
rustAccelHint: 'Use the backend Rust extension to accelerate filtering, RSS, indexer parsing, and recognition hot paths',
rustAccelUnavailableHint: 'The backend Rust acceleration extension is not installed or loaded, so this cannot be enabled',
transferThreads: 'File Transfer Threads',
transferThreadsHint: 'Multi-threaded file transfer can improve speed but may increase system resource usage',
tokenizedSearch: 'Tokenized Search',
@@ -1645,6 +1704,11 @@ export default {
securityImageDomainsHint: 'Allowed image domains whitelist for caching, used to control trusted image sources',
noSecurityImageDomains: 'No security domains',
securityImageDomainAdd: 'Add domain, e.g.: image.tmdb.org',
imageProxyAllowedPrivateRanges: 'Image Proxy Allowed Private Ranges',
imageProxyAllowedPrivateRangesHint:
'Only applies after a URL matches a security image domain. Use for TUN mappings or internal CDNs; broad ranges weaken SSRF protection',
noImageProxyAllowedPrivateRanges: 'No allowed ranges',
imageProxyAllowedPrivateRangeAdd: 'Add CIDR, e.g.: 198.18.0.0/15',
proxyHost: 'Proxy Server',
proxyHostHint: 'Set proxy server address, support: http(s), socks5, socks5h, etc.',
moviePilotAutoUpdate: 'Auto Update MoviePilot',
@@ -1707,13 +1771,16 @@ 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',
siteOptions: 'Site Options',
siteDataRefreshInterval: 'Site Data Refresh Interval',
siteDataRefreshIntervalHint: 'Time interval for refreshing site user upload/download data',
searchResourcePages: 'Search Resource Pages',
searchResourcePagesHint:
'Number of consecutive pages to fetch from the current page when searching site resources. Default is 1.',
readSiteMessage: 'Read Site Messages',
readSiteMessageHint: 'Read site messages and send notifications when refreshing data',
siteReset: 'Site Reset',
@@ -1769,6 +1836,7 @@ export default {
channel: 'Notification',
wechat: 'WeChat Work',
wechatClawBot: 'WeChat ClawBot',
feishu: 'Feishu',
resourceDownload: 'Resource Download',
mediaImport: 'Media Import',
subscription: 'Subscription',
@@ -1796,7 +1864,7 @@ export default {
'Word to replace => Replacement\n' +
'Front word <> Back word >> Episode offset (EP)\n' +
'Word to replace => Replacement && Front word <> Back word >> Episode offset (EP)\n' +
'Replacement format supports: &#123;[tmdbid/doubanid=xxx;type=movie/tv;s=xxx;e=xxx]&#125; to directly specify TMDBID/Douban ID, where s and e are season and episode numbers (optional)',
'Replacement format supports: &#123;[tmdbid/doubanid=xxx;type=movie/tv;g=xxx;s=xxx;e=xxx]&#125; to directly specify TMDBID/Douban ID, where g is the episode group ID and s/e are season and episode numbers (all optional)',
identifierSaveSuccess: 'Custom identifiers saved successfully',
identifierSaveFailed: 'Failed to save custom identifiers!',
@@ -1824,6 +1892,29 @@ export default {
excludeWordsHint: 'Support regular expressions, special characters need \\ escape, one line for each block word',
excludeWordsSaveSuccess: 'File organization block words saved successfully',
excludeWordsSaveFailed: 'Failed to save file organization block words!',
episodeFormatRule: 'Manual Reorganize Episode Format Rules',
episodeFormatRuleDesc: 'Extract episode number from filename via regex.',
episodeFormatRuleName: 'Rule Name',
episodeFormatRuleNameHint: 'Enter the rule name',
episodeFormatRulePattern: 'Regular Expression',
episodeFormatRulePatternHint: 'Must contain (?&lt;ep&gt;...) named group',
episodeFormatRuleMinSize: 'Min Size (MB)',
episodeFormatRuleMinSizeHint: 'Files smaller than this will be ignored',
episodeFormatRuleGuideTitle: 'Tips',
episodeFormatRuleGuideContent:
'Rules are matched from top to bottom, and the first matched rule will be used.\n' +
'Write matching rules based on the actual filename structure:\n' +
'Match fixed text directly, and mark the episode number with the named group (?&lt;ep&gt;...);\n' +
'For other variable but position-sensitive parts, use named groups like (?&lt;a&gt;...) and (?&lt;b&gt;...).\n' +
'Escape special characters such as [](). when they should be matched literally;\n' +
'it is recommended to use ^ and $ to constrain the full filename, especially $, to avoid false positives from partial matches.',
episodeFormatRuleAdd: 'Add Rule',
episodeFormatRuleEdit: 'Edit Rule',
episodeFormatRuleDeleteConfirm: 'Are you sure you want to delete this rule?',
episodeFormatRuleSaveSuccess: 'Episode format rules saved successfully',
episodeFormatRuleSaveFailed: 'Failed to save episode format rules!',
episodeFormatRuleEmptyError: 'Rule name and regular expression cannot be empty',
},
search: {
basicSettings: 'Basic Settings',
@@ -2467,9 +2558,13 @@ export default {
doubanId: 'Douban ID',
mediaIdHint: 'Query media ID by name, leave empty for auto recognition',
mediaIdPlaceholder: 'Leave empty for auto recognition',
episodeGroup: 'Episode Group ID',
episodeGroupHint: 'Specify episode group',
episodeGroupPlaceholder: 'Manually query episode group',
episodeGroup: 'Episode Group',
episodeGroupHint: 'After entering a TMDB ID, episode groups are queried automatically; group IDs can still be entered manually',
episodeGroupPlaceholder: 'Enter TMDB ID first',
defaultEpisodeGroup: 'No episode group',
defaultEpisodeGroupHint: 'Use TMDB default season and episode order',
seasonCount: '{count} seasons',
episodeCount: '{count} episodes',
season: 'Season',
seasonHint: 'Which season',
episodeDetail: 'Episode',
@@ -2478,6 +2573,17 @@ export default {
episodeFormat: 'Episode Positioning',
episodeFormatHint: 'Use {ep} to position episode number part in filename to assist recognition',
episodeFormatPlaceholder: 'Use {ep} to position episode',
episodeFormatRecommendAction: 'Generate',
episodeFormatRecommendLoading: 'Generating...',
episodeFormatRecommendSelectFile: 'Please select a single file, a single directory, or multiple files first',
episodeFormatRecommendInvalidSelection: 'Current selection cannot be used for template generation',
episodeFormatRecommendNeedWords:
'Manual episode positioning rules are empty, please fill them in on the words page first',
episodeFormatRecommendSuccess: 'Episode format template generated',
episodeFormatRecommendOverwriteSuccess: 'Current episode format has been overwritten by the generated result',
episodeFormatRecommendFailed: 'Failed to generate episode format, please try again later',
episodeFormatRecommendRule: 'Matched Rule: {rule}',
episodeFormatRecommendSample: 'Sample File: {file}',
episodeOffset: 'Episode Offset',
episodeOffsetHint: 'Episode offset calculation, e.g. -10 or EP*2',
episodeOffsetPlaceholder: 'e.g. -10',
@@ -2494,6 +2600,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',
@@ -2528,6 +2657,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',
@@ -2547,7 +2678,7 @@ export default {
customWords: 'Custom Recognition Words',
customWordsHint: 'Recognition words only used for this subscription',
customWordsPlaceholder:
'Block word\nReplaced word => Replacement word\nPrefix <> Suffix >> Episode offset (EP)\nReplaced word => Replacement word && Prefix <> Suffix >> Episode offset (EP)\nReplacement word supports format: &#123; tmdbid/doubanid=xxx;type=movie/tv;s=xxx;e=xxx &#125; to directly specify TMDBID/Douban ID recognition, where s, e are season and episode numbers (optional)',
'Block word\nReplaced word => Replacement word\nPrefix <> Suffix >> Episode offset (EP)\nReplaced word => Replacement word && Prefix <> Suffix >> Episode offset (EP)\nReplacement word supports format: &#123;[tmdbid/doubanid=xxx;type=movie/tv;g=xxx;s=xxx;e=xxx]&#125; to directly specify TMDBID/Douban ID recognition, where g is the episode group ID and s/e are season and episode numbers (all optional)',
cancelSubscribe: 'Cancel Subscription',
save: 'Save',
cancelSubscribeConfirm: 'Are you sure you want to cancel the subscription?',
@@ -2677,6 +2808,7 @@ export default {
close: 'Close',
loadingDirectoryStructure: 'Loading directory structure...',
reorganize: 'Reorganize',
filterPlaceholder: 'Filter (supports * ? wildcards)',
},
person: {
alias: 'Also Known As:',
@@ -2734,6 +2866,8 @@ export default {
projectHome: 'Project Home',
updateHistory: 'Update History',
local: 'Local',
systemVersion: 'System Version',
incompatibleSystemVersion: 'The current MoviePilot version does not meet this plugin requirement.',
installToLocal: 'Install to Local',
totalDownloads: 'Total {count} downloads',
viewData: 'View Data',
@@ -2826,6 +2960,7 @@ export default {
accountBinding: 'Account Binding',
wechatUser: 'WeChat Work User',
wechatClawBotUser: 'WeChat ClawBot User',
feishuUser: 'Feishu User',
telegramUser: 'Telegram User',
slackUser: 'Slack User',
discordUser: 'Discord User',
@@ -2889,7 +3024,7 @@ export default {
},
transferHistory: {
title: 'Transfer History',
searchPlaceholder: 'Search transfer records',
searchPlaceholder: 'Search (supports * ? wildcards)',
titleColumn: 'Title',
pathColumn: 'Path',
modeColumn: 'Mode',
@@ -2997,7 +3132,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',
@@ -3416,6 +3552,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',

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