添加实时比分功能,更新比赛数据结构,优化比赛卡片显示

This commit is contained in:
yuchenglong
2026-01-19 14:38:39 +08:00
parent ecd65098b0
commit 2e9938542a
4 changed files with 264 additions and 74 deletions

View File

@@ -13,11 +13,13 @@ import { useTheme } from "@/context/ThemeContext";
import { import {
checkFavorite, checkFavorite,
fetchLeagues, fetchLeagues,
fetchLiveScore,
fetchSports, fetchSports,
fetchTodayMatches, fetchTodayMatches,
} from "@/lib/api"; } from "@/lib/api";
import { storage } from "@/lib/storage"; import { storage } from "@/lib/storage";
import { League, Match, Sport } from "@/types/api"; import { League, Match, Sport } from "@/types/api";
import { useRouter } from "expo-router";
import React, { useEffect, useMemo, useState } from "react"; import React, { useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { import {
@@ -29,6 +31,7 @@ import {
} from "react-native"; } from "react-native";
export default function HomeScreen() { export default function HomeScreen() {
const router = useRouter();
const { theme } = useTheme(); const { theme } = useTheme();
const { t } = useTranslation(); const { t } = useTranslation();
const isDark = theme === "dark"; const isDark = theme === "dark";
@@ -44,12 +47,15 @@ export default function HomeScreen() {
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [loadingLeagues, setLoadingLeagues] = useState(false); const [loadingLeagues, setLoadingLeagues] = useState(false);
const [now, setNow] = useState(() => new Date()); const [now, setNow] = useState(() => new Date());
const [liveLeagueIdByMatchId, setLiveLeagueIdByMatchId] = useState<
Record<string, number>
>({});
const deviceTimeZone = useMemo(() => { const deviceTimeZone = useMemo(() => {
try { try {
console.log( console.log(
"deviceTimeZone", "deviceTimeZone",
Intl.DateTimeFormat().resolvedOptions().timeZone Intl.DateTimeFormat().resolvedOptions().timeZone,
); );
return Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC"; return Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC";
} catch { } catch {
@@ -315,16 +321,90 @@ export default function HomeScreen() {
const list = await fetchTodayMatches( const list = await fetchTodayMatches(
sportId, sportId,
selectedDate, selectedDate,
deviceTimeZone deviceTimeZone,
); );
//将isLive全改为true
list.forEach((m) => {
(m as Match).isLive = true;
});
const normalizeDate = (d: Date) => {
const year = d.getFullYear();
const month = String(d.getMonth() + 1).padStart(2, "0");
const day = String(d.getDate()).padStart(2, "0");
return `${year}-${month}-${day}`;
};
const todayStr = normalizeDate(new Date());
const selectedStr = normalizeDate(selectedDate);
const shouldMergeLive = selectedStr === todayStr;
let merged: Match[] = list.map((m) => ({
...m,
date: (m as any).date || selectedStr,
sport: (m as any).sport ?? sportId,
}));
if (shouldMergeLive) {
try {
const liveData = await fetchLiveScore(
sportId,
state.selectedLeagueKey
? parseInt(state.selectedLeagueKey)
: undefined,
state.timezone || deviceTimeZone,
);
const formatLiveTime = (status: string, fallback: string) => {
const s = (status || "").trim();
if (!s) return (fallback || "").trim();
if (/^\d{1,3}$/.test(s)) return `${s}'`;
return s;
};
const map: Record<string, number> = {};
const liveMatches: Match[] = (liveData || []).map((item) => {
const id = item.event_key.toString();
map[id] = item.league_key;
return {
id,
league: item.league_name,
time: formatLiveTime(item.event_status, item.event_time),
date: item.event_date,
home: item.event_home_team,
away: item.event_away_team,
scoreText: item.event_final_result || "0 - 0",
fav: false,
sport: sportId,
isLive: true,
};
});
setLiveLeagueIdByMatchId(map);
// Merge by id: live takes precedence
const byId = new Map<string, Match>();
liveMatches.forEach((m) => byId.set(m.id, m));
merged.forEach((m) => {
if (!byId.has(m.id)) byId.set(m.id, m);
});
merged = Array.from(byId.values());
} catch (e) {
// Live merge failure should not block base list
console.warn("Fetch live score failed:", e);
}
} else {
setLiveLeagueIdByMatchId({});
}
const token = await storage.getAccessToken(); const token = await storage.getAccessToken();
let listWithFavStatus = list; let listWithFavStatus = merged;
if (token) { if (token) {
// 直接传递 match.id 查询是否收藏,并更新列表状态 // 直接传递 match.id 查询是否收藏,并更新列表状态
listWithFavStatus = await Promise.all( listWithFavStatus = await Promise.all(
list.map(async (m) => { merged.map(async (m) => {
try { try {
// 查询比赛是否已被收藏 // 查询比赛是否已被收藏
const favRes = await checkFavorite("match", m.id); const favRes = await checkFavorite("match", m.id);
@@ -333,15 +413,26 @@ export default function HomeScreen() {
console.error(`Check favorite failed for match ${m.id}:`, error); console.error(`Check favorite failed for match ${m.id}:`, error);
return m; return m;
} }
}) }),
); );
} }
// 将收藏的比赛置顶 const isLiveRow = (m: Match) => {
const t = (m.time || "").trim();
return (
/\d+'$/.test(t) || /^\d{1,3}$/.test(t) || /\b(ht|half)\b/i.test(t)
);
};
// 收藏置顶,其次直播置顶
const sortedList = [...listWithFavStatus].sort((a, b) => { const sortedList = [...listWithFavStatus].sort((a, b) => {
if (a.fav === b.fav) return 0; if (a.fav !== b.fav) return a.fav ? -1 : 1;
return a.fav ? -1 : 1; const aLive = isLiveRow(a);
const bLive = isLiveRow(b);
if (aLive !== bLive) return aLive ? -1 : 1;
return 0;
}); });
setMatches(sortedList); setMatches(sortedList);
} catch (e) { } catch (e) {
console.error(e); console.error(e);
@@ -353,7 +444,7 @@ export default function HomeScreen() {
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) =>
m.id === matchId ? { ...m, fav: isFav } : m m.id === matchId ? { ...m, fav: isFav } : m,
); );
return [...updated].sort((a, b) => { return [...updated].sort((a, b) => {
if (a.fav === b.fav) return 0; if (a.fav === b.fav) return 0;

View File

@@ -65,6 +65,8 @@ export default function LiveScreen() {
leagueId: item.league_key, leagueId: item.league_key,
sportId: state.selectedSportId ?? undefined, sportId: state.selectedSportId ?? undefined,
isLive: true, isLive: true,
date: item.event_date,
// sport: item.sport_name,
})); }));
const token = await storage.getAccessToken(); const token = await storage.getAccessToken();

View File

@@ -4,6 +4,7 @@ import { Colors } from "@/constants/theme";
import { useTheme } from "@/context/ThemeContext"; import { useTheme } from "@/context/ThemeContext";
import { addFavorite, removeFavorite } from "@/lib/api"; import { addFavorite, removeFavorite } from "@/lib/api";
import { Match } from "@/types/api"; import { Match } from "@/types/api";
import { LinearGradient } from "expo-linear-gradient";
import { useRouter } from "expo-router"; import { useRouter } from "expo-router";
import React, { useState } from "react"; import React, { useState } from "react";
import { Pressable, StyleSheet, TouchableOpacity, View } from "react-native"; import { Pressable, StyleSheet, TouchableOpacity, View } from "react-native";
@@ -23,6 +24,7 @@ export function MatchCard({
const { theme } = useTheme(); const { theme } = useTheme();
const [isFav, setIsFav] = useState(match.fav); const [isFav, setIsFav] = useState(match.fav);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
// console.log("MatchCard render:", JSON.stringify(match));
// 当外部传入的 match.fav 改变时,更新内部状态 // 当外部传入的 match.fav 改变时,更新内部状态
React.useEffect(() => { React.useEffect(() => {
@@ -32,7 +34,35 @@ export function MatchCard({
const isDark = theme === "dark"; const isDark = theme === "dark";
const iconColor = isDark ? Colors.dark.icon : Colors.light.icon; const iconColor = isDark ? Colors.dark.icon : Colors.light.icon;
const cardBg = isDark ? "#1C1C1E" : "#FFFFFF"; const cardBg = isDark ? "#1C1C1E" : "#FFFFFF";
const borderColor = isDark ? "#38383A" : "#E5E5EA"; const borderColor = isDark ? "#2C2C2E" : "rgba(0,0,0,0.06)";
const scoreBorder = isDark ? "rgba(255,255,255,0.18)" : "rgba(0,0,0,0.12)";
const scoreBg = isDark ? "rgba(255,255,255,0.04)" : "rgba(255,255,255,0.6)";
const isLive = React.useMemo(() => {
return !!match.isLive;
}, [match.isLive]);
const timeLabel = React.useMemo(() => {
const raw = (match.time || "").trim();
if (!raw) return "";
if (/^\d{1,3}$/.test(raw)) return `${raw}'`;
return raw;
}, [match.time]);
const leagueShort = React.useMemo(() => {
const league = (match.league || "").trim();
if (!league) return "--";
const first = league.split(/[^A-Za-z0-9]+/).filter(Boolean)[0] || league;
return first.slice(0, 3).toUpperCase();
}, [match.league]);
const scoreParts = React.useMemo(() => {
const s = (match.scoreText || "").trim();
const m = s.match(/(\d+)\s*[-:]\s*(\d+)/);
if (m) return { home: m[1], away: m[2], hasScore: true };
if (s && s !== "-") return { home: s, away: "", hasScore: true };
return { home: "", away: "", hasScore: false };
}, [match.scoreText]);
const handlePress = () => { const handlePress = () => {
if (onPress) { if (onPress) {
@@ -79,28 +109,72 @@ export function MatchCard({
{ backgroundColor: cardBg, borderColor, opacity: pressed ? 0.7 : 1 }, { backgroundColor: cardBg, borderColor, opacity: pressed ? 0.7 : 1 },
]} ]}
> >
<View style={styles.header}> {isLive && (
<View <LinearGradient
style={[ colors={
styles.leagueBadge, isDark
{ backgroundColor: isDark ? "#2C2C2E" : "#F2F2F7" }, ? ["rgba(255, 149, 0, 0.15)", "transparent"]
]} : ["rgba(255, 149, 0, 0.1)", "transparent"]
> }
<ThemedText style={styles.leagueText} numberOfLines={1}> start={{ x: 0, y: 0 }}
{match.league} end={{ x: 0.5, y: 0 }}
style={StyleSheet.absoluteFill}
/>
)}
<View style={styles.row}>
{/* Left: League short + time */}
<View style={styles.left}>
<ThemedText style={styles.leagueShortText} numberOfLines={1}>
{leagueShort}
</ThemedText>
<ThemedText
style={[styles.timeText, isLive && styles.timeTextLive]}
numberOfLines={1}
>
{timeLabel}
</ThemedText> </ThemedText>
</View>
<ThemedText style={styles.timeText}>{match.time}</ThemedText>
</View> </View>
<View style={styles.teamsContainer}> {/* Middle: Teams */}
<ThemedText type="defaultSemiBold" style={styles.teamsText}> <View style={styles.middle}>
{match.home} vs {match.away} <ThemedText
type="defaultSemiBold"
style={styles.teamLine}
numberOfLines={1}
ellipsizeMode="tail"
>
{match.home}
</ThemedText> </ThemedText>
<View style={styles.scoreContainer}> <ThemedText
<ThemedText type="defaultSemiBold" style={styles.scoreText}> type="defaultSemiBold"
{match.scoreText} style={styles.teamLine}
numberOfLines={1}
ellipsizeMode="tail"
>
{match.away}
</ThemedText> </ThemedText>
</View>
{/* Right: Score box + favorite */}
<View style={styles.right}>
{scoreParts.hasScore ? (
<View
style={[
styles.scoreBox,
{ borderColor: scoreBorder, backgroundColor: scoreBg },
]}
>
<ThemedText style={styles.scoreBoxText} numberOfLines={1}>
{scoreParts.home}
</ThemedText>
<ThemedText style={styles.scoreBoxText} numberOfLines={1}>
{scoreParts.away}
</ThemedText>
</View>
) : (
<View style={styles.scoreBoxPlaceholder} />
)}
<TouchableOpacity <TouchableOpacity
onPress={(e) => { onPress={(e) => {
e.stopPropagation(); e.stopPropagation();
@@ -111,69 +185,90 @@ export function MatchCard({
> >
<IconSymbol <IconSymbol
name={isFav ? "star" : "star-outline"} name={isFav ? "star" : "star-outline"}
size={24} size={22}
color={isFav ? "#FFD700" : iconColor} color={isFav ? "#FFD700" : iconColor}
/> />
</TouchableOpacity> </TouchableOpacity>
</View> </View>
</View> </View>
{match.meta && (
<ThemedText style={styles.metaText}>{match.meta}</ThemedText>
)}
</Pressable> </Pressable>
); );
} }
const styles = StyleSheet.create({ const styles = StyleSheet.create({
card: { card: {
padding: 12, height: 78,
paddingHorizontal: 14,
marginBottom: 12, marginBottom: 12,
borderRadius: 12, borderRadius: 14,
borderWidth: 1, borderWidth: 1,
justifyContent: "center",
overflow: "hidden",
// iOS shadow
shadowColor: "#000",
shadowOffset: { width: 0, height: 0.5 },
shadowOpacity: 0.03,
shadowRadius: 2,
// Android elevation
elevation: 1,
}, },
header: { row: {
flexDirection: "row",
alignItems: "center",
marginBottom: 8,
},
leagueBadge: {
paddingHorizontal: 8,
paddingVertical: 4,
borderRadius: 4,
marginRight: 8,
maxWidth: "70%",
},
leagueText: {
fontSize: 12,
opacity: 0.7,
},
timeText: {
fontSize: 12,
opacity: 0.5,
},
teamsContainer: {
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
marginBottom: 4,
},
teamsText: {
fontSize: 16,
flex: 1,
marginRight: 8,
},
scoreContainer: {
flexDirection: "row", flexDirection: "row",
alignItems: "center", alignItems: "center",
gap: 12, gap: 12,
}, },
scoreText: { left: {
fontSize: 16, width: 52,
alignItems: "center",
justifyContent: "center",
gap: 6,
}, },
metaText: { leagueShortText: {
fontSize: 12, fontSize: 12,
opacity: 0.5, fontWeight: "700",
marginTop: 4, opacity: 0.85,
},
timeText: {
fontSize: 12,
opacity: 0.55,
},
timeTextLive: {
color: "#FF3B30",
opacity: 1,
},
middle: {
flex: 1,
justifyContent: "center",
gap: 6,
minWidth: 0,
},
teamLine: {
fontSize: 16,
lineHeight: 18,
},
right: {
flexDirection: "row",
alignItems: "center",
gap: 10,
},
scoreBox: {
width: 44,
height: 56,
borderRadius: 10,
borderWidth: 1,
borderColor: "rgba(0,0,0,0.12)",
backgroundColor: "rgba(255,255,255,0.6)",
alignItems: "center",
justifyContent: "center",
gap: 6,
},
scoreBoxText: {
fontSize: 16,
fontWeight: "700",
opacity: 0.9,
},
scoreBoxPlaceholder: {
width: 44,
height: 56,
}, },
}); });

View File

@@ -12,14 +12,16 @@ export interface Match {
id: string; id: string;
league: string; league: string;
time: string; time: string;
date: string;
home: string; home: string;
away: string; away: string;
meta?: string;
scoreText: string; scoreText: string;
fav: boolean; fav: boolean;
leagueId?: number; // sport: string;
sportId?: number; sportId?: number;
leagueId?: number;
isLive?: boolean; isLive?: boolean;
meta?: string;
} }
export interface LiveScoreMatch { export interface LiveScoreMatch {