Files
OJ2/apps/web/src/oj/rank/list.vue
T
xuyueandClaude Opus 5.5 30f34f0e36 fix(老师权限): 老师在自己比赛里点题目「可见」没反应、看不到学生的智能分析入口等一批权限错位
- 比赛题的可见开关按题目作者判归属,从题库加进比赛的题作者是别人,老师点了 404 且无提示;
  改成和编辑、删除一样按比赛创建者判(canEdit)
- 学生主页「智能分析」和「查看指定用户」只给超管,后端本来就允许老师;改成老师可见
- 排名页班级详情的「AI 分析」学生也看得到,点了权限不足;只给老师显示
- 班级 PK 页解构 store 丢了响应性,资料晚到时老师看不到「AI 分析」
- 学生管理员能给任何人的流程图重新评分且不限流;别人的提交只有老师能重评,免限流也只给老师
- 超管没退登就过期的机房电脑上,下一个登录的老师继承演示模式、看不到后台和看板且关不掉;
  演示模式只对超管生效
- 老师的出题权限能设成「无权限」,设了比赛题目页全是权限不足;后端兜成「仅自己」,
  编辑框里老师不再给这个选项

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 14:00:04 +08:00

935 lines
28 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.
<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 Chart from "./components/Chart.vue"
import RankMedal from "./components/RankMedal.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 { useAIStream } from "shared/composables/aiStream"
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 classDetailAiStream = useAIStream()
const classDetailAiLoading = classDetailAiStream.waiting
const classDetailAiContent = ref("")
const showClassDetailAiModal = ref(false)
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
showClassDetailModal.value = false
showClassDetailAiModal.value = true
classDetailAiContent.value = ""
try {
await classDetailAiStream.run(
"ai/class-analysis",
{ comparison: classDetailData.value },
{ onDelta: (content) => (classDetailAiContent.value += content) },
)
} catch (error) {
message.error((error as Error).message)
}
}
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: "排名",
key: "index",
width: 100,
align: "center",
render: (_, index) => h(RankMedal, { index, page: query.page, limit: query.limit }),
},
{
title: "用户",
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: "个性签名",
key: "mood",
minWidth: 200,
},
{
title: "已解决",
key: "acceptedNumber",
width: 120,
align: "center",
},
{
title: "提交数",
key: "submissionNumber",
width: 120,
align: "center",
},
{
title: "正确率",
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: "排名",
key: "rank",
width: 100,
align: "center",
// rank 是服务端给的周榜名次,不是行号 —— 换算回 RankMedal 要的 0 基下标
render: (row) => h(RankMedal, { index: row.rank - 1, page: 1, limit: 10 }),
},
{
title: "用户",
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: "新解决",
key: "solvedCount",
width: 100,
align: "center",
},
{
title: "提交",
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) {
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) {
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">
<!--
两张榜并排:左边全服总榜(分母是历史全部 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-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>
</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
v-if="userStore.isTeacherOrAbove"
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>