mirror of
https://gitcode.com/gh_mirrors/hy/Hydro.git
synced 2026-10-17 09:36:08 +08:00
bug fixes & improvements for on site contest
This commit is contained in:
19 files changed
+378
-63
No files matched your search
@@ -6,9 +6,9 @@
|
||||
"repository": "https://github.com/hydro-dev/Hydro",
|
||||
"dependencies": {
|
||||
"@antfu/eslint-config": "^6.2.0",
|
||||
"@eslint-react/eslint-plugin": "^2.3.4",
|
||||
"@typescript-eslint/eslint-plugin": "^8.46.4",
|
||||
"@typescript-eslint/parser": "^8.46.4",
|
||||
"@eslint-react/eslint-plugin": "^2.3.5",
|
||||
"@typescript-eslint/eslint-plugin": "^8.47.0",
|
||||
"@typescript-eslint/parser": "^8.47.0",
|
||||
"eslint-plugin-de-morgan": "^2.0.0",
|
||||
"eslint-plugin-github": "^6.0.0",
|
||||
"eslint-plugin-react-hooks": "^7.0.1",
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
"@hydrooj/register": "workspace:^",
|
||||
"cac": "^6.7.14",
|
||||
"fs-extra": "^11.3.2",
|
||||
"js-yaml": "^4.1.0",
|
||||
"js-yaml": "^4.1.1",
|
||||
"reggol": "^2.1.0",
|
||||
"search-query-parser": "^1.6.0",
|
||||
"systeminformation": "^5.27.11"
|
||||
|
||||
+1
-1
@@ -53,7 +53,7 @@
|
||||
"mongodb-memory-server": "10.3.0",
|
||||
"nyc": "^17.1.0",
|
||||
"ora": "^9.0.0",
|
||||
"oxlint": "^1.28.0",
|
||||
"oxlint": "^1.29.0",
|
||||
"package-json": "^10.0.1",
|
||||
"semver": "^7.7.3",
|
||||
"simple-git": "^3.30.0",
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
"dependencies": {
|
||||
"allotment": "^1.20.4",
|
||||
"diff": "^8.0.2",
|
||||
"js-yaml": "^4.1.0",
|
||||
"js-yaml": "^4.1.1",
|
||||
"lodash": "^4.17.21",
|
||||
"react-dnd": "^16.0.1",
|
||||
"react-dnd-html5-backend": "^16.0.1",
|
||||
@@ -21,7 +21,7 @@
|
||||
"@types/diff": "^8.0.0",
|
||||
"@types/js-yaml": "^4.0.9",
|
||||
"@types/lodash": "^4.17.20",
|
||||
"sass": "^1.94.0"
|
||||
"sass": "^1.94.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "*",
|
||||
|
||||
@@ -32,7 +32,7 @@
|
||||
"emoji-regex": "^10.6.0",
|
||||
"emojis-list": "2.1.0",
|
||||
"fs-extra": "^11.3.2",
|
||||
"js-yaml": "^4.1.0",
|
||||
"js-yaml": "^4.1.1",
|
||||
"koa-proxies": "^0.12.4",
|
||||
"koa-static-cache": "^5.1.4",
|
||||
"lodash": "^4.17.21",
|
||||
@@ -63,7 +63,7 @@
|
||||
"@types/lodash": "^4.17.20",
|
||||
"@types/mime-types": "^3.0.1",
|
||||
"@types/mongodb-uri": "^0.9.4",
|
||||
"@types/nodemailer": "^7.0.3",
|
||||
"@types/nodemailer": "^7.0.4",
|
||||
"@types/notp": "^2.0.5",
|
||||
"@types/semver": "^7.7.1",
|
||||
"@types/superagent": "^8.1.9",
|
||||
|
||||
@@ -12,7 +12,7 @@ import { Context, Service } from '../context';
|
||||
import {
|
||||
BadRequestError, ContestNotAttendedError, ContestNotEndedError, ContestNotFoundError, ContestNotLiveError,
|
||||
ContestScoreboardHiddenError, FileLimitExceededError, FileUploadError,
|
||||
InvalidTokenError, NotAssignedError, NotFoundError, PermissionError, ValidationError,
|
||||
InvalidTokenError, MethodNotAllowedError, NotAssignedError, NotFoundError, PermissionError, ValidationError,
|
||||
} from '../error';
|
||||
import { FileInfo, ScoreboardConfig, Tdoc } from '../interface';
|
||||
import { PERM, PRIV, STATUS } from '../model/builtin';
|
||||
@@ -126,6 +126,11 @@ export class ContestDetailBaseHandler extends Handler {
|
||||
args: { tid, prefix: 'contest_problemlist' },
|
||||
checker: () => this.tsdoc?.attend || contest.isDone(this.tdoc),
|
||||
},
|
||||
{
|
||||
name: 'contest_print',
|
||||
args: { tid, prefix: 'contest_print' },
|
||||
checker: () => this.tdoc.allowPrint && (this.tsdoc?.attend || this.user.own(this.tdoc) || this.user.hasPerm(PERM.PERM_EDIT_CONTEST)),
|
||||
},
|
||||
{
|
||||
name: 'contest_scoreboard',
|
||||
args: { tid, prefix: 'contest_scoreboard' },
|
||||
@@ -197,6 +202,24 @@ export class ContestPrintHandler extends ContestDetailBaseHandler {
|
||||
this.response.template = 'contest_print.html';
|
||||
}
|
||||
|
||||
async post() {
|
||||
if (this.args.operation) return;
|
||||
if (this.args.file_contents && this.args.original_name) {
|
||||
try {
|
||||
await (this.postPrint as any)({
|
||||
...this.args,
|
||||
title: this.args.original_name,
|
||||
content: Buffer.from(this.args.file_contents, 'base64').toString('utf-8'),
|
||||
});
|
||||
this.response.body = { success: true, output: '' };
|
||||
} catch (e) {
|
||||
this.response.body = { success: false, output: e.message };
|
||||
} finally {
|
||||
delete this.response.redirect;
|
||||
}
|
||||
} else throw new MethodNotAllowedError('POST');
|
||||
}
|
||||
|
||||
@param('tid', Types.ObjectId)
|
||||
@param('title', Types.Title, true)
|
||||
@param('content', Types.Content, true)
|
||||
@@ -758,8 +781,10 @@ export class ContestScoreboardHandler extends ContestDetailBaseHandler {
|
||||
@param('tid', Types.ObjectId)
|
||||
@param('view', Types.String, true)
|
||||
async get(domainId: string, tid: ObjectId, viewId = 'default') {
|
||||
if (!this.user.own(this.tdoc)) {
|
||||
if (!contest.canShowScoreboard.call(this, this.tdoc, true)) throw new ContestScoreboardHiddenError(tid);
|
||||
if (contest.isNotStarted(this.tdoc)) throw new ContestNotLiveError(domainId, tid);
|
||||
}
|
||||
const view = this.ctx.scoreboard.getView(viewId);
|
||||
if (!view) throw new NotFoundError(`View ${viewId} not found`);
|
||||
const args = {};
|
||||
@@ -846,6 +871,8 @@ export async function apply(ctx: Context) {
|
||||
ctx.Route('contest_problemlist', '/contest/:tid/problems', ContestProblemListHandler, PERM.PERM_VIEW_CONTEST);
|
||||
ctx.Route('contest_edit', '/contest/:tid/edit', ContestEditHandler, PERM.PERM_VIEW_CONTEST);
|
||||
ctx.Route('contest_print', '/contest/:tid/print', ContestPrintHandler, PERM.PERM_VIEW_CONTEST);
|
||||
// Support for DOMJudge printfile
|
||||
ctx.Route('contest_print_alt', '/contest/:tid/api/printing/team', 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);
|
||||
|
||||
@@ -519,7 +519,10 @@ export class ProblemSubmitHandler extends ProblemDetailHandler {
|
||||
await storage.put(`submission/${this.user._id}/${id}`, file.filepath, this.user._id);
|
||||
files.code = `${this.user._id}/${id}#${file.originalFilename}`;
|
||||
}
|
||||
} else if (code.length > lengthLimit) throw new ValidationError('code');
|
||||
} else {
|
||||
code = code.replace(/\r\n/g, '\n');
|
||||
if (code.length > lengthLimit) throw new ValidationError('code');
|
||||
}
|
||||
const rid = await record.add(
|
||||
domainId, this.pdoc.docId, this.user._id, lang, code, true,
|
||||
pretest ? { input, type: 'pretest' } : { contest: tid, files, type: 'judge' },
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
import { load } from 'js-yaml';
|
||||
import { normalizeSubtasks, ProblemConfigFile } from '@hydrooj/common';
|
||||
import { normalizeSubtasks, ProblemConfigFile, readSubtasksFromFiles } from '@hydrooj/common';
|
||||
import { readYamlCases } from '@hydrooj/common/cases';
|
||||
import { parseMemoryMB, parseTimeMS } from '@hydrooj/utils';
|
||||
import type { ProblemConfig } from '../interface';
|
||||
|
||||
export async function parseConfig(config: string | ProblemConfigFile = {}) {
|
||||
export async function parseConfig(config: string | ProblemConfigFile = {}, files: string[]) {
|
||||
const cfg: ProblemConfigFile = typeof config === 'string'
|
||||
? await readYamlCases(load(config) as Record<string, any>)
|
||||
: await readYamlCases(config);
|
||||
const result: ProblemConfig = {
|
||||
count: 0,
|
||||
count: Object.keys(cfg.answers || {}).length || Math.sum((cfg.subtasks || []).map((s) => s.cases.length)),
|
||||
memoryMin: Number.MAX_SAFE_INTEGER,
|
||||
memoryMax: 0,
|
||||
timeMin: Number.MAX_SAFE_INTEGER,
|
||||
@@ -19,6 +19,7 @@ export async function parseConfig(config: string | ProblemConfigFile = {}) {
|
||||
};
|
||||
if (cfg.subType) result.subType = cfg.subType;
|
||||
if (cfg.target) result.target = cfg.target;
|
||||
result.count ||= Math.sum(readSubtasksFromFiles(files, cfg).map((i) => i.cases.length));
|
||||
if (cfg.subtasks?.length) {
|
||||
for (const subtask of normalizeSubtasks(cfg.subtasks as any || [], (i) => i, cfg.time, cfg.memory)) {
|
||||
result.memoryMax = Math.max(result.memoryMax, ...subtask.cases.map((i) => parseMemoryMB(i.memory)));
|
||||
|
||||
@@ -98,7 +98,7 @@ export function buildContestRule<T>(def: Partial<ContestRule<T>>, baseRule: Cont
|
||||
}
|
||||
|
||||
const acm = buildContestRule({
|
||||
TEXT: 'ACM/ICPC',
|
||||
TEXT: 'XCPC',
|
||||
check: () => { },
|
||||
statusSort: { accept: -1, time: 1 },
|
||||
submitAfterAccept: false,
|
||||
@@ -276,6 +276,7 @@ const acm = buildContestRule({
|
||||
delete rdoc.memory;
|
||||
rdoc.testCases = [];
|
||||
rdoc.judgeTexts = [];
|
||||
delete rdoc.progress;
|
||||
delete rdoc.subtasks;
|
||||
delete rdoc.score;
|
||||
return rdoc;
|
||||
@@ -908,7 +909,7 @@ export async function updateStatus(
|
||||
}: { status?: STATUS, score?: number, subtasks?: Record<number, SubtaskResult>, lang?: string } = {},
|
||||
) {
|
||||
const tdoc = await get(domainId, tid);
|
||||
if (tdoc.balloon && status === STATUS.STATUS_ACCEPTED) await addBalloon(domainId, tid, uid, rid, pid);
|
||||
if (tdoc.balloon && status === STATUS.STATUS_ACCEPTED && !isLocked(tdoc)) await addBalloon(domainId, tid, uid, rid, pid);
|
||||
const tsdoc = await document.revPushStatus(tdoc.domainId, document.TYPE_CONTEST, tdoc.docId, uid, 'journal', {
|
||||
rid, pid, status, score, subtasks, lang,
|
||||
}, 'rid');
|
||||
|
||||
@@ -75,21 +75,25 @@ interface ProblemCreateOptions {
|
||||
reference?: { domainId: string, pid: number };
|
||||
}
|
||||
|
||||
export class ProblemModel {
|
||||
static PROJECTION_CONTEST_LIST: Field[] = [
|
||||
const PROJECTION_BASE: Field[] = [
|
||||
'_id', 'domainId', 'docType', 'docId', 'pid',
|
||||
'owner', 'title',
|
||||
];
|
||||
|
||||
export class ProblemModel {
|
||||
static PROJECTION_CONTEST_LIST: Field[] = [
|
||||
...PROJECTION_BASE, 'config',
|
||||
];
|
||||
|
||||
static PROJECTION_LIST: Field[] = [
|
||||
...ProblemModel.PROJECTION_CONTEST_LIST,
|
||||
...PROJECTION_BASE,
|
||||
'nSubmit', 'nAccept', 'difficulty', 'tag', 'hidden',
|
||||
'stats',
|
||||
];
|
||||
|
||||
static PROJECTION_CONTEST_DETAIL: Field[] = [
|
||||
...ProblemModel.PROJECTION_CONTEST_LIST,
|
||||
'content', 'html', 'data', 'config', 'additional_file',
|
||||
'content', 'html', 'data', 'additional_file',
|
||||
'reference', 'maintainer',
|
||||
];
|
||||
|
||||
@@ -198,7 +202,7 @@ export class ProblemModel {
|
||||
.project(buildProjection(projection)).limit(1).toArray())[0];
|
||||
if (!res) return null;
|
||||
try {
|
||||
if (!rawConfig) res.config = await parseConfig(res.config);
|
||||
if (!rawConfig) res.config = await parseConfig(res.config, res.data?.map((i) => i.name) || []);
|
||||
} catch (e) {
|
||||
res.config = `Cannot parse: ${e.message}`;
|
||||
}
|
||||
@@ -390,7 +394,7 @@ export class ProblemModel {
|
||||
}
|
||||
for (const pdoc of pdocs) {
|
||||
try {
|
||||
pdoc.config = await parseConfig(pdoc.config as string);
|
||||
pdoc.config = await parseConfig(pdoc.config as string, pdoc.data?.map((i) => i.name) || []);
|
||||
} catch (e) {
|
||||
pdoc.config = `Cannot parse: ${e.message}`;
|
||||
}
|
||||
@@ -552,16 +556,29 @@ export class ProblemModel {
|
||||
const tag = (pdoc.tag || []).map((t) => t.toString());
|
||||
let configChanged = false;
|
||||
let config: ProblemConfigFile = {};
|
||||
if (await fs.exists('testdata/config.yaml')) {
|
||||
if (await fs.exists(path.join(tmpdir, i, 'testdata/config.yaml'))) {
|
||||
try {
|
||||
config = yaml.load(await fs.readFile('testdata/config.yaml', 'utf-8'));
|
||||
config = yaml.load(await fs.readFile(path.join(tmpdir, i, 'testdata/config.yaml'), 'utf-8'));
|
||||
} catch (e) {
|
||||
// TODO: report this as a warning
|
||||
}
|
||||
}
|
||||
if (await fs.exists(path.join(tmpdir, i, 'domjudge-problem.ini'))) {
|
||||
const djConfig = (await fs.readFile(path.join(tmpdir, i, 'domjudge-problem.ini'), 'utf-8') as string)
|
||||
.split('\n').map((line: string) => line.split('=').map((lines: string) => lines.trim()));
|
||||
const djConfigJson: any = {};
|
||||
for (const [key, value] of djConfig) {
|
||||
djConfigJson[key] = value;
|
||||
}
|
||||
if (djConfigJson.timelimit) {
|
||||
config.time = `${djConfigJson.timelimit * 1000}ms`;
|
||||
configChanged = true;
|
||||
}
|
||||
if (djConfigJson.externalid) pid = djConfigJson.externalid;
|
||||
}
|
||||
if ((pdoc as any).limits) {
|
||||
config.time = (pdoc as any).limits.time_limit;
|
||||
config.memory = (pdoc as any).limits.memory;
|
||||
config.time = (pdoc as any).limits.time_limit ? `${(pdoc as any).limits.time_limit * 1000}ms` : config.time || undefined;
|
||||
config.memory = (pdoc as any).limits.memory ? `${(pdoc as any).limits.memory}m` : config.memory || undefined;
|
||||
configChanged = true;
|
||||
}
|
||||
const docId = overridePid
|
||||
@@ -587,9 +604,8 @@ export class ProblemModel {
|
||||
if (!f.isDirectory()) continue;
|
||||
const sub = await fs.readdir(loc);
|
||||
for (const file of sub) {
|
||||
await (f.name === 'sample' ? ProblemModel.addAdditionalFile
|
||||
: f.name === 'secret' ? ProblemModel.addTestdata
|
||||
: null)?.(domainId, docId, file, path.join(loc, file));
|
||||
if (f.name === 'sample') await ProblemModel.addAdditionalFile(domainId, docId, file, path.join(loc, file));
|
||||
await ProblemModel.addTestdata(domainId, docId, file, path.join(loc, file));
|
||||
}
|
||||
}
|
||||
for (const [f, loc] of await getFiles('output_validators')) {
|
||||
@@ -598,10 +614,10 @@ export class ProblemModel {
|
||||
for (const file of sub) {
|
||||
if (file === 'testlib.h') continue;
|
||||
await ProblemModel.addTestdata(domainId, docId, file, path.join(loc, file));
|
||||
if (file === 'checker') {
|
||||
if (f.name === 'checker') {
|
||||
config.checker_type = 'testlib';
|
||||
config.checker = file;
|
||||
} else if (file === 'interactor') {
|
||||
} else if (f.name === 'interactor') {
|
||||
config.type = ProblemType.Interactive;
|
||||
config.interactor = file;
|
||||
}
|
||||
@@ -624,6 +640,7 @@ export class ProblemModel {
|
||||
config.judge_extra_files = Array.from(new Set(config.judge_extra_files.concat(f.name)));
|
||||
configChanged = true;
|
||||
}
|
||||
if (configChanged) await ProblemModel.addTestdata(domainId, docId, 'config.yaml', Buffer.from(yaml.dump(config)));
|
||||
let count = 0;
|
||||
for (const [f, loc] of await getFiles('std')) {
|
||||
if (!f.isFile()) continue;
|
||||
@@ -631,7 +648,16 @@ export class ProblemModel {
|
||||
if (count > 5) continue;
|
||||
await RecordModel.add(domainId, docId, operator, f.name.split('.')[1], await fs.readFile(loc, 'utf-8'), true);
|
||||
}
|
||||
if (configChanged) await ProblemModel.addTestdata(domainId, docId, 'config.yaml', yaml.dump(config));
|
||||
for (const [f, loc] of await getFiles('submissions')) {
|
||||
if (f.isFile()) continue;
|
||||
const sub = await fs.readdir(loc);
|
||||
for (const file of sub) {
|
||||
if (file.endsWith('.zip')) continue;
|
||||
const code = await fs.readFile(path.join(loc, file), 'utf-8');
|
||||
await RecordModel.add(domainId, docId, operator, file.split('.')[1], `// ${file}: ${loc}\n${code}`, true);
|
||||
}
|
||||
}
|
||||
if (configChanged) await ProblemModel.addTestdata(domainId, docId, 'config.yaml', Buffer.from(yaml.dump(config)));
|
||||
const message = `${overridePid ? 'Updated' : 'Imported'} problem ${pdoc.pid || docId} (${title})`;
|
||||
(process.env.HYDRO_CLI ? logger.info : progress)?.(message);
|
||||
} catch (e) {
|
||||
|
||||
@@ -627,7 +627,12 @@ export const coreScripts: MigrationScript[] = [
|
||||
};
|
||||
for (const file of files) {
|
||||
const [, domainId, tid, type, name] = file.path.split('/');
|
||||
const tdoc = await contest.get(domainId, new ObjectId(tid));
|
||||
let tdoc;
|
||||
try {
|
||||
tdoc = await contest.get(domainId, new ObjectId(tid));
|
||||
} catch (e) {
|
||||
continue;
|
||||
}
|
||||
if (!tdoc) console.error('Contest not found', file.path);
|
||||
if (tdoc.rule === 'homework' || !name) {
|
||||
await rename(file.path, `contest/${domainId}/${tid}/public/${name || type}`);
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
/* eslint-disable max-len */
|
||||
import { LRUCache } from 'lru-cache';
|
||||
import moment from 'moment';
|
||||
import {
|
||||
avatar, ContestModel, ContestNotEndedError, Context, db, findFileSync,
|
||||
ForbiddenError, fs, ObjectId, parseTimeMS, PERM, ProblemConfig, ProblemModel,
|
||||
STATUS, STATUS_SHORT_TEXTS, STATUS_TEXTS, Time, UserModel, Zip,
|
||||
_, avatar, ContestModel, ContestNotEndedError, Context, db, findFileSync,
|
||||
ForbiddenError, fs, ObjectId, parseTimeMS, PERM, PRIV, ProblemConfig, ProblemModel,
|
||||
randomstring, Schema, SettingModel, STATUS, STATUS_SHORT_TEXTS, STATUS_TEXTS,
|
||||
Time, Types, UserModel, Zip,
|
||||
} from 'hydrooj';
|
||||
import { ResolverInput } from './interface';
|
||||
|
||||
@@ -18,31 +20,79 @@ declare module 'hydrooj' {
|
||||
}
|
||||
}
|
||||
const coll = db.collection('iplogin');
|
||||
const ipLoginCache = new LRUCache<string, IpLoginInfo>({ max: 1000, ttl: 60 * 1000 });
|
||||
|
||||
function normalizeIp(ip: string) {
|
||||
if (ip.startsWith('::ffff:')) return ip.slice(7);
|
||||
return ip;
|
||||
}
|
||||
|
||||
export function apply(ctx: Context) {
|
||||
const QuickImportSchema = Schema.array(Schema.object({
|
||||
id: Schema.union([Schema.string().required(), Schema.number().required()]),
|
||||
name: Schema.string().required(),
|
||||
password: Schema.string(),
|
||||
school: Schema.string(),
|
||||
members: Schema.array(Schema.string()).default([]),
|
||||
member1: Schema.string(),
|
||||
member2: Schema.string(),
|
||||
member3: Schema.string(),
|
||||
member4: Schema.string(),
|
||||
coach: Schema.string(),
|
||||
seat: Schema.string(),
|
||||
rank: Schema.boolean(),
|
||||
ip: Schema.string(),
|
||||
}));
|
||||
|
||||
export const Config = Schema.object({
|
||||
ipLogin: Schema.boolean().default(false),
|
||||
extraFields: Schema.boolean().default(false),
|
||||
submit: Schema.boolean().default(false).description('Enable submit script'),
|
||||
});
|
||||
|
||||
export function apply(ctx: Context, config: ReturnType<typeof Config>) {
|
||||
if (config.ipLogin) {
|
||||
ctx.on('handler/init', async (that) => {
|
||||
const iplogin = await coll.findOne({ _id: normalizeIp(that.request.ip) });
|
||||
if (iplogin) {
|
||||
const iplogin = ipLoginCache.get(normalizeIp(that.request.ip)) || await coll.findOne({ _id: normalizeIp(that.request.ip) });
|
||||
if (!iplogin) {
|
||||
if (that.session.ipLoggedIn && that.session.ipLoggedIn !== normalizeIp(that.request.ip)) {
|
||||
that.session.uid = 0;
|
||||
that.session.user = await UserModel.getById(that.domain._id, 0);
|
||||
}
|
||||
return;
|
||||
}
|
||||
ipLoginCache.set(normalizeIp(that.request.ip), iplogin);
|
||||
that.user = await UserModel.getById(that.domain._id, iplogin.uid);
|
||||
if (!that.user) {
|
||||
that.user = await UserModel.getById(that.domain._id, 0);
|
||||
throw new ForbiddenError(`User ${iplogin.uid} not found`);
|
||||
}
|
||||
that.session.ipLoggedIn = true;
|
||||
that.session.ipLoggedIn = that.request.ip;
|
||||
that.session.uid = iplogin.uid;
|
||||
that.session.user = that.user;
|
||||
}
|
||||
});
|
||||
|
||||
const disable = (that) => {
|
||||
if (!that.user.hasPriv(PRIV.PRIV_EDIT_SYSTEM)) throw new ForbiddenError('Not available');
|
||||
};
|
||||
ctx.on('handler/before/HomeDomain', disable);
|
||||
ctx.on('handler/before/DomainUser', disable);
|
||||
ctx.on('handler/before/HomeMessages#post', disable);
|
||||
ctx.on('handler/before/Files', disable);
|
||||
ctx.on('handler/before/HomeAvatar', disable);
|
||||
ctx.on('handler/before/HomeSecurity', disable);
|
||||
ctx.on('handler/before/UserDetail', disable);
|
||||
ctx.on('handler/before/UserLostPass', disable);
|
||||
|
||||
ctx.on('handler/before/HomeSettings', (that) => {
|
||||
if (that.user.hasPriv(PRIV.PRIV_EDIT_SYSTEM)) return;
|
||||
if (['domain', 'account'].includes(that.args.category)) throw new ForbiddenError('Not available');
|
||||
});
|
||||
}
|
||||
|
||||
ctx.inject(['scoreboard'], ({ scoreboard }) => {
|
||||
scoreboard.addView('resolver-tiny', 'Resolver(Tiny)', { tdoc: 'tdoc' }, {
|
||||
async display({ tdoc }) {
|
||||
if (!this.user.own(tdoc) && ContestModel.isLocked(tdoc)) this.checkPerm(PERM.PERM_VIEW_CONTEST_HIDDEN_SCOREBOARD);
|
||||
if (!this.user.own(tdoc)) this.checkPerm(PERM.PERM_VIEW_CONTEST_HIDDEN_SCOREBOARD);
|
||||
const teams = await ContestModel.getMultiStatus(tdoc.domainId, { docId: tdoc.docId }).toArray();
|
||||
const udict = await UserModel.getList(tdoc.domainId, teams.map((i) => i.uid));
|
||||
const teamIds: Record<number, number> = {};
|
||||
@@ -82,9 +132,7 @@ export function apply(ctx: Context) {
|
||||
|
||||
scoreboard.addView('cdp', 'CDP', { tdoc: 'tdoc' }, {
|
||||
async display({ tdoc }) {
|
||||
if (ContestModel.isLocked(tdoc) && !this.user.own(tdoc)) {
|
||||
this.checkPerm(PERM.PERM_VIEW_CONTEST_HIDDEN_SCOREBOARD);
|
||||
}
|
||||
if (!this.user.own(tdoc)) this.checkPerm(PERM.PERM_EDIT_CONTEST);
|
||||
if (!ContestModel.isDone(tdoc)) throw new ContestNotEndedError();
|
||||
let token = 0;
|
||||
const getFeed = (type: string, data: any) => ({
|
||||
@@ -100,7 +148,7 @@ export function apply(ctx: Context) {
|
||||
return {
|
||||
team_id: `team-${udoc._id}`,
|
||||
name: udoc.uname,
|
||||
displayName: (i.unrank ? '⭐' : '') + udoc.displayName,
|
||||
displayName: (i.unrank ? '⭐' : '') + (udoc.displayName || udoc.uname),
|
||||
organization: udoc.school || udoc.uname,
|
||||
avatar: avatar(udoc.avatar),
|
||||
group: [
|
||||
@@ -270,4 +318,71 @@ export function apply(ctx: Context) {
|
||||
supportedRules: ['acm'],
|
||||
});
|
||||
});
|
||||
|
||||
/* eslint-disable no-await-in-loop */
|
||||
// @ts-ignore
|
||||
Hydro.model.system.onsiteImport = async function (filepath: string, tidsInput: string, format = 'webp') {
|
||||
const data = QuickImportSchema(JSON.parse(fs.readFileSync(filepath, 'utf-8')));
|
||||
const tids = tidsInput.split(',').map((i) => i.trim()).filter((i) => i).map((i) => new ObjectId(i));
|
||||
const tdocs = await Promise.all(tids.map((i) => ContestModel.get('system', i)));
|
||||
const convertUname = Types.Username[0];
|
||||
for (const line of data) {
|
||||
const uname = convertUname(line.name);
|
||||
const email = `${line.id}@onsite.local`;
|
||||
let team = await UserModel.getByEmail('system', email);
|
||||
if (!team) {
|
||||
const uid = await UserModel.create(email, uname, line.password || randomstring());
|
||||
team = await UserModel.getById('system', uid);
|
||||
} else {
|
||||
await UserModel.setById(team._id, { uname });
|
||||
if (line.password) await UserModel.setPassword(team._id, line.password);
|
||||
}
|
||||
if (line.member1) line.members.push(line.member1);
|
||||
if (line.member2) line.members.push(line.member2);
|
||||
if (line.member3) line.members.push(line.member3);
|
||||
if (line.member4) line.members.push(line.member4);
|
||||
const set: any = _.pick(line, 'school', 'members', 'coach', 'seat');
|
||||
if (line.school) set.avatar = `url:/avatars/${line.school.replace(/[ ()]/g, '')}.${format}`;
|
||||
if (Object.keys(set).length) await UserModel.setById(team._id, set);
|
||||
for (const tdoc of tdocs) {
|
||||
const tsdoc = await ContestModel.getStatus('system', tdoc.docId, team._id);
|
||||
if (!tsdoc?.attend) await ContestModel.attend('system', tdoc.docId, team._id, 'rank' in line ? { unrank: !line.rank } : {});
|
||||
else if ('rank' in line && tsdoc.unrank === line.rank) await ContestModel.setStatus('system', tdoc.docId, team._id, { unrank: !line.rank });
|
||||
}
|
||||
if (line.ip) await coll.updateOne({ _id: normalizeIp(line.ip) }, { $set: { uid: team._id } }, { upsert: true });
|
||||
}
|
||||
};
|
||||
|
||||
// @ts-ignore
|
||||
Hydro.model.system.setIpLogin = async function (filepath: string) {
|
||||
const data = fs.readFileSync(filepath, 'utf-8').split('\n').filter((i) => i);
|
||||
for (const line of data) {
|
||||
const [seat, ip] = line.split(',').map((i) => i.trim());
|
||||
const user = await UserModel.coll.findOne({ seat });
|
||||
if (!user) {
|
||||
console.warn(`User ${seat} not found`);
|
||||
continue;
|
||||
}
|
||||
await coll.updateOne({ _id: normalizeIp(ip) }, { $set: { uid: user._id } }, { upsert: true });
|
||||
}
|
||||
};
|
||||
|
||||
if (config.submit) ctx.plugin(require('./submit'));
|
||||
|
||||
if (config.extraFields) {
|
||||
ctx.inject(['setting'], (c) => {
|
||||
c.setting.AccountSetting(
|
||||
SettingModel.Setting('setting_info', 'coach', null, 'text', 'coach', '', SettingModel.FLAG_DISABLED | SettingModel.FLAG_PUBLIC),
|
||||
SettingModel.Setting('setting_info', 'members', null, 'text', 'members', '', SettingModel.FLAG_DISABLED | SettingModel.FLAG_PUBLIC),
|
||||
SettingModel.Setting('setting_info', 'seat', null, 'text', 'seat', '', SettingModel.FLAG_DISABLED | SettingModel.FLAG_PUBLIC),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
ctx.on('handler/before/ContestScoreboard#get', (that) => {
|
||||
if (that.request.path.endsWith('/scoreboard')) {
|
||||
that.response.redirect = `${that.request.path}/xcpcio`;
|
||||
return 'cleanup';
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -4,6 +4,7 @@
|
||||
"repository": "https://github.com/hydro-dev/Hydro",
|
||||
"dependencies": {
|
||||
"@react-spring/web": "^10.0.3",
|
||||
"lru-cache": "^11.2.2",
|
||||
"react-use": "^17.6.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import {
|
||||
ContestModel, Context, DomainModel, fs, nanoid, ProblemDoc, ProblemModel,
|
||||
RecordModel, SettingModel, StorageModel, SystemModel,
|
||||
} from 'hydrooj';
|
||||
import { ContestDetailBaseHandler } from 'hydrooj/src/handler/contest';
|
||||
|
||||
class AutoSubmitHandler extends ContestDetailBaseHandler {
|
||||
pdoc: ProblemDoc;
|
||||
|
||||
async submit({ domainId, dryrun }: { domainId: string, dryrun: boolean }) {
|
||||
if (!this.tdoc || !ContestModel.isOngoing(this.tdoc, this.tsdoc)) return { error: 'Contest not live' };
|
||||
if (!this.tdoc.allowPrint) return { error: 'Not a on-site contest' };
|
||||
const file = this.request.files?.file;
|
||||
if (!file) return { error: 'No file uploaded' };
|
||||
const filename = file.originalFilename.split('/').pop();
|
||||
if (!/^[A-Z]\./i.test(filename.toUpperCase())) return { error: 'Unsupported file name' };
|
||||
const pids = this.tdoc.pids;
|
||||
const pidIndex = filename.toUpperCase().charCodeAt(0) - 'A'.charCodeAt(0);
|
||||
if (pidIndex < 0 || pidIndex >= pids.length) return { error: 'Unsupported file name' };
|
||||
const pid = pids[pidIndex];
|
||||
this.pdoc = await ProblemModel.get(domainId, pid);
|
||||
|
||||
const ext = filename.split('.').pop();
|
||||
let lang = ext.toLowerCase();
|
||||
|
||||
const config = this.pdoc.config;
|
||||
const lengthLimit = SystemModel.get('limit.codelength') || 128 * 1024;
|
||||
if (typeof config === 'string' || config === null) return { error: 'system error' };
|
||||
const sizeLimit = config.type === 'submit_answer' ? 128 * 1024 * 1024 : lengthLimit;
|
||||
if (file.size > sizeLimit) return { error: 'File too large' };
|
||||
|
||||
if (['submit_answer', 'objective'].includes(config.type)) {
|
||||
lang = '_';
|
||||
} else if ((config.langs && !config.langs.includes(lang)) || !SettingModel.langs[lang] || SettingModel.langs[lang].disabled) {
|
||||
return { error: 'Unsupported language' };
|
||||
}
|
||||
const shouldReadFile = () => {
|
||||
if (config.type === 'objective') return true;
|
||||
if (lang === '_') return false;
|
||||
return file.size < lengthLimit && !file.filepath.endsWith('.zip') && !SettingModel.langs[lang].isBinary;
|
||||
};
|
||||
let code = '';
|
||||
const files: Record<string, string> = {};
|
||||
if (shouldReadFile()) code = await fs.readFile(file.filepath, 'utf-8');
|
||||
else {
|
||||
const id = nanoid();
|
||||
await StorageModel.put(`submission/${this.user._id}/${id}`, file.filepath, this.user._id);
|
||||
files.code = `${this.user._id}/${id}#${file.originalFilename}`;
|
||||
}
|
||||
|
||||
if (!code) return { error: 'Cannot submit empty code' };
|
||||
|
||||
if (dryrun) {
|
||||
return {
|
||||
info: `Info
|
||||
|
||||
Contest ${this.tdoc.title}
|
||||
Problem ${this.pdoc.title}
|
||||
User ${this.user.uname}
|
||||
School ${this.user.school}
|
||||
Language ${SettingModel.langs[lang].display} (${lang})
|
||||
`,
|
||||
};
|
||||
}
|
||||
|
||||
await this.limitRate('add_record', 60, SystemModel.get('limit.submission_user'), '{{user}}');
|
||||
await this.limitRate('add_record', 60, SystemModel.get('limit.submission'));
|
||||
|
||||
const rid = await RecordModel.add(
|
||||
domainId, this.pdoc.docId, this.user._id, lang, code, true,
|
||||
{ contest: this.tdoc.docId, files, type: 'judge' },
|
||||
);
|
||||
await Promise.all([
|
||||
ProblemModel.inc(domainId, this.pdoc.docId, 'nSubmit', 1),
|
||||
DomainModel.incUserInDomain(domainId, this.user._id, 'nSubmit'),
|
||||
ContestModel.updateStatus(domainId, this.tdoc.docId, this.user._id, rid, this.pdoc.docId),
|
||||
]);
|
||||
return { rid };
|
||||
}
|
||||
|
||||
async post({ domainId, dryrun }) {
|
||||
try {
|
||||
this.response.body = await this.submit({ domainId, dryrun });
|
||||
} catch (e) {
|
||||
this.response.body = { error: e.message };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function apply(ctx: Context) {
|
||||
ctx.Route('contest_autosubmit', '/contest/:tid/autosubmit', AutoSubmitHandler);
|
||||
}
|
||||
@@ -24,6 +24,22 @@ const status = {
|
||||
[STATUS.STATUS_ETC]: 'SYSTEM_ERROR',
|
||||
[STATUS.STATUS_CANCELED]: 'CANCELED',
|
||||
};
|
||||
const statusPrivate = {
|
||||
[STATUS.STATUS_WRONG_ANSWER]: 'REJECTED',
|
||||
[STATUS.STATUS_ACCEPTED]: 'CORRECT',
|
||||
[STATUS.STATUS_COMPILING]: 'PENDING',
|
||||
[STATUS.STATUS_WAITING]: 'PENDING',
|
||||
[STATUS.STATUS_JUDGING]: 'PENDING',
|
||||
[STATUS.STATUS_TIME_LIMIT_EXCEEDED]: 'REJECTED',
|
||||
[STATUS.STATUS_MEMORY_LIMIT_EXCEEDED]: 'REJECTED',
|
||||
[STATUS.STATUS_RUNTIME_ERROR]: 'REJECTED',
|
||||
[STATUS.STATUS_SYSTEM_ERROR]: 'SYSTEM_ERROR',
|
||||
[STATUS.STATUS_COMPILE_ERROR]: 'COMPILATION_ERROR',
|
||||
[STATUS.STATUS_FETCHED]: 'PENDING',
|
||||
[STATUS.STATUS_OUTPUT_LIMIT_EXCEEDED]: 'REJECTED',
|
||||
[STATUS.STATUS_ETC]: 'SYSTEM_ERROR',
|
||||
[STATUS.STATUS_CANCELED]: 'CANCELED',
|
||||
};
|
||||
|
||||
function submissionBase(tdoc: Tdoc, rdoc: RecordDoc, uid?: number) {
|
||||
// NOTE: rdoc can be either record, or a tsdoc detail entry
|
||||
@@ -39,6 +55,7 @@ function submissionBase(tdoc: Tdoc, rdoc: RecordDoc, uid?: number) {
|
||||
|
||||
async function loadContestState(tdoc: Tdoc, realtime: boolean) {
|
||||
const tsdocs = await ContestModel.getMultiStatus(tdoc.domainId, { docId: tdoc.docId }).toArray();
|
||||
const ended = ContestModel.isDone(tdoc);
|
||||
const udict = await UserModel.getList(tdoc.domainId, tsdocs.map((i) => i.uid));
|
||||
const teams = tsdocs.map((i) => {
|
||||
const udoc = udict[i.uid];
|
||||
@@ -46,11 +63,11 @@ async function loadContestState(tdoc: Tdoc, realtime: boolean) {
|
||||
team_id: `${udoc._id}`,
|
||||
name: udoc.uname,
|
||||
organization: udoc.school,
|
||||
members: udoc.members?.split(',').filter((t) => t) || [],
|
||||
members: 'members' in udoc ? (typeof udoc.members === 'string' ? udoc.members.split(',').filter((t) => t) : udoc.members) : [],
|
||||
coach: udoc.coach,
|
||||
badge: { url: avatar(udoc.avatar) },
|
||||
group: [
|
||||
...(udoc.group || []),
|
||||
...((udoc.group || []).filter((g) => !Number.isSafeInteger(+g))),
|
||||
i.unrank ? 'unofficial' : 'official',
|
||||
],
|
||||
};
|
||||
@@ -58,11 +75,11 @@ async function loadContestState(tdoc: Tdoc, realtime: boolean) {
|
||||
return {
|
||||
submissions: tsdocs.flatMap((i) => (i.journal || []).map((j) => {
|
||||
const submit = new ObjectId(j.rid as string).getTimestamp().getTime();
|
||||
const curStatus = status[j.status] || 'SYSTEM_ERROR';
|
||||
const curStatus = (ended ? status : statusPrivate)[j.status] || 'SYSTEM_ERROR';
|
||||
return {
|
||||
...submissionBase(tdoc, j, i.uid),
|
||||
status: (ContestModel.isLocked(tdoc) && submit > tdoc.lockAt.getTime() && !realtime)
|
||||
? 'PENDING'
|
||||
? 'FROZEN'
|
||||
: curStatus,
|
||||
};
|
||||
})),
|
||||
@@ -95,7 +112,7 @@ export async function apply(ctx: Context, config: ReturnType<typeof Config>) {
|
||||
const tdoc = await ContestModel.get(rdoc.domainId, rdoc.contest);
|
||||
const submit = new ObjectId(rdoc._id).getTimestamp().getTime();
|
||||
const isLocked = ContestModel.isLocked(tdoc) && submit > tdoc.lockAt.getTime();
|
||||
const statusStr = status[rdoc.status] || 'SYSTEM_ERROR';
|
||||
const statusStr = statusPrivate[rdoc.status] || 'SYSTEM_ERROR';
|
||||
if (realtime) {
|
||||
const found = realtime.submissions.find((i) => i.submission_id === rdoc._id.toHexString());
|
||||
if (found) found.status = statusStr;
|
||||
@@ -104,7 +121,7 @@ export async function apply(ctx: Context, config: ReturnType<typeof Config>) {
|
||||
if (pub) {
|
||||
const found = pub.submissions.find((i) => i.submission_id === rdoc._id.toHexString());
|
||||
if (found && !isLocked) found.status = statusStr;
|
||||
else pub.submissions.push({ ...submissionBase(tdoc, rdoc), status: isLocked ? 'PENDING' : statusStr });
|
||||
else pub.submissions.push({ ...submissionBase(tdoc, rdoc), status: isLocked ? 'FROZEN' : statusStr });
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -115,12 +132,14 @@ export async function apply(ctx: Context, config: ReturnType<typeof Config>) {
|
||||
groups: 'groups',
|
||||
json: Types.Boolean,
|
||||
realtime: Types.Boolean,
|
||||
badge: Types.Boolean,
|
||||
banner: Types.Boolean,
|
||||
gold: Schema.transform(Schema.union([Schema.string(), Schema.number().step(1).min(0)]), (v) => +v).default(0),
|
||||
silver: Schema.transform(Schema.union([Schema.string(), Schema.number().step(1).min(0)]), (v) => +v).default(0),
|
||||
bronze: Schema.transform(Schema.union([Schema.string(), Schema.number().step(1).min(0)]), (v) => +v).default(0),
|
||||
}, {
|
||||
async display({
|
||||
tdoc, groups, json, realtime, gold, silver, bronze,
|
||||
tdoc, groups, json, realtime, gold, silver, bronze, badge = true, banner = false,
|
||||
}) {
|
||||
if (realtime && !this.user.own(tdoc)) this.checkPerm(PERM.PERM_VIEW_CONTEST_HIDDEN_SCOREBOARD);
|
||||
if (json || this.request.json) {
|
||||
@@ -143,6 +162,7 @@ export async function apply(ctx: Context, config: ReturnType<typeof Config>) {
|
||||
unofficial: '打星队伍',
|
||||
...Object.fromEntries(groups.filter((i) => relatedGroups.includes(i.name)).map((i) => [i.name, i.name])),
|
||||
},
|
||||
...(badge ? { badge: 'Badge' } : {}),
|
||||
organization: 'School',
|
||||
status_time_display: {
|
||||
correct: true,
|
||||
@@ -165,9 +185,7 @@ export async function apply(ctx: Context, config: ReturnType<typeof Config>) {
|
||||
logo: {
|
||||
preset: 'ICPC',
|
||||
},
|
||||
// banner: {
|
||||
// url: 'assets/banner.png',
|
||||
// },
|
||||
...(banner ? { banner: { url: 'banner.jpg' } } : {}),
|
||||
options: {
|
||||
submission_timestamp_unit: 'millisecond',
|
||||
},
|
||||
@@ -178,12 +196,15 @@ export async function apply(ctx: Context, config: ReturnType<typeof Config>) {
|
||||
this.response.template = 'xcpcio_board.html';
|
||||
let query = '';
|
||||
if (gold || silver || bronze) query = `&gold=${gold}&silver=${silver}&bronze=${bronze}`;
|
||||
if (badge) query += '&badge=true';
|
||||
if (banner) query += '&banner=true';
|
||||
if (realtime) query += '&realtime=true';
|
||||
const endpoint = `/d/${tdoc.domainId}/contest/${tdoc.docId}/scoreboard/xcpcio`;
|
||||
this.response.body = {
|
||||
dataSource: `${endpoint}?json=true${query}#allInOne=true`,
|
||||
js: indexJs,
|
||||
css: indexCss,
|
||||
realtime,
|
||||
};
|
||||
}
|
||||
},
|
||||
|
||||
@@ -30,6 +30,22 @@
|
||||
</style>
|
||||
<script type="module" crossorigin src="{{ '/' if process.env.DEV else UiContext.cdn_prefix }}assets/index-{{ js }}.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="{{ '/' if process.env.DEV else UiContext.cdn_prefix }}assets/index-{{ css }}.css">
|
||||
{% if realtime and model.contest.isLocked(tdoc) and model.contest.isDone(tdoc) and handler.user.own(tdoc) %}
|
||||
<div class="main">
|
||||
<div class="row typo">
|
||||
<blockquote class="note">
|
||||
{{ _('You are viewing the realtime scoreboard while the scoreboard is currently frozen.') }} <br>
|
||||
{{ _('To allow contestants to view the scoreboard, you can unfreeze the scoreboard.') }} <br>
|
||||
<form method="POST">
|
||||
<input type="hidden" name="operation" value="unlock">
|
||||
<button type="submit" class="button">
|
||||
<span class="icon icon-feeling-lucky"></span> {{ _('Unlock scoreboard') }}
|
||||
</button>
|
||||
</form>
|
||||
</blockquote>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
<div id="app"></div>
|
||||
<noscript>This website requires JavaScript to function properly. Please enable JavaScript tocontinue.</noscript>
|
||||
{% endblock %}
|
||||
@@ -20,9 +20,9 @@
|
||||
"@fontsource/source-code-pro": "^5.2.7",
|
||||
"@fontsource/ubuntu-mono": "^5.2.8",
|
||||
"@hydrooj/utils": "workspace:^",
|
||||
"@mantine/core": "^8.3.7",
|
||||
"@mantine/hooks": "^8.3.7",
|
||||
"@mantine/notifications": "^8.3.7",
|
||||
"@mantine/core": "^8.3.8",
|
||||
"@mantine/hooks": "^8.3.8",
|
||||
"@mantine/notifications": "^8.3.8",
|
||||
"@sentry/browser": "^10.25.0",
|
||||
"@sentry/webpack-plugin": "^4.6.0",
|
||||
"@simplewebauthn/browser": "13.2.2",
|
||||
@@ -36,7 +36,7 @@
|
||||
"@types/nunjucks": "^3.2.6",
|
||||
"@types/pickadate": "^3.5.35",
|
||||
"@types/qrcode": "^1.5.6",
|
||||
"@types/react": "^19.2.4",
|
||||
"@types/react": "^19.2.6",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@types/redux-logger": "^3.0.13",
|
||||
"@types/uuid": "^11.0.0",
|
||||
@@ -67,7 +67,7 @@
|
||||
"jquery-scroll-lock": "^3.1.3",
|
||||
"jquery.easing": "^1.4.1",
|
||||
"jquery.transit": "^0.9.12",
|
||||
"mantine-contextmenu": "^8.3.7",
|
||||
"mantine-contextmenu": "^8.3.8",
|
||||
"matchmedia-polyfill": "^0.3.2",
|
||||
"md-editor-rt": "^6.1.1",
|
||||
"mini-css-extract-plugin": "^2.9.4",
|
||||
@@ -101,7 +101,7 @@
|
||||
"redux-promise-middleware": "^6.2.0",
|
||||
"redux-thunk": "^3.1.0",
|
||||
"rupture": "^0.7.1",
|
||||
"sass": "^1.94.0",
|
||||
"sass": "^1.94.1",
|
||||
"sass-loader": "^16.0.6",
|
||||
"schemastery": "^3.17.2",
|
||||
"shorty.js": "^1.0.1",
|
||||
|
||||
@@ -74,6 +74,13 @@
|
||||
{% endif %}
|
||||
<td class="col--problem col--problem-name">
|
||||
{{ problem.render_problem_title(pdict[pid], tdoc, alphabetic=true) }}
|
||||
{% if pdict[pid].config.type != 'objective' and pdict[pid].config.type != 'submit_answer' %}
|
||||
<span class="problem__tag-item icon icon-stopwatch" style="font-size: .75rem;margin: 0;padding: 0 5px!important;">{{ pdict[pid].config.timeMax if pdict[pid].config.timeMax == pdict[pid].config.timeMin else pdict[pid].config.timeMin+'~'+pdict[pid].config.timeMax }}ms</span>
|
||||
<span class="problem__tag-item icon icon-comparison" style="font-size: .75rem;margin: 0;padding: 0 5px!important;">{{ pdict[pid].config.memoryMax if pdict[pid].config.memoryMax == pdict[pid].config.memoryMin else pdict[pid].config.memoryMin+'~'+pdict[pid].config.memoryMax }}MiB</span>
|
||||
{% endif %}
|
||||
{% if pdict[pid].config.type != 'objective' %}
|
||||
<a href="{{ url('problem_submit', pid=pdict[pid].pid, query={tid:tdoc.docId}) }}" target="_blank" class="typo-a" style="float: right;"><span class="icon icon-send">提交</span></a>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{%- endfor -%}
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
"react-dom": "^19.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^19.2.3",
|
||||
"@types/react": "^19.2.6",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react-swc": "^4.2.2",
|
||||
"vite": "^7.2.2"
|
||||
|
||||
Reference in new issue
Block a user