update
Some checks failed
Deploy / deploy (build, debian, 22, /root/OJDeploy/data/clientnext) (push) Has been cancelled
Deploy / deploy (build:staging, school, 8822, /root/OJ/data/dist) (push) Has been cancelled

This commit is contained in:
2026-06-05 09:03:38 -06:00
parent 324e85d2c0
commit f9d7c2ff92
18 changed files with 335 additions and 164 deletions

View File

@@ -8,8 +8,14 @@
style="width: 200px" style="width: 200px"
/> />
</n-flex> </n-flex>
<n-alert v-if="pinnedReports.length > 0" type="warning" :show-icon="true" style="margin-bottom: 12px"> <n-alert
以下 <strong>{{ pinnedReports.length }}</strong> 位用户的 AI 分析报告已被锁定前台将固定显示该报告 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-flex style="margin-top: 8px" :wrap="true" :size="[8, 6]">
<n-tag <n-tag
v-for="r in pinnedReports" v-for="r in pinnedReports"
@@ -30,13 +36,24 @@
v-model:page="query.page" v-model:page="query.page"
/> />
<n-modal v-model:show="showModal" preset="card" title="分析报告详情" style="width: 800px; max-width: 95vw"> <n-modal
v-model:show="showModal"
preset="card"
title="分析报告详情"
style="width: 800px; max-width: 95vw"
>
<n-spin :show="loadingDetail"> <n-spin :show="loadingDetail">
<div v-if="detail" class="detail"> <div v-if="detail" class="detail">
<n-descriptions :column="2" bordered size="small" class="meta"> <n-descriptions :column="2" bordered size="small" class="meta">
<n-descriptions-item label="用户">{{ detail.username }}</n-descriptions-item> <n-descriptions-item label="用户">{{
<n-descriptions-item label="班级">{{ detail.class_name || "-" }}</n-descriptions-item> detail.username
<n-descriptions-item label="时间" :span="2">{{ parseTime(detail.create_time, "YYYY-MM-DD HH:mm:ss") }}</n-descriptions-item> }}</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-descriptions>
<n-scrollbar style="max-height: 60vh; margin-top: 12px"> <n-scrollbar style="max-height: 60vh; margin-top: 12px">
<MdPreview :model-value="detail.analysis" /> <MdPreview :model-value="detail.analysis" />
@@ -51,7 +68,12 @@ import { MdPreview } from "md-editor-v3"
import "md-editor-v3/lib/preview.css" import "md-editor-v3/lib/preview.css"
import Pagination from "shared/components/Pagination.vue" import Pagination from "shared/components/Pagination.vue"
import { parseTime } from "utils/functions" import { parseTime } from "utils/functions"
import { getAIReportList, getAIReportDetail, pinAIReport, getPinnedAIReports } from "../api" import {
getAIReportList,
getAIReportDetail,
pinAIReport,
getPinnedAIReports,
} from "../api"
import { NButton, NTag } from "naive-ui" import { NButton, NTag } from "naive-ui"
interface ReportItem { interface ReportItem {
@@ -83,7 +105,11 @@ const columns: DataTableColumn<ReportItem>[] = [
key: "username", key: "username",
width: 150, width: 150,
render: (row) => render: (row) =>
h("span", { style: row.is_pinned ? "font-weight:600" : "" }, row.username), h(
"span",
{ style: row.is_pinned ? "font-weight:600" : "" },
row.username,
),
}, },
{ {
title: "AI 分析内容", title: "AI 分析内容",
@@ -111,7 +137,11 @@ const columns: DataTableColumn<ReportItem>[] = [
width: 160, width: 160,
render: (row) => render: (row) =>
h("span", { style: "display:flex;gap:8px" }, [ h("span", { style: "display:flex;gap:8px" }, [
h(NButton, { size: "small", type: "primary", onClick: () => openDetail(row.id) }, () => "查看"), h(
NButton,
{ size: "small", type: "primary", onClick: () => openDetail(row.id) },
() => "查看",
),
h( h(
NButton, NButton,
{ {
@@ -156,7 +186,10 @@ async function openDetail(id: number) {
onMounted(() => Promise.all([listReports(), loadPinnedReports()])) onMounted(() => Promise.all([listReports(), loadPinnedReports()]))
watch(() => [query.page, query.limit], listReports) watch(() => [query.page, query.limit], listReports)
watchDebounced(() => query.username, listReports, { debounce: 500, maxWait: 1000 }) watchDebounced(() => query.username, listReports, {
debounce: 500,
maxWait: 1000,
})
</script> </script>
<style scoped> <style scoped>

View File

@@ -1,4 +1,5 @@
import http from "utils/http" import http from "utils/http"
import { toProblemListItem } from "admin/transforms"
import type { import type {
AdminProblem, AdminProblem,
Announcement, Announcement,
@@ -30,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: {

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

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

View File

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

View File

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

View File

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

View File

@@ -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,
@@ -203,7 +169,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)
@@ -460,7 +426,7 @@ export function getProblemSetUserProgress(
} }
export async function getExercises(tutorialId: number): Promise<Exercise[]> { export async function getExercises(tutorialId: number): Promise<Exercise[]> {
const res = await http.get("exercises", { const res = await http.get<Exercise[]>("exercises", {
params: { tutorial_id: tutorialId }, params: { tutorial_id: tutorialId },
}) })
return res.data return res.data

View File

@@ -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>

View File

@@ -85,16 +85,14 @@ 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="{
@@ -147,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>

View File

@@ -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

View File

@@ -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>

View File

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

View File

@@ -514,42 +514,71 @@ watch(
<n-modal <n-modal
v-model:show="showClassDetailModal" v-model:show="showClassDetailModal"
preset="card" preset="card"
:title="classDetailData ? `${classDetailData.class_name.slice(0, 2)}计算机${classDetailData.class_name.slice(2)}班` : '班级详情'" :title="
classDetailData
? `${classDetailData.class_name.slice(0, 2)}计算机${classDetailData.class_name.slice(2)}班`
: '班级详情'
"
:style="{ width: '700px', maxWidth: '95vw' }" :style="{ width: '700px', maxWidth: '95vw' }"
> >
<n-spin :show="classDetailLoading" style="min-height: 200px"> <n-spin :show="classDetailLoading" style="min-height: 200px">
<n-flex v-if="classDetailData" vertical :size="12"> <n-flex v-if="classDetailData" vertical :size="12">
<n-grid :cols="5" :x-gap="8" responsive="screen"> <n-grid :cols="5" :x-gap="8" responsive="screen">
<n-gi> <n-gi>
<n-statistic label="总AC数" :value="classDetailData.total_ac" size="large" class="stat-total-ac"> <n-statistic
label="总AC数"
:value="classDetailData.total_ac"
size="large"
class="stat-total-ac"
>
<template #suffix> <template #suffix>
<Icon icon="streamline-emojis:raised-fist-1" width="20" /> <Icon icon="streamline-emojis:raised-fist-1" width="20" />
</template> </template>
</n-statistic> </n-statistic>
</n-gi> </n-gi>
<n-gi> <n-gi>
<n-statistic label="平均AC数" :value="classDetailData.avg_ac.toFixed(2)" size="large" class="stat-avg-ac"> <n-statistic
label="平均AC数"
:value="classDetailData.avg_ac.toFixed(2)"
size="large"
class="stat-avg-ac"
>
<template #suffix> <template #suffix>
<Icon icon="streamline-emojis:chart" width="20" /> <Icon icon="streamline-emojis:chart" width="20" />
</template> </template>
</n-statistic> </n-statistic>
</n-gi> </n-gi>
<n-gi> <n-gi>
<n-statistic label="中位数AC数" :value="classDetailData.median_ac.toFixed(2)" size="large" class="stat-median-ac"> <n-statistic
label="中位数AC数"
:value="classDetailData.median_ac.toFixed(2)"
size="large"
class="stat-median-ac"
>
<template #suffix> <template #suffix>
<Icon icon="streamline-emojis:target" width="20" /> <Icon icon="streamline-emojis:target" width="20" />
</template> </template>
</n-statistic> </n-statistic>
</n-gi> </n-gi>
<n-gi> <n-gi>
<n-statistic label="总提交数" :value="classDetailData.total_submission" size="large" class="stat-total-submission"> <n-statistic
label="总提交数"
:value="classDetailData.total_submission"
size="large"
class="stat-total-submission"
>
<template #suffix> <template #suffix>
<Icon icon="streamline-emojis:paper" width="20" /> <Icon icon="streamline-emojis:paper" width="20" />
</template> </template>
</n-statistic> </n-statistic>
</n-gi> </n-gi>
<n-gi> <n-gi>
<n-statistic label="AC率" :value="classDetailData.ac_rate.toFixed(1) + '%'" size="large" class="stat-ac-rate"> <n-statistic
label="AC率"
:value="classDetailData.ac_rate.toFixed(1) + '%'"
size="large"
class="stat-ac-rate"
>
<template #suffix> <template #suffix>
<Icon icon="streamline-emojis:check-mark" width="20" /> <Icon icon="streamline-emojis:check-mark" width="20" />
</template> </template>
@@ -559,43 +588,88 @@ watch(
<n-divider style="margin: 12px 0" /> <n-divider style="margin: 12px 0" />
<n-descriptions bordered :column="2" size="small" label-placement="left"> <n-descriptions
bordered
:column="2"
size="small"
label-placement="left"
>
<n-descriptions-item label="第一四分位数(Q1)"> <n-descriptions-item label="第一四分位数(Q1)">
<span style="color: #9254de; font-weight: 500">{{ classDetailData.q1_ac.toFixed(2) }}</span> <span style="color: #9254de; font-weight: 500">{{
classDetailData.q1_ac.toFixed(2)
}}</span>
</n-descriptions-item> </n-descriptions-item>
<n-descriptions-item label="第三四分位数(Q3)"> <n-descriptions-item label="第三四分位数(Q3)">
<span style="color: #f759ab; font-weight: 500">{{ classDetailData.q3_ac.toFixed(2) }}</span> <span style="color: #f759ab; font-weight: 500">{{
classDetailData.q3_ac.toFixed(2)
}}</span>
</n-descriptions-item> </n-descriptions-item>
<n-descriptions-item label="四分位距(IQR)"> <n-descriptions-item label="四分位距(IQR)">
<span style="color: #13c2c2; font-weight: 500">{{ classDetailData.iqr.toFixed(2) }}</span> <span style="color: #13c2c2; font-weight: 500">{{
classDetailData.iqr.toFixed(2)
}}</span>
</n-descriptions-item> </n-descriptions-item>
<n-descriptions-item label="标准差"> <n-descriptions-item label="标准差">
<span style="color: #fa8c16; font-weight: 500">{{ classDetailData.std_dev.toFixed(2) }}</span> <span style="color: #fa8c16; font-weight: 500">{{
classDetailData.std_dev.toFixed(2)
}}</span>
</n-descriptions-item> </n-descriptions-item>
<n-descriptions-item label="前10%均值"> <n-descriptions-item label="前10%均值">
<span style="color: #cf1322; font-weight: 600">{{ classDetailData.top_10_avg.toFixed(2) }}</span> <span style="color: #cf1322; font-weight: 600">{{
classDetailData.top_10_avg.toFixed(2)
}}</span>
</n-descriptions-item> </n-descriptions-item>
<n-descriptions-item label="中间80%均值"> <n-descriptions-item label="中间80%均值">
<span style="color: #389e0d; font-weight: 600">{{ classDetailData.middle_80_avg.toFixed(2) }}</span> <span style="color: #389e0d; font-weight: 600">{{
classDetailData.middle_80_avg.toFixed(2)
}}</span>
</n-descriptions-item> </n-descriptions-item>
<n-descriptions-item label="后10%均值"> <n-descriptions-item label="后10%均值">
<span style="color: #096dd9; font-weight: 500">{{ classDetailData.bottom_10_avg.toFixed(2) }}</span> <span style="color: #096dd9; font-weight: 500">{{
classDetailData.bottom_10_avg.toFixed(2)
}}</span>
</n-descriptions-item> </n-descriptions-item>
<n-descriptions-item label="人数"> <n-descriptions-item label="人数">
<span style="color: #1890ff; font-weight: 600">{{ classDetailData.user_count }}</span> <span style="color: #1890ff; font-weight: 600">{{
classDetailData.user_count
}}</span>
</n-descriptions-item> </n-descriptions-item>
</n-descriptions> </n-descriptions>
<n-card size="small" title="比率统计" embedded style="margin-top: 12px"> <n-card size="small" title="比率统计" embedded style="margin-top: 12px">
<n-space vertical :size="10"> <n-space vertical :size="10">
<n-progress type="line" :percentage="classDetailData.excellent_rate" :show-indicator="true" :border-radius="4"> <n-progress
<template #default>优秀率: {{ classDetailData.excellent_rate.toFixed(1) }}%</template> type="line"
:percentage="classDetailData.excellent_rate"
:show-indicator="true"
:border-radius="4"
>
<template #default
>优秀率:
{{ classDetailData.excellent_rate.toFixed(1) }}%</template
>
</n-progress> </n-progress>
<n-progress type="line" :percentage="classDetailData.pass_rate" :show-indicator="true" :border-radius="4" status="success"> <n-progress
<template #default>及格率: {{ classDetailData.pass_rate.toFixed(1) }}%</template> 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>
<n-progress type="line" :percentage="classDetailData.active_rate" :show-indicator="true" :border-radius="4" status="info"> <n-progress
<template #default>参与度: {{ classDetailData.active_rate.toFixed(1) }}%</template> 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-progress>
</n-space> </n-space>
</n-card> </n-card>
@@ -606,7 +680,11 @@ watch(
</n-tag> </n-tag>
</n-flex> </n-flex>
</n-flex> </n-flex>
<n-empty v-else-if="!classDetailLoading" description="暂无数据" style="padding: 40px 0" /> <n-empty
v-else-if="!classDetailLoading"
description="暂无数据"
style="padding: 40px 0"
/>
</n-spin> </n-spin>
</n-modal> </n-modal>
</template> </template>

View File

@@ -1,6 +1,11 @@
import { DetailsData, DurationData } from "utils/types" import { DetailsData, DurationData } from "utils/types"
import { consumeJSONEventStream } from "utils/stream" import { consumeJSONEventStream } from "utils/stream"
import { getAIDetailData, getAIDurationData, getAIHeatmapData, getAIPinnedReport } 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", () => {

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

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

View File

@@ -169,10 +169,7 @@ import {
BarElement, BarElement,
CategoryScale, 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,
@@ -332,16 +329,16 @@ const gradeChartData = computed(() => {
}) })
const completionChartData = computed(() => { const completionChartData = computed(() => {
const uncompleted = Math.max(0, adjustedPersonCount.value - 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,
}, },
@@ -490,9 +487,7 @@ 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: words.map(() => 0), rotate: words.map(() => 0),
} as any, } as any,
], ],

View File

@@ -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

View File

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