mirror of
https://github.com/jxxghp/MoviePilot-Frontend.git
synced 2026-06-21 15:43:51 +08:00
Compare commits
151 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1f170030ee | ||
|
|
e78ed20936 | ||
|
|
b1787b207d | ||
|
|
fdb34732cc | ||
|
|
fc1f163a94 | ||
|
|
a771dc5354 | ||
|
|
d28360a161 | ||
|
|
a730abc437 | ||
|
|
5b72eda4fc | ||
|
|
6c49d7a59e | ||
|
|
8900366faf | ||
|
|
e8e0ac9084 | ||
|
|
c66ee881b1 | ||
|
|
c055740926 | ||
|
|
a5bc4e6baf | ||
|
|
15b4ee5893 | ||
|
|
8868403ff3 | ||
|
|
3abff72e25 | ||
|
|
0c56cf0be7 | ||
|
|
ce12d04648 | ||
|
|
efc0ae4df6 | ||
|
|
2530c3bcd9 | ||
|
|
60e2402aff | ||
|
|
1a478f97fb | ||
|
|
33666703af | ||
|
|
cd69172a99 | ||
|
|
61749e3595 | ||
|
|
b658533262 | ||
|
|
d8015b7def | ||
|
|
33599cc21d | ||
|
|
bf22a4809d | ||
|
|
4a6f7390e6 | ||
|
|
405e460ad6 | ||
|
|
18566c0e9d | ||
|
|
2c471a936f | ||
|
|
2efb07402f | ||
|
|
9434ef71e4 | ||
|
|
e06b9537ff | ||
|
|
2829e3b082 | ||
|
|
1a0fc10559 | ||
|
|
5a1aec3323 | ||
|
|
48913b8811 | ||
|
|
0a7d53b5c7 | ||
|
|
da0cd14af8 | ||
|
|
342c62c085 | ||
|
|
891274cc0e | ||
|
|
889a4b744a | ||
|
|
7fc5b74851 | ||
|
|
785cbcf81d | ||
|
|
364b660390 | ||
|
|
599ca912f4 | ||
|
|
2f66f0f1fc | ||
|
|
cd2f561194 | ||
|
|
c59a555a2d | ||
|
|
4413fedec5 | ||
|
|
d7562ea506 | ||
|
|
951d76481b | ||
|
|
68b0071009 | ||
|
|
0594d1d5b2 | ||
|
|
4f328add1b | ||
|
|
62c9a10377 | ||
|
|
d3d0d847f6 | ||
|
|
b7dd397664 | ||
|
|
c0276fca9f | ||
|
|
4691d12faa | ||
|
|
d0cac34d08 | ||
|
|
2f46c19826 | ||
|
|
b1cb07ae8c | ||
|
|
19710a5f0f | ||
|
|
4362bbed42 | ||
|
|
89cf7070bb | ||
|
|
369afd6674 | ||
|
|
c4fd8f5631 | ||
|
|
c9ebf23977 | ||
|
|
e239c0c5ea | ||
|
|
87d780d985 | ||
|
|
f51b253c83 | ||
|
|
bf05cd0697 | ||
|
|
8e5b8f7207 | ||
|
|
5e3e106d91 | ||
|
|
c5fa6aad68 | ||
|
|
989e8b4c5e | ||
|
|
a865aa433c | ||
|
|
df8e6016cd | ||
|
|
75da7d35b4 | ||
|
|
e2722801e4 | ||
|
|
63e28b76c8 | ||
|
|
9dc63e2c21 | ||
|
|
08a2741c06 | ||
|
|
0dd95508b5 | ||
|
|
f3c524b6b5 | ||
|
|
a73c28c1f7 | ||
|
|
b3fb7e1de1 | ||
|
|
3620b2a979 | ||
|
|
1046cb276f | ||
|
|
6dfda4807c | ||
|
|
8d13f3e5ca | ||
|
|
9ebe740c69 | ||
|
|
73673820f1 | ||
|
|
c6d0116e0f | ||
|
|
6a06001dae | ||
|
|
ad75b50a0c | ||
|
|
2a68aa05f6 | ||
|
|
fa90411c7a | ||
|
|
643ddcef07 | ||
|
|
addc0838c0 | ||
|
|
8b43e0a754 | ||
|
|
f81a9f0929 | ||
|
|
3cf5cc24cd | ||
|
|
841e9479af | ||
|
|
8c3380e8f5 | ||
|
|
0ac42f0a76 | ||
|
|
caef6eca67 | ||
|
|
0867236b68 | ||
|
|
09dfdbaf67 | ||
|
|
57224e15fb | ||
|
|
200500a060 | ||
|
|
a4731aade1 | ||
|
|
7d21eabf1a | ||
|
|
b639737bd6 | ||
|
|
d02ece234c | ||
|
|
889a5c9e51 | ||
|
|
880a34f508 | ||
|
|
50b0148ed6 | ||
|
|
285ddab45a | ||
|
|
aa12f4b6b6 | ||
|
|
9bbb060073 | ||
|
|
3b0623628c | ||
|
|
b45c147452 | ||
|
|
25bc7c4b3c | ||
|
|
d6b7b6d813 | ||
|
|
a3ac46c891 | ||
|
|
b6e824246b | ||
|
|
5191f6780d | ||
|
|
261aaf17ad | ||
|
|
258e64bca7 | ||
|
|
e905df014e | ||
|
|
b93f8f2bff | ||
|
|
9aa0a5e1b7 | ||
|
|
ee9f41d015 | ||
|
|
ad6a664cbe | ||
|
|
3387067636 | ||
|
|
07dc3c3e9a | ||
|
|
262b4bebd4 | ||
|
|
6e50cf31de | ||
|
|
14aa75dfae | ||
|
|
348aa4757b | ||
|
|
6e6819acc1 | ||
|
|
51a58aaae0 | ||
|
|
fbde99389e | ||
|
|
5a4e345529 |
18
README.md
18
README.md
@@ -11,15 +11,6 @@
|
||||
- 支持多语言(中文/英文)
|
||||
- 完整的插件系统支持,包括远程组件动态加载
|
||||
|
||||
## 模块联邦功能
|
||||
|
||||
MoviePilot 现已支持模块联邦(Module Federation)功能,允许插件开发者创建可动态加载的远程组件,实现更丰富的插件用户界面。
|
||||
|
||||
### 相关文档
|
||||
|
||||
- [模块联邦开发指南](docs/module-federation-guide.md) - 如何开发远程组件插件
|
||||
- [模块联邦问题排查指南](docs/federation-troubleshooting.md) - 常见问题和解决方案
|
||||
- [插件远程组件示例](examples/plugin-component/) - 开发插件组件的完整示例项目
|
||||
|
||||
## 开发部署
|
||||
|
||||
@@ -58,3 +49,12 @@ yarn build
|
||||
```shell
|
||||
node dist/service.js
|
||||
```
|
||||
|
||||
|
||||
### 模块联邦功能
|
||||
|
||||
MoviePilot 现已支持模块联邦(Module Federation)功能,允许插件开发者创建可动态加载的远程组件,实现更丰富的插件用户界面。
|
||||
|
||||
- [模块联邦开发指南](docs/module-federation-guide.md) - 如何开发远程组件插件
|
||||
- [模块联邦问题排查指南](docs/federation-troubleshooting.md) - 常见问题和解决方案
|
||||
- [插件远程组件示例](examples/plugin-component/) - 开发插件组件的完整示例项目
|
||||
|
||||
16
README_EN.md
16
README_EN.md
@@ -11,15 +11,6 @@ Frontend project for [MoviePilot](https://github.com/jxxghp/MoviePilot), NodeJS
|
||||
- Multi-language support (Chinese/English)
|
||||
- Complete plugin system with dynamic remote component loading
|
||||
|
||||
## Module Federation
|
||||
|
||||
MoviePilot now supports Module Federation, allowing plugin developers to create dynamically loadable remote components for richer plugin user interfaces.
|
||||
|
||||
### Documentation
|
||||
|
||||
- [Module Federation Troubleshooting Guide](docs/federation-troubleshooting.md) - Common issues and solutions
|
||||
- [Plugin Remote Component Example](examples/plugin-component/) - Complete example project for developing plugin components
|
||||
|
||||
## Development
|
||||
|
||||
### Recommended IDE Setup
|
||||
@@ -57,3 +48,10 @@ yarn build
|
||||
```shell
|
||||
node dist/service.js
|
||||
```
|
||||
|
||||
### Module Federation
|
||||
|
||||
MoviePilot now supports Module Federation, allowing plugin developers to create dynamically loadable remote components for richer plugin user interfaces.
|
||||
|
||||
- [Module Federation Troubleshooting Guide](docs/federation-troubleshooting.md) - Common issues and solutions
|
||||
- [Plugin Remote Component Example](examples/plugin-component/) - Complete example project for developing plugin components
|
||||
|
||||
162
index.html
162
index.html
@@ -7,8 +7,10 @@
|
||||
--initial-loader-color: #9155FD;
|
||||
--initial-loader-height: 100svh;
|
||||
--initial-loader-width: 100vw;
|
||||
--initial-color-scheme: dark;
|
||||
background: var(--initial-loader-bg, #0E1116);
|
||||
background-color: var(--initial-loader-bg, #0E1116);
|
||||
color-scheme: dark;
|
||||
">
|
||||
|
||||
<head>
|
||||
@@ -16,7 +18,7 @@
|
||||
<meta charset="UTF-8" />
|
||||
<!-- 核心viewport设置 - 针对PWA优化 -->
|
||||
<meta name="viewport"
|
||||
content="width=device-width, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0, user-scalable=no, viewport-fit=cover, shrink-to-fit=no, interactive-widget=resizes-content" />
|
||||
content="width=device-width, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0, user-scalable=no, viewport-fit=cover, shrink-to-fit=no" />
|
||||
|
||||
<!-- 防止缩放和选择,提供原生应用体验 -->
|
||||
<meta name="format-detection" content="telephone=no, date=no, email=no, address=no" />
|
||||
@@ -37,7 +39,7 @@
|
||||
|
||||
<!-- iOS Safari PWA 优化 -->
|
||||
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
|
||||
<link rel="apple-touch-icon-precomposed" href="/apple-touch-icon.png" />
|
||||
<link rel="apple-touch-icon-precomposed" href="/apple-touch-icon-precomposed.png" />
|
||||
<link rel="apple-touch-startup-image" href="/splash/apple-splash.png" />
|
||||
|
||||
<!-- iOS Safari 全屏模式 -->
|
||||
@@ -99,6 +101,7 @@
|
||||
body {
|
||||
background: var(--initial-loader-bg, #0E1116);
|
||||
background-color: var(--initial-loader-bg, #0E1116);
|
||||
color-scheme: var(--initial-color-scheme, dark);
|
||||
}
|
||||
|
||||
html[data-launch-loading="true"],
|
||||
@@ -118,6 +121,12 @@
|
||||
overscroll-behavior: contain;
|
||||
}
|
||||
|
||||
html[data-launch-loading="true"] .footer-nav-container {
|
||||
opacity: 0 !important;
|
||||
pointer-events: none !important;
|
||||
visibility: hidden !important;
|
||||
}
|
||||
|
||||
#loading-bg {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
@@ -282,27 +291,113 @@
|
||||
}
|
||||
}
|
||||
|
||||
// 根据当前主题提前确定启动屏色彩,避免 iOS PWA 从原生启动图切到网页时露出默认白底。
|
||||
const launchThemeBackgrounds = {
|
||||
light: '#F4F5FA',
|
||||
dark: '#0E1116',
|
||||
purple: '#28243D',
|
||||
transparent: '#1C1C1C',
|
||||
default: '#F4F5FA',
|
||||
function getLocalStorageValue(key) {
|
||||
try {
|
||||
return localStorage.getItem(key)
|
||||
} catch (e) {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
const savedTheme = localStorage.getItem('theme') || 'auto'
|
||||
const resolvedLaunchTheme = savedTheme === 'auto'
|
||||
? (checkPrefersColorSchemeIsDark() ? 'dark' : 'light')
|
||||
: savedTheme
|
||||
// 根据当前主题提前确定启动屏色彩,避免 iOS PWA 从原生启动图切到网页时露出默认白底。
|
||||
const launchThemePalettes = {
|
||||
light: {
|
||||
background: '#F4F5FA',
|
||||
primary: '#9155FD',
|
||||
},
|
||||
dark: {
|
||||
background: '#0E1116',
|
||||
primary: '#6E66ED',
|
||||
},
|
||||
purple: {
|
||||
background: '#28243D',
|
||||
primary: '#9155FD',
|
||||
},
|
||||
transparent: {
|
||||
background: '#1C1C1C',
|
||||
primary: '#A370F7',
|
||||
},
|
||||
}
|
||||
|
||||
let loaderColor = localStorage.getItem('materio-initial-loader-bg')
|
||||
|| launchThemeBackgrounds[resolvedLaunchTheme]
|
||||
|| launchThemeBackgrounds.light
|
||||
function getSavedThemePreference() {
|
||||
return getLocalStorageValue('theme') || 'auto'
|
||||
}
|
||||
|
||||
let primaryColor = localStorage.getItem('materio-initial-loader-color')
|
||||
if (!primaryColor) {
|
||||
primaryColor = '#9155FD'
|
||||
function resolveLaunchTheme(themePreference) {
|
||||
if (themePreference === 'auto') {
|
||||
return checkPrefersColorSchemeIsDark() ? 'dark' : 'light'
|
||||
}
|
||||
|
||||
if (themePreference === 'default') {
|
||||
return 'light'
|
||||
}
|
||||
|
||||
return launchThemePalettes[themePreference] ? themePreference : 'light'
|
||||
}
|
||||
|
||||
function getLaunchColorScheme(themeName) {
|
||||
return ['dark', 'purple', 'transparent'].includes(themeName) ? 'dark' : 'light'
|
||||
}
|
||||
|
||||
function setMetaContent(selector, content) {
|
||||
document.querySelectorAll(selector).forEach(meta => {
|
||||
meta.setAttribute('content', content)
|
||||
})
|
||||
}
|
||||
|
||||
function syncThemeColorMeta(themeColor) {
|
||||
const metas = document.querySelectorAll('meta[name="theme-color"]')
|
||||
|
||||
if (metas.length) {
|
||||
metas.forEach(meta => {
|
||||
meta.setAttribute('content', themeColor)
|
||||
})
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
const meta = document.createElement('meta')
|
||||
meta.name = 'theme-color'
|
||||
meta.content = themeColor
|
||||
document.head.appendChild(meta)
|
||||
}
|
||||
|
||||
function applyLaunchThemeChrome() {
|
||||
const themePreference = getSavedThemePreference()
|
||||
const resolvedLaunchTheme = resolveLaunchTheme(themePreference)
|
||||
const colorScheme = getLaunchColorScheme(resolvedLaunchTheme)
|
||||
const palette = launchThemePalettes[resolvedLaunchTheme] || launchThemePalettes.light
|
||||
|
||||
// auto 模式下系统明暗可能已变化,不能复用旧的启动背景缓存。
|
||||
const storedLoaderColor = themePreference === 'auto' ? null : getLocalStorageValue('materio-initial-loader-bg')
|
||||
const loaderColor = storedLoaderColor || palette.background
|
||||
const primaryColor = getLocalStorageValue('materio-initial-loader-color') || palette.primary
|
||||
|
||||
document.documentElement.setAttribute('data-launch-theme', resolvedLaunchTheme)
|
||||
document.documentElement.setAttribute('data-theme', resolvedLaunchTheme)
|
||||
document.documentElement.setAttribute('data-theme-preference', themePreference)
|
||||
document.documentElement.style.setProperty('--initial-loader-bg', loaderColor)
|
||||
document.documentElement.style.setProperty('--initial-loader-color', primaryColor)
|
||||
document.documentElement.style.setProperty('--initial-color-scheme', colorScheme)
|
||||
document.documentElement.style.backgroundColor = loaderColor
|
||||
document.documentElement.style.colorScheme = colorScheme
|
||||
|
||||
if (document.body) {
|
||||
document.body.setAttribute('data-theme', resolvedLaunchTheme)
|
||||
document.body.setAttribute('data-theme-preference', themePreference)
|
||||
document.body.style.backgroundColor = loaderColor
|
||||
document.body.style.colorScheme = colorScheme
|
||||
}
|
||||
|
||||
setMetaContent('meta[name="color-scheme"]', colorScheme === 'dark' ? 'dark light' : 'light dark')
|
||||
syncThemeColorMeta(loaderColor)
|
||||
|
||||
return {
|
||||
background: loaderColor,
|
||||
colorScheme,
|
||||
resolvedLaunchTheme,
|
||||
themePreference,
|
||||
}
|
||||
}
|
||||
|
||||
// 在应用脚本接管前锁定一次启动层内容高度,避免 iOS 独立模式首次重算 safe area 时把 logo 顶下去。
|
||||
@@ -328,20 +423,39 @@
|
||||
}
|
||||
|
||||
// 应用主题色彩
|
||||
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
|
||||
applyLaunchThemeChrome()
|
||||
syncInitialViewport(true)
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
document.body.style.backgroundColor = loaderColor
|
||||
applyLaunchThemeChrome()
|
||||
})
|
||||
|
||||
document.addEventListener('visibilitychange', () => {
|
||||
if (document.visibilityState === 'visible') {
|
||||
applyLaunchThemeChrome()
|
||||
}
|
||||
})
|
||||
|
||||
window.addEventListener('pageshow', () => {
|
||||
applyLaunchThemeChrome()
|
||||
})
|
||||
|
||||
window.addEventListener('focus', () => {
|
||||
applyLaunchThemeChrome()
|
||||
})
|
||||
|
||||
window.addEventListener('orientationchange', () => {
|
||||
window.setTimeout(() => syncInitialViewport(true), 160)
|
||||
})
|
||||
|
||||
try {
|
||||
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () => {
|
||||
applyLaunchThemeChrome()
|
||||
})
|
||||
} catch (e) {
|
||||
// 老浏览器不支持监听系统主题变化时,运行时主题管理器仍会继续接管。
|
||||
}
|
||||
|
||||
// 状态栏适配
|
||||
if (window.navigator.standalone) {
|
||||
document.documentElement.style.setProperty('--status-bar-height', '20px')
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "moviepilot",
|
||||
"version": "2.13.1-1",
|
||||
"version": "2.13.11",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"bin": "dist/service.js",
|
||||
@@ -49,6 +49,7 @@
|
||||
"dayjs": "^1.11.13",
|
||||
"express": "^4.21.2",
|
||||
"express-http-proxy": "^2.1.1",
|
||||
"gridstack": "^12.6.0",
|
||||
"http-proxy-middleware": "^3.0.0",
|
||||
"js-cookie": "^3.0.5",
|
||||
"lodash-es": "^4.17.21",
|
||||
|
||||
BIN
public/apple-touch-icon-precomposed.png
Normal file
BIN
public/apple-touch-icon-precomposed.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 102 KiB |
12
scripts/check-season-label.ts
Normal file
12
scripts/check-season-label.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import assert from 'node:assert/strict'
|
||||
|
||||
import { formatSeasonLabel } from '../src/@core/utils/season.ts'
|
||||
|
||||
assert.equal(formatSeasonLabel(0, '特别篇'), '特别篇')
|
||||
assert.equal(formatSeasonLabel('0', 'Specials'), 'Specials')
|
||||
assert.equal(formatSeasonLabel(1, '特别篇'), 'S01')
|
||||
assert.equal(formatSeasonLabel('12', '特别篇'), 'S12')
|
||||
assert.equal(formatSeasonLabel(null, '特别篇'), '')
|
||||
assert.equal(formatSeasonLabel(undefined, '特别篇'), '')
|
||||
|
||||
console.log('season label checks passed')
|
||||
@@ -22,8 +22,8 @@ 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%);
|
||||
isolation: isolate;
|
||||
|
||||
@media (width >= 1280px) and (hover: hover) {
|
||||
background: rgba(var(--v-theme-background), 1);
|
||||
|
||||
@@ -5,13 +5,13 @@
|
||||
// Vertical nav scrolled sticky elevated nav
|
||||
%default-layout-vertical-nav-scrolled-sticky-elevated-nav {
|
||||
background-color: rgb(var(--v-theme-surface));
|
||||
box-shadow: 0 4px 8px -4px rgb(94 86 105 / 42%);
|
||||
box-shadow: var(--app-surface-shadow);
|
||||
}
|
||||
|
||||
// Floating navbar and sticky elevated navbar scrolled
|
||||
%default-layout-vertical-nav-floating-navbar-and-sticky-elevated-navbar-scrolled {
|
||||
background-color: rgb(var(--v-theme-surface));
|
||||
box-shadow: 0 4px 8px -4px rgb(94 86 105 / 42%);
|
||||
box-shadow: var(--app-surface-shadow);
|
||||
}
|
||||
|
||||
// Floating navbar overlay
|
||||
|
||||
15
src/@core/utils/season.ts
Normal file
15
src/@core/utils/season.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
/**
|
||||
* 格式化用户可见的季标签。
|
||||
*
|
||||
* TMDB 使用季号 0 表示特别季;调用方传入当前语言的特别季名称,
|
||||
* 其余季号保持 MoviePilot 现有的 Sxx 展示口径。
|
||||
*/
|
||||
export function formatSeasonLabel(
|
||||
season: number | string | null | undefined,
|
||||
specialsLabel: string,
|
||||
): string {
|
||||
if (season === null || season === undefined || season === '') return ''
|
||||
if (Number(season) === 0) return specialsLabel
|
||||
|
||||
return `S${String(season).padStart(2, '0')}`
|
||||
}
|
||||
@@ -1,7 +1,12 @@
|
||||
<script lang="ts">
|
||||
import { Transition } from 'vue'
|
||||
import { useDisplay } from 'vuetify'
|
||||
import VerticalNav from '@layouts/components/VerticalNav.vue'
|
||||
import {
|
||||
readThemeCustomizerSettings,
|
||||
THEME_CUSTOMIZER_CHANGE_EVENT,
|
||||
type ThemeCustomizerSettings,
|
||||
} from '@/composables/useThemeCustomizer'
|
||||
import { usePWA } from '@/composables/usePWA'
|
||||
|
||||
export default defineComponent({
|
||||
setup(props, { slots }) {
|
||||
@@ -11,6 +16,11 @@ export default defineComponent({
|
||||
|
||||
const route = useRoute()
|
||||
const { mdAndDown } = useDisplay()
|
||||
const { appMode } = usePWA()
|
||||
const themeLayout = ref(readThemeCustomizerSettings().layout)
|
||||
const canUseDesktopLayout = computed(() => !mdAndDown.value && !appMode.value)
|
||||
const isCollapsedLayout = computed(() => canUseDesktopLayout.value && themeLayout.value === 'collapsed')
|
||||
const isHorizontalLayout = computed(() => canUseDesktopLayout.value && themeLayout.value === 'horizontal')
|
||||
|
||||
// ℹ️ This is alternative to below two commented watcher
|
||||
// We want to show overlay if overlay nav is visible and want to hide overlay if overlay is hidden and vice versa.
|
||||
@@ -25,6 +35,10 @@ export default defineComponent({
|
||||
scrollDistance.value = window.scrollY
|
||||
}
|
||||
|
||||
const handleThemeCustomizerChange = (event: Event) => {
|
||||
themeLayout.value = (event as CustomEvent<ThemeCustomizerSettings>).detail.layout
|
||||
}
|
||||
|
||||
// 监听弹窗状态变化
|
||||
const checkDialogState = () => {
|
||||
const wasDialogOpen = isDialogOpen.value
|
||||
@@ -32,12 +46,13 @@ export default defineComponent({
|
||||
|
||||
// 当弹窗刚打开时,记录当前的滚动状态
|
||||
if (!wasDialogOpen && isDialogOpen.value) {
|
||||
wasScrolledBeforeDialog.value = scrollDistance.value > 0
|
||||
wasScrolledBeforeDialog.value = scrollDistance.value > 10
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
window.addEventListener('scroll', handleScroll)
|
||||
window.addEventListener(THEME_CUSTOMIZER_CHANGE_EVENT, handleThemeCustomizerChange)
|
||||
|
||||
// 初始检查弹窗状态
|
||||
checkDialogState()
|
||||
@@ -52,6 +67,7 @@ export default defineComponent({
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener('scroll', handleScroll)
|
||||
window.removeEventListener(THEME_CUSTOMIZER_CHANGE_EVENT, handleThemeCustomizerChange)
|
||||
dialogObserver?.disconnect()
|
||||
dialogObserver = null
|
||||
})
|
||||
@@ -93,13 +109,12 @@ export default defineComponent({
|
||||
const main = h(
|
||||
'main',
|
||||
{ class: 'layout-page-content' },
|
||||
h(Transition, { name: 'fade-slide', mode: 'out-in', appear: true }, () =>
|
||||
h('section', { class: 'page-content-container' }, slots.default?.()),
|
||||
),
|
||||
h('section', { class: 'page-content-container' }, slots.default?.()),
|
||||
)
|
||||
|
||||
// 👉 根据路由 meta 决定 footer 高度
|
||||
const shouldShowFooter = !route.meta.hideFooter
|
||||
const isNavbarScrolled = scrollDistance.value > 5 || (isDialogOpen.value && wasScrolledBeforeDialog.value)
|
||||
|
||||
// 👉 Footer
|
||||
const footer = h('footer', { class: 'layout-footer' }, [
|
||||
@@ -127,8 +142,11 @@ export default defineComponent({
|
||||
'layout-wrapper layout-nav-type-vertical layout-navbar-static layout-footer-static layout-content-width-fluid',
|
||||
'layout-navbar-fixed',
|
||||
mdAndDown.value && 'layout-overlay-nav',
|
||||
isCollapsedLayout.value && 'layout-vertical-nav-collapsed',
|
||||
isHorizontalLayout.value && 'layout-horizontal-nav-active',
|
||||
isHorizontalLayout.value && isNavbarScrolled && 'layout-horizontal-nav-scrolled',
|
||||
route.meta.layoutWrapperClasses,
|
||||
(scrollDistance.value > 5 || (isDialogOpen.value && wasScrolledBeforeDialog.value)) && 'window-scrolled',
|
||||
!isHorizontalLayout.value && isNavbarScrolled && 'window-scrolled',
|
||||
],
|
||||
},
|
||||
[verticalNav, h('div', { class: 'layout-content-wrapper' }, [navbar, main, footer]), layoutOverlay],
|
||||
@@ -139,6 +157,8 @@ export default defineComponent({
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
/* stylelint-disable no-descending-specificity */
|
||||
|
||||
@use '@configured-variables' as variables;
|
||||
@use '@layouts/styles/placeholders';
|
||||
@use '@layouts/styles/mixins';
|
||||
@@ -150,6 +170,10 @@ export default defineComponent({
|
||||
}
|
||||
|
||||
.layout-wrapper.layout-nav-type-vertical {
|
||||
--layout-navbar-block-size: calc(
|
||||
env(safe-area-inset-top, 0px) + #{variables.$layout-vertical-nav-navbar-height} + var(--navbar-tab-height)
|
||||
);
|
||||
|
||||
// TODO(v2): Check why we need height in vertical nav & min-height in horizontal nav
|
||||
min-block-size: 100%;
|
||||
|
||||
@@ -165,13 +189,16 @@ export default defineComponent({
|
||||
.layout-navbar {
|
||||
position: fixed;
|
||||
z-index: variables.$layout-vertical-nav-layout-navbar-z-index;
|
||||
// iOS Safari 在地址栏收起和惯性滚动时可能把 fixed 顶栏和页面滚动层合成到一起,
|
||||
// 单独提升顶栏图层可避免导航栏短暂上移到安全区下方。
|
||||
backface-visibility: hidden;
|
||||
block-size: var(--layout-navbar-block-size);
|
||||
inline-size: calc(100vw - variables.$layout-vertical-nav-width - 0.5rem);
|
||||
inset-block-start: 0;
|
||||
transform: translate3d(0, 0, 0);
|
||||
|
||||
.navbar-content-container {
|
||||
block-size: calc(
|
||||
env(safe-area-inset-top) + variables.$layout-vertical-nav-navbar-height + var(--navbar-tab-height)
|
||||
);
|
||||
block-size: var(--layout-navbar-block-size);
|
||||
}
|
||||
|
||||
@at-root {
|
||||
@@ -223,6 +250,211 @@ export default defineComponent({
|
||||
// Adjust right column pl when vertical nav is collapsed
|
||||
&.layout-vertical-nav-collapsed .layout-content-wrapper {
|
||||
padding-inline-start: variables.$layout-vertical-nav-collapsed-width;
|
||||
|
||||
.page-content-container > div:first-child {
|
||||
inline-size: calc(100vw - variables.$layout-vertical-nav-collapsed-width - 1rem);
|
||||
}
|
||||
}
|
||||
|
||||
&.layout-vertical-nav-collapsed .layout-navbar {
|
||||
inline-size: calc(100vw - variables.$layout-vertical-nav-collapsed-width - 0.5rem);
|
||||
}
|
||||
|
||||
&.layout-vertical-nav-collapsed .layout-vertical-nav:not(.overlay-nav) {
|
||||
.nav-header {
|
||||
justify-content: center;
|
||||
margin-inline: 0;
|
||||
padding-inline: 0;
|
||||
}
|
||||
|
||||
.app-logo {
|
||||
justify-content: center;
|
||||
inline-size: 100%;
|
||||
transform: none !important;
|
||||
}
|
||||
|
||||
.app-logo > div {
|
||||
display: flex;
|
||||
overflow: hidden;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
block-size: 2.75rem;
|
||||
inline-size: 2.75rem;
|
||||
}
|
||||
|
||||
.app-logo svg {
|
||||
block-size: 2.5rem;
|
||||
inline-size: 2.5rem;
|
||||
}
|
||||
|
||||
.app-logo h1,
|
||||
.nav-item-title,
|
||||
.nav-section-title {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.nav-link > a {
|
||||
justify-content: center;
|
||||
border-radius: 0.75rem !important;
|
||||
block-size: 2.75rem;
|
||||
margin-inline: 0.75rem;
|
||||
padding-inline: 0;
|
||||
}
|
||||
|
||||
.nav-item-icon {
|
||||
margin-inline-end: 0 !important;
|
||||
}
|
||||
}
|
||||
|
||||
&.layout-horizontal-nav-active {
|
||||
.layout-vertical-nav:not(.overlay-nav) {
|
||||
pointer-events: none;
|
||||
transform: translateX(-100%);
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
.layout-content-wrapper {
|
||||
padding-inline-start: 0;
|
||||
}
|
||||
|
||||
.layout-navbar {
|
||||
background: rgb(var(--v-theme-background));
|
||||
border-block-end: 1px solid rgba(var(--v-theme-on-surface), 0.08);
|
||||
inline-size: 100%;
|
||||
max-inline-size: none;
|
||||
padding-inline: 0;
|
||||
}
|
||||
|
||||
.navbar-content-container {
|
||||
border: 0 !important;
|
||||
border-radius: 0 !important;
|
||||
background: transparent !important;
|
||||
inline-size: 100%;
|
||||
margin-inline: auto;
|
||||
max-inline-size: variables.$layout-boxed-content-width;
|
||||
padding-inline: 1.5rem;
|
||||
}
|
||||
|
||||
.layout-page-content {
|
||||
inline-size: 100%;
|
||||
margin-inline: auto;
|
||||
max-inline-size: variables.$layout-boxed-content-width;
|
||||
padding-inline: 1rem;
|
||||
}
|
||||
|
||||
.page-content-container > div:first-child {
|
||||
inline-size: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
@at-root {
|
||||
.layout-wrapper.layout-horizontal-nav-active.layout-horizontal-nav-scrolled.layout-navbar-fixed .layout-navbar {
|
||||
backdrop-filter: blur(12px) saturate(1.2);
|
||||
background: rgb(var(--v-theme-surface)) !important;
|
||||
box-shadow:
|
||||
0 1px 3px rgba(0, 0, 0, 4%),
|
||||
0 1px 2px rgba(0, 0, 0, 2%);
|
||||
}
|
||||
|
||||
.layout-wrapper.layout-horizontal-nav-active.layout-horizontal-nav-scrolled.layout-navbar-fixed
|
||||
.navbar-content-container {
|
||||
backdrop-filter: none !important;
|
||||
background: transparent !important;
|
||||
background-color: transparent !important;
|
||||
box-shadow: none !important;
|
||||
filter: none !important;
|
||||
padding-inline: 1.5rem !important;
|
||||
|
||||
&::before {
|
||||
display: none !important;
|
||||
backdrop-filter: none !important;
|
||||
background: transparent !important;
|
||||
background-color: transparent !important;
|
||||
content: none !important;
|
||||
filter: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
html[data-theme='transparent'] .layout-wrapper.layout-horizontal-nav-active .layout-navbar,
|
||||
.v-theme--transparent .layout-wrapper.layout-horizontal-nav-active .layout-navbar {
|
||||
backdrop-filter: none !important;
|
||||
background: transparent !important;
|
||||
border-block-end-color: rgba(var(--v-theme-on-surface), 0.04);
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
html[data-theme='transparent'] .layout-wrapper.layout-horizontal-nav-active .navbar-content-container,
|
||||
.v-theme--transparent .layout-wrapper.layout-horizontal-nav-active .navbar-content-container {
|
||||
backdrop-filter: none !important;
|
||||
background: transparent !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
// 透明主题的水平导航不叠加滚动磨砂层,避免中间区域出现一块更深的背景。
|
||||
html[data-theme='transparent']
|
||||
.layout-wrapper.layout-horizontal-nav-active.layout-horizontal-nav-scrolled.layout-navbar-fixed
|
||||
.layout-navbar,
|
||||
.v-theme--transparent
|
||||
.layout-wrapper.layout-horizontal-nav-active.layout-horizontal-nav-scrolled.layout-navbar-fixed
|
||||
.layout-navbar {
|
||||
backdrop-filter: blur(var(--transparent-blur-light, 6px)) !important;
|
||||
background: rgba(var(--v-theme-surface), var(--transparent-opacity-light, 0.2)) !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
// 透明主题滚动时只让外层导航栏承载整屏背景,避免内部最大宽度容器单独变深。
|
||||
html[data-theme='transparent']
|
||||
.layout-wrapper.layout-horizontal-nav-active.layout-horizontal-nav-scrolled.layout-navbar-fixed
|
||||
.navbar-content-container,
|
||||
.v-theme--transparent
|
||||
.layout-wrapper.layout-horizontal-nav-active.layout-horizontal-nav-scrolled.layout-navbar-fixed
|
||||
.navbar-content-container {
|
||||
backdrop-filter: none !important;
|
||||
background: transparent !important;
|
||||
background-color: transparent !important;
|
||||
box-shadow: none !important;
|
||||
filter: none !important;
|
||||
padding-inline: 1.5rem !important;
|
||||
|
||||
&::before {
|
||||
display: none !important;
|
||||
backdrop-filter: none !important;
|
||||
background: transparent !important;
|
||||
background-color: transparent !important;
|
||||
content: none !important;
|
||||
filter: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
html[data-theme='light'][data-theme-semi-dark-menu='true'][data-theme-layout='vertical']
|
||||
.layout-wrapper.layout-nav-type-vertical:not(.layout-horizontal-nav-active)
|
||||
.layout-vertical-nav:not(.overlay-nav),
|
||||
html[data-theme='light'][data-theme-semi-dark-menu='true'][data-theme-layout='collapsed']
|
||||
.layout-wrapper.layout-nav-type-vertical:not(.layout-horizontal-nav-active)
|
||||
.layout-vertical-nav:not(.overlay-nav) {
|
||||
background: #2f3349;
|
||||
color: #e7e3fc;
|
||||
|
||||
.app-logo h1,
|
||||
.nav-section-title,
|
||||
.nav-link > a,
|
||||
.nav-item-icon {
|
||||
color: rgba(231, 227, 252, 78%) !important;
|
||||
}
|
||||
|
||||
.nav-link > a:hover {
|
||||
background-color: rgba(231, 227, 252, 6%);
|
||||
}
|
||||
|
||||
.nav-link > .router-link-exact-active {
|
||||
color: #fff !important;
|
||||
|
||||
.nav-item-icon,
|
||||
.nav-item-title {
|
||||
color: #fff !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 👉 Content height fixed
|
||||
@@ -233,9 +465,7 @@ export default defineComponent({
|
||||
|
||||
.layout-page-content {
|
||||
// display: flex;
|
||||
// 使用 clip 替代 hidden,避免 Chrome 144+ 滚动锁定问题
|
||||
overflow-x: clip;
|
||||
overflow-y: auto;
|
||||
overflow: auto;
|
||||
|
||||
.page-content-container {
|
||||
inline-size: 100%;
|
||||
|
||||
@@ -11,12 +11,11 @@ html {
|
||||
}
|
||||
|
||||
body {
|
||||
overflow: visible !important;
|
||||
background: rgb(var(--v-theme-background));
|
||||
overscroll-behavior-y: contain;
|
||||
// Chrome 144+ 兼容性:覆盖 Vuetify 的内联 overflow: hidden 样式
|
||||
overflow: visible !important;
|
||||
|
||||
--webkit-overflow-scrolling: touch;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
body,
|
||||
@@ -37,10 +36,8 @@ body,
|
||||
.layout-page-content {
|
||||
@include mixins.boxed-content(true);
|
||||
|
||||
// Chrome 144+ 兼容性:使用 clip 替代 hidden,避免滚动锁定问题
|
||||
// overflow: hidden 在新版 Chrome 中可能意外阻止垂直滚动
|
||||
overflow: clip;
|
||||
flex-grow: 1;
|
||||
overflow: clip visible;
|
||||
|
||||
// TODO: Use grid gutter variable here;
|
||||
padding-block: 1.5rem;
|
||||
|
||||
11
src/@layouts/types.d.ts
vendored
11
src/@layouts/types.d.ts
vendored
@@ -1,5 +1,6 @@
|
||||
import type { Component, Ref, VNode } from 'vue'
|
||||
import type { RouteLocationRaw } from 'vue-router'
|
||||
import type { UserPermissionKey } from '@/utils/permission'
|
||||
import { ContentWidth, FooterType, NavbarType } from './enums'
|
||||
|
||||
export interface UserConfig {
|
||||
@@ -119,6 +120,14 @@ export interface NavLink extends NavLinkProps, Partial<AclProperties> {
|
||||
badgeContent?: string
|
||||
badgeClass?: string
|
||||
disable?: boolean
|
||||
permission?: UserPermissionKey
|
||||
}
|
||||
|
||||
export interface NavMenuTabItem {
|
||||
title: string
|
||||
icon?: string
|
||||
tab: string
|
||||
description?: string
|
||||
}
|
||||
|
||||
export interface NavMenu extends NavLink {
|
||||
@@ -126,6 +135,8 @@ export interface NavMenu extends NavLink {
|
||||
description?: string
|
||||
admin?: boolean
|
||||
footer?: boolean
|
||||
// 水平三级菜单和页面动态标签页共用的静态标签定义。
|
||||
tabs?: NavMenuTabItem[]
|
||||
}
|
||||
|
||||
// 👉 Vertical nav group
|
||||
|
||||
116
src/App.vue
116
src/App.vue
@@ -1,6 +1,5 @@
|
||||
<script lang="ts" setup>
|
||||
import { useTheme } from 'vuetify'
|
||||
import { checkPrefersColorSchemeIsDark } from '@/@core/utils'
|
||||
import { ensureRenderComplete, removeEl } from './@core/utils/dom'
|
||||
import api from '@/api'
|
||||
import { useAuthStore, useGlobalSettingsStore } from '@/stores'
|
||||
@@ -12,25 +11,32 @@ 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 { applyStoredThemeCustomizerAppearance } from '@/composables/useThemeCustomizer'
|
||||
import { completeLaunchLoading } from '@/composables/useLaunchLoading'
|
||||
import { usePWA } from '@/composables/usePWA'
|
||||
import { themeManager } from '@/utils/themeManager'
|
||||
import { applyDocumentThemeChrome, resolveThemeName } from '@/utils/themePalette'
|
||||
import { configureApexChartsTheme } from '@/utils/apexCharts'
|
||||
|
||||
const LOGIN_WALLPAPER_ROUTE = '/login'
|
||||
|
||||
// 生效主题
|
||||
const { global: globalTheme } = useTheme()
|
||||
const vuetifyTheme = useTheme()
|
||||
const { global: globalTheme } = vuetifyTheme
|
||||
let themeValue = localStorage.getItem('theme') || 'auto'
|
||||
const autoTheme = checkPrefersColorSchemeIsDark() ? 'dark' : 'light'
|
||||
globalTheme.name.value = themeValue === 'auto' ? autoTheme : themeValue
|
||||
globalTheme.name.value = resolveThemeName(themeValue)
|
||||
applyStoredThemeCustomizerAppearance(vuetifyTheme)
|
||||
|
||||
// 启动屏和 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
|
||||
applyDocumentThemeChrome(themeValue, {
|
||||
background,
|
||||
persistLoaderColors: true,
|
||||
primary,
|
||||
resolvedTheme: globalTheme.name.value,
|
||||
})
|
||||
}
|
||||
|
||||
// 生效语言
|
||||
@@ -41,6 +47,7 @@ setI18nLanguage(localeValue as SupportedLocale)
|
||||
const authStore = useAuthStore()
|
||||
const isLogin = computed(() => authStore.token)
|
||||
const route = useRoute()
|
||||
const { initializePWA } = usePWA()
|
||||
|
||||
// 全局设置store
|
||||
const globalSettingsStore = useGlobalSettingsStore()
|
||||
@@ -52,8 +59,9 @@ 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 isLoginWallpaperRoute = computed(() => !isLogin.value && route.path === LOGIN_WALLPAPER_ROUTE)
|
||||
const shouldLoadBackgroundImages = computed(
|
||||
() => (!isLogin.value && route.path === LOGIN_WALLPAPER_ROUTE) || (Boolean(isLogin.value) && isTransparentTheme.value),
|
||||
() => isLoginWallpaperRoute.value || (Boolean(isLogin.value) && isTransparentTheme.value),
|
||||
)
|
||||
let backgroundRetryTimer: number | null = null
|
||||
let backgroundRequestController: AbortController | null = null
|
||||
@@ -81,6 +89,7 @@ applyTransparentBackgroundSettings()
|
||||
|
||||
// 心跳检测
|
||||
let heartbeatInterval: number | null = null
|
||||
let prefersColorSchemeMediaQuery: MediaQueryList | null = null
|
||||
|
||||
// 启动心跳
|
||||
const startHeartbeat = () => {
|
||||
@@ -93,7 +102,7 @@ const startHeartbeat = () => {
|
||||
heartbeatInterval = window.setInterval(async () => {
|
||||
try {
|
||||
if (isLogin.value) {
|
||||
await api.get('dashboard/cpu')
|
||||
await api.get('system/ping')
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Heartbeat request failed:', error)
|
||||
@@ -116,6 +125,45 @@ function updateHtmlThemeAttribute(themeName: string) {
|
||||
syncRootLaunchPalette()
|
||||
}
|
||||
|
||||
function syncThemePreferenceFromStorage() {
|
||||
themeValue = localStorage.getItem('theme') || 'auto'
|
||||
|
||||
const resolvedTheme = resolveThemeName(themeValue)
|
||||
if (globalTheme.name.value !== resolvedTheme) {
|
||||
globalTheme.name.value = resolvedTheme
|
||||
}
|
||||
|
||||
applyStoredThemeCustomizerAppearance(vuetifyTheme)
|
||||
updateHtmlThemeAttribute(resolvedTheme)
|
||||
configureApexChartsTheme(resolvedTheme)
|
||||
|
||||
// 前台恢复时重新跑一次主题管理器,补齐 transparent CSS 和 auto 的实际 DOM 主题。
|
||||
void themeManager
|
||||
.setTheme(themeValue)
|
||||
.then(() => {
|
||||
updateHtmlThemeAttribute(globalTheme.name.value)
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('同步主题管理器失败:', error)
|
||||
})
|
||||
}
|
||||
|
||||
function handleSystemThemeChange() {
|
||||
if ((localStorage.getItem('theme') || 'auto') === 'auto') {
|
||||
syncThemePreferenceFromStorage()
|
||||
}
|
||||
}
|
||||
|
||||
function handleVisibilityThemeSync() {
|
||||
if (document.visibilityState === 'visible') {
|
||||
syncThemePreferenceFromStorage()
|
||||
}
|
||||
}
|
||||
|
||||
function handlePageShowThemeSync() {
|
||||
syncThemePreferenceFromStorage()
|
||||
}
|
||||
|
||||
// 获取背景图片
|
||||
async function fetchBackgroundImages() {
|
||||
try {
|
||||
@@ -201,19 +249,25 @@ function scheduleAuthenticatedStateInitialization() {
|
||||
}
|
||||
|
||||
// 添加logo动画效果并延迟移除加载界面
|
||||
function animateAndRemoveLoader() {
|
||||
async function animateAndRemoveLoader() {
|
||||
const loadingBg = document.querySelector('#loading-bg') as HTMLElement
|
||||
if (loadingBg) {
|
||||
// 只收掉启动内容,背景层保持实色直到节点被移除,避免底部 safe area 先透出页面内容。
|
||||
loadingBg.classList.add('loading-complete')
|
||||
window.setTimeout(() => {
|
||||
removeEl('#loading-bg')
|
||||
await new Promise<void>(resolve => {
|
||||
window.setTimeout(() => {
|
||||
removeEl('#loading-bg')
|
||||
|
||||
// 启动阶段的根节点锁定只在 loader 存在时生效,移除后恢复正常页面与弹窗布局。
|
||||
document.documentElement.removeAttribute('data-launch-loading')
|
||||
document.documentElement.style.removeProperty('overflow')
|
||||
document.body.style.removeProperty('overflow')
|
||||
}, 120)
|
||||
// 启动阶段的根节点锁定只在 loader 存在时生效,移除后恢复正常页面与弹窗布局。
|
||||
document.documentElement.removeAttribute('data-launch-loading')
|
||||
document.documentElement.style.removeProperty('overflow')
|
||||
document.body.style.removeProperty('overflow')
|
||||
completeLaunchLoading()
|
||||
resolve()
|
||||
}, 120)
|
||||
})
|
||||
} else {
|
||||
completeLaunchLoading()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -230,13 +284,15 @@ async function removeLoadingWithStateCheck() {
|
||||
}
|
||||
globalLoadingStateManager.setLoadingState('pwa-state', false)
|
||||
|
||||
// PWA/App 模式会影响布局和底部导航,必须在启动屏退场前稳定下来。
|
||||
await initializePWA()
|
||||
await initializeAuthenticatedState()
|
||||
|
||||
// 等待所有加载完成
|
||||
await globalLoadingStateManager.waitForAllComplete()
|
||||
|
||||
// 移除加载界面
|
||||
animateAndRemoveLoader()
|
||||
await animateAndRemoveLoader()
|
||||
|
||||
// 检查未读消息
|
||||
if (isLogin.value) {
|
||||
@@ -245,7 +301,7 @@ async function removeLoadingWithStateCheck() {
|
||||
} catch (error) {
|
||||
// 即使出错也要移除加载界面
|
||||
globalLoadingStateManager.reset()
|
||||
animateAndRemoveLoader()
|
||||
await animateAndRemoveLoader()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -285,6 +341,8 @@ onMounted(async () => {
|
||||
|
||||
// 初始化主题管理器 - 统一处理主题初始化
|
||||
await themeManager.setTheme(themeValue)
|
||||
applyStoredThemeCustomizerAppearance(vuetifyTheme)
|
||||
updateHtmlThemeAttribute(globalTheme.name.value)
|
||||
|
||||
// 监听主题变化
|
||||
watch(
|
||||
@@ -297,6 +355,12 @@ onMounted(async () => {
|
||||
},
|
||||
)
|
||||
|
||||
prefersColorSchemeMediaQuery = window.matchMedia?.('(prefers-color-scheme: dark)') ?? null
|
||||
prefersColorSchemeMediaQuery?.addEventListener('change', handleSystemThemeChange)
|
||||
document.addEventListener('visibilitychange', handleVisibilityThemeSync)
|
||||
window.addEventListener('pageshow', handlePageShowThemeSync)
|
||||
window.addEventListener('focus', handlePageShowThemeSync)
|
||||
|
||||
// 登录页壁纸仅在未登录登录页需要,避免其他首屏额外发起图片列表请求。
|
||||
watch(
|
||||
shouldLoadBackgroundImages,
|
||||
@@ -344,6 +408,11 @@ onUnmounted(() => {
|
||||
}
|
||||
// 停止心跳
|
||||
stopHeartbeat()
|
||||
prefersColorSchemeMediaQuery?.removeEventListener('change', handleSystemThemeChange)
|
||||
prefersColorSchemeMediaQuery = null
|
||||
document.removeEventListener('visibilitychange', handleVisibilityThemeSync)
|
||||
window.removeEventListener('pageshow', handlePageShowThemeSync)
|
||||
window.removeEventListener('focus', handlePageShowThemeSync)
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -366,7 +435,7 @@ onUnmounted(() => {
|
||||
<div v-if="isLogin && isTransparentTheme" class="global-blur-layer"></div>
|
||||
</div>
|
||||
<!-- 页面内容 -->
|
||||
<VApp>
|
||||
<VApp :class="{ 'app-shell--login-wallpaper': isLoginWallpaperRoute }">
|
||||
<RouterView />
|
||||
<!-- 全局共享弹窗入口,列表与卡片按需在这里挂载业务弹窗。 -->
|
||||
<SharedDialogHost />
|
||||
@@ -436,4 +505,9 @@ onUnmounted(() => {
|
||||
inset-block-start: 0;
|
||||
inset-inline-start: 0;
|
||||
}
|
||||
|
||||
/* 登录页壁纸在 VApp 外层渲染,登录页 VApp 需要透明才能露出壁纸。 */
|
||||
.app-shell--login-wallpaper.v-application {
|
||||
background: transparent !important;
|
||||
}
|
||||
</style>
|
||||
|
||||
158
src/api/types.ts
158
src/api/types.ts
@@ -49,7 +49,7 @@ export interface Subscribe {
|
||||
// 已完成集数(普通订阅 = 已入库集数,洗版订阅 = 起始集前 + [start, total] 范围内 priority==100 命中数)
|
||||
completed_episode?: number
|
||||
// 附加信息
|
||||
note?: string
|
||||
note?: string | number[]
|
||||
// 状态:N-新建 R-订阅中 P-待定 S-暂停
|
||||
state: string
|
||||
// 最后更新时间
|
||||
@@ -656,6 +656,8 @@ export interface Plugin {
|
||||
system_version_message?: string
|
||||
// 主系统版本限定范围
|
||||
system_version?: string
|
||||
// 是否声明支持通过 GitHub Release 资产安装
|
||||
release?: boolean
|
||||
// 是否本地插件
|
||||
is_local?: boolean
|
||||
// 插件仓库地址
|
||||
@@ -668,6 +670,38 @@ export interface Plugin {
|
||||
page_open?: boolean
|
||||
}
|
||||
|
||||
// 插件 Release 可安装版本
|
||||
export interface PluginReleaseVersion {
|
||||
// 插件版本
|
||||
version: string
|
||||
// GitHub Release tag
|
||||
tag_name: string
|
||||
// Release 标题
|
||||
name?: string
|
||||
// 发布时间
|
||||
published_at?: string
|
||||
// Release 说明
|
||||
body?: string
|
||||
// 匹配到的资产文件名
|
||||
asset_name?: string
|
||||
// 是否为当前市场最新版本
|
||||
is_latest?: boolean
|
||||
// 是否为本地已安装版本
|
||||
is_current?: boolean
|
||||
}
|
||||
|
||||
// 插件 Release 可安装版本响应
|
||||
export interface PluginReleaseVersionsResponse {
|
||||
// 当前插件是否存在可直接安装的 Release 资产
|
||||
release_supported: boolean
|
||||
// 当前市场 package 声明的最新版本
|
||||
latest_version?: string | null
|
||||
// 本地已安装版本
|
||||
current_version?: string | null
|
||||
// 可安装版本列表
|
||||
items: PluginReleaseVersion[]
|
||||
}
|
||||
|
||||
// 插件侧栏全页导航项(与后端 PluginSidebarNavItem 对齐)
|
||||
export interface PluginSidebarNavItem {
|
||||
plugin_id: string
|
||||
@@ -702,6 +736,8 @@ export interface DashboardItem {
|
||||
attrs: { [key: string]: any }
|
||||
// col列数
|
||||
cols: { [key: string]: number }
|
||||
// Grid行数
|
||||
rows?: number
|
||||
// 页面元素
|
||||
elements: RenderProps[]
|
||||
// 渲染方式
|
||||
@@ -766,6 +802,58 @@ export interface TorrentInfo {
|
||||
category: string
|
||||
}
|
||||
|
||||
// 字幕信息
|
||||
export interface SubtitleInfo {
|
||||
// 站点ID
|
||||
site?: number
|
||||
// 站点名称
|
||||
site_name?: string
|
||||
// 站点Cookie
|
||||
site_cookie?: string
|
||||
// 站点UA
|
||||
site_ua?: string
|
||||
// 站点是否使用代理
|
||||
site_proxy?: boolean
|
||||
// 站点优先级
|
||||
site_order?: number
|
||||
// 字幕标题
|
||||
title?: string
|
||||
// 字幕描述
|
||||
description?: string
|
||||
// 字幕下载链接
|
||||
enclosure?: string
|
||||
// 详情页面
|
||||
page_url?: string
|
||||
// 语言
|
||||
language?: string
|
||||
// 语言图标
|
||||
language_icon?: string
|
||||
// 字幕大小
|
||||
size?: number
|
||||
// 发布时间
|
||||
pubdate?: string
|
||||
// 已过时间
|
||||
date_elapsed?: string
|
||||
// 点击/下载次数
|
||||
grabs?: number
|
||||
// 上传者
|
||||
uploader?: string
|
||||
// 举报页面
|
||||
report_url?: string
|
||||
// 种子ID
|
||||
torrent_id?: string
|
||||
// 字幕ID
|
||||
subtitle_id?: string
|
||||
// 下载文件名
|
||||
file_name?: string
|
||||
// 识别元数据
|
||||
meta_info?: MetaInfo
|
||||
// SxxExx
|
||||
season_episode?: string
|
||||
// 集列表
|
||||
episode_list?: number[]
|
||||
}
|
||||
|
||||
// 识别元数据
|
||||
export interface MetaInfo {
|
||||
// 是否处理的文件
|
||||
@@ -1025,6 +1113,10 @@ export interface FileItem {
|
||||
export interface MediaServerPlayItem {
|
||||
// ID
|
||||
id?: string | number
|
||||
// 媒体服务器项目ID
|
||||
item_id?: string | number
|
||||
// 媒体服务器ID
|
||||
server_id?: string
|
||||
// 标题
|
||||
title: string
|
||||
// 副标题
|
||||
@@ -1049,6 +1141,10 @@ export interface MediaServerLibrary {
|
||||
server: string
|
||||
// ID
|
||||
id?: string | number
|
||||
// 媒体服务器项目ID
|
||||
item_id?: string | number
|
||||
// 媒体服务器ID
|
||||
server_id?: string
|
||||
// 名称
|
||||
name: string
|
||||
// 路径
|
||||
@@ -1069,6 +1165,12 @@ export interface MediaServerLibrary {
|
||||
|
||||
// 消息通知
|
||||
export interface Message {
|
||||
// 消息ID
|
||||
id?: number
|
||||
// 消息渠道
|
||||
channel?: string
|
||||
// 消息来源
|
||||
source?: string
|
||||
// 消息类型
|
||||
mtype?: string
|
||||
// 消息标题
|
||||
@@ -1088,19 +1190,15 @@ export interface Message {
|
||||
// 消息方向:0-接收,1-发送
|
||||
action?: number
|
||||
// JSON
|
||||
note?: string
|
||||
note?: string | any[] | Record<string, any>
|
||||
}
|
||||
|
||||
// 系统通知
|
||||
export interface SystemNotification {
|
||||
// 通知类型 user/system/plugin
|
||||
type: string
|
||||
// 通知标题
|
||||
title: string
|
||||
// 通知内容
|
||||
text: string
|
||||
export interface SystemNotification extends Message {
|
||||
// 通知类型 user/system/plugin/notification
|
||||
type?: string
|
||||
// 通知时间
|
||||
date: string
|
||||
date?: string
|
||||
// 是否已读
|
||||
read?: boolean
|
||||
}
|
||||
@@ -1290,9 +1388,9 @@ export interface TransferForm {
|
||||
// 历史ID
|
||||
logid: number
|
||||
// 目标存储
|
||||
target_storage: string
|
||||
target_storage: string | null
|
||||
// 目标路径
|
||||
target_path: string
|
||||
target_path: string | null
|
||||
// TMDB ID
|
||||
tmdbid?: number
|
||||
// 豆瓣 ID
|
||||
@@ -1302,7 +1400,7 @@ export interface TransferForm {
|
||||
// 类型
|
||||
type_name?: string
|
||||
// 整理方式
|
||||
transfer_type: string
|
||||
transfer_type: string | null
|
||||
// 自定义格式
|
||||
episode_format?: string
|
||||
// 指定集数
|
||||
@@ -1314,13 +1412,13 @@ export interface TransferForm {
|
||||
// 最小文件大小
|
||||
min_filesize: number
|
||||
// 刮削
|
||||
scrape: boolean
|
||||
scrape: boolean | null
|
||||
// 复用历史识别信息
|
||||
from_history: boolean
|
||||
// 媒体库类型子目录
|
||||
library_type_folder?: boolean
|
||||
library_type_folder?: boolean | null
|
||||
// 媒体库类别子目录
|
||||
library_category_folder?: boolean
|
||||
library_category_folder?: boolean | null
|
||||
// 剧集组编号
|
||||
episode_group?: string | null
|
||||
// 预览模式
|
||||
@@ -1335,6 +1433,22 @@ export interface ManualTransferPayload extends Omit<TransferForm, 'fileitem'> {
|
||||
fileitems?: FileItem[]
|
||||
}
|
||||
|
||||
// 手动整理目的路径匹配结果
|
||||
export interface ManualTransferTargetPathData {
|
||||
// 目标存储
|
||||
target_storage?: string | null
|
||||
// 目标路径
|
||||
target_path?: string | null
|
||||
// 整理方式
|
||||
transfer_type?: string | null
|
||||
// 刮削
|
||||
scrape?: boolean | null
|
||||
// 媒体库类型子目录
|
||||
library_type_folder?: boolean | null
|
||||
// 媒体库类别子目录
|
||||
library_category_folder?: boolean | null
|
||||
}
|
||||
|
||||
// 手动整理预览统计
|
||||
export interface ManualTransferPreviewSummary {
|
||||
// 总数
|
||||
@@ -1369,6 +1483,14 @@ export interface ManualTransferPreviewItem {
|
||||
episode_end?: number | string
|
||||
// Part
|
||||
part?: string
|
||||
// 原始识别字符串
|
||||
org_string?: string
|
||||
// 应用的自定义识别词
|
||||
apply_words?: string[]
|
||||
// 制作组/字幕组
|
||||
resource_team?: string
|
||||
// 自定义占位符
|
||||
customization?: string
|
||||
}
|
||||
|
||||
// 手动整理预览数据
|
||||
@@ -1457,6 +1579,10 @@ export interface Workflow {
|
||||
actions?: any[]
|
||||
// 动作流
|
||||
flows?: any[]
|
||||
// 工作流执行配置
|
||||
execution_config?: { [key: string]: any }
|
||||
// 工作流结构化执行状态
|
||||
execution_state?: { [key: string]: any }
|
||||
// 创建时间
|
||||
add_time?: string
|
||||
// 最后执行时间
|
||||
|
||||
2460
src/components/AgentAssistantWidget.vue
Normal file
2460
src/components/AgentAssistantWidget.vue
Normal file
File diff suppressed because it is too large
Load Diff
@@ -7,6 +7,8 @@ import { storageIconDict } from '@/api/constants'
|
||||
import type { AxiosInstance } from 'axios'
|
||||
import { useDynamicButton } from '@/composables/useDynamicButton'
|
||||
import { usePWA } from '@/composables/usePWA'
|
||||
import { useUserStore } from '@/stores'
|
||||
import { buildUserPermissionContext, hasPermission } from '@/utils/permission'
|
||||
|
||||
// LocalStorage keys
|
||||
const SORT_KEY = 'fileBrowser.sort'
|
||||
@@ -41,6 +43,10 @@ const props = defineProps({
|
||||
const emit = defineEmits(['pathchanged'])
|
||||
const route = useRoute()
|
||||
const { appMode } = usePWA()
|
||||
const userStore = useUserStore()
|
||||
const canManage = computed(() =>
|
||||
hasPermission(buildUserPermissionContext(userStore.superUser, userStore.permissions), 'manage'),
|
||||
)
|
||||
const toolbarRef = ref<InstanceType<typeof FileToolbar> | null>(null)
|
||||
|
||||
const fileIcons = {
|
||||
@@ -136,11 +142,12 @@ function openNewFolderDialog() {
|
||||
toolbarRef.value?.openNewFolderDialog()
|
||||
}
|
||||
|
||||
const showFloatingNewFolderAction = computed(() => route.path === '/filemanager')
|
||||
const showFloatingNewFolderAction = computed(() => route.path === '/filemanager' && canManage.value)
|
||||
|
||||
useDynamicButton({
|
||||
icon: 'mdi-folder-plus-outline',
|
||||
onClick: openNewFolderDialog,
|
||||
permission: 'manage',
|
||||
show: computed(() => appMode.value && showFloatingNewFolderAction.value),
|
||||
})
|
||||
|
||||
@@ -161,15 +168,15 @@ const isDragging = ref(false)
|
||||
const dragStartX = ref(0)
|
||||
const dragStartWidth = ref(0)
|
||||
|
||||
watch(sort, (val) => {
|
||||
watch(sort, val => {
|
||||
localStorage.setItem(SORT_KEY, val)
|
||||
})
|
||||
|
||||
watch(showDirTree, (val) => {
|
||||
watch(showDirTree, val => {
|
||||
localStorage.setItem(SHOW_TREE_KEY, String(val))
|
||||
})
|
||||
|
||||
watch(navigatorWidth, (val) => {
|
||||
watch(navigatorWidth, val => {
|
||||
localStorage.setItem(NAV_WIDTH_KEY, String(val))
|
||||
})
|
||||
|
||||
@@ -182,7 +189,6 @@ const storagesArray = computed(() => {
|
||||
}))
|
||||
})
|
||||
|
||||
|
||||
// 方法
|
||||
function loadingChanged(isLoading: number) {
|
||||
if (isLoading) loading.value++
|
||||
@@ -272,7 +278,7 @@ function stopDrag() {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="mx-auto" :loading="loading > 0">
|
||||
<div class="mx-auto overflow-hidden" :loading="loading > 0">
|
||||
<div v-if="item">
|
||||
<FileToolbar
|
||||
ref="toolbarRef"
|
||||
@@ -347,7 +353,7 @@ function stopDrag() {
|
||||
justify-content: center;
|
||||
background-color: transparent;
|
||||
cursor: col-resize;
|
||||
inline-size: 4px;
|
||||
inline-size: 1px;
|
||||
transition: background-color 0.2s ease;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
@@ -163,9 +163,9 @@ const instructions = computed(() => {
|
||||
</VAlert>
|
||||
</VCardText>
|
||||
|
||||
<VCardActions>
|
||||
<VCardActions class="app-dialog-actions">
|
||||
<VSpacer />
|
||||
<VBtn color="primary" variant="text" @click="showInstructions = false">
|
||||
<VBtn color="primary" variant="flat" class="px-5" @click="showInstructions = false">
|
||||
{{ t('pwa.gotIt') }}
|
||||
</VBtn>
|
||||
</VCardActions>
|
||||
@@ -177,10 +177,7 @@ const instructions = computed(() => {
|
||||
.pwa-install-banner {
|
||||
position: fixed;
|
||||
z-index: 1000;
|
||||
border: 1px solid rgba(var(--v-border-color), var(--v-border-opacity));
|
||||
border-radius: 12px;
|
||||
background: rgb(var(--v-theme-surface));
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 10%);
|
||||
inset-block-end: 5rem;
|
||||
inset-inline: 20px;
|
||||
}
|
||||
|
||||
904
src/components/ThemeCustomizer.vue
Normal file
904
src/components/ThemeCustomizer.vue
Normal file
@@ -0,0 +1,904 @@
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
themeCustomizerPrimaryColors,
|
||||
useThemeCustomizer,
|
||||
type ThemeCustomizerLayout,
|
||||
type ThemeCustomizerRadius,
|
||||
type ThemeCustomizerShadow,
|
||||
type ThemeCustomizerSkin,
|
||||
type ThemeCustomizerTheme,
|
||||
} from '@/composables/useThemeCustomizer'
|
||||
import { usePWA } from '@/composables/usePWA'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useTheme } from 'vuetify'
|
||||
|
||||
const emit = defineEmits<{
|
||||
'close': []
|
||||
}>()
|
||||
|
||||
const customColorInput = ref<HTMLInputElement | null>(null)
|
||||
|
||||
const {
|
||||
isCustomized,
|
||||
resetSettings,
|
||||
setLayout,
|
||||
setPrimaryColor,
|
||||
setRadius,
|
||||
setSemiDarkMenu,
|
||||
setShadow,
|
||||
setSkin,
|
||||
setTheme,
|
||||
settings,
|
||||
} = useThemeCustomizer()
|
||||
const { appMode } = usePWA()
|
||||
const { t } = useI18n()
|
||||
const { global: globalTheme } = useTheme()
|
||||
const defaultPrimaryColor = themeCustomizerPrimaryColors[0].value
|
||||
|
||||
// 将主题定制器打开状态同步到根节点,供全局悬浮按钮避让右侧面板。
|
||||
function syncThemeCustomizerOpenState(isOpen: boolean) {
|
||||
if (typeof document === 'undefined') return
|
||||
|
||||
if (isOpen) {
|
||||
document.documentElement.setAttribute('data-theme-customizer-open', 'true')
|
||||
document.body.setAttribute('data-theme-customizer-open', 'true')
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
document.documentElement.removeAttribute('data-theme-customizer-open')
|
||||
document.body.removeAttribute('data-theme-customizer-open')
|
||||
}
|
||||
|
||||
// 组件卸载时清理根节点状态,避免路由切换后悬浮按钮继续保持让位。
|
||||
function clearThemeCustomizerOpenState() {
|
||||
syncThemeCustomizerOpenState(false)
|
||||
}
|
||||
|
||||
function handleGlobalKeydown(event: KeyboardEvent) {
|
||||
// 固定侧栏不再依赖 Vuetify overlay,手动补上常见的 Esc 关闭行为。
|
||||
if (event.key === 'Escape') emit('close')
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
// 面板一挂载就代表已打开,及时同步根节点状态让全局 FAB 预留右侧空间。
|
||||
syncThemeCustomizerOpenState(true)
|
||||
window.addEventListener('keydown', handleGlobalKeydown)
|
||||
})
|
||||
|
||||
onScopeDispose(clearThemeCustomizerOpenState)
|
||||
onScopeDispose(() => {
|
||||
if (typeof window === 'undefined') return
|
||||
|
||||
window.removeEventListener('keydown', handleGlobalKeydown)
|
||||
})
|
||||
|
||||
const themeOptions = computed<Array<{ icon: string; title: string; value: ThemeCustomizerTheme }>>(() => [
|
||||
{ title: t('theme.light'), value: 'light', icon: 'mdi-white-balance-sunny' },
|
||||
{ title: t('theme.dark'), value: 'dark', icon: 'mdi-weather-night' },
|
||||
{ title: t('theme.auto'), value: 'auto', icon: 'mdi-monitor' },
|
||||
{ title: t('theme.purple'), value: 'purple', icon: 'mdi-theme-light-dark' },
|
||||
{ title: t('theme.transparent'), value: 'transparent', icon: 'mdi-blur' },
|
||||
])
|
||||
|
||||
const skinOptions = computed<Array<{ title: string; value: ThemeCustomizerSkin }>>(() => [
|
||||
{ title: t('theme.customizer.skinDefault'), value: 'default' },
|
||||
{ title: t('theme.customizer.skinBordered'), value: 'bordered' },
|
||||
])
|
||||
|
||||
const shadowOptions = computed<
|
||||
Array<{
|
||||
title: string
|
||||
value: ThemeCustomizerShadow
|
||||
}>
|
||||
>(() => [
|
||||
{
|
||||
title: t('theme.customizer.shadowNone'),
|
||||
value: 'none',
|
||||
},
|
||||
{
|
||||
title: t('theme.customizer.shadowLow'),
|
||||
value: 'low',
|
||||
},
|
||||
{
|
||||
title: t('theme.customizer.shadowMedium'),
|
||||
value: 'medium',
|
||||
},
|
||||
{
|
||||
title: t('theme.customizer.shadowHigh'),
|
||||
value: 'high',
|
||||
},
|
||||
])
|
||||
|
||||
const radiusOptions = computed<
|
||||
Array<{
|
||||
previewRadius: string
|
||||
title: string
|
||||
value: ThemeCustomizerRadius
|
||||
}>
|
||||
>(() => [
|
||||
{
|
||||
previewRadius: '4px',
|
||||
title: t('theme.customizer.radiusSmall'),
|
||||
value: 'small',
|
||||
},
|
||||
{
|
||||
previewRadius: '8px',
|
||||
title: t('theme.customizer.radiusDefault'),
|
||||
value: 'default',
|
||||
},
|
||||
{
|
||||
previewRadius: '12px',
|
||||
title: t('theme.customizer.radiusLarge'),
|
||||
value: 'large',
|
||||
},
|
||||
{
|
||||
previewRadius: '16px',
|
||||
title: t('theme.customizer.radiusExtra'),
|
||||
value: 'extra',
|
||||
},
|
||||
{
|
||||
previewRadius: '24px',
|
||||
title: t('theme.customizer.radiusHuge'),
|
||||
value: 'huge',
|
||||
},
|
||||
])
|
||||
|
||||
const layoutOptions = computed<Array<{ icon: string; title: string; value: ThemeCustomizerLayout }>>(() => [
|
||||
{ title: t('theme.customizer.layoutVertical'), value: 'vertical', icon: 'mdi-dock-left' },
|
||||
{ title: t('theme.customizer.layoutCollapsed'), value: 'collapsed', icon: 'mdi-dock-window' },
|
||||
{ title: t('theme.customizer.layoutHorizontal'), value: 'horizontal', icon: 'mdi-dock-top' },
|
||||
])
|
||||
|
||||
const showLayoutSection = computed(() => !appMode.value)
|
||||
|
||||
const hasAppModeCustomization = computed(() => {
|
||||
return (
|
||||
settings.value.primaryColor !== defaultPrimaryColor ||
|
||||
settings.value.radius !== 'default' ||
|
||||
settings.value.shadow !== 'none' ||
|
||||
settings.value.skin !== 'default' ||
|
||||
settings.value.theme !== 'auto'
|
||||
)
|
||||
})
|
||||
|
||||
const showResetBadge = computed(() => (appMode.value ? hasAppModeCustomization.value : isCustomized.value))
|
||||
|
||||
const showSemiDarkMenuOption = computed(() => {
|
||||
return (
|
||||
!appMode.value &&
|
||||
!globalTheme.current.value.dark &&
|
||||
(settings.value.layout === 'vertical' || settings.value.layout === 'collapsed')
|
||||
)
|
||||
})
|
||||
|
||||
function openColorPicker() {
|
||||
customColorInput.value?.click()
|
||||
}
|
||||
|
||||
function handleCustomColorInput(event: Event) {
|
||||
const color = (event.target as HTMLInputElement).value
|
||||
|
||||
setPrimaryColor(color)
|
||||
}
|
||||
|
||||
function handleLayoutChange(layout: ThemeCustomizerLayout) {
|
||||
// App 模式固定使用移动端导航,避免切换桌面布局后破坏底部导航体验。
|
||||
if (appMode.value) return
|
||||
|
||||
setLayout(layout)
|
||||
}
|
||||
|
||||
async function handleResetSettings() {
|
||||
if (!appMode.value) {
|
||||
await resetSettings()
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// App 模式共享定制器,但保留桌面导航相关偏好,只重置 App 侧可调整的外观设置。
|
||||
await setPrimaryColor(defaultPrimaryColor)
|
||||
await setRadius('default')
|
||||
await setShadow('none')
|
||||
await setSkin('default')
|
||||
await setTheme('auto')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<aside
|
||||
class="theme-customizer-panel-host"
|
||||
role="dialog"
|
||||
:aria-label="t('theme.customizer.title')"
|
||||
>
|
||||
<div class="theme-customizer-panel" :class="{ 'theme-customizer-panel--dialog': appMode, 'app-surface': appMode }">
|
||||
<div class="theme-customizer-header py-5 px-4">
|
||||
<div>
|
||||
<h2 class="theme-customizer-title">{{ t('theme.customizer.title') }}</h2>
|
||||
</div>
|
||||
<div class="theme-customizer-header-actions">
|
||||
<VBadge color="error" dot :model-value="showResetBadge" location="top end" offset-x="2" offset-y="2">
|
||||
<IconBtn :aria-label="t('theme.customizer.reset')" @click="handleResetSettings">
|
||||
<VIcon class="text-high-emphasis" icon="mdi-refresh" />
|
||||
</IconBtn>
|
||||
</VBadge>
|
||||
<IconBtn :aria-label="t('common.close')" @click="emit('close')">
|
||||
<VIcon class="text-high-emphasis" icon="mdi-close" />
|
||||
</IconBtn>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<VDivider />
|
||||
|
||||
<PerfectScrollbar class="theme-customizer-body" :options="{ wheelPropagation: false }">
|
||||
<section class="theme-customizer-section">
|
||||
<h3 class="theme-customizer-section-title">{{ t('theme.customizer.primaryColor') }}</h3>
|
||||
<div class="theme-customizer-color-grid">
|
||||
<div
|
||||
v-for="color in themeCustomizerPrimaryColors"
|
||||
:key="color.value"
|
||||
class="theme-customizer-color-option"
|
||||
:class="{ 'is-active': settings.primaryColor === color.value }"
|
||||
:aria-label="t('theme.customizer.usePrimaryColor', { color: color.name })"
|
||||
@click="setPrimaryColor(color.value)"
|
||||
>
|
||||
<span class="theme-customizer-color-swatch" :style="{ backgroundColor: color.value }" />
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="!appMode"
|
||||
class="theme-customizer-color-option theme-customizer-color-option--picker"
|
||||
:class="{
|
||||
'is-active': !themeCustomizerPrimaryColors.some(color => color.value === settings.primaryColor),
|
||||
}"
|
||||
:aria-label="t('theme.customizer.chooseCustomColor')"
|
||||
@click="openColorPicker"
|
||||
>
|
||||
<VIcon class="theme-customizer-native-icon" icon="mdi-palette-outline" size="30" />
|
||||
<input
|
||||
ref="customColorInput"
|
||||
class="theme-customizer-native-color"
|
||||
type="color"
|
||||
:value="settings.primaryColor"
|
||||
@input="handleCustomColorInput"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h3 class="theme-customizer-section-title">{{ t('common.theme') }}</h3>
|
||||
<div class="theme-customizer-option-grid theme-customizer-option-grid--theme">
|
||||
<div
|
||||
v-for="theme in themeOptions"
|
||||
:key="theme.value"
|
||||
class="theme-customizer-card-option"
|
||||
:class="{ 'is-active': settings.theme === theme.value }"
|
||||
@click="setTheme(theme.value)"
|
||||
>
|
||||
<VIcon class="theme-customizer-theme-icon" :icon="theme.icon" size="36" />
|
||||
<span>{{ theme.title }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<VDivider class="mt-7" />
|
||||
|
||||
<h3 class="theme-customizer-section-title">{{ t('theme.customizer.skins') }}</h3>
|
||||
<div class="theme-customizer-preview-grid theme-customizer-preview-grid--skins">
|
||||
<div
|
||||
v-for="skin in skinOptions"
|
||||
:key="skin.value"
|
||||
class="theme-customizer-preview-option"
|
||||
:class="{ 'is-active': settings.skin === skin.value }"
|
||||
@click="setSkin(skin.value)"
|
||||
>
|
||||
<span class="theme-customizer-mini-layout" :class="`theme-customizer-mini-layout--${skin.value}`">
|
||||
<span class="mini-sidebar">
|
||||
<i />
|
||||
<i />
|
||||
<i />
|
||||
<i />
|
||||
</span>
|
||||
<span class="mini-content">
|
||||
<i />
|
||||
<i />
|
||||
<i />
|
||||
</span>
|
||||
</span>
|
||||
<span>{{ skin.title }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<VDivider class="mt-7" />
|
||||
|
||||
<h3 class="theme-customizer-section-title">{{ t('theme.customizer.radius') }}</h3>
|
||||
<div class="theme-customizer-preview-grid theme-customizer-preview-grid--radius">
|
||||
<div
|
||||
v-for="radius in radiusOptions"
|
||||
:key="radius.value"
|
||||
class="theme-customizer-preview-option"
|
||||
:class="{ 'is-active': settings.radius === radius.value }"
|
||||
@click="setRadius(radius.value)"
|
||||
>
|
||||
<span
|
||||
class="theme-customizer-radius-scene"
|
||||
:style="{ '--theme-customizer-radius-preview': radius.previewRadius }"
|
||||
>
|
||||
<span class="theme-customizer-radius-scene__card">
|
||||
<span class="theme-customizer-radius-scene__badge" />
|
||||
<span class="theme-customizer-radius-scene__line" />
|
||||
<span class="theme-customizer-radius-scene__line theme-customizer-radius-scene__line--short" />
|
||||
</span>
|
||||
</span>
|
||||
<span>{{ radius.title }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<VDivider class="mt-7" />
|
||||
|
||||
<h3 class="theme-customizer-section-title">{{ t('theme.customizer.shadow') }}</h3>
|
||||
<div class="theme-customizer-preview-grid theme-customizer-preview-grid--shadow">
|
||||
<div
|
||||
v-for="shadow in shadowOptions"
|
||||
:key="shadow.value"
|
||||
class="theme-customizer-preview-option"
|
||||
:class="{ 'is-active': settings.shadow === shadow.value }"
|
||||
@click="setShadow(shadow.value)"
|
||||
>
|
||||
<span class="theme-customizer-shadow-scene" :class="`theme-customizer-shadow-scene--${shadow.value}`">
|
||||
<span class="theme-customizer-shadow-scene__panel">
|
||||
<span class="theme-customizer-shadow-scene__panel-line" />
|
||||
<span
|
||||
class="theme-customizer-shadow-scene__panel-line theme-customizer-shadow-scene__panel-line--short"
|
||||
/>
|
||||
</span>
|
||||
|
||||
<span class="theme-customizer-shadow-scene__card">
|
||||
<span class="theme-customizer-shadow-scene__badge" />
|
||||
<span class="theme-customizer-shadow-scene__line theme-customizer-shadow-scene__line--short" />
|
||||
<span class="theme-customizer-shadow-scene__line" />
|
||||
</span>
|
||||
</span>
|
||||
<span>{{ shadow.title }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="showSemiDarkMenuOption" class="theme-customizer-semi-dark">
|
||||
<span>{{ t('theme.customizer.semiDarkMenu') }}</span>
|
||||
<VSwitch
|
||||
:model-value="settings.semiDarkMenu"
|
||||
color="primary"
|
||||
inset
|
||||
hide-details
|
||||
@update:model-value="setSemiDarkMenu(Boolean($event))"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<VDivider v-if="showLayoutSection" />
|
||||
|
||||
<section v-if="showLayoutSection" class="theme-customizer-section">
|
||||
<h3 class="theme-customizer-section-title">{{ t('theme.customizer.layout') }}</h3>
|
||||
<div class="theme-customizer-preview-grid">
|
||||
<div
|
||||
v-for="layout in layoutOptions"
|
||||
:key="layout.value"
|
||||
class="theme-customizer-preview-option"
|
||||
:class="{ 'is-active': settings.layout === layout.value, 'is-disabled': appMode }"
|
||||
@click="handleLayoutChange(layout.value)"
|
||||
>
|
||||
<span class="theme-customizer-mini-layout" :class="`theme-customizer-mini-layout--${layout.value}`">
|
||||
<span class="mini-sidebar">
|
||||
<i />
|
||||
<i />
|
||||
<i />
|
||||
</span>
|
||||
<span class="mini-content">
|
||||
<i />
|
||||
<i />
|
||||
<i />
|
||||
</span>
|
||||
</span>
|
||||
<span>{{ layout.title }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</PerfectScrollbar>
|
||||
</div>
|
||||
</aside>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
/* stylelint-disable no-descending-specificity */
|
||||
|
||||
.theme-customizer-panel-host {
|
||||
position: fixed !important;
|
||||
z-index: 2102 !important;
|
||||
display: flex;
|
||||
overflow: hidden;
|
||||
border-radius: 0;
|
||||
background: rgb(var(--v-theme-surface));
|
||||
|
||||
/* 背景层保持完整视口高度,避免 iOS 键盘触发 visual viewport resize 后露出底层页面。 */
|
||||
block-size: 100vh !important;
|
||||
border-inline-start: 1px solid rgba(var(--v-theme-on-surface), 0.08) !important;
|
||||
box-shadow: var(--app-surface-shadow) !important;
|
||||
inline-size: 420px !important;
|
||||
inset-block-start: 0 !important;
|
||||
inset-inline-end: 0 !important;
|
||||
max-block-size: none !important;
|
||||
min-block-size: 100vh !important;
|
||||
}
|
||||
|
||||
@supports (block-size: 100lvh) {
|
||||
.theme-customizer-panel-host {
|
||||
block-size: 100lvh !important;
|
||||
min-block-size: 100lvh !important;
|
||||
}
|
||||
}
|
||||
|
||||
.theme-customizer-panel {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
block-size: 100%;
|
||||
inline-size: 100%;
|
||||
min-block-size: 0;
|
||||
}
|
||||
|
||||
.theme-customizer-panel--dialog {
|
||||
overflow: hidden;
|
||||
block-size: 100%;
|
||||
max-block-size: 100%;
|
||||
|
||||
/* 独立 App 模式会贴近 viewport-fit=cover 顶部,面板内部需要避开 iOS 状态栏。 */
|
||||
padding-block-start: env(safe-area-inset-top, 0px);
|
||||
}
|
||||
|
||||
.theme-customizer-panel--dialog .theme-customizer-body {
|
||||
block-size: auto;
|
||||
padding-block-end: env(safe-area-inset-bottom, 0px);
|
||||
}
|
||||
|
||||
@media (width <= 600px) {
|
||||
.theme-customizer-panel-host {
|
||||
inline-size: 100vw !important;
|
||||
}
|
||||
}
|
||||
|
||||
.theme-customizer-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.theme-customizer-title {
|
||||
margin: 0;
|
||||
color: rgba(var(--v-theme-on-surface), var(--v-high-emphasis-opacity));
|
||||
font-size: 1.45rem;
|
||||
font-weight: 600;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.theme-customizer-header-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.theme-customizer-body {
|
||||
flex: 1 1 auto;
|
||||
min-block-size: 0;
|
||||
-ms-overflow-style: none;
|
||||
overscroll-behavior: contain;
|
||||
scrollbar-width: none;
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
:deep(.ps__rail-x),
|
||||
:deep(.ps__rail-y) {
|
||||
display: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
.theme-customizer-section {
|
||||
padding-block-end: 28px;
|
||||
padding-inline: 32px;
|
||||
}
|
||||
|
||||
.theme-customizer-section-title {
|
||||
color: rgba(var(--v-theme-on-surface), var(--v-high-emphasis-opacity));
|
||||
font-size: 1.1rem;
|
||||
font-weight: 600;
|
||||
line-height: 1.25;
|
||||
margin-block: 28px 16px;
|
||||
}
|
||||
|
||||
.theme-customizer-section-note {
|
||||
color: rgba(var(--v-theme-on-surface), var(--v-medium-emphasis-opacity));
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.45;
|
||||
margin-block: -6px 16px;
|
||||
}
|
||||
|
||||
.theme-customizer-color-grid {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
grid-template-columns: repeat(auto-fill, 48px);
|
||||
}
|
||||
|
||||
.theme-customizer-color-option {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 1px solid rgba(var(--v-theme-on-surface), 0.12);
|
||||
border-radius: 10px;
|
||||
appearance: none;
|
||||
block-size: 48px;
|
||||
color: rgba(var(--v-theme-on-surface), var(--v-high-emphasis-opacity));
|
||||
cursor: pointer;
|
||||
inline-size: 48px;
|
||||
transition:
|
||||
border-color 0.18s ease,
|
||||
background-color 0.18s ease,
|
||||
box-shadow 0.18s ease;
|
||||
|
||||
&.is-active {
|
||||
border-width: 2px;
|
||||
border-color: rgb(var(--v-theme-primary));
|
||||
box-shadow: 0 0 0 3px rgba(var(--v-theme-primary), 0.12);
|
||||
}
|
||||
}
|
||||
|
||||
.theme-customizer-color-swatch {
|
||||
display: block;
|
||||
border-radius: 8px;
|
||||
block-size: 30px;
|
||||
inline-size: 30px;
|
||||
}
|
||||
|
||||
.theme-customizer-color-option--picker {
|
||||
background: rgba(var(--v-theme-on-surface), 0.04);
|
||||
}
|
||||
|
||||
.theme-customizer-native-color {
|
||||
position: absolute;
|
||||
block-size: 1px;
|
||||
inline-size: 1px;
|
||||
inset-block: 50% auto;
|
||||
inset-inline: 50% auto;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.theme-customizer-option-grid {
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.theme-customizer-option-grid--theme {
|
||||
grid-template-columns: repeat(auto-fit, minmax(96px, 1fr));
|
||||
}
|
||||
|
||||
.theme-customizer-card-option,
|
||||
.theme-customizer-preview-option {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
border: 1px solid rgba(var(--v-theme-on-surface), 0.12);
|
||||
border-radius: 10px;
|
||||
appearance: none;
|
||||
color: rgba(var(--v-theme-on-surface), var(--v-high-emphasis-opacity));
|
||||
cursor: pointer;
|
||||
font-size: 1rem;
|
||||
gap: 10px;
|
||||
transition:
|
||||
border-color 0.18s ease,
|
||||
background-color 0.18s ease,
|
||||
color 0.18s ease,
|
||||
box-shadow 0.18s ease;
|
||||
|
||||
&.is-active {
|
||||
border-width: 2px;
|
||||
border-color: rgb(var(--v-theme-primary));
|
||||
background: rgba(var(--v-theme-primary), 0.08);
|
||||
box-shadow: 0 0 0 3px rgba(var(--v-theme-primary), 0.12);
|
||||
color: rgb(var(--v-theme-primary));
|
||||
}
|
||||
}
|
||||
|
||||
.theme-customizer-card-option {
|
||||
justify-content: center;
|
||||
padding: 16px;
|
||||
min-block-size: 112px;
|
||||
}
|
||||
|
||||
.theme-customizer-preview-grid {
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.theme-customizer-preview-grid--skins {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.theme-customizer-preview-grid--shadow {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.theme-customizer-preview-grid--radius {
|
||||
grid-template-columns: repeat(auto-fit, minmax(92px, 1fr));
|
||||
}
|
||||
|
||||
.theme-customizer-preview-option {
|
||||
align-items: flex-start;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
box-shadow: none !important;
|
||||
|
||||
&.is-active {
|
||||
background: transparent;
|
||||
box-shadow: none !important;
|
||||
|
||||
.theme-customizer-mini-layout,
|
||||
.theme-customizer-radius-scene,
|
||||
.theme-customizer-shadow-scene {
|
||||
border-width: 2px;
|
||||
border-color: rgb(var(--v-theme-primary));
|
||||
background: rgba(var(--v-theme-primary), 0.04);
|
||||
}
|
||||
}
|
||||
|
||||
> span:last-child {
|
||||
color: rgba(var(--v-theme-on-surface), var(--v-medium-emphasis-opacity));
|
||||
padding-inline-start: 2px;
|
||||
}
|
||||
|
||||
&.is-disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.52;
|
||||
}
|
||||
}
|
||||
|
||||
.theme-customizer-semi-dark {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
color: rgba(var(--v-theme-on-surface), var(--v-high-emphasis-opacity));
|
||||
font-size: 1.1rem;
|
||||
font-weight: 600;
|
||||
margin-block-start: 28px;
|
||||
margin-inline: -32px;
|
||||
padding-inline: 32px;
|
||||
}
|
||||
|
||||
.theme-customizer-mini-layout {
|
||||
display: grid;
|
||||
overflow: hidden;
|
||||
border: 1px solid rgba(var(--v-theme-on-surface), 0.12);
|
||||
border-radius: 10px;
|
||||
block-size: 74px;
|
||||
grid-template-columns: 34% 1fr;
|
||||
inline-size: 100%;
|
||||
min-inline-size: 92px;
|
||||
}
|
||||
|
||||
.theme-customizer-mini-layout--collapsed {
|
||||
grid-template-columns: 18% 1fr;
|
||||
}
|
||||
|
||||
.theme-customizer-mini-layout--horizontal {
|
||||
grid-template-columns: 1fr;
|
||||
grid-template-rows: 24% 1fr;
|
||||
|
||||
.mini-sidebar {
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
}
|
||||
}
|
||||
|
||||
.mini-sidebar,
|
||||
.mini-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 10px;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.mini-sidebar {
|
||||
background: rgba(var(--v-theme-on-surface), 0.04);
|
||||
}
|
||||
|
||||
.mini-sidebar i,
|
||||
.mini-content i {
|
||||
display: block;
|
||||
border-radius: 4px;
|
||||
background: rgba(var(--v-theme-on-surface), 0.12);
|
||||
block-size: 6px;
|
||||
}
|
||||
|
||||
.mini-content i {
|
||||
background: rgba(var(--v-theme-on-surface), 0.06);
|
||||
block-size: 18px;
|
||||
}
|
||||
|
||||
.theme-customizer-mini-layout--bordered {
|
||||
.mini-content i,
|
||||
.mini-sidebar i {
|
||||
border: 1px solid rgba(var(--v-theme-on-surface), 0.12);
|
||||
background: transparent;
|
||||
}
|
||||
}
|
||||
|
||||
.theme-customizer-radius-scene {
|
||||
position: relative;
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
border: 1px solid rgba(var(--v-theme-on-surface), 0.12);
|
||||
border-radius: 10px;
|
||||
background:
|
||||
linear-gradient(180deg, rgba(var(--v-theme-on-surface), 0.02), rgba(var(--v-theme-on-surface), 0.05)),
|
||||
rgb(var(--v-theme-surface));
|
||||
block-size: 90px;
|
||||
inline-size: 100%;
|
||||
min-inline-size: 0;
|
||||
}
|
||||
|
||||
.theme-customizer-radius-scene__card {
|
||||
position: absolute;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border: 1px solid rgba(var(--v-theme-on-surface), 0.08);
|
||||
border-radius: var(--theme-customizer-radius-preview);
|
||||
background: rgb(var(--v-theme-surface));
|
||||
gap: 8px;
|
||||
inset: 16px;
|
||||
padding-block: 12px;
|
||||
padding-inline: 14px;
|
||||
}
|
||||
|
||||
.theme-customizer-radius-scene__badge,
|
||||
.theme-customizer-radius-scene__line {
|
||||
display: block;
|
||||
border-radius: 999px;
|
||||
background: rgba(var(--v-theme-on-surface), 0.1);
|
||||
}
|
||||
|
||||
.theme-customizer-radius-scene__badge {
|
||||
block-size: 8px;
|
||||
inline-size: 42%;
|
||||
min-inline-size: 28px;
|
||||
}
|
||||
|
||||
.theme-customizer-radius-scene__line {
|
||||
block-size: 7px;
|
||||
}
|
||||
|
||||
.theme-customizer-radius-scene__line--short {
|
||||
inline-size: 66%;
|
||||
}
|
||||
|
||||
.theme-customizer-shadow-scene {
|
||||
position: relative;
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
border: 1px solid rgba(var(--v-theme-on-surface), 0.12);
|
||||
border-radius: 10px;
|
||||
background:
|
||||
linear-gradient(180deg, rgba(var(--v-theme-on-surface), 0.02), rgba(var(--v-theme-on-surface), 0.06)),
|
||||
rgb(var(--v-theme-surface));
|
||||
block-size: 110px;
|
||||
inline-size: 100%;
|
||||
min-inline-size: 0;
|
||||
}
|
||||
|
||||
.theme-customizer-shadow-scene__panel,
|
||||
.theme-customizer-shadow-scene__card {
|
||||
position: absolute;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border: 1px solid rgba(var(--v-theme-on-surface), 0.08);
|
||||
background: rgb(var(--v-theme-surface));
|
||||
box-shadow: none;
|
||||
transition: box-shadow 0.18s ease;
|
||||
}
|
||||
|
||||
.theme-customizer-shadow-scene__panel {
|
||||
padding: 12px;
|
||||
gap: 8px;
|
||||
inset-block-start: 16px;
|
||||
inset-inline: 14px;
|
||||
min-block-size: 54px;
|
||||
}
|
||||
|
||||
.theme-customizer-shadow-scene__card {
|
||||
gap: 8px;
|
||||
inset-block-end: 12px;
|
||||
inset-inline: 20px 16px;
|
||||
min-block-size: 46px;
|
||||
padding-block: 10px;
|
||||
padding-inline: 12px;
|
||||
}
|
||||
|
||||
.theme-customizer-shadow-scene__panel-line,
|
||||
.theme-customizer-shadow-scene__line,
|
||||
.theme-customizer-shadow-scene__badge {
|
||||
display: block;
|
||||
border-radius: 999px;
|
||||
background: rgba(var(--v-theme-on-surface), 0.1);
|
||||
}
|
||||
|
||||
.theme-customizer-shadow-scene__badge {
|
||||
block-size: 6px;
|
||||
inline-size: 34%;
|
||||
min-inline-size: 28px;
|
||||
}
|
||||
|
||||
.theme-customizer-shadow-scene__panel-line,
|
||||
.theme-customizer-shadow-scene__line {
|
||||
block-size: 7px;
|
||||
}
|
||||
|
||||
.theme-customizer-shadow-scene__panel-line--short,
|
||||
.theme-customizer-shadow-scene__line--short {
|
||||
inline-size: 62%;
|
||||
}
|
||||
|
||||
.theme-customizer-shadow-scene--low {
|
||||
.theme-customizer-shadow-scene__panel {
|
||||
box-shadow:
|
||||
0 8px 18px rgba(var(--v-theme-on-surface), 0.08),
|
||||
0 2px 6px rgba(var(--v-theme-on-surface), 0.05);
|
||||
}
|
||||
|
||||
.theme-customizer-shadow-scene__card {
|
||||
box-shadow:
|
||||
0 10px 22px rgba(var(--v-theme-on-surface), 0.1),
|
||||
0 4px 10px rgba(var(--v-theme-on-surface), 0.06);
|
||||
}
|
||||
}
|
||||
|
||||
.theme-customizer-shadow-scene--medium {
|
||||
.theme-customizer-shadow-scene__panel {
|
||||
box-shadow:
|
||||
0 12px 28px rgba(var(--v-theme-on-surface), 0.12),
|
||||
0 4px 12px rgba(var(--v-theme-on-surface), 0.08);
|
||||
}
|
||||
|
||||
.theme-customizer-shadow-scene__card {
|
||||
box-shadow:
|
||||
0 16px 34px rgba(var(--v-theme-on-surface), 0.14),
|
||||
0 6px 16px rgba(var(--v-theme-on-surface), 0.09);
|
||||
}
|
||||
}
|
||||
|
||||
.theme-customizer-shadow-scene--high {
|
||||
.theme-customizer-shadow-scene__panel {
|
||||
box-shadow:
|
||||
0 16px 38px rgba(var(--v-theme-on-surface), 0.16),
|
||||
0 6px 18px rgba(var(--v-theme-on-surface), 0.1);
|
||||
}
|
||||
|
||||
.theme-customizer-shadow-scene__card {
|
||||
box-shadow:
|
||||
0 22px 48px rgba(var(--v-theme-on-surface), 0.18),
|
||||
0 8px 22px rgba(var(--v-theme-on-surface), 0.12);
|
||||
}
|
||||
}
|
||||
|
||||
@media (width <= 600px) {
|
||||
.theme-customizer-header,
|
||||
.theme-customizer-section {
|
||||
padding-inline: 22px;
|
||||
}
|
||||
|
||||
.theme-customizer-preview-grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,7 +1,7 @@
|
||||
<script lang="ts" setup>
|
||||
import type { MediaServerPlayItem } from '@/api/types'
|
||||
import noImage from '@images/no-image.jpeg'
|
||||
import { openMediaServerWithAutoDetect } from '@/utils/appDeepLink'
|
||||
import { openMediaServerItem } from '@/utils/appDeepLink'
|
||||
// 输入参数
|
||||
const props = defineProps({
|
||||
media: Object as PropType<MediaServerPlayItem>,
|
||||
@@ -25,8 +25,8 @@ function imageErrorHandler() {
|
||||
|
||||
// 跳转播放
|
||||
async function goPlay() {
|
||||
if (props.media?.link) {
|
||||
await openMediaServerWithAutoDetect(props.media.link, undefined, props.media.server_type)
|
||||
if (props.media) {
|
||||
await openMediaServerItem(props.media)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -71,52 +71,80 @@ async function deleteDownload() {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VCard v-if="cardState" :key="props.info?.hash" class="flex flex-col h-full" min-height="150">
|
||||
<template #image>
|
||||
<VImg :src="props.info?.media.image" aspect-ratio="2/3" cover @load="imageLoadHandler" position="top">
|
||||
<template #placeholder>
|
||||
<div class="w-full h-full">
|
||||
<VSkeletonLoader class="object-cover aspect-w-2 aspect-h-3" />
|
||||
</div>
|
||||
<VHover>
|
||||
<template #default="hover">
|
||||
<VCard
|
||||
v-if="cardState"
|
||||
v-bind="hover.props"
|
||||
:key="props.info?.hash"
|
||||
class="downloading-card app-surface flex flex-col h-full overflow-hidden"
|
||||
:class="{
|
||||
'transition transform-cpu duration-300 -translate-y-1': hover.isHovering,
|
||||
}"
|
||||
min-height="150"
|
||||
>
|
||||
<template #image>
|
||||
<VImg
|
||||
:src="props.info?.media.image"
|
||||
class="downloading-card-image"
|
||||
aspect-ratio="2/3"
|
||||
cover
|
||||
@load="imageLoadHandler"
|
||||
position="top"
|
||||
>
|
||||
<template #placeholder>
|
||||
<div class="w-full h-full">
|
||||
<VSkeletonLoader class="object-cover aspect-w-2 aspect-h-3" />
|
||||
</div>
|
||||
</template>
|
||||
<template #default>
|
||||
<div class="absolute inset-0 outline-none downloading-card-background"></div>
|
||||
</template>
|
||||
</VImg>
|
||||
</template>
|
||||
<template #default>
|
||||
<div class="absolute inset-0 outline-none downloading-card-background"></div>
|
||||
</template>
|
||||
</VImg>
|
||||
|
||||
<div>
|
||||
<VCardTitle class="break-words whitespace-normal text-white">
|
||||
{{ props.info?.media.title || props.info?.name }}
|
||||
{{
|
||||
props.info?.media.episode
|
||||
? `${props.info?.media.season} ${props.info?.media.episode}`
|
||||
: props.info?.season_episode
|
||||
}}
|
||||
</VCardTitle>
|
||||
|
||||
<VCardSubtitle class="break-words whitespace-normal text-white">
|
||||
{{ props.info?.title }}
|
||||
</VCardSubtitle>
|
||||
|
||||
<VCardText class="text-subtitle-1 pt-3 pb-1 text-white">
|
||||
{{ getSpeedText() }}
|
||||
</VCardText>
|
||||
|
||||
<VCardText v-if="getPercentage() > 0" class="text-white">
|
||||
<VProgressLinear :model-value="getPercentage()" bg-color="success" color="success" />
|
||||
</VCardText>
|
||||
|
||||
<VCardActions class="justify-space-between">
|
||||
<VBtn :icon="`${isDownloading ? 'mdi-pause' : 'mdi-play'}`" @click="toggleDownload" />
|
||||
<VBtn color="error" icon="mdi-trash-can-outline" @click="deleteDownload" />
|
||||
</VCardActions>
|
||||
</div>
|
||||
</VCard>
|
||||
</template>
|
||||
|
||||
<div>
|
||||
<VCardTitle class="break-words whitespace-normal text-white">
|
||||
{{ props.info?.media.title || props.info?.name }}
|
||||
{{
|
||||
props.info?.media.episode
|
||||
? `${props.info?.media.season} ${props.info?.media.episode}`
|
||||
: props.info?.season_episode
|
||||
}}
|
||||
</VCardTitle>
|
||||
|
||||
<VCardSubtitle class="break-words whitespace-normal text-white">
|
||||
{{ props.info?.title }}
|
||||
</VCardSubtitle>
|
||||
|
||||
<VCardText class="text-subtitle-1 pt-3 pb-1 text-white">
|
||||
{{ getSpeedText() }}
|
||||
</VCardText>
|
||||
|
||||
<VCardText v-if="getPercentage() > 0" class="text-white">
|
||||
<VProgressLinear :model-value="getPercentage()" bg-color="success" color="success" />
|
||||
</VCardText>
|
||||
|
||||
<VCardActions class="justify-space-between">
|
||||
<VBtn :icon="`${isDownloading ? 'mdi-pause' : 'mdi-play'}`" @click="toggleDownload" />
|
||||
<VBtn color="error" icon="mdi-trash-can-outline" @click="deleteDownload" />
|
||||
</VCardActions>
|
||||
</div>
|
||||
</VCard>
|
||||
</VHover>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
/* stylelint-disable selector-pseudo-class-no-unknown */
|
||||
|
||||
.downloading-card-image {
|
||||
block-size: 100%;
|
||||
}
|
||||
|
||||
.downloading-card-background {
|
||||
border-radius: inherit;
|
||||
background-image: linear-gradient(180deg, rgba(31, 41, 55, 47%) 0%, rgb(31, 41, 55) 100%);
|
||||
pointer-events: none;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -4,7 +4,7 @@ import plex from '@images/misc/plex.png'
|
||||
import emby from '@images/misc/emby.png'
|
||||
import jellyfin from '@images/misc/jellyfin.png'
|
||||
import { getLogoUrl } from '@/utils/imageUtils'
|
||||
import { openMediaServerWithAutoDetect } from '@/utils/appDeepLink'
|
||||
import { openMediaServerItem } from '@/utils/appDeepLink'
|
||||
|
||||
// 输入参数
|
||||
const props = defineProps({
|
||||
@@ -49,8 +49,8 @@ function getDefaultImage() {
|
||||
|
||||
// 跳转播放
|
||||
async function goPlay() {
|
||||
if (props.media?.link) {
|
||||
await openMediaServerWithAutoDetect(props.media.link, undefined, props.media.server_type)
|
||||
if (props.media) {
|
||||
await openMediaServerItem(props.media)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script lang="ts" setup>
|
||||
import noImage from '@images/no-image.jpeg'
|
||||
import { getLogoUrl } from '@/utils/imageUtils'
|
||||
import { getDisplayImageUrl, getLogoUrl } from '@/utils/imageUtils'
|
||||
import api from '@/api'
|
||||
import { useToast } from 'vue-toastification'
|
||||
import { formatSeason, formatRating } from '@/@core/utils/formatters'
|
||||
@@ -10,7 +10,7 @@ import router from '@/router'
|
||||
import { useUserStore, useGlobalSettingsStore } from '@/stores'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { mediaTypeDict } from '@/api/constants'
|
||||
import { hasPermission } from '@/utils/permission'
|
||||
import { buildUserPermissionContext, hasPermission } from '@/utils/permission'
|
||||
import { openSharedDialog } from '@/composables/useSharedDialog'
|
||||
import {
|
||||
getCachedMediaExistsStatus,
|
||||
@@ -45,6 +45,9 @@ const globalSettings = globalSettingsStore.globalSettings
|
||||
|
||||
// 用户 Store
|
||||
const userStore = useUserStore()
|
||||
const userPermissions = computed(() => buildUserPermissionContext(userStore.superUser, userStore.permissions))
|
||||
const canSearch = computed(() => hasPermission(userPermissions.value, 'search'))
|
||||
const canSubscribe = computed(() => hasPermission(userPermissions.value, 'subscribe'))
|
||||
|
||||
// 提示框
|
||||
const $toast = useToast()
|
||||
@@ -143,7 +146,7 @@ async function querySites() {
|
||||
// 查询用户选中的站点
|
||||
async function querySelectedSites() {
|
||||
try {
|
||||
const result: { [key: string]: any } = await api.get('system/setting/IndexerSites')
|
||||
const result: { [key: string]: any } = await api.get('system/setting/public/IndexerSites')
|
||||
selectedSites.value = result.data?.value ?? []
|
||||
} catch (error) {
|
||||
console.log(error)
|
||||
@@ -336,12 +339,11 @@ async function checkSubscribe(season: number | null) {
|
||||
|
||||
// 查询订阅弹窗规则
|
||||
async function queryDefaultSubscribeConfig() {
|
||||
// 非管理员不显示
|
||||
if (!userStore.superUser) return false
|
||||
if (!canSubscribe.value) return false
|
||||
try {
|
||||
let subscribe_config_url = ''
|
||||
if (props.media?.type === '电影') subscribe_config_url = 'system/setting/DefaultMovieSubscribeConfig'
|
||||
else subscribe_config_url = 'system/setting/DefaultTvSubscribeConfig'
|
||||
if (props.media?.type === '电影') subscribe_config_url = 'system/setting/public/DefaultMovieSubscribeConfig'
|
||||
else subscribe_config_url = 'system/setting/public/DefaultTvSubscribeConfig'
|
||||
const result: { [key: string]: any } = await api.get(subscribe_config_url)
|
||||
if (result.data?.value) return result.data.value.show_edit_dialog
|
||||
} catch (error) {
|
||||
@@ -464,13 +466,7 @@ function setupIntersectionObserver() {
|
||||
const getImgUrl: Ref<string> = computed(() => {
|
||||
if (imageLoadError.value) return noImage
|
||||
const url = props.media?.poster_path?.replace('original', 'w500') ?? noImage
|
||||
// 使用图片缓存
|
||||
if (globalSettings.GLOBAL_IMAGE_CACHE)
|
||||
return `${import.meta.env.VITE_API_BASE_URL}system/cache/image?url=${encodeURIComponent(url)}`
|
||||
// 如果地址中包含douban则使用中转代理
|
||||
if (url.includes('doubanio.com'))
|
||||
return `${import.meta.env.VITE_API_BASE_URL}system/img/0?imgurl=${encodeURIComponent(url)}`
|
||||
return url
|
||||
return getDisplayImageUrl(url, globalSettings.GLOBAL_IMAGE_CACHE)
|
||||
})
|
||||
|
||||
// 移除订阅
|
||||
@@ -540,7 +536,7 @@ onBeforeUnmount(() => {
|
||||
<div v-if="props.media?.collection_id" class="mb-3" @click.stop=""></div>
|
||||
<div v-else class="flex align-center justify-between">
|
||||
<IconBtn
|
||||
v-if="hasPermission({ is_superuser: userStore.superUser, ...userStore.permissions }, 'search')"
|
||||
v-if="canSearch"
|
||||
icon="mdi-magnify"
|
||||
color="white"
|
||||
size="small"
|
||||
@@ -548,6 +544,7 @@ onBeforeUnmount(() => {
|
||||
/>
|
||||
<VSpacer />
|
||||
<IconBtn
|
||||
v-if="canSubscribe"
|
||||
icon="mdi-heart"
|
||||
:color="isSubscribed ? 'error' : 'white'"
|
||||
size="small"
|
||||
@@ -580,6 +577,7 @@ onBeforeUnmount(() => {
|
||||
<!--来源图标-->
|
||||
<VAvatar
|
||||
size="24"
|
||||
variant="plain"
|
||||
density="compact"
|
||||
class="absolute bottom-1 right-1"
|
||||
tile
|
||||
|
||||
@@ -1,226 +0,0 @@
|
||||
<script lang="ts" setup>
|
||||
import MarkdownIt from 'markdown-it'
|
||||
import mdLinkAttributes from 'markdown-it-link-attributes'
|
||||
import { isNullOrEmptyObject } from '@/@core/utils'
|
||||
import type { Message } from '@/api/types'
|
||||
import { formatDateDifference } from '@core/utils/formatters'
|
||||
|
||||
// 输入参数
|
||||
const props = defineProps({
|
||||
message: Object as PropType<Message>,
|
||||
width: String,
|
||||
height: String,
|
||||
})
|
||||
|
||||
// 定义事件
|
||||
const emit = defineEmits(['imageload'])
|
||||
|
||||
// 图片是否加载完成
|
||||
const isImageLoaded = ref(false)
|
||||
|
||||
// 图片是否加载失败
|
||||
const imageLoadError = ref(false)
|
||||
|
||||
// 初始化 markdown-it
|
||||
const md = new MarkdownIt({
|
||||
html: true,
|
||||
breaks: true,
|
||||
linkify: true,
|
||||
typographer: true,
|
||||
})
|
||||
|
||||
// 插件:链接在新窗口打开
|
||||
md.use(mdLinkAttributes, {
|
||||
attrs: {
|
||||
target: '_blank',
|
||||
rel: 'noopener noreferrer',
|
||||
},
|
||||
})
|
||||
|
||||
// 图片加载完成
|
||||
async function imageLoaded() {
|
||||
isImageLoaded.value = true
|
||||
emit('imageload')
|
||||
}
|
||||
|
||||
// 链接打开新窗口
|
||||
function openLink() {
|
||||
if (props.message?.link) window.open(props.message.link, '_blank')
|
||||
}
|
||||
|
||||
// 将note转换为json
|
||||
function noteToJson() {
|
||||
if (props.message?.note) {
|
||||
try {
|
||||
return JSON.parse(props.message.note)
|
||||
} catch (error) {
|
||||
return props.message.note
|
||||
}
|
||||
}
|
||||
return {}
|
||||
}
|
||||
|
||||
// 渲染 Markdown
|
||||
function renderMarkdown(value: string) {
|
||||
if (!value) return ''
|
||||
return md.render(value)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VCard variant="tonal" :width="props.width" :height="props.height" @click="openLink" max-width="23rem">
|
||||
<div v-if="props.message?.image" class="relative text-center card-cover-blurred">
|
||||
<VImg
|
||||
:src="props.message?.image"
|
||||
aspect-ratio="3/2"
|
||||
cover
|
||||
position="top"
|
||||
@load="imageLoaded"
|
||||
@error="imageLoadError = true"
|
||||
min-height="10rem"
|
||||
>
|
||||
<template #placeholder>
|
||||
<div class="w-full h-full">
|
||||
<VSkeletonLoader class="object-cover aspect-w-2 aspect-h-3" />
|
||||
</div>
|
||||
</template>
|
||||
</VImg>
|
||||
</div>
|
||||
<div
|
||||
v-if="
|
||||
props.message?.title &&
|
||||
!props.message?.text &&
|
||||
!props.message?.image &&
|
||||
isNullOrEmptyObject(props.message?.note) &&
|
||||
props.message?.action === 0
|
||||
"
|
||||
class="rounded-md text-body-1 py-2 px-4 elevation-2 bg-primary text-white chat-right mb-1"
|
||||
>
|
||||
<p class="mb-0">{{ props.message?.title }}</p>
|
||||
</div>
|
||||
<VCardTitle v-else-if="props.message?.title" class="break-words whitespace-break-spaces">
|
||||
{{ props.message?.title }}
|
||||
</VCardTitle>
|
||||
<div
|
||||
v-if="props.message?.text && props.message?.action === 0"
|
||||
class="rounded-md text-body-1 py-1 px-4 elevation-2 bg-primary text-white chat-right"
|
||||
>
|
||||
<div class="markdown-body" v-html="renderMarkdown(props.message?.text)" />
|
||||
</div>
|
||||
<VCardText
|
||||
v-if="props.message?.text && props.message?.action === 1"
|
||||
class="markdown-body"
|
||||
v-html="renderMarkdown(props.message?.text)"
|
||||
/>
|
||||
<VCardText v-if="!isNullOrEmptyObject(props.message?.note)">
|
||||
<VList>
|
||||
<VListItem v-for="(value, key) in noteToJson()" :key="key" two-line>
|
||||
<VListItemTitle v-if="value.title_year" class="font-bold break-words whitespace-break-spaces">
|
||||
{{ Number(key) + 1 }}. {{ value.title_year }}
|
||||
</VListItemTitle>
|
||||
<VListItemTitle v-if="value.enclosure" class="font-bold break-words whitespace-break-spaces">
|
||||
{{ Number(key) + 1 }}. {{ value.title }} {{ value.volume_factor }} ↑{{ value.seeders }}
|
||||
</VListItemTitle>
|
||||
<VListItemSubtitle v-if="value.type">
|
||||
类型:{{ value.type }} 评分:{{ value.vote_average }}
|
||||
</VListItemSubtitle>
|
||||
<VListItemSubtitle v-if="value.enclosure" class="whitespace-break-spaces">
|
||||
{{ value.description }}
|
||||
</VListItemSubtitle>
|
||||
</VListItem>
|
||||
</VList>
|
||||
</VCardText>
|
||||
</VCard>
|
||||
<div class="text-end">
|
||||
<span v-if="props.message?.action === 0" class="text-sm italic me-2">{{ props.message?.userid }}</span>
|
||||
<span class="text-sm italic me-2">{{
|
||||
formatDateDifference(props.message?.reg_time || props.message?.date || '')
|
||||
}}</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss">
|
||||
.markdown-body {
|
||||
word-break: break-all;
|
||||
|
||||
p {
|
||||
margin-block-end: 0.5rem;
|
||||
}
|
||||
|
||||
p:last-child {
|
||||
margin-block-end: 0;
|
||||
}
|
||||
|
||||
a {
|
||||
color: inherit;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
ul {
|
||||
list-style-type: disc;
|
||||
margin-block-end: 0.5rem;
|
||||
padding-inline-start: 1.5rem;
|
||||
}
|
||||
|
||||
ol {
|
||||
list-style-type: decimal;
|
||||
margin-block-end: 0.5rem;
|
||||
padding-inline-start: 1.5rem;
|
||||
}
|
||||
|
||||
li {
|
||||
display: list-item;
|
||||
margin-block-end: 0.25rem;
|
||||
}
|
||||
|
||||
code {
|
||||
border-radius: 4px;
|
||||
background-color: rgba(var(--v-border-color), 0.1);
|
||||
font-family: monospace;
|
||||
padding-block: 0.2rem;
|
||||
padding-inline: 0.4rem;
|
||||
}
|
||||
|
||||
pre {
|
||||
overflow: auto;
|
||||
padding: 1rem;
|
||||
border-radius: 8px;
|
||||
background-color: rgba(var(--v-border-color), 0.1);
|
||||
margin-block-end: 0.5rem;
|
||||
|
||||
code {
|
||||
padding: 0;
|
||||
background-color: transparent;
|
||||
}
|
||||
}
|
||||
|
||||
blockquote {
|
||||
border-inline-start: 4px solid rgba(var(--v-border-color), 0.2);
|
||||
font-style: italic;
|
||||
margin-block-end: 0.5rem;
|
||||
padding-inline-start: 1rem;
|
||||
}
|
||||
|
||||
table {
|
||||
border-collapse: collapse;
|
||||
inline-size: 100%;
|
||||
margin-block-end: 1rem;
|
||||
|
||||
th,
|
||||
td {
|
||||
padding: 0.5rem;
|
||||
border: 1px solid rgba(var(--v-border-color), 0.1);
|
||||
text-align: start;
|
||||
}
|
||||
|
||||
th {
|
||||
background-color: rgba(var(--v-border-color), 0.05);
|
||||
}
|
||||
}
|
||||
|
||||
img {
|
||||
block-size: auto;
|
||||
max-inline-size: 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -3,6 +3,7 @@ import personIcon from '@images/misc/person-icon.png'
|
||||
import type { Person } from '@/api/types'
|
||||
import router from '@/router'
|
||||
import { useGlobalSettingsStore } from '@/stores'
|
||||
import { getDisplayImageUrl } from '@/utils/imageUtils'
|
||||
|
||||
const personProps = defineProps({
|
||||
person: Object as PropType<Person>,
|
||||
@@ -40,9 +41,7 @@ function getPersonImage() {
|
||||
} else {
|
||||
return personIcon
|
||||
}
|
||||
if (globalSettings.GLOBAL_IMAGE_CACHE && url)
|
||||
return `${import.meta.env.VITE_API_BASE_URL}system/cache/image?url=${encodeURIComponent(url)}`
|
||||
return url
|
||||
return getDisplayImageUrl(url, globalSettings.GLOBAL_IMAGE_CACHE)
|
||||
}
|
||||
|
||||
// 人物姓名
|
||||
|
||||
@@ -1,14 +1,20 @@
|
||||
<script lang="ts" setup>
|
||||
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 { useToast } from 'vue-toastification'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { openSharedDialog } from '@/composables/useSharedDialog'
|
||||
import { useConfirm } from '@/composables/useConfirm'
|
||||
|
||||
const PluginMarketDetailDialog = defineAsyncComponent(() => import('@/components/dialog/PluginMarketDetailDialog.vue'))
|
||||
const PluginVersionHistoryDialog = defineAsyncComponent(() => import('@/components/dialog/PluginVersionHistoryDialog.vue'))
|
||||
const PluginVersionHistoryDialog = defineAsyncComponent(
|
||||
() => import('@/components/dialog/PluginVersionHistoryDialog.vue'),
|
||||
)
|
||||
const ProgressDialog = defineAsyncComponent(() => import('@/components/dialog/ProgressDialog.vue'))
|
||||
|
||||
// 输入参数
|
||||
const props = defineProps({
|
||||
@@ -24,6 +30,11 @@ const emit = defineEmits(['install'])
|
||||
// 多语言
|
||||
const { t } = useI18n()
|
||||
|
||||
// 提示框
|
||||
const $toast = useToast()
|
||||
|
||||
const createConfirm = useConfirm()
|
||||
|
||||
// 背景颜色
|
||||
const backgroundColor = ref('#28A9E1')
|
||||
|
||||
@@ -46,6 +57,21 @@ const isImageLoaded = ref(false)
|
||||
// 图片是否加载失败
|
||||
const imageLoadError = ref(false)
|
||||
|
||||
let progressDialogController: ReturnType<typeof openSharedDialog> | null = null
|
||||
let versionHistoryDialogController: ReturnType<typeof openSharedDialog> | null = null
|
||||
|
||||
/** 打开插件安装进度弹窗。 */
|
||||
function showInstallProgress(text: string) {
|
||||
progressDialogController?.close()
|
||||
progressDialogController = openSharedDialog(ProgressDialog, { text }, {}, { closeOn: false })
|
||||
}
|
||||
|
||||
/** 关闭插件安装进度弹窗。 */
|
||||
function closeInstallProgress() {
|
||||
progressDialogController?.close()
|
||||
progressDialogController = null
|
||||
}
|
||||
|
||||
// 图片加载完成
|
||||
async function imageLoaded() {
|
||||
isImageLoaded.value = true
|
||||
@@ -94,14 +120,69 @@ function visitPluginPage() {
|
||||
|
||||
// 显示更新日志
|
||||
function showUpdateHistory() {
|
||||
openSharedDialog(
|
||||
versionHistoryDialogController?.close()
|
||||
versionHistoryDialogController = openSharedDialog(
|
||||
PluginVersionHistoryDialog,
|
||||
{ plugin: props.plugin },
|
||||
{},
|
||||
{ plugin: props.plugin, actionMode: 'install' },
|
||||
{
|
||||
update: installPlugin,
|
||||
},
|
||||
{ closeOn: ['close', 'update:modelValue'] },
|
||||
)
|
||||
}
|
||||
|
||||
/** 从插件市场版本历史安装指定 Release;最新版本走普通安装路径以保留主程序兼容校验。 */
|
||||
async function installPlugin(releaseVersion?: string, repoUrl?: string) {
|
||||
if (!releaseVersion && props.plugin?.system_version_compatible === false) {
|
||||
$toast.error(props.plugin?.system_version_message || t('plugin.incompatibleSystemVersion'))
|
||||
return
|
||||
}
|
||||
|
||||
if (releaseVersion) {
|
||||
const isConfirmed = await createConfirm({
|
||||
title: t('common.confirm'),
|
||||
content: t('plugin.confirmInstallOldRelease', {
|
||||
name: props.plugin?.plugin_name,
|
||||
version: releaseVersion,
|
||||
}),
|
||||
confirmText: t('common.confirm'),
|
||||
})
|
||||
|
||||
if (!isConfirmed) return
|
||||
}
|
||||
|
||||
try {
|
||||
showInstallProgress(
|
||||
t('plugin.installing', {
|
||||
name: props.plugin?.plugin_name,
|
||||
version: releaseVersion || props.plugin?.plugin_version,
|
||||
}),
|
||||
)
|
||||
|
||||
const result: { [key: string]: any } = await api.get(`plugin/install/${props.plugin?.id}`, {
|
||||
params: {
|
||||
repo_url: repoUrl || props.plugin?.repo_url,
|
||||
release_version: releaseVersion,
|
||||
force: props.plugin?.has_update || Boolean(releaseVersion),
|
||||
},
|
||||
})
|
||||
|
||||
closeInstallProgress()
|
||||
|
||||
if (result.success) {
|
||||
$toast.success(t('plugin.installSuccess', { name: props.plugin?.plugin_name }))
|
||||
versionHistoryDialogController?.close()
|
||||
versionHistoryDialogController = null
|
||||
emit('install')
|
||||
} else {
|
||||
$toast.error(t('plugin.installFailed', { name: props.plugin?.plugin_name, message: result.message }))
|
||||
}
|
||||
} catch (error) {
|
||||
closeInstallProgress()
|
||||
console.error(error)
|
||||
}
|
||||
}
|
||||
|
||||
/** 打开共享插件市场详情弹窗。 */
|
||||
function showPluginDetail() {
|
||||
openSharedDialog(
|
||||
@@ -119,6 +200,15 @@ function showPluginDetail() {
|
||||
|
||||
// 弹出菜单
|
||||
const dropdownItems = ref([
|
||||
{
|
||||
title: t('plugin.versionHistory'),
|
||||
value: 2,
|
||||
show: !isNullOrEmptyObject(props.plugin?.history || {}),
|
||||
props: {
|
||||
prependIcon: 'mdi-update',
|
||||
click: showUpdateHistory,
|
||||
},
|
||||
},
|
||||
{
|
||||
title: t('plugin.projectHome'),
|
||||
value: 1,
|
||||
@@ -128,17 +218,12 @@ const dropdownItems = ref([
|
||||
click: visitPluginPage,
|
||||
},
|
||||
},
|
||||
{
|
||||
title: t('plugin.updateHistory'),
|
||||
value: 2,
|
||||
show: !isNullOrEmptyObject(props.plugin?.history || {}),
|
||||
props: {
|
||||
prependIcon: 'mdi-update',
|
||||
click: showUpdateHistory,
|
||||
},
|
||||
},
|
||||
])
|
||||
|
||||
onUnmounted(() => {
|
||||
closeInstallProgress()
|
||||
versionHistoryDialogController?.close()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -176,14 +261,14 @@ const dropdownItems = ref([
|
||||
{{ props.plugin?.plugin_desc }}
|
||||
</div>
|
||||
<!-- 插件标签 -->
|
||||
<div v-if="pluginLabels.length > 0" class="plugin-app-card__tags-section px-2">
|
||||
<div v-if="pluginLabels.length > 0" class="plugin-app-card__tags-section px-2 mb-2">
|
||||
<VChip
|
||||
v-for="tag in pluginLabels"
|
||||
:key="tag"
|
||||
size="x-small"
|
||||
variant="tonal"
|
||||
color="info"
|
||||
class="me-1 mb-1"
|
||||
class="plugin-app-card__tag"
|
||||
tile
|
||||
>
|
||||
{{ tag }}
|
||||
@@ -245,3 +330,25 @@ const dropdownItems = ref([
|
||||
</VHover>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.plugin-app-card__tags-section {
|
||||
display: flex;
|
||||
overflow: hidden;
|
||||
flex-wrap: nowrap;
|
||||
gap: 4px;
|
||||
max-inline-size: 100%;
|
||||
}
|
||||
|
||||
.plugin-app-card__tag {
|
||||
flex: 0 0 auto;
|
||||
max-inline-size: 100%;
|
||||
min-inline-size: 0;
|
||||
}
|
||||
|
||||
.plugin-app-card__tag :deep(.v-chip__content) {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -3,7 +3,6 @@ import { useToast } from 'vue-toastification'
|
||||
import { useConfirm } from '@/composables/useConfirm'
|
||||
import api from '@/api'
|
||||
import type { Plugin } from '@/api/types'
|
||||
import { isNullOrEmptyObject } from '@core/utils'
|
||||
import { getLogoUrl } from '@/utils/imageUtils'
|
||||
import { getDominantColor } from '@/@core/utils/image'
|
||||
import { formatDownloadCount } from '@/@core/utils/formatters'
|
||||
@@ -70,6 +69,7 @@ const imageLoadError = ref(false)
|
||||
|
||||
let progressDialogController: ReturnType<typeof openSharedDialog> | null = null
|
||||
let cloneDialogController: ReturnType<typeof openSharedDialog> | null = null
|
||||
let versionHistoryDialogController: ReturnType<typeof openSharedDialog> | null = null
|
||||
|
||||
/** 打开插件操作进度弹窗,插件卡片自身不再持有进度弹窗实例。 */
|
||||
function showPluginProgress(text: string) {
|
||||
@@ -103,18 +103,14 @@ async function imageLoaded() {
|
||||
}
|
||||
|
||||
// 显示更新日志
|
||||
function showUpdateHistory() {
|
||||
// 检查当前版本是否有更新日志
|
||||
if (isNullOrEmptyObject(props.plugin?.history)) {
|
||||
updatePlugin()
|
||||
} else {
|
||||
openSharedDialog(
|
||||
PluginVersionHistoryDialog,
|
||||
{ plugin: props.plugin, showUpdateAction: true },
|
||||
{ update: updatePlugin },
|
||||
{ closeOn: ['close', 'update', 'update:modelValue'] },
|
||||
)
|
||||
}
|
||||
function showUpdateHistory(showUpdateAction: boolean = false) {
|
||||
versionHistoryDialogController?.close()
|
||||
versionHistoryDialogController = openSharedDialog(
|
||||
PluginVersionHistoryDialog,
|
||||
{ plugin: props.plugin, showUpdateAction },
|
||||
{ update: updatePlugin },
|
||||
{ closeOn: ['close', 'update:modelValue'] },
|
||||
)
|
||||
}
|
||||
|
||||
// 调用API卸载插件
|
||||
@@ -225,19 +221,37 @@ async function resetPlugin() {
|
||||
}
|
||||
|
||||
// 更新插件
|
||||
async function updatePlugin() {
|
||||
if (props.plugin?.system_version_compatible === false) {
|
||||
async function updatePlugin(releaseVersion?: string, repoUrl?: string) {
|
||||
if (!releaseVersion && props.plugin?.system_version_compatible === false) {
|
||||
$toast.error(props.plugin?.system_version_message || t('plugin.incompatibleSystemVersion'))
|
||||
return
|
||||
}
|
||||
|
||||
if (releaseVersion) {
|
||||
const isConfirmed = await createConfirm({
|
||||
title: t('common.confirm'),
|
||||
content: t('plugin.confirmInstallOldRelease', {
|
||||
name: props.plugin?.plugin_name,
|
||||
version: releaseVersion,
|
||||
}),
|
||||
confirmText: t('common.confirm'),
|
||||
})
|
||||
|
||||
if (!isConfirmed) return
|
||||
}
|
||||
|
||||
try {
|
||||
// 显示等待提示框
|
||||
showPluginProgress(t('plugin.updating', { name: props.plugin?.plugin_name }))
|
||||
showPluginProgress(
|
||||
releaseVersion
|
||||
? t('plugin.installing', { name: props.plugin?.plugin_name, version: releaseVersion })
|
||||
: t('plugin.updating', { name: props.plugin?.plugin_name }),
|
||||
)
|
||||
|
||||
const result: { [key: string]: any } = await api.get(`plugin/install/${props.plugin?.id}`, {
|
||||
params: {
|
||||
repo_url: props.plugin?.repo_url,
|
||||
repo_url: repoUrl || props.plugin?.repo_url,
|
||||
release_version: releaseVersion,
|
||||
force: true,
|
||||
},
|
||||
})
|
||||
@@ -247,6 +261,8 @@ async function updatePlugin() {
|
||||
|
||||
if (result.success) {
|
||||
$toast.success(t('plugin.updateSuccess', { name: props.plugin?.plugin_name }))
|
||||
versionHistoryDialogController?.close()
|
||||
versionHistoryDialogController = null
|
||||
|
||||
// 通知父组件刷新
|
||||
emit('save')
|
||||
@@ -264,9 +280,102 @@ async function updatePlugin() {
|
||||
}
|
||||
}
|
||||
|
||||
// 访问作者主页
|
||||
function visitAuthorPage() {
|
||||
window.open(props.plugin?.author_url, '_blank')
|
||||
/** 将 raw.githubusercontent.com 插件地址转换为可访问的 GitHub 项目主页。 */
|
||||
function normalizePluginRepoUrl(repoUrl?: string) {
|
||||
if (!repoUrl || !repoUrl.includes('raw.githubusercontent.com')) return repoUrl
|
||||
|
||||
try {
|
||||
const rawUrl = new URL(repoUrl)
|
||||
const [user, repo] = rawUrl.pathname.split('/').filter(Boolean)
|
||||
|
||||
if (user && repo) return `https://github.com/${user}/${repo}`
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
}
|
||||
|
||||
return repoUrl
|
||||
}
|
||||
|
||||
/** 判断插件当前是否已经有可用的远程项目地址。 */
|
||||
function hasRemoteRepoUrl(plugin?: Plugin) {
|
||||
return Boolean(plugin?.repo_url && !plugin.repo_url.startsWith('local://'))
|
||||
}
|
||||
|
||||
/** 优先解析插件仓库地址,本地插件或缺少仓库地址时回退到作者主页。 */
|
||||
function resolvePluginPageUrl(plugin?: Plugin) {
|
||||
if (!plugin) return ''
|
||||
|
||||
const repoUrl =
|
||||
hasRemoteRepoUrl(plugin)
|
||||
? normalizePluginRepoUrl(plugin.repo_url)
|
||||
: plugin.author_url
|
||||
|
||||
return repoUrl || plugin.author_url || ''
|
||||
}
|
||||
|
||||
/** 从插件市场中查找同 ID 插件,补齐已安装插件缺失的 repo_url。 */
|
||||
async function fetchMarketPlugin(pluginId?: string) {
|
||||
if (!pluginId) return null
|
||||
|
||||
try {
|
||||
const marketPlugins: Plugin[] = await api.get('plugin/', {
|
||||
params: {
|
||||
state: 'market',
|
||||
force: false,
|
||||
},
|
||||
})
|
||||
|
||||
return marketPlugins.find(plugin => plugin.id === pluginId) || null
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
// 访问插件项目主页
|
||||
async function visitPluginPage() {
|
||||
const popup = window.open('about:blank', '_blank')
|
||||
let pluginDetail = props.plugin
|
||||
|
||||
if (popup) popup.opener = null
|
||||
|
||||
try {
|
||||
if (props.plugin?.id) {
|
||||
const historyPlugin: Plugin = await api.get(`plugin/history/${props.plugin.id}`, {
|
||||
params: {
|
||||
force: false,
|
||||
},
|
||||
})
|
||||
|
||||
// 历史接口可能只返回部分字段,合并原卡片数据避免丢失 author_url 兜底。
|
||||
pluginDetail = { ...(props.plugin || {}), ...(historyPlugin || {}) } as Plugin
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
}
|
||||
|
||||
if (!hasRemoteRepoUrl(pluginDetail)) {
|
||||
const marketPlugin = await fetchMarketPlugin(props.plugin?.id)
|
||||
|
||||
if (marketPlugin) {
|
||||
// 插件市场条目通常包含真实仓库地址,优先使用它来对齐市场卡片跳转。
|
||||
pluginDetail = { ...(pluginDetail || {}), ...marketPlugin } as Plugin
|
||||
}
|
||||
}
|
||||
|
||||
const repoUrl = resolvePluginPageUrl(pluginDetail)
|
||||
|
||||
if (repoUrl) {
|
||||
if (popup) {
|
||||
popup.location.replace(repoUrl)
|
||||
return
|
||||
}
|
||||
|
||||
window.open(repoUrl, '_blank')
|
||||
return
|
||||
}
|
||||
|
||||
popup?.close()
|
||||
}
|
||||
|
||||
// 打开插件详情
|
||||
@@ -377,7 +486,7 @@ const dropdownItems = ref([
|
||||
props: {
|
||||
prependIcon: 'mdi-arrow-up-circle-outline',
|
||||
color: 'success',
|
||||
click: showUpdateHistory,
|
||||
click: () => showUpdateHistory(true),
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -400,6 +509,15 @@ const dropdownItems = ref([
|
||||
click: uninstallPlugin,
|
||||
},
|
||||
},
|
||||
{
|
||||
title: t('plugin.versionHistory'),
|
||||
value: 9,
|
||||
show: !props.plugin?.has_update,
|
||||
props: {
|
||||
prependIcon: 'mdi-update',
|
||||
click: () => showUpdateHistory(false),
|
||||
},
|
||||
},
|
||||
{
|
||||
title: t('plugin.viewLogs'),
|
||||
value: 6,
|
||||
@@ -412,12 +530,12 @@ const dropdownItems = ref([
|
||||
},
|
||||
},
|
||||
{
|
||||
title: t('plugin.authorHome'),
|
||||
title: t('plugin.projectHome'),
|
||||
value: 7,
|
||||
show: true,
|
||||
props: {
|
||||
prependIcon: 'mdi-home-circle-outline',
|
||||
click: visitAuthorPage,
|
||||
prependIcon: 'mdi-github',
|
||||
click: visitPluginPage,
|
||||
},
|
||||
},
|
||||
])
|
||||
@@ -428,6 +546,9 @@ watch(
|
||||
(newHasUpdate, _) => {
|
||||
const updateItemIndex = dropdownItems.value.findIndex(item => item.value === 3)
|
||||
if (updateItemIndex !== -1) dropdownItems.value[updateItemIndex].show = newHasUpdate
|
||||
|
||||
const updateHistoryItemIndex = dropdownItems.value.findIndex(item => item.value === 9)
|
||||
if (updateHistoryItemIndex !== -1) dropdownItems.value[updateHistoryItemIndex].show = !newHasUpdate
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@@ -137,7 +137,7 @@ function handleDropToFolder(event: DragEvent) {
|
||||
|
||||
&.sortable-ghost {
|
||||
border: 2px dashed #2196f3;
|
||||
border-radius: 16px;
|
||||
border-radius: var(--app-surface-radius);
|
||||
background: rgba(33, 150, 243, 10%);
|
||||
opacity: 0.3;
|
||||
}
|
||||
@@ -151,7 +151,7 @@ function handleDropToFolder(event: DragEvent) {
|
||||
|
||||
&.drag-over {
|
||||
border: 2px dashed #2196f3;
|
||||
border-radius: 16px;
|
||||
border-radius: var(--app-surface-radius);
|
||||
box-shadow: 0 0 20px rgba(33, 150, 243, 50%);
|
||||
transform: scale(1.02);
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import type { PropType } from 'vue'
|
||||
import type { MediaServerPlayItem } from '@/api/types'
|
||||
import noImage from '@images/no-image.jpeg'
|
||||
import { openMediaServerWithAutoDetect } from '@/utils/appDeepLink'
|
||||
import { openMediaServerItem } from '@/utils/appDeepLink'
|
||||
|
||||
// 输入参数
|
||||
const props = defineProps({
|
||||
@@ -38,8 +38,8 @@ const getImgUrl = computed(() => {
|
||||
|
||||
// 跳转播放
|
||||
async function goPlay(isHovering: boolean | null = false) {
|
||||
if (props.media?.link && isHovering) {
|
||||
await openMediaServerWithAutoDetect(props.media.link, undefined, props.media.server_type)
|
||||
if (props.media && isHovering) {
|
||||
await openMediaServerItem(props.media)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -255,7 +255,6 @@ onMounted(() => {
|
||||
:ripple="false"
|
||||
variant="flat"
|
||||
elevation="0"
|
||||
rounded="lg"
|
||||
:hover="!cardProps.sortable"
|
||||
@click="handleCardClick"
|
||||
>
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
<script lang="ts" setup>
|
||||
import { useToast } from 'vue-toastification'
|
||||
import { useConfirm } from '@/composables/useConfirm'
|
||||
import { formatDateDifference, formatSeason } from '@/@core/utils/formatters'
|
||||
import { formatDateDifference } from '@/@core/utils/formatters'
|
||||
import { formatSeasonLabel } from '@/@core/utils/season'
|
||||
import api from '@/api'
|
||||
import type { Subscribe } from '@/api/types'
|
||||
import router from '@/router'
|
||||
@@ -9,6 +10,7 @@ import { useI18n } from 'vue-i18n'
|
||||
import { useDisplay } from 'vuetify'
|
||||
import { useGlobalSettingsStore } from '@/stores'
|
||||
import { openSharedDialog } from '@/composables/useSharedDialog'
|
||||
import { getDisplayImageUrl } from '@/utils/imageUtils'
|
||||
|
||||
const SubscribeEditDialog = defineAsyncComponent(() => import('../dialog/SubscribeEditDialog.vue'))
|
||||
const SubscribeFilesDialog = defineAsyncComponent(() => import('../dialog/SubscribeFilesDialog.vue'))
|
||||
@@ -363,19 +365,13 @@ watch(
|
||||
// 计算backdrop图片地址
|
||||
const backdropUrl = computed(() => {
|
||||
const url = props.media?.backdrop || props.media?.poster
|
||||
// 使用图片缓存
|
||||
if (globalSettings.GLOBAL_IMAGE_CACHE && url)
|
||||
return `${import.meta.env.VITE_API_BASE_URL}system/cache/image?url=${encodeURIComponent(url)}`
|
||||
return url
|
||||
return getDisplayImageUrl(url || '', globalSettings.GLOBAL_IMAGE_CACHE)
|
||||
})
|
||||
|
||||
// 计算海报图片地址
|
||||
const posterUrl = computed(() => {
|
||||
const url = props.media?.poster
|
||||
// 使用图片缓存
|
||||
if (globalSettings.GLOBAL_IMAGE_CACHE && url)
|
||||
return `${import.meta.env.VITE_API_BASE_URL}system/cache/image?url=${encodeURIComponent(url)}`
|
||||
return url
|
||||
return getDisplayImageUrl(url || '', globalSettings.GLOBAL_IMAGE_CACHE)
|
||||
})
|
||||
|
||||
// 订阅编辑保存
|
||||
@@ -409,22 +405,21 @@ function handleCardClick() {
|
||||
<VHover>
|
||||
<template #default="hover">
|
||||
<div
|
||||
class="w-full h-full rounded-lg overflow-hidden relative"
|
||||
class="subscribe-card-shell w-full h-full relative"
|
||||
:class="{
|
||||
'transition transform-cpu duration-300 -translate-y-1': hover.isHovering && !props.sortable,
|
||||
'outline-dotted outline-pink-500 outline-2': props.batchMode && props.selected,
|
||||
'subscribe-card-pending-tint': subscribeState === 'P',
|
||||
}"
|
||||
>
|
||||
<VCard
|
||||
v-bind="hover.props"
|
||||
:key="props.media?.id"
|
||||
class="flex flex-col h-full"
|
||||
class="flex flex-col h-full overflow-hidden"
|
||||
:class="{
|
||||
'subscribe-card-paused': subscribeState === 'S',
|
||||
'subscribe-card-pending-tint': subscribeState === 'P',
|
||||
'cursor-move': props.sortable,
|
||||
}"
|
||||
rounded="0"
|
||||
min-height="150"
|
||||
@click="handleCardClick"
|
||||
:ripple="!props.batchMode && !props.sortable"
|
||||
@@ -484,7 +479,7 @@ function handleCardClick() {
|
||||
<div class="text-sm font-medium text-white sm:pt-1">{{ props.media?.year }}</div>
|
||||
<div class="mr-2 min-w-0 text-lg font-bold text-white text-ellipsis overflow-hidden line-clamp-2 ...">
|
||||
{{ props.media?.name }}
|
||||
{{ formatSeason(props.media?.season ? props.media?.season.toString() : '') }}
|
||||
{{ formatSeasonLabel(props.media?.season, t('media.specials')) }}
|
||||
</div>
|
||||
</div>
|
||||
</VCardText>
|
||||
@@ -511,10 +506,26 @@ function handleCardClick() {
|
||||
{{ subscribeProgressTooltip }}
|
||||
</VTooltip>
|
||||
</div>
|
||||
<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" />
|
||||
<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">
|
||||
<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>
|
||||
@@ -577,19 +588,20 @@ function handleCardClick() {
|
||||
}
|
||||
|
||||
/**
|
||||
* 待定:用 ::after 浮层在 VCard 之上渲染 sky 漫反射式内发光
|
||||
* 待定:内发光挂在实际 VCard 上,跟随卡片圆角并被 overflow-hidden 裁剪。
|
||||
*/
|
||||
.subscribe-card-pending-tint {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.subscribe-card-pending-tint::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
z-index: 3;
|
||||
border-radius: inherit;
|
||||
box-shadow: inset 0 0 48px rgba(56, 189, 248, 40%); // sky-400
|
||||
content: '';
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
border-radius: 8px;
|
||||
box-shadow: inset 0 0 48px rgba(56, 189, 248, 0.4); // sky-400
|
||||
z-index: 3;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -599,22 +611,23 @@ function handleCardClick() {
|
||||
*/
|
||||
.best-version-badge {
|
||||
position: absolute;
|
||||
top: 6px;
|
||||
left: 8px;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 50%;
|
||||
background: rgba(0, 0, 0, 0.75);
|
||||
z-index: 4;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 4;
|
||||
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.5);
|
||||
border-radius: 50%;
|
||||
backdrop-filter: blur(6px);
|
||||
background: rgba(0, 0, 0, 75%);
|
||||
block-size: 24px;
|
||||
box-shadow: 0 1px 4px rgba(0, 0, 0, 50%);
|
||||
inline-size: 24px;
|
||||
inset-block-start: 6px;
|
||||
inset-inline-start: 8px;
|
||||
}
|
||||
|
||||
.best-version-badge-full {
|
||||
background: rgba(255, 255, 255, 0.22);
|
||||
backdrop-filter: blur(10px);
|
||||
box-shadow: 0 2px 8px rgba(255, 255, 255, 0.15);
|
||||
background: rgba(255, 255, 255, 22%);
|
||||
box-shadow: 0 2px 8px rgba(255, 255, 255, 15%);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -4,6 +4,7 @@ import type { SubscribeShare } from '@/api/types'
|
||||
import router from '@/router'
|
||||
import { useGlobalSettingsStore } from '@/stores'
|
||||
import { openSharedDialog } from '@/composables/useSharedDialog'
|
||||
import { getDisplayImageUrl } from '@/utils/imageUtils'
|
||||
|
||||
const ForkSubscribeDialog = defineAsyncComponent(() => import('../dialog/ForkSubscribeDialog.vue'))
|
||||
const SubscribeEditDialog = defineAsyncComponent(() => import('../dialog/SubscribeEditDialog.vue'))
|
||||
@@ -35,19 +36,13 @@ const dateText = ref(props.media && props.media?.date ? formatDateDifference(pro
|
||||
// 计算backdrop图片地址
|
||||
const backdropUrl = computed(() => {
|
||||
const url = props.media?.backdrop || props.media?.poster
|
||||
// 使用图片缓存
|
||||
if (globalSettings.GLOBAL_IMAGE_CACHE && url)
|
||||
return `${import.meta.env.VITE_API_BASE_URL}system/cache/image?url=${encodeURIComponent(url)}`
|
||||
return url
|
||||
return getDisplayImageUrl(url || '', globalSettings.GLOBAL_IMAGE_CACHE)
|
||||
})
|
||||
|
||||
// 计算海报图片地址
|
||||
const posterUrl = computed(() => {
|
||||
const url = props.media?.poster
|
||||
// 使用图片缓存
|
||||
if (globalSettings.GLOBAL_IMAGE_CACHE && url)
|
||||
return `${import.meta.env.VITE_API_BASE_URL}system/cache/image?url=${encodeURIComponent(url)}`
|
||||
return url
|
||||
return getDisplayImageUrl(url || '', globalSettings.GLOBAL_IMAGE_CACHE)
|
||||
})
|
||||
|
||||
// 获得mediaid
|
||||
@@ -99,7 +94,7 @@ function doDelete() {
|
||||
<VHover>
|
||||
<template #default="hover">
|
||||
<div
|
||||
class="w-full h-full rounded-lg overflow-hidden"
|
||||
class="w-full h-full overflow-hidden"
|
||||
:class="{
|
||||
'transition transform-cpu duration-300 -translate-y-1': hover.isHovering,
|
||||
}"
|
||||
@@ -108,7 +103,6 @@ function doDelete() {
|
||||
v-bind="hover.props"
|
||||
:key="props.media?.id"
|
||||
class="flex flex-col h-full"
|
||||
rounded="0"
|
||||
min-height="150"
|
||||
@click="showForkSubscribe"
|
||||
>
|
||||
|
||||
213
src/components/cards/SubtitleCard.vue
Normal file
213
src/components/cards/SubtitleCard.vue
Normal file
@@ -0,0 +1,213 @@
|
||||
<script lang="ts" setup>
|
||||
import type { PropType } from 'vue'
|
||||
import { formatDateDifference, formatFileSize } from '@/@core/utils/formatters'
|
||||
import api from '@/api'
|
||||
import type { SubtitleInfo } from '@/api/types'
|
||||
import { getCachedSiteIcon } from '@/utils/siteIconCache'
|
||||
import { downloadedSubtitleMap, markSubtitleDownloaded } from '@/utils/subtitleDownloadCache'
|
||||
import { openSharedDialog } from '@/composables/useSharedDialog'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
const AddSubtitleDownloadDialog = defineAsyncComponent(() => import('../dialog/AddSubtitleDownloadDialog.vue'))
|
||||
|
||||
// 多语言支持
|
||||
const { t } = useI18n()
|
||||
|
||||
// 输入参数
|
||||
const props = defineProps({
|
||||
subtitle: Object as PropType<SubtitleInfo>,
|
||||
width: String,
|
||||
})
|
||||
|
||||
// 字幕信息
|
||||
const subtitle = ref(props.subtitle)
|
||||
|
||||
// 站点图标
|
||||
const siteIcon = ref('')
|
||||
|
||||
const isDownloaded = computed(() => Boolean(subtitle.value?.enclosure && downloadedSubtitleMap[subtitle.value.enclosure]))
|
||||
|
||||
// 查询站点图标
|
||||
async function getSiteIcon() {
|
||||
if (!subtitle.value?.site) {
|
||||
siteIcon.value = ''
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
siteIcon.value = await getCachedSiteIcon(subtitle.value.site, async () => {
|
||||
try {
|
||||
const response = await api.get(`site/icon/${subtitle.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)
|
||||
siteIcon.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
// 添加字幕下载成功
|
||||
function addDownloadSuccess(url: string) {
|
||||
markSubtitleDownloaded(url)
|
||||
}
|
||||
|
||||
// 添加字幕下载失败
|
||||
function addDownloadError(error: string) {
|
||||
console.error(error)
|
||||
}
|
||||
|
||||
// 询问并下载字幕
|
||||
async function handleAddDownload() {
|
||||
openSharedDialog(
|
||||
AddSubtitleDownloadDialog,
|
||||
{
|
||||
title: subtitle.value?.title,
|
||||
subtitle: subtitle.value,
|
||||
},
|
||||
{
|
||||
done: addDownloadSuccess,
|
||||
error: addDownloadError,
|
||||
},
|
||||
{ closeOn: ['close', 'done', 'error'] },
|
||||
)
|
||||
}
|
||||
|
||||
// 打开字幕详情页面
|
||||
function openSubtitleDetail() {
|
||||
if (!subtitle.value?.page_url) return
|
||||
window.open(subtitle.value.page_url, '_blank')
|
||||
}
|
||||
|
||||
// 打开字幕举报页面
|
||||
function openReportPage() {
|
||||
if (!subtitle.value?.report_url) return
|
||||
window.open(subtitle.value.report_url, '_blank')
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.subtitle,
|
||||
value => {
|
||||
subtitle.value = value
|
||||
getSiteIcon()
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="h-full">
|
||||
<VCard
|
||||
:width="props.width || '100%'"
|
||||
:variant="isDownloaded ? 'outlined' : 'flat'"
|
||||
@click="handleAddDownload"
|
||||
class="h-full cursor-pointer transition-transform hover:-translate-y-1 duration-300 d-flex flex-column overflow-hidden subtitle-card"
|
||||
:class="{ 'border-success border-2 opacity-85': isDownloaded }"
|
||||
hover
|
||||
>
|
||||
<VCardItem class="pt-3 pb-0">
|
||||
<div class="d-flex justify-space-between align-center flex-wrap gap-2 mb-2">
|
||||
<div class="d-flex align-center min-w-0">
|
||||
<VImg
|
||||
v-if="siteIcon"
|
||||
:src="siteIcon"
|
||||
:alt="subtitle?.site_name"
|
||||
class="mr-2 rounded"
|
||||
width="20"
|
||||
height="20"
|
||||
/>
|
||||
<VAvatar v-else size="20" class="mr-2 text-caption bg-surface-variant" color="surface-variant">
|
||||
{{ subtitle?.site_name?.substring(0, 1) }}
|
||||
</VAvatar>
|
||||
<span class="font-weight-bold text-body-2 text-truncate">{{ subtitle?.site_name }}</span>
|
||||
</div>
|
||||
|
||||
<div class="d-flex align-center gap-2">
|
||||
<VChip v-if="subtitle?.season_episode" size="x-small" color="secondary" variant="tonal" class="rounded-sm">
|
||||
{{ subtitle.season_episode }}
|
||||
</VChip>
|
||||
<VChip v-if="subtitle?.language" size="x-small" color="info" variant="tonal" class="rounded-sm">
|
||||
<VImg
|
||||
v-if="subtitle?.language_icon"
|
||||
:src="subtitle.language_icon"
|
||||
:alt="subtitle.language"
|
||||
width="14"
|
||||
height="14"
|
||||
class="me-1"
|
||||
/>
|
||||
{{ subtitle.language }}
|
||||
</VChip>
|
||||
<VChip v-if="isDownloaded" size="x-small" color="success" variant="tonal" class="rounded-sm">
|
||||
{{ t('dialog.addSubtitleDownload.downloaded') }}
|
||||
</VChip>
|
||||
</div>
|
||||
</div>
|
||||
</VCardItem>
|
||||
|
||||
<VCardText class="d-flex flex-column flex-grow-1 pa-3 overflow-hidden">
|
||||
<div class="text-subtitle-2 text-high-emphasis font-weight-medium mb-2 break-all" :title="subtitle?.title">
|
||||
{{ subtitle?.title }}
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="subtitle?.description"
|
||||
class="text-body-2 text-medium-emphasis mb-2 break-all"
|
||||
:title="subtitle?.description"
|
||||
>
|
||||
{{ subtitle.description }}
|
||||
</div>
|
||||
|
||||
<div class="d-flex flex-wrap align-center gap-2 mb-2">
|
||||
<span v-if="subtitle?.pubdate || subtitle?.date_elapsed" class="d-flex align-center text-sm text-medium-emphasis">
|
||||
<VIcon size="small" color="grey" icon="mdi-clock-outline" class="me-1"></VIcon>
|
||||
{{ subtitle?.date_elapsed || formatDateDifference(subtitle.pubdate || '') }}
|
||||
</span>
|
||||
<span v-if="subtitle?.grabs !== undefined" class="d-flex align-center text-sm text-medium-emphasis">
|
||||
<VIcon size="small" color="primary" icon="mdi-download-outline" class="me-1"></VIcon>
|
||||
{{ subtitle.grabs }}
|
||||
</span>
|
||||
<span v-if="subtitle?.uploader" class="d-flex align-center text-sm text-medium-emphasis">
|
||||
<VIcon size="small" color="grey" icon="mdi-account-outline" class="me-1"></VIcon>
|
||||
{{ subtitle.uploader }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="d-flex flex-wrap gap-1">
|
||||
<VChip v-if="subtitle?.torrent_id" size="x-small" variant="tonal" class="rounded-sm">
|
||||
TID {{ subtitle.torrent_id }}
|
||||
</VChip>
|
||||
<VChip v-if="subtitle?.subtitle_id" size="x-small" variant="tonal" class="rounded-sm">
|
||||
SID {{ subtitle.subtitle_id }}
|
||||
</VChip>
|
||||
</div>
|
||||
</VCardText>
|
||||
|
||||
<VCardActions class="border-t border-opacity-10 mt-auto pa-2">
|
||||
<VChip v-if="subtitle?.size" color="primary" size="x-small" variant="elevated" class="rounded-sm">
|
||||
{{ formatFileSize(subtitle.size) }}
|
||||
</VChip>
|
||||
<VSpacer />
|
||||
<VBtn v-if="subtitle?.report_url" icon size="small" variant="text" color="warning" @click.stop="openReportPage">
|
||||
<VIcon icon="mdi-alert-outline"></VIcon>
|
||||
</VBtn>
|
||||
<VBtn v-if="subtitle?.page_url" icon size="small" variant="text" color="primary" @click.stop="openSubtitleDetail">
|
||||
<VIcon icon="mdi-information-outline"></VIcon>
|
||||
</VBtn>
|
||||
</VCardActions>
|
||||
</VCard>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.subtitle-card {
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
|
||||
.subtitle-card:hover {
|
||||
border-color: rgba(var(--v-theme-primary), 0.3);
|
||||
}
|
||||
</style>
|
||||
216
src/components/cards/SubtitleItem.vue
Normal file
216
src/components/cards/SubtitleItem.vue
Normal file
@@ -0,0 +1,216 @@
|
||||
<script lang="ts" setup>
|
||||
import type { PropType } from 'vue'
|
||||
import { formatDateDifference, formatFileSize } from '@/@core/utils/formatters'
|
||||
import api from '@/api'
|
||||
import type { SubtitleInfo } from '@/api/types'
|
||||
import { getCachedSiteIcon } from '@/utils/siteIconCache'
|
||||
import { downloadedSubtitleMap, markSubtitleDownloaded } from '@/utils/subtitleDownloadCache'
|
||||
import { openSharedDialog } from '@/composables/useSharedDialog'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
const AddSubtitleDownloadDialog = defineAsyncComponent(() => import('../dialog/AddSubtitleDownloadDialog.vue'))
|
||||
|
||||
// 多语言支持
|
||||
const { t } = useI18n()
|
||||
|
||||
// 输入参数
|
||||
const props = defineProps({
|
||||
subtitle: Object as PropType<SubtitleInfo>,
|
||||
})
|
||||
|
||||
// 字幕信息
|
||||
const subtitle = ref(props.subtitle)
|
||||
|
||||
// 站点图标
|
||||
const siteIcon = ref('')
|
||||
|
||||
const isDownloaded = computed(() => Boolean(subtitle.value?.enclosure && downloadedSubtitleMap[subtitle.value.enclosure]))
|
||||
|
||||
// 查询站点图标
|
||||
async function getSiteIcon() {
|
||||
if (!subtitle.value?.site) {
|
||||
siteIcon.value = ''
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
siteIcon.value = await getCachedSiteIcon(subtitle.value.site, async () => {
|
||||
try {
|
||||
const response = await api.get(`site/icon/${subtitle.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)
|
||||
siteIcon.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
// 询问并下载字幕
|
||||
async function handleAddDownload() {
|
||||
openSharedDialog(
|
||||
AddSubtitleDownloadDialog,
|
||||
{
|
||||
title: subtitle.value?.title,
|
||||
subtitle: subtitle.value,
|
||||
},
|
||||
{
|
||||
done: addDownloadSuccess,
|
||||
error: addDownloadError,
|
||||
},
|
||||
{ closeOn: ['close', 'done', 'error'] },
|
||||
)
|
||||
}
|
||||
|
||||
// 添加字幕下载成功
|
||||
function addDownloadSuccess(url: string) {
|
||||
markSubtitleDownloaded(url)
|
||||
}
|
||||
|
||||
// 添加字幕下载失败
|
||||
function addDownloadError(error: string) {
|
||||
console.error(error)
|
||||
}
|
||||
|
||||
// 打开字幕详情页面
|
||||
function openSubtitleDetail() {
|
||||
if (!subtitle.value?.page_url) return
|
||||
window.open(subtitle.value.page_url, '_blank')
|
||||
}
|
||||
|
||||
// 打开字幕举报页面
|
||||
function openReportPage() {
|
||||
if (!subtitle.value?.report_url) return
|
||||
window.open(subtitle.value.report_url, '_blank')
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.subtitle,
|
||||
value => {
|
||||
subtitle.value = value
|
||||
getSiteIcon()
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="w-100">
|
||||
<VListItem
|
||||
:value="subtitle?.enclosure"
|
||||
class="pa-3 mb-2 rounded subtitle-item transition-all duration-300 hover:-translate-y-1 overflow-hidden"
|
||||
:class="{ 'border-start border-success border-3 opacity-85': isDownloaded }"
|
||||
@click="handleAddDownload"
|
||||
>
|
||||
<template #prepend>
|
||||
<div class="d-flex flex-column align-center pr-3" :title="subtitle?.site_name">
|
||||
<VImg
|
||||
v-if="siteIcon"
|
||||
:src="siteIcon"
|
||||
:alt="subtitle?.site_name"
|
||||
class="rounded mb-1 site-icon"
|
||||
width="32"
|
||||
height="32"
|
||||
/>
|
||||
<VAvatar
|
||||
v-else
|
||||
size="32"
|
||||
class="mb-1 text-caption bg-primary-lighten-4 text-primary font-weight-bold site-icon"
|
||||
>
|
||||
{{ subtitle?.site_name?.substring(0, 1) }}
|
||||
</VAvatar>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<VListItemTitle class="whitespace-normal">
|
||||
<div class="d-flex flex-row flex-wrap align-center gap-2 mb-2">
|
||||
<span class="text-h6 font-weight-bold me-1">{{ subtitle?.site_name }}</span>
|
||||
<VChip v-if="subtitle?.season_episode" size="x-small" color="secondary" variant="tonal" class="rounded-sm">
|
||||
{{ subtitle.season_episode }}
|
||||
</VChip>
|
||||
<VChip v-if="subtitle?.language" size="x-small" color="info" variant="tonal" class="rounded-sm">
|
||||
<VImg
|
||||
v-if="subtitle?.language_icon"
|
||||
:src="subtitle.language_icon"
|
||||
:alt="subtitle.language"
|
||||
width="14"
|
||||
height="14"
|
||||
class="me-1"
|
||||
/>
|
||||
{{ subtitle.language }}
|
||||
</VChip>
|
||||
<VChip v-if="isDownloaded" size="x-small" color="success" variant="tonal" class="rounded-sm">
|
||||
{{ t('dialog.addSubtitleDownload.downloaded') }}
|
||||
</VChip>
|
||||
</div>
|
||||
|
||||
<div class="text-subtitle-2 font-weight-medium mb-2 break-all" :title="subtitle?.title">
|
||||
{{ subtitle?.title }}
|
||||
</div>
|
||||
|
||||
<div v-if="subtitle?.description" class="text-body-2 text-medium-emphasis mb-2 break-all" :title="subtitle.description">
|
||||
{{ subtitle.description }}
|
||||
</div>
|
||||
|
||||
<div class="d-flex flex-wrap gap-2 mb-2">
|
||||
<span v-if="subtitle?.pubdate || subtitle?.date_elapsed" class="d-flex align-center text-sm text-medium-emphasis">
|
||||
<VIcon size="small" color="grey" icon="mdi-clock-outline" class="me-1"></VIcon>
|
||||
{{ subtitle?.date_elapsed || formatDateDifference(subtitle.pubdate || '') }}
|
||||
</span>
|
||||
<span v-if="subtitle?.grabs !== undefined" class="d-flex align-center text-sm text-medium-emphasis">
|
||||
<VIcon size="small" color="primary" icon="mdi-download-outline" class="me-1"></VIcon>
|
||||
{{ subtitle.grabs }}
|
||||
</span>
|
||||
<span v-if="subtitle?.uploader" class="d-flex align-center text-sm text-medium-emphasis">
|
||||
<VIcon size="small" color="grey" icon="mdi-account-outline" class="me-1"></VIcon>
|
||||
{{ subtitle.uploader }}
|
||||
</span>
|
||||
</div>
|
||||
</VListItemTitle>
|
||||
|
||||
<template #append>
|
||||
<div class="d-flex flex-column align-end gap-2">
|
||||
<VChip v-if="subtitle?.size" color="primary" size="x-small" variant="elevated" class="rounded-sm">
|
||||
{{ formatFileSize(subtitle.size) }}
|
||||
</VChip>
|
||||
<div class="d-flex align-center">
|
||||
<VBtn
|
||||
v-if="subtitle?.report_url"
|
||||
icon
|
||||
size="small"
|
||||
variant="text"
|
||||
color="warning"
|
||||
@click.stop="openReportPage"
|
||||
>
|
||||
<VIcon icon="mdi-alert-outline"></VIcon>
|
||||
</VBtn>
|
||||
<VBtn
|
||||
v-if="subtitle?.page_url"
|
||||
icon
|
||||
size="small"
|
||||
variant="text"
|
||||
color="primary"
|
||||
@click.stop="openSubtitleDetail"
|
||||
>
|
||||
<VIcon icon="mdi-information-outline"></VIcon>
|
||||
</VBtn>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</VListItem>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.subtitle-item {
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
|
||||
.subtitle-item:hover {
|
||||
border-color: rgba(var(--v-theme-primary), 0.3);
|
||||
}
|
||||
</style>
|
||||
@@ -95,52 +95,59 @@ function doDelete() {
|
||||
<div class="h-full">
|
||||
<VHover>
|
||||
<template #default="hover">
|
||||
<div
|
||||
class="w-full h-full rounded-lg overflow-hidden"
|
||||
<VCard
|
||||
v-bind="hover.props"
|
||||
:key="props.workflow?.id"
|
||||
class="workflow-share-card flex flex-col h-full cursor-pointer overflow-hidden"
|
||||
:class="{
|
||||
'transition transform-cpu duration-300 -translate-y-1': hover.isHovering,
|
||||
'workflow-share-card--hovering': hover.isHovering,
|
||||
}"
|
||||
min-height="150"
|
||||
:style="{ background: gradientStyle }"
|
||||
@click="showForkWorkflow"
|
||||
>
|
||||
<VCard
|
||||
v-bind="hover.props"
|
||||
:key="props.workflow?.id"
|
||||
class="flex flex-col h-full"
|
||||
rounded="0"
|
||||
min-height="150"
|
||||
:style="{ background: gradientStyle }"
|
||||
@click="showForkWorkflow"
|
||||
>
|
||||
<div class="h-full flex flex-col">
|
||||
<VCardText class="flex items-center pa-3 pb-1 grow">
|
||||
<div class="flex flex-col justify-center w-full">
|
||||
<VCardTitle class="text-lg text-bold text-white line-clamp-2 break-words">
|
||||
{{ props.workflow?.share_title }}
|
||||
</VCardTitle>
|
||||
<div class="px-4 text-white text-opacity-90 overflow-hidden line-clamp-3 break-all ...">
|
||||
{{ props.workflow?.share_comment }}
|
||||
</div>
|
||||
<div class="h-full flex flex-col">
|
||||
<VCardText class="flex items-center pa-3 pb-1 grow">
|
||||
<div class="flex flex-col justify-center w-full">
|
||||
<VCardTitle class="text-lg text-bold text-white line-clamp-2 break-words">
|
||||
{{ props.workflow?.share_title }}
|
||||
</VCardTitle>
|
||||
<div class="px-4 text-white text-opacity-90 overflow-hidden line-clamp-3 break-all ...">
|
||||
{{ props.workflow?.share_comment }}
|
||||
</div>
|
||||
</VCardText>
|
||||
<VCardText class="flex justify-space-between align-center flex-wrap py-2">
|
||||
<div class="flex align-center">
|
||||
<IconBtn v-bind="props" icon="mdi-account" class="me-1 text-white" />
|
||||
<div class="text-subtitle-2 me-4 text-white text-opacity-90">
|
||||
{{ props.workflow?.share_user }}
|
||||
</div>
|
||||
<IconBtn v-if="props.workflow?.count" icon="mdi-fire" class="me-1 text-white" />
|
||||
<span v-if="props.workflow?.count" class="text-subtitle-2 me-4 text-white text-opacity-90">
|
||||
{{ props.workflow?.count.toLocaleString() }}
|
||||
</span>
|
||||
</div>
|
||||
</VCardText>
|
||||
<VCardText class="flex justify-space-between align-center flex-wrap py-2">
|
||||
<div class="flex align-center">
|
||||
<IconBtn v-bind="props" icon="mdi-account" class="me-1 text-white" />
|
||||
<div class="text-subtitle-2 me-4 text-white text-opacity-90">
|
||||
{{ props.workflow?.share_user }}
|
||||
</div>
|
||||
</VCardText>
|
||||
<VCardText class="absolute right-0 bottom-0 d-flex align-center p-2 text-white text-sm text-opacity-75">
|
||||
<VIcon icon="mdi-calendar" size="small" class="me-1" />
|
||||
{{ dateText }}
|
||||
</VCardText>
|
||||
</div>
|
||||
</VCard>
|
||||
</div>
|
||||
<IconBtn v-if="props.workflow?.count" icon="mdi-fire" class="me-1 text-white" />
|
||||
<span v-if="props.workflow?.count" class="text-subtitle-2 me-4 text-white text-opacity-90">
|
||||
{{ props.workflow?.count.toLocaleString() }}
|
||||
</span>
|
||||
</div>
|
||||
</VCardText>
|
||||
<VCardText class="absolute right-0 bottom-0 d-flex align-center p-2 text-white text-sm text-opacity-75">
|
||||
<VIcon icon="mdi-calendar" size="small" class="me-1" />
|
||||
{{ dateText }}
|
||||
</VCardText>
|
||||
</div>
|
||||
</VCard>
|
||||
</template>
|
||||
</VHover>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
// 阴影需要落在实际卡片上,不能被额外的 overflow 容器裁掉。
|
||||
.workflow-share-card {
|
||||
transition: transform 0.3s ease, box-shadow 0.2s ease;
|
||||
transform: translateZ(0);
|
||||
}
|
||||
|
||||
.workflow-share-card--hovering {
|
||||
transform: translate3d(0, -0.25rem, 0);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script lang="ts" setup>
|
||||
import { formatDateDifference } from '@/@core/utils/formatters'
|
||||
import api from '@/api'
|
||||
import type { Process as SystemProcess } from '@/api/types'
|
||||
import { clearCacheAndReload } from '@/composables/useVersionChecker'
|
||||
import MarkdownIt from 'markdown-it'
|
||||
import mdLinkAttributes from 'markdown-it-link-attributes'
|
||||
@@ -37,6 +38,12 @@ md.use(mdLinkAttributes, {
|
||||
// 系统环境变量
|
||||
const systemEnv = ref<any>({})
|
||||
|
||||
// 系统运行时间的基准秒数和同步时间,用于在弹窗打开后实时递增展示。
|
||||
const systemUptimeBaseSeconds = ref<number | null>(null)
|
||||
const systemUptimeSyncedAt = ref(0)
|
||||
const systemUptimeNow = ref(Date.now())
|
||||
let systemUptimeTimer: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
// 所有Release
|
||||
const allRelease = ref<any>([])
|
||||
|
||||
@@ -102,6 +109,110 @@ const frontendVersionStatistics = computed(() => versionStatistic.value?.fronten
|
||||
// 活跃用户统计
|
||||
const activeUsers = computed(() => versionStatistic.value?.active_users ?? {})
|
||||
|
||||
// 系统运行秒数
|
||||
const systemUptimeSeconds = computed(() => {
|
||||
if (systemUptimeBaseSeconds.value === null) return null
|
||||
|
||||
const elapsedSeconds = Math.floor((systemUptimeNow.value - systemUptimeSyncedAt.value) / 1000)
|
||||
|
||||
return Math.max(0, systemUptimeBaseSeconds.value + elapsedSeconds)
|
||||
})
|
||||
|
||||
// 友好的系统运行时间文本
|
||||
const systemUptimeText = computed(() => {
|
||||
if (systemUptimeSeconds.value === null) return ''
|
||||
|
||||
return formatUptimeDuration(systemUptimeSeconds.value)
|
||||
})
|
||||
|
||||
/** 格式化版本安装统计数字为千分位展示。 */
|
||||
function formatVersionStatisticNumber(value: unknown) {
|
||||
const numberValue = Number(value ?? 0)
|
||||
|
||||
if (!Number.isFinite(numberValue)) return '0'
|
||||
|
||||
return numberValue.toLocaleString()
|
||||
}
|
||||
|
||||
/** 将秒数保存为运行时间基准,并记录本地同步时间。 */
|
||||
function syncSystemUptime(seconds: number | null) {
|
||||
if (seconds === null) return
|
||||
|
||||
const now = Date.now()
|
||||
|
||||
systemUptimeBaseSeconds.value = seconds
|
||||
systemUptimeSyncedAt.value = now
|
||||
systemUptimeNow.value = now
|
||||
}
|
||||
|
||||
/** 将接口返回值规范化为可展示的秒数。 */
|
||||
function normalizeUptimeSeconds(value: unknown) {
|
||||
const numberValue = Number(value)
|
||||
|
||||
if (!Number.isFinite(numberValue) || numberValue < 0) return null
|
||||
|
||||
return Math.floor(numberValue)
|
||||
}
|
||||
|
||||
/** 从进程创建时间推导运行秒数;兼容秒级和毫秒级时间戳。 */
|
||||
function uptimeSecondsFromCreateTime(value: unknown) {
|
||||
const timestamp = Number(value)
|
||||
|
||||
if (!Number.isFinite(timestamp) || timestamp <= 0) return null
|
||||
|
||||
const timestampMs = timestamp > 1_000_000_000_000 ? timestamp : timestamp * 1000
|
||||
|
||||
return Math.max(0, Math.floor((Date.now() - timestampMs) / 1000))
|
||||
}
|
||||
|
||||
/** 获取单个进程的运行秒数,优先使用创建时间以保留跨天运行时长。 */
|
||||
function getProcessUptimeSeconds(process: SystemProcess) {
|
||||
return uptimeSecondsFromCreateTime(process.create_time) ?? normalizeUptimeSeconds(process.run_time)
|
||||
}
|
||||
|
||||
/** 从进程列表中挑选 MoviePilot 主进程,找不到时使用运行时间最长的进程兜底。 */
|
||||
function resolveSystemUptimeSeconds(processes: SystemProcess[]) {
|
||||
const availableProcesses = processes
|
||||
.map(process => ({
|
||||
process,
|
||||
uptimeSeconds: getProcessUptimeSeconds(process),
|
||||
}))
|
||||
.filter((item): item is { process: SystemProcess; uptimeSeconds: number } => item.uptimeSeconds !== null)
|
||||
|
||||
if (!availableProcesses.length) return null
|
||||
|
||||
const preferredProcesses = availableProcesses.filter(({ process }) =>
|
||||
/moviepilot|python|uvicorn|gunicorn|hypercorn/i.test(process.name ?? ''),
|
||||
)
|
||||
const targetProcesses = preferredProcesses.length ? preferredProcesses : availableProcesses
|
||||
|
||||
return targetProcesses.reduce((max, item) => (item.uptimeSeconds > max.uptimeSeconds ? item : max)).uptimeSeconds
|
||||
}
|
||||
|
||||
/** 格式化单个运行时间单位。 */
|
||||
function formatUptimeUnit(value: number, unit: 'day' | 'hour' | 'minute' | 'second') {
|
||||
const unitKey = value === 1 ? unit : `${unit}s`
|
||||
|
||||
return t(`setting.about.uptimeUnits.${unitKey}`, { count: value })
|
||||
}
|
||||
|
||||
/** 将运行秒数格式化为两段以内的友好文本,例如“3天 2小时”。 */
|
||||
function formatUptimeDuration(totalSeconds: number) {
|
||||
const normalizedSeconds = Math.max(0, Math.floor(totalSeconds))
|
||||
const days = Math.floor(normalizedSeconds / 86400)
|
||||
const hours = Math.floor((normalizedSeconds % 86400) / 3600)
|
||||
const minutes = Math.floor((normalizedSeconds % 3600) / 60)
|
||||
const seconds = normalizedSeconds % 60
|
||||
const parts: string[] = []
|
||||
|
||||
if (days > 0) parts.push(formatUptimeUnit(days, 'day'))
|
||||
if (hours > 0) parts.push(formatUptimeUnit(hours, 'hour'))
|
||||
if (minutes > 0 && parts.length < 2) parts.push(formatUptimeUnit(minutes, 'minute'))
|
||||
if (!parts.length) parts.push(formatUptimeUnit(seconds, 'second'))
|
||||
|
||||
return parts.slice(0, 2).join(' ')
|
||||
}
|
||||
|
||||
// 打开日志对话框
|
||||
function showReleaseDialog(title: string, body: string) {
|
||||
releaseDialogTitle.value = title
|
||||
@@ -142,6 +253,17 @@ async function querySystemEnv() {
|
||||
}
|
||||
}
|
||||
|
||||
// 查询系统运行时间
|
||||
async function querySystemUptime() {
|
||||
try {
|
||||
const processes: SystemProcess[] = await api.get('dashboard/processes')
|
||||
|
||||
syncSystemUptime(resolveSystemUptimeSeconds(processes))
|
||||
} catch (error) {
|
||||
console.log(error)
|
||||
}
|
||||
}
|
||||
|
||||
// 查询所有Release
|
||||
async function queryAllRelease() {
|
||||
try {
|
||||
@@ -183,8 +305,17 @@ async function clearCache() {
|
||||
|
||||
onMounted(() => {
|
||||
querySystemEnv()
|
||||
querySystemUptime()
|
||||
queryAllRelease()
|
||||
querySupportingSites()
|
||||
|
||||
systemUptimeTimer = setInterval(() => {
|
||||
if (systemUptimeBaseSeconds.value !== null) systemUptimeNow.value = Date.now()
|
||||
}, 1000)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (systemUptimeTimer) clearInterval(systemUptimeTimer)
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -312,6 +443,16 @@ onMounted(() => {
|
||||
</dd>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="systemUptimeText">
|
||||
<div class="max-w-6xl py-4 sm:grid sm:grid-cols-3 sm:gap-4">
|
||||
<dt class="block text-sm font-bold">{{ t('setting.about.systemUptime') }}</dt>
|
||||
<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">{{ systemUptimeText }}</code>
|
||||
</span>
|
||||
</dd>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="max-w-6xl py-4 sm:grid sm:grid-cols-3 sm:gap-4">
|
||||
<dt class="block text-sm font-bold">{{ t('setting.about.supportingSites') }}</dt>
|
||||
@@ -473,19 +614,19 @@ onMounted(() => {
|
||||
<div class="version-stat-summary">
|
||||
<div>
|
||||
<div class="text-caption text-medium-emphasis">{{ t('setting.about.totalInstallUsers') }}</div>
|
||||
<div class="version-stat-number">{{ versionStatistic.total_users ?? 0 }}</div>
|
||||
<div class="version-stat-number">{{ formatVersionStatisticNumber(versionStatistic.total_users) }}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-caption text-medium-emphasis">{{ t('setting.about.activeToday') }}</div>
|
||||
<div class="version-stat-number">{{ activeUsers.today ?? 0 }}</div>
|
||||
<div class="version-stat-number">{{ formatVersionStatisticNumber(activeUsers.today) }}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-caption text-medium-emphasis">{{ t('setting.about.active7Days') }}</div>
|
||||
<div class="version-stat-number">{{ activeUsers.last_7_days ?? 0 }}</div>
|
||||
<div class="version-stat-number">{{ formatVersionStatisticNumber(activeUsers.last_7_days) }}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-caption text-medium-emphasis">{{ t('setting.about.active30Days') }}</div>
|
||||
<div class="version-stat-number">{{ activeUsers.last_30_days ?? 0 }}</div>
|
||||
<div class="version-stat-number">{{ formatVersionStatisticNumber(activeUsers.last_30_days) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-5">
|
||||
@@ -502,7 +643,7 @@ onMounted(() => {
|
||||
<td>
|
||||
<code>{{ item.version }}</code>
|
||||
</td>
|
||||
<td class="text-end">{{ item.count }}</td>
|
||||
<td class="text-end">{{ formatVersionStatisticNumber(item.count) }}</td>
|
||||
</tr>
|
||||
<tr v-if="!backendVersionStatistics.length">
|
||||
<td colspan="2" class="text-medium-emphasis">{{ t('setting.about.noVersionStatisticData') }}</td>
|
||||
@@ -524,7 +665,7 @@ onMounted(() => {
|
||||
<td>
|
||||
<code>{{ item.version }}</code>
|
||||
</td>
|
||||
<td class="text-end">{{ item.count }}</td>
|
||||
<td class="text-end">{{ formatVersionStatisticNumber(item.count) }}</td>
|
||||
</tr>
|
||||
<tr v-if="!frontendVersionStatistics.length">
|
||||
<td colspan="2" class="text-medium-emphasis">{{ t('setting.about.noVersionStatisticData') }}</td>
|
||||
|
||||
@@ -71,7 +71,7 @@ const buttonText = computed(() =>
|
||||
// 加载目录设置
|
||||
async function loadDirectories() {
|
||||
try {
|
||||
const result: { [key: string]: any } = await api.get('system/setting/Directories')
|
||||
const result: { [key: string]: any } = await api.get('system/setting/public/Directories')
|
||||
directories.value = result.data?.value ?? []
|
||||
} catch (error) {
|
||||
console.log(error)
|
||||
|
||||
270
src/components/dialog/AddSubtitleDownloadDialog.vue
Normal file
270
src/components/dialog/AddSubtitleDownloadDialog.vue
Normal file
@@ -0,0 +1,270 @@
|
||||
<script setup lang="ts">
|
||||
import { useToast } from 'vue-toastification'
|
||||
import api from '@/api'
|
||||
import { doneNProgress, startNProgress } from '@/api/nprogress'
|
||||
import type { SubtitleInfo, TransferDirectoryConf } from '@/api/types'
|
||||
import { formatFileSize } from '@/@core/utils/formatters'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import MediaIdSelector from '../misc/MediaIdSelector.vue'
|
||||
import { numberValidator } from '@/@validators'
|
||||
import { useGlobalSettingsStore } from '@/stores'
|
||||
|
||||
// 多语言支持
|
||||
const { t } = useI18n()
|
||||
|
||||
// 从 provide 中获取全局设置
|
||||
const globalSettingsStore = useGlobalSettingsStore()
|
||||
const globalSettings = globalSettingsStore.globalSettings
|
||||
|
||||
// 当前识别类型
|
||||
const mediaSource = ref(globalSettings.RECOGNIZE_SOURCE || 'themoviedb')
|
||||
|
||||
// 输入参数
|
||||
const props = defineProps({
|
||||
title: String,
|
||||
subtitle: Object as PropType<SubtitleInfo>,
|
||||
})
|
||||
|
||||
// 定义成功和失败事件
|
||||
const emit = defineEmits(['done', 'error', 'close'])
|
||||
|
||||
// 提示框
|
||||
const $toast = useToast()
|
||||
|
||||
// 选择的保存目录
|
||||
const selectedDirectory = ref<string | null>(null)
|
||||
|
||||
// 所有目录设置
|
||||
const directories = ref<TransferDirectoryConf[]>([])
|
||||
|
||||
// 是否正在加载
|
||||
const loading = ref(false)
|
||||
|
||||
// 是否显示高级选项
|
||||
const showAdvancedOptions = ref(false)
|
||||
|
||||
// TMDB ID
|
||||
const tmdbid = ref<number | undefined>(undefined)
|
||||
|
||||
// 豆瓣ID
|
||||
const doubanId = ref<string | undefined>(undefined)
|
||||
|
||||
// TMDB选择对话框
|
||||
const mediaSelectorDialog = ref(false)
|
||||
|
||||
// 计算按钮图标
|
||||
const icon = computed(() => (loading.value ? 'mdi-progress-download' : 'mdi-download'))
|
||||
|
||||
// 计算按钮文字
|
||||
const buttonText = computed(() =>
|
||||
loading.value ? t('dialog.addSubtitleDownload.downloading') : t('dialog.addSubtitleDownload.startDownload'),
|
||||
)
|
||||
|
||||
// 加载目录设置
|
||||
async function loadDirectories() {
|
||||
try {
|
||||
const result: { [key: string]: any } = await api.get('system/setting/public/Directories')
|
||||
directories.value = result.data?.value ?? []
|
||||
} catch (error) {
|
||||
console.log(error)
|
||||
}
|
||||
}
|
||||
|
||||
function convertToUri(item: TransferDirectoryConf) {
|
||||
if (!item.download_path) {
|
||||
return undefined
|
||||
}
|
||||
if (item.storage === 'local') {
|
||||
return item.download_path
|
||||
}
|
||||
return item.storage + ':' + item.download_path
|
||||
}
|
||||
|
||||
// 获取保存目录
|
||||
const targetDirectories = computed(() => {
|
||||
const downloadDirectories = directories.value
|
||||
.map(item => convertToUri(item))
|
||||
.filter((item): item is string => item !== undefined)
|
||||
return [...new Set(downloadDirectories)]
|
||||
})
|
||||
|
||||
// 下载字幕
|
||||
async function addSubtitleDownload() {
|
||||
startNProgress()
|
||||
loading.value = true
|
||||
try {
|
||||
const payload: any = {
|
||||
subtitle_in: props.subtitle,
|
||||
save_path: selectedDirectory.value,
|
||||
}
|
||||
|
||||
if (tmdbid.value) {
|
||||
payload.tmdbid = tmdbid.value
|
||||
}
|
||||
if (doubanId.value) {
|
||||
payload.doubanid = doubanId.value
|
||||
}
|
||||
|
||||
const result: { [key: string]: any } = await api.post('download/subtitle', payload)
|
||||
|
||||
if (result && result.success) {
|
||||
$toast.success(
|
||||
t('dialog.addSubtitleDownload.downloadSuccess', {
|
||||
site: props.subtitle?.site_name,
|
||||
title: props.subtitle?.title,
|
||||
}),
|
||||
)
|
||||
emit('done', props.subtitle?.enclosure)
|
||||
} else {
|
||||
$toast.error(
|
||||
t('dialog.addSubtitleDownload.downloadFailed', {
|
||||
site: props.subtitle?.site_name,
|
||||
title: props.subtitle?.title,
|
||||
message: result?.message,
|
||||
}),
|
||||
)
|
||||
emit('error', result?.message)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
emit('error', String(error))
|
||||
}
|
||||
loading.value = false
|
||||
doneNProgress()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadDirectories()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VDialog max-width="35rem" scrollable>
|
||||
<VCard>
|
||||
<VCardItem class="py-2">
|
||||
<template #prepend>
|
||||
<VIcon icon="mdi-subtitles-outline" class="me-2" />
|
||||
</template>
|
||||
<VCardTitle>{{ t('dialog.addSubtitleDownload.confirmDownload') }}</VCardTitle>
|
||||
<VCardSubtitle>{{ subtitle?.site_name }} - {{ title }}</VCardSubtitle>
|
||||
</VCardItem>
|
||||
<VDialogCloseBtn @click="emit('close')" />
|
||||
<VDivider />
|
||||
<VCardText>
|
||||
<VList lines="one">
|
||||
<VListItem>
|
||||
<template #prepend>
|
||||
<VIcon icon="mdi-web"></VIcon>
|
||||
</template>
|
||||
<VListItemTitle>
|
||||
<span class="whitespace-break-spaces me-2">{{ subtitle?.title }}</span>
|
||||
</VListItemTitle>
|
||||
</VListItem>
|
||||
<VListItem v-if="subtitle?.description">
|
||||
<template #prepend>
|
||||
<VIcon icon="mdi-text-box-outline"></VIcon>
|
||||
</template>
|
||||
<VListItemTitle>
|
||||
<span class="text-body-2 whitespace-break-spaces">{{ subtitle?.description }}</span>
|
||||
</VListItemTitle>
|
||||
</VListItem>
|
||||
<VListItem v-if="subtitle?.language || subtitle?.uploader">
|
||||
<template #prepend>
|
||||
<VIcon icon="mdi-translate"></VIcon>
|
||||
</template>
|
||||
<VListItemTitle>
|
||||
<span class="text-body-2">
|
||||
{{ subtitle?.language || t('common.unknown') }}
|
||||
<span v-if="subtitle?.uploader" class="text-medium-emphasis ms-2">{{ subtitle.uploader }}</span>
|
||||
</span>
|
||||
</VListItemTitle>
|
||||
</VListItem>
|
||||
<VListItem v-if="subtitle?.size">
|
||||
<template #prepend>
|
||||
<VIcon icon="mdi-database"></VIcon>
|
||||
</template>
|
||||
<VListItemTitle>
|
||||
<VChip variant="tonal" label>
|
||||
{{ formatFileSize(subtitle?.size || 0) }}
|
||||
</VChip>
|
||||
</VListItemTitle>
|
||||
</VListItem>
|
||||
</VList>
|
||||
<VRow class="px-5">
|
||||
<VCol cols="12">
|
||||
<VCombobox
|
||||
v-model="selectedDirectory"
|
||||
:items="targetDirectories"
|
||||
:label="t('dialog.addSubtitleDownload.saveDirectory')"
|
||||
:placeholder="t('dialog.addSubtitleDownload.autoPlaceholder')"
|
||||
variant="underlined"
|
||||
density="comfortable"
|
||||
prepend-inner-icon="mdi-folder"
|
||||
/>
|
||||
</VCol>
|
||||
</VRow>
|
||||
<VRow class="px-5 mt-2">
|
||||
<VCol cols="12">
|
||||
<VBtn
|
||||
variant="text"
|
||||
:prepend-icon="showAdvancedOptions ? 'mdi-chevron-up' : 'mdi-chevron-down'"
|
||||
@click="showAdvancedOptions = !showAdvancedOptions"
|
||||
>
|
||||
{{
|
||||
showAdvancedOptions
|
||||
? t('dialog.addDownload.hideAdvancedOptions')
|
||||
: t('dialog.addDownload.showAdvancedOptions')
|
||||
}}
|
||||
</VBtn>
|
||||
</VCol>
|
||||
</VRow>
|
||||
<VRow v-show="showAdvancedOptions" class="px-5">
|
||||
<VCol cols="12">
|
||||
<VTextField
|
||||
v-if="mediaSource === 'themoviedb'"
|
||||
v-model="tmdbid"
|
||||
:label="t('dialog.reorganize.tmdbId')"
|
||||
:placeholder="t('dialog.reorganize.mediaIdPlaceholder')"
|
||||
:rules="[numberValidator]"
|
||||
append-inner-icon="mdi-magnify"
|
||||
:hint="t('dialog.reorganize.mediaIdHint')"
|
||||
persistent-hint
|
||||
prepend-inner-icon="mdi-identifier"
|
||||
variant="underlined"
|
||||
density="comfortable"
|
||||
@click:append-inner="mediaSelectorDialog = true"
|
||||
/>
|
||||
<VTextField
|
||||
v-else
|
||||
v-model="doubanId"
|
||||
:label="t('dialog.reorganize.doubanId')"
|
||||
:placeholder="t('dialog.reorganize.mediaIdPlaceholder')"
|
||||
:rules="[numberValidator]"
|
||||
append-inner-icon="mdi-magnify"
|
||||
:hint="t('dialog.reorganize.mediaIdHint')"
|
||||
persistent-hint
|
||||
prepend-inner-icon="mdi-identifier"
|
||||
variant="underlined"
|
||||
density="comfortable"
|
||||
@click:append-inner="mediaSelectorDialog = true"
|
||||
/>
|
||||
</VCol>
|
||||
</VRow>
|
||||
</VCardText>
|
||||
<VCardText class="text-center">
|
||||
<VBtn variant="elevated" :disabled="loading" @click="addSubtitleDownload" :prepend-icon="icon" class="px-5">
|
||||
{{ buttonText }}
|
||||
</VBtn>
|
||||
</VCardText>
|
||||
</VCard>
|
||||
<VDialog v-model="mediaSelectorDialog" width="40rem" scrollable max-height="85vh">
|
||||
<MediaIdSelector
|
||||
v-if="mediaSource === 'themoviedb'"
|
||||
v-model="tmdbid"
|
||||
@close="mediaSelectorDialog = false"
|
||||
:type="mediaSource"
|
||||
/>
|
||||
<MediaIdSelector v-else v-model="doubanId" @close="mediaSelectorDialog = false" :type="mediaSource" />
|
||||
</VDialog>
|
||||
</VDialog>
|
||||
</template>
|
||||
@@ -133,12 +133,12 @@ async function savaAlistConfig() {
|
||||
</VCol>
|
||||
</VRow>
|
||||
</VCardText>
|
||||
<VCardActions>
|
||||
<VBtn color="error" @click="handleReset" prepend-icon="mdi-restore" class="px-5 me-3">
|
||||
<VCardActions class="app-dialog-actions">
|
||||
<VBtn color="error" variant="tonal" @click="handleReset" prepend-icon="mdi-restore">
|
||||
{{ t('dialog.alistConfig.reset') }}
|
||||
</VBtn>
|
||||
<VSpacer />
|
||||
<VBtn @click="handleDone" prepend-icon="mdi-check" class="px-5 me-3">
|
||||
<VBtn color="primary" variant="flat" @click="handleDone" prepend-icon="mdi-check" class="px-5">
|
||||
{{ t('dialog.alistConfig.complete') }}
|
||||
</VBtn>
|
||||
</VCardActions>
|
||||
|
||||
@@ -138,12 +138,12 @@ onUnmounted(() => {
|
||||
</VAlert>
|
||||
</div>
|
||||
</VCardText>
|
||||
<VCardActions>
|
||||
<VBtn color="error" @click="handleReset" prepend-icon="mdi-restore" class="px-5 me-3">
|
||||
<VCardActions class="app-dialog-actions">
|
||||
<VBtn color="error" variant="tonal" @click="handleReset" prepend-icon="mdi-restore">
|
||||
{{ t('dialog.aliyunAuth.reset') }}
|
||||
</VBtn>
|
||||
<VSpacer />
|
||||
<VBtn @click="handleDone" prepend-icon="mdi-check" class="px-5 me-3">
|
||||
<VBtn color="primary" variant="flat" @click="handleDone" prepend-icon="mdi-check" class="px-5">
|
||||
{{ t('dialog.aliyunAuth.complete') }}
|
||||
</VBtn>
|
||||
</VCardActions>
|
||||
|
||||
@@ -84,9 +84,16 @@ function submitReidentify() {
|
||||
</VAlert>
|
||||
</VCardText>
|
||||
|
||||
<VCardActions>
|
||||
<VCardActions class="app-dialog-actions">
|
||||
<VSpacer />
|
||||
<VBtn color="primary" :loading="props.loading" prepend-icon="mdi-check" @click="submitReidentify">
|
||||
<VBtn
|
||||
color="primary"
|
||||
variant="flat"
|
||||
:loading="props.loading"
|
||||
prepend-icon="mdi-check"
|
||||
class="px-5"
|
||||
@click="submitReidentify"
|
||||
>
|
||||
{{ t('setting.cache.reidentifyDialog.confirm') }}
|
||||
</VBtn>
|
||||
</VCardActions>
|
||||
|
||||
@@ -383,7 +383,7 @@ onMounted(() => {
|
||||
</VTab>
|
||||
</VTabs>
|
||||
|
||||
<div v-if="loading" class="d-flex justify-center align-center" style="min-height: 300px">
|
||||
<div v-if="loading" class="d-flex justify-center align-center" style="min-block-size: 300px">
|
||||
<VProgressCircular indeterminate color="primary" size="64" />
|
||||
</div>
|
||||
|
||||
@@ -610,12 +610,16 @@ onMounted(() => {
|
||||
</VWindow>
|
||||
</VCardText>
|
||||
|
||||
<VCardActions class="pt-3">
|
||||
<VCardActions class="app-dialog-actions">
|
||||
<VSpacer />
|
||||
<VBtn variant="text" @click="emit('close')">
|
||||
{{ t('common.cancel') }}
|
||||
</VBtn>
|
||||
<VBtn color="primary" :loading="saving" prepend-icon="mdi-content-save" class="px-5" @click="saveConfig">
|
||||
<VBtn
|
||||
color="primary"
|
||||
variant="flat"
|
||||
:loading="saving"
|
||||
prepend-icon="mdi-content-save"
|
||||
class="px-5"
|
||||
@click="saveConfig"
|
||||
>
|
||||
{{ t('common.save') }}
|
||||
</VBtn>
|
||||
</VCardActions>
|
||||
@@ -638,15 +642,6 @@ onMounted(() => {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.category-item {
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
|
||||
.category-item:hover {
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.add-category-btn {
|
||||
border-style: dashed !important;
|
||||
transition: all 0.2s ease;
|
||||
|
||||
@@ -109,7 +109,14 @@ function submitSettings() {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VDialog v-if="visible" v-model="visible" width="35rem" class="settings-dialog" scrollable :fullscreen="!display.mdAndUp.value">
|
||||
<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>
|
||||
@@ -146,15 +153,15 @@ function submitSettings() {
|
||||
<VSwitch v-model="elevatedValue" :label="props.switchLabel" />
|
||||
</p>
|
||||
</VCardText>
|
||||
<VCardActions class="pt-3">
|
||||
<VBtn v-if="props.showBulkActions" variant="text" @click="setAllItems(true)">
|
||||
<VCardActions class="app-dialog-actions">
|
||||
<VBtn v-if="props.showBulkActions" color="success" variant="tonal" @click="setAllItems(true)">
|
||||
{{ props.selectAllText }}
|
||||
</VBtn>
|
||||
<VBtn v-if="props.showBulkActions" variant="text" @click="setAllItems(false)">
|
||||
<VBtn v-if="props.showBulkActions" color="warning" variant="tonal" @click="setAllItems(false)">
|
||||
{{ props.selectNoneText }}
|
||||
</VBtn>
|
||||
<VSpacer />
|
||||
<VBtn color="primary" class="px-5" @click="submitSettings">
|
||||
<VBtn color="primary" variant="flat" class="px-5" @click="submitSettings">
|
||||
<template #prepend>
|
||||
<VIcon icon="mdi-content-save" />
|
||||
</template>
|
||||
@@ -195,8 +202,7 @@ function submitSettings() {
|
||||
.setting-item {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
border: 1px solid rgba(var(--v-theme-on-surface), 0.1);
|
||||
border-radius: 8px;
|
||||
border-radius: var(--app-surface-radius);
|
||||
background-color: rgba(var(--v-theme-surface-variant), 0.3);
|
||||
cursor: pointer;
|
||||
padding-block: 10px;
|
||||
|
||||
@@ -86,8 +86,9 @@ function submitCustomCSS() {
|
||||
class="custom-css-editor"
|
||||
/>
|
||||
</div>
|
||||
<VCardActions class="custom-css-actions">
|
||||
<VBtn color="primary" prepend-icon="mdi-content-save" class="px-5" @click="submitCustomCSS">
|
||||
<VCardActions class="app-dialog-actions custom-css-actions">
|
||||
<VSpacer />
|
||||
<VBtn color="primary" variant="flat" prepend-icon="mdi-content-save" class="px-5" @click="submitCustomCSS">
|
||||
{{ t('common.save') }}
|
||||
</VBtn>
|
||||
</VCardActions>
|
||||
@@ -98,9 +99,9 @@ function submitCustomCSS() {
|
||||
<style scoped>
|
||||
.custom-css-dialog {
|
||||
display: flex;
|
||||
overflow: hidden;
|
||||
flex-direction: column;
|
||||
max-block-size: calc(100dvh - 2rem);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.custom-css-header {
|
||||
@@ -110,7 +111,7 @@ function submitCustomCSS() {
|
||||
|
||||
.custom-css-editor-body {
|
||||
flex: 1 1 auto;
|
||||
min-block-size: 0;
|
||||
min-block-size: 240px;
|
||||
}
|
||||
|
||||
.custom-css-editor {
|
||||
@@ -140,8 +141,8 @@ function submitCustomCSS() {
|
||||
|
||||
.custom-css-editor {
|
||||
flex: 1 1 auto;
|
||||
min-block-size: 0;
|
||||
block-size: auto;
|
||||
min-block-size: 0;
|
||||
}
|
||||
|
||||
.custom-css-actions {
|
||||
|
||||
@@ -199,8 +199,9 @@ onMounted(() => {
|
||||
</VRow>
|
||||
</VForm>
|
||||
</VCardText>
|
||||
<VCardActions class="pt-3">
|
||||
<VBtn @click="saveRuleInfo" prepend-icon="mdi-content-save" class="px-5">
|
||||
<VCardActions class="app-dialog-actions">
|
||||
<VSpacer />
|
||||
<VBtn color="primary" variant="flat" @click="saveRuleInfo" prepend-icon="mdi-content-save" class="px-5">
|
||||
{{ t('customRule.action.confirm') }}
|
||||
</VBtn>
|
||||
</VCardActions>
|
||||
|
||||
@@ -88,9 +88,9 @@ function submitOrder() {
|
||||
</template>
|
||||
</draggable>
|
||||
</VCardText>
|
||||
<VCardActions class="pt-3">
|
||||
<VCardActions class="app-dialog-actions">
|
||||
<VSpacer />
|
||||
<VBtn @click="submitOrder">
|
||||
<VBtn color="primary" variant="flat" class="px-5" @click="submitOrder">
|
||||
<template #prepend>
|
||||
<VIcon icon="mdi-content-save" />
|
||||
</template>
|
||||
@@ -117,8 +117,6 @@ function submitOrder() {
|
||||
.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;
|
||||
|
||||
@@ -117,9 +117,20 @@ function generateId() {
|
||||
return Math.random().toString(36).substring(2, 9)
|
||||
}
|
||||
|
||||
/** 初始化下载器新增配置项的兼容默认值。 */
|
||||
function initializeDownloaderConfigDefaults() {
|
||||
if (!['qbittorrent', 'transmission'].includes(downloaderInfo.value.type)) return
|
||||
if (!downloaderInfo.value.config) downloaderInfo.value.config = {}
|
||||
if (downloaderInfo.value.type === 'qbittorrent' && downloaderInfo.value.config.incomplete_files_ext === undefined)
|
||||
downloaderInfo.value.config.incomplete_files_ext = true
|
||||
if (downloaderInfo.value.type === 'transmission' && downloaderInfo.value.config.rename_partial_files === undefined)
|
||||
downloaderInfo.value.config.rename_partial_files = true
|
||||
}
|
||||
|
||||
/** 初始化下载器编辑表单数据。 */
|
||||
function initializeDownloaderInfo() {
|
||||
downloaderInfo.value = cloneDeep(props.downloader)
|
||||
initializeDownloaderConfigDefaults()
|
||||
pathMappingRows.value = (downloaderInfo.value.path_mapping || []).map(item => ({
|
||||
id: generateId(),
|
||||
storage: item[0],
|
||||
@@ -299,6 +310,15 @@ onMounted(() => {
|
||||
active
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VSwitch
|
||||
v-model="downloaderInfo.config.incomplete_files_ext"
|
||||
:label="t('downloader.incomplete_files_ext')"
|
||||
:hint="t('downloader.incomplete_files_extHint')"
|
||||
persistent-hint
|
||||
active
|
||||
/>
|
||||
</VCol>
|
||||
</VRow>
|
||||
<VRow v-else-if="downloaderInfo.type == 'transmission'">
|
||||
<VCol cols="12" md="6">
|
||||
@@ -344,6 +364,15 @@ onMounted(() => {
|
||||
prepend-inner-icon="mdi-lock"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VSwitch
|
||||
v-model="downloaderInfo.config.rename_partial_files"
|
||||
:label="t('downloader.rename_partial_files')"
|
||||
:hint="t('downloader.rename_partial_filesHint')"
|
||||
persistent-hint
|
||||
active
|
||||
/>
|
||||
</VCol>
|
||||
</VRow>
|
||||
<VRow v-else-if="downloaderInfo.type == 'rtorrent'">
|
||||
<VCol cols="12" md="6">
|
||||
@@ -507,8 +536,9 @@ onMounted(() => {
|
||||
</VRow>
|
||||
</VForm>
|
||||
</VCardText>
|
||||
<VCardActions class="pt-3">
|
||||
<VBtn @click="saveDownloaderInfo" prepend-icon="mdi-content-save" class="px-5">
|
||||
<VCardActions class="app-dialog-actions">
|
||||
<VSpacer />
|
||||
<VBtn color="primary" variant="flat" @click="saveDownloaderInfo" prepend-icon="mdi-content-save" class="px-5">
|
||||
{{ t('common.save') }}
|
||||
</VBtn>
|
||||
</VCardActions>
|
||||
|
||||
@@ -52,9 +52,16 @@ function closeDialog() {
|
||||
<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')">
|
||||
<VCardActions class="app-dialog-actions">
|
||||
<VSpacer />
|
||||
<VBtn
|
||||
color="primary"
|
||||
variant="flat"
|
||||
:disabled="!folderName"
|
||||
prepend-icon="mdi-folder-plus"
|
||||
class="px-5"
|
||||
@click="emit('create')"
|
||||
>
|
||||
{{ t('common.create') }}
|
||||
</VBtn>
|
||||
</VCardActions>
|
||||
|
||||
@@ -81,11 +81,19 @@ function closeDialog() {
|
||||
</VCol>
|
||||
</VRow>
|
||||
</VCardText>
|
||||
<VCardActions>
|
||||
<VBtn color="success" prepend-icon="mdi-magic" class="px-5 me-3" @click="emit('auto-name')">
|
||||
<VCardActions class="app-dialog-actions">
|
||||
<VBtn color="success" variant="tonal" prepend-icon="mdi-magic" @click="emit('auto-name')">
|
||||
{{ t('file.autoRecognizeName') }}
|
||||
</VBtn>
|
||||
<VBtn :disabled="!renameName" prepend-icon="mdi-check" class="px-5 me-3" @click="emit('rename')">
|
||||
<VSpacer />
|
||||
<VBtn
|
||||
color="primary"
|
||||
variant="flat"
|
||||
:disabled="!renameName"
|
||||
prepend-icon="mdi-check"
|
||||
class="px-5"
|
||||
@click="emit('rename')"
|
||||
>
|
||||
{{ t('common.confirm') }}
|
||||
</VBtn>
|
||||
</VCardActions>
|
||||
|
||||
@@ -294,18 +294,23 @@ onMounted(() => {
|
||||
</Draggable>
|
||||
<div class="text-center" v-if="filterRuleCards.length == 0">{{ t('filterRule.add') }}</div>
|
||||
</VCardText>
|
||||
<VCardActions class="pt-3">
|
||||
<VBtn color="primary" @click="addFilterCard">
|
||||
<VCardActions class="app-dialog-actions">
|
||||
<VBtn color="primary" variant="tonal" class="app-dialog-actions__icon-btn" @click="addFilterCard">
|
||||
<VIcon icon="mdi-plus" />
|
||||
</VBtn>
|
||||
<VBtn color="success" @click="importRules('priority')">
|
||||
<VBtn
|
||||
color="success"
|
||||
variant="tonal"
|
||||
class="app-dialog-actions__icon-btn"
|
||||
@click="importRules('priority')"
|
||||
>
|
||||
<VIcon icon="mdi-import" />
|
||||
</VBtn>
|
||||
<VBtn color="info" @click="shareRules">
|
||||
<VBtn color="info" variant="tonal" class="app-dialog-actions__icon-btn" @click="shareRules">
|
||||
<VIcon icon="mdi-share" />
|
||||
</VBtn>
|
||||
<VSpacer />
|
||||
<VBtn @click="saveGroupInfo" prepend-icon="mdi-content-save" class="px-5">
|
||||
<VBtn color="primary" variant="flat" @click="saveGroupInfo" prepend-icon="mdi-content-save" class="px-5">
|
||||
{{ t('common.save') }}
|
||||
</VBtn>
|
||||
</VCardActions>
|
||||
|
||||
@@ -7,6 +7,7 @@ import { useToast } from 'vue-toastification'
|
||||
import { VBtn } from 'vuetify/lib/components/index.mjs'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useGlobalSettingsStore } from '@/stores'
|
||||
import { getDisplayImageUrl } from '@/utils/imageUtils'
|
||||
|
||||
// 国际化
|
||||
const { t } = useI18n()
|
||||
@@ -50,7 +51,7 @@ function toggleExpand() {
|
||||
// 加载follow用户列表
|
||||
async function queryFollowUsers() {
|
||||
try {
|
||||
const result: { [key: string]: any } = await api.get('system/setting/FollowSubscribers')
|
||||
const result: { [key: string]: any } = await api.get('system/setting/public/FollowSubscribers')
|
||||
followUsers.value = result.data?.value ?? []
|
||||
} catch (error) {
|
||||
console.log(error)
|
||||
@@ -88,10 +89,7 @@ async function unfollowUser() {
|
||||
// 计算海报图片地址
|
||||
const posterUrl = computed(() => {
|
||||
const url = props.media?.poster
|
||||
// 使用图片缓存
|
||||
if (globalSettings.GLOBAL_IMAGE_CACHE && url)
|
||||
return `${import.meta.env.VITE_API_BASE_URL}system/cache/image?url=${encodeURIComponent(url)}`
|
||||
return url
|
||||
return getDisplayImageUrl(url || '', globalSettings.GLOBAL_IMAGE_CACHE)
|
||||
})
|
||||
|
||||
// 获得mediaid
|
||||
@@ -203,7 +201,7 @@ onMounted(() => {
|
||||
>
|
||||
{{ props.media?.share_comment }}
|
||||
</VCardSubtitle>
|
||||
<VList lines="one">
|
||||
<VList lines="one" class="border-0">
|
||||
<VListItem class="ps-0">
|
||||
<VListItemTitle class="text-center text-md-left">
|
||||
<span class="font-weight-medium">{{ t('subscribe.sharer') }}:</span>
|
||||
|
||||
@@ -193,7 +193,7 @@ async function doDelete() {
|
||||
>
|
||||
{{ props.workflow?.share_comment }}
|
||||
</VCardSubtitle>
|
||||
<VList lines="one">
|
||||
<VList lines="one" class="border-0">
|
||||
<VListItem class="ps-0">
|
||||
<VListItemTitle class="text-center text-md-left">
|
||||
<span class="font-weight-medium">{{ t('workflow.sharer') }}:</span>
|
||||
@@ -277,8 +277,6 @@ async function doDelete() {
|
||||
.workflow-preview {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
border: 1px solid rgba(var(--v-border-color), var(--v-border-opacity));
|
||||
border-radius: 8px;
|
||||
background-color: rgba(var(--v-theme-surface), 0.8);
|
||||
block-size: 280px;
|
||||
inline-size: 240px;
|
||||
@@ -289,8 +287,6 @@ async function doDelete() {
|
||||
inline-size: 100%;
|
||||
|
||||
.vue-flow__node {
|
||||
border: 1px solid rgba(var(--v-border-color), var(--v-border-opacity));
|
||||
border-radius: 8px;
|
||||
font-size: 10px;
|
||||
|
||||
&:hover {
|
||||
|
||||
@@ -36,9 +36,9 @@ function handleImport() {
|
||||
<VCardText class="pt-2">
|
||||
<VTextarea v-model="codeString" prepend-inner-icon="mdi-code-json" />
|
||||
</VCardText>
|
||||
<VCardActions>
|
||||
<VCardActions class="app-dialog-actions">
|
||||
<VSpacer />
|
||||
<VBtn @click="handleImport" prepend-icon="mdi-import" class="px-5 me-3">
|
||||
<VBtn color="primary" variant="flat" @click="handleImport" prepend-icon="mdi-import" class="px-5">
|
||||
{{ t('dialog.importCode.import') }}
|
||||
</VBtn>
|
||||
</VCardActions>
|
||||
|
||||
@@ -43,7 +43,10 @@ function closeDialog() {
|
||||
<template>
|
||||
<VDialog v-if="visible" v-model="visible" max-width="560">
|
||||
<VCard>
|
||||
<VCardTitle>{{ t('setting.system.llmProviderAuthDialogTitle') }}</VCardTitle>
|
||||
<VCardItem>
|
||||
<VCardTitle>{{ t('setting.system.llmProviderAuthDialogTitle') }}</VCardTitle>
|
||||
</VCardItem>
|
||||
<VDivider />
|
||||
<VCardText class="d-flex flex-column ga-4">
|
||||
<VAlert v-if="props.authSession?.instructions" type="info" variant="tonal">
|
||||
{{ props.authSession.instructions }}
|
||||
@@ -71,9 +74,9 @@ function closeDialog() {
|
||||
</VBtn>
|
||||
</div>
|
||||
</VCardText>
|
||||
<VCardActions>
|
||||
<VCardActions class="app-dialog-actions">
|
||||
<VSpacer />
|
||||
<VBtn variant="text" @click="closeDialog">
|
||||
<VBtn color="primary" variant="flat" class="px-5" @click="closeDialog">
|
||||
{{ t('common.close') }}
|
||||
</VBtn>
|
||||
</VCardActions>
|
||||
|
||||
@@ -591,8 +591,15 @@ onMounted(() => {
|
||||
</VRow>
|
||||
</VForm>
|
||||
</VCardText>
|
||||
<VCardActions class="pt-3">
|
||||
<VBtn @click="saveMediaServerInfo" prepend-icon="mdi-content-save" class="px-5">
|
||||
<VCardActions class="app-dialog-actions">
|
||||
<VSpacer />
|
||||
<VBtn
|
||||
color="primary"
|
||||
variant="flat"
|
||||
@click="saveMediaServerInfo"
|
||||
prepend-icon="mdi-content-save"
|
||||
class="px-5"
|
||||
>
|
||||
{{ t('common.confirm') }}
|
||||
</VBtn>
|
||||
</VCardActions>
|
||||
|
||||
@@ -915,6 +915,16 @@ onMounted(() => {
|
||||
prepend-inner-icon="mdi-pound"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="notificationInfo.config.SLACK_ADMINS"
|
||||
:label="t('notification.slack.admins')"
|
||||
:placeholder="t('notification.slack.adminsPlaceholder')"
|
||||
:hint="t('notification.slack.adminsHint')"
|
||||
persistent-hint
|
||||
prepend-inner-icon="mdi-account-supervisor"
|
||||
/>
|
||||
</VCol>
|
||||
</VRow>
|
||||
<VRow v-else-if="notificationInfo.type == 'discord'">
|
||||
<VCol cols="12" md="6">
|
||||
@@ -956,6 +966,16 @@ onMounted(() => {
|
||||
prepend-inner-icon="mdi-pound-box"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="notificationInfo.config.DISCORD_ADMINS"
|
||||
:label="t('notification.discord.admins')"
|
||||
:placeholder="t('notification.discord.adminsPlaceholder')"
|
||||
:hint="t('notification.discord.adminsHint')"
|
||||
persistent-hint
|
||||
prepend-inner-icon="mdi-account-supervisor"
|
||||
/>
|
||||
</VCol>
|
||||
</VRow>
|
||||
<VRow v-else-if="notificationInfo.type == 'synologychat'">
|
||||
<VCol cols="12" md="6">
|
||||
@@ -986,6 +1006,16 @@ onMounted(() => {
|
||||
prepend-inner-icon="mdi-key"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="notificationInfo.config.SYNOLOGYCHAT_ADMINS"
|
||||
:label="t('notification.synologychat.admins')"
|
||||
:placeholder="t('notification.synologychat.adminsPlaceholder')"
|
||||
:hint="t('notification.synologychat.adminsHint')"
|
||||
persistent-hint
|
||||
prepend-inner-icon="mdi-account-supervisor"
|
||||
/>
|
||||
</VCol>
|
||||
</VRow>
|
||||
<VRow v-else-if="notificationInfo.type == 'vocechat'">
|
||||
<VCol cols="12" md="6">
|
||||
@@ -1026,6 +1056,16 @@ onMounted(() => {
|
||||
prepend-inner-icon="mdi-pound"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="notificationInfo.config.VOCECHAT_ADMINS"
|
||||
:label="t('notification.vocechat.admins')"
|
||||
:placeholder="t('notification.vocechat.adminsPlaceholder')"
|
||||
:hint="t('notification.vocechat.adminsHint')"
|
||||
persistent-hint
|
||||
prepend-inner-icon="mdi-account-supervisor"
|
||||
/>
|
||||
</VCol>
|
||||
</VRow>
|
||||
<VRow v-else-if="notificationInfo.type == 'qqbot'">
|
||||
<VCol cols="12" md="6">
|
||||
@@ -1076,6 +1116,16 @@ onMounted(() => {
|
||||
prepend-inner-icon="mdi-account-group"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="notificationInfo.config.QQBOT_ADMINS"
|
||||
:label="t('notification.qqbot.admins')"
|
||||
:placeholder="t('notification.qqbot.adminsPlaceholder')"
|
||||
:hint="t('notification.qqbot.adminsHint')"
|
||||
persistent-hint
|
||||
prepend-inner-icon="mdi-account-supervisor"
|
||||
/>
|
||||
</VCol>
|
||||
</VRow>
|
||||
<VRow v-else-if="notificationInfo.type == 'webpush'">
|
||||
<VCol cols="12" md="6">
|
||||
@@ -1121,8 +1171,15 @@ onMounted(() => {
|
||||
</VRow>
|
||||
</VForm>
|
||||
</VCardText>
|
||||
<VCardActions class="pt-3">
|
||||
<VBtn @click="saveNotificationInfo" prepend-icon="mdi-content-save" class="px-5">
|
||||
<VCardActions class="app-dialog-actions">
|
||||
<VSpacer />
|
||||
<VBtn
|
||||
color="primary"
|
||||
variant="flat"
|
||||
@click="saveNotificationInfo"
|
||||
prepend-icon="mdi-content-save"
|
||||
class="px-5"
|
||||
>
|
||||
{{ t('common.confirm') }}
|
||||
</VBtn>
|
||||
</VCardActions>
|
||||
|
||||
@@ -92,8 +92,9 @@ function submitTemplate() {
|
||||
class="template-ace-editor"
|
||||
/>
|
||||
</div>
|
||||
<VCardActions class="template-editor-actions">
|
||||
<VBtn color="primary" prepend-icon="mdi-content-save" class="px-5" @click="submitTemplate">
|
||||
<VCardActions class="app-dialog-actions template-editor-actions">
|
||||
<VSpacer />
|
||||
<VBtn color="primary" variant="flat" prepend-icon="mdi-content-save" class="px-5" @click="submitTemplate">
|
||||
{{ t('common.save') }}
|
||||
</VBtn>
|
||||
</VCardActions>
|
||||
|
||||
@@ -115,10 +115,6 @@ const colorTheme = computed(() => {
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.offline-dialog {
|
||||
border-radius: 16px;
|
||||
}
|
||||
|
||||
.status-icon-wrapper {
|
||||
padding-block: 24px 0;
|
||||
padding-inline: 24px;
|
||||
|
||||
@@ -299,8 +299,9 @@ watch(
|
||||
</VAlert>
|
||||
</VCardText>
|
||||
|
||||
<VCardActions class="justify-end px-6 pb-4">
|
||||
<VBtn variant="outlined" @click="show = false">{{ t('common.close') }}</VBtn>
|
||||
<VCardActions class="app-dialog-actions">
|
||||
<VSpacer />
|
||||
<VBtn color="primary" variant="flat" class="px-5" @click="show = false">{{ t('common.close') }}</VBtn>
|
||||
</VCardActions>
|
||||
</VCard>
|
||||
</VDialog>
|
||||
|
||||
@@ -154,10 +154,11 @@ onMounted(() => {
|
||||
</VRow>
|
||||
</VForm>
|
||||
</VCardText>
|
||||
<VCardActions class="pt-3">
|
||||
<VCardActions class="app-dialog-actions">
|
||||
<VSpacer />
|
||||
<VBtn
|
||||
color="primary"
|
||||
variant="flat"
|
||||
@click="submitClone"
|
||||
prepend-icon="mdi-content-copy"
|
||||
class="px-5"
|
||||
|
||||
@@ -160,13 +160,26 @@ onBeforeMount(async () => {
|
||||
<div v-if="!pluginFormItems || pluginFormItems.length === 0">此插件没有可配置项</div>
|
||||
</div>
|
||||
</VCardText>
|
||||
<VCardActions class="pt-3">
|
||||
<VBtn v-if="props.plugin?.has_page" @click="emit('switch')" color="info">
|
||||
<VCardActions class="app-dialog-actions">
|
||||
<VBtn
|
||||
v-if="props.plugin?.has_page"
|
||||
color="info"
|
||||
variant="tonal"
|
||||
prepend-icon="mdi-database-eye-outline"
|
||||
@click="emit('switch')"
|
||||
>
|
||||
{{ t('dialog.pluginConfig.viewData') }}
|
||||
</VBtn>
|
||||
<VSpacer />
|
||||
<!-- 只有Vuetify模式显示默认保存按钮,Vue模式由组件内部控制 -->
|
||||
<VBtn v-if="renderMode === 'vuetify'" @click="savePluginConf" prepend-icon="mdi-content-save" class="px-5">
|
||||
<VBtn
|
||||
v-if="renderMode === 'vuetify'"
|
||||
color="primary"
|
||||
variant="flat"
|
||||
@click="savePluginConf"
|
||||
prepend-icon="mdi-content-save"
|
||||
class="px-5"
|
||||
>
|
||||
保存
|
||||
</VBtn>
|
||||
</VCardActions>
|
||||
|
||||
@@ -54,9 +54,9 @@ function closeDialog() {
|
||||
@keyup.enter="emit('create')"
|
||||
/>
|
||||
</VCardText>
|
||||
<VCardActions>
|
||||
<VCardActions class="app-dialog-actions">
|
||||
<VSpacer />
|
||||
<VBtn color="primary" prepend-icon="mdi-folder-plus" class="px-5" @click="emit('create')">
|
||||
<VBtn color="primary" variant="flat" prepend-icon="mdi-folder-plus" class="px-5" @click="emit('create')">
|
||||
{{ t('plugin.create') }}
|
||||
</VBtn>
|
||||
</VCardActions>
|
||||
|
||||
@@ -57,9 +57,9 @@ function confirmRename() {
|
||||
@keyup.enter="confirmRename"
|
||||
/>
|
||||
</VCardText>
|
||||
<VCardActions>
|
||||
<VCardActions class="app-dialog-actions">
|
||||
<VSpacer />
|
||||
<VBtn color="primary" prepend-icon="mdi-check" class="px-5" @click="confirmRename">确认</VBtn>
|
||||
<VBtn color="primary" variant="flat" prepend-icon="mdi-check" class="px-5" @click="confirmRename">确认</VBtn>
|
||||
</VCardActions>
|
||||
</VCard>
|
||||
</VDialog>
|
||||
|
||||
@@ -201,9 +201,11 @@ onMounted(() => {
|
||||
</VCol>
|
||||
</VRow>
|
||||
</VCardText>
|
||||
<VCardActions>
|
||||
<VCardActions class="app-dialog-actions">
|
||||
<VSpacer />
|
||||
<VBtn color="primary" prepend-icon="mdi-content-save" class="px-5" @click="saveSettings">保存</VBtn>
|
||||
<VBtn color="primary" variant="flat" prepend-icon="mdi-content-save" class="px-5" @click="saveSettings">
|
||||
保存
|
||||
</VBtn>
|
||||
</VCardActions>
|
||||
</VCard>
|
||||
</VDialog>
|
||||
|
||||
@@ -42,6 +42,12 @@ function openLoggerWindow() {
|
||||
}system/logging?length=-1&logfile=plugins/${props.plugin?.id?.toLowerCase()}.log`
|
||||
window.open(url, '_blank')
|
||||
}
|
||||
|
||||
/** 下载当前插件日志压缩包。 */
|
||||
function downloadLogger() {
|
||||
const url = `${import.meta.env.VITE_API_BASE_URL}system/logging/download/${props.plugin?.id?.toLowerCase()}`
|
||||
window.open(url, '_blank')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -52,12 +58,20 @@ function openLoggerWindow() {
|
||||
<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>
|
||||
<span class="ms-4 d-inline-flex align-center ga-1">
|
||||
<a class="d-inline-flex align-center cursor-pointer" @click="downloadLogger">
|
||||
<VChip color="grey-darken-1" size="small">
|
||||
<VIcon icon="mdi-download" size="small" start />
|
||||
{{ t('common.download') }}
|
||||
</VChip>
|
||||
</a>
|
||||
<a class="d-inline-flex align-center cursor-pointer" @click="openLoggerWindow">
|
||||
<VChip color="grey-darken-1" size="small">
|
||||
<VIcon icon="mdi-open-in-new" size="small" start />
|
||||
{{ t('common.openInNewWindow') }}
|
||||
</VChip>
|
||||
</a>
|
||||
</span>
|
||||
</VCardTitle>
|
||||
</VCardItem>
|
||||
<VDivider />
|
||||
|
||||
@@ -6,8 +6,12 @@ import { getLogoUrl } from '@/utils/imageUtils'
|
||||
import { useToast } from 'vue-toastification'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { openSharedDialog } from '@/composables/useSharedDialog'
|
||||
import { useConfirm } from '@/composables/useConfirm'
|
||||
|
||||
const ProgressDialog = defineAsyncComponent(() => import('@/components/dialog/ProgressDialog.vue'))
|
||||
const PluginVersionHistoryDialog = defineAsyncComponent(
|
||||
() => import('@/components/dialog/PluginVersionHistoryDialog.vue'),
|
||||
)
|
||||
|
||||
// 多语言
|
||||
const { t } = useI18n()
|
||||
@@ -15,6 +19,8 @@ const { t } = useI18n()
|
||||
// 提示框
|
||||
const $toast = useToast()
|
||||
|
||||
const createConfirm = useConfirm()
|
||||
|
||||
// 输入参数
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
@@ -47,6 +53,7 @@ const imageRef = ref<any>()
|
||||
const imageLoadError = ref(false)
|
||||
|
||||
let progressDialogController: ReturnType<typeof openSharedDialog> | null = null
|
||||
let versionHistoryDialogController: ReturnType<typeof openSharedDialog> | null = null
|
||||
|
||||
/** 打开插件安装进度弹窗。 */
|
||||
function showInstallProgress(text: string) {
|
||||
@@ -97,24 +104,38 @@ function visitPluginPage() {
|
||||
}
|
||||
|
||||
/** 安装插件并通知父级刷新市场列表。 */
|
||||
async function installPlugin() {
|
||||
if (props.plugin?.system_version_compatible === false) {
|
||||
async function installPlugin(releaseVersion?: string, repoUrl?: string) {
|
||||
if (!releaseVersion && props.plugin?.system_version_compatible === false) {
|
||||
$toast.error(props.plugin?.system_version_message || t('plugin.incompatibleSystemVersion'))
|
||||
return
|
||||
}
|
||||
|
||||
if (releaseVersion) {
|
||||
const isConfirmed = await createConfirm({
|
||||
title: t('common.confirm'),
|
||||
content: t('plugin.confirmInstallOldRelease', {
|
||||
name: props.plugin?.plugin_name,
|
||||
version: releaseVersion,
|
||||
}),
|
||||
confirmText: t('common.confirm'),
|
||||
})
|
||||
|
||||
if (!isConfirmed) return
|
||||
}
|
||||
|
||||
try {
|
||||
showInstallProgress(
|
||||
t('plugin.installing', {
|
||||
name: props.plugin?.plugin_name,
|
||||
version: props?.plugin?.plugin_version,
|
||||
version: releaseVersion || 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,
|
||||
repo_url: repoUrl || props.plugin?.repo_url,
|
||||
release_version: releaseVersion,
|
||||
force: props.plugin?.has_update || Boolean(releaseVersion),
|
||||
},
|
||||
})
|
||||
|
||||
@@ -122,6 +143,8 @@ async function installPlugin() {
|
||||
|
||||
if (result.success) {
|
||||
$toast.success(t('plugin.installSuccess', { name: props.plugin?.plugin_name }))
|
||||
versionHistoryDialogController?.close()
|
||||
versionHistoryDialogController = null
|
||||
visible.value = false
|
||||
emit('install')
|
||||
} else {
|
||||
@@ -133,8 +156,22 @@ async function installPlugin() {
|
||||
}
|
||||
}
|
||||
|
||||
/** 打开版本历史并支持从 Release 资产安装指定版本。 */
|
||||
function showUpdateHistory() {
|
||||
versionHistoryDialogController?.close()
|
||||
versionHistoryDialogController = openSharedDialog(
|
||||
PluginVersionHistoryDialog,
|
||||
{ plugin: props.plugin, actionMode: 'install' },
|
||||
{
|
||||
update: installPlugin,
|
||||
},
|
||||
{ closeOn: ['close', 'update:modelValue'] },
|
||||
)
|
||||
}
|
||||
|
||||
onUnmounted(() => {
|
||||
closeInstallProgress()
|
||||
versionHistoryDialogController?.close()
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -147,13 +184,7 @@ onUnmounted(() => {
|
||||
<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"
|
||||
/>
|
||||
<VImg ref="imageRef" :src="pluginIconPath()" aspect-ratio="4/3" cover @error="imageLoadError = true" />
|
||||
</VAvatar>
|
||||
</div>
|
||||
<div class="flex-grow">
|
||||
@@ -166,7 +197,7 @@ onUnmounted(() => {
|
||||
>
|
||||
{{ props.plugin?.plugin_desc }}
|
||||
</VCardSubtitle>
|
||||
<VList lines="one">
|
||||
<VList lines="one" class="border-0">
|
||||
<VListItem class="ps-0">
|
||||
<VListItemTitle class="text-center text-md-left">
|
||||
<span class="font-weight-medium">{{ t('common.version') }}:</span>
|
||||
@@ -196,16 +227,23 @@ onUnmounted(() => {
|
||||
class="mb-3"
|
||||
:text="props.plugin?.system_version_message || t('plugin.incompatibleSystemVersion')"
|
||||
/>
|
||||
<div class="text-center text-md-left">
|
||||
<div class="plugin-market-detail-actions">
|
||||
<VBtn
|
||||
variant="tonal"
|
||||
@click="showUpdateHistory"
|
||||
prepend-icon="mdi-update"
|
||||
>
|
||||
{{ t('plugin.versionHistory') }}
|
||||
</VBtn>
|
||||
<VBtn
|
||||
color="primary"
|
||||
@click="installPlugin"
|
||||
@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">
|
||||
<div class="plugin-market-detail-actions__downloads" v-if="props.count">
|
||||
<VIcon icon="mdi-fire" />
|
||||
{{ t('plugin.totalDownloads', { count: formatDownloadCount(props.count) }) }}
|
||||
</div>
|
||||
@@ -218,3 +256,30 @@ onUnmounted(() => {
|
||||
</VCard>
|
||||
</VDialog>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.plugin-market-detail-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.75rem;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.plugin-market-detail-actions__downloads {
|
||||
flex-basis: 100%;
|
||||
color: rgba(var(--v-theme-on-surface), var(--v-medium-emphasis-opacity));
|
||||
font-size: 0.75rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
@media (min-width: 960px) {
|
||||
.plugin-market-detail-actions {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.plugin-market-detail-actions__downloads {
|
||||
text-align: start;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -24,11 +24,14 @@ const repoText = ref('')
|
||||
const newRepoUrl = ref('')
|
||||
const editingIndex = ref<number | null>(null)
|
||||
const editingUrl = ref('')
|
||||
const syncingWiki = ref(false)
|
||||
|
||||
const emit = defineEmits(['save', 'close'])
|
||||
|
||||
const parsedTextRepos = computed(() => parseRepoInput(repoText.value))
|
||||
const activeRepoCount = computed(() => (editorMode.value === 'text' ? parsedTextRepos.value.repos.length : repoList.value.length))
|
||||
const activeRepoCount = computed(() =>
|
||||
editorMode.value === 'text' ? parsedTextRepos.value.repos.length : repoList.value.length,
|
||||
)
|
||||
const saveDisabled = computed(
|
||||
() => activeRepoCount.value === 0 || (editorMode.value === 'text' && parsedTextRepos.value.invalidRepos.length > 0),
|
||||
)
|
||||
@@ -108,7 +111,7 @@ function switchEditorMode(mode: EditorMode | undefined) {
|
||||
/** 加载插件市场仓库配置。 */
|
||||
async function queryMarketRepoSetting() {
|
||||
try {
|
||||
const result: { [key: string]: any } = await api.get('system/setting/PLUGIN_MARKET')
|
||||
const result: { [key: string]: any } = await api.get('system/setting/public/PLUGIN_MARKET')
|
||||
if (result && result.data && result.data.value) {
|
||||
repoList.value = parseRepoInput(result.data.value).repos
|
||||
syncTextFromList()
|
||||
@@ -136,6 +139,35 @@ async function saveHandle() {
|
||||
}
|
||||
}
|
||||
|
||||
/** 从 Wiki 同步公开插件仓库清单并写入配置。 */
|
||||
async function syncWikiRepos() {
|
||||
try {
|
||||
syncingWiki.value = true
|
||||
const result: { [key: string]: any } = await api.post('system/setting/PLUGIN_MARKET/sync-wiki', {})
|
||||
|
||||
if (result.success) {
|
||||
const repos = Array.isArray(result.data?.repos)
|
||||
? result.data.repos
|
||||
: parseRepoInput(result.data?.value || '').repos
|
||||
repoList.value = repos
|
||||
syncTextFromList()
|
||||
$toast.success(
|
||||
t('dialog.pluginMarketSetting.syncSuccess', {
|
||||
added: result.data?.added_count ?? 0,
|
||||
total: result.data?.total_count ?? repos.length,
|
||||
}),
|
||||
)
|
||||
} else {
|
||||
$toast.error(t('dialog.pluginMarketSetting.syncFailed', { message: result?.message }))
|
||||
}
|
||||
} catch (error) {
|
||||
console.log(error)
|
||||
$toast.error(t('dialog.pluginMarketSetting.syncFailed', { message: error instanceof Error ? error.message : '' }))
|
||||
} finally {
|
||||
syncingWiki.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** 获取当前维护模式下可保存的仓库地址。 */
|
||||
function normalizeCurrentRepos() {
|
||||
if (editorMode.value === 'text') {
|
||||
@@ -224,8 +256,8 @@ function formatRepoDisplay(url: string) {
|
||||
const pathSegments = parsedUrl.pathname.split('/').filter(Boolean)
|
||||
|
||||
if (
|
||||
['github.com', 'www.github.com', 'raw.githubusercontent.com'].includes(parsedUrl.hostname)
|
||||
&& pathSegments.length >= 2
|
||||
['github.com', 'www.github.com', 'raw.githubusercontent.com'].includes(parsedUrl.hostname) &&
|
||||
pathSegments.length >= 2
|
||||
) {
|
||||
return `${pathSegments[0]}/${pathSegments[1].replace(/\.git$/, '')}`
|
||||
}
|
||||
@@ -258,25 +290,47 @@ onMounted(() => {
|
||||
</div>
|
||||
<VDialogCloseBtn @click="emit('close')" />
|
||||
</VCardItem>
|
||||
|
||||
<VDivider />
|
||||
<VCardText class="plugin-market-dialog-body pt-4">
|
||||
<div class="plugin-market-toolbar">
|
||||
<VBtnToggle
|
||||
:model-value="editorMode"
|
||||
mandatory
|
||||
color="primary"
|
||||
density="comfortable"
|
||||
variant="tonal"
|
||||
class="plugin-market-mode-toggle"
|
||||
@update:model-value="switchEditorMode"
|
||||
>
|
||||
<VBtn value="list" prepend-icon="mdi-format-list-bulleted">
|
||||
{{ t('dialog.pluginMarketSetting.listMode') }}
|
||||
</VBtn>
|
||||
<VBtn value="text" prepend-icon="mdi-text-box-edit-outline">
|
||||
{{ t('dialog.pluginMarketSetting.textMode') }}
|
||||
</VBtn>
|
||||
</VBtnToggle>
|
||||
<div class="plugin-market-toolbar-hint">
|
||||
<VIcon icon="mdi-information-outline" size="18" />
|
||||
<span>{{ t('dialog.pluginMarketSetting.repoCountHint', { count: activeRepoCount }) }}</span>
|
||||
</div>
|
||||
<div class="plugin-market-mode-switch" role="tablist" :aria-label="t('dialog.pluginMarketSetting.title')">
|
||||
<VTooltip :text="t('dialog.pluginMarketSetting.listMode')" location="top">
|
||||
<template #activator="{ props }">
|
||||
<button
|
||||
v-bind="props"
|
||||
type="button"
|
||||
class="plugin-market-mode-button"
|
||||
:class="{ 'is-active': editorMode === 'list' }"
|
||||
role="tab"
|
||||
:aria-label="t('dialog.pluginMarketSetting.listMode')"
|
||||
:aria-selected="editorMode === 'list'"
|
||||
@click="switchEditorMode('list')"
|
||||
>
|
||||
<VIcon icon="mdi-format-list-bulleted" size="20" />
|
||||
</button>
|
||||
</template>
|
||||
</VTooltip>
|
||||
<VTooltip :text="t('dialog.pluginMarketSetting.textMode')" location="top">
|
||||
<template #activator="{ props }">
|
||||
<button
|
||||
v-bind="props"
|
||||
type="button"
|
||||
class="plugin-market-mode-button"
|
||||
:class="{ 'is-active': editorMode === 'text' }"
|
||||
role="tab"
|
||||
:aria-label="t('dialog.pluginMarketSetting.textMode')"
|
||||
:aria-selected="editorMode === 'text'"
|
||||
@click="switchEditorMode('text')"
|
||||
>
|
||||
<VIcon icon="mdi-text-box-edit-outline" size="20" />
|
||||
</button>
|
||||
</template>
|
||||
</VTooltip>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="editorMode === 'list'" class="plugin-market-list-panel">
|
||||
@@ -424,7 +478,17 @@ onMounted(() => {
|
||||
</div>
|
||||
</VCardText>
|
||||
|
||||
<VCardActions class="plugin-market-actions">
|
||||
<VCardActions class="app-dialog-actions">
|
||||
<VBtn
|
||||
color="success"
|
||||
variant="tonal"
|
||||
prepend-icon="mdi-cloud-sync-outline"
|
||||
:loading="syncingWiki"
|
||||
:disabled="syncingWiki"
|
||||
@click="syncWikiRepos"
|
||||
>
|
||||
{{ t('dialog.pluginMarketSetting.syncWiki') }}
|
||||
</VBtn>
|
||||
<VSpacer />
|
||||
<VBtn
|
||||
color="primary"
|
||||
@@ -478,14 +542,70 @@ onMounted(() => {
|
||||
.plugin-market-toolbar {
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem;
|
||||
min-block-size: 2.25rem;
|
||||
}
|
||||
|
||||
.plugin-market-mode-toggle {
|
||||
inline-size: 100%;
|
||||
.plugin-market-toolbar-hint {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
border-radius: 0.375rem;
|
||||
background: rgba(var(--v-theme-info), 0.08);
|
||||
color: rgb(var(--v-theme-info));
|
||||
font-size: 0.875rem;
|
||||
gap: 0.5rem;
|
||||
min-inline-size: 0;
|
||||
padding-block: 0.5rem;
|
||||
padding-inline: 1rem;
|
||||
|
||||
:deep(.v-btn) {
|
||||
flex: 1;
|
||||
min-inline-size: 0;
|
||||
span {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
|
||||
.plugin-market-mode-switch {
|
||||
display: inline-flex;
|
||||
padding: 0.125rem;
|
||||
border: 1px solid rgba(var(--v-theme-on-surface), 0.08);
|
||||
border-radius: 0.375rem;
|
||||
background: rgba(var(--v-theme-surface), 0.72);
|
||||
gap: 0.125rem;
|
||||
}
|
||||
|
||||
.plugin-market-mode-button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
border-radius: 0.375rem;
|
||||
background: transparent;
|
||||
block-size: 2.25rem;
|
||||
color: rgba(var(--v-theme-on-surface), 0.68);
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
inline-size: 2.25rem;
|
||||
transition:
|
||||
background-color 0.16s ease,
|
||||
color 0.16s ease;
|
||||
|
||||
&:hover {
|
||||
background: rgba(var(--v-theme-primary), 0.07);
|
||||
color: rgb(var(--v-theme-on-surface));
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: 2px solid rgba(var(--v-theme-primary), 0.48);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
&.is-active {
|
||||
background: rgba(var(--v-theme-primary), 0.12);
|
||||
color: rgb(var(--v-theme-primary));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -504,8 +624,6 @@ onMounted(() => {
|
||||
|
||||
.plugin-market-list-wrap {
|
||||
flex: 1;
|
||||
border: 1px solid rgba(var(--v-border-color), var(--v-border-opacity));
|
||||
border-radius: 8px;
|
||||
background: rgba(var(--v-theme-surface), 0.72);
|
||||
min-block-size: 0;
|
||||
overflow-y: auto;
|
||||
@@ -531,8 +649,8 @@ onMounted(() => {
|
||||
display: -webkit-box;
|
||||
overflow: hidden;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 2;
|
||||
line-break: anywhere;
|
||||
-webkit-line-clamp: 2;
|
||||
overflow-wrap: anywhere;
|
||||
white-space: normal;
|
||||
word-break: break-word;
|
||||
@@ -552,22 +670,22 @@ onMounted(() => {
|
||||
|
||||
.plugin-market-empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-direction: column;
|
||||
min-block-size: 14rem;
|
||||
}
|
||||
|
||||
.plugin-market-textarea-field {
|
||||
position: relative;
|
||||
display: flex;
|
||||
overflow: hidden;
|
||||
flex: 1;
|
||||
border: 1px solid rgba(var(--v-border-color), var(--v-border-opacity));
|
||||
border-radius: 8px;
|
||||
background: rgba(var(--v-theme-surface), 0.72);
|
||||
min-block-size: 0;
|
||||
overflow: hidden;
|
||||
transition: border-color 0.2s ease, box-shadow 0.2s ease;
|
||||
transition:
|
||||
border-color 0.2s ease,
|
||||
box-shadow 0.2s ease;
|
||||
|
||||
&:focus-within {
|
||||
border-color: rgb(var(--v-theme-primary));
|
||||
@@ -590,13 +708,14 @@ onMounted(() => {
|
||||
background: transparent;
|
||||
block-size: 100%;
|
||||
color: rgb(var(--v-theme-on-surface));
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', monospace;
|
||||
font-size: 1rem;
|
||||
line-height: 1.6;
|
||||
min-block-size: 0;
|
||||
outline: none;
|
||||
overflow-y: auto;
|
||||
padding: 1rem 1rem 1rem 3.25rem;
|
||||
padding-block: 1rem;
|
||||
padding-inline: 3.25rem 1rem;
|
||||
resize: none;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
@@ -616,19 +735,14 @@ onMounted(() => {
|
||||
}
|
||||
}
|
||||
|
||||
.plugin-market-actions {
|
||||
flex: 0 0 auto;
|
||||
gap: 0.5rem;
|
||||
padding: 0.75rem 1.5rem 1rem;
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
@media (width <= 600px) {
|
||||
.plugin-market-dialog-card {
|
||||
block-size: 100dvh;
|
||||
}
|
||||
|
||||
.plugin-market-card-item {
|
||||
padding: 0.75rem 1rem 0.625rem;
|
||||
padding-block: 0.75rem 0.625rem;
|
||||
padding-inline: 1rem;
|
||||
}
|
||||
|
||||
.plugin-market-header {
|
||||
@@ -644,16 +758,22 @@ onMounted(() => {
|
||||
|
||||
.plugin-market-dialog-body {
|
||||
gap: 0.625rem;
|
||||
padding: 0.75rem 1rem !important;
|
||||
padding-block: 0.75rem !important;
|
||||
padding-inline: 1rem !important;
|
||||
}
|
||||
|
||||
.plugin-market-mode-toggle {
|
||||
inline-size: 100%;
|
||||
.plugin-market-toolbar {
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
:deep(.v-btn) {
|
||||
flex: 1;
|
||||
min-inline-size: 0;
|
||||
}
|
||||
.plugin-market-mode-switch {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.plugin-market-toolbar-hint {
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
|
||||
.plugin-market-list-panel,
|
||||
@@ -668,9 +788,5 @@ onMounted(() => {
|
||||
.plugin-market-empty {
|
||||
min-block-size: 10rem;
|
||||
}
|
||||
|
||||
.plugin-market-actions {
|
||||
padding: 0.75rem 1rem calc(0.75rem + env(safe-area-inset-bottom));
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -90,7 +90,7 @@ function closeDialog() {
|
||||
/>
|
||||
</VToolbar>
|
||||
<VDialogCloseBtn @click="closeDialog" />
|
||||
<VList v-if="plugins.length > 0" lines="two">
|
||||
<VList v-if="plugins.length > 0" class="plugin-search-list" lines="two">
|
||||
<VVirtualScroll :items="plugins">
|
||||
<template #default="{ item }">
|
||||
<VListItem @click="emit('open-plugin', item)">
|
||||
@@ -130,4 +130,8 @@ function closeDialog() {
|
||||
</VCard>
|
||||
</VDialog>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.plugin-search-list {
|
||||
border-radius: 0 !important;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
<script setup lang="ts">
|
||||
import type { Plugin } from '@/api/types'
|
||||
import api from '@/api'
|
||||
import type { Plugin, PluginReleaseVersion, PluginReleaseVersionsResponse } from '@/api/types'
|
||||
import VersionHistory from '@/components/misc/VersionHistory.vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
// 多语言
|
||||
const { t } = useI18n()
|
||||
const { t, locale } = useI18n()
|
||||
|
||||
// 输入参数
|
||||
const props = defineProps({
|
||||
@@ -20,10 +21,25 @@ const props = defineProps({
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
actionMode: {
|
||||
type: String as PropType<'install' | 'update'>,
|
||||
default: 'update',
|
||||
},
|
||||
})
|
||||
|
||||
// 定义触发的自定义事件
|
||||
const emit = defineEmits(['update:modelValue', 'close', 'update'])
|
||||
const emit = defineEmits<{
|
||||
(event: 'update:modelValue', value: boolean): void
|
||||
(event: 'close'): void
|
||||
(event: 'update', releaseVersion?: string, repoUrl?: string): void
|
||||
}>()
|
||||
|
||||
const loading = ref(false)
|
||||
const loadError = ref('')
|
||||
const pluginDetail = ref<Plugin | null>(null)
|
||||
const releaseLoading = ref(false)
|
||||
const releaseError = ref('')
|
||||
const releaseDetail = ref<PluginReleaseVersionsResponse | null>(null)
|
||||
|
||||
// 弹窗显示状态
|
||||
const visible = computed({
|
||||
@@ -34,30 +50,209 @@ const visible = computed({
|
||||
},
|
||||
})
|
||||
|
||||
/** 触发插件更新操作。 */
|
||||
function handleUpdate() {
|
||||
emit('update')
|
||||
const resolvedPlugin = computed(() => pluginDetail.value ?? props.plugin)
|
||||
|
||||
const resolvedHistory = computed(() => {
|
||||
const history = { ...(resolvedPlugin.value?.history || {}) }
|
||||
releaseItems.value.forEach(item => {
|
||||
const key = normalizeHistoryVersion(item.version)
|
||||
if (!(key in history)) history[key] = item.body || ''
|
||||
})
|
||||
return history
|
||||
})
|
||||
|
||||
const hasHistory = computed(() => Object.keys(resolvedHistory.value).length > 0)
|
||||
|
||||
const latestActionText = computed(() => props.actionMode === 'install' ? t('plugin.installReleaseVersion') : t('plugin.updateToLatest'))
|
||||
|
||||
const releaseItems = computed(() => releaseDetail.value?.items || [])
|
||||
|
||||
const shouldShowUpdatePanel = computed(() => props.showUpdateAction)
|
||||
|
||||
const releaseByHistoryVersion = computed(() => {
|
||||
const releaseMap = new Map<string, PluginReleaseVersion>()
|
||||
releaseItems.value.forEach(item => {
|
||||
releaseMap.set(normalizeHistoryVersion(item.version), item)
|
||||
})
|
||||
return releaseMap
|
||||
})
|
||||
|
||||
function normalizeHistoryVersion(version: string) {
|
||||
return version.startsWith('v') ? version : `v${version}`
|
||||
}
|
||||
|
||||
function formatReleaseDate(value?: string) {
|
||||
if (!value) return ''
|
||||
const date = new Date(value)
|
||||
if (Number.isNaN(date.getTime())) return value
|
||||
return date.toLocaleDateString(locale.value)
|
||||
}
|
||||
|
||||
function releaseItemByHistoryVersion(version: string) {
|
||||
return releaseByHistoryVersion.value.get(version)
|
||||
}
|
||||
|
||||
function shouldShowReleaseButton(item?: PluginReleaseVersion) {
|
||||
if (!item || item.is_current) return false
|
||||
return !(item.is_latest && shouldShowUpdatePanel.value && props.actionMode === 'update')
|
||||
}
|
||||
|
||||
async function loadPluginHistory() {
|
||||
if (!props.plugin?.id) {
|
||||
pluginDetail.value = null
|
||||
loadError.value = ''
|
||||
releaseDetail.value = null
|
||||
releaseError.value = ''
|
||||
return
|
||||
}
|
||||
|
||||
loading.value = true
|
||||
loadError.value = ''
|
||||
releaseDetail.value = null
|
||||
releaseError.value = ''
|
||||
|
||||
// 插件市场条目已经携带远端信息;history 接口只查询已安装插件,
|
||||
// 未安装插件打开版本历史时只能基于传入的市场数据和 Release 列表展示。
|
||||
if (props.actionMode === 'install' && props.plugin?.repo_url) {
|
||||
pluginDetail.value = null
|
||||
loading.value = false
|
||||
loadPluginReleases(props.plugin, false)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
pluginDetail.value = await api.get(`plugin/history/${props.plugin.id}`, {
|
||||
params: {
|
||||
force: true,
|
||||
},
|
||||
})
|
||||
loadPluginReleases(pluginDetail.value ?? props.plugin, true)
|
||||
} catch (error) {
|
||||
pluginDetail.value = null
|
||||
loadError.value = t('plugin.updateHistoryLoadFailed')
|
||||
console.error(error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadPluginReleases(plugin: Plugin | null | undefined = resolvedPlugin.value, force = false) {
|
||||
if (!plugin?.id || !plugin?.repo_url || !plugin?.release) {
|
||||
releaseDetail.value = null
|
||||
releaseError.value = ''
|
||||
return
|
||||
}
|
||||
|
||||
releaseLoading.value = true
|
||||
releaseError.value = ''
|
||||
|
||||
try {
|
||||
releaseDetail.value = await api.get(`plugin/releases/${plugin.id}`, {
|
||||
params: {
|
||||
repo_url: plugin.repo_url,
|
||||
force,
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
releaseDetail.value = null
|
||||
releaseError.value = t('plugin.releaseVersionsLoadFailed')
|
||||
console.error(error)
|
||||
} finally {
|
||||
releaseLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** 触发插件更新操作。 */
|
||||
function handleUpdate(releaseItem?: PluginReleaseVersion) {
|
||||
emit('update', releaseItem?.is_latest ? undefined : releaseItem?.version, resolvedPlugin.value?.repo_url)
|
||||
}
|
||||
|
||||
watch(
|
||||
() => [visible.value, props.plugin?.id],
|
||||
([isVisible]) => {
|
||||
if (isVisible) {
|
||||
loadPluginHistory()
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
</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 })">
|
||||
<VDialog v-if="visible" v-model="visible" width="680" max-height="85vh" scrollable>
|
||||
<VCard :title="t('plugin.updateHistoryTitle', { name: resolvedPlugin?.plugin_name })">
|
||||
<VDialogCloseBtn v-model="visible" />
|
||||
<VDivider />
|
||||
<VersionHistory :history="props.plugin?.history" />
|
||||
<template v-if="props.showUpdateAction">
|
||||
<VProgressLinear v-if="releaseLoading && !loading" indeterminate color="primary" height="2" />
|
||||
<div v-if="loading" class="plugin-version-history-dialog__loading">
|
||||
<VProgressCircular indeterminate color="primary" />
|
||||
</div>
|
||||
<VCardText v-else-if="loadError && !hasHistory">
|
||||
<VAlert type="warning" variant="tonal" density="compact" :text="loadError" />
|
||||
</VCardText>
|
||||
<VCardText v-else-if="!hasHistory && !releaseLoading">
|
||||
<VAlert type="info" variant="tonal" density="compact" :text="t('plugin.updateHistoryEmpty')" />
|
||||
</VCardText>
|
||||
<template v-else>
|
||||
<VCardText v-if="releaseError" class="pb-0">
|
||||
<VAlert type="warning" variant="tonal" density="compact" :text="releaseError" />
|
||||
</VCardText>
|
||||
<VersionHistory
|
||||
:history="resolvedHistory"
|
||||
:has-action="version => shouldShowReleaseButton(releaseItemByHistoryVersion(version))"
|
||||
>
|
||||
<template #meta="{ version }">
|
||||
<div v-if="releaseItemByHistoryVersion(version)" class="plugin-release-meta">
|
||||
<span v-if="formatReleaseDate(releaseItemByHistoryVersion(version)?.published_at)" class="plugin-release-meta__date">
|
||||
{{ formatReleaseDate(releaseItemByHistoryVersion(version)?.published_at) }}
|
||||
</span>
|
||||
<VChip v-if="releaseItemByHistoryVersion(version)?.is_latest" size="x-small" color="primary" variant="tonal">
|
||||
{{ t('plugin.latestVersion') }}
|
||||
</VChip>
|
||||
<VChip v-if="releaseItemByHistoryVersion(version)?.is_current" size="x-small" color="success" variant="tonal">
|
||||
{{ t('plugin.currentVersion') }}
|
||||
</VChip>
|
||||
</div>
|
||||
</template>
|
||||
<template #action="{ version }">
|
||||
<VBtn
|
||||
v-if="shouldShowReleaseButton(releaseItemByHistoryVersion(version))"
|
||||
class="plugin-release-button"
|
||||
size="small"
|
||||
min-width="5rem"
|
||||
:color="releaseItemByHistoryVersion(version)?.is_latest ? 'primary' : undefined"
|
||||
:variant="releaseItemByHistoryVersion(version)?.is_latest ? 'flat' : 'tonal'"
|
||||
:disabled="
|
||||
releaseItemByHistoryVersion(version)?.is_current ||
|
||||
(releaseItemByHistoryVersion(version)?.is_latest && resolvedPlugin?.system_version_compatible === false)
|
||||
"
|
||||
@click.stop="handleUpdate(releaseItemByHistoryVersion(version))"
|
||||
>
|
||||
{{
|
||||
releaseItemByHistoryVersion(version)?.is_latest
|
||||
? latestActionText
|
||||
: t('plugin.installReleaseVersion')
|
||||
}}
|
||||
</VBtn>
|
||||
</template>
|
||||
</VersionHistory>
|
||||
</template>
|
||||
<template v-if="shouldShowUpdatePanel">
|
||||
<VDivider />
|
||||
<VCardItem>
|
||||
<VAlert
|
||||
v-if="props.plugin?.system_version_compatible === false"
|
||||
v-if="resolvedPlugin?.system_version_compatible === false"
|
||||
type="warning"
|
||||
variant="tonal"
|
||||
density="compact"
|
||||
class="mb-3"
|
||||
:text="props.plugin?.system_version_message || t('plugin.incompatibleSystemVersion')"
|
||||
:text="resolvedPlugin?.system_version_message || t('plugin.incompatibleSystemVersion')"
|
||||
/>
|
||||
<VBtn @click="handleUpdate" block :disabled="props.plugin?.system_version_compatible === false">
|
||||
<VBtn
|
||||
@click="handleUpdate()"
|
||||
block
|
||||
:disabled="resolvedPlugin?.system_version_compatible === false"
|
||||
>
|
||||
<template #prepend>
|
||||
<VIcon icon="mdi-arrow-up-circle-outline" />
|
||||
</template>
|
||||
@@ -68,3 +263,31 @@ function handleUpdate() {
|
||||
</VCard>
|
||||
</VDialog>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.plugin-version-history-dialog__loading {
|
||||
min-height: 12rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.plugin-release-button {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.plugin-release-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.plugin-release-meta__date {
|
||||
color: rgba(var(--v-theme-on-surface), var(--v-disabled-opacity));
|
||||
font-size: 0.875rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
</style>
|
||||
|
||||
@@ -89,12 +89,12 @@ async function handleReset() {
|
||||
</VCol>
|
||||
</VRow>
|
||||
</VCardText>
|
||||
<VCardActions>
|
||||
<VBtn color="error" @click="handleReset" prepend-icon="mdi-restore" class="px-5 me-3">
|
||||
<VCardActions class="app-dialog-actions">
|
||||
<VBtn color="error" variant="tonal" @click="handleReset" prepend-icon="mdi-restore">
|
||||
{{ t('dialog.rcloneConfig.reset') }}
|
||||
</VBtn>
|
||||
<VSpacer />
|
||||
<VBtn @click="handleDone" prepend-icon="mdi-check" class="px-5 me-3">
|
||||
<VBtn color="primary" variant="flat" @click="handleDone" prepend-icon="mdi-check" class="px-5">
|
||||
{{ t('dialog.rcloneConfig.complete') }}
|
||||
</VBtn>
|
||||
</VCardActions>
|
||||
|
||||
@@ -102,17 +102,28 @@ interface EpisodeFormatRecommendData {
|
||||
const episodeFormatRecommendState = reactive<{
|
||||
loading: boolean
|
||||
ruleName?: string
|
||||
rulePattern?: string
|
||||
generatedFormat?: string
|
||||
sampleFile?: string
|
||||
lastMessage?: string
|
||||
}>({
|
||||
loading: false,
|
||||
ruleName: undefined,
|
||||
rulePattern: undefined,
|
||||
generatedFormat: undefined,
|
||||
sampleFile: undefined,
|
||||
lastMessage: undefined,
|
||||
})
|
||||
|
||||
const episodeFormatRuleConfigured = ref<boolean | undefined>(undefined)
|
||||
|
||||
interface TargetDirectoryOption {
|
||||
title: string
|
||||
value: string
|
||||
}
|
||||
|
||||
const AUTO_TARGET_PATH_VALUE = '__moviepilot_auto_target_path__'
|
||||
|
||||
// 生成文件项稳定键,用于去重和状态同步。
|
||||
function getFileItemKey(item?: FileItem) {
|
||||
return [item?.storage ?? '', item?.type ?? '', item?.path ?? ''].join('|')
|
||||
@@ -162,7 +173,7 @@ let episodeGroupQueryTimer: ReturnType<typeof setTimeout> | undefined
|
||||
// 查询存储
|
||||
async function loadStorages() {
|
||||
try {
|
||||
const result: { [key: string]: any } = await api.get('system/setting/Storages')
|
||||
const result: { [key: string]: any } = await api.get('system/setting/public/Storages')
|
||||
|
||||
storages.value = result.data?.value ?? []
|
||||
} catch (error) {
|
||||
@@ -172,10 +183,27 @@ async function loadStorages() {
|
||||
|
||||
// 存储字典
|
||||
const storageOptions = computed(() => {
|
||||
return storages.value.map(item => ({
|
||||
title: item.name,
|
||||
value: item.type,
|
||||
}))
|
||||
return [
|
||||
{
|
||||
title: t('dialog.reorganize.auto'),
|
||||
value: null,
|
||||
},
|
||||
...storages.value.map(item => ({
|
||||
title: item.name,
|
||||
value: item.type,
|
||||
})),
|
||||
]
|
||||
})
|
||||
|
||||
// 整理方式选项,包含可提交 null 的自动项。
|
||||
const manualTransferTypeOptions = computed(() => {
|
||||
return [
|
||||
{
|
||||
title: t('dialog.reorganize.auto'),
|
||||
value: null,
|
||||
},
|
||||
...transferTypeOptions,
|
||||
]
|
||||
})
|
||||
|
||||
// 剧集组选项属性
|
||||
@@ -260,16 +288,20 @@ const disableEpisodeDetail = computed(() => {
|
||||
}
|
||||
})
|
||||
|
||||
const initialTargetPath = normalizeTargetPath(props.target_path)
|
||||
|
||||
// 表单
|
||||
const transferForm = reactive<TransferForm>({
|
||||
fileitem: {} as FileItem,
|
||||
logid: 0,
|
||||
target_storage: props.target_storage ?? 'local',
|
||||
target_path: props.target_path ?? '',
|
||||
transfer_type: '',
|
||||
target_storage: initialTargetPath ? (props.target_storage ?? 'local') : null,
|
||||
target_path: initialTargetPath,
|
||||
transfer_type: null,
|
||||
min_filesize: 0,
|
||||
scrape: false,
|
||||
scrape: initialTargetPath ? false : null,
|
||||
from_history: false,
|
||||
library_type_folder: null,
|
||||
library_category_folder: null,
|
||||
episode_group: null,
|
||||
})
|
||||
|
||||
@@ -279,19 +311,54 @@ const directories = ref<TransferDirectoryConf[]>([])
|
||||
// 查询目录
|
||||
async function loadDirectories() {
|
||||
try {
|
||||
const result: { [key: string]: any } = await api.get('system/setting/Directories')
|
||||
const result: { [key: string]: any } = await api.get('system/setting/public/Directories')
|
||||
directories.value = result.data?.value ?? []
|
||||
} catch (error) {
|
||||
console.log(error)
|
||||
}
|
||||
}
|
||||
|
||||
// 目的目录下拉框
|
||||
const targetDirectories = computed(() => {
|
||||
const libraryDirectories = directories.value.map(item => item.library_path)
|
||||
return [...new Set(libraryDirectories)]
|
||||
// 目的目录下拉框,第一项用于把目标路径显式重置为后端自动匹配。
|
||||
const targetDirectoryOptions = computed<TargetDirectoryOption[]>(() => {
|
||||
const libraryDirectories = directories.value.map(item => item.library_path).filter(Boolean) as string[]
|
||||
return [
|
||||
{
|
||||
title: t('dialog.reorganize.auto'),
|
||||
value: AUTO_TARGET_PATH_VALUE,
|
||||
},
|
||||
...[...new Set(libraryDirectories)].map(path => ({
|
||||
title: path,
|
||||
value: path,
|
||||
})),
|
||||
]
|
||||
})
|
||||
|
||||
// 目标路径选择值,用哨兵值把界面上的“自动”和接口里的 null 解耦。
|
||||
const targetPathSelection = computed({
|
||||
get() {
|
||||
return transferForm.target_path ?? AUTO_TARGET_PATH_VALUE
|
||||
},
|
||||
set(value: string | null) {
|
||||
const targetPath = normalizeTargetPath(value)
|
||||
if (!targetPath || targetPath === AUTO_TARGET_PATH_VALUE) {
|
||||
resetAutomaticTargetConfig()
|
||||
return
|
||||
}
|
||||
|
||||
transferForm.target_path = targetPath
|
||||
},
|
||||
})
|
||||
|
||||
// 重置为完全自动匹配状态,提交时不携带目标路径及其派生配置。
|
||||
function resetAutomaticTargetConfig() {
|
||||
transferForm.target_storage = null
|
||||
transferForm.target_path = null
|
||||
transferForm.transfer_type = null
|
||||
transferForm.scrape = null
|
||||
transferForm.library_type_folder = null
|
||||
transferForm.library_category_folder = null
|
||||
}
|
||||
|
||||
// 监听目的路径变化,配置默认值
|
||||
watch(
|
||||
() => transferForm.target_path,
|
||||
@@ -305,6 +372,7 @@ watch(
|
||||
transferForm.library_category_folder = directory.library_category_folder ?? false
|
||||
transferForm.library_type_folder = directory.library_type_folder ?? false
|
||||
} else {
|
||||
transferForm.target_storage = transferForm.target_storage || 'local'
|
||||
transferForm.transfer_type = transferForm.transfer_type || 'copy'
|
||||
transferForm.scrape = false
|
||||
transferForm.library_category_folder = false
|
||||
@@ -312,9 +380,9 @@ watch(
|
||||
}
|
||||
} else {
|
||||
// 路径为空时, 恢复到`自动`条件
|
||||
transferForm.transfer_type = ''
|
||||
transferForm.library_type_folder = undefined
|
||||
transferForm.library_category_folder = undefined
|
||||
transferForm.transfer_type = null
|
||||
transferForm.library_type_folder = null
|
||||
transferForm.library_category_folder = null
|
||||
}
|
||||
},
|
||||
)
|
||||
@@ -332,15 +400,22 @@ watch(
|
||||
)
|
||||
|
||||
// 切换媒体类型或识别源时,非 TMDB 电视剧不保留剧集组选择。
|
||||
watch([() => transferForm.type_name, () => mediaSource.value], ([typeName, source]) => {
|
||||
if (typeName === '电视剧' && source === 'themoviedb' && transferForm.tmdbid) {
|
||||
getEpisodeGroups(transferForm.tmdbid)
|
||||
return
|
||||
}
|
||||
transferForm.episode_group = null
|
||||
episodeGroups.value = []
|
||||
})
|
||||
|
||||
watch(
|
||||
[() => transferForm.type_name, () => mediaSource.value],
|
||||
([typeName, source]) => {
|
||||
if (typeName === '电视剧' && source === 'themoviedb' && transferForm.tmdbid) {
|
||||
getEpisodeGroups(transferForm.tmdbid)
|
||||
return
|
||||
() => transferForm.episode_group,
|
||||
episodeGroup => {
|
||||
const normalizedEpisodeGroup = normalizeEpisodeGroup(episodeGroup)
|
||||
if (episodeGroup !== normalizedEpisodeGroup) {
|
||||
transferForm.episode_group = normalizedEpisodeGroup
|
||||
}
|
||||
transferForm.episode_group = null
|
||||
episodeGroups.value = []
|
||||
},
|
||||
)
|
||||
|
||||
@@ -397,6 +472,32 @@ function getUniqueValues(values: (string | undefined)[]) {
|
||||
return [...new Set(values.map(item => item?.trim()).filter(Boolean) as string[])]
|
||||
}
|
||||
|
||||
// 归一化可选目的路径,保证未指定时向接口传递 null 而不是空字符串。
|
||||
function normalizeTargetPath(path?: string | null) {
|
||||
const normalizedPath = path?.trim()
|
||||
return normalizedPath || null
|
||||
}
|
||||
|
||||
// 归一化可选文本参数,保证自动项提交 null 而不是空字符串。
|
||||
function normalizeOptionalText(value?: string | null) {
|
||||
const normalizedValue = value?.trim()
|
||||
return normalizedValue || null
|
||||
}
|
||||
|
||||
// 归一化剧集组值,兼容历史对象态值。
|
||||
function normalizeEpisodeGroup(episodeGroup?: string | { value?: string | null } | null) {
|
||||
if (!episodeGroup) return null
|
||||
if (typeof episodeGroup === 'string') {
|
||||
const normalizedEpisodeGroup = episodeGroup.trim()
|
||||
return normalizedEpisodeGroup || null
|
||||
}
|
||||
if (typeof episodeGroup === 'object' && typeof episodeGroup.value === 'string') {
|
||||
const normalizedEpisodeGroup = episodeGroup.value.trim()
|
||||
return normalizedEpisodeGroup || null
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
// 统一解析接口返回的数字字段,兼容 string/number
|
||||
function toPreviewNumber(value: unknown) {
|
||||
if (value === undefined || value === null || value === '') return undefined
|
||||
@@ -511,6 +612,99 @@ const previewFileRows = computed(() => {
|
||||
})
|
||||
})
|
||||
|
||||
// 标准化预览项中的识别词命中详情
|
||||
function getPreviewApplyWords(item: ManualTransferPreviewItem) {
|
||||
return [
|
||||
...new Set((item.apply_words ?? []).map(word => word?.trim()).filter((word): word is string => Boolean(word))),
|
||||
]
|
||||
}
|
||||
|
||||
// 手动整理识别词应用详情
|
||||
const previewCustomWordDetails = computed(() => {
|
||||
const groupedDetails = new Map<string, { sourceNames: string[]; orgString?: string; applyWords: string[] }>()
|
||||
|
||||
filteredPreviewItems.value.forEach(item => {
|
||||
const applyWords = getPreviewApplyWords(item)
|
||||
if (!applyWords.length) return
|
||||
|
||||
const sourceName = getFileName(item.source)
|
||||
const orgString = item.org_string?.trim() || undefined
|
||||
const detailKey = JSON.stringify(applyWords)
|
||||
const existingDetail = groupedDetails.get(detailKey)
|
||||
|
||||
if (existingDetail) {
|
||||
if (!existingDetail.sourceNames.includes(sourceName)) existingDetail.sourceNames.push(sourceName)
|
||||
return
|
||||
}
|
||||
|
||||
groupedDetails.set(detailKey, {
|
||||
sourceNames: [sourceName],
|
||||
orgString,
|
||||
applyWords,
|
||||
})
|
||||
})
|
||||
|
||||
return [...groupedDetails.values()].map(detail => ({
|
||||
sourceName:
|
||||
detail.sourceNames.length > 1
|
||||
? t('dialog.reorganize.customWordsSameRules', { count: detail.sourceNames.length })
|
||||
: detail.sourceNames[0],
|
||||
orgString: detail.sourceNames.length > 1 ? undefined : detail.orgString,
|
||||
applyWords: detail.applyWords,
|
||||
}))
|
||||
})
|
||||
|
||||
const previewEpisodeFormatRuleDetails = computed(() => {
|
||||
const episodeFormat = transferForm.episode_format?.trim()
|
||||
if (!episodeFormat) return []
|
||||
|
||||
const rulePattern = episodeFormatRecommendState.rulePattern?.trim()
|
||||
const isGeneratedEpisodeFormat =
|
||||
Boolean(episodeFormatRecommendState.generatedFormat) &&
|
||||
episodeFormatRecommendState.generatedFormat === episodeFormat
|
||||
|
||||
if (!isGeneratedEpisodeFormat || !episodeFormatRecommendState.ruleName) {
|
||||
return [
|
||||
{
|
||||
sourceName: t('dialog.reorganize.episodeFormatManualInput'),
|
||||
orgString: t('dialog.reorganize.episodeFormatFinal', {
|
||||
format: episodeFormat,
|
||||
}),
|
||||
applyWords: [],
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
return [
|
||||
{
|
||||
sourceName: t('dialog.reorganize.episodeFormatRecommendRule', {
|
||||
rule: episodeFormatRecommendState.ruleName,
|
||||
}),
|
||||
orgString: t('dialog.reorganize.episodeFormatFinal', {
|
||||
format: episodeFormat,
|
||||
}),
|
||||
applyWords: rulePattern
|
||||
? [
|
||||
t('dialog.reorganize.episodeFormatRulePattern', {
|
||||
pattern: rulePattern,
|
||||
}),
|
||||
]
|
||||
: [],
|
||||
},
|
||||
]
|
||||
})
|
||||
|
||||
const previewRecognitionDetails = computed(() => [
|
||||
...previewCustomWordDetails.value,
|
||||
...previewEpisodeFormatRuleDetails.value,
|
||||
])
|
||||
|
||||
const previewRecognitionDetailTitle = computed(() => {
|
||||
return previewCustomWordDetails.value.length
|
||||
? t('dialog.reorganize.customWordsApplied')
|
||||
: t('dialog.reorganize.episodeFormatRuleDetails')
|
||||
})
|
||||
|
||||
// 是否需要拓宽窗口
|
||||
const previewNeedsWideLayout = computed(() => {
|
||||
const candidates = [...previewFileRows.value.map(item => `${item.sourceName}${item.targetName}`)]
|
||||
@@ -550,9 +744,7 @@ const episodeFormatRecommendSelectedFileItems = computed(() => {
|
||||
const episodeFormatRecommendHasValidSelectedFiles = computed(() => {
|
||||
if (episodeFormatRecommendSelectedFileItems.value.length <= 1) return false
|
||||
|
||||
const directoryKeys = new Set(
|
||||
episodeFormatRecommendSelectedFileItems.value.map(item => getFileParentKey(item)),
|
||||
)
|
||||
const directoryKeys = new Set(episodeFormatRecommendSelectedFileItems.value.map(item => getFileParentKey(item)))
|
||||
return directoryKeys.size === 1
|
||||
})
|
||||
|
||||
@@ -563,19 +755,21 @@ const episodeFormatRecommendSourceItem = computed<FileItem | undefined>(() => {
|
||||
|
||||
const canRecommendEpisodeFormat = computed(() => {
|
||||
return (
|
||||
(Boolean(episodeFormatRecommendSourceItem.value?.path) ||
|
||||
episodeFormatRecommendHasValidSelectedFiles.value) &&
|
||||
(Boolean(episodeFormatRecommendSourceItem.value?.path) || episodeFormatRecommendHasValidSelectedFiles.value) &&
|
||||
!progressDialog.value &&
|
||||
!episodeFormatRecommendState.loading
|
||||
)
|
||||
})
|
||||
|
||||
const episodeFormatRecommendSelectionKey = computed(() => {
|
||||
const sourceItem = episodeFormatRecommendSourceItem.value
|
||||
if (sourceItem) return getFileItemKey(sourceItem)
|
||||
return episodeFormatRecommendSelectedFileItems.value.map(item => getFileItemKey(item)).join('||')
|
||||
})
|
||||
|
||||
const episodeFormatRecommendTooltip = computed(() => {
|
||||
if (episodeFormatRecommendState.loading) return t('dialog.reorganize.episodeFormatRecommendLoading')
|
||||
if (
|
||||
normalizedItems.value.length > 1 &&
|
||||
!episodeFormatRecommendHasValidSelectedFiles.value
|
||||
) {
|
||||
if (normalizedItems.value.length > 1 && !episodeFormatRecommendHasValidSelectedFiles.value) {
|
||||
return t('dialog.reorganize.episodeFormatRecommendInvalidSelection')
|
||||
}
|
||||
if (!episodeFormatRecommendSourceItem.value?.path && !episodeFormatRecommendHasValidSelectedFiles.value) {
|
||||
@@ -586,14 +780,14 @@ const episodeFormatRecommendTooltip = computed(() => {
|
||||
})
|
||||
|
||||
watch(
|
||||
() => getFileItemKey(episodeFormatRecommendSourceItem.value),
|
||||
sourceKey => {
|
||||
episodeFormatRecommendSelectionKey,
|
||||
() => {
|
||||
transferForm.fileitem = episodeFormatRecommendSourceItem.value ?? ({} as FileItem)
|
||||
if (!sourceKey) {
|
||||
episodeFormatRecommendState.ruleName = undefined
|
||||
episodeFormatRecommendState.sampleFile = undefined
|
||||
episodeFormatRecommendState.lastMessage = undefined
|
||||
}
|
||||
episodeFormatRecommendState.ruleName = undefined
|
||||
episodeFormatRecommendState.rulePattern = undefined
|
||||
episodeFormatRecommendState.generatedFormat = undefined
|
||||
episodeFormatRecommendState.sampleFile = undefined
|
||||
episodeFormatRecommendState.lastMessage = undefined
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
@@ -611,16 +805,15 @@ function getBatchItemsLabel(items: FileItem[]) {
|
||||
|
||||
// 构造整理请求
|
||||
function createTransferPayload(options: { item?: FileItem; items?: FileItem[]; logid?: number; preview?: boolean }) {
|
||||
const sourceItem =
|
||||
options.item ??
|
||||
(options.items?.length
|
||||
? options.items[0]
|
||||
: ({} as FileItem))
|
||||
const sourceItem = options.item ?? (options.items?.length ? options.items[0] : ({} as FileItem))
|
||||
const payload: ManualTransferPayload = {
|
||||
...transferForm,
|
||||
fileitem: sourceItem,
|
||||
logid: options.logid ?? 0,
|
||||
episode_group: transferForm.episode_group?.trim() || null,
|
||||
target_storage: normalizeOptionalText(transferForm.target_storage),
|
||||
target_path: normalizeTargetPath(transferForm.target_path),
|
||||
transfer_type: normalizeOptionalText(transferForm.transfer_type),
|
||||
episode_group: normalizeEpisodeGroup(transferForm.episode_group),
|
||||
}
|
||||
|
||||
if (options.items?.length) {
|
||||
@@ -645,7 +838,7 @@ async function requestManualTransfer<T = any>(
|
||||
// 加载剧集格式规则配置状态,用于决定是否允许自动推荐。
|
||||
async function loadEpisodeFormatRuleConfiguration() {
|
||||
try {
|
||||
const result: { [key: string]: any } = await api.get('system/setting/EpisodeFormatRuleTable')
|
||||
const result: { [key: string]: any } = await api.get('system/setting/public/EpisodeFormatRuleTable')
|
||||
episodeFormatRuleConfigured.value = Boolean(result.data?.value?.length)
|
||||
} catch (error) {
|
||||
console.log(error)
|
||||
@@ -700,6 +893,8 @@ async function handleRecommendEpisodeFormat() {
|
||||
|
||||
transferForm.episode_format = data.episode_format
|
||||
episodeFormatRecommendState.ruleName = data.rule_name
|
||||
episodeFormatRecommendState.rulePattern = data.pattern
|
||||
episodeFormatRecommendState.generatedFormat = data.episode_format
|
||||
episodeFormatRecommendState.sampleFile = data.sample_file
|
||||
episodeFormatRecommendState.lastMessage = data.message
|
||||
|
||||
@@ -1099,8 +1294,8 @@ async function transfer(background: boolean = false) {
|
||||
emit('done')
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadDirectories()
|
||||
onMounted(async () => {
|
||||
await loadDirectories()
|
||||
loadStorages()
|
||||
loadEpisodeFormatRuleConfiguration()
|
||||
})
|
||||
@@ -1150,20 +1345,19 @@ onUnmounted(() => {
|
||||
<VSelect
|
||||
v-model="transferForm.transfer_type"
|
||||
:label="t('dialog.reorganize.transferType')"
|
||||
:items="transferTypeOptions"
|
||||
:items="manualTransferTypeOptions"
|
||||
:hint="t('dialog.reorganize.transferTypeHint')"
|
||||
persistent-hint
|
||||
prepend-inner-icon="mdi-swap-horizontal"
|
||||
>
|
||||
<template v-slot:selection="{ item }">
|
||||
{{ transferForm.transfer_type === '' ? t('dialog.reorganize.auto') : item.title }}
|
||||
</template>
|
||||
</VSelect>
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12">
|
||||
<VCombobox
|
||||
v-model="transferForm.target_path"
|
||||
:items="targetDirectories"
|
||||
v-model="targetPathSelection"
|
||||
:items="targetDirectoryOptions"
|
||||
item-title="title"
|
||||
item-value="value"
|
||||
:return-object="false"
|
||||
:label="t('dialog.reorganize.targetPath')"
|
||||
:placeholder="t('dialog.reorganize.targetPathPlaceholder')"
|
||||
:hint="t('dialog.reorganize.targetPathHint')"
|
||||
@@ -1218,9 +1412,11 @@ onUnmounted(() => {
|
||||
</VRow>
|
||||
<VRow v-show="transferForm.type_name === '电视剧'">
|
||||
<VCol v-if="mediaSource === 'themoviedb'" cols="12" md="6">
|
||||
<VCombobox
|
||||
<VSelect
|
||||
v-model="transferForm.episode_group"
|
||||
:items="episodeGroupOptions"
|
||||
item-title="title"
|
||||
item-value="value"
|
||||
:item-props="episodeGroupItemProps"
|
||||
:loading="episodeGroupLoading"
|
||||
:disabled="!transferForm.tmdbid"
|
||||
@@ -1320,7 +1516,7 @@ onUnmounted(() => {
|
||||
</VCol>
|
||||
</VRow>
|
||||
<VRow>
|
||||
<VCol cols="12" md="6" v-if="transferForm.target_path">
|
||||
<VCol cols="12" md="6">
|
||||
<VSwitch
|
||||
v-model="transferForm.library_type_folder"
|
||||
:label="t('dialog.reorganize.typeFolderOption')"
|
||||
@@ -1328,7 +1524,7 @@ onUnmounted(() => {
|
||||
persistent-hint
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6" v-if="transferForm.target_path">
|
||||
<VCol cols="12" md="6">
|
||||
<VSwitch
|
||||
v-model="transferForm.library_category_folder"
|
||||
:label="t('dialog.reorganize.categoryFolderOption')"
|
||||
@@ -1354,35 +1550,39 @@ onUnmounted(() => {
|
||||
</VCol>
|
||||
</VRow>
|
||||
</VForm>
|
||||
<VCardActions class="reorganize-form-pane__actions pt-3 px-0 pb-0">
|
||||
<VBtn
|
||||
color="info"
|
||||
:variant="previewVisible ? 'tonal' : 'text'"
|
||||
@click="togglePreview"
|
||||
:prepend-icon="previewToggleIcon"
|
||||
class="reorganize-action-btn reorganize-action-btn--preview"
|
||||
:class="{ 'reorganize-action-btn--active': previewVisible }"
|
||||
:loading="previewLoading"
|
||||
>
|
||||
{{ t('dialog.reorganize.previewResult') }}
|
||||
</VBtn>
|
||||
<VBtn
|
||||
color="success"
|
||||
@click="transfer(true)"
|
||||
prepend-icon="mdi-plus"
|
||||
class="reorganize-action-btn reorganize-action-btn--queue"
|
||||
>
|
||||
{{ t('dialog.reorganize.addToQueue') }}
|
||||
</VBtn>
|
||||
<VBtn
|
||||
@click="transfer(false)"
|
||||
prepend-icon="mdi-arrow-right-bold"
|
||||
class="reorganize-action-btn reorganize-action-btn--primary"
|
||||
>
|
||||
{{ t('dialog.reorganize.reorganizeNow') }}
|
||||
</VBtn>
|
||||
</VCardActions>
|
||||
</div>
|
||||
<VCardActions class="app-dialog-actions reorganize-form-pane__actions">
|
||||
<VBtn
|
||||
color="info"
|
||||
variant="tonal"
|
||||
@click="togglePreview"
|
||||
:prepend-icon="previewToggleIcon"
|
||||
class="reorganize-action-btn reorganize-action-btn--preview"
|
||||
:class="{ 'reorganize-action-btn--active': previewVisible }"
|
||||
:loading="previewLoading"
|
||||
>
|
||||
{{ t('dialog.reorganize.previewResult') }}
|
||||
</VBtn>
|
||||
<VBtn
|
||||
color="success"
|
||||
variant="tonal"
|
||||
@click="transfer(true)"
|
||||
prepend-icon="mdi-plus"
|
||||
class="reorganize-action-btn reorganize-action-btn--queue"
|
||||
>
|
||||
{{ t('dialog.reorganize.addToQueue') }}
|
||||
</VBtn>
|
||||
<VSpacer />
|
||||
<VBtn
|
||||
color="primary"
|
||||
variant="flat"
|
||||
@click="transfer(false)"
|
||||
prepend-icon="mdi-arrow-right-bold"
|
||||
class="reorganize-action-btn reorganize-action-btn--primary"
|
||||
>
|
||||
{{ t('dialog.reorganize.reorganizeNow') }}
|
||||
</VBtn>
|
||||
</VCardActions>
|
||||
</div>
|
||||
<div v-show="previewVisible" class="reorganize-preview-pane">
|
||||
<div class="reorganize-preview-pane__header">
|
||||
@@ -1439,13 +1639,43 @@ onUnmounted(() => {
|
||||
<span class="preview-overview-card__value">{{ previewEpisodeCountText }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="previewRecognitionDetails.length" class="preview-custom-words">
|
||||
<div class="preview-custom-words__title">
|
||||
<VIcon icon="mdi-tag-text-outline" size="16" />
|
||||
<span>{{ previewRecognitionDetailTitle }}</span>
|
||||
</div>
|
||||
<div class="preview-custom-words__items">
|
||||
<div
|
||||
v-for="(detail, index) in previewRecognitionDetails"
|
||||
:key="`${detail.sourceName}-${index}`"
|
||||
class="preview-custom-words__item"
|
||||
>
|
||||
<div class="preview-custom-words__source">{{ detail.sourceName }}</div>
|
||||
<div v-if="detail.orgString" class="preview-custom-words__original">
|
||||
{{ detail.orgString }}
|
||||
</div>
|
||||
<div v-if="detail.applyWords.length" class="preview-custom-words__chips">
|
||||
<VChip
|
||||
v-for="(word, wordIndex) in detail.applyWords"
|
||||
:key="`${word}-${wordIndex}`"
|
||||
variant="outlined"
|
||||
color="info"
|
||||
size="small"
|
||||
class="preview-custom-words__chip"
|
||||
>
|
||||
{{ word }}
|
||||
</VChip>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="reorganize-preview-list">
|
||||
<div v-if="pagedPreviewRows.length" ref="previewFileBodyRef" class="preview-file-body">
|
||||
<div
|
||||
v-for="(item, index) in pagedPreviewRows"
|
||||
:key="`${item.source}-${item.target}-${index}`"
|
||||
class="preview-file-row"
|
||||
class="preview-file-row app-surface-shape"
|
||||
:class="{ 'preview-file-row--failed': item.success === false }"
|
||||
>
|
||||
<div class="preview-file-row__card preview-file-row__card--source">
|
||||
@@ -1585,17 +1815,9 @@ onUnmounted(() => {
|
||||
}
|
||||
|
||||
.reorganize-form-pane__actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
gap: 0.75rem;
|
||||
margin-block-start: auto;
|
||||
}
|
||||
|
||||
.reorganize-action-btn {
|
||||
min-inline-size: 0;
|
||||
}
|
||||
|
||||
.reorganize-action-btn--active {
|
||||
background: rgba(var(--v-theme-info), 0.12);
|
||||
}
|
||||
@@ -1656,8 +1878,6 @@ onUnmounted(() => {
|
||||
}
|
||||
|
||||
.preview-note {
|
||||
border: 1px solid rgba(var(--v-border-color), var(--v-border-opacity));
|
||||
border-radius: 1rem;
|
||||
color: rgb(var(--v-theme-error));
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.5;
|
||||
@@ -1674,8 +1894,6 @@ onUnmounted(() => {
|
||||
.preview-overview-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border: 1px solid rgba(var(--v-border-color), var(--v-border-opacity));
|
||||
border-radius: 1rem;
|
||||
gap: 0.375rem;
|
||||
min-inline-size: 0;
|
||||
padding-block: 0.875rem;
|
||||
@@ -1698,6 +1916,64 @@ onUnmounted(() => {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.preview-custom-words {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
padding-block: 0.875rem;
|
||||
padding-inline: 1rem;
|
||||
}
|
||||
|
||||
.preview-custom-words__title {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
color: rgb(var(--v-theme-info));
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
gap: 0.375rem;
|
||||
}
|
||||
|
||||
.preview-custom-words__items {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
min-inline-size: 0;
|
||||
}
|
||||
|
||||
.preview-custom-words__item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.375rem;
|
||||
min-inline-size: 0;
|
||||
}
|
||||
|
||||
.preview-custom-words__source {
|
||||
color: rgb(var(--v-theme-on-surface));
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 600;
|
||||
line-height: 1.4;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.preview-custom-words__original {
|
||||
color: rgba(var(--v-theme-on-surface), var(--v-medium-emphasis-opacity));
|
||||
font-size: 0.75rem;
|
||||
line-height: 1.4;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.preview-custom-words__chips {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.375rem;
|
||||
min-inline-size: 0;
|
||||
}
|
||||
|
||||
.preview-custom-words__chip {
|
||||
max-inline-size: 100%;
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.reorganize-preview-pane__scroll {
|
||||
display: flex;
|
||||
overflow: hidden auto;
|
||||
@@ -1734,8 +2010,6 @@ onUnmounted(() => {
|
||||
overflow: visible;
|
||||
flex: 0 0 auto;
|
||||
flex-direction: column;
|
||||
border: 1px solid rgba(var(--v-border-color), var(--v-border-opacity));
|
||||
border-radius: 1rem;
|
||||
margin-block-end: 1.5rem;
|
||||
margin-inline: 1.5rem;
|
||||
min-block-size: 0;
|
||||
@@ -1797,11 +2071,13 @@ onUnmounted(() => {
|
||||
}
|
||||
|
||||
.preview-file-row__path {
|
||||
overflow: hidden;
|
||||
overflow: visible;
|
||||
color: rgba(var(--v-theme-on-surface), var(--v-medium-emphasis-opacity));
|
||||
font-size: 0.8125rem;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
line-height: 1.4;
|
||||
overflow-wrap: anywhere;
|
||||
white-space: normal;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.preview-file-row__card--target .preview-file-row__name {
|
||||
@@ -1881,15 +2157,9 @@ onUnmounted(() => {
|
||||
border-inline-end: none;
|
||||
}
|
||||
|
||||
.reorganize-form-pane__actions {
|
||||
display: grid;
|
||||
justify-content: stretch;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.reorganize-action-btn {
|
||||
inline-size: 100%;
|
||||
min-block-size: 2.75rem;
|
||||
padding-inline: 1rem;
|
||||
}
|
||||
|
||||
.reorganize-preview-pane__summary {
|
||||
@@ -1904,14 +2174,10 @@ onUnmounted(() => {
|
||||
|
||||
@media (width <= 640px) {
|
||||
.reorganize-form-pane__actions {
|
||||
justify-content: stretch;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.reorganize-action-btn {
|
||||
min-inline-size: 0;
|
||||
}
|
||||
|
||||
.reorganize-action-btn--primary {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ import { useUserStore, useGlobalSettingsStore } from '@/stores'
|
||||
import SearchSiteDialog from '@/components/dialog/SearchSiteDialog.vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useDisplay } from 'vuetify'
|
||||
import { hasPermission, filterMenusByPermission } from '@/utils/permission'
|
||||
import { buildUserPermissionContext, hasPermission, filterMenusByPermission } from '@/utils/permission'
|
||||
|
||||
// 显示器宽度
|
||||
const display = useDisplay()
|
||||
@@ -30,41 +30,29 @@ const userStore = useUserStore()
|
||||
const globalSettingsStore = useGlobalSettingsStore()
|
||||
const globalSettings = globalSettingsStore.globalSettings
|
||||
|
||||
// 超级用户
|
||||
const superUser = userStore.superUser
|
||||
|
||||
// 当前用户名
|
||||
const userName = userStore.userName
|
||||
const userPermissions = computed(() => buildUserPermissionContext(userStore.superUser, userStore.permissions))
|
||||
|
||||
// 权限检查
|
||||
const hasSearchPermission = computed(() => {
|
||||
return hasPermission(
|
||||
{
|
||||
is_superuser: userStore.superUser,
|
||||
...userStore.permissions,
|
||||
},
|
||||
'search',
|
||||
)
|
||||
return hasPermission(userPermissions.value, 'search')
|
||||
})
|
||||
|
||||
const hasDiscoveryPermission = computed(() => {
|
||||
return hasPermission(userPermissions.value, 'discovery')
|
||||
})
|
||||
|
||||
const hasSubscribePermission = computed(() => {
|
||||
return hasPermission(
|
||||
{
|
||||
is_superuser: userStore.superUser,
|
||||
...userStore.permissions,
|
||||
},
|
||||
'subscribe',
|
||||
)
|
||||
return hasPermission(userPermissions.value, 'subscribe')
|
||||
})
|
||||
|
||||
const hasManagePermission = computed(() => {
|
||||
return hasPermission(
|
||||
{
|
||||
is_superuser: userStore.superUser,
|
||||
...userStore.permissions,
|
||||
},
|
||||
'manage',
|
||||
)
|
||||
return hasPermission(userPermissions.value, 'manage')
|
||||
})
|
||||
|
||||
const hasAdminPermission = computed(() => {
|
||||
return hasPermission(userPermissions.value, 'admin')
|
||||
})
|
||||
|
||||
// 是否显示合集搜索项(当SEARCH_SOURCE包含themoviedb时显示)
|
||||
@@ -79,6 +67,7 @@ const SubscribeItems = ref<Subscribe[]>([])
|
||||
const chooseSiteDialog = ref(false)
|
||||
const selectedSites = ref<number[]>([])
|
||||
const allSites = ref<Site[]>([])
|
||||
const siteSearchType = ref<'torrent' | 'subtitle'>('torrent')
|
||||
|
||||
// 定义事件
|
||||
const emit = defineEmits(['close', 'update:modelValue'])
|
||||
@@ -139,6 +128,7 @@ function getMenus(): NavMenu[] {
|
||||
to: item.to,
|
||||
header: item.header,
|
||||
admin: item.admin,
|
||||
permission: item.permission,
|
||||
}),
|
||||
)
|
||||
// 设置标签页
|
||||
@@ -151,6 +141,7 @@ function getMenus(): NavMenu[] {
|
||||
to: `/setting?tab=${item.tab}`,
|
||||
header: '',
|
||||
admin: true,
|
||||
permission: 'admin',
|
||||
description: item.description,
|
||||
}),
|
||||
)
|
||||
@@ -158,12 +149,6 @@ function getMenus(): NavMenu[] {
|
||||
return menus
|
||||
}
|
||||
|
||||
// 获取用户权限信息
|
||||
const userPermissions = computed(() => ({
|
||||
is_superuser: userStore.superUser,
|
||||
...userStore.permissions,
|
||||
}))
|
||||
|
||||
// 匹配的菜单列表
|
||||
const matchedMenuItems = computed(() => {
|
||||
if (!searchWord.value) return []
|
||||
@@ -201,7 +186,7 @@ async function fetchInstalledPlugins() {
|
||||
// 匹配的插件列表
|
||||
const matchedPluginItems = computed(() => {
|
||||
if (!searchWord.value) return []
|
||||
if (!hasManagePermission.value) return []
|
||||
if (!hasAdminPermission.value) return []
|
||||
const lowerWord = (searchWord.value as string).toLowerCase()
|
||||
return pluginItems.value.filter((item: Plugin) => {
|
||||
if (!item.plugin_name && !item.plugin_desc) return false
|
||||
@@ -221,7 +206,7 @@ async function fetchSubscribes() {
|
||||
// 从接口加载用户站点偏好设置
|
||||
const loadUserSitePreferences = async () => {
|
||||
try {
|
||||
const result = await api.get('system/setting/IndexerSites')
|
||||
const result = await api.get('system/setting/public/IndexerSites')
|
||||
if (result && result.data && result.data.value) {
|
||||
selectedSites.value = result.data.value
|
||||
return
|
||||
@@ -247,7 +232,8 @@ async function queryAllSites() {
|
||||
}
|
||||
|
||||
// 打开站点选择对话框
|
||||
const openSiteDialog = () => {
|
||||
const openSiteDialog = (type: 'torrent' | 'subtitle' = 'torrent') => {
|
||||
siteSearchType.value = type
|
||||
chooseSiteDialog.value = true
|
||||
}
|
||||
|
||||
@@ -257,7 +243,7 @@ const matchedSubscribeItems = computed(() => {
|
||||
if (!hasSubscribePermission.value) return []
|
||||
const lowerWord = (searchWord.value as string).toLowerCase()
|
||||
return SubscribeItems.value.filter((item: Subscribe) => {
|
||||
return (item.name.toLowerCase().includes(lowerWord) && (superUser || userName === item.username)) || false
|
||||
return (item.name.toLowerCase().includes(lowerWord) && (userStore.superUser || userName === item.username)) || false
|
||||
})
|
||||
})
|
||||
|
||||
@@ -265,12 +251,16 @@ const matchedSubscribeItems = computed(() => {
|
||||
function searchSites(sites: number[]) {
|
||||
chooseSiteDialog.value = false
|
||||
selectedSites.value = sites
|
||||
if (siteSearchType.value === 'subtitle') {
|
||||
searchSubtitle()
|
||||
return
|
||||
}
|
||||
searchTorrent()
|
||||
}
|
||||
|
||||
// 搜索资源
|
||||
function searchTorrent() {
|
||||
if (!searchWord.value) return
|
||||
if (!searchWord.value || !hasSearchPermission.value) return
|
||||
// 记录搜索词
|
||||
saveRecentSearches(searchWord.value)
|
||||
// 跳转到搜索页面
|
||||
@@ -279,6 +269,7 @@ function searchTorrent() {
|
||||
query: {
|
||||
keyword: searchWord.value,
|
||||
area: 'title',
|
||||
result_type: 'torrent',
|
||||
sites: selectedSites.value.join(','),
|
||||
},
|
||||
})
|
||||
@@ -287,10 +278,27 @@ function searchTorrent() {
|
||||
emit('close')
|
||||
}
|
||||
|
||||
// 搜索字幕资源
|
||||
function searchSubtitle() {
|
||||
if (!searchWord.value || !hasSearchPermission.value) return
|
||||
saveRecentSearches(searchWord.value)
|
||||
router.push({
|
||||
path: '/resource',
|
||||
query: {
|
||||
keyword: searchWord.value,
|
||||
area: 'title',
|
||||
result_type: 'subtitle',
|
||||
sites: selectedSites.value.join(','),
|
||||
},
|
||||
})
|
||||
dialog.value = false
|
||||
emit('close')
|
||||
}
|
||||
|
||||
// 跳转媒体搜索页面
|
||||
function searchMedia(searchType: string) {
|
||||
// 搜索类型 media/person
|
||||
if (!searchWord.value) return
|
||||
if (!searchWord.value || !hasDiscoveryPermission.value) return
|
||||
saveRecentSearches(searchWord.value)
|
||||
router.push({
|
||||
path: '/browse/media/search',
|
||||
@@ -371,7 +379,7 @@ onMounted(() => {
|
||||
searchWordInput.value?.focus()
|
||||
}, 500)
|
||||
// 根据权限加载不同的数据
|
||||
if (hasManagePermission.value) {
|
||||
if (hasAdminPermission.value) {
|
||||
fetchInstalledPlugins()
|
||||
}
|
||||
if (hasSubscribePermission.value) {
|
||||
@@ -413,58 +421,60 @@ onMounted(() => {
|
||||
<!-- 有搜索词时显示搜索入口和匹配结果 -->
|
||||
<VList lines="two" v-if="searchWord" class="search-list pa-0 py-2">
|
||||
<!-- 媒体搜索入口 -->
|
||||
<VListSubheader class="font-weight-medium text-uppercase px-4">
|
||||
{{ t('common.media') }}
|
||||
</VListSubheader>
|
||||
<template v-if="hasDiscoveryPermission">
|
||||
<VListSubheader class="font-weight-medium text-uppercase px-4">
|
||||
{{ t('common.media') }}
|
||||
</VListSubheader>
|
||||
|
||||
<VListItem density="comfortable" link @click="searchMedia('media')" class="search-result-item mx-2 my-1">
|
||||
<template #prepend>
|
||||
<div class="result-icon-wrapper">
|
||||
<VIcon icon="mdi-movie-search" size="small" color="medium-emphasis" />
|
||||
</div>
|
||||
</template>
|
||||
<VListItemTitle class="font-weight-medium text-body-2">
|
||||
{{ t('recommend.categoryMovie') }}、{{ t('recommend.categoryTV') }}
|
||||
</VListItemTitle>
|
||||
<VListItemSubtitle class="text-caption text-medium-emphasis">
|
||||
{{ t('common.search') }} <span class="primary-text font-weight-medium">{{ searchWord }}</span>
|
||||
{{ t('resource.title') }}
|
||||
</VListItemSubtitle>
|
||||
</VListItem>
|
||||
<VListItem density="comfortable" link @click="searchMedia('media')" class="search-result-item mx-2 my-1">
|
||||
<template #prepend>
|
||||
<div class="result-icon-wrapper">
|
||||
<VIcon icon="mdi-movie-search" size="small" color="medium-emphasis" />
|
||||
</div>
|
||||
</template>
|
||||
<VListItemTitle class="font-weight-medium text-body-2">
|
||||
{{ t('recommend.categoryMovie') }}、{{ t('recommend.categoryTV') }}
|
||||
</VListItemTitle>
|
||||
<VListItemSubtitle class="text-caption text-medium-emphasis">
|
||||
{{ t('common.search') }} <span class="primary-text font-weight-medium">{{ searchWord }}</span>
|
||||
{{ t('resource.title') }}
|
||||
</VListItemSubtitle>
|
||||
</VListItem>
|
||||
|
||||
<VListItem
|
||||
v-if="showCollectionSearch"
|
||||
density="comfortable"
|
||||
link
|
||||
@click="searchMedia('collection')"
|
||||
class="search-result-item mx-2 my-1"
|
||||
>
|
||||
<template #prepend>
|
||||
<div class="result-icon-wrapper">
|
||||
<VIcon icon="mdi-movie-filter" size="small" color="medium-emphasis" />
|
||||
</div>
|
||||
</template>
|
||||
<VListItemTitle class="font-weight-medium text-body-2">{{
|
||||
t('dialog.searchBar.collections')
|
||||
}}</VListItemTitle>
|
||||
<VListItemSubtitle class="text-caption text-medium-emphasis">
|
||||
{{ t('common.search') }} <span class="primary-text font-weight-medium">{{ searchWord }}</span>
|
||||
{{ t('dialog.searchBar.collectionSearch') }}
|
||||
</VListItemSubtitle>
|
||||
</VListItem>
|
||||
<VListItem
|
||||
v-if="showCollectionSearch"
|
||||
density="comfortable"
|
||||
link
|
||||
@click="searchMedia('collection')"
|
||||
class="search-result-item mx-2 my-1"
|
||||
>
|
||||
<template #prepend>
|
||||
<div class="result-icon-wrapper">
|
||||
<VIcon icon="mdi-movie-filter" size="small" color="medium-emphasis" />
|
||||
</div>
|
||||
</template>
|
||||
<VListItemTitle class="font-weight-medium text-body-2">{{
|
||||
t('dialog.searchBar.collections')
|
||||
}}</VListItemTitle>
|
||||
<VListItemSubtitle class="text-caption text-medium-emphasis">
|
||||
{{ t('common.search') }} <span class="primary-text font-weight-medium">{{ searchWord }}</span>
|
||||
{{ t('dialog.searchBar.collectionSearch') }}
|
||||
</VListItemSubtitle>
|
||||
</VListItem>
|
||||
|
||||
<VListItem density="comfortable" link @click="searchMedia('person')" class="search-result-item mx-2 my-1">
|
||||
<template #prepend>
|
||||
<div class="result-icon-wrapper">
|
||||
<VIcon icon="mdi-account-search" size="small" color="medium-emphasis" />
|
||||
</div>
|
||||
</template>
|
||||
<VListItemTitle class="font-weight-medium text-body-2">{{ t('browse.actor') }}</VListItemTitle>
|
||||
<VListItemSubtitle class="text-caption text-medium-emphasis">
|
||||
{{ t('common.search') }} <span class="primary-text font-weight-medium">{{ searchWord }}</span>
|
||||
{{ t('dialog.searchBar.actorSearch') }}
|
||||
</VListItemSubtitle>
|
||||
</VListItem>
|
||||
<VListItem density="comfortable" link @click="searchMedia('person')" class="search-result-item mx-2 my-1">
|
||||
<template #prepend>
|
||||
<div class="result-icon-wrapper">
|
||||
<VIcon icon="mdi-account-search" size="small" color="medium-emphasis" />
|
||||
</div>
|
||||
</template>
|
||||
<VListItemTitle class="font-weight-medium text-body-2">{{ t('browse.actor') }}</VListItemTitle>
|
||||
<VListItemSubtitle class="text-caption text-medium-emphasis">
|
||||
{{ t('common.search') }} <span class="primary-text font-weight-medium">{{ searchWord }}</span>
|
||||
{{ t('dialog.searchBar.actorSearch') }}
|
||||
</VListItemSubtitle>
|
||||
</VListItem>
|
||||
</template>
|
||||
|
||||
<VListItem
|
||||
v-if="hasSubscribePermission"
|
||||
@@ -622,7 +632,34 @@ onMounted(() => {
|
||||
variant="tonal"
|
||||
color="primary"
|
||||
rounded="pill"
|
||||
@click.stop="openSiteDialog"
|
||||
@click.stop="openSiteDialog('torrent')"
|
||||
>
|
||||
{{ t('dialog.searchBar.selectSites') }}
|
||||
</VBtn>
|
||||
</template>
|
||||
</VListItem>
|
||||
|
||||
<VListItem density="comfortable" link @click="searchSubtitle" class="search-result-item mx-2 my-1">
|
||||
<template #prepend>
|
||||
<div class="result-icon-wrapper">
|
||||
<VIcon icon="mdi-subtitles-outline" size="small" color="medium-emphasis" />
|
||||
</div>
|
||||
</template>
|
||||
<VListItemTitle class="font-weight-medium text-body-2">{{
|
||||
t('dialog.searchBar.searchSubtitlesInSites')
|
||||
}}</VListItemTitle>
|
||||
<VListItemSubtitle class="text-caption text-medium-emphasis">
|
||||
{{ t('common.search') }} <span class="primary-text font-weight-medium">{{ searchWord }}</span>
|
||||
{{ t('dialog.searchBar.relatedSubtitles') }}
|
||||
</VListItemSubtitle>
|
||||
<template #append>
|
||||
<VBtn
|
||||
v-if="hasManagePermission"
|
||||
size="x-small"
|
||||
variant="tonal"
|
||||
color="primary"
|
||||
rounded="pill"
|
||||
@click.stop="openSiteDialog('subtitle')"
|
||||
>
|
||||
{{ t('dialog.searchBar.selectSites') }}
|
||||
</VBtn>
|
||||
@@ -698,8 +735,6 @@ onMounted(() => {
|
||||
display: flex;
|
||||
overflow: hidden;
|
||||
flex-direction: column;
|
||||
border-radius: 16px !important;
|
||||
box-shadow: 0 12px 40px rgba(0, 0, 0, 12%) !important;
|
||||
}
|
||||
|
||||
/* 搜索头部区域 */
|
||||
@@ -712,7 +747,7 @@ onMounted(() => {
|
||||
.search-input-wrapper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
border: 1.5px solid rgba(var(--v-theme-on-surface), 0.15);
|
||||
border: 1.5px solid rgba(var(--v-theme-primary), 0.4);
|
||||
border-radius: 28px;
|
||||
background-color: rgba(var(--v-theme-surface-variant), 0.04);
|
||||
block-size: 48px;
|
||||
@@ -723,7 +758,7 @@ onMounted(() => {
|
||||
}
|
||||
|
||||
.search-input-wrapper:focus-within {
|
||||
border-color: rgba(var(--v-theme-on-surface), 0.3);
|
||||
border-color: rgb(var(--v-theme-primary));
|
||||
box-shadow: 0 0 0 3px rgba(var(--v-theme-on-surface), 0.04);
|
||||
}
|
||||
|
||||
@@ -774,7 +809,6 @@ onMounted(() => {
|
||||
}
|
||||
|
||||
.search-result-item {
|
||||
border-radius: 10px !important;
|
||||
margin-block-end: 2px;
|
||||
transition: background-color 0.15s ease;
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ const props = defineProps({
|
||||
type: Array as PropType<Site[]>,
|
||||
required: true,
|
||||
},
|
||||
selected: Array as PropType<Number[]>,
|
||||
selected: Array as PropType<number[]>,
|
||||
})
|
||||
|
||||
// 定义事件
|
||||
@@ -20,38 +20,66 @@ const emit = defineEmits(['close', 'search', 'reload'])
|
||||
const siteFilter = ref('')
|
||||
|
||||
// 已选择站点
|
||||
const selectedSites = ref<any[]>(props.selected || [])
|
||||
const selectedSites = ref<number[]>([])
|
||||
|
||||
// 根据当前可用站点清理选中项,避免停用或已删除站点参与计数。
|
||||
function normalizeSelectedSites(selectedSiteIds: number[] = []) {
|
||||
const availableSiteIds = new Set(props.sites.map((site: Site) => site.id))
|
||||
const normalizedSiteIds: number[] = []
|
||||
|
||||
selectedSiteIds.forEach(siteId => {
|
||||
if (availableSiteIds.has(siteId) && !normalizedSiteIds.includes(siteId)) {
|
||||
normalizedSiteIds.push(siteId)
|
||||
}
|
||||
})
|
||||
|
||||
return normalizedSiteIds
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.selected,
|
||||
value => {
|
||||
if (selectedSites.value.length == 0 && value) {
|
||||
selectedSites.value = value
|
||||
}
|
||||
[() => props.selected, () => props.sites],
|
||||
([value]) => {
|
||||
selectedSites.value = normalizeSelectedSites(value || [])
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
// 全选/全不选按钮文字
|
||||
const checkAllText = computed(() => {
|
||||
return selectedSites.value.length < props.sites?.length
|
||||
return selectedSites.value.length < props.sites.length
|
||||
? t('dialog.searchSite.selectAll')
|
||||
: t('dialog.searchSite.deselectAll')
|
||||
})
|
||||
|
||||
// 全选/全不选
|
||||
const checkAllSitesorNot = () => {
|
||||
if (selectedSites.value.length < props.sites?.length) {
|
||||
selectedSites.value = props.sites?.map((item: Site) => item.id)
|
||||
if (selectedSites.value.length < props.sites.length) {
|
||||
selectedSites.value = props.sites.map((item: Site) => item.id)
|
||||
} else {
|
||||
selectedSites.value = []
|
||||
}
|
||||
}
|
||||
|
||||
// 切换单个站点的选择状态。
|
||||
function toggleSiteSelection(siteId: number) {
|
||||
const index = selectedSites.value.indexOf(siteId)
|
||||
if (index === -1) {
|
||||
selectedSites.value.push(siteId)
|
||||
} else {
|
||||
selectedSites.value.splice(index, 1)
|
||||
}
|
||||
}
|
||||
|
||||
// 确认搜索时只提交当前可用站点。
|
||||
function confirmSearch() {
|
||||
emit('search', normalizeSelectedSites(selectedSites.value))
|
||||
}
|
||||
|
||||
// 根据筛选条件过滤站点
|
||||
const filteredSites = computed(() => {
|
||||
if (!siteFilter.value) return props.sites
|
||||
const filter = siteFilter.value.toLowerCase()
|
||||
return props.sites?.filter((site: Site) => site.name.toLowerCase().includes(filter))
|
||||
return props.sites.filter((site: Site) => site.name.toLowerCase().includes(filter))
|
||||
})
|
||||
</script>
|
||||
<template>
|
||||
@@ -101,22 +129,13 @@ const filteredSites = computed(() => {
|
||||
<div
|
||||
v-bind="props"
|
||||
:class="[
|
||||
'site-checkbox-wrapper pa-2 pa-sm-3 rounded-lg d-flex align-center',
|
||||
'site-checkbox-wrapper pa-2 pa-sm-3 d-flex align-center',
|
||||
{
|
||||
'site-selected': selectedSites.includes(site.id),
|
||||
'site-hover': isHovering && !selectedSites.includes(site.id),
|
||||
},
|
||||
]"
|
||||
@click="
|
||||
() => {
|
||||
const index = selectedSites.indexOf(site.id)
|
||||
if (index === -1) {
|
||||
selectedSites.push(site.id)
|
||||
} else {
|
||||
selectedSites.splice(index, 1)
|
||||
}
|
||||
}
|
||||
"
|
||||
@click="toggleSiteSelection(site.id)"
|
||||
>
|
||||
<VIcon
|
||||
:icon="selectedSites.includes(site.id) ? 'mdi-check-circle' : 'mdi-checkbox-blank-circle-outline'"
|
||||
@@ -156,12 +175,13 @@ const filteredSites = computed(() => {
|
||||
</div>
|
||||
</VCardText>
|
||||
|
||||
<VCardActions class="pt-3">
|
||||
<VCardActions class="app-dialog-actions">
|
||||
<VSpacer />
|
||||
<VBtn
|
||||
color="primary"
|
||||
variant="flat"
|
||||
:disabled="selectedSites.length === 0"
|
||||
@click="emit('search', selectedSites)"
|
||||
@click="confirmSearch"
|
||||
prepend-icon="mdi-magnify"
|
||||
class="d-flex align-center justify-center px-5"
|
||||
>
|
||||
@@ -174,8 +194,11 @@ const filteredSites = computed(() => {
|
||||
<style scoped>
|
||||
.site-checkbox-wrapper {
|
||||
border: 1px solid rgba(var(--v-theme-on-surface), 0.08);
|
||||
border-radius: var(--app-surface-radius);
|
||||
cursor: pointer;
|
||||
transition: transform 0.2s ease, background-color 0.2s ease;
|
||||
transition:
|
||||
transform 0.2s ease,
|
||||
background-color 0.2s ease;
|
||||
}
|
||||
|
||||
.site-checkbox-wrapper:hover {
|
||||
|
||||
@@ -34,6 +34,11 @@ const visible = computed({
|
||||
function allLoggingUrl() {
|
||||
return `${import.meta.env.VITE_API_BASE_URL}system/logging?length=-1`
|
||||
}
|
||||
|
||||
/** 拼接主程序日志下载 URL。 */
|
||||
function allLoggingDownloadUrl() {
|
||||
return `${import.meta.env.VITE_API_BASE_URL}system/logging/download/moviepilot`
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -44,12 +49,20 @@ function allLoggingUrl() {
|
||||
<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>
|
||||
<span class="ms-4 d-inline-flex align-center ga-1">
|
||||
<a class="d-inline-flex align-center" :href="allLoggingDownloadUrl()" target="_blank">
|
||||
<VChip color="grey-darken-1" size="small">
|
||||
<VIcon icon="mdi-download" size="small" start />
|
||||
{{ t('common.download') }}
|
||||
</VChip>
|
||||
</a>
|
||||
<a class="d-inline-flex align-center" :href="allLoggingUrl()" target="_blank">
|
||||
<VChip color="grey-darken-1" size="small">
|
||||
<VIcon icon="mdi-open-in-new" size="small" start />
|
||||
{{ t('common.openInNewWindow') }}
|
||||
</VChip>
|
||||
</a>
|
||||
</span>
|
||||
</VCardTitle>
|
||||
</VCardItem>
|
||||
<VDivider />
|
||||
|
||||
@@ -1,139 +0,0 @@
|
||||
<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>
|
||||
@@ -39,10 +39,21 @@ const visible = computed({
|
||||
if (!value) emit('close')
|
||||
},
|
||||
})
|
||||
|
||||
const isFullscreen = computed(() => !display.mdAndUp.value)
|
||||
|
||||
// 仅系统健康检查弹窗需要在全屏时取消固定高度,避免其它快捷弹窗被误伤。
|
||||
const bodyClasses = computed(() => [
|
||||
props.bodyClass,
|
||||
{
|
||||
'system-health-dialog-body--fullscreen':
|
||||
isFullscreen.value && props.bodyClass.split(/\s+/).includes('system-health-dialog-body'),
|
||||
},
|
||||
])
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VDialog v-if="visible" v-model="visible" :max-width="props.maxWidth" scrollable :fullscreen="!display.mdAndUp.value">
|
||||
<VDialog v-if="visible" v-model="visible" :max-width="props.maxWidth" scrollable :fullscreen="isFullscreen">
|
||||
<VCard :class="props.cardClass">
|
||||
<VCardItem>
|
||||
<VCardTitle>
|
||||
@@ -53,7 +64,7 @@ const visible = computed({
|
||||
<VDialogCloseBtn v-model="visible" />
|
||||
</VCardItem>
|
||||
<VDivider />
|
||||
<VCardText :class="props.bodyClass">
|
||||
<VCardText :class="bodyClasses">
|
||||
<Component :is="props.view" v-bind="props.viewProps" />
|
||||
</VCardText>
|
||||
</VCard>
|
||||
@@ -63,20 +74,20 @@ const visible = computed({
|
||||
<style scoped>
|
||||
.system-health-dialog-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.system-health-dialog-body {
|
||||
/* 弹窗正文本身不滚动,滚动只交给健康检查结果列表。 */
|
||||
display: flex;
|
||||
overflow: hidden !important;
|
||||
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 {
|
||||
.system-health-dialog-body--fullscreen {
|
||||
block-size: auto;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -340,12 +340,26 @@ onMounted(async () => {
|
||||
</VRow>
|
||||
</VForm>
|
||||
</VCardText>
|
||||
<VCardActions class="pt-3">
|
||||
<VCardActions class="app-dialog-actions">
|
||||
<VSpacer />
|
||||
<VBtn v-if="props.oper === 'add'" color="primary" @click="addSite" prepend-icon="mdi-plus" class="px-5">
|
||||
<VBtn
|
||||
v-if="props.oper === 'add'"
|
||||
color="primary"
|
||||
variant="flat"
|
||||
@click="addSite"
|
||||
prepend-icon="mdi-plus"
|
||||
class="px-5"
|
||||
>
|
||||
{{ t('site.actions.add') }}
|
||||
</VBtn>
|
||||
<VBtn v-else color="primary" @click="updateSiteInfo" prepend-icon="mdi-content-save" class="px-5">
|
||||
<VBtn
|
||||
v-else
|
||||
color="primary"
|
||||
variant="flat"
|
||||
@click="updateSiteInfo"
|
||||
prepend-icon="mdi-content-save"
|
||||
class="px-5"
|
||||
>
|
||||
{{ t('common.save') }}
|
||||
</VBtn>
|
||||
</VCardActions>
|
||||
|
||||
@@ -50,23 +50,34 @@ async function updateSiteCookie() {
|
||||
progressDialog.value = true
|
||||
progressText.value = t('dialog.siteCookieUpdate.updating', { site: cardProps.site?.name })
|
||||
|
||||
const result: { [key: string]: any } = await api.get(`site/cookie/${cardProps.site?.id}`, {
|
||||
params: {
|
||||
username: userPwForm.value.username,
|
||||
password: userPwForm.value.password,
|
||||
code: userPwForm.value.code,
|
||||
},
|
||||
const result: { [key: string]: any } = await api.post(`site/cookie/${cardProps.site?.id}`, {
|
||||
username: userPwForm.value.username,
|
||||
password: userPwForm.value.password,
|
||||
code: userPwForm.value.code,
|
||||
})
|
||||
|
||||
if (result.success) {
|
||||
$toast.success(t('dialog.siteCookieUpdate.success', { site: cardProps.site?.name }))
|
||||
emit('done')
|
||||
} else $toast.error(t('dialog.siteCookieUpdate.failed', { site: cardProps.site?.name, message: result.message }))
|
||||
|
||||
} else {
|
||||
$toast.error(
|
||||
t('dialog.siteCookieUpdate.failed', {
|
||||
site: cardProps.site?.name,
|
||||
message: result.message || t('dialog.siteCookieUpdate.requestFailed'),
|
||||
}),
|
||||
)
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error(error)
|
||||
const detail = error?.response?.data?.detail
|
||||
const message =
|
||||
error?.response?.data?.message ||
|
||||
(typeof detail === 'string' ? detail : error?.message) ||
|
||||
t('dialog.siteCookieUpdate.requestFailed')
|
||||
$toast.error(t('dialog.siteCookieUpdate.failed', { site: cardProps.site?.name, message }))
|
||||
} finally {
|
||||
progressDialog.value = false
|
||||
updateButtonDisable.value = false
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -99,9 +110,11 @@ async function updateSiteCookie() {
|
||||
</VRow>
|
||||
</VForm>
|
||||
</VCardText>
|
||||
<VCardActions class="mx-auto">
|
||||
<VCardActions class="app-dialog-actions">
|
||||
<VSpacer />
|
||||
<VBtn
|
||||
size="large"
|
||||
color="primary"
|
||||
variant="flat"
|
||||
@click="updateSiteCookie"
|
||||
:disabled="updateButtonDisable"
|
||||
:loading="updateButtonDisable"
|
||||
|
||||
@@ -237,7 +237,7 @@ watch(selectedFile, async newFile => {
|
||||
<!-- 阶段1:选择文件阶段 -->
|
||||
<div v-if="currentStage === ImportStage.SELECT_FILE" class="upload-area">
|
||||
<div
|
||||
class="upload-zone"
|
||||
class="upload-zone app-surface-shape"
|
||||
:class="{ 'dragging': isDragging }"
|
||||
@dragover="handleDragOver"
|
||||
@dragleave="handleDragLeave"
|
||||
@@ -394,7 +394,6 @@ watch(selectedFile, async newFile => {
|
||||
.upload-zone {
|
||||
padding: 2rem;
|
||||
border: 2px dashed #ccc;
|
||||
border-radius: 8px;
|
||||
text-align: center;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
@@ -220,7 +220,7 @@ onMounted(() => {
|
||||
|
||||
<div class="pa-3 pb-2">
|
||||
<template v-if="!isMobileLayout">
|
||||
<VSheet class="site-resource-filter-panel" rounded="lg" border>
|
||||
<VSheet class="site-resource-filter-panel">
|
||||
<div class="site-resource-filter-panel__inner">
|
||||
<VRow class="site-resource-filter-row">
|
||||
<VCol cols="12" md="4">
|
||||
@@ -304,7 +304,7 @@ onMounted(() => {
|
||||
|
||||
<VExpandTransition>
|
||||
<div v-if="mobileSearchExpanded" class="mt-2">
|
||||
<VSheet class="site-resource-filter-panel" rounded="lg" border>
|
||||
<VSheet class="site-resource-filter-panel">
|
||||
<div class="site-resource-filter-panel__inner">
|
||||
<VRow class="site-resource-filter-row">
|
||||
<VCol cols="12">
|
||||
@@ -475,26 +475,26 @@ onMounted(() => {
|
||||
:items="mobileResourceList"
|
||||
:columns="1"
|
||||
:gap="12"
|
||||
:estimated-item-height="320"
|
||||
:estimated-item-height="220"
|
||||
:overscan-rows="5"
|
||||
:get-item-key="getResourceItemKey"
|
||||
>
|
||||
<template #default="{ item }">
|
||||
<VCard>
|
||||
<VCardText class="pa-4">
|
||||
<VCard class="site-resource-card" variant="flat">
|
||||
<VCardText class="pa-3">
|
||||
<button type="button" class="site-resource-title-btn text-start" @click="addDownload(item)">
|
||||
<div class="text-body-1 font-weight-medium text-high-emphasis">
|
||||
<div class="site-resource-card__title 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"
|
||||
class="site-resource-card__description mt-1 text-body-2 text-medium-emphasis"
|
||||
>
|
||||
{{ item.description }}
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<div class="mt-3">
|
||||
<div class="site-resource-card__chips mt-2">
|
||||
<VChip
|
||||
v-if="item.hit_and_run"
|
||||
variant="elevated"
|
||||
@@ -533,47 +533,82 @@ onMounted(() => {
|
||||
</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 class="site-resource-card__summary mt-3">
|
||||
<div class="site-resource-card__stat">
|
||||
<VIcon icon="mdi-clock-outline" size="15" />
|
||||
<span>{{ item.date_elapsed || item.pubdate || '-' }}</span>
|
||||
</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 class="site-resource-card__stat">
|
||||
<VIcon icon="mdi-harddisk" size="15" />
|
||||
<span>{{ formatFileSize(item.size) }}</span>
|
||||
</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 class="site-resource-card__stat site-resource-card__stat--success">
|
||||
<VIcon icon="mdi-arrow-up" size="15" />
|
||||
<span>{{ item.seeders ?? '-' }}</span>
|
||||
</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 class="site-resource-card__stat site-resource-card__stat--warning">
|
||||
<VIcon icon="mdi-arrow-down" size="15" />
|
||||
<span>{{ item.peers ?? '-' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="site-resource-card__actions mt-4">
|
||||
<VBtn color="primary" variant="flat" block prepend-icon="mdi-download" @click="addDownload(item)">
|
||||
<!-- 下载保留文本,其它低频操作改为图标按钮并保持同一行。 -->
|
||||
<div class="site-resource-card__actions mt-2">
|
||||
<VBtn
|
||||
color="primary"
|
||||
variant="flat"
|
||||
class="site-resource-card__download-btn"
|
||||
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>
|
||||
<VTooltip :text="t('common.viewDetails')" location="top">
|
||||
<template #activator="{ props: tooltipProps }">
|
||||
<VBtn
|
||||
v-bind="tooltipProps"
|
||||
icon
|
||||
variant="tonal"
|
||||
color="primary"
|
||||
class="site-resource-card__icon-btn"
|
||||
:aria-label="t('common.viewDetails')"
|
||||
@click="openTorrentDetail(item.page_url || '')"
|
||||
>
|
||||
<VIcon icon="mdi-open-in-new" />
|
||||
</VBtn>
|
||||
</template>
|
||||
</VTooltip>
|
||||
<VTooltip
|
||||
v-if="item.enclosure?.startsWith('http')"
|
||||
:text="t('dialog.siteResource.downloadTorrent')"
|
||||
location="top"
|
||||
>
|
||||
<template #activator="{ props: tooltipProps }">
|
||||
<VBtn
|
||||
v-bind="tooltipProps"
|
||||
icon
|
||||
variant="tonal"
|
||||
color="primary"
|
||||
class="site-resource-card__icon-btn"
|
||||
:aria-label="t('dialog.siteResource.downloadTorrent')"
|
||||
@click="downloadTorrentFile(item.enclosure)"
|
||||
>
|
||||
<VIcon icon="mdi-file-download-outline" />
|
||||
</VBtn>
|
||||
</template>
|
||||
</VTooltip>
|
||||
<VBtn
|
||||
v-else
|
||||
icon
|
||||
variant="tonal"
|
||||
color="primary"
|
||||
disabled
|
||||
class="site-resource-card__icon-btn"
|
||||
:aria-label="t('dialog.siteResource.downloadTorrent')"
|
||||
>
|
||||
<VIcon icon="mdi-file-download-outline" />
|
||||
</VBtn>
|
||||
</div>
|
||||
</VCardText>
|
||||
</VCard>
|
||||
@@ -611,11 +646,9 @@ onMounted(() => {
|
||||
}
|
||||
|
||||
.site-resource-filter-panel {
|
||||
border-color: rgba(var(--v-border-color), calc(var(--v-border-opacity) * 0.9));
|
||||
background:
|
||||
radial-gradient(circle at top left, rgba(var(--v-theme-primary), 0.06), transparent 40%),
|
||||
linear-gradient(180deg, rgba(var(--v-theme-surface), 0.98), rgba(var(--v-theme-surface), 0.93));
|
||||
box-shadow: 0 10px 24px rgba(15, 23, 42, 4%);
|
||||
}
|
||||
|
||||
.site-resource-filter-panel__inner {
|
||||
@@ -623,7 +656,7 @@ onMounted(() => {
|
||||
}
|
||||
|
||||
.site-resource-filter-input :deep(.v-field) {
|
||||
border-radius: 0.75rem;
|
||||
border-radius: var(--app-field-radius);
|
||||
background: rgba(var(--v-theme-surface), 0.92);
|
||||
box-shadow: inset 0 0 0 1px rgba(var(--v-border-color), calc(var(--v-border-opacity) * 0.8));
|
||||
}
|
||||
@@ -704,46 +737,107 @@ onMounted(() => {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.site-resource-card {
|
||||
--site-resource-card-bg:
|
||||
linear-gradient(180deg, rgba(var(--v-theme-surface), 0.98), rgba(var(--v-theme-surface), 0.94)),
|
||||
radial-gradient(circle at top right, rgba(var(--v-theme-primary), 0.08), transparent 34%);
|
||||
|
||||
border: 1px solid rgba(var(--v-border-color), calc(var(--v-border-opacity) * 0.9));
|
||||
background: var(--site-resource-card-bg);
|
||||
}
|
||||
|
||||
:global(html[data-theme="transparent"]) .site-resource-card {
|
||||
--site-resource-card-bg: rgba(var(--v-theme-surface), var(--transparent-opacity));
|
||||
|
||||
backdrop-filter: blur(var(--transparent-blur));
|
||||
}
|
||||
|
||||
.site-resource-card__summary {
|
||||
display: grid;
|
||||
gap: 0.35rem;
|
||||
grid-template-columns: minmax(0, 1.4fr) minmax(0, 1fr) minmax(2.5rem, 0.62fr) minmax(2.5rem, 0.62fr);
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.site-resource-card__stat {
|
||||
display: inline-flex;
|
||||
overflow: hidden;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.22rem;
|
||||
border-radius: 6px;
|
||||
background: rgba(var(--v-theme-on-surface), 0.05);
|
||||
color: rgba(var(--v-theme-on-surface), 0.72);
|
||||
font-size: 0.74rem;
|
||||
font-weight: 600;
|
||||
line-height: 1;
|
||||
min-block-size: 1.65rem;
|
||||
min-inline-size: 0;
|
||||
padding-inline: 0.4rem;
|
||||
}
|
||||
|
||||
.site-resource-card__stat span {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.site-resource-card__stat--success {
|
||||
color: rgb(var(--v-theme-success));
|
||||
}
|
||||
|
||||
.site-resource-card__stat--warning {
|
||||
color: rgb(var(--v-theme-warning));
|
||||
}
|
||||
|
||||
.site-resource-card__title {
|
||||
display: -webkit-box;
|
||||
overflow: hidden;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 2;
|
||||
line-height: 1.38;
|
||||
}
|
||||
|
||||
.site-resource-card__description {
|
||||
display: -webkit-box;
|
||||
overflow: hidden;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 3;
|
||||
-webkit-line-clamp: 2;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.site-resource-card__meta {
|
||||
.site-resource-card__chips {
|
||||
max-block-size: 4.75rem;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.site-resource-card__actions {
|
||||
display: grid;
|
||||
gap: 0.55rem;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 0.45rem;
|
||||
grid-template-columns: minmax(0, 1fr) 2.5rem 2.5rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.site-resource-card__meta-item {
|
||||
border: 1px solid rgba(var(--v-border-color), calc(var(--v-border-opacity) * 0.7));
|
||||
border-radius: 0.6rem;
|
||||
background: rgba(var(--v-theme-surface), 0.78);
|
||||
min-block-size: 0;
|
||||
padding-block: 0.55rem;
|
||||
padding-inline: 0.65rem;
|
||||
.site-resource-card__download-btn {
|
||||
min-block-size: 2.5rem;
|
||||
min-inline-size: 0;
|
||||
box-shadow: 0 6px 16px rgba(var(--v-theme-primary), 0.17);
|
||||
}
|
||||
|
||||
.site-resource-card__meta-item :deep(.text-caption) {
|
||||
font-size: 0.72rem !important;
|
||||
line-height: 1.2;
|
||||
.site-resource-card__download-btn :deep(.v-btn__content) {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.site-resource-card__meta-item :deep(.text-body-2) {
|
||||
font-size: 0.82rem !important;
|
||||
line-height: 1.25;
|
||||
.site-resource-card__icon-btn {
|
||||
block-size: 2.5rem;
|
||||
inline-size: 2.5rem;
|
||||
min-inline-size: 2.5rem;
|
||||
}
|
||||
|
||||
.site-resource-card__secondary-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.site-resource-card__secondary-actions :deep(.v-btn) {
|
||||
flex: 1 1 12rem;
|
||||
.site-resource-card__icon-btn :deep(.v-btn__content) {
|
||||
font-size: 1.05rem;
|
||||
}
|
||||
|
||||
@media (width >= 960px) {
|
||||
@@ -765,4 +859,14 @@ onMounted(() => {
|
||||
min-block-size: 2.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media (width <= 420px) {
|
||||
.site-resource-card__summary {
|
||||
grid-template-columns: minmax(0, 1.15fr) minmax(0, 0.95fr) minmax(2.3rem, 0.55fr) minmax(2.3rem, 0.55fr);
|
||||
}
|
||||
|
||||
.site-resource-card__stat {
|
||||
padding-inline: 0.3rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -391,8 +391,6 @@ onMounted(() => {
|
||||
|
||||
.stat-card {
|
||||
padding: 16px;
|
||||
border: 1px solid var(--v-border-color);
|
||||
border-radius: 8px;
|
||||
background: var(--v-theme-surface);
|
||||
min-inline-size: 100px;
|
||||
text-align: center;
|
||||
|
||||
@@ -117,12 +117,12 @@ async function saveSmbConfig() {
|
||||
</VCol>
|
||||
</VRow>
|
||||
</VCardText>
|
||||
<VCardActions>
|
||||
<VBtn color="error" @click="handleReset" prepend-icon="mdi-restore" class="px-5 me-3">
|
||||
<VCardActions class="app-dialog-actions">
|
||||
<VBtn color="error" variant="tonal" @click="handleReset" prepend-icon="mdi-restore">
|
||||
{{ t('dialog.smbConfig.reset') }}
|
||||
</VBtn>
|
||||
<VSpacer />
|
||||
<VBtn @click="handleDone" prepend-icon="mdi-check" class="px-5 me-3">
|
||||
<VBtn color="primary" variant="flat" @click="handleDone" prepend-icon="mdi-check" class="px-5">
|
||||
{{ t('dialog.smbConfig.complete') }}
|
||||
</VBtn>
|
||||
</VCardActions>
|
||||
|
||||
@@ -89,8 +89,9 @@ function handleDone() {
|
||||
</VCol>
|
||||
</VRow>
|
||||
</VCardText>
|
||||
<VCardActions class="pt-3">
|
||||
<VBtn @click="handleDone" prepend-icon="mdi-content-save" class="px-5">
|
||||
<VCardActions class="app-dialog-actions">
|
||||
<VSpacer />
|
||||
<VBtn color="primary" variant="flat" @click="handleDone" prepend-icon="mdi-content-save" class="px-5">
|
||||
{{ t('common.save') }}
|
||||
</VBtn>
|
||||
</VCardActions>
|
||||
|
||||
@@ -7,8 +7,14 @@ import { useDisplay } from 'vuetify'
|
||||
import { useConfirm } from '@/composables/useConfirm'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { qualityOptions, resolutionOptions, effectOptions } from '@/api/constants'
|
||||
import { useUserStore } from '@/stores'
|
||||
import { buildUserPermissionContext, hasPermission } from '@/utils/permission'
|
||||
// i18n
|
||||
const { t } = useI18n()
|
||||
const userStore = useUserStore()
|
||||
const canAdmin = computed(() =>
|
||||
hasPermission(buildUserPermissionContext(userStore.superUser, userStore.permissions), 'admin'),
|
||||
)
|
||||
|
||||
// 显示器宽度
|
||||
const display = useDisplay()
|
||||
@@ -128,6 +134,8 @@ async function loadDownloaderSetting() {
|
||||
|
||||
// 加载规则组
|
||||
async function queryFilterRuleGroups() {
|
||||
if (!canAdmin.value) return
|
||||
|
||||
try {
|
||||
const result: { [key: string]: any } = await api.get('system/setting/UserFilterRuleGroups')
|
||||
filterRuleGroups.value = result.data?.value ?? []
|
||||
@@ -163,6 +171,8 @@ async function updateSubscribeInfo() {
|
||||
|
||||
// 设置用户设置的默认订阅规则
|
||||
async function saveDefaultSubscribeConfig() {
|
||||
if (!canAdmin.value) return
|
||||
|
||||
try {
|
||||
let subscribe_config_url = ''
|
||||
if (props.type === '电影') subscribe_config_url = 'system/setting/DefaultMovieSubscribeConfig'
|
||||
@@ -183,8 +193,8 @@ async function saveDefaultSubscribeConfig() {
|
||||
async function queryDefaultSubscribeConfig() {
|
||||
try {
|
||||
let subscribe_config_url = ''
|
||||
if (props.type === '电影') subscribe_config_url = 'system/setting/DefaultMovieSubscribeConfig'
|
||||
else subscribe_config_url = 'system/setting/DefaultTvSubscribeConfig'
|
||||
if (props.type === '电影') subscribe_config_url = 'system/setting/public/DefaultMovieSubscribeConfig'
|
||||
else subscribe_config_url = 'system/setting/public/DefaultTvSubscribeConfig'
|
||||
|
||||
const result: { [key: string]: any } = await api.get(subscribe_config_url)
|
||||
|
||||
@@ -260,7 +270,7 @@ async function removeSubscribe() {
|
||||
// 查询下载目录
|
||||
async function loadDownloadDirectories() {
|
||||
try {
|
||||
const result: { [key: string]: any } = await api.get('system/setting/Directories')
|
||||
const result: { [key: string]: any } = await api.get('system/setting/public/Directories')
|
||||
if (result.success && result.data?.value) {
|
||||
downloadDirectories.value = result.data.value
|
||||
}
|
||||
@@ -549,12 +559,14 @@ onMounted(() => {
|
||||
</VWindow>
|
||||
</VForm>
|
||||
</VCardText>
|
||||
<VCardActions class="pt-3">
|
||||
<VBtn v-if="!props.default" color="error" @click="removeSubscribe" class="me-3">
|
||||
<VCardActions class="app-dialog-actions">
|
||||
<VBtn v-if="!props.default" color="error" variant="tonal" @click="removeSubscribe">
|
||||
{{ t('dialog.subscribeEdit.cancelSubscribe') }}
|
||||
</VBtn>
|
||||
<VSpacer />
|
||||
<VBtn
|
||||
color="primary"
|
||||
variant="flat"
|
||||
@click=";`${props.default ? saveDefaultSubscribeConfig() : updateSubscribeInfo()}`"
|
||||
prepend-icon="mdi-content-save"
|
||||
class="px-5"
|
||||
|
||||
@@ -216,11 +216,50 @@ function getMediaTypeText(type: string | undefined) {
|
||||
</VVirtualScroll>
|
||||
</VInfiniteScroll>
|
||||
</VList>
|
||||
<VCardText v-if="historyList.length === 0 && isRefreshed" class="text-center">{{
|
||||
t('dialog.subscribeHistory.noData')
|
||||
}}</VCardText>
|
||||
<VCardText v-if="historyList.length === 0 && isRefreshed" class="subscribe-history-empty">
|
||||
<VIcon class="subscribe-history-empty__icon" icon="mdi-sync" size="30" />
|
||||
|
||||
<div class="subscribe-history-empty__headline">
|
||||
{{ t('dialog.subscribeHistory.noData') }}
|
||||
</div>
|
||||
|
||||
<div class="subscribe-history-empty__description">
|
||||
{{ t('dialog.subscribeHistory.noDataHint') }}
|
||||
</div>
|
||||
</VCardText>
|
||||
</VCard>
|
||||
<!-- 进度框 -->
|
||||
<ProgressDialog v-if="progressDialog" v-model="progressDialog" :text="progressText" />
|
||||
</VDialog>
|
||||
</template>
|
||||
<style scoped>
|
||||
.subscribe-history-empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.75rem;
|
||||
min-block-size: 13rem;
|
||||
padding-block: 2.5rem !important;
|
||||
padding-inline: 1.5rem !important;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.subscribe-history-empty__icon {
|
||||
color: rgba(var(--v-theme-on-surface), 0.32);
|
||||
}
|
||||
|
||||
.subscribe-history-empty__headline {
|
||||
color: rgba(var(--v-theme-on-surface), 0.9);
|
||||
font-size: 1.15rem;
|
||||
font-weight: 600;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.subscribe-history-empty__description {
|
||||
color: rgba(var(--v-theme-on-surface), 0.6);
|
||||
font-size: 0.92rem;
|
||||
line-height: 1.65;
|
||||
max-inline-size: 25rem;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -105,9 +105,17 @@ const $toast = useToast()
|
||||
</VRow>
|
||||
</VForm>
|
||||
</VCardText>
|
||||
<VCardActions class="pt-3">
|
||||
<VCardActions class="app-dialog-actions">
|
||||
<VSpacer />
|
||||
<VBtn :disabled="shareDoing" @click="doShare" prepend-icon="mdi-share" class="px-5" :loading="shareDoing">
|
||||
<VBtn
|
||||
color="primary"
|
||||
variant="flat"
|
||||
:disabled="shareDoing"
|
||||
@click="doShare"
|
||||
prepend-icon="mdi-share"
|
||||
class="px-5"
|
||||
:loading="shareDoing"
|
||||
>
|
||||
{{ t('dialog.subscribeShare.confirmShare') }}
|
||||
</VBtn>
|
||||
</VCardActions>
|
||||
|
||||
@@ -141,4 +141,29 @@ function updateFilter(key: string, values: string[]) {
|
||||
gap: 1rem;
|
||||
grid-template-columns: repeat(auto-fit, minmax(18rem, 1fr));
|
||||
}
|
||||
|
||||
.filter-options {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.filter-chip {
|
||||
border: 1px solid rgba(var(--v-theme-primary), 0.2);
|
||||
margin: 4px;
|
||||
background-color: rgba(var(--v-theme-primary), 0.1) !important;
|
||||
color: rgba(var(--v-theme-on-surface), 0.9) !important;
|
||||
font-weight: 500;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.filter-chip:hover {
|
||||
background-color: rgba(var(--v-theme-primary), 0.15) !important;
|
||||
}
|
||||
|
||||
.filter-chip.v-chip--selected {
|
||||
background-color: rgba(var(--v-theme-primary), 0.85) !important;
|
||||
box-shadow: 0 2px 4px rgba(var(--v-theme-primary), 0.3);
|
||||
color: rgb(var(--v-theme-on-primary)) !important;
|
||||
font-weight: 600;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -142,4 +142,24 @@ function handleDetail(item: Context) {
|
||||
max-block-size: 60vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.chip-season {
|
||||
background-color: #3f51b5;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.chip-free {
|
||||
background-color: #4caf50;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.chip-discount {
|
||||
background-color: #ff5722;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.chip-bonus {
|
||||
background-color: #9c27b0;
|
||||
color: white;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -85,7 +85,7 @@ function updateFilter(values: string[]) {
|
||||
@update:model-value="updateFilter"
|
||||
>
|
||||
<VChip
|
||||
v-for="option in options"
|
||||
v-for="option in options"
|
||||
:key="option"
|
||||
:value="option"
|
||||
filter
|
||||
@@ -97,12 +97,39 @@ function updateFilter(values: string[]) {
|
||||
</VChip>
|
||||
</VChipGroup>
|
||||
</VCardText>
|
||||
<VCardActions>
|
||||
<VCardActions class="app-dialog-actions">
|
||||
<VSpacer />
|
||||
<VBtn color="primary" prepend-icon="mdi-check" class="px-5" @click="visible = false">
|
||||
<VBtn color="primary" variant="flat" prepend-icon="mdi-check" class="px-5" @click="visible = false">
|
||||
{{ t('torrent.confirm') }}
|
||||
</VBtn>
|
||||
</VCardActions>
|
||||
</VCard>
|
||||
</VDialog>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.filter-options {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.filter-chip {
|
||||
border: 1px solid rgba(var(--v-theme-primary), 0.2);
|
||||
margin: 4px;
|
||||
background-color: rgba(var(--v-theme-primary), 0.1) !important;
|
||||
color: rgba(var(--v-theme-on-surface), 0.9) !important;
|
||||
font-weight: 500;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.filter-chip:hover {
|
||||
background-color: rgba(var(--v-theme-primary), 0.15) !important;
|
||||
}
|
||||
|
||||
.filter-chip.v-chip--selected {
|
||||
background-color: rgba(var(--v-theme-primary), 0.85) !important;
|
||||
box-shadow: 0 2px 4px rgba(var(--v-theme-primary), 0.3);
|
||||
color: rgb(var(--v-theme-on-primary)) !important;
|
||||
font-weight: 600;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -311,8 +311,16 @@ onUnmounted(() => {
|
||||
<VProgressLinear v-if="dataList.length > 0" :model-value="overallProgressComputed" color="primary" />
|
||||
<VDivider v-else />
|
||||
|
||||
<VCardText v-if="dataList.length === 0" class="text-center">
|
||||
{{ t('dialog.transferQueue.noTasks') }}
|
||||
<VCardText v-if="dataList.length === 0" class="transfer-queue-empty">
|
||||
<VIcon class="transfer-queue-empty__icon" icon="mdi-sync" size="30" />
|
||||
|
||||
<div class="transfer-queue-empty__headline">
|
||||
{{ t('dialog.transferQueue.noTasks') }}
|
||||
</div>
|
||||
|
||||
<div class="transfer-queue-empty__description">
|
||||
{{ t('dialog.transferQueue.noTasksHint') }}
|
||||
</div>
|
||||
</VCardText>
|
||||
|
||||
<VCardText v-if="dataList.length > 0">
|
||||
@@ -366,3 +374,51 @@ onUnmounted(() => {
|
||||
</VCard>
|
||||
</VDialog>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.transfer-queue-empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.75rem;
|
||||
min-block-size: 13rem;
|
||||
padding-block: 2.5rem !important;
|
||||
padding-inline: 1.5rem !important;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.transfer-queue-empty__icon {
|
||||
color: rgba(var(--v-theme-on-surface), 0.32);
|
||||
}
|
||||
|
||||
.transfer-queue-empty__headline {
|
||||
color: rgba(var(--v-theme-on-surface), 0.9);
|
||||
font-size: 1.15rem;
|
||||
font-weight: 600;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.transfer-queue-empty__description {
|
||||
color: rgba(var(--v-theme-on-surface), 0.6);
|
||||
font-size: 0.92rem;
|
||||
line-height: 1.65;
|
||||
max-inline-size: 25rem;
|
||||
}
|
||||
|
||||
@media (width <= 600px) {
|
||||
.transfer-queue-empty {
|
||||
min-block-size: 11rem;
|
||||
padding-block: 2rem !important;
|
||||
padding-inline: 1rem !important;
|
||||
}
|
||||
|
||||
.transfer-queue-empty__headline {
|
||||
font-size: 1.05rem;
|
||||
}
|
||||
|
||||
.transfer-queue-empty__description {
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -225,11 +225,11 @@ onUnmounted(() => {
|
||||
</div>
|
||||
</VCardText>
|
||||
|
||||
<VCardActions>
|
||||
<VCardActions class="app-dialog-actions">
|
||||
<VBtn
|
||||
color="error"
|
||||
variant="tonal"
|
||||
prepend-icon="mdi-restore"
|
||||
class="px-5 me-3"
|
||||
@click="handleReset"
|
||||
>
|
||||
{{ t('dialog.u115Auth.reset') }}
|
||||
@@ -238,8 +238,10 @@ onUnmounted(() => {
|
||||
<VSpacer />
|
||||
|
||||
<VBtn
|
||||
color="primary"
|
||||
variant="flat"
|
||||
prepend-icon="mdi-check"
|
||||
class="px-5 me-3"
|
||||
class="px-5"
|
||||
@click="handleDone"
|
||||
>
|
||||
{{ t('dialog.u115Auth.complete') }}
|
||||
|
||||
@@ -612,12 +612,13 @@ onMounted(() => {
|
||||
</div>
|
||||
</VForm>
|
||||
</VCardText>
|
||||
<VCardActions class="pt-3">
|
||||
<VCardActions class="app-dialog-actions">
|
||||
<VSpacer />
|
||||
<VBtn
|
||||
v-if="props.oper === 'add'"
|
||||
:disabled="isAdding"
|
||||
color="primary"
|
||||
variant="flat"
|
||||
@click="addUser"
|
||||
prepend-icon="mdi-plus"
|
||||
class="px-5"
|
||||
@@ -629,6 +630,7 @@ onMounted(() => {
|
||||
v-else
|
||||
:disabled="isUpdating"
|
||||
color="primary"
|
||||
variant="flat"
|
||||
@click="updateUser"
|
||||
prepend-icon="mdi-content-save"
|
||||
class="px-5"
|
||||
|
||||
@@ -25,7 +25,33 @@ onConnect((connection: Connection) => {
|
||||
$toast.warning(t('dialog.workflowActions.invalidConnection'))
|
||||
return
|
||||
}
|
||||
addEdges(connection)
|
||||
addEdges(
|
||||
normalizeWorkflowEdge({
|
||||
...connection,
|
||||
id: `edge_${connection.source}_${connection.target}_${Date.now()}`,
|
||||
type: 'animation',
|
||||
animated: true,
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
// 当前选中的流程边ID
|
||||
const selectedEdgeId = ref<string | null>(null)
|
||||
|
||||
// 流程边配置表单
|
||||
const edgeForm = ref({
|
||||
condition: '',
|
||||
})
|
||||
|
||||
// 后端动作固定契约,供条件构造器读取上一节点输出
|
||||
const actionDefinitions = ref<any[]>([])
|
||||
|
||||
// 动作类型到契约的映射
|
||||
const actionContractMap = computed(() => {
|
||||
return actionDefinitions.value.reduce((result, action) => {
|
||||
result[action.type] = action.contract || {}
|
||||
return result
|
||||
}, {} as Record<string, any>)
|
||||
})
|
||||
|
||||
// 获取指定节点端口的类型(输入/输出)
|
||||
@@ -59,6 +85,197 @@ const isValidConnection = (connection: Connection) => {
|
||||
return sourcePortType === 'output' && targetPortType === 'input' && connection.source !== connection.target
|
||||
}
|
||||
|
||||
// 读取流程边扩展配置,兼容后端支持的顶层字段与 data 字段
|
||||
const getEdgeConfigValue = (edge: any, key: string) => {
|
||||
return edge?.[key] ?? edge?.data?.[key] ?? ''
|
||||
}
|
||||
|
||||
// 复制对象并移除不再由前端编辑的高级配置
|
||||
const omitConfigKeys = (value: any, keys: string[]) => {
|
||||
const result = { ...(value || {}) }
|
||||
keys.forEach(key => delete result[key])
|
||||
return result
|
||||
}
|
||||
|
||||
// 统一流程边数据结构,前端只编辑边条件,汇合和分支策略由执行器默认处理
|
||||
const normalizeWorkflowEdge = (edge: any) => {
|
||||
const condition = String(getEdgeConfigValue(edge, 'condition') || '').trim()
|
||||
const edgeClass = String(edge?.class || '')
|
||||
.replace(/\bworkflow-conditional-edge\b/g, '')
|
||||
.trim()
|
||||
const data = omitConfigKeys(edge?.data, ['join_policy', 'branch_policy'])
|
||||
data.condition = condition || undefined
|
||||
const edgePayload = omitConfigKeys(edge, ['join_policy', 'branch_policy'])
|
||||
|
||||
return {
|
||||
...edgePayload,
|
||||
animated: edge?.animated ?? true,
|
||||
type: edge?.type || 'animation',
|
||||
label: condition ? t('dialog.workflowActions.edgeConditionalLabel') : undefined,
|
||||
class: [edgeClass, condition ? 'workflow-conditional-edge' : ''].filter(Boolean).join(' ') || undefined,
|
||||
condition: condition || undefined,
|
||||
data,
|
||||
}
|
||||
}
|
||||
|
||||
// 标准化所有流程边,导入和保存前都会调用
|
||||
const normalizeWorkflowEdges = () => {
|
||||
edges.value = (edges.value || []).map(edge => normalizeWorkflowEdge(edge))
|
||||
}
|
||||
|
||||
// 统一动作节点数据结构,高级运行配置由后端默认值和动作契约接管
|
||||
const normalizeWorkflowNode = (node: any) => {
|
||||
const hiddenConfigKeys = [
|
||||
'inputs',
|
||||
'outputs',
|
||||
'join_policy',
|
||||
'fail_policy',
|
||||
'branch_policy',
|
||||
'concurrency_key',
|
||||
'timeout',
|
||||
'retry',
|
||||
'contract',
|
||||
'_contract',
|
||||
]
|
||||
const data = omitConfigKeys(node?.data, hiddenConfigKeys)
|
||||
const nodePayload = omitConfigKeys(node, hiddenConfigKeys)
|
||||
|
||||
return {
|
||||
...nodePayload,
|
||||
data,
|
||||
}
|
||||
}
|
||||
|
||||
// 标准化所有动作节点,导入和保存前都会调用
|
||||
const normalizeWorkflowNodes = () => {
|
||||
nodes.value = (nodes.value || []).map(node => normalizeWorkflowNode(node))
|
||||
}
|
||||
|
||||
// 获取节点名称,便于在边设置面板展示流转关系
|
||||
const getNodeName = (nodeId?: string) => {
|
||||
const node = nodes.value.find(item => item.id === nodeId)
|
||||
return (node as any)?.name || node?.data?.label || nodeId || ''
|
||||
}
|
||||
|
||||
// 获取流程边源节点可用于条件判断的输出字段
|
||||
const getEdgeConditionFields = (edge: any) => {
|
||||
const sourceNode = edge
|
||||
? nodes.value.find(node => node.id === edge.source)
|
||||
: null
|
||||
const contract = sourceNode ? actionContractMap.value[sourceNode.type] || {} : {}
|
||||
const fields = contract.condition_fields || contract.outputs || []
|
||||
return Array.isArray(fields)
|
||||
? fields.filter((field: any) => field?.name || field)
|
||||
: []
|
||||
}
|
||||
|
||||
// 判断流程边是否存在可编辑条件
|
||||
const canConfigureEdge = (edge: any) => {
|
||||
const condition = String(getEdgeConfigValue(edge, 'condition') || '').trim()
|
||||
return Boolean(condition || getEdgeConditionFields(edge).length)
|
||||
}
|
||||
|
||||
// 选中流程边时打开设置面板
|
||||
async function handleEdgeClick(params: any) {
|
||||
const edge = params?.edge
|
||||
if (!edge) return
|
||||
if (!actionDefinitions.value.length) {
|
||||
await loadActionDefinitions()
|
||||
}
|
||||
if (!canConfigureEdge(edge)) {
|
||||
closeEdgeSettings()
|
||||
$toast.info(t('dialog.workflowActions.edgeNoConditionFields'))
|
||||
return
|
||||
}
|
||||
selectedEdgeId.value = edge.id
|
||||
edgeForm.value = {
|
||||
condition: String(getEdgeConfigValue(edge, 'condition') || ''),
|
||||
}
|
||||
}
|
||||
|
||||
// 关闭流程边设置面板
|
||||
function closeEdgeSettings() {
|
||||
selectedEdgeId.value = null
|
||||
edgeForm.value = {
|
||||
condition: '',
|
||||
}
|
||||
}
|
||||
|
||||
// 保存流程边设置
|
||||
function saveEdgeSettings() {
|
||||
if (!selectedEdgeId.value) return
|
||||
edges.value = edges.value.map(edge => {
|
||||
if (edge.id !== selectedEdgeId.value) return edge
|
||||
return normalizeWorkflowEdge({
|
||||
...edge,
|
||||
condition: edgeForm.value.condition,
|
||||
data: {
|
||||
...(edge.data || {}),
|
||||
condition: edgeForm.value.condition,
|
||||
},
|
||||
})
|
||||
})
|
||||
$toast.success(t('dialog.workflowActions.edgeSaveSuccess'))
|
||||
}
|
||||
|
||||
// 删除当前选中的流程边
|
||||
function deleteSelectedEdge() {
|
||||
if (!selectedEdgeId.value) return
|
||||
edges.value = edges.value.filter(edge => edge.id !== selectedEdgeId.value)
|
||||
closeEdgeSettings()
|
||||
}
|
||||
|
||||
// 当前选中的流程边
|
||||
const selectedEdge = computed(() => {
|
||||
if (!selectedEdgeId.value) return null
|
||||
return edges.value.find(edge => edge.id === selectedEdgeId.value) || null
|
||||
})
|
||||
|
||||
// 当前边可用于条件判断的输出字段
|
||||
const selectedEdgeConditionFields = computed(() => (
|
||||
selectedEdge.value ? getEdgeConditionFields(selectedEdge.value) : []
|
||||
))
|
||||
|
||||
// 当前边的条件下拉选项,按源节点固定输出自动生成
|
||||
const edgeConditionOptions = computed(() => {
|
||||
const sourceNode = selectedEdge.value
|
||||
? nodes.value.find(node => node.id === selectedEdge.value?.source)
|
||||
: null
|
||||
const options = [{ title: t('dialog.workflowActions.conditionAlways'), value: '' }]
|
||||
selectedEdgeConditionFields.value.forEach((field: any) => {
|
||||
const fieldName = field.name || field
|
||||
if (!fieldName) return
|
||||
const fieldLabel = field.label || fieldName
|
||||
if (field.kind === 'list') {
|
||||
options.push({
|
||||
title: t('dialog.workflowActions.conditionHasOutput', { field: fieldLabel }),
|
||||
value: `outputs.${sourceNode?.id}.${fieldName}.count > 0`,
|
||||
})
|
||||
options.push({
|
||||
title: t('dialog.workflowActions.conditionNoOutput', { field: fieldLabel }),
|
||||
value: `outputs.${sourceNode?.id}.${fieldName}.count == 0`,
|
||||
})
|
||||
return
|
||||
}
|
||||
options.push({
|
||||
title: t('dialog.workflowActions.conditionHasValue', { field: fieldLabel }),
|
||||
value: `outputs.${sourceNode?.id}.${fieldName} != None`,
|
||||
})
|
||||
})
|
||||
if (edgeForm.value.condition && !options.some(item => item.value === edgeForm.value.condition)) {
|
||||
options.push({
|
||||
title: t('dialog.workflowActions.conditionCustom'),
|
||||
value: edgeForm.value.condition,
|
||||
})
|
||||
}
|
||||
return options
|
||||
})
|
||||
|
||||
// 选中动作节点时关闭可能打开的边条件面板,不再提供节点运行设置
|
||||
function handleNodeClick() {
|
||||
closeEdgeSettings()
|
||||
}
|
||||
|
||||
// 自定义节点类型
|
||||
const nodeTypes: Record<string, any> = ref({})
|
||||
|
||||
@@ -85,6 +302,17 @@ for (const path in components) {
|
||||
})
|
||||
}
|
||||
|
||||
// 加载动作契约,供边条件构造器使用
|
||||
async function loadActionDefinitions() {
|
||||
try {
|
||||
const actionList = await api.get('workflow/actions')
|
||||
actionDefinitions.value = Array.isArray(actionList) ? actionList : []
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
actionDefinitions.value = []
|
||||
}
|
||||
}
|
||||
|
||||
// 定义输入参数
|
||||
const props = defineProps({
|
||||
workflow: Object as PropType<Workflow>,
|
||||
@@ -142,8 +370,10 @@ function handleComponentClick(action: any) {
|
||||
// 调用API 编辑任务
|
||||
async function updateWorkflow() {
|
||||
// 更新节点和流程
|
||||
workflowForm.value.actions = nodes
|
||||
workflowForm.value.flows = edges
|
||||
normalizeWorkflowNodes()
|
||||
normalizeWorkflowEdges()
|
||||
workflowForm.value.actions = nodes.value
|
||||
workflowForm.value.flows = edges.value
|
||||
|
||||
try {
|
||||
const result: { [key: string]: string } = await api.put(`workflow/${workflowForm.value.id}`, workflowForm.value)
|
||||
@@ -166,6 +396,11 @@ function saveCodeString(type: string, code: any) {
|
||||
if (type === 'workflow') {
|
||||
nodes.value = codeObject.actions || []
|
||||
edges.value = codeObject.flows || []
|
||||
if (codeObject.execution_config) {
|
||||
workflowForm.value.execution_config = codeObject.execution_config
|
||||
}
|
||||
normalizeWorkflowNodes()
|
||||
normalizeWorkflowEdges()
|
||||
}
|
||||
importCodeDialog.value = false
|
||||
$toast.success(t('dialog.workflowActions.importSuccess'))
|
||||
@@ -178,18 +413,47 @@ function saveCodeString(type: string, code: any) {
|
||||
|
||||
// 分享工作流程
|
||||
function shareWorkflow() {
|
||||
const codeString = JSON.stringify({ actions: nodes.value, flows: edges.value })
|
||||
normalizeWorkflowNodes()
|
||||
normalizeWorkflowEdges()
|
||||
const codeString = JSON.stringify({
|
||||
actions: nodes.value,
|
||||
flows: edges.value,
|
||||
execution_config: workflowForm.value.execution_config,
|
||||
})
|
||||
navigator.clipboard.writeText(codeString)
|
||||
$toast.success(t('dialog.workflowActions.codeCopied'))
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadActionDefinitions()
|
||||
if (props.workflow) {
|
||||
nodes.value = props.workflow.actions ?? []
|
||||
edges.value = props.workflow.flows ?? []
|
||||
normalizeWorkflowNodes()
|
||||
normalizeWorkflowEdges()
|
||||
}
|
||||
})
|
||||
|
||||
watch(
|
||||
edges,
|
||||
() => {
|
||||
if (selectedEdgeId.value && !selectedEdge.value) {
|
||||
closeEdgeSettings()
|
||||
}
|
||||
},
|
||||
{ deep: true },
|
||||
)
|
||||
|
||||
watch(
|
||||
nodes,
|
||||
() => {
|
||||
if (selectedEdge.value && !canConfigureEdge(selectedEdge.value)) {
|
||||
closeEdgeSettings()
|
||||
}
|
||||
},
|
||||
{ deep: true },
|
||||
)
|
||||
|
||||
// 判断是不是MACOS
|
||||
const isMacOS = computed(() => {
|
||||
return /Macintosh|MacIntel|MacPPC|Mac68K/.test(navigator.userAgent)
|
||||
@@ -231,6 +495,8 @@ const isMacOS = computed(() => {
|
||||
:edge-updater-radius="10"
|
||||
@dragover="onDragOver"
|
||||
@dragleave="onDragLeave"
|
||||
@node-click="handleNodeClick"
|
||||
@edge-click="handleEdgeClick"
|
||||
:delete-key-code="isMacOS ? 'Backspace' : 'Delete'"
|
||||
auto-connect
|
||||
>
|
||||
@@ -243,6 +509,50 @@ const isMacOS = computed(() => {
|
||||
>
|
||||
</DropzoneBackground>
|
||||
</VueFlow>
|
||||
|
||||
<div v-if="selectedEdge" class="workflow-edge-panel">
|
||||
<div class="edge-panel-header">
|
||||
<div class="edge-panel-title">
|
||||
<VIcon icon="mdi-source-branch" size="20" />
|
||||
<span>{{ t('dialog.workflowActions.edgeSettingsTitle') }}</span>
|
||||
</div>
|
||||
<VBtn icon variant="text" size="small" @click="closeEdgeSettings">
|
||||
<VIcon icon="mdi-close" />
|
||||
</VBtn>
|
||||
</div>
|
||||
|
||||
<div class="edge-route">
|
||||
<span>{{ getNodeName(selectedEdge.source) }}</span>
|
||||
<VIcon icon="mdi-arrow-right" size="18" />
|
||||
<span>{{ getNodeName(selectedEdge.target) }}</span>
|
||||
</div>
|
||||
|
||||
<VSelect
|
||||
v-model="edgeForm.condition"
|
||||
:items="edgeConditionOptions"
|
||||
:label="t('dialog.workflowActions.edgeConditionLabel')"
|
||||
clearable
|
||||
item-title="title"
|
||||
item-value="value"
|
||||
variant="outlined"
|
||||
density="comfortable"
|
||||
hide-details="auto"
|
||||
/>
|
||||
|
||||
<div class="edge-panel-actions">
|
||||
<VBtn icon variant="text" color="error" @click="deleteSelectedEdge">
|
||||
<VIcon icon="mdi-delete" />
|
||||
</VBtn>
|
||||
<VSpacer />
|
||||
<VBtn variant="text" @click="closeEdgeSettings">
|
||||
{{ t('dialog.workflowActions.edgeCancel') }}
|
||||
</VBtn>
|
||||
<VBtn color="primary" @click="saveEdgeSettings">
|
||||
{{ t('dialog.workflowActions.edgeSave') }}
|
||||
</VBtn>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<WorkflowSidebar @component-click="handleComponentClick" />
|
||||
</div>
|
||||
</VCardText>
|
||||
@@ -285,12 +595,64 @@ const isMacOS = computed(() => {
|
||||
inline-size: 100%;
|
||||
}
|
||||
|
||||
.workflow-edge-panel {
|
||||
position: absolute;
|
||||
z-index: 120;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 16px;
|
||||
background-color: rgb(var(--v-theme-surface));
|
||||
gap: 14px;
|
||||
inline-size: min(360px, calc(100vw - 32px));
|
||||
inset-block-start: 20px;
|
||||
inset-inline-end: 20px;
|
||||
max-block-size: calc(100% - 40px);
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.edge-panel-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.edge-panel-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
color: rgb(var(--v-theme-on-surface));
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.edge-route {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
border-radius: 6px;
|
||||
background-color: rgba(var(--v-theme-primary), 0.08);
|
||||
color: rgb(var(--v-theme-on-surface));
|
||||
font-size: 13px;
|
||||
gap: 8px;
|
||||
padding-block: 8px;
|
||||
padding-inline: 10px;
|
||||
|
||||
span {
|
||||
overflow: hidden;
|
||||
flex: 1;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
|
||||
.edge-panel-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.vue-flow__minimap {
|
||||
overflow: hidden;
|
||||
border: 1px solid rgba(var(--v-border-color), var(--v-border-opacity));
|
||||
border-radius: 8px;
|
||||
background-color: rgba(var(--v-theme-surface), 0.8);
|
||||
box-shadow: 0 4px 15px rgba(var(--v-shadow-key-umbra-color), 0.1);
|
||||
inset-block-end: 20px;
|
||||
inset-inline-end: 20px;
|
||||
transform: scale(75%);
|
||||
@@ -318,16 +680,12 @@ const isMacOS = computed(() => {
|
||||
|
||||
// 自定义节点样式
|
||||
.vue-flow__node {
|
||||
border: 1px solid rgba(var(--v-border-color), var(--v-border-opacity));
|
||||
border-radius: 12px;
|
||||
|
||||
&:hover {
|
||||
box-shadow: 0 8px 16px rgba(var(--v-shadow-key-umbra-color), 0.15) !important;
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
&.selected {
|
||||
box-shadow: 0 0 0 1px rgb(var(--v-theme-primary)) !important;
|
||||
box-shadow: 0 0 0 1px rgb(var(--v-theme-primary));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -345,9 +703,23 @@ const isMacOS = computed(() => {
|
||||
}
|
||||
}
|
||||
|
||||
.vue-flow__edge.workflow-conditional-edge {
|
||||
.vue-flow__edge-path {
|
||||
stroke: rgb(var(--v-theme-warning));
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (width <= 600px) {
|
||||
.vue-flow__minimap {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.workflow-edge-panel {
|
||||
inline-size: auto;
|
||||
inset-block: auto 88px;
|
||||
inset-inline: 16px;
|
||||
max-block-size: min(72vh, calc(100% - 112px));
|
||||
}
|
||||
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -37,9 +37,35 @@ const workflowForm = ref<Workflow>(
|
||||
event_type: undefined,
|
||||
state: 'P',
|
||||
run_count: 0,
|
||||
execution_config: {},
|
||||
},
|
||||
)
|
||||
|
||||
// 将并发数清洗为正整数,空值表示使用后端默认值
|
||||
const normalizePositiveInteger = (value: any) => {
|
||||
if (value === undefined || value === null || value === '') return undefined
|
||||
const numberValue = Number(value)
|
||||
if (!Number.isFinite(numberValue) || numberValue < 1) return undefined
|
||||
return Math.floor(numberValue)
|
||||
}
|
||||
|
||||
// 工作流级执行配置中的最大并行数
|
||||
const workflowMaxWorkers = computed<number | null>({
|
||||
get() {
|
||||
return normalizePositiveInteger(workflowForm.value.execution_config?.max_workers) ?? null
|
||||
},
|
||||
set(value) {
|
||||
const executionConfig = { ...(workflowForm.value.execution_config || {}) }
|
||||
const maxWorkers = normalizePositiveInteger(value)
|
||||
if (maxWorkers) {
|
||||
executionConfig.max_workers = maxWorkers
|
||||
} else {
|
||||
delete executionConfig.max_workers
|
||||
}
|
||||
workflowForm.value.execution_config = Object.keys(executionConfig).length ? executionConfig : undefined
|
||||
},
|
||||
})
|
||||
|
||||
// 监听props变化,处理存量数据
|
||||
watch(
|
||||
() => props.workflow,
|
||||
@@ -49,7 +75,10 @@ watch(
|
||||
if (!newWorkflow.trigger_type) {
|
||||
newWorkflow.trigger_type = 'timer'
|
||||
}
|
||||
workflowForm.value = { ...newWorkflow }
|
||||
workflowForm.value = {
|
||||
...newWorkflow,
|
||||
execution_config: { ...(newWorkflow.execution_config || {}) },
|
||||
}
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
@@ -99,6 +128,18 @@ watch(
|
||||
// 提示框
|
||||
const $toast = useToast()
|
||||
|
||||
// 保存前统一清洗工作流执行配置
|
||||
function normalizeWorkflowExecutionConfig() {
|
||||
const executionConfig = { ...(workflowForm.value.execution_config || {}) }
|
||||
const maxWorkers = normalizePositiveInteger(executionConfig.max_workers)
|
||||
if (maxWorkers) {
|
||||
executionConfig.max_workers = maxWorkers
|
||||
} else {
|
||||
delete executionConfig.max_workers
|
||||
}
|
||||
workflowForm.value.execution_config = Object.keys(executionConfig).length ? executionConfig : undefined
|
||||
}
|
||||
|
||||
// 调用API 新增任务
|
||||
async function addWorkflow() {
|
||||
if (!workflowForm.value.name) {
|
||||
@@ -122,6 +163,7 @@ async function addWorkflow() {
|
||||
return
|
||||
}
|
||||
|
||||
normalizeWorkflowExecutionConfig()
|
||||
startNProgress()
|
||||
try {
|
||||
const result: { [key: string]: string } = await api.post('workflow/', workflowForm.value)
|
||||
@@ -160,6 +202,7 @@ async function editWorkflow() {
|
||||
return
|
||||
}
|
||||
|
||||
normalizeWorkflowExecutionConfig()
|
||||
startNProgress()
|
||||
try {
|
||||
const result: { [key: string]: string } = await api.put(`workflow/${workflowForm.value.id}`, workflowForm.value)
|
||||
@@ -256,15 +299,32 @@ onMounted(() => {
|
||||
prepend-inner-icon="mdi-text-box-outline"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12">
|
||||
<VTextField
|
||||
v-model.number="workflowMaxWorkers"
|
||||
type="number"
|
||||
min="1"
|
||||
clearable
|
||||
:label="t('dialog.workflowAddEdit.maxWorkers')"
|
||||
prepend-inner-icon="mdi-call-split"
|
||||
/>
|
||||
</VCol>
|
||||
</VRow>
|
||||
</VForm>
|
||||
</VCardText>
|
||||
<VCardActions class="pt-3">
|
||||
<VCardActions class="app-dialog-actions">
|
||||
<VSpacer />
|
||||
<VBtn v-if="workflow" color="primary" @click="editWorkflow" prepend-icon="mdi-content-save" class="px-5">
|
||||
<VBtn
|
||||
v-if="workflow"
|
||||
color="primary"
|
||||
variant="flat"
|
||||
@click="editWorkflow"
|
||||
prepend-icon="mdi-content-save"
|
||||
class="px-5"
|
||||
>
|
||||
{{ t('dialog.workflowAddEdit.confirm') }}
|
||||
</VBtn>
|
||||
<VBtn v-else color="primary" @click="addWorkflow" prepend-icon="mdi-plus" class="px-5">
|
||||
<VBtn v-else color="primary" variant="flat" @click="addWorkflow" prepend-icon="mdi-plus" class="px-5">
|
||||
{{ t('dialog.workflowAddEdit.confirm') }}
|
||||
</VBtn>
|
||||
</VCardActions>
|
||||
|
||||
@@ -125,9 +125,17 @@ const $toast = useToast()
|
||||
</VRow>
|
||||
</VForm>
|
||||
</VCardText>
|
||||
<VCardActions class="pt-3">
|
||||
<VCardActions class="app-dialog-actions">
|
||||
<VSpacer />
|
||||
<VBtn :disabled="shareDoing" @click="doShare" prepend-icon="mdi-share" class="px-5" :loading="shareDoing">
|
||||
<VBtn
|
||||
color="primary"
|
||||
variant="flat"
|
||||
:disabled="shareDoing"
|
||||
@click="doShare"
|
||||
prepend-icon="mdi-share"
|
||||
class="px-5"
|
||||
:loading="shareDoing"
|
||||
>
|
||||
{{ t('dialog.workflowShare.confirmShare') }}
|
||||
</VBtn>
|
||||
</VCardActions>
|
||||
|
||||
@@ -29,8 +29,7 @@ const display = useDisplay()
|
||||
const { appMode } = usePWA()
|
||||
|
||||
// 计算列表可用高度
|
||||
// componentOffset = FileToolbar(48) + FileList操作栏(40) + VCard边距(4) = 92
|
||||
const { availableHeight: listAvailableHeight } = useAvailableHeight(92, 300)
|
||||
const { availableHeight: listAvailableHeight } = useAvailableHeight(100, 300)
|
||||
|
||||
// 输入参数
|
||||
const inProps = defineProps({
|
||||
@@ -239,6 +238,12 @@ function changeSelectMode() {
|
||||
if (!selectMode.value) selected.value = []
|
||||
}
|
||||
|
||||
// 退出多选模式
|
||||
function exitSelectMode() {
|
||||
selectMode.value = false
|
||||
selected.value = []
|
||||
}
|
||||
|
||||
// 调API加载文件夹内的内容
|
||||
async function list_files(context: KeepAliveRefreshContext = {}) {
|
||||
const silentRefresh = Boolean(context.silent && items.value.length > 0)
|
||||
@@ -261,7 +266,7 @@ async function list_files(context: KeepAliveRefreshContext = {}) {
|
||||
}
|
||||
|
||||
// 加载数据
|
||||
const data = ((await inProps.axios.request<FileItem[], FileItem[]>(config))) ?? []
|
||||
const data = (await inProps.axios.request<FileItem[], FileItem[]>(config)) ?? []
|
||||
// 如果当前路径已经变化,则放弃此次加载结果
|
||||
if (prevURI !== takeURISnapshot()) {
|
||||
return
|
||||
@@ -316,6 +321,8 @@ async function deleteItem(item: FileItem, confirm: boolean = true) {
|
||||
|
||||
// 批量删除
|
||||
async function batchDelete() {
|
||||
if (!selected.value.length) return
|
||||
|
||||
const confirmed = await createConfirm({
|
||||
title: t('common.confirm'),
|
||||
content: t('file.confirmBatchDelete', { count: selected.value.length }),
|
||||
@@ -327,18 +334,24 @@ async function batchDelete() {
|
||||
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)
|
||||
})
|
||||
try {
|
||||
const selectedItems = dedupeFileItems(selected.value)
|
||||
|
||||
// 关闭进度条
|
||||
closeProgressDialog()
|
||||
// 删除选中的项目
|
||||
for (const item of selectedItems) {
|
||||
progressText.value = t('file.deleting', { name: item.name })
|
||||
progressDialogController?.updateProps({ text: progressText.value })
|
||||
await deleteItem(item, false)
|
||||
}
|
||||
|
||||
// 重新加载
|
||||
list_files()
|
||||
exitSelectMode()
|
||||
} finally {
|
||||
// 关闭进度条
|
||||
closeProgressDialog()
|
||||
|
||||
// 重新加载
|
||||
list_files()
|
||||
}
|
||||
}
|
||||
|
||||
// 切换路径
|
||||
@@ -367,7 +380,7 @@ async function download(item: FileItem) {
|
||||
responseType: 'blob',
|
||||
}
|
||||
// 加载数据
|
||||
const result: Blob = (await inProps.axios.request<Blob, Blob>(config))
|
||||
const result: Blob = await inProps.axios.request<Blob, Blob>(config)
|
||||
if (result) {
|
||||
const downloadUrl = URL.createObjectURL(result)
|
||||
window.open(downloadUrl, '_blank')
|
||||
@@ -388,7 +401,7 @@ async function getImgLink(item: FileItem) {
|
||||
responseType: 'blob',
|
||||
}
|
||||
// 加载二进制数据
|
||||
const result: Blob = (await inProps.axios.request<Blob, Blob>(config))
|
||||
const result: Blob = await inProps.axios.request<Blob, Blob>(config)
|
||||
if (result) {
|
||||
// 创建图片地址
|
||||
revokeCurrentImgLink()
|
||||
@@ -494,7 +507,7 @@ async function rename() {
|
||||
method: inProps.endpoints?.rename.method || 'post',
|
||||
data: currentItem.value,
|
||||
}
|
||||
const result: { [key: string]: any } = (await inProps.axios?.request<any, { [key: string]: any }>(config))
|
||||
const result: { [key: string]: any } = await inProps.axios?.request<any, { [key: string]: any }>(config)
|
||||
if (!result.success) {
|
||||
$toast.error(result.message)
|
||||
}
|
||||
@@ -528,6 +541,7 @@ function showBatchTransfer() {
|
||||
|
||||
// 整理完成
|
||||
function transferDone() {
|
||||
exitSelectMode()
|
||||
list_files()
|
||||
}
|
||||
|
||||
@@ -688,6 +702,8 @@ async function scrape(item: FileItem, confirm: boolean = true) {
|
||||
|
||||
// 批量刮削
|
||||
async function batchScrape() {
|
||||
if (!selected.value.length) return
|
||||
|
||||
// 确认
|
||||
const confirmed = await createConfirm({
|
||||
title: t('common.confirm'),
|
||||
@@ -695,9 +711,17 @@ async function batchScrape() {
|
||||
})
|
||||
if (!confirmed) return
|
||||
|
||||
selected.value.map(item => {
|
||||
scrape(item, false)
|
||||
})
|
||||
try {
|
||||
const selectedItems = dedupeFileItems(selected.value)
|
||||
|
||||
for (const item of selectedItems) {
|
||||
await scrape(item, false)
|
||||
}
|
||||
|
||||
exitSelectMode()
|
||||
} finally {
|
||||
list_files({ silent: true })
|
||||
}
|
||||
}
|
||||
|
||||
// 进度SSE消息处理函数
|
||||
@@ -743,17 +767,9 @@ onUnmounted(() => {
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.file-list-container {
|
||||
overflow: hidden auto;
|
||||
block-size: 100%;
|
||||
max-block-size: 100%;
|
||||
}
|
||||
</style>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<VCard class="d-flex flex-column w-full h-full rounded-t-0" :class="{ 'rounded-s-0': showTree }">
|
||||
<VCard class="d-flex flex-column w-full h-full file-list">
|
||||
<div v-if="!loading" class="flex">
|
||||
<IconBtn v-if="display.mdAndUp.value">
|
||||
<VIcon v-if="showTree" icon="mdi-file-tree" @click="switchFileTree(false)" />
|
||||
@@ -767,7 +783,7 @@ onUnmounted(() => {
|
||||
density="compact"
|
||||
variant="plain"
|
||||
:placeholder="t('file.filterPlaceholder')"
|
||||
:prepend-inner-icon="(filter.includes('*') || filter.includes('?')) ? 'mdi-asterisk' : 'mdi-filter-outline'"
|
||||
:prepend-inner-icon="filter.includes('*') || filter.includes('?') ? 'mdi-asterisk' : 'mdi-filter-outline'"
|
||||
class="mx-2"
|
||||
rounded
|
||||
/>
|
||||
@@ -906,3 +922,17 @@ onUnmounted(() => {
|
||||
</VCard>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.file-list {
|
||||
border-radius: 0 !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
.file-list-container {
|
||||
overflow: hidden auto;
|
||||
border-radius: 0 !important;
|
||||
block-size: 100%;
|
||||
max-block-size: 100%;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -4,7 +4,6 @@ import type { FileItem } from '@/api/types'
|
||||
import { useDisplay } from 'vuetify'
|
||||
import type { AxiosRequestConfig, AxiosInstance } from 'axios'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { usePWA } from '@/composables/usePWA'
|
||||
import { useAvailableHeight } from '@/composables/useAvailableHeight'
|
||||
|
||||
// 国际化
|
||||
@@ -12,16 +11,13 @@ const { t } = useI18n()
|
||||
|
||||
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)
|
||||
const { availableHeight } = useAvailableHeight(58, 300)
|
||||
|
||||
// 输入参数
|
||||
const props = defineProps({
|
||||
@@ -116,7 +112,7 @@ async function loadSubdirectories(path: string) {
|
||||
data: fakeItem,
|
||||
}
|
||||
|
||||
const result = (await props.axios?.request(config))
|
||||
const result = await props.axios?.request(config)
|
||||
if (result && Array.isArray(result)) {
|
||||
// 过滤出目录项
|
||||
const dirs = result.filter(item => item.type === 'dir')
|
||||
@@ -249,11 +245,10 @@ function getTreeRowStyle(level: number) {
|
||||
onMounted(async () => {
|
||||
await loadRootDirectories()
|
||||
})
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VCard class="file-navigator rounded-e-0 rounded-t-0" v-if="!isMobile" :height="`${availableHeight}px`">
|
||||
<VCard class="file-navigator" v-if="!isMobile" :height="`${availableHeight}px`">
|
||||
<VVirtualScroll :items="visibleTreeRows" :item-height="32" class="tree-container">
|
||||
<template #default="{ item }">
|
||||
<div
|
||||
@@ -296,13 +291,7 @@ onMounted(async () => {
|
||||
: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"
|
||||
/>
|
||||
<VProgressCircular v-if="loading[item.dir.path || '']" indeterminate size="14" width="2" color="primary" />
|
||||
<VIcon
|
||||
v-else
|
||||
size="small"
|
||||
@@ -332,9 +321,9 @@ onMounted(async () => {
|
||||
overflow: hidden;
|
||||
flex-direction: column;
|
||||
flex-shrink: 0;
|
||||
background: rgb(var(--v-table-header-background));
|
||||
border-radius: 0 !important;
|
||||
block-size: 100%;
|
||||
border-end-start-radius: 12px;
|
||||
box-shadow: none !important;
|
||||
inline-size: 240px;
|
||||
}
|
||||
|
||||
|
||||
@@ -372,7 +372,7 @@ onMounted(() => {
|
||||
:key="key"
|
||||
variant="tonal"
|
||||
size="small"
|
||||
:color="filterForm[key].length > 0 ? 'primary' : undefined"
|
||||
color="primary"
|
||||
:prepend-icon="getFilterIcon(key)"
|
||||
class="filter-btn"
|
||||
rounded="pill"
|
||||
@@ -555,7 +555,7 @@ onMounted(() => {
|
||||
v-for="(title, key) in filterTitles"
|
||||
v-show="filterOptions[key].length > 0"
|
||||
:key="key"
|
||||
variant="text"
|
||||
variant="tonal"
|
||||
color="primary"
|
||||
class="filter-btn-mobile"
|
||||
@click="toggleFilterMenu(key)"
|
||||
@@ -575,7 +575,7 @@ onMounted(() => {
|
||||
</VBtn>
|
||||
|
||||
<!-- 全部筛选按钮 -->
|
||||
<VBtn variant="text" color="primary" class="filter-btn-mobile" @click="toggleAllFilterMenu">
|
||||
<VBtn variant="tonal" color="primary" class="filter-btn-mobile" @click="toggleAllFilterMenu">
|
||||
<VIcon icon="mdi-filter-variant" class="filter-icon me-1"></VIcon>
|
||||
<span class="filter-label">
|
||||
{{ t('torrent.allFilters') }}
|
||||
@@ -609,8 +609,6 @@ onMounted(() => {
|
||||
|
||||
.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);
|
||||
}
|
||||
|
||||
@@ -632,11 +630,6 @@ onMounted(() => {
|
||||
margin-inline-end: 2px !important;
|
||||
}
|
||||
|
||||
.sort-menu-list {
|
||||
border: 1px solid rgba(var(--v-theme-on-surface), 0.08);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1) !important;
|
||||
}
|
||||
|
||||
.sort-menu-list :deep(.v-list-item__prepend > .v-icon) {
|
||||
margin-inline-end: 0px !important;
|
||||
}
|
||||
@@ -672,7 +665,6 @@ onMounted(() => {
|
||||
|
||||
.filter-btn {
|
||||
min-inline-size: 0;
|
||||
background: rgba(var(--v-theme-surface-variant), 0.1);
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
|
||||
@@ -740,7 +732,6 @@ 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-variant), 0.08);
|
||||
block-size: auto;
|
||||
min-block-size: 48px;
|
||||
padding-block: 4px;
|
||||
@@ -758,10 +749,6 @@ onMounted(() => {
|
||||
}
|
||||
|
||||
@media (width <= 600px) {
|
||||
.filter-toolbar-card {
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.filter-buttons-grid {
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
@@ -6,6 +6,8 @@ import DashboardRender from '@/components/render/DashboardRender.vue'
|
||||
import { isNullOrEmptyObject } from '@/@core/utils'
|
||||
import { loadRemoteComponent } from '@/utils/federationLoader'
|
||||
|
||||
type DashboardComponentLoader = () => Promise<any>
|
||||
|
||||
const DashboardSkeleton = {
|
||||
setup() {
|
||||
const SkeletonLoader = resolveComponent('VSkeletonLoader')
|
||||
@@ -19,51 +21,59 @@ const asyncDashboardOptions = {
|
||||
loadingComponent: DashboardSkeleton,
|
||||
}
|
||||
|
||||
const builtInDashboardComponentLoaders: Record<string, DashboardComponentLoader> = {
|
||||
storage: () => import('@/views/dashboard/AnalyticsStorage.vue'),
|
||||
mediaStatistic: () => import('@/views/dashboard/AnalyticsMediaStatistic.vue'),
|
||||
weeklyOverview: () => import('@/views/dashboard/AnalyticsWeeklyOverview.vue'),
|
||||
speed: () => import('@/views/dashboard/AnalyticsSpeed.vue'),
|
||||
scheduler: () => import('@/views/dashboard/AnalyticsScheduler.vue'),
|
||||
cpu: () => import('@/views/dashboard/AnalyticsCpu.vue'),
|
||||
memory: () => import('@/views/dashboard/AnalyticsMemory.vue'),
|
||||
network: () => import('@/views/dashboard/AnalyticsNetwork.vue'),
|
||||
library: () => import('@/views/dashboard/MediaServerLibrary.vue'),
|
||||
playing: () => import('@/views/dashboard/MediaServerPlaying.vue'),
|
||||
latest: () => import('@/views/dashboard/MediaServerLatest.vue'),
|
||||
}
|
||||
|
||||
const builtInDashboardComponentPromises = new Map<string, Promise<any>>()
|
||||
|
||||
// 复用内置仪表盘组件加载 Promise,让页面层可以等待异步组件模块真正加载完成。
|
||||
function loadBuiltInDashboardComponent(id: string) {
|
||||
const loader = builtInDashboardComponentLoaders[id]
|
||||
if (!loader) return Promise.resolve()
|
||||
|
||||
let loadPromise = builtInDashboardComponentPromises.get(id)
|
||||
if (!loadPromise) {
|
||||
loadPromise = loader().catch(error => {
|
||||
builtInDashboardComponentPromises.delete(id)
|
||||
throw error
|
||||
})
|
||||
builtInDashboardComponentPromises.set(id, loadPromise)
|
||||
}
|
||||
|
||||
return loadPromise
|
||||
}
|
||||
|
||||
// 创建内置仪表盘异步组件,并与加载完成上报共享同一份加载 Promise。
|
||||
function createAsyncDashboardComponent(id: string) {
|
||||
return defineAsyncComponent({
|
||||
loader: () => loadBuiltInDashboardComponent(id),
|
||||
...asyncDashboardOptions,
|
||||
})
|
||||
}
|
||||
|
||||
// 内置仪表盘按需加载,关闭的卡片不再挤进 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 AnalyticsStorage = createAsyncDashboardComponent('storage')
|
||||
const AnalyticsMediaStatistic = createAsyncDashboardComponent('mediaStatistic')
|
||||
const AnalyticsWeeklyOverview = createAsyncDashboardComponent('weeklyOverview')
|
||||
const AnalyticsSpeed = createAsyncDashboardComponent('speed')
|
||||
const AnalyticsScheduler = createAsyncDashboardComponent('scheduler')
|
||||
const AnalyticsCpu = createAsyncDashboardComponent('cpu')
|
||||
const AnalyticsMemory = createAsyncDashboardComponent('memory')
|
||||
const AnalyticsNetwork = createAsyncDashboardComponent('network')
|
||||
const MediaServerLibrary = createAsyncDashboardComponent('library')
|
||||
const MediaServerPlaying = createAsyncDashboardComponent('playing')
|
||||
const MediaServerLatest = createAsyncDashboardComponent('latest')
|
||||
|
||||
// 输入参数
|
||||
const props = defineProps({
|
||||
@@ -78,27 +88,43 @@ const props = defineProps({
|
||||
},
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:refreshStatus'])
|
||||
const emit = defineEmits(['update:refreshStatus', 'loaded'])
|
||||
|
||||
// 当前仪表盘节点是否已经向页面层报告过加载完成。
|
||||
const isDashboardElementLoaded = ref(false)
|
||||
|
||||
let isDashboardElementUnmounted = false
|
||||
let pluginDashboardComponentLoadPromise: Promise<any> | null = null
|
||||
|
||||
// 插件UI渲染模式 ('vuetify' 或 'vue')
|
||||
const pluginRenderMode = computed(() => props.config?.render_mode || 'vuetify')
|
||||
|
||||
// 加载 Vue 模式的插件仪表盘远程组件,并缓存当前节点的加载 Promise。
|
||||
function loadPluginDashboardComponent() {
|
||||
if (!props.config?.id) return Promise.reject(new Error('插件ID不存在'))
|
||||
|
||||
if (!pluginDashboardComponentLoadPromise) {
|
||||
pluginDashboardComponentLoadPromise = loadRemoteComponent(props.config.id, 'Dashboard').catch(error => {
|
||||
pluginDashboardComponentLoadPromise = null
|
||||
throw error
|
||||
})
|
||||
}
|
||||
|
||||
return pluginDashboardComponentLoadPromise
|
||||
}
|
||||
|
||||
// Vue 模式:动态加载的组件
|
||||
const dynamicPluginComponent = defineAsyncComponent({
|
||||
// 工厂函数
|
||||
loader: async () => {
|
||||
try {
|
||||
if (!props.config?.id) {
|
||||
throw new Error('插件ID不存在')
|
||||
}
|
||||
|
||||
// 动态加载远程组件
|
||||
const module = await loadRemoteComponent(props.config.id, 'Dashboard')
|
||||
const module = await loadPluginDashboardComponent()
|
||||
|
||||
// 直接返回加载的组件,无需再获取default
|
||||
return module
|
||||
} catch (error) {
|
||||
console.error('加载远程组件失败:', error)
|
||||
throw error
|
||||
}
|
||||
},
|
||||
// 加载中显示的组件
|
||||
@@ -115,7 +141,53 @@ const dynamicPluginComponent = defineAsyncComponent({
|
||||
},
|
||||
})
|
||||
|
||||
// 判断当前配置是否对应内置异步仪表盘组件。
|
||||
function isBuiltInDashboardElement() {
|
||||
return !!props.config?.id && !!builtInDashboardComponentLoaders[props.config.id]
|
||||
}
|
||||
|
||||
// 判断当前配置是否需要等待插件 Vue 远程组件加载。
|
||||
function isVuePluginDashboardElement() {
|
||||
return !isBuiltInDashboardElement() && pluginRenderMode.value === 'vue' && !isNullOrEmptyObject(props.config)
|
||||
}
|
||||
|
||||
// 向页面层上报当前仪表盘节点已完成首次组件加载。
|
||||
function emitDashboardElementLoaded() {
|
||||
if (isDashboardElementLoaded.value || isDashboardElementUnmounted) return
|
||||
|
||||
isDashboardElementLoaded.value = true
|
||||
emit('loaded')
|
||||
}
|
||||
|
||||
// 等待当前仪表盘节点的异步组件加载完成,静态渲染模式则等待一次 DOM 更新。
|
||||
async function waitForDashboardElementLoaded() {
|
||||
if (isDashboardElementLoaded.value) return
|
||||
|
||||
try {
|
||||
if (isBuiltInDashboardElement() && props.config?.id) {
|
||||
await loadBuiltInDashboardComponent(props.config.id)
|
||||
} else if (isVuePluginDashboardElement()) {
|
||||
await loadPluginDashboardComponent()
|
||||
}
|
||||
|
||||
await nextTick()
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
} finally {
|
||||
emitDashboardElementLoaded()
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => [props.config?.id, props.config?.key, pluginRenderMode.value],
|
||||
() => {
|
||||
void waitForDashboardElementLoaded()
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
onUnmounted(() => {
|
||||
isDashboardElementUnmounted = true
|
||||
// 组件卸载时禁用刷新状态
|
||||
emit('update:refreshStatus', false)
|
||||
})
|
||||
@@ -136,43 +208,47 @@ onUnmounted(() => {
|
||||
<!-- 插件仪表板 -->
|
||||
<template v-else-if="!isNullOrEmptyObject(props.config)">
|
||||
<!-- Vue 渲染模式 -->
|
||||
<div v-if="pluginRenderMode === 'vue'">
|
||||
<div v-if="pluginRenderMode === 'vue'" class="dashboard-plugin-vue-renderer">
|
||||
<component :is="dynamicPluginComponent" :config="props.config" :allow-refresh="props.allowRefresh" :api="api" />
|
||||
</div>
|
||||
<!-- Vuetify 渲染模式 -->
|
||||
<VHover v-else-if="pluginRenderMode === 'vuetify'">
|
||||
<template #default="hover">
|
||||
<!-- 无边框 -->
|
||||
<div v-if="props.config?.attrs.border === false">
|
||||
<VCard v-bind="hover.props">
|
||||
<VCardText class="p-0">
|
||||
<DashboardRender v-for="(item, index) in props.config?.elements" :key="index" :config="item" />
|
||||
</VCardText>
|
||||
<div v-if="hover.isHovering" class="absolute right-5 top-5">
|
||||
<VIcon class="cursor-move">mdi-drag</VIcon>
|
||||
</div>
|
||||
</VCard>
|
||||
</div>
|
||||
<!-- 有边框 -->
|
||||
<VCard v-else v-bind="hover.props">
|
||||
<VCardItem v-if="props.config?.attrs.border !== false">
|
||||
<template #append>
|
||||
<VIcon class="cursor-move" v-if="hover.isHovering">mdi-drag</VIcon>
|
||||
</template>
|
||||
<VCardTitle>
|
||||
{{ props.config?.attrs?.title ?? props.config?.name }}
|
||||
</VCardTitle>
|
||||
<VCardSubtitle v-if="props.config?.attrs?.subtitle"> {{ props.config?.attrs?.subtitle }}</VCardSubtitle>
|
||||
</VCardItem>
|
||||
<VCardText>
|
||||
<template v-else-if="pluginRenderMode === 'vuetify'">
|
||||
<!-- 无边框 -->
|
||||
<div v-if="props.config?.attrs.border === false">
|
||||
<VCard>
|
||||
<VCardText class="p-0">
|
||||
<DashboardRender v-for="(item, index) in props.config?.elements" :key="index" :config="item" />
|
||||
</VCardText>
|
||||
</VCard>
|
||||
</template>
|
||||
</VHover>
|
||||
</div>
|
||||
<!-- 有边框 -->
|
||||
<VCard v-else>
|
||||
<VCardItem v-if="props.config?.attrs.border !== false">
|
||||
<VCardTitle>
|
||||
{{ props.config?.attrs?.title ?? props.config?.name }}
|
||||
</VCardTitle>
|
||||
<VCardSubtitle v-if="props.config?.attrs?.subtitle"> {{ props.config?.attrs?.subtitle }}</VCardSubtitle>
|
||||
</VCardItem>
|
||||
<VCardText>
|
||||
<DashboardRender v-for="(item, index) in props.config?.elements" :key="index" :config="item" />
|
||||
</VCardText>
|
||||
</VCard>
|
||||
</template>
|
||||
<!-- 未知模式或错误 -->
|
||||
<VCard v-else>
|
||||
<VCardText>无法渲染插件仪表盘部件: 未知渲染模式或配置错误</VCardText>
|
||||
</VCard>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* stylelint-disable selector-pseudo-class-no-unknown */
|
||||
|
||||
.dashboard-plugin-vue-renderer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
block-size: 100%;
|
||||
inline-size: 100%;
|
||||
min-block-size: 0;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -3,9 +3,9 @@ import type { PropType } from 'vue'
|
||||
import MarkdownIt from 'markdown-it'
|
||||
import mdLinkAttributes from 'markdown-it-link-attributes'
|
||||
|
||||
// 初始化 markdown-it
|
||||
// 版本历史可能来自插件市场或 Release 内容,禁止透传原始 HTML,避免外部内容注入脚本或事件属性。
|
||||
const md = new MarkdownIt({
|
||||
html: true,
|
||||
html: false,
|
||||
linkify: true,
|
||||
typographer: true,
|
||||
})
|
||||
@@ -27,23 +27,100 @@ function renderMarkdown(value: string) {
|
||||
// 输入参数
|
||||
const props = defineProps({
|
||||
history: Object as PropType<{ [key: string]: string }>,
|
||||
hasAction: Function as PropType<(version: string) => boolean>,
|
||||
})
|
||||
|
||||
function shouldRenderAction(version: string) {
|
||||
return props.hasAction?.(version) ?? true
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VCardText>
|
||||
<VList>
|
||||
<VListItem v-for="(value, key) in props.history" :key="key">
|
||||
<VListItemTitle class="font-bold text-lg">
|
||||
{{ key }}
|
||||
</VListItemTitle>
|
||||
<div class="markdown-body text-gray-500" v-html="renderMarkdown(value)" />
|
||||
</VListItem>
|
||||
</VList>
|
||||
<VCardText class="version-history">
|
||||
<div class="version-history__list">
|
||||
<section v-for="(value, key) in props.history" :key="key" class="version-history__item">
|
||||
<div
|
||||
class="version-history__top"
|
||||
:class="{ 'version-history__top--with-action': $slots.action && shouldRenderAction(String(key)) }"
|
||||
>
|
||||
<div class="version-history__header">
|
||||
<div class="version-history__version">
|
||||
{{ key }}
|
||||
</div>
|
||||
<div v-if="$slots.meta" class="version-history__meta">
|
||||
<slot name="meta" :version="String(key)" />
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="$slots.action && shouldRenderAction(String(key))" class="version-history__action">
|
||||
<slot name="action" :version="String(key)" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="markdown-body text-medium-emphasis" v-html="renderMarkdown(value)" />
|
||||
</section>
|
||||
</div>
|
||||
</VCardText>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.version-history {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.version-history__list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.version-history__item {
|
||||
padding: 1.25rem 2rem;
|
||||
}
|
||||
|
||||
.version-history__item + .version-history__item {
|
||||
border-block-start: 1px solid rgba(var(--v-border-color), var(--v-border-opacity));
|
||||
}
|
||||
|
||||
.version-history__top {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
grid-template-areas: "main";
|
||||
gap: 0;
|
||||
align-items: center;
|
||||
margin-block-end: 0.5rem;
|
||||
}
|
||||
|
||||
.version-history__top--with-action {
|
||||
grid-template-columns: minmax(0, 1fr) max-content;
|
||||
grid-template-areas: "main action";
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.version-history__header {
|
||||
grid-area: main;
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.version-history__version {
|
||||
color: rgba(var(--v-theme-on-surface), var(--v-high-emphasis-opacity));
|
||||
font-size: 1.25rem;
|
||||
font-weight: 700;
|
||||
line-height: 1.25;
|
||||
}
|
||||
|
||||
.version-history__meta {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.version-history__action {
|
||||
grid-area: action;
|
||||
align-self: center;
|
||||
justify-self: end;
|
||||
}
|
||||
|
||||
.markdown-body :deep(h1),
|
||||
.markdown-body :deep(h2),
|
||||
.markdown-body :deep(h3) {
|
||||
@@ -112,4 +189,28 @@ const props = defineProps({
|
||||
border-inline-start: 3px solid rgba(127, 127, 127, 0.4);
|
||||
color: rgba(127, 127, 127, 0.8);
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.version-history {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.version-history__item {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.version-history__top--with-action {
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.version-history__header {
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.version-history__version {
|
||||
font-size: 1.125rem;
|
||||
}
|
||||
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -140,7 +140,8 @@ function slideNext(next: boolean) {
|
||||
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)
|
||||
const currentIndex =
|
||||
element.scrollLeft === 0 ? 0 : Math.trunc((element.scrollLeft + itemStep.value / 2) / itemStep.value)
|
||||
let targetLeft = 0
|
||||
|
||||
if (next) {
|
||||
@@ -285,15 +286,22 @@ watch(
|
||||
<style lang="scss" scoped>
|
||||
.slider-container {
|
||||
position: relative;
|
||||
isolation: isolate;
|
||||
margin-block-end: 8px;
|
||||
|
||||
--slider-shadow-bleed-start: 28px;
|
||||
--slider-shadow-bleed-end: 56px;
|
||||
}
|
||||
|
||||
.slider-header {
|
||||
// 阴影缓冲区会把滚动区域上移,标题层级需高于滚动区域以保留按钮点击。
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
margin-block-end: 8px;
|
||||
margin-block-end: 12px;
|
||||
padding-block: 0;
|
||||
padding-inline: 8px;
|
||||
|
||||
@@ -340,20 +348,22 @@ watch(
|
||||
|
||||
.slider-content-wrapper {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
inline-size: 100%;
|
||||
}
|
||||
|
||||
.slider-content-container {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
inline-size: 100%;
|
||||
}
|
||||
|
||||
.slider-content {
|
||||
overflow: scroll hidden !important;
|
||||
// 横向滚动会让纵向 visible 被浏览器计算成可裁剪区域,这里用缓冲区承接卡片阴影。
|
||||
margin-block: calc(var(--slider-shadow-bleed-start) * -1) calc(var(--slider-shadow-bleed-end) * -1);
|
||||
-ms-overflow-style: none !important;
|
||||
overflow: auto hidden;
|
||||
overscroll-behavior-x: contain !important;
|
||||
padding-block: 8px;
|
||||
padding-block: var(--slider-shadow-bleed-start) var(--slider-shadow-bleed-end);
|
||||
padding-inline: 12px;
|
||||
scroll-behavior: smooth;
|
||||
scrollbar-width: none !important;
|
||||
@@ -380,6 +390,11 @@ watch(
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.virtual-slide-item,
|
||||
.loading-track > * {
|
||||
padding-block-end: 12px;
|
||||
}
|
||||
|
||||
.nav-button {
|
||||
position: absolute;
|
||||
z-index: 20;
|
||||
@@ -399,8 +414,12 @@ watch(
|
||||
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;
|
||||
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;
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user