diff --git a/src/components/ClubEventList.tsx b/src/components/ClubEventList.tsx
index cdecc47..0e9112f 100644
--- a/src/components/ClubEventList.tsx
+++ b/src/components/ClubEventList.tsx
@@ -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 (
<>
-
- 显示已结束的活动
- setShowAll(!showAll)} unCheckedChildren="隐藏" checkedChildren="显示" />
-
+ {showAllDisable ? null : (
+
+ 显示已结束的活动
+ setShowAll(!showAll)} unCheckedChildren="隐藏" checkedChildren="显示" />
+
+ )}
+ }
+ onClick={handleAddToCalendar}
+ >
+ 本页全部添加到日历
+
{showAll ? (
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 (
+
+ {
+ setSearchKey(e);
+ handleSearch(e);
+ }}
+ onPressEnter={() => handleSearch(searchKey)}
+ value={searchKey}
+ onChange={e => setSearchKey(e.target.value)}
+ />
+ 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);
+ }
+ }}
+ >
+ {
+ return (
+
+
+ {name}
+
+ );
+ }} />
+
+ `${num} 人`} />
+ {
+ return (
+
+
+ :
+ } onClick={() => props.handleClick?.('FAV', record)}>关注
+ } onClick={() => props.handleClick?.('VIEW', record)}>查看
+
+ );
+ }} />
+
+
+ );
+}
\ No newline at end of file
diff --git a/src/components/EventCard.tsx b/src/components/EventCard.tsx
index 37f41bd..d8bd7f0 100644
--- a/src/components/EventCard.tsx
+++ b/src/components/EventCard.tsx
@@ -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 (
}
+ onClick={handleAddCalendar}
+ >
+ 加入日历
+ ,
}
>
查看
-
+ ,
]}
>
{e.title}
diff --git a/src/components/GameSelector/GameSelector.tsx b/src/components/GameSelector/GameSelector.tsx
index 72a987d..ef36f9d 100644
--- a/src/components/GameSelector/GameSelector.tsx
+++ b/src/components/GameSelector/GameSelector.tsx
@@ -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 = () => {
+ const [options, setOptions] = useState<{ label: React.ReactNode, value: string }[]>(defaultOptions);
const [clubId, setClubId] = useState(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: (
+
+ {info.name}
+ } onClick={(e) => {
+ e.stopPropagation();
+ clubStore.unFav(info.id);
+ }} />
+
+ ),
+ value: info.id
+ }))]);
+ return;
+ }
+ setOptions(defaultOptions);
+ setClubId(defaultOptions[0]?.value ?? '');
+ }, [clubStore]);
return (
-
+
+ setSearchOpen(false)}
+ >
+ {
+ 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;
+ }
+ }}
+ />
+
);
}
diff --git a/src/components/GameSelector/clubList.ts b/src/components/GameSelector/clubList.ts
index 726bdb4..32974ea 100644
--- a/src/components/GameSelector/clubList.ts
+++ b/src/components/GameSelector/clubList.ts
@@ -3,8 +3,4 @@ export const clubs = [
name: '东华',
clubId: '47',
},
- {
- name: '飞酷乒乓球',
- clubId: '7841',
- },
];
\ No newline at end of file
diff --git a/src/index.tsx b/src/index.tsx
index 76bbc4b..7729f83 100644
--- a/src/index.tsx
+++ b/src/index.tsx
@@ -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);
diff --git a/src/page/ClubEvents.tsx b/src/page/ClubEvents.tsx
index ad44505..688a885 100644
--- a/src/page/ClubEvents.tsx
+++ b/src/page/ClubEvents.tsx
@@ -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;
}>();
diff --git a/src/routes.tsx b/src/routes.tsx
index 889b84d..9bcf1ec 100644
--- a/src/routes.tsx
+++ b/src/routes.tsx
@@ -45,11 +45,10 @@ export const route = createBrowserRouter([
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 };
},
},
{
diff --git a/src/services/KaiqiuService.ts b/src/services/KaiqiuService.ts
index 7821d55..0bf43ee 100644
--- a/src/services/KaiqiuService.ts
+++ b/src/services/KaiqiuService.ts
@@ -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 = /-(?\d+).html$/.exec(url)?.groups?.id
+ const clubId = /-(?\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(),
}
}
diff --git a/src/store/useClubStore.ts b/src/store/useClubStore.ts
new file mode 100644
index 0000000..d7fb2d0
--- /dev/null
+++ b/src/store/useClubStore.ts
@@ -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((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),
+}));
\ No newline at end of file
diff --git a/src/types/index.ts b/src/types/index.ts
index 6cb29fe..1eddf41 100644
--- a/src/types/index.ts
+++ b/src/types/index.ts
@@ -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;