Files
mini-programs/src/main_pages/components/ListPageContent.tsx
张成 9f63a2369a 1
2025-12-13 00:20:26 +08:00

613 lines
21 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/DistanceQuickFilterV2";
import { updateUserLocation } from "@/services/userService";
import { useDictionaryStore } from "@/store/dictionaryStore";
import { saveImage, navigateTo } from "@/utils";
export interface ListPageContentProps {
isActive?: boolean; // 是否处于激活状态(当前显示的页面)
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> = ({
isActive = true,
onNavStateChange,
onScrollToTop: _onScrollToTop,
scrollToTopTrigger,
onDistanceFilterVisibleChange,
onCityPickerVisibleChange: _onCityPickerVisibleChange,
onFilterPopupVisibleChange,
}) => {
const store = useListStore() || {};
const { statusNavbarHeightInfo, getCurrentLocationInfo } =
useGlobalState() || {};
const { totalHeight = 98 } = statusNavbarHeightInfo || {};
const {
listPageState,
loading,
error,
searchValue,
distanceData,
quickFilterData,
getMatchesData,
updateState,
updateListPageState,
updateFilterOptions,
clearFilterOptions,
initialFilterSearch,
loadMoreMatches,
fetchGetGamesCount,
refreshBothLists,
updateDistanceQuickFilter,
getCities,
getCityQrCode,
getDistricts,
area,
cityQrCode,
districts,
gamesNum, // 新增:获取球局数量
} = 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 lastLoadedAreaRef = useRef<[string, string] | null>(null);
const prevIsActiveRef = useRef(isActive);
// 处理距离筛选显示/隐藏
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 scrollHeight = e?.detail?.scrollHeight || 0;
const clientHeight = e?.detail?.clientHeight || 0;
const lastScrollTop = lastScrollTopRef.current;
const currentTime = Date.now();
const timeDiff = currentTime - lastScrollTimeRef.current;
if (timeDiff < 100) return;
// 计算距离底部的距离提前加载距离底部600px时开始加载
// 注意:如果 scrollHeight 或 clientHeight 不可用,则使用 lowerThreshold 触发
if (scrollHeight > 0 && clientHeight > 0) {
const distanceToBottom = scrollHeight - currentScrollTop - clientHeight;
const preloadThreshold = 600; // 提前加载阈值
// 如果距离底部小于阈值,且正在向下滚动,且有更多数据,则提前加载
if (
distanceToBottom < preloadThreshold &&
distanceToBottom > 0 &&
!loading &&
!loadingMoreRef.current &&
listPageState?.isHasMoreData &&
currentScrollTop > lastScrollTop // 向下滚动
) {
loadingMoreRef.current = true;
loadMoreMatches().finally(() => {
loadingMoreRef.current = false;
});
}
}
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, loading, loadMoreMatches, listPageState?.isHasMoreData]
// 移除 showSearchBar 和 isShowInputCustomerNavBar 依赖,使用 ref 获取最新值
);
useEffect(() => {
// 分批异步执行初始化操作,避免阻塞首屏渲染
// 1. 立即执行:获取城市、二维码和行政区列表(轻量操作)
getCities();
getCityQrCode();
getDistricts(); // 新增:获取行政区列表
// 只有当页面激活时才加载位置和列表数据
if (isActive) {
getLocation().catch((error) => {
console.error('获取位置信息失败:', error);
});
}
}, [isActive]);
// 记录上一次的城市,用于检测城市变化
const prevAreaRef = useRef<[string, string] | null>(null);
// 监听城市变化,重新获取行政区列表并清空已选择的行政区
useEffect(() => {
if (area && area.length >= 2) {
const currentProvince = area[1];
const prevProvince = prevAreaRef.current?.[1];
// 只有当城市真正改变时才执行(避免初始化时也触发)
if (prevProvince && prevProvince !== currentProvince) {
console.log("城市改变,重新获取行政区列表:", {
prevProvince,
currentProvince,
});
// 城市改变时,重新获取行政区列表
getDistricts();
// 清空已选择的行政区,避免显示错误的行政区
const currentState = useListStore.getState();
const currentPageState = currentState.isSearchResult
? currentState.searchPageState
: currentState.listPageState;
if (currentPageState?.distanceQuickFilter?.district) {
updateDistanceQuickFilter({ district: undefined });
}
}
// 更新记录的城市
prevAreaRef.current = area as [string, string];
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [area?.[1]]); // 只监听省份area[1])的变化
// 当页面从非激活状态切换为激活状态时,检查城市是否变化,如果变化则重新加载数据
useEffect(() => {
// 如果从非激活状态变为激活状态(切回列表页)
if (isActive && !prevIsActiveRef.current) {
const currentArea = area;
const lastArea = lastLoadedAreaRef.current;
// 检查城市是否发生变化(比较省份)
const currentProvince = currentArea?.[1] || "";
const lastProvince = lastArea?.[1] || "";
// 如果城市发生变化,或者地址存在但不一致,需要重新加载数据
// 注意:即使 lastArea 为空,只要 currentArea 存在,也应该加载数据
if (currentProvince && (currentProvince !== lastProvince || !lastArea)) {
console.log("切回列表页,检测到地址变化或不一致,重新加载数据:", {
lastArea,
currentArea,
lastProvince,
currentProvince,
});
// 地址发生变化或不一致,重新加载数据和球局数量
// 先调用列表接口,然后在列表接口完成后调用数量接口
(async () => {
try {
if (refreshBothLists) {
await refreshBothLists();
}
// 列表接口完成后,再调用数量接口
if (fetchGetGamesCount) {
await fetchGetGamesCount();
}
// 数据加载完成后,更新记录的城市(记录为上一次在列表页加载数据时的城市)
if (currentArea) {
lastLoadedAreaRef.current = [...currentArea] as [string, string];
}
} catch (error) {
console.error("重新加载数据失败:", error);
}
})();
}
}
// 如果是首次加载且列表页激活,记录当前城市(用于后续比较)
if (isActive && !lastLoadedAreaRef.current && area) {
lastLoadedAreaRef.current = [...area] as [string, string];
}
// 更新上一次的激活状态
prevIsActiveRef.current = isActive;
}, [isActive, area, refreshBothLists, fetchGetGamesCount]);
// 监听城市变化(在列表页激活状态下),当城市切换后立即更新记录
// 注意:这个 useEffect 用于处理在列表页激活状态下切换城市的情况
// 当用户在列表页切换城市时HomeNavbar 的 handleCityChange 已经会调用 refreshBothLists
// 这里只需要同步更新 lastLoadedAreaRef确保后续检测逻辑正确
useEffect(() => {
// 如果页面激活且城市发生变化(用户在列表页切换了城市)
if (isActive && area) {
const currentProvince = area[1] || "";
const lastProvince = lastLoadedAreaRef.current?.[1] || "";
// 如果城市发生变化,立即更新记录(因为 refreshBothLists 已经在 HomeNavbar 中调用)
if (currentProvince && currentProvince !== lastProvince) {
// 立即更新记录,确保地址显示和使用的地址一致
lastLoadedAreaRef.current = [...area] as [string, string];
}
}
}, [isActive, area]);
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);
}
}
// 先调用列表接口
await getMatchesData();
// 列表接口完成后,再调用数量接口
await fetchGetGamesCount();
// 初始数据加载完成后,记录当前城市
if (area && isActive) {
lastLoadedAreaRef.current = [...area] as [string, string];
}
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();
}, []);
// 获取省份名称area 格式: ["中国", "省份"]
const province = area?.at(1) || "上海";
function renderCityQrcode() {
// 根据省份查找对应的二维码
let item = cityQrCode.find((item) => item.city_name === province);
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"
showMenuByLongpress
onClick={() => {
saveImage(item.qr_code_url);
}}
/>
<Text className={styles.qrcodeTip}>
使
</Text>
</View>
</View>
) : (
<View>
<Text>, </Text>
</View>
)}
</View>
);
}
// 判定是否显示"暂无球局"页面
// 条件:省份不是上海 或 (已加载完成且球局数量为0)
const shouldShowNoGames = province !== "上海";
return (
<>
{shouldShowNoGames ? (
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}
districtOptions={districts || []}
cityName="distanceFilter"
quickName="order"
districtName="district"
cityValue={distanceQuickFilter?.distanceFilter}
quickValue={distanceQuickFilter?.order}
districtValue={distanceQuickFilter?.district}
onMenuVisibleChange={handleDistanceFilterVisibleChange}
/>
</View>
</View>
<ScrollView refresherBackground="#FAFAFA"
ref={scrollViewRef}
scrollY
scrollTop={scrollTop}
className={styles.listScrollView}
scrollWithAnimation
enhanced
showScrollbar={false}
refresherEnabled={true}
refresherTriggered={refreshing}
onRefresherRefresh={handleRefresh}
lowerThreshold={600}
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;