core&ui: add problem_config

Co-authored-by: undefined <i@undefined.moe>
This commit is contained in:
pandaandundefined committed 2022-05-10 01:50:23 +08:00
1 parent 0b71b089e0
commit 1f498484d2
20 files changed
+1128 -159

No files matched your search

+4 -151
View File
@@ -2,158 +2,11 @@ import path from 'path';
import fs from 'fs-extra';
import yaml from 'js-yaml';
import { max, sum } from 'lodash';
import readYamlCases, { convertIniConfig } from '@hydrooj/utils/lib/cases';
import readYamlCases, { convertIniConfig, readCasesFromFiles, readSubtasksFromFiles } from '@hydrooj/utils/lib/cases';
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;
}
import { ensureFile, parseMemoryMB } from './utils';
async function readAutoCases(folder: string, { next }, cfg, rst) {
const config = {
@@ -174,8 +27,8 @@ async function readAutoCases(folder: string, { next }, cfg, rst) {
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);
let result = await readCasesFromFiles(files, checkFile, cfg);
if (!result.count) result = await readSubtasksFromFiles(files, checkFile, cfg, rst);
Object.assign(config, result);
if (cfg.isSelfSubmission) next({ message: { message: 'Found {0} testcases.', params: [config.count] } });
} catch (e) {
+32 -1
View File
@@ -4,7 +4,8 @@ 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 { readCasesFromFiles, readSubtasksFromFiles } from '@hydrooj/utils/lib/cases';
import { sortFiles, streamToBuffer } from '@hydrooj/utils/lib/utils';
import {
BadRequestError, ContestNotAttendedError, ContestNotEndedError,
ContestNotFoundError, ContestNotLiveError,
@@ -38,6 +39,9 @@ import { registerResolver, registerValue } from './api';
export const parseCategory = (value: string) => value.replace(/,/g, ',').split(',').map((e) => e.trim());
export const parsePid = (value: string) => (isSafeInteger(value) ? +value : value);
function ensureFile(testdata) {
return (file: string) => testdata.filter((i) => i === file)[0];
}
registerValue('FileInfo', [
['_id', 'String!'],
@@ -525,6 +529,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 +555,30 @@ 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 */ }
}
const testdata = (this.pdoc.data || []).map((i) => i.name);
const checkFile = ensureFile(testdata);
let autocases = await readCasesFromFiles(testdata, checkFile, {});
if (!autocases.count) {
autocases = await readSubtasksFromFiles(testdata, checkFile, {}, { subtasks: [] });
}
this.response.body.autocases = autocases;
this.response.template = 'problem_config.html';
}
}
export class ProblemFilesHandler extends ProblemDetailHandler {
notUsage = true;
@@ -831,6 +860,7 @@ export class ProblemCreateHandler extends Handler {
this.response.template = 'problem_edit.html';
this.response.body = {
page_name: 'problem_create',
additional_file: [],
};
}
@@ -892,6 +922,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);
@@ -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',
@@ -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
@@ -0,0 +1,89 @@
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 yaml from 'js-yaml';
const mapStateToProps = (state) => ({
config: state.config,
});
const mapDispatchToProps = (dispatch) => ({
handleUpdateCode: (code) => {
dispatch({
type: 'CONFIG_CODE_UPDATE',
payload: code,
});
},
});
interface Props {
config: object;
handleUpdateCode: Function;
}
export default connect(mapStateToProps, mapDispatchToProps)(class MonacoEditor extends React.PureComponent<Props> {
disposable = [];
containerElement: HTMLElement;
private __preventUpdate = 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(this.props.config), 'yaml', uri);
this.vjEditor = Editor.getOrConstruct($(this.containerElement), {
language: 'yaml',
model: this.model,
onChange: (value: string) => {
this.__preventUpdate = true;
this.props.handleUpdateCode(value);
this.__preventUpdate = false;
},
}) as Editor;
this.editor = this.vjEditor.editor;
}
componentDidUpdate(prevProps) {
if (this.__preventUpdate) return;
if (yaml.dump(prevProps.config) !== yaml.dump(this.props.config)) {
this.model?.pushEditOperations(
[],
[{
range: this.model.getFullModelRange(),
text: yaml.dump(this.props.config),
}],
undefined,
);
}
}
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,455 @@
import React, { useState } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import i18n from 'vj/utils/i18n';
import { isEqual } from 'lodash';
import { Switch, Tab, Tabs } from '@blueprintjs/core';
import type { TestCaseConfig } from 'hydrooj/src/interface';
import type { RootState } from './reducer/index';
import CustomSelectAutoComplete from '../autocomplete/components/CustomSelectAutoComplete';
const SelectValue = {
type: ['default', 'interactive', 'submit_answer', 'objective'],
checker_type: ['default', 'lemon', 'syzoj', 'hustoj', 'testlib', 'qduoj'],
task_type: ['min', 'max', 'sum'],
};
const eq = (a: TestCaseConfig[], b: TestCaseConfig[]) => isEqual(a, b);
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];
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 || ''}
onChange={(ev) => {
dispatch({ type: 'CONFIG_FORM_UPDATE', key: formKey, value: ev.currentTarget.value });
}}
className="textbox"
/>
);
}
function ManagedSelect({ placeholder, formKey }: { placeholder: string, formKey: KeyType<RootState['config']> }) {
const value = useSelector((state: RootState) => state.config[formKey]);
const dispatch = useDispatch();
return (
<select
placeholder={i18n(placeholder)}
value={value || ''}
onChange={(ev) => {
dispatch({ type: 'CONFIG_FORM_UPDATE', key: formKey, value: ev.currentTarget.value });
}}
className="select"
>
{SelectValue[formKey].map((i) => (<option value={i} key={i}>{i}</option>))}
</select>
);
}
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 (
<CustomSelectAutoComplete
width="100%"
data={Files}
selectedKeys={[value]}
onChange={(val) => dispatch({ type: 'CONFIG_FORM_UPDATE', key: formKey, value: val })}
/>
);
}
function BasicInfo() {
const Type = useSelector((state: RootState) => state.config.type);
const checkerType = useSelector((state: RootState) => state.config.checker_type);
const dispatch = useDispatch();
return (
<>
<FormItem columns={6} label="Type">
<ManagedSelect placeholder="type" formKey="type" />
</FormItem>
<FormItem columns={6} label="Filename" style={Type === 'interactive' ? { display: 'none' } : {}}>
<ManagedInput placeholder="filename" formKey="filename" />
</FormItem>
<FormItem
columns={12}
label="CheckerType"
disableLabel
style={Type !== 'default' ? { display: 'none' } : {}}
>
<Tabs
id="CheckerTypeTabs"
selectedTabId={checkerType !== 'strict' ? checkerType : 'default'}
onChange={(value) => {
dispatch({ type: 'CONFIG_FORM_UPDATE', key: 'checker_type', value });
}}
renderActiveTabPanelOnly
>
<span>{i18n('CheckerType')}</span>
<Tabs.Expander />
{
SelectValue.checker_type.map((i) => (
<Tab
id={i}
title={i}
key={i}
panel={(
<FormItem columns={12} label="Checker">
{['default', 'strict'].includes(i)
? (
<Switch
checked={checkerType === 'strict'}
label="Don't ignore space and enter."
onChange={
() => {
dispatch({ type: 'CONFIG_FORM_UPDATE', key: 'checker_type', value: checkerType === 'strict' ? 'default' : 'strict' });
}
}
/>
)
: (
<SingleFileSelect formKey="checker" />
)}
</FormItem>
)}
/>
))
}
</Tabs>
</FormItem>
<FormItem columns={6} label="Interactor" style={Type !== 'interactive' ? { display: 'none' } : {}}>
<SingleFileSelect formKey="interactor" />
</FormItem>
</>
);
}
function ExtraFilesConfig() {
const [showTab, setshowTab] = useState(false);
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>
<Switch checked={showTab} label="Extra Files Config" onChange={() => setshowTab(!showTab)} />
<div style={!showTab ? { display: 'none' } : {}}>
<Tabs id="ExtraFilesTabs">
<Tab
id="user_extra_files"
title="user_extra_files"
panel={(
<CustomSelectAutoComplete
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="judge_extra_files"
panel={(
<CustomSelectAutoComplete
data={Files}
selectedKeys={judgeExtraFiles || []}
onChange={(val) => dispatch({ type: 'CONFIG_FORM_UPDATE', key: 'judge_extra_files', value: val.split(',') })}
multi
/>
)}
/>
</Tabs>
</div>
</FormItem>
);
}
function CasesTable({ index }) {
const [time, setTime] = useState('');
const [memory, setMemory] = useState('');
const [input, setInput] = useState('');
const [output, setOutput] = useState('');
const cases = useSelector((state: RootState) => (index === -1 ? state.config.cases : state.config.subtasks[index].cases), eq);
console.log(cases);
const Files = useSelector((state: RootState) => state.testdata);
const dispatch = useDispatch();
return (
<table className="data-table">
<thead>
<tr>
{
index === -1 && (
<>
<th>
{i18n('Time')}<br />
<input
value={time || ''}
onChange={(ev) => setTime(ev.currentTarget.value)}
className="textbox"
/>
</th>
<th>
{i18n('Memory')}<br />
<input
value={memory || ''}
onChange={(ev) => setMemory(ev.currentTarget.value)}
className="textbox"
/>
</th>
</>
)
}
<th>
{i18n('Input')}<br />
<CustomSelectAutoComplete
width="100%"
data={Files}
selectedKeys={[input]}
onChange={setInput}
/>
</th>
<th>
{i18n('Output')}<br />
<CustomSelectAutoComplete
width="100%"
data={Files}
selectedKeys={[output]}
onChange={setOutput}
/>
</th>
<th>
<span className="icon icon-wrench"></span><br />
<a
onClick={() => {
setTime('');
setMemory('');
setInput('');
setOutput('');
dispatch({
type: index === -1 ? 'CONFIG_CASES_UPDATE' : 'CONFIG_SUBTASK_UPDATE',
id: index,
key: 'cases-add',
value: index === -1 ? {
time, memory, input, output,
} : { input, output },
});
}}
><span className="icon icon-add">{i18n('Add')}</span>
</a>
</th>
</tr>
</thead>
<tbody>
{cases && cases.map((k, v) => (
<tr key={JSON.stringify(k)}>
{
index === -1 && (
<>
<td>{k.time}</td>
<td>{k.memory}</td>
</>
)
}
<td>{k.input}</td>
<td>{k.output}</td>
<td>
<a
onClick={() => dispatch({
type: index === -1 ? 'CONFIG_CASES_UPDATE' : 'CONFIG_SUBTASK_UPDATE', id: index, key: 'cases-delete', value: v,
})}
><span className="icon icon-close"></span>
</a>
</td>
</tr>
))}
</tbody>
</table>
);
}
function SubtasksTable({ data, index }) {
const subtasks = useSelector((state: RootState) => state.config.subtasks, eq);
const dispatch = useDispatch();
const dispatcher = (key: string): React.ChangeEventHandler<HTMLInputElement | HTMLSelectElement> => (ev) => {
dispatch({
type: 'CONFIG_SUBTASK_UPDATE', id: index, key, value: ev.currentTarget.value,
});
};
return (
<div key={index}>
<span>Subtasks #{index} </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>
<td>
<input
value={data.id}
onChange={dispatcher('id')}
className="textbox"
/>
</td>
<td>
<input
value={data.score || ''}
onChange={dispatcher('score')}
className="textbox"
/>
</td>
<td>
<select
value={data.type}
onChange={dispatcher('type')}
className="select"
>
<option aria-label="null" value="" style={{ display: 'none' }}></option>
{SelectValue.task_type.map((i) => (<option value={i} key={i}>{i}</option>))}
</select>
</td>
<td>
<input
value={data.time || ''}
onChange={dispatcher('time')}
className="textbox"
/>
</td>
<td>
<input
value={data.memory || ''}
onChange={dispatcher('memory')}
className="textbox"
/>
</td>
</tr>
<tr>
<td>if</td>
<td colSpan={4}>
<CustomSelectAutoComplete
data={subtasks}
selectedKeys={data.if || []}
onChange={(val) => dispatch({
type: 'CONFIG_SUBTASK_UPDATE', id: index, key: 'if', value: val.split(','),
})}
multi
/>
</td>
</tr>
</tbody>
</table>
<CasesTable index={index} />
</div>
);
}
function TaskConfig() {
const subtasks = useSelector((state: RootState) => state.config.subtasks, eq);
const cases = useSelector((state: RootState) => state.config.cases, eq);
const dispatch = useDispatch();
return (
<FormItem columns={12} label="Task Settings">
<div className="row">
<FormItem columns={4} label="Time">
<ManagedInput placeholder="Time" formKey="time" />
</FormItem>
<FormItem columns={4} label="Memory">
<ManagedInput placeholder="Memory" formKey="memory" />
</FormItem>
<FormItem columns={4} label="Score">
<ManagedInput placeholder="Score" formKey="score" />
</FormItem>
<FormItem columns={12} label="Cases Settings" disableLabel>
{cases && <Switch label="Use Subtasks" onChange={() => dispatch({ type: 'CONFIG_SUBTASKS_SWITCH', value: true })} />}
{
subtasks || cases
? subtasks && <ul>{subtasks.map((k, v) => <SubtasksTable data={k} index={v} key={k.id} />)}</ul>
|| cases && (<CasesTable index={-1} />)
: (
<a
onClick={() => dispatch({ type: 'CONFIG_AUTOCASES_UPDATE' })}
><span className="icon icon-settings"> {i18n('Auto Read Tasks')}</span>
</a>
)
}
</FormItem>
</div>
</FormItem>
);
}
function LangConfig() {
const [showTab, setshowTab] = useState(false);
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="LangsTabs" disableLabel>
<Switch checked={showTab} label="Langs Config" onChange={() => setshowTab(!showTab)} />
<FormItem columns={12} label="langs" style={!showTab ? { display: 'none' } : {}}>
<CustomSelectAutoComplete
ref={ref}
data={data}
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>
</FormItem>
);
}
export default function ProblemConfigForm() {
return (
<div className="row">
<BasicInfo />
<TaskConfig />
<ExtraFilesConfig />
<LangConfig />
</div>
);
}
@@ -0,0 +1,73 @@
import yaml from 'js-yaml';
import type { ProblemConfigFile } from 'hydrooj/src/interface';
let autocases = { subtasks: [] };
export default function reducer(state = { type: 'default' } as ProblemConfigFile, action): ProblemConfigFile {
switch (action.type) {
case 'CONFIG_LOAD_FULFILLED': {
// TODO set yaml schema
autocases = action.payload.autocases;
return { ...state, ...yaml.load(action.payload.config) as object };
}
case 'CONFIG_FORM_UPDATE': {
const next = { ...state, [action.key]: action.value };
if (!action.value || (typeof action.value === 'object' && !action.value.join(''))) delete next[action.key];
if (action.key === 'type' && action.value !== 'default') {
delete next.checker_type;
delete next.checker;
}
if (action.key === 'type' && action.value !== 'interactive') delete next.interactor;
if (action.key === 'checker_type' && ['default', 'strict'].includes(action.value)) delete next.checker;
return next;
}
case 'CONFIG_CODE_UPDATE': {
try {
return yaml.load(action.payload);
} catch {
return state;
}
}
case 'CONFIG_AUTOCASES_UPDATE': {
const next = { ...state };
if (autocases.subtasks.length === 1) {
next.score = autocases.subtasks[0].score;
next.cases = [];
autocases.subtasks[0].cases.map((i) => next.cases.push({
time: autocases.subtasks[0].time, memory: autocases.subtasks[0].memory, input: i.input, output: i.output,
}));
} else {
next.subtasks = autocases.subtasks.map((subtask) => (
{ ...subtask, ...{ cases: subtask.cases.map((i) => ({ input: i.input, output: i.output })) } }));
}
return next;
}
case 'CONFIG_SUBTASKS_SWITCH': {
const next = { ...state };
next.subtasks = next.cases;
if (!next.subtasks.length) next.subtasks.push({ id: 0 });
delete next.cases;
return next;
}
case 'CONFIG_CASES_UPDATE': {
const next = { ...state };
if (action.key === 'cases-add') next.cases.push(action.value);
else if (action.key === 'cases-delete') {
next.cases = next.cases.filter((k, v) => v !== action.value);
}
return next;
}
case 'CONFIG_SUBTASK_UPDATE': {
const next = { ...state };
if (action.key === 'add') next.subtasks.splice(action.id, 0, { id: 0 });
else if (action.key === 'delete') delete next.subtasks[action.key];
else if (action.key === 'cases-add') next.subtasks[action.id].cases.push(action.value);
else if (action.key === 'cases-delete') {
next.subtasks[action.id].cases = next.subtasks[action.id].cases.filter((k, v) => v !== action.value);
} else next.subtasks[action.id][action.key] = action.value;
return next;
}
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': {
return state.filter((i) => i.name !== action.payload.name);
}
case 'CONFIG_ADD_TESTDATA': {
state.push(action.payload);
return state;
}
default:
return state;
}
}
+2
View File
@@ -268,6 +268,7 @@ domain: 域
Domain: 域
Don't have an account?: 还没有账户?
Done: 已结束
Download All: 下载全部
Download Dataset: 下载数据集
Download Selected: 下载选中
Download: 下载
@@ -414,6 +415,7 @@ Is rated: 计入排名
It works!: 它成了!
Italic: 斜体
Join: 加入
Judge Config: 评测设置
Judge Status: 评测状态
judge_playground: 评测游乐场
Judged At: 评测时间
@@ -0,0 +1,23 @@
.page--problem_config
.section--problem-sidebar-testdata
.problem-sidebar-testdata__detail
height: 270px
overflow-y: auto
&.collapsed .collapsed--hidden,
&.expanded .expanded--hidden
display: none
&.collapsed .problem-sidebar-testdata__detail
display: none
.col--size
width: 80px
.col--operation
width: 40px
.textbox,.select
margin: 0
.bp3-tab-panel
margin-top: 5px
@@ -0,0 +1,73 @@
import { NamedPage } from 'vj/misc/Page';
import { slideDown, slideUp } from 'vj/utils/slide';
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';
async function handleSection(ev: JQuery.ClickEvent<Document, undefined, any, any>, type: string) {
const $section = $(ev.currentTarget).closest('.section--problem-sidebar-testdata');
if ($section.is(`.${type}d, .animating`)) return;
$section.addClass('animating');
const $detail = $section.find('.problem-sidebar-testdata__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');
}
const page = new NamedPage('problem_config', () => {
async function uploadConfig(config:object) {
const configYaml = yaml.dump(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 } = await import('vj/components/problemconfig/ProblemConfigEditor');
const { default: ProblemConfigForm } = await import('vj/components/problemconfig/ProblemConfigForm');
const { default: ProblemConfigReducer } = await import('vj/components/problemconfig/reducer');
const {
React, render, Provider, store,
} = await loadReactRedux(ProblemConfigReducer);
store.dispatch({
type: 'CONFIG_LOAD',
payload: request.get(),
});
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__section__expand"]', (ev) => handleSection(ev, 'expand'));
$(document).on('click', '[name="testdata__section__collapse"]', (ev) => handleSection(ev, 'collapse'));
});
export default page;
@@ -7,6 +7,7 @@ import { 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';
const categories = {};
const dirtyCategories = [];
@@ -143,6 +144,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) => {
@@ -232,4 +248,8 @@ export default new NamedPage(['problem_create', 'problem_edit'], (pagename) => {
ev.preventDefault();
}
});
$(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,29 @@
background: #f8f8f8;
&.tab--active
border-color: #ed5f82;
border-color: #ed5f82;
.section--problem-sidebar-tags
&.collapsed .collapsed--hidden,
&.expanded .expanded--hidden
display: none
&.collapsed .problem-sidebar-tags__detail
display: none
.section--problem-sidebar-additional_file
.problem-sidebar-additional_file__detail
height: 270px
overflow-y: auto
&.collapsed .collapsed--hidden,
&.expanded .expanded--hidden
display: none
&.collapsed .problem-sidebar-additional_file__detail
display: none
.col--size
width: 80px
.col--operation
width: 40px
@@ -0,0 +1,57 @@
{% 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 {{ 'expanded' if page_name == 'problem_edit' else 'collapsed'}}">
<div class="section__header">
<h1 class="section__title">{{ additional_file.length if additional_file.length else 0 }} {{_('Testdata')}}</h1>
</div>
<ol class="menu">
<li class="menu__item flex-row">
<a class="menu__link half" href="javascript:;" name="create_file">
<span class="icon icon-add"></span> {{ _('Create') }}
</a>
<a class="menu__link half" href="javascript:;" name="upload_file">
<span class="icon icon-upload"></span> {{ _('Upload') }}
</a>
</li>
<li class="menu__seperator"></li>
<li class="menu__item">
<a class="menu__link expanded--hidden" href="javascript:;" name="additional_file__section__expand">
<span class="icon icon-expand_more"></span> {{ _('expand') }}
</a>
<a class="menu__link collapsed--hidden" href="javascript:;" name="additional_file__section__collapse">
<span class="icon icon-expand_less"></span> {{ _('collapse') }}
</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">
<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="{{ file.name }}"><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,58 @@
{% import "components/nothing.html" as nothing with context %}
{% import "components/problem.html" as problem with context %}
<div class="section side section--problem-sidebar-testdata collapsed">
<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="create_file">
<span class="icon icon-add"></span> {{ _('Create') }}
</a>
<a class="menu__link half" href="javascript:;" name="upload_file">
<span class="icon icon-upload"></span> {{ _('Upload') }}
</a>
</li>
<li class="menu__seperator"></li>
<li class="menu__item flex-row">
<a class="menu__link half" href="javascript:;" name="upload_file">
<span class="icon icon-download"></span> {{ _('Download All') }}
</a>
<a class="menu__link half expanded--hidden" href="javascript:;" name="testdata__section__expand">
<span class="icon icon-expand_more"></span> {{ _('expand') }}
</a>
<a class="menu__link half collapsed--hidden" href="javascript:;" name="testdata__section__collapse">
<span class="icon icon-expand_less"></span> {{ _('collapse') }}
</a>
</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">
<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="{{ file.name }}"><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')}}</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 %}
@@ -92,14 +92,23 @@
</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 {{ 'collapsed' if page_name == 'problem_edit' else 'expanded'}}">
<div class="section__header">
<h1 class="section__title">{{ _('Categories') }} ({{ _('click to add') }})</h1>
<div class="section__tools">
<a class="expanded--hidden" href="javascript:;" name="tags__section__expand">
<span class="icon icon-expand_more"></span>
</a>
<a class="collapsed--hidden" href="javascript:;" name="tags__section__collapse">
<span class="icon icon-expand_less"></span>
</a>
</div>
</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" %}
+147
View File
@@ -1,5 +1,44 @@
import { findFileSync, 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],
},
];
// 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 = {
@@ -98,3 +137,111 @@ export function convertIniConfig(ini: string) {
}
return res;
}
export async function readCasesFromFiles(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' || checkFile(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;
}
export async function readSubtasksFromFiles(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' || checkFile(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;
}