ui: UI&UX enhance, bug fixes

Co-authored-by: panda <panda_dtdyy@outlook.com>
This commit is contained in:
undefinedandpanda authored and panda committed 2022-05-10 01:55:20 +08:00
1 parent 3f37b02b79
commit 310189bc4d
28 files changed
+770 -614

No files matched your search

+4 -1
View File
@@ -25,7 +25,7 @@
"debug": "node --trace-warnings --async-stack-traces --trace-deprecation node_modules/hydrooj/bin/hydrooj --debug --template", "debug": "node --trace-warnings --async-stack-traces --trace-deprecation node_modules/hydrooj/bin/hydrooj --debug --template",
"start:all": "node build/start", "start:all": "node build/start",
"start": "node_modules/.bin/hydrooj", "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", "version": "1.0.0",
"license": "AGPL-3.0-only", "license": "AGPL-3.0-only",
@@ -64,5 +64,8 @@
"supertest": "^6.2.2", "supertest": "^6.2.2",
"typedoc": "^0.22.13", "typedoc": "^0.22.13",
"typescript": "4.6.2" "typescript": "4.6.2"
},
"resolutions": {
"esbuild": "0.14.3"
} }
} }
+9 -10
View File
@@ -3,7 +3,7 @@ import fs from 'fs-extra';
import yaml from 'js-yaml'; import yaml from 'js-yaml';
import { max, sum } from 'lodash'; import { max, sum } from 'lodash';
import readYamlCases, { convertIniConfig } from '@hydrooj/utils/lib/cases'; import readYamlCases, { convertIniConfig } from '@hydrooj/utils/lib/cases';
import { readCasesFromFiles, readSubtasksFromFiles } from '@hydrooj/utils/lib/common'; import { readSubtasksFromFiles } from '@hydrooj/utils/lib/common';
import { changeErrorType } from '@hydrooj/utils/lib/utils'; import { changeErrorType } from '@hydrooj/utils/lib/utils';
import { getConfig } from './config'; import { getConfig } from './config';
import { FormatError, SystemError } from './error'; import { FormatError, SystemError } from './error';
@@ -28,8 +28,7 @@ async function readAutoCases(folder: string, { next }, cfg, rst) {
const outputs = await fs.readdir(path.resolve(folder, 'output')); const outputs = await fs.readdir(path.resolve(folder, 'output'));
files.push(...outputs.map((i) => `output/${i}`)); files.push(...outputs.map((i) => `output/${i}`));
} }
let result = await readCasesFromFiles(files, checkFile, cfg); const result = readSubtasksFromFiles(files, checkFile, cfg, rst);
if (!result.count) result = await readSubtasksFromFiles(files, checkFile, cfg, rst);
Object.assign(config, result); Object.assign(config, result);
if (cfg.isSelfSubmission) next({ message: { message: 'Found {0} testcases.', params: [config.count] } }); if (cfg.isSelfSubmission) next({ message: { message: 'Found {0} testcases.', params: [config.count] } });
} catch (e) { } catch (e) {
@@ -42,11 +41,11 @@ function isValidConfig(config) {
if (config.count > (getConfig('testcases_max') || 100)) { if (config.count > (getConfig('testcases_max') || 100)) {
throw new FormatError('Too many testcases. Cancelled.'); 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) { if (time > (getConfig('total_time_limit') || 60) * 1000) {
throw new FormatError('Total time limit longer than {0}s. Cancelled.', [+getConfig('total_time_limit') || 60]); 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 (memMax > parseMemoryMB(getConfig('memoryMax'))) throw new FormatError('Memory limit larger than memory_max');
if (!['default', 'strict'].includes(config.checker_type || 'default') && !config.checker) { if (!['default', 'strict'].includes(config.checker_type || 'default') && !config.checker) {
throw new FormatError('You did not specify a checker.'); throw new FormatError('You did not specify a checker.');
@@ -57,18 +56,18 @@ export default async function readCases(folder: string, cfg: Record<string, any>
const iniConfig = path.resolve(folder, 'config.ini'); const iniConfig = path.resolve(folder, 'config.ini');
const yamlConfig = path.resolve(folder, 'config.yaml'); const yamlConfig = path.resolve(folder, 'config.yaml');
const ymlConfig = path.resolve(folder, 'config.yml'); const ymlConfig = path.resolve(folder, 'config.yml');
let config; const config = { ...cfg };
if (fs.existsSync(yamlConfig)) { if (fs.existsSync(yamlConfig)) {
config = { ...cfg, ...yaml.load(fs.readFileSync(yamlConfig).toString()) as object }; Object.assign(config, yaml.load(fs.readFileSync(yamlConfig).toString()));
} else if (fs.existsSync(ymlConfig)) { } else if (fs.existsSync(ymlConfig)) {
config = { ...cfg, ...yaml.load(fs.readFileSync(ymlConfig).toString()) as object }; Object.assign(config, yaml.load(fs.readFileSync(ymlConfig).toString()));
} else if (fs.existsSync(iniConfig)) { } else if (fs.existsSync(iniConfig)) {
try { try {
config = { ...cfg, ...convertIniConfig(fs.readFileSync(iniConfig).toString()) }; Object.assign(config, convertIniConfig(fs.readFileSync(iniConfig).toString()));
} catch (e) { } catch (e) {
throw changeErrorType(e, FormatError); throw changeErrorType(e, FormatError);
} }
} else config = cfg; }
let result; let result;
try { try {
result = await readYamlCases(config, ensureFile(folder)); result = await readYamlCases(config, ensureFile(folder));
+1
View File
@@ -178,6 +178,7 @@ export async function service(pending: string[], fail: string[]) {
} }
} }
for (const key in global.Hydro.service) { for (const key in global.Hydro.service) {
if (key === 'server') continue;
const srv = global.Hydro.service[key]; const srv = global.Hydro.service[key];
if (!srv.started && srv.start) await srv.start(); if (!srv.started && srv.start) await srv.start();
} }
+5 -4
View File
@@ -127,10 +127,11 @@ export interface FileInfo {
} }
export interface TestCaseConfig { export interface TestCaseConfig {
input: string, input: string;
output: string, output: string;
time?: number, time?: number;
memory?: number, memory?: number;
score?: number;
} }
export enum ProblemType { export enum ProblemType {
+2 -1
View File
@@ -81,7 +81,7 @@ module.exports = {
indent: [ indent: [
'error', 'error',
2, 2,
{ SwitchCase: 0 }, { SwitchCase: 1 },
], ],
'max-len': ['error', 150], 'max-len': ['error', 150],
quotes: 'warn', quotes: 'warn',
@@ -115,6 +115,7 @@ module.exports = {
'no-underscore-dangle': 'off', 'no-underscore-dangle': 'off',
'no-await-in-loop': 'off', 'no-await-in-loop': 'off',
'no-lonely-if': 'off', 'no-lonely-if': 'off',
'no-nested-ternary': 'off',
'no-param-reassign': 'off', 'no-param-reassign': 'off',
'no-script-url': 'off', 'no-script-url': 'off',
'no-bitwise': 'off', 'no-bitwise': 'off',
@@ -53,7 +53,7 @@
white-space: nowrap white-space: nowrap
text-overflow: ellipsis text-overflow: ellipsis
&>span.bp3-icon>svg &>span.bp4-icon>svg
font-size: 12px font-size: 12px
cursor: pointer cursor: pointer
padding: 4px padding: 4px
@@ -78,19 +78,19 @@
&>div &>div
flex-grow: 1 flex-grow: 1
&>span.bp3-icon>svg &>span.bp4-icon>svg
color: transparent color: transparent
&[aria-selected='true'] &[aria-selected='true']
background-color: #fafafa background-color: #fafafa
font-weight: 600 font-weight: 600
&>span.bp3-icon>svg &>span.bp4-icon>svg
color: #1890ff color: #1890ff
&[data-focus='true'] &[data-focus='true']
background-color: #e6f7ff background-color: #e6f7ff
cursor: pointer cursor: pointer
&>span.bp3-icon>svg &>span.bp4-icon>svg
color: currentColor color: currentColor
@@ -33,7 +33,7 @@ CustomSelectAutoComplete.propTypes = {
allowEmptyQuery: PropTypes.bool, allowEmptyQuery: PropTypes.bool,
freeSolo: PropTypes.bool, freeSolo: PropTypes.bool,
freeSoloConverter: PropTypes.func, freeSoloConverter: PropTypes.func,
data: PropTypes.any, data: PropTypes.arrayOf(PropTypes.any),
}; };
CustomSelectAutoComplete.displayName = 'CustomSelectAutoComplete'; 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;
@@ -8,6 +8,9 @@ const problemConfigSchemaDef = {
properties: { properties: {
input: { type: 'string' }, input: { type: 'string' },
output: { type: 'string' }, output: { type: 'string' },
time: { $ref: '#/def/time' },
memory: { $ref: '#/def/memory' },
score: { $ref: '#/def/score', description: 'score' },
}, },
required: ['input'], required: ['input'],
additionalProperties: false, additionalProperties: false,
@@ -28,7 +31,7 @@ const problemConfigSchemaDef = {
additionalProperties: false, additionalProperties: false,
}, },
time: { type: 'string', pattern: '^([0-9]+(?:\\.[0-9]*)?)([mu]?)s?$' }, 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 }, score: { type: 'integer', maximum: 100, minimum: 1 },
}; };
@@ -37,7 +40,7 @@ const problemConfigSchema = {
type: 'object', type: 'object',
def: problemConfigSchemaDef, def: problemConfigSchemaDef,
properties: { 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}' }, key: { type: 'string', pattern: '[0-9a-f]{32}' },
type: { enum: ['default', 'interactive', 'submit_answer', 'objective', 'remote_judge'] }, type: { enum: ['default', 'interactive', 'submit_answer', 'objective', 'remote_judge'] },
subType: { type: 'string' }, 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 })}
/>
);
}
@@ -3,6 +3,7 @@ import type { editor } from 'monaco-editor';
import { connect } from 'react-redux'; import { connect } from 'react-redux';
import { load } from 'vj/components/monaco/loader'; import { load } from 'vj/components/monaco/loader';
import Editor from 'vj/components/editor'; import Editor from 'vj/components/editor';
import { diffLines } from 'diff';
import yaml from 'js-yaml'; import yaml from 'js-yaml';
import type { ProblemConfigFile, TestCaseConfig } from 'hydrooj/src/interface'; import type { ProblemConfigFile, TestCaseConfig } from 'hydrooj/src/interface';
@@ -41,6 +42,7 @@ function configYamlFormat(config: ProblemConfigFile) {
const formatConfig: ProblemConfigFile = {}; const formatConfig: ProblemConfigFile = {};
configKey.forEach((key) => { configKey.forEach((key) => {
if (config[key] !== undefined) { 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_type' && config.type !== 'default') return;
if (key === 'checker' if (key === 'checker'
&& (['default', 'strict'].includes(formatConfig.checker_type) || formatConfig.checker_type === undefined)) return; && (['default', 'strict'].includes(formatConfig.checker_type) || formatConfig.checker_type === undefined)) return;
@@ -71,6 +73,11 @@ function configYamlFormat(config: ProblemConfigFile) {
} else formatConfig[key] = config[key]; } 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; return formatConfig;
} }
@@ -87,7 +94,7 @@ export default connect(mapStateToProps, mapDispatchToProps)(class MonacoEditor e
async componentDidMount() { async componentDidMount() {
const { monaco } = await load(['yaml']); const { monaco } = await load(['yaml']);
const uri = monaco.Uri.parse('hydro://problem/file/config.yaml'); const uri = monaco.Uri.parse('hydro://problem/file/config.yaml');
this.model = monaco.editor.createModel(yaml.dump(this.props.config), 'yaml', uri); this.model = monaco.editor.createModel(yaml.dump(configYamlFormat(this.props.config)), 'yaml', uri);
this.vjEditor = Editor.getOrConstruct($(this.containerElement), { this.vjEditor = Editor.getOrConstruct($(this.containerElement), {
language: 'yaml', language: 'yaml',
model: this.model, model: this.model,
@@ -101,17 +108,28 @@ export default connect(mapStateToProps, mapDispatchToProps)(class MonacoEditor e
} }
componentDidUpdate(prevProps) { componentDidUpdate(prevProps) {
if (this.__preventUpdate) return; if (this.__preventUpdate || !this.model) return;
if (yaml.dump(prevProps.config) !== yaml.dump(this.props.config)) { if (yaml.dump(prevProps.config) !== yaml.dump(this.props.config)) {
this.__preventFormat = true; this.__preventFormat = true;
this.model?.pushEditOperations( const curValue = this.model.getValue();
[], const diff = diffLines(curValue, yaml.dump(configYamlFormat(this.props.config)));
[{ const ops = [];
range: this.model.getFullModelRange(), let cursor = 1;
text: yaml.dump(configYamlFormat(this.props.config)), for (const line of diff) {
}], if (line.added) {
undefined, 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; this.__preventFormat = false;
} }
} }
@@ -1,153 +1,36 @@
import React, { useState } from 'react'; import React, { useState } from 'react';
import { useDispatch, useSelector } from 'react-redux'; import { useDispatch, useSelector } from 'react-redux';
import i18n from 'vj/utils/i18n'; import i18n from 'vj/utils/i18n';
import { isEqual } from 'lodash'; import {
import { Switch, Tab, Tabs } from '@blueprintjs/core'; Card, Switch, Tab, Tabs, InputGroup, Tag,
import type { SubtaskConfig, TestCaseConfig } from 'hydrooj/src/interface'; } from '@blueprintjs/core';
import type { RootState } from './reducer/index'; import type { RootState } from './reducer/index';
import CustomSelectAutoComplete from '../autocomplete/components/CustomSelectAutoComplete'; import CustomSelectAutoComplete from '../autocomplete/components/CustomSelectAutoComplete';
import FileSelectAutoComplete from '../autocomplete/components/FileSelectAutoComplete';
import { FormItem } from './BasicForm';
import ProblemType from './ProblemType';
import { TaskConfig } from './SubtaskTable';
const SelectValue = { function FileIOConfig() {
type: ['default', 'interactive', 'submit_answer', 'objective'], const filename = useSelector((state: RootState) => state.config.filename);
checker_type: ['default', 'lemon', 'syzoj', 'hustoj', 'testlib', 'qduoj'], const dispatch = useDispatch();
task_type: ['min', 'max', 'sum'],
};
const eq = (a: TestCaseConfig, b: TestCaseConfig) => isEqual(a, b);
const eqId = (a: SubtaskConfig[], b: SubtaskConfig[]) => {
if (a.length !== b.length) return true;
for (let i = 0; i < a.length; i++) {
if (a[i].id !== b[i].id) return true;
}
return false;
};
function FormItem({
columns, label, children, helpText = '', disableLabel = false, ...props
}) {
return ( return (
<div {...props} className={`${columns && `medium-${columns}`} columns form__item`}> <FormItem columns={12} label="FileIOConfig" disableLabel>
<label htmlFor={`${label}-form`}> <Card style={{ padding: 10 }}>
{!disableLabel && i18n(label)} <div className="row">
{children} <FormItem columns={6} label="FileIO">
{helpText && (<p className="help-text">{i18n(helpText)}</p>)} <InputGroup
</label> rightElement={<Tag minimal>.in/.out</Tag>}
value={filename || ''}
onChange={(ev) => {
dispatch({ type: 'CONFIG_FORM_UPDATE', key: 'filename', value: ev.currentTarget.value });
}}
fill
/>
</FormItem>
</div> </div>
); </Card>
}
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>
<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={i18n('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>
</>
); );
} }
@@ -166,7 +49,7 @@ function ExtraFilesConfig() {
id="user_extra_files" id="user_extra_files"
title={i18n('user_extra_files')} title={i18n('user_extra_files')}
panel={( panel={(
<CustomSelectAutoComplete <FileSelectAutoComplete
data={Files} data={Files}
selectedKeys={userExtraFiles || []} selectedKeys={userExtraFiles || []}
onChange={(val) => dispatch({ type: 'CONFIG_FORM_UPDATE', key: 'user_extra_files', value: val.split(',') })} onChange={(val) => dispatch({ type: 'CONFIG_FORM_UPDATE', key: 'user_extra_files', value: val.split(',') })}
@@ -178,7 +61,7 @@ function ExtraFilesConfig() {
id="judge_extra_files" id="judge_extra_files"
title={i18n('judge_extra_files')} title={i18n('judge_extra_files')}
panel={( panel={(
<CustomSelectAutoComplete <FileSelectAutoComplete
data={Files} data={Files}
selectedKeys={judgeExtraFiles || []} selectedKeys={judgeExtraFiles || []}
onChange={(val) => dispatch({ type: 'CONFIG_FORM_UPDATE', key: 'judge_extra_files', value: val.split(',') })} onChange={(val) => dispatch({ type: 'CONFIG_FORM_UPDATE', key: 'judge_extra_files', value: val.split(',') })}
@@ -192,261 +75,6 @@ function ExtraFilesConfig() {
); );
} }
function CasesSubCasesTable({ index, subindex }) {
const subcases = useSelector((state: RootState) => (index === -1
? state.config.cases[subindex] : state.config.subtasks[index].cases[subindex]), eq);
const Files = useSelector((state: RootState) => state.testdata);
const dispatch = useDispatch();
const dispatcher = (casesKey: string): React.ChangeEventHandler<HTMLInputElement | HTMLSelectElement> => (ev) => {
dispatch({
type: index === -1 ? 'CONFIG_CASES_UPDATE' : 'CONFIG_SUBTASK_UPDATE',
id: index,
key: 'cases-edit',
casesId: subindex,
casesKey,
value: ev.currentTarget.value,
});
};
const dispatcher1 = (casesKey: string): React.ChangeEventHandler<HTMLInputElement | HTMLSelectElement> => (val) => {
dispatch({
type: index === -1 ? 'CONFIG_CASES_UPDATE' : 'CONFIG_SUBTASK_UPDATE',
id: index,
key: 'cases-edit',
casesId: subindex,
casesKey,
value: val,
});
};
return (
<tr>
{
index === -1 && (
<>
<td>
<input
value={subcases.time || ''}
onChange={dispatcher('time')}
className="textbox"
/>
</td>
<td>
<input
value={subcases.memory || ''}
onChange={dispatcher('memory')}
className="textbox"
/>
</td>
</>
)
}
<td>
<CustomSelectAutoComplete
width="100%"
data={Files}
selectedKeys={[subcases.input]}
onChange={dispatcher1('input')}
/>
</td>
<td>
<CustomSelectAutoComplete
width="100%"
data={Files}
selectedKeys={[subcases.output]}
onChange={dispatcher1('output')}
/>
</td>
<td>
<a
onClick={() => dispatch({
type: index === -1 ? 'CONFIG_CASES_UPDATE' : 'CONFIG_SUBTASK_UPDATE', id: index, key: 'cases-delete', value: subindex,
})}
><span className="icon icon-close"></span>
</a>
</td>
</tr>
);
}
function CasesTable({ index }) {
const casesLength = useSelector((state: RootState) => (index === -1 ? state.config.cases?.length : state.config.subtasks[index].cases?.length));
const dispatch = useDispatch();
return (
<table className="data-table">
<thead>
<tr>
{
index === -1 && (
<>
<th>
{i18n('Time')}
</th>
<th>
{i18n('Memory')}
</th>
</>
)
}
<th>
{i18n('Input')}
</th>
<th>
{i18n('Output')}
</th>
<th className="col--operation">
<a
onClick={() => dispatch({
type: index === -1 ? 'CONFIG_CASES_UPDATE' : 'CONFIG_SUBTASK_UPDATE',
id: index,
key: 'cases-add',
value: index === -1 ? {
time: 1000, memory: 256, input: '', output: '',
} : { input: '', output: '' },
})}
><span className="icon icon-add" />
</a>
</th>
</tr>
</thead>
<tbody>
{casesLength && [...Array(casesLength).keys()].map((i) => <CasesSubCasesTable index={index} subindex={i} key={i} />)}
</tbody>
</table>
);
}
function SubtasksIds({ index }) {
const subtasks = useSelector((state: RootState) => state.config.subtasks, eqId);
const subtaskIf = useSelector((state: RootState) => state.config.subtasks[index].if);
const dispatch = useDispatch();
const subtasksIds = subtasks.map((i) => i.id).filter((i) => i !== undefined);
return (
<tr>
<td>if</td>
<td colSpan={4}>
<CustomSelectAutoComplete
data={subtasksIds}
selectedKeys={subtaskIf?.join(',').split(',') || []}
itemKey={(item) => `${item}`}
onChange={(val) => dispatch({
type: 'CONFIG_SUBTASK_UPDATE', id: index, key: 'if', value: val.split(','),
})}
multi
/>
</td>
</tr>
);
}
function SubtasksTable({ index }) {
const subtask = (key: string) => useSelector((state: RootState) => state.config.subtasks[index][key]);
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 (
<>
<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>
<td>
<input
value={subtask('id')?.toString() || ''}
onChange={dispatcher('id')}
className="textbox"
/>
</td>
<td>
<input
value={subtask('score') || ''}
onChange={dispatcher('score')}
className="textbox"
/>
</td>
<td>
<select
value={subtask('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={subtask('time') || ''}
onChange={dispatcher('time')}
className="textbox"
/>
</td>
<td>
<input
value={subtask('memory') || ''}
onChange={dispatcher('memory')}
className="textbox"
/>
</td>
</tr>
<SubtasksIds index={index} />
</tbody>
</table>
<CasesTable index={index} />
</>
);
}
function TaskConfig({ onAutoLoad }) {
const subtasksLength = useSelector((state: RootState) => state.config.subtasks?.length);
const casesLength = useSelector((state: RootState) => state.config.cases?.length);
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>
{casesLength && <Switch label={i18n('Use Subtasks Mode')} onChange={() => dispatch({ type: 'CONFIG_SUBTASKS_SWITCH', value: true })} />}
{
subtasksLength || casesLength
? subtasksLength && [...Array(subtasksLength).keys()].map((i) => <SubtasksTable index={i} key={i} />)
|| casesLength && (<CasesTable index={-1} />)
: (
<a onClick={() => onAutoLoad()}>
<span className="icon icon-settings"> {i18n('Auto Read Tasks')}</span>
</a>
)
}
</FormItem>
</div>
</FormItem>
);
}
function LangConfig() { function LangConfig() {
const [showTab, setshowTab] = useState(false); const [showTab, setshowTab] = useState(false);
const langs = useSelector((state: RootState) => state.config.langs) || []; const langs = useSelector((state: RootState) => state.config.langs) || [];
@@ -479,13 +107,19 @@ function LangConfig() {
); );
} }
export default function ProblemConfigForm({ onAutoLoad }) { export default function ProblemConfigForm() {
const Type = useSelector((state: RootState) => state.config.type);
return ( return (
<div className="row"> <div className="row problem-config-form">
<BasicInfo /> <ProblemType />
<TaskConfig onAutoLoad={onAutoLoad} /> {Type === 'default' && <FileIOConfig />}
{!['submit_answer', 'objective'].includes(Type) && (
<>
<TaskConfig />
<ExtraFilesConfig /> <ExtraFilesConfig />
<LangConfig /> <LangConfig />
</>
)}
</div> </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>暂不支持快捷配置此类题目。请参照文档操作。</p>)}
/>
</Tabs>
</Card>
</FormItem>
);
}
@@ -0,0 +1,139 @@
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 CustomSelectAutoComplete from '../autocomplete/components/CustomSelectAutoComplete';
import { FormItem, ManagedInput } 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.id).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,
});
};
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 || ''}
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).toString() : ''}
placeholder={parseTimeMS(defaultTime || '1000ms').toString()}
onValueChange={dispatcher('time', 'ms')}
buttonPosition="none"
fill
/>
</td>
<td>
<NumericInput
rightElement={<Tag minimal>MB</Tag>}
value={subtask.memory ? parseMemoryMB(subtask.memory).toString() : ''}
placeholder={parseMemoryMB(defaultMemory || '256m').toString()}
onValueChange={dispatcher('memory', 'MB')}
buttonPosition="none"
fill
/>
</td>
</tr>
<SubtasksIds index={index} />
</tbody>
</table>
<CasesTable index={index} />
</Card>
);
}
export function TaskConfig() {
const len = useSelector((state: RootState) => state.config.subtasks?.length);
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="TestCases" disableLabel>
{len && [...Array(len).keys()].map((i) => <SubtasksTable index={i} key={i} />)}
</FormItem>
</div>
</FormItem>
);
}
@@ -0,0 +1,123 @@
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,
});
};
return (
<tr>
<td>
<NumericInput
rightElement={<Tag minimal>ms</Tag>}
value={testcase.time ? parseTimeMS(testcase.time).toString() : ''}
placeholder={parseTimeMS(defaultTime || '1000ms').toString()}
onValueChange={dispatcher('time', 'ms')}
buttonPosition="none"
fill
/>
</td>
<td>
<NumericInput
rightElement={<Tag minimal>MB</Tag>}
value={testcase.memory ? parseMemoryMB(testcase.memory).toString() : ''}
placeholder={parseMemoryMB(defaultMemory || '256m').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>
{[...Array(len).keys()].map((i) => <TestCaseEntry index={index} subindex={i} key={i} />)}
</tbody>
</table>
);
}
@@ -2,15 +2,17 @@ import yaml from 'js-yaml';
import type { ProblemConfigFile } from 'hydrooj/src/interface'; import type { ProblemConfigFile } from 'hydrooj/src/interface';
import { cloneDeep } from 'lodash'; import { cloneDeep } from 'lodash';
export default function reducer(state = { type: 'default' } as ProblemConfigFile, action): ProblemConfigFile { type State = ProblemConfigFile & { __loaded: boolean };
export default function reducer(state = { type: 'default', __loaded: false } as State, action): State {
switch (action.type) { switch (action.type) {
case 'CONFIG_LOAD_FULFILLED': { case 'CONFIG_LOAD_FULFILLED': {
// TODO set yaml schema return { ...state, ...yaml.load(action.payload.config) as object, __loaded: true };
return { ...state, ...yaml.load(action.payload.config) as object };
} }
case 'CONFIG_FORM_UPDATE': { case 'CONFIG_FORM_UPDATE': {
const next = { ...state, [action.key]: action.value }; const next = { ...state, [action.key]: action.value };
if (action.key === 'score' && action.value) next.score = +next.score; 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]; if (!action.value || (typeof action.value === 'object' && !action.value.join(''))) delete next[action.key];
return next; return next;
} }
@@ -24,14 +26,8 @@ export default function reducer(state = { type: 'default' } as ProblemConfigFile
case 'CONFIG_AUTOCASES_UPDATE': { case 'CONFIG_AUTOCASES_UPDATE': {
const next = { ...state }; const next = { ...state };
const autocases = action.value; const autocases = action.value;
if (autocases.subtasks.length === 0) next.cases = []; if (autocases.subtasks.length === 0) next.subtasks = [];
else if (autocases.subtasks.length === 1) { else {
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) => ( next.subtasks = autocases.subtasks.map((subtask) => (
{ ...subtask, ...{ cases: subtask.cases.map((i) => ({ input: i.input, output: i.output })) } })); { ...subtask, ...{ cases: subtask.cases.map((i) => ({ input: i.input, output: i.output })) } }));
} }
@@ -46,37 +42,26 @@ export default function reducer(state = { type: 'default' } as ProblemConfigFile
delete next.cases; delete next.cases;
return next; return next;
} }
case 'CONFIG_CASES_UPDATE': {
if (['time', 'memory'].includes(action.casesKey) && action.value !== '') action.value = +action.value;
if (action.key === 'cases-add') return { ...state, cases: [...state.cases, action.value] };
if (action.key === 'cases-delete') {
return { ...state, cases: state.cases.filter((k, v) => v !== action.value) };
}
const cases = cloneDeep(state.cases);
cases[action.casesId][action.casesKey] = action.value;
if (action.value === '') delete cases[action.casesId][action.casesKey];
return { ...state, cases };
}
case 'CONFIG_SUBTASK_UPDATE': { case 'CONFIG_SUBTASK_UPDATE': {
const subtasks = cloneDeep(state.subtasks); const subtasks = cloneDeep(state.subtasks);
const subsubtasks = cloneDeep(state.subtasks[action.id]); const subsubtasks = cloneDeep(state.subtasks[action.id]);
if (action.value !== '' if (action.value !== '' && ['score', 'id'].includes(action.key)) action.value = +action.value;
&& (['time', 'memory'].includes(action.casesKey) || ['time', 'memory', '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 === 'if' && action.value.join('') !== '') action.value = action.value.map((i) => +i);
if (action.key.split('-')[0] === 'cases') { if (action.key.split('-')[0] === 'cases') {
if (action.key === 'cases-add') subsubtasks.cases.push(action.value); if (action.key === 'cases-add') subsubtasks.cases.push(action.value);
else if (action.key === 'cases-edit') subsubtasks.cases[action.casesId][action.casesKey] = action.value; else if (action.key === 'cases-edit') {
else if (action.key === 'cases-delete') { 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); subsubtasks.cases = subsubtasks.cases.filter((k, v) => v !== action.value);
} }
} else if (action.key === 'add') subtasks.push({ time: 1000, memory: 256, cases: [] }); } else if (action.key === 'add') subtasks.push({ cases: [] });
else if (action.key === 'delete') delete subtasks[action.key]; else if (action.key === 'delete') return { ...state, subtasks: subtasks.filter((k, v) => v !== action.id) };
else { else {
if (action.value === '' || (action.key === 'if' && action.value.join('') === '')) delete subsubtasks[action.key]; if (action.value === '' || (action.key === 'if' && action.value.join('') === '')) delete subsubtasks[action.key];
else subsubtasks[action.key] = action.value; else subsubtasks[action.key] = action.value;
} }
if (action.key !== 'delete') subtasks[action.id] = subsubtasks; subtasks[action.id] = subsubtasks;
return { ...state, subtasks }; return { ...state, subtasks };
} }
default: default:
+6 -6
View File
@@ -118,8 +118,8 @@ Change Email: 修改电子邮件
Change Password: 修改密码 Change Password: 修改密码
Change Theme: 切换主题 Change Theme: 切换主题
Check In: 签到 Check In: 签到
CheckerType: 比较器类型
Checker: 比较器 Checker: 比较器
CheckerType: 比较器类型
Chinese: 中文 Chinese: 中文
Choose the background image in your profile page.: 选择您资料页面的背景图片。 Choose the background image in your profile page.: 选择您资料页面的背景图片。
Claim homework: 认领作业 Claim homework: 认领作业
@@ -270,7 +270,6 @@ domain_user: 管理用户
domain: 域 domain: 域
Domain: 域 Domain: 域
Don't have an account?: 还没有账户? Don't have an account?: 还没有账户?
Don't ignore space and enter.: 不忽略行末回车与空格。
Done: 已结束 Done: 已结束
Download All: 下载全部 Download All: 下载全部
Download Dataset: 下载数据集 Download Dataset: 下载数据集
@@ -388,6 +387,7 @@ Host: 主持人
hour(s): 小时 hour(s): 小时
If enabled, source code will be emailed to you after the submission is accepted.: 如果启用,在您通过题目后,源代码会以 Email 的形式发送给您。 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.: 若留空,则将使用对应语言的内置代码模板。 If left blank, the built-in template of the corresponding language will be used.: 若留空,则将使用对应语言的内置代码模板。
Ignore trailing space and enter.: 忽略行末空格与文件尾回车。
Images With Size: 指定大小的图片 Images With Size: 指定大小的图片
Images: 图片 Images: 图片
Import from: 导入自 Import from: 导入自
@@ -423,15 +423,15 @@ Italic: 斜体
Join: 加入 Join: 加入
Judge Config: 评测设置 Judge Config: 评测设置
Judge Status: 评测状态 Judge Status: 评测状态
judge_playground: 评测游乐场
judge_extra_files: 评测额外文件 judge_extra_files: 评测额外文件
judge_playground: 评测游乐场
Judged At: 评测时间 Judged At: 评测时间
Judged By: 评测机 Judged By: 评测机
Judging Queue: 评测队列 Judging Queue: 评测队列
Keep current expiration: 保持当前过期设置 Keep current expiration: 保持当前过期设置
Langs Config: 语言设置
langs: 语言 langs: 语言
Language: 语言 Language: 语言
Langs Config: 语言设置
last active at: 最后活动于 last active at: 最后活动于
last login at: 最后登录于 last login at: 最后登录于
Last Submit At: 最后递交于 Last Submit At: 最后递交于
@@ -594,8 +594,8 @@ Problem Solution List: 题解列表
Problem Solution: 题解列表 Problem Solution: 题解列表
Problem Tags Visibility: 题目标签可见性 Problem Tags Visibility: 题目标签可见性
Problem Tags: 题目标签 Problem Tags: 题目标签
problem_create: 创建题目
problem_config: 评测设置 problem_config: 评测设置
problem_create: 创建题目
problem_edit: 编辑题目 problem_edit: 编辑题目
problem_import: 导入题目 problem_import: 导入题目
problem_main: 题库 problem_main: 题库
@@ -768,8 +768,8 @@ SuperUser: 超级管理员
Support: 支持 Support: 支持
Switch account: 切换账户 Switch account: 切换账户
Tags: 标签 Tags: 标签
tasks_list: 任务列表
Task Settings: 任务设置 Task Settings: 任务设置
tasks_list: 任务列表
Technical Information: 技术信息 Technical Information: 技术信息
Temporary session: 临时会话 Temporary session: 临时会话
Terms of Service: 服务条款 Terms of Service: 服务条款
+2 -1
View File
@@ -10,7 +10,7 @@
"lint": "eslint ." "lint": "eslint ."
}, },
"devDependencies": { "devDependencies": {
"@blueprintjs/core": "^3.54.0", "@blueprintjs/core": "^4.2.1",
"@fontsource/dm-mono": "^4.5.7", "@fontsource/dm-mono": "^4.5.7",
"@fontsource/fira-code": "^4.5.8", "@fontsource/fira-code": "^4.5.8",
"@fontsource/inconsolata": "^4.5.4", "@fontsource/inconsolata": "^4.5.4",
@@ -37,6 +37,7 @@
"clipboard": "^2.0.10", "clipboard": "^2.0.10",
"copy-webpack-plugin": "^6.4.1", "copy-webpack-plugin": "^6.4.1",
"css-loader": "^4.3.0", "css-loader": "^4.3.0",
"diff": "^5.0.0",
"diff-dom": "^4.2.3", "diff-dom": "^4.2.3",
"echarts": "^5.3.2", "echarts": "^5.3.2",
"emojis-keywords": "2.0.0", "emojis-keywords": "2.0.0",
+4 -4
View File
@@ -40,12 +40,12 @@ async function handleClickUpload(files) {
const dialog = new Dialog({ const dialog = new Dialog({
$body: ` $body: `
<div class="file-label" style="text-align: center; margin-bottom: 5px; color: gray; font-size: small;"></div> <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="bp4-progress-bar bp4-intent-primary bp4-no-stripes">
<div class="file-progress bp3-progress-meter" style="width: 0"></div> <div class="file-progress bp4-progress-meter" style="width: 0"></div>
</div> </div>
<div class="upload-label" style="text-align: center; margin: 5px 0; color: gray; font-size: small;"></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="bp4-progress-bar bp4-intent-primary bp4-no-stripes">
<div class="upload-progress bp3-progress-meter" style="width: 0"></div> <div class="upload-progress bp4-progress-meter" style="width: 0"></div>
</div>`, </div>`,
}); });
try { try {
@@ -15,9 +15,57 @@
width: 80px width: 80px
.col--operation .col--operation
width: 40px width: 40px
text-align: center
.textbox,.select .textbox,.select
margin: 0 margin: 0
.bp3-tab-panel .bp4-tab-panel
margin-top: 5px 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
input::placeholder
color: inherit
.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
@@ -7,7 +7,9 @@ import yaml from 'js-yaml';
import Notification from 'vj/components/notification'; import Notification from 'vj/components/notification';
import Dialog, { ConfirmDialog } from 'vj/components/dialog/index'; import Dialog, { ConfirmDialog } from 'vj/components/dialog/index';
import download from 'vj/components/zipDownloader'; import download from 'vj/components/zipDownloader';
import { size, readCasesFromFiles, readSubtasksFromFiles } from '@hydrooj/utils/lib/common'; import {
size, readSubtasksFromFiles, parseTimeMS, parseMemoryMB,
} from '@hydrooj/utils/lib/common';
import tpl from 'vj/utils/tpl'; import tpl from 'vj/utils/tpl';
async function handleSection(ev: JQuery.ClickEvent<Document, undefined, any, any>, type: string) { async function handleSection(ev: JQuery.ClickEvent<Document, undefined, any, any>, type: string) {
@@ -29,10 +31,6 @@ function onBeforeUnload(e) {
e.returnValue = ''; e.returnValue = '';
} }
function ensureFile(testdata) {
return (file: string) => testdata.filter((i) => i === file)[0];
}
const page = new NamedPage('problem_config', () => { const page = new NamedPage('problem_config', () => {
let reduxStore; let reduxStore;
@@ -46,12 +44,12 @@ const page = new NamedPage('problem_config', () => {
const dialog = new Dialog({ const dialog = new Dialog({
$body: ` $body: `
<div class="file-label" style="text-align: center; margin-bottom: 5px; color: gray; font-size: small;"></div> <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="bp4-progress-bar bp4-intent-primary bp4-no-stripes">
<div class="file-progress bp3-progress-meter" style="width: 0"></div> <div class="file-progress bp4-progress-meter" style="width: 0"></div>
</div> </div>
<div class="upload-label" style="text-align: center; margin: 5px 0; color: gray; font-size: small;"></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="bp4-progress-bar bp4-intent-primary bp4-no-stripes">
<div class="upload-progress bp3-progress-meter" style="width: 0"></div> <div class="upload-progress bp4-progress-meter" style="width: 0"></div>
</div>`, </div>`,
}); });
try { try {
@@ -161,9 +159,11 @@ const page = new NamedPage('problem_config', () => {
} }
async function mountComponent() { async function mountComponent() {
const { default: ProblemConfigEditor } = await import('vj/components/problemconfig/ProblemConfigEditor'); const [{ default: ProblemConfigEditor }, { default: ProblemConfigForm }, { default: ProblemConfigReducer }] = await Promise.all([
const { default: ProblemConfigForm } = await import('vj/components/problemconfig/ProblemConfigForm'); import('vj/components/problemconfig/ProblemConfigEditor'),
const { default: ProblemConfigReducer } = await import('vj/components/problemconfig/reducer'); import('vj/components/problemconfig/ProblemConfigForm'),
import('vj/components/problemconfig/reducer'),
]);
const { const {
React, render, Provider, store, React, render, Provider, store,
@@ -175,6 +175,26 @@ const page = new NamedPage('problem_config', () => {
type: 'CONFIG_LOAD', type: 'CONFIG_LOAD',
payload: request.get(), payload: request.get(),
}); });
const unsubscribe = store.subscribe(() => {
// TODO set yaml schema
const state = store.getState();
if (!state.config.__loaded) return;
if (state.config.subtasks || state.config.cases) return;
const testdata = (state.testdata || []).map((i) => i.name);
const checkFile = (file: string) => (testdata.includes(file) ? file : null);
unsubscribe();
const value = readSubtasksFromFiles(testdata, checkFile, state.config, {});
for (const subtask of value.subtasks) {
if (subtask.time === parseTimeMS(state.config.time || '1s')) delete subtask.time;
if (subtask.memory === parseMemoryMB(state.config.memory || '256m')) delete subtask.memory;
if (subtask.time) subtask.time += 'ms';
if (subtask.memory) subtask.memory += 'MB';
}
store.dispatch({
type: 'CONFIG_AUTOCASES_UPDATE',
value,
});
});
render( render(
<Provider store={store}> <Provider store={store}>
<div className="row"> <div className="row">
@@ -182,20 +202,7 @@ const page = new NamedPage('problem_config', () => {
<ProblemConfigEditor /> <ProblemConfigEditor />
</div> </div>
<div className="medium-7 columns"> <div className="medium-7 columns">
<ProblemConfigForm <ProblemConfigForm />
onAutoLoad={async () => {
const testdata = (reduxStore.getState().testdata || []).map((i) => i.name);
const checkFile = ensureFile(testdata);
let autocases = await readCasesFromFiles(testdata, checkFile, {});
if (!autocases.count) {
autocases = await readSubtasksFromFiles(testdata, checkFile, {}, { subtasks: [] });
}
reduxStore.dispatch({
type: 'CONFIG_AUTOCASES_UPDATE',
value: autocases,
});
}}
/>
</div> </div>
</div> </div>
<button className="rounded primary button" onClick={() => uploadConfig(store.getState().config)}>{i18n('Submit')}</button> <button className="rounded primary button" onClick={() => uploadConfig(store.getState().config)}>{i18n('Submit')}</button>
@@ -202,12 +202,12 @@ export default new NamedPage(['problem_create', 'problem_edit'], (pagename) => {
const dialog = new Dialog({ const dialog = new Dialog({
$body: ` $body: `
<div class="file-label" style="text-align: center; margin-bottom: 5px; color: gray; font-size: small;"></div> <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="bp4-progress-bar bp4-intent-primary bp4-no-stripes">
<div class="file-progress bp3-progress-meter" style="width: 0"></div> <div class="file-progress bp4-progress-meter" style="width: 0"></div>
</div> </div>
<div class="upload-label" style="text-align: center; margin: 5px 0; color: gray; font-size: small;"></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="bp4-progress-bar bp4-intent-primary bp4-no-stripes">
<div class="upload-progress bp3-progress-meter" style="width: 0"></div> <div class="upload-progress bp4-progress-meter" style="width: 0"></div>
</div>`, </div>`,
}); });
try { try {
@@ -51,12 +51,12 @@ const page = new NamedPage('problem_files', () => {
const dialog = new Dialog({ const dialog = new Dialog({
$body: ` $body: `
<div class="file-label" style="text-align: center; margin-bottom: 5px; color: gray; font-size: small;"></div> <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="bp4-progress-bar bp4-intent-primary bp4-no-stripes">
<div class="file-progress bp3-progress-meter" style="width: 0"></div> <div class="file-progress bp4-progress-meter" style="width: 0"></div>
</div> </div>
<div class="upload-label" style="text-align: center; margin: 5px 0; color: gray; font-size: small;"></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="bp4-progress-bar bp4-intent-primary bp4-no-stripes">
<div class="upload-progress bp3-progress-meter" style="width: 0"></div> <div class="upload-progress bp4-progress-meter" style="width: 0"></div>
</div>`, </div>`,
}); });
try { try {
@@ -11,14 +11,14 @@
<h1 class="section__title">{{ tdoc.title }}</h1> <h1 class="section__title">{{ tdoc.title }}</h1>
</div> </div>
<div class="section__body"> <div class="section__body">
<span class="bp3-tag bp3-large bp3-minimal problem__tag-item">{{ _(model.contest.statusText(tdoc)) }}</span> <span class="bp4-tag bp4-large bp4-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="bp4-tag bp4-large bp4-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="bp4-tag bp4-large bp4-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="bp4-tag bp4-large bp4-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="bp4-tag bp4-large bp4-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 icon icon-user--multiple">{{ tdoc['attend']|default(0) }}</span>
{% if tsdoc.attend %} {% 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 %} {% endif %}
</div> </div>
</div> </div>
@@ -49,22 +49,22 @@
</div> </div>
<div class="section__body"> <div class="section__body">
<ul class="problem__tags"> <ul class="problem__tags">
<span class="bp3-tag bp3-large bp3-minimal problem__tag-item">{{ _('ID') }}: {{ pdoc.docId }}</span> <span class="bp4-tag bp4-large bp4-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 icon icon-book">{{ _('problem_type.' + pdoc.config.type).format(pdoc.config.subType) }}</span>
{% if pdoc.config.type != 'objective' and pdoc.config.type != 'submit_answer' %} {% 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="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="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-comparison">{{ pdoc.config.memoryMax if pdoc.config.memoryMax == pdoc.config.memoryMin else pdoc.config.memoryMin+'~'+pdoc.config.memoryMax }}MiB</span>
{% endif %} {% endif %}
{% if not tdoc %} {% 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="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="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> <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="bp3-tag bp3-large bp3-minimal problem__tag-item">{{ _('Difficulty') }}: {{ pdoc['difficulty'] or lib.difficulty(pdoc.nSubmit, pdoc.nAccept) or _('(None)') }}</span> <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 %} {% 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 %} {% 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 -%} {%- 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 -%} {%- endfor -%}
{% endif %} {% endif %}
</ul> </ul>
+1 -1
View File
@@ -212,7 +212,7 @@ $hover-background-color = #424242
.menu__link.highlight .menu__link.highlight
background: rgb(40, 11, 20) 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) background-color: rgba(87, 100, 117, .2)
color: #d9dfe7 color: #d9dfe7
+1
View File
@@ -1,6 +1,7 @@
import substitute from './substitute'; import substitute from './substitute';
export default function i18n(str, ...params) { export default function i18n(str, ...params) {
if (!str) return '';
return substitute(window.LOCALES?.[str] || str, params); return substitute(window.LOCALES?.[str] || str, params);
} }
+28 -85
View File
@@ -137,14 +137,14 @@ const MEMORY_RE = /^([0-9]+(?:\.[0-9]*)?)([kmg])b?$/i;
const MEMORY_UNITS = { k: 1 / 1024, m: 1, g: 1024 }; const MEMORY_UNITS = { k: 1 / 1024, m: 1, g: 1024 };
export function parseTimeMS(str: string | number) { export function parseTimeMS(str: string | number) {
if (typeof str === 'number') return str; if (typeof str === 'number' || Number.isSafeInteger(+str)) return +str;
const match = TIME_RE.exec(str); const match = TIME_RE.exec(str);
if (!match) throw new Error(`${str} error parsing time`); if (!match) throw new Error(`${str} error parsing time`);
return Math.floor(parseFloat(match[1]) * TIME_UNITS[match[2].toLowerCase()]); return Math.floor(parseFloat(match[1]) * TIME_UNITS[match[2].toLowerCase()]);
} }
export function parseMemoryMB(str: string | number) { export function parseMemoryMB(str: string | number) {
if (typeof str === 'number') return str; if (typeof str === 'number' || Number.isSafeInteger(+str)) return +str;
const match = MEMORY_RE.exec(str); const match = MEMORY_RE.exec(str);
if (!match) throw new Error(`${str} error parsing memory`); if (!match) throw new Error(`${str} error parsing memory`);
return Math.ceil(parseFloat(match[1]) * MEMORY_UNITS[match[2].toLowerCase()]); return Math.ceil(parseFloat(match[1]) * MEMORY_UNITS[match[2].toLowerCase()]);
@@ -161,19 +161,17 @@ export function size(s: number, base = 1) {
return `${Math.round(s * unit)} ${unitNames[unitNames.length - 1]}`; return `${Math.round(s * unit)} ${unitNames[unitNames.length - 1]}`;
} }
interface Re0 { interface MatchRule {
reg: RegExp, regex: RegExp;
output: ((a: RegExpExecArray) => string)[], output: ((a: RegExpExecArray) => string)[];
id: (a: RegExpExecArray) => number, id: (a: RegExpExecArray) => number;
subtask: (a: RegExpExecArray) => number;
preferredScorerType: 'min' | 'max' | 'sum';
} }
interface Re1 extends Re0 { const SubtaskMatcher: MatchRule[] = [
subtask: (a: RegExpExecArray) => number,
}
const RE0: Re0[] = [
{ {
reg: /^([^\d]*)(\d+).(in|txt)$/, regex: /^([^\d]*)(\d+).(in|txt)$/,
output: [ output: [
(a) => `${a[1] + a[2]}.out`, (a) => `${a[1] + a[2]}.out`,
(a) => `${a[1] + a[2]}.ans`, (a) => `${a[1] + a[2]}.ans`,
@@ -181,94 +179,39 @@ const RE0: Re0[] = [
(a) => (a[1].includes('input') ? `${a[1] + a[2]}.txt`.replace(/input/g, 'output') : null), (a) => (a[1].includes('input') ? `${a[1] + a[2]}.txt`.replace(/input/g, 'output') : null),
], ],
id: (a) => +a[2], id: (a) => +a[2],
subtask: () => 0,
preferredScorerType: 'sum',
}, },
{ {
reg: /^([^\d]*)\.in(\d+)$/, regex: /^([^\d]*)\.in(\d+)$/,
output: [ output: [
(a) => `${a[1]}.ou${a[2]}`, (a) => `${a[1]}.ou${a[2]}`,
(a) => `${a[1]}.ou${a[2]}`.replace(/input/g, 'output'), (a) => `${a[1]}.ou${a[2]}`.replace(/input/g, 'output'),
], ],
id: (a) => +a[2], id: (a) => +a[2],
subtask: () => 0,
preferredScorerType: 'sum',
}, },
];
const RE1: Re1[] = [
{ {
reg: /^([^\d]*)([0-9]+)([-_])([0-9]+).in$/, regex: /^([^\d]*)([0-9]+)([-_])([0-9]+).in$/,
output: [(a) => `${a[1] + a[2]}${a[3]}${a[4]}.out`], output: [(a) => `${a[1] + a[2]}${a[3]}${a[4]}.out`],
subtask: (a) => +a[2],
id: (a) => +a[4], id: (a) => +a[4],
subtask: (a) => +a[2],
preferredScorerType: 'min',
}, },
]; ];
export async function readCasesFromFiles(files: string[], checkFile, cfg) { export function readSubtasksFromFiles(files: string[], checkFile, cfg, rst) {
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 = {}; const subtask = {};
for (const s of rst.subtasks) if (s.id) subtask[s.id] = s; for (const s of rst.subtasks || []) if (s.id) subtask[s.id] = s;
const subtasks = []; const subtasks = [];
for (const file of files) { for (const file of files) {
for (const REG of RE1) { for (const rule of SubtaskMatcher) {
if (REG.reg.test(file)) { const data = rule.regex.exec(file);
const data = REG.reg.exec(file); if (!data) continue;
const sid = REG.subtask(data); const sid = rule.subtask(data);
const c = { input: file, output: '', id: REG.id(data) }; const c = { input: file, output: '', id: rule.id(data) };
for (const func of REG.output) { for (const func of rule.output) {
if (cfg.noOutputFile) c.output = '/dev/null'; if (cfg.noOutputFile) c.output = '/dev/null';
else c.output = func(data); else c.output = func(data);
if (c.output === '/dev/null' || checkFile(c.output)) { if (c.output === '/dev/null' || checkFile(c.output)) {
@@ -276,7 +219,7 @@ export async function readSubtasksFromFiles(files: string[], checkFile, cfg, rst
subtask[sid] = { subtask[sid] = {
time: parseTimeMS(cfg.time || '1s'), time: parseTimeMS(cfg.time || '1s'),
memory: parseMemoryMB(cfg.memory || '256m'), memory: parseMemoryMB(cfg.memory || '256m'),
type: 'min', type: rule.preferredScorerType,
cases: [c], cases: [c],
}; };
} else if (!subtask[sid].cases) subtask[sid].cases = [c]; } else if (!subtask[sid].cases) subtask[sid].cases = [c];
@@ -286,11 +229,11 @@ export async function readSubtasksFromFiles(files: string[], checkFile, cfg, rst
} }
} }
} }
}
for (const i in subtask) { for (const i in subtask) {
subtask[i].cases.sort((a, b) => (a.id - b.id)); subtask[i].cases.sort((a, b) => (a.id - b.id));
subtasks.push(subtask[i]); subtasks.push(subtask[i]);
} }
subtasks.sort((a, b) => (a.id - b.id));
const base = Math.floor(100 / subtasks.length); const base = Math.floor(100 / subtasks.length);
const extra = subtasks.length - (100 % subtasks.length); const extra = subtasks.length - (100 % subtasks.length);
const config = { count: 0, subtasks }; const config = { count: 0, subtasks };