diff --git a/packages/hydrooj/locales/en.yaml b/packages/hydrooj/locales/en.yaml index f8aaea602..9620ea657 100644 --- a/packages/hydrooj/locales/en.yaml +++ b/packages/hydrooj/locales/en.yaml @@ -8,6 +8,7 @@ contest_detail: Contest Detail contest_edit: Contest Edit contest_files: Contest Files contest_main: Contest +contest_problemlist: Proble List contest_scoreboard: Scoreboard discussion_create: Create Discussion discussion_detail: Discussion Detail diff --git a/packages/hydrooj/locales/zh.yaml b/packages/hydrooj/locales/zh.yaml index 28f585018..84af58b3d 100644 --- a/packages/hydrooj/locales/zh.yaml +++ b/packages/hydrooj/locales/zh.yaml @@ -88,6 +88,7 @@ Attended: 已参加 author: 作者 Auto format code: 自动格式化代码 Avatar: 头像 +Back to contest list: 返回比赛列表 Be Copied: 被复制 Begin at: 开始于 Begin Date: 开始日期 @@ -159,6 +160,7 @@ contest_detail: 比赛详情 contest_edit: 编辑比赛 contest_files: 比赛文件 contest_main: 比赛 +contest_problemlist: 题目列表 contest_scoreboard: 比赛成绩表 Contest: 比赛 Contests per page: 每页展示的比赛数量 @@ -437,8 +439,8 @@ List View: 列表视图 Live: 正在进行 Live...: 正在进行… Location: 位置 -Lock Topic: 锁定主题 Lock discussions: 锁定讨论 +Lock Topic: 锁定主题 Login to Attend Contest: 登录后参加比赛 Login to Claim Homework: 登录后认领作业 Login to Create a Discussion: 登录后创建讨论 @@ -868,8 +870,8 @@ View or create discussion: 查看与发表讨论 View or submit solution: 查看与发表题解 View Owned Problems: 查看自己的题目 View Owned Training Plans: 查看自己的训练计划 -View problem solutions: 查看题解 View problem solutions after accept: 题目通过后查看题解 +View problem solutions: 查看题解 View Problem: 查看题目 View problems: 查看题目 View ranking: 查看排名 diff --git a/packages/hydrooj/src/handler/contest.ts b/packages/hydrooj/src/handler/contest.ts index aa0683d97..615e81cd6 100644 --- a/packages/hydrooj/src/handler/contest.ts +++ b/packages/hydrooj/src/handler/contest.ts @@ -6,8 +6,8 @@ import { ObjectID } from 'mongodb'; import { Counter, sortFiles, Time } from '@hydrooj/utils/lib/utils'; import { BadRequestError, ContestNotEndedError, ContestNotFoundError, ContestNotLiveError, - ContestScoreboardHiddenError, FileLimitExceededError, FileUploadError, InvalidTokenError, - NotAssignedError, PermissionError, ValidationError, + ContestScoreboardHiddenError, FileLimitExceededError, FileUploadError, ForbiddenError, + InvalidTokenError, NotAssignedError, PermissionError, ValidationError, } from '../error'; import { Tdoc } from '../interface'; import paginate from '../lib/paginate'; @@ -92,6 +92,10 @@ export class ContestDetailBaseHandler extends Handler { @param('tid', Types.ObjectID, true) async __prepare(domainId: string, tid: ObjectID) { + if (this.session.tid && this.session.tid !== tid) { + // TODO: you are already in a contest + throw new ForbiddenError(); + } if (!tid) return; // ProblemDetailHandler also extends from ContestDetailBaseHandler [this.tdoc, this.tsdoc] = await Promise.all([ contest.get(domainId, tid), @@ -110,32 +114,33 @@ export class ContestDetailBaseHandler extends Handler { @param('tid', Types.ObjectID, true) async after(domainId: string, tid: ObjectID) { - if (!tid || this.tdoc.rule === 'homework' || !contest.isOngoing(this.tdoc, this.tsdoc)) return; + if (!tid || this.tdoc.rule === 'homework') return; if (this.request.json || !this.response.template) return; const pdoc = 'pdoc' in this ? (this as any).pdoc : {}; this.response.body.overrideNav = [ - { name: 'homepage', args: { prefix: 'homepage' }, checker: () => true }, + { + name: 'contest_list', + args: {}, + displayName: 'Back to contest list', + checker: () => !(this.tdoc.access & contest.AccessControl.LOCK_OTHER), + }, { name: 'contest_detail', displayName: this.tdoc.title, args: { tid, prefix: 'contest_detail' }, checker: () => true, }, + { + name: 'contest_problemlist', + args: { tid, prefix: 'contest_problemlist' }, + checker: () => contest.isDone(this.tdoc, this.tsdoc) + || (contest.isOngoing(this.tdoc, this.tsdoc) && this.tsdoc?.attend), + }, { name: 'contest_scoreboard', args: { tid, prefix: 'contest_scoreboard' }, checker: () => contest.canShowScoreboard.call(this, this.tdoc, true), }, - { - name: 'record_main', - args: { - prefix: 'record', - query: contest.canShowRecord.call(this, this.tdoc, true) - ? { tid } - : { tid, uidOrName: this.user._id }, - }, - checker: () => contest.canShowSelfRecord.call(this, this.tdoc, true), - }, { displayName: `${String.fromCharCode(65 + this.tdoc.pids.indexOf(pdoc.docId))}. ${pdoc.title}`, args: { query: { tid }, pid: pdoc.docId, prefix: 'contest_detail_problem' }, @@ -145,10 +150,30 @@ export class ContestDetailBaseHandler extends Handler { } } -export class ContestDetailHandler extends ContestDetailBaseHandler { +export class ContestDetailHandler extends Handler { + tdoc?: Tdoc<30>; + tsdoc?: any; + @param('tid', Types.ObjectID) async prepare(domainId: string, tid: ObjectID) { + [this.tdoc, this.tsdoc] = await Promise.all([ + contest.get(domainId, tid), + contest.getStatus(domainId, tid, this.user._id), + ]); if (contest.RULES[this.tdoc.rule].hidden) throw new ContestNotFoundError(domainId, tid); + if (this.session.tid && this.session.tid !== tid) { + // TODO: you are already in a contest + throw new ForbiddenError(); + } + if (this.tdoc.assign?.length && !this.user.own(this.tdoc)) { + const groups = await user.listGroup(domainId, this.user._id); + if (!Set.intersection(this.tdoc.assign, groups.map((i) => i.name)).size) { + throw new NotAssignedError('contest', tid); + } + } + if (this.tdoc.duration && this.tsdoc?.startAt) { + this.tsdoc.endAt = moment(this.tsdoc.startAt).add(this.tdoc.duration, 'hours').toDate(); + } } @param('tid', Types.ObjectID) @@ -158,32 +183,41 @@ export class ContestDetailHandler extends ContestDetailBaseHandler { this.response.body = { tdoc: this.tdoc, tsdoc: pick(this.tsdoc, ['attend', 'startAt']), udict, }; - if (!this.request.json) { - this.response.body.tdoc.content = this.response.body.tdoc.content - .replace(/\(file:\/\//g, `(./${this.tdoc.docId}/file/`) - .replace(/="file:\/\//g, `="./${this.tdoc.docId}/file/`); - } - if ( - (contest.isNotStarted(this.tdoc) || (!this.tsdoc?.attend && !contest.isDone(this.tdoc))) - && !this.user.own(this.tdoc) - && !this.user.hasPerm(PERM.PERM_VIEW_CONTEST_HIDDEN_SCOREBOARD) - ) return; - const pdict = await problem.getList(domainId, this.tdoc.pids, true, true, problem.PROJECTION_CONTEST_LIST); - let psdict = {}; - let rdict = {}; - if (this.tsdoc) { - if (this.tsdoc.attend && !this.tsdoc.startAt && contest.isOngoing(this.tdoc)) { - await contest.setStatus(domainId, tid, this.user._id, { startAt: new Date() }); - this.tsdoc.startAt = new Date(); - } - psdict = this.tsdoc.detail || {}; - if (contest.canShowSelfRecord.call(this, this.tdoc)) { - rdict = await record.getList(domainId, Object.values(psdict).map((i: any) => i.rid)); - } else { - for (const i in psdict) rdict[psdict[i].rid] = { _id: psdict[i].rid }; - } - } - Object.assign(this.response.body, { pdict, psdict, rdict }); + if (this.request.json) return; + this.response.body.tdoc.content = this.response.body.tdoc.content + .replace(/\(file:\/\//g, `(./${this.tdoc.docId}/file/`) + .replace(/="file:\/\//g, `="./${this.tdoc.docId}/file/`); + const pdoc = 'pdoc' in this ? (this as any).pdoc : {}; + this.response.body.overrideNav = [ + { + name: 'contest_list', + args: {}, + displayName: 'Back to contest list', + checker: () => !(this.tdoc.access & contest.AccessControl.LOCK_OTHER), + }, + { + name: 'contest_detail', + displayName: this.tdoc.title, + args: { tid, prefix: 'contest_detail' }, + checker: () => true, + }, + { + name: 'contest_problemlist', + args: { tid }, + checker: () => contest.isDone(this.tdoc, this.tsdoc) + || (contest.isOngoing(this.tdoc, this.tsdoc) && this.tsdoc?.attend), + }, + { + name: 'contest_scoreboard', + args: { tid, prefix: 'contest_scoreboard' }, + checker: () => contest.canShowScoreboard.call(this, this.tdoc, true), + }, + { + displayName: `${String.fromCharCode(65 + this.tdoc.pids.indexOf(pdoc.docId))}. ${pdoc.title}`, + args: { query: { tid }, pid: pdoc.docId, prefix: 'contest_detail_problem' }, + checker: () => 'pdoc' in this, + }, + ]; } @param('tid', Types.ObjectID) @@ -194,16 +228,35 @@ export class ContestDetailHandler extends ContestDetailBaseHandler { await contest.attend(domainId, tid, this.user._id); this.back(); } +} +export class ContestProblemListHandler extends ContestDetailBaseHandler { @param('tid', Types.ObjectID) - async postDelete(domainId: string, tid: ObjectID) { - if (!this.user.own(this.tdoc)) this.checkPerm(PERM.PERM_EDIT_CONTEST); - await contest.del(domainId, tid); - await record.updateMulti(domainId, { domainId, contest: tid }, undefined, undefined, { contest: '' }); - await ScheduleModel.deleteMany({ - type: 'schedule', subType: 'contest', domainId, tid, - }); - this.response.redirect = this.url('contest_main'); + async get(domainId: string, tid: ObjectID) { + if (contest.isNotStarted(this.tdoc) || (!this.tsdoc?.attend && !contest.isDone(this.tdoc))) throw new ForbiddenError(); + const [pdict, udict] = await Promise.all([ + problem.getList(domainId, this.tdoc.pids, true, true, problem.PROJECTION_CONTEST_LIST), + user.getList(domainId, [this.tdoc.owner, this.user._id]), + ]); + this.response.body = { + pdict, psdict: {}, udict, rdict: {}, tdoc: this.tdoc, tsdoc: this.tsdoc, + }; + this.response.template = 'contest_problemlist.html'; + if (!this.tsdoc) return; + if (this.tsdoc.attend && !this.tsdoc.startAt && contest.isOngoing(this.tdoc)) { + await contest.setStatus(domainId, tid, this.user._id, { startAt: new Date() }); + this.tsdoc.startAt = new Date(); + } + this.response.body.psdict = this.tsdoc.detail || {}; + const psdocs: any[] = Object.values(this.response.body.psdict); + if (contest.canShowSelfRecord.call(this, this.tdoc)) { + [this.response.body.rdict, this.response.body.rdocs] = await Promise.all([ + record.getList(domainId, psdocs.map((i: any) => i.rid)), + await record.getMulti(domainId, { tid, uid: this.user._id }).toArray(), + ]); + } else { + for (const i of psdocs) this.response.body.rdict[i.rid] = { _id: i.rid }; + } } } @@ -358,7 +411,7 @@ export class ContestEditHandler extends Handler { @param('assign', Types.CommaSeperatedArray, true) @param('lock', Types.UnsignedInt, true) @param('contestDuration', Types.Float, true) - async post( + async postUpdate( 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, lock: number = null, @@ -408,6 +461,17 @@ export class ContestEditHandler extends Handler { this.response.body = { tid }; this.response.redirect = this.url('contest_detail', { tid }); } + + @param('tid', Types.ObjectID) + async postDelete(domainId: string, tid: ObjectID) { + if (!this.user.own(this.tdoc)) this.checkPerm(PERM.PERM_EDIT_CONTEST); + await contest.del(domainId, tid); + await record.updateMulti(domainId, { domainId, contest: tid }, undefined, undefined, { contest: '' }); + await ScheduleModel.deleteMany({ + type: 'schedule', subType: 'contest', domainId, tid, + }); + this.response.redirect = this.url('contest_main'); + } } export class ContestCodeHandler extends Handler { @@ -526,6 +590,7 @@ export async function apply(ctx) { 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_broadcast', '/contest/:tid/broadcast', ContestBroadcastHandler); 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); diff --git a/packages/hydrooj/src/interface.ts b/packages/hydrooj/src/interface.ts index 862ac5530..ce5982d87 100644 --- a/packages/hydrooj/src/interface.ts +++ b/packages/hydrooj/src/interface.ts @@ -366,6 +366,8 @@ export interface Tdoc (a.rid.generationTime - b.rid.generationTime)); } diff --git a/packages/ui-default/pages/contest_main.page.styl b/packages/ui-default/pages/contest.page.styl similarity index 72% rename from packages/ui-default/pages/contest_main.page.styl rename to packages/ui-default/pages/contest.page.styl index 9476099dd..40b000d65 100644 --- a/packages/ui-default/pages/contest_main.page.styl +++ b/packages/ui-default/pages/contest.page.styl @@ -107,3 +107,63 @@ $highlight-button-color = #F6DF45 .detail-button width: auto + + +.page--contest_edit +.page--contest_create + label + &.checkbox + margin-top: 24px + + +.page--contest_problemlist + .col--status + width: rem(150px) + + .col--submit-at + width: rem(150px) + + .col--problem + border-left: 1px solid $table-border-color + + +mobile() + .col--submit-at + display: none + + +.page--contest_scoreboard + .main + .row + max-width: 100vw !important; + + .col--rank + width: rem(60px) + text-align: center + border-right: 1px solid $table-border-color + + .col--user + min-width: rem(150px) + + .col--solved, .col--time + width: rem(70px) + text-align: center + + .col--total_score + width: rem(100px) + text-align: center + + .col--problem + width: rem(100px) + border-left: 1px solid $table-border-color + text-align: center + + .scoreboard--acm + .col--problem + width: rem(70px) !important + + +mobile() + .col--user + width: auto + + .col--problem + display: none diff --git a/packages/ui-default/pages/contest_detail.page.styl b/packages/ui-default/pages/contest_detail.page.styl deleted file mode 100644 index 0724ef46f..000000000 --- a/packages/ui-default/pages/contest_detail.page.styl +++ /dev/null @@ -1,13 +0,0 @@ -.page--contest_detail - .col--status - width: rem(150px) - - .col--submit-at - width: rem(150px) - - .col--problem - border-left: 1px solid $table-border-color - - +mobile() - .col--submit-at - display: none diff --git a/packages/ui-default/pages/contest_edit.page.styl b/packages/ui-default/pages/contest_edit.page.styl deleted file mode 100644 index 136cc0a60..000000000 --- a/packages/ui-default/pages/contest_edit.page.styl +++ /dev/null @@ -1,6 +0,0 @@ -.page--contest_edit -.page--contest_create - label - &.checkbox - margin-top: 24px - \ No newline at end of file diff --git a/packages/ui-default/pages/contest_scoreboard.page.styl b/packages/ui-default/pages/contest_scoreboard.page.styl deleted file mode 100644 index a867dc06b..000000000 --- a/packages/ui-default/pages/contest_scoreboard.page.styl +++ /dev/null @@ -1,36 +0,0 @@ -.page--contest_scoreboard - .main - .row - max-width: 100vw !important; - - .col--rank - width: rem(60px) - text-align: center - border-right: 1px solid $table-border-color - - .col--user - min-width: rem(150px) - - .col--solved, .col--time - width: rem(70px) - text-align: center - - .col--total_score - width: rem(100px) - text-align: center - - .col--problem - width: rem(100px) - border-left: 1px solid $table-border-color - text-align: center - - .scoreboard--acm - .col--problem - width: rem(70px) !important - - +mobile() - .col--user - width: auto - - .col--problem - display: none diff --git a/packages/ui-default/templates/contest_detail.html b/packages/ui-default/templates/contest_detail.html index f504a1033..46ed8036a 100644 --- a/packages/ui-default/templates/contest_detail.html +++ b/packages/ui-default/templates/contest_detail.html @@ -37,66 +37,6 @@ {% endif %} -
-
-

{{ _('Problem') }}

-
- {% if pdict %} -
- - - {% if handler.user.hasPriv(PRIV.PRIV_USER_PROFILE) %} - - - {% endif %} - - - - - {% if handler.user.hasPriv(PRIV.PRIV_USER_PROFILE) %} - - - {% endif %} - - - - - {% set isAdmin = handler.user.own(tdoc) or handler.user.hasPerm(perm.PERM_VIEW_CONTEST_HIDDEN_SCOREBOARD) %} - {% set ntdoc = model.contest.isDone(tdoc) or (tsdoc.attend and not model.contest.isNotStarted(tdoc)) %} - {%- for pid in tdoc.pids -%} - - {% if handler.user.hasPriv(PRIV.PRIV_USER_PROFILE) %} - {% if psdict[pid] and psdict[pid].rid %} - {% set rdoc = rdict[psdict[pid].rid] %} - {% if model.contest.canShowSelfRecord.call(handler, tdoc) %} - {{ record.render_status_td(rdoc) }} - {% else %} - - {% endif %} - - {% else %} - - - {% endif %} - {% endif %} - - - {%- endfor -%} - -
{{ _('Status') }}{{ _('Last Submit At') }}{{ _('Problem') }}
{{ _('Submitted') }}{{ datetimeSpan(rdoc._id)|safe }}{{ _('No Submissions') }}- - {% if isAdmin and not ntdoc %} - {{ problem.render_problem_title(pdict[pid], show_invisible_flag=false, show_tags=false) }} - {% else %} - {{ problem.render_problem_title(pdict[pid], tdoc=tdoc, show_invisible_flag=false, show_tags=false) }} - {% endif %} -
-
- {% elif tsdoc.attend %} - {{ nothing.render('This contest is not live.') }} - {% else %} - {{ nothing.render('Please attend contest to see the problems.') }} - {% endif %} -
{% set owner_udoc = udict[tdoc.owner] %} diff --git a/packages/ui-default/templates/contest_edit.html b/packages/ui-default/templates/contest_edit.html index 0ed2cf529..80330e70f 100644 --- a/packages/ui-default/templates/contest_edit.html +++ b/packages/ui-default/templates/contest_edit.html @@ -108,7 +108,7 @@
{% if page_name == 'contest_edit' %} - {% if handler.user.own(tdoc) or handler.user.hasPerm(perm.PERM_EDIT_CONTEST) %} @@ -117,7 +117,7 @@ {% endif %} {% else %} - {% endif %} diff --git a/packages/ui-default/templates/contest_problemlist.html b/packages/ui-default/templates/contest_problemlist.html new file mode 100644 index 000000000..11688cdb0 --- /dev/null +++ b/packages/ui-default/templates/contest_problemlist.html @@ -0,0 +1,100 @@ +{% extends "layout/basic.html" %} +{% import "components/contest.html" as contest with context %} +{% import "components/record.html" as record with context %} +{% import "components/problem.html" as problem with context %} +{% block content %} +{{ set(UiContext, 'tdoc', tdoc) }} +
+
+
+
+

{{ _('Problem') }}

+
+
+ + + {% if handler.user.hasPriv(PRIV.PRIV_USER_PROFILE) %} + + + {% endif %} + + + + + {% if handler.user.hasPriv(PRIV.PRIV_USER_PROFILE) %} + + + {% endif %} + + + + + {%- for pid in tdoc.pids -%} + + {% if handler.user.hasPriv(PRIV.PRIV_USER_PROFILE) %} + {% if psdict[pid] and psdict[pid].rid %} + {% set rdoc = rdict[psdict[pid].rid] %} + {% if model.contest.canShowSelfRecord.call(handler, tdoc) %} + {{ record.render_status_td(rdoc) }} + {% else %} + + {% endif %} + + {% else %} + + + {% endif %} + {% endif %} + + + {%- endfor -%} + +
{{ _('Status') }}{{ _('Last Submit At') }}{{ _('Problem') }}
{{ _('Submitted') }}{{ datetimeSpan(rdoc._id)|safe }}{{ _('No Submissions') }}- + {{ problem.render_problem_title(pdict[pid], tdoc=tdoc, show_invisible_flag=false, show_tags=false) }} +
+
+
+
+
+ {% if not rdocs.length %} + {{ nothing.render('Oh, there is no task in the queue!') }} + {% else %} + + + + + + + + + + + + + + + + + + + + + + + {%- for rdoc in rdocs -%} + {% set udoc = udict[rdoc['uid']] %} + {% set pdoc = pdict[rdoc['pid']] %} + {% include 'record_main_tr.html' %} + {%- endfor -%} + +
{{ _('Status') }}{{ _('Problem') }}{{ _('Submit By') }}{{ _('Time') }}{{ _('Memory') }}{{ _('Language') }}{{ _('Submit At') }}
+ {% endif %} +
+
+
+
+ {% set owner_udoc = udict[tdoc.owner] %} + {% include "partials/contest_sidebar.html" %} +
+
+{% endblock %} \ No newline at end of file