diff --git a/packages/hydrooj/locales/zh.yaml b/packages/hydrooj/locales/zh.yaml index 2b928e6f..1e3957e5 100644 --- a/packages/hydrooj/locales/zh.yaml +++ b/packages/hydrooj/locales/zh.yaml @@ -148,6 +148,9 @@ Contact Us: 联系我们 Contact: 联系 content: 内容 Content: 内容 +Contest {0} has a new clarification about {1}, please go to contest management to reply.: 比赛 {0} 有一条关于 {1} 的新问答,请前往比赛管理页面回复。 +Contest {0} jury replied to your clarification, please go to contest page to view.: 比赛 {0} 的裁判回复了您的问答,请前往比赛页面查看。 +Contest Clarifications: 问答 Contest Management: 比赛管理 Contest scoreboard is not visible.: 当前比赛成绩表隐藏,暂不可显示。 Contest Scoreboard: 比赛成绩表 @@ -342,6 +345,7 @@ Formula blocks: 公式区块 fs_upload: 上传文件 Gender Visibility: 性别可见性 Gender: 性别 +General Issue: 一般问题 Hard Deadline: 最终截止时间 Hash: 散列 Have ALL PERMISSIONS in this domain: 在此域中拥有全部权限 @@ -520,6 +524,7 @@ Oh, the user doesn't have any contributions!: 啊哦,这个用户还没贡献 Oh, the user hasn't created any discussions yet!: 这个用户还没有发布过讨论 Oh, the user hasn't submitted yet!: 这个用户还没有交过题 _(:зゝ∠)_ Oh, there are no tasks that match the filter!: 喔,目前没有符合过滤条件的任务。 +Oh, there is no clarification!: 喔,目前没有提问! Oh, there is no task in the queue!: 喔,队列中目前没有任务。 Ok: 确定 Only A-Z, a-z, 0-9 and _ are accepted: 只接受 A-Z, a-z, 0-9 和 _ @@ -691,6 +696,8 @@ Select User: 选择用户 Selected categories: 已选标签 Selected roles have been deleted.: 所选角色已删除。 Selected users have been removed from the domain.: 所选用户已从此域中移除。 +Send Broadcast Message: 发送公告 +Send Clarification Request: 提问 Send Code after acceptance: 通过题目后发送源代码 Send Message: 发送站内信息 Send Password Reset Email: 发送密码重置邮件 @@ -750,6 +757,7 @@ Storage engine endPoint: 存储桶 endPoint Storage engine region: 存储桶地域 Storage engine secret: 存储桶 secretKey Student ID: 学号 +Subject: 主题 Submission Statistics: 递交统计 Submission: 递交 Submissions: 递交 @@ -760,6 +768,7 @@ Sync problem filelist from s3 service: 从 S3 同步题目文件列表。 Tags: 标签 Target: 目标 Technical Information: 技术信息 +Technical Issue: 技术问题 Temporary session: 临时会话 Terms of Service: 服务条款 Test data comes from: 测试数据来自 diff --git a/packages/hydrooj/src/handler/contest.ts b/packages/hydrooj/src/handler/contest.ts index 1942a11c..4dbfe7c6 100644 --- a/packages/hydrooj/src/handler/contest.ts +++ b/packages/hydrooj/src/handler/contest.ts @@ -256,12 +256,13 @@ export class ContestProblemListHandler extends ContestDetailBaseHandler { async get(domainId: string, tid: ObjectId) { if (contest.isNotStarted(this.tdoc)) throw new ContestNotLiveError(domainId, tid); if (!this.tsdoc?.attend && !contest.isDone(this.tdoc)) throw new ContestNotAttendedError(domainId, tid); - const [pdict, udict] = await Promise.all([ + const [pdict, udict, tcdocs] = await Promise.all([ problem.getList(domainId, this.tdoc.pids, true, true, problem.PROJECTION_CONTEST_LIST), user.getList(domainId, [this.tdoc.owner, this.user._id]), + contest.getMultiClarification(domainId, tid, this.user._id), ]); this.response.body = { - pdict, psdict: {}, udict, rdict: {}, tdoc: this.tdoc, tsdoc: this.tsdoc, + pdict, psdict: {}, udict, rdict: {}, tdoc: this.tdoc, tsdoc: this.tsdoc, tcdocs, }; this.response.template = 'contest_problemlist.html'; if (!this.tsdoc) return; @@ -282,6 +283,24 @@ export class ContestProblemListHandler extends ContestDetailBaseHandler { for (const i of psdocs) this.response.body.rdict[i.rid] = { _id: i.rid }; } } + + @param('tid', Types.ObjectId) + @param('content', Types.Content) + @param('subject', Types.Int) + async postClarification(domainId: string, tid: ObjectId, content: string, subject: number) { + if (!this.tsdoc?.attend) throw new ContestNotAttendedError(domainId, tid); + if (!contest.isOngoing(this.tdoc)) throw new ContestNotLiveError(domainId, tid); + await this.limitRate('add_discussion', 3600, 60); + await contest.addClarification(domainId, tid, this.user._id, content, this.request.ip, subject); + if (!this.user.own(this.tdoc)) { + await Promise.all([this.tdoc.owner, ...this.tdoc.maintainer].map((uid) => message.send(1, uid, JSON.stringify({ + message: 'Contest {0} has a new clarification about {1}, please go to contest management to reply.', + params: [this.tdoc.title, subject > 0 ? `#${this.tdoc.pids.indexOf(subject) + 1}` : 'the contest'], + url: this.url('contest_manage', { tid }), + }), message.FLAG_I18N | message.FLAG_UNREAD))); + } + this.back(); + } } export class ContestScoreboardHandler extends ContestDetailBaseHandler { @@ -558,12 +577,15 @@ export class ContestCodeHandler extends Handler { export class ContestManagementHandler extends ContestManagementBaseHandler { @param('tid', Types.ObjectId) async get(domainId: string, tid: ObjectId) { + const tcdocs = await contest.getMultiClarification(domainId, tid); 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), files: sortFiles(this.tdoc.files || []), + udict: await user.getListForRender(domainId, tcdocs.map((i) => i.owner)), + tcdocs, urlForFile: (filename: string) => this.url('contest_file_download', { tid, filename }), }; this.response.pjax = 'partials/files.html'; @@ -572,11 +594,28 @@ export class ContestManagementHandler extends ContestManagementBaseHandler { @param('tid', Types.ObjectId) @param('content', Types.Content) - async postBroadcast(domainId: string, tid: ObjectId, content: string) { - const tsdocs = await contest.getMultiStatus(domainId, { docId: tid }).toArray(); - const uids = Array.from(new Set(tsdocs.map((tsdoc) => tsdoc.uid))); - const flag = contest.isOngoing(this.tdoc) ? message.FLAG_ALERT : message.FLAG_UNREAD; - await Promise.all(uids.map((uid) => message.send(this.user._id, uid, content, flag))); + @param('did', Types.ObjectId, true) + @param('subject', Types.Int, true) + async postClarification(domainId: string, tid: ObjectId, content: string, did: ObjectId, subject = 0) { + if (did) { + const tcdoc = await contest.getClarification(domainId, did); + await Promise.all([ + contest.addClarificationReply(domainId, did, 0, content, this.request.ip), + message.send(1, tcdoc.owner, JSON.stringify({ + message: 'Contest {0} jury replied to your clarification, please go to contest page to view.', + params: [this.tdoc.title], + url: this.url('contest_problemlist', { tid }), + }), message.FLAG_I18N | message.FLAG_ALERT), + ]); + } else { + const tsdocs = await contest.getMultiStatus(domainId, { docId: tid }).toArray(); + const uids = Array.from(new Set(tsdocs.map((tsdoc) => tsdoc.uid))); + const flag = contest.isOngoing(this.tdoc) ? message.FLAG_ALERT : message.FLAG_UNREAD; + await Promise.all([ + contest.addClarification(domainId, tid, 0, content, this.request.ip, subject), + ...uids.map((uid) => message.send(1, uid, content, flag)), + ]); + } this.back(); } diff --git a/packages/hydrooj/src/handler/problem.ts b/packages/hydrooj/src/handler/problem.ts index 5c3b6296..bb9f8211 100644 --- a/packages/hydrooj/src/handler/problem.ts +++ b/packages/hydrooj/src/handler/problem.ts @@ -267,7 +267,7 @@ export class ProblemMainHandler extends Handler { // eslint-disable-next-line no-await-in-loop await problem.del(domainId, pid); i++; - this.progress(`Deleting: (${i}/${pids.length})`); + this.progress('Deleting: ({0}/{1})', [i, pids.length]); } this.back(); } diff --git a/packages/hydrooj/src/interface.ts b/packages/hydrooj/src/interface.ts index df12e42a..bfd1da3d 100644 --- a/packages/hydrooj/src/interface.ts +++ b/packages/hydrooj/src/interface.ts @@ -493,6 +493,18 @@ export interface DiscussionTailReplyDoc { editor?: number; } +export interface ContestClarificationDoc extends Document { + docType: document['TYPE_CONTEST_CLARIFICATION']; + docId: ObjectId; + parentType: document['TYPE_CONTEST']; + parentId: ObjectId; + // 0: contest -1: technique [pid]: problem + subject: number; + ip: string; + content: string; + reply: DiscussionTailReplyDoc[]; +} + export interface TokenDoc { _id: string, tokenType: number, diff --git a/packages/hydrooj/src/model/contest.ts b/packages/hydrooj/src/model/contest.ts index 74b028cc..76083269 100644 --- a/packages/hydrooj/src/model/contest.ts +++ b/packages/hydrooj/src/model/contest.ts @@ -911,6 +911,37 @@ export async function getScoreboard( return [tdoc, rows, udict, pdict]; } +export function addClarification( + domainId: string, tid: ObjectId, owner: number, content: string, + ip: string, subject = 0, +) { + return document.add( + domainId, content, owner, document.TYPE_CONTEST_CLARIFICATION, + null, document.TYPE_CONTEST, tid, { ip, subject }, + ); +} + +export function addClarificationReply( + domainId: string, did: ObjectId, owner: number, + content: string, ip: string, +) { + return document.push( + domainId, document.TYPE_CONTEST_CLARIFICATION, did, + 'reply', { content, owner, ip }, + ); +} + +export function getClarification(domainId: string, did: ObjectId) { + return document.get(domainId, document.TYPE_CONTEST_CLARIFICATION, did); +} + +export function getMultiClarification(domainId: string, tid: ObjectId, owner = 0) { + return document.getMulti( + domainId, document.TYPE_CONTEST_CLARIFICATION, + { parentType: document.TYPE_CONTEST, parentId: tid, ...(owner ? { owner: { $in: [owner, 0] } } : {}) }, + ).sort('_id', -1).toArray(); +} + export const statusText = (tdoc: Tdoc, tsdoc?: any) => ( isNew(tdoc) ? 'New' @@ -948,6 +979,10 @@ global.Hydro.model.contest = { canShowScoreboard, canViewHiddenScoreboard, getScoreboard, + addClarification, + addClarificationReply, + getClarification, + getMultiClarification, isNew, isUpcoming, isNotStarted, diff --git a/packages/hydrooj/src/model/document.ts b/packages/hydrooj/src/model/document.ts index 7ad19c73..75210398 100644 --- a/packages/hydrooj/src/model/document.ts +++ b/packages/hydrooj/src/model/document.ts @@ -5,7 +5,7 @@ import { } from 'mongodb'; import { Context } from '../context'; import { - Content, DiscussionDoc, + Content, ContestClarificationDoc, DiscussionDoc, DiscussionReplyDoc, ProblemDoc, ProblemStatusDoc, Tdoc, TrainingDoc, } from '../interface'; @@ -27,6 +27,7 @@ export const TYPE_DISCUSSION_NODE: 20 = 20; export const TYPE_DISCUSSION: 21 = 21; export const TYPE_DISCUSSION_REPLY: 22 = 22; export const TYPE_CONTEST: 30 = 30; +export const TYPE_CONTEST_CLARIFICATION: 31 = 31; export const TYPE_TRAINING: 40 = 40; /** @deprecated use `TYPE_CONTEST` with rule `homework` instead. */ export const TYPE_HOMEWORK: 60 = 60; @@ -39,6 +40,7 @@ export interface DocType { [TYPE_DISCUSSION]: DiscussionDoc; [TYPE_DISCUSSION_REPLY]: DiscussionReplyDoc; [TYPE_CONTEST]: Tdoc; + [TYPE_CONTEST_CLARIFICATION]: ContestClarificationDoc; [TYPE_TRAINING]: TrainingDoc; } @@ -498,6 +500,7 @@ global.Hydro.model.document = { setSub, TYPE_CONTEST, + TYPE_CONTEST_CLARIFICATION, TYPE_DISCUSSION, TYPE_DISCUSSION_NODE, TYPE_DISCUSSION_REPLY, diff --git a/packages/hydrooj/src/model/message.ts b/packages/hydrooj/src/model/message.ts index 1bd76a0f..50b5a85b 100644 --- a/packages/hydrooj/src/model/message.ts +++ b/packages/hydrooj/src/model/message.ts @@ -12,6 +12,7 @@ class MessageModel { static FLAG_ALERT = 2; static FLAG_RICHTEXT = 4; static FLAG_INFO = 8; + static FLAG_I18N = 16; static coll = db.collection('message'); @@ -33,7 +34,7 @@ class MessageModel { static async sendInfo(to: number, content: string) { const _id = new ObjectId(); const mdoc: MessageDoc = { - _id, from: 1, to, content, flag: MessageModel.FLAG_INFO, + _id, from: 1, to, content, flag: MessageModel.FLAG_INFO | MessageModel.FLAG_I18N, }; bus.broadcast('user/message', to, mdoc); } diff --git a/packages/hydrooj/src/service/server.ts b/packages/hydrooj/src/service/server.ts index f6a46029..a71ea46c 100644 --- a/packages/hydrooj/src/service/server.ts +++ b/packages/hydrooj/src/service/server.ts @@ -183,8 +183,8 @@ export class Handler extends HandlerCommon { } // This is beta API, may be changed in the future. - progress(message: string) { - Hydro.model.message.sendInfo(this.user._id, message); + progress(message: string, params: any[]) { + Hydro.model.message.sendInfo(this.user._id, JSON.stringify({ message, params })); } async init() { diff --git a/packages/ui-default/components/message/index.page.ts b/packages/ui-default/components/message/index.page.ts index d1a83805..2707b373 100644 --- a/packages/ui-default/components/message/index.page.ts +++ b/packages/ui-default/components/message/index.page.ts @@ -2,13 +2,24 @@ import { nanoid } from 'nanoid'; import ReconnectingWebsocket from 'reconnecting-websocket'; import { InfoDialog } from 'vj/components/dialog'; import VjNotification from 'vj/components/notification/index'; -import { FLAG_ALERT, FLAG_INFO, FLAG_RICHTEXT } from 'vj/constant/message'; +import { + FLAG_ALERT, FLAG_I18N, FLAG_INFO, FLAG_RICHTEXT, +} from 'vj/constant/message'; import { AutoloadPage } from 'vj/misc/Page'; import { i18n, tpl } from 'vj/utils'; let previous: VjNotification; const onmessage = (msg) => { console.log('Received message', msg); + if (msg.mdoc.flag & FLAG_I18N) { + try { + msg.mdoc.content = JSON.parse(msg.mdoc.content); + if (msg.mdoc.content.url) msg.mdoc.url = msg.mdoc.content.url; + msg.mdoc.content = i18n(msg.mdoc.content.message, ...msg.mdoc.content.params); + } catch (e) { + msg.mdoc.content = i18n(msg.mdoc.content); + } + } if (msg.mdoc.flag & FLAG_ALERT) { // Is alert new InfoDialog({ @@ -24,7 +35,7 @@ const onmessage = (msg) => { if (msg.mdoc.flag & FLAG_INFO) { if (previous) previous.hide(); previous = new VjNotification({ - message: i18n(msg.mdoc.content), + message: msg.mdoc.content, duration: 3000, }); previous.show(); @@ -33,15 +44,17 @@ const onmessage = (msg) => { if (document.hidden) return false; // Is message new VjNotification({ - ...(msg.udoc._id === 1 && msg.mdoc.flag & FLAG_RICHTEXT) - ? { message: i18n('You received a system message, click here to view.') } - : { + ...(msg.udoc._id === 1) + ? { + type: 'info', + message: msg.mdoc.flag & FLAG_RICHTEXT ? i18n('You received a system message, click here to view.') : msg.mdoc.content, + } : { title: msg.udoc.uname, avatar: msg.udoc.avatarUrl, message: msg.mdoc.content, }, duration: 15000, - action: () => window.open(`/home/messages?uid=${msg.udoc._id}`, '_blank'), + action: () => window.open(msg.mdoc.url ? msg.mdoc.url : `/home/messages?uid=${msg.udoc._id}`, '_blank'), }).show(); return true; }; diff --git a/packages/ui-default/constant/message.js b/packages/ui-default/constant/message.js index 073420a2..dfc58e05 100644 --- a/packages/ui-default/constant/message.js +++ b/packages/ui-default/constant/message.js @@ -2,3 +2,4 @@ export const FLAG_UNREAD = 1; export const FLAG_ALERT = 2; export const FLAG_RICHTEXT = 4; export const FLAG_INFO = 8; +export const FLAG_I18N = 16; diff --git a/packages/ui-default/pages/contest_manage.page.ts b/packages/ui-default/pages/contest_manage.page.ts new file mode 100644 index 00000000..82ac9c7f --- /dev/null +++ b/packages/ui-default/pages/contest_manage.page.ts @@ -0,0 +1,24 @@ +import $ from 'jquery'; +import { NamedPage } from 'vj/misc/Page'; +function handleReplyOrBroadcast(ev) { + const title = $(ev.currentTarget).data('title'); + const did = $(ev.currentTarget).data('did'); + + $('#reply_or_broadcast .section_title').text(title); + $('#reply_or_broadcast [name="did"]').val(did ?? ''); + const $item = $(`#clarification_${did} .media`); + if ($item.length) { + $('#reply_or_broadcast .form__item_subject').hide(); + $('#reply_or_broadcast .clarification-container').empty().append($item.clone()); + } else { + $('#reply_or_broadcast .form__item_subject').show(); + $('#reply_or_broadcast .clarification-container').empty(); + } +} + +const page = new NamedPage('contest_manage', () => { + $(document).on('click', '[name="broadcast"]', handleReplyOrBroadcast); + $(document).on('click', '[name="reply"]', handleReplyOrBroadcast); +}); + +export default page; diff --git a/packages/ui-default/templates/components/contest.html b/packages/ui-default/templates/components/contest.html index 0501817f..d8c8a19d 100644 --- a/packages/ui-default/templates/components/contest.html +++ b/packages/ui-default/templates/components/contest.html @@ -6,3 +6,7 @@ {% macro render_duration(tdoc) %} {{ tdoc.duration|round(1) if tdoc.duration else ((tdoc.endAt.getTime() - tdoc.beginAt.getTime()) /1000 / 3600)|round(1) }} {% endmacro %} + +{% macro render_clarification_subject(tdoc, pdict, subject) %} +{% if subject == 0 %}{{ _('General Issue') }}{% elif subject == -1 %}{{ _('Technical Issue') }}{% else %}{{ String.fromCharCode(65 + tdoc.pids.indexOf(subject)) + 1 }}. {{ pdict[subject].title }}{% endif %} +{% endmacro %} \ No newline at end of file diff --git a/packages/ui-default/templates/contest_manage.html b/packages/ui-default/templates/contest_manage.html index f9fe3556..228bf9aa 100644 --- a/packages/ui-default/templates/contest_manage.html +++ b/packages/ui-default/templates/contest_manage.html @@ -1,69 +1,143 @@ {% extends "layout/basic.html" %} +{% import "components/contest.html" as contest with context %} {% block content %}
-
-
-
- - - - - - - - - - - {%- for pid in tdoc.pids -%} - - - - {%- endfor -%} - -
{{ _('Problem') }}
- - {{ String.fromCharCode(65+loop.index0) }}  {{ pdict[pid].title }} - -
-
-
-
-
-
-
-

{{ _('Files') }}

-
- +
+
+
+
+
+ + + + + + + + + + + {%- for pid in tdoc.pids -%} + + + + {%- endfor -%} + +
{{ _('Problem') }}
+ + {{ String.fromCharCode(65+loop.index0) }}  {{ pdict[pid].title }} + +
+
- {{ noscript_note.render() }} - {% include "partials/files.html" %} -
- -
-
- {% if model.contest.isOngoing(tdoc) %} -
-
-

{{ _('Broadcast Message') }}

+
+
+
+

{{ _('Files') }}

+
+ +
+
+ {{ noscript_note.render() }} + {% include "partials/files.html" %} +
+ +
+
-
-
- {{ form.form_textarea({ - columns:null, - label:'Content', - name:'content', - value:'' - }) }} -
- -
-
+
+
+
+

{{ _('Contest Clarifications') }}

+ +
+
+ {% if not tcdocs.length %} + {{ nothing.render('Oh, there is no clarification!') }} + {% else %} +
    + {% for doc in tcdocs %} +
  • +
    +
    +
    +
    + {{_('Subject') }}: {{ contest.render_clarification_subject(tdoc,pdict,doc.subject) }} | {% if doc.owner == 0 %}{{ _('Jury') }}{% else %}{{ user.render_inline(udict[doc.owner], badge=false) }}{% endif %} @ {{ datetimeSpan(doc._id)|safe }} +
    + {% if doc.owner %} + + {% endif %} +
    +
    + {{ doc['content']|markdown|safe }} +
    +
    +
    +
      + {%- for rdoc in doc['reply'] -%} +
    • +
      +
              
      +
      +
      +
      + {{ _('Jury') }} +
      +
      +
      + {{ rdoc['content']|markdown|safe }} +
      +
      +
      +
    • + {%- endfor -%} +
    +
  • + {% endfor %} +
+ {% endif %} +
+
+
+

{{ _('Send Broadcast Message') }}

+
+
+
+
+ + +
+
+ +
+
+ +
+
+ +
+
+
- {% endif %}
{% include 'partials/contest_sidebar_management.html' %} diff --git a/packages/ui-default/templates/contest_problemlist.html b/packages/ui-default/templates/contest_problemlist.html index 4046e2a3..73495ad1 100644 --- a/packages/ui-default/templates/contest_problemlist.html +++ b/packages/ui-default/templates/contest_problemlist.html @@ -5,7 +5,7 @@ {% block content %} {{ set(UiContext, 'tdoc', tdoc) }} {{ set(UiContext, 'tsdoc', tsdoc) }} -
+
@@ -63,7 +63,7 @@
{% if not canViewRecord %} - {{ nothing.render('According to the contest rules, you cannot view your submission details at current.') }}' + {{ nothing.render('According to the contest rules, you cannot view your submission details at current.') }} {% elif not rdocs.length %} {{ nothing.render('Oh, there is no submission!') }} {% else %} @@ -99,8 +99,86 @@ {% endif %}
+
+
+

{{ _('Contest Clarifications') }}

+
+
+ {% if not tcdocs.length %} + {{ nothing.render('Oh, there is no clarification!') }} + {% else %} +
    + {% for doc in tcdocs %} +
  • +
    +
    +
    +
    + {{_('Subject') }}: {{ contest.render_clarification_subject(tdoc,pdict,doc.subject) }} | {% if doc.owner == 0 %}{{ _('Jury') }}{% else %}{{ user.render_inline(udict[doc.owner], badge=false) }}{% endif %} @ {{ datetimeSpan(doc._id)|safe }} +
    +
    +
    + {{ doc['content']|markdown|safe }} +
    +
    +
    +
      + {%- for rdoc in doc['reply'] -%} +
    • +
      +
              
      +
      +
      +
      + {{ _('Jury') }} @ {{ datetimeSpan(rdoc['_id'])|safe }} +
      +
      +
      + {{ rdoc['content']|markdown|safe }} +
      +
      +
      +
    • + {%- endfor -%} +
    +
  • + {% endfor %} +
+ {% endif %} +
+ {% if tsdoc.attend %} +
+

{{ _('Send Clarification Request') }}

+
+
+ + +
+
+ +
+
+ +
+
+ +
+ {% endif %} +
-
+
{% set owner_udoc = udict[tdoc.owner] %} {% include "partials/contest_sidebar.html" %}