搜索页面

This commit is contained in:
xianyi
2026-01-15 15:03:31 +08:00
parent e60b190dff
commit cff077c5f7
3 changed files with 371 additions and 260 deletions

View File

@@ -1,13 +1,19 @@
import { ThemedText } from "@/components/themed-text"; import { ThemedText } from "@/components/themed-text";
import { ThemedView } from "@/components/themed-view"; import { ThemedView } from "@/components/themed-view";
import { IconSymbol } from "@/components/ui/icon-symbol"; import { IconSymbol } from "@/components/ui/icon-symbol";
import { Colors } from "@/constants/theme";
import { useAppState } from "@/context/AppStateContext";
import { useTheme } from "@/context/ThemeContext"; import { useTheme } from "@/context/ThemeContext";
import { fetchSearch } from "@/lib/api";
import { SearchLeague, SearchPlayer, SearchTeam } from "@/types/api";
import AsyncStorage from "@react-native-async-storage/async-storage";
import { BlurView } from "expo-blur"; import { BlurView } from "expo-blur";
import { LinearGradient } from "expo-linear-gradient"; import { LinearGradient } from "expo-linear-gradient";
import { Stack, useRouter } from "expo-router"; import { Stack, useRouter } from "expo-router";
import React, { useEffect, useRef, useState } from "react"; import React, { useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { import {
ActivityIndicator,
Animated, Animated,
FlatList, FlatList,
ScrollView, ScrollView,
@@ -18,7 +24,7 @@ import {
} from "react-native"; } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useSafeAreaInsets } from "react-native-safe-area-context";
type SearchType = "team" | "player" | "league"; type SearchType = "all" | "team" | "player" | "league";
interface SearchEntity { interface SearchEntity {
id: string; id: string;
@@ -33,22 +39,44 @@ interface SearchEntity {
}; };
} }
// 模拟数据 // 将 API 数据转换为 SearchEntity
const ENTITIES: SearchEntity[] = [ function convertLeagueToEntity(league: SearchLeague): SearchEntity {
{ id: "t_rm", type: "team", name: "Real Madrid", meta: { country: "Spain", league: "LaLiga", season: "2025/26" } }, return {
{ id: "t_fcb", type: "team", name: "FC Barcelona", meta: { country: "Spain", league: "LaLiga", season: "2025/26" } }, id: `league_${league.ID}`,
{ id: "t_mci", type: "team", name: "Manchester City", meta: { country: "England", league: "Premier League", season: "2025/26" } }, type: "league",
{ id: "t_liv", type: "team", name: "Liverpool", meta: { country: "England", league: "Premier League", season: "2025/26" } }, name: league.name,
{ id: "t_ars", type: "team", name: "Arsenal", meta: { country: "England", league: "Premier League", season: "2025/26" } }, meta: {
{ id: "p_mbappe", type: "player", name: "Kylian Mbappé", meta: { country: "France", team: "Real Madrid", league: "LaLiga", pos: "FW", season: "2025/26" } }, country: league.countryName,
{ id: "p_haaland", type: "player", name: "Erling Haaland", meta: { country: "Norway", team: "Manchester City", league: "Premier League", pos: "FW", season: "2025/26" } }, season: "", // API 中没有 season 字段
{ id: "p_salah", type: "player", name: "Mohamed Salah", meta: { country: "Egypt", team: "Liverpool", league: "Premier League", pos: "FW", season: "2025/26" } }, },
{ id: "p_curry", type: "player", name: "Stephen Curry", meta: { country: "USA", team: "Golden State Warriors", league: "NBA", pos: "G", season: "2025/26" } }, };
{ id: "l_epl", type: "league", name: "Premier League", meta: { country: "England", season: "2025/26" } }, }
{ id: "l_laliga", type: "league", name: "LaLiga", meta: { country: "Spain", season: "2025/26" } },
{ id: "l_ucl", type: "league", name: "UEFA Champions League", meta: { country: "Europe", season: "2025/26" } }, function convertPlayerToEntity(player: SearchPlayer): SearchEntity {
{ id: "l_nba", type: "league", name: "NBA", meta: { country: "USA", season: "2025/26" } }, return {
]; id: `player_${player.ID}`,
type: "player",
name: player.name,
meta: {
country: player.countryName,
team: player.teamName,
league: player.leagueName,
pos: player.position,
},
};
}
function convertTeamToEntity(team: SearchTeam): SearchEntity {
return {
id: `team_${team.ID}`,
type: "team",
name: team.name,
meta: {
country: team.countryName,
league: team.leagueName,
},
};
}
// 生成首字母 // 生成首字母
function getInitials(name: string): string { function getInitials(name: string): string {
@@ -113,33 +141,66 @@ function getLogoGradient(name: string): { color1: string; color2: string } {
// 获取副标题文本 // 获取副标题文本
function getSubText(entity: SearchEntity): string { function getSubText(entity: SearchEntity): string {
const { meta } = entity; const { meta } = entity;
const parts: string[] = [];
if (entity.type === "league") { if (entity.type === "league") {
return `${meta.country || ""} · ${meta.season || ""}`; // 联赛:显示国家
if (meta.country) parts.push(meta.country);
} else if (entity.type === "player") {
// 球员:优先显示球队和位置,如果没有位置则显示联赛
if (meta.team) parts.push(meta.team);
if (meta.pos) {
parts.push(meta.pos);
} else if (meta.league) {
parts.push(meta.league);
} }
if (entity.type === "player") { } else if (entity.type === "team") {
return `${meta.team || ""} · ${meta.league || ""}`; // 球队:显示国家和联赛
if (meta.country) parts.push(meta.country);
if (meta.league) parts.push(meta.league);
} }
return `${meta.country || ""} · ${meta.league || ""}`;
return parts.filter(Boolean).join(" · ") || "";
} }
const RECENT_SEARCHES_KEY = "recent_searches";
export default function SearchScreen() { export default function SearchScreen() {
const router = useRouter(); const router = useRouter();
const { theme } = useTheme(); const { theme } = useTheme();
const { t } = useTranslation(); const { t } = useTranslation();
const insets = useSafeAreaInsets(); const insets = useSafeAreaInsets();
const isDark = theme === "dark"; const isDark = theme === "dark";
const { state } = useAppState();
const [searchType, setSearchType] = useState<SearchType>("team"); const [searchType, setSearchType] = useState<SearchType>("all");
const [query, setQuery] = useState(""); const [query, setQuery] = useState("");
const [recentSearches, setRecentSearches] = useState<string[]>([ const [recentSearches, setRecentSearches] = useState<string[]>([]);
"Real Madrid",
"Premier League",
"Curry",
]);
const [showDetailSheet, setShowDetailSheet] = useState(false); const [showDetailSheet, setShowDetailSheet] = useState(false);
const [selectedEntity, setSelectedEntity] = useState<SearchEntity | null>(null); const [selectedEntity, setSelectedEntity] = useState<SearchEntity | null>(null);
const [entities, setEntities] = useState<SearchEntity[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const sheetAnim = useRef(new Animated.Value(0)).current; const sheetAnim = useRef(new Animated.Value(0)).current;
const searchTimeoutRef = useRef<number | null>(null);
useEffect(() => {
const loadRecentSearches = async () => {
try {
const stored = await AsyncStorage.getItem(RECENT_SEARCHES_KEY);
if (stored) {
const parsed = JSON.parse(stored);
if (Array.isArray(parsed)) {
setRecentSearches(parsed);
}
}
} catch (err) {
console.error("Failed to load recent searches:", err);
}
};
loadRecentSearches();
}, []);
useEffect(() => { useEffect(() => {
if (showDetailSheet) { if (showDetailSheet) {
@@ -156,18 +217,70 @@ export default function SearchScreen() {
} }
}, [showDetailSheet]); }, [showDetailSheet]);
const filteredEntities = ENTITIES.filter((e) => { // 搜索 API 调用(带防抖)
if (e.type !== searchType) return false; useEffect(() => {
if (!query.trim()) return true; if (searchTimeoutRef.current) {
const q = query.toLowerCase(); clearTimeout(searchTimeoutRef.current);
return ( }
e.name.toLowerCase().includes(q) || getSubText(e).toLowerCase().includes(q)
if (!query.trim()) {
setEntities([]);
setError(null);
return;
}
searchTimeoutRef.current = setTimeout(async () => {
try {
setLoading(true);
setError(null);
const result = await fetchSearch(
query.trim(),
state.selectedSportId || undefined
); );
const allEntities: SearchEntity[] = [
...result.leagues.map(convertLeagueToEntity),
...result.players.map(convertPlayerToEntity),
...result.teams.map(convertTeamToEntity),
];
setEntities(allEntities);
if (allEntities.length > 0) {
const searchQuery = query.trim();
setRecentSearches((prev) => {
const filtered = prev.filter((x) => x !== searchQuery);
const newRecent = [searchQuery, ...filtered].slice(0, 8);
AsyncStorage.setItem(RECENT_SEARCHES_KEY, JSON.stringify(newRecent)).catch(
(err) => console.error("Failed to save recent searches:", err)
);
return newRecent;
});
}
} catch (err: any) {
console.error("Search error:", err);
setError(err?.message || t("search.error"));
setEntities([]);
} finally {
setLoading(false);
}
}, 500); // 500ms 防抖
return () => {
if (searchTimeoutRef.current) {
clearTimeout(searchTimeoutRef.current);
}
};
}, [query, state.selectedSportId]);
const filteredEntities = entities.filter((e) => {
if (searchType === "all") return true;
if (e.type !== searchType) return false;
return true;
}); });
const handleSearchTypeChange = (type: SearchType) => { const handleSearchTypeChange = (type: SearchType) => {
setSearchType(type); setSearchType(type);
setQuery(""); // 不再清空输入框,保持搜索结果
}; };
const handleRecentClick = (text: string) => { const handleRecentClick = (text: string) => {
@@ -181,6 +294,9 @@ export default function SearchScreen() {
const handleClearRecent = () => { const handleClearRecent = () => {
setRecentSearches([]); setRecentSearches([]);
AsyncStorage.removeItem(RECENT_SEARCHES_KEY).catch(
(err) => console.error("Failed to clear recent searches:", err)
);
}; };
const handleSaveToRecent = () => { const handleSaveToRecent = () => {
@@ -188,8 +304,11 @@ export default function SearchScreen() {
const newRecent = [ const newRecent = [
selectedEntity.name, selectedEntity.name,
...recentSearches.filter((x) => x !== selectedEntity.name), ...recentSearches.filter((x) => x !== selectedEntity.name),
].slice(0, 10); ].slice(0, 8);
setRecentSearches(newRecent); setRecentSearches(newRecent);
AsyncStorage.setItem(RECENT_SEARCHES_KEY, JSON.stringify(newRecent)).catch(
(err) => console.error("Failed to save recent searches:", err)
);
} }
}; };
@@ -242,7 +361,7 @@ export default function SearchScreen() {
style={styles.chips} style={styles.chips}
contentContainerStyle={styles.chipsContent} contentContainerStyle={styles.chipsContent}
> >
{(["team", "player", "league"] as SearchType[]).map((type) => ( {(["all", "team", "player", "league"] as SearchType[]).map((type) => (
<TouchableOpacity <TouchableOpacity
key={type} key={type}
style={[ style={[
@@ -473,6 +592,7 @@ export default function SearchScreen() {
<View style={styles.kvContainer}> <View style={styles.kvContainer}>
{selectedEntity.type === "team" && ( {selectedEntity.type === "team" && (
<> <>
{selectedEntity.meta.country && (
<View <View
style={[ style={[
styles.kvItem, styles.kvItem,
@@ -490,9 +610,11 @@ export default function SearchScreen() {
{t("search.detail.country")} {t("search.detail.country")}
</ThemedText> </ThemedText>
<ThemedText style={styles.kvValue} numberOfLines={1}> <ThemedText style={styles.kvValue} numberOfLines={1}>
{selectedEntity.meta.country || "-"} {selectedEntity.meta.country}
</ThemedText> </ThemedText>
</View> </View>
)}
{selectedEntity.meta.league && (
<View <View
style={[ style={[
styles.kvItem, styles.kvItem,
@@ -510,33 +632,15 @@ export default function SearchScreen() {
{t("search.detail.league")} {t("search.detail.league")}
</ThemedText> </ThemedText>
<ThemedText style={styles.kvValue} numberOfLines={1}> <ThemedText style={styles.kvValue} numberOfLines={1}>
{selectedEntity.meta.league || "-"} {selectedEntity.meta.league}
</ThemedText>
</View>
<View
style={[
styles.kvItem,
{
backgroundColor: isDark
? "rgba(255, 255, 255, 0.05)"
: "rgba(0, 0, 0, 0.04)",
borderColor: isDark
? "rgba(255, 255, 255, 0.10)"
: "rgba(0, 0, 0, 0.08)",
},
]}
>
<ThemedText style={styles.kvLabel}>
{t("search.detail.season")}
</ThemedText>
<ThemedText style={styles.kvValue} numberOfLines={1}>
{selectedEntity.meta.season || "-"}
</ThemedText> </ThemedText>
</View> </View>
)}
</> </>
)} )}
{selectedEntity.type === "player" && ( {selectedEntity.type === "player" && (
<> <>
{selectedEntity.meta.team && (
<View <View
style={[ style={[
styles.kvItem, styles.kvItem,
@@ -554,29 +658,11 @@ export default function SearchScreen() {
{t("search.detail.team")} {t("search.detail.team")}
</ThemedText> </ThemedText>
<ThemedText style={styles.kvValue} numberOfLines={1}> <ThemedText style={styles.kvValue} numberOfLines={1}>
{selectedEntity.meta.team || "-"} {selectedEntity.meta.team}
</ThemedText>
</View>
<View
style={[
styles.kvItem,
{
backgroundColor: isDark
? "rgba(255, 255, 255, 0.05)"
: "rgba(0, 0, 0, 0.04)",
borderColor: isDark
? "rgba(255, 255, 255, 0.10)"
: "rgba(0, 0, 0, 0.08)",
},
]}
>
<ThemedText style={styles.kvLabel}>
{t("search.detail.league")}
</ThemedText>
<ThemedText style={styles.kvValue} numberOfLines={1}>
{selectedEntity.meta.league || "-"}
</ThemedText> </ThemedText>
</View> </View>
)}
{selectedEntity.meta.pos && (
<View <View
style={[ style={[
styles.kvItem, styles.kvItem,
@@ -594,9 +680,33 @@ export default function SearchScreen() {
{t("search.detail.position")} {t("search.detail.position")}
</ThemedText> </ThemedText>
<ThemedText style={styles.kvValue} numberOfLines={1}> <ThemedText style={styles.kvValue} numberOfLines={1}>
{selectedEntity.meta.pos || "-"} {selectedEntity.meta.pos}
</ThemedText> </ThemedText>
</View> </View>
)}
{selectedEntity.meta.league && (
<View
style={[
styles.kvItem,
{
backgroundColor: isDark
? "rgba(255, 255, 255, 0.05)"
: "rgba(0, 0, 0, 0.04)",
borderColor: isDark
? "rgba(255, 255, 255, 0.10)"
: "rgba(0, 0, 0, 0.08)",
},
]}
>
<ThemedText style={styles.kvLabel}>
{t("search.detail.league")}
</ThemedText>
<ThemedText style={styles.kvValue} numberOfLines={1}>
{selectedEntity.meta.league}
</ThemedText>
</View>
)}
{selectedEntity.meta.country && (
<View <View
style={[ style={[
styles.kvItem, styles.kvItem,
@@ -614,13 +724,15 @@ export default function SearchScreen() {
{t("search.detail.country")} {t("search.detail.country")}
</ThemedText> </ThemedText>
<ThemedText style={styles.kvValue} numberOfLines={1}> <ThemedText style={styles.kvValue} numberOfLines={1}>
{selectedEntity.meta.country || "-"} {selectedEntity.meta.country}
</ThemedText> </ThemedText>
</View> </View>
)}
</> </>
)} )}
{selectedEntity.type === "league" && ( {selectedEntity.type === "league" && (
<> <>
{selectedEntity.meta.country && (
<View <View
style={[ style={[
styles.kvItem, styles.kvItem,
@@ -638,29 +750,10 @@ export default function SearchScreen() {
{t("search.detail.country")} {t("search.detail.country")}
</ThemedText> </ThemedText>
<ThemedText style={styles.kvValue} numberOfLines={1}> <ThemedText style={styles.kvValue} numberOfLines={1}>
{selectedEntity.meta.country || "-"} {selectedEntity.meta.country}
</ThemedText>
</View>
<View
style={[
styles.kvItem,
{
backgroundColor: isDark
? "rgba(255, 255, 255, 0.05)"
: "rgba(0, 0, 0, 0.04)",
borderColor: isDark
? "rgba(255, 255, 255, 0.10)"
: "rgba(0, 0, 0, 0.08)",
},
]}
>
<ThemedText style={styles.kvLabel}>
{t("search.detail.season")}
</ThemedText>
<ThemedText style={styles.kvValue} numberOfLines={1}>
{selectedEntity.meta.season || "-"}
</ThemedText> </ThemedText>
</View> </View>
)}
</> </>
)} )}
</View> </View>
@@ -684,7 +777,7 @@ export default function SearchScreen() {
{t("search.detail.open")} {t("search.detail.open")}
</ThemedText> </ThemedText>
</TouchableOpacity> </TouchableOpacity>
<TouchableOpacity {/* <TouchableOpacity
style={[ style={[
styles.sheetBtn, styles.sheetBtn,
styles.sheetBtnPrimary, styles.sheetBtnPrimary,
@@ -707,7 +800,7 @@ export default function SearchScreen() {
> >
{t("search.detail.save")} {t("search.detail.save")}
</ThemedText> </ThemedText>
</TouchableOpacity> </TouchableOpacity> */}
</View> </View>
</Animated.View> </Animated.View>
</> </>
@@ -762,26 +855,6 @@ export default function SearchScreen() {
{t("search.subtitle")} {t("search.subtitle")}
</ThemedText> </ThemedText>
</View> </View>
<TouchableOpacity
style={[
styles.iconBtn,
{
backgroundColor: isDark
? "rgba(255, 255, 255, 0.06)"
: "rgba(0, 0, 0, 0.06)",
borderColor: isDark
? "rgba(255, 255, 255, 0.10)"
: "rgba(0, 0, 0, 0.10)",
},
]}
onPress={handleClearRecent}
>
<IconSymbol
name="refresh"
size={18}
color={isDark ? "rgba(255,255,255,0.9)" : "rgba(0,0,0,0.9)"}
/>
</TouchableOpacity>
</BlurView> </BlurView>
<View style={styles.content}> <View style={styles.content}>
@@ -790,18 +863,36 @@ export default function SearchScreen() {
{renderChips()} {renderChips()}
{renderRecentSearches()} {renderRecentSearches()}
{query.trim() && (
<View style={styles.sectionHeader}> <View style={styles.sectionHeader}>
<ThemedText style={styles.sectionTitle}> <ThemedText style={styles.sectionTitle}>
{t("search.results")} · {t(`search.type.${searchType}`)} {t("search.results")}
{searchType !== "all" && ` · ${t(`search.type.${searchType}`)}`}
</ThemedText> </ThemedText>
<ThemedText style={styles.sectionAction}> <ThemedText style={styles.sectionAction}>
{query.trim() {loading
? `${filteredEntities.length} ${t("search.found")}` ? t("search.loading")
: t("search.tap_to_open")} : error
? t("search.error")
: `${filteredEntities.length} ${t("search.found")}`}
</ThemedText> </ThemedText>
</View> </View>
)}
</View> </View>
{query.trim() ? (
loading ? (
<ThemedView style={styles.center}>
<ActivityIndicator size="large" color={Colors[theme].tint} />
</ThemedView>
) : error ? (
<View style={styles.emptyState}>
<ThemedText style={styles.emptyTitle}>{error}</ThemedText>
<ThemedText style={styles.emptySub}>
{t("search.error_hint")}
</ThemedText>
</View>
) : (
<FlatList <FlatList
data={filteredEntities} data={filteredEntities}
keyExtractor={(item) => item.id} keyExtractor={(item) => item.id}
@@ -813,6 +904,8 @@ export default function SearchScreen() {
]} ]}
showsVerticalScrollIndicator={false} showsVerticalScrollIndicator={false}
/> />
)
) : null}
</View> </View>
{showDetailSheet && renderDetailSheet()} {showDetailSheet && renderDetailSheet()}
@@ -984,6 +1077,12 @@ const styles = StyleSheet.create({
opacity: 0.52, opacity: 0.52,
fontWeight: "900", fontWeight: "900",
}, },
center: {
flex: 1,
justifyContent: "center",
alignItems: "center",
padding: 20,
},
emptyState: { emptyState: {
padding: 14, padding: 14,
borderRadius: 20, borderRadius: 20,

View File

@@ -133,7 +133,13 @@
"tap_to_open": "Tap to open", "tap_to_open": "Tap to open",
"no_results": "No results", "no_results": "No results",
"no_results_hint": "Try another keyword or switch category", "no_results_hint": "Try another keyword or switch category",
"loading": "Loading",
"error": "Error",
"error_hint": "Search failed, please try again",
"start_searching": "Start searching",
"start_searching_hint": "Enter keywords to search for teams, players or leagues",
"type": { "type": {
"all": "All",
"team": "Team", "team": "Team",
"player": "Player", "player": "Player",
"league": "League" "league": "League"

View File

@@ -133,7 +133,13 @@
"tap_to_open": "点击打开", "tap_to_open": "点击打开",
"no_results": "无结果", "no_results": "无结果",
"no_results_hint": "尝试其他关键词或切换分类", "no_results_hint": "尝试其他关键词或切换分类",
"loading": "加载中",
"error": "错误",
"error_hint": "搜索失败,请重试",
"start_searching": "开始搜索",
"start_searching_hint": "输入关键词搜索球队、球员或联赛",
"type": { "type": {
"all": "全部",
"team": "球队", "team": "球队",
"player": "球员", "player": "球员",
"league": "联赛" "league": "联赛"