From 125c4b238880000ad967b5417381daa29fc82e22 Mon Sep 17 00:00:00 2001 From: Baoshuo Date: Tue, 28 Apr 2026 04:26:02 +0800 Subject: [PATCH] core: allow user to manually end contest early (#1147) --- packages/hydrooj/src/handler/contest.ts | 44 ++++++++++++-- packages/hydrooj/src/model/contest.ts | 6 +- packages/hydrooj/src/model/document.ts | 9 ++- packages/hydrooj/src/model/solution.ts | 2 +- packages/ui-default/locales/zh.yaml | 3 + packages/ui-default/pages/contest.page.ts | 2 +- .../ui-default/pages/contest_user.page.ts | 58 ++++++++++--------- .../templates/partials/contest_sidebar.html | 10 ++++ .../templates/partials/contest_user.html | 5 +- 9 files changed, 99 insertions(+), 40 deletions(-) diff --git a/packages/hydrooj/src/handler/contest.ts b/packages/hydrooj/src/handler/contest.ts index a948b10cd..5b63f4440 100644 --- a/packages/hydrooj/src/handler/contest.ts +++ b/packages/hydrooj/src/handler/contest.ts @@ -93,14 +93,17 @@ export class ContestDetailBaseHandler extends Handler { } } if (this.tdoc.duration && this.tsdoc?.startAt) { - const endAt = moment(this.tsdoc.startAt).add(this.tdoc.duration, 'hours').toDate(); - this.tsdoc.endAt = endAt < this.tdoc.endAt ? endAt : this.tdoc.endAt; + this.tsdoc.endAt = moment.min([ + moment(this.tsdoc.startAt).add(this.tdoc.duration, 'hours'), + moment(this.tdoc.endAt), + ...(this.tsdoc.endAt ? [moment(this.tsdoc.endAt)] : []), + ]).toDate(); } } tsdocAsPublic() { if (!this.tsdoc) return null; - return pick(this.tsdoc, ['attend', 'subscribe', 'startAt', ...(this.tdoc.duration ? ['endAt'] : [])]); + return pick(this.tsdoc, ['attend', 'subscribe', 'startAt', ...(this.tdoc.duration || this.tsdoc.endAt ? ['endAt'] : [])]); } @param('tid', Types.ObjectId, true) @@ -173,7 +176,7 @@ export class ContestDetailHandler extends ContestDetailBaseHandler { @param('code', Types.String, true) async postAttend(domainId: string, tid: ObjectId, code = '') { this.checkPerm(PERM.PERM_ATTEND_CONTEST); - if (contest.isDone(this.tdoc)) throw new ContestNotLiveError(tid); + if (contest.isDone(this.tdoc)) throw new ContestNotLiveError(domainId, tid); if (this.tdoc._code && code !== this.tdoc._code) throw new InvalidTokenError('Contest Invitation', code); await contest.attend(domainId, tid, this.user._id, { subscribe: 1 }); this.back(); @@ -186,6 +189,15 @@ export class ContestDetailHandler extends ContestDetailBaseHandler { await contest.setStatus(domainId, tid, this.user._id, { subscribe: subscribe ? 1 : 0 }); this.back(); } + + @param('tid', Types.ObjectId) + async postEarlyEnd(domainId: string, tid: ObjectId) { + if (this.tdoc.rule === 'homework') throw new ContestNotFoundError(domainId, tid); + if (!this.tsdoc?.attend) throw new ContestNotAttendedError(domainId, tid); + if (!contest.isOngoing(this.tdoc, this.tsdoc)) throw new ContestNotLiveError(domainId, tid); + await contest.setStatus(domainId, tid, this.user._id, { endAt: new Date() }); + this.back(); + } } export class ContestPrintHandler extends ContestDetailBaseHandler { @@ -698,10 +710,16 @@ export class ContestUserHandler extends ContestManagementBaseHandler { @param('tid', Types.ObjectId) async get(domainId: string, tid: ObjectId) { const tsdocs = await contest.getMultiStatus(domainId, { docId: tid }).project({ - uid: 1, attend: 1, startAt: 1, unrank: 1, + uid: 1, attend: 1, startAt: 1, unrank: 1, endAt: 1, }).toArray(); for (const tsdoc of tsdocs) { - tsdoc.endAt = (this.tdoc.duration && tsdoc.startAt) ? moment(tsdoc.startAt).add(this.tdoc.duration, 'hours').toDate() : null; + if (this.tdoc.duration && tsdoc.startAt) { + tsdoc.endAt = moment.min([ + moment(tsdoc.startAt).add(this.tdoc.duration, 'hours'), + moment(this.tdoc.endAt), + ...(tsdoc.endAt ? [moment(tsdoc.endAt)] : []), + ]).toDate(); + } } const udict = await user.getListForRender( domainId, [this.tdoc.owner, ...tsdocs.map((i) => i.uid)], @@ -728,6 +746,20 @@ export class ContestUserHandler extends ContestManagementBaseHandler { await contest.setStatus(domainId, tid, uid, { unrank: !tsdoc.unrank }); this.back(); } + + @param('tid', Types.ObjectId) + @param('uid', Types.PositiveInt) + async postResume(domainId: string, tid: ObjectId, uid: number) { + const tsdoc = await contest.getStatus(domainId, tid, uid); + if (!tsdoc?.attend) throw new ContestNotAttendedError(uid); + if (this.tdoc.endAt <= new Date()) throw new ContestNotLiveError(domainId, tid); + if (this.tdoc.duration && tsdoc.startAt) { + const durationEnd = moment(tsdoc.startAt).add(this.tdoc.duration, 'hours').toDate(); + if (durationEnd <= new Date()) throw new ContestNotLiveError(domainId, tid); + } + await contest.setStatus(domainId, tid, uid, null, { endAt: '' }); + this.back(); + } } export class ContestBalloonHandler extends ContestManagementBaseHandler { diff --git a/packages/hydrooj/src/model/contest.ts b/packages/hydrooj/src/model/contest.ts index b701a732d..c0765c94c 100644 --- a/packages/hydrooj/src/model/contest.ts +++ b/packages/hydrooj/src/model/contest.ts @@ -62,12 +62,14 @@ export function isNotStarted(tdoc: Tdoc) { export function isOngoing(tdoc: Tdoc, tsdoc?: any) { const now = new Date(); + if (tsdoc?.endAt && tsdoc.endAt <= now) return false; if (tsdoc && tdoc.duration && tsdoc.startAt <= new Date(Date.now() - Math.floor(tdoc.duration * Time.hour))) return false; return (tdoc.beginAt <= now && now < tdoc.endAt); } export function isDone(tdoc: Tdoc, tsdoc?: any) { if (tdoc.endAt <= new Date()) return true; + if (tsdoc?.endAt && tsdoc.endAt <= new Date()) return true; if (tsdoc && tdoc.duration && tsdoc.startAt <= new Date(Date.now() - Math.floor(tdoc.duration * Time.hour))) return true; return false; } @@ -940,8 +942,8 @@ export function getMultiStatus(domainId: string, query: any) { return document.getMultiStatus(domainId, document.TYPE_CONTEST, query); } -export function setStatus(domainId: string, tid: ObjectId, uid: number, $set: any) { - return document.setStatus(domainId, document.TYPE_CONTEST, tid, uid, $set); +export function setStatus(domainId: string, tid: ObjectId, uid: number, $set?: any, $unset?: any) { + return document.setStatus(domainId, document.TYPE_CONTEST, tid, uid, $set, $unset); } export function count(domainId: string, query: any) { diff --git a/packages/hydrooj/src/model/document.ts b/packages/hydrooj/src/model/document.ts index 6c6199ed0..0749a99d8 100644 --- a/packages/hydrooj/src/model/document.ts +++ b/packages/hydrooj/src/model/document.ts @@ -295,11 +295,16 @@ export function getMultiStatusWithoutDomain( export async function setStatus( domainId: string, docType: K, docId: DocStatusType[K]['docId'], uid: number, - args: UpdateFilter['$set'], returnDocument: 'before' | 'after' = 'after', + $set: UpdateFilter['$set'] | null, + $unset?: UpdateFilter['$unset'] | null, + returnDocument: 'before' | 'after' = 'after', ): Promise { + const op: UpdateFilter = {}; + if ($set && Object.keys($set).length) op.$set = $set; + if ($unset && Object.keys($unset).length) op.$unset = $unset; return await collStatus.findOneAndUpdate( { domainId, docType, docId, uid }, - { $set: args }, + op, { upsert: true, returnDocument, diff --git a/packages/hydrooj/src/model/solution.ts b/packages/hydrooj/src/model/solution.ts index 42d37efd3..1543e32bf 100644 --- a/packages/hydrooj/src/model/solution.ts +++ b/packages/hydrooj/src/model/solution.ts @@ -74,7 +74,7 @@ class SolutionModel { if (!doc) throw new SolutionNotFoundError(domainId, psid); const before = await document.setStatus( domainId, document.TYPE_PROBLEM_SOLUTION, psid, uid, - { vote: value }, 'before', + { vote: value }, null, 'before', ); let inc = value; if (before?.vote) inc -= before.vote; diff --git a/packages/ui-default/locales/zh.yaml b/packages/ui-default/locales/zh.yaml index 6aea34e8a..489c7c4e3 100644 --- a/packages/ui-default/locales/zh.yaml +++ b/packages/ui-default/locales/zh.yaml @@ -195,6 +195,7 @@ Confirm deleting the selected roles?: 您确定删除所选角色吗? Confirm deleting this comment? Its replies will be deleted as well.: 确认删除这个评论吗?回复会被同时删除。 Confirm deleting this domain? This action cannot be undone.: '确认删除此域?此操作无法撤销。' Confirm deleting this reply?: 确认删除这个回复吗? +Confirm end contest early? You will not be able to submit after this.: 确认提前结束比赛吗?之后您将无法提交代码。 Confirm rejudge this problem?: 确定要重测这道题吗? Confirm removing the selected users?: 您确定将所选用户移除吗? Confirm to delete the file?: 确认删除此文件吗? @@ -209,6 +210,7 @@ Content copied to clipboard!: 内容已复制到剪贴板! content: 内容 Content: 内容 Contest Maintainer: 比赛管理员 +Contest resumed.: 比赛已恢复。 Contest scoreboard is not visible.: 当前比赛成绩表隐藏,暂不可显示。 Contest Scoreboard: 比赛成绩表 Contest Settings: 比赛设置 @@ -376,6 +378,7 @@ Email Visibility: Email 可见性 Email: 电子邮件 Enabled: 开启 End at: 结束于 +End Contest Early: 提前结束比赛 End Date: 结束日期 End Time: 结束时间 Enroll Training: 参加训练 diff --git a/packages/ui-default/pages/contest.page.ts b/packages/ui-default/pages/contest.page.ts index 32d89c7c0..e672e8665 100644 --- a/packages/ui-default/pages/contest.page.ts +++ b/packages/ui-default/pages/contest.page.ts @@ -8,7 +8,7 @@ contestTimer.appendTo(document.body); export default new NamedPage(['contest_detail', 'contest_problemlist', 'contest_detail_problem', 'contest_scoreboard'], () => { const beginAt = new Date((UiContext.tdoc.duration && UiContext.tsdoc?.startAt) || UiContext.tdoc.beginAt).getTime(); - const endAt = new Date((UiContext.tdoc.duration && UiContext.tsdoc?.endAt) || UiContext.tdoc.endAt).getTime(); + const endAt = new Date(UiContext.tsdoc?.endAt || UiContext.tdoc.endAt).getTime(); NProgress.configure({ trickle: false, showSpinner: false, minimum: 0 }); function updateProgress() { const now = Date.now(); diff --git a/packages/ui-default/pages/contest_user.page.ts b/packages/ui-default/pages/contest_user.page.ts index 764df81fe..88ee578dc 100644 --- a/packages/ui-default/pages/contest_user.page.ts +++ b/packages/ui-default/pages/contest_user.page.ts @@ -52,46 +52,50 @@ const page = new NamedPage('contest_user', () => { return this; }; + async function handlePostRequest(params, successMessage) { + try { + const res = await request.post('', params); + if (res.url && res.url !== window.location.href) window.location.href = res.url; + else { + Notification.success(successMessage); + pjax.request({ push: false }); + } + } catch (error) { + Notification.error([error.message, ...error.params].join(' ')); + } + } + async function handleClickAddUser() { const action = await addUserDialog.clear().open(); if (action !== 'ok') return; const unrank = addUserDialog.$dom.find('[name="unrank"]').prop('checked'); const uids = userSelect.value(); - try { - const res = await request.post('', { - operation: 'add_user', - uids: uids.join(','), - unrank, - }); - if (res.url && res.url !== window.location.href) window.location.href = res.url; - else { - Notification.success(i18n('User added.')); - pjax.request({ push: false }); - } - } catch (error) { - Notification.error([error.message, ...error.params].join(' ')); - } + await handlePostRequest({ + operation: 'add_user', + uids: uids.join(','), + unrank, + }, i18n('User added.')); } async function handleEditRank(ev) { const uid = $(ev.target).data('uid'); - try { - const res = await request.post('', { - operation: 'rank', - uid, - }); - if (res.url && res.url !== window.location.href) window.location.href = res.url; - else { - Notification.success(i18n('Ranking status updated.')); - pjax.request({ push: false }); - } - } catch (error) { - Notification.error([error.message, ...error.params].join(' ')); - } + await handlePostRequest({ + operation: 'rank', + uid, + }, i18n('Ranking status updated.')); + } + + async function handleResume(ev) { + const uid = $(ev.target).data('uid'); + await handlePostRequest({ + operation: 'resume', + uid, + }, i18n('Contest resumed.')); } $('[name="add_user"]').on('click', () => handleClickAddUser()); $(document).on('click', '[name="edit_rank"]', handleEditRank); + $(document).on('click', '[name="resume_contest"]', handleResume); }); export default page; diff --git a/packages/ui-default/templates/partials/contest_sidebar.html b/packages/ui-default/templates/partials/contest_sidebar.html index b2bf78858..522594045 100644 --- a/packages/ui-default/templates/partials/contest_sidebar.html +++ b/packages/ui-default/templates/partials/contest_sidebar.html @@ -68,6 +68,16 @@ + {% if page_name == 'contest_detail' and tsdoc.attend and model.contest.isOngoing(tdoc, tsdoc) and tdoc.rule !== 'homework' %} + + {% endif %} {% endif %}{# not attend and not done #} {% if model.contest.canShowScoreboard.call(handler, tdoc, False) %}