From 867cd069c57bbddc7a43572e45568ff5579b9ff8 Mon Sep 17 00:00:00 2001 From: panda Date: Sun, 5 Nov 2023 02:51:25 +0800 Subject: [PATCH] core&ui: add contest balloon list (#578) Co-authored-by: undefined --- package.json | 2 +- packages/hydrooj/locales/zh.yaml | 1 + packages/hydrooj/src/handler/contest.ts | 54 +++++++- packages/hydrooj/src/interface.ts | 14 ++ packages/hydrooj/src/model/contest.ts | 39 +++++- packages/hydrooj/src/service/bus.ts | 3 +- packages/ui-default/build/config/webpack.ts | 2 +- packages/ui-default/locales/zh.yaml | 8 ++ packages/ui-default/misc/icons/balloon.svg | 1 + packages/ui-default/package.json | 1 + packages/ui-default/pages/contest.page.styl | 13 ++ .../ui-default/pages/contest_balloon.page.tsx | 123 ++++++++++++++++++ .../ui-default/templates/contest_balloon.html | 48 +++++++ .../templates/partials/contest_balloon.html | 37 ++++++ .../partials/contest_sidebar_management.html | 3 + 15 files changed, 341 insertions(+), 8 deletions(-) create mode 100644 packages/ui-default/misc/icons/balloon.svg create mode 100644 packages/ui-default/pages/contest_balloon.page.tsx create mode 100644 packages/ui-default/templates/contest_balloon.html create mode 100644 packages/ui-default/templates/partials/contest_balloon.html diff --git a/package.json b/package.json index b31000d0..d052b26b 100644 --- a/package.json +++ b/package.json @@ -65,7 +65,7 @@ "simple-git": "^3.20.0", "style-loader": "^3.3.3", "stylus": "^0.61.0", - "stylus-loader": "7.1.3", + "stylus-loader": "7.1.2", "supertest": "^6.3.3", "ts-loader": "^9.5.0", "typescript": "^5.2.2", diff --git a/packages/hydrooj/locales/zh.yaml b/packages/hydrooj/locales/zh.yaml index c80f00cc..2b928e6f 100644 --- a/packages/hydrooj/locales/zh.yaml +++ b/packages/hydrooj/locales/zh.yaml @@ -151,6 +151,7 @@ Content: 内容 Contest Management: 比赛管理 Contest scoreboard is not visible.: 当前比赛成绩表隐藏,暂不可显示。 Contest Scoreboard: 比赛成绩表 +contest_balloon: 气球状态 contest_create: 创建比赛 contest_detail_problem_submit: 递交比赛题目 contest_detail_problem: 题目详情 diff --git a/packages/hydrooj/src/handler/contest.ts b/packages/hydrooj/src/handler/contest.ts index 8cabcdcc..1942a11c 100644 --- a/packages/hydrooj/src/handler/contest.ts +++ b/packages/hydrooj/src/handler/contest.ts @@ -4,8 +4,9 @@ import { pick } from 'lodash'; import moment from 'moment-timezone'; import { ObjectId } from 'mongodb'; import { - Counter, sortFiles, streamToBuffer, Time, + Counter, sortFiles, streamToBuffer, Time, yaml, } from '@hydrooj/utils/lib/utils'; +import { Context } from '../context'; import { BadRequestError, ContestNotAttendedError, ContestNotEndedError, ContestNotFoundError, ContestNotLiveError, ContestScoreboardHiddenError, FileLimitExceededError, FileUploadError, @@ -663,7 +664,55 @@ export class ContestUserHandler extends ContestManagementBaseHandler { this.back(); } } -export async function apply(ctx) { + +export class ContestBalloonHandler extends ContestManagementBaseHandler { + @param('tid', Types.ObjectId) + @param('todo', Types.Boolean) + async get(domainId: string, tid: ObjectId, todo = false) { + const bdocs = await contest.getMultiBalloon(domainId, tid, { + ...todo ? { sent: { $exists: false } } : {}, + ...(!this.tdoc.lockAt || this.user.hasPerm(PERM.PERM_VIEW_CONTEST_HIDDEN_SCOREBOARD)) + ? {} : { _id: { $lt: this.tdoc.lockAt } }, + }).sort({ _id: -1 }).project({ uid: 1 }).toArray(); + const uids = bdocs.map((i) => i.uid).concat(bdocs.filter((i) => i.sent).map((i) => i.sent)); + this.response.body = { + tdoc: this.tdoc, + tsdoc: this.tsdoc, + owner_udoc: await user.getById(domainId, this.tdoc.owner), + pdict: await problem.getList(domainId, this.tdoc.pids, true, true, problem.PROJECTION_CONTEST_LIST), + bdocs, + udict: await user.getListForRender(domainId, uids), + }; + this.response.pjax = 'partials/contest_balloon.html'; + this.response.template = 'contest_balloon.html'; + } + + @param('tid', Types.ObjectId) + @param('color', Types.Content) + async postSetColor(domainId: string, tid: ObjectId, color: string) { + const config = yaml.load(color); + if (typeof config !== 'object') throw new ValidationError('color'); + const balloon = {}; + for (const pid of this.tdoc.pids) { + if (!config[pid]) throw new ValidationError('color'); + balloon[pid] = config[pid.toString()]; + } + await contest.edit(domainId, tid, { balloon }); + this.back(); + } + + @param('tid', Types.ObjectId) + @param('balloon', Types.ObjectId) + async postDone(domainId: string, tid: ObjectId, bid: ObjectId) { + const balloon = await contest.getBalloon(domainId, tid, bid); + if (!balloon) throw new ValidationError('balloon'); + if (balloon.sent) throw new ValidationError('balloon', null, 'Balloon already sent'); + await contest.updateBalloon(domainId, tid, bid, { sent: this.user._id, sentAt: new Date() }); + this.back(); + } +} + +export async function apply(ctx: Context) { ctx.Route('contest_create', '/contest/create', ContestEditHandler); ctx.Route('contest_main', '/contest', ContestListHandler, PERM.PERM_VIEW_CONTEST); ctx.Route('contest_detail', '/contest/:tid', ContestDetailHandler, PERM.PERM_VIEW_CONTEST); @@ -675,4 +724,5 @@ export async function apply(ctx) { 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); ctx.Route('contest_user', '/contest/:tid/user', ContestUserHandler, PERM.PERM_VIEW_CONTEST); + ctx.Route('contest_balloon', '/contest/:tid/balloon', ContestBalloonHandler, PERM.PERM_VIEW_CONTEST); } diff --git a/packages/hydrooj/src/interface.ts b/packages/hydrooj/src/interface.ts index 9d0f77c6..df12e42a 100644 --- a/packages/hydrooj/src/interface.ts +++ b/packages/hydrooj/src/interface.ts @@ -393,6 +393,7 @@ export interface Tdoc; /** * In hours @@ -651,6 +652,18 @@ export interface DiscussionHistoryDoc { ip: string; } +export interface ContestBalloonDoc { + _id: ObjectId; + domainId: string; + tid: ObjectId; + pid: number; + uid: number; + first?: boolean; + /** Sent by */ + sent?: number; + sentAt?: Date; +} + declare module './service/db' { interface Collections { 'blacklist': BlacklistDoc; @@ -678,6 +691,7 @@ declare module './service/db' { 'event': EventDoc; 'opcount': OpCountDoc; 'schedule': Schedule; + 'contest.balloon': ContestBalloonDoc; } } diff --git a/packages/hydrooj/src/model/contest.ts b/packages/hydrooj/src/model/contest.ts index b29fb04d..74b028cc 100644 --- a/packages/hydrooj/src/model/contest.ts +++ b/packages/hydrooj/src/model/contest.ts @@ -11,6 +11,7 @@ import { } from '../interface'; import ranked from '../lib/rank'; import * as bus from '../service/bus'; +import db from '../service/db'; import type { Handler } from '../service/server'; import { PERM, STATUS, STATUS_SHORT_TEXTS } from './builtin'; import * as document from './document'; @@ -655,6 +656,8 @@ export const RULES: ContestRules = { acm, oi, homework, ioi, ledo, strictioi, }; +const collBalloon = db.collection('contest.balloon'); + function _getStatusJournal(tsdoc) { return tsdoc.journal.sort((a, b) => (a.rid.getTimestamp() - b.rid.getTimestamp())); } @@ -705,6 +708,32 @@ export async function getRelated(domainId: string, pid: number, rule?: string) { return await document.getMulti(domainId, document.TYPE_CONTEST, { pids: pid, rule: rule || { $in: rules } }).toArray(); } +export async function addBalloon(domainId: string, tid: ObjectId, uid: number, rid: ObjectId, pid: number) { + const balloon = await collBalloon.findOne({ + domainId, tid, pid, uid, + }); + if (balloon) return null; + const bdcount = await collBalloon.countDocuments({ domainId, tid, pid }); + const newBdoc = { + _id: rid, domainId, tid, pid, uid, ...(!bdcount ? { first: true } : {}), + }; + await collBalloon.insertOne(newBdoc); + bus.broadcast('contest/balloon', domainId, tid, newBdoc); + return rid; +} + +export async function getBalloon(domainId: string, tid: ObjectId, _id: ObjectId) { + return await collBalloon.findOne({ domainId, tid, _id }); +} + +export function getMultiBalloon(domainId: string, tid: ObjectId, query: any = {}) { + return collBalloon.find({ domainId, tid, ...query }); +} + +export async function updateBalloon(domainId: string, tid: ObjectId, _id: ObjectId, $set: any) { + return await collBalloon.findOneAndUpdate({ domainId, tid, _id }, { $set }); +} + export async function getStatus(domainId: string, tid: ObjectId, uid: number) { return await document.getStatus(domainId, document.TYPE_CONTEST, tid, uid); } @@ -726,6 +755,7 @@ export async function updateStatus( status = STATUS.STATUS_WRONG_ANSWER, score = 0, subtasks: Record = {}, ) { const tdoc = await get(domainId, tid); + if (tdoc.balloon && status === STATUS.STATUS_ACCEPTED) await addBalloon(domainId, tid, uid, rid, pid); return await _updateStatus(tdoc, uid, rid, pid, status, score, subtasks); } @@ -778,10 +808,9 @@ export function isDone(tdoc: Tdoc, tsdoc?: any) { return false; } -export function isLocked(tdoc: Tdoc) { +export function isLocked(tdoc: Tdoc, time = new Date()) { if (!tdoc.lockAt) return false; - const now = new Date(); - return tdoc.lockAt < now && !tdoc.unlocked; + return tdoc.lockAt < time && !tdoc.unlocked; } export function isExtended(tdoc: Tdoc) { @@ -910,6 +939,10 @@ global.Hydro.model.contest = { getAndListStatus, recalcStatus, unlockScoreboard, + getBalloon, + addBalloon, + getMultiBalloon, + updateBalloon, canShowRecord, canShowSelfRecord, canShowScoreboard, diff --git a/packages/hydrooj/src/service/bus.ts b/packages/hydrooj/src/service/bus.ts index 64f953c3..7409bc85 100644 --- a/packages/hydrooj/src/service/bus.ts +++ b/packages/hydrooj/src/service/bus.ts @@ -6,7 +6,7 @@ import pm2 from '@hydrooj/utils/lib/locate-pm2'; import type { ProblemSolutionHandler } from '../handler/problem'; import type { UserRegisterHandler } from '../handler/user'; import type { - BaseUserDict, DiscussionDoc, DomainDoc, FileInfo, + BaseUserDict, ContestBalloonDoc, DiscussionDoc, DomainDoc, FileInfo, MessageDoc, ProblemDict, ProblemDoc, RecordDoc, ScoreboardRow, Tdoc, TrainingDoc, User, } from '../interface'; @@ -92,6 +92,7 @@ export interface EventMap extends LifecycleEvents, HandlerEvents { 'contest/before-add': (payload: Partial>) => VoidReturn 'contest/add': (payload: Partial>, id: ObjectId) => VoidReturn 'contest/scoreboard': (tdoc: Tdoc<30>, rows: ScoreboardRow[], udict: BaseUserDict, pdict: ProblemDict) => VoidReturn + 'contest/balloon': (domainId: string, tid: ObjectId, bdoc: ContestBalloonDoc) => VoidReturn 'oplog/log': (type: string, handler: Handler, args: any, data: any) => VoidReturn; diff --git a/packages/ui-default/build/config/webpack.ts b/packages/ui-default/build/config/webpack.ts index 14e2cef4..433afcef 100644 --- a/packages/ui-default/build/config/webpack.ts +++ b/packages/ui-default/build/config/webpack.ts @@ -201,7 +201,7 @@ export default function (env: { watch?: boolean, production?: boolean, measure?: name(module) { const packageName = module.context.replace(/\\/g, '/').split('node_modules/').pop().split('/')[0]; if (packageName === 'monaco-editor-nls') { - return `i.monaco.${module.userRequest.split('/').pop().split('.')[0]}`; + return `i.monaco.${module.userRequest.replace(/\\/g, '/').split('/').pop().split('.')[0]}`; } return `n.${packageName.replace('@', '')}`; }, diff --git a/packages/ui-default/locales/zh.yaml b/packages/ui-default/locales/zh.yaml index 431c0a26..bbb65782 100644 --- a/packages/ui-default/locales/zh.yaml +++ b/packages/ui-default/locales/zh.yaml @@ -109,6 +109,8 @@ Auto configure: 自动配置 Auto detect: 自动检测 Auto hide problems during the contest: 在比赛过程中自动隐藏题目 Auto Read Tasks: 自动识别任务 +Awards: 奖项 +Balloon Status: 气球状态 Basic Info: 基础信息 Basic: 基础 Be Copied: 被复制 @@ -159,6 +161,7 @@ Code language: 代码语言 Code: 代码 codeFontFamily: 代码字体 collapse: 收缩 +Color: 颜色 Comment: 评论 Comments: 评论 CommonMark Syntax: CommonMark 语法 @@ -374,6 +377,8 @@ File: 文件 Filename: 文件名 Files: 文件 Filter: 过滤 +First of Contest: 全场一血 +First of Problem: 题目一血 fontFamily: 字体 Footer: 页脚 Footnote: 脚注 @@ -636,6 +641,7 @@ Please follow the instructions on your device to complete the verification.: 请 Please select at least one file to perform this operation.: 请选择至少一个文件来执行此操作。 Please select at least one role to perform this operation.: 请选择至少一个角色来进行操作。 Please select at least one user to perform this operation.: 请选择至少一个用户来进行操作。 +Please set the balloon color for each problem first.: 请先为每道题设置气球颜色。 Please wait until contest host unfreeze the scoreboard.: 请等待比赛主办方解除封榜。 Preference Settings: 偏好设置 preferredPrefix_hint: 此选项用于重排题号。例如,若题目包中所给的题号分别是 P1001, P1002, P1003,而此选项填写了 T,则导入后三道题的题号分别为 T1001, T1002 和 T1003。 @@ -776,6 +782,7 @@ Selected files have been deleted.: 所选文件已被删除。 Selected problems have been deleted.: 所选题目已被删除。 Selected roles have been deleted.: 所选角色已删除。 Selected users have been removed from the domain.: 所选用户已从此域中移除。 +Send By: 递送者 Send Code after acceptance: 通过题目后发送源代码 Send Message: 发送站内信息 Send Password Reset Email: 发送密码重置邮件 @@ -783,6 +790,7 @@ Send Verification Email: 发送验证邮件 Send: 发送 Seperated with ',': 用 ',' 分隔 Service Status: 服务状态 +Set Color: 设置颜色 Set Privilege: 设置权限 Set Role: 设置角色 Set Roles for Selected User: 设置所选用户的角色 diff --git a/packages/ui-default/misc/icons/balloon.svg b/packages/ui-default/misc/icons/balloon.svg new file mode 100644 index 00000000..84d87311 --- /dev/null +++ b/packages/ui-default/misc/icons/balloon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui-default/package.json b/packages/ui-default/package.json index b4c73fc9..05c958e8 100644 --- a/packages/ui-default/package.json +++ b/packages/ui-default/package.json @@ -84,6 +84,7 @@ "qrcode": "^1.5.3", "queue-microtask": "^1.2.3", "react": "^18.2.0", + "react-colorful": "^5.6.1", "react-dnd": "^16.0.1", "react-dnd-html5-backend": "^16.0.1", "react-dom": "^18.2.0", diff --git a/packages/ui-default/pages/contest.page.styl b/packages/ui-default/pages/contest.page.styl index 1b00388d..f85fe138 100644 --- a/packages/ui-default/pages/contest.page.styl +++ b/packages/ui-default/pages/contest.page.styl @@ -195,3 +195,16 @@ $highlight-button-color = #F6DF45 .page--contest_user .col--uid width: rem(100px) + +.page--contest_balloon + .col--bid + width: rem(100px) + + .col--status + width: rem(100px) + + .col--problem + width: rem(150px) + + .col--awards + width: rem(130px) diff --git a/packages/ui-default/pages/contest_balloon.page.tsx b/packages/ui-default/pages/contest_balloon.page.tsx new file mode 100644 index 00000000..c30d867f --- /dev/null +++ b/packages/ui-default/pages/contest_balloon.page.tsx @@ -0,0 +1,123 @@ +import $ from 'jquery'; +import yaml from 'js-yaml'; +import React from 'react'; +import { HexColorInput, HexColorPicker } from 'react-colorful'; +import { createRoot } from 'react-dom/client'; +import { ActionDialog } from 'vj/components/dialog'; +import Notification from 'vj/components/notification'; +import { NamedPage } from 'vj/misc/Page'; +import { + i18n, pjax, request, tpl, +} from 'vj/utils'; + +function Balloon({ tdoc, val }) { + const [color, setColor] = React.useState(''); + const [now, setNow] = React.useState(''); + return ( +
+
+ + + + + + + + + + + {tdoc.pids.map((pid) => { + const { color: c, name } = val[+pid]; + return ( + + + + + {tdoc.pids.indexOf(+pid) === 0 && } + + ); + })} + +
{i18n('Problem')}{i18n('Color')}{i18n('Name')}
+ {now === pid + ? ({String.fromCharCode(65 + tdoc.pids.indexOf(+pid))}) + : ({String.fromCharCode(65 + tdoc.pids.indexOf(+pid))})} + + { setNow(pid); setColor(c); }} + onChange={(e) => { val[+pid].color = e; setColor(e); }} + /> + + { setNow(pid); setColor(c); }} + onChange={(e) => { val[+pid].name = e.target.value; }} + /> + + {now && { val[+now].color = e; setColor(e); }} style={{ padding: '1rem' }} />} +
+
+
+ ); +} + +async function handleSetColor(tdoc) { + let val = tdoc.balloon; + if (!val) { + val = {}; + for (const pid of tdoc.pids) val[+pid] = { color: '#ffffff', name: '' }; + } + Notification.info(i18n('Loading...')); + const promise = new ActionDialog({ + $body: tpl` +
+

${i18n('Set Color')}

+
+
`, + }).open(); + createRoot($('#balloon').get(0)).render( + , + ); + const action = await promise; + if (action !== 'ok') return; + Notification.info(i18n('Updating...')); + try { + await request.post('', { operation: 'set_color', color: yaml.dump(val) }); + } catch (e) { + Notification.error(`${e.message} ${e.params?.[0]}`); + } + Notification.info(i18n('Successfully updated.')); + pjax.request({ url: '', push: false }); +} + +const page = new NamedPage('contest_balloon', () => { + const { tdoc } = UiContext; + + const beginAt = new Date(tdoc.beginAt).getTime(); + const endAt = new Date(tdoc.endAt).getTime(); + function update() { + const now = Date.now(); + if (beginAt <= now && now <= endAt) pjax.request({ url: '', push: false }); + } + + $('[name="set_color"]').on('click', () => handleSetColor(tdoc)); + setInterval(update, 60000); + + $(document).on('click', '[value="done"]', async (ev) => { + ev.preventDefault(); + const balloon = $(ev.currentTarget).data('balloon'); + try { + await request.post('', { balloon, operation: 'done' }); + } catch (e) { + Notification.error(`${e.message} ${e.params?.[0]}`); + } + Notification.info(i18n('Successfully updated.')); + pjax.request({ url: '', push: false }); + }); +}); + +export default page; diff --git a/packages/ui-default/templates/contest_balloon.html b/packages/ui-default/templates/contest_balloon.html new file mode 100644 index 00000000..771b44e5 --- /dev/null +++ b/packages/ui-default/templates/contest_balloon.html @@ -0,0 +1,48 @@ +{% import "components/nothing.html" as nothing with context %} +{% extends "layout/basic.html" %} +{% block content %} +{{ set(UiContext, 'tdoc', tdoc) }} +
+
+
+
+

{{ _('Balloon Status') }}

+
+ +
+
+ {{ noscript_note.render() }} +
+ {% if not tdoc.balloon|length %} + {{ nothing.render('Please set the balloon color for each problem first.') }} + {% else %} + + + + + + + + + + + + + + + + + + + + {% include "partials/contest_balloon.html" %} +
{{ _('Status') }}{{ _('#') }}{{ _('Problem') }}{{ _('Submit By') }}{{ _('Send By') }}{{ _('Awards') }}
+ {% endif %} +
+
+
+
+ {% include 'partials/contest_sidebar_management.html' %} +
+
+{% endblock %} diff --git a/packages/ui-default/templates/partials/contest_balloon.html b/packages/ui-default/templates/partials/contest_balloon.html new file mode 100644 index 00000000..23e44b43 --- /dev/null +++ b/packages/ui-default/templates/partials/contest_balloon.html @@ -0,0 +1,37 @@ +{% import "components/user.html" as user with context %} +{% import "components/contest.html" as contest with context %} + + {%- for bdoc in bdocs -%} + + {% if bdoc.sent %} + + + {{ _('Sent') }} + + {% else %} + + + {{ _('Waiting') }} + + {% endif %} + {{ bdoc._id.toHexString()|truncate(8,true,'') }} + + {% if not bdoc.sent %} +
+ + +
+ {% endif %} + {{ String.fromCharCode(65+tdoc.pids.indexOf(bdoc.pid)) }}  ({{ tdoc.balloon[bdoc.pid].name }}) + + {{ user.render_inline(udict[bdoc.uid], badge=false) }} + + {% if bdoc.sent %}{{ user.render_inline(udict[bdoc.sent], avatar=false, badge=false) }}{% else %}-{% endif %} + + {% if bdoc.first %}{{ _('First of Problem') }}{% endif %} + + {%- endfor -%} + \ No newline at end of file diff --git a/packages/ui-default/templates/partials/contest_sidebar_management.html b/packages/ui-default/templates/partials/contest_sidebar_management.html index 0be6e7ee..1114449f 100644 --- a/packages/ui-default/templates/partials/contest_sidebar_management.html +++ b/packages/ui-default/templates/partials/contest_sidebar_management.html @@ -26,6 +26,9 @@ +