mirror of
https://github.com/jxxghp/MoviePilot-Frontend.git
synced 2026-06-22 16:13:47 +08:00
Compare commits
54 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
841e9479af | ||
|
|
8c3380e8f5 | ||
|
|
0ac42f0a76 | ||
|
|
caef6eca67 | ||
|
|
0867236b68 | ||
|
|
09dfdbaf67 | ||
|
|
57224e15fb | ||
|
|
200500a060 | ||
|
|
a4731aade1 | ||
|
|
7d21eabf1a | ||
|
|
b639737bd6 | ||
|
|
d02ece234c | ||
|
|
889a5c9e51 | ||
|
|
880a34f508 | ||
|
|
50b0148ed6 | ||
|
|
285ddab45a | ||
|
|
aa12f4b6b6 | ||
|
|
9bbb060073 | ||
|
|
3b0623628c | ||
|
|
b45c147452 | ||
|
|
25bc7c4b3c | ||
|
|
d6b7b6d813 | ||
|
|
a3ac46c891 | ||
|
|
b6e824246b | ||
|
|
5191f6780d | ||
|
|
261aaf17ad | ||
|
|
258e64bca7 | ||
|
|
e905df014e | ||
|
|
b93f8f2bff | ||
|
|
9aa0a5e1b7 | ||
|
|
ee9f41d015 | ||
|
|
ad6a664cbe | ||
|
|
3387067636 | ||
|
|
07dc3c3e9a | ||
|
|
262b4bebd4 | ||
|
|
6e50cf31de | ||
|
|
14aa75dfae | ||
|
|
348aa4757b | ||
|
|
6e6819acc1 | ||
|
|
51a58aaae0 | ||
|
|
fbde99389e | ||
|
|
5a4e345529 | ||
|
|
b446afb6d8 | ||
|
|
8580af36d1 | ||
|
|
95ca092117 | ||
|
|
ba200cae5c | ||
|
|
87c73e0253 | ||
|
|
d4d7f635f5 | ||
|
|
729db1510e | ||
|
|
8a12ecf918 | ||
|
|
cacc2602df | ||
|
|
8c6cfa7fc5 | ||
|
|
0113f28d8c | ||
|
|
d870b788bc |
@@ -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 全屏模式 -->
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "moviepilot",
|
||||
"version": "2.13.0",
|
||||
"version": "2.13.4",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"bin": "dist/service.js",
|
||||
|
||||
BIN
public/apple-touch-icon-precomposed.png
Normal file
BIN
public/apple-touch-icon-precomposed.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 102 KiB |
@@ -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);
|
||||
|
||||
@@ -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%;
|
||||
|
||||
9
src/@layouts/types.d.ts
vendored
9
src/@layouts/types.d.ts
vendored
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
// 手动整理预览数据
|
||||
|
||||
691
src/components/ThemeCustomizer.vue
Normal file
691
src/components/ThemeCustomizer.vue
Normal 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>
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ import plex from '@images/misc/plex.png'
|
||||
import emby from '@images/misc/emby.png'
|
||||
import jellyfin from '@images/misc/jellyfin.png'
|
||||
import { getLogoUrl } from '@/utils/imageUtils'
|
||||
import { openMediaServerWithAutoDetect } from '@/utils/appDeepLink'
|
||||
import { openMediaServerItem } from '@/utils/appDeepLink'
|
||||
|
||||
// 输入参数
|
||||
const props = defineProps({
|
||||
@@ -49,8 +49,8 @@ function getDefaultImage() {
|
||||
|
||||
// 跳转播放
|
||||
async function goPlay() {
|
||||
if (props.media?.link) {
|
||||
await openMediaServerWithAutoDetect(props.media.link, undefined, props.media.server_type)
|
||||
if (props.media) {
|
||||
await openMediaServerItem(props.media)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script lang="ts" setup>
|
||||
import noImage from '@images/no-image.jpeg'
|
||||
import { getLogoUrl } from '@/utils/imageUtils'
|
||||
import { getDisplayImageUrl, getLogoUrl } from '@/utils/imageUtils'
|
||||
import api from '@/api'
|
||||
import { useToast } from 'vue-toastification'
|
||||
import { formatSeason, formatRating } from '@/@core/utils/formatters'
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
// 移除订阅
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
// 人物姓名
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
// 订阅编辑保存
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -84,6 +84,33 @@ const releaseDialogTitle = ref('')
|
||||
// 变更日志对话框内容
|
||||
const releaseDialogBody = ref('')
|
||||
|
||||
// 版本统计对话框
|
||||
const versionStatisticDialog = ref(false)
|
||||
|
||||
// 版本统计加载状态
|
||||
const versionStatisticLoading = ref(false)
|
||||
|
||||
// 版本统计数据
|
||||
const versionStatistic = ref<any>({})
|
||||
|
||||
// 后端版本统计
|
||||
const backendVersionStatistics = computed(() => versionStatistic.value?.backend_versions ?? [])
|
||||
|
||||
// 前端版本统计
|
||||
const frontendVersionStatistics = computed(() => versionStatistic.value?.frontend_versions ?? [])
|
||||
|
||||
// 活跃用户统计
|
||||
const activeUsers = computed(() => versionStatistic.value?.active_users ?? {})
|
||||
|
||||
/** 格式化版本安装统计数字为千分位展示。 */
|
||||
function formatVersionStatisticNumber(value: unknown) {
|
||||
const numberValue = Number(value ?? 0)
|
||||
|
||||
if (!Number.isFinite(numberValue)) return '0'
|
||||
|
||||
return numberValue.toLocaleString()
|
||||
}
|
||||
|
||||
// 打开日志对话框
|
||||
function showReleaseDialog(title: string, body: string) {
|
||||
releaseDialogTitle.value = title
|
||||
@@ -91,6 +118,28 @@ function showReleaseDialog(title: string, body: string) {
|
||||
releaseDialog.value = true
|
||||
}
|
||||
|
||||
// 查询版本统计
|
||||
async function queryVersionStatistic() {
|
||||
if (!systemEnv.value.USAGE_STATISTIC_SHARE) return
|
||||
versionStatisticLoading.value = true
|
||||
try {
|
||||
const result: { [key: string]: any } = await api.get('system/usage/statistic')
|
||||
|
||||
versionStatistic.value = result.data ?? {}
|
||||
} catch (error) {
|
||||
console.log(error)
|
||||
versionStatistic.value = {}
|
||||
} finally {
|
||||
versionStatisticLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 打开版本统计对话框
|
||||
async function showVersionStatisticDialog() {
|
||||
versionStatisticDialog.value = true
|
||||
await queryVersionStatistic()
|
||||
}
|
||||
|
||||
// 查询系统环境变量
|
||||
async function querySystemEnv() {
|
||||
try {
|
||||
@@ -182,6 +231,18 @@ onMounted(() => {
|
||||
{{ t('setting.about.latest') }}
|
||||
</span>
|
||||
</a>
|
||||
<VTooltip v-if="systemEnv.USAGE_STATISTIC_SHARE" :text="t('setting.about.versionStatistic')">
|
||||
<template #activator="{ props }">
|
||||
<VBtn
|
||||
v-bind="props"
|
||||
icon="mdi-chart-bar"
|
||||
size="x-small"
|
||||
variant="text"
|
||||
class="ms-2 flex-shrink-0"
|
||||
@click="showVersionStatisticDialog"
|
||||
/>
|
||||
</template>
|
||||
</VTooltip>
|
||||
</span>
|
||||
</dd>
|
||||
</div>
|
||||
@@ -406,6 +467,86 @@ onMounted(() => {
|
||||
<VCardText class="markdown-body" v-html="releaseDialogBody" />
|
||||
</VCard>
|
||||
</VDialog>
|
||||
<VDialog v-if="versionStatisticDialog" v-model="versionStatisticDialog" width="680" scrollable max-height="85vh">
|
||||
<VCard>
|
||||
<VCardItem>
|
||||
<VDialogCloseBtn @click="versionStatisticDialog = false" />
|
||||
<VCardTitle>
|
||||
<VIcon icon="mdi-chart-bar" class="me-2" />
|
||||
{{ t('setting.about.versionStatisticTitle') }}
|
||||
</VCardTitle>
|
||||
</VCardItem>
|
||||
<VDivider />
|
||||
<VProgressLinear v-if="versionStatisticLoading" indeterminate color="primary" />
|
||||
<VCardText>
|
||||
<div class="version-stat-summary">
|
||||
<div>
|
||||
<div class="text-caption text-medium-emphasis">{{ t('setting.about.totalInstallUsers') }}</div>
|
||||
<div class="version-stat-number">{{ formatVersionStatisticNumber(versionStatistic.total_users) }}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-caption text-medium-emphasis">{{ t('setting.about.activeToday') }}</div>
|
||||
<div class="version-stat-number">{{ formatVersionStatisticNumber(activeUsers.today) }}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-caption text-medium-emphasis">{{ t('setting.about.active7Days') }}</div>
|
||||
<div class="version-stat-number">{{ formatVersionStatisticNumber(activeUsers.last_7_days) }}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-caption text-medium-emphasis">{{ t('setting.about.active30Days') }}</div>
|
||||
<div class="version-stat-number">{{ formatVersionStatisticNumber(activeUsers.last_30_days) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-5">
|
||||
<div class="text-subtitle-2 mb-2">{{ t('setting.about.backendVersionStatistic') }}</div>
|
||||
<VTable density="compact">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{{ t('setting.about.version') }}</th>
|
||||
<th class="text-end">{{ t('setting.about.users') }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="item in backendVersionStatistics" :key="`backend-${item.version}`">
|
||||
<td>
|
||||
<code>{{ item.version }}</code>
|
||||
</td>
|
||||
<td class="text-end">{{ formatVersionStatisticNumber(item.count) }}</td>
|
||||
</tr>
|
||||
<tr v-if="!backendVersionStatistics.length">
|
||||
<td colspan="2" class="text-medium-emphasis">{{ t('setting.about.noVersionStatisticData') }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</VTable>
|
||||
</div>
|
||||
<div class="mt-5">
|
||||
<div class="text-subtitle-2 mb-2">{{ t('setting.about.frontendVersionStatistic') }}</div>
|
||||
<VTable density="compact">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{{ t('setting.about.version') }}</th>
|
||||
<th class="text-end">{{ t('setting.about.users') }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="item in frontendVersionStatistics" :key="`frontend-${item.version}`">
|
||||
<td>
|
||||
<code>{{ item.version }}</code>
|
||||
</td>
|
||||
<td class="text-end">{{ formatVersionStatisticNumber(item.count) }}</td>
|
||||
</tr>
|
||||
<tr v-if="!frontendVersionStatistics.length">
|
||||
<td colspan="2" class="text-medium-emphasis">{{ t('setting.about.noVersionStatisticData') }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</VTable>
|
||||
</div>
|
||||
<div v-if="versionStatistic.updated_at" class="mt-4 text-caption text-medium-emphasis">
|
||||
{{ t('setting.about.lastUpdated') }}: {{ versionStatistic.updated_at }}
|
||||
</div>
|
||||
</VCardText>
|
||||
</VCard>
|
||||
</VDialog>
|
||||
</VDialog>
|
||||
</template>
|
||||
|
||||
@@ -422,6 +563,18 @@ onMounted(() => {
|
||||
margin-block: 0.5rem 2.5rem;
|
||||
}
|
||||
|
||||
.version-stat-summary {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
grid-template-columns: repeat(auto-fit, minmax(7rem, 1fr));
|
||||
}
|
||||
|
||||
.version-stat-number {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
line-height: 2rem;
|
||||
}
|
||||
|
||||
.markdown-body :deep(h1),
|
||||
.markdown-body :deep(h2),
|
||||
.markdown-body :deep(h3) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -41,42 +41,69 @@ const otpPassword = ref('')
|
||||
|
||||
const allowPasskeyWithoutOtp = computed(() => globalSettingsStore.get('PASSKEY_ALLOW_REGISTER_WITHOUT_OTP') === true)
|
||||
|
||||
// OTP 初始化加载状态
|
||||
const otpLoading = ref(false)
|
||||
|
||||
// OTP 初始化失败信息
|
||||
const otpGenerateError = ref('')
|
||||
|
||||
// 二维码图片 base64
|
||||
const qrCodeImage = ref('')
|
||||
|
||||
// 二维码信息
|
||||
const qrCode = ref('')
|
||||
|
||||
// 为当前用户获取Otp Uri
|
||||
// 清空当前 OTP 设置流程的临时数据。
|
||||
function resetOtpSetupState() {
|
||||
qrCodeImage.value = ''
|
||||
qrCode.value = ''
|
||||
otpUri.value = ''
|
||||
secret.value = ''
|
||||
otpGenerateError.value = ''
|
||||
}
|
||||
|
||||
// 标记 OTP 初始化失败,并向用户显示明确错误。
|
||||
function setOtpGenerateError(message?: string) {
|
||||
const errorMessage = message || t('common.error')
|
||||
otpGenerateError.value = t('profile.otpGenerateFailed', { message: errorMessage })
|
||||
$toast.error(otpGenerateError.value)
|
||||
}
|
||||
|
||||
// 为当前用户获取 OTP URI 并生成二维码图片。
|
||||
async function getOtpUri() {
|
||||
resetOtpSetupState()
|
||||
// 如果已经启用OTP,只打开对话框,不生成新的二维码
|
||||
if (props.isOtp) {
|
||||
qrCode.value = '' // 清空二维码,这样对话框会显示清除界面
|
||||
qrCodeImage.value = ''
|
||||
return
|
||||
}
|
||||
|
||||
// 未启用OTP,生成新的二维码
|
||||
otpLoading.value = true
|
||||
try {
|
||||
const result = (await api.post('mfa/otp/generate')) as ApiResponse<{
|
||||
uri: string
|
||||
secret: string
|
||||
}>
|
||||
if (result.success) {
|
||||
otpUri.value = result.data.uri
|
||||
secret.value = result.data.secret
|
||||
qrCode.value = result.data.uri
|
||||
const uri = result.data?.uri?.trim()
|
||||
const otpSecret = result.data?.secret?.trim()
|
||||
|
||||
if (result.success && uri) {
|
||||
otpUri.value = uri
|
||||
secret.value = otpSecret || ''
|
||||
qrCode.value = uri
|
||||
// 生成二维码图片
|
||||
qrCodeImage.value = await QRCode.toDataURL(result.data.uri, {
|
||||
qrCodeImage.value = await QRCode.toDataURL(uri, {
|
||||
width: 200,
|
||||
margin: 1,
|
||||
})
|
||||
} else {
|
||||
$toast.error(t('profile.otpGenerateFailed', { message: result.message }))
|
||||
setOtpGenerateError(result.message || 'empty otp uri')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
$toast.error(t('profile.otpGenerateFailed', { message: error instanceof Error ? error.message : String(error) }))
|
||||
setOtpGenerateError(error instanceof Error ? error.message : String(error))
|
||||
} finally {
|
||||
otpLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -145,13 +172,12 @@ watch(
|
||||
otpPassword.value = ''
|
||||
} else {
|
||||
// 弹窗关闭时,清空数据
|
||||
qrCodeImage.value = ''
|
||||
qrCode.value = ''
|
||||
otpUri.value = ''
|
||||
secret.value = ''
|
||||
resetOtpSetupState()
|
||||
otpLoading.value = false
|
||||
otpPassword.value = ''
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
</script>
|
||||
|
||||
@@ -193,16 +219,29 @@ watch(
|
||||
|
||||
<!-- 设置新的OTP -->
|
||||
<template v-else>
|
||||
<div class="my-6 rounded text-center p-3 border" style="width: fit-content; margin: 0 auto">
|
||||
<VImg class="mx-auto" :src="qrCodeImage" width="200" height="200">
|
||||
<template #placeholder>
|
||||
<div class="w-full h-full">
|
||||
<VSkeletonLoader class="object-cover aspect-w-1 aspect-h-1" />
|
||||
</div>
|
||||
</template>
|
||||
</VImg>
|
||||
<div
|
||||
class="my-6 rounded text-center p-3 border d-flex align-center justify-center"
|
||||
style="width: 226px; height: 226px; margin: 0 auto"
|
||||
>
|
||||
<img
|
||||
v-if="qrCodeImage"
|
||||
class="mx-auto d-block otp-qrcode-image"
|
||||
:src="qrCodeImage"
|
||||
:alt="t('profile.setupAuthenticator')"
|
||||
width="200"
|
||||
height="200"
|
||||
/>
|
||||
<VProgressCircular v-else-if="otpLoading" indeterminate color="primary" />
|
||||
<div v-else class="w-100">
|
||||
<VAlert type="error" variant="tonal" density="compact" class="mb-3">
|
||||
{{ otpGenerateError || t('profile.otpGenerateFailed', { message: t('common.error') }) }}
|
||||
</VAlert>
|
||||
<VBtn size="small" variant="tonal" prepend-icon="mdi-refresh" @click="getOtpUri">
|
||||
{{ t('common.retry') }}
|
||||
</VBtn>
|
||||
</div>
|
||||
</div>
|
||||
<VAlert :title="secret" variant="tonal" type="warning" class="my-4" :text="t('profile.secretKeyTip')">
|
||||
<VAlert v-if="secret" :title="secret" variant="tonal" type="warning" class="my-4" :text="t('profile.secretKeyTip')">
|
||||
<template #prepend />
|
||||
</VAlert>
|
||||
<VForm @submit.prevent="judgeOtpPassword">
|
||||
@@ -220,7 +259,7 @@ watch(
|
||||
<VBtn variant="outlined" color="secondary" @click="show = false">
|
||||
{{ t('common.cancel') }}
|
||||
</VBtn>
|
||||
<VBtn type="submit">
|
||||
<VBtn type="submit" :disabled="!otpUri || otpLoading">
|
||||
<template #prepend>
|
||||
<VIcon icon="mdi-check" />
|
||||
</template>
|
||||
@@ -233,3 +272,10 @@ watch(
|
||||
</VCard>
|
||||
</VDialog>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.otp-qrcode-image {
|
||||
inline-size: 200px;
|
||||
block-size: 200px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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"
|
||||
>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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消息处理函数
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
})
|
||||
|
||||
@@ -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 || ''
|
||||
|
||||
322
src/composables/useThemeCustomizer.ts
Normal file
322
src/composables/useThemeCustomizer.ts
Normal 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),
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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状态恢复
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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',
|
||||
@@ -1378,6 +1396,18 @@ export default {
|
||||
expand: 'Expand',
|
||||
collapse: 'Collapse',
|
||||
clearCache: 'Clear Cache',
|
||||
versionStatistic: 'Version Statistics',
|
||||
versionStatisticTitle: 'Installation Version Statistics',
|
||||
totalInstallUsers: 'Install Users',
|
||||
activeToday: 'Active Today',
|
||||
active7Days: 'Active 7 Days',
|
||||
active30Days: 'Active 30 Days',
|
||||
backendVersionStatistic: 'Backend Versions',
|
||||
frontendVersionStatistic: 'Frontend Versions',
|
||||
version: 'Version',
|
||||
users: 'Users',
|
||||
lastUpdated: 'Updated At',
|
||||
noVersionStatisticData: 'No statistics data',
|
||||
},
|
||||
system: {
|
||||
custom: 'Custom',
|
||||
@@ -1444,6 +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.',
|
||||
@@ -1458,15 +1493,16 @@ export default {
|
||||
llmProviderCheckAuthStatus: 'Check Authorization Status',
|
||||
audioInputProvider: 'Audio Input Provider',
|
||||
audioInputProviderHint:
|
||||
'Service used to transcribe incoming audio messages. Supports OpenAI audio, Chat Audio compatible APIs, and Xiaomi MiMo.',
|
||||
'Service used to transcribe incoming audio messages. Supports OpenAI audio, Chat Audio compatible APIs, Xiaomi MiMo, and MiniMax.',
|
||||
audioProviderOpenAiAudio: 'OpenAI Audio Compatible',
|
||||
audioProviderChatAudio: 'Chat Audio Compatible',
|
||||
audioProviderMimo: 'Xiaomi MiMo',
|
||||
audioProviderMinimax: 'MiniMax',
|
||||
audioInputApiKey: 'Audio Input API Key',
|
||||
audioInputApiKeyHint: 'API key used for audio transcription.',
|
||||
audioInputBaseUrl: 'Audio Input Base URL',
|
||||
audioInputBaseUrlHint:
|
||||
'Base URL for audio input. Use the matching compatible endpoint for Chat Audio services; MiMo defaults to https://api.xiaomimimo.com/v1.',
|
||||
'Base URL for audio input. Use the matching compatible endpoint for Chat Audio services; MiMo defaults to https://api.xiaomimimo.com/v1, MiniMax defaults to https://api.minimaxi.com/v1.',
|
||||
audioInputModel: 'Audio Input Model',
|
||||
audioInputModelHint: 'Model name used to convert audio content into text.',
|
||||
audioInputLanguage: 'Recognition Language',
|
||||
@@ -1474,12 +1510,12 @@ export default {
|
||||
'Default language for audio transcription, such as zh or en. Leave blank to use the backend default.',
|
||||
audioOutputProvider: 'Audio Output Provider',
|
||||
audioOutputProviderHint:
|
||||
'Service used to generate voice replies. Supports OpenAI audio, Chat Audio compatible APIs, and Xiaomi MiMo.',
|
||||
'Service used to generate voice replies. Supports OpenAI audio, Chat Audio compatible APIs, Xiaomi MiMo, and MiniMax.',
|
||||
audioOutputApiKey: 'Audio Output API Key',
|
||||
audioOutputApiKeyHint: 'API key used for speech synthesis.',
|
||||
audioOutputBaseUrl: 'Audio Output Base URL',
|
||||
audioOutputBaseUrlHint:
|
||||
'Base URL for audio output. Use the matching compatible endpoint for Chat Audio services; MiMo defaults to https://api.xiaomimimo.com/v1.',
|
||||
'Base URL for audio output. Use the matching compatible endpoint for Chat Audio services; MiMo defaults to https://api.xiaomimimo.com/v1, MiniMax defaults to https://api.minimaxi.com/v1.',
|
||||
audioOutputModel: 'Audio Output Model',
|
||||
audioOutputModelHint: 'Model name used to convert text content into speech.',
|
||||
audioOutputVoice: 'Voice Preset',
|
||||
@@ -1561,6 +1597,9 @@ export default {
|
||||
'Share subscription statistics to popular subscriptions for other MP users to reference',
|
||||
pluginStatisticShare: 'Report Plugin Installation Data',
|
||||
pluginStatisticShareHint: 'Report plugin installation data to the server for statistics and display purposes',
|
||||
usageStatisticShare: 'Report Installation Version Statistics',
|
||||
usageStatisticShareHint:
|
||||
'Report anonymous installation ID and current backend/frontend versions to count users by version',
|
||||
workflowStatisticShare: 'Share Workflow Data',
|
||||
workflowStatisticShareHint: 'Share workflow statistics to popular workflows for other MP users to reference',
|
||||
bigMemoryMode: 'Large Memory Mode',
|
||||
@@ -1655,8 +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',
|
||||
@@ -2431,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: {
|
||||
@@ -2469,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',
|
||||
@@ -2559,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',
|
||||
@@ -2584,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',
|
||||
@@ -2614,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',
|
||||
@@ -2865,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.',
|
||||
@@ -2881,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',
|
||||
|
||||
@@ -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',
|
||||
@@ -1373,6 +1391,18 @@ export default {
|
||||
expand: '展开',
|
||||
collapse: '收起',
|
||||
clearCache: '清除缓存',
|
||||
versionStatistic: '版本统计',
|
||||
versionStatisticTitle: '安装版本统计',
|
||||
totalInstallUsers: '安装用户',
|
||||
activeToday: '今日活跃',
|
||||
active7Days: '7日活跃',
|
||||
active30Days: '30日活跃',
|
||||
backendVersionStatistic: '后端版本',
|
||||
frontendVersionStatistic: '前端版本',
|
||||
version: '版本',
|
||||
users: '用户数',
|
||||
lastUpdated: '更新时间',
|
||||
noVersionStatisticData: '暂无统计数据',
|
||||
},
|
||||
system: {
|
||||
custom: '自定义',
|
||||
@@ -1436,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}',
|
||||
@@ -1447,26 +1481,29 @@ export default {
|
||||
llmProviderOpenAuthPage: '打开授权页面',
|
||||
llmProviderCheckAuthStatus: '检查授权状态',
|
||||
audioInputProvider: '音频输入提供商',
|
||||
audioInputProviderHint: '用于识别用户音频消息的服务,支持 OpenAI 音频接口、Chat Audio 兼容接口和 Xiaomi MiMo',
|
||||
audioInputProviderHint:
|
||||
'用于识别用户音频消息的服务,支持 OpenAI 音频接口、Chat Audio 兼容接口、Xiaomi MiMo 和 MiniMax',
|
||||
audioProviderOpenAiAudio: 'OpenAI Audio 兼容',
|
||||
audioProviderChatAudio: 'Chat Audio 兼容',
|
||||
audioProviderMimo: '小米 MiMo',
|
||||
audioProviderMinimax: 'MiniMax',
|
||||
audioInputApiKey: '音频输入 API密钥',
|
||||
audioInputApiKeyHint: '音频输入转写使用的 API 密钥',
|
||||
audioInputBaseUrl: '音频输入基础URL',
|
||||
audioInputBaseUrlHint:
|
||||
'音频输入接口基础URL,Chat Audio 类服务可填写对应兼容地址,MiMo 默认 https://api.xiaomimimo.com/v1',
|
||||
'音频输入接口基础URL,Chat Audio 类服务可填写对应兼容地址,MiMo 默认 https://api.xiaomimimo.com/v1,MiniMax 默认 https://api.minimaxi.com/v1',
|
||||
audioInputModel: '音频输入模型',
|
||||
audioInputModelHint: '用于将音频内容转换为文字的模型名称',
|
||||
audioInputLanguage: '识别语言',
|
||||
audioInputLanguageHint: '音频转写默认语言,例如 zh、en,留空时按后端默认处理',
|
||||
audioOutputProvider: '音频输出提供商',
|
||||
audioOutputProviderHint: '用于生成语音回复的服务,支持 OpenAI 音频接口、Chat Audio 兼容接口和 Xiaomi MiMo',
|
||||
audioOutputProviderHint:
|
||||
'用于生成语音回复的服务,支持 OpenAI 音频接口、Chat Audio 兼容接口、Xiaomi MiMo 和 MiniMax',
|
||||
audioOutputApiKey: '音频输出 API密钥',
|
||||
audioOutputApiKeyHint: '文字转语音使用的 API 密钥',
|
||||
audioOutputBaseUrl: '音频输出基础URL',
|
||||
audioOutputBaseUrlHint:
|
||||
'音频输出接口基础URL,Chat Audio 类服务可填写对应兼容地址,MiMo 默认 https://api.xiaomimimo.com/v1',
|
||||
'音频输出接口基础URL,Chat Audio 类服务可填写对应兼容地址,MiMo 默认 https://api.xiaomimimo.com/v1,MiniMax 默认 https://api.minimaxi.com/v1',
|
||||
audioOutputModel: '音频输出模型',
|
||||
audioOutputModelHint: '用于将文字内容转换为语音的模型名称',
|
||||
audioOutputVoice: '语音音色',
|
||||
@@ -1545,6 +1582,8 @@ export default {
|
||||
subscribeStatisticShareHint: '分享订阅统计数据到热门订阅,供其他MPer参考',
|
||||
pluginStatisticShare: '上报插件安装数据',
|
||||
pluginStatisticShareHint: '上报插件安装数据给服务器,用于统计展示插件安装情况',
|
||||
usageStatisticShare: '上报安装版本统计',
|
||||
usageStatisticShareHint: '上报匿名安装ID和当前前后端版本,用于统计各版本安装用户数',
|
||||
workflowStatisticShare: '分享工作流数据',
|
||||
workflowStatisticShareHint: '分享工作流统计数据到热门工作流,供其他MPer参考',
|
||||
bigMemoryMode: '大内存模式',
|
||||
@@ -2385,6 +2424,7 @@ export default {
|
||||
updating: '正在更新 {site} Cookie & UA...',
|
||||
success: '{site} 更新Cookie & UA成功!',
|
||||
failed: '{site} 更新失败:{message}',
|
||||
requestFailed: '请求失败,请稍后重试',
|
||||
updateButton: '开始更新',
|
||||
},
|
||||
siteAddEdit: {
|
||||
@@ -2423,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: '插件仓库保存成功',
|
||||
@@ -2537,6 +2585,10 @@ export default {
|
||||
episodeFormatRecommendFailed: '集数定位生成失败,请稍后重试',
|
||||
episodeFormatRecommendRule: '命中规则:{rule}',
|
||||
episodeFormatRecommendSample: '样本文件:{file}',
|
||||
episodeFormatRuleDetails: '手动整理集数定位规则',
|
||||
episodeFormatFinal: '最终集数定位:{format}',
|
||||
episodeFormatManualInput: '手动输入集数定位',
|
||||
episodeFormatRulePattern: '规则正则:{pattern}',
|
||||
episodeOffset: '集数偏移',
|
||||
episodeOffsetHint: '集数偏移运算,如-10或EP*2',
|
||||
episodeOffsetPlaceholder: '如-10',
|
||||
@@ -2567,6 +2619,8 @@ export default {
|
||||
previewSeasonInfo: '季信息',
|
||||
previewSeasonLabel: '季',
|
||||
previewEpisodeCount: '总集数',
|
||||
customWordsApplied: '识别词应用详情',
|
||||
customWordsSameRules: '{count} 个文件应用相同识别词',
|
||||
previewAfterColumn: '整理后',
|
||||
previewBeforeColumn: '整理前',
|
||||
previewFileNameColumn: '文件名',
|
||||
@@ -2818,6 +2872,7 @@ export default {
|
||||
settings: '设置',
|
||||
projectHome: '项目主页',
|
||||
updateHistory: '更新说明',
|
||||
versionHistory: '版本历史',
|
||||
local: '本地',
|
||||
systemVersion: '系统版本',
|
||||
incompatibleSystemVersion: '当前 MoviePilot 版本不满足插件要求,无法安装',
|
||||
@@ -2833,7 +2888,9 @@ export default {
|
||||
confirmReset: '此操作将恢复插件 {name} 的默认设置,并清除所有相关数据,确定要继续吗?',
|
||||
resetSuccess: '插件 {name} 数据已重置',
|
||||
resetFailed: '插件 {name} 重置失败:{message}',
|
||||
updateHistoryTitle: '{name} 更新说明',
|
||||
updateHistoryTitle: '{name} 版本历史',
|
||||
updateHistoryEmpty: '暂未获取到更新说明',
|
||||
updateHistoryLoadFailed: '读取更新说明失败,请稍后重试',
|
||||
updateToLatest: '更新到最新版本',
|
||||
updatingTo: '更新 {name} 到 {version} 版本...',
|
||||
folderNameEmpty: '文件夹名称不能为空',
|
||||
|
||||
@@ -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',
|
||||
@@ -1374,6 +1392,18 @@ export default {
|
||||
expand: '展開',
|
||||
collapse: '收起',
|
||||
clearCache: '清除快取',
|
||||
versionStatistic: '版本統計',
|
||||
versionStatisticTitle: '安裝版本統計',
|
||||
totalInstallUsers: '安裝用戶',
|
||||
activeToday: '今日活躍',
|
||||
active7Days: '7日活躍',
|
||||
active30Days: '30日活躍',
|
||||
backendVersionStatistic: '後端版本',
|
||||
frontendVersionStatistic: '前端版本',
|
||||
version: '版本',
|
||||
users: '用戶數',
|
||||
lastUpdated: '更新時間',
|
||||
noVersionStatisticData: '暫無統計數據',
|
||||
},
|
||||
system: {
|
||||
custom: '自定義',
|
||||
@@ -1437,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}',
|
||||
@@ -1448,26 +1482,29 @@ export default {
|
||||
llmProviderOpenAuthPage: '開啟授權頁面',
|
||||
llmProviderCheckAuthStatus: '檢查授權狀態',
|
||||
audioInputProvider: '音頻輸入提供商',
|
||||
audioInputProviderHint: '用於識別用戶音頻消息的服務,支援 OpenAI 音頻接口、Chat Audio 兼容接口和 Xiaomi MiMo',
|
||||
audioInputProviderHint:
|
||||
'用於識別用戶音頻消息的服務,支援 OpenAI 音頻接口、Chat Audio 兼容接口、Xiaomi MiMo 和 MiniMax',
|
||||
audioProviderOpenAiAudio: 'OpenAI Audio 兼容',
|
||||
audioProviderChatAudio: 'Chat Audio 兼容',
|
||||
audioProviderMimo: '小米 MiMo',
|
||||
audioProviderMinimax: 'MiniMax',
|
||||
audioInputApiKey: '音頻輸入 API密鑰',
|
||||
audioInputApiKeyHint: '音頻輸入轉寫使用的 API 密鑰',
|
||||
audioInputBaseUrl: '音頻輸入基礎URL',
|
||||
audioInputBaseUrlHint:
|
||||
'音頻輸入接口基礎URL,Chat Audio 類服務可填寫對應兼容地址,MiMo 預設 https://api.xiaomimimo.com/v1',
|
||||
'音頻輸入接口基礎URL,Chat Audio 類服務可填寫對應兼容地址,MiMo 預設 https://api.xiaomimimo.com/v1,MiniMax 預設 https://api.minimaxi.com/v1',
|
||||
audioInputModel: '音頻輸入模型',
|
||||
audioInputModelHint: '用於將音頻內容轉換為文字的模型名稱',
|
||||
audioInputLanguage: '識別語言',
|
||||
audioInputLanguageHint: '音頻轉寫預設語言,例如 zh、en,留空時按後端預設處理',
|
||||
audioOutputProvider: '音頻輸出提供商',
|
||||
audioOutputProviderHint: '用於生成語音回覆的服務,支援 OpenAI 音頻接口、Chat Audio 兼容接口和 Xiaomi MiMo',
|
||||
audioOutputProviderHint:
|
||||
'用於生成語音回覆的服務,支援 OpenAI 音頻接口、Chat Audio 兼容接口、Xiaomi MiMo 和 MiniMax',
|
||||
audioOutputApiKey: '音頻輸出 API密鑰',
|
||||
audioOutputApiKeyHint: '文字轉語音使用的 API 密鑰',
|
||||
audioOutputBaseUrl: '音頻輸出基礎URL',
|
||||
audioOutputBaseUrlHint:
|
||||
'音頻輸出接口基礎URL,Chat Audio 類服務可填寫對應兼容地址,MiMo 預設 https://api.xiaomimimo.com/v1',
|
||||
'音頻輸出接口基礎URL,Chat Audio 類服務可填寫對應兼容地址,MiMo 預設 https://api.xiaomimimo.com/v1,MiniMax 預設 https://api.minimaxi.com/v1',
|
||||
audioOutputModel: '音頻輸出模型',
|
||||
audioOutputModelHint: '用於將文字內容轉換為語音的模型名稱',
|
||||
audioOutputVoice: '語音音色',
|
||||
@@ -1546,6 +1583,8 @@ export default {
|
||||
subscribeStatisticShareHint: '分享訂閱統計數據到熱門訂閱,供其他MPer參考',
|
||||
pluginStatisticShare: '上報插件安裝數據',
|
||||
pluginStatisticShareHint: '上報插件安裝數據給服務器,用於統計展示插件安裝情況',
|
||||
usageStatisticShare: '上報安裝版本統計',
|
||||
usageStatisticShareHint: '上報匿名安裝ID和當前前後端版本,用於統計各版本安裝用戶數',
|
||||
workflowStatisticShare: '分享工作流數據',
|
||||
workflowStatisticShareHint: '分享工作流統計數據到熱門工作流,供其他MPer參考',
|
||||
bigMemoryMode: '大內存模式',
|
||||
@@ -2386,6 +2425,7 @@ export default {
|
||||
updating: '正在更新 {site} Cookie & UA...',
|
||||
success: '{site} 更新Cookie & UA成功!',
|
||||
failed: '{site} 更新失敗:{message}',
|
||||
requestFailed: '請求失敗,請稍後重試',
|
||||
updateButton: '開始更新',
|
||||
},
|
||||
siteAddEdit: {
|
||||
@@ -2424,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: '插件倉庫儲存成功',
|
||||
@@ -2538,6 +2586,10 @@ export default {
|
||||
episodeFormatRecommendFailed: '集數定位生成失敗,請稍後重試',
|
||||
episodeFormatRecommendRule: '命中規則:{rule}',
|
||||
episodeFormatRecommendSample: '樣本文件:{file}',
|
||||
episodeFormatRuleDetails: '手動整理集數定位規則',
|
||||
episodeFormatFinal: '最終集數定位:{format}',
|
||||
episodeFormatManualInput: '手動輸入集數定位',
|
||||
episodeFormatRulePattern: '規則正則:{pattern}',
|
||||
episodeOffset: '集數偏移',
|
||||
episodeOffsetHint: '集數偏移運算,如-10或EP*2',
|
||||
episodeOffsetPlaceholder: '如-10',
|
||||
@@ -2568,6 +2620,8 @@ export default {
|
||||
previewSeasonInfo: '季資訊',
|
||||
previewSeasonLabel: '季',
|
||||
previewEpisodeCount: '總集數',
|
||||
customWordsApplied: '識別詞應用詳情',
|
||||
customWordsSameRules: '{count} 個文件套用相同識別詞',
|
||||
previewAfterColumn: '整理後',
|
||||
previewBeforeColumn: '整理前',
|
||||
previewFileNameColumn: '文件名',
|
||||
@@ -2819,6 +2873,7 @@ export default {
|
||||
settings: '設置',
|
||||
projectHome: '項目主頁',
|
||||
updateHistory: '更新說明',
|
||||
versionHistory: '版本歷史',
|
||||
local: '本地',
|
||||
installToLocal: '安裝到本地',
|
||||
totalDownloads: '共 {count} 次下載',
|
||||
@@ -2832,7 +2887,9 @@ export default {
|
||||
confirmReset: '此操作將恢復插件 {name} 的默認設置,並清除所有相關數據,確定要繼續嗎?',
|
||||
resetSuccess: '插件 {name} 數據已重置',
|
||||
resetFailed: '插件 {name} 重置失敗:{message}',
|
||||
updateHistoryTitle: '{name} 更新說明',
|
||||
updateHistoryTitle: '{name} 版本歷史',
|
||||
updateHistoryEmpty: '暫未獲取到更新說明',
|
||||
updateHistoryLoadFailed: '讀取更新說明失敗,請稍後重試',
|
||||
updateToLatest: '更新到最新版本',
|
||||
updatingTo: '正在更新 {name} 至 v{version} ...',
|
||||
folderNameEmpty: '文件夾名稱不能為空',
|
||||
|
||||
@@ -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: {},
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -44,7 +44,7 @@ const { registerHeaderTab } = useDynamicHeaderTab()
|
||||
|
||||
// 注册动态标签页
|
||||
registerHeaderTab({
|
||||
items: settingTabs.value,
|
||||
items: settingTabs,
|
||||
modelValue: activeTab,
|
||||
})
|
||||
|
||||
|
||||
@@ -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: [
|
||||
{
|
||||
|
||||
@@ -69,7 +69,7 @@ const { registerHeaderTab } = useDynamicHeaderTab()
|
||||
|
||||
// 注册动态标签页
|
||||
registerHeaderTab({
|
||||
items: workflowTabs.value,
|
||||
items: workflowTabs,
|
||||
modelValue: activeTab,
|
||||
appendButtons: [
|
||||
{
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
// 工具栏
|
||||
|
||||
@@ -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深度链接构建成功:', {
|
||||
|
||||
@@ -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 图标名称数组
|
||||
|
||||
@@ -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}!`)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
// 将别名数组拆分为、分隔的字符串
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -57,7 +57,10 @@ const SystemSettings = ref<any>({
|
||||
LLM_SUPPORT_AUDIO_OUTPUT: false,
|
||||
LLM_API_KEY: null,
|
||||
LLM_BASE_URL: 'https://api.deepseek.com',
|
||||
LLM_USE_PROXY: true,
|
||||
LLM_BASE_URL_PRESET: null,
|
||||
LLM_MAX_CONTEXT_TOKENS: 64,
|
||||
LLM_USER_AGENT: null,
|
||||
AUDIO_INPUT_PROVIDER: 'openai',
|
||||
AUDIO_INPUT_API_KEY: null,
|
||||
AUDIO_INPUT_BASE_URL: null,
|
||||
@@ -73,7 +76,6 @@ const SystemSettings = ref<any>({
|
||||
AI_RECOMMEND_ENABLED: false,
|
||||
AI_RECOMMEND_USER_PREFERENCE: null,
|
||||
AI_RECOMMEND_MAX_ITEMS: 50,
|
||||
LLM_MAX_CONTEXT_TOKENS: 64,
|
||||
},
|
||||
// 高级系统设置
|
||||
Advanced: {
|
||||
@@ -82,6 +84,7 @@ const SystemSettings = ref<any>({
|
||||
GLOBAL_IMAGE_CACHE: false,
|
||||
SUBSCRIBE_STATISTIC_SHARE: true,
|
||||
PLUGIN_STATISTIC_SHARE: true,
|
||||
USAGE_STATISTIC_SHARE: true,
|
||||
WORKFLOW_STATISTIC_SHARE: true,
|
||||
BIG_MEMORY_MODE: false,
|
||||
DB_WAL_ENABLE: false,
|
||||
@@ -132,6 +135,7 @@ const audioProviderItems = computed(() => [
|
||||
{ title: t('setting.system.audioProviderOpenAiAudio'), value: 'openai' },
|
||||
{ title: t('setting.system.audioProviderChatAudio'), value: 'openai_chat_audio' },
|
||||
{ title: t('setting.system.audioProviderMimo'), value: 'mimo' },
|
||||
{ title: t('setting.system.audioProviderMinimax'), value: 'minimax' },
|
||||
])
|
||||
|
||||
// 刮削配置
|
||||
@@ -211,7 +215,9 @@ type LlmSettingsSnapshot = {
|
||||
LLM_THINKING_LEVEL: string
|
||||
LLM_API_KEY: string
|
||||
LLM_BASE_URL: string
|
||||
LLM_USE_PROXY: boolean
|
||||
LLM_BASE_URL_PRESET: string
|
||||
LLM_USER_AGENT: string
|
||||
}
|
||||
|
||||
let llmTestRequestId = 0
|
||||
@@ -245,6 +251,20 @@ const llmBaseUrlPresetRef = computed({
|
||||
},
|
||||
})
|
||||
|
||||
const llmUseProxyRef = computed({
|
||||
get: () => Boolean(SystemSettings.value.Basic.LLM_USE_PROXY),
|
||||
set: value => {
|
||||
SystemSettings.value.Basic.LLM_USE_PROXY = Boolean(value)
|
||||
},
|
||||
})
|
||||
|
||||
const llmUserAgentRef = computed({
|
||||
get: () => String(SystemSettings.value.Basic.LLM_USER_AGENT ?? ''),
|
||||
set: value => {
|
||||
SystemSettings.value.Basic.LLM_USER_AGENT = value || ''
|
||||
},
|
||||
})
|
||||
|
||||
const llmModelRef = computed({
|
||||
get: () => String(SystemSettings.value.Basic.LLM_MODEL ?? ''),
|
||||
set: value => {
|
||||
@@ -290,6 +310,8 @@ const {
|
||||
apiKey: llmApiKeyRef,
|
||||
baseUrl: llmBaseUrlRef,
|
||||
baseUrlPreset: llmBaseUrlPresetRef,
|
||||
useProxy: llmUseProxyRef,
|
||||
userAgent: llmUserAgentRef,
|
||||
model: llmModelRef,
|
||||
maxContextTokens: llmMaxContextRef,
|
||||
})
|
||||
@@ -350,7 +372,9 @@ function buildLlmSnapshot(): LlmSettingsSnapshot {
|
||||
LLM_THINKING_LEVEL: String(SystemSettings.value.Basic.LLM_THINKING_LEVEL ?? 'off'),
|
||||
LLM_API_KEY: String(SystemSettings.value.Basic.LLM_API_KEY ?? ''),
|
||||
LLM_BASE_URL: String(SystemSettings.value.Basic.LLM_BASE_URL ?? ''),
|
||||
LLM_USE_PROXY: Boolean(SystemSettings.value.Basic.LLM_USE_PROXY),
|
||||
LLM_BASE_URL_PRESET: String(SystemSettings.value.Basic.LLM_BASE_URL_PRESET ?? ''),
|
||||
LLM_USER_AGENT: String(SystemSettings.value.Basic.LLM_USER_AGENT ?? ''),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -366,7 +390,9 @@ function buildLlmTestPayload(snapshot: LlmSettingsSnapshot) {
|
||||
thinking_level: snapshot.LLM_THINKING_LEVEL.trim(),
|
||||
api_key: snapshot.LLM_API_KEY.trim(),
|
||||
base_url: snapshot.LLM_BASE_URL.trim(),
|
||||
use_proxy: snapshot.LLM_USE_PROXY,
|
||||
base_url_preset: snapshot.LLM_BASE_URL_PRESET.trim(),
|
||||
user_agent: snapshot.LLM_USER_AGENT.trim(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -640,9 +666,9 @@ async function loadSystemSettings() {
|
||||
if (result.data.hasOwnProperty(key)) (SystemSettings.value[sectionKey] as any)[key] = result.data[key]
|
||||
})
|
||||
}
|
||||
const accelEnabled = Boolean(result.data.RUST_ACCEL_ENABLED)
|
||||
rustAccelAvailable.value = accelEnabled
|
||||
if (!accelEnabled) SystemSettings.value.Advanced.RUST_ACCEL = false
|
||||
const accelAvailable = Boolean(result.data.RUST_ACCEL_AVAILABLE ?? result.data.RUST_ACCEL_ENABLED)
|
||||
rustAccelAvailable.value = accelAvailable
|
||||
if (!accelAvailable) SystemSettings.value.Advanced.RUST_ACCEL = false
|
||||
SystemSettings.value.Basic.LLM_THINKING_LEVEL = resolveThinkingLevelValue(result.data)
|
||||
await loadLlmProviders()
|
||||
}
|
||||
@@ -1192,6 +1218,14 @@ watch(currentLlmSnapshotKey, (snapshotKey, previousSnapshotKey) => {
|
||||
</template>
|
||||
</VCombobox>
|
||||
</VCol>
|
||||
<VCol v-if="SystemSettings.Basic.AI_AGENT_ENABLE && showBaseUrlField" cols="12">
|
||||
<VSwitch
|
||||
v-model="SystemSettings.Basic.LLM_USE_PROXY"
|
||||
:label="t('setting.system.llmUseProxy')"
|
||||
:hint="t('setting.system.llmUseProxyHint')"
|
||||
persistent-hint
|
||||
/>
|
||||
</VCol>
|
||||
<VCol v-if="SystemSettings.Basic.AI_AGENT_ENABLE && showApiKeyField" cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="SystemSettings.Basic.LLM_API_KEY"
|
||||
@@ -1306,6 +1340,15 @@ watch(currentLlmSnapshotKey, (snapshotKey, previousSnapshotKey) => {
|
||||
prepend-inner-icon="mdi-counter"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol v-if="SystemSettings.Basic.AI_AGENT_ENABLE && showBaseUrlField" cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="SystemSettings.Basic.LLM_USER_AGENT"
|
||||
:label="t('setting.system.llmUserAgent')"
|
||||
:hint="t('setting.system.llmUserAgentHint')"
|
||||
persistent-hint
|
||||
prepend-inner-icon="mdi-card-account-details-outline"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol v-if="SystemSettings.Basic.AI_AGENT_ENABLE" cols="12" md="6">
|
||||
<VSelect
|
||||
v-model="SystemSettings.Basic.LLM_THINKING_LEVEL"
|
||||
@@ -1767,6 +1810,14 @@ watch(currentLlmSnapshotKey, (snapshotKey, previousSnapshotKey) => {
|
||||
persistent-hint
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VSwitch
|
||||
v-model="SystemSettings.Advanced.USAGE_STATISTIC_SHARE"
|
||||
:label="t('setting.system.usageStatisticShare')"
|
||||
:hint="t('setting.system.usageStatisticShareHint')"
|
||||
persistent-hint
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VSwitch
|
||||
v-model="SystemSettings.Advanced.WORKFLOW_STATISTIC_SHARE"
|
||||
|
||||
@@ -40,6 +40,20 @@ const baseUrlPresetRef = computed({
|
||||
},
|
||||
})
|
||||
|
||||
const useProxyRef = computed({
|
||||
get: () => wizardData.value.agent.useProxy,
|
||||
set: value => {
|
||||
wizardData.value.agent.useProxy = Boolean(value)
|
||||
},
|
||||
})
|
||||
|
||||
const userAgentRef = computed({
|
||||
get: () => wizardData.value.agent.userAgent,
|
||||
set: value => {
|
||||
wizardData.value.agent.userAgent = value || ''
|
||||
},
|
||||
})
|
||||
|
||||
const modelRef = computed({
|
||||
get: () => wizardData.value.agent.model,
|
||||
set: value => {
|
||||
@@ -92,6 +106,8 @@ const {
|
||||
apiKey: apiKeyRef,
|
||||
baseUrl: baseUrlRef,
|
||||
baseUrlPreset: baseUrlPresetRef,
|
||||
useProxy: useProxyRef,
|
||||
userAgent: userAgentRef,
|
||||
model: modelRef,
|
||||
maxContextTokens: maxContextTokensRef,
|
||||
authConnected: authConnectedRef,
|
||||
@@ -171,6 +187,7 @@ const audioProviderItems = computed(() => [
|
||||
{ title: t('setting.system.audioProviderOpenAiAudio'), value: 'openai' },
|
||||
{ title: t('setting.system.audioProviderChatAudio'), value: 'openai_chat_audio' },
|
||||
{ title: t('setting.system.audioProviderMimo'), value: 'mimo' },
|
||||
{ title: t('setting.system.audioProviderMinimax'), value: 'minimax' },
|
||||
])
|
||||
|
||||
const providerAuthMethods = computed(() => selectedProvider.value?.oauth_methods || [])
|
||||
@@ -332,6 +349,16 @@ onMounted(async () => {
|
||||
</VCombobox>
|
||||
</VCol>
|
||||
|
||||
<VCol v-if="showBaseUrlField" cols="12">
|
||||
<VSwitch
|
||||
v-model="wizardData.agent.useProxy"
|
||||
:label="t('setting.system.llmUseProxy')"
|
||||
:hint="t('setting.system.llmUseProxyHint')"
|
||||
persistent-hint
|
||||
color="primary"
|
||||
/>
|
||||
</VCol>
|
||||
|
||||
<VCol v-if="showApiKeyField" cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="wizardData.agent.apiKey"
|
||||
@@ -437,6 +464,16 @@ onMounted(async () => {
|
||||
/>
|
||||
</VCol>
|
||||
|
||||
<VCol v-if="showBaseUrlField" cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="wizardData.agent.userAgent"
|
||||
:label="t('setting.system.llmUserAgent')"
|
||||
:hint="t('setting.system.llmUserAgentHint')"
|
||||
persistent-hint
|
||||
prepend-inner-icon="mdi-card-account-details-outline"
|
||||
/>
|
||||
</VCol>
|
||||
|
||||
<VCol cols="12" md="6">
|
||||
<VSelect
|
||||
v-model="wizardData.agent.thinkingLevel"
|
||||
|
||||
@@ -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)))
|
||||
|
||||
Reference in New Issue
Block a user