feat(BattleTable): share code

This commit is contained in:
kyuuseiryuu 2026-03-11 18:08:39 +09:00
parent 9044073afd
commit 6f1f8044dd
4 changed files with 169 additions and 43 deletions

View File

@ -1,28 +1,35 @@
import type { BasePlayer } from "../types";
import { useCallback, useEffect, useMemo, useState } from "react";
import { calculate, getRoundTable } from "../utils/common";
import { Button, Card, Divider, Flex, Space, Table } from "antd";
import { Button, Card, Divider, Flex, Table, Modal, Typography, Input } from "antd";
import styled from "styled-components";
import { cloneDeep } from "lodash";
import { ClearOutlined } from "@ant-design/icons";
import { ClearOutlined, EditOutlined, ShareAltOutlined } from "@ant-design/icons";
import { useLocation } from "react-router";
const StyledContainer = styled(Flex)`
min-width: 300px;
width: 80vw;
max-width: 600px;
margin: 0 auto;
.ant-card, .share-buttons {
width: 100%;
}
.winer {
color: red;
position: relative;
::after {
width: 4px
content: '';
font-size: 0.8em;
background-color: red;
color: white;
padding: 0;
border-radius: 50%;
position: absolute;
}
}
`;
type Result = {
winer: string;
score: number;
}
function getMatchKey(player1: BasePlayer, player2: BasePlayer) {
return `${player1.uid}-${player2.uid}`;
}
function BattleTable({
players: list,
}: {
@ -35,7 +42,7 @@ function BattleTable({
}], [list]);
const resultMap = useMemo(() => {
const cache = localStorage.getItem('match-result-map');
const cacheEntries: [string, { winer: string, score: number }][] = cache ? Object.entries(JSON.parse(cache)) : [];
const cacheEntries: [string, Result][] = cache ? Object.entries(JSON.parse(cache)) : [];
return new Map<string, {
winer: string,
score: number,
@ -54,7 +61,7 @@ function BattleTable({
const [left, right] = getRoundTable(dataList, i);
left?.forEach((l, i) => {
const r = right?.[i];
const key = `${l?.uid}-${r?.uid}`;
const key = getMatchKey(l!, r!);
const winer = resultMap.get(key)?.winer;
const score = resultMap.get(key)?.score;
if (winer && l && r) {
@ -72,10 +79,20 @@ function BattleTable({
return roundTable;
}, []);
const [matchGroupTable, setMatchGroupTable] = useState<ReturnType<typeof buildMatchGroupTable>>([]);
const matchKeys = useMemo(() => {
const keys: string[] = [];
matchGroupTable.forEach(([left, right]) => {
left?.forEach((l, i) => {
const r = right?.[i];
const key = getMatchKey(l!, r!);
keys.push(key);
});
});
return keys;
}, [matchGroupTable]);
const handleWiner = useCallback((l?: BasePlayer, r?: BasePlayer, winer?: BasePlayer, loser?: BasePlayer) => {
if (!l?.uid || !r?.uid || !winer?.uid || !loser?.uid) return;
const key = `${l.uid}-${r.uid}`;
const key = getMatchKey(l!, r!);
const score = calculate(+winer.score, +loser.score);
if (resultMap.get(key)?.winer) {
resultMap.delete(key);
@ -87,7 +104,7 @@ function BattleTable({
const handleClear = useCallback((left?: (BasePlayer | null | undefined)[], right?: (BasePlayer | null | undefined)[]) => {
left?.forEach((l, i) => {
const r = right?.[i];
const key = `${l?.uid}-${r?.uid}`;
const key = getMatchKey(l!, r!);
resultMap.delete(key);
});
setMatchGroupTable(buildMatchGroupTable(playerList));
@ -96,7 +113,7 @@ function BattleTable({
matchGroupTable.forEach(([left, right]) => {
left?.forEach((l, i) => {
const r = right?.[i];
const key = `${l?.uid}-${r?.uid}`;
const key = getMatchKey(l!, r!);
resultMap.delete(key);
});
});
@ -107,13 +124,74 @@ function BattleTable({
const newScoreMap = Object.fromEntries([...left, ...right].map(e => [e?.uid, e?.score]));
return newScoreMap;
}, [playerList, matchGroupTable]);
const [modal, contextHodler] = Modal.useModal();
const eventId = useLocation().pathname.split('/').pop() ?? '';
const handleShareCode = useCallback(async () => {
const entries = matchKeys.map(e => [e, resultMap.get(e)]);
const data = JSON.stringify(Object.fromEntries(entries));
const code = await fetch(`/api/battle/${eventId}`, {
method: 'PUT',
body: JSON.stringify({ data }),
}).then(res => res.json()).then(json => json.code);
modal.success({
maskClosable: true,
icon: null,
footer: null,
style: { width: 300, height: 300 },
title: '分享码',
content: (
<Flex vertical align="center" justify="center">
<Typography.Text copyable>{code}</Typography.Text>
</Flex>
),
});
}, [resultMap, matchKeys, eventId]);
const [importCodeModalVisible, setImportModalVisible] = useState(false);
const handleImportCodeClick = useCallback(() => {
setImportModalVisible(true);
}, []);
const handleScanModalClose = useCallback(() => {
setImportModalVisible(false);
}, []);
useEffect(() => {
const roundTable = buildMatchGroupTable(playerList);
setMatchGroupTable(roundTable);
}, [playerList]);
const [shareCode, setShareCode] = useState('');
const handleImportCode = useCallback(async () => {
const data: Record<string, Result> = await fetch(`/api/battle/${eventId}?code=${shareCode}`)
.then(res => res.json())
.then(json => json);
Object.entries(data).forEach(([k, v]) => {
const { winer = '', score = 0 } = v ?? {};
resultMap.set(k, { winer, score });
});
setMatchGroupTable(buildMatchGroupTable(playerList));
setImportModalVisible(false);
}, [shareCode, playerList]);
// console.debug('matchGroupTable', matchGroupTable, gameResultMap);
return (
<StyledContainer vertical gap={12} justify="center" align="center">
{contextHodler}
<Modal
forceRender
title="导入分享码"
open={importCodeModalVisible}
onCancel={handleScanModalClose}
onOk={handleImportCode}
okText="导入"
cancelText="取消"
>
<Flex vertical align="center" justify="center">
<Input
allowClear
value={shareCode}
placeholder="输入分享码"
onChange={e => setShareCode(e.target.value)}
onPressEnter={handleImportCode}
/>
</Flex>
</Modal>
{matchGroupTable.map(([left, right], i) => {
return (
<Card
@ -132,7 +210,7 @@ function BattleTable({
const nameL = l?.uid ? `${l?.name}(${l?.score})${resultL}` : '-';
const nameR = r?.uid ? `${r?.name}(${r?.score})${resultR}` : '-';
return (
<div key={key} style={{ minWidth: 280, width: '80vw', maxWidth: 600 }}>
<div key={key} className="round-row">
<Flex align="center" justify="space-between">
<div
className={winer === l?.uid ? 'winer' : ''}
@ -162,30 +240,40 @@ function BattleTable({
</Card>
);
})}
<Table
title={() => (
<Flex align="center" justify="space-between">
<span></span>
<Button type="link" icon={<ClearOutlined />} onClick={handleClearAll}></Button>
</Flex>
<Card
title={'积分增减预测'}
extra={(
<Button type="link" icon={<ClearOutlined />} onClick={handleClearAll}></Button>
)}
showHeader={false}
pagination={false}
rowHoverable={false}
size="small"
dataSource={playerList}
rowKey={e => e.uid}
style={{ minWidth: 300, width: '80vw', maxWidth: 620 }}
columns={[
{ dataIndex: 'name', align: 'center', },
{ dataIndex: 'score', align: 'center', },
{ dataIndex: 'uid', align: 'center', render: (uid, { score }) => scoreDiff[uid] || score },
{ dataIndex: 'uid', align: 'right', render: (uid, { score }) => {
const diff = +(scoreDiff[uid] || score) - +score;
return diff > 0 ? `+${diff}` : diff;
} },
]}
/>
>
<Table
className="score-table"
showHeader={false}
pagination={false}
rowHoverable={false}
size="small"
dataSource={playerList}
rowKey={e => e.uid}
style={{ width: '100%' }}
columns={[
{ dataIndex: 'name', align: 'center', },
{ dataIndex: 'score', align: 'center', },
{ dataIndex: 'uid', align: 'center', render: (uid, { score }) => scoreDiff[uid] || score },
{ dataIndex: 'uid', align: 'right', render: (uid, { score }) => {
const diff = +(scoreDiff[uid] || score) - +score;
return diff > 0 ? `+${diff}` : diff;
} },
]}
/>
</Card>
<Flex className="share-buttons" gap={24} align="center" justify="space-between">
<Flex flex={1}>
<Button block onClick={handleShareCode} icon={<ShareAltOutlined />}></Button>
</Flex>
<Flex flex={1}>
<Button block onClick={handleImportCodeClick} icon={<EditOutlined />}></Button>
</Flex>
</Flex>
</StyledContainer>
);
}

View File

@ -2,7 +2,8 @@ import { serve } from "bun";
import { getMatchInfo, listEvent, xcxApi } from "./utils/server";
import index from "./index.html";
import { getUidScore } from "./services/uidScoreStore";
import { checkIsUserFav, favPlayer, listFavPlayers, unFavPlayer } from "./services/FavPlayer";
import { checkIsUserFav, favPlayer, listFavPlayers, unFavPlayer } from "./services/favPlayerService";
import { BattleService } from "./services/BattleService";
const server = serve({
port: process.env.PORT || 3000,
@ -89,6 +90,21 @@ const server = serve({
await unFavPlayer(req.params.aud, req.params.uid);
return Response.json({ ok: 'ok' });
}
},
'/api/battle/:eventId': {
async PUT(req) {
const { data } = await req.json();
const eventId = req.params.eventId;
const code = await BattleService.publishBattle(eventId, data);
return Response.json({ code });
},
async GET(req) {
const code = new URL(req.url).searchParams.get('code');
const eventId = req.params.eventId;
if (!code) return Response.json({});
const data = await BattleService.getBattle(eventId, code);
return Response.json(data);
}
}
},

View File

@ -0,0 +1,22 @@
import Bun from "bun";
import { redis } from "../utils/server";
export class BattleService {
public static async publishBattle(eventId: string, jsonData: string) {
const hasher = new Bun.CryptoHasher('md5');
const hash = hasher.update(jsonData).digest('hex').slice(0, 6);
console.log(`Battle published with hash: ${hash}`);
await redis.setex(`my-kaiqiuwang:battle:${eventId}:${hash}`, 60 * 10, jsonData);
return hash;
}
public static async getBattle(eventId: string, hash: string) {
const battleData = await redis.get(`my-kaiqiuwang:battle:${eventId}:${hash}`);
if (!battleData) return {};
try {
return JSON.parse(battleData);
} catch (e) {
console.error("Error", e);
return {};
}
}
}