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({
|
defaultResponseInterceptor({
|
||||||
codeField: 'code',
|
codeField: 'code',
|
||||||
dataField: 'data',
|
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()
|
||||||
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