feat: search club

This commit is contained in:
kyuuseiryuu 2026-03-13 20:15:57 +09:00
parent 457fc8595d
commit 99fd5778df
11 changed files with 336 additions and 36 deletions

View File

@ -1,18 +1,20 @@
import { Divider, Empty, Flex, Pagination, Skeleton, Space, Spin, Switch, Typography } from "antd";
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 { EventCard } from "./EventCard";
import { useRequest } from "ahooks";
import { CalendarOutlined } from "@ant-design/icons";
interface Props {
clubId: string;
}
export const ClubEvenList = (props: Props) => {
const [showAllDisable, setShowAllDiable] = useState(false);
const [showAll, setShowAll] = useState(false);
const requestEvents = useRequest<{data: IEventInfo[]; total: number; }, [string, number]>(
async (clubId: string, page = 1) => {
if (!clubId) return [];
return (await fetch(`/api/events/${clubId}?page=${page}`)).json()
return (await fetch(`/api/club/${clubId}/events?page=${page}`)).json()
}, { manual: true });
const [page, setPage] = useState(1);
const onPageChange = useCallback((page: number) => {
@ -37,21 +39,40 @@ export const ClubEvenList = (props: Props) => {
}
}, [showAll, onPageChange]);
useEffect(() => {
const id = setTimeout(() => {
requestEvents.run(props.clubId, 1);
const id = setTimeout(async () => {
const data = await requestEvents.runAsync(props.clubId, 1).then(res => res.data);
setPage(1);
if (data.length === 10) {
setShowAll(data.every(e => !e.isFinished));
setShowAllDiable(data.every(e => !e.isFinished));
}
}, 100);
return () => clearTimeout(id);
}, [props.clubId]);
const handleAddToCalendar = useCallback(() => {
const url = `${window.location.origin}/api/club/${props.clubId}/calendar.ics?page=${page}`;
const uri = url.replace(/^http(s)?/, 'webcal');
console.debug(uri);
window.open(uri);
}, [props.clubId, page]);
return (
<>
<Divider>
<Space>
<Typography.Text type="secondary"></Typography.Text>
<Switch checked={showAll} onChange={() => setShowAll(!showAll)} unCheckedChildren="隐藏" checkedChildren="显示" />
</Space>
{showAllDisable ? null : (
<Space>
<Typography.Text type="secondary"></Typography.Text>
<Switch checked={showAll} onChange={() => setShowAll(!showAll)} unCheckedChildren="隐藏" checkedChildren="显示" />
</Space>
)}
</Divider>
<Flex wrap vertical gap={12} justify="center" align="center">
<Button
type="link"
icon={<CalendarOutlined />}
onClick={handleAddToCalendar}
>
</Button>
{showAll ? (
<Pagination
showQuickJumper

View File

@ -0,0 +1,78 @@
import { useRequest } from "ahooks";
import { Avatar, Button, Flex, Input, Space, Table } from "antd";
import { useCallback, useState } from "react";
import type { ClubInfo } from "../types";
import { EyeOutlined, StarFilled, StarOutlined } from "@ant-design/icons";
import { useClubStore } from "../store/useClubStore";
type ClickType = 'VIEW' | 'FAV';
interface Props {
handleClick?: (type: ClickType, club: ClubInfo) => void;
}
export const ClubSearchTable = (props: Props) => {
const [searchKey, setSearchKey] = useState('');
const searchClub = useRequest<{ clubs: ClubInfo[], total: number }, [string, number]>(async (searchKey: string, page = 1) => {
const resp: { clubs: ClubInfo[], total: number } = await fetch(`/api/club/find?key=${searchKey}&page=${page}`).then(e => e.json());
return resp;
}, { manual: true });
const [page, setPage] = useState(1);
const handleSearch = useCallback((searchKey: string) => {
searchClub.runAsync(searchKey, 1);
}, [searchKey]);
return (
<Flex vertical gap={12} justify="center" align="center">
<Input.Search
allowClear
onSearch={e => {
setSearchKey(e);
handleSearch(e);
}}
onPressEnter={() => handleSearch(searchKey)}
value={searchKey}
onChange={e => setSearchKey(e.target.value)}
/>
<Table
rowKey={e => e.id}
loading={searchClub.loading}
dataSource={searchClub.data?.clubs}
style={{ width: '100%' }}
scroll={{ x: 400 }}
pagination={{
current: page,
showSizeChanger: false,
total: searchClub.data?.total,
onChange: (page) => {
setPage(page);
searchClub.runAsync(searchKey, page);
}
}}
>
<Table.Column dataIndex="name" width={140} fixed="left" render={(name, { img }) => {
return (
<Space>
<Avatar src={img} size={32} />
<span>{name}</span>
</Space>
);
}} />
<Table.Column dataIndex="area" width={80} />
<Table.Column dataIndex="members" width={80} render={num => `${num}`} />
<Table.Column dataIndex="id" align="center" width={200} render={(id, record: ClubInfo) => {
return (
<Space.Compact size="small">
<Button icon={
useClubStore.getState().isFav(id)
? <StarFilled style={{ color: 'yellow' }} />
: <StarOutlined />
} onClick={() => props.handleClick?.('FAV', record)}></Button>
<Button icon={<EyeOutlined />} onClick={() => props.handleClick?.('VIEW', record)}></Button>
</Space.Compact>
);
}} />
</Table>
</Flex>
);
}

View File

@ -1,8 +1,8 @@
import { Button, Card, Statistic, Typography } from "antd";
import type { IEventInfo } from "../types";
import dayjs from "dayjs";
import { EyeOutlined } from "@ant-design/icons";
import { useCallback, useMemo } from "react";
import { CalendarOutlined, EyeOutlined } from "@ant-design/icons";
import { useCallback } from "react";
import { useNavigate } from "react-router";
interface EventCardProps {
@ -16,19 +16,31 @@ export function EventCard(props: EventCardProps) {
const handleView = useCallback(() => {
navigate(`/event/${e.matchId}`);
}, [e]);
const handleAddCalendar = useCallback(() => {
const url = `${window.location.origin}/calendar/event/${e.matchId}/events.ics`;
const uri = url.replace(/^http(s)?/, 'webcal');
window.open(uri);
}, [e]);
return (
<Card
key={e.matchId}
title={e.title}
style={{ width: '100%' }}
actions={[
<Button
type="link"
icon={<CalendarOutlined />}
onClick={handleAddCalendar}
>
</Button>,
<Button
type="link"
onClick={handleView}
icon={<EyeOutlined />}
>
</Button>
</Button>,
]}
>
<Typography.Text type={e.isFinished ? undefined : 'success'}>{e.title}</Typography.Text>

View File

@ -1,30 +1,87 @@
import { Flex, Select, Space } from 'antd';
import { Button, Drawer, Flex, Select, Space } from 'antd';
import type React from 'react';
import { clubs } from './clubList';
import { useCallback, useState } from 'react';
import { useCallback, useEffect, useState } from 'react';
import { ClubEvenList } from '../ClubEventList';
import { DeleteOutlined, PlusOutlined, SearchOutlined } from '@ant-design/icons';
import { useClubStore } from '../../store/useClubStore';
import { ClubSearchTable } from '../ClubSearchTable';
interface Props {
}
const defaultOptions = clubs.map(e => ({
label: e.name,
value: e.clubId,
}));
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 clubStore = useClubStore(store => store);
const handleClubChange = useCallback(async (id: string) => {
setClubId(id);
}, []);
const [searchOpen, setSearchOpen] = useState(false);
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>
),
value: info.id
}))]);
return;
}
setOptions(defaultOptions);
setClubId(defaultOptions[0]?.value ?? '');
}, [clubStore]);
return (
<Space orientation='vertical' style={{ width: '100%' }}>
<Flex vertical gap={12} justify='center'>
<Flex gap={12} justify='center' align='center'>
<Select
style={{ width: '100%' }}
placeholder={'请选择俱乐部'}
size='large'
value={clubId}
options={clubs.map(e => ({ label: e.name, value: e.clubId }))}
options={options}
onChange={handleClubChange}
/>
<Flex gap={12}>
<Flex flex={1}>
<Button block size='large' icon={<SearchOutlined />} onClick={() => setSearchOpen(true)}></Button>
</Flex>
</Flex>
</Flex>
<ClubEvenList clubId={clubId} />
<Drawer
open={searchOpen}
placement='bottom'
size={"80vh"}
onClose={() => setSearchOpen(false)}
>
<ClubSearchTable
handleClick={(type, record) => {
switch (type) {
case 'VIEW':
window.open(`/club/${record.id}`);
break;
case 'FAV':
if (clubStore.isFav(record.id)) {
clubStore.unFav(record.id);
} else {
clubStore.add(record);
}
break;
}
}}
/>
</Drawer>
</Space>
);
}

View File

@ -3,8 +3,4 @@ export const clubs = [
name: '东华',
clubId: '47',
},
{
name: '飞酷乒乓球',
clubId: '7841',
},
];

View File

@ -1,10 +1,12 @@
import { serve } from "bun";
import { getMatchInfo, listEvent, xcxApi } from "./utils/server";
import { getMatchInfo, xcxApi } from "./utils/server";
import ics from 'ics';
import index from "./index.html";
import { getUidScore } from "./services/uidScoreStore";
import { checkIsUserFav, favPlayer, listFavPlayers, unFavPlayer } from "./services/favPlayerService";
import { BattleService } from "./services/BattleService";
import { KaiqiuService } from "./services/KaiqiuService";
import dayjs from "dayjs";
const server = serve({
port: process.env.PORT || 3000,
@ -28,13 +30,84 @@ const server = serve({
return Response.json(data);
}
},
"/api/events/:clubid": {
"/api/club/:clubid/events": {
async GET(req) {
const page = Number(new URL(req.url).searchParams.get('page')) ?? 1;
const data = await KaiqiuService.listClubEvents(req.params.clubid, page);
return Response.json(data);
}
},
// "/calendar/club/:id/events.ics": {
// },
"/calendar/event/:id/events.ics": {
async GET(req) {
const id = req.params.id;
const info = await KaiqiuService.getEventInfo(id);
const configs: ics.EventAttributes = {
start: dayjs(info.startDate).format('YYYY-MM-DD-HH-mm').split('-').map(v => Number(v)) as any,
duration: { hours: 6, minutes: 30 },
title: info.title,
// end: dayjs(event.startDate).add(6, 'h').add(30, 'minute').format('YYYY-MM-DD HH:mm'),
// description: 'Annual 10-kilometer run in Boulder, Colorado',
// location: 'Folsom Field, University of Colorado (finish line)',
url: info.url,
// categories: ['10k races', 'Memorial Day Weekend', 'Boulder CO'],
// status: 'CONFIRMED',
// busyStatus: 'BUSY',
// organizer: { name: 'Admin', email: 'Race@BolderBOULDER.com' },
// attendees: [
// { name: 'Adam Gibbons', email: 'adam@example.com', rsvp: true, partstat: 'ACCEPTED', role: 'REQ-PARTICIPANT' },
// { name: 'Brittany Seaton', email: 'brittany@example2.org', dir: 'https://linkedin.com/in/brittanyseaton', role: 'OPT-PARTICIPANT' }
// ]
};
const data: string = await new Promise(resolve => ics.createEvent(configs, (err, data) => {
if (err) {
console.log(err);
resolve('');
}
resolve(data);
}));
return new Response(data, { headers: {
'Content-Type': 'text/calendar; charset=utf-8',
} });
}
},
"/api/club/:id/calendar.ics": {
async GET(req) {
const id = req.params.id;
const page = Number(new URL(req.url).searchParams.get('page')) || 1;
const events = await KaiqiuService.listClubEvents(id, page);
const configs: ics.EventAttributes[] = events?.data?.filter(e => !e.isFinished).map(e => ({
start: dayjs(e.startDate).format('YYYY-MM-DD-HH-mm').split('-').map(v => Number(v)) as any,
duration: { hours: 6, minutes: 30 },
title: e.title,
// end: dayjs(event.startDate).add(6, 'h').add(30, 'minute').format('YYYY-MM-DD HH:mm'),
// description: 'Annual 10-kilometer run in Boulder, Colorado',
// location: 'Folsom Field, University of Colorado (finish line)',
url: e.url,
geo: { lat: events.location.lat, lon: events.location.lng },
// categories: ['10k races', 'Memorial Day Weekend', 'Boulder CO'],
// status: 'CONFIRMED',
// busyStatus: 'BUSY',
// organizer: { name: 'Admin', email: 'Race@BolderBOULDER.com' },
// attendees: [
// { name: 'Adam Gibbons', email: 'adam@example.com', rsvp: true, partstat: 'ACCEPTED', role: 'REQ-PARTICIPANT' },
// { name: 'Brittany Seaton', email: 'brittany@example2.org', dir: 'https://linkedin.com/in/brittanyseaton', role: 'OPT-PARTICIPANT' }
// ]
}));
const data: string = await new Promise(resolve => ics.createEvents(configs, (err, data) => {
if (err) {
console.log(err);
resolve('');
}
resolve(data);
}));
return new Response(data, { headers: {
'Content-Type': 'text/calendar; charset=utf-8',
} });
}
},
"/api/match/:matchId": {
async GET(req) {
const data = await getMatchInfo(req.params.matchId);

View File

@ -1,14 +1,13 @@
import { Avatar, Button, Drawer, Typography } from "antd";
import { useLoaderData } from "react-router";
import type { ClubInfo, IEventInfo } from "../types";
import { useCallback, useState } from "react";
import type { ClubDetail, IEventInfo } from "../types";
import { useState } from "react";
import { ChangeBackground } from "../components/ChangeBackground";
import { ClubEvenList } from "../components/ClubEventList";
import { useRequest } from "ahooks";
export const ClubEventsPage = () => {
const { info, events, total } = useLoaderData<{
info: ClubInfo;
const { info } = useLoaderData<{
info: ClubDetail;
events: IEventInfo[];
total: number;
}>();

View File

@ -45,11 +45,10 @@ export const route = createBrowserRouter([
HydrateFallback: () => <HydrateFallback />,
loader: async ({ params }) => {
const id = params.id;
const [info, event] = await Promise.all([
const [info] = await Promise.all([
fetch(`/api/club/${id}`).then(r => r.json()),
fetch(`/api/events/${id}`).then(r => r.json()),
]);
return { info, events: event.data, total: event.total };
return { info };
},
},
{

View File

@ -1,6 +1,6 @@
import * as cheerio from "cheerio";
import { htmlRequestHeaders, redis, REDIS_CACHE_HOUR } from "../utils/server";
import type { IEventInfo } from "../types";
import type { ClubInfo, IEventInfo } from "../types";
import dayjs from "dayjs";
export class KaiqiuService {
@ -15,19 +15,22 @@ export class KaiqiuService {
return this.parseSearchClubPage(html, Boolean(normalClub));
}
private static parseSearchClubPage(html: string, normal: boolean) {
private static parseSearchClubPage(html: string, normal: boolean): {
clubs: ClubInfo[];
total: number;
} {
const $ = cheerio.load(html);
const infos = $(`#content > div:nth-of-type(${normal ? 2 : 1}) td[width="250"]`).toArray();
const imgs = $(`#content > div:nth-of-type(${normal ? 2 : 1}) td[width="80"]`).toArray();
const parseInfo = (info: typeof infos[number], index: number) => {
const parseInfo = (info: typeof infos[number], index: number): ClubInfo => {
const name = $(info).find('a').text().trim();
const url = $(info).find('a').attr('href') ?? '';
const clubId = /-(?<id>\d+).html$/.exec(url)?.groups?.id
const clubId = /-(?<id>\d+).html$/.exec(url)?.groups?.id?.toString() ?? '';
const members = parseInt($(info).find('span.num').text().trim());
const area = $(info).find('div.gray:nth-of-type(1)').text().trim();
const src = $(imgs[index]).find('.threadimg60 img').attr('src') ?? '';
const img = src.startsWith('http') ? src : 'https://kaiqiuwang.cc/home/image/nologo.jpg';
return { name, clubId, members, area, img, url: `${this.#baseURL}/home/${url}` };
return { name, id: clubId, members, area, img, url: `${this.#baseURL}/home/${url}` };
}
const clubs = infos.map((e, i) => parseInfo(e, i));
const total = parseInt($('#content > div:nth-of-type(1) div.page > em').text().trim()) || clubs.length;
@ -56,6 +59,10 @@ export class KaiqiuService {
if (!clubId) return {
data: [],
total: 0,
location: {
lat: 0,
lng: 0,
},
};
const key = `my-kaiqiuwang:club-events:${clubId}:page-${page}`;
let html = await redis.get(key).catch(() => '');
@ -63,10 +70,15 @@ export class KaiqiuService {
html = await this.#fetchEventListHTML(clubId, page);
redis.setex(key, 60 * 60 * REDIS_CACHE_HOUR, html);
}
return await this.#parseEventList(html);
const location = await this.#getClubLocation(clubId);
const data = await this.#parseEventList(html);
return {
...data,
location,
}
}
static async #parseEventList(html: string) {
static async #parseEventList(html: string) {
const $ = cheerio.load(html);
const blockList = $('div.event_list > ol > li');
const list: IEventInfo[] = [];
@ -95,6 +107,20 @@ export class KaiqiuService {
};
}
static async #getClubLocation(clubId: string) {
const url = `${this.#baseURL}/home/space-mtag-tagid-${clubId}-view-map.html`;
const key = `my-kaiqiuwang:location:${clubId}`;
let html: string = await redis.get(key) || '';
if (!html) {
html = await fetch(url, { headers: htmlRequestHeaders }).then(res => res.text());
redis.setex(key, 60 * 60 * 24, html);
}
const $ = cheerio.load(html);
const lng = Number($('#lng').val());
const lat = Number($('#lat').val());
return { lng, lat };
}
public static async getEventInfo(eventId: string) {
let eventPage = await redis.get(`my-kaiqiuwang:match:${eventId}`) ?? '';
// https://kaiqiuwang.cc/home/space-event-id-175775.html
@ -112,6 +138,8 @@ export class KaiqiuService {
return {
title,
startDate,
url: eventURL,
isFinished: dayjs(startDate).isBefore(),
}
}

28
src/store/useClubStore.ts Normal file
View File

@ -0,0 +1,28 @@
import { create } from "zustand";
import { persist, createJSONStorage } from 'zustand/middleware';
import type { ClubInfo } from "../types";
interface Store {
clubs: ClubInfo[];
add(club: ClubInfo): void;
unFav(id: string): void;
isFav(id: string): boolean;
}
export const useClubStore = create(persist<Store>((set, get) => {
return {
clubs: [],
add: (club: ClubInfo) => {
set({ clubs: [...get().clubs, club] });
},
unFav: (id: string) => {
const newClubs = get().clubs.filter(e => e.id !== id);
set({ clubs: newClubs });
},
isFav: (id: string) => {
return get().clubs.findIndex(e => e.id === id) > -1;
},
}
}, {
name: 'club-storage',
storage: createJSONStorage(() => localStorage),
}));

View File

@ -60,6 +60,15 @@ export interface XCXFindUserResp {
}
export interface ClubInfo {
id: string;
name: string;
members: number;
area: string;
url: string;
img: string;
}
export interface ClubDetail {
id: string;
name: string;
article: string;