From 720eb6b7e0693cf392f9de693f860fec302c3f35 Mon Sep 17 00:00:00 2001 From: undefined Date: Sun, 17 Aug 2025 12:02:45 +0800 Subject: [PATCH] core&ui: contest: add print (#1036) Co-authored-by: panda --- packages/hydrooj/locales/en.yaml | 1 + packages/hydrooj/locales/zh.yaml | 4 +- packages/hydrooj/src/handler/contest.ts | 78 +++++- packages/hydrooj/src/interface.ts | 12 + packages/hydrooj/src/model/contest.ts | 46 +++- packages/hydrooj/src/model/document.ts | 5 +- packages/hydrooj/src/model/user.ts | 4 +- packages/ui-default/locales/zh.yaml | 1 + packages/ui-default/misc/icons/print.svg | 1 + .../ui-default/pages/contest_print.page.tsx | 233 ++++++++++++++++++ .../ui-default/templates/contest_edit.html | 8 + .../ui-default/templates/contest_print.html | 27 ++ .../templates/partials/contest_sidebar.html | 5 + 13 files changed, 419 insertions(+), 6 deletions(-) create mode 100644 packages/ui-default/misc/icons/print.svg create mode 100644 packages/ui-default/pages/contest_print.page.tsx create mode 100644 packages/ui-default/templates/contest_print.html diff --git a/packages/hydrooj/locales/en.yaml b/packages/hydrooj/locales/en.yaml index e33197b61..3c5f6b132 100644 --- a/packages/hydrooj/locales/en.yaml +++ b/packages/hydrooj/locales/en.yaml @@ -10,6 +10,7 @@ contest_detail: Contest Detail contest_edit: Contest Edit contest_main: Contest contest_manage: Contest Management +contest_print: Print contest_problemlist: Problem List contest_scoreboard: Scoreboard contest_user: Attendee Manage diff --git a/packages/hydrooj/locales/zh.yaml b/packages/hydrooj/locales/zh.yaml index 8a09af1fb..af35c8b2b 100644 --- a/packages/hydrooj/locales/zh.yaml +++ b/packages/hydrooj/locales/zh.yaml @@ -32,13 +32,13 @@ __langname: 简体中文 'Join {0}': '加入 {0}' 'No': 否 'Note: Problem title may not be hidden.': 注意:题目标题可能不会被隐藏。 +'Or upload a file:': 或者上传一个文件: 'Original score: {0}': '原得分: {0}' 'Role {1} already exists in domain {0}.': '域 {0} 中已存在角色 {1}。' 'Solved {0} problems, RP: {1} (No. {2})': '解决了 {0} 道题目,RP: {1} (No. {2})' 'The value `{1}` of {0} already exists.': '{0} 的值 `{1}` 已经存在。' 'Yes': 是 "Effects only when Difficulty is not 'Use algorithm calculated'.": 仅当难度不为“使用算法计算”时才起效。 -'Or upload a file:': 或者上传一个文件: "Split by ',' '.'": 由“, ”或“,”分隔。 "What's file?": 什么是文件? (All Problems): (所有题目) @@ -167,6 +167,7 @@ contest_detail: 比赛详情 contest_edit: 编辑比赛 contest_main: 比赛 contest_manage: 比赛管理 +contest_print: 打印 contest_problemlist: 题目列表 contest_scoreboard: 比赛成绩表 contest_user: 参赛者管理 @@ -592,6 +593,7 @@ Pretest Your Code: 使用自定义数据测试 Pretest: 自测 Pretest?: 自测? Preview: 预览 +Print: 打印 Privacy: 隐私 Problem {1} not found.: 题目 {1} 不存在。 Problem Categories: 题目标签 diff --git a/packages/hydrooj/src/handler/contest.ts b/packages/hydrooj/src/handler/contest.ts index 42a7a8230..fb96c0729 100644 --- a/packages/hydrooj/src/handler/contest.ts +++ b/packages/hydrooj/src/handler/contest.ts @@ -1,6 +1,7 @@ import { Readable } from 'stream'; import { BlobWriter, TextReader, ZipWriter } from '@zip.js/zip.js'; import { stringify as toCSV } from 'csv-stringify/sync'; +import { readFile } from 'fs-extra'; import { escapeRegExp, pick } from 'lodash'; import moment from 'moment-timezone'; import { ObjectId } from 'mongodb'; @@ -182,6 +183,77 @@ export class ContestDetailHandler extends ContestDetailBaseHandler { } } +export class ContestPrintHandler extends ContestDetailBaseHandler { + @param('tid', Types.ObjectId) + async prepare({ domainId }, tid: ObjectId) { + if (!this.tdoc?.allowPrint) throw new NotFoundError(); + if (!this.user.own(this.tdoc) && !this.user.hasPerm(PERM.PERM_EDIT_CONTEST) && !this.tsdoc?.attend) { + throw new ContestNotAttendedError(domainId, tid); + } + } + + async get() { + this.response.body = { tdoc: this.tdoc }; + this.response.template = 'contest_print.html'; + } + + @param('tid', Types.ObjectId) + @param('title', Types.Title, true) + @param('content', Types.Content, true) + async postPrint(domainId: string, tid: ObjectId, title = '', content = '') { + if (!this.tsdoc?.attend) throw new ContestNotAttendedError(domainId, tid); + if (!contest.isOngoing(this.tdoc, this.tsdoc)) throw new ContestNotLiveError(domainId, tid); + await this.limitRate('add_print', 3600, 60); + if (this.request.files?.file) { + const file = this.request.files.file; + if (file.size > 1024 * 1024) throw new ValidationError('file'); + content = await readFile(file.filepath, 'utf-8'); + title ||= file.originalFilename || 'file'; + } + if (!content) throw new ValidationError('content'); + await contest.addPrintTask(domainId, tid, this.user._id, title, content); + this.back(); + } + + @param('tid', Types.ObjectId) + async postGetPrintTask(domainId: string, tid: ObjectId) { + const isContestAdmin = this.user.own(this.tdoc) || this.user.hasPerm(PERM.PERM_EDIT_CONTEST); + const tasks = await contest.getMultiPrintTask(domainId, tid, isContestAdmin ? {} : { owner: this.user._id }) + .project({ _id: 1, title: 1, owner: 1, status: 1 }).sort({ _id: 1 }).toArray(); + const uids = Array.from(new Set(tasks.map((i) => i.owner))); + const udict = await user.getListForRender( + domainId, uids, + this.user.hasPerm(PERM.PERM_VIEW_DISPLAYNAME) + ? ['displayName', 'school', 'studentId'] + : [], + ); + this.response.body = { tasks, udict }; + } + + @param('tid', Types.ObjectId) + async postAllocatePrintTask(domainId: string, tid: ObjectId) { + if (!this.user.own(this.tdoc) && !this.user.hasPerm(PERM.PERM_EDIT_CONTEST)) { + throw new PermissionError(PERM.PERM_EDIT_CONTEST); + } + const task = await contest.allocatePrintTask(domainId, tid); + const udoc = task ? await user.getById(domainId, task.owner) : null; + this.response.body = { task, udoc }; + } + + @param('tid', Types.ObjectId) + @param('taskId', Types.ObjectId) + @param('status', Types.Range(['printed', 'pending'])) + async postUpdatePrintTask(domainId: string, tid: ObjectId, taskId: ObjectId, status: 'printed' | 'pending') { + if (!this.user.own(this.tdoc) && !this.user.hasPerm(PERM.PERM_EDIT_CONTEST)) { + throw new PermissionError(PERM.PERM_EDIT_CONTEST); + } + await contest.updatePrintTask(domainId, tid, taskId, { + status: status === 'printed' ? contest.PrintTaskStatus.printed : contest.PrintTaskStatus.pending, + }); + this.response.body = { success: true }; + } +} + export class ContestProblemListHandler extends ContestDetailBaseHandler { @param('tid', Types.ObjectId) async get(domainId: string, tid: ObjectId) { @@ -305,12 +377,13 @@ export class ContestEditHandler extends Handler { @param('contestDuration', Types.Float, true) @param('maintainer', Types.NumericArray, true) @param('allowViewCode', Types.Boolean) + @param('allowPrint', Types.Boolean) @param('langs', Types.CommaSeperatedArray, true) async postUpdate( domainId: string, tid: ObjectId, beginAtDate: string, beginAtTime: string, duration: number, title: string, content: string, rule: string, _pids: string, rated = false, _code = '', autoHide = false, assign: string[] = [], lock: number = null, - contestDuration: number = null, maintainer: number[] = [], allowViewCode = false, langs: string[] = [], + contestDuration: number = null, maintainer: number[] = [], allowViewCode = false, allowPrint = false, langs: string[] = [], ) { if (autoHide) this.checkPerm(PERM.PERM_EDIT_PROBLEM); const pids = _pids.replace(/,/g, ',').split(',').map((i) => +i).filter((i) => i); @@ -351,7 +424,7 @@ export class ContestEditHandler extends Handler { }); } await contest.edit(domainId, tid, { - assign, _code, autoHide, lockAt, maintainer, allowViewCode, langs, + assign, _code, autoHide, lockAt, maintainer, allowViewCode, allowPrint, langs, }); this.response.body = { tid }; this.response.redirect = this.url('contest_detail', { tid }); @@ -734,6 +807,7 @@ export async function apply(ctx: Context) { ctx.Route('contest_detail', '/contest/:tid', ContestDetailHandler, PERM.PERM_VIEW_CONTEST); ctx.Route('contest_problemlist', '/contest/:tid/problems', ContestProblemListHandler, PERM.PERM_VIEW_CONTEST); ctx.Route('contest_edit', '/contest/:tid/edit', ContestEditHandler, PERM.PERM_VIEW_CONTEST); + ctx.Route('contest_print', '/contest/:tid/print', ContestPrintHandler, PERM.PERM_VIEW_CONTEST); ctx.Route('contest_manage', '/contest/:tid/management', ContestManagementHandler); ctx.Route('contest_code', '/contest/:tid/code', ContestCodeHandler, PERM.PERM_VIEW_CONTEST); ctx.Route('contest_file_download', '/contest/:tid/file/:filename', ContestFileDownloadHandler, PERM.PERM_VIEW_CONTEST); diff --git a/packages/hydrooj/src/interface.ts b/packages/hydrooj/src/interface.ts index 913f36591..597109dc0 100644 --- a/packages/hydrooj/src/interface.ts +++ b/packages/hydrooj/src/interface.ts @@ -8,6 +8,7 @@ import type { FileInfo, RecordJudgeInfo, RecordPayload, } from '@hydrooj/common/types'; import type { Context } from './context'; +import type { PrintTaskStatus } from './model/contest'; import type { DocStatusType } from './model/document'; import type { OauthMap } from './model/oauth'; import type { ProblemDoc } from './model/problem'; @@ -264,6 +265,7 @@ export interface Tdoc extends Document { assign?: string[]; files?: FileInfo[]; allowViewCode?: boolean; + allowPrint?: boolean; // For contest lockAt?: Date; @@ -383,6 +385,16 @@ export interface ContestClarificationDoc extends Document { reply: DiscussionTailReplyDoc[]; } +export interface ContestPrintDoc extends Document { + docType: document['TYPE_CONTEST_PRINT']; + docId: ObjectId; + parentType: document['TYPE_CONTEST']; + parentId: ObjectId; + title: string; + content: string; + status: PrintTaskStatus; +} + export interface TokenDoc { _id: string; tokenType: number; diff --git a/packages/hydrooj/src/model/contest.ts b/packages/hydrooj/src/model/contest.ts index 24c10d29f..07cabf52f 100644 --- a/packages/hydrooj/src/model/contest.ts +++ b/packages/hydrooj/src/model/contest.ts @@ -8,7 +8,7 @@ import { ContestScoreboardHiddenError, ValidationError, } from '../error'; import { - BaseUserDict, ContestRule, ContestRules, ProblemDict, RecordDoc, + BaseUserDict, ContestPrintDoc, ContestRule, ContestRules, ProblemDict, RecordDoc, ScoreboardConfig, ScoreboardNode, ScoreboardRow, SubtaskResult, Tdoc, } from '../interface'; import bus from '../service/bus'; @@ -20,6 +20,13 @@ import * as document from './document'; import problem from './problem'; import user, { User } from './user'; +export enum PrintTaskStatus { + pending = 'pending', + printing = 'printing', + printed = 'printed', + failed = 'failed', +} + interface AcmJournal { rid: ObjectId; pid: number; @@ -1047,8 +1054,41 @@ export const statusText = (tdoc: Tdoc, tsdoc?: any) => ( ? 'Live...' : 'Done'); +export function addPrintTask(domainId: string, tid: ObjectId, uid: number, name: string, content: string) { + return document.add(domainId, content, uid, document.TYPE_CONTEST_PRINT, null, document.TYPE_CONTEST, tid, { + title: name, + status: PrintTaskStatus.pending, + }); +} + +export async function updatePrintTask(domainId: string, tid: ObjectId, taskId: ObjectId, $set: Partial) { + const res = await document.coll.updateOne({ + domainId, docType: document.TYPE_CONTEST_PRINT, + docId: taskId, parentType: document.TYPE_CONTEST, parentId: tid, + }, { $set }); + return !!res.modifiedCount; +} + +export function allocatePrintTask(domainId: string, tid: ObjectId) { + return document.coll.findOneAndUpdate({ + domainId, docType: document.TYPE_CONTEST_PRINT, + parentType: document.TYPE_CONTEST, parentId: tid, + status: PrintTaskStatus.pending, + }, { + $set: { + status: PrintTaskStatus.printing, + }, + }, { returnDocument: 'after' }); +} + +export function getMultiPrintTask(domainId: string, tid: ObjectId, query = {}) { + return document.getMulti(domainId, document.TYPE_CONTEST_PRINT, { parentType: document.TYPE_CONTEST, parentId: tid, ...query }) + .sort({ _id: 1 }); +} + global.Hydro.model.contest = { RULES, + PrintTaskStatus, buildContestRule, add, getListStatus, @@ -1089,4 +1129,8 @@ global.Hydro.model.contest = { isExtended, applyProjection, statusText, + addPrintTask, + updatePrintTask, + allocatePrintTask, + getMultiPrintTask, }; diff --git a/packages/hydrooj/src/model/document.ts b/packages/hydrooj/src/model/document.ts index 73f941231..c956536e4 100644 --- a/packages/hydrooj/src/model/document.ts +++ b/packages/hydrooj/src/model/document.ts @@ -4,7 +4,7 @@ import { } from 'mongodb'; import { Context } from '../context'; import { - Content, ContestClarificationDoc, DiscussionDoc, + Content, ContestClarificationDoc, ContestPrintDoc, DiscussionDoc, DiscussionReplyDoc, ProblemDoc, ProblemStatusDoc, Tdoc, TrainingDoc, } from '../interface'; @@ -27,6 +27,7 @@ export const TYPE_DISCUSSION = 21 as const; export const TYPE_DISCUSSION_REPLY = 22 as const; export const TYPE_CONTEST = 30 as const; export const TYPE_CONTEST_CLARIFICATION = 31 as const; +export const TYPE_CONTEST_PRINT = 32 as const; export const TYPE_TRAINING = 40 as const; export interface DocType { @@ -37,6 +38,7 @@ export interface DocType { [TYPE_DISCUSSION]: DiscussionDoc; [TYPE_DISCUSSION_REPLY]: DiscussionReplyDoc; [TYPE_CONTEST]: Tdoc; + [TYPE_CONTEST_PRINT]: ContestPrintDoc; [TYPE_CONTEST_CLARIFICATION]: ContestClarificationDoc; [TYPE_TRAINING]: TrainingDoc; } @@ -480,6 +482,7 @@ global.Hydro.model.document = { TYPE_CONTEST, TYPE_CONTEST_CLARIFICATION, + TYPE_CONTEST_PRINT, TYPE_DISCUSSION, TYPE_DISCUSSION_NODE, TYPE_DISCUSSION_REPLY, diff --git a/packages/hydrooj/src/model/user.ts b/packages/hydrooj/src/model/user.ts index 7409fc288..f6927ec33 100644 --- a/packages/hydrooj/src/model/user.ts +++ b/packages/hydrooj/src/model/user.ts @@ -162,7 +162,9 @@ export class User { serialize(h) { if (!this._isPrivate) { const fields = ['_id', 'uname', 'mail', 'perm', 'role', 'priv', 'regat', 'loginat', 'tfa', 'authn']; - if (h?.user?.hasPerm(PERM.PERM_VIEW_DISPLAYNAME)) fields.push('displayName'); + if (h?.user?.hasPerm(PERM.PERM_VIEW_DISPLAYNAME)) { + fields.push('displayName', 'school', 'studentId'); + } return pick(this, fields); } return JSON.stringify(this, serializer(true, h)); diff --git a/packages/ui-default/locales/zh.yaml b/packages/ui-default/locales/zh.yaml index 4d6b50635..f19a3962b 100644 --- a/packages/ui-default/locales/zh.yaml +++ b/packages/ui-default/locales/zh.yaml @@ -688,6 +688,7 @@ Pretest Data: 自测数据 Pretest Your Code: 使用自定义数据测试 Pretest: 自测 Pretest?: 自测? +Print New File: 打印新文件 Privacy: 隐私 Privilege: 权限 Problem {1} not found.: 题目 {1} 不存在。 diff --git a/packages/ui-default/misc/icons/print.svg b/packages/ui-default/misc/icons/print.svg new file mode 100644 index 000000000..5530da084 --- /dev/null +++ b/packages/ui-default/misc/icons/print.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui-default/pages/contest_print.page.tsx b/packages/ui-default/pages/contest_print.page.tsx new file mode 100644 index 000000000..b286db845 --- /dev/null +++ b/packages/ui-default/pages/contest_print.page.tsx @@ -0,0 +1,233 @@ +import $ from 'jquery'; +import React, { useEffect, useState } from 'react'; +import ReactDOM from 'react-dom/client'; +import { ConfirmDialog } from 'vj/components/dialog'; +import highlighter from 'vj/components/highlighter/prismjs'; +import { NamedPage } from 'vj/misc/Page'; +import { delay, i18n, mongoId, request, tpl } from 'vj/utils'; + +interface PrintTask { + _id: string; + owner: number; + title: string; + createAt: string; + uid: number; + content: string; + status: string; +} + +const apis = { + getPrintTask: () => request.post('', { operation: 'get_print_task' }), + updatePrintTask: (taskId: string, status: string) => request.post('', { operation: 'update_print_task', taskId, status }), + allocatePrintTask: () => request.post('', { operation: 'allocate_print_task' }), +} as const; + +const callApi = async (api: T, ...args: Parameters & any[]) => { + try { + return (await apis[api].call(apis[api], ...args)) as Awaited>; + } catch (error) { + console.error(`Failed to call API ${api}:`, error); + return null; + } +}; + +function inlineStyles(element: HTMLElement) { + const style = window.getComputedStyle(element); + element.style.color = style.color; + element.style.backgroundColor = style.backgroundColor; + element.style.fontWeight = style.fontWeight; + element.style.fontStyle = style.fontStyle; + element.style.textDecoration = style.textDecoration; + element.style.fontFamily = style.fontFamily; + element.style.fontSize = style.fontSize; + element.style.lineHeight = style.lineHeight; + element.style.whiteSpace = style.whiteSpace; + element.style.wordSpacing = style.wordSpacing; + element.style.wordBreak = style.wordBreak; + element.style.tabSize = style.tabSize; + element.style.hyphens = style.hyphens; + Array.from(element.children).forEach((child) => inlineStyles(child as HTMLElement)); +} + +// eslint-disable-next-line react-refresh/only-export-components +const PrintKiosk = ({ isAdmin }: { isAdmin: boolean }) => { + const [printTasks, setPrintTasks] = useState([]); + const [isKioskActive, setIsKioskActive] = useState(false); + const [udict, setUdict] = useState>({}); + + const pollPrintTasks = async () => { + const response = await callApi('getPrintTask'); + setPrintTasks(response.tasks || []); + setUdict(response.udict || {}); + }; + + const printTask = async (task, udoc) => { + const printWindow = window.open('', '_blank', 'width=800,height=600,popup=1'); + if (!printWindow) return; + + // Limit line count to prevent printing too many pages + const finalContent = []; + let cnt = 0; + for (const line of task.content.split('\n')) { + cnt += Math.ceil(line.length / 100); + if (cnt > 300) break; + finalContent.push(line); + } + + const parts = task.title.split('.'); + const ext = parts.length > 1 ? parts[parts.length - 1].toLowerCase() : 'plaintext'; + const tempDiv = document.createElement('div'); + tempDiv.style.display = 'none'; + const pre = document.createElement('pre'); + pre.className = 'content'; + const code = document.createElement('code'); + code.className = `language-${ext}`; + code.textContent = finalContent.join('\n'); + pre.appendChild(code); + tempDiv.appendChild(pre); + document.body.appendChild(tempDiv); + highlighter.highlightBlocks($(tempDiv)); + inlineStyles(pre); + const highlightedContent = pre.outerHTML; + document.body.removeChild(tempDiv); + + const header = tpl(
+

+ [{udoc.uname}] {udoc.school || ''} {udoc.displayName || ''}   + {new Date(mongoId(task._id).timestamp * 1000).toLocaleString()} +
+ Filename: {task.title} + By Hydro +

+
); + + printWindow.document.write(` + + + + Print Page + + + + ${header} + ${highlightedContent} + + + `); + printWindow.document.close(); + + setTimeout(() => { + printWindow.print(); + // printWindow.close(); + callApi('updatePrintTask', task._id, 'printed').then(() => pollPrintTasks()); + }, 500); + }; + + useEffect(() => { + pollPrintTasks(); + let active = true; + if (isKioskActive) { + (async () => { + while (active) { // eslint-disable-line no-unmodified-loop-condition + const task = await callApi('allocatePrintTask'); + if (!task?.task) await delay(5000); + else { + await printTask(task.task, task.udoc); + await pollPrintTasks(); + } + } + })(); + } + return () => { + active = false; + }; + }, [isKioskActive]); + + useEffect(() => { + const cb = () => setIsKioskActive(true); + $(document).on('click', '[name="enable_print_kiosk"]', cb); + return () => { + $(document).off('click', '[name="enable_print_kiosk"]', cb); + }; + }, []); + + return
+ {isKioskActive &&

Print Kiosk is enabled

} + +
+ + + + + + + + {isAdmin && } + + + + {printTasks.length === 0 + && } + {printTasks.map((task) => ( + + + + + + {isAdmin && } + + ))} + +
UserTitleTimeStatusAction

No pending print tasks.

{udict[task.owner]?.uname}{task.title}{new Date(mongoId(task._id).timestamp * 1000).toLocaleString()}{task.status} + +
+
+
; +}; + +const page = new NamedPage('contest_print', () => { + const element = document.createElement('input'); + element.type = 'file'; + element.name = 'file'; + element.style.display = 'none'; + document.body.appendChild(element); + element.onchange = async (ev: Event) => { + const file = (ev.target as HTMLInputElement).files[0]; + if (!file) return; + const formData = new FormData(); + formData.append('file', file); + formData.append('operation', 'print'); + const language = file.name.split('.').pop()?.toLowerCase(); + const dialog = new ConfirmDialog({ + $body: tpl(
+

{i18n('Are you sure to print this file?')}{i18n('Filename: {0}', file.name)}

+
+
{await file.text()}
+
+
), + }); + const action = await dialog.open(); + if (action !== 'yes') return; + request.postFile('', formData).then(() => { + window.location.reload(); + }); + }; + $(document).on('click', '[name="add_print_task"]', () => { + element.click(); + }); + const container = document.getElementById('printKioskContainer'); + if (!container) return; + const root = ReactDOM.createRoot(container); + root.render(); +}); + +export default page; diff --git a/packages/ui-default/templates/contest_edit.html b/packages/ui-default/templates/contest_edit.html index 903213ec0..f50356be6 100644 --- a/packages/ui-default/templates/contest_edit.html +++ b/packages/ui-default/templates/contest_edit.html @@ -166,6 +166,14 @@ value:tdoc.allowViewCode|default(true), row:false }) }} + {{ form.form_checkbox({ + columns:4, + label:'Allow Print', + name:'allowPrint', + placeholder:_('Allow user to print during contest'), + value:tdoc.allowPrint|default(false), + row:false + }) }}