From 310189bc4ddb62e8b236778dd567107f32be03b2 Mon Sep 17 00:00:00 2001 From: undefined Date: Tue, 26 Apr 2022 23:49:24 +0800 Subject: [PATCH] ui: UI&UX enhance, bug fixes Co-authored-by: panda --- package.json | 5 +- packages/hydrojudge/src/cases.ts | 19 +- packages/hydrooj/src/entry/common.ts | 1 + packages/hydrooj/src/interface.ts | 9 +- packages/ui-default/.eslintrc.js | 3 +- .../autocomplete/autocomplete.page.styl | 8 +- .../components/CustomSelectAutoComplete.tsx | 2 +- .../components/FileSelectAutoComplete.tsx | 51 ++ .../components/hotkey/hotkey.page.js | 18 +- .../messagepad/reducers/activeId.js | 18 +- .../messagepad/reducers/dialogues.js | 94 ++-- .../components/messagepad/reducers/inputs.js | 66 +-- .../messagepad/reducers/isPosting.js | 84 ++-- .../components/monaco/languages/yaml.ts | 7 +- .../components/problemconfig/BasicForm.tsx | 69 +++ .../problemconfig/ProblemConfigEditor.tsx | 40 +- .../problemconfig/ProblemConfigForm.tsx | 444 ++---------------- .../components/problemconfig/ProblemType.tsx | 129 +++++ .../components/problemconfig/SubtaskTable.tsx | 139 ++++++ .../problemconfig/TestCasesTable.tsx | 123 +++++ .../problemconfig/reducer/config.ts | 133 +++--- .../problemconfig/reducer/testdata.ts | 24 +- .../components/scratchpad/reducers/records.ts | 46 +- .../components/scratchpad/reducers/ui.ts | 174 +++---- packages/ui-default/locales/zh.yaml | 12 +- packages/ui-default/package.json | 3 +- packages/ui-default/pages/home_files.page.js | 8 +- .../ui-default/pages/homework_main.page.js | 20 +- .../ui-default/pages/problem_config.page.styl | 52 +- .../ui-default/pages/problem_config.page.tsx | 61 +-- .../ui-default/pages/problem_edit.page.js | 8 +- .../ui-default/pages/problem_files.page.js | 8 +- .../ui-default/templates/contest_detail.html | 14 +- .../ui-default/templates/problem_detail.html | 20 +- packages/ui-default/theme/dark.styl | 2 +- packages/ui-default/utils/i18n.js | 1 + packages/utils/lib/common.ts | 137 ++---- 37 files changed, 1104 insertions(+), 948 deletions(-) create mode 100644 packages/ui-default/components/autocomplete/components/FileSelectAutoComplete.tsx create mode 100644 packages/ui-default/components/problemconfig/BasicForm.tsx create mode 100644 packages/ui-default/components/problemconfig/ProblemType.tsx create mode 100644 packages/ui-default/components/problemconfig/SubtaskTable.tsx create mode 100644 packages/ui-default/components/problemconfig/TestCasesTable.tsx diff --git a/package.json b/package.json index d00626301..c42cf827a 100644 --- a/package.json +++ b/package.json @@ -25,7 +25,7 @@ "debug": "node --trace-warnings --async-stack-traces --trace-deprecation node_modules/hydrooj/bin/hydrooj --debug --template", "start:all": "node build/start", "start": "node_modules/.bin/hydrooj", - "postinstall": "node build/hack && node build/prepare.js && rm -rf node_modules/esbuild-loader/node_modules/esbuild" + "postinstall": "node build/hack && node build/prepare.js" }, "version": "1.0.0", "license": "AGPL-3.0-only", @@ -64,5 +64,8 @@ "supertest": "^6.2.2", "typedoc": "^0.22.13", "typescript": "4.6.2" + }, + "resolutions": { + "esbuild": "0.14.3" } } diff --git a/packages/hydrojudge/src/cases.ts b/packages/hydrojudge/src/cases.ts index 6edf22aa3..d81ab2e17 100644 --- a/packages/hydrojudge/src/cases.ts +++ b/packages/hydrojudge/src/cases.ts @@ -3,7 +3,7 @@ import fs from 'fs-extra'; import yaml from 'js-yaml'; import { max, sum } from 'lodash'; 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 { getConfig } from './config'; 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')); files.push(...outputs.map((i) => `output/${i}`)); } - let result = await readCasesFromFiles(files, checkFile, cfg); - if (!result.count) result = await readSubtasksFromFiles(files, checkFile, cfg, rst); + const result = readSubtasksFromFiles(files, checkFile, cfg, rst); Object.assign(config, result); if (cfg.isSelfSubmission) next({ message: { message: 'Found {0} testcases.', params: [config.count] } }); } catch (e) { @@ -42,11 +41,11 @@ function isValidConfig(config) { if (config.count > (getConfig('testcases_max') || 100)) { throw new FormatError('Too many testcases. Cancelled.'); } - const time = sum(config.subtasks.map((subtask) => subtask.time * subtask.cases.length)); + const time = sum(config.subtasks.map((subtask) => sum(subtask.cases.map((c) => c.time)))); if (time > (getConfig('total_time_limit') || 60) * 1000) { throw new FormatError('Total time limit longer than {0}s. Cancelled.', [+getConfig('total_time_limit') || 60]); } - const memMax = max(config.subtasks.map((subtask) => subtask.memory)); + const memMax = max(config.subtasks.map((subtask) => max(subtask.cases.map((c) => c.memory)))); if (memMax > parseMemoryMB(getConfig('memoryMax'))) throw new FormatError('Memory limit larger than memory_max'); if (!['default', 'strict'].includes(config.checker_type || 'default') && !config.checker) { throw new FormatError('You did not specify a checker.'); @@ -57,18 +56,18 @@ export default async function readCases(folder: string, cfg: Record const iniConfig = path.resolve(folder, 'config.ini'); const yamlConfig = path.resolve(folder, 'config.yaml'); const ymlConfig = path.resolve(folder, 'config.yml'); - let config; + const config = { ...cfg }; 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)) { - config = { ...cfg, ...yaml.load(fs.readFileSync(ymlConfig).toString()) as object }; + Object.assign(config, yaml.load(fs.readFileSync(ymlConfig).toString())); } else if (fs.existsSync(iniConfig)) { try { - config = { ...cfg, ...convertIniConfig(fs.readFileSync(iniConfig).toString()) }; + Object.assign(config, convertIniConfig(fs.readFileSync(iniConfig).toString())); } catch (e) { throw changeErrorType(e, FormatError); } - } else config = cfg; + } let result; try { result = await readYamlCases(config, ensureFile(folder)); diff --git a/packages/hydrooj/src/entry/common.ts b/packages/hydrooj/src/entry/common.ts index 5a15bb04a..4cfb71851 100644 --- a/packages/hydrooj/src/entry/common.ts +++ b/packages/hydrooj/src/entry/common.ts @@ -178,6 +178,7 @@ export async function service(pending: string[], fail: string[]) { } } for (const key in global.Hydro.service) { + if (key === 'server') continue; const srv = global.Hydro.service[key]; if (!srv.started && srv.start) await srv.start(); } diff --git a/packages/hydrooj/src/interface.ts b/packages/hydrooj/src/interface.ts index f6ea50295..f2772bf57 100644 --- a/packages/hydrooj/src/interface.ts +++ b/packages/hydrooj/src/interface.ts @@ -127,10 +127,11 @@ export interface FileInfo { } export interface TestCaseConfig { - input: string, - output: string, - time?: number, - memory?: number, + input: string; + output: string; + time?: number; + memory?: number; + score?: number; } export enum ProblemType { diff --git a/packages/ui-default/.eslintrc.js b/packages/ui-default/.eslintrc.js index 09bf98b73..20983ca61 100644 --- a/packages/ui-default/.eslintrc.js +++ b/packages/ui-default/.eslintrc.js @@ -81,7 +81,7 @@ module.exports = { indent: [ 'error', 2, - { SwitchCase: 0 }, + { SwitchCase: 1 }, ], 'max-len': ['error', 150], quotes: 'warn', @@ -115,6 +115,7 @@ module.exports = { 'no-underscore-dangle': 'off', 'no-await-in-loop': 'off', 'no-lonely-if': 'off', + 'no-nested-ternary': 'off', 'no-param-reassign': 'off', 'no-script-url': 'off', 'no-bitwise': 'off', diff --git a/packages/ui-default/components/autocomplete/autocomplete.page.styl b/packages/ui-default/components/autocomplete/autocomplete.page.styl index dd4fc3e28..ff150f4fe 100644 --- a/packages/ui-default/components/autocomplete/autocomplete.page.styl +++ b/packages/ui-default/components/autocomplete/autocomplete.page.styl @@ -53,7 +53,7 @@ white-space: nowrap text-overflow: ellipsis - &>span.bp3-icon>svg + &>span.bp4-icon>svg font-size: 12px cursor: pointer padding: 4px @@ -78,19 +78,19 @@ &>div flex-grow: 1 - &>span.bp3-icon>svg + &>span.bp4-icon>svg color: transparent &[aria-selected='true'] background-color: #fafafa font-weight: 600 - &>span.bp3-icon>svg + &>span.bp4-icon>svg color: #1890ff &[data-focus='true'] background-color: #e6f7ff cursor: pointer - &>span.bp3-icon>svg + &>span.bp4-icon>svg color: currentColor diff --git a/packages/ui-default/components/autocomplete/components/CustomSelectAutoComplete.tsx b/packages/ui-default/components/autocomplete/components/CustomSelectAutoComplete.tsx index 70d185a00..f8cd5acd8 100644 --- a/packages/ui-default/components/autocomplete/components/CustomSelectAutoComplete.tsx +++ b/packages/ui-default/components/autocomplete/components/CustomSelectAutoComplete.tsx @@ -33,7 +33,7 @@ CustomSelectAutoComplete.propTypes = { allowEmptyQuery: PropTypes.bool, freeSolo: PropTypes.bool, freeSoloConverter: PropTypes.func, - data: PropTypes.any, + data: PropTypes.arrayOf(PropTypes.any), }; CustomSelectAutoComplete.displayName = 'CustomSelectAutoComplete'; diff --git a/packages/ui-default/components/autocomplete/components/FileSelectAutoComplete.tsx b/packages/ui-default/components/autocomplete/components/FileSelectAutoComplete.tsx new file mode 100644 index 000000000..4ff950c0c --- /dev/null +++ b/packages/ui-default/components/autocomplete/components/FileSelectAutoComplete.tsx @@ -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((props, ref) => ( + + 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; diff --git a/packages/ui-default/components/hotkey/hotkey.page.js b/packages/ui-default/components/hotkey/hotkey.page.js index ab174d564..e4537a6df 100644 --- a/packages/ui-default/components/hotkey/hotkey.page.js +++ b/packages/ui-default/components/hotkey/hotkey.page.js @@ -38,15 +38,15 @@ function testElementHotkey(hotkey, $element, attr) { const [defStr, trigger] = singleDef.split(':'); if (isHotkeyMatch(hotkey, defStr)) { switch (trigger) { - case 'submit': - $element.closest('form').trigger('submit'); - break; - case undefined: - $element.trigger('click'); - break; - default: - $element.trigger(trigger); - break; + case 'submit': + $element.closest('form').trigger('submit'); + break; + case undefined: + $element.trigger('click'); + break; + default: + $element.trigger(trigger); + break; } } }); diff --git a/packages/ui-default/components/messagepad/reducers/activeId.js b/packages/ui-default/components/messagepad/reducers/activeId.js index 036642c14..8af2782c8 100644 --- a/packages/ui-default/components/messagepad/reducers/activeId.js +++ b/packages/ui-default/components/messagepad/reducers/activeId.js @@ -21,14 +21,14 @@ function scrollToViewport() { export default function reducer(state = null, action) { switch (action.type) { - case 'DIALOGUES_SWITCH_TO': { - scrollToViewport(); - return action.payload; - } - case 'DIALOGUES_POST_SEND_FULFILLED': { - return action.meta.dialogueId; - } - default: - return state; + case 'DIALOGUES_SWITCH_TO': { + scrollToViewport(); + return action.payload; + } + case 'DIALOGUES_POST_SEND_FULFILLED': { + return action.meta.dialogueId; + } + default: + return state; } } diff --git a/packages/ui-default/components/messagepad/reducers/dialogues.js b/packages/ui-default/components/messagepad/reducers/dialogues.js index e56009a5a..d6e55d7bf 100644 --- a/packages/ui-default/components/messagepad/reducers/dialogues.js +++ b/packages/ui-default/components/messagepad/reducers/dialogues.js @@ -2,52 +2,52 @@ import _ from 'lodash'; export default function reducer(state = {}, action) { switch (action.type) { - case 'DIALOGUES_LOAD_DIALOGUES_FULFILLED': { - const { messages } = action.payload; - return { ...state, ..._.keyBy(messages, 'udoc._id') }; - } - case 'DIALOGUES_CREATE': { - const { user } = action.payload; - if (state[user._id]) return state; - return { - ...state, - [user._id]: { - _id: user._id, - udoc: { ...user }, - messages: [], - }, - }; - } - case 'DIALOGUES_POST_SEND_FULFILLED': { - const id = action.meta.dialogueId; - const { mdoc } = action.payload; - return { - ...state, - [id]: { - ...state[id], - messages: [ - ...state[id].messages, - mdoc, - ], - }, - }; - } - case 'DIALOGUES_MESSAGE_PUSH': { - const { mdoc, udoc } = action.payload; - const to = mdoc.from === UserContext._id ? mdoc.to : mdoc.from; - return { - ...state, - [to]: { - ...state[to] || {}, - udoc, - messages: [ - ...state[to]?.messages || [], - mdoc, - ], - }, - }; - } - default: - return state; + case 'DIALOGUES_LOAD_DIALOGUES_FULFILLED': { + const { messages } = action.payload; + return { ...state, ..._.keyBy(messages, 'udoc._id') }; + } + case 'DIALOGUES_CREATE': { + const { user } = action.payload; + if (state[user._id]) return state; + return { + ...state, + [user._id]: { + _id: user._id, + udoc: { ...user }, + messages: [], + }, + }; + } + case 'DIALOGUES_POST_SEND_FULFILLED': { + const id = action.meta.dialogueId; + const { mdoc } = action.payload; + return { + ...state, + [id]: { + ...state[id], + messages: [ + ...state[id].messages, + mdoc, + ], + }, + }; + } + case 'DIALOGUES_MESSAGE_PUSH': { + const { mdoc, udoc } = action.payload; + const to = mdoc.from === UserContext._id ? mdoc.to : mdoc.from; + return { + ...state, + [to]: { + ...state[to] || {}, + udoc, + messages: [ + ...state[to]?.messages || [], + mdoc, + ], + }, + }; + } + default: + return state; } } diff --git a/packages/ui-default/components/messagepad/reducers/inputs.js b/packages/ui-default/components/messagepad/reducers/inputs.js index a0856a741..a188073e6 100644 --- a/packages/ui-default/components/messagepad/reducers/inputs.js +++ b/packages/ui-default/components/messagepad/reducers/inputs.js @@ -2,42 +2,42 @@ import _ from 'lodash'; export default function reducer(state = {}, action) { switch (action.type) { - case 'DIALOGUES_LOAD_DIALOGUES_FULFILLED': { - const dialogues = action.payload.messages; - return { ...state, ..._.fromPairs(_.map(dialogues, (d) => [d._id, ''])) }; - } - case 'DIALOGUES_CREATE': { - const { user } = action.payload; - return { - ...state, - [user._id]: '', - }; - } - case 'DIALOGUES_INPUT_CHANGED': { - const id = action.meta.dialogueId; - return { - ...state, - [id]: action.payload, - }; - } - case 'DIALOGUES_POST_SEND_FULFILLED': { - const id = action.meta.dialogueId; - return { - ...state, - [id]: '', - }; - } - case 'DIALOGUES_MESSAGE_PUSH': { - const { udoc } = action.payload; - if (!state[udoc._id]) { + case 'DIALOGUES_LOAD_DIALOGUES_FULFILLED': { + const dialogues = action.payload.messages; + return { ...state, ..._.fromPairs(_.map(dialogues, (d) => [d._id, ''])) }; + } + case 'DIALOGUES_CREATE': { + const { user } = action.payload; return { ...state, - [udoc._id]: '', + [user._id]: '', }; } - return state; - } - default: - return state; + case 'DIALOGUES_INPUT_CHANGED': { + const id = action.meta.dialogueId; + return { + ...state, + [id]: action.payload, + }; + } + case 'DIALOGUES_POST_SEND_FULFILLED': { + const id = action.meta.dialogueId; + return { + ...state, + [id]: '', + }; + } + case 'DIALOGUES_MESSAGE_PUSH': { + const { udoc } = action.payload; + if (!state[udoc._id]) { + return { + ...state, + [udoc._id]: '', + }; + } + return state; + } + default: + return state; } } diff --git a/packages/ui-default/components/messagepad/reducers/isPosting.js b/packages/ui-default/components/messagepad/reducers/isPosting.js index 4bb841122..ecd04cbff 100644 --- a/packages/ui-default/components/messagepad/reducers/isPosting.js +++ b/packages/ui-default/components/messagepad/reducers/isPosting.js @@ -3,47 +3,47 @@ import Notification from 'vj/components/notification'; export default function reducer(state = {}, action) { switch (action.type) { - case 'DIALOGUES_LOAD_DIALOGUES_FULFILLED': { - const dialogues = action.payload.messages; - return { ...state, ..._.fromPairs(_.map(dialogues, (d) => [d._id, false])) }; - } - case 'DIALOGUES_CREATE': { - const { user } = action.payload; - return { - ...state, - [user._id]: false, - }; - } - case 'DIALOGUES_POST_SEND_PENDING': { - const id = action.meta.dialogueId; - return { - ...state, - [id]: true, - }; - } - case 'DIALOGUES_POST_SEND_REJECTED': { - Notification.error(action.payload.message); - const id = action.meta.dialogueId; - return { - ...state, - [id]: false, - }; - } - case 'DIALOGUES_POST_SEND_FULFILLED': { - const id = action.meta.dialogueId; - return { - ...state, - [id]: false, - }; - } - case 'DIALOGUES_MESSAGE_PUSH': { - const { udoc } = action.payload; - return { - ...state, - [udoc._id]: false, - }; - } - default: - return state; + case 'DIALOGUES_LOAD_DIALOGUES_FULFILLED': { + const dialogues = action.payload.messages; + return { ...state, ..._.fromPairs(_.map(dialogues, (d) => [d._id, false])) }; + } + case 'DIALOGUES_CREATE': { + const { user } = action.payload; + return { + ...state, + [user._id]: false, + }; + } + case 'DIALOGUES_POST_SEND_PENDING': { + const id = action.meta.dialogueId; + return { + ...state, + [id]: true, + }; + } + case 'DIALOGUES_POST_SEND_REJECTED': { + Notification.error(action.payload.message); + const id = action.meta.dialogueId; + return { + ...state, + [id]: false, + }; + } + case 'DIALOGUES_POST_SEND_FULFILLED': { + const id = action.meta.dialogueId; + return { + ...state, + [id]: false, + }; + } + case 'DIALOGUES_MESSAGE_PUSH': { + const { udoc } = action.payload; + return { + ...state, + [udoc._id]: false, + }; + } + default: + return state; } } diff --git a/packages/ui-default/components/monaco/languages/yaml.ts b/packages/ui-default/components/monaco/languages/yaml.ts index 544d0fd85..783524435 100644 --- a/packages/ui-default/components/monaco/languages/yaml.ts +++ b/packages/ui-default/components/monaco/languages/yaml.ts @@ -8,6 +8,9 @@ const problemConfigSchemaDef = { properties: { input: { type: 'string' }, output: { type: 'string' }, + time: { $ref: '#/def/time' }, + memory: { $ref: '#/def/memory' }, + score: { $ref: '#/def/score', description: 'score' }, }, required: ['input'], additionalProperties: false, @@ -28,7 +31,7 @@ const problemConfigSchemaDef = { additionalProperties: false, }, time: { type: 'string', pattern: '^([0-9]+(?:\\.[0-9]*)?)([mu]?)s?$' }, - memory: { type: 'string', pattern: '^([0-9]+(?:\\.[0-9]*)?)([kmg])b?$' }, + memory: { type: 'string', pattern: '^([0-9]+(?:\\.[0-9]*)?)([kKmMgG])[bB]?$' }, score: { type: 'integer', maximum: 100, minimum: 1 }, }; @@ -37,7 +40,7 @@ const problemConfigSchema = { type: 'object', def: problemConfigSchemaDef, properties: { - redirect: { type: 'string', pattern: '[0-9a-z_-]+\\/[0-9]+' }, + redirect: { type: 'string', pattern: '[0-9a-zA-Z_-]+\\/[0-9]+' }, key: { type: 'string', pattern: '[0-9a-f]{32}' }, type: { enum: ['default', 'interactive', 'submit_answer', 'objective', 'remote_judge'] }, subType: { type: 'string' }, diff --git a/packages/ui-default/components/problemconfig/BasicForm.tsx b/packages/ui-default/components/problemconfig/BasicForm.tsx new file mode 100644 index 000000000..0d34abcaf --- /dev/null +++ b/packages/ui-default/components/problemconfig/BasicForm.tsx @@ -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 ( +
+ +
+ ); +} + +type KeyType = { + [Q in keyof K]: K[Q] extends T ? Q : never; +}[keyof K]; + +export function ManagedInput({ placeholder, formKey }: { placeholder?: string, formKey: KeyType }) { + const value = useSelector((state: RootState) => state.config[formKey]); + const dispatch = useDispatch(); + return ( + { + dispatch({ type: 'CONFIG_FORM_UPDATE', key: formKey, value: ev.currentTarget.value }); + }} + className="textbox" + /> + ); +} + +export function ManagedSelect({ options, formKey }: { options: string[], formKey: KeyType }) { + const value = useSelector((state: RootState) => state.config[formKey]); + const dispatch = useDispatch(); + return ( + + ); +} + +export function SingleFileSelect({ formKey }: { formKey: KeyType }) { + const value = useSelector((state: RootState) => state.config[formKey]); + const Files = useSelector((state: RootState) => state.testdata); + const dispatch = useDispatch(); + return ( + dispatch({ type: 'CONFIG_FORM_UPDATE', key: formKey, value: val })} + /> + ); +} diff --git a/packages/ui-default/components/problemconfig/ProblemConfigEditor.tsx b/packages/ui-default/components/problemconfig/ProblemConfigEditor.tsx index e3debd546..68774fc7c 100644 --- a/packages/ui-default/components/problemconfig/ProblemConfigEditor.tsx +++ b/packages/ui-default/components/problemconfig/ProblemConfigEditor.tsx @@ -3,6 +3,7 @@ import type { editor } from 'monaco-editor'; import { connect } from 'react-redux'; import { load } from 'vj/components/monaco/loader'; import Editor from 'vj/components/editor'; +import { diffLines } from 'diff'; import yaml from 'js-yaml'; import type { ProblemConfigFile, TestCaseConfig } from 'hydrooj/src/interface'; @@ -41,9 +42,10 @@ function configYamlFormat(config: ProblemConfigFile) { const formatConfig: ProblemConfigFile = {}; configKey.forEach((key) => { if (config[key] !== undefined) { + if (key === 'subType' && ['single', 'multi'].includes(config[key]) && config.type !== 'submit_answer') return; if (key === 'checker_type' && config.type !== 'default') return; if (key === 'checker' - && (['default', 'strict'].includes(formatConfig.checker_type) || formatConfig.checker_type === undefined)) return; + && (['default', 'strict'].includes(formatConfig.checker_type) || formatConfig.checker_type === undefined)) return; if (key === 'interactor' && config.type !== 'interactive') return; if (key === 'subtasks') { formatConfig[key] = []; @@ -71,6 +73,11 @@ function configYamlFormat(config: ProblemConfigFile) { } 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; } @@ -87,7 +94,7 @@ export default connect(mapStateToProps, mapDispatchToProps)(class MonacoEditor e 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.model = monaco.editor.createModel(yaml.dump(configYamlFormat(this.props.config)), 'yaml', uri); this.vjEditor = Editor.getOrConstruct($(this.containerElement), { language: 'yaml', model: this.model, @@ -101,17 +108,28 @@ export default connect(mapStateToProps, mapDispatchToProps)(class MonacoEditor e } componentDidUpdate(prevProps) { - if (this.__preventUpdate) return; + if (this.__preventUpdate || !this.model) return; if (yaml.dump(prevProps.config) !== yaml.dump(this.props.config)) { this.__preventFormat = true; - this.model?.pushEditOperations( - [], - [{ - range: this.model.getFullModelRange(), - text: yaml.dump(configYamlFormat(this.props.config)), - }], - undefined, - ); + const curValue = this.model.getValue(); + const diff = diffLines(curValue, yaml.dump(configYamlFormat(this.props.config))); + const ops = []; + let cursor = 1; + for (const line of diff) { + if (line.added) { + let range = this.model.getFullModelRange(); + range = range.setStartPosition(cursor, 0); + range = range.setEndPosition(cursor, 0); + ops.push({ range, text: line.value }); + } else if (line.removed) { + let range = this.model.getFullModelRange(); + range = range.setStartPosition(cursor, 0); + cursor += line.count; + range = range.setEndPosition(cursor, 0); + ops.push({ range, text: '' }); + } else cursor += line.count; + } + this.model.pushEditOperations([], ops, undefined); this.__preventFormat = false; } } diff --git a/packages/ui-default/components/problemconfig/ProblemConfigForm.tsx b/packages/ui-default/components/problemconfig/ProblemConfigForm.tsx index f70d4b410..afab7fac0 100644 --- a/packages/ui-default/components/problemconfig/ProblemConfigForm.tsx +++ b/packages/ui-default/components/problemconfig/ProblemConfigForm.tsx @@ -1,153 +1,36 @@ 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 { SubtaskConfig, TestCaseConfig } from 'hydrooj/src/interface'; +import { + Card, Switch, Tab, Tabs, InputGroup, Tag, +} from '@blueprintjs/core'; import type { RootState } from './reducer/index'; import CustomSelectAutoComplete from '../autocomplete/components/CustomSelectAutoComplete'; +import FileSelectAutoComplete from '../autocomplete/components/FileSelectAutoComplete'; +import { FormItem } from './BasicForm'; +import ProblemType from './ProblemType'; +import { TaskConfig } from './SubtaskTable'; -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); -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 ( -
- -
- ); -} - -type KeyType = { - [Q in keyof K]: K[Q] extends T ? Q : never; -}[keyof K]; - -function ManagedInput({ placeholder, formKey }: { placeholder: string, formKey: KeyType }) { - const value = useSelector((state: RootState) => state.config[formKey]); +function FileIOConfig() { + const filename = useSelector((state: RootState) => state.config.filename); const dispatch = useDispatch(); return ( - { - dispatch({ type: 'CONFIG_FORM_UPDATE', key: formKey, value: ev.currentTarget.value }); - }} - className="textbox" - /> - ); -} - -function ManagedSelect({ placeholder, formKey }: { placeholder: string, formKey: KeyType }) { - const value = useSelector((state: RootState) => state.config[formKey]); - const dispatch = useDispatch(); - return ( - - ); -} - -function SingleFileSelect({ formKey }: { formKey: KeyType }) { - const value = useSelector((state: RootState) => state.config[formKey]); - const Files = useSelector((state: RootState) => state.testdata); - const dispatch = useDispatch(); - return ( - 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 ( - <> - - - - - - - - { - dispatch({ type: 'CONFIG_FORM_UPDATE', key: 'checker_type', value }); - }} - renderActiveTabPanelOnly - > - {i18n('CheckerType')} - - { - SelectValue.checker_type.map((i) => ( - - {['default', 'strict'].includes(i) - ? ( - { - dispatch({ type: 'CONFIG_FORM_UPDATE', key: 'checker_type', value: checkerType === 'strict' ? 'default' : 'strict' }); - } - } - /> - ) - : ( - - )} - - )} - /> - )) - } - - - - - - + + +
+ + .in/.out} + value={filename || ''} + onChange={(ev) => { + dispatch({ type: 'CONFIG_FORM_UPDATE', key: 'filename', value: ev.currentTarget.value }); + }} + fill + /> + +
+
+
); } @@ -166,7 +49,7 @@ function ExtraFilesConfig() { id="user_extra_files" title={i18n('user_extra_files')} panel={( - dispatch({ type: 'CONFIG_FORM_UPDATE', key: 'user_extra_files', value: val.split(',') })} @@ -178,7 +61,7 @@ function ExtraFilesConfig() { id="judge_extra_files" title={i18n('judge_extra_files')} panel={( - 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 => (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 => (val) => { - dispatch({ - type: index === -1 ? 'CONFIG_CASES_UPDATE' : 'CONFIG_SUBTASK_UPDATE', - id: index, - key: 'cases-edit', - casesId: subindex, - casesKey, - value: val, - }); - }; - return ( - - { - index === -1 && ( - <> - - - - - - - - ) - } - - - - - - - - dispatch({ - type: index === -1 ? 'CONFIG_CASES_UPDATE' : 'CONFIG_SUBTASK_UPDATE', id: index, key: 'cases-delete', value: subindex, - })} - > - - - - ); -} - -function CasesTable({ index }) { - const casesLength = useSelector((state: RootState) => (index === -1 ? state.config.cases?.length : state.config.subtasks[index].cases?.length)); - const dispatch = useDispatch(); - return ( - - - - { - index === -1 && ( - <> - - - - ) - } - - - - - - - {casesLength && [...Array(casesLength).keys()].map((i) => )} - -
- {i18n('Time')} - - {i18n('Memory')} - - {i18n('Input')} - - {i18n('Output')} - - 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: '' }, - })} - > - -
- ); -} - -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 ( - - if - - `${item}`} - onChange={(val) => dispatch({ - type: 'CONFIG_SUBTASK_UPDATE', id: index, key: 'if', value: val.split(','), - })} - multi - /> - - - ); -} - -function SubtasksTable({ index }) { - const subtask = (key: string) => useSelector((state: RootState) => state.config.subtasks[index][key]); - const dispatch = useDispatch(); - const dispatcher = (key: string): React.ChangeEventHandler => (ev) => { - dispatch({ - type: 'CONFIG_SUBTASK_UPDATE', id: index, key, value: ev.currentTarget.value, - }); - }; - return ( - <> - Subtasks #{index + 1} - { dispatch({ type: 'CONFIG_SUBTASK_UPDATE', id: index, key: 'add' }); }}> - { dispatch({ type: 'CONFIG_SUBTASK_UPDATE', id: index, key: 'delete' }); }} - > - - - - - - - - - - - - - - - - - - - - - -
#ScoreTypeTimeMemory
- - - - - - - - - -
- - - ); -} - -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 ( - -
- - - - - - - - - - - {casesLength && dispatch({ type: 'CONFIG_SUBTASKS_SWITCH', value: true })} />} - { - subtasksLength || casesLength - ? subtasksLength && [...Array(subtasksLength).keys()].map((i) => ) - || casesLength && () - : ( - onAutoLoad()}> - {i18n('Auto Read Tasks')} - - ) - } - -
-
- ); -} - function LangConfig() { const [showTab, setshowTab] = useState(false); 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 ( -
- - - - +
+ + {Type === 'default' && } + {!['submit_answer', 'objective'].includes(Type) && ( + <> + + + + + )}
); } diff --git a/packages/ui-default/components/problemconfig/ProblemType.tsx b/packages/ui-default/components/problemconfig/ProblemType.tsx new file mode 100644 index 000000000..f81c88fe1 --- /dev/null +++ b/packages/ui-default/components/problemconfig/ProblemType.tsx @@ -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 ( + + + + {i18n('Problem Type')} + + + {i18n('CheckerType')} + + + + { + dispatch({ type: 'CONFIG_FORM_UPDATE', key: 'checker_type', value: checkerType === 'strict' ? 'default' : 'strict' }); + }} + /> + +
+ )} + /> + {['testlib', 'other'].map((i) => ( + + {i === 'other' && ( + + + + )} + + + + + )} + /> + ))} + + )} + /> + + + + + + )} + /> + + + { + dispatch({ type: 'CONFIG_FORM_UPDATE', key: 'subType', value: subType === 'multi' ? 'single' : 'multi' }); + }} + /> + + + dispatch(({ type: 'CONFIG_FORM_UPDATE', key: 'filename', value: ev.currentTarget.value }))} + className="textbox" + /> + + + )} + /> + 暂不支持快捷配置此类题目。请参照文档操作。

)} + /> + + + + ); +} diff --git a/packages/ui-default/components/problemconfig/SubtaskTable.tsx b/packages/ui-default/components/problemconfig/SubtaskTable.tsx new file mode 100644 index 000000000..0f3e68148 --- /dev/null +++ b/packages/ui-default/components/problemconfig/SubtaskTable.tsx @@ -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 ( + + if + + i.length)} + selectedKeys={subtaskIf?.map(String) || []} + onChange={(val) => dispatch({ + type: 'CONFIG_SUBTASK_UPDATE', id: index, key: 'if', value: val.split(','), + })} + multi + /> + + + ); +} + +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 | 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 ( + + Subtasks #{index + 1} + dispatch({ type: 'CONFIG_SUBTASK_UPDATE', id: index, key: 'add' })}> + dispatch({ type: 'CONFIG_SUBTASK_UPDATE', id: index, key: 'delete' })} + > + + + + + + + + + + + + + + + {['id', 'score'].map((key) => ( + + ))} + + + + + + +
#ScoreTypeTimeMemory
+ + + + + ms} + value={subtask.time ? parseTimeMS(subtask.time).toString() : ''} + placeholder={parseTimeMS(defaultTime || '1000ms').toString()} + onValueChange={dispatcher('time', 'ms')} + buttonPosition="none" + fill + /> + + MB} + value={subtask.memory ? parseMemoryMB(subtask.memory).toString() : ''} + placeholder={parseMemoryMB(defaultMemory || '256m').toString()} + onValueChange={dispatcher('memory', 'MB')} + buttonPosition="none" + fill + /> +
+ +
+ ); +} + +export function TaskConfig() { + const len = useSelector((state: RootState) => state.config.subtasks?.length); + return ( + +
+ + + + + + + + + + + {len && [...Array(len).keys()].map((i) => )} + +
+
+ ); +} diff --git a/packages/ui-default/components/problemconfig/TestCasesTable.tsx b/packages/ui-default/components/problemconfig/TestCasesTable.tsx new file mode 100644 index 000000000..23c2ff287 --- /dev/null +++ b/packages/ui-default/components/problemconfig/TestCasesTable.tsx @@ -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 | 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 ( + + + ms} + value={testcase.time ? parseTimeMS(testcase.time).toString() : ''} + placeholder={parseTimeMS(defaultTime || '1000ms').toString()} + onValueChange={dispatcher('time', 'ms')} + buttonPosition="none" + fill + /> + + + MB} + value={testcase.memory ? parseMemoryMB(testcase.memory).toString() : ''} + placeholder={parseMemoryMB(defaultMemory || '256m').toString()} + onValueChange={dispatcher('memory', 'MB')} + buttonPosition="none" + fill + /> + + {['input', 'output'].map((t) => ( + + + + ))} + + dispatch({ + type: 'CONFIG_SUBTASK_UPDATE', id: index, key: 'cases-delete', value: subindex, + })} + > + + + + ); +} + +export function CasesTable({ index }) { + const len = useSelector((state: RootState) => state.config.subtasks[index].cases?.length); + const dispatch = useDispatch(); + return ( + + + + + + + + + + + + + + + + + + + {[...Array(len).keys()].map((i) => )} + +
{i18n('Time')}{i18n('Memory')}{i18n('Input')}{i18n('Output')} + dispatch({ + type: 'CONFIG_SUBTASK_UPDATE', + id: index, + key: 'cases-add', + value: { input: '', output: '' }, + })} + > + +
{i18n('Time')}{i18n('Memory')}{i18n('Input')}{i18n('Output')} + dispatch({ + type: 'CONFIG_SUBTASK_UPDATE', + id: index, + key: 'cases-add', + value: { input: '', output: '' }, + })} + > + +
+ ); +} diff --git a/packages/ui-default/components/problemconfig/reducer/config.ts b/packages/ui-default/components/problemconfig/reducer/config.ts index 011bd0ff4..eec223d6a 100644 --- a/packages/ui-default/components/problemconfig/reducer/config.ts +++ b/packages/ui-default/components/problemconfig/reducer/config.ts @@ -2,84 +2,69 @@ import yaml from 'js-yaml'; import type { ProblemConfigFile } from 'hydrooj/src/interface'; 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) { - case 'CONFIG_LOAD_FULFILLED': { - // TODO set yaml schema - return { ...state, ...yaml.load(action.payload.config) as object }; - } - case 'CONFIG_FORM_UPDATE': { - const next = { ...state, [action.key]: action.value }; - if (action.key === 'score' && action.value) next.score = +next.score; - if (!action.value || (typeof action.value === 'object' && !action.value.join(''))) delete next[action.key]; - return next; - } - case 'CONFIG_CODE_UPDATE': { - try { - return { ...state, ...yaml.load(action.payload) as object }; - } catch { - return state; + case 'CONFIG_LOAD_FULFILLED': { + return { ...state, ...yaml.load(action.payload.config) as object, __loaded: true }; } - } - case 'CONFIG_AUTOCASES_UPDATE': { - const next = { ...state }; - const autocases = action.value; - if (autocases.subtasks.length === 0) next.cases = []; - else 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 })) } })); + case 'CONFIG_FORM_UPDATE': { + const next = { ...state, [action.key]: action.value }; + if (action.key === 'score' && action.value) next.score = +next.score; + if (action.key === 'checker_type' && action.value === 'other') next.checker_type = 'syzoj'; + if (!action.value || (typeof action.value === 'object' && !action.value.join(''))) delete next[action.key]; + return next; } - return next; - } - case 'CONFIG_SUBTASKS_SWITCH': { - const next = { ...state }; - next.subtasks = next.cases.map((k, v) => ({ - id: v, time: k.time, memory: k.memory, cases: [{ input: k.input, output: k.output }], - })); - if (!next.subtasks.length) next.subtasks.push({ id: 0 }); - delete next.cases; - 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': { - const subtasks = cloneDeep(state.subtasks); - const subsubtasks = cloneDeep(state.subtasks[action.id]); - if (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.split('-')[0] === 'cases') { - 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-delete') { - subsubtasks.cases = subsubtasks.cases.filter((k, v) => v !== action.value); + case 'CONFIG_CODE_UPDATE': { + try { + return { ...state, ...yaml.load(action.payload) as object }; + } catch { + return state; } - } else if (action.key === 'add') subtasks.push({ time: 1000, memory: 256, cases: [] }); - else if (action.key === 'delete') delete subtasks[action.key]; - else { - if (action.value === '' || (action.key === 'if' && action.value.join('') === '')) delete subsubtasks[action.key]; - else subsubtasks[action.key] = action.value; } - if (action.key !== 'delete') subtasks[action.id] = subsubtasks; - return { ...state, subtasks }; - } - default: - return state; + case 'CONFIG_AUTOCASES_UPDATE': { + const next = { ...state }; + const autocases = action.value; + if (autocases.subtasks.length === 0) next.subtasks = []; + 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.map((k, v) => ({ + id: v, time: k.time, memory: k.memory, cases: [{ input: k.input, output: k.output }], + })); + if (!next.subtasks.length) next.subtasks.push({ id: 0 }); + delete next.cases; + return next; + } + case 'CONFIG_SUBTASK_UPDATE': { + const subtasks = cloneDeep(state.subtasks); + const subsubtasks = cloneDeep(state.subtasks[action.id]); + if (action.value !== '' && ['score', 'id'].includes(action.key)) action.value = +action.value; + if (action.key === 'if' && action.value.join('') !== '') action.value = action.value.map((i) => +i); + if (action.key.split('-')[0] === 'cases') { + if (action.key === 'cases-add') subsubtasks.cases.push(action.value); + else if (action.key === 'cases-edit') { + if (action.value === '') delete subsubtasks.cases[action.casesId][action.casesKey]; + else subsubtasks.cases[action.casesId][action.casesKey] = action.value; + } else if (action.key === 'cases-delete') { + subsubtasks.cases = subsubtasks.cases.filter((k, v) => v !== action.value); + } + } else if (action.key === 'add') subtasks.push({ cases: [] }); + else if (action.key === 'delete') return { ...state, subtasks: subtasks.filter((k, v) => v !== action.id) }; + else { + if (action.value === '' || (action.key === 'if' && action.value.join('') === '')) delete subsubtasks[action.key]; + else subsubtasks[action.key] = action.value; + } + subtasks[action.id] = subsubtasks; + return { ...state, subtasks }; + } + default: + return state; } } diff --git a/packages/ui-default/components/problemconfig/reducer/testdata.ts b/packages/ui-default/components/problemconfig/reducer/testdata.ts index 924766e41..34fb0fb88 100644 --- a/packages/ui-default/components/problemconfig/reducer/testdata.ts +++ b/packages/ui-default/components/problemconfig/reducer/testdata.ts @@ -1,16 +1,16 @@ export default function reducer(state = [], action) { switch (action.type) { - case 'CONFIG_LOAD_FULFILLED': { - return state.concat(action.payload.testdata); - } - case 'CONFIG_DELETE_TESTDATA': { - const next = state.filter((i) => i.name !== action.value[0]); - return next; - } - case 'CONFIG_ADD_TESTDATA': { - return state.concat([action.value]); - } - default: - return state; + case 'CONFIG_LOAD_FULFILLED': { + return state.concat(action.payload.testdata); + } + case 'CONFIG_DELETE_TESTDATA': { + const next = state.filter((i) => i.name !== action.value[0]); + return next; + } + case 'CONFIG_ADD_TESTDATA': { + return state.concat([action.value]); + } + default: + return state; } } diff --git a/packages/ui-default/components/scratchpad/reducers/records.ts b/packages/ui-default/components/scratchpad/reducers/records.ts index 4c44b09e6..3b8b0d655 100644 --- a/packages/ui-default/components/scratchpad/reducers/records.ts +++ b/packages/ui-default/components/scratchpad/reducers/records.ts @@ -5,36 +5,36 @@ export default function reducer(state = { items: {}, }, action) { switch (action.type) { - case 'SCRATCHPAD_RECORDS_LOAD_SUBMISSIONS_FULFILLED': { - const { rdocs } = action.payload; - return { - ...state, - rows: _.map(rdocs, '_id'), - items: _.keyBy(rdocs, '_id'), - }; - } - case 'SCRATCHPAD_RECORDS_PUSH': { - const { rdoc } = action.payload; - const rows = [...state.rows]; - if (!rows.includes(rdoc._id)) { + case 'SCRATCHPAD_RECORDS_LOAD_SUBMISSIONS_FULFILLED': { + const { rdocs } = action.payload; + return { + ...state, + rows: _.map(rdocs, '_id'), + items: _.keyBy(rdocs, '_id'), + }; + } + case 'SCRATCHPAD_RECORDS_PUSH': { + const { rdoc } = action.payload; + const rows = [...state.rows]; + if (!rows.includes(rdoc._id)) { + return { + ...state, + rows: [rdoc._id, ...state.rows], + items: { + ...state.items, + [rdoc._id]: rdoc, + }, + }; + } return { ...state, - rows: [rdoc._id, ...state.rows], items: { ...state.items, [rdoc._id]: rdoc, }, }; } - return { - ...state, - items: { - ...state.items, - [rdoc._id]: rdoc, - }, - }; - } - default: - return state; + default: + return state; } } diff --git a/packages/ui-default/components/scratchpad/reducers/ui.ts b/packages/ui-default/components/scratchpad/reducers/ui.ts index 257868f14..ca374c480 100644 --- a/packages/ui-default/components/scratchpad/reducers/ui.ts +++ b/packages/ui-default/components/scratchpad/reducers/ui.ts @@ -19,93 +19,93 @@ export default function reducer(state = { isPosting: false, }, action) { switch (action.type) { - case 'SCRATCHPAD_UI_CHANGE_SIZE': { - const { uiElement, size } = action.payload; - return { - ...state, - [uiElement]: { - ...state[uiElement], - size, - }, - }; - } - case 'SCRATCHPAD_UI_SET_VISIBILITY': { - const { uiElement, visibility } = action.payload; - localStorage.setItem(`scratchpad/${uiElement}`, visibility.toString()); - return { - ...state, - [uiElement]: { - ...state[uiElement], - visible: visibility, - }, - }; - } - case 'SCRATCHPAD_UI_TOGGLE_VISIBILITY': { - const { uiElement } = action.payload; - localStorage.setItem(`scratchpad/${uiElement}`, (!state[uiElement].visible).toString()); - return { - ...state, - [uiElement]: { - ...state[uiElement], - visible: !state[uiElement].visible, - }, - }; - } - case 'SCRATCHPAD_POST_PRETEST_PENDING': - case 'SCRATCHPAD_POST_SUBMIT_PENDING': { - return { - ...state, - isPosting: true, - }; - } - case 'SCRATCHPAD_POST_PRETEST_FULFILLED': - case 'SCRATCHPAD_POST_SUBMIT_FULFILLED': { - Notification.success(i18n('Submitted.')); - if (action.type === 'SCRATCHPAD_POST_SUBMIT_FULFILLED' && UiContext.canViewRecord) { - state.records.visible = true; + case 'SCRATCHPAD_UI_CHANGE_SIZE': { + const { uiElement, size } = action.payload; + return { + ...state, + [uiElement]: { + ...state[uiElement], + size, + }, + }; } - return { - ...state, - isPosting: false, - }; - } - case 'SCRATCHPAD_POST_PRETEST_REJECTED': - case 'SCRATCHPAD_POST_SUBMIT_REJECTED': { - Notification.error(action.payload.message); - return { - ...state, - isPosting: false, - }; - } - case 'SCRATCHPAD_RECORDS_LOAD_SUBMISSIONS_PENDING': { - return { - ...state, - records: { - ...state.records, - isLoading: true, - }, - }; - } - case 'SCRATCHPAD_RECORDS_LOAD_SUBMISSIONS_REJECTED': { - Notification.error(action.payload.message); - return { - ...state, - records: { - ...state.records, - isLoading: false, - }, - }; - } - case 'SCRATCHPAD_RECORDS_LOAD_SUBMISSIONS_FULFILLED': { - return { - ...state, - records: { - ...state.records, - isLoading: false, - }, - }; - } - default: - return state; + case 'SCRATCHPAD_UI_SET_VISIBILITY': { + const { uiElement, visibility } = action.payload; + localStorage.setItem(`scratchpad/${uiElement}`, visibility.toString()); + return { + ...state, + [uiElement]: { + ...state[uiElement], + visible: visibility, + }, + }; + } + case 'SCRATCHPAD_UI_TOGGLE_VISIBILITY': { + const { uiElement } = action.payload; + localStorage.setItem(`scratchpad/${uiElement}`, (!state[uiElement].visible).toString()); + return { + ...state, + [uiElement]: { + ...state[uiElement], + visible: !state[uiElement].visible, + }, + }; + } + case 'SCRATCHPAD_POST_PRETEST_PENDING': + case 'SCRATCHPAD_POST_SUBMIT_PENDING': { + return { + ...state, + isPosting: true, + }; + } + case 'SCRATCHPAD_POST_PRETEST_FULFILLED': + case 'SCRATCHPAD_POST_SUBMIT_FULFILLED': { + Notification.success(i18n('Submitted.')); + if (action.type === 'SCRATCHPAD_POST_SUBMIT_FULFILLED' && UiContext.canViewRecord) { + state.records.visible = true; + } + return { + ...state, + isPosting: false, + }; + } + case 'SCRATCHPAD_POST_PRETEST_REJECTED': + case 'SCRATCHPAD_POST_SUBMIT_REJECTED': { + Notification.error(action.payload.message); + return { + ...state, + isPosting: false, + }; + } + case 'SCRATCHPAD_RECORDS_LOAD_SUBMISSIONS_PENDING': { + return { + ...state, + records: { + ...state.records, + isLoading: true, + }, + }; + } + case 'SCRATCHPAD_RECORDS_LOAD_SUBMISSIONS_REJECTED': { + Notification.error(action.payload.message); + return { + ...state, + records: { + ...state.records, + isLoading: false, + }, + }; + } + case 'SCRATCHPAD_RECORDS_LOAD_SUBMISSIONS_FULFILLED': { + return { + ...state, + records: { + ...state.records, + isLoading: false, + }, + }; + } + default: + return state; } } diff --git a/packages/ui-default/locales/zh.yaml b/packages/ui-default/locales/zh.yaml index 73a82ef28..7aaa46934 100644 --- a/packages/ui-default/locales/zh.yaml +++ b/packages/ui-default/locales/zh.yaml @@ -118,8 +118,8 @@ Change Email: 修改电子邮件 Change Password: 修改密码 Change Theme: 切换主题 Check In: 签到 -CheckerType: 比较器类型 Checker: 比较器 +CheckerType: 比较器类型 Chinese: 中文 Choose the background image in your profile page.: 选择您资料页面的背景图片。 Claim homework: 认领作业 @@ -270,7 +270,6 @@ domain_user: 管理用户 domain: 域 Domain: 域 Don't have an account?: 还没有账户? -Don't ignore space and enter.: 不忽略行末回车与空格。 Done: 已结束 Download All: 下载全部 Download Dataset: 下载数据集 @@ -388,6 +387,7 @@ Host: 主持人 hour(s): 小时 If enabled, source code will be emailed to you after the submission is accepted.: 如果启用,在您通过题目后,源代码会以 Email 的形式发送给您。 If left blank, the built-in template of the corresponding language will be used.: 若留空,则将使用对应语言的内置代码模板。 +Ignore trailing space and enter.: 忽略行末空格与文件尾回车。 Images With Size: 指定大小的图片 Images: 图片 Import from: 导入自 @@ -423,15 +423,15 @@ Italic: 斜体 Join: 加入 Judge Config: 评测设置 Judge Status: 评测状态 -judge_playground: 评测游乐场 judge_extra_files: 评测额外文件 +judge_playground: 评测游乐场 Judged At: 评测时间 Judged By: 评测机 Judging Queue: 评测队列 Keep current expiration: 保持当前过期设置 +Langs Config: 语言设置 langs: 语言 Language: 语言 -Langs Config: 语言设置 last active at: 最后活动于 last login at: 最后登录于 Last Submit At: 最后递交于 @@ -594,8 +594,8 @@ Problem Solution List: 题解列表 Problem Solution: 题解列表 Problem Tags Visibility: 题目标签可见性 Problem Tags: 题目标签 -problem_create: 创建题目 problem_config: 评测设置 +problem_create: 创建题目 problem_edit: 编辑题目 problem_import: 导入题目 problem_main: 题库 @@ -768,8 +768,8 @@ SuperUser: 超级管理员 Support: 支持 Switch account: 切换账户 Tags: 标签 -tasks_list: 任务列表 Task Settings: 任务设置 +tasks_list: 任务列表 Technical Information: 技术信息 Temporary session: 临时会话 Terms of Service: 服务条款 diff --git a/packages/ui-default/package.json b/packages/ui-default/package.json index 5a3abccdd..9df5f5240 100644 --- a/packages/ui-default/package.json +++ b/packages/ui-default/package.json @@ -10,7 +10,7 @@ "lint": "eslint ." }, "devDependencies": { - "@blueprintjs/core": "^3.54.0", + "@blueprintjs/core": "^4.2.1", "@fontsource/dm-mono": "^4.5.7", "@fontsource/fira-code": "^4.5.8", "@fontsource/inconsolata": "^4.5.4", @@ -37,6 +37,7 @@ "clipboard": "^2.0.10", "copy-webpack-plugin": "^6.4.1", "css-loader": "^4.3.0", + "diff": "^5.0.0", "diff-dom": "^4.2.3", "echarts": "^5.3.2", "emojis-keywords": "2.0.0", diff --git a/packages/ui-default/pages/home_files.page.js b/packages/ui-default/pages/home_files.page.js index 1aca098df..e2f3ee0c2 100644 --- a/packages/ui-default/pages/home_files.page.js +++ b/packages/ui-default/pages/home_files.page.js @@ -40,12 +40,12 @@ async function handleClickUpload(files) { const dialog = new Dialog({ $body: `
-
-
+
+
-
-
+
+
`, }); try { diff --git a/packages/ui-default/pages/homework_main.page.js b/packages/ui-default/pages/homework_main.page.js index 4dc9a6d58..842e223b2 100644 --- a/packages/ui-default/pages/homework_main.page.js +++ b/packages/ui-default/pages/homework_main.page.js @@ -19,16 +19,16 @@ const page = new NamedPage('homework_main', () => { calendar.getDom().appendTo('[name="calendar_entry"]'); $('[name="homework_display"]').change((ev) => { switch (ev.currentTarget.value) { - case 'calendar': - $('.homework__list').hide(); - $('[name="calendar_entry"]').show(); - break; - case 'list': - $('.homework__list').show(); - $('[name="calendar_entry"]').hide(); - break; - default: - throw new Error('Unexpected display parameter'); + case 'calendar': + $('.homework__list').hide(); + $('[name="calendar_entry"]').show(); + break; + case 'list': + $('.homework__list').show(); + $('[name="calendar_entry"]').hide(); + break; + default: + throw new Error('Unexpected display parameter'); } }); } diff --git a/packages/ui-default/pages/problem_config.page.styl b/packages/ui-default/pages/problem_config.page.styl index 304845f7e..0166572f8 100644 --- a/packages/ui-default/pages/problem_config.page.styl +++ b/packages/ui-default/pages/problem_config.page.styl @@ -15,9 +15,57 @@ width: 80px .col--operation width: 40px + text-align: center .textbox,.select margin: 0 - .bp3-tab-panel - margin-top: 5px \ No newline at end of file + .bp4-tab-panel + margin-top: 5px + + .problem-config-form + font-family: var(--code-font-family) + + input::-webkit-outer-spin-button, input::-webkit-inner-spin-button + -webkit-appearance: none !important + input[type="number"] + -moz-appearance: textfield + + .bp4-tag.bp4-minimal + background: transparent + .bp4-input-action + pointer-events: none + .bp4-input-group + input + appearance: none + display: block + width: 100% + font-size: 0.875rem + height: 2.1875rem + line-height: 1.2 + padding: 0.3125rem; + border: 1px solid #ddd + color: inherit + border-radius: 0 + background-color: transparent + outline: 2px solid transparent + transition: outline-color 0.2s, border-color 0.2s + transition-timing-function: cubic-bezier(0.215, 0.61, 0.355, 1) + box-shadow: none + 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 \ No newline at end of file diff --git a/packages/ui-default/pages/problem_config.page.tsx b/packages/ui-default/pages/problem_config.page.tsx index 51334c9f8..06eeeef47 100644 --- a/packages/ui-default/pages/problem_config.page.tsx +++ b/packages/ui-default/pages/problem_config.page.tsx @@ -7,7 +7,9 @@ import yaml from 'js-yaml'; import Notification from 'vj/components/notification'; import Dialog, { ConfirmDialog } from 'vj/components/dialog/index'; import download from 'vj/components/zipDownloader'; -import { size, readCasesFromFiles, readSubtasksFromFiles } from '@hydrooj/utils/lib/common'; +import { + size, readSubtasksFromFiles, parseTimeMS, parseMemoryMB, +} from '@hydrooj/utils/lib/common'; import tpl from 'vj/utils/tpl'; async function handleSection(ev: JQuery.ClickEvent, type: string) { @@ -29,10 +31,6 @@ function onBeforeUnload(e) { e.returnValue = ''; } -function ensureFile(testdata) { - return (file: string) => testdata.filter((i) => i === file)[0]; -} - const page = new NamedPage('problem_config', () => { let reduxStore; @@ -46,12 +44,12 @@ const page = new NamedPage('problem_config', () => { const dialog = new Dialog({ $body: `
-
-
+
+
-
-
+
+
`, }); try { @@ -147,7 +145,7 @@ const page = new NamedPage('problem_config', () => { await download(`${pdoc.docId} ${pdoc.title}.zip`, targets); } - async function uploadConfig(config:object) { + async function uploadConfig(config: object) { const configYaml = yaml.dump(config); Notification.info(i18n('Saving file...')); const data = new FormData(); @@ -161,9 +159,11 @@ const page = new NamedPage('problem_config', () => { } 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 [{ default: ProblemConfigEditor }, { default: ProblemConfigForm }, { default: ProblemConfigReducer }] = await Promise.all([ + import('vj/components/problemconfig/ProblemConfigEditor'), + import('vj/components/problemconfig/ProblemConfigForm'), + import('vj/components/problemconfig/reducer'), + ]); const { React, render, Provider, store, @@ -175,6 +175,26 @@ const page = new NamedPage('problem_config', () => { type: 'CONFIG_LOAD', payload: request.get(), }); + const unsubscribe = store.subscribe(() => { + // TODO set yaml schema + const state = store.getState(); + if (!state.config.__loaded) return; + if (state.config.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(
@@ -182,20 +202,7 @@ const page = new NamedPage('problem_config', () => {
- { - 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, - }); - }} - /> +
diff --git a/packages/ui-default/pages/problem_edit.page.js b/packages/ui-default/pages/problem_edit.page.js index 3e5c4f8e0..759ccec00 100644 --- a/packages/ui-default/pages/problem_edit.page.js +++ b/packages/ui-default/pages/problem_edit.page.js @@ -202,12 +202,12 @@ export default new NamedPage(['problem_create', 'problem_edit'], (pagename) => { const dialog = new Dialog({ $body: `
-
-
+
+
-
-
+
+
`, }); try { diff --git a/packages/ui-default/pages/problem_files.page.js b/packages/ui-default/pages/problem_files.page.js index 1b4e08311..1f633665f 100644 --- a/packages/ui-default/pages/problem_files.page.js +++ b/packages/ui-default/pages/problem_files.page.js @@ -51,12 +51,12 @@ const page = new NamedPage('problem_files', () => { const dialog = new Dialog({ $body: `
-
-
+
+
-
-
+
+
`, }); try { diff --git a/packages/ui-default/templates/contest_detail.html b/packages/ui-default/templates/contest_detail.html index f22c726fa..64b521654 100644 --- a/packages/ui-default/templates/contest_detail.html +++ b/packages/ui-default/templates/contest_detail.html @@ -11,14 +11,14 @@

{{ tdoc.title }}

- {{ _(model.contest.statusText(tdoc)) }} - {{ _(model.contest.RULES[tdoc.rule].TEXT) }} - {{ _('Start at') }}: {{ contest.render_time(tdoc.beginAt) }} - {{ contest.render_duration(tdoc) }} {{ _('hour(s)') }} - {{ _('Host') }}: {{ user.render_inline(udict[tdoc.owner], badge=false) }} - {{ tdoc['attend']|default(0) }} + {{ _(model.contest.statusText(tdoc)) }} + {{ _(model.contest.RULES[tdoc.rule].TEXT) }} + {{ _('Start at') }}: {{ contest.render_time(tdoc.beginAt) }} + {{ contest.render_duration(tdoc) }} {{ _('hour(s)') }} + {{ _('Host') }}: {{ user.render_inline(udict[tdoc.owner], badge=false) }} + {{ tdoc['attend']|default(0) }} {% if tsdoc.attend %} - {{ _('Attended') }} + {{ _('Attended') }} {% endif %}
diff --git a/packages/ui-default/templates/problem_detail.html b/packages/ui-default/templates/problem_detail.html index 69b0fdb26..5929c7849 100644 --- a/packages/ui-default/templates/problem_detail.html +++ b/packages/ui-default/templates/problem_detail.html @@ -49,22 +49,22 @@
    - {{ _('ID') }}: {{ pdoc.docId }} - {{ _('problem_type.' + pdoc.config.type).format(pdoc.config.subType) }} + {{ _('ID') }}: {{ pdoc.docId }} + {{ _('problem_type.' + pdoc.config.type).format(pdoc.config.subType) }} {% if pdoc.config.type != 'objective' and pdoc.config.type != 'submit_answer' %} - {{ pdoc.config.timeMax if pdoc.config.timeMax == pdoc.config.timeMin else pdoc.config.timeMin+'~'+pdoc.config.timeMax }}ms - {{ pdoc.config.memoryMax if pdoc.config.memoryMax == pdoc.config.memoryMin else pdoc.config.memoryMin+'~'+pdoc.config.memoryMax }}MiB + {{ pdoc.config.timeMax if pdoc.config.timeMax == pdoc.config.timeMin else pdoc.config.timeMin+'~'+pdoc.config.timeMax }}ms + {{ pdoc.config.memoryMax if pdoc.config.memoryMax == pdoc.config.memoryMin else pdoc.config.memoryMin+'~'+pdoc.config.memoryMax }}MiB {% endif %} {% if not tdoc %} - {{ _('Tried') }}: {{ pdoc.nSubmit }} - {{ _('Accepted') }}: {{ pdoc.nAccept if ((not tdoc) or showAccept) else '?' }} - {{ _('Difficulty') }}: {{ pdoc['difficulty'] or lib.difficulty(pdoc.nSubmit, pdoc.nAccept) or _('(None)') }} + {{ _('Tried') }}: {{ pdoc.nSubmit }} + {{ _('Accepted') }}: {{ pdoc.nAccept if ((not tdoc) or showAccept) else '?' }} + {{ _('Difficulty') }}: {{ pdoc['difficulty'] or lib.difficulty(pdoc.nSubmit, pdoc.nAccept) or _('(None)') }} {% endif %} - {{ _('Uploaded By') }}: {{ user.render_inline(udoc, badge=false) }} + {{ _('Uploaded By') }}: {{ user.render_inline(udoc, badge=false) }} {% if pdoc.tag.length %} - {{ _('Tags') + '>' }} + {{ _('Tags') + '>' }} {%- for tag in pdoc.tag -%} - {{ tag }} + {{ tag }} {%- endfor -%} {% endif %}
diff --git a/packages/ui-default/theme/dark.styl b/packages/ui-default/theme/dark.styl index 93f58cdf1..f0f03f2b9 100644 --- a/packages/ui-default/theme/dark.styl +++ b/packages/ui-default/theme/dark.styl @@ -212,7 +212,7 @@ $hover-background-color = #424242 .menu__link.highlight background: rgb(40, 11, 20) - .bp3-tag.bp3-minimal:not([class*="bp3-intent-"]) + .bp4-tag.bp4-minimal:not([class*="bp4-intent-"]) background-color: rgba(87, 100, 117, .2) color: #d9dfe7 diff --git a/packages/ui-default/utils/i18n.js b/packages/ui-default/utils/i18n.js index bc5b6464d..7cb5aab10 100644 --- a/packages/ui-default/utils/i18n.js +++ b/packages/ui-default/utils/i18n.js @@ -1,6 +1,7 @@ import substitute from './substitute'; export default function i18n(str, ...params) { + if (!str) return ''; return substitute(window.LOCALES?.[str] || str, params); } diff --git a/packages/utils/lib/common.ts b/packages/utils/lib/common.ts index 9469bb70b..1edd95a88 100644 --- a/packages/utils/lib/common.ts +++ b/packages/utils/lib/common.ts @@ -137,14 +137,14 @@ const MEMORY_RE = /^([0-9]+(?:\.[0-9]*)?)([kmg])b?$/i; const MEMORY_UNITS = { k: 1 / 1024, m: 1, g: 1024 }; export function parseTimeMS(str: string | number) { - if (typeof str === 'number') return str; + if (typeof str === 'number' || Number.isSafeInteger(+str)) return +str; const match = TIME_RE.exec(str); if (!match) throw new Error(`${str} error parsing time`); return Math.floor(parseFloat(match[1]) * TIME_UNITS[match[2].toLowerCase()]); } export function parseMemoryMB(str: string | number) { - if (typeof str === 'number') return str; + if (typeof str === 'number' || Number.isSafeInteger(+str)) return +str; const match = MEMORY_RE.exec(str); if (!match) throw new Error(`${str} error parsing memory`); return Math.ceil(parseFloat(match[1]) * MEMORY_UNITS[match[2].toLowerCase()]); @@ -161,19 +161,17 @@ export function size(s: number, base = 1) { return `${Math.round(s * unit)} ${unitNames[unitNames.length - 1]}`; } -interface Re0 { - reg: RegExp, - output: ((a: RegExpExecArray) => string)[], - id: (a: RegExpExecArray) => number, +interface MatchRule { + regex: RegExp; + output: ((a: RegExpExecArray) => string)[]; + id: (a: RegExpExecArray) => number; + subtask: (a: RegExpExecArray) => number; + preferredScorerType: 'min' | 'max' | 'sum'; } -interface Re1 extends Re0 { - subtask: (a: RegExpExecArray) => number, -} - -const RE0: Re0[] = [ +const SubtaskMatcher: MatchRule[] = [ { - reg: /^([^\d]*)(\d+).(in|txt)$/, + regex: /^([^\d]*)(\d+).(in|txt)$/, output: [ (a) => `${a[1] + a[2]}.out`, (a) => `${a[1] + a[2]}.ans`, @@ -181,108 +179,52 @@ const RE0: Re0[] = [ (a) => (a[1].includes('input') ? `${a[1] + a[2]}.txt`.replace(/input/g, 'output') : null), ], id: (a) => +a[2], + subtask: () => 0, + preferredScorerType: 'sum', }, { - reg: /^([^\d]*)\.in(\d+)$/, + regex: /^([^\d]*)\.in(\d+)$/, output: [ (a) => `${a[1]}.ou${a[2]}`, (a) => `${a[1]}.ou${a[2]}`.replace(/input/g, 'output'), ], id: (a) => +a[2], + subtask: () => 0, + preferredScorerType: 'sum', }, -]; -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`], - subtask: (a) => +a[2], id: (a) => +a[4], + subtask: (a) => +a[2], + preferredScorerType: 'min', }, ]; -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) { +export function readSubtasksFromFiles(files: string[], checkFile, cfg, rst) { 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 = []; 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 rule of SubtaskMatcher) { + const data = rule.regex.exec(file); + if (!data) continue; + const sid = rule.subtask(data); + const c = { input: file, output: '', id: rule.id(data) }; + for (const func of rule.output) { + if (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: rule.preferredScorerType, + cases: [c], + }; + } else if (!subtask[sid].cases) subtask[sid].cases = [c]; + else subtask[sid].cases.push(c); + break; } } } @@ -291,6 +233,7 @@ export async function readSubtasksFromFiles(files: string[], checkFile, cfg, rst subtask[i].cases.sort((a, b) => (a.id - b.id)); subtasks.push(subtask[i]); } + subtasks.sort((a, b) => (a.id - b.id)); const base = Math.floor(100 / subtasks.length); const extra = subtasks.length - (100 % subtasks.length); const config = { count: 0, subtasks };