feat: 增加主页的动画
This commit is contained in:
parent
e1829b2296
commit
7bc6c7e535
@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<view class="ai-dazi-container">
|
||||
<!-- 背景图 -->
|
||||
<image class="bg-image" src="/static/AIimg/beijing.jpg" mode="aspectFill" />
|
||||
<image class="bg-image" src="/static/AIimg/beijing.png" mode="aspectFill" />
|
||||
|
||||
<!-- 左上角关闭按钮 -->
|
||||
<view class="close-btn" @click="handleClose">
|
||||
|
||||
@ -3,19 +3,27 @@
|
||||
<image class="bg-wrapper" src="/static/castlove/beijingban.png" mode="aspectFill"></image>
|
||||
<view class="main-container">
|
||||
<view class="cards-container" @touchstart="onTouchStart" @touchmove="onTouchMove" @touchend="onTouchEnd">
|
||||
<view v-for="(card, index) in currentCardList" :key="index" class="card-item"
|
||||
:class="{ 'no-transition': disableTransition }" :style="getCardStyle(index)">
|
||||
<view class="card-frame"
|
||||
:class="{ 'no-border': card.comingSoon, 'card-frame--tappable': !card.comingSoon }"
|
||||
@tap.stop="onCardFrameTap(index)">
|
||||
<image class="card-image" :src="card.image" mode="aspectFill" />
|
||||
<image v-if="card.comingSoon" class="coming-soon-badge"
|
||||
src="/static/castlove/jinqingqidai.png" />
|
||||
<view v-if="currentCardList.length">
|
||||
<view v-for="(card, index) in currentCardList" :key="index" class="card-item"
|
||||
:class="{ 'no-transition': disableTransition }" :style="getCardStyle(index)">
|
||||
<view class="card-frame"
|
||||
:class="{ 'no-border': card.comingSoon, 'card-frame--tappable': !card.comingSoon }"
|
||||
@tap.stop="onCardFrameTap(index)">
|
||||
<image class="card-image" :src="card.image" mode="aspectFill" />
|
||||
<image v-if="card.comingSoon" class="coming-soon-badge"
|
||||
src="/static/castlove/jinqingqidai.png" />
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
<!-- 空态:当前分类暂无卡片时显示 -->
|
||||
<!-- <view v-else class="empty-state">
|
||||
<image class="empty-state-icon" src="/static/castlove/jinqingqidai.png" mode="aspectFit" />
|
||||
<text class="empty-state-title">{{ currentCategoryName }}敬请期待</text>
|
||||
<text class="empty-state-desc">该分类正在打磨中,马上与您见面</text>
|
||||
</view> -->
|
||||
</view>
|
||||
|
||||
<view class="text-panel">
|
||||
<view v-if="showMenu" class="text-panel">
|
||||
<view class="arrow-btn arrow-up" @click="scrollUp">
|
||||
<image class="arrow-icon" src="/static/castlove/jiantou.png" style="transform:rotate(180deg)" />
|
||||
</view>
|
||||
@ -36,6 +44,39 @@
|
||||
|
||||
<script>
|
||||
export default {
|
||||
// 作为组件被 mall.vue 引入时的入参
|
||||
// (作为页面被 navigateTo 直接打开时不传值,走 onLoad 读取 URL 参数)
|
||||
props: {
|
||||
// 初始分类:star_card | badge | poster
|
||||
type: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
// 作为组件时,父组件传 type 进来时自动定位
|
||||
// 用 immediate + handler 显式写法,避免简写在某些 H5 场景下不触发
|
||||
type: {
|
||||
immediate: true,
|
||||
handler(val) {
|
||||
this.applyType(val)
|
||||
},
|
||||
},
|
||||
},
|
||||
created() {
|
||||
// 组件创建时立即应用一次 type,避免 mall.vue 在 onLoad 才设置 initialType
|
||||
// 导致首次渲染拿到默认 "" 而 watch 错过的情况
|
||||
this.applyType(this.type)
|
||||
},
|
||||
onLoad(options) {
|
||||
// 作为页面被直接打开时,隐藏右侧分类菜单栏(根据用户需求)
|
||||
this.showMenu = false
|
||||
// 接收从主Tab(square.vue / CastloveContent.vue)传入的 type,自动定位到对应分类
|
||||
// type 取值:star_card | badge | poster
|
||||
if (options && options.type) {
|
||||
this.applyType(options.type)
|
||||
}
|
||||
},
|
||||
onShow() {
|
||||
try {
|
||||
uni.hideToast()
|
||||
@ -44,7 +85,18 @@ export default {
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
// 是否显示右侧分类菜单栏
|
||||
// - 作为组件(mall.vue 中)使用时,默认 true
|
||||
// - 作为页面被直接打开时,onLoad 会把它置为 false
|
||||
showMenu: true,
|
||||
selectedCategoryIndex: 0,
|
||||
// 主Tab 的 type 值与 categoryList 索引的映射
|
||||
// 与 square.vue / CastloveContent.vue 中 mainTabs 的 type 保持一致
|
||||
categoryTypeMap: {
|
||||
star_card: 0,
|
||||
badge: 1,
|
||||
poster: 2,
|
||||
},
|
||||
categoryList: [{ name: '星卡' }, { name: '吧唧' }, { name: '海报' }],
|
||||
cardListMap: {
|
||||
'星卡': [
|
||||
@ -81,12 +133,23 @@ export default {
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
currentCategoryName() {
|
||||
return this.categoryList[this.selectedCategoryIndex]?.name || ''
|
||||
},
|
||||
currentCardList() {
|
||||
const name = this.categoryList[this.selectedCategoryIndex]?.name
|
||||
return this.cardListMap[name] || this.cardList
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
// 根据 type 字符串定位到对应分类索引
|
||||
applyType(type) {
|
||||
if (!type) return
|
||||
const idx = this.categoryTypeMap[type]
|
||||
if (typeof idx === 'number') {
|
||||
this.selectedCategoryIndex = idx
|
||||
}
|
||||
},
|
||||
selectCategory(index) {
|
||||
this.selectedCategoryIndex = index
|
||||
this.selectedIndex = 1
|
||||
@ -344,4 +407,39 @@ export default {
|
||||
.font-mid {
|
||||
font-size: 30rpx;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 80%;
|
||||
padding: 40rpx 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.empty-state-icon {
|
||||
width: 200rpx;
|
||||
height: 200rpx;
|
||||
opacity: 0.85;
|
||||
margin-bottom: 32rpx;
|
||||
}
|
||||
|
||||
.empty-state-title {
|
||||
color: #fff;
|
||||
font-size: 36rpx;
|
||||
font-weight: 600;
|
||||
margin-bottom: 16rpx;
|
||||
text-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.35);
|
||||
}
|
||||
|
||||
.empty-state-desc {
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
font-size: 26rpx;
|
||||
text-align: center;
|
||||
}
|
||||
</style>
|
||||
@ -1,66 +1,124 @@
|
||||
<template>
|
||||
<view class="page-container">
|
||||
<CastloveContent />
|
||||
<view class="page-container">
|
||||
<view class="nav-back" @tap="goBack">
|
||||
<text class="nav-back-icon">←</text>
|
||||
</view>
|
||||
|
||||
<!-- 蒙层 - 导航栏展开时显示 -->
|
||||
<view v-if="navExpanded" class="nav-mask" @click="navExpanded = false"></view>
|
||||
<CastloveContent :type="initialType" />
|
||||
|
||||
<BottomNav :activeTab="2" :isExpanded="navExpanded" @update:activeTab="handleTabChange" @update:isExpanded="navExpanded = $event" />
|
||||
</view>
|
||||
<!-- 蒙层 - 导航栏展开时显示 -->
|
||||
<view
|
||||
v-if="navExpanded"
|
||||
class="nav-mask"
|
||||
@click="navExpanded = false"
|
||||
></view>
|
||||
|
||||
<BottomNav
|
||||
:activeTab="2"
|
||||
:isExpanded="navExpanded"
|
||||
@update:activeTab="handleTabChange"
|
||||
@update:isExpanded="navExpanded = $event"
|
||||
/>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from "vue";
|
||||
import { onLoad } from "@dcloudio/uni-app";
|
||||
import BottomNav from "../components/BottomNav.vue";
|
||||
import CastloveContent from "./craft-select.vue";
|
||||
|
||||
const navExpanded = ref(false);
|
||||
// 从入口 URL 接收 type,作为组件的初始分类
|
||||
// (由主Tab square.vue / CastloveContent.vue 跳转时通过 query 传入)
|
||||
const initialType = ref("star_card");
|
||||
|
||||
const handleTabChange = (newTab) => {
|
||||
const routes = [
|
||||
'/pages/ai-dazi/index',
|
||||
'/pages/starbook/index',
|
||||
'/pages/castlove/mall',
|
||||
'/pages/starcity/index',
|
||||
'/pages/square/square'
|
||||
];
|
||||
const routes = [
|
||||
"/pages/ai-dazi/index",
|
||||
"/pages/starbook/index",
|
||||
"/pages/castlove/mall",
|
||||
"/pages/starcity/index",
|
||||
"/pages/square/square",
|
||||
];
|
||||
|
||||
if (newTab >= 0 && newTab < routes.length) {
|
||||
uni.navigateTo({
|
||||
url: routes[newTab]
|
||||
});
|
||||
}
|
||||
if (newTab >= 0 && newTab < routes.length) {
|
||||
uni.navigateTo({
|
||||
url: routes[newTab],
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const goBack = () => {
|
||||
// 获取页面栈
|
||||
const pages = getCurrentPages();
|
||||
if (pages.length > 1) {
|
||||
// 有上一页,执行返回
|
||||
uni.navigateBack();
|
||||
} else {
|
||||
// 没有上一页,跳转到square页面
|
||||
uni.reLaunch({
|
||||
url: "/pages/square/square",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// uni-app 页面生命周期:接收跳转参数
|
||||
onLoad((options) => {
|
||||
if (options && options.type) {
|
||||
initialType.value = options.type;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.page-container {
|
||||
position: relative;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
min-height: 100vh;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
min-height: 100vh;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.nav-back {
|
||||
width: 80rpx;
|
||||
height: 80rpx;
|
||||
position: fixed;
|
||||
top: 96rpx;
|
||||
left: 32rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 12;
|
||||
/* background: rgba(255,255,255,0.5);
|
||||
border-radius: 50%; */
|
||||
}
|
||||
|
||||
.nav-back-icon {
|
||||
font-size: 48rpx;
|
||||
font-weight: bold;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.nav-mask {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
z-index: 999;
|
||||
animation: fadeIn 0.3s ease-out;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
z-index: 999;
|
||||
animation: fadeIn 0.3s ease-out;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@ -312,19 +312,10 @@ const handleBannerClick = (banner) => {
|
||||
// 主Tab点击 - 进入铸造页面
|
||||
const handleMainTabClick = (tab) => {
|
||||
console.log('点击主Tab:', tab);
|
||||
// 只有星卡类型才跳转到创建页面
|
||||
if (tab.type === 'star_card') {
|
||||
uni.navigateTo({
|
||||
url: `/pages/castlove/craft-select`
|
||||
});
|
||||
} else {
|
||||
// 其他类型暂时提示
|
||||
uni.showToast({
|
||||
title: `${tab.name}功能开发中`,
|
||||
icon: 'none',
|
||||
duration: 1500
|
||||
});
|
||||
}
|
||||
// 跳转到 mall 页(mall 内嵌 craft-select 组件,带菜单),并带上 type
|
||||
uni.navigateTo({
|
||||
url: `/pages/castlove/mall?type=${encodeURIComponent(tab.type)}`
|
||||
});
|
||||
};
|
||||
|
||||
// 分类切换
|
||||
|
||||
@ -628,11 +628,13 @@ defineExpose({
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border-radius: 120rpx;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
/* --- 上层:文字内容 --- */
|
||||
.crystal-text-layer {
|
||||
height: 100%;
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
display: flex;
|
||||
@ -647,7 +649,6 @@ defineExpose({
|
||||
rgba(51, 241, 255, 0) 178.07%
|
||||
);
|
||||
padding: 0 24rpx;
|
||||
|
||||
}
|
||||
|
||||
.balance-number {
|
||||
|
||||
@ -1,6 +1,12 @@
|
||||
<template>
|
||||
<view class="creation-grid">
|
||||
<view v-for="(item, index) in creationList" :key="item.id" class="creation-card" @click="handleCardClick(item)">
|
||||
<view
|
||||
v-for="item in creationList"
|
||||
:key="item.id"
|
||||
:ref="(el) => setCardRef(el, item)"
|
||||
class="creation-card"
|
||||
@click="handleCardClick(item)"
|
||||
>
|
||||
<image class="creation-image" :src="item.cover_image" mode="aspectFill"></image>
|
||||
<view class="like-badge">
|
||||
<view class="like-icon-wrapper">
|
||||
@ -49,7 +55,7 @@ const props = defineProps({
|
||||
isActive: { type: Boolean, default: true },
|
||||
})
|
||||
|
||||
const emit = defineEmits(['cardClick', 'scroll'])
|
||||
const emit = defineEmits(['cardClick', 'scroll', 'loaded'])
|
||||
|
||||
const creationList = ref([])
|
||||
const cursor = ref('')
|
||||
@ -65,6 +71,17 @@ const formatCount = (count) => {
|
||||
return count.toString()
|
||||
}
|
||||
|
||||
// 卡片 DOM 引用 map(key = item.id)。供父组件读取位置做 spotlight。
|
||||
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)
|
||||
}
|
||||
@ -102,6 +119,7 @@ const loadUsers = async () => {
|
||||
console.error('[CreationGrid] 加载数据失败', e?.message ?? e)
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
emit('loaded', creationList.value.length)
|
||||
}
|
||||
}
|
||||
|
||||
@ -137,6 +155,7 @@ const loadMore = async () => {
|
||||
console.error('[CreationGrid] 加载更多失败', e?.message ?? e)
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
emit('loaded', creationList.value.length)
|
||||
}
|
||||
}
|
||||
|
||||
@ -188,7 +207,7 @@ onUnmounted(() => {
|
||||
uni.$off("assetLiked");
|
||||
});
|
||||
|
||||
defineExpose({ loadMore })
|
||||
defineExpose({ loadMore, getCardRefs })
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
151
frontend/pages/square/composables/useSpotlight.js
Normal file
151
frontend/pages/square/composables/useSpotlight.js
Normal file
@ -0,0 +1,151 @@
|
||||
/**
|
||||
* useSpotlight - 滚动 spotlight / 边缘渐隐
|
||||
*
|
||||
* 行为:
|
||||
* - opacity 等于「元素在视口中可见的比例」
|
||||
* - 元素完全在视口内 → 1
|
||||
* - 元素被滚出一半 → 0.5
|
||||
* - 元素完全在视口外 → 0
|
||||
* - 元素比视口高、且完全覆盖视口 → 1
|
||||
*
|
||||
* 关键修复(针对 app-plus):
|
||||
* - app-plus 的 <scroll-view> 是原生 UIScrollView,WebView 内容本身不滚动。
|
||||
* 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(),作为双保险
|
||||
*/
|
||||
const SEEN_THRESHOLD = 0.5 // 透明度高于此值记为"首次出现"
|
||||
|
||||
export function useSpotlight(options = {}) {
|
||||
const { getElements = () => [] } = options
|
||||
|
||||
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 = 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 ≈ 20fps,SelectorQuery 开销小但不为零,频率不用太高
|
||||
}
|
||||
|
||||
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",
|
||||
}
|
||||
}
|
||||
@ -45,13 +45,17 @@
|
||||
<!-- 内容区域 -->
|
||||
<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 class="banner-section">
|
||||
<view ref="bannerSectionRef" class="banner-section">
|
||||
<BannerCarousel
|
||||
:bannerActivities="bannerActivities"
|
||||
@activityClick="handleActivityClick"
|
||||
@ -60,13 +64,14 @@
|
||||
</view>
|
||||
|
||||
<ContentTabs
|
||||
ref="contentTabsRef"
|
||||
class="tabs"
|
||||
:modelValue="activeContentTab"
|
||||
@update:modelValue="activeContentTab = $event"
|
||||
/>
|
||||
|
||||
<!-- 在线榜单区块 -->
|
||||
<view class="hot-category-wrapper">
|
||||
<view ref="hotCategoryRef" class="hot-category-wrapper">
|
||||
<HotCategoryBlock
|
||||
:dimension="activeContentTab"
|
||||
@cardClick="handleCardClick"
|
||||
@ -77,34 +82,38 @@
|
||||
</view>
|
||||
|
||||
<!-- 区域二:主Tab标签区(星卡/吧唧/海报) -->
|
||||
<view class="main-tab-section">
|
||||
<view ref="mainTabsRef" 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)"
|
||||
>
|
||||
<image
|
||||
class="tab-icon"
|
||||
:src="tab.icon"
|
||||
mode="aspectFit"
|
||||
:style="{
|
||||
width: tab.width + 'rpx',
|
||||
height: tab.height + 'rpx',
|
||||
borderRadius: tab.type === 'badge' ? '50%' : '0',
|
||||
transform: 'rotate(' + tab.rotate + 'deg)',
|
||||
}"
|
||||
>
|
||||
</image>
|
||||
<view class="tab-icon-wrap">
|
||||
<image
|
||||
class="tab-icon"
|
||||
:src="tab.icon"
|
||||
mode="aspectFit"
|
||||
:style="{
|
||||
width: tab.width + 'rpx',
|
||||
height: tab.height + 'rpx',
|
||||
borderRadius: tab.type === 'badge' ? '50%' : '0',
|
||||
transform: 'rotate(' + tab.rotate + 'deg)',
|
||||
}"
|
||||
>
|
||||
</image>
|
||||
</view>
|
||||
<text class="tab-name">{{ tab.name }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 区域三:分类标签区 -->
|
||||
<view
|
||||
ref="categoryRef"
|
||||
id="category-section"
|
||||
class="category-section"
|
||||
:class="{ fixed: isFixed }"
|
||||
:class="{ fixed: isFixed, 'just-fixed': justFixed }"
|
||||
>
|
||||
<scroll-view class="category-scroll" scroll-x :show-scrollbar="false">
|
||||
<view
|
||||
@ -118,6 +127,12 @@
|
||||
</view>
|
||||
</scroll-view>
|
||||
</view>
|
||||
<!-- fixed 时占位:避免下方内容跳变 -->
|
||||
<view
|
||||
v-if="isFixed && categoryHeight > 0"
|
||||
class="category-placeholder"
|
||||
:style="{ height: categoryHeight + 'px' }"
|
||||
></view>
|
||||
|
||||
<!-- 区域四:创作网格列表 -->
|
||||
<CreationGrid
|
||||
@ -127,6 +142,7 @@
|
||||
:category="activeCategoryTab"
|
||||
:isActive="isActive"
|
||||
@cardClick="handleCardClick"
|
||||
@loaded="onGridLoaded"
|
||||
ref="creationGridRef"
|
||||
/>
|
||||
</scroll-view>
|
||||
@ -134,7 +150,7 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, watch, onMounted, onUnmounted } from "vue";
|
||||
import { ref, computed, watch, onMounted, onUnmounted, nextTick } from "vue";
|
||||
import { onLoad, onShow, onHide } from "@dcloudio/uni-app";
|
||||
import { useStore } from "vuex";
|
||||
import Header from "../components/Header.vue";
|
||||
@ -147,6 +163,7 @@ import HotCategoryBlock from "./components/HotCategoryBlock.vue";
|
||||
import CreationGrid from "./components/CreationGrid.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 ==========
|
||||
@ -161,10 +178,88 @@ const navExpanded = ref(false);
|
||||
const showRankingModal = ref(false);
|
||||
const isActive = ref(true);
|
||||
const isFixed = ref(false);
|
||||
const justFixed = ref(false);
|
||||
const creationGridRef = ref(null);
|
||||
const cardTapTimers = {};
|
||||
const likingMap = ref({});
|
||||
|
||||
// ========== 各区块的 template ref ==========
|
||||
const bannerSectionRef = ref(null);
|
||||
const contentTabsRef = ref(null);
|
||||
const hotCategoryRef = ref(null);
|
||||
const mainTabsRef = ref(null);
|
||||
const categoryRef = ref(null);
|
||||
|
||||
// ========== 分类标签 fixed 触发的"占位 + 阈值" ==========
|
||||
// 记录 section 在滚动内容中的初始位置(offsetTop)和自然高度
|
||||
// 滚动时用 scrollTop + 阈值 对比,决定是否切到 fixed
|
||||
const categoryOffsetTop = ref(null);
|
||||
const categoryHeight = ref(0);
|
||||
const fixedTopPx = ref(50); // 近似对应 CSS 的 top: 96rpx
|
||||
|
||||
// ========== 滚动 spotlight ==========
|
||||
// 把所有"会随滚动淡出/淡入"的元素喂给 composable:
|
||||
// - 4 个区块
|
||||
// - CreationGrid 里的所有卡片(通过 defineExpose 暴露的 getCardRefs 拿到)
|
||||
const allSpotlightRefs = () => {
|
||||
const sections = [
|
||||
bannerSectionRef.value,
|
||||
contentTabsRef.value?.$el || contentTabsRef.value,
|
||||
hotCategoryRef.value,
|
||||
mainTabsRef.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 + isFixed
|
||||
const onScroll = (e) => {
|
||||
bindScroll() // 兼容路径:scroll 事件触发时也跑一次(主驱动是 rAF 轮询)
|
||||
const scrollTop = (e && e.detail && e.detail.scrollTop) || 0
|
||||
if (categoryOffsetTop.value !== null) {
|
||||
isFixed.value = scrollTop + fixedTopPx.value >= categoryOffsetTop.value
|
||||
}
|
||||
}
|
||||
|
||||
// 移动端 rAF 在用户滚动时会被节流,opacity 跟不上。
|
||||
// 用 touchstart / touchmove 同步触发 update(),绕开 rAF 节流。
|
||||
const onTouchStart = () => {
|
||||
update()
|
||||
}
|
||||
const onTouchMove = () => {
|
||||
update()
|
||||
}
|
||||
|
||||
// 分类标签 fixed 触发的回弹:状态变化瞬间加 class,动画播完摘掉
|
||||
watch(isFixed, (val) => {
|
||||
if (!val) return;
|
||||
justFixed.value = true;
|
||||
setTimeout(() => {
|
||||
justFixed.value = false;
|
||||
}, 500);
|
||||
});
|
||||
|
||||
// tab / 分类变化 → 新内容可能进入视口,重做一次 spotlight 计算
|
||||
watch(activeContentTab, () => {
|
||||
nextTick(() => setTimeout(update, 50));
|
||||
});
|
||||
watch(activeCategoryTab, () => {
|
||||
nextTick(() => setTimeout(update, 50));
|
||||
});
|
||||
|
||||
// 卡片加载到位 → 重新算一次 spotlight(卡片异步加载,不在初始 200ms update 范围内)
|
||||
const onGridLoaded = (count) => {
|
||||
if (count > 0) {
|
||||
nextTick(() => update());
|
||||
}
|
||||
};
|
||||
|
||||
// 主Tab配置
|
||||
const mainTabs = ref([
|
||||
{
|
||||
@ -313,6 +408,14 @@ const handleTabChange = (newTab) => {
|
||||
}
|
||||
};
|
||||
|
||||
// 主Tab点击 - 进入铸造页面
|
||||
const handleMainTabClick = (tab) => {
|
||||
// 跳转到 mall 页(mall 内嵌 craft-select 组件,带菜单),并带上 type
|
||||
uni.navigateTo({
|
||||
url: `/pages/castlove/mall?type=${encodeURIComponent(tab.type)}`,
|
||||
});
|
||||
};
|
||||
|
||||
const handleCategoryChange = (value) => {
|
||||
if (activeCategoryTab.value === value) return;
|
||||
activeCategoryTab.value = value;
|
||||
@ -332,12 +435,26 @@ onMounted(() => {
|
||||
|
||||
resetSquare();
|
||||
loadBannerActivities();
|
||||
|
||||
// 等首屏 DOM 稳定后,测量分类标签 section 的位置/高度 + 启动 spotlight 轮询
|
||||
nextTick(() => {
|
||||
if (categoryRef.value) {
|
||||
categoryOffsetTop.value = categoryRef.value.offsetTop;
|
||||
categoryHeight.value = categoryRef.value.offsetHeight;
|
||||
}
|
||||
// rpx → px:96rpx 在不同屏幕宽度下换算
|
||||
fixedTopPx.value = (96 * screenWidth.value) / 750;
|
||||
startSpotlight();
|
||||
});
|
||||
});
|
||||
|
||||
onShow(() => {
|
||||
isActive.value = true;
|
||||
activeContentTab.value = "displaying";
|
||||
activeCategoryTab.value = "hot";
|
||||
nextTick(() => {
|
||||
setTimeout(update, 80);
|
||||
});
|
||||
});
|
||||
|
||||
onHide(() => {
|
||||
@ -377,6 +494,7 @@ uni.$on("guide:openComponent", (componentName) => {
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
stopSpotlight();
|
||||
uni.$off("guide:openComponent");
|
||||
});
|
||||
</script>
|
||||
@ -448,8 +566,15 @@ onUnmounted(() => {
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.tab-icon {
|
||||
.tab-icon-wrap {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-bottom: 12rpx;
|
||||
}
|
||||
|
||||
.tab-icon {
|
||||
margin-bottom: 0;
|
||||
object-fit: cover;
|
||||
box-shadow: 8rpx 8rpx 16rpx rgba(229, 76, 93, 0.9);
|
||||
}
|
||||
@ -477,6 +602,11 @@ onUnmounted(() => {
|
||||
padding: 16rpx 0;
|
||||
}
|
||||
|
||||
.category-placeholder {
|
||||
/* 仅占位,无内容;高度由内联 style 写入 */
|
||||
margin-bottom: 24rpx;
|
||||
}
|
||||
|
||||
.category-scroll {
|
||||
white-space: nowrap;
|
||||
}
|
||||
@ -562,4 +692,84 @@ 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 1;JS 通过 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: 37 KiB |
Loading…
Reference in New Issue
Block a user