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:
parent
c26bb93ffa
commit
79c839db00
8
__test__/redis.test.ts
Normal file
8
__test__/redis.test.ts
Normal 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();
|
||||||
|
});
|
||||||
@ -1,7 +1,7 @@
|
|||||||
import { expect, test } from 'bun:test';
|
import { expect, test } from 'bun:test';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
import { fetchEventContentHTML, fetchEventListHTML } from '../src/utils';
|
import { fetchEventContentHTML, fetchEventListHTML } from '../src/utils/utils';
|
||||||
|
|
||||||
test('load html', async () => {
|
test('load html', async () => {
|
||||||
const saveTo = path.resolve(__dirname, 'data', 'view-event.html');
|
const saveTo = path.resolve(__dirname, 'data', 'view-event.html');
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
import { expect, test } from 'bun:test';
|
import { expect, test } from 'bun:test';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
import { parseEventInfo, parseEventList, sneckGroup } from '../src/utils';
|
import { parseEventInfo, parseEventList, sneckGroup } from '../src/utils/utils';
|
||||||
|
|
||||||
|
|
||||||
const matchId = '167684';
|
const matchId = '167684';
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
import { useMemo, useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import { Button, Card, Divider, Drawer, Flex, Space, Table } from "antd";
|
import { Button, Card, Divider, Drawer, Flex, Space, Table } from "antd";
|
||||||
import type { BasePlayer } from "../types";
|
import type { BasePlayer } from "../types";
|
||||||
import { getRoundTable } from "../utils";
|
import { getRoundTable } from "../utils/utils";
|
||||||
import User from "./User";
|
import User from "./User";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
|
|||||||
@ -1,30 +1,51 @@
|
|||||||
import React, { useMemo, useState } from "react";
|
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 { chunk } from 'lodash';
|
||||||
import type { BasePlayer } from "../types";
|
import type { BasePlayer } from "../types";
|
||||||
import { GroupMember } from "./GroupMember";
|
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 {
|
interface Props {
|
||||||
players?: BasePlayer[];
|
players?: Player[];
|
||||||
sneckMode: boolean;
|
sneckMode: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
type CustomPlayer = (BasePlayer & { index: number; id: string; });
|
enum OrderScore {
|
||||||
|
年度积分 = '年度积分',
|
||||||
|
当前积分 = '当前积分',
|
||||||
|
}
|
||||||
|
|
||||||
export const GroupingPrediction: React.FC<Props> = props => {
|
export const GroupingPrediction: React.FC<Props> = props => {
|
||||||
const [maxPlayerSize, setMaxPlayerSize] = useState(48);
|
const [maxPlayerSize, setMaxPlayerSize] = useState(48);
|
||||||
const players: CustomPlayer[] = useMemo(() => {
|
const [nowScoreGroup, setNowScoreGroup] = useState(OrderScore.当前积分);
|
||||||
return props.players
|
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)
|
?.slice(0, maxPlayerSize)
|
||||||
?.sort((a, b) => Number(b.score) - Number(a.score))
|
?.sort((a, b) => Number(b.score) - Number(a.score))
|
||||||
?.map((e, i) => ({ ...e, index: i + 1, id: `${i}-${e.name}-${e.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 [groupLen, setGroupLen] = useState(6);
|
||||||
const [sneckMode, setSneckMode] = useState(props.sneckMode);
|
const [sneckMode, setSneckMode] = useState(props.sneckMode);
|
||||||
const chunkSize = useMemo(() => Math.floor((players.length ?? 0) / groupLen) || 1, [players, groupLen]);
|
const chunkSize = useMemo(() => Math.floor((players.length ?? 0) / groupLen) || 1, [players, groupLen]);
|
||||||
const grouped = useMemo(() => {
|
const grouped = useMemo(() => {
|
||||||
return chunk(players, chunkSize);
|
return chunk(players, chunkSize);
|
||||||
}, [chunkSize]);
|
}, [chunkSize, players]);
|
||||||
const sneckedGroups = useMemo(() => {
|
const sneckedGroups = useMemo(() => {
|
||||||
const sneckIndexGroups = sneckGroup(players.length, groupLen);
|
const sneckIndexGroups = sneckGroup(players.length, groupLen);
|
||||||
return sneckIndexGroups.map(g => {
|
return sneckIndexGroups.map(g => {
|
||||||
@ -33,7 +54,7 @@ export const GroupingPrediction: React.FC<Props> = props => {
|
|||||||
})).filter(Boolean) as CustomPlayer[];
|
})).filter(Boolean) as CustomPlayer[];
|
||||||
return subGroup;
|
return subGroup;
|
||||||
});
|
});
|
||||||
}, [grouped, groupLen, maxPlayerSize]);
|
}, [players, grouped, groupLen, maxPlayerSize]);
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Flex gap={10} wrap>
|
<Flex gap={10} wrap>
|
||||||
@ -55,6 +76,12 @@ export const GroupingPrediction: React.FC<Props> = props => {
|
|||||||
<Form.Item label="蛇形分组">
|
<Form.Item label="蛇形分组">
|
||||||
<Switch checked={sneckMode} onChange={setSneckMode} />
|
<Switch checked={sneckMode} onChange={setSneckMode} />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
<Form.Item label="排序积分">
|
||||||
|
<Segmented value={nowScoreGroup} onChange={setNowScoreGroup} options={[
|
||||||
|
OrderScore.当前积分,
|
||||||
|
OrderScore.年度积分,
|
||||||
|
]} />
|
||||||
|
</Form.Item>
|
||||||
</Flex>
|
</Flex>
|
||||||
<Flex gap='middle' wrap align="center" justify="center">
|
<Flex gap='middle' wrap align="center" justify="center">
|
||||||
<React.Fragment key={'normal'}>
|
<React.Fragment key={'normal'}>
|
||||||
|
|||||||
14
src/components/ScoreBoard/index.tsx
Normal file
14
src/components/ScoreBoard/index.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -1,6 +1,7 @@
|
|||||||
import { useRequest } from "ahooks";
|
import { useRequest } from "ahooks";
|
||||||
import { Divider, Flex, Skeleton, Tag, Typography } from "antd";
|
import { Divider, Flex, Skeleton, Tag, Typography } from "antd";
|
||||||
import { EType, type XCXTag } from "../types";
|
import { EType, type XCXTag } from "../types";
|
||||||
|
import { useLoaderData } from "react-router";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
uid?: string;
|
uid?: string;
|
||||||
@ -17,25 +18,16 @@ const color: {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export default function UserTags(props: Props) {
|
export default function UserTags(props: Props) {
|
||||||
const fetchTags = useRequest<XCXTag[], []>(async () => (await fetch(`/api/user/${props.uid}/tags`)).json(), {
|
const { tags } = useLoaderData<{ tags: XCXTag[] }>();
|
||||||
refreshDeps: [props.uid],
|
if (!props.uid || !tags.length) return null;
|
||||||
});
|
|
||||||
if (!props.uid) return null;
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Divider>评价标签</Divider>
|
<Divider>评价标签</Divider>
|
||||||
<Flex wrap gap={12} justify="center">
|
<Flex wrap gap={12} justify="center">
|
||||||
{ fetchTags.loading ? (
|
{ (tags?.length ? tags?.map(e => (
|
||||||
<>
|
|
||||||
<Skeleton.Button active shape="square" />
|
|
||||||
<Skeleton.Button active shape="square" />
|
|
||||||
<Skeleton.Button active shape="square" />
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
fetchTags.data?.length
|
|
||||||
? fetchTags.data?.map(e => (
|
|
||||||
<Tag key={e.eid} color={color[e.etype]}>{e.ename}({e.count})</Tag>
|
<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>
|
</Flex>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -48,7 +48,8 @@ const route = createBrowserRouter([
|
|||||||
score: e.score,
|
score: e.score,
|
||||||
realname: e.name,
|
realname: e.name,
|
||||||
} as XCXMember));
|
} 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,
|
Component: EventPage,
|
||||||
HydrateFallback: () => <HydrateFallback />
|
HydrateFallback: () => <HydrateFallback />
|
||||||
@ -58,7 +59,8 @@ const route = createBrowserRouter([
|
|||||||
loader: async ({ params }) => {
|
loader: async ({ params }) => {
|
||||||
const { uid } = params;
|
const { uid } = params;
|
||||||
const profile = await (await fetch(`/api/user/${uid}`)).json();
|
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,
|
Component: ProfilePage,
|
||||||
HydrateFallback: () => <HydrateFallback />
|
HydrateFallback: () => <HydrateFallback />
|
||||||
|
|||||||
@ -1,14 +1,14 @@
|
|||||||
import { serve } from "bun";
|
import { serve } from "bun";
|
||||||
import { getMatchInfo, listEvent } from "./utils";
|
import { getMatchInfo, listEvent } from "./utils/utils";
|
||||||
import index from "./index.html";
|
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) {
|
if (!process.env.KAIQIUCC_TOKEN) {
|
||||||
console.error('env KAIQIUCC_TOKEN not found');
|
console.error('env KAIQIUCC_TOKEN not found');
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
const xcxApi = new XCXAPI(process.env.KAIQIUCC_TOKEN ?? '');
|
|
||||||
|
|
||||||
const server = serve({
|
const server = serve({
|
||||||
port: process.env.PORT || 3000,
|
port: process.env.PORT || 3000,
|
||||||
@ -43,6 +43,14 @@ const server = serve({
|
|||||||
return Response.json(users);
|
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": {
|
"/api/user/:uid": {
|
||||||
async GET(req) {
|
async GET(req) {
|
||||||
const uid = req.params.uid;
|
const uid = req.params.uid;
|
||||||
|
|||||||
@ -1,25 +1,36 @@
|
|||||||
import { useLoaderData, useNavigate } from "react-router";
|
import { useLoaderData } from "react-router";
|
||||||
import { GamePanel } from "../components/GamePanel";
|
import { GamePanel } from "../components/GamePanel";
|
||||||
import type { BasePlayer, MatchInfo, XCXMember } from "../types";
|
import type { BasePlayer, MatchInfo, XCXMember } from "../types";
|
||||||
import { Typography } from "antd";
|
import { Typography } from "antd";
|
||||||
import { HomeOutlined } from "@ant-design/icons";
|
|
||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
import { useTitle } from "ahooks";
|
import { useTitle } from "ahooks";
|
||||||
|
|
||||||
export default function EventPage() {
|
export default function EventPage() {
|
||||||
const {
|
const {
|
||||||
info: game,
|
info: game,
|
||||||
members
|
members,
|
||||||
} = useLoaderData<{ info: MatchInfo, members: XCXMember[] }>();
|
uidScore,
|
||||||
const navigate = useNavigate();
|
} = useLoaderData<{
|
||||||
|
uidScore: Map<string, string>,
|
||||||
|
info: MatchInfo,
|
||||||
|
members: XCXMember[],
|
||||||
|
}>();
|
||||||
const map = useMemo(() => Object.fromEntries(members.map(e => [e.uid, e])), [members]);
|
const map = useMemo(() => Object.fromEntries(members.map(e => [e.uid, e])), [members]);
|
||||||
const players = useMemo(() => {
|
const players = useMemo(() => {
|
||||||
return game.players
|
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));
|
.sort(((a, b) => +a.number - +b.number));
|
||||||
}, [game, map]);
|
}, [game, map]);
|
||||||
const basePlayers = useMemo<BasePlayer[]>(() => {
|
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]);
|
}, [members]);
|
||||||
useTitle(game.title, { restoreOnUnmount: true });
|
useTitle(game.title, { restoreOnUnmount: true });
|
||||||
return (
|
return (
|
||||||
|
|||||||
@ -2,7 +2,7 @@ import { useLocalStorageState, useRequest } from "ahooks";
|
|||||||
import { Input, Table, Spin, Typography, Flex, Space } from "antd";
|
import { Input, Table, Spin, Typography, Flex, Space } from "antd";
|
||||||
import type { XCXFindUser, XCXFindUserResp } from "../types";
|
import type { XCXFindUser, XCXFindUserResp } from "../types";
|
||||||
import User from "../components/User";
|
import User from "../components/User";
|
||||||
import { SEX } from "../utils";
|
import { SEX } from "../utils/front";
|
||||||
import dayjs from "dayjs";
|
import dayjs from "dayjs";
|
||||||
|
|
||||||
export function FindUserPage() {
|
export function FindUserPage() {
|
||||||
|
|||||||
22
src/services/uidScoreStore.ts
Normal file
22
src/services/uidScoreStore.ts
Normal 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));
|
||||||
|
}
|
||||||
@ -1,5 +1,5 @@
|
|||||||
import type { GamesData, XCXFindUser, XCXFindUserResp, XCXMember, XCXProfile, XCXTag } from "../types";
|
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`;
|
const XCX_BASE_URL = `${BASE_URL}/xcx/public/index.php`;
|
||||||
|
|
||||||
|
|||||||
@ -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
5
src/utils/front.ts
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
|
||||||
|
export enum SEX {
|
||||||
|
'男' = 1,
|
||||||
|
'女' = 2,
|
||||||
|
}
|
||||||
3
src/utils/server.ts
Normal file
3
src/utils/server.ts
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
import { XCXAPI } from "../services/xcxApi";
|
||||||
|
|
||||||
|
export const xcxApi = new XCXAPI(process.env.KAIQIUCC_TOKEN ?? '');
|
||||||
@ -1,4 +1,4 @@
|
|||||||
import type { IEventInfo, Player } from "./types";
|
import type { IEventInfo, Player } from "../types";
|
||||||
import * as cheerio from "cheerio";
|
import * as cheerio from "cheerio";
|
||||||
import { chunk } from 'lodash';
|
import { chunk } from 'lodash';
|
||||||
|
|
||||||
@ -116,11 +116,6 @@ export function sneckGroup(size: number, groupLen: number) {
|
|||||||
return newGroups;
|
return newGroups;
|
||||||
}
|
}
|
||||||
|
|
||||||
export enum SEX {
|
|
||||||
'男' = 1,
|
|
||||||
'女' = 2,
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getRoundTable<T>(nameList: T[], round: number) {
|
export function getRoundTable<T>(nameList: T[], round: number) {
|
||||||
const list = [...nameList];
|
const list = [...nameList];
|
||||||
const half = list.length / 2;
|
const half = list.length / 2;
|
||||||
@ -140,3 +135,7 @@ export function getRoundTable<T>(nameList: T[], round: number) {
|
|||||||
const right = [...newList].slice(half).reverse();
|
const right = [...newList].slice(half).reverse();
|
||||||
return [left, right];
|
return [left, right];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function createGameID(matchId: string, user1: string, user2: string) {
|
||||||
|
return [matchId, user1, user2].join('-');
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user