From 30339596d758d05558efeb95d2690a30e1bd1fef Mon Sep 17 00:00:00 2001 From: kyuuseiryuu Date: Wed, 25 Mar 2026 20:27:51 +0900 Subject: [PATCH] feat(search): support province and city filtering for club search - Add CascaderProvinceCity component to ClubSearchTable for selecting province/city. - Update KaiqiuService.findClub to accept and append province/city parameters to the query URL. - Update server-side API handler to extract province/city from query parameters. - Improve date parsing logic in event processing to handle invalid start dates gracefully. - Refactor uidScoreStore to use a loop instead of map for better performance/clarity. - Add debug logging for profile caching in xcxApi. The search functionality now supports filtering clubs by administrative division, providing users with more precise search results. --- src/components/CascaderProvinceCity.tsx | 36 ++++++++++++++++++++++ src/components/ClubSearchTable.tsx | 41 +++++++++++++++++-------- src/index.tsx | 4 ++- src/services/KaiqiuService.ts | 8 ++--- src/services/uidScoreStore.ts | 9 +++--- src/services/xcxApi.ts | 1 + 6 files changed, 78 insertions(+), 21 deletions(-) create mode 100644 src/components/CascaderProvinceCity.tsx diff --git a/src/components/CascaderProvinceCity.tsx b/src/components/CascaderProvinceCity.tsx new file mode 100644 index 0000000..1a6551e --- /dev/null +++ b/src/components/CascaderProvinceCity.tsx @@ -0,0 +1,36 @@ +import { Cascader } from "antd"; +import { useMemo } from "react"; + +const provinceCityConfig = require('../private/city.json'); + +interface Props { + onChange?: (province?: string, city?: string) => void; +} + +export const CascaderProvinceCity = (props: Props) => { + const options = useMemo(() => { + const keys = Object.keys(provinceCityConfig); + const options = keys.map(key => { + return { + label: key, + value: key, + children: (provinceCityConfig[key] as string[]).map(k => ({ + label: k, + value: k, + })) + } + }); + return options; + }, []); + return ( + props.onChange?.(a, b)} + options={options} + /> + ); +} \ No newline at end of file diff --git a/src/components/ClubSearchTable.tsx b/src/components/ClubSearchTable.tsx index 2d2fa59..9f6cf5b 100644 --- a/src/components/ClubSearchTable.tsx +++ b/src/components/ClubSearchTable.tsx @@ -4,6 +4,7 @@ import { useCallback, useRef, useState } from "react"; import type { ClubInfo } from "../types"; import { EyeOutlined, StarFilled, StarOutlined } from "@ant-design/icons"; import { useClubStore } from "../store/useClubStore"; +import { CascaderProvinceCity } from "./CascaderProvinceCity"; type ClickType = 'VIEW' | 'FAV'; @@ -24,6 +25,7 @@ const initData = { clubs: [], total: 0, page: 1 }; export const ClubSearchTable = (props: Props) => { const [searchKey, setSearchKey] = useState(''); + const [provinceCity, setProvinceCity] = useState<{ province?: string, city?: string }>({ province: '', city: '' }); const [datasource, setDatasource] = useState(initData); const dataRef = useRef>>({}); const searchClub = useRequest(async ( @@ -31,15 +33,23 @@ export const ClubSearchTable = (props: Props) => { page = 1, clubType = ClubType.积分俱乐部, ) => { + const searchParams = new URLSearchParams(); + searchParams.append('key', searchKey); + searchParams.append('page', page.toString()); + searchParams.append('province', provinceCity.province ?? ''); + searchParams.append('city', provinceCity.city ?? ''); + if (clubType) { + searchParams.append('normalClub', '1'); + } const resp: Data = await fetch( - `/api/club/find?key=${searchKey}&page=${page}${clubType ? '&normalClub=1' : ''}` + `/api/club/find?${searchParams.toString()}` ).then(e => e.json()); dataRef.current[clubType] = { ...resp, page, }; return resp; - }, { manual: true }); + }, { manual: true, refreshDeps: [provinceCity] }); const clubStore = useClubStore(store => store); const [clubType, setClubType] = useState(ClubType.积分俱乐部); const handleSearch = useCallback(async (searchKey: string, clubType: ClubType, page = 1) => { @@ -83,16 +93,23 @@ export const ClubSearchTable = (props: Props) => { ]} /> {clubType !== ClubType.我的收藏 ? ( - { - setSearchKey(e); - handleSearch(e, clubType); - }} - onPressEnter={() => handleSearch(searchKey, clubType)} - value={searchKey} - onChange={e => setSearchKey(e.target.value)} - /> + + + setProvinceCity({ province, city })} /> + + + { + setSearchKey(e); + handleSearch(e, clubType); + }} + onPressEnter={() => handleSearch(searchKey, clubType)} + value={searchKey} + onChange={e => setSearchKey(e.target.value)} + /> + + ) : null} e.id} diff --git a/src/index.tsx b/src/index.tsx index c930f8c..405a947 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -110,7 +110,9 @@ const server = Bun.serve({ const key = searchParams.get('key') ?? ''; const normalClub = searchParams.get('normalClub'); const page = Number(searchParams.get('page')); - const data = await KaiqiuService.findClub(key, page, Boolean(normalClub)); + const province = searchParams.get('province') ?? ''; + const city = searchParams.get('city') ?? ''; + const data = await KaiqiuService.findClub(key, page, Boolean(normalClub), province, city); return Response.json(data); } }, diff --git a/src/services/KaiqiuService.ts b/src/services/KaiqiuService.ts index 1e9ae2c..786e7d2 100644 --- a/src/services/KaiqiuService.ts +++ b/src/services/KaiqiuService.ts @@ -11,9 +11,9 @@ dayjs.extend(timezone); export class KaiqiuService { static #baseURL = 'https://kaiqiuwang.cc'; - public static async findClub(name: string, page = 1, normalClub?: boolean) { + public static async findClub(name: string, page = 1, normalClub?: boolean, province?: string, city?: string) { const searchKey = encodeURIComponent(name); - const url = `${this.#baseURL}/home/space.php?province=&city=&searchkey=${searchKey}&searchsubmit=%E6%90%9C%E7%B4%A2&searchmode=1&do=mtag&view=hot&${normalClub ? 'page2': 'page'}=${page}`; + const url = `${this.#baseURL}/home/space.php?province=${province}&city=${city}&searchkey=${searchKey}&searchsubmit=%E6%90%9C%E7%B4%A2&searchmode=1&do=mtag&view=hot&${normalClub ? 'page2': 'page'}=${page}`; const html = await fetch(url, { headers: htmlRequestHeaders, }).then(res => res.text()); @@ -146,13 +146,13 @@ export class KaiqiuService { 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 startDate = [y, M, D, H, m].every(Boolean) ? `${y}-${M}-${D} ${H}:${m}` : '0000-00'; const title = $('#mainarea > h2 > a:nth-child(3)').text().trim(); const nums = $('#content > div:nth-child(1) > div > div.event_content > ul > li:nth-child(2)').text(); const see = /(?\d+)\b/.exec($('#content > div:nth-child(1) > div > div.event_content > ul > li:nth-child(1)').text())?.groups?.see ?? ''; const location = $('#content > div:nth-child(1) > div > div.event_content > dl > dd:nth-child(6) > a') .text().split(' ')?.[1]?.trim() ?? ''; - const startTime = dayjs.tz(startDate, 'Asia/Tokyo'); + const startTime = dayjs.tz(startDate || new Date(), 'Asia/Tokyo'); const overTime = startTime.add(6, 'hour'); const now = dayjs(); const isProcessing = now.isAfter(startTime) && now.isBefore(overTime); diff --git a/src/services/uidScoreStore.ts b/src/services/uidScoreStore.ts index f2ebb44..e65ecf5 100644 --- a/src/services/uidScoreStore.ts +++ b/src/services/uidScoreStore.ts @@ -1,9 +1,10 @@ import { xcxApi } from '../utils/server'; export const getUidScore = async (uids: string[], force?: boolean): Promise> => { - const jobs = uids.map(async uid => { + const entries: [string, string][] = []; + for (const uid of uids) { const profile = await xcxApi.getAdvProfile(uid, force); - return [uid, profile?.score ?? '']; - }); - return Object.fromEntries(await Promise.all(jobs)); + entries.push([uid, profile?.score ?? '']); + } + return Object.fromEntries(entries); } \ No newline at end of file diff --git a/src/services/xcxApi.ts b/src/services/xcxApi.ts index fe384e1..9be6331 100644 --- a/src/services/xcxApi.ts +++ b/src/services/xcxApi.ts @@ -38,6 +38,7 @@ export class XCXAPI { if (!cacheProfile || force) { const url = `/api/User/adv_profile?uid=${uid}`; const profile = await this.#fetch(url); + console.debug('Save profile: %s - %s', profile?.uid, profile?.realname); await redis.setex(`my-kaiqiuwang:profile:${uid}`, 60 * 60 * 24, JSON.stringify(profile)); return profile; }