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,
"tag": "0037_add_user_token_version",
"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,
JudgeStatus,
LessonLanguage,
ProblemChallenges,
ProblemDifficulty,
ProblemLanguage,
ProblemPermission,
@@ -570,6 +571,8 @@ export const problem = pgTable(
mermaidCode: text("mermaid_code"),
showFlowchart: boolean("show_flowchart").default(false).notNull(),
astRules: jsonb("ast_rules").$type<AstRules>(),
/** 做对之后的挑战,按语言分组。写入侧在 admin/problem.ts 校验,见契约 challengeSchema */
challenges: jsonb().$type<ProblemChallenges>(),
sqlConfig: jsonb("sql_config").$type<SqlConfig>(),
sqlDisplay: jsonb("sql_display").$type<SqlDisplay>(),
},
@@ -1467,3 +1470,59 @@ export const classLesson = pgTable(
}).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 { trialRunRoutes } from "./routes/trial-run"
import { siteRoutes } from "./routes/site"
import { scanPendingChallenges } from "./services/challenge"
import {
bridgeSubmissionEvents,
isAllowedWebSocketOrigin,
@@ -165,3 +166,6 @@ const server = Bun.serve<SubmissionSocketData>({
await bridgeSubmissionEvents(server)
startSessionSweep()
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
/** count_* 引擎实际数到的次数。失败时前端拿它补一句「当前 N 次」 */
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
@@ -266,47 +278,60 @@ function evaluateRule(root: Node, rule: AstRule, language: string): AstResult |
description: describeAstRule(rule, language),
passed: hasNode(root, nodeType),
}
case "must_not_exist_node":
return {
description: describeAstRule(rule, language),
passed: !hasNode(root, nodeType),
}
case "must_not_exist_node": {
const nodes = collectNodes(root, nodeType)
return withLines(
{ description: describeAstRule(rule, language), passed: nodes.length === 0 },
nodes,
)
}
case "count_node": {
const count = collectNodes(root, nodeType).length
return {
description: describeAstRule(rule, language),
passed: rangePassed(count, rule),
actual: count,
}
const nodes = collectNodes(root, nodeType)
return withLines(
{
description: describeAstRule(rule, language),
passed: rangePassed(nodes.length, rule),
actual: nodes.length,
},
// 次数太少时没有「哪几行」可标
rule.max !== undefined || rule.exact !== undefined ? nodes : [],
)
}
case "must_call_function":
return {
description: describeAstRule(rule, language),
passed: functionCalls(root, target, language).length > 0,
}
case "must_not_call_function":
return {
description: describeAstRule(rule, language),
passed: functionCalls(root, target, language).length === 0,
}
case "must_not_call_function": {
const calls = functionCalls(root, target, language)
return withLines(
{ description: describeAstRule(rule, language), passed: calls.length === 0 },
calls,
)
}
case "count_function_call": {
const count = functionCalls(root, target, language).length
return {
description: describeAstRule(rule, language),
passed: rangePassed(count, rule),
actual: count,
}
const calls = functionCalls(root, target, language)
return withLines(
{
description: describeAstRule(rule, language),
passed: rangePassed(calls.length, rule),
actual: calls.length,
},
rule.max !== undefined || rule.exact !== undefined ? calls : [],
)
}
case "must_call_method":
return {
description: describeAstRule(rule, language),
passed: methodCalls(root, target, language).length > 0,
}
case "must_not_call_method":
return {
description: describeAstRule(rule, language),
passed: methodCalls(root, target, language).length === 0,
}
case "must_not_call_method": {
const calls = methodCalls(root, target, language)
return withLines(
{ description: describeAstRule(rule, language), passed: calls.length === 0 },
calls,
)
}
case "must_use_operator":
return {
description: describeAstRule(rule, language),
@@ -357,3 +382,40 @@ export async function checkAst(
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 { publishAchievementNotification } from "../events"
import { updateAchievementsForSubmission } from "../services/achievements"
import {
challengesFor,
judgeChallenges,
recordChallengeSuccesses,
type ChallengeVerdict,
} from "../services/challenge"
import { recordSolvedAndNotify } from "../services/problemset"
import { checkAst, type AstRule } from "./ast"
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)
@@ -722,6 +741,22 @@ export async function judgeSubmission(job: JudgeJobData) {
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 {
const unlocked = await updateAchievementsForSubmission(row.submission.id, counted)
await publishAchievementNotification(
+61 -5
View File
@@ -4,6 +4,7 @@ import {
addContestProblemRequestSchema,
reorderContestProblemsRequestSchema,
astCheckRequestSchema,
challengeSuggestionsRequestSchema,
generateTestInputsRequestSchema,
isSqlProblem,
TEST_CASE_EDIT_MAX_CASES,
@@ -18,8 +19,10 @@ import {
type AdminProblemListItem,
type AstCheckResponse,
type AstRules,
type ChallengeSuggestionsResponse,
type GenerateTestInputsResponse,
type GenerateSqlTestCaseResponse,
type ProblemChallenges,
type SqlConfig,
type SqlDisplay,
type SqlTestCaseScript,
@@ -35,6 +38,12 @@ import { failure, parseBody, success } from "../../http"
import { astRulesError, checkAst, describeAstRule, pickAstRules } from "../../judge/ast"
import { buildSqlDisplay } from "../../judge/sql"
import { completeChat } from "../../services/ai"
import {
challengesFor,
challengeSuggestions,
normalizeChallenges,
scanPendingChallenges,
} from "../../services/challenge"
import { plainText } from "../../services/hint-diagnosis"
import { contestStatus } from "../../services/contest"
import {
@@ -152,6 +161,7 @@ async function serialize(row: ProblemRow) {
flowchartData: asRecord(row.flowchartData),
flowchartHint: row.flowchartHint,
astRules: row.astRules,
challenges: row.challenges,
answers: Array.isArray(row.answers) ? row.answers : [],
prompt: row.prompt,
sqlConfig: row.sqlConfig,
@@ -271,19 +281,34 @@ type ProblemInput = ReturnType<typeof createProblemRequestSchema.parse>
*/
async function validateProblem(
data: ProblemInput,
): Promise<{ error: string } | { sql: boolean; sqlDisplay: SqlDisplay | null }> {
previousChallenges: ProblemChallenges | null = null,
): Promise<{ error: string } | Validated> {
const checked = commonChecks(data)
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!)
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)连同标签,一个事务 */
async function insertProblem(
data: ProblemInput,
validated: { sql: boolean; sqlDisplay: SqlDisplay | null },
validated: Validated,
owner: { contestId: number | null; createdById: number },
) {
const now = new Date().toISOString()
@@ -292,6 +317,7 @@ async function insertProblem(
.insert(schema.problem)
.values({
...problemValues(data, validated.sql),
challenges: validated.challenges,
...owner,
createTime: now,
lastUpdateTime: now,
@@ -489,6 +515,7 @@ adminProblemRoutes.post("/problems", requireProblemPermission, async (c) => {
contestId: null,
createdById: c.get("user")!.id,
})
void scanPendingChallenges()
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))) {
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)
// 题号唯一性的作用域跟着题目走:公开题在全部公开题里唯一,比赛题在本场比赛内唯一
@@ -529,6 +556,7 @@ adminProblemRoutes.put("/problems/:id", requireProblemPermission, async (c) => {
.update(schema.problem)
.set({
...problemValues(parsed.data, validated.sql),
challenges: validated.challenges,
sqlDisplay: validated.sqlDisplay,
lastUpdateTime: new Date().toISOString(),
})
@@ -537,6 +565,8 @@ adminProblemRoutes.put("/problems/:id", requireProblemPermission, async (c) => {
await setTags(tx, id, parsed.data.tags)
return row!
})
// 新加、改过的挑战拿历史上做对的代码补一遍谁已经做到了,不等它
void scanPendingChallenges()
return success(c, await serialize(updated))
})
@@ -962,6 +992,32 @@ adminProblemRoutes.post("/ast-check", requireProblemPermission, async (c) => {
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 编的输出不可信。
+31
View File
@@ -20,6 +20,7 @@ import { seenToday } from "../auth/presence"
import { getLoginWindow } from "../auth/session"
import { presenceOf } from "../collab/state"
import { db, schema } from "../db"
import { challengeSuccessesOf } from "../services/challenge"
import { failure, parseBody, success } from "../http"
import { isAccepted } from "../judge/status"
import {
@@ -465,6 +466,27 @@ classroomRoutes.get("/classroom/board", requireTeacher, async (c) => {
// 「来了几次」代码和流程图合起来数:那天只画了流程图的也算来过,和 recentDays 同一个口径
const recentActivity = activityRows({ since: windowStart, users: userIds })
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([
ids.length && userIds.length
? db
@@ -619,6 +641,7 @@ classroomRoutes.get("/classroom/board", requireTeacher, async (c) => {
problemDisplayId: problem.displayId,
title: problem.title,
isSql: problem.isSql,
hasChallenges: (liveChallenges.get(problem.id)?.size ?? 0) > 0,
})),
students: roster.map(
(student) =>
@@ -659,6 +682,14 @@ classroomRoutes.get("/classroom/board", requireTeacher, async (c) => {
}))
: [],
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,
+19 -1
View File
@@ -1,8 +1,10 @@
import {
AST_SUPPORTED_LANGUAGES,
isSqlProblem,
normalizeLanguage,
problemListSortSchema,
problemTypeFilterSchema,
type ChallengeList,
type ProblemAuthor,
type ProblemDetail,
type ProblemList,
@@ -28,9 +30,10 @@ import {
} from "drizzle-orm"
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 { astRequirements } from "../judge/ast"
import { challengeList } from "../services/challenge"
import { failure, success } from "../http"
import { JudgeStatus } from "../judge/status"
import {
@@ -340,6 +343,21 @@ problemRoutes.get("/problem-authors", async (c) => {
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) => {
const [target] = await db
.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 type {
AddContestProblemRequest,
ChallengeSuggestionsResponse,
AddProblemsToSetRequest,
BatchProblemTagRequest,
BatchProblemVisibleRequest,
@@ -353,6 +354,16 @@ export function checkAstRules(data: 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 按题面和标准答案想几组测试输入(输出由标准答案跑)
export function generateTestInputs(data: GenerateTestInputsRequest) {
return api.post<GenerateTestInputsResponse>(
@@ -15,6 +15,11 @@ interface Props {
languages: LANGUAGE[]
/** 各语言的标准答案(学生那段代码,不套模板):拿来自测规则 */
answers: Partial<Record<LANGUAGE, string>>
/**
* 挑战的判定规则(ChallengeEditor 里一条挑战一个)。和语法要求是同一套规则,但口径不同:
* 不满足不算错、说法由挑战自己写、标准答案不这么写是常事(挑战就是另一种写法)
*/
challenge?: boolean
}
const props = defineProps<Props>()
@@ -294,8 +299,13 @@ watch(supportedLanguages, (langs) => {
<template>
<div class="astRules">
<div class="head">
<span class="label">语法要求</span>
<div v-if="supportedLanguages.length" class="langs" role="tablist" aria-label="语言">
<span class="label">{{ challenge ? "判定" : "语法要求" }}</span>
<div
v-if="supportedLanguages.length && !challenge"
class="langs"
role="tablist"
aria-label="语言"
>
<button
v-for="lang in supportedLanguages"
:key="lang"
@@ -310,7 +320,8 @@ watch(supportedLanguages, (langs) => {
</button>
</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>
<n-text v-if="unsupportedLanguages.length" depth="3" class="note">
{{ unsupportedLanguages.join("、") }} 检查不了语法要求(判题机只认
@@ -402,6 +413,7 @@ watch(supportedLanguages, (langs) => {
</div>
<div class="ruleRow sub">
<n-input
v-if="!challenge"
:value="rule.message"
size="tiny"
class="message"
@@ -412,7 +424,19 @@ watch(supportedLanguages, (langs) => {
"
@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">
✗ 标准答案自己没过{{
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">
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 PageHeader from "admin/components/PageHeader.vue"
import { getProblemTagList } from "shared/api"
@@ -19,6 +19,7 @@ import {
uploadTestcases,
} from "../api"
import AstRulesEditor from "./components/AstRulesEditor.vue"
import ChallengeEditor from "./components/ChallengeEditor.vue"
import CaseTable from "./components/CaseTable.vue"
import CodePreview from "./components/CodePreview.vue"
import PreviewStrip from "./components/PreviewStrip.vue"
@@ -108,6 +109,8 @@ interface CodeForm {
answers: Record<RunnableLanguage, string>
templates: Record<RunnableLanguage, TemplateParts>
astRules: AstRules | null
/** 做对之后的挑战,按语言分组(见契约 challengeSchema) */
challenges: Record<string, ChallengeInput[]> | null
flowchartMode: FlowchartMode
mermaidCode: string
/** 画布画的参考流程图;null 是老题(只有手写的 Mermaid)或者还没画 */
@@ -135,6 +138,7 @@ const blankForm = (): CodeForm => ({
answers: { Python: "", C: "", "C++": "" },
templates: { Python: blankParts(), C: blankParts(), "C++": blankParts() },
astRules: null,
challenges: null,
flowchartMode: "none",
mermaidCode: "",
flowchartDoc: null,
@@ -428,6 +432,14 @@ watch(
const showHidden = ref(false)
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 templatesForPreview = computed(
() => ({ ...form.templates }) as Partial<Record<LANGUAGE, TemplateParts>>,
@@ -493,6 +505,7 @@ const open = reactive({
answer: true,
cases: true,
write: false,
challenge: false,
flowchart: false,
more: false,
})
@@ -612,6 +625,15 @@ async function loadProblem(data: AdminProblem) {
answers,
templates,
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",
mermaidCode: data.mermaidCode ?? "",
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 (legacyMermaid.value) {
if (!mermaidOk.value)
@@ -804,6 +835,7 @@ async function save() {
: null,
flowchartHint: form.flowchartHint.trim(),
astRules: form.astRules,
challenges: form.challenges,
sqlConfig: null,
sqlDisplay: null,
contestId: props.contestID ? Number(props.contestID) : null,
@@ -1197,6 +1229,24 @@ onMounted(async () => {
</template>
</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">
<button type="button" class="cardHead" @click="open.flowchart = !open.flowchart">
+10
View File
@@ -1,4 +1,5 @@
import type {
ChallengeList,
ClassLessonRequest,
ContestPasswordRequest,
CreateFlowchartRequest,
@@ -431,6 +432,15 @@ export function getSimilarProblems(problemId: string) {
.then((response) => response.map(toProblemRow))
}
// ==================== 挑战 ====================
/** 几道题、某种语言下的挑战(说法、几人做到、我做到没有),见契约 problemChallengeGroupSchema */
export function getChallenges(problemIds: string[], language: string) {
return api.get<ChallengeList>("challenges", {
params: { problems: problemIds.join(","), language },
})
}
// ==================== 流程图相关API ====================
export function submitFlowchart(data: {
+56 -1
View File
@@ -256,6 +256,17 @@ const groupCount = computed(() => {
for (const student of students.value) count[groupOf(student)]++
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 starts = new Set<number>()
@@ -298,7 +309,11 @@ const problemStats = computed(() =>
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 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="stat.wrong ? 'wrong' : 'muted'">错着 {{ stat.wrong }}</span>
<span class="muted">没做 {{ stat.none }}</span>
<span v-if="stat.challenged !== null" class="challenged">
挑战成功 {{ stat.challenged }}
</span>
</span>
</button>
</div>
@@ -651,6 +669,9 @@ function lastText(student: ClassBoardStudent) {
<span class="group-label">
{{ GROUP_LABEL[groupOf(student)] }}
<span class="muted">{{ groupCount[groupOf(student)] }}</span>
<span v-if="groupOf(student) === 'done' && challengingCount" class="challenged">
在挑战 {{ challengingCount }}
</span>
</span>
</td>
</tr>
@@ -709,6 +730,7 @@ function lastText(student: ClassBoardStudent) {
v-if="cell.status === 'tried' && cell.latest"
type="button"
class="cell"
:data-challenge="cell.challenge ?? undefined"
:style="cellStyle(cell)"
:title="cellTitle(cell, student)"
@click="openExplain(i, student.userId)"
@@ -727,6 +749,7 @@ function lastText(student: ClassBoardStudent) {
<button
type="button"
class="cell"
:data-challenge="cell.challenge ?? undefined"
:class="{ picked: trailKey === `${student.userId}:${i}` }"
:style="cellStyle(cell)"
:title="cellTitle(cell, student)"
@@ -747,6 +770,7 @@ function lastText(student: ClassBoardStudent) {
<a
v-else-if="cell.attempts"
class="cell"
:data-challenge="cell.challenge ?? undefined"
:style="cellStyle(cell)"
:href="submissionsHref(student, board.problems[i], !cell.codeAttempts)"
target="_blank"
@@ -757,6 +781,7 @@ function lastText(student: ClassBoardStudent) {
<span
v-else
class="cell"
:data-challenge="cell.challenge ?? undefined"
:style="cellStyle(cell)"
:title="cellTitle(cell, student)"
>
@@ -1220,6 +1245,36 @@ a.cell {
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 {
box-shadow: 0 0 0 2px v-bind("theme.textColor1");
}
+4 -1
View File
@@ -53,9 +53,12 @@ export function cellTitle(
day: string | undefined,
) {
if (cell.status === "accepted") {
return acceptedToday(cell, day)
const when = acceptedToday(cell, day)
? `${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.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">
import { useThemeVars } from "naive-ui"
import { useLessonStore } from "shared/store/lesson"
import ChallengeList from "./ChallengeList.vue"
/**
* 通过之后告诉学生「这节课的下一题是哪道」。
@@ -16,6 +17,12 @@ import { useLessonStore } from "shared/store/lesson"
const props = defineProps<{
/** 当前这道题的展示题号 */
problemDisplayId: string
/** 刚做对用的语言:挑战按语言分 */
language: string
/** 刚做对的那次提交,换了就重拉「还能挑战」 */
refreshKey: string
/** 这个入口给不给挑战(见 problemPageContext) */
challenges: boolean
}>()
const lessonStore = useLessonStore()
@@ -31,6 +38,11 @@ const lesson = computed(() => lessonStore.nextAfter(props.problemDisplayId))
// 设计稿和上下文条都叫「这节课」,不区分是老师布置的还是从同班提交推断的
const label = "这节课"
/** 这节课的几道题:都做完了以后,这几道里还能挑战的列出来(设计稿 StkDone) */
const lessonProblemIds = computed(
() => lessonStore.activity?.problems.map((item) => item.problemDisplayId) ?? [],
)
</script>
<template>
@@ -66,13 +78,18 @@ const label = "这节课"
<template v-else>
<div class="text">
<span class="headline">{{ label }}都做完了,一共 {{ lesson.total }} 道</span>
<span class="detail">想接着练,可以去题单里挑一个</span>
<span v-if="!challenges" class="detail">想接着练,可以去题单里挑一个</span>
</div>
<router-link to="/problemset" class="next secondary">
<router-link v-if="!challenges" to="/problemset" class="next secondary">
<span class="next-label">去题单继续练</span>
</router-link>
</template>
</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>
<style scoped>
@@ -129,6 +146,17 @@ const label = "这节课"
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 {
background-color: transparent;
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 { hangWrapExtensions } from "shared/extensions/hangIndent"
import EditorToolbar from "./EditorToolbar.vue"
import ChallengeBar from "./ChallengeBar.vue"
import { useChallengeStore } from "oj/store/challenge"
import { useDraftKey } from "../composables/draftKey"
import { useCollabHere, useTeacherCollab } from "../composables/teacherCollab"
import { useProblemPageContext } from "../composables/problemPageContext"
@@ -99,9 +101,14 @@ const storageKey = useDraftKey()
// 桌面:顶栏 56 + 工具栏 48,协作条再占 40(两栏顶满内容区,见 detail.vue 的 problem-split)。
// 手机:顶栏两行 + 课堂条 + 页签 + 工具栏约 236px,底部还有「运行例子 / 提交」那条约 64px
// 挑战中编辑器顶上多一条 44 的挑战条(协作中的老师不显示,见模板)
const challengeStore = useChallengeStore()
const showChallengeBar = computed(
() => ctx.value.challenges && !teacherCollab.value && !!challengeStore.active,
)
const editorHeight = computed(() => {
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() {
@@ -220,6 +227,7 @@ onBeforeUnmount(() => flowchartStore.attachEditor(null))
<template>
<div class="problem-editor">
<CollabBar v-if="teacherCollab" />
<ChallengeBar v-if="showChallengeBar" />
<EditorToolbar @change-language="changeLanguage" />
<!--
协作中教师这边不会落到流程图分支:上面那个 watch 已经把他的语言换成了学生的,
@@ -21,6 +21,9 @@ import WrongAnswerExplain from "./WrongAnswerExplain.vue"
import LessonNext from "./LessonNext.vue"
import ResultHeader from "./ResultHeader.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 { useProblemPageContext } from "../composables/problemPageContext"
import Markdown from "shared/components/Markdown.vue"
@@ -109,6 +112,40 @@ const showSimilar = computed(
!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(() => {
if (!props.submission) return ""
// 走 CompileErrorExplain / RuntimeErrorExplain 那两张中文卡片
@@ -298,18 +335,44 @@ const columns: DataTableColumn<JudgeCaseResult>[] = [
<template>
<div v-if="submission">
<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"
/>
<SimilarProblems v-if="showSimilar" title="再练几道相似的" />
<!-- 挑战中:答案对了就只说挑战成没成,「下一题」「相似题」都不给,免得挤掉挑战 -->
<template v-if="challengeVerdict && challengeStore.active">
<ChallengeOutcome
:passed="challengeVerdict.passed"
:results="challengeVerdict.results"
:wording="challengeStore.active.wording"
/>
<ChallengeList
v-if="challengeVerdict.passed"
:problem-ids="challengeProblemIds"
:language="submission.language"
: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
vertical
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
/** 通过之后给「这节课的下一题」 */
lessonNext: boolean
/**
* 做对之后的挑战(编辑器顶上的挑战条、结果里的「还能挑战」)。题单里通过后 1.5 秒就跳回去,
* 比赛提交不判挑战
*/
challenges: boolean
/** 通过之后 1.5 秒弹强制点评 */
reviewAfterAccepted: boolean
/** 通过之后 1.5 秒回题单 */
@@ -57,6 +62,7 @@ const RULES: Record<ProblemEntry, EntryRules> = {
statusTag: true,
similar: true,
lessonNext: true,
challenges: true,
reviewAfterAccepted: true,
backToProblemSet: false,
help: true,
@@ -71,6 +77,7 @@ const RULES: Record<ProblemEntry, EntryRules> = {
// 点进相似题就离开了题单
similar: false,
lessonNext: false,
challenges: false,
// 1.5 秒后要跳回题单页,弹了也会被冲掉
reviewAfterAccepted: false,
backToProblemSet: true,
@@ -86,6 +93,7 @@ const RULES: Record<ProblemEntry, EntryRules> = {
// 撞上同号公开题时反而会在比赛中把题库列给学生
similar: false,
lessonNext: false,
challenges: false,
reviewAfterAccepted: false,
backToProblemSet: false,
help: false,
+39 -1
View File
@@ -75,6 +75,30 @@ const markField = StateField.define<DecorationSet>({
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,不如让编辑器挂载时自己登记。只挂在题目页主编辑器上
@@ -98,6 +122,11 @@ const theme = EditorView.baseTheme({
textUnderlineOffset: "3px",
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": {
backgroundColor: "rgba(240, 160, 32, 0.35)",
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)。手机上的符号键往它里面插字 */
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() {
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,
JudgeStatus,
AstRules,
ChallengeInput,
CreateAnnouncementRequest,
ProblemLanguage,
} from "@oj2/contract"
@@ -142,8 +143,10 @@ type ExcludeKeys =
export type BlankProblem = Omit<
AdminProblem,
ExcludeKeys | "hint" | "mermaidCode" | "flowchartData"
ExcludeKeys | "hint" | "mermaidCode" | "flowchartData" | "challenges"
> & {
/** 提交的挑战:新加的不带 id,后端发(见契约 challengeInputSchema) */
challenges?: Record<string, ChallengeInput[]> | null
id?: number
// 新建比赛题时由 detail.vue 在提交前写进来
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 的框架表并已在
> 生产库执行完毕),所以「停新栈起旧栈」不再是退路,**唯一退路是从数据库备份恢复**。
### 把线上数据拉到本机
`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)
`docker/move-db-into-oj2.sh` 把服务器上旧栈 compose 起的 postgres / redis 换成本栈的。
+55
View File
@@ -11,6 +11,10 @@ import { problemLanguageSchema, runnableLanguageSchema } from "./language"
import {
astRuleSchema,
astRulesSchema,
CHALLENGE_RULES_MAX,
CHALLENGE_WORDING_MAX,
CHALLENGES_PER_LANGUAGE_MAX,
problemChallengesSchema,
problemDifficultySchema,
sqlConfigSchema,
sqlDisplaySchema,
@@ -945,6 +949,7 @@ export const adminProblemSchema = z.object({
flowchartData: z.record(z.string(), z.unknown()),
flowchartHint: z.string().nullable(),
astRules: astRulesSchema.nullable(),
challenges: problemChallengesSchema.nullable(),
answers: z.array(problemAnswerSchema),
prompt: z.string().nullable(),
sqlConfig: sqlConfigSchema.nullable(),
@@ -963,6 +968,21 @@ export const adminProblemSchema = z.object({
*/
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({
_id: z.string().trim().min(1).max(32),
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),
flowchartHint: z.string().nullable().default(null),
astRules: astRulesSchema.nullable().default(null),
/**
* 不带这个键 = 不动原来的挑战。上线那一刻浏览器里还开着旧版出题页,它保存时不带挑战,
* 给个默认 null 的话一保存就把挑战清了;null 才是「清空」
*/
challenges: challengeInputsSchema.nullable().optional(),
// order_sensitive 缺省补 false —— 与旧后端 SQLConfigSerializer 的
// `BooleanField(default=False)` 一致
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 编) */
export const generateTestInputsRequestSchema = z.object({
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 AstCheckRequest = z.input<typeof astCheckRequestSchema>
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 GenerateTestInputsResponse = z.infer<typeof generateTestInputsResponseSchema>
+7
View File
@@ -88,6 +88,8 @@ export const classBoardProblemSchema = z.object({
title: z.string(),
/** SQL 题不受布置的语言限制:格子数的是 SQL 交的,点进提交列表也按 SQL 筛 */
isSql: z.boolean(),
/** 这道题配了挑战(见 problem.ts 的 challengeSchema):题卡片写挑战成功几人 */
hasChallenges: z.boolean(),
})
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() })),
/** 今天第几次代码提交做对的,1 = 一次就对;今天之前就做对了、今天没靠代码做对的为 null */
acceptedTry: z.number().int().nullable(),
/**
* 挑战:done = 做到了这道题的一条挑战(不限今天);doing = 做对之后今天又交了、还没做到 ——
* 做完了的学生是在挑战还是闲着,老师一眼看得出。没配挑战的题、没做对的格子为 null
*/
challenge: z.enum(["done", "doing"]).nullable(),
})
export const classBoardStudentSchema = z.object({
+62
View File
@@ -355,6 +355,62 @@ export const astRequirementSchema = z.object({
/** 按语言分组,与 astRulesSchema 同一套键 */
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({
id: z.number().int(),
_id: z.string(),
@@ -555,6 +611,12 @@ export type AstRule = z.infer<typeof astRuleSchema>
export type AstRules = z.infer<typeof astRulesSchema>
export type AstRequirement = z.infer<typeof astRequirementSchema>
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 ProblemListItem = z.infer<typeof problemListItemSchema>
export type ProblemList = z.infer<typeof problemListSchema>
+22 -4
View File
@@ -51,6 +51,19 @@ export interface JudgeInfo {
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 原文)。
*
@@ -67,13 +80,17 @@ export const statisticInfoSchema = z.looseObject({
err_info: z.string().optional(),
time_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(
z.object({
description: z.string(),
id: z.string(),
passed: z.boolean(),
/** count_* 规则实际数到的次数,判题机只在这两个引擎上写 */
actual: z.number().optional(),
results: z.array(astResultSchema),
}),
)
.optional(),
@@ -616,6 +633,7 @@ export type TrialRunResponse =
| { status: "too-much-output" }
export type StatisticInfo = z.infer<typeof statisticInfoSchema>
export type AstResultInfo = z.infer<typeof astResultSchema>
export type SubmissionTrace = z.infer<typeof submissionTraceSchema>
export type CreateSubmissionRequest = z.input<typeof createSubmissionRequestSchema>
export type SubmissionDetail = z.infer<typeof submissionDetailSchema>