From ee66f383b1697f1062d235c0eb27bbbd4f4f73ea Mon Sep 17 00:00:00 2001 From: xianyi Date: Mon, 19 Jan 2026 17:42:42 +0800 Subject: [PATCH 1/2] =?UTF-8?q?=E5=88=86=E9=A1=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/(tabs)/index.tsx | 78 +++++++++++++++++++++++++++++++++++----- lib/api.ts | 84 ++++++++++++++++++++++++-------------------- types/api.ts | 1 + 3 files changed, 115 insertions(+), 48 deletions(-) diff --git a/app/(tabs)/index.tsx b/app/(tabs)/index.tsx index 42108ca..240915c 100644 --- a/app/(tabs)/index.tsx +++ b/app/(tabs)/index.tsx @@ -49,6 +49,9 @@ export default function HomeScreen() { const [liveLeagueIdByMatchId, setLiveLeagueIdByMatchId] = useState< Record >({}); + const [page, setPage] = useState(1); + const [total, setTotal] = useState(0); + const [loadingMore, setLoadingMore] = useState(false); const deviceTimeZone = useMemo(() => { try { @@ -96,6 +99,11 @@ export default function HomeScreen() { } }, [selectedSportId, selectedDate]); + useEffect(() => { + setPage(1); + setTotal(0); + }, [selectedSportId, selectedDate, state.selectedLeagueKey]); + const timezoneLabel = useMemo(() => { // 仅展示 UTC 偏移(不展示时区名) const offsetMin = -now.getTimezoneOffset(); @@ -303,8 +311,8 @@ export default function HomeScreen() { try { if (selectedSportId !== null) { setLoadingLeagues(true); - const list = await fetchLeagues(selectedSportId, ""); - setLeagues(list); + const res = await fetchLeagues({ sportId: selectedSportId }); + setLeagues(res.list); } } catch (e) { console.error(e); @@ -316,11 +324,16 @@ export default function HomeScreen() { const loadMatches = async (sportId: number) => { setLoading(true); try { - const list = await fetchTodayMatches( + const res = await fetchTodayMatches({ sportId, - selectedDate, - deviceTimeZone, - ); + date: selectedDate, + timezone: deviceTimeZone, + leagueKey: state.selectedLeagueKey || "", + page: 1, + pageSize: 50, + }); + setPage(1); + setTotal(res.total); const normalizeDate = (d: Date) => { const year = d.getFullYear(); @@ -333,7 +346,7 @@ export default function HomeScreen() { const selectedStr = normalizeDate(selectedDate); const shouldMergeLive = selectedStr === todayStr; - let merged: Match[] = list.map((m) => ({ + let merged: Match[] = res.list.map((m) => ({ ...m, date: m.date || selectedStr, sportId: m.sportId ?? sportId, @@ -402,11 +415,9 @@ export default function HomeScreen() { let listWithFavStatus = merged; if (token) { - // 直接传递 match.id 查询是否收藏,并更新列表状态 listWithFavStatus = await Promise.all( merged.map(async (m) => { try { - // 查询比赛是否已被收藏 const favRes = await checkFavorite("match", m.id); return { ...m, fav: favRes.isFavorite }; } catch (error) { @@ -434,6 +445,41 @@ export default function HomeScreen() { } }; + const loadMoreMatches = async () => { + if (loadingMore || loading) return; + if (!selectedSportId) return; + if (matches.length >= total) return; + + setLoadingMore(true); + try { + const nextPage = page + 1; + const res = await fetchTodayMatches({ + sportId: selectedSportId, + date: selectedDate, + timezone: deviceTimeZone, + leagueKey: state.selectedLeagueKey || "", + page: nextPage, + pageSize: 50, + }); + + setPage(nextPage); + setTotal(res.total); + + setMatches((prev) => { + const byId = new Map(); + prev.forEach((m) => byId.set(m.id, m)); + res.list.forEach((m) => { + if (!byId.has(m.id)) byId.set(m.id, m); + }); + return Array.from(byId.values()); + }); + } catch (e) { + console.error(e); + } finally { + setLoadingMore(false); + } + }; + const handleFavoriteToggle = (matchId: string, isFav: boolean) => { setMatches((prev) => { const updated = prev.map((m) => @@ -558,6 +604,15 @@ export default function HomeScreen() { renderItem={({ item }) => ( )} + onEndReached={loadMoreMatches} + onEndReachedThreshold={0.4} + ListFooterComponent={ + loadingMore ? ( + + + + ) : null + } contentContainerStyle={styles.listContent} ListEmptyComponent={ @@ -650,4 +705,9 @@ const styles = StyleSheet.create({ padding: 16, paddingTop: 8, }, + footer: { + paddingVertical: 16, + alignItems: "center", + justifyContent: "center", + }, }); diff --git a/lib/api.ts b/lib/api.ts index 5e2ae7a..0f630b4 100644 --- a/lib/api.ts +++ b/lib/api.ts @@ -115,22 +115,22 @@ export const fetchCountries = async (): Promise => { } }; -export const fetchLeagues = async ( - sportId: number, - countryKey: string -): Promise => { +export const fetchLeagues = async (params: { + sportId?: number; + countryKey?: string; + date?: string; + page?: number; + pageSize?: number; + sortBy?: "name" | "matchCount" | "createdAt"; + sortOrder?: "asc" | "desc"; +}): Promise> => { try { const response = await apiClient.get>>( API_ENDPOINTS.LEAGUES, - { - params: { - sportId, - countryKey, - }, - } + { params } ); if (response.data.code === 0) { - return response.data.data.list; + return response.data.data; } throw new Error(response.data.message); } catch (error) { @@ -139,48 +139,54 @@ export const fetchLeagues = async ( } }; -export const fetchTodayMatches = async ( - sportId: number, - date?: Date | string, - timezone?: string -): Promise => { +export const fetchTodayMatches = async (options: { + sportId: number; + date?: Date | string; + timezone?: string; + leagueKey?: string; + page?: number; + pageSize?: number; +}): Promise> => { try { - const params: { sportId: number; date?: string; timezone?: string } = { - sportId, + const params: { + sportId: number; + date?: string; + timezone?: string; + leagueKey?: string; + page?: number; + pageSize?: number; + } = { + sportId: options.sportId, + leagueKey: options.leagueKey, + page: options.page, + pageSize: options.pageSize, }; - // 如果提供了日期,格式化为 YYYY-MM-DD 格式 - if (date) { - let dateStr: string; - if (date instanceof Date) { - const year = date.getFullYear(); - const month = String(date.getMonth() + 1).padStart(2, "0"); - const day = String(date.getDate()).padStart(2, "0"); - dateStr = `${year}-${month}-${day}`; + if (options.date) { + if (options.date instanceof Date) { + const year = options.date.getFullYear(); + const month = String(options.date.getMonth() + 1).padStart(2, "0"); + const day = String(options.date.getDate()).padStart(2, "0"); + params.date = `${year}-${month}-${day}`; } else { - dateStr = date; + params.date = options.date; } - params.date = dateStr; } - // 如果提供了时区,传给后端;不传则由后端使用本地时区 - if (timezone) { - params.timezone = timezone; + if (options.timezone) { + params.timezone = options.timezone; } const response = await apiClient.get>>( API_ENDPOINTS.MATCHES_TODAY, - { - params, - } + { params } ); if (response.data.code === 0) { - return response.data.data.list; + return response.data.data; } throw new Error(response.data.message); } catch (error) { console.error("Fetch matches error:", error); - // Let the caller handle errors; rethrow the original error throw error; } }; @@ -214,9 +220,9 @@ export const fetchLiveScore = async ( // }); try { const params: { sport_id: number; league_id?: number; timezone?: string } = - { - sport_id: sportId, - }; + { + sport_id: sportId, + }; if (leagueId) { params.league_id = leagueId; diff --git a/types/api.ts b/types/api.ts index 9631f43..a40a05f 100644 --- a/types/api.ts +++ b/types/api.ts @@ -152,6 +152,7 @@ export interface League { isActive: boolean; createdAt: string; updatedAt: string; + matchCount?: number; } export interface GoalEvent { From f81cc2f7e6714b97e0037c433eafb0b676f8aaf6 Mon Sep 17 00:00:00 2001 From: xianyi Date: Mon, 19 Jan 2026 18:11:08 +0800 Subject: [PATCH 2/2] =?UTF-8?q?=E8=81=94=E8=B5=9B=E6=98=BE=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/(tabs)/index.tsx | 4 +- components/match-card-league.tsx | 2 +- components/matches-by-league.tsx | 288 ++++++++++++++++++++++--------- types/api.ts | 10 +- 4 files changed, 218 insertions(+), 86 deletions(-) diff --git a/app/(tabs)/index.tsx b/app/(tabs)/index.tsx index 240915c..c18ef58 100644 --- a/app/(tabs)/index.tsx +++ b/app/(tabs)/index.tsx @@ -622,7 +622,9 @@ export default function HomeScreen() { /> ) : ( )} diff --git a/components/match-card-league.tsx b/components/match-card-league.tsx index 88eb1f7..0c04c42 100644 --- a/components/match-card-league.tsx +++ b/components/match-card-league.tsx @@ -121,7 +121,7 @@ export function MatchCardLeague({ {/* 如果有状态字段 match.meta (如 'FT', 'AET'), 优先显示,否则显示时间 */} - {(match.meta || match.time || "").toUpperCase()} + {(match.time || "").toUpperCase()} diff --git a/components/matches-by-league.tsx b/components/matches-by-league.tsx index 8b3d760..509f4b2 100644 --- a/components/matches-by-league.tsx +++ b/components/matches-by-league.tsx @@ -1,7 +1,8 @@ import { MatchCardLeague } from "@/components/match-card-league"; import { ThemedText } from "@/components/themed-text"; import { useTheme } from "@/context/ThemeContext"; -import { Match } from "@/types/api"; +import { fetchLeagues, fetchTodayMatches } from "@/lib/api"; +import { League, Match } from "@/types/api"; import React, { useState } from "react"; import { Image, @@ -23,70 +24,97 @@ if ( } interface MatchesByLeagueProps { - matches: Match[]; + sportId: number; + date: Date; + timezone: string; onFavoriteToggle?: (matchId: string, isFav: boolean) => void; - /** - * 是否支持折叠收起 - * @default true - */ enableCollapsible?: boolean; } - export function MatchesByLeague({ - matches, + sportId, + date, + timezone, onFavoriteToggle, enableCollapsible = true, }: MatchesByLeagueProps) { const { theme } = useTheme(); const isDark = theme === "dark"; - // 数据分组逻辑 - const matchesByLeague = React.useMemo(() => { - const grouped: Record = {}; - matches.forEach((match) => { - const league = match.league || match.leagueName || "其他"; - if (!grouped[league]) { - grouped[league] = []; - } - grouped[league].push(match); - }); - return grouped; - }, [matches]); - - const leagueNames = Object.keys(matchesByLeague); - - // 状态:记录哪些联赛被折叠了 (Key 为联赛名称),默认全部收起 - const [collapsedSections, setCollapsedSections] = useState< + const [leagues, setLeagues] = useState([]); + const [collapsed, setCollapsed] = useState>({}); + const [matchesByLeagueKey, setMatchesByLeagueKey] = useState< + Record + >({}); + const [loadingLeagueKey, setLoadingLeagueKey] = useState< Record >({}); - // 当联赛列表变化时,确保所有联赛默认都是收起状态 + const dateStr = React.useMemo(() => { + const year = date.getFullYear(); + const month = String(date.getMonth() + 1).padStart(2, "0"); + const day = String(date.getDate()).padStart(2, "0"); + return `${year}-${month}-${day}`; + }, [date]); + React.useEffect(() => { - setCollapsedSections((prev) => { - const updated: Record = {}; - leagueNames.forEach((name) => { - updated[name] = prev[name] !== undefined ? prev[name] : true; - }); - return updated; - }); - }, [leagueNames.join(",")]); + let mounted = true; + fetchLeagues({ + sportId, + date: dateStr, + page: 1, + pageSize: 200, + sortBy: "matchCount", + sortOrder: "desc", + }) + .then((res) => { + if (!mounted) return; + setLeagues(res.list); + setCollapsed((prev) => { + const next: Record = {}; + res.list.forEach((l) => { + next[l.key] = prev[l.key] !== undefined ? prev[l.key] : true; + }); + return next; + }); + setMatchesByLeagueKey({}); + setLoadingLeagueKey({}); + }) + .catch(() => { }); + return () => { + mounted = false; + }; + }, [sportId, dateStr]); - // 切换折叠状态 - const toggleCollapse = (leagueName: string) => { + const toggleCollapse = async (leagueKey: string) => { if (!enableCollapsible) return; - LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut); - setCollapsedSections((prev) => ({ - ...prev, - [leagueName]: !prev[leagueName], - })); + setCollapsed((prev) => ({ ...prev, [leagueKey]: !prev[leagueKey] })); + + const nextCollapsed = !collapsed[leagueKey]; + if (nextCollapsed) return; + if (matchesByLeagueKey[leagueKey]) return; + + setLoadingLeagueKey((prev) => ({ ...prev, [leagueKey]: true })); + try { + const res = await fetchTodayMatches({ + sportId, + date: dateStr, + timezone, + leagueKey, + page: 1, + pageSize: 50, + }); + setMatchesByLeagueKey((prev) => ({ ...prev, [leagueKey]: res.list })); + } finally { + setLoadingLeagueKey((prev) => ({ ...prev, [leagueKey]: false })); + } }; - if (leagueNames.length === 0) { + if (leagues.length === 0) { return ( - 暂无比赛 + 暂无联赛 ); } @@ -99,54 +127,51 @@ export function MatchesByLeague({ ]} contentContainerStyle={{ paddingBottom: 40 }} > - {leagueNames.map((leagueName) => { - const leagueMatches = matchesByLeague[leagueName]; - // 取该组第一场比赛的数据作为头部信息的来源(图标、国家等) - const firstMatch = leagueMatches[0]; - const isCollapsed = collapsedSections[leagueName]; + {leagues.map((league) => { + const isCollapsed = collapsed[league.key] !== false; + const leagueMatches = matchesByLeagueKey[league.key] || []; + const isLoading = !!loadingLeagueKey[league.key]; return ( - - {/* 联赛头部 */} + toggleCollapse(leagueName)} + activeOpacity={enableCollapsible && league.matchCount > 0 ? 0.7 : 1} + onPress={() => { + if (enableCollapsible && league.matchCount > 0) { + toggleCollapse(league.key); + } + }} style={styles.leagueHeaderWrapper} > - {/* 联赛 Logo */} - {/* 联赛名称 */} - {leagueName} + {league.name} - {/* 国家信息行 */} - {firstMatch.countryName || (firstMatch as any).countryName || "International"} + {league.countryName || "International"} - {/* 比赛数量 */} - {leagueMatches.length} + {league.matchCount} - {/* 折叠箭头 (仅当支持折叠时显示) */} - {enableCollapsible && ( + {enableCollapsible && league.matchCount > 0 && ( {isCollapsed ? "⌄" : "⌃"} @@ -154,23 +179,74 @@ export function MatchesByLeague({ - {/* 比赛列表内容 (根据状态显示/隐藏) */} {!isCollapsed && ( - {leagueMatches.map((match, index) => ( - - - - ))} + {isLoading ? ( + <> + {[0, 1, 2].map((i) => ( + + + + + + + + + + + + + + + + + + + + + + + + + ))} + + ) : ( + leagueMatches.map((match, index) => ( + + + + )) + )} )} @@ -252,13 +328,67 @@ const styles = StyleSheet.create({ marginHorizontal: 16, overflow: "hidden", }, + // 布局与 MatchCardLeague 保持一致,便于骨架对齐 + leftColumn: { + width: 50, + justifyContent: "center", + alignItems: "flex-start", + marginRight: 8, + }, + teamsColumn: { + flex: 1, + justifyContent: "center", + paddingRight: 8, + }, + rightWrapper: { + flexDirection: "row", + alignItems: "center", + }, matchCardWrapper: { - // 卡片包装器 }, matchCardDivider: { borderBottomWidth: 0.5, borderBottomColor: "#3A3A3C", }, + favoriteButton: { + paddingLeft: 12, + justifyContent: "center", + alignItems: "center", + }, + skeletonRow: { + flexDirection: "row", + alignItems: "center", + paddingHorizontal: 16, + paddingVertical: 14, + }, + skeletonLine: { + height: 8, + borderRadius: 4, + backgroundColor: "#2C2C2E", + }, + skeletonAvatar: { + width: 20, + height: 20, + borderRadius: 10, + backgroundColor: "#2C2C2E", + marginRight: 10, + }, + skeletonTeamRow: { + flexDirection: "row", + alignItems: "center", + }, + skeletonScoreBox: { + width: 24, + height: 32, + borderRadius: 8, + backgroundColor: "#2C2C2E", + }, + skeletonCircle: { + width: 16, + height: 16, + borderRadius: 8, + backgroundColor: "#2C2C2E", + }, emptyContainer: { flex: 1, justifyContent: "center", diff --git a/types/api.ts b/types/api.ts index 94c127d..aac8012 100644 --- a/types/api.ts +++ b/types/api.ts @@ -88,11 +88,11 @@ export interface LiveScoreMatch { substitutes?: { time: string; home_scorer: - | { in: string; out: string; in_id: number; out_id: number } - | any[]; + | { in: string; out: string; in_id: number; out_id: number } + | any[]; away_scorer: - | { in: string; out: string; in_id: number; out_id: number } - | any[]; + | { in: string; out: string; in_id: number; out_id: number } + | any[]; info: string; info_time: string; score: string; @@ -153,7 +153,7 @@ export interface League { isActive: boolean; createdAt: string; updatedAt: string; - matchCount?: number; + matchCount: number; } export interface GoalEvent {