diff --git a/src/components/ClubEventList.tsx b/src/components/ClubEventList.tsx index d5fa2ed..2be8b4e 100644 --- a/src/components/ClubEventList.tsx +++ b/src/components/ClubEventList.tsx @@ -1,26 +1,36 @@ 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 { useCallback, useMemo, useState } from "react"; import { EventCard } from "./EventCard"; import { useRequest } from "ahooks"; import { CalendarOutlined } from "@ant-design/icons"; +import { STORE_PAGE_LIST_KEY } from "../utils/constants"; +import { useRunOnce } from "../hooks/useRunOnce"; interface Props { clubId: string; } + +const getStorageKey = (clubId: string) => `events-page-${clubId}`; + export const ClubEvenList = (props: Props) => { - const [showAllDisable, setShowAllDiable] = useState(false); - const [showAll, setShowAll] = useState(false); + const [paginationControlVisible, setPaginationControlVisible] = useState(true); + const [showFinishedEvents, setShowFinishedEvents] = useState(false); const requestEvents = useRequest<{data: IEventInfo[]; total: number; }, [string, number]>( async (clubId: string, page = 1) => { - if (!clubId) return []; - return (await fetch(`/api/club/${clubId}/events?page=${page}`)).json() - }, { manual: true }); - const [page, setPage] = useState(1); + if (!clubId) return { data: [], total: 0 }; + return fetch(`/api/club/${clubId}/events?page=${page}`).then(e => e.json()); + }, { manual: true, refreshDeps: [] }); + const [page, setPageState] = useState(1); + const setPage = useCallback((page: number) => { + const key = getStorageKey(props.clubId); + sessionStorage.setItem(key, page.toString()); + setPageState(page); + }, [props.clubId]) const onPageChange = useCallback((page: number) => { setPage(page); requestEvents.runAsync(props.clubId, page); - }, [props.clubId]); + }, [props.clubId, setPage]); const pagination = useMemo(() => { return { page, @@ -30,26 +40,26 @@ export const ClubEvenList = (props: Props) => { }, [requestEvents.data?.total, page]); const visibleEvents = useMemo(() => { const events = requestEvents.data?.data || []; - if (showAll) return events; + if (showFinishedEvents) return events; return events.filter(e => !e.isFinished); - }, [showAll, requestEvents.data?.data]); - useEffect(() => { - if (!showAll && page > 1) { - onPageChange(1); // Reset page + }, [showFinishedEvents, requestEvents.data?.data]); + const onPageInit = useCallback(async () => { + const storePageKey = getStorageKey(props.clubId); + const keys: string[] = JSON.parse(localStorage.getItem(STORE_PAGE_LIST_KEY) ?? '[]'); + if (!keys.includes(storePageKey)) { + keys.push(storePageKey); + localStorage.setItem(STORE_PAGE_LIST_KEY, JSON.stringify(keys)); } - }, [showAll, onPageChange]); - useEffect(() => { - const id = setTimeout(async () => { - const data = await requestEvents.runAsync(props.clubId, 1).then(res => res.data); - setPage(1); - if (data.length) { - const isAllFinishedOrAllNotFinished = data.every(e => e.isFinished) || data.every(e => !e.isFinished); - setShowAll(isAllFinishedOrAllNotFinished); - setShowAllDiable(data.every(e => !e.isFinished)); - } - }, 100); - return () => clearTimeout(id); - }, [props.clubId]); + const prePage = Number(sessionStorage.getItem(storePageKey)) || 1; + const data = await requestEvents.runAsync(props.clubId, prePage).then(res => res.data); + setPage(prePage); + if (data.length) { + const isAllFinishedOrAllNotFinished = data.every(e => e.isFinished) || data.every(e => !e.isFinished); + setShowFinishedEvents(prePage !== 1 || isAllFinishedOrAllNotFinished); + setPaginationControlVisible(prePage === 1 ? !isAllFinishedOrAllNotFinished : false); + } + }, [props]); + useRunOnce(onPageInit); const handleAddToCalendar = useCallback(() => { const url = `${window.location.origin}/api/club/${props.clubId}/calendar.ics`; const uri = url.replace(/^http(s)?/, 'webcal'); @@ -59,12 +69,12 @@ export const ClubEvenList = (props: Props) => { return ( <> - {showAllDisable ? null : ( + {paginationControlVisible ? ( 显示已结束的活动 - setShowAll(!showAll)} unCheckedChildren="隐藏" checkedChildren="显示" /> + setShowFinishedEvents(!showFinishedEvents)} unCheckedChildren="隐藏" checkedChildren="显示" /> - )} + ) : null} - {showAll ? ( + {showFinishedEvents ? ( { ); }) : })} - {showAll ? ( + {showFinishedEvents ? ( void; } +enum ClubType { + 积分俱乐部, + 俱乐部, + 我的收藏, +} + +type Data = { clubs: ClubInfo[], total: number, page: number; }; + +const initData = { clubs: [], total: 0, page: 1 }; + export const ClubSearchTable = (props: Props) => { const [searchKey, setSearchKey] = useState(''); - const searchClub = useRequest<{ clubs: ClubInfo[], total: number }, [string, number, number]>(async ( + const [datasource, setDatasource] = useState(initData); + const dataRef = useRef>>({}); + const searchClub = useRequest(async ( searchKey: string, page = 1, - clubType = 0, + clubType = ClubType.积分俱乐部, ) => { - const resp: { clubs: ClubInfo[], total: number } = await fetch( + const resp: Data = await fetch( `/api/club/find?key=${searchKey}&page=${page}${clubType ? '&normalClub=1' : ''}` ).then(e => e.json()); + dataRef.current[clubType] = { + ...resp, + page, + }; return resp; }, { manual: true }); - const [page, setPage] = useState(1); - const [clubType, setClubType] = useState(0); - const handleSearch = useCallback((searchKey: string, clubType: number) => { - setPage(1); - searchClub.runAsync(searchKey, 1, clubType); + const clubStore = useClubStore(store => store); + const [clubType, setClubType] = useState(ClubType.积分俱乐部); + const handleSearch = useCallback(async (searchKey: string, clubType: ClubType, page = 1) => { + switch (clubType) { + case ClubType.积分俱乐部: + case ClubType.俱乐部: { + const resp = await searchClub.runAsync(searchKey, page, clubType); + setDatasource({ ...resp, page }); + break; + } + } }, []); + const handleTypeChange = useCallback(async (type: ClubType) => { + setClubType(type); + switch (type) { + case ClubType.积分俱乐部: + case ClubType.俱乐部: { + if (!searchKey) { + setDatasource(dataRef.current[type] ?? initData); + break; + } + await handleSearch(searchKey, type); + break; + } + case ClubType.我的收藏: + setDatasource({ clubs: clubStore.clubs, total: clubStore.clubs.length, page: 1 }); + break; + default: break; + } + }, [searchKey, searchClub, clubStore]); return ( { - setClubType(e.target.value); - if (!searchKey) return; - handleSearch(searchKey, e.target.value); - }} + onChange={e => handleTypeChange(e.target.value)} options={[ - { label: '积分俱乐部', value: 0 }, - { label: '俱乐部', value: 1 }, + { label: '积分俱乐部', value: ClubType.积分俱乐部 }, + { label: '俱乐部', value: ClubType.俱乐部 }, + { label: '我的收藏', value: ClubType.我的收藏 }, ]} /> - { - setSearchKey(e); - handleSearch(e, clubType); - }} - onPressEnter={() => handleSearch(searchKey, clubType)} - value={searchKey} - onChange={e => setSearchKey(e.target.value)} - /> + {clubType !== ClubType.我的收藏 ? ( + { + setSearchKey(e); + handleSearch(e, clubType); + }} + onPressEnter={() => handleSearch(searchKey, clubType)} + value={searchKey} + onChange={e => setSearchKey(e.target.value)} + /> + ) : null} e.id} loading={searchClub.loading} - dataSource={searchClub.data?.clubs} + dataSource={datasource.clubs} style={{ width: '100%' }} scroll={{ x: 400 }} - pagination={{ - current: page, + pagination={clubType !== ClubType.我的收藏 ? { + current: datasource.page, showSizeChanger: false, - total: searchClub.data?.total, + total: datasource.total, onChange: (page) => { - setPage(page); - searchClub.runAsync(searchKey, page, clubType); - console.debug('onPageChange', { searchKey, page, clubType }); + handleSearch(searchKey, clubType, page); } - }} + } : false} > { return ( diff --git a/src/components/ClubSummary.tsx b/src/components/ClubSummary.tsx new file mode 100644 index 0000000..4218987 --- /dev/null +++ b/src/components/ClubSummary.tsx @@ -0,0 +1,38 @@ +import { Avatar, Button, Drawer, Flex, Skeleton, Typography } from "antd"; +import { ChangeBackground } from "./ChangeBackground"; +import { useState } from "react"; +import { useRequest } from "ahooks"; +import type { ClubDetail } from "../types"; + +interface Props { + clubId: string; +} +export const ClubSummary = (props: Props) => { + + const [isArticleOpen, setIsArticleOpen] = useState(false); + const requestClubSummary = useRequest(async () => { + return fetch(`/api/club/${props.clubId}`).then(r => r.json()); + }, { manual: false, refreshDeps: [props.clubId] }) + const info = requestClubSummary.data; + return ( +
+ {requestClubSummary.loading ? ( + + ) : ( + + + + {info?.name} + + + + setIsArticleOpen(false)} placement="bottom"> +
+ {info?.article} +
+
+
+ )} +
+ ); +} \ No newline at end of file diff --git a/src/components/EventCard.tsx b/src/components/EventCard.tsx index 03670f1..ebde926 100644 --- a/src/components/EventCard.tsx +++ b/src/components/EventCard.tsx @@ -4,8 +4,12 @@ import dayjs from "dayjs"; import utc from 'dayjs/plugin/utc'; import timezone from 'dayjs/plugin/timezone'; import { EyeOutlined } from "@ant-design/icons"; -import { useCallback, useMemo } from "react"; +import { useCallback, useEffect, useMemo, useState } from "react"; import { useNavigate } from "react-router"; +import { useRunOnce } from "../hooks/useRunOnce"; + +dayjs.extend(utc); +dayjs.extend(timezone); interface EventCardProps { eventInfo: IEventInfo; @@ -13,20 +17,35 @@ interface EventCardProps { export function EventCard(props: EventCardProps) { const { eventInfo: e } = props; - const day = dayjs(e.startDate); + const day = useMemo(() => dayjs.tz(e.startDate, 'Asia/Tokyo'), [e]); const navigate = useNavigate(); const handleView = useCallback(() => { navigate(`/event/${e.matchId}`); }, [e]); - const messageFormat = useMemo(() => { + const [messageFormat, setMessageFormat] = useState(''); + const getMessageFormat = useCallback(() => { if (e.isFinished) { + if (dayjs().diff(day, 'days') < 1) return `已结束`; return `已结束 DD 天`; } if (e.isProcessing) { - return '比赛进行中'; + return '比赛进行中 HH:mm:ss'; } - return `还有 DD 天 HH 时开始`; - }, [e]); + return `距离比赛开始还有 DD 天 HH:mm:ss`; + }, [e, day]); + const updateMessageFormat = useCallback(() => { + const format = getMessageFormat(); + setMessageFormat(format); + console.debug('format: %s', format); + }, [getMessageFormat]) + useRunOnce(updateMessageFormat); + useEffect(() => { + const timeout = day.toDate().getTime() - Date.now(); + const id = setTimeout(() => { + updateMessageFormat(); + }, timeout); + return () => clearTimeout(id); + }, [updateMessageFormat]); return ( , ]} > - {e.title} + {e.title} = () => { const [options, setOptions] = useState<{ label: React.ReactNode, value: string }[]>(defaultOptions); - const [clubId, setClubId] = useState(clubs[0]?.clubId ?? ''); + const [clubId, setClubId] = useLocalStorageState(CLUB_SELECTOR_KEY, { defaultValue: clubs[0]?.clubId ?? '' }); const clubStore = useClubStore(store => store); const handleClubChange = useCallback(async (id: string) => { setClubId(id); @@ -32,15 +41,7 @@ export const GameSelector: React.FC = () => { useEffect(() => { if (clubStore.clubs.length) { setOptions([...defaultOptions, ...clubStore.clubs.map(info => ({ - label: ( - - {info.name} - + - + + = () => { }} /> - + ); } diff --git a/src/hooks/useRunOnce.ts b/src/hooks/useRunOnce.ts new file mode 100644 index 0000000..25292cc --- /dev/null +++ b/src/hooks/useRunOnce.ts @@ -0,0 +1,13 @@ +import { useEffect, useRef } from "react" + +export const useRunOnce = (callback: () => void) => { + const done = useRef(false); + useEffect(() => { + const id = setTimeout(() => { + if (done.current) return; + done.current = true; + callback(); + }, 0); + return () => clearTimeout(id); + }, [callback]); +} \ No newline at end of file diff --git a/src/index.tsx b/src/index.tsx index 7d3bba4..a0027e1 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -56,11 +56,11 @@ const server = serve({ events = await KaiqiuService.listClubEvents(id, page); allEvents = allEvents.concat(...events.data); } - const noGeo = !events.geo.lat && !events.geo.lng; - const geo = { lat: events.geo.lat, lon: events.geo.lng }; - const configs: ics.EventAttributes[] = allEvents.filter(e => !e.isFinished).map(e => ({ - ...(noGeo ? {} : { geo }), + ...(clubInfo?.geo ? { geo: { + lat: clubInfo.geo.lat, + lon: clubInfo.geo.lng, + } } : {}), start: dayjs.tz(e.startDate, 'Asia/Tokyo').format('YYYY-MM-DD-HH-mm').split('-').map(v => Number(v)) as any, duration: { hours: 6, minutes: 30 }, title: e.title, diff --git a/src/services/KaiqiuService.ts b/src/services/KaiqiuService.ts index a50aa6e..d4cbebc 100644 --- a/src/services/KaiqiuService.ts +++ b/src/services/KaiqiuService.ts @@ -51,7 +51,8 @@ export class KaiqiuService { const src = $('#space_avatar img').attr('src') ?? ''; const img = src.startsWith('http') ? src : 'https://kaiqiuwang.cc/home/image/nologo.jpg'; const article = $('.article').text().trim(); - return { id: clubId, name, img, article }; + const geo = await this.#getClubLocation(clubId); + return { id: clubId, name, img, article, geo }; } static async #fetchEventListHTML(clubId: string, page: number) { @@ -64,10 +65,6 @@ export class KaiqiuService { if (!clubId) return { data: [], total: 0, - geo: { - lat: 0, - lng: 0, - }, }; const key = `my-kaiqiuwang:club-events:${clubId}:page-${page}`; let html = await redis.get(key).catch(() => ''); @@ -75,12 +72,8 @@ export class KaiqiuService { html = await this.#fetchEventListHTML(clubId, page); redis.setex(key, 60 * 60 * REDIS_CACHE_HOUR, html); } - const geo = await this.#getClubLocation(clubId); const data = await this.#parseEventList(html); - return { - ...data, - geo, - } + return data; } static async #parseEventList(html: string) { @@ -130,6 +123,7 @@ export class KaiqiuService { const $ = cheerio.load(html); const lng = Number($('#lng').val()); const lat = Number($('#lat').val()); + if (!lng && !lat) return null; return { lng, lat }; } diff --git a/src/utils/constants.ts b/src/utils/constants.ts new file mode 100644 index 0000000..fb23329 --- /dev/null +++ b/src/utils/constants.ts @@ -0,0 +1,3 @@ + +export const CLUB_SELECTOR_KEY = 'CLUB_SELECTOR'; +export const STORE_PAGE_LIST_KEY = 'events-page-keys'; \ No newline at end of file