Compare commits

...

109 Commits

Author SHA1 Message Date
df45b8f545 fix
Some checks failed
Deploy / deploy (build, debian, 22, /root/OJDeploy/data/clientnext) (push) Has been cancelled
Deploy / deploy (build:staging, school, 8822, /root/OJ/data/dist) (push) Has been cancelled
2026-06-14 09:53:04 -06:00
be0bc87d47 add state for submitting button
Some checks failed
Deploy / deploy (build, debian, 22, /root/OJDeploy/data/clientnext) (push) Has been cancelled
Deploy / deploy (build:staging, school, 8822, /root/OJ/data/dist) (push) Has been cancelled
2026-06-14 08:52:47 -06:00
43a5c923b4 Plan submit formatting button state 2026-06-14 08:46:35 -06:00
62d75b6e06 Document submit formatting button state 2026-06-14 08:43:19 -06:00
bd4461d2bc fix 2026-06-14 08:36:32 -06:00
12342f7f79 feat(problem): auto-format Python3/C/C++ code before submit 2026-06-14 08:12:31 -06:00
dad65c4bef feat(api): add formatCode endpoint for pre-submit formatting
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-14 08:09:38 -06:00
d16ee709b2 update
Some checks failed
Deploy / deploy (build, debian, 22, /root/OJDeploy/data/clientnext) (push) Has been cancelled
Deploy / deploy (build:staging, school, 8822, /root/OJ/data/dist) (push) Has been cancelled
2026-06-09 05:04:40 -06:00
77db837af3 fix
Some checks failed
Deploy / deploy (build, debian, 22, /root/OJDeploy/data/clientnext) (push) Has been cancelled
Deploy / deploy (build:staging, school, 8822, /root/OJ/data/dist) (push) Has been cancelled
2026-06-07 05:57:48 -06:00
4b05086ba1 use default props
Some checks failed
Deploy / deploy (build, debian, 22, /root/OJDeploy/data/clientnext) (push) Has been cancelled
Deploy / deploy (build:staging, school, 8822, /root/OJ/data/dist) (push) Has been cancelled
2026-06-06 05:49:02 -06:00
31d7f4d274 fix
Some checks failed
Deploy / deploy (build, debian, 22, /root/OJDeploy/data/clientnext) (push) Has been cancelled
Deploy / deploy (build:staging, school, 8822, /root/OJ/data/dist) (push) Has been cancelled
2026-06-05 10:46:26 -06:00
45a0638b7e fix
Some checks failed
Deploy / deploy (build, debian, 22, /root/OJDeploy/data/clientnext) (push) Has been cancelled
Deploy / deploy (build:staging, school, 8822, /root/OJ/data/dist) (push) Has been cancelled
2026-06-05 10:39:35 -06:00
9920bc4aed test
Some checks failed
Deploy / deploy (build, debian, 22, /root/OJDeploy/data/clientnext) (push) Has been cancelled
Deploy / deploy (build:staging, school, 8822, /root/OJ/data/dist) (push) Has been cancelled
2026-06-05 10:33:56 -06:00
6a97c7ee6e update
Some checks failed
Deploy / deploy (build, debian, 22, /root/OJDeploy/data/clientnext) (push) Has been cancelled
Deploy / deploy (build:staging, school, 8822, /root/OJ/data/dist) (push) Has been cancelled
2026-06-05 10:06:46 -06:00
fe51ad94cc update
Some checks failed
Deploy / deploy (build, debian, 22, /root/OJDeploy/data/clientnext) (push) Has been cancelled
Deploy / deploy (build:staging, school, 8822, /root/OJ/data/dist) (push) Has been cancelled
2026-06-05 09:59:08 -06:00
0a0d53124d fix
Some checks failed
Deploy / deploy (build, debian, 22, /root/OJDeploy/data/clientnext) (push) Has been cancelled
Deploy / deploy (build:staging, school, 8822, /root/OJ/data/dist) (push) Has been cancelled
2026-06-05 09:46:52 -06:00
f9d7c2ff92 update
Some checks failed
Deploy / deploy (build, debian, 22, /root/OJDeploy/data/clientnext) (push) Has been cancelled
Deploy / deploy (build:staging, school, 8822, /root/OJ/data/dist) (push) Has been cancelled
2026-06-05 09:03:38 -06:00
324e85d2c0 update
Some checks failed
Deploy / deploy (build, debian, 22, /root/OJDeploy/data/clientnext) (push) Has been cancelled
Deploy / deploy (build:staging, school, 8822, /root/OJ/data/dist) (push) Has been cancelled
2026-06-05 08:52:00 -06:00
4ef2738afd fix
Some checks failed
Deploy / deploy (build, debian, 22, /root/OJDeploy/data/clientnext) (push) Has been cancelled
Deploy / deploy (build:staging, school, 8822, /root/OJ/data/dist) (push) Has been cancelled
2026-06-05 00:59:56 -06:00
89a6e79489 update
Some checks failed
Deploy / deploy (build, debian, 22, /root/OJDeploy/data/clientnext) (push) Has been cancelled
Deploy / deploy (build:staging, school, 8822, /root/OJ/data/dist) (push) Has been cancelled
2026-06-05 00:58:55 -06:00
1dac639003 update
Some checks failed
Deploy / deploy (build, debian, 22, /root/OJDeploy/data/clientnext) (push) Has been cancelled
Deploy / deploy (build:staging, school, 8822, /root/OJ/data/dist) (push) Has been cancelled
2026-06-05 00:52:02 -06:00
9344a6e648 update
Some checks failed
Deploy / deploy (build, debian, 22, /root/OJDeploy/data/clientnext) (push) Has been cancelled
Deploy / deploy (build:staging, school, 8822, /root/OJ/data/dist) (push) Has been cancelled
2026-06-05 00:03:29 -06:00
d9a1ee28c6 fix
Some checks failed
Deploy / deploy (build, debian, 22, /root/OJDeploy/data/clientnext) (push) Has been cancelled
Deploy / deploy (build:staging, school, 8822, /root/OJ/data/dist) (push) Has been cancelled
2026-06-04 23:59:23 -06:00
0b2383bb48 fix
Some checks failed
Deploy / deploy (build, debian, 22, /root/OJDeploy/data/clientnext) (push) Has been cancelled
Deploy / deploy (build:staging, school, 8822, /root/OJ/data/dist) (push) Has been cancelled
2026-06-04 09:03:17 -06:00
cd5ab41981 update
Some checks failed
Deploy / deploy (build, debian, 22, /root/OJDeploy/data/clientnext) (push) Has been cancelled
Deploy / deploy (build:staging, school, 8822, /root/OJ/data/dist) (push) Has been cancelled
2026-06-04 09:00:53 -06:00
8549b6c177 revert
Some checks failed
Deploy / deploy (build, debian, 22, /root/OJDeploy/data/clientnext) (push) Has been cancelled
Deploy / deploy (build:staging, school, 8822, /root/OJ/data/dist) (push) Has been cancelled
2026-06-04 08:56:47 -06:00
4aa0072567 fix
Some checks failed
Deploy / deploy (build, debian, 22, /root/OJDeploy/data/clientnext) (push) Has been cancelled
Deploy / deploy (build:staging, school, 8822, /root/OJ/data/dist) (push) Has been cancelled
2026-06-04 08:54:04 -06:00
41c4fdbc5c update
Some checks failed
Deploy / deploy (build, debian, 22, /root/OJDeploy/data/clientnext) (push) Has been cancelled
Deploy / deploy (build:staging, school, 8822, /root/OJ/data/dist) (push) Has been cancelled
2026-06-04 08:44:54 -06:00
33b6e35d6b add ai report
Some checks failed
Deploy / deploy (build, debian, 22, /root/OJDeploy/data/clientnext) (push) Has been cancelled
Deploy / deploy (build:staging, school, 8822, /root/OJ/data/dist) (push) Has been cancelled
2026-06-04 08:06:36 -06:00
b3edf5383a update
Some checks failed
Deploy / deploy (build, debian, 22, /root/OJDeploy/data/clientnext) (push) Has been cancelled
Deploy / deploy (build:staging, school, 8822, /root/OJ/data/dist) (push) Has been cancelled
2026-06-04 07:11:47 -06:00
2e31040b79 update
Some checks failed
Deploy / deploy (build, debian, 22, /root/OJDeploy/data/clientnext) (push) Has been cancelled
Deploy / deploy (build:staging, school, 8822, /root/OJ/data/dist) (push) Has been cancelled
2026-06-03 09:39:30 -06:00
f6232da3ba fix
Some checks failed
Deploy / deploy (build, debian, 22, /root/OJDeploy/data/clientnext) (push) Has been cancelled
Deploy / deploy (build:staging, school, 8822, /root/OJ/data/dist) (push) Has been cancelled
2026-06-03 09:06:08 -06:00
e33ef710af add a chart
Some checks failed
Deploy / deploy (build, debian, 22, /root/OJDeploy/data/clientnext) (push) Has been cancelled
Deploy / deploy (build:staging, school, 8822, /root/OJ/data/dist) (push) Has been cancelled
2026-06-03 08:54:10 -06:00
0c165d61ff revert
Some checks failed
Deploy / deploy (build, debian, 22, /root/OJDeploy/data/clientnext) (push) Has been cancelled
Deploy / deploy (build:staging, school, 8822, /root/OJ/data/dist) (push) Has been cancelled
2026-06-03 08:16:08 -06:00
e9a416b6b4 fix
Some checks failed
Deploy / deploy (build, debian, 22, /root/OJDeploy/data/clientnext) (push) Has been cancelled
Deploy / deploy (build:staging, school, 8822, /root/OJ/data/dist) (push) Has been cancelled
2026-06-03 08:05:43 -06:00
39dbe143cb fix
Some checks failed
Deploy / deploy (build, debian, 22, /root/OJDeploy/data/clientnext) (push) Has been cancelled
Deploy / deploy (build:staging, school, 8822, /root/OJ/data/dist) (push) Has been cancelled
2026-06-03 07:41:14 -06:00
d8363b997a fix 2026-06-03 07:38:05 -06:00
7f51544615 update
Some checks failed
Deploy / deploy (build, debian, 22, /root/OJDeploy/data/clientnext) (push) Has been cancelled
Deploy / deploy (build:staging, school, 8822, /root/OJ/data/dist) (push) Has been cancelled
2026-06-03 07:29:06 -06:00
d1875619ec add wc
Some checks failed
Deploy / deploy (build, debian, 22, /root/OJDeploy/data/clientnext) (push) Has been cancelled
Deploy / deploy (build:staging, school, 8822, /root/OJ/data/dist) (push) Has been cancelled
2026-06-03 07:19:53 -06:00
aeadb46ffa update
Some checks failed
Deploy / deploy (build, debian, 22, /root/OJDeploy/data/clientnext) (push) Has been cancelled
Deploy / deploy (build:staging, school, 8822, /root/OJ/data/dist) (push) Has been cancelled
2026-06-03 00:01:34 -06:00
b510c305d5 update
Some checks failed
Deploy / deploy (build, debian, 22, /root/OJDeploy/data/clientnext) (push) Has been cancelled
Deploy / deploy (build:staging, school, 8822, /root/OJ/data/dist) (push) Has been cancelled
2026-06-02 23:23:00 -06:00
cd81fd1e10 fix
Some checks failed
Deploy / deploy (build, debian, 22, /root/OJDeploy/data/clientnext) (push) Has been cancelled
Deploy / deploy (build:staging, school, 8822, /root/OJ/data/dist) (push) Has been cancelled
2026-06-02 23:13:10 -06:00
a02e6df604 feat: open stuck problems and AC trend routes to Teacher Admin
Some checks failed
Deploy / deploy (build, debian, 22, /root/OJDeploy/data/clientnext) (push) Has been cancelled
Deploy / deploy (build:staging, school, 8822, /root/OJ/data/dist) (push) Has been cancelled
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-06-02 18:17:32 -06:00
2fbcbd07c5 feat: update frontend for four-tier role system
Add Student Admin and Teacher Admin roles to constants, types, store,
permissions, routes, and admin UI. Teacher Admin sees contests and
problemsets in sidebar; Student Admin sees only problems.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-06-02 18:13:39 -06:00
8444d6e21a update
Some checks failed
Deploy / deploy (build, debian, 22, /root/OJDeploy/data/clientnext) (push) Has been cancelled
Deploy / deploy (build:staging, school, 8822, /root/OJ/data/dist) (push) Has been cancelled
2026-06-02 10:48:21 -06:00
0460a2f7a0 update
Some checks failed
Deploy / deploy (build, debian, 22, /root/OJDeploy/data/clientnext) (push) Has been cancelled
Deploy / deploy (build:staging, school, 8822, /root/OJ/data/dist) (push) Has been cancelled
2026-06-02 10:31:45 -06:00
80e916e817 fix
Some checks failed
Deploy / deploy (build, debian, 22, /root/OJDeploy/data/clientnext) (push) Has been cancelled
Deploy / deploy (build:staging, school, 8822, /root/OJ/data/dist) (push) Has been cancelled
2026-06-01 21:09:34 +08:00
5349e8ed6d fix
Some checks failed
Deploy / deploy (build, debian, 22, /root/OJDeploy/data/clientnext) (push) Has been cancelled
Deploy / deploy (build:staging, school, 8822, /root/OJ/data/dist) (push) Has been cancelled
2026-06-01 05:51:52 -06:00
cb7743367a remove contest type
Some checks failed
Deploy / deploy (build, debian, 22, /root/OJDeploy/data/clientnext) (push) Has been cancelled
Deploy / deploy (build:staging, school, 8822, /root/OJ/data/dist) (push) Has been cancelled
2026-05-26 23:10:15 -06:00
c1678c9060 use asgi
Some checks failed
Deploy / deploy (build, debian, 22, /root/OJDeploy/data/clientnext) (push) Has been cancelled
Deploy / deploy (build:staging, school, 8822, /root/OJ/data/dist) (push) Has been cancelled
2026-05-26 07:37:09 -06:00
7e784be061 fix
Some checks failed
Deploy / deploy (build, debian, 22, /root/OJDeploy/data/clientnext) (push) Has been cancelled
Deploy / deploy (build:staging, school, 8822, /root/OJ/data/dist) (push) Has been cancelled
2026-05-26 02:44:55 -06:00
714e07d514 update
Some checks failed
Deploy / deploy (build, debian, 22, /root/OJDeploy/data/clientnext) (push) Has been cancelled
Deploy / deploy (build:staging, school, 8822, /root/OJ/data/dist) (push) Has been cancelled
2026-05-26 02:19:39 -06:00
bf69a355fe update
Some checks failed
Deploy / deploy (build, debian, 22, /root/OJDeploy/data/clientnext) (push) Has been cancelled
Deploy / deploy (build:staging, school, 8822, /root/OJ/data/dist) (push) Has been cancelled
2026-05-25 23:55:16 -06:00
e8bc91bd59 fix
Some checks failed
Deploy / deploy (build, debian, 22, /root/OJDeploy/data/clientnext) (push) Has been cancelled
Deploy / deploy (build:staging, school, 8822, /root/OJ/data/dist) (push) Has been cancelled
2026-05-25 23:10:07 -06:00
f970bb955d fix
Some checks failed
Deploy / deploy (build, debian, 22, /root/OJDeploy/data/clientnext) (push) Has been cancelled
Deploy / deploy (build:staging, school, 8822, /root/OJ/data/dist) (push) Has been cancelled
2026-05-25 23:06:03 -06:00
82987ffd54 update
Some checks failed
Deploy / deploy (build, debian, 22, /root/OJDeploy/data/clientnext) (push) Has been cancelled
Deploy / deploy (build:staging, school, 8822, /root/OJ/data/dist) (push) Has been cancelled
2026-05-25 22:40:24 -06:00
fb2bd8981b fix
Some checks failed
Deploy / deploy (build, debian, 22, /root/OJDeploy/data/clientnext) (push) Has been cancelled
Deploy / deploy (build:staging, school, 8822, /root/OJ/data/dist) (push) Has been cancelled
2026-05-25 22:28:00 -06:00
3a33c8ff3a update
Some checks failed
Deploy / deploy (build, debian, 22, /root/OJDeploy/data/clientnext) (push) Has been cancelled
Deploy / deploy (build:staging, school, 8822, /root/OJ/data/dist) (push) Has been cancelled
2026-05-25 22:24:56 -06:00
11e447d4b7 update
Some checks failed
Deploy / deploy (build, debian, 22, /root/OJDeploy/data/clientnext) (push) Has been cancelled
Deploy / deploy (build:staging, school, 8822, /root/OJ/data/dist) (push) Has been cancelled
2026-05-25 21:56:07 -06:00
7547f896f6 feat: add admin UI for AST rule configuration 2026-05-25 20:50:40 -06:00
18fc65f2ce feat: add AST_CHECK_FAILED status and update result display
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-25 20:47:46 -06:00
8e2fcceb8f update
Some checks failed
Deploy / deploy (build, debian, 22, /root/OJDeploy/data/clientnext) (push) Has been cancelled
Deploy / deploy (build:staging, school, 8822, /root/OJ/data/dist) (push) Has been cancelled
2026-05-25 07:01:07 -06:00
873b7c875b fix
Some checks failed
Deploy / deploy (build, debian, 22, /root/OJDeploy/data/clientnext) (push) Has been cancelled
Deploy / deploy (build:staging, school, 8822, /root/OJ/data/dist) (push) Has been cancelled
2026-05-25 06:26:05 -06:00
1296251c80 update
Some checks failed
Deploy / deploy (build, debian, 22, /root/OJDeploy/data/clientnext) (push) Has been cancelled
Deploy / deploy (build:staging, school, 8822, /root/OJ/data/dist) (push) Has been cancelled
2026-05-25 06:15:04 -06:00
9f18ba900a fix: reset collapsed state when new flowchart is pinned 2026-05-25 00:18:00 -06:00
c898b94174 feat: add pin button to flowchart score detail modal 2026-05-25 00:07:52 -06:00
dcb1171210 feat: register PinnedFlowchartPanel in default layout 2026-05-25 00:06:35 -06:00
1d28d2c7c2 fix: clear renderError on panel collapse 2026-05-25 00:05:39 -06:00
d05f4a8918 feat: add PinnedFlowchartPanel draggable component 2026-05-25 00:03:36 -06:00
ad18800ca6 feat: add pinnedFlowchart Pinia store 2026-05-25 00:01:50 -06:00
46c3176cd2 hide who is leaving
Some checks failed
Deploy / deploy (build, debian, 22, /root/OJDeploy/data/clientnext) (push) Has been cancelled
Deploy / deploy (build:staging, school, 8822, /root/OJ/data/dist) (push) Has been cancelled
2026-05-24 23:43:14 -06:00
5a378b095c add all in duration
Some checks failed
Deploy / deploy (build, debian, 22, /root/OJDeploy/data/clientnext) (push) Has been cancelled
Deploy / deploy (build:staging, school, 8822, /root/OJ/data/dist) (push) Has been cancelled
2026-05-24 23:11:55 -06:00
eee7c63f97 fix
Some checks failed
Deploy / deploy (build, debian, 22, /root/OJDeploy/data/clientnext) (push) Has been cancelled
Deploy / deploy (build:staging, school, 8822, /root/OJ/data/dist) (push) Has been cancelled
2026-05-24 21:12:58 -06:00
9736fdf883 update
Some checks failed
Deploy / deploy (build, debian, 22, /root/OJDeploy/data/clientnext) (push) Has been cancelled
Deploy / deploy (build:staging, school, 8822, /root/OJ/data/dist) (push) Has been cancelled
2026-05-21 19:57:56 -06:00
340a58fc17 update
Some checks failed
Deploy / deploy (build, debian, 22, /root/OJDeploy/data/clientnext) (push) Has been cancelled
Deploy / deploy (build:staging, school, 8822, /root/OJ/data/dist) (push) Has been cancelled
2026-05-21 19:55:39 -06:00
a2e8c6b274 update 2026-05-21 19:55:26 -06:00
6403b69294 update
Some checks failed
Deploy / deploy (build, debian, 22, /root/OJDeploy/data/clientnext) (push) Has been cancelled
Deploy / deploy (build:staging, school, 8822, /root/OJ/data/dist) (push) Has been cancelled
2026-05-21 19:52:44 -06:00
9315963cce update
Some checks failed
Deploy / deploy (build, debian, 22, /root/OJDeploy/data/clientnext) (push) Has been cancelled
Deploy / deploy (build:staging, school, 8822, /root/OJ/data/dist) (push) Has been cancelled
2026-05-21 19:40:25 -06:00
c1c6e75a7b update
Some checks failed
Deploy / deploy (build, debian, 22, /root/OJDeploy/data/clientnext) (push) Has been cancelled
Deploy / deploy (build:staging, school, 8822, /root/OJ/data/dist) (push) Has been cancelled
2026-05-21 19:38:24 -06:00
c4ac0f06cb fix
Some checks failed
Deploy / deploy (build, debian, 22, /root/OJDeploy/data/clientnext) (push) Has been cancelled
Deploy / deploy (build:staging, school, 8822, /root/OJ/data/dist) (push) Has been cancelled
2026-05-21 19:30:36 -06:00
776cfdf9de feat: add contest clone button to admin contest list 2026-05-21 19:21:40 -06:00
2cf971b40b update
Some checks failed
Deploy / deploy (build, debian, 22, /root/OJDeploy/data/clientnext) (push) Has been cancelled
Deploy / deploy (build:staging, school, 8822, /root/OJ/data/dist) (push) Has been cancelled
2026-05-21 19:06:41 -06:00
9e63016231 update
Some checks failed
Deploy / deploy (build, debian, 22, /root/OJDeploy/data/clientnext) (push) Has been cancelled
Deploy / deploy (build:staging, school, 8822, /root/OJ/data/dist) (push) Has been cancelled
2026-05-21 19:03:43 -06:00
2e285e29f0 fix
Some checks failed
Deploy / deploy (build, debian, 22, /root/OJDeploy/data/clientnext) (push) Has been cancelled
Deploy / deploy (build:staging, school, 8822, /root/OJ/data/dist) (push) Has been cancelled
2026-05-21 18:59:28 -06:00
5984aa715d fix
Some checks failed
Deploy / deploy (build, debian, 22, /root/OJDeploy/data/clientnext) (push) Has been cancelled
Deploy / deploy (build:staging, school, 8822, /root/OJ/data/dist) (push) Has been cancelled
2026-05-21 18:36:34 -06:00
ee14e4065c update
Some checks failed
Deploy / deploy (build, debian, 22, /root/OJDeploy/data/clientnext) (push) Has been cancelled
Deploy / deploy (build:staging, school, 8822, /root/OJ/data/dist) (push) Has been cancelled
2026-05-21 02:12:11 -06:00
16588d2965 fix
Some checks failed
Deploy / deploy (build, debian, 22, /root/OJDeploy/data/clientnext) (push) Has been cancelled
Deploy / deploy (build:staging, school, 8822, /root/OJ/data/dist) (push) Has been cancelled
2026-05-20 18:23:01 -06:00
2850887ce3 fix
Some checks failed
Deploy / deploy (build, debian, 22, /root/OJDeploy/data/clientnext) (push) Has been cancelled
Deploy / deploy (build:staging, school, 8822, /root/OJ/data/dist) (push) Has been cancelled
2026-05-20 09:32:08 -06:00
9093ba56e6 fix in low version
Some checks failed
Deploy / deploy (build, debian, 22, /root/OJDeploy/data/clientnext) (push) Has been cancelled
Deploy / deploy (build:staging, school, 8822, /root/OJ/data/dist) (push) Has been cancelled
2026-05-20 08:12:46 -06:00
5f92aeaea4 update
Some checks failed
Deploy / deploy (build, debian, 22, /root/OJDeploy/data/clientnext) (push) Has been cancelled
Deploy / deploy (build:staging, school, 8822, /root/OJ/data/dist) (push) Has been cancelled
2026-05-19 07:44:26 -06:00
ffa55cb92d fix
Some checks failed
Deploy / deploy (build, debian, 22, /root/OJDeploy/data/clientnext) (push) Has been cancelled
Deploy / deploy (build:staging, school, 8822, /root/OJ/data/dist) (push) Has been cancelled
2026-05-19 04:48:45 -06:00
332ab2f966 fix mermaid
Some checks failed
Deploy / deploy (build, debian, 22, /root/OJDeploy/data/clientnext) (push) Has been cancelled
Deploy / deploy (build:staging, school, 8822, /root/OJ/data/dist) (push) Has been cancelled
2026-05-19 04:46:48 -06:00
ef7aa44577 fix
Some checks failed
Deploy / deploy (build, debian, 22, /root/OJDeploy/data/clientnext) (push) Has been cancelled
Deploy / deploy (build:staging, school, 8822, /root/OJ/data/dist) (push) Has been cancelled
2026-05-19 04:29:04 -06:00
a72317173b feat: show warning when no answer code exists in generator 2026-05-19 04:27:10 -06:00
0ac203806c fix: round up initial rows to nearest multiple of 5 2026-05-19 04:25:31 -06:00
e7e270b928 fix: ensure at least 5 rows in generator even when samples exist 2026-05-19 04:25:00 -06:00
874a6fbe90 feat: pre-populate generator with problem samples on open 2026-05-19 04:22:23 -06:00
06738f6e29 refactor: move reminder tooltip into testcase section 2026-05-19 04:20:48 -06:00
8047a7af8e refactor: move test case info alert into testcase section 2026-05-19 04:20:16 -06:00
2912c7495c fix: use display-directive=show on modal to preserve generator state 2026-05-19 04:18:56 -06:00
60851e3255 feat: preserve testcase generator state across modal open/close 2026-05-19 04:17:49 -06:00
f57c2c4137 fix: show all languages in selector regardless of answer code 2026-05-19 04:16:17 -06:00
09475db932 fix: explicitly import TestcaseGenerator component 2026-05-19 04:08:37 -06:00
cf2f5eec7d fix: disable add/remove during run and fix score distribution to sum to 100 2026-05-19 04:04:18 -06:00
5c037bb438 fix: remount TestcaseGenerator on modal open to reset state 2026-05-19 04:02:00 -06:00
3b7b518109 feat: integrate testcase generator modal into problem edit page
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 04:00:13 -06:00
a48baddcc3 fix: guard reset during run and use stable key for file list 2026-05-19 03:58:24 -06:00
631292c33b feat: add TestcaseGenerator component for inline test case creation 2026-05-19 03:55:54 -06:00
6485861c57 chore: install client-zip for frontend zip generation 2026-05-19 03:53:01 -06:00
82 changed files with 5126 additions and 1535 deletions

2
.env
View File

@@ -4,4 +4,4 @@ PUBLIC_OJ_URL=http://localhost:8000
PUBLIC_CODE_URL=http://localhost:3000 PUBLIC_CODE_URL=http://localhost:3000
PUBLIC_JUDGE0_URL=https://judge0api.xuyue.cc PUBLIC_JUDGE0_URL=https://judge0api.xuyue.cc
PUBLIC_SIGNALING_URL=ws://10.13.114.114:8085 PUBLIC_SIGNALING_URL=ws://10.13.114.114:8085
PUBLIC_WS_URL=ws://localhost:8001/ws PUBLIC_WS_URL=ws://localhost:8000/ws

View File

@@ -0,0 +1,259 @@
# Submit Formatting Button State Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Show `格式化中` on the submit button during automatic formatting, then show `正在提交` continuously while the submission request is pending.
**Architecture:** Extract the button presentation rules into a small pure TypeScript function so the state priority can be tested without adding a frontend test framework. Keep formatter and submission-request flags local to `SubmitCode.vue`, with `finally` blocks ensuring both flags clear on every outcome.
**Tech Stack:** Vue 3 Composition API, TypeScript, Node.js built-in test runner, Rsbuild
---
### Task 1: Define and test submit button presentation rules
**Files:**
- Create: `tests/submitButtonState.test.ts`
- Create: `src/oj/problem/components/submitButtonState.ts`
- [ ] **Step 1: Write the failing test**
Create `tests/submitButtonState.test.ts`:
```ts
import assert from "node:assert/strict"
import test from "node:test"
import { getSubmitButtonState } from "../src/oj/problem/components/submitButtonState.ts"
const idleInput = {
isAuthed: true,
hasCode: true,
isFormatting: false,
isSubmitting: false,
isJudging: false,
isCooldown: false,
}
test("shows a disabled loading state while formatting", () => {
assert.deepEqual(
getSubmitButtonState({ ...idleInput, isFormatting: true }),
{
disabled: true,
label: "格式化中",
icon: "eos-icons:loading",
},
)
})
test("shows submitting immediately after formatting", () => {
assert.deepEqual(
getSubmitButtonState({ ...idleInput, isSubmitting: true }),
{
disabled: true,
label: "正在提交",
icon: "eos-icons:loading",
},
)
})
test("preserves existing login, judging, cooldown, and idle states", () => {
assert.deepEqual(
getSubmitButtonState({ ...idleInput, isAuthed: false }),
{
disabled: true,
label: "请先登录",
icon: "ph:play-fill",
},
)
assert.deepEqual(getSubmitButtonState({ ...idleInput, isJudging: true }), {
disabled: true,
label: "正在评分",
icon: "eos-icons:loading",
})
assert.deepEqual(getSubmitButtonState({ ...idleInput, isCooldown: true }), {
disabled: true,
label: "正在冷却",
icon: "ph:lightbulb-fill",
})
assert.deepEqual(getSubmitButtonState(idleInput), {
disabled: false,
label: "提交代码",
icon: "ph:play-fill",
})
})
```
- [ ] **Step 2: Run the test to verify it fails**
Run:
```bash
node --test tests/submitButtonState.test.ts
```
Expected: FAIL with `ERR_MODULE_NOT_FOUND` for `submitButtonState.ts`.
- [ ] **Step 3: Implement the pure state function**
Create `src/oj/problem/components/submitButtonState.ts`:
```ts
export interface SubmitButtonStateInput {
isAuthed: boolean
hasCode: boolean
isFormatting: boolean
isSubmitting: boolean
isJudging: boolean
isCooldown: boolean
}
export interface SubmitButtonState {
disabled: boolean
label: string
icon: string
}
export function getSubmitButtonState({
isAuthed,
hasCode,
isFormatting,
isSubmitting,
isJudging,
isCooldown,
}: SubmitButtonStateInput): SubmitButtonState {
const disabled =
!isAuthed ||
!hasCode ||
isFormatting ||
isSubmitting ||
isJudging ||
isCooldown
let label = "提交代码"
if (!isAuthed) {
label = "请先登录"
} else if (isFormatting) {
label = "格式化中"
} else if (isSubmitting) {
label = "正在提交"
} else if (isJudging) {
label = "正在评分"
} else if (isCooldown) {
label = "正在冷却"
}
const icon =
isFormatting || isSubmitting || isJudging
? "eos-icons:loading"
: isCooldown
? "ph:lightbulb-fill"
: "ph:play-fill"
return { disabled, label, icon }
}
```
- [ ] **Step 4: Run the test to verify it passes**
Run:
```bash
node --test tests/submitButtonState.test.ts
```
Expected: 3 tests pass.
### Task 2: Connect formatting and submission request lifecycle to the button
**Files:**
- Modify: `src/oj/problem/components/SubmitCode.vue`
- [ ] **Step 1: Add local request states and computed presentation**
Import `getSubmitButtonState`, add `isFormatting` and `isSubmittingRequest` refs, and replace the three existing button computed properties with:
```ts
const buttonState = computed(() =>
getSubmitButtonState({
isAuthed: userStore.isAuthed,
hasCode: codeStore.code.value.trim() !== "",
isFormatting: isFormatting.value,
isSubmitting: isSubmittingRequest.value || submitting.value,
isJudging: judging.value || pending.value,
isCooldown: isCooldown.value,
}),
)
```
Use `buttonState.disabled`, `buttonState.icon`, and `buttonState.label` in the template.
- [ ] **Step 2: Guard and track the formatting request**
At the start of `submit`, return when `buttonState.value.disabled` is true. Around `formatCode`, set `isFormatting.value = true` before the request and clear it in `finally`:
```ts
isFormatting.value = true
try {
const res = await formatCode({
code: codeStore.code.value,
language: formatLang,
})
codeStore.setCode(res.data.code)
} catch (e: any) {
if (e?.error === "format-error") {
message.warning(`代码格式化失败:${e.data},请检查代码后重试`)
return
}
} finally {
isFormatting.value = false
}
```
- [ ] **Step 3: Track the submission API request**
Set `isSubmittingRequest.value = true` immediately before `submitCode`, keep the existing success flow inside the `try`, and clear the request state in `finally`:
```ts
isSubmittingRequest.value = true
try {
const res = await submitCode(data)
console.log(`[Submit] 代码已提交: ID=${res.data.submission_id}`)
startCooldown()
startMonitoring(res.data.submission_id)
showResult.value = true
} finally {
isSubmittingRequest.value = false
}
```
- [ ] **Step 4: Run focused tests**
Run:
```bash
node --test tests/submitButtonState.test.ts
```
Expected: 3 tests pass.
- [ ] **Step 5: Run the production build**
Run:
```bash
npm run build
```
Expected: Rsbuild exits with status 0.
- [ ] **Step 6: Check the final diff**
Run:
```bash
git diff --check
git diff -- src/oj/problem/components/SubmitCode.vue src/oj/problem/components/submitButtonState.ts tests/submitButtonState.test.ts
```
Expected: no whitespace errors; diff is limited to the button state feature and its test.

View File

@@ -0,0 +1,34 @@
# Submit Formatting Button State
## Goal
Make the code submission button reflect the automatic formatting request that runs before submission.
## Behavior
- For Python3, C, and C++, the button displays `格式化中` while the formatting API request is pending.
- During formatting, the button uses the existing loading icon and is disabled to prevent duplicate submissions.
- After formatting succeeds, the existing submission flow continues and the button can display `正在提交`.
- A formatting error stops submission and clears the formatting state before showing the existing warning.
- A formatter server or network failure keeps the existing fallback behavior: clear the formatting state and submit the original code.
- Languages without automatic formatting skip this state and submit directly.
- Existing button labels and judging/cooldown behavior remain unchanged.
## Implementation
Add a component-local `isFormatting` ref in `SubmitCode.vue`.
- Include it in `submitDisabled`.
- Give it priority in `submitLabel`, using `格式化中`.
- Include it in the loading-icon condition.
- Set it immediately before `formatCode`.
- Clear it in a `finally` block so every formatter outcome restores the button state.
The state remains local because it is transient UI state owned only by the submission button.
## Verification
The frontend currently has no automated test suite. Verify with:
- TypeScript production build.
- Manual inspection of the state transitions for successful formatting, formatting errors, formatter infrastructure failures, and languages that do not format.

839
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -23,16 +23,19 @@
"@vueuse/router": "^14.3.0", "@vueuse/router": "^14.3.0",
"@wangeditor-next/editor": "^5.7.0", "@wangeditor-next/editor": "^5.7.0",
"@wangeditor-next/editor-for-vue": "^5.1.14", "@wangeditor-next/editor-for-vue": "^5.1.14",
"axios": "^1.16.0", "axios": "^1.16.1",
"canvas-confetti": "^1.9.4", "canvas-confetti": "^1.9.4",
"chart.js": "^4.5.1", "chart.js": "^4.5.1",
"chartjs-chart-wordcloud": "^4.4.5",
"client-zip": "^2.5.0",
"codemirror": "^6.0.2", "codemirror": "^6.0.2",
"copy-text-to-clipboard": "^3.2.2", "copy-text-to-clipboard": "^3.2.2",
"date-fns": "^4.1.0", "date-fns": "^4.1.0",
"fflate": "^0.8.2", "fflate": "^0.8.2",
"highlight.js": "^11.11.1", "highlight.js": "^11.11.1",
"md-editor-v3": "^6.5.0", "md-editor-v3": "^6.5.0",
"mermaid": "^11.14.0", "mermaid": "^11.15.0",
"mermaid-legacy": "npm:mermaid@^9.1.7",
"naive-ui": "^2.44.1", "naive-ui": "^2.44.1",
"nanoid": "^5.1.11", "nanoid": "^5.1.11",
"normalize.css": "^8.0.1", "normalize.css": "^8.0.1",

View File

@@ -1,329 +0,0 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Python流程图作业 - 学情分析看板</title>
<script src="https://cdn.jsdelivr.net/npm/echarts@5.4.3/dist/echarts.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/echarts-wordcloud@2.1.0/dist/echarts-wordcloud.min.js"></script>
<style>
:root {
--bg-color: #f0f2f5;
--card-bg: #ffffff;
--primary: #1890ff;
--text-main: #333;
--text-secondary: #666;
}
body {
font-family: 'Helvetica Neue', Helvetica, 'PingFang SC', 'Microsoft YaHei', Arial, sans-serif;
background-color: var(--bg-color);
margin: 0;
padding: 20px;
}
.header {
text-align: center;
margin-bottom: 20px;
}
.header h1 { color: var(--text-main); margin: 0; }
.header p { color: var(--text-secondary); margin-top: 5px; }
/* 顶部概览卡片 */
.overview-container {
display: flex;
justify-content: space-between;
gap: 20px;
margin-bottom: 20px;
}
.card {
background: var(--card-bg);
border-radius: 8px;
padding: 20px;
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
flex: 1;
transition: transform 0.3s;
}
.card:hover { transform: translateY(-5px); }
.stat-title { font-size: 14px; color: var(--text-secondary); }
.stat-value { font-size: 28px; font-weight: bold; color: var(--text-main); margin-top: 10px; }
.stat-sub { font-size: 12px; color: #52c41a; margin-top: 5px; }
/* 图表布局 */
.charts-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 20px;
margin-bottom: 20px;
}
.chart-box {
background: var(--card-bg);
border-radius: 8px;
padding: 20px;
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
height: 400px;
}
.chart-title {
font-size: 16px;
font-weight: bold;
border-left: 4px solid var(--primary);
padding-left: 10px;
margin-bottom: 15px;
}
/* 学生列表 */
.student-list {
background: var(--card-bg);
border-radius: 8px;
padding: 20px;
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
}
table { width: 100%; border-collapse: collapse; }
th, td { text-align: left; padding: 12px; border-bottom: 1px solid #eee; }
th { background-color: #fafafa; color: var(--text-secondary); }
.tag {
padding: 4px 8px;
border-radius: 4px;
font-size: 12px;
font-weight: bold;
}
.tag-S { background: #fff7e6; color: #fa8c16; }
.tag-A { background: #e6f7ff; color: #1890ff; }
.tag-B { background: #f6ffed; color: #52c41a; }
.tag-C { background: #fff1f0; color: #f5222d; }
</style>
</head>
<body>
<div class="overview-container">
<div class="card">
<div class="stat-title">班级平均分</div>
<div class="stat-value" id="avgScore">0</div>
<div class="stat-sub">↑ 比上周For循环 +2.5分</div>
</div>
<div class="card">
<div class="stat-title">S+A 级别(卓越+优秀)人数</div>
<div class="stat-value" id="countA">0</div>
<div class="stat-sub">占比 35.7%</div>
</div>
<div class="card">
<div class="stat-title">未掌握核心难点</div>
<div class="stat-value" style="color: #f5222d; font-size: 24px;">循环条件</div>
<div class="stat-sub">需重点讲解 a<100 边界</div>
</div>
</div>
<div class="charts-grid">
<div class="chart-box">
<div class="chart-title">作业评级分布</div>
<div id="pieChart" style="width: 100%; height: 340px;"></div>
</div>
<div class="chart-box">
<div class="chart-title">薄弱知识点词云 (AI分析)</div>
<div id="wordCloud" style="width: 100%; height: 340px;"></div>
</div>
</div>
<div class="charts-grid">
<div class="chart-box" style="grid-column: span 2;">
<div class="chart-title">全班分数段统计</div>
<div id="barChart" style="width: 100%; height: 340px;"></div>
</div>
</div>
<script>
// --- 1. 模拟数据生成 (40位同学) ---
const totalStudents = 40;
const students = [];
const familyNames = "赵钱孙李周吴郑王冯陈褚卫蒋沈韩杨";
// 精确分配等级人数S 10% (4人), A 15% (6人), B 50% (20人), C 15% (6人), D 10% (4人)
const gradeDistribution = {
S: 4, // 10% = 4人
A: 6, // 15% = 6人
B: 20, // 50% = 20人
C: 6, // 15% = 6人
D: 4 // 10% = 4人
};
let gradeCounts = { S: 0, A: 0, B: 0, C: 0, D: 0 };
let totalScore = 0;
// 创建等级数组,确保精确分配
let levelQueue = [];
for (let level in gradeDistribution) {
for (let i = 0; i < gradeDistribution[level]; i++) {
levelQueue.push(level);
}
}
// 打乱顺序
for (let i = levelQueue.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[levelQueue[i], levelQueue[j]] = [levelQueue[j], levelQueue[i]];
}
for (let i = 1; i <= totalStudents; i++) {
let score;
let level = levelQueue[i - 1];
// 根据等级生成对应分数范围
if (level === 'S') {
// S级95-100
score = Math.floor(Math.random() * (100 - 95 + 1) + 95);
} else if (level === 'A') {
// A级85-94
score = Math.floor(Math.random() * (95 - 85) + 85);
} else if (level === 'B') {
// B级70-84
score = Math.floor(Math.random() * (85 - 70) + 70);
} else if (level === 'C') {
// C级60-69
score = Math.floor(Math.random() * (70 - 60) + 60);
} else {
// D级50-59
score = Math.floor(Math.random() * (60 - 50) + 50);
}
gradeCounts[level]++;
totalScore += score;
let comment = "";
if (level === 'S') comment = "完美!逻辑清晰,变量初始化正确,闭环完美,代码规范。";
else if (level === 'A') comment = "逻辑清晰,变量初始化正确,闭环完美。";
else if (level === 'B') comment = "整体逻辑正确,但部分连线方向有误。";
else if (level === 'C') comment = "循环条件判断错误,导致死循环或无法进入。";
else comment = "基础概念理解不足,需要重新学习。";
students.push({
id: 2025000 + i,
name: familyNames[i % familyNames.length] + "同学",
score: score,
level: level,
comment: comment
});
}
// --- 2. 填充顶部数据 ---
document.getElementById('avgScore').innerText = (totalScore / totalStudents).toFixed(1);
const excellentCount = gradeCounts.S + gradeCounts.A; // S级和A级合计
document.getElementById('countA').innerText = excellentCount;
// 更新占比显示
const excellentPercent = ((excellentCount / totalStudents) * 100).toFixed(1);
const countACard = document.getElementById('countA').parentElement;
countACard.querySelector('.stat-sub').innerText = `占比 ${excellentPercent}%`;
// --- 3. 初始化图表 ---
// A. 饼图 - 等级分布
const pieChart = echarts.init(document.getElementById('pieChart'));
pieChart.setOption({
tooltip: { trigger: 'item' },
legend: { top: '5%', left: 'center' },
color: ['#fa8c16', '#1890ff', '#52c41a', '#faad14', '#f5222d'],
series: [{
name: '评级占比',
type: 'pie',
radius: ['40%', '70%'],
avoidLabelOverlap: false,
itemStyle: { borderRadius: 10, borderColor: '#fff', borderWidth: 2 },
label: { show: false, position: 'center' },
emphasis: { label: { show: true, fontSize: 20, fontWeight: 'bold' } },
data: [
{ value: gradeCounts.S, name: 'S级 (卓越)' },
{ value: gradeCounts.A, name: 'A级 (优秀)' },
{ value: gradeCounts.B, name: 'B级 (良好)' },
{ value: gradeCounts.C, name: 'C级 (待改进)' }
]
}]
});
// B. 词云图 - 知识点掌握情况
// 这里重点突出“循环条件”
const wordChart = echarts.init(document.getElementById('wordCloud'));
wordChart.setOption({
tooltip: {},
series: [{
type: 'wordCloud',
gridSize: 2,
sizeRange: [12, 60], // 字体大小范围
rotationRange: [-45, 45],
shape: 'circle',
width: '100%',
height: '100%',
textStyle: {
fontFamily: 'sans-serif',
fontWeight: 'bold',
color: function () {
return 'rgb(' + [
Math.round(Math.random() * 160),
Math.round(Math.random() * 160),
Math.round(Math.random() * 160)
].join(',') + ')';
}
},
data: [
{ name: '循环条件', value: 150, textStyle: { color: 'red' } }, // 核心痛点
{ name: '变量初始化', value: 80 },
{ name: 'i=i+1', value: 70 },
{ name: 'a<100', value: 65 },
{ name: '死循环', value: 60 },
{ name: '连线方向', value: 50 },
{ name: '退出逻辑', value: 45 },
{ name: 'While语法', value: 40 },
{ name: 'Print缩进', value: 35 },
{ name: 'Yes/No分支', value: 30 },
{ name: '流程结束符', value: 25 },
{ name: '变量定义', value: 20 }
]
}]
});
// C. 柱状图 - 分数段
const barChart = echarts.init(document.getElementById('barChart'));
// 简单的分段统计
let ranges = { '90-100': 0, '80-89': 0, '70-79': 0, '60-69': 0, '<60': 0 };
students.forEach(s => {
if (s.score >= 90) ranges['90-100']++;
else if (s.score >= 80) ranges['80-89']++;
else if (s.score >= 70) ranges['70-79']++;
else if (s.score >= 60) ranges['60-69']++;
else ranges['<60']++;
});
barChart.setOption({
tooltip: { trigger: 'axis', axisPointer: { type: 'shadow' } },
grid: { left: '3%', right: '4%', bottom: '3%', containLabel: true },
xAxis: { type: 'category', data: Object.keys(ranges) },
yAxis: { type: 'value' },
series: [{
name: '人数',
type: 'bar',
barWidth: '50%',
data: Object.values(ranges),
itemStyle: {
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
{ offset: 0, color: '#83bff6' },
{ offset: 0.5, color: '#188df0' },
{ offset: 1, color: '#188df0' }
])
}
}]
});
// 窗口缩放适配
window.addEventListener('resize', function() {
pieChart.resize();
wordChart.resize();
barChart.resize();
});
</script>
</body>
</html>

View File

@@ -74,6 +74,9 @@ const config: ReturnType<typeof defineConfig> = defineConfig(({ envMode }) => {
}, },
define: publicVars, define: publicVars,
}, },
output: {
polyfill: "usage",
},
performance: { performance: {
chunkSplit: { chunkSplit: {
strategy: "split-by-module", strategy: "split-by-module",

206
src/admin/ai/list.vue Normal file
View File

@@ -0,0 +1,206 @@
<template>
<n-flex justify="space-between" class="titleWrapper">
<h2 class="title">AI 学习分析报告</h2>
<n-input
v-model:value="query.username"
clearable
placeholder="输入用户名筛选"
style="width: 200px"
/>
</n-flex>
<n-alert
v-if="pinnedReports.length > 0"
type="warning"
:show-icon="true"
style="margin-bottom: 12px"
>
以下 <strong>{{ pinnedReports.length }}</strong> 位用户的 AI
分析报告已被锁定前台将固定显示该报告
<n-flex style="margin-top: 8px" :wrap="true" :size="[8, 6]">
<n-tag
v-for="r in pinnedReports"
:key="r.id"
type="warning"
size="small"
closable
@close="togglePin(r)"
>
{{ r.username }}
</n-tag>
</n-flex>
</n-alert>
<n-data-table striped :columns="columns" :data="reports" />
<Pagination
:total="total"
v-model:limit="query.limit"
v-model:page="query.page"
/>
<n-modal
v-model:show="showModal"
preset="card"
title="分析报告详情"
style="width: 800px; max-width: 95vw"
>
<n-spin :show="loadingDetail">
<div v-if="detail" class="detail">
<n-descriptions :column="2" bordered size="small" class="meta">
<n-descriptions-item label="用户">{{
detail.username
}}</n-descriptions-item>
<n-descriptions-item label="班级">{{
detail.class_name || "-"
}}</n-descriptions-item>
<n-descriptions-item label="时间" :span="2">{{
parseTime(detail.create_time, "YYYY-MM-DD HH:mm:ss")
}}</n-descriptions-item>
</n-descriptions>
<n-scrollbar style="max-height: 60vh; margin-top: 12px">
<MdPreview :model-value="detail.analysis" />
</n-scrollbar>
</div>
</n-spin>
</n-modal>
</template>
<script lang="ts" setup>
import { MdPreview } from "md-editor-v3"
import "md-editor-v3/lib/preview.css"
import Pagination from "shared/components/Pagination.vue"
import { parseTime } from "utils/functions"
import {
getAIReportList,
getAIReportDetail,
pinAIReport,
getPinnedAIReports,
} from "../api"
import { NButton, NTag } from "naive-ui"
interface ReportItem {
id: number
create_time: string
username: string
analysis_excerpt: string
is_pinned: boolean
}
interface ReportDetail extends ReportItem {
analysis: string
class_name: string | null
}
const reports = ref<ReportItem[]>([])
const total = ref(0)
const query = reactive({ limit: 10, page: 1, username: "" })
const pinnedReports = ref<ReportItem[]>([])
const showModal = ref(false)
const loadingDetail = ref(false)
const detail = ref<ReportDetail | null>(null)
const columns: DataTableColumn<ReportItem>[] = [
{ title: "ID", key: "id", width: 80 },
{
title: "用户名",
key: "username",
width: 150,
render: (row) =>
h(
"span",
{ style: row.is_pinned ? "font-weight:600" : "" },
row.username,
),
},
{
title: "AI 分析内容",
key: "analysis_excerpt",
render: (row) => row.analysis_excerpt || "-",
},
{
title: "生成时间",
key: "create_time",
width: 200,
render: (row) => parseTime(row.create_time, "YYYY-MM-DD HH:mm:ss"),
},
{
title: "PIN 状态",
key: "is_pinned",
width: 100,
render: (row) =>
row.is_pinned
? h(NTag, { type: "warning", size: "small" }, () => "已锁定")
: null,
},
{
title: "操作",
key: "action",
width: 160,
render: (row) =>
h("span", { style: "display:flex;gap:8px" }, [
h(
NButton,
{ size: "small", type: "primary", onClick: () => openDetail(row.id) },
() => "查看",
),
h(
NButton,
{
size: "small",
type: row.is_pinned ? "error" : "default",
onClick: () => togglePin(row),
},
() => (row.is_pinned ? "取消 PIN" : "PIN"),
),
]),
},
]
async function loadPinnedReports() {
const res = await getPinnedAIReports()
pinnedReports.value = res.data
}
async function togglePin(row: ReportItem) {
await pinAIReport(row.id)
await Promise.all([listReports(), loadPinnedReports()])
}
async function listReports() {
const offset = (query.page - 1) * query.limit
const res = await getAIReportList(offset, query.limit, query.username)
reports.value = res.data.results
total.value = res.data.total
}
async function openDetail(id: number) {
showModal.value = true
loadingDetail.value = true
detail.value = null
try {
const res = await getAIReportDetail(id)
detail.value = res.data
} finally {
loadingDetail.value = false
}
}
onMounted(() => Promise.all([listReports(), loadPinnedReports()]))
watch(() => [query.page, query.limit], listReports)
watchDebounced(() => query.username, listReports, {
debounce: 500,
maxWait: 1000,
})
</script>
<style scoped>
.titleWrapper {
margin-bottom: 16px;
align-items: center;
}
.title {
margin: 0;
}
.detail .meta {
margin-bottom: 0;
}
</style>

View File

@@ -1,5 +1,6 @@
import http from "utils/http" import http from "utils/http"
import { import { toProblemListItem } from "admin/transforms"
import type {
AdminProblem, AdminProblem,
Announcement, Announcement,
AnnouncementEdit, AnnouncementEdit,
@@ -30,25 +31,21 @@ export async function getProblemList(
contestID?: string, contestID?: string,
) { ) {
const endpoint = !!contestID ? "admin/contest/problem" : "admin/problem" const endpoint = !!contestID ? "admin/contest/problem" : "admin/problem"
const res = await http.get(endpoint, { const res = await http.get<{ results: AdminProblem[]; total: number }>(
params: { endpoint,
paging: true, {
offset, params: {
limit, paging: true,
keyword, offset,
author, limit,
contest_id: contestID, keyword,
author,
contest_id: contestID,
},
}, },
}) )
return { return {
results: res.data.results.map((result: AdminProblem) => ({ results: res.data.results.map(toProblemListItem),
id: result.id,
_id: result._id,
title: result.title,
username: result.created_by.username,
create_time: result.create_time,
visible: result.visible,
})),
total: res.data.total, total: res.data.total,
} }
} }
@@ -128,10 +125,10 @@ export function getContestList(offset = 0, limit = 10, keyword: string) {
export async function uploadImage(file: File): Promise<string> { export async function uploadImage(file: File): Promise<string> {
const form = new window.FormData() const form = new window.FormData()
form.append("image", file) form.append("image", file)
const res: { success: boolean; file_path: string; msg: "Success" } = // 该端点不走 { error, data } 信封,直接返回上传结果
await http.post("admin/upload_image", form, { const res = (await http.post("admin/upload_image", form, {
headers: { "content-type": "multipart/form-data" }, headers: { "content-type": "multipart/form-data" },
}) })) as unknown as { success: boolean; file_path: string; msg: "Success" }
return res.success ? res.file_path : "" return res.success ? res.file_path : ""
} }
@@ -160,6 +157,10 @@ export function editContest(contest: Contest | BlankContest) {
return http.put("admin/contest", contest) return http.put("admin/contest", contest)
} }
export function cloneContest(contest_id: number) {
return http.post("admin/contest/clone", { contest_id })
}
export function getContest(id: string) { export function getContest(id: string) {
return http.get<Contest & { password: string }>("admin/contest", { return http.get<Contest & { password: string }>("admin/contest", {
params: { id }, params: { id },
@@ -235,17 +236,17 @@ export function deleteComment(id: number) {
} }
export async function getTutorialList() { export async function getTutorialList() {
const res = await http.get("admin/tutorial") const res = await http.get<Tutorial[]>("admin/tutorial")
return res.data return res.data
} }
export async function getTutorial(id: number) { export async function getTutorial(id: number) {
const res = await http.get("admin/tutorial", { params: { id } }) const res = await http.get<Tutorial>("admin/tutorial", { params: { id } })
return res.data return res.data
} }
export async function createTutorial(data: Partial<Tutorial>) { export async function createTutorial(data: Partial<Tutorial>) {
const res = await http.post("admin/tutorial", data) const res = await http.post<Tutorial>("admin/tutorial", data)
return res.data return res.data
} }
@@ -263,10 +264,10 @@ export function setTutorialVisibility(id: number, is_public: boolean) {
} }
export async function getAdminExercises(tutorialId: number) { export async function getAdminExercises(tutorialId: number) {
const res = await http.get("admin/exercise", { const res = await http.get<Exercise[]>("admin/exercise", {
params: { tutorial_id: tutorialId }, params: { tutorial_id: tutorialId },
}) })
return res.data as Exercise[] return res.data
} }
export async function createExercise(data: { export async function createExercise(data: {
@@ -275,8 +276,8 @@ export async function createExercise(data: {
data: object data: object
order: number order: number
}) { }) {
const res = await http.post("admin/exercise", data) const res = await http.post<Exercise>("admin/exercise", data)
return res.data as Exercise return res.data
} }
export async function updateExercise(data: { export async function updateExercise(data: {
@@ -474,6 +475,29 @@ export function getStuckProblems() {
return http.get("admin/problem/stuck") return http.get("admin/problem/stuck")
} }
export function getTopACTrend(params: { since_year: number; until_year: number; min_per_year: number }) { export function getTopACTrend(params: {
since_year: number
until_year: number
min_per_year: number
}) {
return http.get("admin/problem/top_ac_trend", { params }) return http.get("admin/problem/top_ac_trend", { params })
} }
// AI 学习分析报告
export function getAIReportList(offset = 0, limit = 10, username = "") {
return http.get("admin/ai/reports", {
params: { paging: true, offset, limit, username: username || undefined },
})
}
export function getAIReportDetail(id: number) {
return http.get("admin/ai/reports", { params: { id } })
}
export function pinAIReport(id: number) {
return http.post("admin/ai/reports", { id })
}
export function getPinnedAIReports() {
return http.get("admin/ai/reports", { params: { pinned_only: "true" } })
}

View File

@@ -1,11 +1,13 @@
<script lang="ts" setup> <script lang="ts" setup>
import { Contest } from "utils/types" import { Contest } from "utils/types"
import { cloneContest } from "../../api"
interface Props { interface Props {
contest: Contest contest: Contest
} }
const props = defineProps<Props>() const props = defineProps<Props>()
const router = useRouter() const router = useRouter()
const message = useMessage()
function goEdit() { function goEdit() {
router.push({ router.push({
@@ -28,6 +30,19 @@ function goACMHelper() {
}) })
} }
async function clone() {
try {
const res = await cloneContest(props.contest.id)
message.success("复制成功")
router.push({
name: "admin contest edit",
params: { contestID: res.data.id },
})
} catch {
message.error("复制失败")
}
}
const isACM = computed(() => props.contest.rule_type === "ACM") const isACM = computed(() => props.contest.rule_type === "ACM")
</script> </script>
<template> <template>
@@ -47,6 +62,7 @@ const isACM = computed(() => props.contest.rule_type === "ACM")
<n-button size="small" type="info" secondary @click="goEdit"> <n-button size="small" type="info" secondary @click="goEdit">
编辑 编辑
</n-button> </n-button>
<n-button size="small" secondary @click="clone"> 复制 </n-button>
</n-flex> </n-flex>
</template> </template>
<style scoped></style> <style scoped></style>

View File

@@ -2,7 +2,7 @@
import { formatISO } from "date-fns" import { formatISO } from "date-fns"
import TextEditor from "shared/components/TextEditor.vue" import TextEditor from "shared/components/TextEditor.vue"
import { parseTime } from "utils/functions" import { parseTime } from "utils/functions"
import { BlankContest } from "utils/types" import type { BlankContest } from "utils/types"
import { createContest, editContest, getContest } from "../api" import { createContest, editContest, getContest } from "../api"
interface Props { interface Props {
@@ -56,9 +56,7 @@ const contest = reactive<BlankContest & { id: number }>({
tag: "练习", tag: "练习",
start_time: "", start_time: "",
end_time: "", end_time: "",
rule_type: "ACM",
password: "", password: "",
real_time_rank: true,
visible: false, visible: false,
allowed_ip_ranges: [], allowed_ip_ranges: [],
}) })
@@ -79,9 +77,7 @@ async function getContestDetail() {
contest.tag = data.tag contest.tag = data.tag
contest.start_time = data.start_time contest.start_time = data.start_time
contest.end_time = data.end_time contest.end_time = data.end_time
contest.rule_type = "ACM"
contest.password = data.password contest.password = data.password
contest.real_time_rank = true
contest.visible = data.visible contest.visible = data.visible
contest.allowed_ip_ranges = [] contest.allowed_ip_ranges = []

View File

@@ -4,7 +4,8 @@ import ContestTitle from "shared/components/ContestTitle.vue"
import ContestType from "shared/components/ContestType.vue" import ContestType from "shared/components/ContestType.vue"
import Pagination from "shared/components/Pagination.vue" import Pagination from "shared/components/Pagination.vue"
import { CONTEST_STATUS } from "utils/constants" import { CONTEST_STATUS } from "utils/constants"
import { Contest } from "utils/types" import { parseTime } from "utils/functions"
import type { Contest } from "utils/types"
import { editContest, getContestList } from "../api" import { editContest, getContestList } from "../api"
import Actions from "./components/Actions.vue" import Actions from "./components/Actions.vue"
@@ -26,24 +27,24 @@ const columns: DataTableColumn<Contest>[] = [
{ {
title: "比赛", title: "比赛",
key: "title", key: "title",
minWidth: 250, minWidth: 200,
render: (row) => h(ContestTitle, { contest: row }), render: (row) => h(ContestTitle, { contest: row }),
}, },
{ {
title: "标签", title: "标签",
key: "tag", key: "tag",
width: 80, width: 100,
}, },
{ {
title: "类型", title: "类型",
key: "contest_type", key: "contest_type",
width: 80, width: 100,
render: (row) => h(ContestType, { contest: row, size: "small" }), render: (row) => h(ContestType, { contest: row, size: "small" }),
}, },
{ {
title: "状态", title: "状态",
key: "status", key: "status",
width: 80, width: 100,
render: (row) => render: (row) =>
h( h(
NTag, NTag,
@@ -51,10 +52,22 @@ const columns: DataTableColumn<Contest>[] = [
() => CONTEST_STATUS[row.status]["name"], () => CONTEST_STATUS[row.status]["name"],
), ),
}, },
{
title: "创建者",
key: "created_by",
width: 120,
render: (row) => row.created_by.username,
},
{
title: "创建时间",
key: "create_time",
width: 160,
render: (row) => parseTime(row.create_time, "YYYY-MM-DD HH:mm"),
},
{ {
title: "可见", title: "可见",
key: "visible", key: "visible",
width: 60, width: 100,
render: (row) => render: (row) =>
h(NSwitch, { h(NSwitch, {
value: row.visible, value: row.visible,
@@ -66,7 +79,7 @@ const columns: DataTableColumn<Contest>[] = [
{ {
title: "选项", title: "选项",
key: "actions", key: "actions",
width: 220, width: 300,
render: (row) => h(Actions, { contest: row }), render: (row) => h(Actions, { contest: row }),
}, },
] ]

View File

@@ -47,7 +47,7 @@ const minPerYearOptions = [
] ]
const sinceYear = ref(2023) const sinceYear = ref(2023)
const untilYear = ref(new Date().getFullYear()-1) const untilYear = ref(new Date().getFullYear() - 1)
const minPerYear = ref(100) const minPerYear = ref(100)
const loading = ref(false) const loading = ref(false)
const data = ref<ProblemTrend[]>([]) const data = ref<ProblemTrend[]>([])
@@ -126,7 +126,11 @@ function getChartOptions(problem: ProblemTrend) {
async function fetchData() { async function fetchData() {
loading.value = true loading.value = true
try { try {
const res = await getTopACTrend({ since_year: sinceYear.value, until_year: untilYear.value, min_per_year: minPerYear.value }) const res = await getTopACTrend({
since_year: sinceYear.value,
until_year: untilYear.value,
min_per_year: minPerYear.value,
})
data.value = res.data data.value = res.data
} finally { } finally {
loading.value = false loading.value = false
@@ -171,7 +175,11 @@ onMounted(fetchData)
</div> </div>
<div v-else class="grid"> <div v-else class="grid">
<div v-for="problem in data" :key="problem.problem_id" class="chart-card"> <div v-for="problem in data" :key="problem.problem_id" class="chart-card">
<Line :data="getChartData(problem)" :options="getChartOptions(problem)" :plugins="[acLabelPlugin]" /> <Line
:data="getChartData(problem)"
:options="getChartOptions(problem)"
:plugins="[acLabelPlugin]"
/>
</div> </div>
</div> </div>
</n-spin> </n-spin>

View File

@@ -4,13 +4,14 @@ import { addProblemForContest } from "admin/api"
interface Props { interface Props {
problemID: number problemID: number
contestID: string contestID: string
nextDisplayId?: string
} }
const props = defineProps<Props>() const props = defineProps<Props>()
const emit = defineEmits(["added"]) const emit = defineEmits(["added"])
const message = useMessage() const message = useMessage()
const displayID = ref("") const displayID = ref(props.nextDisplayId || "")
async function addProblem() { async function addProblem() {
if (!displayID.value) return if (!displayID.value) return

View File

@@ -0,0 +1,397 @@
<script setup lang="ts">
import type { LANGUAGE } from "utils/types"
interface AstRule {
engine: string
target?: string
label?: string
exact?: number
min?: number
max?: number
message: string
}
interface Props {
modelValue: { [key: string]: AstRule[] } | null
languages: LANGUAGE[]
}
const props = defineProps<Props>()
const emit = defineEmits<{
(e: "update:modelValue", value: { [key: string]: AstRule[] } | null): void
}>()
const activeTab = ref(props.languages[0] || "Python3")
const ENGINE_OPTIONS: SelectOption[] = [
{
label: "节点检查",
type: "group",
key: "node_group",
children: [
{ label: "必须存在", value: "must_exist_node" },
{ label: "不能存在", value: "must_not_exist_node" },
{ label: "出现次数", value: "count_node" },
],
},
{
label: "函数调用",
type: "group",
key: "func_group",
children: [
{ label: "必须调用函数", value: "must_call_function" },
{ label: "不能调用函数", value: "must_not_call_function" },
{ label: "函数调用次数", value: "count_function_call" },
],
},
{
label: "方法调用",
type: "group",
key: "method_group",
children: [
{ label: "必须调用方法", value: "must_call_method" },
{ label: "不能调用方法", value: "must_not_call_method" },
],
},
{
label: "运算符",
type: "group",
key: "op_group",
children: [{ label: "必须使用运算符", value: "must_use_operator" }],
},
]
const NODE_TARGET_OPTIONS: SelectOption[] = [
{ label: "for 循环", value: "for_loop" },
{ label: "while 循环", value: "while_loop" },
{ label: "if 条件", value: "if_statement" },
{ label: "else 子句", value: "else_clause" },
{ label: "函数定义", value: "function_definition" },
{ label: "return 语句", value: "return" },
{ label: "break 语句", value: "break" },
{ label: "continue 语句", value: "continue" },
{ label: "列表推导式", value: "list_comprehension" },
{ label: "列表", value: "list_literal" },
{ label: "字典", value: "dict_literal" },
{ label: "集合", value: "set_literal" },
{ label: "f-string", value: "f_string" },
{ label: "try-except", value: "try_except" },
{ label: "类定义", value: "class_definition" },
]
const OPERATOR_TARGET_OPTIONS: SelectOption[] = [
{ label: "+", value: "+" },
{ label: "-", value: "-" },
{ label: "*", value: "*" },
{ label: "/", value: "/" },
{ label: "//", value: "//" },
{ label: "%", value: "%" },
{ label: "**", value: "**" },
{ label: "+=", value: "+=" },
{ label: "-=", value: "-=" },
{ label: "==", value: "==" },
{ label: "!=", value: "!=" },
{ label: ">", value: ">" },
{ label: ">=", value: ">=" },
{ label: "<", value: "<" },
{ label: "<=", value: "<=" },
{ label: "and / &&", value: "and" },
{ label: "or / ||", value: "or" },
{ label: "not / !", value: "not" },
]
const NODE_ENGINES = ["must_exist_node", "must_not_exist_node", "count_node"]
const FUNCTION_ENGINES = [
"must_call_function",
"must_not_call_function",
"count_function_call",
]
const METHOD_ENGINES = ["must_call_method", "must_not_call_method"]
const OPERATOR_ENGINES = ["must_use_operator"]
const COUNT_ENGINES = ["count_node", "count_function_call"]
function isNodeEngine(engine: string) {
return NODE_ENGINES.includes(engine)
}
function isFunctionEngine(engine: string) {
return FUNCTION_ENGINES.includes(engine)
}
function isMethodEngine(engine: string) {
return METHOD_ENGINES.includes(engine)
}
function isOperatorEngine(engine: string) {
return OPERATOR_ENGINES.includes(engine)
}
function isCountEngine(engine: string) {
return COUNT_ENGINES.includes(engine)
}
const COUNT_MODE_OPTIONS: SelectOption[] = [
{ label: "精确", value: "exact" },
{ label: "范围", value: "range" },
]
function getCountMode(rule: AstRule): "exact" | "range" {
return rule.exact !== undefined ? "exact" : "range"
}
function updateCountMode(lang: string, index: number, mode: "exact" | "range") {
const rules = [...getRulesForLang(lang)]
const rule = { ...rules[index] }
if (mode === "exact") {
rule.exact = rule.min ?? 1
delete rule.min
delete rule.max
} else {
delete rule.exact
}
rules[index] = rule
updateRules(lang, rules)
}
function updateExactCount(lang: string, index: number, v: number | null) {
const rules = [...getRulesForLang(lang)]
const rule = { ...rules[index] }
if (v === null) delete rule.exact
else rule.exact = v
rules[index] = rule
updateRules(lang, rules)
}
function needsTargetDropdown(engine: string) {
return isNodeEngine(engine)
}
function needsTargetInput(engine: string) {
return isFunctionEngine(engine) || isMethodEngine(engine)
}
function needsOperatorDropdown(engine: string) {
return isOperatorEngine(engine)
}
function getRulesForLang(lang: string): AstRule[] {
if (!props.modelValue) return []
return props.modelValue[lang] || []
}
function updateRules(lang: string, rules: AstRule[]) {
const current = { ...(props.modelValue || {}) }
if (rules.length === 0) {
delete current[lang]
} else {
current[lang] = rules
}
emit("update:modelValue", Object.keys(current).length > 0 ? current : null)
}
function getTargetLabel(engine: string, target: string): string | undefined {
if (isNodeEngine(engine))
return (NODE_TARGET_OPTIONS.find((o) => o.value === target) as any)?.label
if (isOperatorEngine(engine))
return (OPERATOR_TARGET_OPTIONS.find((o) => o.value === target) as any)
?.label
return undefined
}
function addRule(lang: string) {
const rules = [...getRulesForLang(lang)]
rules.push({
engine: "must_exist_node",
target: "for_loop",
label: "for 循环",
message: "",
})
updateRules(lang, rules)
}
function removeRule(lang: string, index: number) {
const rules = [...getRulesForLang(lang)]
rules.splice(index, 1)
updateRules(lang, rules)
}
function updateRule(lang: string, index: number, field: string, value: any) {
const rules = [...getRulesForLang(lang)]
const rule = { ...rules[index] }
if (field === "engine") {
rule.engine = value
if (isNodeEngine(value)) {
rule.target = "for_loop"
rule.label = "for 循环"
} else if (isOperatorEngine(value)) {
rule.target = "+"
rule.label = "+"
} else {
rule.target = ""
delete rule.label
}
delete rule.min
delete rule.max
delete rule.exact
} else if (field === "target") {
rule.target = value
const lbl = getTargetLabel(rule.engine, value)
if (lbl) rule.label = lbl
else delete rule.label
} else if (field === "min") {
if (value === null || value === undefined) delete rule.min
else rule.min = value
} else if (field === "max") {
if (value === null || value === undefined) delete rule.max
else rule.max = value
} else if (field === "message") {
rule.message = value
}
rules[index] = rule
updateRules(lang, rules)
}
watch(
() => props.languages,
(langs) => {
if (langs.length && !langs.includes(activeTab.value as LANGUAGE)) {
activeTab.value = langs[0]
}
},
)
</script>
<template>
<n-collapse>
<n-collapse-item title="代码规则检查(选填)" name="ast-rules">
<n-tabs v-if="languages.length" type="segment" v-model:value="activeTab">
<n-tab-pane
v-for="lang in languages"
:key="lang"
:name="lang"
:tab="lang"
>
<n-flex vertical>
<div
v-for="(rule, index) in getRulesForLang(lang)"
:key="index"
style="margin-bottom: 8px"
>
<n-flex align="center" :wrap="false">
<n-select
:options="ENGINE_OPTIONS"
:value="rule.engine"
@update:value="
(v: string) => updateRule(lang, index, 'engine', v)
"
style="width: 150px"
size="small"
/>
<n-select
v-if="needsTargetDropdown(rule.engine)"
:options="NODE_TARGET_OPTIONS"
:value="rule.target"
@update:value="
(v: string) => updateRule(lang, index, 'target', v)
"
style="width: 150px"
size="small"
filterable
/>
<n-input
v-if="needsTargetInput(rule.engine)"
:value="rule.target"
@update:value="
(v: string) => updateRule(lang, index, 'target', v)
"
placeholder="函数/方法名"
style="width: 150px"
size="small"
/>
<n-select
v-if="needsOperatorDropdown(rule.engine)"
:options="OPERATOR_TARGET_OPTIONS"
:value="rule.target"
@update:value="
(v: string) => updateRule(lang, index, 'target', v)
"
style="width: 150px"
size="small"
/>
<template v-if="isCountEngine(rule.engine)">
<n-select
:options="COUNT_MODE_OPTIONS"
:value="getCountMode(rule)"
@update:value="
(v: 'exact' | 'range') => updateCountMode(lang, index, v)
"
style="width: 80px"
size="small"
/>
<n-input-number
v-if="getCountMode(rule) === 'exact'"
:value="rule.exact ?? null"
@update:value="
(v: number | null) => updateExactCount(lang, index, v)
"
placeholder="次数"
style="width: 100px"
size="small"
:min="1"
clearable
/>
<template v-else>
<n-input-number
:value="rule.min ?? null"
@update:value="
(v: number | null) => updateRule(lang, index, 'min', v)
"
placeholder="最少"
style="width: 100px"
size="small"
:min="0"
clearable
/>
<n-input-number
:value="rule.max ?? null"
@update:value="
(v: number | null) => updateRule(lang, index, 'max', v)
"
placeholder="最多"
style="width: 100px"
size="small"
:min="0"
clearable
/>
</template>
</template>
<n-input
:value="rule.message"
@update:value="
(v: string) => updateRule(lang, index, 'message', v)
"
placeholder="错误提示(选填)"
style="flex: 1"
size="small"
/>
<n-button
size="small"
tertiary
type="error"
@click="removeRule(lang, index)"
>
删除
</n-button>
</n-flex>
</div>
<n-button
size="small"
tertiary
type="primary"
@click="addRule(lang)"
>
添加规则
</n-button>
</n-flex>
</n-tab-pane>
</n-tabs>
<n-empty v-else description="请先选择编程语言" />
</n-collapse-item>
</n-collapse>
</template>

View File

@@ -1,12 +1,13 @@
<script lang="ts" setup> <script lang="ts" setup>
import { getProblemList } from "admin/api" import { getProblemList } from "admin/api"
import Pagination from "shared/components/Pagination.vue" import Pagination from "shared/components/Pagination.vue"
import { AdminProblemFiltered } from "utils/types" import type { AdminProblemFiltered } from "utils/types"
import AddButton from "./AddButton.vue" import AddButton from "./AddButton.vue"
interface Props { interface Props {
show: boolean show: boolean
count: number count: number
nextDisplayId?: string
} }
const props = defineProps<Props>() const props = defineProps<Props>()
const emit = defineEmits<{ const emit = defineEmits<{
@@ -34,6 +35,7 @@ const columns: DataTableColumn<AdminProblemFiltered>[] = [
h(AddButton, { h(AddButton, {
problemID: row.id, problemID: row.id,
contestID: route.params.contestID as string, contestID: route.params.contestID as string,
nextDisplayId: props.nextDisplayId,
onAdded: () => emit("change"), onAdded: () => emit("change"),
}), }),
width: 60, width: 60,
@@ -53,7 +55,14 @@ watch(
}, },
) )
watch(() => [query.limit, query.page], getList) watch(() => [query.limit, query.page], getList)
watchDebounced(() => query.keyword, getList, { debounce: 500, maxWait: 1000 }) watchDebounced(
() => query.keyword,
() => {
query.page = 1
getList()
},
{ debounce: 500, maxWait: 1000 },
)
</script> </script>
<template> <template>
<n-modal <n-modal

View File

@@ -0,0 +1,264 @@
<script setup lang="ts">
import { downloadZip } from "client-zip"
import type { LANGUAGE, Testcase } from "utils/types"
import { createTestSubmission } from "utils/judge"
import { uploadTestcases } from "../../api"
interface FileEntry {
id: number
in: string
out: string
error: boolean
}
interface Props {
answers: { language: LANGUAGE; code: string }[]
samples?: { input: string; output: string }[]
}
const props = defineProps<Props>()
const emit = defineEmits<{
uploaded: [testCaseId: string, testCaseScore: Testcase[]]
}>()
const message = useMessage()
let nextId = 0
function makeInitialFiles(): FileEntry[] {
const fromSamples = (props.samples ?? []).map((s) => ({
id: nextId++,
in: s.input,
out: s.output,
error: false,
}))
const total = Math.ceil(Math.max(fromSamples.length, 1) / 5) * 5
const extra = total - fromSamples.length
return [
...fromSamples,
...Array.from({ length: extra }, () => ({
id: nextId++,
in: "",
out: "",
error: false,
})),
]
}
const files = ref<FileEntry[]>(makeInitialFiles())
const selectedLanguage = ref<LANGUAGE>("Python3")
// 始终显示所有语言,不管有没有答案代码
const availableLanguages = computed(() =>
props.answers.map((a) => ({ label: a.language, value: a.language })),
)
const hasAnyAnswerCode = computed(() =>
props.answers.some((a) => a.code.trim()),
)
// 当前选中语言是否有答案代码(用于控制"先运行"按钮)
const hasAnswerCode = computed(() => {
const answer = props.answers.find(
(a) => a.language === selectedLanguage.value,
)
return !!answer?.code.trim()
})
// 当语言列表变化时,确保 selectedLanguage 始终指向一个有效值
watch(
availableLanguages,
(langs) => {
if (
langs.length &&
!langs.find((l) => l.value === selectedLanguage.value)
) {
selectedLanguage.value = langs[0].value
}
},
{ immediate: true },
)
const isRunning = ref(false)
const isUploading = ref(false)
const hasAnyInput = computed(() => files.value.some((f) => f.in.trim()))
const canUpload = computed(
() =>
!isRunning.value &&
hasAnyInput.value &&
files.value.filter((f) => f.in.trim()).every((f) => f.out && !f.error),
)
function reset() {
files.value = Array.from({ length: 5 }, () => ({
id: nextId++,
in: "",
out: "",
error: false,
}))
}
function add(n: number) {
files.value.push(
...Array.from({ length: n }, () => ({
id: nextId++,
in: "",
out: "",
error: false,
})),
)
}
function remove(index: number) {
files.value.splice(index, 1)
}
async function run() {
const answer = props.answers.find(
(a) => a.language === selectedLanguage.value,
)
if (!answer?.code.trim()) return
// 过滤空行,去重(按输入内容)
const seen = new Set<string>()
files.value = files.value.filter((f) => {
if (!f.in.trim()) return false
if (seen.has(f.in)) return false
seen.add(f.in)
return true
})
// 清空旧输出
files.value = files.value.map((f) => ({ ...f, out: "", error: false }))
isRunning.value = true
await Promise.all(
files.value.map(async (_, i) => {
try {
const result = await createTestSubmission(
{ language: selectedLanguage.value, value: answer.code },
files.value[i].in,
)
files.value[i] = {
...files.value[i],
out: result.output,
error: result.status !== 3,
}
} catch {
files.value[i] = { ...files.value[i], out: "", error: true }
}
}),
)
isRunning.value = false
}
async function upload() {
isUploading.value = true
try {
const now = new Date()
const data = files.value
.filter((f) => f.in.trim() && f.out && !f.error)
.flatMap((f, i) => [
{ name: `${i + 1}.in`, input: f.in, lastModified: now },
{ name: `${i + 1}.out`, input: f.out, lastModified: now },
])
const blob = await downloadZip(data).blob()
const file = new File([blob], "testcase.zip", { type: "application/zip" })
const res = await uploadTestcases(file)
const testcases: Testcase[] = res.data.info
const baseScore = Math.floor(100 / testcases.length)
const remainder = 100 - baseScore * testcases.length
testcases.forEach((tc, i) => {
tc.score = String(
i === testcases.length - 1 ? baseScore + remainder : baseScore,
)
})
emit("uploaded", res.data.id, testcases)
message.success("上传成功")
} catch {
message.error("上传失败")
} finally {
isUploading.value = false
}
}
</script>
<template>
<n-flex vertical>
<n-alert
v-if="!hasAnyAnswerCode"
type="warning"
:show-icon="false"
style="margin-bottom: 8px"
>
还没有填写答案代码请先在上方"本题参考答案"中填写至少一种语言的答案再来生成测试用例
</n-alert>
<n-flex align="center" wrap>
<n-select
style="width: 120px"
:options="availableLanguages"
v-model:value="selectedLanguage"
/>
<n-button :disabled="isRunning" @click="reset">清空</n-button>
<n-button :disabled="isRunning" @click="add(1)">+1</n-button>
<n-button :disabled="isRunning" @click="add(5)">+5</n-button>
<n-tooltip :disabled="hasAnswerCode && hasAnyInput">
<template #trigger>
<span>
<n-button
type="success"
:loading="isRunning"
:disabled="!hasAnswerCode || !hasAnyInput"
@click="run"
>
先运行
</n-button>
</span>
</template>
{{ !hasAnswerCode ? "请先在题目中填写答案代码" : "请先填写输入" }}
</n-tooltip>
<n-button
type="primary"
:loading="isUploading"
:disabled="!canUpload"
@click="upload"
>
上传
</n-button>
</n-flex>
<n-flex
v-for="(file, index) in files"
:key="file.id"
align="start"
style="gap: 8px"
>
<n-flex vertical style="flex: 1">
<span>{{ index + 1 }}.in</span>
<n-input type="textarea" v-model:value="file.in" :rows="3" />
</n-flex>
<n-flex vertical style="flex: 1">
<span>{{ index + 1 }}.out</span>
<n-input
type="textarea"
v-model:value="file.out"
:rows="3"
:status="file.out ? (file.error ? 'error' : 'success') : undefined"
/>
</n-flex>
<n-button
:disabled="files.length === 1 || isRunning"
style="margin-top: 22px"
@click="remove(index)"
>
删除
</n-button>
</n-flex>
</n-flex>
</template>

View File

@@ -1,6 +1,8 @@
<script setup lang="ts"> <script setup lang="ts">
import { getProblemTagList } from "shared/api" import { getProblemTagList } from "shared/api"
import TextEditor from "shared/components/TextEditor.vue" import TextEditor from "shared/components/TextEditor.vue"
import TestcaseGenerator from "./components/TestcaseGenerator.vue"
import AstRulesEditor from "./components/AstRulesEditor.vue"
import { import {
CODE_TEMPLATES, CODE_TEMPLATES,
LANGUAGE_SHOW_VALUE, LANGUAGE_SHOW_VALUE,
@@ -8,7 +10,7 @@ import {
} from "utils/constants" } from "utils/constants"
import download from "utils/download" import download from "utils/download"
import { unique } from "utils/functions" import { unique } from "utils/functions"
import { BlankProblem, LANGUAGE, Tag, Testcase } from "utils/types" import type { BlankProblem, LANGUAGE, Tag, Testcase } from "utils/types"
import { import {
createContestProblem, createContestProblem,
createProblem, createProblem,
@@ -86,14 +88,46 @@ const problem = useLocalStorage<BlankProblem>(STORAGE_KEY.ADMIN_PROBLEM, {
flowchart_data: {}, flowchart_data: {},
flowchart_hint: "", flowchart_hint: "",
show_flowchart: false, show_flowchart: false,
ast_rules: null as { [key: string]: any[] } | null,
}) })
// 从服务器来的tag列表 // 从服务器来的tag列表
const tagList = shallowRef<Tag[]>([]) const tagList = shallowRef<Tag[]>([])
const tagListLoaded = ref(false)
const selectedTags = ref<string[]>([]) const selectedTags = ref<string[]>([])
const newTags = ref<string[]>([]) const newTags = ref<string[]>([])
const selectedTagSet = computed(() => new Set(selectedTags.value)) const selectedTagSet = computed(() => new Set(selectedTags.value))
let syncingTagInputs = false
function normalizeTagNames(tags: unknown): string[] {
if (!Array.isArray(tags)) return []
return unique(
tags
.map((tag) => (typeof tag === "string" ? tag : tag?.name))
.filter((tag): tag is string => !!tag),
)
}
function syncProblemTags() {
problem.value.tags = unique([...selectedTags.value, ...newTags.value])
}
function syncTagInputsFromProblemTags(tags: unknown = problem.value.tags) {
const tagNames = normalizeTagNames(tags)
const existingTagNames = new Set(tagList.value.map((tag) => tag.name))
syncingTagInputs = true
if (!tagListLoaded.value) {
selectedTags.value = tagNames
newTags.value = []
} else {
selectedTags.value = tagNames.filter((tag) => existingTagNames.has(tag))
newTags.value = tagNames.filter((tag) => !existingTagNames.has(tag))
}
syncingTagInputs = false
syncProblemTags()
}
function toggleTag(name: string) { function toggleTag(name: string) {
const set = new Set(selectedTags.value) const set = new Set(selectedTags.value)
@@ -139,9 +173,9 @@ const languageOptions = [
{ label: LANGUAGE_SHOW_VALUE["C++"], value: "C++" }, { label: LANGUAGE_SHOW_VALUE["C++"], value: "C++" },
] ]
async function getProblemDetail() { async function getProblemDetail() {
if (!props.problemID) { if (!props.problemID) {
syncTagInputsFromProblemTags()
toggleReady(true) toggleReady(true)
return return
} }
@@ -159,7 +193,7 @@ async function getProblemDetail() {
problem.value.difficulty = data.difficulty problem.value.difficulty = data.difficulty
problem.value.visible = data.visible problem.value.visible = data.visible
problem.value.share_submission = data.share_submission problem.value.share_submission = data.share_submission
problem.value.tags = data.tags problem.value.tags = normalizeTagNames(data.tags)
problem.value.languages = data.languages problem.value.languages = data.languages
problem.value.template = data.template problem.value.template = data.template
problem.value.samples = data.samples problem.value.samples = data.samples
@@ -176,6 +210,7 @@ async function getProblemDetail() {
problem.value.mermaid_code = data.mermaid_code ?? "" problem.value.mermaid_code = data.mermaid_code ?? ""
problem.value.flowchart_hint = data.flowchart_hint ?? "" problem.value.flowchart_hint = data.flowchart_hint ?? ""
problem.value.flowchart_data = data.flowchart_data problem.value.flowchart_data = data.flowchart_data
problem.value.ast_rules = data.ast_rules ?? null
if (data.answers && data.answers.length) { if (data.answers && data.answers.length) {
problem.value.answers = data.answers problem.value.answers = data.answers
} else { } else {
@@ -198,8 +233,7 @@ async function getProblemDetail() {
} }
}) })
// 标签 // 标签
selectedTags.value = data.tags syncTagInputsFromProblemTags(problem.value.tags)
newTags.value = []
toggleReady(true) toggleReady(true)
} catch (error) { } catch (error) {
message.error("获取题目失败") message.error("获取题目失败")
@@ -210,6 +244,8 @@ async function getProblemDetail() {
async function getTagList() { async function getTagList() {
const res = await getProblemTagList() const res = await getProblemTagList()
tagList.value = res.data tagList.value = res.data
tagListLoaded.value = true
syncTagInputsFromProblemTags()
} }
function addSample() { function addSample() {
@@ -349,6 +385,7 @@ async function submit() {
filterHint() filterHint()
getTemplate() getTemplate()
filterAnswers() filterAnswers()
syncProblemTags()
const api = { const api = {
"admin problem create": createProblem, "admin problem create": createProblem,
"admin problem edit": editProblem, "admin problem edit": editProblem,
@@ -416,13 +453,25 @@ async function generateMermaid() {
problem.value.mermaid_code = res.data.flowchart problem.value.mermaid_code = res.data.flowchart
} }
const showGeneratorModal = ref(false)
function handleTestcasesGenerated(
testCaseId: string,
testCaseScore: Testcase[],
) {
problem.value.test_case_id = testCaseId
problem.value.test_case_score = testCaseScore
showGeneratorModal.value = false
}
onMounted(() => { onMounted(() => {
getTagList() getTagList()
getProblemDetail() getProblemDetail()
}) })
watch([selectedTags, newTags], ([sel, newT]) => { watch([selectedTags, newTags], ([sel, newT]) => {
problem.value.tags = [...sel, ...newT] if (syncingTagInputs) return
problem.value.tags = unique([...sel, ...newT])
}) })
watch( watch(
() => problem.value.languages, () => problem.value.languages,
@@ -632,6 +681,83 @@ watch(
</n-gi> </n-gi>
</n-grid> </n-grid>
<n-grid :cols="2">
<n-gi :span="1">
<AstRulesEditor
v-model="problem.ast_rules!"
:languages="problem.languages"
/>
</n-gi>
</n-grid>
<n-divider />
<h2 class="title">测试用例区域</h2>
<n-flex align="center" style="margin-bottom: 12px">
<div>
<n-button type="success" @click="showGeneratorModal = true">
直接生成
</n-button>
</div>
<div>
<n-upload
:show-file-list="false"
accept=".zip"
:custom-request="handleUploadTestcases"
>
<n-button type="info">手动上传</n-button>
</n-upload>
</div>
<n-tooltip placement="right">
<template #trigger>
<n-button text>温馨提醒</n-button>
</template>
测试用例最好要有10个要考虑边界情况且不要跟测试样例一模一样
</n-tooltip>
</n-flex>
<n-alert
class="box"
v-if="problem.test_case_score.length"
:show-icon="false"
type="info"
>
<template #header>
<n-flex align="center">
<div>
测试组编号 {{ problem.test_case_id.slice(0, 12) }} 共有
{{ problem.test_case_score.length }}
条测试用例
</div>
<n-button
v-if="problem.id"
tertiary
type="info"
size="small"
@click="downloadTestcases"
>
下载
</n-button>
</n-flex>
</template>
</n-alert>
<n-modal
v-model:show="showGeneratorModal"
preset="card"
title="测试用例生成器"
style="width: 80vw; max-width: 900px"
:mask-closable="false"
display-directive="show"
>
<TestcaseGenerator
:answers="problem.answers"
:samples="problem.samples"
@uploaded="handleTestcasesGenerated"
/>
</n-modal>
<n-divider /> <n-divider />
<h2 class="title">流程图区域</h2> <h2 class="title">流程图区域</h2>
@@ -674,48 +800,7 @@ watch(
/> />
</n-form-item> </n-form-item>
</n-form> </n-form>
<n-divider />
<n-alert
class="box"
v-if="problem.test_case_score.length"
:show-icon="false"
type="info"
>
<template #header>
<n-flex align="center">
<div>
测试组编号 {{ problem.test_case_id.slice(0, 12) }} 共有
{{ problem.test_case_score.length }}
条测试用例
</div>
<n-button
v-if="problem.id"
tertiary
type="info"
size="small"
@click="downloadTestcases"
>
下载
</n-button>
</n-flex>
</template>
</n-alert>
<n-flex style="margin-bottom: 120px" align="center" justify="end"> <n-flex style="margin-bottom: 120px" align="center" justify="end">
<n-tooltip placement="left">
<template #trigger>
<n-button text>温馨提醒</n-button>
</template>
测试用例最好要有10个要考虑边界情况且不要跟测试样例一模一样
</n-tooltip>
<div>
<n-upload
:show-file-list="false"
accept=".zip"
:custom-request="handleUploadTestcases"
>
<n-button type="info">上传测试用例</n-button>
</n-upload>
</div>
<n-button type="primary" @click="submit">提交</n-button> <n-button type="primary" @click="submit">提交</n-button>
</n-flex> </n-flex>
</template> </template>

View File

@@ -1,9 +1,11 @@
<script setup lang="ts"> <script setup lang="ts">
import { NSwitch } from "naive-ui" import { NFlex, NSwitch, NTag } from "naive-ui"
import { Icon } from "@iconify/vue"
import Pagination from "shared/components/Pagination.vue" import Pagination from "shared/components/Pagination.vue"
import { usePagination } from "shared/composables/pagination" import { usePagination } from "shared/composables/pagination"
import { parseTime } from "utils/functions" import { getTagColor, parseTime } from "utils/functions"
import { AdminProblemFiltered } from "utils/types" import type { AdminProblemFiltered } from "utils/types"
import { DIFFICULTY } from "utils/constants"
import { getProblemList, toggleProblemVisible } from "../api" import { getProblemList, toggleProblemVisible } from "../api"
import Actions from "./components/Actions.vue" import Actions from "./components/Actions.vue"
import Modal from "./components/Modal.vue" import Modal from "./components/Modal.vue"
@@ -34,6 +36,16 @@ const { count, inc } = useCounter(0)
const total = ref(0) const total = ref(0)
const problems = ref<AdminProblemFiltered[]>([]) const problems = ref<AdminProblemFiltered[]>([])
const nextDisplayID = computed(() => {
if (!isContestProblemList.value) return ""
if (problems.value.length === 0) return "1"
const ids = problems.value.map((p) => p._id)
if (ids.every((id) => /^\d+$/.test(id))) {
return String(Math.max(...ids.map((id) => parseInt(id))) + 1)
}
return ""
})
interface ProblemQuery { interface ProblemQuery {
keyword: string keyword: string
author: string author: string
@@ -48,8 +60,56 @@ const { query, clearQuery } = usePagination<ProblemQuery>({
const columns: DataTableColumn<AdminProblemFiltered>[] = [ const columns: DataTableColumn<AdminProblemFiltered>[] = [
{ title: "ID", key: "id", width: 100 }, { title: "ID", key: "id", width: 100 },
{ title: "显示编号", key: "_id", width: 100 }, { title: "显示编号", key: "_id", width: 100 },
{ title: "标题", key: "title", minWidth: 300 }, { title: "标题", key: "title", minWidth: 200 },
{ title: "出题人", key: "username", width: 160 }, {
title: "难度",
key: "difficulty",
width: 80,
render: (row) =>
h(
NTag,
{ type: getTagColor(row.difficulty), size: "small" },
() => DIFFICULTY[row.difficulty],
),
},
{
title: "标签",
key: "tags",
minWidth: 120,
render: (row) =>
h(NFlex, { size: 4 }, () =>
row.tags.map((t) => h(NTag, { key: t, size: "small" }, () => t)),
),
},
{
title: "功能",
key: "features",
width: 80,
render: (row) =>
h(NFlex, { size: 4, align: "center" }, () => [
row.allow_flowchart
? h(Icon, {
width: 18,
icon: "vscode-icons:file-type-drawio",
title: "绘图",
})
: row.show_flowchart
? h(Icon, {
width: 18,
icon: "vscode-icons:file-type-graphql",
title: "流程图",
})
: null,
row.has_ast_rules
? h(Icon, {
width: 18,
icon: "vscode-icons:file-type-light-todo",
title: "AST",
})
: null,
]),
},
{ title: "出题人", key: "username", width: 120 },
{ {
title: "创建时间", title: "创建时间",
key: "create_time", key: "create_time",
@@ -59,7 +119,7 @@ const columns: DataTableColumn<AdminProblemFiltered>[] = [
{ {
title: "可见", title: "可见",
key: "visible", key: "visible",
minWidth: 80, minWidth: 100,
render: (row) => render: (row) =>
h(NSwitch, { h(NSwitch, {
value: row.visible, value: row.visible,
@@ -71,7 +131,7 @@ const columns: DataTableColumn<AdminProblemFiltered>[] = [
{ {
title: "选项", title: "选项",
key: "actions", key: "actions",
width: 330, width: 320,
render: (row) => render: (row) =>
h(Actions, { h(Actions, {
problemID: row.id, problemID: row.id,
@@ -184,7 +244,12 @@ watch(() => [query.page, query.limit, query.author], listProblems)
v-model:limit="query.limit" v-model:limit="query.limit"
v-model:page="query.page" v-model:page="query.page"
/> />
<Modal v-model:show="show" :count="count" @change="listProblems" /> <Modal
v-model:show="show"
:count="count"
:next-display-id="nextDisplayID"
@change="listProblems"
/>
</template> </template>
<style scoped> <style scoped>

View File

@@ -2,7 +2,7 @@
import Pagination from "shared/components/Pagination.vue" import Pagination from "shared/components/Pagination.vue"
import { usePagination } from "shared/composables/pagination" import { usePagination } from "shared/composables/pagination"
import { parseTime } from "utils/functions" import { parseTime } from "utils/functions"
import { ProblemSetList } from "utils/types" import type { ProblemSetList } from "utils/types"
import { getProblemSetList, toggleProblemSetVisible } from "../api" import { getProblemSetList, toggleProblemSetVisible } from "../api"
import Actions from "./components/Actions.vue" import Actions from "./components/Actions.vue"
import { NTag, NSwitch } from "naive-ui" import { NTag, NSwitch } from "naive-ui"
@@ -58,7 +58,11 @@ const columns: DataTableColumn<ProblemSetList>[] = [
Hard: { type: "error" as const, text: "困难" }, Hard: { type: "error" as const, text: "困难" },
} }
const config = difficultyMap[row.difficulty] const config = difficultyMap[row.difficulty]
return h(NTag, { type: config.type }, { default: () => config.text }) return h(
NTag,
{ type: config.type, size: "small" },
{ default: () => config.text },
)
}, },
}, },
{ {
@@ -72,7 +76,11 @@ const columns: DataTableColumn<ProblemSetList>[] = [
draft: { type: "info" as const, text: "草稿" }, draft: { type: "info" as const, text: "草稿" },
} }
const config = statusMap[row.status] const config = statusMap[row.status]
return h(NTag, { type: config.type }, { default: () => config.text }) return h(
NTag,
{ type: config.type, size: "small" },
{ default: () => config.text },
)
}, },
}, },
{ {
@@ -84,7 +92,7 @@ const columns: DataTableColumn<ProblemSetList>[] = [
{ {
title: "可见", title: "可见",
key: "visible", key: "visible",
width: 80, width: 100,
render: (row) => render: (row) =>
h(NSwitch, { h(NSwitch, {
value: row.visible, value: row.visible,

18
src/admin/transforms.ts Normal file
View File

@@ -0,0 +1,18 @@
import type { AdminProblem } from "utils/types"
// 把后端的 AdminProblem 塑形成管理端列表项,与请求逻辑解耦。
export function toProblemListItem(result: AdminProblem) {
return {
id: result.id,
_id: result._id,
title: result.title,
username: result.created_by.username,
create_time: result.create_time,
visible: result.visible,
difficulty: result.difficulty,
tags: result.tags,
has_ast_rules: result.has_ast_rules,
allow_flowchart: result.allow_flowchart,
show_flowchart: result.show_flowchart,
}
}

View File

@@ -282,7 +282,7 @@ function typeTagType(type: string): "success" | "info" | "warning" {
type="textarea" type="textarea"
:rows="10" :rows="10"
placeholder="在此粘贴正确的代码,保存后将自动按行拆分并乱序" placeholder="在此粘贴正确的代码,保存后将自动按行拆分并乱序"
style="font-family: 'Monaco'" style="font-family: &quot;Monaco&quot;"
/> />
</n-form-item> </n-form-item>
</template> </template>
@@ -302,7 +302,7 @@ function typeTagType(type: string): "success" | "info" | "warning" {
type="textarea" type="textarea"
:rows="10" :rows="10"
placeholder="用 {{答案}} 标记空位,多个合法答案用 | 分隔例如for {{i|idx}} in range(10):" placeholder="用 {{答案}} 标记空位,多个合法答案用 | 分隔例如for {{i|idx}} in range(10):"
style="font-family: 'Monaco'" style="font-family: &quot;Monaco&quot;"
/> />
</n-form-item> </n-form-item>
</template> </template>

View File

@@ -24,7 +24,13 @@ const isNotRegularUser = computed(
> >
{{ getUserRole(props.user.admin_type).label }} {{ getUserRole(props.user.admin_type).label }}
</n-tag> </n-tag>
<n-tag size="small" v-if="props.user.admin_type === USER_TYPE.ADMIN"> <n-tag
size="small"
v-if="
props.user.admin_type === USER_TYPE.STUDENT_ADMIN ||
props.user.admin_type === USER_TYPE.TEACHER_ADMIN
"
>
{{ {{
props.user.problem_permission === PROBLEM_PERMISSION.ALL props.user.problem_permission === PROBLEM_PERMISSION.ALL
? "全部" ? "全部"

View File

@@ -38,7 +38,8 @@ const userEditing = ref<User | null>(null)
const adminOptions = [ const adminOptions = [
{ label: "全部用户", value: "" }, { label: "全部用户", value: "" },
{ label: "管理员", value: USER_TYPE.ADMIN }, { label: "学生管理员", value: USER_TYPE.STUDENT_ADMIN },
{ label: "教师管理员", value: USER_TYPE.TEACHER_ADMIN },
{ label: "超级管理员", value: USER_TYPE.SUPER_ADMIN }, { label: "超级管理员", value: USER_TYPE.SUPER_ADMIN },
] ]
@@ -106,7 +107,8 @@ const columns: DataTableColumn<User>[] = [
const options: SelectOption[] = [ const options: SelectOption[] = [
{ label: "普通", value: USER_TYPE.REGULAR_USER }, { label: "普通", value: USER_TYPE.REGULAR_USER },
{ label: "管理员", value: USER_TYPE.ADMIN }, { label: "学生管理员", value: USER_TYPE.STUDENT_ADMIN },
{ label: "教师管理员", value: USER_TYPE.TEACHER_ADMIN },
{ label: "超级管理员", value: USER_TYPE.SUPER_ADMIN }, { label: "超级管理员", value: USER_TYPE.SUPER_ADMIN },
] ]
@@ -166,7 +168,7 @@ function createNewUser() {
username: "", username: "",
real_name: "", real_name: "",
email: "", email: "",
admin_type: "Admin", admin_type: "Student Admin",
problem_permission: "None", problem_permission: "None",
create_time: new Date(), create_time: new Date(),
last_login: new Date(), last_login: new Date(),
@@ -312,7 +314,11 @@ watch(() => [query.page, query.limit, query.type, query.orderBy], listUsers)
<n-input v-model:value="password" /> <n-input v-model:value="password" />
</n-form-item-gi> </n-form-item-gi>
<n-form-item-gi <n-form-item-gi
v-if="!create && userEditing.admin_type === USER_TYPE.ADMIN" v-if="
!create &&
(userEditing.admin_type === USER_TYPE.STUDENT_ADMIN ||
userEditing.admin_type === USER_TYPE.TEACHER_ADMIN)
"
:span="1" :span="1"
label="出题权限" label="出题权限"
> >

View File

@@ -40,7 +40,9 @@ router.beforeEach(async (to, from, next) => {
if ( if (
to.matched.some( to.matched.some(
(record) => (record) =>
record.meta.requiresSuperAdmin || record.meta.requiresProblemPermission, record.meta.requiresSuperAdmin ||
record.meta.requiresTeacherAdmin ||
record.meta.requiresProblemPermission,
) )
) { ) {
if (!storage.get(STORAGE_KEY.AUTHED)) { if (!storage.get(STORAGE_KEY.AUTHED)) {
@@ -63,6 +65,11 @@ router.beforeEach(async (to, from, next) => {
next("/") next("/")
return return
} }
} else if (to.matched.some((record) => record.meta.requiresTeacherAdmin)) {
if (!userStore.isTeacherOrAbove) {
next("/")
return
}
} else if ( } else if (
to.matched.some((record) => record.meta.requiresProblemPermission) to.matched.some((record) => record.meta.requiresProblemPermission)
) { ) {

1
src/mermaid-legacy.d.ts vendored Normal file
View File

@@ -0,0 +1 @@
declare module "mermaid-legacy"

View File

@@ -36,8 +36,18 @@ async function handleAnalyze() {
if (aiStore.loading.fetching || aiStore.loading.ai) { if (aiStore.loading.fetching || aiStore.loading.ai) {
return return
} }
await aiStore.fetchAIAnalysis() if (aiStore.pinnedReport) {
await aiStore.simulatePinnedStream()
} else {
await aiStore.fetchAIAnalysis()
}
} }
onMounted(async () => {
if (!aiStore.targetUsername) {
await aiStore.fetchPinnedReport()
}
})
</script> </script>
<style scoped> <style scoped>
.cool-title { .cool-title {

View File

@@ -60,7 +60,7 @@ import { useAIStore } from "oj/store/ai"
import { parseTime } from "utils/functions" import { parseTime } from "utils/functions"
const aiStore = useAIStore() const aiStore = useAIStore()
const containerRef = ref<HTMLElement>() const containerRef = useTemplateRef<HTMLElement>("containerRef")
const CELL_SIZE = 12 const CELL_SIZE = 12
const CELL_GAP = 3 const CELL_GAP = 3

View File

@@ -1,7 +1,6 @@
import { DIFFICULTY } from "utils/constants"
import { getACRate } from "utils/functions"
import http from "utils/http" import http from "utils/http"
import { import { filterResult } from "oj/transforms"
import type {
Exercise, Exercise,
Problem, Problem,
Submission, Submission,
@@ -9,30 +8,6 @@ import {
SubmitCodePayload, SubmitCodePayload,
} from "utils/types" } from "utils/types"
function filterResult(result: Problem) {
const newResult = {
id: result.id,
_id: result._id,
title: result.title,
difficulty: DIFFICULTY[result.difficulty],
tags: result.tags,
submission: result.submission_number,
rate: getACRate(result.accepted_number, result.submission_number),
status: "",
author: result.created_by.username,
allow_flowchart: result.allow_flowchart,
show_flowchart: result.show_flowchart,
}
if (result.my_status === null || result.my_status === undefined) {
newResult.status = "not_test"
} else if (result.my_status === 0) {
newResult.status = "passed"
} else {
newResult.status = "failed"
}
return newResult
}
export function getWebsiteConfig() { export function getWebsiteConfig() {
return http.get("website") return http.get("website")
} }
@@ -42,17 +17,9 @@ export async function getProblemList(
limit = 10, limit = 10,
searchParams: any = {}, searchParams: any = {},
) { ) {
let params: any = { const res = await http.get<{ results: Problem[]; total: number }>("problem", {
paging: true, params: { paging: true, offset, limit, ...searchParams },
offset,
limit,
}
Object.keys(searchParams).forEach((element) => {
if (searchParams[element]) {
params[element] = searchParams[element]
}
}) })
const res = await http.get("problem", { params })
return { return {
results: res.data.results.map(filterResult), results: res.data.results.map(filterResult),
total: res.data.total, total: res.data.total,
@@ -95,6 +62,10 @@ export function submitCode(data: SubmitCodePayload) {
return http.post("submission", data) return http.post("submission", data)
} }
export function formatCode(data: { code: string; language: string }) {
return http.post<{ code: string }>("format_code", data)
}
export function getSubmissions(params: Partial<SubmissionListPayload>) { export function getSubmissions(params: Partial<SubmissionListPayload>) {
const endpoint = !!params.contest_id ? "contest_submissions" : "submissions" const endpoint = !!params.contest_id ? "contest_submissions" : "submissions"
return http.get(endpoint, { params }) return http.get(endpoint, { params })
@@ -104,8 +75,8 @@ export function getRankOfProblem(problem_id: string) {
return http.get("user_problem_rank", { params: { problem_id: problem_id } }) return http.get("user_problem_rank", { params: { problem_id: problem_id } })
} }
export function getTodaySubmissionCount() { export function getTodaySubmissionCount(language?: string) {
return http.get("submissions/today_count") return http.get("submissions/today_count", { params: { language } })
} }
export function adminRejudge(id: string) { export function adminRejudge(id: string) {
@@ -115,7 +86,7 @@ export function adminRejudge(id: string) {
} }
export function getSubmissionStatistics( export function getSubmissionStatistics(
duration: { start: string; end: string }, duration: { start?: string; end: string },
problemID?: string, problemID?: string,
username?: string, username?: string,
) { ) {
@@ -202,7 +173,7 @@ export function checkContestPassword(contestID: string, password: string) {
} }
export async function getContestProblems(contestID: string) { export async function getContestProblems(contestID: string) {
const res = await http.get("contest/problem", { const res = await http.get<Problem[]>("contest/problem", {
params: { contest_id: contestID }, params: { contest_id: contestID },
}) })
return res.data.map(filterResult) return res.data.map(filterResult)
@@ -210,7 +181,7 @@ export async function getContestProblems(contestID: string) {
export function getContestRank( export function getContestRank(
contestID: string, contestID: string,
query: { limit: number; offset: number; force_refresh: "1" | "0" }, query: { limit: number; offset: number },
) { ) {
return http.get("contest_rank", { return http.get("contest_rank", {
params: { params: {
@@ -307,6 +278,10 @@ export function getAILoginSummary() {
return http.get("ai/login_summary") return http.get("ai/login_summary")
} }
export function getAIPinnedReport() {
return http.get("ai/pinned")
}
// ==================== 相似题目推荐 ==================== // ==================== 相似题目推荐 ====================
export function getSimilarProblems(problemId: string) { export function getSimilarProblems(problemId: string) {
@@ -348,10 +323,26 @@ export function getFlowchartSubmissions(params: {
myself?: string myself?: string
offset?: number offset?: number
limit?: number limit?: number
today?: string
grade?: string
}) { }) {
return http.get("flowchart/submissions", { params }) return http.get("flowchart/submissions", { params })
} }
export function getFlowchartStatistics(
duration: { start?: string; end: string },
problemID?: string,
username?: string,
) {
return http.get("admin/flowchart/statistics", {
params: {
...duration,
problem_id: problemID,
username,
},
})
}
export function retryFlowchartSubmission(submissionId: string) { export function retryFlowchartSubmission(submissionId: string) {
return http.post("flowchart/submission/retry", { return http.post("flowchart/submission/retry", {
submission_id: submissionId, submission_id: submissionId,
@@ -440,7 +431,7 @@ export function getProblemSetUserProgress(
} }
export async function getExercises(tutorialId: number): Promise<Exercise[]> { export async function getExercises(tutorialId: number): Promise<Exercise[]> {
const res = await http.get("exercises", { const res = await http.get<Exercise[]>("exercises", {
params: { tutorial_id: tutorialId }, params: { tutorial_id: tutorialId },
}) })
return res.data return res.data

View File

@@ -3,9 +3,14 @@ import { h } from "vue"
import { formatISO, sub, type Duration } from "date-fns" import { formatISO, sub, type Duration } from "date-fns"
import { getClassPK } from "oj/api" import { getClassPK } from "oj/api"
import { useConfigStore } from "shared/store/config" import { useConfigStore } from "shared/store/config"
import { useUserStore } from "shared/store/user"
import { Icon } from "@iconify/vue" import { Icon } from "@iconify/vue"
import { Bar, Radar } from "vue-chartjs" import { Bar, Radar } from "vue-chartjs"
import { useBreakpoints } from "shared/composables/breakpoints" import { useBreakpoints } from "shared/composables/breakpoints"
import { MdPreview } from "md-editor-v3"
import "md-editor-v3/lib/preview.css"
import { consumeJSONEventStream } from "utils/stream"
import { getCSRFToken } from "utils/functions"
import { import {
Chart as ChartJS, Chart as ChartJS,
CategoryScale, CategoryScale,
@@ -37,6 +42,7 @@ ChartJS.register(
) )
const configStore = useConfigStore() const configStore = useConfigStore()
const { isTeacherOrAbove } = useUserStore()
const message = useMessage() const message = useMessage()
const { isDesktop } = useBreakpoints() const { isDesktop } = useBreakpoints()
@@ -52,13 +58,13 @@ interface ClassComparison {
iqr: number iqr: number
std_dev: number std_dev: number
top_10_avg: number top_10_avg: number
middle_80_avg: number
bottom_10_avg: number bottom_10_avg: number
top_25_avg: number
bottom_25_avg: number
excellent_rate: number excellent_rate: number
pass_rate: number pass_rate: number
active_rate: number active_rate: number
ac_rate: number ac_rate: number
composite_score: number
recent_total_ac?: number recent_total_ac?: number
recent_avg_ac?: number recent_avg_ac?: number
recent_median_ac?: number recent_median_ac?: number
@@ -72,6 +78,11 @@ const duration = ref<string>("")
const loading = ref(false) const loading = ref(false)
const hasTimeRange = ref(false) const hasTimeRange = ref(false)
const aiLoading = ref(false)
const aiContent = ref("")
const showAIModal = ref(false)
let aiController: AbortController | null = null
// 时间段选项(与 rank/list.vue 保持一致) // 时间段选项(与 rank/list.vue 保持一致)
const timeRangeOptions: SelectOption[] = [ const timeRangeOptions: SelectOption[] = [
{ label: "全部时间", value: "" }, { label: "全部时间", value: "" },
@@ -145,6 +156,73 @@ async function compare() {
} }
} }
async function analyzeWithAI() {
if (aiController) {
aiController.abort()
}
const controller = new AbortController()
aiController = controller
const timeRangeLabel =
timeRangeOptions.find((o) => o.value === duration.value)?.label ??
"全部时间"
showAIModal.value = true
aiContent.value = ""
aiLoading.value = true
const headers: Record<string, string> = { "Content-Type": "application/json" }
const csrfToken = getCSRFToken()
if (csrfToken) headers["X-CSRFToken"] = csrfToken
try {
const response = await fetch("/api/ai/class_pk", {
method: "POST",
headers,
body: JSON.stringify({
comparisons: comparisons.value,
time_range_label: timeRangeLabel,
}),
signal: controller.signal,
})
if (!response.ok) throw new Error("AI 分析生成失败")
let hasStarted = false
await consumeJSONEventStream(response, {
signal: controller.signal,
onEvent(event) {
if (event === "end" && !hasStarted) aiLoading.value = false
},
onMessage(payload) {
const parsed = payload as {
type?: string
content?: string
message?: string
}
if (parsed.type === "delta" && parsed.content) {
if (!hasStarted) {
hasStarted = true
aiLoading.value = false
}
aiContent.value += parsed.content
} else if (parsed.type === "error") {
throw new Error(parsed.message || "AI 服务异常")
} else if (parsed.type === "done" && !hasStarted) {
aiLoading.value = false
}
},
})
} catch (error: any) {
if (controller.signal.aborted) return
message.error(error?.message || "AI 分析失败,请稍后再试")
aiLoading.value = false
} finally {
if (aiController === controller) aiController = null
}
}
// 计算排名颜色 // 计算排名颜色
function getRankColor(index: number) { function getRankColor(index: number) {
if (index === 0) return { type: "success" as const, text: "1" } if (index === 0) return { type: "success" as const, text: "1" }
@@ -170,6 +248,24 @@ function getClassColor(index: number) {
return colors[index % colors.length] return colors[index % colors.length]
} }
// 综合分对比图
const compositeScoreChartData = computed(() => {
if (comparisons.value.length === 0) return null
const labels = comparisons.value.map((c) => c.class_name)
const datasets = [
{
label: "综合分",
data: comparisons.value.map((c) => c.composite_score),
backgroundColor: comparisons.value.map((_, i) => getClassColor(i).bg),
borderColor: comparisons.value.map((_, i) => getClassColor(i).border),
borderWidth: 2,
},
]
return { labels, datasets }
})
// 总AC数对比图 - 每个班级用不同颜色 // 总AC数对比图 - 每个班级用不同颜色
const totalAcChartData = computed(() => { const totalAcChartData = computed(() => {
if (comparisons.value.length === 0) return null if (comparisons.value.length === 0) return null
@@ -278,14 +374,14 @@ const activeRateChartData = computed(() => {
return { labels, datasets } return { labels, datasets }
}) })
// 前10平均对比图 // 前10%平均对比图
const top10AvgChartData = computed(() => { const top10AvgChartData = computed(() => {
if (comparisons.value.length === 0) return null if (comparisons.value.length === 0) return null
const labels = comparisons.value.map((c) => c.class_name) const labels = comparisons.value.map((c) => c.class_name)
const datasets = [ const datasets = [
{ {
label: "前10平均", label: "前10%平均",
data: comparisons.value.map((c) => c.top_10_avg), data: comparisons.value.map((c) => c.top_10_avg),
backgroundColor: comparisons.value.map((_, i) => getClassColor(i).bg), backgroundColor: comparisons.value.map((_, i) => getClassColor(i).bg),
borderColor: comparisons.value.map((_, i) => getClassColor(i).border), borderColor: comparisons.value.map((_, i) => getClassColor(i).border),
@@ -296,14 +392,14 @@ const top10AvgChartData = computed(() => {
return { labels, datasets } return { labels, datasets }
}) })
// 后10平均对比图 // 后10%平均对比图
const bottom10AvgChartData = computed(() => { const bottom10AvgChartData = computed(() => {
if (comparisons.value.length === 0) return null if (comparisons.value.length === 0) return null
const labels = comparisons.value.map((c) => c.class_name) const labels = comparisons.value.map((c) => c.class_name)
const datasets = [ const datasets = [
{ {
label: "后10平均", label: "后10%平均",
data: comparisons.value.map((c) => c.bottom_10_avg), data: comparisons.value.map((c) => c.bottom_10_avg),
backgroundColor: comparisons.value.map((_, i) => getClassColor(i).bg), backgroundColor: comparisons.value.map((_, i) => getClassColor(i).bg),
borderColor: comparisons.value.map((_, i) => getClassColor(i).border), borderColor: comparisons.value.map((_, i) => getClassColor(i).border),
@@ -314,33 +410,15 @@ const bottom10AvgChartData = computed(() => {
return { labels, datasets } return { labels, datasets }
}) })
// 前25%平均对比图 // 中间80%均值对比图
const top25AvgChartData = computed(() => { const middle80AvgChartData = computed(() => {
if (comparisons.value.length === 0) return null if (comparisons.value.length === 0) return null
const labels = comparisons.value.map((c) => c.class_name) const labels = comparisons.value.map((c) => c.class_name)
const datasets = [ const datasets = [
{ {
label: "前25%平均", label: "中间80%均值",
data: comparisons.value.map((c) => c.top_25_avg), data: comparisons.value.map((c) => c.middle_80_avg),
backgroundColor: comparisons.value.map((_, i) => getClassColor(i).bg),
borderColor: comparisons.value.map((_, i) => getClassColor(i).border),
borderWidth: 2,
},
]
return { labels, datasets }
})
// 后25%平均对比图
const bottom25AvgChartData = computed(() => {
if (comparisons.value.length === 0) return null
const labels = comparisons.value.map((c) => c.class_name)
const datasets = [
{
label: "后25%平均",
data: comparisons.value.map((c) => c.bottom_25_avg),
backgroundColor: comparisons.value.map((_, i) => getClassColor(i).bg), backgroundColor: comparisons.value.map((_, i) => getClassColor(i).bg),
borderColor: comparisons.value.map((_, i) => getClassColor(i).border), borderColor: comparisons.value.map((_, i) => getClassColor(i).border),
borderWidth: 2, borderWidth: 2,
@@ -474,6 +552,160 @@ const chartOptions = {
}, },
} }
const compositeScoreChartOptions = {
...chartOptions,
scales: {
...chartOptions.scales,
y: {
...chartOptions.scales.y,
max: 100,
},
},
}
const tableColumns: DataTableColumn<ClassComparison>[] = [
{
title: "排名",
key: "rank",
render: (_, index) => getRankColor(index).text,
width: 80,
},
{
title: "综合分",
key: "composite_score",
width: 90,
render: (row) =>
h(
"span",
{
style: {
color: "#722ed1",
fontWeight: "700",
fontSize: "15px",
},
},
row.composite_score.toFixed(1),
),
},
{
title: "班级",
key: "class_name",
render: (row) =>
`${row.class_name.slice(0, 2)}计算机${row.class_name.slice(2)}`,
width: 160,
},
{
title: "人数",
key: "user_count",
width: 80,
render: (row) =>
h(
"span",
{ style: { color: "#1890ff", fontWeight: "600" } },
row.user_count,
),
},
{
title: "总AC数",
key: "total_ac",
width: 100,
render: (row) =>
h(
"span",
{ style: { color: "#ff4d4f", fontWeight: "600" } },
row.total_ac,
),
},
{
title: "平均AC",
key: "avg_ac",
width: 100,
render: (row) =>
h(
"span",
{ style: { color: "#52c41a", fontWeight: "600" } },
row.avg_ac.toFixed(2),
),
},
{
title: "中位数AC",
key: "median_ac",
width: 100,
render: (row) =>
h(
"span",
{ style: { color: "#fa8c16", fontWeight: "600" } },
row.median_ac.toFixed(2),
),
},
{
title: "前10%均值",
key: "top_10_avg",
width: 100,
render: (row) =>
h(
"span",
{ style: { color: "#cf1322", fontWeight: "600" } },
row.top_10_avg.toFixed(2),
),
},
{
title: "中间80%均值",
key: "middle_80_avg",
width: 110,
render: (row) =>
h(
"span",
{ style: { color: "#389e0d", fontWeight: "600" } },
row.middle_80_avg.toFixed(2),
),
},
{
title: "后10%均值",
key: "bottom_10_avg",
width: 100,
render: (row) =>
h(
"span",
{ style: { color: "#096dd9", fontWeight: "500" } },
row.bottom_10_avg.toFixed(2),
),
},
{
title: "优秀率",
key: "excellent_rate",
width: 100,
render: (row) =>
h(
"span",
{ style: { color: "#faad14", fontWeight: "600" } },
row.excellent_rate.toFixed(1) + "%",
),
},
{
title: "及格率",
key: "pass_rate",
width: 100,
render: (row) =>
h(
"span",
{ style: { color: "#52c41a", fontWeight: "600" } },
row.pass_rate.toFixed(1) + "%",
),
},
{
title: "参与度",
key: "active_rate",
width: 100,
render: (row) =>
h(
"span",
{ style: { color: "#1890ff", fontWeight: "600" } },
row.active_rate.toFixed(1) + "%",
),
},
]
const radarChartOptions = { const radarChartOptions = {
responsive: true, responsive: true,
maintainAspectRatio: false, maintainAspectRatio: false,
@@ -555,8 +787,42 @@ const radarChartOptions = {
> >
开始PK 开始PK
</n-button> </n-button>
<n-button
v-if="isTeacherOrAbove"
type="info"
@click="analyzeWithAI"
:loading="aiLoading"
:disabled="comparisons.length === 0"
style="margin-top: 26px"
>
<template #icon>
<Icon icon="mingcute:ai-line" />
</template>
AI分析
</n-button>
</n-flex> </n-flex>
<n-modal
v-model:show="showAIModal"
preset="card"
title="AI 分析报告"
:style="{ width: '800px', maxWidth: '95vw' }"
>
<n-spin :show="aiLoading" :delay="50">
<div style="min-height: 200px">
<MdPreview v-if="aiContent" :model-value="aiContent" />
<n-flex
v-else-if="!aiLoading"
align="center"
justify="center"
style="min-height: 200px"
>
<n-empty description="暂无分析内容" />
</n-flex>
</div>
</n-spin>
</n-modal>
<!-- 班级对比卡片 --> <!-- 班级对比卡片 -->
<n-grid v-if="comparisons.length > 0" :cols="2" :x-gap="16" :y-gap="16"> <n-grid v-if="comparisons.length > 0" :cols="2" :x-gap="16" :y-gap="16">
<n-gi <n-gi
@@ -575,6 +841,9 @@ const radarChartOptions = {
<template #header-extra> <template #header-extra>
<n-tag :type="getRankColor(index).type" size="large"> <n-tag :type="getRankColor(index).type" size="large">
#{{ getRankColor(index).text }} #{{ getRankColor(index).text }}
<span style="margin-left: 6px; font-size: 12px; opacity: 0.85">
{{ classData.composite_score }}
</span>
</n-tag> </n-tag>
</template> </template>
@@ -676,26 +945,21 @@ const radarChartOptions = {
</n-descriptions-item> </n-descriptions-item>
<!-- 分层统计 --> <!-- 分层统计 -->
<n-descriptions-item label="前10名平均"> <n-descriptions-item label="前10%均值">
<span style="color: #cf1322; font-weight: 600">{{ <span style="color: #cf1322; font-weight: 600">{{
classData.top_10_avg.toFixed(2) classData.top_10_avg.toFixed(2)
}}</span> }}</span>
</n-descriptions-item> </n-descriptions-item>
<n-descriptions-item label="后10名平均"> <n-descriptions-item label="中间80%均值">
<span style="color: #389e0d; font-weight: 600">{{
classData.middle_80_avg.toFixed(2)
}}</span>
</n-descriptions-item>
<n-descriptions-item label="后10%均值">
<span style="color: #096dd9; font-weight: 500">{{ <span style="color: #096dd9; font-weight: 500">{{
classData.bottom_10_avg.toFixed(2) classData.bottom_10_avg.toFixed(2)
}}</span> }}</span>
</n-descriptions-item> </n-descriptions-item>
<n-descriptions-item label="前25%平均">
<span style="color: #f5222d; font-weight: 600">{{
classData.top_25_avg.toFixed(2)
}}</span>
</n-descriptions-item>
<n-descriptions-item label="后25%平均">
<span style="color: #531dab; font-weight: 500">{{
classData.bottom_25_avg.toFixed(2)
}}</span>
</n-descriptions-item>
<!-- 人数 --> <!-- 人数 -->
<n-descriptions-item label="人数"> <n-descriptions-item label="人数">
@@ -793,6 +1057,32 @@ const radarChartOptions = {
<!-- 可视化图表 - 专注于对比 --> <!-- 可视化图表 - 专注于对比 -->
<template v-if="comparisons.length > 0"> <template v-if="comparisons.length > 0">
<!-- 综合分对比 + 多维度雷达图 同行 -->
<n-grid style="margin-top: 20px" :cols="2" :x-gap="16">
<n-gi>
<n-card title="综合分对比满分100" style="height: 100%">
<div style="height: 380px">
<Bar
v-if="compositeScoreChartData"
:data="compositeScoreChartData"
:options="compositeScoreChartOptions"
/>
</div>
</n-card>
</n-gi>
<n-gi>
<n-card title="多维度综合对比" style="height: 100%">
<div style="height: 380px">
<Radar
v-if="radarChartData"
:data="radarChartData"
:options="radarChartOptions"
/>
</div>
</n-card>
</n-gi>
</n-grid>
<!-- AC核心指标对比 - 三个独立图表并排显示 --> <!-- AC核心指标对比 - 三个独立图表并排显示 -->
<n-card title="AC核心指标对比" style="margin-top: 20px"> <n-card title="AC核心指标对比" style="margin-top: 20px">
<n-grid :cols="3" :x-gap="16" :y-gap="16"> <n-grid :cols="3" :x-gap="16" :y-gap="16">
@@ -859,9 +1149,9 @@ const radarChartOptions = {
</n-grid> </n-grid>
</n-card> </n-card>
<!-- 分层统计对比 - 个独立图表并排显示 --> <!-- 分层统计对比 - 个独立图表并排显示 -->
<n-card title="分层统计对比" style="margin-top: 20px"> <n-card title="分层统计对比" style="margin-top: 20px">
<n-grid :cols="2" :x-gap="16" :y-gap="16"> <n-grid :cols="3" :x-gap="16" :y-gap="16">
<n-gi> <n-gi>
<div style="height: 300px"> <div style="height: 300px">
<Bar <Bar
@@ -871,6 +1161,15 @@ const radarChartOptions = {
/> />
</div> </div>
</n-gi> </n-gi>
<n-gi>
<div style="height: 300px">
<Bar
v-if="middle80AvgChartData"
:data="middle80AvgChartData"
:options="chartOptions"
/>
</div>
</n-gi>
<n-gi> <n-gi>
<div style="height: 300px"> <div style="height: 300px">
<Bar <Bar
@@ -880,37 +1179,8 @@ const radarChartOptions = {
/> />
</div> </div>
</n-gi> </n-gi>
<n-gi>
<div style="height: 300px">
<Bar
v-if="top25AvgChartData"
:data="top25AvgChartData"
:options="chartOptions"
/>
</div>
</n-gi>
<n-gi>
<div style="height: 300px">
<Bar
v-if="bottom25AvgChartData"
:data="bottom25AvgChartData"
:options="chartOptions"
/>
</div>
</n-gi>
</n-grid> </n-grid>
</n-card> </n-card>
<!-- 多维度雷达图 - 综合对比 -->
<n-card title="多维度综合对比" style="margin-top: 20px">
<div style="height: 500px">
<Radar
v-if="radarChartData"
:data="radarChartData"
:options="radarChartOptions"
/>
</div>
</n-card>
</template> </template>
<!-- 对比表格 --> <!-- 对比表格 -->
@@ -919,123 +1189,7 @@ const radarChartOptions = {
title="对比表格" title="对比表格"
style="margin-top: 20px" style="margin-top: 20px"
> >
<n-data-table <n-data-table :data="comparisons" :columns="tableColumns" />
:data="comparisons"
:columns="[
{
title: '排名',
key: 'rank',
render: (_, index) => getRankColor(index).text,
width: 80,
},
{
title: '班级',
key: 'class_name',
render: (row) =>
`${row.class_name.slice(0, 2)}计算机${row.class_name.slice(2)}班`,
width: 160,
},
{
title: '人数',
key: 'user_count',
width: 80,
render: (row) =>
h(
'span',
{ style: { color: '#1890ff', fontWeight: '600' } },
row.user_count,
),
},
{
title: '总AC数',
key: 'total_ac',
width: 100,
render: (row) =>
h(
'span',
{ style: { color: '#ff4d4f', fontWeight: '600' } },
row.total_ac,
),
},
{
title: '平均AC',
key: 'avg_ac',
width: 100,
render: (row) =>
h(
'span',
{ style: { color: '#52c41a', fontWeight: '600' } },
row.avg_ac.toFixed(2),
),
},
{
title: '中位数AC',
key: 'median_ac',
width: 100,
render: (row) =>
h(
'span',
{ style: { color: '#fa8c16', fontWeight: '600' } },
row.median_ac.toFixed(2),
),
},
{
title: '前10名平均',
key: 'top_10_avg',
width: 100,
render: (row) =>
h(
'span',
{ style: { color: '#cf1322', fontWeight: '600' } },
row.top_10_avg.toFixed(2),
),
},
{
title: '后10名平均',
key: 'bottom_10_avg',
width: 100,
render: (row) =>
h(
'span',
{ style: { color: '#096dd9', fontWeight: '500' } },
row.bottom_10_avg.toFixed(2),
),
},
{
title: '优秀率',
key: 'excellent_rate',
width: 100,
render: (row) =>
h(
'span',
{ style: { color: '#faad14', fontWeight: '600' } },
row.excellent_rate.toFixed(1) + '%',
),
},
{
title: '及格率',
key: 'pass_rate',
width: 100,
render: (row) =>
h(
'span',
{ style: { color: '#52c41a', fontWeight: '600' } },
row.pass_rate.toFixed(1) + '%',
),
},
{
title: '参与度',
key: 'active_rate',
width: 100,
render: (row) =>
h(
'span',
{ style: { color: '#1890ff', fontWeight: '600' } },
row.active_rate.toFixed(1) + '%',
),
},
]"
/>
</n-card> </n-card>
</n-flex> </n-flex>
</n-card> </n-card>

View File

@@ -34,188 +34,188 @@ interface Props {
const props = defineProps<Props>() const props = defineProps<Props>()
const PENALTY_SECONDS = 20 * 60
const showChart = computed(() => { const showChart = computed(() => {
const hasRanks = props.ranks.length > 0 const hasRanks = props.ranks.length > 0
const hasProblems = props.problems.length >= 3 const hasProblems = props.problems.length >= 3
return hasProblems && hasRanks return hasProblems && hasRanks
}) })
// 预定义的颜色方案 - 更现代和可访问的颜色
const colorPalette = [ const colorPalette = [
"#3B82F6", // 蓝色 "#3B82F6",
"#EF4444", // 红色 "#EF4444",
"#10B981", // 绿色 "#10B981",
"#F59E0B", // 黄色 "#F59E0B",
"#8B5CF6", // 紫色 "#8B5CF6",
"#EC4899", // 粉色 "#EC4899",
"#06B6D4", // 青色 "#06B6D4",
"#84CC16", // 青绿色 "#84CC16",
"#F97316", // 橙色 "#F97316",
"#6366F1", // 靛蓝色 "#6366F1",
] ]
// 数据处理函数 function formatTime(seconds: number): string {
const processChartData = () => { const h = Math.floor(seconds / 3600)
if (!props.ranks || props.ranks.length === 0) { const m = Math.floor((seconds % 3600) / 60)
return { if (h > 0) return `${h}h${m}m`
labels: [], return `${m}m`
datasets: [],
}
}
// 获取前10名用户的数据
const topUsers = props.ranks.slice(0, 10)
// 获取所有题目ID从所有用户的submission_info中收集
const allProblemIds = new Set<string>()
topUsers.forEach((rank) => {
Object.keys(rank.submission_info).forEach((problemId) => {
allProblemIds.add(problemId)
})
})
// 按题目ID排序
const problemIds = Array.from(allProblemIds).sort()
// 创建题目标签
const labels = problemIds.map((id) => {
if (props.problems) {
const problem = props.problems.find((p) => p.id.toString() === id)
return problem ? problem.title : `题目${id}`
}
return `题目${id}`
})
// 找到所有用户中最早的提交时间
let earliestTime = Infinity
topUsers.forEach((rank) => {
Object.values(rank.submission_info).forEach((submissionInfo) => {
if (submissionInfo.is_ac && submissionInfo.ac_time < earliestTime) {
earliestTime = submissionInfo.ac_time
}
})
})
// 如果没有找到任何通过记录使用0作为基准
if (earliestTime === Infinity) {
earliestTime = 0
}
// 为每个用户创建数据集
const datasets = topUsers.map((rank, userIndex) => {
const userData = problemIds.map((problemId) => {
const submissionInfo = rank.submission_info[problemId]
if (!submissionInfo || !submissionInfo.is_ac) {
return null
}
return submissionInfo.ac_time - earliestTime
})
const actualRank = userIndex + 1
const colorIndex = userIndex % colorPalette.length
const color = colorPalette[colorIndex]
return {
label: `${actualRank}名: ${rank.user.username}`,
data: userData,
borderColor: color,
backgroundColor: color + "20",
tension: 0.3,
fill: false,
pointRadius: 6,
pointHoverRadius: 8,
pointBackgroundColor: color,
pointBorderColor: "#fff",
pointBorderWidth: 2,
spanGaps: false,
}
})
return {
labels,
datasets,
}
} }
// 监听数据变化,重新处理 interface AcEvent {
watch( time: number
() => [props.ranks, props.problems], userIndex: number
() => { problemId: string
if (props.ranks && props.ranks.length > 0) { }
// 数据变化时重新处理
}
},
{ deep: true, immediate: true },
)
const chartData = computed(() => { const chartData = computed(() => {
return processChartData() if (!props.ranks || props.ranks.length === 0) {
return { labels: [], datasets: [] }
}
const topUsers = props.ranks.slice(0, 10)
// 收集所有AC事件并按时间排序
const events: AcEvent[] = []
topUsers.forEach((rank, userIndex) => {
Object.entries(rank.submission_info).forEach(([problemId, info]) => {
if (info.is_ac) {
events.push({ time: info.ac_time, userIndex, problemId })
}
})
})
events.sort((a, b) => a.time - b.time)
if (events.length === 0) {
return { labels: [], datasets: [] }
}
// 在每个时间点计算所有人的排名
// 状态: 每个用户当前已AC题数和罚时
const userState = topUsers.map(() => ({
solved: 0,
penalty: 0,
}))
// 用于记录每个用户每道题的错误次数
const userErrors: Map<string, number>[] = topUsers.map(() => new Map())
topUsers.forEach((rank, i) => {
Object.entries(rank.submission_info).forEach(([problemId, info]) => {
if (info.error_number > 0) {
userErrors[i].set(problemId, info.error_number)
}
})
})
function calcRanks(): number[] {
const indexed = userState.map((s, i) => ({ ...s, i }))
indexed.sort((a, b) => {
if (b.solved !== a.solved) return b.solved - a.solved
return a.penalty - b.penalty
})
const ranks = new Array(topUsers.length).fill(0)
indexed.forEach((item, pos) => {
ranks[item.i] = pos + 1
})
return ranks
}
// 时间轴上的数据点: [时间标签, 各用户排名]
const timePoints: number[] = [0]
const rankSnapshots: number[][] = [calcRanks()]
// 按时间处理事件(合并同一时刻的事件)
let i = 0
while (i < events.length) {
const currentTime = events[i].time
// 处理同一时刻的所有事件
while (i < events.length && events[i].time === currentTime) {
const ev = events[i]
userState[ev.userIndex].solved++
const errors = userErrors[ev.userIndex].get(ev.problemId) || 0
userState[ev.userIndex].penalty =
userState[ev.userIndex].penalty + ev.time + errors * PENALTY_SECONDS
i++
}
timePoints.push(currentTime)
rankSnapshots.push(calcRanks())
}
const labels = timePoints.map((t) => formatTime(t))
const datasets = topUsers.map((rank, userIndex) => {
const color = colorPalette[userIndex % colorPalette.length]
const finalRank = rankSnapshots[rankSnapshots.length - 1][userIndex]
return {
label: `#${finalRank} ${rank.user.username}`,
data: rankSnapshots.map((snapshot) => snapshot[userIndex]),
borderColor: color,
backgroundColor: color,
tension: 0.3,
fill: false,
pointRadius: 3,
pointHoverRadius: 6,
pointBackgroundColor: color,
pointBorderColor: "#fff",
pointBorderWidth: 1,
borderWidth: 2.5,
}
})
return { labels, datasets }
}) })
const chartOptions = computed(() => ({ const chartOptions = computed(() => ({
responsive: true, responsive: true,
maintainAspectRatio: false, maintainAspectRatio: false,
interaction: {
mode: "index" as const,
intersect: false,
},
plugins: { plugins: {
legend: { legend: {
display: true, display: true,
position: "top" as const, position: "top" as const,
maxHeight: 80, maxHeight: 80,
labels: { labels: {
boxWidth: 12, boxWidth: 14,
boxHeight: 12, boxHeight: 3,
padding: 8, padding: 10,
usePointStyle: true, font: { size: 12 },
font: {
size: 11,
},
}, },
}, },
tooltip: { tooltip: {
mode: "index" as const, mode: "index" as const,
intersect: false, intersect: false,
itemSort: (a: any, b: any) => a.parsed.y - b.parsed.y,
callbacks: { callbacks: {
title: function (context: any) { title: (context: any) => `比赛进行: ${context[0].label}`,
return `题目: ${context[0].label}` label: (context: any) => {
}, const rank = context.parsed.y
label: function (context: any) { const name = context.dataset.label
const value = context.parsed.y return `${rank}名 — ${name}`
const label = context.dataset.label
if (value === null) {
return `${label}: 未通过`
}
const hours = Math.floor(value / 3600)
const minutes = Math.floor((value % 3600) / 60)
const seconds = Math.floor(value % 60)
let timeStr = ""
if (hours > 0) timeStr += `${hours}小时`
if (minutes > 0) timeStr += `${minutes}分钟`
if (seconds > 0 || timeStr === "") timeStr += `${seconds}`
return `${label}: +${timeStr}`
}, },
}, },
}, },
}, },
scales: { scales: {
x: {
title: {
display: true,
text: "比赛时间",
},
},
y: { y: {
title: { title: {
display: true, display: true,
text: "相对通过时间", text: "排名",
}, },
min: 0, reverse: true,
min: 1,
max: 10,
ticks: { ticks: {
callback: function (value: any) { stepSize: 1,
const hours = Math.floor(value / 3600) callback: (value: any) => `${value}`,
const minutes = Math.floor((value % 3600) / 60)
const seconds = Math.floor(value % 60)
if (hours > 0) return `+${hours}h${minutes}m`
if (minutes > 0) return `+${minutes}m${seconds}s`
return `+${seconds}s`
},
}, },
}, },
}, },
@@ -224,7 +224,7 @@ const chartOptions = computed(() => ({
<style scoped> <style scoped>
.chart { .chart {
height: 500px; height: 420px;
width: 100%; width: 100%;
margin-bottom: 24px; margin-bottom: 24px;
} }

View File

@@ -3,7 +3,7 @@ import { useRouteQuery } from "@vueuse/router"
import { NTag } from "naive-ui" import { NTag } from "naive-ui"
import { getContestList } from "oj/api" import { getContestList } from "oj/api"
import { duration, parseTime } from "utils/functions" import { duration, parseTime } from "utils/functions"
import { Contest } from "utils/types" import type { Contest } from "utils/types"
import ContestTitle from "shared/components/ContestTitle.vue" import ContestTitle from "shared/components/ContestTitle.vue"
import Pagination from "shared/components/Pagination.vue" import Pagination from "shared/components/Pagination.vue"
import { useAuthModalStore } from "shared/store/authModal" import { useAuthModalStore } from "shared/store/authModal"

View File

@@ -8,7 +8,7 @@ import Pagination from "shared/components/Pagination.vue"
import { usePagination } from "shared/composables/pagination" import { usePagination } from "shared/composables/pagination"
import { ContestStatus } from "utils/constants" import { ContestStatus } from "utils/constants"
import { renderTableTitle } from "utils/renders" import { renderTableTitle } from "utils/renders"
import { ContestRank, ProblemFiltered } from "utils/types" import type { ContestRank, ProblemFiltered } from "utils/types"
import AcAndSubmission from "../components/AcAndSubmission.vue" import AcAndSubmission from "../components/AcAndSubmission.vue"
import LineChart from "../components/LineChart.vue" import LineChart from "../components/LineChart.vue"
@@ -95,7 +95,6 @@ async function listRanks() {
const res = await getContestRank(props.contestID, { const res = await getContestRank(props.contestID, {
limit: query.limit, limit: query.limit,
offset: query.limit * (query.page - 1), offset: query.limit * (query.page - 1),
force_refresh: "1",
}) })
total.value = res.data.total total.value = res.data.total
data.value = res.data.results data.value = res.data.results
@@ -190,6 +189,77 @@ async function addColumns() {
} }
} }
// 导出弹窗
const showExportModal = ref(false)
const exportLoading = ref(false)
const exportForm = reactive({
first: 0,
second: 0,
third: 0,
})
watch(
() => total.value,
(val) => {
if (val > 0) {
exportForm.first = Math.round(val * 0.1)
exportForm.second = Math.round(val * 0.2)
exportForm.third = Math.round(val * 0.3)
}
},
)
function openExportModal() {
if (total.value > 0) {
exportForm.first = Math.round(total.value * 0.1)
exportForm.second = Math.round(total.value * 0.2)
exportForm.third = Math.round(total.value * 0.3)
}
showExportModal.value = true
}
async function downloadExcel() {
exportLoading.value = true
try {
const res = await getContestRank(props.contestID, {
limit: total.value || 10000,
offset: 0,
})
const allRanks: ContestRank[] = res.data.results
const rows = allRanks.map((rank, index) => {
const rank1 = index + 1
let level = ""
if (rank1 <= exportForm.first) {
level = "一等奖"
} else if (rank1 <= exportForm.first + exportForm.second) {
level = "二等奖"
} else if (
rank1 <=
exportForm.first + exportForm.second + exportForm.third
) {
level = "三等奖"
} else {
level = "参与奖"
}
return { 用户名: rank.user.username, 等级: level }
})
const csv =
"用户名,等级\n" + rows.map((r) => `${r.用户名},${r.等级}`).join("\n")
const blob = new Blob(["" + csv], { type: "text/csv;charset=utf-8" })
const url = URL.createObjectURL(blob)
const a = document.createElement("a")
a.href = url
a.download = `${contestStore.contest?.title ?? "contest"}获奖情况.csv`
a.click()
URL.revokeObjectURL(url)
showExportModal.value = false
} finally {
exportLoading.value = false
}
}
// 监听分页参数变化 // 监听分页参数变化
watch([() => query.page, () => query.limit], listRanks) watch([() => query.page, () => query.limit], listRanks)
watch(autoRefresh, (checked) => (checked ? resume() : pause())) watch(autoRefresh, (checked) => (checked ? resume() : pause()))
@@ -223,6 +293,13 @@ onMounted(() => {
<n-switch v-model:value="autoRefresh" /> <n-switch v-model:value="autoRefresh" />
</n-form-item> </n-form-item>
</n-form> </n-form>
<n-button
v-if="contestStore.contestStatus === ContestStatus.finished"
type="primary"
@click="openExportModal"
>
导出数据
</n-button>
<Pagination <Pagination
:total="total" :total="total"
:limit="query.limit" :limit="query.limit"
@@ -231,6 +308,31 @@ onMounted(() => {
@update:page="(page: number) => (query.page = page)" @update:page="(page: number) => (query.page = page)"
/> />
</n-space> </n-space>
<n-modal v-model:show="showExportModal" preset="dialog" title="导出获奖数据">
<n-form
label-placement="left"
label-width="auto"
:show-feedback="false"
style="margin-top: 16px"
>
<n-form-item label="一等奖人数" style="margin-bottom: 12px">
<n-input-number v-model:value="exportForm.first" :min="0" />
</n-form-item>
<n-form-item label="二等奖人数" style="margin-bottom: 12px">
<n-input-number v-model:value="exportForm.second" :min="0" />
</n-form-item>
<n-form-item label="三等奖人数">
<n-input-number v-model:value="exportForm.third" :min="0" />
</n-form-item>
</n-form>
<template #action>
<n-button @click="showExportModal = false">取消</n-button>
<n-button type="primary" :loading="exportLoading" @click="downloadExcel">
下载 CSV
</n-button>
</template>
</n-modal>
</template> </template>
<style> <style>
.oj-time-with-modal { .oj-time-with-modal {

View File

@@ -85,19 +85,19 @@ function inputWidth(idx: number): string {
</script> </script>
<template> <template>
<n-card <n-card style="margin: 16px 0; border: 1.5px solid var(--n-border-color)">
size="small"
style="margin: 16px 0; border: 1.5px solid var(--n-border-color)"
>
<template #header> <template #header>
<n-tag type="warning" size="small" :bordered="false">练一练 · 代码填空</n-tag> <n-tag type="warning" :bordered="false">练一练 · 代码填空</n-tag>
</template> </template>
<p style="font-weight: 500; margin-bottom: 12px">{{ data.question }}</p> <p style="font-weight: 500; font-size: 16px; margin-bottom: 12px">
{{ data.question }}
</p>
<pre <pre
:style="{ :style="{
fontFamily: 'Monaco', fontFamily: 'Monaco',
fontSize: '16px',
lineHeight: '1.6', lineHeight: '1.6',
background: 'var(--n-color)', background: 'var(--n-color)',
border: '1px solid var(--n-border-color)', border: '1px solid var(--n-border-color)',
@@ -115,7 +115,8 @@ function inputWidth(idx: number): string {
:style="{ :style="{
width: inputWidth(seg.index), width: inputWidth(seg.index),
fontFamily: 'Monaco', fontFamily: 'Monaco',
padding: '1px 4px', fontSize: '16px',
padding: '2px 6px',
borderRadius: '3px', borderRadius: '3px',
border: `1.5px solid ${ border: `1.5px solid ${
allCorrect allCorrect
@@ -144,15 +145,10 @@ function inputWidth(idx: number): string {
/> />
<n-space style="margin-top: 12px" :size="8"> <n-space style="margin-top: 12px" :size="8">
<n-button <n-button type="warning" :disabled="allCorrect" @click="submit">
type="warning"
size="small"
:disabled="allCorrect"
@click="submit"
>
提交 提交
</n-button> </n-button>
<n-button size="small" @click="reset">重置</n-button> <n-button @click="reset">重置</n-button>
</n-space> </n-space>
</n-card> </n-card>
</template> </template>

View File

@@ -63,19 +63,18 @@ function optionType(idx: number): "default" | "primary" | "success" {
</script> </script>
<template> <template>
<n-card <n-card style="margin: 16px 0; border: 1.5px solid var(--n-border-color)">
size="small"
style="margin: 16px 0; border: 1.5px solid var(--n-border-color)"
>
<template #header> <template #header>
<n-space align="center" :size="8"> <n-space align="center" :size="8">
<n-tag type="success" size="small" :bordered="false"> <n-tag type="success" :bordered="false">
练一练 · {{ isSingle ? "单选题" : "多选题" }} 练一练 · {{ isSingle ? "单选题" : "多选题" }}
</n-tag> </n-tag>
</n-space> </n-space>
</template> </template>
<p style="font-weight: 500; margin-bottom: 12px">{{ data.question }}</p> <p style="font-weight: 500; font-size: 16px; margin-bottom: 12px">
{{ data.question }}
</p>
<n-space vertical :size="8"> <n-space vertical :size="8">
<n-button <n-button
@@ -113,13 +112,12 @@ function optionType(idx: number): "default" | "primary" | "success" {
<n-space style="margin-top: 12px" :size="8"> <n-space style="margin-top: 12px" :size="8">
<n-button <n-button
type="primary" type="primary"
size="small"
:disabled="selected.size === 0 || correct" :disabled="selected.size === 0 || correct"
@click="submit" @click="submit"
> >
提交 提交
</n-button> </n-button>
<n-button size="small" @click="reset">重置</n-button> <n-button @click="reset">重置</n-button>
</n-space> </n-space>
</n-card> </n-card>
</template> </template>

View File

@@ -101,17 +101,14 @@ const lineHtmlMap = computed<Record<number, string>>(() => {
</script> </script>
<template> <template>
<n-card <n-card style="margin: 16px 0; border: 1.5px solid var(--n-border-color)">
size="small"
style="margin: 16px 0; border: 1.5px solid var(--n-border-color)"
>
<template #header> <template #header>
<n-tag type="info" size="small" :bordered="false" <n-tag type="info" :bordered="false">练一练 · 代码排序</n-tag>
>练一练 · 代码排序</n-tag
>
</template> </template>
<p style="font-weight: 500; margin-bottom: 12px">{{ data.question }}</p> <p style="font-weight: 500; font-size: 16px; margin-bottom: 12px">
{{ data.question }}
</p>
<n-space vertical :size="6"> <n-space vertical :size="6">
<div <div
@@ -158,15 +155,10 @@ const lineHtmlMap = computed<Record<number, string>>(() => {
/> />
<n-space style="margin-top: 12px" :size="8"> <n-space style="margin-top: 12px" :size="8">
<n-button <n-button type="info" :disabled="submitted && allCorrect" @click="submit">
type="info"
size="small"
:disabled="submitted && allCorrect"
@click="submit"
>
提交 提交
</n-button> </n-button>
<n-button size="small" @click="reset">重置</n-button> <n-button @click="reset">重置</n-button>
</n-space> </n-space>
</n-card> </n-card>
</template> </template>

View File

@@ -1,8 +1,13 @@
<template> <template>
<div> <div class="learn-container">
<!-- 桌面端布局 --> <!-- 桌面端布局 -->
<n-grid :cols="5" :x-gap="16" v-if="tutorial.id && isDesktop"> <n-grid
<n-gi :span="1"> :cols="5"
:x-gap="16"
v-if="tutorial.id && isDesktop"
class="learn-grid"
>
<n-gi :span="1" class="learn-col">
<n-card title="教程目录" :bordered="false" size="small"> <n-card title="教程目录" :bordered="false" size="small">
<n-list hoverable clickable> <n-list hoverable clickable>
<n-list-item <n-list-item
@@ -21,7 +26,7 @@
</n-card> </n-card>
</n-gi> </n-gi>
<n-gi :span="tutorial.code ? 2 : 4"> <n-gi :span="tutorial.code ? 2 : 4" class="learn-col">
<n-card <n-card
:title="`第 ${step} 课:${titles[step - 1]?.title}`" :title="`第 ${step} 课:${titles[step - 1]?.title}`"
:bordered="false" :bordered="false"
@@ -43,9 +48,19 @@
</n-card> </n-card>
</n-gi> </n-gi>
<n-gi :span="2" v-if="tutorial.code"> <n-gi :span="2" v-if="tutorial.code" class="learn-col learn-col--code">
<n-card title="示例代码" :bordered="false" size="small"> <n-card
<CodeEditor language="Python3" v-model="tutorial.code" /> title="示例代码"
:bordered="false"
size="small"
class="code-card"
content-style="height: calc(100% - 44px); padding: 0;"
>
<CodeEditor
language="Python3"
v-model="tutorial.code"
height="100%"
/>
</n-card> </n-card>
</n-gi> </n-gi>
</n-grid> </n-grid>
@@ -119,7 +134,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { MdPreview } from "md-editor-v3" import { MdPreview } from "md-editor-v3"
import "md-editor-v3/lib/preview.css" import "md-editor-v3/lib/preview.css"
import { Tutorial, Exercise } from "utils/types" import type { Tutorial, Exercise } from "utils/types"
import { getTutorial, getTutorials, getExercises } from "../api" import { getTutorial, getTutorials, getExercises } from "../api"
import { parseExercises } from "./composables/useExerciseParse" import { parseExercises } from "./composables/useExerciseParse"
import { useBreakpoints } from "shared/composables/breakpoints" import { useBreakpoints } from "shared/composables/breakpoints"
@@ -190,3 +205,26 @@ watch(
{ immediate: true }, { immediate: true },
) )
</script> </script>
<style scoped>
.learn-container {
height: calc(100vh - 138px);
}
.learn-grid {
height: 100%;
}
.learn-col {
overflow-y: auto;
height: 100%;
}
.learn-col--code {
overflow-y: hidden;
}
.code-card {
height: 100%;
}
</style>

View File

@@ -31,9 +31,7 @@ interface Props {
isConnected?: boolean // WebSocket 实际的连接状态(已建立/未建立) isConnected?: boolean // WebSocket 实际的连接状态(已建立/未建立)
} }
const props = withDefaults(defineProps<Props>(), { const { storageKey, isConnected = false } = defineProps<Props>()
isConnected: false,
})
// 注入同步状态 // 注入同步状态
const syncStatus = injectSyncStatus() const syncStatus = injectSyncStatus()
@@ -102,7 +100,7 @@ const reset = () => {
problem.value!.template[codeStore.code.language] || problem.value!.template[codeStore.code.language] ||
SOURCES[codeStore.code.language], SOURCES[codeStore.code.language],
) )
storage.remove(props.storageKey) storage.remove(storageKey)
message.success("代码重置成功") message.success("代码重置成功")
} }
@@ -185,7 +183,7 @@ onMounted(() => {
</n-button> </n-button>
<n-button <n-button
v-if="userStore.isSuperAdmin" v-if="userStore.isTeacherOrAbove"
:size="buttonSize" :size="buttonSize"
@click="statisticPanel = true" @click="statisticPanel = true"
> >
@@ -228,7 +226,7 @@ onMounted(() => {
/> />
<!-- 同步状态标签 --> <!-- 同步状态标签 -->
<template v-if="props.isConnected"> <template v-if="isConnected">
<n-tag v-if="syncStatus.otherUser.value" type="info"> <n-tag v-if="syncStatus.otherUser.value" type="info">
{{ SYNC_MESSAGES.SYNCING_WITH(syncStatus.otherUser.value.name) }} {{ SYNC_MESSAGES.SYNCING_WITH(syncStatus.otherUser.value.name) }}
</n-tag> </n-tag>
@@ -247,7 +245,7 @@ onMounted(() => {
</n-flex> </n-flex>
<n-modal <n-modal
v-if="userStore.isSuperAdmin" v-if="userStore.isTeacherOrAbove"
v-model:show="statisticPanel" v-model:show="statisticPanel"
preset="card" preset="card"
title="提交记录的统计" title="提交记录的统计"

View File

@@ -0,0 +1,42 @@
<script setup lang="ts">
import { useMyFlowchartStore } from "shared/store/myFlowchart"
import { useMermaid } from "shared/composables/useMermaid"
const store = useMyFlowchartStore()
const { renderError, renderFlowchart } = useMermaid()
const mermaidContainer = useTemplateRef<HTMLElement>("mermaidContainer")
watch(
() => store.mermaidCode,
async (code) => {
if (!code) return
await nextTick()
await renderFlowchart(mermaidContainer.value, code)
},
{ immediate: true },
)
</script>
<template>
<div style="padding: 8px 0">
<n-alert v-if="renderError" type="error" title="渲染失败" size="small">
{{ renderError }}
</n-alert>
<div v-else ref="mermaidContainer" class="flowchart-container"></div>
</div>
</template>
<style scoped>
.flowchart-container {
width: 100%;
min-height: 500px;
display: flex;
justify-content: center;
align-items: flex-start;
}
:deep(.flowchart-container > svg) {
width: 100%;
height: auto;
}
</style>

View File

@@ -67,7 +67,7 @@
{{ content }} {{ content }}
</n-form-item> </n-form-item>
<n-button <n-button
v-if="hasCommented && props.showStatistics" v-if="hasCommented && showStatistics"
type="primary" type="primary"
@click="getComments" @click="getComments"
> >
@@ -77,7 +77,7 @@
提交 提交
</n-button> </n-button>
</n-form> </n-form>
<div v-if="props.showStatistics"> <div v-if="showStatistics">
<n-descriptions <n-descriptions
class="list" class="list"
v-if="count" v-if="count"
@@ -117,9 +117,7 @@ interface Props {
showStatistics?: boolean showStatistics?: boolean
} }
const props = withDefaults(defineProps<Props>(), { const { showStatistics = true } = defineProps<Props>()
showStatistics: true,
})
const userStore = useUserStore() const userStore = useUserStore()
const problemStore = useProblemStore() const problemStore = useProblemStore()

View File

@@ -6,7 +6,7 @@ import { useCodeStore } from "oj/store/code"
import { useProblemStore } from "oj/store/problem" import { useProblemStore } from "oj/store/problem"
import { createTestSubmission } from "utils/judge" import { createTestSubmission } from "utils/judge"
import { DIFFICULTY } from "utils/constants" import { DIFFICULTY } from "utils/constants"
import { Problem, ProblemStatus } from "utils/types" import type { Problem, ProblemStatus } from "utils/types"
import Copy from "shared/components/Copy.vue" import Copy from "shared/components/Copy.vue"
import { useDark } from "@vueuse/core" import { useDark } from "@vueuse/core"
import { MdPreview } from "md-editor-v3" import { MdPreview } from "md-editor-v3"
@@ -58,11 +58,7 @@ watch(
// AC 或失败次数 >= 3 时加载推荐 // AC 或失败次数 >= 3 时加载推荐
watch( watch(
() => [ () => [problem.value?._id, problem.value?.my_status, problemStore.failCount],
problem.value?._id,
problem.value?.my_status,
problemStore.totalFailCount,
],
([, status, failCount]) => { ([, status, failCount]) => {
if (status === 0 || (failCount as number) >= 3) { if (status === 0 || (failCount as number) >= 3) {
loadSimilarProblems() loadSimilarProblems()
@@ -89,6 +85,91 @@ const samples = ref<Sample[]>(
})), })),
) )
const NODE_TARGET_LABELS: Record<string, string> = {
for_loop: "for 循环",
while_loop: "while 循环",
if_statement: "if 条件",
else_clause: "else 子句",
function_definition: "函数定义",
return: "return 语句",
break: "break 语句",
continue: "continue 语句",
list_comprehension: "列表推导式",
list_literal: "列表",
dict_literal: "字典",
set_literal: "集合",
f_string: "f-string",
try_except: "try-except",
class_definition: "类定义",
}
type AstRule = {
engine: string
target?: string
label?: string
exact?: number
min?: number
max?: number
message: string
}
function ruleDescription(rule: AstRule): string {
if (rule.message) return rule.message
const target = rule.target || ""
const targetLabel = rule.label || NODE_TARGET_LABELS[target] || target
const countDesc = () => {
if (rule.exact !== undefined) return `出现 ${rule.exact}`
if (rule.min !== undefined && rule.max !== undefined)
return `出现 ${rule.min}${rule.max}`
if (rule.min !== undefined) return `至少出现 ${rule.min}`
if (rule.max !== undefined) return `至多出现 ${rule.max}`
return ""
}
const callDesc = () => {
if (rule.exact !== undefined) return `调用 ${rule.exact}`
if (rule.min !== undefined && rule.max !== undefined)
return `调用 ${rule.min}${rule.max}`
if (rule.min !== undefined) return `至少调用 ${rule.min}`
if (rule.max !== undefined) return `至多调用 ${rule.max}`
return ""
}
switch (rule.engine) {
case "must_exist_node":
return `必须使用 ${targetLabel}`
case "must_not_exist_node":
return `不能使用 ${targetLabel}`
case "count_node":
return `${targetLabel} ${countDesc()}`
case "must_call_function":
return `必须调用 ${target}()`
case "must_not_call_function":
return `不能调用 ${target}()`
case "count_function_call":
return `${target}() ${callDesc()}`
case "must_call_method":
return `必须调用 .${target}()`
case "must_not_call_method":
return `不能调用 .${target}()`
case "must_use_operator":
return `必须使用 ${target} 运算符`
default:
return rule.engine
}
}
function ruleTagType(engine: string): "error" | "success" | "info" {
if (engine.startsWith("must_not")) return "error"
if (engine.startsWith("must")) return "success"
return "info"
}
const astRulesForDisplay = computed(() => {
if (!problem.value?.ast_rules) return []
return Object.entries(problem.value.ast_rules).filter(
([, rules]) => rules.length > 0,
)
})
async function test(sample: Sample, index: number) { async function test(sample: Sample, index: number) {
samples.value = samples.value.map((sample) => { samples.value = samples.value.map((sample) => {
if (sample.id === index) { if (sample.id === index) {
@@ -226,6 +307,33 @@ function type(status: ProblemStatus) {
/> />
</div> </div>
<!-- 代码要求AST 规则 -->
<div v-if="astRulesForDisplay.length > 0">
<p class="title" :style="style">
<n-flex align="center">
<Icon icon="streamline-emojis:open-book"></Icon>
要求
</n-flex>
</p>
<div v-for="[lang, rules] in astRulesForDisplay" :key="lang">
<p v-if="astRulesForDisplay.length > 1" class="lang-label">
{{ lang }}
</p>
<n-list bordered style="margin-bottom: 8px">
<n-list-item v-for="(rule, i) in rules" :key="i">
<n-flex align="center">
<n-tag :type="ruleTagType(rule.engine)">
{{ ruleDescription(rule) }}
</n-tag>
<span v-if="rule.message" class="rule-message">{{
rule.message
}}</span>
</n-flex>
</n-list-item>
</n-list>
</div>
</div>
<div v-for="(sample, index) of samples" :key="index"> <div v-for="(sample, index) of samples" :key="index">
<n-flex align="center"> <n-flex align="center">
<p class="title" :style="style">例子 {{ index + 1 }}</p> <p class="title" :style="style">例子 {{ index + 1 }}</p>
@@ -342,4 +450,14 @@ function type(status: ProblemStatus) {
.status-alert { .status-alert {
margin-bottom: 16px; margin-bottom: 16px;
} }
.lang-label {
font-weight: 600;
margin: 8px 0 4px;
}
.rule-message {
font-size: 13px;
opacity: 0.65;
}
</style> </style>

View File

@@ -7,7 +7,7 @@ import { SOURCES } from "utils/constants"
import SyncCodeEditor from "shared/components/SyncCodeEditor.vue" import SyncCodeEditor from "shared/components/SyncCodeEditor.vue"
import { useBreakpoints } from "shared/composables/breakpoints" import { useBreakpoints } from "shared/composables/breakpoints"
import storage from "utils/storage" import storage from "utils/storage"
import { LANGUAGE } from "utils/types" import type { LANGUAGE } from "utils/types"
import Form from "./Form.vue" import Form from "./Form.vue"
const FlowchartEditor = defineAsyncComponent( const FlowchartEditor = defineAsyncComponent(
@@ -51,6 +51,13 @@ onMounted(loadCode)
watch(() => problem.value?._id, loadCode) watch(() => problem.value?._id, loadCode)
watch(
() => codeStore.code.value,
(v) => {
storage.set(storageKey.value, v)
},
)
const changeCode = (v: string) => { const changeCode = (v: string) => {
storage.set(storageKey.value, v) storage.set(storageKey.value, v)
} }

View File

@@ -1,5 +1,5 @@
<script lang="ts" setup> <script lang="ts" setup>
import { ProblemFiltered } from "utils/types" import type { ProblemFiltered } from "utils/types"
import { Icon } from "@iconify/vue" import { Icon } from "@iconify/vue"
defineProps<{ defineProps<{
@@ -19,5 +19,10 @@ defineProps<{
width="18" width="18"
icon="vscode-icons:file-type-graphql" icon="vscode-icons:file-type-graphql"
/> />
<Icon
v-if="problem.has_ast_rules"
width="18"
icon="vscode-icons:file-type-light-todo"
/>
</n-flex> </n-flex>
</template> </template>

View File

@@ -1,17 +1,14 @@
<script lang="ts" setup> <script lang="ts" setup>
import { NButton } from "naive-ui" import { NButton, NFlex, NTooltip } from "naive-ui"
import { Icon } from "@iconify/vue"
import { getSubmissions, getRankOfProblem } from "oj/api" import { getSubmissions, getRankOfProblem } from "oj/api"
import Pagination from "shared/components/Pagination.vue" import Pagination from "shared/components/Pagination.vue"
import SubmissionResultTag from "shared/components/SubmissionResultTag.vue" import SubmissionResultTag from "shared/components/SubmissionResultTag.vue"
import { useUserStore } from "shared/store/user" import { useUserStore } from "shared/store/user"
import { import { JUDGE_STATUS, LANGUAGE_SHOW_VALUE } from "utils/constants"
JUDGE_STATUS,
LANGUAGE_SHOW_VALUE,
SubmissionStatus,
} from "utils/constants"
import { parseTime } from "utils/functions" import { parseTime } from "utils/functions"
import { renderTableTitle } from "utils/renders" import { renderTableTitle } from "utils/renders"
import { Submission } from "utils/types" import type { Submission } from "utils/types"
import SubmissionDetail from "oj/submission/detail.vue" import SubmissionDetail from "oj/submission/detail.vue"
import { useBreakpoints } from "shared/composables/breakpoints" import { useBreakpoints } from "shared/composables/breakpoints"
@@ -44,6 +41,22 @@ const columns: DataTableColumn<Submission>[] = [
key: "id", key: "id",
minWidth: 160, minWidth: 160,
render: (row) => { render: (row) => {
if (!row.show_link)
return h(NFlex, { align: "center" }, () => [
h("span", row.id.slice(0, 12)),
h(
NTooltip,
{},
{
trigger: () =>
h(NButton, { text: true }, () =>
h(Icon, { icon: "catppuccin:lock" }),
),
default: () =>
"这道题在你已经加入的题单中,只有在题单中完成此题,代码才可见。",
},
),
])
return h( return h(
NButton, NButton,
{ {

View File

@@ -1,4 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { Icon } from "@iconify/vue"
import { useThemeVars } from "naive-ui"
import { JUDGE_STATUS, SubmissionStatus } from "utils/constants" import { JUDGE_STATUS, SubmissionStatus } from "utils/constants"
import { import {
getCSRFToken, getCSRFToken,
@@ -19,6 +21,7 @@ const props = defineProps<{
const isDark = useDark() const isDark = useDark()
const problemStore = useProblemStore() const problemStore = useProblemStore()
const theme = useThemeVars()
// AI 提示状态 // AI 提示状态
const hintContent = ref("") const hintContent = ref("")
@@ -40,7 +43,10 @@ const msg = computed(() => {
msg += "请仔细检查,看看代码的格式是不是写错了!\n\n" msg += "请仔细检查,看看代码的格式是不是写错了!\n\n"
} }
if (props.submission.statistic_info?.err_info) { if (
result !== SubmissionStatus.ast_check_failed &&
props.submission.statistic_info?.err_info
) {
msg += props.submission.statistic_info.err_info msg += props.submission.statistic_info.err_info
} }
@@ -51,8 +57,9 @@ const msg = computed(() => {
const showAIHint = computed(() => { const showAIHint = computed(() => {
if (!props.submission) return false if (!props.submission) return false
return ( return (
problemStore.totalFailCount >= 3 && problemStore.failCount >= 3 &&
props.submission.result !== SubmissionStatus.accepted && props.submission.result !== SubmissionStatus.accepted &&
props.submission.result !== SubmissionStatus.ast_check_failed &&
props.submission.result !== SubmissionStatus.pending && props.submission.result !== SubmissionStatus.pending &&
props.submission.result !== SubmissionStatus.judging && props.submission.result !== SubmissionStatus.judging &&
props.submission.result !== SubmissionStatus.submitting props.submission.result !== SubmissionStatus.submitting
@@ -108,6 +115,7 @@ const infoTable = computed(() => {
// AC、编译错误、运行时错误不显示测试用例表格 // AC、编译错误、运行时错误不显示测试用例表格
if ( if (
result === SubmissionStatus.accepted || result === SubmissionStatus.accepted ||
result === SubmissionStatus.ast_check_failed ||
result === SubmissionStatus.compile_error || result === SubmissionStatus.compile_error ||
result === SubmissionStatus.runtime_error result === SubmissionStatus.runtime_error
) { ) {
@@ -145,10 +153,34 @@ const columns: DataTableColumn<Submission["info"]["data"][number]>[] = [
<div v-if="submission"> <div v-if="submission">
<n-alert <n-alert
:type="JUDGE_STATUS[submission.result]['type']" :type="JUDGE_STATUS[submission.result]['type']"
:title="JUDGE_STATUS[submission.result]['name']" :title="JUDGE_STATUS[submission.result]['title']"
class="mb-3" class="mb-3"
/> />
<n-flex vertical v-if="msg || infoTable.length"> <n-flex
vertical
v-if="
msg ||
infoTable.length ||
submission.statistic_info?.ast_results?.length
"
>
<n-card v-if="submission.statistic_info?.ast_results?.length" embedded>
<n-flex vertical :size="8">
<n-flex
v-for="(rule, i) in submission.statistic_info.ast_results"
:key="i"
align="center"
:size="6"
>
<n-icon
:color="rule.passed ? theme.successColor : theme.errorColor"
>
<Icon :icon="rule.passed ? 'ep:select' : 'ep:close-bold'" />
</n-icon>
<span>{{ rule.description }}</span>
</n-flex>
</n-flex>
</n-card>
<n-card v-if="msg" embedded class="msg">{{ msg }}</n-card> <n-card v-if="msg" embedded class="msg">{{ msg }}</n-card>
<n-data-table <n-data-table
v-if="infoTable.length" v-if="infoTable.length"

View File

@@ -1,14 +1,20 @@
<script setup lang="ts"> <script setup lang="ts">
import { Icon } from "@iconify/vue" import { Icon } from "@iconify/vue"
import { storeToRefs } from "pinia" import { storeToRefs } from "pinia"
import { getComment, submitCode, updateProblemSetProgress } from "oj/api" import {
formatCode,
getComment,
submitCode,
updateProblemSetProgress,
} from "oj/api"
import { useCodeStore } from "oj/store/code" import { useCodeStore } from "oj/store/code"
import { useProblemStore } from "oj/store/problem" import { useProblemStore } from "oj/store/problem"
import { useFireworks } from "oj/problem/composables/useFireworks" import { useFireworks } from "oj/problem/composables/useFireworks"
import { useSubmissionMonitor } from "oj/problem/composables/useSubmissionMonitor" import { useSubmissionMonitor } from "oj/problem/composables/useSubmissionMonitor"
import { SubmissionStatus } from "utils/constants" import { LANGUAGE_FORMAT_VALUE, SubmissionStatus } from "utils/constants"
import type { SubmitCodePayload } from "utils/types" import type { SubmitCodePayload } from "utils/types"
import SubmissionResult from "./SubmissionResult.vue" import SubmissionResult from "./SubmissionResult.vue"
import { getSubmitButtonState } from "./submitButtonState"
import { useBreakpoints } from "shared/composables/breakpoints" import { useBreakpoints } from "shared/composables/breakpoints"
import { useUserStore } from "shared/store/user" import { useUserStore } from "shared/store/user"
import { checkPythonSyntax } from "oj/problem/utils/pythonSyntaxCheck" import { checkPythonSyntax } from "oj/problem/utils/pythonSyntaxCheck"
@@ -37,16 +43,12 @@ const { isDesktop } = useBreakpoints()
const { celebrate } = useFireworks() const { celebrate } = useFireworks()
// ==================== 判题监控 ==================== // ==================== 判题监控 ====================
const { const { submission, judging, pending, submitting, startMonitoring } =
submission, useSubmissionMonitor()
judging,
pending,
submitting,
isProcessing,
startMonitoring,
} = useSubmissionMonitor()
const showResult = ref(false) const showResult = ref(false)
const isFormatting = ref(false)
const isSubmittingRequest = ref(false)
// ==================== 提交冷却 ==================== // ==================== 提交冷却 ====================
const { start: startCooldown, isPending: isCooldown } = useTimeout(5000, { const { start: startCooldown, isPending: isCooldown } = useTimeout(5000, {
@@ -80,35 +82,20 @@ const { start: goToProblemSetDelayed } = useTimeoutFn(
) )
// ==================== 计算属性 ==================== // ==================== 计算属性 ====================
// 按钮禁用逻辑 const buttonState = computed(() =>
const submitDisabled = computed(() => { getSubmitButtonState({
return ( isAuthed: userStore.isAuthed,
!userStore.isAuthed || hasCode: codeStore.code.value.trim() !== "",
codeStore.code.value.trim() === "" || isFormatting: isFormatting.value,
isProcessing.value || isSubmitting: isSubmittingRequest.value || submitting.value,
isCooldown.value isJudging: judging.value || pending.value,
) isCooldown: isCooldown.value,
}) }),
)
// 按钮文案
const submitLabel = computed(() => {
if (!userStore.isAuthed) return "请先登录"
if (submitting.value) return "正在提交"
if (judging.value || pending.value) return "正在评分"
if (isCooldown.value) return "正在冷却"
return "提交代码"
})
// 按钮图标
const submitIcon = computed(() => {
if (isProcessing.value) return "eos-icons:loading"
if (isCooldown.value) return "ph:lightbulb-fill"
return "ph:play-fill"
})
// ==================== 提交函数 ==================== // ==================== 提交函数 ====================
async function submit() { async function submit() {
if (!userStore.isAuthed) return if (buttonState.value.disabled) return
// 0. Python3 语法检测 // 0. Python3 语法检测
if (codeStore.code.language === "Python3") { if (codeStore.code.language === "Python3") {
@@ -119,6 +106,28 @@ async function submit() {
} }
} }
// 0.5 提交前自动格式化Python3 用 ruffC/C++ 用 clang-format
const formatLang = LANGUAGE_FORMAT_VALUE[codeStore.code.language]
if (["python", "c", "cpp"].includes(formatLang)) {
isFormatting.value = true
try {
const res = await formatCode({
code: codeStore.code.value,
language: formatLang,
})
codeStore.setCode(res.data.code)
} catch (e: any) {
if (e?.error === "format-error") {
// 仅 Python3 会出现:代码本身存在语法错误
message.warning(`代码格式化失败:${e.data},请检查代码后重试`)
return
}
// server-error / 网络异常:格式化工具问题,静默降级,提交原代码
} finally {
isFormatting.value = false
}
}
// 1. 构建提交数据 // 1. 构建提交数据
const data: SubmitCodePayload = { const data: SubmitCodePayload = {
problem_id: problem.value!.id, problem_id: problem.value!.id,
@@ -129,13 +138,18 @@ async function submit() {
data.contest_id = parseInt(contestID) data.contest_id = parseInt(contestID)
} }
// 2. 提交代码到后端 // 2. 提交代码到后端
const res = await submitCode(data) isSubmittingRequest.value = true
console.log(`[Submit] 代码已提交: ID=${res.data.submission_id}`) try {
const res = await submitCode(data)
console.log(`[Submit] 代码已提交: ID=${res.data.submission_id}`)
// 3. 启动冷却 + 监控 // 3. 启动冷却 + 监控
startCooldown() startCooldown()
startMonitoring(res.data.submission_id) startMonitoring(res.data.submission_id)
showResult.value = true showResult.value = true
} finally {
isSubmittingRequest.value = false
}
} }
// ==================== 失败计数 ==================== // ==================== 失败计数 ====================
@@ -149,7 +163,10 @@ watch(
result === SubmissionStatus.submitting result === SubmissionStatus.submitting
) )
return return
if (result !== SubmissionStatus.accepted) { if (
result !== SubmissionStatus.accepted &&
result !== SubmissionStatus.ast_check_failed
) {
problemStore.incrementFailCount() problemStore.incrementFailCount()
} }
}, },
@@ -159,7 +176,11 @@ watch(
watch( watch(
() => submission.value?.result, () => submission.value?.result,
async (result) => { async (result) => {
if (result !== SubmissionStatus.accepted) return if (
result !== SubmissionStatus.accepted &&
result !== SubmissionStatus.ast_check_failed
)
return
// 1. 刷新题目状态 // 1. 刷新题目状态
problem.value!.my_status = 0 problem.value!.my_status = 0
@@ -173,6 +194,8 @@ watch(
) )
} }
if (result !== SubmissionStatus.accepted) return
// 3. 放烟花 // 3. 放烟花
celebrate() celebrate()
@@ -204,15 +227,15 @@ watch(
<n-button <n-button
:size="isDesktop ? 'medium' : 'small'" :size="isDesktop ? 'medium' : 'small'"
type="primary" type="primary"
:disabled="submitDisabled" :disabled="buttonState.disabled"
@click="submit" @click="submit"
> >
<template #icon> <template #icon>
<n-icon> <n-icon>
<Icon :icon="submitIcon" /> <Icon :icon="buttonState.icon" />
</n-icon> </n-icon>
</template> </template>
{{ submitLabel }} {{ buttonState.label }}
</n-button> </n-button>
</template> </template>

View File

@@ -12,6 +12,7 @@ import {
useFlowchartWebSocket, useFlowchartWebSocket,
type FlowchartEvaluationUpdate, type FlowchartEvaluationUpdate,
} from "shared/composables/websocket" } from "shared/composables/websocket"
import { useMyFlowchartStore } from "shared/store/myFlowchart"
// API 和状态管理 // API 和状态管理
import { import {
@@ -51,6 +52,7 @@ const message = useMessage()
const problemStore = useProblemStore() const problemStore = useProblemStore()
const { problem } = toRefs(problemStore) const { problem } = toRefs(problemStore)
const { isDesktop } = useBreakpoints() const { isDesktop } = useBreakpoints()
const myFlowchartStore = useMyFlowchartStore()
const { convertToMermaid } = useMermaidConverter() const { convertToMermaid } = useMermaidConverter()
const { renderError, renderFlowchart } = useMermaid() const { renderError, renderFlowchart } = useMermaid()
@@ -71,6 +73,7 @@ const evaluation = ref<Evaluation>({
criteria_details: {}, criteria_details: {},
}) })
const page = ref(1) const page = ref(1)
const lastSubmittedMermaidCode = ref("")
const suggestionLines = computed(() => const suggestionLines = computed(() =>
splitSuggestionLines(evaluation.value.suggestions), splitSuggestionLines(evaluation.value.suggestions),
) )
@@ -85,15 +88,15 @@ function splitSuggestionLines(suggestions?: string | null) {
} }
// ==================== WebSocket 相关函数 ==================== // ==================== WebSocket 相关函数 ====================
// 处理 WebSocket 消息
const handleWebSocketMessage = (data: FlowchartEvaluationUpdate) => { const handleWebSocketMessage = (data: FlowchartEvaluationUpdate) => {
if (data.type === "flowchart_evaluation_completed") { if (data.type === "flowchart_evaluation_completed") {
loading.value = false loading.value = false
latestRating.value = { const grade = data.grade || ""
score: data.score || 0, latestRating.value = { score: data.score || 0, grade }
grade: data.grade || "", message.success(`流程图评分完成!得分: ${data.score}分 (${grade}级)`)
if ((grade === "A" || grade === "S") && lastSubmittedMermaidCode.value) {
myFlowchartStore.show(lastSubmittedMermaidCode.value)
} }
message.success(`流程图评分完成!得分: ${data.score}分 (${data.grade}级)`)
} else if (data.type === "flowchart_evaluation_failed") { } else if (data.type === "flowchart_evaluation_failed") {
loading.value = false loading.value = false
message.error(`流程图评分失败: ${data.error}`) message.error(`流程图评分失败: ${data.error}`)
@@ -124,6 +127,7 @@ async function submitFlowchartData() {
} }
const mermaidCode = convertToMermaid(flowchartData) const mermaidCode = convertToMermaid(flowchartData)
lastSubmittedMermaidCode.value = mermaidCode
const compressed = utoa(JSON.stringify(flowchartData)) const compressed = utoa(JSON.stringify(flowchartData))
loading.value = true loading.value = true
@@ -251,11 +255,17 @@ const getPercentType = (percent: number) => {
} }
// ==================== 生命周期钩子 ==================== // ==================== 生命周期钩子 ====================
// 组件挂载时连接 WebSocket 并检查状态
onMounted(async () => { onMounted(async () => {
connect() connect()
await getCurrentSubmission() await getCurrentSubmission()
page.value = submissionCount.value page.value = submissionCount.value
const grade = latestRating.value.grade
if ((grade === "A" || grade === "S") && submissionCount.value > 0) {
await getSubmission(submissionCount.value)
if (myMermaidCode.value) {
myFlowchartStore.show(myMermaidCode.value)
}
}
}) })
// 组件卸载时断开连接 // 组件卸载时断开连接

View File

@@ -0,0 +1,53 @@
export interface SubmitButtonStateInput {
isAuthed: boolean
hasCode: boolean
isFormatting: boolean
isSubmitting: boolean
isJudging: boolean
isCooldown: boolean
}
export interface SubmitButtonState {
disabled: boolean
label: string
icon: string
}
export function getSubmitButtonState({
isAuthed,
hasCode,
isFormatting,
isSubmitting,
isJudging,
isCooldown,
}: SubmitButtonStateInput): SubmitButtonState {
const disabled =
!isAuthed ||
!hasCode ||
isFormatting ||
isSubmitting ||
isJudging ||
isCooldown
let label = "提交代码"
if (!isAuthed) {
label = "请先登录"
} else if (isFormatting) {
label = "格式化中"
} else if (isSubmitting) {
label = "正在提交"
} else if (isJudging) {
label = "正在评分"
} else if (isCooldown) {
label = "正在冷却"
}
const icon =
isFormatting || isSubmitting || isJudging
? "eos-icons:loading"
: isCooldown
? "ph:lightbulb-fill"
: "ph:play-fill"
return { disabled, label, icon }
}

View File

@@ -4,6 +4,7 @@ import { useBreakpoints } from "shared/composables/breakpoints"
import { storeToRefs } from "pinia" import { storeToRefs } from "pinia"
import { useProblemStore } from "oj/store/problem" import { useProblemStore } from "oj/store/problem"
import { useScreenModeStore } from "shared/store/screenMode" import { useScreenModeStore } from "shared/store/screenMode"
import { useMyFlowchartStore } from "shared/store/myFlowchart"
const ProblemEditor = defineAsyncComponent( const ProblemEditor = defineAsyncComponent(
() => import("./components/ProblemEditor.vue"), () => import("./components/ProblemEditor.vue"),
@@ -29,6 +30,9 @@ const ProblemComment = defineAsyncComponent(
const ProblemFlowchart = defineAsyncComponent( const ProblemFlowchart = defineAsyncComponent(
() => import("./components/ProblemFlowchart.vue"), () => import("./components/ProblemFlowchart.vue"),
) )
const MyFlowchartTab = defineAsyncComponent(
() => import("./components/MyFlowchartTab.vue"),
)
interface Props { interface Props {
problemID: string problemID: string
@@ -36,10 +40,7 @@ interface Props {
problemSetId?: string problemSetId?: string
} }
const props = withDefaults(defineProps<Props>(), { const { problemID, contestID = "", problemSetId = "" } = defineProps<Props>()
contestID: "",
problemSetId: "",
})
const errMsg = ref("无数据") const errMsg = ref("无数据")
const route = useRoute() const route = useRoute()
@@ -47,6 +48,7 @@ const router = useRouter()
const problemStore = useProblemStore() const problemStore = useProblemStore()
const screenModeStore = useScreenModeStore() const screenModeStore = useScreenModeStore()
const myFlowchartStore = useMyFlowchartStore()
const { problem } = storeToRefs(problemStore) const { problem } = storeToRefs(problemStore)
const { shouldShowProblem } = storeToRefs(screenModeStore) const { shouldShowProblem } = storeToRefs(screenModeStore)
@@ -57,13 +59,17 @@ const tabOptions = computed(() => {
if (problem.value?.show_flowchart) { if (problem.value?.show_flowchart) {
options.push("flowchart") options.push("flowchart")
} }
if (isMobile.value) { if (isMobile.value) {
options.push("editor") options.push("editor")
} }
options.push("info") options.push("info")
if (!props.contestID) { if (!contestID) {
options.push("comment") options.push("comment")
} }
if (myFlowchartStore.showing) {
options.push("my-flowchart")
}
options.push("submission") options.push("submission")
return options return options
}) })
@@ -91,10 +97,17 @@ watch(currentTab, (tab) => {
}) })
}) })
watch(
() => myFlowchartStore.showing,
(showing) => {
if (showing) currentTab.value = "my-flowchart"
},
)
async function init() { async function init() {
screenModeStore.resetScreenMode() screenModeStore.resetScreenMode()
try { try {
const res = await getProblem(props.problemID, props.contestID) const res = await getProblem(problemID, contestID)
problem.value = res.data problem.value = res.data
} catch (err: any) { } catch (err: any) {
problem.value = null problem.value = null
@@ -104,11 +117,12 @@ async function init() {
} }
} }
onMounted(init) onMounted(init)
watch(() => props.problemID, init) watch(() => problemID, init)
onBeforeUnmount(() => { onBeforeUnmount(() => {
problem.value = null problem.value = null
errMsg.value = "无数据" errMsg.value = "无数据"
screenModeStore.resetScreenMode() screenModeStore.resetScreenMode()
myFlowchartStore.hide()
}) })
watch(isMobile, (value) => { watch(isMobile, (value) => {
@@ -142,22 +156,29 @@ watch(isMobile, (value) => {
<n-tab-pane <n-tab-pane
name="info" name="info"
tab="题目统计" tab="题目统计"
:disabled="!!props.problemSetId" :disabled="!!problemSetId"
> >
<ProblemInfo /> <ProblemInfo />
</n-tab-pane> </n-tab-pane>
<n-tab-pane <n-tab-pane
v-if="!props.contestID" v-if="!contestID"
name="comment" name="comment"
tab="题目点评" tab="题目点评"
:disabled="!!props.problemSetId" :disabled="!!problemSetId"
> >
<ProblemComment /> <ProblemComment />
</n-tab-pane> </n-tab-pane>
<n-tab-pane
v-if="myFlowchartStore.showing"
name="my-flowchart"
tab="我的流程图"
>
<MyFlowchartTab />
</n-tab-pane>
<n-tab-pane <n-tab-pane
name="submission" name="submission"
tab="我的提交" tab="我的提交"
:disabled="!!props.problemSetId" :disabled="!!problemSetId"
> >
<ProblemSubmission /> <ProblemSubmission />
</n-tab-pane> </n-tab-pane>
@@ -191,22 +212,29 @@ watch(isMobile, (value) => {
<n-tab-pane <n-tab-pane
name="info" name="info"
tab="题目统计" tab="题目统计"
:disabled="!!props.problemSetId" :disabled="!!problemSetId"
> >
<ProblemInfo /> <ProblemInfo />
</n-tab-pane> </n-tab-pane>
<n-tab-pane <n-tab-pane
v-if="!props.contestID" v-if="!contestID"
name="comment" name="comment"
tab="题目点评" tab="题目点评"
:disabled="!!props.problemSetId" :disabled="!!problemSetId"
> >
<ProblemComment /> <ProblemComment />
</n-tab-pane> </n-tab-pane>
<n-tab-pane
v-if="myFlowchartStore.showing"
name="my-flowchart"
tab="我的流程图"
>
<MyFlowchartTab />
</n-tab-pane>
<n-tab-pane <n-tab-pane
name="submission" name="submission"
tab="我的提交" tab="我的提交"
:disabled="!!props.problemSetId" :disabled="!!problemSetId"
> >
<ProblemSubmission /> <ProblemSubmission />
</n-tab-pane> </n-tab-pane>
@@ -225,18 +253,25 @@ watch(isMobile, (value) => {
<n-tab-pane name="editor" tab="代码"> <n-tab-pane name="editor" tab="代码">
<component :is="inProblem ? ProblemEditor : ContestEditor" /> <component :is="inProblem ? ProblemEditor : ContestEditor" />
</n-tab-pane> </n-tab-pane>
<n-tab-pane name="info" tab="统计" :disabled="!!props.problemSetId"> <n-tab-pane name="info" tab="统计" :disabled="!!problemSetId">
<ProblemInfo /> <ProblemInfo />
</n-tab-pane> </n-tab-pane>
<n-tab-pane <n-tab-pane
v-if="!props.contestID" v-if="!contestID"
name="comment" name="comment"
tab="点评" tab="点评"
:disabled="!!props.problemSetId" :disabled="!!problemSetId"
> >
<ProblemComment /> <ProblemComment />
</n-tab-pane> </n-tab-pane>
<n-tab-pane name="submission" tab="提交" :disabled="!!props.problemSetId"> <n-tab-pane
v-if="myFlowchartStore.showing"
name="my-flowchart"
tab="我的流程图"
>
<MyFlowchartTab />
</n-tab-pane>
<n-tab-pane name="submission" tab="提交" :disabled="!!problemSetId">
<ProblemSubmission /> <ProblemSubmission />
</n-tab-pane> </n-tab-pane>
</n-tabs> </n-tabs>

View File

@@ -4,7 +4,7 @@ import { NFlex, NTag } from "naive-ui"
import { useRouteQuery } from "@vueuse/router" import { useRouteQuery } from "@vueuse/router"
import { getProblemList, getRandomProblemID } from "oj/api" import { getProblemList, getRandomProblemID } from "oj/api"
import { getTagColor } from "utils/functions" import { getTagColor } from "utils/functions"
import { ProblemFiltered } from "utils/types" import type { ProblemFiltered } from "utils/types"
import { getProblemTagList } from "shared/api" import { getProblemTagList } from "shared/api"
import Hitokoto from "shared/components/Hitokoto.vue" import Hitokoto from "shared/components/Hitokoto.vue"
import Pagination from "shared/components/Pagination.vue" import Pagination from "shared/components/Pagination.vue"
@@ -45,6 +45,7 @@ const sortOptions = [
{ label: "最多通过", value: "-accepted_number" }, { label: "最多通过", value: "-accepted_number" },
{ label: "最少通过", value: "accepted_number" }, { label: "最少通过", value: "accepted_number" },
{ label: "画流程图", value: "flowchart" }, { label: "画流程图", value: "flowchart" },
{ label: "语法检查", value: "ast" },
] ]
const router = useRouter() const router = useRouter()
@@ -225,7 +226,7 @@ function rowProps(row: ProblemFiltered) {
<n-form :show-feedback="false" inline label-placement="left"> <n-form :show-feedback="false" inline label-placement="left">
<n-form-item label="难度"> <n-form-item label="难度">
<n-select <n-select
style="width: 100px" style="width: 80px"
v-model:value="query.difficulty" v-model:value="query.difficulty"
:options="difficultyOptions" :options="difficultyOptions"
/> />
@@ -237,9 +238,10 @@ function rowProps(row: ProblemFiltered) {
<n-form :show-feedback="false" inline label-placement="left"> <n-form :show-feedback="false" inline label-placement="left">
<n-form-item label="排序"> <n-form-item label="排序">
<n-select <n-select
style="width: 100px" style="width: 120px"
v-model:value="query.sort" v-model:value="query.sort"
:options="sortOptions" :options="sortOptions"
:dropdown-style="{ maxHeight: 'unset' }"
/> />
</n-form-item> </n-form-item>
<n-form-item> <n-form-item>

View File

@@ -1,15 +1,16 @@
<script setup lang="ts"> <script setup lang="ts">
import { formatISO, sub, type Duration } from "date-fns" import { formatISO, sub, type Duration } from "date-fns"
import { NButton, NFlex, useThemeVars } from "naive-ui" import { NButton, NFlex } from "naive-ui"
import { import {
getActivityRank, getActivityRank,
getClassRank, getClassRank,
getRank, getRank,
getUserClassRank, getUserClassRank,
getClassPK,
} from "oj/api" } from "oj/api"
import { useBreakpoints } from "shared/composables/breakpoints" import { useBreakpoints } from "shared/composables/breakpoints"
import { getACRate } from "utils/functions" import { getACRate, getCSRFToken } from "utils/functions"
import { Rank } from "utils/types" import type { Rank } from "utils/types"
import Pagination from "shared/components/Pagination.vue" import Pagination from "shared/components/Pagination.vue"
import { ChartType } from "utils/constants" import { ChartType } from "utils/constants"
import { renderTableTitle } from "utils/renders" import { renderTableTitle } from "utils/renders"
@@ -17,6 +18,9 @@ import Chart from "./components/Chart.vue"
import Index from "./components/Index.vue" import Index from "./components/Index.vue"
import { useUserStore } from "shared/store/user" import { useUserStore } from "shared/store/user"
import { Icon } from "@iconify/vue" import { Icon } from "@iconify/vue"
import { MdPreview } from "md-editor-v3"
import "md-editor-v3/lib/preview.css"
import { consumeJSONEventStream } from "utils/stream"
const gradeOptions = [ const gradeOptions = [
{ label: "24年级", value: 24 }, { label: "24年级", value: 24 },
@@ -52,6 +56,83 @@ const myClassQuery = reactive({
limit: 10, limit: 10,
}) })
const showClassDetailModal = ref(false)
const classDetailData = ref<ClassComparison | null>(null)
const classDetailLoading = ref(false)
const classDetailAiLoading = ref(false)
const classDetailAiContent = ref("")
const showClassDetailAiModal = ref(false)
let classDetailAiController: AbortController | null = null
async function loadClassDetail(className: string) {
showClassDetailModal.value = true
classDetailLoading.value = true
classDetailData.value = null
try {
const res = await getClassPK([className])
classDetailData.value = res.data.comparisons[0] ?? null
} catch {
// ignore
} finally {
classDetailLoading.value = false
}
}
async function analyzeSingleClassWithAI() {
if (!classDetailData.value) return
if (classDetailAiController) classDetailAiController.abort()
const controller = new AbortController()
classDetailAiController = controller
showClassDetailModal.value = false
showClassDetailAiModal.value = true
classDetailAiContent.value = ""
classDetailAiLoading.value = true
const headers: Record<string, string> = { "Content-Type": "application/json" }
const csrfToken = getCSRFToken()
if (csrfToken) headers["X-CSRFToken"] = csrfToken
try {
const response = await fetch("/api/ai/class_single", {
method: "POST",
headers,
body: JSON.stringify({ comparison: classDetailData.value }),
signal: controller.signal,
})
if (!response.ok) throw new Error("AI 分析生成失败")
let hasStarted = false
await consumeJSONEventStream(response, {
signal: controller.signal,
onEvent(event) {
if (event === "end" && !hasStarted) classDetailAiLoading.value = false
},
onMessage(payload) {
const parsed = payload as { type?: string; content?: string; message?: string }
if (parsed.type === "delta" && parsed.content) {
if (!hasStarted) {
hasStarted = true
classDetailAiLoading.value = false
}
classDetailAiContent.value += parsed.content
} else if (parsed.type === "error") {
throw new Error(parsed.message || "AI 服务异常")
} else if (parsed.type === "done" && !hasStarted) {
classDetailAiLoading.value = false
}
},
})
} catch (error: any) {
if (controller.signal.aborted) return
message.error(error?.message || "AI 分析失败,请稍后再试")
classDetailAiLoading.value = false
} finally {
if (classDetailAiController === controller) classDetailAiController = null
}
}
interface ClassRank { interface ClassRank {
rank: number rank: number
class_name: string class_name: string
@@ -62,6 +143,27 @@ interface ClassRank {
ac_rate: number ac_rate: number
} }
interface ClassComparison {
class_name: string
user_count: number
total_ac: number
total_submission: number
avg_ac: number
median_ac: number
q1_ac: number
q3_ac: number
iqr: number
std_dev: number
top_10_avg: number
middle_80_avg: number
bottom_10_avg: number
excellent_rate: number
pass_rate: number
active_rate: number
ac_rate: number
composite_score: number
}
interface UserRank { interface UserRank {
rank: number rank: number
username: string username: string
@@ -191,7 +293,7 @@ const classColumns: DataTableColumn<ClassRank>[] = [
{ {
title: "排名", title: "排名",
key: "rank", key: "rank",
width: 100, width: 60,
titleAlign: "center", titleAlign: "center",
align: "center", align: "center",
}, },
@@ -200,46 +302,63 @@ const classColumns: DataTableColumn<ClassRank>[] = [
key: "class_name", key: "class_name",
render: (row) => render: (row) =>
`${row.class_name.slice(0, 2)}计算机${row.class_name.slice(2)}`, `${row.class_name.slice(0, 2)}计算机${row.class_name.slice(2)}`,
width: 200, minWidth: 120,
titleAlign: "center", titleAlign: "center",
align: "center", align: "center",
}, },
{ {
title: "人数", title: "人数",
key: "user_count", key: "user_count",
width: 100, width: 80,
titleAlign: "center", titleAlign: "center",
align: "center", align: "center",
}, },
{ {
title: "总AC数", title: "总AC数",
key: "total_ac", key: "total_ac",
width: 120, width: 90,
titleAlign: "center", titleAlign: "center",
align: "center", align: "center",
}, },
{ {
title: "提交数", title: "提交数",
key: "total_submission", key: "total_submission",
width: 120, width: 90,
titleAlign: "center", titleAlign: "center",
align: "center", align: "center",
}, },
{ {
title: "平均AC数", title: "平均AC数",
key: "avg_ac", key: "avg_ac",
width: 120, width: 100,
titleAlign: "center", titleAlign: "center",
align: "center", align: "center",
}, },
{ {
title: "正确率", title: "正确率",
key: "ac_rate", key: "ac_rate",
width: 100, width: 90,
titleAlign: "center", titleAlign: "center",
align: "center", align: "center",
render: (row) => `${row.ac_rate}%`, render: (row) => `${row.ac_rate}%`,
}, },
{
title: "详情",
key: "action",
width: 70,
titleAlign: "center",
align: "center",
render: (row) =>
h(
NButton,
{
text: true,
type: "info",
onClick: () => loadClassDetail(row.class_name),
},
() => "查看",
),
},
] ]
const myClassColumns: DataTableColumn<UserRank>[] = [ const myClassColumns: DataTableColumn<UserRank>[] = [
@@ -453,6 +572,260 @@ watch(
</n-gi> </n-gi>
</n-grid> </n-grid>
</n-flex> </n-flex>
<n-modal
v-model:show="showClassDetailModal"
preset="card"
:title="
classDetailData
? `${classDetailData.class_name.slice(0, 2)}计算机${classDetailData.class_name.slice(2)}班`
: '班级详情'
"
:style="{ width: '700px', maxWidth: '95vw' }"
>
<n-spin :show="classDetailLoading" style="min-height: 200px">
<n-flex v-if="classDetailData" vertical :size="12">
<n-grid :cols="5" :x-gap="8" responsive="screen">
<n-gi>
<n-statistic
label="总AC数"
:value="classDetailData.total_ac"
size="large"
class="stat-total-ac"
>
<template #suffix>
<Icon icon="streamline-emojis:raised-fist-1" width="20" />
</template>
</n-statistic>
</n-gi>
<n-gi>
<n-statistic
label="平均AC数"
:value="classDetailData.avg_ac.toFixed(2)"
size="large"
class="stat-avg-ac"
>
<template #suffix>
<Icon icon="streamline-emojis:chart" width="20" />
</template>
</n-statistic>
</n-gi>
<n-gi>
<n-statistic
label="中位数AC数"
:value="classDetailData.median_ac.toFixed(2)"
size="large"
class="stat-median-ac"
>
<template #suffix>
<Icon icon="streamline-emojis:target" width="20" />
</template>
</n-statistic>
</n-gi>
<n-gi>
<n-statistic
label="总提交数"
:value="classDetailData.total_submission"
size="large"
class="stat-total-submission"
>
<template #suffix>
<Icon icon="streamline-emojis:paper" width="20" />
</template>
</n-statistic>
</n-gi>
<n-gi>
<n-statistic
label="AC率"
:value="classDetailData.ac_rate.toFixed(1) + '%'"
size="large"
class="stat-ac-rate"
>
<template #suffix>
<Icon icon="streamline-emojis:check-mark" width="20" />
</template>
</n-statistic>
</n-gi>
</n-grid>
<n-divider style="margin: 12px 0" />
<n-descriptions
bordered
:column="2"
size="small"
label-placement="left"
>
<n-descriptions-item label="第一四分位数(Q1)">
<span style="color: #9254de; font-weight: 500">{{
classDetailData.q1_ac.toFixed(2)
}}</span>
</n-descriptions-item>
<n-descriptions-item label="第三四分位数(Q3)">
<span style="color: #f759ab; font-weight: 500">{{
classDetailData.q3_ac.toFixed(2)
}}</span>
</n-descriptions-item>
<n-descriptions-item label="四分位距(IQR)">
<span style="color: #13c2c2; font-weight: 500">{{
classDetailData.iqr.toFixed(2)
}}</span>
</n-descriptions-item>
<n-descriptions-item label="标准差">
<span style="color: #fa8c16; font-weight: 500">{{
classDetailData.std_dev.toFixed(2)
}}</span>
</n-descriptions-item>
<n-descriptions-item label="前10%均值">
<span style="color: #cf1322; font-weight: 600">{{
classDetailData.top_10_avg.toFixed(2)
}}</span>
</n-descriptions-item>
<n-descriptions-item label="中间80%均值">
<span style="color: #389e0d; font-weight: 600">{{
classDetailData.middle_80_avg.toFixed(2)
}}</span>
</n-descriptions-item>
<n-descriptions-item label="后10%均值">
<span style="color: #096dd9; font-weight: 500">{{
classDetailData.bottom_10_avg.toFixed(2)
}}</span>
</n-descriptions-item>
<n-descriptions-item label="人数">
<span style="color: #1890ff; font-weight: 600">{{
classDetailData.user_count
}}</span>
</n-descriptions-item>
</n-descriptions>
<n-card size="small" title="比率统计" embedded style="margin-top: 12px">
<n-space vertical :size="10">
<n-progress
type="line"
:percentage="classDetailData.excellent_rate"
:show-indicator="true"
:border-radius="4"
>
<template #default
>优秀率:
{{ classDetailData.excellent_rate.toFixed(1) }}%</template
>
</n-progress>
<n-progress
type="line"
:percentage="classDetailData.pass_rate"
:show-indicator="true"
:border-radius="4"
status="success"
>
<template #default
>及格率: {{ classDetailData.pass_rate.toFixed(1) }}%</template
>
</n-progress>
<n-progress
type="line"
:percentage="classDetailData.active_rate"
:show-indicator="true"
:border-radius="4"
status="info"
>
<template #default
>参与度: {{ classDetailData.active_rate.toFixed(1) }}%</template
>
</n-progress>
</n-space>
</n-card>
<n-flex justify="center" align="center" :size="12" style="margin-top: 12px">
<n-tag type="success" size="large">
综合分: {{ classDetailData.composite_score.toFixed(1) }}
</n-tag>
<n-button
type="info"
size="small"
:loading="classDetailAiLoading"
@click="analyzeSingleClassWithAI"
>
<template #icon>
<Icon icon="mingcute:ai-line" />
</template>
AI分析
</n-button>
</n-flex>
</n-flex>
<n-empty
v-else-if="!classDetailLoading"
description="暂无数据"
style="padding: 40px 0"
/>
</n-spin>
</n-modal>
<n-modal
v-model:show="showClassDetailAiModal"
preset="card"
title="AI 分析报告"
:style="{ width: '800px', maxWidth: '95vw' }"
>
<n-spin :show="classDetailAiLoading" :delay="50">
<div style="min-height: 200px">
<MdPreview v-if="classDetailAiContent" :model-value="classDetailAiContent" />
<n-flex
v-else-if="!classDetailAiLoading"
align="center"
justify="center"
style="min-height: 200px"
>
<n-empty description="暂无分析内容" />
</n-flex>
</div>
</n-spin>
</n-modal>
</template> </template>
<style scoped></style> <style scoped>
.stat-total-ac :deep(.n-statistic-value),
.stat-total-ac :deep(.n-statistic-value__content),
.stat-total-ac :deep(.n-number-animation),
.stat-total-ac :deep(.n-statistic-value > *),
.stat-total-ac :deep(.n-statistic-value span) {
color: #ff4d4f !important;
font-weight: 600;
}
.stat-avg-ac :deep(.n-statistic-value),
.stat-avg-ac :deep(.n-statistic-value__content),
.stat-avg-ac :deep(.n-number-animation),
.stat-avg-ac :deep(.n-statistic-value > *),
.stat-avg-ac :deep(.n-statistic-value span) {
color: #52c41a !important;
font-weight: 600;
}
.stat-median-ac :deep(.n-statistic-value),
.stat-median-ac :deep(.n-statistic-value__content),
.stat-median-ac :deep(.n-number-animation),
.stat-median-ac :deep(.n-statistic-value > *),
.stat-median-ac :deep(.n-statistic-value span) {
color: #fa8c16 !important;
font-weight: 600;
}
.stat-total-submission :deep(.n-statistic-value),
.stat-total-submission :deep(.n-statistic-value__content),
.stat-total-submission :deep(.n-number-animation),
.stat-total-submission :deep(.n-statistic-value > *),
.stat-total-submission :deep(.n-statistic-value span) {
color: #805ad5 !important;
font-weight: 600;
}
.stat-ac-rate :deep(.n-statistic-value),
.stat-ac-rate :deep(.n-statistic-value__content),
.stat-ac-rate :deep(.n-number-animation),
.stat-ac-rate :deep(.n-statistic-value > *),
.stat-ac-rate :deep(.n-statistic-value span) {
color: #00b894 !important;
font-weight: 600;
}
</style>

View File

@@ -1,6 +1,11 @@
import { DetailsData, DurationData } from "utils/types" import { DetailsData, DurationData } from "utils/types"
import { consumeJSONEventStream } from "utils/stream" import { consumeJSONEventStream } from "utils/stream"
import { getAIDetailData, getAIDurationData, getAIHeatmapData } from "../api" import {
getAIDetailData,
getAIDurationData,
getAIHeatmapData,
getAIPinnedReport,
} from "../api"
import { getCSRFToken } from "utils/functions" import { getCSRFToken } from "utils/functions"
export const useAIStore = defineStore("ai", () => { export const useAIStore = defineStore("ai", () => {
@@ -27,6 +32,7 @@ export const useAIStore = defineStore("ai", () => {
}) })
const mdContent = ref("") const mdContent = ref("")
const pinnedReport = ref<{ analysis: string } | null>(null)
async function fetchDetailsData(start: string, end: string) { async function fetchDetailsData(start: string, end: string) {
const res = await getAIDetailData( const res = await getAIDetailData(
@@ -156,10 +162,38 @@ export const useAIStore = defineStore("ai", () => {
} }
} }
async function fetchPinnedReport() {
const res = await getAIPinnedReport()
pinnedReport.value = res.data
}
async function simulatePinnedStream() {
if (!pinnedReport.value) return
const text = pinnedReport.value.analysis
mdContent.value = ""
const CHUNK = 6
const DELAY = 18
await new Promise<void>((resolve) => {
let i = 0
function step() {
if (i >= text.length) {
resolve()
return
}
mdContent.value += text.slice(i, i + CHUNK)
i += CHUNK
setTimeout(step, DELAY)
}
step()
})
}
return { return {
fetchAnalysisData, fetchAnalysisData,
fetchHeatmapData, fetchHeatmapData,
fetchAIAnalysis, fetchAIAnalysis,
fetchPinnedReport,
simulatePinnedStream,
durationData, durationData,
detailsData, detailsData,
heatmapData, heatmapData,
@@ -167,5 +201,6 @@ export const useAIStore = defineStore("ai", () => {
targetUsername, targetUsername,
loading, loading,
mdContent, mdContent,
pinnedReport,
} }
}) })

View File

@@ -1,19 +1,12 @@
import { defineStore } from "pinia" import { defineStore } from "pinia"
import { LANGUAGE, Problem } from "utils/types" import type { LANGUAGE, Problem } from "utils/types"
/**
* 题目状态管理 Store
* 管理当前题目的信息
*/
export const useProblemStore = defineStore("problem", () => { export const useProblemStore = defineStore("problem", () => {
// ==================== 状态 ====================
const problem = ref<Problem | null>(null) const problem = ref<Problem | null>(null)
const route = useRoute() const route = useRoute()
// 本次会话内累计的失败次数(与服务端 my_failed_count 叠加) const failCount = ref(0)
const localFailCount = ref(0)
// ==================== 计算属性 ====================
const languages = computed<LANGUAGE[]>(() => { const languages = computed<LANGUAGE[]>(() => {
if (route.name === "problem" && problem.value?.allow_flowchart) { if (route.name === "problem" && problem.value?.allow_flowchart) {
return ["Flowchart", ...problem.value?.languages] return ["Flowchart", ...problem.value?.languages]
@@ -21,27 +14,21 @@ export const useProblemStore = defineStore("problem", () => {
return problem.value?.languages ?? [] return problem.value?.languages ?? []
}) })
const totalFailCount = computed(
() => (problem.value?.my_failed_count ?? 0) + localFailCount.value,
)
function incrementFailCount() { function incrementFailCount() {
localFailCount.value++ failCount.value++
} }
// 切题时重置
watch( watch(
() => problem.value?.id, () => problem.value?.id,
() => { () => {
localFailCount.value = 0 failCount.value = 0
}, },
) )
return { return {
problem, problem,
localFailCount, failCount,
languages, languages,
totalFailCount,
incrementFailCount, incrementFailCount,
} }
}) })

View File

@@ -1,36 +1,45 @@
<template> <template>
<n-grid v-if="submission" :cols="5" :x-gap="16"> <n-grid v-if="submission" :cols="5" :x-gap="16">
<!-- 左侧流程图预览区域 --> <!-- 左侧流程图预览区域 -->
<n-gi :span="showLargeImage ? 5 : 3"> <n-gi :span="3">
<n-card title="流程图预览"> <n-card title="流程图预览">
<template #header-extra> <template #header-extra>
<n-button <n-button
v-if="!renderError && submission?.mermaid_code" v-if="!renderError && submission?.mermaid_code"
quaternary quaternary
size="small" size="small"
@click="showLargeImage = !showLargeImage" @click="showLargeImage = true"
> >
<template #icon> <template #icon>
<Icon <Icon icon="mdi:fullscreen" />
:icon="
showLargeImage ? 'mdi:fullscreen-exit' : 'mdi:fullscreen'
"
/>
</template> </template>
{{ showLargeImage ? "退出大图" : "查看大图" }} 查看大图
</n-button> </n-button>
</template> </template>
<div class="flowchart"> <div class="flowchart">
<n-alert v-if="renderError" type="error" title="流程图渲染失败"> <n-alert v-if="renderError" type="error" title="流程图渲染失败">
{{ renderError }} {{ renderError }}
</n-alert> </n-alert>
<div class="flowchart" v-else ref="mermaidContainer"></div> <Teleport v-else to="body" :disabled="!showLargeImage">
<div
:class="['flowchart', { 'flowchart-fullscreen': showLargeImage }]"
ref="mermaidContainer"
></div>
<div v-if="showLargeImage" class="fullscreen-toolbar">
<n-button secondary round @click="showLargeImage = false">
<template #icon>
<Icon icon="mdi:fullscreen-exit" />
</template>
退出大图
</n-button>
</div>
</Teleport>
</div> </div>
</n-card> </n-card>
</n-gi> </n-gi>
<!-- 右侧评分详情区域 --> <!-- 右侧评分详情区域 -->
<n-gi v-if="!showLargeImage" :span="2"> <n-gi :span="2">
<!-- AI反馈 --> <!-- AI反馈 -->
<n-card <n-card
v-if="submission.ai_feedback" v-if="submission.ai_feedback"
@@ -137,6 +146,7 @@ function getPercentType(percent: number) {
async function loadSubmission() { async function loadSubmission() {
if (!props.submissionId) return if (!props.submissionId) return
showLargeImage.value = false
loading.value = true loading.value = true
try { try {
const { getFlowchartSubmission } = await import("oj/api") const { getFlowchartSubmission } = await import("oj/api")
@@ -171,11 +181,42 @@ watch(() => props.submissionId, loadSubmission, { immediate: true })
align-items: center; align-items: center;
} }
/* 全屏大图:覆盖整个视口,脱离弹框宽度限制 */
.flowchart-fullscreen {
position: fixed;
inset: 0;
z-index: 4000;
width: 100vw;
height: 100vh;
padding: 32px;
box-sizing: border-box;
background: #ffffff;
/* 改为可滚动块布局,超出视口的大图可以滚动查看 */
display: block;
overflow: auto;
}
.fullscreen-toolbar {
position: fixed;
top: 16px;
right: 16px;
z-index: 4001;
}
/* 确保 SVG 图表占满容器 */ /* 确保 SVG 图表占满容器 */
:deep(.flowchart > svg) { :deep(.flowchart > svg) {
height: 100%; height: 100%;
} }
/* 全屏时按自然尺寸显示并水平居中,配合容器滚动 */
:deep(.flowchart-fullscreen > svg) {
display: block;
margin: 0 auto;
width: auto;
height: auto;
max-width: none;
}
.loading-container { .loading-container {
min-height: 600px; min-height: 600px;
display: flex; display: flex;

View File

@@ -14,14 +14,26 @@
查看测试详情 查看测试详情
</n-tooltip> </n-tooltip>
</n-flex> </n-flex>
<span v-else> <n-flex v-else-if="isOwnSubmission" align="center">
{{ props.submission.id.slice(0, 12) }} <span>{{ props.submission.id.slice(0, 12) }}</span>
</span> <n-tooltip>
<template #trigger>
<n-button text>
<template #icon>
<Icon icon="catppuccin:lock"></Icon>
</template>
</n-button>
</template>
这道题在你已经加入的题单中只有在题单中完成此题代码才可见
</n-tooltip>
</n-flex>
<span v-else>{{ props.submission.id.slice(0, 12) }}</span>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { Icon } from "@iconify/vue" import { Icon } from "@iconify/vue"
import { SubmissionListItem } from "utils/types" import { useUserStore } from "shared/store/user"
import type { SubmissionListItem } from "utils/types"
interface Props { interface Props {
submission: SubmissionListItem submission: SubmissionListItem
@@ -29,6 +41,11 @@ interface Props {
const props = defineProps<Props>() const props = defineProps<Props>()
defineEmits(["showCode"]) defineEmits(["showCode"])
const userStore = useUserStore()
const isOwnSubmission = computed(
() => userStore.profile?.user?.id === props.submission.user_id,
)
function goto() { function goto() {
window.open("/submission/" + props.submission.id, "_blank") window.open("/submission/" + props.submission.id, "_blank")
} }

View File

@@ -120,7 +120,7 @@ onMounted(init)
<n-alert <n-alert
style="flex: 1" style="flex: 1"
:type="JUDGE_STATUS[submission.result]['type']" :type="JUDGE_STATUS[submission.result]['type']"
:title="JUDGE_STATUS[submission.result]['name']" :title="JUDGE_STATUS[submission.result]['title']"
> >
<n-flex> <n-flex>
<span>提交时间{{ parseTime(submission.create_time) }}</span> <span>提交时间{{ parseTime(submission.create_time) }}</span>

View File

@@ -6,9 +6,10 @@ import {
getFlowchartSubmissions, getFlowchartSubmissions,
getSubmissions, getSubmissions,
getTodaySubmissionCount, getTodaySubmissionCount,
retryFlowchartSubmission,
} from "oj/api" } from "oj/api"
import { parseTime } from "utils/functions" import { parseTime } from "utils/functions"
import { import type {
FlowchartSubmissionListItem, FlowchartSubmissionListItem,
LANGUAGE, LANGUAGE,
SubmissionListItem, SubmissionListItem,
@@ -22,6 +23,7 @@ import { LANGUAGE_SHOW_VALUE } from "utils/constants"
import { renderTableTitle } from "utils/renders" import { renderTableTitle } from "utils/renders"
import ButtonWithSearch from "./components/ButtonWithSearch.vue" import ButtonWithSearch from "./components/ButtonWithSearch.vue"
import StatisticsPanel from "shared/components/StatisticsPanel.vue" import StatisticsPanel from "shared/components/StatisticsPanel.vue"
import FlowchartStatisticsPanel from "shared/components/FlowchartStatisticsPanel.vue"
import SubmissionLink from "./components/SubmissionLink.vue" import SubmissionLink from "./components/SubmissionLink.vue"
import SubmissionDetail from "./detail.vue" import SubmissionDetail from "./detail.vue"
import Grade from "./components/Grade.vue" import Grade from "./components/Grade.vue"
@@ -61,8 +63,7 @@ const { query, clearQuery } = usePagination<SubmissionQuery>({
const submissionID = ref("") const submissionID = ref("")
const problemDisplayID = ref("") const problemDisplayID = ref("")
const [statisticPanel, toggleStatisticPanel] = useToggle(false) const [statisticPanel, toggleStatisticPanel] = useToggle(false)
const [flowchartStatisticPanel, toggleFlowchartStatisticPanel] =
useToggle(false)
const [codePanel, toggleCodePanel] = useToggle(false) const [codePanel, toggleCodePanel] = useToggle(false)
const [scoreDetailPanel, toggleScoreDetailPanel] = useToggle(false) const [scoreDetailPanel, toggleScoreDetailPanel] = useToggle(false)
const selectedFlowchartId = ref("") const selectedFlowchartId = ref("")
@@ -73,11 +74,20 @@ const selectedFlowchart = computed(() => {
const resultOptions: SelectOption[] = [ const resultOptions: SelectOption[] = [
{ label: "全部", value: "" }, { label: "全部", value: "" },
{ label: "答案正确", value: "0" }, { label: "答案正确", value: "0" },
{ label: "语法未通过", value: "10" },
{ label: "答案错误", value: "-1" }, { label: "答案错误", value: "-1" },
{ label: "编译失败", value: "-2" }, { label: "编译失败", value: "-2" },
{ label: "运行时错误", value: "4" }, { label: "运行时错误", value: "4" },
] ]
const gradeOptions: SelectOption[] = [
{ label: "全部", value: "" },
{ label: "S级", value: "S" },
{ label: "A级", value: "A" },
{ label: "B级", value: "B" },
{ label: "C级", value: "C" },
]
const languageOptions: SelectOption[] = [ const languageOptions: SelectOption[] = [
{ label: "流程图", value: "Flowchart" }, { label: "流程图", value: "Flowchart" },
{ label: "全部语言", value: "" }, { label: "全部语言", value: "" },
@@ -95,6 +105,8 @@ async function listSubmissions() {
myself: query.myself, myself: query.myself,
offset, offset,
limit: query.limit, limit: query.limit,
today: query.today,
grade: query.result,
}) })
total.value = res.data.total total.value = res.data.total
flowcharts.value = res.data.results flowcharts.value = res.data.results
@@ -113,7 +125,7 @@ async function listSubmissions() {
} }
async function getTodayCount() { async function getTodayCount() {
const res = await getTodaySubmissionCount() const res = await getTodaySubmissionCount(query.language)
todayCount.value = res.data todayCount.value = res.data
} }
@@ -139,6 +151,12 @@ async function rejudge(submissionID: string) {
listSubmissions() listSubmissions()
} }
async function retryFlowchart(submissionId: string) {
await retryFlowchartSubmission(submissionId)
message.success("重新评分已提交")
listSubmissions()
}
function problemClicked(row: SubmissionListItem | FlowchartSubmissionListItem) { function problemClicked(row: SubmissionListItem | FlowchartSubmissionListItem) {
if (route.name === "contest submissions") { if (route.name === "contest submissions") {
const path = router.resolve({ const path = router.resolve({
@@ -191,6 +209,24 @@ watch(
listSubmissions, listSubmissions,
) )
// 切换语言时重置过滤条件,刷新今日提交数
watch(
() => query.language,
() => {
query.result = ""
if (route.name === "submissions") getTodayCount()
},
)
// 登录状态变化后刷新提交列表,更新提交编号列的可点击状态
watch(
() => userStore.isAuthed,
() => {
listSubmissions()
if (route.name === "submissions") getTodayCount()
},
)
const columns = computed(() => { const columns = computed(() => {
const res: DataTableColumn<SubmissionListItem>[] = [ const res: DataTableColumn<SubmissionListItem>[] = [
{ {
@@ -260,7 +296,7 @@ const columns = computed(() => {
), ),
}, },
] ]
if (!route.params.contestID && userStore.isSuperAdmin) { if (!route.params.contestID && userStore.isTeacherOrAbove) {
res.push({ res.push({
title: renderTableTitle("选项", "streamline-emojis:wrench"), title: renderTableTitle("选项", "streamline-emojis:wrench"),
key: "rejudge", key: "rejudge",
@@ -280,61 +316,81 @@ const columns = computed(() => {
return res return res
}) })
const flowchartColumns: DataTableColumn<FlowchartSubmissionListItem>[] = [ const flowchartColumns = computed(() => {
{ const res: DataTableColumn<FlowchartSubmissionListItem>[] = [
title: renderTableTitle("提交时间", "noto:seven-oclock"), {
key: "create_time", title: renderTableTitle("提交时间", "noto:seven-oclock"),
render: (row) => parseTime(row.create_time, "YYYY-MM-DD HH:mm:ss"), key: "create_time",
}, render: (row) => parseTime(row.create_time, "YYYY-MM-DD HH:mm:ss"),
{ },
title: renderTableTitle("提交编号", "fluent-emoji-flat:input-numbers"), {
key: "id", title: renderTableTitle("提交编号", "fluent-emoji-flat:input-numbers"),
render: (row) => key: "id",
h(FlowchartLink, { render: (row) =>
flowchart: row, h(FlowchartLink, {
onShowDetail: (id: string) => showScoreDetail(id), flowchart: row,
}), onShowDetail: (id: string) => showScoreDetail(id),
}, }),
{ },
title: renderTableTitle("题目", "streamline-emojis:blossom"), {
key: "problem_title", title: renderTableTitle("题目", "streamline-emojis:blossom"),
render: (row) => key: "problem_title",
h( render: (row) =>
ButtonWithSearch, h(
{ ButtonWithSearch,
type: "题目", {
onClick: () => problemClicked(row), type: "题目",
onSearch: () => (query.problem = row.problem), onClick: () => problemClicked(row),
}, onSearch: () => (query.problem = row.problem),
() => `${row.problem} ${row.problem_title}`, },
() => `${row.problem} ${row.problem_title}`,
),
},
{
title: renderTableTitle("评分", "streamline-emojis:bar-chart"),
key: "ai_score",
render: (row) => h(Grade, { score: row.ai_score, grade: row.ai_grade }),
},
{
title: renderTableTitle(
"用户",
"streamline-emojis:smiling-face-with-sunglasses",
), ),
}, key: "username",
{ minWidth: 200,
title: renderTableTitle("评分", "streamline-emojis:bar-chart"), render: (row) =>
key: "ai_score", h(
render: (row) => h(Grade, { score: row.ai_score, grade: row.ai_grade }), ButtonWithSearch,
}, {
{ type: "用户",
title: renderTableTitle( username: row.username,
"用户", onClick: () => window.open("/user?name=" + row.username, "_blank"),
"streamline-emojis:smiling-face-with-sunglasses", onSearch: () => (query.username = row.username),
), onFilterClass: (classname: string) => (query.username = classname),
key: "username", },
minWidth: 200, () => row.username,
render: (row) => ),
h( },
ButtonWithSearch, ]
{ if (!route.params.contestID && userStore.isTeacherOrAbove) {
type: "用户", res.push({
username: row.username, title: renderTableTitle("选项", "streamline-emojis:wrench"),
onClick: () => window.open("/user?name=" + row.username, "_blank"), key: "retry",
onSearch: () => (query.username = row.username), render: (row) =>
onFilterClass: (classname: string) => (query.username = classname), h(
}, NButton,
() => row.username, {
), quaternary: true,
}, size: "small",
] type: "primary",
onClick: () => retryFlowchart(row.id),
},
() => "重新判题",
),
})
}
return res
})
</script> </script>
<template> <template>
<n-flex vertical size="large"> <n-flex vertical size="large">
@@ -354,12 +410,13 @@ const flowchartColumns: DataTableColumn<FlowchartSubmissionListItem>[] = [
:options="languageOptions" :options="languageOptions"
/> />
</n-form-item> </n-form-item>
<n-form-item label="状态"> <n-form-item :label="query.language === 'Flowchart' ? '等级' : '状态'">
<n-select <n-select
:disabled="query.language === 'Flowchart'"
class="select" class="select"
v-model:value="query.result" v-model:value="query.result"
:options="resultOptions" :options="
query.language === 'Flowchart' ? gradeOptions : resultOptions
"
/> />
</n-form-item> </n-form-item>
</n-form> </n-form>
@@ -399,7 +456,7 @@ const flowchartColumns: DataTableColumn<FlowchartSubmissionListItem>[] = [
<n-button @click="clear" quaternary>重置</n-button> <n-button @click="clear" quaternary>重置</n-button>
</n-form-item> </n-form-item>
<n-form-item <n-form-item
v-if="userStore.isSuperAdmin && route.name === 'submissions'" v-if="userStore.isTeacherOrAbove && route.name === 'submissions'"
> >
<n-button <n-button
quaternary quaternary
@@ -418,9 +475,9 @@ const flowchartColumns: DataTableColumn<FlowchartSubmissionListItem>[] = [
size="large" size="large"
@update:checked="(v: boolean) => (query.today = v ? '1' : '0')" @update:checked="(v: boolean) => (query.today = v ? '1' : '0')"
> >
<n-gradient-text v-if="query.today !== '1'" type="success" <n-gradient-text v-if="query.today !== '1'" type="success">
>今日提交数{{ todayCount }}</n-gradient-text 今日提交数{{ todayCount }}
> </n-gradient-text>
<template v-else>今日提交数{{ todayCount }}</template> <template v-else>今日提交数{{ todayCount }}</template>
</n-tag> </n-tag>
</n-space> </n-space>
@@ -443,14 +500,25 @@ const flowchartColumns: DataTableColumn<FlowchartSubmissionListItem>[] = [
v-model:page="query.page" v-model:page="query.page"
/> />
<n-modal <n-modal
v-if="userStore.isSuperAdmin" v-if="userStore.isTeacherOrAbove"
v-model:show="statisticPanel" v-model:show="statisticPanel"
preset="card" preset="card"
:style="{ maxWidth: isDesktop && '800px', maxHeight: '80vh' }" :style="{ maxWidth: isDesktop && '800px', maxHeight: '80vh' }"
:content-style="{ overflow: 'auto' }" :content-style="{ overflow: 'auto' }"
title="提交记录的统计" :title="
query.language === 'Flowchart' ? '流程图提交的统计' : '提交记录的统计'
"
> >
<StatisticsPanel :problem="query.problem" :username="query.username" /> <FlowchartStatisticsPanel
v-if="query.language === 'Flowchart'"
:problem="query.problem"
:username="query.username"
/>
<StatisticsPanel
v-else
:problem="query.problem"
:username="query.username"
/>
</n-modal> </n-modal>
<n-modal <n-modal
v-model:show="codePanel" v-model:show="codePanel"

29
src/oj/transforms.ts Normal file
View File

@@ -0,0 +1,29 @@
import { DIFFICULTY } from "utils/constants"
import { getACRate } from "utils/functions"
import type { Problem } from "utils/types"
// 把后端的 Problem 塑形成列表项需要的形状,与请求逻辑解耦。
export function filterResult(result: Problem) {
const newResult = {
id: result.id,
_id: result._id,
title: result.title,
difficulty: DIFFICULTY[result.difficulty],
tags: result.tags,
submission: result.submission_number,
rate: getACRate(result.accepted_number, result.submission_number),
status: "",
author: result.created_by.username,
allow_flowchart: result.allow_flowchart,
show_flowchart: result.show_flowchart,
has_ast_rules: result.has_ast_rules,
}
if (result.my_status === null || result.my_status === undefined) {
newResult.status = "not_test"
} else if (result.my_status === 0) {
newResult.status = "passed"
} else {
newResult.status = "failed"
}
return newResult
}

View File

@@ -28,7 +28,7 @@ const isDefaultAvatar = computed(
() => profile.value?.avatar.endsWith("default.png") ?? true, () => profile.value?.avatar.endsWith("default.png") ?? true,
) )
const problemsFlexRef = ref<HTMLElement | null>(null) const problemsFlexRef = useTemplateRef<HTMLElement>("problemsFlexRef")
const itemsPerRow = ref(8) const itemsPerRow = ref(8)
function updateItemsPerRow() { function updateItemsPerRow() {

View File

@@ -1,4 +1,4 @@
import { RouteRecordRaw } from "vue-router" import type { RouteRecordRaw } from "vue-router"
export const ojs: RouteRecordRaw = { export const ojs: RouteRecordRaw = {
path: "/", path: "/",
@@ -182,48 +182,48 @@ export const admins: RouteRecordRaw = {
path: "contest/list", path: "contest/list",
name: "admin contest list", name: "admin contest list",
component: () => import("admin/contest/list.vue"), component: () => import("admin/contest/list.vue"),
meta: { requiresSuperAdmin: true }, meta: { requiresTeacherAdmin: true },
}, },
{ {
path: "contest/create", path: "contest/create",
name: "admin contest create", name: "admin contest create",
component: () => import("admin/contest/detail.vue"), component: () => import("admin/contest/detail.vue"),
meta: { requiresSuperAdmin: true }, meta: { requiresTeacherAdmin: true },
}, },
{ {
path: "contest/edit/:contestID", path: "contest/edit/:contestID",
name: "admin contest edit", name: "admin contest edit",
component: () => import("admin/contest/detail.vue"), component: () => import("admin/contest/detail.vue"),
props: true, props: true,
meta: { requiresSuperAdmin: true }, meta: { requiresTeacherAdmin: true },
}, },
{ {
path: "contest/:contestID/problem/list", path: "contest/:contestID/problem/list",
name: "admin contest problem list", name: "admin contest problem list",
component: () => import("admin/problem/list.vue"), component: () => import("admin/problem/list.vue"),
props: true, props: true,
meta: { requiresSuperAdmin: true }, meta: { requiresTeacherAdmin: true },
}, },
{ {
path: "contest/:contestID/problem/create", path: "contest/:contestID/problem/create",
name: "admin contest problem create", name: "admin contest problem create",
component: () => import("admin/problem/detail.vue"), component: () => import("admin/problem/detail.vue"),
props: true, props: true,
meta: { requiresSuperAdmin: true }, meta: { requiresTeacherAdmin: true },
}, },
{ {
path: "contest/:contestID/problem/edit/:problemID", path: "contest/:contestID/problem/edit/:problemID",
name: "admin contest problem edit", name: "admin contest problem edit",
component: () => import("admin/problem/detail.vue"), component: () => import("admin/problem/detail.vue"),
props: true, props: true,
meta: { requiresSuperAdmin: true }, meta: { requiresTeacherAdmin: true },
}, },
{ {
path: "contest/:contestID/helper", path: "contest/:contestID/helper",
name: "admin contest helper", name: "admin contest helper",
component: () => import("admin/contest/helper.vue"), component: () => import("admin/contest/helper.vue"),
props: true, props: true,
meta: { requiresSuperAdmin: true }, meta: { requiresTeacherAdmin: true },
}, },
// 只有super_admin可以访问的路由 // 只有super_admin可以访问的路由
{ {
@@ -280,40 +280,46 @@ export const admins: RouteRecordRaw = {
path: "problem/stuck", path: "problem/stuck",
name: "admin stuck problems", name: "admin stuck problems",
component: () => import("admin/problem/Stuck.vue"), component: () => import("admin/problem/Stuck.vue"),
meta: { requiresSuperAdmin: true }, meta: { requiresTeacherAdmin: true },
}, },
{ {
path: "problem/top_ac_trend", path: "problem/top_ac_trend",
name: "admin top ac trend", name: "admin top ac trend",
component: () => import("admin/problem/TopACTrend.vue"), component: () => import("admin/problem/TopACTrend.vue"),
meta: { requiresSuperAdmin: true }, meta: { requiresTeacherAdmin: true },
}, },
// 题单管理路由 // 题单管理路由
{ {
path: "problemset/list", path: "problemset/list",
name: "admin problemset list", name: "admin problemset list",
component: () => import("admin/problemset/list.vue"), component: () => import("admin/problemset/list.vue"),
meta: { requiresSuperAdmin: true }, meta: { requiresTeacherAdmin: true },
}, },
{ {
path: "problemset/create", path: "problemset/create",
name: "admin problemset create", name: "admin problemset create",
component: () => import("admin/problemset/edit.vue"), component: () => import("admin/problemset/edit.vue"),
meta: { requiresSuperAdmin: true }, meta: { requiresTeacherAdmin: true },
}, },
{ {
path: "problemset/edit/:problemSetId", path: "problemset/edit/:problemSetId",
name: "admin problemset edit", name: "admin problemset edit",
component: () => import("admin/problemset/edit.vue"), component: () => import("admin/problemset/edit.vue"),
props: true, props: true,
meta: { requiresSuperAdmin: true }, meta: { requiresTeacherAdmin: true },
}, },
{ {
path: "problemset/:problemSetId", path: "problemset/:problemSetId",
name: "admin problemset detail", name: "admin problemset detail",
component: () => import("admin/problemset/detail.vue"), component: () => import("admin/problemset/detail.vue"),
props: true, props: true,
meta: { requiresSuperAdmin: true }, meta: { requiresTeacherAdmin: true },
},
{
path: "ai/reports",
name: "admin ai reports",
component: () => import("admin/ai/list.vue"),
meta: { requiresTeacherAdmin: true },
}, },
], ],
} }

View File

@@ -45,6 +45,6 @@ function goPublicSecurity() {
</script> </script>
<style scoped> <style scoped>
.beian { .beian {
margin-bottom: 12px; margin: 12px 0;
} }
</style> </style>

View File

@@ -22,21 +22,19 @@ interface Props {
placeholder?: string placeholder?: string
} }
const props = withDefaults(defineProps<Props>(), { const {
language: "Python3", language = "Python3",
fontSize: 20, fontSize = 20,
height: "100%", height = "100%",
readonly: false, readonly = false,
placeholder: "", placeholder = "",
}) } = defineProps<Props>()
const { readonly, placeholder, height, fontSize } = toRefs(props)
const code = defineModel<string>("value") const code = defineModel<string>("value")
const isDark = useDark() const isDark = useDark()
const langExtension = computed(() => { const langExtension = computed(() => {
return ["Python2", "Python3"].includes(props.language) ? python() : cpp() return ["Python2", "Python3"].includes(language) ? python() : cpp()
}) })
const extensions = computed(() => [ const extensions = computed(() => [
@@ -45,7 +43,7 @@ const extensions = computed(() => [
bracketMatching(), bracketMatching(),
closeBrackets(), closeBrackets(),
autocompletion({ autocompletion({
override: [enhanceCompletion(props.language), completeAnyWord], override: [enhanceCompletion(language), completeAnyWord],
}), }),
isDark.value ? oneDark : smoothy, isDark.value ? oneDark : smoothy,
]) ])

View File

@@ -78,7 +78,7 @@ const emit = defineEmits<Emits>()
const isHovered = ref(false) const isHovered = ref(false)
const isEditing = ref(false) const isEditing = ref(false)
const editText = ref("") const editText = ref("")
const editInput = ref<HTMLInputElement>() const editInput = useTemplateRef<HTMLInputElement>("editInput")
// 定时器和事件处理器 // 定时器和事件处理器
let hideTimeout: ReturnType<typeof setTimeout> | null = null let hideTimeout: ReturnType<typeof setTimeout> | null = null

View File

@@ -26,9 +26,7 @@ interface Props {
height?: string height?: string
} }
withDefaults(defineProps<Props>(), { const { height = "calc(100vh - 133px)" } = defineProps<Props>()
height: "calc(100vh - 133px)",
})
// Vue Flow 实例 // Vue Flow 实例
const { addEdges, removeNodes, removeEdges } = useVueFlow() const { addEdges, removeNodes, removeEdges } = useVueFlow()

View File

@@ -0,0 +1,577 @@
<template>
<n-flex align="center">
<n-input
placeholder="用户(可选)"
v-model:value="query.username"
style="width: 150px"
clearable
/>
<n-input
placeholder="题号(可选)"
v-model:value="query.problem"
style="width: 120px"
clearable
/>
<n-select
style="width: 120px"
v-model:value="query.duration"
:options="durationOptions"
/>
<n-button type="primary" @click="handleStatistics">统计</n-button>
</n-flex>
<n-empty
v-if="data.total_count === 0"
description="暂无数据"
style="margin: 40px 0"
/>
<template v-if="data.total_count > 0">
<n-divider style="margin: 16px 0" />
<n-flex justify="space-around">
<div class="stat-item">
<n-text>总提交</n-text>
<n-gradient-text type="info" font-size="28">
{{ data.total_count }}
</n-gradient-text>
</div>
<div class="stat-item">
<n-text>平均分</n-text>
<n-gradient-text type="primary" font-size="28">
{{ data.avg_score }}
</n-gradient-text>
</div>
<template v-if="data.person_count > 0">
<div class="stat-item">
<n-text>完成人数</n-text>
<n-gradient-text type="error" font-size="28">
{{ data.completed_count }}
</n-gradient-text>
</div>
<div class="stat-item">
<n-text>班级人数</n-text>
<n-gradient-text type="warning" font-size="28">
{{ data.person_count }}
</n-gradient-text>
</div>
<div class="stat-item">
<n-text>完成度</n-text>
<n-gradient-text type="success" font-size="28">
{{ completionRate }}
</n-gradient-text>
</div>
</template>
</n-flex>
<n-divider style="margin: 16px 0" />
<n-tabs animated type="line">
<n-tab-pane name="charts" tab="数据图表">
<n-grid :cols="2" :x-gap="20" :y-gap="20" style="margin-top: 12px">
<!-- 1. Grade pie chart -->
<n-gi>
<n-card title="等级分布">
<div class="chart-container">
<Doughnut :data="gradeChartData" :options="doughnutOptions" />
</div>
</n-card>
</n-gi>
<!-- 3. Completion doughnut -->
<n-gi v-if="data.person_count > 0">
<n-card title="班级完成度">
<div class="chart-container">
<Doughnut
:data="completionChartData"
:options="doughnutOptions"
/>
</div>
</n-card>
</n-gi>
<!-- 2. Radar chart -->
<n-gi v-if="hasRadarData">
<n-card title="四维评分雷达图">
<div class="chart-container">
<Radar :data="radarChartData" :options="radarOptions" />
</div>
</n-card>
</n-gi>
<!-- 4. Criteria bar chart (only when class exists, pairs with radar) -->
<n-gi v-if="data.person_count > 0 && hasRadarData">
<n-card title="各维度平均得分">
<div class="chart-container">
<Bar :data="criteriaBarChartData" :options="barOptions" />
</div>
</n-card>
</n-gi>
<!-- 4. Word cloud -->
<n-gi :span="2" v-if="data.word_frequencies.length > 0">
<n-card title="常见问题高频词">
<div class="wordcloud-container">
<canvas ref="wordcloudCanvas"></canvas>
</div>
</n-card>
</n-gi>
</n-grid>
</n-tab-pane>
<n-tab-pane
v-if="data.data_unaccepted.length > 0"
name="unaccepted"
:tab="`未完成(${visibleUnaccepted.length}`"
>
<n-flex align="center" style="margin: 12px 0">
<n-switch v-model:value="hideMode" size="large">
<template #checked>请假隐藏中</template>
<template #unchecked>请假隐藏</template>
</n-switch>
<n-button
v-if="hiddenCount > 0"
size="small"
type="info"
@click="showAll"
>
恢复 {{ hiddenCount }}
</n-button>
</n-flex>
<n-flex size="large" align="center">
<n-gradient-text
v-if="visibleUnaccepted.length === 0"
font-size="24"
type="success"
>
全都完成了
</n-gradient-text>
<template v-for="item in visibleUnaccepted" :key="item.username">
<n-tag
v-if="hideMode"
closable
size="large"
style="font-size: 20px"
@close="hideStudent(item.username)"
>
{{ item.real_name }}
</n-tag>
<span v-else style="font-size: 24px">{{ item.real_name }}</span>
</template>
</n-flex>
</n-tab-pane>
</n-tabs>
</template>
</template>
<script setup lang="ts">
import { formatISO, sub, type Duration } from "date-fns"
import { getFlowchartStatistics } from "oj/api"
import { DURATION_OPTIONS } from "utils/constants"
import { Doughnut, Radar, Bar } from "vue-chartjs"
import {
Chart as ChartJS,
ArcElement,
Title,
Tooltip,
Legend,
RadialLinearScale,
PointElement,
LineElement,
Filler,
LinearScale,
BarElement,
CategoryScale,
} from "chart.js"
import { WordCloudController, WordElement } from "chartjs-chart-wordcloud"
ChartJS.register(
ArcElement,
Title,
Tooltip,
Legend,
RadialLinearScale,
PointElement,
LineElement,
Filler,
LinearScale,
BarElement,
CategoryScale,
WordCloudController,
WordElement,
)
interface Props {
problem: string
username: string
}
const props = defineProps<Props>()
const durationOptions: SelectOption[] = [
{ label: "10分钟内", value: "minutes:10" },
{ label: "20分钟内", value: "minutes:20" },
{ label: "30分钟内", value: "minutes:30" },
...DURATION_OPTIONS,
{ label: "全部时段", value: "all" },
]
const query = reactive({
username: props.username,
problem: props.problem,
duration: durationOptions[0].value,
})
interface StatisticsData {
total_count: number
avg_score: number
grade_distribution: Record<string, number>
criteria_averages: Record<string, { avg: number; max: number }>
person_count: number
completed_count: number
word_frequencies: { word: string; count: number }[]
data_unaccepted: { username: string; real_name: string }[]
}
const data = reactive<StatisticsData>({
total_count: 0,
avg_score: 0,
grade_distribution: {},
criteria_averages: {},
person_count: 0,
completed_count: 0,
word_frequencies: [],
data_unaccepted: [],
})
const wordcloudCanvas = useTemplateRef<HTMLCanvasElement>("wordcloudCanvas")
let wordcloudChart: ChartJS | null = null
const HIDE_DURATION = 2 * 60 * 60 * 1000
const STORAGE_KEY = "oj_hidden_students_flowchart"
function loadHidden(): Record<string, number> {
try {
return JSON.parse(localStorage.getItem(STORAGE_KEY) ?? "{}")
} catch {
return {}
}
}
const hiddenStudents = ref<Record<string, number>>(loadHidden())
const hideMode = ref(false)
function saveHidden(d: Record<string, number>) {
localStorage.setItem(STORAGE_KEY, JSON.stringify(d))
}
function hideStudent(username: string) {
hiddenStudents.value = {
...hiddenStudents.value,
[username]: Date.now() + HIDE_DURATION,
}
saveHidden(hiddenStudents.value)
}
function showAll() {
hiddenStudents.value = {}
saveHidden({})
}
const visibleUnaccepted = computed(() => {
const now = Date.now()
return data.data_unaccepted.filter((item) => {
const exp = hiddenStudents.value[item.username]
return !exp || exp <= now
})
})
const hiddenCount = computed(() => {
const now = Date.now()
return data.data_unaccepted.filter((item) => {
const exp = hiddenStudents.value[item.username]
return !!exp && exp > now
}).length
})
const adjustedPersonCount = computed(() =>
Math.max(0, data.person_count - hiddenCount.value),
)
onMounted(() => {
const now = Date.now()
const cleaned = Object.fromEntries(
Object.entries(hiddenStudents.value).filter(([, exp]) => exp > now),
)
hiddenStudents.value = cleaned
saveHidden(cleaned)
})
const completionRate = computed(() => {
if (adjustedPersonCount.value <= 0) return "0%"
const rate = Math.min(
100,
(data.completed_count / adjustedPersonCount.value) * 100,
)
return `${Math.round(rate * 100) / 100}%`
})
const GRADE_COLORS: Record<string, { bg: string; border: string }> = {
S: { bg: "rgba(24, 160, 88, 0.6)", border: "rgba(24, 160, 88, 1)" },
A: { bg: "rgba(32, 128, 240, 0.6)", border: "rgba(32, 128, 240, 1)" },
B: { bg: "rgba(240, 160, 32, 0.6)", border: "rgba(240, 160, 32, 1)" },
C: { bg: "rgba(208, 48, 80, 0.6)", border: "rgba(208, 48, 80, 1)" },
}
const gradeChartData = computed(() => {
const grades = ["S", "A", "B", "C"]
const counts = grades.map((g) => data.grade_distribution[g] || 0)
const labels = grades.map(
(g) => `${g}级 (${data.grade_distribution[g] || 0})`,
)
return {
labels,
datasets: [
{
data: counts,
backgroundColor: grades.map((g) => GRADE_COLORS[g].bg),
borderColor: grades.map((g) => GRADE_COLORS[g].border),
borderWidth: 2,
},
],
}
})
const completionChartData = computed(() => {
const uncompleted = Math.max(
0,
adjustedPersonCount.value - data.completed_count,
)
return {
labels: ["已完成", "未完成"],
datasets: [
{
data: [data.completed_count, uncompleted],
backgroundColor: ["rgba(106, 176, 76, 0.6)", "rgba(255, 159, 64, 0.6)"],
borderColor: ["rgba(106, 176, 76, 1)", "rgba(255, 159, 64, 1)"],
borderWidth: 2,
},
],
}
})
const doughnutOptions = {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: { position: "bottom" as const },
tooltip: {
callbacks: {
label(context: any) {
const label = context.label || ""
const value = context.parsed || 0
const total = context.dataset.data.reduce(
(a: number, b: number) => a + b,
0,
)
const pct = ((value / total) * 100).toFixed(1)
return `${label}: ${value} (${pct}%)`
},
},
},
},
}
const CRITERIA_ORDER = ["逻辑正确性", "完整性", "规范性", "清晰度"]
const hasRadarData = computed(() =>
CRITERIA_ORDER.some((k) => k in data.criteria_averages),
)
const radarChartData = computed(() => {
const labels = CRITERIA_ORDER
const values = CRITERIA_ORDER.map((k) => {
const item = data.criteria_averages[k]
if (!item) return 0
return Math.round((item.avg / item.max) * 100)
})
return {
labels,
datasets: [
{
label: "平均得分率 (%)",
data: values,
backgroundColor: "rgba(32, 128, 240, 0.2)",
borderColor: "rgba(32, 128, 240, 1)",
borderWidth: 2,
pointBackgroundColor: "rgba(32, 128, 240, 1)",
},
],
}
})
const radarOptions = {
responsive: true,
maintainAspectRatio: false,
scales: {
r: {
beginAtZero: true,
max: 100,
ticks: { stepSize: 20 },
},
},
plugins: {
legend: { display: false },
tooltip: {
callbacks: {
label(context: any) {
const key = CRITERIA_ORDER[context.dataIndex]
const item = data.criteria_averages[key]
if (!item) return ""
return `${key}: ${item.avg}/${item.max} (${context.parsed.r}%)`
},
},
},
},
}
const criteriaBarChartData = computed(() => {
const labels = CRITERIA_ORDER.filter((k) => k in data.criteria_averages)
return {
labels,
datasets: [
{
label: "平均得分",
data: labels.map((k) => data.criteria_averages[k]?.avg ?? 0),
backgroundColor: labels.map(
(_, i) => GRADE_COLORS[["S", "A", "B", "C"][i]].bg,
),
borderColor: labels.map(
(_, i) => GRADE_COLORS[["S", "A", "B", "C"][i]].border,
),
borderWidth: 2,
},
],
}
})
const barOptions = {
responsive: true,
aspectRatio: 1,
scales: {
y: { beginAtZero: true },
},
plugins: {
legend: { display: false },
tooltip: {
callbacks: {
label(context: any) {
const key = context.label
const item = data.criteria_averages[key]
if (!item) return ""
return `${item.avg} / ${item.max}`
},
},
},
},
}
const WORD_COLORS = [
"#2080f0",
"#18a058",
"#f0a020",
"#d03050",
"#722ed1",
"#13c2c2",
"#1890ff",
"#52c41a",
"#faad14",
"#f5222d",
]
function renderWordCloud() {
if (!wordcloudCanvas.value || data.word_frequencies.length === 0) return
if (wordcloudChart) {
wordcloudChart.destroy()
wordcloudChart = null
}
const words = data.word_frequencies
const maxCount = Math.max(...words.map((w) => w.count))
wordcloudChart = new ChartJS(wordcloudCanvas.value, {
type: "wordCloud" as any,
data: {
labels: words.map((w) => w.word),
datasets: [
{
label: "",
data: words.map((w) => 10 + (w.count / maxCount) * 50),
color: words.map((_, i) => WORD_COLORS[i % WORD_COLORS.length]),
rotate: 0,
} as any,
],
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: { display: false },
tooltip: {
callbacks: {
label(context: any) {
const word = words[context.dataIndex]
return word ? `${word.word}: ${word.count}` : ""
},
},
},
},
},
})
}
const subOptions = computed<Duration>(() => {
const dur =
durationOptions.find((it) => it.value === query.duration) ??
durationOptions[0]
const x = dur.value!.toString().split(":")
return { [x[0]]: parseInt(x[1]) }
})
async function handleStatistics() {
const current = Date.now()
const end = formatISO(current)
const duration =
query.duration === "all"
? { end }
: { start: formatISO(sub(current, subOptions.value)), end }
const res = await getFlowchartStatistics(
duration,
query.problem,
query.username,
)
Object.assign(data, res.data)
await nextTick()
renderWordCloud()
}
onUnmounted(() => {
if (wordcloudChart) {
wordcloudChart.destroy()
}
})
</script>
<style scoped>
.stat-item {
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
}
.chart-container {
height: 280px;
position: relative;
}
.wordcloud-container {
height: 300px;
position: relative;
}
</style>

View File

@@ -166,7 +166,7 @@ const menus = computed<MenuOption[]>(() => [
label: () => label: () =>
h( h(
RouterLink, RouterLink,
{ to: userStore.isTheAdmin ? "/admin/problem/list" : "/admin" }, { to: userStore.isSuperAdmin ? "/admin" : "/admin/problem/list" },
{ default: () => "后台" }, { default: () => "后台" },
), ),
show: userStore.isAdminRole, show: userStore.isAdminRole,

View File

@@ -17,7 +17,7 @@ const {
loginLoading: isLoading, loginLoading: isLoading,
loginError: msg, loginError: msg,
} = storeToRefs(authStore) } = storeToRefs(authStore)
const loginRef = ref() const loginRef = useTemplateRef("loginRef")
const classUserOptions = ref<SelectOption[]>([]) const classUserOptions = ref<SelectOption[]>([])
const classUserLoading = ref(false) const classUserLoading = ref(false)
const isClassLogin = computed(() => Boolean(form.value.class)) const isClassLogin = computed(() => Boolean(form.value.class))

View File

@@ -7,17 +7,14 @@ interface Props {
page: number page: number
} }
const props = withDefaults(defineProps<Props>(), { const { total, limit: initialLimit = 10, page: initialPage = 1 } = defineProps<Props>()
limit: 10,
page: 1,
})
const emit = defineEmits(["update:limit", "update:page"]) const emit = defineEmits(["update:limit", "update:page"])
const { isDesktop } = useBreakpoints() const { isDesktop } = useBreakpoints()
const limit = ref(props.limit) const limit = ref(initialLimit)
const page = ref(props.page) const page = ref(initialPage)
const sizes = [10, 30, 50] const sizes = [10, 30, 50]
watch(limit, () => emit("update:limit", limit)) watch(limit, () => emit("update:limit", limit))
@@ -26,9 +23,9 @@ watch(page, () => emit("update:page", page))
<template> <template>
<n-pagination <n-pagination
v-if="props.total" v-if="total"
class="right margin" class="right margin"
:item-count="props.total" :item-count="total"
v-model:page="page" v-model:page="page"
v-model:page-size="limit" v-model:page-size="limit"
:page-sizes="sizes" :page-sizes="sizes"

View File

@@ -12,7 +12,7 @@ const {
signupError: msg, signupError: msg,
captchaSrc, captchaSrc,
} = storeToRefs(authStore) } = storeToRefs(authStore)
const signupRef = ref() const signupRef = useTemplateRef("signupRef")
const rules: FormRules = { const rules: FormRules = {
username: [{ required: true, message: "用户名必填", trigger: "blur" }], username: [{ required: true, message: "用户名必填", trigger: "blur" }],

View File

@@ -1,15 +1,15 @@
<template> <template>
<n-flex> <n-flex align="center">
<n-input <n-input
placeholder="用户(可选)" placeholder="用户(可选)"
v-model:value="query.username" v-model:value="query.username"
style="width: 160px" style="width: 150px"
clearable clearable
/> />
<n-input <n-input
placeholder="题号(可选)" placeholder="题号(可选)"
v-model:value="query.problem" v-model:value="query.problem"
style="width: 160px" style="width: 120px"
clearable clearable
/> />
<n-select <n-select
@@ -22,79 +22,132 @@
前往提交列表 前往提交列表
</n-button> </n-button>
</n-flex> </n-flex>
<n-flex style="margin: 20px 0" v-if="count.total > 0">
<n-gradient-text font-size="24" type="primary">
正确提交数{{ count.accepted }}
</n-gradient-text>
<n-gradient-text font-size="24" type="info">
总提交数{{ count.total }}
</n-gradient-text>
<n-gradient-text font-size="24" type="warning">
正确率{{ count.rate }}
</n-gradient-text>
</n-flex>
<n-flex style="margin: 20px 0" v-if="count.total > 0">
<n-gradient-text font-size="24" type="error">
回答正确的人数{{ list.length }}
</n-gradient-text>
<n-gradient-text font-size="24" v-if="person.count > 0" type="warning">
班级人数{{ person.count }}
</n-gradient-text>
<n-gradient-text font-size="24" v-if="person.count > 0" type="success">
班级完成度{{ person.rate }}
</n-gradient-text>
</n-flex>
<n-flex style="margin: 20px 0" v-if="count.total > 0">
<n-button type="warning" @click="toggleUnaccepted(!unaccepted)">
{{ unaccepted ? "隐藏没有完成的" : "显示没有完成的" }}
</n-button>
</n-flex>
<n-flex style="margin-top: 20px">
<n-gradient-text font-size="24" v-if="count.total === 0" type="primary">
暂无数据统计
</n-gradient-text>
</n-flex>
<n-flex style="margin-bottom: 20px" v-if="unaccepted" size="large"> <n-empty
<span style="font-size: 24px"> v-if="count.total === 0"
{{ listUnaccepted.length }} 位没有完成 description="暂无数据"
</span> style="margin: 40px 0"
<span style="font-size: 24px" v-for="name in listUnaccepted" :key="name"> />
{{ name }}
</span>
</n-flex>
<n-tabs animated v-if="count.total > 0"> <template v-if="count.total > 0">
<n-tab-pane name="charts" tab="数据图表"> <n-divider style="margin: 16px 0" />
<n-grid :cols="2" :x-gap="20" :y-gap="20" style="margin-top: 20px"> <n-flex justify="space-around">
<n-gi> <div class="stat-item">
<n-card title="提交正确率"> <n-text>总提交</n-text>
<Doughnut :data="pieChartData" :options="pieChartOptions" /> <n-gradient-text type="info" font-size="28">{{
</n-card> count.total
</n-gi> }}</n-gradient-text>
<n-gi v-if="person.count > 0"> </div>
<n-card title="班级完成度"> <div class="stat-item">
<Doughnut <n-text>正确提交</n-text>
:data="completionChartData" <n-gradient-text type="primary" font-size="28">{{
:options="completionChartOptions" count.accepted
/> }}</n-gradient-text>
</n-card> </div>
</n-gi> <div class="stat-item">
</n-grid> <n-text>正确率</n-text>
</n-tab-pane> <n-gradient-text type="warning" font-size="28">{{
<n-tab-pane name="submissions" tab="提交记录"> count.rate
<n-data-table }}</n-gradient-text>
v-if="list.length" </div>
striped <template v-if="person.count > 0">
:columns="columns" <div class="stat-item">
:data="list" <n-text>完成人数</n-text>
:row-key="rowKey" <n-gradient-text type="error" font-size="28">{{
:expanded-row-keys="expandedRowKeys" list.length
@update:expanded-row-keys="updateExpandedRowKeys" }}</n-gradient-text>
:row-props="rowProps" </div>
/> <div class="stat-item">
</n-tab-pane> <n-text>班级人数</n-text>
</n-tabs> <n-gradient-text type="warning" font-size="28">{{
adjustedPersonCount
}}</n-gradient-text>
</div>
<div class="stat-item">
<n-text>完成度</n-text>
<n-gradient-text type="success" font-size="28">{{
adjustedPersonRate
}}</n-gradient-text>
</div>
</template>
</n-flex>
<n-divider style="margin: 16px 0" />
<n-tabs animated type="line">
<n-tab-pane name="charts" tab="数据图表">
<n-grid :cols="2" :x-gap="20" :y-gap="20" style="margin-top: 12px">
<n-gi>
<n-card title="提交正确率">
<Doughnut :data="pieChartData" :options="pieChartOptions" />
</n-card>
</n-gi>
<n-gi v-if="person.count > 0">
<n-card title="班级完成度">
<Doughnut
:data="completionChartData"
:options="completionChartOptions"
/>
</n-card>
</n-gi>
</n-grid>
</n-tab-pane>
<n-tab-pane name="submissions" tab="提交记录">
<n-data-table
v-if="list.length"
striped
:columns="columns"
:data="list"
:row-key="rowKey"
:expanded-row-keys="expandedRowKeys"
@update:expanded-row-keys="updateExpandedRowKeys"
:row-props="rowProps"
style="margin-top: 12px"
/>
</n-tab-pane>
<n-tab-pane
name="unaccepted"
:tab="`未完成(${visibleUnaccepted.length}`"
>
<n-flex align="center" style="margin: 12px 0">
<n-switch v-model:value="hideMode" size="large">
<template #checked>请假隐藏中</template>
<template #unchecked>请假隐藏</template>
</n-switch>
<n-button
v-if="hiddenCount > 0"
size="small"
type="info"
@click="showAll"
>
恢复 {{ hiddenCount }}
</n-button>
</n-flex>
<n-flex size="large" align="center">
<n-gradient-text
v-if="visibleUnaccepted.length === 0"
font-size="24"
type="success"
>
全都完成了
</n-gradient-text>
<template v-for="item in visibleUnaccepted" :key="item.username">
<n-tag
v-if="hideMode"
closable
size="large"
style="font-size: 20px"
@close="hideStudent(item.username)"
>
{{ item.real_name }}
</n-tag>
<span v-else style="font-size: 24px">{{ item.real_name }}</span>
</template>
</n-flex>
</n-tab-pane>
</n-tabs>
</template>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { h } from "vue" import { h } from "vue"
@@ -121,7 +174,9 @@ const options: SelectOption[] = [
{ label: "10分钟内", value: "minutes:10" }, { label: "10分钟内", value: "minutes:10" },
{ label: "20分钟内", value: "minutes:20" }, { label: "20分钟内", value: "minutes:20" },
{ label: "30分钟内", value: "minutes:30" }, { label: "30分钟内", value: "minutes:30" },
].concat(DURATION_OPTIONS) ...DURATION_OPTIONS,
{ label: "全部时段", value: "all" },
]
function openSubmission(id: string) { function openSubmission(id: string) {
window.open(`/submission/${id}`, "_blank", "noopener") window.open(`/submission/${id}`, "_blank", "noopener")
@@ -186,11 +241,82 @@ interface UserStatistic {
}> }>
} }
interface UnacceptedItem {
username: string
real_name: string
}
const list = ref<UserStatistic[]>([]) const list = ref<UserStatistic[]>([])
const listUnaccepted = ref<string[]>([]) const listUnaccepted = ref<UnacceptedItem[]>([])
const [unaccepted, toggleUnaccepted] = useToggle()
const expandedRowKeys = ref<DataTableRowKey[]>([]) const expandedRowKeys = ref<DataTableRowKey[]>([])
const HIDE_DURATION = 2 * 60 * 60 * 1000
const STORAGE_KEY = "oj_hidden_students"
function loadHidden(): Record<string, number> {
try {
return JSON.parse(localStorage.getItem(STORAGE_KEY) ?? "{}")
} catch {
return {}
}
}
const hiddenStudents = ref<Record<string, number>>(loadHidden())
const hideMode = ref(false)
function saveHidden(data: Record<string, number>) {
localStorage.setItem(STORAGE_KEY, JSON.stringify(data))
}
function hideStudent(username: string) {
hiddenStudents.value = {
...hiddenStudents.value,
[username]: Date.now() + HIDE_DURATION,
}
saveHidden(hiddenStudents.value)
}
function showAll() {
hiddenStudents.value = {}
saveHidden({})
}
const visibleUnaccepted = computed(() => {
const now = Date.now()
return listUnaccepted.value.filter((item) => {
const exp = hiddenStudents.value[item.username]
return !exp || exp <= now
})
})
const hiddenCount = computed(() => {
const now = Date.now()
return listUnaccepted.value.filter((item) => {
const exp = hiddenStudents.value[item.username]
return !!exp && exp > now
}).length
})
const adjustedPersonCount = computed(() => person.count - hiddenCount.value)
const adjustedPersonRate = computed(() => {
if (adjustedPersonCount.value <= 0) return "0%"
const rate = Math.min(
100,
(list.value.length / adjustedPersonCount.value) * 100,
)
return `${Math.round(rate * 100) / 100}%`
})
onMounted(() => {
const now = Date.now()
const cleaned = Object.fromEntries(
Object.entries(hiddenStudents.value).filter(([, exp]) => exp > now),
)
hiddenStudents.value = cleaned
saveHidden(cleaned)
})
// 饼图数据 - 提交正确率分布 // 饼图数据 - 提交正确率分布
const pieChartData = computed(() => { const pieChartData = computed(() => {
const wrongCount = count.total - count.accepted const wrongCount = count.total - count.accepted
@@ -235,7 +361,10 @@ const pieChartOptions = {
// 环形图数据 - 班级完成度 // 环形图数据 - 班级完成度
const completionChartData = computed(() => { const completionChartData = computed(() => {
const completedCount = list.value.length const completedCount = list.value.length
const uncompletedCount = person.count - completedCount const uncompletedCount = Math.max(
0,
adjustedPersonCount.value - completedCount,
)
return { return {
labels: ["已完成", "未完成"], labels: ["已完成", "未完成"],
datasets: [ datasets: [
@@ -294,9 +423,12 @@ function goSubmissions() {
async function handleStatistics() { async function handleStatistics() {
const current = Date.now() const current = Date.now()
const end = formatISO(current) const end = formatISO(current)
const start = formatISO(sub(current, subOptions.value)) const duration =
query.duration === "all"
? { end }
: { start: formatISO(sub(current, subOptions.value)), end }
const res = await getSubmissionStatistics( const res = await getSubmissionStatistics(
{ start, end }, duration,
query.problem, query.problem,
query.username, query.username,
) )
@@ -307,8 +439,6 @@ async function handleStatistics() {
listUnaccepted.value = res.data.data_unaccepted listUnaccepted.value = res.data.data_unaccepted
person.count = res.data.person_count person.count = res.data.person_count
person.rate = res.data.person_rate person.rate = res.data.person_rate
toggleUnaccepted(false)
} }
function rowKey(row: UserStatistic): DataTableRowKey { function rowKey(row: UserStatistic): DataTableRowKey {
@@ -330,4 +460,11 @@ function rowProps(row: UserStatistic) {
} }
} }
</script> </script>
<style scoped></style> <style scoped>
.stat-item {
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
}
</style>

View File

@@ -36,15 +36,15 @@ interface Props {
placeholder?: string placeholder?: string
} }
const props = withDefaults(defineProps<Props>(), { const {
language: "Python3", sync,
fontSize: 20, problem,
height: "100%", language = "Python3",
readonly: false, fontSize = 20,
placeholder: "", height = "100%",
}) readonly = false,
placeholder = "",
const { readonly, placeholder, height, fontSize } = toRefs(props) } = defineProps<Props>()
const code = defineModel<string>("value") const code = defineModel<string>("value")
const emit = defineEmits<{ const emit = defineEmits<{
@@ -57,7 +57,7 @@ const emit = defineEmits<{
const { isDesktop } = useBreakpoints() const { isDesktop } = useBreakpoints()
const langExtension = computed((): Extension => { const langExtension = computed((): Extension => {
return ["Python2", "Python3"].includes(props.language) ? python() : cpp() return ["Python2", "Python3"].includes(language) ? python() : cpp()
}) })
const extensions = computed(() => [ const extensions = computed(() => [
@@ -67,7 +67,7 @@ const extensions = computed(() => [
closeBrackets(), closeBrackets(),
isDark.value ? oneDark : smoothy, isDark.value ? oneDark : smoothy,
autocompletion({ autocompletion({
override: [enhanceCompletion(props.language), completeAnyWord], override: [enhanceCompletion(language), completeAnyWord],
}), }),
getInitialExtension(), getInitialExtension(),
]) ])
@@ -85,12 +85,12 @@ const cleanupSyncResources = () => {
} }
const initSync = async () => { const initSync = async () => {
if (!editorView.value || !props.problem || !isDesktop.value) return if (!editorView.value || !problem || !isDesktop.value) return
cleanupSyncResources() cleanupSyncResources()
cleanupSync = await startSync({ cleanupSync = await startSync({
problemId: props.problem, problemId: problem,
editorView: editorView.value as EditorView, editorView: editorView.value as EditorView,
onStatusChange: (status) => { onStatusChange: (status) => {
// 处理需要断开同步的情况 // 处理需要断开同步的情况
@@ -108,13 +108,13 @@ const initSync = async () => {
const handleEditorReady = (payload: EditorReadyPayload) => { const handleEditorReady = (payload: EditorReadyPayload) => {
editorView.value = payload.view as EditorView editorView.value = payload.view as EditorView
if (props.sync) { if (sync) {
initSync() initSync()
} }
} }
watch( watch(
() => props.sync, () => sync,
(shouldSync) => { (shouldSync) => {
if (shouldSync) { if (shouldSync) {
initSync() initSync()
@@ -125,9 +125,9 @@ watch(
) )
watch( watch(
() => props.problem, () => problem,
(newProblem, oldProblem) => { (newProblem, oldProblem) => {
if (newProblem !== oldProblem && props.sync) { if (newProblem !== oldProblem && sync) {
initSync() initSync()
} }
}, },

View File

@@ -17,10 +17,7 @@ interface Props {
const rawHtml = defineModel<string>("value") const rawHtml = defineModel<string>("value")
type InsertFnType = (url: string, alt: string, href: string) => void type InsertFnType = (url: string, alt: string, href: string) => void
const props = withDefaults(defineProps<Props>(), { const { title, minHeight = 0, simple = false } = defineProps<Props>()
minHeight: 0,
simple: false,
})
const message = useMessage() const message = useMessage()
@@ -112,17 +109,17 @@ async function customUpload(file: File, insertFn: InsertFnType) {
</script> </script>
<template> <template>
<div class="title" v-if="props.title">{{ props.title }}</div> <div class="title" v-if="title">{{ title }}</div>
<div class="editorWrapper"> <div class="editorWrapper">
<Toolbar <Toolbar
class="toolbar" class="toolbar"
:editor="toolbarEditorRef" :editor="toolbarEditorRef"
:defaultConfig="props.simple ? toolbarConfigSimple : toolbarConfig" :defaultConfig="simple ? toolbarConfigSimple : toolbarConfig"
mode="simple" mode="simple"
/> />
<Editor <Editor
@click="onClick" @click="onClick"
:style="{ minHeight: props.minHeight + 'px' }" :style="{ minHeight: minHeight + 'px' }"
v-model="rawHtml" v-model="rawHtml"
:defaultConfig="editorConfig" :defaultConfig="editorConfig"
mode="simple" mode="simple"

View File

@@ -5,6 +5,7 @@ const mermaidThemeVariables = {
primaryTextColor: "#0f172a", primaryTextColor: "#0f172a",
primaryBorderColor: "#0284c7", primaryBorderColor: "#0284c7",
lineColor: "#64748b", lineColor: "#64748b",
arrowheadColor: "#64748b",
secondaryColor: "#f5f3ff", secondaryColor: "#f5f3ff",
tertiaryColor: "#ecfdf5", tertiaryColor: "#ecfdf5",
background: "#ffffff", background: "#ffffff",
@@ -250,12 +251,22 @@ function applyFlowchartDisplayStyle(container: HTMLElement) {
svg.insertBefore(style, svg.firstChild) svg.insertBefore(style, svg.firstChild)
} }
function getChromeVersion(): number {
const match = navigator.userAgent.match(/Chrome\/(\d+)/)
return match ? parseInt(match[1]) : Infinity
}
let mermaidInstance: any = null let mermaidInstance: any = null
let mermaidIsLegacy = false
async function loadMermaid() { async function loadMermaid() {
if (!mermaidInstance) { if (!mermaidInstance) {
const mermaidModule = await import("mermaid") if (getChromeVersion() < 94) {
mermaidInstance = mermaidModule.default mermaidInstance = (await import("mermaid-legacy")).default
mermaidIsLegacy = true
} else {
mermaidInstance = (await import("mermaid")).default
}
mermaidInstance.initialize({ mermaidInstance.initialize({
startOnLoad: false, startOnLoad: false,
securityLevel: "strict", securityLevel: "strict",
@@ -286,7 +297,17 @@ export function useMermaid() {
try { try {
const m = await loadMermaid() const m = await loadMermaid()
const id = `mermaid-${getRandomId()}` const id = `mermaid-${getRandomId()}`
const { svg } = await m.render(id, mermaidCode) // v9 (mermaid-legacy): callback-based render(id, code, cb)
// v10+: Promise-based render(id, code) → { svg }
const svg = mermaidIsLegacy
? await new Promise<string>((resolve, reject) => {
try {
m.render(id, mermaidCode, resolve)
} catch (e) {
reject(e)
}
})
: (await m.render(id, mermaidCode)).svg
if (gen !== renderGeneration) return if (gen !== renderGeneration) return
container.innerHTML = svg container.innerHTML = svg
applyFlowchartDisplayStyle(container) applyFlowchartDisplayStyle(container)

View File

@@ -19,8 +19,8 @@ const options = computed<MenuOption[]>(() => {
}, },
] ]
// admin 可以访问的功能 // Student Admin: only problems
if (userStore.isTheAdmin) { if (userStore.isStudentAdmin) {
baseOptions.push({ baseOptions.push({
label: () => label: () =>
h(RouterLink, { to: "/admin/problem/list" }, { default: () => "题目" }), h(RouterLink, { to: "/admin/problem/list" }, { default: () => "题目" }),
@@ -28,7 +28,49 @@ const options = computed<MenuOption[]>(() => {
}) })
} }
// super_admin 可以访问的功能 // Teacher Admin: problems + contests + problemsets
if (userStore.isTeacherAdmin) {
baseOptions.push(
{
label: () =>
h(
RouterLink,
{ to: "/admin/problem/list" },
{ default: () => "题目" },
),
key: "admin problem list",
},
{
label: () =>
h(
RouterLink,
{ to: "/admin/contest/list" },
{ default: () => "比赛" },
),
key: "admin contest list",
},
{
label: () =>
h(
RouterLink,
{ to: "/admin/problemset/list" },
{ default: () => "题单" },
),
key: "admin problemset list",
},
{
label: () =>
h(
RouterLink,
{ to: "/admin/ai/reports" },
{ default: () => "AI报告" },
),
key: "admin ai reports",
},
)
}
// Super Admin: everything
if (userStore.isSuperAdmin) { if (userStore.isSuperAdmin) {
baseOptions.push( baseOptions.push(
{ {
@@ -99,6 +141,15 @@ const options = computed<MenuOption[]>(() => {
), ),
key: "admin tutorial list", key: "admin tutorial list",
}, },
{
label: () =>
h(
RouterLink,
{ to: "/admin/ai/reports" },
{ default: () => "AI报告" },
),
key: "admin ai reports",
},
) )
} }
@@ -119,6 +170,7 @@ const active = computed(() => {
if (path.startsWith("/admin/comment")) return "admin comment list" if (path.startsWith("/admin/comment")) return "admin comment list"
if (path.startsWith("/admin/announcement")) return "admin announcement list" if (path.startsWith("/admin/announcement")) return "admin announcement list"
if (path.startsWith("/admin/tutorial")) return "admin tutorial list" if (path.startsWith("/admin/tutorial")) return "admin tutorial list"
if (path.startsWith("/admin/ai")) return "admin ai reports"
return route.name as string return route.name as string
}) })

View File

@@ -0,0 +1,18 @@
import { defineStore } from "pinia"
export const useMyFlowchartStore = defineStore("myFlowchart", () => {
const showing = ref(false)
const mermaidCode = ref("")
function show(code: string) {
mermaidCode.value = code
showing.value = true
}
function hide() {
showing.value = false
mermaidCode.value = ""
}
return { showing, mermaidCode, show, hide }
})

View File

@@ -13,10 +13,21 @@ export const useUserStore = defineStore("user", () => {
const isAuthed = computed(() => !!user.value?.email) const isAuthed = computed(() => !!user.value?.email)
const isAdminRole = computed( const isAdminRole = computed(
() => () =>
user.value?.admin_type === USER_TYPE.ADMIN || user.value?.admin_type === USER_TYPE.STUDENT_ADMIN ||
user.value?.admin_type === USER_TYPE.TEACHER_ADMIN ||
user.value?.admin_type === USER_TYPE.SUPER_ADMIN,
)
const isStudentAdmin = computed(
() => user.value?.admin_type === USER_TYPE.STUDENT_ADMIN,
)
const isTeacherAdmin = computed(
() => user.value?.admin_type === USER_TYPE.TEACHER_ADMIN,
)
const isTeacherOrAbove = computed(
() =>
user.value?.admin_type === USER_TYPE.TEACHER_ADMIN ||
user.value?.admin_type === USER_TYPE.SUPER_ADMIN, user.value?.admin_type === USER_TYPE.SUPER_ADMIN,
) )
const isTheAdmin = computed(() => user.value?.admin_type === USER_TYPE.ADMIN)
const isSuperAdmin = computed( const isSuperAdmin = computed(
() => user.value?.admin_type === USER_TYPE.SUPER_ADMIN, () => user.value?.admin_type === USER_TYPE.SUPER_ADMIN,
) )
@@ -47,7 +58,9 @@ export const useUserStore = defineStore("user", () => {
isFinished, isFinished,
user, user,
isAdminRole, isAdminRole,
isTheAdmin, isStudentAdmin,
isTeacherAdmin,
isTeacherOrAbove,
isSuperAdmin, isSuperAdmin,
hasProblemPermission, hasProblemPermission,
isAuthed, isAuthed,

View File

@@ -1,4 +1,4 @@
import { SUBMISSION_RESULT } from "./types" import type { SUBMISSION_RESULT } from "./types"
export enum SubmissionStatus { export enum SubmissionStatus {
compile_error = -2, compile_error = -2,
@@ -12,6 +12,7 @@ export enum SubmissionStatus {
judging = 7, judging = 7,
partial_accepted = 8, partial_accepted = 8,
submitting = 9, submitting = 9,
ast_check_failed = 10,
} }
export enum ContestStatus { export enum ContestStatus {
@@ -29,57 +30,75 @@ export enum ContestType {
export const JUDGE_STATUS: { export const JUDGE_STATUS: {
[key in SUBMISSION_RESULT]: { [key in SUBMISSION_RESULT]: {
name: string name: string
title: string
type: "error" | "success" | "warning" | "info" type: "error" | "success" | "warning" | "info"
} }
} = { } = {
"-2": { "-2": {
name: "编译失败", name: "编译失败",
title: "编译失败",
type: "warning", type: "warning",
}, },
"-1": { "-1": {
name: "答案错误", name: "答案错误",
title: "答案错误",
type: "error", type: "error",
}, },
"0": { "0": {
name: "答案正确", name: "答案正确",
title: "答案正确",
type: "success", type: "success",
}, },
"1": { "1": {
name: "运行超时", name: "运行超时",
title: "运行超时",
type: "error", type: "error",
}, },
"2": { "2": {
name: "运行超时", name: "运行超时",
title: "运行超时",
type: "error", type: "error",
}, },
"3": { "3": {
name: "内存超限", name: "内存超限",
title: "内存超限",
type: "error", type: "error",
}, },
"4": { "4": {
name: "运行时错误", name: "运行时错误",
title: "运行时错误",
type: "warning", type: "warning",
}, },
"5": { "5": {
name: "系统错误", name: "系统错误",
title: "系统错误",
type: "error", type: "error",
}, },
"6": { "6": {
name: "等待评分", name: "等待评分",
title: "等待评分",
type: "warning", type: "warning",
}, },
"7": { "7": {
name: "正在评分", name: "正在评分",
title: "正在评分",
type: "warning", type: "warning",
}, },
"8": { "8": {
name: "部分正确", name: "部分正确",
title: "部分正确",
type: "warning", type: "warning",
}, },
"9": { "9": {
name: "正在提交", name: "正在提交",
title: "正在提交",
type: "info", type: "info",
}, },
"10": {
name: "语法未通过",
title: "答案正确,但语法未通过",
type: "success",
},
} }
export const CONTEST_STATUS: { export const CONTEST_STATUS: {
@@ -114,7 +133,8 @@ export const CONTEST_TYPE = {
export const USER_TYPE = { export const USER_TYPE = {
REGULAR_USER: "Regular User", REGULAR_USER: "Regular User",
ADMIN: "Admin", STUDENT_ADMIN: "Student Admin",
TEACHER_ADMIN: "Teacher Admin",
SUPER_ADMIN: "Super Admin", SUPER_ADMIN: "Super Admin",
} }

View File

@@ -102,11 +102,11 @@ export function secondsToDuration(seconds: number): string {
start: 0, start: 0,
end: seconds * 1000, end: seconds * 1000,
}) })
return [ const hours = (duration.days ?? 0) * 24 + (duration.hours ?? 0)
duration.hours ?? 0, const pad = (n: number) => String(n).padStart(2, "0")
duration.minutes ?? 0, return [hours, pad(duration.minutes ?? 0), pad(duration.seconds ?? 0)].join(
duration.seconds ?? 0, ":",
].join(":") )
} }
export function submissionMemoryFormat(memory: number | string | undefined) { export function submissionMemoryFormat(memory: number | string | undefined) {
@@ -133,15 +133,22 @@ export function debounce<T extends (...args: any[]) => any>(
} }
export function getUserRole(role: User["admin_type"]): { export function getUserRole(role: User["admin_type"]): {
type: "default" | "info" | "error" type: "default" | "info" | "warning" | "error"
label: "普通" | "管理员" | "超管" label: "普通" | "学生管理员" | "教师管理员" | "超管"
} { } {
const roleMap = { const roleMap = {
[USER_TYPE.REGULAR_USER]: { [USER_TYPE.REGULAR_USER]: {
type: "default" as const, type: "default" as const,
label: "普通" as const, label: "普通" as const,
}, },
[USER_TYPE.ADMIN]: { type: "info" as const, label: "管理员" as const }, [USER_TYPE.STUDENT_ADMIN]: {
type: "info" as const,
label: "学生管理员" as const,
},
[USER_TYPE.TEACHER_ADMIN]: {
type: "warning" as const,
label: "教师管理员" as const,
},
[USER_TYPE.SUPER_ADMIN]: { [USER_TYPE.SUPER_ADMIN]: {
type: "error" as const, type: "error" as const,
label: "超管" as const, label: "超管" as const,

View File

@@ -1,18 +1,68 @@
import axios from "axios" import axios, { type AxiosRequestConfig } from "axios"
import { createDiscreteApi } from "naive-ui"
import { useAuthModalStore } from "shared/store/authModal"
import storage from "./storage" import storage from "./storage"
import { STORAGE_KEY } from "./constants" import { STORAGE_KEY } from "./constants"
const http = axios.create({ const { message } = createDiscreteApi(["message"])
// 后端统一返回 { error, data } 信封;拦截器剥掉 axios 外层后,
// 调用方拿到的就是这个信封data 才是真正的业务数据。
export interface ApiResponse<T = any> {
error: string | null
data: T
}
// 让 http.get<T>() 的类型真实反映"解包后返回信封"这件事,
// 调用方 res.data 直接拿到带类型的 T不再依赖 axios 的 AxiosResponse 巧合对齐。
interface Http {
get<T = any>(
url: string,
config?: AxiosRequestConfig,
): Promise<ApiResponse<T>>
delete<T = any>(
url: string,
config?: AxiosRequestConfig,
): Promise<ApiResponse<T>>
post<T = any>(
url: string,
data?: unknown,
config?: AxiosRequestConfig,
): Promise<ApiResponse<T>>
put<T = any>(
url: string,
data?: unknown,
config?: AxiosRequestConfig,
): Promise<ApiResponse<T>>
}
const instance = axios.create({
baseURL: "/api", baseURL: "/api",
xsrfHeaderName: "X-CSRFToken", xsrfHeaderName: "X-CSRFToken",
xsrfCookieName: "csrftoken", xsrfCookieName: "csrftoken",
}) })
http.interceptors.response.use( // 统一剥掉空字符串 / null / undefined 的 query 参数,
// 各 api 函数不必再手写过滤逻辑(保留 0、false
instance.interceptors.request.use((config) => {
if (config.params) {
config.params = Object.fromEntries(
Object.entries(config.params).filter(
([, v]) => v !== "" && v !== null && v !== undefined,
),
)
}
return config
})
instance.interceptors.response.use(
(res) => { (res) => {
if (res.data.error) { if (res.data.error) {
if (res.data.data && res.data.data.startsWith("Please login")) { if (res.data.error === "login-required") {
storage.remove(STORAGE_KEY.AUTHED) storage.remove(STORAGE_KEY.AUTHED)
useAuthModalStore().openLoginModal()
} else if (res.data.error === "permission-denied") {
message.error(res.data.data || "权限不足")
} }
return Promise.reject(res.data) return Promise.reject(res.data)
} else { } else {
@@ -24,4 +74,6 @@ http.interceptors.response.use(
}, },
) )
const http = instance as unknown as Http
export default http export default http

View File

@@ -1,19 +1,15 @@
import { useUserStore } from "shared/store/user" import { useUserStore } from "shared/store/user"
/**
* 权限检查工具函数
*/
export function usePermissions() { export function usePermissions() {
const userStore = useUserStore() const userStore = useUserStore()
return { return {
// 基本权限检查
isAuthenticated: computed(() => userStore.isAuthed), isAuthenticated: computed(() => userStore.isAuthed),
isAdminRole: computed(() => userStore.isAdminRole), isAdminRole: computed(() => userStore.isAdminRole),
isTeacherOrAbove: computed(() => userStore.isTeacherOrAbove),
isSuperAdmin: computed(() => userStore.isSuperAdmin), isSuperAdmin: computed(() => userStore.isSuperAdmin),
hasProblemPermission: computed(() => userStore.hasProblemPermission), hasProblemPermission: computed(() => userStore.hasProblemPermission),
// 功能权限检查
canManageUsers: computed(() => userStore.isSuperAdmin), canManageUsers: computed(() => userStore.isSuperAdmin),
canManageAnnouncements: computed(() => userStore.isSuperAdmin), canManageAnnouncements: computed(() => userStore.isSuperAdmin),
canManageComments: computed(() => userStore.isSuperAdmin), canManageComments: computed(() => userStore.isSuperAdmin),
@@ -22,9 +18,10 @@ export function usePermissions() {
canSendMessages: computed(() => userStore.isSuperAdmin), canSendMessages: computed(() => userStore.isSuperAdmin),
canManageProblems: computed(() => userStore.hasProblemPermission), canManageProblems: computed(() => userStore.hasProblemPermission),
canManageContests: computed(() => userStore.isSuperAdmin), canManageContests: computed(() => userStore.isTeacherOrAbove),
canManageProblemsets: computed(() => userStore.isTeacherOrAbove),
canViewClassroomData: computed(() => userStore.isTeacherOrAbove),
// 题目权限细分检查
canManageAllProblems: computed( canManageAllProblems: computed(
() => () =>
userStore.user?.problem_permission === "All" || userStore.isSuperAdmin, userStore.user?.problem_permission === "All" || userStore.isSuperAdmin,
@@ -34,17 +31,15 @@ export function usePermissions() {
userStore.user?.problem_permission === "Own" && !userStore.isSuperAdmin, userStore.user?.problem_permission === "Own" && !userStore.isSuperAdmin,
), ),
// 获取用户权限级别描述
getUserPermissionLevel: computed(() => { getUserPermissionLevel: computed(() => {
if (userStore.isSuperAdmin) return "超级管理员" if (userStore.isSuperAdmin) return "超级管理员"
if (userStore.isAdminRole) return "管理员" if (userStore.isTeacherAdmin) return "教师管理员"
if (userStore.isStudentAdmin) return "学生管理员"
return "普通用户" return "普通用户"
}), }),
// 获取题目权限描述
getProblemPermissionLevel: computed(() => { getProblemPermissionLevel: computed(() => {
if (!userStore.user) return "无权限" if (!userStore.user) return "无权限"
switch (userStore.user.problem_permission) { switch (userStore.user.problem_permission) {
case "All": case "All":
return "管理所有题目" return "管理所有题目"
@@ -59,13 +54,9 @@ export function usePermissions() {
} }
} }
/**
* 路由权限检查
*/
export function checkRoutePermission(routeName: string): boolean { export function checkRoutePermission(routeName: string): boolean {
const userStore = useUserStore() const userStore = useUserStore()
// 需要super admin权限的路由
const superAdminRoutes = [ const superAdminRoutes = [
"admin home", "admin home",
"admin config", "admin config",
@@ -79,35 +70,41 @@ export function checkRoutePermission(routeName: string): boolean {
"admin tutorial list", "admin tutorial list",
"admin tutorial create", "admin tutorial create",
"admin tutorial edit", "admin tutorial edit",
]
const teacherAdminRoutes = [
"admin contest list", "admin contest list",
"admin contest create", "admin contest create",
"admin contest edit", "admin contest edit",
"admin contest problem list", "admin contest problem list",
"admin contest problem create", "admin contest problem create",
"admin contest problem edit", "admin contest problem edit",
"admin contest helper",
"admin problemset list",
"admin problemset create",
"admin problemset edit",
"admin problemset detail",
"admin stuck problems",
"admin top ac trend",
] ]
// 需要题目权限的路由
const problemPermissionRoutes = [ const problemPermissionRoutes = [
"admin problem list", "admin problem list",
"admin problem create", "admin problem create",
"admin problem edit", "admin problem edit",
] ]
// 需要基本admin权限的路由
const adminRoutes: string[] = ["admin problem list"]
if (superAdminRoutes.includes(routeName)) { if (superAdminRoutes.includes(routeName)) {
return userStore.isSuperAdmin return userStore.isSuperAdmin
} }
if (teacherAdminRoutes.includes(routeName)) {
return userStore.isTeacherOrAbove
}
if (problemPermissionRoutes.includes(routeName)) { if (problemPermissionRoutes.includes(routeName)) {
return userStore.hasProblemPermission return userStore.hasProblemPermission
} }
if (adminRoutes.includes(routeName)) {
return userStore.isAdminRole
}
return true return true
} }

View File

@@ -33,7 +33,11 @@ export interface Profile {
submission_number: number submission_number: number
} }
export type UserAdminType = "Regular User" | "Admin" | "Super Admin" export type UserAdminType =
| "Regular User"
| "Student Admin"
| "Teacher Admin"
| "Super Admin"
export interface User { export interface User {
id: number id: number
@@ -65,7 +69,20 @@ export type LANGUAGE =
export type LANGUAGE_SHOW_LABEL = export type LANGUAGE_SHOW_LABEL =
(typeof LANGUAGE_SHOW_VALUE)[keyof typeof LANGUAGE_SHOW_VALUE] (typeof LANGUAGE_SHOW_VALUE)[keyof typeof LANGUAGE_SHOW_VALUE]
export type SUBMISSION_RESULT = -2 | -1 | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 export type SUBMISSION_RESULT =
| -2
| -1
| 0
| 1
| 2
| 3
| 4
| 5
| 6
| 7
| 8
| 9
| 10
export type ProblemStatus = "passed" | "failed" | "not_test" export type ProblemStatus = "passed" | "failed" | "not_test"
@@ -137,6 +154,16 @@ export interface Problem {
flowchart_data?: Record<string, any> flowchart_data?: Record<string, any>
flowchart_hint?: string flowchart_hint?: string
show_flowchart?: boolean show_flowchart?: boolean
ast_rules?: {
[key: string]: {
engine: string
target?: string
min?: number
max?: number
message: string
}[]
} | null
has_ast_rules?: boolean
} }
export type AdminProblem = Problem & AlterProblem export type AdminProblem = Problem & AlterProblem
@@ -174,6 +201,7 @@ export interface ProblemFiltered {
author: string author: string
allow_flowchart: boolean allow_flowchart: boolean
show_flowchart: boolean show_flowchart: boolean
has_ast_rules: boolean
} }
export interface AdminProblemFiltered { export interface AdminProblemFiltered {
@@ -183,6 +211,11 @@ export interface AdminProblemFiltered {
visible: boolean visible: boolean
username: string username: string
create_time: string create_time: string
difficulty: "Low" | "Mid" | "High"
tags: string[]
has_ast_rules: boolean
allow_flowchart: boolean
show_flowchart: boolean
} }
// 题单相关类型 // 题单相关类型
@@ -387,6 +420,7 @@ export interface Submission {
err_info?: string err_info?: string
time_cost?: number time_cost?: number
memory_cost?: number memory_cost?: number
ast_results?: Array<{ description: string; passed: boolean }>
} }
ip: string ip: string
contest: number contest: number
@@ -473,9 +507,7 @@ export interface BlankContest {
tag: string tag: string
start_time: string start_time: string
end_time: string end_time: string
rule_type: "ACM" | "OI"
password: string password: string
real_time_rank: boolean
visible: boolean visible: boolean
allowed_ip_ranges: { value: string }[] allowed_ip_ranges: { value: string }[]
} }

View File

@@ -0,0 +1,51 @@
import assert from "node:assert/strict"
import test from "node:test"
import { getSubmitButtonState } from "../src/oj/problem/components/submitButtonState.ts"
const idleInput = {
isAuthed: true,
hasCode: true,
isFormatting: false,
isSubmitting: false,
isJudging: false,
isCooldown: false,
}
test("shows a disabled loading state while formatting", () => {
assert.deepEqual(getSubmitButtonState({ ...idleInput, isFormatting: true }), {
disabled: true,
label: "格式化中",
icon: "eos-icons:loading",
})
})
test("shows submitting immediately after formatting", () => {
assert.deepEqual(getSubmitButtonState({ ...idleInput, isSubmitting: true }), {
disabled: true,
label: "正在提交",
icon: "eos-icons:loading",
})
})
test("preserves existing login, judging, cooldown, and idle states", () => {
assert.deepEqual(getSubmitButtonState({ ...idleInput, isAuthed: false }), {
disabled: true,
label: "请先登录",
icon: "ph:play-fill",
})
assert.deepEqual(getSubmitButtonState({ ...idleInput, isJudging: true }), {
disabled: true,
label: "正在评分",
icon: "eos-icons:loading",
})
assert.deepEqual(getSubmitButtonState({ ...idleInput, isCooldown: true }), {
disabled: true,
label: "正在冷却",
icon: "ph:lightbulb-fill",
})
assert.deepEqual(getSubmitButtonState(idleInput), {
disabled: false,
label: "提交代码",
icon: "ph:play-fill",
})
})