Compare commits

...
3 Commits
Author SHA1 Message Date
xuyueandClaude Opus 5.5 1a173f75d7 feat(挑战): 这节课做完了给「还能挑战」,挑战中编辑器顶上一条紫色的,老师看板上看得出谁在挑战
Deploy / deploy (push) Canceled after 0s
做完这节课(或题库里自己做对)之后列出还能挑战的题;去挑战后编辑器顶上写着要求,
没做到标出是哪几行,做到了写本班第几个、全站第几个。课堂看板做到的格子贴一枚紫色筹码、
在挑战的加一圈紫边,题卡片写挑战成功几人。出题页多一块「挑战」,能从做对的代码里找候选。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 08:52:50 +08:00
xuyueandClaude Opus 5.5 0faf78ad8f feat(挑战): 做对之后可以加一条写法上的限制再做一遍,判题时记下谁做到了
借小丑牌的赌注档:同一道题加一条限制(「整首歌只用 1 次 print」)再做一遍,
给课上做得快的学生找事做(做完一节课的题,中位要空等 13 分钟)。
判定沿用 AST 规则,挑战配在 problem.challenges,做到的记在 challenge_success;
做对的提交顺手判一遍,挑战没做到也照样算做对。没过的规则带上是第几行。
0039 灌进第一批 56 道常用题的 73 条挑战,api 起来后拿历史上做对的代码补一遍谁已经做到了。
出题页能配挑战、从做对的代码里找候选;课堂看板的格子带上挑战状态。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 08:52:50 +08:00
xuyueandClaude Opus 5.5 cc1361e596 chore(数据): 一条命令把线上最新的库和测试点、图片拉到本机
docker/pull-prod.sh 经 ssh debian 只导 onlinejudge 一个库,灌进新库对过行数再换名,
旧库留作 onlinejudge_prev;--files 顺带同步测试点、题面图片、头像。对服务器只读。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 19:59:25 +08:00
38 changed files with 10536 additions and 61 deletions

No files matched your search

+15
View File
@@ -0,0 +1,15 @@
CREATE TABLE "challenge_success" (
"id" integer PRIMARY KEY GENERATED BY DEFAULT AS IDENTITY (sequence name "challenge_success_id_seq" INCREMENT BY 1 MINVALUE 1 MAXVALUE 2147483647 START WITH 1 CACHE 1),
"user_id" integer NOT NULL,
"problem_id" integer NOT NULL,
"challenge_id" text NOT NULL,
"submission_id" text NOT NULL,
"created_at" timestamp with time zone NOT NULL,
CONSTRAINT "challenge_success_user_challenge_unique" UNIQUE("user_id","problem_id","challenge_id")
);
--> statement-breakpoint
ALTER TABLE "problem" ADD COLUMN "challenges" jsonb;--> statement-breakpoint
ALTER TABLE "challenge_success" ADD CONSTRAINT "challenge_success_user_id_fk_user_id" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "challenge_success" ADD CONSTRAINT "challenge_success_problem_id_fk_problem_id" FOREIGN KEY ("problem_id") REFERENCES "public"."problem"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "challenge_success" ADD CONSTRAINT "challenge_success_submission_id_fk_submission_id" FOREIGN KEY ("submission_id") REFERENCES "public"."submission"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
CREATE INDEX "challenge_success_problem_idx" ON "challenge_success" USING btree ("problem_id","challenge_id","created_at");
+58
View File
@@ -0,0 +1,58 @@
-- 2026-10-25 第一批挑战:课上被 4 个以上班布置过的题里,从做对的代码挖出、逐条看过例子定下的 73 条
-- (56 道题)。只填还没配过挑战的题;谁已经做到了由 api 启动后 services/challenge.ts 拿历史提交补。
UPDATE "problem" SET "challenges" = '{"Python":[{"id":"d0b6eeb8","wording":"用 map() 读入两个数","rules":[{"engine":"must_call_function","target":"map"}]}]}'::jsonb WHERE "_id" = '1044' AND "contest_id" IS NULL AND "challenges" IS NULL;--> statement-breakpoint
UPDATE "problem" SET "challenges" = '{"Python":[{"id":"b4844e67","wording":"用 f-string 拼出这句话","rules":[{"engine":"must_exist_node","target":"f_string"}]}]}'::jsonb WHERE "_id" = '1087' AND "contest_id" IS NULL AND "challenges" IS NULL;--> statement-breakpoint
UPDATE "problem" SET "challenges" = '{"Python":[{"id":"2a157045","wording":"用循环打印出旗帜","rules":[{"engine":"must_exist_node","target":"for_loop"},{"engine":"count_function_call","target":"print","max":2}]},{"id":"a72f48d6","wording":"整面旗只用 1 次 print","rules":[{"engine":"count_function_call","target":"print","max":1}]}]}'::jsonb WHERE "_id" = '1052' AND "contest_id" IS NULL AND "challenges" IS NULL;--> statement-breakpoint
UPDATE "problem" SET "challenges" = '{"Python":[{"id":"b8f41dd4","wording":"用 f-string 保留两位小数","rules":[{"engine":"must_exist_node","target":"f_string"}]}]}'::jsonb WHERE "_id" = '4007' AND "contest_id" IS NULL AND "challenges" IS NULL;--> statement-breakpoint
UPDATE "problem" SET "challenges" = '{"Python":[{"id":"ea1be4b2","wording":"用 if-elif-else 写","rules":[{"engine":"must_exist_node","target":"elif_clause"}]},{"id":"d46a661a","wording":"每段台词只用 1 次 print","rules":[{"engine":"count_function_call","target":"print","max":3}]}]}'::jsonb WHERE "_id" = '1047' AND "contest_id" IS NULL AND "challenges" IS NULL;--> statement-breakpoint
UPDATE "problem" SET "challenges" = '{"Python":[{"id":"fba3b158","wording":"全题只用 1 次 print","rules":[{"engine":"count_function_call","target":"print","max":1}]}]}'::jsonb WHERE "_id" = '1035' AND "contest_id" IS NULL AND "challenges" IS NULL;--> statement-breakpoint
UPDATE "problem" SET "challenges" = '{"Python":[{"id":"8df071eb","wording":"用 map() 一次读两个数","rules":[{"engine":"must_call_function","target":"map"}]}]}'::jsonb WHERE "_id" = '3018' AND "contest_id" IS NULL AND "challenges" IS NULL;--> statement-breakpoint
UPDATE "problem" SET "challenges" = '{"Python":[{"id":"c01df2d3","wording":"整首歌只用 1 次 print","rules":[{"engine":"count_function_call","target":"print","max":1}]}]}'::jsonb WHERE "_id" = '1080' AND "contest_id" IS NULL AND "challenges" IS NULL;--> statement-breakpoint
UPDATE "problem" SET "challenges" = '{"Python":[{"id":"5409669d","wording":"用 f-string 保留四位小数","rules":[{"engine":"must_exist_node","target":"f_string"}]}]}'::jsonb WHERE "_id" = '1046' AND "contest_id" IS NULL AND "challenges" IS NULL;--> statement-breakpoint
UPDATE "problem" SET "challenges" = '{"Python":[{"id":"192b2fda","wording":"用 f-string 套公式","rules":[{"engine":"must_exist_node","target":"f_string"}]}]}'::jsonb WHERE "_id" = '2020' AND "contest_id" IS NULL AND "challenges" IS NULL;--> statement-breakpoint
UPDATE "problem" SET "challenges" = '{"Python":[{"id":"c91d60aa","wording":"三条规定只用 1 次 print","rules":[{"engine":"count_function_call","target":"print","max":1}]}]}'::jsonb WHERE "_id" = '1051' AND "contest_id" IS NULL AND "challenges" IS NULL;--> statement-breakpoint
UPDATE "problem" SET "challenges" = '{"Python":[{"id":"498feb32","wording":"只用 1 次 print 写完","rules":[{"engine":"count_function_call","target":"print","max":1}]}]}'::jsonb WHERE "_id" = '1083' AND "contest_id" IS NULL AND "challenges" IS NULL;--> statement-breakpoint
UPDATE "problem" SET "challenges" = '{"Python":[{"id":"b9cb3d4e","wording":"用列表查出几转","rules":[{"engine":"must_exist_node","target":"list_literal"},{"engine":"count_function_call","target":"print","max":2}]},{"id":"822bf287","wording":"不用 if,只用 1 次 print","rules":[{"engine":"count_function_call","target":"print","max":1},{"engine":"must_not_exist_node","target":"if_statement"}]}]}'::jsonb WHERE "_id" = '1058' AND "contest_id" IS NULL AND "challenges" IS NULL;--> statement-breakpoint
UPDATE "problem" SET "challenges" = '{"Python":[{"id":"56dda53b","wording":"用 while 循环写 101 次","rules":[{"engine":"must_exist_node","target":"while_loop"}]}]}'::jsonb WHERE "_id" = '1099' AND "contest_id" IS NULL AND "challenges" IS NULL;--> statement-breakpoint
UPDATE "problem" SET "challenges" = '{"Python":[{"id":"f9e79693","wording":"用 for 和 range 写完","rules":[{"engine":"must_exist_node","target":"for_loop"},{"engine":"must_not_exist_node","target":"while_loop"}]}]}'::jsonb WHERE "_id" = '2032' AND "contest_id" IS NULL AND "challenges" IS NULL;--> statement-breakpoint
UPDATE "problem" SET "challenges" = '{"Python":[{"id":"5c67590d","wording":"只用 1 次 print","rules":[{"engine":"count_function_call","target":"print","max":1}]}]}'::jsonb WHERE "_id" = '1089' AND "contest_id" IS NULL AND "challenges" IS NULL;--> statement-breakpoint
UPDATE "problem" SET "challenges" = '{"Python":[{"id":"b4003e97","wording":"用 for 循环再写一遍","rules":[{"engine":"must_exist_node","target":"for_loop"},{"engine":"must_not_exist_node","target":"while_loop"}]},{"id":"3c2fc363","wording":"用 while 循环再写一遍","rules":[{"engine":"must_exist_node","target":"while_loop"},{"engine":"must_not_exist_node","target":"for_loop"}]}]}'::jsonb WHERE "_id" = '1084' AND "contest_id" IS NULL AND "challenges" IS NULL;--> statement-breakpoint
UPDATE "problem" SET "challenges" = '{"Python":[{"id":"6dd5a3cd","wording":"用字典查出教规","rules":[{"engine":"must_exist_node","target":"dict_literal"}]},{"id":"76fc381d","wording":"只用 2 次 print 写完","rules":[{"engine":"count_function_call","target":"print","max":2}]}]}'::jsonb WHERE "_id" = '1054' AND "contest_id" IS NULL AND "challenges" IS NULL;--> statement-breakpoint
UPDATE "problem" SET "challenges" = '{"Python":[{"id":"9e82648b","wording":"用 len() 判断几位数","rules":[{"engine":"must_call_function","target":"len"}]}]}'::jsonb WHERE "_id" = '2075' AND "contest_id" IS NULL AND "challenges" IS NULL;--> statement-breakpoint
UPDATE "problem" SET "challenges" = '{"Python":[{"id":"318c2371","wording":"不用 if,再写一遍","rules":[{"engine":"must_not_exist_node","target":"if_statement"}]}]}'::jsonb WHERE "_id" = '2073' AND "contest_id" IS NULL AND "challenges" IS NULL;--> statement-breakpoint
UPDATE "problem" SET "challenges" = '{"Python":[{"id":"5cdb6fa8","wording":"不用 if,再写一遍","rules":[{"engine":"must_not_exist_node","target":"if_statement"}]}]}'::jsonb WHERE "_id" = '1022' AND "contest_id" IS NULL AND "challenges" IS NULL;--> statement-breakpoint
UPDATE "problem" SET "challenges" = '{"Python":[{"id":"35e8ebb4","wording":"不用循环,夸 n 遍","rules":[{"engine":"must_not_exist_node","target":"for_loop"},{"engine":"must_not_exist_node","target":"while_loop"}]},{"id":"73f9af3b","wording":"用 while 循环写一遍","rules":[{"engine":"must_exist_node","target":"while_loop"},{"engine":"must_not_exist_node","target":"for_loop"}]}]}'::jsonb WHERE "_id" = '1016' AND "contest_id" IS NULL AND "challenges" IS NULL;--> statement-breakpoint
UPDATE "problem" SET "challenges" = '{"Python":[{"id":"210fb5da","wording":"用字典查出歌名","rules":[{"engine":"must_exist_node","target":"dict_literal"}]}]}'::jsonb WHERE "_id" = '5015' AND "contest_id" IS NULL AND "challenges" IS NULL;--> statement-breakpoint
UPDATE "problem" SET "challenges" = '{"Python":[{"id":"561c2021","wording":"用字典存五种形态","rules":[{"engine":"must_exist_node","target":"dict_literal"}]}]}'::jsonb WHERE "_id" = '9030' AND "contest_id" IS NULL AND "challenges" IS NULL;--> statement-breakpoint
UPDATE "problem" SET "challenges" = '{"Python":[{"id":"a0e03f18","wording":"只用 1 次 print","rules":[{"engine":"count_function_call","target":"print","max":1}]}]}'::jsonb WHERE "_id" = '3031' AND "contest_id" IS NULL AND "challenges" IS NULL;--> statement-breakpoint
UPDATE "problem" SET "challenges" = '{"Python":[{"id":"62dd1388","wording":"用 f-string 保留两位小数","rules":[{"engine":"must_exist_node","target":"f_string"}]}]}'::jsonb WHERE "_id" = '1025' AND "contest_id" IS NULL AND "challenges" IS NULL;--> statement-breakpoint
UPDATE "problem" SET "challenges" = '{"Python":[{"id":"e90bae2e","wording":"用 f-string 保留两位小数","rules":[{"engine":"must_exist_node","target":"f_string"}]}]}'::jsonb WHERE "_id" = '3026' AND "contest_id" IS NULL AND "challenges" IS NULL;--> statement-breakpoint
UPDATE "problem" SET "challenges" = '{"Python":[{"id":"c8d6ef22","wording":"用 count() 数出个数","rules":[{"engine":"must_call_method","target":"count"}]}]}'::jsonb WHERE "_id" = '2038' AND "contest_id" IS NULL AND "challenges" IS NULL;--> statement-breakpoint
UPDATE "problem" SET "challenges" = '{"Python":[{"id":"7d1f0479","wording":"用 f-string 保留两位小数","rules":[{"engine":"must_exist_node","target":"f_string"}]}]}'::jsonb WHERE "_id" = '9021' AND "contest_id" IS NULL AND "challenges" IS NULL;--> statement-breakpoint
UPDATE "problem" SET "challenges" = '{"C":[{"id":"fbd744ac","wording":"用 for 循环再写一遍","rules":[{"engine":"must_exist_node","target":"for_loop"},{"engine":"must_not_exist_node","target":"while_loop"}]}]}'::jsonb WHERE "_id" = '1059' AND "contest_id" IS NULL AND "challenges" IS NULL;--> statement-breakpoint
UPDATE "problem" SET "challenges" = '{"Python":[{"id":"edd54861","wording":"不用 if,再写一遍","rules":[{"engine":"must_not_exist_node","target":"if_statement"}]}]}'::jsonb WHERE "_id" = '2071' AND "contest_id" IS NULL AND "challenges" IS NULL;--> statement-breakpoint
UPDATE "problem" SET "challenges" = '{"Python":[{"id":"5e3eb176","wording":"用 for 循环再写一遍","rules":[{"engine":"must_exist_node","target":"for_loop"}]},{"id":"60e19d6b","wording":"用 f-string 保留两位小数","rules":[{"engine":"must_exist_node","target":"f_string"}]}]}'::jsonb WHERE "_id" = '1098' AND "contest_id" IS NULL AND "challenges" IS NULL;--> statement-breakpoint
UPDATE "problem" SET "challenges" = '{"Python":[{"id":"96900c74","wording":"用 while 循环再写一遍","rules":[{"engine":"must_exist_node","target":"while_loop"},{"engine":"must_not_exist_node","target":"for_loop"}]}]}'::jsonb WHERE "_id" = '7002' AND "contest_id" IS NULL AND "challenges" IS NULL;--> statement-breakpoint
UPDATE "problem" SET "challenges" = '{"Python":[{"id":"3578ecf6","wording":"用 .replace() 一步换好","rules":[{"engine":"must_call_method","target":"replace"}]}]}'::jsonb WHERE "_id" = '3070' AND "contest_id" IS NULL AND "challenges" IS NULL;--> statement-breakpoint
UPDATE "problem" SET "challenges" = '{"Python":[{"id":"07b26542","wording":"不用 if,再写一遍","rules":[{"engine":"must_not_exist_node","target":"if_statement"}]},{"id":"e1b569e2","wording":"用字典查出答案","rules":[{"engine":"must_exist_node","target":"dict_literal"},{"engine":"must_not_exist_node","target":"if_statement"}]}]}'::jsonb WHERE "_id" = '2039' AND "contest_id" IS NULL AND "challenges" IS NULL;--> statement-breakpoint
UPDATE "problem" SET "challenges" = '{"C":[{"id":"87935a21","wording":"用 for 循环算出总和","rules":[{"engine":"must_exist_node","target":"for_loop"},{"engine":"must_not_exist_node","target":"while_loop"}]}]}'::jsonb WHERE "_id" = '1041' AND "contest_id" IS NULL AND "challenges" IS NULL;--> statement-breakpoint
UPDATE "problem" SET "challenges" = '{"Python":[{"id":"40ed6ec6","wording":"不用 if,再写一遍","rules":[{"engine":"must_not_exist_node","target":"if_statement"}]}]}'::jsonb WHERE "_id" = '2066' AND "contest_id" IS NULL AND "challenges" IS NULL;--> statement-breakpoint
UPDATE "problem" SET "challenges" = '{"Python":[{"id":"50a17686","wording":"用列表推导式筛出来","rules":[{"engine":"must_exist_node","target":"list_comprehension"}]}]}'::jsonb WHERE "_id" = '3033' AND "contest_id" IS NULL AND "challenges" IS NULL;--> statement-breakpoint
UPDATE "problem" SET "challenges" = '{"Python":[{"id":"a652c5f8","wording":"用列表推导式筛出来","rules":[{"engine":"must_exist_node","target":"list_comprehension"}]}]}'::jsonb WHERE "_id" = '3034' AND "contest_id" IS NULL AND "challenges" IS NULL;--> statement-breakpoint
UPDATE "problem" SET "challenges" = '{"Python":[{"id":"2f1e618f","wording":"用 sum() 一步求和","rules":[{"engine":"must_call_function","target":"sum"},{"engine":"must_not_exist_node","target":"for_loop"},{"engine":"must_not_exist_node","target":"while_loop"}]}]}'::jsonb WHERE "_id" = '1018' AND "contest_id" IS NULL AND "challenges" IS NULL;--> statement-breakpoint
UPDATE "problem" SET "challenges" = '{"Python":[{"id":"7f7a98be","wording":"用列表推导式转大写","rules":[{"engine":"must_exist_node","target":"list_comprehension"}]},{"id":"4c5ecedc","wording":"用 replace() 换掉逗号","rules":[{"engine":"must_call_method","target":"replace"}]}]}'::jsonb WHERE "_id" = '3075' AND "contest_id" IS NULL AND "challenges" IS NULL;--> statement-breakpoint
UPDATE "problem" SET "challenges" = '{"Python":[{"id":"d9cb6be1","wording":"用 f-string 写算式","rules":[{"engine":"must_exist_node","target":"f_string"}]},{"id":"224c2ae6","wording":"4 行只用 1 次 print","rules":[{"engine":"count_function_call","target":"print","max":1}]}]}'::jsonb WHERE "_id" = '1007' AND "contest_id" IS NULL AND "challenges" IS NULL;--> statement-breakpoint
UPDATE "problem" SET "challenges" = '{"Python":[{"id":"5b76324a","wording":"用 sum() 求总和","rules":[{"engine":"must_call_function","target":"sum"}]},{"id":"f61a47ae","wording":"用 map() 一次读三个数","rules":[{"engine":"must_call_function","target":"map"}]}]}'::jsonb WHERE "_id" = '3051' AND "contest_id" IS NULL AND "challenges" IS NULL;--> statement-breakpoint
UPDATE "problem" SET "challenges" = '{"Python":[{"id":"a5245180","wording":"不用 if,再写一遍","rules":[{"engine":"must_not_exist_node","target":"if_statement"}]},{"id":"f70990b6","wording":"用列表下标取出枪意","rules":[{"engine":"must_exist_node","target":"list_literal"},{"engine":"must_not_exist_node","target":"if_statement"}]}]}'::jsonb WHERE "_id" = '1086' AND "contest_id" IS NULL AND "challenges" IS NULL;--> statement-breakpoint
UPDATE "problem" SET "challenges" = '{"Python":[{"id":"84fa94c8","wording":"不用 if,再写一遍","rules":[{"engine":"must_not_exist_node","target":"if_statement"}]},{"id":"579e40c5","wording":"用列表推导式筛出来","rules":[{"engine":"must_exist_node","target":"list_comprehension"}]}]}'::jsonb WHERE "_id" = '3035' AND "contest_id" IS NULL AND "challenges" IS NULL;--> statement-breakpoint
UPDATE "problem" SET "challenges" = '{"Python":[{"id":"5892a636","wording":"用列表推导式数偶数","rules":[{"engine":"must_exist_node","target":"list_comprehension"}]},{"id":"b48b1336","wording":"用 sum() 数出奇偶个数","rules":[{"engine":"must_call_function","target":"sum"}]}]}'::jsonb WHERE "_id" = '3032' AND "contest_id" IS NULL AND "challenges" IS NULL;--> statement-breakpoint
UPDATE "problem" SET "challenges" = '{"Python":[{"id":"328d6a02","wording":"写一个函数算出打鱼还是晒网","rules":[{"engine":"must_exist_node","target":"function_definition"}]},{"id":"b583525a","wording":"只用 1 次 print","rules":[{"engine":"count_function_call","target":"print","max":1}]}]}'::jsonb WHERE "_id" = '1001' AND "contest_id" IS NULL AND "challenges" IS NULL;--> statement-breakpoint
UPDATE "problem" SET "challenges" = '{"Python":[{"id":"88d9a3b6","wording":"用 abs() 让负数也判对","rules":[{"engine":"must_call_function","target":"abs"}]}]}'::jsonb WHERE "_id" = '2074' AND "contest_id" IS NULL AND "challenges" IS NULL;--> statement-breakpoint
UPDATE "problem" SET "challenges" = '{"Python":[{"id":"e8394da9","wording":"不用循环,加上感叹号","rules":[{"engine":"must_not_exist_node","target":"for_loop"},{"engine":"must_not_exist_node","target":"while_loop"}]},{"id":"d105cbe5","wording":"用列表推导式加感叹号","rules":[{"engine":"must_exist_node","target":"list_comprehension"}]}]}'::jsonb WHERE "_id" = '1085' AND "contest_id" IS NULL AND "challenges" IS NULL;--> statement-breakpoint
UPDATE "problem" SET "challenges" = '{"Python":[{"id":"dd4b7b1e","wording":"用自定义函数换算分钟","rules":[{"engine":"must_exist_node","target":"function_definition"}]}]}'::jsonb WHERE "_id" = '1003' AND "contest_id" IS NULL AND "challenges" IS NULL;--> statement-breakpoint
UPDATE "problem" SET "challenges" = '{"Python":[{"id":"7b46949e","wording":"四行只用 1 次 print","rules":[{"engine":"count_function_call","target":"print","max":1}]}]}'::jsonb WHERE "_id" = '3028' AND "contest_id" IS NULL AND "challenges" IS NULL;--> statement-breakpoint
UPDATE "problem" SET "challenges" = '{"Python":[{"id":"e5b04d3b","wording":"用 map() 读入两个数","rules":[{"engine":"must_call_function","target":"map"}]}]}'::jsonb WHERE "_id" = '2044' AND "contest_id" IS NULL AND "challenges" IS NULL;--> statement-breakpoint
UPDATE "problem" SET "challenges" = '{"Python":[{"id":"0b108eaa","wording":"用 for 循环加各位","rules":[{"engine":"must_exist_node","target":"for_loop"},{"engine":"count_function_call","target":"int","max":1}]}]}'::jsonb WHERE "_id" = '3061' AND "contest_id" IS NULL AND "challenges" IS NULL;--> statement-breakpoint
UPDATE "problem" SET "challenges" = '{"Python":[{"id":"0774ee1c","wording":"用 sum() 算总分","rules":[{"engine":"must_call_function","target":"sum"}]},{"id":"150a618a","wording":"用 map() 一次读四门","rules":[{"engine":"must_call_function","target":"map"}]}]}'::jsonb WHERE "_id" = '3055' AND "contest_id" IS NULL AND "challenges" IS NULL;--> statement-breakpoint
UPDATE "problem" SET "challenges" = '{"Python":[{"id":"8d814fb4","wording":"用 while 循环再写一遍","rules":[{"engine":"must_exist_node","target":"while_loop"},{"engine":"must_not_exist_node","target":"for_loop"}]}]}'::jsonb WHERE "_id" = '2078' AND "contest_id" IS NULL AND "challenges" IS NULL;--> statement-breakpoint
UPDATE "problem" SET "challenges" = '{"Python":[{"id":"29baa412","wording":"不用 if,再写一遍","rules":[{"engine":"must_not_exist_node","target":"if_statement"}]}]}'::jsonb WHERE "_id" = '1045' AND "contest_id" IS NULL AND "challenges" IS NULL;
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+14
View File
@@ -267,6 +267,20 @@
"when": 1792494707274, "when": 1792494707274,
"tag": "0037_add_user_token_version", "tag": "0037_add_user_token_version",
"breakpoints": true "breakpoints": true
},
{
"idx": 38,
"version": "7",
"when": 1792930010361,
"tag": "0038_add_challenges",
"breakpoints": true
},
{
"idx": 39,
"version": "7",
"when": 1792930018504,
"tag": "0039_seed_challenges",
"breakpoints": true
} }
] ]
} }
+59
View File
@@ -45,6 +45,7 @@ import type {
HintDiagnosis, HintDiagnosis,
JudgeStatus, JudgeStatus,
LessonLanguage, LessonLanguage,
ProblemChallenges,
ProblemDifficulty, ProblemDifficulty,
ProblemLanguage, ProblemLanguage,
ProblemPermission, ProblemPermission,
@@ -570,6 +571,8 @@ export const problem = pgTable(
mermaidCode: text("mermaid_code"), mermaidCode: text("mermaid_code"),
showFlowchart: boolean("show_flowchart").default(false).notNull(), showFlowchart: boolean("show_flowchart").default(false).notNull(),
astRules: jsonb("ast_rules").$type<AstRules>(), astRules: jsonb("ast_rules").$type<AstRules>(),
/** 做对之后的挑战,按语言分组。写入侧在 admin/problem.ts 校验,见契约 challengeSchema */
challenges: jsonb().$type<ProblemChallenges>(),
sqlConfig: jsonb("sql_config").$type<SqlConfig>(), sqlConfig: jsonb("sql_config").$type<SqlConfig>(),
sqlDisplay: jsonb("sql_display").$type<SqlDisplay>(), sqlDisplay: jsonb("sql_display").$type<SqlDisplay>(),
}, },
@@ -1467,3 +1470,59 @@ export const classLesson = pgTable(
}).onDelete("cascade"), }).onDelete("cascade"),
], ],
) )
/**
* 谁做到了哪条挑战(problem.challenges 里的 id)。一人一条挑战一行,记第一次做到的那份提交。
*
* 两条路写进来:判题时做对的提交顺手判一遍(judge/run.ts),以及新加一条挑战时拿历史上
* 做对的代码补一遍(services/challenge.ts)—— 后者的 created_at 也是那份提交的时刻,
* 所以「本班第几个、全站第几个做到」按它排,补的和新做的一个口径。
*
* 外键:提交、题目没了,从它算出来的这一行也就没意义了,CASCADE;
* user 跟着 submission 走(submission.user_id 是 NO ACTION,删不掉有提交的用户)。
*/
export const challengeSuccess = pgTable(
"challenge_success",
{
id: integer().primaryKey().generatedByDefaultAsIdentity({
name: "challenge_success_id_seq",
startWith: 1,
increment: 1,
minValue: 1,
maxValue: 2147483647,
cache: 1,
}),
userId: integer("user_id").notNull(),
problemId: integer("problem_id").notNull(),
challengeId: text("challenge_id").notNull(),
submissionId: text("submission_id").notNull(),
/** 做到的那份提交的时刻(不是写这一行的时刻) */
createdAt: timestamp("created_at", {
withTimezone: true,
mode: "string",
}).notNull(),
},
(table) => [
unique("challenge_success_user_challenge_unique").on(
table.userId,
table.problemId,
table.challengeId,
),
index("challenge_success_problem_idx").on(table.problemId, table.challengeId, table.createdAt),
foreignKey({
columns: [table.userId],
foreignColumns: [user.id],
name: "challenge_success_user_id_fk_user_id",
}),
foreignKey({
columns: [table.problemId],
foreignColumns: [problem.id],
name: "challenge_success_problem_id_fk_problem_id",
}).onDelete("cascade"),
foreignKey({
columns: [table.submissionId],
foreignColumns: [submission.id],
name: "challenge_success_submission_id_fk_submission_id",
}).onDelete("cascade"),
],
)
+4
View File
@@ -22,6 +22,7 @@ import { problemStatsRoutes } from "./routes/problem-stats"
import { submissionRoutes } from "./routes/submission" import { submissionRoutes } from "./routes/submission"
import { trialRunRoutes } from "./routes/trial-run" import { trialRunRoutes } from "./routes/trial-run"
import { siteRoutes } from "./routes/site" import { siteRoutes } from "./routes/site"
import { scanPendingChallenges } from "./services/challenge"
import { import {
bridgeSubmissionEvents, bridgeSubmissionEvents,
isAllowedWebSocketOrigin, isAllowedWebSocketOrigin,
@@ -165,3 +166,6 @@ const server = Bun.serve<SubmissionSocketData>({
await bridgeSubmissionEvents(server) await bridgeSubmissionEvents(server)
startSessionSweep() startSessionSweep()
console.log(`OJ2 API listening on http://localhost:${server.port}`) console.log(`OJ2 API listening on http://localhost:${server.port}`)
// 迁移刚灌进来、或者上次没补完的挑战:拿历史上做对的代码补一遍谁已经做到了。
// 晚一点再跑,别和刚起来时涌进来的请求抢那两个核
setTimeout(() => void scanPendingChallenges(), 20_000)
+89 -27
View File
@@ -30,6 +30,18 @@ export interface AstResult {
passed: boolean passed: boolean
/** count_* 引擎实际数到的次数。失败时前端拿它补一句「当前 N 次」 */ /** count_* 引擎实际数到的次数。失败时前端拿它补一句「当前 N 次」 */
actual?: number actual?: number
/** 没过的「不能用 X」「X 太多」:X 在第几行(1 起),挑战没做到时编辑器里标出来 */
lines?: number[]
}
/** 节点所在的行,去重排序。submission.code 只是学生写的那段,不含题目模板,行号不用再扣 */
function linesOf(nodes: Node[]) {
return [...new Set(nodes.map((node) => node.startPosition.row + 1))].sort((a, b) => a - b)
}
/** 没过才带行号:过了的规则不用标 */
function withLines(result: AstResult, nodes: Node[]): AstResult {
return result.passed || nodes.length === 0 ? result : { ...result, lines: linesOf(nodes) }
} }
let initPromise: Promise<void> | undefined let initPromise: Promise<void> | undefined
@@ -266,47 +278,60 @@ function evaluateRule(root: Node, rule: AstRule, language: string): AstResult |
description: describeAstRule(rule, language), description: describeAstRule(rule, language),
passed: hasNode(root, nodeType), passed: hasNode(root, nodeType),
} }
case "must_not_exist_node": case "must_not_exist_node": {
return { const nodes = collectNodes(root, nodeType)
description: describeAstRule(rule, language), return withLines(
passed: !hasNode(root, nodeType), { description: describeAstRule(rule, language), passed: nodes.length === 0 },
} nodes,
)
}
case "count_node": { case "count_node": {
const count = collectNodes(root, nodeType).length const nodes = collectNodes(root, nodeType)
return { return withLines(
description: describeAstRule(rule, language), {
passed: rangePassed(count, rule), description: describeAstRule(rule, language),
actual: count, passed: rangePassed(nodes.length, rule),
} actual: nodes.length,
},
// 次数太少时没有「哪几行」可标
rule.max !== undefined || rule.exact !== undefined ? nodes : [],
)
} }
case "must_call_function": case "must_call_function":
return { return {
description: describeAstRule(rule, language), description: describeAstRule(rule, language),
passed: functionCalls(root, target, language).length > 0, passed: functionCalls(root, target, language).length > 0,
} }
case "must_not_call_function": case "must_not_call_function": {
return { const calls = functionCalls(root, target, language)
description: describeAstRule(rule, language), return withLines(
passed: functionCalls(root, target, language).length === 0, { description: describeAstRule(rule, language), passed: calls.length === 0 },
} calls,
)
}
case "count_function_call": { case "count_function_call": {
const count = functionCalls(root, target, language).length const calls = functionCalls(root, target, language)
return { return withLines(
description: describeAstRule(rule, language), {
passed: rangePassed(count, rule), description: describeAstRule(rule, language),
actual: count, passed: rangePassed(calls.length, rule),
} actual: calls.length,
},
rule.max !== undefined || rule.exact !== undefined ? calls : [],
)
} }
case "must_call_method": case "must_call_method":
return { return {
description: describeAstRule(rule, language), description: describeAstRule(rule, language),
passed: methodCalls(root, target, language).length > 0, passed: methodCalls(root, target, language).length > 0,
} }
case "must_not_call_method": case "must_not_call_method": {
return { const calls = methodCalls(root, target, language)
description: describeAstRule(rule, language), return withLines(
passed: methodCalls(root, target, language).length === 0, { description: describeAstRule(rule, language), passed: calls.length === 0 },
} calls,
)
}
case "must_use_operator": case "must_use_operator":
return { return {
description: describeAstRule(rule, language), description: describeAstRule(rule, language),
@@ -357,3 +382,40 @@ export async function checkAst(
parser.delete() parser.delete()
} }
} }
/**
* 一份代码对着几组规则各判一遍,只解析一次。挑战用:一道题几条挑战各是一组,补历史记录时
* 一份代码要判全部挑战,每组 checkAst 一次就是把同一份代码解析好几遍。
*
* 和 checkAst 不同,**判不了时返回 null 而不是「通过」**:checkAst 是题目的硬要求,
* 判不了就放行、别冤枉学生;挑战判不了就是没做到,放行等于白送。
*/
export async function checkAstGroups(
code: string,
language: string,
groups: AstRule[][],
): Promise<{ passed: boolean; results: AstResult[] }[] | null> {
const treeSitterLanguage = await loadLanguage(language)
if (!treeSitterLanguage) return null
const parser = new Parser()
try {
parser.setLanguage(treeSitterLanguage)
const tree = parser.parse(code)
if (!tree) return null
try {
return groups.map((rules) => {
const results = rules
.filter(astRuleIsMeaningful)
.map((rule) => evaluateRule(tree.rootNode, rule, language))
.filter((result): result is AstResult => result !== null)
return { passed: results.length > 0 && results.every((r) => r.passed), results }
})
} finally {
tree.delete()
}
} catch {
return null
} finally {
parser.delete()
}
}
+35
View File
@@ -7,6 +7,12 @@ import { config } from "../config"
import { db, schema } from "../db" import { db, schema } from "../db"
import { publishAchievementNotification } from "../events" import { publishAchievementNotification } from "../events"
import { updateAchievementsForSubmission } from "../services/achievements" import { updateAchievementsForSubmission } from "../services/achievements"
import {
challengesFor,
judgeChallenges,
recordChallengeSuccesses,
type ChallengeVerdict,
} from "../services/challenge"
import { recordSolvedAndNotify } from "../services/problemset" import { recordSolvedAndNotify } from "../services/problemset"
import { checkAst, type AstRule } from "./ast" import { checkAst, type AstRule } from "./ast"
import { publishSubmissionUpdate } from "./events" import { publishSubmissionUpdate } from "./events"
@@ -696,6 +702,19 @@ export async function judgeSubmission(job: JudgeJobData) {
} }
} }
} }
// 挑战:做对了就拿这道题的每条挑战各判一遍(比赛不判,同上)。结果全存下来,
// 前端在挑战中时按 id 取它正在挑战的那条;做到的下面落库
if (result === JudgeStatus.ACCEPTED && row.submission.contestId === null) {
const challenges = challengesFor(row.problem.challenges, row.submission.language)
if (challenges.length > 0) {
statisticInfo.challenges = await judgeChallenges(
row.submission.code,
row.submission.language,
challenges,
)
}
}
} }
const counted = countedBefore(job.rejudgedFrom) const counted = countedBefore(job.rejudgedFrom)
@@ -722,6 +741,22 @@ export async function judgeSubmission(job: JudgeJobData) {
await recordSolvedAndNotify(row.submission.userId, row.problem.id, row.submission.createTime) await recordSolvedAndNotify(row.submission.userId, row.problem.id, row.submission.createTime)
} }
if (Array.isArray(statisticInfo.challenges)) {
await recordChallengeSuccesses(
(statisticInfo.challenges as ChallengeVerdict[])
.filter((verdict) => verdict.passed)
.map((verdict) => ({
userId: row.submission.userId,
problemId: row.problem.id,
challengeId: verdict.id,
submissionId: row.submission.id,
createdAt: row.submission.createTime,
})),
).catch((error: unknown) =>
console.error(`[judge] 挑战记录失败 ${row.submission.id}:`, error),
)
}
try { try {
const unlocked = await updateAchievementsForSubmission(row.submission.id, counted) const unlocked = await updateAchievementsForSubmission(row.submission.id, counted)
await publishAchievementNotification( await publishAchievementNotification(
+61 -5
View File
@@ -4,6 +4,7 @@ import {
addContestProblemRequestSchema, addContestProblemRequestSchema,
reorderContestProblemsRequestSchema, reorderContestProblemsRequestSchema,
astCheckRequestSchema, astCheckRequestSchema,
challengeSuggestionsRequestSchema,
generateTestInputsRequestSchema, generateTestInputsRequestSchema,
isSqlProblem, isSqlProblem,
TEST_CASE_EDIT_MAX_CASES, TEST_CASE_EDIT_MAX_CASES,
@@ -18,8 +19,10 @@ import {
type AdminProblemListItem, type AdminProblemListItem,
type AstCheckResponse, type AstCheckResponse,
type AstRules, type AstRules,
type ChallengeSuggestionsResponse,
type GenerateTestInputsResponse, type GenerateTestInputsResponse,
type GenerateSqlTestCaseResponse, type GenerateSqlTestCaseResponse,
type ProblemChallenges,
type SqlConfig, type SqlConfig,
type SqlDisplay, type SqlDisplay,
type SqlTestCaseScript, type SqlTestCaseScript,
@@ -35,6 +38,12 @@ import { failure, parseBody, success } from "../../http"
import { astRulesError, checkAst, describeAstRule, pickAstRules } from "../../judge/ast" import { astRulesError, checkAst, describeAstRule, pickAstRules } from "../../judge/ast"
import { buildSqlDisplay } from "../../judge/sql" import { buildSqlDisplay } from "../../judge/sql"
import { completeChat } from "../../services/ai" import { completeChat } from "../../services/ai"
import {
challengesFor,
challengeSuggestions,
normalizeChallenges,
scanPendingChallenges,
} from "../../services/challenge"
import { plainText } from "../../services/hint-diagnosis" import { plainText } from "../../services/hint-diagnosis"
import { contestStatus } from "../../services/contest" import { contestStatus } from "../../services/contest"
import { import {
@@ -152,6 +161,7 @@ async function serialize(row: ProblemRow) {
flowchartData: asRecord(row.flowchartData), flowchartData: asRecord(row.flowchartData),
flowchartHint: row.flowchartHint, flowchartHint: row.flowchartHint,
astRules: row.astRules, astRules: row.astRules,
challenges: row.challenges,
answers: Array.isArray(row.answers) ? row.answers : [], answers: Array.isArray(row.answers) ? row.answers : [],
prompt: row.prompt, prompt: row.prompt,
sqlConfig: row.sqlConfig, sqlConfig: row.sqlConfig,
@@ -271,19 +281,34 @@ type ProblemInput = ReturnType<typeof createProblemRequestSchema.parse>
*/ */
async function validateProblem( async function validateProblem(
data: ProblemInput, data: ProblemInput,
): Promise<{ error: string } | { sql: boolean; sqlDisplay: SqlDisplay | null }> { previousChallenges: ProblemChallenges | null = null,
): Promise<{ error: string } | Validated> {
const checked = commonChecks(data) const checked = commonChecks(data)
if ("error" in checked) return checked if ("error" in checked) return checked
if (!checked.sql) return { sql: false, sqlDisplay: null } // 不带挑战(旧版出题页)就原样留着,见契约 createProblemRequestSchema.challenges
const normalized =
data.challenges === undefined
? { challenges: previousChallenges }
: normalizeChallenges(data.challenges, previousChallenges, data.languages)
if ("error" in normalized) return normalized
const { challenges } = normalized
if (!checked.sql) return { sql: false, sqlDisplay: null, challenges }
const built = await generateSqlDisplay(data.testCaseId, data.answers, data.sqlConfig!) const built = await generateSqlDisplay(data.testCaseId, data.answers, data.sqlConfig!)
if ("error" in built) return built if ("error" in built) return built
return { sql: true, sqlDisplay: built.display } return { sql: true, sqlDisplay: built.display, challenges }
}
interface Validated {
sql: boolean
sqlDisplay: SqlDisplay | null
/** 规则没变的沿用原来的 id,见 services/challenge.ts 的 normalizeChallenges */
challenges: ProblemChallenges | null
} }
/** 新建一道题(公开题 contestId 为 null)连同标签,一个事务 */ /** 新建一道题(公开题 contestId 为 null)连同标签,一个事务 */
async function insertProblem( async function insertProblem(
data: ProblemInput, data: ProblemInput,
validated: { sql: boolean; sqlDisplay: SqlDisplay | null }, validated: Validated,
owner: { contestId: number | null; createdById: number }, owner: { contestId: number | null; createdById: number },
) { ) {
const now = new Date().toISOString() const now = new Date().toISOString()
@@ -292,6 +317,7 @@ async function insertProblem(
.insert(schema.problem) .insert(schema.problem)
.values({ .values({
...problemValues(data, validated.sql), ...problemValues(data, validated.sql),
challenges: validated.challenges,
...owner, ...owner,
createTime: now, createTime: now,
lastUpdateTime: now, lastUpdateTime: now,
@@ -489,6 +515,7 @@ adminProblemRoutes.post("/problems", requireProblemPermission, async (c) => {
contestId: null, contestId: null,
createdById: c.get("user")!.id, createdById: c.get("user")!.id,
}) })
void scanPendingChallenges()
return success(c, await serialize(created), 201) return success(c, await serialize(created), 201)
}) })
@@ -505,7 +532,7 @@ adminProblemRoutes.put("/problems/:id", requireProblemPermission, async (c) => {
if (!(await canEditProblem(c.get("user")!, existing))) { if (!(await canEditProblem(c.get("user")!, existing))) {
return failure(c, 404, "problem-not-found", "Problem does not exist") return failure(c, 404, "problem-not-found", "Problem does not exist")
} }
const validated = await validateProblem(parsed.data) const validated = await validateProblem(parsed.data, existing.challenges)
if ("error" in validated) return failure(c, 400, "invalid-problem", validated.error) if ("error" in validated) return failure(c, 400, "invalid-problem", validated.error)
// 题号唯一性的作用域跟着题目走:公开题在全部公开题里唯一,比赛题在本场比赛内唯一 // 题号唯一性的作用域跟着题目走:公开题在全部公开题里唯一,比赛题在本场比赛内唯一
@@ -529,6 +556,7 @@ adminProblemRoutes.put("/problems/:id", requireProblemPermission, async (c) => {
.update(schema.problem) .update(schema.problem)
.set({ .set({
...problemValues(parsed.data, validated.sql), ...problemValues(parsed.data, validated.sql),
challenges: validated.challenges,
sqlDisplay: validated.sqlDisplay, sqlDisplay: validated.sqlDisplay,
lastUpdateTime: new Date().toISOString(), lastUpdateTime: new Date().toISOString(),
}) })
@@ -537,6 +565,8 @@ adminProblemRoutes.put("/problems/:id", requireProblemPermission, async (c) => {
await setTags(tx, id, parsed.data.tags) await setTags(tx, id, parsed.data.tags)
return row! return row!
}) })
// 新加、改过的挑战拿历史上做对的代码补一遍谁已经做到了,不等它
void scanPendingChallenges()
return success(c, await serialize(updated)) return success(c, await serialize(updated))
}) })
@@ -962,6 +992,32 @@ adminProblemRoutes.post("/ast-check", requireProblemPermission, async (c) => {
return success(c, { rules: checked } satisfies AstCheckResponse) return success(c, { rules: checked } satisfies AstCheckResponse)
}) })
/** 出题页「挑战」的候选:拿这道题历史上做对的代码挖,见 services/challenge.ts */
adminProblemRoutes.post(
"/problems/:id/challenge-suggestions",
requireProblemPermission,
async (c) => {
const parsed = await parseBody(c, challengeSuggestionsRequestSchema)
if (!parsed.success) return parsed.response
const [problem] = await db
.select()
.from(schema.problem)
.where(eq(schema.problem.id, queryInteger(c.req.param("id"), 0, { min: 1 })))
.limit(1)
if (!problem) return failure(c, 404, "problem-not-found", "Problem does not exist")
if (!(await canEditProblem(c.get("user")!, problem))) {
return failure(c, 404, "problem-not-found", "Problem does not exist")
}
const { language } = parsed.data
const result = await challengeSuggestions(
problem.id,
language,
challengesFor(problem.challenges, language),
)
return success(c, result satisfies ChallengeSuggestionsResponse)
},
)
/** /**
* AI 按题面和标准答案想几组测试输入。只要输入:输出一律由标准答案在判题机上跑, * AI 按题面和标准答案想几组测试输入。只要输入:输出一律由标准答案在判题机上跑,
* AI 编的输出不可信。 * AI 编的输出不可信。
+31
View File
@@ -20,6 +20,7 @@ import { seenToday } from "../auth/presence"
import { getLoginWindow } from "../auth/session" import { getLoginWindow } from "../auth/session"
import { presenceOf } from "../collab/state" import { presenceOf } from "../collab/state"
import { db, schema } from "../db" import { db, schema } from "../db"
import { challengeSuccessesOf } from "../services/challenge"
import { failure, parseBody, success } from "../http" import { failure, parseBody, success } from "../http"
import { isAccepted } from "../judge/status" import { isAccepted } from "../judge/status"
import { import {
@@ -465,6 +466,27 @@ classroomRoutes.get("/classroom/board", requireTeacher, async (c) => {
// 「来了几次」代码和流程图合起来数:那天只画了流程图的也算来过,和 recentDays 同一个口径 // 「来了几次」代码和流程图合起来数:那天只画了流程图的也算来过,和 recentDays 同一个口径
const recentActivity = activityRows({ since: windowStart, users: userIds }) const recentActivity = activityRows({ since: windowStart, users: userIds })
const localDay = sql<string>`to_char(${localTime(recentActivity.createTime)}, 'YYYY-MM-DD')` const localDay = sql<string>`to_char(${localTime(recentActivity.createTime)}, 'YYYY-MM-DD')`
const [challengeConfigs, challengeDone] = await Promise.all([
ids.length
? db
.select({ id: schema.problem.id, challenges: schema.problem.challenges })
.from(schema.problem)
.where(inArray(schema.problem.id, ids))
: [],
challengeSuccessesOf(userIds, ids),
])
// 只认现在还配着的挑战:规则改过的旧 id 不算
const liveChallenges = new Map(
challengeConfigs.map((row) => [
row.id,
new Set(Object.values(row.challenges ?? {}).flatMap((list) => list.map((c) => c.id))),
]),
)
const challengeDoneKeys = new Set(
challengeDone
.filter((row) => liveChallenges.get(row.problemId)?.has(row.challengeId))
.map((row) => `${row.userId}:${row.problemId}`),
)
const [cells, lastSubmits, lastDrawn, attendance, drawn, todays, seen] = await Promise.all([ const [cells, lastSubmits, lastDrawn, attendance, drawn, todays, seen] = await Promise.all([
ids.length && userIds.length ids.length && userIds.length
? db ? db
@@ -619,6 +641,7 @@ classroomRoutes.get("/classroom/board", requireTeacher, async (c) => {
problemDisplayId: problem.displayId, problemDisplayId: problem.displayId,
title: problem.title, title: problem.title,
isSql: problem.isSql, isSql: problem.isSql,
hasChallenges: (liveChallenges.get(problem.id)?.size ?? 0) > 0,
})), })),
students: roster.map( students: roster.map(
(student) => (student) =>
@@ -659,6 +682,14 @@ classroomRoutes.get("/classroom/board", requireTeacher, async (c) => {
})) }))
: [], : [],
acceptedTry: firstTry >= 0 ? firstTry + 1 : null, acceptedTry: firstTry >= 0 ? firstTry + 1 : null,
challenge:
!acceptedAt || !liveChallenges.get(id)?.size
? null
: challengeDoneKeys.has(key)
? "done"
: today.some((row) => Date.parse(row.createTime) > Date.parse(acceptedAt))
? "doing"
: null,
} }
}), }),
lastSubmitAt: lastByUser.get(student.userId) ?? null, lastSubmitAt: lastByUser.get(student.userId) ?? null,
+19 -1
View File
@@ -1,8 +1,10 @@
import { import {
AST_SUPPORTED_LANGUAGES,
isSqlProblem, isSqlProblem,
normalizeLanguage, normalizeLanguage,
problemListSortSchema, problemListSortSchema,
problemTypeFilterSchema, problemTypeFilterSchema,
type ChallengeList,
type ProblemAuthor, type ProblemAuthor,
type ProblemDetail, type ProblemDetail,
type ProblemList, type ProblemList,
@@ -28,9 +30,10 @@ import {
} from "drizzle-orm" } from "drizzle-orm"
import { Hono } from "hono" import { Hono } from "hono"
import { optionalAuth, type AppEnv } from "../auth/middleware" import { optionalAuth, requireAuth, type AppEnv } from "../auth/middleware"
import { db, schema } from "../db" import { db, schema } from "../db"
import { astRequirements } from "../judge/ast" import { astRequirements } from "../judge/ast"
import { challengeList } from "../services/challenge"
import { failure, success } from "../http" import { failure, success } from "../http"
import { JudgeStatus } from "../judge/status" import { JudgeStatus } from "../judge/status"
import { import {
@@ -340,6 +343,21 @@ problemRoutes.get("/problem-authors", async (c) => {
return success(c, rows satisfies ProblemAuthor[]) return success(c, rows satisfies ProblemAuthor[])
}) })
/**
* 几道题的挑战:`?problems=1080,2020,4077&language=Python`。课堂条「这节课都做完了」一次要
* 一节课的几道,题目页只要当前这道。挑战按语言分,语言是学生编辑器里选的那种
*/
problemRoutes.get("/challenges", requireAuth, async (c) => {
const language = c.req.query("language") ?? ""
if (!AST_SUPPORTED_LANGUAGES.includes(language)) return success(c, [] satisfies ChallengeList)
const ids = (c.req.query("problems") ?? "")
.split(",")
.map((id) => id.trim())
.filter(Boolean)
.slice(0, 20)
return success(c, (await challengeList(c.get("user")!, ids, language)) satisfies ChallengeList)
})
problemRoutes.get("/problems/:displayId/similar", optionalAuth, async (c) => { problemRoutes.get("/problems/:displayId/similar", optionalAuth, async (c) => {
const [target] = await db const [target] = await db
.select({ id: schema.problem.id }) .select({ id: schema.problem.id })
+560
View File
@@ -0,0 +1,560 @@
import { randomBytes } from "node:crypto"
import {
AST_SUPPORTED_LANGUAGES,
challengeSchema,
type AstRule,
type Challenge,
type ChallengeInput,
type ChallengeList,
type ChallengeSuggestion,
type ProblemChallenges,
type ProblemLanguage,
} from "@oj2/contract"
import { and, asc, eq, inArray, isNull, sql } from "drizzle-orm"
import { db, schema } from "../db"
import { astRulesError, checkAstGroups, type AstResult } from "../judge/ast"
import { JudgeStatus } from "../judge/status"
/**
* 挑战:做对之后,同一道题加一条写法上的限制再做一遍。规则存在 problem.challenges,
* 谁做到了记在 challenge_success。形状和口径见契约 challengeSchema。
*
* 这里管四件事:判(judge/run.ts 在做对的提交上调)、补(新加的挑战拿历史上做对的
* 代码补一遍谁已经做到了)、给学生的列表、给出题页的候选。
*/
/** problem.challenges 里某种语言的挑战。逐条过 schema,一条坏的不连累别的(同 run.ts 的 astRulesForLanguage) */
export function challengesFor(value: unknown, language: string): Challenge[] {
if (!value || typeof value !== "object" || Array.isArray(value)) return []
const list = (value as Record<string, unknown>)[language]
if (!Array.isArray(list)) return []
return list.flatMap((item) => {
const parsed = challengeSchema.safeParse(item)
return parsed.success && parsed.data.rules.length > 0 ? [parsed.data] : []
})
}
export interface ChallengeVerdict {
id: string
passed: boolean
results: AstResult[]
}
/** 一份代码对着几条挑战各判一遍(只解析一次)。判不了就当一条都没做到 */
export async function judgeChallenges(
code: string,
language: string,
challenges: Challenge[],
): Promise<ChallengeVerdict[]> {
if (challenges.length === 0) return []
const groups = await checkAstGroups(
code,
language,
challenges.map((challenge) => challenge.rules),
)
if (!groups) return []
return challenges.map((challenge, i) => ({
id: challenge.id,
passed: groups[i]!.passed,
results: groups[i]!.results,
}))
}
interface SuccessRow {
userId: number
problemId: number
challengeId: string
submissionId: string
createdAt: string
}
/** 记下做到了。已经做到过的不动 —— 留的是第一次做到的那份提交 */
export async function recordChallengeSuccesses(rows: SuccessRow[]) {
if (rows.length === 0) return
await db.insert(schema.challengeSuccess).values(rows).onConflictDoNothing()
}
// ---------------------------------------------------------------- 补历史记录
let scanning: Promise<void> | null = null
/**
* 新加的挑战(没有 scannedAt 的)拿历史上做对的代码补一遍:一批人早就这么写过,
* 不补的话「全站 N 人做到」从 0 开始,那些人打开挑战还得再交一次一模一样的代码。
*
* api 起来之后跑一次(迁移灌进来的那批在这里补),出题页保存之后再跑一次。
* 两个站点共库、各起一个 api,可能同时补同一批 —— 插入是 on conflict do nothing,
* 重复补只是白算一遍。
*
* 服务器只有 2 核:一道题几百份代码,每 50 份让出一次事件循环,别把请求卡住。
*/
export function scanPendingChallenges(): Promise<void> {
if (!scanning) {
scanning = scanAll()
.catch((error: unknown) => console.error("[challenge] 补做到记录失败", error))
.finally(() => {
scanning = null
})
}
return scanning
}
async function scanAll() {
const problems = await db
.select({ id: schema.problem.id, challenges: schema.problem.challenges })
.from(schema.problem)
.where(sql`${schema.problem.challenges} is not null`)
for (const problem of problems) {
for (const language of Object.keys(problem.challenges ?? {})) {
const pending = challengesFor(problem.challenges, language).filter((c) => !c.scannedAt)
if (pending.length === 0) continue
const started = Date.now()
const found = await scanProblem(problem.id, language, pending)
await markScanned(
problem.id,
language,
pending.map((c) => c.id),
)
console.log(
`[challenge] 题 ${problem.id} ${language} 补了 ${pending.length} 条挑战、${found} 条做到,用时 ${Date.now() - started}ms`,
)
}
}
}
async function scanProblem(problemId: number, language: string, pending: Challenge[]) {
const subs = await db
.select({
id: schema.submission.id,
userId: schema.submission.userId,
code: schema.submission.code,
createTime: schema.submission.createTime,
})
.from(schema.submission)
// 库里有一批孤儿提交(用户删了、提交还在),记不进 challenge_success 的外键
.innerJoin(schema.user, eq(schema.user.id, schema.submission.userId))
.where(
and(
eq(schema.submission.problemId, problemId),
eq(schema.submission.result, JudgeStatus.ACCEPTED),
eq(schema.submission.language, language as ProblemLanguage),
isNull(schema.submission.contestId),
),
)
// 按时间顺着判:同一个人第一次做到的那份先遇到
.orderBy(asc(schema.submission.createTime))
const seen = pending.map(() => new Set<number>())
let rows: SuccessRow[] = []
let found = 0
for (const [index, sub] of subs.entries()) {
if (pending.every((_, i) => seen[i]!.has(sub.userId))) continue
const verdicts = await judgeChallenges(sub.code, language, pending)
for (const [i, verdict] of verdicts.entries()) {
if (!verdict.passed || seen[i]!.has(sub.userId)) continue
seen[i]!.add(sub.userId)
rows.push({
userId: sub.userId,
problemId,
challengeId: verdict.id,
submissionId: sub.id,
createdAt: sub.createTime,
})
}
if (rows.length >= 500) {
found += rows.length
await recordChallengeSuccesses(rows)
rows = []
}
if (index % 50 === 49) await Bun.sleep(0)
}
found += rows.length
await recordChallengeSuccesses(rows)
return found
}
/** 补完记一笔。重新读一遍再写:补的这会儿老师可能刚存过,只给还在、id 没变的那几条打标记 */
async function markScanned(problemId: number, language: string, ids: string[]) {
const [row] = await db
.select({ challenges: schema.problem.challenges })
.from(schema.problem)
.where(eq(schema.problem.id, problemId))
.limit(1)
const current = row?.challenges
if (!current?.[language]) return
const now = new Date().toISOString()
const next: ProblemChallenges = {
...current,
[language]: current[language]!.map((c) => (ids.includes(c.id) ? { ...c, scannedAt: now } : c)),
}
await db.update(schema.problem).set({ challenges: next }).where(eq(schema.problem.id, problemId))
}
// ---------------------------------------------------------------- 出题页保存
function sameRules(a: AstRule[], b: AstRule[]) {
return JSON.stringify(a) === JSON.stringify(b)
}
/**
* 出题页提交的挑战 → 落库的形状。只留题目收的、判题机判得了的语言(同 pickAstRules);
* 规则没变的沿用原来的 id 和 scannedAt,改过的、新加的发新 id —— 做到记录跟着 id 走,
* 规则一变旧记录就不作数了。
*/
export function normalizeChallenges(
input: Record<string, ChallengeInput[]> | null,
previous: ProblemChallenges | null,
languages: string[],
): { error: string } | { challenges: ProblemChallenges | null } {
if (!input) return { challenges: null }
const out: ProblemChallenges = {}
for (const [language, list] of Object.entries(input)) {
if (!languages.includes(language) || !AST_SUPPORTED_LANGUAGES.includes(language)) continue
const before = previous?.[language] ?? []
const items: Challenge[] = []
for (const [index, item] of list.entries()) {
const error = astRulesError({ [language]: item.rules })
if (error)
return {
error: `挑战 ${language} 第 ${index + 1} 条:${error.replace(/^代码规则 \S+ /, "")}`,
}
const kept = before.find((c) => c.id === item.id && sameRules(c.rules, item.rules))
items.push({
id: kept?.id ?? randomBytes(4).toString("hex"),
wording: item.wording,
rules: item.rules,
...(kept?.scannedAt ? { scannedAt: kept.scannedAt } : {}),
})
}
if (items.length > 0) out[language] = items
}
return { challenges: Object.keys(out).length > 0 ? out : null }
}
// ---------------------------------------------------------------- 给学生的列表
/**
* 几道题、某种语言下的挑战:说法、全站几人做到、我做到没有(做到了是本班、全站第几个)。
* 规则原文不下发,同 astRequirements。题号按传进来的顺序返回,没配挑战的题不返回。
*/
export async function challengeList(
user: { id: number; className: string | null },
displayIds: string[],
language: string,
): Promise<ChallengeList> {
if (displayIds.length === 0) return []
const problems = await db
.select({
id: schema.problem.id,
displayId: schema.problem.displayId,
title: schema.problem.title,
challenges: schema.problem.challenges,
})
.from(schema.problem)
.where(
and(
inArray(schema.problem.displayId, displayIds),
isNull(schema.problem.contestId),
sql`${schema.problem.challenges} is not null`,
),
)
const configured = problems
.map((p) => ({ ...p, list: challengesFor(p.challenges, language) }))
.filter((p) => p.list.length > 0)
if (configured.length === 0) return []
const ids = configured.map((p) => p.id)
const acceptedBy = and(
inArray(schema.submission.problemId, ids),
eq(schema.submission.result, JudgeStatus.ACCEPTED),
eq(schema.submission.language, language as ProblemLanguage),
isNull(schema.submission.contestId),
)
const [acRows, solvedRows, doneRows, mineRows] = await Promise.all([
db
.select({
problemId: schema.submission.problemId,
count: sql<number>`count(distinct ${schema.submission.userId})::int`,
})
.from(schema.submission)
.where(acceptedBy)
.groupBy(schema.submission.problemId),
db
.selectDistinct({ problemId: schema.submission.problemId })
.from(schema.submission)
.where(and(acceptedBy, eq(schema.submission.userId, user.id))),
db
.select({
problemId: schema.challengeSuccess.problemId,
challengeId: schema.challengeSuccess.challengeId,
count: sql<number>`count(*)::int`,
})
.from(schema.challengeSuccess)
.where(inArray(schema.challengeSuccess.problemId, ids))
.groupBy(schema.challengeSuccess.problemId, schema.challengeSuccess.challengeId),
db
.select()
.from(schema.challengeSuccess)
.where(
and(
inArray(schema.challengeSuccess.problemId, ids),
eq(schema.challengeSuccess.userId, user.id),
),
),
])
// 第几个做到:比我早的有几个。同一时刻的按 id 排(补记录时同一批插入的 id 也是按时间顺的)
const ranks = await Promise.all(
mineRows.map(async (mine) => {
const earlier = and(
eq(schema.challengeSuccess.problemId, mine.problemId),
eq(schema.challengeSuccess.challengeId, mine.challengeId),
sql`(${schema.challengeSuccess.createdAt}, ${schema.challengeSuccess.id}) < (${mine.createdAt}, ${mine.id})`,
)
const [site] = await db
.select({ count: sql<number>`count(*)::int` })
.from(schema.challengeSuccess)
.where(earlier)
const [cls] = user.className
? await db
.select({ count: sql<number>`count(*)::int` })
.from(schema.challengeSuccess)
.innerJoin(schema.user, eq(schema.user.id, schema.challengeSuccess.userId))
.where(and(earlier, eq(schema.user.className, user.className)))
: [null]
return {
key: `${mine.problemId}:${mine.challengeId}`,
mine: {
at: mine.createdAt,
siteRank: (site?.count ?? 0) + 1,
classRank: cls ? cls.count + 1 : null,
},
}
}),
)
const mineByKey = new Map(ranks.map((r) => [r.key, r.mine]))
const acByProblem = new Map(acRows.map((r) => [r.problemId, r.count]))
const solved = new Set(solvedRows.map((r) => r.problemId))
const doneByKey = new Map(doneRows.map((r) => [`${r.problemId}:${r.challengeId}`, r.count]))
const byDisplayId = new Map(configured.map((p) => [p.displayId, p]))
return displayIds.flatMap((displayId) => {
const p = byDisplayId.get(displayId)
if (!p) return []
return [
{
problemId: p.displayId,
title: p.title,
acCount: acByProblem.get(p.id) ?? 0,
solved: solved.has(p.id),
challenges: p.list.map((c) => ({
id: c.id,
wording: c.wording,
doneCount: doneByKey.get(`${p.id}:${c.id}`) ?? 0,
mine: mineByKey.get(`${p.id}:${c.id}`) ?? null,
})),
},
]
})
}
/** 课堂看板用:这些人在这些题上做到了哪些挑战(只认现在还配着的挑战) */
export async function challengeSuccessesOf(userIds: number[], problemIds: number[]) {
if (userIds.length === 0 || problemIds.length === 0) return []
return db
.select({
userId: schema.challengeSuccess.userId,
problemId: schema.challengeSuccess.problemId,
challengeId: schema.challengeSuccess.challengeId,
})
.from(schema.challengeSuccess)
.where(
and(
inArray(schema.challengeSuccess.userId, userIds),
inArray(schema.challengeSuccess.problemId, problemIds),
),
)
}
// ---------------------------------------------------------------- 出题页的候选
interface Feature {
rule: AstRule
/** 用了它时的说法、没用它时的说法 */
use: string
avoid: string
}
/** 中文里夹的英文词两边留空格:「用 f-string 再写一遍」「用列表再写一遍」 */
function phrases(label: string) {
const before = /^[A-Za-z.]/.test(label) ? " " : ""
const after = /[A-Za-z)]$/.test(label) ? " " : ""
return { use: `用${before}${label}${after}再写一遍`, avoid: `不用${before}${label},再写一遍` }
}
const node = (target: string, label: string): Feature => ({
rule: { engine: "must_exist_node", target },
...phrases(label),
})
const call = (target: string): Feature => ({
rule: { engine: "must_call_function", target },
...phrases(`${target}()`),
})
const method = (target: string): Feature => ({
rule: { engine: "must_call_method", target },
...phrases(`.${target}()`),
})
/**
* 去挖哪些写法。2026-10 第一批挑战是这样挖出来的,挖出来以后人工逐条看过,这几类基本
* 都判掉了,所以这里干脆不挖:「不用 int()」(做到的是 eval)、「不用 elif / else」
* (拆成一串 if、多写一个反向条件)、「用 str() / list() / strip()」(凑数)。
*/
const FEATURES: Record<string, Feature[]> = {
Python: [
node("for_loop", "for 循环"),
node("while_loop", "while 循环"),
node("if_statement", "if"),
node("function_definition", "自定义函数"),
node("list_comprehension", "列表推导式"),
node("list_literal", "列表"),
node("dict_literal", "字典"),
node("f_string", "f-string"),
...["max", "min", "sum", "len", "sorted", "abs", "round", "map", "enumerate", "zip"].map(call),
...["join", "format", "count", "replace", "split", "upper", "lower", "find"].map(method),
],
C: [
node("for_loop", "for 循环"),
node("while_loop", "while 循环"),
node("do_while", "do-while 循环"),
node("if_statement", "if"),
node("switch_statement", "switch"),
node("function_definition", "自定义函数"),
...["puts", "strlen", "abs", "pow", "sqrt"].map(call),
],
}
FEATURES["C++"] = FEATURES.C!
const PRINT: Record<string, string> = { Python: "print", C: "printf", "C++": "printf" }
/** 「不用」能直接变成规则的引擎 */
const NEGATE: Record<string, AstRule["engine"]> = {
must_exist_node: "must_not_exist_node",
must_call_function: "must_not_call_function",
must_call_method: "must_not_call_method",
}
/**
* 出题页「挑战」的候选:这道题历史上做对的代码里,一批人用另一种写法也做对了的地方。
* 门槛和第一批一样:多数人用了、有 ≥5 人没用也做对了 → 「不用 X」;少数人用了且 ≥5 人 →
* 「用 X」;print 次数少于中位数且 ≥5 人做到 → 「少用 print」。
*/
export async function challengeSuggestions(
problemId: number,
language: string,
existing: Challenge[],
): Promise<{ acCount: number; suggestions: ChallengeSuggestion[] }> {
const features = FEATURES[language] ?? []
const printName = PRINT[language] ?? "print"
const subs = await db
.select({ userId: schema.submission.userId, code: schema.submission.code })
.from(schema.submission)
.where(
and(
eq(schema.submission.problemId, problemId),
eq(schema.submission.result, JudgeStatus.ACCEPTED),
eq(schema.submission.language, language as ProblemLanguage),
isNull(schema.submission.contestId),
),
)
.orderBy(asc(schema.submission.createTime))
const groups = [
...features.map((f) => [f.rule]),
[{ engine: "count_function_call", target: printName, min: 0 } satisfies AstRule],
]
interface Seen {
code: string
has: boolean[]
prints: number
}
const byUser = new Map<number, Seen[]>()
for (const [index, sub] of subs.entries()) {
const list = byUser.get(sub.userId) ?? []
// 一个人最多看 3 份:有人同一道题交了几十遍
if (list.length >= 3) continue
const verdicts = await checkAstGroups(sub.code, language, groups)
if (!verdicts) continue
list.push({
code: sub.code,
has: verdicts.slice(0, features.length).map((v) => v.passed),
prints: verdicts.at(-1)?.results[0]?.actual ?? 0,
})
byUser.set(sub.userId, list)
if (index % 50 === 49) await Bun.sleep(0)
}
const users = [...byUser.values()]
const acCount = users.length
if (acCount === 0) return { acCount, suggestions: [] }
const example = (pick: (s: Seen) => boolean) => {
const pool = users.flatMap((list) => list.filter(pick)).map((s) => s.code.trim())
pool.sort((a, b) => a.length - b.length)
return pool[Math.floor(pool.length / 2)] ?? ""
}
const suggestions: ChallengeSuggestion[] = []
for (const [i, feature] of features.entries()) {
const usedFirst = users.filter((list) => list[0]!.has[i]).length
if (usedFirst / acCount >= 0.5) {
const did = users.filter((list) => list.some((s) => !s.has[i])).length
const engine = NEGATE[feature.rule.engine]
if (did >= 5 && engine) {
suggestions.push({
wording: feature.avoid,
rules: [{ ...feature.rule, engine }],
did,
firstShare: Math.round((usedFirst / acCount) * 100),
example: example((s) => !s.has[i]),
})
}
} else {
const did = users.filter((list) => list.some((s) => s.has[i])).length
if (did >= 5) {
suggestions.push({
wording: feature.use,
rules: [feature.rule],
did,
firstShare: Math.round((usedFirst / acCount) * 100),
example: example((s) => s.has[i] === true),
})
}
}
}
const firstPrints = users.map((list) => list[0]!.prints).sort((a, b) => a - b)
const median = firstPrints[Math.floor(firstPrints.length / 2)]!
for (let level = 1; level < median; level++) {
const ok = (s: Seen) => s.prints >= 1 && s.prints <= level
const did = users.filter((list) => list.some(ok)).length
if (did < 5) continue
suggestions.push({
wording: `只用 ${level} 次 ${printName}`,
rules: [{ engine: "count_function_call", target: printName, max: level }],
did,
firstShare: null,
example: example(ok),
})
break
}
const taken = existing.map((c) => JSON.stringify(c.rules))
return {
acCount,
suggestions: suggestions
.filter((s) => !taken.includes(JSON.stringify(s.rules)))
.sort((a, b) => b.did - a.did)
.slice(0, 12),
}
}
+11
View File
@@ -1,6 +1,7 @@
import { toAdminType, toProblemPermission } from "@oj2/contract" import { toAdminType, toProblemPermission } from "@oj2/contract"
import type { import type {
AddContestProblemRequest, AddContestProblemRequest,
ChallengeSuggestionsResponse,
AddProblemsToSetRequest, AddProblemsToSetRequest,
BatchProblemTagRequest, BatchProblemTagRequest,
BatchProblemVisibleRequest, BatchProblemVisibleRequest,
@@ -353,6 +354,16 @@ export function checkAstRules(data: AstCheckRequest) {
return api.post<AstCheckResponse>("admin/ast-check", data satisfies AstCheckRequest) return api.post<AstCheckResponse>("admin/ast-check", data satisfies AstCheckRequest)
} }
// 出题页「挑战」的候选:拿这道题历史上做对的代码挖
export function getChallengeSuggestions(problemId: number, language: string) {
return api.post<ChallengeSuggestionsResponse>(
`admin/problems/${problemId}/challenge-suggestions`,
{
language,
},
)
}
// AI 按题面和标准答案想几组测试输入(输出由标准答案跑) // AI 按题面和标准答案想几组测试输入(输出由标准答案跑)
export function generateTestInputs(data: GenerateTestInputsRequest) { export function generateTestInputs(data: GenerateTestInputsRequest) {
return api.post<GenerateTestInputsResponse>( return api.post<GenerateTestInputsResponse>(
@@ -15,6 +15,11 @@ interface Props {
languages: LANGUAGE[] languages: LANGUAGE[]
/** 各语言的标准答案(学生那段代码,不套模板):拿来自测规则 */ /** 各语言的标准答案(学生那段代码,不套模板):拿来自测规则 */
answers: Partial<Record<LANGUAGE, string>> answers: Partial<Record<LANGUAGE, string>>
/**
* 挑战的判定规则(ChallengeEditor 里一条挑战一个)。和语法要求是同一套规则,但口径不同:
* 不满足不算错、说法由挑战自己写、标准答案不这么写是常事(挑战就是另一种写法)
*/
challenge?: boolean
} }
const props = defineProps<Props>() const props = defineProps<Props>()
@@ -294,8 +299,13 @@ watch(supportedLanguages, (langs) => {
<template> <template>
<div class="astRules"> <div class="astRules">
<div class="head"> <div class="head">
<span class="label">语法要求</span> <span class="label">{{ challenge ? "判定" : "语法要求" }}</span>
<div v-if="supportedLanguages.length" class="langs" role="tablist" aria-label="语言"> <div
v-if="supportedLanguages.length && !challenge"
class="langs"
role="tablist"
aria-label="语言"
>
<button <button
v-for="lang in supportedLanguages" v-for="lang in supportedLanguages"
:key="lang" :key="lang"
@@ -310,7 +320,8 @@ watch(supportedLanguages, (langs) => {
</button> </button>
</div> </div>
<div class="grow"></div> <div class="grow"></div>
<n-text depth="3" class="note">提交时逐条检查,不满足算错</n-text> <n-text v-if="!challenge" depth="3" class="note">提交时逐条检查,不满足算错</n-text>
<n-text v-else depth="3" class="note">几条都满足才算做到</n-text>
</div> </div>
<n-text v-if="unsupportedLanguages.length" depth="3" class="note"> <n-text v-if="unsupportedLanguages.length" depth="3" class="note">
{{ unsupportedLanguages.join("、") }} 检查不了语法要求(判题机只认 {{ unsupportedLanguages.join("、") }} 检查不了语法要求(判题机只认
@@ -402,6 +413,7 @@ watch(supportedLanguages, (langs) => {
</div> </div>
<div class="ruleRow sub"> <div class="ruleRow sub">
<n-input <n-input
v-if="!challenge"
:value="rule.message" :value="rule.message"
size="tiny" size="tiny"
class="message" class="message"
@@ -412,7 +424,19 @@ watch(supportedLanguages, (langs) => {
" "
@update:value="(v: string) => updateRule(lang, index, 'message', v)" @update:value="(v: string) => updateRule(lang, index, 'message', v)"
/> />
<span v-if="checkOf(lang, index)?.passed === true" class="ok">✓ 标准答案过了</span> <template v-if="challenge">
<n-text v-if="checkOf(lang, index)?.passed === true" depth="3" class="note">
标准答案这样写也满足
</n-text>
<n-text v-else-if="checkOf(lang, index)?.passed === false" depth="3" class="note">
标准答案不是这样写的{{
checkOf(lang, index)!.actual !== undefined
? `(数到 ${checkOf(lang, index)!.actual} 次)`
: ""
}}
</n-text>
</template>
<span v-else-if="checkOf(lang, index)?.passed === true" class="ok">✓ 标准答案过了</span>
<span v-else-if="checkOf(lang, index)?.passed === false" class="bad"> <span v-else-if="checkOf(lang, index)?.passed === false" class="bad">
✗ 标准答案自己没过{{ ✗ 标准答案自己没过{{
checkOf(lang, index)!.actual !== undefined checkOf(lang, index)!.actual !== undefined
@@ -0,0 +1,265 @@
<script setup lang="ts">
import {
AST_SUPPORTED_LANGUAGES,
CHALLENGE_RULES_MAX,
CHALLENGE_WORDING_MAX,
CHALLENGES_PER_LANGUAGE_MAX,
type AstRules,
type ChallengeInput,
type ChallengeSuggestionsResponse,
} from "@oj2/contract"
import { errorMessage } from "utils/api"
import type { LANGUAGE } from "utils/types"
import { getChallengeSuggestions } from "../../api"
import AstRulesEditor from "./AstRulesEditor.vue"
/**
* 出题页的「挑战」:做对之后,同一道题加一条写法上的限制再做一遍(设计稿 StkAdmin)。
* 判定就是语法要求那套规则,所以每条挑战底下直接摆一个规则编辑器(只开这一种语言)。
*
* 「从做对的代码里找」把这道题历史上做对的代码挖一遍,列出「一批人用另一种写法也做对了」
* 的地方。机械挖出来的会有坏的(拆掉 elif 换成一串 if 也算「不用 elif」),所以每条带一份
* 做到的人的真实代码,好不好老师看了再加。新建的题还没人做过,不给这个按钮。
*/
const props = defineProps<{
modelValue: Record<string, ChallengeInput[]> | null
languages: LANGUAGE[]
answers: Partial<Record<LANGUAGE, string>>
/** 编辑已有的题才有;新建的题没有做对的代码可挖 */
problemId: number | null
}>()
const emit = defineEmits<{
(e: "update:modelValue", value: Record<string, ChallengeInput[]> | null): void
}>()
const languages = computed(() =>
props.languages.filter((lang) => AST_SUPPORTED_LANGUAGES.includes(lang)),
)
const tab = ref<string>(languages.value[0] ?? "Python")
watch(languages, (list) => {
if (!list.includes(tab.value as LANGUAGE)) tab.value = list[0] ?? "Python"
})
const list = computed(() => props.modelValue?.[tab.value] ?? [])
function update(next: ChallengeInput[]) {
const value = { ...props.modelValue }
if (next.length) value[tab.value] = next
else delete value[tab.value]
emit("update:modelValue", Object.keys(value).length ? value : null)
}
function patch(index: number, change: Partial<ChallengeInput>) {
update(list.value.map((item, i) => (i === index ? { ...item, ...change } : item)))
}
function remove(index: number) {
update(list.value.filter((_, i) => i !== index))
}
function add(item: ChallengeInput = { wording: "", rules: [] }) {
update([...list.value, item])
}
/** AstRulesEditor 按语言分组收发,这里只给它一种语言 */
function rulesModel(item: ChallengeInput): AstRules {
return { [tab.value]: item.rules }
}
function onRules(index: number, value: AstRules | null) {
patch(index, { rules: (value?.[tab.value] ?? []).slice(0, CHALLENGE_RULES_MAX) })
}
const full = computed(() => list.value.length >= CHALLENGES_PER_LANGUAGE_MAX)
// ---------------------------------------------------------------- 候选
const suggestions = ref<Record<string, ChallengeSuggestionsResponse>>({})
const loading = ref(false)
const error = ref("")
async function findSuggestions() {
if (!props.problemId) return
loading.value = true
error.value = ""
try {
suggestions.value = {
...suggestions.value,
[tab.value]: await getChallengeSuggestions(props.problemId, tab.value),
}
} catch (err) {
error.value = errorMessage(err, "没找成")
} finally {
loading.value = false
}
}
const current = computed(() => suggestions.value[tab.value] ?? null)
/** 已经加上的不再列 */
const shown = computed(() => {
const taken = list.value.map((item) => JSON.stringify(item.rules))
return (current.value?.suggestions ?? []).filter(
(item) => !taken.includes(JSON.stringify(item.rules)),
)
})
</script>
<template>
<div class="challenge-editor">
<n-radio-group v-if="languages.length > 1" v-model:value="tab" size="small">
<n-radio-button v-for="lang in languages" :key="lang" :value="lang">
{{ lang }}{{ modelValue?.[lang]?.length ? ` ${modelValue[lang]!.length} 条` : "" }}
</n-radio-button>
</n-radio-group>
<div v-for="(item, index) in list" :key="`${tab}-${item.id ?? index}`" class="item">
<div class="itemHead">
<span class="index">{{ index + 1 }}</span>
<n-input
:value="item.wording"
:maxlength="CHALLENGE_WORDING_MAX"
placeholder="给学生看的说法,例如「整首歌只用 1 次 print」"
:status="item.wording.trim() ? undefined : 'warning'"
@update:value="(value: string) => patch(index, { wording: value })"
/>
<n-button size="small" quaternary @click="remove(index)">删掉</n-button>
</div>
<AstRulesEditor
:model-value="rulesModel(item)"
:languages="[tab as LANGUAGE]"
:answers="answers"
challenge
@update:model-value="(value) => onRules(index, value)"
/>
</div>
<n-text v-if="!languages.length" depth="3" class="note">
只有 Python、C、C++ 能配挑战,先在「学生怎么写」里勾上其中一种。
</n-text>
<template v-else>
<div class="actions">
<n-button size="small" :disabled="full" @click="add()">+ 自己写一条</n-button>
<n-button
v-if="problemId"
size="small"
:loading="loading"
:disabled="full"
@click="findSuggestions"
>
从做对的代码里找
</n-button>
</div>
<n-text v-if="error" type="error" class="note">{{ error }}</n-text>
<div v-if="current" class="suggestions">
<n-text depth="3" class="note">
{{ current.acCount }} 个人用 {{ tab }} 做对过,下面是有一批人换了种写法也做对的地方
</n-text>
<n-text v-if="!shown.length" depth="3" class="note">没找到别的写法</n-text>
<div v-for="item in shown" :key="JSON.stringify(item.rules)" class="suggestion">
<div class="suggestionHead">
<div class="suggestionText">
<b>{{ item.wording }}</b>
<n-text depth="3" class="note">
{{ item.did }} 人这样做对<template v-if="item.firstShare !== null"
>,第一次做对时 {{ item.firstShare }}% 这样写</template
>
</n-text>
</div>
<n-button
size="small"
:disabled="full"
@click="add({ wording: item.wording, rules: item.rules })"
>
加上
</n-button>
</div>
<pre class="example">{{ item.example }}</pre>
</div>
</div>
</template>
</div>
</template>
<style scoped>
.challenge-editor {
display: flex;
flex-direction: column;
gap: 10px;
}
.item {
display: flex;
flex-direction: column;
gap: 8px;
padding: 10px 12px;
border: 1px solid rgba(91, 52, 196, 0.3);
border-radius: 6px;
}
.itemHead {
display: flex;
align-items: center;
gap: 8px;
}
.index {
flex-shrink: 0;
width: 22px;
height: 22px;
border-radius: 11px;
background-color: rgba(91, 52, 196, 0.12);
color: #5b34c4;
font-size: 12px;
font-weight: 600;
display: inline-flex;
align-items: center;
justify-content: center;
}
.actions {
display: flex;
gap: 8px;
}
.note {
font-size: 12px;
}
.suggestions {
display: flex;
flex-direction: column;
}
.suggestion {
padding: 10px 0;
border-top: 1px solid rgba(128, 128, 128, 0.15);
}
.suggestionHead {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
}
.suggestionText {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
}
.example {
margin: 6px 0 0;
padding: 6px 10px;
border: 1px solid rgba(128, 128, 128, 0.2);
border-radius: 4px;
font-family: ui-monospace, "Cascadia Mono", Consolas, "Microsoft YaHei", monospace;
font-size: 12.5px;
line-height: 1.6;
white-space: pre;
overflow-x: auto;
max-height: 180px;
}
</style>
+51 -1
View File
@@ -1,5 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { TEST_CASE_MAX_CASES, type RunnableLanguage } from "@oj2/contract" import { TEST_CASE_MAX_CASES, type ChallengeInput, type RunnableLanguage } from "@oj2/contract"
import { useThemeVars } from "naive-ui" import { useThemeVars } from "naive-ui"
import PageHeader from "admin/components/PageHeader.vue" import PageHeader from "admin/components/PageHeader.vue"
import { getProblemTagList } from "shared/api" import { getProblemTagList } from "shared/api"
@@ -19,6 +19,7 @@ import {
uploadTestcases, uploadTestcases,
} from "../api" } from "../api"
import AstRulesEditor from "./components/AstRulesEditor.vue" import AstRulesEditor from "./components/AstRulesEditor.vue"
import ChallengeEditor from "./components/ChallengeEditor.vue"
import CaseTable from "./components/CaseTable.vue" import CaseTable from "./components/CaseTable.vue"
import CodePreview from "./components/CodePreview.vue" import CodePreview from "./components/CodePreview.vue"
import PreviewStrip from "./components/PreviewStrip.vue" import PreviewStrip from "./components/PreviewStrip.vue"
@@ -108,6 +109,8 @@ interface CodeForm {
answers: Record<RunnableLanguage, string> answers: Record<RunnableLanguage, string>
templates: Record<RunnableLanguage, TemplateParts> templates: Record<RunnableLanguage, TemplateParts>
astRules: AstRules | null astRules: AstRules | null
/** 做对之后的挑战,按语言分组(见契约 challengeSchema) */
challenges: Record<string, ChallengeInput[]> | null
flowchartMode: FlowchartMode flowchartMode: FlowchartMode
mermaidCode: string mermaidCode: string
/** 画布画的参考流程图;null 是老题(只有手写的 Mermaid)或者还没画 */ /** 画布画的参考流程图;null 是老题(只有手写的 Mermaid)或者还没画 */
@@ -135,6 +138,7 @@ const blankForm = (): CodeForm => ({
answers: { Python: "", C: "", "C++": "" }, answers: { Python: "", C: "", "C++": "" },
templates: { Python: blankParts(), C: blankParts(), "C++": blankParts() }, templates: { Python: blankParts(), C: blankParts(), "C++": blankParts() },
astRules: null, astRules: null,
challenges: null,
flowchartMode: "none", flowchartMode: "none",
mermaidCode: "", mermaidCode: "",
flowchartDoc: null, flowchartDoc: null,
@@ -428,6 +432,14 @@ watch(
const showHidden = ref(false) const showHidden = ref(false)
const astChecks = ref<AstCheckState>({}) const astChecks = ref<AstCheckState>({})
const challengeSummary = computed(() => {
const parts = form.languages
.map((lang) => [lang, form.challenges?.[lang]?.length ?? 0] as const)
.filter(([, n]) => n > 0)
.map(([lang, n]) => `${lang} ${n} 条`)
return parts.length ? parts.join(" · ") : "没有"
})
const answersForAst = computed(() => ({ ...form.answers }) as Partial<Record<LANGUAGE, string>>) const answersForAst = computed(() => ({ ...form.answers }) as Partial<Record<LANGUAGE, string>>)
const templatesForPreview = computed( const templatesForPreview = computed(
() => ({ ...form.templates }) as Partial<Record<LANGUAGE, TemplateParts>>, () => ({ ...form.templates }) as Partial<Record<LANGUAGE, TemplateParts>>,
@@ -493,6 +505,7 @@ const open = reactive({
answer: true, answer: true,
cases: true, cases: true,
write: false, write: false,
challenge: false,
flowchart: false, flowchart: false,
more: false, more: false,
}) })
@@ -612,6 +625,15 @@ async function loadProblem(data: AdminProblem) {
answers, answers,
templates, templates,
astRules: data.astRules ?? null, astRules: data.astRules ?? null,
// scannedAt 是后端记账用的,表单里不带;id 带着,规则没改就沿用(做到记录跟着 id 走)
challenges: data.challenges
? Object.fromEntries(
Object.entries(data.challenges).map(([lang, list]) => [
lang,
list.map(({ id, wording, rules }) => ({ id, wording, rules })),
]),
)
: null,
flowchartMode: data.allowFlowchart ? "draw" : data.showFlowchart ? "show" : "none", flowchartMode: data.allowFlowchart ? "draw" : data.showFlowchart ? "show" : "none",
mermaidCode: data.mermaidCode ?? "", mermaidCode: data.mermaidCode ?? "",
flowchartDoc: parseDoc(data.flowchartData), flowchartDoc: parseDoc(data.flowchartData),
@@ -719,6 +741,15 @@ const blockers = computed<Blocker[]>(() => {
}) })
}) })
} }
for (const lang of form.languages) {
form.challenges?.[lang]?.forEach((item, i) => {
if (!item.wording.trim() || !item.rules.length)
list.push({
text: `把挑战 ${lang} 第 ${i + 1} 条${item.wording.trim() ? "的规则" : "的说法"}写上`,
section: "challenge",
})
})
}
if (form.flowchartMode === "show") { if (form.flowchartMode === "show") {
if (legacyMermaid.value) { if (legacyMermaid.value) {
if (!mermaidOk.value) if (!mermaidOk.value)
@@ -804,6 +835,7 @@ async function save() {
: null, : null,
flowchartHint: form.flowchartHint.trim(), flowchartHint: form.flowchartHint.trim(),
astRules: form.astRules, astRules: form.astRules,
challenges: form.challenges,
sqlConfig: null, sqlConfig: null,
sqlDisplay: null, sqlDisplay: null,
contestId: props.contestID ? Number(props.contestID) : null, contestId: props.contestID ? Number(props.contestID) : null,
@@ -1197,6 +1229,24 @@ onMounted(async () => {
</template> </template>
</section> </section>
<!-- 挑战 -->
<section id="section-challenge" class="card">
<button type="button" class="cardHead" @click="open.challenge = !open.challenge">
<h3>挑战</h3>
<span v-if="!open.challenge" class="summary">{{ challengeSummary }}</span>
<n-text v-else depth="3" class="note">选填,学生做对以后才看得到</n-text>
<span class="grow"></span>
<span class="caret">{{ open.challenge ? "⌃" : "›" }}</span>
</button>
<ChallengeEditor
v-if="open.challenge"
v-model="form.challenges"
:languages="form.languages"
:answers="answersForAst"
:problem-id="props.initial?.id ?? null"
/>
</section>
<!-- 流程图 --> <!-- 流程图 -->
<section id="section-flowchart" class="card"> <section id="section-flowchart" class="card">
<button type="button" class="cardHead" @click="open.flowchart = !open.flowchart"> <button type="button" class="cardHead" @click="open.flowchart = !open.flowchart">
+10
View File
@@ -1,4 +1,5 @@
import type { import type {
ChallengeList,
ClassLessonRequest, ClassLessonRequest,
ContestPasswordRequest, ContestPasswordRequest,
CreateFlowchartRequest, CreateFlowchartRequest,
@@ -431,6 +432,15 @@ export function getSimilarProblems(problemId: string) {
.then((response) => response.map(toProblemRow)) .then((response) => response.map(toProblemRow))
} }
// ==================== 挑战 ====================
/** 几道题、某种语言下的挑战(说法、几人做到、我做到没有),见契约 problemChallengeGroupSchema */
export function getChallenges(problemIds: string[], language: string) {
return api.get<ChallengeList>("challenges", {
params: { problems: problemIds.join(","), language },
})
}
// ==================== 流程图相关API ==================== // ==================== 流程图相关API ====================
export function submitFlowchart(data: { export function submitFlowchart(data: {
+56 -1
View File
@@ -256,6 +256,17 @@ const groupCount = computed(() => {
for (const student of students.value) count[groupOf(student)]++ for (const student of students.value) count[groupOf(student)]++
return count return count
}) })
/**
* 全做完了的人里有几个在挑战:做对之后又交了、还没做到(见契约 classBoardCellSchema.challenge)。
* 做完了又没在挑战的,就是空着的
*/
const challengingCount = computed(
() =>
students.value.filter(
(student) =>
groupOf(student) === "done" && student.cells.some((cell) => cell.challenge === "doing"),
).length,
)
/** 每组第一个人,在他上面插一行组名 */ /** 每组第一个人,在他上面插一行组名 */
const groupStarts = computed(() => { const groupStarts = computed(() => {
const starts = new Set<number>() const starts = new Set<number>()
@@ -298,7 +309,11 @@ const problemStats = computed(() =>
const done = present.value.filter((student) => student.cells[i]?.status === "accepted").length const done = present.value.filter((student) => student.cells[i]?.status === "accepted").length
const wrong = present.value.filter((student) => student.cells[i]?.status === "tried").length const wrong = present.value.filter((student) => student.cells[i]?.status === "tried").length
const total = present.value.length const total = present.value.length
return { problem, done, wrong, none: total - done - wrong, total } // 挑战成功几人:只有配了挑战的题才写
const challenged = problem.hasChallenges
? present.value.filter((student) => student.cells[i]?.challenge === "done").length
: null
return { problem, done, wrong, none: total - done - wrong, total, challenged }
}), }),
) )
@@ -532,6 +547,9 @@ function lastText(student: ClassBoardStudent) {
<span class="card-foot"> <span class="card-foot">
<span :class="stat.wrong ? 'wrong' : 'muted'">错着 {{ stat.wrong }}</span> <span :class="stat.wrong ? 'wrong' : 'muted'">错着 {{ stat.wrong }}</span>
<span class="muted">没做 {{ stat.none }}</span> <span class="muted">没做 {{ stat.none }}</span>
<span v-if="stat.challenged !== null" class="challenged">
挑战成功 {{ stat.challenged }}
</span>
</span> </span>
</button> </button>
</div> </div>
@@ -651,6 +669,9 @@ function lastText(student: ClassBoardStudent) {
<span class="group-label"> <span class="group-label">
{{ GROUP_LABEL[groupOf(student)] }} {{ GROUP_LABEL[groupOf(student)] }}
<span class="muted">{{ groupCount[groupOf(student)] }}</span> <span class="muted">{{ groupCount[groupOf(student)] }}</span>
<span v-if="groupOf(student) === 'done' && challengingCount" class="challenged">
在挑战 {{ challengingCount }}
</span>
</span> </span>
</td> </td>
</tr> </tr>
@@ -709,6 +730,7 @@ function lastText(student: ClassBoardStudent) {
v-if="cell.status === 'tried' && cell.latest" v-if="cell.status === 'tried' && cell.latest"
type="button" type="button"
class="cell" class="cell"
:data-challenge="cell.challenge ?? undefined"
:style="cellStyle(cell)" :style="cellStyle(cell)"
:title="cellTitle(cell, student)" :title="cellTitle(cell, student)"
@click="openExplain(i, student.userId)" @click="openExplain(i, student.userId)"
@@ -727,6 +749,7 @@ function lastText(student: ClassBoardStudent) {
<button <button
type="button" type="button"
class="cell" class="cell"
:data-challenge="cell.challenge ?? undefined"
:class="{ picked: trailKey === `${student.userId}:${i}` }" :class="{ picked: trailKey === `${student.userId}:${i}` }"
:style="cellStyle(cell)" :style="cellStyle(cell)"
:title="cellTitle(cell, student)" :title="cellTitle(cell, student)"
@@ -747,6 +770,7 @@ function lastText(student: ClassBoardStudent) {
<a <a
v-else-if="cell.attempts" v-else-if="cell.attempts"
class="cell" class="cell"
:data-challenge="cell.challenge ?? undefined"
:style="cellStyle(cell)" :style="cellStyle(cell)"
:href="submissionsHref(student, board.problems[i], !cell.codeAttempts)" :href="submissionsHref(student, board.problems[i], !cell.codeAttempts)"
target="_blank" target="_blank"
@@ -757,6 +781,7 @@ function lastText(student: ClassBoardStudent) {
<span <span
v-else v-else
class="cell" class="cell"
:data-challenge="cell.challenge ?? undefined"
:style="cellStyle(cell)" :style="cellStyle(cell)"
:title="cellTitle(cell, student)" :title="cellTitle(cell, student)"
> >
@@ -1220,6 +1245,36 @@ a.cell {
cursor: pointer; cursor: pointer;
} }
/* 挑战:做到了贴一枚紫色筹码,在挑战加一圈紫边(设计稿 StkBoard) */
.cell[data-challenge="done"] {
position: relative;
}
.cell[data-challenge="done"]::after {
content: "";
position: absolute;
right: -6px;
top: -6px;
width: 14px;
height: 14px;
border-radius: 50%;
box-sizing: border-box;
background: #5b34c4;
border: 3px solid #b49cf5;
box-shadow: 0 0 0 2px v-bind("theme.cardColor");
}
.cell[data-challenge="doing"] {
box-shadow:
0 0 0 2px v-bind("theme.cardColor"),
0 0 0 4px #8f72e3;
}
.challenged {
color: #6a45d6;
font-weight: 600;
}
.cell.picked { .cell.picked {
box-shadow: 0 0 0 2px v-bind("theme.textColor1"); box-shadow: 0 0 0 2px v-bind("theme.textColor1");
} }
+4 -1
View File
@@ -53,9 +53,12 @@ export function cellTitle(
day: string | undefined, day: string | undefined,
) { ) {
if (cell.status === "accepted") { if (cell.status === "accepted") {
return acceptedToday(cell, day) const when = acceptedToday(cell, day)
? `${parseTime(cell.acceptedAt!, "HH:mm")} 做对` ? `${parseTime(cell.acceptedAt!, "HH:mm")} 做对`
: "以前就做对了" : "以前就做对了"
if (cell.challenge === "done") return `${when},挑战也做到了`
if (cell.challenge === "doing") return `${when},在挑战`
return when
} }
if (cell.status === "tried") { if (cell.status === "tried") {
if (cell.latest) return `讲讲 ${student.username} 这道题的错` if (cell.latest) return `讲讲 ${student.username} 这道题的错`
@@ -0,0 +1,127 @@
<script setup lang="ts">
import { useChallengeStore } from "oj/store/challenge"
import { useChallengeTone } from "../composables/challengeTone"
import ChallengeIcon from "./ChallengeIcon.vue"
/**
* 挑战中:编辑器顶上一条紫色的,写着要求和全站几人做到,随时「不挑战了」(设计稿 StkDoing)。
* 做到了换成「挑战成功」,留着让学生看一眼自己是照什么要求写的,再「回到普通模式」
*/
const challengeStore = useChallengeStore()
const tone = useChallengeTone()
const active = computed(() => challengeStore.active)
const done = computed(() => !!active.value?.mine)
</script>
<template>
<div v-if="active" class="challenge-bar">
<span class="pill">
<svg
v-if="done"
width="12"
height="12"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="3"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
>
<path d="M5 12.5l4.5 4.5L19 7.5" />
</svg>
<ChallengeIcon v-else />
{{ done ? "挑战成功" : "挑战" }}
</span>
<b class="wording">{{ active.wording }}</b>
<span class="spacer" />
<span v-if="!done" class="meta">{{ active.doneCount }} 人做到</span>
<button type="button" class="quit" @click="challengeStore.stop()">
{{ done ? "回到普通模式" : "不挑战了" }}
</button>
</div>
</template>
<style scoped>
.challenge-bar {
height: 44px;
flex-shrink: 0;
box-sizing: border-box;
padding: 0 14px;
display: flex;
align-items: center;
gap: 10px;
background-color: v-bind("tone.soft");
border-bottom: 1px solid v-bind("tone.border");
white-space: nowrap;
min-width: 0;
}
.pill {
display: inline-flex;
align-items: center;
gap: 5px;
height: 24px;
padding: 0 9px;
border-radius: 12px;
background-color: v-bind("tone.solid");
color: v-bind("tone.onSolid");
font-size: 13px;
font-weight: 600;
flex-shrink: 0;
}
.wording {
font-size: 15px;
color: v-bind("tone.text");
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
}
.spacer {
flex-grow: 1;
}
.meta {
font-size: 13px;
opacity: 0.75;
}
.quit {
height: 28px;
padding: 0 10px;
border: 1px solid v-bind("tone.border");
border-radius: 4px;
background: transparent;
font: inherit;
font-size: 13px;
color: v-bind("tone.text");
cursor: pointer;
flex-shrink: 0;
}
/* 手机上一行放不下:说法最要紧,让它折行,人数不写了 */
@media (max-width: 768px) {
.challenge-bar {
height: auto;
min-height: 44px;
padding: 6px 12px;
white-space: normal;
}
.wording {
flex: 1 1 0;
}
.spacer,
.meta {
display: none;
}
}
.quit:focus-visible {
outline: 2px solid v-bind("tone.solid");
outline-offset: 2px;
}
</style>
@@ -0,0 +1,20 @@
<script setup lang="ts">
/** 挑战的标记:一枚筹码(借的是小丑牌的赌注筹码),设计稿「做对后再挑战」 */
withDefaults(defineProps<{ size?: number }>(), { size: 14 })
</script>
<template>
<svg
:width="size"
:height="size"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2.2"
aria-hidden="true"
>
<circle cx="12" cy="12" r="9" />
<circle cx="12" cy="12" r="4" />
<path d="M12 3v3M12 18v3M3 12h3M18 12h3" stroke-linecap="round" />
</svg>
</template>
@@ -0,0 +1,201 @@
<script setup lang="ts">
import type { ChallengeList } from "@oj2/contract"
import { getChallenges } from "oj/api"
import { useChallengeStore } from "oj/store/challenge"
import { useChallengeTone } from "../composables/challengeTone"
import ChallengeIcon from "./ChallengeIcon.vue"
/**
* 「还能挑战」:这几道题里我做对了、还没做到的挑战,一条一行,「去挑战」(设计稿 StkDone)。
* 一条都没有就什么都不显示 —— 课堂题多是一两行,能出挑战的只是一部分。
*/
const props = defineProps<{
problemIds: string[]
language: string
/** 变了就重拉:刚做对 / 刚做到一条挑战,列表里要去掉它 */
refreshKey?: string
}>()
const challengeStore = useChallengeStore()
const tone = useChallengeTone()
const groups = ref<ChallengeList>([])
let sequence = 0
watch(
() => [props.problemIds.join(","), props.language, props.refreshKey] as const,
async () => {
const mine = ++sequence
if (!props.problemIds.length || !props.language) {
groups.value = []
return
}
try {
const list = await getChallenges(props.problemIds, props.language)
if (mine === sequence) groups.value = list
} catch {
if (mine === sequence) groups.value = []
}
},
{ immediate: true },
)
const rows = computed(() =>
groups.value.flatMap((group) =>
// 没做对的题不给接挑战:挑战是做对之后的事
group.solved
? group.challenges
.filter((challenge) => !challenge.mine)
.map((challenge) => ({ group, challenge }))
: [],
),
)
</script>
<template>
<section v-if="rows.length" class="challenge-list" aria-label="还能挑战">
<div class="head">
<span class="pill"><ChallengeIcon />挑战</span>
<b>还能挑战</b>
</div>
<div v-for="({ group, challenge }, i) in rows" :key="challenge.id" class="row">
<div class="text">
<div class="problem">
<b>{{ group.problemId }}</b> <span class="title">{{ group.title }}</span>
</div>
<b class="wording">{{ challenge.wording }}</b>
<span class="stat">
{{ group.acCount }} 个做对的人里,{{ challenge.doneCount }} 人做到
</span>
</div>
<button
type="button"
class="go"
:class="{ ghost: i > 0 }"
@click="challengeStore.start(group.problemId, challenge.id)"
>
去挑战
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2.5"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
>
<path d="M5 12h14M13 6l6 6-6 6" />
</svg>
</button>
</div>
</section>
</template>
<style scoped>
.challenge-list {
margin-top: 14px;
display: flex;
flex-direction: column;
gap: 8px;
}
.head {
display: flex;
align-items: center;
gap: 8px;
font-size: 15px;
}
.pill {
display: inline-flex;
align-items: center;
gap: 5px;
height: 24px;
padding: 0 9px;
border-radius: 12px;
background-color: v-bind("tone.solid");
color: v-bind("tone.onSolid");
font-size: 13px;
font-weight: 600;
}
.row {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
align-items: center;
gap: 4px 16px;
padding: 12px 14px;
border: 1px solid v-bind("tone.border");
border-radius: 8px;
}
.text {
display: flex;
flex-direction: column;
gap: 3px;
min-width: 0;
}
.problem {
font-size: 13px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.title,
.stat {
opacity: 0.7;
}
.stat {
font-size: 13px;
}
.wording {
font-size: 17px;
line-height: 24px;
color: v-bind("tone.text");
}
.go {
display: inline-flex;
align-items: center;
gap: 6px;
height: 40px;
padding: 0 16px;
border-radius: 6px;
border: 1px solid v-bind("tone.solid");
background-color: v-bind("tone.solid");
color: v-bind("tone.onSolid");
font: inherit;
font-size: 15px;
font-weight: 600;
white-space: nowrap;
cursor: pointer;
}
.go.ghost {
background-color: transparent;
color: v-bind("tone.text");
border-color: v-bind("tone.border");
}
.go:focus-visible {
outline: 2px solid v-bind("tone.solid");
outline-offset: 2px;
}
/* 手机上按钮另起一行占满,好按 */
@media (max-width: 768px) {
.row {
grid-template-columns: minmax(0, 1fr);
}
.go {
justify-content: center;
height: 44px;
}
}
</style>
@@ -0,0 +1,194 @@
<script setup lang="ts">
import type { AstResultInfo } from "@oj2/contract"
import { useChallengeStore } from "oj/store/challenge"
import { clearChallengeLines, showChallengeLines } from "oj/problem/utils/errorMark"
import { useChallengeTone } from "../composables/challengeTone"
import ChallengeIcon from "./ChallengeIcon.vue"
/**
* 挑战中交的这一次,挑战成没成(设计稿 StkFail / StkPass)。答案本身是对的 —— 没做到挑战
* 也照样算做对,所以没做到时大字写「答案对了,挑战还差一点」,不是错误的红色。
*/
const props = defineProps<{
passed: boolean
results: AstResultInfo[]
wording: string
}>()
const challengeStore = useChallengeStore()
const tone = useChallengeTone()
const failed = computed(() => props.results.filter((result) => !result.passed))
/** 「print() 至多调用 1 次,现在 3 次」 */
const reason = computed(() =>
failed.value
.map((result) =>
result.actual !== undefined
? `${result.description},现在 ${result.actual} 次`
: result.description,
)
.join(";"),
)
const lines = computed(() => [...new Set(failed.value.flatMap((result) => result.lines ?? []))])
// 编辑器里把那几行标紫。做到了、换了一次提交就清掉
watch(
() => (props.passed ? [] : lines.value),
(value) => (value.length ? showChallengeLines(value) : clearChallengeLines()),
{ immediate: true },
)
onUnmounted(clearChallengeLines)
/** 做到了:本班、全站第几个。刚判完那一刻列表还没重拉,先不显示 */
const mine = computed(() => (props.passed ? (challengeStore.active?.mine ?? null) : null))
</script>
<template>
<div class="challenge-outcome">
<div class="line">
<span class="badge" :class="{ outline: !passed }" aria-hidden="true">
<ChallengeIcon :size="18" />
</span>
<div class="text">
<b class="title">{{ passed ? "挑战成功" : "答案对了,挑战还差一点" }}</b>
<span class="sub">{{ passed ? wording : reason }}</span>
</div>
</div>
<div v-if="!passed" class="chips">
<span class="chip ok">
<svg
width="12"
height="12"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="3"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
>
<path d="M5 12.5l4.5 4.5L19 7.5" />
</svg>
测试点全对
</span>
<span v-if="lines.length" class="chip mark">第 {{ lines.join("、") }} 行</span>
</div>
<div v-if="mine" class="tiles">
<div v-if="mine.classRank !== null" class="tile">
<b>第 {{ mine.classRank }} 个</b>
<span>本班做到的</span>
</div>
<div class="tile">
<b>第 {{ mine.siteRank }} 个</b>
<span>全站做到的</span>
</div>
</div>
</div>
</template>
<style scoped>
.challenge-outcome {
display: flex;
flex-direction: column;
gap: 14px;
}
.line {
display: flex;
align-items: center;
gap: 12px;
}
.badge {
width: 34px;
height: 34px;
border-radius: 50%;
flex-shrink: 0;
box-sizing: border-box;
display: inline-flex;
align-items: center;
justify-content: center;
background-color: v-bind("tone.solid");
color: v-bind("tone.onSolid");
}
.badge.outline {
background-color: transparent;
border: 2px solid v-bind("tone.solid");
color: v-bind("tone.solid");
}
.text {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
}
.title {
font-size: 22px;
line-height: 28px;
color: v-bind("tone.text");
}
.sub {
font-size: 14px;
opacity: 0.75;
}
.chips {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.chip {
display: inline-flex;
align-items: center;
gap: 4px;
height: 26px;
padding: 0 9px;
border-radius: 13px;
font-size: 13px;
}
.chip.ok {
background-color: rgba(24, 160, 88, 0.12);
color: #18a058;
}
.chip.mark {
background-color: v-bind("tone.soft");
color: v-bind("tone.text");
font-weight: 600;
}
.tiles {
display: flex;
gap: 10px;
}
.tile {
flex: 1;
display: flex;
flex-direction: column;
padding: 10px 14px;
border-radius: 8px;
border: 1px solid v-bind("tone.border");
background-color: v-bind("tone.soft");
}
.tile b {
font-size: 24px;
line-height: 30px;
font-variant-numeric: tabular-nums;
color: v-bind("tone.text");
}
.tile span {
font-size: 13px;
opacity: 0.75;
}
</style>
@@ -1,6 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { useThemeVars } from "naive-ui" import { useThemeVars } from "naive-ui"
import { useLessonStore } from "shared/store/lesson" import { useLessonStore } from "shared/store/lesson"
import ChallengeList from "./ChallengeList.vue"
/** /**
* 通过之后告诉学生「这节课的下一题是哪道」。 * 通过之后告诉学生「这节课的下一题是哪道」。
@@ -16,6 +17,12 @@ import { useLessonStore } from "shared/store/lesson"
const props = defineProps<{ const props = defineProps<{
/** 当前这道题的展示题号 */ /** 当前这道题的展示题号 */
problemDisplayId: string problemDisplayId: string
/** 刚做对用的语言:挑战按语言分 */
language: string
/** 刚做对的那次提交,换了就重拉「还能挑战」 */
refreshKey: string
/** 这个入口给不给挑战(见 problemPageContext) */
challenges: boolean
}>() }>()
const lessonStore = useLessonStore() const lessonStore = useLessonStore()
@@ -31,6 +38,11 @@ const lesson = computed(() => lessonStore.nextAfter(props.problemDisplayId))
// 设计稿和上下文条都叫「这节课」,不区分是老师布置的还是从同班提交推断的 // 设计稿和上下文条都叫「这节课」,不区分是老师布置的还是从同班提交推断的
const label = "这节课" const label = "这节课"
/** 这节课的几道题:都做完了以后,这几道里还能挑战的列出来(设计稿 StkDone) */
const lessonProblemIds = computed(
() => lessonStore.activity?.problems.map((item) => item.problemDisplayId) ?? [],
)
</script> </script>
<template> <template>
@@ -66,13 +78,18 @@ const label = "这节课"
<template v-else> <template v-else>
<div class="text"> <div class="text">
<span class="headline">{{ label }}都做完了,一共 {{ lesson.total }} 道</span> <span class="headline">{{ label }}都做完了,一共 {{ lesson.total }} 道</span>
<span class="detail">想接着练,可以去题单里挑一个</span> <span v-if="!challenges" class="detail">想接着练,可以去题单里挑一个</span>
</div> </div>
<router-link to="/problemset" class="next secondary"> <router-link v-if="!challenges" to="/problemset" class="next secondary">
<span class="next-label">去题单继续练</span> <span class="next-label">去题单继续练</span>
</router-link> </router-link>
</template> </template>
</div> </div>
<!-- 做完了:先给这节课里还能挑战的,再给题单(设计稿 StkDone)。挑战一条都没有时只剩题单那句 -->
<template v-if="lesson && !lesson.next && challenges">
<ChallengeList :problem-ids="lessonProblemIds" :language="language" :refresh-key="refreshKey" />
<router-link to="/problemset" class="more">去题单继续练</router-link>
</template>
</template> </template>
<style scoped> <style scoped>
@@ -129,6 +146,17 @@ const label = "这节课"
outline-offset: 2px; outline-offset: 2px;
} }
.more {
display: inline-block;
margin-top: 12px;
font-size: 14px;
color: v-bind("theme.primaryColor");
}
.more:hover {
text-decoration: underline;
}
.next.secondary { .next.secondary {
background-color: transparent; background-color: transparent;
border: 1px solid v-bind("theme.primaryColor"); border: 1px solid v-bind("theme.primaryColor");
@@ -13,6 +13,8 @@ import { errorMarkExtensions } from "oj/problem/utils/errorMark"
import { editorFocusTracker } from "oj/problem/utils/symbolKeys" import { editorFocusTracker } from "oj/problem/utils/symbolKeys"
import { hangWrapExtensions } from "shared/extensions/hangIndent" import { hangWrapExtensions } from "shared/extensions/hangIndent"
import EditorToolbar from "./EditorToolbar.vue" import EditorToolbar from "./EditorToolbar.vue"
import ChallengeBar from "./ChallengeBar.vue"
import { useChallengeStore } from "oj/store/challenge"
import { useDraftKey } from "../composables/draftKey" import { useDraftKey } from "../composables/draftKey"
import { useCollabHere, useTeacherCollab } from "../composables/teacherCollab" import { useCollabHere, useTeacherCollab } from "../composables/teacherCollab"
import { useProblemPageContext } from "../composables/problemPageContext" import { useProblemPageContext } from "../composables/problemPageContext"
@@ -99,9 +101,14 @@ const storageKey = useDraftKey()
// 桌面:顶栏 56 + 工具栏 48,协作条再占 40(两栏顶满内容区,见 detail.vue 的 problem-split)。 // 桌面:顶栏 56 + 工具栏 48,协作条再占 40(两栏顶满内容区,见 detail.vue 的 problem-split)。
// 手机:顶栏两行 + 课堂条 + 页签 + 工具栏约 236px,底部还有「运行例子 / 提交」那条约 64px // 手机:顶栏两行 + 课堂条 + 页签 + 工具栏约 236px,底部还有「运行例子 / 提交」那条约 64px
// 挑战中编辑器顶上多一条 44 的挑战条(协作中的老师不显示,见模板)
const challengeStore = useChallengeStore()
const showChallengeBar = computed(
() => ctx.value.challenges && !teacherCollab.value && !!challengeStore.active,
)
const editorHeight = computed(() => { const editorHeight = computed(() => {
const base = isDesktop.value ? 104 : 300 const base = isDesktop.value ? 104 : 300
return `calc(100vh - ${base + (teacherCollab.value ? 40 : 0)}px)` return `calc(100vh - ${base + (teacherCollab.value ? 40 : 0) + (showChallengeBar.value ? 44 : 0)}px)`
}) })
function loadCode() { function loadCode() {
@@ -220,6 +227,7 @@ onBeforeUnmount(() => flowchartStore.attachEditor(null))
<template> <template>
<div class="problem-editor"> <div class="problem-editor">
<CollabBar v-if="teacherCollab" /> <CollabBar v-if="teacherCollab" />
<ChallengeBar v-if="showChallengeBar" />
<EditorToolbar @change-language="changeLanguage" /> <EditorToolbar @change-language="changeLanguage" />
<!-- <!--
协作中教师这边不会落到流程图分支:上面那个 watch 已经把他的语言换成了学生的, 协作中教师这边不会落到流程图分支:上面那个 watch 已经把他的语言换成了学生的,
@@ -21,6 +21,9 @@ import WrongAnswerExplain from "./WrongAnswerExplain.vue"
import LessonNext from "./LessonNext.vue" import LessonNext from "./LessonNext.vue"
import ResultHeader from "./ResultHeader.vue" import ResultHeader from "./ResultHeader.vue"
import SimilarProblems from "./SimilarProblems.vue" import SimilarProblems from "./SimilarProblems.vue"
import ChallengeList from "./ChallengeList.vue"
import ChallengeOutcome from "./ChallengeOutcome.vue"
import { useChallengeStore } from "oj/store/challenge"
import { useLessonStore } from "shared/store/lesson" import { useLessonStore } from "shared/store/lesson"
import { useProblemPageContext } from "../composables/problemPageContext" import { useProblemPageContext } from "../composables/problemPageContext"
import Markdown from "shared/components/Markdown.vue" import Markdown from "shared/components/Markdown.vue"
@@ -109,6 +112,40 @@ const showSimilar = computed(
!lessonStore.includes(problemStore.problem._id), !lessonStore.includes(problemStore.problem._id),
) )
/**
* 挑战中交的这一次:正在挑战的那条判出来的结果(设计稿 StkFail / StkPass)。只有做对的提交
* 才判挑战;进挑战之前那次做对的不算(见 challenge store 的 baselineSubmissionId)
*/
const challengeStore = useChallengeStore()
const challengeVerdict = computed(() => {
const submission = props.submission
if (!submission || props.peer || !ctx.value.challenges) return null
if (submission.result !== SubmissionStatus.accepted) return null
const id = challengeStore.activeId
if (!id || submission.id === challengeStore.baselineSubmissionId) return null
return submission.statisticInfo?.challenges?.find((verdict) => verdict.id === id) ?? null
})
// 做到了:重拉一次,挑战条换成「挑战成功」、拿到本班 / 全站第几个
watch(
() => (challengeVerdict.value?.passed ? props.submission?.id : null),
(id) => {
if (id) void challengeStore.load()
},
)
/**
* 「还能挑战」看哪几道:这道在这节课里就看这节课的几道(课堂条「这节课都做完了」之后),
* 不在就只看这一道
*/
const challengeProblemIds = computed(() => {
const problem = problemStore.problem
if (!problem) return []
const lesson = lessonStore.activity
return lessonStore.includes(problem._id) && lesson
? lesson.problems.map((item) => item.problemDisplayId)
: [problem._id]
})
const msg = computed(() => { const msg = computed(() => {
if (!props.submission) return "" if (!props.submission) return ""
// 走 CompileErrorExplain / RuntimeErrorExplain 那两张中文卡片 // 走 CompileErrorExplain / RuntimeErrorExplain 那两张中文卡片
@@ -298,18 +335,44 @@ const columns: DataTableColumn<JudgeCaseResult>[] = [
<template> <template>
<div v-if="submission"> <div v-if="submission">
<ResultHeader <!-- 挑战中:答案对了就只说挑战成没成,「下一题」「相似题」都不给,免得挤掉挑战 -->
v-if="header" <template v-if="challengeVerdict && challengeStore.active">
:kind="header.kind" <ChallengeOutcome
:title="header.title" :passed="challengeVerdict.passed"
:sub="header.sub" :results="challengeVerdict.results"
:progress="header.progress" :wording="challengeStore.active.wording"
/> />
<LessonNext <ChallengeList
v-if="showLessonNext && problemStore.problem" v-if="challengeVerdict.passed"
:problem-display-id="problemStore.problem._id" :problem-ids="challengeProblemIds"
/> :language="submission.language"
<SimilarProblems v-if="showSimilar" title="再练几道相似的" /> :refresh-key="submission.id"
/>
</template>
<template v-else>
<ResultHeader
v-if="header"
:kind="header.kind"
:title="header.title"
:sub="header.sub"
:progress="header.progress"
/>
<LessonNext
v-if="showLessonNext && problemStore.problem"
:problem-display-id="problemStore.problem._id"
:language="submission.language"
:refresh-key="submission.id"
:challenges="ctx.challenges"
/>
<!-- 不在这节课里(题库入口自己做的):做对之后先给这道题的挑战,再给相似题 -->
<ChallengeList
v-if="showSimilar && ctx.challenges && problemStore.problem"
:problem-ids="[problemStore.problem._id]"
:language="submission.language"
:refresh-key="submission.id"
/>
<SimilarProblems v-if="showSimilar" title="再练几道相似的" />
</template>
<n-flex <n-flex
vertical vertical
v-if=" v-if="
@@ -0,0 +1,24 @@
/**
* 挑战的颜色:紫。课堂条是绿、题单条是蓝、比赛条是黄,挑战不能和它们撞(设计稿「做对后再挑战」)。
* Naive 的主题里没有紫,暗色下换浅一档,深紫放在暗底上看不清
*/
export function useChallengeTone() {
const isDark = useDark()
return computed(() =>
isDark.value
? {
solid: "#b49cf5",
onSolid: "#17112b",
text: "#c9b8ff",
soft: "rgba(180, 156, 245, 0.14)",
border: "rgba(180, 156, 245, 0.35)",
}
: {
solid: "#5b34c4",
onSolid: "#ffffff",
text: "#2c1a66",
soft: "#f4f0fe",
border: "#ddd3f6",
},
)
}
@@ -26,6 +26,11 @@ interface EntryRules {
similar: boolean similar: boolean
/** 通过之后给「这节课的下一题」 */ /** 通过之后给「这节课的下一题」 */
lessonNext: boolean lessonNext: boolean
/**
* 做对之后的挑战(编辑器顶上的挑战条、结果里的「还能挑战」)。题单里通过后 1.5 秒就跳回去,
* 比赛提交不判挑战
*/
challenges: boolean
/** 通过之后 1.5 秒弹强制点评 */ /** 通过之后 1.5 秒弹强制点评 */
reviewAfterAccepted: boolean reviewAfterAccepted: boolean
/** 通过之后 1.5 秒回题单 */ /** 通过之后 1.5 秒回题单 */
@@ -57,6 +62,7 @@ const RULES: Record<ProblemEntry, EntryRules> = {
statusTag: true, statusTag: true,
similar: true, similar: true,
lessonNext: true, lessonNext: true,
challenges: true,
reviewAfterAccepted: true, reviewAfterAccepted: true,
backToProblemSet: false, backToProblemSet: false,
help: true, help: true,
@@ -71,6 +77,7 @@ const RULES: Record<ProblemEntry, EntryRules> = {
// 点进相似题就离开了题单 // 点进相似题就离开了题单
similar: false, similar: false,
lessonNext: false, lessonNext: false,
challenges: false,
// 1.5 秒后要跳回题单页,弹了也会被冲掉 // 1.5 秒后要跳回题单页,弹了也会被冲掉
reviewAfterAccepted: false, reviewAfterAccepted: false,
backToProblemSet: true, backToProblemSet: true,
@@ -86,6 +93,7 @@ const RULES: Record<ProblemEntry, EntryRules> = {
// 撞上同号公开题时反而会在比赛中把题库列给学生 // 撞上同号公开题时反而会在比赛中把题库列给学生
similar: false, similar: false,
lessonNext: false, lessonNext: false,
challenges: false,
reviewAfterAccepted: false, reviewAfterAccepted: false,
backToProblemSet: false, backToProblemSet: false,
help: false, help: false,
+39 -1
View File
@@ -75,6 +75,30 @@ const markField = StateField.define<DecorationSet>({
provide: (field) => EditorView.decorations.from(field), provide: (field) => EditorView.decorations.from(field),
}) })
/**
* 挑战没做到时标出是哪几行(「print 最多 1 次」→ 三个 print 所在的三行),紫底,和挑战
* 那条的颜色一致。和错误标记分开存:答案对了、挑战没做到时没有错误可标,两种也不会同时出现
*/
const setChallengeLines = StateEffect.define<number[]>()
const challengeLineDeco = Decoration.line({ class: "cm-challenge-line" })
const challengeField = StateField.define<DecorationSet>({
create: () => Decoration.none,
update(deco, tr) {
deco = deco.map(tr.changes)
for (const effect of tr.effects) {
if (effect.is(setChallengeLines)) {
const lines = effect.value.filter((line) => line >= 1 && line <= tr.state.doc.lines)
deco = Decoration.set(
lines.map((line) => challengeLineDeco.range(tr.state.doc.line(line).from)),
)
}
}
return deco
},
provide: (field) => EditorView.decorations.from(field),
})
/** /**
* 题目页当前那个编辑器。结果面板和编辑器是兄弟组件,隔着好几层, * 题目页当前那个编辑器。结果面板和编辑器是兄弟组件,隔着好几层,
* 与其一路透传 ref,不如让编辑器挂载时自己登记。只挂在题目页主编辑器上 * 与其一路透传 ref,不如让编辑器挂载时自己登记。只挂在题目页主编辑器上
@@ -98,6 +122,11 @@ const theme = EditorView.baseTheme({
textUnderlineOffset: "3px", textUnderlineOffset: "3px",
backgroundColor: "rgba(208, 48, 80, 0.18)", backgroundColor: "rgba(208, 48, 80, 0.18)",
}, },
// 左边再压一道紫线:光标所在那行的底色会盖住行底色,线不会被盖
".cm-challenge-line": {
backgroundColor: "rgba(91, 52, 196, 0.12)",
boxShadow: "inset 3px 0 0 #8f72e3",
},
".cm-punctuation-mark": { ".cm-punctuation-mark": {
backgroundColor: "rgba(240, 160, 32, 0.35)", backgroundColor: "rgba(240, 160, 32, 0.35)",
outline: "1px solid rgba(240, 160, 32, 0.8)", outline: "1px solid rgba(240, 160, 32, 0.8)",
@@ -105,7 +134,7 @@ const theme = EditorView.baseTheme({
}, },
}) })
export const errorMarkExtensions = [markField, punctuationField, tracker, theme] export const errorMarkExtensions = [markField, punctuationField, challengeField, tracker, theme]
/** 题目页主编辑器(没挂着就是 null)。手机上的符号键往它里面插字 */ /** 题目页主编辑器(没挂着就是 null)。手机上的符号键往它里面插字 */
export function currentEditorView(): EditorView | null { export function currentEditorView(): EditorView | null {
@@ -145,6 +174,15 @@ export function showErrorMark(
}) })
} }
/** 标出挑战没做到的那几行(行号来自判题时的 AST,学生之后改过代码的话会对不上,不滚过去) */
export function showChallengeLines(lines: number[]) {
current?.dispatch({ effects: setChallengeLines.of(lines) })
}
export function clearChallengeLines() {
current?.dispatch({ effects: setChallengeLines.of([]) })
}
export function clearErrorMark() { export function clearErrorMark() {
current?.dispatch({ effects: setMark.of(null) }) current?.dispatch({ effects: setMark.of(null) })
} }
+87
View File
@@ -0,0 +1,87 @@
import { defineStore } from "pinia"
import type { ProblemChallengeGroup } from "@oj2/contract"
import { getChallenges } from "oj/api"
import { useCodeStore } from "oj/store/code"
import { useProblemStore } from "oj/store/problem"
import { useSubmissionStore } from "oj/store/submission"
import { useBreakpoints } from "shared/composables/breakpoints"
/**
* 挑战:做对之后,同一道题加一条写法上的限制再做一遍(契约 challengeSchema)。
*
* 正在挑战哪一条记在路由的 `?challenge=<id>` 上:「去挑战」常常是去另一道题(这节课的
* 别的题),带着 query 跳过去最直接;刷新、返回也还在挑战里。
*
* 这里只管当前这道题的挑战(编辑器顶上那条、结果里的成没成)。课堂条「这节课都做完了」
* 那几道题的列表由 ChallengeList 自己拉。
*/
export const useChallengeStore = defineStore("challenge", () => {
const route = useRoute()
const router = useRouter()
const problemStore = useProblemStore()
const codeStore = useCodeStore()
const { isMobile } = useBreakpoints()
const activeId = computed(() =>
typeof route.query.challenge === "string" ? route.query.challenge : "",
)
/**
* 进挑战那一刻结果页签里挂着的那次提交。它是进挑战之前那次做对的,也判过挑战(没做到),
* 不能拿它说「挑战还差一点」—— 只认之后交的。不用时刻比:机房电脑的钟不一定准
*/
const submissionStore = useSubmissionStore()
const baselineSubmissionId = ref<string | null>(null)
watch(
activeId,
(id) => {
if (id) baselineSubmissionId.value = submissionStore.submission?.id ?? null
},
{ immediate: true },
)
/** 当前这道题、编辑器当前语言下的挑战;没配挑战为 null */
const current = ref<ProblemChallengeGroup | null>(null)
let sequence = 0
async function load() {
const problemId = problemStore.problem?._id
const language = codeStore.code.language
const mine = ++sequence
if (!problemId || language === "Flowchart" || problemStore.problem?.contestId != null) {
current.value = null
return
}
try {
const [group] = await getChallenges([problemId], language)
if (mine === sequence) current.value = group ?? null
} catch {
// 拉不到就没有挑战,不影响做题
if (mine === sequence) current.value = null
}
}
watch([() => problemStore.problem?._id, () => codeStore.code.language], load, {
immediate: true,
})
/** 正在挑战的那条(换了语言、挑战被老师删了就是 null) */
const active = computed(
() => current.value?.challenges.find((item) => item.id === activeId.value) ?? null,
)
function start(problemId: string, challengeId: string) {
// 进挑战先看题和代码:手机上编辑器是单独一个页签
const tab = isMobile.value ? "editor" : "content"
void router.push({
name: "problem",
params: { problemID: problemId },
query: { challenge: challengeId, tab },
})
}
function stop() {
const { challenge: _, ...rest } = route.query
void router.replace({ query: rest })
}
return { activeId, active, current, baselineSubmissionId, load, start, stop }
})
+4 -1
View File
@@ -10,6 +10,7 @@ import type {
ProblemDifficulty, ProblemDifficulty,
JudgeStatus, JudgeStatus,
AstRules, AstRules,
ChallengeInput,
CreateAnnouncementRequest, CreateAnnouncementRequest,
ProblemLanguage, ProblemLanguage,
} from "@oj2/contract" } from "@oj2/contract"
@@ -142,8 +143,10 @@ type ExcludeKeys =
export type BlankProblem = Omit< export type BlankProblem = Omit<
AdminProblem, AdminProblem,
ExcludeKeys | "hint" | "mermaidCode" | "flowchartData" ExcludeKeys | "hint" | "mermaidCode" | "flowchartData" | "challenges"
> & { > & {
/** 提交的挑战:新加的不带 id,后端发(见契约 challengeInputSchema) */
challenges?: Record<string, ChallengeInput[]> | null
id?: number id?: number
// 新建比赛题时由 detail.vue 在提交前写进来 // 新建比赛题时由 detail.vue 在提交前写进来
contestId?: number | null contestId?: number | null
+229
View File
@@ -0,0 +1,229 @@
#!/usr/bin/env bash
#
# 把服务器上的最新数据拉到本机 dev:数据库整库换掉,可选把测试点、题面图片、头像一起同步。
# 统计、画设计稿、实跑验证都要真实数据,原来靠手工备份 → 拷回来 → 恢复,每次都要重踩
# docs/deploy.md「备份与灾难恢复」里那两个坑。
#
# ## 用法(在本机跑)
#
# docker/pull-prod.sh # 只换库
# docker/pull-prod.sh --files # 换库 + 同步测试点、题面图片、头像
# docker/pull-prod.sh --files-only # 只同步文件,库不动
# docker/pull-prod.sh --force # 本机 dev 还连着库也照换(断开它们,dev 会自己重连)
# PROD_HOST=debian docker/pull-prod.sh # ssh 的主机名,默认就是 debian(~/.ssh/config 里的别名)
#
# 换下来的旧库改名成 onlinejudge_prev 留着,后悔了:
#
# docker exec oj2-postgres psql -U onlinejudge -d postgres \
# -c 'drop database onlinejudge' -c 'alter database onlinejudge_prev rename to onlinejudge'
#
# 拉完如果本机代码比线上新(有还没上线的迁移),再跑一次 `bun run db:migrate`。
#
# ## 对服务器只读
#
# 服务器上只跑 pg_dump(单个快照事务,不锁表,学生照常提交)和 rsync 的读端。
# 服务器只有 2 核,pg_dump 会占一个核十几秒 —— 上课时间(工作日 8–17 点)会提醒一句。
#
# ## 几个刻意的做法
#
# - **只导 onlinejudge 这一个库,不用 pg_dumpall**。pg_dumpall 带角色和口令,灌回来会把
# 本机 onlinejudge 的口令改成线上的,.env 里的 DATABASE_URL 当场连不上(deploy.md 的坑 1)。
# pg_dump -Fc + pg_restore --no-owner --no-privileges 只搬数据和结构。
# - **先落盘、验完再灌**。直接 `ssh ... | pg_restore` 网络一断就是半个库。落盘的 dump 先
# pg_restore -l 读一遍目录,读不出来就不往下走。
# - **灌进新库再换名,不在原库上 --clean**。原库连着 dev 的 api / worker 时,--clean 的
# DROP TABLE 会卡在锁上(deploy.md 的坑 2);灌新库不用停 dev,只有最后换名那一下要求
# 没人连着。灌到一半失败,原库原封不动。
# - **dump 存仓库外面**(默认 <仓库上级>/backups,和 backup-db.sh 一处),umask 077:
# 里面有 user.raw_password 明文。只留最近 3 份 prod_pull_*.dump。
# - **文件同步不带 --delete**。本机后台出题时生成的测试点只在本机,镜像过来会把它们删掉。
# 不带 owner / group / 权限:线上是 root 的,本机要你自己和判题沙箱都读得了。
#
# 本机库换成线上的之后,账号口令也都是线上的了 —— 自己在本机改过的测试口令要重设。
# 同 backup-db.sh:被 sh 调起时换成 bash,pipefail 是 bash 专有的
[ -n "${BASH_VERSION:-}" ] || exec bash "$0" "$@"
set -euo pipefail
cd "$(dirname "${BASH_SOURCE[0]}")/.."
REPO_DIR="$PWD"
PROD_HOST="${PROD_HOST:-debian}"
REMOTE_CONTAINER="${REMOTE_CONTAINER:-oj-postgres}"
# 服务器 docker/.env 里的 DATA_DIR
REMOTE_DATA="${REMOTE_DATA:-/root/OJDeploy/data}"
LOCAL_CONTAINER="${LOCAL_CONTAINER:-oj2-postgres}"
DB_USER=onlinejudge
DB_NAME=onlinejudge
INCOMING="${DB_NAME}_incoming"
PREV="${DB_NAME}_prev"
OUT_DIR="${BACKUP_DIR:-$(dirname "$REPO_DIR")/backups}"
KEEP=3
DO_DB=1
DO_FILES=0
FORCE=0
while [ $# -gt 0 ]; do
case "$1" in
--files) DO_FILES=1; shift ;;
--files-only) DO_FILES=1; DO_DB=0; shift ;;
--force) FORCE=1; shift ;;
*) echo "未知参数:$1(可用:--files、--files-only、--force)" >&2; exit 2 ;;
esac
done
say() { printf '\n\033[1;36m==> %s\033[0m\n' "$*"; }
ok() { printf ' \033[32m✓\033[0m %s\n' "$*"; }
warn() { printf ' \033[33m!\033[0m %s\n' "$*"; }
die() { printf '\n\033[1;31m❌ %s\033[0m\n\n' "$*" >&2; exit 1; }
# drop database if exists 在库不存在时会打一条 NOTICE,正常情况不该有噪音
local_psql() { docker exec -e PGOPTIONS="-c client_min_messages=warning" "$LOCAL_CONTAINER" psql -U "$DB_USER" -v ON_ERROR_STOP=1 "$@"; }
# ssh 把参数拼成一行交给远端的 shell 重新拆,'select 1' 这种带空格的参数会被拆成两个。
# 逐个 %q 转义之后再拼,远端拆出来的才和这边写的一样
remote() { ssh -o BatchMode=yes "$PROD_HOST" "$(printf '%q ' "$@")"; }
# 本机除了这个脚本自己以外,还有谁连着 onlinejudge(dev 的 api、worker、开着的 psql)
others_connected() {
local_psql -d postgres -tAc \
"select count(*) from pg_stat_activity where datname = '$DB_NAME' and pid <> pg_backend_pid()" \
| tr -d '[:space:]'
}
# ---------------------------------------------------------------- 自检
say "自检"
remote true 2>/dev/null \
|| die "ssh $PROD_HOST 连不上(BatchMode,不会问口令)。先确认 ssh $PROD_HOST 能直接登进去"
ok "ssh $PROD_HOST 通"
if [ "$DO_DB" -eq 1 ]; then
remote docker exec "$REMOTE_CONTAINER" psql -U "$DB_USER" -d "$DB_NAME" -tAc 'select 1' >/dev/null 2>&1 \
|| die "服务器上连不上 $REMOTE_CONTAINER 里的 $DB_NAME。容器名不对就用 REMOTE_CONTAINER=... 指定"
ok "服务器的 $REMOTE_CONTAINER 能连上"
docker ps --filter "name=^/${LOCAL_CONTAINER}\$" --filter status=running -q | grep -q . \
|| die "本机容器 $LOCAL_CONTAINER 没在跑,先 bun run db:up"
ok "本机的 $LOCAL_CONTAINER 在跑"
# 换名那一步才真正要求没人连着,但在下载之前就先说,省得白等
n=$(others_connected)
if [ "${n:-0}" != 0 ]; then
[ "$FORCE" -eq 1 ] \
|| die "本机还有 $n 个连接连着 $DB_NAME(多半是 bun run dev 的 api / worker)。
停掉 dev 再跑,或者加 --force:换名前断开它们,dev 会自己重连到新库"
warn "本机有 $n 个连接,--force 已指定,换名前会断开"
fi
# 服务器的时钟是 UTC 还是东八区不确定,按 TZ 显式取东八区(大陆没有夏令时)
read -r dow hour < <(TZ=Asia/Shanghai date '+%u %-H')
if [ "$dow" -le 5 ] && [ "$hour" -ge 8 ] && [ "$hour" -lt 17 ]; then
warn "现在是上课时间,服务器只有 2 核,pg_dump 会占一个核十几秒"
fi
fi
# ---------------------------------------------------------------- 导出
if [ "$DO_DB" -eq 1 ]; then
say "从 $PROD_HOST 导出 $DB_NAME"
mkdir -p "$OUT_DIR" || die "建不了目录 $OUT_DIR"
umask 077
stamp=$(date +%Y_%m_%d_%H_%M_%S)
target="$OUT_DIR/prod_pull_${stamp}.dump"
partial="${target}.partial"
trap 'rm -f "$partial"' EXIT INT TERM
started=$(date +%s)
# 不给 docker exec 加 -t、ssh 也不分配 TTY:-Fc 是二进制,经过 TTY 会被改坏
remote docker exec "$REMOTE_CONTAINER" pg_dump -U "$DB_USER" -d "$DB_NAME" -Fc > "$partial" \
|| die "pg_dump 失败(半截文件已删)"
elapsed=$(( $(date +%s) - started ))
# 读得出目录才算完整的 dump。用本机容器里的 pg_restore 读,宿主机不一定装了
toc=$(docker exec -i "$LOCAL_CONTAINER" pg_restore -l < "$partial" 2>/dev/null | grep -c ' TABLE DATA ' || true)
[ "${toc:-0}" -gt 0 ] || die "dump 读不出表数据目录,文件是坏的(已删)"
mv -- "$partial" "$target"
trap - EXIT INT TERM
ok "$(basename "$target") — $(du -h "$target" | cut -f1),$toc 张表的数据,用时 ${elapsed}s"
# 线上此刻的提交数,灌完拿来对。dump 是之前那一刻的快照,所以本机只会 ≤ 它
remote_count=$(remote docker exec "$REMOTE_CONTAINER" psql -U "$DB_USER" -d "$DB_NAME" -tAc \
'select count(*) from submission' | tr -d '[:space:]')
# ---------------------------------------------------------------- 灌进新库
say "灌进本机的 $INCOMING"
local_psql -d postgres -qc "drop database if exists $INCOMING" \
-c "create database $INCOMING owner $DB_USER"
# --exit-on-error:任何一条失败就停,别留一个缺表的库被换上去
docker exec -i "$LOCAL_CONTAINER" pg_restore -U "$DB_USER" -d "$INCOMING" \
--no-owner --no-privileges --exit-on-error < "$target" \
|| die "pg_restore 失败,原来的 $DB_NAME 没动。$INCOMING 留着方便看哪里坏了"
local_count=$(local_psql -d "$INCOMING" -tAc 'select count(*) from submission' | tr -d '[:space:]')
[ "${local_count:-0}" -gt 0 ] || die "灌完 submission 是空的,不换。原来的 $DB_NAME 没动"
if [ -n "$remote_count" ] && [ "$local_count" -gt "$remote_count" ]; then
die "灌完 submission $local_count 条,比线上此刻的 $remote_count 还多 —— 不对劲,不换"
fi
if [ "$local_count" = "${remote_count:-}" ]; then
ok "submission $local_count 条,和线上此刻一样"
else
ok "submission $local_count 条(线上此刻 ${remote_count:-?} 条,差的是导出之后新交的)"
fi
# ---------------------------------------------------------------- 换名
say "换上新库"
n=$(others_connected)
if [ "${n:-0}" != 0 ]; then
[ "$FORCE" -eq 1 ] \
|| die "灌的这会儿又有 $n 个连接连上了 $DB_NAME。停掉 dev 再跑一次换名,或者加 --force。
新库已经在 $INCOMING 里,手工换:
docker exec $LOCAL_CONTAINER psql -U $DB_USER -d postgres \\
-c 'drop database if exists $PREV' \\
-c 'alter database $DB_NAME rename to $PREV' \\
-c 'alter database $INCOMING rename to $DB_NAME'"
fi
# 断开连接和换名放在同一个 psql 里,中间不给 dev 的连接池留重连的空当
local_psql -d postgres -q \
-c "select pg_terminate_backend(pid) from pg_stat_activity where datname = '$DB_NAME' and pid <> pg_backend_pid()" \
-c "drop database if exists $PREV" \
-c "alter database $DB_NAME rename to $PREV" \
-c "alter database $INCOMING rename to $DB_NAME" >/dev/null \
|| die "换名失败。看一下 pg_database 里现在是什么:
docker exec $LOCAL_CONTAINER psql -U $DB_USER -d postgres -c '\\l'"
ok "$DB_NAME 换成了线上的,原来的改名成 $PREV"
# ---------------------------------------------------------------- 只留最近几份
old=$(find "$OUT_DIR" -maxdepth 1 -type f -name 'prod_pull_*.dump' -printf '%T@ %p\n' \
| sort -rn | tail -n +"$((KEEP + 1))" | cut -d' ' -f2-)
if [ -n "$old" ]; then
printf '%s\n' "$old" | while IFS= read -r f; do rm -f -- "$f"; done
ok "删掉 $(printf '%s\n' "$old" | wc -l) 份旧的 dump,只留最近 $KEEP 份"
fi
fi
# ---------------------------------------------------------------- 文件
if [ "$DO_FILES" -eq 1 ]; then
say "同步文件"
command -v rsync >/dev/null || die "本机没装 rsync"
# 线上路径(DATA_DIR 下)→ 本机路径(apps/api/src/config.ts 的默认值)
sync_dir() {
local from="$1" to="$2"
mkdir -p "$to"
rsync -rltz --chmod=Du=rwx,Dgo=rx,Fu=rw,Fgo=r \
"$PROD_HOST:$REMOTE_DATA/$from/" "$to/" \
|| die "rsync $from 失败"
ok "$from → ${to#"$REPO_DIR"/}($(du -sh "$to" | cut -f1))"
}
sync_dir backend/test_case "$REPO_DIR/data/test_case"
sync_dir backend/public/upload "$REPO_DIR/data/upload"
sync_dir backend/public/avatar "$REPO_DIR/data/avatar"
fi
say "完成"
if [ "$DO_DB" -eq 1 ]; then
printf ' 本机代码比线上新的话,再跑一次:bun run db:migrate\n'
printf ' 账号口令现在都是线上的了,本机改过的测试口令要重设\n\n'
fi
+7
View File
@@ -161,6 +161,13 @@ WebSocket)→ 后台判题机列表在线 → 后台题目列表翻页 → 带
> **旧栈已经不可逆地下线**(`0002_drop_django_leftovers` 删掉了 Django 的框架表并已在 > **旧栈已经不可逆地下线**(`0002_drop_django_leftovers` 删掉了 Django 的框架表并已在
> 生产库执行完毕),所以「停新栈起旧栈」不再是退路,**唯一退路是从数据库备份恢复**。 > 生产库执行完毕),所以「停新栈起旧栈」不再是退路,**唯一退路是从数据库备份恢复**。
### 把线上数据拉到本机
`docker/pull-prod.sh`(在本机跑)经 `ssh debian` 只导 `onlinejudge` 一个库(`pg_dump -Fc`,
不带角色,所以不会踩上面的坑 1),先灌进 `onlinejudge_incoming`、对过行数再换名(不用停 dev,
避开坑 2),换下来的旧库留作 `onlinejudge_prev`。`--files` 顺带同步测试点、题面图片、头像。
对服务器只读;拉完本机的账号口令都是线上的。用法和刻意的做法在脚本头部。
## 把库搬进 OJ2(一次性,顺带 PostgreSQL 16 → 18、Redis 7 → 8) ## 把库搬进 OJ2(一次性,顺带 PostgreSQL 16 → 18、Redis 7 → 8)
`docker/move-db-into-oj2.sh` 把服务器上旧栈 compose 起的 postgres / redis 换成本栈的。 `docker/move-db-into-oj2.sh` 把服务器上旧栈 compose 起的 postgres / redis 换成本栈的。
+55
View File
@@ -11,6 +11,10 @@ import { problemLanguageSchema, runnableLanguageSchema } from "./language"
import { import {
astRuleSchema, astRuleSchema,
astRulesSchema, astRulesSchema,
CHALLENGE_RULES_MAX,
CHALLENGE_WORDING_MAX,
CHALLENGES_PER_LANGUAGE_MAX,
problemChallengesSchema,
problemDifficultySchema, problemDifficultySchema,
sqlConfigSchema, sqlConfigSchema,
sqlDisplaySchema, sqlDisplaySchema,
@@ -945,6 +949,7 @@ export const adminProblemSchema = z.object({
flowchartData: z.record(z.string(), z.unknown()), flowchartData: z.record(z.string(), z.unknown()),
flowchartHint: z.string().nullable(), flowchartHint: z.string().nullable(),
astRules: astRulesSchema.nullable(), astRules: astRulesSchema.nullable(),
challenges: problemChallengesSchema.nullable(),
answers: z.array(problemAnswerSchema), answers: z.array(problemAnswerSchema),
prompt: z.string().nullable(), prompt: z.string().nullable(),
sqlConfig: sqlConfigSchema.nullable(), sqlConfig: sqlConfigSchema.nullable(),
@@ -963,6 +968,21 @@ export const adminProblemSchema = z.object({
*/ */
export const PROBLEM_TAG_MAX_LENGTH = 32 export const PROBLEM_TAG_MAX_LENGTH = 32
/**
* 出题页提交的一条挑战。新加的不带 id,后端发;规则改过的后端也换新 id(见契约
* challengeSchema)。规则的语义校验(这种语言有没有这个语法)在后端 astRulesError,同 astRules
*/
export const challengeInputSchema = z.object({
id: z.string().max(32).optional(),
wording: z.string().trim().min(1).max(CHALLENGE_WORDING_MAX),
rules: z.array(astRuleSchema).min(1).max(CHALLENGE_RULES_MAX),
})
export const challengeInputsSchema = z.record(
z.string(),
z.array(challengeInputSchema).max(CHALLENGES_PER_LANGUAGE_MAX),
)
export const createProblemRequestSchema = z.object({ export const createProblemRequestSchema = z.object({
_id: z.string().trim().min(1).max(32), _id: z.string().trim().min(1).max(32),
title: z.string().trim().min(1).max(1024), title: z.string().trim().min(1).max(1024),
@@ -1002,6 +1022,11 @@ export const createProblemRequestSchema = z.object({
flowchartData: z.record(z.string(), z.unknown()).nullable().default(null), flowchartData: z.record(z.string(), z.unknown()).nullable().default(null),
flowchartHint: z.string().nullable().default(null), flowchartHint: z.string().nullable().default(null),
astRules: astRulesSchema.nullable().default(null), astRules: astRulesSchema.nullable().default(null),
/**
* 不带这个键 = 不动原来的挑战。上线那一刻浏览器里还开着旧版出题页,它保存时不带挑战,
* 给个默认 null 的话一保存就把挑战清了;null 才是「清空」
*/
challenges: challengeInputsSchema.nullable().optional(),
// order_sensitive 缺省补 false —— 与旧后端 SQLConfigSerializer 的 // order_sensitive 缺省补 false —— 与旧后端 SQLConfigSerializer 的
// `BooleanField(default=False)` 一致 // `BooleanField(default=False)` 一致
sqlConfig: sqlConfigSchema sqlConfig: sqlConfigSchema
@@ -1122,6 +1147,33 @@ export const astCheckResponseSchema = z.object({
), ),
}) })
/**
* 出题页「挑战」的候选:拿这道题历史上做对的代码,找「一批人用另一种写法也做对了」的地方。
* 只是给老师挑的素材 —— 机械找出来的会有坏的(拆掉 elif 换成一串 if 也算「不用 elif」),
* 所以每条都带一份做到的人的真实代码,好不好老师看了再加
*/
export const challengeSuggestionsRequestSchema = z.object({
language: runnableLanguageSchema,
})
export const challengeSuggestionSchema = z.object({
/** 默认说法,老师加上之后可以改 */
wording: z.string(),
rules: z.array(astRuleSchema),
/** 这样做对过的人数 */
did: z.number().int(),
/** 第一次做对时就这样写的人占几成(%);「少用 print」这类没有 */
firstShare: z.number().int().nullable(),
/** 一份做到的人的代码 */
example: z.string(),
})
export const challengeSuggestionsResponseSchema = z.object({
/** 这种语言做对过的人数 */
acCount: z.number().int(),
suggestions: z.array(challengeSuggestionSchema),
})
/** AI 按题面和标准答案想几组测试输入(输出由标准答案在判题机上跑,不让 AI 编) */ /** AI 按题面和标准答案想几组测试输入(输出由标准答案在判题机上跑,不让 AI 编) */
export const generateTestInputsRequestSchema = z.object({ export const generateTestInputsRequestSchema = z.object({
description: z.string().max(64 * 1024), description: z.string().max(64 * 1024),
@@ -1180,6 +1232,9 @@ export type GenerateSqlTestCaseResponse = z.infer<typeof generateSqlTestCaseResp
export type TestCaseFiles = z.infer<typeof testCaseFilesSchema> export type TestCaseFiles = z.infer<typeof testCaseFilesSchema>
export type AstCheckRequest = z.input<typeof astCheckRequestSchema> export type AstCheckRequest = z.input<typeof astCheckRequestSchema>
export type AstCheckResponse = z.infer<typeof astCheckResponseSchema> export type AstCheckResponse = z.infer<typeof astCheckResponseSchema>
export type ChallengeInput = z.input<typeof challengeInputSchema>
export type ChallengeSuggestion = z.infer<typeof challengeSuggestionSchema>
export type ChallengeSuggestionsResponse = z.infer<typeof challengeSuggestionsResponseSchema>
export type GenerateTestInputsRequest = z.input<typeof generateTestInputsRequestSchema> export type GenerateTestInputsRequest = z.input<typeof generateTestInputsRequestSchema>
export type GenerateTestInputsResponse = z.infer<typeof generateTestInputsResponseSchema> export type GenerateTestInputsResponse = z.infer<typeof generateTestInputsResponseSchema>
+7
View File
@@ -88,6 +88,8 @@ export const classBoardProblemSchema = z.object({
title: z.string(), title: z.string(),
/** SQL 题不受布置的语言限制:格子数的是 SQL 交的,点进提交列表也按 SQL 筛 */ /** SQL 题不受布置的语言限制:格子数的是 SQL 交的,点进提交列表也按 SQL 筛 */
isSql: z.boolean(), isSql: z.boolean(),
/** 这道题配了挑战(见 problem.ts 的 challengeSchema):题卡片写挑战成功几人 */
hasChallenges: z.boolean(),
}) })
export const classBoardCellSchema = z.object({ export const classBoardCellSchema = z.object({
@@ -128,6 +130,11 @@ export const classBoardCellSchema = z.object({
trail: z.array(z.object({ id: z.string(), result: judgeStatusSchema, createTime: z.string() })), trail: z.array(z.object({ id: z.string(), result: judgeStatusSchema, createTime: z.string() })),
/** 今天第几次代码提交做对的,1 = 一次就对;今天之前就做对了、今天没靠代码做对的为 null */ /** 今天第几次代码提交做对的,1 = 一次就对;今天之前就做对了、今天没靠代码做对的为 null */
acceptedTry: z.number().int().nullable(), acceptedTry: z.number().int().nullable(),
/**
* 挑战:done = 做到了这道题的一条挑战(不限今天);doing = 做对之后今天又交了、还没做到 ——
* 做完了的学生是在挑战还是闲着,老师一眼看得出。没配挑战的题、没做对的格子为 null
*/
challenge: z.enum(["done", "doing"]).nullable(),
}) })
export const classBoardStudentSchema = z.object({ export const classBoardStudentSchema = z.object({
+62
View File
@@ -355,6 +355,62 @@ export const astRequirementSchema = z.object({
/** 按语言分组,与 astRulesSchema 同一套键 */ /** 按语言分组,与 astRulesSchema 同一套键 */
export const astRequirementsSchema = z.record(z.string(), z.array(astRequirementSchema)) export const astRequirementsSchema = z.record(z.string(), z.array(astRequirementSchema))
/**
* 挑战:做对之后,同一道题加一条写法上的限制再做一遍(「整首歌只用 1 次 print」)。
* 判定沿用 AST 规则,几条规则同时满足才算做到 —— 单写「用 while」的话学生留着原来的
* for、再补一个空 while 就过了,所以常配成「用 while + 不用 for」。
*
* 和 astRules 一样按语言分组。`id` 是谁做到了(challenge_success)的记账键:
* 规则一改就换新 id,旧的做到记录跟着旧规则作废,只改说法不换。
* `scannedAt` 是后端记的「已经拿历史上做对的代码补过一遍记录」,见 services/challenge.ts。
*/
export const challengeSchema = z.object({
id: z.string(),
/** 给学生看的说法,例如「整首歌只用 1 次 print」 */
wording: z.string(),
rules: z.array(astRuleSchema),
scannedAt: z.string().optional(),
})
export const problemChallengesSchema = z.record(z.string(), z.array(challengeSchema))
/** 挑战说法的长度上限:要在结果页签的一行里放得下 */
export const CHALLENGE_WORDING_MAX = 24
/** 一条挑战最多几条规则、一道题一种语言最多几条挑战 */
export const CHALLENGE_RULES_MAX = 4
export const CHALLENGES_PER_LANGUAGE_MAX = 4
/** 我做到了一条挑战:本班、全站各是第几个做到的 */
export const challengeMineSchema = z.object({
at: z.string(),
siteRank: z.number().int(),
/** 没有班级(老师、自己注册的号)时为 null */
classRank: z.number().int().nullable(),
})
/** 下发给学生的一条挑战:只有说法和人数,规则原文不出去(同 astRequirements) */
export const challengeItemSchema = z.object({
id: z.string(),
wording: z.string(),
/** 全站做到的人数 */
doneCount: z.number().int(),
mine: challengeMineSchema.nullable(),
})
/** GET /challenges:几道题、某种语言下的挑战 */
export const problemChallengeGroupSchema = z.object({
problemId: z.string(),
title: z.string(),
/** 这种语言做对过这道题的人数(「642 个做对的人里 62 人做到」的 642) */
acCount: z.number().int(),
/** 我用这种语言做对过没有:没做对的挑战不给接 */
solved: z.boolean(),
challenges: z.array(challengeItemSchema),
})
export const challengeListSchema = z.array(problemChallengeGroupSchema)
export const problemDetailSchema = z.object({ export const problemDetailSchema = z.object({
id: z.number().int(), id: z.number().int(),
_id: z.string(), _id: z.string(),
@@ -555,6 +611,12 @@ export type AstRule = z.infer<typeof astRuleSchema>
export type AstRules = z.infer<typeof astRulesSchema> export type AstRules = z.infer<typeof astRulesSchema>
export type AstRequirement = z.infer<typeof astRequirementSchema> export type AstRequirement = z.infer<typeof astRequirementSchema>
export type AstRequirements = z.infer<typeof astRequirementsSchema> export type AstRequirements = z.infer<typeof astRequirementsSchema>
export type Challenge = z.infer<typeof challengeSchema>
export type ProblemChallenges = z.infer<typeof problemChallengesSchema>
export type ChallengeMine = z.infer<typeof challengeMineSchema>
export type ChallengeItem = z.infer<typeof challengeItemSchema>
export type ProblemChallengeGroup = z.infer<typeof problemChallengeGroupSchema>
export type ChallengeList = z.infer<typeof challengeListSchema>
export type ProblemDifficulty = z.infer<typeof problemDifficultySchema> export type ProblemDifficulty = z.infer<typeof problemDifficultySchema>
export type ProblemListItem = z.infer<typeof problemListItemSchema> export type ProblemListItem = z.infer<typeof problemListItemSchema>
export type ProblemList = z.infer<typeof problemListSchema> export type ProblemList = z.infer<typeof problemListSchema>
+22 -4
View File
@@ -51,6 +51,19 @@ export interface JudgeInfo {
data: JudgeCaseResult[] | null data: JudgeCaseResult[] | null
} }
/** 一条 AST 规则的判定结果,judge/ast.ts 的 checkAst 产出 */
export const astResultSchema = z.object({
description: z.string(),
passed: z.boolean(),
/** count_* 规则实际数到的次数,判题机只在这两个引擎上写 */
actual: z.number().optional(),
/**
* 没过的「不能用 X」「X 最多 N 次」:X 出现在学生代码的第几行(1 起,已扣掉题目模板的
* 前置代码)。2026-10 加的,之前的提交没有
*/
lines: z.array(z.number().int()).optional(),
})
/** /**
* 判题产出的统计(`submission.statistic_info` 的 JSONB 原文)。 * 判题产出的统计(`submission.statistic_info` 的 JSONB 原文)。
* *
@@ -67,13 +80,17 @@ export const statisticInfoSchema = z.looseObject({
err_info: z.string().optional(), err_info: z.string().optional(),
time_cost: z.number().optional(), time_cost: z.number().optional(),
memory_cost: z.number().optional(), memory_cost: z.number().optional(),
ast_results: z ast_results: z.array(astResultSchema).optional(),
/**
* 做对的提交拿这道题的每条挑战(problem.challenges 里这种语言的)各判一遍,
* judge/run.ts 写。比赛提交不判。前端在挑战中时按 id 取它正在挑战的那条
*/
challenges: z
.array( .array(
z.object({ z.object({
description: z.string(), id: z.string(),
passed: z.boolean(), passed: z.boolean(),
/** count_* 规则实际数到的次数,判题机只在这两个引擎上写 */ results: z.array(astResultSchema),
actual: z.number().optional(),
}), }),
) )
.optional(), .optional(),
@@ -616,6 +633,7 @@ export type TrialRunResponse =
| { status: "too-much-output" } | { status: "too-much-output" }
export type StatisticInfo = z.infer<typeof statisticInfoSchema> export type StatisticInfo = z.infer<typeof statisticInfoSchema>
export type AstResultInfo = z.infer<typeof astResultSchema>
export type SubmissionTrace = z.infer<typeof submissionTraceSchema> export type SubmissionTrace = z.infer<typeof submissionTraceSchema>
export type CreateSubmissionRequest = z.input<typeof createSubmissionRequestSchema> export type CreateSubmissionRequest = z.input<typeof createSubmissionRequestSchema>
export type SubmissionDetail = z.infer<typeof submissionDetailSchema> export type SubmissionDetail = z.infer<typeof submissionDetailSchema>