update
This commit is contained in:
@@ -8,8 +8,14 @@
|
||||
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-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"
|
||||
@@ -30,13 +36,24 @@
|
||||
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">
|
||||
<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-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" />
|
||||
@@ -51,7 +68,12 @@ 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 {
|
||||
getAIReportList,
|
||||
getAIReportDetail,
|
||||
pinAIReport,
|
||||
getPinnedAIReports,
|
||||
} from "../api"
|
||||
import { NButton, NTag } from "naive-ui"
|
||||
|
||||
interface ReportItem {
|
||||
@@ -83,7 +105,11 @@ const columns: DataTableColumn<ReportItem>[] = [
|
||||
key: "username",
|
||||
width: 150,
|
||||
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 分析内容",
|
||||
@@ -111,7 +137,11 @@ const columns: DataTableColumn<ReportItem>[] = [
|
||||
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: "primary", onClick: () => openDetail(row.id) },
|
||||
() => "查看",
|
||||
),
|
||||
h(
|
||||
NButton,
|
||||
{
|
||||
@@ -156,7 +186,10 @@ async function openDetail(id: number) {
|
||||
|
||||
onMounted(() => Promise.all([listReports(), loadPinnedReports()]))
|
||||
watch(() => [query.page, query.limit], listReports)
|
||||
watchDebounced(() => query.username, listReports, { debounce: 500, maxWait: 1000 })
|
||||
watchDebounced(() => query.username, listReports, {
|
||||
debounce: 500,
|
||||
maxWait: 1000,
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import http from "utils/http"
|
||||
import { toProblemListItem } from "admin/transforms"
|
||||
import type {
|
||||
AdminProblem,
|
||||
Announcement,
|
||||
@@ -30,30 +31,21 @@ export async function getProblemList(
|
||||
contestID?: string,
|
||||
) {
|
||||
const endpoint = !!contestID ? "admin/contest/problem" : "admin/problem"
|
||||
const res = await http.get(endpoint, {
|
||||
params: {
|
||||
paging: true,
|
||||
offset,
|
||||
limit,
|
||||
keyword,
|
||||
author,
|
||||
contest_id: contestID,
|
||||
const res = await http.get<{ results: AdminProblem[]; total: number }>(
|
||||
endpoint,
|
||||
{
|
||||
params: {
|
||||
paging: true,
|
||||
offset,
|
||||
limit,
|
||||
keyword,
|
||||
author,
|
||||
contest_id: contestID,
|
||||
},
|
||||
},
|
||||
})
|
||||
)
|
||||
return {
|
||||
results: res.data.results.map((result: AdminProblem) => ({
|
||||
id: result.id,
|
||||
_id: result._id,
|
||||
title: result.title,
|
||||
username: result.created_by.username,
|
||||
create_time: result.create_time,
|
||||
visible: result.visible,
|
||||
difficulty: result.difficulty,
|
||||
tags: result.tags,
|
||||
has_ast_rules: result.has_ast_rules,
|
||||
allow_flowchart: result.allow_flowchart,
|
||||
show_flowchart: result.show_flowchart,
|
||||
})),
|
||||
results: res.data.results.map(toProblemListItem),
|
||||
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> {
|
||||
const form = new window.FormData()
|
||||
form.append("image", file)
|
||||
const res: { success: boolean; file_path: string; msg: "Success" } =
|
||||
await http.post("admin/upload_image", form, {
|
||||
headers: { "content-type": "multipart/form-data" },
|
||||
})
|
||||
// 该端点不走 { error, data } 信封,直接返回上传结果
|
||||
const res = (await http.post("admin/upload_image", form, {
|
||||
headers: { "content-type": "multipart/form-data" },
|
||||
})) as unknown as { success: boolean; file_path: string; msg: "Success" }
|
||||
return res.success ? res.file_path : ""
|
||||
}
|
||||
|
||||
@@ -244,17 +236,17 @@ export function deleteComment(id: number) {
|
||||
}
|
||||
|
||||
export async function getTutorialList() {
|
||||
const res = await http.get("admin/tutorial")
|
||||
const res = await http.get<Tutorial[]>("admin/tutorial")
|
||||
return res.data
|
||||
}
|
||||
|
||||
export async function getTutorial(id: number) {
|
||||
const res = await http.get("admin/tutorial", { params: { id } })
|
||||
const res = await http.get<Tutorial>("admin/tutorial", { params: { id } })
|
||||
return res.data
|
||||
}
|
||||
|
||||
export async function createTutorial(data: Partial<Tutorial>) {
|
||||
const res = await http.post("admin/tutorial", data)
|
||||
const res = await http.post<Tutorial>("admin/tutorial", data)
|
||||
return res.data
|
||||
}
|
||||
|
||||
@@ -272,10 +264,10 @@ export function setTutorialVisibility(id: number, is_public: boolean) {
|
||||
}
|
||||
|
||||
export async function getAdminExercises(tutorialId: number) {
|
||||
const res = await http.get("admin/exercise", {
|
||||
const res = await http.get<Exercise[]>("admin/exercise", {
|
||||
params: { tutorial_id: tutorialId },
|
||||
})
|
||||
return res.data as Exercise[]
|
||||
return res.data
|
||||
}
|
||||
|
||||
export async function createExercise(data: {
|
||||
@@ -284,8 +276,8 @@ export async function createExercise(data: {
|
||||
data: object
|
||||
order: number
|
||||
}) {
|
||||
const res = await http.post("admin/exercise", data)
|
||||
return res.data as Exercise
|
||||
const res = await http.post<Exercise>("admin/exercise", data)
|
||||
return res.data
|
||||
}
|
||||
|
||||
export async function updateExercise(data: {
|
||||
|
||||
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 }}
|
||||
</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
|
||||
? "全部"
|
||||
|
||||
@@ -314,7 +314,11 @@ watch(() => [query.page, query.limit, query.type, query.orderBy], listUsers)
|
||||
<n-input v-model:value="password" />
|
||||
</n-form-item-gi>
|
||||
<n-form-item-gi
|
||||
v-if="!create && (userEditing.admin_type === USER_TYPE.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"
|
||||
label="出题权限"
|
||||
>
|
||||
|
||||
@@ -65,9 +65,7 @@ router.beforeEach(async (to, from, next) => {
|
||||
next("/")
|
||||
return
|
||||
}
|
||||
} else if (
|
||||
to.matched.some((record) => record.meta.requiresTeacherAdmin)
|
||||
) {
|
||||
} else if (to.matched.some((record) => record.meta.requiresTeacherAdmin)) {
|
||||
if (!userStore.isTeacherOrAbove) {
|
||||
next("/")
|
||||
return
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { DIFFICULTY } from "utils/constants"
|
||||
import { getACRate } from "utils/functions"
|
||||
import http from "utils/http"
|
||||
import {
|
||||
import { filterResult } from "oj/transforms"
|
||||
import type {
|
||||
Exercise,
|
||||
Problem,
|
||||
Submission,
|
||||
@@ -9,31 +8,6 @@ import {
|
||||
SubmitCodePayload,
|
||||
} from "utils/types"
|
||||
|
||||
function filterResult(result: Problem) {
|
||||
const newResult = {
|
||||
id: result.id,
|
||||
_id: result._id,
|
||||
title: result.title,
|
||||
difficulty: DIFFICULTY[result.difficulty],
|
||||
tags: result.tags,
|
||||
submission: result.submission_number,
|
||||
rate: getACRate(result.accepted_number, result.submission_number),
|
||||
status: "",
|
||||
author: result.created_by.username,
|
||||
allow_flowchart: result.allow_flowchart,
|
||||
show_flowchart: result.show_flowchart,
|
||||
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() {
|
||||
return http.get("website")
|
||||
}
|
||||
@@ -43,17 +17,9 @@ export async function getProblemList(
|
||||
limit = 10,
|
||||
searchParams: any = {},
|
||||
) {
|
||||
let params: any = {
|
||||
paging: true,
|
||||
offset,
|
||||
limit,
|
||||
}
|
||||
Object.keys(searchParams).forEach((element) => {
|
||||
if (searchParams[element]) {
|
||||
params[element] = searchParams[element]
|
||||
}
|
||||
const res = await http.get<{ results: Problem[]; total: number }>("problem", {
|
||||
params: { paging: true, offset, limit, ...searchParams },
|
||||
})
|
||||
const res = await http.get("problem", { params })
|
||||
return {
|
||||
results: res.data.results.map(filterResult),
|
||||
total: res.data.total,
|
||||
@@ -203,7 +169,7 @@ export function checkContestPassword(contestID: string, password: string) {
|
||||
}
|
||||
|
||||
export async function getContestProblems(contestID: string) {
|
||||
const res = await http.get("contest/problem", {
|
||||
const res = await http.get<Problem[]>("contest/problem", {
|
||||
params: { contest_id: contestID },
|
||||
})
|
||||
return res.data.map(filterResult)
|
||||
@@ -460,7 +426,7 @@ export function getProblemSetUserProgress(
|
||||
}
|
||||
|
||||
export async function getExercises(tutorialId: number): Promise<Exercise[]> {
|
||||
const res = await http.get("exercises", {
|
||||
const res = await http.get<Exercise[]>("exercises", {
|
||||
params: { tutorial_id: tutorialId },
|
||||
})
|
||||
return res.data
|
||||
|
||||
@@ -164,7 +164,8 @@ async function analyzeWithAI() {
|
||||
aiController = controller
|
||||
|
||||
const timeRangeLabel =
|
||||
timeRangeOptions.find((o) => o.value === duration.value)?.label ?? "全部时间"
|
||||
timeRangeOptions.find((o) => o.value === duration.value)?.label ??
|
||||
"全部时间"
|
||||
|
||||
showAIModal.value = true
|
||||
aiContent.value = ""
|
||||
@@ -195,7 +196,11 @@ async function analyzeWithAI() {
|
||||
if (event === "end" && !hasStarted) aiLoading.value = false
|
||||
},
|
||||
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 (!hasStarted) {
|
||||
hasStarted = true
|
||||
@@ -1176,7 +1181,6 @@ const radarChartOptions = {
|
||||
</n-gi>
|
||||
</n-grid>
|
||||
</n-card>
|
||||
|
||||
</template>
|
||||
|
||||
<!-- 对比表格 -->
|
||||
@@ -1185,10 +1189,7 @@ const radarChartOptions = {
|
||||
title="对比表格"
|
||||
style="margin-top: 20px"
|
||||
>
|
||||
<n-data-table
|
||||
:data="comparisons"
|
||||
:columns="tableColumns"
|
||||
/>
|
||||
<n-data-table :data="comparisons" :columns="tableColumns" />
|
||||
</n-card>
|
||||
</n-flex>
|
||||
</n-card>
|
||||
|
||||
@@ -85,16 +85,14 @@ function inputWidth(idx: number): string {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<n-card
|
||||
style="margin: 16px 0; border: 1.5px solid var(--n-border-color)"
|
||||
>
|
||||
<n-card style="margin: 16px 0; border: 1.5px solid var(--n-border-color)">
|
||||
<template #header>
|
||||
<n-tag type="warning" :bordered="false"
|
||||
>练一练 · 代码填空</n-tag
|
||||
>
|
||||
<n-tag type="warning" :bordered="false">练一练 · 代码填空</n-tag>
|
||||
</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
|
||||
:style="{
|
||||
@@ -147,11 +145,7 @@ function inputWidth(idx: number): string {
|
||||
/>
|
||||
|
||||
<n-space style="margin-top: 12px" :size="8">
|
||||
<n-button
|
||||
type="warning"
|
||||
:disabled="allCorrect"
|
||||
@click="submit"
|
||||
>
|
||||
<n-button type="warning" :disabled="allCorrect" @click="submit">
|
||||
提交
|
||||
</n-button>
|
||||
<n-button @click="reset">重置</n-button>
|
||||
|
||||
@@ -63,9 +63,7 @@ function optionType(idx: number): "default" | "primary" | "success" {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<n-card
|
||||
style="margin: 16px 0; border: 1.5px solid var(--n-border-color)"
|
||||
>
|
||||
<n-card style="margin: 16px 0; border: 1.5px solid var(--n-border-color)">
|
||||
<template #header>
|
||||
<n-space align="center" :size="8">
|
||||
<n-tag type="success" :bordered="false">
|
||||
@@ -74,7 +72,9 @@ function optionType(idx: number): "default" | "primary" | "success" {
|
||||
</n-space>
|
||||
</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-button
|
||||
|
||||
@@ -101,16 +101,14 @@ const lineHtmlMap = computed<Record<number, string>>(() => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<n-card
|
||||
style="margin: 16px 0; border: 1.5px solid var(--n-border-color)"
|
||||
>
|
||||
<n-card style="margin: 16px 0; border: 1.5px solid var(--n-border-color)">
|
||||
<template #header>
|
||||
<n-tag type="info" :bordered="false"
|
||||
>练一练 · 代码排序</n-tag
|
||||
>
|
||||
<n-tag type="info" :bordered="false">练一练 · 代码排序</n-tag>
|
||||
</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">
|
||||
<div
|
||||
@@ -157,11 +155,7 @@ const lineHtmlMap = computed<Record<number, string>>(() => {
|
||||
/>
|
||||
|
||||
<n-space style="margin-top: 12px" :size="8">
|
||||
<n-button
|
||||
type="info"
|
||||
:disabled="submitted && allCorrect"
|
||||
@click="submit"
|
||||
>
|
||||
<n-button type="info" :disabled="submitted && allCorrect" @click="submit">
|
||||
提交
|
||||
</n-button>
|
||||
<n-button @click="reset">重置</n-button>
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
<template>
|
||||
<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-card title="教程目录" :bordered="false" size="small">
|
||||
<n-list hoverable clickable>
|
||||
@@ -51,7 +56,11 @@
|
||||
class="code-card"
|
||||
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-gi>
|
||||
</n-grid>
|
||||
|
||||
@@ -514,42 +514,71 @@ watch(
|
||||
<n-modal
|
||||
v-model:show="showClassDetailModal"
|
||||
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' }"
|
||||
>
|
||||
<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">
|
||||
<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">
|
||||
<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">
|
||||
<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">
|
||||
<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">
|
||||
<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>
|
||||
@@ -559,43 +588,88 @@ watch(
|
||||
|
||||
<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)">
|
||||
<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 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 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 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 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 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 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 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>
|
||||
|
||||
<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
|
||||
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
|
||||
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
|
||||
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>
|
||||
@@ -606,7 +680,11 @@ watch(
|
||||
</n-tag>
|
||||
</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-modal>
|
||||
</template>
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import { DetailsData, DurationData } from "utils/types"
|
||||
import { consumeJSONEventStream } from "utils/stream"
|
||||
import { getAIDetailData, getAIDurationData, getAIHeatmapData, getAIPinnedReport } from "../api"
|
||||
import {
|
||||
getAIDetailData,
|
||||
getAIDurationData,
|
||||
getAIHeatmapData,
|
||||
getAIPinnedReport,
|
||||
} from "../api"
|
||||
import { getCSRFToken } from "utils/functions"
|
||||
|
||||
export const useAIStore = defineStore("ai", () => {
|
||||
|
||||
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
|
||||
}
|
||||
@@ -169,10 +169,7 @@ import {
|
||||
BarElement,
|
||||
CategoryScale,
|
||||
} from "chart.js"
|
||||
import {
|
||||
WordCloudController,
|
||||
WordElement,
|
||||
} from "chartjs-chart-wordcloud"
|
||||
import { WordCloudController, WordElement } from "chartjs-chart-wordcloud"
|
||||
|
||||
ChartJS.register(
|
||||
ArcElement,
|
||||
@@ -332,16 +329,16 @@ const gradeChartData = 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 {
|
||||
labels: ["已完成", "未完成"],
|
||||
datasets: [
|
||||
{
|
||||
data: [data.completed_count, uncompleted],
|
||||
backgroundColor: [
|
||||
"rgba(106, 176, 76, 0.6)",
|
||||
"rgba(255, 159, 64, 0.6)",
|
||||
],
|
||||
backgroundColor: ["rgba(106, 176, 76, 0.6)", "rgba(255, 159, 64, 0.6)"],
|
||||
borderColor: ["rgba(106, 176, 76, 1)", "rgba(255, 159, 64, 1)"],
|
||||
borderWidth: 2,
|
||||
},
|
||||
@@ -490,9 +487,7 @@ function renderWordCloud() {
|
||||
{
|
||||
label: "",
|
||||
data: words.map((w) => 10 + (w.count / maxCount) * 50),
|
||||
color: words.map(
|
||||
(_, i) => WORD_COLORS[i % WORD_COLORS.length],
|
||||
),
|
||||
color: words.map((_, i) => WORD_COLORS[i % WORD_COLORS.length]),
|
||||
rotate: words.map(() => 0),
|
||||
} as any,
|
||||
],
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import axios from "axios"
|
||||
import axios, { type AxiosRequestConfig } from "axios"
|
||||
import { createDiscreteApi } from "naive-ui"
|
||||
import { useAuthModalStore } from "shared/store/authModal"
|
||||
import storage from "./storage"
|
||||
@@ -6,13 +6,56 @@ import { STORAGE_KEY } from "./constants"
|
||||
|
||||
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",
|
||||
xsrfHeaderName: "X-CSRFToken",
|
||||
xsrfCookieName: "csrftoken",
|
||||
})
|
||||
|
||||
http.interceptors.response.use(
|
||||
// 统一剥掉空字符串 / null / undefined 的 query 参数,
|
||||
// 各 api 函数不必再手写过滤逻辑(保留 0、false)。
|
||||
instance.interceptors.request.use((config) => {
|
||||
if (config.params) {
|
||||
config.params = Object.fromEntries(
|
||||
Object.entries(config.params).filter(
|
||||
([, v]) => v !== "" && v !== null && v !== undefined,
|
||||
),
|
||||
)
|
||||
}
|
||||
return config
|
||||
})
|
||||
|
||||
instance.interceptors.response.use(
|
||||
(res) => {
|
||||
if (res.data.error) {
|
||||
if (res.data.error === "login-required") {
|
||||
@@ -31,4 +74,6 @@ http.interceptors.response.use(
|
||||
},
|
||||
)
|
||||
|
||||
const http = instance as unknown as Http
|
||||
|
||||
export default http
|
||||
|
||||
@@ -33,7 +33,11 @@ export interface Profile {
|
||||
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 {
|
||||
id: number
|
||||
|
||||
Reference in New Issue
Block a user