mirror of
https://github.com/jxxghp/MoviePilot-Frontend.git
synced 2026-06-04 07:09:54 +08:00
Compare commits
113 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cc8d5cf931 | ||
|
|
6083887675 | ||
|
|
beb0506b0c | ||
|
|
0f906f791a | ||
|
|
7614696e61 | ||
|
|
4235d3687c | ||
|
|
2960e7cfde | ||
|
|
e0ebc35178 | ||
|
|
07c9442ac8 | ||
|
|
ccc820e8d2 | ||
|
|
68bb568400 | ||
|
|
13cd214e6d | ||
|
|
311880bcd3 | ||
|
|
088ebbe0bb | ||
|
|
de3523056a | ||
|
|
cf139a938e | ||
|
|
be2f4d0170 | ||
|
|
79493665c1 | ||
|
|
106062da82 | ||
|
|
50e54e943d | ||
|
|
6b811f2250 | ||
|
|
fa7f2a6c7c | ||
|
|
e362f3cbdd | ||
|
|
f4c4d7495f | ||
|
|
5b850d9464 | ||
|
|
d7f74a3a8a | ||
|
|
91dbf065db | ||
|
|
1759e666ba | ||
|
|
65230f1ae8 | ||
|
|
508cf5d08f | ||
|
|
0e9ddc9da2 | ||
|
|
48e6fc4466 | ||
|
|
30a4c55050 | ||
|
|
dee5d9d213 | ||
|
|
c5e2b1349f | ||
|
|
0e005c3c7e | ||
|
|
348ae6b313 | ||
|
|
122ecc82fd | ||
|
|
88fad5b764 | ||
|
|
f01971ee3a | ||
|
|
5e8489c620 | ||
|
|
6900042cf7 | ||
|
|
75862c026a | ||
|
|
bbe3368c69 | ||
|
|
587f06eb9f | ||
|
|
7114c63e8f | ||
|
|
2a6f9e3cc0 | ||
|
|
00d37d7bda | ||
|
|
546af84dab | ||
|
|
5953496d84 | ||
|
|
0fda7c70de | ||
|
|
48546e1999 | ||
|
|
06355ff91d | ||
|
|
523f8c4cc8 | ||
|
|
73f6e7482f | ||
|
|
81ab3f9da8 | ||
|
|
d520645a8b | ||
|
|
af67fddce0 | ||
|
|
6d89dad8de | ||
|
|
f3ab2a8eff | ||
|
|
74c980c7a5 | ||
|
|
52fc2557ec | ||
|
|
34124418f8 | ||
|
|
e2d36da299 | ||
|
|
9965428bae | ||
|
|
e62a0b5a8d | ||
|
|
3c926f7485 | ||
|
|
de3f4e6374 | ||
|
|
2e22f6ae86 | ||
|
|
99665c7d79 | ||
|
|
a4a00586c7 | ||
|
|
cf59a07d4b | ||
|
|
8a362d0740 | ||
|
|
b49385af29 | ||
|
|
b227412c96 | ||
|
|
b3c8faab70 | ||
|
|
9a480dd803 | ||
|
|
847fd13982 | ||
|
|
7fa4f4a2f0 | ||
|
|
4207a70716 | ||
|
|
c97247b92b | ||
|
|
e9bed7ff8a | ||
|
|
f25a619f13 | ||
|
|
2065b05143 | ||
|
|
eec1f2d7b3 | ||
|
|
17a343392c | ||
|
|
a2b2e8cd94 | ||
|
|
9703b2dbee | ||
|
|
310a501380 | ||
|
|
30bf895ae1 | ||
|
|
4f9dce70d3 | ||
|
|
f495e13667 | ||
|
|
f293681588 | ||
|
|
2f1a356e65 | ||
|
|
5909d2423c | ||
|
|
42f7df8f4a | ||
|
|
abaa40d819 | ||
|
|
0d05a104c4 | ||
|
|
e8708f8de7 | ||
|
|
7918b21b5b | ||
|
|
088db67089 | ||
|
|
62e0d8e9dc | ||
|
|
96d655155a | ||
|
|
a475085d7b | ||
|
|
58fdb77b37 | ||
|
|
8a25c6578d | ||
|
|
ef62bd6e98 | ||
|
|
876a46607b | ||
|
|
107f70abde | ||
|
|
090b9d735d | ||
|
|
dbeea6afcc | ||
|
|
2931f5df46 | ||
|
|
e14c81d178 |
5
env.d.ts
vendored
5
env.d.ts
vendored
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
193
index.html
193
index.html
@@ -1,11 +1,14 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN" style="
|
||||
overflow: hidden auto;
|
||||
min-block-size: 100vh;
|
||||
min-block-size: 100dvh;
|
||||
<html lang="zh-CN" data-launch-loading="true" style="
|
||||
overflow: hidden;
|
||||
--safe-area-inset-bottom: env(safe-area-inset-bottom);
|
||||
--safe-area-inset-top: env(safe-area-inset-top);
|
||||
background: var(--initial-loader-bg, #fff);
|
||||
--initial-loader-bg: #0E1116;
|
||||
--initial-loader-color: #9155FD;
|
||||
--initial-loader-height: 100svh;
|
||||
--initial-loader-width: 100vw;
|
||||
background: var(--initial-loader-bg, #0E1116);
|
||||
background-color: var(--initial-loader-bg, #0E1116);
|
||||
">
|
||||
|
||||
<head>
|
||||
@@ -92,50 +95,95 @@
|
||||
<link rel="preconnect" href="https://cdn.jsdelivr.net" crossorigin />
|
||||
|
||||
<style>
|
||||
#app {
|
||||
min-block-size: 100%;
|
||||
html,
|
||||
body {
|
||||
background: var(--initial-loader-bg, #0E1116);
|
||||
background-color: var(--initial-loader-bg, #0E1116);
|
||||
}
|
||||
|
||||
html[data-launch-loading="true"],
|
||||
html[data-launch-loading="true"] body {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
html[data-launch-loading="true"] body {
|
||||
min-block-size: var(--initial-loader-height, 100svh);
|
||||
}
|
||||
|
||||
html[data-launch-loading="true"] #app {
|
||||
min-block-size: var(--initial-loader-height, 100svh);
|
||||
background: var(--initial-loader-bg, #0E1116);
|
||||
background-color: var(--initial-loader-bg, #0E1116);
|
||||
-webkit-overflow-scrolling: touch;
|
||||
overscroll-behavior: contain;
|
||||
}
|
||||
|
||||
#loading-bg {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 99999;
|
||||
display: block;
|
||||
background: var(--initial-loader-bg, #fff);
|
||||
block-size: 100vh;
|
||||
inline-size: 100vw;
|
||||
transition: opacity 0.8s ease, transform 0.8s ease, filter 0.8s ease;
|
||||
overflow: hidden;
|
||||
background: var(--initial-loader-bg, #0E1116);
|
||||
background-color: var(--initial-loader-bg, #0E1116);
|
||||
}
|
||||
|
||||
.loading-shell {
|
||||
box-sizing: border-box;
|
||||
display: grid;
|
||||
grid-template-rows: minmax(0, 1fr) auto;
|
||||
block-size: var(--initial-loader-height, 100svh);
|
||||
inline-size: 100%;
|
||||
min-block-size: var(--initial-loader-height, 100svh);
|
||||
transition: opacity 0.12s ease-out, transform 0.12s ease-out;
|
||||
padding:
|
||||
calc(env(safe-area-inset-top, 0px) + 24px)
|
||||
24px
|
||||
calc(env(safe-area-inset-bottom, 0px) + 48px);
|
||||
}
|
||||
|
||||
.loading-main {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-block-size: 0;
|
||||
}
|
||||
|
||||
.loading-logo {
|
||||
position: absolute;
|
||||
inset-block-start: 35%;
|
||||
inset-inline-start: calc(50% - 5rem);
|
||||
transition: opacity 0.8s ease, transform 0.8s ease, filter 0.8s ease;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
inline-size: min(160px, 36vw);
|
||||
transform: translate3d(0, 0, 0);
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
.loading-complete .loading-logo {
|
||||
filter: blur(10px);
|
||||
opacity: 0;
|
||||
transform: scale(1.5);
|
||||
.loading-logo img {
|
||||
display: block;
|
||||
block-size: auto;
|
||||
inline-size: 100%;
|
||||
}
|
||||
|
||||
.loading-complete {
|
||||
filter: blur(15px);
|
||||
.loading-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-block-size: clamp(72px, 14vh, 120px);
|
||||
}
|
||||
|
||||
.loading-complete .loading-shell,
|
||||
.loading-complete #loading-timeout {
|
||||
opacity: 0;
|
||||
transform: scale(1.2);
|
||||
transform: translate3d(0, 6px, 0);
|
||||
}
|
||||
|
||||
.loading {
|
||||
position: absolute;
|
||||
position: relative;
|
||||
box-sizing: border-box;
|
||||
border: 3px solid transparent;
|
||||
border-radius: 50%;
|
||||
block-size: 55px;
|
||||
inline-size: 55px;
|
||||
inset-block-start: 80%;
|
||||
inset-inline-start: calc(50% - 27.5px);
|
||||
block-size: 46px;
|
||||
inline-size: 46px;
|
||||
transition: opacity 0.6s ease;
|
||||
}
|
||||
|
||||
@@ -198,7 +246,7 @@
|
||||
position: absolute;
|
||||
z-index: 2500;
|
||||
display: none;
|
||||
inset-block-end: 20px;
|
||||
inset-block-end: calc(env(safe-area-inset-bottom, 0px) + 24px);
|
||||
inset-inline-start: 50%;
|
||||
transform: translateX(-50%);
|
||||
background: rgba(0, 0, 0, 0.8);
|
||||
@@ -209,7 +257,8 @@
|
||||
font-family: sans-serif;
|
||||
text-align: center;
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4);
|
||||
white-space: nowrap;
|
||||
max-inline-size: calc(100% - 32px);
|
||||
white-space: normal;
|
||||
backdrop-filter: blur(4px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
@@ -233,25 +282,65 @@
|
||||
}
|
||||
}
|
||||
|
||||
// 主题色彩初始化
|
||||
let loaderColor = localStorage.getItem('materio-initial-loader-bg')
|
||||
let primaryColor = localStorage.getItem('materio-initial-loader-color')
|
||||
|
||||
// 检查主题设置
|
||||
const savedTheme = localStorage.getItem('theme') || 'auto'
|
||||
const isAutoTheme = savedTheme === 'auto'
|
||||
|
||||
// 如果是自动主题或者没有保存的背景色,根据系统主题设置背景色
|
||||
if (isAutoTheme || !loaderColor) {
|
||||
loaderColor = checkPrefersColorSchemeIsDark() ? '#0E1116' : '#FFFFFF'
|
||||
// 根据当前主题提前确定启动屏色彩,避免 iOS PWA 从原生启动图切到网页时露出默认白底。
|
||||
const launchThemeBackgrounds = {
|
||||
light: '#F4F5FA',
|
||||
dark: '#0E1116',
|
||||
purple: '#28243D',
|
||||
transparent: '#1C1C1C',
|
||||
default: '#F4F5FA',
|
||||
}
|
||||
|
||||
const savedTheme = localStorage.getItem('theme') || 'auto'
|
||||
const resolvedLaunchTheme = savedTheme === 'auto'
|
||||
? (checkPrefersColorSchemeIsDark() ? 'dark' : 'light')
|
||||
: savedTheme
|
||||
|
||||
let loaderColor = localStorage.getItem('materio-initial-loader-bg')
|
||||
|| launchThemeBackgrounds[resolvedLaunchTheme]
|
||||
|| launchThemeBackgrounds.light
|
||||
|
||||
let primaryColor = localStorage.getItem('materio-initial-loader-color')
|
||||
if (!primaryColor) {
|
||||
primaryColor = '#9155FD'
|
||||
}
|
||||
|
||||
// 在应用脚本接管前锁定一次启动层内容高度,避免 iOS 独立模式首次重算 safe area 时把 logo 顶下去。
|
||||
function syncInitialViewport(force) {
|
||||
const viewport = window.visualViewport
|
||||
const nextHeight = Math.round(viewport?.height || window.innerHeight || document.documentElement.clientHeight || 0)
|
||||
const nextWidth = Math.round(viewport?.width || window.innerWidth || document.documentElement.clientWidth || 0)
|
||||
const currentHeight = parseInt(
|
||||
document.documentElement.style.getPropertyValue('--initial-loader-height') || '0',
|
||||
10,
|
||||
)
|
||||
|
||||
if (!nextHeight || !nextWidth) {
|
||||
return
|
||||
}
|
||||
|
||||
if (!force && currentHeight && Math.abs(nextHeight - currentHeight) < 120) {
|
||||
return
|
||||
}
|
||||
|
||||
document.documentElement.style.setProperty('--initial-loader-height', `${nextHeight}px`)
|
||||
document.documentElement.style.setProperty('--initial-loader-width', `${nextWidth}px`)
|
||||
}
|
||||
|
||||
// 应用主题色彩
|
||||
document.documentElement.setAttribute('data-launch-theme', resolvedLaunchTheme)
|
||||
document.documentElement.style.setProperty('--initial-loader-bg', loaderColor)
|
||||
document.documentElement.style.setProperty('--initial-loader-color', primaryColor)
|
||||
document.documentElement.style.backgroundColor = loaderColor
|
||||
syncInitialViewport(true)
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
document.body.style.backgroundColor = loaderColor
|
||||
})
|
||||
|
||||
window.addEventListener('orientationchange', () => {
|
||||
window.setTimeout(() => syncInitialViewport(true), 160)
|
||||
})
|
||||
|
||||
// 状态栏适配
|
||||
if (window.navigator.standalone) {
|
||||
@@ -343,14 +432,20 @@
|
||||
|
||||
<body style="margin: 0; overflow: hidden; overscroll-behavior: none; -webkit-overflow-scrolling: touch">
|
||||
<div id="loading-bg">
|
||||
<div class="loading-logo">
|
||||
<!-- Logo -->
|
||||
<img src="/logo.svg" alt="MoviePilot" width="160px" height="160px" />
|
||||
</div>
|
||||
<div class="loading">
|
||||
<div class="effect-1 effects"></div>
|
||||
<div class="effect-2 effects"></div>
|
||||
<div class="effect-3 effects"></div>
|
||||
<div class="loading-shell">
|
||||
<div class="loading-main">
|
||||
<div class="loading-logo">
|
||||
<!-- Logo -->
|
||||
<img src="/logo.svg" alt="MoviePilot" width="160" height="160" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="loading-footer">
|
||||
<div class="loading">
|
||||
<div class="effect-1 effects"></div>
|
||||
<div class="effect-2 effects"></div>
|
||||
<div class="effect-3 effects"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 超时提示 - 默认隐藏 -->
|
||||
<div id="loading-timeout"></div>
|
||||
@@ -359,4 +454,4 @@
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
</html>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "moviepilot",
|
||||
"version": "2.10.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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
// 预加载图片
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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(
|
||||
|
||||
223
src/App.vue
223
src/App.vue
@@ -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%;
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
// 整理队列
|
||||
|
||||
BIN
src/assets/images/logos/clawbot.png
Normal file
BIN
src/assets/images/logos/clawbot.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 63 KiB |
BIN
src/assets/images/logos/feishu.png
Normal file
BIN
src/assets/images/logos/feishu.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 6.1 KiB |
BIN
src/assets/images/logos/zspace.webp
Normal file
BIN
src/assets/images/logos/zspace.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 7.2 KiB |
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
95
src/components/dialog/CacheReidentifyDialog.vue
Normal file
95
src/components/dialog/CacheReidentifyDialog.vue
Normal 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>
|
||||
237
src/components/dialog/ContentToggleSettingsDialog.vue
Normal file
237
src/components/dialog/ContentToggleSettingsDialog.vue
Normal 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>
|
||||
151
src/components/dialog/CustomCssDialog.vue
Normal file
151
src/components/dialog/CustomCssDialog.vue
Normal 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>
|
||||
209
src/components/dialog/CustomRuleInfoDialog.vue
Normal file
209
src/components/dialog/CustomRuleInfoDialog.vue
Normal 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>
|
||||
161
src/components/dialog/DiscoverTabOrderDialog.vue
Normal file
161
src/components/dialog/DiscoverTabOrderDialog.vue
Normal 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>
|
||||
529
src/components/dialog/DownloaderInfoDialog.vue
Normal file
529
src/components/dialog/DownloaderInfoDialog.vue
Normal 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>
|
||||
63
src/components/dialog/FileNewFolderDialog.vue
Normal file
63
src/components/dialog/FileNewFolderDialog.vue
Normal 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>
|
||||
94
src/components/dialog/FileRenameDialog.vue
Normal file
94
src/components/dialog/FileRenameDialog.vue
Normal 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>
|
||||
314
src/components/dialog/FilterRuleGroupInfoDialog.vue
Normal file
314
src/components/dialog/FilterRuleGroupInfoDialog.vue
Normal 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>
|
||||
82
src/components/dialog/LlmProviderAuthDialog.vue
Normal file
82
src/components/dialog/LlmProviderAuthDialog.vue
Normal 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>
|
||||
102
src/components/dialog/LoginMfaDialog.vue
Normal file
102
src/components/dialog/LoginMfaDialog.vue
Normal 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>
|
||||
601
src/components/dialog/MediaServerInfoDialog.vue
Normal file
601
src/components/dialog/MediaServerInfoDialog.vue
Normal 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>
|
||||
1131
src/components/dialog/NotificationChannelInfoDialog.vue
Normal file
1131
src/components/dialog/NotificationChannelInfoDialog.vue
Normal file
File diff suppressed because it is too large
Load Diff
157
src/components/dialog/NotificationTemplateEditorDialog.vue
Normal file
157
src/components/dialog/NotificationTemplateEditorDialog.vue
Normal 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>
|
||||
175
src/components/dialog/OfflineStatusDialog.vue
Normal file
175
src/components/dialog/OfflineStatusDialog.vue
Normal 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>
|
||||
@@ -206,6 +206,7 @@ watch(
|
||||
passkeyList.value = []
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
</script>
|
||||
|
||||
|
||||
172
src/components/dialog/PluginCloneDialog.vue
Normal file
172
src/components/dialog/PluginCloneDialog.vue
Normal 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>
|
||||
65
src/components/dialog/PluginFolderCreateDialog.vue
Normal file
65
src/components/dialog/PluginFolderCreateDialog.vue
Normal 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>
|
||||
66
src/components/dialog/PluginFolderRenameDialog.vue
Normal file
66
src/components/dialog/PluginFolderRenameDialog.vue
Normal 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>
|
||||
210
src/components/dialog/PluginFolderSettingsDialog.vue
Normal file
210
src/components/dialog/PluginFolderSettingsDialog.vue
Normal 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>
|
||||
69
src/components/dialog/PluginLogDialog.vue
Normal file
69
src/components/dialog/PluginLogDialog.vue
Normal 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>
|
||||
220
src/components/dialog/PluginMarketDetailDialog.vue
Normal file
220
src/components/dialog/PluginMarketDetailDialog.vue
Normal 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>
|
||||
133
src/components/dialog/PluginSearchDialog.vue
Normal file
133
src/components/dialog/PluginSearchDialog.vue
Normal 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>
|
||||
|
||||
70
src/components/dialog/PluginVersionHistoryDialog.vue
Normal file
70
src/components/dialog/PluginVersionHistoryDialog.vue
Normal 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>
|
||||
@@ -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
64
src/components/dialog/SharedDialogHost.vue
Normal file
64
src/components/dialog/SharedDialogHost.vue
Normal 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>
|
||||
61
src/components/dialog/ShortcutLogDialog.vue
Normal file
61
src/components/dialog/ShortcutLogDialog.vue
Normal 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>
|
||||
139
src/components/dialog/ShortcutMessageDialog.vue
Normal file
139
src/components/dialog/ShortcutMessageDialog.vue
Normal 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>
|
||||
82
src/components/dialog/ShortcutToolDialog.vue
Normal file
82
src/components/dialog/ShortcutToolDialog.vue
Normal 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>
|
||||
@@ -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&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&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;
|
||||
}
|
||||
|
||||
99
src/components/dialog/StorageCustomConfigDialog.vue
Normal file
99
src/components/dialog/StorageCustomConfigDialog.vue
Normal 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>
|
||||
@@ -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"
|
||||
|
||||
@@ -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">{{
|
||||
|
||||
144
src/components/dialog/TorrentAllFiltersDialog.vue
Normal file
144
src/components/dialog/TorrentAllFiltersDialog.vue
Normal 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>
|
||||
145
src/components/dialog/TorrentMoreSourcesDialog.vue
Normal file
145
src/components/dialog/TorrentMoreSourcesDialog.vue
Normal 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>
|
||||
108
src/components/dialog/TorrentSingleFilterDialog.vue
Normal file
108
src/components/dialog/TorrentSingleFilterDialog.vue
Normal 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>
|
||||
56
src/components/dialog/TransferHistoryDeleteDialog.vue
Normal file
56
src/components/dialog/TransferHistoryDeleteDialog.vue
Normal 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>
|
||||
@@ -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
|
||||
})
|
||||
|
||||
166
src/components/dialog/TransparencySettingsDialog.vue
Normal file
166
src/components/dialog/TransparencySettingsDialog.vue
Normal 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>
|
||||
@@ -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"
|
||||
|
||||
71
src/components/dialog/VerifyPasswordDialog.vue
Normal file
71
src/components/dialog/VerifyPasswordDialog.vue
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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: `
|
||||
|
||||
959
src/components/misc/ProgressiveCardGrid.vue
Normal file
959
src/components/misc/ProgressiveCardGrid.vue
Normal 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>
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
443
src/components/slide/VirtualSlideView.vue
Normal file
443
src/components/slide/VirtualSlideView.vue
Normal 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>
|
||||
@@ -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
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
38
src/composables/useCardAccentColor.ts
Normal file
38
src/composables/useCardAccentColor.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
98
src/composables/useKeepAliveRefresh.ts
Normal file
98
src/composables/useKeepAliveRefresh.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
96
src/composables/useSharedDialog.ts
Normal file
96
src/composables/useSharedDialog.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
33
src/composables/useSilentSettingRefresh.ts
Normal file
33
src/composables/useSilentSettingRefresh.ts
Normal 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)),
|
||||
},
|
||||
)
|
||||
}
|
||||
177
src/composables/useTransparencySettings.ts
Normal file
177
src/composables/useTransparencySettings.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user