fix: 修复卡片 hover 上浮抖动

This commit is contained in:
jxxghp
2026-06-24 21:34:38 +08:00
parent 48ed396a19
commit 530fe9d35b
21 changed files with 339 additions and 201 deletions
+11 -3
View File
@@ -46,13 +46,14 @@ const getImgUrl = computed(() => {
<template>
<VHover>
<template #default="hover">
<!-- Hover 命中区域保持静止避免卡片上浮后底边反复触发 mouseleave -->
<div v-bind="hover.props" class="backdrop-card-hover-area">
<VCard
v-bind="hover.props"
:height="props.height"
:width="props.width"
class="ring-gray-500"
class="app-hover-lift-card ring-gray-500"
:class="{
'transition transform-cpu duration-300 -translate-y-1': hover.isHovering,
'app-hover-lift-card--hovering': hover.isHovering,
'ring-1': imageLoaded,
}"
@click="goPlay"
@@ -87,6 +88,13 @@ const getImgUrl = computed(() => {
/>
</div>
</VCard>
</div>
</template>
</VHover>
</template>
<style scoped>
.backdrop-card-hover-area {
inline-size: 100%;
}
</style>
+9 -4
View File
@@ -73,13 +73,13 @@ async function deleteDownload() {
<template>
<VHover>
<template #default="hover">
<!-- Hover 命中区域保持静止避免卡片上浮后底边反复触发 mouseleave -->
<div v-if="cardState" v-bind="hover.props" class="downloading-card-hover-area h-full">
<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="downloading-card app-hover-lift-card app-surface flex flex-col h-full overflow-hidden"
:class="{
'transition transform-cpu duration-300 -translate-y-1': hover.isHovering,
'app-hover-lift-card--hovering': hover.isHovering,
}"
min-height="150"
>
@@ -131,6 +131,7 @@ async function deleteDownload() {
</VCardActions>
</div>
</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%;
}
+11 -2
View File
@@ -156,12 +156,14 @@ onMounted(async () => {
<template>
<VHover>
<template #default="hover">
<!-- Hover 命中区域保持静止避免卡片上浮后底边反复触发 mouseleave -->
<div v-bind="hover.props" class="library-card-hover-area">
<VCard
v-bind="hover.props"
:height="props.height"
:width="props.width"
class="app-hover-lift-card"
:class="{
'transition transform-cpu duration-300 -translate-y-1': hover.isHovering,
'app-hover-lift-card--hovering': hover.isHovering,
}"
@click="goPlay"
>
@@ -185,6 +187,13 @@ onMounted(async () => {
</VImg>
</template>
</VCard>
</div>
</template>
</VHover>
</template>
<style scoped>
.library-card-hover-area {
inline-size: 100%;
}
</style>
+2 -2
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)"
+9 -2
View File
@@ -75,12 +75,14 @@ function goPersonDetail() {
<template>
<VHover>
<template #default="hover">
<!-- Hover 命中区域保持静止避免卡片上浮后底边反复触发 mouseleave -->
<div v-bind="hover.props" class="person-card-hover-area">
<VCard
v-bind="hover.props"
:height="personProps.height"
:width="personProps.width"
class="app-hover-lift-card"
:class="{
'transition transform-cpu duration-300 -translate-y-1': hover.isHovering,
'app-hover-lift-card--hovering': hover.isHovering,
}"
@click.stop="goPersonDetail"
>
@@ -108,11 +110,16 @@ function goPersonDetail() {
</div>
</div>
</VCard>
</div>
</template>
</VHover>
</template>
<style lang="scss" scoped>
.person-card-hover-area {
inline-size: 100%;
}
.person-card {
background-image: linear-gradient(
45deg,
+9 -3
View File
@@ -230,14 +230,15 @@ onUnmounted(() => {
<div>
<VHover>
<template #default="hover">
<!-- Hover 命中区域保持静止避免卡片上浮后底边反复触发 mouseleave -->
<div v-bind="hover.props" class="plugin-app-card-hover-area h-full">
<VCard
v-bind="hover.props"
:width="props.width"
:height="props.height"
@click="showPluginDetail"
class="flex flex-col h-full"
class="app-hover-lift-card flex flex-col h-full"
:class="{
'transition transform-cpu duration-300 -translate-y-1': hover.isHovering,
'app-hover-lift-card--hovering': hover.isHovering,
}"
>
<div
@@ -326,12 +327,17 @@ onUnmounted(() => {
</div>
</VCardText>
</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;
+9 -4
View File
@@ -567,15 +567,15 @@ watch(
<!-- 插件卡片 -->
<VHover>
<template #default="hover">
<!-- Hover 命中区域保持静止避免卡片上浮后底边反复触发 mouseleave -->
<div v-if="isVisible" v-bind="hover.props" class="plugin-card-hover-area h-full">
<VCard
v-if="isVisible"
v-bind="hover.props"
:width="props.width"
:height="props.height"
@click="handleCardClick"
class="flex flex-col h-full"
class="app-hover-lift-card flex flex-col h-full"
:class="{
'transition transform-cpu duration-300 -translate-y-1': hover.isHovering && !props.sortable,
'app-hover-lift-card--hovering': hover.isHovering && !props.sortable,
'cursor-move': props.sortable,
}"
:ripple="!props.sortable"
@@ -670,6 +670,7 @@ watch(
<VIcon icon="mdi-new-box" class="text-white" />
</div>
</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 */
+9 -4
View File
@@ -211,14 +211,15 @@ const dropdownItems = ref([
<!-- 文件夹卡片 -->
<VHover>
<template #default="hover">
<!-- Hover 命中区域保持静止避免卡片上浮后底边反复触发 mouseleave -->
<div v-bind="hover.props" class="plugin-folder-card-hover-area h-full">
<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 app-hover-lift-card h-full"
:class="{
'plugin-folder-card--mobile': display.mobile,
'plugin-folder-card--hover': hover.isHovering && !props.sortable,
@@ -289,24 +290,28 @@ const dropdownItems = ref([
</div>
</div>
</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 {
+11 -3
View File
@@ -47,13 +47,14 @@ async function goPlay(isHovering: boolean | null = false) {
<template>
<VHover>
<template #default="hover">
<!-- Hover 命中区域保持静止避免卡片上浮后底边反复触发 mouseleave -->
<div v-bind="hover.props" class="poster-card-hover-area">
<VCard
v-bind="hover.props"
:height="props.height"
:width="props.width"
class="outline-none ring-gray-500"
class="app-hover-lift-card outline-none ring-gray-500"
:class="{
'transition transform-cpu duration-300 -translate-y-1': hover.isHovering,
'app-hover-lift-card--hovering': hover.isHovering,
'ring-1': isImageLoaded,
}"
>
@@ -94,6 +95,13 @@ async function goPlay(isHovering: boolean | null = false) {
</h1>
</VCardText>
</VCard>
</div>
</template>
</VHover>
</template>
<style scoped>
.poster-card-hover-area {
inline-size: 100%;
}
</style>
+15 -4
View File
@@ -239,15 +239,17 @@ onMounted(() => {
<template>
<div>
<!-- Hover 命中区域保持静止避免卡片上浮后底边反复触发 mouseleave -->
<div class="site-card-hover-area h-full">
<VCard
class="site-card relative h-full flex flex-col overflow-hidden group transition-all duration-300"
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 hover:-translate-y-1': !cardProps.sortable,
'cursor-pointer site-card--hoverable': !cardProps.sortable,
'cursor-move': cardProps.sortable,
'site-card--sortable': cardProps.sortable,
},
@@ -421,9 +423,18 @@ onMounted(() => {
</VSheet>
</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;
+9 -3
View File
@@ -404,15 +404,16 @@ function handleCardClick() {
<div>
<VHover>
<template #default="hover">
<!-- Hover 命中区域保持静止避免卡片上浮后底边反复触发 mouseleave -->
<div v-bind="hover.props" class="subscribe-card-hover-area w-full h-full">
<div
class="subscribe-card-shell w-full h-full relative"
class="subscribe-card-shell app-hover-lift-card w-full h-full relative"
:class="{
'transition transform-cpu duration-300 -translate-y-1': hover.isHovering && !props.sortable,
'app-hover-lift-card--hovering': 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"
:class="{
@@ -570,11 +571,16 @@ function handleCardClick() {
</div>
</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%);
}
+10 -4
View File
@@ -93,16 +93,17 @@ function doDelete() {
<div class="h-full">
<VHover>
<template #default="hover">
<!-- Hover 命中区域保持静止避免卡片上浮后底边反复触发 mouseleave -->
<div v-bind="hover.props" class="subscribe-share-card-hover-area w-full h-full">
<div
class="w-full h-full overflow-hidden"
class="app-hover-lift-card w-full h-full overflow-hidden"
:class="{
'transition transform-cpu duration-300 -translate-y-1': hover.isHovering,
'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"
>
@@ -157,11 +158,16 @@ function doDelete() {
</div>
</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%);
}
+12 -3
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>
+12 -3
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>
+12 -3
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);
}
+12 -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);
}
+12 -1
View File
@@ -127,9 +127,11 @@ onMounted(() => {
})
</script>
<template>
<!-- Hover 命中区域保持静止避免卡片上浮后底边反复触发 mouseleave -->
<div class="user-card-hover-area h-full">
<VCard
:class="[
'transition-transform duration-300 hover:-translate-y-1',
'app-hover-lift-card',
!props.user.is_active ? 'opacity-85 bg-surface-lighten-1' : '',
]"
class="user-card flex flex-column h-full"
@@ -303,9 +305,18 @@ onMounted(() => {
</VCardText>
</div>
</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%;
}
+7 -10
View File
@@ -95,12 +95,13 @@ function doDelete() {
<div class="h-full">
<VHover>
<template #default="hover">
<!-- Hover 命中区域保持静止避免卡片上浮后底边反复触发 mouseleave -->
<div v-bind="hover.props" class="workflow-share-card-hover-area h-full">
<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 app-hover-lift-card flex flex-col h-full cursor-pointer overflow-hidden"
:class="{
'workflow-share-card--hovering': hover.isHovering,
'app-hover-lift-card--hovering': hover.isHovering,
}"
min-height="150"
:style="{ background: gradientStyle }"
@@ -135,19 +136,15 @@ function doDelete() {
</VCardText>
</div>
</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>
+11 -3
View File
@@ -220,13 +220,14 @@ const resolveProgress = (item: Workflow) => {
<template>
<div class="h-full">
<VHover v-slot="hover">
<!-- Hover 命中区域保持静止避免卡片上浮后底边反复触发 mouseleave -->
<div v-bind="hover.props" class="workflow-task-card-hover-area h-full">
<VCard
v-bind="hover.props"
class="mx-auto h-full"
class="app-hover-lift-card mx-auto h-full"
@click="handleFlow(workflow)"
:ripple="false"
:loading="loading"
:class="{ 'transition transform-cpu duration-300 -translate-y-1': hover.isHovering }"
:class="{ 'app-hover-lift-card--hovering': hover.isHovering }"
>
<VCardItem
class="px-2 py-2"
@@ -368,6 +369,13 @@ const resolveProgress = (item: Workflow) => {
</div>
</VCardText>
</VCard>
</div>
</VHover>
</div>
</template>
<style scoped>
.workflow-task-card-hover-area {
inline-size: 100%;
}
</style>
+10
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%;
+5 -5
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 {