mirror of
https://gitcode.com/gh_mirrors/hy/Hydro.git
synced 2026-10-17 09:36:08 +08:00
core&ui: contest: acm: lock scoreboard
This commit is contained in:
1 parent
cb02b8dafe
commit
e67ff511bb
17 files changed
+224
-196
No files matched your search
+6
-6
@@ -28,27 +28,27 @@
|
||||
"license": "AGPL-3.0-only",
|
||||
"devDependencies": {
|
||||
"@types/cross-spawn": "^6.0.2",
|
||||
"@types/node": "^16.11.19",
|
||||
"@types/node": "^16.11.21",
|
||||
"@types/semver": "^7.3.9",
|
||||
"@typescript-eslint/eslint-plugin": "5.9.0",
|
||||
"@typescript-eslint/parser": "5.9.0",
|
||||
"@typescript-eslint/eslint-plugin": "5.10.0",
|
||||
"@typescript-eslint/parser": "5.10.0",
|
||||
"cac": "^6.7.12",
|
||||
"cross-env": "^7.0.3",
|
||||
"cross-spawn": "^7.0.3",
|
||||
"esbuild": "0.14.3",
|
||||
"eslint": "^8.6.0",
|
||||
"eslint": "^8.7.0",
|
||||
"eslint-config-airbnb-typescript": "12.3.1",
|
||||
"eslint-import-resolver-typescript": "2.5.0",
|
||||
"eslint-plugin-import": "2.25.4",
|
||||
"eslint-plugin-simple-import-sort": "7.0.0",
|
||||
"fs-extra": "^10.0.0",
|
||||
"globby": "11.0.4",
|
||||
"globby": "11.1.0",
|
||||
"latest-version": "^6.0.0",
|
||||
"mongodb": "^3.7.3",
|
||||
"nmls": "^3.0.1",
|
||||
"ora": "5.4.1",
|
||||
"semver": "^7.3.5",
|
||||
"typedoc": "^0.22.10",
|
||||
"typedoc": "^0.22.11",
|
||||
"typescript": "4.4.3"
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "hydrooj",
|
||||
"version": "3.3.20",
|
||||
"version": "3.4.0",
|
||||
"bin": "bin/hydrooj.js",
|
||||
"main": "src/loader",
|
||||
"module": "src/loader",
|
||||
@@ -22,7 +22,7 @@
|
||||
"detect-browser": "^5.3.0",
|
||||
"esbuild": "0.14.3",
|
||||
"fs-extra": "^10.0.0",
|
||||
"graphql": "^16.2.0",
|
||||
"graphql": "^16.3.0",
|
||||
"graphql-scalars": "1.14.1",
|
||||
"js-yaml": "^4.1.0",
|
||||
"koa": "^2.13.4",
|
||||
@@ -40,7 +40,7 @@
|
||||
"nanoid": "^3.2.0",
|
||||
"nodemailer": "^6.7.2",
|
||||
"notp": "^2.0.3",
|
||||
"p-queue": "^7.1.0",
|
||||
"p-queue": "^7.2.0",
|
||||
"reflect-metadata": "^0.1.13",
|
||||
"require-resolve-hook": "^1.1.0",
|
||||
"semver": "^7.3.5",
|
||||
@@ -62,7 +62,7 @@
|
||||
"@types/lodash": "^4.14.178",
|
||||
"@types/lru-cache": "^5.1.1",
|
||||
"@types/mime-types": "^2.1.1",
|
||||
"@types/minio": "^7.0.11",
|
||||
"@types/minio": "^7.0.12",
|
||||
"@types/mongodb": "^3.6.20",
|
||||
"@types/nodemailer": "^6.4.4",
|
||||
"@types/notp": "^2.0.1",
|
||||
@@ -70,7 +70,7 @@
|
||||
"@types/serialize-javascript": "^5.0.2",
|
||||
"@types/sockjs": "^0.3.33",
|
||||
"@types/source-map-support": "^0.5.4",
|
||||
"@types/superagent": "^4.1.14",
|
||||
"@types/superagent": "^4.1.15",
|
||||
"moment": "^2.29.1",
|
||||
"pm2": "^5.1.2"
|
||||
},
|
||||
|
||||
@@ -47,13 +47,18 @@ registerResolver(
|
||||
'Get a contest by ID',
|
||||
);
|
||||
|
||||
TaskModel.Worker.addHandler('contest.problemHide', async (doc) => {
|
||||
TaskModel.Worker.addHandler('contest', async (doc) => {
|
||||
const tdoc = await contest.get(doc.domainId, doc.tid);
|
||||
if (!tdoc) return;
|
||||
for (const pid of tdoc.pids) {
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
await problem.edit(doc.domainId, pid, { hidden: false });
|
||||
const tasks = [];
|
||||
for (const op of doc.operation) {
|
||||
if (op === 'unhide') {
|
||||
for (const pid of tdoc.pids) {
|
||||
tasks.push(problem.edit(doc.domainId, pid, { hidden: false }));
|
||||
}
|
||||
} else if (op === 'unlock') tasks.push(contest.recalcStatus(doc.domainId, doc.tid));
|
||||
}
|
||||
await Promise.all(tasks);
|
||||
});
|
||||
|
||||
export class ContestListHandler extends Handler {
|
||||
@@ -129,7 +134,7 @@ export class ContestDetailHandler extends Handler {
|
||||
if (!this.user.own(tdoc)) this.checkPerm(PERM.PERM_EDIT_CONTEST);
|
||||
await contest.del(domainId, tid);
|
||||
await TaskModel.deleteMany({
|
||||
type: 'schedule', subType: 'contest.problemHide', domainId, tid,
|
||||
type: 'schedule', subType: 'contest', domainId, tid,
|
||||
});
|
||||
this.response.redirect = this.url('contest_main');
|
||||
}
|
||||
@@ -167,8 +172,13 @@ export class ContestBroadcastHandler extends Handler {
|
||||
export class ContestScoreboardHandler extends Handler {
|
||||
@param('tid', Types.ObjectID)
|
||||
@param('page', Types.PositiveInt, true)
|
||||
async get(domainId: string, tid: ObjectID, page = 1) {
|
||||
const [tdoc, rows, udict, , nPages] = await contest.getScoreboard.call(this, domainId, tid, false, page);
|
||||
@param('ignoreLock', Types.Boolean, true)
|
||||
async get(domainId: string, tid: ObjectID, page = 1, ignoreLock = false) {
|
||||
const tdoc = await contest.get(domainId, tid);
|
||||
if (ignoreLock && !this.user.own(tdoc)) {
|
||||
this.checkPerm(PERM.PERM_VIEW_CONTEST_HIDDEN_SCOREBOARD);
|
||||
}
|
||||
const [, rows, udict, , nPages] = await contest.getScoreboard.call(this, domainId, tid, false, page, ignoreLock);
|
||||
const path = [
|
||||
['Hydro', 'homepage'],
|
||||
['contest_main', 'contest_main'],
|
||||
@@ -185,13 +195,18 @@ export class ContestScoreboardHandler extends Handler {
|
||||
export class ContestScoreboardDownloadHandler extends Handler {
|
||||
@param('tid', Types.ObjectID)
|
||||
@param('ext', Types.Range(['csv', 'html']))
|
||||
async get(domainId: string, tid: ObjectID, ext: string) {
|
||||
@param('ignoreLock', Types.Boolean, true)
|
||||
async get(domainId: string, tid: ObjectID, ext: string, ignoreLock = false) {
|
||||
await this.limitRate('scoreboard_download', 120, 3);
|
||||
const getContent = {
|
||||
csv: async (rows) => `\uFEFF${rows.map((c) => (c.map((i) => i.value).join(','))).join('\n')}`,
|
||||
html: (rows, tdoc) => this.renderHTML('contest_scoreboard_download_html.html', { rows, tdoc }),
|
||||
};
|
||||
const [tdoc, rows] = await contest.getScoreboard.call(this, domainId, tid, true, 0);
|
||||
const tdoc = await contest.get(domainId, tid);
|
||||
if (ignoreLock && !this.user.own(tdoc)) {
|
||||
this.checkPerm(PERM.PERM_VIEW_CONTEST_HIDDEN_SCOREBOARD);
|
||||
}
|
||||
const [, rows] = await contest.getScoreboard.call(this, domainId, tid, true, 0, ignoreLock);
|
||||
this.binary(await getContent[ext](rows, tdoc), `${tdoc.title}.${ext}`);
|
||||
}
|
||||
}
|
||||
@@ -240,10 +255,11 @@ export class ContestEditHandler extends Handler {
|
||||
@param('code', Types.String, true)
|
||||
@param('autoHide', Types.String, true)
|
||||
@param('assign', Types.CommaSeperatedArray, true)
|
||||
@param('lock', Types.UnsignedInt, true)
|
||||
async post(
|
||||
domainId: string, tid: ObjectID, beginAtDate: string, beginAtTime: string, duration: number,
|
||||
title: string, content: string, rule: string, _pids: string, rated = false,
|
||||
_code = '', autoHide = false, assign: string[] = null,
|
||||
_code = '', autoHide = false, assign: string[] = null, lock: number = null,
|
||||
) {
|
||||
if (autoHide) this.checkPerm(PERM.PERM_EDIT_PROBLEM);
|
||||
const pids = _pids.replace(/,/g, ',').split(',').map((i) => +i).filter((i) => i);
|
||||
@@ -252,20 +268,22 @@ export class ContestEditHandler extends Handler {
|
||||
const endAt = beginAtMoment.clone().add(duration, 'hours').toDate();
|
||||
if (beginAtMoment.isSameOrAfter(endAt)) throw new ValidationError('duration');
|
||||
const beginAt = beginAtMoment.toDate();
|
||||
const lockAt = lock ? moment(endAt).add(-lock, 'minutes').toDate() : null;
|
||||
await problem.getList(domainId, pids, this.user.hasPerm(PERM.PERM_VIEW_PROBLEM_HIDDEN) || this.user._id, this.user.group, true);
|
||||
if (tid) {
|
||||
await contest.edit(domainId, tid, {
|
||||
title, content, rule, beginAt, endAt, pids, rated,
|
||||
});
|
||||
if (this.tdoc.beginAt !== beginAt || this.tdoc.endAt !== endAt
|
||||
|| Array.isDiff(this.tdoc.pids, pids) || this.tdoc.rule !== rule) {
|
||||
|| Array.isDiff(this.tdoc.pids, pids) || this.tdoc.rule !== rule
|
||||
|| lockAt !== this.tdoc.lockAt) {
|
||||
await contest.recalcStatus(domainId, this.tdoc.docId);
|
||||
}
|
||||
} else {
|
||||
tid = await contest.add(domainId, title, content, this.user._id, rule, beginAt, endAt, pids, rated);
|
||||
}
|
||||
const task = {
|
||||
type: 'schedule', subType: 'contest.problemHide', domainId, tid,
|
||||
type: 'schedule', subType: 'contest', domainId, tid,
|
||||
};
|
||||
await TaskModel.deleteMany(task);
|
||||
if (Date.now() <= endAt.getTime() && autoHide) {
|
||||
@@ -273,10 +291,20 @@ export class ContestEditHandler extends Handler {
|
||||
await Promise.all(pids.map((pid) => problem.edit(domainId, pid, { hidden: true })));
|
||||
await TaskModel.add({
|
||||
...task,
|
||||
operation: ['unhide'],
|
||||
executeAfter: endAt,
|
||||
});
|
||||
}
|
||||
await contest.edit(domainId, tid, { assign, _code, autoHide });
|
||||
if (lock && lockAt <= endAt) {
|
||||
await TaskModel.add({
|
||||
...task,
|
||||
operation: ['unlock'],
|
||||
executeAfter: endAt,
|
||||
});
|
||||
}
|
||||
await contest.edit(domainId, tid, {
|
||||
assign, _code, autoHide, lockAt,
|
||||
});
|
||||
this.response.body = { tid };
|
||||
this.response.redirect = this.url('contest_detail', { tid });
|
||||
}
|
||||
|
||||
@@ -348,6 +348,9 @@ export interface Tdoc<docType = document['TYPE_CONTEST'] | document['TYPE_TRAINI
|
||||
_code?: string;
|
||||
assign?: string[];
|
||||
|
||||
// For contest
|
||||
lockAt?: Date;
|
||||
|
||||
// For homework
|
||||
penaltySince?: Date;
|
||||
penaltyRules?: PenaltyRules;
|
||||
@@ -478,19 +481,21 @@ export interface ContestStat extends Record<string, any> {
|
||||
detail: any,
|
||||
}
|
||||
|
||||
export interface ContestRule {
|
||||
export interface ContestRule<T = any> {
|
||||
_originalRule?: Partial<ContestRule<T>>;
|
||||
TEXT: string;
|
||||
check: (args: any) => any;
|
||||
statusSort: any;
|
||||
submitAfterAccept: boolean;
|
||||
showScoreboard: (tdoc: Tdoc<30>, now: Date) => boolean;
|
||||
showSelfRecord: (tdoc: Tdoc<30>, now: Date) => boolean;
|
||||
showRecord: (tdoc: Tdoc<30>, now: Date) => boolean;
|
||||
stat: (tdoc: Tdoc<30>, journal: any[]) => ContestStat;
|
||||
stat: (this: ContestRule<T>, tdoc: Tdoc<30>, journal: any[], ignoreLock?: boolean) => ContestStat & T;
|
||||
scoreboard: (
|
||||
isExport: boolean, _: (s: string) => string,
|
||||
tdoc: Tdoc<30>, pdict: ProblemDict, cursor: Cursor<any>, page: number,
|
||||
this: ContestRule<T>, isExport: boolean, _: (s: string) => string,
|
||||
tdoc: Tdoc<30>, pdict: ProblemDict, cursor: Cursor<ContestStat & T>, page: number,
|
||||
) => Promise<[board: ScoreboardRow[], udict: Udict, nPages: number]>;
|
||||
ranked: (tdoc: Tdoc<30>, cursor: Cursor<any>) => Promise<any[]>;
|
||||
ranked: (tdoc: Tdoc<30>, cursor: Cursor<ContestStat & T>) => Promise<[Array<[number, ContestStat & T]>, number]>;
|
||||
}
|
||||
|
||||
export type ContestRules = Dictionary<ContestRule>;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { FilterQuery, ObjectID } from 'mongodb';
|
||||
import { Counter } from '@hydrooj/utils/lib/utils';
|
||||
import {
|
||||
ContestAlreadyAttendedError, ContestNotAttendedError, ContestNotFoundError,
|
||||
ContestScoreboardHiddenError, ValidationError,
|
||||
@@ -11,6 +12,7 @@ import {
|
||||
import * as misc from '../lib/misc';
|
||||
import ranked from '../lib/rank';
|
||||
import * as bus from '../service/bus';
|
||||
import type { Handler } from '../service/server';
|
||||
import { PERM, STATUS } from './builtin';
|
||||
import * as document from './document';
|
||||
import problem from './problem';
|
||||
@@ -29,41 +31,51 @@ interface AcmDetail extends AcmJournal {
|
||||
real: number;
|
||||
}
|
||||
|
||||
const acm: ContestRule = {
|
||||
function buildContestRule<T>(def: ContestRule<T>): ContestRule<T> {
|
||||
def._originalRule = { scoreboard: def.scoreboard, stat: def.stat };
|
||||
def.scoreboard = (def._originalRule?.scoreboard || def.scoreboard).bind(def);
|
||||
def.stat = (def._originalRule?.stat || def.stat).bind(def);
|
||||
return def;
|
||||
}
|
||||
|
||||
function filterEffective<T extends AcmJournal>(tdoc: Tdoc, journal: T[], ignoreLock = false): T[] {
|
||||
// eslint-disable-next-line @typescript-eslint/no-use-before-define
|
||||
if (isLocked(tdoc) && !ignoreLock) journal = journal.filter((i) => i.rid.generationTime * 1000 < tdoc.lockAt.getTime());
|
||||
return journal.filter((i) => tdoc.pids.includes(i.pid));
|
||||
}
|
||||
|
||||
const acm = buildContestRule({
|
||||
TEXT: 'ACM/ICPC',
|
||||
check: () => { },
|
||||
statusSort: { accept: -1, time: 1 },
|
||||
submitAfterAccept: false,
|
||||
showScoreboard: () => true,
|
||||
showSelfRecord: () => true,
|
||||
showRecord: (tdoc, now) => now > tdoc.endAt,
|
||||
stat: (tdoc, journal: AcmJournal[]) => {
|
||||
const naccept: Record<number, number> = {};
|
||||
stat(tdoc, journal: AcmJournal[], ignoreLock = false) {
|
||||
const naccept = Counter<number>();
|
||||
const effective: Record<number, AcmJournal> = {};
|
||||
const detail: AcmDetail[] = [];
|
||||
let accept = 0;
|
||||
let time = 0;
|
||||
for (const j of journal) {
|
||||
if (tdoc.pids.includes(j.pid)
|
||||
&& effective[j.pid]?.status !== STATUS.STATUS_ACCEPTED) {
|
||||
effective[j.pid] = j;
|
||||
if (![STATUS.STATUS_ACCEPTED, STATUS.STATUS_COMPILE_ERROR].includes(j.status)) {
|
||||
naccept[j.pid] = (naccept[j.pid] || 0) + 1;
|
||||
}
|
||||
for (const j of filterEffective(tdoc, journal, ignoreLock)) {
|
||||
if (!this.submitAfterAccept && effective[j.pid]?.status === STATUS.STATUS_ACCEPTED) continue;
|
||||
effective[j.pid] = j;
|
||||
if (![STATUS.STATUS_ACCEPTED, STATUS.STATUS_COMPILE_ERROR].includes(j.status)) {
|
||||
naccept[j.pid]++;
|
||||
}
|
||||
}
|
||||
for (const key in effective) {
|
||||
const j = effective[key];
|
||||
for (const pid in effective) {
|
||||
const j = effective[pid];
|
||||
const real = j.rid.generationTime - Math.floor(tdoc.beginAt.getTime() / 1000);
|
||||
const penalty = 20 * 60 * (naccept[j.pid] || 0);
|
||||
const penalty = 20 * 60 * naccept[j.pid];
|
||||
detail.push({
|
||||
...j, naccept: naccept[j.pid] || 0, time: real + penalty, real, penalty,
|
||||
...j, naccept: naccept[j.pid], time: real + penalty, real, penalty,
|
||||
});
|
||||
}
|
||||
for (const d of detail) {
|
||||
if (d.status === STATUS.STATUS_ACCEPTED) {
|
||||
accept += 1;
|
||||
time += d.time;
|
||||
}
|
||||
for (const d of detail.filter((i) => i.status === STATUS.STATUS_ACCEPTED)) {
|
||||
accept++;
|
||||
time += d.time;
|
||||
}
|
||||
return { accept, time, detail };
|
||||
},
|
||||
@@ -80,7 +92,6 @@ const acm: ContestRule = {
|
||||
columns.push(
|
||||
{ type: 'total_time', value: _('Penalty') },
|
||||
{ type: 'total_time', value: _('Total Time (Seconds)') },
|
||||
{ type: 'total_time_str', value: _('Total Time') },
|
||||
);
|
||||
}
|
||||
for (let i = 1; i <= tdoc.pids.length; i++) {
|
||||
@@ -108,41 +119,43 @@ const acm: ContestRule = {
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Find first accept
|
||||
const first = {};
|
||||
for (const pid of tdoc.pids) first[pid] = new ObjectID().generationTime;
|
||||
for (const [, tsdoc] of rankedTsdocs) {
|
||||
const tsddict = {};
|
||||
for (const item of tsdoc.journal || []) tsddict[item.pid] = item;
|
||||
for (const item of tsdoc.detail || []) tsddict[item.pid] = item;
|
||||
for (const pid of tdoc.pids) {
|
||||
if (tsddict[pid]?.status === STATUS.STATUS_ACCEPTED && tsddict[pid].rid.generationTime < first[pid]) {
|
||||
first[pid] = tsddict[pid].rid.generationTime;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const rows: ScoreboardRow[] = [columns];
|
||||
for (const [rank, tsdoc] of rankedTsdocs) {
|
||||
const tsddict = {};
|
||||
for (const item of tsdoc.detail || []) tsddict[item.pid] = item;
|
||||
const tsddict: Record<number, AcmDetail> = {};
|
||||
for (const item of tsdoc.detail) tsddict[item.pid] = item;
|
||||
const row: ScoreboardRow = [
|
||||
{ type: 'string', value: rank.toString() },
|
||||
{ type: 'user', value: udict[tsdoc.uid].uname, raw: tsdoc.uid },
|
||||
{ type: 'string', value: tsdoc.accept || 0 },
|
||||
];
|
||||
if (isExport) {
|
||||
const penalty = Math.sum(tdoc.pids.map((i) => (tsddict[i]?.naccept || 0))) * 20 * 60;
|
||||
const penalty = Math.sum(tdoc.pids.map((i) => tsddict[i]?.naccept || 0)) * 20 * 60;
|
||||
row.push(
|
||||
{ type: 'string', value: penalty.toString() },
|
||||
{ type: 'string', value: tsdoc.time || 0.0 },
|
||||
{ type: 'string', value: tsdoc.time || 0.0 },
|
||||
);
|
||||
}
|
||||
for (const pid of tdoc.pids) {
|
||||
const doc = tsddict[pid] || {};
|
||||
const doc = tsddict[pid] || {} as Partial<AcmDetail>;
|
||||
const accept = doc.status === STATUS.STATUS_ACCEPTED;
|
||||
const rid = accept ? doc.rid : null;
|
||||
const colAccepted = `${(accept && isExport) ? `${_('Accepted')} ` : ''}${doc.naccept ? ` (-${doc.naccept})` : ''}`;
|
||||
const colTime = accept ? doc.time : '-';
|
||||
const colTimeStr = accept ? misc.formatSeconds(colTime) : '-';
|
||||
const colTime = accept ? doc.time.toString() : '-';
|
||||
const colTimeStr = accept ? misc.formatSeconds(doc.time) : '-';
|
||||
if (isExport) {
|
||||
row.push(
|
||||
{ type: 'string', value: colAccepted },
|
||||
@@ -161,7 +174,6 @@ const acm: ContestRule = {
|
||||
});
|
||||
}
|
||||
}
|
||||
row.raw = tsdoc;
|
||||
rows.push(row);
|
||||
}
|
||||
return [rows, udict, nPages];
|
||||
@@ -169,17 +181,18 @@ const acm: ContestRule = {
|
||||
async ranked(tdoc, cursor) {
|
||||
return await ranked.all(cursor, (a, b) => a.score === b.score);
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const oi: ContestRule = {
|
||||
const oi = buildContestRule({
|
||||
TEXT: 'OI',
|
||||
check: () => { },
|
||||
submitAfterAccept: true,
|
||||
statusSort: { score: -1 },
|
||||
stat: (tdoc, journal) => {
|
||||
stat(tdoc, journal) {
|
||||
const detail = {};
|
||||
let score = 0;
|
||||
for (const j of journal) {
|
||||
if (tdoc.pids.includes(j.pid)) detail[j.pid] = j;
|
||||
for (const j of journal.filter((i) => tdoc.pids.includes(i.pid))) {
|
||||
if (detail[j.pid]?.status === STATUS.STATUS_ACCEPTED && !this.submitAfterAccept) detail[j.pid] = j;
|
||||
}
|
||||
for (const i in detail) score += detail[i].score;
|
||||
return { score, detail };
|
||||
@@ -278,19 +291,21 @@ const oi: ContestRule = {
|
||||
async ranked(tdoc, cursor) {
|
||||
return await ranked.all(cursor, (a, b) => a.score === b.score);
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const ioi: ContestRule = {
|
||||
const ioi = buildContestRule({
|
||||
...oi,
|
||||
TEXT: 'IOI',
|
||||
submitAfterAccept: false,
|
||||
showRecord: (tdoc, now) => now > tdoc.endAt,
|
||||
showSelfRecord: () => true,
|
||||
showScoreboard: () => true,
|
||||
};
|
||||
});
|
||||
|
||||
const homework: ContestRule = {
|
||||
const homework = buildContestRule({
|
||||
TEXT: 'Assignment',
|
||||
check: () => { },
|
||||
submitAfterAccept: false,
|
||||
statusSort: { penaltyScore: -1, time: 1 },
|
||||
stat: (tdoc, journal) => {
|
||||
const effective = {};
|
||||
@@ -434,7 +449,7 @@ const homework: ContestRule = {
|
||||
async ranked(tdoc, cursor) {
|
||||
return await ranked.all(cursor, (a, b) => a.score === b.score);
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
export const RULES: ContestRules = {
|
||||
acm, oi, homework, ioi,
|
||||
@@ -449,12 +464,12 @@ export async function add(
|
||||
rule: string, beginAt = new Date(), endAt = new Date(), pids: number[] = [],
|
||||
rated = false, data: Partial<Tdoc> = {},
|
||||
) {
|
||||
if (!this.RULES[rule]) throw new ValidationError('rule');
|
||||
if (!RULES[rule]) throw new ValidationError('rule');
|
||||
if (beginAt >= endAt) throw new ValidationError('beginAt', 'endAt');
|
||||
Object.assign(data, {
|
||||
content, owner, title, rule, beginAt, endAt, pids, attend: 0,
|
||||
});
|
||||
this.RULES[rule].check(data);
|
||||
RULES[rule].check(data);
|
||||
await bus.serial('contest/before-add', data);
|
||||
const res = await document.add(domainId, content, owner, document.TYPE_CONTEST, null, null, null, {
|
||||
...data, title, rule, beginAt, endAt, pids, attend: 0, rated,
|
||||
@@ -464,10 +479,10 @@ export async function add(
|
||||
}
|
||||
|
||||
export async function edit(domainId: string, tid: ObjectID, $set: any) {
|
||||
if ($set.rule && !this.RULES[$set.rule]) throw new ValidationError('rule');
|
||||
if ($set.rule && !RULES[$set.rule]) throw new ValidationError('rule');
|
||||
const tdoc = await document.get(domainId, document.TYPE_CONTEST, tid);
|
||||
if (!tdoc) throw new ContestNotFoundError(domainId, tid);
|
||||
this.RULES[$set.rule || tdoc.rule].check(Object.assign(tdoc, $set));
|
||||
RULES[$set.rule || tdoc.rule].check(Object.assign(tdoc, $set));
|
||||
return await document.set(domainId, document.TYPE_CONTEST, tid, $set);
|
||||
}
|
||||
|
||||
@@ -489,8 +504,14 @@ export async function getRelated(domainId: string, pid: number) {
|
||||
return await document.getMulti(domainId, document.TYPE_CONTEST, { pids: pid }).toArray();
|
||||
}
|
||||
|
||||
export function getStatus(domainId: string, tid: ObjectID, uid: number) {
|
||||
return document.getStatus(domainId, document.TYPE_CONTEST, tid, uid);
|
||||
export async function getStatus(domainId: string, tid: ObjectID, uid: number) {
|
||||
const [tdoc, status] = await Promise.all([
|
||||
get(domainId, tid),
|
||||
document.getStatus(domainId, document.TYPE_CONTEST, tid, uid),
|
||||
]);
|
||||
// eslint-disable-next-line @typescript-eslint/no-use-before-define
|
||||
if (isLocked(tdoc)) Object.assign(status, RULES[tdoc.rule].stat(tdoc, status.journal || [], true));
|
||||
return status;
|
||||
}
|
||||
|
||||
export async function updateStatus(
|
||||
@@ -556,6 +577,12 @@ export function isDone(tdoc: Tdoc) {
|
||||
return tdoc.endAt <= new Date();
|
||||
}
|
||||
|
||||
export function isLocked(tdoc: Tdoc) {
|
||||
if (!tdoc.lockAt) return false;
|
||||
const now = new Date();
|
||||
return (tdoc.lockAt < now && now < tdoc.endAt);
|
||||
}
|
||||
|
||||
export function isExtended(tdoc: Tdoc) {
|
||||
const now = new Date().getTime();
|
||||
return tdoc.penaltySince.getTime() <= now && now < tdoc.endAt.getTime();
|
||||
@@ -627,11 +654,12 @@ export function canShowScoreboard(tdoc: Tdoc<30>, allowPermOverride = true) {
|
||||
}
|
||||
|
||||
export async function getScoreboard(
|
||||
domainId: string, tid: ObjectID,
|
||||
isExport = false, page: number,
|
||||
this: Handler, domainId: string, tid: ObjectID,
|
||||
isExport = false, page: number, ignoreLock = false,
|
||||
): Promise<[Tdoc<30 | 60>, ScoreboardRow[], Udict, ProblemDict, number]> {
|
||||
const tdoc = await get(domainId, tid);
|
||||
if (!canShowScoreboard.call(this, tdoc)) throw new ContestScoreboardHiddenError(tid);
|
||||
if (ignoreLock) delete tdoc.lockAt;
|
||||
const tsdocsCursor = getMultiStatus(domainId, { docId: tid }).sort(RULES[tdoc.rule].statusSort);
|
||||
const pdict = await problem.getList(domainId, tdoc.pids, true);
|
||||
const [rows, udict, nPages] = await RULES[tdoc.rule].scoreboard(
|
||||
@@ -684,6 +712,7 @@ global.Hydro.model.contest = {
|
||||
isNotStarted,
|
||||
isOngoing,
|
||||
isDone,
|
||||
isLocked,
|
||||
isExtended,
|
||||
statusText,
|
||||
getStatusText,
|
||||
|
||||
@@ -286,6 +286,7 @@ export class ProblemModel {
|
||||
group: string[] = [], doThrow = true, projection = ProblemModel.PROJECTION_PUBLIC,
|
||||
indexByDocIdOnly = false,
|
||||
): Promise<ProblemDict> {
|
||||
if (!pids?.length) return [];
|
||||
const r: Record<number, ProblemDoc> = {};
|
||||
const l: Record<string, ProblemDoc> = {};
|
||||
const q: any = { docId: { $in: pids } };
|
||||
|
||||
@@ -70,7 +70,10 @@ class WorkerService implements BaseService {
|
||||
try {
|
||||
logger.debug('Worker task: %o', doc);
|
||||
const start = Date.now();
|
||||
await this.handlers[doc.subType](doc);
|
||||
await Promise.race([
|
||||
this.handlers[doc.subType](doc),
|
||||
new Promise((resolve) => setTimeout(resolve, 300000)),
|
||||
]);
|
||||
const spent = Date.now() - start;
|
||||
if (spent > 500) logger.warn('Slow worker task (%d ms): %s', spent, doc);
|
||||
} catch (e) {
|
||||
|
||||
@@ -21,6 +21,7 @@ import problem from './model/problem';
|
||||
import RecordModel from './model/record';
|
||||
import StorageModel from './model/storage';
|
||||
import * as system from './model/system';
|
||||
import TaskModel from './model/task';
|
||||
import user from './model/user';
|
||||
import {
|
||||
iterateAllDomain, iterateAllProblem, iterateAllPsdoc, iterateAllUser,
|
||||
@@ -673,6 +674,14 @@ const scripts: UpgradeScript[] = [
|
||||
);
|
||||
return true;
|
||||
},
|
||||
async function _58_59() {
|
||||
const _FRESH_INSTALL_IGNORE = 1;
|
||||
const tasks = await db.collection('task').find({ type: 'schedule', subType: 'contest.problemHide' }).toArray();
|
||||
for (const task of tasks) {
|
||||
await TaskModel.add({ ...task, subType: 'contest', operation: ['unhide'] });
|
||||
}
|
||||
return true;
|
||||
},
|
||||
];
|
||||
|
||||
export default scripts;
|
||||
@@ -27,7 +27,7 @@ class ChangeThemeAction extends EditorAction {
|
||||
super({
|
||||
id: 'hydro.changeEditorTheme',
|
||||
label: i18n('Change Theme'),
|
||||
alias: i18n('Change Theme'),
|
||||
alias: [i18n('Change Theme'), 'Change Theme'],
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -663,6 +663,7 @@ Saving file...: 正在保存文件...
|
||||
score canceled: 成绩取消
|
||||
Score: 分数
|
||||
Scoreboard (Hidden): 成绩表 (隐藏)
|
||||
Scoreboard locked at {0}: 比赛已于 {0} 封榜。
|
||||
Scoreboard: 成绩表
|
||||
Scripts: 脚本
|
||||
Search: 搜索
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@hydrooj/ui-default",
|
||||
"version": "4.30.9",
|
||||
"version": "4.30.10",
|
||||
"author": "undefined <i@undefined.moe>",
|
||||
"license": "AGPL-3.0",
|
||||
"main": "hydro.js",
|
||||
@@ -25,15 +25,15 @@
|
||||
"chalk": "^4.1.2",
|
||||
"classnames": "^2.3.1",
|
||||
"clean-webpack-plugin": "^4.0.0",
|
||||
"clipboard": "^2.0.8",
|
||||
"clipboard": "^2.0.9",
|
||||
"copy-webpack-plugin": "^6.4.1",
|
||||
"css-loader": "^4.3.0",
|
||||
"diff-dom": "^4.2.3",
|
||||
"echarts": "^5.2.2",
|
||||
"echarts": "^5.3.0",
|
||||
"emojis-keywords": "2.0.0",
|
||||
"emojis-list": "2.1.0",
|
||||
"esbuild-loader": "^2.18.0",
|
||||
"eslint": "^8.7.0",
|
||||
"eslint": "^8.8.0",
|
||||
"eslint-config-airbnb": "^18.2.1",
|
||||
"eslint-import-resolver-webpack": "^0.13.2",
|
||||
"eslint-plugin-import": "^2.25.4",
|
||||
@@ -92,7 +92,7 @@
|
||||
"through2": "^4.0.2",
|
||||
"timeago-react": "^3.0.4",
|
||||
"timeago.js": "^4.0.2",
|
||||
"vditor": "^3.8.10",
|
||||
"vditor": "^3.8.11",
|
||||
"vinyl-buffer": "^1.0.1",
|
||||
"web-streams-polyfill": "^3.2.0",
|
||||
"webpack": "^4.46.0",
|
||||
@@ -116,7 +116,7 @@
|
||||
"markdown-it-table-of-contents": "^0.6.0",
|
||||
"mongodb": "^3.7.3",
|
||||
"nunjucks": "^3.2.3",
|
||||
"react-query": "^3.34.8",
|
||||
"react-query": "^3.34.12",
|
||||
"streamsaver": "^2.0.5",
|
||||
"xss": "^1.0.10"
|
||||
}
|
||||
|
||||
@@ -1,65 +1,16 @@
|
||||
import { NamedPage } from 'vj/misc/Page';
|
||||
import code from 'vj/components/highlighter/code-example';
|
||||
import i18n from 'vj/utils/i18n';
|
||||
import tpl from 'vj/utils/tpl';
|
||||
|
||||
const page = new NamedPage('home_preference', async () => {
|
||||
async function mountComponent() {
|
||||
const [{ default: prismjs }, React, { render }] = await Promise.all([
|
||||
import('vj/components/highlighter/prismjs'),
|
||||
import('react'),
|
||||
import('react-dom'),
|
||||
]);
|
||||
const page = new NamedPage('home_preference', () => {
|
||||
function setOptions($el, options) {
|
||||
$el.empty();
|
||||
$.each(options, (key, value) => {
|
||||
$el.append($('<option></option>').attr('value', key).text(value));
|
||||
});
|
||||
}
|
||||
|
||||
class AstylePreview extends React.PureComponent {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = { highlight: prismjs.highlight(this.preview, prismjs.Prism.languages.cpp, 'C++') };
|
||||
}
|
||||
|
||||
render() {
|
||||
return (
|
||||
<>
|
||||
<div className="medium-4 columns form__item end">
|
||||
<label htmlFor="astyleOptions_input">
|
||||
{i18n('astyleOptions')}
|
||||
<div id="astyleOptions_input" name="form_item_astyleOptions" className="textbox-container">
|
||||
<textarea
|
||||
type="textarea"
|
||||
spellCheck={false}
|
||||
name="astyleOptions"
|
||||
value={this.state.value}
|
||||
style={{ height: '400px' }}
|
||||
onChange={(ev) => {
|
||||
ev.stopPropagation();
|
||||
const highlight = prismjs.highlight(code, prismjs.Prism.languages.cpp, 'C++');
|
||||
this.setState({ value: ev.target.value, highlight });
|
||||
}}
|
||||
className="textbox"
|
||||
/>
|
||||
</div>
|
||||
</label>
|
||||
<p className="help-text">{i18n('astyle formatting options')}</p>
|
||||
</div>
|
||||
<div className="medium-8 columns form__item end">
|
||||
<label htmlFor="astylePreview">
|
||||
{i18n('Preview') /* eslint-disable-next-line */}
|
||||
<pre id="astylePreview" style={{ height: '400px' }}><code dangerouslySetInnerHTML={{ __html: this.state.highlight }}></code></pre>
|
||||
</label>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function setOptions($el, options) {
|
||||
$el.empty();
|
||||
$.each(options, (key, value) => {
|
||||
$el.append($('<option></option>').attr('value', key).text(value));
|
||||
});
|
||||
}
|
||||
|
||||
const $el = $(tpl`\
|
||||
const $el = $(tpl`\
|
||||
<div class="row">
|
||||
<div class="medium-5 columns form__item end">
|
||||
<label>
|
||||
@@ -79,50 +30,42 @@ const page = new NamedPage('home_preference', async () => {
|
||||
</div>
|
||||
</div>
|
||||
`);
|
||||
$('[name="codeLang"]')
|
||||
.parent().parent().parent()
|
||||
.parent()
|
||||
.hide()
|
||||
.after($el);
|
||||
$('[name="codeLang"]')
|
||||
.parent().parent().parent()
|
||||
.parent()
|
||||
.hide()
|
||||
.after($el);
|
||||
|
||||
function onChangeMain(update = true) {
|
||||
const options = {};
|
||||
for (const key in window.LANGS) {
|
||||
if (key.startsWith(`${this.value}.`) && key !== this.value) options[key] = window.LANGS[key].display;
|
||||
}
|
||||
if (Object.keys(options).length > 1) {
|
||||
setOptions($('#codelang-sub-select'), options);
|
||||
$('#codelang-sub-container').show();
|
||||
if (update) $('[name="codeLang"]').val($('#codelang-sub-select').val());
|
||||
} else {
|
||||
$('#codelang-sub-container').hide();
|
||||
if (update) $('[name="codeLang"]').val(this.value);
|
||||
}
|
||||
}
|
||||
const main = {};
|
||||
function onChangeMain(update = true) {
|
||||
const options = {};
|
||||
for (const key in window.LANGS) {
|
||||
if (!key.includes('.')) main[key] = window.LANGS[key].display;
|
||||
if (key.startsWith(`${this.value}.`) && key !== this.value) options[key] = window.LANGS[key].display;
|
||||
}
|
||||
if (Object.keys(options).length > 1) {
|
||||
setOptions($('#codelang-sub-select'), options);
|
||||
$('#codelang-sub-container').show();
|
||||
if (update) $('[name="codeLang"]').val($('#codelang-sub-select').val());
|
||||
} else {
|
||||
$('#codelang-sub-container').hide();
|
||||
if (update) $('[name="codeLang"]').val(this.value);
|
||||
}
|
||||
setOptions($('#codelang-main-select'), main);
|
||||
const current = $('[name="codeLang"]').val();
|
||||
if (current.includes('.')) {
|
||||
const [m] = current.split('.');
|
||||
$('#codelang-main-select').val(m);
|
||||
onChangeMain.call({ value: m }, false);
|
||||
$('#codelang-sub-select').val(current);
|
||||
} else $('#codelang-main-select').val(current);
|
||||
$('#codelang-main-select').on('change', onChangeMain);
|
||||
$('#codelang-sub-select').on('change', function () {
|
||||
$('[name="codeLang"]').val(this.value);
|
||||
});
|
||||
|
||||
render(
|
||||
<AstylePreview></AstylePreview>,
|
||||
$('[name="form_item_astyleOptions"]').get(0).parentNode.parentNode.parentNode,
|
||||
);
|
||||
}
|
||||
|
||||
mountComponent();
|
||||
const main = {};
|
||||
for (const key in window.LANGS) {
|
||||
if (!key.includes('.')) main[key] = window.LANGS[key].display;
|
||||
}
|
||||
setOptions($('#codelang-main-select'), main);
|
||||
const current = $('[name="codeLang"]').val();
|
||||
if (current.includes('.')) {
|
||||
const [m] = current.split('.');
|
||||
$('#codelang-main-select').val(m);
|
||||
onChangeMain.call({ value: m }, false);
|
||||
$('#codelang-sub-select').val(current);
|
||||
} else $('#codelang-main-select').val(current);
|
||||
$('#codelang-main-select').on('change', onChangeMain);
|
||||
$('#codelang-sub-select').on('change', function () {
|
||||
$('[name="codeLang"]').val(this.value);
|
||||
});
|
||||
});
|
||||
|
||||
export default page;
|
||||
@@ -40,16 +40,6 @@ formatCode:
|
||||
desc: Auto format code
|
||||
category: preference
|
||||
value: true
|
||||
astyleOptions:
|
||||
type: text
|
||||
family: highlight
|
||||
desc: astyle formatting options
|
||||
category: preference
|
||||
value: >
|
||||
style=java attach-namespaces attach-classes attach-inlines attach-extern-c attach-closing-while
|
||||
indent-col1-comments break-blocks pad-oper pad-comma pad-header unpad-paren align-pointer=name
|
||||
break-one-line-headers attach-return-type attach-return-type-decl convert-tabs close-templates
|
||||
max-code-length=110 break-after-logical
|
||||
about:
|
||||
type: markdown
|
||||
name: about
|
||||
|
||||
@@ -92,6 +92,9 @@
|
||||
disabled:not handler.user.hasPerm(perm.PERM_EDIT_PROBLEM),
|
||||
row:false
|
||||
}) }}
|
||||
{% if tdoc.lockAt %}
|
||||
<input type="hidden" name="lock" value="{{ ((tdoc.endAt - tdoc.lockAt)/60000)|int }}">
|
||||
{% endif %}
|
||||
</div>
|
||||
{{ form.form_textarea({
|
||||
columns:null,
|
||||
|
||||
@@ -12,6 +12,13 @@
|
||||
<span class="icon icon-download"></span> {{ _('Export as CSV') }}
|
||||
</a>
|
||||
</div>
|
||||
{% if model.contest.isLocked(tdoc) %}
|
||||
<div class="section__body no-padding">
|
||||
<blockquote class="note">
|
||||
{{ _('Scoreboard locked at {0}').format(datetimeSpan(tdoc.lockAt))|safe }}
|
||||
</blockquote>
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="section__body no-padding overflow-hidden-horizontal">
|
||||
<table class="data-table">
|
||||
<colgroup>
|
||||
|
||||
@@ -305,7 +305,7 @@ function _digit2(number: number) {
|
||||
return number < 10 ? `0${number}` : number.toString();
|
||||
}
|
||||
|
||||
export function formatSeconds(_seconds = '0') {
|
||||
export function formatSeconds(_seconds: string | number = '0') {
|
||||
const seconds = +_seconds;
|
||||
return '{0}:{1}:{2}'.format(
|
||||
_digit2(Math.floor(seconds / 3600)),
|
||||
@@ -431,11 +431,20 @@ export function sortFiles(files: { _id: string }[] | string[]) {
|
||||
return isString ? result.map((x) => x.name) : result;
|
||||
}
|
||||
|
||||
export const htmlEncode = (str) => str.replace(/[&<>'"]/g,
|
||||
(tag) => ({
|
||||
export const htmlEncode = (str: string) => str.replace(/[&<>'"]/g,
|
||||
(tag: string) => ({
|
||||
'&': '&',
|
||||
'<': '<',
|
||||
'>': '>',
|
||||
"'": ''',
|
||||
'"': '"',
|
||||
}[tag]));
|
||||
|
||||
export function Counter<T extends (string | number) = string>() {
|
||||
return new Proxy({}, {
|
||||
get: (target, prop) => {
|
||||
if (target[prop] === undefined) return 0;
|
||||
return target[prop];
|
||||
},
|
||||
}) as Record<T, number>;
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@hydrooj/utils",
|
||||
"version": "1.1.11",
|
||||
"version": "1.1.13",
|
||||
"description": "hydrooj utils",
|
||||
"main": "package.json",
|
||||
"repository": "https://github.com/hydro-dev/Hydro.git",
|
||||
@@ -12,7 +12,7 @@
|
||||
"js-yaml": "^4.1.0",
|
||||
"moment": "^2.29.1",
|
||||
"mongodb": "^3.7.3",
|
||||
"systeminformation": "^5.9.17"
|
||||
"systeminformation": "^5.11.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/fs-extra": "^9.0.13",
|
||||
|
||||
Reference in new issue
Block a user