feat(global): Skeleton

This commit is contained in:
kyuuseiryuu 2026-01-30 01:10:14 +09:00
parent bf74e99a47
commit 6924cc873b
5 changed files with 86 additions and 63 deletions

View File

@ -1,6 +1,6 @@
import type React from "react"; import type React from "react";
import type { BasePlayer, Player } from "../types"; import type { BasePlayer, Player } from "../types";
import { Tabs } from "antd"; import { Skeleton, Spin, Tabs } from "antd";
import { PlayerList } from "./PlayerList"; import { PlayerList } from "./PlayerList";
import { GroupingPrediction } from "./GroupingPrediction"; import { GroupingPrediction } from "./GroupingPrediction";
import { useMemo } from "react"; import { useMemo } from "react";

View File

@ -1,4 +1,4 @@
import { Card, Divider, Flex, Select, Space, Statistic, Switch, Typography } from 'antd'; import { Card, Divider, Flex, Select, Skeleton, Space, Statistic, Switch, Typography } from 'antd';
import type React from 'react'; import type React from 'react';
import { useRequest } from 'ahooks'; import { useRequest } from 'ahooks';
import { clubs } from './clubList'; import { clubs } from './clubList';
@ -48,12 +48,14 @@ export const GameSelector: React.FC<Props> = props => {
/> />
</Flex> </Flex>
<Divider>{isEmpty && (<Typography.Text type='secondary'></Typography.Text>)}</Divider> <Divider>{isEmpty && (<Typography.Text type='secondary'></Typography.Text>)}</Divider>
{requestEvents.loading ? <Skeleton.Button block active style={{ height: 200 }} /> : (
<Flex wrap gap={12} justify='center'> <Flex wrap gap={12} justify='center'>
{gameList {gameList
.filter(e => showFinished || !e.finished) .filter(e => showFinished || !e.finished)
.map(e => <EventCard key={e.matchId} eventInfo={e} onGameClick={props.onGameClick} />) .map(e => <EventCard key={e.matchId} eventInfo={e} onGameClick={props.onGameClick} />)
} }
</Flex> </Flex>
)}
</Space> </Space>
); );
} }

View File

@ -24,16 +24,14 @@ const route = createBrowserRouter([
{ {
path: '/event/:matchId', path: '/event/:matchId',
loader: async ({ params }) => { loader: async ({ params }) => {
const info: MatchInfo = await (await fetch(`/api/match/${params.matchId}`)).json(); return { matchId: params.matchId };
const members = await (await fetch(`/api/match/${params.matchId}/${info.itemId}`)).json();
return { info, members };
}, },
Component: EventPage, Component: EventPage,
}, },
{ {
path: '/profile/:uid', path: '/profile/:uid',
loader: async ({ params }) => { loader: async ({ params }) => {
return fetch(`/api/user/${params.uid}`); return params.uid;
}, },
Component: ProfilePage, Component: ProfilePage,
}, },

View File

@ -1,32 +1,42 @@
import { useLoaderData, useNavigate } from "react-router"; import { useLoaderData, useNavigate } from "react-router";
import { GamePanel } from "../components/GamePanel"; import { GamePanel } from "../components/GamePanel";
import type { BasePlayer, MatchInfo, XCXMember } from "../types"; import type { BasePlayer, MatchInfo, XCXMember } from "../types";
import { Typography } from "antd"; import { Skeleton, Typography } from "antd";
import { HomeOutlined } from "@ant-design/icons"; import { HomeOutlined } from "@ant-design/icons";
import { useMemo } from "react"; import { useMemo } from "react";
import { useRequest } from "ahooks";
export default function EventPage() { export default function EventPage() {
const { const {
info: game, matchId,
members } = useLoaderData<{ matchId: string }>();
} = useLoaderData<{ info: MatchInfo, members: XCXMember[] }>(); const fetchData = useRequest<{ info: MatchInfo, members: XCXMember[] }, []>(async () => {
const info: MatchInfo = await (await fetch(`/api/match/${matchId}`)).json();
const members = await (await fetch(`/api/match/${matchId}/${info.itemId}`)).json();
return { info, members };
}, { refreshDeps: [matchId]});
const { info: game, members = [] } = useMemo<{ info?: MatchInfo, members?: XCXMember[] }>(() => fetchData.data ?? {}, [fetchData]);
const navigate = useNavigate(); const navigate = useNavigate();
const map = useMemo(() => Object.fromEntries(members.map(e => [e.uid, e])), [members]); const map = useMemo(() => Object.fromEntries(members.map(e => [e.uid, e])), [members]);
const players = useMemo(() => { const players = useMemo(() => {
return game.players return game?.players
.map(e => ({ ...e, name: map[e.uid]?.realname ?? e.name, number: map[e.uid]?.number ?? NaN })) ?.map(e => ({ ...e, name: map[e.uid]?.realname ?? e.name, number: map[e.uid]?.number ?? NaN }))
.sort(((a, b) => +a.number - +b.number)); ?.sort(((a, b) => +a.number - +b.number));
}, [game, map]); }, [game, map]);
const basePlayers = useMemo<BasePlayer[]>(() => { const basePlayers = useMemo<BasePlayer[]>(() => {
return members.map(e => ({ ...e, name: e.realname } as BasePlayer)) return members.map(e => ({ ...e, name: e.realname } as BasePlayer))
}, [members]); }, [members]);
return ( return (
<div style={{ width: '100%', padding: 10, boxSizing: 'border-box' }}> <div style={{ width: '100%', padding: 10, boxSizing: 'border-box' }}>
{fetchData.loading ? (<Skeleton.Button block />) : (
<Typography.Title level={3}> <Typography.Title level={3}>
<HomeOutlined style={{ marginRight: 4 }} onClick={() => navigate('/')}/> <HomeOutlined style={{ marginRight: 4 }} onClick={() => navigate('/')}/>
{game.title} {game?.title}
</Typography.Title> </Typography.Title>
<GamePanel members={basePlayers} title={game.title} players={players} /> )}
{fetchData.loading ? <Skeleton.Button active block size="large" style={{ height: 400, marginTop: 40 }} /> : (
<GamePanel members={basePlayers} title={game?.title ?? ''} players={players} />
)}
</div> </div>
); );
} }

View File

@ -1,12 +1,13 @@
import { Link, useLoaderData, useNavigate } from "react-router"; import { Link, useLoaderData, useNavigate } from "react-router";
import type { XCXProfile } from "../types/profile"; import type { XCXProfile } from "../types/profile";
import { Avatar, Descriptions, Divider, Flex, FloatButton, Image, Typography } from "antd"; import { Skeleton, Avatar, Descriptions, Divider, Flex, FloatButton, Image, Typography } from "antd";
import { HomeOutlined } from "@ant-design/icons"; import { HomeOutlined } from "@ant-design/icons";
import User from "../components/User"; import User from "../components/User";
import React from "react"; import React, { useMemo } from "react";
import { ChangeBackground } from "../components/ChangeBackground"; import { ChangeBackground } from "../components/ChangeBackground";
import UserTags from "../components/Tags"; import UserTags from "../components/Tags";
import { useRequest } from "ahooks";
function Honor(props: { honors?: XCXProfile['honors'] }) { function Honor(props: { honors?: XCXProfile['honors'] }) {
if (!props.honors?.length) return null; if (!props.honors?.length) return null;
@ -79,29 +80,40 @@ function PlayerList(props: { title: string; names?: string[]; uids?: string[] })
} }
export default function ProfilePage() { export default function ProfilePage() {
const profile = useLoaderData<XCXProfile | null>(); const uid = useLoaderData<string | null>();
const fetchData = useRequest<XCXProfile | undefined, []>(async () => {
return (await fetch(`/api/user/${uid}`)).json();
}, { refreshDeps: [uid] });
const loading = fetchData.loading;
const profile = fetchData.data;
const navigate = useNavigate(); const navigate = useNavigate();
const tags = useMemo<React.ReactNode[]>(() =>
[profile?.province, profile?.sex, profile?.bg ?? '', ...(profile?.allCities ?? [])]
?.filter(Boolean), []);
return ( return (
<> <>
<ChangeBackground url={profile?.realpic} /> <ChangeBackground url={profile?.realpic} />
<FloatButton icon={<HomeOutlined />} onClick={() => navigate('/')} /> <FloatButton icon={<HomeOutlined />} onClick={() => navigate('/')} />
<Flex vertical align="center" style={{ padding: 24 }}> <Flex vertical align="center" style={{ padding: 24 }}>
{ loading ? <Skeleton.Avatar active size={128} /> : (
<Avatar src={profile?.realpic} size={128} /> <Avatar src={profile?.realpic} size={128} />
) }
<Skeleton active loading={loading}>
<Typography.Title level={2}>{profile?.username}</Typography.Title> <Typography.Title level={2}>{profile?.username}</Typography.Title>
<Typography.Text>{profile?.realname}</Typography.Text> <Typography.Text>{profile?.realname}</Typography.Text>
<Typography.Text>{profile?.score}</Typography.Text> <Typography.Text>{profile?.score}</Typography.Text>
<Typography.Text style={{ textAlign: 'center' }}> <Typography.Text style={{ textAlign: 'center' }}>
{ { tags.length ? tags.reduce((a, b) => (<>{a}<Divider orientation="vertical" />{b}</>)) : null }
([profile?.province, profile?.sex, profile?.bg ?? '', ...(profile?.allCities ?? [])] as React.ReactNode[])
.filter(Boolean)
.reduce((a, b) => (<>{a}<Divider orientation="vertical" />{b}</>))
}
</Typography.Text> </Typography.Text>
</Skeleton>
<Divider></Divider> <Divider></Divider>
<Skeleton active loading={loading}>
<Typography.Paragraph> <Typography.Paragraph>
{profile?.description} {profile?.description}
</Typography.Paragraph> </Typography.Paragraph>
</Skeleton>
<UserTags uid={profile?.uid} /> <UserTags uid={profile?.uid} />
<Skeleton active loading={loading}>
<Raket profile={profile} /> <Raket profile={profile} />
<Flex wrap gap={24} justify="center"> <Flex wrap gap={24} justify="center">
<Flex vertical align="center"> <Flex vertical align="center">
@ -126,6 +138,7 @@ export default function ProfilePage() {
</Flex> </Flex>
</Flex> </Flex>
<Honor honors={profile?.honors} /> <Honor honors={profile?.honors} />
</Skeleton>
</Flex> </Flex>
</> </>
); );