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
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
This commit is contained in:
parent
391132e127
commit
32189c0fbc
@ -5,6 +5,8 @@ import { get, post } from '#/api/http'
|
||||
export type ClassroomItem = {
|
||||
id: number | string
|
||||
name: string
|
||||
/** 1 监控开启,0 关闭 */
|
||||
monitorState?: number
|
||||
}
|
||||
|
||||
export type ClassMonitorCurrent = {
|
||||
@ -25,6 +27,61 @@ export type ClassMonitorSnapshot = {
|
||||
studentName: string
|
||||
}
|
||||
|
||||
export type ClassMonitorStudentSnapshot = {
|
||||
ts: number
|
||||
url: string
|
||||
}
|
||||
|
||||
export type ClassMonitorSessionRow = {
|
||||
classId: number | string
|
||||
className: string
|
||||
durationSec: number
|
||||
endTime: string
|
||||
intervalMs: number
|
||||
schoolId: number | string
|
||||
sessionId: number | string
|
||||
snapshotCount: number
|
||||
startTime: string
|
||||
status: number
|
||||
studentCount: number
|
||||
teacherId: number | string
|
||||
teacherName: string
|
||||
}
|
||||
|
||||
/** `/school/classMonitor/sessionDetail` 中 data 内单条学生(与列表快照字段接近,可能含 snapshotCount) */
|
||||
export type ClassMonitorSessionDetailStudent = {
|
||||
avatar: string
|
||||
latestTs: number
|
||||
latestUrl: string
|
||||
snapshotCount?: number
|
||||
staleSeconds?: number
|
||||
studentId: number | string
|
||||
studentName: string
|
||||
}
|
||||
|
||||
/** `/school/classMonitor/sessionDetail` 返回的 data 对象 */
|
||||
export type ClassMonitorSessionDetail = {
|
||||
classId: number | string
|
||||
className: string
|
||||
durationSec: number
|
||||
endTime: string
|
||||
intervalMs: number
|
||||
schoolId: number | string
|
||||
sessionId: number | string
|
||||
snapshotCount: number
|
||||
startTime: string
|
||||
status: number
|
||||
studentCount: number
|
||||
students: ClassMonitorSessionDetailStudent[]
|
||||
teacherId: number | string
|
||||
teacherName: string
|
||||
}
|
||||
|
||||
/** 分页里的扩展字段 rainbow 暂不消费 */
|
||||
export type ClassMonitorSessionsPage = PageResponse<ClassMonitorSessionRow> & {
|
||||
rainbow?: unknown[]
|
||||
}
|
||||
|
||||
class ClassroomMonitorService {
|
||||
async getClassList() {
|
||||
return await get<PageResponse<ClassroomItem>>('/org/class/list', {
|
||||
@ -46,6 +103,41 @@ class ClassroomMonitorService {
|
||||
)
|
||||
}
|
||||
|
||||
async getStudentSnapshots(
|
||||
sessionId: number | string,
|
||||
studentId: number | string,
|
||||
) {
|
||||
return await get<ClassMonitorStudentSnapshot[]>(
|
||||
'/school/classMonitor/studentSnapshots',
|
||||
{ sessionId, studentId },
|
||||
)
|
||||
}
|
||||
|
||||
async getSessions(
|
||||
classId: number | string,
|
||||
params?: { current?: number; size?: number },
|
||||
) {
|
||||
return await post<ClassMonitorSessionsPage>('/school/classMonitor/sessions', {
|
||||
classId,
|
||||
current: params?.current ?? 1,
|
||||
size: params?.size ?? 10,
|
||||
})
|
||||
}
|
||||
|
||||
async getSessionDetail(
|
||||
sessionId: number | string,
|
||||
classId?: number | string,
|
||||
) {
|
||||
const params: Record<string, number | string> = { sessionId }
|
||||
if (classId !== undefined && classId !== '') {
|
||||
params.classId = classId
|
||||
}
|
||||
return await get<ClassMonitorSessionDetail>(
|
||||
'/school/classMonitor/sessionDetail',
|
||||
params,
|
||||
)
|
||||
}
|
||||
|
||||
async updateMonitorStatus(classId: number | string, status: 0 | 1) {
|
||||
return await post('/school/classMonitor/control', {
|
||||
classId,
|
||||
|
||||
@ -324,12 +324,20 @@ function updateScreenLock(row: any, status: 0 | 1) {
|
||||
v-bind="$attrs"
|
||||
>
|
||||
<template #operation="{ row }">
|
||||
<el-button type="danger" @click="updateScreenLock(row, 1)">
|
||||
锁屏
|
||||
</el-button>
|
||||
<el-button type="success" @click="updateScreenLock(row, 0)">
|
||||
<el-button
|
||||
v-if="row.screenLock === 1"
|
||||
type="success"
|
||||
@click="updateScreenLock(row, 0)"
|
||||
>
|
||||
解锁
|
||||
</el-button>
|
||||
<el-button
|
||||
v-else
|
||||
type="danger"
|
||||
@click="updateScreenLock(row, 1)"
|
||||
>
|
||||
锁屏
|
||||
</el-button>
|
||||
</template>
|
||||
<template #currencyFlag="{ row }">
|
||||
<el-switch
|
||||
|
||||
@ -1,31 +1,58 @@
|
||||
<script setup lang="ts">
|
||||
import type {
|
||||
ClassMonitorCurrent,
|
||||
ClassMonitorSessionRow,
|
||||
ClassMonitorSnapshot,
|
||||
ClassMonitorStudentSnapshot,
|
||||
ClassroomItem,
|
||||
} from '../../../api/service/org/classroom-monitor'
|
||||
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { computed, onBeforeUnmount, onMounted, reactive, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
import { classroomMonitorService } from '../../../api/service/org/classroom-monitor'
|
||||
import hud from '../../../utils/hud'
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
const classLoading = ref(false)
|
||||
const detailLoading = ref(false)
|
||||
const snapshotLoading = ref(false)
|
||||
const controlLoading = ref(false)
|
||||
const studentSnapshotLoading = ref(false)
|
||||
|
||||
const classList = ref<ClassroomItem[]>([])
|
||||
const selectedClass = ref<ClassroomItem>()
|
||||
const currentMonitor = ref<ClassMonitorCurrent>()
|
||||
const snapshots = ref<ClassMonitorSnapshot[]>([])
|
||||
const selectedSnapshotStudent = ref<ClassMonitorSnapshot>()
|
||||
const studentSnapshots = ref<ClassMonitorStudentSnapshot[]>([])
|
||||
const studentSnapshotDialogVisible = ref(false)
|
||||
|
||||
const sessionsDialogVisible = ref(false)
|
||||
const sessionsLoading = ref(false)
|
||||
const sessionsRows = ref<ClassMonitorSessionRow[]>([])
|
||||
const sessionsPage = reactive({
|
||||
current: 1,
|
||||
size: 10,
|
||||
total: 0,
|
||||
})
|
||||
|
||||
const isMonitoring = computed(() => currentMonitor.value?.monitoring === true)
|
||||
const studentSnapshotPreviewList = computed(() => {
|
||||
return studentSnapshots.value.map((item) => item.url).filter(Boolean)
|
||||
})
|
||||
const SNAPSHOT_REFRESH_INTERVAL = 5000
|
||||
let snapshotRefreshTimer: ReturnType<typeof setInterval> | undefined
|
||||
|
||||
onMounted(() => {
|
||||
loadClassList()
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
stopSnapshotAutoRefresh()
|
||||
})
|
||||
|
||||
async function loadClassList() {
|
||||
classLoading.value = true
|
||||
try {
|
||||
@ -37,6 +64,7 @@ async function loadClassList() {
|
||||
}
|
||||
|
||||
async function handleViewMonitor(item: ClassroomItem) {
|
||||
stopSnapshotAutoRefresh()
|
||||
selectedClass.value = item
|
||||
currentMonitor.value = undefined
|
||||
snapshots.value = []
|
||||
@ -51,7 +79,9 @@ async function loadMonitorDetail(classId = selectedClass.value?.id) {
|
||||
currentMonitor.value = data
|
||||
if (data?.monitoring) {
|
||||
await loadSnapshots(classId)
|
||||
startSnapshotAutoRefresh()
|
||||
} else {
|
||||
stopSnapshotAutoRefresh()
|
||||
snapshots.value = []
|
||||
}
|
||||
} finally {
|
||||
@ -69,6 +99,44 @@ async function loadSnapshots(classId = selectedClass.value?.id) {
|
||||
}
|
||||
}
|
||||
|
||||
async function openStudentSnapshots(item: ClassMonitorSnapshot) {
|
||||
if (!currentMonitor.value?.sessionId) {
|
||||
hud.error('暂无监控会话')
|
||||
return
|
||||
}
|
||||
selectedSnapshotStudent.value = item
|
||||
studentSnapshots.value = []
|
||||
studentSnapshotDialogVisible.value = true
|
||||
studentSnapshotLoading.value = true
|
||||
try {
|
||||
studentSnapshots.value = await classroomMonitorService.getStudentSnapshots(
|
||||
currentMonitor.value.sessionId,
|
||||
item.studentId,
|
||||
)
|
||||
} finally {
|
||||
studentSnapshotLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function startSnapshotAutoRefresh() {
|
||||
if (snapshotRefreshTimer) return
|
||||
snapshotRefreshTimer = setInterval(() => {
|
||||
if (!isMonitoring.value || !selectedClass.value?.id) {
|
||||
stopSnapshotAutoRefresh()
|
||||
return
|
||||
}
|
||||
if (!snapshotLoading.value) {
|
||||
loadSnapshots(selectedClass.value.id)
|
||||
}
|
||||
}, SNAPSHOT_REFRESH_INTERVAL)
|
||||
}
|
||||
|
||||
function stopSnapshotAutoRefresh() {
|
||||
if (!snapshotRefreshTimer) return
|
||||
clearInterval(snapshotRefreshTimer)
|
||||
snapshotRefreshTimer = undefined
|
||||
}
|
||||
|
||||
async function updateMonitorStatus(status: 0 | 1) {
|
||||
if (!selectedClass.value?.id) return
|
||||
const option = status === 1 ? '开启' : '停止'
|
||||
@ -83,9 +151,68 @@ async function updateMonitorStatus(status: 0 | 1) {
|
||||
}
|
||||
|
||||
function backToClassList() {
|
||||
stopSnapshotAutoRefresh()
|
||||
selectedClass.value = undefined
|
||||
currentMonitor.value = undefined
|
||||
snapshots.value = []
|
||||
studentSnapshotDialogVisible.value = false
|
||||
sessionsDialogVisible.value = false
|
||||
}
|
||||
|
||||
function formatDurationSec(sec?: number | string) {
|
||||
if (sec === undefined || sec === null || sec === '') return '-'
|
||||
const n = Number(sec)
|
||||
if (Number.isNaN(n) || n < 0) return '-'
|
||||
const s = Math.floor(n)
|
||||
const m = Math.floor(s / 60)
|
||||
const rest = s % 60
|
||||
if (m === 0) return `${rest}秒`
|
||||
return `${m}分${rest}秒`
|
||||
}
|
||||
|
||||
async function openHistoryMonitor() {
|
||||
if (!selectedClass.value?.id) return
|
||||
sessionsPage.current = 1
|
||||
sessionsDialogVisible.value = true
|
||||
await loadSessions()
|
||||
}
|
||||
|
||||
async function loadSessions() {
|
||||
if (!selectedClass.value?.id) return
|
||||
sessionsLoading.value = true
|
||||
try {
|
||||
const data = await classroomMonitorService.getSessions(selectedClass.value.id, {
|
||||
current: sessionsPage.current,
|
||||
size: sessionsPage.size,
|
||||
})
|
||||
sessionsRows.value = data?.rows ?? []
|
||||
sessionsPage.total = data?.totalRows ?? 0
|
||||
} finally {
|
||||
sessionsLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function onSessionsSizeChange(size: number) {
|
||||
sessionsPage.size = size
|
||||
sessionsPage.current = 1
|
||||
loadSessions()
|
||||
}
|
||||
|
||||
function onSessionsPageChange(p: number) {
|
||||
sessionsPage.current = p
|
||||
loadSessions()
|
||||
}
|
||||
|
||||
function onSessionRowClick(row: ClassMonitorSessionRow) {
|
||||
if (!selectedClass.value) return
|
||||
sessionsDialogVisible.value = false
|
||||
router.push({
|
||||
path: `/org/classroom-monitor/session/${encodeURIComponent(String(row.sessionId))}`,
|
||||
query: {
|
||||
classId: String(selectedClass.value.id),
|
||||
className: selectedClass.value.name ?? '',
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function formatTime(time?: number | string) {
|
||||
@ -115,7 +242,17 @@ function formatTime(time?: number | string) {
|
||||
|
||||
<div v-loading="classLoading" class="class-list">
|
||||
<div v-for="item in classList" :key="item.id" class="class-row">
|
||||
<div class="class-row-info">
|
||||
<span class="class-name">{{ item.name }}</span>
|
||||
<el-tag
|
||||
:type="item.monitorState === 1 ? 'success' : 'info'"
|
||||
size="small"
|
||||
>
|
||||
{{
|
||||
item.monitorState === 1 ? '监控开启' : '监控关闭'
|
||||
}}
|
||||
</el-tag>
|
||||
</div>
|
||||
<el-button type="primary" @click="handleViewMonitor(item)">
|
||||
查看监控
|
||||
</el-button>
|
||||
@ -134,6 +271,7 @@ function formatTime(time?: number | string) {
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<el-button @click="backToClassList">返回班级列表</el-button>
|
||||
<el-button @click="openHistoryMonitor">历史监控</el-button>
|
||||
<el-button
|
||||
:loading="detailLoading || snapshotLoading"
|
||||
type="primary"
|
||||
@ -193,12 +331,12 @@ function formatTime(time?: number | string) {
|
||||
:key="item.studentId"
|
||||
class="snapshot-card"
|
||||
shadow="hover"
|
||||
@click="openStudentSnapshots(item)"
|
||||
>
|
||||
<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>
|
||||
@ -227,6 +365,104 @@ function formatTime(time?: number | string) {
|
||||
</template>
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<el-dialog
|
||||
v-model="sessionsDialogVisible"
|
||||
title="历史监控"
|
||||
width="960px"
|
||||
align-center
|
||||
destroy-on-close
|
||||
>
|
||||
<div v-loading="sessionsLoading">
|
||||
<el-table
|
||||
:data="sessionsRows"
|
||||
stripe
|
||||
highlight-current-row
|
||||
class="sessions-table"
|
||||
@row-click="onSessionRowClick"
|
||||
>
|
||||
<el-table-column prop="sessionId" label="会话 ID" width="100" />
|
||||
<el-table-column prop="teacherName" label="教师" min-width="110" />
|
||||
<el-table-column prop="startTime" label="开始时间" min-width="160" />
|
||||
<el-table-column prop="endTime" label="结束时间" min-width="160" />
|
||||
<el-table-column label="时长" width="110">
|
||||
<template #default="{ row }">
|
||||
{{ formatDurationSec(row.durationSec) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="studentCount" label="学生数" width="88" />
|
||||
<el-table-column prop="snapshotCount" label="截图数" width="88" />
|
||||
<el-table-column prop="status" label="状态" width="80" />
|
||||
<el-table-column prop="intervalMs" label="采集间隔(ms)" width="120" />
|
||||
<el-table-column label="操作" width="88" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button type="primary" link @click.stop="onSessionRowClick(row)">
|
||||
查看
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<div class="sessions-pagination">
|
||||
<el-pagination
|
||||
:current-page="sessionsPage.current"
|
||||
:page-size="sessionsPage.size"
|
||||
layout="total, sizes, prev, pager, next"
|
||||
:total="sessionsPage.total"
|
||||
:page-sizes="[10, 20, 50]"
|
||||
@current-change="onSessionsPageChange"
|
||||
@size-change="onSessionsSizeChange"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</el-dialog>
|
||||
|
||||
<el-dialog
|
||||
v-model="studentSnapshotDialogVisible"
|
||||
:title="`${selectedSnapshotStudent?.studentName || '-'}的全部截图`"
|
||||
width="900px"
|
||||
align-center
|
||||
destroy-on-close
|
||||
>
|
||||
<div v-loading="studentSnapshotLoading" class="student-snapshot-dialog">
|
||||
<div class="student-snapshot-summary">
|
||||
<el-avatar :size="40" :src="selectedSnapshotStudent?.avatar">
|
||||
{{ selectedSnapshotStudent?.studentName?.slice(0, 1) || '学' }}
|
||||
</el-avatar>
|
||||
<div>
|
||||
<div class="student-name">
|
||||
{{ selectedSnapshotStudent?.studentName || '-' }}
|
||||
</div>
|
||||
<div class="student-meta">
|
||||
共 {{ studentSnapshots.length }} 张截图
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="studentSnapshots.length" class="student-snapshot-list">
|
||||
<div
|
||||
v-for="item in studentSnapshots"
|
||||
:key="`${item.ts}-${item.url}`"
|
||||
class="student-snapshot-item"
|
||||
>
|
||||
<el-image
|
||||
class="student-snapshot-image"
|
||||
fit="cover"
|
||||
:preview-src-list="studentSnapshotPreviewList"
|
||||
:src="item.url"
|
||||
/>
|
||||
<div class="student-meta">
|
||||
截图时间:{{ formatTime(item.ts) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-empty
|
||||
v-else-if="!studentSnapshotLoading"
|
||||
description="暂无截图"
|
||||
/>
|
||||
</div>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@ -277,6 +513,14 @@ function formatTime(time?: number | string) {
|
||||
}
|
||||
}
|
||||
|
||||
.class-row-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.class-name {
|
||||
color: #111827;
|
||||
font-size: 16px;
|
||||
@ -335,7 +579,15 @@ function formatTime(time?: number | string) {
|
||||
}
|
||||
|
||||
.snapshot-card {
|
||||
cursor: pointer;
|
||||
overflow: hidden;
|
||||
transition:
|
||||
border-color 0.2s,
|
||||
box-shadow 0.2s;
|
||||
|
||||
&:hover {
|
||||
border-color: #409eff;
|
||||
}
|
||||
}
|
||||
|
||||
.snapshot-image,
|
||||
@ -369,6 +621,50 @@ function formatTime(time?: number | string) {
|
||||
color: #6b7280;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.student-snapshot-dialog {
|
||||
min-height: 240px;
|
||||
}
|
||||
|
||||
.student-snapshot-summary {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding-bottom: 16px;
|
||||
margin-bottom: 16px;
|
||||
border-bottom: 1px solid #edf0f5;
|
||||
}
|
||||
|
||||
.student-snapshot-list {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 16px;
|
||||
max-height: 620px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.student-snapshot-item {
|
||||
padding: 12px;
|
||||
border: 1px solid #edf0f5;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.student-snapshot-image {
|
||||
width: 100%;
|
||||
height: 180px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.sessions-table {
|
||||
cursor: pointer;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.sessions-pagination {
|
||||
margin-top: 16px;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 1400px) {
|
||||
@ -376,6 +672,10 @@ function formatTime(time?: number | string) {
|
||||
.snapshot-grid {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.student-snapshot-list {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -408,6 +708,10 @@ function formatTime(time?: number | string) {
|
||||
.snapshot-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.student-snapshot-list {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
15
playground/src/views/org/classroom-monitor/routes.ts
Normal file
15
playground/src/views/org/classroom-monitor/routes.ts
Normal file
@ -0,0 +1,15 @@
|
||||
import type { RouteRecordRaw } from 'vue-router'
|
||||
|
||||
const routes: RouteRecordRaw[] = [
|
||||
{
|
||||
path: '/org/classroom-monitor/session/:sessionId',
|
||||
name: 'ClassroomMonitorSessionDetail',
|
||||
component: () => import('./session-detail.vue'),
|
||||
meta: {
|
||||
title: '历史监控会话',
|
||||
hideInMenu: true,
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
export default routes
|
||||
417
playground/src/views/org/classroom-monitor/session-detail.vue
Normal file
417
playground/src/views/org/classroom-monitor/session-detail.vue
Normal file
@ -0,0 +1,417 @@
|
||||
<script setup lang="ts">
|
||||
import type {
|
||||
ClassMonitorSessionDetail,
|
||||
ClassMonitorSessionDetailStudent,
|
||||
ClassMonitorSnapshot,
|
||||
ClassMonitorStudentSnapshot,
|
||||
} from '../../../api/service/org/classroom-monitor'
|
||||
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
|
||||
import { classroomMonitorService } from '../../../api/service/org/classroom-monitor'
|
||||
import hud from '../../../utils/hud'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
const detailLoading = ref(false)
|
||||
const studentSnapshotLoading = ref(false)
|
||||
const snapshots = ref<ClassMonitorSnapshot[]>([])
|
||||
const selectedSnapshotStudent = ref<ClassMonitorSnapshot>()
|
||||
const studentSnapshots = ref<ClassMonitorStudentSnapshot[]>([])
|
||||
const studentSnapshotDialogVisible = ref(false)
|
||||
const sessionMeta = ref<ClassMonitorSessionDetail | null>(null)
|
||||
|
||||
const sessionId = computed(() => String(route.params.sessionId || ''))
|
||||
const classId = computed(() => {
|
||||
const q = route.query.classId
|
||||
if (typeof q === 'string' && q) return q
|
||||
if (Array.isArray(q) && q[0]) return String(q[0])
|
||||
return ''
|
||||
})
|
||||
const classDisplayName = computed(() => {
|
||||
const q = route.query.className
|
||||
if (typeof q === 'string' && q) return q
|
||||
const fromApi = sessionMeta.value?.className
|
||||
return typeof fromApi === 'string' && fromApi ? fromApi : ''
|
||||
})
|
||||
|
||||
const studentSnapshotPreviewList = computed(() => {
|
||||
return studentSnapshots.value.map((item) => item.url).filter(Boolean)
|
||||
})
|
||||
|
||||
watch(
|
||||
() => [sessionId.value, classId.value] as const,
|
||||
([id]) => {
|
||||
if (id) loadSessionDetail()
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
function isValidSessionStudent(
|
||||
row: unknown,
|
||||
): row is ClassMonitorSessionDetailStudent {
|
||||
if (row == null || typeof row !== 'object') return false
|
||||
const r = row as ClassMonitorSessionDetailStudent
|
||||
return r.studentId != null && r.studentId !== ''
|
||||
}
|
||||
|
||||
function mapSessionStudentToSnapshot(
|
||||
s: ClassMonitorSessionDetailStudent,
|
||||
): ClassMonitorSnapshot {
|
||||
return {
|
||||
avatar: s.avatar ?? '',
|
||||
latestTs: s.latestTs,
|
||||
latestUrl: s.latestUrl ?? '',
|
||||
staleSeconds: s.staleSeconds ?? 0,
|
||||
studentId: s.studentId,
|
||||
studentName: s.studentName ?? '',
|
||||
}
|
||||
}
|
||||
|
||||
async function loadSessionDetail() {
|
||||
const sid = sessionId.value
|
||||
if (!sid) return
|
||||
detailLoading.value = true
|
||||
try {
|
||||
const cid = classId.value
|
||||
const detail = await classroomMonitorService.getSessionDetail(
|
||||
sid,
|
||||
cid || undefined,
|
||||
)
|
||||
sessionMeta.value = detail ?? null
|
||||
const students = detail?.students ?? []
|
||||
snapshots.value = students
|
||||
.filter(isValidSessionStudent)
|
||||
.map(mapSessionStudentToSnapshot)
|
||||
} finally {
|
||||
detailLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function openStudentSnapshots(item: ClassMonitorSnapshot) {
|
||||
const sid = sessionId.value
|
||||
if (!sid) {
|
||||
hud.error('暂无会话')
|
||||
return
|
||||
}
|
||||
if (item?.studentId == null || item.studentId === '') {
|
||||
hud.error('学生信息不完整')
|
||||
return
|
||||
}
|
||||
selectedSnapshotStudent.value = item
|
||||
studentSnapshots.value = []
|
||||
studentSnapshotDialogVisible.value = true
|
||||
studentSnapshotLoading.value = true
|
||||
try {
|
||||
studentSnapshots.value = await classroomMonitorService.getStudentSnapshots(
|
||||
sid,
|
||||
item.studentId,
|
||||
)
|
||||
} finally {
|
||||
studentSnapshotLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
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-session g-container">
|
||||
<el-card class="content-card" shadow="never">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<div>
|
||||
<div class="page-title">
|
||||
{{ classDisplayName ? `${classDisplayName} · ` : '' }}历史会话
|
||||
</div>
|
||||
<div class="page-desc">
|
||||
会话 ID:{{ sessionId || '-' }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<el-button @click="router.back()">返回</el-button>
|
||||
<el-button :loading="detailLoading" type="primary" @click="loadSessionDetail()">
|
||||
刷新
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div v-loading="detailLoading" class="snapshot-section">
|
||||
<div class="snapshot-header">
|
||||
<div class="section-title">学生监控信息</div>
|
||||
</div>
|
||||
|
||||
<div class="snapshot-grid">
|
||||
<el-card
|
||||
v-for="(item, idx) in snapshots"
|
||||
:key="`${item.studentId}-${idx}`"
|
||||
class="snapshot-card"
|
||||
shadow="hover"
|
||||
@click="openStudentSnapshots(item)"
|
||||
>
|
||||
<el-image
|
||||
v-if="item.latestUrl"
|
||||
class="snapshot-image"
|
||||
fit="cover"
|
||||
: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="!detailLoading && snapshots.length === 0"
|
||||
description="暂无学生监控信息"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<el-dialog
|
||||
v-model="studentSnapshotDialogVisible"
|
||||
:title="`${selectedSnapshotStudent?.studentName || '-'}的全部截图`"
|
||||
width="900px"
|
||||
align-center
|
||||
destroy-on-close
|
||||
>
|
||||
<div v-loading="studentSnapshotLoading" class="student-snapshot-dialog">
|
||||
<div class="student-snapshot-summary">
|
||||
<el-avatar :size="40" :src="selectedSnapshotStudent?.avatar">
|
||||
{{ selectedSnapshotStudent?.studentName?.slice(0, 1) || '学' }}
|
||||
</el-avatar>
|
||||
<div>
|
||||
<div class="student-name">
|
||||
{{ selectedSnapshotStudent?.studentName || '-' }}
|
||||
</div>
|
||||
<div class="student-meta">
|
||||
共 {{ studentSnapshots.length }} 张截图
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="studentSnapshots.length" class="student-snapshot-list">
|
||||
<div
|
||||
v-for="item in studentSnapshots"
|
||||
:key="`${item.ts}-${item.url}`"
|
||||
class="student-snapshot-item"
|
||||
>
|
||||
<el-image
|
||||
class="student-snapshot-image"
|
||||
fit="cover"
|
||||
:preview-src-list="studentSnapshotPreviewList"
|
||||
:src="item.url"
|
||||
/>
|
||||
<div class="student-meta">
|
||||
截图时间:{{ formatTime(item.ts) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-empty
|
||||
v-else-if="!studentSnapshotLoading"
|
||||
description="暂无截图"
|
||||
/>
|
||||
</div>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.classroom-monitor-session {
|
||||
.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;
|
||||
}
|
||||
|
||||
.snapshot-section {
|
||||
min-height: 260px;
|
||||
}
|
||||
|
||||
.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 {
|
||||
cursor: pointer;
|
||||
overflow: hidden;
|
||||
transition:
|
||||
border-color 0.2s,
|
||||
box-shadow 0.2s;
|
||||
|
||||
&:hover {
|
||||
border-color: #409eff;
|
||||
}
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
.student-snapshot-dialog {
|
||||
min-height: 240px;
|
||||
}
|
||||
|
||||
.student-snapshot-summary {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding-bottom: 16px;
|
||||
margin-bottom: 16px;
|
||||
border-bottom: 1px solid #edf0f5;
|
||||
}
|
||||
|
||||
.student-snapshot-list {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 16px;
|
||||
max-height: 620px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.student-snapshot-item {
|
||||
padding: 12px;
|
||||
border: 1px solid #edf0f5;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.student-snapshot-image {
|
||||
width: 100%;
|
||||
height: 180px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 1400px) {
|
||||
.classroom-monitor-session {
|
||||
.snapshot-grid {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.student-snapshot-list {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 1080px) {
|
||||
.classroom-monitor-session {
|
||||
.snapshot-grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.classroom-monitor-session {
|
||||
.card-header,
|
||||
.snapshot-header {
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.snapshot-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.student-snapshot-list {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
Loading…
x
Reference in New Issue
Block a user