Compare commits
115 Commits
73bde644f5
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| df45b8f545 | |||
| be0bc87d47 | |||
| 43a5c923b4 | |||
| 62d75b6e06 | |||
| bd4461d2bc | |||
| 12342f7f79 | |||
| dad65c4bef | |||
| d16ee709b2 | |||
| 77db837af3 | |||
| 4b05086ba1 | |||
| 31d7f4d274 | |||
| 45a0638b7e | |||
| 9920bc4aed | |||
| 6a97c7ee6e | |||
| fe51ad94cc | |||
| 0a0d53124d | |||
| f9d7c2ff92 | |||
| 324e85d2c0 | |||
| 4ef2738afd | |||
| 89a6e79489 | |||
| 1dac639003 | |||
| 9344a6e648 | |||
| d9a1ee28c6 | |||
| 0b2383bb48 | |||
| cd5ab41981 | |||
| 8549b6c177 | |||
| 4aa0072567 | |||
| 41c4fdbc5c | |||
| 33b6e35d6b | |||
| b3edf5383a | |||
| 2e31040b79 | |||
| f6232da3ba | |||
| e33ef710af | |||
| 0c165d61ff | |||
| e9a416b6b4 | |||
| 39dbe143cb | |||
| d8363b997a | |||
| 7f51544615 | |||
| d1875619ec | |||
| aeadb46ffa | |||
| b510c305d5 | |||
| cd81fd1e10 | |||
| a02e6df604 | |||
| 2fbcbd07c5 | |||
| 8444d6e21a | |||
| 0460a2f7a0 | |||
| 80e916e817 | |||
| 5349e8ed6d | |||
| cb7743367a | |||
| c1678c9060 | |||
| 7e784be061 | |||
| 714e07d514 | |||
| bf69a355fe | |||
| e8bc91bd59 | |||
| f970bb955d | |||
| 82987ffd54 | |||
| fb2bd8981b | |||
| 3a33c8ff3a | |||
| 11e447d4b7 | |||
| 7547f896f6 | |||
| 18fc65f2ce | |||
| 8e2fcceb8f | |||
| 873b7c875b | |||
| 1296251c80 | |||
| 9f18ba900a | |||
| c898b94174 | |||
| dcb1171210 | |||
| 1d28d2c7c2 | |||
| d05f4a8918 | |||
| ad18800ca6 | |||
| 46c3176cd2 | |||
| 5a378b095c | |||
| eee7c63f97 | |||
| 9736fdf883 | |||
| 340a58fc17 | |||
| a2e8c6b274 | |||
| 6403b69294 | |||
| 9315963cce | |||
| c1c6e75a7b | |||
| c4ac0f06cb | |||
| 776cfdf9de | |||
| 2cf971b40b | |||
| 9e63016231 | |||
| 2e285e29f0 | |||
| 5984aa715d | |||
| ee14e4065c | |||
| 16588d2965 | |||
| 2850887ce3 | |||
| 9093ba56e6 | |||
| 5f92aeaea4 | |||
| ffa55cb92d | |||
| 332ab2f966 | |||
| ef7aa44577 | |||
| a72317173b | |||
| 0ac203806c | |||
| e7e270b928 | |||
| 874a6fbe90 | |||
| 06738f6e29 | |||
| 8047a7af8e | |||
| 2912c7495c | |||
| 60851e3255 | |||
| f57c2c4137 | |||
| 09475db932 | |||
| cf2f5eec7d | |||
| 5c037bb438 | |||
| 3b7b518109 | |||
| a48baddcc3 | |||
| 631292c33b | |||
| 6485861c57 | |||
| aaf53e3a0c | |||
| 07c86fe969 | |||
| 3c90bedff6 | |||
| d25f126710 | |||
| a70de90e41 | |||
| 1a1759518d |
2
.env
2
.env
@@ -4,4 +4,4 @@ PUBLIC_OJ_URL=http://localhost:8000
|
||||
PUBLIC_CODE_URL=http://localhost:3000
|
||||
PUBLIC_JUDGE0_URL=https://judge0api.xuyue.cc
|
||||
PUBLIC_SIGNALING_URL=ws://10.13.114.114:8085
|
||||
PUBLIC_WS_URL=ws://localhost:8001/ws
|
||||
PUBLIC_WS_URL=ws://localhost:8000/ws
|
||||
@@ -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.
|
||||
@@ -0,0 +1,34 @@
|
||||
# Submit Formatting Button State
|
||||
|
||||
## Goal
|
||||
|
||||
Make the code submission button reflect the automatic formatting request that runs before submission.
|
||||
|
||||
## Behavior
|
||||
|
||||
- For Python3, C, and C++, the button displays `格式化中` while the formatting API request is pending.
|
||||
- During formatting, the button uses the existing loading icon and is disabled to prevent duplicate submissions.
|
||||
- After formatting succeeds, the existing submission flow continues and the button can display `正在提交`.
|
||||
- A formatting error stops submission and clears the formatting state before showing the existing warning.
|
||||
- A formatter server or network failure keeps the existing fallback behavior: clear the formatting state and submit the original code.
|
||||
- Languages without automatic formatting skip this state and submit directly.
|
||||
- Existing button labels and judging/cooldown behavior remain unchanged.
|
||||
|
||||
## Implementation
|
||||
|
||||
Add a component-local `isFormatting` ref in `SubmitCode.vue`.
|
||||
|
||||
- Include it in `submitDisabled`.
|
||||
- Give it priority in `submitLabel`, using `格式化中`.
|
||||
- Include it in the loading-icon condition.
|
||||
- Set it immediately before `formatCode`.
|
||||
- Clear it in a `finally` block so every formatter outcome restores the button state.
|
||||
|
||||
The state remains local because it is transient UI state owned only by the submission button.
|
||||
|
||||
## Verification
|
||||
|
||||
The frontend currently has no automated test suite. Verify with:
|
||||
|
||||
- TypeScript production build.
|
||||
- Manual inspection of the state transitions for successful formatting, formatting errors, formatter infrastructure failures, and languages that do not format.
|
||||
839
package-lock.json
generated
839
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -23,16 +23,19 @@
|
||||
"@vueuse/router": "^14.3.0",
|
||||
"@wangeditor-next/editor": "^5.7.0",
|
||||
"@wangeditor-next/editor-for-vue": "^5.1.14",
|
||||
"axios": "^1.16.0",
|
||||
"axios": "^1.16.1",
|
||||
"canvas-confetti": "^1.9.4",
|
||||
"chart.js": "^4.5.1",
|
||||
"chartjs-chart-wordcloud": "^4.4.5",
|
||||
"client-zip": "^2.5.0",
|
||||
"codemirror": "^6.0.2",
|
||||
"copy-text-to-clipboard": "^3.2.2",
|
||||
"date-fns": "^4.1.0",
|
||||
"fflate": "^0.8.2",
|
||||
"highlight.js": "^11.11.1",
|
||||
"md-editor-v3": "^6.5.0",
|
||||
"mermaid": "^11.14.0",
|
||||
"mermaid": "^11.15.0",
|
||||
"mermaid-legacy": "npm:mermaid@^9.1.7",
|
||||
"naive-ui": "^2.44.1",
|
||||
"nanoid": "^5.1.11",
|
||||
"normalize.css": "^8.0.1",
|
||||
|
||||
@@ -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>
|
||||
@@ -74,6 +74,9 @@ const config: ReturnType<typeof defineConfig> = defineConfig(({ envMode }) => {
|
||||
},
|
||||
define: publicVars,
|
||||
},
|
||||
output: {
|
||||
polyfill: "usage",
|
||||
},
|
||||
performance: {
|
||||
chunkSplit: {
|
||||
strategy: "split-by-module",
|
||||
|
||||
206
src/admin/ai/list.vue
Normal file
206
src/admin/ai/list.vue
Normal 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>
|
||||
@@ -1,5 +1,6 @@
|
||||
import http from "utils/http"
|
||||
import {
|
||||
import { toProblemListItem } from "admin/transforms"
|
||||
import type {
|
||||
AdminProblem,
|
||||
Announcement,
|
||||
AnnouncementEdit,
|
||||
@@ -30,25 +31,21 @@ export async function getProblemList(
|
||||
contestID?: string,
|
||||
) {
|
||||
const endpoint = !!contestID ? "admin/contest/problem" : "admin/problem"
|
||||
const res = await http.get(endpoint, {
|
||||
params: {
|
||||
paging: true,
|
||||
offset,
|
||||
limit,
|
||||
keyword,
|
||||
author,
|
||||
contest_id: contestID,
|
||||
const res = await http.get<{ results: AdminProblem[]; total: number }>(
|
||||
endpoint,
|
||||
{
|
||||
params: {
|
||||
paging: true,
|
||||
offset,
|
||||
limit,
|
||||
keyword,
|
||||
author,
|
||||
contest_id: contestID,
|
||||
},
|
||||
},
|
||||
})
|
||||
)
|
||||
return {
|
||||
results: res.data.results.map((result: AdminProblem) => ({
|
||||
id: result.id,
|
||||
_id: result._id,
|
||||
title: result.title,
|
||||
username: result.created_by.username,
|
||||
create_time: result.create_time,
|
||||
visible: result.visible,
|
||||
})),
|
||||
results: res.data.results.map(toProblemListItem),
|
||||
total: res.data.total,
|
||||
}
|
||||
}
|
||||
@@ -128,10 +125,10 @@ export function getContestList(offset = 0, limit = 10, keyword: string) {
|
||||
export async function uploadImage(file: File): Promise<string> {
|
||||
const form = new window.FormData()
|
||||
form.append("image", file)
|
||||
const res: { success: boolean; file_path: string; msg: "Success" } =
|
||||
await http.post("admin/upload_image", form, {
|
||||
headers: { "content-type": "multipart/form-data" },
|
||||
})
|
||||
// 该端点不走 { error, data } 信封,直接返回上传结果
|
||||
const res = (await http.post("admin/upload_image", form, {
|
||||
headers: { "content-type": "multipart/form-data" },
|
||||
})) as unknown as { success: boolean; file_path: string; msg: "Success" }
|
||||
return res.success ? res.file_path : ""
|
||||
}
|
||||
|
||||
@@ -160,6 +157,10 @@ export function editContest(contest: Contest | BlankContest) {
|
||||
return http.put("admin/contest", contest)
|
||||
}
|
||||
|
||||
export function cloneContest(contest_id: number) {
|
||||
return http.post("admin/contest/clone", { contest_id })
|
||||
}
|
||||
|
||||
export function getContest(id: string) {
|
||||
return http.get<Contest & { password: string }>("admin/contest", {
|
||||
params: { id },
|
||||
@@ -235,17 +236,17 @@ export function deleteComment(id: number) {
|
||||
}
|
||||
|
||||
export async function getTutorialList() {
|
||||
const res = await http.get("admin/tutorial")
|
||||
const res = await http.get<Tutorial[]>("admin/tutorial")
|
||||
return res.data
|
||||
}
|
||||
|
||||
export async function getTutorial(id: number) {
|
||||
const res = await http.get("admin/tutorial", { params: { id } })
|
||||
const res = await http.get<Tutorial>("admin/tutorial", { params: { id } })
|
||||
return res.data
|
||||
}
|
||||
|
||||
export async function createTutorial(data: Partial<Tutorial>) {
|
||||
const res = await http.post("admin/tutorial", data)
|
||||
const res = await http.post<Tutorial>("admin/tutorial", data)
|
||||
return res.data
|
||||
}
|
||||
|
||||
@@ -263,10 +264,10 @@ export function setTutorialVisibility(id: number, is_public: boolean) {
|
||||
}
|
||||
|
||||
export async function getAdminExercises(tutorialId: number) {
|
||||
const res = await http.get("admin/exercise", {
|
||||
const res = await http.get<Exercise[]>("admin/exercise", {
|
||||
params: { tutorial_id: tutorialId },
|
||||
})
|
||||
return res.data as Exercise[]
|
||||
return res.data
|
||||
}
|
||||
|
||||
export async function createExercise(data: {
|
||||
@@ -275,8 +276,8 @@ export async function createExercise(data: {
|
||||
data: object
|
||||
order: number
|
||||
}) {
|
||||
const res = await http.post("admin/exercise", data)
|
||||
return res.data as Exercise
|
||||
const res = await http.post<Exercise>("admin/exercise", data)
|
||||
return res.data
|
||||
}
|
||||
|
||||
export async function updateExercise(data: {
|
||||
@@ -474,6 +475,29 @@ export function getStuckProblems() {
|
||||
return http.get("admin/problem/stuck")
|
||||
}
|
||||
|
||||
export function getTopACTrend(params: { since_year: number; until_year: number; min_per_year: number }) {
|
||||
export function getTopACTrend(params: {
|
||||
since_year: number
|
||||
until_year: number
|
||||
min_per_year: number
|
||||
}) {
|
||||
return http.get("admin/problem/top_ac_trend", { params })
|
||||
}
|
||||
|
||||
// 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" } })
|
||||
}
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
<script lang="ts" setup>
|
||||
import { Contest } from "utils/types"
|
||||
import { cloneContest } from "../../api"
|
||||
|
||||
interface Props {
|
||||
contest: Contest
|
||||
}
|
||||
const props = defineProps<Props>()
|
||||
const router = useRouter()
|
||||
const message = useMessage()
|
||||
|
||||
function goEdit() {
|
||||
router.push({
|
||||
@@ -28,6 +30,19 @@ function goACMHelper() {
|
||||
})
|
||||
}
|
||||
|
||||
async function clone() {
|
||||
try {
|
||||
const res = await cloneContest(props.contest.id)
|
||||
message.success("复制成功")
|
||||
router.push({
|
||||
name: "admin contest edit",
|
||||
params: { contestID: res.data.id },
|
||||
})
|
||||
} catch {
|
||||
message.error("复制失败")
|
||||
}
|
||||
}
|
||||
|
||||
const isACM = computed(() => props.contest.rule_type === "ACM")
|
||||
</script>
|
||||
<template>
|
||||
@@ -47,6 +62,7 @@ const isACM = computed(() => props.contest.rule_type === "ACM")
|
||||
<n-button size="small" type="info" secondary @click="goEdit">
|
||||
编辑
|
||||
</n-button>
|
||||
<n-button size="small" secondary @click="clone"> 复制 </n-button>
|
||||
</n-flex>
|
||||
</template>
|
||||
<style scoped></style>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import { formatISO } from "date-fns"
|
||||
import TextEditor from "shared/components/TextEditor.vue"
|
||||
import { parseTime } from "utils/functions"
|
||||
import { BlankContest } from "utils/types"
|
||||
import type { BlankContest } from "utils/types"
|
||||
import { createContest, editContest, getContest } from "../api"
|
||||
|
||||
interface Props {
|
||||
@@ -56,9 +56,7 @@ const contest = reactive<BlankContest & { id: number }>({
|
||||
tag: "练习",
|
||||
start_time: "",
|
||||
end_time: "",
|
||||
rule_type: "ACM",
|
||||
password: "",
|
||||
real_time_rank: true,
|
||||
visible: false,
|
||||
allowed_ip_ranges: [],
|
||||
})
|
||||
@@ -79,9 +77,7 @@ async function getContestDetail() {
|
||||
contest.tag = data.tag
|
||||
contest.start_time = data.start_time
|
||||
contest.end_time = data.end_time
|
||||
contest.rule_type = "ACM"
|
||||
contest.password = data.password
|
||||
contest.real_time_rank = true
|
||||
contest.visible = data.visible
|
||||
contest.allowed_ip_ranges = []
|
||||
|
||||
|
||||
@@ -4,7 +4,8 @@ import ContestTitle from "shared/components/ContestTitle.vue"
|
||||
import ContestType from "shared/components/ContestType.vue"
|
||||
import Pagination from "shared/components/Pagination.vue"
|
||||
import { CONTEST_STATUS } from "utils/constants"
|
||||
import { Contest } from "utils/types"
|
||||
import { parseTime } from "utils/functions"
|
||||
import type { Contest } from "utils/types"
|
||||
import { editContest, getContestList } from "../api"
|
||||
import Actions from "./components/Actions.vue"
|
||||
|
||||
@@ -26,24 +27,24 @@ const columns: DataTableColumn<Contest>[] = [
|
||||
{
|
||||
title: "比赛",
|
||||
key: "title",
|
||||
minWidth: 250,
|
||||
minWidth: 200,
|
||||
render: (row) => h(ContestTitle, { contest: row }),
|
||||
},
|
||||
{
|
||||
title: "标签",
|
||||
key: "tag",
|
||||
width: 80,
|
||||
width: 100,
|
||||
},
|
||||
{
|
||||
title: "类型",
|
||||
key: "contest_type",
|
||||
width: 80,
|
||||
width: 100,
|
||||
render: (row) => h(ContestType, { contest: row, size: "small" }),
|
||||
},
|
||||
{
|
||||
title: "状态",
|
||||
key: "status",
|
||||
width: 80,
|
||||
width: 100,
|
||||
render: (row) =>
|
||||
h(
|
||||
NTag,
|
||||
@@ -51,10 +52,22 @@ const columns: DataTableColumn<Contest>[] = [
|
||||
() => CONTEST_STATUS[row.status]["name"],
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "创建者",
|
||||
key: "created_by",
|
||||
width: 120,
|
||||
render: (row) => row.created_by.username,
|
||||
},
|
||||
{
|
||||
title: "创建时间",
|
||||
key: "create_time",
|
||||
width: 160,
|
||||
render: (row) => parseTime(row.create_time, "YYYY-MM-DD HH:mm"),
|
||||
},
|
||||
{
|
||||
title: "可见",
|
||||
key: "visible",
|
||||
width: 60,
|
||||
width: 100,
|
||||
render: (row) =>
|
||||
h(NSwitch, {
|
||||
value: row.visible,
|
||||
@@ -66,7 +79,7 @@ const columns: DataTableColumn<Contest>[] = [
|
||||
{
|
||||
title: "选项",
|
||||
key: "actions",
|
||||
width: 220,
|
||||
width: 300,
|
||||
render: (row) => h(Actions, { contest: row }),
|
||||
},
|
||||
]
|
||||
|
||||
@@ -47,7 +47,7 @@ const minPerYearOptions = [
|
||||
]
|
||||
|
||||
const sinceYear = ref(2023)
|
||||
const untilYear = ref(new Date().getFullYear()-1)
|
||||
const untilYear = ref(new Date().getFullYear() - 1)
|
||||
const minPerYear = ref(100)
|
||||
const loading = ref(false)
|
||||
const data = ref<ProblemTrend[]>([])
|
||||
@@ -126,7 +126,11 @@ function getChartOptions(problem: ProblemTrend) {
|
||||
async function fetchData() {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await getTopACTrend({ since_year: sinceYear.value, until_year: untilYear.value, min_per_year: minPerYear.value })
|
||||
const res = await getTopACTrend({
|
||||
since_year: sinceYear.value,
|
||||
until_year: untilYear.value,
|
||||
min_per_year: minPerYear.value,
|
||||
})
|
||||
data.value = res.data
|
||||
} finally {
|
||||
loading.value = false
|
||||
@@ -171,7 +175,11 @@ onMounted(fetchData)
|
||||
</div>
|
||||
<div v-else class="grid">
|
||||
<div v-for="problem in data" :key="problem.problem_id" class="chart-card">
|
||||
<Line :data="getChartData(problem)" :options="getChartOptions(problem)" :plugins="[acLabelPlugin]" />
|
||||
<Line
|
||||
:data="getChartData(problem)"
|
||||
:options="getChartOptions(problem)"
|
||||
:plugins="[acLabelPlugin]"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</n-spin>
|
||||
|
||||
@@ -4,13 +4,14 @@ import { addProblemForContest } from "admin/api"
|
||||
interface Props {
|
||||
problemID: number
|
||||
contestID: string
|
||||
nextDisplayId?: string
|
||||
}
|
||||
const props = defineProps<Props>()
|
||||
const emit = defineEmits(["added"])
|
||||
|
||||
const message = useMessage()
|
||||
|
||||
const displayID = ref("")
|
||||
const displayID = ref(props.nextDisplayId || "")
|
||||
|
||||
async function addProblem() {
|
||||
if (!displayID.value) return
|
||||
|
||||
397
src/admin/problem/components/AstRulesEditor.vue
Normal file
397
src/admin/problem/components/AstRulesEditor.vue
Normal file
@@ -0,0 +1,397 @@
|
||||
<script setup lang="ts">
|
||||
import type { LANGUAGE } from "utils/types"
|
||||
|
||||
interface AstRule {
|
||||
engine: string
|
||||
target?: string
|
||||
label?: string
|
||||
exact?: number
|
||||
min?: number
|
||||
max?: number
|
||||
message: string
|
||||
}
|
||||
|
||||
interface Props {
|
||||
modelValue: { [key: string]: AstRule[] } | null
|
||||
languages: LANGUAGE[]
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
const emit = defineEmits<{
|
||||
(e: "update:modelValue", value: { [key: string]: AstRule[] } | null): void
|
||||
}>()
|
||||
|
||||
const activeTab = ref(props.languages[0] || "Python3")
|
||||
|
||||
const ENGINE_OPTIONS: SelectOption[] = [
|
||||
{
|
||||
label: "节点检查",
|
||||
type: "group",
|
||||
key: "node_group",
|
||||
children: [
|
||||
{ label: "必须存在", value: "must_exist_node" },
|
||||
{ label: "不能存在", value: "must_not_exist_node" },
|
||||
{ label: "出现次数", value: "count_node" },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "函数调用",
|
||||
type: "group",
|
||||
key: "func_group",
|
||||
children: [
|
||||
{ label: "必须调用函数", value: "must_call_function" },
|
||||
{ label: "不能调用函数", value: "must_not_call_function" },
|
||||
{ label: "函数调用次数", value: "count_function_call" },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "方法调用",
|
||||
type: "group",
|
||||
key: "method_group",
|
||||
children: [
|
||||
{ label: "必须调用方法", value: "must_call_method" },
|
||||
{ label: "不能调用方法", value: "must_not_call_method" },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "运算符",
|
||||
type: "group",
|
||||
key: "op_group",
|
||||
children: [{ label: "必须使用运算符", value: "must_use_operator" }],
|
||||
},
|
||||
]
|
||||
|
||||
const NODE_TARGET_OPTIONS: SelectOption[] = [
|
||||
{ label: "for 循环", value: "for_loop" },
|
||||
{ label: "while 循环", value: "while_loop" },
|
||||
{ label: "if 条件", value: "if_statement" },
|
||||
{ label: "else 子句", value: "else_clause" },
|
||||
{ label: "函数定义", value: "function_definition" },
|
||||
{ label: "return 语句", value: "return" },
|
||||
{ label: "break 语句", value: "break" },
|
||||
{ label: "continue 语句", value: "continue" },
|
||||
{ label: "列表推导式", value: "list_comprehension" },
|
||||
{ label: "列表", value: "list_literal" },
|
||||
{ label: "字典", value: "dict_literal" },
|
||||
{ label: "集合", value: "set_literal" },
|
||||
{ label: "f-string", value: "f_string" },
|
||||
{ label: "try-except", value: "try_except" },
|
||||
{ label: "类定义", value: "class_definition" },
|
||||
]
|
||||
|
||||
const OPERATOR_TARGET_OPTIONS: SelectOption[] = [
|
||||
{ label: "+", value: "+" },
|
||||
{ label: "-", value: "-" },
|
||||
{ label: "*", value: "*" },
|
||||
{ label: "/", value: "/" },
|
||||
{ label: "//", value: "//" },
|
||||
{ label: "%", value: "%" },
|
||||
{ label: "**", value: "**" },
|
||||
{ label: "+=", value: "+=" },
|
||||
{ label: "-=", value: "-=" },
|
||||
{ label: "==", value: "==" },
|
||||
{ label: "!=", value: "!=" },
|
||||
{ label: ">", value: ">" },
|
||||
{ label: ">=", value: ">=" },
|
||||
{ label: "<", value: "<" },
|
||||
{ label: "<=", value: "<=" },
|
||||
{ label: "and / &&", value: "and" },
|
||||
{ label: "or / ||", value: "or" },
|
||||
{ label: "not / !", value: "not" },
|
||||
]
|
||||
|
||||
const NODE_ENGINES = ["must_exist_node", "must_not_exist_node", "count_node"]
|
||||
const FUNCTION_ENGINES = [
|
||||
"must_call_function",
|
||||
"must_not_call_function",
|
||||
"count_function_call",
|
||||
]
|
||||
const METHOD_ENGINES = ["must_call_method", "must_not_call_method"]
|
||||
const OPERATOR_ENGINES = ["must_use_operator"]
|
||||
const COUNT_ENGINES = ["count_node", "count_function_call"]
|
||||
|
||||
function isNodeEngine(engine: string) {
|
||||
return NODE_ENGINES.includes(engine)
|
||||
}
|
||||
function isFunctionEngine(engine: string) {
|
||||
return FUNCTION_ENGINES.includes(engine)
|
||||
}
|
||||
function isMethodEngine(engine: string) {
|
||||
return METHOD_ENGINES.includes(engine)
|
||||
}
|
||||
function isOperatorEngine(engine: string) {
|
||||
return OPERATOR_ENGINES.includes(engine)
|
||||
}
|
||||
function isCountEngine(engine: string) {
|
||||
return COUNT_ENGINES.includes(engine)
|
||||
}
|
||||
|
||||
const COUNT_MODE_OPTIONS: SelectOption[] = [
|
||||
{ label: "精确", value: "exact" },
|
||||
{ label: "范围", value: "range" },
|
||||
]
|
||||
|
||||
function getCountMode(rule: AstRule): "exact" | "range" {
|
||||
return rule.exact !== undefined ? "exact" : "range"
|
||||
}
|
||||
|
||||
function updateCountMode(lang: string, index: number, mode: "exact" | "range") {
|
||||
const rules = [...getRulesForLang(lang)]
|
||||
const rule = { ...rules[index] }
|
||||
if (mode === "exact") {
|
||||
rule.exact = rule.min ?? 1
|
||||
delete rule.min
|
||||
delete rule.max
|
||||
} else {
|
||||
delete rule.exact
|
||||
}
|
||||
rules[index] = rule
|
||||
updateRules(lang, rules)
|
||||
}
|
||||
|
||||
function updateExactCount(lang: string, index: number, v: number | null) {
|
||||
const rules = [...getRulesForLang(lang)]
|
||||
const rule = { ...rules[index] }
|
||||
if (v === null) delete rule.exact
|
||||
else rule.exact = v
|
||||
rules[index] = rule
|
||||
updateRules(lang, rules)
|
||||
}
|
||||
|
||||
function needsTargetDropdown(engine: string) {
|
||||
return isNodeEngine(engine)
|
||||
}
|
||||
function needsTargetInput(engine: string) {
|
||||
return isFunctionEngine(engine) || isMethodEngine(engine)
|
||||
}
|
||||
function needsOperatorDropdown(engine: string) {
|
||||
return isOperatorEngine(engine)
|
||||
}
|
||||
|
||||
function getRulesForLang(lang: string): AstRule[] {
|
||||
if (!props.modelValue) return []
|
||||
return props.modelValue[lang] || []
|
||||
}
|
||||
|
||||
function updateRules(lang: string, rules: AstRule[]) {
|
||||
const current = { ...(props.modelValue || {}) }
|
||||
if (rules.length === 0) {
|
||||
delete current[lang]
|
||||
} else {
|
||||
current[lang] = rules
|
||||
}
|
||||
emit("update:modelValue", Object.keys(current).length > 0 ? current : null)
|
||||
}
|
||||
|
||||
function getTargetLabel(engine: string, target: string): string | undefined {
|
||||
if (isNodeEngine(engine))
|
||||
return (NODE_TARGET_OPTIONS.find((o) => o.value === target) as any)?.label
|
||||
if (isOperatorEngine(engine))
|
||||
return (OPERATOR_TARGET_OPTIONS.find((o) => o.value === target) as any)
|
||||
?.label
|
||||
return undefined
|
||||
}
|
||||
|
||||
function addRule(lang: string) {
|
||||
const rules = [...getRulesForLang(lang)]
|
||||
rules.push({
|
||||
engine: "must_exist_node",
|
||||
target: "for_loop",
|
||||
label: "for 循环",
|
||||
message: "",
|
||||
})
|
||||
updateRules(lang, rules)
|
||||
}
|
||||
|
||||
function removeRule(lang: string, index: number) {
|
||||
const rules = [...getRulesForLang(lang)]
|
||||
rules.splice(index, 1)
|
||||
updateRules(lang, rules)
|
||||
}
|
||||
|
||||
function updateRule(lang: string, index: number, field: string, value: any) {
|
||||
const rules = [...getRulesForLang(lang)]
|
||||
const rule = { ...rules[index] }
|
||||
|
||||
if (field === "engine") {
|
||||
rule.engine = value
|
||||
if (isNodeEngine(value)) {
|
||||
rule.target = "for_loop"
|
||||
rule.label = "for 循环"
|
||||
} else if (isOperatorEngine(value)) {
|
||||
rule.target = "+"
|
||||
rule.label = "+"
|
||||
} else {
|
||||
rule.target = ""
|
||||
delete rule.label
|
||||
}
|
||||
delete rule.min
|
||||
delete rule.max
|
||||
delete rule.exact
|
||||
} else if (field === "target") {
|
||||
rule.target = value
|
||||
const lbl = getTargetLabel(rule.engine, value)
|
||||
if (lbl) rule.label = lbl
|
||||
else delete rule.label
|
||||
} else if (field === "min") {
|
||||
if (value === null || value === undefined) delete rule.min
|
||||
else rule.min = value
|
||||
} else if (field === "max") {
|
||||
if (value === null || value === undefined) delete rule.max
|
||||
else rule.max = value
|
||||
} else if (field === "message") {
|
||||
rule.message = value
|
||||
}
|
||||
|
||||
rules[index] = rule
|
||||
updateRules(lang, rules)
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.languages,
|
||||
(langs) => {
|
||||
if (langs.length && !langs.includes(activeTab.value as LANGUAGE)) {
|
||||
activeTab.value = langs[0]
|
||||
}
|
||||
},
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<n-collapse>
|
||||
<n-collapse-item title="代码规则检查(选填)" name="ast-rules">
|
||||
<n-tabs v-if="languages.length" type="segment" v-model:value="activeTab">
|
||||
<n-tab-pane
|
||||
v-for="lang in languages"
|
||||
:key="lang"
|
||||
:name="lang"
|
||||
:tab="lang"
|
||||
>
|
||||
<n-flex vertical>
|
||||
<div
|
||||
v-for="(rule, index) in getRulesForLang(lang)"
|
||||
:key="index"
|
||||
style="margin-bottom: 8px"
|
||||
>
|
||||
<n-flex align="center" :wrap="false">
|
||||
<n-select
|
||||
:options="ENGINE_OPTIONS"
|
||||
:value="rule.engine"
|
||||
@update:value="
|
||||
(v: string) => updateRule(lang, index, 'engine', v)
|
||||
"
|
||||
style="width: 150px"
|
||||
size="small"
|
||||
/>
|
||||
<n-select
|
||||
v-if="needsTargetDropdown(rule.engine)"
|
||||
:options="NODE_TARGET_OPTIONS"
|
||||
:value="rule.target"
|
||||
@update:value="
|
||||
(v: string) => updateRule(lang, index, 'target', v)
|
||||
"
|
||||
style="width: 150px"
|
||||
size="small"
|
||||
filterable
|
||||
/>
|
||||
<n-input
|
||||
v-if="needsTargetInput(rule.engine)"
|
||||
:value="rule.target"
|
||||
@update:value="
|
||||
(v: string) => updateRule(lang, index, 'target', v)
|
||||
"
|
||||
placeholder="函数/方法名"
|
||||
style="width: 150px"
|
||||
size="small"
|
||||
/>
|
||||
<n-select
|
||||
v-if="needsOperatorDropdown(rule.engine)"
|
||||
:options="OPERATOR_TARGET_OPTIONS"
|
||||
:value="rule.target"
|
||||
@update:value="
|
||||
(v: string) => updateRule(lang, index, 'target', v)
|
||||
"
|
||||
style="width: 150px"
|
||||
size="small"
|
||||
/>
|
||||
<template v-if="isCountEngine(rule.engine)">
|
||||
<n-select
|
||||
:options="COUNT_MODE_OPTIONS"
|
||||
:value="getCountMode(rule)"
|
||||
@update:value="
|
||||
(v: 'exact' | 'range') => updateCountMode(lang, index, v)
|
||||
"
|
||||
style="width: 80px"
|
||||
size="small"
|
||||
/>
|
||||
<n-input-number
|
||||
v-if="getCountMode(rule) === 'exact'"
|
||||
:value="rule.exact ?? null"
|
||||
@update:value="
|
||||
(v: number | null) => updateExactCount(lang, index, v)
|
||||
"
|
||||
placeholder="次数"
|
||||
style="width: 100px"
|
||||
size="small"
|
||||
:min="1"
|
||||
clearable
|
||||
/>
|
||||
<template v-else>
|
||||
<n-input-number
|
||||
:value="rule.min ?? null"
|
||||
@update:value="
|
||||
(v: number | null) => updateRule(lang, index, 'min', v)
|
||||
"
|
||||
placeholder="最少"
|
||||
style="width: 100px"
|
||||
size="small"
|
||||
:min="0"
|
||||
clearable
|
||||
/>
|
||||
<n-input-number
|
||||
:value="rule.max ?? null"
|
||||
@update:value="
|
||||
(v: number | null) => updateRule(lang, index, 'max', v)
|
||||
"
|
||||
placeholder="最多"
|
||||
style="width: 100px"
|
||||
size="small"
|
||||
:min="0"
|
||||
clearable
|
||||
/>
|
||||
</template>
|
||||
</template>
|
||||
<n-input
|
||||
:value="rule.message"
|
||||
@update:value="
|
||||
(v: string) => updateRule(lang, index, 'message', v)
|
||||
"
|
||||
placeholder="错误提示(选填)"
|
||||
style="flex: 1"
|
||||
size="small"
|
||||
/>
|
||||
<n-button
|
||||
size="small"
|
||||
tertiary
|
||||
type="error"
|
||||
@click="removeRule(lang, index)"
|
||||
>
|
||||
删除
|
||||
</n-button>
|
||||
</n-flex>
|
||||
</div>
|
||||
<n-button
|
||||
size="small"
|
||||
tertiary
|
||||
type="primary"
|
||||
@click="addRule(lang)"
|
||||
>
|
||||
添加规则
|
||||
</n-button>
|
||||
</n-flex>
|
||||
</n-tab-pane>
|
||||
</n-tabs>
|
||||
<n-empty v-else description="请先选择编程语言" />
|
||||
</n-collapse-item>
|
||||
</n-collapse>
|
||||
</template>
|
||||
@@ -1,12 +1,13 @@
|
||||
<script lang="ts" setup>
|
||||
import { getProblemList } from "admin/api"
|
||||
import Pagination from "shared/components/Pagination.vue"
|
||||
import { AdminProblemFiltered } from "utils/types"
|
||||
import type { AdminProblemFiltered } from "utils/types"
|
||||
import AddButton from "./AddButton.vue"
|
||||
|
||||
interface Props {
|
||||
show: boolean
|
||||
count: number
|
||||
nextDisplayId?: string
|
||||
}
|
||||
const props = defineProps<Props>()
|
||||
const emit = defineEmits<{
|
||||
@@ -34,6 +35,7 @@ const columns: DataTableColumn<AdminProblemFiltered>[] = [
|
||||
h(AddButton, {
|
||||
problemID: row.id,
|
||||
contestID: route.params.contestID as string,
|
||||
nextDisplayId: props.nextDisplayId,
|
||||
onAdded: () => emit("change"),
|
||||
}),
|
||||
width: 60,
|
||||
@@ -53,7 +55,14 @@ watch(
|
||||
},
|
||||
)
|
||||
watch(() => [query.limit, query.page], getList)
|
||||
watchDebounced(() => query.keyword, getList, { debounce: 500, maxWait: 1000 })
|
||||
watchDebounced(
|
||||
() => query.keyword,
|
||||
() => {
|
||||
query.page = 1
|
||||
getList()
|
||||
},
|
||||
{ debounce: 500, maxWait: 1000 },
|
||||
)
|
||||
</script>
|
||||
<template>
|
||||
<n-modal
|
||||
|
||||
264
src/admin/problem/components/TestcaseGenerator.vue
Normal file
264
src/admin/problem/components/TestcaseGenerator.vue
Normal file
@@ -0,0 +1,264 @@
|
||||
<script setup lang="ts">
|
||||
import { downloadZip } from "client-zip"
|
||||
import type { LANGUAGE, Testcase } from "utils/types"
|
||||
import { createTestSubmission } from "utils/judge"
|
||||
import { uploadTestcases } from "../../api"
|
||||
|
||||
interface FileEntry {
|
||||
id: number
|
||||
in: string
|
||||
out: string
|
||||
error: boolean
|
||||
}
|
||||
|
||||
interface Props {
|
||||
answers: { language: LANGUAGE; code: string }[]
|
||||
samples?: { input: string; output: string }[]
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
const emit = defineEmits<{
|
||||
uploaded: [testCaseId: string, testCaseScore: Testcase[]]
|
||||
}>()
|
||||
|
||||
const message = useMessage()
|
||||
|
||||
let nextId = 0
|
||||
|
||||
function makeInitialFiles(): FileEntry[] {
|
||||
const fromSamples = (props.samples ?? []).map((s) => ({
|
||||
id: nextId++,
|
||||
in: s.input,
|
||||
out: s.output,
|
||||
error: false,
|
||||
}))
|
||||
const total = Math.ceil(Math.max(fromSamples.length, 1) / 5) * 5
|
||||
const extra = total - fromSamples.length
|
||||
return [
|
||||
...fromSamples,
|
||||
...Array.from({ length: extra }, () => ({
|
||||
id: nextId++,
|
||||
in: "",
|
||||
out: "",
|
||||
error: false,
|
||||
})),
|
||||
]
|
||||
}
|
||||
|
||||
const files = ref<FileEntry[]>(makeInitialFiles())
|
||||
|
||||
const selectedLanguage = ref<LANGUAGE>("Python3")
|
||||
|
||||
// 始终显示所有语言,不管有没有答案代码
|
||||
const availableLanguages = computed(() =>
|
||||
props.answers.map((a) => ({ label: a.language, value: a.language })),
|
||||
)
|
||||
|
||||
const hasAnyAnswerCode = computed(() =>
|
||||
props.answers.some((a) => a.code.trim()),
|
||||
)
|
||||
|
||||
// 当前选中语言是否有答案代码(用于控制"先运行"按钮)
|
||||
const hasAnswerCode = computed(() => {
|
||||
const answer = props.answers.find(
|
||||
(a) => a.language === selectedLanguage.value,
|
||||
)
|
||||
return !!answer?.code.trim()
|
||||
})
|
||||
|
||||
// 当语言列表变化时,确保 selectedLanguage 始终指向一个有效值
|
||||
watch(
|
||||
availableLanguages,
|
||||
(langs) => {
|
||||
if (
|
||||
langs.length &&
|
||||
!langs.find((l) => l.value === selectedLanguage.value)
|
||||
) {
|
||||
selectedLanguage.value = langs[0].value
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
const isRunning = ref(false)
|
||||
const isUploading = ref(false)
|
||||
|
||||
const hasAnyInput = computed(() => files.value.some((f) => f.in.trim()))
|
||||
|
||||
const canUpload = computed(
|
||||
() =>
|
||||
!isRunning.value &&
|
||||
hasAnyInput.value &&
|
||||
files.value.filter((f) => f.in.trim()).every((f) => f.out && !f.error),
|
||||
)
|
||||
|
||||
function reset() {
|
||||
files.value = Array.from({ length: 5 }, () => ({
|
||||
id: nextId++,
|
||||
in: "",
|
||||
out: "",
|
||||
error: false,
|
||||
}))
|
||||
}
|
||||
|
||||
function add(n: number) {
|
||||
files.value.push(
|
||||
...Array.from({ length: n }, () => ({
|
||||
id: nextId++,
|
||||
in: "",
|
||||
out: "",
|
||||
error: false,
|
||||
})),
|
||||
)
|
||||
}
|
||||
|
||||
function remove(index: number) {
|
||||
files.value.splice(index, 1)
|
||||
}
|
||||
|
||||
async function run() {
|
||||
const answer = props.answers.find(
|
||||
(a) => a.language === selectedLanguage.value,
|
||||
)
|
||||
if (!answer?.code.trim()) return
|
||||
|
||||
// 过滤空行,去重(按输入内容)
|
||||
const seen = new Set<string>()
|
||||
files.value = files.value.filter((f) => {
|
||||
if (!f.in.trim()) return false
|
||||
if (seen.has(f.in)) return false
|
||||
seen.add(f.in)
|
||||
return true
|
||||
})
|
||||
|
||||
// 清空旧输出
|
||||
files.value = files.value.map((f) => ({ ...f, out: "", error: false }))
|
||||
|
||||
isRunning.value = true
|
||||
await Promise.all(
|
||||
files.value.map(async (_, i) => {
|
||||
try {
|
||||
const result = await createTestSubmission(
|
||||
{ language: selectedLanguage.value, value: answer.code },
|
||||
files.value[i].in,
|
||||
)
|
||||
files.value[i] = {
|
||||
...files.value[i],
|
||||
out: result.output,
|
||||
error: result.status !== 3,
|
||||
}
|
||||
} catch {
|
||||
files.value[i] = { ...files.value[i], out: "", error: true }
|
||||
}
|
||||
}),
|
||||
)
|
||||
isRunning.value = false
|
||||
}
|
||||
|
||||
async function upload() {
|
||||
isUploading.value = true
|
||||
try {
|
||||
const now = new Date()
|
||||
const data = files.value
|
||||
.filter((f) => f.in.trim() && f.out && !f.error)
|
||||
.flatMap((f, i) => [
|
||||
{ name: `${i + 1}.in`, input: f.in, lastModified: now },
|
||||
{ name: `${i + 1}.out`, input: f.out, lastModified: now },
|
||||
])
|
||||
|
||||
const blob = await downloadZip(data).blob()
|
||||
const file = new File([blob], "testcase.zip", { type: "application/zip" })
|
||||
|
||||
const res = await uploadTestcases(file)
|
||||
const testcases: Testcase[] = res.data.info
|
||||
const baseScore = Math.floor(100 / testcases.length)
|
||||
const remainder = 100 - baseScore * testcases.length
|
||||
testcases.forEach((tc, i) => {
|
||||
tc.score = String(
|
||||
i === testcases.length - 1 ? baseScore + remainder : baseScore,
|
||||
)
|
||||
})
|
||||
|
||||
emit("uploaded", res.data.id, testcases)
|
||||
message.success("上传成功")
|
||||
} catch {
|
||||
message.error("上传失败")
|
||||
} finally {
|
||||
isUploading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<n-flex vertical>
|
||||
<n-alert
|
||||
v-if="!hasAnyAnswerCode"
|
||||
type="warning"
|
||||
:show-icon="false"
|
||||
style="margin-bottom: 8px"
|
||||
>
|
||||
还没有填写答案代码,请先在上方"本题参考答案"中填写至少一种语言的答案,再来生成测试用例
|
||||
</n-alert>
|
||||
<n-flex align="center" wrap>
|
||||
<n-select
|
||||
style="width: 120px"
|
||||
:options="availableLanguages"
|
||||
v-model:value="selectedLanguage"
|
||||
/>
|
||||
<n-button :disabled="isRunning" @click="reset">清空</n-button>
|
||||
<n-button :disabled="isRunning" @click="add(1)">+1</n-button>
|
||||
<n-button :disabled="isRunning" @click="add(5)">+5</n-button>
|
||||
<n-tooltip :disabled="hasAnswerCode && hasAnyInput">
|
||||
<template #trigger>
|
||||
<span>
|
||||
<n-button
|
||||
type="success"
|
||||
:loading="isRunning"
|
||||
:disabled="!hasAnswerCode || !hasAnyInput"
|
||||
@click="run"
|
||||
>
|
||||
先运行
|
||||
</n-button>
|
||||
</span>
|
||||
</template>
|
||||
{{ !hasAnswerCode ? "请先在题目中填写答案代码" : "请先填写输入" }}
|
||||
</n-tooltip>
|
||||
<n-button
|
||||
type="primary"
|
||||
:loading="isUploading"
|
||||
:disabled="!canUpload"
|
||||
@click="upload"
|
||||
>
|
||||
上传
|
||||
</n-button>
|
||||
</n-flex>
|
||||
|
||||
<n-flex
|
||||
v-for="(file, index) in files"
|
||||
:key="file.id"
|
||||
align="start"
|
||||
style="gap: 8px"
|
||||
>
|
||||
<n-flex vertical style="flex: 1">
|
||||
<span>{{ index + 1 }}.in</span>
|
||||
<n-input type="textarea" v-model:value="file.in" :rows="3" />
|
||||
</n-flex>
|
||||
<n-flex vertical style="flex: 1">
|
||||
<span>{{ index + 1 }}.out</span>
|
||||
<n-input
|
||||
type="textarea"
|
||||
v-model:value="file.out"
|
||||
:rows="3"
|
||||
:status="file.out ? (file.error ? 'error' : 'success') : undefined"
|
||||
/>
|
||||
</n-flex>
|
||||
<n-button
|
||||
:disabled="files.length === 1 || isRunning"
|
||||
style="margin-top: 22px"
|
||||
@click="remove(index)"
|
||||
>
|
||||
删除
|
||||
</n-button>
|
||||
</n-flex>
|
||||
</n-flex>
|
||||
</template>
|
||||
@@ -1,6 +1,8 @@
|
||||
<script setup lang="ts">
|
||||
import { getProblemTagList } from "shared/api"
|
||||
import TextEditor from "shared/components/TextEditor.vue"
|
||||
import TestcaseGenerator from "./components/TestcaseGenerator.vue"
|
||||
import AstRulesEditor from "./components/AstRulesEditor.vue"
|
||||
import {
|
||||
CODE_TEMPLATES,
|
||||
LANGUAGE_SHOW_VALUE,
|
||||
@@ -8,7 +10,7 @@ import {
|
||||
} from "utils/constants"
|
||||
import download from "utils/download"
|
||||
import { unique } from "utils/functions"
|
||||
import { BlankProblem, LANGUAGE, Tag, Testcase } from "utils/types"
|
||||
import type { BlankProblem, LANGUAGE, Tag, Testcase } from "utils/types"
|
||||
import {
|
||||
createContestProblem,
|
||||
createProblem,
|
||||
@@ -86,20 +88,66 @@ const problem = useLocalStorage<BlankProblem>(STORAGE_KEY.ADMIN_PROBLEM, {
|
||||
flowchart_data: {},
|
||||
flowchart_hint: "",
|
||||
show_flowchart: false,
|
||||
ast_rules: null as { [key: string]: any[] } | null,
|
||||
})
|
||||
|
||||
// 从服务器来的tag列表
|
||||
const tagList = shallowRef<Tag[]>([])
|
||||
const tagListLoaded = ref(false)
|
||||
|
||||
interface Tags {
|
||||
select: string[]
|
||||
upload: string[]
|
||||
const selectedTags = ref<string[]>([])
|
||||
const newTags = ref<string[]>([])
|
||||
const selectedTagSet = computed(() => new Set(selectedTags.value))
|
||||
let syncingTagInputs = false
|
||||
|
||||
function normalizeTagNames(tags: unknown): string[] {
|
||||
if (!Array.isArray(tags)) return []
|
||||
return unique(
|
||||
tags
|
||||
.map((tag) => (typeof tag === "string" ? tag : tag?.name))
|
||||
.filter((tag): tag is string => !!tag),
|
||||
)
|
||||
}
|
||||
|
||||
function syncProblemTags() {
|
||||
problem.value.tags = unique([...selectedTags.value, ...newTags.value])
|
||||
}
|
||||
|
||||
function syncTagInputsFromProblemTags(tags: unknown = problem.value.tags) {
|
||||
const tagNames = normalizeTagNames(tags)
|
||||
const existingTagNames = new Set(tagList.value.map((tag) => tag.name))
|
||||
|
||||
syncingTagInputs = true
|
||||
if (!tagListLoaded.value) {
|
||||
selectedTags.value = tagNames
|
||||
newTags.value = []
|
||||
} else {
|
||||
selectedTags.value = tagNames.filter((tag) => existingTagNames.has(tag))
|
||||
newTags.value = tagNames.filter((tag) => !existingTagNames.has(tag))
|
||||
}
|
||||
syncingTagInputs = false
|
||||
syncProblemTags()
|
||||
}
|
||||
|
||||
function toggleTag(name: string) {
|
||||
const set = new Set(selectedTags.value)
|
||||
if (set.has(name)) set.delete(name)
|
||||
else set.add(name)
|
||||
selectedTags.value = Array.from(set)
|
||||
}
|
||||
|
||||
function validateNewTags(v: string[]) {
|
||||
const existing = new Set(tagList.value.map((t) => t.name))
|
||||
const blanks: string[] = []
|
||||
for (const tag of unique(v)) {
|
||||
if (existing.has(tag)) {
|
||||
message.error("已经存在标签:" + tag)
|
||||
break
|
||||
}
|
||||
blanks.push(tag)
|
||||
}
|
||||
newTags.value = blanks
|
||||
}
|
||||
// 从 tagList 中选择的 和 新上传的
|
||||
const tags = useLocalStorage<Tags>(STORAGE_KEY.ADMIN_PROBLEM_TAGS, {
|
||||
select: [],
|
||||
upload: [],
|
||||
})
|
||||
|
||||
// 这几个用的少,就不缓存本地了
|
||||
const [needTemplate, toggleNeedTemplate] = useToggle(false)
|
||||
@@ -125,12 +173,9 @@ const languageOptions = [
|
||||
{ label: LANGUAGE_SHOW_VALUE["C++"], value: "C++" },
|
||||
]
|
||||
|
||||
const tagOptions = computed(() =>
|
||||
tagList.value.map((tag) => ({ label: tag.name, value: tag.name })),
|
||||
)
|
||||
|
||||
async function getProblemDetail() {
|
||||
if (!props.problemID) {
|
||||
syncTagInputsFromProblemTags()
|
||||
toggleReady(true)
|
||||
return
|
||||
}
|
||||
@@ -148,7 +193,7 @@ async function getProblemDetail() {
|
||||
problem.value.difficulty = data.difficulty
|
||||
problem.value.visible = data.visible
|
||||
problem.value.share_submission = data.share_submission
|
||||
problem.value.tags = data.tags
|
||||
problem.value.tags = normalizeTagNames(data.tags)
|
||||
problem.value.languages = data.languages
|
||||
problem.value.template = data.template
|
||||
problem.value.samples = data.samples
|
||||
@@ -165,6 +210,7 @@ async function getProblemDetail() {
|
||||
problem.value.mermaid_code = data.mermaid_code ?? ""
|
||||
problem.value.flowchart_hint = data.flowchart_hint ?? ""
|
||||
problem.value.flowchart_data = data.flowchart_data
|
||||
problem.value.ast_rules = data.ast_rules ?? null
|
||||
if (data.answers && data.answers.length) {
|
||||
problem.value.answers = data.answers
|
||||
} else {
|
||||
@@ -187,7 +233,7 @@ async function getProblemDetail() {
|
||||
}
|
||||
})
|
||||
// 标签
|
||||
tags.value.select = data.tags
|
||||
syncTagInputsFromProblemTags(problem.value.tags)
|
||||
toggleReady(true)
|
||||
} catch (error) {
|
||||
message.error("获取题目失败")
|
||||
@@ -198,22 +244,8 @@ async function getProblemDetail() {
|
||||
async function getTagList() {
|
||||
const res = await getProblemTagList()
|
||||
tagList.value = res.data
|
||||
}
|
||||
|
||||
function updateNewTags(v: string[]) {
|
||||
const blanks = []
|
||||
const uniqueTags = unique(v)
|
||||
const items = tagList.value.map((t) => t.name)
|
||||
for (let i = 0; i < uniqueTags.length; i++) {
|
||||
const tag = uniqueTags[i]
|
||||
if (items.indexOf(tag) < 0) {
|
||||
blanks.push(tag)
|
||||
} else {
|
||||
message.error("已经存在标签:" + tag)
|
||||
break
|
||||
}
|
||||
}
|
||||
tags.value.upload = blanks
|
||||
tagListLoaded.value = true
|
||||
syncTagInputsFromProblemTags()
|
||||
}
|
||||
|
||||
function addSample() {
|
||||
@@ -265,7 +297,7 @@ async function validateProblem() {
|
||||
hasErrors = true
|
||||
}
|
||||
// 标签
|
||||
else if (tags.value.upload.length === 0 && tags.value.select.length === 0) {
|
||||
else if (selectedTags.value.length === 0 && newTags.value.length === 0) {
|
||||
message.error("标签没有填写")
|
||||
hasErrors = true
|
||||
}
|
||||
@@ -353,6 +385,7 @@ async function submit() {
|
||||
filterHint()
|
||||
getTemplate()
|
||||
filterAnswers()
|
||||
syncProblemTags()
|
||||
const api = {
|
||||
"admin problem create": createProblem,
|
||||
"admin problem edit": editProblem,
|
||||
@@ -368,7 +401,8 @@ async function submit() {
|
||||
try {
|
||||
await api!(problem.value)
|
||||
problem.value = null
|
||||
tags.value = null
|
||||
selectedTags.value = []
|
||||
newTags.value = []
|
||||
if (
|
||||
route.name === "admin problem create" ||
|
||||
route.name === "admin contest problem create"
|
||||
@@ -403,7 +437,8 @@ const showClear = computed(
|
||||
|
||||
function clear() {
|
||||
problem.value = null
|
||||
tags.value = null
|
||||
selectedTags.value = []
|
||||
newTags.value = []
|
||||
// 为了给所有状态初始化,刷新页面
|
||||
location.reload()
|
||||
}
|
||||
@@ -418,18 +453,26 @@ async function generateMermaid() {
|
||||
problem.value.mermaid_code = res.data.flowchart
|
||||
}
|
||||
|
||||
const showGeneratorModal = ref(false)
|
||||
|
||||
function handleTestcasesGenerated(
|
||||
testCaseId: string,
|
||||
testCaseScore: Testcase[],
|
||||
) {
|
||||
problem.value.test_case_id = testCaseId
|
||||
problem.value.test_case_score = testCaseScore
|
||||
showGeneratorModal.value = false
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
getTagList()
|
||||
getProblemDetail()
|
||||
})
|
||||
|
||||
watch(
|
||||
() => [tags.value.select, tags.value.upload],
|
||||
(tags) => {
|
||||
const uniqueTags = unique<string>(tags[0].concat(tags[1]))
|
||||
problem.value.tags = uniqueTags
|
||||
},
|
||||
)
|
||||
watch([selectedTags, newTags], ([sel, newT]) => {
|
||||
if (syncingTagInputs) return
|
||||
problem.value.tags = unique([...sel, ...newT])
|
||||
})
|
||||
watch(
|
||||
() => problem.value.languages,
|
||||
(langs) => {
|
||||
@@ -468,20 +511,25 @@ watch(
|
||||
<n-switch v-model:value="problem.visible" />
|
||||
</n-form-item>
|
||||
</n-form>
|
||||
<n-form inline label-placement="left">
|
||||
<n-form-item label="现成的标签">
|
||||
<n-select
|
||||
class="tag"
|
||||
multiple
|
||||
v-model:value="tags.select"
|
||||
:options="tagOptions"
|
||||
/>
|
||||
</n-form-item>
|
||||
<n-form-item label="新增的标签">
|
||||
<n-dynamic-tags
|
||||
v-model:value="tags.upload"
|
||||
@update:value="updateNewTags"
|
||||
/>
|
||||
<n-form label-placement="left" :show-feedback="false">
|
||||
<n-form-item label="标签">
|
||||
<n-flex vertical style="width: 100%">
|
||||
<n-flex size="small" style="flex-wrap: wrap">
|
||||
<n-tag
|
||||
v-for="tag in tagList"
|
||||
:key="tag.id"
|
||||
checkable
|
||||
:checked="selectedTagSet.has(tag.name)"
|
||||
@update:checked="toggleTag(tag.name)"
|
||||
>
|
||||
{{ tag.name }}
|
||||
</n-tag>
|
||||
</n-flex>
|
||||
<n-dynamic-tags
|
||||
v-model:value="newTags"
|
||||
@update:value="validateNewTags"
|
||||
/>
|
||||
</n-flex>
|
||||
</n-form-item>
|
||||
</n-form>
|
||||
<TextEditor
|
||||
@@ -633,6 +681,83 @@ watch(
|
||||
</n-gi>
|
||||
</n-grid>
|
||||
|
||||
<n-grid :cols="2">
|
||||
<n-gi :span="1">
|
||||
<AstRulesEditor
|
||||
v-model="problem.ast_rules!"
|
||||
:languages="problem.languages"
|
||||
/>
|
||||
</n-gi>
|
||||
</n-grid>
|
||||
|
||||
<n-divider />
|
||||
|
||||
<h2 class="title">测试用例区域</h2>
|
||||
|
||||
<n-flex align="center" style="margin-bottom: 12px">
|
||||
<div>
|
||||
<n-button type="success" @click="showGeneratorModal = true">
|
||||
(新)直接生成
|
||||
</n-button>
|
||||
</div>
|
||||
<div>
|
||||
<n-upload
|
||||
:show-file-list="false"
|
||||
accept=".zip"
|
||||
:custom-request="handleUploadTestcases"
|
||||
>
|
||||
<n-button type="info">(老)手动上传</n-button>
|
||||
</n-upload>
|
||||
</div>
|
||||
<n-tooltip placement="right">
|
||||
<template #trigger>
|
||||
<n-button text>温馨提醒</n-button>
|
||||
</template>
|
||||
【测试用例】最好要有10个,要考虑边界情况,且不要跟【测试样例】一模一样
|
||||
</n-tooltip>
|
||||
</n-flex>
|
||||
|
||||
<n-alert
|
||||
class="box"
|
||||
v-if="problem.test_case_score.length"
|
||||
:show-icon="false"
|
||||
type="info"
|
||||
>
|
||||
<template #header>
|
||||
<n-flex align="center">
|
||||
<div>
|
||||
测试组编号 {{ problem.test_case_id.slice(0, 12) }} 共有
|
||||
{{ problem.test_case_score.length }}
|
||||
条测试用例
|
||||
</div>
|
||||
<n-button
|
||||
v-if="problem.id"
|
||||
tertiary
|
||||
type="info"
|
||||
size="small"
|
||||
@click="downloadTestcases"
|
||||
>
|
||||
下载
|
||||
</n-button>
|
||||
</n-flex>
|
||||
</template>
|
||||
</n-alert>
|
||||
|
||||
<n-modal
|
||||
v-model:show="showGeneratorModal"
|
||||
preset="card"
|
||||
title="测试用例生成器"
|
||||
style="width: 80vw; max-width: 900px"
|
||||
:mask-closable="false"
|
||||
display-directive="show"
|
||||
>
|
||||
<TestcaseGenerator
|
||||
:answers="problem.answers"
|
||||
:samples="problem.samples"
|
||||
@uploaded="handleTestcasesGenerated"
|
||||
/>
|
||||
</n-modal>
|
||||
|
||||
<n-divider />
|
||||
|
||||
<h2 class="title">流程图区域</h2>
|
||||
@@ -675,48 +800,7 @@ watch(
|
||||
/>
|
||||
</n-form-item>
|
||||
</n-form>
|
||||
<n-divider />
|
||||
<n-alert
|
||||
class="box"
|
||||
v-if="problem.test_case_score.length"
|
||||
:show-icon="false"
|
||||
type="info"
|
||||
>
|
||||
<template #header>
|
||||
<n-flex align="center">
|
||||
<div>
|
||||
测试组编号 {{ problem.test_case_id.slice(0, 12) }} 共有
|
||||
{{ problem.test_case_score.length }}
|
||||
条测试用例
|
||||
</div>
|
||||
<n-button
|
||||
v-if="problem.id"
|
||||
tertiary
|
||||
type="info"
|
||||
size="small"
|
||||
@click="downloadTestcases"
|
||||
>
|
||||
下载
|
||||
</n-button>
|
||||
</n-flex>
|
||||
</template>
|
||||
</n-alert>
|
||||
<n-flex style="margin-bottom: 120px" align="center" justify="end">
|
||||
<n-tooltip placement="left">
|
||||
<template #trigger>
|
||||
<n-button text>温馨提醒</n-button>
|
||||
</template>
|
||||
【测试用例】最好要有10个,要考虑边界情况,且不要跟【测试样例】一模一样
|
||||
</n-tooltip>
|
||||
<div>
|
||||
<n-upload
|
||||
:show-file-list="false"
|
||||
accept=".zip"
|
||||
:custom-request="handleUploadTestcases"
|
||||
>
|
||||
<n-button type="info">上传测试用例</n-button>
|
||||
</n-upload>
|
||||
</div>
|
||||
<n-button type="primary" @click="submit">提交</n-button>
|
||||
</n-flex>
|
||||
</template>
|
||||
@@ -738,10 +822,6 @@ watch(
|
||||
width: 300px;
|
||||
}
|
||||
|
||||
.tag {
|
||||
width: 500px;
|
||||
}
|
||||
|
||||
.addSamples {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
<script setup lang="ts">
|
||||
import { NSwitch } from "naive-ui"
|
||||
import { NFlex, NSwitch, NTag } from "naive-ui"
|
||||
import { Icon } from "@iconify/vue"
|
||||
import Pagination from "shared/components/Pagination.vue"
|
||||
import { usePagination } from "shared/composables/pagination"
|
||||
import { parseTime } from "utils/functions"
|
||||
import { AdminProblemFiltered } from "utils/types"
|
||||
import { getTagColor, parseTime } from "utils/functions"
|
||||
import type { AdminProblemFiltered } from "utils/types"
|
||||
import { DIFFICULTY } from "utils/constants"
|
||||
import { getProblemList, toggleProblemVisible } from "../api"
|
||||
import Actions from "./components/Actions.vue"
|
||||
import Modal from "./components/Modal.vue"
|
||||
@@ -34,6 +36,16 @@ const { count, inc } = useCounter(0)
|
||||
const total = ref(0)
|
||||
const problems = ref<AdminProblemFiltered[]>([])
|
||||
|
||||
const nextDisplayID = computed(() => {
|
||||
if (!isContestProblemList.value) return ""
|
||||
if (problems.value.length === 0) return "1"
|
||||
const ids = problems.value.map((p) => p._id)
|
||||
if (ids.every((id) => /^\d+$/.test(id))) {
|
||||
return String(Math.max(...ids.map((id) => parseInt(id))) + 1)
|
||||
}
|
||||
return ""
|
||||
})
|
||||
|
||||
interface ProblemQuery {
|
||||
keyword: string
|
||||
author: string
|
||||
@@ -48,8 +60,56 @@ const { query, clearQuery } = usePagination<ProblemQuery>({
|
||||
const columns: DataTableColumn<AdminProblemFiltered>[] = [
|
||||
{ title: "ID", key: "id", width: 100 },
|
||||
{ title: "显示编号", key: "_id", width: 100 },
|
||||
{ title: "标题", key: "title", minWidth: 300 },
|
||||
{ title: "出题人", key: "username", width: 160 },
|
||||
{ title: "标题", key: "title", minWidth: 200 },
|
||||
{
|
||||
title: "难度",
|
||||
key: "difficulty",
|
||||
width: 80,
|
||||
render: (row) =>
|
||||
h(
|
||||
NTag,
|
||||
{ type: getTagColor(row.difficulty), size: "small" },
|
||||
() => DIFFICULTY[row.difficulty],
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "标签",
|
||||
key: "tags",
|
||||
minWidth: 120,
|
||||
render: (row) =>
|
||||
h(NFlex, { size: 4 }, () =>
|
||||
row.tags.map((t) => h(NTag, { key: t, size: "small" }, () => t)),
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "功能",
|
||||
key: "features",
|
||||
width: 80,
|
||||
render: (row) =>
|
||||
h(NFlex, { size: 4, align: "center" }, () => [
|
||||
row.allow_flowchart
|
||||
? h(Icon, {
|
||||
width: 18,
|
||||
icon: "vscode-icons:file-type-drawio",
|
||||
title: "绘图",
|
||||
})
|
||||
: row.show_flowchart
|
||||
? h(Icon, {
|
||||
width: 18,
|
||||
icon: "vscode-icons:file-type-graphql",
|
||||
title: "流程图",
|
||||
})
|
||||
: null,
|
||||
row.has_ast_rules
|
||||
? h(Icon, {
|
||||
width: 18,
|
||||
icon: "vscode-icons:file-type-light-todo",
|
||||
title: "AST",
|
||||
})
|
||||
: null,
|
||||
]),
|
||||
},
|
||||
{ title: "出题人", key: "username", width: 120 },
|
||||
{
|
||||
title: "创建时间",
|
||||
key: "create_time",
|
||||
@@ -59,7 +119,7 @@ const columns: DataTableColumn<AdminProblemFiltered>[] = [
|
||||
{
|
||||
title: "可见",
|
||||
key: "visible",
|
||||
minWidth: 80,
|
||||
minWidth: 100,
|
||||
render: (row) =>
|
||||
h(NSwitch, {
|
||||
value: row.visible,
|
||||
@@ -71,7 +131,7 @@ const columns: DataTableColumn<AdminProblemFiltered>[] = [
|
||||
{
|
||||
title: "选项",
|
||||
key: "actions",
|
||||
width: 330,
|
||||
width: 320,
|
||||
render: (row) =>
|
||||
h(Actions, {
|
||||
problemID: row.id,
|
||||
@@ -184,7 +244,12 @@ watch(() => [query.page, query.limit, query.author], listProblems)
|
||||
v-model:limit="query.limit"
|
||||
v-model:page="query.page"
|
||||
/>
|
||||
<Modal v-model:show="show" :count="count" @change="listProblems" />
|
||||
<Modal
|
||||
v-model:show="show"
|
||||
:count="count"
|
||||
:next-display-id="nextDisplayID"
|
||||
@change="listProblems"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import Pagination from "shared/components/Pagination.vue"
|
||||
import { usePagination } from "shared/composables/pagination"
|
||||
import { parseTime } from "utils/functions"
|
||||
import { ProblemSetList } from "utils/types"
|
||||
import type { ProblemSetList } from "utils/types"
|
||||
import { getProblemSetList, toggleProblemSetVisible } from "../api"
|
||||
import Actions from "./components/Actions.vue"
|
||||
import { NTag, NSwitch } from "naive-ui"
|
||||
@@ -58,7 +58,11 @@ const columns: DataTableColumn<ProblemSetList>[] = [
|
||||
Hard: { type: "error" as const, text: "困难" },
|
||||
}
|
||||
const config = difficultyMap[row.difficulty]
|
||||
return h(NTag, { type: config.type }, { default: () => config.text })
|
||||
return h(
|
||||
NTag,
|
||||
{ type: config.type, size: "small" },
|
||||
{ default: () => config.text },
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -72,7 +76,11 @@ const columns: DataTableColumn<ProblemSetList>[] = [
|
||||
draft: { type: "info" as const, text: "草稿" },
|
||||
}
|
||||
const config = statusMap[row.status]
|
||||
return h(NTag, { type: config.type }, { default: () => config.text })
|
||||
return h(
|
||||
NTag,
|
||||
{ type: config.type, size: "small" },
|
||||
{ default: () => config.text },
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -84,7 +92,7 @@ const columns: DataTableColumn<ProblemSetList>[] = [
|
||||
{
|
||||
title: "可见",
|
||||
key: "visible",
|
||||
width: 80,
|
||||
width: 100,
|
||||
render: (row) =>
|
||||
h(NSwitch, {
|
||||
value: row.visible,
|
||||
|
||||
18
src/admin/transforms.ts
Normal file
18
src/admin/transforms.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
@@ -282,7 +282,7 @@ function typeTagType(type: string): "success" | "info" | "warning" {
|
||||
type="textarea"
|
||||
:rows="10"
|
||||
placeholder="在此粘贴正确的代码,保存后将自动按行拆分并乱序"
|
||||
style="font-family: 'Monaco'"
|
||||
style="font-family: "Monaco""
|
||||
/>
|
||||
</n-form-item>
|
||||
</template>
|
||||
@@ -302,7 +302,7 @@ function typeTagType(type: string): "success" | "info" | "warning" {
|
||||
type="textarea"
|
||||
:rows="10"
|
||||
placeholder="用 {{答案}} 标记空位,多个合法答案用 | 分隔,例如:for {{i|idx}} in range(10):"
|
||||
style="font-family: 'Monaco'"
|
||||
style="font-family: "Monaco""
|
||||
/>
|
||||
</n-form-item>
|
||||
</template>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script lang="ts" setup>
|
||||
import { editUser } from "admin/api"
|
||||
import { User } from "utils/types"
|
||||
import type { User } from "utils/types"
|
||||
|
||||
interface Props {
|
||||
user: User
|
||||
@@ -21,14 +21,6 @@ async function banUser() {
|
||||
</script>
|
||||
<template>
|
||||
<n-flex>
|
||||
<n-button
|
||||
size="small"
|
||||
type="info"
|
||||
secondary
|
||||
@click="$router.push({ name: 'ai', query: { username: props.user.username, duration: 'months:2' } })"
|
||||
>
|
||||
智能分析
|
||||
</n-button>
|
||||
<n-button
|
||||
size="small"
|
||||
type="error"
|
||||
|
||||
@@ -24,7 +24,13 @@ const isNotRegularUser = computed(
|
||||
>
|
||||
{{ getUserRole(props.user.admin_type).label }}
|
||||
</n-tag>
|
||||
<n-tag size="small" v-if="props.user.admin_type === USER_TYPE.ADMIN">
|
||||
<n-tag
|
||||
size="small"
|
||||
v-if="
|
||||
props.user.admin_type === USER_TYPE.STUDENT_ADMIN ||
|
||||
props.user.admin_type === USER_TYPE.TEACHER_ADMIN
|
||||
"
|
||||
>
|
||||
{{
|
||||
props.user.problem_permission === PROBLEM_PERMISSION.ALL
|
||||
? "全部"
|
||||
|
||||
@@ -38,7 +38,8 @@ const userEditing = ref<User | null>(null)
|
||||
|
||||
const adminOptions = [
|
||||
{ label: "全部用户", value: "" },
|
||||
{ label: "管理员", value: USER_TYPE.ADMIN },
|
||||
{ label: "学生管理员", value: USER_TYPE.STUDENT_ADMIN },
|
||||
{ label: "教师管理员", value: USER_TYPE.TEACHER_ADMIN },
|
||||
{ label: "超级管理员", value: USER_TYPE.SUPER_ADMIN },
|
||||
]
|
||||
|
||||
@@ -106,7 +107,8 @@ const columns: DataTableColumn<User>[] = [
|
||||
|
||||
const options: SelectOption[] = [
|
||||
{ label: "普通", value: USER_TYPE.REGULAR_USER },
|
||||
{ label: "管理员", value: USER_TYPE.ADMIN },
|
||||
{ label: "学生管理员", value: USER_TYPE.STUDENT_ADMIN },
|
||||
{ label: "教师管理员", value: USER_TYPE.TEACHER_ADMIN },
|
||||
{ label: "超级管理员", value: USER_TYPE.SUPER_ADMIN },
|
||||
]
|
||||
|
||||
@@ -166,7 +168,7 @@ function createNewUser() {
|
||||
username: "",
|
||||
real_name: "",
|
||||
email: "",
|
||||
admin_type: "Admin",
|
||||
admin_type: "Student Admin",
|
||||
problem_permission: "None",
|
||||
create_time: new Date(),
|
||||
last_login: new Date(),
|
||||
@@ -312,7 +314,11 @@ watch(() => [query.page, query.limit, query.type, query.orderBy], listUsers)
|
||||
<n-input v-model:value="password" />
|
||||
</n-form-item-gi>
|
||||
<n-form-item-gi
|
||||
v-if="!create && userEditing.admin_type === USER_TYPE.ADMIN"
|
||||
v-if="
|
||||
!create &&
|
||||
(userEditing.admin_type === USER_TYPE.STUDENT_ADMIN ||
|
||||
userEditing.admin_type === USER_TYPE.TEACHER_ADMIN)
|
||||
"
|
||||
:span="1"
|
||||
label="出题权限"
|
||||
>
|
||||
|
||||
@@ -40,7 +40,9 @@ router.beforeEach(async (to, from, next) => {
|
||||
if (
|
||||
to.matched.some(
|
||||
(record) =>
|
||||
record.meta.requiresSuperAdmin || record.meta.requiresProblemPermission,
|
||||
record.meta.requiresSuperAdmin ||
|
||||
record.meta.requiresTeacherAdmin ||
|
||||
record.meta.requiresProblemPermission,
|
||||
)
|
||||
) {
|
||||
if (!storage.get(STORAGE_KEY.AUTHED)) {
|
||||
@@ -63,6 +65,11 @@ router.beforeEach(async (to, from, next) => {
|
||||
next("/")
|
||||
return
|
||||
}
|
||||
} else if (to.matched.some((record) => record.meta.requiresTeacherAdmin)) {
|
||||
if (!userStore.isTeacherOrAbove) {
|
||||
next("/")
|
||||
return
|
||||
}
|
||||
} else if (
|
||||
to.matched.some((record) => record.meta.requiresProblemPermission)
|
||||
) {
|
||||
|
||||
1
src/mermaid-legacy.d.ts
vendored
Normal file
1
src/mermaid-legacy.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
||||
declare module "mermaid-legacy"
|
||||
@@ -36,8 +36,18 @@ async function handleAnalyze() {
|
||||
if (aiStore.loading.fetching || aiStore.loading.ai) {
|
||||
return
|
||||
}
|
||||
await aiStore.fetchAIAnalysis()
|
||||
if (aiStore.pinnedReport) {
|
||||
await aiStore.simulatePinnedStream()
|
||||
} else {
|
||||
await aiStore.fetchAIAnalysis()
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
if (!aiStore.targetUsername) {
|
||||
await aiStore.fetchPinnedReport()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
<style scoped>
|
||||
.cool-title {
|
||||
|
||||
@@ -60,7 +60,7 @@ import { useAIStore } from "oj/store/ai"
|
||||
import { parseTime } from "utils/functions"
|
||||
|
||||
const aiStore = useAIStore()
|
||||
const containerRef = ref<HTMLElement>()
|
||||
const containerRef = useTemplateRef<HTMLElement>("containerRef")
|
||||
|
||||
const CELL_SIZE = 12
|
||||
const CELL_GAP = 3
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { DIFFICULTY } from "utils/constants"
|
||||
import { getACRate } from "utils/functions"
|
||||
import http from "utils/http"
|
||||
import {
|
||||
import { filterResult } from "oj/transforms"
|
||||
import type {
|
||||
Exercise,
|
||||
Problem,
|
||||
Submission,
|
||||
@@ -9,30 +8,6 @@ import {
|
||||
SubmitCodePayload,
|
||||
} from "utils/types"
|
||||
|
||||
function filterResult(result: Problem) {
|
||||
const newResult = {
|
||||
id: result.id,
|
||||
_id: result._id,
|
||||
title: result.title,
|
||||
difficulty: DIFFICULTY[result.difficulty],
|
||||
tags: result.tags,
|
||||
submission: result.submission_number,
|
||||
rate: getACRate(result.accepted_number, result.submission_number),
|
||||
status: "",
|
||||
author: result.created_by.username,
|
||||
allow_flowchart: result.allow_flowchart,
|
||||
show_flowchart: result.show_flowchart,
|
||||
}
|
||||
if (result.my_status === null || result.my_status === undefined) {
|
||||
newResult.status = "not_test"
|
||||
} else if (result.my_status === 0) {
|
||||
newResult.status = "passed"
|
||||
} else {
|
||||
newResult.status = "failed"
|
||||
}
|
||||
return newResult
|
||||
}
|
||||
|
||||
export function getWebsiteConfig() {
|
||||
return http.get("website")
|
||||
}
|
||||
@@ -42,17 +17,9 @@ export async function getProblemList(
|
||||
limit = 10,
|
||||
searchParams: any = {},
|
||||
) {
|
||||
let params: any = {
|
||||
paging: true,
|
||||
offset,
|
||||
limit,
|
||||
}
|
||||
Object.keys(searchParams).forEach((element) => {
|
||||
if (searchParams[element]) {
|
||||
params[element] = searchParams[element]
|
||||
}
|
||||
const res = await http.get<{ results: Problem[]; total: number }>("problem", {
|
||||
params: { paging: true, offset, limit, ...searchParams },
|
||||
})
|
||||
const res = await http.get("problem", { params })
|
||||
return {
|
||||
results: res.data.results.map(filterResult),
|
||||
total: res.data.total,
|
||||
@@ -95,6 +62,10 @@ export function submitCode(data: SubmitCodePayload) {
|
||||
return http.post("submission", data)
|
||||
}
|
||||
|
||||
export function formatCode(data: { code: string; language: string }) {
|
||||
return http.post<{ code: string }>("format_code", data)
|
||||
}
|
||||
|
||||
export function getSubmissions(params: Partial<SubmissionListPayload>) {
|
||||
const endpoint = !!params.contest_id ? "contest_submissions" : "submissions"
|
||||
return http.get(endpoint, { params })
|
||||
@@ -104,8 +75,8 @@ export function getRankOfProblem(problem_id: string) {
|
||||
return http.get("user_problem_rank", { params: { problem_id: problem_id } })
|
||||
}
|
||||
|
||||
export function getTodaySubmissionCount() {
|
||||
return http.get("submissions/today_count")
|
||||
export function getTodaySubmissionCount(language?: string) {
|
||||
return http.get("submissions/today_count", { params: { language } })
|
||||
}
|
||||
|
||||
export function adminRejudge(id: string) {
|
||||
@@ -115,7 +86,7 @@ export function adminRejudge(id: string) {
|
||||
}
|
||||
|
||||
export function getSubmissionStatistics(
|
||||
duration: { start: string; end: string },
|
||||
duration: { start?: string; end: string },
|
||||
problemID?: string,
|
||||
username?: string,
|
||||
) {
|
||||
@@ -202,7 +173,7 @@ export function checkContestPassword(contestID: string, password: string) {
|
||||
}
|
||||
|
||||
export async function getContestProblems(contestID: string) {
|
||||
const res = await http.get("contest/problem", {
|
||||
const res = await http.get<Problem[]>("contest/problem", {
|
||||
params: { contest_id: contestID },
|
||||
})
|
||||
return res.data.map(filterResult)
|
||||
@@ -210,7 +181,7 @@ export async function getContestProblems(contestID: string) {
|
||||
|
||||
export function getContestRank(
|
||||
contestID: string,
|
||||
query: { limit: number; offset: number; force_refresh: "1" | "0" },
|
||||
query: { limit: number; offset: number },
|
||||
) {
|
||||
return http.get("contest_rank", {
|
||||
params: {
|
||||
@@ -307,6 +278,10 @@ export function getAILoginSummary() {
|
||||
return http.get("ai/login_summary")
|
||||
}
|
||||
|
||||
export function getAIPinnedReport() {
|
||||
return http.get("ai/pinned")
|
||||
}
|
||||
|
||||
// ==================== 相似题目推荐 ====================
|
||||
|
||||
export function getSimilarProblems(problemId: string) {
|
||||
@@ -348,10 +323,26 @@ export function getFlowchartSubmissions(params: {
|
||||
myself?: string
|
||||
offset?: number
|
||||
limit?: number
|
||||
today?: string
|
||||
grade?: string
|
||||
}) {
|
||||
return http.get("flowchart/submissions", { params })
|
||||
}
|
||||
|
||||
export function getFlowchartStatistics(
|
||||
duration: { start?: string; end: string },
|
||||
problemID?: string,
|
||||
username?: string,
|
||||
) {
|
||||
return http.get("admin/flowchart/statistics", {
|
||||
params: {
|
||||
...duration,
|
||||
problem_id: problemID,
|
||||
username,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function retryFlowchartSubmission(submissionId: string) {
|
||||
return http.post("flowchart/submission/retry", {
|
||||
submission_id: submissionId,
|
||||
@@ -440,7 +431,7 @@ export function getProblemSetUserProgress(
|
||||
}
|
||||
|
||||
export async function getExercises(tutorialId: number): Promise<Exercise[]> {
|
||||
const res = await http.get("exercises", {
|
||||
const res = await http.get<Exercise[]>("exercises", {
|
||||
params: { tutorial_id: tutorialId },
|
||||
})
|
||||
return res.data
|
||||
|
||||
@@ -3,9 +3,14 @@ import { h } from "vue"
|
||||
import { formatISO, sub, type Duration } from "date-fns"
|
||||
import { getClassPK } from "oj/api"
|
||||
import { useConfigStore } from "shared/store/config"
|
||||
import { useUserStore } from "shared/store/user"
|
||||
import { Icon } from "@iconify/vue"
|
||||
import { Bar, Radar } from "vue-chartjs"
|
||||
import { useBreakpoints } from "shared/composables/breakpoints"
|
||||
import { MdPreview } from "md-editor-v3"
|
||||
import "md-editor-v3/lib/preview.css"
|
||||
import { consumeJSONEventStream } from "utils/stream"
|
||||
import { getCSRFToken } from "utils/functions"
|
||||
import {
|
||||
Chart as ChartJS,
|
||||
CategoryScale,
|
||||
@@ -37,6 +42,7 @@ ChartJS.register(
|
||||
)
|
||||
|
||||
const configStore = useConfigStore()
|
||||
const { isTeacherOrAbove } = useUserStore()
|
||||
const message = useMessage()
|
||||
const { isDesktop } = useBreakpoints()
|
||||
|
||||
@@ -52,13 +58,13 @@ interface ClassComparison {
|
||||
iqr: number
|
||||
std_dev: number
|
||||
top_10_avg: number
|
||||
middle_80_avg: number
|
||||
bottom_10_avg: number
|
||||
top_25_avg: number
|
||||
bottom_25_avg: number
|
||||
excellent_rate: number
|
||||
pass_rate: number
|
||||
active_rate: number
|
||||
ac_rate: number
|
||||
composite_score: number
|
||||
recent_total_ac?: number
|
||||
recent_avg_ac?: number
|
||||
recent_median_ac?: number
|
||||
@@ -72,6 +78,11 @@ const duration = ref<string>("")
|
||||
const loading = ref(false)
|
||||
const hasTimeRange = ref(false)
|
||||
|
||||
const aiLoading = ref(false)
|
||||
const aiContent = ref("")
|
||||
const showAIModal = ref(false)
|
||||
let aiController: AbortController | null = null
|
||||
|
||||
// 时间段选项(与 rank/list.vue 保持一致)
|
||||
const timeRangeOptions: SelectOption[] = [
|
||||
{ label: "全部时间", value: "" },
|
||||
@@ -145,6 +156,73 @@ async function compare() {
|
||||
}
|
||||
}
|
||||
|
||||
async function analyzeWithAI() {
|
||||
if (aiController) {
|
||||
aiController.abort()
|
||||
}
|
||||
const controller = new AbortController()
|
||||
aiController = controller
|
||||
|
||||
const timeRangeLabel =
|
||||
timeRangeOptions.find((o) => o.value === duration.value)?.label ??
|
||||
"全部时间"
|
||||
|
||||
showAIModal.value = true
|
||||
aiContent.value = ""
|
||||
aiLoading.value = true
|
||||
|
||||
const headers: Record<string, string> = { "Content-Type": "application/json" }
|
||||
const csrfToken = getCSRFToken()
|
||||
if (csrfToken) headers["X-CSRFToken"] = csrfToken
|
||||
|
||||
try {
|
||||
const response = await fetch("/api/ai/class_pk", {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify({
|
||||
comparisons: comparisons.value,
|
||||
time_range_label: timeRangeLabel,
|
||||
}),
|
||||
signal: controller.signal,
|
||||
})
|
||||
|
||||
if (!response.ok) throw new Error("AI 分析生成失败")
|
||||
|
||||
let hasStarted = false
|
||||
|
||||
await consumeJSONEventStream(response, {
|
||||
signal: controller.signal,
|
||||
onEvent(event) {
|
||||
if (event === "end" && !hasStarted) aiLoading.value = false
|
||||
},
|
||||
onMessage(payload) {
|
||||
const parsed = payload as {
|
||||
type?: string
|
||||
content?: string
|
||||
message?: string
|
||||
}
|
||||
if (parsed.type === "delta" && parsed.content) {
|
||||
if (!hasStarted) {
|
||||
hasStarted = true
|
||||
aiLoading.value = false
|
||||
}
|
||||
aiContent.value += parsed.content
|
||||
} else if (parsed.type === "error") {
|
||||
throw new Error(parsed.message || "AI 服务异常")
|
||||
} else if (parsed.type === "done" && !hasStarted) {
|
||||
aiLoading.value = false
|
||||
}
|
||||
},
|
||||
})
|
||||
} catch (error: any) {
|
||||
if (controller.signal.aborted) return
|
||||
message.error(error?.message || "AI 分析失败,请稍后再试")
|
||||
aiLoading.value = false
|
||||
} finally {
|
||||
if (aiController === controller) aiController = null
|
||||
}
|
||||
}
|
||||
|
||||
// 计算排名颜色
|
||||
function getRankColor(index: number) {
|
||||
if (index === 0) return { type: "success" as const, text: "1" }
|
||||
@@ -170,6 +248,24 @@ function getClassColor(index: number) {
|
||||
return colors[index % colors.length]
|
||||
}
|
||||
|
||||
// 综合分对比图
|
||||
const compositeScoreChartData = computed(() => {
|
||||
if (comparisons.value.length === 0) return null
|
||||
|
||||
const labels = comparisons.value.map((c) => c.class_name)
|
||||
const datasets = [
|
||||
{
|
||||
label: "综合分",
|
||||
data: comparisons.value.map((c) => c.composite_score),
|
||||
backgroundColor: comparisons.value.map((_, i) => getClassColor(i).bg),
|
||||
borderColor: comparisons.value.map((_, i) => getClassColor(i).border),
|
||||
borderWidth: 2,
|
||||
},
|
||||
]
|
||||
|
||||
return { labels, datasets }
|
||||
})
|
||||
|
||||
// 总AC数对比图 - 每个班级用不同颜色
|
||||
const totalAcChartData = computed(() => {
|
||||
if (comparisons.value.length === 0) return null
|
||||
@@ -278,14 +374,14 @@ const activeRateChartData = computed(() => {
|
||||
return { labels, datasets }
|
||||
})
|
||||
|
||||
// 前10名平均对比图
|
||||
// 前10%平均对比图
|
||||
const top10AvgChartData = computed(() => {
|
||||
if (comparisons.value.length === 0) return null
|
||||
|
||||
const labels = comparisons.value.map((c) => c.class_name)
|
||||
const datasets = [
|
||||
{
|
||||
label: "前10名平均",
|
||||
label: "前10%平均",
|
||||
data: comparisons.value.map((c) => c.top_10_avg),
|
||||
backgroundColor: comparisons.value.map((_, i) => getClassColor(i).bg),
|
||||
borderColor: comparisons.value.map((_, i) => getClassColor(i).border),
|
||||
@@ -296,14 +392,14 @@ const top10AvgChartData = computed(() => {
|
||||
return { labels, datasets }
|
||||
})
|
||||
|
||||
// 后10名平均对比图
|
||||
// 后10%平均对比图
|
||||
const bottom10AvgChartData = computed(() => {
|
||||
if (comparisons.value.length === 0) return null
|
||||
|
||||
const labels = comparisons.value.map((c) => c.class_name)
|
||||
const datasets = [
|
||||
{
|
||||
label: "后10名平均",
|
||||
label: "后10%平均",
|
||||
data: comparisons.value.map((c) => c.bottom_10_avg),
|
||||
backgroundColor: comparisons.value.map((_, i) => getClassColor(i).bg),
|
||||
borderColor: comparisons.value.map((_, i) => getClassColor(i).border),
|
||||
@@ -314,33 +410,15 @@ const bottom10AvgChartData = computed(() => {
|
||||
return { labels, datasets }
|
||||
})
|
||||
|
||||
// 前25%平均对比图
|
||||
const top25AvgChartData = computed(() => {
|
||||
// 中间80%均值对比图
|
||||
const middle80AvgChartData = computed(() => {
|
||||
if (comparisons.value.length === 0) return null
|
||||
|
||||
const labels = comparisons.value.map((c) => c.class_name)
|
||||
const datasets = [
|
||||
{
|
||||
label: "前25%平均",
|
||||
data: comparisons.value.map((c) => c.top_25_avg),
|
||||
backgroundColor: comparisons.value.map((_, i) => getClassColor(i).bg),
|
||||
borderColor: comparisons.value.map((_, i) => getClassColor(i).border),
|
||||
borderWidth: 2,
|
||||
},
|
||||
]
|
||||
|
||||
return { labels, datasets }
|
||||
})
|
||||
|
||||
// 后25%平均对比图
|
||||
const bottom25AvgChartData = computed(() => {
|
||||
if (comparisons.value.length === 0) return null
|
||||
|
||||
const labels = comparisons.value.map((c) => c.class_name)
|
||||
const datasets = [
|
||||
{
|
||||
label: "后25%平均",
|
||||
data: comparisons.value.map((c) => c.bottom_25_avg),
|
||||
label: "中间80%均值",
|
||||
data: comparisons.value.map((c) => c.middle_80_avg),
|
||||
backgroundColor: comparisons.value.map((_, i) => getClassColor(i).bg),
|
||||
borderColor: comparisons.value.map((_, i) => getClassColor(i).border),
|
||||
borderWidth: 2,
|
||||
@@ -474,6 +552,160 @@ const chartOptions = {
|
||||
},
|
||||
}
|
||||
|
||||
const compositeScoreChartOptions = {
|
||||
...chartOptions,
|
||||
scales: {
|
||||
...chartOptions.scales,
|
||||
y: {
|
||||
...chartOptions.scales.y,
|
||||
max: 100,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
const tableColumns: DataTableColumn<ClassComparison>[] = [
|
||||
{
|
||||
title: "排名",
|
||||
key: "rank",
|
||||
render: (_, index) => getRankColor(index).text,
|
||||
width: 80,
|
||||
},
|
||||
{
|
||||
title: "综合分",
|
||||
key: "composite_score",
|
||||
width: 90,
|
||||
render: (row) =>
|
||||
h(
|
||||
"span",
|
||||
{
|
||||
style: {
|
||||
color: "#722ed1",
|
||||
fontWeight: "700",
|
||||
fontSize: "15px",
|
||||
},
|
||||
},
|
||||
row.composite_score.toFixed(1),
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "班级",
|
||||
key: "class_name",
|
||||
render: (row) =>
|
||||
`${row.class_name.slice(0, 2)}计算机${row.class_name.slice(2)}班`,
|
||||
width: 160,
|
||||
},
|
||||
{
|
||||
title: "人数",
|
||||
key: "user_count",
|
||||
width: 80,
|
||||
render: (row) =>
|
||||
h(
|
||||
"span",
|
||||
{ style: { color: "#1890ff", fontWeight: "600" } },
|
||||
row.user_count,
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "总AC数",
|
||||
key: "total_ac",
|
||||
width: 100,
|
||||
render: (row) =>
|
||||
h(
|
||||
"span",
|
||||
{ style: { color: "#ff4d4f", fontWeight: "600" } },
|
||||
row.total_ac,
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "平均AC",
|
||||
key: "avg_ac",
|
||||
width: 100,
|
||||
render: (row) =>
|
||||
h(
|
||||
"span",
|
||||
{ style: { color: "#52c41a", fontWeight: "600" } },
|
||||
row.avg_ac.toFixed(2),
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "中位数AC",
|
||||
key: "median_ac",
|
||||
width: 100,
|
||||
render: (row) =>
|
||||
h(
|
||||
"span",
|
||||
{ style: { color: "#fa8c16", fontWeight: "600" } },
|
||||
row.median_ac.toFixed(2),
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "前10%均值",
|
||||
key: "top_10_avg",
|
||||
width: 100,
|
||||
render: (row) =>
|
||||
h(
|
||||
"span",
|
||||
{ style: { color: "#cf1322", fontWeight: "600" } },
|
||||
row.top_10_avg.toFixed(2),
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "中间80%均值",
|
||||
key: "middle_80_avg",
|
||||
width: 110,
|
||||
render: (row) =>
|
||||
h(
|
||||
"span",
|
||||
{ style: { color: "#389e0d", fontWeight: "600" } },
|
||||
row.middle_80_avg.toFixed(2),
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "后10%均值",
|
||||
key: "bottom_10_avg",
|
||||
width: 100,
|
||||
render: (row) =>
|
||||
h(
|
||||
"span",
|
||||
{ style: { color: "#096dd9", fontWeight: "500" } },
|
||||
row.bottom_10_avg.toFixed(2),
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "优秀率",
|
||||
key: "excellent_rate",
|
||||
width: 100,
|
||||
render: (row) =>
|
||||
h(
|
||||
"span",
|
||||
{ style: { color: "#faad14", fontWeight: "600" } },
|
||||
row.excellent_rate.toFixed(1) + "%",
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "及格率",
|
||||
key: "pass_rate",
|
||||
width: 100,
|
||||
render: (row) =>
|
||||
h(
|
||||
"span",
|
||||
{ style: { color: "#52c41a", fontWeight: "600" } },
|
||||
row.pass_rate.toFixed(1) + "%",
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "参与度",
|
||||
key: "active_rate",
|
||||
width: 100,
|
||||
render: (row) =>
|
||||
h(
|
||||
"span",
|
||||
{ style: { color: "#1890ff", fontWeight: "600" } },
|
||||
row.active_rate.toFixed(1) + "%",
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
const radarChartOptions = {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
@@ -555,8 +787,42 @@ const radarChartOptions = {
|
||||
>
|
||||
开始PK
|
||||
</n-button>
|
||||
<n-button
|
||||
v-if="isTeacherOrAbove"
|
||||
type="info"
|
||||
@click="analyzeWithAI"
|
||||
:loading="aiLoading"
|
||||
:disabled="comparisons.length === 0"
|
||||
style="margin-top: 26px"
|
||||
>
|
||||
<template #icon>
|
||||
<Icon icon="mingcute:ai-line" />
|
||||
</template>
|
||||
AI分析
|
||||
</n-button>
|
||||
</n-flex>
|
||||
|
||||
<n-modal
|
||||
v-model:show="showAIModal"
|
||||
preset="card"
|
||||
title="AI 分析报告"
|
||||
:style="{ width: '800px', maxWidth: '95vw' }"
|
||||
>
|
||||
<n-spin :show="aiLoading" :delay="50">
|
||||
<div style="min-height: 200px">
|
||||
<MdPreview v-if="aiContent" :model-value="aiContent" />
|
||||
<n-flex
|
||||
v-else-if="!aiLoading"
|
||||
align="center"
|
||||
justify="center"
|
||||
style="min-height: 200px"
|
||||
>
|
||||
<n-empty description="暂无分析内容" />
|
||||
</n-flex>
|
||||
</div>
|
||||
</n-spin>
|
||||
</n-modal>
|
||||
|
||||
<!-- 班级对比卡片 -->
|
||||
<n-grid v-if="comparisons.length > 0" :cols="2" :x-gap="16" :y-gap="16">
|
||||
<n-gi
|
||||
@@ -575,6 +841,9 @@ const radarChartOptions = {
|
||||
<template #header-extra>
|
||||
<n-tag :type="getRankColor(index).type" size="large">
|
||||
#{{ getRankColor(index).text }}
|
||||
<span style="margin-left: 6px; font-size: 12px; opacity: 0.85">
|
||||
{{ classData.composite_score }} 分
|
||||
</span>
|
||||
</n-tag>
|
||||
</template>
|
||||
|
||||
@@ -676,26 +945,21 @@ const radarChartOptions = {
|
||||
</n-descriptions-item>
|
||||
|
||||
<!-- 分层统计 -->
|
||||
<n-descriptions-item label="前10名平均">
|
||||
<n-descriptions-item label="前10%均值">
|
||||
<span style="color: #cf1322; font-weight: 600">{{
|
||||
classData.top_10_avg.toFixed(2)
|
||||
}}</span>
|
||||
</n-descriptions-item>
|
||||
<n-descriptions-item label="后10名平均">
|
||||
<n-descriptions-item label="中间80%均值">
|
||||
<span style="color: #389e0d; font-weight: 600">{{
|
||||
classData.middle_80_avg.toFixed(2)
|
||||
}}</span>
|
||||
</n-descriptions-item>
|
||||
<n-descriptions-item label="后10%均值">
|
||||
<span style="color: #096dd9; font-weight: 500">{{
|
||||
classData.bottom_10_avg.toFixed(2)
|
||||
}}</span>
|
||||
</n-descriptions-item>
|
||||
<n-descriptions-item label="前25%平均">
|
||||
<span style="color: #f5222d; font-weight: 600">{{
|
||||
classData.top_25_avg.toFixed(2)
|
||||
}}</span>
|
||||
</n-descriptions-item>
|
||||
<n-descriptions-item label="后25%平均">
|
||||
<span style="color: #531dab; font-weight: 500">{{
|
||||
classData.bottom_25_avg.toFixed(2)
|
||||
}}</span>
|
||||
</n-descriptions-item>
|
||||
|
||||
<!-- 人数 -->
|
||||
<n-descriptions-item label="人数">
|
||||
@@ -793,6 +1057,32 @@ const radarChartOptions = {
|
||||
|
||||
<!-- 可视化图表 - 专注于对比 -->
|
||||
<template v-if="comparisons.length > 0">
|
||||
<!-- 综合分对比 + 多维度雷达图 同行 -->
|
||||
<n-grid style="margin-top: 20px" :cols="2" :x-gap="16">
|
||||
<n-gi>
|
||||
<n-card title="综合分对比(满分100)" style="height: 100%">
|
||||
<div style="height: 380px">
|
||||
<Bar
|
||||
v-if="compositeScoreChartData"
|
||||
:data="compositeScoreChartData"
|
||||
:options="compositeScoreChartOptions"
|
||||
/>
|
||||
</div>
|
||||
</n-card>
|
||||
</n-gi>
|
||||
<n-gi>
|
||||
<n-card title="多维度综合对比" style="height: 100%">
|
||||
<div style="height: 380px">
|
||||
<Radar
|
||||
v-if="radarChartData"
|
||||
:data="radarChartData"
|
||||
:options="radarChartOptions"
|
||||
/>
|
||||
</div>
|
||||
</n-card>
|
||||
</n-gi>
|
||||
</n-grid>
|
||||
|
||||
<!-- AC核心指标对比 - 三个独立图表并排显示 -->
|
||||
<n-card title="AC核心指标对比" style="margin-top: 20px">
|
||||
<n-grid :cols="3" :x-gap="16" :y-gap="16">
|
||||
@@ -859,9 +1149,9 @@ const radarChartOptions = {
|
||||
</n-grid>
|
||||
</n-card>
|
||||
|
||||
<!-- 分层统计对比 - 四个独立图表并排显示 -->
|
||||
<!-- 分层统计对比 - 三个独立图表并排显示 -->
|
||||
<n-card title="分层统计对比" style="margin-top: 20px">
|
||||
<n-grid :cols="2" :x-gap="16" :y-gap="16">
|
||||
<n-grid :cols="3" :x-gap="16" :y-gap="16">
|
||||
<n-gi>
|
||||
<div style="height: 300px">
|
||||
<Bar
|
||||
@@ -871,6 +1161,15 @@ const radarChartOptions = {
|
||||
/>
|
||||
</div>
|
||||
</n-gi>
|
||||
<n-gi>
|
||||
<div style="height: 300px">
|
||||
<Bar
|
||||
v-if="middle80AvgChartData"
|
||||
:data="middle80AvgChartData"
|
||||
:options="chartOptions"
|
||||
/>
|
||||
</div>
|
||||
</n-gi>
|
||||
<n-gi>
|
||||
<div style="height: 300px">
|
||||
<Bar
|
||||
@@ -880,37 +1179,8 @@ const radarChartOptions = {
|
||||
/>
|
||||
</div>
|
||||
</n-gi>
|
||||
<n-gi>
|
||||
<div style="height: 300px">
|
||||
<Bar
|
||||
v-if="top25AvgChartData"
|
||||
:data="top25AvgChartData"
|
||||
:options="chartOptions"
|
||||
/>
|
||||
</div>
|
||||
</n-gi>
|
||||
<n-gi>
|
||||
<div style="height: 300px">
|
||||
<Bar
|
||||
v-if="bottom25AvgChartData"
|
||||
:data="bottom25AvgChartData"
|
||||
:options="chartOptions"
|
||||
/>
|
||||
</div>
|
||||
</n-gi>
|
||||
</n-grid>
|
||||
</n-card>
|
||||
|
||||
<!-- 多维度雷达图 - 综合对比 -->
|
||||
<n-card title="多维度综合对比" style="margin-top: 20px">
|
||||
<div style="height: 500px">
|
||||
<Radar
|
||||
v-if="radarChartData"
|
||||
:data="radarChartData"
|
||||
:options="radarChartOptions"
|
||||
/>
|
||||
</div>
|
||||
</n-card>
|
||||
</template>
|
||||
|
||||
<!-- 对比表格 -->
|
||||
@@ -919,123 +1189,7 @@ const radarChartOptions = {
|
||||
title="对比表格"
|
||||
style="margin-top: 20px"
|
||||
>
|
||||
<n-data-table
|
||||
:data="comparisons"
|
||||
:columns="[
|
||||
{
|
||||
title: '排名',
|
||||
key: 'rank',
|
||||
render: (_, index) => getRankColor(index).text,
|
||||
width: 80,
|
||||
},
|
||||
{
|
||||
title: '班级',
|
||||
key: 'class_name',
|
||||
render: (row) =>
|
||||
`${row.class_name.slice(0, 2)}计算机${row.class_name.slice(2)}班`,
|
||||
width: 160,
|
||||
},
|
||||
{
|
||||
title: '人数',
|
||||
key: 'user_count',
|
||||
width: 80,
|
||||
render: (row) =>
|
||||
h(
|
||||
'span',
|
||||
{ style: { color: '#1890ff', fontWeight: '600' } },
|
||||
row.user_count,
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '总AC数',
|
||||
key: 'total_ac',
|
||||
width: 100,
|
||||
render: (row) =>
|
||||
h(
|
||||
'span',
|
||||
{ style: { color: '#ff4d4f', fontWeight: '600' } },
|
||||
row.total_ac,
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '平均AC',
|
||||
key: 'avg_ac',
|
||||
width: 100,
|
||||
render: (row) =>
|
||||
h(
|
||||
'span',
|
||||
{ style: { color: '#52c41a', fontWeight: '600' } },
|
||||
row.avg_ac.toFixed(2),
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '中位数AC',
|
||||
key: 'median_ac',
|
||||
width: 100,
|
||||
render: (row) =>
|
||||
h(
|
||||
'span',
|
||||
{ style: { color: '#fa8c16', fontWeight: '600' } },
|
||||
row.median_ac.toFixed(2),
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '前10名平均',
|
||||
key: 'top_10_avg',
|
||||
width: 100,
|
||||
render: (row) =>
|
||||
h(
|
||||
'span',
|
||||
{ style: { color: '#cf1322', fontWeight: '600' } },
|
||||
row.top_10_avg.toFixed(2),
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '后10名平均',
|
||||
key: 'bottom_10_avg',
|
||||
width: 100,
|
||||
render: (row) =>
|
||||
h(
|
||||
'span',
|
||||
{ style: { color: '#096dd9', fontWeight: '500' } },
|
||||
row.bottom_10_avg.toFixed(2),
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '优秀率',
|
||||
key: 'excellent_rate',
|
||||
width: 100,
|
||||
render: (row) =>
|
||||
h(
|
||||
'span',
|
||||
{ style: { color: '#faad14', fontWeight: '600' } },
|
||||
row.excellent_rate.toFixed(1) + '%',
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '及格率',
|
||||
key: 'pass_rate',
|
||||
width: 100,
|
||||
render: (row) =>
|
||||
h(
|
||||
'span',
|
||||
{ style: { color: '#52c41a', fontWeight: '600' } },
|
||||
row.pass_rate.toFixed(1) + '%',
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '参与度',
|
||||
key: 'active_rate',
|
||||
width: 100,
|
||||
render: (row) =>
|
||||
h(
|
||||
'span',
|
||||
{ style: { color: '#1890ff', fontWeight: '600' } },
|
||||
row.active_rate.toFixed(1) + '%',
|
||||
),
|
||||
},
|
||||
]"
|
||||
/>
|
||||
<n-data-table :data="comparisons" :columns="tableColumns" />
|
||||
</n-card>
|
||||
</n-flex>
|
||||
</n-card>
|
||||
|
||||
@@ -34,188 +34,188 @@ interface Props {
|
||||
|
||||
const props = defineProps<Props>()
|
||||
|
||||
const PENALTY_SECONDS = 20 * 60
|
||||
|
||||
const showChart = computed(() => {
|
||||
const hasRanks = props.ranks.length > 0
|
||||
const hasProblems = props.problems.length >= 3
|
||||
return hasProblems && hasRanks
|
||||
})
|
||||
|
||||
// 预定义的颜色方案 - 更现代和可访问的颜色
|
||||
const colorPalette = [
|
||||
"#3B82F6", // 蓝色
|
||||
"#EF4444", // 红色
|
||||
"#10B981", // 绿色
|
||||
"#F59E0B", // 黄色
|
||||
"#8B5CF6", // 紫色
|
||||
"#EC4899", // 粉色
|
||||
"#06B6D4", // 青色
|
||||
"#84CC16", // 青绿色
|
||||
"#F97316", // 橙色
|
||||
"#6366F1", // 靛蓝色
|
||||
"#3B82F6",
|
||||
"#EF4444",
|
||||
"#10B981",
|
||||
"#F59E0B",
|
||||
"#8B5CF6",
|
||||
"#EC4899",
|
||||
"#06B6D4",
|
||||
"#84CC16",
|
||||
"#F97316",
|
||||
"#6366F1",
|
||||
]
|
||||
|
||||
// 数据处理函数
|
||||
const processChartData = () => {
|
||||
if (!props.ranks || props.ranks.length === 0) {
|
||||
return {
|
||||
labels: [],
|
||||
datasets: [],
|
||||
}
|
||||
}
|
||||
|
||||
// 获取前10名用户的数据
|
||||
const topUsers = props.ranks.slice(0, 10)
|
||||
|
||||
// 获取所有题目ID(从所有用户的submission_info中收集)
|
||||
const allProblemIds = new Set<string>()
|
||||
topUsers.forEach((rank) => {
|
||||
Object.keys(rank.submission_info).forEach((problemId) => {
|
||||
allProblemIds.add(problemId)
|
||||
})
|
||||
})
|
||||
|
||||
// 按题目ID排序
|
||||
const problemIds = Array.from(allProblemIds).sort()
|
||||
|
||||
// 创建题目标签
|
||||
const labels = problemIds.map((id) => {
|
||||
if (props.problems) {
|
||||
const problem = props.problems.find((p) => p.id.toString() === id)
|
||||
return problem ? problem.title : `题目${id}`
|
||||
}
|
||||
return `题目${id}`
|
||||
})
|
||||
|
||||
// 找到所有用户中最早的提交时间
|
||||
let earliestTime = Infinity
|
||||
topUsers.forEach((rank) => {
|
||||
Object.values(rank.submission_info).forEach((submissionInfo) => {
|
||||
if (submissionInfo.is_ac && submissionInfo.ac_time < earliestTime) {
|
||||
earliestTime = submissionInfo.ac_time
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
// 如果没有找到任何通过记录,使用0作为基准
|
||||
if (earliestTime === Infinity) {
|
||||
earliestTime = 0
|
||||
}
|
||||
|
||||
// 为每个用户创建数据集
|
||||
const datasets = topUsers.map((rank, userIndex) => {
|
||||
const userData = problemIds.map((problemId) => {
|
||||
const submissionInfo = rank.submission_info[problemId]
|
||||
if (!submissionInfo || !submissionInfo.is_ac) {
|
||||
return null
|
||||
}
|
||||
return submissionInfo.ac_time - earliestTime
|
||||
})
|
||||
|
||||
const actualRank = userIndex + 1
|
||||
const colorIndex = userIndex % colorPalette.length
|
||||
const color = colorPalette[colorIndex]
|
||||
|
||||
return {
|
||||
label: `第${actualRank}名: ${rank.user.username}`,
|
||||
data: userData,
|
||||
borderColor: color,
|
||||
backgroundColor: color + "20",
|
||||
tension: 0.3,
|
||||
fill: false,
|
||||
pointRadius: 6,
|
||||
pointHoverRadius: 8,
|
||||
pointBackgroundColor: color,
|
||||
pointBorderColor: "#fff",
|
||||
pointBorderWidth: 2,
|
||||
spanGaps: false,
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
labels,
|
||||
datasets,
|
||||
}
|
||||
function formatTime(seconds: number): string {
|
||||
const h = Math.floor(seconds / 3600)
|
||||
const m = Math.floor((seconds % 3600) / 60)
|
||||
if (h > 0) return `${h}h${m}m`
|
||||
return `${m}m`
|
||||
}
|
||||
|
||||
// 监听数据变化,重新处理
|
||||
watch(
|
||||
() => [props.ranks, props.problems],
|
||||
() => {
|
||||
if (props.ranks && props.ranks.length > 0) {
|
||||
// 数据变化时重新处理
|
||||
}
|
||||
},
|
||||
{ deep: true, immediate: true },
|
||||
)
|
||||
interface AcEvent {
|
||||
time: number
|
||||
userIndex: number
|
||||
problemId: string
|
||||
}
|
||||
|
||||
const chartData = computed(() => {
|
||||
return processChartData()
|
||||
if (!props.ranks || props.ranks.length === 0) {
|
||||
return { labels: [], datasets: [] }
|
||||
}
|
||||
|
||||
const topUsers = props.ranks.slice(0, 10)
|
||||
|
||||
// 收集所有AC事件并按时间排序
|
||||
const events: AcEvent[] = []
|
||||
topUsers.forEach((rank, userIndex) => {
|
||||
Object.entries(rank.submission_info).forEach(([problemId, info]) => {
|
||||
if (info.is_ac) {
|
||||
events.push({ time: info.ac_time, userIndex, problemId })
|
||||
}
|
||||
})
|
||||
})
|
||||
events.sort((a, b) => a.time - b.time)
|
||||
|
||||
if (events.length === 0) {
|
||||
return { labels: [], datasets: [] }
|
||||
}
|
||||
|
||||
// 在每个时间点计算所有人的排名
|
||||
// 状态: 每个用户当前已AC题数和罚时
|
||||
const userState = topUsers.map(() => ({
|
||||
solved: 0,
|
||||
penalty: 0,
|
||||
}))
|
||||
|
||||
// 用于记录每个用户每道题的错误次数
|
||||
const userErrors: Map<string, number>[] = topUsers.map(() => new Map())
|
||||
topUsers.forEach((rank, i) => {
|
||||
Object.entries(rank.submission_info).forEach(([problemId, info]) => {
|
||||
if (info.error_number > 0) {
|
||||
userErrors[i].set(problemId, info.error_number)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
function calcRanks(): number[] {
|
||||
const indexed = userState.map((s, i) => ({ ...s, i }))
|
||||
indexed.sort((a, b) => {
|
||||
if (b.solved !== a.solved) return b.solved - a.solved
|
||||
return a.penalty - b.penalty
|
||||
})
|
||||
const ranks = new Array(topUsers.length).fill(0)
|
||||
indexed.forEach((item, pos) => {
|
||||
ranks[item.i] = pos + 1
|
||||
})
|
||||
return ranks
|
||||
}
|
||||
|
||||
// 时间轴上的数据点: [时间标签, 各用户排名]
|
||||
const timePoints: number[] = [0]
|
||||
const rankSnapshots: number[][] = [calcRanks()]
|
||||
|
||||
// 按时间处理事件(合并同一时刻的事件)
|
||||
let i = 0
|
||||
while (i < events.length) {
|
||||
const currentTime = events[i].time
|
||||
// 处理同一时刻的所有事件
|
||||
while (i < events.length && events[i].time === currentTime) {
|
||||
const ev = events[i]
|
||||
userState[ev.userIndex].solved++
|
||||
const errors = userErrors[ev.userIndex].get(ev.problemId) || 0
|
||||
userState[ev.userIndex].penalty =
|
||||
userState[ev.userIndex].penalty + ev.time + errors * PENALTY_SECONDS
|
||||
i++
|
||||
}
|
||||
timePoints.push(currentTime)
|
||||
rankSnapshots.push(calcRanks())
|
||||
}
|
||||
|
||||
const labels = timePoints.map((t) => formatTime(t))
|
||||
|
||||
const datasets = topUsers.map((rank, userIndex) => {
|
||||
const color = colorPalette[userIndex % colorPalette.length]
|
||||
const finalRank = rankSnapshots[rankSnapshots.length - 1][userIndex]
|
||||
return {
|
||||
label: `#${finalRank} ${rank.user.username}`,
|
||||
data: rankSnapshots.map((snapshot) => snapshot[userIndex]),
|
||||
borderColor: color,
|
||||
backgroundColor: color,
|
||||
tension: 0.3,
|
||||
fill: false,
|
||||
pointRadius: 3,
|
||||
pointHoverRadius: 6,
|
||||
pointBackgroundColor: color,
|
||||
pointBorderColor: "#fff",
|
||||
pointBorderWidth: 1,
|
||||
borderWidth: 2.5,
|
||||
}
|
||||
})
|
||||
|
||||
return { labels, datasets }
|
||||
})
|
||||
|
||||
const chartOptions = computed(() => ({
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
interaction: {
|
||||
mode: "index" as const,
|
||||
intersect: false,
|
||||
},
|
||||
plugins: {
|
||||
legend: {
|
||||
display: true,
|
||||
position: "top" as const,
|
||||
maxHeight: 80,
|
||||
labels: {
|
||||
boxWidth: 12,
|
||||
boxHeight: 12,
|
||||
padding: 8,
|
||||
usePointStyle: true,
|
||||
font: {
|
||||
size: 11,
|
||||
},
|
||||
boxWidth: 14,
|
||||
boxHeight: 3,
|
||||
padding: 10,
|
||||
font: { size: 12 },
|
||||
},
|
||||
},
|
||||
tooltip: {
|
||||
mode: "index" as const,
|
||||
intersect: false,
|
||||
itemSort: (a: any, b: any) => a.parsed.y - b.parsed.y,
|
||||
callbacks: {
|
||||
title: function (context: any) {
|
||||
return `题目: ${context[0].label}`
|
||||
},
|
||||
label: function (context: any) {
|
||||
const value = context.parsed.y
|
||||
const label = context.dataset.label
|
||||
|
||||
if (value === null) {
|
||||
return `${label}: 未通过`
|
||||
}
|
||||
|
||||
const hours = Math.floor(value / 3600)
|
||||
const minutes = Math.floor((value % 3600) / 60)
|
||||
const seconds = Math.floor(value % 60)
|
||||
|
||||
let timeStr = ""
|
||||
if (hours > 0) timeStr += `${hours}小时`
|
||||
if (minutes > 0) timeStr += `${minutes}分钟`
|
||||
if (seconds > 0 || timeStr === "") timeStr += `${seconds}秒`
|
||||
|
||||
return `${label}: +${timeStr}`
|
||||
title: (context: any) => `比赛进行: ${context[0].label}`,
|
||||
label: (context: any) => {
|
||||
const rank = context.parsed.y
|
||||
const name = context.dataset.label
|
||||
return ` 第${rank}名 — ${name}`
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
title: {
|
||||
display: true,
|
||||
text: "比赛时间",
|
||||
},
|
||||
},
|
||||
y: {
|
||||
title: {
|
||||
display: true,
|
||||
text: "相对通过时间",
|
||||
text: "排名",
|
||||
},
|
||||
min: 0,
|
||||
reverse: true,
|
||||
min: 1,
|
||||
max: 10,
|
||||
ticks: {
|
||||
callback: function (value: any) {
|
||||
const hours = Math.floor(value / 3600)
|
||||
const minutes = Math.floor((value % 3600) / 60)
|
||||
const seconds = Math.floor(value % 60)
|
||||
|
||||
if (hours > 0) return `+${hours}h${minutes}m`
|
||||
if (minutes > 0) return `+${minutes}m${seconds}s`
|
||||
return `+${seconds}s`
|
||||
},
|
||||
stepSize: 1,
|
||||
callback: (value: any) => `第${value}名`,
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -224,7 +224,7 @@ const chartOptions = computed(() => ({
|
||||
|
||||
<style scoped>
|
||||
.chart {
|
||||
height: 500px;
|
||||
height: 420px;
|
||||
width: 100%;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ import { useRouteQuery } from "@vueuse/router"
|
||||
import { NTag } from "naive-ui"
|
||||
import { getContestList } from "oj/api"
|
||||
import { duration, parseTime } from "utils/functions"
|
||||
import { Contest } from "utils/types"
|
||||
import type { Contest } from "utils/types"
|
||||
import ContestTitle from "shared/components/ContestTitle.vue"
|
||||
import Pagination from "shared/components/Pagination.vue"
|
||||
import { useAuthModalStore } from "shared/store/authModal"
|
||||
|
||||
@@ -8,7 +8,7 @@ import Pagination from "shared/components/Pagination.vue"
|
||||
import { usePagination } from "shared/composables/pagination"
|
||||
import { ContestStatus } from "utils/constants"
|
||||
import { renderTableTitle } from "utils/renders"
|
||||
import { ContestRank, ProblemFiltered } from "utils/types"
|
||||
import type { ContestRank, ProblemFiltered } from "utils/types"
|
||||
import AcAndSubmission from "../components/AcAndSubmission.vue"
|
||||
import LineChart from "../components/LineChart.vue"
|
||||
|
||||
@@ -95,7 +95,6 @@ async function listRanks() {
|
||||
const res = await getContestRank(props.contestID, {
|
||||
limit: query.limit,
|
||||
offset: query.limit * (query.page - 1),
|
||||
force_refresh: "1",
|
||||
})
|
||||
total.value = res.data.total
|
||||
data.value = res.data.results
|
||||
@@ -190,6 +189,77 @@ async function addColumns() {
|
||||
}
|
||||
}
|
||||
|
||||
// 导出弹窗
|
||||
const showExportModal = ref(false)
|
||||
const exportLoading = ref(false)
|
||||
const exportForm = reactive({
|
||||
first: 0,
|
||||
second: 0,
|
||||
third: 0,
|
||||
})
|
||||
|
||||
watch(
|
||||
() => total.value,
|
||||
(val) => {
|
||||
if (val > 0) {
|
||||
exportForm.first = Math.round(val * 0.1)
|
||||
exportForm.second = Math.round(val * 0.2)
|
||||
exportForm.third = Math.round(val * 0.3)
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
function openExportModal() {
|
||||
if (total.value > 0) {
|
||||
exportForm.first = Math.round(total.value * 0.1)
|
||||
exportForm.second = Math.round(total.value * 0.2)
|
||||
exportForm.third = Math.round(total.value * 0.3)
|
||||
}
|
||||
showExportModal.value = true
|
||||
}
|
||||
|
||||
async function downloadExcel() {
|
||||
exportLoading.value = true
|
||||
try {
|
||||
const res = await getContestRank(props.contestID, {
|
||||
limit: total.value || 10000,
|
||||
offset: 0,
|
||||
})
|
||||
const allRanks: ContestRank[] = res.data.results
|
||||
|
||||
const rows = allRanks.map((rank, index) => {
|
||||
const rank1 = index + 1
|
||||
let level = ""
|
||||
if (rank1 <= exportForm.first) {
|
||||
level = "一等奖"
|
||||
} else if (rank1 <= exportForm.first + exportForm.second) {
|
||||
level = "二等奖"
|
||||
} else if (
|
||||
rank1 <=
|
||||
exportForm.first + exportForm.second + exportForm.third
|
||||
) {
|
||||
level = "三等奖"
|
||||
} else {
|
||||
level = "参与奖"
|
||||
}
|
||||
return { 用户名: rank.user.username, 等级: level }
|
||||
})
|
||||
|
||||
const csv =
|
||||
"用户名,等级\n" + rows.map((r) => `${r.用户名},${r.等级}`).join("\n")
|
||||
const blob = new Blob(["" + csv], { type: "text/csv;charset=utf-8" })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement("a")
|
||||
a.href = url
|
||||
a.download = `${contestStore.contest?.title ?? "contest"}获奖情况.csv`
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
showExportModal.value = false
|
||||
} finally {
|
||||
exportLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 监听分页参数变化
|
||||
watch([() => query.page, () => query.limit], listRanks)
|
||||
watch(autoRefresh, (checked) => (checked ? resume() : pause()))
|
||||
@@ -223,6 +293,13 @@ onMounted(() => {
|
||||
<n-switch v-model:value="autoRefresh" />
|
||||
</n-form-item>
|
||||
</n-form>
|
||||
<n-button
|
||||
v-if="contestStore.contestStatus === ContestStatus.finished"
|
||||
type="primary"
|
||||
@click="openExportModal"
|
||||
>
|
||||
导出数据
|
||||
</n-button>
|
||||
<Pagination
|
||||
:total="total"
|
||||
:limit="query.limit"
|
||||
@@ -231,6 +308,31 @@ onMounted(() => {
|
||||
@update:page="(page: number) => (query.page = page)"
|
||||
/>
|
||||
</n-space>
|
||||
|
||||
<n-modal v-model:show="showExportModal" preset="dialog" title="导出获奖数据">
|
||||
<n-form
|
||||
label-placement="left"
|
||||
label-width="auto"
|
||||
:show-feedback="false"
|
||||
style="margin-top: 16px"
|
||||
>
|
||||
<n-form-item label="一等奖人数" style="margin-bottom: 12px">
|
||||
<n-input-number v-model:value="exportForm.first" :min="0" />
|
||||
</n-form-item>
|
||||
<n-form-item label="二等奖人数" style="margin-bottom: 12px">
|
||||
<n-input-number v-model:value="exportForm.second" :min="0" />
|
||||
</n-form-item>
|
||||
<n-form-item label="三等奖人数">
|
||||
<n-input-number v-model:value="exportForm.third" :min="0" />
|
||||
</n-form-item>
|
||||
</n-form>
|
||||
<template #action>
|
||||
<n-button @click="showExportModal = false">取消</n-button>
|
||||
<n-button type="primary" :loading="exportLoading" @click="downloadExcel">
|
||||
下载 CSV
|
||||
</n-button>
|
||||
</template>
|
||||
</n-modal>
|
||||
</template>
|
||||
<style>
|
||||
.oj-time-with-modal {
|
||||
|
||||
@@ -85,19 +85,19 @@ function inputWidth(idx: number): string {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<n-card
|
||||
size="small"
|
||||
style="margin: 16px 0; border: 1.5px solid var(--n-border-color)"
|
||||
>
|
||||
<n-card style="margin: 16px 0; border: 1.5px solid var(--n-border-color)">
|
||||
<template #header>
|
||||
<n-tag type="warning" size="small" :bordered="false">练一练 · 代码填空</n-tag>
|
||||
<n-tag type="warning" :bordered="false">练一练 · 代码填空</n-tag>
|
||||
</template>
|
||||
|
||||
<p style="font-weight: 500; margin-bottom: 12px">{{ data.question }}</p>
|
||||
<p style="font-weight: 500; font-size: 16px; margin-bottom: 12px">
|
||||
{{ data.question }}
|
||||
</p>
|
||||
|
||||
<pre
|
||||
:style="{
|
||||
fontFamily: 'Monaco',
|
||||
fontSize: '16px',
|
||||
lineHeight: '1.6',
|
||||
background: 'var(--n-color)',
|
||||
border: '1px solid var(--n-border-color)',
|
||||
@@ -115,7 +115,8 @@ function inputWidth(idx: number): string {
|
||||
:style="{
|
||||
width: inputWidth(seg.index),
|
||||
fontFamily: 'Monaco',
|
||||
padding: '1px 4px',
|
||||
fontSize: '16px',
|
||||
padding: '2px 6px',
|
||||
borderRadius: '3px',
|
||||
border: `1.5px solid ${
|
||||
allCorrect
|
||||
@@ -144,15 +145,10 @@ function inputWidth(idx: number): string {
|
||||
/>
|
||||
|
||||
<n-space style="margin-top: 12px" :size="8">
|
||||
<n-button
|
||||
type="warning"
|
||||
size="small"
|
||||
:disabled="allCorrect"
|
||||
@click="submit"
|
||||
>
|
||||
<n-button type="warning" :disabled="allCorrect" @click="submit">
|
||||
提交
|
||||
</n-button>
|
||||
<n-button size="small" @click="reset">重置</n-button>
|
||||
<n-button @click="reset">重置</n-button>
|
||||
</n-space>
|
||||
</n-card>
|
||||
</template>
|
||||
|
||||
@@ -63,19 +63,18 @@ function optionType(idx: number): "default" | "primary" | "success" {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<n-card
|
||||
size="small"
|
||||
style="margin: 16px 0; border: 1.5px solid var(--n-border-color)"
|
||||
>
|
||||
<n-card style="margin: 16px 0; border: 1.5px solid var(--n-border-color)">
|
||||
<template #header>
|
||||
<n-space align="center" :size="8">
|
||||
<n-tag type="success" size="small" :bordered="false">
|
||||
<n-tag type="success" :bordered="false">
|
||||
练一练 · {{ isSingle ? "单选题" : "多选题" }}
|
||||
</n-tag>
|
||||
</n-space>
|
||||
</template>
|
||||
|
||||
<p style="font-weight: 500; margin-bottom: 12px">{{ data.question }}</p>
|
||||
<p style="font-weight: 500; font-size: 16px; margin-bottom: 12px">
|
||||
{{ data.question }}
|
||||
</p>
|
||||
|
||||
<n-space vertical :size="8">
|
||||
<n-button
|
||||
@@ -113,13 +112,12 @@ function optionType(idx: number): "default" | "primary" | "success" {
|
||||
<n-space style="margin-top: 12px" :size="8">
|
||||
<n-button
|
||||
type="primary"
|
||||
size="small"
|
||||
:disabled="selected.size === 0 || correct"
|
||||
@click="submit"
|
||||
>
|
||||
提交
|
||||
</n-button>
|
||||
<n-button size="small" @click="reset">重置</n-button>
|
||||
<n-button @click="reset">重置</n-button>
|
||||
</n-space>
|
||||
</n-card>
|
||||
</template>
|
||||
|
||||
@@ -101,17 +101,14 @@ const lineHtmlMap = computed<Record<number, string>>(() => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<n-card
|
||||
size="small"
|
||||
style="margin: 16px 0; border: 1.5px solid var(--n-border-color)"
|
||||
>
|
||||
<n-card style="margin: 16px 0; border: 1.5px solid var(--n-border-color)">
|
||||
<template #header>
|
||||
<n-tag type="info" size="small" :bordered="false"
|
||||
>练一练 · 代码排序</n-tag
|
||||
>
|
||||
<n-tag type="info" :bordered="false">练一练 · 代码排序</n-tag>
|
||||
</template>
|
||||
|
||||
<p style="font-weight: 500; margin-bottom: 12px">{{ data.question }}</p>
|
||||
<p style="font-weight: 500; font-size: 16px; margin-bottom: 12px">
|
||||
{{ data.question }}
|
||||
</p>
|
||||
|
||||
<n-space vertical :size="6">
|
||||
<div
|
||||
@@ -158,15 +155,10 @@ const lineHtmlMap = computed<Record<number, string>>(() => {
|
||||
/>
|
||||
|
||||
<n-space style="margin-top: 12px" :size="8">
|
||||
<n-button
|
||||
type="info"
|
||||
size="small"
|
||||
:disabled="submitted && allCorrect"
|
||||
@click="submit"
|
||||
>
|
||||
<n-button type="info" :disabled="submitted && allCorrect" @click="submit">
|
||||
提交
|
||||
</n-button>
|
||||
<n-button size="small" @click="reset">重置</n-button>
|
||||
<n-button @click="reset">重置</n-button>
|
||||
</n-space>
|
||||
</n-card>
|
||||
</template>
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="learn-container">
|
||||
<!-- 桌面端布局 -->
|
||||
<n-grid :cols="5" :x-gap="16" v-if="tutorial.id && isDesktop">
|
||||
<n-gi :span="1">
|
||||
<n-grid
|
||||
:cols="5"
|
||||
:x-gap="16"
|
||||
v-if="tutorial.id && isDesktop"
|
||||
class="learn-grid"
|
||||
>
|
||||
<n-gi :span="1" class="learn-col">
|
||||
<n-card title="教程目录" :bordered="false" size="small">
|
||||
<n-list hoverable clickable>
|
||||
<n-list-item
|
||||
@@ -21,7 +26,7 @@
|
||||
</n-card>
|
||||
</n-gi>
|
||||
|
||||
<n-gi :span="tutorial.code ? 2 : 4">
|
||||
<n-gi :span="tutorial.code ? 2 : 4" class="learn-col">
|
||||
<n-card
|
||||
:title="`第 ${step} 课:${titles[step - 1]?.title}`"
|
||||
:bordered="false"
|
||||
@@ -43,9 +48,19 @@
|
||||
</n-card>
|
||||
</n-gi>
|
||||
|
||||
<n-gi :span="2" v-if="tutorial.code">
|
||||
<n-card title="示例代码" :bordered="false" size="small">
|
||||
<CodeEditor language="Python3" v-model="tutorial.code" />
|
||||
<n-gi :span="2" v-if="tutorial.code" class="learn-col learn-col--code">
|
||||
<n-card
|
||||
title="示例代码"
|
||||
:bordered="false"
|
||||
size="small"
|
||||
class="code-card"
|
||||
content-style="height: calc(100% - 44px); padding: 0;"
|
||||
>
|
||||
<CodeEditor
|
||||
language="Python3"
|
||||
v-model="tutorial.code"
|
||||
height="100%"
|
||||
/>
|
||||
</n-card>
|
||||
</n-gi>
|
||||
</n-grid>
|
||||
@@ -119,7 +134,7 @@
|
||||
<script setup lang="ts">
|
||||
import { MdPreview } from "md-editor-v3"
|
||||
import "md-editor-v3/lib/preview.css"
|
||||
import { Tutorial, Exercise } from "utils/types"
|
||||
import type { Tutorial, Exercise } from "utils/types"
|
||||
import { getTutorial, getTutorials, getExercises } from "../api"
|
||||
import { parseExercises } from "./composables/useExerciseParse"
|
||||
import { useBreakpoints } from "shared/composables/breakpoints"
|
||||
@@ -190,3 +205,26 @@ watch(
|
||||
{ immediate: true },
|
||||
)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.learn-container {
|
||||
height: calc(100vh - 138px);
|
||||
}
|
||||
|
||||
.learn-grid {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.learn-col {
|
||||
overflow-y: auto;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.learn-col--code {
|
||||
overflow-y: hidden;
|
||||
}
|
||||
|
||||
.code-card {
|
||||
height: 100%;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -31,9 +31,7 @@ interface Props {
|
||||
isConnected?: boolean // WebSocket 实际的连接状态(已建立/未建立)
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
isConnected: false,
|
||||
})
|
||||
const { storageKey, isConnected = false } = defineProps<Props>()
|
||||
|
||||
// 注入同步状态
|
||||
const syncStatus = injectSyncStatus()
|
||||
@@ -102,7 +100,7 @@ const reset = () => {
|
||||
problem.value!.template[codeStore.code.language] ||
|
||||
SOURCES[codeStore.code.language],
|
||||
)
|
||||
storage.remove(props.storageKey)
|
||||
storage.remove(storageKey)
|
||||
message.success("代码重置成功")
|
||||
}
|
||||
|
||||
@@ -185,7 +183,7 @@ onMounted(() => {
|
||||
</n-button>
|
||||
|
||||
<n-button
|
||||
v-if="userStore.isSuperAdmin"
|
||||
v-if="userStore.isTeacherOrAbove"
|
||||
:size="buttonSize"
|
||||
@click="statisticPanel = true"
|
||||
>
|
||||
@@ -228,7 +226,7 @@ onMounted(() => {
|
||||
/>
|
||||
|
||||
<!-- 同步状态标签 -->
|
||||
<template v-if="props.isConnected">
|
||||
<template v-if="isConnected">
|
||||
<n-tag v-if="syncStatus.otherUser.value" type="info">
|
||||
{{ SYNC_MESSAGES.SYNCING_WITH(syncStatus.otherUser.value.name) }}
|
||||
</n-tag>
|
||||
@@ -247,7 +245,7 @@ onMounted(() => {
|
||||
</n-flex>
|
||||
|
||||
<n-modal
|
||||
v-if="userStore.isSuperAdmin"
|
||||
v-if="userStore.isTeacherOrAbove"
|
||||
v-model:show="statisticPanel"
|
||||
preset="card"
|
||||
title="提交记录的统计"
|
||||
|
||||
42
src/oj/problem/components/MyFlowchartTab.vue
Normal file
42
src/oj/problem/components/MyFlowchartTab.vue
Normal file
@@ -0,0 +1,42 @@
|
||||
<script setup lang="ts">
|
||||
import { useMyFlowchartStore } from "shared/store/myFlowchart"
|
||||
import { useMermaid } from "shared/composables/useMermaid"
|
||||
|
||||
const store = useMyFlowchartStore()
|
||||
const { renderError, renderFlowchart } = useMermaid()
|
||||
const mermaidContainer = useTemplateRef<HTMLElement>("mermaidContainer")
|
||||
|
||||
watch(
|
||||
() => store.mermaidCode,
|
||||
async (code) => {
|
||||
if (!code) return
|
||||
await nextTick()
|
||||
await renderFlowchart(mermaidContainer.value, code)
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div style="padding: 8px 0">
|
||||
<n-alert v-if="renderError" type="error" title="渲染失败" size="small">
|
||||
{{ renderError }}
|
||||
</n-alert>
|
||||
<div v-else ref="mermaidContainer" class="flowchart-container"></div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.flowchart-container {
|
||||
width: 100%;
|
||||
min-height: 500px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
:deep(.flowchart-container > svg) {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
</style>
|
||||
@@ -67,7 +67,7 @@
|
||||
{{ content }}
|
||||
</n-form-item>
|
||||
<n-button
|
||||
v-if="hasCommented && props.showStatistics"
|
||||
v-if="hasCommented && showStatistics"
|
||||
type="primary"
|
||||
@click="getComments"
|
||||
>
|
||||
@@ -77,7 +77,7 @@
|
||||
提交
|
||||
</n-button>
|
||||
</n-form>
|
||||
<div v-if="props.showStatistics">
|
||||
<div v-if="showStatistics">
|
||||
<n-descriptions
|
||||
class="list"
|
||||
v-if="count"
|
||||
@@ -117,9 +117,7 @@ interface Props {
|
||||
showStatistics?: boolean
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
showStatistics: true,
|
||||
})
|
||||
const { showStatistics = true } = defineProps<Props>()
|
||||
|
||||
const userStore = useUserStore()
|
||||
const problemStore = useProblemStore()
|
||||
|
||||
@@ -6,7 +6,7 @@ import { useCodeStore } from "oj/store/code"
|
||||
import { useProblemStore } from "oj/store/problem"
|
||||
import { createTestSubmission } from "utils/judge"
|
||||
import { DIFFICULTY } from "utils/constants"
|
||||
import { Problem, ProblemStatus } from "utils/types"
|
||||
import type { Problem, ProblemStatus } from "utils/types"
|
||||
import Copy from "shared/components/Copy.vue"
|
||||
import { useDark } from "@vueuse/core"
|
||||
import { MdPreview } from "md-editor-v3"
|
||||
@@ -58,11 +58,7 @@ watch(
|
||||
|
||||
// AC 或失败次数 >= 3 时加载推荐
|
||||
watch(
|
||||
() => [
|
||||
problem.value?._id,
|
||||
problem.value?.my_status,
|
||||
problemStore.totalFailCount,
|
||||
],
|
||||
() => [problem.value?._id, problem.value?.my_status, problemStore.failCount],
|
||||
([, status, failCount]) => {
|
||||
if (status === 0 || (failCount as number) >= 3) {
|
||||
loadSimilarProblems()
|
||||
@@ -89,6 +85,91 @@ const samples = ref<Sample[]>(
|
||||
})),
|
||||
)
|
||||
|
||||
const NODE_TARGET_LABELS: Record<string, string> = {
|
||||
for_loop: "for 循环",
|
||||
while_loop: "while 循环",
|
||||
if_statement: "if 条件",
|
||||
else_clause: "else 子句",
|
||||
function_definition: "函数定义",
|
||||
return: "return 语句",
|
||||
break: "break 语句",
|
||||
continue: "continue 语句",
|
||||
list_comprehension: "列表推导式",
|
||||
list_literal: "列表",
|
||||
dict_literal: "字典",
|
||||
set_literal: "集合",
|
||||
f_string: "f-string",
|
||||
try_except: "try-except",
|
||||
class_definition: "类定义",
|
||||
}
|
||||
|
||||
type AstRule = {
|
||||
engine: string
|
||||
target?: string
|
||||
label?: string
|
||||
exact?: number
|
||||
min?: number
|
||||
max?: number
|
||||
message: string
|
||||
}
|
||||
|
||||
function ruleDescription(rule: AstRule): string {
|
||||
if (rule.message) return rule.message
|
||||
const target = rule.target || ""
|
||||
const targetLabel = rule.label || NODE_TARGET_LABELS[target] || target
|
||||
const countDesc = () => {
|
||||
if (rule.exact !== undefined) return `出现 ${rule.exact} 次`
|
||||
if (rule.min !== undefined && rule.max !== undefined)
|
||||
return `出现 ${rule.min}~${rule.max} 次`
|
||||
if (rule.min !== undefined) return `至少出现 ${rule.min} 次`
|
||||
if (rule.max !== undefined) return `至多出现 ${rule.max} 次`
|
||||
return ""
|
||||
}
|
||||
const callDesc = () => {
|
||||
if (rule.exact !== undefined) return `调用 ${rule.exact} 次`
|
||||
if (rule.min !== undefined && rule.max !== undefined)
|
||||
return `调用 ${rule.min}~${rule.max} 次`
|
||||
if (rule.min !== undefined) return `至少调用 ${rule.min} 次`
|
||||
if (rule.max !== undefined) return `至多调用 ${rule.max} 次`
|
||||
return ""
|
||||
}
|
||||
switch (rule.engine) {
|
||||
case "must_exist_node":
|
||||
return `必须使用 ${targetLabel}`
|
||||
case "must_not_exist_node":
|
||||
return `不能使用 ${targetLabel}`
|
||||
case "count_node":
|
||||
return `${targetLabel} ${countDesc()}`
|
||||
case "must_call_function":
|
||||
return `必须调用 ${target}()`
|
||||
case "must_not_call_function":
|
||||
return `不能调用 ${target}()`
|
||||
case "count_function_call":
|
||||
return `${target}() ${callDesc()}`
|
||||
case "must_call_method":
|
||||
return `必须调用 .${target}()`
|
||||
case "must_not_call_method":
|
||||
return `不能调用 .${target}()`
|
||||
case "must_use_operator":
|
||||
return `必须使用 ${target} 运算符`
|
||||
default:
|
||||
return rule.engine
|
||||
}
|
||||
}
|
||||
|
||||
function ruleTagType(engine: string): "error" | "success" | "info" {
|
||||
if (engine.startsWith("must_not")) return "error"
|
||||
if (engine.startsWith("must")) return "success"
|
||||
return "info"
|
||||
}
|
||||
|
||||
const astRulesForDisplay = computed(() => {
|
||||
if (!problem.value?.ast_rules) return []
|
||||
return Object.entries(problem.value.ast_rules).filter(
|
||||
([, rules]) => rules.length > 0,
|
||||
)
|
||||
})
|
||||
|
||||
async function test(sample: Sample, index: number) {
|
||||
samples.value = samples.value.map((sample) => {
|
||||
if (sample.id === index) {
|
||||
@@ -226,6 +307,33 @@ function type(status: ProblemStatus) {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 代码要求(AST 规则) -->
|
||||
<div v-if="astRulesForDisplay.length > 0">
|
||||
<p class="title" :style="style">
|
||||
<n-flex align="center">
|
||||
<Icon icon="streamline-emojis:open-book"></Icon>
|
||||
要求
|
||||
</n-flex>
|
||||
</p>
|
||||
<div v-for="[lang, rules] in astRulesForDisplay" :key="lang">
|
||||
<p v-if="astRulesForDisplay.length > 1" class="lang-label">
|
||||
{{ lang }}
|
||||
</p>
|
||||
<n-list bordered style="margin-bottom: 8px">
|
||||
<n-list-item v-for="(rule, i) in rules" :key="i">
|
||||
<n-flex align="center">
|
||||
<n-tag :type="ruleTagType(rule.engine)">
|
||||
{{ ruleDescription(rule) }}
|
||||
</n-tag>
|
||||
<span v-if="rule.message" class="rule-message">{{
|
||||
rule.message
|
||||
}}</span>
|
||||
</n-flex>
|
||||
</n-list-item>
|
||||
</n-list>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-for="(sample, index) of samples" :key="index">
|
||||
<n-flex align="center">
|
||||
<p class="title" :style="style">例子 {{ index + 1 }}</p>
|
||||
@@ -342,4 +450,14 @@ function type(status: ProblemStatus) {
|
||||
.status-alert {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.lang-label {
|
||||
font-weight: 600;
|
||||
margin: 8px 0 4px;
|
||||
}
|
||||
|
||||
.rule-message {
|
||||
font-size: 13px;
|
||||
opacity: 0.65;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -7,7 +7,7 @@ import { SOURCES } from "utils/constants"
|
||||
import SyncCodeEditor from "shared/components/SyncCodeEditor.vue"
|
||||
import { useBreakpoints } from "shared/composables/breakpoints"
|
||||
import storage from "utils/storage"
|
||||
import { LANGUAGE } from "utils/types"
|
||||
import type { LANGUAGE } from "utils/types"
|
||||
import Form from "./Form.vue"
|
||||
|
||||
const FlowchartEditor = defineAsyncComponent(
|
||||
@@ -51,6 +51,13 @@ onMounted(loadCode)
|
||||
|
||||
watch(() => problem.value?._id, loadCode)
|
||||
|
||||
watch(
|
||||
() => codeStore.code.value,
|
||||
(v) => {
|
||||
storage.set(storageKey.value, v)
|
||||
},
|
||||
)
|
||||
|
||||
const changeCode = (v: string) => {
|
||||
storage.set(storageKey.value, v)
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script lang="ts" setup>
|
||||
import { ProblemFiltered } from "utils/types"
|
||||
import type { ProblemFiltered } from "utils/types"
|
||||
import { Icon } from "@iconify/vue"
|
||||
|
||||
defineProps<{
|
||||
@@ -19,5 +19,10 @@ defineProps<{
|
||||
width="18"
|
||||
icon="vscode-icons:file-type-graphql"
|
||||
/>
|
||||
<Icon
|
||||
v-if="problem.has_ast_rules"
|
||||
width="18"
|
||||
icon="vscode-icons:file-type-light-todo"
|
||||
/>
|
||||
</n-flex>
|
||||
</template>
|
||||
|
||||
@@ -1,17 +1,14 @@
|
||||
<script lang="ts" setup>
|
||||
import { NButton } from "naive-ui"
|
||||
import { NButton, NFlex, NTooltip } from "naive-ui"
|
||||
import { Icon } from "@iconify/vue"
|
||||
import { getSubmissions, getRankOfProblem } from "oj/api"
|
||||
import Pagination from "shared/components/Pagination.vue"
|
||||
import SubmissionResultTag from "shared/components/SubmissionResultTag.vue"
|
||||
import { useUserStore } from "shared/store/user"
|
||||
import {
|
||||
JUDGE_STATUS,
|
||||
LANGUAGE_SHOW_VALUE,
|
||||
SubmissionStatus,
|
||||
} from "utils/constants"
|
||||
import { JUDGE_STATUS, LANGUAGE_SHOW_VALUE } from "utils/constants"
|
||||
import { parseTime } from "utils/functions"
|
||||
import { renderTableTitle } from "utils/renders"
|
||||
import { Submission } from "utils/types"
|
||||
import type { Submission } from "utils/types"
|
||||
import SubmissionDetail from "oj/submission/detail.vue"
|
||||
import { useBreakpoints } from "shared/composables/breakpoints"
|
||||
|
||||
@@ -44,6 +41,22 @@ const columns: DataTableColumn<Submission>[] = [
|
||||
key: "id",
|
||||
minWidth: 160,
|
||||
render: (row) => {
|
||||
if (!row.show_link)
|
||||
return h(NFlex, { align: "center" }, () => [
|
||||
h("span", row.id.slice(0, 12)),
|
||||
h(
|
||||
NTooltip,
|
||||
{},
|
||||
{
|
||||
trigger: () =>
|
||||
h(NButton, { text: true }, () =>
|
||||
h(Icon, { icon: "catppuccin:lock" }),
|
||||
),
|
||||
default: () =>
|
||||
"这道题在你已经加入的题单中,只有在题单中完成此题,代码才可见。",
|
||||
},
|
||||
),
|
||||
])
|
||||
return h(
|
||||
NButton,
|
||||
{
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { Icon } from "@iconify/vue"
|
||||
import { useThemeVars } from "naive-ui"
|
||||
import { JUDGE_STATUS, SubmissionStatus } from "utils/constants"
|
||||
import {
|
||||
getCSRFToken,
|
||||
@@ -19,6 +21,7 @@ const props = defineProps<{
|
||||
|
||||
const isDark = useDark()
|
||||
const problemStore = useProblemStore()
|
||||
const theme = useThemeVars()
|
||||
|
||||
// AI 提示状态
|
||||
const hintContent = ref("")
|
||||
@@ -40,7 +43,10 @@ const msg = computed(() => {
|
||||
msg += "请仔细检查,看看代码的格式是不是写错了!\n\n"
|
||||
}
|
||||
|
||||
if (props.submission.statistic_info?.err_info) {
|
||||
if (
|
||||
result !== SubmissionStatus.ast_check_failed &&
|
||||
props.submission.statistic_info?.err_info
|
||||
) {
|
||||
msg += props.submission.statistic_info.err_info
|
||||
}
|
||||
|
||||
@@ -51,8 +57,9 @@ const msg = computed(() => {
|
||||
const showAIHint = computed(() => {
|
||||
if (!props.submission) return false
|
||||
return (
|
||||
problemStore.totalFailCount >= 3 &&
|
||||
problemStore.failCount >= 3 &&
|
||||
props.submission.result !== SubmissionStatus.accepted &&
|
||||
props.submission.result !== SubmissionStatus.ast_check_failed &&
|
||||
props.submission.result !== SubmissionStatus.pending &&
|
||||
props.submission.result !== SubmissionStatus.judging &&
|
||||
props.submission.result !== SubmissionStatus.submitting
|
||||
@@ -108,6 +115,7 @@ const infoTable = computed(() => {
|
||||
// AC、编译错误、运行时错误不显示测试用例表格
|
||||
if (
|
||||
result === SubmissionStatus.accepted ||
|
||||
result === SubmissionStatus.ast_check_failed ||
|
||||
result === SubmissionStatus.compile_error ||
|
||||
result === SubmissionStatus.runtime_error
|
||||
) {
|
||||
@@ -145,10 +153,34 @@ const columns: DataTableColumn<Submission["info"]["data"][number]>[] = [
|
||||
<div v-if="submission">
|
||||
<n-alert
|
||||
:type="JUDGE_STATUS[submission.result]['type']"
|
||||
:title="JUDGE_STATUS[submission.result]['name']"
|
||||
:title="JUDGE_STATUS[submission.result]['title']"
|
||||
class="mb-3"
|
||||
/>
|
||||
<n-flex vertical v-if="msg || infoTable.length">
|
||||
<n-flex
|
||||
vertical
|
||||
v-if="
|
||||
msg ||
|
||||
infoTable.length ||
|
||||
submission.statistic_info?.ast_results?.length
|
||||
"
|
||||
>
|
||||
<n-card v-if="submission.statistic_info?.ast_results?.length" embedded>
|
||||
<n-flex vertical :size="8">
|
||||
<n-flex
|
||||
v-for="(rule, i) in submission.statistic_info.ast_results"
|
||||
:key="i"
|
||||
align="center"
|
||||
:size="6"
|
||||
>
|
||||
<n-icon
|
||||
:color="rule.passed ? theme.successColor : theme.errorColor"
|
||||
>
|
||||
<Icon :icon="rule.passed ? 'ep:select' : 'ep:close-bold'" />
|
||||
</n-icon>
|
||||
<span>{{ rule.description }}</span>
|
||||
</n-flex>
|
||||
</n-flex>
|
||||
</n-card>
|
||||
<n-card v-if="msg" embedded class="msg">{{ msg }}</n-card>
|
||||
<n-data-table
|
||||
v-if="infoTable.length"
|
||||
|
||||
@@ -1,14 +1,20 @@
|
||||
<script setup lang="ts">
|
||||
import { Icon } from "@iconify/vue"
|
||||
import { storeToRefs } from "pinia"
|
||||
import { getComment, submitCode, updateProblemSetProgress } from "oj/api"
|
||||
import {
|
||||
formatCode,
|
||||
getComment,
|
||||
submitCode,
|
||||
updateProblemSetProgress,
|
||||
} from "oj/api"
|
||||
import { useCodeStore } from "oj/store/code"
|
||||
import { useProblemStore } from "oj/store/problem"
|
||||
import { useFireworks } from "oj/problem/composables/useFireworks"
|
||||
import { useSubmissionMonitor } from "oj/problem/composables/useSubmissionMonitor"
|
||||
import { SubmissionStatus } from "utils/constants"
|
||||
import { LANGUAGE_FORMAT_VALUE, SubmissionStatus } from "utils/constants"
|
||||
import type { SubmitCodePayload } from "utils/types"
|
||||
import SubmissionResult from "./SubmissionResult.vue"
|
||||
import { getSubmitButtonState } from "./submitButtonState"
|
||||
import { useBreakpoints } from "shared/composables/breakpoints"
|
||||
import { useUserStore } from "shared/store/user"
|
||||
import { checkPythonSyntax } from "oj/problem/utils/pythonSyntaxCheck"
|
||||
@@ -37,16 +43,12 @@ const { isDesktop } = useBreakpoints()
|
||||
const { celebrate } = useFireworks()
|
||||
|
||||
// ==================== 判题监控 ====================
|
||||
const {
|
||||
submission,
|
||||
judging,
|
||||
pending,
|
||||
submitting,
|
||||
isProcessing,
|
||||
startMonitoring,
|
||||
} = useSubmissionMonitor()
|
||||
const { submission, judging, pending, submitting, startMonitoring } =
|
||||
useSubmissionMonitor()
|
||||
|
||||
const showResult = ref(false)
|
||||
const isFormatting = ref(false)
|
||||
const isSubmittingRequest = ref(false)
|
||||
|
||||
// ==================== 提交冷却 ====================
|
||||
const { start: startCooldown, isPending: isCooldown } = useTimeout(5000, {
|
||||
@@ -80,35 +82,20 @@ const { start: goToProblemSetDelayed } = useTimeoutFn(
|
||||
)
|
||||
|
||||
// ==================== 计算属性 ====================
|
||||
// 按钮禁用逻辑
|
||||
const submitDisabled = computed(() => {
|
||||
return (
|
||||
!userStore.isAuthed ||
|
||||
codeStore.code.value.trim() === "" ||
|
||||
isProcessing.value ||
|
||||
isCooldown.value
|
||||
)
|
||||
})
|
||||
|
||||
// 按钮文案
|
||||
const submitLabel = computed(() => {
|
||||
if (!userStore.isAuthed) return "请先登录"
|
||||
if (submitting.value) return "正在提交"
|
||||
if (judging.value || pending.value) return "正在评分"
|
||||
if (isCooldown.value) return "正在冷却"
|
||||
return "提交代码"
|
||||
})
|
||||
|
||||
// 按钮图标
|
||||
const submitIcon = computed(() => {
|
||||
if (isProcessing.value) return "eos-icons:loading"
|
||||
if (isCooldown.value) return "ph:lightbulb-fill"
|
||||
return "ph:play-fill"
|
||||
})
|
||||
const buttonState = computed(() =>
|
||||
getSubmitButtonState({
|
||||
isAuthed: userStore.isAuthed,
|
||||
hasCode: codeStore.code.value.trim() !== "",
|
||||
isFormatting: isFormatting.value,
|
||||
isSubmitting: isSubmittingRequest.value || submitting.value,
|
||||
isJudging: judging.value || pending.value,
|
||||
isCooldown: isCooldown.value,
|
||||
}),
|
||||
)
|
||||
|
||||
// ==================== 提交函数 ====================
|
||||
async function submit() {
|
||||
if (!userStore.isAuthed) return
|
||||
if (buttonState.value.disabled) return
|
||||
|
||||
// 0. Python3 语法检测
|
||||
if (codeStore.code.language === "Python3") {
|
||||
@@ -119,6 +106,28 @@ async function submit() {
|
||||
}
|
||||
}
|
||||
|
||||
// 0.5 提交前自动格式化(Python3 用 ruff,C/C++ 用 clang-format)
|
||||
const formatLang = LANGUAGE_FORMAT_VALUE[codeStore.code.language]
|
||||
if (["python", "c", "cpp"].includes(formatLang)) {
|
||||
isFormatting.value = true
|
||||
try {
|
||||
const res = await formatCode({
|
||||
code: codeStore.code.value,
|
||||
language: formatLang,
|
||||
})
|
||||
codeStore.setCode(res.data.code)
|
||||
} catch (e: any) {
|
||||
if (e?.error === "format-error") {
|
||||
// 仅 Python3 会出现:代码本身存在语法错误
|
||||
message.warning(`代码格式化失败:${e.data},请检查代码后重试`)
|
||||
return
|
||||
}
|
||||
// server-error / 网络异常:格式化工具问题,静默降级,提交原代码
|
||||
} finally {
|
||||
isFormatting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 1. 构建提交数据
|
||||
const data: SubmitCodePayload = {
|
||||
problem_id: problem.value!.id,
|
||||
@@ -129,13 +138,18 @@ async function submit() {
|
||||
data.contest_id = parseInt(contestID)
|
||||
}
|
||||
// 2. 提交代码到后端
|
||||
const res = await submitCode(data)
|
||||
console.log(`[Submit] 代码已提交: ID=${res.data.submission_id}`)
|
||||
isSubmittingRequest.value = true
|
||||
try {
|
||||
const res = await submitCode(data)
|
||||
console.log(`[Submit] 代码已提交: ID=${res.data.submission_id}`)
|
||||
|
||||
// 3. 启动冷却 + 监控
|
||||
startCooldown()
|
||||
startMonitoring(res.data.submission_id)
|
||||
showResult.value = true
|
||||
// 3. 启动冷却 + 监控
|
||||
startCooldown()
|
||||
startMonitoring(res.data.submission_id)
|
||||
showResult.value = true
|
||||
} finally {
|
||||
isSubmittingRequest.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 失败计数 ====================
|
||||
@@ -149,7 +163,10 @@ watch(
|
||||
result === SubmissionStatus.submitting
|
||||
)
|
||||
return
|
||||
if (result !== SubmissionStatus.accepted) {
|
||||
if (
|
||||
result !== SubmissionStatus.accepted &&
|
||||
result !== SubmissionStatus.ast_check_failed
|
||||
) {
|
||||
problemStore.incrementFailCount()
|
||||
}
|
||||
},
|
||||
@@ -159,7 +176,11 @@ watch(
|
||||
watch(
|
||||
() => submission.value?.result,
|
||||
async (result) => {
|
||||
if (result !== SubmissionStatus.accepted) return
|
||||
if (
|
||||
result !== SubmissionStatus.accepted &&
|
||||
result !== SubmissionStatus.ast_check_failed
|
||||
)
|
||||
return
|
||||
|
||||
// 1. 刷新题目状态
|
||||
problem.value!.my_status = 0
|
||||
@@ -173,6 +194,8 @@ watch(
|
||||
)
|
||||
}
|
||||
|
||||
if (result !== SubmissionStatus.accepted) return
|
||||
|
||||
// 3. 放烟花
|
||||
celebrate()
|
||||
|
||||
@@ -204,15 +227,15 @@ watch(
|
||||
<n-button
|
||||
:size="isDesktop ? 'medium' : 'small'"
|
||||
type="primary"
|
||||
:disabled="submitDisabled"
|
||||
:disabled="buttonState.disabled"
|
||||
@click="submit"
|
||||
>
|
||||
<template #icon>
|
||||
<n-icon>
|
||||
<Icon :icon="submitIcon" />
|
||||
<Icon :icon="buttonState.icon" />
|
||||
</n-icon>
|
||||
</template>
|
||||
{{ submitLabel }}
|
||||
{{ buttonState.label }}
|
||||
</n-button>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
useFlowchartWebSocket,
|
||||
type FlowchartEvaluationUpdate,
|
||||
} from "shared/composables/websocket"
|
||||
import { useMyFlowchartStore } from "shared/store/myFlowchart"
|
||||
|
||||
// API 和状态管理
|
||||
import {
|
||||
@@ -51,6 +52,7 @@ const message = useMessage()
|
||||
const problemStore = useProblemStore()
|
||||
const { problem } = toRefs(problemStore)
|
||||
const { isDesktop } = useBreakpoints()
|
||||
const myFlowchartStore = useMyFlowchartStore()
|
||||
const { convertToMermaid } = useMermaidConverter()
|
||||
const { renderError, renderFlowchart } = useMermaid()
|
||||
|
||||
@@ -71,6 +73,7 @@ const evaluation = ref<Evaluation>({
|
||||
criteria_details: {},
|
||||
})
|
||||
const page = ref(1)
|
||||
const lastSubmittedMermaidCode = ref("")
|
||||
const suggestionLines = computed(() =>
|
||||
splitSuggestionLines(evaluation.value.suggestions),
|
||||
)
|
||||
@@ -85,15 +88,15 @@ function splitSuggestionLines(suggestions?: string | null) {
|
||||
}
|
||||
|
||||
// ==================== WebSocket 相关函数 ====================
|
||||
// 处理 WebSocket 消息
|
||||
const handleWebSocketMessage = (data: FlowchartEvaluationUpdate) => {
|
||||
if (data.type === "flowchart_evaluation_completed") {
|
||||
loading.value = false
|
||||
latestRating.value = {
|
||||
score: data.score || 0,
|
||||
grade: data.grade || "",
|
||||
const grade = data.grade || ""
|
||||
latestRating.value = { score: data.score || 0, grade }
|
||||
message.success(`流程图评分完成!得分: ${data.score}分 (${grade}级)`)
|
||||
if ((grade === "A" || grade === "S") && lastSubmittedMermaidCode.value) {
|
||||
myFlowchartStore.show(lastSubmittedMermaidCode.value)
|
||||
}
|
||||
message.success(`流程图评分完成!得分: ${data.score}分 (${data.grade}级)`)
|
||||
} else if (data.type === "flowchart_evaluation_failed") {
|
||||
loading.value = false
|
||||
message.error(`流程图评分失败: ${data.error}`)
|
||||
@@ -124,6 +127,7 @@ async function submitFlowchartData() {
|
||||
}
|
||||
|
||||
const mermaidCode = convertToMermaid(flowchartData)
|
||||
lastSubmittedMermaidCode.value = mermaidCode
|
||||
const compressed = utoa(JSON.stringify(flowchartData))
|
||||
|
||||
loading.value = true
|
||||
@@ -251,11 +255,17 @@ const getPercentType = (percent: number) => {
|
||||
}
|
||||
|
||||
// ==================== 生命周期钩子 ====================
|
||||
// 组件挂载时连接 WebSocket 并检查状态
|
||||
onMounted(async () => {
|
||||
connect()
|
||||
await getCurrentSubmission()
|
||||
page.value = submissionCount.value
|
||||
const grade = latestRating.value.grade
|
||||
if ((grade === "A" || grade === "S") && submissionCount.value > 0) {
|
||||
await getSubmission(submissionCount.value)
|
||||
if (myMermaidCode.value) {
|
||||
myFlowchartStore.show(myMermaidCode.value)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// 组件卸载时断开连接
|
||||
|
||||
53
src/oj/problem/components/submitButtonState.ts
Normal file
53
src/oj/problem/components/submitButtonState.ts
Normal 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 }
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import { useBreakpoints } from "shared/composables/breakpoints"
|
||||
import { storeToRefs } from "pinia"
|
||||
import { useProblemStore } from "oj/store/problem"
|
||||
import { useScreenModeStore } from "shared/store/screenMode"
|
||||
import { useMyFlowchartStore } from "shared/store/myFlowchart"
|
||||
|
||||
const ProblemEditor = defineAsyncComponent(
|
||||
() => import("./components/ProblemEditor.vue"),
|
||||
@@ -29,6 +30,9 @@ const ProblemComment = defineAsyncComponent(
|
||||
const ProblemFlowchart = defineAsyncComponent(
|
||||
() => import("./components/ProblemFlowchart.vue"),
|
||||
)
|
||||
const MyFlowchartTab = defineAsyncComponent(
|
||||
() => import("./components/MyFlowchartTab.vue"),
|
||||
)
|
||||
|
||||
interface Props {
|
||||
problemID: string
|
||||
@@ -36,10 +40,7 @@ interface Props {
|
||||
problemSetId?: string
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
contestID: "",
|
||||
problemSetId: "",
|
||||
})
|
||||
const { problemID, contestID = "", problemSetId = "" } = defineProps<Props>()
|
||||
|
||||
const errMsg = ref("无数据")
|
||||
const route = useRoute()
|
||||
@@ -47,6 +48,7 @@ const router = useRouter()
|
||||
|
||||
const problemStore = useProblemStore()
|
||||
const screenModeStore = useScreenModeStore()
|
||||
const myFlowchartStore = useMyFlowchartStore()
|
||||
const { problem } = storeToRefs(problemStore)
|
||||
const { shouldShowProblem } = storeToRefs(screenModeStore)
|
||||
|
||||
@@ -57,13 +59,17 @@ const tabOptions = computed(() => {
|
||||
if (problem.value?.show_flowchart) {
|
||||
options.push("flowchart")
|
||||
}
|
||||
|
||||
if (isMobile.value) {
|
||||
options.push("editor")
|
||||
}
|
||||
options.push("info")
|
||||
if (!props.contestID) {
|
||||
if (!contestID) {
|
||||
options.push("comment")
|
||||
}
|
||||
if (myFlowchartStore.showing) {
|
||||
options.push("my-flowchart")
|
||||
}
|
||||
options.push("submission")
|
||||
return options
|
||||
})
|
||||
@@ -91,10 +97,17 @@ watch(currentTab, (tab) => {
|
||||
})
|
||||
})
|
||||
|
||||
watch(
|
||||
() => myFlowchartStore.showing,
|
||||
(showing) => {
|
||||
if (showing) currentTab.value = "my-flowchart"
|
||||
},
|
||||
)
|
||||
|
||||
async function init() {
|
||||
screenModeStore.resetScreenMode()
|
||||
try {
|
||||
const res = await getProblem(props.problemID, props.contestID)
|
||||
const res = await getProblem(problemID, contestID)
|
||||
problem.value = res.data
|
||||
} catch (err: any) {
|
||||
problem.value = null
|
||||
@@ -104,11 +117,12 @@ async function init() {
|
||||
}
|
||||
}
|
||||
onMounted(init)
|
||||
watch(() => props.problemID, init)
|
||||
watch(() => problemID, init)
|
||||
onBeforeUnmount(() => {
|
||||
problem.value = null
|
||||
errMsg.value = "无数据"
|
||||
screenModeStore.resetScreenMode()
|
||||
myFlowchartStore.hide()
|
||||
})
|
||||
|
||||
watch(isMobile, (value) => {
|
||||
@@ -142,22 +156,29 @@ watch(isMobile, (value) => {
|
||||
<n-tab-pane
|
||||
name="info"
|
||||
tab="题目统计"
|
||||
:disabled="!!props.problemSetId"
|
||||
:disabled="!!problemSetId"
|
||||
>
|
||||
<ProblemInfo />
|
||||
</n-tab-pane>
|
||||
<n-tab-pane
|
||||
v-if="!props.contestID"
|
||||
v-if="!contestID"
|
||||
name="comment"
|
||||
tab="题目点评"
|
||||
:disabled="!!props.problemSetId"
|
||||
:disabled="!!problemSetId"
|
||||
>
|
||||
<ProblemComment />
|
||||
</n-tab-pane>
|
||||
<n-tab-pane
|
||||
v-if="myFlowchartStore.showing"
|
||||
name="my-flowchart"
|
||||
tab="我的流程图"
|
||||
>
|
||||
<MyFlowchartTab />
|
||||
</n-tab-pane>
|
||||
<n-tab-pane
|
||||
name="submission"
|
||||
tab="我的提交"
|
||||
:disabled="!!props.problemSetId"
|
||||
:disabled="!!problemSetId"
|
||||
>
|
||||
<ProblemSubmission />
|
||||
</n-tab-pane>
|
||||
@@ -191,22 +212,29 @@ watch(isMobile, (value) => {
|
||||
<n-tab-pane
|
||||
name="info"
|
||||
tab="题目统计"
|
||||
:disabled="!!props.problemSetId"
|
||||
:disabled="!!problemSetId"
|
||||
>
|
||||
<ProblemInfo />
|
||||
</n-tab-pane>
|
||||
<n-tab-pane
|
||||
v-if="!props.contestID"
|
||||
v-if="!contestID"
|
||||
name="comment"
|
||||
tab="题目点评"
|
||||
:disabled="!!props.problemSetId"
|
||||
:disabled="!!problemSetId"
|
||||
>
|
||||
<ProblemComment />
|
||||
</n-tab-pane>
|
||||
<n-tab-pane
|
||||
v-if="myFlowchartStore.showing"
|
||||
name="my-flowchart"
|
||||
tab="我的流程图"
|
||||
>
|
||||
<MyFlowchartTab />
|
||||
</n-tab-pane>
|
||||
<n-tab-pane
|
||||
name="submission"
|
||||
tab="我的提交"
|
||||
:disabled="!!props.problemSetId"
|
||||
:disabled="!!problemSetId"
|
||||
>
|
||||
<ProblemSubmission />
|
||||
</n-tab-pane>
|
||||
@@ -225,18 +253,25 @@ watch(isMobile, (value) => {
|
||||
<n-tab-pane name="editor" tab="代码">
|
||||
<component :is="inProblem ? ProblemEditor : ContestEditor" />
|
||||
</n-tab-pane>
|
||||
<n-tab-pane name="info" tab="统计" :disabled="!!props.problemSetId">
|
||||
<n-tab-pane name="info" tab="统计" :disabled="!!problemSetId">
|
||||
<ProblemInfo />
|
||||
</n-tab-pane>
|
||||
<n-tab-pane
|
||||
v-if="!props.contestID"
|
||||
v-if="!contestID"
|
||||
name="comment"
|
||||
tab="点评"
|
||||
:disabled="!!props.problemSetId"
|
||||
:disabled="!!problemSetId"
|
||||
>
|
||||
<ProblemComment />
|
||||
</n-tab-pane>
|
||||
<n-tab-pane name="submission" tab="提交" :disabled="!!props.problemSetId">
|
||||
<n-tab-pane
|
||||
v-if="myFlowchartStore.showing"
|
||||
name="my-flowchart"
|
||||
tab="我的流程图"
|
||||
>
|
||||
<MyFlowchartTab />
|
||||
</n-tab-pane>
|
||||
<n-tab-pane name="submission" tab="提交" :disabled="!!problemSetId">
|
||||
<ProblemSubmission />
|
||||
</n-tab-pane>
|
||||
</n-tabs>
|
||||
|
||||
@@ -4,7 +4,7 @@ import { NFlex, NTag } from "naive-ui"
|
||||
import { useRouteQuery } from "@vueuse/router"
|
||||
import { getProblemList, getRandomProblemID } from "oj/api"
|
||||
import { getTagColor } from "utils/functions"
|
||||
import { ProblemFiltered } from "utils/types"
|
||||
import type { ProblemFiltered } from "utils/types"
|
||||
import { getProblemTagList } from "shared/api"
|
||||
import Hitokoto from "shared/components/Hitokoto.vue"
|
||||
import Pagination from "shared/components/Pagination.vue"
|
||||
@@ -45,6 +45,7 @@ const sortOptions = [
|
||||
{ label: "最多通过", value: "-accepted_number" },
|
||||
{ label: "最少通过", value: "accepted_number" },
|
||||
{ label: "画流程图", value: "flowchart" },
|
||||
{ label: "语法检查", value: "ast" },
|
||||
]
|
||||
|
||||
const router = useRouter()
|
||||
@@ -225,7 +226,7 @@ function rowProps(row: ProblemFiltered) {
|
||||
<n-form :show-feedback="false" inline label-placement="left">
|
||||
<n-form-item label="难度">
|
||||
<n-select
|
||||
style="width: 100px"
|
||||
style="width: 80px"
|
||||
v-model:value="query.difficulty"
|
||||
:options="difficultyOptions"
|
||||
/>
|
||||
@@ -237,9 +238,10 @@ function rowProps(row: ProblemFiltered) {
|
||||
<n-form :show-feedback="false" inline label-placement="left">
|
||||
<n-form-item label="排序">
|
||||
<n-select
|
||||
style="width: 100px"
|
||||
style="width: 120px"
|
||||
v-model:value="query.sort"
|
||||
:options="sortOptions"
|
||||
:dropdown-style="{ maxHeight: 'unset' }"
|
||||
/>
|
||||
</n-form-item>
|
||||
<n-form-item>
|
||||
|
||||
@@ -1,15 +1,16 @@
|
||||
<script setup lang="ts">
|
||||
import { formatISO, sub, type Duration } from "date-fns"
|
||||
import { NButton, NFlex, useThemeVars } from "naive-ui"
|
||||
import { NButton, NFlex } from "naive-ui"
|
||||
import {
|
||||
getActivityRank,
|
||||
getClassRank,
|
||||
getRank,
|
||||
getUserClassRank,
|
||||
getClassPK,
|
||||
} from "oj/api"
|
||||
import { useBreakpoints } from "shared/composables/breakpoints"
|
||||
import { getACRate } from "utils/functions"
|
||||
import { Rank } from "utils/types"
|
||||
import { getACRate, getCSRFToken } from "utils/functions"
|
||||
import type { Rank } from "utils/types"
|
||||
import Pagination from "shared/components/Pagination.vue"
|
||||
import { ChartType } from "utils/constants"
|
||||
import { renderTableTitle } from "utils/renders"
|
||||
@@ -17,6 +18,9 @@ import Chart from "./components/Chart.vue"
|
||||
import Index from "./components/Index.vue"
|
||||
import { useUserStore } from "shared/store/user"
|
||||
import { Icon } from "@iconify/vue"
|
||||
import { MdPreview } from "md-editor-v3"
|
||||
import "md-editor-v3/lib/preview.css"
|
||||
import { consumeJSONEventStream } from "utils/stream"
|
||||
|
||||
const gradeOptions = [
|
||||
{ label: "24年级", value: 24 },
|
||||
@@ -52,6 +56,83 @@ const myClassQuery = reactive({
|
||||
limit: 10,
|
||||
})
|
||||
|
||||
const showClassDetailModal = ref(false)
|
||||
const classDetailData = ref<ClassComparison | null>(null)
|
||||
const classDetailLoading = ref(false)
|
||||
|
||||
const classDetailAiLoading = ref(false)
|
||||
const classDetailAiContent = ref("")
|
||||
const showClassDetailAiModal = ref(false)
|
||||
let classDetailAiController: AbortController | null = null
|
||||
|
||||
async function loadClassDetail(className: string) {
|
||||
showClassDetailModal.value = true
|
||||
classDetailLoading.value = true
|
||||
classDetailData.value = null
|
||||
try {
|
||||
const res = await getClassPK([className])
|
||||
classDetailData.value = res.data.comparisons[0] ?? null
|
||||
} catch {
|
||||
// ignore
|
||||
} finally {
|
||||
classDetailLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function analyzeSingleClassWithAI() {
|
||||
if (!classDetailData.value) return
|
||||
if (classDetailAiController) classDetailAiController.abort()
|
||||
const controller = new AbortController()
|
||||
classDetailAiController = controller
|
||||
|
||||
showClassDetailModal.value = false
|
||||
showClassDetailAiModal.value = true
|
||||
classDetailAiContent.value = ""
|
||||
classDetailAiLoading.value = true
|
||||
|
||||
const headers: Record<string, string> = { "Content-Type": "application/json" }
|
||||
const csrfToken = getCSRFToken()
|
||||
if (csrfToken) headers["X-CSRFToken"] = csrfToken
|
||||
|
||||
try {
|
||||
const response = await fetch("/api/ai/class_single", {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify({ comparison: classDetailData.value }),
|
||||
signal: controller.signal,
|
||||
})
|
||||
if (!response.ok) throw new Error("AI 分析生成失败")
|
||||
|
||||
let hasStarted = false
|
||||
await consumeJSONEventStream(response, {
|
||||
signal: controller.signal,
|
||||
onEvent(event) {
|
||||
if (event === "end" && !hasStarted) classDetailAiLoading.value = false
|
||||
},
|
||||
onMessage(payload) {
|
||||
const parsed = payload as { type?: string; content?: string; message?: string }
|
||||
if (parsed.type === "delta" && parsed.content) {
|
||||
if (!hasStarted) {
|
||||
hasStarted = true
|
||||
classDetailAiLoading.value = false
|
||||
}
|
||||
classDetailAiContent.value += parsed.content
|
||||
} else if (parsed.type === "error") {
|
||||
throw new Error(parsed.message || "AI 服务异常")
|
||||
} else if (parsed.type === "done" && !hasStarted) {
|
||||
classDetailAiLoading.value = false
|
||||
}
|
||||
},
|
||||
})
|
||||
} catch (error: any) {
|
||||
if (controller.signal.aborted) return
|
||||
message.error(error?.message || "AI 分析失败,请稍后再试")
|
||||
classDetailAiLoading.value = false
|
||||
} finally {
|
||||
if (classDetailAiController === controller) classDetailAiController = null
|
||||
}
|
||||
}
|
||||
|
||||
interface ClassRank {
|
||||
rank: number
|
||||
class_name: string
|
||||
@@ -62,6 +143,27 @@ interface ClassRank {
|
||||
ac_rate: number
|
||||
}
|
||||
|
||||
interface ClassComparison {
|
||||
class_name: string
|
||||
user_count: number
|
||||
total_ac: number
|
||||
total_submission: number
|
||||
avg_ac: number
|
||||
median_ac: number
|
||||
q1_ac: number
|
||||
q3_ac: number
|
||||
iqr: number
|
||||
std_dev: number
|
||||
top_10_avg: number
|
||||
middle_80_avg: number
|
||||
bottom_10_avg: number
|
||||
excellent_rate: number
|
||||
pass_rate: number
|
||||
active_rate: number
|
||||
ac_rate: number
|
||||
composite_score: number
|
||||
}
|
||||
|
||||
interface UserRank {
|
||||
rank: number
|
||||
username: string
|
||||
@@ -191,7 +293,7 @@ const classColumns: DataTableColumn<ClassRank>[] = [
|
||||
{
|
||||
title: "排名",
|
||||
key: "rank",
|
||||
width: 100,
|
||||
width: 60,
|
||||
titleAlign: "center",
|
||||
align: "center",
|
||||
},
|
||||
@@ -200,46 +302,63 @@ const classColumns: DataTableColumn<ClassRank>[] = [
|
||||
key: "class_name",
|
||||
render: (row) =>
|
||||
`${row.class_name.slice(0, 2)}计算机${row.class_name.slice(2)}班`,
|
||||
width: 200,
|
||||
minWidth: 120,
|
||||
titleAlign: "center",
|
||||
align: "center",
|
||||
},
|
||||
{
|
||||
title: "人数",
|
||||
key: "user_count",
|
||||
width: 100,
|
||||
width: 80,
|
||||
titleAlign: "center",
|
||||
align: "center",
|
||||
},
|
||||
{
|
||||
title: "总AC数",
|
||||
key: "total_ac",
|
||||
width: 120,
|
||||
width: 90,
|
||||
titleAlign: "center",
|
||||
align: "center",
|
||||
},
|
||||
{
|
||||
title: "总提交数",
|
||||
title: "提交数",
|
||||
key: "total_submission",
|
||||
width: 120,
|
||||
width: 90,
|
||||
titleAlign: "center",
|
||||
align: "center",
|
||||
},
|
||||
{
|
||||
title: "平均AC数",
|
||||
key: "avg_ac",
|
||||
width: 120,
|
||||
width: 100,
|
||||
titleAlign: "center",
|
||||
align: "center",
|
||||
},
|
||||
{
|
||||
title: "正确率",
|
||||
key: "ac_rate",
|
||||
width: 100,
|
||||
width: 90,
|
||||
titleAlign: "center",
|
||||
align: "center",
|
||||
render: (row) => `${row.ac_rate}%`,
|
||||
},
|
||||
{
|
||||
title: "详情",
|
||||
key: "action",
|
||||
width: 70,
|
||||
titleAlign: "center",
|
||||
align: "center",
|
||||
render: (row) =>
|
||||
h(
|
||||
NButton,
|
||||
{
|
||||
text: true,
|
||||
type: "info",
|
||||
onClick: () => loadClassDetail(row.class_name),
|
||||
},
|
||||
() => "查看",
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
const myClassColumns: DataTableColumn<UserRank>[] = [
|
||||
@@ -453,6 +572,260 @@ watch(
|
||||
</n-gi>
|
||||
</n-grid>
|
||||
</n-flex>
|
||||
|
||||
<n-modal
|
||||
v-model:show="showClassDetailModal"
|
||||
preset="card"
|
||||
:title="
|
||||
classDetailData
|
||||
? `${classDetailData.class_name.slice(0, 2)}计算机${classDetailData.class_name.slice(2)}班`
|
||||
: '班级详情'
|
||||
"
|
||||
:style="{ width: '700px', maxWidth: '95vw' }"
|
||||
>
|
||||
<n-spin :show="classDetailLoading" style="min-height: 200px">
|
||||
<n-flex v-if="classDetailData" vertical :size="12">
|
||||
<n-grid :cols="5" :x-gap="8" responsive="screen">
|
||||
<n-gi>
|
||||
<n-statistic
|
||||
label="总AC数"
|
||||
:value="classDetailData.total_ac"
|
||||
size="large"
|
||||
class="stat-total-ac"
|
||||
>
|
||||
<template #suffix>
|
||||
<Icon icon="streamline-emojis:raised-fist-1" width="20" />
|
||||
</template>
|
||||
</n-statistic>
|
||||
</n-gi>
|
||||
<n-gi>
|
||||
<n-statistic
|
||||
label="平均AC数"
|
||||
:value="classDetailData.avg_ac.toFixed(2)"
|
||||
size="large"
|
||||
class="stat-avg-ac"
|
||||
>
|
||||
<template #suffix>
|
||||
<Icon icon="streamline-emojis:chart" width="20" />
|
||||
</template>
|
||||
</n-statistic>
|
||||
</n-gi>
|
||||
<n-gi>
|
||||
<n-statistic
|
||||
label="中位数AC数"
|
||||
:value="classDetailData.median_ac.toFixed(2)"
|
||||
size="large"
|
||||
class="stat-median-ac"
|
||||
>
|
||||
<template #suffix>
|
||||
<Icon icon="streamline-emojis:target" width="20" />
|
||||
</template>
|
||||
</n-statistic>
|
||||
</n-gi>
|
||||
<n-gi>
|
||||
<n-statistic
|
||||
label="总提交数"
|
||||
:value="classDetailData.total_submission"
|
||||
size="large"
|
||||
class="stat-total-submission"
|
||||
>
|
||||
<template #suffix>
|
||||
<Icon icon="streamline-emojis:paper" width="20" />
|
||||
</template>
|
||||
</n-statistic>
|
||||
</n-gi>
|
||||
<n-gi>
|
||||
<n-statistic
|
||||
label="AC率"
|
||||
:value="classDetailData.ac_rate.toFixed(1) + '%'"
|
||||
size="large"
|
||||
class="stat-ac-rate"
|
||||
>
|
||||
<template #suffix>
|
||||
<Icon icon="streamline-emojis:check-mark" width="20" />
|
||||
</template>
|
||||
</n-statistic>
|
||||
</n-gi>
|
||||
</n-grid>
|
||||
|
||||
<n-divider style="margin: 12px 0" />
|
||||
|
||||
<n-descriptions
|
||||
bordered
|
||||
:column="2"
|
||||
size="small"
|
||||
label-placement="left"
|
||||
>
|
||||
<n-descriptions-item label="第一四分位数(Q1)">
|
||||
<span style="color: #9254de; font-weight: 500">{{
|
||||
classDetailData.q1_ac.toFixed(2)
|
||||
}}</span>
|
||||
</n-descriptions-item>
|
||||
<n-descriptions-item label="第三四分位数(Q3)">
|
||||
<span style="color: #f759ab; font-weight: 500">{{
|
||||
classDetailData.q3_ac.toFixed(2)
|
||||
}}</span>
|
||||
</n-descriptions-item>
|
||||
<n-descriptions-item label="四分位距(IQR)">
|
||||
<span style="color: #13c2c2; font-weight: 500">{{
|
||||
classDetailData.iqr.toFixed(2)
|
||||
}}</span>
|
||||
</n-descriptions-item>
|
||||
<n-descriptions-item label="标准差">
|
||||
<span style="color: #fa8c16; font-weight: 500">{{
|
||||
classDetailData.std_dev.toFixed(2)
|
||||
}}</span>
|
||||
</n-descriptions-item>
|
||||
<n-descriptions-item label="前10%均值">
|
||||
<span style="color: #cf1322; font-weight: 600">{{
|
||||
classDetailData.top_10_avg.toFixed(2)
|
||||
}}</span>
|
||||
</n-descriptions-item>
|
||||
<n-descriptions-item label="中间80%均值">
|
||||
<span style="color: #389e0d; font-weight: 600">{{
|
||||
classDetailData.middle_80_avg.toFixed(2)
|
||||
}}</span>
|
||||
</n-descriptions-item>
|
||||
<n-descriptions-item label="后10%均值">
|
||||
<span style="color: #096dd9; font-weight: 500">{{
|
||||
classDetailData.bottom_10_avg.toFixed(2)
|
||||
}}</span>
|
||||
</n-descriptions-item>
|
||||
<n-descriptions-item label="人数">
|
||||
<span style="color: #1890ff; font-weight: 600">{{
|
||||
classDetailData.user_count
|
||||
}}</span>
|
||||
</n-descriptions-item>
|
||||
</n-descriptions>
|
||||
|
||||
<n-card size="small" title="比率统计" embedded style="margin-top: 12px">
|
||||
<n-space vertical :size="10">
|
||||
<n-progress
|
||||
type="line"
|
||||
:percentage="classDetailData.excellent_rate"
|
||||
:show-indicator="true"
|
||||
:border-radius="4"
|
||||
>
|
||||
<template #default
|
||||
>优秀率:
|
||||
{{ classDetailData.excellent_rate.toFixed(1) }}%</template
|
||||
>
|
||||
</n-progress>
|
||||
<n-progress
|
||||
type="line"
|
||||
:percentage="classDetailData.pass_rate"
|
||||
:show-indicator="true"
|
||||
:border-radius="4"
|
||||
status="success"
|
||||
>
|
||||
<template #default
|
||||
>及格率: {{ classDetailData.pass_rate.toFixed(1) }}%</template
|
||||
>
|
||||
</n-progress>
|
||||
<n-progress
|
||||
type="line"
|
||||
:percentage="classDetailData.active_rate"
|
||||
:show-indicator="true"
|
||||
:border-radius="4"
|
||||
status="info"
|
||||
>
|
||||
<template #default
|
||||
>参与度: {{ classDetailData.active_rate.toFixed(1) }}%</template
|
||||
>
|
||||
</n-progress>
|
||||
</n-space>
|
||||
</n-card>
|
||||
|
||||
<n-flex justify="center" align="center" :size="12" style="margin-top: 12px">
|
||||
<n-tag type="success" size="large">
|
||||
综合分: {{ classDetailData.composite_score.toFixed(1) }}
|
||||
</n-tag>
|
||||
<n-button
|
||||
type="info"
|
||||
size="small"
|
||||
:loading="classDetailAiLoading"
|
||||
@click="analyzeSingleClassWithAI"
|
||||
>
|
||||
<template #icon>
|
||||
<Icon icon="mingcute:ai-line" />
|
||||
</template>
|
||||
AI分析
|
||||
</n-button>
|
||||
</n-flex>
|
||||
</n-flex>
|
||||
<n-empty
|
||||
v-else-if="!classDetailLoading"
|
||||
description="暂无数据"
|
||||
style="padding: 40px 0"
|
||||
/>
|
||||
</n-spin>
|
||||
</n-modal>
|
||||
|
||||
<n-modal
|
||||
v-model:show="showClassDetailAiModal"
|
||||
preset="card"
|
||||
title="AI 分析报告"
|
||||
:style="{ width: '800px', maxWidth: '95vw' }"
|
||||
>
|
||||
<n-spin :show="classDetailAiLoading" :delay="50">
|
||||
<div style="min-height: 200px">
|
||||
<MdPreview v-if="classDetailAiContent" :model-value="classDetailAiContent" />
|
||||
<n-flex
|
||||
v-else-if="!classDetailAiLoading"
|
||||
align="center"
|
||||
justify="center"
|
||||
style="min-height: 200px"
|
||||
>
|
||||
<n-empty description="暂无分析内容" />
|
||||
</n-flex>
|
||||
</div>
|
||||
</n-spin>
|
||||
</n-modal>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
<style scoped>
|
||||
.stat-total-ac :deep(.n-statistic-value),
|
||||
.stat-total-ac :deep(.n-statistic-value__content),
|
||||
.stat-total-ac :deep(.n-number-animation),
|
||||
.stat-total-ac :deep(.n-statistic-value > *),
|
||||
.stat-total-ac :deep(.n-statistic-value span) {
|
||||
color: #ff4d4f !important;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.stat-avg-ac :deep(.n-statistic-value),
|
||||
.stat-avg-ac :deep(.n-statistic-value__content),
|
||||
.stat-avg-ac :deep(.n-number-animation),
|
||||
.stat-avg-ac :deep(.n-statistic-value > *),
|
||||
.stat-avg-ac :deep(.n-statistic-value span) {
|
||||
color: #52c41a !important;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.stat-median-ac :deep(.n-statistic-value),
|
||||
.stat-median-ac :deep(.n-statistic-value__content),
|
||||
.stat-median-ac :deep(.n-number-animation),
|
||||
.stat-median-ac :deep(.n-statistic-value > *),
|
||||
.stat-median-ac :deep(.n-statistic-value span) {
|
||||
color: #fa8c16 !important;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.stat-total-submission :deep(.n-statistic-value),
|
||||
.stat-total-submission :deep(.n-statistic-value__content),
|
||||
.stat-total-submission :deep(.n-number-animation),
|
||||
.stat-total-submission :deep(.n-statistic-value > *),
|
||||
.stat-total-submission :deep(.n-statistic-value span) {
|
||||
color: #805ad5 !important;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.stat-ac-rate :deep(.n-statistic-value),
|
||||
.stat-ac-rate :deep(.n-statistic-value__content),
|
||||
.stat-ac-rate :deep(.n-number-animation),
|
||||
.stat-ac-rate :deep(.n-statistic-value > *),
|
||||
.stat-ac-rate :deep(.n-statistic-value span) {
|
||||
color: #00b894 !important;
|
||||
font-weight: 600;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import { DetailsData, DurationData } from "utils/types"
|
||||
import { consumeJSONEventStream } from "utils/stream"
|
||||
import { getAIDetailData, getAIDurationData, getAIHeatmapData } from "../api"
|
||||
import {
|
||||
getAIDetailData,
|
||||
getAIDurationData,
|
||||
getAIHeatmapData,
|
||||
getAIPinnedReport,
|
||||
} from "../api"
|
||||
import { getCSRFToken } from "utils/functions"
|
||||
|
||||
export const useAIStore = defineStore("ai", () => {
|
||||
@@ -27,6 +32,7 @@ export const useAIStore = defineStore("ai", () => {
|
||||
})
|
||||
|
||||
const mdContent = ref("")
|
||||
const pinnedReport = ref<{ analysis: string } | null>(null)
|
||||
|
||||
async function fetchDetailsData(start: string, end: string) {
|
||||
const res = await getAIDetailData(
|
||||
@@ -156,10 +162,38 @@ export const useAIStore = defineStore("ai", () => {
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchPinnedReport() {
|
||||
const res = await getAIPinnedReport()
|
||||
pinnedReport.value = res.data
|
||||
}
|
||||
|
||||
async function simulatePinnedStream() {
|
||||
if (!pinnedReport.value) return
|
||||
const text = pinnedReport.value.analysis
|
||||
mdContent.value = ""
|
||||
const CHUNK = 6
|
||||
const DELAY = 18
|
||||
await new Promise<void>((resolve) => {
|
||||
let i = 0
|
||||
function step() {
|
||||
if (i >= text.length) {
|
||||
resolve()
|
||||
return
|
||||
}
|
||||
mdContent.value += text.slice(i, i + CHUNK)
|
||||
i += CHUNK
|
||||
setTimeout(step, DELAY)
|
||||
}
|
||||
step()
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
fetchAnalysisData,
|
||||
fetchHeatmapData,
|
||||
fetchAIAnalysis,
|
||||
fetchPinnedReport,
|
||||
simulatePinnedStream,
|
||||
durationData,
|
||||
detailsData,
|
||||
heatmapData,
|
||||
@@ -167,5 +201,6 @@ export const useAIStore = defineStore("ai", () => {
|
||||
targetUsername,
|
||||
loading,
|
||||
mdContent,
|
||||
pinnedReport,
|
||||
}
|
||||
})
|
||||
|
||||
@@ -1,19 +1,12 @@
|
||||
import { defineStore } from "pinia"
|
||||
import { LANGUAGE, Problem } from "utils/types"
|
||||
import type { LANGUAGE, Problem } from "utils/types"
|
||||
|
||||
/**
|
||||
* 题目状态管理 Store
|
||||
* 管理当前题目的信息
|
||||
*/
|
||||
export const useProblemStore = defineStore("problem", () => {
|
||||
// ==================== 状态 ====================
|
||||
const problem = ref<Problem | null>(null)
|
||||
const route = useRoute()
|
||||
|
||||
// 本次会话内累计的失败次数(与服务端 my_failed_count 叠加)
|
||||
const localFailCount = ref(0)
|
||||
const failCount = ref(0)
|
||||
|
||||
// ==================== 计算属性 ====================
|
||||
const languages = computed<LANGUAGE[]>(() => {
|
||||
if (route.name === "problem" && problem.value?.allow_flowchart) {
|
||||
return ["Flowchart", ...problem.value?.languages]
|
||||
@@ -21,27 +14,21 @@ export const useProblemStore = defineStore("problem", () => {
|
||||
return problem.value?.languages ?? []
|
||||
})
|
||||
|
||||
const totalFailCount = computed(
|
||||
() => (problem.value?.my_failed_count ?? 0) + localFailCount.value,
|
||||
)
|
||||
|
||||
function incrementFailCount() {
|
||||
localFailCount.value++
|
||||
failCount.value++
|
||||
}
|
||||
|
||||
// 切题时重置
|
||||
watch(
|
||||
() => problem.value?.id,
|
||||
() => {
|
||||
localFailCount.value = 0
|
||||
failCount.value = 0
|
||||
},
|
||||
)
|
||||
|
||||
return {
|
||||
problem,
|
||||
localFailCount,
|
||||
failCount,
|
||||
languages,
|
||||
totalFailCount,
|
||||
incrementFailCount,
|
||||
}
|
||||
})
|
||||
|
||||
@@ -1,36 +1,45 @@
|
||||
<template>
|
||||
<n-grid v-if="submission" :cols="5" :x-gap="16">
|
||||
<!-- 左侧:流程图预览区域 -->
|
||||
<n-gi :span="showLargeImage ? 5 : 3">
|
||||
<n-gi :span="3">
|
||||
<n-card title="流程图预览">
|
||||
<template #header-extra>
|
||||
<n-button
|
||||
v-if="!renderError && submission?.mermaid_code"
|
||||
quaternary
|
||||
size="small"
|
||||
@click="showLargeImage = !showLargeImage"
|
||||
@click="showLargeImage = true"
|
||||
>
|
||||
<template #icon>
|
||||
<Icon
|
||||
:icon="
|
||||
showLargeImage ? 'mdi:fullscreen-exit' : 'mdi:fullscreen'
|
||||
"
|
||||
/>
|
||||
<Icon icon="mdi:fullscreen" />
|
||||
</template>
|
||||
{{ showLargeImage ? "退出大图" : "查看大图" }}
|
||||
查看大图
|
||||
</n-button>
|
||||
</template>
|
||||
<div class="flowchart">
|
||||
<n-alert v-if="renderError" type="error" title="流程图渲染失败">
|
||||
{{ renderError }}
|
||||
</n-alert>
|
||||
<div class="flowchart" v-else ref="mermaidContainer"></div>
|
||||
<Teleport v-else to="body" :disabled="!showLargeImage">
|
||||
<div
|
||||
:class="['flowchart', { 'flowchart-fullscreen': showLargeImage }]"
|
||||
ref="mermaidContainer"
|
||||
></div>
|
||||
<div v-if="showLargeImage" class="fullscreen-toolbar">
|
||||
<n-button secondary round @click="showLargeImage = false">
|
||||
<template #icon>
|
||||
<Icon icon="mdi:fullscreen-exit" />
|
||||
</template>
|
||||
退出大图
|
||||
</n-button>
|
||||
</div>
|
||||
</Teleport>
|
||||
</div>
|
||||
</n-card>
|
||||
</n-gi>
|
||||
|
||||
<!-- 右侧:评分详情区域 -->
|
||||
<n-gi v-if="!showLargeImage" :span="2">
|
||||
<n-gi :span="2">
|
||||
<!-- AI反馈 -->
|
||||
<n-card
|
||||
v-if="submission.ai_feedback"
|
||||
@@ -137,6 +146,7 @@ function getPercentType(percent: number) {
|
||||
|
||||
async function loadSubmission() {
|
||||
if (!props.submissionId) return
|
||||
showLargeImage.value = false
|
||||
loading.value = true
|
||||
try {
|
||||
const { getFlowchartSubmission } = await import("oj/api")
|
||||
@@ -171,11 +181,42 @@ watch(() => props.submissionId, loadSubmission, { immediate: true })
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
/* 全屏大图:覆盖整个视口,脱离弹框宽度限制 */
|
||||
.flowchart-fullscreen {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 4000;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
padding: 32px;
|
||||
box-sizing: border-box;
|
||||
background: #ffffff;
|
||||
/* 改为可滚动块布局,超出视口的大图可以滚动查看 */
|
||||
display: block;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.fullscreen-toolbar {
|
||||
position: fixed;
|
||||
top: 16px;
|
||||
right: 16px;
|
||||
z-index: 4001;
|
||||
}
|
||||
|
||||
/* 确保 SVG 图表占满容器 */
|
||||
:deep(.flowchart > svg) {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
/* 全屏时按自然尺寸显示并水平居中,配合容器滚动 */
|
||||
:deep(.flowchart-fullscreen > svg) {
|
||||
display: block;
|
||||
margin: 0 auto;
|
||||
width: auto;
|
||||
height: auto;
|
||||
max-width: none;
|
||||
}
|
||||
|
||||
.loading-container {
|
||||
min-height: 600px;
|
||||
display: flex;
|
||||
|
||||
@@ -14,14 +14,26 @@
|
||||
查看测试详情
|
||||
</n-tooltip>
|
||||
</n-flex>
|
||||
<span v-else>
|
||||
{{ props.submission.id.slice(0, 12) }}
|
||||
</span>
|
||||
<n-flex v-else-if="isOwnSubmission" align="center">
|
||||
<span>{{ props.submission.id.slice(0, 12) }}</span>
|
||||
<n-tooltip>
|
||||
<template #trigger>
|
||||
<n-button text>
|
||||
<template #icon>
|
||||
<Icon icon="catppuccin:lock"></Icon>
|
||||
</template>
|
||||
</n-button>
|
||||
</template>
|
||||
这道题在你已经加入的题单中,只有在题单中完成此题,代码才可见。
|
||||
</n-tooltip>
|
||||
</n-flex>
|
||||
<span v-else>{{ props.submission.id.slice(0, 12) }}</span>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Icon } from "@iconify/vue"
|
||||
import { SubmissionListItem } from "utils/types"
|
||||
import { useUserStore } from "shared/store/user"
|
||||
import type { SubmissionListItem } from "utils/types"
|
||||
|
||||
interface Props {
|
||||
submission: SubmissionListItem
|
||||
@@ -29,6 +41,11 @@ interface Props {
|
||||
const props = defineProps<Props>()
|
||||
defineEmits(["showCode"])
|
||||
|
||||
const userStore = useUserStore()
|
||||
const isOwnSubmission = computed(
|
||||
() => userStore.profile?.user?.id === props.submission.user_id,
|
||||
)
|
||||
|
||||
function goto() {
|
||||
window.open("/submission/" + props.submission.id, "_blank")
|
||||
}
|
||||
|
||||
@@ -120,7 +120,7 @@ onMounted(init)
|
||||
<n-alert
|
||||
style="flex: 1"
|
||||
:type="JUDGE_STATUS[submission.result]['type']"
|
||||
:title="JUDGE_STATUS[submission.result]['name']"
|
||||
:title="JUDGE_STATUS[submission.result]['title']"
|
||||
>
|
||||
<n-flex>
|
||||
<span>提交时间:{{ parseTime(submission.create_time) }}</span>
|
||||
|
||||
@@ -6,9 +6,10 @@ import {
|
||||
getFlowchartSubmissions,
|
||||
getSubmissions,
|
||||
getTodaySubmissionCount,
|
||||
retryFlowchartSubmission,
|
||||
} from "oj/api"
|
||||
import { parseTime } from "utils/functions"
|
||||
import {
|
||||
import type {
|
||||
FlowchartSubmissionListItem,
|
||||
LANGUAGE,
|
||||
SubmissionListItem,
|
||||
@@ -22,6 +23,7 @@ import { LANGUAGE_SHOW_VALUE } from "utils/constants"
|
||||
import { renderTableTitle } from "utils/renders"
|
||||
import ButtonWithSearch from "./components/ButtonWithSearch.vue"
|
||||
import StatisticsPanel from "shared/components/StatisticsPanel.vue"
|
||||
import FlowchartStatisticsPanel from "shared/components/FlowchartStatisticsPanel.vue"
|
||||
import SubmissionLink from "./components/SubmissionLink.vue"
|
||||
import SubmissionDetail from "./detail.vue"
|
||||
import Grade from "./components/Grade.vue"
|
||||
@@ -61,8 +63,7 @@ const { query, clearQuery } = usePagination<SubmissionQuery>({
|
||||
const submissionID = ref("")
|
||||
const problemDisplayID = ref("")
|
||||
const [statisticPanel, toggleStatisticPanel] = useToggle(false)
|
||||
const [flowchartStatisticPanel, toggleFlowchartStatisticPanel] =
|
||||
useToggle(false)
|
||||
|
||||
const [codePanel, toggleCodePanel] = useToggle(false)
|
||||
const [scoreDetailPanel, toggleScoreDetailPanel] = useToggle(false)
|
||||
const selectedFlowchartId = ref("")
|
||||
@@ -73,11 +74,20 @@ const selectedFlowchart = computed(() => {
|
||||
const resultOptions: SelectOption[] = [
|
||||
{ label: "全部", value: "" },
|
||||
{ label: "答案正确", value: "0" },
|
||||
{ label: "语法未通过", value: "10" },
|
||||
{ label: "答案错误", value: "-1" },
|
||||
{ label: "编译失败", value: "-2" },
|
||||
{ label: "运行时错误", value: "4" },
|
||||
]
|
||||
|
||||
const gradeOptions: SelectOption[] = [
|
||||
{ label: "全部", value: "" },
|
||||
{ label: "S级", value: "S" },
|
||||
{ label: "A级", value: "A" },
|
||||
{ label: "B级", value: "B" },
|
||||
{ label: "C级", value: "C" },
|
||||
]
|
||||
|
||||
const languageOptions: SelectOption[] = [
|
||||
{ label: "流程图", value: "Flowchart" },
|
||||
{ label: "全部语言", value: "" },
|
||||
@@ -95,6 +105,8 @@ async function listSubmissions() {
|
||||
myself: query.myself,
|
||||
offset,
|
||||
limit: query.limit,
|
||||
today: query.today,
|
||||
grade: query.result,
|
||||
})
|
||||
total.value = res.data.total
|
||||
flowcharts.value = res.data.results
|
||||
@@ -113,7 +125,7 @@ async function listSubmissions() {
|
||||
}
|
||||
|
||||
async function getTodayCount() {
|
||||
const res = await getTodaySubmissionCount()
|
||||
const res = await getTodaySubmissionCount(query.language)
|
||||
todayCount.value = res.data
|
||||
}
|
||||
|
||||
@@ -139,6 +151,12 @@ async function rejudge(submissionID: string) {
|
||||
listSubmissions()
|
||||
}
|
||||
|
||||
async function retryFlowchart(submissionId: string) {
|
||||
await retryFlowchartSubmission(submissionId)
|
||||
message.success("重新评分已提交")
|
||||
listSubmissions()
|
||||
}
|
||||
|
||||
function problemClicked(row: SubmissionListItem | FlowchartSubmissionListItem) {
|
||||
if (route.name === "contest submissions") {
|
||||
const path = router.resolve({
|
||||
@@ -191,6 +209,24 @@ watch(
|
||||
listSubmissions,
|
||||
)
|
||||
|
||||
// 切换语言时重置过滤条件,刷新今日提交数
|
||||
watch(
|
||||
() => query.language,
|
||||
() => {
|
||||
query.result = ""
|
||||
if (route.name === "submissions") getTodayCount()
|
||||
},
|
||||
)
|
||||
|
||||
// 登录状态变化后刷新提交列表,更新提交编号列的可点击状态
|
||||
watch(
|
||||
() => userStore.isAuthed,
|
||||
() => {
|
||||
listSubmissions()
|
||||
if (route.name === "submissions") getTodayCount()
|
||||
},
|
||||
)
|
||||
|
||||
const columns = computed(() => {
|
||||
const res: DataTableColumn<SubmissionListItem>[] = [
|
||||
{
|
||||
@@ -260,7 +296,7 @@ const columns = computed(() => {
|
||||
),
|
||||
},
|
||||
]
|
||||
if (!route.params.contestID && userStore.isSuperAdmin) {
|
||||
if (!route.params.contestID && userStore.isTeacherOrAbove) {
|
||||
res.push({
|
||||
title: renderTableTitle("选项", "streamline-emojis:wrench"),
|
||||
key: "rejudge",
|
||||
@@ -280,61 +316,81 @@ const columns = computed(() => {
|
||||
return res
|
||||
})
|
||||
|
||||
const flowchartColumns: DataTableColumn<FlowchartSubmissionListItem>[] = [
|
||||
{
|
||||
title: renderTableTitle("提交时间", "noto:seven-oclock"),
|
||||
key: "create_time",
|
||||
render: (row) => parseTime(row.create_time, "YYYY-MM-DD HH:mm:ss"),
|
||||
},
|
||||
{
|
||||
title: renderTableTitle("提交编号", "fluent-emoji-flat:input-numbers"),
|
||||
key: "id",
|
||||
render: (row) =>
|
||||
h(FlowchartLink, {
|
||||
flowchart: row,
|
||||
onShowDetail: (id: string) => showScoreDetail(id),
|
||||
}),
|
||||
},
|
||||
{
|
||||
title: renderTableTitle("题目", "streamline-emojis:blossom"),
|
||||
key: "problem_title",
|
||||
render: (row) =>
|
||||
h(
|
||||
ButtonWithSearch,
|
||||
{
|
||||
type: "题目",
|
||||
onClick: () => problemClicked(row),
|
||||
onSearch: () => (query.problem = row.problem),
|
||||
},
|
||||
() => `${row.problem} ${row.problem_title}`,
|
||||
const flowchartColumns = computed(() => {
|
||||
const res: DataTableColumn<FlowchartSubmissionListItem>[] = [
|
||||
{
|
||||
title: renderTableTitle("提交时间", "noto:seven-oclock"),
|
||||
key: "create_time",
|
||||
render: (row) => parseTime(row.create_time, "YYYY-MM-DD HH:mm:ss"),
|
||||
},
|
||||
{
|
||||
title: renderTableTitle("提交编号", "fluent-emoji-flat:input-numbers"),
|
||||
key: "id",
|
||||
render: (row) =>
|
||||
h(FlowchartLink, {
|
||||
flowchart: row,
|
||||
onShowDetail: (id: string) => showScoreDetail(id),
|
||||
}),
|
||||
},
|
||||
{
|
||||
title: renderTableTitle("题目", "streamline-emojis:blossom"),
|
||||
key: "problem_title",
|
||||
render: (row) =>
|
||||
h(
|
||||
ButtonWithSearch,
|
||||
{
|
||||
type: "题目",
|
||||
onClick: () => problemClicked(row),
|
||||
onSearch: () => (query.problem = row.problem),
|
||||
},
|
||||
() => `${row.problem} ${row.problem_title}`,
|
||||
),
|
||||
},
|
||||
{
|
||||
title: renderTableTitle("评分", "streamline-emojis:bar-chart"),
|
||||
key: "ai_score",
|
||||
render: (row) => h(Grade, { score: row.ai_score, grade: row.ai_grade }),
|
||||
},
|
||||
{
|
||||
title: renderTableTitle(
|
||||
"用户",
|
||||
"streamline-emojis:smiling-face-with-sunglasses",
|
||||
),
|
||||
},
|
||||
{
|
||||
title: renderTableTitle("评分", "streamline-emojis:bar-chart"),
|
||||
key: "ai_score",
|
||||
render: (row) => h(Grade, { score: row.ai_score, grade: row.ai_grade }),
|
||||
},
|
||||
{
|
||||
title: renderTableTitle(
|
||||
"用户",
|
||||
"streamline-emojis:smiling-face-with-sunglasses",
|
||||
),
|
||||
key: "username",
|
||||
minWidth: 200,
|
||||
render: (row) =>
|
||||
h(
|
||||
ButtonWithSearch,
|
||||
{
|
||||
type: "用户",
|
||||
username: row.username,
|
||||
onClick: () => window.open("/user?name=" + row.username, "_blank"),
|
||||
onSearch: () => (query.username = row.username),
|
||||
onFilterClass: (classname: string) => (query.username = classname),
|
||||
},
|
||||
() => row.username,
|
||||
),
|
||||
},
|
||||
]
|
||||
key: "username",
|
||||
minWidth: 200,
|
||||
render: (row) =>
|
||||
h(
|
||||
ButtonWithSearch,
|
||||
{
|
||||
type: "用户",
|
||||
username: row.username,
|
||||
onClick: () => window.open("/user?name=" + row.username, "_blank"),
|
||||
onSearch: () => (query.username = row.username),
|
||||
onFilterClass: (classname: string) => (query.username = classname),
|
||||
},
|
||||
() => row.username,
|
||||
),
|
||||
},
|
||||
]
|
||||
if (!route.params.contestID && userStore.isTeacherOrAbove) {
|
||||
res.push({
|
||||
title: renderTableTitle("选项", "streamline-emojis:wrench"),
|
||||
key: "retry",
|
||||
render: (row) =>
|
||||
h(
|
||||
NButton,
|
||||
{
|
||||
quaternary: true,
|
||||
size: "small",
|
||||
type: "primary",
|
||||
onClick: () => retryFlowchart(row.id),
|
||||
},
|
||||
() => "重新判题",
|
||||
),
|
||||
})
|
||||
}
|
||||
return res
|
||||
})
|
||||
</script>
|
||||
<template>
|
||||
<n-flex vertical size="large">
|
||||
@@ -354,12 +410,13 @@ const flowchartColumns: DataTableColumn<FlowchartSubmissionListItem>[] = [
|
||||
:options="languageOptions"
|
||||
/>
|
||||
</n-form-item>
|
||||
<n-form-item label="状态">
|
||||
<n-form-item :label="query.language === 'Flowchart' ? '等级' : '状态'">
|
||||
<n-select
|
||||
:disabled="query.language === 'Flowchart'"
|
||||
class="select"
|
||||
v-model:value="query.result"
|
||||
:options="resultOptions"
|
||||
:options="
|
||||
query.language === 'Flowchart' ? gradeOptions : resultOptions
|
||||
"
|
||||
/>
|
||||
</n-form-item>
|
||||
</n-form>
|
||||
@@ -399,7 +456,7 @@ const flowchartColumns: DataTableColumn<FlowchartSubmissionListItem>[] = [
|
||||
<n-button @click="clear" quaternary>重置</n-button>
|
||||
</n-form-item>
|
||||
<n-form-item
|
||||
v-if="userStore.isSuperAdmin && route.name === 'submissions'"
|
||||
v-if="userStore.isTeacherOrAbove && route.name === 'submissions'"
|
||||
>
|
||||
<n-button
|
||||
quaternary
|
||||
@@ -418,9 +475,9 @@ const flowchartColumns: DataTableColumn<FlowchartSubmissionListItem>[] = [
|
||||
size="large"
|
||||
@update:checked="(v: boolean) => (query.today = v ? '1' : '0')"
|
||||
>
|
||||
<n-gradient-text v-if="query.today !== '1'" type="success"
|
||||
>今日提交数:{{ todayCount }}</n-gradient-text
|
||||
>
|
||||
<n-gradient-text v-if="query.today !== '1'" type="success">
|
||||
今日提交数:{{ todayCount }}
|
||||
</n-gradient-text>
|
||||
<template v-else>今日提交数:{{ todayCount }}</template>
|
||||
</n-tag>
|
||||
</n-space>
|
||||
@@ -443,14 +500,25 @@ const flowchartColumns: DataTableColumn<FlowchartSubmissionListItem>[] = [
|
||||
v-model:page="query.page"
|
||||
/>
|
||||
<n-modal
|
||||
v-if="userStore.isSuperAdmin"
|
||||
v-if="userStore.isTeacherOrAbove"
|
||||
v-model:show="statisticPanel"
|
||||
preset="card"
|
||||
:style="{ maxWidth: isDesktop && '800px', maxHeight: '80vh' }"
|
||||
:content-style="{ overflow: 'auto' }"
|
||||
title="提交记录的统计"
|
||||
:title="
|
||||
query.language === 'Flowchart' ? '流程图提交的统计' : '提交记录的统计'
|
||||
"
|
||||
>
|
||||
<StatisticsPanel :problem="query.problem" :username="query.username" />
|
||||
<FlowchartStatisticsPanel
|
||||
v-if="query.language === 'Flowchart'"
|
||||
:problem="query.problem"
|
||||
:username="query.username"
|
||||
/>
|
||||
<StatisticsPanel
|
||||
v-else
|
||||
:problem="query.problem"
|
||||
:username="query.username"
|
||||
/>
|
||||
</n-modal>
|
||||
<n-modal
|
||||
v-model:show="codePanel"
|
||||
|
||||
29
src/oj/transforms.ts
Normal file
29
src/oj/transforms.ts
Normal 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
|
||||
}
|
||||
@@ -28,7 +28,7 @@ const isDefaultAvatar = computed(
|
||||
() => profile.value?.avatar.endsWith("default.png") ?? true,
|
||||
)
|
||||
|
||||
const problemsFlexRef = ref<HTMLElement | null>(null)
|
||||
const problemsFlexRef = useTemplateRef<HTMLElement>("problemsFlexRef")
|
||||
const itemsPerRow = ref(8)
|
||||
|
||||
function updateItemsPerRow() {
|
||||
@@ -206,16 +206,20 @@ onMounted(init)
|
||||
}"
|
||||
/>
|
||||
<h2>{{ profile.user.username }}</h2>
|
||||
<p class="desc">{{ profile.mood }}</p>
|
||||
<n-button
|
||||
v-if="userStore.isSuperAdmin"
|
||||
type="info"
|
||||
secondary
|
||||
size="small"
|
||||
@click="router.push({ name: 'ai', query: { username: profile.user.username, duration: 'months:1' } })"
|
||||
@click="
|
||||
router.push({
|
||||
name: 'ai',
|
||||
query: { username: profile.user.username, duration: 'months:6' },
|
||||
})
|
||||
"
|
||||
>
|
||||
智能分析
|
||||
</n-button>
|
||||
<p class="desc">{{ profile.mood }}</p>
|
||||
</n-flex>
|
||||
|
||||
<n-grid
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { RouteRecordRaw } from "vue-router"
|
||||
import type { RouteRecordRaw } from "vue-router"
|
||||
|
||||
export const ojs: RouteRecordRaw = {
|
||||
path: "/",
|
||||
@@ -182,48 +182,48 @@ export const admins: RouteRecordRaw = {
|
||||
path: "contest/list",
|
||||
name: "admin contest list",
|
||||
component: () => import("admin/contest/list.vue"),
|
||||
meta: { requiresSuperAdmin: true },
|
||||
meta: { requiresTeacherAdmin: true },
|
||||
},
|
||||
{
|
||||
path: "contest/create",
|
||||
name: "admin contest create",
|
||||
component: () => import("admin/contest/detail.vue"),
|
||||
meta: { requiresSuperAdmin: true },
|
||||
meta: { requiresTeacherAdmin: true },
|
||||
},
|
||||
{
|
||||
path: "contest/edit/:contestID",
|
||||
name: "admin contest edit",
|
||||
component: () => import("admin/contest/detail.vue"),
|
||||
props: true,
|
||||
meta: { requiresSuperAdmin: true },
|
||||
meta: { requiresTeacherAdmin: true },
|
||||
},
|
||||
{
|
||||
path: "contest/:contestID/problem/list",
|
||||
name: "admin contest problem list",
|
||||
component: () => import("admin/problem/list.vue"),
|
||||
props: true,
|
||||
meta: { requiresSuperAdmin: true },
|
||||
meta: { requiresTeacherAdmin: true },
|
||||
},
|
||||
{
|
||||
path: "contest/:contestID/problem/create",
|
||||
name: "admin contest problem create",
|
||||
component: () => import("admin/problem/detail.vue"),
|
||||
props: true,
|
||||
meta: { requiresSuperAdmin: true },
|
||||
meta: { requiresTeacherAdmin: true },
|
||||
},
|
||||
{
|
||||
path: "contest/:contestID/problem/edit/:problemID",
|
||||
name: "admin contest problem edit",
|
||||
component: () => import("admin/problem/detail.vue"),
|
||||
props: true,
|
||||
meta: { requiresSuperAdmin: true },
|
||||
meta: { requiresTeacherAdmin: true },
|
||||
},
|
||||
{
|
||||
path: "contest/:contestID/helper",
|
||||
name: "admin contest helper",
|
||||
component: () => import("admin/contest/helper.vue"),
|
||||
props: true,
|
||||
meta: { requiresSuperAdmin: true },
|
||||
meta: { requiresTeacherAdmin: true },
|
||||
},
|
||||
// 只有super_admin可以访问的路由
|
||||
{
|
||||
@@ -280,40 +280,46 @@ export const admins: RouteRecordRaw = {
|
||||
path: "problem/stuck",
|
||||
name: "admin stuck problems",
|
||||
component: () => import("admin/problem/Stuck.vue"),
|
||||
meta: { requiresSuperAdmin: true },
|
||||
meta: { requiresTeacherAdmin: true },
|
||||
},
|
||||
{
|
||||
path: "problem/top_ac_trend",
|
||||
name: "admin top ac trend",
|
||||
component: () => import("admin/problem/TopACTrend.vue"),
|
||||
meta: { requiresSuperAdmin: true },
|
||||
meta: { requiresTeacherAdmin: true },
|
||||
},
|
||||
// 题单管理路由
|
||||
{
|
||||
path: "problemset/list",
|
||||
name: "admin problemset list",
|
||||
component: () => import("admin/problemset/list.vue"),
|
||||
meta: { requiresSuperAdmin: true },
|
||||
meta: { requiresTeacherAdmin: true },
|
||||
},
|
||||
{
|
||||
path: "problemset/create",
|
||||
name: "admin problemset create",
|
||||
component: () => import("admin/problemset/edit.vue"),
|
||||
meta: { requiresSuperAdmin: true },
|
||||
meta: { requiresTeacherAdmin: true },
|
||||
},
|
||||
{
|
||||
path: "problemset/edit/:problemSetId",
|
||||
name: "admin problemset edit",
|
||||
component: () => import("admin/problemset/edit.vue"),
|
||||
props: true,
|
||||
meta: { requiresSuperAdmin: true },
|
||||
meta: { requiresTeacherAdmin: true },
|
||||
},
|
||||
{
|
||||
path: "problemset/:problemSetId",
|
||||
name: "admin problemset detail",
|
||||
component: () => import("admin/problemset/detail.vue"),
|
||||
props: true,
|
||||
meta: { requiresSuperAdmin: true },
|
||||
meta: { requiresTeacherAdmin: true },
|
||||
},
|
||||
{
|
||||
path: "ai/reports",
|
||||
name: "admin ai reports",
|
||||
component: () => import("admin/ai/list.vue"),
|
||||
meta: { requiresTeacherAdmin: true },
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
@@ -45,6 +45,6 @@ function goPublicSecurity() {
|
||||
</script>
|
||||
<style scoped>
|
||||
.beian {
|
||||
margin-bottom: 12px;
|
||||
margin: 12px 0;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -22,21 +22,19 @@ interface Props {
|
||||
placeholder?: string
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
language: "Python3",
|
||||
fontSize: 20,
|
||||
height: "100%",
|
||||
readonly: false,
|
||||
placeholder: "",
|
||||
})
|
||||
|
||||
const { readonly, placeholder, height, fontSize } = toRefs(props)
|
||||
const {
|
||||
language = "Python3",
|
||||
fontSize = 20,
|
||||
height = "100%",
|
||||
readonly = false,
|
||||
placeholder = "",
|
||||
} = defineProps<Props>()
|
||||
const code = defineModel<string>("value")
|
||||
|
||||
const isDark = useDark()
|
||||
|
||||
const langExtension = computed(() => {
|
||||
return ["Python2", "Python3"].includes(props.language) ? python() : cpp()
|
||||
return ["Python2", "Python3"].includes(language) ? python() : cpp()
|
||||
})
|
||||
|
||||
const extensions = computed(() => [
|
||||
@@ -45,7 +43,7 @@ const extensions = computed(() => [
|
||||
bracketMatching(),
|
||||
closeBrackets(),
|
||||
autocompletion({
|
||||
override: [enhanceCompletion(props.language), completeAnyWord],
|
||||
override: [enhanceCompletion(language), completeAnyWord],
|
||||
}),
|
||||
isDark.value ? oneDark : smoothy,
|
||||
])
|
||||
|
||||
@@ -78,7 +78,7 @@ const emit = defineEmits<Emits>()
|
||||
const isHovered = ref(false)
|
||||
const isEditing = ref(false)
|
||||
const editText = ref("")
|
||||
const editInput = ref<HTMLInputElement>()
|
||||
const editInput = useTemplateRef<HTMLInputElement>("editInput")
|
||||
|
||||
// 定时器和事件处理器
|
||||
let hideTimeout: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
@@ -26,9 +26,7 @@ interface Props {
|
||||
height?: string
|
||||
}
|
||||
|
||||
withDefaults(defineProps<Props>(), {
|
||||
height: "calc(100vh - 133px)",
|
||||
})
|
||||
const { height = "calc(100vh - 133px)" } = defineProps<Props>()
|
||||
|
||||
// Vue Flow 实例
|
||||
const { addEdges, removeNodes, removeEdges } = useVueFlow()
|
||||
|
||||
577
src/shared/components/FlowchartStatisticsPanel.vue
Normal file
577
src/shared/components/FlowchartStatisticsPanel.vue
Normal 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>
|
||||
@@ -166,7 +166,7 @@ const menus = computed<MenuOption[]>(() => [
|
||||
label: () =>
|
||||
h(
|
||||
RouterLink,
|
||||
{ to: userStore.isTheAdmin ? "/admin/problem/list" : "/admin" },
|
||||
{ to: userStore.isSuperAdmin ? "/admin" : "/admin/problem/list" },
|
||||
{ default: () => "后台" },
|
||||
),
|
||||
show: userStore.isAdminRole,
|
||||
|
||||
@@ -17,7 +17,7 @@ const {
|
||||
loginLoading: isLoading,
|
||||
loginError: msg,
|
||||
} = storeToRefs(authStore)
|
||||
const loginRef = ref()
|
||||
const loginRef = useTemplateRef("loginRef")
|
||||
const classUserOptions = ref<SelectOption[]>([])
|
||||
const classUserLoading = ref(false)
|
||||
const isClassLogin = computed(() => Boolean(form.value.class))
|
||||
|
||||
@@ -7,17 +7,14 @@ interface Props {
|
||||
page: number
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
limit: 10,
|
||||
page: 1,
|
||||
})
|
||||
const { total, limit: initialLimit = 10, page: initialPage = 1 } = defineProps<Props>()
|
||||
|
||||
const emit = defineEmits(["update:limit", "update:page"])
|
||||
|
||||
const { isDesktop } = useBreakpoints()
|
||||
|
||||
const limit = ref(props.limit)
|
||||
const page = ref(props.page)
|
||||
const limit = ref(initialLimit)
|
||||
const page = ref(initialPage)
|
||||
const sizes = [10, 30, 50]
|
||||
|
||||
watch(limit, () => emit("update:limit", limit))
|
||||
@@ -26,9 +23,9 @@ watch(page, () => emit("update:page", page))
|
||||
|
||||
<template>
|
||||
<n-pagination
|
||||
v-if="props.total"
|
||||
v-if="total"
|
||||
class="right margin"
|
||||
:item-count="props.total"
|
||||
:item-count="total"
|
||||
v-model:page="page"
|
||||
v-model:page-size="limit"
|
||||
:page-sizes="sizes"
|
||||
|
||||
@@ -12,7 +12,7 @@ const {
|
||||
signupError: msg,
|
||||
captchaSrc,
|
||||
} = storeToRefs(authStore)
|
||||
const signupRef = ref()
|
||||
const signupRef = useTemplateRef("signupRef")
|
||||
|
||||
const rules: FormRules = {
|
||||
username: [{ required: true, message: "用户名必填", trigger: "blur" }],
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
<template>
|
||||
<n-flex>
|
||||
<n-flex align="center">
|
||||
<n-input
|
||||
placeholder="用户(可选)"
|
||||
v-model:value="query.username"
|
||||
style="width: 160px"
|
||||
style="width: 150px"
|
||||
clearable
|
||||
/>
|
||||
<n-input
|
||||
placeholder="题号(可选)"
|
||||
v-model:value="query.problem"
|
||||
style="width: 160px"
|
||||
style="width: 120px"
|
||||
clearable
|
||||
/>
|
||||
<n-select
|
||||
@@ -22,79 +22,132 @@
|
||||
前往提交列表
|
||||
</n-button>
|
||||
</n-flex>
|
||||
<n-flex style="margin: 20px 0" v-if="count.total > 0">
|
||||
<n-gradient-text font-size="24" type="primary">
|
||||
正确提交数:{{ count.accepted }}
|
||||
</n-gradient-text>
|
||||
<n-gradient-text font-size="24" type="info">
|
||||
总提交数:{{ count.total }}
|
||||
</n-gradient-text>
|
||||
<n-gradient-text font-size="24" type="warning">
|
||||
正确率:{{ count.rate }}
|
||||
</n-gradient-text>
|
||||
</n-flex>
|
||||
<n-flex style="margin: 20px 0" v-if="count.total > 0">
|
||||
<n-gradient-text font-size="24" type="error">
|
||||
回答正确的人数:{{ list.length }}
|
||||
</n-gradient-text>
|
||||
<n-gradient-text font-size="24" v-if="person.count > 0" type="warning">
|
||||
班级人数:{{ person.count }}
|
||||
</n-gradient-text>
|
||||
<n-gradient-text font-size="24" v-if="person.count > 0" type="success">
|
||||
班级完成度:{{ person.rate }}
|
||||
</n-gradient-text>
|
||||
</n-flex>
|
||||
<n-flex style="margin: 20px 0" v-if="count.total > 0">
|
||||
<n-button type="warning" @click="toggleUnaccepted(!unaccepted)">
|
||||
{{ unaccepted ? "隐藏没有完成的" : "显示没有完成的" }}
|
||||
</n-button>
|
||||
</n-flex>
|
||||
<n-flex style="margin-top: 20px">
|
||||
<n-gradient-text font-size="24" v-if="count.total === 0" type="primary">
|
||||
暂无数据统计
|
||||
</n-gradient-text>
|
||||
</n-flex>
|
||||
|
||||
<n-flex style="margin-bottom: 20px" v-if="unaccepted" size="large">
|
||||
<span style="font-size: 24px">
|
||||
这 {{ listUnaccepted.length }} 位没有完成:
|
||||
</span>
|
||||
<span style="font-size: 24px" v-for="name in listUnaccepted" :key="name">
|
||||
{{ name }}
|
||||
</span>
|
||||
</n-flex>
|
||||
<n-empty
|
||||
v-if="count.total === 0"
|
||||
description="暂无数据"
|
||||
style="margin: 40px 0"
|
||||
/>
|
||||
|
||||
<n-tabs animated v-if="count.total > 0">
|
||||
<n-tab-pane name="charts" tab="数据图表">
|
||||
<n-grid :cols="2" :x-gap="20" :y-gap="20" style="margin-top: 20px">
|
||||
<n-gi>
|
||||
<n-card title="提交正确率">
|
||||
<Doughnut :data="pieChartData" :options="pieChartOptions" />
|
||||
</n-card>
|
||||
</n-gi>
|
||||
<n-gi v-if="person.count > 0">
|
||||
<n-card title="班级完成度">
|
||||
<Doughnut
|
||||
:data="completionChartData"
|
||||
:options="completionChartOptions"
|
||||
/>
|
||||
</n-card>
|
||||
</n-gi>
|
||||
</n-grid>
|
||||
</n-tab-pane>
|
||||
<n-tab-pane name="submissions" tab="提交记录">
|
||||
<n-data-table
|
||||
v-if="list.length"
|
||||
striped
|
||||
:columns="columns"
|
||||
:data="list"
|
||||
:row-key="rowKey"
|
||||
:expanded-row-keys="expandedRowKeys"
|
||||
@update:expanded-row-keys="updateExpandedRowKeys"
|
||||
:row-props="rowProps"
|
||||
/>
|
||||
</n-tab-pane>
|
||||
</n-tabs>
|
||||
<template v-if="count.total > 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">{{
|
||||
count.total
|
||||
}}</n-gradient-text>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<n-text>正确提交</n-text>
|
||||
<n-gradient-text type="primary" font-size="28">{{
|
||||
count.accepted
|
||||
}}</n-gradient-text>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<n-text>正确率</n-text>
|
||||
<n-gradient-text type="warning" font-size="28">{{
|
||||
count.rate
|
||||
}}</n-gradient-text>
|
||||
</div>
|
||||
<template v-if="person.count > 0">
|
||||
<div class="stat-item">
|
||||
<n-text>完成人数</n-text>
|
||||
<n-gradient-text type="error" font-size="28">{{
|
||||
list.length
|
||||
}}</n-gradient-text>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<n-text>班级人数</n-text>
|
||||
<n-gradient-text type="warning" font-size="28">{{
|
||||
adjustedPersonCount
|
||||
}}</n-gradient-text>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<n-text>完成度</n-text>
|
||||
<n-gradient-text type="success" font-size="28">{{
|
||||
adjustedPersonRate
|
||||
}}</n-gradient-text>
|
||||
</div>
|
||||
</template>
|
||||
</n-flex>
|
||||
<n-divider style="margin: 16px 0" />
|
||||
|
||||
<n-tabs animated type="line">
|
||||
<n-tab-pane name="charts" tab="数据图表">
|
||||
<n-grid :cols="2" :x-gap="20" :y-gap="20" style="margin-top: 12px">
|
||||
<n-gi>
|
||||
<n-card title="提交正确率">
|
||||
<Doughnut :data="pieChartData" :options="pieChartOptions" />
|
||||
</n-card>
|
||||
</n-gi>
|
||||
<n-gi v-if="person.count > 0">
|
||||
<n-card title="班级完成度">
|
||||
<Doughnut
|
||||
:data="completionChartData"
|
||||
:options="completionChartOptions"
|
||||
/>
|
||||
</n-card>
|
||||
</n-gi>
|
||||
</n-grid>
|
||||
</n-tab-pane>
|
||||
|
||||
<n-tab-pane name="submissions" tab="提交记录">
|
||||
<n-data-table
|
||||
v-if="list.length"
|
||||
striped
|
||||
:columns="columns"
|
||||
:data="list"
|
||||
:row-key="rowKey"
|
||||
:expanded-row-keys="expandedRowKeys"
|
||||
@update:expanded-row-keys="updateExpandedRowKeys"
|
||||
:row-props="rowProps"
|
||||
style="margin-top: 12px"
|
||||
/>
|
||||
</n-tab-pane>
|
||||
|
||||
<n-tab-pane
|
||||
name="unaccepted"
|
||||
:tab="`未完成(${visibleUnaccepted.length})`"
|
||||
>
|
||||
<n-flex align="center" style="margin: 12px 0">
|
||||
<n-switch v-model:value="hideMode" size="large">
|
||||
<template #checked>请假隐藏中</template>
|
||||
<template #unchecked>请假隐藏</template>
|
||||
</n-switch>
|
||||
<n-button
|
||||
v-if="hiddenCount > 0"
|
||||
size="small"
|
||||
type="info"
|
||||
@click="showAll"
|
||||
>
|
||||
恢复 {{ hiddenCount }} 位
|
||||
</n-button>
|
||||
</n-flex>
|
||||
<n-flex size="large" align="center">
|
||||
<n-gradient-text
|
||||
v-if="visibleUnaccepted.length === 0"
|
||||
font-size="24"
|
||||
type="success"
|
||||
>
|
||||
全都完成了
|
||||
</n-gradient-text>
|
||||
<template v-for="item in visibleUnaccepted" :key="item.username">
|
||||
<n-tag
|
||||
v-if="hideMode"
|
||||
closable
|
||||
size="large"
|
||||
style="font-size: 20px"
|
||||
@close="hideStudent(item.username)"
|
||||
>
|
||||
{{ item.real_name }}
|
||||
</n-tag>
|
||||
<span v-else style="font-size: 24px">{{ item.real_name }}</span>
|
||||
</template>
|
||||
</n-flex>
|
||||
</n-tab-pane>
|
||||
</n-tabs>
|
||||
</template>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { h } from "vue"
|
||||
@@ -121,7 +174,9 @@ const options: SelectOption[] = [
|
||||
{ label: "10分钟内", value: "minutes:10" },
|
||||
{ label: "20分钟内", value: "minutes:20" },
|
||||
{ label: "30分钟内", value: "minutes:30" },
|
||||
].concat(DURATION_OPTIONS)
|
||||
...DURATION_OPTIONS,
|
||||
{ label: "全部时段", value: "all" },
|
||||
]
|
||||
|
||||
function openSubmission(id: string) {
|
||||
window.open(`/submission/${id}`, "_blank", "noopener")
|
||||
@@ -186,11 +241,82 @@ interface UserStatistic {
|
||||
}>
|
||||
}
|
||||
|
||||
interface UnacceptedItem {
|
||||
username: string
|
||||
real_name: string
|
||||
}
|
||||
|
||||
const list = ref<UserStatistic[]>([])
|
||||
const listUnaccepted = ref<string[]>([])
|
||||
const [unaccepted, toggleUnaccepted] = useToggle()
|
||||
const listUnaccepted = ref<UnacceptedItem[]>([])
|
||||
const expandedRowKeys = ref<DataTableRowKey[]>([])
|
||||
|
||||
const HIDE_DURATION = 2 * 60 * 60 * 1000
|
||||
const STORAGE_KEY = "oj_hidden_students"
|
||||
|
||||
function loadHidden(): Record<string, number> {
|
||||
try {
|
||||
return JSON.parse(localStorage.getItem(STORAGE_KEY) ?? "{}")
|
||||
} catch {
|
||||
return {}
|
||||
}
|
||||
}
|
||||
|
||||
const hiddenStudents = ref<Record<string, number>>(loadHidden())
|
||||
const hideMode = ref(false)
|
||||
|
||||
function saveHidden(data: Record<string, number>) {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(data))
|
||||
}
|
||||
|
||||
function hideStudent(username: string) {
|
||||
hiddenStudents.value = {
|
||||
...hiddenStudents.value,
|
||||
[username]: Date.now() + HIDE_DURATION,
|
||||
}
|
||||
saveHidden(hiddenStudents.value)
|
||||
}
|
||||
|
||||
function showAll() {
|
||||
hiddenStudents.value = {}
|
||||
saveHidden({})
|
||||
}
|
||||
|
||||
const visibleUnaccepted = computed(() => {
|
||||
const now = Date.now()
|
||||
return listUnaccepted.value.filter((item) => {
|
||||
const exp = hiddenStudents.value[item.username]
|
||||
return !exp || exp <= now
|
||||
})
|
||||
})
|
||||
|
||||
const hiddenCount = computed(() => {
|
||||
const now = Date.now()
|
||||
return listUnaccepted.value.filter((item) => {
|
||||
const exp = hiddenStudents.value[item.username]
|
||||
return !!exp && exp > now
|
||||
}).length
|
||||
})
|
||||
|
||||
const adjustedPersonCount = computed(() => person.count - hiddenCount.value)
|
||||
|
||||
const adjustedPersonRate = computed(() => {
|
||||
if (adjustedPersonCount.value <= 0) return "0%"
|
||||
const rate = Math.min(
|
||||
100,
|
||||
(list.value.length / adjustedPersonCount.value) * 100,
|
||||
)
|
||||
return `${Math.round(rate * 100) / 100}%`
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
const now = Date.now()
|
||||
const cleaned = Object.fromEntries(
|
||||
Object.entries(hiddenStudents.value).filter(([, exp]) => exp > now),
|
||||
)
|
||||
hiddenStudents.value = cleaned
|
||||
saveHidden(cleaned)
|
||||
})
|
||||
|
||||
// 饼图数据 - 提交正确率分布
|
||||
const pieChartData = computed(() => {
|
||||
const wrongCount = count.total - count.accepted
|
||||
@@ -235,7 +361,10 @@ const pieChartOptions = {
|
||||
// 环形图数据 - 班级完成度
|
||||
const completionChartData = computed(() => {
|
||||
const completedCount = list.value.length
|
||||
const uncompletedCount = person.count - completedCount
|
||||
const uncompletedCount = Math.max(
|
||||
0,
|
||||
adjustedPersonCount.value - completedCount,
|
||||
)
|
||||
return {
|
||||
labels: ["已完成", "未完成"],
|
||||
datasets: [
|
||||
@@ -294,9 +423,12 @@ function goSubmissions() {
|
||||
async function handleStatistics() {
|
||||
const current = Date.now()
|
||||
const end = formatISO(current)
|
||||
const start = formatISO(sub(current, subOptions.value))
|
||||
const duration =
|
||||
query.duration === "all"
|
||||
? { end }
|
||||
: { start: formatISO(sub(current, subOptions.value)), end }
|
||||
const res = await getSubmissionStatistics(
|
||||
{ start, end },
|
||||
duration,
|
||||
query.problem,
|
||||
query.username,
|
||||
)
|
||||
@@ -307,8 +439,6 @@ async function handleStatistics() {
|
||||
listUnaccepted.value = res.data.data_unaccepted
|
||||
person.count = res.data.person_count
|
||||
person.rate = res.data.person_rate
|
||||
|
||||
toggleUnaccepted(false)
|
||||
}
|
||||
|
||||
function rowKey(row: UserStatistic): DataTableRowKey {
|
||||
@@ -330,4 +460,11 @@ function rowProps(row: UserStatistic) {
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<style scoped></style>
|
||||
<style scoped>
|
||||
.stat-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -36,15 +36,15 @@ interface Props {
|
||||
placeholder?: string
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
language: "Python3",
|
||||
fontSize: 20,
|
||||
height: "100%",
|
||||
readonly: false,
|
||||
placeholder: "",
|
||||
})
|
||||
|
||||
const { readonly, placeholder, height, fontSize } = toRefs(props)
|
||||
const {
|
||||
sync,
|
||||
problem,
|
||||
language = "Python3",
|
||||
fontSize = 20,
|
||||
height = "100%",
|
||||
readonly = false,
|
||||
placeholder = "",
|
||||
} = defineProps<Props>()
|
||||
const code = defineModel<string>("value")
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -57,7 +57,7 @@ const emit = defineEmits<{
|
||||
const { isDesktop } = useBreakpoints()
|
||||
|
||||
const langExtension = computed((): Extension => {
|
||||
return ["Python2", "Python3"].includes(props.language) ? python() : cpp()
|
||||
return ["Python2", "Python3"].includes(language) ? python() : cpp()
|
||||
})
|
||||
|
||||
const extensions = computed(() => [
|
||||
@@ -67,7 +67,7 @@ const extensions = computed(() => [
|
||||
closeBrackets(),
|
||||
isDark.value ? oneDark : smoothy,
|
||||
autocompletion({
|
||||
override: [enhanceCompletion(props.language), completeAnyWord],
|
||||
override: [enhanceCompletion(language), completeAnyWord],
|
||||
}),
|
||||
getInitialExtension(),
|
||||
])
|
||||
@@ -85,12 +85,12 @@ const cleanupSyncResources = () => {
|
||||
}
|
||||
|
||||
const initSync = async () => {
|
||||
if (!editorView.value || !props.problem || !isDesktop.value) return
|
||||
if (!editorView.value || !problem || !isDesktop.value) return
|
||||
|
||||
cleanupSyncResources()
|
||||
|
||||
cleanupSync = await startSync({
|
||||
problemId: props.problem,
|
||||
problemId: problem,
|
||||
editorView: editorView.value as EditorView,
|
||||
onStatusChange: (status) => {
|
||||
// 处理需要断开同步的情况
|
||||
@@ -108,13 +108,13 @@ const initSync = async () => {
|
||||
|
||||
const handleEditorReady = (payload: EditorReadyPayload) => {
|
||||
editorView.value = payload.view as EditorView
|
||||
if (props.sync) {
|
||||
if (sync) {
|
||||
initSync()
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.sync,
|
||||
() => sync,
|
||||
(shouldSync) => {
|
||||
if (shouldSync) {
|
||||
initSync()
|
||||
@@ -125,9 +125,9 @@ watch(
|
||||
)
|
||||
|
||||
watch(
|
||||
() => props.problem,
|
||||
() => problem,
|
||||
(newProblem, oldProblem) => {
|
||||
if (newProblem !== oldProblem && props.sync) {
|
||||
if (newProblem !== oldProblem && sync) {
|
||||
initSync()
|
||||
}
|
||||
},
|
||||
|
||||
@@ -17,10 +17,7 @@ interface Props {
|
||||
const rawHtml = defineModel<string>("value")
|
||||
type InsertFnType = (url: string, alt: string, href: string) => void
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
minHeight: 0,
|
||||
simple: false,
|
||||
})
|
||||
const { title, minHeight = 0, simple = false } = defineProps<Props>()
|
||||
|
||||
const message = useMessage()
|
||||
|
||||
@@ -112,17 +109,17 @@ async function customUpload(file: File, insertFn: InsertFnType) {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="title" v-if="props.title">{{ props.title }}</div>
|
||||
<div class="title" v-if="title">{{ title }}</div>
|
||||
<div class="editorWrapper">
|
||||
<Toolbar
|
||||
class="toolbar"
|
||||
:editor="toolbarEditorRef"
|
||||
:defaultConfig="props.simple ? toolbarConfigSimple : toolbarConfig"
|
||||
:defaultConfig="simple ? toolbarConfigSimple : toolbarConfig"
|
||||
mode="simple"
|
||||
/>
|
||||
<Editor
|
||||
@click="onClick"
|
||||
:style="{ minHeight: props.minHeight + 'px' }"
|
||||
:style="{ minHeight: minHeight + 'px' }"
|
||||
v-model="rawHtml"
|
||||
:defaultConfig="editorConfig"
|
||||
mode="simple"
|
||||
|
||||
@@ -5,6 +5,7 @@ const mermaidThemeVariables = {
|
||||
primaryTextColor: "#0f172a",
|
||||
primaryBorderColor: "#0284c7",
|
||||
lineColor: "#64748b",
|
||||
arrowheadColor: "#64748b",
|
||||
secondaryColor: "#f5f3ff",
|
||||
tertiaryColor: "#ecfdf5",
|
||||
background: "#ffffff",
|
||||
@@ -250,12 +251,22 @@ function applyFlowchartDisplayStyle(container: HTMLElement) {
|
||||
svg.insertBefore(style, svg.firstChild)
|
||||
}
|
||||
|
||||
function getChromeVersion(): number {
|
||||
const match = navigator.userAgent.match(/Chrome\/(\d+)/)
|
||||
return match ? parseInt(match[1]) : Infinity
|
||||
}
|
||||
|
||||
let mermaidInstance: any = null
|
||||
let mermaidIsLegacy = false
|
||||
|
||||
async function loadMermaid() {
|
||||
if (!mermaidInstance) {
|
||||
const mermaidModule = await import("mermaid")
|
||||
mermaidInstance = mermaidModule.default
|
||||
if (getChromeVersion() < 94) {
|
||||
mermaidInstance = (await import("mermaid-legacy")).default
|
||||
mermaidIsLegacy = true
|
||||
} else {
|
||||
mermaidInstance = (await import("mermaid")).default
|
||||
}
|
||||
mermaidInstance.initialize({
|
||||
startOnLoad: false,
|
||||
securityLevel: "strict",
|
||||
@@ -286,7 +297,17 @@ export function useMermaid() {
|
||||
try {
|
||||
const m = await loadMermaid()
|
||||
const id = `mermaid-${getRandomId()}`
|
||||
const { svg } = await m.render(id, mermaidCode)
|
||||
// v9 (mermaid-legacy): callback-based render(id, code, cb)
|
||||
// v10+: Promise-based render(id, code) → { svg }
|
||||
const svg = mermaidIsLegacy
|
||||
? await new Promise<string>((resolve, reject) => {
|
||||
try {
|
||||
m.render(id, mermaidCode, resolve)
|
||||
} catch (e) {
|
||||
reject(e)
|
||||
}
|
||||
})
|
||||
: (await m.render(id, mermaidCode)).svg
|
||||
if (gen !== renderGeneration) return
|
||||
container.innerHTML = svg
|
||||
applyFlowchartDisplayStyle(container)
|
||||
|
||||
@@ -19,8 +19,8 @@ const options = computed<MenuOption[]>(() => {
|
||||
},
|
||||
]
|
||||
|
||||
// admin 可以访问的功能
|
||||
if (userStore.isTheAdmin) {
|
||||
// Student Admin: only problems
|
||||
if (userStore.isStudentAdmin) {
|
||||
baseOptions.push({
|
||||
label: () =>
|
||||
h(RouterLink, { to: "/admin/problem/list" }, { default: () => "题目" }),
|
||||
@@ -28,7 +28,49 @@ const options = computed<MenuOption[]>(() => {
|
||||
})
|
||||
}
|
||||
|
||||
// super_admin 可以访问的功能
|
||||
// Teacher Admin: problems + contests + problemsets
|
||||
if (userStore.isTeacherAdmin) {
|
||||
baseOptions.push(
|
||||
{
|
||||
label: () =>
|
||||
h(
|
||||
RouterLink,
|
||||
{ to: "/admin/problem/list" },
|
||||
{ default: () => "题目" },
|
||||
),
|
||||
key: "admin problem list",
|
||||
},
|
||||
{
|
||||
label: () =>
|
||||
h(
|
||||
RouterLink,
|
||||
{ to: "/admin/contest/list" },
|
||||
{ default: () => "比赛" },
|
||||
),
|
||||
key: "admin contest list",
|
||||
},
|
||||
{
|
||||
label: () =>
|
||||
h(
|
||||
RouterLink,
|
||||
{ to: "/admin/problemset/list" },
|
||||
{ default: () => "题单" },
|
||||
),
|
||||
key: "admin problemset list",
|
||||
},
|
||||
{
|
||||
label: () =>
|
||||
h(
|
||||
RouterLink,
|
||||
{ to: "/admin/ai/reports" },
|
||||
{ default: () => "AI报告" },
|
||||
),
|
||||
key: "admin ai reports",
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
// Super Admin: everything
|
||||
if (userStore.isSuperAdmin) {
|
||||
baseOptions.push(
|
||||
{
|
||||
@@ -99,6 +141,15 @@ const options = computed<MenuOption[]>(() => {
|
||||
),
|
||||
key: "admin tutorial list",
|
||||
},
|
||||
{
|
||||
label: () =>
|
||||
h(
|
||||
RouterLink,
|
||||
{ to: "/admin/ai/reports" },
|
||||
{ default: () => "AI报告" },
|
||||
),
|
||||
key: "admin ai reports",
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@@ -119,6 +170,7 @@ const active = computed(() => {
|
||||
if (path.startsWith("/admin/comment")) return "admin comment list"
|
||||
if (path.startsWith("/admin/announcement")) return "admin announcement list"
|
||||
if (path.startsWith("/admin/tutorial")) return "admin tutorial list"
|
||||
if (path.startsWith("/admin/ai")) return "admin ai reports"
|
||||
return route.name as string
|
||||
})
|
||||
|
||||
|
||||
18
src/shared/store/myFlowchart.ts
Normal file
18
src/shared/store/myFlowchart.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { defineStore } from "pinia"
|
||||
|
||||
export const useMyFlowchartStore = defineStore("myFlowchart", () => {
|
||||
const showing = ref(false)
|
||||
const mermaidCode = ref("")
|
||||
|
||||
function show(code: string) {
|
||||
mermaidCode.value = code
|
||||
showing.value = true
|
||||
}
|
||||
|
||||
function hide() {
|
||||
showing.value = false
|
||||
mermaidCode.value = ""
|
||||
}
|
||||
|
||||
return { showing, mermaidCode, show, hide }
|
||||
})
|
||||
@@ -13,10 +13,21 @@ export const useUserStore = defineStore("user", () => {
|
||||
const isAuthed = computed(() => !!user.value?.email)
|
||||
const isAdminRole = computed(
|
||||
() =>
|
||||
user.value?.admin_type === USER_TYPE.ADMIN ||
|
||||
user.value?.admin_type === USER_TYPE.STUDENT_ADMIN ||
|
||||
user.value?.admin_type === USER_TYPE.TEACHER_ADMIN ||
|
||||
user.value?.admin_type === USER_TYPE.SUPER_ADMIN,
|
||||
)
|
||||
const isStudentAdmin = computed(
|
||||
() => user.value?.admin_type === USER_TYPE.STUDENT_ADMIN,
|
||||
)
|
||||
const isTeacherAdmin = computed(
|
||||
() => user.value?.admin_type === USER_TYPE.TEACHER_ADMIN,
|
||||
)
|
||||
const isTeacherOrAbove = computed(
|
||||
() =>
|
||||
user.value?.admin_type === USER_TYPE.TEACHER_ADMIN ||
|
||||
user.value?.admin_type === USER_TYPE.SUPER_ADMIN,
|
||||
)
|
||||
const isTheAdmin = computed(() => user.value?.admin_type === USER_TYPE.ADMIN)
|
||||
const isSuperAdmin = computed(
|
||||
() => user.value?.admin_type === USER_TYPE.SUPER_ADMIN,
|
||||
)
|
||||
@@ -47,7 +58,9 @@ export const useUserStore = defineStore("user", () => {
|
||||
isFinished,
|
||||
user,
|
||||
isAdminRole,
|
||||
isTheAdmin,
|
||||
isStudentAdmin,
|
||||
isTeacherAdmin,
|
||||
isTeacherOrAbove,
|
||||
isSuperAdmin,
|
||||
hasProblemPermission,
|
||||
isAuthed,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { SUBMISSION_RESULT } from "./types"
|
||||
import type { SUBMISSION_RESULT } from "./types"
|
||||
|
||||
export enum SubmissionStatus {
|
||||
compile_error = -2,
|
||||
@@ -12,6 +12,7 @@ export enum SubmissionStatus {
|
||||
judging = 7,
|
||||
partial_accepted = 8,
|
||||
submitting = 9,
|
||||
ast_check_failed = 10,
|
||||
}
|
||||
|
||||
export enum ContestStatus {
|
||||
@@ -29,57 +30,75 @@ export enum ContestType {
|
||||
export const JUDGE_STATUS: {
|
||||
[key in SUBMISSION_RESULT]: {
|
||||
name: string
|
||||
title: string
|
||||
type: "error" | "success" | "warning" | "info"
|
||||
}
|
||||
} = {
|
||||
"-2": {
|
||||
name: "编译失败",
|
||||
title: "编译失败",
|
||||
type: "warning",
|
||||
},
|
||||
"-1": {
|
||||
name: "答案错误",
|
||||
title: "答案错误",
|
||||
type: "error",
|
||||
},
|
||||
"0": {
|
||||
name: "答案正确",
|
||||
title: "答案正确",
|
||||
type: "success",
|
||||
},
|
||||
"1": {
|
||||
name: "运行超时",
|
||||
title: "运行超时",
|
||||
type: "error",
|
||||
},
|
||||
"2": {
|
||||
name: "运行超时",
|
||||
title: "运行超时",
|
||||
type: "error",
|
||||
},
|
||||
"3": {
|
||||
name: "内存超限",
|
||||
title: "内存超限",
|
||||
type: "error",
|
||||
},
|
||||
"4": {
|
||||
name: "运行时错误",
|
||||
title: "运行时错误",
|
||||
type: "warning",
|
||||
},
|
||||
"5": {
|
||||
name: "系统错误",
|
||||
title: "系统错误",
|
||||
type: "error",
|
||||
},
|
||||
"6": {
|
||||
name: "等待评分",
|
||||
title: "等待评分",
|
||||
type: "warning",
|
||||
},
|
||||
"7": {
|
||||
name: "正在评分",
|
||||
title: "正在评分",
|
||||
type: "warning",
|
||||
},
|
||||
"8": {
|
||||
name: "部分正确",
|
||||
title: "部分正确",
|
||||
type: "warning",
|
||||
},
|
||||
"9": {
|
||||
name: "正在提交",
|
||||
title: "正在提交",
|
||||
type: "info",
|
||||
},
|
||||
"10": {
|
||||
name: "语法未通过",
|
||||
title: "答案正确,但语法未通过",
|
||||
type: "success",
|
||||
},
|
||||
}
|
||||
|
||||
export const CONTEST_STATUS: {
|
||||
@@ -114,7 +133,8 @@ export const CONTEST_TYPE = {
|
||||
|
||||
export const USER_TYPE = {
|
||||
REGULAR_USER: "Regular User",
|
||||
ADMIN: "Admin",
|
||||
STUDENT_ADMIN: "Student Admin",
|
||||
TEACHER_ADMIN: "Teacher Admin",
|
||||
SUPER_ADMIN: "Super Admin",
|
||||
}
|
||||
|
||||
|
||||
@@ -102,11 +102,11 @@ export function secondsToDuration(seconds: number): string {
|
||||
start: 0,
|
||||
end: seconds * 1000,
|
||||
})
|
||||
return [
|
||||
duration.hours ?? 0,
|
||||
duration.minutes ?? 0,
|
||||
duration.seconds ?? 0,
|
||||
].join(":")
|
||||
const hours = (duration.days ?? 0) * 24 + (duration.hours ?? 0)
|
||||
const pad = (n: number) => String(n).padStart(2, "0")
|
||||
return [hours, pad(duration.minutes ?? 0), pad(duration.seconds ?? 0)].join(
|
||||
":",
|
||||
)
|
||||
}
|
||||
|
||||
export function submissionMemoryFormat(memory: number | string | undefined) {
|
||||
@@ -133,15 +133,22 @@ export function debounce<T extends (...args: any[]) => any>(
|
||||
}
|
||||
|
||||
export function getUserRole(role: User["admin_type"]): {
|
||||
type: "default" | "info" | "error"
|
||||
label: "普通" | "管理员" | "超管"
|
||||
type: "default" | "info" | "warning" | "error"
|
||||
label: "普通" | "学生管理员" | "教师管理员" | "超管"
|
||||
} {
|
||||
const roleMap = {
|
||||
[USER_TYPE.REGULAR_USER]: {
|
||||
type: "default" as const,
|
||||
label: "普通" as const,
|
||||
},
|
||||
[USER_TYPE.ADMIN]: { type: "info" as const, label: "管理员" as const },
|
||||
[USER_TYPE.STUDENT_ADMIN]: {
|
||||
type: "info" as const,
|
||||
label: "学生管理员" as const,
|
||||
},
|
||||
[USER_TYPE.TEACHER_ADMIN]: {
|
||||
type: "warning" as const,
|
||||
label: "教师管理员" as const,
|
||||
},
|
||||
[USER_TYPE.SUPER_ADMIN]: {
|
||||
type: "error" as const,
|
||||
label: "超管" as const,
|
||||
|
||||
@@ -1,18 +1,68 @@
|
||||
import axios from "axios"
|
||||
import axios, { type AxiosRequestConfig } from "axios"
|
||||
import { createDiscreteApi } from "naive-ui"
|
||||
import { useAuthModalStore } from "shared/store/authModal"
|
||||
import storage from "./storage"
|
||||
import { STORAGE_KEY } from "./constants"
|
||||
|
||||
const http = axios.create({
|
||||
const { message } = createDiscreteApi(["message"])
|
||||
|
||||
// 后端统一返回 { error, data } 信封;拦截器剥掉 axios 外层后,
|
||||
// 调用方拿到的就是这个信封,data 才是真正的业务数据。
|
||||
export interface ApiResponse<T = any> {
|
||||
error: string | null
|
||||
data: T
|
||||
}
|
||||
|
||||
// 让 http.get<T>() 的类型真实反映"解包后返回信封"这件事,
|
||||
// 调用方 res.data 直接拿到带类型的 T,不再依赖 axios 的 AxiosResponse 巧合对齐。
|
||||
interface Http {
|
||||
get<T = any>(
|
||||
url: string,
|
||||
config?: AxiosRequestConfig,
|
||||
): Promise<ApiResponse<T>>
|
||||
delete<T = any>(
|
||||
url: string,
|
||||
config?: AxiosRequestConfig,
|
||||
): Promise<ApiResponse<T>>
|
||||
post<T = any>(
|
||||
url: string,
|
||||
data?: unknown,
|
||||
config?: AxiosRequestConfig,
|
||||
): Promise<ApiResponse<T>>
|
||||
put<T = any>(
|
||||
url: string,
|
||||
data?: unknown,
|
||||
config?: AxiosRequestConfig,
|
||||
): Promise<ApiResponse<T>>
|
||||
}
|
||||
|
||||
const instance = axios.create({
|
||||
baseURL: "/api",
|
||||
xsrfHeaderName: "X-CSRFToken",
|
||||
xsrfCookieName: "csrftoken",
|
||||
})
|
||||
|
||||
http.interceptors.response.use(
|
||||
// 统一剥掉空字符串 / null / undefined 的 query 参数,
|
||||
// 各 api 函数不必再手写过滤逻辑(保留 0、false)。
|
||||
instance.interceptors.request.use((config) => {
|
||||
if (config.params) {
|
||||
config.params = Object.fromEntries(
|
||||
Object.entries(config.params).filter(
|
||||
([, v]) => v !== "" && v !== null && v !== undefined,
|
||||
),
|
||||
)
|
||||
}
|
||||
return config
|
||||
})
|
||||
|
||||
instance.interceptors.response.use(
|
||||
(res) => {
|
||||
if (res.data.error) {
|
||||
if (res.data.data && res.data.data.startsWith("Please login")) {
|
||||
if (res.data.error === "login-required") {
|
||||
storage.remove(STORAGE_KEY.AUTHED)
|
||||
useAuthModalStore().openLoginModal()
|
||||
} else if (res.data.error === "permission-denied") {
|
||||
message.error(res.data.data || "权限不足")
|
||||
}
|
||||
return Promise.reject(res.data)
|
||||
} else {
|
||||
@@ -24,4 +74,6 @@ http.interceptors.response.use(
|
||||
},
|
||||
)
|
||||
|
||||
const http = instance as unknown as Http
|
||||
|
||||
export default http
|
||||
|
||||
@@ -1,19 +1,15 @@
|
||||
import { useUserStore } from "shared/store/user"
|
||||
|
||||
/**
|
||||
* 权限检查工具函数
|
||||
*/
|
||||
export function usePermissions() {
|
||||
const userStore = useUserStore()
|
||||
|
||||
return {
|
||||
// 基本权限检查
|
||||
isAuthenticated: computed(() => userStore.isAuthed),
|
||||
isAdminRole: computed(() => userStore.isAdminRole),
|
||||
isTeacherOrAbove: computed(() => userStore.isTeacherOrAbove),
|
||||
isSuperAdmin: computed(() => userStore.isSuperAdmin),
|
||||
hasProblemPermission: computed(() => userStore.hasProblemPermission),
|
||||
|
||||
// 功能权限检查
|
||||
canManageUsers: computed(() => userStore.isSuperAdmin),
|
||||
canManageAnnouncements: computed(() => userStore.isSuperAdmin),
|
||||
canManageComments: computed(() => userStore.isSuperAdmin),
|
||||
@@ -22,9 +18,10 @@ export function usePermissions() {
|
||||
canSendMessages: computed(() => userStore.isSuperAdmin),
|
||||
|
||||
canManageProblems: computed(() => userStore.hasProblemPermission),
|
||||
canManageContests: computed(() => userStore.isSuperAdmin),
|
||||
canManageContests: computed(() => userStore.isTeacherOrAbove),
|
||||
canManageProblemsets: computed(() => userStore.isTeacherOrAbove),
|
||||
canViewClassroomData: computed(() => userStore.isTeacherOrAbove),
|
||||
|
||||
// 题目权限细分检查
|
||||
canManageAllProblems: computed(
|
||||
() =>
|
||||
userStore.user?.problem_permission === "All" || userStore.isSuperAdmin,
|
||||
@@ -34,17 +31,15 @@ export function usePermissions() {
|
||||
userStore.user?.problem_permission === "Own" && !userStore.isSuperAdmin,
|
||||
),
|
||||
|
||||
// 获取用户权限级别描述
|
||||
getUserPermissionLevel: computed(() => {
|
||||
if (userStore.isSuperAdmin) return "超级管理员"
|
||||
if (userStore.isAdminRole) return "管理员"
|
||||
if (userStore.isTeacherAdmin) return "教师管理员"
|
||||
if (userStore.isStudentAdmin) return "学生管理员"
|
||||
return "普通用户"
|
||||
}),
|
||||
|
||||
// 获取题目权限描述
|
||||
getProblemPermissionLevel: computed(() => {
|
||||
if (!userStore.user) return "无权限"
|
||||
|
||||
switch (userStore.user.problem_permission) {
|
||||
case "All":
|
||||
return "管理所有题目"
|
||||
@@ -59,13 +54,9 @@ export function usePermissions() {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 路由权限检查
|
||||
*/
|
||||
export function checkRoutePermission(routeName: string): boolean {
|
||||
const userStore = useUserStore()
|
||||
|
||||
// 需要super admin权限的路由
|
||||
const superAdminRoutes = [
|
||||
"admin home",
|
||||
"admin config",
|
||||
@@ -79,35 +70,41 @@ export function checkRoutePermission(routeName: string): boolean {
|
||||
"admin tutorial list",
|
||||
"admin tutorial create",
|
||||
"admin tutorial edit",
|
||||
]
|
||||
|
||||
const teacherAdminRoutes = [
|
||||
"admin contest list",
|
||||
"admin contest create",
|
||||
"admin contest edit",
|
||||
"admin contest problem list",
|
||||
"admin contest problem create",
|
||||
"admin contest problem edit",
|
||||
"admin contest helper",
|
||||
"admin problemset list",
|
||||
"admin problemset create",
|
||||
"admin problemset edit",
|
||||
"admin problemset detail",
|
||||
"admin stuck problems",
|
||||
"admin top ac trend",
|
||||
]
|
||||
|
||||
// 需要题目权限的路由
|
||||
const problemPermissionRoutes = [
|
||||
"admin problem list",
|
||||
"admin problem create",
|
||||
"admin problem edit",
|
||||
]
|
||||
|
||||
// 需要基本admin权限的路由
|
||||
const adminRoutes: string[] = ["admin problem list"]
|
||||
|
||||
if (superAdminRoutes.includes(routeName)) {
|
||||
return userStore.isSuperAdmin
|
||||
}
|
||||
|
||||
if (teacherAdminRoutes.includes(routeName)) {
|
||||
return userStore.isTeacherOrAbove
|
||||
}
|
||||
|
||||
if (problemPermissionRoutes.includes(routeName)) {
|
||||
return userStore.hasProblemPermission
|
||||
}
|
||||
|
||||
if (adminRoutes.includes(routeName)) {
|
||||
return userStore.isAdminRole
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -33,7 +33,11 @@ export interface Profile {
|
||||
submission_number: number
|
||||
}
|
||||
|
||||
export type UserAdminType = "Regular User" | "Admin" | "Super Admin"
|
||||
export type UserAdminType =
|
||||
| "Regular User"
|
||||
| "Student Admin"
|
||||
| "Teacher Admin"
|
||||
| "Super Admin"
|
||||
|
||||
export interface User {
|
||||
id: number
|
||||
@@ -65,7 +69,20 @@ export type LANGUAGE =
|
||||
export type LANGUAGE_SHOW_LABEL =
|
||||
(typeof LANGUAGE_SHOW_VALUE)[keyof typeof LANGUAGE_SHOW_VALUE]
|
||||
|
||||
export type SUBMISSION_RESULT = -2 | -1 | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9
|
||||
export type SUBMISSION_RESULT =
|
||||
| -2
|
||||
| -1
|
||||
| 0
|
||||
| 1
|
||||
| 2
|
||||
| 3
|
||||
| 4
|
||||
| 5
|
||||
| 6
|
||||
| 7
|
||||
| 8
|
||||
| 9
|
||||
| 10
|
||||
|
||||
export type ProblemStatus = "passed" | "failed" | "not_test"
|
||||
|
||||
@@ -137,6 +154,16 @@ export interface Problem {
|
||||
flowchart_data?: Record<string, any>
|
||||
flowchart_hint?: string
|
||||
show_flowchart?: boolean
|
||||
ast_rules?: {
|
||||
[key: string]: {
|
||||
engine: string
|
||||
target?: string
|
||||
min?: number
|
||||
max?: number
|
||||
message: string
|
||||
}[]
|
||||
} | null
|
||||
has_ast_rules?: boolean
|
||||
}
|
||||
|
||||
export type AdminProblem = Problem & AlterProblem
|
||||
@@ -174,6 +201,7 @@ export interface ProblemFiltered {
|
||||
author: string
|
||||
allow_flowchart: boolean
|
||||
show_flowchart: boolean
|
||||
has_ast_rules: boolean
|
||||
}
|
||||
|
||||
export interface AdminProblemFiltered {
|
||||
@@ -183,6 +211,11 @@ export interface AdminProblemFiltered {
|
||||
visible: boolean
|
||||
username: string
|
||||
create_time: string
|
||||
difficulty: "Low" | "Mid" | "High"
|
||||
tags: string[]
|
||||
has_ast_rules: boolean
|
||||
allow_flowchart: boolean
|
||||
show_flowchart: boolean
|
||||
}
|
||||
|
||||
// 题单相关类型
|
||||
@@ -387,6 +420,7 @@ export interface Submission {
|
||||
err_info?: string
|
||||
time_cost?: number
|
||||
memory_cost?: number
|
||||
ast_results?: Array<{ description: string; passed: boolean }>
|
||||
}
|
||||
ip: string
|
||||
contest: number
|
||||
@@ -473,9 +507,7 @@ export interface BlankContest {
|
||||
tag: string
|
||||
start_time: string
|
||||
end_time: string
|
||||
rule_type: "ACM" | "OI"
|
||||
password: string
|
||||
real_time_rank: boolean
|
||||
visible: boolean
|
||||
allowed_ip_ranges: { value: string }[]
|
||||
}
|
||||
|
||||
51
tests/submitButtonState.test.ts
Normal file
51
tests/submitButtonState.test.ts
Normal 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",
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user