diff --git a/packages/hydrooj/src/handler/problem.ts b/packages/hydrooj/src/handler/problem.ts index bb6e0f8eb..2b0c6b038 100644 --- a/packages/hydrooj/src/handler/problem.ts +++ b/packages/hydrooj/src/handler/problem.ts @@ -667,6 +667,7 @@ export class ProblemFilesHandler extends ProblemDetailHandler { @param('pjax', Types.Boolean) @param('sidebar', Types.Boolean) async get(domainId: string, d = ['testdata', 'additional_file'], pjax = false, sidebar = false) { + if (this.tdoc) throw new ContestNotEndedError(); this.response.body.testdata = d.includes('testdata') ? sortFiles(this.pdoc.data || []) : []; this.response.body.reference = this.pdoc.reference; this.response.body.additional_file = d.includes('additional_file') ? sortFiles(this.pdoc.additional_file || []) : []; @@ -972,6 +973,32 @@ export class ProblemSolutionRawHandler extends ProblemDetailHandler { } } +export class ProblemStatisticsHandler extends ProblemDetailHandler { + @param('sort', Types.Range(Object.keys(record.STAT_QUERY)), true) + @param('direction', Types.Range([-1, 1]), true) + @param('lang', Types.String, true) + @param('page', Types.PositiveInt, true) + async get(domainId: string, sort = 'time', direction: 1 | -1 = 1, lang?: string, page = 1) { + if (this.tdoc) throw new ContestNotEndedError(); + const [rsdocs, pcount, rscount] = await this.paginate( + record.getMultiStat(domainId, { + pid: this.pdoc.docId, + ...lang ? { lang } : {}, + }, record.STAT_QUERY[sort][Math.max(direction, 0)]), + page, + 'record', + ); + const [udict, udoc] = await Promise.all([ + user.getListForRender(domainId, rsdocs.map((i) => i.uid), this.user.hasPerm(PERM.PERM_VIEW_DISPLAYNAME)), + user.getById(domainId, this.pdoc.owner), + ]); + this.response.template = 'problem_statistics.html'; + this.response.body = { + rsdocs, page, pcount, rscount, sort, direction, pdoc: this.pdoc, udict, types: Object.keys(record.STAT_QUERY), udoc, + }; + } +} + export class ProblemCreateHandler extends Handler { async get() { this.response.template = 'problem_edit.html'; @@ -1014,7 +1041,7 @@ export class ProblemCreateHandler extends Handler { export class ProblemPrefixListHandler extends Handler { @param('prefix', Types.Name) async get(domainId: string, prefix: string) { - const projection = ['domainId', 'docId', 'pid', 'title'] as const; + const projection = ['domainId', 'docId', 'pid', 'title', 'hidden'] as const; const [pdocs, pdoc, apdoc] = await Promise.all([ problem.getPrefixList(domainId, prefix), problem.get(domainId, Number.isSafeInteger(+prefix) ? +prefix : prefix, projection), @@ -1047,6 +1074,7 @@ export async function apply(ctx) { ctx.Route('problem_solution_detail', '/p/:pid/solution/:sid', ProblemSolutionHandler, PERM.PERM_VIEW_PROBLEM); ctx.Route('problem_solution_raw', '/p/:pid/solution/:psid/raw', ProblemSolutionRawHandler, PERM.PERM_VIEW_PROBLEM); ctx.Route('problem_solution_reply_raw', '/p/:pid/solution/:psid/:psrid/raw', ProblemSolutionRawHandler, PERM.PERM_VIEW_PROBLEM); + ctx.Route('problem_statistics', '/p/:pid/stat', ProblemStatisticsHandler, PERM.PERM_VIEW_PROBLEM); ctx.Route('problem_create', '/problem/create', ProblemCreateHandler, PERM.PERM_CREATE_PROBLEM); ctx.Route('problem_prefix_list', '/problem/list', ProblemPrefixListHandler, PERM.PERM_VIEW_PROBLEM); } diff --git a/packages/hydrooj/src/interface.ts b/packages/hydrooj/src/interface.ts index 7f57a3637..ae614a253 100644 --- a/packages/hydrooj/src/interface.ts +++ b/packages/hydrooj/src/interface.ts @@ -338,6 +338,16 @@ export interface RecordDoc { subtasks?: Record; } +export interface RecordStatDoc { + _id: ObjectId; + domainId: string; + pid: number; + uid: number; + time: number; + memory: number; + code: number; + lang: string; +} export interface JudgeMeta { problemOwner: number; hackRejudge?: string; @@ -688,6 +698,7 @@ declare module './service/db' { 'domain': DomainDoc; 'domain.user': any; 'record': RecordDoc; + 'record.stat': RecordStatDoc; 'document': any; 'document.status': StatusDocBase & { [K in keyof DocStatusType]: { docType: K } & DocStatusType[K]; diff --git a/packages/hydrooj/src/model/record.ts b/packages/hydrooj/src/model/record.ts index 368d75cfd..dfb70ff29 100644 --- a/packages/hydrooj/src/model/record.ts +++ b/packages/hydrooj/src/model/record.ts @@ -19,6 +19,7 @@ import task from './task'; export default class RecordModel { static coll = db.collection('record'); + static collStat = db.collection('record.stat'); static PROJECTION_LIST: (keyof RecordDoc)[] = [ '_id', 'score', 'time', 'memory', 'lang', 'uid', 'pid', 'rejudged', 'progress', 'domainId', @@ -26,6 +27,13 @@ export default class RecordModel { 'files', ]; + static STAT_QUERY = { + time: [{ time: -1 }, { time: 1 }], + memory: [{ memory: -1 }, { memory: 1 }], + length: [{ length: -1 }, { length: 1 }], + date: [{ _id: -1 }, { _id: 1 }], + }; + static RECORD_PRETEST = new ObjectId('000000000000000000000000'); static RECORD_GENERATE = new ObjectId('000000000000000000000001'); @@ -163,6 +171,10 @@ export default class RecordModel { return RecordModel.coll.find(query); } + static getMultiStat(domainId: string, query: any, sortBy: any = { _id: -1 }) { + return RecordModel.collStat.find({ domainId, ...query }).sort(sortBy); + } + static async update( domainId: string, _id: MaybeArray, $set?: MatchKeysAndValues, @@ -218,6 +230,7 @@ export default class RecordModel { judger: null, }; if (isRejudge) upd.rejudged = true; + await RecordModel.collStat.deleteOne(rid instanceof Array ? { _id: { $in: rid } } : { _id: rid }); await task.deleteMany(rid instanceof Array ? { rid: { $in: rid } } : { rid }); return RecordModel.update(domainId, rid, upd); } @@ -243,14 +256,36 @@ export function apply(ctx: Context) { // Mark problem as deleted ctx.on('problem/delete', (domainId, docId) => RecordModel.coll.deleteMany({ domainId, pid: docId })); ctx.on('domain/delete', (domainId) => RecordModel.coll.deleteMany({ domainId })); - ctx.on('ready', () => db.ensureIndexes( - RecordModel.coll, - { key: { domainId: 1, contest: 1, _id: -1 }, name: 'basic' }, - { key: { domainId: 1, contest: 1, uid: 1, _id: -1 }, name: 'withUser' }, - { key: { domainId: 1, contest: 1, pid: 1, _id: -1 }, name: 'withProblem' }, - { key: { domainId: 1, contest: 1, pid: 1, uid: 1, _id: -1 }, name: 'withUserAndProblem' }, - { key: { domainId: 1, contest: 1, status: 1, _id: -1 }, name: 'withStatus' }, - )); + ctx.on('record/judge', (rdoc, updated) => { + if (rdoc.status === STATUS.STATUS_ACCEPTED && updated) { + RecordModel.collStat.insertOne({ + _id: rdoc._id, + domainId: rdoc.domainId, + pid: rdoc.pid, + uid: rdoc.uid, + time: rdoc.time, + memory: rdoc.memory, + code: rdoc.code?.length || 0, + lang: rdoc.lang, + }); + } + }); + ctx.on('ready', async () => { + await db.ensureIndexes( + RecordModel.coll, + { key: { domainId: 1, contest: 1, _id: -1 }, name: 'basic' }, + { key: { domainId: 1, contest: 1, uid: 1, _id: -1 }, name: 'withUser' }, + { key: { domainId: 1, contest: 1, pid: 1, _id: -1 }, name: 'withProblem' }, + { key: { domainId: 1, contest: 1, pid: 1, uid: 1, _id: -1 }, name: 'withUserAndProblem' }, + { key: { domainId: 1, contest: 1, status: 1, _id: -1 }, name: 'withStatus' }, + ); + await db.ensureIndexes( + RecordModel.collStat, + { key: { domainId: 1, pid: 1, uid: 1, _id: -1 }, name: 'basic' }, + { key: { domainId: 1, pid: 1, uid: 1, time: 1 }, name: 'time' }, + { key: { domainId: 1, pid: 1, uid: 1, memory: 1 }, name: 'memory' }, + { key: { domainId: 1, pid: 1, uid: 1, code: 1 }, name: 'code' }, + ); + }); } - global.Hydro.model.record = RecordModel; diff --git a/packages/hydrooj/src/upgrade.ts b/packages/hydrooj/src/upgrade.ts index 93b5ae87a..bbc358004 100644 --- a/packages/hydrooj/src/upgrade.ts +++ b/packages/hydrooj/src/upgrade.ts @@ -649,6 +649,28 @@ const scripts: UpgradeScript[] = [ await domain.setRoles(ddoc._id, ddoc.roles); }); }, + async function _88_89() { + const cursor = RecordModel.getMulti(undefined, { status: STATUS.STATUS_ACCEPTED }); + let bulk = []; + for await (const doc of cursor) { + bulk.push({ + _id: doc._id, + domainId: doc.domainId, + pid: doc.pid, + uid: doc.uid, + time: doc.time, + memory: doc.memory, + code: doc.code?.length || 0, + lang: doc.lang, + }); + if (bulk.length > 500) { + await RecordModel.collStat.insertMany(bulk); + bulk = []; + } + } + if (bulk.length) await RecordModel.collStat.insertMany(bulk); + return true; + }, ]; export default scripts; diff --git a/packages/ui-default/locales/zh.yaml b/packages/ui-default/locales/zh.yaml index e0d311d5c..441e0ad75 100644 --- a/packages/ui-default/locales/zh.yaml +++ b/packages/ui-default/locales/zh.yaml @@ -708,6 +708,10 @@ problem.import.input_format: '## 输入格式' problem.import.limit_and_hint: '## 限制' problem.import.output_format: '## 输出格式' problem.import.problem_description: '## 题目描述' +problem.stats.sort.date: 提交时间 +problem.stats.sort.length: 代码长度 +problem.stats.sort.memory: 内存占用 +problem.stats.sort.time: 运行时间 Problems Sets: 题库 Problems: 题目 Processing...: 处理中... diff --git a/packages/ui-default/pages/problem_detail.page.jsx b/packages/ui-default/pages/problem_detail.page.jsx index 7dadd92b7..1816d650f 100644 --- a/packages/ui-default/pages/problem_detail.page.jsx +++ b/packages/ui-default/pages/problem_detail.page.jsx @@ -1,4 +1,3 @@ -import { getScoreColor } from '@hydrooj/utils/lib/status'; import $ from 'jquery'; import yaml from 'js-yaml'; import React from 'react'; @@ -101,7 +100,7 @@ class ProblemPageExtender { } } -const page = new NamedPage(['problem_detail', 'contest_detail_problem', 'homework_detail_problem'], async (pagename) => { +const page = new NamedPage(['problem_detail', 'contest_detail_problem', 'homework_detail_problem'], async () => { let reactLoaded = false; let renderReact = null; let unmountReact = null; @@ -306,57 +305,6 @@ const page = new NamedPage(['problem_detail', 'contest_detail_problem', 'homewor $('.outer-loader-container').hide(); } - async function initChart() { - if (!Object.keys(UiContext.pdoc.stats || {}).length) { - $('#submission-status-placeholder').parent().hide(); - return; - } - const echarts = await import('echarts'); - const $status = document.getElementById('submission-status-placeholder'); - const statusChart = echarts.init($status); - statusChart.setOption({ - tooltip: { trigger: 'item' }, - series: [ - { - name: 'Submissions', - type: 'pie', - radius: '70%', - label: { show: false }, - labelLine: { show: false }, - data: [ - { value: UiContext.pdoc.stats.TLE, name: 'TLE' }, - { value: UiContext.pdoc.stats.AC, name: 'AC' }, - { value: UiContext.pdoc.stats.MLE, name: 'MLE' }, - { value: UiContext.pdoc.stats.WA, name: 'WA' }, - { value: UiContext.pdoc.stats.RE, name: 'RE' }, - { value: UiContext.pdoc.stats.CE, name: 'CE' }, - ], - }, - ], - }); - const $score = document.getElementById('submission-score-placeholder'); - const x = Array.from({ length: 101 }, (v, i) => i).filter((i) => UiContext.pdoc.stats[`s${i}`]); - const scoreChart = echarts.init($score); - scoreChart.setOption({ - tooltip: { trigger: 'item' }, - xAxis: { data: x }, - yAxis: {}, - series: [{ - data: x.map((i) => ({ - value: UiContext.pdoc.stats[`s${i}`], - itemStyle: { color: getScoreColor(i) }, - })), - type: 'bar', - }], - }); - - window.onresize = function () { - statusChart.resize(); - scoreChart.resize(); - }; - if (UiContext.pdoc.config?.type === 'objective') $($status).hide(); - } - $(document).on('click', '[name="problem-sidebar__open-scratchpad"]', (ev) => { enterScratchpadMode(); ev.preventDefault(); @@ -380,7 +328,6 @@ const page = new NamedPage(['problem_detail', 'contest_detail_problem', 'homewor }); $('[name="problem-sidebar__download"]').on('click', handleClickDownloadProblem); if (UiContext.pdoc.config?.type === 'objective') loadObjective(); - if (pagename !== 'contest_detail_problem') initChart(); }); export default page; diff --git a/packages/ui-default/pages/problem_statistics.page.styl b/packages/ui-default/pages/problem_statistics.page.styl new file mode 100644 index 000000000..5f897d632 --- /dev/null +++ b/packages/ui-default/pages/problem_statistics.page.styl @@ -0,0 +1,14 @@ +.page--problem_statistics + .col--status + width: rem(80px) + + .col--submit-at + width: rem(160px) + text-align: center + + .col--time, .col--memory, .col--lang, .col--code + width: rem(85px) + text-align: center + + .col--status, .col--submit-by, .col--code + border-right: 1px solid $table-border-color \ No newline at end of file diff --git a/packages/ui-default/pages/problem_statistics.page.ts b/packages/ui-default/pages/problem_statistics.page.ts new file mode 100644 index 000000000..2f6a10f72 --- /dev/null +++ b/packages/ui-default/pages/problem_statistics.page.ts @@ -0,0 +1,69 @@ +import { getScoreColor } from '@hydrooj/utils/lib/status'; +import $ from 'jquery'; +import { NamedPage } from 'vj/misc/Page'; +import { getAvailableLangs, tpl } from 'vj/utils'; + +const page = new NamedPage('problem_statistics', () => { + async function initChart() { + if (!Object.keys(UiContext.pdoc.stats || {}).length) { + $('#submission-status-placeholder').parent().hide(); + return; + } + const echarts = await import('echarts'); + const $status = document.getElementById('submission-status-placeholder'); + const statusChart = echarts.init($status); + statusChart.setOption({ + tooltip: { trigger: 'item' }, + series: [ + { + name: 'Submissions', + type: 'pie', + radius: '70%', + label: { show: false }, + labelLine: { show: false }, + data: [ + { value: UiContext.pdoc.stats.TLE, name: 'TLE' }, + { value: UiContext.pdoc.stats.AC, name: 'AC' }, + { value: UiContext.pdoc.stats.MLE, name: 'MLE' }, + { value: UiContext.pdoc.stats.WA, name: 'WA' }, + { value: UiContext.pdoc.stats.RE, name: 'RE' }, + { value: UiContext.pdoc.stats.CE, name: 'CE' }, + ], + }, + ], + }); + const $score = document.getElementById('submission-score-placeholder'); + const x = Array.from({ length: 101 }, (v, i) => i).filter((i) => UiContext.pdoc.stats[`s${i}`]); + const scoreChart = echarts.init($score); + scoreChart.setOption({ + tooltip: { trigger: 'item' }, + xAxis: { data: x }, + yAxis: {}, + series: [{ + data: x.map((i) => ({ + value: UiContext.pdoc.stats[`s${i}`], + itemStyle: { color: getScoreColor(i) }, + })), + type: 'bar', + }], + }); + + window.onresize = function () { + statusChart.resize(); + scoreChart.resize(); + }; + if (UiContext.pdoc.config?.type === 'objective') $($status).hide(); + } + + const availableLangs = getAvailableLangs(UiContext.pdoc.config.langs); + Object.keys(availableLangs).map( + (i) => ($('select[name="lang"]').append(tpl``))); + const lang = new URL(window.location.href).searchParams.get('lang'); + if (lang) $('select[name="lang"]').val(lang); + initChart(); + $('[name="filter-form"] select').on('change', () => { + $('[name="filter-form"]').trigger('submit'); + }); +}); + +export default page; diff --git a/packages/ui-default/templates/partials/problem_sidebar_normal.html b/packages/ui-default/templates/partials/problem_sidebar_normal.html index bbdde373e..a5a2277f1 100644 --- a/packages/ui-default/templates/partials/problem_sidebar_normal.html +++ b/packages/ui-default/templates/partials/problem_sidebar_normal.html @@ -78,6 +78,9 @@ + {% if handler.user.own(pdoc, perm.PERM_EDIT_PROBLEM_SELF) or handler.user.hasPerm(perm.PERM_EDIT_PROBLEM) %}