Compare commits

..

8 Commits
v2.13.14 ... v2

Author SHA1 Message Date
jxxghp
530fe9d35b fix: 修复卡片 hover 上浮抖动 2026-06-24 21:34:38 +08:00
jxxghp
48ed396a19 Refresh notifications when opening the menu 2026-06-24 20:58:46 +08:00
jxxghp
b0356c217d feat: add welcome back message to login page and enhance styles 2026-06-24 17:38:03 +08:00
jxxghp
55eed1ecb5 Fix notification read and clear state 2026-06-24 16:58:08 +08:00
jxxghp
50ae739a4d fix: resolve stylelint warnings and enhance bubble layout for agent assistant 2026-06-24 14:33:55 +08:00
jxxghp
d9cbcc2991 Increase agent assistant bubble text size 2026-06-24 13:44:09 +08:00
jxxghp
ad12701fe2 chore: update version to 2.13.15 2026-06-24 12:55:32 +08:00
jxxghp
2b426a47c6 Route toast notifications to agent assistant bubbles 2026-06-24 12:55:01 +08:00
32 changed files with 1131 additions and 461 deletions

View File

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

View File

@@ -1,15 +1,18 @@
<script setup lang="ts">
import {
onAgentAssistantNotificationBubble,
onAgentAssistantBubble,
setAgentAssistantBubbleEntryActive,
type AgentAssistantBubbleKind,
type AgentAssistantBubblePayload,
type AgentAssistantBubbleVariant,
type AgentAssistantNotificationBubblePayload,
} from '@/utils/agentAssistantBubble'
import { useI18n } from 'vue-i18n'
type AgentAssistantEntryBubbleKind = 'assistant' | 'custom' | 'notification'
interface AgentAssistantEntryBubble {
id: string
kind: AgentAssistantEntryBubbleKind
kind: AgentAssistantBubbleKind
variant: AgentAssistantBubbleVariant
title?: string
text: string
keepOpen?: boolean
@@ -17,7 +20,8 @@ interface AgentAssistantEntryBubble {
interface AgentAssistantEntryBubbleInput {
id?: string
kind?: AgentAssistantEntryBubbleKind
kind?: AgentAssistantBubbleKind
variant?: AgentAssistantBubbleVariant
title?: string
text: string
autoClose?: boolean
@@ -45,6 +49,7 @@ const { t } = useI18n()
const FAB_IDLE_DOCK_DELAY = 4200
const FAB_RIGHT_EDGE_DOCK_DISTANCE = 88
const FAB_NOTIFICATION_BUBBLE_DURATION = 7000
const FAB_TOAST_BUBBLE_DURATION = 4500
const FAB_MAX_BUBBLES = 4
const FAB_DEFAULT_RIGHT_OFFSET = 18
const FAB_DEFAULT_VERTICAL_RATIO = 2 / 3
@@ -128,7 +133,7 @@ let fabPendingPointerPoint: FabPointerPoint | null = null
let fabLastRandomAction: FabRandomAction | null = null
let fabRandomActionTimer: number | null = null
let fabRandomActionEndTimer: number | null = null
let stopNotificationBubbleListener: (() => void) | null = null
let stopBubbleListener: (() => void) | null = null
const fabBubbleTimers = new Map<string, number>()
@@ -377,7 +382,10 @@ function pauseFabAutoDock() {
// 返回下一次趣味动作的随机等待时间,让动作出现节奏更自然。
function getFabRandomActionDelay() {
return FAB_RANDOM_ACTION_MIN_DELAY + Math.round(Math.random() * (FAB_RANDOM_ACTION_MAX_DELAY - FAB_RANDOM_ACTION_MIN_DELAY))
return (
FAB_RANDOM_ACTION_MIN_DELAY +
Math.round(Math.random() * (FAB_RANDOM_ACTION_MAX_DELAY - FAB_RANDOM_ACTION_MIN_DELAY))
)
}
// 判断当前交互状态是否适合播放随机动作,避免干扰半隐藏、拖拽和思考态。
@@ -456,7 +464,8 @@ function runFabRandomAction() {
// 根据当前显示和交互状态同步随机动作队列。
function syncFabRandomActionSchedule() {
if (canRunFabRandomAction()) {
if (!fabRandomAction.value && fabRandomActionTimer === null && fabRandomActionEndTimer === null) scheduleFabRandomAction()
if (!fabRandomAction.value && fabRandomActionTimer === null && fabRandomActionEndTimer === null)
scheduleFabRandomAction()
return
}
@@ -483,6 +492,36 @@ function buildNotificationBubbleText(payload: AgentAssistantNotificationBubblePa
return stripMarkdownPreview(payload.text || payload.title || payload.source || payload.mtype || '')
}
function getBubbleVariant(payload: AgentAssistantBubblePayload): AgentAssistantBubbleVariant {
return payload.variant || 'default'
}
function getBubbleIcon(variant: AgentAssistantBubbleVariant) {
const icons: Record<AgentAssistantBubbleVariant, string> = {
default: 'mdi-bell-outline',
error: 'mdi-alert-circle-outline',
info: 'mdi-information-outline',
success: 'mdi-check-circle-outline',
warning: 'mdi-alert-outline',
}
return icons[variant]
}
function getToastBubbleTitle(payload: AgentAssistantBubblePayload) {
if (payload.title) return payload.title
const titles: Record<AgentAssistantBubbleVariant, string> = {
default: t('common.notice'),
error: t('common.error'),
info: t('common.notice'),
success: t('common.success'),
warning: t('common.notice'),
}
return titles[getBubbleVariant(payload)]
}
function clearFabBubbleTimer(id: string) {
const timer = fabBubbleTimers.get(id)
if (!timer) return
@@ -525,6 +564,7 @@ function showBubble(input: AgentAssistantEntryBubbleInput) {
{
id: input.id || createBubbleId(input.kind || 'custom'),
kind: input.kind || 'custom',
variant: input.variant || 'default',
title: input.title,
text,
keepOpen: input.keepOpen,
@@ -551,6 +591,7 @@ function showNotificationBubble(payload: AgentAssistantNotificationBubblePayload
showBubble({
id: payload.id,
kind: 'notification',
variant: getBubbleVariant(payload),
title: buildNotificationBubbleTitle(payload),
text,
autoClose: true,
@@ -558,6 +599,31 @@ function showNotificationBubble(payload: AgentAssistantNotificationBubblePayload
})
}
function showToastBubble(payload: AgentAssistantBubblePayload) {
const text = stripMarkdownPreview(payload.text || payload.title || '')
if (!text) return
showBubble({
id: payload.id,
kind: 'toast',
variant: getBubbleVariant(payload),
title: getToastBubbleTitle(payload),
text,
autoClose: true,
duration: payload.duration || FAB_TOAST_BUBBLE_DURATION,
keepOpen: payload.keepOpen,
})
}
function showAgentAssistantBubble(payload: AgentAssistantBubblePayload) {
if ((payload.kind || 'notification') === 'toast') {
showToastBubble(payload)
return
}
showNotificationBubble(payload as AgentAssistantNotificationBubblePayload)
}
function closeBubble(id?: string) {
if (id) {
clearFabBubbleTimer(id)
@@ -596,7 +662,10 @@ function setFabDocked(docked: boolean) {
updateFabPosition({
...currentPosition,
x: Math.min(currentPosition.x, Math.max(0, getViewportSize().width - getOpenFabSize().width - FAB_DEFAULT_RIGHT_OFFSET)),
x: Math.min(
currentPosition.x,
Math.max(0, getViewportSize().width - getOpenFabSize().width - FAB_DEFAULT_RIGHT_OFFSET),
),
})
scheduleFabAutoDock()
}
@@ -614,7 +683,6 @@ function handleFabTriggerPointerDown(event: PointerEvent) {
startY: currentPosition.y,
moved: false,
}
;(event.currentTarget as HTMLElement).setPointerCapture?.(event.pointerId)
}
@@ -684,16 +752,19 @@ function handleFabPointerEnter() {
onMounted(() => {
nextTick(resetFabPosition)
setAgentAssistantBubbleEntryActive(props.active)
window.addEventListener('resize', handleWindowResize)
window.addEventListener('pointermove', handleGlobalFabPointer, { passive: true })
window.addEventListener('pointerdown', handleGlobalFabPointer, { passive: true })
stopNotificationBubbleListener = onAgentAssistantNotificationBubble(showNotificationBubble)
stopBubbleListener = onAgentAssistantBubble(showAgentAssistantBubble)
scheduleFabRandomAction()
})
watch(
() => props.active,
active => {
setAgentAssistantBubbleEntryActive(active)
if (active) {
if (isFabNearRightEdge()) scheduleFabAutoDock()
return
@@ -712,8 +783,9 @@ onScopeDispose(clearFabIdleTimer)
onScopeDispose(clearFabRandomAction)
onScopeDispose(resetFabBubbles)
onScopeDispose(() => {
stopNotificationBubbleListener?.()
stopNotificationBubbleListener = null
setAgentAssistantBubbleEntryActive(false)
stopBubbleListener?.()
stopBubbleListener = null
window.removeEventListener('resize', handleWindowResize)
teardownFabPointerTracking()
})
@@ -725,6 +797,7 @@ defineExpose({
showAssistantReplyPreview,
showBubble,
showNotificationBubble,
showToastBubble,
})
</script>
@@ -750,10 +823,13 @@ defineExpose({
v-for="bubble in fabBubbles"
:key="bubble.id"
class="agent-assistant-fab__bubble"
:class="`agent-assistant-fab__bubble--${bubble.kind}`"
:class="[`agent-assistant-fab__bubble--${bubble.kind}`, `agent-assistant-fab__bubble--${bubble.variant}`]"
role="status"
>
<strong v-if="bubble.title">{{ bubble.title }}</strong>
<strong v-if="bubble.title" class="agent-assistant-fab__bubble-title">
<VIcon class="agent-assistant-fab__bubble-icon" :icon="getBubbleIcon(bubble.variant)" size="20" />
<span>{{ bubble.title }}</span>
</strong>
<span>{{ bubble.text }}</span>
<button
class="agent-assistant-fab__bubble-close"
@@ -801,6 +877,7 @@ defineExpose({
<style lang="scss" scoped>
/* stylelint-disable no-descending-specificity */
/* stylelint-disable no-duplicate-selectors */
.agent-assistant-fab {
position: fixed;
@@ -886,14 +963,14 @@ defineExpose({
.agent-assistant-fab__bubbles {
position: absolute;
display: grid;
overflow: visible;
gap: 0.45rem;
inline-size: 13.2rem;
inline-size: clamp(15.5rem, 22vw, 19rem);
inset-block-end: 4.45rem;
inset-inline-end: 2.75rem;
max-block-size: min(22rem, calc(100vh - 8rem));
max-block-size: min(34rem, calc(100vh - 8rem));
max-inline-size: calc(100vw - 6.4rem);
opacity: 0;
overflow-y: auto;
pointer-events: none;
transform: translateY(0.22rem) scale(0.96);
transform-origin: 100% 100%;
@@ -905,6 +982,10 @@ defineExpose({
.agent-assistant-fab__bubble {
position: relative;
display: grid;
--agent-assistant-bubble-accent: var(--v-theme-primary);
--agent-assistant-bubble-accent-rgb: var(--v-theme-primary);
border: 1px solid rgba(var(--v-theme-on-surface), 0.1);
border-radius: 18px;
backdrop-filter: blur(12px);
@@ -921,26 +1002,60 @@ defineExpose({
linear-gradient(135deg, rgba(var(--v-theme-primary), 0.1), transparent 48%), rgba(var(--v-theme-surface), 0.94);
}
.agent-assistant-fab__bubble strong {
.agent-assistant-fab__bubble--success {
--agent-assistant-bubble-accent-rgb: var(--v-theme-success);
}
.agent-assistant-fab__bubble--error {
--agent-assistant-bubble-accent-rgb: var(--v-theme-error);
}
.agent-assistant-fab__bubble--warning {
--agent-assistant-bubble-accent-rgb: 245, 158, 11;
}
.agent-assistant-fab__bubble--info {
--agent-assistant-bubble-accent-rgb: 14, 165, 233;
}
.agent-assistant-fab__bubble--toast {
border-color: rgba(var(--agent-assistant-bubble-accent-rgb), 0.3);
background:
linear-gradient(135deg, rgba(var(--agent-assistant-bubble-accent-rgb), 0.12), transparent 54%),
rgba(var(--v-theme-surface), 0.95);
}
.agent-assistant-fab__bubble-title {
display: inline-grid;
overflow: hidden;
color: rgba(var(--v-theme-primary), 0.92);
font-size: 0.72rem;
font-weight: 700;
align-items: center;
color: rgba(var(--agent-assistant-bubble-accent-rgb), 0.92);
column-gap: 0.32rem;
font-size: 1.05rem;
font-weight: 800;
grid-template-columns: auto minmax(0, 1fr);
line-height: 1.25;
margin-block-end: 0.22rem;
}
.agent-assistant-fab__bubble-title span {
text-overflow: ellipsis;
white-space: nowrap;
}
.agent-assistant-fab__bubble span {
.agent-assistant-fab__bubble-icon {
color: rgba(var(--agent-assistant-bubble-accent-rgb), 0.92) !important;
}
.agent-assistant-fab__bubble > span {
display: -webkit-box;
overflow: hidden;
-webkit-box-orient: vertical;
color: rgba(var(--v-theme-on-surface), 0.9);
font-size: 0.78rem;
font-size: 1rem;
font-weight: 600;
-webkit-line-clamp: 4;
line-height: 1.42;
-webkit-line-clamp: 8;
line-height: 1.46;
text-align: start;
white-space: normal;
}
@@ -1034,12 +1149,12 @@ defineExpose({
z-index: 3;
display: block;
border-radius: 999px;
animation: agent-fab-antenna-idle 3.9s ease-in-out infinite;
background: var(--agent-assistant-robot-outline);
block-size: 0.66rem;
inline-size: 0.18rem;
inset-block-start: 0.72rem;
inset-inline-start: 2.62rem;
animation: agent-fab-antenna-idle 3.9s ease-in-out infinite;
transform: translate(var(--agent-assistant-head-x), var(--agent-assistant-head-y)) rotate(22deg);
transform-origin: bottom center;
transition:
@@ -1065,6 +1180,7 @@ defineExpose({
display: block;
border: 2px solid var(--agent-assistant-robot-outline);
border-radius: 11px;
animation: agent-fab-head-idle 4.6s ease-in-out infinite;
background: linear-gradient(
145deg,
var(--agent-assistant-robot-shell-start) 0%,
@@ -1077,7 +1193,6 @@ defineExpose({
inline-size: 2.82rem;
inset-block-start: 1.42rem;
inset-inline-start: 0.88rem;
animation: agent-fab-head-idle 4.6s ease-in-out infinite;
transform: translate(var(--agent-assistant-head-x), var(--agent-assistant-head-y));
transform-origin: 50% 85%;
}
@@ -1085,6 +1200,7 @@ defineExpose({
.agent-assistant-fab__face {
position: absolute;
display: block;
overflow: hidden;
border: 2px solid var(--agent-assistant-robot-outline-soft);
border-radius: 8px;
background: linear-gradient(
@@ -1097,7 +1213,6 @@ defineExpose({
inline-size: 2.1rem;
inset-block-start: 0.33rem;
inset-inline-start: 0.25rem;
overflow: hidden;
}
.agent-assistant-fab__eye {
@@ -1110,6 +1225,7 @@ defineExpose({
inline-size: 0.42rem;
inset-block-start: 0.36rem;
transform: translate(var(--agent-assistant-eye-x), var(--agent-assistant-eye-y));
/* 触屏设备没有连续 hover 轨迹,给眼神位移补过渡避免点按时瞬移。 */
transition: transform 0.2s ease-out;
}
@@ -1125,9 +1241,9 @@ defineExpose({
.agent-assistant-fab__smile {
position: absolute;
display: block;
border-block-end: 0.13rem solid var(--agent-assistant-robot-eye);
border-radius: 0 0 999px 999px;
block-size: 0.32rem;
border-block-end: 0.13rem solid var(--agent-assistant-robot-eye);
inline-size: 0.7rem;
inset-block-start: 0.75rem;
inset-inline-start: 50%;
@@ -1142,6 +1258,7 @@ defineExpose({
display: block;
border: 2px solid var(--agent-assistant-robot-outline);
border-radius: 0.65rem 0.65rem 0.55rem 0.55rem;
animation: agent-fab-body-idle 4.2s ease-in-out infinite;
background: linear-gradient(
145deg,
var(--agent-assistant-robot-shell-mid) 0%,
@@ -1154,7 +1271,6 @@ defineExpose({
inline-size: 1.88rem;
inset-block-start: 3.24rem;
inset-inline-start: 1.32rem;
animation: agent-fab-body-idle 4.2s ease-in-out infinite;
transform: translate(var(--agent-assistant-body-x), var(--agent-assistant-body-y));
transform-origin: 50% 18%;
transition:
@@ -1271,10 +1387,7 @@ defineExpose({
}
.agent-assistant-fab.is-docked .agent-assistant-fab__eye {
transform: translate(
calc(var(--agent-assistant-eye-x) * 0.24 - 0.22rem),
calc(var(--agent-assistant-eye-y) * 0.24)
);
transform: translate(calc(var(--agent-assistant-eye-x) * 0.24 - 0.22rem), calc(var(--agent-assistant-eye-y) * 0.24));
}
.agent-assistant-fab.is-docked .agent-assistant-fab__body,
@@ -2046,23 +2159,27 @@ defineExpose({
9%,
37%,
65% {
transform: translateY(0.22rem) scale(var(--agent-assistant-bot-scale)) rotate(calc(var(--agent-assistant-robot-tilt) - 3deg));
transform: translateY(0.22rem) scale(var(--agent-assistant-bot-scale))
rotate(calc(var(--agent-assistant-robot-tilt) - 3deg));
}
20%,
48%,
76% {
transform: translateY(-0.76rem) scale(var(--agent-assistant-bot-scale)) rotate(calc(var(--agent-assistant-robot-tilt) + 4deg));
transform: translateY(-0.76rem) scale(var(--agent-assistant-bot-scale))
rotate(calc(var(--agent-assistant-robot-tilt) + 4deg));
}
29%,
57%,
85% {
transform: translateY(0.1rem) scale(var(--agent-assistant-bot-scale)) rotate(calc(var(--agent-assistant-robot-tilt) - 2deg));
transform: translateY(0.1rem) scale(var(--agent-assistant-bot-scale))
rotate(calc(var(--agent-assistant-robot-tilt) - 2deg));
}
92% {
transform: translateY(-0.1rem) scale(var(--agent-assistant-bot-scale)) rotate(calc(var(--agent-assistant-robot-tilt) + 1deg));
transform: translateY(-0.1rem) scale(var(--agent-assistant-bot-scale))
rotate(calc(var(--agent-assistant-robot-tilt) + 1deg));
}
}
@@ -2178,19 +2295,22 @@ defineExpose({
9%,
37%,
65% {
transform: translate(var(--agent-assistant-body-x), calc(var(--agent-assistant-body-y) + 0.18rem)) scaleY(0.84) rotate(-2deg);
transform: translate(var(--agent-assistant-body-x), calc(var(--agent-assistant-body-y) + 0.18rem)) scaleY(0.84)
rotate(-2deg);
}
20%,
48%,
76% {
transform: translate(var(--agent-assistant-body-x), calc(var(--agent-assistant-body-y) - 0.1rem)) scaleY(1.08) rotate(4deg);
transform: translate(var(--agent-assistant-body-x), calc(var(--agent-assistant-body-y) - 0.1rem)) scaleY(1.08)
rotate(4deg);
}
29%,
57%,
85% {
transform: translate(var(--agent-assistant-body-x), calc(var(--agent-assistant-body-y) + 0.08rem)) scaleY(0.94) rotate(-3deg);
transform: translate(var(--agent-assistant-body-x), calc(var(--agent-assistant-body-y) + 0.08rem)) scaleY(0.94)
rotate(-3deg);
}
}
@@ -2334,9 +2454,9 @@ defineExpose({
.agent-assistant-fab__bubbles {
gap: 0.38rem;
inline-size: min(9.6rem, calc(100vw - 5.6rem));
inline-size: min(16.5rem, calc(100vw - 5.6rem));
inset-inline-end: 2.35rem;
max-block-size: min(18rem, calc(100vh - 9.2rem));
max-block-size: min(30rem, calc(100vh - 9.2rem));
}
.agent-assistant-fab__bubble {
@@ -2344,12 +2464,12 @@ defineExpose({
padding-inline: 0.72rem 1.62rem;
}
.agent-assistant-fab__bubble strong {
font-size: 0.8rem;
.agent-assistant-fab__bubble-title {
font-size: 1.05rem;
}
.agent-assistant-fab__bubble span {
font-size: 0.68rem;
.agent-assistant-fab__bubble > span {
font-size: 1rem;
}
.agent-assistant-fab__trigger {

View File

@@ -46,17 +46,18 @@ const getImgUrl = computed(() => {
<template>
<VHover>
<template #default="hover">
<VCard
v-bind="hover.props"
:height="props.height"
:width="props.width"
class="ring-gray-500"
:class="{
'transition transform-cpu duration-300 -translate-y-1': hover.isHovering,
'ring-1': imageLoaded,
}"
@click="goPlay"
>
<!-- Hover 命中区域保持静止避免卡片上浮后底边反复触发 mouseleave -->
<div v-bind="hover.props" class="backdrop-card-hover-area">
<VCard
:height="props.height"
:width="props.width"
class="app-hover-lift-card ring-gray-500"
:class="{
'app-hover-lift-card--hovering': hover.isHovering,
'ring-1': imageLoaded,
}"
@click="goPlay"
>
<template #image>
<VImg :src="getImgUrl" aspect-ratio="2/3" cover @load="imageLoadHandler" @error="imageErrorHandler">
<template #placeholder>
@@ -86,7 +87,14 @@ const getImgUrl = computed(() => {
color="success"
/>
</div>
</VCard>
</VCard>
</div>
</template>
</VHover>
</template>
<style scoped>
.backdrop-card-hover-area {
inline-size: 100%;
}
</style>

View File

@@ -73,16 +73,16 @@ async function deleteDownload() {
<template>
<VHover>
<template #default="hover">
<VCard
v-if="cardState"
v-bind="hover.props"
:key="props.info?.hash"
class="downloading-card app-surface flex flex-col h-full overflow-hidden"
:class="{
'transition transform-cpu duration-300 -translate-y-1': hover.isHovering,
}"
min-height="150"
>
<!-- Hover 命中区域保持静止避免卡片上浮后底边反复触发 mouseleave -->
<div v-if="cardState" v-bind="hover.props" class="downloading-card-hover-area h-full">
<VCard
:key="props.info?.hash"
class="downloading-card app-hover-lift-card app-surface flex flex-col h-full overflow-hidden"
:class="{
'app-hover-lift-card--hovering': hover.isHovering,
}"
min-height="150"
>
<template #image>
<VImg
:src="props.info?.media.image"
@@ -130,7 +130,8 @@ async function deleteDownload() {
<VBtn color="error" icon="mdi-trash-can-outline" @click="deleteDownload" />
</VCardActions>
</div>
</VCard>
</VCard>
</div>
</template>
</VHover>
</template>
@@ -138,6 +139,10 @@ async function deleteDownload() {
<style lang="scss" scoped>
/* stylelint-disable selector-pseudo-class-no-unknown */
.downloading-card-hover-area {
inline-size: 100%;
}
.downloading-card-image {
block-size: 100%;
}

View File

@@ -156,15 +156,17 @@ onMounted(async () => {
<template>
<VHover>
<template #default="hover">
<VCard
v-bind="hover.props"
:height="props.height"
:width="props.width"
:class="{
'transition transform-cpu duration-300 -translate-y-1': hover.isHovering,
}"
@click="goPlay"
>
<!-- Hover 命中区域保持静止避免卡片上浮后底边反复触发 mouseleave -->
<div v-bind="hover.props" class="library-card-hover-area">
<VCard
:height="props.height"
:width="props.width"
class="app-hover-lift-card"
:class="{
'app-hover-lift-card--hovering': hover.isHovering,
}"
@click="goPlay"
>
<template #image>
<canvas ref="canvasRef" width="640" height="360" class="w-full h-full hidden" />
<VImg :src="imgUrl" aspect-ratio="2/3" cover @load="imageLoadHandler" @error="imageErrorHandler">
@@ -184,7 +186,14 @@ onMounted(async () => {
</template>
</VImg>
</template>
</VCard>
</VCard>
</div>
</template>
</VHover>
</template>
<style scoped>
.library-card-hover-area {
inline-size: 100%;
}
</style>

View File

@@ -498,9 +498,9 @@ onBeforeUnmount(() => {
<VCard
:height="props.height"
:width="props.width"
class="outline-none ring-gray-500 media-card"
class="app-hover-lift-card outline-none ring-gray-500 media-card"
:class="{
'transition transform-cpu duration-300 -translate-y-1': hover.isHovering,
'app-hover-lift-card--hovering': hover.isHovering,
'ring-1': isImageLoaded,
}"
@click.stop="goMediaDetail(hover.isHovering ?? false)"

View File

@@ -75,15 +75,17 @@ function goPersonDetail() {
<template>
<VHover>
<template #default="hover">
<VCard
v-bind="hover.props"
:height="personProps.height"
:width="personProps.width"
:class="{
'transition transform-cpu duration-300 -translate-y-1': hover.isHovering,
}"
@click.stop="goPersonDetail"
>
<!-- Hover 命中区域保持静止避免卡片上浮后底边反复触发 mouseleave -->
<div v-bind="hover.props" class="person-card-hover-area">
<VCard
:height="personProps.height"
:width="personProps.width"
class="app-hover-lift-card"
:class="{
'app-hover-lift-card--hovering': hover.isHovering,
}"
@click.stop="goPersonDetail"
>
<div class="person-card relative cursor-pointer ring-gray-700">
<div style="padding-block-end: 150%">
<div class="absolute inset-0 flex h-full w-full flex-col items-center p-2">
@@ -107,12 +109,17 @@ function goPersonDetail() {
</div>
</div>
</div>
</VCard>
</VCard>
</div>
</template>
</VHover>
</template>
<style lang="scss" scoped>
.person-card-hover-area {
inline-size: 100%;
}
.person-card {
background-image: linear-gradient(
45deg,

View File

@@ -230,16 +230,17 @@ onUnmounted(() => {
<div>
<VHover>
<template #default="hover">
<VCard
v-bind="hover.props"
:width="props.width"
:height="props.height"
@click="showPluginDetail"
class="flex flex-col h-full"
:class="{
'transition transform-cpu duration-300 -translate-y-1': hover.isHovering,
}"
>
<!-- Hover 命中区域保持静止避免卡片上浮后底边反复触发 mouseleave -->
<div v-bind="hover.props" class="plugin-app-card-hover-area h-full">
<VCard
:width="props.width"
:height="props.height"
@click="showPluginDetail"
class="app-hover-lift-card flex flex-col h-full"
:class="{
'app-hover-lift-card--hovering': hover.isHovering,
}"
>
<div
class="flex-grow"
:style="`background: linear-gradient(rgba(0, 0, 0, 0.6) 0%, rgba(0, 0, 0, 0.5) 100%), linear-gradient(${backgroundColor} 0%, ${backgroundColor} 100%)`"
@@ -325,13 +326,18 @@ onUnmounted(() => {
</IconBtn>
</div>
</VCardText>
</VCard>
</VCard>
</div>
</template>
</VHover>
</div>
</template>
<style scoped>
.plugin-app-card-hover-area {
inline-size: 100%;
}
.plugin-app-card__tags-section {
display: flex;
overflow: hidden;

View File

@@ -567,19 +567,19 @@ watch(
<!-- 插件卡片 -->
<VHover>
<template #default="hover">
<VCard
v-if="isVisible"
v-bind="hover.props"
:width="props.width"
:height="props.height"
@click="handleCardClick"
class="flex flex-col h-full"
:class="{
'transition transform-cpu duration-300 -translate-y-1': hover.isHovering && !props.sortable,
'cursor-move': props.sortable,
}"
:ripple="!props.sortable"
>
<!-- Hover 命中区域保持静止避免卡片上浮后底边反复触发 mouseleave -->
<div v-if="isVisible" v-bind="hover.props" class="plugin-card-hover-area h-full">
<VCard
:width="props.width"
:height="props.height"
@click="handleCardClick"
class="app-hover-lift-card flex flex-col h-full"
:class="{
'app-hover-lift-card--hovering': hover.isHovering && !props.sortable,
'cursor-move': props.sortable,
}"
:ripple="!props.sortable"
>
<div
class="flex-grow"
:style="`background: linear-gradient(rgba(0, 0, 0, 0.6) 0%, rgba(0, 0, 0, 0.5) 100%), linear-gradient(${backgroundColor} 0%, ${backgroundColor} 100%)`"
@@ -669,7 +669,8 @@ watch(
<div v-if="props.plugin?.has_update" class="me-n3 absolute top-0 right-5">
<VIcon icon="mdi-new-box" class="text-white" />
</div>
</VCard>
</VCard>
</div>
</template>
</VHover>
@@ -677,6 +678,10 @@ watch(
</template>
<style lang="scss" scoped>
.plugin-card-hover-area {
inline-size: 100%;
}
.card-cover-blurred::before {
position: absolute;
/* stylelint-disable-next-line property-no-vendor-prefix */

View File

@@ -211,20 +211,21 @@ const dropdownItems = ref([
<!-- 文件夹卡片 -->
<VHover>
<template #default="hover">
<VCard
v-bind="hover.props"
:ripple="false"
:width="props.width"
:height="props.height"
min-height="8.5rem"
@click="handleCardClick"
class="plugin-folder-card h-full"
:class="{
'plugin-folder-card--mobile': display.mobile,
'plugin-folder-card--hover': hover.isHovering && !props.sortable,
'plugin-folder-card--sortable': props.sortable,
}"
>
<!-- Hover 命中区域保持静止避免卡片上浮后底边反复触发 mouseleave -->
<div v-bind="hover.props" class="plugin-folder-card-hover-area h-full">
<VCard
:ripple="false"
:width="props.width"
:height="props.height"
min-height="8.5rem"
@click="handleCardClick"
class="plugin-folder-card app-hover-lift-card h-full"
:class="{
'plugin-folder-card--mobile': display.mobile,
'plugin-folder-card--hover': hover.isHovering && !props.sortable,
'plugin-folder-card--sortable': props.sortable,
}"
>
<template v-if="backgroundImage" #image>
<VImg :src="backgroundImage" cover position="top"> </VImg>
</template>
@@ -288,25 +289,29 @@ const dropdownItems = ref([
</VMenu>
</div>
</div>
</VCard>
</VCard>
</div>
</template>
</VHover>
</div>
</template>
<style lang="scss" scoped>
.plugin-folder-card-hover-area {
inline-size: 100%;
}
.plugin-folder-card {
position: relative;
overflow: hidden;
cursor: pointer;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
&--sortable {
cursor: move;
}
&--hover {
transform: translateY(-4px);
transform: translate3d(0, -0.25rem, 0);
}
&__bg {

View File

@@ -47,16 +47,17 @@ async function goPlay(isHovering: boolean | null = false) {
<template>
<VHover>
<template #default="hover">
<VCard
v-bind="hover.props"
:height="props.height"
:width="props.width"
class="outline-none ring-gray-500"
:class="{
'transition transform-cpu duration-300 -translate-y-1': hover.isHovering,
'ring-1': isImageLoaded,
}"
>
<!-- Hover 命中区域保持静止避免卡片上浮后底边反复触发 mouseleave -->
<div v-bind="hover.props" class="poster-card-hover-area">
<VCard
:height="props.height"
:width="props.width"
class="app-hover-lift-card outline-none ring-gray-500"
:class="{
'app-hover-lift-card--hovering': hover.isHovering,
'ring-1': isImageLoaded,
}"
>
<VImg
aspect-ratio="2/3"
:src="getImgUrl"
@@ -93,7 +94,14 @@ async function goPlay(isHovering: boolean | null = false) {
{{ props.media?.title }}
</h1>
</VCardText>
</VCard>
</VCard>
</div>
</template>
</VHover>
</template>
<style scoped>
.poster-card-hover-area {
inline-size: 100%;
}
</style>

View File

@@ -239,25 +239,27 @@ onMounted(() => {
<template>
<div>
<VCard
class="site-card relative h-full flex flex-col overflow-hidden group transition-all duration-300"
:class="[
cardProps.site?.is_active ? '' : 'opacity-70',
{
'border-error': statColor === 'error',
'border-warning': statColor === 'warning',
'border-success': statColor === 'success',
'cursor-pointer hover:-translate-y-1': !cardProps.sortable,
'cursor-move': cardProps.sortable,
'site-card--sortable': cardProps.sortable,
},
]"
:ripple="false"
variant="flat"
elevation="0"
:hover="!cardProps.sortable"
@click="handleCardClick"
>
<!-- Hover 命中区域保持静止避免卡片上浮后底边反复触发 mouseleave -->
<div class="site-card-hover-area h-full">
<VCard
class="site-card app-hover-lift-card relative h-full flex flex-col overflow-hidden group"
:class="[
cardProps.site?.is_active ? '' : 'opacity-70',
{
'border-error': statColor === 'error',
'border-warning': statColor === 'warning',
'border-success': statColor === 'success',
'cursor-pointer site-card--hoverable': !cardProps.sortable,
'cursor-move': cardProps.sortable,
'site-card--sortable': cardProps.sortable,
},
]"
:ripple="false"
variant="flat"
elevation="0"
:hover="!cardProps.sortable"
@click="handleCardClick"
>
<!-- 装饰性状态指示器 -->
<div v-if="cardProps.site?.is_active" class="site-status-indicator" :class="statColor"></div>
@@ -419,11 +421,20 @@ onMounted(() => {
</VMenu>
</VBtn>
</VSheet>
</VCard>
</VCard>
</div>
</div>
</template>
<style scoped>
.site-card-hover-area {
inline-size: 100%;
}
.site-card-hover-area:hover .site-card--hoverable {
transform: translate3d(0, -0.25rem, 0);
}
.site-status-indicator {
position: absolute;
z-index: 1;
@@ -455,7 +466,7 @@ onMounted(() => {
}
/* 站点卡片悬停时状态指示器变化 */
.site-card:not(.site-card--sortable):hover .site-status-indicator {
.site-card-hover-area:hover .site-card:not(.site-card--sortable) .site-status-indicator {
block-size: 2px;
opacity: 0.8;
}
@@ -644,7 +655,7 @@ onMounted(() => {
visibility: hidden;
}
.site-card:hover .site-card-actions {
.site-card-hover-area:hover .site-card-actions {
opacity: 1;
transform: translateX(0);
visibility: visible;

View File

@@ -404,26 +404,27 @@ function handleCardClick() {
<div>
<VHover>
<template #default="hover">
<div
class="subscribe-card-shell w-full h-full relative"
:class="{
'transition transform-cpu duration-300 -translate-y-1': hover.isHovering && !props.sortable,
'outline-dotted outline-pink-500 outline-2': props.batchMode && props.selected,
}"
>
<VCard
v-bind="hover.props"
:key="props.media?.id"
class="flex flex-col h-full overflow-hidden"
<!-- Hover 命中区域保持静止避免卡片上浮后底边反复触发 mouseleave -->
<div v-bind="hover.props" class="subscribe-card-hover-area w-full h-full">
<div
class="subscribe-card-shell app-hover-lift-card w-full h-full relative"
:class="{
'subscribe-card-paused': subscribeState === 'S',
'subscribe-card-pending-tint': subscribeState === 'P',
'cursor-move': props.sortable,
'app-hover-lift-card--hovering': hover.isHovering && !props.sortable,
'outline-dotted outline-pink-500 outline-2': props.batchMode && props.selected,
}"
min-height="150"
@click="handleCardClick"
:ripple="!props.batchMode && !props.sortable"
>
<VCard
:key="props.media?.id"
class="flex flex-col h-full overflow-hidden"
:class="{
'subscribe-card-paused': subscribeState === 'S',
'subscribe-card-pending-tint': subscribeState === 'P',
'cursor-move': props.sortable,
}"
min-height="150"
@click="handleCardClick"
:ripple="!props.batchMode && !props.sortable"
>
<div
v-if="bestVersionBadge && imageLoaded"
class="best-version-badge"
@@ -568,13 +569,18 @@ function handleCardClick() {
/>
</div>
</div>
</VCard>
</VCard>
</div>
</div>
</template>
</VHover>
</div>
</template>
<style lang="scss" scoped>
.subscribe-card-hover-area {
inline-size: 100%;
}
.subscribe-card-background {
background-image: linear-gradient(180deg, rgba(31, 41, 55, 47%) 0%, rgb(31, 41, 55) 100%);
}

View File

@@ -93,16 +93,17 @@ function doDelete() {
<div class="h-full">
<VHover>
<template #default="hover">
<div
class="w-full h-full overflow-hidden"
:class="{
'transition transform-cpu duration-300 -translate-y-1': hover.isHovering,
}"
>
<!-- Hover 命中区域保持静止避免卡片上浮后底边反复触发 mouseleave -->
<div v-bind="hover.props" class="subscribe-share-card-hover-area w-full h-full">
<div
class="app-hover-lift-card w-full h-full overflow-hidden"
:class="{
'app-hover-lift-card--hovering': hover.isHovering,
}"
>
<VCard
v-bind="hover.props"
:key="props.media?.id"
class="flex flex-col h-full"
class="app-hover-lift-card flex flex-col h-full"
min-height="150"
@click="showForkSubscribe"
>
@@ -155,13 +156,18 @@ function doDelete() {
{{ dateText }}
</VCardText>
</div>
</VCard>
</VCard>
</div>
</div>
</template>
</VHover>
</div>
</template>
<style lang="scss" scoped>
.subscribe-share-card-hover-area {
inline-size: 100%;
}
.subscribe-card-background {
background-image: linear-gradient(180deg, rgba(31, 41, 55, 47%) 0%, rgb(31, 41, 55) 100%);
}

View File

@@ -100,12 +100,13 @@ watch(
</script>
<template>
<div class="h-full">
<!-- Hover 命中区域保持静止避免卡片上浮后底边反复触发 mouseleave -->
<div class="subtitle-card-hover-area h-full">
<VCard
:width="props.width || '100%'"
:variant="isDownloaded ? 'outlined' : 'flat'"
@click="handleAddDownload"
class="h-full cursor-pointer transition-transform hover:-translate-y-1 duration-300 d-flex flex-column overflow-hidden subtitle-card"
class="app-hover-lift-card h-full cursor-pointer d-flex flex-column overflow-hidden subtitle-card"
:class="{ 'border-success border-2 opacity-85': isDownloaded }"
hover
>
@@ -203,11 +204,19 @@ watch(
</template>
<style scoped>
.subtitle-card-hover-area {
inline-size: 100%;
}
.subtitle-card-hover-area:hover .subtitle-card {
transform: translate3d(0, -0.25rem, 0);
}
.subtitle-card {
border: 1px solid transparent;
}
.subtitle-card:hover {
.subtitle-card-hover-area:hover .subtitle-card {
border-color: rgba(var(--v-theme-primary), 0.3);
}
</style>

View File

@@ -99,10 +99,11 @@ watch(
</script>
<template>
<div class="w-100">
<!-- Hover 命中区域保持静止避免列表项上浮后底边反复触发 mouseleave -->
<div class="subtitle-item-hover-area w-100">
<VListItem
:value="subtitle?.enclosure"
class="pa-3 mb-2 rounded subtitle-item transition-all duration-300 hover:-translate-y-1 overflow-hidden"
class="app-hover-lift-card pa-3 mb-2 rounded subtitle-item overflow-hidden"
:class="{ 'border-start border-success border-3 opacity-85': isDownloaded }"
@click="handleAddDownload"
>
@@ -206,11 +207,19 @@ watch(
</template>
<style scoped>
.subtitle-item-hover-area {
inline-size: 100%;
}
.subtitle-item-hover-area:hover .subtitle-item {
transform: translate3d(0, -0.25rem, 0);
}
.subtitle-item {
border: 1px solid transparent;
}
.subtitle-item:hover {
.subtitle-item-hover-area:hover .subtitle-item {
border-color: rgba(var(--v-theme-primary), 0.3);
}
</style>

View File

@@ -146,12 +146,13 @@ watch(
</script>
<template>
<div class="h-full">
<!-- Hover 命中区域保持静止避免卡片上浮后底边反复触发 mouseleave -->
<div class="torrent-card-hover-area h-full">
<VCard
:width="props.width || '100%'"
:variant="isDownloaded ? 'outlined' : 'flat'"
@click="handleAddDownload(props.torrent)"
class="h-full cursor-pointer transition-transform hover:-translate-y-1 duration-300 d-flex flex-column overflow-hidden torrent-card"
class="app-hover-lift-card h-full cursor-pointer d-flex flex-column overflow-hidden torrent-card"
:class="{ 'border-success border-2 opacity-85': isDownloaded }"
hover
>
@@ -316,12 +317,20 @@ watch(
inset-inline-end: 0;
}
.torrent-card-hover-area {
inline-size: 100%;
}
.torrent-card-hover-area:hover .torrent-card {
transform: translate3d(0, -0.25rem, 0);
}
/* 卡片悬停效果 */
.torrent-card {
border: 1px solid transparent;
}
.torrent-card:hover {
.torrent-card-hover-area:hover .torrent-card {
border-color: rgba(var(--v-theme-primary), 0.3);
}

View File

@@ -115,10 +115,11 @@ watch(
</script>
<template>
<div class="w-100">
<!-- Hover 命中区域保持静止避免列表项上浮后底边反复触发 mouseleave -->
<div class="torrent-item-hover-area w-100">
<VListItem
:value="props.torrent?.torrent_info?.enclosure"
class="pa-3 mb-2 rounded torrent-item transition-all duration-300 hover:-translate-y-1 overflow-hidden"
class="app-hover-lift-card pa-3 mb-2 rounded torrent-item overflow-hidden"
:class="{ 'border-start border-success border-3 opacity-85': isDownloaded }"
@click="handleAddDownload"
>
@@ -262,11 +263,19 @@ watch(
inset-inline-end: 0;
}
.torrent-item-hover-area {
inline-size: 100%;
}
.torrent-item-hover-area:hover .torrent-item {
transform: translate3d(0, -0.25rem, 0);
}
.torrent-item {
border: 1px solid transparent;
}
.torrent-item:hover {
.torrent-item-hover-area:hover .torrent-item {
border-color: rgba(var(--v-theme-primary), 0.3);
}

View File

@@ -127,14 +127,16 @@ onMounted(() => {
})
</script>
<template>
<VCard
:class="[
'transition-transform duration-300 hover:-translate-y-1',
!props.user.is_active ? 'opacity-85 bg-surface-lighten-1' : '',
]"
class="user-card flex flex-column h-full"
@click="editUser"
>
<!-- Hover 命中区域保持静止避免卡片上浮后底边反复触发 mouseleave -->
<div class="user-card-hover-area h-full">
<VCard
:class="[
'app-hover-lift-card',
!props.user.is_active ? 'opacity-85 bg-surface-lighten-1' : '',
]"
class="user-card flex flex-column h-full"
@click="editUser"
>
<div class="user-card__body flex-grow flex-grow-1">
<!-- 用户头像和基本信息 -->
<VCardItem :class="[user.is_superuser ? 'admin-header' : '']">
@@ -302,10 +304,19 @@ onMounted(() => {
</div>
</VCardText>
</div>
</VCard>
</VCard>
</div>
</template>
<style scoped>
.user-card-hover-area {
inline-size: 100%;
}
.user-card-hover-area:hover .user-card {
transform: translate3d(0, -0.25rem, 0);
}
.user-card {
block-size: 100%;
}

View File

@@ -95,17 +95,18 @@ function doDelete() {
<div class="h-full">
<VHover>
<template #default="hover">
<VCard
v-bind="hover.props"
:key="props.workflow?.id"
class="workflow-share-card flex flex-col h-full cursor-pointer overflow-hidden"
:class="{
'workflow-share-card--hovering': hover.isHovering,
}"
min-height="150"
:style="{ background: gradientStyle }"
@click="showForkWorkflow"
>
<!-- Hover 命中区域保持静止避免卡片上浮后底边反复触发 mouseleave -->
<div v-bind="hover.props" class="workflow-share-card-hover-area h-full">
<VCard
:key="props.workflow?.id"
class="workflow-share-card app-hover-lift-card flex flex-col h-full cursor-pointer overflow-hidden"
:class="{
'app-hover-lift-card--hovering': hover.isHovering,
}"
min-height="150"
:style="{ background: gradientStyle }"
@click="showForkWorkflow"
>
<div class="h-full flex flex-col">
<VCardText class="flex items-center pa-3 pb-1 grow">
<div class="flex flex-col justify-center w-full">
@@ -134,20 +135,16 @@ function doDelete() {
{{ dateText }}
</VCardText>
</div>
</VCard>
</VCard>
</div>
</template>
</VHover>
</div>
</template>
<style lang="scss" scoped>
// 阴影需要落在实际卡片上,不能被额外的 overflow 容器裁掉。
.workflow-share-card {
transition: transform 0.3s ease, box-shadow 0.2s ease;
transform: translateZ(0);
.workflow-share-card-hover-area {
inline-size: 100%;
}
.workflow-share-card--hovering {
transform: translate3d(0, -0.25rem, 0);
}
</style>

View File

@@ -220,14 +220,15 @@ const resolveProgress = (item: Workflow) => {
<template>
<div class="h-full">
<VHover v-slot="hover">
<VCard
v-bind="hover.props"
class="mx-auto h-full"
@click="handleFlow(workflow)"
:ripple="false"
:loading="loading"
:class="{ 'transition transform-cpu duration-300 -translate-y-1': hover.isHovering }"
>
<!-- Hover 命中区域保持静止避免卡片上浮后底边反复触发 mouseleave -->
<div v-bind="hover.props" class="workflow-task-card-hover-area h-full">
<VCard
class="app-hover-lift-card mx-auto h-full"
@click="handleFlow(workflow)"
:ripple="false"
:loading="loading"
:class="{ 'app-hover-lift-card--hovering': hover.isHovering }"
>
<VCardItem
class="px-2 py-2"
:style="{
@@ -367,7 +368,14 @@ const resolveProgress = (item: Workflow) => {
</div>
</div>
</VCardText>
</VCard>
</VCard>
</div>
</VHover>
</div>
</template>
<style scoped>
.workflow-task-card-hover-area {
inline-size: 100%;
}
</style>

View File

@@ -1,7 +1,7 @@
<script setup lang="ts">
import type { SystemNotification } from '@/api/types'
import api from '@/api'
import { clearUnreadMessages } from '@/utils/badge'
import { appUnreadMessageCount, clearUnreadMessages } from '@/utils/badge'
import { emitAgentAssistantNotificationBubble } from '@/utils/agentAssistantBubble'
import { formatDateDifference } from '@core/utils/formatters'
import { useBackground } from '@/composables/useBackground'
@@ -13,7 +13,6 @@ type NotificationDisplayItem =
| { kind: 'section'; key: string; title: string; count: number }
| { kind: 'notification'; key: string; notification: SystemNotification }
type NotificationClearScope = 'all' | 'system' | 'media'
type NotificationClearBefore = Record<NotificationClearScope, number>
const { t } = useI18n()
const { useDelayedSSE } = useBackground()
@@ -23,9 +22,6 @@ const createConfirm = useConfirm()
const PAGE_SIZE = 20
// 虚拟滚动的默认通知项高度,展开后的实际高度由 VVirtualScroll 的 itemRef 动态测量。
const NOTIFICATION_ITEM_HEIGHT = 136
const MAX_FILTERED_PAGES_PER_LOAD = 5
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)
@@ -35,10 +31,12 @@ 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 hasBadgeUnreadMessages = computed(() => appUnreadMessageCount.value > 0)
const canMarkAllAsRead = computed(() => hasUnreadNotifications.value || hasBadgeUnreadMessages.value)
const hasUnreadMessageIndicator = computed(() => hasNewMessage.value || canMarkAllAsRead.value)
const notificationDisplayList = computed(() => buildNotificationDisplayList(notificationList.value))
const notificationClearCounts = computed(() => getNotificationClearCounts())
const notificationClearOptions = computed(() => [
@@ -65,60 +63,6 @@ const notificationClearOptions = computed(() => [
},
])
/** 生成默认清理时间,分别记录全部、系统消息和媒体消息的清理范围。 */
function createDefaultNotificationClearBefore(): NotificationClearBefore {
return {
all: 0,
system: 0,
media: 0,
}
}
/** 规范化清理时间,兼容旧版本只存一个数字的本地存储格式。 */
function normalizeNotificationClearBefore(value: unknown): NotificationClearBefore {
const clearBefore = createDefaultNotificationClearBefore()
if (typeof value === 'number' && Number.isFinite(value)) {
clearBefore.all = value
return clearBefore
}
if (!value || typeof value !== 'object') return clearBefore
const scopes: NotificationClearScope[] = ['all', 'system', 'media']
scopes.forEach(scope => {
const scopeValue = Number((value as Partial<NotificationClearBefore>)[scope] || 0)
clearBefore[scope] = Number.isFinite(scopeValue) ? scopeValue : 0
})
return clearBefore
}
/** 从本地存储读取通知清理时间戳,用于过滤已清理的历史通知。 */
function readNotificationClearBefore() {
if (typeof localStorage === 'undefined') return createDefaultNotificationClearBefore()
const storedValue = localStorage.getItem(NOTIFICATION_CLEAR_BEFORE_STORAGE_KEY)
if (!storedValue) return createDefaultNotificationClearBefore()
try {
return normalizeNotificationClearBefore(JSON.parse(storedValue))
} catch {
return normalizeNotificationClearBefore(Number(storedValue))
}
}
/** 写入指定范围的通知清理时间戳,使清理结果在刷新后仍然生效。 */
function writeNotificationClearBefore(scope: NotificationClearScope, value: number) {
notificationClearBefore.value = {
...notificationClearBefore.value,
[scope]: value,
}
if (typeof localStorage === 'undefined') return
localStorage.setItem(NOTIFICATION_CLEAR_BEFORE_STORAGE_KEY, JSON.stringify(notificationClearBefore.value))
}
/** 将通知备注统一转换成稳定字符串,用于生成去重 key。 */
function normalizeNote(note: SystemNotification['note']) {
if (note == null) return ''
@@ -185,16 +129,6 @@ function parseNotificationTime(value: string) {
return new Date(value.includes('T') ? value : value.replaceAll(/-/g, '/')).getTime() || 0
}
/** 判断历史通知是否早于本地清理时间,需要从列表中过滤。 */
function isClearedHistoryNotification(item: SystemNotification) {
const scope = getNotificationClearScope(item)
const clearBefore = Math.max(notificationClearBefore.value.all, notificationClearBefore.value[scope])
if (!clearBefore) return false
const notificationTime = parseNotificationTime(getNotificationTime(item))
return notificationTime > 0 && notificationTime <= clearBefore
}
/** 按通知时间倒序重排当前列表。 */
function sortNotifications() {
notificationList.value = [...notificationList.value].sort(
@@ -284,8 +218,8 @@ function rebuildExpandedNotificationKeys() {
/** 列表内容变化后同步未读红点和应用角标状态。 */
function syncUnreadStateAfterListChange() {
hasNewMessage.value = hasUnreadNotifications.value
if (!hasUnreadNotifications.value) void clearUnreadMessages()
// 只用当前列表更新通知中心红点,应用 badge 数量由 badge 工具维护。
hasNewMessage.value = canMarkAllAsRead.value
}
/** 统计当前已加载通知中各清理范围的数量,用于菜单展示和禁用空操作。 */
@@ -333,33 +267,12 @@ function getClearSuccessText(scope: NotificationClearScope) {
return t('notification.clearAllSuccess')
}
/** 通过后端接口清理全部通知历史,兼容新旧后端可能暴露的清理路径。 */
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(scope: NotificationClearScope) {
if (scope !== 'all') return true
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
}
const result: { [key: string]: any } = await api.delete('message/notification', {
params: { scope },
})
return result?.success !== false
}
/** 确认并清空通知中心历史,同时同步清理未读角标。 */
@@ -382,7 +295,6 @@ async function clearNotifications(scope: NotificationClearScope) {
return
}
writeNotificationClearBefore(scope, Date.now())
removeNotificationsByScope(scope)
if (scope === 'all') {
await clearUnreadMessages()
@@ -396,6 +308,46 @@ async function clearNotifications(scope: NotificationClearScope) {
}
}
/** 按页请求历史通知,并合并到当前虚拟列表。 */
async function fetchNotificationPage() {
const items = (await api.get('message/notification', {
params: {
page: page.value,
count: PAGE_SIZE,
},
})) as SystemNotification[]
if (items.length === 0) {
hasMore.value = false
return items
}
mergeNotifications(items, { read: true })
page.value += 1
hasMore.value = items.length >= PAGE_SIZE
return items
}
/** 刷新通知中心首屏数据,确保点开红点时能立即看到后端已有的新消息。 */
async function refreshNotificationsOnOpen() {
if (loading.value) return
try {
loading.value = true
page.value = 1
hasMore.value = true
notificationKeys.clear()
notificationList.value = compactNotifications(notificationList.value)
rebuildNotificationKeys()
await fetchNotificationPage()
} catch (error) {
console.error('刷新通知失败:', error)
} finally {
loading.value = false
}
}
/** 按页加载历史通知,并合并到当前虚拟列表。 */
async function loadNotifications({ done }: { done: (status: 'ok' | 'empty' | 'error') => void }) {
if (loading.value) {
@@ -410,28 +362,11 @@ async function loadNotifications({ done }: { done: (status: 'ok' | 'empty' | 'er
try {
loading.value = true
let accepted = false
let loadedPages = 0
// 清理后的分页里可能连续出现已被本地过滤的历史消息,循环跳过这些空页。
while (hasMore.value && !accepted && loadedPages < MAX_FILTERED_PAGES_PER_LOAD) {
const items = (await api.get('message/notification', {
params: {
page: page.value,
count: PAGE_SIZE,
},
})) as SystemNotification[]
if (items.length === 0) {
hasMore.value = false
break
}
const visibleItems = items.filter(item => !isClearedHistoryNotification(item))
accepted = mergeNotifications(visibleItems, { read: true })
page.value += 1
loadedPages += 1
hasMore.value = items.length >= PAGE_SIZE
const items = await fetchNotificationPage()
if (items.length === 0) {
done('empty')
return
}
done(hasMore.value ? 'ok' : 'empty')
@@ -449,7 +384,6 @@ function handleMessage(event: MessageEvent) {
try {
const notification = JSON.parse(event.data) as SystemNotification
if (isClearedHistoryNotification(notification)) return
if (mergeNotifications([notification], { prepend: true, read: false })) {
hasNewMessage.value = true
@@ -462,6 +396,8 @@ function handleMessage(event: MessageEvent) {
/** 将通知列表标记为已读,并同步清理应用角标、未读红点和通知弹窗。 */
function markAllAsRead() {
if (!canMarkAllAsRead.value) return
hasNewMessage.value = false
notificationList.value.forEach(item => {
item.read = true
@@ -536,11 +472,10 @@ 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()
}
/** 切换通知正文展开状态。 */
@@ -566,6 +501,13 @@ useDelayedSSE(
maxReconnectAttempts: 3,
},
)
/** 监听通知中心展开状态,展开时主动刷新首屏通知。 */
function handleNotificationMenuVisibleChange(open: boolean) {
if (open) void refreshNotificationsOnOpen()
}
watch(appsMenu, handleNotificationMenuVisibleChange)
</script>
<template>
@@ -579,7 +521,7 @@ useDelayedSSE(
scrim
>
<template #activator="{ props }">
<VBadge v-if="hasNewMessage" dot color="error" :offset-x="5" :offset-y="5" v-bind="props">
<VBadge v-if="hasUnreadMessageIndicator" dot color="error" :offset-x="5" :offset-y="5" v-bind="props">
<IconBtn>
<VIcon icon="mdi-bell-outline" size="22" />
</IconBtn>
@@ -628,7 +570,7 @@ useDelayedSSE(
</VTooltip>
<VTooltip :text="t('notification.markRead')">
<template #activator="{ props }">
<IconBtn v-bind="props" :disabled="!hasUnreadNotifications" @click.stop="markAllAsRead">
<IconBtn v-bind="props" :disabled="!canMarkAllAsRead" @click.stop="markAllAsRead">
<VIcon icon="mdi-email-check-outline" size="20" />
</IconBtn>
</template>
@@ -652,10 +594,7 @@ useDelayedSSE(
</div>
</template>
<template #empty>
<div v-if="notificationList.length > 0" class="py-3 text-center text-caption text-medium-emphasis">
{{ t('message.noMoreData') }}
</div>
<div v-else class="notification-empty">
<div v-if="notificationList.length === 0" class="notification-empty">
<div class="notification-empty__icon">
<VIcon icon="mdi-bell-sleep-outline" size="22" />
</div>

View File

@@ -279,6 +279,7 @@ export default {
login: {
wallpapers: 'Wallpapers',
tagline: 'Your smart media library',
welcomeBack: 'Welcome Back',
copyright: '© {year} MoviePilot',
username: 'Username',
password: 'Password',

View File

@@ -278,6 +278,7 @@ export default {
login: {
wallpapers: '壁纸',
tagline: '你的智能影视媒体库',
welcomeBack: '欢迎回来',
copyright: '© {year} MoviePilot',
username: '用户名',
password: '密码',

View File

@@ -278,6 +278,7 @@ export default {
login: {
wallpapers: '壁紙',
tagline: '你的智能影視媒體庫',
welcomeBack: '歡迎回來',
copyright: '© {year} MoviePilot',
username: '用戶名',
password: '密碼',

View File

@@ -14,9 +14,14 @@ import App from '@/App.vue'
import { PerfectScrollbarPlugin } from 'vue3-perfect-scrollbar'
// 4. 其他插件和功能模块
import Toast from 'vue-toastification'
import Toast, { TYPE, type PluginOptions } from 'vue-toastification'
import ConfirmDialog from '@/composables/useConfirm'
import { configureApexChartsTheme } from '@/utils/apexCharts'
import {
canUseAgentAssistantBubble,
emitAgentAssistantToastBubble,
type AgentAssistantBubbleVariant,
} from '@/utils/agentAssistantBubble'
// 5. 注册自定义组件
import DialogCloseBtn from '@/@core/components/DialogCloseBtn.vue'
@@ -29,6 +34,8 @@ import '@/styles/main.scss'
// 7. 状态恢复插件
import stateRestorePlugin from '@/plugins/stateRestore'
type ToastFilterPayload = Parameters<NonNullable<PluginOptions['filterBeforeCreate']>>[0]
function runWhenBrowserIdle(callback: () => void, timeout = 1500) {
const requestIdle = globalThis.requestIdleCallback
if (requestIdle) {
@@ -53,6 +60,61 @@ function loadRemoteComponentsAfterLogin() {
})
}
function shouldUseAgentAssistantToastBubble() {
const settings = pinia.state.value.globalSettings
if (!settings?.initialized) return false
return (
settings.data?.AI_AGENT_ENABLE === true &&
settings.data?.AI_AGENT_HIDE_ENTRY !== true &&
canUseAgentAssistantBubble()
)
}
function getAgentAssistantToastVariant(type?: ToastFilterPayload['type']): AgentAssistantBubbleVariant {
const variants: Record<string, AgentAssistantBubbleVariant> = {
[TYPE.DEFAULT]: 'default',
[TYPE.ERROR]: 'error',
[TYPE.INFO]: 'info',
[TYPE.SUCCESS]: 'success',
[TYPE.WARNING]: 'warning',
}
return variants[type || TYPE.DEFAULT] || 'default'
}
function getToastBubbleDuration(type?: ToastFilterPayload['type'], timeout?: ToastFilterPayload['timeout']) {
if (typeof timeout === 'number') return timeout
if (timeout === false) return undefined
return type === TYPE.ERROR || type === TYPE.WARNING ? 7000 : 4500
}
function getToastTextContent(content: ToastFilterPayload['content']) {
if (typeof content === 'string') return content
// 组件型 toast 可能包含操作按钮或复杂布局,无法可靠转成气泡文本时继续使用原生 toast。
return ''
}
function routeToastToAgentAssistantBubble(toast: ToastFilterPayload) {
const text = getToastTextContent(toast.content)
if (!text || !shouldUseAgentAssistantToastBubble()) return toast
const variant = getAgentAssistantToastVariant(toast.type)
emitAgentAssistantToastBubble({
id: `toast-${String(toast.id)}`,
kind: 'toast',
variant,
text,
duration: getToastBubbleDuration(toast.type, toast.timeout),
keepOpen: toast.timeout === false,
})
return false
}
let remoteComponentsInitialized = false
const AsyncAceEditor = defineAsyncComponent(async () => {
@@ -111,6 +173,7 @@ app
.use(Toast, {
position: 'bottom-right',
hideProgressBar: true,
filterBeforeCreate: routeToastToAgentAssistantBubble,
})
.use(ConfirmDialog)
.use(i18n)

View File

@@ -719,6 +719,13 @@ onUnmounted(() => {
<template>
<!-- 登录页面容器 -->
<div class="login-root">
<!-- 装饰性背景光晕 -->
<div class="login-bg-decor" aria-hidden="true">
<div class="login-orb login-orb--1" />
<div class="login-orb login-orb--2" />
<div class="login-orb login-orb--3" />
</div>
<!-- 顶部漂浮语言切换 -->
<VMenu v-model="langMenu" :close-on-content-click="false">
<template #activator="{ props }">
@@ -728,7 +735,7 @@ onUnmounted(() => {
<span class="ms-1">{{ SUPPORTED_LOCALES[currentLocale].title }}</span>
</VBtn>
</template>
<VCard min-width="180">
<VCard min-width="180" class="lang-menu-card">
<VList>
<VListItem
v-for="locale in locales"
@@ -749,15 +756,18 @@ onUnmounted(() => {
<!-- 登录表单 -->
<div v-if="!mfaDialog" class="auth-wrapper d-flex align-center justify-center">
<VCard
class="auth-card login-card pa-7 pa-sm-8 w-full h-full login-card--enter"
class="auth-card login-card pa-7 pa-sm-9 w-full h-full login-card--enter"
:class="{ 'glass-effect': !isTransparentTheme }"
max-width="23rem"
max-width="24rem"
flat
>
<!-- 卡片头部Logo + 标题 + -->
<!-- 卡片头部Logo + 标题 + 欢迎 -->
<div class="login-head">
<VImg :src="logo" width="68" height="68" class="login-logo" />
<div class="login-logo-wrapper">
<VImg :src="logo" width="72" height="72" class="login-logo" />
</div>
<h1 class="login-title">MoviePilot</h1>
<p class="login-subtitle">{{ t('login.welcomeBack') || 'Welcome Back' }}</p>
</div>
<VCardText class="login-body">
@@ -777,6 +787,8 @@ onUnmounted(() => {
hide-details
variant="outlined"
density="comfortable"
prepend-inner-icon="mdi-account-outline"
class="login-input"
@input="scheduleLoginAutofillSync"
@change="scheduleLoginAutofillSync"
/>
@@ -790,11 +802,13 @@ onUnmounted(() => {
name="password"
id="password"
autocomplete="current-password"
prepend-inner-icon="mdi-lock-outline"
:append-inner-icon="isPasswordVisible ? 'mdi-eye-off-outline' : 'mdi-eye-outline'"
:rules="[requiredValidator]"
hide-details
variant="outlined"
density="comfortable"
class="login-input"
@click:append-inner="isPasswordVisible = !isPasswordVisible"
@input="scheduleLoginAutofillSync"
@change="scheduleLoginAutofillSync"
@@ -809,6 +823,7 @@ onUnmounted(() => {
required
hide-details
density="compact"
class="login-checkbox"
/>
</div>
</VCol>
@@ -842,14 +857,15 @@ onUnmounted(() => {
block
variant="outlined"
color="primary"
class="mt-3"
class="mt-3 plugin-auth-btn"
:prepend-icon="provider.icon || 'mdi-login-variant'"
:loading="pluginAuthLoading && selectedAuthProvider?.id === provider.id"
rounded="lg"
@click="openPluginAuth(provider)"
>
{{ provider.name }}
</VBtn>
<VAlert v-if="errorMessage" type="error" variant="tonal" class="mt-3">
<VAlert v-if="errorMessage" type="error" variant="tonal" class="mt-4 login-alert">
{{ errorMessage }}
</VAlert>
</VCol>
@@ -865,7 +881,7 @@ onUnmounted(() => {
</VCard>
</div>
<VDialog v-model="pluginAuthDialog" max-width="520" persistent>
<VCard>
<VCard class="plugin-auth-card">
<VCardItem>
<VCardTitle>{{ selectedAuthProvider?.name }}</VCardTitle>
<template #append>
@@ -894,12 +910,15 @@ onUnmounted(() => {
</template>
<style lang="scss" scoped>
/* stylelint-disable selector-pseudo-class-no-unknown */
@use '@core/scss/pages/page-auth';
/* ===================== 布局根容器 ===================== */
.login-root {
position: relative;
display: flex;
overflow: hidden;
flex-direction: column;
align-items: center;
justify-content: center;
@@ -908,16 +927,99 @@ onUnmounted(() => {
min-block-size: 100dvh;
}
/* ===================== 装饰性背景光晕 ===================== */
.login-bg-decor {
position: absolute;
z-index: 0;
overflow: hidden;
inset: 0;
pointer-events: none;
}
.login-orb {
position: absolute;
border-radius: 50%;
will-change: transform;
}
.login-orb--1 {
animation: orb-float-1 12s ease-in-out infinite alternate;
background: rgba(var(--v-theme-primary), 0.35);
block-size: 360px;
filter: blur(60px);
inline-size: 360px;
inset-block-start: -15%;
inset-inline-end: -12%;
}
.login-orb--2 {
animation: orb-float-2 15s ease-in-out infinite alternate;
background: rgba(var(--v-theme-primary), 0.25);
block-size: 300px;
filter: blur(55px);
inline-size: 300px;
inset-block-end: -10%;
inset-inline-start: -15%;
}
.login-orb--3 {
animation: orb-float-3 10s ease-in-out infinite alternate;
background: rgba(var(--v-theme-primary), 0.15);
block-size: 220px;
filter: blur(50px);
inline-size: 220px;
inset-block-start: 50%;
inset-inline-end: 15%;
}
@keyframes orb-float-1 {
0% {
transform: translate(0, 0) scale(1);
}
100% {
transform: translate(-30px, 40px) scale(1.1);
}
}
@keyframes orb-float-2 {
0% {
transform: translate(0, 0) scale(1);
}
100% {
transform: translate(25px, -30px) scale(1.08);
}
}
@keyframes orb-float-3 {
0% {
transform: translate(0, 0) scale(1);
}
100% {
transform: translate(-20px, 20px) scale(0.92);
}
}
/* ===================== 浮动语言切换 ===================== */
.lang-switch-btn {
position: absolute;
z-index: 3;
border: 1px solid rgba(var(--v-border-color), calc(var(--v-border-opacity) * 0.6));
border-radius: 999px;
backdrop-filter: blur(10px);
background: rgba(var(--v-theme-surface), 0.55);
backdrop-filter: blur(12px) saturate(140%);
background: rgba(var(--v-theme-surface), 0.6);
inset-block-start: calc(env(safe-area-inset-top, 0px) + 16px);
inset-inline-end: calc(env(safe-area-inset-right, 0px) + 16px);
transition:
background 200ms ease,
border-color 200ms ease;
&:hover {
border-color: rgba(var(--v-theme-primary), 0.3);
background: rgba(var(--v-theme-surface), 0.85);
}
}
/* ===================== 表单容器 ===================== */
@@ -935,17 +1037,19 @@ onUnmounted(() => {
position: relative;
z-index: 1;
border: none !important;
border-radius: var(--app-surface-radius, 16px) !important;
box-shadow: var(
--app-overlay-shadow,
0 18px 42px rgba(var(--app-shadow-rgb, 0, 0, 0), 0.14),
0 6px 18px rgba(var(--app-shadow-rgb, 0, 0, 0), 0.08)
) !important;
border-radius: var(--app-surface-radius, 20px) !important;
box-shadow:
0 20px 50px rgba(var(--app-shadow-rgb, 0, 0, 0), 0.12),
0 8px 20px rgba(var(--app-shadow-rgb, 0, 0, 0), 0.06),
0 0 0 1px rgba(var(--v-theme-primary), 0.04) !important;
transition: box-shadow 300ms ease;
/* 顶部高光线,营造立体感 */
&::before {
position: absolute;
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 40%), transparent);
z-index: 1;
border-radius: inherit;
background: linear-gradient(90deg, transparent 10%, rgba(255, 255, 255, 35%) 50%, transparent 90%);
block-size: 1px;
content: '';
inset-block-start: 0;
@@ -956,8 +1060,8 @@ onUnmounted(() => {
/* 非透明主题:磨砂玻璃卡片 */
.glass-effect {
backdrop-filter: blur(24px) saturate(160%) !important;
background: rgba(var(--v-theme-surface), 0.72) !important;
backdrop-filter: blur(28px) saturate(170%) !important;
background: rgba(var(--v-theme-surface), 0.75) !important;
}
/* 深色主题上叠一条更亮的描边,区分背景 */
@@ -976,34 +1080,80 @@ onUnmounted(() => {
display: flex;
flex-direction: column;
align-items: center;
gap: 6px;
margin-block-end: 6px;
gap: 4px;
margin-block-end: 12px;
text-align: center;
}
.login-logo-wrapper {
position: relative;
display: flex;
align-items: center;
justify-content: center;
margin-block-end: 8px;
/* Logo 背后的柔光环 */
&::before {
position: absolute;
z-index: -1;
border-radius: 50%;
animation: logo-pulse 4s ease-in-out infinite;
background: radial-gradient(circle, rgba(var(--v-theme-primary), 0.2) 0%, transparent 70%);
block-size: 120px;
content: '';
inline-size: 120px;
}
}
.login-logo {
filter: drop-shadow(0 6px 16px rgba(var(--v-theme-primary), 0.35));
margin-block-end: 4px;
animation: logo-float 6s ease-in-out infinite;
filter: drop-shadow(0 8px 20px rgba(var(--v-theme-primary), 0.3));
}
@keyframes logo-float {
0%,
100% {
transform: translateY(0);
}
50% {
transform: translateY(-4px);
}
}
@keyframes logo-pulse {
0%,
100% {
opacity: 0.6;
transform: scale(1);
}
50% {
opacity: 1;
transform: scale(1.05);
}
}
.login-title {
margin: 0;
background: linear-gradient(120deg, rgb(var(--v-theme-on-surface)), rgba(var(--v-theme-primary), 1));
background: linear-gradient(135deg, rgb(var(--v-theme-on-surface)) 30%, rgba(var(--v-theme-primary), 1) 100%);
background-clip: text;
font-size: 1.8rem;
font-size: 1.85rem;
font-weight: 800;
letter-spacing: 0.025em;
letter-spacing: 0.03em;
line-height: 1.2;
-webkit-text-fill-color: transparent;
text-transform: uppercase;
}
.login-tagline {
margin: 0;
.login-subtitle {
color: rgba(var(--v-theme-on-surface), var(--v-medium-emphasis-opacity));
font-size: 0.85rem;
font-weight: 500;
font-size: 0.875rem;
font-weight: 400;
letter-spacing: 0.01em;
margin-block: 4px 0;
margin-inline: 0;
opacity: 0.8;
}
/* ===================== 卡片主体 ===================== */
@@ -1011,29 +1161,110 @@ onUnmounted(() => {
padding-block: 8px !important;
}
/* 输入框聚焦时增加主色光晕 */
:deep(.login-body .v-field.v-field--focused) {
box-shadow: 0 0 0 2px rgba(var(--v-theme-primary), 0.18);
/* 输入框增强样式 */
:deep(.login-input .v-field) {
border-radius: 12px;
transition:
box-shadow 200ms ease,
border-color 200ms ease;
}
/* 登录按钮:主色 + 悬浮抬升 */
.login-submit {
box-shadow: 0 8px 20px rgba(var(--v-theme-primary), 0.35);
letter-spacing: 0.02em;
transition:
transform var(--mp-motion-duration-overlay, 160ms) var(--mp-motion-ease-standard, ease),
box-shadow var(--mp-motion-duration-overlay, 160ms) var(--mp-motion-ease-standard, ease);
/* 输入框聚焦时增加主色光晕 */
:deep(.login-body .v-field.v-field--focused) {
box-shadow:
0 0 0 3px rgba(var(--v-theme-primary), 0.12),
0 4px 12px rgba(var(--v-theme-primary), 0.08);
}
/* 输入框悬停 */
:deep(.login-input .v-field:hover:not(.v-field--focused)) {
border-color: rgba(var(--v-theme-primary), 0.4);
}
/* Remember me 复选框样式优化 */
.login-checkbox {
opacity: 0.85;
transition: opacity 150ms ease;
&:hover {
box-shadow: 0 12px 26px rgba(var(--v-theme-primary), 0.42);
transform: translateY(-1px);
opacity: 1;
}
}
/* 登录按钮:渐变 + 悬浮抬升 + 光泽 */
.login-submit {
position: relative;
overflow: hidden;
box-shadow: 0 8px 24px rgba(var(--v-theme-primary), 0.35);
font-weight: 600;
letter-spacing: 0.03em;
transition:
transform 200ms cubic-bezier(0.2, 0.8, 0.2, 1),
box-shadow 200ms cubic-bezier(0.2, 0.8, 0.2, 1);
&:hover {
box-shadow: 0 12px 32px rgba(var(--v-theme-primary), 0.45);
transform: translateY(-2px);
}
&:active {
box-shadow: 0 4px 12px rgba(var(--v-theme-primary), 0.3);
transform: translateY(0);
}
}
/* 登录按钮内部光泽扫描层 */
.login-submit :deep(.v-btn__content)::after {
position: absolute;
z-index: 10;
background: linear-gradient(
105deg,
transparent 35%,
rgba(255, 255, 255, 30%) 43%,
rgba(255, 255, 255, 40%) 50%,
rgba(255, 255, 255, 30%) 57%,
transparent 65%
);
content: '';
inset-block: -50%;
inset-inline: -50%;
pointer-events: none;
transform: translateX(-120%);
transition: transform 700ms cubic-bezier(0.4, 0, 0.2, 1);
}
.login-submit:hover :deep(.v-btn__content)::after {
transform: translateX(120%);
}
/* Passkey 按钮 */
.passkey-btn {
border-radius: 12px;
font-weight: 500;
transition:
background 200ms ease,
border-color 200ms ease,
transform 150ms ease;
&:hover {
transform: translateY(-1px);
}
}
/* 插件认证按钮 */
.plugin-auth-btn {
border-radius: 12px;
font-weight: 500;
transition:
background 200ms ease,
border-color 200ms ease,
transform 150ms ease;
&:hover {
transform: translateY(-1px);
}
}
/* or 分隔线 */
.or-divider {
position: relative;
@@ -1044,21 +1275,26 @@ onUnmounted(() => {
&::before,
&::after {
flex: 1;
border-block-end: 1px solid rgba(var(--v-border-color), var(--v-border-opacity));
border-block-end: 1px solid rgba(var(--v-border-color), calc(var(--v-border-opacity) * 0.7));
content: '';
}
.or-divider-text {
color: rgba(var(--v-theme-on-surface), var(--v-medium-emphasis-opacity));
font-size: 0.75rem;
font-size: 0.72rem;
font-weight: 600;
letter-spacing: 0.08em;
padding-inline: 14px;
letter-spacing: 0.1em;
padding-inline: 16px;
text-transform: uppercase;
white-space: nowrap;
}
}
/* 错误提示 */
.login-alert {
border-radius: 12px;
}
/* 浅色主题下 passkey 按钮保持绿色辨识度 */
:deep(.v-theme--light) .passkey-btn.v-btn--variant-outlined {
color: rgb(86, 170, 0) !important;
@@ -1070,43 +1306,103 @@ onUnmounted(() => {
align-items: center;
justify-content: center;
color: rgba(var(--v-theme-on-surface), calc(var(--v-disabled-opacity) * 1.4));
font-size: 0.72rem;
font-size: 0.7rem;
gap: 8px;
letter-spacing: 0.02em;
margin-block-start: 8px;
letter-spacing: 0.03em;
margin-block-start: 14px;
opacity: 0.75;
}
.login-version {
border-inline-start: 1px solid rgba(var(--v-border-color), var(--v-border-opacity));
border-inline-start: 1px solid rgba(var(--v-border-color), calc(var(--v-border-opacity) * 0.6));
padding-inline: 6px;
}
/* ===================== 入场动画 ===================== */
.login-card--enter {
animation: login-enter 520ms var(--mp-motion-ease-standard, cubic-bezier(0.2, 0.8, 0.2, 1)) both;
animation: login-enter 600ms cubic-bezier(0.16, 1, 0.3, 1) both;
}
@keyframes login-enter {
0% {
filter: blur(4px);
opacity: 0;
transform: translateY(14px) scale(0.985);
transform: translateY(20px) scale(0.97);
}
100% {
filter: blur(0);
opacity: 1;
transform: translateY(0) scale(1);
}
}
/* Logo 入场 */
.login-logo-wrapper {
animation: logo-enter 700ms cubic-bezier(0.16, 1, 0.3, 1) 100ms both;
}
@keyframes logo-enter {
0% {
opacity: 0;
transform: scale(0.8) translateY(10px);
}
100% {
opacity: 1;
transform: scale(1) translateY(0);
}
}
/* 标题入场 */
.login-title {
animation: text-enter 600ms cubic-bezier(0.16, 1, 0.3, 1) 200ms both;
}
.login-subtitle {
animation: text-enter 600ms cubic-bezier(0.16, 1, 0.3, 1) 300ms both;
}
@keyframes text-enter {
0% {
opacity: 0;
transform: translateY(8px);
}
100% {
opacity: 1;
transform: translateY(0);
}
}
/* ===================== 无障碍:尊重减少动态偏好 ===================== */
@media (prefers-reduced-motion: reduce) {
.login-card--enter {
.login-card--enter,
.login-logo-wrapper,
.login-title,
.login-subtitle {
animation-duration: 1ms !important;
}
.login-submit {
transition: none !important;
}
.login-submit :deep(.v-btn__content)::after {
display: none !important;
}
.login-logo {
animation: none !important;
}
.login-orb {
animation: none !important;
}
.login-logo-wrapper::before {
animation: none !important;
}
}
/* ===================== 小屏适配 ===================== */
@@ -1121,6 +1417,21 @@ onUnmounted(() => {
.login-card {
padding: 1.5rem !important;
border-radius: 16px !important;
}
.login-orb--1 {
block-size: 220px;
inline-size: 220px;
}
.login-orb--2 {
block-size: 180px;
inline-size: 180px;
}
.login-orb--3 {
display: none;
}
}
</style>

View File

@@ -283,6 +283,17 @@ async function setStoredUnreadCount(count: number): Promise<void> {
await set(UNREAD_COUNT_KEY, count)
}
// 通知已打开的页面同步未读计数,保证前台通知中心能感知 PWA badge 的变化。
async function broadcastUnreadCount(count: number) {
const clients = await self.clients.matchAll({ includeUncontrolled: true, type: 'window' })
clients.forEach(client => {
client.postMessage({
type: 'UNREAD_COUNT_UPDATE',
count,
})
})
}
async function updateBadge(count: number) {
if ('setAppBadge' in self.navigator) {
try {
@@ -309,6 +320,7 @@ async function clearBadge() {
try {
await setStoredUnreadCount(0)
await broadcastUnreadCount(0)
} catch (error) {
console.error('Failed to clear unread count:', error)
}
@@ -422,7 +434,11 @@ self.addEventListener('push', function (event) {
const currentCount = await getStoredUnreadCount()
const newCount = currentCount + 1
await setStoredUnreadCount(newCount)
await Promise.all([self.registration.showNotification(payload.title, content), updateBadge(newCount)])
await Promise.all([
self.registration.showNotification(payload.title, content),
updateBadge(newCount),
broadcastUnreadCount(newCount),
])
})(),
)
} catch (e) {
@@ -454,6 +470,7 @@ self.addEventListener('message', function (event) {
const count = event.data.count || 0
setStoredUnreadCount(count)
.then(() => updateBadge(count))
.then(() => broadcastUnreadCount(count))
.then(() => {
event.ports[0]?.postMessage({ success: true })
})

View File

@@ -184,6 +184,16 @@ html[data-theme-radius='extra'] {
box-shadow: var(--app-surface-shadow) !important;
}
// 统一卡片上浮反馈hover 命中区域应放在静止外层,避免上浮后底边反复触发 mouseleave。
.app-hover-lift-card {
transition: transform 0.3s ease, box-shadow 0.2s ease;
transform: translateZ(0);
}
.app-hover-lift-card--hovering {
transform: translate3d(0, -0.25rem, 0);
}
// 全局页面与 overlay 动效:短距离、轻缩放,保持快速但不生硬。
.mp-page-route {
inline-size: 100%;

View File

@@ -1,11 +1,20 @@
import type { SystemNotification } from '@/api/types'
const AGENT_ASSISTANT_BUBBLE_EVENT = 'agentAssistantBubble'
let agentAssistantBubbleListenerCount = 0
let agentAssistantBubbleEntryActive = false
export interface AgentAssistantNotificationBubblePayload {
export type AgentAssistantBubbleKind = 'assistant' | 'custom' | 'notification' | 'toast'
export type AgentAssistantBubbleVariant = 'default' | 'info' | 'success' | 'warning' | 'error'
export interface AgentAssistantBubblePayload {
id: string
kind?: AgentAssistantBubbleKind
variant?: AgentAssistantBubbleVariant
title?: string
text?: string
duration?: number
keepOpen?: boolean
type?: string
mtype?: string
source?: string
@@ -13,7 +22,16 @@ export interface AgentAssistantNotificationBubblePayload {
reg_time?: string
}
interface AgentAssistantBubbleEvent extends CustomEvent<AgentAssistantNotificationBubblePayload> {}
export interface AgentAssistantNotificationBubblePayload extends AgentAssistantBubblePayload {
kind?: 'notification'
}
export interface AgentAssistantToastBubblePayload extends AgentAssistantBubblePayload {
kind: 'toast'
variant: AgentAssistantBubbleVariant
}
interface AgentAssistantBubbleEvent extends CustomEvent<AgentAssistantBubblePayload> {}
function createNotificationBubbleId(notification: SystemNotification) {
if (notification.id) return `notification-${notification.id}`
@@ -21,29 +39,44 @@ function createNotificationBubbleId(notification: SystemNotification) {
return `notification-${Date.now()}-${Math.random().toString(16).slice(2)}`
}
// 通知中心和智能助手入口没有父子关系,通过全局事件传递实时通知气泡数据。
export function emitAgentAssistantNotificationBubble(notification: SystemNotification) {
function emitAgentAssistantBubble(payload: AgentAssistantBubblePayload) {
if (typeof window === 'undefined') return
window.dispatchEvent(
new CustomEvent<AgentAssistantNotificationBubblePayload>(AGENT_ASSISTANT_BUBBLE_EVENT, {
detail: {
id: createNotificationBubbleId(notification),
title: notification.title,
text: notification.text,
type: notification.type,
mtype: notification.mtype,
source: notification.source,
date: notification.date,
reg_time: notification.reg_time,
},
new CustomEvent<AgentAssistantBubblePayload>(AGENT_ASSISTANT_BUBBLE_EVENT, {
detail: payload,
}),
)
}
export function onAgentAssistantNotificationBubble(
callback: (payload: AgentAssistantNotificationBubblePayload) => void,
) {
// 通知中心、toast 和智能助手入口没有父子关系,通过全局事件传递实时气泡数据。
export function emitAgentAssistantNotificationBubble(notification: SystemNotification) {
emitAgentAssistantBubble({
id: createNotificationBubbleId(notification),
kind: 'notification',
title: notification.title,
text: notification.text,
type: notification.type,
mtype: notification.mtype,
source: notification.source,
date: notification.date,
reg_time: notification.reg_time,
})
}
export function emitAgentAssistantToastBubble(payload: AgentAssistantToastBubblePayload) {
emitAgentAssistantBubble(payload)
}
export function setAgentAssistantBubbleEntryActive(active: boolean) {
agentAssistantBubbleEntryActive = active
}
export function canUseAgentAssistantBubble() {
return agentAssistantBubbleEntryActive && agentAssistantBubbleListenerCount > 0
}
export function onAgentAssistantBubble(callback: (payload: AgentAssistantBubblePayload) => void) {
if (typeof window === 'undefined') return () => {}
const handler = (event: Event) => {
@@ -51,6 +84,18 @@ export function onAgentAssistantNotificationBubble(
}
window.addEventListener(AGENT_ASSISTANT_BUBBLE_EVENT, handler)
agentAssistantBubbleListenerCount += 1
return () => window.removeEventListener(AGENT_ASSISTANT_BUBBLE_EVENT, handler)
return () => {
window.removeEventListener(AGENT_ASSISTANT_BUBBLE_EVENT, handler)
agentAssistantBubbleListenerCount = Math.max(0, agentAssistantBubbleListenerCount - 1)
}
}
export function onAgentAssistantNotificationBubble(
callback: (payload: AgentAssistantNotificationBubblePayload) => void,
) {
return onAgentAssistantBubble(payload => {
if ((payload.kind || 'notification') === 'notification') callback(payload as AgentAssistantNotificationBubblePayload)
})
}

View File

@@ -1,3 +1,5 @@
import { readonly, ref } from 'vue'
/**
* PWA 徽章管理工具
*/
@@ -7,9 +9,27 @@ interface UnreadMessageEvent extends CustomEvent {
detail: { count: number }
}
const unreadMessageCount = ref(0)
// 暴露只读未读计数,供通知中心等组件直接判断应用角标状态。
export const appUnreadMessageCount = readonly(unreadMessageCount)
function normalizeUnreadMessageCount(count: unknown) {
const normalizedCount = Number(count)
if (!Number.isFinite(normalizedCount) || normalizedCount <= 0) return 0
return Math.floor(normalizedCount)
}
function setUnreadMessageCount(count: unknown) {
unreadMessageCount.value = normalizeUnreadMessageCount(count)
return unreadMessageCount.value
}
// 发送全局未读消息事件
export function emitUnreadMessageEvent(count: number) {
const event = new CustomEvent('unreadMessage', { detail: { count } }) as UnreadMessageEvent
const normalizedCount = setUnreadMessageCount(count)
const event = new CustomEvent('unreadMessage', { detail: { count: normalizedCount } }) as UnreadMessageEvent
window.dispatchEvent(event)
}
@@ -88,9 +108,8 @@ export async function checkUnreadOnStartup(): Promise<number> {
export async function checkAndEmitUnreadMessages() {
try {
const count = await checkUnreadOnStartup()
if (count > 0) {
emitUnreadMessageEvent(count)
}
// 启动时同步 0 值,避免组件复用上一轮角标状态。
emitUnreadMessageEvent(count)
} catch (error) {
// 静默处理错误
}
@@ -139,11 +158,13 @@ export async function clearAppBadge(): Promise<boolean> {
// 更新桌面图标徽章数量
export async function updateAppBadge(count: number): Promise<boolean> {
const normalizedCount = normalizeUnreadMessageCount(count)
try {
// 如果浏览器支持原生Badge API直接调用
if ('setAppBadge' in navigator) {
if (count > 0) {
await navigator.setAppBadge(count)
if (normalizedCount > 0) {
await navigator.setAppBadge(normalizedCount)
} else {
await navigator.clearAppBadge()
}
@@ -155,13 +176,18 @@ export async function updateAppBadge(count: number): Promise<boolean> {
return new Promise(resolve => {
messageChannel.port1.onmessage = event => {
resolve(event.data.success)
const success = Boolean(event.data.success)
if (success) emitUnreadMessageEvent(normalizedCount)
resolve(success)
}
navigator.serviceWorker.controller?.postMessage({ type: 'UPDATE_BADGE', count }, [messageChannel.port2])
navigator.serviceWorker.controller?.postMessage({ type: 'UPDATE_BADGE', count: normalizedCount }, [
messageChannel.port2,
])
})
}
emitUnreadMessageEvent(normalizedCount)
return true
} catch (error) {
console.error('Failed to update app badge:', error)
@@ -195,3 +221,11 @@ export async function getUnreadCount(): Promise<number> {
export function supportsBadgeAPI(): boolean {
return 'setAppBadge' in navigator && 'clearAppBadge' in navigator
}
if (typeof navigator !== 'undefined' && 'serviceWorker' in navigator) {
navigator.serviceWorker.addEventListener('message', event => {
if (event.data?.type === 'UNREAD_COUNT_UPDATE') {
emitUnreadMessageEvent(event.data.count || 0)
}
})
}

View File

@@ -157,11 +157,12 @@ onMounted(() => {
<VCardText>
<p class="text-body-2 text-medium-emphasis mb-4">{{ t('setupWizard.preferences.quickPresetsDesc') }}</p>
<VRow>
<VCol v-for="(preset, key) in presetConfigs" :key="key" cols="12" sm="6" md="3">
<!-- Hover 命中区域保持静止避免预设卡片上浮后底边反复触发 mouseleave -->
<VCol v-for="(preset, key) in presetConfigs" :key="key" class="preset-card-hover-area" cols="12" sm="6" md="3">
<VCard
:color="selectedPreset === key ? preset.color : 'default'"
:variant="selectedPreset === key ? 'tonal' : 'outlined'"
class="cursor-pointer preset-card"
class="app-hover-lift-card cursor-pointer preset-card"
@click="selectPreset(key)"
>
<VCardText class="text-center pa-4">
@@ -218,11 +219,10 @@ onMounted(() => {
<style scoped>
.cursor-pointer {
cursor: pointer;
transition: all 0.3s ease;
}
.preset-card:hover {
transform: translateY(-4px);
.preset-card-hover-area:hover .preset-card {
transform: translate3d(0, -0.25rem, 0);
}
.preset-card:active {