mirror of
https://github.com/jxxghp/MoviePilot-Frontend.git
synced 2026-06-17 21:50:35 +08:00
Compare commits
21 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5b72eda4fc | ||
|
|
6c49d7a59e | ||
|
|
8900366faf | ||
|
|
e8e0ac9084 | ||
|
|
c66ee881b1 | ||
|
|
c055740926 | ||
|
|
a5bc4e6baf | ||
|
|
15b4ee5893 | ||
|
|
8868403ff3 | ||
|
|
3abff72e25 | ||
|
|
0c56cf0be7 | ||
|
|
ce12d04648 | ||
|
|
efc0ae4df6 | ||
|
|
2530c3bcd9 | ||
|
|
60e2402aff | ||
|
|
1a478f97fb | ||
|
|
33666703af | ||
|
|
cd69172a99 | ||
|
|
61749e3595 | ||
|
|
b658533262 | ||
|
|
d8015b7def |
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "moviepilot",
|
||||
"version": "2.13.10",
|
||||
"version": "2.13.11",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"bin": "dist/service.js",
|
||||
|
||||
@@ -1 +1 @@
|
||||
{"root":["./build-icons.ts"],"version":"5.8.3"}
|
||||
{"root":["./build-icons.ts"],"errors":true,"version":"6.0.3"}
|
||||
10
src/App.vue
10
src/App.vue
@@ -59,8 +59,9 @@ const loginStateKey = computed(() => (isLogin.value ? 'logged-in' : 'logged-out'
|
||||
const backgroundImages = ref<string[]>([])
|
||||
const activeImageIndex = ref(0)
|
||||
const isTransparentTheme = computed(() => globalTheme.name.value === 'transparent')
|
||||
const isLoginWallpaperRoute = computed(() => !isLogin.value && route.path === LOGIN_WALLPAPER_ROUTE)
|
||||
const shouldLoadBackgroundImages = computed(
|
||||
() => (!isLogin.value && route.path === LOGIN_WALLPAPER_ROUTE) || (Boolean(isLogin.value) && isTransparentTheme.value),
|
||||
() => isLoginWallpaperRoute.value || (Boolean(isLogin.value) && isTransparentTheme.value),
|
||||
)
|
||||
let backgroundRetryTimer: number | null = null
|
||||
let backgroundRequestController: AbortController | null = null
|
||||
@@ -434,7 +435,7 @@ onUnmounted(() => {
|
||||
<div v-if="isLogin && isTransparentTheme" class="global-blur-layer"></div>
|
||||
</div>
|
||||
<!-- 页面内容 -->
|
||||
<VApp>
|
||||
<VApp :class="{ 'app-shell--login-wallpaper': isLoginWallpaperRoute }">
|
||||
<RouterView />
|
||||
<!-- 全局共享弹窗入口,列表与卡片按需在这里挂载业务弹窗。 -->
|
||||
<SharedDialogHost />
|
||||
@@ -504,4 +505,9 @@ onUnmounted(() => {
|
||||
inset-block-start: 0;
|
||||
inset-inline-start: 0;
|
||||
}
|
||||
|
||||
/* 登录页壁纸在 VApp 外层渲染,登录页 VApp 需要透明才能露出壁纸。 */
|
||||
.app-shell--login-wallpaper.v-application {
|
||||
background: transparent !important;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1131,6 +1131,12 @@ export interface MediaServerLibrary {
|
||||
|
||||
// 消息通知
|
||||
export interface Message {
|
||||
// 消息ID
|
||||
id?: number
|
||||
// 消息渠道
|
||||
channel?: string
|
||||
// 消息来源
|
||||
source?: string
|
||||
// 消息类型
|
||||
mtype?: string
|
||||
// 消息标题
|
||||
@@ -1150,19 +1156,15 @@ export interface Message {
|
||||
// 消息方向:0-接收,1-发送
|
||||
action?: number
|
||||
// JSON
|
||||
note?: string
|
||||
note?: string | any[] | Record<string, any>
|
||||
}
|
||||
|
||||
// 系统通知
|
||||
export interface SystemNotification {
|
||||
// 通知类型 user/system/plugin
|
||||
type: string
|
||||
// 通知标题
|
||||
title: string
|
||||
// 通知内容
|
||||
text: string
|
||||
export interface SystemNotification extends Message {
|
||||
// 通知类型 user/system/plugin/notification
|
||||
type?: string
|
||||
// 通知时间
|
||||
date: string
|
||||
date?: string
|
||||
// 是否已读
|
||||
read?: boolean
|
||||
}
|
||||
|
||||
2193
src/components/AgentAssistantWidget.vue
Normal file
2193
src/components/AgentAssistantWidget.vue
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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;
|
||||
|
||||
@@ -1,226 +0,0 @@
|
||||
<script lang="ts" setup>
|
||||
import MarkdownIt from 'markdown-it'
|
||||
import mdLinkAttributes from 'markdown-it-link-attributes'
|
||||
import { isNullOrEmptyObject } from '@/@core/utils'
|
||||
import type { Message } from '@/api/types'
|
||||
import { formatDateDifference } from '@core/utils/formatters'
|
||||
|
||||
// 输入参数
|
||||
const props = defineProps({
|
||||
message: Object as PropType<Message>,
|
||||
width: String,
|
||||
height: String,
|
||||
})
|
||||
|
||||
// 定义事件
|
||||
const emit = defineEmits(['imageload'])
|
||||
|
||||
// 图片是否加载完成
|
||||
const isImageLoaded = ref(false)
|
||||
|
||||
// 图片是否加载失败
|
||||
const imageLoadError = ref(false)
|
||||
|
||||
// 初始化 markdown-it
|
||||
const md = new MarkdownIt({
|
||||
html: true,
|
||||
breaks: true,
|
||||
linkify: true,
|
||||
typographer: true,
|
||||
})
|
||||
|
||||
// 插件:链接在新窗口打开
|
||||
md.use(mdLinkAttributes, {
|
||||
attrs: {
|
||||
target: '_blank',
|
||||
rel: 'noopener noreferrer',
|
||||
},
|
||||
})
|
||||
|
||||
// 图片加载完成
|
||||
async function imageLoaded() {
|
||||
isImageLoaded.value = true
|
||||
emit('imageload')
|
||||
}
|
||||
|
||||
// 链接打开新窗口
|
||||
function openLink() {
|
||||
if (props.message?.link) window.open(props.message.link, '_blank')
|
||||
}
|
||||
|
||||
// 将note转换为json
|
||||
function noteToJson() {
|
||||
if (props.message?.note) {
|
||||
try {
|
||||
return JSON.parse(props.message.note)
|
||||
} catch (error) {
|
||||
return props.message.note
|
||||
}
|
||||
}
|
||||
return {}
|
||||
}
|
||||
|
||||
// 渲染 Markdown
|
||||
function renderMarkdown(value: string) {
|
||||
if (!value) return ''
|
||||
return md.render(value)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VCard variant="tonal" :width="props.width" :height="props.height" @click="openLink" max-width="23rem">
|
||||
<div v-if="props.message?.image" class="relative text-center card-cover-blurred">
|
||||
<VImg
|
||||
:src="props.message?.image"
|
||||
aspect-ratio="3/2"
|
||||
cover
|
||||
position="top"
|
||||
@load="imageLoaded"
|
||||
@error="imageLoadError = true"
|
||||
min-height="10rem"
|
||||
>
|
||||
<template #placeholder>
|
||||
<div class="w-full h-full">
|
||||
<VSkeletonLoader class="object-cover aspect-w-2 aspect-h-3" />
|
||||
</div>
|
||||
</template>
|
||||
</VImg>
|
||||
</div>
|
||||
<div
|
||||
v-if="
|
||||
props.message?.title &&
|
||||
!props.message?.text &&
|
||||
!props.message?.image &&
|
||||
isNullOrEmptyObject(props.message?.note) &&
|
||||
props.message?.action === 0
|
||||
"
|
||||
class="rounded-md text-body-1 py-2 px-4 elevation-2 bg-primary text-white chat-right mb-1"
|
||||
>
|
||||
<p class="mb-0">{{ props.message?.title }}</p>
|
||||
</div>
|
||||
<VCardTitle v-else-if="props.message?.title" class="break-words whitespace-break-spaces">
|
||||
{{ props.message?.title }}
|
||||
</VCardTitle>
|
||||
<div
|
||||
v-if="props.message?.text && props.message?.action === 0"
|
||||
class="rounded-md text-body-1 py-1 px-4 elevation-2 bg-primary text-white chat-right"
|
||||
>
|
||||
<div class="markdown-body" v-html="renderMarkdown(props.message?.text)" />
|
||||
</div>
|
||||
<VCardText
|
||||
v-if="props.message?.text && props.message?.action === 1"
|
||||
class="markdown-body"
|
||||
v-html="renderMarkdown(props.message?.text)"
|
||||
/>
|
||||
<VCardText v-if="!isNullOrEmptyObject(props.message?.note)">
|
||||
<VList>
|
||||
<VListItem v-for="(value, key) in noteToJson()" :key="key" two-line>
|
||||
<VListItemTitle v-if="value.title_year" class="font-bold break-words whitespace-break-spaces">
|
||||
{{ Number(key) + 1 }}. {{ value.title_year }}
|
||||
</VListItemTitle>
|
||||
<VListItemTitle v-if="value.enclosure" class="font-bold break-words whitespace-break-spaces">
|
||||
{{ Number(key) + 1 }}. {{ value.title }} {{ value.volume_factor }} ↑{{ value.seeders }}
|
||||
</VListItemTitle>
|
||||
<VListItemSubtitle v-if="value.type">
|
||||
类型:{{ value.type }} 评分:{{ value.vote_average }}
|
||||
</VListItemSubtitle>
|
||||
<VListItemSubtitle v-if="value.enclosure" class="whitespace-break-spaces">
|
||||
{{ value.description }}
|
||||
</VListItemSubtitle>
|
||||
</VListItem>
|
||||
</VList>
|
||||
</VCardText>
|
||||
</VCard>
|
||||
<div class="text-end">
|
||||
<span v-if="props.message?.action === 0" class="text-sm italic me-2">{{ props.message?.userid }}</span>
|
||||
<span class="text-sm italic me-2">{{
|
||||
formatDateDifference(props.message?.reg_time || props.message?.date || '')
|
||||
}}</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss">
|
||||
.markdown-body {
|
||||
word-break: break-all;
|
||||
|
||||
p {
|
||||
margin-block-end: 0.5rem;
|
||||
}
|
||||
|
||||
p:last-child {
|
||||
margin-block-end: 0;
|
||||
}
|
||||
|
||||
a {
|
||||
color: inherit;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
ul {
|
||||
list-style-type: disc;
|
||||
margin-block-end: 0.5rem;
|
||||
padding-inline-start: 1.5rem;
|
||||
}
|
||||
|
||||
ol {
|
||||
list-style-type: decimal;
|
||||
margin-block-end: 0.5rem;
|
||||
padding-inline-start: 1.5rem;
|
||||
}
|
||||
|
||||
li {
|
||||
display: list-item;
|
||||
margin-block-end: 0.25rem;
|
||||
}
|
||||
|
||||
code {
|
||||
border-radius: 4px;
|
||||
background-color: rgba(var(--v-border-color), 0.1);
|
||||
font-family: monospace;
|
||||
padding-block: 0.2rem;
|
||||
padding-inline: 0.4rem;
|
||||
}
|
||||
|
||||
pre {
|
||||
overflow: auto;
|
||||
padding: 1rem;
|
||||
border-radius: 8px;
|
||||
background-color: rgba(var(--v-border-color), 0.1);
|
||||
margin-block-end: 0.5rem;
|
||||
|
||||
code {
|
||||
padding: 0;
|
||||
background-color: transparent;
|
||||
}
|
||||
}
|
||||
|
||||
blockquote {
|
||||
border-inline-start: 4px solid rgba(var(--v-border-color), 0.2);
|
||||
font-style: italic;
|
||||
margin-block-end: 0.5rem;
|
||||
padding-inline-start: 1rem;
|
||||
}
|
||||
|
||||
table {
|
||||
border-collapse: collapse;
|
||||
inline-size: 100%;
|
||||
margin-block-end: 1rem;
|
||||
|
||||
th,
|
||||
td {
|
||||
padding: 0.5rem;
|
||||
border: 1px solid rgba(var(--v-border-color), 0.1);
|
||||
text-align: start;
|
||||
}
|
||||
|
||||
th {
|
||||
background-color: rgba(var(--v-border-color), 0.05);
|
||||
}
|
||||
}
|
||||
|
||||
img {
|
||||
block-size: auto;
|
||||
max-inline-size: 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -409,7 +409,6 @@ function handleCardClick() {
|
||||
:class="{
|
||||
'transition transform-cpu duration-300 -translate-y-1': hover.isHovering && !props.sortable,
|
||||
'outline-dotted outline-pink-500 outline-2': props.batchMode && props.selected,
|
||||
'subscribe-card-pending-tint': subscribeState === 'P',
|
||||
}"
|
||||
>
|
||||
<VCard
|
||||
@@ -418,6 +417,7 @@ function handleCardClick() {
|
||||
class="flex flex-col h-full overflow-hidden"
|
||||
:class="{
|
||||
'subscribe-card-paused': subscribeState === 'S',
|
||||
'subscribe-card-pending-tint': subscribeState === 'P',
|
||||
'cursor-move': props.sortable,
|
||||
}"
|
||||
min-height="150"
|
||||
@@ -588,7 +588,7 @@ function handleCardClick() {
|
||||
}
|
||||
|
||||
/**
|
||||
* 待定:用 ::after 浮层在 VCard 之上渲染 sky 漫反射式内发光
|
||||
* 待定:内发光挂在实际 VCard 上,跟随卡片圆角并被 overflow-hidden 裁剪。
|
||||
*/
|
||||
.subscribe-card-pending-tint {
|
||||
position: relative;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -1,139 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import api from '@/api'
|
||||
import { clearUnreadMessages } from '@/utils/badge'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useDisplay } from 'vuetify'
|
||||
|
||||
const MessageView = defineAsyncComponent(() => import('@/views/system/MessageView.vue'))
|
||||
|
||||
type MessageViewExpose = {
|
||||
pauseSSE?: () => void
|
||||
resumeSSE?: () => void
|
||||
refreshLatestMessages?: () => Promise<void> | void
|
||||
forceScrollToEnd?: () => void
|
||||
}
|
||||
|
||||
// 国际化
|
||||
const { t } = useI18n()
|
||||
|
||||
// 显示器宽度
|
||||
const display = useDisplay()
|
||||
|
||||
// 输入参数
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
})
|
||||
|
||||
// 定义触发的自定义事件
|
||||
const emit = defineEmits(['update:modelValue', 'close'])
|
||||
|
||||
// 弹窗显示状态
|
||||
const visible = computed({
|
||||
get: () => props.modelValue,
|
||||
set: value => {
|
||||
emit('update:modelValue', value)
|
||||
if (!value) emit('close')
|
||||
},
|
||||
})
|
||||
|
||||
// 输入消息
|
||||
const user_message = ref('')
|
||||
|
||||
// 发送按钮是否可用
|
||||
const sendButtonDisabled = ref(false)
|
||||
|
||||
// 消息视图引用
|
||||
const messageViewRef = ref<MessageViewExpose | null>(null)
|
||||
|
||||
/** 发送 Web 消息。 */
|
||||
async function sendMessage() {
|
||||
const messageText = user_message.value.trim()
|
||||
if (!messageText) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
sendButtonDisabled.value = true
|
||||
await api.post(`message/web?text=${encodeURIComponent(messageText)}`)
|
||||
user_message.value = ''
|
||||
messageViewRef.value?.forceScrollToEnd?.()
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
} finally {
|
||||
sendButtonDisabled.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** 清除未读消息计数和桌面角标。 */
|
||||
function clearUnreadMessageState() {
|
||||
window.setTimeout(() => {
|
||||
void clearUnreadMessages()
|
||||
}, 500)
|
||||
}
|
||||
|
||||
watch(visible, async newValue => {
|
||||
if (newValue) {
|
||||
await nextTick()
|
||||
messageViewRef.value?.resumeSSE?.()
|
||||
|
||||
clearUnreadMessageState()
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
messageViewRef.value?.pauseSSE?.()
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
await nextTick()
|
||||
|
||||
clearUnreadMessageState()
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
messageViewRef.value?.pauseSSE?.()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VDialog v-if="visible" v-model="visible" max-width="50rem" scrollable :fullscreen="!display.mdAndUp.value">
|
||||
<VCard>
|
||||
<VCardItem>
|
||||
<VCardTitle>
|
||||
<VIcon icon="mdi-message" class="me-2" />
|
||||
{{ t('shortcut.message.subtitle') }}
|
||||
</VCardTitle>
|
||||
<VDialogCloseBtn v-model="visible" />
|
||||
</VCardItem>
|
||||
<VDivider />
|
||||
<VCardText>
|
||||
<MessageView ref="messageViewRef" />
|
||||
</VCardText>
|
||||
<VDivider />
|
||||
<VCardActions class="pa-4">
|
||||
<div class="d-flex w-100 gap-2">
|
||||
<VTextField
|
||||
v-model="user_message"
|
||||
variant="outlined"
|
||||
hide-details
|
||||
density="compact"
|
||||
:placeholder="t('common.inputMessage')"
|
||||
@keyup.enter="sendMessage"
|
||||
/>
|
||||
<VBtn
|
||||
variant="elevated"
|
||||
:disabled="sendButtonDisabled"
|
||||
@click="sendMessage"
|
||||
:loading="sendButtonDisabled"
|
||||
color="primary"
|
||||
prepend-icon="mdi-send"
|
||||
>{{ t('common.send') }}
|
||||
</VBtn>
|
||||
</div>
|
||||
</VCardActions>
|
||||
</VCard>
|
||||
</VDialog>
|
||||
</template>
|
||||
@@ -39,10 +39,21 @@ const visible = computed({
|
||||
if (!value) emit('close')
|
||||
},
|
||||
})
|
||||
|
||||
const isFullscreen = computed(() => !display.mdAndUp.value)
|
||||
|
||||
// 仅系统健康检查弹窗需要在全屏时取消固定高度,避免其它快捷弹窗被误伤。
|
||||
const bodyClasses = computed(() => [
|
||||
props.bodyClass,
|
||||
{
|
||||
'system-health-dialog-body--fullscreen':
|
||||
isFullscreen.value && props.bodyClass.split(/\s+/).includes('system-health-dialog-body'),
|
||||
},
|
||||
])
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VDialog v-if="visible" v-model="visible" :max-width="props.maxWidth" scrollable :fullscreen="!display.mdAndUp.value">
|
||||
<VDialog v-if="visible" v-model="visible" :max-width="props.maxWidth" scrollable :fullscreen="isFullscreen">
|
||||
<VCard :class="props.cardClass">
|
||||
<VCardItem>
|
||||
<VCardTitle>
|
||||
@@ -53,7 +64,7 @@ const visible = computed({
|
||||
<VDialogCloseBtn v-model="visible" />
|
||||
</VCardItem>
|
||||
<VDivider />
|
||||
<VCardText :class="props.bodyClass">
|
||||
<VCardText :class="bodyClasses">
|
||||
<Component :is="props.view" v-bind="props.viewProps" />
|
||||
</VCardText>
|
||||
</VCard>
|
||||
@@ -61,8 +72,6 @@ const visible = computed({
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* stylelint-disable selector-pseudo-class-no-unknown */
|
||||
|
||||
.system-health-dialog-card {
|
||||
display: flex;
|
||||
overflow: hidden;
|
||||
@@ -78,7 +87,7 @@ const visible = computed({
|
||||
min-block-size: 0;
|
||||
}
|
||||
|
||||
:global(.v-dialog--fullscreen) .system-health-dialog-body {
|
||||
.system-health-dialog-body--fullscreen {
|
||||
block-size: auto;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -810,12 +810,6 @@ function handleBackdropClick(event: MouseEvent) {
|
||||
-webkit-user-select: none;
|
||||
}
|
||||
|
||||
:global(html.quick-access-scroll-locked),
|
||||
:global(html.quick-access-scroll-locked body) {
|
||||
overflow: hidden !important;
|
||||
overscroll-behavior: none;
|
||||
}
|
||||
|
||||
@media (hover: none) and (pointer: coarse) {
|
||||
.plugin-item:hover {
|
||||
background: transparent;
|
||||
|
||||
@@ -5,7 +5,6 @@ import { openSharedDialog } from '@/composables/useSharedDialog'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useUserStore } from '@/stores'
|
||||
import { buildUserPermissionContext, filterItemsByPermission, hasItemPermission, type PermissionProtectedItem } from '@/utils/permission'
|
||||
import { clearUnreadMessages, getUnreadCount, onUnreadMessage } from '@/utils/badge'
|
||||
|
||||
// 国际化
|
||||
const { t } = useI18n()
|
||||
@@ -21,7 +20,6 @@ const WordsView = defineAsyncComponent(() => import('@/views/system/WordsView.vu
|
||||
const CacheView = defineAsyncComponent(() => import('@/views/system/CacheView.vue'))
|
||||
const AccountSettingService = defineAsyncComponent(() => import('@/views/system/ServiceView.vue'))
|
||||
const ShortcutLogDialog = defineAsyncComponent(() => import('@/components/dialog/ShortcutLogDialog.vue'))
|
||||
const ShortcutMessageDialog = defineAsyncComponent(() => import('@/components/dialog/ShortcutMessageDialog.vue'))
|
||||
const ShortcutToolDialog = defineAsyncComponent(() => import('@/components/dialog/ShortcutToolDialog.vue'))
|
||||
|
||||
type ShortcutItem = PermissionProtectedItem & {
|
||||
@@ -44,12 +42,6 @@ const appsMenu = ref(false)
|
||||
// 菜单最大宽度
|
||||
const menuMaxWidth = ref(420)
|
||||
|
||||
// 未读消息数量,用于控制消息捷径卡片上的红点。
|
||||
const unreadMessageCount = ref(0)
|
||||
const hasUnreadMessages = computed(() => unreadMessageCount.value > 0)
|
||||
let unreadStateRevision = 0
|
||||
let stopUnreadMessageListener: (() => void) | null = null
|
||||
|
||||
// 定义捷径列表
|
||||
const shortcuts: ShortcutItem[] = [
|
||||
{
|
||||
@@ -123,55 +115,16 @@ const shortcuts: ShortcutItem[] = [
|
||||
component: ModuleTestView,
|
||||
titleText: t('shortcut.system.subtitle'),
|
||||
},
|
||||
{
|
||||
title: t('shortcut.message.title'),
|
||||
subtitle: t('shortcut.message.subtitle'),
|
||||
icon: 'mdi-message',
|
||||
dialog: 'message',
|
||||
customDialog: ShortcutMessageDialog,
|
||||
},
|
||||
].map(item => ({ ...item, permission: 'admin' }))
|
||||
|
||||
const visibleShortcuts = computed(() => filterItemsByPermission(shortcuts, userPermissions.value))
|
||||
|
||||
/** 设置消息捷径卡片的未读数量。 */
|
||||
function setUnreadMessageCount(count: number) {
|
||||
unreadMessageCount.value = Math.max(0, count)
|
||||
}
|
||||
|
||||
/** 同步全局未读消息数量到消息捷径卡片。 */
|
||||
function handleUnreadMessage(count: number) {
|
||||
unreadStateRevision += 1
|
||||
setUnreadMessageCount(count)
|
||||
}
|
||||
|
||||
/** 从 Service Worker 读取当前未读数量,避免错过启动早期事件。 */
|
||||
async function syncUnreadMessageStateFromBadge() {
|
||||
const revision = unreadStateRevision
|
||||
const count = await getUnreadCount()
|
||||
|
||||
if (revision === unreadStateRevision) {
|
||||
setUnreadMessageCount(count)
|
||||
}
|
||||
}
|
||||
|
||||
/** 清空未读消息数量和 PWA 桌面角标。 */
|
||||
function clearUnreadMessageState() {
|
||||
unreadStateRevision += 1
|
||||
setUnreadMessageCount(0)
|
||||
void clearUnreadMessages()
|
||||
}
|
||||
|
||||
/** 打开快捷工具对应的共享弹窗。 */
|
||||
function openShortcutDialog(item: (typeof shortcuts)[number]) {
|
||||
if (!hasItemPermission(item, userPermissions.value)) return
|
||||
|
||||
appsMenu.value = false
|
||||
|
||||
if (item.dialog === 'message') {
|
||||
clearUnreadMessageState()
|
||||
}
|
||||
|
||||
if (item.customDialog) {
|
||||
openSharedDialog(item.customDialog, {}, {}, { closeOn: ['close', 'update:modelValue'] })
|
||||
return
|
||||
@@ -195,21 +148,7 @@ function openShortcutDialog(item: (typeof shortcuts)[number]) {
|
||||
)
|
||||
}
|
||||
|
||||
/** 供外部调用的打开消息弹窗方法。 */
|
||||
function openMessageDialogFromExternal() {
|
||||
const messageShortcut = visibleShortcuts.value.find(item => item.dialog === 'message')
|
||||
if (messageShortcut) openShortcutDialog(messageShortcut)
|
||||
}
|
||||
|
||||
// 暴露方法给父组件
|
||||
defineExpose({
|
||||
openMessageDialog: openMessageDialogFromExternal,
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
stopUnreadMessageListener = onUnreadMessage(handleUnreadMessage)
|
||||
void syncUnreadMessageStateFromBadge()
|
||||
|
||||
const shortcut = getQueryValue('shortcut')
|
||||
if (shortcut) {
|
||||
const found = visibleShortcuts.value.find(item => item.dialog === shortcut)
|
||||
@@ -218,10 +157,6 @@ onMounted(() => {
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
stopUnreadMessageListener?.()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -257,30 +192,20 @@ onBeforeUnmount(() => {
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<!-- 循环渲染快捷方式 -->
|
||||
<div v-for="(item, index) in visibleShortcuts" :key="index">
|
||||
<VBadge
|
||||
:model-value="item.dialog === 'message' && hasUnreadMessages"
|
||||
dot
|
||||
color="error"
|
||||
location="top end"
|
||||
offset-x="8"
|
||||
offset-y="8"
|
||||
class="d-block h-full w-100"
|
||||
<VCard
|
||||
flat
|
||||
class="pa-2 d-flex align-center cursor-pointer transition-transform duration-300 hover:-translate-y-1 border h-full w-100"
|
||||
hover
|
||||
@click="openShortcutDialog(item)"
|
||||
>
|
||||
<VCard
|
||||
flat
|
||||
class="pa-2 d-flex align-center cursor-pointer transition-transform duration-300 hover:-translate-y-1 border h-full w-100"
|
||||
hover
|
||||
@click="openShortcutDialog(item)"
|
||||
>
|
||||
<VAvatar variant="text" size="48" rounded="lg">
|
||||
<VIcon color="primary" :icon="item.icon" size="24" />
|
||||
</VAvatar>
|
||||
<div>
|
||||
<div class="text-body-1 text-high-emphasis font-weight-medium">{{ item.title }}</div>
|
||||
<div class="text-caption text-medium-emphasis">{{ item.subtitle }}</div>
|
||||
</div>
|
||||
</VCard>
|
||||
</VBadge>
|
||||
<VAvatar variant="text" size="48" rounded="lg">
|
||||
<VIcon color="primary" :icon="item.icon" size="24" />
|
||||
</VAvatar>
|
||||
<div>
|
||||
<div class="text-body-1 text-high-emphasis font-weight-medium">{{ item.title }}</div>
|
||||
<div class="text-caption text-medium-emphasis">{{ item.subtitle }}</div>
|
||||
</div>
|
||||
</VCard>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,68 +1,251 @@
|
||||
<script setup lang="ts">
|
||||
import type { SystemNotification } from '@/api/types'
|
||||
import api from '@/api'
|
||||
import { clearUnreadMessages } from '@/utils/badge'
|
||||
import { formatDateDifference } from '@core/utils/formatters'
|
||||
import { SystemNotification } from '@/api/types'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useBackground } from '@/composables/useBackground'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
const { t } = useI18n()
|
||||
const { useDelayedSSE } = useBackground()
|
||||
|
||||
// 是否有新消息
|
||||
const hasNewMessage = ref(false)
|
||||
const PAGE_SIZE = 20
|
||||
// 固定通知项高度,配合 VVirtualScroll 避免历史通知过多时一次性渲染全部 DOM。
|
||||
const NOTIFICATION_ITEM_HEIGHT = 104
|
||||
const MEDIA_NOTIFICATION_TYPES = ['资源下载', '整理入库', '订阅', '媒体服务器', '手动处理']
|
||||
|
||||
// 通知列表
|
||||
const notificationList = ref<SystemNotification[]>([])
|
||||
const MAX_NOTIFICATIONS = 100
|
||||
|
||||
// 弹窗
|
||||
const appsMenu = ref(false)
|
||||
const hasNewMessage = ref(false)
|
||||
const notificationList = ref<SystemNotification[]>([])
|
||||
const page = ref(1)
|
||||
const loading = ref(false)
|
||||
const loadedOnce = ref(false)
|
||||
const hasMore = ref(true)
|
||||
const notificationKeys = new Set<string>()
|
||||
|
||||
// 标记所有消息为已读
|
||||
function markAllAsRead() {
|
||||
hasNewMessage.value = false
|
||||
// 标记所有消息为已读
|
||||
notificationList.value.forEach(item => {
|
||||
item.read = true
|
||||
})
|
||||
appsMenu.value = false
|
||||
const hasUnreadNotifications = computed(() => notificationList.value.some(item => item.read === false))
|
||||
|
||||
function normalizeNote(note: SystemNotification['note']) {
|
||||
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 handleMessage(event: MessageEvent) {
|
||||
if (event.data) {
|
||||
const noti: SystemNotification = JSON.parse(event.data)
|
||||
notificationList.value.unshift(noti)
|
||||
if (notificationList.value.length > MAX_NOTIFICATIONS) {
|
||||
notificationList.value.length = MAX_NOTIFICATIONS
|
||||
}
|
||||
hasNewMessage.value = true
|
||||
function getNotificationTime(item: SystemNotification) {
|
||||
return item.reg_time || item.date || ''
|
||||
}
|
||||
|
||||
function normalizeText(value: unknown) {
|
||||
return String(value ?? '').replace(/\s+/g, ' ').trim()
|
||||
}
|
||||
|
||||
function getNotificationKind(item: SystemNotification) {
|
||||
if (item.type === 'plugin' || item.mtype === '插件') return 'plugin'
|
||||
if (item.type === 'system' || item.mtype === '其它') return 'system'
|
||||
return item.mtype || item.type || ''
|
||||
}
|
||||
|
||||
function getNotificationTimeBucket(item: SystemNotification) {
|
||||
return getNotificationTime(item).slice(0, 16)
|
||||
}
|
||||
|
||||
function getNotificationContentKey(item: SystemNotification) {
|
||||
return [
|
||||
getNotificationKind(item),
|
||||
getNotificationTimeBucket(item),
|
||||
normalizeText(item.title),
|
||||
normalizeText(item.text),
|
||||
item.image ?? '',
|
||||
item.link ?? '',
|
||||
normalizeNote(item.note),
|
||||
].join('::')
|
||||
}
|
||||
|
||||
function getNotificationKeys(item: SystemNotification) {
|
||||
return [item.id ? `id:${item.id}` : '', `content:${getNotificationContentKey(item)}`].filter(Boolean)
|
||||
}
|
||||
|
||||
function getNotificationKey(item: SystemNotification) {
|
||||
return item.id ? `id:${item.id}` : `content:${getNotificationContentKey(item)}`
|
||||
}
|
||||
|
||||
function parseNotificationTime(value: string) {
|
||||
if (!value) return 0
|
||||
return new Date(value.includes('T') ? value : value.replaceAll(/-/g, '/')).getTime() || 0
|
||||
}
|
||||
|
||||
function sortNotifications() {
|
||||
notificationList.value = [...notificationList.value].sort(
|
||||
(a, b) => parseNotificationTime(getNotificationTime(b)) - parseNotificationTime(getNotificationTime(a)),
|
||||
)
|
||||
}
|
||||
|
||||
function compactNotifications(items: SystemNotification[]) {
|
||||
const contentKeys = new Set<string>()
|
||||
const idKeys = new Set<string>()
|
||||
const compactedItems: SystemNotification[] = []
|
||||
|
||||
items.forEach(item => {
|
||||
const contentKey = getNotificationContentKey(item)
|
||||
const idKey = item.id ? `id:${item.id}` : ''
|
||||
|
||||
if (contentKeys.has(contentKey) || (idKey && idKeys.has(idKey))) return
|
||||
|
||||
contentKeys.add(contentKey)
|
||||
if (idKey) idKeys.add(idKey)
|
||||
compactedItems.push(item)
|
||||
})
|
||||
|
||||
return compactedItems
|
||||
}
|
||||
|
||||
function normalizeNotification(item: SystemNotification, read = true): SystemNotification {
|
||||
return {
|
||||
...item,
|
||||
read,
|
||||
title: item.title || item.source || item.mtype || t('notification.center'),
|
||||
type: item.type || (item.action === 1 ? 'notification' : item.type),
|
||||
}
|
||||
}
|
||||
|
||||
// 延迟3秒启动SSE连接,避免认证信息尚未准备好。
|
||||
function mergeNotifications(items: SystemNotification[], options: { prepend?: boolean; read?: boolean } = {}) {
|
||||
const normalizedItems = items.map(item => normalizeNotification(item, options.read ?? true))
|
||||
const acceptedItems: SystemNotification[] = []
|
||||
|
||||
normalizedItems.forEach(item => {
|
||||
const keys = getNotificationKeys(item)
|
||||
if (keys.some(key => notificationKeys.has(key))) return
|
||||
|
||||
keys.forEach(key => notificationKeys.add(key))
|
||||
acceptedItems.push(item)
|
||||
})
|
||||
|
||||
if (acceptedItems.length === 0) return false
|
||||
|
||||
notificationList.value = options.prepend
|
||||
? [...acceptedItems, ...notificationList.value]
|
||||
: [...notificationList.value, ...acceptedItems]
|
||||
notificationList.value = compactNotifications(notificationList.value)
|
||||
sortNotifications()
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
async function loadNotifications({ done }: { done: (status: 'ok' | 'empty' | 'error') => void }) {
|
||||
if (loading.value) {
|
||||
done('ok')
|
||||
return
|
||||
}
|
||||
|
||||
if (!hasMore.value) {
|
||||
done('empty')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
loading.value = true
|
||||
const items = (await api.get('message/notification', {
|
||||
params: {
|
||||
page: page.value,
|
||||
count: PAGE_SIZE,
|
||||
},
|
||||
})) as SystemNotification[]
|
||||
|
||||
loadedOnce.value = true
|
||||
|
||||
if (items.length === 0) {
|
||||
hasMore.value = false
|
||||
done('empty')
|
||||
return
|
||||
}
|
||||
|
||||
mergeNotifications(items, { read: true })
|
||||
page.value += 1
|
||||
hasMore.value = items.length >= PAGE_SIZE
|
||||
done(hasMore.value ? 'ok' : 'empty')
|
||||
} catch (error) {
|
||||
console.error('加载通知失败:', error)
|
||||
done('error')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleMessage(event: MessageEvent) {
|
||||
if (!event.data) return
|
||||
|
||||
try {
|
||||
const notification = JSON.parse(event.data) as SystemNotification
|
||||
if (mergeNotifications([notification], { prepend: true, read: false })) {
|
||||
hasNewMessage.value = true
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('解析通知失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
/** 将通知列表标记为已读,并同步清理应用角标和未读红点。 */
|
||||
function markAllAsRead() {
|
||||
hasNewMessage.value = false
|
||||
notificationList.value.forEach(item => {
|
||||
item.read = true
|
||||
})
|
||||
void clearUnreadMessages()
|
||||
}
|
||||
|
||||
function getNotificationIcon(item: SystemNotification) {
|
||||
if (getNotificationKind(item) === 'plugin') return 'mdi-puzzle-outline'
|
||||
if (item.mtype === '资源下载') return 'mdi-download'
|
||||
if (item.mtype === '整理入库') return 'mdi-folder-check-outline'
|
||||
if (item.mtype === '订阅') return 'mdi-rss'
|
||||
if (item.mtype === '智能体') return 'lucide:bot'
|
||||
return getNotificationKind(item) === 'system' ? 'mdi-alert-circle-outline' : 'mdi-bell-outline'
|
||||
}
|
||||
|
||||
function getNotificationColor(item: SystemNotification) {
|
||||
if (getNotificationKind(item) === 'system') return 'error'
|
||||
if (getNotificationKind(item) === 'plugin') return 'warning'
|
||||
if (item.mtype === '资源下载') return 'info'
|
||||
if (item.mtype === '整理入库') return 'success'
|
||||
if (item.mtype === '订阅') return 'primary'
|
||||
return 'secondary'
|
||||
}
|
||||
|
||||
function isMediaNotification(item: SystemNotification) {
|
||||
return Boolean(item.image) || MEDIA_NOTIFICATION_TYPES.includes(item.mtype || '')
|
||||
}
|
||||
|
||||
function openNotification(item: SystemNotification) {
|
||||
item.read = true
|
||||
hasNewMessage.value = hasUnreadNotifications.value
|
||||
if (!hasUnreadNotifications.value) void clearUnreadMessages()
|
||||
if (item.link) window.open(item.link, '_blank')
|
||||
}
|
||||
|
||||
useDelayedSSE(
|
||||
`${import.meta.env.VITE_API_BASE_URL}system/message`,
|
||||
`${import.meta.env.VITE_API_BASE_URL}system/message?role=notification`,
|
||||
handleMessage,
|
||||
'user-notification',
|
||||
3000,
|
||||
{
|
||||
backgroundCloseDelay: 5000,
|
||||
reconnectDelay: 3000,
|
||||
maxReconnectAttempts: 3
|
||||
}
|
||||
maxReconnectAttempts: 3,
|
||||
},
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VMenu
|
||||
v-model="appsMenu"
|
||||
width="400"
|
||||
width="420"
|
||||
max-width="calc(100vw - 24px)"
|
||||
transition="scale-transition"
|
||||
close-on-content-click
|
||||
class="notification-menu"
|
||||
scrim
|
||||
>
|
||||
<!-- Menu Activator -->
|
||||
<template #activator="{ props }">
|
||||
<VBadge v-if="hasNewMessage" dot color="error" :offset-x="5" :offset-y="5" v-bind="props">
|
||||
<IconBtn>
|
||||
@@ -73,14 +256,14 @@ useDelayedSSE(
|
||||
<VIcon icon="mdi-bell-outline" />
|
||||
</IconBtn>
|
||||
</template>
|
||||
<!-- Menu Content -->
|
||||
<VCard>
|
||||
|
||||
<VCard class="notification-panel">
|
||||
<VCardItem class="py-3">
|
||||
<VCardTitle>{{ t('notification.center') }}</VCardTitle>
|
||||
<template #append>
|
||||
<VTooltip :text="t('notification.markRead')">
|
||||
<template #activator="{ props }">
|
||||
<IconBtn v-bind="props" @click="markAllAsRead">
|
||||
<IconBtn v-bind="props" @click.stop="markAllAsRead">
|
||||
<VIcon icon="mdi-email-check-outline" size="20" />
|
||||
</IconBtn>
|
||||
</template>
|
||||
@@ -88,42 +271,228 @@ useDelayedSSE(
|
||||
</template>
|
||||
</VCardItem>
|
||||
<VDivider />
|
||||
|
||||
<div class="notification-list-container">
|
||||
<div v-if="notificationList.length > 0">
|
||||
<VListItem v-for="(item, i) in notificationList" :key="i" lines="two" class="mb-1">
|
||||
<template #prepend>
|
||||
<VAvatar rounded>
|
||||
<VIcon v-if="item.type === 'user'" icon="mdi-account-alert" size="large"></VIcon>
|
||||
<VIcon v-else-if="item.type === 'plugin'" icon="mdi-robot" size="large"></VIcon>
|
||||
<VIcon v-else icon="mdi-laptop" size="large"></VIcon>
|
||||
</VAvatar>
|
||||
</template>
|
||||
<div>
|
||||
<div class="text-body-1 text-high-emphasis break-words whitespace-break-spaces">
|
||||
{{ item.title }}
|
||||
</div>
|
||||
<div class="text-caption mt-1.5">
|
||||
{{ item.text }}
|
||||
</div>
|
||||
<div class="text-sm text-primary mt-1.5">
|
||||
{{ formatDateDifference(item.date) }}
|
||||
</div>
|
||||
<VInfiniteScroll
|
||||
mode="intersect"
|
||||
side="end"
|
||||
:items="notificationList"
|
||||
class="notification-list-scroll"
|
||||
@load="loadNotifications"
|
||||
>
|
||||
<template #loading>
|
||||
<div class="py-3 text-center text-caption text-medium-emphasis">
|
||||
{{ t('message.loadMore') }}
|
||||
</div>
|
||||
</VListItem>
|
||||
</div>
|
||||
<div v-else class="py-8 text-center">
|
||||
<VIcon icon="mdi-bell-sleep-outline" size="40" class="mb-3" />
|
||||
<div>{{ t('notification.empty') }}</div>
|
||||
</div>
|
||||
</template>
|
||||
<template #empty>
|
||||
<div v-if="notificationList.length > 0" class="py-3 text-center text-caption text-medium-emphasis">
|
||||
{{ t('message.noMoreData') }}
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<VVirtualScroll
|
||||
v-if="notificationList.length > 0"
|
||||
renderless
|
||||
:items="notificationList"
|
||||
:item-height="NOTIFICATION_ITEM_HEIGHT"
|
||||
>
|
||||
<template #default="{ item, itemRef }">
|
||||
<div :ref="itemRef" :key="getNotificationKey(item)" class="notification-virtual-item">
|
||||
<button
|
||||
type="button"
|
||||
class="notification-row"
|
||||
:class="{
|
||||
'notification-row--unread': item.read === false,
|
||||
'notification-row--media': isMediaNotification(item),
|
||||
}"
|
||||
@click="openNotification(item)"
|
||||
>
|
||||
<div v-if="isMediaNotification(item)" class="notification-media">
|
||||
<VImg v-if="item.image" :src="item.image" cover class="notification-media__image">
|
||||
<template #placeholder>
|
||||
<VSkeletonLoader class="h-100 w-100" />
|
||||
</template>
|
||||
</VImg>
|
||||
<div v-else class="notification-media__fallback">
|
||||
<VIcon :icon="getNotificationIcon(item)" size="24" />
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="notification-icon" :class="`text-${getNotificationColor(item)}`">
|
||||
<VIcon :icon="getNotificationIcon(item)" size="22" />
|
||||
</div>
|
||||
|
||||
<div class="notification-content">
|
||||
<div class="notification-title-row">
|
||||
<span class="notification-title">{{ item.title }}</span>
|
||||
<span v-if="item.read === false" class="notification-unread-dot" />
|
||||
</div>
|
||||
<div v-if="item.text" class="notification-text">
|
||||
{{ item.text }}
|
||||
</div>
|
||||
<div class="notification-meta">
|
||||
<span v-if="item.mtype" class="notification-type">{{ item.mtype }}</span>
|
||||
<span>{{ formatDateDifference(getNotificationTime(item)) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
</VVirtualScroll>
|
||||
|
||||
<div v-if="notificationList.length === 0 && loadedOnce && !loading" class="notification-empty">
|
||||
<VIcon icon="mdi-bell-sleep-outline" size="40" class="mb-3" />
|
||||
<div>{{ t('notification.empty') }}</div>
|
||||
</div>
|
||||
</VInfiniteScroll>
|
||||
</div>
|
||||
</VCard>
|
||||
</VMenu>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.notification-panel {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.notification-list-container {
|
||||
max-block-size: 50vh;
|
||||
overflow-y: auto;
|
||||
max-block-size: min(560px, 62vh);
|
||||
overflow: hidden;
|
||||
scrollbar-width: thin;
|
||||
}
|
||||
|
||||
.notification-list-scroll {
|
||||
max-block-size: min(560px, 62vh);
|
||||
min-block-size: 160px;
|
||||
}
|
||||
|
||||
.notification-virtual-item {
|
||||
block-size: 104px;
|
||||
padding-block: 4px;
|
||||
padding-inline: 8px;
|
||||
}
|
||||
|
||||
.notification-row {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
inline-size: 100%;
|
||||
block-size: 100%;
|
||||
border: 0;
|
||||
border-radius: 8px;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
gap: 12px;
|
||||
padding: 10px;
|
||||
text-align: start;
|
||||
transition:
|
||||
background-color 0.2s ease,
|
||||
transform 0.2s ease;
|
||||
}
|
||||
|
||||
.notification-row:hover {
|
||||
background: rgba(var(--v-theme-primary), 0.08);
|
||||
}
|
||||
|
||||
.notification-row--unread {
|
||||
background: rgba(var(--v-theme-error), 0.07);
|
||||
}
|
||||
|
||||
.notification-row--media {
|
||||
min-block-size: 0;
|
||||
}
|
||||
|
||||
.notification-media {
|
||||
overflow: hidden;
|
||||
flex: 0 0 56px;
|
||||
block-size: 76px;
|
||||
border-radius: 6px;
|
||||
background: rgba(var(--v-theme-on-surface), 0.06);
|
||||
}
|
||||
|
||||
.notification-media__image,
|
||||
.notification-media__fallback {
|
||||
inline-size: 100%;
|
||||
block-size: 100%;
|
||||
}
|
||||
|
||||
.notification-media__fallback,
|
||||
.notification-icon {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
}
|
||||
|
||||
.notification-icon {
|
||||
flex: 0 0 40px;
|
||||
block-size: 40px;
|
||||
border-radius: 8px;
|
||||
background: rgba(var(--v-theme-on-surface), 0.06);
|
||||
}
|
||||
|
||||
.notification-content {
|
||||
min-inline-size: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.notification-title-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-block-size: 20px;
|
||||
}
|
||||
|
||||
.notification-title {
|
||||
overflow: hidden;
|
||||
font-size: 0.925rem;
|
||||
font-weight: 600;
|
||||
line-height: 1.35;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.notification-unread-dot {
|
||||
flex: 0 0 7px;
|
||||
inline-size: 7px;
|
||||
block-size: 7px;
|
||||
border-radius: 999px;
|
||||
background: rgb(var(--v-theme-error));
|
||||
}
|
||||
|
||||
.notification-text {
|
||||
display: -webkit-box;
|
||||
overflow: hidden;
|
||||
margin-block-start: 4px;
|
||||
color: rgba(var(--v-theme-on-surface), var(--v-medium-emphasis-opacity));
|
||||
font-size: 0.8125rem;
|
||||
line-height: 1.45;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 2;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.notification-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
margin-block-start: 6px;
|
||||
color: rgba(var(--v-theme-on-surface), var(--v-disabled-opacity));
|
||||
font-size: 0.75rem;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.notification-type {
|
||||
border-radius: 999px;
|
||||
background: rgba(var(--v-theme-primary), 0.1);
|
||||
color: rgb(var(--v-theme-primary));
|
||||
padding-block: 2px;
|
||||
padding-inline: 6px;
|
||||
}
|
||||
|
||||
.notification-empty {
|
||||
padding: 32px 16px;
|
||||
color: rgba(var(--v-theme-on-surface), var(--v-medium-emphasis-opacity));
|
||||
text-align: center;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -680,10 +680,6 @@ export default {
|
||||
title: 'System',
|
||||
subtitle: 'Health Check',
|
||||
},
|
||||
message: {
|
||||
title: 'Messages',
|
||||
subtitle: 'Message Center',
|
||||
},
|
||||
words: {
|
||||
title: 'Words',
|
||||
subtitle: 'Word Settings',
|
||||
@@ -697,6 +693,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',
|
||||
@@ -1169,6 +1194,7 @@ export default {
|
||||
currentEpisodeNotInLibrary: 'Current not in library',
|
||||
libraryUpdatedAt: 'Updated {time}',
|
||||
libraryUpdatedAtShort: '{time}',
|
||||
expandDayEvents: 'Show {count} more items for this day',
|
||||
},
|
||||
storage: {
|
||||
name: 'Name',
|
||||
|
||||
@@ -676,10 +676,6 @@ export default {
|
||||
title: '系统',
|
||||
subtitle: '健康检查',
|
||||
},
|
||||
message: {
|
||||
title: '消息',
|
||||
subtitle: '消息中心',
|
||||
},
|
||||
words: {
|
||||
title: '词表',
|
||||
subtitle: '词表设置',
|
||||
@@ -693,6 +689,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: '点击添加',
|
||||
@@ -1164,6 +1189,7 @@ export default {
|
||||
currentEpisodeNotInLibrary: '本集未入库',
|
||||
libraryUpdatedAt: '最近更新 {time}',
|
||||
libraryUpdatedAtShort: '{time}',
|
||||
expandDayEvents: '展开当天剩余 {count} 个条目',
|
||||
},
|
||||
storage: {
|
||||
name: '名称',
|
||||
|
||||
@@ -676,10 +676,6 @@ export default {
|
||||
title: '系統',
|
||||
subtitle: '健康檢查',
|
||||
},
|
||||
message: {
|
||||
title: '消息',
|
||||
subtitle: '消息中心',
|
||||
},
|
||||
words: {
|
||||
title: '詞表',
|
||||
subtitle: '詞表設置',
|
||||
@@ -693,6 +689,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: '點擊添加',
|
||||
@@ -1164,6 +1189,7 @@ export default {
|
||||
currentEpisodeNotInLibrary: '本集未入庫',
|
||||
libraryUpdatedAt: '最近更新 {time}',
|
||||
libraryUpdatedAtShort: '{time}',
|
||||
expandDayEvents: '展開當天剩餘 {count} 個條目',
|
||||
},
|
||||
storage: {
|
||||
name: '名稱',
|
||||
|
||||
@@ -894,8 +894,6 @@ onUnmounted(() => {
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
/* stylelint-disable selector-pseudo-class-no-unknown */
|
||||
|
||||
@use '@core/scss/pages/page-auth';
|
||||
|
||||
/* ===================== 布局根容器 ===================== */
|
||||
@@ -910,11 +908,6 @@ onUnmounted(() => {
|
||||
min-block-size: 100dvh;
|
||||
}
|
||||
|
||||
/* 登录页需要透出 App.vue 注入的壁纸层。 */
|
||||
:global(.v-application:has(.login-root)) {
|
||||
background: transparent !important;
|
||||
}
|
||||
|
||||
/* ===================== 浮动语言切换 ===================== */
|
||||
.lang-switch-btn {
|
||||
position: absolute;
|
||||
|
||||
@@ -17,6 +17,12 @@ html.v-overlay-scroll-blocked body {
|
||||
inset-block-start: var(--v-body-scroll-y);
|
||||
}
|
||||
|
||||
html.quick-access-scroll-locked,
|
||||
html.quick-access-scroll-locked body {
|
||||
overflow: hidden !important;
|
||||
overscroll-behavior: none;
|
||||
}
|
||||
|
||||
@mixin hide-scrollbar {
|
||||
-ms-overflow-style: none;
|
||||
scrollbar-width: none;
|
||||
@@ -922,6 +928,7 @@ html[data-theme="transparent"] .app-card-colorful,
|
||||
}
|
||||
|
||||
:root {
|
||||
--agent-assistant-fab-offset: 30rem;
|
||||
--theme-customizer-fab-offset: 420px;
|
||||
}
|
||||
|
||||
@@ -1040,6 +1047,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 +1063,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 {
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script lang="ts" setup>
|
||||
import type { CalendarOptions, EventSourceInput } from '@fullcalendar/core'
|
||||
import type { CalendarOptions, EventInput, EventSourceInput } from '@fullcalendar/core'
|
||||
import dayGridPlugin from '@fullcalendar/daygrid'
|
||||
import interactionPlugin from '@fullcalendar/interaction'
|
||||
import timeGridPlugin from '@fullcalendar/timegrid'
|
||||
@@ -15,6 +15,10 @@ import { openSharedDialog } from '@/composables/useSharedDialog'
|
||||
|
||||
const ProgressDialog = defineAsyncComponent(() => import('@/components/dialog/ProgressDialog.vue'))
|
||||
|
||||
const COLLAPSED_DAY_CARD_LIMIT = 5
|
||||
const COLLAPSED_VISIBLE_CARD_LIMIT = COLLAPSED_DAY_CARD_LIMIT
|
||||
const DAY_GROUP_EVENT_PREFIX = 'calendar-day-group-'
|
||||
|
||||
// 国际化
|
||||
const { t } = useI18n()
|
||||
|
||||
@@ -35,6 +39,7 @@ let progressDialogController: ReturnType<typeof openSharedDialog> | null = null
|
||||
type CalendarLibraryState = 'none' | 'partial' | 'complete'
|
||||
|
||||
interface CalendarEventInfo {
|
||||
id?: string
|
||||
title: string
|
||||
subtitle: string
|
||||
start: Date | null
|
||||
@@ -49,6 +54,9 @@ interface CalendarEventInfo {
|
||||
libraryEpisodeNumbers: number[]
|
||||
libraryState: CalendarLibraryState
|
||||
libraryUpdateText: string
|
||||
dateKey?: string
|
||||
hiddenEventCount?: number
|
||||
calendarSortIndex?: number
|
||||
}
|
||||
|
||||
// 打开订阅日历共享进度弹窗。
|
||||
@@ -85,10 +93,12 @@ const calendarOptions: Ref<CalendarOptions> = ref({
|
||||
center: 'title',
|
||||
right: 'next',
|
||||
},
|
||||
// 日历页需要完整展示每天所有订阅条目,避免折叠成 "+ more" 后隐藏关键信息。
|
||||
// 折叠逻辑由组件自行控制,点击展开时可以直接扩展当前日期格子。
|
||||
dayMaxEvents: false,
|
||||
dayMaxEventRows: false,
|
||||
eventDisplay: 'block',
|
||||
eventOrder: 'start,calendarSortIndex,title',
|
||||
eventOrderStrict: true,
|
||||
views: {
|
||||
week: {
|
||||
titleFormat: { day: 'numeric' },
|
||||
@@ -97,6 +107,91 @@ const calendarOptions: Ref<CalendarOptions> = ref({
|
||||
events: [],
|
||||
})
|
||||
|
||||
// 原始日历事件与已展开日期分离,避免依赖 FullCalendar 的弹窗式 more 链接。
|
||||
const rawCalendarEvents = ref<CalendarEventInfo[]>([])
|
||||
const expandedDateKeys = ref(new Set<string>())
|
||||
const calendarRef = ref<InstanceType<typeof FullCalendar> | null>(null)
|
||||
|
||||
function getDateKey(date: Date | null) {
|
||||
if (!date) return ''
|
||||
|
||||
const year = date.getFullYear()
|
||||
const month = `${date.getMonth() + 1}`.padStart(2, '0')
|
||||
const day = `${date.getDate()}`.padStart(2, '0')
|
||||
|
||||
return `${year}-${month}-${day}`
|
||||
}
|
||||
|
||||
function getDayGroupEventId(dateKey: string) {
|
||||
return `${DAY_GROUP_EVENT_PREFIX}${dateKey}`
|
||||
}
|
||||
|
||||
function createDayGroupCalendarEvent(dateKey: string, events: CalendarEventInfo[]): EventInput {
|
||||
const isExpanded = expandedDateKeys.value.has(dateKey)
|
||||
const visibleEvents = isExpanded ? events : events.slice(0, COLLAPSED_VISIBLE_CARD_LIMIT)
|
||||
|
||||
return {
|
||||
id: getDayGroupEventId(dateKey),
|
||||
title: '',
|
||||
start: events[0]?.start || undefined,
|
||||
allDay: false,
|
||||
interactive: false,
|
||||
calendarSortIndex: events[0]?.calendarSortIndex ?? 0,
|
||||
dateKey,
|
||||
hiddenEventCount: isExpanded ? 0 : Math.max(events.length - COLLAPSED_VISIBLE_CARD_LIMIT, 0),
|
||||
isDayGroup: true,
|
||||
visibleEvents,
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeCalendarEventOrder(events: CalendarEventInfo[]) {
|
||||
return events
|
||||
.sort((first, second) => {
|
||||
const firstTime = first.start?.getTime() ?? 0
|
||||
const secondTime = second.start?.getTime() ?? 0
|
||||
|
||||
return firstTime - secondTime || first.title.localeCompare(second.title)
|
||||
})
|
||||
.map((event, index) => ({
|
||||
...event,
|
||||
calendarSortIndex: index,
|
||||
}))
|
||||
}
|
||||
|
||||
function renderVisibleCalendarEvents() {
|
||||
const groupedEvents = new Map<string, CalendarEventInfo[]>()
|
||||
|
||||
rawCalendarEvents.value.forEach(event => {
|
||||
const dateKey = getDateKey(event.start)
|
||||
if (!dateKey) return
|
||||
|
||||
groupedEvents.set(dateKey, [...(groupedEvents.get(dateKey) || []), event])
|
||||
})
|
||||
|
||||
calendarOptions.value.events = Array.from(groupedEvents.entries()).map(([dateKey, events]) =>
|
||||
createDayGroupCalendarEvent(dateKey, events),
|
||||
) as EventSourceInput
|
||||
}
|
||||
|
||||
function expandCalendarDay(dateKey: string) {
|
||||
const currentScrollY = window.scrollY
|
||||
const events = rawCalendarEvents.value.filter(event => getDateKey(event.start) === dateKey)
|
||||
const calendarApi = calendarRef.value?.getApi()
|
||||
|
||||
expandedDateKeys.value = new Set(expandedDateKeys.value).add(dateKey)
|
||||
|
||||
// 只更新当天这个聚合事件的内容,避免重置整个 FullCalendar 导致页面回到顶部。
|
||||
if (calendarApi) {
|
||||
const event = calendarApi.getEventById(getDayGroupEventId(dateKey))
|
||||
event?.setExtendedProp('visibleEvents', events)
|
||||
event?.setExtendedProp('hiddenEventCount', 0)
|
||||
|
||||
requestAnimationFrame(() => window.scrollTo({ top: currentScrollY, left: window.scrollX }))
|
||||
} else {
|
||||
renderVisibleCalendarEvents()
|
||||
}
|
||||
}
|
||||
|
||||
function clampEpisodeCount(value: number, total: number) {
|
||||
return Math.min(Math.max(value, 0), total)
|
||||
}
|
||||
@@ -183,15 +278,20 @@ function buildCalendarEventInfo(
|
||||
}
|
||||
}
|
||||
|
||||
function getCalendarEventTooltip(event: any) {
|
||||
function getExpandCalendarEventLabel(event: any) {
|
||||
const props = event.extendedProps as CalendarEventInfo
|
||||
|
||||
return t('calendar.expandDayEvents', { count: props.hiddenEventCount || 0 })
|
||||
}
|
||||
|
||||
function getCalendarEventInfoTooltip(event: CalendarEventInfo) {
|
||||
const parts = [event.title]
|
||||
|
||||
if (props.subtitle) parts.push(t('calendar.episode', { number: props.subtitle }))
|
||||
if (props.totalEpisode) {
|
||||
parts.push(t('calendar.libraryProgress', { completed: props.libraryEpisode, total: props.totalEpisode }))
|
||||
if (event.subtitle) parts.push(t('calendar.episode', { number: event.subtitle }))
|
||||
if (event.totalEpisode) {
|
||||
parts.push(t('calendar.libraryProgress', { completed: event.libraryEpisode, total: event.totalEpisode }))
|
||||
}
|
||||
if (props.libraryUpdateText) parts.push(t('calendar.libraryUpdatedAt', { time: props.libraryUpdateText }))
|
||||
if (event.libraryUpdateText) parts.push(t('calendar.libraryUpdatedAt', { time: event.libraryUpdateText }))
|
||||
|
||||
return parts.filter(Boolean).join(' · ')
|
||||
}
|
||||
@@ -273,7 +373,8 @@ async function getSubscribes() {
|
||||
loading.value = false
|
||||
const subEvents = await Promise.allSettled(subscribes.map(async sub => eventsHander(sub)))
|
||||
const succEvents = subEvents.filter(result => result.status === 'fulfilled').map(result => result.value)
|
||||
calendarOptions.value.events = succEvents.flat().filter(event => event.start) as EventSourceInput
|
||||
rawCalendarEvents.value = normalizeCalendarEventOrder(succEvents.flat().filter(event => event.start))
|
||||
renderVisibleCalendarEvents()
|
||||
isLoaded.value = true
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
@@ -295,19 +396,24 @@ onActivated(() => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<FullCalendar :options="calendarOptions">
|
||||
<FullCalendar ref="calendarRef" :options="calendarOptions">
|
||||
<template #eventContent="arg">
|
||||
<div v-if="display.lgAndUp.value">
|
||||
<div
|
||||
v-if="arg.event.extendedProps.isDayGroup"
|
||||
class="calendar-day-events"
|
||||
>
|
||||
<div
|
||||
v-for="calendarEvent in arg.event.extendedProps.visibleEvents"
|
||||
:key="`${calendarEvent.title}-${calendarEvent.subtitle}-${calendarEvent.calendarSortIndex}`"
|
||||
class="calendar-event-card"
|
||||
:class="`calendar-event-card--${arg.event.extendedProps.libraryState}`"
|
||||
:title="getCalendarEventTooltip(arg.event)"
|
||||
:class="`calendar-event-card--${calendarEvent.libraryState}`"
|
||||
:title="getCalendarEventInfoTooltip(calendarEvent)"
|
||||
>
|
||||
<div class="calendar-event-poster">
|
||||
<div v-if="display.lgAndUp.value" class="calendar-event-poster">
|
||||
<VImg
|
||||
height="74"
|
||||
width="50"
|
||||
:src="arg.event.extendedProps.posterPath"
|
||||
:src="calendarEvent.posterPath"
|
||||
aspect-ratio="2/3"
|
||||
class="calendar-event-image object-cover"
|
||||
cover
|
||||
@@ -319,70 +425,82 @@ onActivated(() => {
|
||||
</template>
|
||||
</VImg>
|
||||
<span
|
||||
v-if="arg.event.extendedProps.libraryState === 'complete'"
|
||||
v-if="calendarEvent.libraryState === 'complete'"
|
||||
class="calendar-library-check"
|
||||
>
|
||||
<VIcon icon="mdi-check" size="12" />
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="calendar-event-content">
|
||||
<VImg
|
||||
v-else
|
||||
:src="calendarEvent.posterPath"
|
||||
aspect-ratio="2/3"
|
||||
class="calendar-mobile-image object-cover ring-gray-500"
|
||||
cover
|
||||
:title="getCalendarEventInfoTooltip(calendarEvent)"
|
||||
>
|
||||
<template #placeholder>
|
||||
<div class="w-full h-full">
|
||||
<VSkeletonLoader class="object-cover aspect-w-2 aspect-h-3" />
|
||||
</div>
|
||||
</template>
|
||||
<span
|
||||
v-if="calendarEvent.libraryState === 'complete'"
|
||||
class="calendar-library-check calendar-library-check--mobile"
|
||||
>
|
||||
<VIcon icon="mdi-check" size="11" />
|
||||
</span>
|
||||
<span v-if="calendarEvent.subtitle" class="calendar-mobile-episode">
|
||||
{{ calendarEvent.subtitle }}
|
||||
</span>
|
||||
</VImg>
|
||||
|
||||
<div v-if="display.lgAndUp.value" class="calendar-event-content">
|
||||
<div class="calendar-event-title">
|
||||
{{ arg.event.title }}
|
||||
{{ calendarEvent.title }}
|
||||
</div>
|
||||
<div v-if="arg.event.extendedProps.subtitle" class="calendar-event-episode">
|
||||
<div v-if="calendarEvent.subtitle" class="calendar-event-episode">
|
||||
<VIcon icon="mdi-calendar-blank-outline" size="13" />
|
||||
{{ t('calendar.episode', { number: arg.event.extendedProps.subtitle }) }}
|
||||
{{ t('calendar.episode', { number: calendarEvent.subtitle }) }}
|
||||
</div>
|
||||
<div v-if="arg.event.extendedProps.totalEpisode" class="calendar-event-library-row">
|
||||
<div v-if="calendarEvent.totalEpisode" class="calendar-event-library-row">
|
||||
<span
|
||||
v-if="arg.event.extendedProps.libraryState !== 'complete'"
|
||||
v-if="calendarEvent.libraryState !== 'complete'"
|
||||
class="calendar-event-status"
|
||||
:class="`calendar-event-status--${arg.event.extendedProps.libraryState}`"
|
||||
:class="`calendar-event-status--${calendarEvent.libraryState}`"
|
||||
>
|
||||
<VIcon :icon="getLibraryStateIcon(arg.event.extendedProps.libraryState)" size="13" />
|
||||
{{ getLibraryStateText(arg.event.extendedProps.libraryState) }}
|
||||
<VIcon :icon="getLibraryStateIcon(calendarEvent.libraryState)" size="13" />
|
||||
{{ getLibraryStateText(calendarEvent.libraryState) }}
|
||||
</span>
|
||||
<span class="calendar-event-progress">
|
||||
<VIcon icon="mdi-library" size="13" />
|
||||
{{
|
||||
t('calendar.libraryProgress', {
|
||||
completed: arg.event.extendedProps.libraryEpisode,
|
||||
total: arg.event.extendedProps.totalEpisode,
|
||||
completed: calendarEvent.libraryEpisode,
|
||||
total: calendarEvent.totalEpisode,
|
||||
})
|
||||
}}
|
||||
</span>
|
||||
</div>
|
||||
<div v-if="arg.event.extendedProps.libraryUpdateText" class="calendar-event-time">
|
||||
<div v-if="calendarEvent.libraryUpdateText" class="calendar-event-time">
|
||||
<VIcon icon="mdi-clock-outline" size="13" />
|
||||
{{ t('calendar.libraryUpdatedAtShort', { time: arg.event.extendedProps.libraryUpdateText }) }}
|
||||
{{ t('calendar.libraryUpdatedAtShort', { time: calendarEvent.libraryUpdateText }) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else>
|
||||
<VImg
|
||||
:src="arg.event.extendedProps.posterPath"
|
||||
aspect-ratio="2/3"
|
||||
class="calendar-mobile-image object-cover ring-gray-500"
|
||||
cover
|
||||
:title="getCalendarEventTooltip(arg.event)"
|
||||
|
||||
<button
|
||||
v-if="arg.event.extendedProps.hiddenEventCount"
|
||||
type="button"
|
||||
class="calendar-expand-card"
|
||||
:title="getExpandCalendarEventLabel(arg.event)"
|
||||
:aria-label="getExpandCalendarEventLabel(arg.event)"
|
||||
@click.stop.prevent="expandCalendarDay(arg.event.extendedProps.dateKey)"
|
||||
>
|
||||
<template #placeholder>
|
||||
<div class="w-full h-full">
|
||||
<VSkeletonLoader class="object-cover aspect-w-2 aspect-h-3" />
|
||||
</div>
|
||||
</template>
|
||||
<span
|
||||
v-if="arg.event.extendedProps.libraryState === 'complete'"
|
||||
class="calendar-library-check calendar-library-check--mobile"
|
||||
>
|
||||
<VIcon icon="mdi-check" size="11" />
|
||||
</span>
|
||||
<span v-if="arg.event.extendedProps.subtitle" class="calendar-mobile-episode">
|
||||
{{ arg.event.extendedProps.subtitle }}
|
||||
</span>
|
||||
</VImg>
|
||||
<VIcon icon="mdi-unfold-more-horizontal" size="18" />
|
||||
<span class="calendar-expand-count">+{{ arg.event.extendedProps.hiddenEventCount }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
</FullCalendar>
|
||||
@@ -679,6 +797,38 @@ onActivated(() => {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.calendar-day-events {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.3rem;
|
||||
inline-size: 100%;
|
||||
}
|
||||
|
||||
.calendar-expand-card {
|
||||
display: flex;
|
||||
gap: 0.35rem;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-block-size: 2.1rem;
|
||||
border: 1px dashed rgba(var(--v-theme-primary), 0.44);
|
||||
border-radius: 8px;
|
||||
background: rgba(var(--v-theme-primary), 0.08);
|
||||
color: rgb(var(--v-theme-primary));
|
||||
cursor: pointer;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 700;
|
||||
inline-size: 100%;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.calendar-expand-card:hover {
|
||||
background: rgba(var(--v-theme-primary), 0.14);
|
||||
}
|
||||
|
||||
.calendar-expand-count {
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.calendar-event-poster {
|
||||
position: relative;
|
||||
flex: 0 0 56px;
|
||||
@@ -828,10 +978,27 @@ onActivated(() => {
|
||||
}
|
||||
|
||||
@media (width <= 1279px) {
|
||||
.calendar-day-events {
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.calendar-event-card,
|
||||
.fc-daygrid-event-harness {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.calendar-expand-card {
|
||||
flex-direction: column;
|
||||
gap: 0.12rem;
|
||||
min-block-size: 0;
|
||||
block-size: clamp(60px, 8.7vw, 96px);
|
||||
inline-size: clamp(40px, 5.8vw, 64px);
|
||||
}
|
||||
|
||||
.calendar-expand-count {
|
||||
font-size: 0.68rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,426 +0,0 @@
|
||||
<script lang="ts" setup>
|
||||
import type { Message } from '@/api/types'
|
||||
import MessageCard from '@/components/cards/MessageCard.vue'
|
||||
import api from '@/api'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useBackground } from '@/composables/useBackground'
|
||||
|
||||
// 国际化
|
||||
const { t } = useI18n()
|
||||
const { useSSE } = useBackground()
|
||||
|
||||
// 消息列表
|
||||
const messages = ref<Message[]>([])
|
||||
// 当前页数据
|
||||
const currData = ref<Message[]>([])
|
||||
|
||||
// 已加载消息的签名集合
|
||||
// SSE 消息与数据库消息的字段来源不同(date vs reg_time, null vs {}),签名已归一化处理。
|
||||
const messageKeys = new Set<string>()
|
||||
|
||||
// 是否完成加载
|
||||
const isLoaded = ref(false)
|
||||
|
||||
// 是否加载中
|
||||
const loading = ref(false)
|
||||
|
||||
// 当前页码
|
||||
const page = ref(1)
|
||||
|
||||
// 存量消息最新时间
|
||||
const lastTime = ref('')
|
||||
|
||||
// 消息列表滚动容器
|
||||
const messageListRef = ref<any>(null)
|
||||
|
||||
// 自动滚动状态
|
||||
const shouldAutoScroll = ref(true)
|
||||
const isSyncingScroll = ref(false)
|
||||
|
||||
const MESSAGE_AUTO_SCROLL_THRESHOLD = 64
|
||||
|
||||
let scrollTimer: number | undefined
|
||||
let scrollReleaseTimer: number | undefined
|
||||
let boundScrollContainer: HTMLElement | null = null
|
||||
|
||||
// 生成消息去重签名
|
||||
// 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.title ?? '',
|
||||
message.text ?? '',
|
||||
message.image ?? '',
|
||||
message.link ?? '',
|
||||
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)))
|
||||
}
|
||||
|
||||
// 记录最新消息时间
|
||||
function updateLastTime(message: Message) {
|
||||
const messageTime = getMessageTime(message)
|
||||
if (messageTime && compareTime(messageTime, lastTime.value) > 0) {
|
||||
lastTime.value = messageTime
|
||||
}
|
||||
}
|
||||
|
||||
/** 判断元素自身是否是真正承载滚动的位置。 */
|
||||
function isScrollableElement(element: HTMLElement) {
|
||||
const { overflowY } = window.getComputedStyle(element)
|
||||
const canScroll = overflowY === 'auto' || overflowY === 'scroll' || overflowY === 'overlay'
|
||||
|
||||
return canScroll && element.scrollHeight > element.clientHeight + 1
|
||||
}
|
||||
|
||||
/** 获取消息列表所在的真实滚动容器。 */
|
||||
function getScrollContainer() {
|
||||
const element = messageListRef.value?.$el ?? messageListRef.value
|
||||
|
||||
if (!(element instanceof HTMLElement)) {
|
||||
return null
|
||||
}
|
||||
|
||||
let container: HTMLElement | null = element
|
||||
while (container) {
|
||||
if (isScrollableElement(container)) {
|
||||
return container
|
||||
}
|
||||
|
||||
container = container.parentElement
|
||||
}
|
||||
|
||||
const dialogCardText = element.closest('.v-card-text')
|
||||
|
||||
return dialogCardText instanceof HTMLElement ? dialogCardText : element
|
||||
}
|
||||
|
||||
function isNearBottom(container: HTMLElement) {
|
||||
const distanceFromBottom = container.scrollHeight - container.scrollTop - container.clientHeight
|
||||
|
||||
return distanceFromBottom <= Math.max(MESSAGE_AUTO_SCROLL_THRESHOLD, container.clientHeight / 3)
|
||||
}
|
||||
|
||||
function updateAutoScrollState() {
|
||||
const container = getScrollContainer()
|
||||
if (!container || isSyncingScroll.value) {
|
||||
return
|
||||
}
|
||||
|
||||
shouldAutoScroll.value = isNearBottom(container)
|
||||
}
|
||||
|
||||
function handleScroll() {
|
||||
updateAutoScrollState()
|
||||
}
|
||||
|
||||
function bindScrollListener() {
|
||||
const container = getScrollContainer()
|
||||
if (!container) {
|
||||
return
|
||||
}
|
||||
|
||||
if (boundScrollContainer && boundScrollContainer !== container) {
|
||||
boundScrollContainer.removeEventListener('scroll', handleScroll)
|
||||
}
|
||||
|
||||
container.removeEventListener('scroll', handleScroll)
|
||||
container.addEventListener('scroll', handleScroll, { passive: true })
|
||||
boundScrollContainer = container
|
||||
updateAutoScrollState()
|
||||
}
|
||||
|
||||
function unbindScrollListener() {
|
||||
boundScrollContainer?.removeEventListener('scroll', handleScroll)
|
||||
boundScrollContainer = null
|
||||
}
|
||||
|
||||
/** 滚动到底部,并在布局稳定前连续几帧校正滚动位置。 */
|
||||
function scrollContainerToEnd(retryCount = 1) {
|
||||
const container = getScrollContainer()
|
||||
if (!container) {
|
||||
return
|
||||
}
|
||||
|
||||
bindScrollListener()
|
||||
isSyncingScroll.value = true
|
||||
container.scrollTop = Math.max(0, container.scrollHeight - container.clientHeight)
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
const latestContainer = getScrollContainer()
|
||||
if (!latestContainer) {
|
||||
isSyncingScroll.value = false
|
||||
return
|
||||
}
|
||||
|
||||
latestContainer.scrollTop = Math.max(0, latestContainer.scrollHeight - latestContainer.clientHeight)
|
||||
shouldAutoScroll.value = true
|
||||
|
||||
if (retryCount > 0) {
|
||||
scrollContainerToEnd(retryCount - 1)
|
||||
return
|
||||
}
|
||||
|
||||
if (scrollReleaseTimer) {
|
||||
window.clearTimeout(scrollReleaseTimer)
|
||||
}
|
||||
|
||||
scrollReleaseTimer = window.setTimeout(() => {
|
||||
isSyncingScroll.value = false
|
||||
updateAutoScrollState()
|
||||
}, 80)
|
||||
})
|
||||
}
|
||||
|
||||
function requestScrollToEnd(force = false) {
|
||||
if (!force && !shouldAutoScroll.value) {
|
||||
return
|
||||
}
|
||||
|
||||
if (scrollTimer) {
|
||||
window.clearTimeout(scrollTimer)
|
||||
}
|
||||
|
||||
scrollTimer = window.setTimeout(() => {
|
||||
nextTick(() => {
|
||||
requestAnimationFrame(() => {
|
||||
scrollContainerToEnd(force ? 6 : 1)
|
||||
})
|
||||
})
|
||||
}, force ? 0 : 80)
|
||||
}
|
||||
|
||||
function forceScrollToEnd() {
|
||||
requestScrollToEnd(true)
|
||||
}
|
||||
|
||||
// 合并消息到当前列表
|
||||
function mergeMessages(items: Message[]) {
|
||||
let hasNewMessage = false
|
||||
|
||||
for (const item of sortMessages(items)) {
|
||||
const messageKey = getMessageKey(item)
|
||||
if (messageKeys.has(messageKey)) {
|
||||
continue
|
||||
}
|
||||
|
||||
messageKeys.add(messageKey)
|
||||
messages.value.push(item)
|
||||
updateLastTime(item)
|
||||
hasNewMessage = true
|
||||
}
|
||||
|
||||
if (hasNewMessage) {
|
||||
messages.value = sortMessages(messages.value)
|
||||
}
|
||||
|
||||
return hasNewMessage
|
||||
}
|
||||
|
||||
// SSE消息处理函数
|
||||
function handleSSEMessage(event: MessageEvent) {
|
||||
const message = event.data
|
||||
if (message) {
|
||||
const object = JSON.parse(message)
|
||||
if (mergeMessages([object])) {
|
||||
requestScrollToEnd() // 新消息到达时触发智能滚动
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 使用SSE连接
|
||||
const { manager, isConnected } = useSSE(
|
||||
`${import.meta.env.VITE_API_BASE_URL}system/message?role=user`,
|
||||
handleSSEMessage,
|
||||
'message-view',
|
||||
{
|
||||
backgroundCloseDelay: 5000,
|
||||
reconnectDelay: 3000,
|
||||
maxReconnectAttempts: 3,
|
||||
},
|
||||
)
|
||||
|
||||
// 调用API加载存量消息
|
||||
async function loadMessages({ done }: { done: any }) {
|
||||
// 如果正在加载中,直接返回
|
||||
if (loading.value) {
|
||||
done('ok')
|
||||
return
|
||||
}
|
||||
try {
|
||||
// 设置加载中
|
||||
loading.value = true
|
||||
const isFirstPage = page.value === 1
|
||||
|
||||
currData.value = await api.get('message/web', {
|
||||
params: {
|
||||
page: page.value,
|
||||
size: 20,
|
||||
},
|
||||
})
|
||||
// 已加载过
|
||||
isLoaded.value = true
|
||||
if (currData.value.length > 0) {
|
||||
mergeMessages(currData.value)
|
||||
|
||||
// 页码+1
|
||||
page.value++
|
||||
// 完成
|
||||
done('ok')
|
||||
|
||||
// 首次加载完成后再滚动,避免列表尚未完成布局时滚动失效。
|
||||
if (isFirstPage) {
|
||||
requestScrollToEnd(true)
|
||||
}
|
||||
} else {
|
||||
// 没有新数据
|
||||
done('empty')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载消息失败:', error)
|
||||
done('error')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 主动刷新最新一页消息,作为SSE偶发丢流时的兜底
|
||||
async function refreshLatestMessages() {
|
||||
try {
|
||||
const latestMessages = (await api.get('message/web', {
|
||||
params: {
|
||||
page: 1,
|
||||
size: 20,
|
||||
},
|
||||
})) as Message[]
|
||||
|
||||
if (mergeMessages(latestMessages)) {
|
||||
requestScrollToEnd()
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('刷新最新消息失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// 比较yyyy-MM-dd HH:mm:ss时间大小
|
||||
function compareTime(time1: string, time2: string) {
|
||||
if (!time1 && !time2) return 0
|
||||
if (!time1) return -1
|
||||
if (!time2) return 1
|
||||
|
||||
try {
|
||||
// 统一时间格式处理,支持多种格式
|
||||
const normalizeTime = (time: string) => {
|
||||
// 如果是ISO格式,直接使用
|
||||
if (time.includes('T')) {
|
||||
return new Date(time).getTime()
|
||||
}
|
||||
// 如果是yyyy-MM-dd HH:mm:ss格式,替换-为/
|
||||
return new Date(time.replaceAll(/-/g, '/')).getTime()
|
||||
}
|
||||
|
||||
const timestamp1 = normalizeTime(time1)
|
||||
const timestamp2 = normalizeTime(time2)
|
||||
|
||||
return timestamp1 - timestamp2
|
||||
} catch (error) {
|
||||
console.error('时间比较错误:', error, 'time1:', time1, 'time2:', time2)
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
// 图片加载完成时触发智能滚动
|
||||
function handleImageLoad() {
|
||||
requestScrollToEnd()
|
||||
}
|
||||
|
||||
// 暂停SSE连接
|
||||
function pauseSSE() {
|
||||
if (manager) {
|
||||
manager.removeMessageListener('message-view')
|
||||
}
|
||||
}
|
||||
|
||||
// 恢复SSE连接
|
||||
function resumeSSE() {
|
||||
if (manager) {
|
||||
// 先移除再重建监听,确保恢复时拿到一条新的SSE连接。
|
||||
manager.removeMessageListener('message-view')
|
||||
manager.addMessageListener('message-view', handleSSEMessage)
|
||||
}
|
||||
|
||||
refreshLatestMessages()
|
||||
}
|
||||
|
||||
// 暴露方法给父组件
|
||||
defineExpose({
|
||||
pauseSSE,
|
||||
resumeSSE,
|
||||
refreshLatestMessages,
|
||||
forceScrollToEnd,
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
nextTick(() => {
|
||||
bindScrollListener()
|
||||
})
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (scrollTimer) {
|
||||
window.clearTimeout(scrollTimer)
|
||||
}
|
||||
|
||||
if (scrollReleaseTimer) {
|
||||
window.clearTimeout(scrollReleaseTimer)
|
||||
}
|
||||
|
||||
unbindScrollListener()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VInfiniteScroll
|
||||
ref="messageListRef"
|
||||
:mode="!isLoaded ? 'intersect' : 'manual'"
|
||||
side="start"
|
||||
:items="messages"
|
||||
class="overflow-auto h-full"
|
||||
@load="loadMessages"
|
||||
:load-more-text="t('message.loadMore') + ' ...'"
|
||||
>
|
||||
<template #loading>
|
||||
<LoadingBanner />
|
||||
</template>
|
||||
<template #empty> {{ t('message.noMoreData') }} </template>
|
||||
<div
|
||||
v-for="(item, index) in messages"
|
||||
:key="getMessageKey(item) || index"
|
||||
class="chat-group d-flex mt-5 mb-8"
|
||||
:class="item.action == 1 ? 'flex-row align-start' : 'flex-row-reverse align-end'"
|
||||
>
|
||||
<div class="d-inline-flex flex-column" :class="item.action == 1 ? 'align-start' : 'align-end'">
|
||||
<MessageCard :message="item" @imageload="handleImageLoad" />
|
||||
</div>
|
||||
</div>
|
||||
</VInfiniteScroll>
|
||||
</template>
|
||||
Reference in New Issue
Block a user