This commit is contained in:
张成
2025-11-09 18:05:03 +08:00
parent 712ebe6463
commit 0090fc45c6
9 changed files with 211 additions and 110 deletions

View File

@@ -6,7 +6,7 @@
--nutui-menu-bar-line-height: 30px;
background-color: unset;
box-shadow: unset;
padding: 0 15px;
// padding: 0 15px;
gap: 5px;
}

View File

@@ -5,6 +5,14 @@ import { DialogCalendarCard } from '@/components/index'
import './TimeSelector.scss'
import dayjs from 'dayjs'
// 安全地解析日期字符串,兼容 iOS
const parseDate = (dateStr: string): Date => {
if (!dateStr) return new Date();
// 将 "yyyy-MM-dd HH:mm:ss" 转换为 "yyyy-MM-ddTHH:mm:ss"
const isoStr = dateStr.replace(/^(\d{4}-\d{2}-\d{2})\s(\d{2}:\d{2}(?::\d{2})?)$/, '$1T$2');
return new Date(isoStr);
};
export interface TimeRange {
start_time: string
end_time: string
@@ -39,7 +47,7 @@ const TimeSelector: React.FC<TimeSelectorProps> = ({
if (onChange) onChange({start_time, end_time})
}
const openPicker = (type: 'start' | 'end') => {
setCurrentTimeValue(type === 'start' ? new Date(value.start_time) : new Date(value.end_time))
setCurrentTimeValue(type === 'start' ? parseDate(value.start_time) : parseDate(value.end_time))
setCurrentTimeType(type)
setVisible(true)
}

View File

@@ -84,6 +84,7 @@
padding: 7.8px;
height: 30px;
box-sizing: border-box;
background: #fff;
border-radius: 30px;
border: 0.488px solid rgba(0, 0, 0, 0.06);
box-shadow: 0 3.902px 46.829px 0 rgba(0, 0, 0, 0.08);

View File

@@ -1,6 +1,6 @@
export default definePageConfig({
navigationBarTitleText: '',
enablePullDownRefresh: true,
enablePullDownRefresh: false, // 禁用页面级下拉刷新,使用 ScrollView 的下拉刷新
backgroundTextStyle: 'dark',
navigationStyle: 'custom',
backgroundColor: '#FAFAFA',

View File

@@ -81,22 +81,54 @@
background-color: #FAFAFA;
font-family: "PingFang SC";
transition: padding-top 0.3s ease-in-out; // 添加过渡动画,让布局变化更平滑
display: flex;
flex-direction: column;
height: calc(100vh - var(--status-bar-height, 0px) - var(--nav-bar-height, 0px) - 112px); // 减去底部导航栏高度 112px
overflow: hidden;
.listTopSearchWrapper {
padding: 0 15px;
.fixedHeader {
position: sticky;
top: 0;
z-index: 100;
background-color: #fff;
display: flex;
flex-direction: column;
}
.listTopSearchWrapper {
background-color: #fff;
// 使用 margin-top 负值来控制可见性,保持元素高度不变,筛选项位置固定
transition: margin-top 0.25s cubic-bezier(0.4, 0, 0.2, 1),
opacity 0.2s ease-out;
padding: 10px 15px 10px 15px; // 统一边距上下10px 左右15px
box-sizing: border-box;
overflow: hidden;
will-change: margin-top, opacity;
&.show {
opacity: 1;
margin-top: 0; // 正常显示
}
&.hide {
opacity: 0;
margin-top: -64px; // 使用负 margin 向上隐藏,但仍占据布局空间 (44px内容 + 20px padding = 64px)
pointer-events: none; // 隐藏时禁用交互
}
}
.listTopFilterWrapper {
display: flex;
box-sizing: border-box;
align-items: center;
padding-top: 10px;
padding-bottom: 10px;
padding: 0 15px 10px 15px; // 上0 左右15px 下10px与搜索框左右对齐下边距一致
gap: 5px;
position: sticky;
background-color: #fff;
z-index: 100;
}
.listScrollView {
flex: 1;
height: 0; // 让 flex 生效
}
.menuFilter {

View File

@@ -2,14 +2,10 @@ import SearchBar from "@/components/SearchBar";
import FilterPopup from "@/components/FilterPopup";
import styles from "./index.module.scss";
import { useEffect, useRef, useCallback, useState } from "react";
import Taro, {
usePageScroll,
// useShareAppMessage,
// useShareTimeline,
} from "@tarojs/taro";
import Taro from "@tarojs/taro";
import { useListStore } from "@/store/listStore";
import { useGlobalState } from "@/store/global";
import { View, Image, Text } from "@tarojs/components";
import { View, Image, Text, ScrollView } from "@tarojs/components";
import CustomerNavBar from "@/container/listCustomNavbar";
import GuideBar from "@/components/GuideBar";
import ListContainer from "@/container/listContainer";
@@ -65,46 +61,75 @@ const ListPage = () => {
isShowNoData,
} = listPageState || {};
// 防抖的滚动处理函数
const handleScroll = useCallback(
(res) => {
const currentScrollTop = res?.scrollTop || 0;
// 滚动相关状态
const scrollContextRef = 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 [showSearchBar, setShowSearchBar] = useState(true); // 控制搜索框显示/隐藏(筛选始终显示)
// 添加缓冲区,避免临界点频繁切换
const buffer = 10; // 10px 缓冲区
const shouldShowInputNav = currentScrollTop >= totalHeight + buffer;
const shouldHideInputNav = currentScrollTop < totalHeight - buffer;
// 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 < 16) return; // 约60fps避免过于频繁的计算
// 计算滚动距离
const scrollDiff = currentScrollTop - lastScrollTop;
// 判断滚动方向(提高阈值避免微小滚动误判)
let newDirection = scrollDirectionRef.current;
if (scrollDiff > 5) {
newDirection = 'up';
} else if (scrollDiff < -5) {
newDirection = 'down';
}
scrollDirectionRef.current = newDirection;
// 清除之前的定时器
if (scrollTimeoutRef.current) {
clearTimeout(scrollTimeoutRef.current);
}
// 防抖处理,避免频繁更新状态
scrollTimeoutRef.current = setTimeout(() => {
// 只有在状态真正需要改变时才更新
if (shouldShowInputNav && !isShowInputCustomerNavBar) {
// 滚动阈值(提高阈值减少误触发)
const threshold = 80;
if (newDirection === 'up' && currentScrollTop > threshold) {
// 上滑超过阈值,隐藏搜索框
if (showSearchBar) {
setShowSearchBar(false);
}
if (!isShowInputCustomerNavBar) {
updateListPageState({
isShowInputCustomerNavBar: true,
});
} else if (shouldHideInputNav && isShowInputCustomerNavBar) {
}
} else if (newDirection === 'down' || currentScrollTop <= threshold) {
// 下滑或回到顶部,显示搜索框
if (!showSearchBar) {
setShowSearchBar(true);
}
if (isShowInputCustomerNavBar) {
updateListPageState({
isShowInputCustomerNavBar: false,
});
}
}
lastScrollTopRef.current = currentScrollTop;
}, 16); // 约60fps的防抖间隔
lastScrollTopRef.current = currentScrollTop;
lastScrollTimeRef.current = currentTime;
},
[totalHeight, isShowInputCustomerNavBar, updateState]
[showSearchBar, isShowInputCustomerNavBar, updateListPageState]
);
usePageScroll(handleScroll);
const scrollContextRef = useRef(null);
const scrollTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const lastScrollTopRef = useRef(0);
useEffect(() => {
getLocation();
fetchUserInfo();
@@ -112,15 +137,16 @@ const ListPage = () => {
getCityQrCode();
}, []);
// 监听数据变化,如果是第一页就滚动到顶部
// 监听数据变化,如果是第一页就恢复显示搜索框
useEffect(() => {
if (pageOption?.page === 1 && matches?.length > 0) {
Taro.pageScrollTo({
scrollTop: 0,
duration: 300,
// 恢复搜索框显示
setShowSearchBar(true);
updateListPageState({
isShowInputCustomerNavBar: false,
});
}
}, [matches, pageOption?.page]);
}, [matches, pageOption?.page, updateListPageState]);
// 清理定时器
useEffect(() => {
@@ -164,40 +190,32 @@ const ListPage = () => {
return location;
};
const refreshMatches = () => {
initialFilterSearch(true);
const refreshMatches = async () => {
await initialFilterSearch(true);
};
// const getLoadMoreMatches = () => {
// loadMoreMatches()
// }
// 下拉刷新状态
const [refreshing, setRefreshing] = useState(false);
// 下拉刷新处理函数 - 使用Taro生命周期钩子
Taro.usePullDownRefresh(async () => {
// ScrollView 下拉刷新处理函数
const handleRefresh = async () => {
setRefreshing(true);
try {
// 调用刷新方法
await refreshMatches();
// 刷新完成后停止下拉刷新动画
Taro.stopPullDownRefresh();
// 显示刷新成功提示
// Taro.showToast({
// title: "刷新成功",
// icon: "success",
// duration: 1000,
// });
} catch (error) {
// 刷新失败时也停止动画
Taro.stopPullDownRefresh();
Taro.showToast({
title: "刷新失败,请重试",
icon: "error",
duration: 1000,
});
} finally {
// 使用 setTimeout 确保状态更新在下一个事件循环中执行,让 ScrollView 能正确响应
setTimeout(() => {
setRefreshing(false);
}, 0);
}
});
};
/**
* @description 综合筛选确认
@@ -226,7 +244,7 @@ const ListPage = () => {
updateFilterOptions(params);
};
const handleSearchChange = () => {};
const handleSearchChange = () => { };
// 距离筛选
const handleDistanceOrQuickChange = (name, value) => {
@@ -363,7 +381,7 @@ const ListPage = () => {
return (
<>
{/* 自定义导航 */}
<CustomerNavBar
config={{
@@ -392,44 +410,70 @@ const ListPage = () => {
/>
</View>
)}
<View className={`${styles.listTopSearchWrapper}`}>
<SearchBar
handleFilterIcon={toggleShowPopup}
isSelect={filterCount > 0}
filterCount={filterCount}
onChange={handleSearchChange}
value={searchValue}
onInputClick={handleSearchClick}
/>
</View>
{/* 筛选 */}
<View
className={styles.listTopFilterWrapper}
style={{
top: totalHeight - 1,
}}
>
<DistanceQuickFilter
cityOptions={distanceData}
quickOptions={quickFilterData}
onChange={handleDistanceOrQuickChange}
cityName="distanceFilter"
quickName="order"
cityValue={distanceQuickFilter?.distanceFilter}
quickValue={distanceQuickFilter?.order}
/>
{/* 固定在顶部的搜索框和筛选 */}
<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}
/>
</View>
</View>
{/* 列表内容 */}
<ListContainer
data={matches}
recommendList={recommendList}
loading={loading}
isShowNoData={isShowNoData}
error={error}
reload={refreshMatches}
loadMoreMatches={loadMoreMatches}
/>
{/* 可滚动的列表内容 */}
<ScrollView
scrollY
className={styles.listScrollView}
scrollWithAnimation
enhanced
showScrollbar={false}
refresherEnabled={true}
refresherTriggered={refreshing}
onRefresherRefresh={handleRefresh}
lowerThreshold={100}
onScrollToLower={async () => {
// 防止重复调用,检查 loading 状态和是否正在加载更多
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}
/>
</ScrollView>
</View>
</View>
)}

View File

@@ -520,13 +520,13 @@ export const useListStore = create<TennisStore>()((set, get) => ({
},
// 加载更多数据
loadMoreMatches: () => {
loadMoreMatches: async () => {
const state = get();
const currentPageState = state.isSearchResult ? state.searchPageState : state.listPageState;
const { pageOption, isHasMoreData } = currentPageState || {};
if (!isHasMoreData) {
return;
return Promise.resolve();
}
const newPageOption = {
@@ -539,7 +539,7 @@ export const useListStore = create<TennisStore>()((set, get) => ({
});
// 加载更多时追加数据到现有数组
state.fetchMatches({}, false, true);
return await state.fetchMatches({}, false, true);
},
// 初始化搜索条件 重新搜索

View File

@@ -1,5 +1,21 @@
import dayjs from "dayjs";
/**
* 安全地将字符串转换为 Date 对象,兼容 iOS
* iOS 不支持 "yyyy-MM-dd HH:mm:ss" 格式,需要转换为 "yyyy-MM-ddTHH:mm:ss" 或 "yyyy/MM/dd HH:mm:ss"
* @param dateStr 日期字符串
* @returns Date 对象
*/
const parseDate = (dateStr: string): Date => {
if (!dateStr) return new Date();
// 将 "yyyy-MM-dd HH:mm:ss" 格式转换为 "yyyy-MM-ddTHH:mm:ss" (ISO 8601)
// 替换第一个空格为 T
const isoStr = dateStr.replace(/^(\d{4}-\d{2}-\d{2})\s(\d{2}:\d{2}(?::\d{2})?)$/, '$1T$2');
return new Date(isoStr);
};
/**
* 获取下一个整点时间
* @returns 格式为 YYYY-MM-DD HH:mm 的字符串
@@ -88,7 +104,7 @@ export const getTime = (time: string): string => {
export const formatRelativeTime = (timeStr: string): string => {
if (!timeStr) return "";
const date = new Date(timeStr);
const date = parseDate(timeStr);
const now = new Date();
// 获取日期部分(年-月-日),忽略时间
@@ -150,7 +166,7 @@ export const formatRelativeTime = (timeStr: string): string => {
export const formatShortRelativeTime = (timeStr: string): string => {
if (!timeStr) return "";
const date = new Date(timeStr);
const date = parseDate(timeStr);
const now = new Date();
// 获取日期部分(年-月-日),忽略时间
@@ -204,7 +220,7 @@ export const formatShortRelativeTime = (timeStr: string): string => {
export const formatGameTime = (timeStr: string): string => {
if (!timeStr) return "";
const date = new Date(timeStr);
const date = parseDate(timeStr);
const now = new Date();
// 获取星期几
@@ -287,8 +303,8 @@ export const calculateDuration = (
): string => {
if (!startTime || !endTime) return "";
const start = new Date(startTime);
const end = new Date(endTime);
const start = parseDate(startTime);
const end = parseDate(endTime);
// 计算时间差(毫秒)
const diffMs = end.getTime() - start.getTime();

View File

@@ -110,7 +110,7 @@ export interface ListActions {
clearHistory: () => void;
searchSuggestion: (val: string) => Promise<void>;
getSearchParams: () => Record<string, any>;
loadMoreMatches: () => void;
loadMoreMatches: () => Promise<void>;
initialFilterSearch: (isSearchData?: boolean) => void;
setListData: (payload: IPayload) => void;
fetchGetGamesCount: () => Promise<void>;