diff --git a/README.md b/README.md index 8bb70f49e..afce8536f 100644 --- a/README.md +++ b/README.md @@ -40,6 +40,26 @@ Telegram [@webpack_exports_undefined](https://t.me/webpack_exports_undefined)

更新日志(点击展开)

+### Hydro 4.5.0 / UI 4.44.0 +- fps: 修复题目中含空文件导致导入失败的问题 +- core: 封禁用户时支持附加理由 +- vjudge: codeforces: 跳过无法访问的 1769 和 1772 比赛 +- ui: 收藏题目操作不再触发页面刷新 +- core: 重测时检查题目配置文件有效性 +- core: 退出时自动清理临时文件 +- core: 禁止使用 . 作为文件名 +- import-qduoj: 跳过不合法的题目 +- core: 修复提交答案题的比赛代码导出 +- judge: 添加 stdioLimit 项 +- ui: 修复 message.FLAG_ALERT 显示 +- core: training 可上传文件 +- ui: 优化比赛导航栏 +- ui: 比赛成绩表支持关注队伍 +- core: 允许克隆比赛/作业 +- ui: 比赛编辑页面添加功能入口 +- core: 支持打星参赛 +- core: 整题重测时跳过已取消成绩的提交 + ### Hydro 4.4.5 / UI 4.43.0 - core: 修复比赛基于 ID 搜索题目的功能 - judge: 修复 testlib 错误信息显示异常的问题 diff --git a/packages/fps-importer/package.json b/packages/fps-importer/package.json index fa6c4a651..9afeaea60 100644 --- a/packages/fps-importer/package.json +++ b/packages/fps-importer/package.json @@ -1,6 +1,6 @@ { "name": "@hydrooj/fps-importer", - "version": "1.5.2", + "version": "1.5.3", "description": "Import FPS problems", "main": "index.ts", "repository": "https://github.com/hydro-dev/Hydro.git", diff --git a/packages/hydrojudge/package.json b/packages/hydrojudge/package.json index 5ea0a7f9d..dde0b1e6c 100644 --- a/packages/hydrojudge/package.json +++ b/packages/hydrojudge/package.json @@ -1,7 +1,7 @@ { "name": "@hydrooj/hydrojudge", "bin": "bin/hydrojudge.js", - "version": "3.1.12", + "version": "3.1.13", "main": "package.json", "author": "undefined ", "repository": "https://github.com/hydro-dev/Hydro.git", diff --git a/packages/hydrooj/locales/en.yaml b/packages/hydrooj/locales/en.yaml index f8aaea602..85035ba7d 100644 --- a/packages/hydrooj/locales/en.yaml +++ b/packages/hydrooj/locales/en.yaml @@ -8,7 +8,9 @@ contest_detail: Contest Detail contest_edit: Contest Edit contest_files: Contest Files contest_main: Contest +contest_problemlist: Problem List contest_scoreboard: Scoreboard +contest_user: Attendee Manage discussion_create: Create Discussion discussion_detail: Discussion Detail discussion_edit: Discussion Edit diff --git a/packages/hydrooj/locales/ko.yaml b/packages/hydrooj/locales/ko.yaml index 714bed81b..eddfe3118 100644 --- a/packages/hydrooj/locales/ko.yaml +++ b/packages/hydrooj/locales/ko.yaml @@ -294,8 +294,7 @@ Evaluated difficulty: 평가된 난이도 expand: 확장 Expire: 만료 Export All Code: 모든 코드 내보내기 -Export as CSV: CSV로 내보내기 -Export as HTML: HTML로 내보내기 +Export as {0}: '{0}로 내보내기' Extension (days): 추가 (날짜) Extension Score Penalty: 추가 패널티 Failed to join the domain. You are already a member.: 그룹 참여 실패. 이미 참여함. diff --git a/packages/hydrooj/locales/zh.yaml b/packages/hydrooj/locales/zh.yaml index 28f585018..cd7ef3459 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,7 +160,9 @@ contest_detail: 比赛详情 contest_edit: 编辑比赛 contest_files: 比赛文件 contest_main: 比赛 +contest_problemlist: 题目列表 contest_scoreboard: 比赛成绩表 +contest_user: 参赛者管理 Contest: 比赛 Contests per page: 每页展示的比赛数量 Continue: 继续 @@ -322,8 +325,7 @@ Evaluated difficulty: 估计的难度 expand: 展开 Expire: 过期 Export All Code: 导出所有代码 -Export as CSV: 导出为 CSV -Export as HTML: 导出为 HTML +Export as {0}: 导出为 {0} Extension (days): 最长延期 (日) Extension Score Penalty: 延期递交扣分规则 Failed to join the domain. You are already a member.: 加入域失败,您已是该域的成员。 @@ -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/package.json b/packages/hydrooj/package.json index 886aaeefb..8e46dd15c 100644 --- a/packages/hydrooj/package.json +++ b/packages/hydrooj/package.json @@ -1,6 +1,6 @@ { "name": "hydrooj", - "version": "4.4.5", + "version": "4.5.0", "bin": "bin/hydrooj.js", "main": "src/plugin-api", "module": "src/plugin-api", diff --git a/packages/hydrooj/src/handler/contest.ts b/packages/hydrooj/src/handler/contest.ts index 89448a834..44519e080 100644 --- a/packages/hydrooj/src/handler/contest.ts +++ b/packages/hydrooj/src/handler/contest.ts @@ -7,9 +7,9 @@ import { Counter, sortFiles, streamToBuffer, Time, } from '@hydrooj/utils/lib/utils'; import { - BadRequestError, ContestNotEndedError, ContestNotFoundError, ContestNotLiveError, - ContestScoreboardHiddenError, FileLimitExceededError, FileUploadError, InvalidTokenError, - NotAssignedError, PermissionError, ValidationError, + BadRequestError, ContestNotAttendedError, ContestNotEndedError, ContestNotFoundError, ContestNotLiveError, + ContestScoreboardHiddenError, FileLimitExceededError, FileUploadError, ForbiddenError, + InvalidTokenError, NotAssignedError, PermissionError, ValidationError, } from '../error'; import { Tdoc } from '../interface'; import paginate from '../lib/paginate'; @@ -112,32 +112,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_main', + args: {}, + displayName: 'Back to contest list', + checker: () => true, + }, { 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' }, @@ -147,10 +148,26 @@ 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.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) @@ -160,32 +177,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_main', + args: {}, + displayName: 'Back to contest list', + checker: () => true, + }, + { + 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) @@ -196,16 +222,36 @@ 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 }).sort({ _id: -1 }).toArray(), + ]); + this.response.body.canViewRecord = true; + } else { + for (const i of psdocs) this.response.body.rdict[i.rid] = { _id: i.rid }; + } } } @@ -360,11 +406,13 @@ export class ContestEditHandler extends Handler { @param('assign', Types.CommaSeperatedArray, true) @param('lock', Types.UnsignedInt, true) @param('contestDuration', Types.Float, true) - async post( + @param('maintainer', Types.NumericArray, true) + @param('allowViewCode', Types.Boolean, true) + 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, - contestDuration: number = null, + contestDuration: number = null, maintainer: number[] = null, allowViewCode = true, ) { if (autoHide) this.checkPerm(PERM.PERM_EDIT_PROBLEM); const pids = _pids.replace(/,/g, ',').split(',').map((i) => +i).filter((i) => i); @@ -405,11 +453,22 @@ export class ContestEditHandler extends Handler { }); } await contest.edit(domainId, tid, { - assign, _code, autoHide, lockAt, + assign, _code, autoHide, lockAt, maintainer, allowViewCode, }); 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 { @@ -498,7 +557,7 @@ export class ContestFilesHandler extends ContestDetailBaseHandler { if (filename.includes('/') || filename.includes('..')) throw new ValidationError('filename', null, 'Bad filename'); await storage.put(`contest/${domainId}/${tid}/${filename}`, file.filepath, this.user._id); const meta = await storage.getMeta(`contest/${domainId}/${tid}/${filename}`); - const payload = { name: filename, ...pick(meta, ['size', 'lastModified', 'etag']) }; + const payload = { _id: filename, name: filename, ...pick(meta, ['size', 'lastModified', 'etag']) }; if (!meta) throw new FileUploadError(); await contest.edit(domainId, tid, { files: [...(this.tdoc.files || []), payload] }); this.back(); @@ -533,10 +592,45 @@ export class ContestFileDownloadHandler extends ContestDetailBaseHandler { } } +export class ContestUserHandler extends ContestDetailBaseHandler { + @param('tid', Types.ObjectID) + async get(domainId: string, tid: ObjectID) { + if (!this.user.own(this.tdoc)) this.checkPerm(PERM.PERM_EDIT_CONTEST); + const tsdocs = await contest.getMultiStatus(domainId, { docId: tid }).toArray(); + tsdocs.forEach((i) => { + i.endAt = this.tdoc.duration ? moment(this.tsdoc.startAt).add(this.tdoc.duration, 'hours').toDate() : null; + }); + const udict = await user.getListForRender(domainId, [this.tdoc.owner, ...tsdocs.map((i) => i.uid)]); + this.response.body = { tdoc: this.tdoc, tsdocs, udict }; + this.response.pjax = 'partials/contest_user.html'; + this.response.template = 'contest_user.html'; + } + + @param('tid', Types.ObjectID) + @param('uids', Types.NumericArray) + @param('unrank', Types.Boolean) + async postAddUser(domainId: string, tid: ObjectID, uids: number[], unrank = false) { + if (!this.user.own(this.tdoc)) this.checkPerm(PERM.PERM_EDIT_CONTEST); + await Promise.all(uids.map((uid) => contest.attend(domainId, tid, uid, { unrank }))); + this.back(); + } + + @param('tid', Types.ObjectID) + @param('uid', Types.PositiveInt) + async postRank(domainId: string, tid: ObjectID, uid: number) { + if (!this.user.own(this.tdoc)) this.checkPerm(PERM.PERM_EDIT_CONTEST); + const tsdoc = await contest.getStatus(domainId, tid, uid); + if (!tsdoc) throw new ContestNotAttendedError(uid); + await contest.setStatus(domainId, tid, uid, { unrank: !tsdoc.unrank }); + this.back(); + } +} + 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); @@ -544,4 +638,5 @@ export async function apply(ctx) { ctx.Route('contest_code', '/contest/:tid/code', ContestCodeHandler, PERM.PERM_VIEW_CONTEST); ctx.Route('contest_files', '/contest/:tid/file', ContestFilesHandler, PERM.PERM_VIEW_CONTEST); ctx.Route('contest_file_download', '/contest/:tid/file/:filename', ContestFileDownloadHandler, PERM.PERM_VIEW_CONTEST); + ctx.Route('contest_user', '/contest/:tid/user', ContestUserHandler, PERM.PERM_VIEW_CONTEST); } diff --git a/packages/hydrooj/src/handler/problem.ts b/packages/hydrooj/src/handler/problem.ts index d204a31fd..f1463034c 100644 --- a/packages/hydrooj/src/handler/problem.ts +++ b/packages/hydrooj/src/handler/problem.ts @@ -442,6 +442,7 @@ export class ProblemDetailHandler extends ContestDetailBaseHandler { const rdocs = await record.getMulti(domainId, { pid, contest: { $ne: new ObjectID('0'.repeat(24)) }, + status: { $ne: STATUS.STATUS_CANCELED }, 'files.hack': { $exists: false }, }).project({ _id: 1, contest: 1 }).toArray(); if (!this.pdoc.config || typeof this.pdoc.config === 'string') throw new ProblemConfigError(); diff --git a/packages/hydrooj/src/handler/record.ts b/packages/hydrooj/src/handler/record.ts index effd3568b..1616ee716 100644 --- a/packages/hydrooj/src/handler/record.ts +++ b/packages/hydrooj/src/handler/record.ts @@ -216,7 +216,10 @@ class RecordDetailHandler extends ContestDetailBaseHandler { record.update(domainId, rid, $set), TaskModel.deleteMany({ rid: this.rdoc._id }), ]); - if (latest) await postJudge(latest); + if (latest) { + this.ctx.broadcast('record/change', latest); + await postJudge(latest); + } this.back(); } } diff --git a/packages/hydrooj/src/handler/training.ts b/packages/hydrooj/src/handler/training.ts index 0fdb60ce9..1a1a2f61e 100644 --- a/packages/hydrooj/src/handler/training.ts +++ b/packages/hydrooj/src/handler/training.ts @@ -1,15 +1,24 @@ import assert from 'assert'; +import { statSync } from 'fs-extra'; +import { pick } from 'lodash'; import { FilterQuery, ObjectID } from 'mongodb'; -import { ProblemNotFoundError, ValidationError } from '../error'; +import { sortFiles } from '@hydrooj/utils/lib/utils'; +import { + FileLimitExceededError, FileUploadError, ProblemNotFoundError, ValidationError, +} from '../error'; import { Tdoc, TrainingDoc } from '../interface'; import paginate from '../lib/paginate'; import { PERM, PRIV, STATUS } from '../model/builtin'; +import * as oplog from '../model/oplog'; import problem from '../model/problem'; +import storage from '../model/storage'; import * as system from '../model/system'; import * as training from '../model/training'; import user from '../model/user'; import * as bus from '../service/bus'; -import { Handler, param, Types } from '../service/server'; +import { + Handler, param, post, Types, +} from '../service/server'; async function _parseDagJson(domainId: string, _dag: string): Promise { const parsed = []; @@ -149,6 +158,9 @@ class TrainingDetailHandler extends Handler { this.response.body = { tdoc, tsdoc, pids, pdict, psdict, ndict, nsdict, udoc, udict, selfPsdict, }; + this.response.body.tdoc.description = this.response.body.tdoc.description + .replace(/\(file:\/\//g, `(./${tdoc.docId}/file/`) + .replace(/="file:\/\//g, `="./${tdoc.docId}/file/`); this.response.pjax = 'partials/training_detail.html'; this.response.template = 'training_detail.html'; } @@ -219,9 +231,86 @@ class TrainingEditHandler extends Handler { } } +export class TrainingFilesHandler extends Handler { + tdoc: TrainingDoc; + + @param('tid', Types.ObjectID) + async prepare(domainId: string, tid: ObjectID) { + this.tdoc = await training.get(domainId, tid); + if (!this.user.own(this.tdoc)) this.checkPerm(PERM.PERM_EDIT_TRAINING); + else this.checkPerm(PERM.PERM_EDIT_TRAINING_SELF); + } + + @param('tid', Types.ObjectID) + async get(domainId: string, tid: ObjectID) { + if (!this.user.own(this.tdoc)) this.checkPerm(PERM.PERM_EDIT_TRAINING); + this.response.body = { + tdoc: this.tdoc, + tsdoc: await training.getStatus(domainId, this.tdoc.docId, this.user._id), + udoc: await user.getById(domainId, this.tdoc.owner), + files: sortFiles(this.tdoc.files || []), + urlForFile: (filename: string) => this.url('training_file_download', { tid, filename }), + }; + this.response.pjax = 'partials/files.html'; + this.response.template = 'training_files.html'; + } + + @param('tid', Types.ObjectID) + @post('filename', Types.Name, true) + async postUploadFile(domainId: string, tid: ObjectID, filename: string) { + if ((this.tdoc.files?.length || 0) >= system.get('limit.contest_files')) { + throw new FileLimitExceededError('count'); + } + const file = this.request.files?.file; + if (!file) throw new ValidationError('file'); + const f = statSync(file.filepath); + const size = Math.sum((this.tdoc.files || []).map((i) => i.size)) + f.size; + if (size >= system.get('limit.contest_files_size')) { + throw new FileLimitExceededError('size'); + } + if (!filename) filename = file.originalFilename || String.random(16); + if (filename.includes('/') || filename.includes('..')) throw new ValidationError('filename', null, 'Bad filename'); + await storage.put(`training/${domainId}/${tid}/${filename}`, file.filepath, this.user._id); + const meta = await storage.getMeta(`training/${domainId}/${tid}/${filename}`); + const payload = { _id: filename, name: filename, ...pick(meta, ['size', 'lastModified', 'etag']) }; + if (!meta) throw new FileUploadError(); + await training.edit(domainId, tid, { files: [...(this.tdoc.files || []), payload] }); + this.back(); + } + + @param('tid', Types.ObjectID) + @post('files', Types.Array) + async postDeleteFiles(domainId: string, tid: ObjectID, files: string[]) { + await Promise.all([ + storage.del(files.map((t) => `contest/${domainId}/${tid}/${t}`), this.user._id), + training.edit(domainId, tid, { files: this.tdoc.files.filter((i) => !files.includes(i.name)) }), + ]); + this.back(); + } +} +export class TrainingFileDownloadHandler extends Handler { + @param('tid', Types.ObjectID) + @param('filename', Types.Name) + @param('noDisposition', Types.Boolean) + async get(domainId: string, tid: ObjectID, filename: string, noDisposition = false) { + this.response.addHeader('Cache-Control', 'public'); + const target = `training/${domainId}/${tid}/${filename}`; + const file = await storage.getMeta(target); + await oplog.log(this, 'download.file.training', { + target, + size: file?.size || 0, + }); + this.response.redirect = await storage.signDownloadLink( + target, noDisposition ? undefined : filename, false, 'user', + ); + } +} + export async function apply(ctx) { ctx.Route('training_main', '/training', TrainingMainHandler, PERM.PERM_VIEW_TRAINING); ctx.Route('training_create', '/training/create', TrainingEditHandler); ctx.Route('training_detail', '/training/:tid', TrainingDetailHandler, PERM.PERM_VIEW_TRAINING); ctx.Route('training_edit', '/training/:tid/edit', TrainingEditHandler); + ctx.Route('training_files', '/training/:tid/file', TrainingFilesHandler, PERM.PERM_VIEW_TRAINING); + ctx.Route('training_file_download', '/training/:tid/file/:filename', TrainingFileDownloadHandler, PERM.PERM_VIEW_TRAINING); } diff --git a/packages/hydrooj/src/interface.ts b/packages/hydrooj/src/interface.ts index 862ac5530..d32cb57ed 100644 --- a/packages/hydrooj/src/interface.ts +++ b/packages/hydrooj/src/interface.ts @@ -366,6 +366,8 @@ export interface Tdoc { export interface ContestStat extends Record { detail: Record>, + unrank?: boolean, } export interface ContestRule { diff --git a/packages/hydrooj/src/lib/rank.ts b/packages/hydrooj/src/lib/rank.ts index 6e42f11a7..08666a2ff 100644 --- a/packages/hydrooj/src/lib/rank.ts +++ b/packages/hydrooj/src/lib/rank.ts @@ -1,6 +1,6 @@ import { Cursor } from 'mongodb'; -type ranked = (cursor: Cursor, equ: ((a: T, b: T) => boolean)) => Promise<[number, T][]>; +type ranked = >(cursor: Cursor, equ: ((a: T, b: T) => boolean)) => Promise<[number, T][]>; const ranked: ranked = async (cursor, equ) => { let last = null; @@ -9,6 +9,10 @@ const ranked: ranked = async (cursor, equ) => { const results = []; const docs = await cursor.toArray(); for (const doc of docs) { + if (doc.unrank) { + results.push([0, doc]); + continue; + } count++; if (!last || !equ(last, doc)) r = count; last = doc; diff --git a/packages/hydrooj/src/model/contest.ts b/packages/hydrooj/src/model/contest.ts index 5ec1047b6..b4f5b75c7 100644 --- a/packages/hydrooj/src/model/contest.ts +++ b/packages/hydrooj/src/model/contest.ts @@ -533,7 +533,7 @@ export async function add( return res; } -export async function edit(domainId: string, tid: ObjectID, $set: any) { +export async function edit(domainId: string, tid: ObjectID, $set: Partial) { 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); @@ -593,9 +593,9 @@ export async function getListStatus(domainId: string, uid: number, tids: ObjectI return r; } -export async function attend(domainId: string, tid: ObjectID, uid: number) { +export async function attend(domainId: string, tid: ObjectID, uid: number, payload: any = {}) { try { - await document.cappedIncStatus(domainId, document.TYPE_CONTEST, tid, uid, 'attend', 1, 0, 1); + await document.cappedIncStatus(domainId, document.TYPE_CONTEST, tid, uid, 'attend', 1, 0, 1, payload); } catch (e) { throw new ContestAlreadyAttendedError(tid, uid); } diff --git a/packages/hydrooj/src/model/document.ts b/packages/hydrooj/src/model/document.ts index 2a764a033..3388e2c8a 100644 --- a/packages/hydrooj/src/model/document.ts +++ b/packages/hydrooj/src/model/document.ts @@ -350,12 +350,15 @@ export async function setStatusIfNotCondition( export async function cappedIncStatus( domainId: string, docType: T, docId: DocStatusType[T]['docId'], uid: number, key: NumberKeys, value: number, minValue = -1, maxValue = 1, + setPayload: Partial = {}, ): Promise { assert(value !== 0); const $not = value > 0 ? { $gte: maxValue } : { $lte: minValue }; + const operation = { $inc: { [key]: value } } as UpdateQuery; + if (Object.keys(setPayload).length) operation.$set = setPayload; const res = await collStatus.findOneAndUpdate( { domainId, docType, docId, uid, [key]: { $not } }, - { $inc: { [key]: value } }, + operation, { upsert: true, returnDocument: 'after' }, ); return res.value; diff --git a/packages/hydrooj/src/model/setting.ts b/packages/hydrooj/src/model/setting.ts index eed93d20b..811748439 100644 --- a/packages/hydrooj/src/model/setting.ts +++ b/packages/hydrooj/src/model/setting.ts @@ -214,8 +214,8 @@ SystemSetting( Setting('setting_limits', 'limit.problem_files_max_size', 256 * 1024 * 1024, 'number', 'limit.problem_files_max_size', 'Max files size per problem'), Setting('setting_limits', 'limit.user_files', 100, 'number', 'limit.user_files', 'Max files for user'), Setting('setting_limits', 'limit.user_files_size', 128 * 1024 * 1024, 'number', 'limit.user_files_size', 'Max total file size for user'), - Setting('setting_limits', 'limit.contest_files', 100, 'number', 'limit.contest_files', 'Max files for contest'), - Setting('setting_limits', 'limit.contest_files_size', 128 * 1024 * 1024, 'number', 'limit.contest_files_size', 'Max total file size for contest'), + Setting('setting_limits', 'limit.contest_files', 100, 'number', 'limit.contest_files', 'Max files for contest or training'), + Setting('setting_limits', 'limit.contest_files_size', 128 * 1024 * 1024, 'number', 'limit.contest_files_size', 'Max total file size for contest or training'), Setting('setting_limits', 'limit.submission', 60, 'number', 'limit.submission', 'Max submission count per minute'), Setting('setting_limits', 'limit.submission_user', 15, 'number', 'limit.submission_user', 'Max submission count per user per minute'), Setting('setting_limits', 'limit.pretest', 60, 'number', 'limit.pretest', 'Max pretest count per minute'), diff --git a/packages/import-qduoj/package.json b/packages/import-qduoj/package.json index 185204317..c801431d8 100644 --- a/packages/import-qduoj/package.json +++ b/packages/import-qduoj/package.json @@ -1,6 +1,6 @@ { "name": "@hydrooj/import-qduoj", - "version": "1.5.0", + "version": "1.5.1", "description": "Import QDUOJ problem export", "main": "index.ts", "repository": "https://github.com/hydro-dev/Hydro.git", diff --git a/packages/ui-default/api.ts b/packages/ui-default/api.ts index 13e3912b8..2eae5c6c5 100644 --- a/packages/ui-default/api.ts +++ b/packages/ui-default/api.ts @@ -2,6 +2,7 @@ export * from './utils'; export { default as Notification } from './components/notification'; export * from './components/dialog'; export * as bus from './bus'; +export { default as AnsiUp } from 'ansi_up'; export { default as loadMonaco } from './components/monaco/loader'; export * as redux from 'react-redux'; export * from './components/zipDownloader'; diff --git a/packages/ui-default/backendlib/markdown-it-katex.js b/packages/ui-default/backendlib/markdown-it-katex.js index bad99054c..5e1ebff26 100644 --- a/packages/ui-default/backendlib/markdown-it-katex.js +++ b/packages/ui-default/backendlib/markdown-it-katex.js @@ -105,6 +105,7 @@ module.exports = function plugin(md) { const katexInline = function (latex) { options.displayMode = false; try { + latex = latex.replace(/\\def{\\([a-zA-Z0-9]+)}/g, '\\def\\$1'); return katex.renderToString(latex, options); } catch (error) { if (options.throwOnError) logger.error(error); @@ -117,6 +118,7 @@ module.exports = function plugin(md) { const katexBlock = function (latex) { options.displayMode = true; try { + latex = latex.replace(/\\def{\\([a-zA-Z0-9]+)}/g, '\\def\\$1'); return `

${katex.renderToString(latex, options)}

`; } catch (error) { if (options.throwOnError) logger.error(error); diff --git a/packages/ui-default/components/autocomplete/autocomplete.page.styl b/packages/ui-default/components/autocomplete/autocomplete.page.styl index ff150f4fe..46233b99f 100644 --- a/packages/ui-default/components/autocomplete/autocomplete.page.styl +++ b/packages/ui-default/components/autocomplete/autocomplete.page.styl @@ -2,23 +2,36 @@ display: none !important .autocomplete-wrapper - width: 300px border: 1px solid #d9d9d9 - background-color: #fff padding: 1px display: flex flex-wrap: wrap overflow-y: auto + form-styles() + background-color: $input-background-color + outline: $input-outline + transition: outline-color .2s, border-color .2s + transition-timing-function: ease-out-cubic + + &:focus, &.focused + border-color: $input-focus-border-color + outline: $input-focus-outline + outline-offset: 0 + box-shadow: 0 0 0 2px rgba(24, 144, 255, 0.2) + + // Overwrite, for IE compatibility + &:read-only + outline: 0 + + &:disabled + opacity: 0.5 + &:hover border-color: #40a9ff - &.focused - border-color: #40a9ff - box-shadow: 0 0 0 2px rgba(24, 144, 255, 0.2) - input - background-color: #fff + background-color: $input-background-color color: rgba(0,0,0,.85) height: 30px box-sizing: border-box diff --git a/packages/ui-default/components/autocomplete/components/AutoComplete.tsx b/packages/ui-default/components/autocomplete/components/AutoComplete.tsx index 88299462a..a3ce4ed7b 100644 --- a/packages/ui-default/components/autocomplete/components/AutoComplete.tsx +++ b/packages/ui-default/components/autocomplete/components/AutoComplete.tsx @@ -274,7 +274,7 @@ const AutoComplete = forwardRef(function Impl(props: AutoCompleteProps, re }; return ( -
+
", "license": "AGPL-3.0", "main": "index.ts", diff --git a/packages/ui-default/pages/contest_main.page.styl b/packages/ui-default/pages/contest.page.styl similarity index 74% rename from packages/ui-default/pages/contest_main.page.styl rename to packages/ui-default/pages/contest.page.styl index 9476099dd..f7f1d80ae 100644 --- a/packages/ui-default/pages/contest_main.page.styl +++ b/packages/ui-default/pages/contest.page.styl @@ -107,3 +107,55 @@ $highlight-button-color = #F6DF45 .detail-button width: auto + +.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_edit.page.ts b/packages/ui-default/pages/contest_edit.page.ts index 89b37de4a..f8cd68865 100644 --- a/packages/ui-default/pages/contest_edit.page.ts +++ b/packages/ui-default/pages/contest_edit.page.ts @@ -1,17 +1,41 @@ import $ from 'jquery'; +import moment from 'moment'; import ProblemSelectAutoComplete from 'vj/components/autocomplete/ProblemSelectAutoComplete'; +import UserSelectAutoComplete from 'vj/components/autocomplete/UserSelectAutoComplete'; import { ConfirmDialog } from 'vj/components/dialog'; import { NamedPage } from 'vj/misc/Page'; import { i18n, request, tpl } from 'vj/utils'; const page = new NamedPage(['contest_edit', 'contest_create', 'homework_create', 'homework_edit'], (pagename) => { ProblemSelectAutoComplete.getOrConstruct($('[name="pids"]'), { multi: true, clearDefaultValue: false }); + UserSelectAutoComplete.getOrConstruct($('[name="maintainer"]'), { multi: true, clearDefaultValue: false }); + $('[name="rule"]').on('change', () => { + const rule = $('[name="rule"]').val(); + $('.contest-rule-settings input').attr('disabled', 'disabled'); + $('.contest-rule-settings').hide(); + $(`.contest-rule--${rule} input`).removeAttr('disabled'); + $(`.contest-rule--${rule}`).show(); + }).trigger('change'); + $('[name="beginAtDate"], [name="beginAtTime"], [name="duration"]').on('change', () => { + const beginAtDate = $('[name="beginAtDate"]').val(); + const beginAtTime = $('[name="beginAtTime"]').val(); + const duration = $('[name="duration"]').val(); + const endAt = moment(`${beginAtDate} ${beginAtTime}`).add(+duration, 'hours').toDate(); + if (endAt) $('[name="endAt"]').val(moment(endAt).format('YYYY-MM-DD HH:mm')); + }); + $('[name="permission"]').removeAttr('disabled').on('change', () => { + const type = $('[name="permission"]').val(); + $('[data-perm] input').attr('disabled', 'disabled'); + $('[data-perm]').hide(); + $(`[data-perm="${type}"] input`).removeAttr('disabled'); + $(`[data-perm="${type}"]`).show(); + }).trigger('change'); if (pagename.endsWith('edit')) { let confirmed = false; - $(document).on('click', '[name="operation"]', (ev) => { + $(document).on('click', '[value="delete"]', (ev) => { ev.preventDefault(); if (confirmed) { - return request.post('.', { operation: 'delete' }).then((res) => { + return request.post('', { operation: 'delete' }).then((res) => { window.location.href = res.url; }); } 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/pages/contest_scoreboard.page.ts b/packages/ui-default/pages/contest_scoreboard.page.ts new file mode 100644 index 000000000..3ea594b30 --- /dev/null +++ b/packages/ui-default/pages/contest_scoreboard.page.ts @@ -0,0 +1,39 @@ +import $ from 'jquery'; +import { NamedPage } from 'vj/misc/Page'; + +const page = new NamedPage('contest_scoreboard', () => { + const { tdoc } = UiContext; + const key = `scoreboard-star/${tdoc.domainId}/${tdoc.docId}`; + const read = () => JSON.parse(localStorage.getItem(key) || '[]'); + const write = (data) => localStorage.setItem(key, JSON.stringify(data)); + + read().forEach((uid) => $(`.star.user--${uid}`).addClass('activated')); + $('.star').on('click', (e) => { + const star = read(); + const $target = $(e.currentTarget); + const uid = $target.data('uid'); + if (star.includes(uid)) { + $target.removeClass('activated'); + star.splice(star.indexOf(uid), 1); + } else { + $target.addClass('activated'); + star.push(uid); + } + write(star); + }); + + $('.select.filter').on('change', (e) => { + const val = $(e.target).val(); + if (val === 'all') { + $('.data-table tbody tr').show(); + } else if (val === 'star') { + $('.data-table tbody tr').hide(); + read().forEach((uid) => $(`.star.user--${uid}`).closest('tr').show()); + } else { + $('.data-table tbody tr').show(); + $('.rank--unrank').closest('tr').hide(); + } + }); +}); + +export default page; diff --git a/packages/ui-default/pages/contest_user.page.ts b/packages/ui-default/pages/contest_user.page.ts new file mode 100644 index 000000000..e955a6a47 --- /dev/null +++ b/packages/ui-default/pages/contest_user.page.ts @@ -0,0 +1,97 @@ +import $ from 'jquery'; +import UserSelectAutoComplete from 'vj/components/autocomplete/UserSelectAutoComplete'; +import { ActionDialog } from 'vj/components/dialog'; +import Notification from 'vj/components/notification'; +import { NamedPage } from 'vj/misc/Page'; +import { + i18n, pjax, request, tpl, +} from 'vj/utils'; + +const page = new NamedPage('contest_user', () => { + const addUserDialogContent = $(tpl` +
+
+

${i18n('Add User')}

+
+
+ +
+
+ +
+
+ `); + addUserDialogContent.appendTo(document.body); + const userSelect = UserSelectAutoComplete.getOrConstruct>( + addUserDialogContent.find('[name="add_user_users"]'), + { multi: true, height: 'auto' }, + ); + + const addUserDialog = new ActionDialog({ + $body: addUserDialogContent, + onDispatch(action) { + if (action === 'ok' && !userSelect.value()) { + userSelect.focus(); + return false; + } + return true; + }, + }); + addUserDialog.clear = function () { + userSelect.clear(); + addUserDialog.$dom.find('[name="unrank"]').prop('checked', false); + return this; + }; + + 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 has been added.')); + pjax.request({ push: false }); + } + } catch (error) { + Notification.error([error.message, ...error.params].join(' ')); + } + } + + 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('User Rank has been edited.')); + pjax.request({ push: false }); + } + } catch (error) { + Notification.error([error.message, ...error.params].join(' ')); + } + } + + $('[name="add_user"]').on('click', () => handleClickAddUser()); + $(document).on('click', '[name="edit_rank"]', handleEditRank); +}); + +export default page; diff --git a/packages/ui-default/pages/files.page.js b/packages/ui-default/pages/files.page.js index dd16ab053..5eeed6270 100644 --- a/packages/ui-default/pages/files.page.js +++ b/packages/ui-default/pages/files.page.js @@ -92,7 +92,7 @@ function handleDrop(ev) { handleClickUpload(files); } -const page = new NamedPage(['home_files', 'contest_files'], () => { +const page = new NamedPage(['home_files', 'contest_files', 'training_files'], () => { $(document).on('click', '[name="upload_file"]', () => handleClickUpload()); $(document).on('click', '[name="remove_selected"]', () => handleClickRemoveSelected()); $(document).on('dragover', '.files', (ev) => handleDragOver(ev)); diff --git a/packages/ui-default/pages/files.page.styl b/packages/ui-default/pages/files.page.styl index 435a0938d..9108c57c9 100644 --- a/packages/ui-default/pages/files.page.styl +++ b/packages/ui-default/pages/files.page.styl @@ -1,4 +1,4 @@ -.page--home_files,.page--contest_files +.page--home_files,.page--contest_files,.page--training_files .col--checkbox width: 60px diff --git a/packages/ui-default/templates/components/form.html b/packages/ui-default/templates/components/form.html index 47d3fc122..04df8ee1d 100644 --- a/packages/ui-default/templates/components/form.html +++ b/packages/ui-default/templates/components/form.html @@ -199,13 +199,13 @@
{% endmacro %} {% macro form_checkbox(args) %} -{{ form_begin({columns:5, no_label:true}|assign(args)) }} +{{ form_begin({columns:5}|assign(args)) }} {{ checkbox(args) }} -{{ form_end({no_label:true}|assign(args)) }} +{{ form_end(args) }} {% endmacro %} 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..16c9b9691 100644 --- a/packages/ui-default/templates/contest_edit.html +++ b/packages/ui-default/templates/contest_edit.html @@ -3,8 +3,11 @@
-
-
+ +
+

{{ _('Basic Info') }}

+
+
{{ form.form_select({ columns:3, @@ -15,7 +18,7 @@ row:false }) }} {{ form.form_text({ - columns:4, + columns:9, label:'Title', name:'title', placeholder:_('title'), @@ -23,22 +26,6 @@ autofocus:true, row:false }) }} - {{ form.form_text({ - columns:3, - label:'Invitation Code', - name:'code', - placeholder:_('(leave blank if none)'), - value:tdoc._code|default(''), - row:false - }) }} - {{ form.form_checkbox({ - columns:2, - label:'Rated', - name:'rated', - placeholder:_('Is rated'), - value:tdoc.rated|default(true), - row:false - }) }}
{{ form.form_text({ @@ -68,56 +55,151 @@ }) }} {{ form.form_text({ columns:4, - label:'Assign', - name:'assign', - placeholder:_('Group / UID'), - value:tdoc.assign|default(''), + label:'End Time', + name:'endAt', + placeholder:'YYYY-mm-dd HH:MM', + value:beginAt.add(duration, 'hours').format('YYYY-M-D HH:mm'), + disabled:true, row:false }) }}
-
{{ form.form_text({ - columns:9, + columns:12, label:'Problems', name:'pids', value:pids, placeholder:_("Seperated with ','"), - row:false + row:true }) }} - {{ form.form_checkbox({ - columns:3, - label:'Auto hide-and show', - name:'autoHide', - placeholder:'', - value:handler.user.hasPerm(perm.PERM_EDIT_PROBLEM) and (tdoc.autoHide|default(true)), - disabled:not handler.user.hasPerm(perm.PERM_EDIT_PROBLEM), - row:false - }) }} - {% if tdoc.lockAt %} - - {% endif %} -
{{ form.form_textarea({ columns:null, - label:'Content', + label:'Description', name:'content', value:tdoc['content']|default(''), markdown:true }) }} +
+
+

{{ _('Permission Control') }}

+
+
+ {{ form.form_text({ + columns:12, + label:'Contest Maintainer', + name:'maintainer', + help_text:_('Allow to maintain this contest'), + value:tdoc.maintainer|default('') + }) }} +
+ {{ form.form_select({ + columns:3, + label:'Permission Control', + name:'permission', + options:{ + 'public': _('Public'), + 'invite': _('Require Invitation Code'), + 'assign': _('Assign User or Group') + }, + value: ("public" if not tdoc._code else "invite") if not tdoc.assign else "assign", + disabled:true, + row:false + }) }} +
+ {{ form.form_text({ + columns:9, + label:'Assign', + name:'assign', + placeholder:_('Group / UID'), + value:tdoc.assign|default(''), + disabled:true, + row:false + }) }} +
+
+ {{ form.form_text({ + columns:9, + label:'Invitation Code', + name:'code', + placeholder:_('(leave blank if none)'), + value:tdoc._code|default(''), + disabled:true, + row:false + }) }} +
+
+
+
+

{{ _('Contest Settings') }}

+
+
+
+ {{ form.form_checkbox({ + columns:2, + label:'Rated', + name:'rated', + placeholder:_('Is rated'), + value:tdoc.rated|default(true), + row:false + }) }} + {{ form.form_checkbox({ + columns:3, + label:'Auto hide-and show', + name:'autoHide', + placeholder:_('Auto hide problems during the contest'), + value:handler.user.hasPerm(perm.PERM_EDIT_PROBLEM) and (tdoc.autoHide|default(true)), + disabled:not handler.user.hasPerm(perm.PERM_EDIT_PROBLEM), + row:false + }) }} + {{ form.form_checkbox({ + columns:3, + label:'Allow View Code', + name:'allowViewCode', + placeholder:_('Allow user view others code after contest'), + value:tdoc.allowViewCode|default(true), + row:false + }) }} + + +
{% if page_name == 'contest_edit' %} - + {% if handler.user.own(tdoc) or handler.user.hasPerm(perm.PERM_EDIT_CONTEST) %} {% endif %} {% else %} - {% endif %} diff --git a/packages/ui-default/templates/contest_files.html b/packages/ui-default/templates/contest_files.html index 2e2e515bf..4a0d233e9 100644 --- a/packages/ui-default/templates/contest_files.html +++ b/packages/ui-default/templates/contest_files.html @@ -1,7 +1,4 @@ {% 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) }}
diff --git a/packages/ui-default/templates/contest_problemlist.html b/packages/ui-default/templates/contest_problemlist.html new file mode 100644 index 000000000..742a97e04 --- /dev/null +++ b/packages/ui-default/templates/contest_problemlist.html @@ -0,0 +1,104 @@ +{% 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') }}- + + {{ String.fromCharCode(65+loop.index0) }}  {{ pdict[pid].title }} + +
+
+
+
+
+ {% if not canViewRecord %} + {{ nothing.render('According to the contest rules, you cannot view your submission details at current.') }}' + {% elif not rdocs.length %} + {{ nothing.render('Oh, there is no submission!') }} + {% 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 diff --git a/packages/ui-default/templates/contest_scoreboard.html b/packages/ui-default/templates/contest_scoreboard.html index 463d0caa4..17caf4535 100644 --- a/packages/ui-default/templates/contest_scoreboard.html +++ b/packages/ui-default/templates/contest_scoreboard.html @@ -16,17 +16,24 @@
{%- for ext in ['HTML','CSV','Ghost'] -%} - {{ _('Export as '+ext) }} + {{ _('Export as {0}').format(ext) }} {%- endfor -%} {% if model.contest.isDone(tdoc) and tdoc.lockAt and handler.user.own(tdoc) and not tdoc.unlocked %} - {% endif %} +
+ +
{% if model.contest.isLocked(tdoc) %}
@@ -69,8 +76,14 @@ {%- for column in row -%} - {%- if column.type == 'user' -%} + {%- if column.type == 'rank' -%} + {{ '*' if column.value == '0' else column.value|string|nl2br|safe }} + {%- elif column.type == 'user' -%} {%- set canView = canViewAll or handler.user._id == column.raw -%} + {{ user.render_inline(udict[column.raw], badge=false) }} {%- elif column.type == 'record' and column.raw -%} diff --git a/packages/ui-default/templates/contest_user.html b/packages/ui-default/templates/contest_user.html new file mode 100644 index 000000000..5641730dd --- /dev/null +++ b/packages/ui-default/templates/contest_user.html @@ -0,0 +1,47 @@ +{% 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) }} +
+
+
+
+

{{ _('Attendee Manage') }}

+
+ +
+
+ {{ noscript_note.render() }} +
+ + + + + + + + + + + + + + + + + + + {% include "partials/contest_user.html" %} +
{{ _('User ID') }}{{ _('Username') }}{{ _('Begin Time') }} + {{ _('End Time') }}{{ _('Rank') }}{{ _('Actions') }}
+
+
+
+
+ {% set owner_udoc = udict[tdoc.owner] %} + {% include "partials/contest_sidebar.html" %} +
+
+{% endblock %} diff --git a/packages/ui-default/templates/homework_edit.html b/packages/ui-default/templates/homework_edit.html index 36db68b30..a020d219b 100644 --- a/packages/ui-default/templates/homework_edit.html +++ b/packages/ui-default/templates/homework_edit.html @@ -95,6 +95,9 @@ + {% if handler.user.own(tdoc) or handler.user.hasPerm(perm.PERM_EDIT_HOMEWORK) %}
diff --git a/packages/ui-default/templates/partials/setting.html b/packages/ui-default/templates/partials/setting.html index 6c76bc50a..ecb4f36f3 100644 --- a/packages/ui-default/templates/partials/setting.html +++ b/packages/ui-default/templates/partials/setting.html @@ -69,7 +69,7 @@ {% set _val = current[setting.key] %} {{ form.form_checkbox({ label:setting.name, - help_text:setting.desc, + placeholder:setting.desc, name:setting.key, value:setting.value if (_val === undefined or _val === null) else _val, disabled:setting.flag|bitand(2) @@ -78,7 +78,8 @@ label:setting.name, name:'booleanKeys.' + setting.key, value:true, - extra_class:'display-hidden' + extra_class:'display-hidden', + no_label:true })}} {% endif %} {% endif %} diff --git a/packages/ui-default/templates/problem_detail.html b/packages/ui-default/templates/problem_detail.html index 1563fa6e3..3237d79a7 100644 --- a/packages/ui-default/templates/problem_detail.html +++ b/packages/ui-default/templates/problem_detail.html @@ -44,7 +44,7 @@ {% endif %} - #{{ pdoc.pid|default(pdoc.docId) }}. {{ pdoc.title }} + #{{ String.fromCharCode(65+tdoc.pids.indexOf(pdoc.docId)) if tdoc else pdoc.pid|default(pdoc.docId) }}. {{ pdoc.title }} {% if tdoc %}
diff --git a/packages/ui-default/templates/problem_edit.html b/packages/ui-default/templates/problem_edit.html index a1846e4bc..30bc24a6e 100644 --- a/packages/ui-default/templates/problem_edit.html +++ b/packages/ui-default/templates/problem_edit.html @@ -25,7 +25,7 @@
diff --git a/packages/ui-default/templates/training_detail.html b/packages/ui-default/templates/training_detail.html index 93738b524..c56c56ee5 100644 --- a/packages/ui-default/templates/training_detail.html +++ b/packages/ui-default/templates/training_detail.html @@ -75,6 +75,9 @@ + {% endif %}