Compare commits

...

29 Commits

Author SHA1 Message Date
jxxghp
1f170030ee fix: harden plugin market list rendering 2026-06-18 19:45:21 +08:00
jxxghp
e78ed20936 fix: 更新 viewport meta 标签,移除不必要的 interactive-widget 属性 2026-06-18 19:30:52 +08:00
InfinityPacer
b1787b207d fix(plugin): sanitize version history markdown (#496) 2026-06-18 19:30:24 +08:00
InfinityPacer
fdb34732cc fix(plugin): refine release version history UI (#495) 2026-06-18 18:52:54 +08:00
jxxghp
fc1f163a94 fix: 更新 SiteResourceDialog 组件样式,优化移动端显示和用户交互体验 2026-06-18 17:54:25 +08:00
InfinityPacer
a771dc5354 feat(plugin): add release version install actions (#494) 2026-06-18 15:48:09 +08:00
jxxghp
d28360a161 feat: load agent assistant history 2026-06-18 11:45:50 +08:00
jxxghp
a730abc437 refactor: 优化通知组件的样式和逻辑,移除不必要的状态,改善用户体验 2026-06-17 22:23:04 +08:00
jxxghp
5b72eda4fc fix: keep notification read action available 2026-06-17 18:17:38 +08:00
jxxghp
6c49d7a59e feat: 添加日历事件展开功能,优化事件显示和用户交互体验 2026-06-17 18:05:40 +08:00
jxxghp
8900366faf fix: 修复订阅卡片的待定状态样式,确保正确应用 pending tint 2026-06-17 17:21:56 +08:00
jxxghp
e8e0ac9084 refactor: remove scoped global css escapes 2026-06-17 17:19:09 +08:00
jxxghp
c66ee881b1 refactor: remove ShortcutMessageDialog and MessageView components; update ShortcutBar and UserNotification for improved notification handling 2026-06-17 16:32:00 +08:00
jxxghp
c055740926 feat: 优化错误处理逻辑,避免重复提示并改善用户体验 2026-06-17 12:36:34 +08:00
PKC278
a5bc4e6baf fix: restore search filter chip colors (#493) 2026-06-17 12:03:20 +08:00
jxxghp
15b4ee5893 feat: 隐藏主题定制器的滚动条,改善用户界面体验 2026-06-17 12:00:11 +08:00
jxxghp
8868403ff3 feat: 在组件挂载时同步主题定制器状态,确保全局 FAB 预留右侧空间 2026-06-17 11:29:26 +08:00
jxxghp
3abff72e25 feat: 添加语音录制功能,支持录音和相关提示信息 2026-06-17 11:11:55 +08:00
jxxghp
0c56cf0be7 feat: 优化消息列表样式,增加内容存在时的底部填充,改善滚动体验 2026-06-17 08:31:23 +08:00
jxxghp
ce12d04648 feat: 添加新会话时自动滚动到顶部,优化空态展示 2026-06-17 08:15:30 +08:00
jxxghp
efc0ae4df6 fix: keep agent composer floating without early scroll 2026-06-17 07:15:36 +08:00
jxxghp
2530c3bcd9 fix: stabilize mobile panel height 2026-06-17 07:01:33 +08:00
jxxghp
60e2402aff feat: 优化 AgentAssistantWidget 组件的样式和结构,增强可读性和用户体验 2026-06-16 23:29:58 +08:00
jxxghp
1a478f97fb feat: 添加历史会话功能,支持会话恢复和删除,更新多语言文本 2026-06-16 23:18:14 +08:00
jxxghp
33666703af feat: 添加选择功能和附件上传支持,更新多语言文本 2026-06-16 22:55:26 +08:00
jxxghp
cd69172a99 feat: 更新 AgentAssistantWidget 组件的空状态样式和文本内容 2026-06-16 22:23:37 +08:00
jxxghp
61749e3595 feat: Web Agent 透明主题磨砂效果 (#492) 2026-06-16 21:52:45 +08:00
jxxghp
b658533262 更新 package.json 2026-06-16 21:20:58 +08:00
jxxghp
d8015b7def feat: add Agent Assistant component and integrate with theme customizer
- Implemented Agent Assistant widget with chat functionality, including message handling and streaming responses.
- Added new localization strings for Agent Assistant in English, Simplified Chinese, and Traditional Chinese.
- Updated DefaultLayout to include the Agent Assistant and Theme Customizer components.
- Enhanced UserProfile to manage the opening of the Theme Customizer through global events.
- Adjusted CSS styles for the Agent Assistant and its interactions with other components.
- Introduced new events for opening the Theme Customizer and managing its state.
2026-06-16 20:44:00 +08:00
37 changed files with 4431 additions and 1558 deletions

View File

@@ -18,7 +18,7 @@
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<!-- 核心viewport设置 - 针对PWA优化 --> <!-- 核心viewport设置 - 针对PWA优化 -->
<meta name="viewport" <meta name="viewport"
content="width=device-width, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0, user-scalable=no, viewport-fit=cover, shrink-to-fit=no, interactive-widget=resizes-content" /> content="width=device-width, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0, user-scalable=no, viewport-fit=cover, shrink-to-fit=no" />
<!-- 防止缩放和选择,提供原生应用体验 --> <!-- 防止缩放和选择,提供原生应用体验 -->
<meta name="format-detection" content="telephone=no, date=no, email=no, address=no" /> <meta name="format-detection" content="telephone=no, date=no, email=no, address=no" />

View File

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

View File

@@ -59,8 +59,9 @@ const loginStateKey = computed(() => (isLogin.value ? 'logged-in' : 'logged-out'
const backgroundImages = ref<string[]>([]) const backgroundImages = ref<string[]>([])
const activeImageIndex = ref(0) const activeImageIndex = ref(0)
const isTransparentTheme = computed(() => globalTheme.name.value === 'transparent') const isTransparentTheme = computed(() => globalTheme.name.value === 'transparent')
const isLoginWallpaperRoute = computed(() => !isLogin.value && route.path === LOGIN_WALLPAPER_ROUTE)
const shouldLoadBackgroundImages = computed( 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 backgroundRetryTimer: number | null = null
let backgroundRequestController: AbortController | null = null let backgroundRequestController: AbortController | null = null
@@ -434,7 +435,7 @@ onUnmounted(() => {
<div v-if="isLogin && isTransparentTheme" class="global-blur-layer"></div> <div v-if="isLogin && isTransparentTheme" class="global-blur-layer"></div>
</div> </div>
<!-- 页面内容 --> <!-- 页面内容 -->
<VApp> <VApp :class="{ 'app-shell--login-wallpaper': isLoginWallpaperRoute }">
<RouterView /> <RouterView />
<!-- 全局共享弹窗入口列表与卡片按需在这里挂载业务弹窗 --> <!-- 全局共享弹窗入口列表与卡片按需在这里挂载业务弹窗 -->
<SharedDialogHost /> <SharedDialogHost />
@@ -504,4 +505,9 @@ onUnmounted(() => {
inset-block-start: 0; inset-block-start: 0;
inset-inline-start: 0; inset-inline-start: 0;
} }
/* 登录页壁纸在 VApp 外层渲染,登录页 VApp 需要透明才能露出壁纸。 */
.app-shell--login-wallpaper.v-application {
background: transparent !important;
}
</style> </style>

View File

@@ -656,6 +656,8 @@ export interface Plugin {
system_version_message?: string system_version_message?: string
// 主系统版本限定范围 // 主系统版本限定范围
system_version?: string system_version?: string
// 是否声明支持通过 GitHub Release 资产安装
release?: boolean
// 是否本地插件 // 是否本地插件
is_local?: boolean is_local?: boolean
// 插件仓库地址 // 插件仓库地址
@@ -668,6 +670,38 @@ export interface Plugin {
page_open?: boolean page_open?: boolean
} }
// 插件 Release 可安装版本
export interface PluginReleaseVersion {
// 插件版本
version: string
// GitHub Release tag
tag_name: string
// Release 标题
name?: string
// 发布时间
published_at?: string
// Release 说明
body?: string
// 匹配到的资产文件名
asset_name?: string
// 是否为当前市场最新版本
is_latest?: boolean
// 是否为本地已安装版本
is_current?: boolean
}
// 插件 Release 可安装版本响应
export interface PluginReleaseVersionsResponse {
// 当前插件是否存在可直接安装的 Release 资产
release_supported: boolean
// 当前市场 package 声明的最新版本
latest_version?: string | null
// 本地已安装版本
current_version?: string | null
// 可安装版本列表
items: PluginReleaseVersion[]
}
// 插件侧栏全页导航项(与后端 PluginSidebarNavItem 对齐) // 插件侧栏全页导航项(与后端 PluginSidebarNavItem 对齐)
export interface PluginSidebarNavItem { export interface PluginSidebarNavItem {
plugin_id: string plugin_id: string
@@ -1131,6 +1165,12 @@ export interface MediaServerLibrary {
// 消息通知 // 消息通知
export interface Message { export interface Message {
// 消息ID
id?: number
// 消息渠道
channel?: string
// 消息来源
source?: string
// 消息类型 // 消息类型
mtype?: string mtype?: string
// 消息标题 // 消息标题
@@ -1150,19 +1190,15 @@ export interface Message {
// 消息方向0-接收1-发送 // 消息方向0-接收1-发送
action?: number action?: number
// JSON // JSON
note?: string note?: string | any[] | Record<string, any>
} }
// 系统通知 // 系统通知
export interface SystemNotification { export interface SystemNotification extends Message {
// 通知类型 user/system/plugin // 通知类型 user/system/plugin/notification
type: string type?: string
// 通知标题
title: string
// 通知内容
text: string
// 通知时间 // 通知时间
date: string date?: string
// 是否已读 // 是否已读
read?: boolean read?: boolean
} }

File diff suppressed because it is too large Load Diff

View File

@@ -1,5 +1,4 @@
<script setup lang="ts"> <script setup lang="ts">
import type { CSSProperties } from 'vue'
import { import {
themeCustomizerPrimaryColors, themeCustomizerPrimaryColors,
useThemeCustomizer, useThemeCustomizer,
@@ -12,20 +11,9 @@ import {
import { usePWA } from '@/composables/usePWA' import { usePWA } from '@/composables/usePWA'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { useTheme } from 'vuetify' 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<{ const emit = defineEmits<{
'update:modelValue': [value: boolean] 'close': []
}>() }>()
const customColorInput = ref<HTMLInputElement | null>(null) const customColorInput = ref<HTMLInputElement | null>(null)
@@ -45,27 +33,7 @@ const {
const { appMode } = usePWA() const { appMode } = usePWA()
const { t } = useI18n() const { t } = useI18n()
const { global: globalTheme } = useTheme() const { global: globalTheme } = useTheme()
const display = useDisplay()
const defaultPrimaryColor = themeCustomizerPrimaryColors[0].value 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) { function syncThemeCustomizerOpenState(isOpen: boolean) {
@@ -87,57 +55,22 @@ function clearThemeCustomizerOpenState() {
syncThemeCustomizerOpenState(false) syncThemeCustomizerOpenState(false)
} }
watch(drawer, syncThemeCustomizerOpenState, { immediate: true }) function handleGlobalKeydown(event: KeyboardEvent) {
watch(drawer, isOpen => { // 固定侧栏不再依赖 Vuetify overlay手动补上常见的 Esc 关闭行为。
if (isOpen) nextTick(syncCustomizerViewportHeight) if (event.key === 'Escape') emit('close')
}) }
onMounted(() => { onMounted(() => {
syncCustomizerViewportHeight() // 面板一挂载就代表已打开,及时同步根节点状态让全局 FAB 预留右侧空间。
window.addEventListener('resize', syncCustomizerViewportHeight) syncThemeCustomizerOpenState(true)
window.addEventListener('orientationchange', syncCustomizerViewportHeight) window.addEventListener('keydown', handleGlobalKeydown)
window.visualViewport?.addEventListener('resize', syncCustomizerViewportHeight)
window.visualViewport?.addEventListener('scroll', syncCustomizerViewportHeight)
}) })
onScopeDispose(clearThemeCustomizerOpenState) onScopeDispose(clearThemeCustomizerOpenState)
onScopeDispose(() => { onScopeDispose(() => {
if (typeof window === 'undefined') return if (typeof window === 'undefined') return
window.removeEventListener('resize', syncCustomizerViewportHeight) window.removeEventListener('keydown', handleGlobalKeydown)
window.removeEventListener('orientationchange', syncCustomizerViewportHeight)
window.visualViewport?.removeEventListener('resize', syncCustomizerViewportHeight)
window.visualViewport?.removeEventListener('scroll', syncCustomizerViewportHeight)
})
const customizerContainer = computed(() => (appMode.value ? VDialog : VNavigationDrawer))
const customizerContainerStyle = computed<CSSProperties>(() => {
if (!appMode.value) return {}
return {
'--theme-customizer-viewport-height': customizerViewportHeight.value,
}
})
const customizerContainerProps = computed(() => {
if (appMode.value && display.mdAndDown.value) {
return {
class: 'theme-customizer-dialog-overlay',
scrim: true,
fullscreen: !display.mdAndUp.value,
scrollable: true,
}
}
return {
class: 'theme-customizer-drawer',
location: 'right' as const,
scrim: false,
temporary: true,
width: 420,
persistent: false,
}
}) })
const themeOptions = computed<Array<{ icon: string; title: string; value: ThemeCustomizerTheme }>>(() => [ const themeOptions = computed<Array<{ icon: string; title: string; value: ThemeCustomizerTheme }>>(() => [
@@ -273,347 +206,264 @@ async function handleResetSettings() {
</script> </script>
<template> <template>
<Teleport to="body"> <aside
<Transition name="theme-customizer-glass"> class="theme-customizer-panel-host"
<div role="dialog"
v-if="drawer" :aria-label="t('theme.customizer.title')"
class="theme-customizer-glass-backdrop" >
:class="{ 'theme-customizer-glass-backdrop--dialog': appMode }" <div class="theme-customizer-panel" :class="{ 'theme-customizer-panel--dialog': appMode, 'app-surface': appMode }">
/> <div class="theme-customizer-header py-5 px-4">
</Transition> <div>
<h2 class="theme-customizer-title">{{ t('theme.customizer.title') }}</h2>
<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>
</div> </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 }"> <PerfectScrollbar class="theme-customizer-body" :options="{ wheelPropagation: false }">
<section class="theme-customizer-section"> <section class="theme-customizer-section">
<h3 class="theme-customizer-section-title">{{ t('theme.customizer.primaryColor') }}</h3> <h3 class="theme-customizer-section-title">{{ t('theme.customizer.primaryColor') }}</h3>
<div class="theme-customizer-color-grid"> <div class="theme-customizer-color-grid">
<div <div
v-for="color in themeCustomizerPrimaryColors" v-for="color in themeCustomizerPrimaryColors"
:key="color.value" :key="color.value"
class="theme-customizer-color-option" class="theme-customizer-color-option"
:class="{ 'is-active': settings.primaryColor === color.value }" :class="{ 'is-active': settings.primaryColor === color.value }"
:aria-label="t('theme.customizer.usePrimaryColor', { color: color.name })" :aria-label="t('theme.customizer.usePrimaryColor', { color: color.name })"
@click="setPrimaryColor(color.value)" @click="setPrimaryColor(color.value)"
> >
<span class="theme-customizer-color-swatch" :style="{ backgroundColor: color.value }" /> <span class="theme-customizer-color-swatch" :style="{ backgroundColor: color.value }" />
</div>
<div
v-if="!appMode"
class="theme-customizer-color-option theme-customizer-color-option--picker"
:class="{
'is-active': !themeCustomizerPrimaryColors.some(color => color.value === settings.primaryColor),
}"
:aria-label="t('theme.customizer.chooseCustomColor')"
@click="openColorPicker"
>
<VIcon class="theme-customizer-native-icon" icon="mdi-palette-outline" size="30" />
<input
ref="customColorInput"
class="theme-customizer-native-color"
type="color"
:value="settings.primaryColor"
@input="handleCustomColorInput"
/>
</div>
</div> </div>
<h3 class="theme-customizer-section-title">{{ t('common.theme') }}</h3> <div
<div class="theme-customizer-option-grid theme-customizer-option-grid--theme"> v-if="!appMode"
<div class="theme-customizer-color-option theme-customizer-color-option--picker"
v-for="theme in themeOptions" :class="{
:key="theme.value" 'is-active': !themeCustomizerPrimaryColors.some(color => color.value === settings.primaryColor),
class="theme-customizer-card-option" }"
:class="{ 'is-active': settings.theme === theme.value }" :aria-label="t('theme.customizer.chooseCustomColor')"
@click="setTheme(theme.value)" @click="openColorPicker"
> >
<VIcon class="theme-customizer-theme-icon" :icon="theme.icon" size="36" /> <VIcon class="theme-customizer-native-icon" icon="mdi-palette-outline" size="30" />
<span>{{ theme.title }}</span> <input
</div> ref="customColorInput"
</div> class="theme-customizer-native-color"
type="color"
<VDivider class="mt-7" /> :value="settings.primaryColor"
@input="handleCustomColorInput"
<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> </div>
</section> </div>
<VDivider v-if="showLayoutSection" /> <h3 class="theme-customizer-section-title">{{ t('common.theme') }}</h3>
<div class="theme-customizer-option-grid theme-customizer-option-grid--theme">
<section v-if="showLayoutSection" class="theme-customizer-section"> <div
<h3 class="theme-customizer-section-title">{{ t('theme.customizer.layout') }}</h3> v-for="theme in themeOptions"
<div class="theme-customizer-preview-grid"> :key="theme.value"
<div class="theme-customizer-card-option"
v-for="layout in layoutOptions" :class="{ 'is-active': settings.theme === theme.value }"
:key="layout.value" @click="setTheme(theme.value)"
class="theme-customizer-preview-option" >
:class="{ 'is-active': settings.layout === layout.value, 'is-disabled': appMode }" <VIcon class="theme-customizer-theme-icon" :icon="theme.icon" size="36" />
@click="handleLayoutChange(layout.value)" <span>{{ theme.title }}</span>
>
<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> </div>
</section> </div>
</PerfectScrollbar>
</div> <VDivider class="mt-7" />
</component>
</Teleport> <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> </template>
<style lang="scss"> <style lang="scss" scoped>
/* stylelint-disable no-descending-specificity */ /* stylelint-disable no-descending-specificity */
.theme-customizer-drawer { .theme-customizer-panel-host {
position: fixed !important; position: fixed !important;
z-index: 12000 !important; z-index: 2102 !important;
display: flex;
overflow: hidden; 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; border-inline-start: 1px solid rgba(var(--v-theme-on-surface), 0.08) !important;
box-shadow: var(--app-surface-shadow) !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; inset-inline-end: 0 !important;
max-block-size: 100dvh !important; max-block-size: none !important;
min-block-size: 100vh !important;
}
.v-navigation-drawer__content { @supports (block-size: 100lvh) {
position: relative; .theme-customizer-panel-host {
z-index: 1; block-size: 100lvh !important;
display: flex; min-block-size: 100lvh !important;
overflow: hidden;
flex-direction: column;
block-size: 100%;
} }
} }
.theme-customizer-dialog-overlay {
--theme-customizer-viewport-height: 100dvh;
z-index: 12000 !important;
}
.theme-customizer-dialog-overlay > .v-overlay__content {
overflow: hidden;
block-size: var(--theme-customizer-viewport-height);
margin-block: 0 !important;
max-block-size: var(--theme-customizer-viewport-height);
}
.theme-customizer-panel { .theme-customizer-panel {
position: relative; position: relative;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
block-size: 100%; block-size: 100%;
inline-size: 100%;
min-block-size: 0; min-block-size: 0;
} }
.theme-customizer-panel--dialog { .theme-customizer-panel--dialog {
overflow: hidden; overflow: hidden;
background: rgb(var(--v-theme-surface)); block-size: 100%;
block-size: var(--theme-customizer-viewport-height, 100dvh); max-block-size: 100%;
max-block-size: var(--theme-customizer-viewport-height, 100dvh);
/* fullscreen dialog 会贴 viewport-fit=cover 顶部,iOS 需要在面板内部避开系统状态栏。 */ /* 独立 App 模式会贴 viewport-fit=cover 顶部,面板内部需要避开 iOS 状态栏。 */
padding-block-start: env(safe-area-inset-top); padding-block-start: env(safe-area-inset-top, 0px);
} }
.theme-customizer-panel--dialog .theme-customizer-body { .theme-customizer-panel--dialog .theme-customizer-body {
block-size: auto; block-size: auto;
padding-block-end: env(safe-area-inset-bottom); padding-block-end: env(safe-area-inset-bottom, 0px);
}
.theme-customizer-drawer.v-theme--transparent,
.v-theme--transparent .theme-customizer-drawer,
html[data-theme='transparent'] .theme-customizer-drawer,
.v-theme--transparent .theme-customizer-panel--dialog,
html[data-theme='transparent'] .theme-customizer-panel--dialog {
background: transparent !important;
}
.theme-customizer-glass-backdrop {
position: fixed;
z-index: 11999;
block-size: 100dvh;
inline-size: 420px;
inset-block: 0;
inset-inline-end: 0;
opacity: 0;
pointer-events: none;
transform: translateX(0);
}
:is(html[data-theme='transparent'], .v-theme--transparent) .theme-customizer-glass-backdrop {
backdrop-filter: blur(var(--transparent-blur-heavy, 16px));
background-color: rgba(var(--v-theme-surface), var(--transparent-opacity-heavy, 0.5));
opacity: 1;
}
.theme-customizer-glass-enter-active,
.theme-customizer-glass-leave-active {
transition:
opacity 0.2s cubic-bezier(0.4, 0, 0.2, 1),
transform 0.2s cubic-bezier(0.4, 0, 0.2, 1);
}
.theme-customizer-glass-enter-from,
.theme-customizer-glass-leave-to {
opacity: 0 !important;
transform: translateX(100%);
}
:is(html[data-theme='transparent'], .v-theme--transparent) .theme-customizer-drawer .v-navigation-drawer__content {
background: transparent !important;
} }
@media (width <= 600px) { @media (width <= 600px) {
.theme-customizer-glass-backdrop { .theme-customizer-panel-host {
inline-size: 100vw; 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 { .theme-customizer-header {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -638,6 +488,18 @@ html[data-theme='transparent'] .v-overlay__content:has(.theme-customizer-drawer)
.theme-customizer-body { .theme-customizer-body {
flex: 1 1 auto; flex: 1 1 auto;
min-block-size: 0; 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 { .theme-customizer-section {
@@ -1030,10 +892,6 @@ html[data-theme='transparent'] .v-overlay__content:has(.theme-customizer-drawer)
} }
@media (width <= 600px) { @media (width <= 600px) {
.theme-customizer-drawer {
inline-size: min(100vw, 420px) !important;
}
.theme-customizer-header, .theme-customizer-header,
.theme-customizer-section { .theme-customizer-section {
padding-inline: 22px; padding-inline: 22px;

View File

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

View File

@@ -1,16 +1,20 @@
<script lang="ts" setup> <script lang="ts" setup>
import api from '@/api'
import type { Plugin } from '@/api/types' import type { Plugin } from '@/api/types'
import { getLogoUrl } from '@/utils/imageUtils' import { getLogoUrl } from '@/utils/imageUtils'
import { getDominantColor } from '@/@core/utils/image' import { getDominantColor } from '@/@core/utils/image'
import { isNullOrEmptyObject } from '@/@core/utils' import { isNullOrEmptyObject } from '@/@core/utils'
import { formatDownloadCount } from '@/@core/utils/formatters' import { formatDownloadCount } from '@/@core/utils/formatters'
import { useToast } from 'vue-toastification'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { openSharedDialog } from '@/composables/useSharedDialog' import { openSharedDialog } from '@/composables/useSharedDialog'
import { useConfirm } from '@/composables/useConfirm'
const PluginMarketDetailDialog = defineAsyncComponent(() => import('@/components/dialog/PluginMarketDetailDialog.vue')) const PluginMarketDetailDialog = defineAsyncComponent(() => import('@/components/dialog/PluginMarketDetailDialog.vue'))
const PluginVersionHistoryDialog = defineAsyncComponent( const PluginVersionHistoryDialog = defineAsyncComponent(
() => import('@/components/dialog/PluginVersionHistoryDialog.vue'), () => import('@/components/dialog/PluginVersionHistoryDialog.vue'),
) )
const ProgressDialog = defineAsyncComponent(() => import('@/components/dialog/ProgressDialog.vue'))
// 输入参数 // 输入参数
const props = defineProps({ const props = defineProps({
@@ -26,6 +30,11 @@ const emit = defineEmits(['install'])
// 多语言 // 多语言
const { t } = useI18n() const { t } = useI18n()
// 提示框
const $toast = useToast()
const createConfirm = useConfirm()
// 背景颜色 // 背景颜色
const backgroundColor = ref('#28A9E1') const backgroundColor = ref('#28A9E1')
@@ -48,6 +57,21 @@ const isImageLoaded = ref(false)
// 图片是否加载失败 // 图片是否加载失败
const imageLoadError = ref(false) const imageLoadError = ref(false)
let progressDialogController: ReturnType<typeof openSharedDialog> | null = null
let versionHistoryDialogController: ReturnType<typeof openSharedDialog> | null = null
/** 打开插件安装进度弹窗。 */
function showInstallProgress(text: string) {
progressDialogController?.close()
progressDialogController = openSharedDialog(ProgressDialog, { text }, {}, { closeOn: false })
}
/** 关闭插件安装进度弹窗。 */
function closeInstallProgress() {
progressDialogController?.close()
progressDialogController = null
}
// 图片加载完成 // 图片加载完成
async function imageLoaded() { async function imageLoaded() {
isImageLoaded.value = true isImageLoaded.value = true
@@ -96,14 +120,69 @@ function visitPluginPage() {
// 显示更新日志 // 显示更新日志
function showUpdateHistory() { function showUpdateHistory() {
openSharedDialog( versionHistoryDialogController?.close()
versionHistoryDialogController = openSharedDialog(
PluginVersionHistoryDialog, PluginVersionHistoryDialog,
{ plugin: props.plugin }, { plugin: props.plugin, actionMode: 'install' },
{}, {
update: installPlugin,
},
{ closeOn: ['close', 'update:modelValue'] }, { closeOn: ['close', 'update:modelValue'] },
) )
} }
/** 从插件市场版本历史安装指定 Release最新版本走普通安装路径以保留主程序兼容校验。 */
async function installPlugin(releaseVersion?: string, repoUrl?: string) {
if (!releaseVersion && props.plugin?.system_version_compatible === false) {
$toast.error(props.plugin?.system_version_message || t('plugin.incompatibleSystemVersion'))
return
}
if (releaseVersion) {
const isConfirmed = await createConfirm({
title: t('common.confirm'),
content: t('plugin.confirmInstallOldRelease', {
name: props.plugin?.plugin_name,
version: releaseVersion,
}),
confirmText: t('common.confirm'),
})
if (!isConfirmed) return
}
try {
showInstallProgress(
t('plugin.installing', {
name: props.plugin?.plugin_name,
version: releaseVersion || props.plugin?.plugin_version,
}),
)
const result: { [key: string]: any } = await api.get(`plugin/install/${props.plugin?.id}`, {
params: {
repo_url: repoUrl || props.plugin?.repo_url,
release_version: releaseVersion,
force: props.plugin?.has_update || Boolean(releaseVersion),
},
})
closeInstallProgress()
if (result.success) {
$toast.success(t('plugin.installSuccess', { name: props.plugin?.plugin_name }))
versionHistoryDialogController?.close()
versionHistoryDialogController = null
emit('install')
} else {
$toast.error(t('plugin.installFailed', { name: props.plugin?.plugin_name, message: result.message }))
}
} catch (error) {
closeInstallProgress()
console.error(error)
}
}
/** 打开共享插件市场详情弹窗。 */ /** 打开共享插件市场详情弹窗。 */
function showPluginDetail() { function showPluginDetail() {
openSharedDialog( openSharedDialog(
@@ -140,6 +219,11 @@ const dropdownItems = ref([
}, },
}, },
]) ])
onUnmounted(() => {
closeInstallProgress()
versionHistoryDialogController?.close()
})
</script> </script>
<template> <template>

View File

@@ -69,6 +69,7 @@ const imageLoadError = ref(false)
let progressDialogController: ReturnType<typeof openSharedDialog> | null = null let progressDialogController: ReturnType<typeof openSharedDialog> | null = null
let cloneDialogController: ReturnType<typeof openSharedDialog> | null = null let cloneDialogController: ReturnType<typeof openSharedDialog> | null = null
let versionHistoryDialogController: ReturnType<typeof openSharedDialog> | null = null
/** 打开插件操作进度弹窗,插件卡片自身不再持有进度弹窗实例。 */ /** 打开插件操作进度弹窗,插件卡片自身不再持有进度弹窗实例。 */
function showPluginProgress(text: string) { function showPluginProgress(text: string) {
@@ -103,11 +104,12 @@ async function imageLoaded() {
// 显示更新日志 // 显示更新日志
function showUpdateHistory(showUpdateAction: boolean = false) { function showUpdateHistory(showUpdateAction: boolean = false) {
openSharedDialog( versionHistoryDialogController?.close()
versionHistoryDialogController = openSharedDialog(
PluginVersionHistoryDialog, PluginVersionHistoryDialog,
{ plugin: props.plugin, showUpdateAction }, { plugin: props.plugin, showUpdateAction },
{ update: updatePlugin }, { update: updatePlugin },
{ closeOn: ['close', 'update', 'update:modelValue'] }, { closeOn: ['close', 'update:modelValue'] },
) )
} }
@@ -219,19 +221,37 @@ async function resetPlugin() {
} }
// 更新插件 // 更新插件
async function updatePlugin() { async function updatePlugin(releaseVersion?: string, repoUrl?: string) {
if (props.plugin?.system_version_compatible === false) { if (!releaseVersion && props.plugin?.system_version_compatible === false) {
$toast.error(props.plugin?.system_version_message || t('plugin.incompatibleSystemVersion')) $toast.error(props.plugin?.system_version_message || t('plugin.incompatibleSystemVersion'))
return return
} }
if (releaseVersion) {
const isConfirmed = await createConfirm({
title: t('common.confirm'),
content: t('plugin.confirmInstallOldRelease', {
name: props.plugin?.plugin_name,
version: releaseVersion,
}),
confirmText: t('common.confirm'),
})
if (!isConfirmed) return
}
try { try {
// 显示等待提示框 // 显示等待提示框
showPluginProgress(t('plugin.updating', { name: props.plugin?.plugin_name })) showPluginProgress(
releaseVersion
? t('plugin.installing', { name: props.plugin?.plugin_name, version: releaseVersion })
: t('plugin.updating', { name: props.plugin?.plugin_name }),
)
const result: { [key: string]: any } = await api.get(`plugin/install/${props.plugin?.id}`, { const result: { [key: string]: any } = await api.get(`plugin/install/${props.plugin?.id}`, {
params: { params: {
repo_url: props.plugin?.repo_url, repo_url: repoUrl || props.plugin?.repo_url,
release_version: releaseVersion,
force: true, force: true,
}, },
}) })
@@ -241,6 +261,8 @@ async function updatePlugin() {
if (result.success) { if (result.success) {
$toast.success(t('plugin.updateSuccess', { name: props.plugin?.plugin_name })) $toast.success(t('plugin.updateSuccess', { name: props.plugin?.plugin_name }))
versionHistoryDialogController?.close()
versionHistoryDialogController = null
// 通知父组件刷新 // 通知父组件刷新
emit('save') emit('save')

View File

@@ -409,7 +409,6 @@ function handleCardClick() {
:class="{ :class="{
'transition transform-cpu duration-300 -translate-y-1': hover.isHovering && !props.sortable, 'transition transform-cpu duration-300 -translate-y-1': hover.isHovering && !props.sortable,
'outline-dotted outline-pink-500 outline-2': props.batchMode && props.selected, 'outline-dotted outline-pink-500 outline-2': props.batchMode && props.selected,
'subscribe-card-pending-tint': subscribeState === 'P',
}" }"
> >
<VCard <VCard
@@ -418,6 +417,7 @@ function handleCardClick() {
class="flex flex-col h-full overflow-hidden" class="flex flex-col h-full overflow-hidden"
:class="{ :class="{
'subscribe-card-paused': subscribeState === 'S', 'subscribe-card-paused': subscribeState === 'S',
'subscribe-card-pending-tint': subscribeState === 'P',
'cursor-move': props.sortable, 'cursor-move': props.sortable,
}" }"
min-height="150" min-height="150"
@@ -588,7 +588,7 @@ function handleCardClick() {
} }
/** /**
* 待定:用 ::after 浮层在 VCard 之上渲染 sky 漫反射式内发光 * 待定:内发光挂在实际 VCard 上,跟随卡片圆角并被 overflow-hidden 裁剪。
*/ */
.subscribe-card-pending-tint { .subscribe-card-pending-tint {
position: relative; position: relative;

View File

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

View File

@@ -6,8 +6,12 @@ import { getLogoUrl } from '@/utils/imageUtils'
import { useToast } from 'vue-toastification' import { useToast } from 'vue-toastification'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { openSharedDialog } from '@/composables/useSharedDialog' import { openSharedDialog } from '@/composables/useSharedDialog'
import { useConfirm } from '@/composables/useConfirm'
const ProgressDialog = defineAsyncComponent(() => import('@/components/dialog/ProgressDialog.vue')) const ProgressDialog = defineAsyncComponent(() => import('@/components/dialog/ProgressDialog.vue'))
const PluginVersionHistoryDialog = defineAsyncComponent(
() => import('@/components/dialog/PluginVersionHistoryDialog.vue'),
)
// 多语言 // 多语言
const { t } = useI18n() const { t } = useI18n()
@@ -15,6 +19,8 @@ const { t } = useI18n()
// 提示框 // 提示框
const $toast = useToast() const $toast = useToast()
const createConfirm = useConfirm()
// 输入参数 // 输入参数
const props = defineProps({ const props = defineProps({
modelValue: { modelValue: {
@@ -47,6 +53,7 @@ const imageRef = ref<any>()
const imageLoadError = ref(false) const imageLoadError = ref(false)
let progressDialogController: ReturnType<typeof openSharedDialog> | null = null let progressDialogController: ReturnType<typeof openSharedDialog> | null = null
let versionHistoryDialogController: ReturnType<typeof openSharedDialog> | null = null
/** 打开插件安装进度弹窗。 */ /** 打开插件安装进度弹窗。 */
function showInstallProgress(text: string) { function showInstallProgress(text: string) {
@@ -97,24 +104,38 @@ function visitPluginPage() {
} }
/** 安装插件并通知父级刷新市场列表。 */ /** 安装插件并通知父级刷新市场列表。 */
async function installPlugin() { async function installPlugin(releaseVersion?: string, repoUrl?: string) {
if (props.plugin?.system_version_compatible === false) { if (!releaseVersion && props.plugin?.system_version_compatible === false) {
$toast.error(props.plugin?.system_version_message || t('plugin.incompatibleSystemVersion')) $toast.error(props.plugin?.system_version_message || t('plugin.incompatibleSystemVersion'))
return return
} }
if (releaseVersion) {
const isConfirmed = await createConfirm({
title: t('common.confirm'),
content: t('plugin.confirmInstallOldRelease', {
name: props.plugin?.plugin_name,
version: releaseVersion,
}),
confirmText: t('common.confirm'),
})
if (!isConfirmed) return
}
try { try {
showInstallProgress( showInstallProgress(
t('plugin.installing', { t('plugin.installing', {
name: props.plugin?.plugin_name, name: props.plugin?.plugin_name,
version: props?.plugin?.plugin_version, version: releaseVersion || props?.plugin?.plugin_version,
}), }),
) )
const result: { [key: string]: any } = await api.get(`plugin/install/${props.plugin?.id}`, { const result: { [key: string]: any } = await api.get(`plugin/install/${props.plugin?.id}`, {
params: { params: {
repo_url: props.plugin?.repo_url, repo_url: repoUrl || props.plugin?.repo_url,
force: props.plugin?.has_update, release_version: releaseVersion,
force: props.plugin?.has_update || Boolean(releaseVersion),
}, },
}) })
@@ -122,6 +143,8 @@ async function installPlugin() {
if (result.success) { if (result.success) {
$toast.success(t('plugin.installSuccess', { name: props.plugin?.plugin_name })) $toast.success(t('plugin.installSuccess', { name: props.plugin?.plugin_name }))
versionHistoryDialogController?.close()
versionHistoryDialogController = null
visible.value = false visible.value = false
emit('install') emit('install')
} else { } else {
@@ -133,8 +156,22 @@ async function installPlugin() {
} }
} }
/** 打开版本历史并支持从 Release 资产安装指定版本。 */
function showUpdateHistory() {
versionHistoryDialogController?.close()
versionHistoryDialogController = openSharedDialog(
PluginVersionHistoryDialog,
{ plugin: props.plugin, actionMode: 'install' },
{
update: installPlugin,
},
{ closeOn: ['close', 'update:modelValue'] },
)
}
onUnmounted(() => { onUnmounted(() => {
closeInstallProgress() closeInstallProgress()
versionHistoryDialogController?.close()
}) })
</script> </script>
@@ -190,16 +227,23 @@ onUnmounted(() => {
class="mb-3" class="mb-3"
:text="props.plugin?.system_version_message || t('plugin.incompatibleSystemVersion')" :text="props.plugin?.system_version_message || t('plugin.incompatibleSystemVersion')"
/> />
<div class="text-center text-md-left"> <div class="plugin-market-detail-actions">
<VBtn
variant="tonal"
@click="showUpdateHistory"
prepend-icon="mdi-update"
>
{{ t('plugin.versionHistory') }}
</VBtn>
<VBtn <VBtn
color="primary" color="primary"
@click="installPlugin" @click="installPlugin()"
prepend-icon="mdi-download" prepend-icon="mdi-download"
:disabled="props.plugin?.system_version_compatible === false" :disabled="props.plugin?.system_version_compatible === false"
> >
{{ t('plugin.installToLocal') }} {{ t('plugin.installToLocal') }}
</VBtn> </VBtn>
<div class="text-xs mt-2" v-if="props.count"> <div class="plugin-market-detail-actions__downloads" v-if="props.count">
<VIcon icon="mdi-fire" /> <VIcon icon="mdi-fire" />
{{ t('plugin.totalDownloads', { count: formatDownloadCount(props.count) }) }} {{ t('plugin.totalDownloads', { count: formatDownloadCount(props.count) }) }}
</div> </div>
@@ -212,3 +256,30 @@ onUnmounted(() => {
</VCard> </VCard>
</VDialog> </VDialog>
</template> </template>
<style scoped>
.plugin-market-detail-actions {
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
align-items: center;
justify-content: center;
}
.plugin-market-detail-actions__downloads {
flex-basis: 100%;
color: rgba(var(--v-theme-on-surface), var(--v-medium-emphasis-opacity));
font-size: 0.75rem;
text-align: center;
}
@media (min-width: 960px) {
.plugin-market-detail-actions {
justify-content: flex-start;
}
.plugin-market-detail-actions__downloads {
text-align: start;
}
}
</style>

View File

@@ -1,11 +1,11 @@
<script setup lang="ts"> <script setup lang="ts">
import api from '@/api' import api from '@/api'
import type { Plugin } from '@/api/types' import type { Plugin, PluginReleaseVersion, PluginReleaseVersionsResponse } from '@/api/types'
import VersionHistory from '@/components/misc/VersionHistory.vue' import VersionHistory from '@/components/misc/VersionHistory.vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
// 多语言 // 多语言
const { t } = useI18n() const { t, locale } = useI18n()
// 输入参数 // 输入参数
const props = defineProps({ const props = defineProps({
@@ -21,14 +21,25 @@ const props = defineProps({
type: Boolean, type: Boolean,
default: false, default: false,
}, },
actionMode: {
type: String as PropType<'install' | 'update'>,
default: 'update',
},
}) })
// 定义触发的自定义事件 // 定义触发的自定义事件
const emit = defineEmits(['update:modelValue', 'close', 'update']) const emit = defineEmits<{
(event: 'update:modelValue', value: boolean): void
(event: 'close'): void
(event: 'update', releaseVersion?: string, repoUrl?: string): void
}>()
const loading = ref(false) const loading = ref(false)
const loadError = ref('') const loadError = ref('')
const pluginDetail = ref<Plugin | null>(null) const pluginDetail = ref<Plugin | null>(null)
const releaseLoading = ref(false)
const releaseError = ref('')
const releaseDetail = ref<PluginReleaseVersionsResponse | null>(null)
// 弹窗显示状态 // 弹窗显示状态
const visible = computed({ const visible = computed({
@@ -41,19 +52,73 @@ const visible = computed({
const resolvedPlugin = computed(() => pluginDetail.value ?? props.plugin) const resolvedPlugin = computed(() => pluginDetail.value ?? props.plugin)
const resolvedHistory = computed(() => resolvedPlugin.value?.history || {}) const resolvedHistory = computed(() => {
const history = { ...(resolvedPlugin.value?.history || {}) }
releaseItems.value.forEach(item => {
const key = normalizeHistoryVersion(item.version)
if (!(key in history)) history[key] = item.body || ''
})
return history
})
const hasHistory = computed(() => Object.keys(resolvedHistory.value).length > 0) const hasHistory = computed(() => Object.keys(resolvedHistory.value).length > 0)
const latestActionText = computed(() => props.actionMode === 'install' ? t('plugin.installReleaseVersion') : t('plugin.updateToLatest'))
const releaseItems = computed(() => releaseDetail.value?.items || [])
const shouldShowUpdatePanel = computed(() => props.showUpdateAction)
const releaseByHistoryVersion = computed(() => {
const releaseMap = new Map<string, PluginReleaseVersion>()
releaseItems.value.forEach(item => {
releaseMap.set(normalizeHistoryVersion(item.version), item)
})
return releaseMap
})
function normalizeHistoryVersion(version: string) {
return version.startsWith('v') ? version : `v${version}`
}
function formatReleaseDate(value?: string) {
if (!value) return ''
const date = new Date(value)
if (Number.isNaN(date.getTime())) return value
return date.toLocaleDateString(locale.value)
}
function releaseItemByHistoryVersion(version: string) {
return releaseByHistoryVersion.value.get(version)
}
function shouldShowReleaseButton(item?: PluginReleaseVersion) {
if (!item || item.is_current) return false
return !(item.is_latest && shouldShowUpdatePanel.value && props.actionMode === 'update')
}
async function loadPluginHistory() { async function loadPluginHistory() {
if (!props.plugin?.id) { if (!props.plugin?.id) {
pluginDetail.value = null pluginDetail.value = null
loadError.value = '' loadError.value = ''
releaseDetail.value = null
releaseError.value = ''
return return
} }
loading.value = true loading.value = true
loadError.value = '' loadError.value = ''
releaseDetail.value = null
releaseError.value = ''
// 插件市场条目已经携带远端信息history 接口只查询已安装插件,
// 未安装插件打开版本历史时只能基于传入的市场数据和 Release 列表展示。
if (props.actionMode === 'install' && props.plugin?.repo_url) {
pluginDetail.value = null
loading.value = false
loadPluginReleases(props.plugin, false)
return
}
try { try {
pluginDetail.value = await api.get(`plugin/history/${props.plugin.id}`, { pluginDetail.value = await api.get(`plugin/history/${props.plugin.id}`, {
@@ -61,6 +126,7 @@ async function loadPluginHistory() {
force: true, force: true,
}, },
}) })
loadPluginReleases(pluginDetail.value ?? props.plugin, true)
} catch (error) { } catch (error) {
pluginDetail.value = null pluginDetail.value = null
loadError.value = t('plugin.updateHistoryLoadFailed') loadError.value = t('plugin.updateHistoryLoadFailed')
@@ -70,36 +136,108 @@ async function loadPluginHistory() {
} }
} }
async function loadPluginReleases(plugin: Plugin | null | undefined = resolvedPlugin.value, force = false) {
if (!plugin?.id || !plugin?.repo_url || !plugin?.release) {
releaseDetail.value = null
releaseError.value = ''
return
}
releaseLoading.value = true
releaseError.value = ''
try {
releaseDetail.value = await api.get(`plugin/releases/${plugin.id}`, {
params: {
repo_url: plugin.repo_url,
force,
},
})
} catch (error) {
releaseDetail.value = null
releaseError.value = t('plugin.releaseVersionsLoadFailed')
console.error(error)
} finally {
releaseLoading.value = false
}
}
/** 触发插件更新操作。 */ /** 触发插件更新操作。 */
function handleUpdate() { function handleUpdate(releaseItem?: PluginReleaseVersion) {
emit('update') emit('update', releaseItem?.is_latest ? undefined : releaseItem?.version, resolvedPlugin.value?.repo_url)
} }
watch( watch(
() => [visible.value, props.plugin?.id], () => [visible.value, props.plugin?.id],
([isVisible]) => { ([isVisible]) => {
if (isVisible) loadPluginHistory() if (isVisible) {
loadPluginHistory()
}
}, },
{ immediate: true }, { immediate: true },
) )
</script> </script>
<template> <template>
<VDialog v-if="visible" v-model="visible" width="600" max-height="85vh" scrollable> <VDialog v-if="visible" v-model="visible" width="680" max-height="85vh" scrollable>
<VCard :title="t('plugin.updateHistoryTitle', { name: resolvedPlugin?.plugin_name })"> <VCard :title="t('plugin.updateHistoryTitle', { name: resolvedPlugin?.plugin_name })">
<VDialogCloseBtn v-model="visible" /> <VDialogCloseBtn v-model="visible" />
<VDivider /> <VDivider />
<VProgressLinear v-if="releaseLoading && !loading" indeterminate color="primary" height="2" />
<div v-if="loading" class="plugin-version-history-dialog__loading"> <div v-if="loading" class="plugin-version-history-dialog__loading">
<VProgressCircular indeterminate color="primary" /> <VProgressCircular indeterminate color="primary" />
</div> </div>
<VCardText v-else-if="loadError && !hasHistory"> <VCardText v-else-if="loadError && !hasHistory">
<VAlert type="warning" variant="tonal" density="compact" :text="loadError" /> <VAlert type="warning" variant="tonal" density="compact" :text="loadError" />
</VCardText> </VCardText>
<VCardText v-else-if="!hasHistory"> <VCardText v-else-if="!hasHistory && !releaseLoading">
<VAlert type="info" variant="tonal" density="compact" :text="t('plugin.updateHistoryEmpty')" /> <VAlert type="info" variant="tonal" density="compact" :text="t('plugin.updateHistoryEmpty')" />
</VCardText> </VCardText>
<VersionHistory v-else :history="resolvedHistory" /> <template v-else>
<template v-if="props.showUpdateAction"> <VCardText v-if="releaseError" class="pb-0">
<VAlert type="warning" variant="tonal" density="compact" :text="releaseError" />
</VCardText>
<VersionHistory
:history="resolvedHistory"
:has-action="version => shouldShowReleaseButton(releaseItemByHistoryVersion(version))"
>
<template #meta="{ version }">
<div v-if="releaseItemByHistoryVersion(version)" class="plugin-release-meta">
<span v-if="formatReleaseDate(releaseItemByHistoryVersion(version)?.published_at)" class="plugin-release-meta__date">
{{ formatReleaseDate(releaseItemByHistoryVersion(version)?.published_at) }}
</span>
<VChip v-if="releaseItemByHistoryVersion(version)?.is_latest" size="x-small" color="primary" variant="tonal">
{{ t('plugin.latestVersion') }}
</VChip>
<VChip v-if="releaseItemByHistoryVersion(version)?.is_current" size="x-small" color="success" variant="tonal">
{{ t('plugin.currentVersion') }}
</VChip>
</div>
</template>
<template #action="{ version }">
<VBtn
v-if="shouldShowReleaseButton(releaseItemByHistoryVersion(version))"
class="plugin-release-button"
size="small"
min-width="5rem"
:color="releaseItemByHistoryVersion(version)?.is_latest ? 'primary' : undefined"
:variant="releaseItemByHistoryVersion(version)?.is_latest ? 'flat' : 'tonal'"
:disabled="
releaseItemByHistoryVersion(version)?.is_current ||
(releaseItemByHistoryVersion(version)?.is_latest && resolvedPlugin?.system_version_compatible === false)
"
@click.stop="handleUpdate(releaseItemByHistoryVersion(version))"
>
{{
releaseItemByHistoryVersion(version)?.is_latest
? latestActionText
: t('plugin.installReleaseVersion')
}}
</VBtn>
</template>
</VersionHistory>
</template>
<template v-if="shouldShowUpdatePanel">
<VDivider /> <VDivider />
<VCardItem> <VCardItem>
<VAlert <VAlert
@@ -110,7 +248,11 @@ watch(
class="mb-3" class="mb-3"
:text="resolvedPlugin?.system_version_message || t('plugin.incompatibleSystemVersion')" :text="resolvedPlugin?.system_version_message || t('plugin.incompatibleSystemVersion')"
/> />
<VBtn @click="handleUpdate" block :disabled="resolvedPlugin?.system_version_compatible === false"> <VBtn
@click="handleUpdate()"
block
:disabled="resolvedPlugin?.system_version_compatible === false"
>
<template #prepend> <template #prepend>
<VIcon icon="mdi-arrow-up-circle-outline" /> <VIcon icon="mdi-arrow-up-circle-outline" />
</template> </template>
@@ -129,4 +271,23 @@ watch(
align-items: center; align-items: center;
justify-content: center; justify-content: center;
} }
.plugin-release-button {
white-space: nowrap;
}
.plugin-release-meta {
display: flex;
align-items: center;
gap: 0.5rem;
flex-wrap: wrap;
min-width: 0;
}
.plugin-release-meta__date {
color: rgba(var(--v-theme-on-surface), var(--v-disabled-opacity));
font-size: 0.875rem;
white-space: nowrap;
}
</style> </style>

View File

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

View File

@@ -39,10 +39,21 @@ const visible = computed({
if (!value) emit('close') 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> </script>
<template> <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"> <VCard :class="props.cardClass">
<VCardItem> <VCardItem>
<VCardTitle> <VCardTitle>
@@ -53,7 +64,7 @@ const visible = computed({
<VDialogCloseBtn v-model="visible" /> <VDialogCloseBtn v-model="visible" />
</VCardItem> </VCardItem>
<VDivider /> <VDivider />
<VCardText :class="props.bodyClass"> <VCardText :class="bodyClasses">
<Component :is="props.view" v-bind="props.viewProps" /> <Component :is="props.view" v-bind="props.viewProps" />
</VCardText> </VCardText>
</VCard> </VCard>
@@ -61,8 +72,6 @@ const visible = computed({
</template> </template>
<style scoped> <style scoped>
/* stylelint-disable selector-pseudo-class-no-unknown */
.system-health-dialog-card { .system-health-dialog-card {
display: flex; display: flex;
overflow: hidden; overflow: hidden;
@@ -78,7 +87,7 @@ const visible = computed({
min-block-size: 0; min-block-size: 0;
} }
:global(.v-dialog--fullscreen) .system-health-dialog-body { .system-health-dialog-body--fullscreen {
block-size: auto; block-size: auto;
} }
</style> </style>

View File

@@ -475,26 +475,26 @@ onMounted(() => {
:items="mobileResourceList" :items="mobileResourceList"
:columns="1" :columns="1"
:gap="12" :gap="12"
:estimated-item-height="320" :estimated-item-height="220"
:overscan-rows="5" :overscan-rows="5"
:get-item-key="getResourceItemKey" :get-item-key="getResourceItemKey"
> >
<template #default="{ item }"> <template #default="{ item }">
<VCard> <VCard class="site-resource-card" variant="flat">
<VCardText class="pa-4"> <VCardText class="pa-3">
<button type="button" class="site-resource-title-btn text-start" @click="addDownload(item)"> <button type="button" class="site-resource-title-btn text-start" @click="addDownload(item)">
<div class="text-body-1 font-weight-medium text-high-emphasis"> <div class="site-resource-card__title text-body-1 font-weight-medium text-high-emphasis">
{{ item.title }} {{ item.title }}
</div> </div>
<div <div
v-if="item.description" v-if="item.description"
class="site-resource-card__description mt-2 text-body-2 text-medium-emphasis" class="site-resource-card__description mt-1 text-body-2 text-medium-emphasis"
> >
{{ item.description }} {{ item.description }}
</div> </div>
</button> </button>
<div class="mt-3"> <div class="site-resource-card__chips mt-2">
<VChip <VChip
v-if="item.hit_and_run" v-if="item.hit_and_run"
variant="elevated" variant="elevated"
@@ -533,47 +533,82 @@ onMounted(() => {
</VChip> </VChip>
</div> </div>
<div class="site-resource-card__meta mt-4"> <!-- 移动端在操作区前展示关键资源指标方便点击前快速判断 -->
<div class="site-resource-card__meta-item"> <div class="site-resource-card__summary mt-3">
<div class="text-caption text-medium-emphasis">{{ t('dialog.siteResource.timeColumn') }}</div> <div class="site-resource-card__stat">
<div class="text-body-2 font-weight-medium">{{ item.date_elapsed || item.pubdate || '-' }}</div> <VIcon icon="mdi-clock-outline" size="15" />
<div v-if="item.pubdate" class="text-caption text-medium-emphasis mt-1">{{ item.pubdate }}</div> <span>{{ item.date_elapsed || item.pubdate || '-' }}</span>
</div> </div>
<div class="site-resource-card__meta-item"> <div class="site-resource-card__stat">
<div class="text-caption text-medium-emphasis">{{ t('dialog.siteResource.sizeColumn') }}</div> <VIcon icon="mdi-harddisk" size="15" />
<div class="text-body-2 font-weight-medium">{{ formatFileSize(item.size) }}</div> <span>{{ formatFileSize(item.size) }}</span>
</div> </div>
<div class="site-resource-card__meta-item"> <div class="site-resource-card__stat site-resource-card__stat--success">
<div class="text-caption text-medium-emphasis">{{ t('dialog.siteResource.seedersColumn') }}</div> <VIcon icon="mdi-arrow-up" size="15" />
<div class="text-body-2 font-weight-medium">{{ item.seeders }}</div> <span>{{ item.seeders ?? '-' }}</span>
</div> </div>
<div class="site-resource-card__meta-item"> <div class="site-resource-card__stat site-resource-card__stat--warning">
<div class="text-caption text-medium-emphasis">{{ t('dialog.siteResource.peersColumn') }}</div> <VIcon icon="mdi-arrow-down" size="15" />
<div class="text-body-2 font-weight-medium">{{ item.peers }}</div> <span>{{ item.peers ?? '-' }}</span>
</div> </div>
</div> </div>
<div class="site-resource-card__actions mt-4"> <!-- 下载保留文本其它低频操作改为图标按钮并保持同一行 -->
<VBtn color="primary" variant="flat" block prepend-icon="mdi-download" @click="addDownload(item)"> <div class="site-resource-card__actions mt-2">
<VBtn
color="primary"
variant="flat"
class="site-resource-card__download-btn"
prepend-icon="mdi-download"
@click="addDownload(item)"
>
{{ t('actionStep.addDownload') }} {{ t('actionStep.addDownload') }}
</VBtn> </VBtn>
<div class="site-resource-card__secondary-actions mt-2"> <VTooltip :text="t('common.viewDetails')" location="top">
<VBtn <template #activator="{ props: tooltipProps }">
variant="tonal" <VBtn
prepend-icon="mdi-open-in-new" v-bind="tooltipProps"
@click="openTorrentDetail(item.page_url || '')" icon
> variant="tonal"
{{ t('common.viewDetails') }} color="primary"
</VBtn> class="site-resource-card__icon-btn"
<VBtn :aria-label="t('common.viewDetails')"
v-if="item.enclosure?.startsWith('http')" @click="openTorrentDetail(item.page_url || '')"
variant="tonal" >
prepend-icon="mdi-tray-arrow-down" <VIcon icon="mdi-open-in-new" />
@click="downloadTorrentFile(item.enclosure)" </VBtn>
> </template>
{{ t('dialog.siteResource.downloadTorrent') }} </VTooltip>
</VBtn> <VTooltip
</div> v-if="item.enclosure?.startsWith('http')"
:text="t('dialog.siteResource.downloadTorrent')"
location="top"
>
<template #activator="{ props: tooltipProps }">
<VBtn
v-bind="tooltipProps"
icon
variant="tonal"
color="primary"
class="site-resource-card__icon-btn"
:aria-label="t('dialog.siteResource.downloadTorrent')"
@click="downloadTorrentFile(item.enclosure)"
>
<VIcon icon="mdi-file-download-outline" />
</VBtn>
</template>
</VTooltip>
<VBtn
v-else
icon
variant="tonal"
color="primary"
disabled
class="site-resource-card__icon-btn"
:aria-label="t('dialog.siteResource.downloadTorrent')"
>
<VIcon icon="mdi-file-download-outline" />
</VBtn>
</div> </div>
</VCardText> </VCardText>
</VCard> </VCard>
@@ -702,44 +737,107 @@ onMounted(() => {
white-space: nowrap; white-space: nowrap;
} }
.site-resource-card {
--site-resource-card-bg:
linear-gradient(180deg, rgba(var(--v-theme-surface), 0.98), rgba(var(--v-theme-surface), 0.94)),
radial-gradient(circle at top right, rgba(var(--v-theme-primary), 0.08), transparent 34%);
border: 1px solid rgba(var(--v-border-color), calc(var(--v-border-opacity) * 0.9));
background: var(--site-resource-card-bg);
}
:global(html[data-theme="transparent"]) .site-resource-card {
--site-resource-card-bg: rgba(var(--v-theme-surface), var(--transparent-opacity));
backdrop-filter: blur(var(--transparent-blur));
}
.site-resource-card__summary {
display: grid;
gap: 0.35rem;
grid-template-columns: minmax(0, 1.4fr) minmax(0, 1fr) minmax(2.5rem, 0.62fr) minmax(2.5rem, 0.62fr);
align-items: center;
}
.site-resource-card__stat {
display: inline-flex;
overflow: hidden;
align-items: center;
justify-content: center;
gap: 0.22rem;
border-radius: 6px;
background: rgba(var(--v-theme-on-surface), 0.05);
color: rgba(var(--v-theme-on-surface), 0.72);
font-size: 0.74rem;
font-weight: 600;
line-height: 1;
min-block-size: 1.65rem;
min-inline-size: 0;
padding-inline: 0.4rem;
}
.site-resource-card__stat span {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.site-resource-card__stat--success {
color: rgb(var(--v-theme-success));
}
.site-resource-card__stat--warning {
color: rgb(var(--v-theme-warning));
}
.site-resource-card__title {
display: -webkit-box;
overflow: hidden;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
line-height: 1.38;
}
.site-resource-card__description { .site-resource-card__description {
display: -webkit-box; display: -webkit-box;
overflow: hidden; overflow: hidden;
-webkit-box-orient: vertical; -webkit-box-orient: vertical;
-webkit-line-clamp: 3; -webkit-line-clamp: 2;
line-height: 1.35;
} }
.site-resource-card__meta { .site-resource-card__chips {
max-block-size: 4.75rem;
overflow: hidden;
}
.site-resource-card__actions {
display: grid; display: grid;
gap: 0.55rem; gap: 0.45rem;
grid-template-columns: repeat(2, minmax(0, 1fr)); grid-template-columns: minmax(0, 1fr) 2.5rem 2.5rem;
align-items: center;
} }
.site-resource-card__meta-item { .site-resource-card__download-btn {
background: rgba(var(--v-theme-surface), 0.78); min-block-size: 2.5rem;
min-block-size: 0; min-inline-size: 0;
padding-block: 0.55rem; box-shadow: 0 6px 16px rgba(var(--v-theme-primary), 0.17);
padding-inline: 0.65rem;
} }
.site-resource-card__meta-item :deep(.text-caption) { .site-resource-card__download-btn :deep(.v-btn__content) {
font-size: 0.72rem !important; overflow: hidden;
line-height: 1.2; text-overflow: ellipsis;
white-space: nowrap;
} }
.site-resource-card__meta-item :deep(.text-body-2) { .site-resource-card__icon-btn {
font-size: 0.82rem !important; block-size: 2.5rem;
line-height: 1.25; inline-size: 2.5rem;
min-inline-size: 2.5rem;
} }
.site-resource-card__secondary-actions { .site-resource-card__icon-btn :deep(.v-btn__content) {
display: flex; font-size: 1.05rem;
flex-wrap: wrap;
gap: 0.5rem;
}
.site-resource-card__secondary-actions :deep(.v-btn) {
flex: 1 1 12rem;
} }
@media (width >= 960px) { @media (width >= 960px) {
@@ -761,4 +859,14 @@ onMounted(() => {
min-block-size: 2.5rem; min-block-size: 2.5rem;
} }
} }
@media (width <= 420px) {
.site-resource-card__summary {
grid-template-columns: minmax(0, 1.15fr) minmax(0, 0.95fr) minmax(2.3rem, 0.55fr) minmax(2.3rem, 0.55fr);
}
.site-resource-card__stat {
padding-inline: 0.3rem;
}
}
</style> </style>

View File

@@ -141,4 +141,29 @@ function updateFilter(key: string, values: string[]) {
gap: 1rem; gap: 1rem;
grid-template-columns: repeat(auto-fit, minmax(18rem, 1fr)); 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> </style>

View File

@@ -142,4 +142,24 @@ function handleDetail(item: Context) {
max-block-size: 60vh; max-block-size: 60vh;
overflow-y: auto; 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> </style>

View File

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

View File

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

View File

@@ -3,9 +3,9 @@ import type { PropType } from 'vue'
import MarkdownIt from 'markdown-it' import MarkdownIt from 'markdown-it'
import mdLinkAttributes from 'markdown-it-link-attributes' import mdLinkAttributes from 'markdown-it-link-attributes'
// 初始化 markdown-it // 版本历史可能来自插件市场或 Release 内容,禁止透传原始 HTML避免外部内容注入脚本或事件属性。
const md = new MarkdownIt({ const md = new MarkdownIt({
html: true, html: false,
linkify: true, linkify: true,
typographer: true, typographer: true,
}) })
@@ -27,23 +27,100 @@ function renderMarkdown(value: string) {
// 输入参数 // 输入参数
const props = defineProps({ const props = defineProps({
history: Object as PropType<{ [key: string]: string }>, history: Object as PropType<{ [key: string]: string }>,
hasAction: Function as PropType<(version: string) => boolean>,
}) })
function shouldRenderAction(version: string) {
return props.hasAction?.(version) ?? true
}
</script> </script>
<template> <template>
<VCardText> <VCardText class="version-history">
<VList> <div class="version-history__list">
<VListItem v-for="(value, key) in props.history" :key="key"> <section v-for="(value, key) in props.history" :key="key" class="version-history__item">
<VListItemTitle class="font-bold text-lg"> <div
{{ key }} class="version-history__top"
</VListItemTitle> :class="{ 'version-history__top--with-action': $slots.action && shouldRenderAction(String(key)) }"
<div class="markdown-body text-gray-500" v-html="renderMarkdown(value)" /> >
</VListItem> <div class="version-history__header">
</VList> <div class="version-history__version">
{{ key }}
</div>
<div v-if="$slots.meta" class="version-history__meta">
<slot name="meta" :version="String(key)" />
</div>
</div>
<div v-if="$slots.action && shouldRenderAction(String(key))" class="version-history__action">
<slot name="action" :version="String(key)" />
</div>
</div>
<div class="markdown-body text-medium-emphasis" v-html="renderMarkdown(value)" />
</section>
</div>
</VCardText> </VCardText>
</template> </template>
<style scoped> <style scoped>
.version-history {
padding: 0;
}
.version-history__list {
display: flex;
flex-direction: column;
}
.version-history__item {
padding: 1.25rem 2rem;
}
.version-history__item + .version-history__item {
border-block-start: 1px solid rgba(var(--v-border-color), var(--v-border-opacity));
}
.version-history__top {
display: grid;
grid-template-columns: minmax(0, 1fr);
grid-template-areas: "main";
gap: 0;
align-items: center;
margin-block-end: 0.5rem;
}
.version-history__top--with-action {
grid-template-columns: minmax(0, 1fr) max-content;
grid-template-areas: "main action";
gap: 1rem;
}
.version-history__header {
grid-area: main;
display: flex;
gap: 0.75rem;
align-items: center;
justify-content: flex-start;
min-width: 0;
}
.version-history__version {
color: rgba(var(--v-theme-on-surface), var(--v-high-emphasis-opacity));
font-size: 1.25rem;
font-weight: 700;
line-height: 1.25;
}
.version-history__meta {
display: flex;
min-width: 0;
}
.version-history__action {
grid-area: action;
align-self: center;
justify-self: end;
}
.markdown-body :deep(h1), .markdown-body :deep(h1),
.markdown-body :deep(h2), .markdown-body :deep(h2),
.markdown-body :deep(h3) { .markdown-body :deep(h3) {
@@ -112,4 +189,28 @@ const props = defineProps({
border-inline-start: 3px solid rgba(127, 127, 127, 0.4); border-inline-start: 3px solid rgba(127, 127, 127, 0.4);
color: rgba(127, 127, 127, 0.8); color: rgba(127, 127, 127, 0.8);
} }
@media (max-width: 600px) {
.version-history {
padding: 0;
}
.version-history__item {
padding: 1rem;
}
.version-history__top--with-action {
gap: 0.75rem;
}
.version-history__header {
flex-wrap: wrap;
justify-content: flex-start;
}
.version-history__version {
font-size: 1.125rem;
}
}
</style> </style>

View File

@@ -7,6 +7,7 @@ import { themeManager } from '@/utils/themeManager'
export const THEME_CUSTOMIZER_STORAGE_KEY = 'moviepilot-theme-customizer' export const THEME_CUSTOMIZER_STORAGE_KEY = 'moviepilot-theme-customizer'
export const THEME_CUSTOMIZER_CHANGE_EVENT = 'moviepilot-theme-customizer-change' export const THEME_CUSTOMIZER_CHANGE_EVENT = 'moviepilot-theme-customizer-change'
export const THEME_CUSTOMIZER_OPEN_EVENT = 'moviepilot-theme-customizer-open'
export const themeCustomizerPrimaryColors = [ export const themeCustomizerPrimaryColors = [
{ name: 'Purple', value: '#9155FD' }, { name: 'Purple', value: '#9155FD' },

View File

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

View File

@@ -810,12 +810,6 @@ function handleBackdropClick(event: MouseEvent) {
-webkit-user-select: none; -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) { @media (hover: none) and (pointer: coarse) {
.plugin-item:hover { .plugin-item:hover {
background: transparent; background: transparent;

View File

@@ -5,7 +5,6 @@ import { openSharedDialog } from '@/composables/useSharedDialog'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { useUserStore } from '@/stores' import { useUserStore } from '@/stores'
import { buildUserPermissionContext, filterItemsByPermission, hasItemPermission, type PermissionProtectedItem } from '@/utils/permission' import { buildUserPermissionContext, filterItemsByPermission, hasItemPermission, type PermissionProtectedItem } from '@/utils/permission'
import { clearUnreadMessages, getUnreadCount, onUnreadMessage } from '@/utils/badge'
// 国际化 // 国际化
const { t } = useI18n() const { t } = useI18n()
@@ -21,7 +20,6 @@ const WordsView = defineAsyncComponent(() => import('@/views/system/WordsView.vu
const CacheView = defineAsyncComponent(() => import('@/views/system/CacheView.vue')) const CacheView = defineAsyncComponent(() => import('@/views/system/CacheView.vue'))
const AccountSettingService = defineAsyncComponent(() => import('@/views/system/ServiceView.vue')) const AccountSettingService = defineAsyncComponent(() => import('@/views/system/ServiceView.vue'))
const ShortcutLogDialog = defineAsyncComponent(() => import('@/components/dialog/ShortcutLogDialog.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')) const ShortcutToolDialog = defineAsyncComponent(() => import('@/components/dialog/ShortcutToolDialog.vue'))
type ShortcutItem = PermissionProtectedItem & { type ShortcutItem = PermissionProtectedItem & {
@@ -44,12 +42,6 @@ const appsMenu = ref(false)
// 菜单最大宽度 // 菜单最大宽度
const menuMaxWidth = ref(420) 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[] = [ const shortcuts: ShortcutItem[] = [
{ {
@@ -123,55 +115,16 @@ const shortcuts: ShortcutItem[] = [
component: ModuleTestView, component: ModuleTestView,
titleText: t('shortcut.system.subtitle'), 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' })) ].map(item => ({ ...item, permission: 'admin' }))
const visibleShortcuts = computed(() => filterItemsByPermission(shortcuts, userPermissions.value)) 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]) { function openShortcutDialog(item: (typeof shortcuts)[number]) {
if (!hasItemPermission(item, userPermissions.value)) return if (!hasItemPermission(item, userPermissions.value)) return
appsMenu.value = false appsMenu.value = false
if (item.dialog === 'message') {
clearUnreadMessageState()
}
if (item.customDialog) { if (item.customDialog) {
openSharedDialog(item.customDialog, {}, {}, { closeOn: ['close', 'update:modelValue'] }) openSharedDialog(item.customDialog, {}, {}, { closeOn: ['close', 'update:modelValue'] })
return 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(() => { onMounted(() => {
stopUnreadMessageListener = onUnreadMessage(handleUnreadMessage)
void syncUnreadMessageStateFromBadge()
const shortcut = getQueryValue('shortcut') const shortcut = getQueryValue('shortcut')
if (shortcut) { if (shortcut) {
const found = visibleShortcuts.value.find(item => item.dialog === shortcut) const found = visibleShortcuts.value.find(item => item.dialog === shortcut)
@@ -218,10 +157,6 @@ onMounted(() => {
} }
} }
}) })
onBeforeUnmount(() => {
stopUnreadMessageListener?.()
})
</script> </script>
<template> <template>
@@ -257,30 +192,20 @@ onBeforeUnmount(() => {
<div class="grid grid-cols-2 gap-3"> <div class="grid grid-cols-2 gap-3">
<!-- 循环渲染快捷方式 --> <!-- 循环渲染快捷方式 -->
<div v-for="(item, index) in visibleShortcuts" :key="index"> <div v-for="(item, index) in visibleShortcuts" :key="index">
<VBadge <VCard
:model-value="item.dialog === 'message' && hasUnreadMessages" flat
dot class="pa-2 d-flex align-center cursor-pointer transition-transform duration-300 hover:-translate-y-1 border h-full w-100"
color="error" hover
location="top end" @click="openShortcutDialog(item)"
offset-x="8"
offset-y="8"
class="d-block h-full w-100"
> >
<VCard <VAvatar variant="text" size="48" rounded="lg">
flat <VIcon color="primary" :icon="item.icon" size="24" />
class="pa-2 d-flex align-center cursor-pointer transition-transform duration-300 hover:-translate-y-1 border h-full w-100" </VAvatar>
hover <div>
@click="openShortcutDialog(item)" <div class="text-body-1 text-high-emphasis font-weight-medium">{{ item.title }}</div>
> <div class="text-caption text-medium-emphasis">{{ item.subtitle }}</div>
<VAvatar variant="text" size="48" rounded="lg"> </div>
<VIcon color="primary" :icon="item.icon" size="24" /> </VCard>
</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>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -1,68 +1,251 @@
<script setup lang="ts"> <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 { formatDateDifference } from '@core/utils/formatters'
import { SystemNotification } from '@/api/types'
import { useI18n } from 'vue-i18n'
import { useBackground } from '@/composables/useBackground' import { useBackground } from '@/composables/useBackground'
import { useI18n } from 'vue-i18n'
const { t } = useI18n() const { t } = useI18n()
const { useDelayedSSE } = useBackground() const { useDelayedSSE } = useBackground()
// 是否有新消息 const PAGE_SIZE = 20
const hasNewMessage = ref(false) // 固定通知项高度,配合 VVirtualScroll 避免历史通知过多时一次性渲染全部 DOM。
const NOTIFICATION_ITEM_HEIGHT = 104
const MEDIA_NOTIFICATION_TYPES = ['资源下载', '整理入库', '订阅', '媒体服务器', '手动处理']
// 通知列表
const notificationList = ref<SystemNotification[]>([])
const MAX_NOTIFICATIONS = 100
// 弹窗
const appsMenu = ref(false) const appsMenu = ref(false)
const hasNewMessage = ref(false)
const notificationList = ref<SystemNotification[]>([])
const page = ref(1)
const loading = ref(false)
const hasMore = ref(true)
const notificationKeys = new Set<string>()
// 标记所有消息为已读 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 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),
}
}
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[]
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() { function markAllAsRead() {
hasNewMessage.value = false hasNewMessage.value = false
// 标记所有消息为已读
notificationList.value.forEach(item => { notificationList.value.forEach(item => {
item.read = true item.read = true
}) })
appsMenu.value = false appsMenu.value = false
void clearUnreadMessages()
} }
// 消息处理函数 function getNotificationIcon(item: SystemNotification) {
function handleMessage(event: MessageEvent) { if (getNotificationKind(item) === 'plugin') return 'mdi-puzzle-outline'
if (event.data) { if (item.mtype === '资源下载') return 'mdi-download'
const noti: SystemNotification = JSON.parse(event.data) if (item.mtype === '整理入库') return 'mdi-folder-check-outline'
notificationList.value.unshift(noti) if (item.mtype === '订阅') return 'mdi-rss'
if (notificationList.value.length > MAX_NOTIFICATIONS) { if (item.mtype === '智能体') return 'lucide:bot'
notificationList.value.length = MAX_NOTIFICATIONS return getNotificationKind(item) === 'system' ? 'mdi-alert-circle-outline' : 'mdi-bell-outline'
} }
hasNewMessage.value = true
} 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')
} }
// 延迟3秒启动SSE连接避免认证信息尚未准备好。
useDelayedSSE( useDelayedSSE(
`${import.meta.env.VITE_API_BASE_URL}system/message`, `${import.meta.env.VITE_API_BASE_URL}system/message?role=notification`,
handleMessage, handleMessage,
'user-notification', 'user-notification',
3000, 3000,
{ {
backgroundCloseDelay: 5000, backgroundCloseDelay: 5000,
reconnectDelay: 3000, reconnectDelay: 3000,
maxReconnectAttempts: 3 maxReconnectAttempts: 3,
} },
) )
</script> </script>
<template> <template>
<VMenu <VMenu
v-model="appsMenu" v-model="appsMenu"
width="400" width="420"
max-width="calc(100vw - 24px)"
transition="scale-transition" transition="scale-transition"
close-on-content-click close-on-content-click
class="notification-menu" class="notification-menu"
scrim scrim
> >
<!-- Menu Activator -->
<template #activator="{ props }"> <template #activator="{ props }">
<VBadge v-if="hasNewMessage" dot color="error" :offset-x="5" :offset-y="5" v-bind="props"> <VBadge v-if="hasNewMessage" dot color="error" :offset-x="5" :offset-y="5" v-bind="props">
<IconBtn> <IconBtn>
@@ -73,14 +256,14 @@ useDelayedSSE(
<VIcon icon="mdi-bell-outline" /> <VIcon icon="mdi-bell-outline" />
</IconBtn> </IconBtn>
</template> </template>
<!-- Menu Content -->
<VCard> <VCard class="notification-panel">
<VCardItem class="py-3"> <VCardItem class="py-3">
<VCardTitle>{{ t('notification.center') }}</VCardTitle> <VCardTitle>{{ t('notification.center') }}</VCardTitle>
<template #append> <template #append>
<VTooltip :text="t('notification.markRead')"> <VTooltip :text="t('notification.markRead')">
<template #activator="{ props }"> <template #activator="{ props }">
<IconBtn v-bind="props" @click="markAllAsRead"> <IconBtn v-bind="props" @click.stop="markAllAsRead">
<VIcon icon="mdi-email-check-outline" size="20" /> <VIcon icon="mdi-email-check-outline" size="20" />
</IconBtn> </IconBtn>
</template> </template>
@@ -88,42 +271,228 @@ useDelayedSSE(
</template> </template>
</VCardItem> </VCardItem>
<VDivider /> <VDivider />
<div class="notification-list-container"> <div class="notification-list-container">
<div v-if="notificationList.length > 0"> <VInfiniteScroll
<VListItem v-for="(item, i) in notificationList" :key="i" lines="two" class="mb-1"> mode="intersect"
<template #prepend> side="end"
<VAvatar rounded> :items="notificationList"
<VIcon v-if="item.type === 'user'" icon="mdi-account-alert" size="large"></VIcon> class="notification-list-scroll"
<VIcon v-else-if="item.type === 'plugin'" icon="mdi-robot" size="large"></VIcon> @load="loadNotifications"
<VIcon v-else icon="mdi-laptop" size="large"></VIcon> >
</VAvatar> <template #loading>
</template> <div class="py-3 text-center text-caption text-medium-emphasis">
<div> {{ t('message.loadMore') }}
<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>
</div> </div>
</VListItem> </template>
</div> <template #empty>
<div v-else class="py-8 text-center"> <div v-if="notificationList.length > 0" class="py-3 text-center text-caption text-medium-emphasis">
<VIcon icon="mdi-bell-sleep-outline" size="40" class="mb-3" /> {{ t('message.noMoreData') }}
<div>{{ t('notification.empty') }}</div> </div>
</div> <div v-else class="notification-empty">
<VIcon icon="mdi-bell-sleep-outline" size="40" class="mb-3" />
<div>{{ t('notification.empty') }}</div>
</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>
</VInfiniteScroll>
</div> </div>
</VCard> </VCard>
</VMenu> </VMenu>
</template> </template>
<style scoped> <style scoped>
.notification-panel {
overflow: hidden;
}
.notification-list-container { .notification-list-container {
max-block-size: 50vh; overflow: hidden;
overflow-y: auto; max-block-size: min(560px, 62vh);
scrollbar-width: thin; scrollbar-width: thin;
} }
.notification-list-scroll {
max-block-size: min(560px, 62vh);
min-block-size: 160px;
}
.notification-virtual-item {
block-size: 110px;
padding-block: 4px;
padding-inline: 8px;
}
.notification-row {
position: relative;
display: flex;
align-items: flex-start;
padding: 10px;
border: 0;
border-radius: 8px;
background: transparent;
block-size: 100%;
color: inherit;
cursor: pointer;
gap: 12px;
inline-size: 100%;
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;
border-radius: 6px;
background: rgba(var(--v-theme-on-surface), 0.06);
block-size: 84px;
}
.notification-media__image,
.notification-media__fallback {
block-size: 100%;
inline-size: 100%;
}
.notification-media__fallback,
.notification-icon {
display: grid;
place-items: center;
}
.notification-icon {
flex: 0 0 40px;
border-radius: 8px;
background: rgba(var(--v-theme-on-surface), 0.06);
block-size: 40px;
}
.notification-content {
flex: 1;
min-inline-size: 0;
}
.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;
border-radius: 999px;
background: rgb(var(--v-theme-error));
block-size: 7px;
inline-size: 7px;
}
.notification-text {
display: -webkit-box;
overflow: hidden;
-webkit-box-orient: vertical;
color: rgba(var(--v-theme-on-surface), var(--v-medium-emphasis-opacity));
font-size: 0.8125rem;
-webkit-line-clamp: 2;
line-height: 1.45;
margin-block-start: 4px;
white-space: pre-wrap;
}
.notification-meta {
display: flex;
flex-wrap: wrap;
align-items: center;
color: rgba(var(--v-theme-on-surface), var(--v-disabled-opacity));
font-size: 0.75rem;
gap: 6px;
line-height: 1.2;
margin-block-start: 6px;
}
.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 {
color: rgba(var(--v-theme-on-surface), var(--v-medium-emphasis-opacity));
padding-block: 32px;
padding-inline: 16px;
text-align: center;
}
</style> </style>

View File

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

View File

@@ -680,10 +680,6 @@ export default {
title: 'System', title: 'System',
subtitle: 'Health Check', subtitle: 'Health Check',
}, },
message: {
title: 'Messages',
subtitle: 'Message Center',
},
words: { words: {
title: 'Words', title: 'Words',
subtitle: 'Word Settings', subtitle: 'Word Settings',
@@ -697,6 +693,39 @@ export default {
subtitle: 'Scheduled Services', subtitle: 'Scheduled Services',
}, },
}, },
agentAssistant: {
title: 'AI Assistant',
assistant: 'Assistant',
ready: 'Ready',
thinking: 'Thinking',
newChat: 'New Chat',
history: 'Chat History',
historyLoading: 'Loading chat history...',
historyLoadFailed: 'Failed to load chat history',
noHistory: 'No chat history yet',
deleteHistory: 'Delete chat history',
unknownChannel: 'Unknown channel',
webAgentChannel: 'Web Assistant',
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: { workflow: {
components: 'Action Components', components: 'Action Components',
clickToAdd: 'Click to Add', clickToAdd: 'Click to Add',
@@ -1169,6 +1198,7 @@ export default {
currentEpisodeNotInLibrary: 'Current not in library', currentEpisodeNotInLibrary: 'Current not in library',
libraryUpdatedAt: 'Updated {time}', libraryUpdatedAt: 'Updated {time}',
libraryUpdatedAtShort: '{time}', libraryUpdatedAtShort: '{time}',
expandDayEvents: 'Show {count} more items for this day',
}, },
storage: { storage: {
name: 'Name', name: 'Name',
@@ -3006,6 +3036,12 @@ export default {
projectHome: 'Project Home', projectHome: 'Project Home',
updateHistory: 'Update History', updateHistory: 'Update History',
versionHistory: 'Version History', versionHistory: 'Version History',
releaseVersionsLoadFailed: 'Failed to load Release versions',
latestVersion: 'Latest',
currentVersion: 'Current',
installReleaseVersion: 'Install',
confirmInstallOldRelease:
'Install {name} v{version}? This version has no MoviePilot compatibility metadata and may fail to load or run.',
local: 'Local', local: 'Local',
systemVersion: 'System Version', systemVersion: 'System Version',
incompatibleSystemVersion: 'The current MoviePilot version does not meet this plugin requirement.', incompatibleSystemVersion: 'The current MoviePilot version does not meet this plugin requirement.',

View File

@@ -676,10 +676,6 @@ export default {
title: '系统', title: '系统',
subtitle: '健康检查', subtitle: '健康检查',
}, },
message: {
title: '消息',
subtitle: '消息中心',
},
words: { words: {
title: '词表', title: '词表',
subtitle: '词表设置', subtitle: '词表设置',
@@ -693,6 +689,39 @@ export default {
subtitle: '定时服务', subtitle: '定时服务',
}, },
}, },
agentAssistant: {
title: '智能助手',
assistant: '助手',
ready: '随时待命',
thinking: '思考中',
newChat: '新会话',
history: '历史会话',
historyLoading: '正在加载历史会话...',
historyLoadFailed: '历史会话加载失败',
noHistory: '暂无历史会话',
deleteHistory: '删除历史会话',
unknownChannel: '未知渠道',
webAgentChannel: '网页助手',
untitledSession: '未命名会话',
emptyTitle: '今天想处理什么?',
emptySubtitle: '站点、订阅、下载、整理任务,都可以直接问我。',
placeholder: '询问 MoviePilot...',
stop: '停止生成',
download: '下载',
attachFile: '选择图片或文件',
recordVoice: '录制语音',
stopRecording: '停止录音({time}',
attachmentMessage: '附件消息',
removeAttachment: '移除附件',
uploadFailed: '附件上传失败',
recordUnsupported: '当前浏览器不支持录音',
recordPermissionDenied: '无法访问麦克风,请检查浏览器权限',
recordFailed: '录音失败,请重试',
choiceSelected: '已选择:{option}',
choiceExpired: '该选择已失效,请重新发起选择',
error: '智能助手响应失败',
noStream: '当前浏览器无法读取流式响应',
},
workflow: { workflow: {
components: '动作组件', components: '动作组件',
clickToAdd: '点击添加', clickToAdd: '点击添加',
@@ -1164,6 +1193,7 @@ export default {
currentEpisodeNotInLibrary: '本集未入库', currentEpisodeNotInLibrary: '本集未入库',
libraryUpdatedAt: '最近更新 {time}', libraryUpdatedAt: '最近更新 {time}',
libraryUpdatedAtShort: '{time}', libraryUpdatedAtShort: '{time}',
expandDayEvents: '展开当天剩余 {count} 个条目',
}, },
storage: { storage: {
name: '名称', name: '名称',
@@ -2956,6 +2986,12 @@ export default {
projectHome: '项目主页', projectHome: '项目主页',
updateHistory: '更新说明', updateHistory: '更新说明',
versionHistory: '版本历史', versionHistory: '版本历史',
releaseVersionsLoadFailed: 'Release 版本加载失败',
latestVersion: '最新',
currentVersion: '当前',
installReleaseVersion: '安装',
confirmInstallOldRelease:
'是否确认安装 {name} v{version}?该版本缺少主程序兼容元数据,安装后可能无法加载或运行异常。',
local: '本地', local: '本地',
systemVersion: '系统版本', systemVersion: '系统版本',
incompatibleSystemVersion: '当前 MoviePilot 版本不满足插件要求,无法安装', incompatibleSystemVersion: '当前 MoviePilot 版本不满足插件要求,无法安装',

View File

@@ -676,10 +676,6 @@ export default {
title: '系統', title: '系統',
subtitle: '健康檢查', subtitle: '健康檢查',
}, },
message: {
title: '消息',
subtitle: '消息中心',
},
words: { words: {
title: '詞表', title: '詞表',
subtitle: '詞表設置', subtitle: '詞表設置',
@@ -693,6 +689,39 @@ export default {
subtitle: '定時服務', subtitle: '定時服務',
}, },
}, },
agentAssistant: {
title: '智能助手',
assistant: '助手',
ready: '隨時待命',
thinking: '思考中',
newChat: '新會話',
history: '歷史會話',
historyLoading: '正在載入歷史會話...',
historyLoadFailed: '歷史會話載入失敗',
noHistory: '暫無歷史會話',
deleteHistory: '刪除歷史會話',
unknownChannel: '未知渠道',
webAgentChannel: '網頁助手',
untitledSession: '未命名會話',
emptyTitle: '今天想處理什麼?',
emptySubtitle: '站點、訂閱、下載、整理任務,都可以直接問我。',
placeholder: '詢問 MoviePilot...',
stop: '停止生成',
download: '下載',
attachFile: '選擇圖片或文件',
recordVoice: '錄製語音',
stopRecording: '停止錄音({time}',
attachmentMessage: '附件消息',
removeAttachment: '移除附件',
uploadFailed: '附件上傳失敗',
recordUnsupported: '目前瀏覽器不支援錄音',
recordPermissionDenied: '無法存取麥克風,請檢查瀏覽器權限',
recordFailed: '錄音失敗,請重試',
choiceSelected: '已選擇:{option}',
choiceExpired: '該選擇已失效,請重新發起選擇',
error: '智能助手響應失敗',
noStream: '目前瀏覽器無法讀取串流響應',
},
workflow: { workflow: {
components: '動作組件', components: '動作組件',
clickToAdd: '點擊添加', clickToAdd: '點擊添加',
@@ -1164,6 +1193,7 @@ export default {
currentEpisodeNotInLibrary: '本集未入庫', currentEpisodeNotInLibrary: '本集未入庫',
libraryUpdatedAt: '最近更新 {time}', libraryUpdatedAt: '最近更新 {time}',
libraryUpdatedAtShort: '{time}', libraryUpdatedAtShort: '{time}',
expandDayEvents: '展開當天剩餘 {count} 個條目',
}, },
storage: { storage: {
name: '名稱', name: '名稱',
@@ -2957,6 +2987,12 @@ export default {
projectHome: '項目主頁', projectHome: '項目主頁',
updateHistory: '更新說明', updateHistory: '更新說明',
versionHistory: '版本歷史', versionHistory: '版本歷史',
releaseVersionsLoadFailed: 'Release 版本載入失敗',
latestVersion: '最新',
currentVersion: '當前',
installReleaseVersion: '安裝',
confirmInstallOldRelease:
'是否確認安裝 {name} v{version}?該版本缺少主程序兼容元數據,安裝後可能無法載入或運行異常。',
local: '本地', local: '本地',
installToLocal: '安裝到本地', installToLocal: '安裝到本地',
totalDownloads: '共 {count} 次下載', totalDownloads: '共 {count} 次下載',

View File

@@ -894,8 +894,6 @@ onUnmounted(() => {
</template> </template>
<style lang="scss" scoped> <style lang="scss" scoped>
/* stylelint-disable selector-pseudo-class-no-unknown */
@use '@core/scss/pages/page-auth'; @use '@core/scss/pages/page-auth';
/* ===================== 布局根容器 ===================== */ /* ===================== 布局根容器 ===================== */
@@ -910,11 +908,6 @@ onUnmounted(() => {
min-block-size: 100dvh; min-block-size: 100dvh;
} }
/* 登录页需要透出 App.vue 注入的壁纸层。 */
:global(.v-application:has(.login-root)) {
background: transparent !important;
}
/* ===================== 浮动语言切换 ===================== */ /* ===================== 浮动语言切换 ===================== */
.lang-switch-btn { .lang-switch-btn {
position: absolute; position: absolute;

View File

@@ -17,6 +17,12 @@ html.v-overlay-scroll-blocked body {
inset-block-start: var(--v-body-scroll-y); 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 { @mixin hide-scrollbar {
-ms-overflow-style: none; -ms-overflow-style: none;
scrollbar-width: none; scrollbar-width: none;
@@ -922,6 +928,7 @@ html[data-theme="transparent"] .app-card-colorful,
} }
:root { :root {
--agent-assistant-fab-offset: 30rem;
--theme-customizer-fab-offset: 420px; --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 { html[data-theme-customizer-open='true'] .global-action-buttons {
inset-inline-end: calc(var(--theme-customizer-fab-offset) + 2rem); 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) { @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)) 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 { .apexcharts-title-text {

View File

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

View File

@@ -678,23 +678,57 @@ async function saveFolderPluginOrder() {
} }
} }
/** 将插件市场运行时字段转换为可安全比较的文本。 */
function normalizeMarketText(value: unknown) {
if (typeof value === 'string') return value
if (typeof value === 'number' || typeof value === 'boolean') return String(value)
return ''
}
/** 将插件市场逗号分隔字段转换为去重前的文本数组。 */
function splitMarketValues(value: unknown) {
if (Array.isArray(value)) {
return value.map(normalizeMarketText).map(item => item.trim()).filter(Boolean)
}
return normalizeMarketText(value)
.split(',')
.map(item => item.trim())
.filter(Boolean)
}
/** 判断插件是否来源于本地插件仓库。 */
function isLocalRepoSource(item: Plugin | string | undefined) {
if (!item) return false
const repoUrl = typeof item === 'string' ? item : normalizeMarketText(item.repo_url)
return Boolean((typeof item !== 'string' && item.is_local) || repoUrl.startsWith('local://'))
}
/** 解码本地插件仓库路径,避免异常路径中断市场列表加载。 */
function decodeLocalRepoPath(value: string) {
try {
return decodeURIComponent(value)
} catch (error) {
return value
}
}
// 初始化过滤选项 // 初始化过滤选项
function initOptions(item: Plugin) { function initOptions(item: Plugin) {
const optionValue = (options: Array<string>, value: string | undefined, preferred = false) => { const optionValue = (options: Array<string>, value: unknown, preferred = false) => {
if (!value || options.includes(value)) return const text = normalizeMarketText(value).trim()
if (preferred) options.unshift(value) if (!text || options.includes(text)) return
else options.push(value) if (preferred) options.unshift(text)
else options.push(text)
} }
const optionMutipleValue = (options: Array<string>, value: string | undefined) => { const optionMutipleValue = (options: Array<string>, value: unknown) => {
value && value.split(',').forEach(v => !options.includes(v) && options.push(v)) splitMarketValues(value).forEach(v => !options.includes(v) && options.push(v))
} }
optionValue(authorFilterOptions.value, item.plugin_author) optionValue(authorFilterOptions.value, item.plugin_author)
optionMutipleValue(labelFilterOptions.value, item.plugin_label) optionMutipleValue(labelFilterOptions.value, item.plugin_label)
optionValue( optionValue(repoFilterOptions.value, handleRepoUrl(item), isLocalRepoSource(item))
repoFilterOptions.value,
handleRepoUrl(item),
Boolean(item.is_local || item.repo_url?.startsWith('local://')),
)
} }
// 关闭插件市场窗口 // 关闭插件市场窗口
@@ -775,12 +809,13 @@ function closeSearchDialog() {
// 过滤插件 // 过滤插件
const filterPlugins = computed(() => { const filterPlugins = computed(() => {
const all_list = [...dataList.value, ...uninstalledList.value] const all_list = [...dataList.value, ...uninstalledList.value]
const normalizedKeyword = normalizeMarketText(keyword.value).toLowerCase()
return all_list.filter((item: Plugin) => { return all_list.filter((item: Plugin) => {
// 需要忽略大小写 // 需要忽略大小写
return ( return (
item.plugin_name?.toLowerCase().includes(keyword.value.toLowerCase()) || !normalizedKeyword ||
item.plugin_desc?.toLowerCase().includes(keyword.value.toLowerCase()) || normalizeMarketText(item.plugin_name).toLowerCase().includes(normalizedKeyword) ||
!keyword normalizeMarketText(item.plugin_desc).toLowerCase().includes(normalizedKeyword)
) )
}) })
}) })
@@ -818,12 +853,13 @@ async function fetchUninstalledPlugins(force: boolean = false, context: KeepAliv
if (showLoading) { if (showLoading) {
loading.value = true loading.value = true
} }
uninstalledList.value = await api.get('plugin/', { const marketResponse = await api.get('plugin/', {
params: { params: {
state: 'market', state: 'market',
force: force, force: force,
}, },
}) })
uninstalledList.value = Array.isArray(marketResponse) ? marketResponse : []
// 设置更新状态 // 设置更新状态
for (const uninstalled of uninstalledList.value) { for (const uninstalled of uninstalledList.value) {
for (const data of dataList.value) { for (const data of dataList.value) {
@@ -842,6 +878,8 @@ async function fetchUninstalledPlugins(force: boolean = false, context: KeepAliv
// 排除已安装且有更新的,上面的问题在于"本地存在未安装的旧版本插件且云端有更新时"不会在插件市场展示 // 排除已安装且有更新的,上面的问题在于"本地存在未安装的旧版本插件且云端有更新时"不会在插件市场展示
marketList.value = uninstalledList.value.filter(item => !(item.has_update && item.installed)) marketList.value = uninstalledList.value.filter(item => !(item.has_update && item.installed))
// 初始化过滤选项 // 初始化过滤选项
authorFilterOptions.value = []
labelFilterOptions.value = []
repoFilterOptions.value = [] repoFilterOptions.value = []
marketList.value.forEach(initOptions) marketList.value.forEach(initOptions)
// 设置APP市场加载完成 // 设置APP市场加载完成
@@ -876,12 +914,18 @@ async function refreshData(context: KeepAliveRefreshContext = {}) {
// 对uninstalledList进行排序到sortedUninstalledList // 对uninstalledList进行排序到sortedUninstalledList
watch([marketList, filterForm, activeSort, PluginStatistics], () => { watch([marketList, filterForm, activeSort, PluginStatistics], () => {
// 匹配过滤函数 // 匹配过滤函数
const match = (filter: Array<string>, value: string | undefined) => const match = (filter: Array<string>, value: unknown) => {
filter.length === 0 || (value && filter.includes(value)) const text = normalizeMarketText(value).trim()
const matchMultiple = (filter: Array<string>, value: string | undefined) =>
filter.length === 0 || (value && value.split(',').some(v => filter.includes(v))) return filter.length === 0 || (!!text && filter.includes(text))
const filterText = (filter: string, value: string | undefined) => }
!filter || (value && value.toLowerCase().includes(filter.toLowerCase())) const matchMultiple = (filter: Array<string>, value: unknown) =>
filter.length === 0 || splitMarketValues(value).some(v => filter.includes(v))
const filterText = (filter: string, value: unknown) => {
const text = normalizeMarketText(value).toLowerCase()
return !filter || (!!text && text.includes(filter.toLowerCase()))
}
sortedUninstalledList.value = [] sortedUninstalledList.value = []
@@ -889,7 +933,7 @@ watch([marketList, filterForm, activeSort, PluginStatistics], () => {
marketList.value.forEach(value => { marketList.value.forEach(value => {
if (value) { if (value) {
if ( if (
filterText(filterForm.name, `${value.plugin_name} ${value.plugin_desc}`) && filterText(filterForm.name, `${normalizeMarketText(value.plugin_name)} ${normalizeMarketText(value.plugin_desc)}`) &&
match(filterForm.author, value.plugin_author) && match(filterForm.author, value.plugin_author) &&
matchMultiple(filterForm.label, value.plugin_label) && matchMultiple(filterForm.label, value.plugin_label) &&
match(filterForm.repo, handleRepoUrl(value)) match(filterForm.repo, handleRepoUrl(value))
@@ -960,21 +1004,21 @@ async function refreshActiveTabData(context: KeepAliveRefreshContext = {}) {
} }
function parseLocalRepoPath(repoUrl: string | undefined) { function parseLocalRepoPath(repoUrl: string | undefined) {
if (!repoUrl?.startsWith('local://')) return '' const text = normalizeMarketText(repoUrl)
if (!text.startsWith('local://')) return ''
try { try {
return new URL(repoUrl).searchParams.get('path') || '' return new URL(text).searchParams.get('path') || ''
} catch (error) { } catch (error) {
return decodeURIComponent(repoUrl.match(/[?&]path=([^&]+)/)?.[1] || '') return decodeLocalRepoPath(text.match(/[?&]path=([^&]+)/)?.[1] || '')
} }
} }
// 处理掉github地址的前缀 // 处理掉github地址的前缀
function handleRepoUrl(item: Plugin | string | undefined) { function handleRepoUrl(item: Plugin | string | undefined) {
const url = typeof item === 'string' ? item : item?.repo_url const url = typeof item === 'string' ? item : normalizeMarketText(item?.repo_url)
if (!url) return '' if (!url) return ''
if (url.startsWith('local://')) return parseLocalRepoPath(url) || localRepoLabel.value if (isLocalRepoSource(item)) return parseLocalRepoPath(url) || localRepoLabel.value
if (typeof item !== 'string' && item?.is_local) return parseLocalRepoPath(url) || localRepoLabel.value
return url.replace('https://github.com/', '').replace('https://raw.githubusercontent.com/', '') return url.replace('https://github.com/', '').replace('https://raw.githubusercontent.com/', '')
} }

View File

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

View File

@@ -1,5 +1,5 @@
<script lang="ts" setup> <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 dayGridPlugin from '@fullcalendar/daygrid'
import interactionPlugin from '@fullcalendar/interaction' import interactionPlugin from '@fullcalendar/interaction'
import timeGridPlugin from '@fullcalendar/timegrid' import timeGridPlugin from '@fullcalendar/timegrid'
@@ -15,6 +15,10 @@ import { openSharedDialog } from '@/composables/useSharedDialog'
const ProgressDialog = defineAsyncComponent(() => import('@/components/dialog/ProgressDialog.vue')) 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() const { t } = useI18n()
@@ -35,6 +39,7 @@ let progressDialogController: ReturnType<typeof openSharedDialog> | null = null
type CalendarLibraryState = 'none' | 'partial' | 'complete' type CalendarLibraryState = 'none' | 'partial' | 'complete'
interface CalendarEventInfo { interface CalendarEventInfo {
id?: string
title: string title: string
subtitle: string subtitle: string
start: Date | null start: Date | null
@@ -49,6 +54,9 @@ interface CalendarEventInfo {
libraryEpisodeNumbers: number[] libraryEpisodeNumbers: number[]
libraryState: CalendarLibraryState libraryState: CalendarLibraryState
libraryUpdateText: string libraryUpdateText: string
dateKey?: string
hiddenEventCount?: number
calendarSortIndex?: number
} }
// 打开订阅日历共享进度弹窗。 // 打开订阅日历共享进度弹窗。
@@ -85,10 +93,12 @@ const calendarOptions: Ref<CalendarOptions> = ref({
center: 'title', center: 'title',
right: 'next', right: 'next',
}, },
// 日历页需要完整展示每天所有订阅条目,避免折叠成 "+ more" 后隐藏关键信息 // 折叠逻辑由组件自行控制,点击展开时可以直接扩展当前日期格子
dayMaxEvents: false, dayMaxEvents: false,
dayMaxEventRows: false, dayMaxEventRows: false,
eventDisplay: 'block', eventDisplay: 'block',
eventOrder: 'start,calendarSortIndex,title',
eventOrderStrict: true,
views: { views: {
week: { week: {
titleFormat: { day: 'numeric' }, titleFormat: { day: 'numeric' },
@@ -97,6 +107,91 @@ const calendarOptions: Ref<CalendarOptions> = ref({
events: [], 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) { function clampEpisodeCount(value: number, total: number) {
return Math.min(Math.max(value, 0), total) 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 const props = event.extendedProps as CalendarEventInfo
return t('calendar.expandDayEvents', { count: props.hiddenEventCount || 0 })
}
function getCalendarEventInfoTooltip(event: CalendarEventInfo) {
const parts = [event.title] const parts = [event.title]
if (props.subtitle) parts.push(t('calendar.episode', { number: props.subtitle })) if (event.subtitle) parts.push(t('calendar.episode', { number: event.subtitle }))
if (props.totalEpisode) { if (event.totalEpisode) {
parts.push(t('calendar.libraryProgress', { completed: props.libraryEpisode, total: props.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(' · ') return parts.filter(Boolean).join(' · ')
} }
@@ -273,7 +373,8 @@ async function getSubscribes() {
loading.value = false loading.value = false
const subEvents = await Promise.allSettled(subscribes.map(async sub => eventsHander(sub))) const subEvents = await Promise.allSettled(subscribes.map(async sub => eventsHander(sub)))
const succEvents = subEvents.filter(result => result.status === 'fulfilled').map(result => result.value) 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 isLoaded.value = true
} catch (error) { } catch (error) {
console.error(error) console.error(error)
@@ -295,19 +396,24 @@ onActivated(() => {
</script> </script>
<template> <template>
<FullCalendar :options="calendarOptions"> <FullCalendar ref="calendarRef" :options="calendarOptions">
<template #eventContent="arg"> <template #eventContent="arg">
<div v-if="display.lgAndUp.value"> <div
v-if="arg.event.extendedProps.isDayGroup"
class="calendar-day-events"
>
<div <div
v-for="calendarEvent in arg.event.extendedProps.visibleEvents"
:key="`${calendarEvent.title}-${calendarEvent.subtitle}-${calendarEvent.calendarSortIndex}`"
class="calendar-event-card" class="calendar-event-card"
:class="`calendar-event-card--${arg.event.extendedProps.libraryState}`" :class="`calendar-event-card--${calendarEvent.libraryState}`"
:title="getCalendarEventTooltip(arg.event)" :title="getCalendarEventInfoTooltip(calendarEvent)"
> >
<div class="calendar-event-poster"> <div v-if="display.lgAndUp.value" class="calendar-event-poster">
<VImg <VImg
height="74" height="74"
width="50" width="50"
:src="arg.event.extendedProps.posterPath" :src="calendarEvent.posterPath"
aspect-ratio="2/3" aspect-ratio="2/3"
class="calendar-event-image object-cover" class="calendar-event-image object-cover"
cover cover
@@ -319,70 +425,82 @@ onActivated(() => {
</template> </template>
</VImg> </VImg>
<span <span
v-if="arg.event.extendedProps.libraryState === 'complete'" v-if="calendarEvent.libraryState === 'complete'"
class="calendar-library-check" class="calendar-library-check"
> >
<VIcon icon="mdi-check" size="12" /> <VIcon icon="mdi-check" size="12" />
</span> </span>
</div> </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"> <div class="calendar-event-title">
{{ arg.event.title }} {{ calendarEvent.title }}
</div> </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" /> <VIcon icon="mdi-calendar-blank-outline" size="13" />
{{ t('calendar.episode', { number: arg.event.extendedProps.subtitle }) }} {{ t('calendar.episode', { number: calendarEvent.subtitle }) }}
</div> </div>
<div v-if="arg.event.extendedProps.totalEpisode" class="calendar-event-library-row"> <div v-if="calendarEvent.totalEpisode" class="calendar-event-library-row">
<span <span
v-if="arg.event.extendedProps.libraryState !== 'complete'" v-if="calendarEvent.libraryState !== 'complete'"
class="calendar-event-status" 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" /> <VIcon :icon="getLibraryStateIcon(calendarEvent.libraryState)" size="13" />
{{ getLibraryStateText(arg.event.extendedProps.libraryState) }} {{ getLibraryStateText(calendarEvent.libraryState) }}
</span> </span>
<span class="calendar-event-progress"> <span class="calendar-event-progress">
<VIcon icon="mdi-library" size="13" /> <VIcon icon="mdi-library" size="13" />
{{ {{
t('calendar.libraryProgress', { t('calendar.libraryProgress', {
completed: arg.event.extendedProps.libraryEpisode, completed: calendarEvent.libraryEpisode,
total: arg.event.extendedProps.totalEpisode, total: calendarEvent.totalEpisode,
}) })
}} }}
</span> </span>
</div> </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" /> <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> </div>
</div>
<div v-else> <button
<VImg v-if="arg.event.extendedProps.hiddenEventCount"
:src="arg.event.extendedProps.posterPath" type="button"
aspect-ratio="2/3" class="calendar-expand-card"
class="calendar-mobile-image object-cover ring-gray-500" :title="getExpandCalendarEventLabel(arg.event)"
cover :aria-label="getExpandCalendarEventLabel(arg.event)"
:title="getCalendarEventTooltip(arg.event)" @click.stop.prevent="expandCalendarDay(arg.event.extendedProps.dateKey)"
> >
<template #placeholder> <VIcon icon="mdi-unfold-more-horizontal" size="18" />
<div class="w-full h-full"> <span class="calendar-expand-count">+{{ arg.event.extendedProps.hiddenEventCount }}</span>
<VSkeletonLoader class="object-cover aspect-w-2 aspect-h-3" /> </button>
</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>
</div> </div>
</template> </template>
</FullCalendar> </FullCalendar>
@@ -679,6 +797,38 @@ onActivated(() => {
overflow: hidden; 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 { .calendar-event-poster {
position: relative; position: relative;
flex: 0 0 56px; flex: 0 0 56px;
@@ -828,10 +978,27 @@ onActivated(() => {
} }
@media (width <= 1279px) { @media (width <= 1279px) {
.calendar-day-events {
align-items: center;
}
.calendar-event-card,
.fc-daygrid-event-harness { .fc-daygrid-event-harness {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: 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> </style>

View File

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