Compare commits

..

58 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
65 changed files with 3261 additions and 1163 deletions

2
.env
View File

@@ -4,4 +4,4 @@ PUBLIC_OJ_URL=http://localhost:8000
PUBLIC_CODE_URL=http://localhost:3000
PUBLIC_JUDGE0_URL=https://judge0api.xuyue.cc
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.

44
package-lock.json generated
View File

@@ -24,6 +24,7 @@
"axios": "^1.16.1",
"canvas-confetti": "^1.9.4",
"chart.js": "^4.5.1",
"chartjs-chart-wordcloud": "^4.4.5",
"client-zip": "^2.5.0",
"codemirror": "^6.0.2",
"copy-text-to-clipboard": "^3.2.2",
@@ -1283,6 +1284,21 @@
"integrity": "sha512-LFYWWd8nwfwEmTZG9PfQxd17HbNPksHBiJHaKuY1XeqscXacsS2tyoo6OdRsjf+NQYeB6XrNL3a25E3gH69lcg==",
"license": "MIT"
},
"node_modules/@types/d3-cloud": {
"version": "1.2.9",
"resolved": "https://registry.npmjs.org/@types/d3-cloud/-/d3-cloud-1.2.9.tgz",
"integrity": "sha512-5EWJvnlCrqTThGp8lYHx+DL00sOjx2HTlXH1WRe93k5pfOIhPQaL63NttaKYIbT7bTXp/USiunjNS/N4ipttIQ==",
"license": "MIT",
"dependencies": {
"@types/d3": "^3"
}
},
"node_modules/@types/d3-cloud/node_modules/@types/d3": {
"version": "3.5.53",
"resolved": "https://registry.npmjs.org/@types/d3/-/d3-3.5.53.tgz",
"integrity": "sha512-8yKQA9cAS6+wGsJpBysmnhlaaxlN42Qizqkw+h2nILSlS+MAG2z4JdO6p+PJrJ+ACvimkmLJL281h157e52psQ==",
"license": "MIT"
},
"node_modules/@types/d3-color": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz",
@@ -2457,6 +2473,19 @@
"pnpm": ">=8"
}
},
"node_modules/chartjs-chart-wordcloud": {
"version": "4.4.5",
"resolved": "https://registry.npmjs.org/chartjs-chart-wordcloud/-/chartjs-chart-wordcloud-4.4.5.tgz",
"integrity": "sha512-x7gdE5BZyj31+bjHbZ/0tX4hB6un7TQhvwdO5qHhqpmJCS6bONHjMuDzSjL/Qw2uI6CT2/U04LGqBREHwBiK3g==",
"license": "MIT",
"dependencies": {
"@types/d3-cloud": "^1.2.9",
"d3-cloud": "^1.2.7"
},
"peerDependencies": {
"chart.js": "^4.1.0"
}
},
"node_modules/chokidar": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz",
@@ -2779,6 +2808,21 @@
"node": ">=12"
}
},
"node_modules/d3-cloud": {
"version": "1.2.9",
"resolved": "https://registry.npmjs.org/d3-cloud/-/d3-cloud-1.2.9.tgz",
"integrity": "sha512-leL1GLneC9ZQtnV+6TGWrNlGfI1WX7S2arcTv2vae12DaXo5wjm6GBCkskXbrDlyOymd/A75Pyj1H37MW4BZ/Q==",
"license": "BSD-3-Clause",
"dependencies": {
"d3-dispatch": "^1.0.3"
}
},
"node_modules/d3-cloud/node_modules/d3-dispatch": {
"version": "1.0.6",
"resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-1.0.6.tgz",
"integrity": "sha512-fVjoElzjhCEy+Hbn8KygnmMS7Or0a9sI2UzGwoB7cCtvI1XpVN9GpoYlnb3xt2YV66oXYb1fLJ8GMvP4hdU1RA==",
"license": "BSD-3-Clause"
},
"node_modules/d3-collection": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/d3-collection/-/d3-collection-1.0.7.tgz",

View File

@@ -26,6 +26,7 @@
"axios": "^1.16.1",
"canvas-confetti": "^1.9.4",
"chart.js": "^4.5.1",
"chartjs-chart-wordcloud": "^4.4.5",
"client-zip": "^2.5.0",
"codemirror": "^6.0.2",
"copy-text-to-clipboard": "^3.2.2",

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,
},
output: {
polyfill: "usage",
},
performance: {
chunkSplit: {
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,4 +1,5 @@
import http from "utils/http"
import { toProblemListItem } from "admin/transforms"
import type {
AdminProblem,
Announcement,
@@ -30,27 +31,21 @@ export async function getProblemList(
contestID?: string,
) {
const endpoint = !!contestID ? "admin/contest/problem" : "admin/problem"
const res = await http.get(endpoint, {
params: {
paging: true,
offset,
limit,
keyword,
author,
contest_id: contestID,
const res = await http.get<{ results: AdminProblem[]; total: number }>(
endpoint,
{
params: {
paging: true,
offset,
limit,
keyword,
author,
contest_id: contestID,
},
},
})
)
return {
results: res.data.results.map((result: AdminProblem) => ({
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,
})),
results: res.data.results.map(toProblemListItem),
total: res.data.total,
}
}
@@ -130,10 +125,10 @@ export function getContestList(offset = 0, limit = 10, keyword: string) {
export async function uploadImage(file: File): Promise<string> {
const form = new window.FormData()
form.append("image", file)
const res: { success: boolean; file_path: string; msg: "Success" } =
await http.post("admin/upload_image", form, {
headers: { "content-type": "multipart/form-data" },
})
// 该端点不走 { error, data } 信封,直接返回上传结果
const res = (await http.post("admin/upload_image", form, {
headers: { "content-type": "multipart/form-data" },
})) as unknown as { success: boolean; file_path: string; msg: "Success" }
return res.success ? res.file_path : ""
}
@@ -241,17 +236,17 @@ export function deleteComment(id: number) {
}
export async function getTutorialList() {
const res = await http.get("admin/tutorial")
const res = await http.get<Tutorial[]>("admin/tutorial")
return res.data
}
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
}
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
}
@@ -269,10 +264,10 @@ export function setTutorialVisibility(id: number, is_public: boolean) {
}
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 },
})
return res.data as Exercise[]
return res.data
}
export async function createExercise(data: {
@@ -281,8 +276,8 @@ export async function createExercise(data: {
data: object
order: number
}) {
const res = await http.post("admin/exercise", data)
return res.data as Exercise
const res = await http.post<Exercise>("admin/exercise", data)
return res.data
}
export async function updateExercise(data: {
@@ -487,3 +482,22 @@ export function getTopACTrend(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

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

View File

@@ -4,6 +4,8 @@ import type { LANGUAGE } from "utils/types"
interface AstRule {
engine: string
target?: string
label?: string
exact?: number
min?: number
max?: number
message: string
@@ -22,23 +24,41 @@ const emit = defineEmits<{
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" },
]},
{
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[] = [
@@ -81,20 +101,72 @@ const OPERATOR_TARGET_OPTIONS: SelectOption[] = [
]
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 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) }
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)
}
function needsTargetDropdown(engine: string) { return isNodeEngine(engine) }
function needsTargetInput(engine: string) { return isFunctionEngine(engine) || isMethodEngine(engine) }
function needsOperatorDropdown(engine: string) { return isOperatorEngine(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 []
@@ -111,9 +183,23 @@ function updateRules(lang: string, rules: AstRule[]) {
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", message: "" })
rules.push({
engine: "must_exist_node",
target: "for_loop",
label: "for 循环",
message: "",
})
updateRules(lang, rules)
}
@@ -129,13 +215,24 @@ function updateRule(lang: string, index: number, field: string, value: any) {
if (field === "engine") {
rule.engine = value
if (isNodeEngine(value)) rule.target = "for_loop"
else if (isOperatorEngine(value)) rule.target = "+"
else rule.target = ""
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
@@ -150,25 +247,39 @@ function updateRule(lang: string, index: number, field: string, value: any) {
updateRules(lang, rules)
}
watch(() => props.languages, (langs) => {
if (langs.length && !langs.includes(activeTab.value as LANGUAGE)) {
activeTab.value = langs[0]
}
})
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-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">
<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)"
@update:value="
(v: string) => updateRule(lang, index, 'engine', v)
"
style="width: 150px"
size="small"
/>
@@ -176,7 +287,9 @@ watch(() => props.languages, (langs) => {
v-if="needsTargetDropdown(rule.engine)"
:options="NODE_TARGET_OPTIONS"
:value="rule.target"
@update:value="(v: string) => updateRule(lang, index, 'target', v)"
@update:value="
(v: string) => updateRule(lang, index, 'target', v)
"
style="width: 150px"
size="small"
filterable
@@ -184,7 +297,9 @@ watch(() => props.languages, (langs) => {
<n-input
v-if="needsTargetInput(rule.engine)"
:value="rule.target"
@update:value="(v: string) => updateRule(lang, index, 'target', v)"
@update:value="
(v: string) => updateRule(lang, index, 'target', v)
"
placeholder="函数/方法名"
style="width: 150px"
size="small"
@@ -193,43 +308,84 @@ watch(() => props.languages, (langs) => {
v-if="needsOperatorDropdown(rule.engine)"
:options="OPERATOR_TARGET_OPTIONS"
:value="rule.target"
@update:value="(v: string) => updateRule(lang, index, 'target', v)"
@update:value="
(v: string) => updateRule(lang, index, 'target', v)
"
style="width: 150px"
size="small"
/>
<n-input-number
v-if="isCountEngine(rule.engine)"
:value="rule.min ?? null"
@update:value="(v: number | null) => updateRule(lang, index, 'min', v)"
placeholder="最少"
style="width: 150px"
size="small"
:min="0"
clearable
/>
<n-input-number
v-if="isCountEngine(rule.engine)"
:value="rule.max ?? null"
@update:value="(v: number | null) => updateRule(lang, index, 'max', v)"
placeholder="最多"
style="width: 150px"
size="small"
:min="0"
clearable
/>
<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)"
@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
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
size="small"
tertiary
type="primary"
@click="addRule(lang)"
>
添加规则
</n-button>
</n-flex>

View File

@@ -93,10 +93,41 @@ const problem = useLocalStorage<BlankProblem>(STORAGE_KEY.ADMIN_PROBLEM, {
// 从服务器来的tag列表
const tagList = shallowRef<Tag[]>([])
const tagListLoaded = ref(false)
const selectedTags = ref<string[]>([])
const newTags = ref<string[]>([])
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) {
const set = new Set(selectedTags.value)
@@ -144,6 +175,7 @@ const languageOptions = [
async function getProblemDetail() {
if (!props.problemID) {
syncTagInputsFromProblemTags()
toggleReady(true)
return
}
@@ -161,7 +193,7 @@ async function getProblemDetail() {
problem.value.difficulty = data.difficulty
problem.value.visible = data.visible
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.template = data.template
problem.value.samples = data.samples
@@ -201,8 +233,7 @@ async function getProblemDetail() {
}
})
// 标签
selectedTags.value = data.tags
newTags.value = []
syncTagInputsFromProblemTags(problem.value.tags)
toggleReady(true)
} catch (error) {
message.error("获取题目失败")
@@ -213,6 +244,8 @@ async function getProblemDetail() {
async function getTagList() {
const res = await getProblemTagList()
tagList.value = res.data
tagListLoaded.value = true
syncTagInputsFromProblemTags()
}
function addSample() {
@@ -352,6 +385,7 @@ async function submit() {
filterHint()
getTemplate()
filterAnswers()
syncProblemTags()
const api = {
"admin problem create": createProblem,
"admin problem edit": editProblem,
@@ -436,7 +470,8 @@ onMounted(() => {
})
watch([selectedTags, newTags], ([sel, newT]) => {
problem.value.tags = [...sel, ...newT]
if (syncingTagInputs) return
problem.value.tags = unique([...sel, ...newT])
})
watch(
() => problem.value.languages,

View File

@@ -1,5 +1,6 @@
<script setup lang="ts">
import { NFlex, NSwitch, NTag } from "naive-ui"
import { Icon } from "@iconify/vue"
import Pagination from "shared/components/Pagination.vue"
import { usePagination } from "shared/composables/pagination"
import { getTagColor, parseTime } from "utils/functions"
@@ -36,7 +37,8 @@ const total = ref(0)
const problems = ref<AdminProblemFiltered[]>([])
const nextDisplayID = computed(() => {
if (!isContestProblemList.value || problems.value.length === 0) return ""
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)
@@ -79,6 +81,34 @@ const columns: DataTableColumn<AdminProblemFiltered>[] = [
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: "创建时间",
@@ -101,7 +131,7 @@ const columns: DataTableColumn<AdminProblemFiltered>[] = [
{
title: "选项",
key: "actions",
width: 300,
width: 320,
render: (row) =>
h(Actions, {
problemID: row.id,

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

@@ -24,7 +24,13 @@ const isNotRegularUser = computed(
>
{{ getUserRole(props.user.admin_type).label }}
</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
? "全部"

View File

@@ -38,7 +38,8 @@ const userEditing = ref<User | null>(null)
const adminOptions = [
{ 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 },
]
@@ -106,7 +107,8 @@ const columns: DataTableColumn<User>[] = [
const options: SelectOption[] = [
{ 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 },
]
@@ -166,7 +168,7 @@ function createNewUser() {
username: "",
real_name: "",
email: "",
admin_type: "Admin",
admin_type: "Student Admin",
problem_permission: "None",
create_time: 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-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"
label="出题权限"
>

View File

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

View File

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

View File

@@ -60,7 +60,7 @@ import { useAIStore } from "oj/store/ai"
import { parseTime } from "utils/functions"
const aiStore = useAIStore()
const containerRef = ref<HTMLElement>()
const containerRef = useTemplateRef<HTMLElement>("containerRef")
const CELL_SIZE = 12
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 {
import { filterResult } from "oj/transforms"
import type {
Exercise,
Problem,
Submission,
@@ -9,30 +8,6 @@ import {
SubmitCodePayload,
} 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() {
return http.get("website")
}
@@ -42,17 +17,9 @@ export async function getProblemList(
limit = 10,
searchParams: any = {},
) {
let params: any = {
paging: true,
offset,
limit,
}
Object.keys(searchParams).forEach((element) => {
if (searchParams[element]) {
params[element] = searchParams[element]
}
const res = await http.get<{ results: Problem[]; total: number }>("problem", {
params: { paging: true, offset, limit, ...searchParams },
})
const res = await http.get("problem", { params })
return {
results: res.data.results.map(filterResult),
total: res.data.total,
@@ -95,6 +62,10 @@ export function submitCode(data: SubmitCodePayload) {
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>) {
const endpoint = !!params.contest_id ? "contest_submissions" : "submissions"
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 } })
}
export function getTodaySubmissionCount() {
return http.get("submissions/today_count")
export function getTodaySubmissionCount(language?: string) {
return http.get("submissions/today_count", { params: { language } })
}
export function adminRejudge(id: string) {
@@ -202,7 +173,7 @@ export function checkContestPassword(contestID: string, password: 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 },
})
return res.data.map(filterResult)
@@ -210,7 +181,7 @@ export async function getContestProblems(contestID: string) {
export function getContestRank(
contestID: string,
query: { limit: number; offset: number; force_refresh: "1" | "0" },
query: { limit: number; offset: number },
) {
return http.get("contest_rank", {
params: {
@@ -307,6 +278,10 @@ export function getAILoginSummary() {
return http.get("ai/login_summary")
}
export function getAIPinnedReport() {
return http.get("ai/pinned")
}
// ==================== 相似题目推荐 ====================
export function getSimilarProblems(problemId: string) {
@@ -348,10 +323,26 @@ export function getFlowchartSubmissions(params: {
myself?: string
offset?: number
limit?: number
today?: string
grade?: string
}) {
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) {
return http.post("flowchart/submission/retry", {
submission_id: submissionId,
@@ -440,7 +431,7 @@ export function getProblemSetUserProgress(
}
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 },
})
return res.data

View File

@@ -3,9 +3,14 @@ import { h } from "vue"
import { formatISO, sub, type Duration } from "date-fns"
import { getClassPK } from "oj/api"
import { useConfigStore } from "shared/store/config"
import { useUserStore } from "shared/store/user"
import { Icon } from "@iconify/vue"
import { Bar, Radar } from "vue-chartjs"
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 {
Chart as ChartJS,
CategoryScale,
@@ -37,6 +42,7 @@ ChartJS.register(
)
const configStore = useConfigStore()
const { isTeacherOrAbove } = useUserStore()
const message = useMessage()
const { isDesktop } = useBreakpoints()
@@ -72,6 +78,11 @@ const duration = ref<string>("")
const loading = 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 保持一致)
const timeRangeOptions: SelectOption[] = [
{ 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) {
if (index === 0) return { type: "success" as const, text: "1" }
@@ -485,6 +563,149 @@ const compositeScoreChartOptions = {
},
}
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 = {
responsive: true,
maintainAspectRatio: false,
@@ -566,8 +787,42 @@ const radarChartOptions = {
>
开始PK
</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-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-gi
@@ -802,16 +1057,31 @@ const radarChartOptions = {
<!-- 可视化图表 - 专注于对比 -->
<template v-if="comparisons.length > 0">
<!-- 综合分对比 - 一眼看出胜负 -->
<n-card title="综合分对比满分100" style="margin-top: 20px">
<div style="height: 300px">
<Bar
v-if="compositeScoreChartData"
:data="compositeScoreChartData"
:options="compositeScoreChartOptions"
/>
</div>
</n-card>
<!-- 综合分对比 + 多维度雷达图 同行 -->
<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核心指标对比 - 三个独立图表并排显示 -->
<n-card title="AC核心指标对比" style="margin-top: 20px">
@@ -911,17 +1181,6 @@ const radarChartOptions = {
</n-gi>
</n-grid>
</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>
<!-- 对比表格 -->
@@ -930,151 +1189,7 @@ const radarChartOptions = {
title="对比表格"
style="margin-top: 20px"
>
<n-data-table
:data="comparisons"
:columns="[
{
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) + '%',
),
},
]"
/>
<n-data-table :data="comparisons" :columns="tableColumns" />
</n-card>
</n-flex>
</n-card>

View File

@@ -34,188 +34,188 @@ interface Props {
const props = defineProps<Props>()
const PENALTY_SECONDS = 20 * 60
const showChart = computed(() => {
const hasRanks = props.ranks.length > 0
const hasProblems = props.problems.length >= 3
return hasProblems && hasRanks
})
// 预定义的颜色方案 - 更现代和可访问的颜色
const colorPalette = [
"#3B82F6", // 蓝色
"#EF4444", // 红色
"#10B981", // 绿色
"#F59E0B", // 黄色
"#8B5CF6", // 紫色
"#EC4899", // 粉色
"#06B6D4", // 青色
"#84CC16", // 青绿色
"#F97316", // 橙色
"#6366F1", // 靛蓝色
"#3B82F6",
"#EF4444",
"#10B981",
"#F59E0B",
"#8B5CF6",
"#EC4899",
"#06B6D4",
"#84CC16",
"#F97316",
"#6366F1",
]
// 数据处理函数
const processChartData = () => {
if (!props.ranks || props.ranks.length === 0) {
return {
labels: [],
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,
}
function formatTime(seconds: number): string {
const h = Math.floor(seconds / 3600)
const m = Math.floor((seconds % 3600) / 60)
if (h > 0) return `${h}h${m}m`
return `${m}m`
}
// 监听数据变化,重新处理
watch(
() => [props.ranks, props.problems],
() => {
if (props.ranks && props.ranks.length > 0) {
// 数据变化时重新处理
}
},
{ deep: true, immediate: true },
)
interface AcEvent {
time: number
userIndex: number
problemId: string
}
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(() => ({
responsive: true,
maintainAspectRatio: false,
interaction: {
mode: "index" as const,
intersect: false,
},
plugins: {
legend: {
display: true,
position: "top" as const,
maxHeight: 80,
labels: {
boxWidth: 12,
boxHeight: 12,
padding: 8,
usePointStyle: true,
font: {
size: 11,
},
boxWidth: 14,
boxHeight: 3,
padding: 10,
font: { size: 12 },
},
},
tooltip: {
mode: "index" as const,
intersect: false,
itemSort: (a: any, b: any) => a.parsed.y - b.parsed.y,
callbacks: {
title: function (context: any) {
return `题目: ${context[0].label}`
},
label: function (context: any) {
const value = context.parsed.y
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}`
title: (context: any) => `比赛进行: ${context[0].label}`,
label: (context: any) => {
const rank = context.parsed.y
const name = context.dataset.label
return `${rank}名 — ${name}`
},
},
},
},
scales: {
x: {
title: {
display: true,
text: "比赛时间",
},
},
y: {
title: {
display: true,
text: "相对通过时间",
text: "排名",
},
min: 0,
reverse: true,
min: 1,
max: 10,
ticks: {
callback: function (value: any) {
const hours = Math.floor(value / 3600)
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`
},
stepSize: 1,
callback: (value: any) => `${value}`,
},
},
},
@@ -224,7 +224,7 @@ const chartOptions = computed(() => ({
<style scoped>
.chart {
height: 500px;
height: 420px;
width: 100%;
margin-bottom: 24px;
}

View File

@@ -95,7 +95,6 @@ async function listRanks() {
const res = await getContestRank(props.contestID, {
limit: query.limit,
offset: query.limit * (query.page - 1),
force_refresh: "1",
})
total.value = res.data.total
data.value = res.data.results
@@ -225,7 +224,6 @@ async function downloadExcel() {
const res = await getContestRank(props.contestID, {
limit: total.value || 10000,
offset: 0,
force_refresh: "1",
})
const allRanks: ContestRank[] = res.data.results

View File

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

View File

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

View File

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

View File

@@ -1,8 +1,13 @@
<template>
<div>
<div class="learn-container">
<!-- 桌面端布局 -->
<n-grid :cols="5" :x-gap="16" v-if="tutorial.id && isDesktop">
<n-gi :span="1">
<n-grid
: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-list hoverable clickable>
<n-list-item
@@ -21,7 +26,7 @@
</n-card>
</n-gi>
<n-gi :span="tutorial.code ? 2 : 4">
<n-gi :span="tutorial.code ? 2 : 4" class="learn-col">
<n-card
:title="`第 ${step} 课:${titles[step - 1]?.title}`"
:bordered="false"
@@ -43,9 +48,19 @@
</n-card>
</n-gi>
<n-gi :span="2" v-if="tutorial.code">
<n-card title="示例代码" :bordered="false" size="small">
<CodeEditor language="Python3" v-model="tutorial.code" />
<n-gi :span="2" v-if="tutorial.code" class="learn-col learn-col--code">
<n-card
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-gi>
</n-grid>
@@ -119,7 +134,7 @@
<script setup lang="ts">
import { MdPreview } from "md-editor-v3"
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 { parseExercises } from "./composables/useExerciseParse"
import { useBreakpoints } from "shared/composables/breakpoints"
@@ -190,3 +205,26 @@ watch(
{ immediate: true },
)
</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 实际的连接状态(已建立/未建立)
}
const props = withDefaults(defineProps<Props>(), {
isConnected: false,
})
const { storageKey, isConnected = false } = defineProps<Props>()
// 注入同步状态
const syncStatus = injectSyncStatus()
@@ -102,7 +100,7 @@ const reset = () => {
problem.value!.template[codeStore.code.language] ||
SOURCES[codeStore.code.language],
)
storage.remove(props.storageKey)
storage.remove(storageKey)
message.success("代码重置成功")
}
@@ -185,7 +183,7 @@ onMounted(() => {
</n-button>
<n-button
v-if="userStore.isSuperAdmin"
v-if="userStore.isTeacherOrAbove"
:size="buttonSize"
@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">
{{ SYNC_MESSAGES.SYNCING_WITH(syncStatus.otherUser.value.name) }}
</n-tag>
@@ -247,7 +245,7 @@ onMounted(() => {
</n-flex>
<n-modal
v-if="userStore.isSuperAdmin"
v-if="userStore.isTeacherOrAbove"
v-model:show="statisticPanel"
preset="card"
title="提交记录的统计"

View File

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

View File

@@ -103,28 +103,57 @@ const NODE_TARGET_LABELS: Record<string, string> = {
class_definition: "类定义",
}
type AstRule = { engine: string; target?: string; min?: number; max?: number; message: string }
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 = NODE_TARGET_LABELS[target] || target
const range = (min?: number, max?: number) => {
if (min !== undefined && max !== undefined) return `${min}${max}`
if (min !== undefined) return `至少 ${min}`
if (max !== undefined) return `至多 ${max}`
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} 出现次数 ${range(rule.min, rule.max)}`
case "must_call_function": return `必须调用函数 ${target}`
case "must_not_call_function": return `不能调用函数 ${target}`
case "count_function_call": return `函数 ${target} 调用次数 ${range(rule.min, rule.max)}`
case "must_call_method": return `必须调用方法 ${target}`
case "must_not_call_method": return `不能调用方法 ${target}`
case "must_use_operator": return `必须使用运算符 ${target}`
default: return rule.message || 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
}
}
@@ -136,7 +165,9 @@ function ruleTagType(engine: string): "error" | "success" | "info" {
const astRulesForDisplay = computed(() => {
if (!problem.value?.ast_rules) return []
return Object.entries(problem.value.ast_rules).filter(([, rules]) => rules.length > 0)
return Object.entries(problem.value.ast_rules).filter(
([, rules]) => rules.length > 0,
)
})
async function test(sample: Sample, index: number) {
@@ -285,14 +316,18 @@ function type(status: ProblemStatus) {
</n-flex>
</p>
<div v-for="[lang, rules] in astRulesForDisplay" :key="lang">
<p v-if="astRulesForDisplay.length > 1" class="lang-label">{{ lang }}</p>
<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 size="small" :type="ruleTagType(rule.engine)">
<n-tag :type="ruleTagType(rule.engine)">
{{ ruleDescription(rule) }}
</n-tag>
<span v-if="rule.message" class="rule-message">{{ rule.message }}</span>
<span v-if="rule.message" class="rule-message">{{
rule.message
}}</span>
</n-flex>
</n-list-item>
</n-list>

View File

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

View File

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

View File

@@ -1,4 +1,6 @@
<script setup lang="ts">
import { Icon } from "@iconify/vue"
import { useThemeVars } from "naive-ui"
import { JUDGE_STATUS, SubmissionStatus } from "utils/constants"
import {
getCSRFToken,
@@ -19,6 +21,7 @@ const props = defineProps<{
const isDark = useDark()
const problemStore = useProblemStore()
const theme = useThemeVars()
// AI 提示状态
const hintContent = ref("")
@@ -40,11 +43,10 @@ const msg = computed(() => {
msg += "请仔细检查,看看代码的格式是不是写错了!\n\n"
}
if (result === SubmissionStatus.ast_check_failed) {
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
}
@@ -151,10 +153,34 @@ const columns: DataTableColumn<Submission["info"]["data"][number]>[] = [
<div v-if="submission">
<n-alert
:type="JUDGE_STATUS[submission.result]['type']"
:title="JUDGE_STATUS[submission.result]['name']"
:title="JUDGE_STATUS[submission.result]['title']"
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-data-table
v-if="infoTable.length"

View File

@@ -1,14 +1,20 @@
<script setup lang="ts">
import { Icon } from "@iconify/vue"
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 { useProblemStore } from "oj/store/problem"
import { useFireworks } from "oj/problem/composables/useFireworks"
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 SubmissionResult from "./SubmissionResult.vue"
import { getSubmitButtonState } from "./submitButtonState"
import { useBreakpoints } from "shared/composables/breakpoints"
import { useUserStore } from "shared/store/user"
import { checkPythonSyntax } from "oj/problem/utils/pythonSyntaxCheck"
@@ -37,16 +43,12 @@ const { isDesktop } = useBreakpoints()
const { celebrate } = useFireworks()
// ==================== 判题监控 ====================
const {
submission,
judging,
pending,
submitting,
isProcessing,
startMonitoring,
} = useSubmissionMonitor()
const { submission, judging, pending, submitting, startMonitoring } =
useSubmissionMonitor()
const showResult = ref(false)
const isFormatting = ref(false)
const isSubmittingRequest = ref(false)
// ==================== 提交冷却 ====================
const { start: startCooldown, isPending: isCooldown } = useTimeout(5000, {
@@ -80,35 +82,20 @@ const { start: goToProblemSetDelayed } = useTimeoutFn(
)
// ==================== 计算属性 ====================
// 按钮禁用逻辑
const submitDisabled = computed(() => {
return (
!userStore.isAuthed ||
codeStore.code.value.trim() === "" ||
isProcessing.value ||
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"
})
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,
}),
)
// ==================== 提交函数 ====================
async function submit() {
if (!userStore.isAuthed) return
if (buttonState.value.disabled) return
// 0. 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. 构建提交数据
const data: SubmitCodePayload = {
problem_id: problem.value!.id,
@@ -129,13 +138,18 @@ async function submit() {
data.contest_id = parseInt(contestID)
}
// 2. 提交代码到后端
const res = await submitCode(data)
console.log(`[Submit] 代码已提交: ID=${res.data.submission_id}`)
isSubmittingRequest.value = true
try {
const res = await submitCode(data)
console.log(`[Submit] 代码已提交: ID=${res.data.submission_id}`)
// 3. 启动冷却 + 监控
startCooldown()
startMonitoring(res.data.submission_id)
showResult.value = true
// 3. 启动冷却 + 监控
startCooldown()
startMonitoring(res.data.submission_id)
showResult.value = true
} finally {
isSubmittingRequest.value = false
}
}
// ==================== 失败计数 ====================
@@ -149,7 +163,10 @@ watch(
result === SubmissionStatus.submitting
)
return
if (result !== SubmissionStatus.accepted && result !== SubmissionStatus.ast_check_failed) {
if (
result !== SubmissionStatus.accepted &&
result !== SubmissionStatus.ast_check_failed
) {
problemStore.incrementFailCount()
}
},
@@ -159,7 +176,11 @@ watch(
watch(
() => submission.value?.result,
async (result) => {
if (result !== SubmissionStatus.accepted && result !== SubmissionStatus.ast_check_failed) return
if (
result !== SubmissionStatus.accepted &&
result !== SubmissionStatus.ast_check_failed
)
return
// 1. 刷新题目状态
problem.value!.my_status = 0
@@ -206,15 +227,15 @@ watch(
<n-button
:size="isDesktop ? 'medium' : 'small'"
type="primary"
:disabled="submitDisabled"
:disabled="buttonState.disabled"
@click="submit"
>
<template #icon>
<n-icon>
<Icon :icon="submitIcon" />
<Icon :icon="buttonState.icon" />
</n-icon>
</template>
{{ submitLabel }}
{{ buttonState.label }}
</n-button>
</template>

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

@@ -40,10 +40,7 @@ interface Props {
problemSetId?: string
}
const props = withDefaults(defineProps<Props>(), {
contestID: "",
problemSetId: "",
})
const { problemID, contestID = "", problemSetId = "" } = defineProps<Props>()
const errMsg = ref("无数据")
const route = useRoute()
@@ -67,7 +64,7 @@ const tabOptions = computed(() => {
options.push("editor")
}
options.push("info")
if (!props.contestID) {
if (!contestID) {
options.push("comment")
}
if (myFlowchartStore.showing) {
@@ -110,7 +107,7 @@ watch(
async function init() {
screenModeStore.resetScreenMode()
try {
const res = await getProblem(props.problemID, props.contestID)
const res = await getProblem(problemID, contestID)
problem.value = res.data
} catch (err: any) {
problem.value = null
@@ -120,7 +117,7 @@ async function init() {
}
}
onMounted(init)
watch(() => props.problemID, init)
watch(() => problemID, init)
onBeforeUnmount(() => {
problem.value = null
errMsg.value = "无数据"
@@ -159,15 +156,15 @@ watch(isMobile, (value) => {
<n-tab-pane
name="info"
tab="题目统计"
:disabled="!!props.problemSetId"
:disabled="!!problemSetId"
>
<ProblemInfo />
</n-tab-pane>
<n-tab-pane
v-if="!props.contestID"
v-if="!contestID"
name="comment"
tab="题目点评"
:disabled="!!props.problemSetId"
:disabled="!!problemSetId"
>
<ProblemComment />
</n-tab-pane>
@@ -181,7 +178,7 @@ watch(isMobile, (value) => {
<n-tab-pane
name="submission"
tab="我的提交"
:disabled="!!props.problemSetId"
:disabled="!!problemSetId"
>
<ProblemSubmission />
</n-tab-pane>
@@ -215,15 +212,15 @@ watch(isMobile, (value) => {
<n-tab-pane
name="info"
tab="题目统计"
:disabled="!!props.problemSetId"
:disabled="!!problemSetId"
>
<ProblemInfo />
</n-tab-pane>
<n-tab-pane
v-if="!props.contestID"
v-if="!contestID"
name="comment"
tab="题目点评"
:disabled="!!props.problemSetId"
:disabled="!!problemSetId"
>
<ProblemComment />
</n-tab-pane>
@@ -237,7 +234,7 @@ watch(isMobile, (value) => {
<n-tab-pane
name="submission"
tab="我的提交"
:disabled="!!props.problemSetId"
:disabled="!!problemSetId"
>
<ProblemSubmission />
</n-tab-pane>
@@ -256,21 +253,25 @@ watch(isMobile, (value) => {
<n-tab-pane name="editor" tab="代码">
<component :is="inProblem ? ProblemEditor : ContestEditor" />
</n-tab-pane>
<n-tab-pane name="info" tab="统计" :disabled="!!props.problemSetId">
<n-tab-pane name="info" tab="统计" :disabled="!!problemSetId">
<ProblemInfo />
</n-tab-pane>
<n-tab-pane
v-if="!props.contestID"
v-if="!contestID"
name="comment"
tab="点评"
:disabled="!!props.problemSetId"
:disabled="!!problemSetId"
>
<ProblemComment />
</n-tab-pane>
<n-tab-pane v-if="myFlowchartStore.showing" name="my-flowchart" tab="我的流程图">
<n-tab-pane
v-if="myFlowchartStore.showing"
name="my-flowchart"
tab="我的流程图"
>
<MyFlowchartTab />
</n-tab-pane>
<n-tab-pane name="submission" tab="提交" :disabled="!!props.problemSetId">
<n-tab-pane name="submission" tab="提交" :disabled="!!problemSetId">
<ProblemSubmission />
</n-tab-pane>
</n-tabs>

View File

@@ -45,6 +45,7 @@ const sortOptions = [
{ label: "最多通过", value: "-accepted_number" },
{ label: "最少通过", value: "accepted_number" },
{ label: "画流程图", value: "flowchart" },
{ label: "语法检查", value: "ast" },
]
const router = useRouter()
@@ -240,6 +241,7 @@ function rowProps(row: ProblemFiltered) {
style="width: 120px"
v-model:value="query.sort"
:options="sortOptions"
:dropdown-style="{ maxHeight: 'unset' }"
/>
</n-form-item>
<n-form-item>

View File

@@ -1,15 +1,16 @@
<script setup lang="ts">
import { formatISO, sub, type Duration } from "date-fns"
import { NButton, NFlex, useThemeVars } from "naive-ui"
import { NButton, NFlex } from "naive-ui"
import {
getActivityRank,
getClassRank,
getRank,
getUserClassRank,
getClassPK,
} from "oj/api"
import { useBreakpoints } from "shared/composables/breakpoints"
import { getACRate } from "utils/functions"
import { Rank } from "utils/types"
import { getACRate, getCSRFToken } from "utils/functions"
import type { Rank } from "utils/types"
import Pagination from "shared/components/Pagination.vue"
import { ChartType } from "utils/constants"
import { renderTableTitle } from "utils/renders"
@@ -17,6 +18,9 @@ import Chart from "./components/Chart.vue"
import Index from "./components/Index.vue"
import { useUserStore } from "shared/store/user"
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 = [
{ label: "24年级", value: 24 },
@@ -52,6 +56,83 @@ const myClassQuery = reactive({
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 {
rank: number
class_name: string
@@ -62,6 +143,27 @@ interface ClassRank {
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 {
rank: number
username: string
@@ -191,7 +293,7 @@ const classColumns: DataTableColumn<ClassRank>[] = [
{
title: "排名",
key: "rank",
width: 100,
width: 60,
titleAlign: "center",
align: "center",
},
@@ -200,46 +302,63 @@ const classColumns: DataTableColumn<ClassRank>[] = [
key: "class_name",
render: (row) =>
`${row.class_name.slice(0, 2)}计算机${row.class_name.slice(2)}`,
width: 200,
minWidth: 120,
titleAlign: "center",
align: "center",
},
{
title: "人数",
key: "user_count",
width: 100,
width: 80,
titleAlign: "center",
align: "center",
},
{
title: "总AC数",
key: "total_ac",
width: 120,
width: 90,
titleAlign: "center",
align: "center",
},
{
title: "提交数",
title: "提交数",
key: "total_submission",
width: 120,
width: 90,
titleAlign: "center",
align: "center",
},
{
title: "平均AC数",
key: "avg_ac",
width: 120,
width: 100,
titleAlign: "center",
align: "center",
},
{
title: "正确率",
key: "ac_rate",
width: 100,
width: 90,
titleAlign: "center",
align: "center",
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>[] = [
@@ -453,6 +572,260 @@ watch(
</n-gi>
</n-grid>
</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>
<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 { consumeJSONEventStream } from "utils/stream"
import { getAIDetailData, getAIDurationData, getAIHeatmapData } from "../api"
import {
getAIDetailData,
getAIDurationData,
getAIHeatmapData,
getAIPinnedReport,
} from "../api"
import { getCSRFToken } from "utils/functions"
export const useAIStore = defineStore("ai", () => {
@@ -27,6 +32,7 @@ export const useAIStore = defineStore("ai", () => {
})
const mdContent = ref("")
const pinnedReport = ref<{ analysis: string } | null>(null)
async function fetchDetailsData(start: string, end: string) {
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 {
fetchAnalysisData,
fetchHeatmapData,
fetchAIAnalysis,
fetchPinnedReport,
simulatePinnedStream,
durationData,
detailsData,
heatmapData,
@@ -167,5 +201,6 @@ export const useAIStore = defineStore("ai", () => {
targetUsername,
loading,
mdContent,
pinnedReport,
}
})

View File

@@ -1,36 +1,45 @@
<template>
<n-grid v-if="submission" :cols="5" :x-gap="16">
<!-- 左侧流程图预览区域 -->
<n-gi :span="showLargeImage ? 5 : 3">
<n-gi :span="3">
<n-card title="流程图预览">
<template #header-extra>
<n-button
v-if="!renderError && submission?.mermaid_code"
quaternary
size="small"
@click="showLargeImage = !showLargeImage"
@click="showLargeImage = true"
>
<template #icon>
<Icon
:icon="
showLargeImage ? 'mdi:fullscreen-exit' : 'mdi:fullscreen'
"
/>
<Icon icon="mdi:fullscreen" />
</template>
{{ showLargeImage ? "退出大图" : "查看大图" }}
查看大图
</n-button>
</template>
<div class="flowchart">
<n-alert v-if="renderError" type="error" title="流程图渲染失败">
{{ renderError }}
</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>
</n-card>
</n-gi>
<!-- 右侧评分详情区域 -->
<n-gi v-if="!showLargeImage" :span="2">
<n-gi :span="2">
<!-- AI反馈 -->
<n-card
v-if="submission.ai_feedback"
@@ -137,6 +146,7 @@ function getPercentType(percent: number) {
async function loadSubmission() {
if (!props.submissionId) return
showLargeImage.value = false
loading.value = true
try {
const { getFlowchartSubmission } = await import("oj/api")
@@ -171,11 +181,42 @@ watch(() => props.submissionId, loadSubmission, { immediate: true })
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 图表占满容器 */
:deep(.flowchart > svg) {
height: 100%;
}
/* 全屏时按自然尺寸显示并水平居中,配合容器滚动 */
:deep(.flowchart-fullscreen > svg) {
display: block;
margin: 0 auto;
width: auto;
height: auto;
max-width: none;
}
.loading-container {
min-height: 600px;
display: flex;

View File

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

View File

@@ -6,6 +6,7 @@ import {
getFlowchartSubmissions,
getSubmissions,
getTodaySubmissionCount,
retryFlowchartSubmission,
} from "oj/api"
import { parseTime } from "utils/functions"
import type {
@@ -22,6 +23,7 @@ import { LANGUAGE_SHOW_VALUE } from "utils/constants"
import { renderTableTitle } from "utils/renders"
import ButtonWithSearch from "./components/ButtonWithSearch.vue"
import StatisticsPanel from "shared/components/StatisticsPanel.vue"
import FlowchartStatisticsPanel from "shared/components/FlowchartStatisticsPanel.vue"
import SubmissionLink from "./components/SubmissionLink.vue"
import SubmissionDetail from "./detail.vue"
import Grade from "./components/Grade.vue"
@@ -72,11 +74,20 @@ const selectedFlowchart = computed(() => {
const resultOptions: SelectOption[] = [
{ label: "全部", value: "" },
{ label: "答案正确", value: "0" },
{ label: "语法未通过", value: "10" },
{ label: "答案错误", value: "-1" },
{ label: "编译失败", value: "-2" },
{ 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[] = [
{ label: "流程图", value: "Flowchart" },
{ label: "全部语言", value: "" },
@@ -94,6 +105,8 @@ async function listSubmissions() {
myself: query.myself,
offset,
limit: query.limit,
today: query.today,
grade: query.result,
})
total.value = res.data.total
flowcharts.value = res.data.results
@@ -112,7 +125,7 @@ async function listSubmissions() {
}
async function getTodayCount() {
const res = await getTodaySubmissionCount()
const res = await getTodaySubmissionCount(query.language)
todayCount.value = res.data
}
@@ -138,6 +151,12 @@ async function rejudge(submissionID: string) {
listSubmissions()
}
async function retryFlowchart(submissionId: string) {
await retryFlowchartSubmission(submissionId)
message.success("重新评分已提交")
listSubmissions()
}
function problemClicked(row: SubmissionListItem | FlowchartSubmissionListItem) {
if (route.name === "contest submissions") {
const path = router.resolve({
@@ -190,6 +209,24 @@ watch(
listSubmissions,
)
// 切换语言时重置过滤条件,刷新今日提交数
watch(
() => query.language,
() => {
query.result = ""
if (route.name === "submissions") getTodayCount()
},
)
// 登录状态变化后刷新提交列表,更新提交编号列的可点击状态
watch(
() => userStore.isAuthed,
() => {
listSubmissions()
if (route.name === "submissions") getTodayCount()
},
)
const columns = computed(() => {
const res: DataTableColumn<SubmissionListItem>[] = [
{
@@ -259,7 +296,7 @@ const columns = computed(() => {
),
},
]
if (!route.params.contestID && userStore.isSuperAdmin) {
if (!route.params.contestID && userStore.isTeacherOrAbove) {
res.push({
title: renderTableTitle("选项", "streamline-emojis:wrench"),
key: "rejudge",
@@ -279,61 +316,81 @@ const columns = computed(() => {
return res
})
const flowchartColumns: DataTableColumn<FlowchartSubmissionListItem>[] = [
{
title: renderTableTitle("提交时间", "noto:seven-oclock"),
key: "create_time",
render: (row) => parseTime(row.create_time, "YYYY-MM-DD HH:mm:ss"),
},
{
title: renderTableTitle("提交编号", "fluent-emoji-flat:input-numbers"),
key: "id",
render: (row) =>
h(FlowchartLink, {
flowchart: row,
onShowDetail: (id: string) => showScoreDetail(id),
}),
},
{
title: renderTableTitle("题目", "streamline-emojis:blossom"),
key: "problem_title",
render: (row) =>
h(
ButtonWithSearch,
{
type: "题目",
onClick: () => problemClicked(row),
onSearch: () => (query.problem = row.problem),
},
() => `${row.problem} ${row.problem_title}`,
const flowchartColumns = computed(() => {
const res: DataTableColumn<FlowchartSubmissionListItem>[] = [
{
title: renderTableTitle("提交时间", "noto:seven-oclock"),
key: "create_time",
render: (row) => parseTime(row.create_time, "YYYY-MM-DD HH:mm:ss"),
},
{
title: renderTableTitle("提交编号", "fluent-emoji-flat:input-numbers"),
key: "id",
render: (row) =>
h(FlowchartLink, {
flowchart: row,
onShowDetail: (id: string) => showScoreDetail(id),
}),
},
{
title: renderTableTitle("题目", "streamline-emojis:blossom"),
key: "problem_title",
render: (row) =>
h(
ButtonWithSearch,
{
type: "题目",
onClick: () => problemClicked(row),
onSearch: () => (query.problem = row.problem),
},
() => `${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",
),
},
{
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,
render: (row) =>
h(
ButtonWithSearch,
{
type: "用户",
username: row.username,
onClick: () => window.open("/user?name=" + row.username, "_blank"),
onSearch: () => (query.username = row.username),
onFilterClass: (classname: string) => (query.username = classname),
},
() => row.username,
),
},
]
key: "username",
minWidth: 200,
render: (row) =>
h(
ButtonWithSearch,
{
type: "用户",
username: row.username,
onClick: () => window.open("/user?name=" + row.username, "_blank"),
onSearch: () => (query.username = row.username),
onFilterClass: (classname: string) => (query.username = classname),
},
() => row.username,
),
},
]
if (!route.params.contestID && userStore.isTeacherOrAbove) {
res.push({
title: renderTableTitle("选项", "streamline-emojis:wrench"),
key: "retry",
render: (row) =>
h(
NButton,
{
quaternary: true,
size: "small",
type: "primary",
onClick: () => retryFlowchart(row.id),
},
() => "重新判题",
),
})
}
return res
})
</script>
<template>
<n-flex vertical size="large">
@@ -353,12 +410,13 @@ const flowchartColumns: DataTableColumn<FlowchartSubmissionListItem>[] = [
:options="languageOptions"
/>
</n-form-item>
<n-form-item label="状态">
<n-form-item :label="query.language === 'Flowchart' ? '等级' : '状态'">
<n-select
:disabled="query.language === 'Flowchart'"
class="select"
v-model:value="query.result"
:options="resultOptions"
:options="
query.language === 'Flowchart' ? gradeOptions : resultOptions
"
/>
</n-form-item>
</n-form>
@@ -398,7 +456,7 @@ const flowchartColumns: DataTableColumn<FlowchartSubmissionListItem>[] = [
<n-button @click="clear" quaternary>重置</n-button>
</n-form-item>
<n-form-item
v-if="userStore.isSuperAdmin && route.name === 'submissions'"
v-if="userStore.isTeacherOrAbove && route.name === 'submissions'"
>
<n-button
quaternary
@@ -442,14 +500,25 @@ const flowchartColumns: DataTableColumn<FlowchartSubmissionListItem>[] = [
v-model:page="query.page"
/>
<n-modal
v-if="userStore.isSuperAdmin"
v-if="userStore.isTeacherOrAbove"
v-model:show="statisticPanel"
preset="card"
:style="{ maxWidth: isDesktop && '800px', maxHeight: '80vh' }"
: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
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,
)
const problemsFlexRef = ref<HTMLElement | null>(null)
const problemsFlexRef = useTemplateRef<HTMLElement>("problemsFlexRef")
const itemsPerRow = ref(8)
function updateItemsPerRow() {

View File

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

View File

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

View File

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

View File

@@ -26,9 +26,7 @@ interface Props {
height?: string
}
withDefaults(defineProps<Props>(), {
height: "calc(100vh - 133px)",
})
const { height = "calc(100vh - 133px)" } = defineProps<Props>()
// Vue Flow 实例
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: () =>
h(
RouterLink,
{ to: userStore.isTheAdmin ? "/admin/problem/list" : "/admin" },
{ to: userStore.isSuperAdmin ? "/admin" : "/admin/problem/list" },
{ default: () => "后台" },
),
show: userStore.isAdminRole,

View File

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

View File

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

View File

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

View File

@@ -33,38 +33,38 @@
<n-divider style="margin: 16px 0" />
<n-flex justify="space-around">
<div class="stat-item">
<div class="stat-label">总提交</div>
<n-text>总提交</n-text>
<n-gradient-text type="info" font-size="28">{{
count.total
}}</n-gradient-text>
</div>
<div class="stat-item">
<div class="stat-label">正确提交</div>
<n-text>正确提交</n-text>
<n-gradient-text type="primary" font-size="28">{{
count.accepted
}}</n-gradient-text>
</div>
<div class="stat-item">
<div class="stat-label">正确率</div>
<n-text>正确率</n-text>
<n-gradient-text type="warning" font-size="28">{{
count.rate
}}</n-gradient-text>
</div>
<template v-if="person.count > 0">
<div class="stat-item">
<div class="stat-label">完成人数</div>
<n-text>完成人数</n-text>
<n-gradient-text type="error" font-size="28">{{
list.length
}}</n-gradient-text>
</div>
<div class="stat-item">
<div class="stat-label">班级人数</div>
<n-text>班级人数</n-text>
<n-gradient-text type="warning" font-size="28">{{
adjustedPersonCount
}}</n-gradient-text>
</div>
<div class="stat-item">
<div class="stat-label">完成度</div>
<n-text>完成度</n-text>
<n-gradient-text type="success" font-size="28">{{
adjustedPersonRate
}}</n-gradient-text>
@@ -467,8 +467,4 @@ function rowProps(row: UserStatistic) {
align-items: center;
gap: 4px;
}
.stat-label {
font-size: 13px;
color: var(--n-text-color-3, #999);
}
</style>

View File

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

View File

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

View File

@@ -19,8 +19,8 @@ const options = computed<MenuOption[]>(() => {
},
]
// admin 可以访问的功能
if (userStore.isTheAdmin) {
// Student Admin: only problems
if (userStore.isStudentAdmin) {
baseOptions.push({
label: () =>
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) {
baseOptions.push(
{
@@ -99,6 +141,15 @@ const options = computed<MenuOption[]>(() => {
),
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/announcement")) return "admin announcement list"
if (path.startsWith("/admin/tutorial")) return "admin tutorial list"
if (path.startsWith("/admin/ai")) return "admin ai reports"
return route.name as string
})

View File

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

View File

@@ -30,60 +30,74 @@ export enum ContestType {
export const JUDGE_STATUS: {
[key in SUBMISSION_RESULT]: {
name: string
title: string
type: "error" | "success" | "warning" | "info"
}
} = {
"-2": {
name: "编译失败",
title: "编译失败",
type: "warning",
},
"-1": {
name: "答案错误",
title: "答案错误",
type: "error",
},
"0": {
name: "答案正确",
title: "答案正确",
type: "success",
},
"1": {
name: "运行超时",
title: "运行超时",
type: "error",
},
"2": {
name: "运行超时",
title: "运行超时",
type: "error",
},
"3": {
name: "内存超限",
title: "内存超限",
type: "error",
},
"4": {
name: "运行时错误",
title: "运行时错误",
type: "warning",
},
"5": {
name: "系统错误",
title: "系统错误",
type: "error",
},
"6": {
name: "等待评分",
title: "等待评分",
type: "warning",
},
"7": {
name: "正在评分",
title: "正在评分",
type: "warning",
},
"8": {
name: "部分正确",
title: "部分正确",
type: "warning",
},
"9": {
name: "正在提交",
title: "正在提交",
type: "info",
},
"10": {
name: "代码检查未通过",
type: "warning",
name: "语法未通过",
title: "答案正确,但语法未通过",
type: "success",
},
}
@@ -119,7 +133,8 @@ export const CONTEST_TYPE = {
export const USER_TYPE = {
REGULAR_USER: "Regular User",
ADMIN: "Admin",
STUDENT_ADMIN: "Student Admin",
TEACHER_ADMIN: "Teacher Admin",
SUPER_ADMIN: "Super Admin",
}

View File

@@ -102,11 +102,11 @@ export function secondsToDuration(seconds: number): string {
start: 0,
end: seconds * 1000,
})
return [
duration.hours ?? 0,
duration.minutes ?? 0,
duration.seconds ?? 0,
].join(":")
const hours = (duration.days ?? 0) * 24 + (duration.hours ?? 0)
const pad = (n: number) => String(n).padStart(2, "0")
return [hours, pad(duration.minutes ?? 0), pad(duration.seconds ?? 0)].join(
":",
)
}
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"]): {
type: "default" | "info" | "error"
label: "普通" | "管理员" | "超管"
type: "default" | "info" | "warning" | "error"
label: "普通" | "学生管理员" | "教师管理员" | "超管"
} {
const roleMap = {
[USER_TYPE.REGULAR_USER]: {
type: "default" 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]: {
type: "error" as const,
label: "超管" as const,

View File

@@ -1,20 +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_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",
xsrfHeaderName: "X-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) => {
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)
useAuthModalStore().openLoginModal()
} else if (res.data.error === "permission-denied") {
message.error(res.data.data || "权限不足")
}
return Promise.reject(res.data)
} else {
@@ -26,4 +74,6 @@ http.interceptors.response.use(
},
)
const http = instance as unknown as Http
export default http

View File

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

View File

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