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
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:
parent
7ee8f8f485
commit
391132e127
@ -111,7 +111,7 @@ function createRequestClient(baseURL: string, options?: RequestClientOptions) {
|
||||
defaultResponseInterceptor({
|
||||
codeField: 'code',
|
||||
dataField: 'data',
|
||||
successCode: 200,
|
||||
successCode: (code) => code === 0 || code === 200,
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
57
playground/src/api/service/org/classroom-monitor.ts
Normal file
57
playground/src/api/service/org/classroom-monitor.ts
Normal 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()
|
||||
@ -123,7 +123,7 @@ const grid = {
|
||||
{
|
||||
field: 'account',
|
||||
title: '学员账号',
|
||||
},
|
||||
},
|
||||
{
|
||||
field: 'serialNumber',
|
||||
title: '设备编码',
|
||||
|
||||
413
playground/src/views/org/classroom-monitor/home.vue
Normal file
413
playground/src/views/org/classroom-monitor/home.vue
Normal 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>
|
||||
Loading…
x
Reference in New Issue
Block a user