Compare commits

...

113 Commits

Author SHA1 Message Date
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
jxxghp
4207a70716 feat: add support for ZSpace media server integration including UI configuration and logo assets 2026-05-11 18:09:29 +08:00
jxxghp
c97247b92b refactor: optimize initial loading view and viewport synchronization logic for iOS standalone mode 2026-05-11 12:45:20 +08:00
jxxghp
e9bed7ff8a feat: update loading shell transition and add exit animation transform 2026-05-11 12:35:42 +08:00
jxxghp
f25a619f13 refactor: optimize initial loading screen layout and theme handling for improved PWA startup experience 2026-05-11 12:25:31 +08:00
jxxghp
2065b05143 refactor: remove manual chunk splitting configuration in vite build settings 2026-05-10 23:46:04 +08:00
jxxghp
eec1f2d7b3 style: update loading background to cover full viewport using dynamic units 2026-05-10 22:58:02 +08:00
jxxghp
17a343392c refactor: replace mobile device check with touch capability detection for tab scroll controls 2026-05-10 22:48:49 +08:00
jxxghp
a2b2e8cd94 feat: implement automatic refresh logic for expired WeChat Claw Bot QR codes 2026-05-10 22:45:21 +08:00
jxxghp
9703b2dbee 更新 package.json 2026-05-10 22:12:21 +08:00
jxxghp
310a501380 feat: implement QR code generation for WechatClawBot status display 2026-05-10 22:10:30 +08:00
jxxghp
30bf895ae1 fix: preserve wechat clawbot login state across renames 2026-05-10 21:50:33 +08:00
jxxghp
4f9dce70d3 feat: add wechat clawbot notification setup UI 2026-05-10 21:47:35 +08:00
jxxghp
f495e13667 style: add horizontal padding to overlay list content in common styles 2026-05-10 09:40:19 +08:00
jxxghp
f293681588 refactor: implement search parameter state management and prevent API caching for search requests 2026-05-09 23:02:17 +08:00
jxxghp
2f1a356e65 fix: replace virtual card grid with progressive loading 2026-05-09 22:23:45 +08:00
jxxghp
5909d2423c fix: stabilize virtual card grid during fast scrolling 2026-05-09 21:50:32 +08:00
jxxghp
42f7df8f4a fix: refine data cleanup settings tab
Move the data tab before log, hide retention fields until cleanup is enabled, and remove the extra download files prompt to keep the advanced settings flow focused.
2026-05-09 21:40:35 +08:00
jxxghp
abaa40d819 feat: add data cleanup settings tab
Expose the cleanup switch and per-table retention periods in advanced settings so administrators can manage data cleanup from the UI.
2026-05-09 21:22:02 +08:00
jxxghp
0d05a104c4 refactor: migrate site management actions to dynamic floating menu and update sort mode exit buttons 2026-05-09 18:37:16 +08:00
jxxghp
e8708f8de7 fix: add exit action for drag sort mode 2026-05-09 18:10:56 +08:00
jxxghp
7918b21b5b fix sites title align 2026-05-09 18:05:52 +08:00
jxxghp
088db67089 fix: make sort mode drag-only across cards 2026-05-09 18:04:10 +08:00
jxxghp
62e0d8e9dc perf: virtualize remaining long result views
Reduce DOM growth across resource, history, workflow, share, downloading, and message views so large datasets stay responsive while scrolling.
2026-05-09 17:28:23 +08:00
jxxghp
96d655155a perf: virtualize management lists and make drag sorting opt-in 2026-05-09 16:07:28 +08:00
jxxghp
a475085d7b refactor: implement buffered streaming updates and disable keep-alive for resource 2026-05-09 13:22:40 +08:00
jxxghp
58fdb77b37 更新 index.ts 2026-05-09 12:25:18 +08:00
jxxghp
8a25c6578d 更新 index.ts 2026-05-09 12:15:08 +08:00
jxxghp
ef62bd6e98 fix: restore horizontal slide loading placeholders
Wrap VirtualSlideView loading content in a horizontal track so media and person slide skeletons keep their original full-width carousel layout and title presentation during loading.
2026-05-09 09:02:40 +08:00
jxxghp
876a46607b chore: bump version to 2.11.0 2026-05-09 08:51:59 +08:00
jxxghp
107f70abde refactor: unify horizontal card virtualization
Replace the remaining slide loading fallbacks with VirtualSlideView so horizontal media and person carousels use a single rendering path. Remove the now-unused SlideView component to keep the slide system smaller and easier to maintain.
2026-05-09 08:48:49 +08:00
jxxghp
090b9d735d feat: add media recognition sharing setting and update system settings UI layout 2026-05-09 08:33:29 +08:00
jxxghp
dbeea6afcc perf: reduce frontend memory pressure and startup cost
Limit long-lived page and component retention while virtualizing large card views to keep runtime memory lower. Defer heavy editor, chart, workflow, calendar, and icon code so the app loads less JavaScript up front.
2026-05-09 08:32:14 +08:00
jxxghp
2931f5df46 更新 package.json 2026-05-08 11:21:47 +08:00
jxxghp
e14c81d178 feat(settings): persist LLM base URL presets 2026-05-08 10:52:30 +08:00
179 changed files with 18219 additions and 8832 deletions

5
env.d.ts vendored
View File

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

View File

@@ -1,11 +1,14 @@
<!DOCTYPE html>
<html lang="zh-CN" style="
overflow: hidden auto;
min-block-size: 100vh;
min-block-size: 100dvh;
<html lang="zh-CN" data-launch-loading="true" style="
overflow: hidden;
--safe-area-inset-bottom: env(safe-area-inset-bottom);
--safe-area-inset-top: env(safe-area-inset-top);
background: var(--initial-loader-bg, #fff);
--initial-loader-bg: #0E1116;
--initial-loader-color: #9155FD;
--initial-loader-height: 100svh;
--initial-loader-width: 100vw;
background: var(--initial-loader-bg, #0E1116);
background-color: var(--initial-loader-bg, #0E1116);
">
<head>
@@ -92,50 +95,95 @@
<link rel="preconnect" href="https://cdn.jsdelivr.net" crossorigin />
<style>
#app {
min-block-size: 100%;
html,
body {
background: var(--initial-loader-bg, #0E1116);
background-color: var(--initial-loader-bg, #0E1116);
}
html[data-launch-loading="true"],
html[data-launch-loading="true"] body {
overflow: hidden;
}
html[data-launch-loading="true"] body {
min-block-size: var(--initial-loader-height, 100svh);
}
html[data-launch-loading="true"] #app {
min-block-size: var(--initial-loader-height, 100svh);
background: var(--initial-loader-bg, #0E1116);
background-color: var(--initial-loader-bg, #0E1116);
-webkit-overflow-scrolling: touch;
overscroll-behavior: contain;
}
#loading-bg {
position: fixed;
inset: 0;
z-index: 99999;
display: block;
background: var(--initial-loader-bg, #fff);
block-size: 100vh;
inline-size: 100vw;
transition: opacity 0.8s ease, transform 0.8s ease, filter 0.8s ease;
overflow: hidden;
background: var(--initial-loader-bg, #0E1116);
background-color: var(--initial-loader-bg, #0E1116);
}
.loading-shell {
box-sizing: border-box;
display: grid;
grid-template-rows: minmax(0, 1fr) auto;
block-size: var(--initial-loader-height, 100svh);
inline-size: 100%;
min-block-size: var(--initial-loader-height, 100svh);
transition: opacity 0.12s ease-out, transform 0.12s ease-out;
padding:
calc(env(safe-area-inset-top, 0px) + 24px)
24px
calc(env(safe-area-inset-bottom, 0px) + 48px);
}
.loading-main {
display: flex;
align-items: center;
justify-content: center;
min-block-size: 0;
}
.loading-logo {
position: absolute;
inset-block-start: 35%;
inset-inline-start: calc(50% - 5rem);
transition: opacity 0.8s ease, transform 0.8s ease, filter 0.8s ease;
display: flex;
align-items: center;
justify-content: center;
inline-size: min(160px, 36vw);
transform: translate3d(0, 0, 0);
will-change: transform;
}
.loading-complete .loading-logo {
filter: blur(10px);
opacity: 0;
transform: scale(1.5);
.loading-logo img {
display: block;
block-size: auto;
inline-size: 100%;
}
.loading-complete {
filter: blur(15px);
.loading-footer {
display: flex;
align-items: center;
justify-content: center;
min-block-size: clamp(72px, 14vh, 120px);
}
.loading-complete .loading-shell,
.loading-complete #loading-timeout {
opacity: 0;
transform: scale(1.2);
transform: translate3d(0, 6px, 0);
}
.loading {
position: absolute;
position: relative;
box-sizing: border-box;
border: 3px solid transparent;
border-radius: 50%;
block-size: 55px;
inline-size: 55px;
inset-block-start: 80%;
inset-inline-start: calc(50% - 27.5px);
block-size: 46px;
inline-size: 46px;
transition: opacity 0.6s ease;
}
@@ -198,7 +246,7 @@
position: absolute;
z-index: 2500;
display: none;
inset-block-end: 20px;
inset-block-end: calc(env(safe-area-inset-bottom, 0px) + 24px);
inset-inline-start: 50%;
transform: translateX(-50%);
background: rgba(0, 0, 0, 0.8);
@@ -209,7 +257,8 @@
font-family: sans-serif;
text-align: center;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4);
white-space: nowrap;
max-inline-size: calc(100% - 32px);
white-space: normal;
backdrop-filter: blur(4px);
border: 1px solid rgba(255, 255, 255, 0.1);
}
@@ -233,25 +282,65 @@
}
}
// 主题色彩初始化
let loaderColor = localStorage.getItem('materio-initial-loader-bg')
let primaryColor = localStorage.getItem('materio-initial-loader-color')
// 检查主题设置
const savedTheme = localStorage.getItem('theme') || 'auto'
const isAutoTheme = savedTheme === 'auto'
// 如果是自动主题或者没有保存的背景色,根据系统主题设置背景色
if (isAutoTheme || !loaderColor) {
loaderColor = checkPrefersColorSchemeIsDark() ? '#0E1116' : '#FFFFFF'
// 根据当前主题提前确定启动屏色彩,避免 iOS PWA 从原生启动图切到网页时露出默认白底。
const launchThemeBackgrounds = {
light: '#F4F5FA',
dark: '#0E1116',
purple: '#28243D',
transparent: '#1C1C1C',
default: '#F4F5FA',
}
const savedTheme = localStorage.getItem('theme') || 'auto'
const resolvedLaunchTheme = savedTheme === 'auto'
? (checkPrefersColorSchemeIsDark() ? 'dark' : 'light')
: savedTheme
let loaderColor = localStorage.getItem('materio-initial-loader-bg')
|| launchThemeBackgrounds[resolvedLaunchTheme]
|| launchThemeBackgrounds.light
let primaryColor = localStorage.getItem('materio-initial-loader-color')
if (!primaryColor) {
primaryColor = '#9155FD'
}
// 在应用脚本接管前锁定一次启动层内容高度,避免 iOS 独立模式首次重算 safe area 时把 logo 顶下去。
function syncInitialViewport(force) {
const viewport = window.visualViewport
const nextHeight = Math.round(viewport?.height || window.innerHeight || document.documentElement.clientHeight || 0)
const nextWidth = Math.round(viewport?.width || window.innerWidth || document.documentElement.clientWidth || 0)
const currentHeight = parseInt(
document.documentElement.style.getPropertyValue('--initial-loader-height') || '0',
10,
)
if (!nextHeight || !nextWidth) {
return
}
if (!force && currentHeight && Math.abs(nextHeight - currentHeight) < 120) {
return
}
document.documentElement.style.setProperty('--initial-loader-height', `${nextHeight}px`)
document.documentElement.style.setProperty('--initial-loader-width', `${nextWidth}px`)
}
// 应用主题色彩
document.documentElement.setAttribute('data-launch-theme', resolvedLaunchTheme)
document.documentElement.style.setProperty('--initial-loader-bg', loaderColor)
document.documentElement.style.setProperty('--initial-loader-color', primaryColor)
document.documentElement.style.backgroundColor = loaderColor
syncInitialViewport(true)
document.addEventListener('DOMContentLoaded', () => {
document.body.style.backgroundColor = loaderColor
})
window.addEventListener('orientationchange', () => {
window.setTimeout(() => syncInitialViewport(true), 160)
})
// 状态栏适配
if (window.navigator.standalone) {
@@ -343,14 +432,20 @@
<body style="margin: 0; overflow: hidden; overscroll-behavior: none; -webkit-overflow-scrolling: touch">
<div id="loading-bg">
<div class="loading-logo">
<!-- Logo -->
<img src="/logo.svg" alt="MoviePilot" width="160px" height="160px" />
</div>
<div class="loading">
<div class="effect-1 effects"></div>
<div class="effect-2 effects"></div>
<div class="effect-3 effects"></div>
<div class="loading-shell">
<div class="loading-main">
<div class="loading-logo">
<!-- Logo -->
<img src="/logo.svg" alt="MoviePilot" width="160" height="160" />
</div>
</div>
<div class="loading-footer">
<div class="loading">
<div class="effect-1 effects"></div>
<div class="effect-2 effects"></div>
<div class="effect-3 effects"></div>
</div>
</div>
</div>
<!-- 超时提示 - 默认隐藏 -->
<div id="loading-timeout"></div>
@@ -359,4 +454,4 @@
<script type="module" src="/src/main.ts"></script>
</body>
</html>
</html>

View File

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

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

@@ -17,6 +17,7 @@ import { createRequire } from 'node:module'
// Get current directory
const __dirname = dirname(fileURLToPath(import.meta.url))
const projectSrcDir = join(__dirname, '..')
// Create require function for importing JSON files in ESM
const require = createRequire(import.meta.url)
@@ -86,36 +87,12 @@ const sources: BundleScriptConfig = {
],
icons: [
// 'mdi:home',
// 'mdi:account',
// 'mdi:login',
// 'mdi:logout',
// 'octicon:book-24',
// 'octicon:code-square-24',
'lucide:sparkles',
'material-symbols:passkey',
'line-md:loading-twotone-loop',
],
json: [
// Custom JSON file
// 'json/gg.json',
// Iconify JSON file (@iconify/json is a package name, /json/ is directory where files are, then filename)
require.resolve('@iconify-json/mdi/icons.json'),
// Custom file with only few icons
// {
// filename: require.resolve('@iconify-json/line-md/icons.json'),
// icons: [
// 'home-twotone-alt',
// 'github',
// 'document-list',
// 'document-code',
// 'image-twotone',
// ],
// },
],
json: [],
}
// Iconify component (this changes import statement in generated file)
@@ -133,6 +110,15 @@ const target = join(__dirname, 'icons-bundle.js');
*/
// eslint-disable-next-line sonarjs/cognitive-complexity
(async function () {
const scannedIcons = await collectUsedIcons(projectSrcDir)
if (sources.icons) {
sources.icons.push(...scannedIcons)
sources.icons = Array.from(new Set(sources.icons)).sort()
} else {
sources.icons = scannedIcons
}
let bundle = commonJS
? `const { addCollection } = require('${component}');\n\n`
: `import { addCollection } from '${component}';\n\n`
@@ -278,8 +264,60 @@ 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[]> {
const icons = new Set<string>()
const files = await walkDirectory(rootDir)
const sourceFiles = files.filter(file => /\.(vue|ts|js|tsx|jsx)$/.test(file))
for (const file of sourceFiles) {
if (file.includes('/@iconify/')) {
continue
}
const content = await fs.readFile(file, 'utf8')
for (const match of content.matchAll(/\b(lucide|material-symbols|line-md|tabler):([a-z0-9-]+)\b/g)) {
icons.add(`${match[1]}:${match[2]}`)
}
for (const match of content.matchAll(/\bmdi:([a-z0-9-]+)\b/g)) {
icons.add(`mdi:${match[1]}`)
}
for (const match of content.matchAll(/\btabler-([a-z0-9-]+)\b/g)) {
icons.add(`tabler:${match[1]}`)
}
for (const match of content.matchAll(/\bmdi-([a-z0-9-]+)\b/g)) {
icons.add(`mdi:${match[1]}`)
}
}
return Array.from(icons).sort()
}
async function walkDirectory(dir: string): Promise<string[]> {
const entries = await fs.readdir(dir, { withFileTypes: true })
const files: string[] = []
for (const entry of entries) {
const fullPath = join(dir, entry.name)
if (entry.isDirectory()) {
files.push(...(await walkDirectory(fullPath)))
continue
}
files.push(fullPath)
}
return files
}
/**
* Remove metadata from icon set
*/

View File

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

View File

@@ -11,7 +11,11 @@ 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()
@@ -19,6 +23,16 @@ let themeValue = localStorage.getItem('theme') || 'auto'
const autoTheme = checkPrefersColorSchemeIsDark() ? 'dark' : 'light'
globalTheme.name.value = themeValue === 'auto' ? autoTheme : themeValue
// 启动屏和 iOS safe area 在同一层显示,根节点底色需要尽早和当前主题保持一致。
function syncRootLaunchPalette() {
const { background, primary } = globalTheme.current.value.colors
document.documentElement.style.setProperty('--initial-loader-bg', background)
document.documentElement.style.setProperty('--initial-loader-color', primary)
document.documentElement.style.backgroundColor = background
document.body.style.backgroundColor = background
}
// 生效语言
const localeValue = getBrowserLocale()
setI18nLanguage(localeValue as SupportedLocale)
@@ -26,6 +40,7 @@ setI18nLanguage(localeValue as SupportedLocale)
// 检查是否登录
const authStore = useAuthStore()
const isLogin = computed(() => authStore.token)
const route = useRoute()
// 全局设置store
const globalSettingsStore = useGlobalSettingsStore()
@@ -37,17 +52,36 @@ 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
// ApexCharts 全局配置
declare global {
interface Window {
Apex: any
}
}
// 启动心跳
const startHeartbeat = () => {
// 如果已经有心跳,则先停止
@@ -75,56 +109,20 @@ const stopHeartbeat = () => {
}
}
// 配置 ApexCharts 全局选项
function configureApexCharts() {
if (typeof window !== 'undefined' && window.Apex) {
try {
// 获取当前主题
const currentTheme = globalTheme.name.value
const isDark = currentTheme === 'dark' || currentTheme === 'transparent'
// 数据标签
window.Apex.dataLabels = {
formatter: function (_: number, { seriesIndex, w }: { seriesIndex: number; w: any }) {
// 如果有小数点,保留两位小数,否则保留整数
const data = w.config.series[seriesIndex]
return data.toFixed(data % 1 === 0 ? 0 : 1)
},
}
// 图例
window.Apex.legend = {
labels: {
useSeriesColors: true,
},
}
// 标题
window.Apex.title = {
style: {
color: 'rgba(var(--v-theme-on-surface), var(--v-high-emphasis-opacity))',
},
}
// 鼠标悬浮提示
window.Apex.tooltip = {
theme: isDark ? 'dark' : 'light',
}
} catch (error) {
console.warn('ApexCharts 全局配置失败:', error)
}
}
}
// 更新data-theme属性以便CSS选择器能正确匹配
function updateHtmlThemeAttribute(themeName: string) {
document.documentElement.setAttribute('data-theme', themeName)
document.body.setAttribute('data-theme', themeName)
syncRootLaunchPalette()
}
// 获取背景图片
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) {
@@ -166,12 +164,56 @@ function startBackgroundRotation() {
}
}
function stopBackgroundLoading() {
backgroundRequestController?.abort()
backgroundRequestController = null
if (backgroundRetryTimer) {
window.clearTimeout(backgroundRetryTimer)
backgroundRetryTimer = null
}
removeBackgroundTimer('background-rotation')
}
async function initializeAuthenticatedState() {
if (!isLogin.value) return
try {
globalLoadingStateManager.setLoadingState('global-settings', true)
await globalSettingsStore.initialize()
await globalSettingsStore.loadUserSettings()
} finally {
globalLoadingStateManager.setLoadingState('global-settings', false)
}
}
function scheduleAuthenticatedStateInitialization() {
if (authenticatedStateTimer) {
window.clearTimeout(authenticatedStateTimer)
}
// 登录后会立刻发生路由切换,稍后再拉取设置可避开导航中止请求。
authenticatedStateTimer = window.setTimeout(() => {
authenticatedStateTimer = null
initializeAuthenticatedState()
}, 150)
}
// 添加logo动画效果并延迟移除加载界面
function animateAndRemoveLoader() {
const loadingBg = document.querySelector('#loading-bg') as HTMLElement
if (loadingBg) {
removeEl('#loading-bg')
document.documentElement.style.removeProperty('background')
// 只收掉启动内容,背景层保持实色直到节点被移除,避免底部 safe area 先透出页面内容。
loadingBg.classList.add('loading-complete')
window.setTimeout(() => {
removeEl('#loading-bg')
// 启动阶段的根节点锁定只在 loader 存在时生效,移除后恢复正常页面与弹窗布局。
document.documentElement.removeAttribute('data-launch-loading')
document.documentElement.style.removeProperty('overflow')
document.body.style.removeProperty('overflow')
}, 120)
}
}
@@ -180,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
@@ -190,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()
@@ -214,7 +239,9 @@ async function removeLoadingWithStateCheck() {
animateAndRemoveLoader()
// 检查未读消息
checkAndEmitUnreadMessages()
if (isLogin.value) {
checkAndEmitUnreadMessages()
}
} catch (error) {
// 即使出错也要移除加载界面
globalLoadingStateManager.reset()
@@ -233,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)
}
@@ -250,7 +278,7 @@ onMounted(async () => {
}
// 配置 ApexCharts
configureApexCharts()
configureApexChartsTheme(globalTheme.name.value)
// 初始化data-theme属性
updateHtmlThemeAttribute(globalTheme.name.value)
@@ -265,24 +293,55 @@ onMounted(async () => {
// 更新HTML主题属性
updateHtmlThemeAttribute(newTheme)
// 重新配置ApexCharts以适应新主题
configureApexCharts()
configureApexChartsTheme(newTheme)
},
)
// 加载背景图片
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()
})
@@ -291,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}`"
@@ -305,6 +368,8 @@ onUnmounted(() => {
<!-- 页面内容 -->
<VApp>
<RouterView />
<!-- 全局共享弹窗入口列表与卡片按需在这里挂载业务弹窗 -->
<SharedDialogHost />
<!-- PWA安装提示 -->
<PWAInstallPrompt />
</VApp>
@@ -356,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

@@ -68,6 +68,10 @@ export const mediaServerOptions = [
value: 'emby',
title: i18n.global.t('setting.system.emby'),
},
{
value: 'zspace',
title: i18n.global.t('setting.system.zspace'),
},
{
value: 'jellyfin',
title: i18n.global.t('setting.system.jellyfin'),

View File

@@ -58,6 +58,8 @@ export interface Subscribe {
sites: number[]
// 是否洗版数字或者boolean
best_version: any
// 是否只洗全集整包数字或者boolean
best_version_full?: any
// 使用 imdbid 搜索
search_imdbid?: any
// 当前优先级
@@ -644,6 +646,12 @@ export interface Plugin {
has_page?: boolean
// 是否有新版本
has_update?: boolean
// 主系统版本是否兼容
system_version_compatible?: boolean
// 主系统版本兼容提示
system_version_message?: string
// 主系统版本限定范围
system_version?: string
// 是否本地插件
is_local?: boolean
// 插件仓库地址
@@ -1145,7 +1153,7 @@ export interface StorageConf {
export interface MediaServerConf {
// 名称
name: string
// 类型 emby/jellyfin/plex/trimemedia/ugreen
// 类型 emby/zspace/jellyfin/plex/trimemedia/ugreen
type: string
// 配置
config: { [key: string]: any }
@@ -1311,6 +1319,57 @@ export interface TransferForm {
library_category_folder?: boolean
// 剧集组编号
episode_group?: string
// 预览模式
preview?: boolean
}
// 手动整理请求
export interface ManualTransferPayload extends TransferForm {}
// 手动整理预览统计
export interface ManualTransferPreviewSummary {
// 总数
total: number
// 成功数
success: number
// 失败数
failed: number
}
// 手动整理预览项
export interface ManualTransferPreviewItem {
// 原始路径
source?: string
// 目标路径
target?: string
// 目标目录
target_dir?: string
// 是否成功
success?: boolean
// 提示信息
message?: string
// 媒体类型
type?: string
// 媒体标题
title?: string
// 季
season?: number | string
// 开始集
episode?: number | string
// 结束集
episode_end?: number | string
// Part
part?: string
}
// 手动整理预览数据
export interface ManualTransferPreviewData {
// 统计信息
summary: ManualTransferPreviewSummary
// 预览结果
items: ManualTransferPreviewItem[]
// 额外消息
message?: string
}
// 整理队列

Binary file not shown.

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.2 KiB

View File

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

View File

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

@@ -40,6 +40,7 @@ function imageErrorHandler() {
function getDefaultImage() {
if (props.media?.server_type === 'plex') return plex
else if (props.media?.server_type === 'emby') return emby
else if (props.media?.server_type === 'zspace') return getLogoUrl('zspace')
else if (props.media?.server_type === 'jellyfin') return jellyfin
else if (props.media?.server_type === 'trimemedia') return getLogoUrl('trimemedia')
else if (props.media?.server_type === 'ugreen') return getLogoUrl('ugreen')

View File

@@ -8,12 +8,20 @@ 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,
setCachedMediaExistsStatus,
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()
@@ -53,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[]>([])
@@ -87,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 {
@@ -123,6 +158,22 @@ function getMediaId() {
else return `${props.media?.mediaid_prefix}:${props.media?.media_id}`
}
function getSubscribeStatusKey(season: number | null = props.media?.season ?? null) {
return `${getMediaId()}::${season ?? 'all'}`
}
function getExistsStatusKey() {
return [
props.media?.tmdb_id ?? '',
props.media?.title ?? '',
props.media?.year ?? '',
props.media?.season ?? '',
props.media?.type ?? '',
props.media?.mediaid_prefix ?? '',
props.media?.media_id ?? '',
].join('::')
}
// 角标颜色
function getChipColor(type: string) {
if (type === '电影') return 'border-blue-500 bg-blue-600'
@@ -135,7 +186,7 @@ async function handleAddSubscribe() {
if (props.media?.type === '电视剧') {
// 弹出季选择列表,支持多选
seasonsSelected.value = []
subscribeSeasonDialog.value = true
openSubscribeSeasonDialog()
} else {
// 电影
addSubscribe()
@@ -167,6 +218,7 @@ async function addSubscribe(season: number | null = null, best_version: number =
if (result.success) {
// 订阅成功
isSubscribed.value = true
setCachedMediaSubscribeStatus(getSubscribeStatusKey(season), true)
}
// 提示
@@ -176,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) {
@@ -213,6 +264,7 @@ async function removeSubscribe() {
if (result.success) {
isSubscribed.value = false
setCachedMediaSubscribeStatus(getSubscribeStatusKey(props.media?.season ?? null), false)
$toast.success(`${props.media?.title} ${t('subscribe.cancelSuccess')}`)
} else {
$toast.error(`${props.media?.title} ${t('subscribe.cancelFailed', { message: result.message })}`)
@@ -227,8 +279,10 @@ async function removeSubscribe() {
// 查询当前媒体是否已订阅
async function handleCheckSubscribe() {
try {
const result = await checkSubscribe(props.media?.season ?? null)
if (result) isSubscribed.value = true
const subscribed = await getCachedMediaSubscribeStatus(getSubscribeStatusKey(props.media?.season ?? null), () =>
checkSubscribe(props.media?.season ?? null),
)
isSubscribed.value = subscribed
} catch (error) {
console.error(error)
}
@@ -237,17 +291,22 @@ async function handleCheckSubscribe() {
// 查询当前媒体是否已入库
async function handleCheckExists() {
try {
const result: { [key: string]: any } = await api.get('mediaserver/exists', {
params: {
tmdbid: props.media?.tmdb_id,
title: props.media?.title,
year: props.media?.year,
season: props.media?.season,
mtype: props.media?.type,
},
const exists = await getCachedMediaExistsStatus(getExistsStatusKey(), async () => {
const result: { [key: string]: any } = await api.get('mediaserver/exists', {
params: {
tmdbid: props.media?.tmdb_id,
title: props.media?.title,
year: props.media?.year,
season: props.media?.season,
mtype: props.media?.type,
},
})
return Boolean(result.success)
})
if (result.success) isExists.value = true
isExists.value = exists
setCachedMediaExistsStatus(getExistsStatusKey(), exists)
} catch (error) {
console.error(error)
}
@@ -265,12 +324,14 @@ async function checkSubscribe(season: number | null) {
},
})
return result.id || null
} catch (error) {
console.error(error)
}
return Boolean(result.id)
} catch (error: any) {
if (error?.response?.status === 404) {
return false
}
return null
throw error
}
}
// 查询订阅弹窗规则
@@ -297,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 => {
@@ -342,7 +402,7 @@ async function clickSearch() {
await querySelectedSites()
}
if (allSites.value?.length > 0) {
chooseSiteDialog.value = true
openSearchSiteDialog()
} else {
handleSearch()
}
@@ -366,7 +426,6 @@ function handleSearch() {
// 搜索多站点
function searchSites(sites: number[]) {
chooseSiteDialog.value = false
selectedSites.value = sites
handleSearch()
}
@@ -416,7 +475,7 @@ const getImgUrl: Ref<string> = computed(() => {
// 移除订阅
function onRemoveSubscribe() {
subscribeEditDialog.value = false
isSubscribed.value = false
}
// 获取媒体类型文本
@@ -532,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'] },
)
}
// 根据存储类型选择图标
@@ -121,6 +70,8 @@ const getIcon = computed(() => {
switch (props.mediaserver.type) {
case 'emby':
return getLogoUrl('emby')
case 'zspace':
return getLogoUrl('zspace')
case 'jellyfin':
return getLogoUrl('jellyfin')
case 'trimemedia':
@@ -134,12 +85,12 @@ const getIcon = computed(() => {
}
})
// 按钮点击
/** 关闭媒体服务器卡片。 */
function onClose() {
emit('close')
}
// 调用API加载媒体统计数据
/** 调用 API 加载媒体服务器统计数据。 */
async function loadMediaStatistic() {
try {
const res: MediaStatistic = await api.get('dashboard/statistic', {
@@ -172,458 +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 == '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>

View File

@@ -1,15 +1,14 @@
<script setup lang="ts">
import { NotificationConf } from '@/api/types'
import type { NotificationConf } from '@/api/types'
import { getLogoUrl } from '@/utils/imageUtils'
import { useToast } from 'vue-toastification'
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 NotificationChannelInfoDialog = defineAsyncComponent(() => import('@/components/dialog/NotificationChannelInfoDialog.vue'))
const { t } = useI18n()
const { accentRgb, imageRef, updateAccentColor } = useCardAccentColor()
// 定义输入
const props = defineProps({
@@ -28,23 +27,11 @@ const props = defineProps({
// 定义触发的自定义事件
const emit = defineEmits(['close', 'change', 'done'])
// 提示框
const $toast = useToast()
// 通知详情弹窗
const notificationInfoDialog = ref(false)
// 通知详情
const notificationInfo = ref<NotificationConf>({
name: '',
type: '',
enabled: false,
config: {},
})
// 各通知类型的名称字典
const notificationTypeNames: { [key: string]: string } = {
wechat: t('notification.wechat.name'),
feishu: t('notification.feishu.name'),
wechatclawbot: t('notification.wechatclawbot.name'),
telegram: t('notification.telegram.name'),
qqbot: t('notification.qqbot.name'),
vocechat: t('notification.vocechat.name'),
@@ -55,71 +42,20 @@ const notificationTypeNames: { [key: string]: string } = {
custom: t('setting.notification.custom'),
}
// 消息类型下拉字典
const notificationTypes = [
{ value: '资源下载', title: t('notificationSwitch.resourceDownload') },
{ value: '整理入库', title: t('notificationSwitch.organize') },
{ value: '订阅', title: t('notificationSwitch.subscribe') },
{ value: '站点', title: t('notificationSwitch.site') },
{ value: '媒体服务器', title: t('notificationSwitch.mediaServer') },
{ value: '手动处理', title: t('notificationSwitch.manual') },
{ value: '插件', title: t('notificationSwitch.plugin') },
{ value: '智能体', title: t('notificationSwitch.agent') },
{ value: '其它', title: t('notificationSwitch.other') },
]
function ensureWechatConfigDefaults(notification: NotificationConf) {
if (notification.type !== 'wechat') {
return
}
if (!notification.config) {
notification.config = {}
}
if (!notification.config.WECHAT_MODE) {
notification.config.WECHAT_MODE = 'app'
}
if (!notification.config.WECHAT_BOT_WS_URL) {
notification.config.WECHAT_BOT_WS_URL = 'wss://openws.work.weixin.qq.com'
}
}
const isWechatBotMode = computed({
get: () => notificationInfo.value.config?.WECHAT_MODE === 'bot',
set: value => {
if (!notificationInfo.value.config) {
notificationInfo.value.config = {}
}
notificationInfo.value.config.WECHAT_MODE = value ? 'bot' : 'app'
if (value && !notificationInfo.value.config.WECHAT_BOT_WS_URL) {
notificationInfo.value.config.WECHAT_BOT_WS_URL = 'wss://openws.work.weixin.qq.com'
}
},
})
// 打开详情弹窗
/** 打开共享通知渠道配置弹窗。 */
function openNotificationInfoDialog() {
// 替换成深复制,避免修改时影响原数据
notificationInfo.value = cloneDeep(props.notification)
ensureWechatConfigDefaults(notificationInfo.value)
notificationInfoDialog.value = true
}
// 保存详情数据
function saveNotificationInfo() {
// 为空不保存,跳出警告框
if (!notificationInfo.value.name) {
$toast.error(t('notification.name') + t('common.required'))
return
}
// 重名判断
if (props.notifications.some(item => item.name === notificationInfo.value.name && item !== props.notification)) {
$toast.error(t('notification.channel') + `${notificationInfo.value.name}` + t('common.exists'))
return
}
ensureWechatConfigDefaults(notificationInfo.value)
notificationInfoDialog.value = false
emit('change', notificationInfo.value, props.notification.name)
emit('done')
openSharedDialog(
NotificationChannelInfoDialog,
{
notification: props.notification,
notifications: props.notifications,
},
{
change: (...args: unknown[]) => emit('change', ...args),
done: () => emit('done'),
},
{ closeOn: ['close', 'update:modelValue'] },
)
}
// 根据存储类型选择图标
@@ -127,6 +63,10 @@ const getIcon = computed(() => {
switch (props.notification.type) {
case 'wechat':
return getLogoUrl('wechat')
case 'wechatclawbot':
return getLogoUrl('wechatclawbot')
case 'feishu':
return getLogoUrl('feishu')
case 'telegram':
return getLogoUrl('telegram')
case 'qqbot':
@@ -146,520 +86,36 @@ const getIcon = computed(() => {
}
})
// 按钮点击
/** 关闭通知渠道卡片。 */
function onClose() {
emit('close')
}
</script>
<template>
<div>
<VCard variant="tonal" class="app-card-shell" @click="openNotificationInfoDialog">
<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">
<div class="app-card-summary__title-row">
<VBadge v-if="props.notification.enabled" dot inline color="success" class="me-1" />
<span class="app-card-summary__title text-h6">{{ props.notification.name }}</span>
</div>
<div class="app-card-summary__subtitle text-body-1">{{ notificationTypeNames[notification.type] }}</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="notificationInfoDialog"
v-model="notificationInfoDialog"
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.notification.name }}</VCardSubtitle>
</VCardItem>
<VDialogCloseBtn @click="notificationInfoDialog = false" />
<VDivider />
<VCardText>
<VForm>
<VRow>
<VCol cols="12" md="6">
<VSwitch v-model="notificationInfo.enabled" :label="t('notification.enabled')" />
</VCol>
<VCol cols="12">
<VAutocomplete
v-model="notificationInfo.switchs"
:items="notificationTypes"
:label="t('notification.type')"
:hint="t('notification.typeHint')"
multiple
clearable
chips
persistent-hint
prepend-inner-icon="mdi-bell-outline"
/>
</VCol>
</VRow>
<VRow v-if="notificationInfo.type == 'wechat'">
<VCol cols="12" md="6">
<VTextField
v-model="notificationInfo.name"
:label="t('notification.name')"
:placeholder="t('notification.name')"
:hint="t('notification.nameHint')"
persistent-hint
prepend-inner-icon="mdi-label"
/>
</VCol>
<VCol cols="12" md="6">
<VSwitch
v-model="isWechatBotMode"
:label="t('notification.wechat.useBotMode')"
:hint="t('notification.wechat.useBotModeHint')"
persistent-hint
color="primary"
/>
</VCol>
<template v-if="isWechatBotMode">
<VCol cols="12" md="6">
<VTextField
v-model="notificationInfo.config.WECHAT_BOT_ID"
:label="t('notification.wechat.botId')"
:hint="t('notification.wechat.botIdHint')"
persistent-hint
prepend-inner-icon="mdi-robot"
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="notificationInfo.config.WECHAT_BOT_SECRET"
:label="t('notification.wechat.botSecret')"
:hint="t('notification.wechat.botSecretHint')"
persistent-hint
prepend-inner-icon="mdi-key"
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="notificationInfo.config.WECHAT_BOT_CHAT_ID"
:label="t('notification.wechat.botChatId')"
:placeholder="t('notification.wechat.botChatIdPlaceholder')"
:hint="t('notification.wechat.botChatIdHint')"
persistent-hint
prepend-inner-icon="mdi-chat-processing"
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="notificationInfo.config.WECHAT_BOT_WS_URL"
:label="t('notification.wechat.botWsUrl')"
:hint="t('notification.wechat.botWsUrlHint')"
persistent-hint
prepend-inner-icon="mdi-lan-connect"
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="notificationInfo.config.WECHAT_ADMINS"
:label="t('notification.wechat.admins')"
:placeholder="t('notification.wechat.adminsPlaceholder')"
:hint="t('notification.wechat.adminsHint')"
persistent-hint
prepend-inner-icon="mdi-account-supervisor"
/>
</VCol>
</template>
<template v-else>
<VCol cols="12" md="6">
<VTextField
v-model="notificationInfo.config.WECHAT_CORPID"
:label="t('notification.wechat.corpId')"
:hint="t('notification.wechat.corpIdHint')"
persistent-hint
prepend-inner-icon="mdi-domain"
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="notificationInfo.config.WECHAT_APP_ID"
:label="t('notification.wechat.appId')"
:hint="t('notification.wechat.appIdHint')"
persistent-hint
prepend-inner-icon="mdi-application"
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="notificationInfo.config.WECHAT_APP_SECRET"
:label="t('notification.wechat.appSecret')"
:hint="t('notification.wechat.appSecretHint')"
persistent-hint
prepend-inner-icon="mdi-key"
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="notificationInfo.config.WECHAT_PROXY"
:label="t('notification.wechat.proxy')"
:hint="t('notification.wechat.proxyHint')"
persistent-hint
prepend-inner-icon="mdi-server-network"
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="notificationInfo.config.WECHAT_TOKEN"
:label="t('notification.wechat.token')"
:hint="t('notification.wechat.tokenHint')"
persistent-hint
prepend-inner-icon="mdi-key-variant"
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="notificationInfo.config.WECHAT_ENCODING_AESKEY"
:label="t('notification.wechat.encodingAesKey')"
:hint="t('notification.wechat.encodingAesKeyHint')"
persistent-hint
prepend-inner-icon="mdi-lock"
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="notificationInfo.config.WECHAT_ADMINS"
:label="t('notification.wechat.admins')"
:placeholder="t('notification.wechat.adminsPlaceholder')"
:hint="t('notification.wechat.adminsHint')"
persistent-hint
prepend-inner-icon="mdi-account-supervisor"
/>
</VCol>
</template>
</VRow>
<VRow v-else-if="notificationInfo.type == 'telegram'">
<VCol cols="12" md="6">
<VTextField
v-model="notificationInfo.name"
:label="t('notification.name')"
:placeholder="t('notification.name')"
:hint="t('notification.nameHint')"
persistent-hint
prepend-inner-icon="mdi-label"
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="notificationInfo.config.TELEGRAM_TOKEN"
:label="t('notification.telegram.token')"
:hint="t('notification.telegram.tokenHint')"
persistent-hint
prepend-inner-icon="mdi-key"
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="notificationInfo.config.TELEGRAM_CHAT_ID"
:label="t('notification.telegram.chatId')"
:hint="t('notification.telegram.chatIdHint')"
persistent-hint
prepend-inner-icon="mdi-chat"
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="notificationInfo.config.TELEGRAM_USERS"
:label="t('notification.telegram.users')"
:placeholder="t('notification.telegram.usersPlaceholder')"
:hint="t('notification.telegram.usersHint')"
persistent-hint
prepend-inner-icon="mdi-account-group"
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="notificationInfo.config.TELEGRAM_ADMINS"
:label="t('notification.telegram.admins')"
:placeholder="t('notification.telegram.adminsPlaceholder')"
:hint="t('notification.telegram.adminsHint')"
persistent-hint
prepend-inner-icon="mdi-account-supervisor"
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="notificationInfo.config.API_URL"
:label="t('notification.telegram.apiUrl')"
:placeholder="t('notification.telegram.apiUrlPlaceholder')"
:hint="t('notification.telegram.apiUrlHint')"
persistent-hint
prepend-inner-icon="mdi-web"
/>
</VCol>
</VRow>
<VRow v-else-if="notificationInfo.type == 'slack'">
<VCol cols="12" md="6">
<VTextField
v-model="notificationInfo.name"
:label="t('notification.name')"
:placeholder="t('notification.name')"
:hint="t('notification.nameHint')"
persistent-hint
prepend-inner-icon="mdi-label"
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="notificationInfo.config.SLACK_OAUTH_TOKEN"
:label="t('notification.slack.oauthToken')"
:placeholder="t('notification.slack.oauthTokenPlaceholder')"
:hint="t('notification.slack.oauthTokenHint')"
persistent-hint
prepend-inner-icon="mdi-key"
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="notificationInfo.config.SLACK_APP_TOKEN"
:label="t('notification.slack.appToken')"
:placeholder="t('notification.slack.appTokenPlaceholder')"
:hint="t('notification.slack.appTokenHint')"
persistent-hint
prepend-inner-icon="mdi-application"
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="notificationInfo.config.SLACK_CHANNEL"
:label="t('notification.slack.channel')"
:placeholder="t('notification.slack.channelPlaceholder')"
:hint="t('notification.slack.channelHint')"
persistent-hint
prepend-inner-icon="mdi-pound"
/>
</VCol>
</VRow>
<VRow v-else-if="notificationInfo.type == 'discord'">
<VCol cols="12" md="6">
<VTextField
v-model="notificationInfo.name"
:label="t('notification.name')"
:placeholder="t('notification.name')"
:hint="t('notification.nameHint')"
persistent-hint
prepend-inner-icon="mdi-label"
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="notificationInfo.config.DISCORD_BOT_TOKEN"
:label="t('notification.discord.botToken')"
:hint="t('notification.discord.botTokenHint')"
persistent-hint
prepend-inner-icon="mdi-key-variant"
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="notificationInfo.config.DISCORD_GUILD_ID"
:label="t('notification.discord.guildId')"
:placeholder="t('notification.discord.guildIdPlaceholder')"
:hint="t('notification.discord.guildIdHint')"
persistent-hint
prepend-inner-icon="mdi-pound"
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="notificationInfo.config.DISCORD_CHANNEL_ID"
:label="t('notification.discord.channelId')"
:placeholder="t('notification.discord.channelIdPlaceholder')"
:hint="t('notification.discord.channelIdHint')"
persistent-hint
prepend-inner-icon="mdi-pound-box"
/>
</VCol>
</VRow>
<VRow v-else-if="notificationInfo.type == 'synologychat'">
<VCol cols="12" md="6">
<VTextField
v-model="notificationInfo.name"
:label="t('notification.name')"
:placeholder="t('notification.name')"
:hint="t('notification.nameHint')"
persistent-hint
prepend-inner-icon="mdi-label"
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="notificationInfo.config.SYNOLOGYCHAT_WEBHOOK"
:label="t('notification.synologychat.webhook')"
:hint="t('notification.synologychat.webhookHint')"
persistent-hint
prepend-inner-icon="mdi-webhook"
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="notificationInfo.config.SYNOLOGYCHAT_TOKEN"
:label="t('notification.synologychat.token')"
:hint="t('notification.synologychat.tokenHint')"
persistent-hint
prepend-inner-icon="mdi-key"
/>
</VCol>
</VRow>
<VRow v-else-if="notificationInfo.type == 'vocechat'">
<VCol cols="12" md="6">
<VTextField
v-model="notificationInfo.name"
:label="t('notification.name')"
:placeholder="t('notification.name')"
:hint="t('notification.nameHint')"
persistent-hint
prepend-inner-icon="mdi-label"
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="notificationInfo.config.VOCECHAT_HOST"
:label="t('notification.vocechat.host')"
:hint="t('notification.vocechat.hostHint')"
persistent-hint
prepend-inner-icon="mdi-server"
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="notificationInfo.config.VOCECHAT_API_KEY"
:label="t('notification.vocechat.apiKey')"
:hint="t('notification.vocechat.apiKeyHint')"
persistent-hint
prepend-inner-icon="mdi-key"
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="notificationInfo.config.VOCECHAT_CHANNEL_ID"
:label="t('notification.vocechat.channelId')"
:placeholder="t('notification.vocechat.channelIdPlaceholder')"
:hint="t('notification.vocechat.channelIdHint')"
persistent-hint
prepend-inner-icon="mdi-pound"
/>
</VCol>
</VRow>
<VRow v-else-if="notificationInfo.type == 'qqbot'">
<VCol cols="12" md="6">
<VTextField
v-model="notificationInfo.name"
:label="t('notification.name')"
:placeholder="t('notification.name')"
:hint="t('notification.nameHint')"
persistent-hint
prepend-inner-icon="mdi-label"
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="notificationInfo.config.QQ_APP_ID"
:label="t('notification.qqbot.appId')"
:hint="t('notification.qqbot.appIdHint')"
persistent-hint
prepend-inner-icon="mdi-application"
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="notificationInfo.config.QQ_APP_SECRET"
:label="t('notification.qqbot.appSecret')"
:hint="t('notification.qqbot.appSecretHint')"
persistent-hint
prepend-inner-icon="mdi-key"
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="notificationInfo.config.QQ_OPENID"
:label="t('notification.qqbot.openId')"
:placeholder="t('notification.qqbot.openIdPlaceholder')"
:hint="t('notification.qqbot.openIdHint')"
persistent-hint
prepend-inner-icon="mdi-account"
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="notificationInfo.config.QQ_GROUP_OPENID"
:label="t('notification.qqbot.groupOpenId')"
:placeholder="t('notification.qqbot.groupOpenIdPlaceholder')"
:hint="t('notification.qqbot.groupOpenIdHint')"
persistent-hint
prepend-inner-icon="mdi-account-group"
/>
</VCol>
</VRow>
<VRow v-else-if="notificationInfo.type == 'webpush'">
<VCol cols="12" md="6">
<VTextField
v-model="notificationInfo.name"
:label="t('notification.name')"
:placeholder="t('notification.name')"
:hint="t('notification.nameHint')"
persistent-hint
prepend-inner-icon="mdi-label"
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="notificationInfo.config.WEBPUSH_USERNAME"
:label="t('notification.webpush.username')"
:hint="t('notification.webpush.usernameHint')"
persistent-hint
prepend-inner-icon="mdi-account"
/>
</VCol>
</VRow>
<VRow v-else>
<VCol cols="12" md="6">
<VTextField
v-model="notificationInfo.type"
:label="t('notification.type')"
:hint="t('notification.customTypeHint')"
persistent-hint
active
prepend-inner-icon="mdi-cog"
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="notificationInfo.name"
:label="t('notification.name')"
:hint="t('notification.nameRequired')"
persistent-hint
prepend-inner-icon="mdi-label"
/>
</VCol>
</VRow>
</VForm>
</VCardText>
<VCardActions class="pt-3">
<VBtn @click="saveNotificationInfo" 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="openNotificationInfoDialog"
>
<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">
<div class="app-card-summary__title-row">
<VBadge v-if="props.notification.enabled" dot inline color="success" class="me-1" />
<span class="app-card-summary__title text-h6">{{ props.notification.name }}</span>
</div>
<div class="app-card-summary__subtitle text-body-1">{{ notificationTypeNames[notification.type] }}</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>

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({
@@ -25,6 +26,10 @@ const props = defineProps({
action: Boolean, // 动作标识
width: String,
height: String,
sortable: {
type: Boolean,
default: false,
},
})
// 定义触发的自定义事件
@@ -33,6 +38,9 @@ const emit = defineEmits(['remove', 'save', 'actionDone'])
// 多语言
const { t } = useI18n()
// 显示器宽度
const display = useDisplay()
// 背景颜色
const backgroundColor = ref('#28A9E1')
@@ -48,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)
@@ -75,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(
@@ -115,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'] },
)
}
}
@@ -130,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 }))
@@ -149,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'] },
)
}
// 计算图标路径
@@ -216,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: {
@@ -230,7 +243,7 @@ async function updatePlugin() {
})
// 隐藏等待提示框
progressDialog.value = false
closePluginProgress()
if (result.success) {
$toast.success(t('plugin.updateSuccess', { name: props.plugin?.plugin_name }))
@@ -246,6 +259,7 @@ async function updatePlugin() {
)
}
} catch (error) {
closePluginProgress()
console.error(error)
}
}
@@ -255,74 +269,77 @@ 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()
else showPluginConfig()
}
function handleCardClick() {
if (props.sortable) {
return
}
openPluginDetail()
}
// 配置完成
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([
{
@@ -390,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'] })
},
},
},
@@ -420,6 +437,7 @@ watch(
(newOpenState, _) => {
if (newOpenState) openPluginDetail()
},
{ immediate: true },
)
</script>
@@ -433,11 +451,13 @@ watch(
v-bind="hover.props"
:width="props.width"
:height="props.height"
@click="openPluginDetail"
@click="handleCardClick"
class="flex flex-col h-full"
:class="{
'transition transform-cpu duration-300 -translate-y-1': hover.isHovering,
'transition transform-cpu duration-300 -translate-y-1': hover.isHovering && !props.sortable,
'cursor-move': props.sortable,
}"
:ripple="!props.sortable"
>
<div
class="flex-grow"
@@ -458,7 +478,10 @@ watch(
{{ props.plugin?.plugin_desc }}
</div>
</div>
<div class="relative flex-shrink-0 self-center pb-3" :class="{ 'cursor-move': display.mdAndUp.value }">
<div
class="relative flex-shrink-0 self-center pb-3"
:class="{ 'cursor-move': props.sortable && display.mdAndUp.value }"
>
<VAvatar size="48">
<VImg
ref="imageRef"
@@ -482,7 +505,11 @@ watch(
<VIcon v-if="!isAvatarLoaded" size="small" icon="mdi-github" class="me-1" />
</template>
</VImg>
<span v-if="props.sortable" class="overflow-hidden text-ellipsis whitespace-nowrap">
{{ props.plugin?.plugin_author }}
</span>
<a
v-else
:href="props.plugin?.author_url"
target="_blank"
@click.stop
@@ -496,8 +523,8 @@ watch(
<span class="text-sm">{{ formatDownloadCount(props.count) }}</span>
</span>
</div>
<div class="absolute bottom-0 right-0">
<IconBtn>
<div v-if="!props.sortable" class="absolute bottom-0 right-0">
<IconBtn @click.stop>
<VIcon icon="mdi-dots-vertical" />
<VMenu v-model="menuVisible" activator="parent" close-on-content-click>
<VList>
@@ -525,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 {
@@ -25,6 +29,10 @@ const props = defineProps({
},
width: String,
height: String,
sortable: {
type: Boolean,
default: false,
},
})
// 定义触发的自定义事件
@@ -44,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'
@@ -62,130 +62,76 @@ 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)
}
// 重命名文件夹
function handleCardClick() {
if (props.sortable) {
return
}
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)
}
@@ -209,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([
{
@@ -275,11 +217,12 @@ const dropdownItems = ref([
:width="props.width"
:height="props.height"
min-height="8.5rem"
@click="openFolder"
@click="handleCardClick"
class="plugin-folder-card h-full"
:class="{
'plugin-folder-card--mobile': display.mobile,
'plugin-folder-card--hover': hover.isHovering,
'plugin-folder-card--hover': hover.isHovering && !props.sortable,
'plugin-folder-card--sortable': props.sortable,
}"
>
<template v-if="backgroundImage" #image>
@@ -302,14 +245,14 @@ const dropdownItems = ref([
:icon="folderIcon"
:size="display.mobile ? 56 : 72"
:color="iconColor"
:class="{ 'cursor-move': display.mdAndUp.value }"
:class="{ 'cursor-move': props.sortable && display.mdAndUp.value }"
/>
</div>
<!-- 文件夹信息 -->
<div
class="plugin-folder-card__info"
:class="{ 'cursor-move': display.mdAndUp.value, 'plugin-folder-card__info--no-icon': !shouldShowIcon }"
:class="{ 'cursor-move': props.sortable && display.mdAndUp.value, 'plugin-folder-card__info--no-icon': !shouldShowIcon }"
>
<!-- 文件夹名称 -->
<h3 class="plugin-folder-card__name">
@@ -321,7 +264,7 @@ const dropdownItems = ref([
</div>
<!-- 更多菜单按钮 - 右下角 -->
<div class="absolute top-0 right-0">
<div v-if="!props.sortable" class="absolute top-0 right-0">
<VMenu v-model="menuVisible" location="top end" :close-on-content-click="true">
<template #activator="{ props: menuProps }">
<IconBtn v-bind="menuProps" @click.stop>
@@ -348,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>
@@ -491,6 +301,10 @@ const dropdownItems = ref([
cursor: pointer;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
&--sortable {
cursor: move;
}
&--hover {
transform: translateY(-4px);
}

View File

@@ -14,12 +14,14 @@ interface Props {
pluginStatistics?: { [key: string]: number }
pluginActions?: { [key: string]: boolean }
showRemoveButton?: boolean
sortable?: boolean
}
const props = withDefaults(defineProps<Props>(), {
pluginStatistics: () => ({}),
pluginActions: () => ({}),
showRemoveButton: false,
sortable: false,
})
const emit = defineEmits<{
@@ -36,7 +38,7 @@ const emit = defineEmits<{
// 拖拽事件处理
function handleDragOver(event: DragEvent) {
// 只有当拖拽的是插件时才允许放入文件夹
if (props.item.type === 'folder') {
if (props.sortable && props.item.type === 'folder') {
event.preventDefault()
event.stopPropagation()
event.dataTransfer!.dropEffect = 'move'
@@ -46,14 +48,14 @@ function handleDragOver(event: DragEvent) {
}
function handleDragEnter(event: DragEvent) {
if (props.item.type === 'folder') {
if (props.sortable && props.item.type === 'folder') {
event.preventDefault()
event.stopPropagation()
}
}
function handleDragLeave(event: DragEvent) {
if (props.item.type === 'folder') {
if (props.sortable && props.item.type === 'folder') {
event.preventDefault()
event.stopPropagation()
const target = event.currentTarget as HTMLElement
@@ -62,7 +64,7 @@ function handleDragLeave(event: DragEvent) {
}
function handleDropToFolder(event: DragEvent) {
if (props.item.type === 'folder') {
if (props.sortable && props.item.type === 'folder') {
event.preventDefault()
event.stopPropagation()
const target = event.currentTarget as HTMLElement
@@ -89,6 +91,7 @@ function handleDropToFolder(event: DragEvent) {
:folder-name="item.data.name"
:plugin-count="item.data.pluginCount"
:folder-config="item.data.config"
:sortable="sortable"
@open="$emit('openFolder', item.id)"
@delete="$emit('deleteFolder', item.id)"
@rename="(oldName, newName) => $emit('renameFolder', oldName, newName)"
@@ -102,6 +105,7 @@ function handleDropToFolder(event: DragEvent) {
:count="pluginStatistics[item.id] || 0"
:plugin="item.data"
:action="pluginActions[item.id] || false"
:sortable="sortable"
@remove="$emit('refreshData')"
@save="$emit('refreshData')"
@action-done="$emit('actionDone', item.id)"
@@ -109,7 +113,7 @@ function handleDropToFolder(event: DragEvent) {
<!-- 移出文件夹按钮(仅在文件夹内显示) -->
<VBtn
v-if="showRemoveButton"
v-if="showRemoveButton && !sortable"
icon="mdi-folder-remove"
variant="text"
color="warning"

View File

@@ -3,16 +3,19 @@ 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'
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()
@@ -25,6 +28,10 @@ const cardProps = defineProps({
site: Object as PropType<Site>,
data: Object as PropType<SiteUserData>,
stats: Object as PropType<SiteStatistic>,
sortable: {
type: Boolean,
default: false,
},
})
// 定义触发的自定义事件
@@ -34,7 +41,8 @@ const emit = defineEmits(['update', 'remove', 'refresh-stats'])
const createConfirm = useConfirm()
// 图标
const siteIcon = ref<string>('')
const defaultSiteIcon = getLogoUrl('site')
const siteIcon = ref<string>(defaultSiteIcon)
// 提示框
const $toast = useToast()
@@ -45,26 +53,22 @@ 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
if (!siteId) {
siteIcon.value = defaultSiteIcon
return
}
try {
siteIcon.value = (await api.get(`site/icon/${cardProps.site?.id}`)).data.icon
if (!siteIcon.value) {
siteIcon.value = getLogoUrl('site')
}
siteIcon.value = await getCachedSiteIcon(siteId, async () => {
const response = await api.get(`site/icon/${siteId}`)
return response?.data?.icon || defaultSiteIcon
})
} catch (error) {
siteIcon.value = defaultSiteIcon
console.error(error)
}
}
@@ -91,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'] },
)
}
// 打开站点页面
@@ -109,6 +140,22 @@ function openSitePage() {
window.open(cardProps.site?.url, '_blank')
}
function handleCardClick() {
if (cardProps.sortable) {
return
}
handleResourceBrowse()
}
function handleSiteUrlClick() {
if (cardProps.sortable) {
return
}
openSitePage()
}
// 调用API删除站点信息
async function deleteSiteInfo() {
const isConfirmed = await createConfirm({
@@ -169,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)
}
@@ -196,21 +240,24 @@ onMounted(() => {
<template>
<div>
<VCard
class="site-card relative h-full flex flex-col overflow-hidden group transition-all duration-300 cursor-pointer hover:-translate-y-1"
class="site-card relative h-full flex flex-col overflow-hidden group transition-all duration-300"
:class="[
cardProps.site?.is_active ? '' : 'opacity-70',
{
'border-error': statColor === 'error',
'border-warning': statColor === 'warning',
'border-success': statColor === 'success',
'cursor-pointer hover:-translate-y-1': !cardProps.sortable,
'cursor-move': cardProps.sortable,
'site-card--sortable': cardProps.sortable,
},
]"
:ripple="false"
variant="flat"
elevation="0"
rounded="lg"
hover
@click="handleResourceBrowse"
:hover="!cardProps.sortable"
@click="handleCardClick"
>
<!-- 装饰性状态指示器 -->
<div v-if="cardProps.site?.is_active" class="site-status-indicator" :class="statColor"></div>
@@ -225,7 +272,7 @@ onMounted(() => {
rounded="lg"
size="32"
class="shrink-0"
:class="{ 'cursor-move': display.mdAndUp.value }"
:class="{ 'cursor-move': cardProps.sortable && display.mdAndUp.value }"
>
<VImg :src="siteIcon" class="w-full h-full" :alt="cardProps.site?.name" cover>
<template #placeholder>
@@ -242,17 +289,37 @@ onMounted(() => {
<!-- 站点特性图标 -->
<div class="ml-auto flex shrink-0 items-center gap-2">
<div v-if="cardProps.site?.limit_interval" class="hover:bg-primary/8 transition-colors">
<VIcon icon="mdi-speedometer" size="16" color="primary" class="opacity-85 hover:opacity-100" />
<div v-if="cardProps.site?.limit_interval" :class="cardProps.sortable ? '' : 'hover:bg-primary/8 transition-colors'">
<VIcon
icon="mdi-speedometer"
size="16"
color="primary"
:class="cardProps.sortable ? 'opacity-85' : 'opacity-85 hover:opacity-100'"
/>
</div>
<div v-if="cardProps.site?.proxy" class="hover:bg-primary/8 transition-colors">
<VIcon icon="mdi-network-outline" size="16" color="primary" class="opacity-85 hover:opacity-100" />
<div v-if="cardProps.site?.proxy" :class="cardProps.sortable ? '' : 'hover:bg-primary/8 transition-colors'">
<VIcon
icon="mdi-network-outline"
size="16"
color="primary"
:class="cardProps.sortable ? 'opacity-85' : 'opacity-85 hover:opacity-100'"
/>
</div>
<div v-if="cardProps.site?.render" class="hover:bg-primary/8 transition-colors">
<VIcon icon="mdi-apple-safari" size="16" color="primary" class="opacity-85 hover:opacity-100" />
<div v-if="cardProps.site?.render" :class="cardProps.sortable ? '' : 'hover:bg-primary/8 transition-colors'">
<VIcon
icon="mdi-apple-safari"
size="16"
color="primary"
:class="cardProps.sortable ? 'opacity-85' : 'opacity-85 hover:opacity-100'"
/>
</div>
<div v-if="cardProps.site?.filter" class="hover:bg-primary/8 transition-colors">
<VIcon icon="mdi-filter-cog-outline" size="16" color="primary" class="opacity-85 hover:opacity-100" />
<div v-if="cardProps.site?.filter" :class="cardProps.sortable ? '' : 'hover:bg-primary/8 transition-colors'">
<VIcon
icon="mdi-filter-cog-outline"
size="16"
color="primary"
:class="cardProps.sortable ? 'opacity-85' : 'opacity-85 hover:opacity-100'"
/>
</div>
</div>
</div>
@@ -260,10 +327,10 @@ onMounted(() => {
<!-- 中间部分网址 -->
<div class="my-3">
<div class="min-w-0 truncate text-sm text-medium-emphasis" @click.stop="openSitePage">
{{ cardProps.site?.url }}
<div class="min-w-0 truncate text-sm text-medium-emphasis" @click.stop="handleSiteUrlClick">
{{ cardProps.site?.url }}
</div>
</div>
</div>
<!-- 底部数据统计 -->
<div class="flex-1 flex flex-col justify-end">
@@ -295,7 +362,7 @@ onMounted(() => {
</div>
<!-- 右侧操作按钮区 -->
<VSheet class="site-card-actions absolute inset-y-0 right-0 z-20 flex flex-col py-2 px-1">
<VSheet v-if="!cardProps.sortable" class="site-card-actions absolute inset-y-0 right-0 z-20 flex flex-col py-2 px-1">
<!-- 测试按钮 -->
<VBtn
icon
@@ -333,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>
@@ -354,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>
@@ -418,7 +456,7 @@ onMounted(() => {
}
/* 站点卡片悬停时状态指示器变化 */
.site-card:hover .site-status-indicator {
.site-card:not(.site-card--sortable):hover .site-status-indicator {
block-size: 2px;
opacity: 0.8;
}

View File

@@ -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()
@@ -29,6 +31,10 @@ const props = defineProps({
type: Boolean,
default: false,
},
sortable: {
type: Boolean,
default: false,
},
})
// 从 provide 中获取全局设置
@@ -48,21 +54,31 @@ 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
}
// TV 洗版订阅在卡片上展示分集或全集短标签
const bestVersionModeLabel = computed(() => {
if (!isEnabledFlag(props.media?.best_version) || !isTvSubscribe(props.media)) return ''
return isEnabledFlag(props.media?.best_version_full)
? t('subscribe.bestVersionWholeShort')
: t('subscribe.bestVersionEpisodeShort')
})
// 图片加载完成响应
function imageLoadHandler() {
imageLoaded.value = true
@@ -153,12 +169,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
@@ -184,7 +210,7 @@ async function viewMediaDetail() {
// 查看文件详情
async function viewSubscribeFiles() {
subscribeFilesDialog.value = true
openSharedDialog(SubscribeFilesDialog, { subid: props.media?.id }, {}, { closeOn: ['close'] })
}
// 弹出菜单
@@ -266,6 +292,7 @@ watch(
(newOpenState, _) => {
if (newOpenState) editSubscribeDialog()
},
{ immediate: true },
)
// 监听订阅状态
@@ -296,18 +323,20 @@ const posterUrl = computed(() => {
// 订阅编辑保存
function onSubscribeEditSave() {
subscribeEditDialog.value = false
emit('save')
}
// 订阅编辑取消
function onSubscribeEditRemove() {
subscribeEditDialog.value = false
emit('remove')
}
// 处理卡片点击事件
function handleCardClick() {
if (props.sortable) {
return
}
if (props.batchMode) {
// 批量模式下触发选择事件
emit('select')
@@ -325,7 +354,7 @@ function handleCardClick() {
<div
class="w-full h-full rounded-lg overflow-hidden"
:class="{
'transition transform-cpu duration-300 -translate-y-1': hover.isHovering,
'transition transform-cpu duration-300 -translate-y-1': hover.isHovering && !props.sortable,
'outline-dashed outline-1': props.media?.best_version && imageLoaded,
'outline-dotted outline-pink-500 outline-2': props.batchMode && props.selected,
}"
@@ -336,14 +365,15 @@ function handleCardClick() {
class="flex flex-col h-full"
:class="{
'opacity-70': subscribeState === 'S',
'cursor-move': props.sortable,
}"
rounded="0"
min-height="150"
@click="handleCardClick"
:ripple="!props.batchMode"
:ripple="!props.batchMode && !props.sortable"
>
<div class="me-n3 absolute top-1 right-4">
<IconBtn>
<div v-if="!props.sortable" class="me-n3 absolute top-1 right-4">
<IconBtn @click.stop>
<VIcon icon="mdi-dots-vertical" color="white" />
<VMenu activator="parent" close-on-content-click>
<VList>
@@ -380,7 +410,7 @@ function handleCardClick() {
<div
class="h-auto w-12 flex-shrink-0 overflow-hidden rounded-md"
v-if="imageLoaded"
:class="{ 'cursor-move': display.mdAndUp.value }"
:class="{ 'cursor-move': props.sortable && display.mdAndUp.value }"
>
<VImg :src="posterUrl" aspect-ratio="2/3" cover>
<template #placeholder>
@@ -398,21 +428,39 @@ function handleCardClick() {
</div>
</div>
</VCardText>
<VCardText class="flex justify-space-between align-center flex-wrap px-3">
<div class="flex align-center">
<VCardText class="flex min-w-0 justify-space-between align-center flex-wrap px-3">
<div class="flex min-w-0 max-w-full align-center">
<VIcon
v-if="props.media?.total_episode && props.sortable"
icon="mdi-progress-download"
size="small"
color="white"
class="me-1"
/>
<IconBtn
v-if="props.media?.total_episode"
v-else-if="props.media?.total_episode"
size="small"
v-bind="props"
icon="mdi-progress-download"
color="white"
/>
<div v-if="props.media?.season" class="text-subtitle-2 me-2 text-white">
<div v-if="props.media?.season" class="flex-shrink-0 text-subtitle-2 me-2 text-white">
{{ (props.media?.total_episode || 0) - (props.media?.lack_episode || 0) }} /
{{ props.media?.total_episode }}
</div>
<IconBtn v-if="props.media?.username" icon="mdi-account" size="small" color="white" />
<span v-if="props.media?.username" class="text-subtitle-2 text-white">
<VChip
v-if="bestVersionModeLabel"
size="x-small"
color="primary"
variant="flat"
class="me-2 flex-shrink-0"
>
{{ bestVersionModeLabel }}
</VChip>
<VIcon v-if="props.media?.username && props.sortable" icon="mdi-account" size="small" color="white" class="flex-shrink-0 me-1" />
<IconBtn v-else-if="props.media?.username" icon="mdi-account" size="small" color="white" class="flex-shrink-0" />
<!-- 用户名过长时限制在卡片宽度内并用省略号展示剩余内容 -->
<span v-if="props.media?.username" class="min-w-0 truncate text-subtitle-2 text-white" :title="props.media?.username">
{{ props.media?.username }}
</span>
</div>
@@ -437,30 +485,6 @@ 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>

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,8 +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({
@@ -14,9 +19,6 @@ const props = defineProps({
height: String,
})
// 更多来源界面
const showMoreTorrents = ref(false)
// 种子信息
const torrent = ref(props.torrent?.torrent_info)
@@ -32,31 +34,36 @@ const downloadItem = ref(props.torrent)
// 站点图标
const siteIcons = ref<Record<number, string>>({})
// 存储是否已经下载过的记录
const downloaded = ref<string[]>([])
// 添加下载对话框
const addDownloadDialog = ref(false)
const isDownloaded = computed(() => Boolean(torrent.value?.enclosure && downloadedTorrentMap[torrent.value.enclosure]))
// 添加下载成功
function addDownloadSuccess(url: string) {
addDownloadDialog.value = false
// 添加下载成功
downloaded.value.push(url)
markTorrentDownloaded(url)
}
// 添加下载失败
function addDownloadError(error: string) {
addDownloadDialog.value = false
console.error(error)
}
// 查询站点图标
async function getSiteIcon(site: number | undefined) {
if (!site) return
try {
siteIcons.value[site] = (await api.get(`site/icon/${site}`)).data.icon
siteIcons.value[site] = await getCachedSiteIcon(site, async () => {
try {
const response = await api.get(`site/icon/${site}`)
return response?.data?.icon || ''
} catch (error) {
console.error(error)
return ''
}
})
} catch (error) {
console.error(error)
siteIcons.value[site] = ''
}
}
@@ -66,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'] },
)
}
// 打开种子详情页面
@@ -92,37 +113,46 @@ 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'] },
)
}
// 装载时查询站点图标
onMounted(() => {
getSiteIcon(props.torrent?.torrent_info?.site)
})
watch(
() => props.torrent,
value => {
torrent.value = value?.torrent_info
media.value = value?.media_info
meta.value = value?.meta_info
downloadItem.value = value
getSiteIcon(value?.torrent_info?.site)
},
{ immediate: true },
)
</script>
<template>
<div class="h-full">
<VCard
:width="props.width || '100%'"
:variant="downloaded.includes(torrent?.enclosure || '') ? 'outlined' : 'flat'"
:variant="isDownloaded ? 'outlined' : 'flat'"
@click="handleAddDownload(props.torrent)"
class="h-full cursor-pointer transition-transform hover:-translate-y-1 duration-300 d-flex flex-column overflow-hidden torrent-card"
:class="{ 'border-success border-2 opacity-85': downloaded.includes(torrent?.enclosure || '') }"
:class="{ 'border-success border-2 opacity-85': isDownloaded }"
hover
>
<!-- 优惠标签 -->
@@ -258,7 +288,7 @@ onMounted(() => {
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>
@@ -276,105 +306,6 @@ onMounted(() => {
</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>
@@ -385,11 +316,6 @@ onMounted(() => {
inset-inline-end: 0;
}
.more-sources-content {
max-block-size: 60vh;
overflow-y: auto;
}
/* 卡片悬停效果 */
.torrent-card {
border: 1px solid transparent;

View File

@@ -3,7 +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({
@@ -22,37 +26,28 @@ const meta = ref(props.torrent?.meta_info)
// 站点图标
const siteIcon = ref('')
// 站点图标加载状态
const iconLoading = ref(false)
const iconError = ref(false)
// 存储是否已经下载过的记录
const downloaded = ref<string[]>([])
// 添加下载对话框
const addDownloadDialog = ref(false)
const isDownloaded = computed(() => Boolean(torrent.value?.enclosure && downloadedTorrentMap[torrent.value.enclosure]))
// 查询站点图标
async function getSiteIcon() {
if (!torrent?.value?.site || iconLoading.value) {
if (!torrent?.value?.site) {
return
}
iconLoading.value = true
iconError.value = false
try {
const response = await api.get(`site/icon/${torrent.value.site}`)
if (response && response.data && response.data.icon) {
siteIcon.value = response.data.icon
} else {
iconError.value = true
}
siteIcon.value = await getCachedSiteIcon(torrent.value.site, async () => {
try {
const response = await api.get(`site/icon/${torrent.value?.site}`)
return response?.data?.icon || ''
} catch (error) {
console.error('Failed to load site icon:', error)
return ''
}
})
} catch (error) {
console.error('Failed to load site icon:', error)
iconError.value = true
} finally {
iconLoading.value = false
siteIcon.value = ''
}
}
@@ -77,19 +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
// 添加下载成功
downloaded.value.push(url)
markTorrentDownloaded(url)
}
// 添加下载失败
function addDownloadError(error: string) {
addDownloadDialog.value = false
console.error(error)
}
// 打开种子详情页面
@@ -97,10 +102,16 @@ function openTorrentDetail() {
window.open(torrent.value?.page_url, '_blank')
}
// 装载时查询站点图标
onMounted(() => {
getSiteIcon()
})
watch(
() => props.torrent,
value => {
torrent.value = value?.torrent_info
media.value = value?.media_info
meta.value = value?.meta_info
getSiteIcon()
},
{ immediate: true },
)
</script>
<template>
@@ -108,7 +119,7 @@ onMounted(() => {
<VListItem
:value="props.torrent?.torrent_info?.enclosure"
class="pa-3 mb-2 rounded torrent-item transition-all duration-300 hover:-translate-y-1 overflow-hidden"
:class="{ 'border-start border-success border-3 opacity-85': downloaded.includes(torrent?.enclosure || '') }"
:class="{ 'border-start border-success border-3 opacity-85': isDownloaded }"
@click="handleAddDownload"
>
<!-- 优惠标签 -->
@@ -240,17 +251,6 @@ onMounted(() => {
</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

@@ -76,12 +76,12 @@ async function loadHistory({ done }: { done: any }) {
// 返回加载成功
done('ok')
}
// 取消加载中
loading.value = false
} catch (e) {
console.error(e)
// 返回加载失败
done('error')
} finally {
loading.value = false
}
}
@@ -153,65 +153,67 @@ function getMediaTypeText(type: string | undefined) {
</VCardItem>
<VDivider />
<VDialogCloseBtn @click="emit('close')" />
<VList lines="two">
<VInfiniteScroll mode="intersect" side="end" :items="historyList" class="overflow-visible" @load="loadHistory">
<VList lines="two" class="flex-grow-1 min-h-0 py-0">
<VInfiniteScroll mode="intersect" side="end" :items="historyList" class="h-100" @load="loadHistory">
<template #loading>
<LoadingBanner />
</template>
<template #empty />
<template v-if="historyList.length > 0">
<template v-for="(item, i) in historyList" :key="i">
<VListItem>
<template #prepend>
<VImg
height="75"
width="50"
:src="item.poster"
aspect-ratio="2/3"
class="object-cover rounded ring-gray-500 me-3"
cover
>
<template #placeholder>
<div class="w-full h-full">
<VSkeletonLoader class="object-cover aspect-w-2 aspect-h-3" />
</div>
</template>
</VImg>
</template>
<VListItemTitle v-if="item.type == '电视剧'">
{{ item.name }}
<span class="text-sm">{{ t('dialog.subscribeHistory.season', { season: item.season }) }}</span>
</VListItemTitle>
<VListItemTitle v-else>
{{ item.name }}
</VListItemTitle>
<VListItemSubtitle class="mt-2">{{ formatDateDifference(item.date) }}</VListItemSubtitle>
<VListItemSubtitle class="mt-2">{{ item.description }}</VListItemSubtitle>
<template #append>
<div class="me-n3">
<IconBtn>
<VIcon icon="mdi-dots-vertical" />
<VMenu activator="parent" close-on-content-click>
<VList>
<VListItem
v-for="(menu, i) in dropdownItems"
:key="i"
:base-color="menu.color"
@click="menu.props.click(item)"
>
<template #prepend>
<VIcon :icon="menu.props.prependIcon" />
</template>
<VListItemTitle v-text="menu.title" />
</VListItem>
</VList>
</VMenu>
</IconBtn>
</div>
</template>
</VListItem>
<VVirtualScroll v-if="historyList.length > 0" renderless :items="historyList" :item-height="104">
<template #default="{ item, itemRef }">
<div :ref="itemRef">
<VListItem>
<template #prepend>
<VImg
height="75"
width="50"
:src="item.poster"
aspect-ratio="2/3"
class="object-cover rounded ring-gray-500 me-3"
cover
>
<template #placeholder>
<div class="w-full h-full">
<VSkeletonLoader class="object-cover aspect-w-2 aspect-h-3" />
</div>
</template>
</VImg>
</template>
<VListItemTitle v-if="item.type == '电视剧'">
{{ item.name }}
<span class="text-sm">{{ t('dialog.subscribeHistory.season', { season: item.season }) }}</span>
</VListItemTitle>
<VListItemTitle v-else>
{{ item.name }}
</VListItemTitle>
<VListItemSubtitle class="mt-2">{{ formatDateDifference(item.date) }}</VListItemSubtitle>
<VListItemSubtitle class="mt-2">{{ item.description }}</VListItemSubtitle>
<template #append>
<div class="me-n3">
<IconBtn>
<VIcon icon="mdi-dots-vertical" />
<VMenu activator="parent" close-on-content-click>
<VList>
<VListItem
v-for="(menu, i) in dropdownItems"
:key="i"
:base-color="menu.color"
@click="menu.props.click(item)"
>
<template #prepend>
<VIcon :icon="menu.props.prependIcon" />
</template>
<VListItemTitle v-text="menu.title" />
</VListItem>
</VList>
</VMenu>
</IconBtn>
</div>
</template>
</VListItem>
</div>
</template>
</template>
</VVirtualScroll>
</VInfiniteScroll>
</VList>
<VCardText v-if="historyList.length === 0 && isRefreshed" class="text-center">{{

View File

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

@@ -91,6 +91,7 @@ const userForm = ref<ExtendedUser>({
},
settings: {
wechat_userid: null,
wechatclawbot_userid: null,
telegram_userid: null,
slack_userid: null,
discord_userid: null,
@@ -503,6 +504,15 @@ onMounted(() => {
prepend-inner-icon="mdi-wechat"
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="userForm.settings.wechatclawbot_userid"
density="comfortable"
clearable
:label="t('dialog.userAddEdit.wechatClawBot')"
prepend-inner-icon="mdi-robot-happy-outline"
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="userForm.settings.telegram_userid"

View File

@@ -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')
@@ -148,7 +220,12 @@ const transferItems = ref<FileItem[]>([])
// 当前图片地址
const currentImgLink = ref('')
function revokeCurrentImgLink() {
if (!currentImgLink.value) return
URL.revokeObjectURL(currentImgLink.value)
currentImgLink.value = ''
}
// 是否为图片文件
const isImage = computed(() => {
@@ -163,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
}
}
}
// 删除项目
@@ -235,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()
@@ -260,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)
@@ -287,6 +371,9 @@ async function download(item: FileItem) {
if (result) {
const downloadUrl = URL.createObjectURL(result)
window.open(downloadUrl, '_blank')
setTimeout(() => {
URL.revokeObjectURL(downloadUrl)
}, 60000)
}
}
@@ -304,6 +391,7 @@ async function getImgLink(item: FileItem) {
const result: Blob = (await inProps.axios.request<Blob, Blob>(config))
if (result) {
// 创建图片地址
revokeCurrentImgLink()
currentImgLink.value = URL.createObjectURL(result)
}
}
@@ -314,7 +402,10 @@ watch(
async () => {
if (isImage.value && isFile.value) {
await getImgLink(inProps.item)
return
}
revokeCurrentImgLink()
},
{ immediate: true },
)
@@ -324,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: {
@@ -346,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()
}
@@ -387,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')
}
@@ -399,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()
@@ -441,9 +573,9 @@ watch(
async () => {
// 清空列表
items.value = []
selected.value = []
// 关闭弹窗
nameTestResult.value = undefined
nameTestDialog.value = false
// 重置菜单
dropdownItems.value = [
{
@@ -506,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)
}
}
@@ -536,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)
}
}
@@ -570,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,
@@ -594,8 +731,15 @@ function stopLoadingProgress() {
progressSSE.stop()
}
onMounted(() => {
list_files()
useKeepAliveRefresh(list_files, {
active: computed(() => inProps.active),
})
onUnmounted(() => {
revokeCurrentImgLink()
stopLoadingProgress()
closeProgressDialog()
renameDialogController?.close()
})
</script>
@@ -622,8 +766,8 @@ onMounted(() => {
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
/>
@@ -682,14 +826,18 @@ onMounted(() => {
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
@@ -756,59 +904,5 @@ onMounted(() => {
{{ 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: `

View File

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

View File

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

@@ -1,335 +0,0 @@
<script lang="ts" setup>
import SlideViewTitle from '@/components/slide/SlideViewTitle.vue'
import { useDisplay } from 'vuetify'
// 判断是否可以触摸
const display = useDisplay()
const isTouch = computed(() => display.mobile.value)
// 元素
const slideview_content = ref<HTMLElement | null>(null)
const sliderContainer = ref<HTMLElement | null>(null)
// 分页切换状态: 0-左边不可用 1-两边可用 2-右边不可用 3-两边都不可用
const disabled = ref(0)
// 记录滚动值
const slideview_scrollLeft = ref(0)
// 所有卡片数量
let slide_card_length: number
// 卡片间距
let slide_gap_px: number
// 卡片宽度
let card_width: number
// 容器最多显示N张卡片
let card_max: number
// 当前定位
let card_current: number
// 获取传入的链接地址
const props: any = inject('rankingPropsKey', { linkurl: '', title: '' })
const isScrolling = ref(false)
let scrollTimeout: ReturnType<typeof setTimeout> | null = null
const scrollTimeoutDuration = 1500 // 滚动停止后延迟时间 (ms)
// 分页切换
function slideNext(next: boolean) {
let run_to_left_px
if (next) {
const card_index = card_current + card_max
run_to_left_px = card_index * card_width
if (run_to_left_px >= slideview_content.value!.scrollWidth - slideview_content.value!.clientWidth)
run_to_left_px = slideview_content.value!.scrollWidth - slideview_content.value!.clientWidth
} else {
const card_index = card_current - card_max
run_to_left_px = card_index * card_width
if (run_to_left_px <= 0) run_to_left_px = 0
}
slideview_content.value!.scrollTo({
top: 0,
left: run_to_left_px,
behavior: 'smooth',
})
// 点击后强制显示并重置计时器
isScrolling.value = true
if (scrollTimeout) {
clearTimeout(scrollTimeout)
}
scrollTimeout = setTimeout(() => {
isScrolling.value = false
}, scrollTimeoutDuration)
}
// 计算最大显示数量
function countMaxNumber() {
if (!slideview_content.value || !slideview_content.value.firstElementChild) return
slide_card_length = slideview_content.value.children.length
card_width = slideview_content.value.firstElementChild.getBoundingClientRect().width
slide_gap_px = slideview_content.value.scrollWidth / slide_card_length - card_width
card_width += slide_gap_px
card_max = Math.trunc(slideview_content.value.clientWidth / card_width)
countDisabled()
}
// 修改分页切换按钮状态 & 处理滚动状态
function handleContentScroll() {
if (!slideview_content.value) return
// 更新按钮禁用状态
countDisabled()
// 更新滚动状态并重置计时器
isScrolling.value = true
if (scrollTimeout) {
clearTimeout(scrollTimeout)
}
scrollTimeout = setTimeout(() => {
isScrolling.value = false
}, scrollTimeoutDuration) // 使用常量
}
// 原始的 countDisabled 逻辑,现在由 handleContentScroll 调用
function countDisabled() {
if (!slideview_content.value) return
slideview_scrollLeft.value = slideview_content.value.scrollLeft
card_current =
slideview_content.value.scrollLeft === 0
? 0
: Math.trunc((slideview_content.value.scrollLeft + card_width / 2) / card_width)
if (slide_card_length * card_width <= slideview_content.value.clientWidth) disabled.value = 3
else if (slideview_content.value.scrollLeft === 0) disabled.value = 0
else if (
slideview_content.value.scrollLeft >=
slideview_content.value.scrollWidth - slideview_content.value.clientWidth - 2
)
disabled.value = 2
else disabled.value = 1
}
// 组件加载完成
onMounted(() => {
// 初次获取元素参数
countMaxNumber()
// 窗口大小发生改变时
window.addEventListener('resize', countMaxNumber)
})
onUnmounted(() => {
// 卸载事件
window.removeEventListener('resize', countMaxNumber)
})
onActivated(() => {
if (slideview_scrollLeft.value !== 0) {
slideview_content.value!.scrollLeft = slideview_scrollLeft.value
}
})
</script>
<template>
<div ref="sliderContainer" class="slider-container" :class="{ 'is-scrolling': isScrolling }">
<div class="slider-header">
<slot name="title">
<SlideViewTitle />
</slot>
<!-- 查看全部按钮 -->
<RouterLink v-if="props.linkurl" :to="props.linkurl" class="view-all-button">
<span>更多</span>
<svg width="16" height="16" viewBox="0 0 24 24" class="arrow-svg">
<path d="M8.59,16.58L13.17,12L8.59,7.41L10,6L16,12L10,18L8.59,16.58Z" />
</svg>
</RouterLink>
</div>
<div class="slider-content-wrapper">
<div class="slider-content-container">
<div ref="slideview_content" class="slider-content" tabindex="0" @scroll="handleContentScroll">
<slot name="content" />
</div>
</div>
<!-- 左侧导航按钮 -->
<VBtn
class="nav-button nav-button-left"
@click.stop="slideNext(false)"
v-show="disabled !== 0 && disabled !== 3 && !isTouch"
variant="text"
icon
color="secondary"
>
<svg width="24" height="24" viewBox="0 0 24 24">
<path d="M15.41,16.58L10.83,12L15.41,7.41L14,6L8,12L14,18L15.41,16.58Z" />
</svg>
</VBtn>
<!-- 右侧导航按钮 -->
<VBtn
class="nav-button nav-button-right"
@click.stop="slideNext(true)"
v-show="disabled !== 2 && disabled !== 3 && !isTouch"
variant="text"
icon
color="secondary"
>
<svg width="24" height="24" viewBox="0 0 24 24">
<path d="M8.59,16.58L13.17,12L8.59,7.41L10,6L16,12L10,18L8.59,16.58Z" />
</svg>
</VBtn>
</div>
</div>
</template>
<style lang="scss" scoped>
.slider-container {
position: relative;
margin-block-end: 8px;
}
.slider-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
margin-block-end: 8px;
padding-block: 0;
padding-inline: 8px;
& > :first-child {
flex-grow: 1;
min-inline-size: 0;
}
}
.view-all-button {
.arrow-svg {
fill: currentcolor;
margin-inline-start: 2px;
transition: transform 0.3s ease;
}
display: inline-flex;
flex-shrink: 0;
align-items: center;
border-radius: 8px;
background-color: transparent;
color: rgb(var(--v-theme-primary));
font-size: 0.85rem;
font-weight: 500;
padding-block: 5px;
padding-inline: 12px;
text-decoration: none;
transition: all 0.25s ease;
&:hover {
border-color: rgba(var(--v-theme-primary), 0.5);
background-color: rgba(var(--v-theme-primary), 0.08);
transform: translateY(-1px);
.arrow-svg {
transform: translateX(3px);
}
}
span {
margin-inline-end: 4px;
}
}
.slider-content-wrapper {
position: relative;
inline-size: 100%;
}
.slider-content-container {
position: relative;
overflow: hidden;
inline-size: 100%;
}
.nav-button {
position: absolute;
z-index: 20;
display: flex;
align-items: center;
justify-content: center;
padding: 0;
border-radius: 50%;
backdrop-filter: blur(8px);
background-color: rgba(var(--v-theme-background), 0.3);
block-size: 36px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 8%);
cursor: pointer;
inline-size: 36px;
inset-block-start: 50%;
opacity: 0;
pointer-events: none;
text-shadow: 0 1px 2px rgba(0, 0, 0, 10%);
transform: translateY(-50%);
transition: opacity 0.3s ease, transform 0.3s cubic-bezier(0.25, 0.8, 0.25, 1), background-color 0.3s ease,
box-shadow 0.3s ease, border-color 0.3s ease;
svg {
block-size: 22px;
fill: currentcolor;
filter: none;
inline-size: 22px;
opacity: 0.7;
transition: all 0.3s ease;
}
&:hover {
color: rgb(var(--v-theme-primary));
transform: translateY(-50%) scale(1.05);
svg {
opacity: 1;
}
}
}
.nav-button-left {
inset-inline-start: 8px;
}
.nav-button-right {
inset-inline-end: 8px;
}
.slider-content {
display: grid;
overflow: scroll hidden !important;
justify-content: start;
gap: 16px;
grid-auto-flow: column;
grid-template-rows: 1fr;
-ms-overflow-style: none !important;
overscroll-behavior-x: contain !important;
padding-block: 8px;
padding-inline: 12px;
scroll-behavior: smooth;
scrollbar-width: none !important;
&::-webkit-scrollbar {
display: none;
}
}
// 触摸设备:滚动时显示 (通过 JS 添加的类控制)
// 这个规则会在不支持 hover 的设备上生效
.slider-container.is-scrolling .nav-button {
opacity: 1;
pointer-events: auto;
}
// 桌面设备:悬停时显示
@media (hover: hover) {
.slider-container:hover .nav-button {
// 这个规则会覆盖 .is-scrolling 的效果 (如果同时存在)
// 或者在非 scrolling 状态下hover 时也能显示
opacity: 1;
pointer-events: auto;
}
// 在 hover 设备上,即使在滚动,如果鼠标不悬停,按钮也应该隐藏
// 因此,基础 .nav-button 的 opacity: 0 规则在这里仍然是必要的
// (之前错误地以为 hover 会完全覆盖,但滚动时 class 和 hover 可能同时存在)
// .nav-button { opacity: 0; pointer-events: none; } // 这行其实不需要重复,默认就是这样
}
</style>

View File

@@ -0,0 +1,443 @@
<script lang="ts" setup>
import SlideViewTitle from '@/components/slide/SlideViewTitle.vue'
import { useDisplay } from 'vuetify'
const props = withDefaults(
defineProps<{
items: any[]
itemWidth?: number
itemGap?: number
overscanItems?: number
getItemKey?: (item: any, index: number) => string | number
loading?: boolean
}>(),
{
itemWidth: 144,
itemGap: 16,
overscanItems: 4,
getItemKey: undefined,
loading: false,
},
)
const display = useDisplay()
const isTouch = computed(() => display.mobile.value)
const injectedProps: any = inject('rankingPropsKey', { linkurl: '', title: '' })
const slideContentRef = ref<HTMLElement | null>(null)
const disabled = ref(0)
const slideScrollLeft = ref(0)
const isScrolling = ref(false)
const startIndex = ref(0)
const endIndex = ref(0)
let resizeObserver: ResizeObserver | null = null
let scrollTimeout: ReturnType<typeof setTimeout> | null = null
const scrollTimeoutDuration = 1500
const itemStep = computed(() => props.itemWidth + props.itemGap)
const visibleItems = computed(() => props.items.slice(startIndex.value, endIndex.value))
const leadingSpaceWidth = computed(() => startIndex.value * itemStep.value)
const visibleItemsWidth = computed(() => {
if (!visibleItems.value.length) {
return 0
}
return visibleItems.value.length * props.itemWidth + Math.max(visibleItems.value.length - 1, 0) * props.itemGap
})
const totalContentWidth = computed(() => {
if (!props.items.length) {
return 0
}
return props.items.length * props.itemWidth + Math.max(props.items.length - 1, 0) * props.itemGap
})
const trailingSpaceWidth = computed(() => {
return Math.max(totalContentWidth.value - leadingSpaceWidth.value - visibleItemsWidth.value, 0)
})
function 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)
}
return startIndex.value + index
}
function resetScrollIndicatorTimer() {
isScrolling.value = true
if (scrollTimeout) {
clearTimeout(scrollTimeout)
}
scrollTimeout = setTimeout(() => {
isScrolling.value = false
}, scrollTimeoutDuration)
}
function updateVisibleRange() {
const element = slideContentRef.value
if (!element) {
startIndex.value = 0
endIndex.value = 0
return
}
const viewportWidth = element.clientWidth || getFallbackViewportWidth()
if (!viewportWidth || !props.items.length) {
startIndex.value = 0
endIndex.value = Math.min(props.items.length, props.overscanItems)
return
}
const firstVisible = Math.max(0, Math.floor(element.scrollLeft / itemStep.value) - props.overscanItems)
const lastVisible = Math.min(
props.items.length,
Math.ceil((element.scrollLeft + viewportWidth) / itemStep.value) + props.overscanItems,
)
startIndex.value = firstVisible
endIndex.value = Math.max(firstVisible + 1, lastVisible)
}
function updateDisabledState() {
const element = slideContentRef.value
if (!element) return
slideScrollLeft.value = element.scrollLeft
if (!props.items.length || totalContentWidth.value <= element.clientWidth) {
disabled.value = 3
} else if (element.scrollLeft === 0) {
disabled.value = 0
} else if (element.scrollLeft >= element.scrollWidth - element.clientWidth - 2) {
disabled.value = 2
} else {
disabled.value = 1
}
}
function syncLayoutState() {
updateVisibleRange()
updateDisabledState()
}
function slideNext(next: boolean) {
const element = slideContentRef.value
if (!element) return
const visibleCount = Math.max(1, Math.trunc(element.clientWidth / itemStep.value))
const currentIndex = element.scrollLeft === 0 ? 0 : Math.trunc((element.scrollLeft + itemStep.value / 2) / itemStep.value)
let targetLeft = 0
if (next) {
targetLeft = Math.min((currentIndex + visibleCount) * itemStep.value, element.scrollWidth - element.clientWidth)
} else {
targetLeft = Math.max((currentIndex - visibleCount) * itemStep.value, 0)
}
element.scrollTo({
behavior: 'smooth',
left: targetLeft,
top: 0,
})
resetScrollIndicatorTimer()
}
function handleContentScroll() {
syncLayoutState()
resetScrollIndicatorTimer()
}
onMounted(() => {
syncLayoutState()
resizeObserver = new ResizeObserver(() => {
syncLayoutState()
})
if (slideContentRef.value) {
resizeObserver.observe(slideContentRef.value)
}
window.addEventListener('resize', syncLayoutState)
})
onUnmounted(() => {
if (scrollTimeout) {
clearTimeout(scrollTimeout)
scrollTimeout = null
}
window.removeEventListener('resize', syncLayoutState)
resizeObserver?.disconnect()
resizeObserver = null
})
onActivated(() => {
if (slideContentRef.value && slideScrollLeft.value !== 0) {
slideContentRef.value.scrollLeft = slideScrollLeft.value
}
nextTick(syncLayoutState)
requestAnimationFrame(syncLayoutState)
})
watch(
() => props.items.length,
() => {
nextTick(syncLayoutState)
},
{ immediate: true },
)
</script>
<template>
<div class="slider-container" :class="{ 'is-scrolling': isScrolling }">
<div class="slider-header">
<slot name="title">
<SlideViewTitle />
</slot>
<RouterLink v-if="injectedProps.linkurl" :to="injectedProps.linkurl" class="view-all-button">
<span>更多</span>
<svg width="16" height="16" viewBox="0 0 24 24" class="arrow-svg">
<path d="M8.59,16.58L13.17,12L8.59,7.41L10,6L16,12L10,18L8.59,16.58Z" />
</svg>
</RouterLink>
</div>
<div class="slider-content-wrapper">
<div class="slider-content-container">
<div ref="slideContentRef" class="slider-content" tabindex="0" @scroll="handleContentScroll">
<template v-if="loading">
<div class="loading-track" :style="{ gap: `${itemGap}px` }">
<slot name="loading" />
</div>
</template>
<template v-else-if="items.length > 0">
<div class="virtual-track" :style="{ width: `${totalContentWidth}px` }">
<div v-if="leadingSpaceWidth > 0" class="virtual-spacer" :style="{ width: `${leadingSpaceWidth}px` }" />
<template v-for="(item, index) in visibleItems" :key="resolveItemKey(item, index)">
<div
class="virtual-slide-item"
:style="{
marginInlineEnd: index === visibleItems.length - 1 ? '0px' : `${itemGap}px`,
width: `${itemWidth}px`,
}"
>
<slot name="item" :item="item" :index="startIndex + index" />
</div>
</template>
<div v-if="trailingSpaceWidth > 0" class="virtual-spacer" :style="{ width: `${trailingSpaceWidth}px` }" />
</div>
</template>
<template v-else>
<slot name="empty" />
</template>
</div>
</div>
<VBtn
v-show="disabled !== 0 && disabled !== 3 && !isTouch"
class="nav-button nav-button-left"
variant="text"
icon
color="secondary"
@click.stop="slideNext(false)"
>
<svg width="24" height="24" viewBox="0 0 24 24">
<path d="M15.41,16.58L10.83,12L15.41,7.41L14,6L8,12L14,18L15.41,16.58Z" />
</svg>
</VBtn>
<VBtn
v-show="disabled !== 2 && disabled !== 3 && !isTouch"
class="nav-button nav-button-right"
variant="text"
icon
color="secondary"
@click.stop="slideNext(true)"
>
<svg width="24" height="24" viewBox="0 0 24 24">
<path d="M8.59,16.58L13.17,12L8.59,7.41L10,6L16,12L10,18L8.59,16.58Z" />
</svg>
</VBtn>
</div>
</div>
</template>
<style lang="scss" scoped>
.slider-container {
position: relative;
margin-block-end: 8px;
}
.slider-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
margin-block-end: 8px;
padding-block: 0;
padding-inline: 8px;
& > :first-child {
flex-grow: 1;
min-inline-size: 0;
}
}
.view-all-button {
display: inline-flex;
flex-shrink: 0;
align-items: center;
border-radius: 8px;
background-color: transparent;
color: rgb(var(--v-theme-primary));
font-size: 0.85rem;
font-weight: 500;
padding-block: 5px;
padding-inline: 12px;
text-decoration: none;
transition: all 0.25s ease;
.arrow-svg {
fill: currentcolor;
margin-inline-start: 2px;
transition: transform 0.3s ease;
}
&:hover {
border-color: rgba(var(--v-theme-primary), 0.5);
background-color: rgba(var(--v-theme-primary), 0.08);
transform: translateY(-1px);
.arrow-svg {
transform: translateX(3px);
}
}
span {
margin-inline-end: 4px;
}
}
.slider-content-wrapper {
position: relative;
inline-size: 100%;
}
.slider-content-container {
position: relative;
overflow: hidden;
inline-size: 100%;
}
.slider-content {
overflow: scroll hidden !important;
-ms-overflow-style: none !important;
overscroll-behavior-x: contain !important;
padding-block: 8px;
padding-inline: 12px;
scroll-behavior: smooth;
scrollbar-width: none !important;
&::-webkit-scrollbar {
display: none;
}
}
.virtual-track {
display: flex;
inline-size: max-content;
}
.loading-track {
display: flex;
inline-size: max-content;
min-inline-size: 100%;
}
.virtual-slide-item,
.virtual-spacer,
.loading-track > * {
flex: 0 0 auto;
}
.nav-button {
position: absolute;
z-index: 20;
display: flex;
align-items: center;
justify-content: center;
padding: 0;
border-radius: 50%;
backdrop-filter: blur(8px);
background-color: rgba(var(--v-theme-background), 0.3);
block-size: 36px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 8%);
cursor: pointer;
inline-size: 36px;
inset-block-start: 50%;
opacity: 0;
pointer-events: none;
text-shadow: 0 1px 2px rgba(0, 0, 0, 10%);
transform: translateY(-50%);
transition: opacity 0.3s ease, transform 0.3s cubic-bezier(0.25, 0.8, 0.25, 1), background-color 0.3s ease,
box-shadow 0.3s ease, border-color 0.3s ease;
svg {
block-size: 22px;
fill: currentcolor;
filter: none;
inline-size: 22px;
opacity: 0.7;
transition: all 0.3s ease;
}
&:hover {
color: rgb(var(--v-theme-primary));
transform: translateY(-50%) scale(1.05);
svg {
opacity: 1;
}
}
}
.nav-button-left {
inset-inline-start: 8px;
}
.nav-button-right {
inset-inline-end: 8px;
}
.slider-container.is-scrolling .nav-button {
opacity: 1;
pointer-events: auto;
}
@media (hover: hover) {
.slider-container:hover .nav-button {
opacity: 1;
pointer-events: auto;
}
}
</style>

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,48 +22,63 @@ 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
}
onMounted(() => {
// 延迟建立连接,确保组件完全挂载
const connectDelay = options?.connectDelay || 100
setTimeout(() => {
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)
}
}, connectDelay)
})
onUnmounted(() => {
manager.removeMessageListener(listenerId)
isConnected.value = false
})
onUnmounted(cleanup)
return {
manager,
readyState: () => manager.readyState,
close: () => manager.removeMessageListener(listenerId),
close: cleanup,
isConnected,
forceReconnect: () => manager.forceReconnect(),
}
}
/**
* 使
* 使
* @param id ID
* @param callback
* @param interval
@@ -100,25 +119,51 @@ 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(() => {
setTimeout(() => {
connectTimer = setTimeout(() => {
connectTimer = null
if (isClosed) return
manager.addMessageListener(listenerId, messageHandler)
}, delay)
})
onUnmounted(() => {
manager.removeMessageListener(listenerId)
})
onUnmounted(cleanup)
return {
manager,
readyState: () => manager.readyState,
close: () => manager.removeMessageListener(listenerId),
close: cleanup,
isConnected,
}
}
@@ -135,31 +180,53 @@ export function useBackgroundOptimization() {
listenerId: string,
isActive: Ref<boolean>,
) => {
// 使用独立的SSE管理器确保每个监听器都有独立的连接
const manager = sseManagerSingleton.getIndependentManager(url, listenerId, {
backgroundCloseDelay: 1000, // 进度SSE更快关闭
reconnectDelay: 1000,
maxReconnectAttempts: 5,
})
const getManager = () =>
sseManagerSingleton.getIndependentManager(url, listenerId, {
backgroundCloseDelay: 1000, // 进度SSE更快关闭
reconnectDelay: 1000,
maxReconnectAttempts: 5,
})
let manager: ReturnType<typeof getManager> | null = null
let isListening = false
const startProgress = () => {
if (isActive.value) {
manager.addMessageListener(listenerId, messageHandler)
if (!isActive.value || isListening) return
manager ??= getManager()
manager.addMessageListener(listenerId, messageHandler)
isListening = true
}
const stopProgress = (destroyManager = true) => {
if (!manager) {
isListening = false
return
}
}
const stopProgress = () => {
manager.removeMessageListener(listenerId)
if (destroyManager) {
sseManagerSingleton.closeIndependentManager(url, listenerId)
manager = null
}
isListening = false
}
onUnmounted(() => {
stopProgress()
})
// 进度监听有些场景会在用户操作后动态创建;只有 setup 阶段创建时才注册自动卸载钩子。
if (getCurrentInstance()) {
onUnmounted(() => {
stopProgress(true)
})
}
return {
start: startProgress,
stop: stopProgress,
manager,
get manager() {
return manager
},
}
}

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

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

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

View File

@@ -57,17 +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
@@ -237,17 +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,
@@ -318,6 +330,7 @@ export function useSetupWizard() {
// 媒体服务器映射
mediaServer: {
'emby': 'EmbyModule',
'zspace': 'ZSpaceModule',
'jellyfin': 'JellyfinModule',
'plex': 'PlexModule',
'trimemedia': 'TrimeMediaModule',
@@ -325,8 +338,10 @@ export function useSetupWizard() {
},
// 通知映射
notification: {
'feishu': 'FeishuModule',
'telegram': 'TelegramModule',
'wechat': 'WechatModule',
'wechatclawbot': 'WechatClawBotModule',
'slack': 'SlackModule',
'synologychat': 'SynologyChatModule',
'qqbot': 'QQBotModule',
@@ -421,7 +436,18 @@ export function useSetupWizard() {
wizardData.value.notification.type = type
// 如果名称为空或为默认名称,则设置默认名称
if (!wizardData.value.notification.name || wizardData.value.notification.name.includes('通知')) {
wizardData.value.notification.name = `${type} 通知`
const displayNameMap: Record<string, string> = {
wechat: '企业微信',
feishu: '飞书',
wechatclawbot: '微信 ClawBot',
telegram: 'Telegram',
slack: 'Slack',
synologychat: 'SynologyChat',
qqbot: 'QQ',
vocechat: 'VoceChat',
webpush: 'WebPush',
}
wizardData.value.notification.name = `${displayNameMap[type] || type} 通知`
}
wizardData.value.notification.enabled = true
// 不清空config和switchs保留用户已输入的值
@@ -604,6 +630,15 @@ export function useSetupWizard() {
errors.push(t('mediaserver.apiKeyRequired'))
validationErrors.value.mediaServer.apikey = true
}
} else if (wizardData.value.mediaServer.type === 'zspace') {
if (!wizardData.value.mediaServer.config?.username?.trim()) {
errors.push(t('mediaserver.usernameRequired'))
validationErrors.value.mediaServer.username = true
}
if (!wizardData.value.mediaServer.config?.password?.trim()) {
errors.push(t('mediaserver.passwordRequired'))
validationErrors.value.mediaServer.password = true
}
} else if (wizardData.value.mediaServer.type === 'plex') {
if (!wizardData.value.mediaServer.config?.token?.trim()) {
errors.push(t('mediaserver.tokenRequired'))
@@ -654,6 +689,18 @@ export function useSetupWizard() {
validationErrors.value.notification.WECHAT_APP_SECRET = true
}
break
case 'wechatclawbot':
break
case 'feishu':
if (!config.FEISHU_APP_ID?.trim()) {
errors.push(t('notification.feishu.appIdRequired'))
validationErrors.value.notification.FEISHU_APP_ID = true
}
if (!config.FEISHU_APP_SECRET?.trim()) {
errors.push(t('notification.feishu.appSecretRequired'))
validationErrors.value.notification.FEISHU_APP_SECRET = true
}
break
case 'telegram':
if (!config.TELEGRAM_TOKEN?.trim()) {
errors.push(t('notification.telegram.tokenRequired'))
@@ -852,7 +899,7 @@ export function useSetupWizard() {
case 5: // 媒体服务器测试 - 只有选择了媒体服务器才测试
return !!wizardData.value.mediaServer.type
case 6: // 消息通知测试 - 只有选择了通知才测试
return !!wizardData.value.notification.type
return !!wizardData.value.notification.type && wizardData.value.notification.type !== 'wechatclawbot'
default:
return false
}
@@ -1393,17 +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:
@@ -1500,17 +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()
}
}
/**

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