1060 lines
31 KiB
Vue
1060 lines
31 KiB
Vue
<script setup lang="ts">
|
||
import type {
|
||
ClassComparison,
|
||
ClassRankItem as ClassRank,
|
||
ClassUserRank,
|
||
MyRank,
|
||
Rank,
|
||
WeeklyRankItem,
|
||
} from "utils/types"
|
||
import { formatISO, sub, type Duration } from "date-fns"
|
||
import { NButton, NFlex } from "naive-ui"
|
||
import {
|
||
getActivityRank,
|
||
getClassRank,
|
||
getOnlineCount,
|
||
getRank,
|
||
getUserClassRank,
|
||
getClassPK,
|
||
getWeeklyRank,
|
||
} from "oj/api"
|
||
import { useBreakpoints } from "shared/composables/breakpoints"
|
||
import { durationFromValue, getACRate, parseTime } from "utils/functions"
|
||
import Pagination from "shared/components/Pagination.vue"
|
||
import { ChartType, LONG_DURATION_OPTIONS } from "utils/constants"
|
||
import { renderTableTitle } from "utils/renders"
|
||
import Chart from "./components/Chart.vue"
|
||
import Index from "./components/Index.vue"
|
||
import { useUserStore } from "shared/store/user"
|
||
import { Icon } from "@iconify/vue"
|
||
import { MdPreview } from "md-editor-v3"
|
||
import "md-editor-v3/lib/preview.css"
|
||
import { aiStreamError, consumeJSONEventStream } from "utils/stream"
|
||
|
||
const GRADES = [20, 25]
|
||
|
||
const gradeOptions = Array.from(
|
||
{ length: GRADES[1] - GRADES[0] + 1 },
|
||
(_, i) => ({
|
||
label: `${GRADES[1] - i}年级`,
|
||
value: GRADES[1] - i,
|
||
}),
|
||
)
|
||
|
||
const router = useRouter()
|
||
const userStore = useUserStore()
|
||
const { isDesktop } = useBreakpoints()
|
||
const data = ref<Rank[]>([])
|
||
const total = ref(0)
|
||
/** 我的全服名次;未登录、教师/超管不入榜时为 null */
|
||
const me = ref<MyRank | null>(null)
|
||
/** 我在前 100 名之外 —— 榜上高亮不到我,另起一行显示 */
|
||
const meOffBoard = computed(() => !!me.value && me.value.rank > total.value)
|
||
const query = reactive({
|
||
limit: 10,
|
||
page: 1,
|
||
})
|
||
const message = useMessage()
|
||
const rankChart = ref<Rank[]>([])
|
||
/** 全站在线人数。只是个聚合数字;「谁在线」是每行的 isOnline,服务端只对老师下发 */
|
||
const onlineCount = ref(0)
|
||
const activityChart = ref<Rank[]>([])
|
||
const duration = ref("months:1")
|
||
const classData = ref<ClassRank[]>([])
|
||
const classQuery = reactive({
|
||
grade: gradeOptions[0].value,
|
||
})
|
||
const myClassData = ref<ClassUserRank["ranks"]>([])
|
||
const myRank = ref(-1)
|
||
const myClassName = ref("")
|
||
const myClassScope = ref<"window" | "all">("window")
|
||
const myClassTotal = ref(0)
|
||
const myClassQuery = reactive({
|
||
page: 1,
|
||
limit: 10,
|
||
})
|
||
|
||
/**
|
||
* 本周进步榜。默认落在**本班** —— 全服榜上中位学生仍然看不到自己,班内 30 个人的
|
||
* 周榜才是「我这周排第几」有答案的那张。没有班级(教师、超管、没入班的账号)才退回全服。
|
||
*/
|
||
const weeklyScope = ref<"global" | "class">("global")
|
||
const weeklyData = ref<WeeklyRankItem[]>([])
|
||
const weeklyMe = ref<WeeklyRankItem | null>(null)
|
||
const weeklyTotal = ref(0)
|
||
const weeklyStart = ref("")
|
||
/**
|
||
* 我入不入这张榜。教师/超管本来就不参与排名(服务端 me 恒为 null),未登录同理 ——
|
||
* 这两种情况下 footer 那句「做出 1 题就能上榜」是说给不相干的人听的,不该出现。
|
||
*/
|
||
const weeklyMeEligible = computed(
|
||
() => userStore.isAuthed && !userStore.isTeacherOrAbove,
|
||
)
|
||
/** 我在榜面之外(或本周还没做出题)—— 榜上高亮不到我,footer 另起一行 */
|
||
const weeklyMeOffBoard = computed(
|
||
() =>
|
||
weeklyMeEligible.value &&
|
||
(!weeklyMe.value ||
|
||
!weeklyData.value.some((row) => row.rank === weeklyMe.value!.rank)),
|
||
)
|
||
|
||
const showClassDetailModal = ref(false)
|
||
const classDetailData = ref<ClassComparison | null>(null)
|
||
const classDetailLoading = ref(false)
|
||
|
||
const classDetailAiLoading = ref(false)
|
||
const classDetailAiContent = ref("")
|
||
const showClassDetailAiModal = ref(false)
|
||
let classDetailAiController: AbortController | null = null
|
||
|
||
async function loadClassDetail(className: string) {
|
||
showClassDetailModal.value = true
|
||
classDetailLoading.value = true
|
||
classDetailData.value = null
|
||
try {
|
||
const res = await getClassPK([className])
|
||
classDetailData.value = res.comparisons[0] ?? null
|
||
} catch {
|
||
// ignore
|
||
} finally {
|
||
classDetailLoading.value = false
|
||
}
|
||
}
|
||
|
||
async function analyzeSingleClassWithAI() {
|
||
if (!classDetailData.value) return
|
||
if (classDetailAiController) classDetailAiController.abort()
|
||
const controller = new AbortController()
|
||
classDetailAiController = controller
|
||
|
||
showClassDetailModal.value = false
|
||
showClassDetailAiModal.value = true
|
||
classDetailAiContent.value = ""
|
||
classDetailAiLoading.value = true
|
||
|
||
try {
|
||
const response = await fetch("/api/ai/class-analysis", {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({ comparison: classDetailData.value }),
|
||
signal: controller.signal,
|
||
})
|
||
if (!response.ok) throw await aiStreamError(response)
|
||
|
||
let hasStarted = false
|
||
await consumeJSONEventStream(response, {
|
||
signal: controller.signal,
|
||
onEvent(event) {
|
||
if (event === "end" && !hasStarted) classDetailAiLoading.value = false
|
||
},
|
||
onMessage(payload) {
|
||
const parsed = payload as {
|
||
type?: string
|
||
content?: string
|
||
message?: string
|
||
}
|
||
if (parsed.type === "delta" && parsed.content) {
|
||
if (!hasStarted) {
|
||
hasStarted = true
|
||
classDetailAiLoading.value = false
|
||
}
|
||
classDetailAiContent.value += parsed.content
|
||
} else if (parsed.type === "error") {
|
||
throw new Error(parsed.message || "AI 服务异常")
|
||
} else if (parsed.type === "done" && !hasStarted) {
|
||
classDetailAiLoading.value = false
|
||
}
|
||
},
|
||
})
|
||
} catch (error: any) {
|
||
if (controller.signal.aborted) return
|
||
message.error(error?.message || "AI 分析失败,请稍后再试")
|
||
classDetailAiLoading.value = false
|
||
} finally {
|
||
if (classDetailAiController === controller) classDetailAiController = null
|
||
}
|
||
}
|
||
|
||
async function init() {
|
||
const offset = (query.page - 1) * query.limit
|
||
const res = await getRank(offset, query.limit)
|
||
data.value = res.results
|
||
total.value = res.total
|
||
me.value = res.me
|
||
return res.results
|
||
}
|
||
|
||
function isMe(row: Rank) {
|
||
return !!me.value && row.user.id === me.value.user.id
|
||
}
|
||
|
||
// 高亮我那一行。用 id 比对而不是用户名:用户名会重名到大小写差异上,id 不会
|
||
function rowClassName(row: Rank) {
|
||
return isMe(row) ? "me-row" : ""
|
||
}
|
||
|
||
const columns: DataTableColumn<Rank>[] = [
|
||
{
|
||
title: renderTableTitle("排名", "streamline-emojis:flexed-biceps-1"),
|
||
key: "index",
|
||
width: 100,
|
||
align: "center",
|
||
render: (_, index) =>
|
||
h(Index, { index, page: query.page, limit: query.limit }),
|
||
},
|
||
{
|
||
title: renderTableTitle(
|
||
"用户",
|
||
"streamline-emojis:smiling-face-with-sunglasses",
|
||
),
|
||
key: "username",
|
||
width: 240,
|
||
render: (row) =>
|
||
h("div", { style: "display:flex;align-items:center;gap:6px" }, [
|
||
// isOnline 是三态:null 表示服务端没给(学生视角),只有 true 才点亮
|
||
row.isOnline
|
||
? h("span", {
|
||
title: "在线(5 分钟内有活动)",
|
||
style:
|
||
"width:8px;height:8px;border-radius:50%;background:#18a058;flex:none",
|
||
})
|
||
: null,
|
||
h(
|
||
NButton,
|
||
{
|
||
text: true,
|
||
type: "info",
|
||
onClick: () => router.push("/user?name=" + row.user.username),
|
||
},
|
||
() => row.user.username,
|
||
),
|
||
isMe(row)
|
||
? h(Icon, { width: 20, icon: "fluent-emoji:person-raising-hand" })
|
||
: null,
|
||
h(
|
||
NButton,
|
||
{
|
||
text: true,
|
||
size: "tiny",
|
||
title: "查看成就",
|
||
onClick: () =>
|
||
router.push("/achievement?name=" + row.user.username),
|
||
},
|
||
() => "🏆",
|
||
),
|
||
]),
|
||
},
|
||
{
|
||
title: renderTableTitle(
|
||
"个性签名",
|
||
"streamline-emojis:no-one-under-eighteen",
|
||
),
|
||
key: "mood",
|
||
minWidth: 200,
|
||
},
|
||
{
|
||
title: renderTableTitle("已解决", "streamline-emojis:raised-fist-1"),
|
||
key: "acceptedNumber",
|
||
width: 120,
|
||
align: "center",
|
||
},
|
||
{
|
||
title: renderTableTitle(
|
||
"提交数",
|
||
"streamline-ultimate-color:space-rocket-earth",
|
||
),
|
||
key: "submissionNumber",
|
||
width: 120,
|
||
align: "center",
|
||
},
|
||
{
|
||
title: renderTableTitle("正确率", "streamline-ultimate-color:gift-box-1"),
|
||
key: "rate",
|
||
width: 120,
|
||
align: "center",
|
||
render: (row) => getACRate(row.acceptedNumber, row.submissionNumber),
|
||
},
|
||
]
|
||
|
||
watch(() => query.page, init)
|
||
// 改每页条数时,若当前不在第一页,把重新取数交给 page 的 watcher ——
|
||
// 这里再自己取一次,就是两个一模一样的请求
|
||
watch(
|
||
() => query.limit,
|
||
() => {
|
||
if (query.page === 1) init()
|
||
else query.page = 1
|
||
},
|
||
)
|
||
watch(duration, listActivity)
|
||
|
||
async function listOnline() {
|
||
const res = await getOnlineCount()
|
||
onlineCount.value = res.count
|
||
}
|
||
|
||
async function listActivity() {
|
||
const current = Date.now()
|
||
const start = formatISO(sub(current, subOptions.value))
|
||
const res = await getActivityRank(start)
|
||
// 活动榜只有「用户名 + 做题数」,塞进榜单图表复用的 Rank 形状里
|
||
activityChart.value = res.map((d, index) => ({
|
||
id: index,
|
||
user: { id: index, username: d.username, realName: null },
|
||
acceptedNumber: d.count,
|
||
submissionNumber: 0,
|
||
mood: null,
|
||
isOnline: null,
|
||
}))
|
||
}
|
||
|
||
const options: SelectOption[] = [...LONG_DURATION_OPTIONS]
|
||
|
||
// 认不出来退回 options[1](一个月内),和 duration 的初值一致
|
||
const subOptions = computed<Duration>(
|
||
() =>
|
||
durationFromValue(duration.value) ??
|
||
durationFromValue(LONG_DURATION_OPTIONS[1]!.value)!,
|
||
)
|
||
|
||
// 周榜占的是三栏里的一栏,列头不再重复「本周」(卡片标题已经写着),
|
||
// 宽度也压到 1280 那一档能整张放下,不出横向滚动条
|
||
const weeklyColumns: DataTableColumn<WeeklyRankItem>[] = [
|
||
{
|
||
title: renderTableTitle("排名", "streamline-emojis:flexed-biceps-1"),
|
||
key: "rank",
|
||
width: 100,
|
||
align: "center",
|
||
// rank 是服务端给的周榜名次,不是行号 —— 换算回 Index 要的 0 基下标
|
||
render: (row) => h(Index, { index: row.rank - 1, page: 1, limit: 10 }),
|
||
},
|
||
{
|
||
title: renderTableTitle(
|
||
"用户",
|
||
"streamline-emojis:smiling-face-with-sunglasses",
|
||
),
|
||
key: "username",
|
||
minWidth: 120,
|
||
ellipsis: { tooltip: true },
|
||
render: (row) =>
|
||
h(
|
||
NButton,
|
||
{
|
||
text: true,
|
||
type: "info",
|
||
onClick: () => router.push("/user?name=" + row.user.username),
|
||
},
|
||
() => row.user.username,
|
||
),
|
||
},
|
||
{
|
||
title: renderTableTitle("新解决", "fluent-emoji:party-popper"),
|
||
key: "solvedCount",
|
||
width: 100,
|
||
align: "center",
|
||
},
|
||
{
|
||
title: renderTableTitle("提交", "streamline-emojis:rocket"),
|
||
key: "submissionCount",
|
||
width: 90,
|
||
align: "center",
|
||
},
|
||
]
|
||
|
||
async function initWeeklyRank() {
|
||
if (!userStore.user) await userStore.getMyProfile()
|
||
// 有班级就默认看班内榜。改值会触发上面那个 watch 去取数,
|
||
// 这里再调一次 listWeeklyRank 就是重复发一次请求
|
||
if (userStore.user?.className) weeklyScope.value = "class"
|
||
else await listWeeklyRank()
|
||
}
|
||
|
||
onMounted(() => {
|
||
// 「全服 Top10」就是榜单第一页的前 10 条:挂载时 init() 取的正是 offset=0&limit=10,
|
||
// 再单发一次一模一样的 /rankings/users 只会让这张图排在日活后面出来。
|
||
// 图只在挂载时定一次,翻页/改每页条数不该动它。
|
||
init().then((results) => (rankChart.value = results.slice(0, 10)))
|
||
listOnline()
|
||
listActivity()
|
||
listClassRank()
|
||
listMyClassRank()
|
||
initWeeklyRank()
|
||
})
|
||
|
||
const classColumns: DataTableColumn<ClassRank>[] = [
|
||
{
|
||
title: "排名",
|
||
key: "rank",
|
||
width: 60,
|
||
titleAlign: "center",
|
||
align: "center",
|
||
},
|
||
{
|
||
title: "班级",
|
||
key: "class_name",
|
||
render: (row) =>
|
||
`${row.className.slice(0, 2)}计算机${row.className.slice(2)}班`,
|
||
minWidth: 120,
|
||
titleAlign: "center",
|
||
align: "center",
|
||
},
|
||
{
|
||
title: "人数",
|
||
key: "userCount",
|
||
width: 80,
|
||
titleAlign: "center",
|
||
align: "center",
|
||
},
|
||
{
|
||
title: "总AC数",
|
||
key: "totalAc",
|
||
width: 90,
|
||
titleAlign: "center",
|
||
align: "center",
|
||
},
|
||
{
|
||
title: "提交数",
|
||
key: "totalSubmission",
|
||
width: 90,
|
||
titleAlign: "center",
|
||
align: "center",
|
||
},
|
||
{
|
||
title: "平均AC数",
|
||
key: "avgAc",
|
||
width: 100,
|
||
titleAlign: "center",
|
||
align: "center",
|
||
},
|
||
{
|
||
title: "正确率",
|
||
key: "ac_rate",
|
||
width: 90,
|
||
titleAlign: "center",
|
||
align: "center",
|
||
render: (row) => `${row.acRate}%`,
|
||
},
|
||
{
|
||
title: "详情",
|
||
key: "action",
|
||
width: 70,
|
||
titleAlign: "center",
|
||
align: "center",
|
||
render: (row) =>
|
||
h(
|
||
NButton,
|
||
{
|
||
text: true,
|
||
type: "info",
|
||
onClick: () => loadClassDetail(row.className),
|
||
},
|
||
() => "查看",
|
||
),
|
||
},
|
||
]
|
||
|
||
const myClassColumns: DataTableColumn<ClassUserRank["ranks"][number]>[] = [
|
||
{
|
||
title: "排名",
|
||
key: "rank",
|
||
width: 100,
|
||
align: "center",
|
||
},
|
||
{
|
||
title: "用户名",
|
||
key: "username",
|
||
width: 240,
|
||
render: (row) =>
|
||
h("div", { style: "display:flex;align-items:center;gap:6px" }, [
|
||
h(
|
||
NButton,
|
||
{
|
||
text: true,
|
||
type: "info",
|
||
onClick: () => router.push("/user?name=" + row.username),
|
||
},
|
||
() =>
|
||
row.rank === myRank.value
|
||
? h(
|
||
NFlex,
|
||
{ align: "flex-end" },
|
||
{
|
||
default: () => [
|
||
h("span", {}, row.username),
|
||
h(Icon, {
|
||
width: 20,
|
||
icon: "fluent-emoji:person-raising-hand",
|
||
}),
|
||
],
|
||
},
|
||
)
|
||
: row.username,
|
||
),
|
||
h(
|
||
NButton,
|
||
{
|
||
text: true,
|
||
size: "tiny",
|
||
title: "查看成就",
|
||
onClick: () => router.push("/achievement?name=" + row.username),
|
||
},
|
||
() => "🏆",
|
||
),
|
||
]),
|
||
},
|
||
{
|
||
title: "已解决",
|
||
key: "acceptedNumber",
|
||
width: 120,
|
||
align: "center",
|
||
},
|
||
{
|
||
title: "提交数",
|
||
key: "submissionNumber",
|
||
width: 120,
|
||
align: "center",
|
||
},
|
||
]
|
||
|
||
async function listClassRank() {
|
||
if (!userStore.user) {
|
||
await userStore.getMyProfile()
|
||
}
|
||
const className = userStore.user?.className
|
||
if (className) {
|
||
classQuery.grade = parseInt(className.slice(0, 2))
|
||
}
|
||
const res = await getClassRank(classQuery.grade)
|
||
classData.value = res
|
||
}
|
||
|
||
async function listMyClassRank() {
|
||
try {
|
||
const offset =
|
||
myClassScope.value === "all"
|
||
? (myClassQuery.page - 1) * myClassQuery.limit
|
||
: 0
|
||
const limit = myClassScope.value === "all" ? myClassQuery.limit : undefined
|
||
const res = await getUserClassRank(myClassScope.value, offset, limit)
|
||
myRank.value = res.myRank
|
||
myClassName.value = res.className
|
||
myClassData.value = res.ranks
|
||
myClassTotal.value = res.total ?? res.ranks.length
|
||
if (myClassScope.value === "window") {
|
||
myClassQuery.page = 1
|
||
}
|
||
} catch (err: any) {
|
||
console.error(err)
|
||
}
|
||
}
|
||
|
||
async function listWeeklyRank() {
|
||
try {
|
||
const res = await getWeeklyRank(weeklyScope.value)
|
||
weeklyData.value = res.results
|
||
weeklyMe.value = res.me
|
||
weeklyTotal.value = res.total
|
||
weeklyStart.value = res.start
|
||
} catch (err: any) {
|
||
console.error(err)
|
||
}
|
||
}
|
||
|
||
watch(weeklyScope, listWeeklyRank)
|
||
|
||
watch(
|
||
() => classQuery.grade,
|
||
() => {
|
||
listClassRank()
|
||
},
|
||
)
|
||
|
||
watch(myClassScope, listMyClassRank)
|
||
|
||
watch(
|
||
() => myClassQuery.page,
|
||
() => {
|
||
if (myClassScope.value === "all") {
|
||
listMyClassRank()
|
||
}
|
||
},
|
||
)
|
||
|
||
// 同上:page 改了自会触发下面那个 watcher,别重复取
|
||
watch(
|
||
() => myClassQuery.limit,
|
||
() => {
|
||
if (myClassQuery.page !== 1) myClassQuery.page = 1
|
||
else if (myClassScope.value === "all") listMyClassRank()
|
||
},
|
||
)
|
||
</script>
|
||
|
||
<template>
|
||
<n-flex vertical size="large">
|
||
<n-grid :cols="isDesktop ? 2 : 1" :x-gap="20" :y-gap="20">
|
||
<n-gi :span="1">
|
||
<n-card :bordered="false">
|
||
<template #header>
|
||
<div style="height: 34px">全服 Top10</div>
|
||
</template>
|
||
<Chart
|
||
v-if="rankChart.length"
|
||
:type="ChartType.Rank"
|
||
:rank-data="rankChart"
|
||
/>
|
||
<n-empty v-else style="padding: 20px 0"></n-empty>
|
||
</n-card>
|
||
</n-gi>
|
||
<n-gi :span="1">
|
||
<n-card :bordered="false">
|
||
<template #header>日活 Top10</template>
|
||
<template #header-extra>
|
||
<n-select
|
||
style="width: 120px"
|
||
:options="options"
|
||
v-model:value="duration"
|
||
/>
|
||
</template>
|
||
<Chart
|
||
v-if="activityChart.length"
|
||
:type="ChartType.Activity"
|
||
:rank-data="activityChart"
|
||
/>
|
||
<n-empty v-else style="padding: 20px 0"></n-empty>
|
||
</n-card>
|
||
</n-gi>
|
||
</n-grid>
|
||
<!--
|
||
两张榜并排:左边全服总榜(分母是历史全部 AC,名次几乎不动),右边本周进步榜
|
||
(分母只有这一周)。同一屏里对照着看,「追不上」和「这周还能进前十」是一眼的事。
|
||
-->
|
||
<n-grid :cols="isDesktop ? 3 : 1" :x-gap="20" :y-gap="20">
|
||
<n-gi :span="isDesktop ? 2 : 1">
|
||
<n-card :bordered="false">
|
||
<template #header>全服 Top100</template>
|
||
<template #header-extra>
|
||
<n-tag
|
||
v-if="onlineCount > 0"
|
||
round
|
||
:bordered="false"
|
||
type="success"
|
||
>
|
||
当前在线 {{ onlineCount }} 人
|
||
</n-tag>
|
||
</template>
|
||
<n-data-table
|
||
:data="data"
|
||
:columns="columns"
|
||
:row-class-name="rowClassName"
|
||
/>
|
||
<template #footer>
|
||
<n-flex align="center" justify="space-between" :wrap="false">
|
||
<!-- 前 100 名之外的学生榜上找不到自己,这里单独给一行 -->
|
||
<n-tag v-if="meOffBoard" type="info" round :bordered="false">
|
||
<template #icon>
|
||
<Icon width="18" icon="fluent-emoji:person-raising-hand" />
|
||
</template>
|
||
我的排名:第 {{ me!.rank }} 名 · 已解决
|
||
{{ me!.acceptedNumber }} · 提交 {{ me!.submissionNumber }} ·
|
||
正确率
|
||
{{ getACRate(me!.acceptedNumber, me!.submissionNumber) }}
|
||
</n-tag>
|
||
<span v-else />
|
||
<Pagination
|
||
:total="total"
|
||
v-model:page="query.page"
|
||
v-model:limit="query.limit"
|
||
/>
|
||
</n-flex>
|
||
</template>
|
||
</n-card>
|
||
</n-gi>
|
||
<n-gi :span="1">
|
||
<n-card :bordered="false">
|
||
<template #header>
|
||
<n-flex align="center" :size="8">
|
||
<span>本周进步榜</span>
|
||
<n-text depth="3" style="font-size: 13px">
|
||
{{ weeklyStart ? parseTime(weeklyStart, "M月D日") + "起" : "" }}
|
||
· 每周一清零
|
||
</n-text>
|
||
</n-flex>
|
||
</template>
|
||
<template #header-extra>
|
||
<n-select
|
||
v-if="userStore.user?.className"
|
||
style="width: 140px"
|
||
:options="[
|
||
{ label: '本班', value: 'class' },
|
||
{ label: '全服', value: 'global' },
|
||
]"
|
||
v-model:value="weeklyScope"
|
||
/>
|
||
</template>
|
||
<n-data-table
|
||
v-if="weeklyData.length"
|
||
:data="weeklyData"
|
||
:columns="weeklyColumns"
|
||
:row-class-name="
|
||
(row: WeeklyRankItem) =>
|
||
weeklyMe && row.rank === weeklyMe.rank ? 'me-row' : ''
|
||
"
|
||
/>
|
||
<n-empty
|
||
v-else
|
||
style="padding: 20px 0"
|
||
description="这周还没有人解决新题目 —— 现在做出一题就是第一名"
|
||
/>
|
||
<!--
|
||
本周一题没做出来时 weeklyMe 是 null,这一行照样要出现:它是这张榜对
|
||
「还没上榜的人」说的话,而那恰好是最需要被推一把的那批学生。
|
||
-->
|
||
<template #footer v-if="weeklyMeOffBoard">
|
||
<n-tag type="info" round :bordered="false">
|
||
<template #icon>
|
||
<Icon width="18" icon="fluent-emoji:person-raising-hand" />
|
||
</template>
|
||
<template v-if="weeklyMe">
|
||
我这周第 {{ weeklyMe.rank }} 名(共 {{ weeklyTotal }} 人上榜)·
|
||
新解决 {{ weeklyMe.solvedCount }} 题
|
||
</template>
|
||
<template v-else>
|
||
我这周还没有解决新题目,做出 1 题就能上榜
|
||
</template>
|
||
</n-tag>
|
||
</template>
|
||
</n-card>
|
||
</n-gi>
|
||
</n-grid>
|
||
<n-grid :cols="isDesktop ? 2 : 1" :x-gap="20" :y-gap="20">
|
||
<n-gi :span="1">
|
||
<n-card :bordered="false">
|
||
<template #header>
|
||
<n-flex align="center">
|
||
<span>班级排名</span>
|
||
<n-button
|
||
type="primary"
|
||
secondary
|
||
@click="router.push('/class')"
|
||
v-if="userStore.isAdminRole"
|
||
>
|
||
班级PK
|
||
</n-button>
|
||
</n-flex>
|
||
</template>
|
||
<template #header-extra>
|
||
<n-select
|
||
v-model:value="classQuery.grade"
|
||
placeholder="选择年级"
|
||
clearable
|
||
style="width: 180px"
|
||
:options="gradeOptions"
|
||
/>
|
||
</template>
|
||
<n-data-table :data="classData" :columns="classColumns" />
|
||
</n-card>
|
||
</n-gi>
|
||
<n-gi :span="1">
|
||
<n-card :bordered="false">
|
||
<template #header>我在班级的排名</template>
|
||
<template #header-extra>
|
||
<n-select
|
||
style="width: 180px"
|
||
:options="[
|
||
{ label: '我的位置', value: 'window' },
|
||
{ label: '全班排名', value: 'all' },
|
||
]"
|
||
v-model:value="myClassScope"
|
||
/>
|
||
</template>
|
||
<n-data-table :data="myClassData" :columns="myClassColumns" />
|
||
<template #footer v-if="myClassScope === 'all'">
|
||
<Pagination
|
||
:total="myClassTotal"
|
||
v-model:page="myClassQuery.page"
|
||
v-model:limit="myClassQuery.limit"
|
||
/>
|
||
</template>
|
||
</n-card>
|
||
</n-gi>
|
||
</n-grid>
|
||
</n-flex>
|
||
|
||
<n-modal
|
||
v-model:show="showClassDetailModal"
|
||
preset="card"
|
||
:title="
|
||
classDetailData
|
||
? `${classDetailData.className.slice(0, 2)}计算机${classDetailData.className.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.totalAc"
|
||
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.avgAc.toFixed(2)"
|
||
size="large"
|
||
class="stat-avg-ac"
|
||
>
|
||
<template #suffix>
|
||
<Icon
|
||
icon="streamline-ultimate-color:analytics-pie-2"
|
||
width="20"
|
||
/>
|
||
</template>
|
||
</n-statistic>
|
||
</n-gi>
|
||
<n-gi>
|
||
<n-statistic
|
||
label="中位数AC数"
|
||
:value="classDetailData.medianAc.toFixed(2)"
|
||
size="large"
|
||
class="stat-median-ac"
|
||
>
|
||
<template #suffix>
|
||
<Icon
|
||
icon="streamline-ultimate-color:cursor-target-1"
|
||
width="20"
|
||
/>
|
||
</template>
|
||
</n-statistic>
|
||
</n-gi>
|
||
<n-gi>
|
||
<n-statistic
|
||
label="总提交数"
|
||
:value="classDetailData.totalSubmission"
|
||
size="large"
|
||
class="stat-total-submission"
|
||
>
|
||
<template #suffix>
|
||
<Icon
|
||
icon="streamline-ultimate-color:common-file-text"
|
||
width="20"
|
||
/>
|
||
</template>
|
||
</n-statistic>
|
||
</n-gi>
|
||
<n-gi>
|
||
<n-statistic
|
||
label="AC率"
|
||
:value="classDetailData.acRate.toFixed(1) + '%'"
|
||
size="large"
|
||
class="stat-ac-rate"
|
||
>
|
||
<template #suffix>
|
||
<Icon icon="fluent-emoji:check-mark-button" width="20" />
|
||
</template>
|
||
</n-statistic>
|
||
</n-gi>
|
||
</n-grid>
|
||
|
||
<n-divider style="margin: 12px 0" />
|
||
|
||
<n-descriptions
|
||
bordered
|
||
:column="2"
|
||
size="small"
|
||
label-placement="left"
|
||
>
|
||
<n-descriptions-item label="第一四分位数(Q1)">
|
||
<span style="color: #9254de; font-weight: 500">{{
|
||
classDetailData.q1Ac.toFixed(2)
|
||
}}</span>
|
||
</n-descriptions-item>
|
||
<n-descriptions-item label="第三四分位数(Q3)">
|
||
<span style="color: #f759ab; font-weight: 500">{{
|
||
classDetailData.q3Ac.toFixed(2)
|
||
}}</span>
|
||
</n-descriptions-item>
|
||
<n-descriptions-item label="四分位距(IQR)">
|
||
<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.stdDev.toFixed(2)
|
||
}}</span>
|
||
</n-descriptions-item>
|
||
<n-descriptions-item label="前10%均值">
|
||
<span style="color: #cf1322; font-weight: 600">{{
|
||
classDetailData.top10Avg.toFixed(2)
|
||
}}</span>
|
||
</n-descriptions-item>
|
||
<n-descriptions-item label="中间80%均值">
|
||
<span style="color: #389e0d; font-weight: 600">{{
|
||
classDetailData.middle80Avg.toFixed(2)
|
||
}}</span>
|
||
</n-descriptions-item>
|
||
<n-descriptions-item label="后10%均值">
|
||
<span style="color: #096dd9; font-weight: 500">{{
|
||
classDetailData.bottom10Avg.toFixed(2)
|
||
}}</span>
|
||
</n-descriptions-item>
|
||
<n-descriptions-item label="人数">
|
||
<span style="color: #1890ff; font-weight: 600">{{
|
||
classDetailData.userCount
|
||
}}</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.excellentRate"
|
||
:show-indicator="true"
|
||
:border-radius="4"
|
||
>
|
||
<template #default
|
||
>优秀率:
|
||
{{ classDetailData.excellentRate.toFixed(1) }}%</template
|
||
>
|
||
</n-progress>
|
||
<n-progress
|
||
type="line"
|
||
:percentage="classDetailData.passRate"
|
||
:show-indicator="true"
|
||
:border-radius="4"
|
||
status="success"
|
||
>
|
||
<template #default
|
||
>及格率: {{ classDetailData.passRate.toFixed(1) }}%</template
|
||
>
|
||
</n-progress>
|
||
<n-progress
|
||
type="line"
|
||
:percentage="classDetailData.activeRate"
|
||
:show-indicator="true"
|
||
:border-radius="4"
|
||
status="info"
|
||
>
|
||
<template #default
|
||
>参与度: {{ classDetailData.activeRate.toFixed(1) }}%</template
|
||
>
|
||
</n-progress>
|
||
</n-space>
|
||
</n-card>
|
||
|
||
<n-flex
|
||
justify="center"
|
||
align="center"
|
||
:size="12"
|
||
style="margin-top: 12px"
|
||
>
|
||
<n-tag type="success" size="large">
|
||
综合分: {{ classDetailData.compositeScore.toFixed(1) }}
|
||
</n-tag>
|
||
<n-button
|
||
type="info"
|
||
:loading="classDetailAiLoading"
|
||
@click="analyzeSingleClassWithAI"
|
||
>
|
||
<template #icon>
|
||
<Icon icon="ph:sparkle" />
|
||
</template>
|
||
AI分析
|
||
</n-button>
|
||
</n-flex>
|
||
</n-flex>
|
||
<n-empty
|
||
v-else-if="!classDetailLoading"
|
||
description="暂无数据"
|
||
style="padding: 40px 0"
|
||
/>
|
||
</n-spin>
|
||
</n-modal>
|
||
|
||
<n-modal
|
||
v-model:show="showClassDetailAiModal"
|
||
preset="card"
|
||
title="AI 分析报告"
|
||
:style="{ width: '800px', maxWidth: '95vw' }"
|
||
>
|
||
<n-spin :show="classDetailAiLoading" :delay="50">
|
||
<div style="min-height: 200px">
|
||
<MdPreview
|
||
v-if="classDetailAiContent"
|
||
:model-value="classDetailAiContent"
|
||
/>
|
||
<n-flex
|
||
v-else-if="!classDetailAiLoading"
|
||
align="center"
|
||
justify="center"
|
||
style="min-height: 200px"
|
||
>
|
||
<n-empty description="暂无分析内容" />
|
||
</n-flex>
|
||
</div>
|
||
</n-spin>
|
||
</n-modal>
|
||
</template>
|
||
|
||
<style scoped>
|
||
:deep(.me-row > td) {
|
||
background-color: rgba(24, 160, 88, 0.12) !important;
|
||
}
|
||
|
||
.stat-total-ac :deep(.n-statistic-value),
|
||
.stat-total-ac :deep(.n-statistic-value__content),
|
||
.stat-total-ac :deep(.n-number-animation),
|
||
.stat-total-ac :deep(.n-statistic-value > *),
|
||
.stat-total-ac :deep(.n-statistic-value span) {
|
||
color: #ff4d4f !important;
|
||
font-weight: 600;
|
||
}
|
||
|
||
.stat-avg-ac :deep(.n-statistic-value),
|
||
.stat-avg-ac :deep(.n-statistic-value__content),
|
||
.stat-avg-ac :deep(.n-number-animation),
|
||
.stat-avg-ac :deep(.n-statistic-value > *),
|
||
.stat-avg-ac :deep(.n-statistic-value span) {
|
||
color: #52c41a !important;
|
||
font-weight: 600;
|
||
}
|
||
|
||
.stat-median-ac :deep(.n-statistic-value),
|
||
.stat-median-ac :deep(.n-statistic-value__content),
|
||
.stat-median-ac :deep(.n-number-animation),
|
||
.stat-median-ac :deep(.n-statistic-value > *),
|
||
.stat-median-ac :deep(.n-statistic-value span) {
|
||
color: #fa8c16 !important;
|
||
font-weight: 600;
|
||
}
|
||
|
||
.stat-total-submission :deep(.n-statistic-value),
|
||
.stat-total-submission :deep(.n-statistic-value__content),
|
||
.stat-total-submission :deep(.n-number-animation),
|
||
.stat-total-submission :deep(.n-statistic-value > *),
|
||
.stat-total-submission :deep(.n-statistic-value span) {
|
||
color: #805ad5 !important;
|
||
font-weight: 600;
|
||
}
|
||
|
||
.stat-ac-rate :deep(.n-statistic-value),
|
||
.stat-ac-rate :deep(.n-statistic-value__content),
|
||
.stat-ac-rate :deep(.n-number-animation),
|
||
.stat-ac-rate :deep(.n-statistic-value > *),
|
||
.stat-ac-rate :deep(.n-statistic-value span) {
|
||
color: #00b894 !important;
|
||
font-weight: 600;
|
||
}
|
||
</style>
|