feat: 新增教师看板
Some checks failed
CI / Test (ubuntu-latest) (push) Has been cancelled
CI / Test (windows-latest) (push) Has been cancelled
CI / Lint (ubuntu-latest) (push) Has been cancelled
CI / Lint (windows-latest) (push) Has been cancelled
CI / Check (ubuntu-latest) (push) Has been cancelled
CI / Check (windows-latest) (push) Has been cancelled
CodeQL / Analyze (javascript-typescript) (push) Has been cancelled
Deploy Website on push / Deploy Push Playground Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Docs Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Antd Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Element Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Naive Ftp (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
CI / CI OK (push) Has been cancelled
Deploy Website on push / Rerun on failure (push) Has been cancelled
Lock Threads / action (push) Has been cancelled
Issue Close Require / close-issues (push) Has been cancelled
Close stale issues / stale (push) Has been cancelled

This commit is contained in:
阿梦 2026-06-09 12:44:20 +08:00
parent db6f433179
commit 1bfec1424a
2 changed files with 384 additions and 1 deletions

View File

@ -64,3 +64,35 @@ export async function getClassHomeworkList(
const res = await get<any>('/school/report/classHomework/list', params)
return res ?? {}
}
/** AI 批改看板 - 概览数据 */
export interface AiCorrectionOverview {
classId: number | string
className: string
aiPaperCount: number
aiTitleCount: number
teacherSaveTimeMinutes: number
excellentCount: number
excellentRate: number
goodCount: number
goodRate: number
passCount: number
passRate: number
unsubmittedCount: number
unsubmittedRate: number
}
export interface AiCorrectionParams {
classId: string
schoolId?: string
subjectIds?: number[]
startTime?: string
endTime?: string
}
export async function getAiCorrectionOverview(
params: AiCorrectionParams,
): Promise<AiCorrectionOverview> {
const res = await get<any>('/school/report/aiCorrectionEfficiency/overview', params)
return res ?? {}
}

View File

@ -1,5 +1,6 @@
<script setup lang="ts">
import type {
AiCorrectionOverview,
ClassHomeworkResult,
ClassItem,
StudentHomework,
@ -7,17 +8,25 @@ import type {
} from '#/api/service/org/teacher-kanban'
import {
Aim,
CircleCheck,
CircleCheckFilled,
Clock,
Coin,
Collection,
DataAnalysis,
DataBoard,
Document,
Refresh,
Search,
TrendCharts,
User,
Warning,
} from '@element-plus/icons-vue'
import dayjs from 'dayjs'
import {
getAiCorrectionOverview,
getClassHomeworkList,
getClassListByTeacher,
getSubjectsByClassId,
@ -30,6 +39,9 @@ const selectedClassId = ref('')
const selectedSubjectIds = ref<string[]>([])
const dateRange = ref<[string, string] | undefined>(undefined)
const result = ref<ClassHomeworkResult | null>(null)
const aiOverview = ref<AiCorrectionOverview | null>(null)
const aiLoading = ref(false)
const aiErrorMsg = ref('')
const activeShortcut = ref('')
const shortcuts = [
@ -108,6 +120,31 @@ const summaryStats = computed(() => {
}
})
const saveTimeText = computed(() => {
const mins = aiOverview.value?.teacherSaveTimeMinutes || 0
if (mins <= 0) return 'AI 小助手已准备好为您分担批阅工作。'
if (mins >= 1440) return `AI 小助手已为您节省 ${(mins / 1440).toFixed(1).replace(/\.0$/, '')} 天。`
if (mins >= 60) return `AI 小助手已为您节省 ${(mins / 60).toFixed(1).replace(/\.0$/, '')} 小时。`
return `AI 小助手已为您节省 ${mins} 分钟。`
})
const gradeItems = computed(() => {
const data = aiOverview.value
return [
{ label: '优秀', count: data?.excellentCount || 0, rate: data?.excellentRate || 0, type: 'excellent' },
{ label: '良好', count: data?.goodCount || 0, rate: data?.goodRate || 0, type: 'good' },
{ label: '合格', count: data?.passCount || 0, rate: data?.passRate || 0, type: 'pass' },
{ label: '未提交', count: data?.unsubmittedCount || 0, rate: data?.unsubmittedRate || 0, type: 'unsubmit' },
]
})
const normalizeRate = (rate?: number | null) => {
if (!rate) return 0
return rate > 1 ? rate : rate * 100
}
const formatRate = (rate?: number | null) => `${normalizeRate(rate).toFixed(1).replace(/\.0$/, '')}%`
const rateWidth = (rate?: number | null) => `${Math.min(100, Math.max(0, normalizeRate(rate)))}%`
function getSubjectStat(student: StudentHomework, subjectId: any) {
return student.subjectStats?.find(
(s) => String(s.subjectId) === String(subjectId),
@ -158,6 +195,8 @@ async function loadSubjects() {
async function search() {
if (!selectedClassId.value) return
loading.value = true
aiLoading.value = true
aiErrorMsg.value = ''
try {
const params: any = { classId: selectedClassId.value }
if (dateRange.value?.[0]) params.startTime = dateRange.value[0]
@ -165,9 +204,19 @@ async function search() {
if (selectedSubjectIds.value.length > 0) {
params.subjectIds = selectedSubjectIds.value
}
result.value = await getClassHomeworkList(params)
const [homeworkRes, overviewRes] = await Promise.all([
getClassHomeworkList(params),
getAiCorrectionOverview(params).catch((err) => {
console.error('获取AI看板数据失败', err)
aiErrorMsg.value = 'AI看板数据加载失败'
return null
}),
])
result.value = homeworkRes
aiOverview.value = overviewRes
} finally {
loading.value = false
aiLoading.value = false
}
}
@ -181,6 +230,8 @@ function handleReset() {
function onClassChange() {
selectedSubjectIds.value = []
result.value = null
aiOverview.value = null
aiErrorMsg.value = ''
loadSubjects()
search()
}
@ -280,6 +331,91 @@ onMounted(async () => {
</div>
</div>
<!-- AI 批改看板 -->
<div class="ai-dashboard">
<div class="ai-dashboard__header">
<div class="ai-dashboard__title-row">
<el-icon :size="22" color="#4f46e5"><DataBoard /></el-icon>
<span class="ai-dashboard__title">AI 批改看板</span>
<span class="ai-dashboard__subtitle">按班级学科和日期查看效率数据</span>
</div>
<el-tag v-if="aiOverview?.className" type="info" effect="plain" size="small">
{{ aiOverview.className }}
</el-tag>
</div>
<!-- 加载态 -->
<div v-if="aiLoading" class="ai-dashboard__loading">
<el-skeleton :rows="3" animated />
</div>
<!-- 空状态 -->
<div v-else-if="!selectedClassId" class="ai-dashboard__empty">
<el-icon :size="40" color="#c0c4cc"><DataBoard /></el-icon>
<p>请选择班级查看看板</p>
</div>
<!-- 错误态 -->
<div v-else-if="aiErrorMsg" class="ai-dashboard__empty">
<el-icon :size="40" color="#f56c6c"><Warning /></el-icon>
<p style="color: #f56c6c">{{ aiErrorMsg }}</p>
</div>
<!-- 数据内容 -->
<template v-else-if="aiOverview">
<div class="ai-dashboard__metrics">
<div class="ai-metric-card">
<div class="ai-metric-card__icon ai-metric-card__icon--blue">
<el-icon :size="22"><Collection /></el-icon>
</div>
<div class="ai-metric-card__content">
<span class="ai-metric-card__value">{{ aiOverview.aiPaperCount || 0 }}</span>
<span class="ai-metric-card__label">批改试卷</span>
</div>
</div>
<div class="ai-metric-card">
<div class="ai-metric-card__icon ai-metric-card__icon--green">
<el-icon :size="22"><Aim /></el-icon>
</div>
<div class="ai-metric-card__content">
<span class="ai-metric-card__value">{{ aiOverview.aiTitleCount || 0 }}</span>
<span class="ai-metric-card__label">批改题目</span>
</div>
</div>
</div>
<div class="ai-save-card">
<el-icon :size="18" color="#16a34a" style="flex-shrink:0"><Clock /></el-icon>
<div class="ai-save-card__content">
<span class="ai-save-card__title">节省批阅时间</span>
<span class="ai-save-card__text">{{ saveTimeText }}</span>
</div>
</div>
<div class="ai-grade-card">
<div class="ai-grade-card__header">
<el-icon :size="18" color="#4f46e5"><TrendCharts /></el-icon>
<span class="ai-grade-card__title">成绩分布</span>
</div>
<div class="ai-grade-list">
<div v-for="item in gradeItems" :key="item.label" class="ai-grade-item">
<div class="ai-grade-item__top">
<span class="ai-grade-item__name">{{ item.label }}</span>
<span class="ai-grade-item__count">{{ item.count }} · {{ formatRate(item.rate) }}</span>
</div>
<div class="ai-grade-bar">
<div
class="ai-grade-fill"
:class="`ai-grade-fill--${item.type}`"
:style="{ width: rateWidth(item.rate) }"
></div>
</div>
</div>
</div>
</div>
</template>
</div>
<!-- 统计卡片 -->
<div v-if="result?.className" class="stats-row">
<div class="stat-card stat-card--primary">
@ -907,4 +1043,219 @@ onMounted(async () => {
color: #ff584e;
}
}
// AI
.ai-dashboard {
margin-bottom: 20px;
padding: 20px 24px;
background: #fff;
border-radius: 12px;
box-shadow: 0 1px 3px rgb(0 0 0 / 5%), 0 1px 2px rgb(0 0 0 / 3%);
&__header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 18px;
}
&__title-row {
display: flex;
gap: 8px;
align-items: center;
}
&__title {
font-size: 16px;
font-weight: 700;
color: #312e81;
}
&__subtitle {
font-size: 13px;
color: #94a3b8;
}
&__loading {
min-height: 180px;
padding: 20px 0;
}
&__empty {
display: flex;
flex-direction: column;
gap: 12px;
align-items: center;
justify-content: center;
min-height: 180px;
color: #94a3b8;
font-size: 14px;
}
&__metrics {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 14px;
margin-bottom: 14px;
}
}
.ai-metric-card {
display: flex;
gap: 14px;
align-items: center;
padding: 18px 20px;
background: linear-gradient(135deg, #f8fafc, #f1f5f9);
border-radius: 10px;
border: 1px solid #eef2ff;
transition: all 0.2s ease;
&:hover {
border-color: #c7d2fe;
box-shadow: 0 2px 8px rgb(79 70 229 / 8%);
}
&__icon {
display: flex;
flex-shrink: 0;
align-items: center;
justify-content: center;
width: 44px;
height: 44px;
border-radius: 10px;
&--blue {
color: #4f46e5;
background: linear-gradient(135deg, #eef2ff, #dce8fd);
}
&--green {
color: #16a34a;
background: linear-gradient(135deg, #edfcf7, #d4f5ea);
}
}
&__content {
display: flex;
flex-direction: column;
gap: 2px;
}
&__value {
font-size: 26px;
font-weight: 800;
line-height: 1.2;
color: #1e293b;
letter-spacing: -0.02em;
}
&__label {
font-size: 13px;
color: #94a3b8;
}
}
.ai-save-card {
display: flex;
gap: 12px;
align-items: center;
padding: 16px 20px;
margin-bottom: 14px;
background: linear-gradient(135deg, #f0fdf4, #dcfce7);
border-radius: 10px;
border: 1px solid #bbf7d0;
&__content {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
}
&__title {
font-size: 13px;
font-weight: 700;
color: #16a34a;
}
&__text {
font-size: 14px;
color: #334155;
}
}
.ai-grade-card {
padding: 18px 20px;
background: linear-gradient(135deg, #f8fafc, #f1f5f9);
border-radius: 10px;
border: 1px solid #eef2ff;
&__header {
display: flex;
gap: 6px;
align-items: center;
margin-bottom: 16px;
}
&__title {
font-size: 14px;
font-weight: 700;
color: #312e81;
}
}
.ai-grade-list {
display: flex;
flex-direction: column;
gap: 16px;
}
.ai-grade-item {
&__top {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 6px;
}
&__name {
font-size: 13px;
font-weight: 600;
color: #334155;
}
&__count {
font-size: 12px;
color: #94a3b8;
}
}
.ai-grade-bar {
height: 8px;
border-radius: 999px;
background: #eef2ff;
overflow: hidden;
}
.ai-grade-fill {
height: 100%;
border-radius: 999px;
transition: width 360ms ease;
&--excellent {
background: linear-gradient(90deg, #22c55e, #4ade80);
}
&--good {
background: linear-gradient(90deg, #3b82f6, #60a5fa);
}
&--pass {
background: linear-gradient(90deg, #f59e0b, #fbbf24);
}
&--unsubmit {
background: linear-gradient(90deg, #ef4444, #f87171);
}
}
</style>