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"
/>
</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>

View File

@@ -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
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 }}
</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
? "全部"

View File

@@ -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="出题权限"
>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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