240
src/oj/ai/analysis.vue
Normal file
240
src/oj/ai/analysis.vue
Normal file
@@ -0,0 +1,240 @@
|
||||
<template>
|
||||
<n-grid :cols="5" :x-gap="20">
|
||||
<n-gi :span="2">
|
||||
<n-flex vertical size="large">
|
||||
<n-flex align="center">
|
||||
<n-select
|
||||
style="width: 140px"
|
||||
:options="options"
|
||||
v-model:value="duration"
|
||||
/>
|
||||
<n-flex>
|
||||
<n-input
|
||||
clearable
|
||||
style="width: 140px"
|
||||
v-if="userStore.isSuperAdmin"
|
||||
v-model:value="username"
|
||||
/>
|
||||
<n-button @click="init">查询</n-button>
|
||||
</n-flex>
|
||||
</n-flex>
|
||||
<n-spin :show="detailLoading">
|
||||
<n-flex vertical size="large">
|
||||
<n-alert
|
||||
:show-icon="false"
|
||||
type="success"
|
||||
v-if="solvedProblems.length"
|
||||
>
|
||||
<span>{{ durationLabel }},</span>
|
||||
<span>{{ !!username ? username : "你" }}一共解决 </span>
|
||||
<b class="charming"> {{ solvedProblems.length }} </b>
|
||||
<span> 道题,</span>
|
||||
<span v-if="contest_count > 0">
|
||||
并且参加
|
||||
<b class="charming"> {{ contest_count }} </b> 次比赛,
|
||||
</span>
|
||||
<span>综合评价给到</span>
|
||||
<Grade :grade="grade" />
|
||||
<span>{{ greeting }}</span>
|
||||
</n-alert>
|
||||
<n-alert type="error" v-else title="你还没有完成任何题目"></n-alert>
|
||||
<n-flex>
|
||||
<TagsChart :tags="tags" />
|
||||
<DifficultyChart :difficulty="difficulty" />
|
||||
</n-flex>
|
||||
<n-data-table
|
||||
v-if="solvedProblems.length"
|
||||
striped
|
||||
:max-height="400"
|
||||
:data="solvedProblems"
|
||||
:columns="columns"
|
||||
/>
|
||||
</n-flex>
|
||||
</n-spin>
|
||||
</n-flex>
|
||||
</n-gi>
|
||||
<n-gi :span="3">
|
||||
<n-spin :show="weeklyLoading">
|
||||
<WeeklyChart :weeklyData="weeklyData" :duration="duration" />
|
||||
</n-spin>
|
||||
</n-gi>
|
||||
</n-grid>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch } from "vue"
|
||||
import { formatISO, sub, type Duration } from "date-fns"
|
||||
import { getAIDetailData, getAIWeeklyData } from "../api"
|
||||
import { NButton } from "naive-ui"
|
||||
import { parseTime } from "~/utils/functions"
|
||||
import TagTitle from "./components/TagTitle.vue"
|
||||
import TagsChart from "./components/TagsChart.vue"
|
||||
import DifficultyChart from "./components/DifficultyChart.vue"
|
||||
import WeeklyChart from "./components/WeeklyChart.vue"
|
||||
import Grade from "./components/Grade.vue"
|
||||
import { WeeklyData } from "~/utils/types"
|
||||
import { useUserStore } from "~/shared/store/user"
|
||||
|
||||
const router = useRouter()
|
||||
const userStore = useUserStore()
|
||||
|
||||
const start = ref("")
|
||||
const end = ref("")
|
||||
const duration = ref("months:6")
|
||||
const username = ref("")
|
||||
|
||||
const startLabel = ref("")
|
||||
const endLabel = ref("")
|
||||
|
||||
const weeklyLoading = ref(false)
|
||||
const detailLoading = ref(false)
|
||||
|
||||
const durationLabel = computed(() => {
|
||||
if (duration.value.includes("hours")) {
|
||||
return `在 ${parseTime(startLabel.value, "HH:mm")} - ${parseTime(endLabel.value, "HH:mm")} 期间`
|
||||
} else if (duration.value.includes("days")) {
|
||||
return `在 ${parseTime(endLabel.value, "MM月DD日")}`
|
||||
} else if (
|
||||
duration.value.includes("weeks") ||
|
||||
duration.value.includes("months")
|
||||
) {
|
||||
return `在 ${parseTime(startLabel.value, "MM月DD日")} - ${parseTime(endLabel.value, "MM月DD日")} 期间`
|
||||
} else {
|
||||
return `在 ${parseTime(startLabel.value, "YYYY年MM月DD日")} - ${parseTime(endLabel.value, "YYYY年MM月DD日")} 期间`
|
||||
}
|
||||
})
|
||||
|
||||
const greeting = computed(() => {
|
||||
return {
|
||||
S: "要不试试高难度题目?",
|
||||
A: "你很棒,继续保持!",
|
||||
B: "请再接再厉!",
|
||||
C: "你还需要努力!",
|
||||
}[grade.value]
|
||||
})
|
||||
|
||||
interface SolvedProblem {
|
||||
problem: {
|
||||
title: string
|
||||
display_id: string
|
||||
contest_title: string
|
||||
contest_id: number
|
||||
}
|
||||
ac_time: string
|
||||
rank: number
|
||||
ac_count: number
|
||||
grade: "S" | "A" | "B" | "C"
|
||||
}
|
||||
|
||||
const solvedProblems = ref<SolvedProblem[]>([])
|
||||
const grade = ref<"S" | "A" | "B" | "C">("B")
|
||||
const class_name = ref("")
|
||||
const tags = ref<{ [key: string]: number }>({})
|
||||
const difficulty = ref<{ [key: string]: number }>({})
|
||||
const contest_count = ref(0)
|
||||
const columns: DataTableColumn<SolvedProblem>[] = [
|
||||
{
|
||||
title: "完成的题目",
|
||||
key: "problem.title",
|
||||
render: (row) =>
|
||||
h(
|
||||
NButton,
|
||||
{
|
||||
text: true,
|
||||
onClick: () => {
|
||||
if (row.problem.contest_id) {
|
||||
router.push(
|
||||
"/contest/" +
|
||||
row.problem.contest_id +
|
||||
"/problem/" +
|
||||
row.problem.display_id,
|
||||
)
|
||||
} else {
|
||||
router.push("/problem/" + row.problem.display_id)
|
||||
}
|
||||
},
|
||||
},
|
||||
() => {
|
||||
if (row.problem.contest_id) {
|
||||
return h(TagTitle, { problem: row.problem })
|
||||
} else {
|
||||
return row.problem.display_id + " " + row.problem.title
|
||||
}
|
||||
},
|
||||
),
|
||||
},
|
||||
{
|
||||
title: () => (class_name ? "班级排名" : "全服排名"),
|
||||
key: "rank",
|
||||
width: 100,
|
||||
align: "center",
|
||||
render: (row) => row.rank + " / " + row.ac_count,
|
||||
},
|
||||
{
|
||||
title: "等级",
|
||||
key: "grade",
|
||||
width: 100,
|
||||
align: "center",
|
||||
},
|
||||
]
|
||||
|
||||
const options: SelectOption[] = [
|
||||
{ label: "一节课内", value: "hours:1" },
|
||||
{ label: "两节课内", value: "hours:2" },
|
||||
{ label: "一天内", value: "days:1" },
|
||||
{ label: "一周内", value: "weeks:1" },
|
||||
{ label: "一个月内", value: "months:1" },
|
||||
{ label: "两个月内", value: "months:2" },
|
||||
{ label: "半年内", value: "months:6" },
|
||||
{ label: "一年内", value: "years:1" },
|
||||
]
|
||||
|
||||
const subOptions = computed<Duration>(() => {
|
||||
let dur = options.find((it) => it.value === duration.value) ?? options[0]
|
||||
const x = dur.value!.toString().split(":")
|
||||
const unit = x[0]
|
||||
const n = x[1]
|
||||
return { [unit]: parseInt(n) } as Duration
|
||||
})
|
||||
|
||||
function updateRange() {
|
||||
const current = new Date()
|
||||
end.value = formatISO(current)
|
||||
start.value = formatISO(sub(current, subOptions.value))
|
||||
}
|
||||
|
||||
async function getDetail() {
|
||||
detailLoading.value = true
|
||||
const res = await getAIDetailData(start.value, end.value, username.value)
|
||||
detailLoading.value = false
|
||||
|
||||
startLabel.value = res.data.start
|
||||
endLabel.value = res.data.end
|
||||
solvedProblems.value = res.data.solved
|
||||
grade.value = res.data.grade
|
||||
class_name.value = res.data.class_name
|
||||
tags.value = res.data.tags
|
||||
difficulty.value = res.data.difficulty
|
||||
contest_count.value = res.data.contest_count
|
||||
}
|
||||
|
||||
const weeklyData = ref<WeeklyData[]>([])
|
||||
|
||||
async function getWeeklyData() {
|
||||
weeklyLoading.value = true
|
||||
const res = await getAIWeeklyData(end.value, duration.value, username.value)
|
||||
weeklyData.value = res.data
|
||||
weeklyLoading.value = false
|
||||
}
|
||||
|
||||
function init() {
|
||||
updateRange()
|
||||
getDetail()
|
||||
getWeeklyData()
|
||||
}
|
||||
watch(duration, init, { immediate: true })
|
||||
</script>
|
||||
<style scoped>
|
||||
.charming {
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
</style>
|
||||
58
src/oj/ai/components/DifficultyChart.vue
Normal file
58
src/oj/ai/components/DifficultyChart.vue
Normal file
@@ -0,0 +1,58 @@
|
||||
<template>
|
||||
<div class="chart" v-if="show">
|
||||
<Bar :data="data" :options="options" />
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { Bar } from "vue-chartjs"
|
||||
|
||||
const props = defineProps<{
|
||||
difficulty: { [key: string]: number }
|
||||
}>()
|
||||
|
||||
const show = computed(() => {
|
||||
return Object.values(props.difficulty).reduce((a, b) => a + b, 0) > 0
|
||||
})
|
||||
|
||||
const data = computed(() => {
|
||||
return {
|
||||
labels: Object.keys(props.difficulty),
|
||||
datasets: [
|
||||
{
|
||||
data: Object.values(props.difficulty),
|
||||
backgroundColor: ["#FF6384", "#36A2EB", "#FFCE56"],
|
||||
},
|
||||
],
|
||||
}
|
||||
})
|
||||
|
||||
const options = {
|
||||
interaction: {
|
||||
intersect: false,
|
||||
},
|
||||
maintainAspectRatio: false,
|
||||
y: {
|
||||
ticks: {
|
||||
stepSize: 1,
|
||||
},
|
||||
},
|
||||
plugins: {
|
||||
legend: {
|
||||
display: false,
|
||||
},
|
||||
title: {
|
||||
text: "题目的难度统计",
|
||||
display: true,
|
||||
font: {
|
||||
size: 20,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
<style scoped>
|
||||
.chart {
|
||||
height: 300px;
|
||||
width: 300px;
|
||||
}
|
||||
</style>
|
||||
31
src/oj/ai/components/Grade.vue
Normal file
31
src/oj/ai/components/Grade.vue
Normal file
@@ -0,0 +1,31 @@
|
||||
<template>
|
||||
<img src="/S.png" alt="S Grade" v-if="props.grade === 'S'" />
|
||||
<img src="/A.png" alt="A Grade" v-if="props.grade === 'A'" />
|
||||
<img src="/B.png" alt="B Grade" v-if="props.grade === 'B'" />
|
||||
<img src="/C.png" alt="C Grade" v-if="props.grade === 'C'" />
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
const props = defineProps<{
|
||||
grade: "S" | "A" | "B" | "C"
|
||||
}>()
|
||||
</script>
|
||||
<style scoped>
|
||||
img {
|
||||
animation: shake 0.5s infinite;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
margin: 0 10px -10px;
|
||||
}
|
||||
|
||||
@keyframes shake {
|
||||
0% {
|
||||
transform: translateY(0) scale(1);
|
||||
}
|
||||
50% {
|
||||
transform: translateY(-10px) scale(1.1);
|
||||
}
|
||||
100% {
|
||||
transform: translateY(0) scale(1);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
21
src/oj/ai/components/TagTitle.vue
Normal file
21
src/oj/ai/components/TagTitle.vue
Normal file
@@ -0,0 +1,21 @@
|
||||
<template>
|
||||
<n-flex vertical align="start">
|
||||
<n-flex align="center">
|
||||
<n-tag type="info" size="small" :bordered="false">比赛</n-tag>
|
||||
<span>{{ problem.contest_title }}</span>
|
||||
</n-flex>
|
||||
<span>{{ problem.display_id }} {{ problem.title }}</span>
|
||||
</n-flex>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
interface Props {
|
||||
problem: {
|
||||
title: string
|
||||
display_id: string
|
||||
contest_title: string
|
||||
contest_id: number
|
||||
}
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
</script>
|
||||
57
src/oj/ai/components/TagsChart.vue
Normal file
57
src/oj/ai/components/TagsChart.vue
Normal file
@@ -0,0 +1,57 @@
|
||||
<template>
|
||||
<div class="chart" v-if="show">
|
||||
<Pie :data="data" :options="options" />
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { Pie } from "vue-chartjs"
|
||||
const props = defineProps<{
|
||||
tags: { [key: string]: number }
|
||||
}>()
|
||||
|
||||
const show = computed(() => {
|
||||
return Object.keys(props.tags).length > 0
|
||||
})
|
||||
|
||||
const data = computed(() => {
|
||||
return {
|
||||
labels: Object.keys(props.tags),
|
||||
datasets: [
|
||||
{
|
||||
data: Object.values(props.tags),
|
||||
backgroundColor: [
|
||||
"#FF6384",
|
||||
"#36A2EB",
|
||||
"#FFCE56",
|
||||
"#4BC0C0",
|
||||
"#9966FF",
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
})
|
||||
|
||||
const options = computed(() => {
|
||||
return {
|
||||
interaction: {
|
||||
intersect: false,
|
||||
},
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
title: {
|
||||
text: `题目的标签分布(前${Object.keys(props.tags).length}个)`,
|
||||
display: true,
|
||||
font: {
|
||||
size: 20,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
})
|
||||
</script>
|
||||
<style scoped>
|
||||
.chart {
|
||||
height: 300px;
|
||||
width: 300px;
|
||||
}
|
||||
</style>
|
||||
126
src/oj/ai/components/WeeklyChart.vue
Normal file
126
src/oj/ai/components/WeeklyChart.vue
Normal file
@@ -0,0 +1,126 @@
|
||||
<template>
|
||||
<div class="chart">
|
||||
<Chart type="bar" :data="data" :options="options" />
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import type { ChartData, ChartOptions, TooltipItem } from "chart.js"
|
||||
import { Chart } from "vue-chartjs"
|
||||
import { parseTime } from "~/utils/functions"
|
||||
import { WeeklyData } from "~/utils/types"
|
||||
|
||||
const props = defineProps<{
|
||||
weeklyData: WeeklyData[]
|
||||
duration: string
|
||||
}>()
|
||||
|
||||
const gradeOrder = ["C", "B", "A", "S"] as const
|
||||
|
||||
const title = computed(() => {
|
||||
if (props.duration === "months:2") {
|
||||
return "过去两个月的每周综合情况一览图"
|
||||
} else if (props.duration === "months:6") {
|
||||
return "过去半年的每月综合情况一览图"
|
||||
} else if (props.duration === "years:1") {
|
||||
return "过去一年的每月综合情况一览图"
|
||||
} else {
|
||||
return "过去四周的综合情况一览图"
|
||||
}
|
||||
})
|
||||
|
||||
const data = computed<ChartData<"bar" | "line">>(() => {
|
||||
return {
|
||||
labels: props.weeklyData.map((weekly) => {
|
||||
let prefix = "周"
|
||||
if (weekly.unit === "months") {
|
||||
prefix = "月"
|
||||
}
|
||||
|
||||
return [
|
||||
parseTime(weekly.start, "M月D日"),
|
||||
parseTime(weekly.end, "M月D日"),
|
||||
].join("~")
|
||||
}),
|
||||
datasets: [
|
||||
{
|
||||
type: "bar",
|
||||
label: "完成题目数量",
|
||||
data: props.weeklyData.map((weekly) => weekly.problem_count),
|
||||
yAxisID: "y",
|
||||
},
|
||||
{
|
||||
type: "bar",
|
||||
label: "总提交次数",
|
||||
data: props.weeklyData.map((weekly) => weekly.submission_count),
|
||||
yAxisID: "y",
|
||||
},
|
||||
{
|
||||
type: "line",
|
||||
label: "等级",
|
||||
data: props.weeklyData.map((weekly) =>
|
||||
gradeOrder.indexOf(weekly.grade || "C"),
|
||||
),
|
||||
tension: 0.4,
|
||||
yAxisID: "y1",
|
||||
barThickness: 10,
|
||||
},
|
||||
],
|
||||
}
|
||||
})
|
||||
|
||||
const options = computed<ChartOptions<"bar" | "line">>(() => {
|
||||
return {
|
||||
interaction: {
|
||||
intersect: false,
|
||||
},
|
||||
maintainAspectRatio: false,
|
||||
scales: {
|
||||
y: {
|
||||
ticks: {
|
||||
stepSize: 1,
|
||||
},
|
||||
},
|
||||
y1: {
|
||||
type: "linear",
|
||||
position: "right",
|
||||
min: -1,
|
||||
max: gradeOrder.length,
|
||||
ticks: {
|
||||
stepSize: 1,
|
||||
callback: (v) => {
|
||||
const idx = Number(v)
|
||||
return gradeOrder[idx] || ""
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: {
|
||||
title: {
|
||||
text: title.value,
|
||||
display: true,
|
||||
font: {
|
||||
size: 20,
|
||||
},
|
||||
},
|
||||
tooltip: {
|
||||
callbacks: {
|
||||
label: (ctx: TooltipItem<"bar">) => {
|
||||
const dsLabel = ctx.dataset.label || ""
|
||||
if ((ctx.dataset as any).yAxisID === "y1") {
|
||||
const idx = Number(ctx.parsed.y)
|
||||
return `${dsLabel}: ${gradeOrder[idx] || ""}`
|
||||
}
|
||||
return `${dsLabel}: ${ctx.formattedValue}`
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
})
|
||||
</script>
|
||||
<style scoped>
|
||||
.chart {
|
||||
height: 300px;
|
||||
width: 100%;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user