mirror of
https://gitcode.com/gh_mirrors/hy/Hydro.git
synced 2026-10-17 09:36:08 +08:00
Merge pull request #354 from hydro-dev/judge_config
Co-authored-by: undefined <i@undefined.moe> Co-authored-by: panda <panda_dtdyy@outlook.com>
This commit is contained in:
58 files changed
+2218
-756
No files matched your search
+4
-1
@@ -25,7 +25,7 @@
|
||||
"debug": "node --trace-warnings --async-stack-traces --trace-deprecation node_modules/hydrooj/bin/hydrooj --debug --template",
|
||||
"start:all": "node build/start",
|
||||
"start": "node_modules/.bin/hydrooj",
|
||||
"postinstall": "node build/hack && node build/prepare.js && rm -rf node_modules/esbuild-loader/node_modules/esbuild"
|
||||
"postinstall": "node build/hack && node build/prepare.js"
|
||||
},
|
||||
"version": "1.0.0",
|
||||
"license": "AGPL-3.0-only",
|
||||
@@ -64,5 +64,8 @@
|
||||
"supertest": "^6.2.2",
|
||||
"typedoc": "^0.22.13",
|
||||
"typescript": "4.6.2"
|
||||
},
|
||||
"resolutions": {
|
||||
"esbuild": "0.14.3"
|
||||
}
|
||||
}
|
||||
@@ -3,231 +3,75 @@ import fs from 'fs-extra';
|
||||
import yaml from 'js-yaml';
|
||||
import { max, sum } from 'lodash';
|
||||
import readYamlCases, { convertIniConfig } from '@hydrooj/utils/lib/cases';
|
||||
import { normalizeSubtasks, readSubtasksFromFiles } from '@hydrooj/utils/lib/common';
|
||||
import { changeErrorType } from '@hydrooj/utils/lib/utils';
|
||||
import { getConfig } from './config';
|
||||
import { FormatError, SystemError } from './error';
|
||||
import { ensureFile, parseMemoryMB, parseTimeMS } from './utils';
|
||||
|
||||
interface Re0 {
|
||||
reg: RegExp,
|
||||
output: ((a: RegExpExecArray) => string)[],
|
||||
id: (a: RegExpExecArray) => number,
|
||||
}
|
||||
|
||||
interface Re1 extends Re0 {
|
||||
subtask: (a: RegExpExecArray) => number,
|
||||
}
|
||||
|
||||
const RE0: Re0[] = [
|
||||
{
|
||||
reg: /^([^\d]*)(\d+).(in|txt)$/,
|
||||
output: [
|
||||
(a) => `${a[1] + a[2]}.out`,
|
||||
(a) => `${a[1] + a[2]}.ans`,
|
||||
(a) => `${a[1] + a[2]}.out`.replace(/input/g, 'output'),
|
||||
(a) => (a[1].includes('input') ? `${a[1] + a[2]}.txt`.replace(/input/g, 'output') : null),
|
||||
],
|
||||
id: (a) => +a[2],
|
||||
},
|
||||
{
|
||||
reg: /^([^\d]*)\.in(\d+)$/,
|
||||
output: [
|
||||
(a) => `${a[1]}.ou${a[2]}`,
|
||||
(a) => `${a[1]}.ou${a[2]}`.replace(/input/g, 'output'),
|
||||
],
|
||||
id: (a) => +a[2],
|
||||
},
|
||||
];
|
||||
const RE1: Re1[] = [
|
||||
{
|
||||
reg: /^([^\d]*)([0-9]+)([-_])([0-9]+).in$/,
|
||||
output: [(a) => `${a[1] + a[2]}${a[3]}${a[4]}.out`],
|
||||
subtask: (a) => +a[2],
|
||||
id: (a) => +a[4],
|
||||
},
|
||||
];
|
||||
|
||||
async function read0(folder: string, files: string[], checkFile, cfg) {
|
||||
const cases = [];
|
||||
for (const file of files) {
|
||||
for (const REG of RE0) {
|
||||
if (REG.reg.test(file)) {
|
||||
const data = REG.reg.exec(file);
|
||||
const c = { input: file, output: '', id: REG.id(data) };
|
||||
for (const func of REG.output) {
|
||||
if (cfg.noOutputFile) c.output = '/dev/null';
|
||||
else c.output = func(data);
|
||||
if (c.output && (c.output === '/dev/null' || fs.existsSync(path.resolve(folder, c.output)))) {
|
||||
cases.push(c);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
cases.sort((a, b) => (a.id - b.id));
|
||||
const extra = cases.length - (100 % cases.length);
|
||||
const config = {
|
||||
count: 0,
|
||||
subtasks: [{
|
||||
time: parseTimeMS(cfg.time || '1s'),
|
||||
memory: parseMemoryMB(cfg.memory || '256m'),
|
||||
type: 'sum',
|
||||
cases: [],
|
||||
score: Math.floor(100 / cases.length),
|
||||
}],
|
||||
};
|
||||
for (let i = 0; i < extra; i++) {
|
||||
config.count++;
|
||||
config.subtasks[0].cases.push({
|
||||
id: config.count,
|
||||
input: checkFile(cases[i].input),
|
||||
output: checkFile(cases[i].output),
|
||||
});
|
||||
}
|
||||
if (extra < cases.length) {
|
||||
config.subtasks.push({
|
||||
time: parseTimeMS(cfg.time || '1s'),
|
||||
memory: parseMemoryMB(cfg.memory || '256m'),
|
||||
type: 'sum',
|
||||
cases: [],
|
||||
score: Math.floor(100 / cases.length) + 1,
|
||||
});
|
||||
for (let i = extra; i < cases.length; i++) {
|
||||
config.count++;
|
||||
config.subtasks[1].cases.push({
|
||||
id: config.count,
|
||||
input: checkFile(cases[i].input),
|
||||
output: checkFile(cases[i].output),
|
||||
});
|
||||
}
|
||||
}
|
||||
return config;
|
||||
}
|
||||
|
||||
async function read1(folder: string, files: string[], checkFile, cfg, rst) {
|
||||
const subtask = {};
|
||||
for (const s of rst.subtasks) if (s.id) subtask[s.id] = s;
|
||||
const subtasks = [];
|
||||
for (const file of files) {
|
||||
for (const REG of RE1) {
|
||||
if (REG.reg.test(file)) {
|
||||
const data = REG.reg.exec(file);
|
||||
const sid = REG.subtask(data);
|
||||
const c = { input: file, output: '', id: REG.id(data) };
|
||||
for (const func of REG.output) {
|
||||
if (cfg.noOutputFile) c.output = '/dev/null';
|
||||
else c.output = func(data);
|
||||
if (c.output === '/dev/null' || fs.existsSync(path.resolve(folder, c.output))) {
|
||||
if (!subtask[sid]) {
|
||||
subtask[sid] = {
|
||||
time: parseTimeMS(cfg.time || '1s'),
|
||||
memory: parseMemoryMB(cfg.memory || '256m'),
|
||||
type: 'min',
|
||||
cases: [c],
|
||||
};
|
||||
} else if (!subtask[sid].cases) subtask[sid].cases = [c];
|
||||
else subtask[sid].cases.push(c);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const i in subtask) {
|
||||
subtask[i].cases.sort((a, b) => (a.id - b.id));
|
||||
subtasks.push(subtask[i]);
|
||||
}
|
||||
const base = Math.floor(100 / subtasks.length);
|
||||
const extra = subtasks.length - (100 % subtasks.length);
|
||||
const config = { count: 0, subtasks };
|
||||
const keys = Object.keys(subtask);
|
||||
for (let i = 0; i < keys.length; i++) {
|
||||
if (i >= extra) subtask[keys[i]].score = base + 1;
|
||||
else subtask[keys[i]].score = base;
|
||||
for (const j of subtask[keys[i]].cases) {
|
||||
config.count++;
|
||||
j.input = checkFile(j.input);
|
||||
j.output = checkFile(j.output);
|
||||
j.id = config.count;
|
||||
}
|
||||
}
|
||||
return config;
|
||||
}
|
||||
|
||||
async function readAutoCases(folder: string, { next }, cfg, rst) {
|
||||
const config = {
|
||||
checker_type: 'default',
|
||||
count: 0,
|
||||
subtasks: [],
|
||||
judge_extra_files: [],
|
||||
user_extra_files: [],
|
||||
};
|
||||
const checkFile = ensureFile(folder);
|
||||
try {
|
||||
const files = await fs.readdir(folder);
|
||||
if (await fs.pathExists(path.resolve(folder, 'input'))) {
|
||||
const inputs = await fs.readdir(path.resolve(folder, 'input'));
|
||||
files.push(...inputs.map((i) => `input/${i}`));
|
||||
}
|
||||
if (await fs.pathExists(path.resolve(folder, 'output'))) {
|
||||
const outputs = await fs.readdir(path.resolve(folder, 'output'));
|
||||
files.push(...outputs.map((i) => `output/${i}`));
|
||||
}
|
||||
let result = await read0(folder, files, checkFile, cfg);
|
||||
if (!result.count) result = await read1(folder, files, checkFile, cfg, rst);
|
||||
Object.assign(config, result);
|
||||
if (cfg.isSelfSubmission) next({ message: { message: 'Found {0} testcases.', params: [config.count] } });
|
||||
} catch (e) {
|
||||
throw new SystemError('Cannot parse testdata.', [e.message, ...e.params]);
|
||||
}
|
||||
return config;
|
||||
}
|
||||
import { ensureFile, parseMemoryMB } from './utils';
|
||||
|
||||
function isValidConfig(config) {
|
||||
if (config.count > (getConfig('testcases_max') || 100)) {
|
||||
throw new FormatError('Too many testcases. Cancelled.');
|
||||
}
|
||||
const time = sum(config.subtasks.map((subtask) => subtask.time * subtask.cases.length));
|
||||
const time = sum(config.subtasks.map((subtask) => sum(subtask.cases.map((c) => c.time))));
|
||||
if (time > (getConfig('total_time_limit') || 60) * 1000) {
|
||||
throw new FormatError('Total time limit longer than {0}s. Cancelled.', [+getConfig('total_time_limit') || 60]);
|
||||
}
|
||||
const memMax = max(config.subtasks.map((subtask) => subtask.memory));
|
||||
const memMax = max(config.subtasks.map((subtask) => max(subtask.cases.map((c) => c.memory))));
|
||||
if (memMax > parseMemoryMB(getConfig('memoryMax'))) throw new FormatError('Memory limit larger than memory_max');
|
||||
if (!['default', 'strict'].includes(config.checker_type || 'default') && !config.checker) {
|
||||
throw new FormatError('You did not specify a checker.');
|
||||
}
|
||||
}
|
||||
|
||||
async function collectFiles(folder: string) {
|
||||
const files = await fs.readdir(folder);
|
||||
await Promise.all(['input', 'output'].map(async (t) => {
|
||||
if (await fs.pathExists(path.resolve(folder, t))) {
|
||||
const f = await fs.readdir(path.resolve(folder, t));
|
||||
files.push(...f.map((i) => `${t}/${i}`));
|
||||
}
|
||||
}));
|
||||
return files;
|
||||
}
|
||||
|
||||
export default async function readCases(folder: string, cfg: Record<string, any> = {}, args) {
|
||||
const iniConfig = path.resolve(folder, 'config.ini');
|
||||
const yamlConfig = path.resolve(folder, 'config.yaml');
|
||||
const ymlConfig = path.resolve(folder, 'config.yml');
|
||||
let config;
|
||||
if (fs.existsSync(yamlConfig)) {
|
||||
config = { ...cfg, ...yaml.load(fs.readFileSync(yamlConfig).toString()) as object };
|
||||
} else if (fs.existsSync(ymlConfig)) {
|
||||
config = { ...cfg, ...yaml.load(fs.readFileSync(ymlConfig).toString()) as object };
|
||||
} else if (fs.existsSync(iniConfig)) {
|
||||
try {
|
||||
config = { ...cfg, ...convertIniConfig(fs.readFileSync(iniConfig).toString()) };
|
||||
} catch (e) {
|
||||
throw changeErrorType(e, FormatError);
|
||||
}
|
||||
} else config = cfg;
|
||||
let result;
|
||||
const config: Record<string, any> = {
|
||||
checker_type: 'default',
|
||||
count: 0,
|
||||
subtasks: [],
|
||||
judge_extra_files: [],
|
||||
user_extra_files: [],
|
||||
...cfg,
|
||||
};
|
||||
try {
|
||||
result = await readYamlCases(config, ensureFile(folder));
|
||||
if (fs.existsSync(yamlConfig)) {
|
||||
Object.assign(config, yaml.load(fs.readFileSync(yamlConfig).toString()));
|
||||
} else if (fs.existsSync(ymlConfig)) {
|
||||
Object.assign(config, yaml.load(fs.readFileSync(ymlConfig).toString()));
|
||||
} else if (fs.existsSync(iniConfig)) {
|
||||
Object.assign(config, convertIniConfig(fs.readFileSync(iniConfig).toString()));
|
||||
}
|
||||
} catch (e) {
|
||||
throw changeErrorType(e, FormatError);
|
||||
}
|
||||
let cases = result.outputs?.length || 0;
|
||||
cases += Math.sum((result.subtasks || []).map((subtask) => subtask.cases.length));
|
||||
if (!cases) {
|
||||
const c = await readAutoCases(folder, args, config, result);
|
||||
result.subtasks = c.subtasks;
|
||||
result.count = c.count;
|
||||
const checkFile = ensureFile(folder);
|
||||
const result = await readYamlCases(config, checkFile)
|
||||
.catch((e) => { throw changeErrorType(e, FormatError); });
|
||||
result.count = result.outputs?.length || Math.sum((result.subtasks || []).map((s) => s.cases.length));
|
||||
if (!result.count) {
|
||||
try {
|
||||
result.subtasks = readSubtasksFromFiles(await collectFiles(folder), checkFile, cfg);
|
||||
result.count = Math.sum(result.subtasks.map((i) => i.cases.length));
|
||||
if (cfg.isSelfSubmission) args.next?.({ message: { message: 'Found {0} testcases.', params: [result.count] } });
|
||||
} catch (e) {
|
||||
throw new SystemError('Cannot parse testdata.', [e.message, ...e.params]);
|
||||
}
|
||||
}
|
||||
result.subtasks = normalizeSubtasks(result.subtasks || [], checkFile, config.time, config.memory);
|
||||
if (result.key && args.key !== result.key) throw new FormatError('Incorrect secret key');
|
||||
if (!result.key) isValidConfig(result);
|
||||
return result;
|
||||
|
||||
@@ -76,7 +76,7 @@ function judgeCase(c: Case, sid: string) {
|
||||
user_stdout: stdout,
|
||||
user_stderr: stderr,
|
||||
checker_type: ctx.config.checker_type,
|
||||
score: ctxSubtask.subtask.score,
|
||||
score: c.score,
|
||||
detail: ctx.config.detail ?? true,
|
||||
env: { ...ctx.env, HYDRO_TESTCASE: c.id.toString() },
|
||||
});
|
||||
|
||||
@@ -50,7 +50,7 @@ function judgeCase(c: Case) {
|
||||
if (code < 32) message = signals[code];
|
||||
else message = { message: 'Your program returned {0}.', params: [code] };
|
||||
} else {
|
||||
const result = parse(resInteractor.stderr, ctxSubtask.subtask.score);
|
||||
const result = parse(resInteractor.stderr, c.score);
|
||||
status = result.status;
|
||||
score = result.score;
|
||||
message = result.message;
|
||||
|
||||
@@ -105,4 +105,5 @@ export interface Case {
|
||||
id: number;
|
||||
input: string;
|
||||
output: string;
|
||||
score: number;
|
||||
}
|
||||
@@ -74,7 +74,7 @@ function judgeCase(c: Case, sid: string) {
|
||||
user_stdout: file,
|
||||
user_stderr: { content: '' },
|
||||
checker_type: ctx.config.checker_type,
|
||||
score: ctxSubtask.subtask.score,
|
||||
score: c.score,
|
||||
detail: ctx.config.detail ?? true,
|
||||
env: { ...ctx.env, HYDRO_TESTCASE: c.id.toString() },
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "hydrooj",
|
||||
"version": "3.12.3",
|
||||
"version": "3.13.0",
|
||||
"bin": "bin/hydrooj.js",
|
||||
"main": "src/loader",
|
||||
"module": "src/loader",
|
||||
|
||||
@@ -178,6 +178,7 @@ export async function service(pending: string[], fail: string[]) {
|
||||
}
|
||||
}
|
||||
for (const key in global.Hydro.service) {
|
||||
if (key === 'server') continue;
|
||||
const srv = global.Hydro.service[key];
|
||||
if (!srv.started && srv.start) await srv.start();
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ import { isBinaryFile } from 'isbinaryfile';
|
||||
import { intersection, isSafeInteger } from 'lodash';
|
||||
import { FilterQuery, ObjectID } from 'mongodb';
|
||||
import { nanoid } from 'nanoid';
|
||||
import { sortFiles } from '@hydrooj/utils/lib/utils';
|
||||
import { sortFiles, streamToBuffer } from '@hydrooj/utils/lib/utils';
|
||||
import {
|
||||
BadRequestError, ContestNotAttendedError, ContestNotEndedError,
|
||||
ContestNotFoundError, ContestNotLiveError,
|
||||
@@ -525,6 +525,7 @@ export class ProblemManageHandler extends ProblemDetailHandler {
|
||||
|
||||
export class ProblemEditHandler extends ProblemManageHandler {
|
||||
async get() {
|
||||
this.response.body.additional_file = sortFiles(this.pdoc.additional_file || []);
|
||||
this.response.template = 'problem_edit.html';
|
||||
}
|
||||
|
||||
@@ -550,6 +551,23 @@ export class ProblemEditHandler extends ProblemManageHandler {
|
||||
}
|
||||
}
|
||||
|
||||
export class ProblemConfigHandler extends ProblemManageHandler {
|
||||
async get() {
|
||||
if (this.pdoc.reference) throw new ForbiddenError('Cannot edit config of a referenced problem.');
|
||||
this.response.body.testdata = sortFiles(this.pdoc.data || []);
|
||||
const configFile = (this.pdoc.data || []).filter((i) => i.name.toLowerCase() === 'config.yaml');
|
||||
this.response.body.config = '';
|
||||
if (configFile.length > 0) {
|
||||
try {
|
||||
this.response.body.config = (await streamToBuffer(
|
||||
await storage.get(`problem/${this.pdoc.domainId}/${this.pdoc.docId}/testdata/${configFile[0].name}`),
|
||||
)).toString();
|
||||
} catch (e) { /* ignore */ }
|
||||
}
|
||||
this.response.template = 'problem_config.html';
|
||||
}
|
||||
}
|
||||
|
||||
export class ProblemFilesHandler extends ProblemDetailHandler {
|
||||
notUsage = true;
|
||||
|
||||
@@ -831,6 +849,7 @@ export class ProblemCreateHandler extends Handler {
|
||||
this.response.template = 'problem_edit.html';
|
||||
this.response.body = {
|
||||
page_name: 'problem_create',
|
||||
additional_file: [],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -892,6 +911,7 @@ export async function apply() {
|
||||
Route('problem_detail', '/p/:pid', ProblemDetailHandler, PERM.PERM_VIEW_PROBLEM);
|
||||
Route('problem_submit', '/p/:pid/submit', ProblemSubmitHandler, PERM.PERM_SUBMIT_PROBLEM);
|
||||
Route('problem_edit', '/p/:pid/edit', ProblemEditHandler);
|
||||
Route('problem_config', '/p/:pid/config', ProblemConfigHandler);
|
||||
Route('problem_files', '/p/:pid/files', ProblemFilesHandler, PERM.PERM_VIEW_PROBLEM);
|
||||
Route('problem_file_download', '/p/:pid/file/:filename', ProblemFileDownloadHandler, PERM.PERM_VIEW_PROBLEM);
|
||||
Route('problem_solution', '/p/:pid/solution', ProblemSolutionHandler, PERM.PERM_VIEW_PROBLEM);
|
||||
|
||||
@@ -127,10 +127,11 @@ export interface FileInfo {
|
||||
}
|
||||
|
||||
export interface TestCaseConfig {
|
||||
input: string,
|
||||
output: string,
|
||||
time?: number,
|
||||
memory?: number,
|
||||
input: string;
|
||||
output: string;
|
||||
time?: string;
|
||||
memory?: string;
|
||||
score?: number;
|
||||
}
|
||||
|
||||
export enum ProblemType {
|
||||
@@ -147,8 +148,8 @@ export enum SubtaskType {
|
||||
}
|
||||
|
||||
export interface SubtaskConfig {
|
||||
time?: number;
|
||||
memory?: number;
|
||||
time?: string;
|
||||
memory?: string;
|
||||
score?: number;
|
||||
if?: number[];
|
||||
id?: number;
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
import { load } from 'js-yaml';
|
||||
import readYamlCases from '@hydrooj/utils/lib/cases';
|
||||
import type { ProblemConfig, ProblemConfigFile } from '../interface';
|
||||
import { normalizeSubtasks, parseMemoryMB, parseTimeMS } from '../utils';
|
||||
|
||||
export async function parseConfig(config: string | ProblemConfigFile = {}) {
|
||||
let cfg: ProblemConfigFile = {};
|
||||
if (typeof config === 'string') {
|
||||
// TODO should validate here?
|
||||
cfg = await readYamlCases(load(config) as Record<string, any>);
|
||||
} else if (typeof config === 'object') cfg = await readYamlCases(config);
|
||||
const cfg: ProblemConfigFile = typeof config === 'string'
|
||||
? await readYamlCases(load(config) as Record<string, any>)
|
||||
: await readYamlCases(config);
|
||||
const result: ProblemConfig = {
|
||||
count: 0,
|
||||
memoryMin: Number.MAX_SAFE_INTEGER,
|
||||
@@ -18,23 +17,19 @@ export async function parseConfig(config: string | ProblemConfigFile = {}) {
|
||||
};
|
||||
if (cfg.subType) result.subType = cfg.subType;
|
||||
if (cfg.target) result.target = cfg.target;
|
||||
if (cfg.subtasks.length) {
|
||||
for (const subtask of cfg.subtasks) {
|
||||
result.memoryMax = Math.max(result.memoryMax, subtask.memory);
|
||||
result.memoryMin = Math.min(result.memoryMin, subtask.memory);
|
||||
result.timeMax = Math.max(result.timeMax, subtask.time);
|
||||
result.timeMin = Math.min(result.timeMin, subtask.time);
|
||||
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)));
|
||||
result.memoryMin = Math.min(result.memoryMin, ...subtask.cases.map((i) => parseMemoryMB(i.memory)));
|
||||
result.timeMax = Math.max(result.timeMax, ...subtask.cases.map((i) => parseTimeMS(i.time)));
|
||||
result.timeMin = Math.min(result.timeMin, ...subtask.cases.map((i) => parseTimeMS(i.time)));
|
||||
}
|
||||
} else if (cfg.time || cfg.memory) {
|
||||
} else {
|
||||
if (cfg.time) result.timeMax = result.timeMin = cfg.time as unknown as number;
|
||||
if (cfg.memory) result.memoryMax = result.memoryMin = cfg.memory as unknown as number;
|
||||
}
|
||||
if (result.memoryMax < result.memoryMin) {
|
||||
result.memoryMax = result.memoryMin = 256;
|
||||
}
|
||||
if (result.timeMax < result.timeMin) {
|
||||
result.timeMax = result.timeMin = 1000;
|
||||
}
|
||||
if (result.memoryMax < result.memoryMin) result.memoryMax = result.memoryMin = 256;
|
||||
if (result.timeMax < result.timeMin) result.timeMax = result.timeMin = 1000;
|
||||
if (cfg.langs) result.langs = cfg.langs;
|
||||
if (cfg.redirect) result.redirect = cfg.redirect.split('/', 2) as any;
|
||||
if (cfg.filename && result.type === 'default') {
|
||||
|
||||
@@ -81,7 +81,7 @@ module.exports = {
|
||||
indent: [
|
||||
'error',
|
||||
2,
|
||||
{ SwitchCase: 0 },
|
||||
{ SwitchCase: 1 },
|
||||
],
|
||||
'max-len': ['error', 150],
|
||||
quotes: 'warn',
|
||||
@@ -115,6 +115,7 @@ module.exports = {
|
||||
'no-underscore-dangle': 'off',
|
||||
'no-await-in-loop': 'off',
|
||||
'no-lonely-if': 'off',
|
||||
'no-nested-ternary': 'off',
|
||||
'no-param-reassign': 'off',
|
||||
'no-script-url': 'off',
|
||||
'no-bitwise': 'off',
|
||||
|
||||
@@ -53,7 +53,7 @@
|
||||
white-space: nowrap
|
||||
text-overflow: ellipsis
|
||||
|
||||
&>span.bp3-icon>svg
|
||||
&>span.bp4-icon>svg
|
||||
font-size: 12px
|
||||
cursor: pointer
|
||||
padding: 4px
|
||||
@@ -78,19 +78,19 @@
|
||||
&>div
|
||||
flex-grow: 1
|
||||
|
||||
&>span.bp3-icon>svg
|
||||
&>span.bp4-icon>svg
|
||||
color: transparent
|
||||
|
||||
&[aria-selected='true']
|
||||
background-color: #fafafa
|
||||
font-weight: 600
|
||||
|
||||
&>span.bp3-icon>svg
|
||||
&>span.bp4-icon>svg
|
||||
color: #1890ff
|
||||
|
||||
&[data-focus='true']
|
||||
background-color: #e6f7ff
|
||||
cursor: pointer
|
||||
|
||||
&>span.bp3-icon>svg
|
||||
&>span.bp4-icon>svg
|
||||
color: currentColor
|
||||
@@ -17,6 +17,7 @@ export interface AutoCompleteProps<Item> {
|
||||
*/
|
||||
height?: string;
|
||||
disabled?: boolean;
|
||||
placeholder?: string;
|
||||
disabledHint?: string;
|
||||
listStyle?: React.CSSProperties;
|
||||
cacheKey?: string;
|
||||
@@ -128,8 +129,10 @@ const AutoComplete = forwardRef(function Impl<T>(props: AutoCompleteProps<T>, re
|
||||
else onChange(selectedKeys.join(','));
|
||||
};
|
||||
|
||||
let first = !multi;
|
||||
useEffect(() => {
|
||||
dispatchChange();
|
||||
if (first) first = false;
|
||||
else dispatchChange();
|
||||
const ids = [];
|
||||
for (const key of selectedKeys) if (!valueCache[key]) ids.push(key);
|
||||
if (!ids.length) return;
|
||||
@@ -293,6 +296,7 @@ const AutoComplete = forwardRef(function Impl<T>(props: AutoCompleteProps<T>, re
|
||||
if (allowEmptyQuery) handleInputChange();
|
||||
setFocused(true);
|
||||
}}
|
||||
placeholder={props.placeholder}
|
||||
onBlur={() => setFocused(false)}
|
||||
onKeyDown={handleInputKeyDown}
|
||||
defaultValue={multi ? '' : selectedKeys.join(',')}
|
||||
@@ -342,6 +346,7 @@ AutoComplete.propTypes = {
|
||||
allowEmptyQuery: PropTypes.bool,
|
||||
freeSolo: PropTypes.bool,
|
||||
freeSoloConverter: PropTypes.func,
|
||||
placeholder: PropTypes.string,
|
||||
};
|
||||
|
||||
AutoComplete.defaultProps = {
|
||||
|
||||
@@ -33,7 +33,7 @@ CustomSelectAutoComplete.propTypes = {
|
||||
allowEmptyQuery: PropTypes.bool,
|
||||
freeSolo: PropTypes.bool,
|
||||
freeSoloConverter: PropTypes.func,
|
||||
data: PropTypes.any,
|
||||
data: PropTypes.arrayOf(PropTypes.any),
|
||||
};
|
||||
|
||||
CustomSelectAutoComplete.displayName = 'CustomSelectAutoComplete';
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import AutoComplete from './AutoComplete';
|
||||
|
||||
type DefaultProps = {
|
||||
_id: string;
|
||||
name: string;
|
||||
} & string;
|
||||
|
||||
const FileSelectAutoComplete = React.forwardRef<any, any>((props, ref) => (
|
||||
<AutoComplete<DefaultProps>
|
||||
ref={ref as any}
|
||||
fetchItems={(keys) => props.data.filter((i) => (i._id ? keys.includes(i._id) : keys.includes(i)))}
|
||||
queryItems={(query) => props.data.filter((i) => (i.name
|
||||
? i.name.toLowerCase().match(query.toLowerCase())
|
||||
: i.toString().toLowerCase().match(query.toLowerCase())
|
||||
))}
|
||||
itemText={(item) => `${item.name || item}`}
|
||||
itemKey={(item) => `${item._id?.toString() || item.name || item}`}
|
||||
renderItem={(item) => item.name || item._id || item} // TODO icon
|
||||
allowEmptyQuery
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
|
||||
FileSelectAutoComplete.propTypes = {
|
||||
width: PropTypes.string,
|
||||
height: PropTypes.string,
|
||||
listStyle: PropTypes.object,
|
||||
onChange: PropTypes.func.isRequired,
|
||||
multi: PropTypes.bool,
|
||||
selectedKeys: PropTypes.arrayOf(PropTypes.string),
|
||||
allowEmptyQuery: PropTypes.bool,
|
||||
freeSolo: PropTypes.bool,
|
||||
freeSoloConverter: PropTypes.func,
|
||||
data: PropTypes.arrayOf(PropTypes.any),
|
||||
};
|
||||
|
||||
FileSelectAutoComplete.displayName = 'FileSelectAutoComplete';
|
||||
|
||||
FileSelectAutoComplete.defaultProps = {
|
||||
width: '100%',
|
||||
height: 'auto',
|
||||
listStyle: {},
|
||||
multi: false,
|
||||
selectedKeys: [],
|
||||
freeSolo: false,
|
||||
freeSoloConverter: (input) => input,
|
||||
};
|
||||
|
||||
export default FileSelectAutoComplete;
|
||||
@@ -81,7 +81,7 @@ export default class Editor extends DOMAttachedObject {
|
||||
? monaco.editor.getModel(monaco.Uri.parse(model))
|
||||
|| monaco.editor.createModel(value, language === 'auto' ? undefined : language, monaco.Uri.parse(model))
|
||||
: model;
|
||||
this.model.setValue(value);
|
||||
if (!this.options.model) this.model.setValue(value);
|
||||
const cfg: import('../monaco').default.editor.IStandaloneEditorConstructionOptions = {
|
||||
theme,
|
||||
lineNumbers: 'on',
|
||||
|
||||
@@ -38,15 +38,15 @@ function testElementHotkey(hotkey, $element, attr) {
|
||||
const [defStr, trigger] = singleDef.split(':');
|
||||
if (isHotkeyMatch(hotkey, defStr)) {
|
||||
switch (trigger) {
|
||||
case 'submit':
|
||||
$element.closest('form').trigger('submit');
|
||||
break;
|
||||
case undefined:
|
||||
$element.trigger('click');
|
||||
break;
|
||||
default:
|
||||
$element.trigger(trigger);
|
||||
break;
|
||||
case 'submit':
|
||||
$element.closest('form').trigger('submit');
|
||||
break;
|
||||
case undefined:
|
||||
$element.trigger('click');
|
||||
break;
|
||||
default:
|
||||
$element.trigger(trigger);
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -42,6 +42,9 @@
|
||||
opacity: 0.85
|
||||
|
||||
a.menu__link, button.menu__link
|
||||
&.half
|
||||
padding-right: 0
|
||||
width: 50%
|
||||
&.disabled
|
||||
cursor: default
|
||||
opacity: 0.5
|
||||
|
||||
@@ -21,14 +21,14 @@ function scrollToViewport() {
|
||||
|
||||
export default function reducer(state = null, action) {
|
||||
switch (action.type) {
|
||||
case 'DIALOGUES_SWITCH_TO': {
|
||||
scrollToViewport();
|
||||
return action.payload;
|
||||
}
|
||||
case 'DIALOGUES_POST_SEND_FULFILLED': {
|
||||
return action.meta.dialogueId;
|
||||
}
|
||||
default:
|
||||
return state;
|
||||
case 'DIALOGUES_SWITCH_TO': {
|
||||
scrollToViewport();
|
||||
return action.payload;
|
||||
}
|
||||
case 'DIALOGUES_POST_SEND_FULFILLED': {
|
||||
return action.meta.dialogueId;
|
||||
}
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
}
|
||||
@@ -2,52 +2,52 @@ import _ from 'lodash';
|
||||
|
||||
export default function reducer(state = {}, action) {
|
||||
switch (action.type) {
|
||||
case 'DIALOGUES_LOAD_DIALOGUES_FULFILLED': {
|
||||
const { messages } = action.payload;
|
||||
return { ...state, ..._.keyBy(messages, 'udoc._id') };
|
||||
}
|
||||
case 'DIALOGUES_CREATE': {
|
||||
const { user } = action.payload;
|
||||
if (state[user._id]) return state;
|
||||
return {
|
||||
...state,
|
||||
[user._id]: {
|
||||
_id: user._id,
|
||||
udoc: { ...user },
|
||||
messages: [],
|
||||
},
|
||||
};
|
||||
}
|
||||
case 'DIALOGUES_POST_SEND_FULFILLED': {
|
||||
const id = action.meta.dialogueId;
|
||||
const { mdoc } = action.payload;
|
||||
return {
|
||||
...state,
|
||||
[id]: {
|
||||
...state[id],
|
||||
messages: [
|
||||
...state[id].messages,
|
||||
mdoc,
|
||||
],
|
||||
},
|
||||
};
|
||||
}
|
||||
case 'DIALOGUES_MESSAGE_PUSH': {
|
||||
const { mdoc, udoc } = action.payload;
|
||||
const to = mdoc.from === UserContext._id ? mdoc.to : mdoc.from;
|
||||
return {
|
||||
...state,
|
||||
[to]: {
|
||||
...state[to] || {},
|
||||
udoc,
|
||||
messages: [
|
||||
...state[to]?.messages || [],
|
||||
mdoc,
|
||||
],
|
||||
},
|
||||
};
|
||||
}
|
||||
default:
|
||||
return state;
|
||||
case 'DIALOGUES_LOAD_DIALOGUES_FULFILLED': {
|
||||
const { messages } = action.payload;
|
||||
return { ...state, ..._.keyBy(messages, 'udoc._id') };
|
||||
}
|
||||
case 'DIALOGUES_CREATE': {
|
||||
const { user } = action.payload;
|
||||
if (state[user._id]) return state;
|
||||
return {
|
||||
...state,
|
||||
[user._id]: {
|
||||
_id: user._id,
|
||||
udoc: { ...user },
|
||||
messages: [],
|
||||
},
|
||||
};
|
||||
}
|
||||
case 'DIALOGUES_POST_SEND_FULFILLED': {
|
||||
const id = action.meta.dialogueId;
|
||||
const { mdoc } = action.payload;
|
||||
return {
|
||||
...state,
|
||||
[id]: {
|
||||
...state[id],
|
||||
messages: [
|
||||
...state[id].messages,
|
||||
mdoc,
|
||||
],
|
||||
},
|
||||
};
|
||||
}
|
||||
case 'DIALOGUES_MESSAGE_PUSH': {
|
||||
const { mdoc, udoc } = action.payload;
|
||||
const to = mdoc.from === UserContext._id ? mdoc.to : mdoc.from;
|
||||
return {
|
||||
...state,
|
||||
[to]: {
|
||||
...state[to] || {},
|
||||
udoc,
|
||||
messages: [
|
||||
...state[to]?.messages || [],
|
||||
mdoc,
|
||||
],
|
||||
},
|
||||
};
|
||||
}
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
}
|
||||
@@ -2,42 +2,42 @@ import _ from 'lodash';
|
||||
|
||||
export default function reducer(state = {}, action) {
|
||||
switch (action.type) {
|
||||
case 'DIALOGUES_LOAD_DIALOGUES_FULFILLED': {
|
||||
const dialogues = action.payload.messages;
|
||||
return { ...state, ..._.fromPairs(_.map(dialogues, (d) => [d._id, ''])) };
|
||||
}
|
||||
case 'DIALOGUES_CREATE': {
|
||||
const { user } = action.payload;
|
||||
return {
|
||||
...state,
|
||||
[user._id]: '',
|
||||
};
|
||||
}
|
||||
case 'DIALOGUES_INPUT_CHANGED': {
|
||||
const id = action.meta.dialogueId;
|
||||
return {
|
||||
...state,
|
||||
[id]: action.payload,
|
||||
};
|
||||
}
|
||||
case 'DIALOGUES_POST_SEND_FULFILLED': {
|
||||
const id = action.meta.dialogueId;
|
||||
return {
|
||||
...state,
|
||||
[id]: '',
|
||||
};
|
||||
}
|
||||
case 'DIALOGUES_MESSAGE_PUSH': {
|
||||
const { udoc } = action.payload;
|
||||
if (!state[udoc._id]) {
|
||||
case 'DIALOGUES_LOAD_DIALOGUES_FULFILLED': {
|
||||
const dialogues = action.payload.messages;
|
||||
return { ...state, ..._.fromPairs(_.map(dialogues, (d) => [d._id, ''])) };
|
||||
}
|
||||
case 'DIALOGUES_CREATE': {
|
||||
const { user } = action.payload;
|
||||
return {
|
||||
...state,
|
||||
[udoc._id]: '',
|
||||
[user._id]: '',
|
||||
};
|
||||
}
|
||||
return state;
|
||||
}
|
||||
default:
|
||||
return state;
|
||||
case 'DIALOGUES_INPUT_CHANGED': {
|
||||
const id = action.meta.dialogueId;
|
||||
return {
|
||||
...state,
|
||||
[id]: action.payload,
|
||||
};
|
||||
}
|
||||
case 'DIALOGUES_POST_SEND_FULFILLED': {
|
||||
const id = action.meta.dialogueId;
|
||||
return {
|
||||
...state,
|
||||
[id]: '',
|
||||
};
|
||||
}
|
||||
case 'DIALOGUES_MESSAGE_PUSH': {
|
||||
const { udoc } = action.payload;
|
||||
if (!state[udoc._id]) {
|
||||
return {
|
||||
...state,
|
||||
[udoc._id]: '',
|
||||
};
|
||||
}
|
||||
return state;
|
||||
}
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
}
|
||||
@@ -3,47 +3,47 @@ import Notification from 'vj/components/notification';
|
||||
|
||||
export default function reducer(state = {}, action) {
|
||||
switch (action.type) {
|
||||
case 'DIALOGUES_LOAD_DIALOGUES_FULFILLED': {
|
||||
const dialogues = action.payload.messages;
|
||||
return { ...state, ..._.fromPairs(_.map(dialogues, (d) => [d._id, false])) };
|
||||
}
|
||||
case 'DIALOGUES_CREATE': {
|
||||
const { user } = action.payload;
|
||||
return {
|
||||
...state,
|
||||
[user._id]: false,
|
||||
};
|
||||
}
|
||||
case 'DIALOGUES_POST_SEND_PENDING': {
|
||||
const id = action.meta.dialogueId;
|
||||
return {
|
||||
...state,
|
||||
[id]: true,
|
||||
};
|
||||
}
|
||||
case 'DIALOGUES_POST_SEND_REJECTED': {
|
||||
Notification.error(action.payload.message);
|
||||
const id = action.meta.dialogueId;
|
||||
return {
|
||||
...state,
|
||||
[id]: false,
|
||||
};
|
||||
}
|
||||
case 'DIALOGUES_POST_SEND_FULFILLED': {
|
||||
const id = action.meta.dialogueId;
|
||||
return {
|
||||
...state,
|
||||
[id]: false,
|
||||
};
|
||||
}
|
||||
case 'DIALOGUES_MESSAGE_PUSH': {
|
||||
const { udoc } = action.payload;
|
||||
return {
|
||||
...state,
|
||||
[udoc._id]: false,
|
||||
};
|
||||
}
|
||||
default:
|
||||
return state;
|
||||
case 'DIALOGUES_LOAD_DIALOGUES_FULFILLED': {
|
||||
const dialogues = action.payload.messages;
|
||||
return { ...state, ..._.fromPairs(_.map(dialogues, (d) => [d._id, false])) };
|
||||
}
|
||||
case 'DIALOGUES_CREATE': {
|
||||
const { user } = action.payload;
|
||||
return {
|
||||
...state,
|
||||
[user._id]: false,
|
||||
};
|
||||
}
|
||||
case 'DIALOGUES_POST_SEND_PENDING': {
|
||||
const id = action.meta.dialogueId;
|
||||
return {
|
||||
...state,
|
||||
[id]: true,
|
||||
};
|
||||
}
|
||||
case 'DIALOGUES_POST_SEND_REJECTED': {
|
||||
Notification.error(action.payload.message);
|
||||
const id = action.meta.dialogueId;
|
||||
return {
|
||||
...state,
|
||||
[id]: false,
|
||||
};
|
||||
}
|
||||
case 'DIALOGUES_POST_SEND_FULFILLED': {
|
||||
const id = action.meta.dialogueId;
|
||||
return {
|
||||
...state,
|
||||
[id]: false,
|
||||
};
|
||||
}
|
||||
case 'DIALOGUES_MESSAGE_PUSH': {
|
||||
const { udoc } = action.payload;
|
||||
return {
|
||||
...state,
|
||||
[udoc._id]: false,
|
||||
};
|
||||
}
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,9 @@ const problemConfigSchemaDef = {
|
||||
properties: {
|
||||
input: { type: 'string' },
|
||||
output: { type: 'string' },
|
||||
time: { $ref: '#/def/time' },
|
||||
memory: { $ref: '#/def/memory' },
|
||||
score: { $ref: '#/def/score', description: 'score' },
|
||||
},
|
||||
required: ['input'],
|
||||
additionalProperties: false,
|
||||
@@ -28,7 +31,7 @@ const problemConfigSchemaDef = {
|
||||
additionalProperties: false,
|
||||
},
|
||||
time: { type: 'string', pattern: '^([0-9]+(?:\\.[0-9]*)?)([mu]?)s?$' },
|
||||
memory: { type: 'string', pattern: '^([0-9]+(?:\\.[0-9]*)?)([kmg])b?$' },
|
||||
memory: { type: 'string', pattern: '^([0-9]+(?:\\.[0-9]*)?)([kKmMgG])[bB]?$' },
|
||||
score: { type: 'integer', maximum: 100, minimum: 1 },
|
||||
};
|
||||
|
||||
@@ -37,7 +40,7 @@ const problemConfigSchema = {
|
||||
type: 'object',
|
||||
def: problemConfigSchemaDef,
|
||||
properties: {
|
||||
redirect: { type: 'string', pattern: '[0-9a-z_-]+\\/[0-9]+' },
|
||||
redirect: { type: 'string', pattern: '[0-9a-zA-Z_-]+\\/[0-9]+' },
|
||||
key: { type: 'string', pattern: '[0-9a-f]{32}' },
|
||||
type: { enum: ['default', 'interactive', 'submit_answer', 'objective', 'remote_judge'] },
|
||||
subType: { type: 'string' },
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
import React from 'react';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import i18n from 'vj/utils/i18n';
|
||||
import type { RootState } from './reducer/index';
|
||||
import FileSelectAutoComplete from '../autocomplete/components/FileSelectAutoComplete';
|
||||
|
||||
export function FormItem({
|
||||
columns, label, children, helpText = '', disableLabel = false, ...props
|
||||
}) {
|
||||
return (
|
||||
<div {...props} className={`${columns && `medium-${columns}`} columns form__item`}>
|
||||
<label htmlFor={`${label}-form`}>
|
||||
{!disableLabel && i18n(label)}
|
||||
{children}
|
||||
{helpText && (<p className="help-text">{i18n(helpText)}</p>)}
|
||||
</label>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type KeyType<K, T = string | number> = {
|
||||
[Q in keyof K]: K[Q] extends T ? Q : never;
|
||||
}[keyof K];
|
||||
|
||||
export function ManagedInput({ placeholder, formKey }: { placeholder?: string, formKey: KeyType<RootState['config']> }) {
|
||||
const value = useSelector((state: RootState) => state.config[formKey]);
|
||||
const dispatch = useDispatch();
|
||||
return (
|
||||
<input
|
||||
placeholder={i18n(placeholder)}
|
||||
value={value || ''}
|
||||
type={typeof value === 'number' ? 'number' : 'text'}
|
||||
onChange={(ev) => {
|
||||
dispatch({ type: 'CONFIG_FORM_UPDATE', key: formKey, value: ev.currentTarget.value });
|
||||
}}
|
||||
className="textbox"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function ManagedSelect({ options, formKey }: { options: string[], formKey: KeyType<RootState['config']> }) {
|
||||
const value = useSelector((state: RootState) => state.config[formKey]);
|
||||
const dispatch = useDispatch();
|
||||
return (
|
||||
<select
|
||||
value={value || ''}
|
||||
onChange={(ev) => {
|
||||
dispatch({ type: 'CONFIG_FORM_UPDATE', key: formKey, value: ev.currentTarget.value });
|
||||
}}
|
||||
className="select"
|
||||
>
|
||||
{options.map((o) => (<option id={o} key={o}>{o}</option>))}
|
||||
</select>
|
||||
);
|
||||
}
|
||||
|
||||
export function SingleFileSelect({ formKey }: { formKey: KeyType<RootState['config']> }) {
|
||||
const value = useSelector((state: RootState) => state.config[formKey]);
|
||||
const Files = useSelector((state: RootState) => state.testdata);
|
||||
const dispatch = useDispatch();
|
||||
return (
|
||||
<FileSelectAutoComplete
|
||||
width="100%"
|
||||
data={Files}
|
||||
selectedKeys={[value]}
|
||||
onChange={(val) => dispatch({ type: 'CONFIG_FORM_UPDATE', key: formKey, value: val })}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
import React from 'react';
|
||||
import type { editor } from 'monaco-editor';
|
||||
import { connect } from 'react-redux';
|
||||
import { load } from 'vj/components/monaco/loader';
|
||||
import Editor from 'vj/components/editor';
|
||||
import { diffLines } from 'diff';
|
||||
import yaml from 'js-yaml';
|
||||
import type { ProblemConfigFile, TestCaseConfig } from 'hydrooj/src/interface';
|
||||
|
||||
const mapStateToProps = (state) => ({
|
||||
config: state.config,
|
||||
});
|
||||
const mapDispatchToProps = (dispatch) => ({
|
||||
handleUpdateCode: (code) => {
|
||||
dispatch({
|
||||
type: 'CONFIG_CODE_UPDATE',
|
||||
payload: code,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
interface Props {
|
||||
config: object;
|
||||
handleUpdateCode: Function;
|
||||
}
|
||||
|
||||
const configKey = [
|
||||
'type', 'subType', 'target', 'score', 'time',
|
||||
'memory', 'filename', 'checker_type', 'checker', 'interactor',
|
||||
'user_extra_files', 'judge_extra_files', 'detail', 'outputs', 'redirect',
|
||||
'cases', 'subtasks', 'langs',
|
||||
];
|
||||
|
||||
const subtasksKey = [
|
||||
'time', 'memory', 'score', 'if', 'id',
|
||||
'type', 'cases',
|
||||
];
|
||||
|
||||
const casesKey = ['time', 'memory', 'input', 'output'];
|
||||
|
||||
export function configYamlFormat(config: ProblemConfigFile) {
|
||||
const formatConfig: ProblemConfigFile = {};
|
||||
configKey.forEach((key) => {
|
||||
if (config[key] !== undefined) {
|
||||
if (key === 'subType' && ['single', 'multi'].includes(config[key]) && config.type !== 'submit_answer') return;
|
||||
if (key === 'checker_type' && config.type !== 'default') return;
|
||||
if (key === 'checker'
|
||||
&& (['default', 'strict'].includes(formatConfig.checker_type) || !formatConfig.checker_type)) return;
|
||||
if (key === 'interactor' && config.type !== 'interactive') return;
|
||||
if (key === 'subtasks') {
|
||||
formatConfig[key] = [];
|
||||
config[key].forEach((subtask) => {
|
||||
const formatSubtask: object = {};
|
||||
subtasksKey.forEach((subtaskKey) => {
|
||||
if (subtask && subtask[subtaskKey] !== undefined) {
|
||||
formatSubtask[subtaskKey] = subtask[subtaskKey];
|
||||
}
|
||||
});
|
||||
formatConfig[key].push(formatSubtask);
|
||||
});
|
||||
} else if (key === 'cases') {
|
||||
formatConfig[key] = [];
|
||||
config[key].forEach((caseItem) => {
|
||||
const formatCase: TestCaseConfig = {
|
||||
time: '1000ms', memory: '256MB', input: '', output: '',
|
||||
};
|
||||
casesKey.forEach((caseKey) => {
|
||||
if (caseItem[caseKey] !== undefined) formatCase[caseKey] = caseItem[caseKey];
|
||||
else delete formatCase[caseKey];
|
||||
});
|
||||
formatConfig[key].push(formatCase);
|
||||
});
|
||||
} else formatConfig[key] = config[key];
|
||||
}
|
||||
});
|
||||
if (formatConfig.type === 'objective') {
|
||||
Object.keys(formatConfig).filter((i) => !['type', 'outputs'].includes(i)).forEach((i) => delete formatConfig[i]);
|
||||
formatConfig.outputs = formatConfig.outputs || [];
|
||||
}
|
||||
Object.keys(formatConfig).filter((i) => i.startsWith('__')).forEach((i) => delete formatConfig[i]);
|
||||
return formatConfig;
|
||||
}
|
||||
|
||||
export default connect(mapStateToProps, mapDispatchToProps)(class MonacoEditor extends React.PureComponent<Props> {
|
||||
disposable = [];
|
||||
containerElement: HTMLElement;
|
||||
private __preventUpdate = false;
|
||||
private __preventFormat = false;
|
||||
|
||||
editor: editor.IStandaloneCodeEditor;
|
||||
model: editor.ITextModel;
|
||||
vjEditor: Editor;
|
||||
|
||||
async componentDidMount() {
|
||||
const { monaco } = await load(['yaml']);
|
||||
const uri = monaco.Uri.parse('hydro://problem/file/config.yaml');
|
||||
this.model = monaco.editor.createModel(yaml.dump(configYamlFormat(this.props.config)), 'yaml', uri);
|
||||
this.vjEditor = Editor.getOrConstruct($(this.containerElement), {
|
||||
language: 'yaml',
|
||||
model: this.model,
|
||||
onChange: (value: string) => {
|
||||
this.__preventUpdate = true;
|
||||
if (!this.__preventFormat) this.props.handleUpdateCode(value);
|
||||
this.__preventUpdate = false;
|
||||
},
|
||||
}) as Editor;
|
||||
this.editor = this.vjEditor.editor;
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps) {
|
||||
if (this.__preventUpdate || !this.model) return;
|
||||
if (yaml.dump(prevProps.config) !== yaml.dump(this.props.config)) {
|
||||
this.__preventFormat = true;
|
||||
const curValue = this.model.getValue();
|
||||
const diff = diffLines(curValue, yaml.dump(configYamlFormat(this.props.config)));
|
||||
const ops = [];
|
||||
let cursor = 1;
|
||||
for (const line of diff) {
|
||||
if (line.added) {
|
||||
let range = this.model.getFullModelRange();
|
||||
range = range.setStartPosition(cursor, 0);
|
||||
range = range.setEndPosition(cursor, 0);
|
||||
ops.push({ range, text: line.value });
|
||||
} else if (line.removed) {
|
||||
let range = this.model.getFullModelRange();
|
||||
range = range.setStartPosition(cursor, 0);
|
||||
cursor += line.count;
|
||||
range = range.setEndPosition(cursor, 0);
|
||||
ops.push({ range, text: '' });
|
||||
} else cursor += line.count;
|
||||
}
|
||||
this.model.pushEditOperations([], ops, undefined);
|
||||
this.__preventFormat = false;
|
||||
}
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
if (this.vjEditor) this.vjEditor.destory();
|
||||
if (this.model) this.model.dispose();
|
||||
if (this.editor) this.editor.dispose();
|
||||
this.disposable.map((i) => i.dispose());
|
||||
}
|
||||
|
||||
assignRef = (component) => {
|
||||
this.containerElement = component;
|
||||
};
|
||||
|
||||
render() {
|
||||
return (
|
||||
<div
|
||||
ref={this.assignRef}
|
||||
style={{
|
||||
minHeight: '500px',
|
||||
height: '100%',
|
||||
width: '100%',
|
||||
}}
|
||||
className="ConfigMonacoEditor"
|
||||
>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,118 @@
|
||||
import React from 'react';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import i18n from 'vj/utils/i18n';
|
||||
import {
|
||||
Card, Tab, Tabs, InputGroup, Tag,
|
||||
} from '@blueprintjs/core';
|
||||
import type { RootState } from './reducer/index';
|
||||
import CustomSelectAutoComplete from '../autocomplete/components/CustomSelectAutoComplete';
|
||||
import FileSelectAutoComplete from '../autocomplete/components/FileSelectAutoComplete';
|
||||
import { FormItem } from './BasicForm';
|
||||
import ProblemType from './ProblemType';
|
||||
import { TaskConfig } from './SubtaskTable';
|
||||
|
||||
function FileIOConfig() {
|
||||
const filename = useSelector((state: RootState) => state.config.filename);
|
||||
const dispatch = useDispatch();
|
||||
return (
|
||||
<FormItem columns={12} label="FileIOConfig" disableLabel>
|
||||
<Card style={{ padding: 10 }}>
|
||||
<div className="row">
|
||||
<FormItem columns={6} label="FileIO">
|
||||
<InputGroup
|
||||
rightElement={<Tag minimal>.in/.out</Tag>}
|
||||
value={filename || ''}
|
||||
onChange={(ev) => {
|
||||
dispatch({ type: 'CONFIG_FORM_UPDATE', key: 'filename', value: ev.currentTarget.value });
|
||||
}}
|
||||
fill
|
||||
/>
|
||||
</FormItem>
|
||||
</div>
|
||||
</Card>
|
||||
</FormItem>
|
||||
);
|
||||
}
|
||||
|
||||
function ExtraFilesConfig() {
|
||||
const Files = useSelector((state: RootState) => state.testdata);
|
||||
const userExtraFiles = useSelector((state: RootState) => state.config.user_extra_files);
|
||||
const judgeExtraFiles = useSelector((state: RootState) => state.config.judge_extra_files);
|
||||
const dispatch = useDispatch();
|
||||
return (
|
||||
<FormItem columns={12} label="ExtraFilesTabs" disableLabel>
|
||||
<Tabs id="ExtraFilesTabs">
|
||||
<Tab
|
||||
id="user_extra_files"
|
||||
title={i18n('user_extra_files')}
|
||||
panel={(
|
||||
<FileSelectAutoComplete
|
||||
data={Files}
|
||||
selectedKeys={userExtraFiles || []}
|
||||
onChange={(val) => dispatch({ type: 'CONFIG_FORM_UPDATE', key: 'user_extra_files', value: val.split(',') })}
|
||||
multi
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<Tab
|
||||
id="judge_extra_files"
|
||||
title={i18n('judge_extra_files')}
|
||||
panel={(
|
||||
<FileSelectAutoComplete
|
||||
data={Files}
|
||||
selectedKeys={judgeExtraFiles || []}
|
||||
onChange={(val) => dispatch({ type: 'CONFIG_FORM_UPDATE', key: 'judge_extra_files', value: val.split(',') })}
|
||||
multi
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</Tabs>
|
||||
</FormItem>
|
||||
);
|
||||
}
|
||||
|
||||
function LangConfig() {
|
||||
const langs = useSelector((state: RootState) => state.config.langs) || [];
|
||||
const prefixes = new Set(Object.keys(LANGS).filter((i) => i.includes('.')).map((i) => i.split('.')[0]));
|
||||
const data = Object.keys(LANGS).filter((i) => !prefixes.has(i))
|
||||
.map((i) => ({ name: LANGS[i].display, _id: i }));
|
||||
const dispatch = useDispatch();
|
||||
const ref = React.useRef<any>();
|
||||
const selectedKeys = langs.filter((i) => !prefixes.has(i));
|
||||
React.useEffect(() => {
|
||||
ref.current.setSelectedKeys(selectedKeys);
|
||||
}, [JSON.stringify(selectedKeys)]);
|
||||
return (
|
||||
<FormItem columns={12} label="langs">
|
||||
<CustomSelectAutoComplete
|
||||
ref={ref}
|
||||
data={data}
|
||||
placeholder={i18n('Unlimited')}
|
||||
selectedKeys={selectedKeys}
|
||||
onChange={(val) => {
|
||||
const value = val.split(',');
|
||||
value.push(...Array.from(new Set(value.filter((i) => i.includes('.')).map((i) => i.split('.')[0]))));
|
||||
dispatch({ type: 'CONFIG_FORM_UPDATE', key: 'langs', value });
|
||||
}}
|
||||
multi
|
||||
/>
|
||||
</FormItem>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ProblemConfigForm() {
|
||||
const Type = useSelector((state: RootState) => state.config.type);
|
||||
return (
|
||||
<div className="row problem-config-form">
|
||||
<ProblemType />
|
||||
{Type === 'default' && <FileIOConfig />}
|
||||
{!['submit_answer', 'objective'].includes(Type) && (
|
||||
<>
|
||||
<TaskConfig />
|
||||
<ExtraFilesConfig />
|
||||
<LangConfig />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
import React from 'react';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import i18n from 'vj/utils/i18n';
|
||||
import {
|
||||
Card, Switch, Tab, Tabs,
|
||||
} from '@blueprintjs/core';
|
||||
import type { RootState } from './reducer/index';
|
||||
import { FormItem, ManagedSelect, SingleFileSelect } from './BasicForm';
|
||||
|
||||
export default function ProblemType() {
|
||||
const Type = useSelector((state: RootState) => state.config.type);
|
||||
const checkerType = useSelector((state: RootState) => state.config.checker_type);
|
||||
const filename = useSelector((state: RootState) => state.config.filename);
|
||||
const subType = useSelector((state: RootState) => state.config.subType);
|
||||
const dispatch = useDispatch();
|
||||
const dispatcher = (base) => (value) => dispatch({ ...base, value });
|
||||
return (
|
||||
<FormItem columns={12} label="" disableLabel>
|
||||
<Card style={{ padding: 10 }}>
|
||||
<Tabs
|
||||
id="ProblemTypeTabs"
|
||||
selectedTabId={Type}
|
||||
onChange={dispatcher({ type: 'CONFIG_FORM_UPDATE', key: 'type' })}
|
||||
defaultSelectedTabId="default"
|
||||
renderActiveTabPanelOnly
|
||||
>
|
||||
<span className="bp4-tab">{i18n('Problem Type')}</span>
|
||||
<Tabs.Expander />
|
||||
<Tab
|
||||
id="default"
|
||||
title={i18n('problem_type.default')}
|
||||
panel={(
|
||||
<Tabs
|
||||
id="CheckerTypeTabs"
|
||||
selectedTabId={
|
||||
['strict', 'default'].includes(checkerType) || !checkerType
|
||||
? 'default' : (checkerType !== 'testlib' ? 'other' : 'testlib')
|
||||
}
|
||||
onChange={dispatcher({ type: 'CONFIG_FORM_UPDATE', key: 'checker_type' })}
|
||||
renderActiveTabPanelOnly
|
||||
>
|
||||
<span className="bp4-tab">{i18n('CheckerType')}</span>
|
||||
<Tabs.Expander />
|
||||
<Tab
|
||||
id="default"
|
||||
title={i18n('default')}
|
||||
panel={(
|
||||
<div className="row">
|
||||
<FormItem columns={12} label="Config">
|
||||
<Switch
|
||||
checked={checkerType !== 'strict'}
|
||||
label={i18n('Ignore trailing space and enter.')}
|
||||
onChange={() => {
|
||||
dispatch({ type: 'CONFIG_FORM_UPDATE', key: 'checker_type', value: checkerType === 'strict' ? 'default' : 'strict' });
|
||||
}}
|
||||
/>
|
||||
</FormItem>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
{['testlib', 'other'].map((i) => (
|
||||
<Tab
|
||||
id={i}
|
||||
title={i}
|
||||
key={i}
|
||||
panel={(
|
||||
<div className="row">
|
||||
{i === 'other' && (
|
||||
<FormItem columns={6} label="Interface">
|
||||
<ManagedSelect options={['syzoj', 'hustoj', 'qduoj', 'lemon']} formKey="checker_type" />
|
||||
</FormItem>
|
||||
)}
|
||||
<FormItem columns={6} label="Checker">
|
||||
<SingleFileSelect formKey="checker" />
|
||||
</FormItem>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
))}
|
||||
</Tabs>
|
||||
)}
|
||||
/>
|
||||
<Tab
|
||||
id="interactive"
|
||||
title={i18n('problem_type.interactive')}
|
||||
panel={(
|
||||
<div className="row">
|
||||
<FormItem columns={6} label="Interactor">
|
||||
<SingleFileSelect formKey="interactor" />
|
||||
</FormItem>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
<Tab
|
||||
id="submit_answer"
|
||||
title={i18n('problem_type.submit_answer')}
|
||||
panel={(
|
||||
<div className="row">
|
||||
<FormItem columns={6} label="Config" disableLabel>
|
||||
<Switch
|
||||
checked={subType === 'multi'}
|
||||
label={i18n('Multi-file')}
|
||||
onChange={() => {
|
||||
dispatch({ type: 'CONFIG_FORM_UPDATE', key: 'subType', value: subType === 'multi' ? 'single' : 'multi' });
|
||||
}}
|
||||
/>
|
||||
</FormItem>
|
||||
<FormItem columns={6} label="Filename">
|
||||
<input
|
||||
defaultValue={filename || '#.txt'}
|
||||
placeholder="#.txt"
|
||||
disabled={subType !== 'multi'}
|
||||
onChange={(ev) => dispatch(({ type: 'CONFIG_FORM_UPDATE', key: 'filename', value: ev.currentTarget.value }))}
|
||||
className="textbox"
|
||||
/>
|
||||
</FormItem>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
<Tab
|
||||
id="objective"
|
||||
title={i18n('problem_type.objective')}
|
||||
panel={(<p>{i18n('Unsupported configure this type of problem. Please refer to the documentation.')}</p>)}
|
||||
/>
|
||||
</Tabs>
|
||||
</Card>
|
||||
</FormItem>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
import { Card, NumericInput, Tag } from '@blueprintjs/core';
|
||||
import { pick, isEqual } from 'lodash';
|
||||
import React from 'react';
|
||||
import { useSelector, useDispatch } from 'react-redux';
|
||||
import { SubtaskConfig } from 'hydrooj/src/interface';
|
||||
import { parseTimeMS, parseMemoryMB } from '@hydrooj/utils/lib/common';
|
||||
import i18n from 'vj/utils/i18n';
|
||||
import CustomSelectAutoComplete from '../autocomplete/components/CustomSelectAutoComplete';
|
||||
import { FormItem } from './BasicForm';
|
||||
import { RootState } from './reducer';
|
||||
import { CasesTable } from './TestCasesTable';
|
||||
|
||||
const eq = (a: SubtaskConfig, b: SubtaskConfig) => isEqual(a, b);
|
||||
|
||||
export function SubtasksIds({ index }) {
|
||||
const ids = useSelector((state: RootState) => state.config.subtasks.map((i) => (i ? i.id : undefined)).filter((i) => i !== undefined).join(','));
|
||||
const subtaskIf = useSelector((state: RootState) => state.config.subtasks[index].if);
|
||||
const dispatch = useDispatch();
|
||||
return (
|
||||
<tr>
|
||||
<td style={{ textAlign: 'center' }}>if</td>
|
||||
<td colSpan={4}>
|
||||
<CustomSelectAutoComplete
|
||||
data={ids.split(',').filter((i) => i.length)}
|
||||
selectedKeys={subtaskIf?.map(String) || []}
|
||||
onChange={(val) => dispatch({
|
||||
type: 'CONFIG_SUBTASK_UPDATE', id: index, key: 'if', value: val.split(','),
|
||||
})}
|
||||
multi
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
|
||||
export function SubtasksTable({ index }) {
|
||||
const keys = ['id', 'type', 'score', 'time', 'memory'];
|
||||
const subtask = useSelector((state: RootState) => pick(state.config.subtasks[index], keys), eq);
|
||||
const defaultTime = useSelector((state: RootState) => state.config.time);
|
||||
const defaultMemory = useSelector((state: RootState) => state.config.memory);
|
||||
const dispatch = useDispatch();
|
||||
const dispatcher = (key: string, suffix = '') => (ev: React.ChangeEvent<HTMLInputElement | HTMLSelectElement> | number) => {
|
||||
let value = typeof ev !== 'object' ? ev : ev.currentTarget?.value;
|
||||
if (value === 0) value = '';
|
||||
if (value && suffix) value += suffix;
|
||||
dispatch({
|
||||
type: 'CONFIG_SUBTASK_UPDATE', id: index, key, value,
|
||||
});
|
||||
};
|
||||
if (!subtask || Object.keys(subtask).length === 0) {
|
||||
return (
|
||||
<Card style={{ padding: 0 }}>
|
||||
<span>Subtasks #{index + 1} </span>
|
||||
<p>{i18n('Failed to parse subtask.')}</p>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Card style={{ padding: 0 }}>
|
||||
<span>Subtasks #{index + 1} </span>
|
||||
<a onClick={() => dispatch({ type: 'CONFIG_SUBTASK_UPDATE', id: index, key: 'add' })}><span className="icon icon-add"></span></a>
|
||||
<a
|
||||
style={index === 0 ? { display: 'none' } : {}}
|
||||
onClick={() => dispatch({ type: 'CONFIG_SUBTASK_UPDATE', id: index, key: 'delete' })}
|
||||
>
|
||||
<span className="icon icon-delete"></span>
|
||||
</a>
|
||||
<table className="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>#</th>
|
||||
<th>Score</th>
|
||||
<th>Type</th>
|
||||
<th>Time</th>
|
||||
<th>Memory</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
{['id', 'score'].map((key) => (
|
||||
<td key={key}>
|
||||
<input
|
||||
value={subtask[key] ?? ''}
|
||||
onChange={dispatcher(key)}
|
||||
type="number"
|
||||
className="textbox"
|
||||
/>
|
||||
</td>
|
||||
))}
|
||||
<td>
|
||||
<select
|
||||
value={subtask.type ?? 'min'}
|
||||
onChange={dispatcher('type')}
|
||||
className="select"
|
||||
>
|
||||
{['min', 'max', 'sum'].map((i) => (<option value={i} key={i}>{i}</option>))}
|
||||
</select>
|
||||
</td>
|
||||
<td>
|
||||
<NumericInput
|
||||
rightElement={<Tag minimal>ms</Tag>}
|
||||
value={subtask.time ? parseTimeMS(subtask.time, false).toString() : ''}
|
||||
placeholder={parseTimeMS(defaultTime || '1000ms', false).toString()}
|
||||
onValueChange={dispatcher('time', 'ms')}
|
||||
buttonPosition="none"
|
||||
fill
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<NumericInput
|
||||
rightElement={<Tag minimal>MB</Tag>}
|
||||
value={subtask.memory ? parseMemoryMB(subtask.memory, false).toString() : ''}
|
||||
placeholder={parseMemoryMB(defaultMemory || '256m', false).toString()}
|
||||
onValueChange={dispatcher('memory', 'MB')}
|
||||
buttonPosition="none"
|
||||
fill
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
<SubtasksIds index={index} />
|
||||
</tbody>
|
||||
</table>
|
||||
<CasesTable index={index} />
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function GlobalTaskConfig() {
|
||||
const time = useSelector((state: RootState) => state.config.time);
|
||||
const memory = useSelector((state: RootState) => state.config.memory);
|
||||
const dispatch = useDispatch();
|
||||
const dispatcher = (key: string, suffix = '') => (ev: React.ChangeEvent<HTMLInputElement | HTMLSelectElement> | number) => {
|
||||
let value = typeof ev !== 'object' ? ev : ev.currentTarget?.value;
|
||||
if (value === 0) value = '';
|
||||
if (value && suffix) value += suffix;
|
||||
dispatch({ type: 'CONFIG_FORM_UPDATE', key, value });
|
||||
};
|
||||
return (
|
||||
<>
|
||||
<FormItem columns={6} label="Time">
|
||||
<NumericInput
|
||||
rightElement={<Tag minimal>ms</Tag>}
|
||||
value={time ? parseTimeMS(time, false).toString() : ''}
|
||||
placeholder={parseTimeMS('1000ms').toString()}
|
||||
onValueChange={dispatcher('time', 'ms')}
|
||||
buttonPosition="none"
|
||||
fill
|
||||
/>
|
||||
</FormItem>
|
||||
<FormItem columns={6} label="Memory">
|
||||
<NumericInput
|
||||
rightElement={<Tag minimal>MB</Tag>}
|
||||
value={memory ? parseMemoryMB(memory, false).toString() : ''}
|
||||
placeholder={parseMemoryMB('256m').toString()}
|
||||
onValueChange={dispatcher('memory', 'MB')}
|
||||
buttonPosition="none"
|
||||
fill
|
||||
/>
|
||||
</FormItem>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function TaskConfig() {
|
||||
const len = useSelector((state: RootState) => state.config.subtasks?.length);
|
||||
return (
|
||||
<FormItem columns={12} label="Task Settings">
|
||||
<div className="row">
|
||||
<GlobalTaskConfig />
|
||||
<FormItem columns={12} label="TestCases" disableLabel>
|
||||
{len && [...Array(len).keys()].map((i) => <SubtasksTable index={i} key={i} />)}
|
||||
</FormItem>
|
||||
</div>
|
||||
</FormItem>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
import React from 'react';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import i18n from 'vj/utils/i18n';
|
||||
import { isEqual } from 'lodash';
|
||||
import { Tag, NumericInput } from '@blueprintjs/core';
|
||||
import { parseTimeMS, parseMemoryMB } from '@hydrooj/utils/lib/common';
|
||||
import type { SubtaskConfig } from 'hydrooj/src/interface';
|
||||
import type { RootState } from './reducer/index';
|
||||
import FileSelectAutoComplete from '../autocomplete/components/FileSelectAutoComplete';
|
||||
|
||||
const eq = (a: SubtaskConfig, b: SubtaskConfig) => isEqual(a, b);
|
||||
const eqArr = (a: any[], b: any[]) => isEqual(a, b);
|
||||
|
||||
export function TestCaseEntry({ index, subindex }) {
|
||||
const testcase = useSelector((state: RootState) => state.config.subtasks[index].cases[subindex], eq);
|
||||
const Files = useSelector((state: RootState) => state.testdata, eqArr);
|
||||
const defaultTime = useSelector((state: RootState) => state.config.subtasks[index].time || state.config.time);
|
||||
const defaultMemory = useSelector((state: RootState) => state.config.subtasks[index].memory || state.config.memory);
|
||||
const dispatch = useDispatch();
|
||||
const dispatcher = (casesKey: string, valueSuffix = '') => (ev: React.ChangeEvent<HTMLInputElement | HTMLSelectElement> | number) => {
|
||||
let value = typeof ev !== 'object' ? ev : ev.currentTarget.value;
|
||||
if (value === 0) value = '';
|
||||
if (valueSuffix && value) value += valueSuffix;
|
||||
dispatch({
|
||||
type: 'CONFIG_SUBTASK_UPDATE',
|
||||
id: index,
|
||||
key: 'cases-edit',
|
||||
casesId: subindex,
|
||||
casesKey,
|
||||
value,
|
||||
});
|
||||
};
|
||||
if (!testcase || Object.keys(testcase).length === 0) {
|
||||
return (
|
||||
<tr><td colSpan={5}>{i18n('Failed to parse testcase.')}</td></tr>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<tr>
|
||||
<td>
|
||||
<NumericInput
|
||||
rightElement={<Tag minimal>ms</Tag>}
|
||||
value={testcase.time ? parseTimeMS(testcase.time, false).toString() : ''}
|
||||
placeholder={parseTimeMS(defaultTime || '1000ms', false).toString()}
|
||||
onValueChange={dispatcher('time', 'ms')}
|
||||
buttonPosition="none"
|
||||
fill
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<NumericInput
|
||||
rightElement={<Tag minimal>MB</Tag>}
|
||||
value={testcase.memory ? parseMemoryMB(testcase.memory, false).toString() : ''}
|
||||
placeholder={parseMemoryMB(defaultMemory || '256m', false).toString()}
|
||||
onValueChange={dispatcher('memory', 'MB')}
|
||||
buttonPosition="none"
|
||||
fill
|
||||
/>
|
||||
</td>
|
||||
{['input', 'output'].map((t) => (
|
||||
<td key={t}>
|
||||
<FileSelectAutoComplete
|
||||
width="100%"
|
||||
data={Files}
|
||||
selectedKeys={[testcase[t]]}
|
||||
onChange={dispatcher(t)}
|
||||
/>
|
||||
</td>
|
||||
))}
|
||||
<td className="col--operation">
|
||||
<a
|
||||
onClick={() => dispatch({
|
||||
type: 'CONFIG_SUBTASK_UPDATE', id: index, key: 'cases-delete', value: subindex,
|
||||
})}
|
||||
><span className="icon icon-close"></span>
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
|
||||
export function CasesTable({ index }) {
|
||||
const len = useSelector((state: RootState) => state.config.subtasks[index].cases?.length);
|
||||
const dispatch = useDispatch();
|
||||
return (
|
||||
<table className="data-table">
|
||||
<thead style={{ display: 'none' }}>
|
||||
<tr>
|
||||
<th>{i18n('Time')}</th>
|
||||
<th>{i18n('Memory')}</th>
|
||||
<th>{i18n('Input')}</th>
|
||||
<th>{i18n('Output')}</th>
|
||||
<th className="col--operation">
|
||||
<a
|
||||
onClick={() => dispatch({
|
||||
type: 'CONFIG_SUBTASK_UPDATE',
|
||||
id: index,
|
||||
key: 'cases-add',
|
||||
value: { input: '', output: '' },
|
||||
})}
|
||||
><span className="icon icon-add" />
|
||||
</a>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr className="thead">
|
||||
<td>{i18n('Time')}</td>
|
||||
<td>{i18n('Memory')}</td>
|
||||
<td>{i18n('Input')}</td>
|
||||
<td>{i18n('Output')}</td>
|
||||
<td className="col--operation">
|
||||
<a
|
||||
onClick={() => dispatch({
|
||||
type: 'CONFIG_SUBTASK_UPDATE',
|
||||
id: index,
|
||||
key: 'cases-add',
|
||||
value: { input: '', output: '' },
|
||||
})}
|
||||
><span className="icon icon-add" />
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
{len && [...Array(len).keys()].map((i) => <TestCaseEntry index={index} subindex={i} key={i} />)}
|
||||
</tbody>
|
||||
</table>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import yaml from 'js-yaml';
|
||||
import type { ProblemConfigFile } from 'hydrooj/src/interface';
|
||||
import { cloneDeep } from 'lodash';
|
||||
import { parseMemoryMB, parseTimeMS } from '@hydrooj/utils/lib/common';
|
||||
|
||||
type State = ProblemConfigFile & { __loaded: boolean };
|
||||
|
||||
export default function reducer(state = { type: 'default', __loaded: false } as State, action): State {
|
||||
switch (action.type) {
|
||||
case 'CONFIG_LOAD_FULFILLED': {
|
||||
return { ...state, ...yaml.load(action.payload.config) as object, __loaded: true };
|
||||
}
|
||||
case 'CONFIG_FORM_UPDATE': {
|
||||
const next = { ...state, [action.key]: action.value };
|
||||
if (action.key === 'score' && action.value) next.score = +next.score;
|
||||
if (action.key === 'checker_type' && action.value === 'other') next.checker_type = 'syzoj';
|
||||
if (!action.value || (typeof action.value === 'object' && !action.value.join(''))) delete next[action.key];
|
||||
return next;
|
||||
}
|
||||
case 'CONFIG_CODE_UPDATE': {
|
||||
try {
|
||||
return { ...state, ...yaml.load(action.payload) as object };
|
||||
} catch {
|
||||
return state;
|
||||
}
|
||||
}
|
||||
case 'CONFIG_AUTOCASES_UPDATE': {
|
||||
const next = { ...state };
|
||||
const { subtasks } = action;
|
||||
for (const subtask of subtasks) {
|
||||
if (subtask.time === parseTimeMS(state.time || '1s')) delete subtask.time;
|
||||
if (subtask.memory === parseMemoryMB(state.memory || '256m')) delete subtask.memory;
|
||||
if (subtask.time) subtask.time += 'ms';
|
||||
if (subtask.memory) subtask.memory += 'MB';
|
||||
}
|
||||
if (subtasks.length === 0) next.subtasks = [];
|
||||
else {
|
||||
next.subtasks = subtasks.map((subtask) => (
|
||||
{ ...subtask, ...{ cases: subtask.cases.map((i) => ({ input: i.input, output: i.output })) } }));
|
||||
}
|
||||
return next;
|
||||
}
|
||||
case 'CONFIG_SUBTASK_UPDATE': {
|
||||
const subtasks = cloneDeep(state.subtasks);
|
||||
const subsubtasks = cloneDeep(state.subtasks[action.id]);
|
||||
if (action.value !== '' && ['score', 'id'].includes(action.key)) action.value = +action.value;
|
||||
if (action.key === 'if' && action.value.join('') !== '') action.value = action.value.map((i) => +i);
|
||||
if (action.key.split('-')[0] === 'cases') {
|
||||
if (action.key === 'cases-add') subsubtasks.cases.push(action.value);
|
||||
else if (action.key === 'cases-edit') {
|
||||
if (action.value === '') delete subsubtasks.cases[action.casesId][action.casesKey];
|
||||
else subsubtasks.cases[action.casesId][action.casesKey] = action.value;
|
||||
} else if (action.key === 'cases-delete') {
|
||||
subsubtasks.cases = subsubtasks.cases.filter((k, v) => v !== action.value);
|
||||
}
|
||||
} else if (action.key === 'add') subtasks.push({ cases: [] });
|
||||
else if (action.key === 'delete') return { ...state, subtasks: subtasks.filter((k, v) => v !== action.id) };
|
||||
else {
|
||||
if (action.value === '' || (action.key === 'if' && action.value.join('') === '')) delete subsubtasks[action.key];
|
||||
else subsubtasks[action.key] = action.value;
|
||||
}
|
||||
subtasks[action.id] = subsubtasks;
|
||||
return { ...state, subtasks };
|
||||
}
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { combineReducers } from 'redux';
|
||||
import config from './config';
|
||||
import testdata from './testdata';
|
||||
|
||||
const reducer = combineReducers({
|
||||
config,
|
||||
testdata,
|
||||
});
|
||||
|
||||
export default reducer;
|
||||
export type RootState = ReturnType<typeof reducer>;
|
||||
@@ -0,0 +1,16 @@
|
||||
export default function reducer(state = [], action) {
|
||||
switch (action.type) {
|
||||
case 'CONFIG_LOAD_FULFILLED': {
|
||||
return state.concat(action.payload.testdata);
|
||||
}
|
||||
case 'CONFIG_DELETE_TESTDATA': {
|
||||
const next = state.filter((i) => i.name !== action.value[0]);
|
||||
return next;
|
||||
}
|
||||
case 'CONFIG_ADD_TESTDATA': {
|
||||
return state.concat([action.value]);
|
||||
}
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
}
|
||||
@@ -5,36 +5,36 @@ export default function reducer(state = {
|
||||
items: {},
|
||||
}, action) {
|
||||
switch (action.type) {
|
||||
case 'SCRATCHPAD_RECORDS_LOAD_SUBMISSIONS_FULFILLED': {
|
||||
const { rdocs } = action.payload;
|
||||
return {
|
||||
...state,
|
||||
rows: _.map(rdocs, '_id'),
|
||||
items: _.keyBy(rdocs, '_id'),
|
||||
};
|
||||
}
|
||||
case 'SCRATCHPAD_RECORDS_PUSH': {
|
||||
const { rdoc } = action.payload;
|
||||
const rows = [...state.rows];
|
||||
if (!rows.includes(rdoc._id)) {
|
||||
case 'SCRATCHPAD_RECORDS_LOAD_SUBMISSIONS_FULFILLED': {
|
||||
const { rdocs } = action.payload;
|
||||
return {
|
||||
...state,
|
||||
rows: _.map(rdocs, '_id'),
|
||||
items: _.keyBy(rdocs, '_id'),
|
||||
};
|
||||
}
|
||||
case 'SCRATCHPAD_RECORDS_PUSH': {
|
||||
const { rdoc } = action.payload;
|
||||
const rows = [...state.rows];
|
||||
if (!rows.includes(rdoc._id)) {
|
||||
return {
|
||||
...state,
|
||||
rows: [rdoc._id, ...state.rows],
|
||||
items: {
|
||||
...state.items,
|
||||
[rdoc._id]: rdoc,
|
||||
},
|
||||
};
|
||||
}
|
||||
return {
|
||||
...state,
|
||||
rows: [rdoc._id, ...state.rows],
|
||||
items: {
|
||||
...state.items,
|
||||
[rdoc._id]: rdoc,
|
||||
},
|
||||
};
|
||||
}
|
||||
return {
|
||||
...state,
|
||||
items: {
|
||||
...state.items,
|
||||
[rdoc._id]: rdoc,
|
||||
},
|
||||
};
|
||||
}
|
||||
default:
|
||||
return state;
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
}
|
||||
@@ -19,93 +19,93 @@ export default function reducer(state = {
|
||||
isPosting: false,
|
||||
}, action) {
|
||||
switch (action.type) {
|
||||
case 'SCRATCHPAD_UI_CHANGE_SIZE': {
|
||||
const { uiElement, size } = action.payload;
|
||||
return {
|
||||
...state,
|
||||
[uiElement]: {
|
||||
...state[uiElement],
|
||||
size,
|
||||
},
|
||||
};
|
||||
}
|
||||
case 'SCRATCHPAD_UI_SET_VISIBILITY': {
|
||||
const { uiElement, visibility } = action.payload;
|
||||
localStorage.setItem(`scratchpad/${uiElement}`, visibility.toString());
|
||||
return {
|
||||
...state,
|
||||
[uiElement]: {
|
||||
...state[uiElement],
|
||||
visible: visibility,
|
||||
},
|
||||
};
|
||||
}
|
||||
case 'SCRATCHPAD_UI_TOGGLE_VISIBILITY': {
|
||||
const { uiElement } = action.payload;
|
||||
localStorage.setItem(`scratchpad/${uiElement}`, (!state[uiElement].visible).toString());
|
||||
return {
|
||||
...state,
|
||||
[uiElement]: {
|
||||
...state[uiElement],
|
||||
visible: !state[uiElement].visible,
|
||||
},
|
||||
};
|
||||
}
|
||||
case 'SCRATCHPAD_POST_PRETEST_PENDING':
|
||||
case 'SCRATCHPAD_POST_SUBMIT_PENDING': {
|
||||
return {
|
||||
...state,
|
||||
isPosting: true,
|
||||
};
|
||||
}
|
||||
case 'SCRATCHPAD_POST_PRETEST_FULFILLED':
|
||||
case 'SCRATCHPAD_POST_SUBMIT_FULFILLED': {
|
||||
Notification.success(i18n('Submitted.'));
|
||||
if (action.type === 'SCRATCHPAD_POST_SUBMIT_FULFILLED' && UiContext.canViewRecord) {
|
||||
state.records.visible = true;
|
||||
case 'SCRATCHPAD_UI_CHANGE_SIZE': {
|
||||
const { uiElement, size } = action.payload;
|
||||
return {
|
||||
...state,
|
||||
[uiElement]: {
|
||||
...state[uiElement],
|
||||
size,
|
||||
},
|
||||
};
|
||||
}
|
||||
return {
|
||||
...state,
|
||||
isPosting: false,
|
||||
};
|
||||
}
|
||||
case 'SCRATCHPAD_POST_PRETEST_REJECTED':
|
||||
case 'SCRATCHPAD_POST_SUBMIT_REJECTED': {
|
||||
Notification.error(action.payload.message);
|
||||
return {
|
||||
...state,
|
||||
isPosting: false,
|
||||
};
|
||||
}
|
||||
case 'SCRATCHPAD_RECORDS_LOAD_SUBMISSIONS_PENDING': {
|
||||
return {
|
||||
...state,
|
||||
records: {
|
||||
...state.records,
|
||||
isLoading: true,
|
||||
},
|
||||
};
|
||||
}
|
||||
case 'SCRATCHPAD_RECORDS_LOAD_SUBMISSIONS_REJECTED': {
|
||||
Notification.error(action.payload.message);
|
||||
return {
|
||||
...state,
|
||||
records: {
|
||||
...state.records,
|
||||
isLoading: false,
|
||||
},
|
||||
};
|
||||
}
|
||||
case 'SCRATCHPAD_RECORDS_LOAD_SUBMISSIONS_FULFILLED': {
|
||||
return {
|
||||
...state,
|
||||
records: {
|
||||
...state.records,
|
||||
isLoading: false,
|
||||
},
|
||||
};
|
||||
}
|
||||
default:
|
||||
return state;
|
||||
case 'SCRATCHPAD_UI_SET_VISIBILITY': {
|
||||
const { uiElement, visibility } = action.payload;
|
||||
localStorage.setItem(`scratchpad/${uiElement}`, visibility.toString());
|
||||
return {
|
||||
...state,
|
||||
[uiElement]: {
|
||||
...state[uiElement],
|
||||
visible: visibility,
|
||||
},
|
||||
};
|
||||
}
|
||||
case 'SCRATCHPAD_UI_TOGGLE_VISIBILITY': {
|
||||
const { uiElement } = action.payload;
|
||||
localStorage.setItem(`scratchpad/${uiElement}`, (!state[uiElement].visible).toString());
|
||||
return {
|
||||
...state,
|
||||
[uiElement]: {
|
||||
...state[uiElement],
|
||||
visible: !state[uiElement].visible,
|
||||
},
|
||||
};
|
||||
}
|
||||
case 'SCRATCHPAD_POST_PRETEST_PENDING':
|
||||
case 'SCRATCHPAD_POST_SUBMIT_PENDING': {
|
||||
return {
|
||||
...state,
|
||||
isPosting: true,
|
||||
};
|
||||
}
|
||||
case 'SCRATCHPAD_POST_PRETEST_FULFILLED':
|
||||
case 'SCRATCHPAD_POST_SUBMIT_FULFILLED': {
|
||||
Notification.success(i18n('Submitted.'));
|
||||
if (action.type === 'SCRATCHPAD_POST_SUBMIT_FULFILLED' && UiContext.canViewRecord) {
|
||||
state.records.visible = true;
|
||||
}
|
||||
return {
|
||||
...state,
|
||||
isPosting: false,
|
||||
};
|
||||
}
|
||||
case 'SCRATCHPAD_POST_PRETEST_REJECTED':
|
||||
case 'SCRATCHPAD_POST_SUBMIT_REJECTED': {
|
||||
Notification.error(action.payload.message);
|
||||
return {
|
||||
...state,
|
||||
isPosting: false,
|
||||
};
|
||||
}
|
||||
case 'SCRATCHPAD_RECORDS_LOAD_SUBMISSIONS_PENDING': {
|
||||
return {
|
||||
...state,
|
||||
records: {
|
||||
...state.records,
|
||||
isLoading: true,
|
||||
},
|
||||
};
|
||||
}
|
||||
case 'SCRATCHPAD_RECORDS_LOAD_SUBMISSIONS_REJECTED': {
|
||||
Notification.error(action.payload.message);
|
||||
return {
|
||||
...state,
|
||||
records: {
|
||||
...state.records,
|
||||
isLoading: false,
|
||||
},
|
||||
};
|
||||
}
|
||||
case 'SCRATCHPAD_RECORDS_LOAD_SUBMISSIONS_FULFILLED': {
|
||||
return {
|
||||
...state,
|
||||
records: {
|
||||
...state.records,
|
||||
isLoading: false,
|
||||
},
|
||||
};
|
||||
}
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
}
|
||||
@@ -86,6 +86,7 @@ Attend Contest: 参加比赛
|
||||
Attend contests: 参加比赛
|
||||
Attended: 已参加
|
||||
author: 作者
|
||||
Auto Read Tasks: 自动识别任务
|
||||
Be Copied: 被复制
|
||||
Begin at: 开始于
|
||||
Begin Date: 开始日期
|
||||
@@ -117,6 +118,8 @@ Change Email: 修改电子邮件
|
||||
Change Password: 修改密码
|
||||
Change Theme: 切换主题
|
||||
Check In: 签到
|
||||
Checker: 比较器
|
||||
CheckerType: 比较器类型
|
||||
Chinese: 中文
|
||||
Choose the background image in your profile page.: 选择您资料页面的背景图片。
|
||||
Claim homework: 认领作业
|
||||
@@ -138,6 +141,7 @@ Compile Error: 编译错误
|
||||
Compilers' Version and Parameters: 编译器版本及参数
|
||||
Complete: 完成
|
||||
Completed: 已完成
|
||||
Config: 配置
|
||||
Confirm deleting the selected roles?: 您确定删除所选角色吗?
|
||||
Confirm deleting this comment? Its replies will be deleted as well.: 确认删除这个评论吗?回复会被同时删除。
|
||||
Confirm deleting this reply?: 确认删除这个回复吗?
|
||||
@@ -268,6 +272,7 @@ domain: 域
|
||||
Domain: 域
|
||||
Don't have an account?: 还没有账户?
|
||||
Done: 已结束
|
||||
Download All: 下载全部
|
||||
Download Dataset: 下载数据集
|
||||
Download Selected: 下载选中
|
||||
Download: 下载
|
||||
@@ -318,7 +323,10 @@ Export as CSV: 导出为 CSV
|
||||
Export as HTML: 导出为 HTML
|
||||
Extension (days): 最长延期 (日)
|
||||
Extension Score Penalty: 延期递交扣分规则
|
||||
Extra Files Config: 额外文件设置
|
||||
Failed to join the domain. You are already a member.: 加入域失败,您已是该域的成员。
|
||||
Failed to parse subtask.: 无法解析子任务信息。
|
||||
Failed to parse testcase.: 无法解析测试点信息。
|
||||
Feedback: 反馈
|
||||
Field {0} or {1} validation failed.: 字段 {0} 或 {1} 验证失败。
|
||||
Field {0} validation failed.: 字段 {0} 验证失败。
|
||||
@@ -382,6 +390,7 @@ Host: 主持人
|
||||
hour(s): 小时
|
||||
If enabled, source code will be emailed to you after the submission is accepted.: 如果启用,在您通过题目后,源代码会以 Email 的形式发送给您。
|
||||
If left blank, the built-in template of the corresponding language will be used.: 若留空,则将使用对应语言的内置代码模板。
|
||||
Ignore trailing space and enter.: 忽略行末空格与文件尾回车。
|
||||
Images With Size: 指定大小的图片
|
||||
Images: 图片
|
||||
Import from: 导入自
|
||||
@@ -400,6 +409,7 @@ Information: 信息
|
||||
Initialize: 初始化
|
||||
Inline codes: 行内代码
|
||||
Inline formulas: 行内公式
|
||||
Input: 输入
|
||||
Insert Code: 插入代码
|
||||
Insert Image: 插入图像
|
||||
Installed Modules: 已安装的模块
|
||||
@@ -414,12 +424,16 @@ Is rated: 计入排名
|
||||
It works!: 它成了!
|
||||
Italic: 斜体
|
||||
Join: 加入
|
||||
Judge Config: 评测设置
|
||||
Judge Status: 评测状态
|
||||
judge_extra_files: 评测额外文件
|
||||
judge_playground: 评测游乐场
|
||||
Judged At: 评测时间
|
||||
Judged By: 评测机
|
||||
Judging Queue: 评测队列
|
||||
Keep current expiration: 保持当前过期设置
|
||||
Langs Config: 语言设置
|
||||
langs: 语言
|
||||
Language: 语言
|
||||
last active at: 最后活动于
|
||||
last login at: 最后登录于
|
||||
@@ -531,6 +545,7 @@ Or, with automatically filled invitation code: 或者,这是可以自动填写
|
||||
Ordered List: 有序列表
|
||||
Original Score: 原始分数
|
||||
OS Info: 系统信息
|
||||
Output: 输出
|
||||
Over {0} problems: 超过 {0} 道题目
|
||||
Owner: 所有者
|
||||
page.problem_detail.sidebar.show_category: 点击显示
|
||||
@@ -582,6 +597,8 @@ Problem Solution List: 题解列表
|
||||
Problem Solution: 题解列表
|
||||
Problem Tags Visibility: 题目标签可见性
|
||||
Problem Tags: 题目标签
|
||||
Problem Type: 题目类型
|
||||
problem_config: 评测设置
|
||||
problem_create: 创建题目
|
||||
problem_edit: 编辑题目
|
||||
problem_import: 导入题目
|
||||
@@ -755,6 +772,7 @@ SuperUser: 超级管理员
|
||||
Support: 支持
|
||||
Switch account: 切换账户
|
||||
Tags: 标签
|
||||
Task Settings: 任务设置
|
||||
tasks_list: 任务列表
|
||||
Technical Information: 技术信息
|
||||
Temporary session: 临时会话
|
||||
@@ -811,9 +829,11 @@ Uncompleted: 未完成
|
||||
Unique: 唯一
|
||||
Unknown field {0}.: 未知字段 {0}。
|
||||
Unknown: 未知
|
||||
Unlimited: 无限制
|
||||
Unordered List: 无序列表
|
||||
Unstar Topic: 取消收藏
|
||||
Unstar: 取消收藏
|
||||
Unsupported configure this type of problem. Please refer to the documentation.: 暂不支持快捷配置此类题目。请参照文档操作。
|
||||
Update Permission: 更新权限
|
||||
Update Settings: 更新设置
|
||||
Update: 更新
|
||||
@@ -830,12 +850,14 @@ Usage exceeded.: 用量超限。
|
||||
Use admin specificed: 由管理员指定
|
||||
Use algorithm calculated: 使用算法计算
|
||||
Use average of above: 使用上面的平均值
|
||||
Use Subtasks Mode: 使用子任务模式
|
||||
User {0} already exists.: 用户 {0} 已存在。
|
||||
User {0} not found.: 用户 {0} 不存在。
|
||||
User can join this domain by visiting the following URL: 用户可以访问此链接来加入此域
|
||||
User ID: 用户 ID
|
||||
User will no longer be allowed to join the domain after expiration.: 过期后,用户将不能加入此域。
|
||||
user_detail: 用户
|
||||
user_extra_files: 编译额外文件
|
||||
user_login: 登录
|
||||
user_logout: 登出
|
||||
user_lostpass: 忘记密码
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@hydrooj/ui-default",
|
||||
"version": "4.35.6",
|
||||
"version": "4.36.0",
|
||||
"author": "undefined <i@undefined.moe>",
|
||||
"license": "AGPL-3.0",
|
||||
"main": "hydro.js",
|
||||
@@ -10,7 +10,7 @@
|
||||
"lint": "eslint ."
|
||||
},
|
||||
"devDependencies": {
|
||||
"@blueprintjs/core": "^3.54.0",
|
||||
"@blueprintjs/core": "^4.2.1",
|
||||
"@fontsource/dm-mono": "^4.5.7",
|
||||
"@fontsource/fira-code": "^4.5.8",
|
||||
"@fontsource/inconsolata": "^4.5.4",
|
||||
@@ -37,6 +37,7 @@
|
||||
"clipboard": "^2.0.10",
|
||||
"copy-webpack-plugin": "^6.4.1",
|
||||
"css-loader": "^4.3.0",
|
||||
"diff": "^5.0.0",
|
||||
"diff-dom": "^4.2.3",
|
||||
"echarts": "^5.3.2",
|
||||
"emojis-keywords": "2.0.0",
|
||||
|
||||
@@ -40,12 +40,12 @@ async function handleClickUpload(files) {
|
||||
const dialog = new Dialog({
|
||||
$body: `
|
||||
<div class="file-label" style="text-align: center; margin-bottom: 5px; color: gray; font-size: small;"></div>
|
||||
<div class="bp3-progress-bar bp3-intent-primary bp3-no-stripes">
|
||||
<div class="file-progress bp3-progress-meter" style="width: 0"></div>
|
||||
<div class="bp4-progress-bar bp4-intent-primary bp4-no-stripes">
|
||||
<div class="file-progress bp4-progress-meter" style="width: 0"></div>
|
||||
</div>
|
||||
<div class="upload-label" style="text-align: center; margin: 5px 0; color: gray; font-size: small;"></div>
|
||||
<div class="bp3-progress-bar bp3-intent-primary bp3-no-stripes">
|
||||
<div class="upload-progress bp3-progress-meter" style="width: 0"></div>
|
||||
<div class="bp4-progress-bar bp4-intent-primary bp4-no-stripes">
|
||||
<div class="upload-progress bp4-progress-meter" style="width: 0"></div>
|
||||
</div>`,
|
||||
});
|
||||
try {
|
||||
|
||||
@@ -19,16 +19,16 @@ const page = new NamedPage('homework_main', () => {
|
||||
calendar.getDom().appendTo('[name="calendar_entry"]');
|
||||
$('[name="homework_display"]').change((ev) => {
|
||||
switch (ev.currentTarget.value) {
|
||||
case 'calendar':
|
||||
$('.homework__list').hide();
|
||||
$('[name="calendar_entry"]').show();
|
||||
break;
|
||||
case 'list':
|
||||
$('.homework__list').show();
|
||||
$('[name="calendar_entry"]').hide();
|
||||
break;
|
||||
default:
|
||||
throw new Error('Unexpected display parameter');
|
||||
case 'calendar':
|
||||
$('.homework__list').hide();
|
||||
$('[name="calendar_entry"]').show();
|
||||
break;
|
||||
case 'list':
|
||||
$('.homework__list').show();
|
||||
$('[name="calendar_entry"]').hide();
|
||||
break;
|
||||
default:
|
||||
throw new Error('Unexpected display parameter');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
.page--problem_config
|
||||
.section--problem-sidebar-testdata
|
||||
.problem-sidebar-testdata__detail
|
||||
max-height: 270px
|
||||
overflow-y: auto
|
||||
|
||||
.col--size
|
||||
width: 80px
|
||||
.col--operation
|
||||
width: 40px
|
||||
text-align: center
|
||||
|
||||
.textbox,.select
|
||||
margin: 0
|
||||
|
||||
.bp4-tab-panel
|
||||
margin-top: 5px
|
||||
|
||||
.problem-config-form
|
||||
font-family: var(--code-font-family)
|
||||
|
||||
input::-webkit-outer-spin-button, input::-webkit-inner-spin-button
|
||||
-webkit-appearance: none !important
|
||||
input[type="number"]
|
||||
-moz-appearance: textfield
|
||||
|
||||
.bp4-tag.bp4-minimal
|
||||
background: transparent
|
||||
.bp4-input-action
|
||||
pointer-events: none
|
||||
.bp4-input-group
|
||||
input
|
||||
appearance: none
|
||||
display: block
|
||||
width: 100%
|
||||
font-size: 0.875rem
|
||||
height: 2.1875rem
|
||||
line-height: 1.2
|
||||
padding: 0.3125rem;
|
||||
border: 1px solid #ddd
|
||||
color: inherit
|
||||
border-radius: 0
|
||||
background-color: transparent
|
||||
outline: 2px solid transparent
|
||||
transition: outline-color 0.2s, border-color 0.2s
|
||||
transition-timing-function: cubic-bezier(0.215, 0.61, 0.355, 1)
|
||||
box-shadow: none
|
||||
|
||||
|
||||
.data-table
|
||||
tr
|
||||
border-top: 0
|
||||
border-bottom: 0
|
||||
height: auto
|
||||
td
|
||||
padding: 0
|
||||
.thead td
|
||||
text-align: center
|
||||
td input, td div
|
||||
background: transparent
|
||||
|
||||
.hover-row.status-bar
|
||||
display: none
|
||||
|
||||
input::placeholder
|
||||
color: rgba(95,107,124,.6)!important
|
||||
@@ -0,0 +1,205 @@
|
||||
import { NamedPage } from 'vj/misc/Page';
|
||||
import request from 'vj/utils/request';
|
||||
import loadReactRedux from 'vj/utils/loadReactRedux';
|
||||
import i18n from 'vj/utils/i18n';
|
||||
import yaml from 'js-yaml';
|
||||
import Notification from 'vj/components/notification';
|
||||
import Dialog, { ConfirmDialog } from 'vj/components/dialog/index';
|
||||
import download from 'vj/components/zipDownloader';
|
||||
import { size, readSubtasksFromFiles, normalizeSubtasks } from '@hydrooj/utils/lib/common';
|
||||
import tpl from 'vj/utils/tpl';
|
||||
import { SubtaskType } from 'hydrooj/src/interface';
|
||||
import { configYamlFormat } from 'vj/components/problemconfig/ProblemConfigEditor';
|
||||
|
||||
function onBeforeUnload(e) {
|
||||
e.returnValue = '';
|
||||
}
|
||||
|
||||
const page = new NamedPage('problem_config', () => {
|
||||
let reduxStore;
|
||||
|
||||
async function handleClickUpload() {
|
||||
const input = document.createElement('input');
|
||||
input.type = 'file';
|
||||
input.multiple = true;
|
||||
input.click();
|
||||
await new Promise((resolve) => { input.onchange = resolve; });
|
||||
const { files } = input;
|
||||
const dialog = new Dialog({
|
||||
$body: `
|
||||
<div class="file-label" style="text-align: center; margin-bottom: 5px; color: gray; font-size: small;"></div>
|
||||
<div class="bp4-progress-bar bp4-intent-primary bp4-no-stripes">
|
||||
<div class="file-progress bp4-progress-meter" style="width: 0"></div>
|
||||
</div>
|
||||
<div class="upload-label" style="text-align: center; margin: 5px 0; color: gray; font-size: small;"></div>
|
||||
<div class="bp4-progress-bar bp4-intent-primary bp4-no-stripes">
|
||||
<div class="upload-progress bp4-progress-meter" style="width: 0"></div>
|
||||
</div>`,
|
||||
});
|
||||
try {
|
||||
Notification.info(i18n('Uploading files...'));
|
||||
window.addEventListener('beforeunload', onBeforeUnload);
|
||||
dialog.open();
|
||||
const $uploadLabel = dialog.$dom.find('.dialog__body .upload-label');
|
||||
const $uploadProgress = dialog.$dom.find('.dialog__body .upload-progress');
|
||||
const $fileLabel = dialog.$dom.find('.dialog__body .file-label');
|
||||
const $fileProgress = dialog.$dom.find('.dialog__body .file-progress');
|
||||
for (const i in files) {
|
||||
if (Number.isNaN(+i)) continue;
|
||||
const file = files[i];
|
||||
const data = new FormData();
|
||||
data.append('filename', file.name);
|
||||
data.append('file', file);
|
||||
data.append('type', 'testdata');
|
||||
data.append('operation', 'upload_file');
|
||||
await request.postFile('./files', data, {
|
||||
xhr() {
|
||||
const xhr = new XMLHttpRequest();
|
||||
xhr.upload.addEventListener('loadstart', () => {
|
||||
$fileLabel.text(`[${+i + 1}/${files.length}] ${file.name}`);
|
||||
$fileProgress.width(`${Math.round((+i + 1) / files.length * 100)}%`);
|
||||
$uploadLabel.text(i18n('Uploading... ({0}%)', 0));
|
||||
$uploadProgress.width(0);
|
||||
});
|
||||
xhr.upload.addEventListener('progress', (e) => {
|
||||
if (e.lengthComputable) {
|
||||
const percentComplete = Math.round((e.loaded / e.total) * 100);
|
||||
if (percentComplete === 100) $uploadLabel.text(i18n('Processing...'));
|
||||
else $uploadLabel.text(i18n('Uploading... ({0}%)', percentComplete));
|
||||
$uploadProgress.width(`${percentComplete}%`);
|
||||
}
|
||||
}, false);
|
||||
return xhr;
|
||||
},
|
||||
});
|
||||
reduxStore.dispatch({
|
||||
type: 'CONFIG_ADD_TESTDATA',
|
||||
value: {
|
||||
_id: file.name,
|
||||
name: file.name,
|
||||
size: file.size,
|
||||
},
|
||||
});
|
||||
$('.testdata-table tbody').append(
|
||||
$(tpl`<tr data-filename="${file.name}" data-size="${file.size.toString()}">
|
||||
<td class="col--name" title="${file.name}"><a href="./file/${file.name}?type=testdata">${file.name}</a></td>
|
||||
<td class="col--size">${size(file.size)}</td>
|
||||
<td class="col--operation"><a href="javascript:;" name="testdata__delete"><span class="icon icon-delete"></span></a></td>
|
||||
</tr>`),
|
||||
);
|
||||
}
|
||||
window.removeEventListener('beforeunload', onBeforeUnload);
|
||||
Notification.success(i18n('File uploaded successfully.'));
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
Notification.error(i18n('File upload failed: {0}', e.toString()));
|
||||
} finally {
|
||||
dialog.close();
|
||||
}
|
||||
}
|
||||
|
||||
async function handleClickRemove(ev: JQuery.ClickEvent<Document, undefined, any, any>) {
|
||||
const file = [$(ev.currentTarget).parent().parent().attr('data-filename')];
|
||||
const action = await new ConfirmDialog({
|
||||
$body: tpl.typoMsg(i18n('Confirm to delete the file?')),
|
||||
}).open();
|
||||
if (action !== 'yes') return;
|
||||
try {
|
||||
await request.post('./files', {
|
||||
operation: 'delete_files',
|
||||
files: file,
|
||||
type: 'testdata',
|
||||
});
|
||||
Notification.success(i18n('File have been deleted.'));
|
||||
reduxStore.dispatch({
|
||||
type: 'CONFIG_DELETE_TESTDATA',
|
||||
value: file,
|
||||
});
|
||||
$(ev.currentTarget).parent().parent().remove();
|
||||
} catch (error) {
|
||||
Notification.error(error.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleClickDownloadAll() {
|
||||
const files = reduxStore.getState().testdata.map((i) => i.name);
|
||||
const { links, pdoc } = await request.post('./files', { operation: 'get_links', files, type: 'testdata' });
|
||||
const targets = [];
|
||||
for (const filename of Object.keys(links)) targets.push({ filename, url: links[filename] });
|
||||
await download(`${pdoc.docId} ${pdoc.title}.zip`, targets);
|
||||
}
|
||||
|
||||
async function uploadConfig(config: object) {
|
||||
const configYaml = yaml.dump(configYamlFormat(config));
|
||||
Notification.info(i18n('Saving file...'));
|
||||
const data = new FormData();
|
||||
data.append('filename', 'config.yaml');
|
||||
data.append('file', new Blob([configYaml], { type: 'text/plain' }));
|
||||
data.append('type', 'testdata');
|
||||
data.append('operation', 'upload_file');
|
||||
await request.postFile('./files', data);
|
||||
Notification.success(i18n('File saved.'));
|
||||
window.location.reload();
|
||||
}
|
||||
|
||||
async function mountComponent() {
|
||||
const [{ default: ProblemConfigEditor }, { default: ProblemConfigForm }, { default: ProblemConfigReducer }] = await Promise.all([
|
||||
import('vj/components/problemconfig/ProblemConfigEditor'),
|
||||
import('vj/components/problemconfig/ProblemConfigForm'),
|
||||
import('vj/components/problemconfig/reducer'),
|
||||
]);
|
||||
|
||||
const {
|
||||
React, render, Provider, store,
|
||||
} = await loadReactRedux(ProblemConfigReducer);
|
||||
|
||||
reduxStore = store;
|
||||
|
||||
store.dispatch({
|
||||
type: 'CONFIG_LOAD',
|
||||
payload: request.get(),
|
||||
});
|
||||
const unsubscribe = store.subscribe(() => {
|
||||
// TODO set yaml schema
|
||||
const state = store.getState();
|
||||
if (!state.config.__loaded) return;
|
||||
if (state.config.cases) {
|
||||
const score = state.config.score * state.config.cases.length;
|
||||
state.config.subtasks = [{ type: 'sum' as SubtaskType, score: score && score < 100 ? score : 100, cases: state.config.cases }];
|
||||
delete state.config.cases;
|
||||
delete state.config.score;
|
||||
}
|
||||
if (state.config.subtasks) return;
|
||||
const testdata = (state.testdata || []).map((i) => i.name);
|
||||
const checkFile = (file: string) => (testdata.includes(file) ? file : null);
|
||||
unsubscribe();
|
||||
const subtasks = readSubtasksFromFiles(testdata, checkFile, state.config);
|
||||
store.dispatch({
|
||||
type: 'CONFIG_AUTOCASES_UPDATE',
|
||||
subtasks: normalizeSubtasks(subtasks, (i) => i, state.config.time, state.config.memory, true),
|
||||
});
|
||||
});
|
||||
render(
|
||||
<Provider store={store}>
|
||||
<div className="row">
|
||||
<div className="medium-5 columns">
|
||||
<ProblemConfigEditor />
|
||||
</div>
|
||||
<div className="medium-7 columns">
|
||||
<ProblemConfigForm />
|
||||
</div>
|
||||
</div>
|
||||
<button className="rounded primary button" onClick={() => uploadConfig(store.getState().config)}>{i18n('Submit')}</button>
|
||||
</Provider>,
|
||||
$('#ProblemConfig').get(0),
|
||||
);
|
||||
}
|
||||
|
||||
mountComponent();
|
||||
|
||||
$(document).on('click', '[name="testdata__upload"]', () => handleClickUpload());
|
||||
$(document).on('click', '[name="testdata__delete"]', (ev) => handleClickRemove(ev));
|
||||
$(document).on('click', '[name="testdata__download__all"]', () => handleClickDownloadAll());
|
||||
});
|
||||
|
||||
export default page;
|
||||
@@ -3,10 +3,13 @@ import { NamedPage } from 'vj/misc/Page';
|
||||
import request from 'vj/utils/request';
|
||||
import tpl from 'vj/utils/tpl';
|
||||
import i18n from 'vj/utils/i18n';
|
||||
import { ConfirmDialog } from 'vj/components/dialog';
|
||||
import Dialog, { ConfirmDialog } from 'vj/components/dialog';
|
||||
import Dropdown from 'vj/components/dropdown/Dropdown';
|
||||
import Editor from 'vj/components/editor/index';
|
||||
import Notification from 'vj/components/notification';
|
||||
import { slideDown, slideUp } from 'vj/utils/slide';
|
||||
import download from 'vj/components/zipDownloader';
|
||||
import { size } from '@hydrooj/utils/lib/common';
|
||||
|
||||
const categories = {};
|
||||
const dirtyCategories = [];
|
||||
@@ -18,6 +21,10 @@ function setDomSelected($dom, selected) {
|
||||
else $dom.removeClass('selected');
|
||||
}
|
||||
|
||||
function onBeforeUnload(e) {
|
||||
e.returnValue = '';
|
||||
}
|
||||
|
||||
async function updateSelection() {
|
||||
dirtyCategories.forEach(({ type, category, subcategory }) => {
|
||||
let item = categories[category];
|
||||
@@ -143,6 +150,21 @@ function buildCategoryFilter() {
|
||||
});
|
||||
}
|
||||
|
||||
async function handleSection(ev, sidebar, type) {
|
||||
const $section = $(ev.currentTarget).closest(`.section--problem-sidebar-${sidebar}`);
|
||||
if ($section.is(`.${type}d, .animating`)) return;
|
||||
$section.addClass('animating');
|
||||
const $detail = $section.find(`.section--problem-sidebar-${sidebar}__detail`);
|
||||
if (type === 'expand') {
|
||||
await slideDown($detail, 300, { opacity: 0 }, { opacity: 1 });
|
||||
} else {
|
||||
await slideUp($detail, 300, { opacity: 1 }, { opacity: 0 });
|
||||
}
|
||||
$section.addClass(type === 'expand' ? 'expanded' : 'collapsed');
|
||||
$section.removeClass(type === 'expand' ? 'collapsed' : 'expanded');
|
||||
$section.removeClass('animating');
|
||||
}
|
||||
|
||||
export default new NamedPage(['problem_create', 'problem_edit'], (pagename) => {
|
||||
let confirmed = false;
|
||||
$(document).on('click', '[name="operation"]', (ev) => {
|
||||
@@ -170,6 +192,105 @@ export default new NamedPage(['problem_create', 'problem_edit'], (pagename) => {
|
||||
buildCategoryFilter();
|
||||
parseCategorySelection();
|
||||
|
||||
async function handleClickUpload() {
|
||||
const input = document.createElement('input');
|
||||
input.type = 'file';
|
||||
input.multiple = true;
|
||||
input.click();
|
||||
await new Promise((resolve) => { input.onchange = resolve; });
|
||||
const { files } = input;
|
||||
const dialog = new Dialog({
|
||||
$body: `
|
||||
<div class="file-label" style="text-align: center; margin-bottom: 5px; color: gray; font-size: small;"></div>
|
||||
<div class="bp4-progress-bar bp4-intent-primary bp4-no-stripes">
|
||||
<div class="file-progress bp4-progress-meter" style="width: 0"></div>
|
||||
</div>
|
||||
<div class="upload-label" style="text-align: center; margin: 5px 0; color: gray; font-size: small;"></div>
|
||||
<div class="bp4-progress-bar bp4-intent-primary bp4-no-stripes">
|
||||
<div class="upload-progress bp4-progress-meter" style="width: 0"></div>
|
||||
</div>`,
|
||||
});
|
||||
try {
|
||||
Notification.info(i18n('Uploading files...'));
|
||||
window.addEventListener('beforeunload', onBeforeUnload);
|
||||
dialog.open();
|
||||
const $uploadLabel = dialog.$dom.find('.dialog__body .upload-label');
|
||||
const $uploadProgress = dialog.$dom.find('.dialog__body .upload-progress');
|
||||
const $fileLabel = dialog.$dom.find('.dialog__body .file-label');
|
||||
const $fileProgress = dialog.$dom.find('.dialog__body .file-progress');
|
||||
for (const i in files) {
|
||||
if (Number.isNaN(+i)) continue;
|
||||
const file = files[i];
|
||||
const data = new FormData();
|
||||
data.append('filename', file.name);
|
||||
data.append('file', file);
|
||||
data.append('type', 'additional_file');
|
||||
data.append('operation', 'upload_file');
|
||||
await request.postFile('./files', data, {
|
||||
xhr() {
|
||||
const xhr = new XMLHttpRequest();
|
||||
xhr.upload.addEventListener('loadstart', () => {
|
||||
$fileLabel.text(`[${+i + 1}/${files.length}] ${file.name}`);
|
||||
$fileProgress.width(`${Math.round((+i + 1) / files.length * 100)}%`);
|
||||
$uploadLabel.text(i18n('Uploading... ({0}%)', 0));
|
||||
$uploadProgress.width(0);
|
||||
});
|
||||
xhr.upload.addEventListener('progress', (e) => {
|
||||
if (e.lengthComputable) {
|
||||
const percentComplete = Math.round((e.loaded / e.total) * 100);
|
||||
if (percentComplete === 100) $uploadLabel.text(i18n('Processing...'));
|
||||
else $uploadLabel.text(i18n('Uploading... ({0}%)', percentComplete));
|
||||
$uploadProgress.width(`${percentComplete}%`);
|
||||
}
|
||||
}, false);
|
||||
return xhr;
|
||||
},
|
||||
});
|
||||
$('.additionalfile-table tbody').append(
|
||||
$(tpl`<tr data-filename="${file.name}" data-size="${file.size.toString()}">
|
||||
<td class="col--name" title="${file.name}"><a href="./file/${file.name}?type=testdata">${file.name}</a></td>
|
||||
<td class="col--size">${size(file.size)}</td>
|
||||
<td class="col--operation"><a href="javascript:;" name="testdata__delete"><span class="icon icon-delete"></span></a></td>
|
||||
</tr>`),
|
||||
);
|
||||
}
|
||||
window.removeEventListener('beforeunload', onBeforeUnload);
|
||||
Notification.success(i18n('File uploaded successfully.'));
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
Notification.error(i18n('File upload failed: {0}', e.toString()));
|
||||
} finally {
|
||||
dialog.close();
|
||||
}
|
||||
}
|
||||
|
||||
async function handleClickRemove(ev) {
|
||||
const file = [$(ev.currentTarget).parent().parent().attr('data-filename')];
|
||||
const action = await new ConfirmDialog({
|
||||
$body: tpl.typoMsg(i18n('Confirm to delete the file?')),
|
||||
}).open();
|
||||
if (action !== 'yes') return;
|
||||
try {
|
||||
await request.post('./files', {
|
||||
operation: 'delete_files',
|
||||
files: file,
|
||||
type: 'testdata',
|
||||
});
|
||||
Notification.success(i18n('File have been deleted.'));
|
||||
$(ev.currentTarget).parent().parent().remove();
|
||||
} catch (error) {
|
||||
Notification.error(error.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleClickDownloadAll() {
|
||||
const files = $('.additionalfile-table tr').map(function () { return $(this).attr('data-filename'); }).get();
|
||||
const { links, pdoc } = await request.post('./files', { operation: 'get_links', files, type: 'additional_file' });
|
||||
const targets = [];
|
||||
for (const filename of Object.keys(links)) targets.push({ filename, url: links[filename] });
|
||||
await download(`${pdoc.docId} ${pdoc.title}.zip`, targets);
|
||||
}
|
||||
|
||||
setInterval(() => {
|
||||
$('img').each(function () {
|
||||
if (this.src.startsWith('file://')) {
|
||||
@@ -232,4 +353,11 @@ export default new NamedPage(['problem_create', 'problem_edit'], (pagename) => {
|
||||
ev.preventDefault();
|
||||
}
|
||||
});
|
||||
$(document).on('click', '[name="additional_file__upload"]', () => handleClickUpload());
|
||||
$(document).on('click', '[name="additional_file__delete"]', (ev) => handleClickRemove(ev));
|
||||
$(document).on('click', '[name="additional_file__download"]', () => handleClickDownloadAll());
|
||||
$(document).on('click', '[name="additional_file__section__expand"]', (ev) => handleSection(ev, 'additional_file', 'expand'));
|
||||
$(document).on('click', '[name="additional_file__section__collapse"]', (ev) => handleSection(ev, 'additional_file', 'collapse'));
|
||||
$(document).on('click', '[name="tags__section__expand"]', (ev) => handleSection(ev, 'tags', 'expand'));
|
||||
$(document).on('click', '[name="tags__section__collapse"]', (ev) => handleSection(ev, 'tags', 'collapse'));
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
.page--problem_edit
|
||||
.page--problem_edit,.page--problem_create
|
||||
.section__tab-header-item
|
||||
border-top: 3px solid transparent;
|
||||
transition: border-color 0.2s, background 0.2s, color 0.2s;
|
||||
@@ -10,4 +10,14 @@
|
||||
background: #f8f8f8;
|
||||
|
||||
&.tab--active
|
||||
border-color: #ed5f82;
|
||||
border-color: #ed5f82;
|
||||
|
||||
.section--problem-sidebar-additional_file
|
||||
.problem-sidebar-additional_file__detail
|
||||
height: 270px
|
||||
overflow-y: auto
|
||||
|
||||
.col--size
|
||||
width: 80px
|
||||
.col--operation
|
||||
width: 40px
|
||||
@@ -51,12 +51,12 @@ const page = new NamedPage('problem_files', () => {
|
||||
const dialog = new Dialog({
|
||||
$body: `
|
||||
<div class="file-label" style="text-align: center; margin-bottom: 5px; color: gray; font-size: small;"></div>
|
||||
<div class="bp3-progress-bar bp3-intent-primary bp3-no-stripes">
|
||||
<div class="file-progress bp3-progress-meter" style="width: 0"></div>
|
||||
<div class="bp4-progress-bar bp4-intent-primary bp4-no-stripes">
|
||||
<div class="file-progress bp4-progress-meter" style="width: 0"></div>
|
||||
</div>
|
||||
<div class="upload-label" style="text-align: center; margin: 5px 0; color: gray; font-size: small;"></div>
|
||||
<div class="bp3-progress-bar bp3-intent-primary bp3-no-stripes">
|
||||
<div class="upload-progress bp3-progress-meter" style="width: 0"></div>
|
||||
<div class="bp4-progress-bar bp4-intent-primary bp4-no-stripes">
|
||||
<div class="upload-progress bp4-progress-meter" style="width: 0"></div>
|
||||
</div>`,
|
||||
});
|
||||
try {
|
||||
|
||||
@@ -11,14 +11,14 @@
|
||||
<h1 class="section__title">{{ tdoc.title }}</h1>
|
||||
</div>
|
||||
<div class="section__body">
|
||||
<span class="bp3-tag bp3-large bp3-minimal problem__tag-item">{{ _(model.contest.statusText(tdoc)) }}</span>
|
||||
<span class="bp3-tag bp3-large bp3-minimal problem__tag-item icon icon-award">{{ _(model.contest.RULES[tdoc.rule].TEXT) }}</span>
|
||||
<span class="bp3-tag bp3-large bp3-minimal problem__tag-item">{{ _('Start at') }}: {{ contest.render_time(tdoc.beginAt) }}</span>
|
||||
<span class="bp3-tag bp3-large bp3-minimal problem__tag-item icon icon-schedule">{{ contest.render_duration(tdoc) }} {{ _('hour(s)') }}</span>
|
||||
<span class="bp3-tag bp3-large bp3-minimal problem__tag-item">{{ _('Host') }}: {{ user.render_inline(udict[tdoc.owner], badge=false) }}</span>
|
||||
<span class="bp3-tag bp3-large bp3-minimal problem__tag-item icon icon-user--multiple">{{ tdoc['attend']|default(0) }}</span>
|
||||
<span class="bp4-tag bp4-large bp4-minimal problem__tag-item">{{ _(model.contest.statusText(tdoc)) }}</span>
|
||||
<span class="bp4-tag bp4-large bp4-minimal problem__tag-item icon icon-award">{{ _(model.contest.RULES[tdoc.rule].TEXT) }}</span>
|
||||
<span class="bp4-tag bp4-large bp4-minimal problem__tag-item">{{ _('Start at') }}: {{ contest.render_time(tdoc.beginAt) }}</span>
|
||||
<span class="bp4-tag bp4-large bp4-minimal problem__tag-item icon icon-schedule">{{ contest.render_duration(tdoc) }} {{ _('hour(s)') }}</span>
|
||||
<span class="bp4-tag bp4-large bp4-minimal problem__tag-item">{{ _('Host') }}: {{ user.render_inline(udict[tdoc.owner], badge=false) }}</span>
|
||||
<span class="bp4-tag bp4-large bp4-minimal problem__tag-item icon icon-user--multiple">{{ tdoc['attend']|default(0) }}</span>
|
||||
{% if tsdoc.attend %}
|
||||
<span class="bp3-tag bp3-large bp3-minimal problem__tag-item icon icon-check">{{ _('Attended') }}</span>
|
||||
<span class="bp4-tag bp4-large bp4-minimal problem__tag-item icon icon-check">{{ _('Attended') }}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
{% import "components/nothing.html" as nothing with context %}
|
||||
{% import "components/problem.html" as problem with context %}
|
||||
<div class="section side section--problem-sidebar-additional_file">
|
||||
<div class="section__header">
|
||||
<h1 class="section__title">{{ additional_file.length|default(0) }} {{_('Additional File')}}</h1>
|
||||
</div>
|
||||
<ol class="menu">
|
||||
<li class="menu__item flex-row">
|
||||
<a class="menu__link half" href="javascript:;" name="additional_file__upload">
|
||||
<span class="icon icon-upload"></span> {{ _('Upload') }}
|
||||
</a>
|
||||
<a class="menu__link half" href="javascript:;" name="additional_file__download">
|
||||
<span class="icon icon-download"></span> {{ _('Download All') }}
|
||||
</a>
|
||||
</li>
|
||||
</ol>
|
||||
<div class="section__body no-padding problem-sidebar-additional_file__detail" data-fragment-id="problem-sidebar-additional_file">
|
||||
{% if additional_file.length %}
|
||||
<table class="data-table additionalfile-table">
|
||||
<colgroup>
|
||||
<col class="col--name">
|
||||
<col class="col--size">
|
||||
<col class="col--operation">
|
||||
</colgroup>
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="col--name">{{ _('Filename') }}</th>
|
||||
<th class="col--size">{{ _('Size') }}</th>
|
||||
<th class="col--operation"><span class="icon icon-wrench"></span></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{%- for file in additional_file -%}
|
||||
<tr data-filename="{{ file.name }}" data-size="{{ file.size }}">
|
||||
<td class="col--name" title="{{ file.name }}">
|
||||
<a href="{{ url('problem_file_download', {pid: pdoc.docId, filename: file.name}) }}">{{ file.name }}</a>
|
||||
</td>
|
||||
<td class="col--size">{{ size(file.size) }}</td>
|
||||
<td class="col--operation"><a href="javascript:;"name="additional_file__delete"><span class="icon icon-delete"></span></a></td>
|
||||
</tr>
|
||||
{%- endfor -%}
|
||||
</tbody>
|
||||
</table>
|
||||
{% else %}
|
||||
{{ nothing.render('No additional file at current.',compact = true) }}
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
@@ -83,6 +83,9 @@
|
||||
<li class="menu__item"><a class="menu__link{% if page_name == 'problem_edit' %} active{% endif %}" href="{{ url('problem_edit', pid=pdoc.pid|default(pdoc.docId)) }}">
|
||||
<span class="icon icon-edit"></span> {{ _('Edit') }}
|
||||
</a></li>
|
||||
<li class="menu__item"><a class="menu__link{% if page_name == 'problem_config' %} active{% endif %}" href="{{ url('problem_config', pid=pdoc.pid|default(pdoc.docId)) }}">
|
||||
<span class="icon icon-settings"></span> {{ _('Judge Config') }}
|
||||
</a></li>
|
||||
{% endif %}
|
||||
{% if page_name == 'problem_detail' %}
|
||||
<li class="menu__seperator nojs--hide"></li>
|
||||
@@ -100,14 +103,14 @@
|
||||
</ol>
|
||||
</div>
|
||||
</div>
|
||||
{% if page_name != 'problem_detail' and page_name != 'homework_detail_problem' and page_name != 'contest_detail_problem' %}
|
||||
{% if page_name not in ['problem_config', 'problem_detail', 'homework_detail_problem', 'contest_detail_problem'] %}
|
||||
<div class="section side">
|
||||
<div class="section__header">
|
||||
<h1 class="section__title">{{ _('Information') }}</h1>
|
||||
</div>
|
||||
{% include "partials/problem-sidebar-information.html" %}
|
||||
</div>
|
||||
{% elif page_name != 'contest_detail_problem' %}
|
||||
{% elif page_name != 'contest_detail_problem' and page_name != 'problem_config' %}
|
||||
<div class="section side nojs--hide">
|
||||
<div class="section__header">
|
||||
<h1 class="section__title">{{ _('Statistics') }}</h1>
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
{% import "components/nothing.html" as nothing with context %}
|
||||
{% import "components/problem.html" as problem with context %}
|
||||
<div class="section side section--problem-sidebar-testdata">
|
||||
<div class="section__header">
|
||||
<h1 class="section__title">{{ testdata.length if testdata.length else 0 }} {{_('Testdata')}}</h1>
|
||||
</div>
|
||||
<ol class="menu">
|
||||
<li class="menu__item flex-row">
|
||||
<a class="menu__link half" href="javascript:;" name="testdata__upload">
|
||||
<span class="icon icon-upload"></span> {{ _('Upload') }}
|
||||
</a>
|
||||
<a class="menu__link half" href="javascript:;" name="testdata__download">
|
||||
<span class="icon icon-download"></span> {{ _('Download All') }}
|
||||
</a>
|
||||
</li>
|
||||
<li class="menu__seperator"></li>
|
||||
</ol>
|
||||
<div class="section__body no-padding problem-sidebar-testdata__detail" data-fragment-id="problem-sidebar-testdata">
|
||||
{% if testdata.length %}
|
||||
<table class="data-table testdata-table">
|
||||
<colgroup>
|
||||
<col class="col--name">
|
||||
<col class="col--size">
|
||||
<col class="col--operation">
|
||||
</colgroup>
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="col--name">{{ _('Filename') }}</th>
|
||||
<th class="col--size">{{ _('Size') }}</th>
|
||||
<th class="col--operation"><span class="icon icon-wrench"></span></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{%- for file in testdata -%}
|
||||
<tr data-filename="{{ file.name }}" data-size="{{ file.size }}">
|
||||
<td class="col--name" title="{{ file.name }}"><a href="{{ url('problem_file_download', {pid: pdoc.docId, filename: file.name, query:{type:'testdata'} }) }}">{{ file.name }}</a></td>
|
||||
<td class="col--size">{{ size(file.size) }}</td>
|
||||
<td class="col--operation"><a href="javascript:;" name="testdata__delete"><span class="icon icon-delete"></span></a></td>
|
||||
</tr>
|
||||
{%- endfor -%}
|
||||
</tbody>
|
||||
</table>
|
||||
{% else %}
|
||||
{{ nothing.render('No testdata at current.', compact = true) }}
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,21 @@
|
||||
{% extends "layout/basic.html" %}
|
||||
{% block content %}
|
||||
<div class="row">
|
||||
<div class="medium-9 columns">
|
||||
<div class="section">
|
||||
<div class="section__header">
|
||||
<h1 class="section__title">{{_('Judge Config')}}(Beta)</h1>
|
||||
</div>
|
||||
<div id="ProblemConfig" class="section__body">
|
||||
{{ noscript_note.render() }}
|
||||
<div class="loader-container"><div class="loader"></div></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="medium-3 columns">
|
||||
{% include "partials/problem_sidebar_testdata.html" %}
|
||||
{% set owner_udoc = udoc %}
|
||||
{% include "partials/problem_sidebar.html" %}
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -49,22 +49,22 @@
|
||||
</div>
|
||||
<div class="section__body">
|
||||
<ul class="problem__tags">
|
||||
<span class="bp3-tag bp3-large bp3-minimal problem__tag-item">{{ _('ID') }}: {{ pdoc.docId }}</span>
|
||||
<span class="bp3-tag bp3-large bp3-minimal problem__tag-item icon icon-book">{{ _('problem_type.' + pdoc.config.type).format(pdoc.config.subType) }}</span>
|
||||
<span class="bp4-tag bp4-large bp4-minimal problem__tag-item">{{ _('ID') }}: {{ pdoc.docId }}</span>
|
||||
<span class="bp4-tag bp4-large bp4-minimal problem__tag-item icon icon-book">{{ _('problem_type.' + pdoc.config.type).format(pdoc.config.subType) }}</span>
|
||||
{% if pdoc.config.type != 'objective' and pdoc.config.type != 'submit_answer' %}
|
||||
<span class="bp3-tag bp3-large bp3-minimal problem__tag-item icon icon-stopwatch">{{ pdoc.config.timeMax if pdoc.config.timeMax == pdoc.config.timeMin else pdoc.config.timeMin+'~'+pdoc.config.timeMax }}ms</span>
|
||||
<span class="bp3-tag bp3-large bp3-minimal problem__tag-item icon icon-comparison">{{ pdoc.config.memoryMax if pdoc.config.memoryMax == pdoc.config.memoryMin else pdoc.config.memoryMin+'~'+pdoc.config.memoryMax }}MiB</span>
|
||||
<span class="bp4-tag bp4-large bp4-minimal problem__tag-item icon icon-stopwatch">{{ pdoc.config.timeMax if pdoc.config.timeMax == pdoc.config.timeMin else pdoc.config.timeMin+'~'+pdoc.config.timeMax }}ms</span>
|
||||
<span class="bp4-tag bp4-large bp4-minimal problem__tag-item icon icon-comparison">{{ pdoc.config.memoryMax if pdoc.config.memoryMax == pdoc.config.memoryMin else pdoc.config.memoryMin+'~'+pdoc.config.memoryMax }}MiB</span>
|
||||
{% endif %}
|
||||
{% if not tdoc %}
|
||||
<a class="bp3-tag bp3-large bp3-minimal bp3-interactive problem__tag-item" href="{{ url('record_main', query={pid:pdoc.docId}) }}">{{ _('Tried') }}: {{ pdoc.nSubmit }}</a>
|
||||
<a class="bp3-tag bp3-large bp3-minimal bp3-interactive problem__tag-item" href="{{ url('record_main', query={pid:pdoc.docId, status:1}) }}">{{ _('Accepted') }}: {{ pdoc.nAccept if ((not tdoc) or showAccept) else '?' }}</a>
|
||||
<span class="bp3-tag bp3-large bp3-minimal problem__tag-item">{{ _('Difficulty') }}: {{ pdoc['difficulty'] or lib.difficulty(pdoc.nSubmit, pdoc.nAccept) or _('(None)') }}</span>
|
||||
<a class="bp4-tag bp4-large bp4-minimal bp4-interactive problem__tag-item" href="{{ url('record_main', query={pid:pdoc.docId}) }}">{{ _('Tried') }}: {{ pdoc.nSubmit }}</a>
|
||||
<a class="bp4-tag bp4-large bp4-minimal bp4-interactive problem__tag-item" href="{{ url('record_main', query={pid:pdoc.docId, status:1}) }}">{{ _('Accepted') }}: {{ pdoc.nAccept if ((not tdoc) or showAccept) else '?' }}</a>
|
||||
<span class="bp4-tag bp4-large bp4-minimal problem__tag-item">{{ _('Difficulty') }}: {{ pdoc['difficulty'] or lib.difficulty(pdoc.nSubmit, pdoc.nAccept) or _('(None)') }}</span>
|
||||
{% endif %}
|
||||
<span class="bp3-tag bp3-large bp3-minimal bp3-interactive problem__tag-item">{{ _('Uploaded By') }}: {{ user.render_inline(udoc, badge=false) }}</span>
|
||||
<span class="bp4-tag bp4-large bp4-minimal bp4-interactive problem__tag-item">{{ _('Uploaded By') }}: {{ user.render_inline(udoc, badge=false) }}</span>
|
||||
{% if pdoc.tag.length %}
|
||||
<span name="show_tags" class="bp3-tag bp3-large bp3-minimal bp3-interactive problem__tag-item nojs--hide icon icon-tag">{{ _('Tags') + '>' }}</span>
|
||||
<span name="show_tags" class="bp4-tag bp4-large bp4-minimal bp4-interactive problem__tag-item nojs--hide icon icon-tag">{{ _('Tags') + '>' }}</span>
|
||||
{%- for tag in pdoc.tag -%}
|
||||
<span class="bp3-tag bp3-large bp3-minimal problem__tag-item tags hasjs--hide">{{ tag }}</span>
|
||||
<span class="bp4-tag bp4-large bp4-minimal problem__tag-item tags hasjs--hide">{{ tag }}</span>
|
||||
{%- endfor -%}
|
||||
{% endif %}
|
||||
</ul>
|
||||
|
||||
@@ -92,14 +92,15 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="medium-3 columns">
|
||||
<div class="section side visiable nojs--hide">
|
||||
<div class="section side visiable nojs--hide section--problem-sidebar-tags">
|
||||
<div class="section__header">
|
||||
<h1 class="section__title">{{ _('Categories') }} ({{ _('click to add') }})</h1>
|
||||
</div>
|
||||
<div class="section__body">
|
||||
<div class="section__body problem-sidebar-tags__detail">
|
||||
{% include "partials/category.html" %}
|
||||
</div>
|
||||
</div>
|
||||
{% include "partials/problem_sidebar_additional_file.html" %}
|
||||
{% if page_name == 'problem_edit' %}
|
||||
{% set owner_udoc = udoc %}
|
||||
{% include "partials/problem_sidebar.html" %}
|
||||
|
||||
@@ -7,8 +7,9 @@
|
||||
<h1 class="section__title">{{ _('Testdata') }}</h1>
|
||||
<div class="section__tools">
|
||||
{% if not reference and (handler.user.own(pdoc, perm.PERM_EDIT_PROBLEM_SELF) or handler.user.hasPerm(perm.PERM_EDIT_PROBLEM)) %}
|
||||
<button class="primary rounded button" name="create_testdata">{{ _('Create File') }}</button>
|
||||
<button class="primary rounded button" name="upload_testdata">{{ _('Upload File') }}</button>
|
||||
<a href="{{ url('problem_config', pid=pdoc.docId) }}" class="primary rounded button">{{ _('Config') }}</a>
|
||||
<button class="primary rounded button" name="create_testdata">{{ _('Create') }}</button>
|
||||
<button class="primary rounded button" name="upload_testdata">{{ _('Upload') }}</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -212,7 +212,7 @@ $hover-background-color = #424242
|
||||
.menu__link.highlight
|
||||
background: rgb(40, 11, 20)
|
||||
|
||||
.bp3-tag.bp3-minimal:not([class*="bp3-intent-"])
|
||||
.bp4-tag.bp4-minimal:not([class*="bp4-intent-"])
|
||||
background-color: rgba(87, 100, 117, .2)
|
||||
color: #d9dfe7
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import substitute from './substitute';
|
||||
|
||||
export default function i18n(str, ...params) {
|
||||
if (!str) return '';
|
||||
return substitute(window.LOCALES?.[str] || str, params);
|
||||
}
|
||||
|
||||
|
||||
+11
-57
@@ -3,13 +3,10 @@ import { findFileSync, parseMemoryMB, parseTimeMS } from './utils';
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
export default async function readYamlCases(cfg: Record<string, any> = {}, checkFile = (s: string, errMsg: string) => s) {
|
||||
const config: any = {
|
||||
checker_type: 'default',
|
||||
count: 0,
|
||||
subtasks: [],
|
||||
checker_type: cfg.checker_type || 'default',
|
||||
judge_extra_files: [],
|
||||
user_extra_files: [],
|
||||
};
|
||||
config.checker_type = cfg.checker_type || 'default';
|
||||
if (cfg.checker) {
|
||||
if (!cfg.checker.includes('.')) {
|
||||
config.checker = findFileSync(`@hydrooj/hydrojudge/vendor/testlib/checkers/${cfg.checker}.cpp`, false);
|
||||
@@ -17,62 +14,19 @@ export default async function readYamlCases(cfg: Record<string, any> = {}, check
|
||||
if (!config.checker) config.checker = checkFile(cfg.checker, 'Cannot find checker {0}.');
|
||||
}
|
||||
if (cfg.interactor) config.interactor = checkFile(cfg.interactor, 'Cannot find interactor {0}.');
|
||||
if (cfg.judge_extra_files) {
|
||||
if (typeof cfg.judge_extra_files === 'string') {
|
||||
config.judge_extra_files = [checkFile(cfg.judge_extra_files, 'Cannot find judge extra file {0}.')];
|
||||
} else if (cfg.judge_extra_files instanceof Array) {
|
||||
for (const file of cfg.judge_extra_files) {
|
||||
config.judge_extra_files.push(checkFile(file, 'Cannot find judge extra file {0}.'));
|
||||
}
|
||||
} else throw new Error('Invalid judge_extra_files config.');
|
||||
}
|
||||
if (cfg.user_extra_files) {
|
||||
if (typeof cfg.user_extra_files === 'string') {
|
||||
config.user_extra_files = [checkFile(cfg.user_extra_files, 'Cannot find user extra file {0}.')];
|
||||
} else if (cfg.user_extra_files instanceof Array) {
|
||||
for (const file of cfg.user_extra_files) {
|
||||
config.user_extra_files.push(checkFile(file, 'Cannot find user extra file {0}.'));
|
||||
}
|
||||
} else throw new Error('Invalid user_extra_files config.');
|
||||
}
|
||||
if (cfg.outputs) {
|
||||
config.type = cfg.type || 'objective';
|
||||
} else if (cfg.cases?.length) {
|
||||
['judge', 'user'].forEach((n) => {
|
||||
const conf = cfg[`${n}_extra_files`];
|
||||
if (!conf) return;
|
||||
if (conf instanceof Array) {
|
||||
config[`${n}_extra_files`] = conf.map((file) => checkFile(file, `Cannot find ${n} extra file {0}.`));
|
||||
} else throw new Error(`Invalid ${n}_extra_files config.`);
|
||||
});
|
||||
if (cfg.outputs) config.type = cfg.type || 'objective';
|
||||
if (cfg.cases?.length) {
|
||||
config.subtasks = [{
|
||||
score: +cfg.score || Math.floor(100 / cfg.cases.length),
|
||||
time: parseTimeMS(cfg.time || '1s'),
|
||||
memory: parseMemoryMB(cfg.memory || '256m'),
|
||||
cases: [],
|
||||
cases: cfg.cases,
|
||||
type: 'sum',
|
||||
}];
|
||||
for (const c of cfg.cases) {
|
||||
config.count++;
|
||||
config.subtasks[0].cases.push({
|
||||
input: c.input ? checkFile(c.input, 'Cannot find input file {0}.') : '/dev/null',
|
||||
output: c.output ? checkFile(c.output, 'Cannot find output file {0}.') : '/dev/null',
|
||||
id: config.count,
|
||||
});
|
||||
}
|
||||
} else if (cfg.subtasks?.length) {
|
||||
for (const subtask of cfg.subtasks) {
|
||||
const cases = [];
|
||||
for (const c of subtask.cases || []) {
|
||||
config.count++;
|
||||
cases.push({
|
||||
input: c.input ? checkFile(c.input, 'Cannot find input file {0}.') : '/dev/null',
|
||||
output: c.output ? checkFile(c.output, 'Cannot find output file {0}.') : '/dev/null',
|
||||
id: config.count,
|
||||
});
|
||||
}
|
||||
config.subtasks.push({
|
||||
score: Number.isSafeInteger(+subtask.score) ? +subtask.score : 100,
|
||||
if: subtask.if || [],
|
||||
cases,
|
||||
type: subtask.type || 'min',
|
||||
time: parseTimeMS(subtask.time || cfg.time || '1s'),
|
||||
memory: parseMemoryMB(subtask.memory || cfg.memory || '256m'),
|
||||
});
|
||||
}
|
||||
}
|
||||
if (cfg.time) config.time = parseTimeMS(cfg.time);
|
||||
if (cfg.memory) config.memory = parseMemoryMB(cfg.memory);
|
||||
|
||||
@@ -0,0 +1,309 @@
|
||||
import { inspect } from 'util';
|
||||
|
||||
declare global {
|
||||
interface StringConstructor {
|
||||
random: (digit?: number) => string;
|
||||
}
|
||||
interface String {
|
||||
format: (...args: Array<any>) => string;
|
||||
formatFromArray: (args: any[]) => string;
|
||||
rawformat: (object: any) => string;
|
||||
}
|
||||
interface ArrayConstructor {
|
||||
isDiff: (a: any[], b: any[]) => boolean;
|
||||
}
|
||||
interface Date {
|
||||
format: (fmt?: string) => string;
|
||||
}
|
||||
interface Math {
|
||||
sum: (...args: Array<number[] | number>) => number;
|
||||
}
|
||||
interface SetConstructor {
|
||||
isSuperset: (set: Set<any>, subset: Set<any>) => boolean;
|
||||
intersection: <T>(setA: Set<T> | Array<T>, setB: Set<T> | Array<T>) => Set<T>;
|
||||
union: <T>(setA: Set<T> | Array<T>, setB: Set<T> | Array<T>) => Set<T>;
|
||||
}
|
||||
}
|
||||
|
||||
const defaultDict = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890';
|
||||
|
||||
String.random = function random(digit = 32, dict = defaultDict) {
|
||||
let str = '';
|
||||
for (let i = 1; i <= digit; i++) str += dict[Math.floor(Math.random() * dict.length)];
|
||||
return str;
|
||||
};
|
||||
|
||||
String.prototype.format = function formatStr(...args) {
|
||||
let result = this;
|
||||
if (args.length) {
|
||||
if (args.length === 1 && typeof args[0] === 'object') {
|
||||
const t = args[0];
|
||||
for (const key in t) {
|
||||
if (!key.startsWith('_') && t[key] !== undefined) {
|
||||
if (t._inspect && typeof t[key] === 'object') {
|
||||
t[key] = inspect(t[key], { colors: process?.stderr?.isTTY });
|
||||
}
|
||||
const reg = new RegExp(`(\\{${key}\\})`, 'g');
|
||||
result = result.replace(reg, t[key]);
|
||||
}
|
||||
}
|
||||
} else return this.formatFromArray(args);
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
String.prototype.formatFromArray = function formatStr(args) {
|
||||
let result = this;
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
if (args[i] !== undefined) {
|
||||
const reg = new RegExp(`(\\{)${i}(\\})`, 'g');
|
||||
result = result.replace(reg, args[i]);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
String.prototype.rawformat = function rawFormat(object) {
|
||||
const res = this.split('{@}');
|
||||
return [res[0], object, res[1]].join();
|
||||
};
|
||||
|
||||
Array.isDiff = function isDiff(a, b) {
|
||||
if (a.length !== b.length) return true;
|
||||
a.sort();
|
||||
b.sort();
|
||||
for (const i in a) {
|
||||
if (a[i] !== b[i]) return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
Date.prototype.format = function formatDate(fmt = '%Y-%m-%d %H:%M:%S') {
|
||||
let m = this.getMonth() + 1;
|
||||
if (m < 10) m = `0${m}`;
|
||||
let d = this.getDate();
|
||||
if (d < 10) d = `0${d}`;
|
||||
let H = this.getHours();
|
||||
if (H < 10) H = `0${H}`;
|
||||
let M = this.getMinutes();
|
||||
if (M < 10) M = `0${M}`;
|
||||
let S = this.getSeconds();
|
||||
if (S < 10) S = `0${S}`;
|
||||
return fmt
|
||||
.replace('%Y', this.getFullYear())
|
||||
.replace('%m', m)
|
||||
.replace('%d', d)
|
||||
.replace('%H', H)
|
||||
.replace('%M', M)
|
||||
.replace('%S', S);
|
||||
};
|
||||
|
||||
Math.sum = function sum(...args) {
|
||||
let s = 0;
|
||||
for (const i of args) {
|
||||
if (i instanceof Array) {
|
||||
for (const j of i) {
|
||||
s += j;
|
||||
}
|
||||
} else s += i;
|
||||
}
|
||||
return s;
|
||||
};
|
||||
|
||||
Set.isSuperset = function isSuperset(set, subset) {
|
||||
for (const elem of subset) {
|
||||
if (!set.has(elem)) return false;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
Set.union = function Union<T>(setA: Set<T> | Array<T>, setB: Set<T> | Array<T>) {
|
||||
const union = new Set(setA);
|
||||
for (const elem of setB) union.add(elem);
|
||||
return union;
|
||||
};
|
||||
|
||||
Set.intersection = function Intersection<T>(A: Set<T> | Array<T> = [], B: Set<T> | Array<T> = []) {
|
||||
const intersection = new Set<T>();
|
||||
if (A instanceof Array) A = new Set(A);
|
||||
if (B instanceof Array) B = new Set(B);
|
||||
for (const elem of B) if (A.has(elem)) intersection.add(elem);
|
||||
return intersection;
|
||||
};
|
||||
|
||||
const TIME_RE = /^([0-9]+(?:\.[0-9]*)?)([mu]?)s?$/i;
|
||||
const TIME_UNITS = { '': 1000, m: 1, u: 0.001 };
|
||||
const MEMORY_RE = /^([0-9]+(?:\.[0-9]*)?)([kmg])b?$/i;
|
||||
const MEMORY_UNITS = { k: 1 / 1024, m: 1, g: 1024 };
|
||||
|
||||
export function parseTimeMS(str: string | number, throwOnError = true) {
|
||||
if (typeof str === 'number' || Number.isSafeInteger(+str)) return +str;
|
||||
const match = TIME_RE.exec(str);
|
||||
if (!match && throwOnError) throw new Error(`${str} error parsing time`);
|
||||
if (!match) return 1000;
|
||||
return Math.floor(parseFloat(match[1]) * TIME_UNITS[match[2].toLowerCase()]);
|
||||
}
|
||||
|
||||
export function parseMemoryMB(str: string | number, throwOnError = true) {
|
||||
if (typeof str === 'number' || Number.isSafeInteger(+str)) return +str;
|
||||
const match = MEMORY_RE.exec(str);
|
||||
if (!match && throwOnError) throw new Error(`${str} error parsing memory`);
|
||||
if (!match) return 256;
|
||||
return Math.ceil(parseFloat(match[1]) * MEMORY_UNITS[match[2].toLowerCase()]);
|
||||
}
|
||||
|
||||
export function size(s: number, base = 1) {
|
||||
s *= base;
|
||||
const unit = 1024;
|
||||
const unitNames = ['Bytes', 'KiB', 'MiB', 'GiB', 'TiB', 'PiB', 'EiB', 'ZiB', 'YiB'];
|
||||
for (const unitName of unitNames) {
|
||||
if (s < unit) return '{0} {1}'.format(Math.round(s * 10) / 10, unitName);
|
||||
s /= unit;
|
||||
}
|
||||
return `${Math.round(s * unit)} ${unitNames[unitNames.length - 1]}`;
|
||||
}
|
||||
|
||||
interface MatchRule {
|
||||
regex: RegExp;
|
||||
output: ((a: RegExpExecArray) => string)[];
|
||||
id: (a: RegExpExecArray) => number;
|
||||
subtask: (a: RegExpExecArray) => number;
|
||||
preferredScorerType: 'min' | 'max' | 'sum';
|
||||
}
|
||||
|
||||
const SubtaskMatcher: MatchRule[] = [
|
||||
{
|
||||
regex: /^([^\d]*)(\d+).(in|txt)$/,
|
||||
output: [
|
||||
(a) => `${a[1] + a[2]}.out`,
|
||||
(a) => `${a[1] + a[2]}.ans`,
|
||||
(a) => `${a[1] + a[2]}.out`.replace(/input/g, 'output'),
|
||||
(a) => (a[1].includes('input') ? `${a[1] + a[2]}.txt`.replace(/input/g, 'output') : null),
|
||||
],
|
||||
id: (a) => +a[2],
|
||||
subtask: () => 0,
|
||||
preferredScorerType: 'sum',
|
||||
},
|
||||
{
|
||||
regex: /^([^\d]*)\.in(\d+)$/,
|
||||
output: [
|
||||
(a) => `${a[1]}.ou${a[2]}`,
|
||||
(a) => `${a[1]}.ou${a[2]}`.replace(/input/g, 'output'),
|
||||
],
|
||||
id: (a) => +a[2],
|
||||
subtask: () => 0,
|
||||
preferredScorerType: 'sum',
|
||||
},
|
||||
{
|
||||
regex: /^([^\d]*)([0-9]+)([-_])([0-9]+).in$/,
|
||||
output: [(a) => `${a[1] + a[2]}${a[3]}${a[4]}.out`],
|
||||
id: (a) => +a[4],
|
||||
subtask: (a) => +a[2],
|
||||
preferredScorerType: 'min',
|
||||
},
|
||||
];
|
||||
|
||||
function* getScore(totalScore: number, count: number) {
|
||||
const base = Math.floor(totalScore / count);
|
||||
const extra = count - (100 % count);
|
||||
for (let i = 0; i < count; i++) {
|
||||
if (i >= extra) yield base + 1;
|
||||
else yield base;
|
||||
}
|
||||
}
|
||||
|
||||
interface ParsedCase {
|
||||
id?: number;
|
||||
time?: number | string;
|
||||
memory?: number | string;
|
||||
score?: number;
|
||||
input?: string;
|
||||
output?: string;
|
||||
}
|
||||
interface ParsedSubtask {
|
||||
cases: ParsedCase[];
|
||||
type: 'min' | 'max' | 'sum';
|
||||
time?: number | string;
|
||||
memory?: number | string;
|
||||
score?: number;
|
||||
id?: number;
|
||||
if?: number[];
|
||||
}
|
||||
|
||||
export function readSubtasksFromFiles(files: string[], checkFile, config) {
|
||||
const subtask: Record<number, ParsedSubtask> = {};
|
||||
for (const s of config.subtasks || []) if (s.id) subtask[s.id] = s;
|
||||
for (const file of files) {
|
||||
for (const rule of SubtaskMatcher) {
|
||||
const data = rule.regex.exec(file);
|
||||
if (!data) continue;
|
||||
const sid = rule.subtask(data);
|
||||
const c = { input: file, output: '', id: rule.id(data) };
|
||||
for (const func of rule.output) {
|
||||
if (config.noOutputFile) c.output = '/dev/null';
|
||||
else c.output = func(data);
|
||||
if (c.output === '/dev/null' || checkFile(c.output)) {
|
||||
if (!subtask[sid]) {
|
||||
subtask[sid] = {
|
||||
time: config.time,
|
||||
memory: config.memory,
|
||||
type: rule.preferredScorerType,
|
||||
cases: [c],
|
||||
};
|
||||
} else if (!subtask[sid].cases) subtask[sid].cases = [c];
|
||||
else subtask[sid].cases.push(c);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return Object.values(subtask);
|
||||
}
|
||||
|
||||
type NormalizedCase = Required<ParsedCase>;
|
||||
interface NormalizedSubtask extends Required<ParsedSubtask> {
|
||||
cases: NormalizedCase[];
|
||||
}
|
||||
|
||||
export function normalizeSubtasks(
|
||||
subtasks: ParsedSubtask[], checkFile: (name: string, errMsg: string) => string,
|
||||
time: number | string, memory: number | string, ignoreParseError = false,
|
||||
): NormalizedSubtask[] {
|
||||
subtasks.sort((a, b) => (a.id - b.id));
|
||||
const subtaskScore = getScore(
|
||||
Math.max(100 - Math.sum(subtasks.map((i) => i.score || 0)), 0),
|
||||
subtasks.filter((i) => !i.score).length,
|
||||
);
|
||||
let id = 0;
|
||||
let count = 0;
|
||||
return subtasks.map((s) => {
|
||||
id++;
|
||||
s.cases.sort((a, b) => (a.id - b.id));
|
||||
const score = subtaskScore.next().value as number;
|
||||
const caseScore = getScore(
|
||||
Math.max(score - Math.sum(s.cases.map((i) => i.score || 0)), 0),
|
||||
s.cases.filter((i) => !i.score).length,
|
||||
);
|
||||
return {
|
||||
id,
|
||||
score,
|
||||
type: 'min',
|
||||
if: [],
|
||||
...s,
|
||||
time: parseTimeMS(s.time || time, !ignoreParseError),
|
||||
memory: parseMemoryMB(s.memory || memory, !ignoreParseError),
|
||||
cases: s.cases.map((c) => {
|
||||
count++;
|
||||
return {
|
||||
id: count,
|
||||
score: s.type === 'sum' ? caseScore.next().value as number : score,
|
||||
...c,
|
||||
time: parseTimeMS(c.time || s.time || time, !ignoreParseError),
|
||||
memory: parseMemoryMB(c.memory || s.memory || memory, !ignoreParseError),
|
||||
input: c.input ? checkFile(c.input, 'Cannot find input file {0}.') : '/dev/null',
|
||||
output: c.output ? checkFile(c.output, 'Cannot find output file {0}.') : '/dev/null',
|
||||
};
|
||||
}) as NormalizedCase[],
|
||||
};
|
||||
});
|
||||
}
|
||||
+2
-162
@@ -1,143 +1,11 @@
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
import { Duplex } from 'stream';
|
||||
import { inspect } from 'util';
|
||||
import fs from 'fs-extra';
|
||||
import type { Moment } from 'moment-timezone';
|
||||
import { isMoment } from 'moment-timezone';
|
||||
import { ObjectID } from 'mongodb';
|
||||
|
||||
declare global {
|
||||
interface StringConstructor {
|
||||
random: (digit?: number) => string;
|
||||
}
|
||||
interface String {
|
||||
format: (...args: Array<any>) => string;
|
||||
formatFromArray: (args: any[]) => string;
|
||||
rawformat: (object: any) => string;
|
||||
}
|
||||
interface ArrayConstructor {
|
||||
isDiff: (a: any[], b: any[]) => boolean;
|
||||
}
|
||||
interface Date {
|
||||
format: (fmt?: string) => string;
|
||||
}
|
||||
interface Math {
|
||||
sum: (...args: Array<number[] | number>) => number;
|
||||
}
|
||||
interface SetConstructor {
|
||||
isSuperset: (set: Set<any>, subset: Set<any>) => boolean;
|
||||
intersection: <T>(setA: Set<T> | Array<T>, setB: Set<T> | Array<T>) => Set<T>;
|
||||
union: <T>(setA: Set<T> | Array<T>, setB: Set<T> | Array<T>) => Set<T>;
|
||||
}
|
||||
}
|
||||
|
||||
const defaultDict = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890';
|
||||
|
||||
String.random = function random(digit = 32, dict = defaultDict) {
|
||||
let str = '';
|
||||
for (let i = 1; i <= digit; i++) str += dict[Math.floor(Math.random() * dict.length)];
|
||||
return str;
|
||||
};
|
||||
|
||||
String.prototype.format = function formatStr(...args) {
|
||||
let result = this;
|
||||
if (args.length) {
|
||||
if (args.length === 1 && typeof args[0] === 'object') {
|
||||
const t = args[0];
|
||||
for (const key in t) {
|
||||
if (!key.startsWith('_') && t[key] !== undefined) {
|
||||
if (t._inspect && typeof t[key] === 'object') {
|
||||
t[key] = inspect(t[key], { colors: process?.stderr?.isTTY });
|
||||
}
|
||||
const reg = new RegExp(`(\\{${key}\\})`, 'g');
|
||||
result = result.replace(reg, t[key]);
|
||||
}
|
||||
}
|
||||
} else return this.formatFromArray(args);
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
String.prototype.formatFromArray = function formatStr(args) {
|
||||
let result = this;
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
if (args[i] !== undefined) {
|
||||
const reg = new RegExp(`(\\{)${i}(\\})`, 'g');
|
||||
result = result.replace(reg, args[i]);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
String.prototype.rawformat = function rawFormat(object) {
|
||||
const res = this.split('{@}');
|
||||
return [res[0], object, res[1]].join();
|
||||
};
|
||||
|
||||
Array.isDiff = function isDiff(a, b) {
|
||||
if (a.length !== b.length) return true;
|
||||
a.sort();
|
||||
b.sort();
|
||||
for (const i in a) {
|
||||
if (a[i] !== b[i]) return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
Date.prototype.format = function formatDate(fmt = '%Y-%m-%d %H:%M:%S') {
|
||||
let m = this.getMonth() + 1;
|
||||
if (m < 10) m = `0${m}`;
|
||||
let d = this.getDate();
|
||||
if (d < 10) d = `0${d}`;
|
||||
let H = this.getHours();
|
||||
if (H < 10) H = `0${H}`;
|
||||
let M = this.getMinutes();
|
||||
if (M < 10) M = `0${M}`;
|
||||
let S = this.getSeconds();
|
||||
if (S < 10) S = `0${S}`;
|
||||
return fmt
|
||||
.replace('%Y', this.getFullYear())
|
||||
.replace('%m', m)
|
||||
.replace('%d', d)
|
||||
.replace('%H', H)
|
||||
.replace('%M', M)
|
||||
.replace('%S', S);
|
||||
};
|
||||
|
||||
Math.sum = function sum(...args) {
|
||||
let s = 0;
|
||||
for (const i of args) {
|
||||
if (i instanceof Array) {
|
||||
for (const j of i) {
|
||||
s += j;
|
||||
}
|
||||
} else s += i;
|
||||
}
|
||||
return s;
|
||||
};
|
||||
|
||||
Set.isSuperset = function isSuperset(set, subset) {
|
||||
for (const elem of subset) {
|
||||
if (!set.has(elem)) return false;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
Set.union = function Union<T>(setA: Set<T> | Array<T>, setB: Set<T> | Array<T>) {
|
||||
const union = new Set(setA);
|
||||
for (const elem of setB) union.add(elem);
|
||||
return union;
|
||||
};
|
||||
|
||||
Set.intersection = function Intersection<T>(A: Set<T> | Array<T> = [], B: Set<T> | Array<T> = []) {
|
||||
const intersection = new Set<T>();
|
||||
if (A instanceof Array) A = new Set(A);
|
||||
if (B instanceof Array) B = new Set(B);
|
||||
for (const elem of B) if (A.has(elem)) intersection.add(elem);
|
||||
return intersection;
|
||||
};
|
||||
|
||||
export function folderSize(folderPath: string) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-shadow
|
||||
let size = 0;
|
||||
@@ -161,25 +29,6 @@ export function folderSize(folderPath: string) {
|
||||
return size;
|
||||
}
|
||||
|
||||
const TIME_RE = /^([0-9]+(?:\.[0-9]*)?)([mu]?)s?$/i;
|
||||
const TIME_UNITS = { '': 1000, m: 1, u: 0.001 };
|
||||
const MEMORY_RE = /^([0-9]+(?:\.[0-9]*)?)([kmg])b?$/i;
|
||||
const MEMORY_UNITS = { k: 1 / 1024, m: 1, g: 1024 };
|
||||
|
||||
export function parseTimeMS(str: string | number) {
|
||||
if (typeof str === 'number') return str;
|
||||
const match = TIME_RE.exec(str);
|
||||
if (!match) throw new Error(`${str} error parsing time`);
|
||||
return Math.floor(parseFloat(match[1]) * TIME_UNITS[match[2].toLowerCase()]);
|
||||
}
|
||||
|
||||
export function parseMemoryMB(str: string | number) {
|
||||
if (typeof str === 'number') return str;
|
||||
const match = MEMORY_RE.exec(str);
|
||||
if (!match) throw new Error(`${str} error parsing memory`);
|
||||
return Math.ceil(parseFloat(match[1]) * MEMORY_UNITS[match[2].toLowerCase()]);
|
||||
}
|
||||
|
||||
export function isClass(obj: any, strict = false) {
|
||||
if (typeof obj !== 'function') return false;
|
||||
const str = obj.toString();
|
||||
@@ -290,17 +139,6 @@ export function errorMessage(err: Error | string) {
|
||||
return err;
|
||||
}
|
||||
|
||||
export function size(s: number, base = 1) {
|
||||
s *= base;
|
||||
const unit = 1024;
|
||||
const unitNames = ['Bytes', 'KiB', 'MiB', 'GiB', 'TiB', 'PiB', 'EiB', 'ZiB', 'YiB'];
|
||||
for (const unitName of unitNames) {
|
||||
if (s < unit) return '{0} {1}'.format(Math.round(s * 10) / 10, unitName);
|
||||
s /= unit;
|
||||
}
|
||||
return `${Math.round(s * unit)} ${unitNames[unitNames.length - 1]}`;
|
||||
}
|
||||
|
||||
function _digit2(number: number) {
|
||||
return number < 10 ? `0${number}` : number.toString();
|
||||
}
|
||||
@@ -449,3 +287,5 @@ export function Counter<T extends (string | number) = string>() {
|
||||
},
|
||||
}) as Record<T, number>;
|
||||
}
|
||||
|
||||
export * from '@hydrooj/utils/lib/common';
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@hydrooj/utils",
|
||||
"version": "1.1.17",
|
||||
"version": "1.2.0",
|
||||
"description": "hydrooj utils",
|
||||
"main": "package.json",
|
||||
"repository": "https://github.com/hydro-dev/Hydro.git",
|
||||
|
||||
Reference in new issue
Block a user