mirror of
https://gitcode.com/gh_mirrors/hy/Hydro.git
synced 2026-10-17 09:36:08 +08:00
Merge pull request #902 from hydro-dev/scoreboard-view
This commit is contained in:
11 files changed
+428
-137
No files matched your search
+3
-1
@@ -59,4 +59,6 @@ __*
|
||||
*.local
|
||||
|
||||
# Monokle
|
||||
.monokle
|
||||
.monokle
|
||||
|
||||
packages/*/public/assets
|
||||
@@ -25,6 +25,7 @@
|
||||
"adm-zip": "0.5.14",
|
||||
"cac": "^6.7.14",
|
||||
"cordis": "3.18.1",
|
||||
"csv-stringify": "^6.5.1",
|
||||
"emoji-regex": "^10.4.0",
|
||||
"emojis-list": "2.1.0",
|
||||
"fs-extra": "^11.2.0",
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import AdmZip from 'adm-zip';
|
||||
import { stringify as toCSV } from 'csv-stringify/sync';
|
||||
import { statSync } from 'fs-extra';
|
||||
import { pick } from 'lodash';
|
||||
import moment from 'moment-timezone';
|
||||
@@ -6,11 +7,11 @@ import { ObjectId } from 'mongodb';
|
||||
import {
|
||||
Counter, sortFiles, streamToBuffer, Time, yaml,
|
||||
} from '@hydrooj/utils/lib/utils';
|
||||
import type { Context } from '../context';
|
||||
import { Context, Service } from '../context';
|
||||
import {
|
||||
BadRequestError, ContestNotAttendedError, ContestNotEndedError, ContestNotFoundError, ContestNotLiveError,
|
||||
ContestScoreboardHiddenError, FileLimitExceededError, FileUploadError,
|
||||
InvalidTokenError, NotAssignedError, PermissionError, ValidationError,
|
||||
InvalidTokenError, NotAssignedError, NotFoundError, PermissionError, ValidationError,
|
||||
} from '../error';
|
||||
import { ScoreboardConfig, Tdoc } from '../interface';
|
||||
import { PERM, PRIV, STATUS } from '../model/builtin';
|
||||
@@ -26,7 +27,7 @@ import storage from '../model/storage';
|
||||
import * as system from '../model/system';
|
||||
import user from '../model/user';
|
||||
import {
|
||||
Handler, param, post, Types,
|
||||
Handler, param, post, Type, Types,
|
||||
} from '../service/server';
|
||||
|
||||
export class ContestListHandler extends Handler {
|
||||
@@ -238,115 +239,6 @@ export class ContestProblemListHandler extends ContestDetailBaseHandler {
|
||||
}
|
||||
}
|
||||
|
||||
export class ContestScoreboardHandler extends ContestDetailBaseHandler {
|
||||
@param('tid', Types.ObjectId)
|
||||
@param('ext', Types.Range(['csv', 'html', 'ghost']), true)
|
||||
@param('realtime', Types.Boolean)
|
||||
async get(domainId: string, tid: ObjectId, ext = '', realtime) {
|
||||
if (!contest.canShowScoreboard.call(this, this.tdoc, true)) throw new ContestScoreboardHiddenError(tid);
|
||||
if (contest.isNotStarted(this.tdoc)) throw new ContestNotLiveError(domainId, tid);
|
||||
if (realtime && !this.user.own(this.tdoc)) {
|
||||
this.checkPerm(PERM.PERM_VIEW_CONTEST_HIDDEN_SCOREBOARD);
|
||||
}
|
||||
if (ext) {
|
||||
await this.exportScoreboard(domainId, tid, ext);
|
||||
return;
|
||||
}
|
||||
const config: ScoreboardConfig = { isExport: false, showDisplayName: this.user.hasPerm(PERM.PERM_VIEW_DISPLAYNAME) };
|
||||
if (!realtime && this.tdoc.lockAt && !this.tdoc.unlocked) {
|
||||
config.lockAt = this.tdoc.lockAt;
|
||||
}
|
||||
const allGroups = (this.user.hasPerm(PERM.PERM_EDIT_CONTEST_SELF) && this.user.own(this.tdoc)) || this.user.hasPerm(PERM.PERM_EDIT_CONTEST);
|
||||
const [[, rows, udict, pdict], groups] = await Promise.all([
|
||||
contest.getScoreboard.call(this, domainId, tid, config),
|
||||
user.listGroup(domainId, allGroups ? undefined : this.user._id),
|
||||
]);
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
const page_name = this.tdoc.rule === 'homework'
|
||||
? 'homework_scoreboard'
|
||||
: 'contest_scoreboard';
|
||||
this.response.body = {
|
||||
tdoc: this.tdoc, tsdoc: this.tsdocAsPublic(), rows, udict, pdict, page_name, groups,
|
||||
};
|
||||
this.response.pjax = 'partials/scoreboard.html';
|
||||
this.response.template = 'contest_scoreboard.html';
|
||||
}
|
||||
|
||||
async exportGhost(domainId: string, tid: ObjectId) {
|
||||
const tdoc = this.tdoc;
|
||||
const [pdict, teams] = await Promise.all([
|
||||
problem.getList(domainId, tdoc.pids, true, false, problem.PROJECTION_LIST, true),
|
||||
contest.getMultiStatus(domainId, { docId: tid }).toArray(),
|
||||
]);
|
||||
const udict = await user.getList(domainId, teams.map((i) => i.uid));
|
||||
const teamIds: Record<number, number> = {};
|
||||
for (let i = 1; i <= teams.length; i++) teamIds[teams[i - 1].uid] = i;
|
||||
const time = (t: ObjectId) => Math.floor((t.getTimestamp().getTime() - tdoc.beginAt.getTime()) / Time.second);
|
||||
const pid = (i: number) => String.fromCharCode(65 + i);
|
||||
const escape = (i: string) => i.replace(/[",]/g, '');
|
||||
const unknownSchool = this.translate('Unknown School');
|
||||
const statusMap = {
|
||||
[STATUS.STATUS_ACCEPTED]: 'AC',
|
||||
[STATUS.STATUS_WRONG_ANSWER]: 'WA',
|
||||
[STATUS.STATUS_COMPILE_ERROR]: 'CE',
|
||||
[STATUS.STATUS_TIME_LIMIT_EXCEEDED]: 'TL',
|
||||
[STATUS.STATUS_RUNTIME_ERROR]: 'RT',
|
||||
};
|
||||
const submissions = teams.flatMap((i, idx) => {
|
||||
if (!i.journal) return [];
|
||||
const journal = i.journal.filter((s) => tdoc.pids.includes(s.pid));
|
||||
const c = Counter();
|
||||
return journal.map((s) => {
|
||||
const id = pid(tdoc.pids.indexOf(s.pid));
|
||||
c[id]++;
|
||||
return `@s ${idx + 1},${id},${c[id]},${time(s.rid)},${statusMap[s.status] || 'RJ'}`;
|
||||
});
|
||||
});
|
||||
const res = [
|
||||
`@contest "${escape(tdoc.title)}"`,
|
||||
`@contlen ${Math.floor((tdoc.endAt.getTime() - tdoc.beginAt.getTime()) / Time.minute)}`,
|
||||
`@problems ${tdoc.pids.length}`,
|
||||
`@teams ${tdoc.attend}`,
|
||||
`@submissions ${submissions.length}`,
|
||||
].concat(
|
||||
tdoc.pids.map((i, idx) => `@p ${pid(idx)},${escape(pdict[i]?.title || 'Unknown Problem')},20,0`),
|
||||
teams.map((i, idx) => {
|
||||
const teamName = `${i.rank ? '*' : ''}${escape(udict[i.uid].school || unknownSchool)}-${escape(udict[i.uid].uname)}`;
|
||||
return `@t ${idx + 1},0,1,${teamName}`;
|
||||
}),
|
||||
submissions,
|
||||
);
|
||||
this.binary(res.join('\n'), `${this.tdoc.title}.ghost`);
|
||||
}
|
||||
|
||||
async exportScoreboard(domainId: string, tid: ObjectId, ext: string) {
|
||||
await this.limitRate('scoreboard_download', 60, 3);
|
||||
if (ext === 'ghost') {
|
||||
if (contest.isLocked(this.tdoc) && !this.user.own(this.tdoc)) {
|
||||
this.checkPerm(PERM.PERM_VIEW_CONTEST_HIDDEN_SCOREBOARD);
|
||||
}
|
||||
await this.exportGhost(domainId, tid);
|
||||
return;
|
||||
}
|
||||
const getContent = {
|
||||
csv: async (rows) => `\uFEFF${rows.map((c) => (c.map((i) => i.value?.toString().replace(/\n/g, ' ')).join(','))).join('\n')}`,
|
||||
html: (rows, tdoc) => this.renderHTML('contest_scoreboard_download_html.html', { rows, tdoc }),
|
||||
};
|
||||
const [, rows] = await contest.getScoreboard.call(this, domainId, tid, {
|
||||
isExport: true, lockAt: this.tdoc.lockAt, showDisplayName: this.user.hasPerm(PERM.PERM_VIEW_DISPLAYNAME),
|
||||
});
|
||||
this.binary(await getContent[ext](rows, this.tdoc), `${this.tdoc.title}.${ext}`);
|
||||
}
|
||||
|
||||
@param('tid', Types.ObjectId)
|
||||
async postUnlock(domainId: string, tid: ObjectId) {
|
||||
if (!this.user.own(this.tdoc)) this.checkPerm(PERM.PERM_EDIT_CONTEST);
|
||||
if (!contest.isDone(this.tdoc)) throw new ContestNotEndedError(domainId, tid);
|
||||
await contest.unlockScoreboard(domainId, tid);
|
||||
this.back();
|
||||
}
|
||||
}
|
||||
|
||||
export class ContestEditHandler extends Handler {
|
||||
tdoc: Tdoc;
|
||||
|
||||
@@ -721,14 +613,115 @@ export class ContestBalloonHandler extends ContestManagementBaseHandler {
|
||||
}
|
||||
}
|
||||
|
||||
type BuiltinInput = {
|
||||
tdoc: Tdoc;
|
||||
groups: any[];
|
||||
};
|
||||
type AnyFunction = (...args: any) => any;
|
||||
type ParseArgs<T extends { [key: string]: keyof BuiltinInput | AnyFunction | Type<any> }> = {
|
||||
[key in keyof T]: T[key] extends keyof BuiltinInput ? BuiltinInput[T[key]] : T[key] extends AnyFunction ? ReturnType<T[key]> : any
|
||||
};
|
||||
export interface ScoreboardView<T extends { [key: string]: keyof BuiltinInput | AnyFunction | Type<any> }> {
|
||||
id: string;
|
||||
name: string;
|
||||
supportedRules: string[];
|
||||
cacheTime?: number; // in seconds
|
||||
args: T;
|
||||
display: (this: ContestScoreboardHandler, args: ParseArgs<T>) => Promise<void>;
|
||||
}
|
||||
|
||||
export class ContestScoreboardHandler extends ContestDetailBaseHandler {
|
||||
@param('tid', Types.ObjectId)
|
||||
@param('view', Types.String, true)
|
||||
async get(domainId: string, tid: ObjectId, viewId = 'default') {
|
||||
if (!contest.canShowScoreboard.call(this, this.tdoc, true)) throw new ContestScoreboardHiddenError(tid);
|
||||
if (contest.isNotStarted(this.tdoc)) throw new ContestNotLiveError(domainId, tid);
|
||||
const view = this.ctx.scoreboard.getView(viewId);
|
||||
if (!view) throw new NotFoundError(`View ${viewId} not found`);
|
||||
const args = {};
|
||||
const fetcher = {
|
||||
tdoc: () => this.tdoc,
|
||||
groups: async () => {
|
||||
const allGroups = (this.user.hasPerm(PERM.PERM_EDIT_CONTEST_SELF) && this.user.own(this.tdoc))
|
||||
|| this.user.hasPerm(PERM.PERM_EDIT_CONTEST);
|
||||
return await user.listGroup(domainId, allGroups ? undefined : this.user._id);
|
||||
},
|
||||
};
|
||||
for (const key in view.args) {
|
||||
if (typeof view.args[key] === 'function') {
|
||||
try {
|
||||
args[key] = view.args[key](this.args[key]);
|
||||
} catch (e) {
|
||||
throw new ValidationError(key);
|
||||
}
|
||||
} else if (view.args[key] instanceof Array) {
|
||||
if (this.args[key] === undefined && view.args[key].find((i) => i === true)) continue;
|
||||
if (view.args[key][1] && !view.args[key][1](this.args[key])) throw new ValidationError(key);
|
||||
args[key] = view.args[key][0](this.args[key]);
|
||||
} else if (fetcher[view.args[key]]) {
|
||||
args[key] = await fetcher[view.args[key]](); // eslint-disable-line no-await-in-loop
|
||||
}
|
||||
}
|
||||
await view.display.call(this, args);
|
||||
}
|
||||
|
||||
@param('tid', Types.ObjectId)
|
||||
async postUnlock(domainId: string, tid: ObjectId) {
|
||||
if (!this.user.own(this.tdoc)) this.checkPerm(PERM.PERM_EDIT_CONTEST);
|
||||
if (!contest.isDone(this.tdoc)) throw new ContestNotEndedError(domainId, tid);
|
||||
await contest.unlockScoreboard(domainId, tid);
|
||||
this.back();
|
||||
}
|
||||
}
|
||||
|
||||
class ScoreboardService extends Service {
|
||||
views: Record<string, ScoreboardView<any>> = {};
|
||||
constructor(ctx: Context) {
|
||||
super(ctx, 'scoreboard', true);
|
||||
ctx.set('scoreboard', this);
|
||||
}
|
||||
|
||||
addView<T extends { [key: string]: keyof BuiltinInput | AnyFunction | Type<any> }>(
|
||||
id: string, name: string, args: T,
|
||||
{ display, supportedRules, cacheTime }: {
|
||||
display: (this: ContestScoreboardHandler, args: ParseArgs<T>) => Promise<void>,
|
||||
supportedRules: string[],
|
||||
cacheTime?: number,
|
||||
},
|
||||
) {
|
||||
if (this.views[id]) throw new Error(`View ${id} already exists`);
|
||||
this.ctx.effect(() => {
|
||||
this.views[id] = {
|
||||
id, name, args, display, supportedRules, cacheTime,
|
||||
};
|
||||
return () => {
|
||||
delete this.views[id];
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
getAvailableViews(rule: string) {
|
||||
return Object.fromEntries(Object.values(this.views).filter((i) => i.supportedRules.includes(rule) || i.supportedRules.includes('*'))
|
||||
.map((i) => [i.id, i.name]));
|
||||
}
|
||||
|
||||
getView(id: string) {
|
||||
return this.views[id];
|
||||
}
|
||||
}
|
||||
|
||||
declare module '../context' {
|
||||
interface Context {
|
||||
scoreboard: ScoreboardService;
|
||||
}
|
||||
}
|
||||
|
||||
export async function apply(ctx: Context) {
|
||||
ctx.Route('contest_create', '/contest/create', ContestEditHandler);
|
||||
ctx.Route('contest_main', '/contest', ContestListHandler, PERM.PERM_VIEW_CONTEST);
|
||||
ctx.Route('contest_detail', '/contest/:tid', ContestDetailHandler, PERM.PERM_VIEW_CONTEST);
|
||||
ctx.Route('contest_problemlist', '/contest/:tid/problems', ContestProblemListHandler, PERM.PERM_VIEW_CONTEST);
|
||||
ctx.Route('contest_edit', '/contest/:tid/edit', ContestEditHandler, PERM.PERM_VIEW_CONTEST);
|
||||
ctx.Route('contest_scoreboard', '/contest/:tid/scoreboard', ContestScoreboardHandler, PERM.PERM_VIEW_CONTEST_SCOREBOARD);
|
||||
ctx.Route('contest_scoreboard_download', '/contest/:tid/export/:ext', ContestScoreboardHandler, PERM.PERM_VIEW_CONTEST_SCOREBOARD);
|
||||
ctx.Route('contest_manage', '/contest/:tid/management', ContestManagementHandler);
|
||||
ctx.Route('contest_code', '/contest/:tid/code', ContestCodeHandler, PERM.PERM_VIEW_CONTEST);
|
||||
ctx.Route('contest_file_download', '/contest/:tid/file/:filename', ContestFileDownloadHandler, PERM.PERM_VIEW_CONTEST);
|
||||
@@ -774,4 +767,103 @@ export async function apply(ctx: Context) {
|
||||
'Get a contest by ID',
|
||||
);
|
||||
});
|
||||
ctx.plugin(ScoreboardService);
|
||||
ctx.inject(['scoreboard'], ({ Route, scoreboard }) => {
|
||||
Route('contest_scoreboard', '/contest/:tid/scoreboard', ContestScoreboardHandler, PERM.PERM_VIEW_CONTEST_SCOREBOARD);
|
||||
Route('contest_scoreboard_view', '/contest/:tid/scoreboard/:view', ContestScoreboardHandler, PERM.PERM_VIEW_CONTEST_SCOREBOARD);
|
||||
scoreboard.addView('default', 'Default', { tdoc: 'tdoc', groups: 'groups', realtime: Types.Boolean }, {
|
||||
async display({ realtime, tdoc, groups }) {
|
||||
if (realtime && !this.user.own(tdoc)) {
|
||||
this.checkPerm(PERM.PERM_VIEW_CONTEST_HIDDEN_SCOREBOARD);
|
||||
}
|
||||
const config: ScoreboardConfig = { isExport: false, showDisplayName: this.user.hasPerm(PERM.PERM_VIEW_DISPLAYNAME) };
|
||||
if (!realtime && this.tdoc.lockAt && !this.tdoc.unlocked) {
|
||||
config.lockAt = this.tdoc.lockAt;
|
||||
}
|
||||
const [, rows, udict, pdict] = await contest.getScoreboard.call(this, tdoc.domainId, tdoc._id, config);
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
const page_name = tdoc.rule === 'homework'
|
||||
? 'homework_scoreboard'
|
||||
: 'contest_scoreboard';
|
||||
const availableViews = scoreboard.getAvailableViews(tdoc.rule);
|
||||
this.response.body = {
|
||||
tdoc: this.tdoc, tsdoc: this.tsdocAsPublic(), rows, udict, pdict, page_name, groups, availableViews,
|
||||
};
|
||||
this.response.pjax = 'partials/scoreboard.html';
|
||||
this.response.template = 'contest_scoreboard.html';
|
||||
},
|
||||
supportedRules: ['*'],
|
||||
});
|
||||
scoreboard.addView('ghost', 'Ghost', { tdoc: 'tdoc' }, {
|
||||
async display({ tdoc }) {
|
||||
if (contest.isLocked(tdoc) && !this.user.own(tdoc)) {
|
||||
this.checkPerm(PERM.PERM_VIEW_CONTEST_HIDDEN_SCOREBOARD);
|
||||
}
|
||||
const [pdict, teams] = await Promise.all([
|
||||
problem.getList(tdoc.domainId, tdoc.pids, true, false, problem.PROJECTION_LIST, true),
|
||||
contest.getMultiStatus(tdoc.domainId, { docId: tdoc._id }).toArray(),
|
||||
]);
|
||||
const udict = await user.getList(tdoc.domainId, teams.map((i) => i.uid));
|
||||
const teamIds: Record<number, number> = {};
|
||||
for (let i = 1; i <= teams.length; i++) teamIds[teams[i - 1].uid] = i;
|
||||
const time = (t: ObjectId) => Math.floor((t.getTimestamp().getTime() - tdoc.beginAt.getTime()) / Time.second);
|
||||
const pid = (i: number) => String.fromCharCode(65 + i);
|
||||
const escape = (i: string) => i.replace(/[",]/g, '');
|
||||
const unknownSchool = this.translate('Unknown School');
|
||||
const statusMap = {
|
||||
[STATUS.STATUS_ACCEPTED]: 'AC',
|
||||
[STATUS.STATUS_WRONG_ANSWER]: 'WA',
|
||||
[STATUS.STATUS_COMPILE_ERROR]: 'CE',
|
||||
[STATUS.STATUS_TIME_LIMIT_EXCEEDED]: 'TL',
|
||||
[STATUS.STATUS_RUNTIME_ERROR]: 'RT',
|
||||
};
|
||||
const submissions = teams.flatMap((i, idx) => {
|
||||
if (!i.journal) return [];
|
||||
const journal = i.journal.filter((s) => tdoc.pids.includes(s.pid));
|
||||
const c = Counter();
|
||||
return journal.map((s) => {
|
||||
const id = pid(tdoc.pids.indexOf(s.pid));
|
||||
c[id]++;
|
||||
return `@s ${idx + 1},${id},${c[id]},${time(s.rid)},${statusMap[s.status] || 'RJ'}`;
|
||||
});
|
||||
});
|
||||
const res = [
|
||||
`@contest "${escape(tdoc.title)}"`,
|
||||
`@contlen ${Math.floor((tdoc.endAt.getTime() - tdoc.beginAt.getTime()) / Time.minute)}`,
|
||||
`@problems ${tdoc.pids.length}`,
|
||||
`@teams ${tdoc.attend}`,
|
||||
`@submissions ${submissions.length}`,
|
||||
].concat(
|
||||
tdoc.pids.map((i, idx) => `@p ${pid(idx)},${escape(pdict[i]?.title || 'Unknown Problem')},20,0`),
|
||||
teams.map((i, idx) => {
|
||||
const teamName = `${i.rank ? '*' : ''}${escape(udict[i.uid].school || unknownSchool)}-${escape(udict[i.uid].uname)}`;
|
||||
return `@t ${idx + 1},0,1,${teamName}`;
|
||||
}),
|
||||
submissions,
|
||||
);
|
||||
this.binary(res.join('\n'), `${this.tdoc.title}.ghost`);
|
||||
},
|
||||
supportedRules: ['*'],
|
||||
});
|
||||
scoreboard.addView('html', 'HTML', { tdoc: 'tdoc' }, {
|
||||
async display({ tdoc }) {
|
||||
await this.limitRate('scoreboard_download', 60, 3);
|
||||
const [, rows] = await contest.getScoreboard.call(this, tdoc.domainId, tdoc._id, {
|
||||
isExport: true, lockAt: this.tdoc.lockAt, showDisplayName: this.user.hasPerm(PERM.PERM_VIEW_DISPLAYNAME),
|
||||
});
|
||||
this.binary(await this.renderHTML('contest_scoreboard_download_html.html', { rows, tdoc }), `${this.tdoc.title}.html`);
|
||||
},
|
||||
supportedRules: ['*'],
|
||||
});
|
||||
scoreboard.addView('csv', 'CSV', { tdoc: 'tdoc' }, {
|
||||
async display({ tdoc }) {
|
||||
await this.limitRate('scoreboard_download', 60, 3);
|
||||
const [, rows] = await contest.getScoreboard.call(this, tdoc.domainId, tdoc._id, {
|
||||
isExport: true, lockAt: this.tdoc.lockAt, showDisplayName: this.user.hasPerm(PERM.PERM_VIEW_DISPLAYNAME),
|
||||
});
|
||||
this.binary(toCSV(rows.map((r) => r.map((c) => c.value.toString()))), `${this.tdoc.title}.csv`);
|
||||
},
|
||||
supportedRules: ['*'],
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -86,12 +86,8 @@ export async function postJudge(rdoc: RecordDoc) {
|
||||
if (rdoc.contest?.toString().startsWith('0'.repeat(23))) return;
|
||||
const accept = rdoc.status === builtin.STATUS.STATUS_ACCEPTED;
|
||||
const updated = await problem.updateStatus(rdoc.domainId, rdoc.pid, rdoc.uid, rdoc._id, rdoc.status, rdoc.score);
|
||||
if (rdoc.contest) {
|
||||
await contest.updateStatus(
|
||||
rdoc.domainId, rdoc.contest, rdoc.uid, rdoc._id,
|
||||
rdoc.pid, rdoc.status, rdoc.score, rdoc.subtasks,
|
||||
);
|
||||
} else if (accept && updated) await domain.incUserInDomain(rdoc.domainId, rdoc.uid, 'nAccept', 1);
|
||||
if (rdoc.contest) await contest.updateStatus(rdoc.domainId, rdoc.contest, rdoc.uid, rdoc._id, rdoc.pid, rdoc);
|
||||
else if (accept && updated) await domain.incUserInDomain(rdoc.domainId, rdoc.uid, 'nAccept', 1);
|
||||
const isNormalSubmission = ![
|
||||
STATUS.STATUS_ETC, STATUS.STATUS_HACK_SUCCESSFUL, STATUS.STATUS_HACK_UNSUCCESSFUL,
|
||||
STATUS.STATUS_FORMAT_ERROR, STATUS.STATUS_SYSTEM_ERROR, STATUS.STATUS_CANCELED,
|
||||
|
||||
@@ -390,7 +390,7 @@ export interface Tdoc extends Document {
|
||||
lockAt?: Date;
|
||||
unlocked?: boolean;
|
||||
autoHide?: boolean;
|
||||
balloon?: Record<number, string>;
|
||||
balloon?: Record<number, string | { color: string, name: string }>;
|
||||
score?: Record<number, number>;
|
||||
|
||||
/**
|
||||
|
||||
@@ -849,27 +849,25 @@ export async function getStatus(domainId: string, tid: ObjectId, uid: number) {
|
||||
return await document.getStatus(domainId, document.TYPE_CONTEST, tid, uid);
|
||||
}
|
||||
|
||||
async function _updateStatus(
|
||||
tdoc: Tdoc, uid: number, rid: ObjectId, pid: number, status: STATUS, score: number,
|
||||
subtasks: Record<number, SubtaskResult>,
|
||||
export async function updateStatus(
|
||||
domainId: string, tid: ObjectId, uid: number, rid: ObjectId, pid: number,
|
||||
{
|
||||
status = STATUS.STATUS_WAITING,
|
||||
score = 0,
|
||||
subtasks,
|
||||
lang,
|
||||
}: { status?: STATUS, score?: number, subtasks?: Record<number, SubtaskResult>, lang?: string } = {},
|
||||
) {
|
||||
const tdoc = await get(domainId, tid);
|
||||
if (tdoc.balloon && status === STATUS.STATUS_ACCEPTED) await addBalloon(domainId, tid, uid, rid, pid);
|
||||
const tsdoc = await document.revPushStatus(tdoc.domainId, document.TYPE_CONTEST, tdoc.docId, uid, 'journal', {
|
||||
rid, pid, status, score, subtasks,
|
||||
rid, pid, status, score, subtasks, lang,
|
||||
}, 'rid');
|
||||
const journal = _getStatusJournal(tsdoc);
|
||||
const stats = RULES[tdoc.rule].stat(tdoc, journal);
|
||||
return await document.revSetStatus(tdoc.domainId, document.TYPE_CONTEST, tdoc.docId, uid, tsdoc.rev, { journal, ...stats });
|
||||
}
|
||||
|
||||
export async function updateStatus(
|
||||
domainId: string, tid: ObjectId, uid: number, rid: ObjectId, pid: number,
|
||||
status = STATUS.STATUS_WRONG_ANSWER, score = 0, subtasks: Record<number, SubtaskResult> = {},
|
||||
) {
|
||||
const tdoc = await get(domainId, tid);
|
||||
if (tdoc.balloon && status === STATUS.STATUS_ACCEPTED) await addBalloon(domainId, tid, uid, rid, pid);
|
||||
return await _updateStatus(tdoc, uid, rid, pid, status, score, subtasks);
|
||||
}
|
||||
|
||||
export async function getListStatus(domainId: string, uid: number, tids: ObjectId[]) {
|
||||
const r = {};
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
import path from 'path';
|
||||
import {
|
||||
ContestModel, Context, fs, Handler, ObjectId, Schema, STATUS, Types, UserModel,
|
||||
} from 'hydrooj';
|
||||
|
||||
const file = fs.readFileSync(path.join(__dirname, 'public/assets/board.html'), 'utf8');
|
||||
const indexJs = file.match(/index-([A-Za-z0-9_-]+)\.js"/)?.[1];
|
||||
const indexCss = file.match(/index-([A-Za-z0-9_-]+)\.css"/)?.[1];
|
||||
|
||||
class XcpcioHandler extends Handler {
|
||||
async get() {
|
||||
this.response.body = {
|
||||
js: indexJs,
|
||||
css: indexCss,
|
||||
};
|
||||
this.response.template = 'xcpcio_board.html';
|
||||
}
|
||||
}
|
||||
|
||||
const status = {
|
||||
[STATUS.STATUS_WRONG_ANSWER]: 'WRONG_ANSWER',
|
||||
[STATUS.STATUS_ACCEPTED]: 'CORRECT',
|
||||
[STATUS.STATUS_COMPILING]: 'PENDING',
|
||||
[STATUS.STATUS_WAITING]: 'PENDING',
|
||||
[STATUS.STATUS_JUDGING]: 'PENDING',
|
||||
[STATUS.STATUS_TIME_LIMIT_EXCEEDED]: 'TIME_LIMIT_EXCEEDED',
|
||||
[STATUS.STATUS_MEMORY_LIMIT_EXCEEDED]: 'MEMORY_LIMIT_EXCEEDED',
|
||||
[STATUS.STATUS_RUNTIME_ERROR]: 'RUNTIME_ERROR',
|
||||
[STATUS.STATUS_SYSTEM_ERROR]: 'SYSTEM_ERROR',
|
||||
[STATUS.STATUS_COMPILE_ERROR]: 'COMPILATION_ERROR',
|
||||
[STATUS.STATUS_FETCHED]: 'PENDING',
|
||||
[STATUS.STATUS_OUTPUT_LIMIT_EXCEEDED]: 'OUTPUT_LIMIT_EXCEEDED',
|
||||
[STATUS.STATUS_ETC]: 'SYSTEM_ERROR',
|
||||
[STATUS.STATUS_CANCELED]: 'CANCELED',
|
||||
};
|
||||
|
||||
export async function apply(ctx: Context) {
|
||||
ctx.Route('board_xcpcio', '/board', XcpcioHandler);
|
||||
ctx.inject(['scoreboard'], ({ scoreboard }) => {
|
||||
scoreboard.addView('xcpcio', 'XCPCIO', {
|
||||
tdoc: 'tdoc',
|
||||
groups: 'groups',
|
||||
json: Types.Boolean,
|
||||
gold: Schema.transform(Schema.union([Schema.string(), Schema.number().step(1).min(0)]), (v) => +v).default(0),
|
||||
silver: Schema.transform(Schema.union([Schema.string(), Schema.number().step(1).min(0)]), (v) => +v).default(0),
|
||||
bronze: Schema.transform(Schema.union([Schema.string(), Schema.number().step(1).min(0)]), (v) => +v).default(0),
|
||||
}, {
|
||||
async display({
|
||||
tdoc, groups, json, gold, silver, bronze,
|
||||
}) {
|
||||
if (json || this.request.json) {
|
||||
const tsdocs = await ContestModel.getMultiStatus(tdoc.domainId, { docId: tdoc.docId }).toArray();
|
||||
const udict = await UserModel.getList(tdoc.domainId, tsdocs.map((i) => i.uid));
|
||||
const teams = tsdocs.map((i) => {
|
||||
const udoc = udict[i.uid];
|
||||
return {
|
||||
team_id: `${udoc._id}`,
|
||||
name: udoc.uname,
|
||||
organization: udoc.school,
|
||||
members: [],
|
||||
coach: '',
|
||||
group: [
|
||||
...(udoc.group || []),
|
||||
i.unrank ? 'unofficial' : 'official',
|
||||
],
|
||||
};
|
||||
});
|
||||
const relatedGroups = teams.flatMap((i) => i.group);
|
||||
this.response.body = {
|
||||
contest: {
|
||||
contest_name: tdoc.title,
|
||||
start_time: Math.floor(tdoc.beginAt.getTime() / 1000),
|
||||
end_time: Math.floor(tdoc.endAt.getTime() / 1000),
|
||||
frozen_time: tdoc.lockAt ? Math.floor((tdoc.endAt.getTime() - tdoc.lockAt.getTime()) / 1000) : 0,
|
||||
penalty: 1200,
|
||||
problem_quantity: tdoc.pids.length,
|
||||
problem_id: tdoc.pids.map((i, idx) => String.fromCharCode(65 + idx)),
|
||||
group: {
|
||||
official: '正式队伍',
|
||||
unofficial: '打星队伍',
|
||||
...Object.fromEntries(groups.filter((i) => relatedGroups.includes(i.name)).map((i) => [i.name, i.name])),
|
||||
},
|
||||
organization: 'School',
|
||||
status_time_display: {
|
||||
'correct': true,
|
||||
'incorrect': true,
|
||||
'pending': true,
|
||||
},
|
||||
medal: {
|
||||
official: {
|
||||
gold,
|
||||
silver,
|
||||
bronze,
|
||||
},
|
||||
},
|
||||
balloon_color: tdoc.balloon
|
||||
? tdoc.pids.map((i) => ({
|
||||
color: '#000',
|
||||
background_color: typeof tdoc.balloon[i] === 'string' ? tdoc.balloon[i] : tdoc.balloon[i].color,
|
||||
}))
|
||||
: [],
|
||||
logo: {
|
||||
preset: 'ICPC',
|
||||
},
|
||||
// banner: {
|
||||
// url: 'assets/banner.png',
|
||||
// },
|
||||
options: {
|
||||
submission_timestamp_unit: 'millisecond',
|
||||
},
|
||||
},
|
||||
submissions: tsdocs.flatMap((i) => (i.journal || []).map((j) => ({
|
||||
problem_id: tdoc.pids.indexOf(j.pid),
|
||||
team_id: `${i.uid}`,
|
||||
timestamp: Math.floor(new ObjectId(j.rid).getTimestamp().getTime() - tdoc.beginAt.getTime()),
|
||||
status: status[j.status] || 'SYSTEM_ERROR',
|
||||
language: j.lang || '',
|
||||
submission_id: j.rid,
|
||||
}))),
|
||||
teams,
|
||||
};
|
||||
} else {
|
||||
this.response.template = 'xcpcio_board.html';
|
||||
let query = '';
|
||||
if (gold || silver || bronze) {
|
||||
query = `&gold=${gold}&silver=${silver}&bronze=${bronze}`;
|
||||
}
|
||||
this.response.body = {
|
||||
dataSource: `/d/${tdoc.domainId}/contest/${tdoc.docId}/scoreboard/xcpcio?json=true${query}#allInOne=true`,
|
||||
js: indexJs,
|
||||
css: indexCss,
|
||||
};
|
||||
}
|
||||
},
|
||||
supportedRules: ['acm'],
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
const dist = path.join(path.dirname(require.resolve('@xcpcio/board-app/package.json')), 'dist');
|
||||
const target = path.join(__dirname, 'public');
|
||||
if (fs.existsSync(target)) {
|
||||
fs.rmSync(target, { recursive: true });
|
||||
}
|
||||
fs.mkdirSync(target, { recursive: true });
|
||||
fs.cpSync(path.join(dist, 'assets'), path.join(target, 'assets'), { recursive: true });
|
||||
fs.cpSync(path.join(dist, 'index.html'), path.join(target, 'assets/board.html'));
|
||||
console.log('Copied board app to', target);
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"name": "@hydrooj/scoreboard-xcpcio",
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"version": "0.0.1",
|
||||
"main": "index.ts",
|
||||
"scripts": {
|
||||
"postinstall": "node -r @hydrooj/register install.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@xcpcio/board-app": "^0.53.2"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
{% set page_name = 'contest_scoreboard_xcpcio' %}
|
||||
{% extends "layout/basic.html" %}
|
||||
{% block content %}
|
||||
<script>
|
||||
function normalizePath(_) {
|
||||
for (; _.endsWith("/");)_ = _.slice(0, -1);
|
||||
return `${_}/`;
|
||||
}
|
||||
window.CDN_HOST = "{{ '/' if process.env.DEV else UiContext.cdn_prefix }}";
|
||||
window.__toAssetUrl = url => window.CDN_HOST + url;
|
||||
window.DATA_HOST = "/";
|
||||
window.DATA_REGION = "Hydro";
|
||||
window.DEFAULT_LANG = "{{ 'zh-CN' if _('__id').startsWith('zh') else 'en' }}";
|
||||
window.DATA_SOURCE = "{{ dataSource }}";
|
||||
if (document.documentElement.classList.contains("theme--dark")) document.documentElement.classList.toggle("dark", !0);
|
||||
setInterval(() => {
|
||||
const location = window.location.href;
|
||||
if (location.includes("%2F")) {
|
||||
window.history.replaceState(null, "", location.replace(/%2F/g, "/"));
|
||||
}
|
||||
}, 500);
|
||||
</script>
|
||||
<style>
|
||||
#app .nav, .omnibar-toggle, #app>main>footer>div>a, #app>main>footer>div>p { display: none; }
|
||||
#app footer { padding-top: 0 !important; padding-bottom: 0 !important; }
|
||||
.nav__item .icon img { display: inherit; }
|
||||
table { border-spacing: 2px; }
|
||||
th { text-align: center; }
|
||||
td.unattempted, td.empty { background-color: transparent !important; }
|
||||
</style>
|
||||
<script type="module" crossorigin src="{{ '/' if process.env.DEV else UiContext.cdn_prefix }}assets/index-{{ js }}.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="{{ '/' if process.env.DEV else UiContext.cdn_prefix }}assets/index-{{ css }}.css">
|
||||
<div id="app"></div>
|
||||
<noscript>This website requires JavaScript to function properly. Please enable JavaScript tocontinue.</noscript>
|
||||
{% endblock %}
|
||||
@@ -8,11 +8,16 @@
|
||||
<div class="row"><div class="medium-12 columns scoreboard--{{ tdoc.rule }}">
|
||||
<div class="section visible">
|
||||
<div class="section__header">
|
||||
{%- for ext in ['HTML','CSV','Ghost'] -%}
|
||||
<a class="button" target="_blank" href="{{ url(type + '_scoreboard_download', tid=tdoc.docId, ext=ext.toLowerCase()) }}">
|
||||
{%- for ext in ['HTML', 'CSV', 'Ghost'] -%}
|
||||
<a class="button" target="_blank" href="{{ url(type + '_scoreboard_view', tid=tdoc.docId, view=ext.toLowerCase()) }}">
|
||||
<span class="icon icon-download"></span> {{ _('Export as {0}').format(ext) }}
|
||||
</a>
|
||||
{%- endfor -%}
|
||||
{% for id, name in availableViews %}{% if not ['html', 'csv', 'default', 'ghost'].includes(id) %}
|
||||
<a class="button" target="_blank" href="{{ url(type + '_scoreboard_view', tid=tdoc.docId, view=id) }}">
|
||||
<span class="icon icon-copy"></span> {{ _(name) }}
|
||||
</a>
|
||||
{% endif %}{% endfor %}
|
||||
{% if model.contest.isDone(tdoc) and tdoc.lockAt and handler.user.own(tdoc) and not tdoc.unlocked %}
|
||||
<form method="POST">
|
||||
<input type="hidden" name="operation" value="unlock">
|
||||
|
||||
Reference in new issue
Block a user