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

This commit is contained in:
shawko 2026-05-11 10:43:53 +08:00
parent 391132e127
commit 32189c0fbc
5 changed files with 843 additions and 7 deletions

View File

@ -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,

View File

@ -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

View File

@ -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">
<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>
@ -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>

View 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

View 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>