feat: add Agent Assistant component and integrate with theme customizer

- Implemented Agent Assistant widget with chat functionality, including message handling and streaming responses.
- Added new localization strings for Agent Assistant in English, Simplified Chinese, and Traditional Chinese.
- Updated DefaultLayout to include the Agent Assistant and Theme Customizer components.
- Enhanced UserProfile to manage the opening of the Theme Customizer through global events.
- Adjusted CSS styles for the Agent Assistant and its interactions with other components.
- Introduced new events for opening the Theme Customizer and managing its state.
This commit is contained in:
jxxghp
2026-06-16 20:44:00 +08:00
parent 33599cc21d
commit d8015b7def
10 changed files with 1340 additions and 365 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -12,20 +12,9 @@ import {
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]
'close': []
}>()
const customColorInput = ref<HTMLInputElement | null>(null)
@@ -45,15 +34,9 @@ const {
const { appMode } = usePWA()
const { t } = useI18n()
const { global: globalTheme } = useTheme()
const display = useDisplay()
const defaultPrimaryColor = themeCustomizerPrimaryColors[0].value
const customizerViewportHeight = ref('100dvh')
const drawer = computed({
get: () => props.modelValue,
set: value => emit('update:modelValue', value),
})
function getVisibleViewportHeight() {
if (typeof window === 'undefined') return '100dvh'
@@ -87,13 +70,14 @@ function clearThemeCustomizerOpenState() {
syncThemeCustomizerOpenState(false)
}
watch(drawer, syncThemeCustomizerOpenState, { immediate: true })
watch(drawer, isOpen => {
if (isOpen) nextTick(syncCustomizerViewportHeight)
})
function handleGlobalKeydown(event: KeyboardEvent) {
// 固定侧栏不再依赖 Vuetify overlay手动补上常见的 Esc 关闭行为。
if (event.key === 'Escape') emit('close')
}
onMounted(() => {
syncCustomizerViewportHeight()
window.addEventListener('keydown', handleGlobalKeydown)
window.addEventListener('resize', syncCustomizerViewportHeight)
window.addEventListener('orientationchange', syncCustomizerViewportHeight)
window.visualViewport?.addEventListener('resize', syncCustomizerViewportHeight)
@@ -104,41 +88,16 @@ onScopeDispose(clearThemeCustomizerOpenState)
onScopeDispose(() => {
if (typeof window === 'undefined') return
window.removeEventListener('keydown', handleGlobalKeydown)
window.removeEventListener('resize', syncCustomizerViewportHeight)
window.removeEventListener('orientationchange', syncCustomizerViewportHeight)
window.visualViewport?.removeEventListener('resize', syncCustomizerViewportHeight)
window.visualViewport?.removeEventListener('scroll', syncCustomizerViewportHeight)
})
const customizerContainer = computed(() => (appMode.value ? VDialog : VNavigationDrawer))
const customizerContainerStyle = computed<CSSProperties>(() => {
if (!appMode.value) return {}
return {
'--theme-customizer-viewport-height': customizerViewportHeight.value,
}
})
const customizerContainerProps = computed(() => {
if (appMode.value && display.mdAndDown.value) {
return {
class: 'theme-customizer-dialog-overlay',
scrim: true,
fullscreen: !display.mdAndUp.value,
scrollable: true,
}
}
return {
class: 'theme-customizer-drawer',
location: 'right' as const,
scrim: false,
temporary: true,
width: 420,
persistent: false,
}
})
const customizerContainerStyle = computed<CSSProperties>(() => ({
'--theme-customizer-viewport-height': customizerViewportHeight.value,
}))
const themeOptions = computed<Array<{ icon: string; title: string; value: ThemeCustomizerTheme }>>(() => [
{ title: t('theme.light'), value: 'light', icon: 'mdi-white-balance-sunny' },
@@ -273,255 +232,224 @@ async function handleResetSettings() {
</script>
<template>
<Teleport to="body">
<Transition name="theme-customizer-glass">
<div
v-if="drawer"
class="theme-customizer-glass-backdrop"
:class="{ 'theme-customizer-glass-backdrop--dialog': appMode }"
/>
</Transition>
<component
:is="customizerContainer"
v-model="drawer"
v-bind="customizerContainerProps"
:style="customizerContainerStyle"
>
<div
class="theme-customizer-panel"
:class="{ 'theme-customizer-panel--dialog': appMode, 'app-surface': appMode }"
>
<div class="theme-customizer-header py-5 px-4">
<div>
<h2 class="theme-customizer-title">{{ t('theme.customizer.title') }}</h2>
</div>
<div class="theme-customizer-header-actions">
<VBadge color="error" dot :model-value="showResetBadge" location="top end" offset-x="2" offset-y="2">
<IconBtn :aria-label="t('theme.customizer.reset')" @click="handleResetSettings">
<VIcon class="text-high-emphasis" icon="mdi-refresh" />
</IconBtn>
</VBadge>
<IconBtn :aria-label="t('common.close')" @click="drawer = false">
<VIcon class="text-high-emphasis" icon="mdi-close" />
</IconBtn>
</div>
<aside
class="theme-customizer-panel-host"
:style="customizerContainerStyle"
role="dialog"
:aria-label="t('theme.customizer.title')"
>
<div class="theme-customizer-panel" :class="{ 'theme-customizer-panel--dialog': appMode, 'app-surface': appMode }">
<div class="theme-customizer-header py-5 px-4">
<div>
<h2 class="theme-customizer-title">{{ t('theme.customizer.title') }}</h2>
</div>
<div class="theme-customizer-header-actions">
<VBadge color="error" dot :model-value="showResetBadge" location="top end" offset-x="2" offset-y="2">
<IconBtn :aria-label="t('theme.customizer.reset')" @click="handleResetSettings">
<VIcon class="text-high-emphasis" icon="mdi-refresh" />
</IconBtn>
</VBadge>
<IconBtn :aria-label="t('common.close')" @click="emit('close')">
<VIcon class="text-high-emphasis" icon="mdi-close" />
</IconBtn>
</div>
</div>
<VDivider />
<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>
<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>
<h3 class="theme-customizer-section-title">{{ t('common.theme') }}</h3>
<div class="theme-customizer-option-grid theme-customizer-option-grid--theme">
<div
v-for="theme in themeOptions"
:key="theme.value"
class="theme-customizer-card-option"
:class="{ 'is-active': settings.theme === theme.value }"
@click="setTheme(theme.value)"
>
<VIcon class="theme-customizer-theme-icon" :icon="theme.icon" size="36" />
<span>{{ theme.title }}</span>
</div>
</div>
<VDivider class="mt-7" />
<h3 class="theme-customizer-section-title">{{ t('theme.customizer.skins') }}</h3>
<div class="theme-customizer-preview-grid theme-customizer-preview-grid--skins">
<div
v-for="skin in skinOptions"
:key="skin.value"
class="theme-customizer-preview-option"
:class="{ 'is-active': settings.skin === skin.value }"
@click="setSkin(skin.value)"
>
<span class="theme-customizer-mini-layout" :class="`theme-customizer-mini-layout--${skin.value}`">
<span class="mini-sidebar">
<i />
<i />
<i />
<i />
</span>
<span class="mini-content">
<i />
<i />
<i />
</span>
</span>
<span>{{ skin.title }}</span>
</div>
</div>
<VDivider class="mt-7" />
<h3 class="theme-customizer-section-title">{{ t('theme.customizer.radius') }}</h3>
<div class="theme-customizer-preview-grid theme-customizer-preview-grid--radius">
<div
v-for="radius in radiusOptions"
:key="radius.value"
class="theme-customizer-preview-option"
:class="{ 'is-active': settings.radius === radius.value }"
@click="setRadius(radius.value)"
>
<span
class="theme-customizer-radius-scene"
:style="{ '--theme-customizer-radius-preview': radius.previewRadius }"
>
<span class="theme-customizer-radius-scene__card">
<span class="theme-customizer-radius-scene__badge" />
<span class="theme-customizer-radius-scene__line" />
<span class="theme-customizer-radius-scene__line theme-customizer-radius-scene__line--short" />
</span>
</span>
<span>{{ radius.title }}</span>
</div>
</div>
<VDivider class="mt-7" />
<h3 class="theme-customizer-section-title">{{ t('theme.customizer.shadow') }}</h3>
<div class="theme-customizer-preview-grid theme-customizer-preview-grid--shadow">
<div
v-for="shadow in shadowOptions"
:key="shadow.value"
class="theme-customizer-preview-option"
:class="{ 'is-active': settings.shadow === shadow.value }"
@click="setShadow(shadow.value)"
>
<span class="theme-customizer-shadow-scene" :class="`theme-customizer-shadow-scene--${shadow.value}`">
<span class="theme-customizer-shadow-scene__panel">
<span class="theme-customizer-shadow-scene__panel-line" />
<span
class="theme-customizer-shadow-scene__panel-line theme-customizer-shadow-scene__panel-line--short"
/>
</span>
<span class="theme-customizer-shadow-scene__card">
<span class="theme-customizer-shadow-scene__badge" />
<span class="theme-customizer-shadow-scene__line theme-customizer-shadow-scene__line--short" />
<span class="theme-customizer-shadow-scene__line" />
</span>
</span>
<span>{{ shadow.title }}</span>
</div>
</div>
<div v-if="showSemiDarkMenuOption" class="theme-customizer-semi-dark">
<span>{{ t('theme.customizer.semiDarkMenu') }}</span>
<VSwitch
:model-value="settings.semiDarkMenu"
color="primary"
inset
hide-details
@update:model-value="setSemiDarkMenu(Boolean($event))"
<div
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>
</section>
</div>
<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>
<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>
</section>
</PerfectScrollbar>
</div>
</component>
</Teleport>
</div>
<VDivider class="mt-7" />
<h3 class="theme-customizer-section-title">{{ t('theme.customizer.skins') }}</h3>
<div class="theme-customizer-preview-grid theme-customizer-preview-grid--skins">
<div
v-for="skin in skinOptions"
:key="skin.value"
class="theme-customizer-preview-option"
:class="{ 'is-active': settings.skin === skin.value }"
@click="setSkin(skin.value)"
>
<span class="theme-customizer-mini-layout" :class="`theme-customizer-mini-layout--${skin.value}`">
<span class="mini-sidebar">
<i />
<i />
<i />
<i />
</span>
<span class="mini-content">
<i />
<i />
<i />
</span>
</span>
<span>{{ skin.title }}</span>
</div>
</div>
<VDivider class="mt-7" />
<h3 class="theme-customizer-section-title">{{ t('theme.customizer.radius') }}</h3>
<div class="theme-customizer-preview-grid theme-customizer-preview-grid--radius">
<div
v-for="radius in radiusOptions"
:key="radius.value"
class="theme-customizer-preview-option"
:class="{ 'is-active': settings.radius === radius.value }"
@click="setRadius(radius.value)"
>
<span
class="theme-customizer-radius-scene"
:style="{ '--theme-customizer-radius-preview': radius.previewRadius }"
>
<span class="theme-customizer-radius-scene__card">
<span class="theme-customizer-radius-scene__badge" />
<span class="theme-customizer-radius-scene__line" />
<span class="theme-customizer-radius-scene__line theme-customizer-radius-scene__line--short" />
</span>
</span>
<span>{{ radius.title }}</span>
</div>
</div>
<VDivider class="mt-7" />
<h3 class="theme-customizer-section-title">{{ t('theme.customizer.shadow') }}</h3>
<div class="theme-customizer-preview-grid theme-customizer-preview-grid--shadow">
<div
v-for="shadow in shadowOptions"
:key="shadow.value"
class="theme-customizer-preview-option"
:class="{ 'is-active': settings.shadow === shadow.value }"
@click="setShadow(shadow.value)"
>
<span class="theme-customizer-shadow-scene" :class="`theme-customizer-shadow-scene--${shadow.value}`">
<span class="theme-customizer-shadow-scene__panel">
<span class="theme-customizer-shadow-scene__panel-line" />
<span
class="theme-customizer-shadow-scene__panel-line theme-customizer-shadow-scene__panel-line--short"
/>
</span>
<span class="theme-customizer-shadow-scene__card">
<span class="theme-customizer-shadow-scene__badge" />
<span class="theme-customizer-shadow-scene__line theme-customizer-shadow-scene__line--short" />
<span class="theme-customizer-shadow-scene__line" />
</span>
</span>
<span>{{ shadow.title }}</span>
</div>
</div>
<div v-if="showSemiDarkMenuOption" class="theme-customizer-semi-dark">
<span>{{ t('theme.customizer.semiDarkMenu') }}</span>
<VSwitch
:model-value="settings.semiDarkMenu"
color="primary"
inset
hide-details
@update:model-value="setSemiDarkMenu(Boolean($event))"
/>
</div>
</section>
<VDivider v-if="showLayoutSection" />
<section v-if="showLayoutSection" class="theme-customizer-section">
<h3 class="theme-customizer-section-title">{{ t('theme.customizer.layout') }}</h3>
<div class="theme-customizer-preview-grid">
<div
v-for="layout in layoutOptions"
:key="layout.value"
class="theme-customizer-preview-option"
:class="{ 'is-active': settings.layout === layout.value, 'is-disabled': appMode }"
@click="handleLayoutChange(layout.value)"
>
<span class="theme-customizer-mini-layout" :class="`theme-customizer-mini-layout--${layout.value}`">
<span class="mini-sidebar">
<i />
<i />
<i />
</span>
<span class="mini-content">
<i />
<i />
<i />
</span>
</span>
<span>{{ layout.title }}</span>
</div>
</div>
</section>
</PerfectScrollbar>
</div>
</aside>
</template>
<style lang="scss">
<style lang="scss" scoped>
/* stylelint-disable no-descending-specificity */
.theme-customizer-drawer {
.theme-customizer-panel-host {
position: fixed !important;
z-index: 12000 !important;
z-index: 2102 !important;
display: flex;
overflow: hidden;
block-size: 100dvh !important;
border-radius: 0;
background: rgb(var(--v-theme-surface));
block-size: var(--theme-customizer-viewport-height, 100dvh) !important;
border-inline-start: 1px solid rgba(var(--v-theme-on-surface), 0.08) !important;
box-shadow: var(--app-surface-shadow) !important;
inset-block: 0 !important;
inline-size: 420px !important;
inset-block-start: 0 !important;
inset-inline-end: 0 !important;
max-block-size: 100dvh !important;
.v-navigation-drawer__content {
position: relative;
z-index: 1;
display: flex;
overflow: hidden;
flex-direction: column;
block-size: 100%;
}
}
.theme-customizer-dialog-overlay {
--theme-customizer-viewport-height: 100dvh;
z-index: 12000 !important;
}
.theme-customizer-dialog-overlay > .v-overlay__content {
overflow: hidden;
block-size: var(--theme-customizer-viewport-height);
margin-block: 0 !important;
max-block-size: var(--theme-customizer-viewport-height);
max-block-size: var(--theme-customizer-viewport-height, 100dvh) !important;
}
.theme-customizer-panel {
@@ -529,16 +457,16 @@ async function handleResetSettings() {
display: flex;
flex-direction: column;
block-size: 100%;
inline-size: 100%;
min-block-size: 0;
}
.theme-customizer-panel--dialog {
overflow: hidden;
background: rgb(var(--v-theme-surface));
block-size: var(--theme-customizer-viewport-height, 100dvh);
max-block-size: var(--theme-customizer-viewport-height, 100dvh);
block-size: 100%;
max-block-size: 100%;
/* fullscreen dialog 会贴 viewport-fit=cover 顶部,iOS 需要在面板内部避开系统状态栏。 */
/* 独立 App 模式会贴 viewport-fit=cover 顶部,面板内部需要避开 iOS 状态栏。 */
padding-block-start: env(safe-area-inset-top);
}
@@ -547,73 +475,12 @@ async function handleResetSettings() {
padding-block-end: env(safe-area-inset-bottom);
}
.theme-customizer-drawer.v-theme--transparent,
.v-theme--transparent .theme-customizer-drawer,
html[data-theme='transparent'] .theme-customizer-drawer,
.v-theme--transparent .theme-customizer-panel--dialog,
html[data-theme='transparent'] .theme-customizer-panel--dialog {
background: transparent !important;
}
.theme-customizer-glass-backdrop {
position: fixed;
z-index: 11999;
block-size: 100dvh;
inline-size: 420px;
inset-block: 0;
inset-inline-end: 0;
opacity: 0;
pointer-events: none;
transform: translateX(0);
}
:is(html[data-theme='transparent'], .v-theme--transparent) .theme-customizer-glass-backdrop {
backdrop-filter: blur(var(--transparent-blur-heavy, 16px));
background-color: rgba(var(--v-theme-surface), var(--transparent-opacity-heavy, 0.5));
opacity: 1;
}
.theme-customizer-glass-enter-active,
.theme-customizer-glass-leave-active {
transition:
opacity 0.2s cubic-bezier(0.4, 0, 0.2, 1),
transform 0.2s cubic-bezier(0.4, 0, 0.2, 1);
}
.theme-customizer-glass-enter-from,
.theme-customizer-glass-leave-to {
opacity: 0 !important;
transform: translateX(100%);
}
:is(html[data-theme='transparent'], .v-theme--transparent) .theme-customizer-drawer .v-navigation-drawer__content {
background: transparent !important;
}
@media (width <= 600px) {
.theme-customizer-glass-backdrop {
inline-size: 100vw;
.theme-customizer-panel-host {
inline-size: 100vw !important;
}
}
// 透明主题的全局 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;
@@ -1030,10 +897,6 @@ html[data-theme='transparent'] .v-overlay__content:has(.theme-customizer-drawer)
}
@media (width <= 600px) {
.theme-customizer-drawer {
inline-size: min(100vw, 420px) !important;
}
.theme-customizer-header,
.theme-customizer-section {
padding-inline: 22px;

View File

@@ -99,9 +99,9 @@ function submitCustomCSS() {
<style scoped>
.custom-css-dialog {
display: flex;
overflow: hidden;
flex-direction: column;
max-block-size: calc(100dvh - 2rem);
overflow: hidden;
}
.custom-css-header {
@@ -111,7 +111,7 @@ function submitCustomCSS() {
.custom-css-editor-body {
flex: 1 1 auto;
min-block-size: 0;
min-block-size: 240px;
}
.custom-css-editor {
@@ -141,8 +141,8 @@ function submitCustomCSS() {
.custom-css-editor {
flex: 1 1 auto;
min-block-size: 0;
block-size: auto;
min-block-size: 0;
}
.custom-css-actions {

View File

@@ -7,6 +7,7 @@ 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 THEME_CUSTOMIZER_OPEN_EVENT = 'moviepilot-theme-customizer-open'
export const themeCustomizerPrimaryColors = [
{ name: 'Purple', value: '#9155FD' },

View File

@@ -9,7 +9,9 @@ import ShortcutBar from '@/layouts/components/ShortcutBar.vue'
import UserProfile from '@/layouts/components/UserProfile.vue'
import QuickAccess from '@/layouts/components/QuickAccess.vue'
import HeaderTab from '@/layouts/components/HeaderTab.vue'
import { usePluginSidebarNavStore, useUserStore } from '@/stores'
import AgentAssistantWidget from '@/components/AgentAssistantWidget.vue'
import ThemeCustomizer from '@/components/ThemeCustomizer.vue'
import { useGlobalSettingsStore, usePluginSidebarNavStore, useUserStore } from '@/stores'
import { getNavMenus } from '@/router/i18n-menu'
import { filterPluginSidebarNavEntries } from '@/utils/pluginSidebarNav'
import { NavMenu } from '@/@layouts/types'
@@ -31,6 +33,7 @@ import { useGlobalOfflineStatus } from '@/composables/useOfflineStatus'
import {
readThemeCustomizerSettings,
THEME_CUSTOMIZER_CHANGE_EVENT,
THEME_CUSTOMIZER_OPEN_EVENT,
type ThemeCustomizerSettings,
} from '@/composables/useThemeCustomizer'
import logo from '@images/logo.svg?raw'
@@ -42,14 +45,17 @@ const { t } = useI18n()
const route = useRoute()
const router = useRouter()
const themeLayout = ref(readThemeCustomizerSettings().layout)
const showThemeCustomizer = ref(false)
// 用户 Store
const userStore = useUserStore()
const pluginSidebarNavStore = usePluginSidebarNavStore()
const globalSettingsStore = useGlobalSettingsStore()
// 获取用户权限信息
const userPermissions = computed(() => buildUserPermissionContext(userStore.superUser, userStore.permissions))
const canAdmin = computed(() => hasPermission(userPermissions.value, 'admin'))
const showAgentAssistant = computed(() => canAdmin.value && globalSettingsStore.get('AI_AGENT_ENABLE') === true)
// 开始菜单项
const startMenus = ref<NavMenu[]>([])
@@ -279,6 +285,10 @@ function handleThemeCustomizerChange(event: Event) {
themeLayout.value = (event as CustomEvent<ThemeCustomizerSettings>).detail.layout
}
function handleThemeCustomizerOpen() {
showThemeCustomizer.value = true
}
function isHorizontalNavActive(item: NavMenu) {
const targetPath = normalizeMenuPath(item.to)
if (!targetPath) return false
@@ -416,6 +426,10 @@ function appendPluginSidebarMenus() {
}
onMounted(async () => {
// 主题定制器由布局统一承载,监听需要尽早注册,避免异步加载菜单期间丢失打开事件。
window.addEventListener(THEME_CUSTOMIZER_CHANGE_EVENT, handleThemeCustomizerChange)
window.addEventListener(THEME_CUSTOMIZER_OPEN_EVENT, handleThemeCustomizerOpen)
// 获取菜单列表
startMenus.value = getMenuList(t('menu.start'))
discoveryMenus.value = getMenuList(t('menu.discovery'))
@@ -431,11 +445,10 @@ onMounted(async () => {
navigator.serviceWorker.addEventListener('message', handleServiceWorkerMessage)
}
window.addEventListener(THEME_CUSTOMIZER_CHANGE_EVENT, handleThemeCustomizerChange)
// 组件卸载时清理监听
onBeforeUnmount(() => {
window.removeEventListener(THEME_CUSTOMIZER_CHANGE_EVENT, handleThemeCustomizerChange)
window.removeEventListener(THEME_CUSTOMIZER_OPEN_EVENT, handleThemeCustomizerOpen)
if ('serviceWorker' in navigator) {
navigator.serviceWorker.removeEventListener('message', handleServiceWorkerMessage)
}
@@ -692,6 +705,12 @@ onMounted(async () => {
@close="handleClosePluginQuickAccess"
@plugin-click="handlePluginClick"
/>
<!-- 👉 Theme Customizer -->
<ThemeCustomizer v-if="showThemeCustomizer" @close="showThemeCustomizer = false" />
<!-- 👉 Agent Assistant -->
<AgentAssistantWidget v-if="showAgentAssistant" />
</template>
<style lang="scss" scoped>

View File

@@ -20,6 +20,7 @@ import {
persistPartialThemeCustomizerSettings,
readThemeCustomizerSettings,
THEME_CUSTOMIZER_CHANGE_EVENT,
THEME_CUSTOMIZER_OPEN_EVENT,
type ThemeCustomizerSettings,
} from '@/composables/useThemeCustomizer'
import { buildUserPermissionContext, hasPermission } from '@/utils/permission'
@@ -30,7 +31,6 @@ const ProgressDialog = defineAsyncComponent(() => import('@/components/dialog/Pr
const TransparencySettingsDialog = defineAsyncComponent(
() => import('@/components/dialog/TransparencySettingsDialog.vue'),
)
const ThemeCustomizer = defineAsyncComponent(() => import('@/components/ThemeCustomizer.vue'))
const UserAuthDialog = defineAsyncComponent(() => import('@/components/dialog/UserAuthDialog.vue'))
// 认证 Store
@@ -50,12 +50,12 @@ const $toast = useToast()
// UI模式菜单是否显示
const showUIModeMenu = ref(false)
// 用户头像主菜单是否显示;打开布局级面板前需要主动关闭,避免菜单 overlay 残留。
const showUserMenu = ref(false)
// 主题菜单是否显示
const showThemeMenu = ref(false)
// 主题定制器面板是否显示
const showThemeCustomizer = ref(false)
// 语言菜单是否显示
const showLanguageMenu = ref(false)
@@ -442,8 +442,11 @@ function showTransparencySettingsDialog() {
/** 从用户菜单打开主题定制器App 模式会在面板内部隐藏布局设置。 */
function showThemeCustomizerDrawer() {
showUserMenu.value = false
showThemeMenu.value = false
showThemeCustomizer.value = true
// 主题定制器由 DefaultLayout 统一挂载
window.dispatchEvent(new CustomEvent(THEME_CUSTOMIZER_OPEN_EVENT))
}
/** 保存自定义 CSS。 */
@@ -558,6 +561,7 @@ onUnmounted(() => {
<VImg :src="avatar" />
<VMenu
v-model="showUserMenu"
activator="parent"
width="15rem"
location="bottom end"
@@ -777,7 +781,6 @@ onUnmounted(() => {
</VMenu>
<!-- !SECTION -->
</VAvatar>
<ThemeCustomizer v-model="showThemeCustomizer" />
</template>
<style lang="scss" scoped>

View File

@@ -697,6 +697,19 @@ export default {
subtitle: 'Scheduled Services',
},
},
agentAssistant: {
title: 'AI Assistant',
assistant: 'Assistant',
ready: 'Ready',
thinking: 'Thinking',
newChat: 'New Chat',
emptyTitle: 'Ask about sites, subscriptions, downloads, or organization tasks',
placeholder: 'Ask MoviePilot...',
stop: 'Stop generating',
download: 'Download',
error: 'Assistant response failed',
noStream: 'This browser cannot read streaming responses',
},
workflow: {
components: 'Action Components',
clickToAdd: 'Click to Add',

View File

@@ -693,6 +693,19 @@ export default {
subtitle: '定时服务',
},
},
agentAssistant: {
title: '智能助手',
assistant: '助手',
ready: '随时待命',
thinking: '思考中',
newChat: '新会话',
emptyTitle: '可以直接询问站点、订阅、下载和整理任务',
placeholder: '询问 MoviePilot...',
stop: '停止生成',
download: '下载',
error: '智能助手响应失败',
noStream: '当前浏览器无法读取流式响应',
},
workflow: {
components: '动作组件',
clickToAdd: '点击添加',

View File

@@ -693,6 +693,19 @@ export default {
subtitle: '定時服務',
},
},
agentAssistant: {
title: '智能助手',
assistant: '助手',
ready: '隨時待命',
thinking: '思考中',
newChat: '新會話',
emptyTitle: '可以直接詢問站點、訂閱、下載和整理任務',
placeholder: '詢問 MoviePilot...',
stop: '停止生成',
download: '下載',
error: '智能助手響應失敗',
noStream: '目前瀏覽器無法讀取串流響應',
},
workflow: {
components: '動作組件',
clickToAdd: '點擊添加',

View File

@@ -922,6 +922,7 @@ html[data-theme="transparent"] .app-card-colorful,
}
:root {
--agent-assistant-fab-offset: 30rem;
--theme-customizer-fab-offset: 420px;
}
@@ -1040,6 +1041,14 @@ html[data-theme="transparent"] .app-card-colorful,
html[data-theme-customizer-open='true'] .global-action-buttons {
inset-inline-end: calc(var(--theme-customizer-fab-offset) + 2rem);
}
html[data-agent-assistant-open='true'] .compact-fab-stack {
inset-inline-end: calc(var(--agent-assistant-fab-offset) + max(1rem, calc(env(safe-area-inset-right) + 1rem)));
}
html[data-agent-assistant-open='true'] .global-action-buttons {
inset-inline-end: calc(var(--agent-assistant-fab-offset) + 2rem);
}
}
@media (width >= 601px) and (width <= 768px) {
@@ -1048,6 +1057,12 @@ html[data-theme="transparent"] .app-card-colorful,
var(--theme-customizer-fab-offset) + max(0.875rem, calc(env(safe-area-inset-right) + 0.875rem))
);
}
html[data-agent-assistant-open='true'] .compact-fab-stack {
inset-inline-end: calc(
var(--agent-assistant-fab-offset) + max(0.875rem, calc(env(safe-area-inset-right) + 0.875rem))
);
}
}
.apexcharts-title-text {