add raw data for radar
Some checks failed
Deploy / deploy (push) Has been cancelled

This commit is contained in:
2026-01-04 09:19:40 +08:00
parent a4ebf245c4
commit fab689ffdf
8 changed files with 551 additions and 399 deletions

View File

@@ -8,4 +8,4 @@ body {
.md-editor-dark div.vuepress-theme {
--md-theme-color: var(--n-text-color) !important;
}
}

View File

@@ -143,7 +143,11 @@ export function getActivityRank(start: string) {
})
}
export function getClassRank(offset: number, limit: number, grade?: number | null) {
export function getClassRank(
offset: number,
limit: number,
grade?: number | null,
) {
return http.get("class_rank", {
params: { offset, limit, grade },
})
@@ -155,7 +159,11 @@ export function getUserClassRank(offset: number, limit: number) {
})
}
export function getClassPK(classNames: string[], startTime?: string, endTime?: string) {
export function getClassPK(
classNames: string[],
startTime?: string,
endTime?: string,
) {
const payload: any = {
class_name: classNames,
}

View File

@@ -1,121 +1,120 @@
<script setup lang="ts">
import { getUserClassRank } from "oj/api"
import { useUserStore } from "shared/store/user"
import Pagination from "shared/components/Pagination.vue"
import { renderTableTitle } from "utils/renders"
import { NButton } from "naive-ui"
const userStore = useUserStore()
const router = useRouter()
const message = useMessage()
interface UserRank {
rank: number
username: string
accepted_number: number
submission_number: number
}
const myRank = ref(-1)
const totalUsers = ref(0)
const className = ref("")
const data = ref<UserRank[]>([])
const total = ref(0)
const query = reactive({
limit: 20,
page: 1,
})
const columns: DataTableColumn<UserRank>[] = [
{
title: renderTableTitle("排名", "streamline-emojis:flexed-biceps-1"),
key: "rank",
width: 100,
align: "center",
render: (row) => {
if (row.rank === 1) return "🥇"
if (row.rank === 2) return "🥈"
if (row.rank === 3) return "🥉"
return row.rank
},
},
{
title: renderTableTitle(
"用户名",
"streamline-emojis:smiling-face-with-sunglasses",
),
key: "username",
width: 200,
render: (row) =>
h(
NButton,
{
text: true,
type: "info",
onClick: () => router.push("/user?name=" + row.username),
},
() => row.username,
),
},
{
title: renderTableTitle("AC数", "streamline-emojis:raised-fist-1"),
key: "accepted_number",
width: 120,
align: "center",
},
{
title: renderTableTitle("提交数", "streamline-emojis:rocket"),
key: "submission_number",
width: 120,
align: "center",
},
]
async function init() {
const user = userStore.user
if (!user || !user.class_name) {
message.warning("您没有班级信息")
return
}
const offset = (query.page - 1) * query.limit
const res = await getUserClassRank(offset, query.limit)
myRank.value = res.data.my_rank
totalUsers.value = res.data.total_users
className.value = res.data.class_name
data.value = res.data.ranks.results
total.value = res.data.ranks.total
}
watch(() => query.page, init)
watch(
() => query.limit,
() => {
query.page = 1
init()
},
)
onMounted(init)
</script>
<template>
<n-flex vertical size="large">
<n-h2>我的班级排名</n-h2>
<n-alert v-if="className" type="info">
班级{{ className }} | 我的排名{{
myRank > 0 ? `${myRank}` : "暂无排名"
}}
| 班级总人数{{ totalUsers }}
</n-alert>
<n-alert v-else type="warning"> 您还没有加入班级 </n-alert>
<n-data-table :data="data" :columns="columns" />
<Pagination
:total="total"
v-model:page="query.page"
v-model:limit="query.limit"
/>
</n-flex>
</template>
<script setup lang="ts">
import { getUserClassRank } from "oj/api"
import { useUserStore } from "shared/store/user"
import Pagination from "shared/components/Pagination.vue"
import { renderTableTitle } from "utils/renders"
import { NButton } from "naive-ui"
const userStore = useUserStore()
const router = useRouter()
const message = useMessage()
interface UserRank {
rank: number
username: string
accepted_number: number
submission_number: number
}
const myRank = ref(-1)
const totalUsers = ref(0)
const className = ref("")
const data = ref<UserRank[]>([])
const total = ref(0)
const query = reactive({
limit: 20,
page: 1,
})
const columns: DataTableColumn<UserRank>[] = [
{
title: renderTableTitle("排名", "streamline-emojis:flexed-biceps-1"),
key: "rank",
width: 100,
align: "center",
render: (row) => {
if (row.rank === 1) return "🥇"
if (row.rank === 2) return "🥈"
if (row.rank === 3) return "🥉"
return row.rank
},
},
{
title: renderTableTitle(
"用户名",
"streamline-emojis:smiling-face-with-sunglasses",
),
key: "username",
width: 200,
render: (row) =>
h(
NButton,
{
text: true,
type: "info",
onClick: () => router.push("/user?name=" + row.username),
},
() => row.username,
),
},
{
title: renderTableTitle("AC数", "streamline-emojis:raised-fist-1"),
key: "accepted_number",
width: 120,
align: "center",
},
{
title: renderTableTitle("提交数", "streamline-emojis:rocket"),
key: "submission_number",
width: 120,
align: "center",
},
]
async function init() {
const user = userStore.user
if (!user || !user.class_name) {
message.warning("您没有班级信息")
return
}
const offset = (query.page - 1) * query.limit
const res = await getUserClassRank(offset, query.limit)
myRank.value = res.data.my_rank
totalUsers.value = res.data.total_users
className.value = res.data.class_name
data.value = res.data.ranks.results
total.value = res.data.ranks.total
}
watch(() => query.page, init)
watch(
() => query.limit,
() => {
query.page = 1
init()
},
)
onMounted(init)
</script>
<template>
<n-flex vertical size="large">
<n-h2>我的班级排名</n-h2>
<n-alert v-if="className" type="info">
班级{{ className }} | 我的排名{{
myRank > 0 ? `${myRank}` : "暂无排名"
}}
| 班级总人数{{ totalUsers }}
</n-alert>
<n-alert v-else type="warning"> 您还没有加入班级 </n-alert>
<n-data-table :data="data" :columns="columns" />
<Pagination
:total="total"
v-model:page="query.page"
v-model:limit="query.limit"
/>
</n-flex>
</template>

View File

@@ -384,6 +384,14 @@ const radarChartData = computed(() => {
const datasets = comparisons.value.map((c, index) => {
const color = getClassColor(index)
const rawData = [
c.total_ac,
c.avg_ac,
c.median_ac,
c.excellent_rate,
c.pass_rate,
c.active_rate,
]
return {
label: c.class_name,
data: [
@@ -394,6 +402,7 @@ const radarChartData = computed(() => {
c.pass_rate,
c.active_rate,
],
rawData,
backgroundColor: color.bg,
borderColor: color.border,
borderWidth: 2,
@@ -466,6 +475,28 @@ const radarChartOptions = {
legend: {
position: "bottom" as const,
},
tooltip: {
callbacks: {
label: function (context: any) {
const dataset = context.dataset as any
const rawValue = dataset?.rawData?.[context.dataIndex]
const metric = context.label || ""
const isRate = context.dataIndex >= 3
if (rawValue === undefined || rawValue === null) {
return `${dataset.label || ""}: ${context.parsed.r?.toFixed(2) ?? ""}`
}
const formatted = Number.isFinite(rawValue)
? isRate
? rawValue.toFixed(1)
: Number.isInteger(rawValue)
? rawValue.toString()
: rawValue.toFixed(2)
: String(rawValue)
const suffix = isRate ? "%" : ""
return `${dataset.label || ""} - ${metric}: ${formatted}${suffix}`
},
},
},
},
scales: {
r: {
@@ -485,7 +516,10 @@ const radarChartOptions = {
<n-h2 style="margin-bottom: 0">班级PK</n-h2>
<n-flex :wrap="false" align="flex-start" :size="16">
<n-form-item label="选择班级至少2个" style="width: 300px; margin-bottom: 0">
<n-form-item
label="选择班级至少2个"
style="width: 300px; margin-bottom: 0"
>
<n-select
v-model:value="selectedClasses"
:options="classOptions"
@@ -494,7 +528,10 @@ const radarChartOptions = {
/>
</n-form-item>
<n-form-item label="时间段(可选)" style="width: 200px; margin-bottom: 0">
<n-form-item
label="时间段(可选)"
style="width: 200px; margin-bottom: 0"
>
<n-select
v-model:value="duration"
:options="timeRangeOptions"
@@ -504,7 +541,12 @@ const radarChartOptions = {
/>
</n-form-item>
<n-button type="primary" @click="compare" :loading="loading" style="margin-top: 26px">
<n-button
type="primary"
@click="compare"
:loading="loading"
style="margin-top: 26px"
>
开始PK
</n-button>
</n-flex>
@@ -516,7 +558,10 @@ const radarChartOptions = {
:x-gap="16"
:y-gap="16"
>
<n-gi v-for="(classData, index) in comparisons" :key="classData.class_name">
<n-gi
v-for="(classData, index) in comparisons"
:key="classData.class_name"
>
<n-card
:title="classData.class_name"
:bordered="true"
@@ -544,7 +589,12 @@ const radarChartOptions = {
<!-- AC核心指标 - 突出显示便于横向对比 -->
<n-grid :cols="5" :x-gap="8" responsive="screen">
<n-gi>
<n-statistic label="总AC数" :value="classData.total_ac" size="large" class="stat-total-ac">
<n-statistic
label="总AC数"
:value="classData.total_ac"
size="large"
class="stat-total-ac"
>
<template #suffix>
<Icon icon="streamline-emojis:raised-fist-1" width="20" />
</template>
@@ -603,43 +653,71 @@ const radarChartOptions = {
<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">{{ classData.q1_ac.toFixed(2) }}</span>
<span style="color: #9254de; font-weight: 500">{{
classData.q1_ac.toFixed(2)
}}</span>
</n-descriptions-item>
<n-descriptions-item label="第三四分位数(Q3)">
<span style="color: #f759ab; font-weight: 500">{{ classData.q3_ac.toFixed(2) }}</span>
<span style="color: #f759ab; font-weight: 500">{{
classData.q3_ac.toFixed(2)
}}</span>
</n-descriptions-item>
<n-descriptions-item label="四分位距(IQR)">
<span style="color: #13c2c2; font-weight: 500">{{ classData.iqr.toFixed(2) }}</span>
<span style="color: #13c2c2; font-weight: 500">{{
classData.iqr.toFixed(2)
}}</span>
</n-descriptions-item>
<n-descriptions-item label="标准差">
<span style="color: #fa8c16; font-weight: 500">{{ classData.std_dev.toFixed(2) }}</span>
<span style="color: #fa8c16; font-weight: 500">{{
classData.std_dev.toFixed(2)
}}</span>
</n-descriptions-item>
<!-- 分层统计 -->
<n-descriptions-item label="前10名平均">
<span style="color: #cf1322; font-weight: 600">{{ classData.top_10_avg.toFixed(2) }}</span>
<span style="color: #cf1322; font-weight: 600">{{
classData.top_10_avg.toFixed(2)
}}</span>
</n-descriptions-item>
<n-descriptions-item label="后10名平均">
<span style="color: #096dd9; font-weight: 500">{{ classData.bottom_10_avg.toFixed(2) }}</span>
<span style="color: #096dd9; font-weight: 500">{{
classData.bottom_10_avg.toFixed(2)
}}</span>
</n-descriptions-item>
<n-descriptions-item label="前25%平均">
<span style="color: #f5222d; font-weight: 600">{{ classData.top_25_avg.toFixed(2) }}</span>
<span style="color: #f5222d; font-weight: 600">{{
classData.top_25_avg.toFixed(2)
}}</span>
</n-descriptions-item>
<n-descriptions-item label="后25%平均">
<span style="color: #531dab; font-weight: 500">{{ classData.bottom_25_avg.toFixed(2) }}</span>
<span style="color: #531dab; font-weight: 500">{{
classData.bottom_25_avg.toFixed(2)
}}</span>
</n-descriptions-item>
<!-- 人数 -->
<n-descriptions-item label="人数">
<span style="color: #1890ff; font-weight: 600">{{ classData.user_count }}</span>
<span style="color: #1890ff; font-weight: 600">{{
classData.user_count
}}</span>
</n-descriptions-item>
</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-progress
type="line"
@@ -680,22 +758,37 @@ const radarChartOptions = {
<template
v-if="hasTimeRange && classData.recent_total_ac !== undefined"
>
<n-descriptions bordered :column="2" size="small" label-placement="left" style="margin-top: 12px">
<n-descriptions
bordered
:column="2"
size="small"
label-placement="left"
style="margin-top: 12px"
>
<n-descriptions-item label="时间段总AC">
<span style="color: #ff7875; font-weight: 600">{{ classData.recent_total_ac }}</span>
<span style="color: #ff7875; font-weight: 600">{{
classData.recent_total_ac
}}</span>
</n-descriptions-item>
<n-descriptions-item label="时间段平均AC">
<span style="color: #73d13d; font-weight: 600">{{ classData.recent_avg_ac?.toFixed(2) }}</span>
<span style="color: #73d13d; font-weight: 600">{{
classData.recent_avg_ac?.toFixed(2)
}}</span>
</n-descriptions-item>
<n-descriptions-item label="时间段中位数AC">
<span style="color: #ffc53d; font-weight: 600">{{ classData.recent_median_ac?.toFixed(2) }}</span>
<span style="color: #ffc53d; font-weight: 600">{{
classData.recent_median_ac?.toFixed(2)
}}</span>
</n-descriptions-item>
<n-descriptions-item label="时间段前10名平均">
<span style="color: #ff4d4f; font-weight: 600">{{ classData.recent_top_10_avg?.toFixed(2) }}</span>
<span style="color: #ff4d4f; font-weight: 600">{{
classData.recent_top_10_avg?.toFixed(2)
}}</span>
</n-descriptions-item>
<n-descriptions-item label="活跃学生数" :span="2">
<span style="color: #1890ff; font-weight: 600">{{ classData.recent_active_count }}</span>
<span style="color: #1890ff; font-weight: 600">{{
classData.recent_active_count
}}</span>
</n-descriptions-item>
</n-descriptions>
</template>
@@ -827,7 +920,11 @@ const radarChartOptions = {
</template>
<!-- 对比表格 -->
<n-card v-if="comparisons.length > 0" title="对比表格" style="margin-top: 20px">
<n-card
v-if="comparisons.length > 0"
title="对比表格"
style="margin-top: 20px"
>
<n-data-table
:data="comparisons"
:columns="[
@@ -842,48 +939,93 @@ const radarChartOptions = {
title: '人数',
key: 'user_count',
width: 80,
render: (row) => h('span', { style: { color: '#1890ff', fontWeight: '600' } }, row.user_count),
render: (row) =>
h(
'span',
{ style: { color: '#1890ff', fontWeight: '600' } },
row.user_count,
),
},
{
title: '总AC数',
key: 'total_ac',
width: 100,
render: (row) => h('span', { style: { color: '#ff4d4f', fontWeight: '600' } }, row.total_ac),
render: (row) =>
h(
'span',
{ style: { color: '#ff4d4f', fontWeight: '600' } },
row.total_ac,
),
},
{
title: '平均AC',
key: 'avg_ac',
render: (row) => h('span', { style: { color: '#52c41a', fontWeight: '600' } }, row.avg_ac.toFixed(2)),
render: (row) =>
h(
'span',
{ style: { color: '#52c41a', fontWeight: '600' } },
row.avg_ac.toFixed(2),
),
},
{
title: '中位数AC',
key: 'median_ac',
render: (row) => h('span', { style: { color: '#fa8c16', fontWeight: '600' } }, row.median_ac.toFixed(2)),
render: (row) =>
h(
'span',
{ style: { color: '#fa8c16', fontWeight: '600' } },
row.median_ac.toFixed(2),
),
},
{
title: '前10名平均',
key: 'top_10_avg',
render: (row) => h('span', { style: { color: '#cf1322', fontWeight: '600' } }, row.top_10_avg.toFixed(2)),
render: (row) =>
h(
'span',
{ style: { color: '#cf1322', fontWeight: '600' } },
row.top_10_avg.toFixed(2),
),
},
{
title: '后10名平均',
key: 'bottom_10_avg',
render: (row) => h('span', { style: { color: '#096dd9', fontWeight: '500' } }, row.bottom_10_avg.toFixed(2)),
render: (row) =>
h(
'span',
{ style: { color: '#096dd9', fontWeight: '500' } },
row.bottom_10_avg.toFixed(2),
),
},
{
title: '优秀率',
key: 'excellent_rate',
render: (row) => h('span', { style: { color: '#faad14', fontWeight: '600' } }, row.excellent_rate.toFixed(1) + '%'),
render: (row) =>
h(
'span',
{ style: { color: '#faad14', fontWeight: '600' } },
row.excellent_rate.toFixed(1) + '%',
),
},
{
title: '及格率',
key: 'pass_rate',
render: (row) => h('span', { style: { color: '#52c41a', fontWeight: '600' } }, row.pass_rate.toFixed(1) + '%'),
render: (row) =>
h(
'span',
{ style: { color: '#52c41a', fontWeight: '600' } },
row.pass_rate.toFixed(1) + '%',
),
},
{
title: '参与度',
key: 'active_rate',
render: (row) => h('span', { style: { color: '#1890ff', fontWeight: '600' } }, row.active_rate.toFixed(1) + '%'),
render: (row) =>
h(
'span',
{ style: { color: '#1890ff', fontWeight: '600' } },
row.active_rate.toFixed(1) + '%',
),
},
]"
/>
@@ -946,4 +1088,3 @@ const radarChartOptions = {
font-weight: 600;
}
</style>

View File

@@ -1,144 +1,144 @@
<script setup lang="ts">
import { getClassRank } from "oj/api"
import Pagination from "shared/components/Pagination.vue"
interface ClassRank {
rank: number
class_name: string
user_count: number
total_ac: number
total_submission: number
avg_ac: number
ac_rate: number
}
const data = ref<ClassRank[]>([])
const total = ref(0)
const query = reactive({
limit: 10,
page: 1,
grade: null as number | null,
})
const gradeOptions = [
{ label: "24年级", value: 24 },
{ label: "23年级", value: 23 },
{ label: "22年级", value: 22 },
{ label: "21年级", value: 21 },
{ label: "20年级", value: 20 },
]
const columns: DataTableColumn<ClassRank>[] = [
{
title: "排名",
key: "rank",
width: 100,
titleAlign: "center",
align: "center",
render: (row) => {
if (row.rank === 1) return "🥇"
if (row.rank === 2) return "🥈"
if (row.rank === 3) return "🥉"
return row.rank
},
},
{
title: "班级",
key: "class_name",
width: 200,
titleAlign: "center",
align: "center",
},
{
title: "人数",
key: "user_count",
width: 100,
titleAlign: "center",
align: "center",
},
{
title: "总AC数",
key: "total_ac",
width: 120,
titleAlign: "center",
align: "center",
},
{
title: "总提交数",
key: "total_submission",
width: 120,
titleAlign: "center",
align: "center",
},
{
title: "平均AC数",
key: "avg_ac",
width: 120,
titleAlign: "center",
align: "center",
},
{
title: "正确率",
key: "ac_rate",
width: 100,
titleAlign: "center",
align: "center",
render: (row) => `${row.ac_rate}%`,
},
]
async function init() {
if (query.grade === null) {
data.value = []
total.value = 0
return
}
const offset = (query.page - 1) * query.limit
const res = await getClassRank(offset, query.limit, query.grade)
data.value = res.data.results
total.value = res.data.total
}
watch(() => query.page, init)
watch(
() => query.limit,
() => {
query.page = 1
init()
},
)
watch(
() => query.grade,
() => {
query.page = 1
init()
},
)
onMounted(() => {
if (query.grade !== null) {
init()
}
})
</script>
<template>
<n-flex justify="center">
<n-h2>班级排名</n-h2>
</n-flex>
<n-flex justify="center" style="margin-bottom: 16px">
<n-select
v-model:value="query.grade"
placeholder="选择年级"
clearable
style="width: 200px"
:options="gradeOptions"
/>
</n-flex>
<n-data-table :data="data" :columns="columns" />
<Pagination
:total="total"
v-model:page="query.page"
v-model:limit="query.limit"
/>
</template>
<script setup lang="ts">
import { getClassRank } from "oj/api"
import Pagination from "shared/components/Pagination.vue"
interface ClassRank {
rank: number
class_name: string
user_count: number
total_ac: number
total_submission: number
avg_ac: number
ac_rate: number
}
const data = ref<ClassRank[]>([])
const total = ref(0)
const query = reactive({
limit: 10,
page: 1,
grade: null as number | null,
})
const gradeOptions = [
{ label: "24年级", value: 24 },
{ label: "23年级", value: 23 },
{ label: "22年级", value: 22 },
{ label: "21年级", value: 21 },
{ label: "20年级", value: 20 },
]
const columns: DataTableColumn<ClassRank>[] = [
{
title: "排名",
key: "rank",
width: 100,
titleAlign: "center",
align: "center",
render: (row) => {
if (row.rank === 1) return "🥇"
if (row.rank === 2) return "🥈"
if (row.rank === 3) return "🥉"
return row.rank
},
},
{
title: "班级",
key: "class_name",
width: 200,
titleAlign: "center",
align: "center",
},
{
title: "人数",
key: "user_count",
width: 100,
titleAlign: "center",
align: "center",
},
{
title: "总AC数",
key: "total_ac",
width: 120,
titleAlign: "center",
align: "center",
},
{
title: "总提交数",
key: "total_submission",
width: 120,
titleAlign: "center",
align: "center",
},
{
title: "平均AC数",
key: "avg_ac",
width: 120,
titleAlign: "center",
align: "center",
},
{
title: "正确率",
key: "ac_rate",
width: 100,
titleAlign: "center",
align: "center",
render: (row) => `${row.ac_rate}%`,
},
]
async function init() {
if (query.grade === null) {
data.value = []
total.value = 0
return
}
const offset = (query.page - 1) * query.limit
const res = await getClassRank(offset, query.limit, query.grade)
data.value = res.data.results
total.value = res.data.total
}
watch(() => query.page, init)
watch(
() => query.limit,
() => {
query.page = 1
init()
},
)
watch(
() => query.grade,
() => {
query.page = 1
init()
},
)
onMounted(() => {
if (query.grade !== null) {
init()
}
})
</script>
<template>
<n-flex justify="center">
<n-h2>班级排名</n-h2>
</n-flex>
<n-flex justify="center" style="margin-bottom: 16px">
<n-select
v-model:value="query.grade"
placeholder="选择年级"
clearable
style="width: 200px"
:options="gradeOptions"
/>
</n-flex>
<n-data-table :data="data" :columns="columns" />
<Pagination
:total="total"
v-model:page="query.page"
v-model:limit="query.limit"
/>
</template>

View File

@@ -136,7 +136,11 @@ const menus = computed<MenuOption[]>(() => [
},
{
label: () =>
h(RouterLink, { to: "/class/my-rank" }, { default: () => "我的排名" }),
h(
RouterLink,
{ to: "/class/my-rank" },
{ default: () => "我的排名" },
),
key: "my-rank",
show: userStore.isAuthed,
},