Compare commits

..

14 Commits

17 changed files with 822 additions and 446 deletions

View File

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

View File

@@ -35,6 +35,23 @@ http {
try_files $uri $uri/ /index.html;
}
location = /service-worker.js {
# Service Worker 必须保持稳定 URL 并每次重新验证,避免前端更新后继续注册旧版本。
expires off;
add_header Cache-Control "no-cache, must-revalidate";
root html;
try_files $uri =404;
}
location = /manifest.webmanifest {
# Web App Manifest 参与 PWA 安装与资源发现,不能跟普通静态资源一起长缓存。
expires off;
default_type application/manifest+json;
add_header Cache-Control "no-cache, must-revalidate";
root html;
try_files $uri =404;
}
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg)$ {
# 静态资源
expires 1y;
@@ -44,8 +61,7 @@ http {
location /assets {
# 静态资源
expires 1y;
add_header Cache-Control "public";
add_header Cache-Control "public, max-age=31536000, immutable";
root html;
}

View File

@@ -167,7 +167,8 @@ md.use(mdLinkAttributes, {
})
const canSend = computed(
() => (inputText.value.trim().length > 0 || pendingAttachments.value.length > 0) && !sending.value && !recording.value,
() =>
(inputText.value.trim().length > 0 || pendingAttachments.value.length > 0) && !sending.value && !recording.value,
)
const canRecord = computed(() => !sending.value && !recording.value)
const recordingTimeText = computed(() => {
@@ -254,7 +255,9 @@ function normalizeServerSession(item: AgentServerSession, withMessages = false):
return {
sessionId: sessionIdValue,
clientSessionId: item.client_session_id,
title: item.title?.trim() || (messages.length ? buildSessionHistoryTitle(messages) : t('agentAssistant.untitledSession')),
title:
item.title?.trim() ||
(messages.length ? buildSessionHistoryTitle(messages) : t('agentAssistant.untitledSession')),
preview: item.preview,
channel: item.channel,
source: item.source,
@@ -320,7 +323,9 @@ async function loadServerHistorySessions() {
try {
const data = await fetchAgentApi(`message/agent/sessions?page=1&count=${HISTORY_PAGE_SIZE}`)
const sessions = Array.isArray(data)
? data.map(item => normalizeServerSession(item as AgentServerSession)).filter(Boolean) as AgentSessionHistoryItem[]
? (data
.map(item => normalizeServerSession(item as AgentServerSession))
.filter(Boolean) as AgentSessionHistoryItem[])
: []
historySessions.value = dedupeHistorySessions(sessions)
historyHasMore.value = sessions.length >= HISTORY_PAGE_SIZE
@@ -344,14 +349,15 @@ async function loadMoreServerHistorySessions(options?: { done?: (status: Infinit
const nextPage = historyPage.value + 1
const data = await fetchAgentApi(`message/agent/sessions?page=${nextPage}&count=${HISTORY_PAGE_SIZE}`)
const sessions = Array.isArray(data)
? data.map(item => normalizeServerSession(item as AgentServerSession)).filter(Boolean) as AgentSessionHistoryItem[]
? (data
.map(item => normalizeServerSession(item as AgentServerSession))
.filter(Boolean) as AgentSessionHistoryItem[])
: []
const existingIds = new Set(historySessions.value.map(item => item.sessionId))
historySessions.value = dedupeHistorySessions([
...historySessions.value,
...sessions.filter(item => !existingIds.has(item.sessionId)),
])
.slice(0, MAX_LOCAL_HISTORY_SESSIONS)
]).slice(0, MAX_LOCAL_HISTORY_SESSIONS)
historyPage.value = nextPage
historyHasMore.value = sessions.length >= HISTORY_PAGE_SIZE
persistHistorySessions()
@@ -377,8 +383,10 @@ async function loadServerHistorySession(targetSessionId: string) {
const session = normalizeServerSession(data as AgentServerSession, true)
if (!session) throw new Error(t('agentAssistant.historyLoadFailed'))
historySessions.value = dedupeHistorySessions([session, ...historySessions.value.filter(item => item.sessionId !== targetSessionId)])
.slice(0, MAX_LOCAL_HISTORY_SESSIONS)
historySessions.value = dedupeHistorySessions([
session,
...historySessions.value.filter(item => item.sessionId !== targetSessionId),
]).slice(0, MAX_LOCAL_HISTORY_SESSIONS)
persistHistorySessions()
return session
@@ -483,8 +491,10 @@ function upsertCurrentSessionHistory() {
messages: storedMessages,
}
historySessions.value = dedupeHistorySessions([nextSession, ...historySessions.value.filter(item => item.sessionId !== sessionId.value)])
.slice(0, MAX_LOCAL_HISTORY_SESSIONS)
historySessions.value = dedupeHistorySessions([
nextSession,
...historySessions.value.filter(item => item.sessionId !== sessionId.value),
]).slice(0, MAX_LOCAL_HISTORY_SESSIONS)
persistHistorySessions()
}
@@ -1317,11 +1327,7 @@ onScopeDispose(() => {
class="agent-assistant-history-infinite"
@load="handleHistoryInfiniteLoad"
>
<VVirtualScroll
renderless
:items="historySessions"
:item-height="HISTORY_ITEM_HEIGHT"
>
<VVirtualScroll renderless :items="historySessions" :item-height="HISTORY_ITEM_HEIGHT">
<template #default="{ item: historySession, itemRef }">
<button
:ref="itemRef"
@@ -1591,10 +1597,14 @@ onScopeDispose(() => {
:class="{ 'is-recording': recording }"
:disabled="!recording && !canRecord"
:title="
recording ? t('agentAssistant.stopRecording', { time: recordingTimeText }) : t('agentAssistant.recordVoice')
recording
? t('agentAssistant.stopRecording', { time: recordingTimeText })
: t('agentAssistant.recordVoice')
"
:aria-label="
recording ? t('agentAssistant.stopRecording', { time: recordingTimeText }) : t('agentAssistant.recordVoice')
recording
? t('agentAssistant.stopRecording', { time: recordingTimeText })
: t('agentAssistant.recordVoice')
"
@click="toggleVoiceRecording"
>
@@ -1638,6 +1648,7 @@ onScopeDispose(() => {
box-shadow: var(--app-surface-shadow);
color: rgb(var(--v-theme-primary));
inline-size: 2.8rem;
/* 入口避开屏幕正中,放到视觉上更轻的下三分之一位置。 */
inset-block-start: clamp(8rem, 66.666vh, calc(100vh - 8rem));
inset-inline-end: 0;
@@ -1756,8 +1767,8 @@ onScopeDispose(() => {
.agent-assistant-history-list {
block-size: min(26rem, calc(100vh - 7rem));
max-block-size: min(26rem, calc(100vh - 7rem));
overscroll-behavior: contain;
overflow-y: auto;
overscroll-behavior: contain;
padding-block: 0.35rem;
}
@@ -1779,6 +1790,7 @@ onScopeDispose(() => {
}
.agent-assistant-history-infinite {
gap: 0.25rem;
min-block-size: 100%;
}
@@ -1807,9 +1819,9 @@ onScopeDispose(() => {
column-gap: 0.4rem;
cursor: pointer;
grid-template-columns: minmax(0, 1fr) auto;
min-block-size: 4.75rem;
inline-size: calc(100% - 0.7rem);
margin-inline: 0.35rem;
min-block-size: 4.75rem;
padding-block: 0.55rem;
padding-inline: 0.65rem 0.25rem;
text-align: start;
@@ -1864,8 +1876,8 @@ onScopeDispose(() => {
}
.agent-assistant-messages {
box-sizing: border-box;
display: flex;
box-sizing: border-box;
flex-direction: column;
min-block-size: 0;
overflow-y: auto;

View File

@@ -1,6 +1,7 @@
<script setup lang="ts">
import {
themeCustomizerPrimaryColors,
themeCustomizerShadowLevels,
useThemeCustomizer,
type ThemeCustomizerLayout,
type ThemeCustomizerRadius,
@@ -86,62 +87,35 @@ const skinOptions = computed<Array<{ title: string; value: ThemeCustomizerSkin }
{ title: t('theme.customizer.skinBordered'), value: 'bordered' },
])
const shadowOptions = computed<
Array<{
title: string
value: ThemeCustomizerShadow
}>
>(() => [
{
title: t('theme.customizer.shadowNone'),
value: 'none',
},
{
title: t('theme.customizer.shadowLow'),
value: 'low',
},
{
title: t('theme.customizer.shadowMedium'),
value: 'medium',
},
{
title: t('theme.customizer.shadowHigh'),
value: 'high',
},
])
// 当前阴影滑杆数值,界面使用 number主题设置继续存储 Vuetify elevation 字符串档位。
const shadowSliderValue = computed(() => Number(settings.value.shadow))
const radiusOptions = computed<
Array<{
previewRadius: string
title: string
value: ThemeCustomizerRadius
}>
>(() => [
{
previewRadius: '4px',
title: t('theme.customizer.radiusNone'),
value: 'none',
},
{
title: t('theme.customizer.radiusSmall'),
value: 'small',
},
{
previewRadius: '8px',
title: t('theme.customizer.radiusDefault'),
value: 'default',
},
{
previewRadius: '12px',
title: t('theme.customizer.radiusLarge'),
value: 'large',
},
{
previewRadius: '16px',
title: t('theme.customizer.radiusExtra'),
value: 'extra',
},
{
previewRadius: '24px',
title: t('theme.customizer.radiusHuge'),
value: 'huge',
},
])
const layoutOptions = computed<Array<{ icon: string; title: string; value: ThemeCustomizerLayout }>>(() => [
@@ -156,7 +130,7 @@ const hasAppModeCustomization = computed(() => {
return (
settings.value.primaryColor !== defaultPrimaryColor ||
settings.value.radius !== 'default' ||
settings.value.shadow !== 'none' ||
settings.value.shadow !== '0' ||
settings.value.skin !== 'default' ||
settings.value.theme !== 'auto'
)
@@ -189,6 +163,19 @@ function handleLayoutChange(layout: ThemeCustomizerLayout) {
setLayout(layout)
}
// 将 Vuetify 滑杆的数字步进写回字符串型 elevation 档位。
function handleShadowSliderChange(value: unknown) {
const rawValue = Array.isArray(value) ? value[0] : value
const numericValue = Number(rawValue)
if (!Number.isFinite(numericValue)) return
const clampedValue = Math.min(24, Math.max(0, Math.round(numericValue)))
const shadow = String(clampedValue) as ThemeCustomizerShadow
if (themeCustomizerShadowLevels.includes(shadow)) setShadow(shadow)
}
async function handleResetSettings() {
if (!appMode.value) {
await resetSettings()
@@ -199,7 +186,7 @@ async function handleResetSettings() {
// App 模式共享定制器,但保留桌面导航相关偏好,只重置 App 侧可调整的外观设置。
await setPrimaryColor(defaultPrimaryColor)
await setRadius('default')
await setShadow('none')
await setShadow('0')
await setSkin('default')
await setTheme('auto')
}
@@ -320,7 +307,7 @@ async function handleResetSettings() {
>
<span
class="theme-customizer-radius-scene"
:style="{ '--theme-customizer-radius-preview': radius.previewRadius }"
:class="`theme-customizer-radius-scene--${radius.value}`"
>
<span class="theme-customizer-radius-scene__card">
<span class="theme-customizer-radius-scene__badge" />
@@ -335,29 +322,41 @@ async function handleResetSettings() {
<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>
<div class="theme-customizer-shadow-slider">
<div class="theme-customizer-shadow-slider__header">
<span>{{ t('theme.customizer.shadowLevel', { level: settings.shadow }) }}</span>
<span>0 - 24</span>
</div>
<div class="theme-customizer-shadow-slider__control">
<span
class="theme-customizer-shadow-slider__sample"
:style="{ boxShadow: `var(--app-elevation-${settings.shadow})` }"
>
<span class="theme-customizer-shadow-slider__sample-accent" />
<span class="theme-customizer-shadow-slider__sample-line" />
<span class="theme-customizer-shadow-slider__sample-line theme-customizer-shadow-slider__sample-line--short" />
</span>
<span>{{ shadow.title }}</span>
<VSlider
:model-value="shadowSliderValue"
:aria-label="t('theme.customizer.shadow')"
:max="24"
:min="0"
:step="1"
color="primary"
density="comfortable"
hide-details
show-ticks="always"
thumb-label
tick-size="2"
@update:model-value="handleShadowSliderChange"
/>
</div>
<div
class="theme-customizer-shadow-slider__scale"
aria-hidden="true"
>
<span>0</span>
<span>24</span>
</div>
</div>
@@ -626,10 +625,6 @@ async function handleResetSettings() {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.theme-customizer-preview-grid--shadow {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.theme-customizer-preview-grid--radius {
grid-template-columns: repeat(auto-fit, minmax(92px, 1fr));
}
@@ -646,8 +641,7 @@ async function handleResetSettings() {
box-shadow: none !important;
.theme-customizer-mini-layout,
.theme-customizer-radius-scene,
.theme-customizer-shadow-scene {
.theme-customizer-radius-scene {
border-width: 2px;
border-color: rgb(var(--v-theme-primary));
background: rgba(var(--v-theme-primary), 0.04);
@@ -747,6 +741,24 @@ async function handleResetSettings() {
block-size: 90px;
inline-size: 100%;
min-inline-size: 0;
--theme-customizer-preview-radius: var(--app-vuetify-rounded);
}
.theme-customizer-radius-scene--none {
--theme-customizer-preview-radius: var(--app-vuetify-rounded-0);
}
.theme-customizer-radius-scene--small {
--theme-customizer-preview-radius: var(--app-vuetify-rounded-sm);
}
.theme-customizer-radius-scene--large {
--theme-customizer-preview-radius: var(--app-vuetify-rounded-lg);
}
.theme-customizer-radius-scene--extra {
--theme-customizer-preview-radius: var(--app-vuetify-rounded-xl);
}
.theme-customizer-radius-scene__card {
@@ -754,7 +766,7 @@ async function handleResetSettings() {
display: flex;
flex-direction: column;
border: 1px solid rgba(var(--v-theme-on-surface), 0.08);
border-radius: var(--theme-customizer-radius-preview);
border-radius: var(--theme-customizer-preview-radius);
background: rgb(var(--v-theme-surface));
gap: 8px;
inset: 16px;
@@ -765,17 +777,18 @@ async function handleResetSettings() {
.theme-customizer-radius-scene__badge,
.theme-customizer-radius-scene__line {
display: block;
border-radius: 999px;
background: rgba(var(--v-theme-on-surface), 0.1);
}
.theme-customizer-radius-scene__badge {
border-radius: var(--theme-customizer-preview-radius);
block-size: 8px;
inline-size: 42%;
min-inline-size: 28px;
}
.theme-customizer-radius-scene__line {
border-radius: var(--theme-customizer-preview-radius);
block-size: 7px;
}
@@ -783,112 +796,89 @@ async function handleResetSettings() {
inline-size: 66%;
}
.theme-customizer-shadow-scene {
position: relative;
display: block;
overflow: hidden;
.theme-customizer-shadow-slider {
padding: 16px 18px 12px;
border: 1px solid rgba(var(--v-theme-on-surface), 0.12);
border-radius: 10px;
background:
linear-gradient(180deg, rgba(var(--v-theme-on-surface), 0.02), rgba(var(--v-theme-on-surface), 0.06)),
rgb(var(--v-theme-surface));
block-size: 110px;
inline-size: 100%;
min-inline-size: 0;
border-radius: var(--app-vuetify-rounded-lg);
background: rgba(var(--v-theme-surface), 0.72);
}
.theme-customizer-shadow-scene__panel,
.theme-customizer-shadow-scene__card {
position: absolute;
.theme-customizer-shadow-slider__header,
.theme-customizer-shadow-slider__scale {
display: flex;
align-items: center;
justify-content: space-between;
}
.theme-customizer-shadow-slider__header {
color: rgba(var(--v-theme-on-surface), var(--v-medium-emphasis-opacity));
font-size: 0.875rem;
line-height: 1.3;
margin-block-end: 14px;
> span:first-child {
color: rgba(var(--v-theme-on-surface), var(--v-high-emphasis-opacity));
font-weight: 600;
}
}
.theme-customizer-shadow-slider__control {
display: grid;
align-items: center;
gap: 16px;
grid-template-columns: 56px minmax(0, 1fr);
}
.theme-customizer-shadow-slider__sample {
display: flex;
flex-direction: column;
justify-content: center;
border-radius: var(--app-vuetify-rounded);
border: 1px solid rgba(var(--v-theme-on-surface), 0.08);
background: rgb(var(--v-theme-surface));
box-shadow: none;
transition: box-shadow 0.18s ease;
block-size: 42px;
gap: 5px;
inline-size: 42px;
padding-block: 8px;
padding-inline: 9px;
}
.theme-customizer-shadow-scene__panel {
padding: 12px;
gap: 8px;
inset-block-start: 16px;
inset-inline: 14px;
min-block-size: 54px;
}
.theme-customizer-shadow-scene__card {
gap: 8px;
inset-block-end: 12px;
inset-inline: 20px 16px;
min-block-size: 46px;
padding-block: 10px;
padding-inline: 12px;
}
.theme-customizer-shadow-scene__panel-line,
.theme-customizer-shadow-scene__line,
.theme-customizer-shadow-scene__badge {
.theme-customizer-shadow-slider__sample-accent,
.theme-customizer-shadow-slider__sample-line {
display: block;
border-radius: 999px;
background: rgba(var(--v-theme-on-surface), 0.1);
}
.theme-customizer-shadow-scene__badge {
block-size: 6px;
inline-size: 34%;
min-inline-size: 28px;
.theme-customizer-shadow-slider__sample-accent {
background: rgba(var(--v-theme-primary), 0.48);
block-size: 5px;
inline-size: 44%;
}
.theme-customizer-shadow-scene__panel-line,
.theme-customizer-shadow-scene__line {
block-size: 7px;
.theme-customizer-shadow-slider__sample-line {
background: rgba(var(--v-theme-on-surface), 0.12);
block-size: 4px;
inline-size: 100%;
}
.theme-customizer-shadow-scene__panel-line--short,
.theme-customizer-shadow-scene__line--short {
inline-size: 62%;
.theme-customizer-shadow-slider__sample-line--short {
inline-size: 68%;
}
.theme-customizer-shadow-scene--low {
.theme-customizer-shadow-scene__panel {
box-shadow:
0 8px 18px rgba(var(--v-theme-on-surface), 0.08),
0 2px 6px rgba(var(--v-theme-on-surface), 0.05);
}
.theme-customizer-shadow-scene__card {
box-shadow:
0 10px 22px rgba(var(--v-theme-on-surface), 0.1),
0 4px 10px rgba(var(--v-theme-on-surface), 0.06);
}
.theme-customizer-shadow-slider__scale {
color: rgba(var(--v-theme-on-surface), var(--v-medium-emphasis-opacity));
font-size: 0.75rem;
line-height: 1;
margin-block-start: 2px;
margin-inline-start: 72px;
}
.theme-customizer-shadow-scene--medium {
.theme-customizer-shadow-scene__panel {
box-shadow:
0 12px 28px rgba(var(--v-theme-on-surface), 0.12),
0 4px 12px rgba(var(--v-theme-on-surface), 0.08);
}
.theme-customizer-shadow-scene__card {
box-shadow:
0 16px 34px rgba(var(--v-theme-on-surface), 0.14),
0 6px 16px rgba(var(--v-theme-on-surface), 0.09);
}
.theme-customizer-shadow-slider :deep(.v-slider.v-input) {
margin: 0;
}
.theme-customizer-shadow-scene--high {
.theme-customizer-shadow-scene__panel {
box-shadow:
0 16px 38px rgba(var(--v-theme-on-surface), 0.16),
0 6px 18px rgba(var(--v-theme-on-surface), 0.1);
}
.theme-customizer-shadow-scene__card {
box-shadow:
0 22px 48px rgba(var(--v-theme-on-surface), 0.18),
0 8px 22px rgba(var(--v-theme-on-surface), 0.12);
}
.theme-customizer-shadow-slider :deep(.v-slider-track__tick) {
opacity: 0.5;
}
@media (width <= 600px) {

View File

@@ -493,14 +493,14 @@ onBeforeUnmount(() => {
<template>
<VHover>
<template #default="hover">
<div ref="mediaCardRef">
<!-- Hover 命中区域保持静止避免卡片上浮后底边反复触发 mouseleave -->
<div ref="mediaCardRef" v-bind="hover.props" class="media-card-hover-area">
<VCard
v-bind="hover.props"
:height="props.height"
:width="props.width"
class="outline-none ring-gray-500 media-card"
:class="{
'transition transform-cpu duration-300 -translate-y-1': hover.isHovering,
'transition transform-cpu duration-300 -translate-y-1': hover.isHovering,
'ring-1': isImageLoaded,
}"
@click.stop="goMediaDetail(hover.isHovering ?? false)"
@@ -591,6 +591,10 @@ onBeforeUnmount(() => {
</VHover>
</template>
<style scoped>
.media-card-hover-area {
width: 100%;
}
.media-card-title {
font-size: 1.125rem;
line-height: 1.25rem;

View File

@@ -228,21 +228,19 @@ onUnmounted(() => {
:text="props.plugin?.system_version_message || t('plugin.incompatibleSystemVersion')"
/>
<div class="plugin-market-detail-actions">
<VBtn
variant="tonal"
@click="showUpdateHistory"
prepend-icon="mdi-update"
>
{{ t('plugin.versionHistory') }}
</VBtn>
<VBtn
color="primary"
@click="installPlugin()"
prepend-icon="mdi-download"
:disabled="props.plugin?.system_version_compatible === false"
>
{{ t('plugin.installToLocal') }}
</VBtn>
<div class="plugin-market-detail-actions__buttons">
<VBtn
color="primary"
@click="installPlugin()"
prepend-icon="mdi-download"
:disabled="props.plugin?.system_version_compatible === false"
>
{{ t('plugin.installToLocal') }}
</VBtn>
<VBtn variant="tonal" @click="showUpdateHistory" prepend-icon="mdi-update">
{{ t('plugin.versionHistory') }}
</VBtn>
</div>
<div class="plugin-market-detail-actions__downloads" v-if="props.count">
<VIcon icon="mdi-fire" />
{{ t('plugin.totalDownloads', { count: formatDownloadCount(props.count) }) }}
@@ -261,9 +259,17 @@ onUnmounted(() => {
.plugin-market-detail-actions {
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
align-items: center;
justify-content: center;
gap: 0.75rem;
}
.plugin-market-detail-actions__buttons {
/* 窄屏换行时用统一 gap 控制按钮间距,避免第二个按钮带左边距导致视觉偏移。 */
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 0.5rem;
}
.plugin-market-detail-actions__downloads {
@@ -273,11 +279,15 @@ onUnmounted(() => {
text-align: center;
}
@media (min-width: 960px) {
@media (width >= 960px) {
.plugin-market-detail-actions {
justify-content: flex-start;
}
.plugin-market-detail-actions__buttons {
justify-content: flex-start;
}
.plugin-market-detail-actions__downloads {
text-align: start;
}

View File

@@ -18,7 +18,6 @@ import { useBackground } from '@/composables/useBackground'
import MediaIdSelector from '../misc/MediaIdSelector.vue'
import ProgressDialog from './ProgressDialog.vue'
import { useI18n } from 'vue-i18n'
import { nextTick } from 'vue'
import { useDisplay } from 'vuetify'
import { useGlobalSettingsStore } from '@/stores'
@@ -150,13 +149,7 @@ const normalizedItems = computed(() => dedupeFileItems(props.items))
// 分页
const previewPage = ref(1)
const previewPageSize = ref(10)
// 预览列表主体元素
const previewFileBodyRef = ref<HTMLElement>()
// 预览列表尺寸观察器
let previewFileBodyResizeObserver: ResizeObserver | undefined
const previewPageSize = ref(20)
// 所有存储
const storages = ref<StorageConf[]>([])
@@ -419,9 +412,39 @@ watch(
},
)
// 过滤后的预览数据
// 过滤并排序后的预览数据
const filteredPreviewItems = computed(() => {
return previewData.value?.items ?? []
const items = [...(previewData.value?.items ?? [])]
return items.sort((a, b) => {
// 1. 获取季号(如果有的话优先按季号排)
const seasonA = getPreviewSeasonNumber(a)
const seasonB = getPreviewSeasonNumber(b)
if (seasonA !== seasonB) {
if (seasonA === undefined) return 1
if (seasonB === undefined) return -1
return seasonA - seasonB
}
// 2. 获取集数
const epA = toPreviewNumber(a.episode)
const epB = toPreviewNumber(b.episode)
// 如果都有集数,按集数排序
if (epA !== undefined && epB !== undefined) {
if (epA !== epB) return epA - epB
// 集数相同(可能是同集的视频、字幕等),退化到按文件名排序,保证相关文件挨在一起
}
// 3. 有集数的排前面,没集数的(通常是其他文件)排后面
if (epA !== undefined && epB === undefined) return -1
if (epA === undefined && epB !== undefined) return 1
// 4. 如果都没集数,或者集数完全相同,则按照目标路径(或源路径)的字母顺序排
const nameA = a.target || a.source || ''
const nameB = b.target || b.source || ''
return nameA.localeCompare(nameB, undefined, { numeric: true })
})
})
// 分页后的预览数据(含文件名解析)
@@ -1110,7 +1133,6 @@ async function previewTransfer() {
previewData.value = mergedPreviewData
previewLoaded.value = true
nextTick(() => updatePreviewPageSize())
if (previewHasFailures(mergedPreviewData)) {
$toast.warning(getPreviewResultSummaryMessage(mergedPreviewData))
@@ -1137,45 +1159,6 @@ async function togglePreview() {
await previewTransfer()
}
// 根据可用高度自动计算每页条数,保持统一行高
function updatePreviewPageSize() {
const bodyHeight = previewFileBodyRef.value?.clientHeight ?? 0
if (bodyHeight <= 0) return
const firstRow = previewFileBodyRef.value?.querySelector('.preview-file-row')
const rowHeight = firstRow?.getBoundingClientRect().height ?? 46
const pageSize = Math.max(1, Math.floor(bodyHeight / rowHeight))
previewPageSize.value = pageSize
const totalPages = Math.max(1, Math.ceil(filteredPreviewItems.value.length / pageSize))
if (previewPage.value > totalPages) {
previewPage.value = totalPages
}
}
// 启动预览列表高度监听
function setupPreviewFileBodyObserver() {
previewFileBodyResizeObserver?.disconnect()
if (!previewFileBodyRef.value || typeof ResizeObserver === 'undefined') return
previewFileBodyResizeObserver = new ResizeObserver(() => {
updatePreviewPageSize()
})
previewFileBodyResizeObserver.observe(previewFileBodyRef.value)
}
watch([() => previewLoaded.value, () => previewVisible.value], ([loaded, visible]) => {
if (loaded && visible) {
nextTick(() => {
setupPreviewFileBodyObserver()
updatePreviewPageSize()
})
} else {
previewFileBodyResizeObserver?.disconnect()
}
})
// 整理文件
async function handleTransfer(item: FileItem, background: boolean = false) {
try {
@@ -1303,7 +1286,6 @@ onMounted(async () => {
onUnmounted(() => {
stopLoadingProgress()
if (episodeGroupQueryTimer) clearTimeout(episodeGroupQueryTimer)
previewFileBodyResizeObserver?.disconnect()
})
</script>
@@ -1671,7 +1653,7 @@ onUnmounted(() => {
</div>
</div>
<div class="reorganize-preview-list">
<div v-if="pagedPreviewRows.length" ref="previewFileBodyRef" class="preview-file-body">
<div v-if="pagedPreviewRows.length" class="preview-file-body">
<div
v-for="(item, index) in pagedPreviewRows"
:key="`${item.source}-${item.target}-${index}`"
@@ -1894,6 +1876,8 @@ onUnmounted(() => {
.preview-overview-card {
display: flex;
flex-direction: column;
border: 1px solid rgba(var(--v-border-color), var(--v-border-opacity));
border-radius: 0.5rem;
gap: 0.375rem;
min-inline-size: 0;
padding-block: 0.875rem;
@@ -1919,6 +1903,8 @@ onUnmounted(() => {
.preview-custom-words {
display: flex;
flex-direction: column;
border: 1px solid rgba(var(--v-border-color), var(--v-border-opacity));
border-radius: 0.5rem;
gap: 0.75rem;
padding-block: 0.875rem;
padding-inline: 1rem;
@@ -1970,8 +1956,12 @@ onUnmounted(() => {
}
.preview-custom-words__chip {
block-size: auto !important;
max-inline-size: 100%;
min-block-size: 1.5rem;
padding-block: 0.25rem;
white-space: normal;
word-break: break-all;
}
.reorganize-preview-pane__scroll {
@@ -2011,9 +2001,9 @@ onUnmounted(() => {
flex: 0 0 auto;
flex-direction: column;
margin-block-end: 1.5rem;
margin-inline: 1.5rem;
min-block-size: 0;
min-inline-size: 0;
padding-inline: 1.5rem;
}
.preview-file-body {
@@ -2024,13 +2014,13 @@ onUnmounted(() => {
gap: 0.75rem;
min-block-size: 0;
min-inline-size: 0;
padding-block: 1rem;
padding-inline: 1rem;
}
.preview-file-row {
display: grid;
align-items: center;
border: 1px solid rgba(var(--v-border-color), var(--v-border-opacity));
border-radius: 0.5rem;
gap: 0.875rem;
grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr);
min-block-size: 5.25rem;
@@ -2039,10 +2029,6 @@ onUnmounted(() => {
padding-inline: 1rem;
}
.preview-file-row + .preview-file-row {
border-block-start: 1px solid rgba(var(--v-border-color), var(--v-border-opacity));
}
.preview-file-row--failed {
background: rgba(var(--v-theme-error), 0.04);
}
@@ -2168,7 +2154,7 @@ onUnmounted(() => {
.reorganize-preview-list {
margin-block-end: 1rem;
margin-inline: 1rem;
padding-inline: 1rem;
}
}

View File

@@ -24,9 +24,37 @@ export const themeCustomizerPrimaryColors = [
{ name: 'Slate', value: '#607D8B' },
] as const
export const themeCustomizerShadowLevels = [
'0',
'1',
'2',
'3',
'4',
'5',
'6',
'7',
'8',
'9',
'10',
'11',
'12',
'13',
'14',
'15',
'16',
'17',
'18',
'19',
'20',
'21',
'22',
'23',
'24',
] as const
export type ThemeCustomizerLayout = 'collapsed' | 'horizontal' | 'vertical'
export type ThemeCustomizerRadius = 'default' | 'extra' | 'huge' | 'large' | 'small'
export type ThemeCustomizerShadow = 'none' | 'low' | 'medium' | 'high'
export type ThemeCustomizerRadius = 'default' | 'extra' | 'large' | 'none' | 'small'
export type ThemeCustomizerShadow = (typeof themeCustomizerShadowLevels)[number]
export type ThemeCustomizerSkin = 'bordered' | 'default'
export type ThemeCustomizerTheme = 'auto' | 'dark' | 'light' | 'purple' | 'transparent'
@@ -44,10 +72,16 @@ type VuetifyThemeApi = ReturnType<typeof useTheme>
const defaultPrimaryColor = themeCustomizerPrimaryColors[0].value
const validLayouts: ThemeCustomizerLayout[] = ['vertical', 'collapsed', 'horizontal']
const validRadii: ThemeCustomizerRadius[] = ['small', 'default', 'large', 'extra', 'huge']
const validShadows: ThemeCustomizerShadow[] = ['none', 'low', 'medium', 'high']
const validRadii: ThemeCustomizerRadius[] = ['none', 'small', 'default', 'large', 'extra']
const validShadows: readonly ThemeCustomizerShadow[] = themeCustomizerShadowLevels
const validSkins: ThemeCustomizerSkin[] = ['default', 'bordered']
const validThemes: ThemeCustomizerTheme[] = ['auto', 'light', 'dark', 'purple', 'transparent']
const legacyShadowMap: Record<string, ThemeCustomizerShadow> = {
high: '24',
low: '6',
medium: '12',
none: '0',
}
let themeApplyVersion = 0
@@ -73,27 +107,35 @@ function getDefaultThemeCustomizerSettings(): ThemeCustomizerSettings {
primaryColor: defaultPrimaryColor,
radius: 'default',
semiDarkMenu: false,
shadow: 'none',
shadow: '0',
skin: 'default',
theme: readStoredThemePreference(),
}
}
/** 将旧版语义阴影档位迁移到 Vuetify elevation 数值档位。 */
function normalizeThemeCustomizerShadow(shadow: unknown): ThemeCustomizerShadow {
if (validShadows.includes(shadow as ThemeCustomizerShadow)) return shadow as ThemeCustomizerShadow
if (typeof shadow === 'string' && legacyShadowMap[shadow]) return legacyShadowMap[shadow]
return getDefaultThemeCustomizerSettings().shadow
}
function normalizeThemeCustomizerSettings(settings: Partial<ThemeCustomizerSettings>): ThemeCustomizerSettings {
const fallback = getDefaultThemeCustomizerSettings()
const storedRadius = settings.radius as string | undefined
const radius = storedRadius === 'huge' ? 'extra' : storedRadius
return {
layout: validLayouts.includes(settings.layout as ThemeCustomizerLayout)
? (settings.layout as ThemeCustomizerLayout)
: fallback.layout,
primaryColor: isHexColor(settings.primaryColor) ? settings.primaryColor.toUpperCase() : fallback.primaryColor,
radius: validRadii.includes(settings.radius as ThemeCustomizerRadius)
? (settings.radius as ThemeCustomizerRadius)
radius: validRadii.includes(radius as ThemeCustomizerRadius)
? (radius as ThemeCustomizerRadius)
: fallback.radius,
semiDarkMenu: typeof settings.semiDarkMenu === 'boolean' ? settings.semiDarkMenu : fallback.semiDarkMenu,
shadow: validShadows.includes(settings.shadow as ThemeCustomizerShadow)
? (settings.shadow as ThemeCustomizerShadow)
: fallback.shadow,
shadow: normalizeThemeCustomizerShadow(settings.shadow),
skin: validSkins.includes(settings.skin as ThemeCustomizerSkin)
? (settings.skin as ThemeCustomizerSkin)
: fallback.skin,
@@ -247,7 +289,7 @@ export function isDefaultThemeCustomizerSettings(settings: ThemeCustomizerSettin
primaryColor: defaultPrimaryColor,
radius: 'default',
semiDarkMenu: false,
shadow: 'none',
shadow: '0',
skin: 'default',
theme: 'auto',
})
@@ -324,7 +366,7 @@ export function useThemeCustomizer() {
primaryColor: defaultPrimaryColor,
radius: 'default',
semiDarkMenu: false,
shadow: 'none',
shadow: '0',
skin: 'default',
theme: 'auto',
})

View File

@@ -55,7 +55,9 @@ const globalSettingsStore = useGlobalSettingsStore()
// 获取用户权限信息
const userPermissions = computed(() => buildUserPermissionContext(userStore.superUser, userStore.permissions))
const canAdmin = computed(() => hasPermission(userPermissions.value, 'admin'))
const showAgentAssistant = computed(() => globalSettingsStore.get('AI_AGENT_ENABLE') === true)
const showAgentAssistant = computed(
() => globalSettingsStore.get('AI_AGENT_ENABLE') === true && globalSettingsStore.get('AI_AGENT_HIDE_ENTRY') !== true,
)
// 开始菜单项
const startMenus = ref<NavMenu[]>([])

View File

@@ -223,7 +223,7 @@ function handleDynamicMenuItemClick(item: DynamicButtonMenuItem) {
<VCardText class="footer-card-content">
<!-- 添加指示器 -->
<div ref="indicator" class="nav-indicator"></div>
<VBtnToggle class="footer-btn-group" :mandatory="true" v-model="currentMenu">
<VBtnToggle class="footer-btn-group" :mandatory="true" variant="plain" v-model="currentMenu">
<!-- 遍历底部菜单项 -->
<VBtn
v-for="menu in footerMenus"
@@ -343,6 +343,9 @@ function handleDynamicMenuItemClick(item: DynamicButtonMenuItem) {
transition: all 0.5s cubic-bezier(0.25, 1, 0.5, 1);
will-change: transform, max-inline-size, opacity;
--app-control-radius: var(--app-vuetify-rounded-pill);
--app-surface-radius: var(--app-vuetify-rounded-pill);
// 透明主题下的特殊样式
.v-theme--transparent & {
backdrop-filter: blur(var(--transparent-blur-heavy, 16px));
@@ -361,13 +364,19 @@ function handleDynamicMenuItemClick(item: DynamicButtonMenuItem) {
padding-inline: 6px;
}
.footer-btn-group {
.footer-nav-card .footer-btn-group.v-btn-group {
position: relative;
display: flex;
justify-content: space-around;
border: none;
border-radius: 9999px !important;
background-color: transparent;
box-shadow: none !important;
inline-size: 100%;
&:hover {
box-shadow: none !important;
}
}
.footer-nav-btn {
@@ -377,12 +386,15 @@ function handleDynamicMenuItemClick(item: DynamicButtonMenuItem) {
flex-grow: 1;
align-items: center;
justify-content: center;
border-radius: 9999px !important;
background-color: transparent;
block-size: 48px;
box-shadow: none !important;
&:hover,
&.v-btn--active {
background-color: transparent;
box-shadow: none;
box-shadow: none !important;
}
.btn-content {

View File

@@ -4,26 +4,55 @@ import api from '@/api'
import { clearUnreadMessages } from '@/utils/badge'
import { formatDateDifference } from '@core/utils/formatters'
import { useBackground } from '@/composables/useBackground'
import { useToast } from 'vue-toastification'
import { useI18n } from 'vue-i18n'
import { useConfirm } from '@/composables/useConfirm'
type NotificationDisplayItem =
| { kind: 'section'; key: string; title: string; count: number }
| { kind: 'notification'; key: string; notification: SystemNotification }
const { t } = useI18n()
const { useDelayedSSE } = useBackground()
const $toast = useToast()
const createConfirm = useConfirm()
const PAGE_SIZE = 20
// 固定通知项高度,配合 VVirtualScroll 避免历史通知过多时一次性渲染全部 DOM
const NOTIFICATION_ITEM_HEIGHT = 104
const MEDIA_NOTIFICATION_TYPES = ['资源下载', '整理入库', '订阅', '媒体服务器', '手动处理']
// 虚拟滚动的默认通知项高度,展开后的实际高度由 VVirtualScroll 的 itemRef 动态测量
const NOTIFICATION_ITEM_HEIGHT = 136
const CLEAR_NOTIFICATION_ENDPOINTS = ['message/notification', 'message/notification/clear']
const NOTIFICATION_CLEAR_BEFORE_STORAGE_KEY = 'moviepilot-notification-clear-before'
const appsMenu = ref(false)
const hasNewMessage = ref(false)
const notificationList = ref<SystemNotification[]>([])
const page = ref(1)
const loading = ref(false)
const clearing = ref(false)
const hasMore = ref(true)
const notificationKeys = new Set<string>()
const notificationClearBefore = ref(readNotificationClearBefore())
const expandedNotificationKeys = ref(new Set<string>())
const hasUnreadNotifications = computed(() => notificationList.value.some(item => item.read === false))
const notificationDisplayList = computed(() => buildNotificationDisplayList(notificationList.value))
/** 从本地存储读取通知清理时间戳,用于过滤已清理的历史通知。 */
function readNotificationClearBefore() {
if (typeof localStorage === 'undefined') return 0
return Number(localStorage.getItem(NOTIFICATION_CLEAR_BEFORE_STORAGE_KEY) || 0)
}
/** 写入通知清理时间戳,使清理结果在刷新后仍然生效。 */
function writeNotificationClearBefore(value: number) {
notificationClearBefore.value = value
if (typeof localStorage === 'undefined') return
localStorage.setItem(NOTIFICATION_CLEAR_BEFORE_STORAGE_KEY, String(value))
}
/** 将通知备注统一转换成稳定字符串,用于生成去重 key。 */
function normalizeNote(note: SystemNotification['note']) {
if (note == null) return ''
if (typeof note === 'string') return note
@@ -31,26 +60,31 @@ function normalizeNote(note: SystemNotification['note']) {
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)
}
/** 基于主要展示字段生成内容去重 key。 */
function getNotificationContentKey(item: SystemNotification) {
return [
getNotificationKind(item),
@@ -63,25 +97,44 @@ function getNotificationContentKey(item: SystemNotification) {
].join('::')
}
/** 生成通知可用于去重的全部 key。 */
function getNotificationKeys(item: SystemNotification) {
return [item.id ? `id:${item.id}` : '', `content:${getNotificationContentKey(item)}`].filter(Boolean)
}
/** 获取用于虚拟列表渲染的稳定 key。 */
function getNotificationKey(item: SystemNotification) {
return item.id ? `id:${item.id}` : `content:${getNotificationContentKey(item)}`
}
/** 获取通知正文展开状态使用的稳定 key。 */
function getNotificationExpansionKey(item: SystemNotification) {
return getNotificationKey(item)
}
/** 将通知时间解析成时间戳,用于列表降序排序。 */
function parseNotificationTime(value: string) {
if (!value) return 0
return new Date(value.includes('T') ? value : value.replaceAll(/-/g, '/')).getTime() || 0
}
/** 判断历史通知是否早于本地清理时间,需要从列表中过滤。 */
function isClearedHistoryNotification(item: SystemNotification) {
const clearBefore = notificationClearBefore.value
if (!clearBefore) return false
const notificationTime = parseNotificationTime(getNotificationTime(item))
return notificationTime > 0 && notificationTime <= clearBefore
}
/** 按通知时间倒序重排当前列表。 */
function sortNotifications() {
notificationList.value = [...notificationList.value].sort(
(a, b) => parseNotificationTime(getNotificationTime(b)) - parseNotificationTime(getNotificationTime(a)),
)
}
/** 压缩当前通知列表,移除同一内容或同一 ID 的重复项。 */
function compactNotifications(items: SystemNotification[]) {
const contentKeys = new Set<string>()
const idKeys = new Set<string>()
@@ -101,6 +154,7 @@ function compactNotifications(items: SystemNotification[]) {
return compactedItems
}
/** 规范化通知展示字段,并补齐默认标题、类型和已读状态。 */
function normalizeNotification(item: SystemNotification, read = true): SystemNotification {
return {
...item,
@@ -110,6 +164,7 @@ function normalizeNotification(item: SystemNotification, read = true): SystemNot
}
}
/** 合并新通知到当前列表,并维护去重集合、排序和已读状态。 */
function mergeNotifications(items: SystemNotification[], options: { prepend?: boolean; read?: boolean } = {}) {
const normalizedItems = items.map(item => normalizeNotification(item, options.read ?? true))
const acceptedItems: SystemNotification[] = []
@@ -133,6 +188,77 @@ function mergeNotifications(items: SystemNotification[], options: { prepend?: bo
return true
}
/** 重置通知分页状态,用于清理后重新进入空列表状态。 */
function resetNotifications() {
notificationList.value = []
notificationKeys.clear()
expandedNotificationKeys.value = new Set()
page.value = 1
hasMore.value = true
hasNewMessage.value = false
}
/** 通过后端接口清理通知历史,兼容新旧后端可能暴露的清理路径。 */
async function deleteNotificationHistory() {
let lastError: unknown = null
for (const endpoint of CLEAR_NOTIFICATION_ENDPOINTS) {
try {
return await api.delete(endpoint)
} catch (error: any) {
lastError = error
if (error?.response?.status !== 404 && error?.response?.status !== 405) break
}
}
throw lastError
}
/** 尝试调用后端清理接口,不支持时回退为本地清理。 */
async function tryDeleteNotificationHistory() {
try {
const result: { [key: string]: any } = await deleteNotificationHistory()
return result?.success !== false
} catch (error: any) {
if (error?.response?.status === 404 || error?.response?.status === 405) return true
throw error
}
}
/** 确认并清空通知中心历史,同时同步清理未读角标。 */
async function clearNotifications() {
if (clearing.value || notificationList.value.length === 0) return
const confirmed = await createConfirm({
type: 'warn',
title: t('notification.clear'),
content: t('notification.clearConfirm'),
confirmText: t('notification.clear'),
})
if (!confirmed) return
clearing.value = true
try {
const cleared = await tryDeleteNotificationHistory()
if (!cleared) {
$toast.error(t('notification.clearFailed'))
return
}
writeNotificationClearBefore(Date.now())
resetNotifications()
await clearUnreadMessages()
appsMenu.value = false
hasMore.value = false
$toast.success(t('notification.clearSuccess'))
} catch (error: any) {
$toast.error(error?.response?.data?.message || error?.message || t('notification.clearFailed'))
} finally {
clearing.value = false
}
}
/** 按页加载历史通知,并合并到当前虚拟列表。 */
async function loadNotifications({ done }: { done: (status: 'ok' | 'empty' | 'error') => void }) {
if (loading.value) {
done('ok')
@@ -159,9 +285,10 @@ async function loadNotifications({ done }: { done: (status: 'ok' | 'empty' | 'er
return
}
mergeNotifications(items, { read: true })
const visibleItems = items.filter(item => !isClearedHistoryNotification(item))
mergeNotifications(visibleItems, { read: true })
page.value += 1
hasMore.value = items.length >= PAGE_SIZE
hasMore.value = visibleItems.length === items.length && items.length >= PAGE_SIZE
done(hasMore.value ? 'ok' : 'empty')
} catch (error) {
console.error('加载通知失败:', error)
@@ -171,6 +298,7 @@ async function loadNotifications({ done }: { done: (status: 'ok' | 'empty' | 'er
}
}
/** 处理 SSE 推送的新通知,并置为未读状态展示红点。 */
function handleMessage(event: MessageEvent) {
if (!event.data) return
@@ -194,6 +322,7 @@ function markAllAsRead() {
void clearUnreadMessages()
}
/** 根据通知分类和业务类型选择列表图标。 */
function getNotificationIcon(item: SystemNotification) {
if (getNotificationKind(item) === 'plugin') return 'mdi-puzzle-outline'
if (item.mtype === '资源下载') return 'mdi-download'
@@ -203,6 +332,7 @@ function getNotificationIcon(item: SystemNotification) {
return getNotificationKind(item) === 'system' ? 'mdi-alert-circle-outline' : 'mdi-bell-outline'
}
/** 根据通知分类和业务类型选择图标颜色。 */
function getNotificationColor(item: SystemNotification) {
if (getNotificationKind(item) === 'system') return 'error'
if (getNotificationKind(item) === 'plugin') return 'warning'
@@ -212,15 +342,64 @@ function getNotificationColor(item: SystemNotification) {
return 'secondary'
}
/** 判断通知是否有真实媒体图,决定是否使用媒体缩略图样式。 */
function isMediaNotification(item: SystemNotification) {
return Boolean(item.image) || MEDIA_NOTIFICATION_TYPES.includes(item.mtype || '')
return Boolean(item.image)
}
function openNotification(item: SystemNotification) {
/** 按系统类消息和媒体消息生成带分组标题的虚拟列表数据。 */
function buildNotificationDisplayList(items: SystemNotification[]) {
const systemItems = items.filter(item => !isMediaNotification(item))
const mediaItems = items.filter(isMediaNotification)
const sections = [
{ key: 'system', title: t('notification.systemMessages'), items: systemItems },
{ key: 'media', title: t('notification.mediaMessages'), items: mediaItems },
]
const displayItems: NotificationDisplayItem[] = []
sections.forEach(section => {
if (section.items.length === 0) return
displayItems.push({
kind: 'section',
key: `section:${section.key}`,
title: section.title,
count: section.items.length,
})
section.items.forEach(item => {
displayItems.push({
kind: 'notification',
key: `notification:${getNotificationKey(item)}`,
notification: item,
})
})
})
return displayItems
}
/** 判断通知正文是否已经展开。 */
function isNotificationExpanded(item: SystemNotification) {
return expandedNotificationKeys.value.has(getNotificationExpansionKey(item))
}
/** 标记单条通知为已读,并在全部已读时同步清理未读角标。 */
function markNotificationAsRead(item: SystemNotification) {
item.read = true
hasNewMessage.value = hasUnreadNotifications.value
if (!hasUnreadNotifications.value) void clearUnreadMessages()
if (item.link) window.open(item.link, '_blank')
}
/** 切换通知正文展开状态。 */
function toggleNotificationExpanded(item: SystemNotification) {
markNotificationAsRead(item)
if (!item.text) return
const key = getNotificationExpansionKey(item)
const expandedKeys = new Set(expandedNotificationKeys.value)
if (expandedKeys.has(key)) expandedKeys.delete(key)
else expandedKeys.add(key)
expandedNotificationKeys.value = expandedKeys
}
useDelayedSSE(
@@ -242,18 +421,18 @@ useDelayedSSE(
width="420"
max-width="calc(100vw - 24px)"
transition="scale-transition"
close-on-content-click
:close-on-content-click="false"
class="notification-menu"
scrim
>
<template #activator="{ props }">
<VBadge v-if="hasNewMessage" dot color="error" :offset-x="5" :offset-y="5" v-bind="props">
<IconBtn>
<VIcon icon="mdi-bell-outline" />
<VIcon icon="mdi-bell-outline" size="22" />
</IconBtn>
</VBadge>
<IconBtn v-else v-bind="props">
<VIcon icon="mdi-bell-outline" />
<VIcon icon="mdi-bell-outline" size="22" />
</IconBtn>
</template>
@@ -261,13 +440,27 @@ useDelayedSSE(
<VCardItem class="py-3">
<VCardTitle>{{ t('notification.center') }}</VCardTitle>
<template #append>
<VTooltip :text="t('notification.markRead')">
<template #activator="{ props }">
<IconBtn v-bind="props" @click.stop="markAllAsRead">
<VIcon icon="mdi-email-check-outline" size="20" />
</IconBtn>
</template>
</VTooltip>
<div class="notification-actions">
<VTooltip :text="t('notification.clear')">
<template #activator="{ props }">
<IconBtn
v-bind="props"
:disabled="notificationList.length === 0 || clearing"
@click.stop="clearNotifications"
>
<VProgressCircular v-if="clearing" indeterminate size="18" width="2" />
<VIcon v-else icon="mdi-trash-can-outline" size="20" />
</IconBtn>
</template>
</VTooltip>
<VTooltip :text="t('notification.markRead')">
<template #activator="{ props }">
<IconBtn v-bind="props" :disabled="!hasUnreadNotifications" @click.stop="markAllAsRead">
<VIcon icon="mdi-email-check-outline" size="20" />
</IconBtn>
</template>
</VTooltip>
</div>
</template>
</VCardItem>
<VDivider />
@@ -290,7 +483,9 @@ useDelayedSSE(
{{ t('message.noMoreData') }}
</div>
<div v-else class="notification-empty">
<VIcon icon="mdi-bell-sleep-outline" size="40" class="mb-3" />
<div class="notification-empty__icon">
<VIcon icon="mdi-bell-sleep-outline" size="22" />
</div>
<div>{{ t('notification.empty') }}</div>
</div>
</template>
@@ -298,48 +493,68 @@ useDelayedSSE(
<VVirtualScroll
v-if="notificationList.length > 0"
renderless
:items="notificationList"
:items="notificationDisplayList"
:item-height="NOTIFICATION_ITEM_HEIGHT"
>
<template #default="{ item, itemRef }">
<div :ref="itemRef" :key="getNotificationKey(item)" class="notification-virtual-item">
<button
type="button"
<div
:ref="itemRef"
:key="item.key"
class="notification-virtual-item"
:class="{ 'notification-virtual-item--section': item.kind === 'section' }"
>
<div v-if="item.kind === 'section'" class="notification-section-heading">
<span class="notification-section-heading__title">{{ item.title }}</span>
<span class="notification-section-heading__count">{{ item.count }}</span>
</div>
<div
v-else
class="notification-row"
:class="{
'notification-row--unread': item.read === false,
'notification-row--media': isMediaNotification(item),
'notification-row--unread': item.notification.read === false,
'notification-row--media': isMediaNotification(item.notification),
}"
@click="openNotification(item)"
role="button"
tabindex="0"
:aria-expanded="item.notification.text ? isNotificationExpanded(item.notification) : undefined"
@click="toggleNotificationExpanded(item.notification)"
@keydown.enter.prevent="toggleNotificationExpanded(item.notification)"
@keydown.space.prevent="toggleNotificationExpanded(item.notification)"
>
<div v-if="isMediaNotification(item)" class="notification-media">
<VImg v-if="item.image" :src="item.image" cover class="notification-media__image">
<div v-if="item.notification.image" class="notification-media">
<VImg
v-if="item.notification.image"
:src="item.notification.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 v-else class="notification-icon" :class="`text-${getNotificationColor(item.notification)}`">
<VIcon :icon="getNotificationIcon(item.notification)" 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" />
<span class="notification-title">{{ item.notification.title }}</span>
<span v-if="item.notification.read === false" class="notification-unread-dot" />
</div>
<div v-if="item.text" class="notification-text">
{{ item.text }}
<div
v-if="item.notification.text"
class="notification-text"
:class="{ 'notification-text--expanded': isNotificationExpanded(item.notification) }"
>
{{ item.notification.text }}
</div>
<div class="notification-meta">
<span v-if="item.mtype" class="notification-type">{{ item.mtype }}</span>
<span>{{ formatDateDifference(getNotificationTime(item)) }}</span>
<span v-if="item.notification.mtype" class="notification-type">{{ item.notification.mtype }}</span>
<span>{{ formatDateDifference(getNotificationTime(item.notification)) }}</span>
</div>
</div>
</button>
</div>
</div>
</template>
</VVirtualScroll>
@@ -354,6 +569,12 @@ useDelayedSSE(
overflow: hidden;
}
.notification-actions {
display: inline-flex;
align-items: center;
gap: 4px;
}
.notification-list-container {
overflow: hidden;
max-block-size: min(560px, 62vh);
@@ -366,11 +587,35 @@ useDelayedSSE(
}
.notification-virtual-item {
block-size: 110px;
padding-block: 4px;
padding-inline: 8px;
}
.notification-virtual-item--section {
padding-block: 10px 2px;
}
.notification-section-heading {
display: flex;
align-items: center;
color: rgba(var(--v-theme-on-surface), 0.42);
gap: 8px;
letter-spacing: 0;
padding-inline: 10px;
}
.notification-section-heading__title {
font-size: 0.6875rem;
font-weight: 500;
line-height: 1.2;
}
.notification-section-heading__count {
color: rgba(var(--v-theme-on-surface), 0.34);
font-size: 0.625rem;
line-height: 1;
}
.notification-row {
position: relative;
display: flex;
@@ -379,7 +624,6 @@ useDelayedSSE(
border: 0;
border-radius: 8px;
background: transparent;
block-size: 100%;
color: inherit;
cursor: pointer;
gap: 12px;
@@ -410,13 +654,11 @@ useDelayedSSE(
block-size: 84px;
}
.notification-media__image,
.notification-media__fallback {
.notification-media__image {
block-size: 100%;
inline-size: 100%;
}
.notification-media__fallback,
.notification-icon {
display: grid;
place-items: center;
@@ -436,18 +678,22 @@ useDelayedSSE(
.notification-title-row {
display: flex;
align-items: center;
align-items: flex-start;
gap: 8px;
min-block-size: 20px;
min-block-size: 24px;
}
.notification-title {
display: -webkit-box;
overflow: hidden;
flex: 1 1 auto;
-webkit-box-orient: vertical;
font-size: 0.925rem;
font-weight: 600;
line-height: 1.35;
-webkit-line-clamp: 2;
text-overflow: ellipsis;
white-space: nowrap;
white-space: normal;
}
.notification-unread-dot {
@@ -456,20 +702,31 @@ useDelayedSSE(
background: rgb(var(--v-theme-error));
block-size: 7px;
inline-size: 7px;
margin-block-start: 0.45rem;
}
.notification-text {
display: -webkit-box;
display: block;
overflow: hidden;
-webkit-box-orient: vertical;
padding: 0;
border: 0;
background: transparent;
block-size: auto;
color: rgba(var(--v-theme-on-surface), var(--v-medium-emphasis-opacity));
cursor: pointer;
font-size: 0.8125rem;
-webkit-line-clamp: 2;
line-height: 1.45;
margin-block-start: 4px;
max-block-size: calc(0.8125rem * 1.45 * 3);
text-align: start;
white-space: pre-wrap;
}
.notification-text--expanded {
max-block-size: none;
overflow: visible;
}
.notification-meta {
display: flex;
flex-wrap: wrap;
@@ -495,4 +752,14 @@ useDelayedSSE(
padding-inline: 16px;
text-align: center;
}
.notification-empty__icon {
display: inline-grid;
place-items: center;
border-radius: 8px;
background: rgba(var(--v-theme-on-surface), 0.06);
block-size: 40px;
inline-size: 40px;
margin-block-end: 12px;
}
</style>

View File

@@ -170,16 +170,13 @@ export default {
skinDefault: 'Default',
skinBordered: 'Bordered',
radius: 'Corners',
radiusNone: 'Square',
radiusSmall: 'Small',
radiusDefault: 'Default',
radiusLarge: 'Large',
radiusExtra: 'Larger',
radiusHuge: 'Extra Large',
shadow: 'Shadows',
shadowNone: 'Flat',
shadowLow: 'Soft',
shadowMedium: 'Balanced',
shadowHigh: 'Bold',
shadowLevel: 'Level {level}',
semiDarkMenu: 'Semi Dark Menu',
layout: 'Layout',
layoutVertical: 'Vertical',
@@ -462,7 +459,13 @@ export default {
notification: {
center: 'Notification Center',
markRead: 'Mark as Read',
clear: 'Clear Notifications',
clearConfirm: 'Clear all notification history from Notification Center?',
clearSuccess: 'Notifications cleared',
clearFailed: 'Failed to clear notifications',
empty: 'No Notifications',
systemMessages: 'System Messages',
mediaMessages: 'Media Messages',
channel: 'Notification Channel',
name: 'Name',
nameHint: 'Name of notification channel',
@@ -1616,6 +1619,9 @@ export default {
'Set the check interval for scheduled wake. Select "Disabled" to disable scheduled tasks.',
aiAgentVerbose: 'Verbose Mode',
aiAgentVerboseHint: 'When enabled, tool call process will be displayed in AI agent responses',
aiAgentHideEntry: 'Hide Global Entry',
aiAgentHideEntryHint:
'Only hide the floating AI assistant entry in the bottom-right corner. Message channels and background assistant features are not affected.',
aiAgentJobIntervalDisabled: 'Disabled',
aiAgentJobInterval1h: '1 Hour',
aiAgentJobInterval3h: '3 Hours',

View File

@@ -170,16 +170,13 @@ export default {
skinDefault: '默认',
skinBordered: '边框',
radius: '圆角',
radiusNone: '无圆角',
radiusSmall: '小圆角',
radiusDefault: '默认',
radiusLarge: '大圆角',
radiusExtra: '更大圆角',
radiusHuge: '超大圆角',
shadow: '阴影',
shadowNone: '无阴影',
shadowLow: '柔和',
shadowMedium: '标准',
shadowHigh: '强烈',
shadowLevel: '层级 {level}',
semiDarkMenu: '半暗菜单',
layout: '布局',
layoutVertical: '垂直',
@@ -460,7 +457,13 @@ export default {
notification: {
center: '通知中心',
markRead: '设为已读',
clear: '清理通知',
clearConfirm: '是否确认清理通知中心内的全部历史消息?',
clearSuccess: '通知已清理',
clearFailed: '通知清理失败',
empty: '暂无通知',
systemMessages: '系统类消息',
mediaMessages: '媒体消息',
channel: '通知渠道',
name: '名称',
nameHint: '通知渠道名称',
@@ -1602,6 +1605,8 @@ export default {
aiAgentJobIntervalHint: '设置定时唤醒的检查间隔,选择"不启用"则不执行定时任务',
aiAgentVerbose: '啰嗦模式',
aiAgentVerboseHint: '开启后会在智能体回复时显示工具调用过程',
aiAgentHideEntry: '隐藏全局入口',
aiAgentHideEntryHint: '仅隐藏页面右下角的智能助手浮动入口,不影响消息渠道和后台智能助手功能',
aiAgentJobIntervalDisabled: '不启用',
aiAgentJobInterval1h: '1小时',
aiAgentJobInterval3h: '3小时',

View File

@@ -170,16 +170,13 @@ export default {
skinDefault: '默認',
skinBordered: '邊框',
radius: '圓角',
radiusNone: '無圓角',
radiusSmall: '小圓角',
radiusDefault: '默認',
radiusLarge: '大圓角',
radiusExtra: '更大圓角',
radiusHuge: '超大圓角',
shadow: '陰影',
shadowNone: '無陰影',
shadowLow: '柔和',
shadowMedium: '標準',
shadowHigh: '強烈',
shadowLevel: '層級 {level}',
semiDarkMenu: '半暗菜單',
layout: '佈局',
layoutVertical: '垂直',
@@ -460,7 +457,13 @@ export default {
notification: {
center: '通知中心',
markRead: '設為已讀',
clear: '清理通知',
clearConfirm: '是否確認清理通知中心內的全部歷史消息?',
clearSuccess: '通知已清理',
clearFailed: '通知清理失敗',
empty: '暫無通知',
systemMessages: '系統類消息',
mediaMessages: '媒體消息',
channel: '通知渠道',
name: '名稱',
nameHint: '通知渠道名稱',
@@ -1603,6 +1606,8 @@ export default {
aiAgentJobIntervalHint: '設置定時喚醒的檢查間隔,選擇「不啟用」則不執行定時任務',
aiAgentVerbose: '囉嗦模式',
aiAgentVerboseHint: '開啟後會在智能體回覆時顯示工具調用過程',
aiAgentHideEntry: '隱藏全域入口',
aiAgentHideEntryHint: '僅隱藏頁面右下角的智能助手浮動入口,不影響消息渠道和後台智能助手功能',
aiAgentJobIntervalDisabled: '不啟用',
aiAgentJobInterval1h: '1小時',
aiAgentJobInterval3h: '3小時',

View File

@@ -1,6 +1,30 @@
/* stylelint-disable custom-property-pattern */
/* stylelint-disable no-duplicate-selectors */
/* stylelint-disable scss/at-rule-no-unknown */
/* stylelint-disable no-descending-specificity */
@use 'sass:map';
@use 'vuetify/settings' as vuetify-settings;
// 返回 Vuetify 指定 elevation 的完整三层 box-shadow供主题定制器映射全局阴影档位。
@function app-vuetify-elevation($level) {
@return map.get(vuetify-settings.$shadow-key-umbra, $level),
map.get(vuetify-settings.$shadow-key-penumbra, $level),
map.get(vuetify-settings.$shadow-key-ambient, $level);
}
// 将相对阴影层级限制在 Vuetify elevation 的 0 到 24 档范围内。
@function app-clamp-elevation($level) {
@if $level < 0 {
@return 0;
}
@if $level > 24 {
@return 24;
}
@return $level;
}
// 公共样式 - 所有主题都需要
@tailwind base;
@tailwind components;
@@ -38,24 +62,36 @@ html.quick-access-scroll-locked body {
}
}
// 全局卡片外观 token圆角和阴影在主题定制器中即时切换
// 全局外观 token圆角和阴影复用 Vuetify 的 rounded / elevation 分级
html {
--app-theme-surface-radius: 8px;
--app-vuetify-rounded-0: #{map.get(vuetify-settings.$rounded, 0)};
--app-vuetify-rounded-sm: #{map.get(vuetify-settings.$rounded, 'sm')};
--app-vuetify-rounded: #{map.get(vuetify-settings.$rounded, null)};
--app-vuetify-rounded-lg: #{map.get(vuetify-settings.$rounded, 'lg')};
--app-vuetify-rounded-xl: #{map.get(vuetify-settings.$rounded, 'xl')};
--app-vuetify-rounded-pill: #{map.get(vuetify-settings.$rounded, 'pill')};
@for $level from 0 through 24 {
--app-elevation-#{$level}: #{app-vuetify-elevation($level)};
}
--app-theme-surface-radius: var(--app-vuetify-rounded);
--app-surface-radius: var(--app-theme-surface-radius);
--app-field-radius: var(--app-theme-surface-radius);
--app-field-radius: var(--app-vuetify-rounded);
--app-control-radius: var(--app-vuetify-rounded);
--app-overlay-radius: var(--app-vuetify-rounded);
--app-surface-border-opacity: 0.06;
--app-surface-border: 1px solid rgba(var(--v-theme-on-surface), var(--app-surface-border-opacity));
--app-shadow-rgb: 15, 23, 42;
--app-card-rest-shadow: none;
--app-card-hover-shadow: none;
--app-fab-shadow: none;
--app-fab-shadow-strong: none;
--app-fab-shadow-hover: none;
--app-fab-shadow-strong-hover: none;
--app-fab-shadow-active: none;
--app-overlay-shadow: none;
--app-surface-shadow: none;
--app-surface-hover-shadow: none;
--app-card-rest-shadow: var(--app-elevation-0);
--app-card-hover-shadow: var(--app-elevation-0);
--app-fab-shadow: var(--app-elevation-0);
--app-fab-shadow-strong: var(--app-elevation-0);
--app-fab-shadow-hover: var(--app-elevation-0);
--app-fab-shadow-strong-hover: var(--app-elevation-0);
--app-fab-shadow-active: var(--app-elevation-0);
--app-overlay-shadow: var(--app-elevation-0);
--app-surface-shadow: var(--app-elevation-0);
--app-surface-hover-shadow: var(--app-elevation-0);
--mp-motion-duration-page: 180ms;
--mp-motion-duration-overlay: 160ms;
--mp-motion-ease-standard: cubic-bezier(0.2, 0.8, 0.2, 1);
@@ -66,65 +102,47 @@ html[data-theme-skin='bordered'] {
--app-surface-border-opacity: 0.1;
}
html[data-theme-radius='none'] {
--app-theme-surface-radius: var(--app-vuetify-rounded-0);
--app-field-radius: var(--app-vuetify-rounded-0);
--app-control-radius: var(--app-vuetify-rounded-0);
--app-overlay-radius: var(--app-vuetify-rounded-0);
}
html[data-theme-radius='small'] {
--app-theme-surface-radius: 4px;
--app-theme-surface-radius: var(--app-vuetify-rounded-sm);
--app-field-radius: var(--app-vuetify-rounded-sm);
--app-control-radius: var(--app-vuetify-rounded-sm);
--app-overlay-radius: var(--app-vuetify-rounded-sm);
}
html[data-theme-radius='large'] {
--app-theme-surface-radius: 12px;
--app-theme-surface-radius: var(--app-vuetify-rounded-lg);
--app-field-radius: var(--app-vuetify-rounded-lg);
--app-control-radius: var(--app-vuetify-rounded-lg);
--app-overlay-radius: var(--app-vuetify-rounded-lg);
}
html[data-theme-radius='extra'] {
--app-theme-surface-radius: 16px;
--app-theme-surface-radius: var(--app-vuetify-rounded-xl);
--app-field-radius: var(--app-vuetify-rounded-xl);
--app-control-radius: var(--app-vuetify-rounded-xl);
--app-overlay-radius: var(--app-vuetify-rounded-xl);
}
html[data-theme-radius='huge'] {
--app-theme-surface-radius: 24px;
}
html[data-theme='dark'],
html[data-theme='purple'],
html[data-theme='transparent'] {
--app-shadow-rgb: 0, 0, 0;
}
html[data-theme-shadow='low'] {
--app-card-rest-shadow: 0 10px 24px rgba(var(--app-shadow-rgb), 0.06), 0 2px 8px rgba(var(--app-shadow-rgb), 0.04);
--app-card-hover-shadow: 0 14px 30px rgba(var(--app-shadow-rgb), 0.08), 0 4px 12px rgba(var(--app-shadow-rgb), 0.05);
--app-fab-shadow: 0 16px 34px rgba(var(--app-shadow-rgb), 0.16), 0 6px 16px rgba(var(--app-shadow-rgb), 0.1);
--app-fab-shadow-strong: 0 20px 40px rgba(var(--app-shadow-rgb), 0.2), 0 8px 18px rgba(var(--app-shadow-rgb), 0.12);
--app-fab-shadow-hover: 0 22px 42px rgba(var(--app-shadow-rgb), 0.22), 0 8px 18px rgba(var(--app-shadow-rgb), 0.12);
--app-fab-shadow-strong-hover: 0 26px 46px rgba(var(--app-shadow-rgb), 0.24), 0 10px 22px rgba(var(--app-shadow-rgb), 0.14);
--app-fab-shadow-active: 0 10px 22px rgba(var(--app-shadow-rgb), 0.16), 0 3px 8px rgba(var(--app-shadow-rgb), 0.1);
--app-overlay-shadow: 0 18px 42px rgba(var(--app-shadow-rgb), 0.14), 0 6px 18px rgba(var(--app-shadow-rgb), 0.08);
--app-surface-shadow: 0 10px 24px rgba(var(--app-shadow-rgb), 0.07), 0 2px 8px rgba(var(--app-shadow-rgb), 0.05);
--app-surface-hover-shadow: 0 14px 30px rgba(var(--app-shadow-rgb), 0.09), 0 4px 12px rgba(var(--app-shadow-rgb), 0.06);
}
html[data-theme-shadow='medium'] {
--app-card-rest-shadow: 0 14px 32px rgba(var(--app-shadow-rgb), 0.09), 0 4px 12px rgba(var(--app-shadow-rgb), 0.06);
--app-card-hover-shadow: 0 18px 40px rgba(var(--app-shadow-rgb), 0.11), 0 6px 16px rgba(var(--app-shadow-rgb), 0.07);
--app-fab-shadow: 0 18px 40px rgba(var(--app-shadow-rgb), 0.2), 0 7px 18px rgba(var(--app-shadow-rgb), 0.12);
--app-fab-shadow-strong: 0 24px 48px rgba(var(--app-shadow-rgb), 0.24), 0 10px 24px rgba(var(--app-shadow-rgb), 0.14);
--app-fab-shadow-hover: 0 24px 46px rgba(var(--app-shadow-rgb), 0.24), 0 10px 22px rgba(var(--app-shadow-rgb), 0.14);
--app-fab-shadow-strong-hover: 0 30px 54px rgba(var(--app-shadow-rgb), 0.28), 0 12px 28px rgba(var(--app-shadow-rgb), 0.16);
--app-fab-shadow-active: 0 12px 26px rgba(var(--app-shadow-rgb), 0.18), 0 4px 10px rgba(var(--app-shadow-rgb), 0.12);
--app-overlay-shadow: 0 24px 56px rgba(var(--app-shadow-rgb), 0.18), 0 10px 24px rgba(var(--app-shadow-rgb), 0.1);
--app-surface-shadow: 0 14px 32px rgba(var(--app-shadow-rgb), 0.1), 0 4px 12px rgba(var(--app-shadow-rgb), 0.07);
--app-surface-hover-shadow: 0 18px 40px rgba(var(--app-shadow-rgb), 0.12), 0 6px 16px rgba(var(--app-shadow-rgb), 0.08);
}
html[data-theme-shadow='high'] {
--app-card-rest-shadow: 0 18px 40px rgba(var(--app-shadow-rgb), 0.12), 0 6px 18px rgba(var(--app-shadow-rgb), 0.08);
--app-card-hover-shadow: 0 22px 50px rgba(var(--app-shadow-rgb), 0.15), 0 8px 22px rgba(var(--app-shadow-rgb), 0.1);
--app-fab-shadow: 0 22px 48px rgba(var(--app-shadow-rgb), 0.24), 0 10px 24px rgba(var(--app-shadow-rgb), 0.14);
--app-fab-shadow-strong: 0 28px 58px rgba(var(--app-shadow-rgb), 0.3), 0 12px 30px rgba(var(--app-shadow-rgb), 0.18);
--app-fab-shadow-hover: 0 28px 56px rgba(var(--app-shadow-rgb), 0.28), 0 12px 28px rgba(var(--app-shadow-rgb), 0.17);
--app-fab-shadow-strong-hover: 0 34px 64px rgba(var(--app-shadow-rgb), 0.34), 0 14px 32px rgba(var(--app-shadow-rgb), 0.2);
--app-fab-shadow-active: 0 14px 30px rgba(var(--app-shadow-rgb), 0.22), 0 5px 12px rgba(var(--app-shadow-rgb), 0.14);
--app-overlay-shadow: 0 30px 70px rgba(var(--app-shadow-rgb), 0.22), 0 14px 30px rgba(var(--app-shadow-rgb), 0.12);
--app-surface-shadow: 0 18px 40px rgba(var(--app-shadow-rgb), 0.13), 0 6px 18px rgba(var(--app-shadow-rgb), 0.09);
--app-surface-hover-shadow: 0 22px 50px rgba(var(--app-shadow-rgb), 0.16), 0 8px 22px rgba(var(--app-shadow-rgb), 0.11);
@for $level from 1 through 24 {
html[data-theme-shadow='#{$level}'] {
--app-card-rest-shadow: var(--app-elevation-#{app-clamp-elevation($level - 1)});
--app-card-hover-shadow: var(--app-elevation-#{app-clamp-elevation($level + 1)});
--app-fab-shadow: var(--app-elevation-#{$level});
--app-fab-shadow-active: var(--app-elevation-#{app-clamp-elevation($level - 2)});
--app-fab-shadow-hover: var(--app-elevation-#{app-clamp-elevation($level + 3)});
--app-fab-shadow-strong: var(--app-elevation-#{app-clamp-elevation($level + 2)});
--app-fab-shadow-strong-hover: var(--app-elevation-#{app-clamp-elevation($level + 4)});
--app-overlay-shadow: var(--app-elevation-#{app-clamp-elevation($level + 6)});
--app-surface-shadow: var(--app-elevation-#{$level});
--app-surface-hover-shadow: var(--app-elevation-#{app-clamp-elevation($level + 2)});
}
}
// 进度条样式
@@ -421,12 +439,12 @@ html[data-theme-shadow='high'] {
}
.v-btn:not(.v-btn--rounded, .v-btn--flat, .v-btn--icon, [class^='rounded-'], [class*=' rounded-']) {
border-radius: var(--app-surface-radius);
border-radius: var(--app-control-radius);
transition: border-radius 0.2s ease;
}
.v-btn-group:not(.v-btn-group--variant-plain, .v-btn-group--variant-text) {
border-radius: var(--app-surface-radius);
border-radius: var(--app-control-radius);
box-shadow: var(--app-surface-shadow) !important;
transition: box-shadow 0.2s ease, border-radius 0.2s ease;
}
@@ -703,7 +721,7 @@ html[data-theme="transparent"] .app-card-colorful,
}
.Vue-Toastification__toast {
border-radius: var(--app-surface-radius);
border-radius: var(--app-overlay-radius);
box-shadow: var(--app-overlay-shadow);
}
@@ -1175,6 +1193,7 @@ html[data-theme="transparent"] .app-card-colorful,
.v-bottom-sheet > .v-bottom-sheet__content.v-overlay__content > .v-card,
.v-menu > .v-overlay__content > .v-card,
.v-menu > .v-overlay__content > .v-list {
border-radius: var(--app-overlay-radius) !important;
box-shadow: var(--app-overlay-shadow) !important;
}
@@ -1191,8 +1210,8 @@ html[data-theme="transparent"] .app-card-colorful,
overflow: hidden;
border-end-end-radius: 0 !important;
border-end-start-radius: 0 !important;
border-start-end-radius: var(--app-theme-surface-radius) !important;
border-start-start-radius: var(--app-theme-surface-radius) !important;
border-start-end-radius: var(--app-overlay-radius) !important;
border-start-start-radius: var(--app-overlay-radius) !important;
}
.v-dialog--fullscreen > .v-overlay__content > .v-card,
@@ -1201,8 +1220,8 @@ html[data-theme="transparent"] .app-card-colorful,
.v-dialog--fullscreen > .v-overlay__content > form > .v-sheet {
border-end-end-radius: 0 !important;
border-end-start-radius: 0 !important;
border-start-end-radius: var(--app-theme-surface-radius) !important;
border-start-start-radius: var(--app-theme-surface-radius) !important;
border-start-end-radius: var(--app-overlay-radius) !important;
border-start-start-radius: var(--app-overlay-radius) !important;
}
}

View File

@@ -49,6 +49,7 @@ const SystemSettings = ref<any>({
CUSTOMIZE_WALLPAPER_API_URL: null,
AI_AGENT_ENABLE: false,
AI_AGENT_GLOBAL: false,
AI_AGENT_HIDE_ENTRY: false,
AI_AGENT_VERBOSE: false,
AI_AGENT_JOB_INTERVAL: 24,
LLM_PROVIDER: 'deepseek',
@@ -61,7 +62,7 @@ const SystemSettings = ref<any>({
LLM_BASE_URL: 'https://api.deepseek.com',
LLM_USE_PROXY: true,
LLM_BASE_URL_PRESET: null,
LLM_MAX_CONTEXT_TOKENS: 64,
LLM_MAX_CONTEXT_TOKENS: 128,
LLM_USER_AGENT: null,
AUDIO_INPUT_PROVIDER: 'openai',
AUDIO_INPUT_API_KEY: null,
@@ -1160,7 +1161,7 @@ watch(currentLlmSnapshotKey, (snapshotKey, previousSnapshotKey) => {
<VExpandTransition>
<VCardText v-show="!aiAgentSettingsCollapsed" class="pt-2">
<VRow>
<VCol cols="12" md="4">
<VCol cols="12" md="6">
<VSwitch
v-model="SystemSettings.Basic.AI_AGENT_ENABLE"
:label="t('setting.system.aiAgentEnable')"
@@ -1168,7 +1169,7 @@ watch(currentLlmSnapshotKey, (snapshotKey, previousSnapshotKey) => {
persistent-hint
/>
</VCol>
<VCol v-if="SystemSettings.Basic.AI_AGENT_ENABLE" cols="12" md="4">
<VCol v-if="SystemSettings.Basic.AI_AGENT_ENABLE" cols="12" md="6">
<VSwitch
v-model="SystemSettings.Basic.AI_AGENT_GLOBAL"
:label="t('setting.system.aiAgentGlobal')"
@@ -1176,7 +1177,7 @@ watch(currentLlmSnapshotKey, (snapshotKey, previousSnapshotKey) => {
persistent-hint
/>
</VCol>
<VCol v-if="SystemSettings.Basic.AI_AGENT_ENABLE" cols="12" md="4">
<VCol v-if="SystemSettings.Basic.AI_AGENT_ENABLE" cols="12" md="6">
<VSwitch
v-model="SystemSettings.Basic.AI_AGENT_VERBOSE"
:label="t('setting.system.aiAgentVerbose')"
@@ -1184,6 +1185,14 @@ watch(currentLlmSnapshotKey, (snapshotKey, previousSnapshotKey) => {
persistent-hint
/>
</VCol>
<VCol v-if="SystemSettings.Basic.AI_AGENT_ENABLE" cols="12" md="6">
<VSwitch
v-model="SystemSettings.Basic.AI_AGENT_HIDE_ENTRY"
:label="t('setting.system.aiAgentHideEntry')"
:hint="t('setting.system.aiAgentHideEntryHint')"
persistent-hint
/>
</VCol>
<VCol v-if="SystemSettings.Basic.AI_AGENT_ENABLE" cols="12" md="6">
<VAutocomplete
v-model="SystemSettings.Basic.LLM_PROVIDER"

View File

@@ -203,7 +203,7 @@ function getLibraryEpisodeCount(subscribe: Subscribe) {
const libraryEpisode =
typeof subscribe.lack_episode === 'number'
? totalEpisode - subscribe.lack_episode
: subscribe.completed_episode ?? 0
: (subscribe.completed_episode ?? 0)
return clampEpisodeCount(libraryEpisode, totalEpisode)
}
@@ -218,9 +218,7 @@ function getLackEpisodeCount(subscribe: Subscribe) {
function normalizeEpisodeNumbers(value: unknown) {
if (!Array.isArray(value)) return []
return value
.map(number => Number(number))
.filter(number => Number.isFinite(number) && number > 0)
return value.map(number => Number(number)).filter(number => Number.isFinite(number) && number > 0)
}
function isEnabledFlag(value: unknown) {
@@ -398,10 +396,7 @@ onActivated(() => {
<template>
<FullCalendar ref="calendarRef" :options="calendarOptions">
<template #eventContent="arg">
<div
v-if="arg.event.extendedProps.isDayGroup"
class="calendar-day-events"
>
<div v-if="arg.event.extendedProps.isDayGroup" class="calendar-day-events">
<div
v-for="calendarEvent in arg.event.extendedProps.visibleEvents"
:key="`${calendarEvent.title}-${calendarEvent.subtitle}-${calendarEvent.calendarSortIndex}`"
@@ -424,10 +419,7 @@ onActivated(() => {
</div>
</template>
</VImg>
<span
v-if="calendarEvent.libraryState === 'complete'"
class="calendar-library-check"
>
<span v-if="calendarEvent.libraryState === 'complete'" class="calendar-library-check">
<VIcon icon="mdi-check" size="12" />
</span>
</div>
@@ -679,18 +671,18 @@ onActivated(() => {
.v-application .fc .fc-event,
.v-application .fc .fc-h-event,
.v-application .fc .fc-daygrid-event {
padding: 0 !important;
border-color: transparent;
background: transparent !important;
box-shadow: none;
margin-block-end: 0.3rem;
padding: 0 !important;
}
.v-application .fc .fc-event-main {
padding: 0 !important;
color: inherit;
font-size: 0.75rem;
font-weight: 500;
padding: 0 !important;
}
.v-application .fc tbody[role='rowgroup'] > tr > td[role='presentation'] {
@@ -789,12 +781,12 @@ onActivated(() => {
.calendar-event-card {
display: flex;
gap: 0.55rem;
overflow: hidden;
align-items: flex-start;
padding: 0.4rem;
border-radius: 8px;
background: rgba(var(--v-theme-surface), 0.72);
overflow: hidden;
gap: 0.55rem;
}
.calendar-day-events {
@@ -806,10 +798,9 @@ onActivated(() => {
.calendar-expand-card {
display: flex;
gap: 0.35rem;
align-items: center;
justify-content: center;
min-block-size: 2.1rem;
padding: 0;
border: 1px dashed rgba(var(--v-theme-primary), 0.44);
border-radius: 8px;
background: rgba(var(--v-theme-primary), 0.08);
@@ -817,8 +808,9 @@ onActivated(() => {
cursor: pointer;
font-size: 0.78rem;
font-weight: 700;
gap: 0.35rem;
inline-size: 100%;
padding: 0;
min-block-size: 2.1rem;
}
.calendar-expand-card:hover {
@@ -843,24 +835,24 @@ onActivated(() => {
.calendar-library-check {
position: absolute;
top: 0.18rem;
right: 0.18rem;
display: inline-flex;
align-items: center;
justify-content: center;
border: 2px solid rgb(var(--v-theme-surface));
border: 2px solid rgba(var(--v-theme-surface), 0.5);
border-radius: 50%;
background: rgb(var(--v-theme-success));
block-size: 1.15rem;
color: rgb(var(--v-theme-on-success));
inline-size: 1.15rem;
inset-block-start: 0.18rem;
inset-inline-end: 0.18rem;
}
.calendar-library-check--mobile {
top: 0.12rem;
right: 0.12rem;
block-size: 1rem;
inline-size: 1rem;
inset-block-start: 0.12rem;
inset-inline-end: 0.12rem;
}
.calendar-event-content {
@@ -874,23 +866,23 @@ onActivated(() => {
.calendar-event-title {
display: -webkit-box;
overflow: hidden;
-webkit-box-orient: vertical;
color: rgba(var(--v-theme-on-surface), var(--v-high-emphasis-opacity));
font-size: 0.88rem;
font-weight: 700;
-webkit-line-clamp: 2;
line-clamp: 2;
line-height: 1.28;
max-block-size: calc(0.88rem * 1.28 * 2);
overflow-wrap: anywhere;
white-space: normal;
word-break: break-word;
line-clamp: 2;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
}
.calendar-event-episode {
display: inline-flex;
align-items: center;
overflow: hidden;
align-items: center;
color: rgba(var(--v-theme-on-surface), var(--v-medium-emphasis-opacity));
column-gap: 0.2rem;
font-size: 0.72rem;
@@ -903,8 +895,8 @@ onActivated(() => {
.calendar-event-episode,
.calendar-event-time {
display: inline-flex;
align-items: center;
overflow: hidden;
align-items: center;
color: rgba(var(--v-theme-on-surface), var(--v-medium-emphasis-opacity));
column-gap: 0.2rem;
line-height: 1.25;
@@ -915,8 +907,8 @@ onActivated(() => {
.calendar-event-library-row {
display: flex;
flex-wrap: wrap;
gap: 0.18rem 0.3rem;
align-items: center;
gap: 0.18rem 0.3rem;
min-inline-size: 0;
}
@@ -946,8 +938,8 @@ onActivated(() => {
.calendar-event-status,
.calendar-event-progress,
.calendar-event-time {
max-inline-size: 100%;
overflow: hidden;
max-inline-size: 100%;
text-overflow: ellipsis;
white-space: nowrap;
}
@@ -960,15 +952,14 @@ onActivated(() => {
.calendar-mobile-episode {
position: absolute;
right: 0;
bottom: 0;
left: 0;
display: block;
overflow: hidden;
background: rgba(0, 0, 0, 0.58);
background: rgba(0, 0, 0, 58%);
color: #fff;
font-size: 0.62rem;
font-weight: 700;
inset-block-end: 0;
inset-inline: 0;
line-height: 1.25;
padding-block: 0.1rem;
padding-inline: 0.2rem;
@@ -991,10 +982,10 @@ onActivated(() => {
.calendar-expand-card {
flex-direction: column;
gap: 0.12rem;
min-block-size: 0;
block-size: clamp(60px, 8.7vw, 96px);
gap: 0.12rem;
inline-size: clamp(40px, 5.8vw, 64px);
min-block-size: 0;
}
.calendar-expand-count {