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:
parent
03aa0ead18
commit
30339596d7
36
src/components/CascaderProvinceCity.tsx
Normal file
36
src/components/CascaderProvinceCity.tsx
Normal 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}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -4,6 +4,7 @@ import { useCallback, useRef, useState } from "react";
|
|||||||
import type { ClubInfo } from "../types";
|
import type { ClubInfo } from "../types";
|
||||||
import { EyeOutlined, StarFilled, StarOutlined } from "@ant-design/icons";
|
import { EyeOutlined, StarFilled, StarOutlined } from "@ant-design/icons";
|
||||||
import { useClubStore } from "../store/useClubStore";
|
import { useClubStore } from "../store/useClubStore";
|
||||||
|
import { CascaderProvinceCity } from "./CascaderProvinceCity";
|
||||||
|
|
||||||
|
|
||||||
type ClickType = 'VIEW' | 'FAV';
|
type ClickType = 'VIEW' | 'FAV';
|
||||||
@ -24,6 +25,7 @@ const initData = { clubs: [], total: 0, page: 1 };
|
|||||||
|
|
||||||
export const ClubSearchTable = (props: Props) => {
|
export const ClubSearchTable = (props: Props) => {
|
||||||
const [searchKey, setSearchKey] = useState('');
|
const [searchKey, setSearchKey] = useState('');
|
||||||
|
const [provinceCity, setProvinceCity] = useState<{ province?: string, city?: string }>({ province: '', city: '' });
|
||||||
const [datasource, setDatasource] = useState<Data>(initData);
|
const [datasource, setDatasource] = useState<Data>(initData);
|
||||||
const dataRef = useRef<Partial<Record<ClubType, Data>>>({});
|
const dataRef = useRef<Partial<Record<ClubType, Data>>>({});
|
||||||
const searchClub = useRequest<Data, [string, number, ClubType]>(async (
|
const searchClub = useRequest<Data, [string, number, ClubType]>(async (
|
||||||
@ -31,15 +33,23 @@ export const ClubSearchTable = (props: Props) => {
|
|||||||
page = 1,
|
page = 1,
|
||||||
clubType = ClubType.积分俱乐部,
|
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(
|
const resp: Data = await fetch(
|
||||||
`/api/club/find?key=${searchKey}&page=${page}${clubType ? '&normalClub=1' : ''}`
|
`/api/club/find?${searchParams.toString()}`
|
||||||
).then(e => e.json());
|
).then(e => e.json());
|
||||||
dataRef.current[clubType] = {
|
dataRef.current[clubType] = {
|
||||||
...resp,
|
...resp,
|
||||||
page,
|
page,
|
||||||
};
|
};
|
||||||
return resp;
|
return resp;
|
||||||
}, { manual: true });
|
}, { manual: true, refreshDeps: [provinceCity] });
|
||||||
const clubStore = useClubStore(store => store);
|
const clubStore = useClubStore(store => store);
|
||||||
const [clubType, setClubType] = useState<ClubType>(ClubType.积分俱乐部);
|
const [clubType, setClubType] = useState<ClubType>(ClubType.积分俱乐部);
|
||||||
const handleSearch = useCallback(async (searchKey: string, clubType: ClubType, page = 1) => {
|
const handleSearch = useCallback(async (searchKey: string, clubType: ClubType, page = 1) => {
|
||||||
@ -83,6 +93,11 @@ export const ClubSearchTable = (props: Props) => {
|
|||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
{clubType !== ClubType.我的收藏 ? (
|
{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
|
<Input.Search
|
||||||
allowClear
|
allowClear
|
||||||
onSearch={e => {
|
onSearch={e => {
|
||||||
@ -93,6 +108,8 @@ export const ClubSearchTable = (props: Props) => {
|
|||||||
value={searchKey}
|
value={searchKey}
|
||||||
onChange={e => setSearchKey(e.target.value)}
|
onChange={e => setSearchKey(e.target.value)}
|
||||||
/>
|
/>
|
||||||
|
</Flex>
|
||||||
|
</Flex>
|
||||||
) : null}
|
) : null}
|
||||||
<Table
|
<Table
|
||||||
rowKey={e => e.id}
|
rowKey={e => e.id}
|
||||||
|
|||||||
@ -110,7 +110,9 @@ const server = Bun.serve({
|
|||||||
const key = searchParams.get('key') ?? '';
|
const key = searchParams.get('key') ?? '';
|
||||||
const normalClub = searchParams.get('normalClub');
|
const normalClub = searchParams.get('normalClub');
|
||||||
const page = Number(searchParams.get('page'));
|
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);
|
return Response.json(data);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@ -11,9 +11,9 @@ dayjs.extend(timezone);
|
|||||||
export class KaiqiuService {
|
export class KaiqiuService {
|
||||||
static #baseURL = 'https://kaiqiuwang.cc';
|
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 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, {
|
const html = await fetch(url, {
|
||||||
headers: htmlRequestHeaders,
|
headers: htmlRequestHeaders,
|
||||||
}).then(res => res.text());
|
}).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 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})/
|
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 ?? {};
|
.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 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 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 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')
|
const location = $('#content > div:nth-child(1) > div > div.event_content > dl > dd:nth-child(6) > a')
|
||||||
.text().split(' ')?.[1]?.trim() ?? '';
|
.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 overTime = startTime.add(6, 'hour');
|
||||||
const now = dayjs();
|
const now = dayjs();
|
||||||
const isProcessing = now.isAfter(startTime) && now.isBefore(overTime);
|
const isProcessing = now.isAfter(startTime) && now.isBefore(overTime);
|
||||||
|
|||||||
@ -1,9 +1,10 @@
|
|||||||
import { xcxApi } from '../utils/server';
|
import { xcxApi } from '../utils/server';
|
||||||
|
|
||||||
export const getUidScore = async (uids: string[], force?: boolean): Promise<Record<string, string>> => {
|
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);
|
const profile = await xcxApi.getAdvProfile(uid, force);
|
||||||
return [uid, profile?.score ?? ''];
|
entries.push([uid, profile?.score ?? '']);
|
||||||
});
|
}
|
||||||
return Object.fromEntries(await Promise.all(jobs));
|
return Object.fromEntries(entries);
|
||||||
}
|
}
|
||||||
@ -38,6 +38,7 @@ export class XCXAPI {
|
|||||||
if (!cacheProfile || force) {
|
if (!cacheProfile || force) {
|
||||||
const url = `/api/User/adv_profile?uid=${uid}`;
|
const url = `/api/User/adv_profile?uid=${uid}`;
|
||||||
const profile = await this.#fetch<XCXProfile>(url);
|
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));
|
await redis.setex(`my-kaiqiuwang:profile:${uid}`, 60 * 60 * 24, JSON.stringify(profile));
|
||||||
return profile;
|
return profile;
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user