Compare commits

...

15 Commits

Author SHA1 Message Date
PKC278
a5bc4e6baf fix: restore search filter chip colors (#493) 2026-06-17 12:03:20 +08:00
jxxghp
15b4ee5893 feat: 隐藏主题定制器的滚动条,改善用户界面体验 2026-06-17 12:00:11 +08:00
jxxghp
8868403ff3 feat: 在组件挂载时同步主题定制器状态,确保全局 FAB 预留右侧空间 2026-06-17 11:29:26 +08:00
jxxghp
3abff72e25 feat: 添加语音录制功能,支持录音和相关提示信息 2026-06-17 11:11:55 +08:00
jxxghp
0c56cf0be7 feat: 优化消息列表样式,增加内容存在时的底部填充,改善滚动体验 2026-06-17 08:31:23 +08:00
jxxghp
ce12d04648 feat: 添加新会话时自动滚动到顶部,优化空态展示 2026-06-17 08:15:30 +08:00
jxxghp
efc0ae4df6 fix: keep agent composer floating without early scroll 2026-06-17 07:15:36 +08:00
jxxghp
2530c3bcd9 fix: stabilize mobile panel height 2026-06-17 07:01:33 +08:00
jxxghp
60e2402aff feat: 优化 AgentAssistantWidget 组件的样式和结构,增强可读性和用户体验 2026-06-16 23:29:58 +08:00
jxxghp
1a478f97fb feat: 添加历史会话功能,支持会话恢复和删除,更新多语言文本 2026-06-16 23:18:14 +08:00
jxxghp
33666703af feat: 添加选择功能和附件上传支持,更新多语言文本 2026-06-16 22:55:26 +08:00
jxxghp
cd69172a99 feat: 更新 AgentAssistantWidget 组件的空状态样式和文本内容 2026-06-16 22:23:37 +08:00
jxxghp
61749e3595 feat: Web Agent 透明主题磨砂效果 (#492) 2026-06-16 21:52:45 +08:00
jxxghp
b658533262 更新 package.json 2026-06-16 21:20:58 +08:00
jxxghp
d8015b7def 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.
2026-06-16 20:44:00 +08:00
18 changed files with 2679 additions and 398 deletions

View File

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

View File

@@ -1 +1 @@
{"root":["./build-icons.ts"],"version":"5.8.3"}
{"root":["./build-icons.ts"],"errors":true,"version":"6.0.3"}

File diff suppressed because it is too large Load Diff

View File

@@ -1,5 +1,4 @@
<script setup lang="ts">
import type { CSSProperties } from 'vue'
import {
themeCustomizerPrimaryColors,
useThemeCustomizer,
@@ -12,20 +11,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,27 +33,7 @@ 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'
const height = window.visualViewport?.height || window.innerHeight || document.documentElement.clientHeight
return height > 0 ? `${Math.round(height)}px` : '100dvh'
}
// iOS 小屏的可见视口会随地址栏和独立模式 safe area 变化,面板高度需要跟随真实可见高度。
function syncCustomizerViewportHeight() {
customizerViewportHeight.value = getVisibleViewportHeight()
}
// 将主题定制器打开状态同步到根节点,供全局悬浮按钮避让右侧面板。
function syncThemeCustomizerOpenState(isOpen: boolean) {
@@ -87,57 +55,22 @@ 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('resize', syncCustomizerViewportHeight)
window.addEventListener('orientationchange', syncCustomizerViewportHeight)
window.visualViewport?.addEventListener('resize', syncCustomizerViewportHeight)
window.visualViewport?.addEventListener('scroll', syncCustomizerViewportHeight)
// 面板一挂载就代表已打开,及时同步根节点状态让全局 FAB 预留右侧空间。
syncThemeCustomizerOpenState(true)
window.addEventListener('keydown', handleGlobalKeydown)
})
onScopeDispose(clearThemeCustomizerOpenState)
onScopeDispose(() => {
if (typeof window === 'undefined') return
window.removeEventListener('resize', syncCustomizerViewportHeight)
window.removeEventListener('orientationchange', syncCustomizerViewportHeight)
window.visualViewport?.removeEventListener('resize', syncCustomizerViewportHeight)
window.visualViewport?.removeEventListener('scroll', syncCustomizerViewportHeight)
})
const customizerContainer = computed(() => (appMode.value ? VDialog : VNavigationDrawer))
const customizerContainerStyle = computed<CSSProperties>(() => {
if (!appMode.value) return {}
return {
'--theme-customizer-viewport-height': customizerViewportHeight.value,
}
})
const customizerContainerProps = computed(() => {
if (appMode.value && display.mdAndDown.value) {
return {
class: 'theme-customizer-dialog-overlay',
scrim: true,
fullscreen: !display.mdAndUp.value,
scrollable: true,
}
}
return {
class: 'theme-customizer-drawer',
location: 'right' as const,
scrim: false,
temporary: true,
width: 420,
persistent: false,
}
window.removeEventListener('keydown', handleGlobalKeydown)
})
const themeOptions = computed<Array<{ icon: string; title: string; value: ThemeCustomizerTheme }>>(() => [
@@ -273,347 +206,264 @@ 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"
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));
/* 背景层保持完整视口高度,避免 iOS 键盘触发 visual viewport resize 后露出底层页面。 */
block-size: 100vh !important;
border-inline-start: 1px solid rgba(var(--v-theme-on-surface), 0.08) !important;
box-shadow: var(--app-surface-shadow) !important;
inset-block: 0 !important;
inline-size: 420px !important;
inset-block-start: 0 !important;
inset-inline-end: 0 !important;
max-block-size: 100dvh !important;
max-block-size: none !important;
min-block-size: 100vh !important;
}
.v-navigation-drawer__content {
position: relative;
z-index: 1;
display: flex;
overflow: hidden;
flex-direction: column;
block-size: 100%;
@supports (block-size: 100lvh) {
.theme-customizer-panel-host {
block-size: 100lvh !important;
min-block-size: 100lvh !important;
}
}
.theme-customizer-dialog-overlay {
--theme-customizer-viewport-height: 100dvh;
z-index: 12000 !important;
}
.theme-customizer-dialog-overlay > .v-overlay__content {
overflow: hidden;
block-size: var(--theme-customizer-viewport-height);
margin-block: 0 !important;
max-block-size: var(--theme-customizer-viewport-height);
}
.theme-customizer-panel {
position: relative;
display: flex;
flex-direction: column;
block-size: 100%;
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 需要在面板内部避开系统状态栏。 */
padding-block-start: env(safe-area-inset-top);
/* 独立 App 模式会贴 viewport-fit=cover 顶部,面板内部需要避开 iOS 状态栏。 */
padding-block-start: env(safe-area-inset-top, 0px);
}
.theme-customizer-panel--dialog .theme-customizer-body {
block-size: auto;
padding-block-end: env(safe-area-inset-bottom);
}
.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;
padding-block-end: env(safe-area-inset-bottom, 0px);
}
@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;
@@ -638,6 +488,18 @@ html[data-theme='transparent'] .v-overlay__content:has(.theme-customizer-drawer)
.theme-customizer-body {
flex: 1 1 auto;
min-block-size: 0;
-ms-overflow-style: none;
overscroll-behavior: contain;
scrollbar-width: none;
&::-webkit-scrollbar {
display: none;
}
:deep(.ps__rail-x),
:deep(.ps__rail-y) {
display: none !important;
}
}
.theme-customizer-section {
@@ -1030,10 +892,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

@@ -141,4 +141,29 @@ function updateFilter(key: string, values: string[]) {
gap: 1rem;
grid-template-columns: repeat(auto-fit, minmax(18rem, 1fr));
}
.filter-options {
display: flex;
flex-wrap: wrap;
}
.filter-chip {
border: 1px solid rgba(var(--v-theme-primary), 0.2);
margin: 4px;
background-color: rgba(var(--v-theme-primary), 0.1) !important;
color: rgba(var(--v-theme-on-surface), 0.9) !important;
font-weight: 500;
transition: all 0.2s ease;
}
.filter-chip:hover {
background-color: rgba(var(--v-theme-primary), 0.15) !important;
}
.filter-chip.v-chip--selected {
background-color: rgba(var(--v-theme-primary), 0.85) !important;
box-shadow: 0 2px 4px rgba(var(--v-theme-primary), 0.3);
color: rgb(var(--v-theme-on-primary)) !important;
font-weight: 600;
}
</style>

View File

@@ -142,4 +142,24 @@ function handleDetail(item: Context) {
max-block-size: 60vh;
overflow-y: auto;
}
.chip-season {
background-color: #3f51b5;
color: white;
}
.chip-free {
background-color: #4caf50;
color: white;
}
.chip-discount {
background-color: #ff5722;
color: white;
}
.chip-bonus {
background-color: #9c27b0;
color: white;
}
</style>

View File

@@ -85,7 +85,7 @@ function updateFilter(values: string[]) {
@update:model-value="updateFilter"
>
<VChip
v-for="option in options"
v-for="option in options"
:key="option"
:value="option"
filter
@@ -106,3 +106,30 @@ function updateFilter(values: string[]) {
</VCard>
</VDialog>
</template>
<style scoped>
.filter-options {
display: flex;
flex-wrap: wrap;
}
.filter-chip {
border: 1px solid rgba(var(--v-theme-primary), 0.2);
margin: 4px;
background-color: rgba(var(--v-theme-primary), 0.1) !important;
color: rgba(var(--v-theme-on-surface), 0.9) !important;
font-weight: 500;
transition: all 0.2s ease;
}
.filter-chip:hover {
background-color: rgba(var(--v-theme-primary), 0.15) !important;
}
.filter-chip.v-chip--selected {
background-color: rgba(var(--v-theme-primary), 0.85) !important;
box-shadow: 0 2px 4px rgba(var(--v-theme-primary), 0.3);
color: rgb(var(--v-theme-on-primary)) !important;
font-weight: 600;
}
</style>

View File

@@ -372,7 +372,7 @@ onMounted(() => {
:key="key"
variant="tonal"
size="small"
:color="filterForm[key].length > 0 ? 'primary' : undefined"
color="primary"
:prepend-icon="getFilterIcon(key)"
class="filter-btn"
rounded="pill"
@@ -555,7 +555,7 @@ onMounted(() => {
v-for="(title, key) in filterTitles"
v-show="filterOptions[key].length > 0"
:key="key"
variant="text"
variant="tonal"
color="primary"
class="filter-btn-mobile"
@click="toggleFilterMenu(key)"
@@ -575,7 +575,7 @@ onMounted(() => {
</VBtn>
<!-- 全部筛选按钮 -->
<VBtn variant="text" color="primary" class="filter-btn-mobile" @click="toggleAllFilterMenu">
<VBtn variant="tonal" color="primary" class="filter-btn-mobile" @click="toggleAllFilterMenu">
<VIcon icon="mdi-filter-variant" class="filter-icon me-1"></VIcon>
<span class="filter-label">
{{ t('torrent.allFilters') }}
@@ -665,7 +665,6 @@ onMounted(() => {
.filter-btn {
min-inline-size: 0;
background: rgba(var(--v-theme-surface-variant), 0.1);
transition: opacity 0.2s;
}
@@ -733,7 +732,6 @@ onMounted(() => {
justify-content: center;
border: 1px solid rgba(var(--v-theme-on-surface), 0.08);
border-radius: 8px;
background-color: rgba(var(--v-theme-surface-variant), 0.08);
block-size: auto;
min-block-size: 48px;
padding-block: 4px;

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(() => 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,35 @@ export default {
subtitle: 'Scheduled Services',
},
},
agentAssistant: {
title: 'AI Assistant',
assistant: 'Assistant',
ready: 'Ready',
thinking: 'Thinking',
newChat: 'New Chat',
history: 'Chat History',
noHistory: 'No chat history yet',
deleteHistory: 'Delete chat history',
untitledSession: 'Untitled chat',
emptyTitle: 'What should we handle today?',
emptySubtitle: 'Ask about sites, subscriptions, downloads, or organization tasks.',
placeholder: 'Ask MoviePilot...',
stop: 'Stop generating',
download: 'Download',
attachFile: 'Choose image or file',
recordVoice: 'Record voice',
stopRecording: 'Stop recording ({time})',
attachmentMessage: 'Attachment message',
removeAttachment: 'Remove attachment',
uploadFailed: 'Attachment upload failed',
recordUnsupported: 'Voice recording is not supported by this browser',
recordPermissionDenied: 'Cannot access the microphone. Please check browser permissions.',
recordFailed: 'Voice recording failed. Please try again.',
choiceSelected: 'Selected: {option}',
choiceExpired: 'This choice expired. Please ask again.',
error: 'Assistant response failed',
noStream: 'This browser cannot read streaming responses',
},
workflow: {
components: 'Action Components',
clickToAdd: 'Click to Add',

View File

@@ -693,6 +693,35 @@ export default {
subtitle: '定时服务',
},
},
agentAssistant: {
title: '智能助手',
assistant: '助手',
ready: '随时待命',
thinking: '思考中',
newChat: '新会话',
history: '历史会话',
noHistory: '暂无历史会话',
deleteHistory: '删除历史会话',
untitledSession: '未命名会话',
emptyTitle: '今天想处理什么?',
emptySubtitle: '站点、订阅、下载、整理任务,都可以直接问我。',
placeholder: '询问 MoviePilot...',
stop: '停止生成',
download: '下载',
attachFile: '选择图片或文件',
recordVoice: '录制语音',
stopRecording: '停止录音({time}',
attachmentMessage: '附件消息',
removeAttachment: '移除附件',
uploadFailed: '附件上传失败',
recordUnsupported: '当前浏览器不支持录音',
recordPermissionDenied: '无法访问麦克风,请检查浏览器权限',
recordFailed: '录音失败,请重试',
choiceSelected: '已选择:{option}',
choiceExpired: '该选择已失效,请重新发起选择',
error: '智能助手响应失败',
noStream: '当前浏览器无法读取流式响应',
},
workflow: {
components: '动作组件',
clickToAdd: '点击添加',

View File

@@ -693,6 +693,35 @@ export default {
subtitle: '定時服務',
},
},
agentAssistant: {
title: '智能助手',
assistant: '助手',
ready: '隨時待命',
thinking: '思考中',
newChat: '新會話',
history: '歷史會話',
noHistory: '暫無歷史會話',
deleteHistory: '刪除歷史會話',
untitledSession: '未命名會話',
emptyTitle: '今天想處理什麼?',
emptySubtitle: '站點、訂閱、下載、整理任務,都可以直接問我。',
placeholder: '詢問 MoviePilot...',
stop: '停止生成',
download: '下載',
attachFile: '選擇圖片或文件',
recordVoice: '錄製語音',
stopRecording: '停止錄音({time}',
attachmentMessage: '附件消息',
removeAttachment: '移除附件',
uploadFailed: '附件上傳失敗',
recordUnsupported: '目前瀏覽器不支援錄音',
recordPermissionDenied: '無法存取麥克風,請檢查瀏覽器權限',
recordFailed: '錄音失敗,請重試',
choiceSelected: '已選擇:{option}',
choiceExpired: '該選擇已失效,請重新發起選擇',
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 {

View File

@@ -130,4 +130,34 @@ html[data-theme="transparent"] {
background-color: rgba(var(--v-theme-surface), var(--transparent-opacity));
}
}
// 主题定制器面板
.theme-customizer-panel-host {
backdrop-filter: blur(var(--transparent-blur-heavy));
background-color: rgba(var(--v-theme-surface), var(--transparent-opacity-heavy)) !important;
border-inline-start: 1px solid rgba(var(--v-theme-on-surface), 0.08) !important;
}
.theme-customizer-panel {
backdrop-filter: blur(var(--transparent-blur));
background-color: transparent;
}
// 智能助手面板
.agent-assistant-panel {
backdrop-filter: blur(var(--transparent-blur-heavy));
background-color: rgba(var(--v-theme-surface), var(--transparent-opacity-heavy)) !important;
border-inline-start: 1px solid rgba(var(--v-theme-on-surface), 0.08);
}
.agent-assistant-shell {
--agent-assistant-panel-bg: rgba(var(--v-theme-surface), var(--transparent-opacity-heavy));
--agent-assistant-panel-blur: var(--transparent-blur);
--agent-assistant-assistant-bg: rgba(var(--v-theme-surface), var(--transparent-opacity-heavy));
}
.agent-assistant-fab {
backdrop-filter: blur(var(--transparent-blur));
background-color: rgba(var(--v-theme-surface), var(--transparent-opacity-heavy));
}
}

View File

@@ -2,6 +2,7 @@
<script lang="ts" setup>
import { useToast } from 'vue-toastification'
import api from '@/api'
import { useGlobalSettingsStore } from '@/stores'
import { DownloaderConf, MediaServerConf } from '@/api/types'
import DownloaderCard from '@/components/cards/DownloaderCard.vue'
import MediaServerCard from '@/components/cards/MediaServerCard.vue'
@@ -17,6 +18,7 @@ const display = useDisplay()
const theme = useTheme()
const isTransparentTheme = computed(() => theme.name.value === 'transparent')
const globalSettingsStore = useGlobalSettingsStore()
// 国际化
const { t } = useI18n()
@@ -698,6 +700,8 @@ async function saveBasicSettings() {
savingBasic.value = true
try {
if (await saveSystemSetting(SystemSettings.value.Basic)) {
// 更新全局设置store使Web Agent图标实时生效
globalSettingsStore.setData({ ...globalSettingsStore.getData, ...SystemSettings.value.Basic })
$toast.success(t('setting.system.basicSaveSuccess'))
}
} finally {