Compare commits
35 Commits
39dbe143cb
...
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 |
@@ -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.
|
||||||
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,4 +1,5 @@
|
|||||||
import http from "utils/http"
|
import http from "utils/http"
|
||||||
|
import { toProblemListItem } from "admin/transforms"
|
||||||
import type {
|
import type {
|
||||||
AdminProblem,
|
AdminProblem,
|
||||||
Announcement,
|
Announcement,
|
||||||
@@ -30,7 +31,9 @@ export async function getProblemList(
|
|||||||
contestID?: string,
|
contestID?: string,
|
||||||
) {
|
) {
|
||||||
const endpoint = !!contestID ? "admin/contest/problem" : "admin/problem"
|
const endpoint = !!contestID ? "admin/contest/problem" : "admin/problem"
|
||||||
const res = await http.get(endpoint, {
|
const res = await http.get<{ results: AdminProblem[]; total: number }>(
|
||||||
|
endpoint,
|
||||||
|
{
|
||||||
params: {
|
params: {
|
||||||
paging: true,
|
paging: true,
|
||||||
offset,
|
offset,
|
||||||
@@ -39,21 +42,10 @@ export async function getProblemList(
|
|||||||
author,
|
author,
|
||||||
contest_id: contestID,
|
contest_id: contestID,
|
||||||
},
|
},
|
||||||
})
|
},
|
||||||
|
)
|
||||||
return {
|
return {
|
||||||
results: res.data.results.map((result: AdminProblem) => ({
|
results: res.data.results.map(toProblemListItem),
|
||||||
id: result.id,
|
|
||||||
_id: result._id,
|
|
||||||
title: result.title,
|
|
||||||
username: result.created_by.username,
|
|
||||||
create_time: result.create_time,
|
|
||||||
visible: result.visible,
|
|
||||||
difficulty: result.difficulty,
|
|
||||||
tags: result.tags,
|
|
||||||
has_ast_rules: result.has_ast_rules,
|
|
||||||
allow_flowchart: result.allow_flowchart,
|
|
||||||
show_flowchart: result.show_flowchart,
|
|
||||||
})),
|
|
||||||
total: res.data.total,
|
total: res.data.total,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -133,10 +125,10 @@ export function getContestList(offset = 0, limit = 10, keyword: string) {
|
|||||||
export async function uploadImage(file: File): Promise<string> {
|
export async function uploadImage(file: File): Promise<string> {
|
||||||
const form = new window.FormData()
|
const form = new window.FormData()
|
||||||
form.append("image", file)
|
form.append("image", file)
|
||||||
const res: { success: boolean; file_path: string; msg: "Success" } =
|
// 该端点不走 { error, data } 信封,直接返回上传结果
|
||||||
await http.post("admin/upload_image", form, {
|
const res = (await http.post("admin/upload_image", form, {
|
||||||
headers: { "content-type": "multipart/form-data" },
|
headers: { "content-type": "multipart/form-data" },
|
||||||
})
|
})) as unknown as { success: boolean; file_path: string; msg: "Success" }
|
||||||
return res.success ? res.file_path : ""
|
return res.success ? res.file_path : ""
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -244,17 +236,17 @@ export function deleteComment(id: number) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function getTutorialList() {
|
export async function getTutorialList() {
|
||||||
const res = await http.get("admin/tutorial")
|
const res = await http.get<Tutorial[]>("admin/tutorial")
|
||||||
return res.data
|
return res.data
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getTutorial(id: number) {
|
export async function getTutorial(id: number) {
|
||||||
const res = await http.get("admin/tutorial", { params: { id } })
|
const res = await http.get<Tutorial>("admin/tutorial", { params: { id } })
|
||||||
return res.data
|
return res.data
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function createTutorial(data: Partial<Tutorial>) {
|
export async function createTutorial(data: Partial<Tutorial>) {
|
||||||
const res = await http.post("admin/tutorial", data)
|
const res = await http.post<Tutorial>("admin/tutorial", data)
|
||||||
return res.data
|
return res.data
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -272,10 +264,10 @@ export function setTutorialVisibility(id: number, is_public: boolean) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function getAdminExercises(tutorialId: number) {
|
export async function getAdminExercises(tutorialId: number) {
|
||||||
const res = await http.get("admin/exercise", {
|
const res = await http.get<Exercise[]>("admin/exercise", {
|
||||||
params: { tutorial_id: tutorialId },
|
params: { tutorial_id: tutorialId },
|
||||||
})
|
})
|
||||||
return res.data as Exercise[]
|
return res.data
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function createExercise(data: {
|
export async function createExercise(data: {
|
||||||
@@ -284,8 +276,8 @@ export async function createExercise(data: {
|
|||||||
data: object
|
data: object
|
||||||
order: number
|
order: number
|
||||||
}) {
|
}) {
|
||||||
const res = await http.post("admin/exercise", data)
|
const res = await http.post<Exercise>("admin/exercise", data)
|
||||||
return res.data as Exercise
|
return res.data
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function updateExercise(data: {
|
export async function updateExercise(data: {
|
||||||
@@ -490,3 +482,22 @@ export function getTopACTrend(params: {
|
|||||||
}) {
|
}) {
|
||||||
return http.get("admin/problem/top_ac_trend", { params })
|
return http.get("admin/problem/top_ac_trend", { params })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// AI 学习分析报告
|
||||||
|
export function getAIReportList(offset = 0, limit = 10, username = "") {
|
||||||
|
return http.get("admin/ai/reports", {
|
||||||
|
params: { paging: true, offset, limit, username: username || undefined },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getAIReportDetail(id: number) {
|
||||||
|
return http.get("admin/ai/reports", { params: { id } })
|
||||||
|
}
|
||||||
|
|
||||||
|
export function pinAIReport(id: number) {
|
||||||
|
return http.post("admin/ai/reports", { id })
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getPinnedAIReports() {
|
||||||
|
return http.get("admin/ai/reports", { params: { pinned_only: "true" } })
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
import { formatISO } from "date-fns"
|
import { formatISO } from "date-fns"
|
||||||
import TextEditor from "shared/components/TextEditor.vue"
|
import TextEditor from "shared/components/TextEditor.vue"
|
||||||
import { parseTime } from "utils/functions"
|
import { parseTime } from "utils/functions"
|
||||||
import { BlankContest } from "utils/types"
|
import type { BlankContest } from "utils/types"
|
||||||
import { createContest, editContest, getContest } from "../api"
|
import { createContest, editContest, getContest } from "../api"
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
|
|||||||
@@ -37,7 +37,8 @@ const total = ref(0)
|
|||||||
const problems = ref<AdminProblemFiltered[]>([])
|
const problems = ref<AdminProblemFiltered[]>([])
|
||||||
|
|
||||||
const nextDisplayID = computed(() => {
|
const nextDisplayID = computed(() => {
|
||||||
if (!isContestProblemList.value || problems.value.length === 0) return ""
|
if (!isContestProblemList.value) return ""
|
||||||
|
if (problems.value.length === 0) return "1"
|
||||||
const ids = problems.value.map((p) => p._id)
|
const ids = problems.value.map((p) => p._id)
|
||||||
if (ids.every((id) => /^\d+$/.test(id))) {
|
if (ids.every((id) => /^\d+$/.test(id))) {
|
||||||
return String(Math.max(...ids.map((id) => parseInt(id))) + 1)
|
return String(Math.max(...ids.map((id) => parseInt(id))) + 1)
|
||||||
@@ -130,7 +131,7 @@ const columns: DataTableColumn<AdminProblemFiltered>[] = [
|
|||||||
{
|
{
|
||||||
title: "选项",
|
title: "选项",
|
||||||
key: "actions",
|
key: "actions",
|
||||||
width: 300,
|
width: 320,
|
||||||
render: (row) =>
|
render: (row) =>
|
||||||
h(Actions, {
|
h(Actions, {
|
||||||
problemID: row.id,
|
problemID: row.id,
|
||||||
|
|||||||
18
src/admin/transforms.ts
Normal file
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,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -24,7 +24,13 @@ const isNotRegularUser = computed(
|
|||||||
>
|
>
|
||||||
{{ getUserRole(props.user.admin_type).label }}
|
{{ getUserRole(props.user.admin_type).label }}
|
||||||
</n-tag>
|
</n-tag>
|
||||||
<n-tag size="small" v-if="props.user.admin_type === USER_TYPE.STUDENT_ADMIN || props.user.admin_type === USER_TYPE.TEACHER_ADMIN">
|
<n-tag
|
||||||
|
size="small"
|
||||||
|
v-if="
|
||||||
|
props.user.admin_type === USER_TYPE.STUDENT_ADMIN ||
|
||||||
|
props.user.admin_type === USER_TYPE.TEACHER_ADMIN
|
||||||
|
"
|
||||||
|
>
|
||||||
{{
|
{{
|
||||||
props.user.problem_permission === PROBLEM_PERMISSION.ALL
|
props.user.problem_permission === PROBLEM_PERMISSION.ALL
|
||||||
? "全部"
|
? "全部"
|
||||||
|
|||||||
@@ -314,7 +314,11 @@ watch(() => [query.page, query.limit, query.type, query.orderBy], listUsers)
|
|||||||
<n-input v-model:value="password" />
|
<n-input v-model:value="password" />
|
||||||
</n-form-item-gi>
|
</n-form-item-gi>
|
||||||
<n-form-item-gi
|
<n-form-item-gi
|
||||||
v-if="!create && (userEditing.admin_type === USER_TYPE.STUDENT_ADMIN || userEditing.admin_type === USER_TYPE.TEACHER_ADMIN)"
|
v-if="
|
||||||
|
!create &&
|
||||||
|
(userEditing.admin_type === USER_TYPE.STUDENT_ADMIN ||
|
||||||
|
userEditing.admin_type === USER_TYPE.TEACHER_ADMIN)
|
||||||
|
"
|
||||||
:span="1"
|
:span="1"
|
||||||
label="出题权限"
|
label="出题权限"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -65,9 +65,7 @@ router.beforeEach(async (to, from, next) => {
|
|||||||
next("/")
|
next("/")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
} else if (
|
} else if (to.matched.some((record) => record.meta.requiresTeacherAdmin)) {
|
||||||
to.matched.some((record) => record.meta.requiresTeacherAdmin)
|
|
||||||
) {
|
|
||||||
if (!userStore.isTeacherOrAbove) {
|
if (!userStore.isTeacherOrAbove) {
|
||||||
next("/")
|
next("/")
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -36,8 +36,18 @@ async function handleAnalyze() {
|
|||||||
if (aiStore.loading.fetching || aiStore.loading.ai) {
|
if (aiStore.loading.fetching || aiStore.loading.ai) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if (aiStore.pinnedReport) {
|
||||||
|
await aiStore.simulatePinnedStream()
|
||||||
|
} else {
|
||||||
await aiStore.fetchAIAnalysis()
|
await aiStore.fetchAIAnalysis()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
if (!aiStore.targetUsername) {
|
||||||
|
await aiStore.fetchPinnedReport()
|
||||||
|
}
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.cool-title {
|
.cool-title {
|
||||||
|
|||||||
@@ -60,7 +60,7 @@ import { useAIStore } from "oj/store/ai"
|
|||||||
import { parseTime } from "utils/functions"
|
import { parseTime } from "utils/functions"
|
||||||
|
|
||||||
const aiStore = useAIStore()
|
const aiStore = useAIStore()
|
||||||
const containerRef = ref<HTMLElement>()
|
const containerRef = useTemplateRef<HTMLElement>("containerRef")
|
||||||
|
|
||||||
const CELL_SIZE = 12
|
const CELL_SIZE = 12
|
||||||
const CELL_GAP = 3
|
const CELL_GAP = 3
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { DIFFICULTY } from "utils/constants"
|
|
||||||
import { getACRate } from "utils/functions"
|
|
||||||
import http from "utils/http"
|
import http from "utils/http"
|
||||||
import {
|
import { filterResult } from "oj/transforms"
|
||||||
|
import type {
|
||||||
Exercise,
|
Exercise,
|
||||||
Problem,
|
Problem,
|
||||||
Submission,
|
Submission,
|
||||||
@@ -9,31 +8,6 @@ import {
|
|||||||
SubmitCodePayload,
|
SubmitCodePayload,
|
||||||
} from "utils/types"
|
} from "utils/types"
|
||||||
|
|
||||||
function filterResult(result: Problem) {
|
|
||||||
const newResult = {
|
|
||||||
id: result.id,
|
|
||||||
_id: result._id,
|
|
||||||
title: result.title,
|
|
||||||
difficulty: DIFFICULTY[result.difficulty],
|
|
||||||
tags: result.tags,
|
|
||||||
submission: result.submission_number,
|
|
||||||
rate: getACRate(result.accepted_number, result.submission_number),
|
|
||||||
status: "",
|
|
||||||
author: result.created_by.username,
|
|
||||||
allow_flowchart: result.allow_flowchart,
|
|
||||||
show_flowchart: result.show_flowchart,
|
|
||||||
has_ast_rules: result.has_ast_rules,
|
|
||||||
}
|
|
||||||
if (result.my_status === null || result.my_status === undefined) {
|
|
||||||
newResult.status = "not_test"
|
|
||||||
} else if (result.my_status === 0) {
|
|
||||||
newResult.status = "passed"
|
|
||||||
} else {
|
|
||||||
newResult.status = "failed"
|
|
||||||
}
|
|
||||||
return newResult
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getWebsiteConfig() {
|
export function getWebsiteConfig() {
|
||||||
return http.get("website")
|
return http.get("website")
|
||||||
}
|
}
|
||||||
@@ -43,17 +17,9 @@ export async function getProblemList(
|
|||||||
limit = 10,
|
limit = 10,
|
||||||
searchParams: any = {},
|
searchParams: any = {},
|
||||||
) {
|
) {
|
||||||
let params: any = {
|
const res = await http.get<{ results: Problem[]; total: number }>("problem", {
|
||||||
paging: true,
|
params: { paging: true, offset, limit, ...searchParams },
|
||||||
offset,
|
|
||||||
limit,
|
|
||||||
}
|
|
||||||
Object.keys(searchParams).forEach((element) => {
|
|
||||||
if (searchParams[element]) {
|
|
||||||
params[element] = searchParams[element]
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
const res = await http.get("problem", { params })
|
|
||||||
return {
|
return {
|
||||||
results: res.data.results.map(filterResult),
|
results: res.data.results.map(filterResult),
|
||||||
total: res.data.total,
|
total: res.data.total,
|
||||||
@@ -96,6 +62,10 @@ export function submitCode(data: SubmitCodePayload) {
|
|||||||
return http.post("submission", data)
|
return http.post("submission", data)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function formatCode(data: { code: string; language: string }) {
|
||||||
|
return http.post<{ code: string }>("format_code", data)
|
||||||
|
}
|
||||||
|
|
||||||
export function getSubmissions(params: Partial<SubmissionListPayload>) {
|
export function getSubmissions(params: Partial<SubmissionListPayload>) {
|
||||||
const endpoint = !!params.contest_id ? "contest_submissions" : "submissions"
|
const endpoint = !!params.contest_id ? "contest_submissions" : "submissions"
|
||||||
return http.get(endpoint, { params })
|
return http.get(endpoint, { params })
|
||||||
@@ -105,8 +75,8 @@ export function getRankOfProblem(problem_id: string) {
|
|||||||
return http.get("user_problem_rank", { params: { problem_id: problem_id } })
|
return http.get("user_problem_rank", { params: { problem_id: problem_id } })
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getTodaySubmissionCount() {
|
export function getTodaySubmissionCount(language?: string) {
|
||||||
return http.get("submissions/today_count")
|
return http.get("submissions/today_count", { params: { language } })
|
||||||
}
|
}
|
||||||
|
|
||||||
export function adminRejudge(id: string) {
|
export function adminRejudge(id: string) {
|
||||||
@@ -203,7 +173,7 @@ export function checkContestPassword(contestID: string, password: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function getContestProblems(contestID: string) {
|
export async function getContestProblems(contestID: string) {
|
||||||
const res = await http.get("contest/problem", {
|
const res = await http.get<Problem[]>("contest/problem", {
|
||||||
params: { contest_id: contestID },
|
params: { contest_id: contestID },
|
||||||
})
|
})
|
||||||
return res.data.map(filterResult)
|
return res.data.map(filterResult)
|
||||||
@@ -308,6 +278,10 @@ export function getAILoginSummary() {
|
|||||||
return http.get("ai/login_summary")
|
return http.get("ai/login_summary")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getAIPinnedReport() {
|
||||||
|
return http.get("ai/pinned")
|
||||||
|
}
|
||||||
|
|
||||||
// ==================== 相似题目推荐 ====================
|
// ==================== 相似题目推荐 ====================
|
||||||
|
|
||||||
export function getSimilarProblems(problemId: string) {
|
export function getSimilarProblems(problemId: string) {
|
||||||
@@ -349,6 +323,8 @@ export function getFlowchartSubmissions(params: {
|
|||||||
myself?: string
|
myself?: string
|
||||||
offset?: number
|
offset?: number
|
||||||
limit?: number
|
limit?: number
|
||||||
|
today?: string
|
||||||
|
grade?: string
|
||||||
}) {
|
}) {
|
||||||
return http.get("flowchart/submissions", { params })
|
return http.get("flowchart/submissions", { params })
|
||||||
}
|
}
|
||||||
@@ -455,7 +431,7 @@ export function getProblemSetUserProgress(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function getExercises(tutorialId: number): Promise<Exercise[]> {
|
export async function getExercises(tutorialId: number): Promise<Exercise[]> {
|
||||||
const res = await http.get("exercises", {
|
const res = await http.get<Exercise[]>("exercises", {
|
||||||
params: { tutorial_id: tutorialId },
|
params: { tutorial_id: tutorialId },
|
||||||
})
|
})
|
||||||
return res.data
|
return res.data
|
||||||
|
|||||||
@@ -164,7 +164,8 @@ async function analyzeWithAI() {
|
|||||||
aiController = controller
|
aiController = controller
|
||||||
|
|
||||||
const timeRangeLabel =
|
const timeRangeLabel =
|
||||||
timeRangeOptions.find((o) => o.value === duration.value)?.label ?? "全部时间"
|
timeRangeOptions.find((o) => o.value === duration.value)?.label ??
|
||||||
|
"全部时间"
|
||||||
|
|
||||||
showAIModal.value = true
|
showAIModal.value = true
|
||||||
aiContent.value = ""
|
aiContent.value = ""
|
||||||
@@ -195,7 +196,11 @@ async function analyzeWithAI() {
|
|||||||
if (event === "end" && !hasStarted) aiLoading.value = false
|
if (event === "end" && !hasStarted) aiLoading.value = false
|
||||||
},
|
},
|
||||||
onMessage(payload) {
|
onMessage(payload) {
|
||||||
const parsed = payload as { type?: string; content?: string; message?: string }
|
const parsed = payload as {
|
||||||
|
type?: string
|
||||||
|
content?: string
|
||||||
|
message?: string
|
||||||
|
}
|
||||||
if (parsed.type === "delta" && parsed.content) {
|
if (parsed.type === "delta" && parsed.content) {
|
||||||
if (!hasStarted) {
|
if (!hasStarted) {
|
||||||
hasStarted = true
|
hasStarted = true
|
||||||
@@ -1176,7 +1181,6 @@ const radarChartOptions = {
|
|||||||
</n-gi>
|
</n-gi>
|
||||||
</n-grid>
|
</n-grid>
|
||||||
</n-card>
|
</n-card>
|
||||||
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<!-- 对比表格 -->
|
<!-- 对比表格 -->
|
||||||
@@ -1185,10 +1189,7 @@ const radarChartOptions = {
|
|||||||
title="对比表格"
|
title="对比表格"
|
||||||
style="margin-top: 20px"
|
style="margin-top: 20px"
|
||||||
>
|
>
|
||||||
<n-data-table
|
<n-data-table :data="comparisons" :columns="tableColumns" />
|
||||||
:data="comparisons"
|
|
||||||
:columns="tableColumns"
|
|
||||||
/>
|
|
||||||
</n-card>
|
</n-card>
|
||||||
</n-flex>
|
</n-flex>
|
||||||
</n-card>
|
</n-card>
|
||||||
|
|||||||
@@ -34,188 +34,188 @@ interface Props {
|
|||||||
|
|
||||||
const props = defineProps<Props>()
|
const props = defineProps<Props>()
|
||||||
|
|
||||||
|
const PENALTY_SECONDS = 20 * 60
|
||||||
|
|
||||||
const showChart = computed(() => {
|
const showChart = computed(() => {
|
||||||
const hasRanks = props.ranks.length > 0
|
const hasRanks = props.ranks.length > 0
|
||||||
const hasProblems = props.problems.length >= 3
|
const hasProblems = props.problems.length >= 3
|
||||||
return hasProblems && hasRanks
|
return hasProblems && hasRanks
|
||||||
})
|
})
|
||||||
|
|
||||||
// 预定义的颜色方案 - 更现代和可访问的颜色
|
|
||||||
const colorPalette = [
|
const colorPalette = [
|
||||||
"#3B82F6", // 蓝色
|
"#3B82F6",
|
||||||
"#EF4444", // 红色
|
"#EF4444",
|
||||||
"#10B981", // 绿色
|
"#10B981",
|
||||||
"#F59E0B", // 黄色
|
"#F59E0B",
|
||||||
"#8B5CF6", // 紫色
|
"#8B5CF6",
|
||||||
"#EC4899", // 粉色
|
"#EC4899",
|
||||||
"#06B6D4", // 青色
|
"#06B6D4",
|
||||||
"#84CC16", // 青绿色
|
"#84CC16",
|
||||||
"#F97316", // 橙色
|
"#F97316",
|
||||||
"#6366F1", // 靛蓝色
|
"#6366F1",
|
||||||
]
|
]
|
||||||
|
|
||||||
// 数据处理函数
|
function formatTime(seconds: number): string {
|
||||||
const processChartData = () => {
|
const h = Math.floor(seconds / 3600)
|
||||||
if (!props.ranks || props.ranks.length === 0) {
|
const m = Math.floor((seconds % 3600) / 60)
|
||||||
return {
|
if (h > 0) return `${h}h${m}m`
|
||||||
labels: [],
|
return `${m}m`
|
||||||
datasets: [],
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 获取前10名用户的数据
|
|
||||||
const topUsers = props.ranks.slice(0, 10)
|
|
||||||
|
|
||||||
// 获取所有题目ID(从所有用户的submission_info中收集)
|
|
||||||
const allProblemIds = new Set<string>()
|
|
||||||
topUsers.forEach((rank) => {
|
|
||||||
Object.keys(rank.submission_info).forEach((problemId) => {
|
|
||||||
allProblemIds.add(problemId)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
// 按题目ID排序
|
|
||||||
const problemIds = Array.from(allProblemIds).sort()
|
|
||||||
|
|
||||||
// 创建题目标签
|
|
||||||
const labels = problemIds.map((id) => {
|
|
||||||
if (props.problems) {
|
|
||||||
const problem = props.problems.find((p) => p.id.toString() === id)
|
|
||||||
return problem ? problem.title : `题目${id}`
|
|
||||||
}
|
|
||||||
return `题目${id}`
|
|
||||||
})
|
|
||||||
|
|
||||||
// 找到所有用户中最早的提交时间
|
|
||||||
let earliestTime = Infinity
|
|
||||||
topUsers.forEach((rank) => {
|
|
||||||
Object.values(rank.submission_info).forEach((submissionInfo) => {
|
|
||||||
if (submissionInfo.is_ac && submissionInfo.ac_time < earliestTime) {
|
|
||||||
earliestTime = submissionInfo.ac_time
|
|
||||||
}
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
// 如果没有找到任何通过记录,使用0作为基准
|
|
||||||
if (earliestTime === Infinity) {
|
|
||||||
earliestTime = 0
|
|
||||||
}
|
|
||||||
|
|
||||||
// 为每个用户创建数据集
|
|
||||||
const datasets = topUsers.map((rank, userIndex) => {
|
|
||||||
const userData = problemIds.map((problemId) => {
|
|
||||||
const submissionInfo = rank.submission_info[problemId]
|
|
||||||
if (!submissionInfo || !submissionInfo.is_ac) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
return submissionInfo.ac_time - earliestTime
|
|
||||||
})
|
|
||||||
|
|
||||||
const actualRank = userIndex + 1
|
|
||||||
const colorIndex = userIndex % colorPalette.length
|
|
||||||
const color = colorPalette[colorIndex]
|
|
||||||
|
|
||||||
return {
|
|
||||||
label: `第${actualRank}名: ${rank.user.username}`,
|
|
||||||
data: userData,
|
|
||||||
borderColor: color,
|
|
||||||
backgroundColor: color + "20",
|
|
||||||
tension: 0.3,
|
|
||||||
fill: false,
|
|
||||||
pointRadius: 6,
|
|
||||||
pointHoverRadius: 8,
|
|
||||||
pointBackgroundColor: color,
|
|
||||||
pointBorderColor: "#fff",
|
|
||||||
pointBorderWidth: 2,
|
|
||||||
spanGaps: false,
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
return {
|
|
||||||
labels,
|
|
||||||
datasets,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 监听数据变化,重新处理
|
interface AcEvent {
|
||||||
watch(
|
time: number
|
||||||
() => [props.ranks, props.problems],
|
userIndex: number
|
||||||
() => {
|
problemId: string
|
||||||
if (props.ranks && props.ranks.length > 0) {
|
}
|
||||||
// 数据变化时重新处理
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{ deep: true, immediate: true },
|
|
||||||
)
|
|
||||||
|
|
||||||
const chartData = computed(() => {
|
const chartData = computed(() => {
|
||||||
return processChartData()
|
if (!props.ranks || props.ranks.length === 0) {
|
||||||
|
return { labels: [], datasets: [] }
|
||||||
|
}
|
||||||
|
|
||||||
|
const topUsers = props.ranks.slice(0, 10)
|
||||||
|
|
||||||
|
// 收集所有AC事件并按时间排序
|
||||||
|
const events: AcEvent[] = []
|
||||||
|
topUsers.forEach((rank, userIndex) => {
|
||||||
|
Object.entries(rank.submission_info).forEach(([problemId, info]) => {
|
||||||
|
if (info.is_ac) {
|
||||||
|
events.push({ time: info.ac_time, userIndex, problemId })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
events.sort((a, b) => a.time - b.time)
|
||||||
|
|
||||||
|
if (events.length === 0) {
|
||||||
|
return { labels: [], datasets: [] }
|
||||||
|
}
|
||||||
|
|
||||||
|
// 在每个时间点计算所有人的排名
|
||||||
|
// 状态: 每个用户当前已AC题数和罚时
|
||||||
|
const userState = topUsers.map(() => ({
|
||||||
|
solved: 0,
|
||||||
|
penalty: 0,
|
||||||
|
}))
|
||||||
|
|
||||||
|
// 用于记录每个用户每道题的错误次数
|
||||||
|
const userErrors: Map<string, number>[] = topUsers.map(() => new Map())
|
||||||
|
topUsers.forEach((rank, i) => {
|
||||||
|
Object.entries(rank.submission_info).forEach(([problemId, info]) => {
|
||||||
|
if (info.error_number > 0) {
|
||||||
|
userErrors[i].set(problemId, info.error_number)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
function calcRanks(): number[] {
|
||||||
|
const indexed = userState.map((s, i) => ({ ...s, i }))
|
||||||
|
indexed.sort((a, b) => {
|
||||||
|
if (b.solved !== a.solved) return b.solved - a.solved
|
||||||
|
return a.penalty - b.penalty
|
||||||
|
})
|
||||||
|
const ranks = new Array(topUsers.length).fill(0)
|
||||||
|
indexed.forEach((item, pos) => {
|
||||||
|
ranks[item.i] = pos + 1
|
||||||
|
})
|
||||||
|
return ranks
|
||||||
|
}
|
||||||
|
|
||||||
|
// 时间轴上的数据点: [时间标签, 各用户排名]
|
||||||
|
const timePoints: number[] = [0]
|
||||||
|
const rankSnapshots: number[][] = [calcRanks()]
|
||||||
|
|
||||||
|
// 按时间处理事件(合并同一时刻的事件)
|
||||||
|
let i = 0
|
||||||
|
while (i < events.length) {
|
||||||
|
const currentTime = events[i].time
|
||||||
|
// 处理同一时刻的所有事件
|
||||||
|
while (i < events.length && events[i].time === currentTime) {
|
||||||
|
const ev = events[i]
|
||||||
|
userState[ev.userIndex].solved++
|
||||||
|
const errors = userErrors[ev.userIndex].get(ev.problemId) || 0
|
||||||
|
userState[ev.userIndex].penalty =
|
||||||
|
userState[ev.userIndex].penalty + ev.time + errors * PENALTY_SECONDS
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
timePoints.push(currentTime)
|
||||||
|
rankSnapshots.push(calcRanks())
|
||||||
|
}
|
||||||
|
|
||||||
|
const labels = timePoints.map((t) => formatTime(t))
|
||||||
|
|
||||||
|
const datasets = topUsers.map((rank, userIndex) => {
|
||||||
|
const color = colorPalette[userIndex % colorPalette.length]
|
||||||
|
const finalRank = rankSnapshots[rankSnapshots.length - 1][userIndex]
|
||||||
|
return {
|
||||||
|
label: `#${finalRank} ${rank.user.username}`,
|
||||||
|
data: rankSnapshots.map((snapshot) => snapshot[userIndex]),
|
||||||
|
borderColor: color,
|
||||||
|
backgroundColor: color,
|
||||||
|
tension: 0.3,
|
||||||
|
fill: false,
|
||||||
|
pointRadius: 3,
|
||||||
|
pointHoverRadius: 6,
|
||||||
|
pointBackgroundColor: color,
|
||||||
|
pointBorderColor: "#fff",
|
||||||
|
pointBorderWidth: 1,
|
||||||
|
borderWidth: 2.5,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return { labels, datasets }
|
||||||
})
|
})
|
||||||
|
|
||||||
const chartOptions = computed(() => ({
|
const chartOptions = computed(() => ({
|
||||||
responsive: true,
|
responsive: true,
|
||||||
maintainAspectRatio: false,
|
maintainAspectRatio: false,
|
||||||
|
interaction: {
|
||||||
|
mode: "index" as const,
|
||||||
|
intersect: false,
|
||||||
|
},
|
||||||
plugins: {
|
plugins: {
|
||||||
legend: {
|
legend: {
|
||||||
display: true,
|
display: true,
|
||||||
position: "top" as const,
|
position: "top" as const,
|
||||||
maxHeight: 80,
|
maxHeight: 80,
|
||||||
labels: {
|
labels: {
|
||||||
boxWidth: 12,
|
boxWidth: 14,
|
||||||
boxHeight: 12,
|
boxHeight: 3,
|
||||||
padding: 8,
|
padding: 10,
|
||||||
usePointStyle: true,
|
font: { size: 12 },
|
||||||
font: {
|
|
||||||
size: 11,
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
tooltip: {
|
tooltip: {
|
||||||
mode: "index" as const,
|
mode: "index" as const,
|
||||||
intersect: false,
|
intersect: false,
|
||||||
|
itemSort: (a: any, b: any) => a.parsed.y - b.parsed.y,
|
||||||
callbacks: {
|
callbacks: {
|
||||||
title: function (context: any) {
|
title: (context: any) => `比赛进行: ${context[0].label}`,
|
||||||
return `题目: ${context[0].label}`
|
label: (context: any) => {
|
||||||
},
|
const rank = context.parsed.y
|
||||||
label: function (context: any) {
|
const name = context.dataset.label
|
||||||
const value = context.parsed.y
|
return ` 第${rank}名 — ${name}`
|
||||||
const label = context.dataset.label
|
|
||||||
|
|
||||||
if (value === null) {
|
|
||||||
return `${label}: 未通过`
|
|
||||||
}
|
|
||||||
|
|
||||||
const hours = Math.floor(value / 3600)
|
|
||||||
const minutes = Math.floor((value % 3600) / 60)
|
|
||||||
const seconds = Math.floor(value % 60)
|
|
||||||
|
|
||||||
let timeStr = ""
|
|
||||||
if (hours > 0) timeStr += `${hours}小时`
|
|
||||||
if (minutes > 0) timeStr += `${minutes}分钟`
|
|
||||||
if (seconds > 0 || timeStr === "") timeStr += `${seconds}秒`
|
|
||||||
|
|
||||||
return `${label}: +${timeStr}`
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
scales: {
|
scales: {
|
||||||
|
x: {
|
||||||
|
title: {
|
||||||
|
display: true,
|
||||||
|
text: "比赛时间",
|
||||||
|
},
|
||||||
|
},
|
||||||
y: {
|
y: {
|
||||||
title: {
|
title: {
|
||||||
display: true,
|
display: true,
|
||||||
text: "相对通过时间",
|
text: "排名",
|
||||||
},
|
},
|
||||||
min: 0,
|
reverse: true,
|
||||||
|
min: 1,
|
||||||
|
max: 10,
|
||||||
ticks: {
|
ticks: {
|
||||||
callback: function (value: any) {
|
stepSize: 1,
|
||||||
const hours = Math.floor(value / 3600)
|
callback: (value: any) => `第${value}名`,
|
||||||
const minutes = Math.floor((value % 3600) / 60)
|
|
||||||
const seconds = Math.floor(value % 60)
|
|
||||||
|
|
||||||
if (hours > 0) return `+${hours}h${minutes}m`
|
|
||||||
if (minutes > 0) return `+${minutes}m${seconds}s`
|
|
||||||
return `+${seconds}s`
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -224,7 +224,7 @@ const chartOptions = computed(() => ({
|
|||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.chart {
|
.chart {
|
||||||
height: 500px;
|
height: 420px;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
margin-bottom: 24px;
|
margin-bottom: 24px;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -85,20 +85,19 @@ function inputWidth(idx: number): string {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<n-card
|
<n-card style="margin: 16px 0; border: 1.5px solid var(--n-border-color)">
|
||||||
style="margin: 16px 0; border: 1.5px solid var(--n-border-color)"
|
|
||||||
>
|
|
||||||
<template #header>
|
<template #header>
|
||||||
<n-tag type="warning" :bordered="false"
|
<n-tag type="warning" :bordered="false">练一练 · 代码填空</n-tag>
|
||||||
>练一练 · 代码填空</n-tag
|
|
||||||
>
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<p style="font-weight: 500; font-size: 16px; margin-bottom: 12px">{{ data.question }}</p>
|
<p style="font-weight: 500; font-size: 16px; margin-bottom: 12px">
|
||||||
|
{{ data.question }}
|
||||||
|
</p>
|
||||||
|
|
||||||
<pre
|
<pre
|
||||||
:style="{
|
:style="{
|
||||||
fontFamily: 'Monaco',
|
fontFamily: 'Monaco',
|
||||||
|
fontSize: '16px',
|
||||||
lineHeight: '1.6',
|
lineHeight: '1.6',
|
||||||
background: 'var(--n-color)',
|
background: 'var(--n-color)',
|
||||||
border: '1px solid var(--n-border-color)',
|
border: '1px solid var(--n-border-color)',
|
||||||
@@ -116,7 +115,8 @@ function inputWidth(idx: number): string {
|
|||||||
:style="{
|
:style="{
|
||||||
width: inputWidth(seg.index),
|
width: inputWidth(seg.index),
|
||||||
fontFamily: 'Monaco',
|
fontFamily: 'Monaco',
|
||||||
padding: '1px 4px',
|
fontSize: '16px',
|
||||||
|
padding: '2px 6px',
|
||||||
borderRadius: '3px',
|
borderRadius: '3px',
|
||||||
border: `1.5px solid ${
|
border: `1.5px solid ${
|
||||||
allCorrect
|
allCorrect
|
||||||
@@ -145,11 +145,7 @@ function inputWidth(idx: number): string {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<n-space style="margin-top: 12px" :size="8">
|
<n-space style="margin-top: 12px" :size="8">
|
||||||
<n-button
|
<n-button type="warning" :disabled="allCorrect" @click="submit">
|
||||||
type="warning"
|
|
||||||
:disabled="allCorrect"
|
|
||||||
@click="submit"
|
|
||||||
>
|
|
||||||
提交
|
提交
|
||||||
</n-button>
|
</n-button>
|
||||||
<n-button @click="reset">重置</n-button>
|
<n-button @click="reset">重置</n-button>
|
||||||
|
|||||||
@@ -63,9 +63,7 @@ function optionType(idx: number): "default" | "primary" | "success" {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<n-card
|
<n-card style="margin: 16px 0; border: 1.5px solid var(--n-border-color)">
|
||||||
style="margin: 16px 0; border: 1.5px solid var(--n-border-color)"
|
|
||||||
>
|
|
||||||
<template #header>
|
<template #header>
|
||||||
<n-space align="center" :size="8">
|
<n-space align="center" :size="8">
|
||||||
<n-tag type="success" :bordered="false">
|
<n-tag type="success" :bordered="false">
|
||||||
@@ -74,7 +72,9 @@ function optionType(idx: number): "default" | "primary" | "success" {
|
|||||||
</n-space>
|
</n-space>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<p style="font-weight: 500; font-size: 16px; margin-bottom: 12px">{{ data.question }}</p>
|
<p style="font-weight: 500; font-size: 16px; margin-bottom: 12px">
|
||||||
|
{{ data.question }}
|
||||||
|
</p>
|
||||||
|
|
||||||
<n-space vertical :size="8">
|
<n-space vertical :size="8">
|
||||||
<n-button
|
<n-button
|
||||||
|
|||||||
@@ -101,16 +101,14 @@ const lineHtmlMap = computed<Record<number, string>>(() => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<n-card
|
<n-card style="margin: 16px 0; border: 1.5px solid var(--n-border-color)">
|
||||||
style="margin: 16px 0; border: 1.5px solid var(--n-border-color)"
|
|
||||||
>
|
|
||||||
<template #header>
|
<template #header>
|
||||||
<n-tag type="info" :bordered="false"
|
<n-tag type="info" :bordered="false">练一练 · 代码排序</n-tag>
|
||||||
>练一练 · 代码排序</n-tag
|
|
||||||
>
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<p style="font-weight: 500; font-size: 16px; margin-bottom: 12px">{{ data.question }}</p>
|
<p style="font-weight: 500; font-size: 16px; margin-bottom: 12px">
|
||||||
|
{{ data.question }}
|
||||||
|
</p>
|
||||||
|
|
||||||
<n-space vertical :size="6">
|
<n-space vertical :size="6">
|
||||||
<div
|
<div
|
||||||
@@ -157,11 +155,7 @@ const lineHtmlMap = computed<Record<number, string>>(() => {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<n-space style="margin-top: 12px" :size="8">
|
<n-space style="margin-top: 12px" :size="8">
|
||||||
<n-button
|
<n-button type="info" :disabled="submitted && allCorrect" @click="submit">
|
||||||
type="info"
|
|
||||||
:disabled="submitted && allCorrect"
|
|
||||||
@click="submit"
|
|
||||||
>
|
|
||||||
提交
|
提交
|
||||||
</n-button>
|
</n-button>
|
||||||
<n-button @click="reset">重置</n-button>
|
<n-button @click="reset">重置</n-button>
|
||||||
|
|||||||
@@ -1,7 +1,12 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="learn-container">
|
<div class="learn-container">
|
||||||
<!-- 桌面端布局 -->
|
<!-- 桌面端布局 -->
|
||||||
<n-grid :cols="5" :x-gap="16" v-if="tutorial.id && isDesktop" class="learn-grid">
|
<n-grid
|
||||||
|
:cols="5"
|
||||||
|
:x-gap="16"
|
||||||
|
v-if="tutorial.id && isDesktop"
|
||||||
|
class="learn-grid"
|
||||||
|
>
|
||||||
<n-gi :span="1" class="learn-col">
|
<n-gi :span="1" class="learn-col">
|
||||||
<n-card title="教程目录" :bordered="false" size="small">
|
<n-card title="教程目录" :bordered="false" size="small">
|
||||||
<n-list hoverable clickable>
|
<n-list hoverable clickable>
|
||||||
@@ -51,7 +56,11 @@
|
|||||||
class="code-card"
|
class="code-card"
|
||||||
content-style="height: calc(100% - 44px); padding: 0;"
|
content-style="height: calc(100% - 44px); padding: 0;"
|
||||||
>
|
>
|
||||||
<CodeEditor language="Python3" v-model="tutorial.code" height="100%" />
|
<CodeEditor
|
||||||
|
language="Python3"
|
||||||
|
v-model="tutorial.code"
|
||||||
|
height="100%"
|
||||||
|
/>
|
||||||
</n-card>
|
</n-card>
|
||||||
</n-gi>
|
</n-gi>
|
||||||
</n-grid>
|
</n-grid>
|
||||||
|
|||||||
@@ -31,9 +31,7 @@ interface Props {
|
|||||||
isConnected?: boolean // WebSocket 实际的连接状态(已建立/未建立)
|
isConnected?: boolean // WebSocket 实际的连接状态(已建立/未建立)
|
||||||
}
|
}
|
||||||
|
|
||||||
const props = withDefaults(defineProps<Props>(), {
|
const { storageKey, isConnected = false } = defineProps<Props>()
|
||||||
isConnected: false,
|
|
||||||
})
|
|
||||||
|
|
||||||
// 注入同步状态
|
// 注入同步状态
|
||||||
const syncStatus = injectSyncStatus()
|
const syncStatus = injectSyncStatus()
|
||||||
@@ -102,7 +100,7 @@ const reset = () => {
|
|||||||
problem.value!.template[codeStore.code.language] ||
|
problem.value!.template[codeStore.code.language] ||
|
||||||
SOURCES[codeStore.code.language],
|
SOURCES[codeStore.code.language],
|
||||||
)
|
)
|
||||||
storage.remove(props.storageKey)
|
storage.remove(storageKey)
|
||||||
message.success("代码重置成功")
|
message.success("代码重置成功")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -228,7 +226,7 @@ onMounted(() => {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<!-- 同步状态标签 -->
|
<!-- 同步状态标签 -->
|
||||||
<template v-if="props.isConnected">
|
<template v-if="isConnected">
|
||||||
<n-tag v-if="syncStatus.otherUser.value" type="info">
|
<n-tag v-if="syncStatus.otherUser.value" type="info">
|
||||||
{{ SYNC_MESSAGES.SYNCING_WITH(syncStatus.otherUser.value.name) }}
|
{{ SYNC_MESSAGES.SYNCING_WITH(syncStatus.otherUser.value.name) }}
|
||||||
</n-tag>
|
</n-tag>
|
||||||
|
|||||||
@@ -67,7 +67,7 @@
|
|||||||
{{ content }}
|
{{ content }}
|
||||||
</n-form-item>
|
</n-form-item>
|
||||||
<n-button
|
<n-button
|
||||||
v-if="hasCommented && props.showStatistics"
|
v-if="hasCommented && showStatistics"
|
||||||
type="primary"
|
type="primary"
|
||||||
@click="getComments"
|
@click="getComments"
|
||||||
>
|
>
|
||||||
@@ -77,7 +77,7 @@
|
|||||||
提交
|
提交
|
||||||
</n-button>
|
</n-button>
|
||||||
</n-form>
|
</n-form>
|
||||||
<div v-if="props.showStatistics">
|
<div v-if="showStatistics">
|
||||||
<n-descriptions
|
<n-descriptions
|
||||||
class="list"
|
class="list"
|
||||||
v-if="count"
|
v-if="count"
|
||||||
@@ -117,9 +117,7 @@ interface Props {
|
|||||||
showStatistics?: boolean
|
showStatistics?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
const props = withDefaults(defineProps<Props>(), {
|
const { showStatistics = true } = defineProps<Props>()
|
||||||
showStatistics: true,
|
|
||||||
})
|
|
||||||
|
|
||||||
const userStore = useUserStore()
|
const userStore = useUserStore()
|
||||||
const problemStore = useProblemStore()
|
const problemStore = useProblemStore()
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { SOURCES } from "utils/constants"
|
|||||||
import SyncCodeEditor from "shared/components/SyncCodeEditor.vue"
|
import SyncCodeEditor from "shared/components/SyncCodeEditor.vue"
|
||||||
import { useBreakpoints } from "shared/composables/breakpoints"
|
import { useBreakpoints } from "shared/composables/breakpoints"
|
||||||
import storage from "utils/storage"
|
import storage from "utils/storage"
|
||||||
import { LANGUAGE } from "utils/types"
|
import type { LANGUAGE } from "utils/types"
|
||||||
import Form from "./Form.vue"
|
import Form from "./Form.vue"
|
||||||
|
|
||||||
const FlowchartEditor = defineAsyncComponent(
|
const FlowchartEditor = defineAsyncComponent(
|
||||||
@@ -51,6 +51,13 @@ onMounted(loadCode)
|
|||||||
|
|
||||||
watch(() => problem.value?._id, loadCode)
|
watch(() => problem.value?._id, loadCode)
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => codeStore.code.value,
|
||||||
|
(v) => {
|
||||||
|
storage.set(storageKey.value, v)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
const changeCode = (v: string) => {
|
const changeCode = (v: string) => {
|
||||||
storage.set(storageKey.value, v)
|
storage.set(storageKey.value, v)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,14 +1,20 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { Icon } from "@iconify/vue"
|
import { Icon } from "@iconify/vue"
|
||||||
import { storeToRefs } from "pinia"
|
import { storeToRefs } from "pinia"
|
||||||
import { getComment, submitCode, updateProblemSetProgress } from "oj/api"
|
import {
|
||||||
|
formatCode,
|
||||||
|
getComment,
|
||||||
|
submitCode,
|
||||||
|
updateProblemSetProgress,
|
||||||
|
} from "oj/api"
|
||||||
import { useCodeStore } from "oj/store/code"
|
import { useCodeStore } from "oj/store/code"
|
||||||
import { useProblemStore } from "oj/store/problem"
|
import { useProblemStore } from "oj/store/problem"
|
||||||
import { useFireworks } from "oj/problem/composables/useFireworks"
|
import { useFireworks } from "oj/problem/composables/useFireworks"
|
||||||
import { useSubmissionMonitor } from "oj/problem/composables/useSubmissionMonitor"
|
import { useSubmissionMonitor } from "oj/problem/composables/useSubmissionMonitor"
|
||||||
import { SubmissionStatus } from "utils/constants"
|
import { LANGUAGE_FORMAT_VALUE, SubmissionStatus } from "utils/constants"
|
||||||
import type { SubmitCodePayload } from "utils/types"
|
import type { SubmitCodePayload } from "utils/types"
|
||||||
import SubmissionResult from "./SubmissionResult.vue"
|
import SubmissionResult from "./SubmissionResult.vue"
|
||||||
|
import { getSubmitButtonState } from "./submitButtonState"
|
||||||
import { useBreakpoints } from "shared/composables/breakpoints"
|
import { useBreakpoints } from "shared/composables/breakpoints"
|
||||||
import { useUserStore } from "shared/store/user"
|
import { useUserStore } from "shared/store/user"
|
||||||
import { checkPythonSyntax } from "oj/problem/utils/pythonSyntaxCheck"
|
import { checkPythonSyntax } from "oj/problem/utils/pythonSyntaxCheck"
|
||||||
@@ -37,16 +43,12 @@ const { isDesktop } = useBreakpoints()
|
|||||||
const { celebrate } = useFireworks()
|
const { celebrate } = useFireworks()
|
||||||
|
|
||||||
// ==================== 判题监控 ====================
|
// ==================== 判题监控 ====================
|
||||||
const {
|
const { submission, judging, pending, submitting, startMonitoring } =
|
||||||
submission,
|
useSubmissionMonitor()
|
||||||
judging,
|
|
||||||
pending,
|
|
||||||
submitting,
|
|
||||||
isProcessing,
|
|
||||||
startMonitoring,
|
|
||||||
} = useSubmissionMonitor()
|
|
||||||
|
|
||||||
const showResult = ref(false)
|
const showResult = ref(false)
|
||||||
|
const isFormatting = ref(false)
|
||||||
|
const isSubmittingRequest = ref(false)
|
||||||
|
|
||||||
// ==================== 提交冷却 ====================
|
// ==================== 提交冷却 ====================
|
||||||
const { start: startCooldown, isPending: isCooldown } = useTimeout(5000, {
|
const { start: startCooldown, isPending: isCooldown } = useTimeout(5000, {
|
||||||
@@ -80,35 +82,20 @@ const { start: goToProblemSetDelayed } = useTimeoutFn(
|
|||||||
)
|
)
|
||||||
|
|
||||||
// ==================== 计算属性 ====================
|
// ==================== 计算属性 ====================
|
||||||
// 按钮禁用逻辑
|
const buttonState = computed(() =>
|
||||||
const submitDisabled = computed(() => {
|
getSubmitButtonState({
|
||||||
return (
|
isAuthed: userStore.isAuthed,
|
||||||
!userStore.isAuthed ||
|
hasCode: codeStore.code.value.trim() !== "",
|
||||||
codeStore.code.value.trim() === "" ||
|
isFormatting: isFormatting.value,
|
||||||
isProcessing.value ||
|
isSubmitting: isSubmittingRequest.value || submitting.value,
|
||||||
isCooldown.value
|
isJudging: judging.value || pending.value,
|
||||||
)
|
isCooldown: isCooldown.value,
|
||||||
})
|
}),
|
||||||
|
)
|
||||||
// 按钮文案
|
|
||||||
const submitLabel = computed(() => {
|
|
||||||
if (!userStore.isAuthed) return "请先登录"
|
|
||||||
if (submitting.value) return "正在提交"
|
|
||||||
if (judging.value || pending.value) return "正在评分"
|
|
||||||
if (isCooldown.value) return "正在冷却"
|
|
||||||
return "提交代码"
|
|
||||||
})
|
|
||||||
|
|
||||||
// 按钮图标
|
|
||||||
const submitIcon = computed(() => {
|
|
||||||
if (isProcessing.value) return "eos-icons:loading"
|
|
||||||
if (isCooldown.value) return "ph:lightbulb-fill"
|
|
||||||
return "ph:play-fill"
|
|
||||||
})
|
|
||||||
|
|
||||||
// ==================== 提交函数 ====================
|
// ==================== 提交函数 ====================
|
||||||
async function submit() {
|
async function submit() {
|
||||||
if (!userStore.isAuthed) return
|
if (buttonState.value.disabled) return
|
||||||
|
|
||||||
// 0. Python3 语法检测
|
// 0. Python3 语法检测
|
||||||
if (codeStore.code.language === "Python3") {
|
if (codeStore.code.language === "Python3") {
|
||||||
@@ -119,6 +106,28 @@ async function submit() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 0.5 提交前自动格式化(Python3 用 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. 构建提交数据
|
// 1. 构建提交数据
|
||||||
const data: SubmitCodePayload = {
|
const data: SubmitCodePayload = {
|
||||||
problem_id: problem.value!.id,
|
problem_id: problem.value!.id,
|
||||||
@@ -129,6 +138,8 @@ async function submit() {
|
|||||||
data.contest_id = parseInt(contestID)
|
data.contest_id = parseInt(contestID)
|
||||||
}
|
}
|
||||||
// 2. 提交代码到后端
|
// 2. 提交代码到后端
|
||||||
|
isSubmittingRequest.value = true
|
||||||
|
try {
|
||||||
const res = await submitCode(data)
|
const res = await submitCode(data)
|
||||||
console.log(`[Submit] 代码已提交: ID=${res.data.submission_id}`)
|
console.log(`[Submit] 代码已提交: ID=${res.data.submission_id}`)
|
||||||
|
|
||||||
@@ -136,6 +147,9 @@ async function submit() {
|
|||||||
startCooldown()
|
startCooldown()
|
||||||
startMonitoring(res.data.submission_id)
|
startMonitoring(res.data.submission_id)
|
||||||
showResult.value = true
|
showResult.value = true
|
||||||
|
} finally {
|
||||||
|
isSubmittingRequest.value = false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ==================== 失败计数 ====================
|
// ==================== 失败计数 ====================
|
||||||
@@ -213,15 +227,15 @@ watch(
|
|||||||
<n-button
|
<n-button
|
||||||
:size="isDesktop ? 'medium' : 'small'"
|
:size="isDesktop ? 'medium' : 'small'"
|
||||||
type="primary"
|
type="primary"
|
||||||
:disabled="submitDisabled"
|
:disabled="buttonState.disabled"
|
||||||
@click="submit"
|
@click="submit"
|
||||||
>
|
>
|
||||||
<template #icon>
|
<template #icon>
|
||||||
<n-icon>
|
<n-icon>
|
||||||
<Icon :icon="submitIcon" />
|
<Icon :icon="buttonState.icon" />
|
||||||
</n-icon>
|
</n-icon>
|
||||||
</template>
|
</template>
|
||||||
{{ submitLabel }}
|
{{ buttonState.label }}
|
||||||
</n-button>
|
</n-button>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|||||||
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 }
|
||||||
|
}
|
||||||
@@ -40,10 +40,7 @@ interface Props {
|
|||||||
problemSetId?: string
|
problemSetId?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
const props = withDefaults(defineProps<Props>(), {
|
const { problemID, contestID = "", problemSetId = "" } = defineProps<Props>()
|
||||||
contestID: "",
|
|
||||||
problemSetId: "",
|
|
||||||
})
|
|
||||||
|
|
||||||
const errMsg = ref("无数据")
|
const errMsg = ref("无数据")
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
@@ -67,7 +64,7 @@ const tabOptions = computed(() => {
|
|||||||
options.push("editor")
|
options.push("editor")
|
||||||
}
|
}
|
||||||
options.push("info")
|
options.push("info")
|
||||||
if (!props.contestID) {
|
if (!contestID) {
|
||||||
options.push("comment")
|
options.push("comment")
|
||||||
}
|
}
|
||||||
if (myFlowchartStore.showing) {
|
if (myFlowchartStore.showing) {
|
||||||
@@ -110,7 +107,7 @@ watch(
|
|||||||
async function init() {
|
async function init() {
|
||||||
screenModeStore.resetScreenMode()
|
screenModeStore.resetScreenMode()
|
||||||
try {
|
try {
|
||||||
const res = await getProblem(props.problemID, props.contestID)
|
const res = await getProblem(problemID, contestID)
|
||||||
problem.value = res.data
|
problem.value = res.data
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
problem.value = null
|
problem.value = null
|
||||||
@@ -120,7 +117,7 @@ async function init() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
onMounted(init)
|
onMounted(init)
|
||||||
watch(() => props.problemID, init)
|
watch(() => problemID, init)
|
||||||
onBeforeUnmount(() => {
|
onBeforeUnmount(() => {
|
||||||
problem.value = null
|
problem.value = null
|
||||||
errMsg.value = "无数据"
|
errMsg.value = "无数据"
|
||||||
@@ -159,15 +156,15 @@ watch(isMobile, (value) => {
|
|||||||
<n-tab-pane
|
<n-tab-pane
|
||||||
name="info"
|
name="info"
|
||||||
tab="题目统计"
|
tab="题目统计"
|
||||||
:disabled="!!props.problemSetId"
|
:disabled="!!problemSetId"
|
||||||
>
|
>
|
||||||
<ProblemInfo />
|
<ProblemInfo />
|
||||||
</n-tab-pane>
|
</n-tab-pane>
|
||||||
<n-tab-pane
|
<n-tab-pane
|
||||||
v-if="!props.contestID"
|
v-if="!contestID"
|
||||||
name="comment"
|
name="comment"
|
||||||
tab="题目点评"
|
tab="题目点评"
|
||||||
:disabled="!!props.problemSetId"
|
:disabled="!!problemSetId"
|
||||||
>
|
>
|
||||||
<ProblemComment />
|
<ProblemComment />
|
||||||
</n-tab-pane>
|
</n-tab-pane>
|
||||||
@@ -181,7 +178,7 @@ watch(isMobile, (value) => {
|
|||||||
<n-tab-pane
|
<n-tab-pane
|
||||||
name="submission"
|
name="submission"
|
||||||
tab="我的提交"
|
tab="我的提交"
|
||||||
:disabled="!!props.problemSetId"
|
:disabled="!!problemSetId"
|
||||||
>
|
>
|
||||||
<ProblemSubmission />
|
<ProblemSubmission />
|
||||||
</n-tab-pane>
|
</n-tab-pane>
|
||||||
@@ -215,15 +212,15 @@ watch(isMobile, (value) => {
|
|||||||
<n-tab-pane
|
<n-tab-pane
|
||||||
name="info"
|
name="info"
|
||||||
tab="题目统计"
|
tab="题目统计"
|
||||||
:disabled="!!props.problemSetId"
|
:disabled="!!problemSetId"
|
||||||
>
|
>
|
||||||
<ProblemInfo />
|
<ProblemInfo />
|
||||||
</n-tab-pane>
|
</n-tab-pane>
|
||||||
<n-tab-pane
|
<n-tab-pane
|
||||||
v-if="!props.contestID"
|
v-if="!contestID"
|
||||||
name="comment"
|
name="comment"
|
||||||
tab="题目点评"
|
tab="题目点评"
|
||||||
:disabled="!!props.problemSetId"
|
:disabled="!!problemSetId"
|
||||||
>
|
>
|
||||||
<ProblemComment />
|
<ProblemComment />
|
||||||
</n-tab-pane>
|
</n-tab-pane>
|
||||||
@@ -237,7 +234,7 @@ watch(isMobile, (value) => {
|
|||||||
<n-tab-pane
|
<n-tab-pane
|
||||||
name="submission"
|
name="submission"
|
||||||
tab="我的提交"
|
tab="我的提交"
|
||||||
:disabled="!!props.problemSetId"
|
:disabled="!!problemSetId"
|
||||||
>
|
>
|
||||||
<ProblemSubmission />
|
<ProblemSubmission />
|
||||||
</n-tab-pane>
|
</n-tab-pane>
|
||||||
@@ -256,14 +253,14 @@ watch(isMobile, (value) => {
|
|||||||
<n-tab-pane name="editor" tab="代码">
|
<n-tab-pane name="editor" tab="代码">
|
||||||
<component :is="inProblem ? ProblemEditor : ContestEditor" />
|
<component :is="inProblem ? ProblemEditor : ContestEditor" />
|
||||||
</n-tab-pane>
|
</n-tab-pane>
|
||||||
<n-tab-pane name="info" tab="统计" :disabled="!!props.problemSetId">
|
<n-tab-pane name="info" tab="统计" :disabled="!!problemSetId">
|
||||||
<ProblemInfo />
|
<ProblemInfo />
|
||||||
</n-tab-pane>
|
</n-tab-pane>
|
||||||
<n-tab-pane
|
<n-tab-pane
|
||||||
v-if="!props.contestID"
|
v-if="!contestID"
|
||||||
name="comment"
|
name="comment"
|
||||||
tab="点评"
|
tab="点评"
|
||||||
:disabled="!!props.problemSetId"
|
:disabled="!!problemSetId"
|
||||||
>
|
>
|
||||||
<ProblemComment />
|
<ProblemComment />
|
||||||
</n-tab-pane>
|
</n-tab-pane>
|
||||||
@@ -274,7 +271,7 @@ watch(isMobile, (value) => {
|
|||||||
>
|
>
|
||||||
<MyFlowchartTab />
|
<MyFlowchartTab />
|
||||||
</n-tab-pane>
|
</n-tab-pane>
|
||||||
<n-tab-pane name="submission" tab="提交" :disabled="!!props.problemSetId">
|
<n-tab-pane name="submission" tab="提交" :disabled="!!problemSetId">
|
||||||
<ProblemSubmission />
|
<ProblemSubmission />
|
||||||
</n-tab-pane>
|
</n-tab-pane>
|
||||||
</n-tabs>
|
</n-tabs>
|
||||||
|
|||||||
@@ -1,15 +1,16 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { formatISO, sub, type Duration } from "date-fns"
|
import { formatISO, sub, type Duration } from "date-fns"
|
||||||
import { NButton, NFlex, useThemeVars } from "naive-ui"
|
import { NButton, NFlex } from "naive-ui"
|
||||||
import {
|
import {
|
||||||
getActivityRank,
|
getActivityRank,
|
||||||
getClassRank,
|
getClassRank,
|
||||||
getRank,
|
getRank,
|
||||||
getUserClassRank,
|
getUserClassRank,
|
||||||
|
getClassPK,
|
||||||
} from "oj/api"
|
} from "oj/api"
|
||||||
import { useBreakpoints } from "shared/composables/breakpoints"
|
import { useBreakpoints } from "shared/composables/breakpoints"
|
||||||
import { getACRate } from "utils/functions"
|
import { getACRate, getCSRFToken } from "utils/functions"
|
||||||
import { Rank } from "utils/types"
|
import type { Rank } from "utils/types"
|
||||||
import Pagination from "shared/components/Pagination.vue"
|
import Pagination from "shared/components/Pagination.vue"
|
||||||
import { ChartType } from "utils/constants"
|
import { ChartType } from "utils/constants"
|
||||||
import { renderTableTitle } from "utils/renders"
|
import { renderTableTitle } from "utils/renders"
|
||||||
@@ -17,6 +18,9 @@ import Chart from "./components/Chart.vue"
|
|||||||
import Index from "./components/Index.vue"
|
import Index from "./components/Index.vue"
|
||||||
import { useUserStore } from "shared/store/user"
|
import { useUserStore } from "shared/store/user"
|
||||||
import { Icon } from "@iconify/vue"
|
import { Icon } from "@iconify/vue"
|
||||||
|
import { MdPreview } from "md-editor-v3"
|
||||||
|
import "md-editor-v3/lib/preview.css"
|
||||||
|
import { consumeJSONEventStream } from "utils/stream"
|
||||||
|
|
||||||
const gradeOptions = [
|
const gradeOptions = [
|
||||||
{ label: "24年级", value: 24 },
|
{ label: "24年级", value: 24 },
|
||||||
@@ -52,6 +56,83 @@ const myClassQuery = reactive({
|
|||||||
limit: 10,
|
limit: 10,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const showClassDetailModal = ref(false)
|
||||||
|
const classDetailData = ref<ClassComparison | null>(null)
|
||||||
|
const classDetailLoading = ref(false)
|
||||||
|
|
||||||
|
const classDetailAiLoading = ref(false)
|
||||||
|
const classDetailAiContent = ref("")
|
||||||
|
const showClassDetailAiModal = ref(false)
|
||||||
|
let classDetailAiController: AbortController | null = null
|
||||||
|
|
||||||
|
async function loadClassDetail(className: string) {
|
||||||
|
showClassDetailModal.value = true
|
||||||
|
classDetailLoading.value = true
|
||||||
|
classDetailData.value = null
|
||||||
|
try {
|
||||||
|
const res = await getClassPK([className])
|
||||||
|
classDetailData.value = res.data.comparisons[0] ?? null
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
} finally {
|
||||||
|
classDetailLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function analyzeSingleClassWithAI() {
|
||||||
|
if (!classDetailData.value) return
|
||||||
|
if (classDetailAiController) classDetailAiController.abort()
|
||||||
|
const controller = new AbortController()
|
||||||
|
classDetailAiController = controller
|
||||||
|
|
||||||
|
showClassDetailModal.value = false
|
||||||
|
showClassDetailAiModal.value = true
|
||||||
|
classDetailAiContent.value = ""
|
||||||
|
classDetailAiLoading.value = true
|
||||||
|
|
||||||
|
const headers: Record<string, string> = { "Content-Type": "application/json" }
|
||||||
|
const csrfToken = getCSRFToken()
|
||||||
|
if (csrfToken) headers["X-CSRFToken"] = csrfToken
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch("/api/ai/class_single", {
|
||||||
|
method: "POST",
|
||||||
|
headers,
|
||||||
|
body: JSON.stringify({ comparison: classDetailData.value }),
|
||||||
|
signal: controller.signal,
|
||||||
|
})
|
||||||
|
if (!response.ok) throw new Error("AI 分析生成失败")
|
||||||
|
|
||||||
|
let hasStarted = false
|
||||||
|
await consumeJSONEventStream(response, {
|
||||||
|
signal: controller.signal,
|
||||||
|
onEvent(event) {
|
||||||
|
if (event === "end" && !hasStarted) classDetailAiLoading.value = false
|
||||||
|
},
|
||||||
|
onMessage(payload) {
|
||||||
|
const parsed = payload as { type?: string; content?: string; message?: string }
|
||||||
|
if (parsed.type === "delta" && parsed.content) {
|
||||||
|
if (!hasStarted) {
|
||||||
|
hasStarted = true
|
||||||
|
classDetailAiLoading.value = false
|
||||||
|
}
|
||||||
|
classDetailAiContent.value += parsed.content
|
||||||
|
} else if (parsed.type === "error") {
|
||||||
|
throw new Error(parsed.message || "AI 服务异常")
|
||||||
|
} else if (parsed.type === "done" && !hasStarted) {
|
||||||
|
classDetailAiLoading.value = false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
} catch (error: any) {
|
||||||
|
if (controller.signal.aborted) return
|
||||||
|
message.error(error?.message || "AI 分析失败,请稍后再试")
|
||||||
|
classDetailAiLoading.value = false
|
||||||
|
} finally {
|
||||||
|
if (classDetailAiController === controller) classDetailAiController = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
interface ClassRank {
|
interface ClassRank {
|
||||||
rank: number
|
rank: number
|
||||||
class_name: string
|
class_name: string
|
||||||
@@ -62,6 +143,27 @@ interface ClassRank {
|
|||||||
ac_rate: number
|
ac_rate: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface ClassComparison {
|
||||||
|
class_name: string
|
||||||
|
user_count: number
|
||||||
|
total_ac: number
|
||||||
|
total_submission: number
|
||||||
|
avg_ac: number
|
||||||
|
median_ac: number
|
||||||
|
q1_ac: number
|
||||||
|
q3_ac: number
|
||||||
|
iqr: number
|
||||||
|
std_dev: number
|
||||||
|
top_10_avg: number
|
||||||
|
middle_80_avg: number
|
||||||
|
bottom_10_avg: number
|
||||||
|
excellent_rate: number
|
||||||
|
pass_rate: number
|
||||||
|
active_rate: number
|
||||||
|
ac_rate: number
|
||||||
|
composite_score: number
|
||||||
|
}
|
||||||
|
|
||||||
interface UserRank {
|
interface UserRank {
|
||||||
rank: number
|
rank: number
|
||||||
username: string
|
username: string
|
||||||
@@ -191,7 +293,7 @@ const classColumns: DataTableColumn<ClassRank>[] = [
|
|||||||
{
|
{
|
||||||
title: "排名",
|
title: "排名",
|
||||||
key: "rank",
|
key: "rank",
|
||||||
width: 100,
|
width: 60,
|
||||||
titleAlign: "center",
|
titleAlign: "center",
|
||||||
align: "center",
|
align: "center",
|
||||||
},
|
},
|
||||||
@@ -200,46 +302,63 @@ const classColumns: DataTableColumn<ClassRank>[] = [
|
|||||||
key: "class_name",
|
key: "class_name",
|
||||||
render: (row) =>
|
render: (row) =>
|
||||||
`${row.class_name.slice(0, 2)}计算机${row.class_name.slice(2)}班`,
|
`${row.class_name.slice(0, 2)}计算机${row.class_name.slice(2)}班`,
|
||||||
width: 200,
|
minWidth: 120,
|
||||||
titleAlign: "center",
|
titleAlign: "center",
|
||||||
align: "center",
|
align: "center",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "人数",
|
title: "人数",
|
||||||
key: "user_count",
|
key: "user_count",
|
||||||
width: 100,
|
width: 80,
|
||||||
titleAlign: "center",
|
titleAlign: "center",
|
||||||
align: "center",
|
align: "center",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "总AC数",
|
title: "总AC数",
|
||||||
key: "total_ac",
|
key: "total_ac",
|
||||||
width: 120,
|
width: 90,
|
||||||
titleAlign: "center",
|
titleAlign: "center",
|
||||||
align: "center",
|
align: "center",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "总提交数",
|
title: "提交数",
|
||||||
key: "total_submission",
|
key: "total_submission",
|
||||||
width: 120,
|
width: 90,
|
||||||
titleAlign: "center",
|
titleAlign: "center",
|
||||||
align: "center",
|
align: "center",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "平均AC数",
|
title: "平均AC数",
|
||||||
key: "avg_ac",
|
key: "avg_ac",
|
||||||
width: 120,
|
width: 100,
|
||||||
titleAlign: "center",
|
titleAlign: "center",
|
||||||
align: "center",
|
align: "center",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "正确率",
|
title: "正确率",
|
||||||
key: "ac_rate",
|
key: "ac_rate",
|
||||||
width: 100,
|
width: 90,
|
||||||
titleAlign: "center",
|
titleAlign: "center",
|
||||||
align: "center",
|
align: "center",
|
||||||
render: (row) => `${row.ac_rate}%`,
|
render: (row) => `${row.ac_rate}%`,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
title: "详情",
|
||||||
|
key: "action",
|
||||||
|
width: 70,
|
||||||
|
titleAlign: "center",
|
||||||
|
align: "center",
|
||||||
|
render: (row) =>
|
||||||
|
h(
|
||||||
|
NButton,
|
||||||
|
{
|
||||||
|
text: true,
|
||||||
|
type: "info",
|
||||||
|
onClick: () => loadClassDetail(row.class_name),
|
||||||
|
},
|
||||||
|
() => "查看",
|
||||||
|
),
|
||||||
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
const myClassColumns: DataTableColumn<UserRank>[] = [
|
const myClassColumns: DataTableColumn<UserRank>[] = [
|
||||||
@@ -453,6 +572,260 @@ watch(
|
|||||||
</n-gi>
|
</n-gi>
|
||||||
</n-grid>
|
</n-grid>
|
||||||
</n-flex>
|
</n-flex>
|
||||||
|
|
||||||
|
<n-modal
|
||||||
|
v-model:show="showClassDetailModal"
|
||||||
|
preset="card"
|
||||||
|
:title="
|
||||||
|
classDetailData
|
||||||
|
? `${classDetailData.class_name.slice(0, 2)}计算机${classDetailData.class_name.slice(2)}班`
|
||||||
|
: '班级详情'
|
||||||
|
"
|
||||||
|
:style="{ width: '700px', maxWidth: '95vw' }"
|
||||||
|
>
|
||||||
|
<n-spin :show="classDetailLoading" style="min-height: 200px">
|
||||||
|
<n-flex v-if="classDetailData" vertical :size="12">
|
||||||
|
<n-grid :cols="5" :x-gap="8" responsive="screen">
|
||||||
|
<n-gi>
|
||||||
|
<n-statistic
|
||||||
|
label="总AC数"
|
||||||
|
:value="classDetailData.total_ac"
|
||||||
|
size="large"
|
||||||
|
class="stat-total-ac"
|
||||||
|
>
|
||||||
|
<template #suffix>
|
||||||
|
<Icon icon="streamline-emojis:raised-fist-1" width="20" />
|
||||||
|
</template>
|
||||||
|
</n-statistic>
|
||||||
|
</n-gi>
|
||||||
|
<n-gi>
|
||||||
|
<n-statistic
|
||||||
|
label="平均AC数"
|
||||||
|
:value="classDetailData.avg_ac.toFixed(2)"
|
||||||
|
size="large"
|
||||||
|
class="stat-avg-ac"
|
||||||
|
>
|
||||||
|
<template #suffix>
|
||||||
|
<Icon icon="streamline-emojis:chart" width="20" />
|
||||||
|
</template>
|
||||||
|
</n-statistic>
|
||||||
|
</n-gi>
|
||||||
|
<n-gi>
|
||||||
|
<n-statistic
|
||||||
|
label="中位数AC数"
|
||||||
|
:value="classDetailData.median_ac.toFixed(2)"
|
||||||
|
size="large"
|
||||||
|
class="stat-median-ac"
|
||||||
|
>
|
||||||
|
<template #suffix>
|
||||||
|
<Icon icon="streamline-emojis:target" width="20" />
|
||||||
|
</template>
|
||||||
|
</n-statistic>
|
||||||
|
</n-gi>
|
||||||
|
<n-gi>
|
||||||
|
<n-statistic
|
||||||
|
label="总提交数"
|
||||||
|
:value="classDetailData.total_submission"
|
||||||
|
size="large"
|
||||||
|
class="stat-total-submission"
|
||||||
|
>
|
||||||
|
<template #suffix>
|
||||||
|
<Icon icon="streamline-emojis:paper" width="20" />
|
||||||
|
</template>
|
||||||
|
</n-statistic>
|
||||||
|
</n-gi>
|
||||||
|
<n-gi>
|
||||||
|
<n-statistic
|
||||||
|
label="AC率"
|
||||||
|
:value="classDetailData.ac_rate.toFixed(1) + '%'"
|
||||||
|
size="large"
|
||||||
|
class="stat-ac-rate"
|
||||||
|
>
|
||||||
|
<template #suffix>
|
||||||
|
<Icon icon="streamline-emojis:check-mark" width="20" />
|
||||||
|
</template>
|
||||||
|
</n-statistic>
|
||||||
|
</n-gi>
|
||||||
|
</n-grid>
|
||||||
|
|
||||||
|
<n-divider style="margin: 12px 0" />
|
||||||
|
|
||||||
|
<n-descriptions
|
||||||
|
bordered
|
||||||
|
:column="2"
|
||||||
|
size="small"
|
||||||
|
label-placement="left"
|
||||||
|
>
|
||||||
|
<n-descriptions-item label="第一四分位数(Q1)">
|
||||||
|
<span style="color: #9254de; font-weight: 500">{{
|
||||||
|
classDetailData.q1_ac.toFixed(2)
|
||||||
|
}}</span>
|
||||||
|
</n-descriptions-item>
|
||||||
|
<n-descriptions-item label="第三四分位数(Q3)">
|
||||||
|
<span style="color: #f759ab; font-weight: 500">{{
|
||||||
|
classDetailData.q3_ac.toFixed(2)
|
||||||
|
}}</span>
|
||||||
|
</n-descriptions-item>
|
||||||
|
<n-descriptions-item label="四分位距(IQR)">
|
||||||
|
<span style="color: #13c2c2; font-weight: 500">{{
|
||||||
|
classDetailData.iqr.toFixed(2)
|
||||||
|
}}</span>
|
||||||
|
</n-descriptions-item>
|
||||||
|
<n-descriptions-item label="标准差">
|
||||||
|
<span style="color: #fa8c16; font-weight: 500">{{
|
||||||
|
classDetailData.std_dev.toFixed(2)
|
||||||
|
}}</span>
|
||||||
|
</n-descriptions-item>
|
||||||
|
<n-descriptions-item label="前10%均值">
|
||||||
|
<span style="color: #cf1322; font-weight: 600">{{
|
||||||
|
classDetailData.top_10_avg.toFixed(2)
|
||||||
|
}}</span>
|
||||||
|
</n-descriptions-item>
|
||||||
|
<n-descriptions-item label="中间80%均值">
|
||||||
|
<span style="color: #389e0d; font-weight: 600">{{
|
||||||
|
classDetailData.middle_80_avg.toFixed(2)
|
||||||
|
}}</span>
|
||||||
|
</n-descriptions-item>
|
||||||
|
<n-descriptions-item label="后10%均值">
|
||||||
|
<span style="color: #096dd9; font-weight: 500">{{
|
||||||
|
classDetailData.bottom_10_avg.toFixed(2)
|
||||||
|
}}</span>
|
||||||
|
</n-descriptions-item>
|
||||||
|
<n-descriptions-item label="人数">
|
||||||
|
<span style="color: #1890ff; font-weight: 600">{{
|
||||||
|
classDetailData.user_count
|
||||||
|
}}</span>
|
||||||
|
</n-descriptions-item>
|
||||||
|
</n-descriptions>
|
||||||
|
|
||||||
|
<n-card size="small" title="比率统计" embedded style="margin-top: 12px">
|
||||||
|
<n-space vertical :size="10">
|
||||||
|
<n-progress
|
||||||
|
type="line"
|
||||||
|
:percentage="classDetailData.excellent_rate"
|
||||||
|
:show-indicator="true"
|
||||||
|
:border-radius="4"
|
||||||
|
>
|
||||||
|
<template #default
|
||||||
|
>优秀率:
|
||||||
|
{{ classDetailData.excellent_rate.toFixed(1) }}%</template
|
||||||
|
>
|
||||||
|
</n-progress>
|
||||||
|
<n-progress
|
||||||
|
type="line"
|
||||||
|
:percentage="classDetailData.pass_rate"
|
||||||
|
:show-indicator="true"
|
||||||
|
:border-radius="4"
|
||||||
|
status="success"
|
||||||
|
>
|
||||||
|
<template #default
|
||||||
|
>及格率: {{ classDetailData.pass_rate.toFixed(1) }}%</template
|
||||||
|
>
|
||||||
|
</n-progress>
|
||||||
|
<n-progress
|
||||||
|
type="line"
|
||||||
|
:percentage="classDetailData.active_rate"
|
||||||
|
:show-indicator="true"
|
||||||
|
:border-radius="4"
|
||||||
|
status="info"
|
||||||
|
>
|
||||||
|
<template #default
|
||||||
|
>参与度: {{ classDetailData.active_rate.toFixed(1) }}%</template
|
||||||
|
>
|
||||||
|
</n-progress>
|
||||||
|
</n-space>
|
||||||
|
</n-card>
|
||||||
|
|
||||||
|
<n-flex justify="center" align="center" :size="12" style="margin-top: 12px">
|
||||||
|
<n-tag type="success" size="large">
|
||||||
|
综合分: {{ classDetailData.composite_score.toFixed(1) }}
|
||||||
|
</n-tag>
|
||||||
|
<n-button
|
||||||
|
type="info"
|
||||||
|
size="small"
|
||||||
|
:loading="classDetailAiLoading"
|
||||||
|
@click="analyzeSingleClassWithAI"
|
||||||
|
>
|
||||||
|
<template #icon>
|
||||||
|
<Icon icon="mingcute:ai-line" />
|
||||||
|
</template>
|
||||||
|
AI分析
|
||||||
|
</n-button>
|
||||||
|
</n-flex>
|
||||||
|
</n-flex>
|
||||||
|
<n-empty
|
||||||
|
v-else-if="!classDetailLoading"
|
||||||
|
description="暂无数据"
|
||||||
|
style="padding: 40px 0"
|
||||||
|
/>
|
||||||
|
</n-spin>
|
||||||
|
</n-modal>
|
||||||
|
|
||||||
|
<n-modal
|
||||||
|
v-model:show="showClassDetailAiModal"
|
||||||
|
preset="card"
|
||||||
|
title="AI 分析报告"
|
||||||
|
:style="{ width: '800px', maxWidth: '95vw' }"
|
||||||
|
>
|
||||||
|
<n-spin :show="classDetailAiLoading" :delay="50">
|
||||||
|
<div style="min-height: 200px">
|
||||||
|
<MdPreview v-if="classDetailAiContent" :model-value="classDetailAiContent" />
|
||||||
|
<n-flex
|
||||||
|
v-else-if="!classDetailAiLoading"
|
||||||
|
align="center"
|
||||||
|
justify="center"
|
||||||
|
style="min-height: 200px"
|
||||||
|
>
|
||||||
|
<n-empty description="暂无分析内容" />
|
||||||
|
</n-flex>
|
||||||
|
</div>
|
||||||
|
</n-spin>
|
||||||
|
</n-modal>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped></style>
|
<style scoped>
|
||||||
|
.stat-total-ac :deep(.n-statistic-value),
|
||||||
|
.stat-total-ac :deep(.n-statistic-value__content),
|
||||||
|
.stat-total-ac :deep(.n-number-animation),
|
||||||
|
.stat-total-ac :deep(.n-statistic-value > *),
|
||||||
|
.stat-total-ac :deep(.n-statistic-value span) {
|
||||||
|
color: #ff4d4f !important;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-avg-ac :deep(.n-statistic-value),
|
||||||
|
.stat-avg-ac :deep(.n-statistic-value__content),
|
||||||
|
.stat-avg-ac :deep(.n-number-animation),
|
||||||
|
.stat-avg-ac :deep(.n-statistic-value > *),
|
||||||
|
.stat-avg-ac :deep(.n-statistic-value span) {
|
||||||
|
color: #52c41a !important;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-median-ac :deep(.n-statistic-value),
|
||||||
|
.stat-median-ac :deep(.n-statistic-value__content),
|
||||||
|
.stat-median-ac :deep(.n-number-animation),
|
||||||
|
.stat-median-ac :deep(.n-statistic-value > *),
|
||||||
|
.stat-median-ac :deep(.n-statistic-value span) {
|
||||||
|
color: #fa8c16 !important;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-total-submission :deep(.n-statistic-value),
|
||||||
|
.stat-total-submission :deep(.n-statistic-value__content),
|
||||||
|
.stat-total-submission :deep(.n-number-animation),
|
||||||
|
.stat-total-submission :deep(.n-statistic-value > *),
|
||||||
|
.stat-total-submission :deep(.n-statistic-value span) {
|
||||||
|
color: #805ad5 !important;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-ac-rate :deep(.n-statistic-value),
|
||||||
|
.stat-ac-rate :deep(.n-statistic-value__content),
|
||||||
|
.stat-ac-rate :deep(.n-number-animation),
|
||||||
|
.stat-ac-rate :deep(.n-statistic-value > *),
|
||||||
|
.stat-ac-rate :deep(.n-statistic-value span) {
|
||||||
|
color: #00b894 !important;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -1,6 +1,11 @@
|
|||||||
import { DetailsData, DurationData } from "utils/types"
|
import { DetailsData, DurationData } from "utils/types"
|
||||||
import { consumeJSONEventStream } from "utils/stream"
|
import { consumeJSONEventStream } from "utils/stream"
|
||||||
import { getAIDetailData, getAIDurationData, getAIHeatmapData } from "../api"
|
import {
|
||||||
|
getAIDetailData,
|
||||||
|
getAIDurationData,
|
||||||
|
getAIHeatmapData,
|
||||||
|
getAIPinnedReport,
|
||||||
|
} from "../api"
|
||||||
import { getCSRFToken } from "utils/functions"
|
import { getCSRFToken } from "utils/functions"
|
||||||
|
|
||||||
export const useAIStore = defineStore("ai", () => {
|
export const useAIStore = defineStore("ai", () => {
|
||||||
@@ -27,6 +32,7 @@ export const useAIStore = defineStore("ai", () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const mdContent = ref("")
|
const mdContent = ref("")
|
||||||
|
const pinnedReport = ref<{ analysis: string } | null>(null)
|
||||||
|
|
||||||
async function fetchDetailsData(start: string, end: string) {
|
async function fetchDetailsData(start: string, end: string) {
|
||||||
const res = await getAIDetailData(
|
const res = await getAIDetailData(
|
||||||
@@ -156,10 +162,38 @@ export const useAIStore = defineStore("ai", () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function fetchPinnedReport() {
|
||||||
|
const res = await getAIPinnedReport()
|
||||||
|
pinnedReport.value = res.data
|
||||||
|
}
|
||||||
|
|
||||||
|
async function simulatePinnedStream() {
|
||||||
|
if (!pinnedReport.value) return
|
||||||
|
const text = pinnedReport.value.analysis
|
||||||
|
mdContent.value = ""
|
||||||
|
const CHUNK = 6
|
||||||
|
const DELAY = 18
|
||||||
|
await new Promise<void>((resolve) => {
|
||||||
|
let i = 0
|
||||||
|
function step() {
|
||||||
|
if (i >= text.length) {
|
||||||
|
resolve()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
mdContent.value += text.slice(i, i + CHUNK)
|
||||||
|
i += CHUNK
|
||||||
|
setTimeout(step, DELAY)
|
||||||
|
}
|
||||||
|
step()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
fetchAnalysisData,
|
fetchAnalysisData,
|
||||||
fetchHeatmapData,
|
fetchHeatmapData,
|
||||||
fetchAIAnalysis,
|
fetchAIAnalysis,
|
||||||
|
fetchPinnedReport,
|
||||||
|
simulatePinnedStream,
|
||||||
durationData,
|
durationData,
|
||||||
detailsData,
|
detailsData,
|
||||||
heatmapData,
|
heatmapData,
|
||||||
@@ -167,5 +201,6 @@ export const useAIStore = defineStore("ai", () => {
|
|||||||
targetUsername,
|
targetUsername,
|
||||||
loading,
|
loading,
|
||||||
mdContent,
|
mdContent,
|
||||||
|
pinnedReport,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,36 +1,45 @@
|
|||||||
<template>
|
<template>
|
||||||
<n-grid v-if="submission" :cols="5" :x-gap="16">
|
<n-grid v-if="submission" :cols="5" :x-gap="16">
|
||||||
<!-- 左侧:流程图预览区域 -->
|
<!-- 左侧:流程图预览区域 -->
|
||||||
<n-gi :span="showLargeImage ? 5 : 3">
|
<n-gi :span="3">
|
||||||
<n-card title="流程图预览">
|
<n-card title="流程图预览">
|
||||||
<template #header-extra>
|
<template #header-extra>
|
||||||
<n-button
|
<n-button
|
||||||
v-if="!renderError && submission?.mermaid_code"
|
v-if="!renderError && submission?.mermaid_code"
|
||||||
quaternary
|
quaternary
|
||||||
size="small"
|
size="small"
|
||||||
@click="showLargeImage = !showLargeImage"
|
@click="showLargeImage = true"
|
||||||
>
|
>
|
||||||
<template #icon>
|
<template #icon>
|
||||||
<Icon
|
<Icon icon="mdi:fullscreen" />
|
||||||
:icon="
|
|
||||||
showLargeImage ? 'mdi:fullscreen-exit' : 'mdi:fullscreen'
|
|
||||||
"
|
|
||||||
/>
|
|
||||||
</template>
|
</template>
|
||||||
{{ showLargeImage ? "退出大图" : "查看大图" }}
|
查看大图
|
||||||
</n-button>
|
</n-button>
|
||||||
</template>
|
</template>
|
||||||
<div class="flowchart">
|
<div class="flowchart">
|
||||||
<n-alert v-if="renderError" type="error" title="流程图渲染失败">
|
<n-alert v-if="renderError" type="error" title="流程图渲染失败">
|
||||||
{{ renderError }}
|
{{ renderError }}
|
||||||
</n-alert>
|
</n-alert>
|
||||||
<div class="flowchart" v-else ref="mermaidContainer"></div>
|
<Teleport v-else to="body" :disabled="!showLargeImage">
|
||||||
|
<div
|
||||||
|
:class="['flowchart', { 'flowchart-fullscreen': showLargeImage }]"
|
||||||
|
ref="mermaidContainer"
|
||||||
|
></div>
|
||||||
|
<div v-if="showLargeImage" class="fullscreen-toolbar">
|
||||||
|
<n-button secondary round @click="showLargeImage = false">
|
||||||
|
<template #icon>
|
||||||
|
<Icon icon="mdi:fullscreen-exit" />
|
||||||
|
</template>
|
||||||
|
退出大图
|
||||||
|
</n-button>
|
||||||
|
</div>
|
||||||
|
</Teleport>
|
||||||
</div>
|
</div>
|
||||||
</n-card>
|
</n-card>
|
||||||
</n-gi>
|
</n-gi>
|
||||||
|
|
||||||
<!-- 右侧:评分详情区域 -->
|
<!-- 右侧:评分详情区域 -->
|
||||||
<n-gi v-if="!showLargeImage" :span="2">
|
<n-gi :span="2">
|
||||||
<!-- AI反馈 -->
|
<!-- AI反馈 -->
|
||||||
<n-card
|
<n-card
|
||||||
v-if="submission.ai_feedback"
|
v-if="submission.ai_feedback"
|
||||||
@@ -137,6 +146,7 @@ function getPercentType(percent: number) {
|
|||||||
|
|
||||||
async function loadSubmission() {
|
async function loadSubmission() {
|
||||||
if (!props.submissionId) return
|
if (!props.submissionId) return
|
||||||
|
showLargeImage.value = false
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
const { getFlowchartSubmission } = await import("oj/api")
|
const { getFlowchartSubmission } = await import("oj/api")
|
||||||
@@ -171,11 +181,42 @@ watch(() => props.submissionId, loadSubmission, { immediate: true })
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 全屏大图:覆盖整个视口,脱离弹框宽度限制 */
|
||||||
|
.flowchart-fullscreen {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 4000;
|
||||||
|
width: 100vw;
|
||||||
|
height: 100vh;
|
||||||
|
padding: 32px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
background: #ffffff;
|
||||||
|
/* 改为可滚动块布局,超出视口的大图可以滚动查看 */
|
||||||
|
display: block;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fullscreen-toolbar {
|
||||||
|
position: fixed;
|
||||||
|
top: 16px;
|
||||||
|
right: 16px;
|
||||||
|
z-index: 4001;
|
||||||
|
}
|
||||||
|
|
||||||
/* 确保 SVG 图表占满容器 */
|
/* 确保 SVG 图表占满容器 */
|
||||||
:deep(.flowchart > svg) {
|
:deep(.flowchart > svg) {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 全屏时按自然尺寸显示并水平居中,配合容器滚动 */
|
||||||
|
:deep(.flowchart-fullscreen > svg) {
|
||||||
|
display: block;
|
||||||
|
margin: 0 auto;
|
||||||
|
width: auto;
|
||||||
|
height: auto;
|
||||||
|
max-width: none;
|
||||||
|
}
|
||||||
|
|
||||||
.loading-container {
|
.loading-container {
|
||||||
min-height: 600px;
|
min-height: 600px;
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
getFlowchartSubmissions,
|
getFlowchartSubmissions,
|
||||||
getSubmissions,
|
getSubmissions,
|
||||||
getTodaySubmissionCount,
|
getTodaySubmissionCount,
|
||||||
|
retryFlowchartSubmission,
|
||||||
} from "oj/api"
|
} from "oj/api"
|
||||||
import { parseTime } from "utils/functions"
|
import { parseTime } from "utils/functions"
|
||||||
import type {
|
import type {
|
||||||
@@ -79,6 +80,14 @@ const resultOptions: SelectOption[] = [
|
|||||||
{ label: "运行时错误", value: "4" },
|
{ label: "运行时错误", value: "4" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
const gradeOptions: SelectOption[] = [
|
||||||
|
{ label: "全部", value: "" },
|
||||||
|
{ label: "S级", value: "S" },
|
||||||
|
{ label: "A级", value: "A" },
|
||||||
|
{ label: "B级", value: "B" },
|
||||||
|
{ label: "C级", value: "C" },
|
||||||
|
]
|
||||||
|
|
||||||
const languageOptions: SelectOption[] = [
|
const languageOptions: SelectOption[] = [
|
||||||
{ label: "流程图", value: "Flowchart" },
|
{ label: "流程图", value: "Flowchart" },
|
||||||
{ label: "全部语言", value: "" },
|
{ label: "全部语言", value: "" },
|
||||||
@@ -96,6 +105,8 @@ async function listSubmissions() {
|
|||||||
myself: query.myself,
|
myself: query.myself,
|
||||||
offset,
|
offset,
|
||||||
limit: query.limit,
|
limit: query.limit,
|
||||||
|
today: query.today,
|
||||||
|
grade: query.result,
|
||||||
})
|
})
|
||||||
total.value = res.data.total
|
total.value = res.data.total
|
||||||
flowcharts.value = res.data.results
|
flowcharts.value = res.data.results
|
||||||
@@ -114,7 +125,7 @@ async function listSubmissions() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function getTodayCount() {
|
async function getTodayCount() {
|
||||||
const res = await getTodaySubmissionCount()
|
const res = await getTodaySubmissionCount(query.language)
|
||||||
todayCount.value = res.data
|
todayCount.value = res.data
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -140,6 +151,12 @@ async function rejudge(submissionID: string) {
|
|||||||
listSubmissions()
|
listSubmissions()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function retryFlowchart(submissionId: string) {
|
||||||
|
await retryFlowchartSubmission(submissionId)
|
||||||
|
message.success("重新评分已提交")
|
||||||
|
listSubmissions()
|
||||||
|
}
|
||||||
|
|
||||||
function problemClicked(row: SubmissionListItem | FlowchartSubmissionListItem) {
|
function problemClicked(row: SubmissionListItem | FlowchartSubmissionListItem) {
|
||||||
if (route.name === "contest submissions") {
|
if (route.name === "contest submissions") {
|
||||||
const path = router.resolve({
|
const path = router.resolve({
|
||||||
@@ -192,6 +209,24 @@ watch(
|
|||||||
listSubmissions,
|
listSubmissions,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// 切换语言时重置过滤条件,刷新今日提交数
|
||||||
|
watch(
|
||||||
|
() => query.language,
|
||||||
|
() => {
|
||||||
|
query.result = ""
|
||||||
|
if (route.name === "submissions") getTodayCount()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
// 登录状态变化后刷新提交列表,更新提交编号列的可点击状态
|
||||||
|
watch(
|
||||||
|
() => userStore.isAuthed,
|
||||||
|
() => {
|
||||||
|
listSubmissions()
|
||||||
|
if (route.name === "submissions") getTodayCount()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
const columns = computed(() => {
|
const columns = computed(() => {
|
||||||
const res: DataTableColumn<SubmissionListItem>[] = [
|
const res: DataTableColumn<SubmissionListItem>[] = [
|
||||||
{
|
{
|
||||||
@@ -281,7 +316,8 @@ const columns = computed(() => {
|
|||||||
return res
|
return res
|
||||||
})
|
})
|
||||||
|
|
||||||
const flowchartColumns: DataTableColumn<FlowchartSubmissionListItem>[] = [
|
const flowchartColumns = computed(() => {
|
||||||
|
const res: DataTableColumn<FlowchartSubmissionListItem>[] = [
|
||||||
{
|
{
|
||||||
title: renderTableTitle("提交时间", "noto:seven-oclock"),
|
title: renderTableTitle("提交时间", "noto:seven-oclock"),
|
||||||
key: "create_time",
|
key: "create_time",
|
||||||
@@ -335,7 +371,26 @@ const flowchartColumns: DataTableColumn<FlowchartSubmissionListItem>[] = [
|
|||||||
() => row.username,
|
() => 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>
|
</script>
|
||||||
<template>
|
<template>
|
||||||
<n-flex vertical size="large">
|
<n-flex vertical size="large">
|
||||||
@@ -355,12 +410,13 @@ const flowchartColumns: DataTableColumn<FlowchartSubmissionListItem>[] = [
|
|||||||
:options="languageOptions"
|
:options="languageOptions"
|
||||||
/>
|
/>
|
||||||
</n-form-item>
|
</n-form-item>
|
||||||
<n-form-item label="状态">
|
<n-form-item :label="query.language === 'Flowchart' ? '等级' : '状态'">
|
||||||
<n-select
|
<n-select
|
||||||
:disabled="query.language === 'Flowchart'"
|
|
||||||
class="select"
|
class="select"
|
||||||
v-model:value="query.result"
|
v-model:value="query.result"
|
||||||
:options="resultOptions"
|
:options="
|
||||||
|
query.language === 'Flowchart' ? gradeOptions : resultOptions
|
||||||
|
"
|
||||||
/>
|
/>
|
||||||
</n-form-item>
|
</n-form-item>
|
||||||
</n-form>
|
</n-form>
|
||||||
@@ -449,14 +505,20 @@ const flowchartColumns: DataTableColumn<FlowchartSubmissionListItem>[] = [
|
|||||||
preset="card"
|
preset="card"
|
||||||
:style="{ maxWidth: isDesktop && '800px', maxHeight: '80vh' }"
|
:style="{ maxWidth: isDesktop && '800px', maxHeight: '80vh' }"
|
||||||
:content-style="{ overflow: 'auto' }"
|
:content-style="{ overflow: 'auto' }"
|
||||||
:title="query.language === 'Flowchart' ? '流程图提交的统计' : '提交记录的统计'"
|
:title="
|
||||||
|
query.language === 'Flowchart' ? '流程图提交的统计' : '提交记录的统计'
|
||||||
|
"
|
||||||
>
|
>
|
||||||
<FlowchartStatisticsPanel
|
<FlowchartStatisticsPanel
|
||||||
v-if="query.language === 'Flowchart'"
|
v-if="query.language === 'Flowchart'"
|
||||||
:problem="query.problem"
|
:problem="query.problem"
|
||||||
:username="query.username"
|
:username="query.username"
|
||||||
/>
|
/>
|
||||||
<StatisticsPanel v-else :problem="query.problem" :username="query.username" />
|
<StatisticsPanel
|
||||||
|
v-else
|
||||||
|
:problem="query.problem"
|
||||||
|
:username="query.username"
|
||||||
|
/>
|
||||||
</n-modal>
|
</n-modal>
|
||||||
<n-modal
|
<n-modal
|
||||||
v-model:show="codePanel"
|
v-model:show="codePanel"
|
||||||
|
|||||||
29
src/oj/transforms.ts
Normal file
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,
|
() => profile.value?.avatar.endsWith("default.png") ?? true,
|
||||||
)
|
)
|
||||||
|
|
||||||
const problemsFlexRef = ref<HTMLElement | null>(null)
|
const problemsFlexRef = useTemplateRef<HTMLElement>("problemsFlexRef")
|
||||||
const itemsPerRow = ref(8)
|
const itemsPerRow = ref(8)
|
||||||
|
|
||||||
function updateItemsPerRow() {
|
function updateItemsPerRow() {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { RouteRecordRaw } from "vue-router"
|
import type { RouteRecordRaw } from "vue-router"
|
||||||
|
|
||||||
export const ojs: RouteRecordRaw = {
|
export const ojs: RouteRecordRaw = {
|
||||||
path: "/",
|
path: "/",
|
||||||
@@ -315,5 +315,11 @@ export const admins: RouteRecordRaw = {
|
|||||||
props: true,
|
props: true,
|
||||||
meta: { requiresTeacherAdmin: true },
|
meta: { requiresTeacherAdmin: true },
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: "ai/reports",
|
||||||
|
name: "admin ai reports",
|
||||||
|
component: () => import("admin/ai/list.vue"),
|
||||||
|
meta: { requiresTeacherAdmin: true },
|
||||||
|
},
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,21 +22,19 @@ interface Props {
|
|||||||
placeholder?: string
|
placeholder?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
const props = withDefaults(defineProps<Props>(), {
|
const {
|
||||||
language: "Python3",
|
language = "Python3",
|
||||||
fontSize: 20,
|
fontSize = 20,
|
||||||
height: "100%",
|
height = "100%",
|
||||||
readonly: false,
|
readonly = false,
|
||||||
placeholder: "",
|
placeholder = "",
|
||||||
})
|
} = defineProps<Props>()
|
||||||
|
|
||||||
const { readonly, placeholder, height, fontSize } = toRefs(props)
|
|
||||||
const code = defineModel<string>("value")
|
const code = defineModel<string>("value")
|
||||||
|
|
||||||
const isDark = useDark()
|
const isDark = useDark()
|
||||||
|
|
||||||
const langExtension = computed(() => {
|
const langExtension = computed(() => {
|
||||||
return ["Python2", "Python3"].includes(props.language) ? python() : cpp()
|
return ["Python2", "Python3"].includes(language) ? python() : cpp()
|
||||||
})
|
})
|
||||||
|
|
||||||
const extensions = computed(() => [
|
const extensions = computed(() => [
|
||||||
@@ -45,7 +43,7 @@ const extensions = computed(() => [
|
|||||||
bracketMatching(),
|
bracketMatching(),
|
||||||
closeBrackets(),
|
closeBrackets(),
|
||||||
autocompletion({
|
autocompletion({
|
||||||
override: [enhanceCompletion(props.language), completeAnyWord],
|
override: [enhanceCompletion(language), completeAnyWord],
|
||||||
}),
|
}),
|
||||||
isDark.value ? oneDark : smoothy,
|
isDark.value ? oneDark : smoothy,
|
||||||
])
|
])
|
||||||
|
|||||||
@@ -78,7 +78,7 @@ const emit = defineEmits<Emits>()
|
|||||||
const isHovered = ref(false)
|
const isHovered = ref(false)
|
||||||
const isEditing = ref(false)
|
const isEditing = ref(false)
|
||||||
const editText = ref("")
|
const editText = ref("")
|
||||||
const editInput = ref<HTMLInputElement>()
|
const editInput = useTemplateRef<HTMLInputElement>("editInput")
|
||||||
|
|
||||||
// 定时器和事件处理器
|
// 定时器和事件处理器
|
||||||
let hideTimeout: ReturnType<typeof setTimeout> | null = null
|
let hideTimeout: ReturnType<typeof setTimeout> | null = null
|
||||||
|
|||||||
@@ -26,9 +26,7 @@ interface Props {
|
|||||||
height?: string
|
height?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
withDefaults(defineProps<Props>(), {
|
const { height = "calc(100vh - 133px)" } = defineProps<Props>()
|
||||||
height: "calc(100vh - 133px)",
|
|
||||||
})
|
|
||||||
|
|
||||||
// Vue Flow 实例
|
// Vue Flow 实例
|
||||||
const { addEdges, removeNodes, removeEdges } = useVueFlow()
|
const { addEdges, removeNodes, removeEdges } = useVueFlow()
|
||||||
|
|||||||
@@ -70,22 +70,36 @@
|
|||||||
<!-- 1. Grade pie chart -->
|
<!-- 1. Grade pie chart -->
|
||||||
<n-gi>
|
<n-gi>
|
||||||
<n-card title="等级分布">
|
<n-card title="等级分布">
|
||||||
|
<div class="chart-container">
|
||||||
<Doughnut :data="gradeChartData" :options="doughnutOptions" />
|
<Doughnut :data="gradeChartData" :options="doughnutOptions" />
|
||||||
|
</div>
|
||||||
</n-card>
|
</n-card>
|
||||||
</n-gi>
|
</n-gi>
|
||||||
<!-- 3. Completion doughnut -->
|
<!-- 3. Completion doughnut -->
|
||||||
<n-gi v-if="data.person_count > 0">
|
<n-gi v-if="data.person_count > 0">
|
||||||
<n-card title="班级完成度">
|
<n-card title="班级完成度">
|
||||||
|
<div class="chart-container">
|
||||||
<Doughnut
|
<Doughnut
|
||||||
:data="completionChartData"
|
:data="completionChartData"
|
||||||
:options="doughnutOptions"
|
:options="doughnutOptions"
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
</n-card>
|
</n-card>
|
||||||
</n-gi>
|
</n-gi>
|
||||||
<!-- 2. Radar chart -->
|
<!-- 2. Radar chart -->
|
||||||
<n-gi v-if="hasRadarData">
|
<n-gi v-if="hasRadarData">
|
||||||
<n-card title="四维评分雷达图">
|
<n-card title="四维评分雷达图">
|
||||||
|
<div class="chart-container">
|
||||||
<Radar :data="radarChartData" :options="radarOptions" />
|
<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-card>
|
||||||
</n-gi>
|
</n-gi>
|
||||||
<!-- 4. Word cloud -->
|
<!-- 4. Word cloud -->
|
||||||
@@ -102,16 +116,42 @@
|
|||||||
<n-tab-pane
|
<n-tab-pane
|
||||||
v-if="data.data_unaccepted.length > 0"
|
v-if="data.data_unaccepted.length > 0"
|
||||||
name="unaccepted"
|
name="unaccepted"
|
||||||
:tab="`未完成(${data.data_unaccepted.length})`"
|
:tab="`未完成(${visibleUnaccepted.length})`"
|
||||||
>
|
>
|
||||||
<n-flex size="large" align="center" style="margin-top: 12px">
|
<n-flex align="center" style="margin: 12px 0">
|
||||||
<span
|
<n-switch v-model:value="hideMode" size="large">
|
||||||
v-for="item in data.data_unaccepted"
|
<template #checked>请假隐藏中</template>
|
||||||
:key="item.username"
|
<template #unchecked>请假隐藏</template>
|
||||||
style="font-size: 24px"
|
</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 }}
|
{{ item.real_name }}
|
||||||
</span>
|
</n-tag>
|
||||||
|
<span v-else style="font-size: 24px">{{ item.real_name }}</span>
|
||||||
|
</template>
|
||||||
</n-flex>
|
</n-flex>
|
||||||
</n-tab-pane>
|
</n-tab-pane>
|
||||||
</n-tabs>
|
</n-tabs>
|
||||||
@@ -122,7 +162,7 @@
|
|||||||
import { formatISO, sub, type Duration } from "date-fns"
|
import { formatISO, sub, type Duration } from "date-fns"
|
||||||
import { getFlowchartStatistics } from "oj/api"
|
import { getFlowchartStatistics } from "oj/api"
|
||||||
import { DURATION_OPTIONS } from "utils/constants"
|
import { DURATION_OPTIONS } from "utils/constants"
|
||||||
import { Doughnut, Radar } from "vue-chartjs"
|
import { Doughnut, Radar, Bar } from "vue-chartjs"
|
||||||
import {
|
import {
|
||||||
Chart as ChartJS,
|
Chart as ChartJS,
|
||||||
ArcElement,
|
ArcElement,
|
||||||
@@ -134,11 +174,10 @@ import {
|
|||||||
LineElement,
|
LineElement,
|
||||||
Filler,
|
Filler,
|
||||||
LinearScale,
|
LinearScale,
|
||||||
|
BarElement,
|
||||||
|
CategoryScale,
|
||||||
} from "chart.js"
|
} from "chart.js"
|
||||||
import {
|
import { WordCloudController, WordElement } from "chartjs-chart-wordcloud"
|
||||||
WordCloudController,
|
|
||||||
WordElement,
|
|
||||||
} from "chartjs-chart-wordcloud"
|
|
||||||
|
|
||||||
ChartJS.register(
|
ChartJS.register(
|
||||||
ArcElement,
|
ArcElement,
|
||||||
@@ -150,6 +189,8 @@ ChartJS.register(
|
|||||||
LineElement,
|
LineElement,
|
||||||
Filler,
|
Filler,
|
||||||
LinearScale,
|
LinearScale,
|
||||||
|
BarElement,
|
||||||
|
CategoryScale,
|
||||||
WordCloudController,
|
WordCloudController,
|
||||||
WordElement,
|
WordElement,
|
||||||
)
|
)
|
||||||
@@ -200,11 +241,71 @@ const data = reactive<StatisticsData>({
|
|||||||
const wordcloudCanvas = useTemplateRef<HTMLCanvasElement>("wordcloudCanvas")
|
const wordcloudCanvas = useTemplateRef<HTMLCanvasElement>("wordcloudCanvas")
|
||||||
let wordcloudChart: ChartJS | null = null
|
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(() => {
|
const completionRate = computed(() => {
|
||||||
if (data.person_count <= 0) return "0%"
|
if (adjustedPersonCount.value <= 0) return "0%"
|
||||||
const rate = Math.min(
|
const rate = Math.min(
|
||||||
100,
|
100,
|
||||||
(data.completed_count / data.person_count) * 100,
|
(data.completed_count / adjustedPersonCount.value) * 100,
|
||||||
)
|
)
|
||||||
return `${Math.round(rate * 100) / 100}%`
|
return `${Math.round(rate * 100) / 100}%`
|
||||||
})
|
})
|
||||||
@@ -236,16 +337,16 @@ const gradeChartData = computed(() => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const completionChartData = computed(() => {
|
const completionChartData = computed(() => {
|
||||||
const uncompleted = Math.max(0, data.person_count - data.completed_count)
|
const uncompleted = Math.max(
|
||||||
|
0,
|
||||||
|
adjustedPersonCount.value - data.completed_count,
|
||||||
|
)
|
||||||
return {
|
return {
|
||||||
labels: ["已完成", "未完成"],
|
labels: ["已完成", "未完成"],
|
||||||
datasets: [
|
datasets: [
|
||||||
{
|
{
|
||||||
data: [data.completed_count, uncompleted],
|
data: [data.completed_count, uncompleted],
|
||||||
backgroundColor: [
|
backgroundColor: ["rgba(106, 176, 76, 0.6)", "rgba(255, 159, 64, 0.6)"],
|
||||||
"rgba(106, 176, 76, 0.6)",
|
|
||||||
"rgba(255, 159, 64, 0.6)",
|
|
||||||
],
|
|
||||||
borderColor: ["rgba(106, 176, 76, 1)", "rgba(255, 159, 64, 1)"],
|
borderColor: ["rgba(106, 176, 76, 1)", "rgba(255, 159, 64, 1)"],
|
||||||
borderWidth: 2,
|
borderWidth: 2,
|
||||||
},
|
},
|
||||||
@@ -328,6 +429,47 @@ const radarOptions = {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 = [
|
const WORD_COLORS = [
|
||||||
"#2080f0",
|
"#2080f0",
|
||||||
"#18a058",
|
"#18a058",
|
||||||
@@ -360,10 +502,8 @@ function renderWordCloud() {
|
|||||||
{
|
{
|
||||||
label: "",
|
label: "",
|
||||||
data: words.map((w) => 10 + (w.count / maxCount) * 50),
|
data: words.map((w) => 10 + (w.count / maxCount) * 50),
|
||||||
color: words.map(
|
color: words.map((_, i) => WORD_COLORS[i % WORD_COLORS.length]),
|
||||||
(_, i) => WORD_COLORS[i % WORD_COLORS.length],
|
rotate: 0,
|
||||||
),
|
|
||||||
rotate: words.map(() => 0),
|
|
||||||
} as any,
|
} as any,
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
@@ -425,6 +565,11 @@ onUnmounted(() => {
|
|||||||
gap: 4px;
|
gap: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.chart-container {
|
||||||
|
height: 280px;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
.wordcloud-container {
|
.wordcloud-container {
|
||||||
height: 300px;
|
height: 300px;
|
||||||
position: relative;
|
position: relative;
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ const {
|
|||||||
loginLoading: isLoading,
|
loginLoading: isLoading,
|
||||||
loginError: msg,
|
loginError: msg,
|
||||||
} = storeToRefs(authStore)
|
} = storeToRefs(authStore)
|
||||||
const loginRef = ref()
|
const loginRef = useTemplateRef("loginRef")
|
||||||
const classUserOptions = ref<SelectOption[]>([])
|
const classUserOptions = ref<SelectOption[]>([])
|
||||||
const classUserLoading = ref(false)
|
const classUserLoading = ref(false)
|
||||||
const isClassLogin = computed(() => Boolean(form.value.class))
|
const isClassLogin = computed(() => Boolean(form.value.class))
|
||||||
|
|||||||
@@ -7,17 +7,14 @@ interface Props {
|
|||||||
page: number
|
page: number
|
||||||
}
|
}
|
||||||
|
|
||||||
const props = withDefaults(defineProps<Props>(), {
|
const { total, limit: initialLimit = 10, page: initialPage = 1 } = defineProps<Props>()
|
||||||
limit: 10,
|
|
||||||
page: 1,
|
|
||||||
})
|
|
||||||
|
|
||||||
const emit = defineEmits(["update:limit", "update:page"])
|
const emit = defineEmits(["update:limit", "update:page"])
|
||||||
|
|
||||||
const { isDesktop } = useBreakpoints()
|
const { isDesktop } = useBreakpoints()
|
||||||
|
|
||||||
const limit = ref(props.limit)
|
const limit = ref(initialLimit)
|
||||||
const page = ref(props.page)
|
const page = ref(initialPage)
|
||||||
const sizes = [10, 30, 50]
|
const sizes = [10, 30, 50]
|
||||||
|
|
||||||
watch(limit, () => emit("update:limit", limit))
|
watch(limit, () => emit("update:limit", limit))
|
||||||
@@ -26,9 +23,9 @@ watch(page, () => emit("update:page", page))
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<n-pagination
|
<n-pagination
|
||||||
v-if="props.total"
|
v-if="total"
|
||||||
class="right margin"
|
class="right margin"
|
||||||
:item-count="props.total"
|
:item-count="total"
|
||||||
v-model:page="page"
|
v-model:page="page"
|
||||||
v-model:page-size="limit"
|
v-model:page-size="limit"
|
||||||
:page-sizes="sizes"
|
:page-sizes="sizes"
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ const {
|
|||||||
signupError: msg,
|
signupError: msg,
|
||||||
captchaSrc,
|
captchaSrc,
|
||||||
} = storeToRefs(authStore)
|
} = storeToRefs(authStore)
|
||||||
const signupRef = ref()
|
const signupRef = useTemplateRef("signupRef")
|
||||||
|
|
||||||
const rules: FormRules = {
|
const rules: FormRules = {
|
||||||
username: [{ required: true, message: "用户名必填", trigger: "blur" }],
|
username: [{ required: true, message: "用户名必填", trigger: "blur" }],
|
||||||
|
|||||||
@@ -36,15 +36,15 @@ interface Props {
|
|||||||
placeholder?: string
|
placeholder?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
const props = withDefaults(defineProps<Props>(), {
|
const {
|
||||||
language: "Python3",
|
sync,
|
||||||
fontSize: 20,
|
problem,
|
||||||
height: "100%",
|
language = "Python3",
|
||||||
readonly: false,
|
fontSize = 20,
|
||||||
placeholder: "",
|
height = "100%",
|
||||||
})
|
readonly = false,
|
||||||
|
placeholder = "",
|
||||||
const { readonly, placeholder, height, fontSize } = toRefs(props)
|
} = defineProps<Props>()
|
||||||
const code = defineModel<string>("value")
|
const code = defineModel<string>("value")
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
@@ -57,7 +57,7 @@ const emit = defineEmits<{
|
|||||||
const { isDesktop } = useBreakpoints()
|
const { isDesktop } = useBreakpoints()
|
||||||
|
|
||||||
const langExtension = computed((): Extension => {
|
const langExtension = computed((): Extension => {
|
||||||
return ["Python2", "Python3"].includes(props.language) ? python() : cpp()
|
return ["Python2", "Python3"].includes(language) ? python() : cpp()
|
||||||
})
|
})
|
||||||
|
|
||||||
const extensions = computed(() => [
|
const extensions = computed(() => [
|
||||||
@@ -67,7 +67,7 @@ const extensions = computed(() => [
|
|||||||
closeBrackets(),
|
closeBrackets(),
|
||||||
isDark.value ? oneDark : smoothy,
|
isDark.value ? oneDark : smoothy,
|
||||||
autocompletion({
|
autocompletion({
|
||||||
override: [enhanceCompletion(props.language), completeAnyWord],
|
override: [enhanceCompletion(language), completeAnyWord],
|
||||||
}),
|
}),
|
||||||
getInitialExtension(),
|
getInitialExtension(),
|
||||||
])
|
])
|
||||||
@@ -85,12 +85,12 @@ const cleanupSyncResources = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const initSync = async () => {
|
const initSync = async () => {
|
||||||
if (!editorView.value || !props.problem || !isDesktop.value) return
|
if (!editorView.value || !problem || !isDesktop.value) return
|
||||||
|
|
||||||
cleanupSyncResources()
|
cleanupSyncResources()
|
||||||
|
|
||||||
cleanupSync = await startSync({
|
cleanupSync = await startSync({
|
||||||
problemId: props.problem,
|
problemId: problem,
|
||||||
editorView: editorView.value as EditorView,
|
editorView: editorView.value as EditorView,
|
||||||
onStatusChange: (status) => {
|
onStatusChange: (status) => {
|
||||||
// 处理需要断开同步的情况
|
// 处理需要断开同步的情况
|
||||||
@@ -108,13 +108,13 @@ const initSync = async () => {
|
|||||||
|
|
||||||
const handleEditorReady = (payload: EditorReadyPayload) => {
|
const handleEditorReady = (payload: EditorReadyPayload) => {
|
||||||
editorView.value = payload.view as EditorView
|
editorView.value = payload.view as EditorView
|
||||||
if (props.sync) {
|
if (sync) {
|
||||||
initSync()
|
initSync()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
() => props.sync,
|
() => sync,
|
||||||
(shouldSync) => {
|
(shouldSync) => {
|
||||||
if (shouldSync) {
|
if (shouldSync) {
|
||||||
initSync()
|
initSync()
|
||||||
@@ -125,9 +125,9 @@ watch(
|
|||||||
)
|
)
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
() => props.problem,
|
() => problem,
|
||||||
(newProblem, oldProblem) => {
|
(newProblem, oldProblem) => {
|
||||||
if (newProblem !== oldProblem && props.sync) {
|
if (newProblem !== oldProblem && sync) {
|
||||||
initSync()
|
initSync()
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -17,10 +17,7 @@ interface Props {
|
|||||||
const rawHtml = defineModel<string>("value")
|
const rawHtml = defineModel<string>("value")
|
||||||
type InsertFnType = (url: string, alt: string, href: string) => void
|
type InsertFnType = (url: string, alt: string, href: string) => void
|
||||||
|
|
||||||
const props = withDefaults(defineProps<Props>(), {
|
const { title, minHeight = 0, simple = false } = defineProps<Props>()
|
||||||
minHeight: 0,
|
|
||||||
simple: false,
|
|
||||||
})
|
|
||||||
|
|
||||||
const message = useMessage()
|
const message = useMessage()
|
||||||
|
|
||||||
@@ -112,17 +109,17 @@ async function customUpload(file: File, insertFn: InsertFnType) {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="title" v-if="props.title">{{ props.title }}</div>
|
<div class="title" v-if="title">{{ title }}</div>
|
||||||
<div class="editorWrapper">
|
<div class="editorWrapper">
|
||||||
<Toolbar
|
<Toolbar
|
||||||
class="toolbar"
|
class="toolbar"
|
||||||
:editor="toolbarEditorRef"
|
:editor="toolbarEditorRef"
|
||||||
:defaultConfig="props.simple ? toolbarConfigSimple : toolbarConfig"
|
:defaultConfig="simple ? toolbarConfigSimple : toolbarConfig"
|
||||||
mode="simple"
|
mode="simple"
|
||||||
/>
|
/>
|
||||||
<Editor
|
<Editor
|
||||||
@click="onClick"
|
@click="onClick"
|
||||||
:style="{ minHeight: props.minHeight + 'px' }"
|
:style="{ minHeight: minHeight + 'px' }"
|
||||||
v-model="rawHtml"
|
v-model="rawHtml"
|
||||||
:defaultConfig="editorConfig"
|
:defaultConfig="editorConfig"
|
||||||
mode="simple"
|
mode="simple"
|
||||||
|
|||||||
@@ -58,6 +58,15 @@ const options = computed<MenuOption[]>(() => {
|
|||||||
),
|
),
|
||||||
key: "admin problemset list",
|
key: "admin problemset list",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
label: () =>
|
||||||
|
h(
|
||||||
|
RouterLink,
|
||||||
|
{ to: "/admin/ai/reports" },
|
||||||
|
{ default: () => "AI报告" },
|
||||||
|
),
|
||||||
|
key: "admin ai reports",
|
||||||
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -132,6 +141,15 @@ const options = computed<MenuOption[]>(() => {
|
|||||||
),
|
),
|
||||||
key: "admin tutorial list",
|
key: "admin tutorial list",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
label: () =>
|
||||||
|
h(
|
||||||
|
RouterLink,
|
||||||
|
{ to: "/admin/ai/reports" },
|
||||||
|
{ default: () => "AI报告" },
|
||||||
|
),
|
||||||
|
key: "admin ai reports",
|
||||||
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -152,6 +170,7 @@ const active = computed(() => {
|
|||||||
if (path.startsWith("/admin/comment")) return "admin comment list"
|
if (path.startsWith("/admin/comment")) return "admin comment list"
|
||||||
if (path.startsWith("/admin/announcement")) return "admin announcement list"
|
if (path.startsWith("/admin/announcement")) return "admin announcement list"
|
||||||
if (path.startsWith("/admin/tutorial")) return "admin tutorial list"
|
if (path.startsWith("/admin/tutorial")) return "admin tutorial list"
|
||||||
|
if (path.startsWith("/admin/ai")) return "admin ai reports"
|
||||||
return route.name as string
|
return route.name as string
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import axios from "axios"
|
import axios, { type AxiosRequestConfig } from "axios"
|
||||||
import { createDiscreteApi } from "naive-ui"
|
import { createDiscreteApi } from "naive-ui"
|
||||||
import { useAuthModalStore } from "shared/store/authModal"
|
import { useAuthModalStore } from "shared/store/authModal"
|
||||||
import storage from "./storage"
|
import storage from "./storage"
|
||||||
@@ -6,13 +6,56 @@ import { STORAGE_KEY } from "./constants"
|
|||||||
|
|
||||||
const { message } = createDiscreteApi(["message"])
|
const { message } = createDiscreteApi(["message"])
|
||||||
|
|
||||||
const http = axios.create({
|
// 后端统一返回 { error, data } 信封;拦截器剥掉 axios 外层后,
|
||||||
|
// 调用方拿到的就是这个信封,data 才是真正的业务数据。
|
||||||
|
export interface ApiResponse<T = any> {
|
||||||
|
error: string | null
|
||||||
|
data: T
|
||||||
|
}
|
||||||
|
|
||||||
|
// 让 http.get<T>() 的类型真实反映"解包后返回信封"这件事,
|
||||||
|
// 调用方 res.data 直接拿到带类型的 T,不再依赖 axios 的 AxiosResponse 巧合对齐。
|
||||||
|
interface Http {
|
||||||
|
get<T = any>(
|
||||||
|
url: string,
|
||||||
|
config?: AxiosRequestConfig,
|
||||||
|
): Promise<ApiResponse<T>>
|
||||||
|
delete<T = any>(
|
||||||
|
url: string,
|
||||||
|
config?: AxiosRequestConfig,
|
||||||
|
): Promise<ApiResponse<T>>
|
||||||
|
post<T = any>(
|
||||||
|
url: string,
|
||||||
|
data?: unknown,
|
||||||
|
config?: AxiosRequestConfig,
|
||||||
|
): Promise<ApiResponse<T>>
|
||||||
|
put<T = any>(
|
||||||
|
url: string,
|
||||||
|
data?: unknown,
|
||||||
|
config?: AxiosRequestConfig,
|
||||||
|
): Promise<ApiResponse<T>>
|
||||||
|
}
|
||||||
|
|
||||||
|
const instance = axios.create({
|
||||||
baseURL: "/api",
|
baseURL: "/api",
|
||||||
xsrfHeaderName: "X-CSRFToken",
|
xsrfHeaderName: "X-CSRFToken",
|
||||||
xsrfCookieName: "csrftoken",
|
xsrfCookieName: "csrftoken",
|
||||||
})
|
})
|
||||||
|
|
||||||
http.interceptors.response.use(
|
// 统一剥掉空字符串 / null / undefined 的 query 参数,
|
||||||
|
// 各 api 函数不必再手写过滤逻辑(保留 0、false)。
|
||||||
|
instance.interceptors.request.use((config) => {
|
||||||
|
if (config.params) {
|
||||||
|
config.params = Object.fromEntries(
|
||||||
|
Object.entries(config.params).filter(
|
||||||
|
([, v]) => v !== "" && v !== null && v !== undefined,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return config
|
||||||
|
})
|
||||||
|
|
||||||
|
instance.interceptors.response.use(
|
||||||
(res) => {
|
(res) => {
|
||||||
if (res.data.error) {
|
if (res.data.error) {
|
||||||
if (res.data.error === "login-required") {
|
if (res.data.error === "login-required") {
|
||||||
@@ -31,4 +74,6 @@ http.interceptors.response.use(
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const http = instance as unknown as Http
|
||||||
|
|
||||||
export default http
|
export default http
|
||||||
|
|||||||
@@ -33,7 +33,11 @@ export interface Profile {
|
|||||||
submission_number: number
|
submission_number: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export type UserAdminType = "Regular User" | "Student Admin" | "Teacher Admin" | "Super Admin"
|
export type UserAdminType =
|
||||||
|
| "Regular User"
|
||||||
|
| "Student Admin"
|
||||||
|
| "Teacher Admin"
|
||||||
|
| "Super Admin"
|
||||||
|
|
||||||
export interface User {
|
export interface User {
|
||||||
id: number
|
id: number
|
||||||
|
|||||||
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