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
CI / CI OK (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
Deploy Website on push / Rerun on failure (push) Has been cancelled
Release Drafter / update_release_draft (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:
shawko 2026-05-10 19:06:40 +08:00
parent 7ee8f8f485
commit 391132e127
4 changed files with 472 additions and 2 deletions

View File

@ -111,7 +111,7 @@ function createRequestClient(baseURL: string, options?: RequestClientOptions) {
defaultResponseInterceptor({
codeField: 'code',
dataField: 'data',
successCode: 200,
successCode: (code) => code === 0 || code === 200,
}),
)

View File

@ -0,0 +1,57 @@
import type { PageResponse } from '#/api/http'
import { get, post } from '#/api/http'
export type ClassroomItem = {
id: number | string
name: string
}
export type ClassMonitorCurrent = {
classId: number | string
intervalMs: number
monitoring: boolean
sessionId: number | string
startTime: string
teacherId: number | string
}
export type ClassMonitorSnapshot = {
avatar: string
latestTs: number
latestUrl: string
staleSeconds: number
studentId: number | string
studentName: string
}
class ClassroomMonitorService {
async getClassList() {
return await get<PageResponse<ClassroomItem>>('/org/class/list', {
current: 1,
size: 25,
})
}
async getCurrent(classId: number | string) {
return await get<ClassMonitorCurrent>('/school/classMonitor/current', {
classId,
})
}
async getLatestSnapshots(classId: number | string) {
return await get<ClassMonitorSnapshot[]>(
'/school/classMonitor/latestSnapshots',
{ classId },
)
}
async updateMonitorStatus(classId: number | string, status: 0 | 1) {
return await post('/school/classMonitor/control', {
classId,
status,
})
}
}
export const classroomMonitorService = new ClassroomMonitorService()

View File

@ -0,0 +1,413 @@
<script setup lang="ts">
import type {
ClassMonitorCurrent,
ClassMonitorSnapshot,
ClassroomItem,
} from '../../../api/service/org/classroom-monitor'
import { computed, onMounted, ref } from 'vue'
import { classroomMonitorService } from '../../../api/service/org/classroom-monitor'
import hud from '../../../utils/hud'
const classLoading = ref(false)
const detailLoading = ref(false)
const snapshotLoading = ref(false)
const controlLoading = ref(false)
const classList = ref<ClassroomItem[]>([])
const selectedClass = ref<ClassroomItem>()
const currentMonitor = ref<ClassMonitorCurrent>()
const snapshots = ref<ClassMonitorSnapshot[]>([])
const isMonitoring = computed(() => currentMonitor.value?.monitoring === true)
onMounted(() => {
loadClassList()
})
async function loadClassList() {
classLoading.value = true
try {
const data = await classroomMonitorService.getClassList()
classList.value = data?.rows ?? []
} finally {
classLoading.value = false
}
}
async function handleViewMonitor(item: ClassroomItem) {
selectedClass.value = item
currentMonitor.value = undefined
snapshots.value = []
await loadMonitorDetail(item.id)
}
async function loadMonitorDetail(classId = selectedClass.value?.id) {
if (!classId) return
detailLoading.value = true
try {
const data = await classroomMonitorService.getCurrent(classId)
currentMonitor.value = data
if (data?.monitoring) {
await loadSnapshots(classId)
} else {
snapshots.value = []
}
} finally {
detailLoading.value = false
}
}
async function loadSnapshots(classId = selectedClass.value?.id) {
if (!classId) return
snapshotLoading.value = true
try {
snapshots.value = await classroomMonitorService.getLatestSnapshots(classId)
} finally {
snapshotLoading.value = false
}
}
async function updateMonitorStatus(status: 0 | 1) {
if (!selectedClass.value?.id) return
const option = status === 1 ? '开启' : '停止'
controlLoading.value = true
try {
await classroomMonitorService.updateMonitorStatus(selectedClass.value.id, status)
hud.success(`${option}监控成功`)
await loadMonitorDetail(selectedClass.value.id)
} finally {
controlLoading.value = false
}
}
function backToClassList() {
selectedClass.value = undefined
currentMonitor.value = undefined
snapshots.value = []
}
function formatTime(time?: number | string) {
if (!time) return '-'
const timestamp = Number(time)
if (Number.isNaN(timestamp)) return String(time)
const date = new Date(String(timestamp).length === 10 ? timestamp * 1000 : timestamp)
if (Number.isNaN(date.getTime())) return String(time)
return date.toLocaleString()
}
</script>
<template>
<div class="classroom-monitor g-container">
<el-card v-if="!selectedClass" class="content-card" shadow="never">
<template #header>
<div class="card-header">
<div>
<div class="page-title">课堂监控</div>
<div class="page-desc">请选择班级查看当前课堂监控</div>
</div>
<el-button :loading="classLoading" type="primary" @click="loadClassList">
刷新
</el-button>
</div>
</template>
<div v-loading="classLoading" class="class-list">
<div v-for="item in classList" :key="item.id" class="class-row">
<span class="class-name">{{ item.name }}</span>
<el-button type="primary" @click="handleViewMonitor(item)">
查看监控
</el-button>
</div>
<el-empty v-if="!classLoading && classList.length === 0" description="暂无班级" />
</div>
</el-card>
<el-card v-else class="content-card" shadow="never">
<template #header>
<div class="card-header">
<div>
<div class="page-title">{{ selectedClass.name }}</div>
<div class="page-desc">课堂监控详情</div>
</div>
<div class="header-actions">
<el-button @click="backToClassList">返回班级列表</el-button>
<el-button
:loading="detailLoading || snapshotLoading"
type="primary"
@click="loadMonitorDetail()"
>
刷新监控
</el-button>
</div>
</div>
</template>
<div v-loading="detailLoading" class="monitor-detail">
<div class="status-panel">
<div>
<span class="status-label">当前状态</span>
<el-tag :type="isMonitoring ? 'success' : 'info'" size="large">
{{ isMonitoring ? '监控中' : '未开启' }}
</el-tag>
</div>
<div v-if="currentMonitor?.startTime">
<span class="status-label">开始时间</span>
<strong>{{ currentMonitor.startTime }}</strong>
</div>
<div v-if="currentMonitor?.intervalMs">
<span class="status-label">采集间隔</span>
<strong>{{ currentMonitor.intervalMs }}ms</strong>
</div>
<el-button
v-if="isMonitoring"
:loading="controlLoading"
type="danger"
@click="updateMonitorStatus(0)"
>
停止监控
</el-button>
<el-button
v-else
:loading="controlLoading"
type="primary"
@click="updateMonitorStatus(1)"
>
开启监控
</el-button>
</div>
<template v-if="isMonitoring">
<div class="snapshot-header">
<div class="section-title">学生监控信息</div>
<el-button :loading="snapshotLoading" @click="loadSnapshots()">
刷新截图
</el-button>
</div>
<div v-loading="snapshotLoading" class="snapshot-grid">
<el-card
v-for="item in snapshots"
:key="item.studentId"
class="snapshot-card"
shadow="hover"
>
<el-image
v-if="item.latestUrl"
class="snapshot-image"
fit="cover"
:preview-src-list="[item.latestUrl]"
:src="item.latestUrl"
/>
<div v-else class="snapshot-empty">暂无截图</div>
<div class="student-info">
<el-avatar :size="36" :src="item.avatar">
{{ item.studentName?.slice(0, 1) || '学' }}
</el-avatar>
<div>
<div class="student-name">{{ item.studentName || '-' }}</div>
<div class="student-meta">
最新时间{{ formatTime(item.latestTs) }}
</div>
<div class="student-meta">
延迟{{ item.staleSeconds ?? 0 }}
</div>
</div>
</div>
</el-card>
<el-empty
v-if="!snapshotLoading && snapshots.length === 0"
description="暂无学生监控信息"
/>
</div>
</template>
</div>
</el-card>
</div>
</template>
<style lang="scss" scoped>
.classroom-monitor {
.content-card {
min-height: 360px;
border: 1px solid #edf0f5;
}
.card-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
}
.page-title {
color: #1f2937;
font-size: 20px;
font-weight: 600;
}
.page-desc {
margin-top: 6px;
color: #6b7280;
font-size: 14px;
}
.header-actions {
display: flex;
gap: 12px;
}
.class-list {
min-height: 240px;
}
.class-row {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 0;
border-bottom: 1px solid #edf0f5;
&:last-child {
border-bottom: 0;
}
}
.class-name {
color: #111827;
font-size: 16px;
font-weight: 500;
}
.monitor-detail {
min-height: 260px;
}
.status-panel {
display: flex;
align-items: center;
gap: 24px;
padding: 16px;
margin-bottom: 18px;
background: #f8fafc;
border: 1px solid #edf0f5;
border-radius: 8px;
> div {
display: flex;
align-items: center;
gap: 8px;
}
strong {
color: #111827;
font-weight: 600;
}
}
.status-label {
color: #6b7280;
font-size: 14px;
}
.snapshot-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 16px;
}
.section-title {
color: #111827;
font-size: 16px;
font-weight: 600;
}
.snapshot-grid {
display: grid;
min-height: 220px;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 16px;
}
.snapshot-card {
overflow: hidden;
}
.snapshot-image,
.snapshot-empty {
width: 100%;
height: 180px;
border-radius: 8px;
}
.snapshot-empty {
display: flex;
align-items: center;
justify-content: center;
color: #9ca3af;
background: #f3f4f6;
}
.student-info {
display: flex;
gap: 10px;
margin-top: 12px;
}
.student-name {
color: #111827;
font-weight: 600;
}
.student-meta {
margin-top: 4px;
color: #6b7280;
font-size: 12px;
}
}
@media (max-width: 1400px) {
.classroom-monitor {
.snapshot-grid {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
}
}
@media (max-width: 1080px) {
.classroom-monitor {
.snapshot-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.status-panel {
align-items: flex-start;
flex-direction: column;
}
}
}
@media (max-width: 768px) {
.classroom-monitor {
.card-header,
.class-row,
.snapshot-header {
align-items: stretch;
flex-direction: column;
}
.header-actions {
flex-direction: column;
}
.snapshot-grid {
grid-template-columns: 1fr;
}
}
}
</style>