Skip to content
Merged
Show file tree
Hide file tree
Changes from 10 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 6 additions & 2 deletions framework/framework/base.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,8 +64,12 @@ export default (logger, xff, xhost) => async (ctx: KoaContext, next: Next) => {
}
if (!response.type) {
if (response.pjax && args.pjax) {
const html = await handler.renderHTML(response.pjax, response.body);
response.body = { fragments: [{ html }] };
const pjax = typeof response.pjax === 'string' ? [[response.pjax, {}]] : response.pjax;
response.body = {
fragments: (await Promise.all(
pjax.map(async ([template, extra]) => handler.renderHTML(template, { ...response.body, ...extra })),
)).map((i) => ({ html: i })),
};
response.type = 'application/json';
} else if (
request.json || response.redirect
Expand Down
2 changes: 1 addition & 1 deletion framework/framework/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,7 @@ export interface HydroResponse {
* If set, and pjax content was request from client,
* The template will be used for rendering.
*/
pjax?: string;
pjax?: string | (readonly [string, Record<string, any>])[];
redirect?: string;
disposition?: string;
etag?: string;
Expand Down
11 changes: 7 additions & 4 deletions packages/hydrooj/locales/zh.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -153,13 +153,14 @@ Contact Us: 联系我们
Contact: 联系
content: 内容
Content: 内容
Contest {0} has a new clarification about {1}, please go to contest management to reply.: 比赛 {0} 有一条关于 {1} 的新问答,请前往比赛管理页面回复。
Contest {0} jury replied to your clarification, please go to contest page to view.: 比赛 {0} 的裁判回复了您的问答,请前往比赛页面查看。
Contest Clarifications: 问答
Contest {0} has a new clarification about {1}, please go to contest management to reply.: 比赛 {0} 有一条关于 {1} 的新答疑,请前往比赛管理页面回复。
Contest {0} jury replied to your clarification, please go to contest page to view.: 比赛 {0} 的裁判回复了您的答疑,请前往比赛页面查看。
Contest Clarifications: 赛时答疑
Contest Management: 比赛管理
Contest scoreboard is not visible.: 当前比赛成绩表隐藏,暂不可显示。
Contest Scoreboard: 比赛成绩表
contest_balloon: 气球状态
contest_clarification: 赛时答疑
contest_create: 创建比赛
contest_detail_problem_submit: 递交比赛题目
contest_detail_problem: 题目详情
Expand Down Expand Up @@ -476,6 +477,7 @@ Manage: 管理
Manager: 管理
Mark Green: 标绿
Mark Yellow: 标黄
Materials: 材料
Max files for user: 单用户最大文件数量
Max files per problem: 单题最大文件数量
Max files size per problem: 单题最大总文件大小
Expand Down Expand Up @@ -532,10 +534,11 @@ Oh, the user doesn't have any contributions!: 啊哦,这个用户还没贡献
Oh, the user hasn't created any discussions yet!: 这个用户还没有发布过讨论
Oh, the user hasn't submitted yet!: 这个用户还没有交过题 _(:зゝ∠)_
Oh, there are no tasks that match the filter!: 喔,目前没有符合过滤条件的任务。
Oh, there is no clarification!: 喔,目前没有提问
Oh, there is no clarification!: 喔,目前没有答疑
Oh, there is no task in the queue!: 喔,队列中目前没有任务。
Ok: 确定
Only A-Z, a-z, 0-9 and _ are accepted: 只接受 A-Z, a-z, 0-9 和 _
Only Partic.: 仅参赛者
Oops, there are no results.: 呀,没有结果。
Oops!: Oops!
Open in Problem Set: 在题库中打开
Expand Down
157 changes: 100 additions & 57 deletions packages/hydrooj/src/handler/contest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import {
ContestScoreboardHiddenError, FileLimitExceededError, FileUploadError,
InvalidTokenError, NotAssignedError, NotFoundError, PermissionError, ValidationError,
} from '../error';
import { ScoreboardConfig, Tdoc } from '../interface';
import { FileInfo, ScoreboardConfig, Tdoc } from '../interface';
import { PERM, PRIV, STATUS } from '../model/builtin';
import * as contest from '../model/contest';
import * as discussion from '../model/discussion';
Expand All @@ -25,7 +25,6 @@ import problem from '../model/problem';
import record from '../model/record';
import ScheduleModel from '../model/schedule';
import storage from '../model/storage';
import * as system from '../model/system';
import user from '../model/user';
import {
Handler, param, post, Type, Types,
Expand Down Expand Up @@ -155,13 +154,13 @@ export class ContestDetailHandler extends ContestDetailBaseHandler {
tdoc: this.tdoc,
tsdoc: this.tsdocAsPublic(),
udict,
files: sortFiles(this.tdoc.files || []),
urlForFile: (filename: string) => this.url('contest_file_download', { tid, filename }),
files: (this.tsdoc?.attend && !contest.isNotStarted(this.tdoc)) ? sortFiles(this.tdoc.privateFiles || []) : [],
urlForFile: (filename: string) => this.url('contest_file_download', { tid, filename, type: 'private' }),
};
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/`);
.replace(/\(file:\/\//g, `(./${this.tdoc.docId}/file/public/`)
.replace(/="file:\/\//g, `="./${this.tdoc.docId}/file/public/`);
}

@param('tid', Types.ObjectId)
Expand Down Expand Up @@ -358,6 +357,8 @@ export class ContestEditHandler extends Handler {
pids: tid ? this.tdoc.pids.join(',') : '',
beginAt,
page_name: tid ? 'contest_edit' : 'contest_create',
files: tid ? this.tdoc.files : [],
urlForFile: (filename: string) => this.url('contest_file_download', { tid, filename, type: 'public' }),
};
}

Expand Down Expand Up @@ -443,7 +444,11 @@ export class ContestEditHandler extends Handler {
ScheduleModel.deleteMany({
type: 'schedule', subType: 'contest', domainId, tid,
}),
storage.del(this.tdoc.files?.map((i) => `contest/${domainId}/${tid}/${i.name}`) || [], this.user._id),
storage.del(
(this.tdoc.files?.map((i) => `contest/${domainId}/${tid}/public/${i.name}`) || [])
.concat(this.tdoc.privateFiles?.map((i) => `contest/${domainId}/${tid}/private/${i.name}`) || []),
this.user._id,
),
]));
this.response.redirect = this.url('contest_main');
}
Expand Down Expand Up @@ -506,22 +511,95 @@ export class ContestCodeHandler extends Handler {
export class ContestManagementHandler extends ContestManagementBaseHandler {
@param('tid', Types.ObjectId)
async get(domainId: string, tid: ObjectId) {
const tcdocs = await contest.getMultiClarification(domainId, tid);
this.response.body = {
tdoc: this.tdoc,
tsdoc: this.tsdoc,
owner_udoc: await user.getById(domainId, this.tdoc.owner),
pdict: await problem.getList(domainId, this.tdoc.pids, true, true, [...problem.PROJECTION_CONTEST_LIST, 'tag']),
files: sortFiles(this.tdoc.files || []),
privateFiles: sortFiles(this.tdoc.privateFiles || []),
urlForFile: (filename: string, type: string) => this.url('contest_file_download', { tid, filename, type }),
};
this.response.pjax = [
['partials/files.html', { filetype: 'public' }],
['partials/files.html', {
files: this.response.body.privateFiles,
filetype: 'private',
}],
];
this.response.template = 'contest_manage.html';
}

@param('tid', Types.ObjectId)
@post('filename', Types.Filename, true)
@post('type', Types.Range(['private', 'public']), true)
async postUploadFile(domainId: string, tid: ObjectId, filename: string, type: 'private' | 'public' = 'private') {
const allFiles = [...(this.tdoc.files || []), ...(this.tdoc.privateFiles || [])];
if (allFiles.length >= this.ctx.setting.get('limit.contest_files')) {
throw new FileLimitExceededError('count');
}
const file = this.request.files?.file;
if (!file) throw new ValidationError('file');
if (Math.sum(allFiles.map((i) => i.size)) + file.size >= this.ctx.setting.get('limit.contest_files_size')) {
throw new FileLimitExceededError('size');
}
Comment thread
undefined-moe marked this conversation as resolved.
filename ||= file.originalFilename || randomstring(16);
const target = `contest/${domainId}/${tid}/${type}/${filename}`;
await storage.put(target, file.filepath, this.user._id);
const meta = await storage.getMeta(target);
const payload = { _id: filename, name: filename, ...pick(meta, ['size', 'lastModified', 'etag']) };
if (!meta) throw new FileUploadError();
const updateList = (files: FileInfo[], newFile: FileInfo) => (files || []).filter((i) => i._id !== newFile._id).concat(newFile);
await contest.edit(domainId, tid, {
files: type === 'private' ? this.tdoc.files : updateList(this.tdoc.files, payload),
privateFiles: type === 'private' ? updateList(this.tdoc.privateFiles, payload) : this.tdoc.privateFiles,
});
this.back();
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

@param('tid', Types.ObjectId)
@post('files', Types.ArrayOf(Types.Filename))
@post('type', Types.Range(['public', 'private']), true)
async postDeleteFiles(domainId: string, tid: ObjectId, files: string[], type = 'private') {
await Promise.all([
storage.del(files.map((t) => `contest/${domainId}/${tid}/${type}/${t}`), this.user._id),
contest.edit(domainId, tid, type === 'private'
? { privateFiles: this.tdoc.privateFiles.filter((i) => !files.includes(i.name)) }
: { files: this.tdoc.files.filter((i) => !files.includes(i.name)) },
Comment thread
undefined-moe marked this conversation as resolved.
Outdated
),
]);
this.back();
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

@param('pid', Types.PositiveInt)
@param('score', Types.PositiveInt)
async postSetScore(domainId: string, pid: number, score: number) {
if (!this.tdoc.pids.includes(pid)) throw new ValidationError('pid');
this.tdoc.score ||= {};
this.tdoc.score[pid] = score;
await contest.edit(domainId, this.tdoc.docId, { score: this.tdoc.score });
await contest.recalcStatus(domainId, this.tdoc.docId);
this.back();
}
}

class ContestClarificationHandler extends ContestManagementBaseHandler {
@param('tid', Types.ObjectId)
async get(domainId: string, tid: ObjectId) {
const tcdocs = await contest.getMultiClarification(domainId, tid);
this.response.body = {
tdoc: this.tdoc,
tsdoc: this.tsdoc,
owner_udoc: await user.getById(domainId, this.tdoc.owner),
pdict: await problem.getList(domainId, this.tdoc.pids, true, true, [...problem.PROJECTION_CONTEST_LIST, 'tag']),
tcdocs,
udict: await user.getListForRender(
domainId, tcdocs.map((i) => i.owner),
this.user.hasPerm(PERM.PERM_VIEW_DISPLAYNAME) ? ['displayName'] : [],
),
tcdocs,
urlForFile: (filename: string) => this.url('contest_file_download', { tid, filename }),
};
this.response.pjax = 'partials/files.html';
this.response.template = 'contest_manage.html';
this.response.pjax = 'partials/contest_clarification.html';
this.response.template = 'contest_clarification.html';
}

@param('tid', Types.ObjectId)
Expand Down Expand Up @@ -554,57 +632,21 @@ export class ContestManagementHandler extends ContestManagementBaseHandler {
}
this.back();
}

@param('tid', Types.ObjectId)
@post('filename', Types.Filename, 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 size = Math.sum((this.tdoc.files || []).map((i) => i.size)) + file.size;
if (size >= system.get('limit.contest_files_size')) {
throw new FileLimitExceededError('size');
}
filename ||= file.originalFilename || randomstring(16);
await storage.put(`contest/${domainId}/${tid}/${filename}`, file.filepath, this.user._id);
const meta = await storage.getMeta(`contest/${domainId}/${tid}/${filename}`);
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();
}

@param('tid', Types.ObjectId)
@post('files', Types.ArrayOf(Types.Filename))
async postDeleteFiles(domainId: string, tid: ObjectId, files: string[]) {
await Promise.all([
storage.del(files.map((t) => `contest/${domainId}/${tid}/${t}`), this.user._id),
contest.edit(domainId, tid, { files: this.tdoc.files.filter((i) => !files.includes(i.name)) }),
]);
this.back();
}

@param('pid', Types.PositiveInt)
@param('score', Types.PositiveInt)
async postSetScore(domainId: string, pid: number, score: number) {
if (!this.tdoc.pids.includes(pid)) throw new ValidationError('pid');
this.tdoc.score ||= {};
this.tdoc.score[pid] = score;
await contest.edit(domainId, this.tdoc.docId, { score: this.tdoc.score });
await contest.recalcStatus(domainId, this.tdoc.docId);
this.back();
}
}

export class ContestFileDownloadHandler extends ContestDetailBaseHandler {
@param('tid', Types.ObjectId)
@param('filename', Types.Filename)
@param('noDisposition', Types.Boolean)
async get(domainId: string, tid: ObjectId, filename: string, noDisposition = false) {
@param('type', Types.Range(['public', 'private']), true)
async get(domainId: string, tid: ObjectId, filename: string, noDisposition = false, type = 'private') {
if (type === 'private' && !this.user.own(this.tdoc)) {
if (!this.tsdoc?.attend) throw new ContestNotAttendedError(domainId, tid);
if (!contest.isOngoing(this.tdoc) && !contest.isDone(this.tdoc)) throw new ContestNotLiveError(domainId, tid);
if (!this.tsdoc.startAt) await contest.setStatus(domainId, tid, this.user._id, { startAt: new Date() });
}
this.response.addHeader('Cache-Control', 'public');
const target = `contest/${domainId}/${tid}/${filename}`;
const target = `contest/${domainId}/${tid}/${type}/${filename}`;
const file = await storage.getMeta(target);
await oplog.log(this, 'download.file.contest', {
target,
Expand Down Expand Up @@ -809,8 +851,9 @@ export async function apply(ctx: Context) {
ctx.Route('contest_edit', '/contest/:tid/edit', ContestEditHandler, PERM.PERM_VIEW_CONTEST);
ctx.Route('contest_print', '/contest/:tid/print', ContestPrintHandler, PERM.PERM_VIEW_CONTEST);
ctx.Route('contest_manage', '/contest/:tid/management', ContestManagementHandler);
ctx.Route('contest_clarification', '/contest/:tid/clarification', ContestClarificationHandler);
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);
ctx.Route('contest_file_download', '/contest/:tid/file/:type/:filename', ContestFileDownloadHandler, PERM.PERM_VIEW_CONTEST);
ctx.Route('contest_user', '/contest/:tid/user', ContestUserHandler, PERM.PERM_VIEW_CONTEST);
ctx.Route('contest_balloon', '/contest/:tid/balloon', ContestBalloonHandler, PERM.PERM_VIEW_CONTEST);
ctx.worker.addHandler('contest', async (doc) => {
Expand Down
25 changes: 6 additions & 19 deletions packages/hydrooj/src/handler/problem.ts
Original file line number Diff line number Diff line change
Expand Up @@ -646,28 +646,15 @@ export class ProblemFilesHandler extends ProblemDetailHandler {
notUsage = true;

@param('d', Types.CommaSeperatedArray, true)
@param('pjax', Types.Boolean)
@param('sidebar', Types.Boolean)
async get(domainId: string, d = ['testdata', 'additional_file'], pjax = false, sidebar = false) {
async get({ }, d = ['testdata', 'additional_file'], sidebar = false) {
Comment thread
undefined-moe marked this conversation as resolved.
if (this.tdoc) throw new ContestNotEndedError();
this.response.body.testdata = d.includes('testdata') ? sortFiles(this.pdoc.data || []) : [];
this.response.body.testdata = sortFiles(this.pdoc.data || []);
this.response.body.additional_file = sortFiles(this.pdoc.additional_file || []);
this.response.body.reference = this.pdoc.reference;
this.response.body.additional_file = d.includes('additional_file') ? sortFiles(this.pdoc.additional_file || []) : [];
if (pjax) {
const { testdata, additional_file } = this.response.body;
const owner = await user.getById(domainId, this.pdoc.owner);
const args = {
testdata, additional_file, pdoc: this.pdoc, owner_udoc: owner, sidebar, can_edit: true,
};
const tasks = [];
if (d.includes('testdata')) tasks.push(this.renderHTML('partials/problem_files.html', { ...args, filetype: 'testdata' }));
if (d.includes('additional_file')) tasks.push(this.renderHTML('partials/problem_files.html', { ...args, filetype: 'additional_file' }));
if (!sidebar) tasks.push(this.renderHTML('partials/problem-sidebar-information.html', args));
this.response.body = {
fragments: (await Promise.all(tasks)).map((i) => ({ html: i })),
};
this.response.template = '';
} else this.response.template = 'problem_files.html';
this.response.pjax = d.map((i) => ['partials/problem_files.html', { filetype: i, sidebar, can_edit: true }]);
if (!sidebar) this.response.pjax.push(['partials/problem-sidebar-information.html', {}]);
this.response.template = 'problem_files.html';
Comment thread
undefined-moe marked this conversation as resolved.
}

async post() {
Expand Down
1 change: 1 addition & 0 deletions packages/hydrooj/src/interface.ts
Original file line number Diff line number Diff line change
Expand Up @@ -264,6 +264,7 @@ export interface Tdoc extends Document {
_code?: string;
assign?: string[];
files?: FileInfo[];
privateFiles?: FileInfo[];
allowViewCode?: boolean;
allowPrint?: boolean;

Expand Down
7 changes: 4 additions & 3 deletions packages/ui-default/components/upload.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { Classes } from '@blueprintjs/core';
import $ from 'jquery';
import React from 'react';
import { Dialog } from 'vj/components/dialog/index';
import Notification from 'vj/components/notification';
import {
Expand Down Expand Up @@ -82,9 +83,9 @@ export default async function uploadFiles(endpoint = '', files: File[] | FileLis
Notification.success(i18n('File uploaded successfully.'));
if (options.pjax) {
let params = '';
if (options.type) params += `? d = ${options.type} `;
if (options.sidebar) params += `${params ? '&' : '?'} sidebar = true`;
await pjax.request({ push: false, url: `${endpoint}${params || ''} ` });
if (options.type) params += `?d=${options.type}`;
if (options.sidebar) params += `${params ? '&' : '?'}sidebar=true`;
await pjax.request({ push: false, url: `${endpoint}${params || ''}` });
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
}
} catch (e) {
console.error(e);
Expand Down
5 changes: 3 additions & 2 deletions packages/ui-default/locales/zh.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,7 @@ Add suffix: 添加后缀
Add testcase: 添加测试点
Add User: 添加用户
Add: 添加
Additional Files: 附加文件
additional_file: 附加文件
All {0} Contests: 所有 {0} 比赛
All changes will be lost. Are you sure to clear all answers?: 所有更改将会丢失。您确定要清除所有答案吗?
Expand Down Expand Up @@ -391,8 +392,8 @@ Failed to parse testcase.: 无法解析测试点信息。
Feedback: 反馈
Field {0} or {1} validation failed.: 字段 {0} 或 {1} 验证失败。
Field {0} validation failed.: 字段 {0} 验证失败。
File have been deleted.: 文件已被删除。
File have been renamed.: 文件已被重命名。
File has been deleted.: 文件已被删除。
File has been renamed.: 文件已被重命名。
File saved.: 文件已成功保存。
file too large: 文件过大
File uploaded successfully.: 上传文件成功
Expand Down
2 changes: 2 additions & 0 deletions packages/ui-default/pages/contest.page.styl
Original file line number Diff line number Diff line change
Expand Up @@ -217,5 +217,7 @@ $highlight-button-color = #F6DF45
width: rem(130px)

.page--contest_manage
.col--id
width: rem(80px)
.col--score
width: rem(80px)
Loading
Loading