1
This commit is contained in:
@@ -6,7 +6,7 @@
|
||||
--nutui-menu-bar-line-height: 30px;
|
||||
background-color: unset;
|
||||
box-shadow: unset;
|
||||
padding: 0 15px;
|
||||
// padding: 0 15px;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
export default definePageConfig({
|
||||
navigationBarTitleText: '',
|
||||
enablePullDownRefresh: true,
|
||||
enablePullDownRefresh: false, // 禁用页面级下拉刷新,使用 ScrollView 的下拉刷新
|
||||
backgroundTextStyle: 'dark',
|
||||
navigationStyle: 'custom',
|
||||
backgroundColor: '#FAFAFA',
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
|
||||
// 初始化搜索条件 重新搜索
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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>;
|
||||
|
||||
Reference in New Issue
Block a user