This commit is contained in:
yuchenglong
2026-01-20 09:15:42 +08:00
5 changed files with 332 additions and 133 deletions

View File

@@ -49,6 +49,9 @@ export default function HomeScreen() {
const [liveLeagueIdByMatchId, setLiveLeagueIdByMatchId] = useState< const [liveLeagueIdByMatchId, setLiveLeagueIdByMatchId] = useState<
Record<string, number> Record<string, number>
>({}); >({});
const [page, setPage] = useState(1);
const [total, setTotal] = useState(0);
const [loadingMore, setLoadingMore] = useState(false);
const deviceTimeZone = useMemo(() => { const deviceTimeZone = useMemo(() => {
try { try {
@@ -96,6 +99,11 @@ export default function HomeScreen() {
} }
}, [selectedSportId, selectedDate]); }, [selectedSportId, selectedDate]);
useEffect(() => {
setPage(1);
setTotal(0);
}, [selectedSportId, selectedDate, state.selectedLeagueKey]);
const timezoneLabel = useMemo(() => { const timezoneLabel = useMemo(() => {
// 仅展示 UTC 偏移(不展示时区名) // 仅展示 UTC 偏移(不展示时区名)
const offsetMin = -now.getTimezoneOffset(); const offsetMin = -now.getTimezoneOffset();
@@ -303,8 +311,8 @@ export default function HomeScreen() {
try { try {
if (selectedSportId !== null) { if (selectedSportId !== null) {
setLoadingLeagues(true); setLoadingLeagues(true);
const list = await fetchLeagues(selectedSportId, ""); const res = await fetchLeagues({ sportId: selectedSportId });
setLeagues(list); setLeagues(res.list);
} }
} catch (e) { } catch (e) {
console.error(e); console.error(e);
@@ -316,11 +324,16 @@ export default function HomeScreen() {
const loadMatches = async (sportId: number) => { const loadMatches = async (sportId: number) => {
setLoading(true); setLoading(true);
try { try {
const list = await fetchTodayMatches( const res = await fetchTodayMatches({
sportId, sportId,
selectedDate, date: selectedDate,
deviceTimeZone, timezone: deviceTimeZone,
); leagueKey: state.selectedLeagueKey || "",
page: 1,
pageSize: 50,
});
setPage(1);
setTotal(res.total);
const normalizeDate = (d: Date) => { const normalizeDate = (d: Date) => {
const year = d.getFullYear(); const year = d.getFullYear();
@@ -333,7 +346,7 @@ export default function HomeScreen() {
const selectedStr = normalizeDate(selectedDate); const selectedStr = normalizeDate(selectedDate);
const shouldMergeLive = selectedStr === todayStr; const shouldMergeLive = selectedStr === todayStr;
let merged: Match[] = list.map((m) => ({ let merged: Match[] = res.list.map((m) => ({
...m, ...m,
date: m.date || selectedStr, date: m.date || selectedStr,
sportId: m.sportId ?? sportId, sportId: m.sportId ?? sportId,
@@ -402,11 +415,9 @@ export default function HomeScreen() {
let listWithFavStatus = merged; let listWithFavStatus = merged;
if (token) { if (token) {
// 直接传递 match.id 查询是否收藏,并更新列表状态
listWithFavStatus = await Promise.all( listWithFavStatus = await Promise.all(
merged.map(async (m) => { merged.map(async (m) => {
try { try {
// 查询比赛是否已被收藏
const favRes = await checkFavorite("match", m.id); const favRes = await checkFavorite("match", m.id);
return { ...m, fav: favRes.isFavorite }; return { ...m, fav: favRes.isFavorite };
} catch (error) { } 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<string, Match>();
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) => { const handleFavoriteToggle = (matchId: string, isFav: boolean) => {
setMatches((prev) => { setMatches((prev) => {
const updated = prev.map((m) => const updated = prev.map((m) =>
@@ -558,6 +604,15 @@ export default function HomeScreen() {
renderItem={({ item }) => ( renderItem={({ item }) => (
<MatchCard match={item} onFavoriteToggle={handleFavoriteToggle} /> <MatchCard match={item} onFavoriteToggle={handleFavoriteToggle} />
)} )}
onEndReached={loadMoreMatches}
onEndReachedThreshold={0.4}
ListFooterComponent={
loadingMore ? (
<View style={styles.footer}>
<ActivityIndicator size="small" color={Colors[theme].tint} />
</View>
) : null
}
contentContainerStyle={styles.listContent} contentContainerStyle={styles.listContent}
ListEmptyComponent={ ListEmptyComponent={
<View style={styles.center}> <View style={styles.center}>
@@ -567,7 +622,9 @@ export default function HomeScreen() {
/> />
) : ( ) : (
<MatchesByLeague <MatchesByLeague
matches={matches} sportId={selectedSportId || 1}
date={selectedDate}
timezone={deviceTimeZone}
onFavoriteToggle={handleFavoriteToggle} onFavoriteToggle={handleFavoriteToggle}
/> />
)} )}
@@ -650,4 +707,9 @@ const styles = StyleSheet.create({
padding: 16, padding: 16,
paddingTop: 8, paddingTop: 8,
}, },
footer: {
paddingVertical: 16,
alignItems: "center",
justifyContent: "center",
},
}); });

View File

@@ -121,7 +121,7 @@ export function MatchCardLeague({
<View style={styles.leftColumn}> <View style={styles.leftColumn}>
{/* 如果有状态字段 match.meta (如 'FT', 'AET'), 优先显示,否则显示时间 */} {/* 如果有状态字段 match.meta (如 'FT', 'AET'), 优先显示,否则显示时间 */}
<ThemedText style={styles.statusText}> <ThemedText style={styles.statusText}>
{(match.meta || match.time || "").toUpperCase()} {(match.time || "").toUpperCase()}
</ThemedText> </ThemedText>
</View> </View>

View File

@@ -1,7 +1,8 @@
import { MatchCardLeague } from "@/components/match-card-league"; import { MatchCardLeague } from "@/components/match-card-league";
import { ThemedText } from "@/components/themed-text"; import { ThemedText } from "@/components/themed-text";
import { useTheme } from "@/context/ThemeContext"; 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 React, { useState } from "react";
import { import {
Image, Image,
@@ -23,70 +24,97 @@ if (
} }
interface MatchesByLeagueProps { interface MatchesByLeagueProps {
matches: Match[]; sportId: number;
date: Date;
timezone: string;
onFavoriteToggle?: (matchId: string, isFav: boolean) => void; onFavoriteToggle?: (matchId: string, isFav: boolean) => void;
/**
* 是否支持折叠收起
* @default true
*/
enableCollapsible?: boolean; enableCollapsible?: boolean;
} }
export function MatchesByLeague({ export function MatchesByLeague({
matches, sportId,
date,
timezone,
onFavoriteToggle, onFavoriteToggle,
enableCollapsible = true, enableCollapsible = true,
}: MatchesByLeagueProps) { }: MatchesByLeagueProps) {
const { theme } = useTheme(); const { theme } = useTheme();
const isDark = theme === "dark"; const isDark = theme === "dark";
// 数据分组逻辑 const [leagues, setLeagues] = useState<League[]>([]);
const matchesByLeague = React.useMemo(() => { const [collapsed, setCollapsed] = useState<Record<string, boolean>>({});
const grouped: Record<string, Match[]> = {}; const [matchesByLeagueKey, setMatchesByLeagueKey] = useState<
matches.forEach((match) => { Record<string, Match[]>
const league = match.league || match.leagueName || "其他"; >({});
if (!grouped[league]) { const [loadingLeagueKey, setLoadingLeagueKey] = useState<
grouped[league] = [];
}
grouped[league].push(match);
});
return grouped;
}, [matches]);
const leagueNames = Object.keys(matchesByLeague);
// 状态:记录哪些联赛被折叠了 (Key 为联赛名称),默认全部收起
const [collapsedSections, setCollapsedSections] = useState<
Record<string, boolean> Record<string, boolean>
>({}); >({});
// 当联赛列表变化时,确保所有联赛默认都是收起状态 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(() => { React.useEffect(() => {
setCollapsedSections((prev) => { let mounted = true;
const updated: Record<string, boolean> = {}; fetchLeagues({
leagueNames.forEach((name) => { sportId,
updated[name] = prev[name] !== undefined ? prev[name] : true; date: dateStr,
}); page: 1,
return updated; pageSize: 200,
}); sortBy: "matchCount",
}, [leagueNames.join(",")]); sortOrder: "desc",
})
.then((res) => {
if (!mounted) return;
setLeagues(res.list);
setCollapsed((prev) => {
const next: Record<string, boolean> = {};
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 = async (leagueKey: string) => {
const toggleCollapse = (leagueName: string) => {
if (!enableCollapsible) return; if (!enableCollapsible) return;
LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut); LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut);
setCollapsedSections((prev) => ({ setCollapsed((prev) => ({ ...prev, [leagueKey]: !prev[leagueKey] }));
...prev,
[leagueName]: !prev[leagueName], 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 ( return (
<View style={styles.emptyContainer}> <View style={styles.emptyContainer}>
<ThemedText></ThemedText> <ThemedText></ThemedText>
</View> </View>
); );
} }
@@ -99,54 +127,51 @@ export function MatchesByLeague({
]} ]}
contentContainerStyle={{ paddingBottom: 40 }} contentContainerStyle={{ paddingBottom: 40 }}
> >
{leagueNames.map((leagueName) => { {leagues.map((league) => {
const leagueMatches = matchesByLeague[leagueName]; const isCollapsed = collapsed[league.key] !== false;
// 取该组第一场比赛的数据作为头部信息的来源(图标、国家等) const leagueMatches = matchesByLeagueKey[league.key] || [];
const firstMatch = leagueMatches[0]; const isLoading = !!loadingLeagueKey[league.key];
const isCollapsed = collapsedSections[leagueName];
return ( return (
<View key={leagueName} style={styles.leagueSection}> <View key={league.key} style={styles.leagueSection}>
{/* 联赛头部 */}
<TouchableOpacity <TouchableOpacity
activeOpacity={enableCollapsible ? 0.7 : 1} activeOpacity={enableCollapsible && league.matchCount > 0 ? 0.7 : 1}
onPress={() => toggleCollapse(leagueName)} onPress={() => {
if (enableCollapsible && league.matchCount > 0) {
toggleCollapse(league.key);
}
}}
style={styles.leagueHeaderWrapper} style={styles.leagueHeaderWrapper}
> >
<View style={styles.leagueHeaderLeft}> <View style={styles.leagueHeaderLeft}>
{/* 联赛 Logo */}
<Image <Image
source={{ source={{
uri: firstMatch.leagueLogo || (firstMatch as any).leagueLogoUrl || "https://placehold.co/40x40/png", uri: league.logo || "https://placehold.co/40x40/png",
}} }}
style={styles.leagueLogo} style={styles.leagueLogo}
/> />
<View style={styles.leagueInfoText}> <View style={styles.leagueInfoText}>
{/* 联赛名称 */} <ThemedText style={styles.leagueTitle}>{league.name}</ThemedText>
<ThemedText style={styles.leagueTitle}>{leagueName}</ThemedText>
{/* 国家信息行 */}
<View style={styles.countryRow}> <View style={styles.countryRow}>
<Image <Image
source={{ uri: firstMatch.countryLogo || (firstMatch as any).countryFlagUrl || "https://placehold.co/20x20/png" }} source={{ uri: league.countryLogo || "https://placehold.co/20x20/png" }}
style={styles.countryFlag} style={styles.countryFlag}
/> />
<ThemedText style={styles.countryName}> <ThemedText style={styles.countryName}>
{firstMatch.countryName || (firstMatch as any).countryName || "International"} {league.countryName || "International"}
</ThemedText> </ThemedText>
</View> </View>
</View> </View>
</View> </View>
<View style={styles.leagueHeaderRight}> <View style={styles.leagueHeaderRight}>
{/* 比赛数量 */}
<ThemedText style={styles.matchCount}> <ThemedText style={styles.matchCount}>
{leagueMatches.length} {league.matchCount}
</ThemedText> </ThemedText>
{/* 折叠箭头 (仅当支持折叠时显示) */} {enableCollapsible && league.matchCount > 0 && (
{enableCollapsible && (
<ThemedText style={styles.chevron}> <ThemedText style={styles.chevron}>
{isCollapsed ? "⌄" : "⌃"} {isCollapsed ? "⌄" : "⌃"}
</ThemedText> </ThemedText>
@@ -154,23 +179,74 @@ export function MatchesByLeague({
</View> </View>
</TouchableOpacity> </TouchableOpacity>
{/* 比赛列表内容 (根据状态显示/隐藏) */}
{!isCollapsed && ( {!isCollapsed && (
<View style={styles.matchListContainer}> <View style={styles.matchListContainer}>
{leagueMatches.map((match, index) => ( {isLoading ? (
<View <>
key={match.id} {[0, 1, 2].map((i) => (
style={[ <View
styles.matchCardWrapper, key={i}
index < leagueMatches.length - 1 && styles.matchCardDivider style={[
]} styles.matchCardWrapper,
> i < 2 && styles.matchCardDivider,
<MatchCardLeague ]}
match={match} >
onFavoriteToggle={onFavoriteToggle} <View style={styles.skeletonRow}>
/> <View style={styles.leftColumn}>
</View> <View
))} style={[styles.skeletonLine, { width: 30, marginBottom: 6 }]}
/>
<View
style={[styles.skeletonLine, { width: 24 }]}
/>
</View>
<View style={styles.teamsColumn}>
<View style={styles.skeletonTeamRow}>
<View style={styles.skeletonAvatar} />
<View
style={[
styles.skeletonLine,
{ flex: 1 },
]}
/>
</View>
<View style={[styles.skeletonTeamRow, { marginTop: 8 }]}>
<View style={styles.skeletonAvatar} />
<View
style={[
styles.skeletonLine,
{ flex: 1 },
]}
/>
</View>
</View>
<View style={styles.rightWrapper}>
<View style={styles.skeletonScoreBox} />
<View style={styles.favoriteButton}>
<View style={styles.skeletonCircle} />
</View>
</View>
</View>
</View>
))}
</>
) : (
leagueMatches.map((match, index) => (
<View
key={match.id}
style={[
styles.matchCardWrapper,
index < leagueMatches.length - 1 &&
styles.matchCardDivider,
]}
>
<MatchCardLeague
match={match}
onFavoriteToggle={onFavoriteToggle}
/>
</View>
))
)}
</View> </View>
)} )}
</View> </View>
@@ -252,13 +328,67 @@ const styles = StyleSheet.create({
marginHorizontal: 16, marginHorizontal: 16,
overflow: "hidden", 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: { matchCardWrapper: {
// 卡片包装器
}, },
matchCardDivider: { matchCardDivider: {
borderBottomWidth: 0.5, borderBottomWidth: 0.5,
borderBottomColor: "#3A3A3C", 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: { emptyContainer: {
flex: 1, flex: 1,
justifyContent: "center", justifyContent: "center",

View File

@@ -115,22 +115,22 @@ export const fetchCountries = async (): Promise<Country[]> => {
} }
}; };
export const fetchLeagues = async ( export const fetchLeagues = async (params: {
sportId: number, sportId?: number;
countryKey: string countryKey?: string;
): Promise<League[]> => { date?: string;
page?: number;
pageSize?: number;
sortBy?: "name" | "matchCount" | "createdAt";
sortOrder?: "asc" | "desc";
}): Promise<ApiListResponse<League>> => {
try { try {
const response = await apiClient.get<ApiResponse<ApiListResponse<League>>>( const response = await apiClient.get<ApiResponse<ApiListResponse<League>>>(
API_ENDPOINTS.LEAGUES, API_ENDPOINTS.LEAGUES,
{ { params }
params: {
sportId,
countryKey,
},
}
); );
if (response.data.code === 0) { if (response.data.code === 0) {
return response.data.data.list; return response.data.data;
} }
throw new Error(response.data.message); throw new Error(response.data.message);
} catch (error) { } catch (error) {
@@ -139,48 +139,54 @@ export const fetchLeagues = async (
} }
}; };
export const fetchTodayMatches = async ( export const fetchTodayMatches = async (options: {
sportId: number, sportId: number;
date?: Date | string, date?: Date | string;
timezone?: string timezone?: string;
): Promise<Match[]> => { leagueKey?: string;
page?: number;
pageSize?: number;
}): Promise<ApiListResponse<Match>> => {
try { try {
const params: { sportId: number; date?: string; timezone?: string } = { const params: {
sportId, 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 (options.date) {
if (date) { if (options.date instanceof Date) {
let dateStr: string; const year = options.date.getFullYear();
if (date instanceof Date) { const month = String(options.date.getMonth() + 1).padStart(2, "0");
const year = date.getFullYear(); const day = String(options.date.getDate()).padStart(2, "0");
const month = String(date.getMonth() + 1).padStart(2, "0"); params.date = `${year}-${month}-${day}`;
const day = String(date.getDate()).padStart(2, "0");
dateStr = `${year}-${month}-${day}`;
} else { } else {
dateStr = date; params.date = options.date;
} }
params.date = dateStr;
} }
// 如果提供了时区,传给后端;不传则由后端使用本地时区 if (options.timezone) {
if (timezone) { params.timezone = options.timezone;
params.timezone = timezone;
} }
const response = await apiClient.get<ApiResponse<ApiListResponse<Match>>>( const response = await apiClient.get<ApiResponse<ApiListResponse<Match>>>(
API_ENDPOINTS.MATCHES_TODAY, API_ENDPOINTS.MATCHES_TODAY,
{ { params }
params,
}
); );
if (response.data.code === 0) { if (response.data.code === 0) {
return response.data.data.list; return response.data.data;
} }
throw new Error(response.data.message); throw new Error(response.data.message);
} catch (error) { } catch (error) {
console.error("Fetch matches error:", error); console.error("Fetch matches error:", error);
// Let the caller handle errors; rethrow the original error
throw error; throw error;
} }
}; };
@@ -214,9 +220,9 @@ export const fetchLiveScore = async (
// }); // });
try { try {
const params: { sport_id: number; league_id?: number; timezone?: string } = const params: { sport_id: number; league_id?: number; timezone?: string } =
{ {
sport_id: sportId, sport_id: sportId,
}; };
if (leagueId) { if (leagueId) {
params.league_id = leagueId; params.league_id = leagueId;

View File

@@ -88,11 +88,11 @@ export interface LiveScoreMatch {
substitutes?: { substitutes?: {
time: string; time: string;
home_scorer: home_scorer:
| { in: string; out: string; in_id: number; out_id: number } | { in: string; out: string; in_id: number; out_id: number }
| any[]; | any[];
away_scorer: away_scorer:
| { in: string; out: string; in_id: number; out_id: number } | { in: string; out: string; in_id: number; out_id: number }
| any[]; | any[];
info: string; info: string;
info_time: string; info_time: string;
score: string; score: string;
@@ -153,6 +153,7 @@ export interface League {
isActive: boolean; isActive: boolean;
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;
matchCount: number;
} }
export interface GoalEvent { export interface GoalEvent {