Files
mini-programs/src/main_pages/components/ListPageContent.tsx
2025-11-16 20:06:22 +08:00

471 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import SearchBar from "@/components/SearchBar";
import FilterPopup from "@/components/FilterPopup";
import styles from "./ListPageContent.module.scss";
import { useEffect, useRef, useCallback, useState } from "react";
import Taro from "@tarojs/taro";
import { useListStore } from "@/store/listStore";
import { useGlobalState } from "@/store/global";
import { View, Image, Text, ScrollView } from "@tarojs/components";
import ListContainer from "@/container/listContainer";
import DistanceQuickFilter from "@/components/DistanceQuickFilter";
import { updateUserLocation } from "@/services/userService";
import { useUserActions } from "@/store/userStore";
import { useDictionaryStore } from "@/store/dictionaryStore";
import { saveImage, navigateTo } from "@/utils";
export interface ListPageContentProps {
onNavStateChange?: (state: {
isShowInputCustomerNavBar?: boolean;
isDistanceFilterVisible?: boolean;
isCityPickerVisible?: boolean;
}) => void;
onScrollToTop?: () => void; // 外部滚动到顶部方法(由主容器提供)
scrollToTopTrigger?: number; // 触发滚动的计数器
onDistanceFilterVisibleChange?: (visible: boolean) => void;
onCityPickerVisibleChange?: (visible: boolean) => void; // 保留接口,但由主容器直接处理
onFilterPopupVisibleChange?: (visible: boolean) => void; // 筛选弹窗显示/隐藏回调
}
const ListPageContent: React.FC<ListPageContentProps> = ({
onNavStateChange,
onScrollToTop: _onScrollToTop,
scrollToTopTrigger,
onDistanceFilterVisibleChange,
onCityPickerVisibleChange: _onCityPickerVisibleChange,
onFilterPopupVisibleChange,
}) => {
const store = useListStore() || {};
const { fetchUserInfo } = useUserActions();
const { statusNavbarHeightInfo, getCurrentLocationInfo } =
useGlobalState() || {};
const { totalHeight = 98 } = statusNavbarHeightInfo || {};
const {
listPageState,
loading,
error,
searchValue,
distanceData,
quickFilterData,
getMatchesData,
updateState,
updateListPageState,
updateFilterOptions,
clearFilterOptions,
initialFilterSearch,
loadMoreMatches,
fetchGetGamesCount,
updateDistanceQuickFilter,
getCities,
getCityQrCode,
area,
cityQrCode,
} = store;
const {
isShowFilterPopup,
data: matches,
recommendList,
filterCount,
filterOptions,
distanceQuickFilter,
isShowInputCustomerNavBar,
pageOption,
isShowNoData,
} = listPageState || {};
const scrollContextRef = useRef(null);
const scrollViewRef = useRef(null);
const scrollTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const lastScrollTopRef = useRef(0);
const scrollDirectionRef = useRef<"up" | "down" | null>(null);
const lastScrollTimeRef = useRef(Date.now());
const loadingMoreRef = useRef(false);
const scrollStartPositionRef = useRef(0);
const [showSearchBar, setShowSearchBar] = useState(true);
const [scrollTop, setScrollTop] = useState(0);
const [refreshing, setRefreshing] = useState(false);
// 处理距离筛选显示/隐藏
const handleDistanceFilterVisibleChange = useCallback(
(visible: boolean) => {
onDistanceFilterVisibleChange?.(visible);
onNavStateChange?.({ isDistanceFilterVisible: visible });
},
[onDistanceFilterVisibleChange, onNavStateChange]
);
// 处理城市选择器显示/隐藏(由主容器统一管理,通过 onNavStateChange 通知)
// 注意CustomerNavBar 的 onCityPickerVisibleChange 由主容器直接处理
// 滚动到顶部(用于 ScrollView 内部滚动)
const scrollToTopInternal = useCallback(() => {
setScrollTop((prev) => (prev === 0 ? 0.1 : 0));
}, []);
// 监听外部滚动触发
useEffect(() => {
if (scrollToTopTrigger && scrollToTopTrigger > 0) {
scrollToTopInternal();
}
}, [scrollToTopTrigger, scrollToTopInternal]);
// 使用 ref 保存最新的状态值,避免依赖项变化导致函数重新创建
const showSearchBarRef = useRef(showSearchBar);
const isShowInputCustomerNavBarRef = useRef(isShowInputCustomerNavBar);
useEffect(() => {
showSearchBarRef.current = showSearchBar;
isShowInputCustomerNavBarRef.current = isShowInputCustomerNavBar;
}, [showSearchBar, isShowInputCustomerNavBar]);
// ScrollView 滚动处理
const handleScrollViewScroll = useCallback(
(e: any) => {
const currentScrollTop = e?.detail?.scrollTop || 0;
const lastScrollTop = lastScrollTopRef.current;
const currentTime = Date.now();
const timeDiff = currentTime - lastScrollTimeRef.current;
if (timeDiff < 100) return;
const scrollDiff = currentScrollTop - lastScrollTop;
let newDirection = scrollDirectionRef.current;
if (Math.abs(scrollDiff) > 15) {
if (scrollDiff > 0) {
if (newDirection !== "up") {
scrollStartPositionRef.current = lastScrollTop;
}
newDirection = "up";
} else {
if (newDirection !== "down") {
scrollStartPositionRef.current = lastScrollTop;
}
newDirection = "down";
}
scrollDirectionRef.current = newDirection;
}
const totalScrollDistance = Math.abs(
currentScrollTop - scrollStartPositionRef.current
);
const positionThreshold = 120;
const distanceThreshold = 80;
// 使用 ref 获取最新值,避免依赖项变化
const currentShowSearchBar = showSearchBarRef.current;
const currentIsShowInputCustomerNavBar = isShowInputCustomerNavBarRef.current;
if (
newDirection === "up" &&
currentScrollTop > positionThreshold &&
totalScrollDistance > distanceThreshold
) {
if (currentShowSearchBar || !currentIsShowInputCustomerNavBar) {
setShowSearchBar(false);
updateListPageState({
isShowInputCustomerNavBar: true,
});
onNavStateChange?.({ isShowInputCustomerNavBar: true });
scrollStartPositionRef.current = currentScrollTop;
}
} else if (
(newDirection === "down" && totalScrollDistance > distanceThreshold) ||
currentScrollTop <= positionThreshold
) {
if (!currentShowSearchBar || currentIsShowInputCustomerNavBar) {
setShowSearchBar(true);
updateListPageState({
isShowInputCustomerNavBar: false,
});
onNavStateChange?.({ isShowInputCustomerNavBar: false });
scrollStartPositionRef.current = currentScrollTop;
}
}
lastScrollTopRef.current = currentScrollTop;
lastScrollTimeRef.current = currentTime;
},
[updateListPageState, onNavStateChange]
// 移除 showSearchBar 和 isShowInputCustomerNavBar 依赖,使用 ref 获取最新值
);
useEffect(() => {
// 分批异步执行初始化操作,避免阻塞首屏渲染
// 1. 立即执行:获取城市和二维码(轻量操作)
getCities();
getCityQrCode();
// 2. 延迟执行:获取用户信息(不阻塞渲染)
requestAnimationFrame(() => {
fetchUserInfo().catch((error) => {
console.error('获取用户信息失败:', error);
});
});
// 3. 延迟执行:获取位置信息(可能较慢,不阻塞首屏)
requestAnimationFrame(() => {
requestAnimationFrame(() => {
getLocation().catch((error) => {
console.error('获取位置信息失败:', error);
});
});
});
}, []);
useEffect(() => {
if (pageOption?.page === 1 && matches?.length > 0) {
setShowSearchBar(true);
updateListPageState({
isShowInputCustomerNavBar: false,
});
onNavStateChange?.({ isShowInputCustomerNavBar: false });
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [matches?.length, pageOption?.page]);
// 注意updateListPageState 和 onNavStateChange 是稳定的函数引用,不需要加入依赖项
// 只依赖实际会变化的数据matches 的长度和 pageOption.page
useEffect(() => {
return () => {
if (scrollTimeoutRef.current) {
clearTimeout(scrollTimeoutRef.current);
}
};
}, []);
const getLocation = async () => {
const location = await getCurrentLocationInfo();
updateState({ location });
if (location && location.latitude && location.longitude) {
try {
await updateUserLocation(location.latitude, location.longitude);
} catch (error) {
console.error("更新用户位置失败:", error);
}
}
fetchGetGamesCount();
getMatchesData();
return location;
};
const refreshMatches = async () => {
await initialFilterSearch(true);
};
const handleRefresh = async () => {
setRefreshing(true);
try {
await refreshMatches();
} catch (error) {
(Taro as any).showToast({
title: "刷新失败,请重试",
icon: "error",
duration: 1000,
});
} finally {
// 使用 requestAnimationFrame 替代 setTimeout(0),性能更好
requestAnimationFrame(() => {
setRefreshing(false);
});
}
};
const handleFilterConfirm = () => {
toggleShowPopup();
getMatchesData();
};
const toggleShowPopup = () => {
const newVisible = !isShowFilterPopup;
// 先通知父组件筛选弹窗状态变化(设置 z-index
onFilterPopupVisibleChange?.(newVisible);
// 然后更新本地状态显示/隐藏弹窗
// 使用 requestAnimationFrame 确保 z-index 先设置,再显示弹窗
if (newVisible) {
// 使用双帧延迟确保 z-index 已生效
requestAnimationFrame(() => {
requestAnimationFrame(() => {
updateListPageState({
isShowFilterPopup: newVisible,
});
});
});
} else {
// 关闭时直接更新状态
updateListPageState({
isShowFilterPopup: newVisible,
});
}
};
const handleUpdateFilterOptions = (params: Record<string, any>) => {
updateFilterOptions(params);
};
const handleSearchChange = () => {};
const handleDistanceOrQuickChange = (name, value) => {
updateDistanceQuickFilter({
[name]: value,
});
};
const handleSearchClick = () => {
navigateTo({
url: "/game_pages/search/index",
});
};
const initDictionaryData = async () => {
try {
const { fetchDictionary } = useDictionaryStore.getState();
await fetchDictionary();
} catch (error) {
console.error("初始化字典数据失败:", error);
}
};
useEffect(() => {
initDictionaryData();
}, []);
const area_city = area?.at(-2) || "上海";
function renderCityQrcode() {
let item = cityQrCode.find((item) => item.city_name === area_city);
if (!item) item = cityQrCode.find((item) => item.city_name === "其他");
return (
<View className={styles.cqContainer}>
{item ? (
<View className={styles.wrapper}>
<View className={styles.tips}>
<Text className={styles.tip1}></Text>
<Text className={styles.tip2}>
</Text>
</View>
<View className={styles.qrcodeWrappper}>
<Image
className={styles.qrcode}
src={item.qr_code_url}
mode="widthFix"
onClick={() => {
saveImage(item.qr_code_url);
}}
/>
<Text className={styles.qrcodeTip}>
使
</Text>
</View>
</View>
) : (
<View>
<Text>, </Text>
</View>
)}
</View>
);
}
return (
<>
{area_city !== "上海" ? (
renderCityQrcode()
) : (
<View ref={scrollContextRef}>
<View className={styles.listPage} style={{ paddingTop: totalHeight }}>
{isShowFilterPopup && (
<View>
<FilterPopup
loading={loading}
onCancel={toggleShowPopup}
onConfirm={handleFilterConfirm}
onChange={handleUpdateFilterOptions}
filterOptions={filterOptions}
onClear={clearFilterOptions}
visible={isShowFilterPopup}
onClose={toggleShowPopup}
statusNavbarHeigh={statusNavbarHeightInfo?.totalHeight}
/>
</View>
)}
<View className={styles.fixedHeader}>
<View
className={`${styles.listTopSearchWrapper} ${
showSearchBar ? styles.show : styles.hide
}`}
>
<SearchBar
handleFilterIcon={toggleShowPopup}
isSelect={filterCount > 0}
filterCount={filterCount}
onChange={handleSearchChange}
value={searchValue}
onInputClick={handleSearchClick}
/>
</View>
<View className={styles.listTopFilterWrapper}>
<DistanceQuickFilter
cityOptions={distanceData}
quickOptions={quickFilterData}
onChange={handleDistanceOrQuickChange}
cityName="distanceFilter"
quickName="order"
cityValue={distanceQuickFilter?.distanceFilter}
quickValue={distanceQuickFilter?.order}
onMenuVisibleChange={handleDistanceFilterVisibleChange}
/>
</View>
</View>
<ScrollView
ref={scrollViewRef}
scrollY
scrollTop={scrollTop}
className={styles.listScrollView}
scrollWithAnimation
enhanced
showScrollbar={false}
refresherEnabled={true}
refresherTriggered={refreshing}
onRefresherRefresh={handleRefresh}
lowerThreshold={100}
onScrollToLower={async () => {
if (
!loading &&
!loadingMoreRef.current &&
listPageState?.isHasMoreData
) {
loadingMoreRef.current = true;
try {
await loadMoreMatches();
} catch (error) {
console.error("加载更多失败:", error);
} finally {
loadingMoreRef.current = false;
}
}
}}
onScroll={handleScrollViewScroll}
>
<ListContainer
data={matches}
recommendList={recommendList}
loading={loading}
isShowNoData={isShowNoData}
error={error}
reload={refreshMatches}
loadMoreMatches={loadMoreMatches}
evaluateFlag
/>
</ScrollView>
</View>
</View>
)}
</>
);
};
export default ListPageContent;