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 = {
|
export type ClassroomItem = {
|
||||||
id: number | string
|
id: number | string
|
||||||
name: string
|
name: string
|
||||||
|
/** 1 监控开启,0 关闭 */
|
||||||
|
monitorState?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ClassMonitorCurrent = {
|
export type ClassMonitorCurrent = {
|
||||||
@ -25,6 +27,61 @@ export type ClassMonitorSnapshot = {
|
|||||||
studentName: string
|
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 {
|
class ClassroomMonitorService {
|
||||||
async getClassList() {
|
async getClassList() {
|
||||||
return await get<PageResponse<ClassroomItem>>('/org/class/list', {
|
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) {
|
async updateMonitorStatus(classId: number | string, status: 0 | 1) {
|
||||||
return await post('/school/classMonitor/control', {
|
return await post('/school/classMonitor/control', {
|
||||||
classId,
|
classId,
|
||||||
|
|||||||
@ -324,12 +324,20 @@ function updateScreenLock(row: any, status: 0 | 1) {
|
|||||||
v-bind="$attrs"
|
v-bind="$attrs"
|
||||||
>
|
>
|
||||||
<template #operation="{ row }">
|
<template #operation="{ row }">
|
||||||
<el-button type="danger" @click="updateScreenLock(row, 1)">
|
<el-button
|
||||||
锁屏
|
v-if="row.screenLock === 1"
|
||||||
</el-button>
|
type="success"
|
||||||
<el-button type="success" @click="updateScreenLock(row, 0)">
|
@click="updateScreenLock(row, 0)"
|
||||||
|
>
|
||||||
解锁
|
解锁
|
||||||
</el-button>
|
</el-button>
|
||||||
|
<el-button
|
||||||
|
v-else
|
||||||
|
type="danger"
|
||||||
|
@click="updateScreenLock(row, 1)"
|
||||||
|
>
|
||||||
|
锁屏
|
||||||
|
</el-button>
|
||||||
</template>
|
</template>
|
||||||
<template #currencyFlag="{ row }">
|
<template #currencyFlag="{ row }">
|
||||||
<el-switch
|
<el-switch
|
||||||
|
|||||||
@ -1,31 +1,58 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type {
|
import type {
|
||||||
ClassMonitorCurrent,
|
ClassMonitorCurrent,
|
||||||
|
ClassMonitorSessionRow,
|
||||||
ClassMonitorSnapshot,
|
ClassMonitorSnapshot,
|
||||||
|
ClassMonitorStudentSnapshot,
|
||||||
ClassroomItem,
|
ClassroomItem,
|
||||||
} from '../../../api/service/org/classroom-monitor'
|
} 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 { classroomMonitorService } from '../../../api/service/org/classroom-monitor'
|
||||||
import hud from '../../../utils/hud'
|
import hud from '../../../utils/hud'
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
const classLoading = ref(false)
|
const classLoading = ref(false)
|
||||||
const detailLoading = ref(false)
|
const detailLoading = ref(false)
|
||||||
const snapshotLoading = ref(false)
|
const snapshotLoading = ref(false)
|
||||||
const controlLoading = ref(false)
|
const controlLoading = ref(false)
|
||||||
|
const studentSnapshotLoading = ref(false)
|
||||||
|
|
||||||
const classList = ref<ClassroomItem[]>([])
|
const classList = ref<ClassroomItem[]>([])
|
||||||
const selectedClass = ref<ClassroomItem>()
|
const selectedClass = ref<ClassroomItem>()
|
||||||
const currentMonitor = ref<ClassMonitorCurrent>()
|
const currentMonitor = ref<ClassMonitorCurrent>()
|
||||||
const snapshots = ref<ClassMonitorSnapshot[]>([])
|
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 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(() => {
|
onMounted(() => {
|
||||||
loadClassList()
|
loadClassList()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
stopSnapshotAutoRefresh()
|
||||||
|
})
|
||||||
|
|
||||||
async function loadClassList() {
|
async function loadClassList() {
|
||||||
classLoading.value = true
|
classLoading.value = true
|
||||||
try {
|
try {
|
||||||
@ -37,6 +64,7 @@ async function loadClassList() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function handleViewMonitor(item: ClassroomItem) {
|
async function handleViewMonitor(item: ClassroomItem) {
|
||||||
|
stopSnapshotAutoRefresh()
|
||||||
selectedClass.value = item
|
selectedClass.value = item
|
||||||
currentMonitor.value = undefined
|
currentMonitor.value = undefined
|
||||||
snapshots.value = []
|
snapshots.value = []
|
||||||
@ -51,7 +79,9 @@ async function loadMonitorDetail(classId = selectedClass.value?.id) {
|
|||||||
currentMonitor.value = data
|
currentMonitor.value = data
|
||||||
if (data?.monitoring) {
|
if (data?.monitoring) {
|
||||||
await loadSnapshots(classId)
|
await loadSnapshots(classId)
|
||||||
|
startSnapshotAutoRefresh()
|
||||||
} else {
|
} else {
|
||||||
|
stopSnapshotAutoRefresh()
|
||||||
snapshots.value = []
|
snapshots.value = []
|
||||||
}
|
}
|
||||||
} finally {
|
} 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) {
|
async function updateMonitorStatus(status: 0 | 1) {
|
||||||
if (!selectedClass.value?.id) return
|
if (!selectedClass.value?.id) return
|
||||||
const option = status === 1 ? '开启' : '停止'
|
const option = status === 1 ? '开启' : '停止'
|
||||||
@ -83,9 +151,68 @@ async function updateMonitorStatus(status: 0 | 1) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function backToClassList() {
|
function backToClassList() {
|
||||||
|
stopSnapshotAutoRefresh()
|
||||||
selectedClass.value = undefined
|
selectedClass.value = undefined
|
||||||
currentMonitor.value = undefined
|
currentMonitor.value = undefined
|
||||||
snapshots.value = []
|
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) {
|
function formatTime(time?: number | string) {
|
||||||
@ -115,7 +242,17 @@ function formatTime(time?: number | string) {
|
|||||||
|
|
||||||
<div v-loading="classLoading" class="class-list">
|
<div v-loading="classLoading" class="class-list">
|
||||||
<div v-for="item in classList" :key="item.id" class="class-row">
|
<div v-for="item in classList" :key="item.id" class="class-row">
|
||||||
<span class="class-name">{{ item.name }}</span>
|
<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 type="primary" @click="handleViewMonitor(item)">
|
||||||
查看监控
|
查看监控
|
||||||
</el-button>
|
</el-button>
|
||||||
@ -134,6 +271,7 @@ function formatTime(time?: number | string) {
|
|||||||
</div>
|
</div>
|
||||||
<div class="header-actions">
|
<div class="header-actions">
|
||||||
<el-button @click="backToClassList">返回班级列表</el-button>
|
<el-button @click="backToClassList">返回班级列表</el-button>
|
||||||
|
<el-button @click="openHistoryMonitor">历史监控</el-button>
|
||||||
<el-button
|
<el-button
|
||||||
:loading="detailLoading || snapshotLoading"
|
:loading="detailLoading || snapshotLoading"
|
||||||
type="primary"
|
type="primary"
|
||||||
@ -193,12 +331,12 @@ function formatTime(time?: number | string) {
|
|||||||
:key="item.studentId"
|
:key="item.studentId"
|
||||||
class="snapshot-card"
|
class="snapshot-card"
|
||||||
shadow="hover"
|
shadow="hover"
|
||||||
|
@click="openStudentSnapshots(item)"
|
||||||
>
|
>
|
||||||
<el-image
|
<el-image
|
||||||
v-if="item.latestUrl"
|
v-if="item.latestUrl"
|
||||||
class="snapshot-image"
|
class="snapshot-image"
|
||||||
fit="cover"
|
fit="cover"
|
||||||
:preview-src-list="[item.latestUrl]"
|
|
||||||
:src="item.latestUrl"
|
:src="item.latestUrl"
|
||||||
/>
|
/>
|
||||||
<div v-else class="snapshot-empty">暂无截图</div>
|
<div v-else class="snapshot-empty">暂无截图</div>
|
||||||
@ -227,6 +365,104 @@ function formatTime(time?: number | string) {
|
|||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</el-card>
|
</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>
|
</div>
|
||||||
</template>
|
</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 {
|
.class-name {
|
||||||
color: #111827;
|
color: #111827;
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
@ -335,7 +579,15 @@ function formatTime(time?: number | string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.snapshot-card {
|
.snapshot-card {
|
||||||
|
cursor: pointer;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
transition:
|
||||||
|
border-color 0.2s,
|
||||||
|
box-shadow 0.2s;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
border-color: #409eff;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.snapshot-image,
|
.snapshot-image,
|
||||||
@ -369,6 +621,50 @@ function formatTime(time?: number | string) {
|
|||||||
color: #6b7280;
|
color: #6b7280;
|
||||||
font-size: 12px;
|
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) {
|
@media (max-width: 1400px) {
|
||||||
@ -376,6 +672,10 @@ function formatTime(time?: number | string) {
|
|||||||
.snapshot-grid {
|
.snapshot-grid {
|
||||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
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 {
|
.snapshot-grid {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.student-snapshot-list {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</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