feat(Profile)

- add react-router
This commit is contained in:
kyuuseiryuu 2026-01-28 13:00:48 +09:00
parent 7ca9c46ba6
commit 2f82a4edf5
21 changed files with 753 additions and 47 deletions

384
__test__/data/profile.json Normal file
View File

@ -0,0 +1,384 @@
{
"code": 1,
"msg": "获得用户高级信息",
"time": "1769560179",
"data": {
"uid": "73276",
"username": "武藏野",
"Top3ManOfBeat": "12332,379345,562751,",
"Top3ManOfBeatUsernameScore": [
"陳允文(1889)",
"邵博文(1854)",
"张磊(1845)"
],
"Top3WomanOfBeat": "100252,100748,452471,",
"Top3WomanOfPlay": "0",
"Top3OfBeat": "379345,562751,452471,",
"Top3WomanOfBeatUsernameScore": [
"惠宇晨(1965)",
"楡原al(1815)",
"三島悠華(1766)"
],
"TopPlayer": "107828,428006,427831,",
"TopPlayerUsernameScore": [
"陈永航(2150)",
"金田優陽(2058)",
"李冠远(2006)"
],
"OftenPlayer": "铁板李(7),中河(9),cldws(10),大国(12),滨町张磊(16),chen5274(16),",
"maxConsWin": "11",
"maxConsWinLastGameId": "10193043",
"allCities": [
"日本"
],
"win": "153",
"lose": "89",
"total": "242",
"dateline": "1455960573",
"province": "上海",
"city": "浦东新",
"brand": "银河",
"if_event_uid": "0",
"score": "1711",
"goldNum": "2",
"cityNum": "1",
"path": "",
"pathNum": "0",
"champion": "",
"orgTimes": "0",
"ly_event_times": "6",
"ly_event_games": "49",
"ly_event_rank": "0",
"ly_max_inc": "35",
"ly_max_dis": "15.78",
"ly_beatit": "惠惠惠yc(1965)",
"ly_shopid": "2135",
"sleep": "0",
"pathWords": "",
"fuxing": "张成成,张成成,1666,22992,4012390,3胜0负 胜率:100% 胜负局:9/1;cldws,吴庶,1631,71669,14807559,10胜0负 胜率:100% 胜负局:28/8",
"kuzhu": "邵仁爱,邵仁爱,1781,68186,13745808,0胜4负 胜率:0% 胜负局:3/11;卡斯柏,汪志浩,1893,89518,5038808,0胜4负 胜率:0% 胜负局:1/11;萍姐,尹艳萍,1897,381359,5846361,0胜3负 胜率:0% 胜负局:2/9",
"rank": "53424",
"ifHonorShow": "1",
"locationupdatetime": "0",
"resideupdatetime": "0",
"ifManage": 0,
"age": 62,
"maxscore": "1828",
"maxScoreTheYear": "1819",
"beat": "惠宇晨(1965)",
"rate": "87.18%",
"Top3OfBeatUsernameScore": [
"惠宇晨(1965)",
"陳允文(1889)",
"邵博文(1854)"
],
"KuZhu": {
"names": [
"邵仁爱(1781)",
"汪志浩(1893)",
"尹艳萍(1897)"
],
"uids": [
"68186",
"89518",
"381359"
],
"gameids": [
"13745808",
"5038808",
"5846361"
],
"winlose": [
"0胜4负 胜率:0% 胜负局:3/11",
"0胜4负 胜率:0% 胜负局:1/11",
"0胜3负 胜率:0% 胜负局:2/9"
]
},
"FuXing": {
"names": [
"张成成(1666)",
"吴庶(1631)"
],
"uids": [
"22992",
"71669"
],
"gameids": [
"4012390",
"14807559"
],
"winlose": [
"3胜0负 胜率:100% 胜负局:9/1",
"10胜0负 胜率:100% 胜负局:28/8"
]
},
"lastUpdate": "2026年01月22日",
"realpic": "https://oss.kaiqiu.cc/avatar/000/07/32/76_avatar_big.jpg",
"realname": "lihua",
"qiupai": "蝴蝶Butterfly",
"qiupaitype": "普里莫拉茨碳",
"fanshou": "蝴蝶BUTTERFLY",
"fanshoutype": "Tenergy 05 FX",
"zhengshou": "蝴蝶BUTTERFLY",
"zhengshoutype": "Flarestorm Ⅱ",
"resideprovince": "上海 浦东新",
"sex": "男",
"bg": "业余选手",
"scope": "全国",
"description": "lihua 于 2016年02月20日 这一天开启了开球网ChinaTT积分赛的神奇之旅他是开球网的第 73276 位忠实用户。lihua共进行了 242 盘单打比赛,其中获胜 153 盘,失利 89 盘。lihua的积分超过了全国 87.18% 的乒乓球选手应是业余准高手小圈子里前几名的水平对乒乓球有一定的理解。lihua曾战胜的最高分选手惠宇晨(1965)",
"ifHonor": 1,
"honors": [
{
"uid": "73276",
"hid": "450465",
"eventid": "70476",
"itemid": "6034415",
"honor": "https://kaiqiuwang.cc/home/image/icon/bronze_medal16.png",
"subject": "2024年12月22日 东华乒乓球俱乐部2024年1季军",
"posttime": "0"
},
{
"uid": "73276",
"hid": "274549",
"eventid": "47712",
"itemid": "38822",
"honor": "https://kaiqiuwang.cc/home/image/icon/gold_medal16.png",
"subject": "2024年6月15日 东华乒乓球俱乐部2024年6月冠军",
"posttime": "0"
},
{
"uid": "73276",
"hid": "26332",
"eventid": "10049",
"itemid": "0",
"honor": "https://kaiqiuwang.cc/home/image/icon/silver_medal16.png",
"subject": "东华乒乓球俱乐部2018年2月份积分争霸赛亚军",
"posttime": "0"
},
{
"uid": "73276",
"hid": "25609",
"eventid": "9708",
"itemid": "0",
"honor": "https://kaiqiuwang.cc/home/image/icon/gold_medal16.png",
"subject": "东华乒乓球俱乐部2017年12月份积分争霸赛冠军",
"posttime": "0"
},
{
"uid": "73276",
"hid": "16388",
"eventid": "6078",
"itemid": "0",
"honor": "https://kaiqiuwang.cc/home/image/icon/bronze_medal16.png",
"subject": "东华乒乓球俱乐部2016年4月份积分赛季军",
"posttime": "0"
}
],
"games": {
"data": [
{
"gameid": "20730731",
"uid1": "73276",
"uid2": "666599",
"uid11": "0",
"uid22": "0",
"username1": "武藏野",
"username2": "庞程万里",
"username11": "",
"username22": "",
"result1": "3",
"result2": "0",
"score1": "+16",
"score2": "-16",
"eventid": "157007",
"ascore1": "1711",
"dateline": "2026-01-17",
"groupid": "708465111",
"flag": "0"
},
{
"gameid": "20730724",
"uid1": "73276",
"uid2": "563021",
"uid11": "0",
"uid22": "0",
"username1": "武藏野",
"username2": "Tracy_god",
"username11": "",
"username22": "",
"result1": "0",
"result2": "3",
"score1": "-7",
"score2": "7",
"eventid": "157007",
"ascore1": "1695",
"dateline": "2026-01-17",
"groupid": "708465111",
"flag": "0"
},
{
"gameid": "20730720",
"uid1": "73276",
"uid2": "72155",
"uid11": "0",
"uid22": "0",
"username1": "武藏野",
"username2": "大国",
"username11": "",
"username22": "",
"result1": "1",
"result2": "3",
"score1": "-2",
"score2": "2",
"eventid": "157007",
"ascore1": "1866",
"dateline": "2026-01-17",
"groupid": "708465111",
"flag": "0"
},
{
"gameid": "20730718",
"uid1": "73276",
"uid2": "58891",
"uid11": "0",
"uid22": "0",
"username1": "武藏野",
"username2": "阿迪73",
"username11": "",
"username22": "",
"result1": "2",
"result2": "3",
"score1": "-10",
"score2": "10",
"eventid": "157007",
"ascore1": "1708",
"dateline": "2026-01-17",
"groupid": "708465111",
"flag": "0"
},
{
"gameid": "20730715",
"uid1": "73276",
"uid2": "68186",
"uid11": "0",
"uid22": "0",
"username1": "武藏野",
"username2": "邵仁爱",
"username11": "",
"username22": "",
"result1": "1",
"result2": "3",
"score1": "-5",
"score2": "5",
"eventid": "157007",
"ascore1": "1787",
"dateline": "2026-01-17",
"groupid": "708465111",
"flag": "0"
},
{
"gameid": "20730713",
"uid1": "73276",
"uid2": "376886",
"uid11": "0",
"uid22": "0",
"username1": "武藏野",
"username2": "中河",
"username11": "",
"username22": "",
"result1": "1",
"result2": "3",
"score1": "-6",
"score2": "6",
"eventid": "157007",
"ascore1": "1770",
"dateline": "2026-01-17",
"groupid": "708465111",
"flag": "0"
},
{
"gameid": "17112521",
"uid1": "73276",
"uid2": "434868",
"uid11": "0",
"uid22": "0",
"username1": "武藏野",
"username2": "薛高远",
"username11": "",
"username22": "",
"result1": "0",
"result2": "3",
"score1": "-13",
"score2": "13",
"eventid": "117651",
"ascore1": "1692",
"dateline": "2025-08-24",
"groupid": "-1",
"flag": "0"
},
{
"gameid": "17070397",
"uid1": "73276",
"uid2": "439944",
"uid11": "0",
"uid22": "0",
"username1": "武藏野",
"username2": "周诗博",
"username11": "",
"username22": "",
"result1": "0",
"result2": "3",
"score1": "-13",
"score2": "13",
"eventid": "117651",
"ascore1": "1738",
"dateline": "2025-08-24",
"groupid": "703644614",
"flag": "0"
},
{
"gameid": "17070393",
"uid1": "73276",
"uid2": "90032",
"uid11": "0",
"uid22": "0",
"username1": "武藏野",
"username2": "骏骏",
"username11": "",
"username22": "",
"result1": "3",
"result2": "1",
"score1": "+4",
"score2": "-4",
"eventid": "117651",
"ascore1": "1751",
"dateline": "2025-08-24",
"groupid": "703644614",
"flag": "0"
},
{
"gameid": "17070389",
"uid1": "73276",
"uid2": "318418",
"uid11": "0",
"uid22": "0",
"username1": "武藏野",
"username2": "SYF777",
"username11": "",
"username22": "",
"result1": "3",
"result2": "2",
"score1": "+2",
"score2": "-2",
"eventid": "117651",
"ascore1": "1747",
"dateline": "2025-08-24",
"groupid": "703644614",
"flag": "0"
}
]
},
"latest_headtohead_gameid": "19767882",
"hasFollowed": 0
}
}

View File

@ -21,6 +21,7 @@ test('event content not empty', () => {
expect(itemId).toBe(item_id); expect(itemId).toBe(item_id);
expect(players.length).toBeGreaterThan(0); expect(players.length).toBeGreaterThan(0);
console.log(players); console.log(players);
expect(players[0]?.uid).not.toBe('');
}); });
test("group", () => { test("group", () => {

12
__test__/xcxapi.test.ts Normal file
View File

@ -0,0 +1,12 @@
import { test, expect } from 'bun:test';
import { getAdvProfile } from '../src/utils';
import fs from 'fs';
import path from 'path';
test('Test profile', async () => {
const uid = '73276';
const profile = await getAdvProfile(uid);
console.log(profile)
expect(profile).not.toBe(null)
fs.writeFileSync(path.resolve(__dirname, 'data', 'profile.json'), JSON.stringify(profile, null, 2));
});

View File

@ -12,6 +12,8 @@
"lodash": "^4.17.23", "lodash": "^4.17.23",
"react": "^19", "react": "^19",
"react-dom": "^19", "react-dom": "^19",
"react-router": "^7.13.0",
"zustand": "^5.0.10",
}, },
"devDependencies": { "devDependencies": {
"@types/bun": "latest", "@types/bun": "latest",
@ -156,6 +158,8 @@
"compute-scroll-into-view": ["compute-scroll-into-view@3.1.1", "", {}, "sha512-VRhuHOLoKYOy4UbilLbUzbYg93XLjv2PncJC50EuTWPA3gaja1UjBsUP/D/9/juV3vQFr6XBEzn9KCAHdUvOHw=="], "compute-scroll-into-view": ["compute-scroll-into-view@3.1.1", "", {}, "sha512-VRhuHOLoKYOy4UbilLbUzbYg93XLjv2PncJC50EuTWPA3gaja1UjBsUP/D/9/juV3vQFr6XBEzn9KCAHdUvOHw=="],
"cookie": ["cookie@1.1.1", "", {}, "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ=="],
"css-select": ["css-select@5.2.2", "", { "dependencies": { "boolbase": "^1.0.0", "css-what": "^6.1.0", "domhandler": "^5.0.2", "domutils": "^3.0.1", "nth-check": "^2.0.1" } }, "sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw=="], "css-select": ["css-select@5.2.2", "", { "dependencies": { "boolbase": "^1.0.0", "css-what": "^6.1.0", "domhandler": "^5.0.2", "domutils": "^3.0.1", "nth-check": "^2.0.1" } }, "sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw=="],
"css-what": ["css-what@6.2.2", "", {}, "sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA=="], "css-what": ["css-what@6.2.2", "", {}, "sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA=="],
@ -206,6 +210,8 @@
"react-is": ["react-is@18.3.1", "", {}, "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg=="], "react-is": ["react-is@18.3.1", "", {}, "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg=="],
"react-router": ["react-router@7.13.0", "", { "dependencies": { "cookie": "^1.0.1", "set-cookie-parser": "^2.6.0" }, "peerDependencies": { "react": ">=18", "react-dom": ">=18" }, "optionalPeers": ["react-dom"] }, "sha512-PZgus8ETambRT17BUm/LL8lX3Of+oiLaPuVTRH3l1eLvSPpKO3AvhAEb5N7ihAFZQrYDqkvvWfFh9p0z9VsjLw=="],
"resize-observer-polyfill": ["resize-observer-polyfill@1.5.1", "", {}, "sha512-LwZrotdHOo12nQuZlHEmtuXdqGoOD0OhaxopaNFxWzInpEgaLWoVuAMbTzixuosCx2nEG58ngzW3vxdWoxIgdg=="], "resize-observer-polyfill": ["resize-observer-polyfill@1.5.1", "", {}, "sha512-LwZrotdHOo12nQuZlHEmtuXdqGoOD0OhaxopaNFxWzInpEgaLWoVuAMbTzixuosCx2nEG58ngzW3vxdWoxIgdg=="],
"safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="], "safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="],
@ -216,6 +222,8 @@
"scroll-into-view-if-needed": ["scroll-into-view-if-needed@3.1.0", "", { "dependencies": { "compute-scroll-into-view": "^3.0.2" } }, "sha512-49oNpRjWRvnU8NyGVmUaYG4jtTkNonFZI86MmGRDqBphEK2EXT9gdEUoQPZhuBM8yWHxCWbobltqYO5M4XrUvQ=="], "scroll-into-view-if-needed": ["scroll-into-view-if-needed@3.1.0", "", { "dependencies": { "compute-scroll-into-view": "^3.0.2" } }, "sha512-49oNpRjWRvnU8NyGVmUaYG4jtTkNonFZI86MmGRDqBphEK2EXT9gdEUoQPZhuBM8yWHxCWbobltqYO5M4XrUvQ=="],
"set-cookie-parser": ["set-cookie-parser@2.7.2", "", {}, "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw=="],
"string-convert": ["string-convert@0.2.1", "", {}, "sha512-u/1tdPl4yQnPBjnVrmdLo9gtuLvELKsAoRapekWggdiQNvvvum+jYF329d84NAa660KQw7pB2n36KrIKVoXa3A=="], "string-convert": ["string-convert@0.2.1", "", {}, "sha512-u/1tdPl4yQnPBjnVrmdLo9gtuLvELKsAoRapekWggdiQNvvvum+jYF329d84NAa660KQw7pB2n36KrIKVoXa3A=="],
"stylis": ["stylis@4.3.6", "", {}, "sha512-yQ3rwFWRfwNUY7H5vpU0wfdkNSnvnJinhF9830Swlaxl03zsOjCfmX0ugac+3LtK0lYSgwL/KXc8oYL3mG4YFQ=="], "stylis": ["stylis@4.3.6", "", {}, "sha512-yQ3rwFWRfwNUY7H5vpU0wfdkNSnvnJinhF9830Swlaxl03zsOjCfmX0ugac+3LtK0lYSgwL/KXc8oYL3mG4YFQ=="],
@ -232,6 +240,8 @@
"whatwg-mimetype": ["whatwg-mimetype@4.0.0", "", {}, "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg=="], "whatwg-mimetype": ["whatwg-mimetype@4.0.0", "", {}, "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg=="],
"zustand": ["zustand@5.0.10", "", { "peerDependencies": { "@types/react": ">=18.0.0", "immer": ">=9.0.6", "react": ">=18.0.0", "use-sync-external-store": ">=1.2.0" }, "optionalPeers": ["@types/react", "immer", "react", "use-sync-external-store"] }, "sha512-U1AiltS1O9hSy3rul+Ub82ut2fqIAefiSuwECWt6jlMVUGejvf+5omLcRBSzqbRagSM3hQZbtzdeRc6QVScXTg=="],
"htmlparser2/entities": ["entities@7.0.1", "", {}, "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA=="], "htmlparser2/entities": ["entities@7.0.1", "", {}, "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA=="],
"parse5/entities": ["entities@6.0.1", "", {}, "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g=="], "parse5/entities": ["entities@6.0.1", "", {}, "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g=="],

View File

@ -15,7 +15,9 @@
"cheerio": "^1.2.0", "cheerio": "^1.2.0",
"lodash": "^4.17.23", "lodash": "^4.17.23",
"react": "^19", "react": "^19",
"react-dom": "^19" "react-dom": "^19",
"react-router": "^7.13.0",
"zustand": "^5.0.10"
}, },
"devDependencies": { "devDependencies": {
"@types/bun": "latest", "@types/bun": "latest",

View File

@ -1,29 +1,19 @@
import { useCallback, useState } from "react"; import { useCallback } from "react";
import { ClubSelector } from "./components/GameSelector"; import { ClubSelector } from "./components/GameSelector";
import type { IEventInfo } from "./types";
import { useNavigate } from "react-router";
import "./index.css"; import "./index.css";
import type { IEventInfo, Player } from "./types";
import { Drawer, Tabs } from "antd";
import { PlayerList } from "./components/PlayerList";
import { GamePanel } from "./components/GamePanel";
export function App() { export function App() {
const [game, setGame] = useState<IEventInfo>(); const navigate = useNavigate();
const handleGameClick = useCallback(async (game: IEventInfo) => { const handleGameClick = useCallback(async (game: IEventInfo) => {
setGame(game); navigate(`/event/${game.matchId}`);
}, []); }, []);
return ( return (
<div className="app"> <div className="app">
<h1></h1> <h1></h1>
<ClubSelector onGameClick={handleGameClick} /> <ClubSelector onGameClick={handleGameClick} />
<Drawer
placement="bottom"
title={game?.title}
open={Boolean(game)}
onClose={() => setGame(undefined)}
size={'calc(100vh - 100px)'}
>
<GamePanel game={game} />
</Drawer>
</div> </div>
); );
} }

View File

@ -1,5 +1,5 @@
import type React from "react"; import type React from "react";
import type { IEventInfo, MatchInfo } from "../types"; import type { IEventInfo, MatchInfo, Player } from "../types";
import { useRequest } from "ahooks"; import { useRequest } from "ahooks";
import { Spin, Tabs } from "antd"; import { Spin, Tabs } from "antd";
import { PlayerList } from "./PlayerList"; import { PlayerList } from "./PlayerList";
@ -7,34 +7,30 @@ import { GroupingPrediction } from "./GroupingPrediction";
import { useMemo } from "react"; import { useMemo } from "react";
interface Props { interface Props {
game?: IEventInfo; title: string;
players?: Player[];
} }
export const GamePanel: React.FC<Props> = props => { export const GamePanel: React.FC<Props> = props => {
const fetchPlayers = useRequest<MatchInfo | null, []>(async () => {
if (!props.game) return null;
const info: MatchInfo = await (await fetch(`/api/match/${props.game.matchId}`)).json();
return info;
}, { refreshDeps: [props] });
const sneckMode = useMemo(() => { const sneckMode = useMemo(() => {
return !!props.game?.title?.includes('争霸赛'); return !!props.title?.includes('争霸赛');
}, [props.game]); }, [props.title]);
return ( return (
<Spin spinning={fetchPlayers.loading}> <>
<Tabs <Tabs
items={fetchPlayers.loading ? [] : [ items={[
{ {
key: 'groups', key: 'groups',
label: '分组预测', label: '分组预测',
children: <GroupingPrediction sneckMode={sneckMode} players={fetchPlayers.data?.players} /> children: <GroupingPrediction sneckMode={sneckMode} players={props.players} />
}, },
{ {
key: 'players', key: 'players',
label: '成员列表', label: '成员列表',
children: <PlayerList players={fetchPlayers.data?.players} /> children: <PlayerList players={props.players} />
}, },
]} ]}
/> />
</Spin> </>
); );
} }

View File

@ -4,7 +4,7 @@ import { clubs } from './clubList';
import { useCallback, useEffect, useState } from 'react'; import { useCallback, useEffect, useState } from 'react';
import { useRequest } from 'ahooks'; import { useRequest } from 'ahooks';
import { GlobalOutlined } from '@ant-design/icons'; import { GlobalOutlined } from '@ant-design/icons';
import type { IEventInfo } from '../../types'; import type { IEventInfo } from '../..';
interface Props { interface Props {
onGameClick?: (info: IEventInfo) => void; onGameClick?: (info: IEventInfo) => void;

View File

@ -1,6 +1,7 @@
import { Card, Space, Table, Typography } from "antd"; import { Card, Table } from "antd";
import type { Player } from "../types"; import type { Player } from "../types";
import { useMemo } from "react"; import { useMemo } from "react";
import User from "./User";
interface Props { interface Props {
index: number; index: number;
@ -27,7 +28,7 @@ export const GroupMember: React.FC<Props> = props => {
columns={[ columns={[
{ dataIndex: '_', render: (_, __, i) => `(${i + 1})` }, { dataIndex: '_', render: (_, __, i) => `(${i + 1})` },
{ dataIndex: 'index' }, { dataIndex: 'index' },
{ dataIndex: 'name' }, { dataIndex: 'name', render: (name, { uid }) => <User name={name} uid={uid} /> },
{ dataIndex: 'score' }, { dataIndex: 'score' },
]} ]}
/> />

View File

@ -33,7 +33,7 @@ export const GroupingPrediction: React.FC<Props> = props => {
}, [grouped, groupLen, maxPlayerSize]); }, [grouped, groupLen, maxPlayerSize]);
return ( return (
<> <>
<Form layout='horizontal'> <Flex gap={10} wrap>
<Form.Item label={'取人数'}> <Form.Item label={'取人数'}>
<InputNumber <InputNumber
value={maxPlayerSize} value={maxPlayerSize}
@ -52,7 +52,7 @@ 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> </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'}>
{ !sneckMode && grouped.map((p, i) => <GroupMember key={i} players={p} index={i} />)} { !sneckMode && grouped.map((p, i) => <GroupMember key={i} players={p} index={i} />)}

View File

@ -1,6 +1,7 @@
import type React from "react"; import type React from "react";
import type { Player } from "../types"; import type { Player } from "../types";
import { Avatar, Table } from "antd"; import { Avatar, Table } from "antd";
import User from "./User";
interface Props { interface Props {
loading?: boolean; loading?: boolean;
@ -17,7 +18,13 @@ export const PlayerList: React.FC<Props> = props => {
> >
<Table.Column width={32} dataIndex={'_'} align="center" render={(_, __, i) => `${i + 1}`} /> <Table.Column width={32} dataIndex={'_'} align="center" render={(_, __, i) => `${i + 1}`} />
<Table.Column width={32} dataIndex={'avatar'} align="center" render={src => <Avatar src={src} />} /> <Table.Column width={32} dataIndex={'avatar'} align="center" render={src => <Avatar src={src} />} />
<Table.Column width={200} title="姓名" align="center" dataIndex={'name'} /> <Table.Column
width={200}
title="姓名"
align="center"
dataIndex={'name'}
render={(name, { uid }) => <User name={name} uid={uid} />}
/>
<Table.Column width={200} dataIndex={'score'} title="积分" sorter={{ <Table.Column width={200} dataIndex={'score'} title="积分" sorter={{
compare: ({ score: a }: Player, { score: b}: Player) => a - b, compare: ({ score: a }: Player, { score: b}: Player) => a - b,
}} /> }} />

11
src/components/User.tsx Normal file
View File

@ -0,0 +1,11 @@
import { Link } from "react-router";
interface Props {
name: string;
uid: string;
}
export default function User(props: Props) {
return (
<Link to={`/profile/${props.uid}`}>{props.name}</Link>
);
}

View File

@ -5,18 +5,45 @@
* It is included in `src/index.html`. * It is included in `src/index.html`.
*/ */
import { StrictMode } from "react"; import { Component, StrictMode } from "react";
import { createRoot } from "react-dom/client"; import { createRoot } from "react-dom/client";
import { App } from "./App"; import { App } from "./App";
import { ConfigProvider, theme } from "antd"; import { ConfigProvider, theme } from "antd";
import { createBrowserRouter, RouterProvider } from "react-router";
import ProfilePage from "./page/ProfilePage";
import EventPage from "./page/EventPage";
import type { MatchInfo } from "./types";
const elem = document.getElementById("root")!; const elem = document.getElementById("root")!;
const route = createBrowserRouter([
{
path: '/',
element: <App />
},
{
path: '/event/:matchId',
loader: async ({ params }) => {
const info: MatchInfo = await (await fetch(`/api/match/${params.matchId}`)).json();
return info;
},
Component: EventPage,
},
{
path: '/profile/:uid',
loader: async ({ params }) => {
return fetch(`/api/user/${params.uid}`);
},
Component: ProfilePage,
},
]);
const app = ( const app = (
<StrictMode> <StrictMode>
<ConfigProvider theme={{ <ConfigProvider theme={{
algorithm: theme.darkAlgorithm, algorithm: theme.darkAlgorithm,
}}> }}>
<App /> <RouterProvider router={route} />
</ConfigProvider> </ConfigProvider>
</StrictMode> </StrictMode>
); );

View File

@ -6,7 +6,8 @@
background-color: #242424; background-color: #242424;
} }
#root { #root {
width: 100vw; width: 100%;
max-width: 100vw;
} }
body { body {
margin: 0 auto; margin: 0 auto;

View File

@ -1,6 +1,6 @@
import { serve } from "bun"; import { serve } from "bun";
import index from "./index.html"; import index from "./index.html";
import { getMatchInfo, listEvent } from "./utils"; import { getAdvProfile, getMatchInfo, listEvent } from "./utils";
const server = serve({ const server = serve({
port: process.env.PORT || 3000, port: process.env.PORT || 3000,
@ -19,6 +19,13 @@ const server = serve({
return Response.json(data); return Response.json(data);
} }
}, },
"/api/user/:uid": {
async GET(req) {
const uid = req.params.uid;
const profile = await getAdvProfile(uid);
return Response.json(profile);
},
}
}, },
development: process.env.NODE_ENV !== "production" && { development: process.env.NODE_ENV !== "production" && {

12
src/page/EventPage.tsx Normal file
View File

@ -0,0 +1,12 @@
import { useLoaderData, useNavigate } from "react-router";
import { GamePanel } from "../components/GamePanel";
import type { MatchInfo } from "../types";
export default function EventPage() {
const game = useLoaderData<MatchInfo>();
return (
<div style={{ width: '100%', padding: 10, boxSizing: 'border-box' }}>
<GamePanel title={game.title} players={game.players} />
</div>
);
}

72
src/page/ProfilePage.tsx Normal file
View File

@ -0,0 +1,72 @@
import { useLoaderData } from "react-router";
import type { XCXProfile } from "../types/profile";
import { Avatar, Descriptions, Divider, Flex, Image, Typography } from "antd";
function Honor(props: { honors?: XCXProfile['honors'] }) {
if (!props.honors?.length) return null;
return (
<>
<Divider></Divider>
<Flex vertical gap={12}>
{props.honors?.map(honor => {
return (
<Flex key={honor.hid} gap={12} style={{ width: '100%' }}>
<Image style={{ mixBlendMode: 'multiply' }} src={honor.honor} />
<Typography.Text>{honor.subject}</Typography.Text>
</Flex>
);
})}
</Flex>
</>
);
}
function Raket(props: { profile?: XCXProfile | null }) {
const { qiupaitype, zhengshoutype, fanshoutype, qiupai, zhengshou, fanshou } = props.profile || {};
if ([qiupaitype, zhengshoutype, fanshoutype].every(e => !e)) {
return null;
}
return (
<>
<Divider></Divider>
<Descriptions>
<Descriptions.Item label="底板">
{qiupaitype}
<Typography.Text type="secondary" style={{ marginLeft: 4 }}>({qiupai})</Typography.Text>
</Descriptions.Item>
<Descriptions.Item label="正手">
{zhengshoutype}
<Typography.Text type="secondary" style={{ marginLeft: 4 }}>({zhengshou})</Typography.Text>
</Descriptions.Item>
<Descriptions.Item label="反手">
{fanshoutype}
<Typography.Text type="secondary" style={{ marginLeft: 4 }}>({fanshou})</Typography.Text>
</Descriptions.Item>
</Descriptions>
</>
);
}
export default function ProfilePage() {
const profile = useLoaderData<XCXProfile | null>();
return (
<Flex vertical align="center" style={{ padding: 24 }}>
<Avatar src={profile?.realpic} size={128} />
<Typography.Title level={2}>{profile?.username}</Typography.Title>
<Typography.Text>{profile?.realname}</Typography.Text>
<Typography.Text>{profile?.score}</Typography.Text>
<Typography.Text>
{
[profile?.province, profile?.sex, profile?.bg, profile?.scope, ...profile?.allCities ?? []]
.filter(Boolean).join(' | ')
}
</Typography.Text>
<Divider></Divider>
<Typography.Paragraph>
{profile?.description}
</Typography.Paragraph>
<Raket profile={profile} />
<Honor honors={profile?.honors} />
</Flex>
);
}

18
src/store/useGameStore.ts Normal file
View File

@ -0,0 +1,18 @@
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;

View File

@ -5,14 +5,16 @@ export interface IEventInfo {
matchId: string; matchId: string;
} }
export interface MatchInfo {
itemId: string;
title: string;
players: Player[];
}
export interface Player { export interface Player {
name: string; name: string;
score: number; score: number;
avatar: string; avatar: string;
info: string; info: string;
} uid: string;
export interface MatchInfo {
itemId: string;
players: Player[];
} }

116
src/types/profile.ts Normal file
View File

@ -0,0 +1,116 @@
export interface XCXProfile {
uid: string;
username: string;
Top3ManOfBeat: string;
Top3ManOfBeatUsernameScore: string[];
Top3WomanOfBeat: string;
Top3WomanOfPlay: string;
Top3OfBeat: string;
Top3WomanOfBeatUsernameScore: string[];
TopPlayer: string;
TopPlayerUsernameScore: string[];
OftenPlayer: string;
maxConsWin: string;
maxConsWinLastGameId: string;
allCities: string[];
win: string;
lose: string;
total: string;
dateline: string;
province: string;
city: string;
brand: string;
if_event_uid: string;
score: string;
goldNum: string;
cityNum: string;
path: string;
pathNum: string;
champion: string;
orgTimes: string;
ly_event_times: string;
ly_event_games: string;
ly_event_rank: string;
ly_max_inc: string;
ly_max_dis: string;
ly_beatit: string;
ly_shopid: string;
sleep: string;
pathWords: string;
fuxing: string;
kuzhu: string;
rank: string;
ifHonorShow: string;
locationupdatetime: string;
resideupdatetime: string;
ifManage: number;
age: number;
maxscore: string;
maxScoreTheYear: string;
beat: string;
rate: string;
Top3OfBeatUsernameScore: string[];
KuZhu: KuZhu;
FuXing: KuZhu;
lastUpdate: string;
realpic: string;
realname: string;
qiupai: string;
qiupaitype: string;
fanshou: string;
fanshoutype: string;
zhengshou: string;
zhengshoutype: string;
resideprovince: string;
sex: string;
bg: string;
scope: string;
description: string;
ifHonor: number;
honors: Honor[];
games: Games;
hasFollowed: number;
followed_count: number;
}
interface Games {
data: GamesData[];
}
export interface GamesData {
gameid: string;
uid1: string;
uid2: string;
uid11: string;
uid22: string;
username1: string;
username2: string;
username11: string;
username22: string;
result1: string;
result2: string;
score1: string;
score2: string;
eventid: string;
ascore1: string;
dateline: string;
groupid: string;
flag: string;
}
export interface KuZhu {
names: string[];
uids: string[];
gameids: string[];
winlose: string[];
}
export interface Honor {
uid: string;
hid: string;
eventid: string;
itemid: string;
honor: string;
subject: string;
posttime: string;
}

View File

@ -4,8 +4,28 @@ import * as cheerio from "cheerio";
import fs from 'fs'; import fs from 'fs';
import path from 'path'; import path from 'path';
import { chunk } from 'lodash'; import { chunk } from 'lodash';
import type { XCXProfile } from "./types/profile";
const BASE_URL = `https://kaiqiuwang.cc`; const BASE_URL = `https://kaiqiuwang.cc`;
const XCX_BASE_URL = `${BASE_URL}/xcx/public/index.php`;
/**
* token:
XX-Device-Type: wxapp
content-type: application/json
Accept-Encoding: gzip,compress,br,deflate
User-Agent: Mozilla/5.0 (iPhone; CPU iPhone OS 18_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/15E148 MicroMessenger/8.0.68(0x1800442a) NetType/WIFI Language/zh_CN
Referer: https://servicewechat.com/wxff09fb0e92aa456a/464/page-frame.html
*/
const xcxDefaultHeaders = {
'token': 'e72b91bb-a690-44fe-9274-6a4a251f611b',
'XX-Device-Type': 'wxapp',
'content-type': 'application/json',
'Accept-Encoding': 'gzip,compress,br,deflate',
'User-Agent': 'Mozilla/5.0 (iPhone; CPU iPhone OS 18_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/15E148 MicroMessenger/8.0.68(0x1800442a) NetType/WIFI Language/zh_CN',
'Referer': 'https://servicewechat.com/wxff09fb0e92aa456a/464/page-frame.html',
};
/** /**
* @param tagid ID * @param tagid ID
*/ */
@ -62,6 +82,7 @@ export async function fetchEventContentHTML(matchId: string) {
export function parseEventInfo(html: string) { export function parseEventInfo(html: string) {
const $ = cheerio.load(html); const $ = cheerio.load(html);
const title = $('h2.title a').text();
const itemHref = $('.sub_menu a.active').attr('href') ?? ''; const itemHref = $('.sub_menu a.active').attr('href') ?? '';
const itemId = /\S+item_id=(\d+)$/.exec(itemHref)?.[1] ?? ''; const itemId = /\S+item_id=(\d+)$/.exec(itemHref)?.[1] ?? '';
const players: Player[] = []; const players: Player[] = [];
@ -69,12 +90,14 @@ export function parseEventInfo(html: string) {
for (const player of playersEl) { for (const player of playersEl) {
const img = $(player).find('.image img').attr('src') ?? ''; const img = $(player).find('.image img').attr('src') ?? '';
const name = $(player).find('h6').text().trim(); const name = $(player).find('h6').text().trim();
const uid = /space-(?<uid>\d+).html/.exec($(player).find('h6 a').attr('href') ?? '')?.groups?.uid ?? '';
const info = $(player).find('p:nth-of-type(2)').text().replace(/\s/g, ''); const info = $(player).find('p:nth-of-type(2)').text().replace(/\s/g, '');
const score = Number(/^.*?\b(\d+)\b/.exec(info)?.[1]); const score = Number(/^.*?\b(\d+)\b/.exec(info)?.[1]);
players.push({ name, avatar: img, score, info }); players.push({ name, avatar: img, score, info, uid });
} }
return { return {
itemId, itemId,
title,
players, players,
} }
} }
@ -109,3 +132,17 @@ export function sneckGroup(size: number, groupLen: number) {
} }
return newGroups; return newGroups;
} }
export async function getAdvProfile(uid: string) {
// return JSON.parse(fs.readFileSync(
// path.resolve(__dirname, '..', '__test__', 'data', 'profile.json'),
// ).toString()).data;
if (!/^\d+$/.test(uid)) return null;
if (!uid) return null;
const resp = await fetch(`${XCX_BASE_URL}/api/User/adv_profile?uid=${uid}`, {
headers: xcxDefaultHeaders,
});
const data = await resp.json();
if (data.code !== 1) return null;
return data.data as XCXProfile;
}