feat: add current-score grouping flow with cached uid score API

- move shared parsing/grouping helpers into serverUtils and update imports
- add xcxApi singleton in serverUtils
- add Redis-backed uid score store and /api/user/nowScores endpoint
- preload uid scores in event loader and tags in profile loader
- allow grouping prediction to switch sort mode between current/yearly score
- update tags component to consume loader-provided tags
- remove unused zustand game store
- add redis test scaffold and scoreboard component scaffold
This commit is contained in:
kyuuseiryuu 2026-02-26 00:13:37 +09:00
parent c26bb93ffa
commit 79c839db00
17 changed files with 138 additions and 65 deletions

8
__test__/redis.test.ts Normal file
View File

@ -0,0 +1,8 @@
import { expect, test } from 'bun:test';
import { getUidScore } from '../src/services/uidScoreStore';
test('read redis', async () => {
const uidScore = await getUidScore(['388663']);
expect(uidScore).toBeDefined();
expect(uidScore['388663']).toBeDefined();
});

View File

@ -1,7 +1,7 @@
import { expect, test } from 'bun:test';
import path from 'path';
import fs from 'fs';
import { fetchEventContentHTML, fetchEventListHTML } from '../src/utils';
import { fetchEventContentHTML, fetchEventListHTML } from '../src/utils/utils';
test('load html', async () => {
const saveTo = path.resolve(__dirname, 'data', 'view-event.html');

View File

@ -1,7 +1,7 @@
import fs from 'fs';
import { expect, test } from 'bun:test';
import path from 'path';
import { parseEventInfo, parseEventList, sneckGroup } from '../src/utils';
import { parseEventInfo, parseEventList, sneckGroup } from '../src/utils/utils';
const matchId = '167684';

View File

@ -1,7 +1,7 @@
import { useMemo, useState } from "react";
import { Button, Card, Divider, Drawer, Flex, Space, Table } from "antd";
import type { BasePlayer } from "../types";
import { getRoundTable } from "../utils";
import { getRoundTable } from "../utils/utils";
import User from "./User";
interface Props {

View File

@ -1,30 +1,51 @@
import React, { useMemo, useState } from "react";
import { Flex, Form, InputNumber, Switch } from "antd";
import { Flex, Form, InputNumber, Segmented, Switch } from "antd";
import { chunk } from 'lodash';
import type { BasePlayer } from "../types";
import { GroupMember } from "./GroupMember";
import { sneckGroup } from "../utils";
import { sneckGroup } from "../utils/utils";
interface Player extends BasePlayer {
nowScore?: string;
}
interface CustomPlayer extends Player {
nowScore: never;
index: number;
id: string;
}
interface Props {
players?: BasePlayer[];
players?: Player[];
sneckMode: boolean;
}
type CustomPlayer = (BasePlayer & { index: number; id: string; });
enum OrderScore {
= '年度积分',
= '当前积分',
}
export const GroupingPrediction: React.FC<Props> = props => {
const [maxPlayerSize, setMaxPlayerSize] = useState(48);
const players: CustomPlayer[] = useMemo(() => {
return props.players
const [nowScoreGroup, setNowScoreGroup] = useState(OrderScore.);
const refactoredPlayers = useMemo(() => {
return nowScoreGroup === OrderScore. ? props.players?.map(e => ({
...e,
score: e.nowScore ?? e.score,
})) : [...props.players ?? []];
}, [nowScoreGroup, props.players]);
const players = useMemo(() => {
return (refactoredPlayers as CustomPlayer[])
?.slice(0, maxPlayerSize)
?.sort((a, b) => Number(b.score) - Number(a.score))
?.map((e, i) => ({ ...e, index: i + 1, id: `${i}-${e.name}-${e.score}` })) ?? [];
}, [props.players, maxPlayerSize]);
}, [refactoredPlayers, maxPlayerSize]);
const [groupLen, setGroupLen] = useState(6);
const [sneckMode, setSneckMode] = useState(props.sneckMode);
const chunkSize = useMemo(() => Math.floor((players.length ?? 0) / groupLen) || 1, [players, groupLen]);
const grouped = useMemo(() => {
return chunk(players, chunkSize);
}, [chunkSize]);
}, [chunkSize, players]);
const sneckedGroups = useMemo(() => {
const sneckIndexGroups = sneckGroup(players.length, groupLen);
return sneckIndexGroups.map(g => {
@ -33,7 +54,7 @@ export const GroupingPrediction: React.FC<Props> = props => {
})).filter(Boolean) as CustomPlayer[];
return subGroup;
});
}, [grouped, groupLen, maxPlayerSize]);
}, [players, grouped, groupLen, maxPlayerSize]);
return (
<>
<Flex gap={10} wrap>
@ -55,6 +76,12 @@ export const GroupingPrediction: React.FC<Props> = props => {
<Form.Item label="蛇形分组">
<Switch checked={sneckMode} onChange={setSneckMode} />
</Form.Item>
<Form.Item label="排序积分">
<Segmented value={nowScoreGroup} onChange={setNowScoreGroup} options={[
OrderScore.,
OrderScore.,
]} />
</Form.Item>
</Flex>
<Flex gap='middle' wrap align="center" justify="center">
<React.Fragment key={'normal'}>

View File

@ -0,0 +1,14 @@
import { Flex } from "antd";
interface Props {
}
export function ScoreBoard(props: Props) {
return (
<Flex>
<Flex></Flex>
<Flex></Flex>
<Flex></Flex>
</Flex>
);
}

View File

@ -1,6 +1,7 @@
import { useRequest } from "ahooks";
import { Divider, Flex, Skeleton, Tag, Typography } from "antd";
import { EType, type XCXTag } from "../types";
import { useLoaderData } from "react-router";
interface Props {
uid?: string;
@ -17,25 +18,16 @@ const color: {
};
export default function UserTags(props: Props) {
const fetchTags = useRequest<XCXTag[], []>(async () => (await fetch(`/api/user/${props.uid}/tags`)).json(), {
refreshDeps: [props.uid],
});
if (!props.uid) return null;
const { tags } = useLoaderData<{ tags: XCXTag[] }>();
if (!props.uid || !tags.length) return null;
return (
<>
<Divider></Divider>
<Flex wrap gap={12} justify="center">
{ fetchTags.loading ? (
<>
<Skeleton.Button active shape="square" />
<Skeleton.Button active shape="square" />
<Skeleton.Button active shape="square" />
</>
) : (
fetchTags.data?.length
? fetchTags.data?.map(e => (
{ (tags?.length ? tags?.map(e => (
<Tag key={e.eid} color={color[e.etype]}>{e.ename}({e.count})</Tag>
)): (<Typography.Text type="secondary"></Typography.Text>))}
)) : (<Typography.Text type="secondary"></Typography.Text>)
)}
</Flex>
</>
);

View File

@ -48,7 +48,8 @@ const route = createBrowserRouter([
score: e.score,
realname: e.name,
} as XCXMember));
return { info, members };
const uidScore = await fetch(`/api/user/nowScores?uids=${info.players.map(e => e.uid).join(',')}`);
return { info, members, uidScore: new Map(Object.entries(await uidScore.json())) };
},
Component: EventPage,
HydrateFallback: () => <HydrateFallback />
@ -58,7 +59,8 @@ const route = createBrowserRouter([
loader: async ({ params }) => {
const { uid } = params;
const profile = await (await fetch(`/api/user/${uid}`)).json();
return { profile, uid };
const tags = await (await fetch(`/api/user/${uid}/tags`)).json();
return { profile, uid, tags };
},
Component: ProfilePage,
HydrateFallback: () => <HydrateFallback />

View File

@ -1,14 +1,14 @@
import { serve } from "bun";
import { getMatchInfo, listEvent } from "./utils";
import { getMatchInfo, listEvent } from "./utils/utils";
import index from "./index.html";
import { XCXAPI } from "./services/xcxApi";
import { xcxApi } from "./utils/server";
import { getUidScore } from "./services/uidScoreStore";
if (!process.env.KAIQIUCC_TOKEN) {
console.error('env KAIQIUCC_TOKEN not found');
process.exit(1);
}
const xcxApi = new XCXAPI(process.env.KAIQIUCC_TOKEN ?? '');
const server = serve({
port: process.env.PORT || 3000,
@ -43,6 +43,14 @@ const server = serve({
return Response.json(users);
}
},
"/api/user/nowScores": {
async GET(req) {
const searchParams = new URL(req.url).searchParams;
const uids = searchParams.get('uids')?.split(',') ?? [];
const uidScore = await getUidScore(uids);
return Response.json(uidScore);
}
},
"/api/user/:uid": {
async GET(req) {
const uid = req.params.uid;

View File

@ -1,25 +1,36 @@
import { useLoaderData, useNavigate } from "react-router";
import { useLoaderData } from "react-router";
import { GamePanel } from "../components/GamePanel";
import type { BasePlayer, MatchInfo, XCXMember } from "../types";
import { Typography } from "antd";
import { HomeOutlined } from "@ant-design/icons";
import { useMemo } from "react";
import { useTitle } from "ahooks";
export default function EventPage() {
const {
info: game,
members
} = useLoaderData<{ info: MatchInfo, members: XCXMember[] }>();
const navigate = useNavigate();
members,
uidScore,
} = useLoaderData<{
uidScore: Map<string, string>,
info: MatchInfo,
members: XCXMember[],
}>();
const map = useMemo(() => Object.fromEntries(members.map(e => [e.uid, e])), [members]);
const players = useMemo(() => {
return game.players
.map(e => ({ ...e, name: map[e.uid]?.realname ?? e.name, number: map[e.uid]?.number ?? NaN }))
.map(e => ({
...e,
name: map[e.uid]?.realname ?? e.name,
number: map[e.uid]?.number ?? NaN,
}))
.sort(((a, b) => +a.number - +b.number));
}, [game, map]);
const basePlayers = useMemo<BasePlayer[]>(() => {
return members.map(e => ({ ...e, name: e.realname } as BasePlayer))
return members.map(e => ({
...e,
name: e.realname,
nowScore: uidScore.get(e.uid),
} as BasePlayer))
}, [members]);
useTitle(game.title, { restoreOnUnmount: true });
return (

View File

@ -2,7 +2,7 @@ import { useLocalStorageState, useRequest } from "ahooks";
import { Input, Table, Spin, Typography, Flex, Space } from "antd";
import type { XCXFindUser, XCXFindUserResp } from "../types";
import User from "../components/User";
import { SEX } from "../utils";
import { SEX } from "../utils/front";
import dayjs from "dayjs";
export function FindUserPage() {

View File

@ -0,0 +1,22 @@
import { RedisClient } from 'bun';
import { xcxApi } from '../utils/server';
const redis = new RedisClient('redis://default:redis_8YnmBw@192.168.50.126:6379');
const getKey = (uid: string) => `my-kaiqiuwang:uid-score:${uid}`;
const TIMEOUT = 60 * 60 * 3; // 3H;
export const getUidScore = async (uids: string[]): Promise<Record<string, string>> => {
const jobs = uids.map(async uid => {
const key = getKey(uid);
const value = await redis.get(key);
if (value) {
return [uid, value];
}
const profile = await xcxApi.getAdvProfile(uid);
await redis.setex(key, TIMEOUT, profile?.score ?? '');
return [uid, profile?.score ?? ''];
});
return Object.fromEntries(await Promise.all(jobs));
}

View File

@ -1,5 +1,5 @@
import type { GamesData, XCXFindUser, XCXFindUserResp, XCXMember, XCXProfile, XCXTag } from "../types";
import { BASE_URL } from "../utils";
import { BASE_URL } from "../utils/utils";
const XCX_BASE_URL = `${BASE_URL}/xcx/public/index.php`;

View File

@ -1,18 +0,0 @@
import { create } from "zustand";
import { type IEventInfo } from '../types';
interface StoreType {
eventInfo?: IEventInfo;
setEventInfo: (info?: IEventInfo) => void;
}
const useGameStore = create<StoreType>((set) => {
return {
setEventInfo: (info) => {
set({ eventInfo: info });
},
}
});
export default useGameStore;

5
src/utils/front.ts Normal file
View File

@ -0,0 +1,5 @@
export enum SEX {
'男' = 1,
'女' = 2,
}

3
src/utils/server.ts Normal file
View File

@ -0,0 +1,3 @@
import { XCXAPI } from "../services/xcxApi";
export const xcxApi = new XCXAPI(process.env.KAIQIUCC_TOKEN ?? '');

View File

@ -1,4 +1,4 @@
import type { IEventInfo, Player } from "./types";
import type { IEventInfo, Player } from "../types";
import * as cheerio from "cheerio";
import { chunk } from 'lodash';
@ -116,11 +116,6 @@ export function sneckGroup(size: number, groupLen: number) {
return newGroups;
}
export enum SEX {
'男' = 1,
'女' = 2,
}
export function getRoundTable<T>(nameList: T[], round: number) {
const list = [...nameList];
const half = list.length / 2;
@ -139,4 +134,8 @@ export function getRoundTable<T>(nameList: T[], round: number) {
const left = [...newList].slice(0, half);
const right = [...newList].slice(half).reverse();
return [left, right];
}
}
export function createGameID(matchId: string, user1: string, user2: string) {
return [matchId, user1, user2].join('-');
}