feat:增加top3的动画

This commit is contained in:
zheng020 2026-06-10 22:53:07 +08:00
parent d4e26337de
commit 23862e36ae
9 changed files with 71 additions and 349 deletions

View File

@ -48,9 +48,9 @@ const onTop3DataLoaded = (items) => {
<style scoped>
.banner-carousel {
position: relative;
width: 100%;
/* width: 100%; */
z-index: 100;
/* padding: 0 16rpx; */
padding: 0 16rpx;
box-sizing: border-box;
/* top:16rpx; */
}
@ -74,11 +74,11 @@ const onTop3DataLoaded = (items) => {
.banner-image {
width: 100%;
height: 356rpx;
height: 376rpx;
display: block;
border-radius: 24rpx;
position: relative;
bottom: 8rpx;
bottom: 16rpx;
}
.banner-overlay {

View File

@ -1,12 +1,11 @@
<template>
<view class="creation-grid-wrapper">
<!-- 区域 A主Tab星卡/吧唧/海报 -->
<view ref="mainTabsRef" class="main-tab-section">
<view class="main-tab-section">
<view
v-for="(tab, index) in mainTabs"
:key="index"
class="tab-item"
:style="{ '--tab-delay': index * 0.06 + 's' }"
@click="handleMainTabClick(tab)"
>
<view class="tab-icon-wrap">
@ -31,7 +30,7 @@
ref="categoryRef"
id="category-section"
class="category-section"
:class="{ fixed: isFixed, 'just-fixed': justFixed }"
:class="{ fixed: isFixed }"
>
<scroll-view class="category-scroll" scroll-x :show-scrollbar="false">
<view
@ -57,7 +56,6 @@
<view
v-for="item in creationList"
:key="item.id"
:ref="(el) => setCardRef(el, item)"
class="creation-card"
@click="handleCardClick(item)"
>
@ -163,8 +161,7 @@ const categories = [
{ label: '海报', value: 'poster' },
]
// ========== ref spotlight + ==========
const mainTabsRef = ref(null)
// ========== refcategoryRef ==========
const categoryRef = ref(null)
// ========== fixed ==========
@ -172,16 +169,6 @@ const categoryOffsetTop = ref(null)
const categoryHeight = ref(0)
const fixedTopPx = ref(50) // CSS top: 96rpx
const isFixed = ref(false)
const justFixed = ref(false)
// justFixed class
watch(isFixed, (val) => {
if (!val) return
justFixed.value = true
setTimeout(() => {
justFixed.value = false
}, 450)
})
// ========== ==========
@ -232,16 +219,6 @@ const formatCount = (count) => {
return count.toString()
}
const cardRefsMap = new Map()
const setCardRef = (el, item) => {
if (el && item && item.id != null) {
cardRefsMap.set(item.id, el)
} else if (item && item.id != null) {
cardRefsMap.delete(item.id)
}
}
const getCardRefs = () => Array.from(cardRefsMap.values())
const handleCardClick = (item) => {
emit('cardClick', item)
}
@ -381,13 +358,10 @@ onUnmounted(() => {
uni.$off('assetLiked')
})
// ========== spotlight / ==========
// ========== ==========
defineExpose({
loadMore,
getCardRefs,
mainTabsRef,
categoryRef,
categoryHeight,
updateScroll,
update,
remeasure,

View File

@ -48,6 +48,7 @@ function handleClick() {
top: 400rpx;
left: 50%;
transform: translateX(-50%);
animation: podium-float-center 3s ease-in-out infinite;
&::before {
content: "";
position: absolute;
@ -69,12 +70,17 @@ function handleClick() {
height: 100%;
}
}
.top-label{
left: 58%;
}
}
/* TOP 2: 左上 */
.podium-2 {
top: 184rpx;
left: -96rpx;
animation: podium-float 3.4s ease-in-out infinite;
animation-delay: -1.2s;
&::before {
content: "";
position: absolute;
@ -101,6 +107,8 @@ function handleClick() {
.podium-3 {
top: 152rpx;
right: -96rpx;
animation: podium-float 3.2s ease-in-out infinite;
animation-delay: -2s;
&::before {
content: "";
position: absolute;
@ -113,7 +121,7 @@ function handleClick() {
transform: scale(0.85);
}
.cover-wrap {
width: 176rpx;
width: 160rpx;
height: 180rpx;
bottom: 72rpx;
.podium-frame {
@ -168,4 +176,25 @@ function handleClick() {
z-index: 7;
padding: 0 16rpx;
}
/* 上下浮动动画podium-1 需保留 translateX(-50%) 居中,单独一组 keyframes */
@keyframes podium-float-center {
0%,
100% {
transform: translateX(-50%) translateY(0);
}
50% {
transform: translateX(-50%) translateY(-12rpx);
}
}
@keyframes podium-float {
0%,
100% {
transform: translateY(0);
}
50% {
transform: translateY(-10rpx);
}
}
</style>

View File

@ -27,7 +27,7 @@
:style="ringItemStyle(p)"
@click="handleClick(items[i])"
>
<!-- 相框最底 -->
<!-- 相框中间 -->
<view class="top-label">{{ formatLabel(p.rank) }}</view>
<image class="ring-frame" :src="ringFrameSrc(p.rank)" mode="aspectFit" />
<image
@ -35,6 +35,13 @@
:src="items[i]?.cover_url || items[i]?.cover_image || ''"
mode="aspectFill"
/>
<!-- 底座最底层 -->
<image
v-if="baseSrc(p.rank)"
class="base-image"
:src="baseSrc(p.rank)"
mode="aspectFit"
/>
</view>
</view>
</template>
@ -91,6 +98,14 @@ function ringFrameSrc(rank) {
return `/static/square/galaxy/LV${rank}.png`;
}
function baseSrc(rank) {
// 使4-6 dizuo1, 7-9 dizuo2, 10-12 dizuo3
if (rank >= 4 && rank <= 6) return "/static/square/galaxy/dizuo1.png";
if (rank >= 7 && rank <= 9) return "/static/square/galaxy/dizuo2.png";
if (rank >= 10 && rank <= 12) return "/static/square/galaxy/dizuo3.png";
return "";
}
function handleClick(item) {
if (item) emit("cardClick", item);
}
@ -125,6 +140,7 @@ function handleClick(item) {
cursor: pointer;
/* display: flex; */
/* flex-direction: column; */
will-change: transform;
animation: orbit 36s linear infinite;
}
@ -169,6 +185,16 @@ function handleClick(item) {
position: absolute;
top: 0;
}
.base-image {
position: absolute;
bottom: -24rpx;
left: -8rpx;
width: 96rpx; /* 比 cover (84rpx) 略宽,呈现"承托"感 */
height: 32rpx;
z-index: 0; /* 位于 cover 之下 */
pointer-events: none;
}
</style>
<style>
@ -217,10 +243,10 @@ function handleClick(item) {
}
/* 可访问性:减少动画 */
@media (prefers-reduced-motion: reduce) {
/* @media (prefers-reduced-motion: reduce) {
.ring-item,
.crown {
animation: none !important;
}
}
} */
</style>

View File

@ -1,156 +0,0 @@
/**
* useSpotlight - 滚动 spotlight / 边缘渐隐
*
* 行为
* - opacity 等于元素在视口中可见的比例
* - 元素完全在视口内 1
* - 元素被滚出一半 0.5
* - 元素完全在视口外 0
* - 元素比视口高且完全覆盖视口 1
*
* 关键修复针对 app-plus
* - app-plus <scroll-view> 是原生 UIScrollViewWebView 内容本身不滚动
* getBoundingClientRect() 永远返回初始 layout 位置不随原生滚动更新
* 所以基于 rAF/setTimeout + getBoundingClientRect 的方案在 app-plus 上完全无效
* - uni.createSelectorQuery() 取位置 uni-app 官方 API会桥到原生层
* app-plus / H5 / 小程序上都能拿到当前可视位置
* - 异步结果用 reqId 防止乱序连续触发时丢弃旧结果
*
* 用法square.vue
* const { start, stop } = useSpotlight({ getElements })
* onMounted(start); onUnmounted(stop)
* // @scroll 事件也调一次 update(),作为双保险
*/
import { getCurrentInstance } from 'vue'
const SEEN_THRESHOLD = 0.5 // 透明度高于此值记为"首次出现"
export function useSpotlight(options = {}) {
const { getElements = () => [] } = options
// app-plus / Vue3 组件内 createSelectorQuery 必须挂 in(public proxy),否则 selectAll 在 app 端返回空、opacity 从不生效
const pageProxy = getCurrentInstance()?.proxy || null
const seenSet = typeof WeakSet !== "undefined" ? new WeakSet() : new Set()
const computeOpacity = (rect, vh) => {
if (!rect) return 1
if (rect.bottom <= 0 || rect.top >= vh) return 0
if (rect.height >= vh && rect.top <= 0 && rect.bottom >= vh) return 1
const visibleTop = Math.max(0, rect.top)
const visibleBottom = Math.min(vh, rect.bottom)
const visibleHeight = Math.max(0, visibleBottom - visibleTop)
const totalHeight = rect.height
if (totalHeight <= 0) return 1
return Math.min(1, visibleHeight / totalHeight)
}
// 给每个 ref 对应的 DOM 节点加 data-spotlight-id方便 SelectorQuery 结果映射回节点
const idToNode = new Map()
let nextId = 1
const tagNodes = () => {
const elements = getElements()
elements.forEach((ref) => {
if (!ref) return
const node = ref.$el || ref
if (!node || node.nodeType !== 1) return
if (node.dataset && node.dataset.spotlightId) return
const id = `sl${nextId++}`
node.setAttribute("data-spotlight-id", id)
idToNode.set(id, node)
})
}
// SelectorQuery 异步:连续触发时用 reqId 丢弃旧结果
let reqId = 0
const applyRects = (rects, vh) => {
if (!rects || !rects.length) return
rects.forEach((rect) => {
const id = rect && rect.dataset && rect.dataset.spotlightId
if (!id) return
const node = idToNode.get(id) || (typeof document !== "undefined" && document.querySelector(`[data-spotlight-id="${id}"]`))
if (!node) return
const opacity = computeOpacity(rect, vh)
try {
node.style.opacity = opacity.toFixed(3)
} catch (e) {}
if (opacity > SEEN_THRESHOLD && !seenSet.has(node)) {
seenSet.add(node)
try { node.classList.add("first-seen") } catch (e) {}
}
})
}
// 测高度
const getVH = () => {
try {
if (typeof window !== "undefined" && window.innerHeight) return window.innerHeight
} catch (e) {}
try {
const info = uni.getSystemInfoSync()
return info.windowHeight || info.screenHeight || 667
} catch (e) {
return 667
}
}
const update = () => {
if (typeof uni === "undefined" || typeof uni.createSelectorQuery !== "function") return
tagNodes()
if (!idToNode.size) return
const myReq = ++reqId
const vh = getVH()
try {
const query = pageProxy ? uni.createSelectorQuery().in(pageProxy) : uni.createSelectorQuery()
query.selectAll("[data-spotlight-id]").boundingClientRect()
query.exec((res) => {
if (myReq !== reqId) return // 旧请求,丢弃
if (!res || !res[0]) return
applyRects(res[0], vh)
})
} catch (e) {
// SelectorQuery 不可用时不做事
}
}
// setTimeout 递归轮询:兜底,@scroll 漏触发时仍能跑
let timerId = null
const tick = () => {
update()
timerId = setTimeout(tick, 50) // 50ms ≈ 20fpsSelectorQuery 开销小但不为零,频率不用太高
}
const start = () => {
if (timerId) return
timerId = setTimeout(tick, 50)
}
const stop = () => {
if (timerId) {
clearTimeout(timerId)
timerId = null
}
}
// 兼容:@scroll 触发时直接调一次双保险scroll 事件比 50ms 轮询更密)
const bindScroll = () => {
update()
}
return {
update,
bindScroll,
start,
stop,
isH5: typeof window !== "undefined" && typeof document !== "undefined",
}
}

View File

@ -45,17 +45,14 @@
<!-- 内容区域 -->
<scroll-view
class="content-wrapper"
:class="{ 'spotlight-ready': isH5 }"
scroll-y
:show-scrollbar="false"
:bounce="false"
@scroll="onScroll"
@touchstart="onTouchStart"
@touchmove="onTouchMove"
@scrolltolower="handleScrollToLower"
>
<!-- 区域一顶部运营轮播图 -->
<view ref="bannerSectionRef" class="banner-section">
<view class="banner-section">
<BannerCarousel
:bannerActivities="bannerActivities"
:banners="banners"
@ -66,7 +63,6 @@
</view>
<ContentTabs
ref="contentTabsRef"
class="tabs"
:modelValue="activeContentTab"
@update:modelValue="activeContentTab = $event"
@ -80,7 +76,6 @@
<!-- 在线榜单区块 - 仅在 星榜 时显示 -->
<view
v-if="activeContentTab === 'xingbang'"
ref="hotCategoryRef"
class="hot-category-wrapper"
>
<HotCategoryBlock @cardClick="handleCardClick" />
@ -90,7 +85,6 @@
<CreationGrid
v-if="activeContentTab === 'guangchang'"
@cardClick="handleCardClick"
@loaded="onGridLoaded"
ref="creationGridRef"
/>
</scroll-view>
@ -98,7 +92,7 @@
</template>
<script setup>
import { ref, watch, onMounted, onUnmounted, nextTick } from "vue";
import { ref, onMounted, onUnmounted } from "vue";
import { onLoad, onShow } from "@dcloudio/uni-app";
import { useStore } from "vuex";
import Header from "../components/Header.vue";
@ -112,7 +106,6 @@ import CreationGrid from "./components/CreationGrid.vue";
import StarGalaxy from "./components/StarGalaxy/index.vue";
// import { clearSubStepProgress, shouldShowGuideStartModal } from '@/utils/guideConfig.js'
import { useBanner } from "./composables/useBanner.js";
import { useSpotlight } from "./composables/useSpotlight.js";
import { doubleTapLike } from "@/utils/likeHelper.js";
// ========== Store & User Info ==========
@ -128,65 +121,13 @@ const creationGridRef = ref(null);
const cardTapTimers = {};
const likingMap = ref({});
// ========== template ref ==========
const bannerSectionRef = ref(null);
const contentTabsRef = ref(null);
const hotCategoryRef = ref(null);
// mainTabsRef / categoryRef CreationGrid creationGridRef.value.mainTabsRef / categoryRef
// - 4
// - CreationGrid defineExpose getCardRefs
const allSpotlightRefs = () => {
const sections = [
bannerSectionRef.value,
contentTabsRef.value?.$el || contentTabsRef.value,
// hotCategoryRef.value,
creationGridRef.value?.mainTabsRef?.value,
creationGridRef.value?.categoryRef?.value,
].filter(Boolean);
const cards = creationGridRef.value?.getCardRefs?.() || [];
return [...sections, ...cards];
};
const {
update,
bindScroll,
start: startSpotlight,
stop: stopSpotlight,
isH5,
} = useSpotlight({
getElements: allSpotlightRefs,
});
// scroll spotlight + CreationGrid fixed
// scroll CreationGrid fixed
const onScroll = (e) => {
bindScroll(); // scroll rAF
const scrollTop = (e && e.detail && e.detail.scrollTop) || 0;
// CreationGrid scrollTop fixed
creationGridRef.value?.updateScroll?.(scrollTop);
};
// rAF opacity
// touchstart / touchmove update() rAF
const onTouchStart = () => {
update();
};
const onTouchMove = () => {
update();
};
// tab spotlight
watch(activeContentTab, () => {
nextTick(() => setTimeout(update, 50));
});
// spotlight 200ms update
const onGridLoaded = (count) => {
if (count > 0) {
nextTick(() => update());
}
};
// ========== Composables ==========
const { bannerActivities, banners, loadBannerActivities, loadBanners } =
useBanner();
@ -302,19 +243,10 @@ onMounted(() => {
resetSquare();
loadBannerActivities();
loadBanners();
// DOM spotlight
// / CreationGrid onMounted
nextTick(() => {
startSpotlight();
});
});
onShow(() => {
activeContentTab.value = "xinghe";
nextTick(() => {
setTimeout(update, 80);
});
});
// onLoad((options) => {
@ -350,7 +282,6 @@ uni.$on("guide:openComponent", (componentName) => {
});
onUnmounted(() => {
stopSpotlight();
uni.$off("guide:openComponent");
});
</script>
@ -412,9 +343,7 @@ onUnmounted(() => {
}
/* Tab+ + +
已迁入 CreationGrid 组件内部创建于 components/CreationGrid.vue
这里的 .main-tab-section / .category-section / .tab-item / 等选择器被保留
是因为 .spotlight-ready 选择器需要它们CSS specificity 才能匹配容器外层应用 spotlight-ready */
已迁入 CreationGrid 组件内部创建于 components/CreationGrid.vue */
/* 热门分类区块 */
.hot-category-wrapper {
@ -445,84 +374,4 @@ onUnmounted(() => {
opacity: 1;
}
}
/* ========== spotlight H5 ==========
* JS --scroll-opacity 写到每个 .spotlight 元素上
* 边缘渐隐由 CSS transition 平滑
* H5 不写变量默认 1元素完全可见 */
</style>
<style>
.spotlight,
.spotlight-ready .banner-section,
.spotlight-ready .tabs,
.spotlight-ready .hot-category-wrapper,
.spotlight-ready .main-tab-section,
.spotlight-ready .category-section,
.spotlight-ready .creation-card {
/* 默认 opacity 1JS 通过 node.style.opacity 行内样式直接覆盖 */
opacity: 1;
transition: opacity 0.22s ease-out;
will-change: opacity;
}
/* 主Tab 三个图标首次进入视野时 pop 一下first-seen 由 JS 写) */
.spotlight-ready .main-tab-section.first-seen .tab-item .tab-icon-wrap {
animation: tabIconPop 0.6s cubic-bezier(0.34, 1.56, 0.64, 1) both;
animation-delay: var(--tab-delay, 0s);
}
@keyframes tabIconPop {
0% {
transform: scale(0.5) rotate(-90deg);
opacity: 0;
}
60% {
transform: scale(1.08) rotate(0deg);
opacity: 1;
}
100% {
transform: scale(1) rotate(0);
opacity: 1;
}
}
/* 分类标签 fixed 时的回弹just-fixed 短暂挂载) */
.spotlight-ready .category-section.just-fixed {
animation: categoryBounce 0.4s cubic-bezier(0.34, 1.56, 0.64, 1);
}
@keyframes categoryBounce {
0% {
transform: scale(1);
}
50% {
transform: scale(0.96);
}
100% {
transform: scale(1);
}
}
/* 动效减弱的可访问性兜底 */
@media (prefers-reduced-motion: reduce) {
.spotlight,
.spotlight-ready .banner-section,
.spotlight-ready .tabs,
.spotlight-ready .hot-category-wrapper,
.spotlight-ready .main-tab-section,
.spotlight-ready .category-section,
.spotlight-ready .creation-card,
.spotlight-ready .main-tab-section.first-seen .tab-item .tab-icon-wrap,
.spotlight-ready .category-section.just-fixed {
transition: none !important;
animation: none !important;
opacity: 1 !important;
transform: none !important;
}
}
</style>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 85 KiB

After

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

After

Width:  |  Height:  |  Size: 15 KiB

View File

Before

Width:  |  Height:  |  Size: 1.0 MiB

After

Width:  |  Height:  |  Size: 1.0 MiB