Compare commits

..

72 Commits

Author SHA1 Message Date
jxxghp
a73c28c1f7 fix vlist scroll 2026-06-05 13:30:17 +08:00
jxxghp
b3fb7e1de1 feat: 增强主题管理,支持动态主题切换和持久化设置 2026-06-05 09:06:06 +08:00
jxxghp
3620b2a979 fix: 调整布局和样式,增加卡片网格的内边距以避免溢出 2026-06-05 08:24:16 +08:00
jxxghp
1046cb276f 更新 _default-layout.scss 2026-06-05 06:53:32 +08:00
jxxghp
6dfda4807c fix: 简化 showPasskeyLogin 计算属性,调整登录卡片内边距 2026-06-05 00:26:36 +08:00
jxxghp
8d13f3e5ca fix: 调整布局和样式,修复滚动和间距问题 2026-06-04 23:33:20 +08:00
jxxghp
9ebe740c69 更新 DownloadingListView.vue 2026-06-04 22:53:06 +08:00
jxxghp
73673820f1 fix: prevent download card shadow clipping 2026-06-04 22:42:43 +08:00
jxxghp
c6d0116e0f fix: apply theme shadow to workflow and download cards 2026-06-04 22:22:55 +08:00
jxxghp
6a06001dae fix: 修复横向滑块标题按钮点击区域 2026-06-04 22:07:02 +08:00
jxxghp
ad75b50a0c fix: 修复主题定制器顶部安全区 2026-06-04 21:54:52 +08:00
jxxghp
2a68aa05f6 fix: 修复主题定制器移动端滚动高度 2026-06-04 21:45:21 +08:00
jxxghp
fa90411c7a feat: 添加滑块阴影缓冲区,优化横向滚动效果 2026-06-04 21:25:08 +08:00
jxxghp
643ddcef07 feat: enhance theme customizer with shadow options and update styles
- Added shadow customization options to the theme customizer, allowing users to select from 'none', 'low', 'medium', and 'high'.
- Updated the theme customizer settings interface and default values to include shadow settings.
- Enhanced the CSS variables for shadows in common.scss to support different shadow levels based on user selection.
- Modified the VirtualSlideView component styles to improve layout and scrolling behavior.
- Updated localization files for English, Simplified Chinese, and Traditional Chinese to include new shadow-related terms.
- Adjusted various components to ensure consistent application of shadow styles across the application.
2026-06-04 21:20:08 +08:00
jxxghp
addc0838c0 feat: 添加工作流执行配置和并行数设置,优化工作流管理功能 2026-06-04 15:57:08 +08:00
jxxghp
8b43e0a754 更新 package.json 2026-06-04 08:45:25 +08:00
jxxghp
f81a9f0929 fix: 移除无用的认证提供方加载状态,简化登录逻辑 2026-06-04 08:37:19 +08:00
jxxghp
3cf5cc24cd feat: 添加插件认证支持,优化登录流程 2026-06-04 08:24:10 +08:00
jxxghp
841e9479af refactor: unify header tab menu definitions 2026-06-03 07:21:34 +08:00
jxxghp
8c3380e8f5 fix: 更新主题定制器翻译,优化用户界面文本 2026-06-02 23:03:19 +08:00
jxxghp
0ac42f0a76 fix: 添加订阅排序方式的本地存储功能,优化用户体验 2026-06-02 22:48:42 +08:00
jxxghp
caef6eca67 fix: 同步主题定制器状态到根节点,优化全局悬浮按钮位置 2026-06-02 22:44:53 +08:00
jxxghp
0867236b68 fix: 更新主题定制器图标样式,提升可读性 2026-06-02 21:26:30 +08:00
jxxghp
09dfdbaf67 fix: 简化主题定制器按钮样式,提升界面整洁度 2026-06-02 21:11:13 +08:00
jxxghp
57224e15fb fix: 调整主题定制器和对话框样式,优化用户体验 2026-06-02 21:06:24 +08:00
jxxghp
200500a060 fix: use dialog theme customizer in app mode 2026-06-02 19:14:56 +08:00
jxxghp
a4731aade1 feat: enable app mode theme customizer 2026-06-02 19:00:48 +08:00
jxxghp
7d21eabf1a fix: prevent theme customizer startup crash 2026-06-02 18:35:05 +08:00
jxxghp
b639737bd6 feat: refine theme customizer and horizontal navigation 2026-06-02 17:56:41 +08:00
jxxghp
d02ece234c fix: 修改主题定制器标题,提升语言一致性 2026-06-02 17:07:16 +08:00
jxxghp
889a5c9e51 fix: 优化主题定制器按钮样式和布局,提升用户体验 2026-06-02 17:02:23 +08:00
jxxghp
880a34f508 fix: 调整主题定制器面板的透明度设置显示逻辑 2026-06-02 16:18:00 +08:00
jxxghp
50b0148ed6 feat: add theme customizer component and functionality
- Introduced a new ThemeCustomizer component for real-time theme customization.
- Updated UserProfile.vue to integrate the ThemeCustomizer and manage theme settings.
- Enhanced theme management with new settings for layout, primary color, and skin.
- Added support for semi-dark menu and responsive design adjustments.
- Implemented local storage persistence for theme settings.
- Updated localization files to include new theme customizer strings in English, Simplified Chinese, and Traditional Chinese.
- Modified styles to support new bordered skin and theme customizer layout.
- Refactored existing components (HeaderTab, SearchBar) to accommodate new theme features.
2026-06-02 16:16:20 +08:00
jxxghp
285ddab45a fix: 调整插件项目主页链接逻辑,优化下拉菜单项顺序 2026-06-02 13:10:14 +08:00
jxxghp
aa12f4b6b6 fix: 优化插件项目主页链接解析逻辑 2026-06-02 12:54:54 +08:00
jxxghp
9bbb060073 fix: 修复插件项目主页跳转被拦截 2026-06-02 08:18:12 +08:00
jxxghp
3b0623628c fix: 优化插件卡片项目主页与版本历史入口 2026-06-02 07:53:10 +08:00
jxxghp
b45c147452 fix: 调整插件更新说明菜单行为 2026-06-02 07:31:17 +08:00
jxxghp
25bc7c4b3c feat: 添加插件更新历史功能及相关国际化支持 2026-06-02 07:16:05 +08:00
jxxghp
d6b7b6d813 fix: close plugin filter menus after selection 2026-06-01 21:32:24 +08:00
jxxghp
a3ac46c891 更新 package.json 2026-06-01 12:00:14 +08:00
Album
b6e824246b 优化文件管理多选操作体验 (#483) 2026-05-31 21:31:59 +08:00
Album
5191f6780d 恢复识别词应用详情标题文案 (#482) 2026-05-31 18:13:53 +08:00
Album
261aaf17ad 优化识别词应用详情显示 (#481) 2026-05-31 17:51:05 +08:00
jxxghp
258e64bca7 fix: 更新站点 Cookie 处理逻辑,添加请求失败提示,优化服务工作者缓存策略 2026-05-31 09:16:52 +08:00
jxxghp
e905df014e fix: add precomposed apple touch icon 2026-05-31 08:39:21 +08:00
jxxghp
b93f8f2bff fix: 消息中心首次打开时SSE与数据库消息重复显示
SSE消息只有date字段、note为null,数据库消息只有reg_time、note为{},
原getMessageKey将reg_time和date作为两个独立字段拼接签名导致同一条消息签名不同。
归一化时间字段(reg_time||date)和note字段后去重恢复正常。
2026-05-30 19:18:55 +08:00
jxxghp
9aa0a5e1b7 更新 package.json 2026-05-30 08:58:34 +08:00
jxxghp
ee9f41d015 更新 package.json 2026-05-30 08:58:22 +08:00
jxxghp
ad6a664cbe fix: proxy bangumi images 2026-05-30 08:54:40 +08:00
Album
3387067636 fix: handle episode group values in preview transfer (#480) 2026-05-30 08:28:00 +08:00
jxxghp
07dc3c3e9a fix: build Emby app deep links with server ids 2026-05-28 15:06:30 +08:00
jxxghp
262b4bebd4 更新 package.json 2026-05-28 14:37:07 +08:00
jxxghp
6e50cf31de fix: correct media server card links 2026-05-28 14:33:50 +08:00
jxxghp
14aa75dfae fix: format version install statistics 2026-05-27 17:48:58 +08:00
jxxghp
348aa4757b fix: normalize search site selection 2026-05-27 15:21:44 +08:00
jxxghp
6e6819acc1 fix: auto match manual transfer target path 2026-05-27 13:26:01 +08:00
jxxghp
51a58aaae0 fix: show manual transfer recognition details 2026-05-27 11:03:55 +08:00
jxxghp
fbde99389e 更新 package.json 2026-05-27 07:11:01 +08:00
jxxghp
5a4e345529 feat: add LLM proxy toggle 2026-05-27 06:57:09 +08:00
jxxghp
b446afb6d8 fix: improve plugin market editor layout 2026-05-26 17:39:14 +08:00
jxxghp
8580af36d1 fix: compact plugin market settings dialog 2026-05-26 17:16:19 +08:00
jxxghp
95ca092117 feat: optimize plugin market repository settings 2026-05-26 16:30:31 +08:00
jxxghp
ba200cae5c fix: move LLM user agent after max context 2026-05-26 08:30:33 +08:00
jxxghp
87c73e0253 feat: add llm user agent setting 2026-05-26 08:20:02 +08:00
jxxghp
d4d7f635f5 fix: allow rust acceleration re-enable 2026-05-25 23:48:09 +08:00
jxxghp
729db1510e 更新 package.json 2026-05-25 23:11:22 +08:00
jxxghp
8a12ecf918 fix: render OTP QR code reliably 2026-05-25 23:07:45 +08:00
jxxghp
cacc2602df fix: initialize OTP dialog on open 2026-05-25 19:49:30 +08:00
jxxghp
8c6cfa7fc5 feat: add MiniMax audio provider option 2026-05-25 19:10:21 +08:00
jxxghp
0113f28d8c 更新 package.json 2026-05-25 18:20:49 +08:00
jxxghp
d870b788bc feat: add usage version statistics dialog 2026-05-25 18:16:35 +08:00
70 changed files with 5568 additions and 716 deletions

View File

@@ -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>
@@ -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"],
@@ -282,27 +285,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 +417,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')

View File

@@ -1,6 +1,6 @@
{
"name": "moviepilot",
"version": "2.13.0",
"version": "2.13.5",
"private": true,
"type": "module",
"bin": "dist/service.js",

Binary file not shown.

After

Width:  |  Height:  |  Size: 102 KiB

View File

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

View File

@@ -2,6 +2,12 @@
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 +17,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 +36,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 +47,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 +68,7 @@ export default defineComponent({
onBeforeUnmount(() => {
window.removeEventListener('scroll', handleScroll)
window.removeEventListener(THEME_CUSTOMIZER_CHANGE_EVENT, handleThemeCustomizerChange)
dialogObserver?.disconnect()
dialogObserver = null
})
@@ -100,6 +117,7 @@ export default defineComponent({
// 👉 根据路由 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 +145,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 +160,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';
@@ -223,6 +246,209 @@ 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 4px 8px -4px rgb(94 86 105 / 42%);
}
.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 +459,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%;

View File

@@ -11,10 +11,9 @@ 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;
}
@@ -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;

View File

@@ -121,11 +121,20 @@ export interface NavLink extends NavLinkProps, Partial<AclProperties> {
disable?: boolean
}
export interface NavMenuTabItem {
title: string
icon?: string
tab: string
description?: string
}
export interface NavMenu extends NavLink {
header: string
description?: string
admin?: boolean
footer?: boolean
// 水平三级菜单和页面动态标签页共用的静态标签定义。
tabs?: NavMenuTabItem[]
}
// 👉 Vertical nav group

View File

@@ -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,30 @@ 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 { 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,
})
}
// 生效语言
@@ -81,6 +85,7 @@ applyTransparentBackgroundSettings()
// 心跳检测
let heartbeatInterval: number | null = null
let prefersColorSchemeMediaQuery: MediaQueryList | null = null
// 启动心跳
const startHeartbeat = () => {
@@ -116,6 +121,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 {
@@ -285,6 +329,8 @@ onMounted(async () => {
// 初始化主题管理器 - 统一处理主题初始化
await themeManager.setTheme(themeValue)
applyStoredThemeCustomizerAppearance(vuetifyTheme)
updateHtmlThemeAttribute(globalTheme.name.value)
// 监听主题变化
watch(
@@ -297,6 +343,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 +396,11 @@ onUnmounted(() => {
}
// 停止心跳
stopHeartbeat()
prefersColorSchemeMediaQuery?.removeEventListener('change', handleSystemThemeChange)
prefersColorSchemeMediaQuery = null
document.removeEventListener('visibilitychange', handleVisibilityThemeSync)
window.removeEventListener('pageshow', handlePageShowThemeSync)
window.removeEventListener('focus', handlePageShowThemeSync)
})
</script>

View File

@@ -1025,6 +1025,10 @@ export interface FileItem {
export interface MediaServerPlayItem {
// ID
id?: string | number
// 媒体服务器项目ID
item_id?: string | number
// 媒体服务器ID
server_id?: string
// 标题
title: string
// 副标题
@@ -1049,6 +1053,10 @@ export interface MediaServerLibrary {
server: string
// ID
id?: string | number
// 媒体服务器项目ID
item_id?: string | number
// 媒体服务器ID
server_id?: string
// 名称
name: string
// 路径
@@ -1292,7 +1300,7 @@ export interface TransferForm {
// 目标存储
target_storage: string
// 目标路径
target_path: string
target_path: string | null
// TMDB ID
tmdbid?: number
// 豆瓣 ID
@@ -1335,6 +1343,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
// 媒体库类型子目录
library_type_folder?: boolean
// 媒体库类别子目录
library_category_folder?: boolean
}
// 手动整理预览统计
export interface ManualTransferPreviewSummary {
// 总数
@@ -1369,6 +1393,14 @@ export interface ManualTransferPreviewItem {
episode_end?: number | string
// Part
part?: string
// 原始识别字符串
org_string?: string
// 应用的自定义识别词
apply_words?: string[]
// 制作组/字幕组
resource_team?: string
// 自定义占位符
customization?: string
}
// 手动整理预览数据
@@ -1457,6 +1489,10 @@ export interface Workflow {
actions?: any[]
// 动作流
flows?: any[]
// 工作流执行配置
execution_config?: { [key: string]: any }
// 工作流结构化执行状态
execution_state?: { [key: string]: any }
// 创建时间
add_time?: string
// 最后执行时间

View File

@@ -0,0 +1,928 @@
<script setup lang="ts">
import type { CSSProperties } from 'vue'
import {
themeCustomizerPrimaryColors,
useThemeCustomizer,
type ThemeCustomizerLayout,
type ThemeCustomizerShadow,
type ThemeCustomizerSkin,
type ThemeCustomizerTheme,
} from '@/composables/useThemeCustomizer'
import { usePWA } from '@/composables/usePWA'
import { useI18n } from 'vue-i18n'
import { useTheme } from 'vuetify'
import { VDialog, VNavigationDrawer } from 'vuetify/components'
import { useDisplay } from 'vuetify'
const props = withDefaults(
defineProps<{
modelValue?: boolean
}>(),
{
modelValue: false,
},
)
const emit = defineEmits<{
'update:modelValue': [value: boolean]
}>()
const customColorInput = ref<HTMLInputElement | null>(null)
const {
isCustomized,
resetSettings,
setLayout,
setPrimaryColor,
setSemiDarkMenu,
setShadow,
setSkin,
setTheme,
settings,
} = useThemeCustomizer()
const { appMode } = usePWA()
const { t } = useI18n()
const { global: globalTheme } = useTheme()
const display = useDisplay()
const defaultPrimaryColor = themeCustomizerPrimaryColors[0].value
const customizerViewportHeight = ref('100dvh')
const drawer = computed({
get: () => props.modelValue,
set: value => emit('update:modelValue', value),
})
function getVisibleViewportHeight() {
if (typeof window === 'undefined') return '100dvh'
const height = window.visualViewport?.height || window.innerHeight || document.documentElement.clientHeight
return height > 0 ? `${Math.round(height)}px` : '100dvh'
}
// iOS 小屏的可见视口会随地址栏和独立模式 safe area 变化,面板高度需要跟随真实可见高度。
function syncCustomizerViewportHeight() {
customizerViewportHeight.value = getVisibleViewportHeight()
}
// 将主题定制器打开状态同步到根节点,供全局悬浮按钮避让右侧面板。
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)
}
watch(drawer, syncThemeCustomizerOpenState, { immediate: true })
watch(drawer, isOpen => {
if (isOpen) nextTick(syncCustomizerViewportHeight)
})
onMounted(() => {
syncCustomizerViewportHeight()
window.addEventListener('resize', syncCustomizerViewportHeight)
window.addEventListener('orientationchange', syncCustomizerViewportHeight)
window.visualViewport?.addEventListener('resize', syncCustomizerViewportHeight)
window.visualViewport?.addEventListener('scroll', syncCustomizerViewportHeight)
})
onScopeDispose(clearThemeCustomizerOpenState)
onScopeDispose(() => {
if (typeof window === 'undefined') return
window.removeEventListener('resize', syncCustomizerViewportHeight)
window.removeEventListener('orientationchange', syncCustomizerViewportHeight)
window.visualViewport?.removeEventListener('resize', syncCustomizerViewportHeight)
window.visualViewport?.removeEventListener('scroll', syncCustomizerViewportHeight)
})
const customizerContainer = computed(() => (appMode.value ? VDialog : VNavigationDrawer))
const customizerContainerStyle = computed<CSSProperties>(() => {
if (!appMode.value) return {}
return {
'--theme-customizer-viewport-height': customizerViewportHeight.value,
}
})
const customizerContainerProps = computed(() => {
if (appMode.value && display.mdAndDown.value) {
return {
class: 'theme-customizer-dialog-overlay',
scrim: true,
fullscreen: !display.mdAndUp.value,
scrollable: true,
}
}
return {
class: 'theme-customizer-drawer',
location: 'right' as const,
scrim: false,
temporary: true,
width: 420,
persistent: false,
}
})
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 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.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 setShadow('none')
await setSkin('default')
await setTheme('auto')
}
</script>
<template>
<Teleport to="body">
<Transition name="theme-customizer-glass">
<div
v-if="drawer"
class="theme-customizer-glass-backdrop"
:class="{ 'theme-customizer-glass-backdrop--dialog': appMode }"
/>
</Transition>
<component
:is="customizerContainer"
v-model="drawer"
v-bind="customizerContainerProps"
:style="customizerContainerStyle"
>
<div class="theme-customizer-panel" :class="{ 'theme-customizer-panel--dialog': 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="drawer = false">
<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.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>
</component>
</Teleport>
</template>
<style lang="scss">
/* stylelint-disable no-descending-specificity */
.theme-customizer-drawer {
position: fixed !important;
z-index: 12000 !important;
overflow: hidden;
block-size: 100dvh !important;
border-inline-start: 1px solid rgba(var(--v-theme-on-surface), 0.08) !important;
box-shadow: -2px 0 6px rgba(0, 0, 0, 10%) !important;
inset-block: 0 !important;
inset-inline-end: 0 !important;
max-block-size: 100dvh !important;
.v-navigation-drawer__content {
position: relative;
z-index: 1;
display: flex;
overflow: hidden;
flex-direction: column;
block-size: 100%;
}
}
.theme-customizer-dialog-overlay {
--theme-customizer-viewport-height: 100dvh;
z-index: 12000 !important;
}
.theme-customizer-dialog-overlay > .v-overlay__content {
overflow: hidden;
block-size: var(--theme-customizer-viewport-height);
margin-block: 0 !important;
max-block-size: var(--theme-customizer-viewport-height);
}
.theme-customizer-panel {
position: relative;
display: flex;
flex-direction: column;
block-size: 100%;
min-block-size: 0;
}
.theme-customizer-panel--dialog {
overflow: hidden;
border: 1px solid rgba(var(--v-theme-on-surface), 0.08);
border-radius: 16px;
background: rgb(var(--v-theme-surface));
block-size: var(--theme-customizer-viewport-height, 100dvh);
max-block-size: var(--theme-customizer-viewport-height, 100dvh);
/* fullscreen dialog 会贴到 viewport-fit=cover 顶部iOS 需要在面板内部避开系统状态栏。 */
padding-block-start: env(safe-area-inset-top);
}
.theme-customizer-panel--dialog .theme-customizer-body {
block-size: auto;
padding-block-end: env(safe-area-inset-bottom);
}
.theme-customizer-drawer.v-theme--transparent,
.v-theme--transparent .theme-customizer-drawer,
html[data-theme='transparent'] .theme-customizer-drawer,
.v-theme--transparent .theme-customizer-panel--dialog,
html[data-theme='transparent'] .theme-customizer-panel--dialog {
background: transparent !important;
}
.theme-customizer-glass-backdrop {
position: fixed;
z-index: 11999;
block-size: 100dvh;
inline-size: 420px;
inset-block: 0;
inset-inline-end: 0;
opacity: 0;
pointer-events: none;
transform: translateX(0);
}
:is(html[data-theme='transparent'], .v-theme--transparent) .theme-customizer-glass-backdrop {
backdrop-filter: blur(var(--transparent-blur-heavy, 16px));
background-color: rgba(var(--v-theme-surface), var(--transparent-opacity-heavy, 0.5));
opacity: 1;
}
.theme-customizer-glass-enter-active,
.theme-customizer-glass-leave-active {
transition:
opacity 0.2s cubic-bezier(0.4, 0, 0.2, 1),
transform 0.2s cubic-bezier(0.4, 0, 0.2, 1);
}
.theme-customizer-glass-enter-from,
.theme-customizer-glass-leave-to {
opacity: 0 !important;
transform: translateX(100%);
}
:is(html[data-theme='transparent'], .v-theme--transparent) .theme-customizer-drawer .v-navigation-drawer__content {
background: transparent !important;
}
@media (width <= 600px) {
.theme-customizer-glass-backdrop {
inline-size: 100vw;
}
}
// 透明主题的全局 overlay 毛玻璃会影响临时抽屉绘制,主题定制器改由 drawer 自身承担背景。
html[data-theme='transparent'] .v-overlay__content:has(.theme-customizer-drawer),
.v-theme--transparent .v-overlay__content:has(.theme-customizer-drawer) {
border-radius: 0 !important;
backdrop-filter: none !important;
background: transparent !important;
box-shadow: none !important;
}
:is(html[data-theme='transparent'], .v-theme--transparent) .theme-customizer-card-option .theme-customizer-theme-icon,
:is(html[data-theme='transparent'], .v-theme--transparent)
.theme-customizer-color-option
.theme-customizer-native-icon {
backdrop-filter: none !important;
background: transparent !important;
box-shadow: none !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;
}
.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-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-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-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-drawer {
inline-size: min(100vw, 420px) !important;
}
.theme-customizer-header,
.theme-customizer-section {
padding-inline: 22px;
}
.theme-customizer-preview-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
</style>

View File

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

View File

@@ -71,52 +71,114 @@ 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>
</template>
<template #default>
<div class="absolute inset-0 outline-none downloading-card-background"></div>
</template>
</VImg>
</template>
<div v-if="cardState" class="downloading-card-shadow-shell">
<VCard
:key="props.info?.hash"
class="downloading-card flex flex-col h-full overflow-hidden"
rounded="lg"
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>
<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>
<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>
<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 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>
<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>
<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>
</div>
</template>
<style lang="scss" scoped>
// 外层壳承载主题阴影,避免 VCard 的圆角裁切层影响阴影外扩。
.downloading-card-shadow-shell {
position: relative;
border-radius: 8px;
block-size: 100%;
box-shadow: var(--app-surface-shadow);
inline-size: 100%;
transition: box-shadow 0.2s ease;
}
@media (hover: hover) {
.downloading-card-shadow-shell:hover {
box-shadow: var(--app-surface-hover-shadow);
}
}
.downloading-card {
position: relative;
isolation: isolate;
background-color: rgb(31, 41, 55) !important;
}
// 阴影已经交给外层壳,内层卡片只负责裁切图片和内容圆角。
.downloading-card.downloading-card.v-card[class],
.downloading-card.downloading-card.v-card[class]:hover {
box-shadow: none !important;
}
// 图片槽和渐变遮罩统一继承卡片圆角,避免阴影增强后四角露出页面底色。
.downloading-card :deep(.v-card__image),
.downloading-card :deep(.v-responsive),
.downloading-card :deep(.v-img),
.downloading-card :deep(.v-img__img),
.downloading-card :deep(.v-responsive__content) {
overflow: hidden;
border-radius: inherit;
}
.downloading-card :deep(.v-card__image) {
background-color: rgb(31, 41, 55);
}
.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>

View File

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

View File

@@ -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'
@@ -464,13 +464,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)
})
// 移除订阅

View File

@@ -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)
}
// 人物姓名

View File

@@ -8,7 +8,9 @@ import { useI18n } from 'vue-i18n'
import { openSharedDialog } from '@/composables/useSharedDialog'
const PluginMarketDetailDialog = defineAsyncComponent(() => import('@/components/dialog/PluginMarketDetailDialog.vue'))
const PluginVersionHistoryDialog = defineAsyncComponent(() => import('@/components/dialog/PluginVersionHistoryDialog.vue'))
const PluginVersionHistoryDialog = defineAsyncComponent(
() => import('@/components/dialog/PluginVersionHistoryDialog.vue'),
)
// 输入参数
const props = defineProps({
@@ -119,6 +121,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 +139,7 @@ const dropdownItems = ref([
click: visitPluginPage,
},
},
{
title: t('plugin.updateHistory'),
value: 2,
show: !isNullOrEmptyObject(props.plugin?.history || {}),
props: {
prependIcon: 'mdi-update',
click: showUpdateHistory,
},
},
])
</script>
<template>

View File

@@ -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'
@@ -103,18 +102,13 @@ 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) {
openSharedDialog(
PluginVersionHistoryDialog,
{ plugin: props.plugin, showUpdateAction },
{ update: updatePlugin },
{ closeOn: ['close', 'update', 'update:modelValue'] },
)
}
// 调用API卸载插件
@@ -264,9 +258,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 +464,7 @@ const dropdownItems = ref([
props: {
prependIcon: 'mdi-arrow-up-circle-outline',
color: 'success',
click: showUpdateHistory,
click: () => showUpdateHistory(true),
},
},
{
@@ -400,6 +487,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 +508,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 +524,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
},
)

View File

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

View File

@@ -9,6 +9,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 +364,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,7 +404,7 @@ function handleCardClick() {
<VHover>
<template #default="hover">
<div
class="w-full h-full rounded-lg overflow-hidden relative"
class="w-full h-full rounded-lg 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,
@@ -419,12 +414,12 @@ function handleCardClick() {
<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',
'cursor-move': props.sortable,
}"
rounded="0"
rounded="lg"
min-height="150"
@click="handleCardClick"
:ripple="!props.batchMode && !props.sortable"
@@ -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>
@@ -582,14 +593,15 @@ function handleCardClick() {
.subscribe-card-pending-tint {
position: relative;
}
.subscribe-card-pending-tint::after {
content: '';
position: absolute;
z-index: 3;
border-radius: 8px;
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>

View File

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

View File

@@ -95,52 +95,60 @@ 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,
}"
rounded="lg"
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>

View File

@@ -84,6 +84,33 @@ const releaseDialogTitle = ref('')
// 变更日志对话框内容
const releaseDialogBody = ref('')
// 版本统计对话框
const versionStatisticDialog = ref(false)
// 版本统计加载状态
const versionStatisticLoading = ref(false)
// 版本统计数据
const versionStatistic = ref<any>({})
// 后端版本统计
const backendVersionStatistics = computed(() => versionStatistic.value?.backend_versions ?? [])
// 前端版本统计
const frontendVersionStatistics = computed(() => versionStatistic.value?.frontend_versions ?? [])
// 活跃用户统计
const activeUsers = computed(() => versionStatistic.value?.active_users ?? {})
/** 格式化版本安装统计数字为千分位展示。 */
function formatVersionStatisticNumber(value: unknown) {
const numberValue = Number(value ?? 0)
if (!Number.isFinite(numberValue)) return '0'
return numberValue.toLocaleString()
}
// 打开日志对话框
function showReleaseDialog(title: string, body: string) {
releaseDialogTitle.value = title
@@ -91,6 +118,28 @@ function showReleaseDialog(title: string, body: string) {
releaseDialog.value = true
}
// 查询版本统计
async function queryVersionStatistic() {
if (!systemEnv.value.USAGE_STATISTIC_SHARE) return
versionStatisticLoading.value = true
try {
const result: { [key: string]: any } = await api.get('system/usage/statistic')
versionStatistic.value = result.data ?? {}
} catch (error) {
console.log(error)
versionStatistic.value = {}
} finally {
versionStatisticLoading.value = false
}
}
// 打开版本统计对话框
async function showVersionStatisticDialog() {
versionStatisticDialog.value = true
await queryVersionStatistic()
}
// 查询系统环境变量
async function querySystemEnv() {
try {
@@ -182,6 +231,18 @@ onMounted(() => {
{{ t('setting.about.latest') }}
</span>
</a>
<VTooltip v-if="systemEnv.USAGE_STATISTIC_SHARE" :text="t('setting.about.versionStatistic')">
<template #activator="{ props }">
<VBtn
v-bind="props"
icon="mdi-chart-bar"
size="x-small"
variant="text"
class="ms-2 flex-shrink-0"
@click="showVersionStatisticDialog"
/>
</template>
</VTooltip>
</span>
</dd>
</div>
@@ -406,6 +467,86 @@ onMounted(() => {
<VCardText class="markdown-body" v-html="releaseDialogBody" />
</VCard>
</VDialog>
<VDialog v-if="versionStatisticDialog" v-model="versionStatisticDialog" width="680" scrollable max-height="85vh">
<VCard>
<VCardItem>
<VDialogCloseBtn @click="versionStatisticDialog = false" />
<VCardTitle>
<VIcon icon="mdi-chart-bar" class="me-2" />
{{ t('setting.about.versionStatisticTitle') }}
</VCardTitle>
</VCardItem>
<VDivider />
<VProgressLinear v-if="versionStatisticLoading" indeterminate color="primary" />
<VCardText>
<div class="version-stat-summary">
<div>
<div class="text-caption text-medium-emphasis">{{ t('setting.about.totalInstallUsers') }}</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">{{ formatVersionStatisticNumber(activeUsers.today) }}</div>
</div>
<div>
<div class="text-caption text-medium-emphasis">{{ t('setting.about.active7Days') }}</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">{{ formatVersionStatisticNumber(activeUsers.last_30_days) }}</div>
</div>
</div>
<div class="mt-5">
<div class="text-subtitle-2 mb-2">{{ t('setting.about.backendVersionStatistic') }}</div>
<VTable density="compact">
<thead>
<tr>
<th>{{ t('setting.about.version') }}</th>
<th class="text-end">{{ t('setting.about.users') }}</th>
</tr>
</thead>
<tbody>
<tr v-for="item in backendVersionStatistics" :key="`backend-${item.version}`">
<td>
<code>{{ item.version }}</code>
</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>
</tr>
</tbody>
</VTable>
</div>
<div class="mt-5">
<div class="text-subtitle-2 mb-2">{{ t('setting.about.frontendVersionStatistic') }}</div>
<VTable density="compact">
<thead>
<tr>
<th>{{ t('setting.about.version') }}</th>
<th class="text-end">{{ t('setting.about.users') }}</th>
</tr>
</thead>
<tbody>
<tr v-for="item in frontendVersionStatistics" :key="`frontend-${item.version}`">
<td>
<code>{{ item.version }}</code>
</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>
</tr>
</tbody>
</VTable>
</div>
<div v-if="versionStatistic.updated_at" class="mt-4 text-caption text-medium-emphasis">
{{ t('setting.about.lastUpdated') }}: {{ versionStatistic.updated_at }}
</div>
</VCardText>
</VCard>
</VDialog>
</VDialog>
</template>
@@ -422,6 +563,18 @@ onMounted(() => {
margin-block: 0.5rem 2.5rem;
}
.version-stat-summary {
display: grid;
gap: 1rem;
grid-template-columns: repeat(auto-fit, minmax(7rem, 1fr));
}
.version-stat-number {
font-size: 1.5rem;
font-weight: 700;
line-height: 2rem;
}
.markdown-body :deep(h1),
.markdown-body :deep(h2),
.markdown-body :deep(h3) {

View File

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

View File

@@ -41,42 +41,69 @@ const otpPassword = ref('')
const allowPasskeyWithoutOtp = computed(() => globalSettingsStore.get('PASSKEY_ALLOW_REGISTER_WITHOUT_OTP') === true)
// OTP 初始化加载状态
const otpLoading = ref(false)
// OTP 初始化失败信息
const otpGenerateError = ref('')
// 二维码图片 base64
const qrCodeImage = ref('')
// 二维码信息
const qrCode = ref('')
// 为当前用户获取Otp Uri
// 清空当前 OTP 设置流程的临时数据。
function resetOtpSetupState() {
qrCodeImage.value = ''
qrCode.value = ''
otpUri.value = ''
secret.value = ''
otpGenerateError.value = ''
}
// 标记 OTP 初始化失败,并向用户显示明确错误。
function setOtpGenerateError(message?: string) {
const errorMessage = message || t('common.error')
otpGenerateError.value = t('profile.otpGenerateFailed', { message: errorMessage })
$toast.error(otpGenerateError.value)
}
// 为当前用户获取 OTP URI 并生成二维码图片。
async function getOtpUri() {
resetOtpSetupState()
// 如果已经启用OTP只打开对话框不生成新的二维码
if (props.isOtp) {
qrCode.value = '' // 清空二维码,这样对话框会显示清除界面
qrCodeImage.value = ''
return
}
// 未启用OTP生成新的二维码
otpLoading.value = true
try {
const result = (await api.post('mfa/otp/generate')) as ApiResponse<{
uri: string
secret: string
}>
if (result.success) {
otpUri.value = result.data.uri
secret.value = result.data.secret
qrCode.value = result.data.uri
const uri = result.data?.uri?.trim()
const otpSecret = result.data?.secret?.trim()
if (result.success && uri) {
otpUri.value = uri
secret.value = otpSecret || ''
qrCode.value = uri
// 生成二维码图片
qrCodeImage.value = await QRCode.toDataURL(result.data.uri, {
qrCodeImage.value = await QRCode.toDataURL(uri, {
width: 200,
margin: 1,
})
} else {
$toast.error(t('profile.otpGenerateFailed', { message: result.message }))
setOtpGenerateError(result.message || 'empty otp uri')
}
} catch (error) {
console.error(error)
$toast.error(t('profile.otpGenerateFailed', { message: error instanceof Error ? error.message : String(error) }))
setOtpGenerateError(error instanceof Error ? error.message : String(error))
} finally {
otpLoading.value = false
}
}
@@ -145,13 +172,12 @@ watch(
otpPassword.value = ''
} else {
// 弹窗关闭时,清空数据
qrCodeImage.value = ''
qrCode.value = ''
otpUri.value = ''
secret.value = ''
resetOtpSetupState()
otpLoading.value = false
otpPassword.value = ''
}
},
{ immediate: true },
)
</script>
@@ -193,16 +219,29 @@ watch(
<!-- 设置新的OTP -->
<template v-else>
<div class="my-6 rounded text-center p-3 border" style="width: fit-content; margin: 0 auto">
<VImg class="mx-auto" :src="qrCodeImage" width="200" height="200">
<template #placeholder>
<div class="w-full h-full">
<VSkeletonLoader class="object-cover aspect-w-1 aspect-h-1" />
</div>
</template>
</VImg>
<div
class="my-6 rounded text-center p-3 border d-flex align-center justify-center"
style="width: 226px; height: 226px; margin: 0 auto"
>
<img
v-if="qrCodeImage"
class="mx-auto d-block otp-qrcode-image"
:src="qrCodeImage"
:alt="t('profile.setupAuthenticator')"
width="200"
height="200"
/>
<VProgressCircular v-else-if="otpLoading" indeterminate color="primary" />
<div v-else class="w-100">
<VAlert type="error" variant="tonal" density="compact" class="mb-3">
{{ otpGenerateError || t('profile.otpGenerateFailed', { message: t('common.error') }) }}
</VAlert>
<VBtn size="small" variant="tonal" prepend-icon="mdi-refresh" @click="getOtpUri">
{{ t('common.retry') }}
</VBtn>
</div>
</div>
<VAlert :title="secret" variant="tonal" type="warning" class="my-4" :text="t('profile.secretKeyTip')">
<VAlert v-if="secret" :title="secret" variant="tonal" type="warning" class="my-4" :text="t('profile.secretKeyTip')">
<template #prepend />
</VAlert>
<VForm @submit.prevent="judgeOtpPassword">
@@ -220,7 +259,7 @@ watch(
<VBtn variant="outlined" color="secondary" @click="show = false">
{{ t('common.cancel') }}
</VBtn>
<VBtn type="submit">
<VBtn type="submit" :disabled="!otpUri || otpLoading">
<template #prepend>
<VIcon icon="mdi-check" />
</template>
@@ -233,3 +272,10 @@ watch(
</VCard>
</VDialog>
</template>
<style scoped>
.otp-qrcode-image {
inline-size: 200px;
block-size: 200px;
}
</style>

View File

@@ -10,27 +10,121 @@ const display = useDisplay()
const { t } = useI18n()
const $toast = useToast()
type EditorMode = 'list' | 'text'
interface RepoParseResult {
repos: string[]
invalidRepos: string[]
duplicateRepos: string[]
}
const editorMode = ref<EditorMode>('list')
const repoList = ref<string[]>([])
const repoText = ref('')
const newRepoUrl = ref('')
const editingIndex = ref<number | null>(null)
const editingUrl = ref('')
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 saveDisabled = computed(
() => activeRepoCount.value === 0 || (editorMode.value === 'text' && parsedTextRepos.value.invalidRepos.length > 0),
)
/** 判断仓库地址是否为可保存的 HTTP URL。 */
function isValidRepoUrl(url: string) {
return /^https?:\/\//i.test(url)
}
/** 将粘贴的仓库地址文本解析为有效、无效和重复地址列表。 */
function parseRepoInput(value: string): RepoParseResult {
const repos: string[] = []
const invalidRepos: string[] = []
const duplicateRepos: string[] = []
const seenRepos = new Set<string>()
value
.split(/[\n,]+/)
.map(repo => repo.trim())
.filter(Boolean)
.forEach(repo => {
if (!isValidRepoUrl(repo)) {
invalidRepos.push(repo)
return
}
if (seenRepos.has(repo)) {
duplicateRepos.push(repo)
return
}
seenRepos.add(repo)
repos.push(repo)
})
return {
repos,
invalidRepos,
duplicateRepos: [...new Set(duplicateRepos)],
}
}
/** 将列表模式中的仓库地址同步到文本模式。 */
function syncTextFromList() {
repoText.value = repoList.value.join('\n')
}
/** 将文本模式中的仓库地址同步到列表模式,并忽略无法加入列表的无效地址。 */
function syncListFromText() {
const result = parseRepoInput(repoText.value)
repoList.value = result.repos
syncTextFromList()
if (result.invalidRepos.length > 0) {
$toast.warning(t('dialog.pluginMarketSetting.invalidTextIgnored', { count: result.invalidRepos.length }))
}
}
/** 切换仓库维护模式,并在切换时同步当前模式的编辑内容。 */
function switchEditorMode(mode: EditorMode | undefined) {
if (!mode || mode === editorMode.value) return
if (editorMode.value === 'text') {
syncListFromText()
}
if (mode === 'text') {
syncTextFromList()
}
editorMode.value = mode
}
/** 加载插件市场仓库配置。 */
async function queryMarketRepoSetting() {
try {
const result: { [key: string]: any } = await api.get('system/setting/PLUGIN_MARKET')
if (result && result.data && result.data.value) {
repoList.value = result.data.value.split(',').filter((repo: string) => repo.trim() !== '')
repoList.value = parseRepoInput(result.data.value).repos
syncTextFromList()
}
} catch (error) {
console.log(error)
}
}
/** 保存插件市场仓库配置。 */
async function saveHandle() {
try {
const repoStringToSave = repoList.value.join(',')
const reposToSave = normalizeCurrentRepos()
if (!reposToSave) return
const repoStringToSave = reposToSave.join(',')
const result: { [key: string]: any } = await api.post('system/setting/PLUGIN_MARKET', repoStringToSave)
if (result.success) {
@@ -42,54 +136,88 @@ async function saveHandle() {
}
}
/** 获取当前维护模式下可保存的仓库地址。 */
function normalizeCurrentRepos() {
if (editorMode.value === 'text') {
const result = parseRepoInput(repoText.value)
if (result.invalidRepos.length > 0) {
$toast.error(t('dialog.pluginMarketSetting.invalidText', { count: result.invalidRepos.length }))
return null
}
repoList.value = result.repos
syncTextFromList()
return result.repos
}
return repoList.value
}
/** 校验单个仓库地址是否可以加入或更新到列表。 */
function validateRepoUrl(url: string, editingRepoIndex: number | null = null) {
if (!url) return false
if (!isValidRepoUrl(url)) {
$toast.error(t('dialog.pluginMarketSetting.invalidUrl'))
return false
}
const duplicated = repoList.value.some((repo, index) => repo === url && index !== editingRepoIndex)
if (duplicated) {
$toast.error(t('dialog.pluginMarketSetting.duplicateUrl'))
return false
}
return true
}
/** 添加一个仓库地址到列表。 */
function addRepo() {
const url = newRepoUrl.value.trim()
if (!url) return
if (!url.startsWith('http://') && !url.startsWith('https://')) {
$toast.error(t('dialog.pluginMarketSetting.invalidUrl'))
return
}
if (repoList.value.includes(url)) {
$toast.error(t('dialog.pluginMarketSetting.duplicateUrl'))
return
}
if (!validateRepoUrl(url)) return
repoList.value.push(url)
newRepoUrl.value = ''
syncTextFromList()
}
/** 从列表中删除一个仓库地址。 */
function removeRepo(index: number) {
repoList.value.splice(index, 1)
syncTextFromList()
}
/** 进入指定仓库地址的行内编辑状态。 */
function startEdit(index: number) {
editingIndex.value = index
editingUrl.value = repoList.value[index]
}
function saveEdit() {
if (editingIndex.value === null) return
/** 保存当前行内编辑的仓库地址。 */
function saveEdit(index = editingIndex.value) {
if (index === null) return
const url = editingUrl.value.trim()
if (!url) return
if (!validateRepoUrl(url, index)) return
if (!url.startsWith('http://') && !url.startsWith('https://')) {
$toast.error(t('dialog.pluginMarketSetting.invalidUrl'))
return
}
repoList.value[editingIndex.value] = url
repoList.value[index] = url
syncTextFromList()
editingIndex.value = null
editingUrl.value = ''
}
/** 取消当前行内编辑状态。 */
function cancelEdit() {
editingIndex.value = null
editingUrl.value = ''
}
/** 将仓库地址格式化为更易扫描的显示名称。 */
function formatRepoDisplay(url: string) {
try {
const parsedUrl = new URL(url)
@@ -108,6 +236,7 @@ function formatRepoDisplay(url: string) {
return url
}
/** 返回拖拽列表项的稳定键。 */
function repoItemKey(repo: string) {
return repo
}
@@ -118,108 +247,192 @@ onMounted(() => {
</script>
<template>
<VDialog width="50rem" scrollable :fullscreen="!display.mdAndUp.value">
<VDialog width="56rem" :fullscreen="!display.mdAndUp.value">
<VCard class="plugin-market-dialog-card">
<VCardItem>
<VCardTitle>
<VIcon icon="mdi-store-cog" class="me-2" />
{{ t('dialog.pluginMarketSetting.title') }}
</VCardTitle>
<VCardItem class="plugin-market-card-item">
<div class="plugin-market-header">
<VCardTitle class="plugin-market-title d-flex align-center pa-0">
<VIcon icon="mdi-store-cog" class="me-2" />
{{ t('dialog.pluginMarketSetting.title') }}
</VCardTitle>
</div>
<VDialogCloseBtn @click="emit('close')" />
</VCardItem>
<VDivider />
<VCardText class="plugin-market-dialog-body pt-4">
<div class="plugin-market-input mb-4">
<VTextField
v-model="newRepoUrl"
density="compact"
:placeholder="t('dialog.pluginMarketSetting.urlPlaceholder')"
prepend-inner-icon="mdi-link-plus"
clearable
@keyup.enter="addRepo"
<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"
>
<template #append>
<VBtn icon="mdi-plus" variant="text" color="primary" @click="addRepo" />
</template>
</VTextField>
<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>
<div class="plugin-market-list-wrap">
<VList v-if="repoList.length > 0" class="px-0">
<draggable
v-model="repoList"
:item-key="repoItemKey"
handle=".drag-handle"
animation="200"
:disabled="editingIndex !== null"
<div v-if="editorMode === 'list'" class="plugin-market-list-panel">
<div class="plugin-market-input">
<VTextField
v-model="newRepoUrl"
density="compact"
:placeholder="t('dialog.pluginMarketSetting.urlPlaceholder')"
prepend-inner-icon="mdi-link-plus"
clearable
hide-details
@keyup.enter="addRepo"
>
<template #item="{ element: repo, index }">
<div>
<VListItem class="py-2">
<template #prepend>
<VBtn
icon="mdi-drag-vertical"
size="small"
variant="text"
color="primary"
class="drag-handle me-2"
:disabled="editingIndex !== null"
/>
</template>
<template #append>
<VBtn
icon="mdi-plus"
variant="tonal"
color="primary"
:aria-label="t('dialog.pluginMarketSetting.addRepo')"
@click="addRepo"
/>
</template>
</VTextField>
</div>
<VListItemTitle v-if="editingIndex !== index">
<span class="text-truncate" :title="repo">{{ formatRepoDisplay(repo) }}</span>
</VListItemTitle>
<VTextField
v-else
v-model="editingUrl"
density="compact"
variant="outlined"
hide-details
@keyup.enter="saveEdit"
@keyup.escape="cancelEdit"
/>
<template #append v-if="editingIndex !== index">
<div class="d-flex align-center">
<IconBtn icon="mdi-pencil" size="small" variant="text" @click="startEdit(index)" />
<IconBtn
icon="mdi-delete"
<div class="plugin-market-list-wrap">
<VList v-if="repoList.length > 0" class="plugin-market-repo-list px-0">
<draggable
v-model="repoList"
:item-key="repoItemKey"
handle=".drag-handle"
animation="200"
:disabled="editingIndex !== null"
@end="syncTextFromList"
>
<template #item="{ element: repo, index }">
<div>
<VListItem class="plugin-market-repo-item py-3">
<template #prepend>
<VBtn
icon="mdi-drag-vertical"
size="small"
variant="text"
color="error"
@click="removeRepo(index)"
color="primary"
class="drag-handle me-2"
:disabled="editingIndex !== null"
/>
</div>
</template>
</template>
<template #append v-else>
<div class="d-flex align-center">
<IconBtn icon="mdi-check" size="small" variant="text" color="success" @click="saveEdit" />
<IconBtn icon="mdi-close" size="small" variant="text" @click="cancelEdit" />
</div>
</template>
</VListItem>
<VDivider v-if="index < repoList.length - 1" class="mx-4" />
</div>
</template>
</draggable>
</VList>
<template v-if="editingIndex !== index">
<VListItemTitle>
<div class="plugin-market-repo-title">
<span class="plugin-market-repo-index">{{ index + 1 }}</span>
<span class="plugin-market-repo-name" :title="repo">{{ formatRepoDisplay(repo) }}</span>
</div>
</VListItemTitle>
<VListItemSubtitle class="plugin-market-repo-url mt-1" :title="repo">
{{ repo }}
</VListItemSubtitle>
</template>
<div v-else class="text-center text-medium-emphasis py-8">
<VIcon icon="mdi-folder-open-outline" size="48" class="mb-2" />
<div>{{ t('dialog.pluginMarketSetting.noRepos') }}</div>
<VTextField
v-else
v-model="editingUrl"
density="compact"
variant="outlined"
hide-details
autofocus
@keyup.enter="saveEdit(index)"
@keyup.escape="cancelEdit"
/>
<template #append v-if="editingIndex !== index">
<div class="d-flex align-center">
<IconBtn icon="mdi-pencil" size="small" variant="text" @click="startEdit(index)" />
<IconBtn
icon="mdi-delete"
size="small"
variant="text"
color="error"
@click="removeRepo(index)"
/>
</div>
</template>
<template #append v-else>
<div class="d-flex align-center">
<VBtn
icon="mdi-check"
size="small"
variant="text"
color="success"
@click.stop="saveEdit(index)"
/>
</div>
</template>
</VListItem>
<VDivider v-if="index < repoList.length - 1" class="mx-4" />
</div>
</template>
</draggable>
</VList>
<div v-else class="plugin-market-empty text-center text-medium-emphasis">
<VIcon icon="mdi-source-repository-multiple" size="48" class="mb-2" />
<div>{{ t('dialog.pluginMarketSetting.noRepos') }}</div>
</div>
</div>
</div>
<div v-else class="plugin-market-text-panel">
<div class="plugin-market-textarea-field">
<VIcon icon="mdi-text-box-edit-outline" class="plugin-market-textarea-icon" />
<textarea
v-model="repoText"
class="plugin-market-textarea"
:placeholder="t('dialog.pluginMarketSetting.textPlaceholder')"
/>
</div>
<div class="plugin-market-text-hint">
{{ t('dialog.pluginMarketSetting.textHint') }}
</div>
<VAlert
v-if="parsedTextRepos.invalidRepos.length > 0"
type="error"
variant="tonal"
density="compact"
class="plugin-market-invalid-alert"
>
<div>{{ t('dialog.pluginMarketSetting.invalidText', { count: parsedTextRepos.invalidRepos.length }) }}</div>
<div class="text-truncate">
{{ parsedTextRepos.invalidRepos.slice(0, 3).join(', ') }}
</div>
</VAlert>
<VAlert
v-else-if="parsedTextRepos.duplicateRepos.length > 0"
type="warning"
variant="tonal"
density="compact"
>
{{ t('dialog.pluginMarketSetting.duplicateTextIgnored') }}
</VAlert>
</div>
</VCardText>
<VCardActions>
<VCardActions class="plugin-market-actions">
<VSpacer />
<VBtn
color="primary"
variant="flat"
@click="saveHandle"
prepend-icon="mdi-content-save-check"
class="px-5 me-3"
:disabled="repoList.length === 0"
class="px-5"
:disabled="saveDisabled"
>
{{ t('dialog.pluginMarketSetting.save') }}
</VBtn>
@@ -232,6 +445,24 @@ onMounted(() => {
.plugin-market-dialog-card {
display: flex;
flex-direction: column;
block-size: min(82vh, 50rem);
}
.plugin-market-card-item {
flex: 0 0 auto;
padding-block: 0.875rem;
}
.plugin-market-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
padding-inline-end: 2rem;
}
.plugin-market-title {
min-inline-size: 0;
}
.plugin-market-dialog-body {
@@ -239,6 +470,31 @@ onMounted(() => {
overflow: hidden;
flex: 1;
flex-direction: column;
gap: 0.875rem;
min-block-size: 0;
padding-block: 0.875rem !important;
}
.plugin-market-toolbar {
display: flex;
flex-shrink: 0;
}
.plugin-market-mode-toggle {
inline-size: 100%;
:deep(.v-btn) {
flex: 1;
min-inline-size: 0;
}
}
.plugin-market-list-panel,
.plugin-market-text-panel {
display: flex;
flex: 1;
flex-direction: column;
gap: 0.5rem;
min-block-size: 0;
}
@@ -248,7 +504,173 @@ 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;
}
.plugin-market-repo-list {
background: transparent;
}
.plugin-market-repo-item {
min-block-size: 4.5rem;
}
.plugin-market-repo-title {
display: flex;
align-items: center;
gap: 0.5rem;
min-inline-size: 0;
}
.plugin-market-repo-name,
.plugin-market-repo-url {
display: -webkit-box;
overflow: hidden;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
line-break: anywhere;
overflow-wrap: anywhere;
white-space: normal;
word-break: break-word;
}
.plugin-market-repo-url {
line-height: 1.4;
}
.plugin-market-repo-index {
flex: 0 0 auto;
color: rgba(var(--v-theme-on-surface), 0.48);
font-size: 0.8125rem;
font-variant-numeric: tabular-nums;
inline-size: 1.75rem;
}
.plugin-market-empty {
display: flex;
align-items: center;
justify-content: center;
flex-direction: column;
min-block-size: 14rem;
}
.plugin-market-textarea-field {
position: relative;
display: flex;
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;
&:focus-within {
border-color: rgb(var(--v-theme-primary));
box-shadow: 0 0 0 1px rgb(var(--v-theme-primary));
}
}
.plugin-market-textarea-icon {
position: absolute;
z-index: 1;
color: rgba(var(--v-theme-on-surface), 0.62);
inset-block-start: 1.25rem;
inset-inline-start: 1rem;
pointer-events: none;
}
.plugin-market-textarea {
flex: 1;
border: 0;
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-size: 1rem;
line-height: 1.6;
min-block-size: 0;
outline: none;
overflow-y: auto;
padding: 1rem 1rem 1rem 3.25rem;
resize: none;
white-space: pre-wrap;
word-break: break-word;
}
.plugin-market-text-hint {
flex: 0 0 auto;
color: rgba(var(--v-theme-on-surface), 0.62);
font-size: 0.8125rem;
line-height: 1.4;
padding-inline: 1rem;
}
.plugin-market-invalid-alert {
:deep(.v-alert__content) {
min-inline-size: 0;
}
}
.plugin-market-actions {
flex: 0 0 auto;
gap: 0.5rem;
padding: 0.75rem 1.5rem 1rem;
}
@media (max-width: 600px) {
.plugin-market-dialog-card {
block-size: 100dvh;
}
.plugin-market-card-item {
padding: 0.75rem 1rem 0.625rem;
}
.plugin-market-header {
align-items: center;
gap: 0.5rem;
padding-inline-end: 2.25rem;
}
.plugin-market-header :deep(.v-card-title) {
font-size: 1.125rem;
line-height: 1.35;
}
.plugin-market-dialog-body {
gap: 0.625rem;
padding: 0.75rem 1rem !important;
}
.plugin-market-mode-toggle {
inline-size: 100%;
:deep(.v-btn) {
flex: 1;
min-inline-size: 0;
}
}
.plugin-market-list-panel,
.plugin-market-text-panel {
gap: 0.625rem;
}
.plugin-market-list-wrap {
min-block-size: 0;
}
.plugin-market-empty {
min-block-size: 10rem;
}
.plugin-market-actions {
padding: 0.75rem 1rem calc(0.75rem + env(safe-area-inset-bottom));
}
}
</style>

View File

@@ -1,4 +1,5 @@
<script setup lang="ts">
import api from '@/api'
import type { Plugin } from '@/api/types'
import VersionHistory from '@/components/misc/VersionHistory.vue'
import { useI18n } from 'vue-i18n'
@@ -25,6 +26,10 @@ const props = defineProps({
// 定义触发的自定义事件
const emit = defineEmits(['update:modelValue', 'close', 'update'])
const loading = ref(false)
const loadError = ref('')
const pluginDetail = ref<Plugin | null>(null)
// 弹窗显示状态
const visible = computed({
get: () => props.modelValue,
@@ -34,30 +39,78 @@ const visible = computed({
},
})
const resolvedPlugin = computed(() => pluginDetail.value ?? props.plugin)
const resolvedHistory = computed(() => resolvedPlugin.value?.history || {})
const hasHistory = computed(() => Object.keys(resolvedHistory.value).length > 0)
async function loadPluginHistory() {
if (!props.plugin?.id) {
pluginDetail.value = null
loadError.value = ''
return
}
loading.value = true
loadError.value = ''
try {
pluginDetail.value = await api.get(`plugin/history/${props.plugin.id}`, {
params: {
force: true,
},
})
} catch (error) {
pluginDetail.value = null
loadError.value = t('plugin.updateHistoryLoadFailed')
console.error(error)
} finally {
loading.value = false
}
}
/** 触发插件更新操作。 */
function handleUpdate() {
emit('update')
}
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 })">
<VCard :title="t('plugin.updateHistoryTitle', { name: resolvedPlugin?.plugin_name })">
<VDialogCloseBtn v-model="visible" />
<VDivider />
<VersionHistory :history="props.plugin?.history" />
<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">
<VAlert type="info" variant="tonal" density="compact" :text="t('plugin.updateHistoryEmpty')" />
</VCardText>
<VersionHistory v-else :history="resolvedHistory" />
<template v-if="props.showUpdateAction">
<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 +121,12 @@ function handleUpdate() {
</VCard>
</VDialog>
</template>
<style scoped>
.plugin-version-history-dialog__loading {
min-height: 12rem;
display: flex;
align-items: center;
justify-content: center;
}
</style>

View File

@@ -10,6 +10,7 @@ import {
ManualTransferPayload,
ManualTransferPreviewData,
ManualTransferPreviewItem,
ManualTransferTargetPathData,
StorageConf,
TransferDirectoryConf,
TransferForm,
@@ -102,17 +103,29 @@ 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 ManualTransferTargetPathRequest {
fileitem?: FileItem
fileitems?: FileItem[]
logid?: number
logids?: number[]
target_storage?: string | null
}
// 生成文件项稳定键,用于去重和状态同步。
function getFileItemKey(item?: FileItem) {
return [item?.storage ?? '', item?.type ?? '', item?.path ?? ''].join('|')
@@ -265,7 +278,7 @@ const transferForm = reactive<TransferForm>({
fileitem: {} as FileItem,
logid: 0,
target_storage: props.target_storage ?? 'local',
target_path: props.target_path ?? '',
target_path: normalizeTargetPath(props.target_path),
transfer_type: '',
min_filesize: 0,
scrape: false,
@@ -292,6 +305,79 @@ const targetDirectories = computed(() => {
return [...new Set(libraryDirectories)]
})
// 构造目的路径自动匹配请求,只传用户真实上下文,避免用默认存储误导后端匹配。
function createTargetPathMatchRequest(): ManualTransferTargetPathRequest | undefined {
const payload: ManualTransferTargetPathRequest = {}
if (props.target_storage) {
payload.target_storage = props.target_storage
}
if (normalizedItems.value.length === 1) {
payload.fileitem = normalizedItems.value[0]
return payload
}
if (normalizedItems.value.length > 1) {
payload.fileitems = normalizedItems.value
return payload
}
if (props.logids?.length) {
if (props.logids.length > 1) {
payload.logids = props.logids
return payload
}
payload.logid = props.logids[0]
return payload
}
}
// 应用后端匹配到的目的路径配置,未匹配时保持 null 等待用户手工选择。
function applyMatchedTargetPath(data?: ManualTransferTargetPathData) {
const matchedTargetPath = normalizeTargetPath(data?.target_path)
if (!matchedTargetPath) {
transferForm.target_path = null
return
}
transferForm.target_storage = data?.target_storage || transferForm.target_storage || 'local'
transferForm.transfer_type = data?.transfer_type || transferForm.transfer_type
transferForm.scrape = data?.scrape ?? false
transferForm.library_type_folder = data?.library_type_folder ?? false
transferForm.library_category_folder = data?.library_category_folder ?? false
transferForm.target_path = matchedTargetPath
}
// 请求后端按源目录匹配最合适的手动整理目的路径。
async function autoSelectTargetPath() {
if (normalizeTargetPath(props.target_path) || transferForm.target_path) return
const payload = createTargetPathMatchRequest()
if (!payload) {
transferForm.target_path = null
return
}
try {
const result = await api.post<ApiResponse<ManualTransferTargetPathData>, ApiResponse<ManualTransferTargetPathData>>(
'transfer/manual/target-path',
payload,
)
if (!result.success) {
transferForm.target_path = null
return
}
applyMatchedTargetPath(result.data)
} catch (error) {
console.log(error)
transferForm.target_path = null
}
}
// 监听目的路径变化,配置默认值
watch(
() => transferForm.target_path,
@@ -344,6 +430,16 @@ watch(
},
)
watch(
() => transferForm.episode_group,
episodeGroup => {
const normalizedEpisodeGroup = normalizeEpisodeGroup(episodeGroup)
if (episodeGroup !== normalizedEpisodeGroup) {
transferForm.episode_group = normalizedEpisodeGroup
}
},
)
// 过滤后的预览数据
const filteredPreviewItems = computed(() => {
return previewData.value?.items ?? []
@@ -397,6 +493,28 @@ 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
}
// 归一化剧集组值,兼容历史对象态值。
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 +629,103 @@ 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}`)]
@@ -570,6 +785,12 @@ const canRecommendEpisodeFormat = computed(() => {
)
})
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 (
@@ -586,14 +807,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 },
)
@@ -620,7 +841,8 @@ function createTransferPayload(options: { item?: FileItem; items?: FileItem[]; l
...transferForm,
fileitem: sourceItem,
logid: options.logid ?? 0,
episode_group: transferForm.episode_group?.trim() || null,
target_path: normalizeTargetPath(transferForm.target_path),
episode_group: normalizeEpisodeGroup(transferForm.episode_group),
}
if (options.items?.length) {
@@ -700,6 +922,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 +1323,9 @@ async function transfer(background: boolean = false) {
emit('done')
}
onMounted(() => {
loadDirectories()
onMounted(async () => {
await loadDirectories()
await autoSelectTargetPath()
loadStorages()
loadEpisodeFormatRuleConfiguration()
})
@@ -1218,9 +1443,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"
@@ -1439,6 +1666,36 @@ 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">
@@ -1698,6 +1955,66 @@ onUnmounted(() => {
white-space: nowrap;
}
.preview-custom-words {
display: flex;
flex-direction: column;
border: 1px solid rgba(var(--v-border-color), var(--v-border-opacity));
border-radius: 0.75rem;
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 {
overflow-wrap: anywhere;
color: rgb(var(--v-theme-on-surface));
font-size: 0.8125rem;
font-weight: 600;
line-height: 1.4;
}
.preview-custom-words__original {
overflow-wrap: anywhere;
color: rgba(var(--v-theme-on-surface), var(--v-medium-emphasis-opacity));
font-size: 0.75rem;
line-height: 1.4;
}
.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;
@@ -1797,11 +2114,13 @@ onUnmounted(() => {
}
.preview-file-row__path {
overflow: hidden;
overflow: visible;
overflow-wrap: anywhere;
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;
white-space: normal;
word-break: break-all;
}
.preview-file-row__card--target .preview-file-row__name {

View File

@@ -712,7 +712,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 +723,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);
}

View File

@@ -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>
@@ -107,16 +135,7 @@ const filteredSites = computed(() => {
'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'"
@@ -161,7 +180,7 @@ const filteredSites = computed(() => {
<VBtn
color="primary"
:disabled="selectedSites.length === 0"
@click="emit('search', selectedSites)"
@click="confirmSearch"
prepend-icon="mdi-magnify"
class="d-flex align-center justify-center px-5"
>

View File

@@ -61,19 +61,21 @@ const visible = computed({
</template>
<style scoped>
/* stylelint-disable selector-pseudo-class-no-unknown */
.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 {

View File

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

View File

@@ -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,6 +595,64 @@ const isMacOS = computed(() => {
inline-size: 100%;
}
.workflow-edge-panel {
position: absolute;
z-index: 120;
display: flex;
flex-direction: column;
padding: 16px;
border: 1px solid rgb(var(--v-theme-primary));
border-radius: 8px;
background-color: rgb(var(--v-theme-surface));
box-shadow: 0 8px 24px rgba(var(--v-shadow-key-umbra-color), 0.32);
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));
@@ -345,9 +713,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>

View File

@@ -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,6 +299,16 @@ 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>

View File

@@ -239,6 +239,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)
@@ -316,6 +322,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 +335,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()
}
}
// 切换路径
@@ -528,6 +542,7 @@ function showBatchTransfer() {
// 整理完成
function transferDone() {
exitSelectMode()
list_files()
}
@@ -688,6 +703,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 +712,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消息处理函数

View File

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

View File

@@ -83,6 +83,8 @@ interface UseLlmProviderDirectoryOptions {
apiKey: Ref<string>
baseUrl: Ref<string>
baseUrlPreset?: Ref<string>
useProxy?: Ref<boolean>
userAgent?: Ref<string>
model: Ref<string>
maxContextTokens?: Ref<number>
authConnected?: Ref<boolean>
@@ -253,6 +255,8 @@ export function useLlmProviderDirectory(options: UseLlmProviderDirectoryOptions)
api_key: normalizeValue(options.apiKey.value) || undefined,
base_url: normalizeValue(options.baseUrl.value) || undefined,
base_url_preset: normalizeValue(options.baseUrlPreset?.value) || undefined,
use_proxy: options.useProxy?.value,
user_agent: normalizeValue(options.userAgent?.value) || undefined,
force_refresh: forceRefresh,
},
})

View File

@@ -61,8 +61,10 @@ export interface WizardData {
supportAudioOutput: boolean
apiKey: string
baseUrl: string
useProxy: boolean
baseUrlPreset: string
maxContextTokens: number
userAgent: string
audioInputProvider: string
audioInputApiKey: string
audioInputBaseUrl: string
@@ -247,8 +249,10 @@ const wizardData = ref<WizardData>({
supportAudioOutput: false,
apiKey: '',
baseUrl: 'https://api.deepseek.com',
useProxy: true,
baseUrlPreset: '',
maxContextTokens: 64,
userAgent: '',
audioInputProvider: 'openai',
audioInputApiKey: '',
audioInputBaseUrl: '',
@@ -1444,8 +1448,10 @@ export function useSetupWizard() {
LLM_SUPPORT_AUDIO_OUTPUT: wizardData.value.agent.supportAudioOutput,
LLM_API_KEY: wizardData.value.agent.apiKey,
LLM_BASE_URL: wizardData.value.agent.baseUrl || null,
LLM_USE_PROXY: wizardData.value.agent.useProxy,
LLM_BASE_URL_PRESET: wizardData.value.agent.baseUrlPreset || null,
LLM_MAX_CONTEXT_TOKENS: wizardData.value.agent.maxContextTokens,
LLM_USER_AGENT: wizardData.value.agent.userAgent || null,
AUDIO_INPUT_PROVIDER: wizardData.value.agent.audioInputProvider || 'openai',
AUDIO_INPUT_API_KEY: wizardData.value.agent.audioInputApiKey || null,
AUDIO_INPUT_BASE_URL: wizardData.value.agent.audioInputBaseUrl || null,
@@ -1557,8 +1563,10 @@ export function useSetupWizard() {
wizardData.value.agent.supportAudioOutput = Boolean(result.data.LLM_SUPPORT_AUDIO_OUTPUT)
wizardData.value.agent.apiKey = result.data.LLM_API_KEY || ''
wizardData.value.agent.baseUrl = result.data.LLM_BASE_URL || ''
wizardData.value.agent.useProxy = result.data.LLM_USE_PROXY ?? true
wizardData.value.agent.baseUrlPreset = result.data.LLM_BASE_URL_PRESET || ''
wizardData.value.agent.maxContextTokens = result.data.LLM_MAX_CONTEXT_TOKENS || 64
wizardData.value.agent.userAgent = result.data.LLM_USER_AGENT || ''
wizardData.value.agent.audioInputProvider = result.data.AUDIO_INPUT_PROVIDER || 'openai'
wizardData.value.agent.audioInputApiKey = result.data.AUDIO_INPUT_API_KEY || ''
wizardData.value.agent.audioInputBaseUrl = result.data.AUDIO_INPUT_BASE_URL || ''

View File

@@ -0,0 +1,348 @@
import { computed, onMounted, onScopeDispose, readonly, ref } from 'vue'
import { useTheme } from 'vuetify'
import { checkPrefersColorSchemeIsDark } from '@/@core/utils'
import { saveLocalTheme } from '@/@core/utils/theme'
import vuetify from '@/plugins/vuetify'
import { themeManager } from '@/utils/themeManager'
export const THEME_CUSTOMIZER_STORAGE_KEY = 'moviepilot-theme-customizer'
export const THEME_CUSTOMIZER_CHANGE_EVENT = 'moviepilot-theme-customizer-change'
export const themeCustomizerPrimaryColors = [
{ name: 'Purple', value: '#9155FD' },
{ name: 'Indigo', value: '#3F51B5' },
{ name: 'Blue', value: '#1976D2' },
{ name: 'Cyan', value: '#00BCD4' },
{ name: 'Teal', value: '#009688' },
{ name: 'Green', value: '#4CAF50' },
{ name: 'Amber', value: '#FFB400' },
{ name: 'Orange', value: '#FF9800' },
{ name: 'Coral', value: '#FF4C51' },
{ name: 'Pink', value: '#E91E63' },
{ name: 'Sky', value: '#16B1FF' },
{ name: 'Slate', value: '#607D8B' },
] as const
export type ThemeCustomizerLayout = 'collapsed' | 'horizontal' | 'vertical'
export type ThemeCustomizerShadow = 'none' | 'low' | 'medium' | 'high'
export type ThemeCustomizerSkin = 'bordered' | 'default'
export type ThemeCustomizerTheme = 'auto' | 'dark' | 'light' | 'purple' | 'transparent'
export interface ThemeCustomizerSettings {
layout: ThemeCustomizerLayout
primaryColor: string
semiDarkMenu: boolean
shadow: ThemeCustomizerShadow
skin: ThemeCustomizerSkin
theme: ThemeCustomizerTheme
}
type VuetifyThemeApi = ReturnType<typeof useTheme>
const defaultPrimaryColor = themeCustomizerPrimaryColors[0].value
const validLayouts: ThemeCustomizerLayout[] = ['vertical', 'collapsed', 'horizontal']
const validShadows: ThemeCustomizerShadow[] = ['none', 'low', 'medium', 'high']
const validSkins: ThemeCustomizerSkin[] = ['default', 'bordered']
const validThemes: ThemeCustomizerTheme[] = ['auto', 'light', 'dark', 'purple', 'transparent']
let themeApplyVersion = 0
function isBrowser() {
return typeof window !== 'undefined'
}
function isHexColor(color: unknown): color is string {
return typeof color === 'string' && /^#[\da-f]{6}$/i.test(color)
}
function readStoredThemePreference(): ThemeCustomizerTheme {
if (!isBrowser()) return 'auto'
const storedTheme = localStorage.getItem('theme')
return validThemes.includes(storedTheme as ThemeCustomizerTheme) ? (storedTheme as ThemeCustomizerTheme) : 'auto'
}
function getDefaultThemeCustomizerSettings(): ThemeCustomizerSettings {
return {
layout: 'vertical',
primaryColor: defaultPrimaryColor,
semiDarkMenu: false,
shadow: 'none',
skin: 'default',
theme: readStoredThemePreference(),
}
}
function normalizeThemeCustomizerSettings(settings: Partial<ThemeCustomizerSettings>): ThemeCustomizerSettings {
const fallback = getDefaultThemeCustomizerSettings()
return {
layout: validLayouts.includes(settings.layout as ThemeCustomizerLayout)
? (settings.layout as ThemeCustomizerLayout)
: fallback.layout,
primaryColor: isHexColor(settings.primaryColor) ? settings.primaryColor.toUpperCase() : fallback.primaryColor,
semiDarkMenu: typeof settings.semiDarkMenu === 'boolean' ? settings.semiDarkMenu : fallback.semiDarkMenu,
shadow: validShadows.includes(settings.shadow as ThemeCustomizerShadow)
? (settings.shadow as ThemeCustomizerShadow)
: fallback.shadow,
skin: validSkins.includes(settings.skin as ThemeCustomizerSkin)
? (settings.skin as ThemeCustomizerSkin)
: fallback.skin,
theme: validThemes.includes(settings.theme as ThemeCustomizerTheme)
? (settings.theme as ThemeCustomizerTheme)
: fallback.theme,
}
}
/** 从本地存储读取主题定制器设置,异常数据会自动回落到默认值。 */
export function readThemeCustomizerSettings(): ThemeCustomizerSettings {
const fallback = getDefaultThemeCustomizerSettings()
if (!isBrowser()) return fallback
try {
const stored = localStorage.getItem(THEME_CUSTOMIZER_STORAGE_KEY)
const parsed = stored ? JSON.parse(stored) : {}
return normalizeThemeCustomizerSettings({
...fallback,
...parsed,
theme: readStoredThemePreference(),
})
} catch (error) {
console.warn('读取主题定制设置失败,已使用默认设置:', error)
return fallback
}
}
// 生产构建会改写导出函数的声明形式,状态初始化必须放在读取函数定义之后,避免首屏执行时引用未完成赋值的函数。
const settingsState = ref<ThemeCustomizerSettings>(readThemeCustomizerSettings())
function persistThemeCustomizerSettings(settings: ThemeCustomizerSettings) {
if (!isBrowser()) return
localStorage.setItem(THEME_CUSTOMIZER_STORAGE_KEY, JSON.stringify(settings))
}
function dispatchThemeCustomizerChange(settings: ThemeCustomizerSettings) {
if (!isBrowser()) return
window.dispatchEvent(
new CustomEvent<ThemeCustomizerSettings>(THEME_CUSTOMIZER_CHANGE_EVENT, {
detail: settings,
}),
)
}
function getTextColorForHex(backgroundColor: string) {
const hex = backgroundColor.replace('#', '')
const red = Number.parseInt(hex.slice(0, 2), 16)
const green = Number.parseInt(hex.slice(2, 4), 16)
const blue = Number.parseInt(hex.slice(4, 6), 16)
const luminance = (0.299 * red + 0.587 * green + 0.114 * blue) / 255
return luminance > 0.68 ? '#3A3541' : '#FFFFFF'
}
/** 将主色写入 Vuetify 运行时主题,所有已注册主题会同步更新。 */
export function applyPrimaryColorToVuetify(color: string, themeApi: VuetifyThemeApi) {
if (!isHexColor(color)) return
const onPrimaryColor = getTextColorForHex(color)
for (const themeDefinition of Object.values(themeApi.themes.value)) {
themeDefinition.colors.primary = color
themeDefinition.colors['on-primary'] = onPrimaryColor
}
document.documentElement.style.setProperty('--initial-loader-color', color)
localStorage.setItem('materio-initial-loader-color', color)
}
/** 布局、阴影、皮肤和局部菜单风格只依赖根节点属性CSS 可以在不刷新页面的情况下即时响应。 */
export function applyThemeCustomizerRootSettings(
settings: Pick<ThemeCustomizerSettings, 'layout' | 'semiDarkMenu' | 'shadow' | 'skin'>,
) {
if (!isBrowser()) return
document.documentElement.setAttribute('data-theme-layout', settings.layout)
document.documentElement.setAttribute('data-theme-semi-dark-menu', String(settings.semiDarkMenu))
document.documentElement.setAttribute('data-theme-shadow', settings.shadow)
document.documentElement.setAttribute('data-theme-skin', settings.skin)
document.body.setAttribute('data-theme-layout', settings.layout)
document.body.setAttribute('data-theme-semi-dark-menu', String(settings.semiDarkMenu))
document.body.setAttribute('data-theme-shadow', settings.shadow)
document.body.setAttribute('data-theme-skin', settings.skin)
}
function getResolvedThemeName(themePreference: ThemeCustomizerTheme) {
if (themePreference === 'auto') {
return checkPrefersColorSchemeIsDark() ? 'dark' : 'light'
}
return themePreference
}
function syncThemeAttribute(themeName: string) {
document.documentElement.setAttribute('data-theme', themeName)
document.body.setAttribute('data-theme', themeName)
}
async function applyThemePreference(themePreference: ThemeCustomizerTheme, themeApi: VuetifyThemeApi) {
const currentVersion = ++themeApplyVersion
const resolvedTheme = getResolvedThemeName(themePreference)
themeApi.global.name.value = resolvedTheme
await themeManager.setTheme(themePreference)
// 这里再同步一次实际主题,确保自定义主题色应用后根节点底色也保持最新。
if (currentVersion === themeApplyVersion) {
syncThemeAttribute(resolvedTheme)
saveLocalTheme(themePreference, themeApi.global)
}
}
/** 应用已保存的主色、皮肤和布局,供 App 启动阶段在面板挂载前使用。 */
export function applyStoredThemeCustomizerAppearance(themeApi: VuetifyThemeApi) {
const settings = readThemeCustomizerSettings()
settingsState.value = settings
applyPrimaryColorToVuetify(settings.primaryColor, themeApi)
applyThemeCustomizerRootSettings(settings)
return settings
}
export function persistPartialThemeCustomizerSettings(patch: Partial<ThemeCustomizerSettings>) {
const nextSettings = normalizeThemeCustomizerSettings({
...readThemeCustomizerSettings(),
...patch,
})
settingsState.value = nextSettings
persistThemeCustomizerSettings(nextSettings)
applyPrimaryColorToVuetify(nextSettings.primaryColor, vuetify.theme)
applyThemeCustomizerRootSettings(nextSettings)
dispatchThemeCustomizerChange(nextSettings)
return nextSettings
}
export function isDefaultThemeCustomizerSettings(settings: ThemeCustomizerSettings) {
const defaults = normalizeThemeCustomizerSettings({
layout: 'vertical',
primaryColor: defaultPrimaryColor,
semiDarkMenu: false,
shadow: 'none',
skin: 'default',
theme: 'auto',
})
return (
settings.layout === defaults.layout &&
settings.primaryColor === defaults.primaryColor &&
settings.semiDarkMenu === defaults.semiDarkMenu &&
settings.shadow === defaults.shadow &&
settings.skin === defaults.skin &&
settings.theme === defaults.theme
)
}
/** 提供主题定制器面板使用的响应式状态与操作。 */
export function useThemeCustomizer() {
const themeApi = useTheme()
const settings = settingsState
async function updateSettings(patch: Partial<ThemeCustomizerSettings>) {
const previousTheme = settings.value.theme
const nextSettings = normalizeThemeCustomizerSettings({
...settings.value,
...patch,
})
settings.value = nextSettings
persistThemeCustomizerSettings(nextSettings)
applyPrimaryColorToVuetify(nextSettings.primaryColor, themeApi)
applyThemeCustomizerRootSettings(nextSettings)
if (
previousTheme !== nextSettings.theme ||
themeApi.global.name.value !== getResolvedThemeName(nextSettings.theme)
) {
await applyThemePreference(nextSettings.theme, themeApi)
}
dispatchThemeCustomizerChange(nextSettings)
}
function setPrimaryColor(color: string) {
return updateSettings({ primaryColor: color })
}
function setTheme(theme: ThemeCustomizerTheme) {
return updateSettings({ theme })
}
function setShadow(shadow: ThemeCustomizerShadow) {
return updateSettings({ shadow })
}
function setSkin(skin: ThemeCustomizerSkin) {
return updateSettings({ skin })
}
function setLayout(layout: ThemeCustomizerLayout) {
return updateSettings({ layout })
}
function setSemiDarkMenu(semiDarkMenu: boolean) {
return updateSettings({ semiDarkMenu })
}
async function resetSettings() {
await updateSettings({
layout: 'vertical',
primaryColor: defaultPrimaryColor,
semiDarkMenu: false,
shadow: 'none',
skin: 'default',
theme: 'auto',
})
}
function handleSystemThemeChange() {
if (settings.value.theme === 'auto') {
updateSettings({ theme: 'auto' })
}
}
let mediaQuery: MediaQueryList | null = null
onMounted(() => {
settings.value = readThemeCustomizerSettings()
applyPrimaryColorToVuetify(settings.value.primaryColor, themeApi)
applyThemeCustomizerRootSettings(settings.value)
mediaQuery = window.matchMedia?.('(prefers-color-scheme: dark)') ?? null
mediaQuery?.addEventListener('change', handleSystemThemeChange)
})
onScopeDispose(() => {
mediaQuery?.removeEventListener('change', handleSystemThemeChange)
})
return {
isCustomized: computed(() => !isDefaultThemeCustomizerSettings(settings.value)),
resetSettings,
setLayout,
setPrimaryColor,
setSemiDarkMenu,
setShadow,
setSkin,
setTheme,
settings: readonly(settings),
}
}

View File

@@ -15,19 +15,27 @@ import { filterPluginSidebarNavEntries } from '@/utils/pluginSidebarNav'
import { NavMenu } from '@/@layouts/types'
import { useDisplay } from 'vuetify'
import { useI18n } from 'vue-i18n'
import { useRoute } from 'vue-router'
import { useRoute, useRouter } from 'vue-router'
import { filterMenusByPermission } from '@/utils/permission'
import { onUnreadMessage } from '@/utils/badge'
import { usePullDownGesture } from '@/composables/usePullDownGesture'
import { usePWA } from '@/composables/usePWA'
import OfflinePage from '@/layouts/components/OfflinePage.vue'
import { useGlobalOfflineStatus } from '@/composables/useOfflineStatus'
import {
readThemeCustomizerSettings,
THEME_CUSTOMIZER_CHANGE_EVENT,
type ThemeCustomizerSettings,
} from '@/composables/useThemeCustomizer'
import logo from '@images/logo.svg?raw'
const display = useDisplay()
// PWA模式检测
const { appMode } = usePWA()
const { t } = useI18n()
const route = useRoute()
const router = useRouter()
const themeLayout = ref(readThemeCustomizerSettings().layout)
// 用户 Store
const userStore = useUserStore()
@@ -60,6 +68,35 @@ const organizeMenus = ref<NavMenu[]>([])
// 系统菜单项
const systemMenus = ref<NavMenu[]>([])
// 主题定制器的水平布局只在桌面 UI 中启用App 模式始终保留移动端导航。
const showHorizontalThemeNav = computed(() => {
return themeLayout.value === 'horizontal' && !appMode.value && !display.mdAndDown.value
})
const horizontalNavGroups = computed(() =>
[
{ title: t('menu.start'), icon: 'mdi-home-outline', items: startMenus.value },
{ title: t('menu.discovery'), icon: 'mdi-compass-outline', items: discoveryMenus.value },
{ title: t('menu.subscribe'), icon: 'mdi-rss', items: subscribeMenus.value },
{ title: t('menu.organize'), icon: 'mdi-folder-play-outline', items: organizeMenus.value },
{ title: t('menu.system'), icon: 'mdi-cog-outline', items: systemMenus.value },
].filter(group => group.items.length > 0),
)
const navbarExtraHeight = computed(() => {
const dynamicTabHeight = showDynamicHeaderTab.value ? 2.5 : 0
const horizontalNavHeight = showHorizontalThemeNav.value ? 3.25 : 0
return `${dynamicTabHeight + horizontalNavHeight}rem`
})
const mainContentPaddingTop = computed(() => {
const dynamicTabPadding = showDynamicHeaderTab.value ? 3 : 0
const horizontalNavPadding = showHorizontalThemeNav.value ? 3.5 : 0
return `${dynamicTabPadding + horizontalNavPadding}rem`
})
// 插件快速访问相关状态
const showPluginQuickAccess = ref(false)
@@ -68,26 +105,36 @@ const { setAppOffline, isOffline } = useGlobalOfflineStatus()
// 动态标签页相关
// 定义动态标签页类型
interface DynamicHeaderTabButton {
icon: string
color?: string | ComputedRef<string>
variant?: 'flat' | 'text' | 'elevated' | 'tonal' | 'outlined' | 'plain'
size?: string
class?: string
action?: () => void
show?: boolean | ComputedRef<boolean>
loading?: boolean | ComputedRef<boolean>
dataAttr?: string
}
interface DynamicHeaderTabItem {
title: string
icon?: string
tab: string
}
interface DynamicHeaderTab {
items: Array<{ title: string; icon: string; tab: string }>
items: DynamicHeaderTabItem[]
modelValue: string
appendButtons?: Array<{
icon: string
color?: string | ComputedRef<string>
variant?: 'flat' | 'text' | 'elevated' | 'tonal' | 'outlined' | 'plain'
size?: string
class?: string
action?: () => void
show?: boolean | ComputedRef<boolean>
loading?: boolean | ComputedRef<boolean>
dataAttr?: string
}>
appendButtons?: DynamicHeaderTabButton[]
routePath?: string // 用于标识哪个路由注册的
onUpdateModelValue?: (value: string) => void // 用于通知值更新
}
// 提供动态标签页注册和获取的方法
const dynamicHeaderTab = ref<DynamicHeaderTab | null>(null)
const openHorizontalNavGroup = ref<string | null>(null)
const pendingHorizontalTab = ref<{ path: string; tab: string } | null>(null)
// 提供一个方法让其他组件注册动态标签页
const registerDynamicHeaderTab = (tab: DynamicHeaderTab) => {
@@ -95,6 +142,7 @@ const registerDynamicHeaderTab = (tab: DynamicHeaderTab) => {
tab.routePath = route.path
// 强制更新,确保响应式系统能检测到变化
dynamicHeaderTab.value = { ...tab }
applyPendingHorizontalTab()
}
// 提供一个方法让其他组件取消注册动态标签页
@@ -138,13 +186,22 @@ watch(
{ immediate: false },
)
// 显示动态标签页
const showDynamicHeaderTab = computed(() => {
// 当前路由是否注册了动态标签页
const hasDynamicHeaderTab = computed(() => {
return (
dynamicHeaderTab.value && dynamicHeaderTab.value.items.length > 0 && dynamicHeaderTab.value.routePath === route.path
)
})
// 水平布局下动态标签页会并入顶部导航三级菜单,不再额外显示标签页栏。
const showDynamicHeaderTab = computed(() => hasDynamicHeaderTab.value && !showHorizontalThemeNav.value)
const visibleHorizontalHeaderButtons = computed(() => {
if (!showHorizontalThemeNav.value || !hasDynamicHeaderTab.value) return []
return (dynamicHeaderTab.value?.appendButtons ?? []).filter(button => resolveMaybeRefValue(button.show, true) !== false)
})
// 在组件销毁时清理
onUnmounted(() => {
dynamicHeaderTab.value = null
@@ -210,6 +267,103 @@ function goBack() {
history.back()
}
function handleThemeCustomizerChange(event: Event) {
themeLayout.value = (event as CustomEvent<ThemeCustomizerSettings>).detail.layout
}
function isHorizontalNavActive(item: NavMenu) {
const targetPath = normalizeMenuPath(item.to)
if (!targetPath) return false
const currentPath = normalizeMenuPath(route.path)
return currentPath === targetPath || currentPath.startsWith(`${targetPath}/`)
}
function isHorizontalNavGroupActive(group: { items: NavMenu[] }) {
return group.items.some(isHorizontalNavActive)
}
function hasHorizontalDynamicTabs(item: NavMenu) {
return showHorizontalThemeNav.value && getHorizontalNavTabs(item).length > 0
}
function isHorizontalDynamicTabActive(tab: DynamicHeaderTabItem) {
return dynamicHeaderTab.value?.modelValue === tab.tab
}
async function handleHorizontalDynamicTabSelect(item: NavMenu, tab: DynamicHeaderTabItem) {
const targetPath = normalizeMenuPath(item.to)
const currentPath = normalizeMenuPath(route.path)
if (targetPath && currentPath !== targetPath) {
// 三级菜单可能在目标页面挂载前点击,先记录待切换 tab页面注册动态 tab 后再应用。
pendingHorizontalTab.value = { path: targetPath, tab: tab.tab }
await router.push(targetPath)
} else {
handleTabChange(tab.tab)
}
openHorizontalNavGroup.value = null
}
function closeHorizontalNavGroup() {
openHorizontalNavGroup.value = null
}
function resolveMaybeRefValue<T>(value: T | ComputedRef<T> | undefined, fallback: T): T {
return isRef(value) ? value.value : value ?? fallback
}
function resolveHeaderButtonColor(button: DynamicHeaderTabButton) {
return resolveMaybeRefValue(button.color, 'gray')
}
function resolveHeaderButtonLoading(button: DynamicHeaderTabButton) {
return resolveMaybeRefValue(button.loading, false)
}
function getHorizontalTabIcon(tab: DynamicHeaderTabItem) {
const icon = tab.icon?.trim()
// 部分页面会把业务来源标识(如 themoviedb/douban/bangumi放进 icon 字段,
// 这些值不是菜单里的可渲染图标,三级菜单统一回退到默认图标。
if (!icon || (!icon.startsWith('mdi-') && !icon.startsWith('tabler-') && !icon.includes(':'))) {
return 'mdi-circle-medium'
}
return icon
}
function normalizeMenuPath(value: unknown) {
if (typeof value !== 'string') return ''
return value.replace(/\/$/, '') || '/'
}
function getHorizontalNavTabs(item: NavMenu): DynamicHeaderTabItem[] {
const targetPath = normalizeMenuPath(item.to)
if (targetPath && isHorizontalNavActive(item) && hasDynamicHeaderTab.value) {
return dynamicHeaderTab.value?.items ?? []
}
return item.tabs ?? []
}
function applyPendingHorizontalTab() {
if (!pendingHorizontalTab.value || !hasDynamicHeaderTab.value) return
const pending = pendingHorizontalTab.value
if (normalizeMenuPath(route.path) !== pending.path) return
const tabExists = dynamicHeaderTab.value?.items.some(item => item.tab === pending.tab)
if (!tabExists) return
handleTabChange(pending.tab)
pendingHorizontalTab.value = null
}
// 处理未读消息事件
function handleUnreadMessage(count: number) {
if (superUser.value && count > 0) {
@@ -278,9 +432,12 @@ onMounted(async () => {
navigator.serviceWorker.addEventListener('message', handleServiceWorkerMessage)
}
window.addEventListener(THEME_CUSTOMIZER_CHANGE_EVENT, handleThemeCustomizerChange)
// 组件卸载时清理监听
onBeforeUnmount(() => {
unsubscribe()
window.removeEventListener(THEME_CUSTOMIZER_CHANGE_EVENT, handleThemeCustomizerChange)
if ('serviceWorker' in navigator) {
navigator.serviceWorker.removeEventListener('message', handleServiceWorkerMessage)
}
@@ -316,10 +473,17 @@ onMounted(async () => {
/>
</div>
</div>
<VerticalNavLayout :style="{ '--navbar-tab-height': showDynamicHeaderTab ? '2.5rem' : '0px' }">
<VerticalNavLayout :style="{ '--navbar-tab-height': navbarExtraHeight }">
<!-- 👉 Navbar -->
<template #navbar="{ toggleVerticalOverlayNavActive }">
<div class="d-flex h-14 align-center mx-1">
<div
class="theme-navbar-row d-flex h-14 align-center mx-1"
:class="{ 'theme-navbar-row--horizontal': showHorizontalThemeNav }"
>
<RouterLink v-if="showHorizontalThemeNav" to="/dashboard" class="theme-horizontal-logo">
<span class="theme-horizontal-logo__mark" v-html="logo" />
<span class="theme-horizontal-logo__text">MOVIEPILOT</span>
</RouterLink>
<!-- 👉 Vertical Nav Toggle -->
<IconBtn v-if="!appMode && display.mdAndDown.value" class="ms-n2" @click="toggleVerticalOverlayNavActive(true)">
<VIcon icon="mdi-menu" />
@@ -329,15 +493,113 @@ onMounted(async () => {
<VIcon icon="mdi-arrow-left" size="32" />
</IconBtn>
<!-- 👉 Search Bar -->
<SearchBar />
<SearchBar v-if="!showHorizontalThemeNav" />
<!-- 👉 Spacer -->
<VSpacer />
<!-- 👉 Shortcuts -->
<ShortcutBar v-if="superUser" ref="shortcutBarRef" />
<!-- 👉 Notification -->
<UserNofification />
<!-- 👉 UserProfile -->
<UserProfile />
<div
class="theme-navbar-actions d-flex align-center"
:class="{ 'theme-navbar-actions--horizontal': showHorizontalThemeNav }"
>
<!-- 👉 Horizontal Search Icon -->
<SearchBar v-if="showHorizontalThemeNav" icon-only />
<!-- 👉 Shortcuts -->
<ShortcutBar v-if="superUser" ref="shortcutBarRef" />
<!-- 👉 Notification -->
<UserNofification />
<!-- 👉 UserProfile -->
<UserProfile />
</div>
</div>
<div v-if="showHorizontalThemeNav" class="theme-horizontal-nav">
<VMenu
v-for="group in horizontalNavGroups"
:key="group.title"
:model-value="openHorizontalNavGroup === group.title"
location="bottom start"
offset="8"
:close-on-content-click="false"
@update:model-value="openHorizontalNavGroup = $event ? group.title : null"
>
<template #activator="{ props: menuProps }">
<VBtn
v-bind="menuProps"
:prepend-icon="group.icon"
append-icon="mdi-chevron-down"
:variant="isHorizontalNavGroupActive(group) ? 'tonal' : 'text'"
:color="isHorizontalNavGroupActive(group) ? 'primary' : 'default'"
rounded="pill"
class="theme-horizontal-nav__item"
>
{{ group.title }}
</VBtn>
</template>
<VList class="theme-horizontal-nav__menu" min-width="13rem" density="comfortable">
<template v-for="item in group.items" :key="`${group.title}-${item.title}-${item.to}`">
<VMenu
v-if="hasHorizontalDynamicTabs(item)"
location="end top"
offset="8"
open-on-hover
:open-delay="0"
:close-delay="120"
:close-on-content-click="true"
>
<template #activator="{ props: subMenuProps }">
<VListItem v-bind="subMenuProps" :active="isHorizontalNavActive(item)">
<template #prepend>
<VIcon :icon="String(item.icon || '')" />
</template>
<VListItemTitle>{{ item.full_title || item.title }}</VListItemTitle>
<template #append>
<VIcon icon="mdi-chevron-right" size="small" />
</template>
</VListItem>
</template>
<VList class="theme-horizontal-nav__submenu" min-width="12rem" density="comfortable">
<VListItem
v-for="tab in getHorizontalNavTabs(item)"
:key="`${item.to}-${tab.tab}`"
:active="isHorizontalDynamicTabActive(tab)"
@click="handleHorizontalDynamicTabSelect(item, tab)"
>
<template #prepend>
<VIcon :icon="getHorizontalTabIcon(tab)" />
</template>
<VListItemTitle>{{ tab.title }}</VListItemTitle>
</VListItem>
</VList>
</VMenu>
<VListItem
v-else
:to="item.to || undefined"
:active="isHorizontalNavActive(item)"
@click="closeHorizontalNavGroup"
>
<template #prepend>
<VIcon :icon="String(item.icon || '')" />
</template>
<VListItemTitle>{{ item.full_title || item.title }}</VListItemTitle>
</VListItem>
</template>
</VList>
</VMenu>
<div v-if="visibleHorizontalHeaderButtons.length" class="theme-horizontal-nav__actions">
<VBtn
v-for="button in visibleHorizontalHeaderButtons"
:key="button.icon"
:icon="button.icon"
:variant="button.variant || 'text'"
:color="resolveHeaderButtonColor(button)"
:size="button.size || 'default'"
:class="button.class || 'settings-icon-button'"
:loading="resolveHeaderButtonLoading(button)"
:data-menu-activator="button.dataAttr"
@click="button.action"
/>
</div>
</div>
</template>
@@ -412,7 +674,7 @@ onMounted(async () => {
:style="{
transform: contentTransform,
transition: contentTransition,
paddingTop: showDynamicHeaderTab ? '3rem' : '0px',
paddingTop: mainContentPaddingTop,
}"
>
<slot />
@@ -443,6 +705,103 @@ onMounted(async () => {
will-change: transform;
}
.theme-navbar-row--horizontal {
gap: 1rem;
margin-inline: 0 !important;
}
.theme-horizontal-logo {
display: inline-flex;
flex: 0 0 auto;
align-items: center;
color: rgba(var(--v-theme-on-surface), var(--v-high-emphasis-opacity));
column-gap: 0.75rem;
font-weight: 700;
letter-spacing: 0;
line-height: 1;
text-decoration: none;
}
.theme-horizontal-logo__mark {
display: inline-flex;
align-items: center;
justify-content: center;
block-size: 2rem;
inline-size: 2rem;
}
.theme-horizontal-logo__mark :deep(svg) {
display: block;
block-size: 1.8rem;
inline-size: 1.8rem;
}
.theme-horizontal-logo__text {
font-size: 1.125rem;
white-space: nowrap;
}
.theme-navbar-actions--horizontal {
gap: 0.85rem;
:deep(.ms-2),
:deep(.ms-3) {
margin-inline-start: 0 !important;
}
:deep(.v-btn.v-btn--icon) {
flex: 0 0 auto;
border-radius: 12px;
block-size: 2.75rem;
color: rgba(var(--v-theme-on-surface), 0.78);
inline-size: 2.75rem;
}
:deep(.v-btn.v-btn--icon .v-icon) {
font-size: 1.75rem;
line-height: 1;
}
:deep(.v-avatar.cursor-pointer) {
flex: 0 0 auto;
block-size: 2.75rem !important;
inline-size: 2.75rem !important;
margin-inline-start: 0 !important;
}
}
.theme-horizontal-nav {
display: flex;
overflow-x: auto;
align-items: center;
block-size: 3.25rem;
gap: 0.25rem;
padding-block: 0.25rem 0.5rem;
padding-inline: 0.5rem;
scrollbar-width: none;
&::-webkit-scrollbar {
display: none;
}
}
.theme-horizontal-nav__item {
flex: 0 0 auto;
letter-spacing: 0;
}
.theme-horizontal-nav__menu,
.theme-horizontal-nav__submenu {
border: 1px solid rgba(var(--v-theme-on-surface), 0.08);
}
.theme-horizontal-nav__actions {
display: flex;
flex: 0 0 auto;
align-items: center;
margin-inline-start: auto;
}
.pull-indicator {
position: fixed;
display: flex;

View File

@@ -7,7 +7,7 @@ const props = defineProps({
default: '',
},
items: {
type: Array as PropType<{ title: string; icon: string; tab: string }[]>,
type: Array as PropType<{ title: string; icon?: string; tab: string }[]>,
default: () => [],
},
// 新增是否启用PWA状态恢复

View File

@@ -5,6 +5,15 @@ import { openSharedDialog } from '@/composables/useSharedDialog'
import { useDisplay } from 'vuetify'
import { useI18n } from 'vue-i18n'
const props = withDefaults(
defineProps<{
iconOnly?: boolean
}>(),
{
iconOnly: false,
},
)
const display = useDisplay()
const { t } = useI18n()
@@ -23,17 +32,18 @@ function isMac() {
}
// 计算属性:根据操作系统显示不同的按键提示
const metaKey = computed(() => (isMac() ? '⌘+K' : 'Ctrl+K'))
const showIconOnly = computed(() => props.iconOnly || !display.mdAndUp.value)
</script>
<template>
<!-- 小屏仅图标按钮 -->
<IconBtn v-if="!display.mdAndUp.value" @click="openSearchDialog">
<VIcon icon="mdi-magnify" />
<!-- 小屏或水平导航右侧工具区仅显示搜索图标 -->
<IconBtn v-if="showIconOnly" class="search-icon-trigger" @click="openSearchDialog">
<VIcon class="search-icon-trigger__icon" icon="mdi-magnify" />
</IconBtn>
<!-- 中屏及以上胶囊搜索触发栏 -->
<div v-else class="search-trigger" @click="openSearchDialog">
<VIcon icon="mdi-magnify" size="18" class="search-trigger-icon" />
<VIcon icon="mdi-magnify" size="30" class="search-trigger-icon" />
<span class="search-trigger-text">{{ t('common.search') }}</span>
<kbd class="search-trigger-kbd">{{ metaKey }}</kbd>
</div>
@@ -43,45 +53,65 @@ const metaKey = computed(() => (isMac() ? '⌘+K' : 'Ctrl+K'))
.search-trigger {
display: flex;
align-items: center;
gap: 8px;
border: 1.5px solid rgba(var(--v-theme-on-surface), 0.12);
border-radius: 22px;
block-size: 36px;
border: 1px solid rgba(var(--v-theme-on-surface), 0.1);
border-radius: 999px;
background: rgba(var(--v-theme-surface), 0.44);
block-size: 44px;
cursor: pointer;
padding-inline: 12px;
transition: border-color 0.2s ease, background-color 0.2s ease, box-shadow 0.2s ease;
gap: 12px;
min-inline-size: 168px;
padding-inline: 18px 10px;
transition:
border-color 0.2s ease,
background-color 0.2s ease,
box-shadow 0.2s ease;
user-select: none;
}
.search-trigger:hover {
border-color: rgba(var(--v-theme-on-surface), 0.22);
background-color: rgba(var(--v-theme-on-surface), 0.06);
box-shadow: 0 1px 4px rgba(0, 0, 0, 4%);
border-color: rgba(var(--v-theme-on-surface), 0.18);
background-color: rgba(var(--v-theme-surface), 0.62);
box-shadow: 0 4px 14px rgba(0, 0, 0, 6%);
}
.search-trigger-icon {
color: rgba(var(--v-theme-on-surface), 0.4);
flex-shrink: 0;
color: rgba(var(--v-theme-on-surface), 0.72);
font-size: 1.8rem;
}
.search-trigger-text {
color: rgba(var(--v-theme-on-surface), 0.4);
font-size: 13.5px;
color: rgba(var(--v-theme-on-surface), 0.42);
font-size: 1rem;
line-height: 1;
white-space: nowrap;
}
.search-trigger-kbd {
border: 1px solid rgba(var(--v-theme-on-surface), 0.12);
border-radius: 5px;
background-color: rgba(var(--v-theme-on-surface), 0.04);
color: rgba(var(--v-theme-on-surface), 0.4);
border-radius: 8px;
background-color: rgba(var(--v-theme-surface), 0.5);
color: rgba(var(--v-theme-on-surface), 0.42);
font-family: inherit;
font-size: 11px;
font-size: 0.875rem;
font-weight: 500;
line-height: 1;
margin-inline-start: 4px;
padding-block: 3px;
padding-inline: 5px;
padding-block: 6px;
padding-inline: 8px;
}
html[data-theme='transparent'] .search-trigger,
.v-theme--transparent .search-trigger {
backdrop-filter: none;
background: rgba(var(--v-theme-surface), var(--transparent-opacity-light, 0.2));
}
.search-icon-trigger {
flex: 0 0 auto;
}
.search-icon-trigger__icon {
transform: scaleX(-1);
}
</style>

View File

@@ -16,11 +16,20 @@ import { useConfirm } from '@/composables/useConfirm'
import { themeManager } from '@/utils/themeManager'
import { usePWA, type UIMode } from '@/composables/usePWA'
import { applyStoredTransparencySettings } from '@/composables/useTransparencySettings'
import {
persistPartialThemeCustomizerSettings,
readThemeCustomizerSettings,
THEME_CUSTOMIZER_CHANGE_EVENT,
type ThemeCustomizerSettings,
} from '@/composables/useThemeCustomizer'
const AboutDialog = defineAsyncComponent(() => import('@/components/dialog/AboutDialog.vue'))
const CustomCssDialog = defineAsyncComponent(() => import('@/components/dialog/CustomCssDialog.vue'))
const ProgressDialog = defineAsyncComponent(() => import('@/components/dialog/ProgressDialog.vue'))
const TransparencySettingsDialog = defineAsyncComponent(() => import('@/components/dialog/TransparencySettingsDialog.vue'))
const TransparencySettingsDialog = defineAsyncComponent(
() => import('@/components/dialog/TransparencySettingsDialog.vue'),
)
const ThemeCustomizer = defineAsyncComponent(() => import('@/components/ThemeCustomizer.vue'))
const UserAuthDialog = defineAsyncComponent(() => import('@/components/dialog/UserAuthDialog.vue'))
// 认证 Store
@@ -32,7 +41,7 @@ const globalSettingsStore = useGlobalSettingsStore()
// 国际化
const { t } = useI18n()
// PWA
const { uiMode, setUIMode } = usePWA()
const { appMode, uiMode, setUIMode } = usePWA()
// 提示框
const $toast = useToast()
@@ -43,6 +52,9 @@ const showUIModeMenu = ref(false)
// 主题菜单是否显示
const showThemeMenu = ref(false)
// 主题定制器面板是否显示
const showThemeCustomizer = ref(false)
// 语言菜单是否显示
const showLanguageMenu = ref(false)
@@ -264,6 +276,7 @@ const getUIModeIcon = computed(() => {
const { name: themeName, global: globalTheme } = useTheme()
const savedTheme = ref(localStorage.getItem('theme') ?? 'auto')
const currentThemeName = ref(savedTheme.value)
const themeCustomizerSettings = ref(readThemeCustomizerSettings())
const themes: ThemeSwitcherTheme[] = [
{
@@ -293,6 +306,26 @@ const themes: ThemeSwitcherTheme[] = [
},
]
function getThemeLayoutTitle(layout: ThemeCustomizerSettings['layout']) {
switch (layout) {
case 'collapsed':
return t('theme.customizer.layoutCollapsed')
case 'horizontal':
return t('theme.customizer.layoutHorizontal')
case 'vertical':
default:
return t('theme.customizer.layoutVertical')
}
}
const currentThemeSummary = computed(() => {
const themeTitle = themes.find(theme => theme.name === currentThemeName.value)?.title || t('theme.auto')
const layoutTitle = appMode.value ? '' : getThemeLayoutTitle(themeCustomizerSettings.value.layout)
if (layoutTitle) return `${themeTitle} · ${layoutTitle}`
return themeTitle
})
// Ace 跟随 Vuetify 当前生效主题,避免 auto 模式或弹窗打开后切主题时颜色不同步。
const editorTheme = computed(() => (globalTheme.current.value.dark ? 'github_dark' : 'github_light_default'))
@@ -328,6 +361,7 @@ async function changeTheme(theme: string) {
// 保存主题到服务端
try {
persistPartialThemeCustomizerSettings({ theme: theme as ThemeCustomizerSettings['theme'] })
api.post('/user/config/Layout', {
theme,
})
@@ -336,6 +370,18 @@ async function changeTheme(theme: string) {
}
}
function handleThemeCustomizerSettingsChange(event: Event) {
const nextSettings = (event as CustomEvent<ThemeCustomizerSettings>).detail
const nextTheme = nextSettings.theme
themeCustomizerSettings.value = nextSettings
if (currentThemeName.value === nextTheme) return
currentThemeName.value = nextTheme
savedTheme.value = nextTheme
}
// 获取自定义 CSS
async function getCustomCSS() {
try {
@@ -379,6 +425,12 @@ function showTransparencySettingsDialog() {
openSharedDialog(TransparencySettingsDialog, {}, {}, { closeOn: ['close', 'update:modelValue'] })
}
/** 从用户菜单打开主题定制器App 模式会在面板内部隐藏布局设置。 */
function showThemeCustomizerDrawer() {
showThemeMenu.value = false
showThemeCustomizer.value = true
}
/** 保存自定义 CSS。 */
async function saveCustomCSS(css: string) {
customCSS.value = css
@@ -461,6 +513,7 @@ const getThemeIcon = computed(() => {
onMounted(() => {
getCustomCSS()
window.addEventListener(THEME_CUSTOMIZER_CHANGE_EVENT, handleThemeCustomizerSettingsChange)
// 初始化透明度设置
if (isTransparentTheme.value) {
@@ -479,6 +532,7 @@ onUnmounted(() => {
closeRestartProgress()
siteAuthDialogController?.close()
customCssDialogController?.close()
window.removeEventListener(THEME_CUSTOMIZER_CHANGE_EVENT, handleThemeCustomizerSettingsChange)
})
</script>
@@ -587,7 +641,7 @@ onUnmounted(() => {
</template>
<VListItemTitle>{{ t('common.theme') }}</VListItemTitle>
<VListItemSubtitle>
{{ themes.find(t => t.name === currentThemeName)?.title || t('theme.auto') }}
{{ currentThemeSummary }}
</VListItemSubtitle>
<template #append>
<VIcon icon="mdi-chevron-right" size="small" />
@@ -595,31 +649,20 @@ onUnmounted(() => {
</VListItem>
</template>
<VList>
<VListItem
v-for="theme in themes"
:key="theme.name"
@click="changeTheme(theme.name)"
:active="currentThemeName === theme.name"
class="mb-1"
>
<VListItem @click="showThemeCustomizerDrawer">
<template #prepend>
<VIcon :icon="theme.icon" />
<VIcon icon="mdi-tune-variant" />
</template>
<VListItemTitle>{{ theme.title }}</VListItemTitle>
<template #append v-if="currentThemeName === theme.name">
<VIcon icon="mdi-check" color="primary" size="small" />
<VListItemTitle>{{ t('theme.customizer.title') }}</VListItemTitle>
<template #append>
<VIcon icon="mdi-chevron-right" size="small" />
</template>
</VListItem>
<VListItem @click="showCustomCssDialog">
<template #prepend>
<VIcon icon="mdi-palette" />
</template>
<VListItemTitle>{{ t('theme.custom') }}</VListItemTitle>
</VListItem>
<VDivider class="my-2" />
<!-- 透明度调整 - 仅在透明主题下显示 -->
<template v-if="isTransparentTheme">
<VDivider class="my-2" />
<VListItem @click="showTransparencySettingsDialog">
<template #prepend>
<VIcon icon="mdi-opacity" />
@@ -630,6 +673,16 @@ onUnmounted(() => {
</template>
</VListItem>
</template>
<VListItem @click="showCustomCssDialog">
<template #prepend>
<VIcon icon="mdi-palette" />
</template>
<VListItemTitle>{{ t('theme.custom') }}</VListItemTitle>
<template #append>
<VIcon icon="mdi-chevron-right" size="small" />
</template>
</VListItem>
</VList>
</VMenu>
@@ -707,6 +760,7 @@ onUnmounted(() => {
</VMenu>
<!-- !SECTION -->
</VAvatar>
<ThemeCustomizer v-model="showThemeCustomizer" />
</template>
<style lang="scss" scoped>

View File

@@ -158,6 +158,29 @@ export default {
customCssSaveSuccess: 'Custom CSS saved successfully, please refresh the page to take effect!',
customCssSaveFailed: 'Failed to save custom CSS to server',
deviceNotSupport: 'Current device does not support monitoring system theme changes',
customizer: {
title: 'Theme Customizer',
subtitle: 'Customize & Preview in Real Time',
theming: 'Theming',
primaryColor: 'Primary Color',
usePrimaryColor: 'Use {color} primary color',
chooseCustomColor: 'Choose custom primary color',
skins: 'Framework',
skinDefault: 'Default',
skinBordered: 'Bordered',
shadow: 'Shadows',
shadowNone: 'Flat',
shadowLow: 'Soft',
shadowMedium: 'Balanced',
shadowHigh: 'Bold',
semiDarkMenu: 'Semi Dark Menu',
layout: 'Layout',
layoutVertical: 'Vertical',
layoutCollapsed: 'Collapsed',
layoutHorizontal: 'Horizontal',
reset: 'Reset theme customizer',
appModeLayoutLocked: 'App mode keeps mobile navigation fixed. Switch to desktop UI mode to customize layout.',
},
},
app: {
moviepilot: 'MoviePilot',
@@ -1378,6 +1401,18 @@ export default {
expand: 'Expand',
collapse: 'Collapse',
clearCache: 'Clear Cache',
versionStatistic: 'Version Statistics',
versionStatisticTitle: 'Installation Version Statistics',
totalInstallUsers: 'Install Users',
activeToday: 'Active Today',
active7Days: 'Active 7 Days',
active30Days: 'Active 30 Days',
backendVersionStatistic: 'Backend Versions',
frontendVersionStatistic: 'Frontend Versions',
version: 'Version',
users: 'Users',
lastUpdated: 'Updated At',
noVersionStatisticData: 'No statistics data',
},
system: {
custom: 'Custom',
@@ -1444,6 +1479,11 @@ export default {
llmApiKeyPlaceholder: 'Please enter API key',
llmBaseUrl: 'LLM Base URL',
llmBaseUrlHint: 'Base URL for LLM API, used for custom API endpoints',
llmUseProxy: 'Use System Proxy',
llmUseProxyHint:
'When enabled, Agent connections to the current LLM provider use the system proxy from advanced settings.',
llmUserAgent: 'User-Agent',
llmUserAgentHint: 'User-Agent sent to OpenAI-compatible APIs. Leave empty to use the SDK default.',
llmProviderAuth: 'Provider Authorization',
llmProviderAuthHint:
'Providers that support account authorization can complete sign-in here and reuse the saved auth state.',
@@ -1458,15 +1498,16 @@ export default {
llmProviderCheckAuthStatus: 'Check Authorization Status',
audioInputProvider: 'Audio Input Provider',
audioInputProviderHint:
'Service used to transcribe incoming audio messages. Supports OpenAI audio, Chat Audio compatible APIs, and Xiaomi MiMo.',
'Service used to transcribe incoming audio messages. Supports OpenAI audio, Chat Audio compatible APIs, Xiaomi MiMo, and MiniMax.',
audioProviderOpenAiAudio: 'OpenAI Audio Compatible',
audioProviderChatAudio: 'Chat Audio Compatible',
audioProviderMimo: 'Xiaomi MiMo',
audioProviderMinimax: 'MiniMax',
audioInputApiKey: 'Audio Input API Key',
audioInputApiKeyHint: 'API key used for audio transcription.',
audioInputBaseUrl: 'Audio Input Base URL',
audioInputBaseUrlHint:
'Base URL for audio input. Use the matching compatible endpoint for Chat Audio services; MiMo defaults to https://api.xiaomimimo.com/v1.',
'Base URL for audio input. Use the matching compatible endpoint for Chat Audio services; MiMo defaults to https://api.xiaomimimo.com/v1, MiniMax defaults to https://api.minimaxi.com/v1.',
audioInputModel: 'Audio Input Model',
audioInputModelHint: 'Model name used to convert audio content into text.',
audioInputLanguage: 'Recognition Language',
@@ -1474,12 +1515,12 @@ export default {
'Default language for audio transcription, such as zh or en. Leave blank to use the backend default.',
audioOutputProvider: 'Audio Output Provider',
audioOutputProviderHint:
'Service used to generate voice replies. Supports OpenAI audio, Chat Audio compatible APIs, and Xiaomi MiMo.',
'Service used to generate voice replies. Supports OpenAI audio, Chat Audio compatible APIs, Xiaomi MiMo, and MiniMax.',
audioOutputApiKey: 'Audio Output API Key',
audioOutputApiKeyHint: 'API key used for speech synthesis.',
audioOutputBaseUrl: 'Audio Output Base URL',
audioOutputBaseUrlHint:
'Base URL for audio output. Use the matching compatible endpoint for Chat Audio services; MiMo defaults to https://api.xiaomimimo.com/v1.',
'Base URL for audio output. Use the matching compatible endpoint for Chat Audio services; MiMo defaults to https://api.xiaomimimo.com/v1, MiniMax defaults to https://api.minimaxi.com/v1.',
audioOutputModel: 'Audio Output Model',
audioOutputModelHint: 'Model name used to convert text content into speech.',
audioOutputVoice: 'Voice Preset',
@@ -1561,6 +1602,9 @@ export default {
'Share subscription statistics to popular subscriptions for other MP users to reference',
pluginStatisticShare: 'Report Plugin Installation Data',
pluginStatisticShareHint: 'Report plugin installation data to the server for statistics and display purposes',
usageStatisticShare: 'Report Installation Version Statistics',
usageStatisticShareHint:
'Report anonymous installation ID and current backend/frontend versions to count users by version',
workflowStatisticShare: 'Share Workflow Data',
workflowStatisticShareHint: 'Share workflow statistics to popular workflows for other MP users to reference',
bigMemoryMode: 'Large Memory Mode',
@@ -1655,8 +1699,10 @@ export default {
encodingDetectionPerformanceModeHint:
'Prioritize detection efficiency, but may reduce encoding detection accuracy',
rustAccel: 'Rust Acceleration',
rustAccelHint: 'Use the backend Rust extension to accelerate filtering, RSS, indexer parsing, and recognition hot paths',
rustAccelUnavailableHint: 'The backend Rust acceleration extension is not installed or loaded, so this cannot be enabled',
rustAccelHint:
'Use the backend Rust extension to accelerate filtering, RSS, indexer parsing, and recognition hot paths',
rustAccelUnavailableHint:
'The backend Rust acceleration extension is not installed or loaded, so this cannot be enabled',
transferThreads: 'File Transfer Threads',
transferThreadsHint: 'Multi-threaded file transfer can improve speed but may increase system resource usage',
tokenizedSearch: 'Tokenized Search',
@@ -2374,6 +2420,7 @@ export default {
namePlaceholder: 'Workflow name',
desc: 'Description',
descPlaceholder: 'Workflow description',
maxWorkers: 'Max Parallel Actions',
enabled: 'Enabled',
triggerType: 'Trigger Type',
triggerTypeTimer: 'Timer Trigger',
@@ -2424,6 +2471,18 @@ export default {
importSuccess: 'Import successful!',
importFailed: 'Import failed!',
codeCopied: 'Task workflow code copied to clipboard!',
edgeSettingsTitle: 'Flow Condition',
edgeConditionLabel: 'Condition',
conditionAlways: 'Always continue',
conditionHasOutput: 'Has {field} output',
conditionNoOutput: 'No {field} output',
conditionHasValue: '{field} has value',
conditionCustom: 'Custom condition (preserved)',
edgeNoConditionFields: 'The previous node has no output available for conditions',
edgeConditionalLabel: 'Condition',
edgeSave: 'Save',
edgeCancel: 'Cancel',
edgeSaveSuccess: 'Flow condition saved',
},
siteCookieUpdate: {
title: 'Update Site Cookie & UA',
@@ -2431,6 +2490,7 @@ export default {
updating: 'Updating {site} Cookie & UA...',
success: '{site} Cookie & UA updated successfully!',
failed: '{site} update failed: {message}',
requestFailed: 'Request failed, please try again later',
updateButton: 'Start Update',
},
siteAddEdit: {
@@ -2469,11 +2529,19 @@ export default {
title: 'Plugin Market Settings',
repoUrl: 'Plugin Repository URL',
repoPlaceholder: 'Format: https://github.com/jxxghp/MoviePilot-Plugins/,https://github.com/xxxx/xxxxxx/',
repoHint: 'Multiple URLs separated by lines, only Github repositories are supported',
repoHint: 'Separate multiple URLs with new lines or commas',
urlPlaceholder: 'Enter plugin repository URL',
textPlaceholder: 'https://github.com/jxxghp/MoviePilot-Plugins/\nhttps://github.com/xxxx/xxxxxx/',
listMode: 'List',
textMode: 'Text',
textHint: 'Paste repository URLs one per line or separated by commas.',
addRepo: 'Add repository',
noRepos: 'No plugin repository URLs',
invalidUrl: 'Please enter a valid URL',
duplicateUrl: 'This URL already exists',
invalidText: 'There are {count} invalid URLs in the text. Fix them before saving.',
invalidTextIgnored: '{count} invalid URLs ignored',
duplicateTextIgnored: 'Duplicate URLs will be removed automatically when saving.',
close: 'Close',
save: 'Save',
saveSuccess: 'Plugin repository saved successfully',
@@ -2559,7 +2627,8 @@ export default {
mediaIdHint: 'Query media ID by name, leave empty for auto recognition',
mediaIdPlaceholder: 'Leave empty for auto recognition',
episodeGroup: 'Episode Group',
episodeGroupHint: 'After entering a TMDB ID, episode groups are queried automatically; group IDs can still be entered manually',
episodeGroupHint:
'After entering a TMDB ID, episode groups are queried automatically; group IDs can still be entered manually',
episodeGroupPlaceholder: 'Enter TMDB ID first',
defaultEpisodeGroup: 'No episode group',
defaultEpisodeGroupHint: 'Use TMDB default season and episode order',
@@ -2584,6 +2653,10 @@ export default {
episodeFormatRecommendFailed: 'Failed to generate episode format, please try again later',
episodeFormatRecommendRule: 'Matched Rule: {rule}',
episodeFormatRecommendSample: 'Sample File: {file}',
episodeFormatRuleDetails: 'Manual Episode Positioning Rule',
episodeFormatFinal: 'Final Episode Positioning: {format}',
episodeFormatManualInput: 'Manual Episode Positioning Input',
episodeFormatRulePattern: 'Rule Regex: {pattern}',
episodeOffset: 'Episode Offset',
episodeOffsetHint: 'Episode offset calculation, e.g. -10 or EP*2',
episodeOffsetPlaceholder: 'e.g. -10',
@@ -2614,6 +2687,8 @@ export default {
previewSeasonInfo: 'Season',
previewSeasonLabel: 'Season',
previewEpisodeCount: 'Episodes',
customWordsApplied: 'Recognition Word Details',
customWordsSameRules: '{count} files applied the same recognition words',
previewAfterColumn: 'After',
previewBeforeColumn: 'Before',
previewFileNameColumn: 'Filename',
@@ -2865,6 +2940,7 @@ export default {
settings: 'Settings',
projectHome: 'Project Home',
updateHistory: 'Update History',
versionHistory: 'Version History',
local: 'Local',
systemVersion: 'System Version',
incompatibleSystemVersion: 'The current MoviePilot version does not meet this plugin requirement.',
@@ -2881,7 +2957,9 @@ export default {
'This operation will restore plugin {name} to default settings and clear all related data. Are you sure you want to continue?',
resetSuccess: 'Plugin {name} data has been reset',
resetFailed: 'Plugin {name} reset failed: {message}',
updateHistoryTitle: '{name} Update History',
updateHistoryTitle: '{name} Version History',
updateHistoryEmpty: 'No update history available yet',
updateHistoryLoadFailed: 'Failed to load update history. Please try again later.',
updateToLatest: 'Update to Latest Version',
updatingTo: 'Updating {name} to v{version} ...',
folderNameEmpty: 'Folder name cannot be empty',

View File

@@ -158,6 +158,29 @@ export default {
customCssSaveSuccess: '自定义CSS保存成功请刷新页面生效',
customCssSaveFailed: '保存自定义CSS到服务端失败',
deviceNotSupport: '当前设备不支持监听系统主题变化',
customizer: {
title: '主题定制',
subtitle: '实时自定义与预览',
theming: '主题',
primaryColor: '色调',
usePrimaryColor: '使用 {color} 色调',
chooseCustomColor: '选择自定义色调',
skins: '框架',
skinDefault: '默认',
skinBordered: '边框',
shadow: '阴影',
shadowNone: '无阴影',
shadowLow: '柔和',
shadowMedium: '标准',
shadowHigh: '强烈',
semiDarkMenu: '半暗菜单',
layout: '布局',
layoutVertical: '垂直',
layoutCollapsed: '折叠',
layoutHorizontal: '水平',
reset: '重置主题定制',
appModeLayoutLocked: 'App 模式固定使用移动端导航,请切换到桌面界面后再调整布局。',
},
},
app: {
moviepilot: 'MoviePilot',
@@ -1373,6 +1396,18 @@ export default {
expand: '展开',
collapse: '收起',
clearCache: '清除缓存',
versionStatistic: '版本统计',
versionStatisticTitle: '安装版本统计',
totalInstallUsers: '安装用户',
activeToday: '今日活跃',
active7Days: '7日活跃',
active30Days: '30日活跃',
backendVersionStatistic: '后端版本',
frontendVersionStatistic: '前端版本',
version: '版本',
users: '用户数',
lastUpdated: '更新时间',
noVersionStatisticData: '暂无统计数据',
},
system: {
custom: '自定义',
@@ -1436,6 +1471,10 @@ export default {
llmApiKeyPlaceholder: '请输入API密钥',
llmBaseUrl: 'LLM基础URL',
llmBaseUrlHint: 'LLM API的基础URL地址用于自定义API端点',
llmUseProxy: '使用系统代理',
llmUseProxyHint: '启用后Agent 连接当前 LLM 提供商时会应用高级设置中的系统代理',
llmUserAgent: 'User-Agent',
llmUserAgentHint: 'OpenAI 兼容接口请求使用的 User-Agent留空则使用 SDK 默认值',
llmProviderAuth: '提供商授权',
llmProviderAuthHint: '支持账号登录授权的提供商,可以直接在这里完成登录并复用授权状态。',
llmProviderConnectedAs: '当前已连接:{label}',
@@ -1447,26 +1486,29 @@ export default {
llmProviderOpenAuthPage: '打开授权页面',
llmProviderCheckAuthStatus: '检查授权状态',
audioInputProvider: '音频输入提供商',
audioInputProviderHint: '用于识别用户音频消息的服务,支持 OpenAI 音频接口、Chat Audio 兼容接口和 Xiaomi MiMo',
audioInputProviderHint:
'用于识别用户音频消息的服务,支持 OpenAI 音频接口、Chat Audio 兼容接口、Xiaomi MiMo 和 MiniMax',
audioProviderOpenAiAudio: 'OpenAI Audio 兼容',
audioProviderChatAudio: 'Chat Audio 兼容',
audioProviderMimo: '小米 MiMo',
audioProviderMinimax: 'MiniMax',
audioInputApiKey: '音频输入 API密钥',
audioInputApiKeyHint: '音频输入转写使用的 API 密钥',
audioInputBaseUrl: '音频输入基础URL',
audioInputBaseUrlHint:
'音频输入接口基础URLChat Audio 类服务可填写对应兼容地址MiMo 默认 https://api.xiaomimimo.com/v1',
'音频输入接口基础URLChat Audio 类服务可填写对应兼容地址MiMo 默认 https://api.xiaomimimo.com/v1MiniMax 默认 https://api.minimaxi.com/v1',
audioInputModel: '音频输入模型',
audioInputModelHint: '用于将音频内容转换为文字的模型名称',
audioInputLanguage: '识别语言',
audioInputLanguageHint: '音频转写默认语言,例如 zh、en留空时按后端默认处理',
audioOutputProvider: '音频输出提供商',
audioOutputProviderHint: '用于生成语音回复的服务,支持 OpenAI 音频接口、Chat Audio 兼容接口和 Xiaomi MiMo',
audioOutputProviderHint:
'用于生成语音回复的服务,支持 OpenAI 音频接口、Chat Audio 兼容接口、Xiaomi MiMo 和 MiniMax',
audioOutputApiKey: '音频输出 API密钥',
audioOutputApiKeyHint: '文字转语音使用的 API 密钥',
audioOutputBaseUrl: '音频输出基础URL',
audioOutputBaseUrlHint:
'音频输出接口基础URLChat Audio 类服务可填写对应兼容地址MiMo 默认 https://api.xiaomimimo.com/v1',
'音频输出接口基础URLChat Audio 类服务可填写对应兼容地址MiMo 默认 https://api.xiaomimimo.com/v1MiniMax 默认 https://api.minimaxi.com/v1',
audioOutputModel: '音频输出模型',
audioOutputModelHint: '用于将文字内容转换为语音的模型名称',
audioOutputVoice: '语音音色',
@@ -1545,6 +1587,8 @@ export default {
subscribeStatisticShareHint: '分享订阅统计数据到热门订阅供其他MPer参考',
pluginStatisticShare: '上报插件安装数据',
pluginStatisticShareHint: '上报插件安装数据给服务器,用于统计展示插件安装情况',
usageStatisticShare: '上报安装版本统计',
usageStatisticShareHint: '上报匿名安装ID和当前前后端版本用于统计各版本安装用户数',
workflowStatisticShare: '分享工作流数据',
workflowStatisticShareHint: '分享工作流统计数据到热门工作流供其他MPer参考',
bigMemoryMode: '大内存模式',
@@ -2328,6 +2372,7 @@ export default {
namePlaceholder: '工作流名称',
desc: '描述',
descPlaceholder: '工作流描述',
maxWorkers: '最大并行数',
enabled: '启用',
triggerType: '触发类型',
triggerTypeTimer: '定时触发',
@@ -2378,6 +2423,18 @@ export default {
importSuccess: '导入成功!',
importFailed: '导入失败!',
codeCopied: '任务流程代码已复制到剪贴板!',
edgeSettingsTitle: '流程条件',
edgeConditionLabel: '流转条件',
conditionAlways: '无条件流转',
conditionHasOutput: '有{field}输出',
conditionNoOutput: '没有{field}输出',
conditionHasValue: '{field}有值',
conditionCustom: '自定义条件(保留现有)',
edgeNoConditionFields: '上一节点没有可用于条件判断的输出',
edgeConditionalLabel: '条件',
edgeSave: '保存',
edgeCancel: '取消',
edgeSaveSuccess: '流程条件已保存',
},
siteCookieUpdate: {
title: '更新站点Cookie & UA',
@@ -2385,6 +2442,7 @@ export default {
updating: '正在更新 {site} Cookie & UA...',
success: '{site} 更新Cookie & UA成功',
failed: '{site} 更新失败:{message}',
requestFailed: '请求失败,请稍后重试',
updateButton: '开始更新',
},
siteAddEdit: {
@@ -2423,11 +2481,19 @@ export default {
title: '插件市场设置',
repoUrl: '插件仓库地址',
repoPlaceholder: '格式https://github.com/jxxghp/MoviePilot-Plugins/,https://github.com/xxxx/xxxxxx/',
repoHint: '多个地址使用换行分隔仅支持Github仓库',
repoHint: '多个地址使用换行或英文逗号分隔',
urlPlaceholder: '输入插件仓库地址',
textPlaceholder: 'https://github.com/jxxghp/MoviePilot-Plugins/\nhttps://github.com/xxxx/xxxxxx/',
listMode: '列表维护',
textMode: '文本维护',
textHint: '直接粘贴仓库地址串,一行一个或使用英文逗号分隔。',
addRepo: '添加仓库',
noRepos: '暂无插件仓库地址',
invalidUrl: '请输入有效的URL地址',
duplicateUrl: '该地址已存在',
invalidText: '文本中有 {count} 个无效地址,请修正后保存。',
invalidTextIgnored: '已忽略 {count} 个无效地址',
duplicateTextIgnored: '重复地址会在保存时自动去重。',
close: '关闭',
save: '保存',
saveSuccess: '插件仓库保存成功',
@@ -2537,6 +2603,10 @@ export default {
episodeFormatRecommendFailed: '集数定位生成失败,请稍后重试',
episodeFormatRecommendRule: '命中规则:{rule}',
episodeFormatRecommendSample: '样本文件:{file}',
episodeFormatRuleDetails: '手动整理集数定位规则',
episodeFormatFinal: '最终集数定位:{format}',
episodeFormatManualInput: '手动输入集数定位',
episodeFormatRulePattern: '规则正则:{pattern}',
episodeOffset: '集数偏移',
episodeOffsetHint: '集数偏移运算,如-10或EP*2',
episodeOffsetPlaceholder: '如-10',
@@ -2567,6 +2637,8 @@ export default {
previewSeasonInfo: '季信息',
previewSeasonLabel: '季',
previewEpisodeCount: '总集数',
customWordsApplied: '识别词应用详情',
customWordsSameRules: '{count} 个文件应用相同识别词',
previewAfterColumn: '整理后',
previewBeforeColumn: '整理前',
previewFileNameColumn: '文件名',
@@ -2818,6 +2890,7 @@ export default {
settings: '设置',
projectHome: '项目主页',
updateHistory: '更新说明',
versionHistory: '版本历史',
local: '本地',
systemVersion: '系统版本',
incompatibleSystemVersion: '当前 MoviePilot 版本不满足插件要求,无法安装',
@@ -2833,7 +2906,9 @@ export default {
confirmReset: '此操作将恢复插件 {name} 的默认设置,并清除所有相关数据,确定要继续吗?',
resetSuccess: '插件 {name} 数据已重置',
resetFailed: '插件 {name} 重置失败:{message}',
updateHistoryTitle: '{name} 更新说明',
updateHistoryTitle: '{name} 版本历史',
updateHistoryEmpty: '暂未获取到更新说明',
updateHistoryLoadFailed: '读取更新说明失败,请稍后重试',
updateToLatest: '更新到最新版本',
updatingTo: '更新 {name} 到 {version} 版本...',
folderNameEmpty: '文件夹名称不能为空',

View File

@@ -158,6 +158,29 @@ export default {
customCssSaveSuccess: '自定義CSS保存成功請刷新頁面生效',
customCssSaveFailed: '保存自定義CSS到服務端失敗',
deviceNotSupport: '當前設備不支持監聽系統主題變化',
customizer: {
title: '主題定制',
subtitle: '即時自定義與預覽',
theming: '主題',
primaryColor: '色调',
usePrimaryColor: '使用 {color} 色調',
chooseCustomColor: '選擇自定義色調',
skins: '框架',
skinDefault: '默認',
skinBordered: '邊框',
shadow: '陰影',
shadowNone: '無陰影',
shadowLow: '柔和',
shadowMedium: '標準',
shadowHigh: '強烈',
semiDarkMenu: '半暗菜單',
layout: '佈局',
layoutVertical: '垂直',
layoutCollapsed: '折疊',
layoutHorizontal: '水平',
reset: '重置主題定制',
appModeLayoutLocked: 'App 模式固定使用移動端導航,請切換到桌面界面後再調整佈局。',
},
},
app: {
moviepilot: 'MoviePilot',
@@ -1374,6 +1397,18 @@ export default {
expand: '展開',
collapse: '收起',
clearCache: '清除快取',
versionStatistic: '版本統計',
versionStatisticTitle: '安裝版本統計',
totalInstallUsers: '安裝用戶',
activeToday: '今日活躍',
active7Days: '7日活躍',
active30Days: '30日活躍',
backendVersionStatistic: '後端版本',
frontendVersionStatistic: '前端版本',
version: '版本',
users: '用戶數',
lastUpdated: '更新時間',
noVersionStatisticData: '暫無統計數據',
},
system: {
custom: '自定義',
@@ -1437,6 +1472,10 @@ export default {
llmApiKeyPlaceholder: '請輸入API密鑰',
llmBaseUrl: 'LLM基礎URL',
llmBaseUrlHint: 'LLM API的基礎URL地址用於自定義API端點',
llmUseProxy: '使用系統代理',
llmUseProxyHint: '啟用後Agent 連接目前 LLM 提供商時會套用進階設定中的系統代理',
llmUserAgent: 'User-Agent',
llmUserAgentHint: 'OpenAI 兼容接口請求使用的 User-Agent留空則使用 SDK 預設值',
llmProviderAuth: '提供商授權',
llmProviderAuthHint: '支援帳號登入授權的提供商,可以直接在這裡完成登入並重用授權狀態。',
llmProviderConnectedAs: '目前已連接:{label}',
@@ -1448,26 +1487,29 @@ export default {
llmProviderOpenAuthPage: '開啟授權頁面',
llmProviderCheckAuthStatus: '檢查授權狀態',
audioInputProvider: '音頻輸入提供商',
audioInputProviderHint: '用於識別用戶音頻消息的服務,支援 OpenAI 音頻接口、Chat Audio 兼容接口和 Xiaomi MiMo',
audioInputProviderHint:
'用於識別用戶音頻消息的服務,支援 OpenAI 音頻接口、Chat Audio 兼容接口、Xiaomi MiMo 和 MiniMax',
audioProviderOpenAiAudio: 'OpenAI Audio 兼容',
audioProviderChatAudio: 'Chat Audio 兼容',
audioProviderMimo: '小米 MiMo',
audioProviderMinimax: 'MiniMax',
audioInputApiKey: '音頻輸入 API密鑰',
audioInputApiKeyHint: '音頻輸入轉寫使用的 API 密鑰',
audioInputBaseUrl: '音頻輸入基礎URL',
audioInputBaseUrlHint:
'音頻輸入接口基礎URLChat Audio 類服務可填寫對應兼容地址MiMo 預設 https://api.xiaomimimo.com/v1',
'音頻輸入接口基礎URLChat Audio 類服務可填寫對應兼容地址MiMo 預設 https://api.xiaomimimo.com/v1MiniMax 預設 https://api.minimaxi.com/v1',
audioInputModel: '音頻輸入模型',
audioInputModelHint: '用於將音頻內容轉換為文字的模型名稱',
audioInputLanguage: '識別語言',
audioInputLanguageHint: '音頻轉寫預設語言,例如 zh、en留空時按後端預設處理',
audioOutputProvider: '音頻輸出提供商',
audioOutputProviderHint: '用於生成語音回覆的服務,支援 OpenAI 音頻接口、Chat Audio 兼容接口和 Xiaomi MiMo',
audioOutputProviderHint:
'用於生成語音回覆的服務,支援 OpenAI 音頻接口、Chat Audio 兼容接口、Xiaomi MiMo 和 MiniMax',
audioOutputApiKey: '音頻輸出 API密鑰',
audioOutputApiKeyHint: '文字轉語音使用的 API 密鑰',
audioOutputBaseUrl: '音頻輸出基礎URL',
audioOutputBaseUrlHint:
'音頻輸出接口基礎URLChat Audio 類服務可填寫對應兼容地址MiMo 預設 https://api.xiaomimimo.com/v1',
'音頻輸出接口基礎URLChat Audio 類服務可填寫對應兼容地址MiMo 預設 https://api.xiaomimimo.com/v1MiniMax 預設 https://api.minimaxi.com/v1',
audioOutputModel: '音頻輸出模型',
audioOutputModelHint: '用於將文字內容轉換為語音的模型名稱',
audioOutputVoice: '語音音色',
@@ -1546,6 +1588,8 @@ export default {
subscribeStatisticShareHint: '分享訂閱統計數據到熱門訂閱供其他MPer參考',
pluginStatisticShare: '上報插件安裝數據',
pluginStatisticShareHint: '上報插件安裝數據給服務器,用於統計展示插件安裝情況',
usageStatisticShare: '上報安裝版本統計',
usageStatisticShareHint: '上報匿名安裝ID和當前前後端版本用於統計各版本安裝用戶數',
workflowStatisticShare: '分享工作流數據',
workflowStatisticShareHint: '分享工作流統計數據到熱門工作流供其他MPer參考',
bigMemoryMode: '大內存模式',
@@ -2329,6 +2373,7 @@ export default {
namePlaceholder: '工作流名稱',
desc: '描述',
descPlaceholder: '工作流描述',
maxWorkers: '最大並行數',
enabled: '啟用',
triggerType: '觸發類型',
triggerTypeTimer: '定時觸發',
@@ -2379,6 +2424,18 @@ export default {
importSuccess: '匯入成功!',
importFailed: '匯入失敗!',
codeCopied: '任務流程代碼已複製到剪貼簿!',
edgeSettingsTitle: '流程條件',
edgeConditionLabel: '流轉條件',
conditionAlways: '無條件流轉',
conditionHasOutput: '有{field}輸出',
conditionNoOutput: '沒有{field}輸出',
conditionHasValue: '{field}有值',
conditionCustom: '自訂條件(保留現有)',
edgeNoConditionFields: '上一節點沒有可用於條件判斷的輸出',
edgeConditionalLabel: '條件',
edgeSave: '儲存',
edgeCancel: '取消',
edgeSaveSuccess: '流程條件已儲存',
},
siteCookieUpdate: {
title: '更新站點Cookie & UA',
@@ -2386,6 +2443,7 @@ export default {
updating: '正在更新 {site} Cookie & UA...',
success: '{site} 更新Cookie & UA成功',
failed: '{site} 更新失敗:{message}',
requestFailed: '請求失敗,請稍後重試',
updateButton: '開始更新',
},
siteAddEdit: {
@@ -2424,11 +2482,19 @@ export default {
title: '插件市場設置',
repoUrl: '插件倉庫地址',
repoPlaceholder: '格式https://github.com/jxxghp/MoviePilot-Plugins/,https://github.com/xxxx/xxxxxx/',
repoHint: '多個地址使用换行分隔僅支援Github倉庫',
repoHint: '多個地址使用換行或英文逗號分隔',
urlPlaceholder: '輸入插件倉庫地址',
textPlaceholder: 'https://github.com/jxxghp/MoviePilot-Plugins/\nhttps://github.com/xxxx/xxxxxx/',
listMode: '列表維護',
textMode: '文字維護',
textHint: '直接貼上倉庫地址串,一行一個或使用英文逗號分隔。',
addRepo: '新增倉庫',
noRepos: '暫無插件倉庫地址',
invalidUrl: '請輸入有效的URL地址',
duplicateUrl: '該地址已存在',
invalidText: '文字中有 {count} 個無效地址,請修正後儲存。',
invalidTextIgnored: '已忽略 {count} 個無效地址',
duplicateTextIgnored: '重複地址會在儲存時自動去重。',
close: '關閉',
save: '儲存',
saveSuccess: '插件倉庫儲存成功',
@@ -2538,6 +2604,10 @@ export default {
episodeFormatRecommendFailed: '集數定位生成失敗,請稍後重試',
episodeFormatRecommendRule: '命中規則:{rule}',
episodeFormatRecommendSample: '樣本文件:{file}',
episodeFormatRuleDetails: '手動整理集數定位規則',
episodeFormatFinal: '最終集數定位:{format}',
episodeFormatManualInput: '手動輸入集數定位',
episodeFormatRulePattern: '規則正則:{pattern}',
episodeOffset: '集數偏移',
episodeOffsetHint: '集數偏移運算,如-10或EP*2',
episodeOffsetPlaceholder: '如-10',
@@ -2568,6 +2638,8 @@ export default {
previewSeasonInfo: '季資訊',
previewSeasonLabel: '季',
previewEpisodeCount: '總集數',
customWordsApplied: '識別詞應用詳情',
customWordsSameRules: '{count} 個文件套用相同識別詞',
previewAfterColumn: '整理後',
previewBeforeColumn: '整理前',
previewFileNameColumn: '文件名',
@@ -2819,6 +2891,7 @@ export default {
settings: '設置',
projectHome: '項目主頁',
updateHistory: '更新說明',
versionHistory: '版本歷史',
local: '本地',
installToLocal: '安裝到本地',
totalDownloads: '共 {count} 次下載',
@@ -2832,7 +2905,9 @@ export default {
confirmReset: '此操作將恢復插件 {name} 的默認設置,並清除所有相關數據,確定要繼續嗎?',
resetSuccess: '插件 {name} 數據已重置',
resetFailed: '插件 {name} 重置失敗:{message}',
updateHistoryTitle: '{name} 更新說明',
updateHistoryTitle: '{name} 版本歷史',
updateHistoryEmpty: '暫未獲取到更新說明',
updateHistoryLoadFailed: '讀取更新說明失敗,請稍後重試',
updateToLatest: '更新到最新版本',
updatingTo: '正在更新 {name} 至 v{version} ...',
folderNameEmpty: '文件夾名稱不能為空',

View File

@@ -87,7 +87,7 @@ function initDiscoverTabs() {
const tabs = getDiscoverTabs(t)
for (const tab of tabs) {
discoverTabs.value.push({
name: tab.name,
name: tab.title,
mediaid_prefix: tab.tab,
api_path: '',
filter_params: {},

View File

@@ -1,4 +1,5 @@
<script setup lang="ts">
import type { Component } from 'vue'
import { VForm } from 'vuetify/components/VForm'
import { useAuthStore, useUserStore } from '@/stores'
import { authState, userState } from '@/stores/types'
@@ -14,6 +15,7 @@ import { getNavMenus } from '@/router/i18n-menu'
import { filterMenusByPermission } from '@/utils/permission'
import type { ApiResponse } from '@/api/types'
import { openSharedDialog } from '@/composables/useSharedDialog'
import { loadRemoteComponentFromModule, type RemoteModule } from '@/utils/federationLoader'
const LoginMfaDialog = defineAsyncComponent(() => import('@/components/dialog/LoginMfaDialog.vue'))
@@ -87,6 +89,38 @@ let manualAbortController: AbortController | null = null
// 标记当前是否有手动模式的 PassKey 请求正在进行
let isManualPassKeyActive = false
interface LoginAuthProvider {
id: string
type: 'system' | 'plugin'
method?: string
name: string
icon?: string
enabled?: boolean
plugin_id?: string
component?: string
remote?: RemoteModule
}
interface PluginAuthPayload {
ticket?: string
}
// 登录认证提供方
const authProviders = ref<LoginAuthProvider[]>([])
const selectedAuthProvider = ref<LoginAuthProvider | null>(null)
const RemoteAuthView = shallowRef<Component | null>(null)
const pluginAuthDialog = ref(false)
const pluginAuthLoading = ref(false)
const pluginAuthError = ref('')
const systemPasskeyProvider = computed(() =>
authProviders.value.find(provider => provider.type === 'system' && provider.method === 'passkey'),
)
const pluginAuthProviders = computed(() =>
authProviders.value.filter(provider => provider.type === 'plugin' && provider.remote && provider.enabled !== false),
)
const showPasskeyLogin = computed(() => !!systemPasskeyProvider.value?.enabled)
// 生成 MFA 共享弹窗使用的最新 props。
function getMfaDialogProps() {
return {
@@ -127,6 +161,75 @@ function closeMfaDialog() {
mfaDialogController = null
}
// 加载未登录可用的认证提供方。
async function loadAuthProviders() {
try {
const result = (await api.get('auth/providers')) as LoginAuthProvider[]
authProviders.value = Array.isArray(result) ? result : []
} catch (error) {
console.error('加载认证提供方失败:', error)
authProviders.value = []
}
}
// 打开插件认证联邦页面。
async function openPluginAuth(provider: LoginAuthProvider) {
if (!provider.remote) return
selectedAuthProvider.value = provider
RemoteAuthView.value = null
pluginAuthError.value = ''
pluginAuthLoading.value = true
pluginAuthDialog.value = true
try {
RemoteAuthView.value = (await loadRemoteComponentFromModule(
provider.remote,
provider.component || 'AuthPage',
)) as Component
} catch (error: any) {
console.error('加载插件认证页面失败:', error)
pluginAuthError.value = error?.message || t('login.authFailure')
} finally {
pluginAuthLoading.value = false
}
}
// 关闭插件认证弹窗。
function closePluginAuth() {
pluginAuthDialog.value = false
selectedAuthProvider.value = null
RemoteAuthView.value = null
pluginAuthError.value = ''
}
// 兑换插件认证票据并完成系统登录。
async function exchangePluginAuthTicket(ticket: string) {
pluginAuthLoading.value = true
try {
const response: any = await api.post('auth/exchange', { ticket })
closePluginAuth()
await handleLoginSuccess(response)
} catch (error: any) {
console.error('插件认证票据兑换失败:', error)
pluginAuthError.value = error?.response?.data?.detail || error?.message || t('login.authFailure')
} finally {
pluginAuthLoading.value = false
}
}
// 处理插件认证成功事件。
async function handlePluginAuthenticated(payload: PluginAuthPayload) {
if (!payload?.ticket) {
pluginAuthError.value = t('login.authFailure')
return
}
await exchangePluginAuthTicket(payload.ticket)
}
// 处理插件认证失败事件。
function handlePluginAuthError(error: any) {
pluginAuthError.value = error?.message || String(error || '') || t('login.authFailure')
}
// PassKey 认证核心函数 - 处理 WebAuthn 认证流程
interface PassKeyAuthOptions {
username?: string // 可选的用户名,用于 MFA 场景
@@ -518,6 +621,9 @@ onMounted(async () => {
return
}
// 加载系统和插件声明的未登录认证入口
await loadAuthProviders()
// 初始化 Conditional UI 的 PassKey 自动填充
await initConditionalPasskey()
})
@@ -570,7 +676,7 @@ onUnmounted(() => {
<!-- 登录表单 -->
<div v-if="!mfaDialog" class="auth-wrapper d-flex align-center justify-center">
<VCard
class="auth-card px-7 pt-3 w-full h-full"
class="auth-card pa-7 w-full h-full"
:class="{ 'glass-effect': !isTransparentTheme }"
max-width="24rem"
border
@@ -657,12 +763,13 @@ onUnmounted(() => {
</VBtn>
<!-- or divider -->
<div class="or-divider my-4">
<div v-if="showPasskeyLogin || pluginAuthProviders.length > 0" class="or-divider my-4">
<span class="or-divider-text">{{ t('login.orDivider') }}</span>
</div>
<!-- passkey login button -->
<VBtn
v-if="showPasskeyLogin"
block
variant="outlined"
color="success"
@@ -673,6 +780,19 @@ onUnmounted(() => {
>
{{ t('login.loginWithPasskey') }}
</VBtn>
<VBtn
v-for="provider in pluginAuthProviders"
:key="provider.id"
block
variant="outlined"
color="primary"
class="mt-3"
:prepend-icon="provider.icon || 'mdi-login-variant'"
:loading="pluginAuthLoading && selectedAuthProvider?.id === provider.id"
@click="openPluginAuth(provider)"
>
{{ provider.name }}
</VBtn>
<VAlert v-if="errorMessage" type="error" variant="tonal" class="mt-3">
{{ errorMessage }}
</VAlert>
@@ -682,6 +802,32 @@ onUnmounted(() => {
</VCardText>
</VCard>
</div>
<VDialog v-model="pluginAuthDialog" max-width="520" persistent>
<VCard>
<VCardItem>
<VCardTitle>{{ selectedAuthProvider?.name }}</VCardTitle>
<template #append>
<VBtn icon="mdi-close" variant="text" @click="closePluginAuth" />
</template>
</VCardItem>
<VCardText>
<VSkeletonLoader v-if="pluginAuthLoading && !RemoteAuthView" type="article" />
<VAlert v-else-if="pluginAuthError" type="error" variant="tonal">
{{ pluginAuthError }}
</VAlert>
<component
v-else-if="RemoteAuthView && selectedAuthProvider"
:is="RemoteAuthView"
:api="api"
:provider="selectedAuthProvider"
:plugin-id="selectedAuthProvider.plugin_id"
@authenticated="handlePluginAuthenticated"
@error="handlePluginAuthError"
@close="closePluginAuth"
/>
</VCardText>
</VCard>
</VDialog>
</div>
</template>

View File

@@ -8,6 +8,7 @@ import { useDynamicButton } from '@/composables/useDynamicButton'
import { usePWA } from '@/composables/usePWA'
import { getItemColor, initializeItemColors } from '@/utils/colorUtils'
import { openSharedDialog } from '@/composables/useSharedDialog'
import { getRecommendTabs } from '@/router/i18n-menu'
const ContentToggleSettingsDialog = defineAsyncComponent(() => import('@/components/dialog/ContentToggleSettingsDialog.vue'))
@@ -222,34 +223,8 @@ async function saveConfig(payload?: { enabled?: Record<string, boolean> }) {
settingsDialogController = null
}
// 标签图标映射
const categoryItems = computed(() => [
{
title: t('recommend.all'),
icon: 'mdi-filmstrip-box-multiple',
tab: t('recommend.all'),
},
{
title: t('recommend.categoryMovie'),
icon: 'mdi-movie',
tab: t('recommend.categoryMovie'),
},
{
title: t('recommend.categoryTV'),
icon: 'mdi-television-classic',
tab: t('recommend.categoryTV'),
},
{
title: t('recommend.categoryAnime'),
icon: 'mdi-animation',
tab: t('recommend.categoryAnime'),
},
{
title: t('recommend.categoryRankings'),
icon: 'mdi-trophy',
tab: t('recommend.categoryRankings'),
},
])
// 推荐分类标签与导航三级菜单共用同一份定义。
const categoryItems = computed(() => getRecommendTabs(t))
// 注册动态标签页
registerHeaderTab({

View File

@@ -44,7 +44,7 @@ const { registerHeaderTab } = useDynamicHeaderTab()
// 注册动态标签页
registerHeaderTab({
items: settingTabs.value,
items: settingTabs,
modelValue: activeTab,
})

View File

@@ -56,8 +56,52 @@ const subscribeStatusFilter = ref<string | null>(null)
type SubscribeSortBy = 'custom' | 'last_update' | 'date' | 'lack_episode'
const subscribeSortStorageKeyPrefix = 'MPSubscribeSortBy'
// 获取当前订阅类型对应的排序本地存储键。
function getSubscribeSortStorageKey() {
return `${subscribeSortStorageKeyPrefix}:${subType || 'default'}`
}
// 判断排序值是否适用于当前订阅类型。
function isValidSubscribeSortBy(value: string | null): value is SubscribeSortBy {
if (!value) return false
const sortValues: SubscribeSortBy[] = ['custom', 'last_update', 'date']
if (subType !== '电影') {
sortValues.push('lack_episode')
}
return sortValues.includes(value as SubscribeSortBy)
}
// 从本地读取上次选择的订阅排序方式。
function loadSubscribeSortBy() {
try {
const storedSortBy = localStorage.getItem(getSubscribeSortStorageKey())
return isValidSubscribeSortBy(storedSortBy) ? storedSortBy : ''
} catch (error) {
console.warn('读取订阅排序方式失败:', error)
return ''
}
}
// 保存当前选择的订阅排序方式到本地。
function saveSubscribeSortBy(value: SubscribeSortBy | '') {
try {
const storageKey = getSubscribeSortStorageKey()
if (value) {
localStorage.setItem(storageKey, value)
} else {
localStorage.removeItem(storageKey)
}
} catch (error) {
console.warn('保存订阅排序方式失败:', error)
}
}
// 订阅排序方式
const subscribeSortBy = ref<SubscribeSortBy | ''>('')
const subscribeSortBy = ref<SubscribeSortBy | ''>(loadSubscribeSortBy())
// 分享搜索词
const shareKeyword = ref('')
@@ -195,6 +239,10 @@ watch(activeTab, newTab => {
}
})
watch(subscribeSortBy, newSortBy => {
saveSubscribeSortBy(newSortBy)
})
onUnmounted(() => {
shareKeywordUpdater.cancel()
})
@@ -265,7 +313,7 @@ const { registerHeaderTab } = useDynamicHeaderTab()
// 注册动态标签页
registerHeaderTab({
items: subscribeTabs.value,
items: subscribeTabs,
modelValue: activeTab,
appendButtons: [
{

View File

@@ -69,7 +69,7 @@ const { registerHeaderTab } = useDynamicHeaderTab()
// 注册动态标签页
registerHeaderTab({
items: workflowTabs.value,
items: workflowTabs,
modelValue: activeTab,
appendButtons: [
{

View File

@@ -1,4 +1,5 @@
import { useGlobalSettingsStore } from '@/stores'
import type { NavMenuTabItem } from '@/@layouts/types'
import type { Composer } from 'vue-i18n'
// 构建路由菜单,每次调用时使用当前的语言环境
@@ -34,6 +35,7 @@ export function getNavMenus(t: Composer['t']) {
admin: false,
footer: true,
permission: 'discovery',
tabs: getRecommendTabs(t),
},
{
title: t('navItems.explore'),
@@ -43,6 +45,7 @@ export function getNavMenus(t: Composer['t']) {
admin: false,
footer: true,
permission: 'discovery',
tabs: getDiscoverTabs(t),
},
{
title: t('navItems.movie'),
@@ -53,6 +56,7 @@ export function getNavMenus(t: Composer['t']) {
admin: false,
footer: false,
permission: 'subscribe',
tabs: getSubscribeMovieTabs(t),
},
{
title: t('navItems.tv'),
@@ -63,6 +67,7 @@ export function getNavMenus(t: Composer['t']) {
admin: false,
footer: false,
permission: 'subscribe',
tabs: getSubscribeTvTabs(t),
},
{
title: t('navItems.workflow'),
@@ -73,6 +78,7 @@ export function getNavMenus(t: Composer['t']) {
admin: true,
footer: false,
permission: 'manage',
tabs: getWorkflowTabs(t),
},
{
title: t('navItems.calendar'),
@@ -114,6 +120,7 @@ export function getNavMenus(t: Composer['t']) {
header: t('menu.system'),
admin: true,
permission: 'manage',
tabs: getPluginTabs(t),
},
{
title: t('navItems.siteManager'),
@@ -140,14 +147,26 @@ export function getNavMenus(t: Composer['t']) {
header: t('menu.system'),
admin: true,
permission: 'admin',
tabs: getSettingTabs(t),
},
]
: []),
]
}
// 获取推荐标签页
export function getRecommendTabs(t: Composer['t']): NavMenuTabItem[] {
return [
{ title: t('recommend.all'), icon: 'mdi-filmstrip-box-multiple', tab: t('recommend.all') },
{ title: t('recommend.categoryMovie'), icon: 'mdi-movie', tab: t('recommend.categoryMovie') },
{ title: t('recommend.categoryTV'), icon: 'mdi-television-classic', tab: t('recommend.categoryTV') },
{ title: t('recommend.categoryAnime'), icon: 'mdi-animation', tab: t('recommend.categoryAnime') },
{ title: t('recommend.categoryRankings'), icon: 'mdi-trophy', tab: t('recommend.categoryRankings') },
]
}
// 获取设置标签页
export function getSettingTabs(t: Composer['t']) {
export function getSettingTabs(t: Composer['t']): NavMenuTabItem[] {
return [
{
title: t('settingTabs.system.title'),
@@ -195,7 +214,7 @@ export function getSettingTabs(t: Composer['t']) {
}
// 获取电影订阅标签页
export function getSubscribeMovieTabs(t: Composer['t']) {
export function getSubscribeMovieTabs(t: Composer['t']): NavMenuTabItem[] {
return [
{
title: t('subscribeTabs.movie.mysub'),
@@ -211,7 +230,7 @@ export function getSubscribeMovieTabs(t: Composer['t']) {
}
// 获取电视剧订阅标签页
export function getSubscribeTvTabs(t: Composer['t']) {
export function getSubscribeTvTabs(t: Composer['t']): NavMenuTabItem[] {
return [
{
title: t('subscribeTabs.tv.mysub'),
@@ -232,7 +251,7 @@ export function getSubscribeTvTabs(t: Composer['t']) {
}
// 获取插件标签页
export function getPluginTabs(t: Composer['t']) {
export function getPluginTabs(t: Composer['t']): NavMenuTabItem[] {
return [
{
title: t('pluginTabs.installed'),
@@ -248,28 +267,28 @@ export function getPluginTabs(t: Composer['t']) {
}
// 获取发现标签页
export function getDiscoverTabs(t: Composer['t']) {
export function getDiscoverTabs(t: Composer['t']): NavMenuTabItem[] {
return [
{
name: t('discoverTabs.themoviedb'),
title: t('discoverTabs.themoviedb'),
tab: 'themoviedb',
icon: 'themoviedb',
icon: 'mdi-movie-search-outline',
},
{
name: t('discoverTabs.douban'),
title: t('discoverTabs.douban'),
tab: 'douban',
icon: 'douban',
icon: 'mdi-book-open-page-variant-outline',
},
{
name: t('discoverTabs.bangumi'),
title: t('discoverTabs.bangumi'),
tab: 'bangumi',
icon: 'bangumi',
icon: 'mdi-calendar-star-outline',
},
]
}
// 获取工作流标签页
export function getWorkflowTabs(t: Composer['t']) {
export function getWorkflowTabs(t: Composer['t']): NavMenuTabItem[] {
return [
{
title: t('workflowTabs.list'),

View File

@@ -148,13 +148,17 @@ registerRoute(
url.pathname.includes('/api/v1/') &&
request.method === 'GET' &&
!url.pathname.includes('/api/v1/search/') && // 搜索接口结果动态变化,避免缓存导致重复搜索失效
!url.pathname.includes('/api/v1/site/cookie/') && // 站点 Cookie 更新是副作用请求,不能缓存
!url.pathname.includes('/api/v1/system/message') && // SSE实时消息流
!url.pathname.includes('/api/v1/system/progress/') && // SSE实时进度流
!url.pathname.includes('/api/v1/system/logging') && // SSE实时日志流
!url.pathname.includes('/api/v1/message/') && // 用户消息接口
!url.pathname.includes('/api/v1/system/global') && // 系统配置接口
!url.pathname.includes('/api/v1/mfa/') && // 多因素认证接口
!url.pathname.includes('/api/v1/dashboard/'), // Dashboard实时监控数据
!url.pathname.includes('/api/v1/auth/') && // 登录认证入口与票据交换
!url.pathname.includes('/api/v1/dashboard/') && // Dashboard实时监控数据
!url.pathname.includes('/api/v1/plugin/')&& // 插件接口
!url.pathname.includes('/api/v1/subscribe/'), // 订阅接口
new NetworkFirst({
cacheName: `api-cache-${CACHE_VERSION}`,
networkTimeoutSeconds: 5,

View File

@@ -1,3 +1,6 @@
/* stylelint-disable no-duplicate-selectors */
/* stylelint-disable scss/at-rule-no-unknown */
/* stylelint-disable no-descending-specificity */
// 公共样式 - 所有主题都需要
@tailwind base;
@tailwind components;
@@ -31,8 +34,62 @@ html.v-overlay-scroll-blocked body {
// 全局卡片阴影 token卡片统一不使用投影避免透明主题和密集布局下出现脏边。
html {
--app-shadow-rgb: 15, 23, 42;
--app-card-rest-shadow: none;
--app-card-hover-shadow: none;
--app-fab-shadow: none;
--app-fab-shadow-strong: none;
--app-fab-shadow-hover: none;
--app-fab-shadow-strong-hover: none;
--app-fab-shadow-active: none;
--app-overlay-shadow: none;
--app-surface-shadow: none;
--app-surface-hover-shadow: none;
}
html[data-theme='dark'],
html[data-theme='purple'],
html[data-theme='transparent'] {
--app-shadow-rgb: 0, 0, 0;
}
html[data-theme-shadow='low'] {
--app-card-rest-shadow: 0 10px 24px rgba(var(--app-shadow-rgb), 0.06), 0 2px 8px rgba(var(--app-shadow-rgb), 0.04);
--app-card-hover-shadow: 0 14px 30px rgba(var(--app-shadow-rgb), 0.08), 0 4px 12px rgba(var(--app-shadow-rgb), 0.05);
--app-fab-shadow: 0 16px 34px rgba(var(--app-shadow-rgb), 0.16), 0 6px 16px rgba(var(--app-shadow-rgb), 0.1);
--app-fab-shadow-strong: 0 20px 40px rgba(var(--app-shadow-rgb), 0.2), 0 8px 18px rgba(var(--app-shadow-rgb), 0.12);
--app-fab-shadow-hover: 0 22px 42px rgba(var(--app-shadow-rgb), 0.22), 0 8px 18px rgba(var(--app-shadow-rgb), 0.12);
--app-fab-shadow-strong-hover: 0 26px 46px rgba(var(--app-shadow-rgb), 0.24), 0 10px 22px rgba(var(--app-shadow-rgb), 0.14);
--app-fab-shadow-active: 0 10px 22px rgba(var(--app-shadow-rgb), 0.16), 0 3px 8px rgba(var(--app-shadow-rgb), 0.1);
--app-overlay-shadow: 0 18px 42px rgba(var(--app-shadow-rgb), 0.14), 0 6px 18px rgba(var(--app-shadow-rgb), 0.08);
--app-surface-shadow: 0 10px 24px rgba(var(--app-shadow-rgb), 0.07), 0 2px 8px rgba(var(--app-shadow-rgb), 0.05);
--app-surface-hover-shadow: 0 14px 30px rgba(var(--app-shadow-rgb), 0.09), 0 4px 12px rgba(var(--app-shadow-rgb), 0.06);
}
html[data-theme-shadow='medium'] {
--app-card-rest-shadow: 0 14px 32px rgba(var(--app-shadow-rgb), 0.09), 0 4px 12px rgba(var(--app-shadow-rgb), 0.06);
--app-card-hover-shadow: 0 18px 40px rgba(var(--app-shadow-rgb), 0.11), 0 6px 16px rgba(var(--app-shadow-rgb), 0.07);
--app-fab-shadow: 0 18px 40px rgba(var(--app-shadow-rgb), 0.2), 0 7px 18px rgba(var(--app-shadow-rgb), 0.12);
--app-fab-shadow-strong: 0 24px 48px rgba(var(--app-shadow-rgb), 0.24), 0 10px 24px rgba(var(--app-shadow-rgb), 0.14);
--app-fab-shadow-hover: 0 24px 46px rgba(var(--app-shadow-rgb), 0.24), 0 10px 22px rgba(var(--app-shadow-rgb), 0.14);
--app-fab-shadow-strong-hover: 0 30px 54px rgba(var(--app-shadow-rgb), 0.28), 0 12px 28px rgba(var(--app-shadow-rgb), 0.16);
--app-fab-shadow-active: 0 12px 26px rgba(var(--app-shadow-rgb), 0.18), 0 4px 10px rgba(var(--app-shadow-rgb), 0.12);
--app-overlay-shadow: 0 24px 56px rgba(var(--app-shadow-rgb), 0.18), 0 10px 24px rgba(var(--app-shadow-rgb), 0.1);
--app-surface-shadow: 0 14px 32px rgba(var(--app-shadow-rgb), 0.1), 0 4px 12px rgba(var(--app-shadow-rgb), 0.07);
--app-surface-hover-shadow: 0 18px 40px rgba(var(--app-shadow-rgb), 0.12), 0 6px 16px rgba(var(--app-shadow-rgb), 0.08);
}
html[data-theme-shadow='high'] {
--app-card-rest-shadow: 0 18px 40px rgba(var(--app-shadow-rgb), 0.12), 0 6px 18px rgba(var(--app-shadow-rgb), 0.08);
--app-card-hover-shadow: 0 22px 50px rgba(var(--app-shadow-rgb), 0.15), 0 8px 22px rgba(var(--app-shadow-rgb), 0.1);
--app-fab-shadow: 0 22px 48px rgba(var(--app-shadow-rgb), 0.24), 0 10px 24px rgba(var(--app-shadow-rgb), 0.14);
--app-fab-shadow-strong: 0 28px 58px rgba(var(--app-shadow-rgb), 0.3), 0 12px 30px rgba(var(--app-shadow-rgb), 0.18);
--app-fab-shadow-hover: 0 28px 56px rgba(var(--app-shadow-rgb), 0.28), 0 12px 28px rgba(var(--app-shadow-rgb), 0.17);
--app-fab-shadow-strong-hover: 0 34px 64px rgba(var(--app-shadow-rgb), 0.34), 0 14px 32px rgba(var(--app-shadow-rgb), 0.2);
--app-fab-shadow-active: 0 14px 30px rgba(var(--app-shadow-rgb), 0.22), 0 5px 12px rgba(var(--app-shadow-rgb), 0.14);
--app-overlay-shadow: 0 30px 70px rgba(var(--app-shadow-rgb), 0.22), 0 14px 30px rgba(var(--app-shadow-rgb), 0.12);
--app-surface-shadow: 0 18px 40px rgba(var(--app-shadow-rgb), 0.13), 0 6px 18px rgba(var(--app-shadow-rgb), 0.09);
--app-surface-hover-shadow: 0 22px 50px rgba(var(--app-shadow-rgb), 0.16), 0 8px 22px rgba(var(--app-shadow-rgb), 0.11);
}
// 进度条样式
@@ -57,15 +114,63 @@ html {
// 统一系统内卡片阴影,显式覆盖 Vuetify elevation 或局部卡片默认投影。
.v-card,
.v-application .v-card.v-card[class] {
box-shadow: none !important;
box-shadow: var(--app-surface-shadow) !important;
transition: box-shadow 0.2s ease;
}
@media (hover: hover) {
.v-card:hover,
.v-application .v-card.v-card[class]:hover {
box-shadow: var(--app-surface-hover-shadow) !important;
}
}
// 只给外层 surface 加阴影,卡片内部的子组件保持平面,避免层级噪声。
.v-card .v-card,
.v-card .v-sheet,
.v-card .v-list,
.v-card .v-expansion-panel,
.v-card .v-table,
.v-card .v-window,
.v-card .v-toolbar,
.v-card .v-navigation-drawer,
.v-card .v-stepper,
.v-card .v-alert,
.v-card .v-avatar,
.v-card .v-chip {
box-shadow: none !important;
}
// 主题定制器的 bordered 皮肤:保持原布局密度,只给主要容器增加清晰边界。
html[data-theme-skin='bordered'] {
.v-card:not(.bg-primary),
.v-sheet,
.v-table,
.v-expansion-panel,
.v-list {
border: 1px solid rgba(var(--v-theme-on-surface), 0.1) !important;
}
.v-sheet,
.v-table,
.v-expansion-panel,
.v-list {
box-shadow: none !important;
}
.layout-vertical-nav,
.footer-nav-card {
border: 1px solid rgba(var(--v-theme-on-surface), 0.1) !important;
}
.navbar-content-container {
border-block-end: 1px solid rgba(var(--v-theme-on-surface), 0.1) !important;
}
.layout-vertical-nav {
border-block: 0 !important;
border-inline-start: 0 !important;
}
}
// 应用类信息卡片:固定右侧媒体槽位,避免图片被左侧文字挤压变形。
@@ -89,6 +194,7 @@ html {
rgba(var(--v-theme-surface), var(--app-card-surface-opacity)) !important;
box-shadow: var(--app-card-rest-shadow) !important;
color: rgb(var(--v-theme-on-surface));
--app-card-accent-rgb: var(--v-theme-primary);
--app-card-accent-end-rgb: var(--app-card-accent-rgb);
--app-card-accent-start-opacity: 0.025;
@@ -97,6 +203,7 @@ html {
--app-card-hover-border-opacity: 0.16;
--app-card-stripe-opacity: 0.22;
--app-card-surface-opacity: 0.92;
transition: border-color 0.2s ease, box-shadow 0.2s ease, transform 0.2s ease;
}
@@ -134,8 +241,9 @@ html {
html[data-theme="transparent"] .app-card-colorful,
.v-theme--transparent .app-card-colorful {
backdrop-filter: blur(var(--transparent-blur, 10px));
border: 0 !important;
backdrop-filter: blur(var(--transparent-blur, 10px));
--app-card-accent-start-opacity: 0.018;
--app-card-accent-end-opacity: 0.01;
--app-card-border-opacity: 0;
@@ -144,12 +252,6 @@ html[data-theme="transparent"] .app-card-colorful,
--app-card-surface-opacity: var(--transparent-opacity-light, 0.2);
}
html[data-theme="transparent"],
.v-theme--transparent {
--app-card-rest-shadow: none;
--app-card-hover-shadow: none;
}
// 保证卡片右上角的浮动操作区始终高于可点击的卡片内容层,避免误触发详情打开。
.app-card-top-action {
z-index: 2;
@@ -471,6 +573,10 @@ html[data-theme="transparent"],
opacity:0.75;
}
:root {
--theme-customizer-fab-offset: 420px;
}
// 紧凑型悬浮操作按钮
.compact-fab-stack {
position: fixed;
@@ -482,6 +588,7 @@ html[data-theme="transparent"],
inset-block-end: max(1rem, calc(env(safe-area-inset-bottom) + 1rem));
inset-inline-end: max(1rem, calc(env(safe-area-inset-right) + 1rem));
pointer-events: none;
transition: inset-inline-end 0.2s ease;
}
.compact-fab-stack > * {
@@ -510,9 +617,7 @@ html[data-theme="transparent"],
.compact-fab .v-btn {
border: 1px solid rgba(var(--v-theme-on-surface), 0.08);
backdrop-filter: blur(14px);
box-shadow:
0 16px 34px rgb(15 23 42 / 16%),
0 6px 16px rgb(15 23 42 / 10%);
box-shadow: var(--app-fab-shadow) !important;
opacity: 0.98;
transition:
transform 0.18s ease,
@@ -523,9 +628,7 @@ html[data-theme="transparent"],
.compact-fab--primary .v-btn {
block-size: 3rem !important;
box-shadow:
0 20px 40px rgb(15 23 42 / 20%),
0 8px 18px rgb(15 23 42 / 12%);
box-shadow: var(--app-fab-shadow-strong) !important;
inline-size: 3rem !important;
}
@@ -544,24 +647,18 @@ html[data-theme="transparent"],
@media (hover: hover) {
.compact-fab .v-btn:hover {
box-shadow:
0 22px 42px rgb(15 23 42 / 22%),
0 8px 18px rgb(15 23 42 / 12%);
box-shadow: var(--app-fab-shadow-hover) !important;
filter: saturate(1.03);
transform: translateY(-2px);
}
.compact-fab--primary .v-btn:hover {
box-shadow:
0 26px 46px rgb(15 23 42 / 24%),
0 10px 22px rgb(15 23 42 / 14%);
box-shadow: var(--app-fab-shadow-strong-hover) !important;
}
}
.compact-fab .v-btn:active {
box-shadow:
0 10px 22px rgb(15 23 42 / 16%),
0 3px 8px rgb(15 23 42 / 10%);
box-shadow: var(--app-fab-shadow-active) !important;
transform: translateY(0) scale(0.98);
}
@@ -587,6 +684,24 @@ html[data-theme="transparent"],
}
}
@media (width >= 601px) {
html[data-theme-customizer-open='true'] .compact-fab-stack {
inset-inline-end: calc(var(--theme-customizer-fab-offset) + max(1rem, calc(env(safe-area-inset-right) + 1rem)));
}
html[data-theme-customizer-open='true'] .global-action-buttons {
inset-inline-end: calc(var(--theme-customizer-fab-offset) + 2rem);
}
}
@media (width >= 601px) and (width <= 768px) {
html[data-theme-customizer-open='true'] .compact-fab-stack {
inset-inline-end: calc(
var(--theme-customizer-fab-offset) + max(0.875rem, calc(env(safe-area-inset-right) + 0.875rem))
);
}
}
.apexcharts-title-text {
color: rgba(var(--v-theme-on-surface), var(--v-high-emphasis-opacity)) !important;
}
@@ -611,16 +726,19 @@ html[data-theme="transparent"],
.v-overlay__content .v-list{
backdrop-filter: blur(6px);
background-color: rgb(var(--v-theme-surface), 0.9) !important;
box-shadow: none !important;
padding-inline: 0.5rem !important;
}
.v-overlay__content .v-card:not(.bg-primary){
backdrop-filter: blur(8px);
background-color: rgb(var(--v-theme-surface), 0.95) !important;
box-shadow: none !important;
.v-list, .v-table {
backdrop-filter: none;
background-color: transparent !important;
box-shadow: none !important;
}
}
@@ -639,18 +757,24 @@ html[data-theme="transparent"],
}
.v-overlay__content {
background: transparent !important;
box-shadow: none !important;
margin-block: env(safe-area-inset-top) env(safe-area-inset-bottom);
transition: opacity 0.2s ease !important;
transition: opacity 0.2s ease, box-shadow 0.2s ease !important;
}
.v-menu > .v-overlay__content {
overflow: hidden;
overflow: visible;
}
.v-dialog--fullscreen > .v-overlay__content > .v-card {
padding-block-end: calc(env(safe-area-inset-top) + env(safe-area-inset-bottom));
}
.v-dialog--fullscreen > .v-overlay__content {
box-shadow: none !important;
}
.v-dialog > .v-overlay__content {
margin-block: env(safe-area-inset-top) env(safe-area-inset-bottom);
}
@@ -659,6 +783,17 @@ html[data-theme="transparent"],
padding-block-end: env(safe-area-inset-bottom);
}
.v-dialog > .v-overlay__content > .v-card,
.v-bottom-sheet > .v-bottom-sheet__content.v-overlay__content > .v-card,
.v-menu > .v-overlay__content > .v-card,
.v-menu > .v-overlay__content > .v-list {
box-shadow: var(--app-overlay-shadow) !important;
}
.v-dialog--fullscreen > .v-overlay__content > .v-card {
box-shadow: none !important;
}
.settings-icon-button {
flex-shrink: 0;
border-radius: 0.95rem;

View File

@@ -1,3 +1,5 @@
/* stylelint-disable scss/at-rule-no-unknown */
// 透明主题专用样式
html[data-theme="transparent"] {
// 定义透明度变量
@@ -39,6 +41,7 @@ html[data-theme="transparent"] {
// 设置页彩色卡片保留透明主题的玻璃质感,只叠加极轻的图标主色。
.app-card-colorful {
border: 0 !important;
background:
linear-gradient(
135deg,
@@ -47,7 +50,6 @@ html[data-theme="transparent"] {
rgba(var(--v-theme-surface), 0) 76%
),
rgba(var(--v-theme-surface), var(--transparent-opacity-light)) !important;
border: 0 !important;
}
// 工具栏

View File

@@ -64,6 +64,149 @@ interface DoubanAppParams {
fallbackUrl?: string
}
// 媒体服务器卡片跳转所需的最小字段集合
interface MediaServerLinkTarget {
id?: string | number
item_id?: string | number
itemId?: string | number
server_id?: string
serverId?: string
link?: string
server_type?: string
}
// Emby hash 路由中的目标参数
interface EmbyHashTarget {
mediaId: string | null
serverId: string | null
}
/**
* 判断链接参数是否为有效值。
* @param value 待检查的链接参数
*/
function getValidLinkValue(value?: string | number | null): string | null {
if (value === undefined || value === null) return null
const stringValue = String(value).trim()
if (!stringValue || ['none', 'null', 'undefined'].includes(stringValue.toLowerCase())) return null
return stringValue
}
/**
* 获取媒体服务器条目的真实项目ID。
* @param target 媒体服务器跳转目标
*/
function getTargetItemId(target?: MediaServerLinkTarget): string | null {
return getValidLinkValue(target?.item_id ?? target?.itemId)
}
/**
* 获取媒体服务器条目的真实服务器ID。
* @param target 媒体服务器跳转目标
*/
function getTargetServerId(target?: MediaServerLinkTarget): string | null {
return getValidLinkValue(target?.server_id ?? target?.serverId)
}
/**
* 解析媒体服务器网页链接中的 hash 查询参数。
* @param playUrl 原始播放链接
*/
function getHashRouteParams(playUrl: string): { hashPath: string; params: URLSearchParams } | null {
const url = new URL(playUrl)
const hash = url.hash || ''
const queryIndex = hash.indexOf('?')
if (queryIndex === -1) return null
return {
hashPath: hash.slice(0, queryIndex),
params: new URLSearchParams(hash.slice(queryIndex + 1)),
}
}
/**
* 从 Emby 网页链接中提取 App 跳转需要的媒体ID和服务器ID。
* @param playUrl 原始播放链接
*/
function getEmbyHashTarget(playUrl: string): EmbyHashTarget {
const hashRoute = getHashRouteParams(playUrl)
if (!hashRoute) return { mediaId: null, serverId: null }
const serverId = getValidLinkValue(hashRoute.params.get('serverId'))
if (hashRoute.hashPath.includes('/videos')) {
return {
mediaId: getValidLinkValue(hashRoute.params.get('parentId')),
serverId,
}
}
return {
mediaId: getValidLinkValue(hashRoute.params.get('id')),
serverId,
}
}
/**
* 使用后端返回的真实ID修正Emby网页链接。
* @param playUrl 原始播放链接
* @param target 媒体服务器跳转目标
*/
function normalizeEmbyWebUrl(playUrl: string, target?: MediaServerLinkTarget): string {
try {
const url = new URL(playUrl)
const hashRoute = getHashRouteParams(playUrl)
if (!hashRoute) return playUrl
const { hashPath, params } = hashRoute
const itemId = getTargetItemId(target)
const serverId = getTargetServerId(target)
if (itemId && (hashPath.includes('/item') || params.has('id'))) {
params.set('id', itemId)
}
if (itemId && (hashPath.includes('/videos') || params.has('parentId'))) {
params.set('parentId', itemId)
}
if (serverId) {
params.set('serverId', serverId)
} else if (params.has('serverId') && !getValidLinkValue(params.get('serverId'))) {
params.delete('serverId')
}
url.hash = `${hashPath}?${params.toString()}`
return url.toString()
} catch (error) {
console.warn('修正Emby网页链接失败:', error)
return playUrl
}
}
/**
* 获取媒体服务器卡片可用的跳转链接。
* @param target 媒体服务器跳转目标
*/
function getMediaServerPlayUrl(target: MediaServerLinkTarget): string | null {
const playUrl = getValidLinkValue(target.link)
if (!playUrl) return null
const serverType = target.server_type?.toLowerCase()
if (serverType === 'emby' || serverType === 'zspace') {
return normalizeEmbyWebUrl(playUrl, target)
}
return playUrl
}
/**
* 打开媒体服务器卡片对应的播放页面。
* @param target 媒体服务器跳转目标
*/
export async function openMediaServerItem(target: MediaServerLinkTarget): Promise<void> {
const playUrl = getMediaServerPlayUrl(target)
if (!playUrl) return
await openMediaServerWithAutoDetect(playUrl, undefined, target.server_type)
}
/**
* 尝试跳转到APP如果失败则跳转到网页
* @param appType APP类型
@@ -408,26 +551,27 @@ function buildEmbyDeepLink(playUrl: string): string {
const serverAddress = url.hostname + (url.port ? `:${url.port}` : '')
// 尝试多种格式提取媒体ID
let mediaId: string | null = null
let serverId: string | null = null
const hashTarget = getEmbyHashTarget(playUrl)
let mediaId: string | null = hashTarget.mediaId
let serverId: string | null = hashTarget.serverId
// 格式1: /web/index.html#!/item?id=xxx&context=home&serverId=xxx (后台返回的格式)
const itemHashMatch = playUrl.match(/\/item\?id=([^&]+)/)
if (itemHashMatch) {
const itemHashMatch = !mediaId ? playUrl.match(/\/item\?id=([^&]+)/) : null
if (!mediaId && itemHashMatch) {
mediaId = itemHashMatch[1]
// 提取serverId
const serverIdMatch = playUrl.match(/serverId=([^&]+)/)
if (serverIdMatch) {
serverId = serverIdMatch[1]
serverId = getValidLinkValue(serverIdMatch[1])
}
}
// 格式2: /web/index.html#!/videos?serverId=xxx&parentId=xxx (后台返回的格式)
const videosHashMatch = playUrl.match(/\/videos\?serverId=([^&]+)&parentId=([^&]+)/)
if (videosHashMatch) {
const videosHashMatch = !mediaId ? playUrl.match(/\/videos\?serverId=([^&]+)&parentId=([^&]+)/) : null
if (!mediaId && videosHashMatch) {
// 对于videos格式我们使用parentId作为媒体ID
mediaId = videosHashMatch[2]
serverId = videosHashMatch[1]
serverId = getValidLinkValue(videosHashMatch[1])
}
// 格式3: ?id=xxx (通用格式)
@@ -464,22 +608,22 @@ function buildEmbyDeepLink(playUrl: string): string {
if (mediaId) {
let deepLink: string
const encodedMediaId = encodeURIComponent(mediaId)
const encodedServerId = serverId ? encodeURIComponent(serverId) : null
// 根据设备类型使用不同的深度链接格式
if (isIOSDevice()) {
// iOS格式: emby://items?serverId={SERVER_ID}&itemId={ITEM_ID}
if (serverId) {
deepLink = `emby://items?serverId=${serverId}&itemId=${mediaId}`
if (encodedServerId) {
deepLink = `emby://items?serverId=${encodedServerId}&itemId=${encodedMediaId}`
} else {
// 如果没有serverId尝试使用服务器地址作为serverId
deepLink = `emby://items?serverId=${serverAddress}&itemId=${mediaId}`
deepLink = `emby://items?itemId=${encodedMediaId}`
}
} else if (encodedServerId) {
// Android格式: emby://items/{SERVER_ID}/{ITEM_ID}
deepLink = `emby://items/${encodedServerId}/${encodedMediaId}`
} else {
// Android格式: emby://{服务器地址}/item/{媒体ID}
deepLink = `emby://${serverAddress}/item/${mediaId}`
if (serverId) {
deepLink += `?serverId=${serverId}`
}
deepLink = `emby://${serverAddress}/item/${encodedMediaId}`
}
console.log('Emby深度链接构建成功:', {

View File

@@ -10,9 +10,10 @@ import {
const federationController = new AbortController()
// 定义远程模块接口
interface RemoteModule {
export interface RemoteModule {
id: string
url: string
name?: string
}
/**
@@ -112,6 +113,17 @@ export async function loadRemoteComponent(id: string, componentName: string = 'P
}
}
/**
* 使用后端发现接口返回的 remote 信息加载指定组件。
* @param remoteModule 远程模块信息
* @param componentName 组件名称
*/
export async function loadRemoteComponentFromModule(remoteModule: RemoteModule, componentName: string = 'Page') {
injectRemoteModule(remoteModule)
const module = await __federation_method_getRemote(remoteModule.id, `./${componentName}`)
return __federation_method_unwrapDefault(module)
}
/**
* 从API获取远程模块列表
*/
@@ -131,7 +143,7 @@ async function fetchRemoteModules(): Promise<RemoteModule[]> {
* 动态注入Federation Remote模块
* @param modules 远程模块列表
*/
function injectRemoteModule(module: RemoteModule): void {
export function injectRemoteModule(module: RemoteModule): void {
// 与 API 请求一致:使用 origin + pathname 作为前缀,子路径代理时 pathname 含 /mp 等
const baseUrl = new URL(window.location.href)
const pathBase = baseUrl.pathname.replace(/\/$/, '') || ''

View File

@@ -80,6 +80,39 @@ export function getLogoUrl(logoName: string): string {
return logoMap[logoName] || ''
}
/**
* 判断是否为需要强制走后端代理的 Bangumi 图片。
* @param url 图片地址
* @returns 是否为 Bangumi 图片地址
*/
export function isBangumiImageUrl(url: string): boolean {
if (!url) return false
try {
const hostname = new URL(url).hostname.toLowerCase()
return hostname === 'lain.bgm.tv' || hostname.endsWith('.lain.bgm.tv')
} catch {
return url.includes('lain.bgm.tv')
}
}
/**
* 将远程图片地址转换为前端可直接展示的地址。
* @param url 原始图片地址
* @param useCache 是否使用后端图片缓存
* @returns 转换后的图片地址
*/
export function getDisplayImageUrl(url: string, useCache = false): string {
if (!url || !/^https?:\/\//i.test(url)) return url
const encodedUrl = encodeURIComponent(url)
if (isBangumiImageUrl(url))
return `${import.meta.env.VITE_API_BASE_URL}system/img/1?imgurl=${encodedUrl}${useCache ? '&cache=true' : ''}`
if (useCache)
return `${import.meta.env.VITE_API_BASE_URL}system/cache/image?url=${encodedUrl}`
if (url.includes('doubanio.com'))
return `${import.meta.env.VITE_API_BASE_URL}system/img/0?imgurl=${encodedUrl}`
return url
}
/**
* 获取所有可用的图标名称
* @returns 图标名称数组

View File

@@ -1,3 +1,5 @@
import { applyDocumentThemeChrome } from '@/utils/themePalette'
// 主题管理器 - 动态加载主题CSS
export interface ThemeConfig {
name: string
@@ -116,18 +118,13 @@ class ThemeManager {
* 应用主题到DOM
*/
private applyTheme(themeName: string): void {
// 移除之前的主题属性
document.documentElement.removeAttribute('data-theme')
// 设置新主题除了default主题
if (themeName !== 'default') {
document.documentElement.setAttribute('data-theme', themeName)
}
// auto 是用户偏好DOM 上必须落到实际主题,避免恢复前台时短暂匹配不到深色样式。
const { resolvedTheme } = applyDocumentThemeChrome(themeName)
this.currentTheme = themeName
// 触发主题变更事件
this.dispatchThemeChangeEvent(themeName)
this.dispatchThemeChangeEvent(resolvedTheme)
}
/**

125
src/utils/themePalette.ts Normal file
View File

@@ -0,0 +1,125 @@
import { checkPrefersColorSchemeIsDark } from '@/@core/utils'
export type ThemePreference = 'auto' | 'default' | 'light' | 'dark' | 'purple' | 'transparent'
export type ResolvedThemeName = 'light' | 'dark' | 'purple' | 'transparent'
export type ThemeColorScheme = 'light' | 'dark'
interface ThemeRootPalette {
background: string
primary: string
}
interface ApplyDocumentThemeChromeOptions {
background?: string
persistLoaderColors?: boolean
primary?: string
resolvedTheme?: string
}
export const themeRootPalettes: Record<ResolvedThemeName, ThemeRootPalette> = {
light: {
background: '#F4F5FA',
primary: '#9155FD',
},
dark: {
background: '#0E1116',
primary: '#6E66ED',
},
purple: {
background: '#28243D',
primary: '#9155FD',
},
transparent: {
background: '#1C1C1C',
primary: '#A370F7',
},
}
const validResolvedThemes = new Set<string>(Object.keys(themeRootPalettes))
function normalizeResolvedThemeName(themeName: string | null | undefined): ResolvedThemeName {
return validResolvedThemes.has(themeName || '') ? (themeName as ResolvedThemeName) : 'light'
}
export function resolveThemeName(themePreference: string | null | undefined): ResolvedThemeName {
if (themePreference === 'auto') {
return checkPrefersColorSchemeIsDark() ? 'dark' : 'light'
}
if (themePreference === 'default') {
return 'light'
}
return normalizeResolvedThemeName(themePreference)
}
export function getThemeColorScheme(themeName: string | null | undefined): ThemeColorScheme {
return ['dark', 'purple', 'transparent'].includes(themeName || '') ? 'dark' : 'light'
}
function setMetaContent(selector: string, content: string) {
document.querySelectorAll<HTMLMetaElement>(selector).forEach(meta => {
meta.content = content
})
}
function ensureThemeColorMeta(themeColor: string) {
const metas = document.querySelectorAll<HTMLMetaElement>('meta[name="theme-color"]')
if (metas.length) {
metas.forEach(meta => {
meta.content = themeColor
})
return
}
const meta = document.createElement('meta')
meta.name = 'theme-color'
meta.content = themeColor
document.head.appendChild(meta)
}
/**
* 同步浏览器首帧会使用的根节点底色和系统控件配色。
* iOS PWA 从后台恢复时可能先绘制 WebView 外壳,再等 Vue 响应式主题更新。
*/
export function applyDocumentThemeChrome(
themePreference: string | null | undefined,
options: ApplyDocumentThemeChromeOptions = {},
) {
const resolvedTheme = normalizeResolvedThemeName(options.resolvedTheme || resolveThemeName(themePreference))
const colorScheme = getThemeColorScheme(resolvedTheme)
const palette = themeRootPalettes[resolvedTheme]
const background = options.background || palette.background
const primary = options.primary || palette.primary
document.documentElement.setAttribute('data-theme', resolvedTheme)
document.documentElement.setAttribute('data-theme-preference', themePreference || resolvedTheme)
document.documentElement.style.setProperty('--initial-loader-bg', background)
document.documentElement.style.setProperty('--initial-loader-color', primary)
document.documentElement.style.backgroundColor = background
document.documentElement.style.colorScheme = colorScheme
if (document.body) {
document.body.setAttribute('data-theme', resolvedTheme)
document.body.setAttribute('data-theme-preference', themePreference || resolvedTheme)
document.body.style.backgroundColor = background
document.body.style.colorScheme = colorScheme
}
setMetaContent('meta[name="color-scheme"]', colorScheme === 'dark' ? 'dark light' : 'light dark')
ensureThemeColorMeta(background)
if (options.persistLoaderColors) {
localStorage.setItem('materio-initial-loader-bg', background)
localStorage.setItem('materio-initial-loader-color', primary)
}
return {
background,
colorScheme,
primary,
resolvedTheme,
}
}

View File

@@ -68,20 +68,28 @@ onActivated(() => {
<VCardTitle>{{ t('dashboard.latest') }} - {{ name }}</VCardTitle>
</VCardItem>
<ProgressiveCardGrid
:items="data"
:get-item-key="item => item.id || item.link || item.title"
:min-item-width="144"
:item-aspect-ratio="1.5"
class="mx-3 mb-3"
tabindex="0"
>
<template #default="{ item }">
<PosterCard :media="item" />
</template>
</ProgressiveCardGrid>
<div class="dashboard-card-grid-wrap">
<ProgressiveCardGrid
:items="data"
:get-item-key="item => item.id || item.link || item.title"
:min-item-width="144"
:item-aspect-ratio="1.5"
tabindex="0"
>
<template #default="{ item }">
<PosterCard :media="item" />
</template>
</ProgressiveCardGrid>
</div>
</VCard>
</template>
</VHover>
</div>
</template>
<style scoped>
.dashboard-card-grid-wrap {
/* 用内边距提供卡片留白,避免 100% 宽度网格叠加横向外边距后在 iOS 小屏溢出。 */
padding: 0 0.75rem 0.75rem;
}
</style>

View File

@@ -70,19 +70,27 @@ onActivated(() => {
</template>
<VCardTitle>{{ t('dashboard.library') }}</VCardTitle>
</VCardItem>
<ProgressiveCardGrid
:items="libraryList"
:get-item-key="item => item.id || item.name"
:min-item-width="240"
:estimated-item-height="160"
class="mx-3 mb-3"
tabindex="0"
>
<template #default="{ item }">
<LibraryCard :media="item" height="10rem" />
</template>
</ProgressiveCardGrid>
<div class="dashboard-card-grid-wrap">
<ProgressiveCardGrid
:items="libraryList"
:get-item-key="item => item.id || item.name"
:min-item-width="240"
:estimated-item-height="160"
tabindex="0"
>
<template #default="{ item }">
<LibraryCard :media="item" height="10rem" />
</template>
</ProgressiveCardGrid>
</div>
</VCard>
</template>
</VHover>
</template>
<style scoped>
.dashboard-card-grid-wrap {
/* 用内边距提供卡片留白,避免 100% 宽度网格叠加横向外边距后在 iOS 小屏溢出。 */
padding: 0 0.75rem 0.75rem;
}
</style>

View File

@@ -71,19 +71,27 @@ onActivated(() => {
<VCardTitle>{{ t('dashboard.playing') }}</VCardTitle>
</VCardItem>
<ProgressiveCardGrid
:items="playingList"
:get-item-key="item => item.id || item.link || item.title"
:min-item-width="240"
:estimated-item-height="160"
class="mx-3 mb-3"
tabindex="0"
>
<template #default="{ item }">
<BackdropCard :media="item" height="10rem" />
</template>
</ProgressiveCardGrid>
<div class="dashboard-card-grid-wrap">
<ProgressiveCardGrid
:items="playingList"
:get-item-key="item => item.id || item.link || item.title"
:min-item-width="240"
:estimated-item-height="160"
tabindex="0"
>
<template #default="{ item }">
<BackdropCard :media="item" height="10rem" />
</template>
</ProgressiveCardGrid>
</div>
</VCard>
</template>
</VHover>
</template>
<style scoped>
.dashboard-card-grid-wrap {
/* 用内边距提供卡片留白,避免 100% 宽度网格叠加横向外边距后在 iOS 小屏溢出。 */
padding: 0 0.75rem 0.75rem;
}
</style>

View File

@@ -14,8 +14,9 @@ import { useTheme } from 'vuetify'
import { useI18n } from 'vue-i18n'
import { hasPermission } from '@/utils/permission'
import { useGlobalSettingsStore } from '@/stores'
import { openMediaServerWithAutoDetect, openDoubanApp } from '@/utils/appDeepLink'
import { openMediaServerItem, openDoubanApp } from '@/utils/appDeepLink'
import { openSharedDialog } from '@/composables/useSharedDialog'
import { getDisplayImageUrl } from '@/utils/imageUtils'
const SearchSiteDialog = defineAsyncComponent(() => import('@/components/dialog/SearchSiteDialog.vue'))
const SubscribeEditDialog = defineAsyncComponent(() => import('@/components/dialog/SubscribeEditDialog.vue'))
@@ -417,31 +418,19 @@ function getEpisodeImage(stillPath: string) {
function getW500Image(url = '') {
if (!url) return ''
url = url.replace('original', 'w500')
// 使用图片缓存
if (globalSettings.GLOBAL_IMAGE_CACHE)
return `${import.meta.env.VITE_API_BASE_URL}system/cache/image?url=${encodeURIComponent(url)}`
return url
return getDisplayImageUrl(url, globalSettings.GLOBAL_IMAGE_CACHE)
}
// 计算Poster地址
const getPosterUrl: Ref<string> = computed(() => {
const url = mediaDetail.value.poster_path ?? ''
// 使用图片缓存
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)
})
// 计算backdrop地址
const getBackdropUrl: Ref<string> = computed(() => {
const url = mediaDetail.value.backdrop_path ?? ''
// 使用图片缓存
if (globalSettings.GLOBAL_IMAGE_CACHE)
return `${import.meta.env.VITE_API_BASE_URL}system/cache/image?url=${encodeURIComponent(url)}`
return url
return getDisplayImageUrl(url, globalSettings.GLOBAL_IMAGE_CACHE)
})
// 获取发行国家名称
@@ -526,7 +515,12 @@ async function handlePlay() {
const result: { [key: string]: any } = await api.get(`mediaserver/play/${existsItemId.value}`)
if (result?.success) {
// 使用深度链接工具优先跳转到APP失败后跳转到网页
await openMediaServerWithAutoDetect(result.data.url, undefined, result.data.server_type)
await openMediaServerItem({
link: result.data.url,
item_id: result.data.item_id,
server_id: result.data.server_id,
server_type: result.data.server_type,
})
} else {
$toast.error(`获取播放链接失败:${result.message}`)
}

View File

@@ -6,6 +6,7 @@ import type { Person } from '@/api/types'
import NoDataFound from '@/components/NoDataFound.vue'
import { useI18n } from 'vue-i18n'
import { useGlobalSettingsStore } from '@/stores'
import { getDisplayImageUrl } from '@/utils/imageUtils'
// 国际化
const { t } = useI18n()
@@ -64,10 +65,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)
}
// 将别名数组拆分为、分隔的字符串

View File

@@ -50,7 +50,7 @@ const { registerHeaderTab } = useDynamicHeaderTab()
// 注册动态标签页在setup顶层立即执行
registerHeaderTab({
items: pluginTabs.value,
items: pluginTabs,
modelValue: activeTab,
appendButtons: [
{
@@ -222,6 +222,23 @@ function toggleMarketFilter(field: 'author' | 'label' | 'repo', value: string) {
}
}
// 关闭插件市场过滤菜单。
function closeMarketFilterMenu() {
filterMarketPluginDialog.value = false
}
// 选择插件市场排序项并关闭过滤菜单。
function selectMarketSort(value: string) {
activeSort.value = value
closeMarketFilterMenu()
}
// 提交插件市场关键字过滤并关闭过滤菜单。
function submitMarketNameFilter(event: KeyboardEvent) {
if (event.isComposing) return
closeMarketFilterMenu()
}
// 插件过滤条件
const installedFilter = ref(null)
@@ -237,6 +254,29 @@ const filterInstalledPluginDialog = ref(false)
// 插件市场过滤窗口
const filterMarketPluginDialog = ref(false)
// 关闭已安装插件过滤菜单。
function closeInstalledFilterMenu() {
filterInstalledPluginDialog.value = false
}
// 切换已启用插件过滤条件并关闭过滤菜单。
function toggleEnabledInstalledFilter() {
enabledFilter.value = !enabledFilter.value
closeInstalledFilterMenu()
}
// 切换有新版本插件过滤条件并关闭过滤菜单。
function toggleHasUpdateInstalledFilter() {
hasUpdateFilter.value = !hasUpdateFilter.value
closeInstalledFilterMenu()
}
// 提交已安装插件关键字过滤并关闭过滤菜单。
function submitInstalledNameFilter(event: KeyboardEvent) {
if (event.isComposing) return
closeInstalledFilterMenu()
}
// 作者过滤项
const authorFilterOptions = ref<string[]>([])
// 标签过滤项
@@ -1480,13 +1520,14 @@ function onDragStartPlugin(evt: any) {
variant="outlined"
hide-details
clearable
@keyup.enter="submitInstalledNameFilter"
/>
</div>
<VDivider class="mt-2" />
<!-- 快捷筛选 -->
<VList density="compact" class="px-2 py-1">
<VListSubheader>{{ t('common.filter') }}</VListSubheader>
<VListItem :active="enabledFilter" @click="enabledFilter = !enabledFilter" density="compact">
<VListItem :active="enabledFilter" @click="toggleEnabledInstalledFilter" density="compact">
<template #prepend>
<VIcon icon="mdi-play-circle" color="success" size="small" />
</template>
@@ -1495,7 +1536,7 @@ function onDragStartPlugin(evt: any) {
<VIcon v-if="enabledFilter" icon="mdi-check" color="primary" size="small" />
</template>
</VListItem>
<VListItem :active="hasUpdateFilter" @click="hasUpdateFilter = !hasUpdateFilter" density="compact">
<VListItem :active="hasUpdateFilter" @click="toggleHasUpdateInstalledFilter" density="compact">
<template #prepend>
<VIcon icon="mdi-arrow-up-circle" color="info" size="small" />
</template>
@@ -1528,6 +1569,7 @@ function onDragStartPlugin(evt: any) {
variant="outlined"
hide-details
clearable
@keyup.enter="submitMarketNameFilter"
/>
</div>
<VDivider class="mt-2" />
@@ -1538,7 +1580,7 @@ function onDragStartPlugin(evt: any) {
v-for="option in sortOptions"
:key="option.value"
:active="(activeSort || 'count') === option.value"
@click="activeSort = option.value"
@click="selectMarketSort(option.value)"
density="compact"
>
<VListItemTitle>{{ option.title }}</VListItemTitle>

View File

@@ -1,5 +1,4 @@
<script lang="ts" setup>
import { VPullToRefresh } from 'vuetify/labs/VPullToRefresh'
import api from '@/api'
import type { DownloadingInfo } from '@/api/types'
import NoDataFound from '@/components/NoDataFound.vue'
@@ -39,17 +38,6 @@ async function fetchData(_context: KeepAliveRefreshContext = {}) {
}
}
// 刷新状态
const loading = ref(false)
// 下拉刷新
function onRefresh() {
loading.value = true
void fetchData().finally(() => {
loading.value = false
})
}
// 过滤数据,管理员用户显示全部,非管理员只显示自己的订阅
const filteredDataList = computed(() => {
// 从 Store 中获取用户信息
@@ -64,7 +52,7 @@ const { loading: dataLoading } = useDataRefresh(
'downloading-list',
fetchData,
3000, // 3秒间隔
false // 初始加载交给 keep-alive 页面自身,避免同时发起两次请求
false, // 初始加载交给 keep-alive 页面自身,避免同时发起两次请求
)
onMounted(fetchData)
@@ -76,23 +64,21 @@ useKeepAliveRefresh(fetchData, {
<template>
<LoadingBanner v-if="!isRefreshed" class="mt-12" />
<VPullToRefresh v-model="loading" @load="onRefresh" :pull-down-threshold="64">
<ProgressiveCardGrid
v-if="filteredDataList.length > 0"
:items="filteredDataList"
:get-item-key="item => item.hash || item.name"
:min-item-width="320"
:estimated-item-height="230"
>
<template #default="{ item }">
<DownloadingCard :info="item" :downloader-name="props.name" />
</template>
</ProgressiveCardGrid>
<NoDataFound
v-if="filteredDataList.length === 0 && isRefreshed"
error-code="404"
:error-title="t('downloading.noTask')"
:error-description="t('downloading.noTaskDescription')"
/>
</VPullToRefresh>
<ProgressiveCardGrid
v-if="filteredDataList.length > 0"
:items="filteredDataList"
:get-item-key="item => item.hash || item.name"
:min-item-width="320"
:estimated-item-height="230"
>
<template #default="{ item }">
<DownloadingCard :info="item" :downloader-name="props.name" />
</template>
</ProgressiveCardGrid>
<NoDataFound
v-if="filteredDataList.length === 0 && isRefreshed"
error-code="404"
:error-title="t('downloading.noTask')"
:error-description="t('downloading.noTaskDescription')"
/>
</template>

View File

@@ -57,7 +57,10 @@ const SystemSettings = ref<any>({
LLM_SUPPORT_AUDIO_OUTPUT: false,
LLM_API_KEY: null,
LLM_BASE_URL: 'https://api.deepseek.com',
LLM_USE_PROXY: true,
LLM_BASE_URL_PRESET: null,
LLM_MAX_CONTEXT_TOKENS: 64,
LLM_USER_AGENT: null,
AUDIO_INPUT_PROVIDER: 'openai',
AUDIO_INPUT_API_KEY: null,
AUDIO_INPUT_BASE_URL: null,
@@ -73,7 +76,6 @@ const SystemSettings = ref<any>({
AI_RECOMMEND_ENABLED: false,
AI_RECOMMEND_USER_PREFERENCE: null,
AI_RECOMMEND_MAX_ITEMS: 50,
LLM_MAX_CONTEXT_TOKENS: 64,
},
// 高级系统设置
Advanced: {
@@ -82,6 +84,7 @@ const SystemSettings = ref<any>({
GLOBAL_IMAGE_CACHE: false,
SUBSCRIBE_STATISTIC_SHARE: true,
PLUGIN_STATISTIC_SHARE: true,
USAGE_STATISTIC_SHARE: true,
WORKFLOW_STATISTIC_SHARE: true,
BIG_MEMORY_MODE: false,
DB_WAL_ENABLE: false,
@@ -132,6 +135,7 @@ const audioProviderItems = computed(() => [
{ title: t('setting.system.audioProviderOpenAiAudio'), value: 'openai' },
{ title: t('setting.system.audioProviderChatAudio'), value: 'openai_chat_audio' },
{ title: t('setting.system.audioProviderMimo'), value: 'mimo' },
{ title: t('setting.system.audioProviderMinimax'), value: 'minimax' },
])
// 刮削配置
@@ -211,7 +215,9 @@ type LlmSettingsSnapshot = {
LLM_THINKING_LEVEL: string
LLM_API_KEY: string
LLM_BASE_URL: string
LLM_USE_PROXY: boolean
LLM_BASE_URL_PRESET: string
LLM_USER_AGENT: string
}
let llmTestRequestId = 0
@@ -245,6 +251,20 @@ const llmBaseUrlPresetRef = computed({
},
})
const llmUseProxyRef = computed({
get: () => Boolean(SystemSettings.value.Basic.LLM_USE_PROXY),
set: value => {
SystemSettings.value.Basic.LLM_USE_PROXY = Boolean(value)
},
})
const llmUserAgentRef = computed({
get: () => String(SystemSettings.value.Basic.LLM_USER_AGENT ?? ''),
set: value => {
SystemSettings.value.Basic.LLM_USER_AGENT = value || ''
},
})
const llmModelRef = computed({
get: () => String(SystemSettings.value.Basic.LLM_MODEL ?? ''),
set: value => {
@@ -290,6 +310,8 @@ const {
apiKey: llmApiKeyRef,
baseUrl: llmBaseUrlRef,
baseUrlPreset: llmBaseUrlPresetRef,
useProxy: llmUseProxyRef,
userAgent: llmUserAgentRef,
model: llmModelRef,
maxContextTokens: llmMaxContextRef,
})
@@ -350,7 +372,9 @@ function buildLlmSnapshot(): LlmSettingsSnapshot {
LLM_THINKING_LEVEL: String(SystemSettings.value.Basic.LLM_THINKING_LEVEL ?? 'off'),
LLM_API_KEY: String(SystemSettings.value.Basic.LLM_API_KEY ?? ''),
LLM_BASE_URL: String(SystemSettings.value.Basic.LLM_BASE_URL ?? ''),
LLM_USE_PROXY: Boolean(SystemSettings.value.Basic.LLM_USE_PROXY),
LLM_BASE_URL_PRESET: String(SystemSettings.value.Basic.LLM_BASE_URL_PRESET ?? ''),
LLM_USER_AGENT: String(SystemSettings.value.Basic.LLM_USER_AGENT ?? ''),
}
}
@@ -366,7 +390,9 @@ function buildLlmTestPayload(snapshot: LlmSettingsSnapshot) {
thinking_level: snapshot.LLM_THINKING_LEVEL.trim(),
api_key: snapshot.LLM_API_KEY.trim(),
base_url: snapshot.LLM_BASE_URL.trim(),
use_proxy: snapshot.LLM_USE_PROXY,
base_url_preset: snapshot.LLM_BASE_URL_PRESET.trim(),
user_agent: snapshot.LLM_USER_AGENT.trim(),
}
}
@@ -640,9 +666,9 @@ async function loadSystemSettings() {
if (result.data.hasOwnProperty(key)) (SystemSettings.value[sectionKey] as any)[key] = result.data[key]
})
}
const accelEnabled = Boolean(result.data.RUST_ACCEL_ENABLED)
rustAccelAvailable.value = accelEnabled
if (!accelEnabled) SystemSettings.value.Advanced.RUST_ACCEL = false
const accelAvailable = Boolean(result.data.RUST_ACCEL_AVAILABLE ?? result.data.RUST_ACCEL_ENABLED)
rustAccelAvailable.value = accelAvailable
if (!accelAvailable) SystemSettings.value.Advanced.RUST_ACCEL = false
SystemSettings.value.Basic.LLM_THINKING_LEVEL = resolveThinkingLevelValue(result.data)
await loadLlmProviders()
}
@@ -1192,6 +1218,14 @@ watch(currentLlmSnapshotKey, (snapshotKey, previousSnapshotKey) => {
</template>
</VCombobox>
</VCol>
<VCol v-if="SystemSettings.Basic.AI_AGENT_ENABLE && showBaseUrlField" cols="12">
<VSwitch
v-model="SystemSettings.Basic.LLM_USE_PROXY"
:label="t('setting.system.llmUseProxy')"
:hint="t('setting.system.llmUseProxyHint')"
persistent-hint
/>
</VCol>
<VCol v-if="SystemSettings.Basic.AI_AGENT_ENABLE && showApiKeyField" cols="12" md="6">
<VTextField
v-model="SystemSettings.Basic.LLM_API_KEY"
@@ -1306,6 +1340,15 @@ watch(currentLlmSnapshotKey, (snapshotKey, previousSnapshotKey) => {
prepend-inner-icon="mdi-counter"
/>
</VCol>
<VCol v-if="SystemSettings.Basic.AI_AGENT_ENABLE && showBaseUrlField" cols="12" md="6">
<VTextField
v-model="SystemSettings.Basic.LLM_USER_AGENT"
:label="t('setting.system.llmUserAgent')"
:hint="t('setting.system.llmUserAgentHint')"
persistent-hint
prepend-inner-icon="mdi-card-account-details-outline"
/>
</VCol>
<VCol v-if="SystemSettings.Basic.AI_AGENT_ENABLE" cols="12" md="6">
<VSelect
v-model="SystemSettings.Basic.LLM_THINKING_LEVEL"
@@ -1767,6 +1810,14 @@ watch(currentLlmSnapshotKey, (snapshotKey, previousSnapshotKey) => {
persistent-hint
/>
</VCol>
<VCol cols="12" md="6">
<VSwitch
v-model="SystemSettings.Advanced.USAGE_STATISTIC_SHARE"
:label="t('setting.system.usageStatisticShare')"
:hint="t('setting.system.usageStatisticShareHint')"
persistent-hint
/>
</VCol>
<VCol cols="12" md="6">
<VSwitch
v-model="SystemSettings.Advanced.WORKFLOW_STATISTIC_SHARE"

View File

@@ -40,6 +40,20 @@ const baseUrlPresetRef = computed({
},
})
const useProxyRef = computed({
get: () => wizardData.value.agent.useProxy,
set: value => {
wizardData.value.agent.useProxy = Boolean(value)
},
})
const userAgentRef = computed({
get: () => wizardData.value.agent.userAgent,
set: value => {
wizardData.value.agent.userAgent = value || ''
},
})
const modelRef = computed({
get: () => wizardData.value.agent.model,
set: value => {
@@ -92,6 +106,8 @@ const {
apiKey: apiKeyRef,
baseUrl: baseUrlRef,
baseUrlPreset: baseUrlPresetRef,
useProxy: useProxyRef,
userAgent: userAgentRef,
model: modelRef,
maxContextTokens: maxContextTokensRef,
authConnected: authConnectedRef,
@@ -171,6 +187,7 @@ const audioProviderItems = computed(() => [
{ title: t('setting.system.audioProviderOpenAiAudio'), value: 'openai' },
{ title: t('setting.system.audioProviderChatAudio'), value: 'openai_chat_audio' },
{ title: t('setting.system.audioProviderMimo'), value: 'mimo' },
{ title: t('setting.system.audioProviderMinimax'), value: 'minimax' },
])
const providerAuthMethods = computed(() => selectedProvider.value?.oauth_methods || [])
@@ -332,6 +349,16 @@ onMounted(async () => {
</VCombobox>
</VCol>
<VCol v-if="showBaseUrlField" cols="12">
<VSwitch
v-model="wizardData.agent.useProxy"
:label="t('setting.system.llmUseProxy')"
:hint="t('setting.system.llmUseProxyHint')"
persistent-hint
color="primary"
/>
</VCol>
<VCol v-if="showApiKeyField" cols="12" md="6">
<VTextField
v-model="wizardData.agent.apiKey"
@@ -437,6 +464,16 @@ onMounted(async () => {
/>
</VCol>
<VCol v-if="showBaseUrlField" cols="12" md="6">
<VTextField
v-model="wizardData.agent.userAgent"
:label="t('setting.system.llmUserAgent')"
:hint="t('setting.system.llmUserAgentHint')"
persistent-hint
prepend-inner-icon="mdi-card-account-details-outline"
/>
</VCol>
<VCol cols="12" md="6">
<VSelect
v-model="wizardData.agent.thinkingLevel"

View File

@@ -15,7 +15,7 @@ const messages = ref<Message[]>([])
const currData = ref<Message[]>([])
// 已加载消息的签名集合
// 使用消息内容签名去重,避免仅按秒级时间戳判断时误吞同一秒内的不同消息
// SSE 消息与数据库消息的字段来源不同date vs reg_time, null vs {}),签名已归一化处理
const messageKeys = new Set<string>()
// 是否完成加载
@@ -42,26 +42,34 @@ const MESSAGE_AUTO_SCROLL_THRESHOLD = 64
let scrollTimer: number | undefined
let scrollReleaseTimer: number | undefined
// 获取消息时间
function getMessageTime(message: Message) {
return message.reg_time || message.date || ''
// 生成消息去重签名
// SSE 消息只有 date 没有 reg_time数据库消息只有 reg_time 没有 date
// note 在 SSE 侧为 null数据库侧为 {},需要归一化。
function normalizeNote(note: Message['note']): string {
if (note == null) return ''
if (typeof note === 'string') return note
if (typeof note === 'object' && !Array.isArray(note) && Object.keys(note).length === 0) return ''
return JSON.stringify(note)
}
// 生成消息签名
function getMessageKey(message: Message) {
return [
message.action ?? '',
message.userid ?? '',
message.reg_time ?? '',
message.date ?? '',
message.reg_time || message.date || '',
message.title ?? '',
message.text ?? '',
message.image ?? '',
message.link ?? '',
message.note ?? '',
normalizeNote(message.note),
].join('::')
}
// 获取消息时间
function getMessageTime(message: Message) {
return message.reg_time || message.date || ''
}
// 排序消息列表,确保最新消息始终位于底部
function sortMessages(items: Message[]) {
return [...items].sort((a, b) => compareTime(getMessageTime(a), getMessageTime(b)))