Compare commits

..

50 Commits

Author SHA1 Message Date
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
56 changed files with 3717 additions and 462 deletions

View File

@@ -37,7 +37,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 全屏模式 -->

View File

@@ -1,6 +1,6 @@
{
"name": "moviepilot",
"version": "2.13.1",
"version": "2.13.4",
"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
@@ -234,8 +460,7 @@ export default defineComponent({
.layout-page-content {
// display: flex;
// 使用 clip 替代 hidden避免 Chrome 144+ 滚动锁定问题
overflow-x: clip;
overflow-y: auto;
overflow: clip auto;
.page-content-container {
inline-size: 100%;

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

@@ -12,16 +12,19 @@ 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 { 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
applyStoredThemeCustomizerAppearance(vuetifyTheme)
// 启动屏和 iOS safe area 在同一层显示,根节点底色需要尽早和当前主题保持一致。
function syncRootLaunchPalette() {
@@ -285,6 +288,8 @@ onMounted(async () => {
// 初始化主题管理器 - 统一处理主题初始化
await themeManager.setTheme(themeValue)
applyStoredThemeCustomizerAppearance(vuetifyTheme)
updateHtmlThemeAttribute(globalTheme.name.value)
// 监听主题变化
watch(

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
}
// 手动整理预览数据

View File

@@ -0,0 +1,691 @@
<script setup lang="ts">
import {
themeCustomizerPrimaryColors,
useThemeCustomizer,
type ThemeCustomizerLayout,
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, setSkin, setTheme, settings } =
useThemeCustomizer()
const { appMode } = usePWA()
const { t } = useI18n()
const { global: globalTheme } = useTheme()
const display = useDisplay()
const defaultPrimaryColor = themeCustomizerPrimaryColors[0].value
const drawer = computed({
get: () => props.modelValue,
set: value => emit('update:modelValue', value),
})
// 将主题定制器打开状态同步到根节点,供全局悬浮按钮避让右侧面板。
function syncThemeCustomizerOpenState(isOpen: boolean) {
if (typeof document === 'undefined') return
if (isOpen) {
document.documentElement.setAttribute('data-theme-customizer-open', 'true')
document.body.setAttribute('data-theme-customizer-open', 'true')
return
}
document.documentElement.removeAttribute('data-theme-customizer-open')
document.body.removeAttribute('data-theme-customizer-open')
}
// 组件卸载时清理根节点状态,避免路由切换后悬浮按钮继续保持让位。
function clearThemeCustomizerOpenState() {
syncThemeCustomizerOpenState(false)
}
watch(drawer, syncThemeCustomizerOpenState, { immediate: true })
onScopeDispose(clearThemeCustomizerOpenState)
const customizerContainer = computed(() => (appMode.value ? VDialog : VNavigationDrawer))
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 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.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 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">
<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>
<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 {
z-index: 12000 !important;
}
.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: 100dvh;
}
.theme-customizer-panel--dialog .theme-customizer-body {
block-size: calc(100dvh - 80px - env(safe-area-inset-bottom) - env(safe-area-inset-top));
}
.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-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 {
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,
.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-mini-layout--horizontal {
.mini-sidebar {
flex-direction: row;
align-items: center;
}
}
@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

@@ -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)
})
// 订阅编辑保存

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

@@ -102,6 +102,15 @@ const frontendVersionStatistics = computed(() => versionStatistic.value?.fronten
// 活跃用户统计
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
@@ -473,19 +482,19 @@ onMounted(() => {
<div class="version-stat-summary">
<div>
<div class="text-caption text-medium-emphasis">{{ t('setting.about.totalInstallUsers') }}</div>
<div class="version-stat-number">{{ versionStatistic.total_users ?? 0 }}</div>
<div class="version-stat-number">{{ formatVersionStatisticNumber(versionStatistic.total_users) }}</div>
</div>
<div>
<div class="text-caption text-medium-emphasis">{{ t('setting.about.activeToday') }}</div>
<div class="version-stat-number">{{ activeUsers.today ?? 0 }}</div>
<div class="version-stat-number">{{ formatVersionStatisticNumber(activeUsers.today) }}</div>
</div>
<div>
<div class="text-caption text-medium-emphasis">{{ t('setting.about.active7Days') }}</div>
<div class="version-stat-number">{{ activeUsers.last_7_days ?? 0 }}</div>
<div class="version-stat-number">{{ formatVersionStatisticNumber(activeUsers.last_7_days) }}</div>
</div>
<div>
<div class="text-caption text-medium-emphasis">{{ t('setting.about.active30Days') }}</div>
<div class="version-stat-number">{{ activeUsers.last_30_days ?? 0 }}</div>
<div class="version-stat-number">{{ formatVersionStatisticNumber(activeUsers.last_30_days) }}</div>
</div>
</div>
<div class="mt-5">
@@ -502,7 +511,7 @@ onMounted(() => {
<td>
<code>{{ item.version }}</code>
</td>
<td class="text-end">{{ item.count }}</td>
<td class="text-end">{{ formatVersionStatisticNumber(item.count) }}</td>
</tr>
<tr v-if="!backendVersionStatistics.length">
<td colspan="2" class="text-medium-emphasis">{{ t('setting.about.noVersionStatisticData') }}</td>
@@ -524,7 +533,7 @@ onMounted(() => {
<td>
<code>{{ item.version }}</code>
</td>
<td class="text-end">{{ item.count }}</td>
<td class="text-end">{{ formatVersionStatisticNumber(item.count) }}</td>
</tr>
<tr v-if="!frontendVersionStatistics.length">
<td colspan="2" class="text-medium-emphasis">{{ t('setting.about.noVersionStatisticData') }}</td>

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,10 +172,8 @@ watch(
otpPassword.value = ''
} else {
// 弹窗关闭时,清空数据
qrCodeImage.value = ''
qrCode.value = ''
otpUri.value = ''
secret.value = ''
resetOtpSetupState()
otpLoading.value = false
otpPassword.value = ''
}
},
@@ -194,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">
@@ -221,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>
@@ -234,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

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

@@ -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,322 @@
import { computed, onMounted, onScopeDispose, readonly, ref } from 'vue'
import { useTheme } from 'vuetify'
import { checkPrefersColorSchemeIsDark } from '@/@core/utils'
import { saveLocalTheme } from '@/@core/utils/theme'
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 ThemeCustomizerSkin = 'bordered' | 'default'
export type ThemeCustomizerTheme = 'auto' | 'dark' | 'light' | 'purple' | 'transparent'
export interface ThemeCustomizerSettings {
layout: ThemeCustomizerLayout
primaryColor: string
semiDarkMenu: boolean
skin: ThemeCustomizerSkin
theme: ThemeCustomizerTheme
}
type VuetifyThemeApi = ReturnType<typeof useTheme>
const defaultPrimaryColor = themeCustomizerPrimaryColors[0].value
const validLayouts: ThemeCustomizerLayout[] = ['vertical', 'collapsed', 'horizontal']
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,
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,
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' | '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-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-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)
// auto 模式传给 themeManager 后会写入 data-theme="auto",这里再同步为实际生效主题。
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)
applyThemeCustomizerRootSettings(nextSettings)
dispatchThemeCustomizerChange(nextSettings)
return nextSettings
}
export function isDefaultThemeCustomizerSettings(settings: ThemeCustomizerSettings) {
const defaults = normalizeThemeCustomizerSettings({
layout: 'vertical',
primaryColor: defaultPrimaryColor,
semiDarkMenu: false,
skin: 'default',
theme: 'auto',
})
return (
settings.layout === defaults.layout &&
settings.primaryColor === defaults.primaryColor &&
settings.semiDarkMenu === defaults.semiDarkMenu &&
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 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,
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,
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,24 @@ 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',
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',
@@ -1456,6 +1474,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.',
@@ -1671,8 +1694,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',
@@ -2447,6 +2472,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: {
@@ -2485,11 +2511,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',
@@ -2575,7 +2609,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',
@@ -2600,6 +2635,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',
@@ -2630,6 +2669,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',
@@ -2881,6 +2922,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.',
@@ -2897,7 +2939,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,24 @@ export default {
customCssSaveSuccess: '自定义CSS保存成功请刷新页面生效',
customCssSaveFailed: '保存自定义CSS到服务端失败',
deviceNotSupport: '当前设备不支持监听系统主题变化',
customizer: {
title: '主题定制',
subtitle: '实时自定义与预览',
theming: '主题',
primaryColor: '色调',
usePrimaryColor: '使用 {color} 色调',
chooseCustomColor: '选择自定义色调',
skins: '框架',
skinDefault: '默认',
skinBordered: '边框',
semiDarkMenu: '半暗菜单',
layout: '布局',
layoutVertical: '垂直',
layoutCollapsed: '折叠',
layoutHorizontal: '水平',
reset: '重置主题定制',
appModeLayoutLocked: 'App 模式固定使用移动端导航,请切换到桌面界面后再调整布局。',
},
},
app: {
moviepilot: 'MoviePilot',
@@ -1448,6 +1466,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}',
@@ -2402,6 +2424,7 @@ export default {
updating: '正在更新 {site} Cookie & UA...',
success: '{site} 更新Cookie & UA成功',
failed: '{site} 更新失败:{message}',
requestFailed: '请求失败,请稍后重试',
updateButton: '开始更新',
},
siteAddEdit: {
@@ -2440,11 +2463,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: '插件仓库保存成功',
@@ -2554,6 +2585,10 @@ export default {
episodeFormatRecommendFailed: '集数定位生成失败,请稍后重试',
episodeFormatRecommendRule: '命中规则:{rule}',
episodeFormatRecommendSample: '样本文件:{file}',
episodeFormatRuleDetails: '手动整理集数定位规则',
episodeFormatFinal: '最终集数定位:{format}',
episodeFormatManualInput: '手动输入集数定位',
episodeFormatRulePattern: '规则正则:{pattern}',
episodeOffset: '集数偏移',
episodeOffsetHint: '集数偏移运算,如-10或EP*2',
episodeOffsetPlaceholder: '如-10',
@@ -2584,6 +2619,8 @@ export default {
previewSeasonInfo: '季信息',
previewSeasonLabel: '季',
previewEpisodeCount: '总集数',
customWordsApplied: '识别词应用详情',
customWordsSameRules: '{count} 个文件应用相同识别词',
previewAfterColumn: '整理后',
previewBeforeColumn: '整理前',
previewFileNameColumn: '文件名',
@@ -2835,6 +2872,7 @@ export default {
settings: '设置',
projectHome: '项目主页',
updateHistory: '更新说明',
versionHistory: '版本历史',
local: '本地',
systemVersion: '系统版本',
incompatibleSystemVersion: '当前 MoviePilot 版本不满足插件要求,无法安装',
@@ -2850,7 +2888,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,24 @@ export default {
customCssSaveSuccess: '自定義CSS保存成功請刷新頁面生效',
customCssSaveFailed: '保存自定義CSS到服務端失敗',
deviceNotSupport: '當前設備不支持監聽系統主題變化',
customizer: {
title: '主題定制',
subtitle: '即時自定義與預覽',
theming: '主題',
primaryColor: '色调',
usePrimaryColor: '使用 {color} 色調',
chooseCustomColor: '選擇自定義色調',
skins: '框架',
skinDefault: '默認',
skinBordered: '邊框',
semiDarkMenu: '半暗菜單',
layout: '佈局',
layoutVertical: '垂直',
layoutCollapsed: '折疊',
layoutHorizontal: '水平',
reset: '重置主題定制',
appModeLayoutLocked: 'App 模式固定使用移動端導航,請切換到桌面界面後再調整佈局。',
},
},
app: {
moviepilot: 'MoviePilot',
@@ -1449,6 +1467,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}',
@@ -2403,6 +2425,7 @@ export default {
updating: '正在更新 {site} Cookie & UA...',
success: '{site} 更新Cookie & UA成功',
failed: '{site} 更新失敗:{message}',
requestFailed: '請求失敗,請稍後重試',
updateButton: '開始更新',
},
siteAddEdit: {
@@ -2441,11 +2464,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: '插件倉庫儲存成功',
@@ -2555,6 +2586,10 @@ export default {
episodeFormatRecommendFailed: '集數定位生成失敗,請稍後重試',
episodeFormatRecommendRule: '命中規則:{rule}',
episodeFormatRecommendSample: '樣本文件:{file}',
episodeFormatRuleDetails: '手動整理集數定位規則',
episodeFormatFinal: '最終集數定位:{format}',
episodeFormatManualInput: '手動輸入集數定位',
episodeFormatRulePattern: '規則正則:{pattern}',
episodeOffset: '集數偏移',
episodeOffsetHint: '集數偏移運算,如-10或EP*2',
episodeOffsetPlaceholder: '如-10',
@@ -2585,6 +2620,8 @@ export default {
previewSeasonInfo: '季資訊',
previewSeasonLabel: '季',
previewEpisodeCount: '總集數',
customWordsApplied: '識別詞應用詳情',
customWordsSameRules: '{count} 個文件套用相同識別詞',
previewAfterColumn: '整理後',
previewBeforeColumn: '整理前',
previewFileNameColumn: '文件名',
@@ -2836,6 +2873,7 @@ export default {
settings: '設置',
projectHome: '項目主頁',
updateHistory: '更新說明',
versionHistory: '版本歷史',
local: '本地',
installToLocal: '安裝到本地',
totalDownloads: '共 {count} 次下載',
@@ -2849,7 +2887,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

@@ -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,16 @@ 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/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,5 @@
/* stylelint-disable scss/at-rule-no-unknown */
/* stylelint-disable no-descending-specificity */
// 公共样式 - 所有主题都需要
@tailwind base;
@tailwind components;
@@ -68,6 +70,32 @@ html {
}
}
// 主题定制器的 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;
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;
}
}
// 应用类信息卡片:固定右侧媒体槽位,避免图片被左侧文字挤压变形。
.app-card-shell {
position: relative;
@@ -89,6 +117,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 +126,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 +164,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;
@@ -471,6 +502,10 @@ html[data-theme="transparent"],
opacity:0.75;
}
:root {
--theme-customizer-fab-offset: 420px;
}
// 紧凑型悬浮操作按钮
.compact-fab-stack {
position: fixed;
@@ -482,6 +517,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 > * {
@@ -587,6 +623,24 @@ html[data-theme="transparent"],
}
}
@media (min-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 (min-width: 601px) and (max-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;
}

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

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

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

@@ -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: {
@@ -213,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
@@ -247,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 => {
@@ -292,6 +310,8 @@ const {
apiKey: llmApiKeyRef,
baseUrl: llmBaseUrlRef,
baseUrlPreset: llmBaseUrlPresetRef,
useProxy: llmUseProxyRef,
userAgent: llmUserAgentRef,
model: llmModelRef,
maxContextTokens: llmMaxContextRef,
})
@@ -352,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 ?? ''),
}
}
@@ -368,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(),
}
}
@@ -642,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()
}
@@ -1194,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"
@@ -1308,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"

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,
@@ -333,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"
@@ -438,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)))