refactor(components): optimize event list and search table with pagination persistence

- Refactor ClubEventList to support persistent page state in sessionStorage and dynamic toggle for finished events.
- Move pagination logic to useRunOnce hook for initialization, ensuring correct state restoration.
- Update ClubSearchTable to support "My Favorites" tab, using useRef to cache search results for different club types.
- Enhance EventCard with precise time formatting (HH:mm:ss) and dynamic countdown logic using dayjs timezone.
- Persist club selection in GameSelector using local storage and integrate ClubSummary component.
- Fix geo data handling in KaiqiuService, ensuring coordinates are correctly passed to iCalendar generator.
- Remove unused imports and simplify component structure across affected files.
This commit is contained in:
kyuuseiryuu 2026-03-15 02:33:34 +09:00
parent 4825f41337
commit 0f9e80856b
9 changed files with 234 additions and 114 deletions

View File

@ -1,26 +1,36 @@
import { Button, Divider, Empty, Flex, Pagination, Skeleton, Space, Switch, Typography } from "antd";
import type { IEventInfo } from "../types";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useCallback, useMemo, useState } from "react";
import { EventCard } from "./EventCard";
import { useRequest } from "ahooks";
import { CalendarOutlined } from "@ant-design/icons";
import { STORE_PAGE_LIST_KEY } from "../utils/constants";
import { useRunOnce } from "../hooks/useRunOnce";
interface Props {
clubId: string;
}
const getStorageKey = (clubId: string) => `events-page-${clubId}`;
export const ClubEvenList = (props: Props) => {
const [showAllDisable, setShowAllDiable] = useState(false);
const [showAll, setShowAll] = useState(false);
const [paginationControlVisible, setPaginationControlVisible] = useState(true);
const [showFinishedEvents, setShowFinishedEvents] = useState(false);
const requestEvents = useRequest<{data: IEventInfo[]; total: number; }, [string, number]>(
async (clubId: string, page = 1) => {
if (!clubId) return [];
return (await fetch(`/api/club/${clubId}/events?page=${page}`)).json()
}, { manual: true });
const [page, setPage] = useState(1);
if (!clubId) return { data: [], total: 0 };
return fetch(`/api/club/${clubId}/events?page=${page}`).then(e => e.json());
}, { manual: true, refreshDeps: [] });
const [page, setPageState] = useState(1);
const setPage = useCallback((page: number) => {
const key = getStorageKey(props.clubId);
sessionStorage.setItem(key, page.toString());
setPageState(page);
}, [props.clubId])
const onPageChange = useCallback((page: number) => {
setPage(page);
requestEvents.runAsync(props.clubId, page);
}, [props.clubId]);
}, [props.clubId, setPage]);
const pagination = useMemo(() => {
return {
page,
@ -30,26 +40,26 @@ export const ClubEvenList = (props: Props) => {
}, [requestEvents.data?.total, page]);
const visibleEvents = useMemo(() => {
const events = requestEvents.data?.data || [];
if (showAll) return events;
if (showFinishedEvents) return events;
return events.filter(e => !e.isFinished);
}, [showAll, requestEvents.data?.data]);
useEffect(() => {
if (!showAll && page > 1) {
onPageChange(1); // Reset page
}, [showFinishedEvents, requestEvents.data?.data]);
const onPageInit = useCallback(async () => {
const storePageKey = getStorageKey(props.clubId);
const keys: string[] = JSON.parse(localStorage.getItem(STORE_PAGE_LIST_KEY) ?? '[]');
if (!keys.includes(storePageKey)) {
keys.push(storePageKey);
localStorage.setItem(STORE_PAGE_LIST_KEY, JSON.stringify(keys));
}
}, [showAll, onPageChange]);
useEffect(() => {
const id = setTimeout(async () => {
const data = await requestEvents.runAsync(props.clubId, 1).then(res => res.data);
setPage(1);
if (data.length) {
const isAllFinishedOrAllNotFinished = data.every(e => e.isFinished) || data.every(e => !e.isFinished);
setShowAll(isAllFinishedOrAllNotFinished);
setShowAllDiable(data.every(e => !e.isFinished));
}
}, 100);
return () => clearTimeout(id);
}, [props.clubId]);
const prePage = Number(sessionStorage.getItem(storePageKey)) || 1;
const data = await requestEvents.runAsync(props.clubId, prePage).then(res => res.data);
setPage(prePage);
if (data.length) {
const isAllFinishedOrAllNotFinished = data.every(e => e.isFinished) || data.every(e => !e.isFinished);
setShowFinishedEvents(prePage !== 1 || isAllFinishedOrAllNotFinished);
setPaginationControlVisible(prePage === 1 ? !isAllFinishedOrAllNotFinished : false);
}
}, [props]);
useRunOnce(onPageInit);
const handleAddToCalendar = useCallback(() => {
const url = `${window.location.origin}/api/club/${props.clubId}/calendar.ics`;
const uri = url.replace(/^http(s)?/, 'webcal');
@ -59,12 +69,12 @@ export const ClubEvenList = (props: Props) => {
return (
<>
<Divider>
{showAllDisable ? null : (
{paginationControlVisible ? (
<Space>
<Typography.Text type="secondary"></Typography.Text>
<Switch checked={showAll} onChange={() => setShowAll(!showAll)} unCheckedChildren="隐藏" checkedChildren="显示" />
<Switch checked={showFinishedEvents} onChange={() => setShowFinishedEvents(!showFinishedEvents)} unCheckedChildren="隐藏" checkedChildren="显示" />
</Space>
)}
) : null}
</Divider>
<Flex wrap vertical gap={12} justify="center" align="center">
<Button
@ -74,7 +84,7 @@ export const ClubEvenList = (props: Props) => {
>
</Button>
{showAll ? (
{showFinishedEvents ? (
<Pagination
showQuickJumper
total={pagination.total}
@ -96,7 +106,7 @@ export const ClubEvenList = (props: Props) => {
);
}) : <Empty description="暂无活动" />}</>)}
{showAll ? (
{showFinishedEvents ? (
<Pagination
showQuickJumper
total={pagination.total}

View File

@ -1,6 +1,6 @@
import { useRequest } from "ahooks";
import { Avatar, Button, Flex, Input, Radio, Space, Table } from "antd";
import { useCallback, useState } from "react";
import { useCallback, useRef, useState } from "react";
import type { ClubInfo } from "../types";
import { EyeOutlined, StarFilled, StarOutlined } from "@ant-design/icons";
import { useClubStore } from "../store/useClubStore";
@ -12,65 +12,102 @@ interface Props {
handleClick?: (type: ClickType, club: ClubInfo) => void;
}
enum ClubType {
,
,
,
}
type Data = { clubs: ClubInfo[], total: number, page: number; };
const initData = { clubs: [], total: 0, page: 1 };
export const ClubSearchTable = (props: Props) => {
const [searchKey, setSearchKey] = useState('');
const searchClub = useRequest<{ clubs: ClubInfo[], total: number }, [string, number, number]>(async (
const [datasource, setDatasource] = useState<Data>(initData);
const dataRef = useRef<Partial<Record<ClubType, Data>>>({});
const searchClub = useRequest<Data, [string, number, ClubType]>(async (
searchKey: string,
page = 1,
clubType = 0,
clubType = ClubType.,
) => {
const resp: { clubs: ClubInfo[], total: number } = await fetch(
const resp: Data = await fetch(
`/api/club/find?key=${searchKey}&page=${page}${clubType ? '&normalClub=1' : ''}`
).then(e => e.json());
dataRef.current[clubType] = {
...resp,
page,
};
return resp;
}, { manual: true });
const [page, setPage] = useState(1);
const [clubType, setClubType] = useState(0);
const handleSearch = useCallback((searchKey: string, clubType: number) => {
setPage(1);
searchClub.runAsync(searchKey, 1, clubType);
const clubStore = useClubStore(store => store);
const [clubType, setClubType] = useState<ClubType>(ClubType.);
const handleSearch = useCallback(async (searchKey: string, clubType: ClubType, page = 1) => {
switch (clubType) {
case ClubType.:
case ClubType.: {
const resp = await searchClub.runAsync(searchKey, page, clubType);
setDatasource({ ...resp, page });
break;
}
}
}, []);
const handleTypeChange = useCallback(async (type: ClubType) => {
setClubType(type);
switch (type) {
case ClubType.:
case ClubType.: {
if (!searchKey) {
setDatasource(dataRef.current[type] ?? initData);
break;
}
await handleSearch(searchKey, type);
break;
}
case ClubType.:
setDatasource({ clubs: clubStore.clubs, total: clubStore.clubs.length, page: 1 });
break;
default: break;
}
}, [searchKey, searchClub, clubStore]);
return (
<Flex vertical gap={12} justify="center" align="center">
<Radio.Group
optionType="button"
value={clubType}
onChange={e => {
setClubType(e.target.value);
if (!searchKey) return;
handleSearch(searchKey, e.target.value);
}}
onChange={e => handleTypeChange(e.target.value)}
options={[
{ label: '积分俱乐部', value: 0 },
{ label: '俱乐部', value: 1 },
{ label: '积分俱乐部', value: ClubType.积分俱乐部 },
{ label: '俱乐部', value: ClubType.俱乐部 },
{ label: '我的收藏', value: ClubType.我的收藏 },
]}
/>
<Input.Search
allowClear
onSearch={e => {
setSearchKey(e);
handleSearch(e, clubType);
}}
onPressEnter={() => handleSearch(searchKey, clubType)}
value={searchKey}
onChange={e => setSearchKey(e.target.value)}
/>
{clubType !== ClubType. ? (
<Input.Search
allowClear
onSearch={e => {
setSearchKey(e);
handleSearch(e, clubType);
}}
onPressEnter={() => handleSearch(searchKey, clubType)}
value={searchKey}
onChange={e => setSearchKey(e.target.value)}
/>
) : null}
<Table
rowKey={e => e.id}
loading={searchClub.loading}
dataSource={searchClub.data?.clubs}
dataSource={datasource.clubs}
style={{ width: '100%' }}
scroll={{ x: 400 }}
pagination={{
current: page,
pagination={clubType !== ClubType. ? {
current: datasource.page,
showSizeChanger: false,
total: searchClub.data?.total,
total: datasource.total,
onChange: (page) => {
setPage(page);
searchClub.runAsync(searchKey, page, clubType);
console.debug('onPageChange', { searchKey, page, clubType });
handleSearch(searchKey, clubType, page);
}
}}
} : false}
>
<Table.Column dataIndex="name" width={140} fixed="left" render={(name, { img }) => {
return (

View File

@ -0,0 +1,38 @@
import { Avatar, Button, Drawer, Flex, Skeleton, Typography } from "antd";
import { ChangeBackground } from "./ChangeBackground";
import { useState } from "react";
import { useRequest } from "ahooks";
import type { ClubDetail } from "../types";
interface Props {
clubId: string;
}
export const ClubSummary = (props: Props) => {
const [isArticleOpen, setIsArticleOpen] = useState(false);
const requestClubSummary = useRequest<ClubDetail, []>(async () => {
return fetch(`/api/club/${props.clubId}`).then(r => r.json());
}, { manual: false, refreshDeps: [props.clubId] })
const info = requestClubSummary.data;
return (
<div style={{ width: '100%' }}>
{requestClubSummary.loading ? (
<Skeleton.Button block size="large" style={{ height: 300 }} active={requestClubSummary.loading} />
) : (
<Flex vertical align="center" justify="center">
<ChangeBackground url={info?.img} />
<Avatar src={info?.img} size={80} />
<Typography.Title>{info?.name}</Typography.Title>
<Flex gap={12}>
<Button onClick={() => setIsArticleOpen(true)}></Button>
</Flex>
<Drawer size={'60vh'} open={isArticleOpen} onClose={() => setIsArticleOpen(false)} placement="bottom">
<div style={{ textAlign: 'center' }}>
<Typography.Paragraph style={{ whiteSpace: 'pre', textWrap: 'auto' }}>{info?.article}</Typography.Paragraph>
</div>
</Drawer>
</Flex>
)}
</div>
);
}

View File

@ -4,8 +4,12 @@ import dayjs from "dayjs";
import utc from 'dayjs/plugin/utc';
import timezone from 'dayjs/plugin/timezone';
import { EyeOutlined } from "@ant-design/icons";
import { useCallback, useMemo } from "react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useNavigate } from "react-router";
import { useRunOnce } from "../hooks/useRunOnce";
dayjs.extend(utc);
dayjs.extend(timezone);
interface EventCardProps {
eventInfo: IEventInfo;
@ -13,20 +17,35 @@ interface EventCardProps {
export function EventCard(props: EventCardProps) {
const { eventInfo: e } = props;
const day = dayjs(e.startDate);
const day = useMemo(() => dayjs.tz(e.startDate, 'Asia/Tokyo'), [e]);
const navigate = useNavigate();
const handleView = useCallback(() => {
navigate(`/event/${e.matchId}`);
}, [e]);
const messageFormat = useMemo(() => {
const [messageFormat, setMessageFormat] = useState('');
const getMessageFormat = useCallback(() => {
if (e.isFinished) {
if (dayjs().diff(day, 'days') < 1) return `已结束`;
return `已结束 DD 天`;
}
if (e.isProcessing) {
return '比赛进行中';
return '比赛进行中 HH:mm:ss';
}
return `还有 DD 天 HH 时开始`;
}, [e]);
return `距离比赛开始还有 DD 天 HH:mm:ss`;
}, [e, day]);
const updateMessageFormat = useCallback(() => {
const format = getMessageFormat();
setMessageFormat(format);
console.debug('format: %s', format);
}, [getMessageFormat])
useRunOnce(updateMessageFormat);
useEffect(() => {
const timeout = day.toDate().getTime() - Date.now();
const id = setTimeout(() => {
updateMessageFormat();
}, timeout);
return () => clearTimeout(id);
}, [updateMessageFormat]);
return (
<Card
key={e.matchId}
@ -42,9 +61,9 @@ export function EventCard(props: EventCardProps) {
</Button>,
]}
>
<Typography.Text type={e.isFinished ? undefined : 'success'}>{e.title}</Typography.Text>
<Typography.Text type={e.isFinished ? 'secondary' : 'success'}>{e.title}</Typography.Text>
<Statistic.Timer
type={e.isFinished ? 'countup' : 'countdown'}
type={e.isProcessing ? 'countup' : 'countdown'}
value={day.toDate().getTime()}
format={messageFormat}
styles={{ content: e.isFinished ? { color: 'gray' } : {} }}

View File

@ -1,12 +1,15 @@
import { Button, Drawer, Flex, Select, Space } from 'antd';
import { Button, Drawer, Flex, Select } from 'antd';
import type React from 'react';
import { clubs } from './clubList';
import { useCallback, useEffect, useState } from 'react';
import { ClubEvenList } from '../ClubEventList';
import { DeleteOutlined, SearchOutlined } from '@ant-design/icons';
import { SearchOutlined } from '@ant-design/icons';
import { useClubStore } from '../../store/useClubStore';
import { ClubSearchTable } from '../ClubSearchTable';
import { createGlobalStyle } from 'styled-components';
import styled, { createGlobalStyle } from 'styled-components';
import { useLocalStorageState } from 'ahooks';
import { CLUB_SELECTOR_KEY } from '../../utils/constants';
import { ClubSummary } from '../ClubSummary';
interface Props {
}
@ -21,9 +24,15 @@ const ChangeDrawerHeight = createGlobalStyle`
}
`;
const StyledContainer = styled.div`
.club-selector {
width: 100%;
}
`;
export const GameSelector: React.FC<Props> = () => {
const [options, setOptions] = useState<{ label: React.ReactNode, value: string }[]>(defaultOptions);
const [clubId, setClubId] = useState<string>(clubs[0]?.clubId ?? '');
const [clubId, setClubId] = useLocalStorageState<string>(CLUB_SELECTOR_KEY, { defaultValue: clubs[0]?.clubId ?? '' });
const clubStore = useClubStore(store => store);
const handleClubChange = useCallback(async (id: string) => {
setClubId(id);
@ -32,15 +41,7 @@ export const GameSelector: React.FC<Props> = () => {
useEffect(() => {
if (clubStore.clubs.length) {
setOptions([...defaultOptions, ...clubStore.clubs.map(info => ({
label: (
<Flex align='center' justify='space-between'>
<span>{info.name}</span>
<Button type='link' icon={<DeleteOutlined />} onClick={(e) => {
e.stopPropagation();
clubStore.unFav(info.id);
}} />
</Flex>
),
label: info.name,
value: info.id
}))]);
return;
@ -49,23 +50,28 @@ export const GameSelector: React.FC<Props> = () => {
setClubId(defaultOptions[0]?.value ?? '');
}, [clubStore]);
return (
<Space orientation='vertical' style={{ width: '100%' }}>
<StyledContainer style={{ width: '100%' }}>
<ChangeDrawerHeight />
<Flex gap={12} justify='center' align='center'>
<Select
style={{ width: '100%' }}
placeholder={'请选择俱乐部'}
size='large'
value={clubId}
options={options}
onChange={handleClubChange}
/>
<Flex gap={12}>
<div style={{ display: 'block', marginBottom: 12 }}>
<Flex wrap gap={12} justify='center' align='center'>
<Flex flex={4}>
<Select
className='club-selector'
placeholder={'请选择俱乐部'}
size='large'
value={clubId}
options={options}
onChange={handleClubChange}
/>
</Flex>
<Flex flex={1}>
<Button block size='large' icon={<SearchOutlined />} onClick={() => setSearchOpen(true)}></Button>
<Button block size='large' icon={<SearchOutlined />} onClick={() => setSearchOpen(true)}>
</Button>
</Flex>
</Flex>
</Flex>
</div>
<ClubSummary clubId={clubId} />
<ClubEvenList clubId={clubId} />
<Drawer
className='search-table'
@ -91,6 +97,6 @@ export const GameSelector: React.FC<Props> = () => {
}}
/>
</Drawer>
</Space>
</StyledContainer>
);
}

13
src/hooks/useRunOnce.ts Normal file
View File

@ -0,0 +1,13 @@
import { useEffect, useRef } from "react"
export const useRunOnce = (callback: () => void) => {
const done = useRef(false);
useEffect(() => {
const id = setTimeout(() => {
if (done.current) return;
done.current = true;
callback();
}, 0);
return () => clearTimeout(id);
}, [callback]);
}

View File

@ -56,11 +56,11 @@ const server = serve({
events = await KaiqiuService.listClubEvents(id, page);
allEvents = allEvents.concat(...events.data);
}
const noGeo = !events.geo.lat && !events.geo.lng;
const geo = { lat: events.geo.lat, lon: events.geo.lng };
const configs: ics.EventAttributes[] = allEvents.filter(e => !e.isFinished).map(e => ({
...(noGeo ? {} : { geo }),
...(clubInfo?.geo ? { geo: {
lat: clubInfo.geo.lat,
lon: clubInfo.geo.lng,
} } : {}),
start: dayjs.tz(e.startDate, 'Asia/Tokyo').format('YYYY-MM-DD-HH-mm').split('-').map(v => Number(v)) as any,
duration: { hours: 6, minutes: 30 },
title: e.title,

View File

@ -51,7 +51,8 @@ export class KaiqiuService {
const src = $('#space_avatar img').attr('src') ?? '';
const img = src.startsWith('http') ? src : 'https://kaiqiuwang.cc/home/image/nologo.jpg';
const article = $('.article').text().trim();
return { id: clubId, name, img, article };
const geo = await this.#getClubLocation(clubId);
return { id: clubId, name, img, article, geo };
}
static async #fetchEventListHTML(clubId: string, page: number) {
@ -64,10 +65,6 @@ export class KaiqiuService {
if (!clubId) return {
data: [],
total: 0,
geo: {
lat: 0,
lng: 0,
},
};
const key = `my-kaiqiuwang:club-events:${clubId}:page-${page}`;
let html = await redis.get(key).catch(() => '');
@ -75,12 +72,8 @@ export class KaiqiuService {
html = await this.#fetchEventListHTML(clubId, page);
redis.setex(key, 60 * 60 * REDIS_CACHE_HOUR, html);
}
const geo = await this.#getClubLocation(clubId);
const data = await this.#parseEventList(html);
return {
...data,
geo,
}
return data;
}
static async #parseEventList(html: string) {
@ -130,6 +123,7 @@ export class KaiqiuService {
const $ = cheerio.load(html);
const lng = Number($('#lng').val());
const lat = Number($('#lat').val());
if (!lng && !lat) return null;
return { lng, lat };
}

3
src/utils/constants.ts Normal file
View File

@ -0,0 +1,3 @@
export const CLUB_SELECTOR_KEY = 'CLUB_SELECTOR';
export const STORE_PAGE_LIST_KEY = 'events-page-keys';