Files
ojnext/src/shared/components/StatisticsPanel.vue
yuetsh 4466563933
Some checks failed
Deploy / deploy (push) Has been cancelled
统计那边的提交id
2026-01-04 10:31:52 +08:00

334 lines
9.1 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<template>
<n-flex>
<n-input
placeholder="用户(可选)"
v-model:value="query.username"
style="width: 160px"
clearable
/>
<n-input
placeholder="题号(可选)"
v-model:value="query.problem"
style="width: 160px"
clearable
/>
<n-select
style="width: 120px"
v-model:value="query.duration"
:options="options"
/>
<n-button type="primary" @click="handleStatistics">统计</n-button>
<n-button v-if="route.name !== 'submissions'" @click="goSubmissions">
前往提交列表
</n-button>
</n-flex>
<n-flex style="margin: 20px 0" v-if="count.total > 0">
<n-gradient-text font-size="24" type="primary">
正确提交数{{ count.accepted }}
</n-gradient-text>
<n-gradient-text font-size="24" type="info">
总提交数{{ count.total }}
</n-gradient-text>
<n-gradient-text font-size="24" type="warning">
正确率{{ count.rate }}
</n-gradient-text>
</n-flex>
<n-flex style="margin: 20px 0" v-if="count.total > 0">
<n-gradient-text font-size="24" type="error">
回答正确的人数{{ list.length }}
</n-gradient-text>
<n-gradient-text font-size="24" v-if="person.count > 0" type="warning">
班级人数{{ person.count }}
</n-gradient-text>
<n-gradient-text font-size="24" v-if="person.count > 0" type="success">
班级完成度{{ person.rate }}
</n-gradient-text>
</n-flex>
<n-flex style="margin: 20px 0" v-if="count.total > 0">
<n-button type="warning" @click="toggleUnaccepted(!unaccepted)">
{{ unaccepted ? "隐藏没有完成的" : "显示没有完成的" }}
</n-button>
</n-flex>
<n-flex style="margin-top: 20px">
<n-gradient-text font-size="24" v-if="count.total === 0" type="primary">
暂无数据统计
</n-gradient-text>
</n-flex>
<n-flex style="margin-bottom: 20px" v-if="unaccepted" size="large">
<span style="font-size: 24px">
{{ listUnaccepted.length }} 位没有完成
</span>
<span style="font-size: 24px" v-for="name in listUnaccepted" :key="name">
{{ name }}
</span>
</n-flex>
<n-tabs animated v-if="count.total > 0">
<n-tab-pane name="charts" tab="数据图表">
<n-grid :cols="2" :x-gap="20" :y-gap="20" style="margin-top: 20px">
<n-gi>
<n-card title="提交正确率">
<Doughnut :data="pieChartData" :options="pieChartOptions" />
</n-card>
</n-gi>
<n-gi v-if="person.count > 0">
<n-card title="班级完成度">
<Doughnut
:data="completionChartData"
:options="completionChartOptions"
/>
</n-card>
</n-gi>
</n-grid>
</n-tab-pane>
<n-tab-pane name="submissions" tab="提交记录">
<n-data-table
v-if="list.length"
striped
:columns="columns"
:data="list"
:row-key="rowKey"
:expanded-row-keys="expandedRowKeys"
@update:expanded-row-keys="updateExpandedRowKeys"
:row-props="rowProps"
/>
</n-tab-pane>
</n-tabs>
</template>
<script setup lang="ts">
import { h } from "vue"
import { formatISO, sub, type Duration } from "date-fns"
import { getSubmissionStatistics } from "oj/api"
import { DURATION_OPTIONS } from "utils/constants"
import { Doughnut } from "vue-chartjs"
import { Chart as ChartJS, ArcElement, Title, Tooltip, Legend } from "chart.js"
import { NButton, NFlex, NText, type DataTableRowKey } from "naive-ui"
import { JUDGE_STATUS } from "utils/constants"
import type { SUBMISSION_RESULT } from "utils/types"
// 注册 Chart.js 组件
ChartJS.register(ArcElement, Title, Tooltip, Legend)
interface Props {
problem: string
username: string
}
const props = defineProps<Props>()
const options: SelectOption[] = [
{ label: "10分钟内", value: "minutes:10" },
{ label: "20分钟内", value: "minutes:20" },
{ label: "30分钟内", value: "minutes:30" },
].concat(DURATION_OPTIONS)
function openSubmission(id: string) {
window.open(`/submission/${id}`, "_blank", "noopener")
}
const columns: DataTableColumn<UserStatistic>[] = [
{
type: "expand",
renderExpand: (row) => {
return h(NFlex, { size: "small", wrap: true }, () =>
row.submission_items.map((item) =>
h(
NButton,
{
size: "small",
tertiary: true,
type: JUDGE_STATUS[item.result]?.type ?? "default",
style: "width: 120px",
onClick: (event: MouseEvent) => {
event.stopPropagation()
openSubmission(item.id)
},
},
() => item.id.toString().slice(0, 12),
),
),
)
},
},
{ title: "用户", key: "username" },
{ title: "提交数", key: "submission_count" },
{ title: "已解决", key: "accepted_count" },
{ title: "正确率", key: "correct_rate" },
]
const query = reactive({
username: props.username,
problem: props.problem,
duration: options[0].value,
})
const count = reactive({
total: 0,
accepted: 0,
rate: 0,
})
const person = reactive({
count: 0,
rate: 0,
})
const route = useRoute()
const router = useRouter()
interface UserStatistic {
username: string
submission_count: number
accepted_count: number
correct_rate: string
submission_items: Array<{
id: string
result: SUBMISSION_RESULT
}>
}
const list = ref<UserStatistic[]>([])
const listUnaccepted = ref<string[]>([])
const [unaccepted, toggleUnaccepted] = useToggle()
const expandedRowKeys = ref<DataTableRowKey[]>([])
// 饼图数据 - 提交正确率分布
const pieChartData = computed(() => {
const wrongCount = count.total - count.accepted
return {
labels: ["正确提交", "错误提交"],
datasets: [
{
label: "提交数",
data: [count.accepted, wrongCount],
backgroundColor: ["rgba(75, 192, 192, 0.6)", "rgba(255, 99, 132, 0.6)"],
borderColor: ["rgba(75, 192, 192, 1)", "rgba(255, 99, 132, 1)"],
borderWidth: 2,
},
],
}
})
const pieChartOptions = {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
position: "bottom" as const,
},
tooltip: {
callbacks: {
label: function (context: any) {
const label = context.label || ""
const value = context.parsed || 0
const total = context.dataset.data.reduce(
(a: number, b: number) => a + b,
0,
)
const percentage = ((value / total) * 100).toFixed(1)
return `${label}: ${value} (${percentage}%)`
},
},
},
},
}
// 环形图数据 - 班级完成度
const completionChartData = computed(() => {
const completedCount = list.value.length
const uncompletedCount = person.count - completedCount
return {
labels: ["已完成", "未完成"],
datasets: [
{
label: "人数",
data: [completedCount, uncompletedCount],
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,
},
],
}
})
const completionChartOptions = {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
position: "bottom" as const,
},
tooltip: {
callbacks: {
label: function (context: any) {
const label = context.label || ""
const value = context.parsed || 0
const total = context.dataset.data.reduce(
(a: number, b: number) => a + b,
0,
)
const percentage = ((value / total) * 100).toFixed(1)
return `${label}: ${value} (${percentage}%)`
},
},
},
},
}
const subOptions = computed<Duration>(() => {
let dur = options.find((it) => it.value === query.duration) ?? options[0]
const x = dur.value!.toString().split(":")
const unit = x[0]
const n = x[1]
return { [unit]: parseInt(n) }
})
function goSubmissions() {
router.push({
name: "submissions",
query: {
username: query.username,
problem: query.problem,
},
})
}
async function handleStatistics() {
const current = Date.now()
const end = formatISO(current)
const start = formatISO(sub(current, subOptions.value))
const res = await getSubmissionStatistics(
{ start, end },
query.problem,
query.username,
)
count.total = res.data.submission_count
count.accepted = res.data.accepted_count
count.rate = res.data.correct_rate
list.value = res.data.data
listUnaccepted.value = res.data.data_unaccepted
person.count = res.data.person_count
person.rate = res.data.person_rate
toggleUnaccepted(false)
}
function rowKey(row: UserStatistic): DataTableRowKey {
return row.username
}
function updateExpandedRowKeys(keys: DataTableRowKey[]) {
expandedRowKeys.value = keys.slice(-1)
}
function rowProps(row: UserStatistic) {
return {
style: "cursor: pointer;",
onClick: () => {
const key = rowKey(row)
const isExpanded = expandedRowKeys.value.includes(key)
expandedRowKeys.value = isExpanded ? [] : [key]
},
}
}
</script>
<style scoped></style>