diff --git a/bun.lock b/bun.lock index 43edbbb..1657ecc 100644 --- a/bun.lock +++ b/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=="], diff --git a/package.json b/package.json index bd128a9..0c7d7f4 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/components/ClubEventList.tsx b/src/components/ClubEventList.tsx index 7e89c64..7d9bf2b 100644 --- a/src/components/ClubEventList.tsx +++ b/src/components/ClubEventList.tsx @@ -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 ( - <> + 显示已结束的活动 @@ -21,6 +32,13 @@ export const ClubEvenList = (props: Props) => { + {showAll && props.pagination ? ( + ) : null} {visibleEvents.length ? visibleEvents.map(e => { return (
@@ -28,7 +46,14 @@ export const ClubEvenList = (props: Props) => {
); }) : } + {showAll && props.pagination ? ( + ) : null}
- +
); } \ No newline at end of file diff --git a/src/components/GameSelector/GameSelector.tsx b/src/components/GameSelector/GameSelector.tsx index 352c3bf..12ee842 100644 --- a/src/components/GameSelector/GameSelector.tsx +++ b/src/components/GameSelector/GameSelector.tsx @@ -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 => { const [clubId, setClubId] = useState(clubs[0]?.clubId ?? ''); - const requestEvents = useRequest( - 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 => { onChange={handleClubChange} /> - + { + setPage(page); + requestEvents.runAsync(clubId, page); + } + }} + /> ); } diff --git a/src/components/GroupingPrediction.tsx b/src/components/GroupingPrediction.tsx index e3c0e6b..f54d40a 100644 --- a/src/components/GroupingPrediction.tsx +++ b/src/components/GroupingPrediction.tsx @@ -51,7 +51,7 @@ export const GroupingPrediction: React.FC = props => { if (players.length < 48) { setMaxPlayerSize(players.length); } - if (players.length < 12) { + if (players.length <= 12) { setGroupLen(1); } } diff --git a/src/index.tsx b/src/index.tsx index db3692b..76bbc4b 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -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); } }, diff --git a/src/routes.tsx b/src/routes.tsx index e2ace0c..889b84d 100644 --- a/src/routes.tsx +++ b/src/routes.tsx @@ -45,11 +45,11 @@ export const route = createBrowserRouter([ 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 }; }, }, { diff --git a/src/services/KaiqiuService.ts b/src/services/KaiqiuService.ts index 7c800bd..7821d55 100644 --- a/src/services/KaiqiuService.ts +++ b/src/services/KaiqiuService.ts @@ -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} = /比赛开始:.*?(?\d{4})年(?\d{2})月(?\d{2})日 \w+ (?\d{2}):(?\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, + } + } + } \ No newline at end of file