From b344bd1dd66920e890c0814f369716a13f3854a6 Mon Sep 17 00:00:00 2001 From: yuetsh <517252939@qq.com> Date: Mon, 2 Nov 2026 20:41:25 +0800 Subject: [PATCH] =?UTF-8?q?feat(=E5=B8=83=E7=BD=AE=E4=BD=9C=E4=B8=9A):=20?= =?UTF-8?q?=E8=83=BD=E6=8C=89=E7=9F=A5=E8=AF=86=E7=82=B9=E7=82=B9=E9=80=89?= =?UTF-8?q?=E9=A2=98=E7=9B=AE=EF=BC=9B=E4=B8=80=E4=B8=AA=E7=8F=AD=E4=B8=80?= =?UTF-8?q?=E5=A4=A9=E8=83=BD=E5=B8=83=E7=BD=AE=E5=87=A0=E8=8A=82=E8=AF=BE?= =?UTF-8?q?=EF=BC=9B=E6=95=B0=E6=8D=AE=E7=BB=9F=E8=AE=A1=E8=AE=A4=E8=80=81?= =?UTF-8?q?=E5=B8=88=E5=B8=83=E7=BD=AE=E7=9A=84=E8=AF=BE=EF=BC=9B=E5=90=8E?= =?UTF-8?q?=E5=8F=B0=E8=83=BD=E7=AE=A1=E5=B8=83=E7=BD=AE=E8=BF=87=E7=9A=84?= =?UTF-8?q?=E8=AF=BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 课堂看板「选题」弹框:左边知识点(加必会题),右边搜题号/题名、点选,按点的先后排; 只列这次作业的语言能交的题(/problems、/problem-tags 加 ?language=) - 一天多节课:0040 拿掉 class_lesson (class_name, day) 唯一约束,每次布置一行; 看板「改」只改当前一节,加「新的一节」;这一节布置超过 100 分钟时先问这节课做哪几道题。 不带 lessonId/newLesson 的老请求照旧改今天最后一节 - 数据统计「一行一节课」:布置过的课以布置为准(题目和顺序照布置、做完按全班算、标「布置」), 这些提交不再参与推断切段,也不算零散;详情页「做完」注明是这一天/这节课/这段时间 - 后台「课堂作业」:一节一行,改(班级/日子/时刻/语言/题目)、看统计、删除、补一节; 看板加「布置过的课」入口。题号校验和「做完」口径抽到 services/lesson.ts,三处共用 ⚠️ 机房站点要和服务器一起升级:旧代码布置时 ON CONFLICT (class_name, day) 找不到约束会报错 Co-Authored-By: Claude Opus 5.5 --- .../src/db/0040_class_lesson_many_per_day.sql | 4 + apps/api/src/db/meta/0040_snapshot.json | 3995 +++++++++++++++++ apps/api/src/db/meta/_journal.json | 7 + apps/api/src/db/schema.ts | 11 +- apps/api/src/routes/admin/index.ts | 2 + apps/api/src/routes/admin/lesson.ts | 155 + apps/api/src/routes/classroom.ts | 81 +- apps/api/src/routes/problem.ts | 22 +- apps/api/src/routes/submission-statistics.ts | 230 +- apps/api/src/services/learning-stats.ts | 7 +- apps/api/src/services/lesson.ts | 99 + apps/api/src/time.ts | 6 + apps/web/src/admin/api.ts | 22 +- .../lesson/components/LessonEditModal.vue | 155 + apps/web/src/admin/lesson/list.vue | 227 + apps/web/src/oj/api.ts | 8 +- apps/web/src/oj/classroom/board.vue | 61 +- .../oj/classroom/components/LessonForm.vue | 55 +- .../oj/classroom/components/ProblemPicker.vue | 522 +++ .../src/oj/statistics/components/Lessons.vue | 9 +- .../statistics/components/StatisticsView.vue | 13 +- apps/web/src/routes.ts | 7 + apps/web/src/shared/api.ts | 5 +- apps/web/src/shared/layout/admin.vue | 1 + packages/contract/src/classroom.ts | 64 +- packages/contract/src/submission.ts | 16 +- 26 files changed, 5685 insertions(+), 99 deletions(-) create mode 100644 apps/api/src/db/0040_class_lesson_many_per_day.sql create mode 100644 apps/api/src/db/meta/0040_snapshot.json create mode 100644 apps/api/src/routes/admin/lesson.ts create mode 100644 apps/api/src/services/lesson.ts create mode 100644 apps/web/src/admin/lesson/components/LessonEditModal.vue create mode 100644 apps/web/src/admin/lesson/list.vue create mode 100644 apps/web/src/oj/classroom/components/ProblemPicker.vue diff --git a/apps/api/src/db/0040_class_lesson_many_per_day.sql b/apps/api/src/db/0040_class_lesson_many_per_day.sql new file mode 100644 index 0000000..80a5efc --- /dev/null +++ b/apps/api/src/db/0040_class_lesson_many_per_day.sql @@ -0,0 +1,4 @@ +-- 一个班一天可以上几节内容不同的课:拿掉一天一行的唯一约束,每次布置一行。 +-- 共库的机房站点要一起升级:旧代码布置时 ON CONFLICT (class_name, day) 会因为找不到这条约束报错。 +ALTER TABLE "class_lesson" DROP CONSTRAINT "class_lesson_class_day_unique";--> statement-breakpoint +CREATE INDEX "class_lesson_class_day_idx" ON "class_lesson" USING btree ("class_name","day"); \ No newline at end of file diff --git a/apps/api/src/db/meta/0040_snapshot.json b/apps/api/src/db/meta/0040_snapshot.json new file mode 100644 index 0000000..c8c03fc --- /dev/null +++ b/apps/api/src/db/meta/0040_snapshot.json @@ -0,0 +1,3995 @@ +{ + "id": "3d44f0d2-6a01-4f02-89f0-b8befc3aa0c6", + "prevId": "3539053d-7e49-48c2-91d7-b40cef532c8c", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.achievement": { + "name": "achievement", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigint", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "byDefault", + "name": "achievement_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "9223372036854775807", + "cache": "1", + "cycle": false + } + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "icon": { + "name": "icon", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "rarity": { + "name": "rarity", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "hidden": { + "name": "hidden", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "metric": { + "name": "metric", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "operator": { + "name": "operator", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "threshold": { + "name": "threshold", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "visible": { + "name": "visible", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "unlock_count": { + "name": "unlock_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "order": { + "name": "order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "create_time": { + "name": "create_time", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.acm_contest_rank": { + "name": "acm_contest_rank", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "submission_number": { + "name": "submission_number", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "accepted_number": { + "name": "accepted_number", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_time": { + "name": "total_time", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "submission_info": { + "name": "submission_info", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "contest_id": { + "name": "contest_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "acm_rank_contest_user_idx": { + "name": "acm_rank_contest_user_idx", + "columns": [ + { + "expression": "contest_id", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "int4_ops" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "int4_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "acm_rank_order_idx": { + "name": "acm_rank_order_idx", + "columns": [ + { + "expression": "contest_id", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "int4_ops" + }, + { + "expression": "accepted_number", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "int4_ops" + }, + { + "expression": "total_time", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "int4_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "acm_contest_rank_contest_id_21030ccd_fk_contest_id": { + "name": "acm_contest_rank_contest_id_21030ccd_fk_contest_id", + "tableFrom": "acm_contest_rank", + "tableTo": "contest", + "columnsFrom": [ + "contest_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "acm_contest_rank_user_id_40391ab2_fk_user_id": { + "name": "acm_contest_rank_user_id_40391ab2_fk_user_id", + "tableFrom": "acm_contest_rank", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "unique_acm_rank_user_contest": { + "name": "unique_acm_rank_user_contest", + "nullsNotDistinct": false, + "columns": [ + "contest_id", + "user_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.ai_analysis": { + "name": "ai_analysis", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigint", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "byDefault", + "name": "ai_analysis_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "9223372036854775807", + "cache": "1", + "cycle": false + } + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "data": { + "name": "data", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "system_prompt": { + "name": "system_prompt", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_prompt": { + "name": "user_prompt", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "analysis": { + "name": "analysis", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "create_time": { + "name": "create_time", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "is_pinned": { + "name": "is_pinned", + "type": "boolean", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "ai_analysis_user_id_3aa23011": { + "name": "ai_analysis_user_id_3aa23011", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "int4_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "ai_analysis_user_id_3aa23011_fk_user_id": { + "name": "ai_analysis_user_id_3aa23011_fk_user_id", + "tableFrom": "ai_analysis", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.ai_hint": { + "name": "ai_hint", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigint", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "byDefault", + "name": "ai_hint_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "9223372036854775807", + "cache": "1", + "cycle": false + } + }, + "submission_id": { + "name": "submission_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "prompt_version": { + "name": "prompt_version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "duration_ms": { + "name": "duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "diagnosis": { + "name": "diagnosis", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "diagnosis_error": { + "name": "diagnosis_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "level": { + "name": "level", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "filter_attempt": { + "name": "filter_attempt", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "filter_blocked": { + "name": "filter_blocked", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "filter_reason": { + "name": "filter_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "helpful": { + "name": "helpful", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "feedback_time": { + "name": "feedback_time", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "create_time": { + "name": "create_time", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "ai_hint_submission_id_idx": { + "name": "ai_hint_submission_id_idx", + "columns": [ + { + "expression": "submission_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "ai_hint_submission_id_fk_submission_id": { + "name": "ai_hint_submission_id_fk_submission_id", + "tableFrom": "ai_hint", + "tableTo": "submission", + "columnsFrom": [ + "submission_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.announcement": { + "name": "announcement", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "create_time": { + "name": "create_time", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "last_update_time": { + "name": "last_update_time", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "visible": { + "name": "visible", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "created_by_id": { + "name": "created_by_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "tag": { + "name": "tag", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "top": { + "name": "top", + "type": "boolean", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "announcement_created_by_id_359ccf50": { + "name": "announcement_created_by_id_359ccf50", + "columns": [ + { + "expression": "created_by_id", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "int4_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "announcement_list_idx": { + "name": "announcement_list_idx", + "columns": [ + { + "expression": "visible", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "top", + "isExpression": false, + "asc": false, + "nulls": "first" + }, + { + "expression": "create_time", + "isExpression": false, + "asc": false, + "nulls": "first" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "announcement_created_by_id_359ccf50_fk_user_id": { + "name": "announcement_created_by_id_359ccf50_fk_user_id", + "tableFrom": "announcement", + "tableTo": "user", + "columnsFrom": [ + "created_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.challenge_success": { + "name": "challenge_success", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "byDefault", + "name": "challenge_success_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "2147483647", + "cache": "1", + "cycle": false + } + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "problem_id": { + "name": "problem_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "challenge_id": { + "name": "challenge_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "submission_id": { + "name": "submission_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "challenge_success_problem_idx": { + "name": "challenge_success_problem_idx", + "columns": [ + { + "expression": "problem_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "challenge_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "challenge_success_user_id_fk_user_id": { + "name": "challenge_success_user_id_fk_user_id", + "tableFrom": "challenge_success", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "challenge_success_problem_id_fk_problem_id": { + "name": "challenge_success_problem_id_fk_problem_id", + "tableFrom": "challenge_success", + "tableTo": "problem", + "columnsFrom": [ + "problem_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "challenge_success_submission_id_fk_submission_id": { + "name": "challenge_success_submission_id_fk_submission_id", + "tableFrom": "challenge_success", + "tableTo": "submission", + "columnsFrom": [ + "submission_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "challenge_success_user_challenge_unique": { + "name": "challenge_success_user_challenge_unique", + "nullsNotDistinct": false, + "columns": [ + "user_id", + "problem_id", + "challenge_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.class_lesson": { + "name": "class_lesson", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "byDefault", + "name": "class_lesson_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "2147483647", + "cache": "1", + "cycle": false + } + }, + "class_name": { + "name": "class_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "day": { + "name": "day", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "problem_ids": { + "name": "problem_ids", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "language": { + "name": "language", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "class_lesson_class_day_idx": { + "name": "class_lesson_class_day_idx", + "columns": [ + { + "expression": "class_name", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "day", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "class_lesson_created_by_idx": { + "name": "class_lesson_created_by_idx", + "columns": [ + { + "expression": "created_by", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "class_lesson_created_by_fk_user_id": { + "name": "class_lesson_created_by_fk_user_id", + "tableFrom": "class_lesson", + "tableTo": "user", + "columnsFrom": [ + "created_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.contest": { + "name": "contest", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "start_time": { + "name": "start_time", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "end_time": { + "name": "end_time", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "create_time": { + "name": "create_time", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "last_update_time": { + "name": "last_update_time", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "visible": { + "name": "visible", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "created_by_id": { + "name": "created_by_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "tag": { + "name": "tag", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "contest_created_by_id_a763ca7e": { + "name": "contest_created_by_id_a763ca7e", + "columns": [ + { + "expression": "created_by_id", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "int4_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "contest_created_by_id_a763ca7e_fk_user_id": { + "name": "contest_created_by_id_a763ca7e_fk_user_id", + "tableFrom": "contest", + "tableTo": "user", + "columnsFrom": [ + "created_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.exercise": { + "name": "exercise", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "byDefault", + "name": "exercise_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "2147483647", + "cache": "1", + "cycle": false + } + }, + "type": { + "name": "type", + "type": "varchar(16)", + "primaryKey": false, + "notNull": true + }, + "data": { + "name": "data", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "order": { + "name": "order", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "tutorial_id": { + "name": "tutorial_id", + "type": "integer", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "exercise_tutorial_id_6fd04055": { + "name": "exercise_tutorial_id_6fd04055", + "columns": [ + { + "expression": "tutorial_id", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "int4_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "exercise_tutorial_id_6fd04055_fk_tutorial_id": { + "name": "exercise_tutorial_id_6fd04055_fk_tutorial_id", + "tableFrom": "exercise", + "tableTo": "tutorial", + "columnsFrom": [ + "tutorial_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.exercise_attempt": { + "name": "exercise_attempt", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "exercise_id": { + "name": "exercise_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "wrong_attempts": { + "name": "wrong_attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "solved": { + "name": "solved", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "attempts_to_solve": { + "name": "attempts_to_solve", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "last_wrong_answer": { + "name": "last_wrong_answer", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "first_attempt_at": { + "name": "first_attempt_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "last_attempt_at": { + "name": "last_attempt_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "solved_at": { + "name": "solved_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "exercise_attempt_exercise_id_idx": { + "name": "exercise_attempt_exercise_id_idx", + "columns": [ + { + "expression": "exercise_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "exercise_attempt_user_id_fk_user_id": { + "name": "exercise_attempt_user_id_fk_user_id", + "tableFrom": "exercise_attempt", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "exercise_attempt_exercise_id_fk_exercise_id": { + "name": "exercise_attempt_exercise_id_fk_exercise_id", + "tableFrom": "exercise_attempt", + "tableTo": "exercise", + "columnsFrom": [ + "exercise_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "exercise_attempt_pkey": { + "name": "exercise_attempt_pkey", + "columns": [ + "user_id", + "exercise_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.flowchart_submission": { + "name": "flowchart_submission", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "mermaid_code": { + "name": "mermaid_code", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "flowchart_data": { + "name": "flowchart_data", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "create_time": { + "name": "create_time", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "ai_score": { + "name": "ai_score", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "ai_grade": { + "name": "ai_grade", + "type": "varchar(10)", + "primaryKey": false, + "notNull": false + }, + "ai_feedback": { + "name": "ai_feedback", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ai_suggestions": { + "name": "ai_suggestions", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ai_criteria_details": { + "name": "ai_criteria_details", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "ai_provider": { + "name": "ai_provider", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true + }, + "ai_model": { + "name": "ai_model", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true + }, + "processing_time": { + "name": "processing_time", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "evaluation_time": { + "name": "evaluation_time", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "problem_id": { + "name": "problem_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "flowchart_problem_time_idx": { + "name": "flowchart_problem_time_idx", + "columns": [ + { + "expression": "problem_id", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "int4_ops" + }, + { + "expression": "create_time", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "int4_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "flowchart_status_idx": { + "name": "flowchart_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "int4_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "flowchart_create_time_idx": { + "name": "flowchart_create_time_idx", + "columns": [ + { + "expression": "create_time", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "flowchart_user_time_idx": { + "name": "flowchart_user_time_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "int4_ops" + }, + { + "expression": "create_time", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "int4_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "flowchart_submission_problem_id_8551edbf_fk_problem_id": { + "name": "flowchart_submission_problem_id_8551edbf_fk_problem_id", + "tableFrom": "flowchart_submission", + "tableTo": "problem", + "columnsFrom": [ + "problem_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "flowchart_submission_user_id_225c83e8_fk_user_id": { + "name": "flowchart_submission_user_id_225c83e8_fk_user_id", + "tableFrom": "flowchart_submission", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.judge_server": { + "name": "judge_server", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "hostname": { + "name": "hostname", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "ip": { + "name": "ip", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "judger_version": { + "name": "judger_version", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "cpu_core": { + "name": "cpu_core", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "memory_usage": { + "name": "memory_usage", + "type": "double precision", + "primaryKey": false, + "notNull": true + }, + "cpu_usage": { + "name": "cpu_usage", + "type": "double precision", + "primaryKey": false, + "notNull": true + }, + "last_heartbeat": { + "name": "last_heartbeat", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "create_time": { + "name": "create_time", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "task_number": { + "name": "task_number", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "service_url": { + "name": "service_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_disabled": { + "name": "is_disabled", + "type": "boolean", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.options_sysoptions": { + "name": "options_sysoptions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "jsonb", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "options_sysoptions_key_key": { + "name": "options_sysoptions_key_key", + "nullsNotDistinct": false, + "columns": [ + "key" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.problem": { + "name": "problem", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "input_description": { + "name": "input_description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "output_description": { + "name": "output_description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "samples": { + "name": "samples", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "test_case_id": { + "name": "test_case_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "test_case_score": { + "name": "test_case_score", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "hint": { + "name": "hint", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "languages": { + "name": "languages", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "template": { + "name": "template", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "create_time": { + "name": "create_time", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "last_update_time": { + "name": "last_update_time", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "time_limit": { + "name": "time_limit", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "memory_limit": { + "name": "memory_limit", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "visible": { + "name": "visible", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "difficulty": { + "name": "difficulty", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "submission_number": { + "name": "submission_number", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "accepted_number": { + "name": "accepted_number", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_by_id": { + "name": "created_by_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "_id": { + "name": "_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "statistic_info": { + "name": "statistic_info", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "contest_id": { + "name": "contest_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "is_public": { + "name": "is_public", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "share_submission": { + "name": "share_submission", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "prompt": { + "name": "prompt", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "answers": { + "name": "answers", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "allow_flowchart": { + "name": "allow_flowchart", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "flowchart_data": { + "name": "flowchart_data", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "flowchart_hint": { + "name": "flowchart_hint", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "mermaid_code": { + "name": "mermaid_code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "show_flowchart": { + "name": "show_flowchart", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "ast_rules": { + "name": "ast_rules", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "challenges": { + "name": "challenges", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "sql_config": { + "name": "sql_config", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "sql_display": { + "name": "sql_display", + "type": "jsonb", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "problem_contest_visible_idx": { + "name": "problem_contest_visible_idx", + "columns": [ + { + "expression": "contest_id", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "bool_ops" + }, + { + "expression": "visible", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "int4_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "problem_created_by_id_cb362143": { + "name": "problem_created_by_id_cb362143", + "columns": [ + { + "expression": "created_by_id", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "int4_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "problem_visible_idx": { + "name": "problem_visible_idx", + "columns": [ + { + "expression": "visible", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "bool_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "problem_contest_id_328e013a_fk_contest_id": { + "name": "problem_contest_id_328e013a_fk_contest_id", + "tableFrom": "problem", + "tableTo": "contest", + "columnsFrom": [ + "contest_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "problem_created_by_id_cb362143_fk_user_id": { + "name": "problem_created_by_id_cb362143_fk_user_id", + "tableFrom": "problem", + "tableTo": "user", + "columnsFrom": [ + "created_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "unique_problem_id_contest": { + "name": "unique_problem_id_contest", + "nullsNotDistinct": false, + "columns": [ + "_id", + "contest_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.problem_tag": { + "name": "problem_tag", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "category": { + "name": "category", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'knowledge'" + }, + "category_confirmed": { + "name": "category_confirmed", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + } + }, + "indexes": { + "problem_tag_name_ci_unique": { + "name": "problem_tag_name_ci_unique", + "columns": [ + { + "expression": "lower(name)", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.problem_tags": { + "name": "problem_tags", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "problem_id": { + "name": "problem_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "problemtag_id": { + "name": "problemtag_id", + "type": "integer", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "problem_tags_problemtag_id_72d20571": { + "name": "problem_tags_problemtag_id_72d20571", + "columns": [ + { + "expression": "problemtag_id", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "int4_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "problem_tags_problem_id_866ecb8d_fk_problem_id": { + "name": "problem_tags_problem_id_866ecb8d_fk_problem_id", + "tableFrom": "problem_tags", + "tableTo": "problem", + "columnsFrom": [ + "problem_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "problem_tags_problemtag_id_72d20571_fk_problem_tag_id": { + "name": "problem_tags_problemtag_id_72d20571_fk_problem_tag_id", + "tableFrom": "problem_tags", + "tableTo": "problem_tag", + "columnsFrom": [ + "problemtag_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "problem_tags_problem_id_problemtag_id_318459d1_uniq": { + "name": "problem_tags_problem_id_problemtag_id_318459d1_uniq", + "nullsNotDistinct": false, + "columns": [ + "problem_id", + "problemtag_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.problemset": { + "name": "problemset", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigint", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "byDefault", + "name": "problemset_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "9223372036854775807", + "cache": "1", + "cycle": false + } + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "create_time": { + "name": "create_time", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "last_update_time": { + "name": "last_update_time", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "visible": { + "name": "visible", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "created_by_id": { + "name": "created_by_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "end_time": { + "name": "end_time", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "assigned_at": { + "name": "assigned_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "problemset_created_by_id_01b5197f": { + "name": "problemset_created_by_id_01b5197f", + "columns": [ + { + "expression": "created_by_id", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "int4_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "problemset_created_by_id_01b5197f_fk_user_id": { + "name": "problemset_created_by_id_01b5197f_fk_user_id", + "tableFrom": "problemset", + "tableTo": "user", + "columnsFrom": [ + "created_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.problemset_badge": { + "name": "problemset_badge", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigint", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "byDefault", + "name": "problemset_badge_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "9223372036854775807", + "cache": "1", + "cycle": false + } + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "icon": { + "name": "icon", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "condition_type": { + "name": "condition_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "condition_value": { + "name": "condition_value", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "problemset_id": { + "name": "problemset_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "problemset_badge_problemset_id_6cb6c74f": { + "name": "problemset_badge_problemset_id_6cb6c74f", + "columns": [ + { + "expression": "problemset_id", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "int8_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "problemset_badge_problemset_id_6cb6c74f_fk_problemset_id": { + "name": "problemset_badge_problemset_id_6cb6c74f_fk_problemset_id", + "tableFrom": "problemset_badge", + "tableTo": "problemset", + "columnsFrom": [ + "problemset_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.problemset_problem": { + "name": "problemset_problem", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigint", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "byDefault", + "name": "problemset_problem_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "9223372036854775807", + "cache": "1", + "cycle": false + } + }, + "order": { + "name": "order", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "is_required": { + "name": "is_required", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "problem_id": { + "name": "problem_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "problemset_id": { + "name": "problemset_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "problemset_problem_problem_id_fff2d686": { + "name": "problemset_problem_problem_id_fff2d686", + "columns": [ + { + "expression": "problem_id", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "int4_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "problemset_problem_problem_id_fff2d686_fk_problem_id": { + "name": "problemset_problem_problem_id_fff2d686_fk_problem_id", + "tableFrom": "problemset_problem", + "tableTo": "problem", + "columnsFrom": [ + "problem_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "problemset_problem_problemset_id_350d17fb_fk_problemset_id": { + "name": "problemset_problem_problemset_id_350d17fb_fk_problemset_id", + "tableFrom": "problemset_problem", + "tableTo": "problemset", + "columnsFrom": [ + "problemset_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "unique_problemset_problem": { + "name": "unique_problemset_problem", + "nullsNotDistinct": false, + "columns": [ + "problem_id", + "problemset_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.problemset_progress": { + "name": "problemset_progress", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigint", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "byDefault", + "name": "problemset_progress_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "9223372036854775807", + "cache": "1", + "cycle": false + } + }, + "join_time": { + "name": "join_time", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "complete_time": { + "name": "complete_time", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "is_completed": { + "name": "is_completed", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "completed_problems_count": { + "name": "completed_problems_count", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "total_problems_count": { + "name": "total_problems_count", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "progress_detail": { + "name": "progress_detail", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "problemset_id": { + "name": "problemset_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "problemset_progress_user_id_c8041a80": { + "name": "problemset_progress_user_id_c8041a80", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "int4_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "problemset_progress_problemset_id_20a9632e_fk_problemset_id": { + "name": "problemset_progress_problemset_id_20a9632e_fk_problemset_id", + "tableFrom": "problemset_progress", + "tableTo": "problemset", + "columnsFrom": [ + "problemset_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "problemset_progress_user_id_c8041a80_fk_user_id": { + "name": "problemset_progress_user_id_c8041a80_fk_user_id", + "tableFrom": "problemset_progress", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "unique_problemset_progress_user": { + "name": "unique_problemset_progress_user", + "nullsNotDistinct": false, + "columns": [ + "problemset_id", + "user_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.reaction": { + "name": "reaction", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "byDefault", + "name": "reaction_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "2147483647", + "cache": "1", + "cycle": false + } + }, + "type": { + "name": "type", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true + }, + "create_time": { + "name": "create_time", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "problem_id": { + "name": "problem_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "reaction_problem_type_idx": { + "name": "reaction_problem_type_idx", + "columns": [ + { + "expression": "problem_id", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "int4_ops" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "int4_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "reaction_user_id_cfa7f469": { + "name": "reaction_user_id_cfa7f469", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "int4_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "reaction_problem_id_a7f3b9f3_fk_problem_id": { + "name": "reaction_problem_id_a7f3b9f3_fk_problem_id", + "tableFrom": "reaction", + "tableTo": "problem", + "columnsFrom": [ + "problem_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "reaction_user_id_cfa7f469_fk_user_id": { + "name": "reaction_user_id_cfa7f469_fk_user_id", + "tableFrom": "reaction", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "reaction_problem_user_unique": { + "name": "reaction_problem_user_unique", + "nullsNotDistinct": false, + "columns": [ + "problem_id", + "user_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.submission": { + "name": "submission", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "contest_id": { + "name": "contest_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "problem_id": { + "name": "problem_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "create_time": { + "name": "create_time", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "code": { + "name": "code", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "result": { + "name": "result", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 6 + }, + "info": { + "name": "info", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "language": { + "name": "language", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "shared": { + "name": "shared", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "statistic_info": { + "name": "statistic_info", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "problemset_id": { + "name": "problemset_id", + "type": "bigint", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "contest_create_time_idx": { + "name": "contest_create_time_idx", + "columns": [ + { + "expression": "contest_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "create_time", + "isExpression": false, + "asc": false, + "nulls": "first" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "submission_public_create_time_id_idx": { + "name": "submission_public_create_time_id_idx", + "columns": [ + { + "expression": "create_time", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"submission\".\"contest_id\" is null", + "concurrently": false, + "method": "btree", + "with": {} + }, + "problem_user_idx": { + "name": "problem_user_idx", + "columns": [ + { + "expression": "problem_id", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "int4_ops" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "int4_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "submission_result_37e2f67a": { + "name": "submission_result_37e2f67a", + "columns": [ + { + "expression": "result", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "int4_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_create_time_idx": { + "name": "user_create_time_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "int4_ops" + }, + { + "expression": "create_time", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "timestamptz_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "submission_language_time_idx": { + "name": "submission_language_time_idx", + "columns": [ + { + "expression": "language", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "create_time", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"submission\".\"contest_id\" is null", + "concurrently": false, + "method": "btree", + "with": {} + }, + "submission_result_time_idx": { + "name": "submission_result_time_idx", + "columns": [ + { + "expression": "result", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "create_time", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"submission\".\"contest_id\" is null", + "concurrently": false, + "method": "btree", + "with": {} + }, + "submission_public_problem_time_idx": { + "name": "submission_public_problem_time_idx", + "columns": [ + { + "expression": "problem_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "create_time", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"submission\".\"contest_id\" is null", + "concurrently": false, + "method": "btree", + "with": {} + }, + "submission_public_username_trgm_idx": { + "name": "submission_public_username_trgm_idx", + "columns": [ + { + "expression": "username", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "gin_trgm_ops" + } + ], + "isUnique": false, + "where": "\"submission\".\"contest_id\" is null", + "concurrently": false, + "method": "gin", + "with": {} + }, + "submission_public_metrics_idx": { + "name": "submission_public_metrics_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "problem_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "result", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "create_time", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"submission\".\"contest_id\" is null", + "concurrently": false, + "method": "btree", + "with": {} + }, + "submission_public_problem_user_idx": { + "name": "submission_public_problem_user_idx", + "columns": [ + { + "expression": "problem_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "result", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"submission\".\"contest_id\" is null", + "concurrently": false, + "method": "btree", + "with": {} + }, + "submission_problemset_id_idx": { + "name": "submission_problemset_id_idx", + "columns": [ + { + "expression": "problemset_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"submission\".\"problemset_id\" is not null", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "submission_contest_id_775716d5_fk_contest_id": { + "name": "submission_contest_id_775716d5_fk_contest_id", + "tableFrom": "submission", + "tableTo": "contest", + "columnsFrom": [ + "contest_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "submission_problem_id_76847b55_fk_problem_id": { + "name": "submission_problem_id_76847b55_fk_problem_id", + "tableFrom": "submission", + "tableTo": "problem", + "columnsFrom": [ + "problem_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "submission_problemset_id_fk_problemset_id": { + "name": "submission_problemset_id_fk_problemset_id", + "tableFrom": "submission", + "tableTo": "problemset", + "columnsFrom": [ + "problemset_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.submission_trace": { + "name": "submission_trace", + "schema": "", + "columns": { + "submission_id": { + "name": "submission_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "active_ms": { + "name": "active_ms", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "since_open_ms": { + "name": "since_open_ms", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "typed_chars": { + "name": "typed_chars", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "pasted_chars": { + "name": "pasted_chars", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "paste_count": { + "name": "paste_count", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "max_paste": { + "name": "max_paste", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "deleted_chars": { + "name": "deleted_chars", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "blur_count": { + "name": "blur_count", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "initial_len": { + "name": "initial_len", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "collab": { + "name": "collab", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "since_prev_ms": { + "name": "since_prev_ms", + "type": "bigint", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "submission_trace_submission_id_fk_submission_id": { + "name": "submission_trace_submission_id_fk_submission_id", + "tableFrom": "submission_trace", + "tableTo": "submission", + "columnsFrom": [ + "submission_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.tutorial": { + "name": "tutorial", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "byDefault", + "name": "tutorial_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "2147483647", + "cache": "1", + "cycle": false + } + }, + "title": { + "name": "title", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "is_public": { + "name": "is_public", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "order": { + "name": "order", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_by_id": { + "name": "created_by_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "code": { + "name": "code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "varchar(10)", + "primaryKey": false, + "notNull": true + }, + "project": { + "name": "project", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "default": "''" + } + }, + "indexes": { + "tutorial_created_by_id_07973cab": { + "name": "tutorial_created_by_id_07973cab", + "columns": [ + { + "expression": "created_by_id", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "int4_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "tutorial_created_by_id_07973cab_fk_user_id": { + "name": "tutorial_created_by_id_07973cab_fk_user_id", + "tableFrom": "tutorial", + "tableTo": "user", + "columnsFrom": [ + "created_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.tutorial_problem": { + "name": "tutorial_problem", + "schema": "", + "columns": { + "tutorial_id": { + "name": "tutorial_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "problem_id": { + "name": "problem_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "order": { + "name": "order", + "type": "integer", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "tutorial_problem_tutorial_id_fk_tutorial_id": { + "name": "tutorial_problem_tutorial_id_fk_tutorial_id", + "tableFrom": "tutorial_problem", + "tableTo": "tutorial", + "columnsFrom": [ + "tutorial_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "tutorial_problem_problem_id_fk_problem_id": { + "name": "tutorial_problem_problem_id_fk_problem_id", + "tableFrom": "tutorial_problem", + "tableTo": "problem", + "columnsFrom": [ + "problem_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "tutorial_problem_pkey": { + "name": "tutorial_problem_pkey", + "columns": [ + "tutorial_id", + "problem_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.tutorial_progress": { + "name": "tutorial_progress", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "tutorial_id": { + "name": "tutorial_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "view_count": { + "name": "view_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_seconds": { + "name": "total_seconds", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "first_viewed_at": { + "name": "first_viewed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "last_viewed_at": { + "name": "last_viewed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "tutorial_progress_tutorial_id_idx": { + "name": "tutorial_progress_tutorial_id_idx", + "columns": [ + { + "expression": "tutorial_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "tutorial_progress_user_id_fk_user_id": { + "name": "tutorial_progress_user_id_fk_user_id", + "tableFrom": "tutorial_progress", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "tutorial_progress_tutorial_id_fk_tutorial_id": { + "name": "tutorial_progress_tutorial_id_fk_tutorial_id", + "tableFrom": "tutorial_progress", + "tableTo": "tutorial", + "columnsFrom": [ + "tutorial_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "tutorial_progress_pkey": { + "name": "tutorial_progress_pkey", + "columns": [ + "user_id", + "tutorial_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user": { + "name": "user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "password": { + "name": "password", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true + }, + "last_login": { + "name": "last_login", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "create_time": { + "name": "create_time", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "admin_type": { + "name": "admin_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "is_disabled": { + "name": "is_disabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "problem_permission": { + "name": "problem_permission", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "raw_password": { + "name": "raw_password", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false + }, + "class_name": { + "name": "class_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "rank_hidden_at": { + "name": "rank_hidden_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "token_version": { + "name": "token_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + } + }, + "indexes": { + "user_active_idx": { + "name": "user_active_idx", + "columns": [ + { + "expression": "is_disabled", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "last_login", + "isExpression": false, + "asc": false, + "nulls": "first" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_class_name_idx": { + "name": "user_class_name_idx", + "columns": [ + { + "expression": "class_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_username_key": { + "name": "user_username_key", + "nullsNotDistinct": false, + "columns": [ + "username" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_achievement": { + "name": "user_achievement", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigint", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "byDefault", + "name": "user_achievement_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "9223372036854775807", + "cache": "1", + "cycle": false + } + }, + "unlock_time": { + "name": "unlock_time", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "backfilled": { + "name": "backfilled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "notified": { + "name": "notified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "achievement_id": { + "name": "achievement_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "user_achievement_achievement_id_29db600d": { + "name": "user_achievement_achievement_id_29db600d", + "columns": [ + { + "expression": "achievement_id", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "int8_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_achv_notified_idx": { + "name": "user_achv_notified_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "int4_ops" + }, + { + "expression": "notified", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "bool_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_achv_time_idx": { + "name": "user_achv_time_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "int4_ops" + }, + { + "expression": "unlock_time", + "isExpression": false, + "asc": false, + "nulls": "first", + "opclass": "timestamptz_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_achievement_achievement_id_29db600d_fk_achievement_id": { + "name": "user_achievement_achievement_id_29db600d_fk_achievement_id", + "tableFrom": "user_achievement", + "tableTo": "achievement", + "columnsFrom": [ + "achievement_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_achievement_user_id_b8ec7d6a_fk_user_id": { + "name": "user_achievement_user_id_b8ec7d6a_fk_user_id", + "tableFrom": "user_achievement", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "unique_user_achievement": { + "name": "unique_user_achievement", + "nullsNotDistinct": false, + "columns": [ + "achievement_id", + "user_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_badge": { + "name": "user_badge", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigint", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "byDefault", + "name": "user_badge_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "9223372036854775807", + "cache": "1", + "cycle": false + } + }, + "earned_time": { + "name": "earned_time", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "badge_id": { + "name": "badge_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "user_badge_badge_id_92a983e9": { + "name": "user_badge_badge_id_92a983e9", + "columns": [ + { + "expression": "badge_id", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "int8_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_badge_badge_id_92a983e9_fk_problemset_badge_id": { + "name": "user_badge_badge_id_92a983e9_fk_problemset_badge_id", + "tableFrom": "user_badge", + "tableTo": "problemset_badge", + "columnsFrom": [ + "badge_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_badge_user_id_a286d718_fk_user_id": { + "name": "user_badge_user_id_a286d718_fk_user_id", + "tableFrom": "user_badge", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "unique_user_badge": { + "name": "unique_user_badge", + "nullsNotDistinct": false, + "columns": [ + "badge_id", + "user_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_profile": { + "name": "user_profile", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "acm_problems_status": { + "name": "acm_problems_status", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "avatar": { + "name": "avatar", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "mood": { + "name": "mood", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "accepted_number": { + "name": "accepted_number", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "submission_number": { + "name": "submission_number", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "real_name": { + "name": "real_name", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "user_profile_user_id_8fdce8e2_fk_user_id": { + "name": "user_profile_user_id_8fdce8e2_fk_user_id", + "tableFrom": "user_profile", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_profile_user_id_key": { + "name": "user_profile_user_id_key", + "nullsNotDistinct": false, + "columns": [ + "user_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_stat": { + "name": "user_stat", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigint", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "byDefault", + "name": "user_stat_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "9223372036854775807", + "cache": "1", + "cycle": false + } + }, + "metrics": { + "name": "metrics", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "update_time": { + "name": "update_time", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "user_stat_user_id_73337fc0_fk_user_id": { + "name": "user_stat_user_id_73337fc0_fk_user_id", + "tableFrom": "user_stat", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_stat_user_id_key": { + "name": "user_stat_user_id_key", + "nullsNotDistinct": false, + "columns": [ + "user_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/apps/api/src/db/meta/_journal.json b/apps/api/src/db/meta/_journal.json index bfbc161..b2ea5c8 100644 --- a/apps/api/src/db/meta/_journal.json +++ b/apps/api/src/db/meta/_journal.json @@ -281,6 +281,13 @@ "when": 1792930018504, "tag": "0039_seed_challenges", "breakpoints": true + }, + { + "idx": 40, + "version": "7", + "when": 1793622303180, + "tag": "0040_class_lesson_many_per_day", + "breakpoints": true } ] } \ No newline at end of file diff --git a/apps/api/src/db/schema.ts b/apps/api/src/db/schema.ts index feb3bab..0c987f0 100644 --- a/apps/api/src/db/schema.ts +++ b/apps/api/src/db/schema.ts @@ -1292,7 +1292,8 @@ export const exerciseAttempt = pgTable( ) /** - * 老师给某个班「这节课」布置的题(课堂看板里输入的题号),一个班一天一行。 + * 老师给某个班布置的一节课的题(课堂看板里布置的),一次布置一行。一个班一天可以有几节 + * 内容不同的课,数据统计按它切课(routes/submission-statistics.ts)。 * * 学生首页的「班里在做」优先读它,没有才退回从提交记录推断(routes/classroom.ts)。 * 推断在上课头几分钟是空的 —— 还没人交 —— 而这正是没听清题号的学生最需要它的时候。 @@ -1323,10 +1324,11 @@ export const classLesson = pgTable( * 没有它。写入侧在 PUT /classroom/lesson 按 lessonLanguageSchema 校验过 */ language: text().$type(), - /** 这天第一次布置的老师,别的老师「改」也不换(和 createdAt 一样)。看板上写「HH:mm 谁 布置」 */ + /** 这一节第一次布置的老师,别的老师「改」也不换(和 createdAt 一样)。看板上写「HH:mm 谁 布置」 */ createdBy: integer('created_by').notNull(), /** - * 这天第一次布置的时刻,之后「改」只动 updatedAt。清掉(删行)再布置算重新开始。 + * 这一节第一次布置的时刻,之后「改」只动 updatedAt。清掉(删行)再布置算重新开始。 + * 同一天有几节时,按它分先后:最后布置的那一节是看板上的「这节课」。 * 加这一列之前布置的用 updated_at 回填(0034) */ createdAt: timestamp('created_at', { @@ -1341,7 +1343,8 @@ export const classLesson = pgTable( }).notNull(), }, (table) => [ - unique('class_lesson_class_day_unique').on(table.className, table.day), + // 一个班一天可以上几节内容不同的课,每次布置一行(0040 拿掉了原来的一天一行唯一约束) + index('class_lesson_class_day_idx').on(table.className, table.day), index('class_lesson_created_by_idx').on(table.createdBy), foreignKey({ columns: [table.createdBy], diff --git a/apps/api/src/routes/admin/index.ts b/apps/api/src/routes/admin/index.ts index 9de74ac..a4fe3c2 100644 --- a/apps/api/src/routes/admin/index.ts +++ b/apps/api/src/routes/admin/index.ts @@ -8,6 +8,7 @@ import { adminAnalyticsRoutes } from './analytics' import { adminConfRoutes } from './conf' import { adminContestRoutes } from './contest' import { adminLearnRoutes } from './learn' +import { adminLessonRoutes } from './lesson' import { adminProblemRoutes } from './problem' import { adminProblemSetRoutes } from './problemset' import { adminTagRoutes } from './tag' @@ -31,6 +32,7 @@ adminRoutes.route('/', adminAnalyticsRoutes) adminRoutes.route('/', adminConfRoutes) adminRoutes.route('/', adminContestRoutes) adminRoutes.route('/', adminLearnRoutes) +adminRoutes.route('/', adminLessonRoutes) adminRoutes.route('/', adminProblemRoutes) adminRoutes.route('/', adminProblemSetRoutes) adminRoutes.route('/', adminTagRoutes) diff --git a/apps/api/src/routes/admin/lesson.ts b/apps/api/src/routes/admin/lesson.ts new file mode 100644 index 0000000..5a6d32f --- /dev/null +++ b/apps/api/src/routes/admin/lesson.ts @@ -0,0 +1,155 @@ +import { adminLessonRequestSchema, STUDENT_ROLES, type AdminLessonList, type AdminLessonRequest } from '@oj2/contract' +import { and, count, desc, eq, inArray, sql } from 'drizzle-orm' +import { Hono } from 'hono' + +import { requireTeacher, type AppEnv } from '../../auth/middleware' +import { db, schema } from '../../db' +import { failure, parseBody, success } from '../../http' +import { lessonDoneCounts, resolveLessonProblems } from '../../services/lesson' +import { localDateTime } from '../../time' +import { queryInteger } from '../helpers' + +/** + * 后台「课堂作业」:布置过的每一节课(class_lesson)。看板上布置错了班、错了题可以在这里改, + * 看板外上的课(以前的、没在看板布置的)可以补一节 —— 数据统计按布置切课,补了才认得到。 + * + * 权限和看板一样是老师:看板上任何老师都能改任何班的这节课,这里不另设门槛。 + */ +export const adminLessonRoutes = new Hono() + +const teacherName = sql`coalesce(nullif(${schema.userProfile.realName}, ''), ${schema.user.username})` + +adminLessonRoutes.get('/lessons', requireTeacher, async (c) => { + const limit = queryInteger(c.req.query('limit'), 20, { min: 1, max: 100 }) + const offset = queryInteger(c.req.query('offset'), 0, { min: 0 }) + const className = c.req.query('className')?.trim() + const teacherId = queryInteger(c.req.query('teacher'), 0, { min: 0 }) + const where = and(className ? eq(schema.classLesson.className, className) : undefined, teacherId ? eq(schema.classLesson.createdBy, teacherId) : undefined) + + const [rows, [totalRow], teachers] = await Promise.all([ + db + .select({ + id: schema.classLesson.id, + className: schema.classLesson.className, + day: schema.classLesson.day, + problemIds: schema.classLesson.problemIds, + language: schema.classLesson.language, + createdAt: schema.classLesson.createdAt, + updatedAt: schema.classLesson.updatedAt, + createdBy: teacherName, + }) + .from(schema.classLesson) + .innerJoin(schema.user, eq(schema.user.id, schema.classLesson.createdBy)) + .leftJoin(schema.userProfile, eq(schema.userProfile.userId, schema.classLesson.createdBy)) + .where(where) + .orderBy(desc(schema.classLesson.day), desc(schema.classLesson.createdAt), desc(schema.classLesson.id)) + .limit(limit) + .offset(offset), + db.select({ value: count() }).from(schema.classLesson).where(where), + db + .select({ id: schema.classLesson.createdBy, name: teacherName }) + .from(schema.classLesson) + .innerJoin(schema.user, eq(schema.user.id, schema.classLesson.createdBy)) + .leftJoin(schema.userProfile, eq(schema.userProfile.userId, schema.classLesson.createdBy)) + .groupBy(schema.classLesson.createdBy, schema.user.username, schema.userProfile.realName) + .orderBy(desc(count())), + ]) + + const problemIds = [...new Set(rows.flatMap((row) => row.problemIds))] + const classNames = [...new Set(rows.map((row) => row.className))] + const [problems, sizes, done] = await Promise.all([ + problemIds.length + ? db + .select({ id: schema.problem.id, displayId: schema.problem.displayId, title: schema.problem.title, visible: schema.problem.visible }) + .from(schema.problem) + .where(inArray(schema.problem.id, problemIds)) + : [], + classNames.length + ? db + .select({ className: schema.user.className, value: count() }) + .from(schema.user) + .where(and(inArray(schema.user.className, classNames), inArray(schema.user.adminType, [...STUDENT_ROLES]), eq(schema.user.isDisabled, false))) + .groupBy(schema.user.className) + : [], + lessonDoneCounts(rows), + ]) + const problemById = new Map(problems.map((row) => [row.id, row])) + const sizeByClass = new Map(sizes.map((row) => [row.className, row.value])) + + return success(c, { + results: rows.map((row) => ({ + id: row.id, + className: row.className, + day: row.day, + createdAt: row.createdAt, + updatedAt: row.updatedAt, + createdBy: row.createdBy, + language: row.language ?? null, + // 题被删掉的(problem_ids 不挂外键)直接不列 + problems: row.problemIds.flatMap((id) => { + const problem = problemById.get(id) + return problem ? [{ problemDisplayId: problem.displayId, title: problem.title, visible: problem.visible }] : [] + }), + classSize: sizeByClass.get(row.className) ?? 0, + doneCount: done.get(row.id) ?? 0, + })), + total: totalRow?.value ?? 0, + teachers, + } satisfies AdminLessonList) +}) + +/** 新补一节、改一节共用的校验:班得有人、题号都对得上、语言每道都能交 */ +async function lessonValues( + body: AdminLessonRequest, +): Promise< + | { code: string; message: string } + | { values: { className: string; day: string; problemIds: number[]; language: AdminLessonRequest['language']; createdAt: string } } +> { + const [exists] = await db.select({ id: schema.user.id }).from(schema.user).where(eq(schema.user.className, body.className)).limit(1) + if (!exists) return { code: 'class-not-found', message: `没有 ${body.className} 这个班` } + const resolved = await resolveLessonProblems(body.problemDisplayIds, body.language) + if ('code' in resolved) return resolved + return { + values: { + className: body.className, + day: body.day, + problemIds: resolved.problemIds, + language: body.language, + createdAt: localDateTime(body.day, body.time), + }, + } +} + +adminLessonRoutes.post('/lessons', requireTeacher, async (c) => { + const parsed = await parseBody(c, adminLessonRequestSchema, '班级、日子、时刻或题号不对') + if (!parsed.success) return parsed.response + const result = await lessonValues(parsed.data) + if ('code' in result) return failure(c, 400, result.code, result.message) + const [row] = await db + .insert(schema.classLesson) + .values({ ...result.values, createdBy: c.get('user')!.id, updatedAt: new Date().toISOString() }) + .returning({ id: schema.classLesson.id }) + return success(c, { id: row!.id }) +}) + +adminLessonRoutes.put('/lessons/:id', requireTeacher, async (c) => { + const id = queryInteger(c.req.param('id'), 0, { min: 1 }) + const parsed = await parseBody(c, adminLessonRequestSchema, '班级、日子、时刻或题号不对') + if (!parsed.success) return parsed.response + const result = await lessonValues(parsed.data) + if ('code' in result) return failure(c, 400, result.code, result.message) + // 布置的老师不换,和看板上「改」一样认第一次 + const [row] = await db + .update(schema.classLesson) + .set({ ...result.values, updatedAt: new Date().toISOString() }) + .where(eq(schema.classLesson.id, id)) + .returning({ id: schema.classLesson.id }) + if (!row) return failure(c, 404, 'lesson-not-found', '这一节已经被删掉了') + return success(c, null) +}) + +adminLessonRoutes.delete('/lessons/:id', requireTeacher, async (c) => { + const id = queryInteger(c.req.param('id'), 0, { min: 1 }) + await db.delete(schema.classLesson).where(eq(schema.classLesson.id, id)) + return success(c, null) +}) diff --git a/apps/api/src/routes/classroom.ts b/apps/api/src/routes/classroom.ts index 3379d88..4b2e6ac 100644 --- a/apps/api/src/routes/classroom.ts +++ b/apps/api/src/routes/classroom.ts @@ -1,5 +1,4 @@ import { - isSqlProblem, STUDENT_ROLES, classLessonRequestSchema, type ClassActivity, @@ -25,6 +24,7 @@ import { failure, parseBody, success } from '../http' import { isAccepted } from '../judge/status' import { activityRows, classMemberIds, FLOWCHART_PASSED, lessonLanguageFilter, SOLVED_RESULTS, suggestActiveClass } from '../services/class-activity' import { accepted } from '../services/learning-stats' +import { resolveLessonProblems } from '../services/lesson' import { calendarDay, dayStart, localTime } from '../time' export const classroomRoutes = new Hono() @@ -94,11 +94,12 @@ async function visibleProblems(ids: number[]) { /** * 老师给这个班今天布置的题(problem.id,按输入顺序)、选的语言、布置时刻和布置的老师; - * 没布置为 null。老师写真名:老师之间认的是名字,用户名(lwp0201、little)认不出是谁 + * 没布置为 null。一天布置过几节的,取最后布置的那一节。老师写真名:老师之间认的是名字,用户名(lwp0201、little)认不出是谁 */ async function lessonPlan(className: string, day: string) { const [row] = await db .select({ + id: schema.classLesson.id, problemIds: schema.classLesson.problemIds, language: schema.classLesson.language, createdAt: schema.classLesson.createdAt, @@ -108,6 +109,7 @@ async function lessonPlan(className: string, day: string) { .innerJoin(schema.user, eq(schema.user.id, schema.classLesson.createdBy)) .leftJoin(schema.userProfile, eq(schema.userProfile.userId, schema.classLesson.createdBy)) .where(and(eq(schema.classLesson.className, className), eq(schema.classLesson.day, day))) + .orderBy(desc(schema.classLesson.createdAt), desc(schema.classLesson.id)) .limit(1) return row?.problemIds.length ? row : null } @@ -132,6 +134,7 @@ async function classLessonProblems(className: string, lookbackDays: number) { const groupById = new Map(groups.map((row) => [row.problemId, row])) return { source: 'teacher' as const, + lessonId: plan.id, day: today, language: plan.language ?? null, assignedAt: plan.createdAt, @@ -161,6 +164,7 @@ async function classLessonProblems(className: string, lookbackDays: number) { const problems = await visibleProblems(picked.map((row) => row.problemId)) return { source: picked.length ? ('inferred' as const) : null, + lessonId: null, day: picked.length ? latest : null, // 推断出来的课不知道老师要的是哪种语言,不限 language: null, @@ -379,6 +383,7 @@ classroomRoutes.get('/classroom/board', requireTeacher, async (c) => { className: null, day, source: null, + lessonId: null, language: null, assignedAt: null, assignedBy: null, @@ -397,7 +402,7 @@ classroomRoutes.get('/classroom/board', requireTeacher, async (c) => { .select({ language: schema.classLesson.language }) .from(schema.classLesson) .where(and(eq(schema.classLesson.className, className), isNotNull(schema.classLesson.language))) - .orderBy(desc(schema.classLesson.day)) + .orderBy(desc(schema.classLesson.day), desc(schema.classLesson.createdAt)) .limit(1), ]) const ids = lesson.problems.map((problem) => problem.id) @@ -550,6 +555,7 @@ classroomRoutes.get('/classroom/board', requireTeacher, async (c) => { className, day, source: lesson.source, + lessonId: lesson.lessonId, language: lesson.language, assignedAt: lesson.assignedAt, assignedBy: lesson.assignedBy, @@ -725,54 +731,51 @@ classroomRoutes.get('/classroom/lesson', requireTeacher, async (c) => { }) /** - * 老师给这个班布置今天的题。按展示题号给、不分大小写,存 problem.id 并保留输入顺序; - * 有对不上的题号就整个拒掉并点名是哪几个,免得存下半张单子老师还不知道。空数组 = 清掉, - * 学生那边退回推断。 + * 老师给这个班布置今天的一节课。按展示题号给、不分大小写,存 problem.id 并保留输入顺序; + * 有对不上的题号就整个拒掉并点名是哪几个,免得存下半张单子老师还不知道。空数组 = 清掉这一节, + * 今天一节都不剩时学生那边退回推断。改哪一节、还是新开一节,见契约 classLessonRequestSchema。 */ classroomRoutes.put('/classroom/lesson', requireTeacher, async (c) => { const parsed = await parseBody(c, classLessonRequestSchema, '题号或语言不对') if (!parsed.success) return parsed.response const user = c.get('user')! - const { className, problemDisplayIds, language } = parsed.data + const { className, problemDisplayIds, language, lessonId, newLesson } = parsed.data const day = calendarDay() const [exists] = await db.select({ id: schema.user.id }).from(schema.user).where(eq(schema.user.className, className)).limit(1) if (!exists) return failure(c, 400, 'class-not-found', `没有 ${className} 这个班`) + // 要改的是哪一行:点名的那一节(只认这个班今天的),新开一节就没有,老写法是今天最后布置的那一节 + const [target] = newLesson + ? [] + : await db + .select({ id: schema.classLesson.id }) + .from(schema.classLesson) + .where( + and( + eq(schema.classLesson.className, className), + eq(schema.classLesson.day, day), + lessonId === undefined ? undefined : eq(schema.classLesson.id, lessonId), + ), + ) + .orderBy(desc(schema.classLesson.createdAt), desc(schema.classLesson.id)) + .limit(1) if (!problemDisplayIds.length) { - await db.delete(schema.classLesson).where(and(eq(schema.classLesson.className, className), eq(schema.classLesson.day, day))) + if (target) await db.delete(schema.classLesson).where(eq(schema.classLesson.id, target.id)) return success(c, null) } - const wanted = [...new Set(problemDisplayIds.map((id) => id.toLowerCase()))] - const found = await db - .select({ - id: schema.problem.id, - displayId: schema.problem.displayId, - languages: schema.problem.languages, - }) - .from(schema.problem) - .where(and(inArray(sql`lower(${schema.problem.displayId})`, wanted), eq(schema.problem.visible, true), isNull(schema.problem.contestId))) - const idByDisplay = new Map(found.map((row) => [row.displayId.toLowerCase(), row.id])) - const missing = wanted.filter((id) => !idByDisplay.has(id)) - if (missing.length) { - return failure(c, 400, 'problem-not-found', `这些题号不存在或没有公开:${missing.join('、')}`) - } - - // 选了语言就得每道都能用它交,不然学生点开才发现交不了(提交接口会拒:language-not-allowed)。 - // SQL 题除外:它只收 SQL,C 的作业里夹一道也不会让人选错语言,计数那边也放行了 SQL - if (language) { - const unsupported = found.filter((row) => !row.languages.includes(language) && !isSqlProblem(row)).map((row) => row.displayId) - if (unsupported.length) { - return failure(c, 400, 'language-not-allowed', `这些题不能用 ${language} 交:${unsupported.join('、')}`) - } - } - - const problemIds = wanted.map((id) => idByDisplay.get(id)!) + const resolved = await resolveLessonProblems(problemDisplayIds, language) + if ('code' in resolved) return failure(c, 400, resolved.code, resolved.message) + const { problemIds } = resolved const now = new Date().toISOString() - await db - .insert(schema.classLesson) - .values({ + if (target) { + // 「改」不换布置的人,和 createdAt 一样认第一次 + await db.update(schema.classLesson).set({ problemIds, language, updatedAt: now }).where(eq(schema.classLesson.id, target.id)) + } else { + // 新开一节、今天还没有,或者点名的那一节已经没了(别的老师清掉了)—— 最后这种也照新开处理, + // 老师手上的题号不至于白填 + await db.insert(schema.classLesson).values({ className, day, problemIds, @@ -781,10 +784,6 @@ classroomRoutes.put('/classroom/lesson', requireTeacher, async (c) => { createdAt: now, updatedAt: now, }) - .onConflictDoUpdate({ - target: [schema.classLesson.className, schema.classLesson.day], - // 「改」不换布置的人,和 createdAt 一样认第一次 - set: { problemIds, language, updatedAt: now }, - }) + } return success(c, null) }) diff --git a/apps/api/src/routes/problem.ts b/apps/api/src/routes/problem.ts index 9aedece..e5b448e 100644 --- a/apps/api/src/routes/problem.ts +++ b/apps/api/src/routes/problem.ts @@ -1,6 +1,7 @@ import { AST_SUPPORTED_LANGUAGES, isSqlProblem, + lessonLanguageSchema, parseLanguage, problemListSortSchema, problemTypeFilterSchema, @@ -71,6 +72,15 @@ function typeFilter(type: ProblemTypeFilter | undefined) { return undefined } +/** + * 课堂看板「选题」弹框只列能用这次作业的语言交的题(`?language=C`),左栏知识点的题数也按它数。 + * 学生的题目列表不带这个参数 + */ +function lessonLanguageFilter(raw: string | undefined) { + const language = lessonLanguageSchema.safeParse(raw).data + return language ? sql`${schema.problem.languages} @> ${JSON.stringify([language])}::jsonb` : undefined +} + type ProblemStates = Awaited> | null type UserCounts = Awaited> @@ -137,6 +147,8 @@ problemRoutes.get('/problems', optionalAuth, async (c) => { } const byType = typeFilter(type) if (byType) filters.push(byType) + const byLanguage = lessonLanguageFilter(c.req.query('language')) + if (byLanguage) filters.push(byLanguage) const states = user ? await problemStates(user.id) : null if (undone && states && states.solved.size > 0) filters.push(notInArray(schema.problem.id, [...states.solved])) @@ -207,7 +219,15 @@ problemRoutes.get('/problem-tags', async (c) => { }) .from(schema.problemTag) .innerJoin(schema.problemTags, eq(schema.problemTags.problemtagId, schema.problemTag.id)) - .innerJoin(schema.problem, and(eq(schema.problem.id, schema.problemTags.problemId), eq(schema.problem.visible, true), isNull(schema.problem.contestId))) + .innerJoin( + schema.problem, + and( + eq(schema.problem.id, schema.problemTags.problemId), + eq(schema.problem.visible, true), + isNull(schema.problem.contestId), + lessonLanguageFilter(c.req.query('language')), + ), + ) .where(keyword ? ilike(schema.problemTag.name, `%${keyword}%`) : undefined) .groupBy(schema.problemTag.id, schema.problemTag.name, schema.problemTag.category) .having(sql`count(${schema.problemTags.problemId}) > 0`) diff --git a/apps/api/src/routes/submission-statistics.ts b/apps/api/src/routes/submission-statistics.ts index af5621c..bac0b25 100644 --- a/apps/api/src/routes/submission-statistics.ts +++ b/apps/api/src/routes/submission-statistics.ts @@ -15,7 +15,7 @@ import { type SubmissionStatisticsGrid, type TodaySubmissionStatistics, } from '@oj2/contract' -import { and, count, desc, eq, inArray, isNull, sql, type SQL } from 'drizzle-orm' +import { and, count, desc, eq, gte, inArray, isNull, lte, sql, type SQL } from 'drizzle-orm' import { Hono } from 'hono' import { optionalAuth, requireTeacher } from '../auth/middleware' @@ -24,7 +24,8 @@ import { failure, success } from '../http' import { JudgeStatus, UNJUDGED_RESULTS, type JudgeStatusValue } from '../judge/status' import { type ContestEnv } from '../services/contest' import { getBooleanOption } from '../services/options' -import { dayStart, localTime } from '../time' +import { lessonDoneCounts } from '../services/lesson' +import { calendarDay, dayStart, localTime } from '../time' import { isAdminRole, parseDisplayIds, rounded, scopedUsers, stripClassPrefix } from './helpers' export const submissionStatisticsRoutes = new Hono() @@ -525,6 +526,122 @@ const LESSON_LIVE_MINUTES = 15 const LESSONS_DEFAULT = 50 const LESSONS_MAX = 500 +type StatisticsRange = { start: string | null; end: string } + +/** 这段时间里(按东八区日子)老师在课堂看板布置过的课,同一天的按布置先后 */ +async function lessonPlans(range: StatisticsRange) { + return db + .select({ + id: schema.classLesson.id, + className: schema.classLesson.className, + day: schema.classLesson.day, + problemIds: schema.classLesson.problemIds, + language: schema.classLesson.language, + createdAt: schema.classLesson.createdAt, + }) + .from(schema.classLesson) + .where(and(range.start ? gte(schema.classLesson.day, calendarDay(range.start)) : undefined, lte(schema.classLesson.day, calendarDay(range.end)))) + .orderBy(schema.classLesson.createdAt, schema.classLesson.id) +} + +type LessonPlan = Awaited>[number] + +/** + * 布置的课各自的数:那天这个班交布置的这几道题的提交(不论隔了多久、几个人;和推断的课 + * 一样只算普通学生、不含比赛),加上全做对的人数(口径见 lessonDoneCounts)。时间窗之内一条提交都没有、布置时刻也不在 + * 窗里的不列 —— 「那节课」那种按时段筛的,别把同一天另一节课带出来。 + */ +async function assignedLessons(plans: LessonPlan[], range: StatisticsRange) { + if (!plans.length) return [] + const planRows = JSON.stringify( + plans.flatMap((plan) => + plan.problemIds.map((problemId) => ({ + k: plan.id, + class_name: plan.className, + day: plan.day, + problem_id: problemId, + })), + ), + ) + const accepted = sql.join( + ACCEPTED_RESULTS.map((value) => sql`${value}`), + sql`, `, + ) + const judging = sql.join( + UNJUDGED_RESULTS.map((value) => sql`${value}`), + sql`, `, + ) + const plan = sql`(select * from jsonb_to_recordset(${planRows}::jsonb) as p(k int, class_name text, day text, problem_id int))` + const [counts, doneByLesson] = await Promise.all([ + db.execute<{ + k: number + problem_id: number | null + users: number + total: number + accepted: number + judging: number + first: string + last: string + }>(sql` + with p as ${plan}, + s as ( + select p.k, s.user_id, s.problem_id, s.result, s.create_time + from ${schema.submission} s + join ${schema.user} u on u.id = s.user_id + join p on p.class_name = u.class_name and p.problem_id = s.problem_id + and p.day = to_char(${localTime(sql`s.create_time`)}, 'YYYY-MM-DD') + where s.contest_id is null + and s.create_time <= ${range.end} + ${range.start ? sql`and s.create_time >= ${range.start}` : sql``} + and u.admin_type = 'Regular User' and u.is_disabled = false + ) + select k, problem_id, + count(distinct user_id)::int as users, + count(*)::int as total, + (count(*) filter (where result in (${accepted})))::int as accepted, + (count(*) filter (where result in (${judging})))::int as judging, + min(create_time) as first, + max(create_time) as last + from s + group by grouping sets ((k), (k, problem_id)) + `), + lessonDoneCounts(plans), + ]) + const totalByLesson = new Map([...counts].filter((row) => row.problem_id === null).map((row) => [row.k, row])) + const byProblem = Map.groupBy( + [...counts].filter((row) => row.problem_id !== null), + (row) => row.k, + ) + const inRange = (time: string) => Date.parse(time) <= Date.parse(range.end) && (!range.start || Date.parse(time) >= Date.parse(range.start)) + return plans.flatMap((lesson) => { + const total = totalByLesson.get(lesson.id) + if (!total && !inRange(lesson.createdAt)) return [] + // 刚布置、还没人交:时段就是布置那一刻 + const first = total ? new Date(total.first).toISOString() : lesson.createdAt + const last = total ? new Date(total.last).toISOString() : lesson.createdAt + return [ + { + className: lesson.className, + day: lesson.day, + problemIds: lesson.problemIds, + start: first, + end: last, + users: total?.users ?? 0, + total: total?.total ?? 0, + accepted: total?.accepted ?? 0, + judging: total?.judging ?? 0, + byProblem: (byProblem.get(lesson.id) ?? []).map((row) => ({ + problemId: row.problem_id!, + total: row.total, + accepted: row.accepted, + judging: row.judging, + })), + doneCount: doneByLesson.get(lesson.id) ?? 0, + }, + ] + }) +} + type LessonAggRow = { level: number class_name: string | null @@ -532,7 +649,8 @@ type LessonAggRow = { sid: number | null problem_id: number | null result: JudgeStatusValue | null - lesson: boolean | null + /** plan = 布置的课的题(另算,见 assignedLessons);lesson = 推断出来的课;scattered = 零散 */ + kind: 'plan' | 'lesson' | 'scattered' | null users: number total: number accepted: number @@ -563,10 +681,19 @@ submissionStatisticsRoutes.get('/submissions/statistics/lessons', requireTeacher sql`, `, ) - // grouping() 的位:class_name 32、day 16、sid 8、problem_id 4、result 2、lesson 1。 + // 这段时间里老师布置过的课。它们的题那天交的提交另算(assignedLessons),不参与切段 + const plans = await lessonPlans(range) + const planRows = JSON.stringify( + plans.flatMap((plan) => plan.problemIds.map((problemId) => ({ class_name: plan.className, day: plan.day, problem_id: problemId }))), + ) + + // grouping() 的位:class_name 32、day 16、sid 8、problem_id 4、result 2、kind 1。 // 没参与分组的那几列记 1,所以每种粒度对应一个固定的 level - const rows = await db.execute(sql` - with s as ( + const rowsPromise = db.execute(sql` + with plan as ( + select * from jsonb_to_recordset(${planRows}::jsonb) as p(class_name text, day text, problem_id int) + ), + s as ( select u.class_name, s.user_id, s.problem_id, s.result, s.problemset_id, s.create_time, to_char(${localTime(sql`s.create_time`)}, 'YYYY-MM-DD') as day from ${schema.submission} s @@ -580,7 +707,11 @@ submissionStatisticsRoutes.get('/submissions/statistics/lessons', requireTeacher g as ( select *, case when create_time - lag(create_time) over w > ${`${LESSON_GAP_MINUTES} minutes`}::interval - then 1 else 0 end as brk + then 1 else 0 end as brk, + exists ( + select 1 from plan p + where p.class_name is not distinct from s.class_name and p.day = s.day and p.problem_id = s.problem_id + ) as planned from s window w as (partition by class_name, day order by create_time) ), @@ -594,20 +725,22 @@ submissionStatisticsRoutes.get('/submissions/statistics/lessons', requireTeacher from ( select class_name, day, sid, problem_id, count(distinct user_id) as n from seg + where not planned group by class_name, day, sid, problem_id ) p group by class_name, day, sid having max(n) >= ${LESSON_MIN_USERS} ), marked as ( - select seg.*, (l.sid is not null) as lesson + select seg.*, + case when seg.planned then 'plan' when l.sid is not null then 'lesson' else 'scattered' end as kind from seg left join lesson_seg l -- 没填班级的号(class_name 为 null)合在一起当一个班切,= 会把它们全漏掉 on l.class_name is not distinct from seg.class_name and l.day = seg.day and l.sid = seg.sid ) - select grouping(class_name, day, sid, problem_id, result, lesson) as level, - class_name, day, sid::int as sid, problem_id, result, lesson, + select grouping(class_name, day, sid, problem_id, result, kind) as level, + class_name, day, sid::int as sid, problem_id, result, kind, count(distinct user_id)::int as users, count(*)::int as total, (count(*) filter (where result in (${accepted})))::int as accepted, @@ -618,26 +751,33 @@ submissionStatisticsRoutes.get('/submissions/statistics/lessons', requireTeacher (count(distinct user_id) filter (where result in (${accepted})))::int as accepted_users from marked group by grouping sets ( - (class_name, day, sid, problem_id), - (class_name, day, sid, lesson), + (class_name, day, sid, problem_id, kind), + (class_name, day, sid, kind), (problem_id, class_name), (problem_id, result), - (lesson, class_name, day) + (kind, class_name, day) ) `) + const [rows, assigned] = await Promise.all([rowsPromise, assignedLessons(plans, range)]) - const LEVEL = { segProblem: 3, segment: 6, problemClass: 27, problemResult: 57, dayClass: 14 } + const LEVEL = { segProblem: 2, segment: 6, problemClass: 27, problemResult: 57, dayClass: 14 } const segKey = (row: LessonAggRow) => `${row.class_name}|${row.day}|${row.sid}` const failedOf = (row: { total: number; accepted: number; judging: number }) => row.total - row.accepted - row.judging const problemsOfSeg = new Map() for (const row of rows) { - if (row.level !== LEVEL.segProblem) continue + if (row.level !== LEVEL.segProblem || row.kind !== 'lesson') continue const key = segKey(row) problemsOfSeg.set(key, [...(problemsOfSeg.get(key) ?? []), row]) } - const allLessons = rows.filter((row) => row.level === LEVEL.segment && row.lesson).sort((a, b) => b.first.localeCompare(a.first)) - const picked = allLessons.slice(0, limit) + // 推断的课和布置的课排在一起,按开始时间从近到远取前 limit 节 + const allLessons = [ + ...rows.filter((row) => row.level === LEVEL.segment && row.kind === 'lesson').map((row) => ({ inferred: row, assigned: null, start: row.first })), + ...assigned.map((lesson) => ({ inferred: null, assigned: lesson, start: lesson.start })), + ].sort((a, b) => b.start.localeCompare(a.start)) + const pickedAll = allLessons.slice(0, limit) + const picked = pickedAll.flatMap((item) => (item.inferred ? [item.inferred] : [])) + const pickedAssigned = pickedAll.flatMap((item) => (item.assigned ? [item.assigned] : [])) const hasMore = allLessons.length > limit // 题号、标题一次查齐:课的题、每节课错得最多的那道、整段时间错得最多的几道 @@ -675,7 +815,13 @@ submissionStatisticsRoutes.get('/submissions/statistics/lessons', requireTeacher const lessonProblemRows = picked.map((lesson) => (problemsOfSeg.get(segKey(lesson)) ?? []).filter((row) => row.users >= LESSON_MIN_USERS).sort((a, b) => b.users - a.users), ) - const problemIds = [...new Set([...lessonProblemRows.flat().map((row) => row.problem_id!), ...hardRanked.map((row) => row.problemId)])] + const problemIds = [ + ...new Set([ + ...lessonProblemRows.flat().map((row) => row.problem_id!), + ...hardRanked.map((row) => row.problemId), + ...pickedAssigned.flatMap((lesson) => lesson.problemIds), + ]), + ] const problems = problemIds.length ? await db .select({ @@ -689,7 +835,9 @@ submissionStatisticsRoutes.get('/submissions/statistics/lessons', requireTeacher : [] const problemById = new Map(problems.map((row) => [row.id, row])) - const lessonClasses = [...new Set(picked.flatMap((row) => (row.class_name === null ? [] : [row.class_name])))] + const lessonClasses = [ + ...new Set([...picked.flatMap((row) => (row.class_name === null ? [] : [row.class_name])), ...pickedAssigned.map((lesson) => lesson.className)]), + ] const sizes = lessonClasses.length ? await db .select({ className: schema.user.className, value: count() }) @@ -700,7 +848,8 @@ submissionStatisticsRoutes.get('/submissions/statistics/lessons', requireTeacher const sum = (list: LessonAggRow[], key: 'total' | 'accepted' | 'judging') => list.reduce((acc, row) => acc + row[key], 0) const liveSince = new Date(Date.now() - LESSON_LIVE_MINUTES * 60_000).toISOString() - const lessons = picked.map((lesson, index) => { + const sizeOf = (className: string | null) => (className === null ? 0 : (sizes.find((row) => row.className === className)?.value ?? 0)) + const inferredLessons = picked.map((lesson, index) => { const own = lessonProblemRows[index]! const hardest = [...own].sort((a, b) => failedOf(b) - failedOf(a))[0] const hardestProblem = hardest ? problemById.get(hardest.problem_id!) : undefined @@ -711,13 +860,15 @@ submissionStatisticsRoutes.get('/submissions/statistics/lessons', requireTeacher end: lesson.last, userCount: lesson.users, // 没填班级的谈不上花名册,给 0(界面上就只写「几人」) - classSize: lesson.class_name === null ? 0 : (sizes.find((row) => row.className === lesson.class_name)?.value ?? 0), + classSize: sizeOf(lesson.class_name), // 点进去就拿这几道去筛:统计接口只认公开题,一次最多 20 道 problems: own .map((row) => problemById.get(row.problem_id!)) .filter((row) => row?.visible) .map((row) => row!.displayId) .slice(0, STATISTICS_MAX_PROBLEMS), + assigned: false, + doneCount: null, fromProblemSet: lesson.from_ps * 2 > lesson.total, // 次数和正确率只算这节课的题:点进去筛的就是这几道,两边的数才对得上 // (一段里零星一两个人顺手交的别的题不算) @@ -734,11 +885,44 @@ submissionStatisticsRoutes.get('/submissions/statistics/lessons', requireTeacher : null, } }) + const assignedOut = pickedAssigned.map((lesson) => { + const hardest = [...lesson.byProblem].sort((a, b) => failedOf(b) - failedOf(a))[0] + const hardestProblem = hardest ? problemById.get(hardest.problemId) : undefined + return { + className: lesson.className, + day: lesson.day, + start: lesson.start, + end: lesson.end, + userCount: lesson.users, + classSize: sizeOf(lesson.className), + problems: lesson.problemIds + .map((id) => problemById.get(id)) + .filter((row) => row?.visible) + .map((row) => row!.displayId) + .slice(0, STATISTICS_MAX_PROBLEMS), + assigned: true, + doneCount: lesson.doneCount, + fromProblemSet: false, + total: lesson.total, + correctRate: judgedRate(lesson.accepted, lesson.total - lesson.judging), + // 刚布置还没人交的也算在上课(没人交时 end 就是布置时刻) + live: Date.parse(lesson.end) >= Date.parse(liveSince), + hardest: + hardest && hardestProblem && failedOf(hardest) > 0 + ? { + problemDisplayId: hardestProblem.displayId, + problemTitle: hardestProblem.title, + failed: failedOf(hardest), + } + : null, + } + }) + const lessons = [...inferredLessons, ...assignedOut].sort((a, b) => b.start.localeCompare(a.start)) // 零散提交只给列出来的这些课覆盖到的日子(「这学期」只列了最近 50 节时,更早的零散不给) - const earliestDay = hasMore ? (picked.at(-1)?.day ?? null) : null + const earliestDay = hasMore ? (lessons.at(-1)?.day ?? null) : null const scattered = rows - .filter((row) => row.level === LEVEL.dayClass && row.lesson === false && (earliestDay === null || row.day! >= earliestDay)) + .filter((row) => row.level === LEVEL.dayClass && row.kind === 'scattered' && (earliestDay === null || row.day! >= earliestDay)) .sort((a, b) => (a.day !== b.day ? b.day!.localeCompare(a.day!) : a.class_name === null ? 1 : b.class_name === null ? -1 : b.total - a.total)) .map((row) => ({ day: row.day!, diff --git a/apps/api/src/services/learning-stats.ts b/apps/api/src/services/learning-stats.ts index e32282f..94272fe 100644 --- a/apps/api/src/services/learning-stats.ts +++ b/apps/api/src/services/learning-stats.ts @@ -117,6 +117,8 @@ export async function buildReport( .select({ day: schema.classLesson.day, problemIds: schema.classLesson.problemIds }) .from(schema.classLesson) .where(and(eq(schema.classLesson.className, target.className), start ? gte(schema.classLesson.day, calendarDay(start)) : undefined)) + // 一天布置过几节的,按布置先后接着排(下面的 planOrder 跨节累加) + .orderBy(asc(schema.classLesson.createdAt), asc(schema.classLesson.id)) : [], ]) @@ -149,9 +151,12 @@ export async function buildReport( // 否则在 ≥ 5 人的那些天里挑人最多的,打平取晚的 const lessonDay = new Map() const planOrder = new Map() + const dayOffset = new Map() for (const plan of plans) { + const offset = dayOffset.get(plan.day) ?? 0 + dayOffset.set(plan.day, offset + plan.problemIds.length) plan.problemIds.forEach((problemId, index) => { - planOrder.set(`${plan.day}|${problemId}`, index) + if (!planOrder.has(`${plan.day}|${problemId}`)) planOrder.set(`${plan.day}|${problemId}`, offset + index) const current = lessonDay.get(problemId) if (!current || usersOn(plan.day, problemId) >= usersOn(current, problemId)) lessonDay.set(problemId, plan.day) }) diff --git a/apps/api/src/services/lesson.ts b/apps/api/src/services/lesson.ts new file mode 100644 index 0000000..8f22ca2 --- /dev/null +++ b/apps/api/src/services/lesson.ts @@ -0,0 +1,99 @@ +import { isSqlProblem, STUDENT_ROLES, type LessonLanguage } from '@oj2/contract' +import { and, eq, inArray, isNull, sql } from 'drizzle-orm' + +import { db, schema } from '../db' +import { dayNumber, dayStart } from '../time' +import { FLOWCHART_PASSED, SOLVED_RESULTS } from './class-activity' + +/** + * 布置的课(class_lesson)几处共用的口径:课堂看板布置、后台「课堂作业」增改、 + * 数据统计按布置切课。放在一起,免得看板能布置的题后台布置不上,或者两边「做完」数得不一样。 + */ + +/** + * 题号 → problem.id,保留输入顺序、去重、不分大小写。只认题库里公开的题;有对不上的整个拒掉并 + * 点名是哪几个,免得存下半张单子老师还不知道。选了语言就得每道都能用它交(SQL 题除外:它只收 + * SQL,C 的作业里夹一道也不会让人选错语言,计数那边也放行了 SQL) + */ +export async function resolveLessonProblems( + problemDisplayIds: string[], + language: LessonLanguage | null, +): Promise<{ problemIds: number[] } | { code: 'problem-not-found' | 'language-not-allowed'; message: string }> { + const wanted = [...new Set(problemDisplayIds.map((id) => id.toLowerCase()))] + const found = await db + .select({ + id: schema.problem.id, + displayId: schema.problem.displayId, + languages: schema.problem.languages, + }) + .from(schema.problem) + .where(and(inArray(sql`lower(${schema.problem.displayId})`, wanted), eq(schema.problem.visible, true), isNull(schema.problem.contestId))) + const idByDisplay = new Map(found.map((row) => [row.displayId.toLowerCase(), row.id])) + const missing = wanted.filter((id) => !idByDisplay.has(id)) + if (missing.length) return { code: 'problem-not-found', message: `这些题号不存在或没有公开:${missing.join('、')}` } + if (language) { + const unsupported = found.filter((row) => !row.languages.includes(language) && !isSqlProblem(row)).map((row) => row.displayId) + if (unsupported.length) return { code: 'language-not-allowed', message: `这些题不能用 ${language} 交:${unsupported.join('、')}` } + } + return { problemIds: wanted.map((id) => idByDisplay.get(id)!) } +} + +/** 东八区某一天结束(第二天零点)的时刻 */ +export function lessonDayEnd(day: string) { + return dayStart(new Date((dayNumber(day) + 1) * 86_400_000)) +} + +/** + * 每节布置的课,班里把布置的题全做对了的人数:那天结束前做对过布置的每一道(以前做对的也算), + * 代码只认布置的语言(SQL 题放行),流程图评到 A / S 也算 —— 和看板的格子同一个口径。 + * 人按花名册(STUDENT_ROLES、没禁用),和「全班几人」对得上 + */ +export async function lessonDoneCounts(lessons: { id: number; className: string; day: string; problemIds: number[]; language: LessonLanguage | null }[]) { + const rows = lessons.flatMap((lesson) => + lesson.problemIds.map((problemId) => ({ + k: lesson.id, + class_name: lesson.className, + problem_id: problemId, + language: lesson.language, + until: lessonDayEnd(lesson.day), + })), + ) + if (!rows.length) return new Map() + const solved = sql.join( + SOLVED_RESULTS.map((value) => sql`${value}`), + sql`, `, + ) + const result = await db.execute<{ k: number; done: number }>(sql` + with p as ( + select * from jsonb_to_recordset(${JSON.stringify(rows)}::jsonb) as p(k int, class_name text, problem_id int, language text, until timestamptz) + ), + members as ( + select p.k, p.problem_id, p.language, p.until, u.id as user_id + from p join ${schema.user} u on u.class_name = p.class_name + where u.is_disabled = false and u.admin_type in (${sql.join( + STUDENT_ROLES.map((role) => sql`${role}`), + sql`, `, + )}) + ), + solved as ( + select m.k, m.user_id, m.problem_id + from members m + where exists ( + select 1 from ${schema.submission} s + where s.user_id = m.user_id and s.problem_id = m.problem_id and s.contest_id is null + and s.result in (${solved}) and s.create_time < m.until + and (m.language is null or s.language in (m.language, 'SQL')) + ) or exists ( + select 1 from ${schema.flowchartSubmission} + where ${schema.flowchartSubmission.userId} = m.user_id and ${schema.flowchartSubmission.problemId} = m.problem_id + and ${FLOWCHART_PASSED} and ${schema.flowchartSubmission.createTime} < m.until + ) + ), + per_user as ( + select k, user_id from solved group by k, user_id + having count(distinct problem_id) >= (select count(*) from p p2 where p2.k = solved.k) + ) + select k, count(*)::int as done from per_user group by k + `) + return new Map([...result].map((row) => [row.k, row.done])) +} diff --git a/apps/api/src/time.ts b/apps/api/src/time.ts index 4644cf0..660d679 100644 --- a/apps/api/src/time.ts +++ b/apps/api/src/time.ts @@ -52,6 +52,12 @@ export function dayNumber(day: string): number { return Date.UTC(year!, month! - 1, date!) / DAY_MS } +/** 东八区的「哪天几点」(`2026-10-08`、`10:30`)→ 那一刻的 ISO 字符串。后台补登课堂作业用 */ +export function localDateTime(day: string, time: string): string { + const [hour, minute] = time.split(':').map(Number) + return new Date(dayNumber(day) * DAY_MS + (hour! * 60 + minute!) * 60_000 - OFFSET_MS).toISOString() +} + /** 日历日序号 → `YYYY-MM-DD` */ export function dayText(day: number): string { return new Date(day * DAY_MS).toISOString().slice(0, 10) diff --git a/apps/web/src/admin/api.ts b/apps/web/src/admin/api.ts index c01c2ab..9ad1125 100644 --- a/apps/web/src/admin/api.ts +++ b/apps/web/src/admin/api.ts @@ -1,4 +1,4 @@ -import { toAdminType, toProblemPermission } from '@oj2/contract' +import { toAdminType, toProblemPermission, type AdminLessonList, type AdminLessonRequest } from '@oj2/contract' import type { AddContestProblemRequest, ChallengeInput, @@ -779,3 +779,23 @@ export function updateAchievement(data: Partial) { export function deleteAchievement(id: number) { return api.delete(`admin/achievements/${id}`) } + +// ---------- 课堂作业(class_lesson) ---------- + +export function getAdminLessons(offset: number, limit: number, filters: { className: string; teacher: number | null }) { + return api.get('admin/lessons', { + params: { offset, limit, className: filters.className, teacher: filters.teacher ?? '' }, + }) +} + +export function createAdminLesson(body: AdminLessonRequest) { + return api.post<{ id: number }>('admin/lessons', body) +} + +export function updateAdminLesson(id: number, body: AdminLessonRequest) { + return api.put(`admin/lessons/${id}`, body) +} + +export function deleteAdminLesson(id: number) { + return api.delete(`admin/lessons/${id}`) +} diff --git a/apps/web/src/admin/lesson/components/LessonEditModal.vue b/apps/web/src/admin/lesson/components/LessonEditModal.vue new file mode 100644 index 0000000..1165f0f --- /dev/null +++ b/apps/web/src/admin/lesson/components/LessonEditModal.vue @@ -0,0 +1,155 @@ + + + + + diff --git a/apps/web/src/admin/lesson/list.vue b/apps/web/src/admin/lesson/list.vue new file mode 100644 index 0000000..f2ed76d --- /dev/null +++ b/apps/web/src/admin/lesson/list.vue @@ -0,0 +1,227 @@ + + + + + diff --git a/apps/web/src/oj/api.ts b/apps/web/src/oj/api.ts index 4e21f49..57dc58f 100644 --- a/apps/web/src/oj/api.ts +++ b/apps/web/src/oj/api.ts @@ -260,12 +260,16 @@ export function getClassLesson(className?: string) { return api.get('classroom/lesson', { params: { className } }) } -/** 给这个班布置今天的题,空数组 = 清掉 */ -export function setClassLesson(className: string, problemDisplayIds: string[], language: LessonLanguage | null) { +/** + * 给这个班布置今天的一节课,空数组 = 清掉这一节。`lessonId` 为 null 是今天新开一节 + * (一个班一天可以上几节内容不同的课),否则改那一节 + */ +export function setClassLesson(className: string, problemDisplayIds: string[], language: LessonLanguage | null, lessonId: number | null) { return api.put('classroom/lesson', { className, problemDisplayIds, language, + ...(lessonId === null ? { newLesson: true } : { lessonId }), } satisfies ClassLessonRequest) } diff --git a/apps/web/src/oj/classroom/board.vue b/apps/web/src/oj/classroom/board.vue index 08a35f6..3bb0947 100644 --- a/apps/web/src/oj/classroom/board.vue +++ b/apps/web/src/oj/classroom/board.vue @@ -15,6 +15,8 @@ import { parseTime } from 'utils/functions' import type { ClassBoard, ClassBoardCell, ClassBoardProblem, ClassBoardStudent } from 'utils/types' import ExplainModal, { type ExplainPerson } from './components/ExplainModal.vue' import LeaveModal from './components/LeaveModal.vue' +import AdminLink from 'shared/components/AdminLink.vue' +import { useBreakpoints } from 'shared/composables/breakpoints' import LessonForm from './components/LessonForm.vue' import TrailPanel from './components/TrailPanel.vue' import { errorKind, type ErrorKind } from 'utils/errorKind' @@ -41,6 +43,7 @@ const collabStore = useCollabStore() const message = useMessage() const theme = useThemeVars() const tone = useTone() +const { isDesktop } = useBreakpoints() const router = useRouter() const route = useRoute() const { help, peek } = useTeacherHelp() @@ -111,13 +114,34 @@ onUnmounted(pause) // ---------- 这节课的题:上课时当场填 ---------- -const editing = ref(false) +/** 顶上那条布置框开着没有:edit = 改这一节,new = 今天再开一节 */ +const editing = ref(false) -/** 还没有题(没布置、今天也推断不出来):填题号的框就是这页最大的东西 */ -const needLesson = computed(() => !!board.value?.className && !board.value.problems.length) +/** + * 一个班一天可以上几节内容不同的课。打开看板(或换到这个班)时,这一节已经布置了这么久, + * 多半是上一节课的 —— 先问这节课做哪几道题,底下能点回去接着看上一节。 + * 只在第一次看到这一节时判一次:老师一直开着看板时,15 秒一刷不会把表突然换掉 + */ +const STALE_LESSON_MINUTES = 100 +const staleLessonId = ref(null) +const seenLessonIds = new Set() +watch( + () => board.value?.lessonId ?? null, + (lessonId) => { + if (lessonId === null || seenLessonIds.has(lessonId)) return + seenLessonIds.add(lessonId) + const assignedAt = board.value?.assignedAt + if (assignedAt && Date.now() - Date.parse(assignedAt) > STALE_LESSON_MINUTES * 60_000) staleLessonId.value = lessonId + }, +) +const staleLesson = computed(() => board.value?.lessonId != null && board.value.lessonId === staleLessonId.value) + +/** 还没有题(没布置、今天也推断不出来),或者布置的是上一节课的:填题号的框就是这页最大的东西 */ +const needLesson = computed(() => !!board.value?.className && (!board.value.problems.length || staleLesson.value)) async function lessonSaved() { editing.value = false + staleLessonId.value = null selectedId.value = null await load() } @@ -434,28 +458,36 @@ function lastText(student: ClassBoardStudent) { v-if="editing && board && className" :board="board" :class-name="className" + :lesson-id="editing === 'edit' ? board.lessonId : null" variant="inline" class="lesson-edit" @saved="lessonSaved" @cancel="editing = false" /> -
+
{{ board.source === 'teacher' ? '这节课' : '今天在做' }} {{ LANGUAGE_SHOW_VALUE[board.language] }} {{ problem.problemDisplayId }} {{ parseTime(board.assignedAt, 'HH:mm') }} {{ board.assignedBy }} 布置 - + {{ board.source === 'teacher' ? '改' : '布置' }} + + + + 新的一节 +
{{ leaveStudents.length ? `请假 ${leaveStudents.length} 人` : '请假' }} + + 布置过的课 回头看 →
@@ -466,7 +498,12 @@ function lastText(student: ClassBoardStudent) {
这节课做哪几道题?
- + +
+ 上一节 {{ board.assignedAt ? parseTime(board.assignedAt, 'HH:mm') : '' }} {{ board.assignedBy }} 布置 + {{ problem.problemDisplayId }} + 接着看这一节 +
-
+ +
@@ -815,6 +853,15 @@ function lastText(student: ClassBoardStudent) { gap: 10px; } +.earlier { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 6px; + font-size: 14px; + color: v-bind('theme.textColor3'); +} + /* ---------- 题卡片 ---------- */ .cards { diff --git a/apps/web/src/oj/classroom/components/LessonForm.vue b/apps/web/src/oj/classroom/components/LessonForm.vue index 90987a0..033b3f0 100644 --- a/apps/web/src/oj/classroom/components/LessonForm.vue +++ b/apps/web/src/oj/classroom/components/LessonForm.vue @@ -4,16 +4,21 @@ import { setClassLesson } from 'oj/api' import { errorMessage } from 'utils/api' import { LANGUAGE_SHOW_VALUE } from 'utils/constants' import type { ClassBoard } from 'utils/types' +import { Icon } from '@iconify/vue' +import ProblemPicker from './ProblemPicker.vue' /** * 看板上布置「这节课的题」:题号是上课时当场填的。两种样子 —— - * - `inline`:顶上一行里点「改 / 布置」展开的那一小条; + * - `inline`:顶上一行里点「改 / 新的一节 / 布置」展开的那一小条; * - `start`:刚上课还没有题时,这页最大的那个框。 + * + * 一个班一天可以上几节内容不同的课:`lessonId` 是要改的那一节,null = 今天新开一节 */ const props = defineProps<{ board: ClassBoard className: string variant: 'inline' | 'start' + lessonId: number | null }>() const emit = defineEmits<{ saved: []; cancel: [] }>() const message = useMessage() @@ -31,27 +36,35 @@ const languageOptions = LESSON_LANGUAGES.map((value) => ({ const lessonInput = ref('') const saving = ref(false) +/** 改的是老师布置的这一节:框里是它的题;新开一节是空的 */ +const editingPlanned = computed(() => props.lessonId !== null && props.lessonId === props.board.lessonId) + function resetLessonInput() { - lessonInput.value = props.board.source === 'teacher' ? props.board.problems.map((problem) => problem.problemDisplayId).join(' ') : '' - lessonLanguage.value = props.board.language ?? props.board.lastLanguage ?? 'Python' + lessonInput.value = editingPlanned.value ? props.board.problems.map((problem) => problem.problemDisplayId).join(' ') : '' + lessonLanguage.value = (editingPlanned.value ? props.board.language : null) ?? props.board.lastLanguage ?? 'Python' } resetLessonInput() // 推断出来的题也带进框里,老师多半只是确认一下 -if (props.variant === 'inline' && !lessonInput.value && props.board.problems.length) { +if (props.variant === 'inline' && props.board.source === 'inferred' && !lessonInput.value && props.board.problems.length) { lessonInput.value = props.board.problems.map((problem) => problem.problemDisplayId).join(' ') } -// 换了班要把输入框换成那个班今天布置的题、布置时选的语言(15 秒一次的刷新不动它们)。 -// 必须写成两个来源:一个 getter 返回数组的话,每次刷新整个 board 换新,数组也是新的, +// 换了班、换了一节要把输入框换成那一节的题、布置时选的语言(15 秒一次的刷新不动它们)。 +// 必须写成几个单值来源:一个 getter 返回数组的话,每次刷新整个 board 换新,数组也是新的, // 回调次次都跑,老师打到一半的题号每 15 秒被清一次 -watch([() => props.board.className, () => props.board.source], resetLessonInput) +watch([() => props.board.className, () => props.board.source, () => props.lessonId], resetLessonInput) + +function inputIds() { + return lessonInput.value.split(/[\s,,、;;]+/).filter(Boolean) +} async function saveLesson() { - const ids = lessonInput.value.split(/[\s,,、;;]+/).filter(Boolean) + const ids = inputIds() saving.value = true try { - await setClassLesson(props.className, ids, ids.length ? lessonLanguage.value : null) + await setClassLesson(props.className, ids, ids.length ? lessonLanguage.value : null, props.lessonId) message.success(ids.length ? '已布置,学生首页会显示这几道题' : '已清掉') + picking.value = false emit('saved') } catch (error) { message.error(errorMessage(error)) @@ -59,19 +72,43 @@ async function saveLesson() { saving.value = false } } + +// ---- 选题弹框:不用记题号,按知识点挑。框里已经敲了的题号带进去,题名能对上的带上题名 ---- +const picking = ref(false) +const pickerInitial = ref<{ id: string; title?: string }[]>([]) +function openPicker() { + const titles = new Map(props.board.problems.map((p) => [p.problemDisplayId.toLowerCase(), p.title])) + pickerInitial.value = inputIds().map((id) => ({ id, title: titles.get(id.toLowerCase()) })) + picking.value = true +} +function pickerConfirm(ids: string[]) { + lessonInput.value = ids.join(' ') + saveLesson() +} diff --git a/apps/web/src/oj/classroom/components/ProblemPicker.vue b/apps/web/src/oj/classroom/components/ProblemPicker.vue new file mode 100644 index 0000000..c2c9a3d --- /dev/null +++ b/apps/web/src/oj/classroom/components/ProblemPicker.vue @@ -0,0 +1,522 @@ + + + + + diff --git a/apps/web/src/oj/statistics/components/Lessons.vue b/apps/web/src/oj/statistics/components/Lessons.vue index bfd338b..bd79e64 100644 --- a/apps/web/src/oj/statistics/components/Lessons.vue +++ b/apps/web/src/oj/statistics/components/Lessons.vue @@ -79,12 +79,17 @@ function clock(lesson: Lesson) { return `${parseTime(lesson.start, 'HH:mm')}–${parseTime(lesson.end, 'HH:mm')}` } -/** 23 人来了、花名册却只有 22(有个号不是学生角色)时,不写「23 / 22」 */ +/** + * 布置的课知道要做什么,写全班几人做完;推断的课只知道谁交了,写来了几人。 + * 23 人来了、花名册却只有 22(有个号不是学生角色)时,不写「23 / 22」 + */ function attendance(lesson: Lesson) { + if (lesson.doneCount !== null) return lesson.classSize ? `做完 ${lesson.doneCount} / ${lesson.classSize}` : `做完 ${lesson.doneCount} 人` return lesson.classSize >= lesson.userCount ? `${lesson.userCount} / ${lesson.classSize} 人` : `${lesson.userCount} 人` } function problemText(lesson: Lesson) { + if (lesson.assigned) return `布置 ${lesson.problems.length} 道` return `${lesson.fromProblemSet ? '题单 ' : ''}${lesson.problems.length} 道` } @@ -152,7 +157,7 @@ function percent(value: number, total: number) { {{ classLabel(lesson.className) }} - {{ attendance(lesson) }} + {{ attendance(lesson) }}{{ problemText(lesson) }} {{ lesson.total }} 次 · diff --git a/apps/web/src/oj/statistics/components/StatisticsView.vue b/apps/web/src/oj/statistics/components/StatisticsView.vue index bba7db6..1570b2a 100644 --- a/apps/web/src/oj/statistics/components/StatisticsView.vue +++ b/apps/web/src/oj/statistics/components/StatisticsView.vue @@ -65,6 +65,17 @@ const custom = computed<[number, number] | null>(() => { return from.value && to.value && Number.isFinite(a) && Number.isFinite(b) ? [a, b] : null }) +/** + * 「做完」只数所选时段之内做对的,和看板、后台「课堂作业」、一行一节课的「做完」不一样 + * (那几处以前做对的也算)—— 从那边点进来数字会变小,所以写明是哪段时间里做完的 + */ +const doneLabel = computed(() => { + const value = periodValue.value + if (value === 'today' || (value === 'custom' && custom.value && custom.value[0] === custom.value[1])) return '这一天做完' + if (value === 'lesson' || value === '1h' || value === '2h') return '这节课做完' + return '这段时间做完' +}) + const periodValue = computed(() => { // 「那节课」要带着精确的起止时刻,地址栏里缺了就退回默认 if (period.value === 'lesson') return custom.value ? 'lesson' : DEFAULT_PERIOD @@ -377,7 +388,7 @@ function resultWidth(value: number) { diff --git a/apps/web/src/routes.ts b/apps/web/src/routes.ts index e1cd941..0ad3b73 100644 --- a/apps/web/src/routes.ts +++ b/apps/web/src/routes.ts @@ -302,6 +302,13 @@ export const admins: RouteRecordRaw = { component: () => import('admin/problem/Trend.vue'), meta: { requiresTeacherAdmin: true }, }, + { + // 课堂看板布置的每一节课:改、删、补(数据统计按布置切课) + path: 'lesson', + name: 'admin lesson list', + component: () => import('admin/lesson/list.vue'), + meta: { requiresTeacherAdmin: true }, + }, // 题单管理路由 { path: 'problemset/list', diff --git a/apps/web/src/shared/api.ts b/apps/web/src/shared/api.ts index 5bd04d0..e807ef2 100644 --- a/apps/web/src/shared/api.ts +++ b/apps/web/src/shared/api.ts @@ -32,8 +32,9 @@ export async function getProfile(username: string = ''): Promise return contractDeferred('GET /profiles/:username', () => import('./profileSchema').then((m) => m.userProfileSchema), response) } -export function getProblemTagList() { - return api.get('problem-tags') +/** 带 language 只数能用这个语言交的题(看板「选题」弹框用) */ +export function getProblemTagList(language = '') { + return api.get('problem-tags', { params: { language } }) } export function getHitokoto() { diff --git a/apps/web/src/shared/layout/admin.vue b/apps/web/src/shared/layout/admin.vue index be07efb..f0b9407 100644 --- a/apps/web/src/shared/layout/admin.vue +++ b/apps/web/src/shared/layout/admin.vue @@ -67,6 +67,7 @@ const NAV: NavGroup[] = [ to: '/admin/problemset/list', role: 'teacher', }, + { label: '课堂作业', icon: 'ph:chalkboard-teacher', path: '/admin/lesson', role: 'teacher' }, { label: '教程', icon: 'ph:book-open', diff --git a/packages/contract/src/classroom.ts b/packages/contract/src/classroom.ts index e67e2d2..1e512c0 100644 --- a/packages/contract/src/classroom.ts +++ b/packages/contract/src/classroom.ts @@ -74,12 +74,20 @@ export const lastVisitSchema = z.object({ /** * 课堂看板里老师布置这节课的题:按输入顺序给展示题号,最多 20 道,空数组 = 清掉。 - * `language` 是这份作业用什么语言做,null = 没选 + * `language` 是这份作业用什么语言做,null = 没选。 + * + * 一个班一天可以上几节内容不同的课,每次布置是一行(class_lesson.id): + * - `lessonId`:改 / 清掉这一节(看板上的「改」); + * - `newLesson`:今天再开一节(看板上的「新的一节」); + * - 两个都不带:改今天最后布置的那一节,今天还没有就新开 —— 一天只能一份时的老写法, + * 开着旧页面的老师、没跟上的机房站点发的都是这种,别让它们每改一次就多出一节课 */ export const classLessonRequestSchema = z.object({ className: z.string().trim().min(1), problemDisplayIds: z.array(z.string().trim().min(1)).max(20), language: lessonLanguageSchema.nullable(), + lessonId: z.number().int().positive().optional(), + newLesson: z.boolean().optional(), }) export const classBoardProblemSchema = z.object({ @@ -168,16 +176,19 @@ export const classBoardStudentSchema = z.object({ }) /** - * 课堂看板:一个班、今天、这节课的几道题 × 全班学生。 + * 课堂看板:一个班、今天、这节课的几道题 × 全班学生。「这节课」是今天最后布置的那一节, + * 今天没布置过就按今天同班的提交推断。 * className 为 null = 没指定班级、最近两小时也没有哪个班在交(没法猜)。 */ export const classBoardSchema = z.object({ className: z.string().nullable(), day: z.string(), source: z.enum(['teacher', 'inferred']).nullable(), - /** 今天布置的语言(见 lessonLanguageSchema) */ + /** 老师布置的这一节的 id(「改」带着它);推断出来的课为 null */ + lessonId: z.number().int().nullable(), + /** 这一节布置的语言(见 lessonLanguageSchema) */ language: lessonLanguageSchema, - /** 今天第一次布置的时刻,之后改题不变;推断出来的课为 null */ + /** 这一节第一次布置的时刻,之后改题不变;推断出来的课为 null */ assignedAt: z.string().nullable(), /** 第一次布置的老师(真名,没有真名用用户名);推断出来的课为 null */ assignedBy: z.string().nullable(), @@ -250,3 +261,48 @@ export type ClassBoardProblem = z.infer export type ClassBoardStudent = z.infer export type ClassBoardCell = z.infer export type ClassActivityProblem = z.infer + +/** + * 后台「课堂作业」:布置过的每一节课一行(class_lesson),新的在前。看板上当场布置的、 + * 后台补的都在这里,数据统计按它们切课。 + */ +export const adminLessonSchema = z.object({ + id: z.number().int(), + className: z.string(), + /** 东八区的日子,`2026-10-08` */ + day: z.string(), + /** 布置的时刻(看板上第一次布置那一刻;后台补的是填的时刻) */ + createdAt: z.string(), + updatedAt: z.string(), + /** 布置的老师:真名,没有真名用用户名(和看板「谁 布置」一样) */ + createdBy: z.string(), + language: lessonLanguageSchema, + /** 按布置的顺序;题后来被藏起来的 visible 为 false(统计、学生那边都不再出现) */ + problems: z.array(z.object({ problemDisplayId: z.string(), title: z.string(), visible: z.boolean() })), + /** 班里的学生数(STUDENT_ROLES、没禁用),和 doneCount 同一个花名册 */ + classSize: z.number().int(), + /** 把布置的题全做对了的人数(那天结束前;口径同看板) */ + doneCount: z.number().int(), +}) + +export const adminLessonListSchema = z.object({ + results: z.array(adminLessonSchema), + total: z.number().int().nonnegative(), + /** 布置过课的老师(筛选下拉用),按布置的节数从多到少 */ + teachers: z.array(z.object({ id: z.number().int(), name: z.string() })), +}) + +/** + * 后台新补一节 / 改一节。日子和时刻按东八区填:补的是看板外上的课(没在看板布置、 + * 以前的课),时刻决定同一天几节课的先后。题目至少一道 —— 删掉一节走 DELETE + */ +export const adminLessonRequestSchema = z.object({ + className: z.string().trim().min(1), + day: z.string().regex(/^\d{4}-\d{2}-\d{2}$/), + time: z.string().regex(/^([01]\d|2[0-3]):[0-5]\d$/), + language: lessonLanguageSchema, + problemDisplayIds: z.array(z.string().trim().min(1)).min(1).max(20), +}) +export type AdminLesson = z.infer +export type AdminLessonList = z.infer +export type AdminLessonRequest = z.infer diff --git a/packages/contract/src/submission.ts b/packages/contract/src/submission.ts index e69ac61..4fe03b1 100644 --- a/packages/contract/src/submission.ts +++ b/packages/contract/src/submission.ts @@ -450,6 +450,10 @@ export const NO_CLASS = 'none' * 没填班级的号合在一起当一个班切(`className` 为 null)。 * 其余的是零散提交(补做、课后自己练)。这样 26计算机0班 9 月 29 日是 14:04–15:38, * 不会被 16:24、18:42 那两条课后提交拖成「14:04–18:42」。 + * + * **老师在课堂看板布置过的课以布置为准**(`assigned`):题目和顺序照布置的,那天这个班 + * 交这几道题的提交全算这节课(不论隔了多久、几个人),完成率按全班算。这些提交不再参与上面 + * 的切段 —— 同一天没布置的另一节课照旧推断,也不会被算成零散提交。 */ const lessonFailureSchema = z.object({ result: judgeStatusSchema, count: z.number().int() }) @@ -467,8 +471,18 @@ export const submissionLessonsSchema = z.object({ userCount: z.number().int(), /** 班里的学生数(未禁用的普通用户)。没填班级的谈不上花名册,给 0 */ classSize: z.number().int(), - /** 这节课做的题(这一段里 5 人以上做过的),做的人多的在前,最多 20 道 —— 点进去就筛这几道 */ + /** + * 这节课做的题,最多 20 道 —— 点进去就筛这几道。布置的课是布置的那几道、按布置的顺序; + * 推断的课是这一段里 5 人以上做过的,做的人多的在前 + */ problems: z.array(z.string()), + /** 老师在课堂看板布置的课(不是从提交推断的) */ + assigned: z.boolean(), + /** + * 布置的课:班里把布置的题全做对了的人数(那天结束前,以前做对的也算;布置时选了语言的 + * 只认那个语言,流程图评到 A / S 也算 —— 和看板同一个口径)。推断的课为 null + */ + doneCount: z.number().int().nullable(), /** 多半是从题单里交的 —— 「题单 7 道」 */ fromProblemSet: z.boolean(), /** 这节课的题(上面 problems)交了几次 —— 和点进去看到的对得上 */