feat(game-selector): implement pagination and caching for event list
- Add 'ics' dependency to package.json and update lockfile. - Introduce pagination support in `ClubEventList` component (Ant Design Pagination + Spin loading state). - Refactor `GameSelector` to fetch paginated data (`page` query parameter) and manage page state. - Update `KaiqiuService` to: - Add Redis caching for event lists (1 hour TTL) and individual match details (10 hours TTL). - Implement `listClubEvents` method with pagination support (fetching HTML, parsing titles/urls/status, extracting total count). - Update `getEventInfo` to utilize cached data. - Update server routes (`index.tsx`, `routes.tsx`) to handle query parameters and pass paginated responses to the frontend. - Fix logic in `GroupingPrediction` to set group length when players <= 12. This change improves performance by reducing initial load times through pagination and caching, preventing UI freezing with large event lists.
This commit is contained in:
parent
b560684dfb
commit
d323e1d925
17
bun.lock
17
bun.lock
@ -13,6 +13,7 @@
|
||||
"antd": "^6.3.2",
|
||||
"cheerio": "^1.2.0",
|
||||
"dayjs": "^1.11.19",
|
||||
"ics": "^3.8.1",
|
||||
"lodash": "^4.17.23",
|
||||
"mariadb": "^3.5.2",
|
||||
"react": "^19",
|
||||
@ -317,6 +318,8 @@
|
||||
|
||||
"iconv-lite": ["iconv-lite@0.7.2", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw=="],
|
||||
|
||||
"ics": ["ics@3.8.1", "", { "dependencies": { "nanoid": "^3.1.23", "runes2": "^1.1.2", "yup": "^1.2.0" } }, "sha512-UqQlfkajfhrS4pUGQfGIJMYz/Jsl/ob3LqcfEhUmLbwumg+ZNkU0/6S734Vsjq3/FYNpEcZVKodLBoe+zBM69g=="],
|
||||
|
||||
"intersection-observer": ["intersection-observer@0.12.2", "", {}, "sha512-7m1vEcPCxXYI8HqnL8CKI6siDyD+eIWSwgB3DZA+ZTogxk9I4CDnj4wilt9x/+/QbHI4YG5YZNmC6458/e9Ktg=="],
|
||||
|
||||
"is-mobile": ["is-mobile@5.0.0", "", {}, "sha512-Tz/yndySvLAEXh+Uk8liFCxOwVH6YutuR74utvOcu7I9Di+DwM0mtdPVZNaVvvBUM2OXxne/NhOs1zAO7riusQ=="],
|
||||
@ -389,6 +392,8 @@
|
||||
|
||||
"proper-lockfile": ["proper-lockfile@4.1.2", "", { "dependencies": { "graceful-fs": "^4.2.4", "retry": "^0.12.0", "signal-exit": "^3.0.2" } }, "sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA=="],
|
||||
|
||||
"property-expr": ["property-expr@2.0.6", "", {}, "sha512-SVtmxhRE/CGkn3eZY1T6pC8Nln6Fr/lu1mKSgRud0eC73whjGfoAogbn78LkD8aFL0zz3bAFerKSnOl7NlErBA=="],
|
||||
|
||||
"pure-rand": ["pure-rand@6.1.0", "", {}, "sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA=="],
|
||||
|
||||
"quick-lru": ["quick-lru@6.1.2", "", {}, "sha512-AAFUA5O1d83pIHEhJwWCq/RQcRukCkn/NSm2QsTEMle5f2hP0ChI2+3Xb051PZCkLryI/Ir1MVKviT2FIloaTQ=="],
|
||||
@ -415,6 +420,8 @@
|
||||
|
||||
"retry": ["retry@0.12.0", "", {}, "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow=="],
|
||||
|
||||
"runes2": ["runes2@1.1.4", "", {}, "sha512-LNPnEDPOOU4ehF71m5JoQyzT2yxwD6ZreFJ7MxZUAoMKNMY1XrAo60H1CUoX5ncSm0rIuKlqn9JZNRrRkNou2g=="],
|
||||
|
||||
"safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="],
|
||||
|
||||
"scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="],
|
||||
@ -449,11 +456,15 @@
|
||||
|
||||
"throttle-debounce": ["throttle-debounce@5.0.2", "", {}, "sha512-B71/4oyj61iNH0KeCamLuE2rmKuTO5byTOSVwECM5FA7TiAiAW+UqTKZ9ERueC4qvgSttUhdmq1mXC3kJqGX7A=="],
|
||||
|
||||
"tiny-case": ["tiny-case@1.0.3", "", {}, "sha512-Eet/eeMhkO6TX8mnUteS9zgPbUMQa4I6Kkp5ORiBD5476/m+PIRiumP5tmh5ioJpH7k51Kehawy2UDfsnxxY8Q=="],
|
||||
|
||||
"tinyexec": ["tinyexec@1.0.2", "", {}, "sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg=="],
|
||||
|
||||
"toposort": ["toposort@2.0.2", "", {}, "sha512-0a5EOkAUp8D4moMi2W8ZF8jcga7BgZd91O/yabJCFY8az+XSzeGyTKs0Aoo897iV1Nj6guFq8orWDS96z91oGg=="],
|
||||
|
||||
"tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
|
||||
|
||||
"type-fest": ["type-fest@4.41.0", "", {}, "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA=="],
|
||||
"type-fest": ["type-fest@2.19.0", "", {}, "sha512-RAH822pAdBgcNMAfWnCBU3CFZcfZ/i1eZjwFU/dsLKumyuuP3niueg2UAukXYF0E2AAoc82ZSSf9J0WQBinzHA=="],
|
||||
|
||||
"undici": ["undici@7.19.0", "", {}, "sha512-Heho1hJD81YChi+uS2RkSjcVO+EQLmLSyUlHyp7Y/wFbxQaGb4WXVKD073JytrjXJVkSZVzoE2MCSOKugFGtOQ=="],
|
||||
|
||||
@ -467,6 +478,8 @@
|
||||
|
||||
"which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="],
|
||||
|
||||
"yup": ["yup@1.7.1", "", { "dependencies": { "property-expr": "^2.0.5", "tiny-case": "^1.0.3", "toposort": "^2.0.2", "type-fest": "^2.19.0" } }, "sha512-GKHFX2nXul2/4Dtfxhozv701jLQHdf6J34YDh2cEkpqoo8le5Mg6/LrdseVLrFarmFygZTlfIhHx/QKfb/QWXw=="],
|
||||
|
||||
"zeptomatch": ["zeptomatch@2.1.0", "", { "dependencies": { "grammex": "^3.1.11", "graphmatch": "^1.1.0" } }, "sha512-KiGErG2J0G82LSpniV0CtIzjlJ10E04j02VOudJsPyPwNZgGnRKQy7I1R7GMyg/QswnE4l7ohSGrQbQbjXPPDA=="],
|
||||
|
||||
"zustand": ["zustand@5.0.10", "", { "peerDependencies": { "@types/react": ">=18.0.0", "immer": ">=9.0.6", "react": ">=18.0.0", "use-sync-external-store": ">=1.2.0" }, "optionalPeers": ["@types/react", "immer", "react", "use-sync-external-store"] }, "sha512-U1AiltS1O9hSy3rul+Ub82ut2fqIAefiSuwECWt6jlMVUGejvf+5omLcRBSzqbRagSM3hQZbtzdeRc6QVScXTg=="],
|
||||
@ -527,6 +540,8 @@
|
||||
|
||||
"antd/@rc-component/util": ["@rc-component/util@1.9.0", "", { "dependencies": { "is-mobile": "^5.0.0", "react-is": "^18.2.0" }, "peerDependencies": { "react": ">=18.0.0", "react-dom": ">=18.0.0" } }, "sha512-5uW6AfhIigCWeEQDthTozlxiT4Prn6xYQWeO0xokjcaa186OtwPRHBZJ2o0T0FhbjGhZ3vXdbkv0sx3gAYW7Vg=="],
|
||||
|
||||
"camelcase-keys/type-fest": ["type-fest@4.41.0", "", {}, "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA=="],
|
||||
|
||||
"chevrotain/lodash": ["lodash@4.17.21", "", {}, "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg=="],
|
||||
|
||||
"encoding-sniffer/iconv-lite": ["iconv-lite@0.6.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw=="],
|
||||
|
||||
@ -17,6 +17,7 @@
|
||||
"antd": "^6.3.2",
|
||||
"cheerio": "^1.2.0",
|
||||
"dayjs": "^1.11.19",
|
||||
"ics": "^3.8.1",
|
||||
"lodash": "^4.17.23",
|
||||
"mariadb": "^3.5.2",
|
||||
"react": "^19",
|
||||
|
||||
@ -1,10 +1,16 @@
|
||||
import { Divider, Empty, Flex, Space, Switch, Typography } from "antd";
|
||||
import { Divider, Empty, Flex, Pagination, Space, Spin, Switch, Typography } from "antd";
|
||||
import type { IEventInfo } from "../types";
|
||||
import { useMemo, useState } from "react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { EventCard } from "./EventCard";
|
||||
|
||||
interface Props {
|
||||
events: IEventInfo[];
|
||||
loading?: boolean;
|
||||
pagination?: {
|
||||
page: number;
|
||||
total: number;
|
||||
onPageChange: (page: number) => void;
|
||||
};
|
||||
}
|
||||
export const ClubEvenList = (props: Props) => {
|
||||
const [showAll, setShowAll] = useState(false);
|
||||
@ -12,8 +18,13 @@ export const ClubEvenList = (props: Props) => {
|
||||
if (showAll) return props.events;
|
||||
return props.events.filter(e => !e.isFinished);
|
||||
}, [showAll, props.events]);
|
||||
useEffect(() => {
|
||||
if (!showAll && props.pagination && props.pagination.page > 1) {
|
||||
props.pagination.onPageChange(1); // Reset page
|
||||
}
|
||||
}, [showAll, props]);
|
||||
return (
|
||||
<>
|
||||
<Spin spinning={props.loading}>
|
||||
<Divider>
|
||||
<Space>
|
||||
<Typography.Text type="secondary">显示已结束的活动</Typography.Text>
|
||||
@ -21,6 +32,13 @@ export const ClubEvenList = (props: Props) => {
|
||||
</Space>
|
||||
</Divider>
|
||||
<Flex wrap vertical gap={12} justify="center" align="center">
|
||||
{showAll && props.pagination ? (
|
||||
<Pagination
|
||||
total={props.pagination.total}
|
||||
current={props.pagination.page}
|
||||
onChange={props.pagination.onPageChange}
|
||||
showSizeChanger={false}
|
||||
/>) : null}
|
||||
{visibleEvents.length ? visibleEvents.map(e => {
|
||||
return (
|
||||
<div key={e.matchId} style={{ width: '100%', maxWidth: 600 }}>
|
||||
@ -28,7 +46,14 @@ export const ClubEvenList = (props: Props) => {
|
||||
</div>
|
||||
);
|
||||
}) : <Empty description="暂无活动" />}
|
||||
{showAll && props.pagination ? (
|
||||
<Pagination
|
||||
total={props.pagination.total}
|
||||
current={props.pagination.page}
|
||||
onChange={props.pagination.onPageChange}
|
||||
showSizeChanger={false}
|
||||
/>) : null}
|
||||
</Flex>
|
||||
</>
|
||||
</Spin>
|
||||
);
|
||||
}
|
||||
@ -2,8 +2,7 @@ import { Flex, Select, Space } from 'antd';
|
||||
import type React from 'react';
|
||||
import { useRequest } from 'ahooks';
|
||||
import { clubs } from './clubList';
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import dayjs from 'dayjs';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import type { IEventInfo } from '../../types';
|
||||
import { ClubEvenList } from '../ClubEventList';
|
||||
|
||||
@ -12,31 +11,21 @@ interface Props {
|
||||
|
||||
export const GameSelector: React.FC<Props> = props => {
|
||||
const [clubId, setClubId] = useState<string>(clubs[0]?.clubId ?? '');
|
||||
const requestEvents = useRequest<IEventInfo[], [string]>(
|
||||
async (clubId: string) => {
|
||||
const requestEvents = useRequest<{data: IEventInfo[]; total: number; }, [string, number]>(
|
||||
async (clubId: string, page = 1) => {
|
||||
if (!clubId) return [];
|
||||
return (await fetch(`/api/events/${clubId}`)).json()
|
||||
return (await fetch(`/api/events/${clubId}?page=${page}`)).json()
|
||||
}, { manual: true });
|
||||
const [showFinished, setShowFinished] = useState(false);
|
||||
const gameList = useMemo(() => {
|
||||
const activeList = requestEvents.data?.map(e => ({
|
||||
...e,
|
||||
finished: dayjs(e.startDate).isBefore(dayjs()),
|
||||
}));
|
||||
return activeList;
|
||||
}, [requestEvents.data]);
|
||||
const isEmpty = useMemo(() => {
|
||||
return (gameList ?? []).filter(e => !e.finished).length === 0
|
||||
}, [gameList]);
|
||||
const handleClubChange = useCallback(async (id: string) => {
|
||||
setClubId(id);
|
||||
requestEvents.runAsync(id);
|
||||
requestEvents.runAsync(id, page);
|
||||
}, []);
|
||||
const [page, setPage] = useState(1);
|
||||
useEffect(() => {
|
||||
const id = setTimeout(() => {
|
||||
const clubId = clubs?.[0]?.clubId;
|
||||
if (!clubId) return;
|
||||
requestEvents.run(clubId);
|
||||
requestEvents.run(clubId, 1);
|
||||
}, 100);
|
||||
return () => clearTimeout(id);
|
||||
}, []);
|
||||
@ -52,7 +41,18 @@ export const GameSelector: React.FC<Props> = props => {
|
||||
onChange={handleClubChange}
|
||||
/>
|
||||
</Flex>
|
||||
<ClubEvenList events={requestEvents.data ?? []} />
|
||||
<ClubEvenList
|
||||
events={requestEvents.data?.data ?? []}
|
||||
loading={requestEvents.loading}
|
||||
pagination={{
|
||||
total: requestEvents.data?.total ?? 0,
|
||||
page,
|
||||
onPageChange: page => {
|
||||
setPage(page);
|
||||
requestEvents.runAsync(clubId, page);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Space>
|
||||
);
|
||||
}
|
||||
|
||||
@ -51,7 +51,7 @@ export const GroupingPrediction: React.FC<Props> = props => {
|
||||
if (players.length < 48) {
|
||||
setMaxPlayerSize(players.length);
|
||||
}
|
||||
if (players.length < 12) {
|
||||
if (players.length <= 12) {
|
||||
setGroupLen(1);
|
||||
}
|
||||
}
|
||||
|
||||
@ -30,7 +30,8 @@ const server = serve({
|
||||
},
|
||||
"/api/events/:clubid": {
|
||||
async GET(req) {
|
||||
const data = await listEvent(req.params.clubid);
|
||||
const page = Number(new URL(req.url).searchParams.get('page')) ?? 1;
|
||||
const data = await KaiqiuService.listClubEvents(req.params.clubid, page);
|
||||
return Response.json(data);
|
||||
}
|
||||
},
|
||||
|
||||
@ -45,11 +45,11 @@ export const route = createBrowserRouter([
|
||||
HydrateFallback: () => <HydrateFallback />,
|
||||
loader: async ({ params }) => {
|
||||
const id = params.id;
|
||||
const [info, events] = await Promise.all([
|
||||
const [info, event] = await Promise.all([
|
||||
fetch(`/api/club/${id}`).then(r => r.json()),
|
||||
fetch(`/api/events/${id}`).then(r => r.json()),
|
||||
]);
|
||||
return { info, events };
|
||||
return { info, events: event.data, total: event.total };
|
||||
},
|
||||
},
|
||||
{
|
||||
|
||||
@ -1,5 +1,7 @@
|
||||
import * as cheerio from "cheerio";
|
||||
import { htmlRequestHeaders } from "../utils/server";
|
||||
import { htmlRequestHeaders, redis, REDIS_CACHE_HOUR } from "../utils/server";
|
||||
import type { IEventInfo } from "../types";
|
||||
import dayjs from "dayjs";
|
||||
|
||||
export class KaiqiuService {
|
||||
static #baseURL = 'https://kaiqiuwang.cc';
|
||||
@ -44,4 +46,73 @@ export class KaiqiuService {
|
||||
return { id: clubId, name, img, article };
|
||||
}
|
||||
|
||||
static async #fetchEventListHTML(clubId: string, page: number) {
|
||||
const url = `https://kaiqiuwang.cc/home/space-0-do-mtag-tagid-${clubId}-view-event-page-${page}.html`;
|
||||
const resp = await fetch(url, { headers: htmlRequestHeaders });
|
||||
return resp.text();
|
||||
}
|
||||
|
||||
public static async listClubEvents(clubId: string, page = 1) {
|
||||
if (!clubId) return {
|
||||
data: [],
|
||||
total: 0,
|
||||
};
|
||||
const key = `my-kaiqiuwang:club-events:${clubId}:page-${page}`;
|
||||
let html = await redis.get(key).catch(() => '');
|
||||
if (!html) {
|
||||
html = await this.#fetchEventListHTML(clubId, page);
|
||||
redis.setex(key, 60 * 60 * REDIS_CACHE_HOUR, html);
|
||||
}
|
||||
return await this.#parseEventList(html);
|
||||
}
|
||||
|
||||
static async #parseEventList(html: string) {
|
||||
const $ = cheerio.load(html);
|
||||
const blockList = $('div.event_list > ol > li');
|
||||
const list: IEventInfo[] = [];
|
||||
for (const block of blockList) {
|
||||
const titleEl = $(block).find('.event_title');
|
||||
const title = titleEl.text();
|
||||
const place = $(block).find('ul li:nth-of-type(2)').text().replace('比赛地点: \\t', '').trim();
|
||||
const eventPath = $(titleEl).find('a').attr('href') ?? '';
|
||||
const eventURL = `${this.#baseURL}/home/${eventPath}`;
|
||||
const matchId = /\S+-(\d+).html$/.exec(eventPath)?.[1] ?? '';
|
||||
const { startDate } = await this.getEventInfo(matchId);
|
||||
const event: IEventInfo = {
|
||||
title,
|
||||
info: [`比赛时间:${startDate}`, place],
|
||||
url: eventURL,
|
||||
startDate,
|
||||
matchId,
|
||||
isFinished: dayjs(startDate).isBefore(),
|
||||
}
|
||||
list.push(event);
|
||||
}
|
||||
const total = parseInt($('#mainarea > div.event_list > div > em').text().trim());
|
||||
return {
|
||||
data: list,
|
||||
total: total || list.length,
|
||||
};
|
||||
}
|
||||
|
||||
public static async getEventInfo(eventId: string) {
|
||||
let eventPage = await redis.get(`my-kaiqiuwang:match:${eventId}`) ?? '';
|
||||
// https://kaiqiuwang.cc/home/space-event-id-175775.html
|
||||
const eventURL = `${this.#baseURL}/home/space-event-id-${eventId}.html`;
|
||||
if (!eventPage) {
|
||||
eventPage = await fetch(eventURL, { headers: htmlRequestHeaders }).then(res => res.text() ?? '');
|
||||
await redis.setex(`my-kaiqiuwang:match:${eventId}`, 60 * 60 * 10, eventPage)
|
||||
}
|
||||
const $ = cheerio.load(eventPage);
|
||||
const eventContent = $('.event_content').text().replace(/(\r|\n)/g, ',').split(',').filter(Boolean).join(' ');
|
||||
const { y, M, D, H, m} = /比赛开始:.*?(?<y>\d{4})年(?<M>\d{2})月(?<D>\d{2})日 \w+ (?<H>\d{2}):(?<m>\d{2})/
|
||||
.exec(eventContent)?.groups ?? {};
|
||||
const startDate = y ? `${y}-${M}-${D} ${H}:${m}` : '';
|
||||
const title = $('#mainarea > h2 > a:nth-child(3)').text().trim();
|
||||
return {
|
||||
title,
|
||||
startDate,
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user