feat: add favorite player functionality and improve navigation

- components: add FavButton for player favoriting
- store: create useFavPlayerStore for managing favorites
- pages: add FavePlayersPage for displaying favorites
- components: add MenuButtons for navigation controls
- ui: update App and ProfilePage for new features
- meta: update index.html title to "我的开球网"
This commit is contained in:
kyuuseiryuu 2026-02-11 16:00:01 +09:00
parent 789bbb6e03
commit 3b87230173
8 changed files with 190 additions and 11 deletions

View File

@ -2,8 +2,7 @@ import { useCallback } from "react";
import { ClubSelector } from "./components/GameSelector";
import type { IEventInfo } from "./types";
import { useNavigate } from "react-router";
import { FloatButton, Typography } from "antd";
import { SearchOutlined } from "@ant-design/icons";
import { Typography } from "antd";
import "./index.css";
export function App() {
@ -15,7 +14,6 @@ export function App() {
<div className="app">
<Typography.Title level={1}></Typography.Title>
<ClubSelector onGameClick={handleGameClick} />
<FloatButton icon={<SearchOutlined />} onClick={() => navigate('/find')} />
</div>
);
}

View File

@ -0,0 +1,38 @@
import { Rate } from "antd";
import { useCallback, useState } from "react";
import { useFavPlayerStore, type FavPlayer } from "../store/useFavPlayerStore";
import styled from "styled-components";
interface Props {
user?: FavPlayer;
}
const StyledContainer = styled.div`
display: flex;
align-items: center;
.ant-rate {
margin: 0;
.ant-rate-star {
margin: 0;
}
}
`;
export function FavButton(props: Props) {
const favStore = useFavPlayerStore(state => state);
const [value, setValue] = useState(favStore.isFav(props.user?.uid) ? 1 : 0);
const handleFavClick = useCallback((value: number) => {
if (!props.user) return;
setValue(value);
if (value) {
favStore.fav(props.user);
} else {
favStore.unFav(props.user.uid);
}
}, [favStore]);
return (
<StyledContainer>
<Rate allowClear count={1} value={value} onChange={handleFavClick} />
</StyledContainer>
);
}

View File

@ -0,0 +1,15 @@
import { ArrowLeftOutlined, HeartOutlined, MenuOutlined, ScheduleOutlined, SearchOutlined } from "@ant-design/icons";
import { FloatButton } from "antd";
import { useNavigate } from "react-router";
export function MenuButtons() {
const navigate = useNavigate();
return (
<FloatButton.Group trigger="click" icon={<MenuOutlined />}>
<FloatButton icon={<ScheduleOutlined />} onClick={() => navigate('/')} />
<FloatButton icon={<HeartOutlined />} onClick={() => navigate('/fav-players')} />
<FloatButton icon={<SearchOutlined />} onClick={() => navigate('/find')} />
<FloatButton icon={<ArrowLeftOutlined />} onClick={() => navigate(-1)} />
</FloatButton.Group>
);
}

View File

@ -15,6 +15,8 @@ import ProfilePage from "./page/ProfilePage";
import EventPage from "./page/EventPage";
import type { MatchInfo, XCXMember } from "./types";
import { FindUserPage } from "./page/FindUserPage";
import { FavePlayersPage } from "./page/FavPlayersPage";
import { MenuButtons } from "./components/MenuButtons";
const elem = document.getElementById("root")!;
@ -26,9 +28,13 @@ const route = createBrowserRouter([
{
path: '',
index: true,
element: <App />,
Component: App,
HydrateFallback: () => <HydrateFallback />
},
{
path: '/fav-players',
Component: FavePlayersPage,
},
{
path: 'event/:matchId',
loader: async ({ params }) => {
@ -57,12 +63,12 @@ const route = createBrowserRouter([
Component: ProfilePage,
HydrateFallback: () => <HydrateFallback />
},
],
},
{
path: 'find',
Component: FindUserPage,
}
],
},
]);
function HydrateFallback() {
@ -76,7 +82,10 @@ function HydrateFallback() {
function Layout() {
const navigation = useNavigation();
const loading = navigation.state === 'loading';
return loading ? <HydrateFallback /> : <Outlet />
return loading ? <HydrateFallback /> : (<>
<Outlet />
<MenuButtons />
</>);
}
const app = (

View File

@ -5,7 +5,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no" />
<link rel="icon" type="image/svg+xml" href="./logo.jpg" />
<title>开球网比赛分组预测</title>
<title>我的开球网</title>
</head>
<body>
<div id="root"></div>

View File

@ -0,0 +1,72 @@
import { Avatar, Card, Divider, Flex, Image, Segmented, Space, Typography } from "antd";
import { useFavPlayerStore } from "../store/useFavPlayerStore";
import { useNavigate } from "react-router";
import { useMemo, useState } from "react";
enum SortType {
DEFAULT = '默认排序',
SCORE_UP = '积分升序',
SCORE_DOWN = '积分降序',
}
export function FavePlayersPage() {
const { favMap } = useFavPlayerStore(state => state);
const [sortType, setSortType] = useState<SortType>(SortType.SCORE_DOWN);
const list = useMemo(() => {
const l = Object.values(favMap);
switch (sortType) {
case SortType.DEFAULT: return l;
case SortType.SCORE_UP: return l.sort(({ score: a }, { score: b }) => +a - +b);
case SortType.SCORE_DOWN: return l.sort(({ score: b }, { score: a }) => +a - +b);
default: return l;
}
}, [favMap, sortType]);
const navigate = useNavigate();
return (
<div className="app">
<Flex vertical gap={48}>
<Typography.Title></Typography.Title>
<div
style={{ position: 'sticky', top: 12, zIndex: 1 }}
>
<Segmented
value={sortType}
onChange={e => setSortType(e)}
options={[
SortType.SCORE_DOWN,
SortType.SCORE_UP,
]}
/>
</div>
{list.length === 0 ? (
<Divider></Divider>
) : (
<Flex wrap gap={16} align="center" justify="center">
{list.map(e => (
<Card
hoverable
size="small"
key={e.uid}
style={{ width: 240 }}
>
<Flex gap={12}>
{e.avatar?.includes('noavatar') ? <Avatar size={64} shape="square">{e.name}</Avatar> : <Image width={64} src={e.avatar} />}
<Flex
vertical
justify="center"
align="center"
style={{ flex: 1 }}
onClick={() => navigate(`/profile/${e.uid}`)}
>
<h2 style={{ margin: 0 }}>{e.name}</h2>
<Typography.Text type="secondary">{e.realname}</Typography.Text>
</Flex>
</Flex>
</Card>
))}
</Flex>
)}
</Flex>
</div>
);
}

View File

@ -9,6 +9,7 @@ import { ChangeBackground } from "../components/ChangeBackground";
import UserTags from "../components/Tags";
import { GameTable } from "../components/GameTable";
import { useTitle } from "ahooks";
import { FavButton } from "../components/FavButton";
function Honor(props: { honors?: XCXProfile['honors'] }) {
if (!props.honors?.length) return null;
@ -96,7 +97,20 @@ export default function ProfilePage() {
<FloatButton icon={<HomeOutlined />} onClick={() => navigate('/')} />
<Flex vertical align="center" style={{ padding: 24 }}>
<Avatar src={profile?.realpic} size={128} />
<Typography.Title level={2}>{profile?.username}</Typography.Title>
<Typography.Title level={2}>
<Flex justify="center">
<span>{profile?.username}</span>
<FavButton
user={{
uid,
score: profile.score,
name: profile.username,
realname: profile.realname,
avatar: profile.realpic
}}
/>
</Flex>
</Typography.Title>
<Typography.Text>{profile?.realname}</Typography.Text>
<Typography.Text>{profile?.score}</Typography.Text>
<Typography.Text style={{ textAlign: 'center' }}>

View File

@ -0,0 +1,33 @@
import { create } from 'zustand';
import { persist, createJSONStorage } from 'zustand/middleware';
import type { BasePlayer } from '../types';
export interface FavPlayer extends BasePlayer {
realname?: string;
avatar?: string;
}
interface FavPlayerStore {
favMap: Record<string, FavPlayer>;
fav(player: FavPlayer): void;
unFav(uid: string): void;
isFav(uid?: string): boolean;
}
export const useFavPlayerStore = create(persist<FavPlayerStore>((set, get) => ({
favMap: {},
fav: user => {
const { favMap } = get();
favMap[user.uid] = user;
set({ favMap: { ...favMap } });
},
unFav: uid => {
const { favMap } = get();
delete favMap[uid];
set({ favMap: { ...favMap } });
},
isFav(uid) {
if (!uid) return false;
return !!get().favMap[uid];
},
}), { name: 'fav-player-store', storage: createJSONStorage(() => localStorage) }));