fix:优化

master
zhoulexin 4 days ago
parent 8c86a233d9
commit 89cbb1c348

@ -1,4 +1,4 @@
VITE_API_BASE_URL=http://10.23.22.43:35251/api
VITE_API_NEWPOD_URL=http://10.23.22.43
VITE_API_NEWPOD_URL=http://10.23.22.97
VITE_API_PYTHON_URL=http://10.23.22.43:8010/api
VITE_APP_TITLE=视觉管理平台

@ -37,4 +37,17 @@ export const stopFaceRecognition = () => {
url: '/detection/stop',
method: 'post'
})
}
// 分页查询人脸识别记录
export const getFaceRecordPage = (params) => {
const queryParams = new URLSearchParams()
queryParams.append('pageNum', params.pageNum || 1)
queryParams.append('pageSize', params.pageSize || 10)
if (params.startTime) queryParams.append('startTime', params.startTime)
if (params.endTime) queryParams.append('endTime', params.endTime)
return request({
url: `/faceRecord/page?${queryParams.toString()}`,
method: 'get'
})
}

@ -1,2 +1,2 @@
const fileHttp = 'http://ngsk.tech:3900'
const fileHttp = 'https://shipllm.ngsk.tech:7001/storage'
export default fileHttp

@ -31,45 +31,27 @@
</el-dropdown>
</div>
<div class="camera-video" ref="videoContainers">
<!-- <WebRtcPlayer :src="`${camera.defaultVideo}/whep`"/> -->
<WebRtcPlayer src="http://10.23.22.43:8889/camera4_detected/whep"/>
<WebRtcPlayer :src="`${camera.defaultVideo}/whep`"/>
</div>
</div>
<!-- 第二个摄像头用于画人脸框测试 -->
<div class="camera-card" v-if="cameras.length > 1">
<div class="camera-header">
<span class="camera-title">{{ `${cameras[1]?.groupName}-${cameras[1]?.defaultVideoName}` }}</span>
</div>
<div class="camera-video" ref="faceVideoRef">
<WebRtcPlayer :src="`${cameras[1]?.defaultVideo}/whep`"/>
<canvas ref="faceCanvasRef" class="face-canvas"></canvas>
</div>
</div>
<!-- <div class="camera-card">
<div class="camera-card">
<div class="camera-header">
<span class="camera-title"></span>
</div>
<div class="camera-video" style="color: white; display: flex; align-items: center; justify-content: center;">
<span>暂无画面</span>
</div>
</div> -->
</div>
</div>
</template>
<script setup>
import { ref, nextTick, onMounted, onUnmounted } from 'vue'
import { ref, onMounted, onUnmounted } from 'vue'
import { VideoCamera } from '@element-plus/icons-vue'
import WebRtcPlayer from '@/components/WebRtcPlayer.vue'
import { getPodList } from '@/api/home'
import { useUserStore } from '@/stores/user'
const userStore = useUserStore()
const cameras = ref([])
const faceVideoRef = ref(null)
const faceCanvasRef = ref(null)
// SSE
let eventSource = null
//
const fetchPodList = async () => {
@ -80,151 +62,21 @@ const fetchPodList = async () => {
camera.defaultVideo = camera.cameras[0].videoStreaming
camera.defaultVideoName = camera.cameras[0].cameraLocation
})
// canvas
nextTick(() => {
initCanvas()
})
} catch (error) {
console.error('获取设备列表失败', error)
}
}
// canvas
const initCanvas = () => {
if (faceVideoRef.value && faceCanvasRef.value) {
const container = faceVideoRef.value
faceCanvasRef.value.width = container.clientWidth
faceCanvasRef.value.height = container.clientHeight
}
}
//
const drawFaceBoxes = (data) => {
const canvas = faceCanvasRef.value
if (!canvas) return
const ctx = canvas.getContext('2d')
const container = faceVideoRef.value
//
const containerWidth = container.clientWidth
const containerHeight = container.clientHeight
//
const scaleX = containerWidth / data.frame_width
const scaleY = containerHeight / data.frame_height
//
ctx.clearRect(0, 0, canvas.width, canvas.height)
//
data.faces.forEach(face => {
const [x1, y1, x2, y2] = face.bbox
//
const scaledX1 = x1 * scaleX
const scaledY1 = y1 * scaleY
const scaledX2 = x2 * scaleX
const scaledY2 = y2 * scaleY
const boxWidth = scaledX2 - scaledX1
const boxHeight = scaledY2 - scaledY1
//
ctx.strokeStyle = '#00ff00'
ctx.lineWidth = 2
ctx.strokeRect(scaledX1, scaledY1, boxWidth, boxHeight)
// 5
if (face.kps && face.kps.length === 5) {
ctx.fillStyle = '#ff6600'
face.kps.forEach(([kx, ky]) => {
const scaledKx = kx * scaleX
const scaledKy = ky * scaleY
ctx.beginPath()
ctx.arc(scaledKx, scaledKy, 3, 0, Math.PI * 2)
ctx.fill()
})
}
// -
const label = face.name || '未知'
const similarity = (face.similarity * 100).toFixed(1)
const text = `${label} ${similarity}%`
ctx.font = '12px Arial'
const textWidth = ctx.measureText(text).width
//
ctx.fillStyle = 'rgba(0, 0, 0, 0.6)'
ctx.fillRect(scaledX1, scaledY2 + 2, textWidth + 10, 20)
//
ctx.fillStyle = '#00ff00'
ctx.fillText(text, scaledX1 + 5, scaledY2 + 16)
//
// const label = face.name || ''
// const similarity = (face.similarity * 100).toFixed(1)
// ctx.fillStyle = 'rgba(0, 0, 0, 0.6)'
// ctx.fillRect(scaledX1, scaledY1 - 20, ctx.measureText(`${label} ${similarity}%`).width + 10, 20)
// ctx.fillStyle = '#00ff00'
// ctx.font = '12px Arial'
// ctx.fillText(`${label} ${similarity}%`, scaledX1 + 5, scaledY1 - 5)
})
}
// SSE
//
const startFaceDetection = () => {
if (eventSource) {
eventSource.close()
}
const token = userStore.token || ''
eventSource = new EventSource(`${import.meta.env.VITE_API_PYTHON_URL}/sse/detections`)
eventSource.onmessage = (event) => {
try {
const data = JSON.parse(event.data)
//
if (data.type === 'detection_result' && data.active && data.faces && data.faces.length > 0) {
drawFaceBoxes(data)
} else if (data.type === 'detection_result' && (!data.active || !data.faces || data.faces.length === 0)) {
//
const canvas = faceCanvasRef.value
if (canvas) {
const ctx = canvas.getContext('2d')
ctx.clearRect(0, 0, canvas.width, canvas.height)
}
}
} catch (e) {
console.warn('SSE数据解析失败:', e)
}
}
eventSource.onerror = (error) => {
console.error('SSE连接错误:', error)
eventSource.close()
// 线
setTimeout(() => {
if (eventSource.readyState === EventSource.CONNECTING) {
startFaceDetection()
}
}, 3000)
}
const selectVideo = cameras.value.find(c => c.id === '34')
selectVideo.defaultVideo = 'http://10.23.22.43:8889/camera4_detected'
}
// SSE
//
const stopFaceDetection = () => {
if (eventSource) {
eventSource.close()
eventSource = null
}
//
const canvas = faceCanvasRef.value
if (canvas) {
const ctx = canvas.getContext('2d')
ctx.clearRect(0, 0, canvas.width, canvas.height)
}
const selectVideo = cameras.value.find(c => c.id === '34')
selectVideo.defaultVideo = selectVideo.cameras[0].videoStreaming
}
//
@ -250,13 +102,10 @@ const handleCommand = (cameraId, item) => {
onMounted(() => {
fetchPodList()
// resizecanvas
window.addEventListener('resize', initCanvas)
})
onUnmounted(() => {
stopFaceDetection()
window.removeEventListener('resize', initCanvas)
})
</script>
@ -310,15 +159,6 @@ onUnmounted(() => {
height: 100%;
object-fit: cover;
}
.face-canvas {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
pointer-events: none;
}
}
}
}

@ -21,11 +21,6 @@
<script setup>
import { ref, computed, inject } from 'vue'
import { UserFilled, Histogram, Position } from '@element-plus/icons-vue'
import { ElMessage } from 'element-plus'
import {
startFaceRecognition,
stopFaceRecognition
} from '@/api/face-recognition'
//
const toggleFaceDetection = inject('toggleFaceDetection')
@ -54,27 +49,15 @@ const handleAction = (item) => {
}
}
const toggleFaceRecognition = async () => {
try {
if (isFaceRecognitionOn.value) {
//
const res = await stopFaceRecognition()
if (res.success) {
isFaceRecognitionOn.value = false
toggleFaceDetection(false)
ElMessage.success('关闭人脸识别成功')
}
} else {
//
const res = await startFaceRecognition()
if (res.success) {
isFaceRecognitionOn.value = true
toggleFaceDetection(true)
ElMessage.success('开启人脸识别成功')
}
}
} catch (error) {
ElMessage.error('操作失败')
const toggleFaceRecognition = () => {
if (isFaceRecognitionOn.value) {
//
isFaceRecognitionOn.value = false
toggleFaceDetection(false)
} else {
//
isFaceRecognitionOn.value = true
toggleFaceDetection(true)
}
}

@ -2,26 +2,30 @@
<div class="recognition-records">
<div class="records-title">
<el-icon><WarningFilled /></el-icon>
<span>非配合目标识别记录</span>
<span>人员识别记录</span>
</div>
<!-- 当前目标 -->
<div class="current-target">
<div v-if="currentTarget" class="target-card">
<img class="target-avatar" :src="currentTarget.avatar" alt="avatar" />
<img
class="target-avatar"
:src="getAvatar(currentTarget)"
alt="avatar"
/>
<div class="target-info">
<div class="target-status" :class="currentTarget.type">
{{ currentTarget.type === 'internal' ? '疑似内部人员' : '陌生外来人员' }}
</div>
<div class="target-name">{{ currentTarget.name }}</div>
<div v-if="currentTarget.type === 'internal'" class="target-detail">
<el-tag size="small" type="primary">相似度 {{ currentTarget.similarity }}</el-tag>
<div class="target-dept">所属部门: {{ currentTarget.department }}</div>
<div class="target-id">工号: {{ currentTarget.employeeId }}</div>
<el-tag size="small" type="primary">相似度 {{ formatSimilarity(currentTarget.similarity) }}</el-tag>
<div class="target-dept">所属部门: {{ currentTarget.department || '-' }}</div>
<div class="target-id">工号: {{ currentTarget.employeeId || '-' }}</div>
</div>
<div v-else class="target-detail">
<div class="stranger-tip">未匹配到内部人员</div>
<el-tag size="small" type="danger">相似度 {{ currentTarget.similarity }}</el-tag>
<el-tag size="small" type="danger">相似度 {{ formatSimilarity(currentTarget.similarity) }}</el-tag>
<div class="alert-tip">
<el-icon><WarningFilled /></el-icon>
<span>请注意陌生人员出入!</span>
@ -29,6 +33,7 @@
</div>
</div>
</div>
<div v-else class="no-data-tip">暂无识别记录</div>
</div>
<!-- 记录列表 -->
@ -40,12 +45,12 @@
:class="{ active: currentTarget?.id === record.id }"
@click="selectTarget(record)"
>
<img class="record-avatar" :src="record.avatar" alt="avatar" />
<img class="record-avatar" :src="getAvatar(record)" alt="avatar" />
<div class="record-info">
<div class="record-time">{{ record.time }}</div>
<div class="record-location">{{ record.location }}</div>
<div class="record-time">{{ formatTime(record.enterTime) }}</div>
<div class="record-location">{{ record.department || '未知部门' }}</div>
<div class="record-name">{{ record.name }}</div>
<div class="record-similarity">相似度 {{ record.similarity }}</div>
<div class="record-similarity">相似度 {{ formatSimilarity(record.similarity) }}</div>
</div>
<el-tag
size="small"
@ -60,70 +65,224 @@
<el-button class="more-btn" type="primary" plain @click="viewMore">
查看更多记录
</el-button>
<!-- 查看记录弹窗 -->
<el-dialog
v-model="dialogVisible"
title="识别记录详情"
width="1100px"
:close-on-click-modal="false"
@open="loadDialogData"
>
<!-- 搜索区 -->
<div class="dialog-search">
<el-date-picker
v-model="searchParams.startTime"
type="datetime"
placeholder="开始时间"
format="YYYY-MM-DD HH:mm:ss"
value-format="YYYY-MM-DD HH:mm:ss"
style="width: 210px"
:clearable="true"
/>
<span class="search-separator"></span>
<el-date-picker
v-model="searchParams.endTime"
type="datetime"
placeholder="结束时间"
format="YYYY-MM-DD HH:mm:ss"
value-format="YYYY-MM-DD HH:mm:ss"
style="width: 210px"
:clearable="true"
/>
<el-button type="primary" @click="handleSearch"></el-button>
<el-button @click="handleReset"></el-button>
</div>
<!-- 表格 -->
<el-table
:data="dialogRecords"
v-loading="dialogLoading"
stripe
max-height="420"
style="width: 100%; margin-top: 12px"
>
<el-table-column label="头像" width="60">
<template #default="{ row }">
<img
:src="getAvatar(row)"
class="table-avatar"
alt="avatar"
/>
</template>
</el-table-column>
<el-table-column prop="name" label="姓名" width="100" />
<el-table-column label="性别" width="60">
<template #default="{ row }">
{{ row.gender === 1 ? '男' : '女' }}
</template>
</el-table-column>
<el-table-column prop="age" label="年龄" width="60" />
<el-table-column prop="department" label="部门" min-width="100">
<template #default="{ row }">
{{ row.department || '-' }}
</template>
</el-table-column>
<el-table-column prop="employeeId" label="工号" width="100">
<template #default="{ row }">
{{ row.employeeId || '-' }}
</template>
</el-table-column>
<el-table-column label="相似度" width="90">
<template #default="{ row }">
<span :style="{ color: row.type === 'internal' ? '#67C23A' : '#F56C6C' }">
{{ formatSimilarity(row.similarity) }}
</span>
</template>
</el-table-column>
<el-table-column label="进入时间" width="170">
<template #default="{ row }">
{{ row.enterTime || '-' }}
</template>
</el-table-column>
<el-table-column label="离开时间" width="170">
<template #default="{ row }">
{{ row.exitTime || '-' }}
</template>
</el-table-column>
<el-table-column label="类型" width="90">
<template #default="{ row }">
<el-tag :type="row.type === 'internal' ? 'success' : 'danger'" size="small">
{{ row.type === 'internal' ? '内部人员' : '陌生人员' }}
</el-tag>
</template>
</el-table-column>
</el-table>
<!-- 分页 -->
<div class="dialog-pagination">
<el-pagination
v-model:current-page="dialogPageNum"
v-model:page-size="dialogPageSize"
:page-sizes="[10, 20, 50]"
:total="dialogTotal"
layout="total, sizes, prev, pager, next"
@current-change="loadDialogData"
@size-change="loadDialogData"
/>
</div>
</el-dialog>
</div>
</template>
<script setup>
import { ref } from 'vue'
import { ref, onMounted } from 'vue'
import { WarningFilled } from '@element-plus/icons-vue'
import { ElMessage } from 'element-plus'
import { getFaceRecordPage } from '@/api/face-recognition'
import fileHttp from '@/utils/fileHttp'
const currentTarget = ref(null)
const records = ref([])
//
const formatSimilarity = (val) => {
if (val == null) return '-'
return (val * 100).toFixed(1) + '%'
}
const currentTarget = ref({
id: 1,
name: '张三',
type: 'internal',
similarity: '92.4%',
department: '研发部',
employeeId: '10086',
avatar: 'https://images.unsplash.com/photo-1507003211169-0a1dd7228f2d?w=100&h=100&fit=crop&crop=face'
// HH:mm:ss
const formatTime = (timeStr) => {
if (!timeStr) return '-'
return timeStr.split(' ')[1] || timeStr
}
//
const getRecordType = (name) => {
return name ? 'internal' : 'stranger'
}
//
const getAvatar = (record) => {
return fileHttp + record.imageUrl
}
// API
const mapRecord = (item) => ({
...item,
type: getRecordType(item.name)
})
const records = ref([
{
id: 1,
time: '14:35:20',
location: '视角1 - 园区北门',
name: '张三(研发部)',
similarity: '92.4%',
type: 'internal',
avatar: 'https://images.unsplash.com/photo-1507003211169-0a1dd7228f2d?w=100&h=100&fit=crop&crop=face'
},
{
id: 2,
time: '14:32:18',
location: '视角2 - 园区广场',
name: '未知人员',
similarity: '32.7%',
type: 'stranger',
avatar: 'https://images.unsplash.com/photo-1535713875002-d1d0cf377fde?w=100&h=100&fit=crop&crop=face'
},
{
id: 3,
time: '14:28:45',
location: '视角3 - 园区通道',
name: '李四(运维部)',
similarity: '91.7%',
type: 'internal',
avatar: 'https://images.unsplash.com/photo-1472099645785-5658abf4ff4e?w=100&h=100&fit=crop&crop=face'
},
{
id: 4,
time: '14:25:30',
location: '视角4 - 园区停车场',
name: '王五(行政部)',
similarity: '89.6%',
type: 'internal',
avatar: 'https://images.unsplash.com/photo-1500648767791-00dcc994a43e?w=100&h=100&fit=crop&crop=face'
//
const loadRecords = async () => {
try {
const res = await getFaceRecordPage({ pageNum: 1, pageSize: 10 })
if (res.code === 200 && res.data) {
records.value = (res.data.list || []).map(mapRecord)
if (records.value.length > 0 && !currentTarget.value) {
currentTarget.value = records.value[0]
}
}
} catch (error) {
console.error('加载识别记录失败:', error)
}
])
}
const selectTarget = (record) => {
currentTarget.value = record
}
// ========== ==========
const dialogVisible = ref(false)
const dialogLoading = ref(false)
const dialogRecords = ref([])
const dialogPageNum = ref(1)
const dialogPageSize = ref(10)
const dialogTotal = ref(0)
const searchParams = ref({
startTime: '',
endTime: ''
})
const loadDialogData = async () => {
dialogLoading.value = true
try {
const res = await getFaceRecordPage({
pageNum: dialogPageNum.value,
pageSize: dialogPageSize.value,
startTime: searchParams.value.startTime || undefined,
endTime: searchParams.value.endTime || undefined
})
if (res.code === 200 && res.data) {
dialogRecords.value = (res.data.list || []).map(mapRecord)
dialogTotal.value = res.data.total || 0
}
} catch (error) {
console.error('加载记录详情失败:', error)
} finally {
dialogLoading.value = false
}
}
const handleSearch = () => {
dialogPageNum.value = 1
loadDialogData()
}
const handleReset = () => {
searchParams.value.startTime = ''
searchParams.value.endTime = ''
dialogPageNum.value = 1
loadDialogData()
}
const viewMore = () => {
ElMessage.info('更多记录功能开发中')
dialogVisible.value = true
}
onMounted(() => {
loadRecords()
})
</script>
<style lang="scss" scoped>
@ -154,6 +313,15 @@ const viewMore = () => {
.current-target {
margin-bottom: 10px;
.no-data-tip {
padding: 16px;
text-align: center;
color: #909399;
font-size: 13px;
background: #f5f7fa;
border-radius: 6px;
}
.target-card {
display: flex;
gap: 10px;
@ -296,4 +464,29 @@ const viewMore = () => {
padding: 8px;
}
}
//
.dialog-search {
display: flex;
align-items: center;
gap: 8px;
.search-separator {
color: #909399;
margin: 0 4px;
}
}
.table-avatar {
width: 32px;
height: 32px;
border-radius: 4px;
object-fit: cover;
}
.dialog-pagination {
display: flex;
justify-content: flex-end;
margin-top: 16px;
}
</style>

Loading…
Cancel
Save