网球详情页

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

@@ -10,6 +10,7 @@ import { LeagueInfo } from "@/components/match-detail/league-info";
import { MatchInfoCard } from "@/components/match-detail/match-info-card";
import { MatchTabs } from "@/components/match-detail/match-tabs";
import { ScoreHeader } from "@/components/match-detail/score-header";
import { TennisScoreTable } from "@/components/match-detail/tennis/tennis-score-table";
import { ThemedText } from "@/components/themed-text";
import { ThemedView } from "@/components/themed-view";
import { Colors } from "@/constants/theme";
@@ -52,18 +53,21 @@ export default function MatchDetailScreen() {
if (data?.match?.sportId) {
const sportId = data.match.sportId;
let validTabs: string[] = [];
if (sportId === 1) {
// 足球
validTabs = ["info", "stats", "odds", "h2h", "chat"];
} else if (sportId === 2) {
// 篮球
validTabs = ["info", "h2h", "chat"];
} else if (sportId === 3) {
// 网球
validTabs = ["info", "chat"];
} else {
// 默认
validTabs = ["info", "h2h", "chat"];
}
// 如果当前 activeTab 不在有效标签列表中,重置为第一个
if (!validTabs.includes(activeTab)) {
setActiveTab(validTabs[0]);
@@ -77,11 +81,11 @@ export default function MatchDetailScreen() {
setError(null);
const result = await fetchMatchDetail(id as string);
setData(result);
console.log("首发阵容" , result.match.players?.away_team);
console.log("首发阵容", result.match.players?.away_team);
console.log("红黄牌", result.events);
} catch (err: any) {
setError(err.message || t("detail.fetch_failed"));
} finally {
@@ -130,6 +134,14 @@ export default function MatchDetailScreen() {
<MatchInfoCard data={data} isDark={isDark} />
</>
);
} else if (sportId === 3) {
// 网球:显示 TennisScoreTable 和 MatchInfoCard
return (
<>
<TennisScoreTable data={data} isDark={isDark} />
<MatchInfoCard data={data} isDark={isDark} />
</>
);
} else {
// 默认使用足球组件
return (

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",
},
});

View File

@@ -115,7 +115,8 @@
"halftime": "Half Time",
"full_time": "90'",
"extra_time": "ET",
"penalty": "Pen"
"penalty": "Pen",
"player": "Player"
},
"halftime": "Half: {{score}}",
"empty_stats": "No statistics data",

View File

@@ -111,7 +111,8 @@
"halftime": "हाफ टाइम",
"full_time": "90'",
"extra_time": "अतिरिक्त समय",
"penalty": "पेनल्टी"
"penalty": "पेनल्टी",
"player": "खिलाड़ी"
},
"halftime": "हाफ टाइम: {{score}}",
"empty_stats": "कोई आँकड़े उपलब्ध नहीं",

View File

@@ -111,7 +111,8 @@
"halftime": "Babak Pertama",
"full_time": "90'",
"extra_time": "Perpanjangan",
"penalty": "Adu Penalti"
"penalty": "Adu Penalti",
"player": "Pemain"
},
"halftime": "Babak Pertama: {{score}}",
"empty_stats": "Tidak ada data statistik",

View File

@@ -111,7 +111,8 @@
"halftime": "Separuh Masa",
"full_time": "90'",
"extra_time": "Masa Tambahan",
"penalty": "Sepakan Penalti"
"penalty": "Sepakan Penalti",
"player": "Pemain"
},
"halftime": "Separuh masa: {{score}}",
"empty_stats": "Tiada data statistik",

View File

@@ -111,7 +111,8 @@
"halftime": "ครึ่งแรก",
"full_time": "90'",
"extra_time": "ต่อเวลา",
"penalty": "จุดโทษ"
"penalty": "จุดโทษ",
"player": "ผู้เล่น"
},
"halftime": "ครึ่งแรก: {{score}}",
"empty_stats": "ไม่มีข้อมูลสถิติ",

View File

@@ -111,7 +111,8 @@
"halftime": "Hiệp 1",
"full_time": "90'",
"extra_time": "Hiệp phụ",
"penalty": "Luân lưu"
"penalty": "Luân lưu",
"player": "Cầu thủ"
},
"halftime": "Hiệp 1: {{score}}",
"empty_stats": "Không có dữ liệu thống kê",

View File

@@ -115,7 +115,8 @@
"halftime": "半场",
"full_time": "90分钟",
"extra_time": "加时",
"penalty": "点球"
"penalty": "点球",
"player": "球员"
},
"halftime": "半场: {{score}}",
"empty_stats": "暂无统计数据",

72
temp.json Normal file
View File

@@ -0,0 +1,72 @@
{
"code": 0,
"message": "ok",
"data": {
"match": {
"ID": 250468,
"CreatedAt": "2026-01-21T05:45:15.704+08:00",
"UpdatedAt": "2026-01-21T05:45:15.704+08:00",
"DeletedAt": null,
"eventKey": "12097737",
"eventDate": "2026-01-22",
"eventTime": "03:00",
"eventHomeTeam": "",
"homeTeamKey": "",
"homeTeamLogo": "",
"eventAwayTeam": "",
"awayTeamKey": "",
"awayTeamLogo": "",
"eventHalftimeResult": "",
"eventFinalResult": "-",
"eventFtResult": "",
"eventPenaltyResult": "",
"eventStatus": "1",
"countryName": "Challenger Men Singles",
"leagueName": "Phan Thiet",
"leagueKey": "13614",
"leagueRound": "Phan Thiet - 1/8-finals",
"leagueSeason": "2026",
"eventLive": "0",
"eventStadium": "",
"eventReferee": "",
"eventCountryKey": "281",
"leagueLogo": "",
"countryLogo": "",
"eventHomeFormation": "",
"eventAwayFormation": "",
"fkStageKey": "",
"stageName": "",
"leagueGroup": "",
"sportId": 3,
"eventQuarter": "",
"eventSet": "",
"eventType": "",
"eventToss": "",
"eventManOfMatch": "",
"eventFirstPlayer": "O. Jasika",
"firstPlayerKey": "1261",
"eventSecondPlayer": "I. Simakin",
"secondPlayerKey": "1238",
"eventFirstPlayerLogo": "https://apiv2.allsportsapi.com/logo-tennis/1261_o-jasika.jpg",
"eventSecondPlayerLogo": "https://apiv2.allsportsapi.com/logo-tennis/1238_i-simakin.jpg",
"eventGameResult": "-",
"eventServe": "",
"eventWinner": "",
"events": null,
"players": null,
"scores": [
{
"score_set": "1",
"score_first": "0",
"score_second": "0"
}
],
"stats": null,
"pointByPoint": null,
"scorecard": null,
"comments": null,
"wickets": null,
"extra": null
}
}
}

View File

@@ -103,11 +103,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;
@@ -305,6 +305,14 @@ export interface MatchDetailData {
eventType: string;
eventToss: string;
eventManOfMatch: string;
eventFirstPlayer?: string;
eventSecondPlayer?: string;
eventFirstPlayerLogo?: string;
eventSecondPlayerLogo?: string;
firstPlayerKey?: string;
secondPlayerKey?: string;
eventGameResult?: string;
scores?: any[];
events?: MatchEvents;
players?: {
home_team?: Player[];