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
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:
parent
db6f433179
commit
1bfec1424a
@ -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 ?? {}
|
||||
}
|
||||
|
||||
@ -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>
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user