|
|
|
|
@ -1,45 +1,555 @@
|
|
|
|
|
<template>
|
|
|
|
|
<div class="room-detail">
|
|
|
|
|
<!-- 头部 -->
|
|
|
|
|
<div class="detail-header">
|
|
|
|
|
<el-button @click="$router.back()">返回</el-button>
|
|
|
|
|
<h2>{{ roomName }} — 监控详情</h2>
|
|
|
|
|
<el-button :icon="ArrowLeft" @click="$router.back()">返回</el-button>
|
|
|
|
|
<div class="header-info">
|
|
|
|
|
<div class="header-title-row">
|
|
|
|
|
<h2>{{ room.roomName }} — 监控详情</h2>
|
|
|
|
|
<el-tag v-if="courseName" type="warning" effect="plain" class="header-tag">
|
|
|
|
|
<!-- <el-icon :size="14"><Reading /></el-icon> -->
|
|
|
|
|
课程:
|
|
|
|
|
{{ courseName }}
|
|
|
|
|
</el-tag>
|
|
|
|
|
<el-tag v-if="teacherName" type="primary" effect="plain" class="header-tag">
|
|
|
|
|
<!-- <el-icon :size="14"><UserFilled /></el-icon> -->
|
|
|
|
|
授课老师:
|
|
|
|
|
{{ teacherName }}
|
|
|
|
|
</el-tag>
|
|
|
|
|
</div>
|
|
|
|
|
<div class="header-meta">
|
|
|
|
|
<span>教室编号:{{ room.roomNo || '-' }}</span>
|
|
|
|
|
<i class="meta-divider">|</i>
|
|
|
|
|
<span>容纳 {{ room.capacity || '-' }} 人</span>
|
|
|
|
|
<i class="meta-divider">|</i>
|
|
|
|
|
<span>摄像头 {{ cameras.length }} 个</span>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
<div class="header-status">
|
|
|
|
|
<span class="status-dot online" v-if="onlineCount > 0"></span>
|
|
|
|
|
<span class="status-dot offline" v-else></span>
|
|
|
|
|
<span>{{ onlineCount > 0 ? `${onlineCount} 路在线` : '全部离线' }}</span>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<!-- 主体:两栏布局 -->
|
|
|
|
|
<div class="detail-body">
|
|
|
|
|
<p class="placeholder">监控详情页面开发中...</p>
|
|
|
|
|
<!-- 左栏:摄像头画面 -->
|
|
|
|
|
<div class="camera-panel">
|
|
|
|
|
<div class="panel-title">
|
|
|
|
|
<el-icon :size="18"><VideoCamera /></el-icon>
|
|
|
|
|
<span>实时画面</span>
|
|
|
|
|
</div>
|
|
|
|
|
<div class="camera-grid" :class="{ 'single': cameras.length === 1 }">
|
|
|
|
|
<template v-for="camera in cameras" :key="camera.id">
|
|
|
|
|
<div class="camera-cell">
|
|
|
|
|
<WebRtcPlayer
|
|
|
|
|
v-if="camera.streamUrl"
|
|
|
|
|
:src="`${camera.streamUrl}/whep`"
|
|
|
|
|
@connection-status="(ok) => updateCameraStatus(camera.id, ok)"
|
|
|
|
|
/>
|
|
|
|
|
<div v-else class="cell-no-stream">
|
|
|
|
|
<el-icon :size="36" color="#d9d9d9"><VideoCameraFilled /></el-icon>
|
|
|
|
|
</div>
|
|
|
|
|
<div class="camera-label">
|
|
|
|
|
<span class="dot" :class="getCameraStatus(camera.id)"></span>
|
|
|
|
|
{{ camera.name || camera.deviceNo }}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</template>
|
|
|
|
|
</div>
|
|
|
|
|
<div v-if="cameras.length === 0" class="panel-empty">
|
|
|
|
|
<el-icon :size="48" color="#d9d9d9"><VideoCameraFilled /></el-icon>
|
|
|
|
|
<p>该教室暂未配置摄像头</p>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<!-- 右栏:人脸识别记录 -->
|
|
|
|
|
<div class="recognition-panel">
|
|
|
|
|
<div class="panel-title">
|
|
|
|
|
<el-icon :size="18"><UserFilled /></el-icon>
|
|
|
|
|
<span>人脸识别记录</span>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<!-- 统计卡片 -->
|
|
|
|
|
<div class="recognition-stats">
|
|
|
|
|
<div class="stat-card">
|
|
|
|
|
<div class="stat-num">{{ stats.total }}</div>
|
|
|
|
|
<div class="stat-label">应到</div>
|
|
|
|
|
</div>
|
|
|
|
|
<div class="stat-card">
|
|
|
|
|
<div class="stat-num green">{{ stats.present }}</div>
|
|
|
|
|
<div class="stat-label">已到</div>
|
|
|
|
|
</div>
|
|
|
|
|
<div class="stat-card">
|
|
|
|
|
<div class="stat-num orange">{{ stats.missed }}</div>
|
|
|
|
|
<div class="stat-label">未到</div>
|
|
|
|
|
</div>
|
|
|
|
|
<div class="stat-card">
|
|
|
|
|
<div class="stat-num blue">{{ stats.rate }}%</div>
|
|
|
|
|
<div class="stat-label">出勤率</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<!-- 学生签到表标题 -->
|
|
|
|
|
<div class="sub-title">
|
|
|
|
|
<el-icon :size="14"><List /></el-icon>
|
|
|
|
|
<span>学生签到表</span>
|
|
|
|
|
<span class="sub-title-count" v-if="records.length">({{ records.length }} 人)</span>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<!-- 识别列表 -->
|
|
|
|
|
<div class="recognition-list">
|
|
|
|
|
<div
|
|
|
|
|
v-for="record in records"
|
|
|
|
|
:key="record.id"
|
|
|
|
|
class="recognition-item"
|
|
|
|
|
>
|
|
|
|
|
<div class="rec-avatar">{{ record.studentName ? record.studentName[0] : '?' }}</div>
|
|
|
|
|
<div class="rec-info">
|
|
|
|
|
<div class="rec-name">{{ record.studentName || '未知' }}</div>
|
|
|
|
|
<div class="rec-meta">学号:{{ record.studentNo }}</div>
|
|
|
|
|
<div class="rec-meta">签到时间:{{ formatTime(record.checkInTime) }}</div>
|
|
|
|
|
</div>
|
|
|
|
|
<el-tag
|
|
|
|
|
:type="record.status === '正常' ? 'success' : 'danger'"
|
|
|
|
|
size="small"
|
|
|
|
|
effect="plain"
|
|
|
|
|
>
|
|
|
|
|
{{ record.status }}
|
|
|
|
|
</el-tag>
|
|
|
|
|
<span class="rec-confidence" v-if="record.confidence">{{ record.confidence }}%</span>
|
|
|
|
|
</div>
|
|
|
|
|
<div v-if="records.length === 0" class="list-empty">暂无识别记录</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</template>
|
|
|
|
|
|
|
|
|
|
<script setup>
|
|
|
|
|
import { ref } from 'vue'
|
|
|
|
|
import { ref, reactive, computed, onMounted, onUnmounted } from 'vue'
|
|
|
|
|
import { useRoute } from 'vue-router'
|
|
|
|
|
import { ArrowLeft, Reading, List } from '@element-plus/icons-vue'
|
|
|
|
|
import { ElMessage } from 'element-plus'
|
|
|
|
|
import { getCameras, getRoomsList } from '@/api/info'
|
|
|
|
|
import { getCurrentAttendance } from '@/api/attendance'
|
|
|
|
|
import WebRtcPlayer from '@/components/WebRtcPlayer.vue'
|
|
|
|
|
|
|
|
|
|
const route = useRoute()
|
|
|
|
|
const roomName = ref(`教室 #${route.params.id}`)
|
|
|
|
|
const roomId = Number(route.params.id)
|
|
|
|
|
|
|
|
|
|
const room = ref({})
|
|
|
|
|
const cameras = ref([])
|
|
|
|
|
const records = ref([])
|
|
|
|
|
|
|
|
|
|
// 课程 & 教师信息(来自考勤接口)
|
|
|
|
|
const courseName = ref('')
|
|
|
|
|
const teacherName = ref('')
|
|
|
|
|
|
|
|
|
|
// 摄像头连接状态
|
|
|
|
|
const cameraStatus = reactive({})
|
|
|
|
|
const getCameraStatus = (id) => cameraStatus[id] || 'connecting'
|
|
|
|
|
const updateCameraStatus = (id, ok) => { cameraStatus[id] = ok ? 'online' : 'offline' }
|
|
|
|
|
const onlineCount = computed(() => cameras.value.filter(c => getCameraStatus(c.id) === 'online').length)
|
|
|
|
|
|
|
|
|
|
// 统计数据
|
|
|
|
|
const stats = reactive({ total: 0, present: 0, missed: 0, rate: 0 })
|
|
|
|
|
|
|
|
|
|
const formatTime = (t) => {
|
|
|
|
|
if (!t) return '-'
|
|
|
|
|
const d = new Date(t)
|
|
|
|
|
return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}:${String(d.getSeconds()).padStart(2, '0')}`
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const now = () => {
|
|
|
|
|
const d = new Date()
|
|
|
|
|
const pad = (n) => String(n).padStart(2, '0')
|
|
|
|
|
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const loadRoomInfo = async () => {
|
|
|
|
|
try {
|
|
|
|
|
const res = await getRoomsList()
|
|
|
|
|
const list = res?.data || []
|
|
|
|
|
room.value = list.find(r => r.id === roomId) || { roomName: `教室 #${roomId}` }
|
|
|
|
|
} catch { /* 错误已由拦截器统一处理 */ }
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const loadCameras = async () => {
|
|
|
|
|
try {
|
|
|
|
|
const res = await getCameras({ classroomId: roomId, current: 1, size: 50 })
|
|
|
|
|
const data = res?.data || {}
|
|
|
|
|
cameras.value = (data.records || []).map(c => ({
|
|
|
|
|
id: c.id,
|
|
|
|
|
name: c.deviceName,
|
|
|
|
|
deviceNo: c.deviceNo,
|
|
|
|
|
streamUrl: c.streamUrl,
|
|
|
|
|
online: c.onlineStatus === 1
|
|
|
|
|
}))
|
|
|
|
|
} catch { /* 错误已由拦截器统一处理 */ }
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const loadRecords = async () => {
|
|
|
|
|
try {
|
|
|
|
|
const res = await getCurrentAttendance(roomId, now())
|
|
|
|
|
const data = res?.data
|
|
|
|
|
if (!data) return
|
|
|
|
|
|
|
|
|
|
// 课程 & 教师信息
|
|
|
|
|
courseName.value = data.courseName || ''
|
|
|
|
|
teacherName.value = data.teacherName || ''
|
|
|
|
|
|
|
|
|
|
// 统计信息
|
|
|
|
|
stats.total = data.totalCount || 0
|
|
|
|
|
stats.present = data.actualCount || 0
|
|
|
|
|
stats.missed = stats.total - stats.present
|
|
|
|
|
stats.rate = data.totalCount > 0 ? Math.round((data.actualCount / data.totalCount) * 100) : 0
|
|
|
|
|
|
|
|
|
|
// 识别记录列表
|
|
|
|
|
const list = data.detailList || []
|
|
|
|
|
records.value = list.map(r => ({
|
|
|
|
|
id: r.id,
|
|
|
|
|
studentName: r.studentName || '未知',
|
|
|
|
|
studentNo: r.studentNo || '-',
|
|
|
|
|
status: r.attStatusDesc || (r.attStatus === 1 ? '正常' : '异常'),
|
|
|
|
|
checkInTime: r.checkInTime,
|
|
|
|
|
confidence: r.faceSimilarity
|
|
|
|
|
}))
|
|
|
|
|
} catch { /* 错误已由拦截器统一处理 */ }
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let refreshTimer = null
|
|
|
|
|
onMounted(async () => {
|
|
|
|
|
await loadRoomInfo()
|
|
|
|
|
await Promise.all([loadCameras(), loadRecords()])
|
|
|
|
|
// 每 10 秒刷新摄像头状态和识别记录
|
|
|
|
|
refreshTimer = setInterval(() => {
|
|
|
|
|
loadCameras()
|
|
|
|
|
loadRecords()
|
|
|
|
|
}, 10000)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
onUnmounted(() => {
|
|
|
|
|
clearInterval(refreshTimer)
|
|
|
|
|
})
|
|
|
|
|
</script>
|
|
|
|
|
|
|
|
|
|
<style lang="scss" scoped>
|
|
|
|
|
.room-detail {
|
|
|
|
|
padding: 24px;
|
|
|
|
|
height: 100%;
|
|
|
|
|
display: flex;
|
|
|
|
|
flex-direction: column;
|
|
|
|
|
background: #f5f7fa;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/* ===== Header ===== */
|
|
|
|
|
.detail-header {
|
|
|
|
|
display: flex;
|
|
|
|
|
align-items: center;
|
|
|
|
|
gap: 16px;
|
|
|
|
|
margin-bottom: 24px;
|
|
|
|
|
margin-bottom: 20px;
|
|
|
|
|
flex-shrink: 0;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.header-info {
|
|
|
|
|
flex: 1;
|
|
|
|
|
min-width: 0;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.header-title-row {
|
|
|
|
|
display: flex;
|
|
|
|
|
align-items: center;
|
|
|
|
|
gap: 12px;
|
|
|
|
|
margin-bottom: 4px;
|
|
|
|
|
|
|
|
|
|
h2 {
|
|
|
|
|
font-size: 18px;
|
|
|
|
|
font-weight: 600;
|
|
|
|
|
color: #262626;
|
|
|
|
|
margin: 0;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.header-tag {
|
|
|
|
|
display: inline-flex;
|
|
|
|
|
align-items: center;
|
|
|
|
|
gap: 4px;
|
|
|
|
|
font-size: 13px;
|
|
|
|
|
padding: 0 10px;
|
|
|
|
|
line-height: 24px;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.header-meta {
|
|
|
|
|
font-size: 13px;
|
|
|
|
|
color: #999;
|
|
|
|
|
display: flex;
|
|
|
|
|
align-items: center;
|
|
|
|
|
gap: 8px;
|
|
|
|
|
|
|
|
|
|
.meta-divider {
|
|
|
|
|
font-style: normal;
|
|
|
|
|
color: #e0e0e0;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.header-status {
|
|
|
|
|
display: flex;
|
|
|
|
|
align-items: center;
|
|
|
|
|
gap: 6px;
|
|
|
|
|
font-size: 13px;
|
|
|
|
|
color: #666;
|
|
|
|
|
flex-shrink: 0;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.status-dot {
|
|
|
|
|
width: 8px;
|
|
|
|
|
height: 8px;
|
|
|
|
|
border-radius: 50%;
|
|
|
|
|
display: inline-block;
|
|
|
|
|
|
|
|
|
|
&.online {
|
|
|
|
|
background: #52c41a;
|
|
|
|
|
box-shadow: 0 0 6px rgba(82, 196, 26, 0.6);
|
|
|
|
|
}
|
|
|
|
|
&.offline {
|
|
|
|
|
background: #d9d9d9;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/* ===== Body ===== */
|
|
|
|
|
.detail-body {
|
|
|
|
|
flex: 1;
|
|
|
|
|
min-height: 0;
|
|
|
|
|
display: flex;
|
|
|
|
|
gap: 20px;
|
|
|
|
|
overflow: hidden;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.panel-title {
|
|
|
|
|
display: flex;
|
|
|
|
|
align-items: center;
|
|
|
|
|
gap: 8px;
|
|
|
|
|
font-size: 15px;
|
|
|
|
|
font-weight: 600;
|
|
|
|
|
color: #262626;
|
|
|
|
|
margin-bottom: 14px;
|
|
|
|
|
flex-shrink: 0;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/* ===== Left: Camera ===== */
|
|
|
|
|
.camera-panel {
|
|
|
|
|
flex: 1;
|
|
|
|
|
min-width: 0;
|
|
|
|
|
background: #fff;
|
|
|
|
|
border-radius: 10px;
|
|
|
|
|
padding: 18px;
|
|
|
|
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
|
|
|
|
|
display: flex;
|
|
|
|
|
flex-direction: column;
|
|
|
|
|
overflow: hidden;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.camera-grid {
|
|
|
|
|
flex: 1;
|
|
|
|
|
min-height: 0;
|
|
|
|
|
display: grid;
|
|
|
|
|
grid-template-columns: 1fr 1fr;
|
|
|
|
|
gap: 12px;
|
|
|
|
|
overflow-y: auto;
|
|
|
|
|
|
|
|
|
|
&.single {
|
|
|
|
|
grid-template-columns: 1fr;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.camera-cell {
|
|
|
|
|
border-radius: 8px;
|
|
|
|
|
overflow: hidden;
|
|
|
|
|
border: 1px solid #f0f0f0;
|
|
|
|
|
display: flex;
|
|
|
|
|
flex-direction: column;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.cell-no-stream {
|
|
|
|
|
flex: 1;
|
|
|
|
|
min-height: 180px;
|
|
|
|
|
background: #fafafa;
|
|
|
|
|
display: flex;
|
|
|
|
|
align-items: center;
|
|
|
|
|
justify-content: center;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.camera-label {
|
|
|
|
|
display: flex;
|
|
|
|
|
align-items: center;
|
|
|
|
|
gap: 6px;
|
|
|
|
|
padding: 8px 12px;
|
|
|
|
|
font-size: 13px;
|
|
|
|
|
color: #525252;
|
|
|
|
|
background: #fafafa;
|
|
|
|
|
border-top: 1px solid #f0f0f0;
|
|
|
|
|
|
|
|
|
|
.dot {
|
|
|
|
|
width: 6px;
|
|
|
|
|
height: 6px;
|
|
|
|
|
border-radius: 50%;
|
|
|
|
|
flex-shrink: 0;
|
|
|
|
|
|
|
|
|
|
&.online {
|
|
|
|
|
background: #52c41a;
|
|
|
|
|
box-shadow: 0 0 4px rgba(82, 196, 26, 0.5);
|
|
|
|
|
}
|
|
|
|
|
&.offline {
|
|
|
|
|
background: #d9d9d9;
|
|
|
|
|
}
|
|
|
|
|
&.connecting {
|
|
|
|
|
background: #faad14;
|
|
|
|
|
animation: pulse 1.2s ease-in-out infinite;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.placeholder {
|
|
|
|
|
@keyframes pulse {
|
|
|
|
|
0%, 100% { opacity: 1; transform: scale(1); }
|
|
|
|
|
50% { opacity: 0.4; transform: scale(0.7); }
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/* ===== Right: Recognition ===== */
|
|
|
|
|
.recognition-panel {
|
|
|
|
|
width: 360px;
|
|
|
|
|
flex-shrink: 0;
|
|
|
|
|
background: #fff;
|
|
|
|
|
border-radius: 10px;
|
|
|
|
|
padding: 18px;
|
|
|
|
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
|
|
|
|
|
display: flex;
|
|
|
|
|
flex-direction: column;
|
|
|
|
|
overflow: hidden;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/* 统计卡片 */
|
|
|
|
|
.recognition-stats {
|
|
|
|
|
display: grid;
|
|
|
|
|
grid-template-columns: 1fr 1fr;
|
|
|
|
|
gap: 10px;
|
|
|
|
|
margin-bottom: 16px;
|
|
|
|
|
flex-shrink: 0;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.stat-card {
|
|
|
|
|
background: #fafafa;
|
|
|
|
|
border-radius: 8px;
|
|
|
|
|
padding: 14px 12px;
|
|
|
|
|
text-align: center;
|
|
|
|
|
padding: 80px 0;
|
|
|
|
|
border: 1px solid #f0f0f0;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.stat-num {
|
|
|
|
|
font-size: 26px;
|
|
|
|
|
font-weight: 700;
|
|
|
|
|
color: #262626;
|
|
|
|
|
line-height: 1.2;
|
|
|
|
|
|
|
|
|
|
&.green { color: #52c41a; }
|
|
|
|
|
&.orange { color: #fa8c16; }
|
|
|
|
|
&.blue { color: #409eff; }
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.stat-label {
|
|
|
|
|
font-size: 12px;
|
|
|
|
|
color: #999;
|
|
|
|
|
margin-top: 4px;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/* 学生签到表标题 */
|
|
|
|
|
.sub-title {
|
|
|
|
|
display: flex;
|
|
|
|
|
align-items: center;
|
|
|
|
|
gap: 6px;
|
|
|
|
|
font-size: 14px;
|
|
|
|
|
font-weight: 600;
|
|
|
|
|
color: #262626;
|
|
|
|
|
margin-bottom: 10px;
|
|
|
|
|
flex-shrink: 0;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.sub-title-count {
|
|
|
|
|
font-weight: 400;
|
|
|
|
|
font-size: 13px;
|
|
|
|
|
color: #999;
|
|
|
|
|
font-size: 16px;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/* 识别列表 */
|
|
|
|
|
.recognition-list {
|
|
|
|
|
flex: 1;
|
|
|
|
|
min-height: 0;
|
|
|
|
|
overflow-y: auto;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.recognition-item {
|
|
|
|
|
display: flex;
|
|
|
|
|
align-items: center;
|
|
|
|
|
gap: 12px;
|
|
|
|
|
padding: 12px 8px;
|
|
|
|
|
border-bottom: 1px solid #f5f5f5;
|
|
|
|
|
transition: background 0.2s;
|
|
|
|
|
|
|
|
|
|
&:hover {
|
|
|
|
|
background: #fafafa;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.rec-avatar {
|
|
|
|
|
width: 38px;
|
|
|
|
|
height: 38px;
|
|
|
|
|
border-radius: 50%;
|
|
|
|
|
background: linear-gradient(135deg, #667eea, #764ba2);
|
|
|
|
|
color: #fff;
|
|
|
|
|
display: flex;
|
|
|
|
|
align-items: center;
|
|
|
|
|
justify-content: center;
|
|
|
|
|
font-size: 15px;
|
|
|
|
|
font-weight: 600;
|
|
|
|
|
flex-shrink: 0;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.rec-info {
|
|
|
|
|
flex: 1;
|
|
|
|
|
min-width: 0;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.rec-name {
|
|
|
|
|
font-size: 14px;
|
|
|
|
|
font-weight: 500;
|
|
|
|
|
color: #262626;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.rec-meta {
|
|
|
|
|
font-size: 12px;
|
|
|
|
|
color: #999;
|
|
|
|
|
margin-top: 2px;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.rec-confidence {
|
|
|
|
|
font-size: 12px;
|
|
|
|
|
color: #52c41a;
|
|
|
|
|
font-weight: 600;
|
|
|
|
|
flex-shrink: 0;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.panel-empty,
|
|
|
|
|
.list-empty {
|
|
|
|
|
flex: 1;
|
|
|
|
|
display: flex;
|
|
|
|
|
flex-direction: column;
|
|
|
|
|
align-items: center;
|
|
|
|
|
justify-content: center;
|
|
|
|
|
gap: 8px;
|
|
|
|
|
color: #999;
|
|
|
|
|
font-size: 14px;
|
|
|
|
|
|
|
|
|
|
p { margin: 0; }
|
|
|
|
|
}
|
|
|
|
|
</style>
|
|
|
|
|
|