From 3440b377ee9323c6dbbc6cc34f07908e289d0d6f Mon Sep 17 00:00:00 2001 From: jxxghp Date: Thu, 25 Jun 2026 21:33:37 +0800 Subject: [PATCH] fix: improve mobile interaction handling for media cards --- src/components/agent/AgentAssistantEntry.vue | 43 ++++++++++------ src/components/cards/MediaCard.vue | 53 +++++++++++++++++--- 2 files changed, 76 insertions(+), 20 deletions(-) diff --git a/src/components/agent/AgentAssistantEntry.vue b/src/components/agent/AgentAssistantEntry.vue index e5294448..8d271730 100644 --- a/src/components/agent/AgentAssistantEntry.vue +++ b/src/components/agent/AgentAssistantEntry.vue @@ -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 diff --git a/src/components/cards/MediaCard.vue b/src/components/cards/MediaCard.vue index 3163b75a..af1285aa 100644 --- a/src/components/cards/MediaCard.vue +++ b/src/components/cards/MediaCard.vue @@ -93,6 +93,14 @@ const selectedSites = ref([]) // 搜索菜单显示状态 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)" > { @@ -473,10 +514,10 @@ onBeforeUnmount(() => { {{ getMediaTypeText(props.media?.type) }} - + { 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" >