fix: improve mobile interaction handling for media cards

This commit is contained in:
jxxghp
2026-06-25 21:33:37 +08:00
parent 9161e5e300
commit 3440b377ee
2 changed files with 76 additions and 20 deletions

View File

@@ -53,7 +53,9 @@ const FAB_TOAST_BUBBLE_DURATION = 4500
const FAB_MAX_BUBBLES = 4
const FAB_DEFAULT_RIGHT_OFFSET = 18
const FAB_DEFAULT_VERTICAL_RATIO = 2 / 3
const FAB_MOBILE_VIEWPORT_WIDTH = 600
const FAB_BUBBLE_GAP = 12
const FAB_MOBILE_BUBBLE_GAP = 6
const FAB_BUBBLE_SAFE_MARGIN = 12
const FAB_BUBBLE_ARROW_MARGIN = 28
const FAB_BUBBLE_EDGE_ARROW_OFFSET = 38
@@ -199,9 +201,19 @@ function getViewportSize() {
}
}
// 判断当前 FAB 是否处于移动端布局,用于同步 JS 布局和 CSS 媒体查询。
function isMobileFabViewport() {
return getViewportSize().width <= FAB_MOBILE_VIEWPORT_WIDTH
}
// 移动端气泡与机器人距离更短,避免 iOS 触摸视图里气泡显得过远。
function getFabBubbleGap() {
return isMobileFabViewport() ? FAB_MOBILE_BUBBLE_GAP : FAB_BUBBLE_GAP
}
function getOpenFabSize() {
const viewport = getViewportSize()
const isMobile = viewport.width <= 600
const isMobile = isMobileFabViewport()
return {
height: isMobile ? 106 : 115,
@@ -210,9 +222,8 @@ function getOpenFabSize() {
}
function getFallbackFabInteractiveBounds(): FabInteractiveBounds {
const viewport = getViewportSize()
const rootSize = getOpenFabSize()
const triggerSize = viewport.width <= 600 ? { height: 77, width: 80 } : { height: 82, width: 86 }
const triggerSize = isMobileFabViewport() ? { height: 77, width: 80 } : { height: 82, width: 86 }
return {
height: triggerSize.height,
@@ -355,6 +366,7 @@ function getBubbleCandidatePenalty(
bubbleSize: { height: number; width: number },
anchorRect: DOMRect,
viewport: { height: number; width: number },
bubbleGap: number,
) {
const x = clampBubbleAxis(candidate.idealX, bubbleSize.width, viewport.width)
const y = clampBubbleAxis(candidate.idealY, bubbleSize.height, viewport.height)
@@ -366,8 +378,8 @@ function getBubbleCandidatePenalty(
}[candidate.placement]
const primaryRequired =
candidate.placement === 'left' || candidate.placement === 'right'
? bubbleSize.width + FAB_BUBBLE_GAP
: bubbleSize.height + FAB_BUBBLE_GAP
? bubbleSize.width + bubbleGap
: bubbleSize.height + bubbleGap
const fitPenalty = Math.max(0, primaryRequired - primaryAvailable) * 8
const alignmentPenalty = Math.abs(x - candidate.idealX) + Math.abs(y - candidate.idealY)
@@ -386,36 +398,39 @@ function calculateFabBubbleLayout(): FabBubbleLayout | null {
const viewport = getViewportSize()
const bubbleSize = getFabBubbleSize()
const bubbleGap = getFabBubbleGap()
const anchorCenterX = anchorRect.left + anchorRect.width / 2
const anchorCenterY = anchorRect.top + anchorRect.height / 2
const mobileSidePlacement = anchorCenterX >= viewport.width / 2 ? 'left' : 'right'
const preferMobileSideBubble = isMobileFabViewport()
const candidates: FabBubbleCandidate[] = [
{
idealX: anchorCenterX - bubbleSize.width / 2,
idealY: anchorRect.top - bubbleSize.height - FAB_BUBBLE_GAP,
idealY: anchorRect.top - bubbleSize.height - bubbleGap,
placement: 'top',
weight: 0,
weight: preferMobileSideBubble ? 18 : 0,
},
{
idealX: anchorCenterX - bubbleSize.width / 2,
idealY: anchorRect.bottom + FAB_BUBBLE_GAP,
idealY: anchorRect.bottom + bubbleGap,
placement: 'bottom',
weight: 4,
weight: preferMobileSideBubble ? 22 : 4,
},
{
idealX: anchorRect.right + FAB_BUBBLE_GAP,
idealX: anchorRect.right + bubbleGap,
idealY: anchorCenterY - bubbleSize.height / 2,
placement: 'right',
weight: 8,
weight: preferMobileSideBubble && mobileSidePlacement === 'right' ? -4 : 8,
},
{
idealX: anchorRect.left - bubbleSize.width - FAB_BUBBLE_GAP,
idealX: anchorRect.left - bubbleSize.width - bubbleGap,
idealY: anchorCenterY - bubbleSize.height / 2,
placement: 'left',
weight: 8,
weight: preferMobileSideBubble && mobileSidePlacement === 'left' ? -4 : 8,
},
]
const bestCandidate = candidates
.map(candidate => getBubbleCandidatePenalty(candidate, bubbleSize, anchorRect, viewport))
.map(candidate => getBubbleCandidatePenalty(candidate, bubbleSize, anchorRect, viewport, bubbleGap))
.sort((a, b) => a.score - b.score)[0]
if (!bestCandidate) return null

View File

@@ -93,6 +93,14 @@ const selectedSites = ref<number[]>([])
// 搜索菜单显示状态
const searchMenuShow = ref(false)
// 触摸设备没有稳定 hover单独保存详情层的展开状态。
const touchDetailVisible = ref(false)
// 粗指针设备使用点击展开详情,避免 iOS 返回后沿用 VHover 的触摸态。
const isTouchLikePointer = ref(
typeof window !== 'undefined' && Boolean(window.matchMedia?.('(hover: none), (pointer: coarse)').matches),
)
// 打开站点选择弹窗,并把选择结果交回当前媒体卡片继续搜索。
function openSearchSiteDialog() {
openSharedDialog(
@@ -250,6 +258,8 @@ async function handleSubscribe() {
// 打开详情页
function goMediaDetail(isHovering = false) {
if (isHovering) {
resetMediaCardDetailState()
if (props.media?.collection_id) {
// 跳转到合集列表
router.push({
@@ -273,6 +283,32 @@ function goMediaDetail(isHovering = false) {
}
}
// 当前卡片是否进入可操作详情态,桌面使用 hover触摸端使用显式点击态。
function isMediaCardActive(isHovering: boolean | null | undefined) {
return touchDetailVisible.value || (!isTouchLikePointer.value && Boolean(isHovering))
}
// 当前卡片详情层是否显示,保留图片错误和站点选择时的强制显示。
function isMediaCardDetailVisible(isHovering: boolean | null | undefined) {
return isMediaCardActive(isHovering) || imageLoadError.value || searchMenuShow.value
}
// 清理移动端详情层展开态,避免路由返回后继续显示上一次的详情层。
function resetMediaCardDetailState() {
touchDetailVisible.value = false
}
// 处理媒体卡片点击:触摸端第一次点击展开,展开后再次点击进入详情。
function handleMediaCardClick(isHovering: boolean | null | undefined) {
if (isTouchLikePointer.value && !isMediaCardDetailVisible(isHovering)) {
touchDetailVisible.value = true
querySubscribedSeasons()
return
}
goMediaDetail(isMediaCardActive(isHovering) || isMediaCardDetailVisible(isHovering))
}
// 点击搜索
async function clickSearch() {
if (allSites.value?.length == 0) {
@@ -373,6 +409,7 @@ watch(isSubscribed, subscribed => {
watch(
() => props.media,
() => {
resetMediaCardDetailState()
subscribedSeasons.value = []
subscribedSeasonModes.value = {}
subscribedSeasonsLoaded.value = false
@@ -383,7 +420,11 @@ onMounted(() => {
setupIntersectionObserver()
})
onActivated(resetMediaCardDetailState)
onDeactivated(resetMediaCardDetailState)
onBeforeUnmount(() => {
resetMediaCardDetailState()
observer.value?.disconnect()
observer.value = null
})
@@ -404,10 +445,10 @@ onBeforeUnmount(() => {
:width="props.width"
class="app-hover-lift-card outline-none ring-gray-500 media-card"
:class="{
'app-hover-lift-card--hovering': hover.isHovering,
'app-hover-lift-card--hovering': isMediaCardActive(hover.isHovering),
'ring-1': isImageLoaded,
}"
@click.stop="goMediaDetail(hover.isHovering ?? false)"
@click.stop="handleMediaCardClick(hover.isHovering)"
>
<VImg
aspect-ratio="2/3"
@@ -426,7 +467,7 @@ onBeforeUnmount(() => {
<!-- 详情 -->
<VCardText
v-show="hover.isHovering || imageLoadError || searchMenuShow"
v-show="isMediaCardDetailVisible(hover.isHovering)"
class="w-full h-full flex flex-col flex-wrap justify-end align-left text-white absolute bottom-0 cursor-pointer pa-2"
style="background: linear-gradient(rgba(45, 55, 72, 40%) 0%, rgba(45, 55, 72, 90%) 100%)"
>
@@ -473,10 +514,10 @@ onBeforeUnmount(() => {
{{ getMediaTypeText(props.media?.type) }}
</VChip>
<!-- 本地存在标识 -->
<ExistIcon v-if="isExists && !hover.isHovering" />
<ExistIcon v-if="isExists && !isMediaCardActive(hover.isHovering)" />
<!-- 评分角标 -->
<VChip
v-if="isImageLoaded && props.media?.vote_average && !(isExists && !hover.isHovering)"
v-if="isImageLoaded && props.media?.vote_average && !(isExists && !isMediaCardActive(hover.isHovering))"
variant="elevated"
size="small"
:class="getChipColor('rating')"
@@ -491,7 +532,7 @@ onBeforeUnmount(() => {
density="compact"
class="absolute bottom-1 right-1"
tile
v-if="!hover.isHovering && isImageLoaded && props.media?.source && !imageLoadError"
v-if="!isMediaCardActive(hover.isHovering) && isImageLoaded && props.media?.source && !imageLoadError"
>
<VImg cover :src="sourceIconDict[props.media?.source]" class="shadow-lg" />
</VAvatar>