Skip to content

Commit

Permalink
Merge branch 'master' into master
Browse files Browse the repository at this point in the history
  • Loading branch information
FranGuam authored Apr 7, 2024
2 parents 814a0d0 + 4ff80ed commit d9bc284
Show file tree
Hide file tree
Showing 19 changed files with 1,100 additions and 572 deletions.
4 changes: 2 additions & 2 deletions docs/code.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,8 +42,8 @@ permalink: /code
- `400``400 Bad Request: Interpreted language do not require compilation.`
- `400``400 Bad Request: Unsupported language.`
- `400``400 Bad Request: Code already compiled.`
- `401``401 Unauthorized: User not in team.`
- `401``401 Unauthorized: User and code not in the same team.`
- `403``403 Forbidden: User not in team`
- `403``403 Forbidden: User not in team`
- `409``409 Confilct: Code already in compilation`(代码正在或已编译)
- `500``undefined`(其他内部错误,返回报错信息)
- `/code/compile-finish`:代码完成编译的`hook`,在`docker`结束前调用。更新编译状态并保存可执行文件和`log`
Expand Down
29 changes: 21 additions & 8 deletions docs/contest.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,13 +25,13 @@ permalink: /contest
- 后端服务器存储空间有限,需要定期清理下载的队伍代码和文件。
- 后端服务器与`docker`服务器之间通过`NFS`进行文件共享,因此`docker`服务器自动同步了队伍文件。(备注:建议提前服务器之间组内网减少流量费。)
5. 前两步都执行成功的前提下,后端创建`docker`并入队`docker_queue`,向前端返回创建是否成功的结果。
- 对于一场比赛(两队参与为例),后端需要先后创建4个`docker`:一个`server`镜像对应的比赛逻辑服务器、两个`client`镜像对应的选手代码执行客户端(每队共用一个)一个`envoy`镜像对应的`grpc-web``grpc`转发服务器(用于前端直播,暂不急于实现)。
- 对于一场比赛(两队参与为例),后端需要先后创建4个`docker`:一个`server`镜像对应的比赛逻辑服务器、两个`client`镜像对应的选手代码执行客户端(每队共用一个)一个`envoy`镜像对应的`grpc-web``grpc`转发服务器(用于前端直播,暂不急于实现)。
- 比赛状态显示。后端创建 `docker` 分为两步:【第一步】是将比赛放入队列`docker_queue`尾,此时`room` -> `status``Waiting`;【第二步】是`docker_cron` 定时程序从队列中抽取队首的比赛进行,如果比赛启动成功,此时`room` -> `status``Running`
- 比赛期间,用户可通过特定端口观看直播。后端在上面所述启动比赛的【第二步】时分配好一个端口。如果端口数量不足,则不启动比赛。如果成功分配端口并启动比赛,则应同时更新数据库`contest_room`表中的`port`字段。
- 前端应当使用`subscription`实时更新比赛状态和直播观看端口。
6. `docker` 服务器结束比赛后请求后端`/arena/finish`路由。
- 后端更新数据库,更新`contest_room`表中的`status``Finished`、更新`port``NULL`;更新`contest_room_team`表中的`score`字段,为这场比赛的每个队伍记录分数
- 后端将比赛回放文件上传至 `cos`,具体路径参考[COS存储桶访问路径](https://eesast.github.io/web/cos)
- 后端将比赛回放文件以及日志文件(如有)上传至 `cos`,具体路径参考[COS存储桶访问路径](https://eesast.github.io/web/cos)
- 后端向参与这场比赛的队伍队员发送`Web Push`订阅通知(暂不急于实现)。
7. 比赛结束后,前端提供下载和在线观看回放的功能,直接按照[COS存储桶访问路径](https://eesast.github.io/web/cos)中约定的路径从`cos`下载对应的文件即可。

Expand All @@ -51,11 +51,15 @@ permalink: /contest
5. 后端在数据表`contest_room`中创建 `room`,更新`status``Waiting`,并在`contest_room_team`中绑定`room``team`,并返回创建是否成功的结果,以及`room_id`
6. 后端将比赛数据存入`docker_queue`中,等待`docker_cron`发起比赛。
- 错误:
- `400``400 Bad Request: Contest not found`
- `400``400 Bad Request: Players_label not found`
- `401``401 Unauthorized: Missing token`(未登录)
- `403``403 Forbidden: User not in team`(用户不在队伍中)
- `403``403 Forbidden: Arena is not open`
- `403``403 Forbidden: Team player not assigned `(队伍角色未分配代码)
- `403``403 Forbidden: Team code not compiled`(代码未通过编译)
- `404``404 Not Found: Team code not found in cos ``cos`上找不到文件)
- `403``403 Forbidden: Team code language not supported`
- `422``422 Unprocessable Entity: Duplicate team labels``team labels` 不能重复的原因在于会与 docker 内文件命名规则冲突 )
- `422``422 Unprocessable Entity: Missing credentials`(请求缺失参数)
- `423``423 Locked: Request arena too frequently`(比赛次数过多)
- `500``undefined`(其他内部错误)
Expand Down Expand Up @@ -96,19 +100,28 @@ permalink: /contest

新版比赛接口的前缀为`/competition`

- `/competition/start-all`:管理员专用。后端可以按`contest_round`表中的信息设置所有队伍之间的完整比赛,全部队伍的比赛合起来称为一个`round`,对应一个`round_id`。设置`room`发起对战的流程跟天梯逻辑一致,只需要在`contest_room`里额外加`round_id`标识即可
- `/competition/start-all`:管理员专用。后端可以按`contest_round`表中的信息设置所有队伍之间的完整比赛,全部队伍的比赛合起来称为一个`round`,对应一个`round_id`。设置`room`发起对战的流程跟天梯逻辑类似,需要在`contest_room`里额外加`round_id`标识
- 请求方法:`POST`
- 请求:`{round_id: uuid}`。(请求同时携带了包含用户信息的`token`
- 响应:`200``Competition Created!`
- 错误:
- `422``422 Unprocessable Entity: Missing credentials`(请求缺失参数)
- `403``403 Forbidden: Not a manager`
- `500``undefined`,返回报错信息
- `/competition/start-one`:管理员专用,用于重新发起`round`中某一场特定的比赛。后端需要先删除这场比赛的已有记录(包括数据库中、`cos`中),然后将比赛加入队列中。设置`room`发起对战的过程跟天梯逻辑一致,只需要在`contest_room`里额外加`round_id`标识即可。
- 请求方法:`POST`
- 请求:`{team_labels: TeamLabelBind[], map_id: uuid, round_id: uuid}`。(请求同时携带了包含用户信息的`token`
- 请求:`{team_labels: TeamLabelBind[], round_id: uuid}`。(请求同时携带了包含用户信息的`token`
- 响应:`200``Room Created!`
- 错误:
- `422``422 Unprocessable Entity: Missing credentials`(请求缺失参数)
- `400``400 Bad Request: Contest not found`
- `400``400 Bad Request: Players_label not found`
- `401``401 Unauthorized: Missing token`(未登录)
- `403``403 Forbidden: Not a manager`
- `403``403 Forbidden: Team player not assigned `(队伍角色未分配代码)
- `403``403 Forbidden: Team code not compiled`(代码未通过编译)
- `403``403 Forbidden: Team code language not supported`
- `422``422 Unprocessable Entity: Duplicate team labels`
- `422``422 Unprocessable Entity: Missing credentials`
- `500``undefined`,返回报错信息
- `/competition/get-score``docker`服务器比赛结束后,用于查询参战队伍现有比赛分数的路由,拿来计算本场对战的得分。后端查询数据库即可。
- 请求方法:`POST`
Expand All @@ -126,14 +139,14 @@ permalink: /contest
1. 一场比赛对应两个`docker`镜像、多个`docker`并行。其中`server`镜像为比赛逻辑服务器,`client`镜像为选手代码执行客户端(一队共用)。
2. 队式应当关注上面的`/arena/finish``/arena/get-score``/competition/finish-one``/competition/get-score`路由参数信息。

- `server`镜像启动时会设置环境变量`SCORE_URL`(即`/arena/get-score``/competition/get-score`)、`FINISH_URL`(即`/arena/finish``/competition/finish-one`)、`TOKEN``TEAM_LABELS``json`格式,类型为`TeamLabelBind[]`,定义见下方附录)。
- `server`镜像启动时会设置环境变量`SCORE_URL`(即`/arena/get-score``/competition/get-score`)、`FINISH_URL`(即`/arena/finish``/competition/finish-one`)、`TOKEN``TEAM_LABELS``json`格式,类型为`TeamLabelBind[]`,定义见下方附录)。
- 比赛结束后先请求`SCORE_URL`,获取参战队伍在天梯/比赛中的现有分数,请求时需要在`headers`中加上`TOKEN`
- 获得现有分数后,`docker`应当据此计算出本场对战的得分(增量,而非更新后的总分)
- 完成后再请求`FINISH_URL`,在请求的`body`中传回`result`(即上面计算出的得分),请求时需要在`headers`中加上`TOKEN`
- `client`镜像启动时会设置环境变量`TEAM_LABEL`,供容器得知该队比赛执方,类型定义见下方附录`TeamLabelBind`

3. `docker`目录绑定。
- 对于`server`镜像,地图文件在`/usr/local/map`下,命名为`${map_id}.txt`,回放文件请放在在`/usr/local/playback`下,命名为`playback.thuaipb`
- 对于`server`镜像,地图文件在`/usr/local/map`下,命名为`${map_id}.txt`,回放文件请放在在`/usr/local/output`下,命名为`playback.thuaipb`。如果需要上传日志文件,同样放在此目录下,命名为 `xxx.log`
- 对于`client`镜像,队伍代码在`/usr/local/code`下,命名为`${player_label}.${suffix}``player_label`为在数据库存储的字符串标签,可供赛事组预先定义,如`Student1`)。

## 附录
Expand Down
8 changes: 5 additions & 3 deletions docs/cos.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,11 @@ permalink: /cos
- 编译后的可执行文件:`${name}/code/${team_id}/${code_id}`
- 编译产生的日志文件:`${name}/code/${team_id}/${code_id}.log` 以及 `${name}/code/${team_id}/${code_id}.curl.log`
- 公告文件:`${name}/notice/${notice_id}/${filename}`
- 天梯回放:`${name}/arena/${room_id}/${filename}`
- (选择性实现) 后台比赛回放:`${name}/competition/${round_id}/${filename}`
- (选择性实现) 地图:`${name}/map/${filename}`
- 天梯回放:`${name}/arena/${room_id}/${room_id}.thuaipb`
- 天梯日志:`${name}/arena/${room_id}/${room_id}.log`
- 后台比赛回放:`${name}/competition/${round_id}/${room_id}/${room_id}.thuaipb`
- 后台比赛日志:`${name}/competition/${round_id}/${room_id}/${room_id}.log`
- 地图:`${name}/map/${map_id}/${map_id}.txt`

### Info页面相关文件

Expand Down
49 changes: 45 additions & 4 deletions src/app/ContestSite/IntroPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { useUrl } from "../../api/hooks/url";
import Markdown from "react-markdown";
import * as graphql from "@/generated/graphql";
import { ContestProps } from ".";
import dayjs from "dayjs";

const { Countdown } = Statistic;

Expand Down Expand Up @@ -32,14 +33,19 @@ const IntroPage: React.FC<ContestProps> = ({ mode, user }) => {
},
});

// TODO: 在这里插入获取时间线数据的代码

const { data: CountdownData } = graphql.useGetContestTimesQuery({
variables: {
contest_id: Contest_id,
},
});
const contestTimes = CountdownData?.contest_time || [];
/* ---------------- useEffect ---------------- */
useEffect(() => {
if (contestInfoError) {
message.error("简介加载失败");
}
}, [contestInfoError]);

/* ---------------- 页面组件 ---------------- */
return (
<Space
Expand Down Expand Up @@ -98,8 +104,43 @@ const IntroPage: React.FC<ContestProps> = ({ mode, user }) => {
</Col>
<Col span={8}>
<Card bordered={false}>
{/* TODO: 在这里插入时间线组件,相关设置详见 https://ant.design/components/timeline-cn */}
<Timeline />
<Timeline
items={contestTimes.map((contestTime) => {
// 检查比赛结束时间是否已经过去
const isCurrentEvent =
dayjs().isAfter(dayjs(contestTime.start)) &&
dayjs().isBefore(dayjs(contestTime.end));
const isPastEvent = dayjs().isAfter(dayjs(contestTime.end));

return {
color: isCurrentEvent
? "green"
: isPastEvent
? "grey"
: "blue", // 如果比赛已经结束,设置颜色为灰色,否则为蓝色
children: (
<>
<p
style={{
fontWeight: "bold",
fontSize: "larger",
color: isPastEvent ? "grey" : "inherit",
}}
>
{contestTime.event}
</p>
<p style={{ color: isPastEvent ? "grey" : "inherit" }}>
{dayjs(contestTime.start).format("YYYY-MM-DD")} ~{" "}
{dayjs(contestTime.end).format("YYYY-MM-DD")}
</p>
<p style={{ color: isPastEvent ? "grey" : "inherit" }}>
{contestTime.description}
</p>
</>
),
};
})}
/>
</Card>
</Col>
</Row>
Expand Down
10 changes: 5 additions & 5 deletions src/app/ContestSite/ListPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -346,11 +346,11 @@ const ListPage: React.FC<ContestProps> = ({ mode, user }) => {
<Layout>
<br />
{/* <Row>
<Col span={3}></Col>
<Col span={18}>
<Button onClick={() => setModalVisible(true)}>添加新比赛</Button>
</Col>
</Row> */}
<Col span={3}></Col>
<Col span={18}>
<Button>添加新比赛</Button>
</Col>
</Row> */}
<br />
<Row>
<Col span={3}></Col>
Expand Down
23 changes: 0 additions & 23 deletions src/app/ContestSite/ManagerPage.tsx

This file was deleted.

144 changes: 144 additions & 0 deletions src/app/ContestSite/ManagerPage/EditInfo.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
import React, { useEffect } from "react";
import {
Button,
Card,
DatePicker,
Form,
Input,
message,
Layout,
Select,
Typography,
} from "antd";
import dayjs from "dayjs";
import { useUrl } from "../../../api/hooks/url";
import * as graphql from "@/generated/graphql";

/* ---------------- 不随渲染刷新的常量 ---------------- */
const { Text } = Typography;
const EditInfoPage: React.FC<{}> = (props) => {
const { Option } = Select;
const [form] = Form.useForm();
const RangePicker: any = DatePicker.RangePicker;
const url = useUrl();
const Contest_id = url.query.get("contest");
/* ---------------- 从数据库获取数据的 Hooks ---------------- */
const {
data: contestInfoData,
error: contestInfoError,
refetch: refetchContestInfoData,
} = graphql.useGetContestInfoSuspenseQuery({
variables: {
contest_id: Contest_id,
},
});

const initialValues = {
contest_name: contestInfoData?.contest_by_pk?.contest_name || "",
contest_type: "THUAI",
description: contestInfoData?.contest_by_pk?.description || "",
time: [
dayjs(contestInfoData?.contest_by_pk?.start_date, "YYYY-MM-DD"), // 加一天
dayjs(contestInfoData?.contest_by_pk?.end_date, "YYYY-MM-DD"), // 加一天
],
};

const [UpdateContestInfo, { error: UpdateContestInfoError }] =
graphql.useUpdateContestInfoMutation();

const onFinish = async (record: any) => {
const newinfo = {
contest_id: Contest_id,
contest_name: record.contest_name,
name: record.contest_name,
description: record.description,
start_date: record.time[0].format("YYYY-MM-DD"),
end_date: record.time[1].format("YYYY-MM-DD"),
};
await UpdateContestInfo({
variables: newinfo,
});
await refetchContestInfoData();
if (!UpdateContestInfoError) {
message.info("比赛信息已更新");
}
};

useEffect(() => {
form.setFieldsValue(initialValues);
});

useEffect(() => {
if (contestInfoError) {
message.error("简介加载失败");
}
}, [contestInfoError]);

useEffect(() => {
if (UpdateContestInfoError) {
message.error("简介更新失败");
}
}, [UpdateContestInfoError]);
return (
<Layout>
<Card
hoverable
style={{
padding: "2vh 1vw",
}}
title={
<Text
css={`
font-size: xx-large;
font-weight: bold;
`}
>
比赛信息编辑
</Text>
}
>
<Form form={form} name="contest" onFinish={onFinish} preserve={false}>
<Form.Item
name="contest_name"
label="名称"
rules={[{ required: true, message: "请输入比赛名称" }]}
>
<Input allowClear />
</Form.Item>
<Form.Item
name="contest_type"
label="类型"
rules={[{ required: true, message: "请输入比赛类型" }]}
>
<Select style={{ width: "40%" }} allowClear>
<Option value="THUAI">THUAI</Option>
<Option value="Electronic-design">电子设计大赛</Option>
</Select>
</Form.Item>
<Form.Item
name="description"
label="描述"
className="form-item-description"
rules={[{ required: false, message: "请输入比赛描述" }]}
>
<Input.TextArea autoSize={{ minRows: 6, maxRows: 6 }} allowClear />
</Form.Item>
<Form.Item
name="time"
label="比赛时间"
rules={[{ required: true, message: "请输入比赛时间" }]}
>
<RangePicker />
</Form.Item>
<Form.Item>
<Button type="primary" htmlType="submit">
确认
</Button>
</Form.Item>
</Form>
</Card>
</Layout>
);
};

export default EditInfoPage;
Loading

0 comments on commit d9bc284

Please sign in to comment.