feat: 增加主页的动画
This commit is contained in:
parent
e1829b2296
commit
7bc6c7e535
@ -1,7 +1,7 @@
|
|||||||
<template>
|
<template>
|
||||||
<view class="ai-dazi-container">
|
<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">
|
<view class="close-btn" @click="handleClose">
|
||||||
|
|||||||
@ -3,6 +3,7 @@
|
|||||||
<image class="bg-wrapper" src="/static/castlove/beijingban.png" mode="aspectFill"></image>
|
<image class="bg-wrapper" src="/static/castlove/beijingban.png" mode="aspectFill"></image>
|
||||||
<view class="main-container">
|
<view class="main-container">
|
||||||
<view class="cards-container" @touchstart="onTouchStart" @touchmove="onTouchMove" @touchend="onTouchEnd">
|
<view class="cards-container" @touchstart="onTouchStart" @touchmove="onTouchMove" @touchend="onTouchEnd">
|
||||||
|
<view v-if="currentCardList.length">
|
||||||
<view v-for="(card, index) in currentCardList" :key="index" class="card-item"
|
<view v-for="(card, index) in currentCardList" :key="index" class="card-item"
|
||||||
:class="{ 'no-transition': disableTransition }" :style="getCardStyle(index)">
|
:class="{ 'no-transition': disableTransition }" :style="getCardStyle(index)">
|
||||||
<view class="card-frame"
|
<view class="card-frame"
|
||||||
@ -14,8 +15,15 @@
|
|||||||
</view>
|
</view>
|
||||||
</view>
|
</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">
|
<view class="arrow-btn arrow-up" @click="scrollUp">
|
||||||
<image class="arrow-icon" src="/static/castlove/jiantou.png" style="transform:rotate(180deg)" />
|
<image class="arrow-icon" src="/static/castlove/jiantou.png" style="transform:rotate(180deg)" />
|
||||||
</view>
|
</view>
|
||||||
@ -36,6 +44,39 @@
|
|||||||
|
|
||||||
<script>
|
<script>
|
||||||
export default {
|
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() {
|
onShow() {
|
||||||
try {
|
try {
|
||||||
uni.hideToast()
|
uni.hideToast()
|
||||||
@ -44,7 +85,18 @@ export default {
|
|||||||
},
|
},
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
|
// 是否显示右侧分类菜单栏
|
||||||
|
// - 作为组件(mall.vue 中)使用时,默认 true
|
||||||
|
// - 作为页面被直接打开时,onLoad 会把它置为 false
|
||||||
|
showMenu: true,
|
||||||
selectedCategoryIndex: 0,
|
selectedCategoryIndex: 0,
|
||||||
|
// 主Tab 的 type 值与 categoryList 索引的映射
|
||||||
|
// 与 square.vue / CastloveContent.vue 中 mainTabs 的 type 保持一致
|
||||||
|
categoryTypeMap: {
|
||||||
|
star_card: 0,
|
||||||
|
badge: 1,
|
||||||
|
poster: 2,
|
||||||
|
},
|
||||||
categoryList: [{ name: '星卡' }, { name: '吧唧' }, { name: '海报' }],
|
categoryList: [{ name: '星卡' }, { name: '吧唧' }, { name: '海报' }],
|
||||||
cardListMap: {
|
cardListMap: {
|
||||||
'星卡': [
|
'星卡': [
|
||||||
@ -81,12 +133,23 @@ export default {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
|
currentCategoryName() {
|
||||||
|
return this.categoryList[this.selectedCategoryIndex]?.name || ''
|
||||||
|
},
|
||||||
currentCardList() {
|
currentCardList() {
|
||||||
const name = this.categoryList[this.selectedCategoryIndex]?.name
|
const name = this.categoryList[this.selectedCategoryIndex]?.name
|
||||||
return this.cardListMap[name] || this.cardList
|
return this.cardListMap[name] || this.cardList
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
|
// 根据 type 字符串定位到对应分类索引
|
||||||
|
applyType(type) {
|
||||||
|
if (!type) return
|
||||||
|
const idx = this.categoryTypeMap[type]
|
||||||
|
if (typeof idx === 'number') {
|
||||||
|
this.selectedCategoryIndex = idx
|
||||||
|
}
|
||||||
|
},
|
||||||
selectCategory(index) {
|
selectCategory(index) {
|
||||||
this.selectedCategoryIndex = index
|
this.selectedCategoryIndex = index
|
||||||
this.selectedIndex = 1
|
this.selectedIndex = 1
|
||||||
@ -344,4 +407,39 @@ export default {
|
|||||||
.font-mid {
|
.font-mid {
|
||||||
font-size: 30rpx;
|
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>
|
</style>
|
||||||
@ -1,36 +1,74 @@
|
|||||||
<template>
|
<template>
|
||||||
<view class="page-container">
|
<view class="page-container">
|
||||||
<CastloveContent />
|
<view class="nav-back" @tap="goBack">
|
||||||
|
<text class="nav-back-icon">←</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<CastloveContent :type="initialType" />
|
||||||
|
|
||||||
<!-- 蒙层 - 导航栏展开时显示 -->
|
<!-- 蒙层 - 导航栏展开时显示 -->
|
||||||
<view v-if="navExpanded" class="nav-mask" @click="navExpanded = false"></view>
|
<view
|
||||||
|
v-if="navExpanded"
|
||||||
|
class="nav-mask"
|
||||||
|
@click="navExpanded = false"
|
||||||
|
></view>
|
||||||
|
|
||||||
<BottomNav :activeTab="2" :isExpanded="navExpanded" @update:activeTab="handleTabChange" @update:isExpanded="navExpanded = $event" />
|
<BottomNav
|
||||||
|
:activeTab="2"
|
||||||
|
:isExpanded="navExpanded"
|
||||||
|
@update:activeTab="handleTabChange"
|
||||||
|
@update:isExpanded="navExpanded = $event"
|
||||||
|
/>
|
||||||
</view>
|
</view>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref } from "vue";
|
import { ref } from "vue";
|
||||||
|
import { onLoad } from "@dcloudio/uni-app";
|
||||||
import BottomNav from "../components/BottomNav.vue";
|
import BottomNav from "../components/BottomNav.vue";
|
||||||
import CastloveContent from "./craft-select.vue";
|
import CastloveContent from "./craft-select.vue";
|
||||||
|
|
||||||
const navExpanded = ref(false);
|
const navExpanded = ref(false);
|
||||||
|
// 从入口 URL 接收 type,作为组件的初始分类
|
||||||
|
// (由主Tab square.vue / CastloveContent.vue 跳转时通过 query 传入)
|
||||||
|
const initialType = ref("star_card");
|
||||||
|
|
||||||
const handleTabChange = (newTab) => {
|
const handleTabChange = (newTab) => {
|
||||||
const routes = [
|
const routes = [
|
||||||
'/pages/ai-dazi/index',
|
"/pages/ai-dazi/index",
|
||||||
'/pages/starbook/index',
|
"/pages/starbook/index",
|
||||||
'/pages/castlove/mall',
|
"/pages/castlove/mall",
|
||||||
'/pages/starcity/index',
|
"/pages/starcity/index",
|
||||||
'/pages/square/square'
|
"/pages/square/square",
|
||||||
];
|
];
|
||||||
|
|
||||||
if (newTab >= 0 && newTab < routes.length) {
|
if (newTab >= 0 && newTab < routes.length) {
|
||||||
uni.navigateTo({
|
uni.navigateTo({
|
||||||
url: routes[newTab]
|
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>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
@ -42,6 +80,26 @@ const handleTabChange = (newTab) => {
|
|||||||
overflow: hidden;
|
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 {
|
.nav-mask {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
top: 0;
|
top: 0;
|
||||||
|
|||||||
@ -312,19 +312,10 @@ const handleBannerClick = (banner) => {
|
|||||||
// 主Tab点击 - 进入铸造页面
|
// 主Tab点击 - 进入铸造页面
|
||||||
const handleMainTabClick = (tab) => {
|
const handleMainTabClick = (tab) => {
|
||||||
console.log('点击主Tab:', tab);
|
console.log('点击主Tab:', tab);
|
||||||
// 只有星卡类型才跳转到创建页面
|
// 跳转到 mall 页(mall 内嵌 craft-select 组件,带菜单),并带上 type
|
||||||
if (tab.type === 'star_card') {
|
|
||||||
uni.navigateTo({
|
uni.navigateTo({
|
||||||
url: `/pages/castlove/craft-select`
|
url: `/pages/castlove/mall?type=${encodeURIComponent(tab.type)}`
|
||||||
});
|
});
|
||||||
} else {
|
|
||||||
// 其他类型暂时提示
|
|
||||||
uni.showToast({
|
|
||||||
title: `${tab.name}功能开发中`,
|
|
||||||
icon: 'none',
|
|
||||||
duration: 1500
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// 分类切换
|
// 分类切换
|
||||||
|
|||||||
@ -628,11 +628,13 @@ defineExpose({
|
|||||||
left: 0;
|
left: 0;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
|
border-radius: 120rpx;
|
||||||
z-index: 0;
|
z-index: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* --- 上层:文字内容 --- */
|
/* --- 上层:文字内容 --- */
|
||||||
.crystal-text-layer {
|
.crystal-text-layer {
|
||||||
|
height: 100%;
|
||||||
position: relative;
|
position: relative;
|
||||||
z-index: 2;
|
z-index: 2;
|
||||||
display: flex;
|
display: flex;
|
||||||
@ -647,7 +649,6 @@ defineExpose({
|
|||||||
rgba(51, 241, 255, 0) 178.07%
|
rgba(51, 241, 255, 0) 178.07%
|
||||||
);
|
);
|
||||||
padding: 0 24rpx;
|
padding: 0 24rpx;
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.balance-number {
|
.balance-number {
|
||||||
|
|||||||
@ -1,6 +1,12 @@
|
|||||||
<template>
|
<template>
|
||||||
<view class="creation-grid">
|
<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>
|
<image class="creation-image" :src="item.cover_image" mode="aspectFill"></image>
|
||||||
<view class="like-badge">
|
<view class="like-badge">
|
||||||
<view class="like-icon-wrapper">
|
<view class="like-icon-wrapper">
|
||||||
@ -49,7 +55,7 @@ const props = defineProps({
|
|||||||
isActive: { type: Boolean, default: true },
|
isActive: { type: Boolean, default: true },
|
||||||
})
|
})
|
||||||
|
|
||||||
const emit = defineEmits(['cardClick', 'scroll'])
|
const emit = defineEmits(['cardClick', 'scroll', 'loaded'])
|
||||||
|
|
||||||
const creationList = ref([])
|
const creationList = ref([])
|
||||||
const cursor = ref('')
|
const cursor = ref('')
|
||||||
@ -65,6 +71,17 @@ const formatCount = (count) => {
|
|||||||
return count.toString()
|
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) => {
|
const handleCardClick = (item) => {
|
||||||
emit('cardClick', item)
|
emit('cardClick', item)
|
||||||
}
|
}
|
||||||
@ -102,6 +119,7 @@ const loadUsers = async () => {
|
|||||||
console.error('[CreationGrid] 加载数据失败', e?.message ?? e)
|
console.error('[CreationGrid] 加载数据失败', e?.message ?? e)
|
||||||
} finally {
|
} finally {
|
||||||
isLoading.value = false
|
isLoading.value = false
|
||||||
|
emit('loaded', creationList.value.length)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -137,6 +155,7 @@ const loadMore = async () => {
|
|||||||
console.error('[CreationGrid] 加载更多失败', e?.message ?? e)
|
console.error('[CreationGrid] 加载更多失败', e?.message ?? e)
|
||||||
} finally {
|
} finally {
|
||||||
isLoading.value = false
|
isLoading.value = false
|
||||||
|
emit('loaded', creationList.value.length)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -188,7 +207,7 @@ onUnmounted(() => {
|
|||||||
uni.$off("assetLiked");
|
uni.$off("assetLiked");
|
||||||
});
|
});
|
||||||
|
|
||||||
defineExpose({ loadMore })
|
defineExpose({ loadMore, getCardRefs })
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<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
|
<scroll-view
|
||||||
class="content-wrapper"
|
class="content-wrapper"
|
||||||
|
:class="{ 'spotlight-ready': isH5 }"
|
||||||
scroll-y
|
scroll-y
|
||||||
:show-scrollbar="false"
|
:show-scrollbar="false"
|
||||||
:bounce="false"
|
:bounce="false"
|
||||||
|
@scroll="onScroll"
|
||||||
|
@touchstart="onTouchStart"
|
||||||
|
@touchmove="onTouchMove"
|
||||||
@scrolltolower="handleScrollToLower"
|
@scrolltolower="handleScrollToLower"
|
||||||
>
|
>
|
||||||
<!-- 区域一:顶部运营轮播图 -->
|
<!-- 区域一:顶部运营轮播图 -->
|
||||||
<view class="banner-section">
|
<view ref="bannerSectionRef" class="banner-section">
|
||||||
<BannerCarousel
|
<BannerCarousel
|
||||||
:bannerActivities="bannerActivities"
|
:bannerActivities="bannerActivities"
|
||||||
@activityClick="handleActivityClick"
|
@activityClick="handleActivityClick"
|
||||||
@ -60,13 +64,14 @@
|
|||||||
</view>
|
</view>
|
||||||
|
|
||||||
<ContentTabs
|
<ContentTabs
|
||||||
|
ref="contentTabsRef"
|
||||||
class="tabs"
|
class="tabs"
|
||||||
:modelValue="activeContentTab"
|
:modelValue="activeContentTab"
|
||||||
@update:modelValue="activeContentTab = $event"
|
@update:modelValue="activeContentTab = $event"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<!-- 在线榜单区块 -->
|
<!-- 在线榜单区块 -->
|
||||||
<view class="hot-category-wrapper">
|
<view ref="hotCategoryRef" class="hot-category-wrapper">
|
||||||
<HotCategoryBlock
|
<HotCategoryBlock
|
||||||
:dimension="activeContentTab"
|
:dimension="activeContentTab"
|
||||||
@cardClick="handleCardClick"
|
@cardClick="handleCardClick"
|
||||||
@ -77,13 +82,15 @@
|
|||||||
</view>
|
</view>
|
||||||
|
|
||||||
<!-- 区域二:主Tab标签区(星卡/吧唧/海报) -->
|
<!-- 区域二:主Tab标签区(星卡/吧唧/海报) -->
|
||||||
<view class="main-tab-section">
|
<view ref="mainTabsRef" class="main-tab-section">
|
||||||
<view
|
<view
|
||||||
v-for="(tab, index) in mainTabs"
|
v-for="(tab, index) in mainTabs"
|
||||||
:key="index"
|
:key="index"
|
||||||
class="tab-item"
|
class="tab-item"
|
||||||
|
:style="{ '--tab-delay': index * 0.06 + 's' }"
|
||||||
@click="handleMainTabClick(tab)"
|
@click="handleMainTabClick(tab)"
|
||||||
>
|
>
|
||||||
|
<view class="tab-icon-wrap">
|
||||||
<image
|
<image
|
||||||
class="tab-icon"
|
class="tab-icon"
|
||||||
:src="tab.icon"
|
:src="tab.icon"
|
||||||
@ -96,15 +103,17 @@
|
|||||||
}"
|
}"
|
||||||
>
|
>
|
||||||
</image>
|
</image>
|
||||||
|
</view>
|
||||||
<text class="tab-name">{{ tab.name }}</text>
|
<text class="tab-name">{{ tab.name }}</text>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<!-- 区域三:分类标签区 -->
|
<!-- 区域三:分类标签区 -->
|
||||||
<view
|
<view
|
||||||
|
ref="categoryRef"
|
||||||
id="category-section"
|
id="category-section"
|
||||||
class="category-section"
|
class="category-section"
|
||||||
:class="{ fixed: isFixed }"
|
:class="{ fixed: isFixed, 'just-fixed': justFixed }"
|
||||||
>
|
>
|
||||||
<scroll-view class="category-scroll" scroll-x :show-scrollbar="false">
|
<scroll-view class="category-scroll" scroll-x :show-scrollbar="false">
|
||||||
<view
|
<view
|
||||||
@ -118,6 +127,12 @@
|
|||||||
</view>
|
</view>
|
||||||
</scroll-view>
|
</scroll-view>
|
||||||
</view>
|
</view>
|
||||||
|
<!-- fixed 时占位:避免下方内容跳变 -->
|
||||||
|
<view
|
||||||
|
v-if="isFixed && categoryHeight > 0"
|
||||||
|
class="category-placeholder"
|
||||||
|
:style="{ height: categoryHeight + 'px' }"
|
||||||
|
></view>
|
||||||
|
|
||||||
<!-- 区域四:创作网格列表 -->
|
<!-- 区域四:创作网格列表 -->
|
||||||
<CreationGrid
|
<CreationGrid
|
||||||
@ -127,6 +142,7 @@
|
|||||||
:category="activeCategoryTab"
|
:category="activeCategoryTab"
|
||||||
:isActive="isActive"
|
:isActive="isActive"
|
||||||
@cardClick="handleCardClick"
|
@cardClick="handleCardClick"
|
||||||
|
@loaded="onGridLoaded"
|
||||||
ref="creationGridRef"
|
ref="creationGridRef"
|
||||||
/>
|
/>
|
||||||
</scroll-view>
|
</scroll-view>
|
||||||
@ -134,7 +150,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<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 { onLoad, onShow, onHide } from "@dcloudio/uni-app";
|
||||||
import { useStore } from "vuex";
|
import { useStore } from "vuex";
|
||||||
import Header from "../components/Header.vue";
|
import Header from "../components/Header.vue";
|
||||||
@ -147,6 +163,7 @@ import HotCategoryBlock from "./components/HotCategoryBlock.vue";
|
|||||||
import CreationGrid from "./components/CreationGrid.vue";
|
import CreationGrid from "./components/CreationGrid.vue";
|
||||||
// import { clearSubStepProgress, shouldShowGuideStartModal } from '@/utils/guideConfig.js'
|
// import { clearSubStepProgress, shouldShowGuideStartModal } from '@/utils/guideConfig.js'
|
||||||
import { useBanner } from "./composables/useBanner.js";
|
import { useBanner } from "./composables/useBanner.js";
|
||||||
|
import { useSpotlight } from "./composables/useSpotlight.js";
|
||||||
import { doubleTapLike } from "@/utils/likeHelper.js";
|
import { doubleTapLike } from "@/utils/likeHelper.js";
|
||||||
|
|
||||||
// ========== Store & User Info ==========
|
// ========== Store & User Info ==========
|
||||||
@ -161,10 +178,88 @@ const navExpanded = ref(false);
|
|||||||
const showRankingModal = ref(false);
|
const showRankingModal = ref(false);
|
||||||
const isActive = ref(true);
|
const isActive = ref(true);
|
||||||
const isFixed = ref(false);
|
const isFixed = ref(false);
|
||||||
|
const justFixed = ref(false);
|
||||||
const creationGridRef = ref(null);
|
const creationGridRef = ref(null);
|
||||||
const cardTapTimers = {};
|
const cardTapTimers = {};
|
||||||
const likingMap = ref({});
|
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配置
|
// 主Tab配置
|
||||||
const mainTabs = ref([
|
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) => {
|
const handleCategoryChange = (value) => {
|
||||||
if (activeCategoryTab.value === value) return;
|
if (activeCategoryTab.value === value) return;
|
||||||
activeCategoryTab.value = value;
|
activeCategoryTab.value = value;
|
||||||
@ -332,12 +435,26 @@ onMounted(() => {
|
|||||||
|
|
||||||
resetSquare();
|
resetSquare();
|
||||||
loadBannerActivities();
|
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(() => {
|
onShow(() => {
|
||||||
isActive.value = true;
|
isActive.value = true;
|
||||||
activeContentTab.value = "displaying";
|
activeContentTab.value = "displaying";
|
||||||
activeCategoryTab.value = "hot";
|
activeCategoryTab.value = "hot";
|
||||||
|
nextTick(() => {
|
||||||
|
setTimeout(update, 80);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
onHide(() => {
|
onHide(() => {
|
||||||
@ -377,6 +494,7 @@ uni.$on("guide:openComponent", (componentName) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
|
stopSpotlight();
|
||||||
uni.$off("guide:openComponent");
|
uni.$off("guide:openComponent");
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
@ -448,8 +566,15 @@ onUnmounted(() => {
|
|||||||
opacity: 0.8;
|
opacity: 0.8;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tab-icon {
|
.tab-icon-wrap {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
margin-bottom: 12rpx;
|
margin-bottom: 12rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-icon {
|
||||||
|
margin-bottom: 0;
|
||||||
object-fit: cover;
|
object-fit: cover;
|
||||||
box-shadow: 8rpx 8rpx 16rpx rgba(229, 76, 93, 0.9);
|
box-shadow: 8rpx 8rpx 16rpx rgba(229, 76, 93, 0.9);
|
||||||
}
|
}
|
||||||
@ -477,6 +602,11 @@ onUnmounted(() => {
|
|||||||
padding: 16rpx 0;
|
padding: 16rpx 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.category-placeholder {
|
||||||
|
/* 仅占位,无内容;高度由内联 style 写入 */
|
||||||
|
margin-bottom: 24rpx;
|
||||||
|
}
|
||||||
|
|
||||||
.category-scroll {
|
.category-scroll {
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
@ -562,4 +692,84 @@ onUnmounted(() => {
|
|||||||
opacity: 1;
|
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>
|
</style>
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 37 KiB |
Loading…
Reference in New Issue
Block a user