网球详情页

This commit is contained in:
xianyi
2026-01-22 14:05:39 +08:00
parent 9e7f8dadec
commit a279083252
13 changed files with 443 additions and 46 deletions

View File

@@ -65,6 +65,16 @@ export function MatchTabs({
} else if (sportId === 2) {
// 篮球
return commonTabs;
} else if (sportId === 3) {
// 网球: 详情、聊天
return [
{
id: "info",
label: t("detail.tabs.info"),
icon: "document-text-outline",
},
{ id: "chat", label: t("detail.tabs.chat"), icon: "chatbubbles-outline" },
];
}
return commonTabs;
};

View File

@@ -1,6 +1,7 @@
import { ThemedText } from "@/components/themed-text";
import { IconSymbol } from "@/components/ui/icon-symbol";
import { addFavorite, checkFavorite, removeFavorite } from "@/lib/api";
import { getInitials, getLogoGradient } from "@/lib/avatar-utils";
import { storage } from "@/lib/storage";
import { MatchDetailData } from "@/types/api";
import { LinearGradient } from "expo-linear-gradient";
@@ -19,6 +20,7 @@ export function ScoreHeader({ data, isDark, topInset }: ScoreHeaderProps) {
const router = useRouter();
const { t } = useTranslation();
const { match } = data;
const isTennis = match.sportId === 3;
const [isFav, setIsFav] = useState(false);
const [favLoading, setFavLoading] = useState(false);
@@ -45,39 +47,45 @@ export function ScoreHeader({ data, isDark, topInset }: ScoreHeaderProps) {
}
}, [match.eventKey]);
// 检查主队收藏状态
// 检查主队/第一球员收藏状态
React.useEffect(() => {
const loadHomeFav = async () => {
const token = await storage.getAccessToken();
if (!token) return;
try {
const res = await checkFavorite("team", match.homeTeamKey.toString());
setIsHomeFav(res.isFavorite);
const teamKey = isTennis ? match.firstPlayerKey : match.homeTeamKey;
if (teamKey) {
const res = await checkFavorite("team", teamKey.toString());
setIsHomeFav(res.isFavorite);
}
} catch (error) {
console.error("Check home team favorite status error:", error);
}
};
if (match.homeTeamKey) {
if (isTennis ? match.firstPlayerKey : match.homeTeamKey) {
loadHomeFav();
}
}, [match.homeTeamKey]);
}, [match.homeTeamKey, match.firstPlayerKey, isTennis]);
// 检查客队收藏状态
// 检查客队/第二球员收藏状态
React.useEffect(() => {
const loadAwayFav = async () => {
const token = await storage.getAccessToken();
if (!token) return;
try {
const res = await checkFavorite("team", match.awayTeamKey.toString());
setIsAwayFav(res.isFavorite);
const teamKey = isTennis ? match.secondPlayerKey : match.awayTeamKey;
if (teamKey) {
const res = await checkFavorite("team", teamKey.toString());
setIsAwayFav(res.isFavorite);
}
} catch (error) {
console.error("Check away team favorite status error:", error);
}
};
if (match.awayTeamKey) {
if (isTennis ? match.secondPlayerKey : match.awayTeamKey) {
loadAwayFav();
}
}, [match.awayTeamKey]);
}, [match.awayTeamKey, match.secondPlayerKey, isTennis]);
const toggleFavorite = async () => {
if (favLoading) return;
@@ -111,7 +119,7 @@ export function ScoreHeader({ data, isDark, topInset }: ScoreHeaderProps) {
const setFav = isHome ? setIsHomeFav : setIsAwayFav;
const loading = isHome ? homeFavLoading : awayFavLoading;
if (loading) return;
if (loading || !teamKey) return;
setLoading(true);
const newFavState = !isTeamFav;
@@ -137,6 +145,36 @@ export function ScoreHeader({ data, isDark, topInset }: ScoreHeaderProps) {
}
};
const getScoreDisplay = () => {
if (isTennis) {
const scores = (match.scores as any) || [];
if (scores.length > 0) {
const lastSet = scores[scores.length - 1];
return `${lastSet.score_first || "0"}-${lastSet.score_second || "0"}`;
}
return match.eventGameResult && match.eventGameResult !== "-"
? match.eventGameResult
: "0-0";
}
return match.eventFinalResult && match.eventFinalResult !== "-"
? match.eventFinalResult
: "0-0";
};
const firstPlayerName = isTennis ? match.eventFirstPlayer : match.eventHomeTeam;
const secondPlayerName = isTennis ? match.eventSecondPlayer : match.eventAwayTeam;
const firstPlayerLogo = isTennis ? match.eventFirstPlayerLogo : match.homeTeamLogo;
const secondPlayerLogo = isTennis ? match.eventSecondPlayerLogo : match.awayTeamLogo;
const firstPlayerKey = isTennis ? match.firstPlayerKey : match.homeTeamKey;
const secondPlayerKey = isTennis ? match.secondPlayerKey : match.awayTeamKey;
const hasFirstLogo = firstPlayerLogo && firstPlayerLogo.trim() !== "" && !firstPlayerLogo.includes("placehold");
const hasSecondLogo = secondPlayerLogo && secondPlayerLogo.trim() !== "" && !secondPlayerLogo.includes("placehold");
const firstGradient = getLogoGradient(firstPlayerName || "");
const secondGradient = getLogoGradient(secondPlayerName || "");
const firstInitials = getInitials(firstPlayerName || "");
const secondInitials = getInitials(secondPlayerName || "");
return (
<LinearGradient
colors={["#521e10", "#0e0e10"]}
@@ -184,8 +222,12 @@ export function ScoreHeader({ data, isDark, topInset }: ScoreHeaderProps) {
<View style={styles.teamInfo}>
<View style={styles.teamLogoRow}>
<TouchableOpacity
onPress={() => toggleTeamFavorite(match.homeTeamKey, true)}
disabled={homeFavLoading}
onPress={() => {
if (firstPlayerKey) {
toggleTeamFavorite(firstPlayerKey, true);
}
}}
disabled={homeFavLoading || !firstPlayerKey}
style={styles.starBtnLeft}
>
<IconSymbol
@@ -195,41 +237,69 @@ export function ScoreHeader({ data, isDark, topInset }: ScoreHeaderProps) {
/>
</TouchableOpacity>
<View style={styles.logoContainer}>
<Image
source={{ uri: match.homeTeamLogo }}
style={styles.teamLogo}
/>
{hasFirstLogo ? (
<Image
source={{ uri: firstPlayerLogo }}
style={styles.teamLogo}
/>
) : (
<LinearGradient
colors={[firstGradient.color1, firstGradient.color2]}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
style={styles.teamLogoGradient}
>
<ThemedText style={styles.teamLogoText}>{firstInitials}</ThemedText>
</LinearGradient>
)}
</View>
</View>
<ThemedText style={styles.teamName} numberOfLines={2}>
{match.eventHomeTeam}
{firstPlayerName}
</ThemedText>
</View>
<View style={styles.centerScore}>
<View style={styles.scoreBox}>
<ThemedText style={styles.scoreValue}>
{match.eventFinalResult && match.eventFinalResult !== "-"
? match.eventFinalResult
: "0-0"}
{getScoreDisplay()}
</ThemedText>
</View>
<TouchableOpacity style={styles.fieldBtn}>
<IconSymbol name="football-outline" size={16} color="#FFF" />
<IconSymbol
name={isTennis ? "tennisball-outline" : "football-outline"}
size={16}
color="#FFF"
/>
</TouchableOpacity>
</View>
<View style={styles.teamInfo}>
<View style={styles.teamLogoRow}>
<View style={styles.logoContainer}>
<Image
source={{ uri: match.awayTeamLogo }}
style={styles.teamLogo}
/>
{hasSecondLogo ? (
<Image
source={{ uri: secondPlayerLogo }}
style={styles.teamLogo}
/>
) : (
<LinearGradient
colors={[secondGradient.color1, secondGradient.color2]}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
style={styles.teamLogoGradient}
>
<ThemedText style={styles.teamLogoText}>{secondInitials}</ThemedText>
</LinearGradient>
)}
</View>
<TouchableOpacity
onPress={() => toggleTeamFavorite(match.awayTeamKey, false)}
disabled={awayFavLoading}
onPress={() => {
if (secondPlayerKey) {
toggleTeamFavorite(secondPlayerKey, false);
}
}}
disabled={awayFavLoading || !secondPlayerKey}
style={styles.starBtnRight}
>
<IconSymbol
@@ -240,7 +310,7 @@ export function ScoreHeader({ data, isDark, topInset }: ScoreHeaderProps) {
</TouchableOpacity>
</View>
<ThemedText style={styles.teamName} numberOfLines={2}>
{match.eventAwayTeam}
{secondPlayerName}
</ThemedText>
</View>
</View>
@@ -326,6 +396,19 @@ const styles = StyleSheet.create({
width: 60,
height: 60,
resizeMode: "contain",
borderRadius: 30,
},
teamLogoGradient: {
width: 60,
height: 60,
borderRadius: 30,
alignItems: "center",
justifyContent: "center",
},
teamLogoText: {
fontSize: 20,
fontWeight: "700",
color: "rgba(255, 255, 255, 0.92)",
},
teamName: {
color: "#FFF",

View File

@@ -0,0 +1,205 @@
import { ThemedText } from "@/components/themed-text";
import { MatchDetailData } from "@/types/api";
import { Image } from "expo-image";
import React from "react";
import { useTranslation } from "react-i18next";
import { StyleSheet, View } from "react-native";
import { LinearGradient } from "expo-linear-gradient";
import { getInitials, getLogoGradient } from "@/lib/avatar-utils";
interface TennisScoreTableProps {
data: MatchDetailData;
isDark: boolean;
}
export function TennisScoreTable({ data, isDark }: TennisScoreTableProps) {
const { t } = useTranslation();
const { match } = data;
const bgColor = isDark ? "#1C1C1E" : "#FFF";
const borderColor = isDark ? "#2C2C2E" : "rgba(0,0,0,0.06)";
const headerTextColor = isDark ? "#666" : "#999";
const textColor = isDark ? "#FFF" : "#000";
const isTennis = match.sportId === 3;
const scores = (match.scores as any) || [];
const firstPlayer = match.eventFirstPlayer || "";
const secondPlayer = match.eventSecondPlayer || "";
const firstPlayerLogo = match.eventFirstPlayerLogo || "";
const secondPlayerLogo = match.eventSecondPlayerLogo || "";
const hasFirstLogo = firstPlayerLogo && firstPlayerLogo.trim() !== "" && !firstPlayerLogo.includes("placehold");
const hasSecondLogo = secondPlayerLogo && secondPlayerLogo.trim() !== "" && !secondPlayerLogo.includes("placehold");
const firstGradient = getLogoGradient(firstPlayer);
const secondGradient = getLogoGradient(secondPlayer);
const firstInitials = getInitials(firstPlayer);
const secondInitials = getInitials(secondPlayer);
const headers = [t("detail.score_table.set") || "Set"];
scores.forEach((_, index) => {
headers.push(`${index + 1}`);
});
if (scores.length === 0) {
headers.push("1");
}
return (
<View style={[styles.container, { backgroundColor: bgColor, borderColor }]}>
<View style={[styles.header, { borderBottomColor: borderColor }]}>
<View style={styles.headerLeft}>
<ThemedText style={[styles.headerText, { color: headerTextColor }]}>
{t("detail.score_table.player") || "Player"}
</ThemedText>
</View>
{headers.slice(1).map((header, index) => (
<View key={index} style={styles.headerCell}>
<ThemedText style={[styles.headerText, { color: headerTextColor }]}>
{header}
</ThemedText>
</View>
))}
</View>
<View style={[styles.row, { borderBottomColor: borderColor }]}>
<View style={styles.playerCell}>
{hasFirstLogo ? (
<Image source={{ uri: firstPlayerLogo }} style={styles.playerLogo} />
) : (
<LinearGradient
colors={[firstGradient.color1, firstGradient.color2]}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
style={styles.playerLogoGradient}
>
<ThemedText style={styles.playerLogoText}>{firstInitials}</ThemedText>
</LinearGradient>
)}
<ThemedText style={[styles.playerName, { color: textColor }]} numberOfLines={1}>
{firstPlayer}
</ThemedText>
</View>
{scores.length > 0 ? (
scores.map((score: any, index: number) => (
<View key={index} style={styles.scoreCell}>
<ThemedText style={[styles.scoreText, { color: textColor }]}>
{score.score_first || "0"}
</ThemedText>
</View>
))
) : (
<View style={styles.scoreCell}>
<ThemedText style={[styles.scoreText, { color: textColor }]}>0</ThemedText>
</View>
)}
</View>
<View style={styles.row}>
<View style={styles.playerCell}>
{hasSecondLogo ? (
<Image source={{ uri: secondPlayerLogo }} style={styles.playerLogo} />
) : (
<LinearGradient
colors={[secondGradient.color1, secondGradient.color2]}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
style={styles.playerLogoGradient}
>
<ThemedText style={styles.playerLogoText}>{secondInitials}</ThemedText>
</LinearGradient>
)}
<ThemedText style={[styles.playerName, { color: textColor }]} numberOfLines={1}>
{secondPlayer}
</ThemedText>
</View>
{scores.length > 0 ? (
scores.map((score: any, index: number) => (
<View key={index} style={styles.scoreCell}>
<ThemedText style={[styles.scoreText, { color: textColor }]}>
{score.score_second || "0"}
</ThemedText>
</View>
))
) : (
<View style={styles.scoreCell}>
<ThemedText style={[styles.scoreText, { color: textColor }]}>0</ThemedText>
</View>
)}
</View>
</View>
);
}
const styles = StyleSheet.create({
container: {
marginHorizontal: 16,
marginTop: 12,
borderRadius: 16,
borderWidth: 1,
overflow: "hidden",
},
header: {
flexDirection: "row",
borderBottomWidth: 1,
paddingVertical: 12,
paddingHorizontal: 12,
},
headerLeft: {
flex: 1,
minWidth: 120,
},
headerCell: {
flex: 1,
alignItems: "center",
justifyContent: "center",
minWidth: 40,
},
headerText: {
fontSize: 12,
fontWeight: "600",
},
row: {
flexDirection: "row",
borderBottomWidth: 1,
paddingVertical: 12,
paddingHorizontal: 12,
alignItems: "center",
},
playerCell: {
flex: 1,
flexDirection: "row",
alignItems: "center",
minWidth: 120,
gap: 8,
},
playerLogo: {
width: 32,
height: 32,
borderRadius: 16,
},
playerLogoGradient: {
width: 32,
height: 32,
borderRadius: 16,
alignItems: "center",
justifyContent: "center",
},
playerLogoText: {
fontSize: 10,
fontWeight: "700",
color: "rgba(255, 255, 255, 0.92)",
},
playerName: {
flex: 1,
fontSize: 13,
fontWeight: "600",
},
scoreCell: {
flex: 1,
alignItems: "center",
justifyContent: "center",
minWidth: 40,
},
scoreText: {
fontSize: 15,
fontWeight: "700",
},
});