Compare commits

...

42 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
49 changed files with 2574 additions and 983 deletions

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.

93
package-lock.json generated
View File

@@ -24,6 +24,7 @@
"axios": "^1.16.1", "axios": "^1.16.1",
"canvas-confetti": "^1.9.4", "canvas-confetti": "^1.9.4",
"chart.js": "^4.5.1", "chart.js": "^4.5.1",
"chartjs-chart-wordcloud": "^4.4.5",
"client-zip": "^2.5.0", "client-zip": "^2.5.0",
"codemirror": "^6.0.2", "codemirror": "^6.0.2",
"copy-text-to-clipboard": "^3.2.2", "copy-text-to-clipboard": "^3.2.2",
@@ -523,6 +524,7 @@
"resolved": "https://registry.npmmirror.com/@codemirror/state/-/state-6.5.2.tgz", "resolved": "https://registry.npmmirror.com/@codemirror/state/-/state-6.5.2.tgz",
"integrity": "sha512-FVqsPqtPWKVVL3dPSxy8wEF/ymIEuVzF1PK3VbUgrxXpJUSHQWWZz4JMToquRxnkw+36LTamCZG2iua2Ptq0fA==", "integrity": "sha512-FVqsPqtPWKVVL3dPSxy8wEF/ymIEuVzF1PK3VbUgrxXpJUSHQWWZz4JMToquRxnkw+36LTamCZG2iua2Ptq0fA==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@marijn/find-cluster-break": "^1.0.0" "@marijn/find-cluster-break": "^1.0.0"
} }
@@ -532,6 +534,7 @@
"resolved": "https://registry.npmmirror.com/@codemirror/view/-/view-6.38.4.tgz", "resolved": "https://registry.npmmirror.com/@codemirror/view/-/view-6.38.4.tgz",
"integrity": "sha512-hduz0suCcUSC/kM8Fq3A9iLwInJDl8fD1xLpTIk+5xkNm8z/FT7UsIa9sOXrkpChh+XXc18RzswE8QqELsVl+g==", "integrity": "sha512-hduz0suCcUSC/kM8Fq3A9iLwInJDl8fD1xLpTIk+5xkNm8z/FT7UsIa9sOXrkpChh+XXc18RzswE8QqELsVl+g==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@codemirror/state": "^6.5.0", "@codemirror/state": "^6.5.0",
"crelt": "^1.0.6", "crelt": "^1.0.6",
@@ -1184,6 +1187,7 @@
"integrity": "sha512-jI/VAmtdjB/RnI8GTnokyX7Ug8c+g+ffD6QRLa6XQewtnGyukKkKSk3wLTM3b5cjt1jNh9x0jfVlagdN2gDKQg==", "integrity": "sha512-jI/VAmtdjB/RnI8GTnokyX7Ug8c+g+ffD6QRLa6XQewtnGyukKkKSk3wLTM3b5cjt1jNh9x0jfVlagdN2gDKQg==",
"dev": true, "dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"peer": true,
"dependencies": { "dependencies": {
"tslib": "^2.8.0" "tslib": "^2.8.0"
} }
@@ -1280,6 +1284,21 @@
"integrity": "sha512-LFYWWd8nwfwEmTZG9PfQxd17HbNPksHBiJHaKuY1XeqscXacsS2tyoo6OdRsjf+NQYeB6XrNL3a25E3gH69lcg==", "integrity": "sha512-LFYWWd8nwfwEmTZG9PfQxd17HbNPksHBiJHaKuY1XeqscXacsS2tyoo6OdRsjf+NQYeB6XrNL3a25E3gH69lcg==",
"license": "MIT" "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": { "node_modules/@types/d3-color": {
"version": "3.1.3", "version": "3.1.3",
"resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz",
@@ -1559,6 +1578,7 @@
"resolved": "https://registry.npmmirror.com/@uppy/core/-/core-2.3.4.tgz", "resolved": "https://registry.npmmirror.com/@uppy/core/-/core-2.3.4.tgz",
"integrity": "sha512-iWAqppC8FD8mMVqewavCz+TNaet6HPXitmGXpGGREGrakZ4FeuWytVdrelydzTdXx6vVKkOmI2FLztGg73sENQ==", "integrity": "sha512-iWAqppC8FD8mMVqewavCz+TNaet6HPXitmGXpGGREGrakZ4FeuWytVdrelydzTdXx6vVKkOmI2FLztGg73sENQ==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@transloadit/prettier-bytes": "0.0.7", "@transloadit/prettier-bytes": "0.0.7",
"@uppy/store-default": "^2.1.1", "@uppy/store-default": "^2.1.1",
@@ -1608,6 +1628,7 @@
"resolved": "https://registry.npmmirror.com/@uppy/xhr-upload/-/xhr-upload-2.1.3.tgz", "resolved": "https://registry.npmmirror.com/@uppy/xhr-upload/-/xhr-upload-2.1.3.tgz",
"integrity": "sha512-YWOQ6myBVPs+mhNjfdWsQyMRWUlrDLMoaG7nvf/G6Y3GKZf8AyjFDjvvJ49XWQ+DaZOftGkHmF1uh/DBeGivJQ==", "integrity": "sha512-YWOQ6myBVPs+mhNjfdWsQyMRWUlrDLMoaG7nvf/G6Y3GKZf8AyjFDjvvJ49XWQ+DaZOftGkHmF1uh/DBeGivJQ==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@uppy/companion-client": "^2.2.2", "@uppy/companion-client": "^2.2.2",
"@uppy/utils": "^4.1.2", "@uppy/utils": "^4.1.2",
@@ -1682,6 +1703,7 @@
"resolved": "https://registry.npmjs.org/@vue-flow/core/-/core-1.48.2.tgz", "resolved": "https://registry.npmjs.org/@vue-flow/core/-/core-1.48.2.tgz",
"integrity": "sha512-raxhgKWE+G/mcEvXJjGFUDYW9rAI3GOtiHR3ZkNpwBWuIaCC1EYiBmKGwJOoNzVFgwO7COgErnK7i08i287AFA==", "integrity": "sha512-raxhgKWE+G/mcEvXJjGFUDYW9rAI3GOtiHR3ZkNpwBWuIaCC1EYiBmKGwJOoNzVFgwO7COgErnK7i08i287AFA==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@vueuse/core": "^10.5.0", "@vueuse/core": "^10.5.0",
"d3-drag": "^3.0.0", "d3-drag": "^3.0.0",
@@ -2033,6 +2055,7 @@
"resolved": "https://registry.npmjs.org/@vueuse/core/-/core-14.3.0.tgz", "resolved": "https://registry.npmjs.org/@vueuse/core/-/core-14.3.0.tgz",
"integrity": "sha512-aHfz47g0ZhMtTVHmIzMVpJy8ePhhOy68GY5bv110+5DVtZ+W7BsOx+m61UNQqfrWyPztIHIanWa3E2tib3NFIw==", "integrity": "sha512-aHfz47g0ZhMtTVHmIzMVpJy8ePhhOy68GY5bv110+5DVtZ+W7BsOx+m61UNQqfrWyPztIHIanWa3E2tib3NFIw==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@types/web-bluetooth": "^0.0.21", "@types/web-bluetooth": "^0.0.21",
"@vueuse/metadata": "14.3.0", "@vueuse/metadata": "14.3.0",
@@ -2087,6 +2110,7 @@
"resolved": "https://registry.npmmirror.com/@wangeditor-next/basic-modules/-/basic-modules-2.0.0.tgz", "resolved": "https://registry.npmmirror.com/@wangeditor-next/basic-modules/-/basic-modules-2.0.0.tgz",
"integrity": "sha512-oH7Cv6mHorvBkj5t3isP9wncgWABYLlQpoQZYOIFtWVwgsQatwoGVFHF6PoJzz+mTkt5UaJcyfDxFSo9Thvhdw==", "integrity": "sha512-oH7Cv6mHorvBkj5t3isP9wncgWABYLlQpoQZYOIFtWVwgsQatwoGVFHF6PoJzz+mTkt5UaJcyfDxFSo9Thvhdw==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"is-url": "^1.2.4" "is-url": "^1.2.4"
}, },
@@ -2119,6 +2143,7 @@
"resolved": "https://registry.npmmirror.com/@wangeditor-next/core/-/core-1.8.0.tgz", "resolved": "https://registry.npmmirror.com/@wangeditor-next/core/-/core-1.8.0.tgz",
"integrity": "sha512-U2TlQ0Lpo6aLb0KD8oJgzG/rFAYO61cy+qbZu+t5lDfS3CECNjOhGIC3C7/dXIhiMQ8V/LY4cvrPt9R5G4vLjA==", "integrity": "sha512-U2TlQ0Lpo6aLb0KD8oJgzG/rFAYO61cy+qbZu+t5lDfS3CECNjOhGIC3C7/dXIhiMQ8V/LY4cvrPt9R5G4vLjA==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@types/event-emitter": "^0.3.3", "@types/event-emitter": "^0.3.3",
"event-emitter": "^0.3.5", "event-emitter": "^0.3.5",
@@ -2148,6 +2173,7 @@
"resolved": "https://registry.npmmirror.com/@wangeditor-next/editor/-/editor-5.7.0.tgz", "resolved": "https://registry.npmmirror.com/@wangeditor-next/editor/-/editor-5.7.0.tgz",
"integrity": "sha512-bxkw/TeWBJz7AU4qXZnx5tx/s1yzx8XdLmSRN9ev4btArKnfXR/6hr3dqxUSsyea8q//IpEv1qr9tc2ureEAsA==", "integrity": "sha512-bxkw/TeWBJz7AU4qXZnx5tx/s1yzx8XdLmSRN9ev4btArKnfXR/6hr3dqxUSsyea8q//IpEv1qr9tc2ureEAsA==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@uppy/core": "^2.1.1", "@uppy/core": "^2.1.1",
"@uppy/xhr-upload": "^2.0.3", "@uppy/xhr-upload": "^2.0.3",
@@ -2439,6 +2465,7 @@
"resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.5.1.tgz", "resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.5.1.tgz",
"integrity": "sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw==", "integrity": "sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@kurkle/color": "^0.3.0" "@kurkle/color": "^0.3.0"
}, },
@@ -2446,6 +2473,19 @@
"pnpm": ">=8" "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": { "node_modules/chokidar": {
"version": "5.0.0", "version": "5.0.0",
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz",
@@ -2472,6 +2512,7 @@
"resolved": "https://registry.npmmirror.com/codemirror/-/codemirror-6.0.2.tgz", "resolved": "https://registry.npmmirror.com/codemirror/-/codemirror-6.0.2.tgz",
"integrity": "sha512-VhydHotNW5w1UGK0Qj96BwSk/Zqbp9WbnyK2W/eVMv4QyF41INRGpjUhFJY7/uDNuudSc33a/PKr4iDqRduvHw==", "integrity": "sha512-VhydHotNW5w1UGK0Qj96BwSk/Zqbp9WbnyK2W/eVMv4QyF41INRGpjUhFJY7/uDNuudSc33a/PKr4iDqRduvHw==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@codemirror/autocomplete": "^6.0.0", "@codemirror/autocomplete": "^6.0.0",
"@codemirror/commands": "^6.0.0", "@codemirror/commands": "^6.0.0",
@@ -2591,6 +2632,7 @@
"resolved": "https://registry.npmmirror.com/css-render/-/css-render-0.15.14.tgz", "resolved": "https://registry.npmmirror.com/css-render/-/css-render-0.15.14.tgz",
"integrity": "sha512-9nF4PdUle+5ta4W5SyZdLCCmFd37uVimSjg1evcTqKJCyvCEEj12WKzOSBNak6r4im4J4iYXKH1OWpUV5LBYFg==", "integrity": "sha512-9nF4PdUle+5ta4W5SyZdLCCmFd37uVimSjg1evcTqKJCyvCEEj12WKzOSBNak6r4im4J4iYXKH1OWpUV5LBYFg==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@emotion/hash": "~0.8.0", "@emotion/hash": "~0.8.0",
"csstype": "~3.0.5" "csstype": "~3.0.5"
@@ -2619,6 +2661,7 @@
"resolved": "https://registry.npmjs.org/cytoscape/-/cytoscape-3.33.3.tgz", "resolved": "https://registry.npmjs.org/cytoscape/-/cytoscape-3.33.3.tgz",
"integrity": "sha512-Gej7U+OKR+LZ8kvX7rb2HhCYJ0IhvEFsnkud4SB1PR+BUY/TsSO0dmOW59WEVLu51b1Rm+gQRKoz4bLYxGSZ2g==", "integrity": "sha512-Gej7U+OKR+LZ8kvX7rb2HhCYJ0IhvEFsnkud4SB1PR+BUY/TsSO0dmOW59WEVLu51b1Rm+gQRKoz4bLYxGSZ2g==",
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=0.10" "node": ">=0.10"
} }
@@ -2765,6 +2808,21 @@
"node": ">=12" "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": { "node_modules/d3-collection": {
"version": "1.0.7", "version": "1.0.7",
"resolved": "https://registry.npmjs.org/d3-collection/-/d3-collection-1.0.7.tgz", "resolved": "https://registry.npmjs.org/d3-collection/-/d3-collection-1.0.7.tgz",
@@ -3047,6 +3105,7 @@
"resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz", "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz",
"integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==",
"license": "ISC", "license": "ISC",
"peer": true,
"engines": { "engines": {
"node": ">=12" "node": ">=12"
} }
@@ -3481,6 +3540,7 @@
"resolved": "https://registry.npmmirror.com/date-fns/-/date-fns-4.1.0.tgz", "resolved": "https://registry.npmmirror.com/date-fns/-/date-fns-4.1.0.tgz",
"integrity": "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==", "integrity": "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==",
"license": "MIT", "license": "MIT",
"peer": true,
"funding": { "funding": {
"type": "github", "type": "github",
"url": "https://github.com/sponsors/kossnocorp" "url": "https://github.com/sponsors/kossnocorp"
@@ -3541,6 +3601,7 @@
"resolved": "https://registry.npmmirror.com/dom7/-/dom7-4.0.6.tgz", "resolved": "https://registry.npmmirror.com/dom7/-/dom7-4.0.6.tgz",
"integrity": "sha512-emjdpPLhpNubapLFdjNL9tP06Sr+GZkrIHEXLWvOGsytACUrkbeIdjO5g77m00BrHTznnlcNqgmn7pCN192TBA==", "integrity": "sha512-emjdpPLhpNubapLFdjNL9tP06Sr+GZkrIHEXLWvOGsytACUrkbeIdjO5g77m00BrHTznnlcNqgmn7pCN192TBA==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"ssr-window": "^4.0.0" "ssr-window": "^4.0.0"
} }
@@ -4059,7 +4120,8 @@
"version": "0.2.0", "version": "0.2.0",
"resolved": "https://registry.npmmirror.com/is-hotkey/-/is-hotkey-0.2.0.tgz", "resolved": "https://registry.npmmirror.com/is-hotkey/-/is-hotkey-0.2.0.tgz",
"integrity": "sha512-UknnZK4RakDmTgz4PI1wIph5yxSs/mvChWs9ifnlXsKuXgWmOkY/hAE0H/k2MIqH0RlRye0i1oC07MCRSD28Mw==", "integrity": "sha512-UknnZK4RakDmTgz4PI1wIph5yxSs/mvChWs9ifnlXsKuXgWmOkY/hAE0H/k2MIqH0RlRye0i1oC07MCRSD28Mw==",
"license": "MIT" "license": "MIT",
"peer": true
}, },
"node_modules/is-url": { "node_modules/is-url": {
"version": "1.2.4", "version": "1.2.4",
@@ -4235,37 +4297,43 @@
"version": "4.3.0", "version": "4.3.0",
"resolved": "https://registry.npmmirror.com/lodash.camelcase/-/lodash.camelcase-4.3.0.tgz", "resolved": "https://registry.npmmirror.com/lodash.camelcase/-/lodash.camelcase-4.3.0.tgz",
"integrity": "sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==", "integrity": "sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==",
"license": "MIT" "license": "MIT",
"peer": true
}, },
"node_modules/lodash.clonedeep": { "node_modules/lodash.clonedeep": {
"version": "4.5.0", "version": "4.5.0",
"resolved": "https://registry.npmmirror.com/lodash.clonedeep/-/lodash.clonedeep-4.5.0.tgz", "resolved": "https://registry.npmmirror.com/lodash.clonedeep/-/lodash.clonedeep-4.5.0.tgz",
"integrity": "sha512-H5ZhCF25riFd9uB5UCkVKo61m3S/xZk1x4wA6yp/L3RFP6Z/eHH1ymQcGLo7J3GMPfm0V/7m1tryHuGVxpqEBQ==", "integrity": "sha512-H5ZhCF25riFd9uB5UCkVKo61m3S/xZk1x4wA6yp/L3RFP6Z/eHH1ymQcGLo7J3GMPfm0V/7m1tryHuGVxpqEBQ==",
"license": "MIT" "license": "MIT",
"peer": true
}, },
"node_modules/lodash.debounce": { "node_modules/lodash.debounce": {
"version": "4.0.8", "version": "4.0.8",
"resolved": "https://registry.npmmirror.com/lodash.debounce/-/lodash.debounce-4.0.8.tgz", "resolved": "https://registry.npmmirror.com/lodash.debounce/-/lodash.debounce-4.0.8.tgz",
"integrity": "sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==", "integrity": "sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==",
"license": "MIT" "license": "MIT",
"peer": true
}, },
"node_modules/lodash.foreach": { "node_modules/lodash.foreach": {
"version": "4.5.0", "version": "4.5.0",
"resolved": "https://registry.npmmirror.com/lodash.foreach/-/lodash.foreach-4.5.0.tgz", "resolved": "https://registry.npmmirror.com/lodash.foreach/-/lodash.foreach-4.5.0.tgz",
"integrity": "sha512-aEXTF4d+m05rVOAUG3z4vZZ4xVexLKZGF0lIxuHZ1Hplpk/3B6Z1+/ICICYRLm7c41Z2xiejbkCkJoTlypoXhQ==", "integrity": "sha512-aEXTF4d+m05rVOAUG3z4vZZ4xVexLKZGF0lIxuHZ1Hplpk/3B6Z1+/ICICYRLm7c41Z2xiejbkCkJoTlypoXhQ==",
"license": "MIT" "license": "MIT",
"peer": true
}, },
"node_modules/lodash.throttle": { "node_modules/lodash.throttle": {
"version": "4.1.1", "version": "4.1.1",
"resolved": "https://registry.npmmirror.com/lodash.throttle/-/lodash.throttle-4.1.1.tgz", "resolved": "https://registry.npmmirror.com/lodash.throttle/-/lodash.throttle-4.1.1.tgz",
"integrity": "sha512-wIkUCfVKpVsWo3JSZlc+8MB5it+2AN5W8J7YVMST30UrvcQNZ1Okbj+rbVniijTWE6FGYy4XJq/rHkas8qJMLQ==", "integrity": "sha512-wIkUCfVKpVsWo3JSZlc+8MB5it+2AN5W8J7YVMST30UrvcQNZ1Okbj+rbVniijTWE6FGYy4XJq/rHkas8qJMLQ==",
"license": "MIT" "license": "MIT",
"peer": true
}, },
"node_modules/lodash.toarray": { "node_modules/lodash.toarray": {
"version": "4.4.0", "version": "4.4.0",
"resolved": "https://registry.npmmirror.com/lodash.toarray/-/lodash.toarray-4.4.0.tgz", "resolved": "https://registry.npmmirror.com/lodash.toarray/-/lodash.toarray-4.4.0.tgz",
"integrity": "sha512-QyffEA3i5dma5q2490+SgCvDN0pXLmRGSyAANuVi0HQ01Pkfr9fuoKQW8wm1wGBnJITs/mS7wQvS6VshUEBFCw==", "integrity": "sha512-QyffEA3i5dma5q2490+SgCvDN0pXLmRGSyAANuVi0HQ01Pkfr9fuoKQW8wm1wGBnJITs/mS7wQvS6VshUEBFCw==",
"license": "MIT" "license": "MIT",
"peer": true
}, },
"node_modules/lucide-vue-next": { "node_modules/lucide-vue-next": {
"version": "0.543.0", "version": "0.543.0",
@@ -4305,6 +4373,7 @@
"resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-14.1.1.tgz", "resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-14.1.1.tgz",
"integrity": "sha512-BuU2qnTti9YKgK5N+IeMubp14ZUKUUw7yeJbkjtosvHiP0AZ5c8IAgEMk79D0eC8F23r4Ac/q8cAIFdm2FtyoA==", "integrity": "sha512-BuU2qnTti9YKgK5N+IeMubp14ZUKUUw7yeJbkjtosvHiP0AZ5c8IAgEMk79D0eC8F23r4Ac/q8cAIFdm2FtyoA==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"argparse": "^2.0.1", "argparse": "^2.0.1",
"entities": "^4.4.0", "entities": "^4.4.0",
@@ -4596,6 +4665,7 @@
} }
], ],
"license": "MIT", "license": "MIT",
"peer": true,
"bin": { "bin": {
"nanoid": "bin/nanoid.js" "nanoid": "bin/nanoid.js"
}, },
@@ -4673,6 +4743,7 @@
"resolved": "https://registry.npmjs.org/pinia/-/pinia-3.0.4.tgz", "resolved": "https://registry.npmjs.org/pinia/-/pinia-3.0.4.tgz",
"integrity": "sha512-l7pqLUFTI/+ESXn6k3nu30ZIzW5E2WZF/LaHJEpoq6ElcLD+wduZoB2kBN19du6K/4FDpPMazY2wJr+IndBtQw==", "integrity": "sha512-l7pqLUFTI/+ESXn6k3nu30ZIzW5E2WZF/LaHJEpoq6ElcLD+wduZoB2kBN19du6K/4FDpPMazY2wJr+IndBtQw==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@vue/devtools-api": "^7.7.7" "@vue/devtools-api": "^7.7.7"
}, },
@@ -5034,7 +5105,8 @@
"version": "0.123.0", "version": "0.123.0",
"resolved": "https://registry.npmmirror.com/slate/-/slate-0.123.0.tgz", "resolved": "https://registry.npmmirror.com/slate/-/slate-0.123.0.tgz",
"integrity": "sha512-Oon3HR/QzJQBjuOUJT1jGGlp8Ff7t3Bkr/rJ2lDqxNT4H+cBnXpEVQ/si6hn1ZCHhD2xY/2N91PQoH/rD7kxTg==", "integrity": "sha512-Oon3HR/QzJQBjuOUJT1jGGlp8Ff7t3Bkr/rJ2lDqxNT4H+cBnXpEVQ/si6hn1ZCHhD2xY/2N91PQoH/rD7kxTg==",
"license": "MIT" "license": "MIT",
"peer": true
}, },
"node_modules/slate-history": { "node_modules/slate-history": {
"version": "0.115.0", "version": "0.115.0",
@@ -5050,6 +5122,7 @@
"resolved": "https://registry.npmmirror.com/snabbdom/-/snabbdom-3.6.3.tgz", "resolved": "https://registry.npmmirror.com/snabbdom/-/snabbdom-3.6.3.tgz",
"integrity": "sha512-W2lHLLw2qR2Vv0DcMmcxXqcfdBaIcoN+y/86SmHv8fn4DazEQSH6KN3TjZcWvwujW56OHiiirsbHWZb4vx/0fg==", "integrity": "sha512-W2lHLLw2qR2Vv0DcMmcxXqcfdBaIcoN+y/86SmHv8fn4DazEQSH6KN3TjZcWvwujW56OHiiirsbHWZb4vx/0fg==",
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=12.17.0" "node": ">=12.17.0"
} }
@@ -5196,6 +5269,7 @@
"integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==",
"devOptional": true, "devOptional": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"peer": true,
"bin": { "bin": {
"tsc": "bin/tsc", "tsc": "bin/tsc",
"tsserver": "bin/tsserver" "tsserver": "bin/tsserver"
@@ -5410,6 +5484,7 @@
"resolved": "https://registry.npmjs.org/vue/-/vue-3.5.34.tgz", "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.34.tgz",
"integrity": "sha512-WdLBG9gm02OgJIG9axd5Hpx0TFLdzVgfG2evFFu8Rur5O/IoGc5cMjnjh3tPL6GnRGsYvUhBSKVPYVcxRKpMCA==", "integrity": "sha512-WdLBG9gm02OgJIG9axd5Hpx0TFLdzVgfG2evFFu8Rur5O/IoGc5cMjnjh3tPL6GnRGsYvUhBSKVPYVcxRKpMCA==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@vue/compiler-dom": "3.5.34", "@vue/compiler-dom": "3.5.34",
"@vue/compiler-sfc": "3.5.34", "@vue/compiler-sfc": "3.5.34",
@@ -5457,6 +5532,7 @@
"resolved": "https://registry.npmjs.org/vue-router/-/vue-router-5.0.6.tgz", "resolved": "https://registry.npmjs.org/vue-router/-/vue-router-5.0.6.tgz",
"integrity": "sha512-9+kmUTGbKMyW9Asoy98IXXYIzrTMT7JDAdpDDeEkorHvybpUvBI2wsrSM5jFOXrFydpzRFJ9vAh+80DN2PGu9w==", "integrity": "sha512-9+kmUTGbKMyW9Asoy98IXXYIzrTMT7JDAdpDDeEkorHvybpUvBI2wsrSM5jFOXrFydpzRFJ9vAh+80DN2PGu9w==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@babel/generator": "^7.28.6", "@babel/generator": "^7.28.6",
"@vue-macros/common": "^3.1.1", "@vue-macros/common": "^3.1.1",
@@ -5709,6 +5785,7 @@
"resolved": "https://registry.npmmirror.com/yjs/-/yjs-13.6.30.tgz", "resolved": "https://registry.npmmirror.com/yjs/-/yjs-13.6.30.tgz",
"integrity": "sha512-vv/9h42eCMC81ZHDFswuu/MKzkl/vyq1BhaNGfHyOonwlG4CJbQF4oiBBJPvfdeCt/PlVDWh7Nov9D34YY09uQ==", "integrity": "sha512-vv/9h42eCMC81ZHDFswuu/MKzkl/vyq1BhaNGfHyOonwlG4CJbQF4oiBBJPvfdeCt/PlVDWh7Nov9D34YY09uQ==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"lib0": "^0.2.99" "lib0": "^0.2.99"
}, },

View File

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

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>

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 http from "utils/http"
import { toProblemListItem } from "admin/transforms"
import type { import type {
AdminProblem, AdminProblem,
Announcement, Announcement,
@@ -30,30 +31,21 @@ export async function getProblemList(
contestID?: string, contestID?: string,
) { ) {
const endpoint = !!contestID ? "admin/contest/problem" : "admin/problem" const endpoint = !!contestID ? "admin/contest/problem" : "admin/problem"
const res = await http.get(endpoint, { const res = await http.get<{ results: AdminProblem[]; total: number }>(
params: { endpoint,
paging: true, {
offset, params: {
limit, paging: true,
keyword, offset,
author, limit,
contest_id: contestID, keyword,
author,
contest_id: contestID,
},
}, },
}) )
return { return {
results: res.data.results.map((result: AdminProblem) => ({ results: res.data.results.map(toProblemListItem),
id: result.id,
_id: result._id,
title: result.title,
username: result.created_by.username,
create_time: result.create_time,
visible: result.visible,
difficulty: result.difficulty,
tags: result.tags,
has_ast_rules: result.has_ast_rules,
allow_flowchart: result.allow_flowchart,
show_flowchart: result.show_flowchart,
})),
total: res.data.total, total: res.data.total,
} }
} }
@@ -133,10 +125,10 @@ export function getContestList(offset = 0, limit = 10, keyword: string) {
export async function uploadImage(file: File): Promise<string> { export async function uploadImage(file: File): Promise<string> {
const form = new window.FormData() const form = new window.FormData()
form.append("image", file) form.append("image", file)
const res: { success: boolean; file_path: string; msg: "Success" } = // 该端点不走 { error, data } 信封,直接返回上传结果
await http.post("admin/upload_image", form, { const res = (await http.post("admin/upload_image", form, {
headers: { "content-type": "multipart/form-data" }, headers: { "content-type": "multipart/form-data" },
}) })) as unknown as { success: boolean; file_path: string; msg: "Success" }
return res.success ? res.file_path : "" return res.success ? res.file_path : ""
} }
@@ -244,17 +236,17 @@ export function deleteComment(id: number) {
} }
export async function getTutorialList() { export async function getTutorialList() {
const res = await http.get("admin/tutorial") const res = await http.get<Tutorial[]>("admin/tutorial")
return res.data return res.data
} }
export async function getTutorial(id: number) { export async function getTutorial(id: number) {
const res = await http.get("admin/tutorial", { params: { id } }) const res = await http.get<Tutorial>("admin/tutorial", { params: { id } })
return res.data return res.data
} }
export async function createTutorial(data: Partial<Tutorial>) { export async function createTutorial(data: Partial<Tutorial>) {
const res = await http.post("admin/tutorial", data) const res = await http.post<Tutorial>("admin/tutorial", data)
return res.data return res.data
} }
@@ -272,10 +264,10 @@ export function setTutorialVisibility(id: number, is_public: boolean) {
} }
export async function getAdminExercises(tutorialId: number) { export async function getAdminExercises(tutorialId: number) {
const res = await http.get("admin/exercise", { const res = await http.get<Exercise[]>("admin/exercise", {
params: { tutorial_id: tutorialId }, params: { tutorial_id: tutorialId },
}) })
return res.data as Exercise[] return res.data
} }
export async function createExercise(data: { export async function createExercise(data: {
@@ -284,8 +276,8 @@ export async function createExercise(data: {
data: object data: object
order: number order: number
}) { }) {
const res = await http.post("admin/exercise", data) const res = await http.post<Exercise>("admin/exercise", data)
return res.data as Exercise return res.data
} }
export async function updateExercise(data: { export async function updateExercise(data: {
@@ -490,3 +482,22 @@ export function getTopACTrend(params: {
}) { }) {
return http.get("admin/problem/top_ac_trend", { params }) return http.get("admin/problem/top_ac_trend", { params })
} }
// AI 学习分析报告
export function getAIReportList(offset = 0, limit = 10, username = "") {
return http.get("admin/ai/reports", {
params: { paging: true, offset, limit, username: username || undefined },
})
}
export function getAIReportDetail(id: number) {
return http.get("admin/ai/reports", { params: { id } })
}
export function pinAIReport(id: number) {
return http.post("admin/ai/reports", { id })
}
export function getPinnedAIReports() {
return http.get("admin/ai/reports", { params: { pinned_only: "true" } })
}

View File

@@ -2,7 +2,7 @@
import { formatISO } from "date-fns" import { formatISO } from "date-fns"
import TextEditor from "shared/components/TextEditor.vue" import TextEditor from "shared/components/TextEditor.vue"
import { parseTime } from "utils/functions" import { parseTime } from "utils/functions"
import { BlankContest } from "utils/types" import type { BlankContest } from "utils/types"
import { createContest, editContest, getContest } from "../api" import { createContest, editContest, getContest } from "../api"
interface Props { interface Props {

View File

@@ -37,7 +37,8 @@ const total = ref(0)
const problems = ref<AdminProblemFiltered[]>([]) const problems = ref<AdminProblemFiltered[]>([])
const nextDisplayID = computed(() => { 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) const ids = problems.value.map((p) => p._id)
if (ids.every((id) => /^\d+$/.test(id))) { if (ids.every((id) => /^\d+$/.test(id))) {
return String(Math.max(...ids.map((id) => parseInt(id))) + 1) return String(Math.max(...ids.map((id) => parseInt(id))) + 1)
@@ -130,7 +131,7 @@ const columns: DataTableColumn<AdminProblemFiltered>[] = [
{ {
title: "选项", title: "选项",
key: "actions", key: "actions",
width: 300, width: 320,
render: (row) => render: (row) =>
h(Actions, { h(Actions, {
problemID: row.id, 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 }} {{ getUserRole(props.user.admin_type).label }}
</n-tag> </n-tag>
<n-tag size="small" v-if="props.user.admin_type === USER_TYPE.STUDENT_ADMIN || props.user.admin_type === USER_TYPE.TEACHER_ADMIN"> <n-tag
size="small"
v-if="
props.user.admin_type === USER_TYPE.STUDENT_ADMIN ||
props.user.admin_type === USER_TYPE.TEACHER_ADMIN
"
>
{{ {{
props.user.problem_permission === PROBLEM_PERMISSION.ALL props.user.problem_permission === PROBLEM_PERMISSION.ALL
? "全部" ? "全部"

View File

@@ -314,7 +314,11 @@ watch(() => [query.page, query.limit, query.type, query.orderBy], listUsers)
<n-input v-model:value="password" /> <n-input v-model:value="password" />
</n-form-item-gi> </n-form-item-gi>
<n-form-item-gi <n-form-item-gi
v-if="!create && (userEditing.admin_type === USER_TYPE.STUDENT_ADMIN || userEditing.admin_type === USER_TYPE.TEACHER_ADMIN)" v-if="
!create &&
(userEditing.admin_type === USER_TYPE.STUDENT_ADMIN ||
userEditing.admin_type === USER_TYPE.TEACHER_ADMIN)
"
:span="1" :span="1"
label="出题权限" label="出题权限"
> >

View File

@@ -65,9 +65,7 @@ router.beforeEach(async (to, from, next) => {
next("/") next("/")
return return
} }
} else if ( } else if (to.matched.some((record) => record.meta.requiresTeacherAdmin)) {
to.matched.some((record) => record.meta.requiresTeacherAdmin)
) {
if (!userStore.isTeacherOrAbove) { if (!userStore.isTeacherOrAbove) {
next("/") next("/")
return return

View File

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

View File

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

View File

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

View File

@@ -3,6 +3,7 @@ import { h } from "vue"
import { formatISO, sub, type Duration } from "date-fns" import { formatISO, sub, type Duration } from "date-fns"
import { getClassPK } from "oj/api" import { getClassPK } from "oj/api"
import { useConfigStore } from "shared/store/config" import { useConfigStore } from "shared/store/config"
import { useUserStore } from "shared/store/user"
import { Icon } from "@iconify/vue" import { Icon } from "@iconify/vue"
import { Bar, Radar } from "vue-chartjs" import { Bar, Radar } from "vue-chartjs"
import { useBreakpoints } from "shared/composables/breakpoints" import { useBreakpoints } from "shared/composables/breakpoints"
@@ -41,6 +42,7 @@ ChartJS.register(
) )
const configStore = useConfigStore() const configStore = useConfigStore()
const { isTeacherOrAbove } = useUserStore()
const message = useMessage() const message = useMessage()
const { isDesktop } = useBreakpoints() const { isDesktop } = useBreakpoints()
@@ -162,7 +164,8 @@ async function analyzeWithAI() {
aiController = controller aiController = controller
const timeRangeLabel = const timeRangeLabel =
timeRangeOptions.find((o) => o.value === duration.value)?.label ?? "全部时间" timeRangeOptions.find((o) => o.value === duration.value)?.label ??
"全部时间"
showAIModal.value = true showAIModal.value = true
aiContent.value = "" aiContent.value = ""
@@ -193,7 +196,11 @@ async function analyzeWithAI() {
if (event === "end" && !hasStarted) aiLoading.value = false if (event === "end" && !hasStarted) aiLoading.value = false
}, },
onMessage(payload) { onMessage(payload) {
const parsed = payload as { type?: string; content?: string; message?: string } const parsed = payload as {
type?: string
content?: string
message?: string
}
if (parsed.type === "delta" && parsed.content) { if (parsed.type === "delta" && parsed.content) {
if (!hasStarted) { if (!hasStarted) {
hasStarted = true hasStarted = true
@@ -556,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 = { const radarChartOptions = {
responsive: true, responsive: true,
maintainAspectRatio: false, maintainAspectRatio: false,
@@ -638,6 +788,7 @@ const radarChartOptions = {
开始PK 开始PK
</n-button> </n-button>
<n-button <n-button
v-if="isTeacherOrAbove"
type="info" type="info"
@click="analyzeWithAI" @click="analyzeWithAI"
:loading="aiLoading" :loading="aiLoading"
@@ -1030,7 +1181,6 @@ const radarChartOptions = {
</n-gi> </n-gi>
</n-grid> </n-grid>
</n-card> </n-card>
</template> </template>
<!-- 对比表格 --> <!-- 对比表格 -->
@@ -1039,151 +1189,7 @@ const radarChartOptions = {
title="对比表格" title="对比表格"
style="margin-top: 20px" style="margin-top: 20px"
> >
<n-data-table <n-data-table :data="comparisons" :columns="tableColumns" />
:data="comparisons"
:columns="[
{
title: '排名',
key: 'rank',
render: (_, index) => getRankColor(index).text,
width: 80,
},
{
title: '综合分',
key: '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-card> </n-card>
</n-flex> </n-flex>
</n-card> </n-card>

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,7 +1,12 @@
<template> <template>
<div class="learn-container"> <div class="learn-container">
<!-- 桌面端布局 --> <!-- 桌面端布局 -->
<n-grid :cols="5" :x-gap="16" v-if="tutorial.id && isDesktop" class="learn-grid"> <n-grid
:cols="5"
:x-gap="16"
v-if="tutorial.id && isDesktop"
class="learn-grid"
>
<n-gi :span="1" class="learn-col"> <n-gi :span="1" class="learn-col">
<n-card title="教程目录" :bordered="false" size="small"> <n-card title="教程目录" :bordered="false" size="small">
<n-list hoverable clickable> <n-list hoverable clickable>
@@ -51,7 +56,11 @@
class="code-card" class="code-card"
content-style="height: calc(100% - 44px); padding: 0;" content-style="height: calc(100% - 44px); padding: 0;"
> >
<CodeEditor language="Python3" v-model="tutorial.code" height="100%" /> <CodeEditor
language="Python3"
v-model="tutorial.code"
height="100%"
/>
</n-card> </n-card>
</n-gi> </n-gi>
</n-grid> </n-grid>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

@@ -1,4 +1,4 @@
import { RouteRecordRaw } from "vue-router" import type { RouteRecordRaw } from "vue-router"
export const ojs: RouteRecordRaw = { export const ojs: RouteRecordRaw = {
path: "/", path: "/",
@@ -315,5 +315,11 @@ export const admins: RouteRecordRaw = {
props: true, props: true,
meta: { requiresTeacherAdmin: true }, meta: { requiresTeacherAdmin: true },
}, },
{
path: "ai/reports",
name: "admin ai reports",
component: () => import("admin/ai/list.vue"),
meta: { requiresTeacherAdmin: true },
},
], ],
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -58,6 +58,15 @@ const options = computed<MenuOption[]>(() => {
), ),
key: "admin problemset list", key: "admin problemset list",
}, },
{
label: () =>
h(
RouterLink,
{ to: "/admin/ai/reports" },
{ default: () => "AI报告" },
),
key: "admin ai reports",
},
) )
} }
@@ -132,6 +141,15 @@ const options = computed<MenuOption[]>(() => {
), ),
key: "admin tutorial list", key: "admin tutorial list",
}, },
{
label: () =>
h(
RouterLink,
{ to: "/admin/ai/reports" },
{ default: () => "AI报告" },
),
key: "admin ai reports",
},
) )
} }
@@ -152,6 +170,7 @@ const active = computed(() => {
if (path.startsWith("/admin/comment")) return "admin comment list" if (path.startsWith("/admin/comment")) return "admin comment list"
if (path.startsWith("/admin/announcement")) return "admin announcement list" if (path.startsWith("/admin/announcement")) return "admin announcement list"
if (path.startsWith("/admin/tutorial")) return "admin tutorial list" if (path.startsWith("/admin/tutorial")) return "admin tutorial list"
if (path.startsWith("/admin/ai")) return "admin ai reports"
return route.name as string return route.name as string
}) })

View File

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

View File

@@ -33,7 +33,11 @@ export interface Profile {
submission_number: number submission_number: number
} }
export type UserAdminType = "Regular User" | "Student Admin" | "Teacher Admin" | "Super Admin" export type UserAdminType =
| "Regular User"
| "Student Admin"
| "Teacher Admin"
| "Super Admin"
export interface User { export interface User {
id: number id: number

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",
})
})