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.
This commit is contained in:
kyuuseiryuu 2026-03-25 20:27:51 +09:00
parent 03aa0ead18
commit 30339596d7
6 changed files with 78 additions and 21 deletions

View File

@ -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 (
<Cascader
allowClear
changeOnSelect
showSearch
placeholder={'省/市'}
style={{ width: '100%' }}
onChange={([a, b] = []) => props.onChange?.(a, b)}
options={options}
/>
);
}

View File

@ -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<Data>(initData);
const dataRef = useRef<Partial<Record<ClubType, Data>>>({});
const searchClub = useRequest<Data, [string, number, ClubType]>(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>(ClubType.);
const handleSearch = useCallback(async (searchKey: string, clubType: ClubType, page = 1) => {
@ -83,6 +93,11 @@ export const ClubSearchTable = (props: Props) => {
]}
/>
{clubType !== ClubType. ? (
<Flex wrap gap={8} style={{ width: '100%' }}>
<Flex flex={1}>
<CascaderProvinceCity onChange={(province, city) => setProvinceCity({ province, city })} />
</Flex>
<Flex flex={2} style={{ minWidth: 400 }}>
<Input.Search
allowClear
onSearch={e => {
@ -93,6 +108,8 @@ export const ClubSearchTable = (props: Props) => {
value={searchKey}
onChange={e => setSearchKey(e.target.value)}
/>
</Flex>
</Flex>
) : null}
<Table
rowKey={e => e.id}

View File

@ -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);
}
},

View File

@ -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} = /比赛开始:.*?(?<y>\d{4})年(?<M>\d{2})月(?<D>\d{2})日 \w+ (?<H>\d{2}):(?<m>\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 = /(?<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);

View File

@ -1,9 +1,10 @@
import { xcxApi } from '../utils/server';
export const getUidScore = async (uids: string[], force?: boolean): Promise<Record<string, string>> => {
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);
}

View File

@ -38,6 +38,7 @@ export class XCXAPI {
if (!cacheProfile || force) {
const url = `/api/User/adv_profile?uid=${uid}`;
const profile = await this.#fetch<XCXProfile>(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;
}