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
+29 -14
View File
@@ -53,7 +53,9 @@ const FAB_TOAST_BUBBLE_DURATION = 4500
const FAB_MAX_BUBBLES = 4 const FAB_MAX_BUBBLES = 4
const FAB_DEFAULT_RIGHT_OFFSET = 18 const FAB_DEFAULT_RIGHT_OFFSET = 18
const FAB_DEFAULT_VERTICAL_RATIO = 2 / 3 const FAB_DEFAULT_VERTICAL_RATIO = 2 / 3
const FAB_MOBILE_VIEWPORT_WIDTH = 600
const FAB_BUBBLE_GAP = 12 const FAB_BUBBLE_GAP = 12
const FAB_MOBILE_BUBBLE_GAP = 6
const FAB_BUBBLE_SAFE_MARGIN = 12 const FAB_BUBBLE_SAFE_MARGIN = 12
const FAB_BUBBLE_ARROW_MARGIN = 28 const FAB_BUBBLE_ARROW_MARGIN = 28
const FAB_BUBBLE_EDGE_ARROW_OFFSET = 38 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() { function getOpenFabSize() {
const viewport = getViewportSize() const viewport = getViewportSize()
const isMobile = viewport.width <= 600 const isMobile = isMobileFabViewport()
return { return {
height: isMobile ? 106 : 115, height: isMobile ? 106 : 115,
@@ -210,9 +222,8 @@ function getOpenFabSize() {
} }
function getFallbackFabInteractiveBounds(): FabInteractiveBounds { function getFallbackFabInteractiveBounds(): FabInteractiveBounds {
const viewport = getViewportSize()
const rootSize = getOpenFabSize() 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 { return {
height: triggerSize.height, height: triggerSize.height,
@@ -355,6 +366,7 @@ function getBubbleCandidatePenalty(
bubbleSize: { height: number; width: number }, bubbleSize: { height: number; width: number },
anchorRect: DOMRect, anchorRect: DOMRect,
viewport: { height: number; width: number }, viewport: { height: number; width: number },
bubbleGap: number,
) { ) {
const x = clampBubbleAxis(candidate.idealX, bubbleSize.width, viewport.width) const x = clampBubbleAxis(candidate.idealX, bubbleSize.width, viewport.width)
const y = clampBubbleAxis(candidate.idealY, bubbleSize.height, viewport.height) const y = clampBubbleAxis(candidate.idealY, bubbleSize.height, viewport.height)
@@ -366,8 +378,8 @@ function getBubbleCandidatePenalty(
}[candidate.placement] }[candidate.placement]
const primaryRequired = const primaryRequired =
candidate.placement === 'left' || candidate.placement === 'right' candidate.placement === 'left' || candidate.placement === 'right'
? bubbleSize.width + FAB_BUBBLE_GAP ? bubbleSize.width + bubbleGap
: bubbleSize.height + FAB_BUBBLE_GAP : bubbleSize.height + bubbleGap
const fitPenalty = Math.max(0, primaryRequired - primaryAvailable) * 8 const fitPenalty = Math.max(0, primaryRequired - primaryAvailable) * 8
const alignmentPenalty = Math.abs(x - candidate.idealX) + Math.abs(y - candidate.idealY) const alignmentPenalty = Math.abs(x - candidate.idealX) + Math.abs(y - candidate.idealY)
@@ -386,36 +398,39 @@ function calculateFabBubbleLayout(): FabBubbleLayout | null {
const viewport = getViewportSize() const viewport = getViewportSize()
const bubbleSize = getFabBubbleSize() const bubbleSize = getFabBubbleSize()
const bubbleGap = getFabBubbleGap()
const anchorCenterX = anchorRect.left + anchorRect.width / 2 const anchorCenterX = anchorRect.left + anchorRect.width / 2
const anchorCenterY = anchorRect.top + anchorRect.height / 2 const anchorCenterY = anchorRect.top + anchorRect.height / 2
const mobileSidePlacement = anchorCenterX >= viewport.width / 2 ? 'left' : 'right'
const preferMobileSideBubble = isMobileFabViewport()
const candidates: FabBubbleCandidate[] = [ const candidates: FabBubbleCandidate[] = [
{ {
idealX: anchorCenterX - bubbleSize.width / 2, idealX: anchorCenterX - bubbleSize.width / 2,
idealY: anchorRect.top - bubbleSize.height - FAB_BUBBLE_GAP, idealY: anchorRect.top - bubbleSize.height - bubbleGap,
placement: 'top', placement: 'top',
weight: 0, weight: preferMobileSideBubble ? 18 : 0,
}, },
{ {
idealX: anchorCenterX - bubbleSize.width / 2, idealX: anchorCenterX - bubbleSize.width / 2,
idealY: anchorRect.bottom + FAB_BUBBLE_GAP, idealY: anchorRect.bottom + bubbleGap,
placement: 'bottom', placement: 'bottom',
weight: 4, weight: preferMobileSideBubble ? 22 : 4,
}, },
{ {
idealX: anchorRect.right + FAB_BUBBLE_GAP, idealX: anchorRect.right + bubbleGap,
idealY: anchorCenterY - bubbleSize.height / 2, idealY: anchorCenterY - bubbleSize.height / 2,
placement: 'right', 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, idealY: anchorCenterY - bubbleSize.height / 2,
placement: 'left', placement: 'left',
weight: 8, weight: preferMobileSideBubble && mobileSidePlacement === 'left' ? -4 : 8,
}, },
] ]
const bestCandidate = candidates 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] .sort((a, b) => a.score - b.score)[0]
if (!bestCandidate) return null if (!bestCandidate) return null
+47 -6
View File
@@ -93,6 +93,14 @@ const selectedSites = ref<number[]>([])
// 搜索菜单显示状态 // 搜索菜单显示状态
const searchMenuShow = ref(false) 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() { function openSearchSiteDialog() {
openSharedDialog( openSharedDialog(
@@ -250,6 +258,8 @@ async function handleSubscribe() {
// 打开详情页 // 打开详情页
function goMediaDetail(isHovering = false) { function goMediaDetail(isHovering = false) {
if (isHovering) { if (isHovering) {
resetMediaCardDetailState()
if (props.media?.collection_id) { if (props.media?.collection_id) {
// 跳转到合集列表 // 跳转到合集列表
router.push({ 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() { async function clickSearch() {
if (allSites.value?.length == 0) { if (allSites.value?.length == 0) {
@@ -373,6 +409,7 @@ watch(isSubscribed, subscribed => {
watch( watch(
() => props.media, () => props.media,
() => { () => {
resetMediaCardDetailState()
subscribedSeasons.value = [] subscribedSeasons.value = []
subscribedSeasonModes.value = {} subscribedSeasonModes.value = {}
subscribedSeasonsLoaded.value = false subscribedSeasonsLoaded.value = false
@@ -383,7 +420,11 @@ onMounted(() => {
setupIntersectionObserver() setupIntersectionObserver()
}) })
onActivated(resetMediaCardDetailState)
onDeactivated(resetMediaCardDetailState)
onBeforeUnmount(() => { onBeforeUnmount(() => {
resetMediaCardDetailState()
observer.value?.disconnect() observer.value?.disconnect()
observer.value = null observer.value = null
}) })
@@ -404,10 +445,10 @@ onBeforeUnmount(() => {
:width="props.width" :width="props.width"
class="app-hover-lift-card outline-none ring-gray-500 media-card" class="app-hover-lift-card outline-none ring-gray-500 media-card"
:class="{ :class="{
'app-hover-lift-card--hovering': hover.isHovering, 'app-hover-lift-card--hovering': isMediaCardActive(hover.isHovering),
'ring-1': isImageLoaded, 'ring-1': isImageLoaded,
}" }"
@click.stop="goMediaDetail(hover.isHovering ?? false)" @click.stop="handleMediaCardClick(hover.isHovering)"
> >
<VImg <VImg
aspect-ratio="2/3" aspect-ratio="2/3"
@@ -426,7 +467,7 @@ onBeforeUnmount(() => {
<!-- 详情 --> <!-- 详情 -->
<VCardText <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" 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%)" 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) }} {{ getMediaTypeText(props.media?.type) }}
</VChip> </VChip>
<!-- 本地存在标识 --> <!-- 本地存在标识 -->
<ExistIcon v-if="isExists && !hover.isHovering" /> <ExistIcon v-if="isExists && !isMediaCardActive(hover.isHovering)" />
<!-- 评分角标 --> <!-- 评分角标 -->
<VChip <VChip
v-if="isImageLoaded && props.media?.vote_average && !(isExists && !hover.isHovering)" v-if="isImageLoaded && props.media?.vote_average && !(isExists && !isMediaCardActive(hover.isHovering))"
variant="elevated" variant="elevated"
size="small" size="small"
:class="getChipColor('rating')" :class="getChipColor('rating')"
@@ -491,7 +532,7 @@ onBeforeUnmount(() => {
density="compact" density="compact"
class="absolute bottom-1 right-1" class="absolute bottom-1 right-1"
tile 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" /> <VImg cover :src="sourceIconDict[props.media?.source]" class="shadow-lg" />
</VAvatar> </VAvatar>