搜索页面

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 { ThemedView } from "@/components/themed-view";
import { IconSymbol } from "@/components/ui/icon-symbol";
import { Colors } from "@/constants/theme";
import { useAppState } from "@/context/AppStateContext";
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 { LinearGradient } from "expo-linear-gradient";
import { Stack, useRouter } from "expo-router";
import React, { useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import {
ActivityIndicator,
Animated,
FlatList,
ScrollView,
@@ -18,7 +24,7 @@ import {
} from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
type SearchType = "team" | "player" | "league";
type SearchType = "all" | "team" | "player" | "league";
interface SearchEntity {
id: string;
@@ -33,22 +39,44 @@ interface SearchEntity {
};
}
// 模拟数据
const ENTITIES: SearchEntity[] = [
{ id: "t_rm", type: "team", name: "Real Madrid", meta: { country: "Spain", league: "LaLiga", season: "2025/26" } },
{ id: "t_fcb", type: "team", name: "FC Barcelona", meta: { country: "Spain", league: "LaLiga", season: "2025/26" } },
{ id: "t_mci", type: "team", name: "Manchester City", meta: { country: "England", league: "Premier League", season: "2025/26" } },
{ id: "t_liv", type: "team", name: "Liverpool", meta: { country: "England", league: "Premier League", season: "2025/26" } },
{ id: "t_ars", type: "team", name: "Arsenal", meta: { country: "England", league: "Premier League", season: "2025/26" } },
{ id: "p_mbappe", type: "player", name: "Kylian Mbappé", meta: { country: "France", team: "Real Madrid", league: "LaLiga", pos: "FW", season: "2025/26" } },
{ id: "p_haaland", type: "player", name: "Erling Haaland", meta: { country: "Norway", team: "Manchester City", league: "Premier League", pos: "FW", season: "2025/26" } },
{ 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" } },
{ id: "l_nba", type: "league", name: "NBA", meta: { country: "USA", season: "2025/26" } },
];
// 将 API 数据转换为 SearchEntity
function convertLeagueToEntity(league: SearchLeague): SearchEntity {
return {
id: `league_${league.ID}`,
type: "league",
name: league.name,
meta: {
country: league.countryName,
season: "", // API 中没有 season 字段
},
};
}
function convertPlayerToEntity(player: SearchPlayer): SearchEntity {
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 {
@@ -113,33 +141,66 @@ function getLogoGradient(name: string): { color1: string; color2: string } {
// 获取副标题文本
function getSubText(entity: SearchEntity): string {
const { meta } = entity;
const parts: string[] = [];
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);
}
} else if (entity.type === "team") {
// 球队:显示国家和联赛
if (meta.country) parts.push(meta.country);
if (meta.league) parts.push(meta.league);
}
if (entity.type === "player") {
return `${meta.team || ""} · ${meta.league || ""}`;
}
return `${meta.country || ""} · ${meta.league || ""}`;
return parts.filter(Boolean).join(" · ") || "";
}
const RECENT_SEARCHES_KEY = "recent_searches";
export default function SearchScreen() {
const router = useRouter();
const { theme } = useTheme();
const { t } = useTranslation();
const insets = useSafeAreaInsets();
const isDark = theme === "dark";
const { state } = useAppState();
const [searchType, setSearchType] = useState<SearchType>("team");
const [searchType, setSearchType] = useState<SearchType>("all");
const [query, setQuery] = useState("");
const [recentSearches, setRecentSearches] = useState<string[]>([
"Real Madrid",
"Premier League",
"Curry",
]);
const [recentSearches, setRecentSearches] = useState<string[]>([]);
const [showDetailSheet, setShowDetailSheet] = useState(false);
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 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(() => {
if (showDetailSheet) {
@@ -156,18 +217,70 @@ export default function SearchScreen() {
}
}, [showDetailSheet]);
const filteredEntities = ENTITIES.filter((e) => {
// 搜索 API 调用(带防抖)
useEffect(() => {
if (searchTimeoutRef.current) {
clearTimeout(searchTimeoutRef.current);
}
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;
if (!query.trim()) return true;
const q = query.toLowerCase();
return (
e.name.toLowerCase().includes(q) || getSubText(e).toLowerCase().includes(q)
);
return true;
});
const handleSearchTypeChange = (type: SearchType) => {
setSearchType(type);
setQuery("");
// 不再清空输入框,保持搜索结果
};
const handleRecentClick = (text: string) => {
@@ -181,6 +294,9 @@ export default function SearchScreen() {
const handleClearRecent = () => {
setRecentSearches([]);
AsyncStorage.removeItem(RECENT_SEARCHES_KEY).catch(
(err) => console.error("Failed to clear recent searches:", err)
);
};
const handleSaveToRecent = () => {
@@ -188,8 +304,11 @@ export default function SearchScreen() {
const newRecent = [
selectedEntity.name,
...recentSearches.filter((x) => x !== selectedEntity.name),
].slice(0, 10);
].slice(0, 8);
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}
contentContainerStyle={styles.chipsContent}
>
{(["team", "player", "league"] as SearchType[]).map((type) => (
{(["all", "team", "player", "league"] as SearchType[]).map((type) => (
<TouchableOpacity
key={type}
style={[
@@ -473,194 +592,168 @@ export default function SearchScreen() {
<View style={styles.kvContainer}>
{selectedEntity.type === "team" && (
<>
<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.country")}
</ThemedText>
<ThemedText style={styles.kvValue} numberOfLines={1}>
{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.league")}
</ThemedText>
<ThemedText style={styles.kvValue} numberOfLines={1}>
{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>
</View>
{selectedEntity.meta.country && (
<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.country")}
</ThemedText>
<ThemedText style={styles.kvValue} numberOfLines={1}>
{selectedEntity.meta.country}
</ThemedText>
</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.type === "player" && (
<>
<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.team")}
</ThemedText>
<ThemedText style={styles.kvValue} numberOfLines={1}>
{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>
</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.position")}
</ThemedText>
<ThemedText style={styles.kvValue} numberOfLines={1}>
{selectedEntity.meta.pos || "-"}
</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.country")}
</ThemedText>
<ThemedText style={styles.kvValue} numberOfLines={1}>
{selectedEntity.meta.country || "-"}
</ThemedText>
</View>
{selectedEntity.meta.team && (
<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.team")}
</ThemedText>
<ThemedText style={styles.kvValue} numberOfLines={1}>
{selectedEntity.meta.team}
</ThemedText>
</View>
)}
{selectedEntity.meta.pos && (
<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.position")}
</ThemedText>
<ThemedText style={styles.kvValue} numberOfLines={1}>
{selectedEntity.meta.pos}
</ThemedText>
</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
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.country")}
</ThemedText>
<ThemedText style={styles.kvValue} numberOfLines={1}>
{selectedEntity.meta.country}
</ThemedText>
</View>
)}
</>
)}
{selectedEntity.type === "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.country")}
</ThemedText>
<ThemedText style={styles.kvValue} numberOfLines={1}>
{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>
</View>
{selectedEntity.meta.country && (
<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.country")}
</ThemedText>
<ThemedText style={styles.kvValue} numberOfLines={1}>
{selectedEntity.meta.country}
</ThemedText>
</View>
)}
</>
)}
</View>
@@ -684,7 +777,7 @@ export default function SearchScreen() {
{t("search.detail.open")}
</ThemedText>
</TouchableOpacity>
<TouchableOpacity
{/* <TouchableOpacity
style={[
styles.sheetBtn,
styles.sheetBtnPrimary,
@@ -707,7 +800,7 @@ export default function SearchScreen() {
>
{t("search.detail.save")}
</ThemedText>
</TouchableOpacity>
</TouchableOpacity> */}
</View>
</Animated.View>
</>
@@ -762,26 +855,6 @@ export default function SearchScreen() {
{t("search.subtitle")}
</ThemedText>
</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>
<View style={styles.content}>
@@ -790,29 +863,49 @@ export default function SearchScreen() {
{renderChips()}
{renderRecentSearches()}
<View style={styles.sectionHeader}>
<ThemedText style={styles.sectionTitle}>
{t("search.results")} · {t(`search.type.${searchType}`)}
</ThemedText>
<ThemedText style={styles.sectionAction}>
{query.trim()
? `${filteredEntities.length} ${t("search.found")}`
: t("search.tap_to_open")}
</ThemedText>
</View>
{query.trim() && (
<View style={styles.sectionHeader}>
<ThemedText style={styles.sectionTitle}>
{t("search.results")}
{searchType !== "all" && ` · ${t(`search.type.${searchType}`)}`}
</ThemedText>
<ThemedText style={styles.sectionAction}>
{loading
? t("search.loading")
: error
? t("search.error")
: `${filteredEntities.length} ${t("search.found")}`}
</ThemedText>
</View>
)}
</View>
<FlatList
data={filteredEntities}
keyExtractor={(item) => item.id}
renderItem={renderEntityItem}
ListEmptyComponent={() => renderEmptyState()}
contentContainerStyle={[
styles.listContent,
{ paddingBottom: insets.bottom + 20 },
]}
showsVerticalScrollIndicator={false}
/>
{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
data={filteredEntities}
keyExtractor={(item) => item.id}
renderItem={renderEntityItem}
ListEmptyComponent={() => renderEmptyState()}
contentContainerStyle={[
styles.listContent,
{ paddingBottom: insets.bottom + 20 },
]}
showsVerticalScrollIndicator={false}
/>
)
) : null}
</View>
{showDetailSheet && renderDetailSheet()}
@@ -984,6 +1077,12 @@ const styles = StyleSheet.create({
opacity: 0.52,
fontWeight: "900",
},
center: {
flex: 1,
justifyContent: "center",
alignItems: "center",
padding: 20,
},
emptyState: {
padding: 14,
borderRadius: 20,