feat: search club
This commit is contained in:
parent
457fc8595d
commit
99fd5778df
@ -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 type { IEventInfo } from "../types";
|
||||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
import { EventCard } from "./EventCard";
|
import { EventCard } from "./EventCard";
|
||||||
import { useRequest } from "ahooks";
|
import { useRequest } from "ahooks";
|
||||||
|
import { CalendarOutlined } from "@ant-design/icons";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
clubId: string;
|
clubId: string;
|
||||||
}
|
}
|
||||||
export const ClubEvenList = (props: Props) => {
|
export const ClubEvenList = (props: Props) => {
|
||||||
|
const [showAllDisable, setShowAllDiable] = useState(false);
|
||||||
const [showAll, setShowAll] = useState(false);
|
const [showAll, setShowAll] = useState(false);
|
||||||
const requestEvents = useRequest<{data: IEventInfo[]; total: number; }, [string, number]>(
|
const requestEvents = useRequest<{data: IEventInfo[]; total: number; }, [string, number]>(
|
||||||
async (clubId: string, page = 1) => {
|
async (clubId: string, page = 1) => {
|
||||||
if (!clubId) return [];
|
if (!clubId) return [];
|
||||||
return (await fetch(`/api/events/${clubId}?page=${page}`)).json()
|
return (await fetch(`/api/club/${clubId}/events?page=${page}`)).json()
|
||||||
}, { manual: true });
|
}, { manual: true });
|
||||||
const [page, setPage] = useState(1);
|
const [page, setPage] = useState(1);
|
||||||
const onPageChange = useCallback((page: number) => {
|
const onPageChange = useCallback((page: number) => {
|
||||||
@ -37,21 +39,40 @@ export const ClubEvenList = (props: Props) => {
|
|||||||
}
|
}
|
||||||
}, [showAll, onPageChange]);
|
}, [showAll, onPageChange]);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const id = setTimeout(() => {
|
const id = setTimeout(async () => {
|
||||||
requestEvents.run(props.clubId, 1);
|
const data = await requestEvents.runAsync(props.clubId, 1).then(res => res.data);
|
||||||
setPage(1);
|
setPage(1);
|
||||||
|
if (data.length === 10) {
|
||||||
|
setShowAll(data.every(e => !e.isFinished));
|
||||||
|
setShowAllDiable(data.every(e => !e.isFinished));
|
||||||
|
}
|
||||||
}, 100);
|
}, 100);
|
||||||
return () => clearTimeout(id);
|
return () => clearTimeout(id);
|
||||||
}, [props.clubId]);
|
}, [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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<Divider>
|
<Divider>
|
||||||
<Space>
|
{showAllDisable ? null : (
|
||||||
<Typography.Text type="secondary">显示已结束的活动</Typography.Text>
|
<Space>
|
||||||
<Switch checked={showAll} onChange={() => setShowAll(!showAll)} unCheckedChildren="隐藏" checkedChildren="显示" />
|
<Typography.Text type="secondary">显示已结束的活动</Typography.Text>
|
||||||
</Space>
|
<Switch checked={showAll} onChange={() => setShowAll(!showAll)} unCheckedChildren="隐藏" checkedChildren="显示" />
|
||||||
|
</Space>
|
||||||
|
)}
|
||||||
</Divider>
|
</Divider>
|
||||||
<Flex wrap vertical gap={12} justify="center" align="center">
|
<Flex wrap vertical gap={12} justify="center" align="center">
|
||||||
|
<Button
|
||||||
|
type="link"
|
||||||
|
icon={<CalendarOutlined />}
|
||||||
|
onClick={handleAddToCalendar}
|
||||||
|
>
|
||||||
|
本页全部添加到日历
|
||||||
|
</Button>
|
||||||
{showAll ? (
|
{showAll ? (
|
||||||
<Pagination
|
<Pagination
|
||||||
showQuickJumper
|
showQuickJumper
|
||||||
|
|||||||
78
src/components/ClubSearchTable.tsx
Normal file
78
src/components/ClubSearchTable.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -1,8 +1,8 @@
|
|||||||
import { Button, Card, Statistic, Typography } from "antd";
|
import { Button, Card, Statistic, Typography } from "antd";
|
||||||
import type { IEventInfo } from "../types";
|
import type { IEventInfo } from "../types";
|
||||||
import dayjs from "dayjs";
|
import dayjs from "dayjs";
|
||||||
import { EyeOutlined } from "@ant-design/icons";
|
import { CalendarOutlined, EyeOutlined } from "@ant-design/icons";
|
||||||
import { useCallback, useMemo } from "react";
|
import { useCallback } from "react";
|
||||||
import { useNavigate } from "react-router";
|
import { useNavigate } from "react-router";
|
||||||
|
|
||||||
interface EventCardProps {
|
interface EventCardProps {
|
||||||
@ -16,19 +16,31 @@ export function EventCard(props: EventCardProps) {
|
|||||||
const handleView = useCallback(() => {
|
const handleView = useCallback(() => {
|
||||||
navigate(`/event/${e.matchId}`);
|
navigate(`/event/${e.matchId}`);
|
||||||
}, [e]);
|
}, [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 (
|
return (
|
||||||
<Card
|
<Card
|
||||||
key={e.matchId}
|
key={e.matchId}
|
||||||
title={e.title}
|
title={e.title}
|
||||||
style={{ width: '100%' }}
|
style={{ width: '100%' }}
|
||||||
actions={[
|
actions={[
|
||||||
|
<Button
|
||||||
|
type="link"
|
||||||
|
icon={<CalendarOutlined />}
|
||||||
|
onClick={handleAddCalendar}
|
||||||
|
>
|
||||||
|
加入日历
|
||||||
|
</Button>,
|
||||||
<Button
|
<Button
|
||||||
type="link"
|
type="link"
|
||||||
onClick={handleView}
|
onClick={handleView}
|
||||||
icon={<EyeOutlined />}
|
icon={<EyeOutlined />}
|
||||||
>
|
>
|
||||||
查看
|
查看
|
||||||
</Button>
|
</Button>,
|
||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
<Typography.Text type={e.isFinished ? undefined : 'success'}>{e.title}</Typography.Text>
|
<Typography.Text type={e.isFinished ? undefined : 'success'}>{e.title}</Typography.Text>
|
||||||
|
|||||||
@ -1,30 +1,87 @@
|
|||||||
import { Flex, Select, Space } from 'antd';
|
import { Button, Drawer, Flex, Select, Space } from 'antd';
|
||||||
import type React from 'react';
|
import type React from 'react';
|
||||||
import { clubs } from './clubList';
|
import { clubs } from './clubList';
|
||||||
import { useCallback, useState } from 'react';
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
import { ClubEvenList } from '../ClubEventList';
|
import { ClubEvenList } from '../ClubEventList';
|
||||||
|
import { DeleteOutlined, PlusOutlined, SearchOutlined } from '@ant-design/icons';
|
||||||
|
import { useClubStore } from '../../store/useClubStore';
|
||||||
|
import { ClubSearchTable } from '../ClubSearchTable';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
}
|
}
|
||||||
|
const defaultOptions = clubs.map(e => ({
|
||||||
|
label: e.name,
|
||||||
|
value: e.clubId,
|
||||||
|
}));
|
||||||
|
|
||||||
export const GameSelector: React.FC<Props> = () => {
|
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] = useState<string>(clubs[0]?.clubId ?? '');
|
||||||
|
const clubStore = useClubStore(store => store);
|
||||||
const handleClubChange = useCallback(async (id: string) => {
|
const handleClubChange = useCallback(async (id: string) => {
|
||||||
setClubId(id);
|
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 (
|
return (
|
||||||
<Space orientation='vertical' style={{ width: '100%' }}>
|
<Space orientation='vertical' style={{ width: '100%' }}>
|
||||||
<Flex vertical gap={12} justify='center'>
|
<Flex gap={12} justify='center' align='center'>
|
||||||
<Select
|
<Select
|
||||||
style={{ width: '100%' }}
|
style={{ width: '100%' }}
|
||||||
placeholder={'请选择俱乐部'}
|
placeholder={'请选择俱乐部'}
|
||||||
size='large'
|
size='large'
|
||||||
value={clubId}
|
value={clubId}
|
||||||
options={clubs.map(e => ({ label: e.name, value: e.clubId }))}
|
options={options}
|
||||||
onChange={handleClubChange}
|
onChange={handleClubChange}
|
||||||
/>
|
/>
|
||||||
|
<Flex gap={12}>
|
||||||
|
<Flex flex={1}>
|
||||||
|
<Button block size='large' icon={<SearchOutlined />} onClick={() => setSearchOpen(true)}>查找</Button>
|
||||||
|
</Flex>
|
||||||
|
</Flex>
|
||||||
</Flex>
|
</Flex>
|
||||||
<ClubEvenList clubId={clubId} />
|
<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>
|
</Space>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -3,8 +3,4 @@ export const clubs = [
|
|||||||
name: '东华',
|
name: '东华',
|
||||||
clubId: '47',
|
clubId: '47',
|
||||||
},
|
},
|
||||||
{
|
|
||||||
name: '飞酷乒乓球',
|
|
||||||
clubId: '7841',
|
|
||||||
},
|
|
||||||
];
|
];
|
||||||
@ -1,10 +1,12 @@
|
|||||||
import { serve } from "bun";
|
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 index from "./index.html";
|
||||||
import { getUidScore } from "./services/uidScoreStore";
|
import { getUidScore } from "./services/uidScoreStore";
|
||||||
import { checkIsUserFav, favPlayer, listFavPlayers, unFavPlayer } from "./services/favPlayerService";
|
import { checkIsUserFav, favPlayer, listFavPlayers, unFavPlayer } from "./services/favPlayerService";
|
||||||
import { BattleService } from "./services/BattleService";
|
import { BattleService } from "./services/BattleService";
|
||||||
import { KaiqiuService } from "./services/KaiqiuService";
|
import { KaiqiuService } from "./services/KaiqiuService";
|
||||||
|
import dayjs from "dayjs";
|
||||||
|
|
||||||
const server = serve({
|
const server = serve({
|
||||||
port: process.env.PORT || 3000,
|
port: process.env.PORT || 3000,
|
||||||
@ -28,13 +30,84 @@ const server = serve({
|
|||||||
return Response.json(data);
|
return Response.json(data);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"/api/events/:clubid": {
|
"/api/club/:clubid/events": {
|
||||||
async GET(req) {
|
async GET(req) {
|
||||||
const page = Number(new URL(req.url).searchParams.get('page')) ?? 1;
|
const page = Number(new URL(req.url).searchParams.get('page')) ?? 1;
|
||||||
const data = await KaiqiuService.listClubEvents(req.params.clubid, page);
|
const data = await KaiqiuService.listClubEvents(req.params.clubid, page);
|
||||||
return Response.json(data);
|
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": {
|
"/api/match/:matchId": {
|
||||||
async GET(req) {
|
async GET(req) {
|
||||||
const data = await getMatchInfo(req.params.matchId);
|
const data = await getMatchInfo(req.params.matchId);
|
||||||
|
|||||||
@ -1,14 +1,13 @@
|
|||||||
import { Avatar, Button, Drawer, Typography } from "antd";
|
import { Avatar, Button, Drawer, Typography } from "antd";
|
||||||
import { useLoaderData } from "react-router";
|
import { useLoaderData } from "react-router";
|
||||||
import type { ClubInfo, IEventInfo } from "../types";
|
import type { ClubDetail, IEventInfo } from "../types";
|
||||||
import { useCallback, useState } from "react";
|
import { useState } from "react";
|
||||||
import { ChangeBackground } from "../components/ChangeBackground";
|
import { ChangeBackground } from "../components/ChangeBackground";
|
||||||
import { ClubEvenList } from "../components/ClubEventList";
|
import { ClubEvenList } from "../components/ClubEventList";
|
||||||
import { useRequest } from "ahooks";
|
|
||||||
|
|
||||||
export const ClubEventsPage = () => {
|
export const ClubEventsPage = () => {
|
||||||
const { info, events, total } = useLoaderData<{
|
const { info } = useLoaderData<{
|
||||||
info: ClubInfo;
|
info: ClubDetail;
|
||||||
events: IEventInfo[];
|
events: IEventInfo[];
|
||||||
total: number;
|
total: number;
|
||||||
}>();
|
}>();
|
||||||
|
|||||||
@ -45,11 +45,10 @@ export const route = createBrowserRouter([
|
|||||||
HydrateFallback: () => <HydrateFallback />,
|
HydrateFallback: () => <HydrateFallback />,
|
||||||
loader: async ({ params }) => {
|
loader: async ({ params }) => {
|
||||||
const id = params.id;
|
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/club/${id}`).then(r => r.json()),
|
||||||
fetch(`/api/events/${id}`).then(r => r.json()),
|
|
||||||
]);
|
]);
|
||||||
return { info, events: event.data, total: event.total };
|
return { info };
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
import * as cheerio from "cheerio";
|
import * as cheerio from "cheerio";
|
||||||
import { htmlRequestHeaders, redis, REDIS_CACHE_HOUR } from "../utils/server";
|
import { htmlRequestHeaders, redis, REDIS_CACHE_HOUR } from "../utils/server";
|
||||||
import type { IEventInfo } from "../types";
|
import type { ClubInfo, IEventInfo } from "../types";
|
||||||
import dayjs from "dayjs";
|
import dayjs from "dayjs";
|
||||||
|
|
||||||
export class KaiqiuService {
|
export class KaiqiuService {
|
||||||
@ -15,19 +15,22 @@ export class KaiqiuService {
|
|||||||
return this.parseSearchClubPage(html, Boolean(normalClub));
|
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 $ = cheerio.load(html);
|
||||||
const infos = $(`#content > div:nth-of-type(${normal ? 2 : 1}) td[width="250"]`).toArray();
|
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 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 name = $(info).find('a').text().trim();
|
||||||
const url = $(info).find('a').attr('href') ?? '';
|
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 members = parseInt($(info).find('span.num').text().trim());
|
||||||
const area = $(info).find('div.gray:nth-of-type(1)').text().trim();
|
const area = $(info).find('div.gray:nth-of-type(1)').text().trim();
|
||||||
const src = $(imgs[index]).find('.threadimg60 img').attr('src') ?? '';
|
const src = $(imgs[index]).find('.threadimg60 img').attr('src') ?? '';
|
||||||
const img = src.startsWith('http') ? src : 'https://kaiqiuwang.cc/home/image/nologo.jpg';
|
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 clubs = infos.map((e, i) => parseInfo(e, i));
|
||||||
const total = parseInt($('#content > div:nth-of-type(1) div.page > em').text().trim()) || clubs.length;
|
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 {
|
if (!clubId) return {
|
||||||
data: [],
|
data: [],
|
||||||
total: 0,
|
total: 0,
|
||||||
|
location: {
|
||||||
|
lat: 0,
|
||||||
|
lng: 0,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
const key = `my-kaiqiuwang:club-events:${clubId}:page-${page}`;
|
const key = `my-kaiqiuwang:club-events:${clubId}:page-${page}`;
|
||||||
let html = await redis.get(key).catch(() => '');
|
let html = await redis.get(key).catch(() => '');
|
||||||
@ -63,10 +70,15 @@ export class KaiqiuService {
|
|||||||
html = await this.#fetchEventListHTML(clubId, page);
|
html = await this.#fetchEventListHTML(clubId, page);
|
||||||
redis.setex(key, 60 * 60 * REDIS_CACHE_HOUR, html);
|
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 $ = cheerio.load(html);
|
||||||
const blockList = $('div.event_list > ol > li');
|
const blockList = $('div.event_list > ol > li');
|
||||||
const list: IEventInfo[] = [];
|
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) {
|
public static async getEventInfo(eventId: string) {
|
||||||
let eventPage = await redis.get(`my-kaiqiuwang:match:${eventId}`) ?? '';
|
let eventPage = await redis.get(`my-kaiqiuwang:match:${eventId}`) ?? '';
|
||||||
// https://kaiqiuwang.cc/home/space-event-id-175775.html
|
// https://kaiqiuwang.cc/home/space-event-id-175775.html
|
||||||
@ -112,6 +138,8 @@ export class KaiqiuService {
|
|||||||
return {
|
return {
|
||||||
title,
|
title,
|
||||||
startDate,
|
startDate,
|
||||||
|
url: eventURL,
|
||||||
|
isFinished: dayjs(startDate).isBefore(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
28
src/store/useClubStore.ts
Normal file
28
src/store/useClubStore.ts
Normal 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),
|
||||||
|
}));
|
||||||
@ -60,6 +60,15 @@ export interface XCXFindUserResp {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface ClubInfo {
|
export interface ClubInfo {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
members: number;
|
||||||
|
area: string;
|
||||||
|
url: string;
|
||||||
|
img: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ClubDetail {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
article: string;
|
article: string;
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user