Initial commit

master
zhoulexin 6 days ago
commit 79b80d5cb3

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

@ -0,0 +1,2 @@
VITE_API_BASE_URL=https://api.example.com/api
VITE_APP_TITLE=视觉管理平台

2
.gitignore vendored

@ -0,0 +1,2 @@
node_modules
dist

@ -0,0 +1,12 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>智能视觉管理平台</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>

2200
package-lock.json generated

File diff suppressed because it is too large Load Diff

@ -0,0 +1,26 @@
{
"name": "new-pod-ui",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"@element-plus/icons-vue": "^2.3.1",
"axios": "^1.6.7",
"element-plus": "^2.6.1",
"js-md5": "^0.8.3",
"js-sha256": "^0.11.1",
"pinia": "^2.1.7",
"vue": "^3.4.21",
"vue-router": "^4.3.0"
},
"devDependencies": {
"@vitejs/plugin-vue": "^5.0.4",
"sass": "^1.71.1",
"vite": "^5.1.5"
}
}

@ -0,0 +1,26 @@
<template>
<router-view />
</template>
<script setup>
import { useThemeStore } from '@/stores/theme'
import { watch } from 'vue'
const themeStore = useThemeStore()
watch(
() => themeStore.themeColor,
(newColor) => {
document.documentElement.style.setProperty('--el-color-primary', newColor)
},
{ immediate: true }
)
</script>
<style lang="scss">
@use './styles/index.scss';
#app {
height: 100vh;
width: 100vw;
}
</style>

@ -0,0 +1,74 @@
import request from '@/utils/request'
// 获取树形结构数据
export const getTreeList = () => {
return request({
url: '/project/getList?name=',
method: 'get'
})
}
// 添加树节点
export const saveTreeNode = (data) => {
return request({
url: '/project/add',
method: 'post',
data
})
}
// 编辑树节点
export const editTreeNode = (data) => {
return request({
url: '/project/update',
method: 'post',
data
})
}
// 删除树节点
export const deleteTreeNode = (data) => {
return request({
url: `/project/delete`,
method: 'post',
data
})
}
// 获取数据列表
export const getDataList = (params) => {
return request({
url: '/file/getProjectId',
method: 'get',
params
})
}
// 删除数据
export const deleteData = (data) => {
return request({
url: `/file/deleteFile`,
method: 'post',
data
})
}
// 批量导出(返回文件流)
export const batchExport = (data) => {
return request({
url: '/file/download',
method: 'post',
data,
responseType: 'blob'
})
}
// 上传文件到 minio
export const uploadMinio = (data) => {
return request({
url: '/file/upload-minio',
method: 'post',
data,
headers: {
'Content-Type': 'multipart/form-data'
}
})
}

@ -0,0 +1,40 @@
import request, { pythonService } from '@/utils/request'
// 获取会话历史记录
export const getAIChatHistory = (data) => {
return request({
url: `/aiConversation/page?pageNum=${data.pageNum}&pageSize=${data.pageSize}`,
method: 'get'
})
}
// 获取会话详情
export const getChatContent = (data) => {
return request({
url: `/aiConversation/chat_content?conversationId=${data.conversationId}`,
method: 'get'
})
}
// 删除会话
export const deleteAIChat = (conversationId) => {
return request({
url: `/aiConversation/delete?conversationId=${conversationId}`,
method: 'post'
})
}
// 开启人脸识别
export const startFaceRecognition = (data) => {
return pythonService({
url: '/detection/start',
method: 'post',
data
})
}
// 关闭人脸识别
export const stopFaceRecognition = () => {
return pythonService({
url: '/detection/stop',
method: 'post'
})
}

@ -0,0 +1,42 @@
import request from '@/utils/request'
// 获取吊舱列表
export const getPodList = () => {
return request({
url: '/boards/location',
method: 'get'
})
}
// 添加设备
export const addPod = (data) => {
return request({
url: '/sterams/addDevice',
method: 'post',
data,
headers: {
'Content-Type': 'multipart/form-data'
}
})
}
// 编辑设备
export const editPod = (data) => {
return request({
url: '/sterams/updateDevice',
method: 'post',
data,
headers: {
'Content-Type': 'multipart/form-data'
}
})
}
// 删除设备
export const deletePod = (data) => {
return request({
url: `/sterams/deleteCameraGroup`,
method: 'post',
data
})
}

@ -0,0 +1,9 @@
import request from '@/utils/request'
// 获取设备详情
export const getPodInfos = (groupId) => {
return request({
url: `/sterams/getGroupMsg?groupId=${groupId}`,
method: 'get'
})
}

@ -0,0 +1,53 @@
import request,{ newPodService } from '@/utils/request'
// 新吊舱api
export const newPodApi = (data) => {
return newPodService({
url: '/SDK/UNIV_API',
method: 'post',
data
})
}
// 登录
export const loginApi = (data) => {
return request({
url: '/user/login',
method: 'post',
data
})
}
// 注册
export const registerApi = (data) => {
return request({
url: '/user/register',
method: 'post',
data
})
}
// 获取用户信息
export const getUserInfoApi = () => {
return request({
url: '/user/info',
method: 'get'
})
}
// 发送手机验证码
export const sendSmsCodeApi = (phone) => {
return request({
url: '/user/sendSms',
method: 'post',
data: { phone }
})
}
// 登出
export const logoutApi = () => {
return request({
url: '/user/logout',
method: 'post'
})
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 122 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

@ -0,0 +1,73 @@
<template>
<div class="auth-bg">
<div class="particles">
<div class="particle" v-for="i in 20" :key="i" :style="getParticleStyle(i)"></div>
</div>
<div class="bg-gradient"></div>
</div>
</template>
<script setup>
const getParticleStyle = (index) => {
const size = Math.random() * 6 + 2
const left = Math.random() * 100
const delay = Math.random() * 20
const duration = Math.random() * 20 + 15
return {
width: `${size}px`,
height: `${size}px`,
left: `${left}%`,
animationDelay: `${delay}s`,
animationDuration: `${duration}s`
}
}
</script>
<style lang="scss" scoped>
.auth-bg {
position: absolute;
inset: 0;
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 50%, #0f3460 100%);
.particles {
position: absolute;
inset: 0;
overflow: hidden;
.particle {
position: absolute;
bottom: -20px;
background: rgba(64, 158, 255, 0.6);
border-radius: 50%;
animation: float-up linear infinite;
opacity: 0.6;
}
}
.bg-gradient {
position: absolute;
inset: 0;
background:
radial-gradient(circle at 20% 80%, rgba(102, 126, 234, 0.3) 0%, transparent 50%),
radial-gradient(circle at 80% 20%, rgba(64, 158, 255, 0.3) 0%, transparent 50%),
radial-gradient(circle at 50% 50%, rgba(118, 75, 162, 0.2) 0%, transparent 70%);
}
}
@keyframes float-up {
0% {
transform: translateY(0) scale(1);
opacity: 0;
}
10% {
opacity: 0.6;
}
90% {
opacity: 0.6;
}
100% {
transform: translateY(-100vh) scale(0.5);
opacity: 0;
}
}
</style>

@ -0,0 +1,62 @@
<template>
<div class="auth-logo" :class="{ 'logo-link': to }">
<router-link v-if="to" :to="to" class="logo-inner">
<img src="@/assets/images/logo.png" alt="logo" />
<span v-if="showText" class="logo-text"><slot /></span>
</router-link>
<div v-else class="logo-inner">
<img src="@/assets/images/logo.png" alt="logo" />
<span v-if="showText" class="logo-text"><slot /></span>
</div>
</div>
</template>
<script setup>
defineProps({
to: {
type: [String, Object],
default: ''
},
showText: {
type: Boolean,
default: true
}
})
</script>
<style lang="scss" scoped>
.auth-logo {
position: fixed;
top: 20px;
left: 30px;
z-index: 100;
.logo-inner {
display: flex;
align-items: center;
gap: 12px;
text-decoration: none;
color: #fff;
img {
height: 45px;
}
.logo-text {
font-size: 18px;
font-weight: 600;
letter-spacing: 1px;
}
}
}
.logo-link {
.logo-inner {
transition: opacity 0.3s;
&:hover {
opacity: 0.8;
}
}
}
</style>

@ -0,0 +1,243 @@
<template>
<div class="webrtc-wrapper">
<video
ref="videoRef"
autoplay
muted
playsinline
class="video-player"
></video>
<!-- 录制状态指示器 -->
<div v-if="isRecording" class="recording-indicator">
<span class="dot"></span> 录制中 {{ recordTime }}s
</div>
<div v-if="error" class="error-overlay">{{ error }}</div>
</div>
</template>
<script setup>
import { ref, onMounted, onUnmounted, watch } from 'vue';
const props = defineProps({
src: { type: String, required: true }
});
// src
watch(() => props.src, (newSrc, oldSrc) => {
if (newSrc && newSrc !== oldSrc) {
disconnect();
connect();
}
});
//
const emit = defineEmits(['record-status-change','record-complete']);
const videoRef = ref(null);
const error = ref('');
let pc = null;
// --- ---
const isRecording = ref(false);
const recordTime = ref(0);
let mediaRecorder = null;
let recordedChunks = [];
let timerInterval = null;
// 1. WebRTC
const disconnect = () => {
if (pc) {
pc.close();
pc = null;
}
if (videoRef.value) {
videoRef.value.srcObject = null;
}
error.value = '';
};
// 2. WebRTC
const connect = async () => {
try {
pc = new RTCPeerConnection();
pc.ontrack = (event) => {
if (videoRef.value && event.streams[0]) {
videoRef.value.srcObject = event.streams[0];
}
};
pc.addTransceiver('video', { direction: 'recvonly' });
pc.addTransceiver('audio', { direction: 'recvonly' });
const offer = await pc.createOffer();
await pc.setLocalDescription(offer);
const res = await fetch(props.src, {
method: 'POST',
headers: { 'Content-Type': 'application/sdp' },
body: pc.localDescription.sdp
});
if (!res.ok) throw new Error(`Status: ${res.status}`);
const answer = await res.text();
await pc.setRemoteDescription({ type: 'answer', sdp: answer });
} catch (e) {
error.value = '连接失败,请检查视频地址';
console.error(e);
}
};
// 2.
const takeSnapshot = () => {
if (!videoRef.value) return null;
const video = videoRef.value;
const canvas = document.createElement('canvas');
canvas.width = video.videoWidth;
canvas.height = video.videoHeight;
const ctx = canvas.getContext('2d');
ctx.drawImage(video, 0, 0, canvas.width, canvas.height);
// Blob (: image/png, image/jpeg)
return new Promise((resolve) => {
canvas.toBlob((blob) => {
resolve(blob);
}, 'image/jpeg', 0.9);
});
};
// 3.
const startRecord = () => {
if (!videoRef.value || isRecording.value) return;
try {
// MediaStream
// captureStream API Chrome/Edge/Firefox
const stream = videoRef.value.captureStream ? videoRef.value.captureStream() : videoRef.value.mozCaptureStream();
if (!stream) {
error.value = '浏览器不支持捕获视频流';
return;
}
recordedChunks = [];
// MediaRecorder
// mimeType 'video/webm; codecs=vp9'
mediaRecorder = new MediaRecorder(stream, { mimeType: 'video/webm' });
mediaRecorder.ondataavailable = (event) => {
if (event.data.size > 0) {
recordedChunks.push(event.data);
}
};
mediaRecorder.onstop = () => {
const blob = new Blob(recordedChunks, { type: 'video/webm' });
recordedChunks = []; //
// blob
emit('record-complete', blob);
console.log('录制完成Blob大小:', blob.size);
};
mediaRecorder.start();
isRecording.value = true;
recordTime.value = 0;
//
timerInterval = setInterval(() => {
recordTime.value++;
}, 1000);
emit('record-status-change', true);
} catch (err) {
console.error('录制启动失败:', err);
error.value = '录制启动失败';
}
};
// 4.
const stopRecord = () => {
if (mediaRecorder && isRecording.value) {
mediaRecorder.stop();
isRecording.value = false;
clearInterval(timerInterval);
emit('record-status-change', false);
}
};
// 5.
const handleRecordComplete = (blob) => {
console.log('录制完成,文件大小:', blob.size, 'bytes');
//
// uploadVideoToServer(blob);
// URL
// const url = URL.createObjectURL(blob);
};
//
const uploadVideoToServer = async (blob) => {
const formData = new FormData();
formData.append('file', blob, `record_${new Date().getTime()}.webm`);
try {
//
// await axios.post('/api/upload/video', formData);
console.log('视频上传成功');
} catch (e) {
console.error('上传失败', e);
}
};
onMounted(() => connect());
onUnmounted(() => {
if (pc) pc.close();
if (isRecording.value) stopRecord();
});
//
defineExpose({
takeSnapshot,
startRecord,
stopRecord
});
</script>
<style scoped>
.webrtc-wrapper { width: 100%; height: 100%; position: relative; background: #000; }
.video-player { width: 100%; height: 100%; object-fit: contain; }
.error-overlay { position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); color: white; }
.recording-indicator {
position: absolute;
top: 10px;
right: 10px;
background: rgba(0, 0, 0, 0.6);
color: white;
padding: 4px 8px;
border-radius: 4px;
font-size: 12px;
display: flex;
align-items: center;
gap: 6px;
}
.dot {
width: 8px;
height: 8px;
background-color: red;
border-radius: 50%;
animation: blink 1s infinite;
}
@keyframes blink {
0% { opacity: 1; }
50% { opacity: 0.4; }
100% { opacity: 1; }
}
</style>

@ -0,0 +1,66 @@
// composables/useWebRtc.js
import { ref, onUnmounted } from 'vue';
export function useWebRtc(whepUrl) {
const videoRef = ref(null);
const error = ref(null);
let pc = null;
const connect = async () => {
if (!whepUrl) return;
try {
pc = new RTCPeerConnection();
pc.ontrack = (event) => {
if (videoRef.value && event.streams[0]) {
videoRef.value.srcObject = event.streams[0];
}
};
pc.addTransceiver('video', { direction: 'recvonly' });
pc.addTransceiver('audio', { direction: 'recvonly' });
const offer = await pc.createOffer();
await pc.setLocalDescription(offer);
const response = await fetch(whepUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/sdp' },
body: pc.localDescription.sdp
});
if (!response.ok) throw new Error(`HTTP Error: ${response.status}`);
const answerSdp = await response.text();
await pc.setRemoteDescription({ type: 'answer', sdp: answerSdp });
} catch (err) {
console.error(err);
error.value = err.message;
}
};
// 清理逻辑
const cleanup = () => {
if (pc) {
pc.close();
pc = null;
}
if (videoRef.value) {
videoRef.value.srcObject = null;
}
};
// 组件卸载时自动清理
onUnmounted(() => {
cleanup();
});
return {
videoRef,
error,
connect,
cleanup
};
}

@ -0,0 +1,23 @@
// 设备类型枚举
export const DeviceType = {
POD: 0,
DRONE: 1,
CCTV: 2,
ROBOT: 3
}
// 设备类型名称映射
export const DeviceTypeName = {
[DeviceType.POD]: '吊舱',
[DeviceType.DRONE]: '无人机',
[DeviceType.CCTV]: 'CCTV摄像头',
[DeviceType.ROBOT]: '机器人'
}
// 设备类型颜色映射
export const DeviceTypeColor = {
[DeviceType.POD]: 'primary', // 蓝色 - 吊舱
[DeviceType.DRONE]: 'success', // 绿色 - 无人机
[DeviceType.CCTV]: 'warning', // 橙色 - CCTV摄像头
[DeviceType.ROBOT]: 'danger' // 红色 - 机器人
}

@ -0,0 +1,277 @@
<template>
<el-container class="layout-container">
<el-aside :width="isCollapsed ? '64px' : '200px'" class="aside">
<div class="logo">
<!-- <img src="@/assets/images/logo.png" alt="logo" v-if="!isCollapsed" /> -->
<svg v-if="!isCollapsed" t="1776756485739" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="2128" width="32" height="32"><path d="M512 40.832a381.76 381.76 0 1 0 381.76 381.76A381.824 381.824 0 0 0 512 40.832zM307.2 206.208a44.8 44.8 0 1 1 44.8 44.8 44.8 44.8 0 0 1-44.8-44.8zM512 582.272a159.68 159.68 0 1 1 159.68-159.68A159.68 159.68 0 0 1 512 582.272z m0 0" fill="#409EFF" p-id="2129"></path><path d="M797.312 761.728a380.416 380.416 0 0 1-567.104 4.16l-70.4 161.216c-12.8 30.016 12.8 56.128 57.6 56.128h593.984c45.312 0 70.976-26.112 57.6-56.128z m0 0" fill="#409EFF" p-id="2130"></path></svg>
<span v-if="!isCollapsed"></span>
<el-icon v-else><Document /></el-icon>
</div>
<el-menu
:default-active="activeMenu"
:collapse="isCollapsed"
:collapse-transition="false"
router
class="menu"
>
<el-menu-item index="/home">
<el-icon><HomeFilled /></el-icon>
<template #title>首页</template>
</el-menu-item>
<el-menu-item index="/dataset">
<el-icon><FolderOpened /></el-icon>
<template #title>数据集</template>
</el-menu-item>
<el-menu-item index="/face-recognition">
<el-icon><UserFilled /></el-icon>
<template #title>人脸识别</template>
</el-menu-item>
<el-menu-item index="/personnel">
<el-icon><Avatar /></el-icon>
<template #title>人员管理</template>
</el-menu-item>
</el-menu>
</el-aside>
<el-container>
<el-header class="header">
<div class="header-left">
<el-icon class="collapse-btn" @click="isCollapsed = !isCollapsed">
<Fold v-if="!isCollapsed" />
<Expand v-else />
</el-icon>
</div>
<div class="header-right">
<!-- <el-popover
placement="bottom"
:width="300"
trigger="click"
>
<template #reference>
<el-icon class="header-icon"><Brush /></el-icon>
</template>
<div class="theme-picker">
<h4>切换主题颜色</h4>
<div class="color-list">
<div
v-for="item in themeStore.themeColors"
:key="item.color"
class="color-item"
:style="{ backgroundColor: item.color }"
:class="{ active: item.color === themeStore.themeColor }"
@click="handleChangeTheme(item.color)"
>
<el-icon v-if="item.color === themeStore.themeColor"><Check /></el-icon>
</div>
</div>
</div>
</el-popover> -->
<el-dropdown @command="handleCommand">
<span class="user-info">
<el-avatar :size="32" icon="UserFilled" />
<span class="username">{{ userStore.userInfo?.userName || '用户' }}</span>
<el-icon><ArrowDown /></el-icon>
</span>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item command="profile">
<el-icon><User /></el-icon>
</el-dropdown-item>
<el-dropdown-item command="settings">
<el-icon><Setting /></el-icon>
</el-dropdown-item>
<el-dropdown-item divided command="logout">
<el-icon><SwitchButton /></el-icon>退
</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</div>
</el-header>
<el-main class="main">
<router-view />
</el-main>
</el-container>
</el-container>
</template>
<script setup>
import { ref, computed } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { ElMessageBox, ElMessage } from 'element-plus'
import { useUserStore } from '@/stores/user'
import { useThemeStore } from '@/stores/theme'
const router = useRouter()
const route = useRoute()
const userStore = useUserStore()
const themeStore = useThemeStore()
const isCollapsed = ref(false)
const activeMenu = computed(() => route.path)
const handleChangeTheme = (color) => {
themeStore.setThemeColor(color)
ElMessage.success('主题颜色已切换')
}
const handleCommand = (command) => {
switch (command) {
case 'logout':
ElMessageBox.confirm('确定要退出登录吗?', '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}).then(() => {
userStore.logout()
router.push('/login')
ElMessage.success('已退出登录')
}).catch(() => {})
break
case 'profile':
ElMessage.info('个人中心功能开发中')
break
case 'settings':
ElMessage.info('设置功能开发中')
break
}
}
</script>
<style lang="scss" scoped>
@use '@/styles/variables.scss' as *;
@use '@/styles/mixin.scss' as *;
.layout-container {
height: 100vh;
}
.aside {
background: #fff;
box-shadow: 2px 0 8px rgba(0, 0, 0, 0.05);
transition: width 0.3s;
overflow: hidden;
.logo {
height: 60px;
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
font-size: 18px;
font-weight: 600;
color: var(--el-color-primary);
border-bottom: 1px solid $border-light;
img {
width: 32px;
height: 32px;
}
}
.menu {
border-right: none;
}
}
.header {
background: #fff;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 20px;
.header-left {
.collapse-btn {
font-size: 20px;
cursor: pointer;
color: $text-regular;
transition: color 0.3s;
&:hover {
color: $primary-color;
}
}
}
.header-right {
display: flex;
align-items: center;
gap: 20px;
.header-icon {
font-size: 20px;
cursor: pointer;
color: $text-regular;
transition: color 0.3s;
&:hover {
color: $primary-color;
}
}
.user-info {
display: flex;
align-items: center;
gap: 8px;
cursor: pointer;
.username {
font-size: 14px;
color: $text-regular;
}
}
}
}
.main {
background: $bg-base;
padding: 20px;
}
.theme-picker {
h4 {
font-size: 14px;
font-weight: 500;
margin-bottom: 15px;
color: $text-primary;
}
.color-list {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 12px;
.color-item {
width: 50px;
height: 50px;
border-radius: 8px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
color: #fff;
transition: all 0.3s;
border: 2px solid transparent;
&:hover {
transform: scale(1.1);
}
&.active {
border-color: $text-primary;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
}
}
}
}
:deep(.el-menu--collapse .el-menu-item span) {
display: none;
}
</style>

@ -0,0 +1,21 @@
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import ElementPlus from 'element-plus'
import zhCn from 'element-plus/dist/locale/zh-cn.mjs'
import * as ElementPlusIconsVue from '@element-plus/icons-vue'
import 'element-plus/dist/index.css'
import router from './router'
import App from './App.vue'
import './styles/index.scss'
const app = createApp(App)
for (const [key, component] of Object.entries(ElementPlusIconsVue)) {
app.component(key, component)
}
app.use(createPinia())
app.use(router)
app.use(ElementPlus, { locale: zhCn })
app.mount('#app')

@ -0,0 +1,86 @@
import { createRouter, createWebHistory } from 'vue-router'
import { useUserStore } from '@/stores/user'
const routes = [
{
path: '/login',
name: 'Login',
component: () => import('@/views/login/index.vue'),
meta: { title: '登录' }
},
{
path: '/register',
name: 'Register',
component: () => import('@/views/register/index.vue'),
meta: { title: '注册' }
},
{
path: '/',
component: () => import('@/layout/index.vue'),
redirect: '/home',
children: [
{
path: 'home',
name: 'Home',
component: () => import('@/views/home/index.vue'),
meta: { title: '首页', requiresAuth: true }
},
{
path: 'detail',
name: 'Detail',
component: () => import('@/views/detail/index.vue'),
meta: { title: '设备详情', requiresAuth: true }
},
{
path: 'dataset',
name: 'Dataset',
component: () => import('@/views/dataset/index.vue'),
meta: { title: '数据集', requiresAuth: true }
},
{
path: 'face-recognition',
name: 'FaceRecognition',
component: () => import('@/views/face-recognition/index.vue'),
meta: { title: '人脸识别', requiresAuth: true }
},
{
path: 'personnel',
name: 'Personnel',
component: () => import('@/views/personnel/index.vue'),
meta: { title: '人员管理', requiresAuth: true }
}
]
},
{
path: '/:pathMatch(.*)*',
redirect: '/login'
}
]
const router = createRouter({
history: createWebHistory(),
routes
})
router.beforeEach((to, from, next) => {
document.title = to.meta.title ? `${to.meta.title} - 视觉管理平台` : '视觉管理平台'
const userStore = useUserStore()
const token = userStore.token
if (to.meta.requiresAuth) {
if (!token) {
next({ name: 'Login', query: { redirect: to.fullPath } })
} else {
next()
}
} else {
if (token && (to.name === 'Login' || to.name === 'Register')) {
next({ name: 'Home' })
} else {
next()
}
}
})
export default router

@ -0,0 +1,4 @@
// 路由权限控制指令(可扩展)
export const initPermission = () => {
// 预留权限初始化逻辑
}

@ -0,0 +1,27 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
export const useThemeStore = defineStore('theme', () => {
const themeColors = ref([
{ name: '科技蓝', color: '#409EFF' },
{ name: '极客绿', color: '#67C23A' },
{ name: '活力橙', color: '#E6A23C' },
{ name: '玫瑰红', color: '#F56C6C' },
{ name: '神秘紫', color: '#9C27B0' },
{ name: '商务灰', color: '#607D8B' }
])
const themeColor = ref(localStorage.getItem('themeColor') || '#409EFF')
const setThemeColor = (color) => {
themeColor.value = color
localStorage.setItem('themeColor', color)
document.documentElement.style.setProperty('--el-color-primary', color)
}
return {
themeColors,
themeColor,
setThemeColor
}
})

@ -0,0 +1,68 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
import { loginApi, registerApi, logoutApi, getUserInfoApi } from '@/api/user'
export const useUserStore = defineStore('user', () => {
const token = ref(localStorage.getItem('token') || '')
const userInfo = ref(null)
const isLoggingOut = ref(false) // 防止logout循环调用
const setToken = (newToken) => {
token.value = newToken
localStorage.setItem('token', newToken)
}
const setUserInfo = (info) => {
userInfo.value = info
}
const login = async (loginData) => {
const res = await loginApi(loginData)
const { token, userName, id, permissions } = res.data
setToken(token)
setUserInfo({
id,
userName,
permissions
})
return { success: true, data: res.data }
}
const register = async (registerData) => {
const res = await registerApi(registerData)
return { success: true, data: res.data }
}
const logout = async () => {
if (isLoggingOut.value) return
isLoggingOut.value = true
try {
await logoutApi()
} catch (e) {
// 忽略logout接口错误
} finally {
token.value = ''
userInfo.value = null
localStorage.removeItem('token')
isLoggingOut.value = false
}
}
const getUserInfo = async () => {
if (!token.value) return
const res = await getUserInfoApi()
setUserInfo(res.data)
}
return {
token,
userInfo,
setToken,
setUserInfo,
login,
register,
logout,
getUserInfo
}
})

@ -0,0 +1,73 @@
@use './variables.scss' as *;
@use './mixin.scss' as *;
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
html, body {
height: 100%;
font-family: 'Helvetica Neue', Helvetica, 'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei', Arial, sans-serif;
color: $text-primary;
background-color: $bg-base;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
::-webkit-scrollbar {
width: 6px;
height: 6px;
}
::-webkit-scrollbar-track {
background: $bg-base;
}
::-webkit-scrollbar-thumb {
background: $border-base;
border-radius: 3px;
&:hover {
background: $text-secondary;
}
}
.text-center {
text-align: center;
}
.text-right {
text-align: right;
}
.mt-10 {
margin-top: 10px;
}
.mt-20 {
margin-top: 20px;
}
.mb-10 {
margin-bottom: 10px;
}
.mb-20 {
margin-bottom: 20px;
}
.page-container {
padding: 20px;
}
.card-container {
@include flat-card;
padding: 20px;
}
.btn-flat {
@include flat-button;
}

@ -0,0 +1,54 @@
@use './variables.scss' as *;
@mixin flat-button {
border: none;
border-radius: $border-radius-base;
transition: all $transition-duration $transition-function;
&:hover {
transform: translateY(-2px);
box-shadow: $box-shadow-base;
}
&:active {
transform: translateY(0);
}
}
@mixin flat-card {
background: #fff;
border-radius: $border-radius-base;
box-shadow: $box-shadow-light;
border: 1px solid $border-lighter;
transition: all $transition-duration $transition-function;
&:hover {
box-shadow: $box-shadow-base;
}
}
@mixin flex-center {
display: flex;
align-items: center;
justify-content: center;
}
@mixin flex-between {
display: flex;
align-items: center;
justify-content: space-between;
}
@mixin text-ellipsis {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
@mixin multi-ellipsis($lines: 2) {
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: $lines;
overflow: hidden;
text-overflow: ellipsis;
}

@ -0,0 +1,27 @@
$primary-color: #409EFF;
$success-color: #67C23A;
$warning-color: #E6A23C;
$danger-color: #F56C6C;
$info-color: #909399;
$text-primary: #303133;
$text-regular: #606266;
$text-secondary: #909399;
$text-placeholder: #c0c4cc;
$border-base: #dcdfe6;
$border-light: #e4e7ed;
$border-lighter: #ebeef5;
$border-extra-light: #f2f6fc;
$bg-base: #f5f7fa;
$bg-light: #fafafa;
$border-radius-base: 4px;
$border-radius-small: 2px;
$box-shadow-base: 0 2px 12px rgba(0, 0, 0, 0.1);
$box-shadow-light: 0 2px 6px rgba(0, 0, 0, 0.05);
$transition-duration: 0.3s;
$transition-function: ease;

@ -0,0 +1,2 @@
const fileHttp = 'http://ngsk.tech:3900'
export default fileHttp

@ -0,0 +1,6 @@
const ptUrl = {
url0:'http://10.23.22.43:5000',
url1:'http://10.23.22.43:5001',
url2:'http://10.23.22.43:5004'
}
export default ptUrl;

@ -0,0 +1,95 @@
import axios from 'axios'
import { ElMessage } from 'element-plus'
import { useUserStore } from '@/stores/user'
import router from '@/router'
function createService(baseURL) {
const service = axios.create({
baseURL: baseURL,
timeout: 15000
})
// 请求拦截器
service.interceptors.request.use(
(config) => {
const userStore = useUserStore()
if (userStore.token) {
config.headers['Authorization'] = `Bearer ${userStore.token}`
}
return config
},
(error) => {
console.error('请求错误:', error)
return Promise.reject(error)
}
)
// 响应拦截器
service.interceptors.response.use(
(response) => {
// blob 类型响应直接返回原始 response
if (response.config.responseType === 'blob') {
return response
}
const res = response.data
if (res.code && res.code !== 200) {
ElMessage.error(res.message || '请求失败')
if (res.code === 401) {
const userStore = useUserStore()
if (!userStore.isLoggingOut) {
userStore.logout()
router.push('/login')
}
}
return Promise.reject(new Error(res.message || '请求失败'))
}
return res
},
(error) => {
console.error('响应错误:', error)
if (error.response) {
switch (error.response.status) {
case 401:
ElMessage.error('登录已过期,请重新登录')
const userStore = useUserStore()
if (!userStore.isLoggingOut) {
userStore.logout()
router.push('/login')
}
break
case 403:
ElMessage.error('没有权限访问')
break
case 404:
ElMessage.error('请求资源不存在')
break
case 500:
ElMessage.error('服务器错误')
break
default:
ElMessage.error(error.message || '网络错误')
}
} else {
ElMessage.error('网络连接失败,请检查网络')
}
return Promise.reject(error)
}
)
return service
}
// 系统后端api
export const mainService = createService(import.meta.env.VITE_API_BASE_URL || '/api')
// 新吊舱api
export const newPodService = createService(import.meta.env.VITE_API_NEWPOD_URL || '')
// python接口api
export const pythonService = createService(import.meta.env.VITE_API_PYTHON_URL || '/api')
export default mainService

@ -0,0 +1,465 @@
<template>
<div class="data-list">
<!-- 筛选区域 -->
<div class="filter-bar">
<el-input
v-model="filterParams.name"
placeholder="文件名"
clearable
style="width: 200px"
/>
<el-select
v-model="filterParams.fileType"
placeholder="文件类型"
clearable
style="width: 140px"
>
<el-option label="图片" :value="1" />
<el-option label="视频" :value="0" />
</el-select>
<el-button type="primary" @click="handleSearch">
<el-icon><Search /></el-icon>
搜索
</el-button>
<el-button @click="handleReset">
<el-icon><Refresh /></el-icon>
重置
</el-button>
<el-button
type="success"
@click="toggleSelection"
>
{{ selectionMode ? '取消选择' : '选择导出' }}
<span v-if="selectionMode">({{ selectedItems.length }})</span>
</el-button>
<el-button
v-if="selectionMode"
type="primary"
@click="toggleSelectAll"
>
{{ isAllSelected ? '取消全选' : '全选' }}
</el-button>
<el-button
type="warning"
@click="handleBatchExport"
:disabled="selectedItems.length === 0"
:loading="exporting"
>
<el-icon><Download /></el-icon>
批量导出 ({{ selectedItems.length }})
</el-button>
<el-button
v-if="isLastLevel"
type="primary"
@click="handleAddFile"
>
<el-icon><Plus /></el-icon>
添加文件
</el-button>
</div>
<!-- 数据展示 -->
<div class="data-container">
<el-row :gutter="12" v-if="dataList.length > 0">
<el-col :span="6" v-for="item in dataList" :key="item.id">
<div
class="data-item"
:class="{ 'is-selected': isSelected(item.id) }"
@click="handleItemClick(item)"
>
<div class="item-checkbox" v-if="selectionMode">
<el-icon><Check /></el-icon>
</div>
<div class="item-preview">
<el-image
v-if="item.fileType == '1'"
style="width: 100%; height: 100%"
:src="fileHttp + item.url"
:preview-src-list="[fileHttp + item.url]"
show-progress
fit="cover"
/>
<video v-else :src="fileHttp + item.url" controls />
<div class="item-type">
<el-tag size="small" :type="item.fileType == '1' ? undefined : 'warning'">
{{ item.fileType == '1' ? '图片' : '视频' }}
</el-tag>
</div>
</div>
<div class="item-info">
<span class="item-name" :title="item.name">{{ item.name }}</span>
<el-button
type="danger"
size="small"
plain
@click="handleDelete(item)"
>
<el-icon><Delete /></el-icon>
</el-button>
</div>
</div>
</el-col>
</el-row>
<el-empty
v-if="dataList.length === 0 && !loading"
description="暂无数据"
/>
</div>
<!-- 分页 -->
<div class="pagination-wrapper" v-if="total > 0">
<el-pagination
v-model:current-page="pagination.pageNum"
v-model:page-size="pagination.pageSize"
:total="total"
:page-sizes="[12, 24, 36, 48]"
layout="total, sizes, prev, pager, next"
@size-change="handleSizeChange"
@current-change="handleCurrentChange"
/>
</div>
</div>
</template>
<script setup>
import { ref, reactive, watch, computed } from 'vue'
import { ElMessageBox, ElMessage } from 'element-plus'
import { getDataList, deleteData, batchExport } from '@/api/dataset'
import fileHttp from '@/utils/fileHttp'
const props = defineProps({
projectId: {
type: [String, Number],
default: ''
},
isLastLevel: {
type: Boolean,
default: false
}
})
const emit = defineEmits(['add-file'])
const loading = ref(false)
const exporting = ref(false)
const dataList = ref([])
const total = ref(0)
const selectionMode = ref(false)
const selectedItems = ref([])
//
const isAllSelected = computed(() => {
if (dataList.value.length === 0) return false
return dataList.value.every(item => isSelected(item.id))
})
const pagination = reactive({
pageNum: 1,
pageSize: 12
})
const filterParams = reactive({
name: '',
fileType: ''
})
//
const fetchData = async () => {
if (!props.projectId) {
dataList.value = []
total.value = 0
return
}
loading.value = true
try {
const params = {
projectId: props.projectId,
name: filterParams.name,
fileType: filterParams.fileType,
pageNum: pagination.pageNum,
pageSize: pagination.pageSize
}
const res = await getDataList(params)
dataList.value = res.data?.list || []
total.value = res.data?.total || 0
} catch (error) {
ElMessage.error('获取数据列表失败')
} finally {
loading.value = false
}
}
//
const handleSearch = () => {
pagination.pageNum = 1
fetchData()
}
//
const handleReset = () => {
filterParams.name = ''
filterParams.fileType = ''
pagination.pageNum = 1
fetchData()
}
//
const handleSizeChange = () => {
fetchData()
}
const handleCurrentChange = () => {
fetchData()
}
//
const handleDelete = (item) => {
ElMessageBox.confirm(`确定要删除"${item.name}"吗?`, '删除确认', {
confirmButtonText: '确定删除',
cancelButtonText: '取消',
type: 'warning'
}).then(async () => {
try {
const formData = new FormData()
formData.append('id',item.id)
await deleteData(formData)
ElMessage.success('删除成功')
fetchData()
} catch (error) {
ElMessage.error('删除失败')
}
}).catch(() => {})
}
//
const toggleSelection = () => {
if (selectionMode.value) {
// 退
selectedItems.value = []
}
selectionMode.value = !selectionMode.value
}
// /
const toggleSelectAll = () => {
if (isAllSelected.value) {
//
const currentPageIds = dataList.value.map(item => item.id)
selectedItems.value = selectedItems.value.filter(
item => !currentPageIds.includes(item.id)
)
} else {
//
const existingIds = new Set(selectedItems.value.map(item => item.id))
dataList.value.forEach(item => {
if (!existingIds.has(item.id)) {
selectedItems.value.push(item)
}
})
}
}
//
const isSelected = (id) => {
return selectedItems.value.some(item => item.id === id)
}
//
const handleItemClick = (item) => {
if (!selectionMode.value) return
const index = selectedItems.value.findIndex(i => i.id === item.id)
if (index > -1) {
selectedItems.value.splice(index, 1)
} else {
selectedItems.value.push(item)
}
}
//
const handleAddFile = () => {
emit('add-file')
}
//
const handleBatchExport = async () => {
if (selectedItems.value.length === 0) {
ElMessage.warning('请先选择要导出的文件')
return
}
exporting.value = true
try {
const ids = selectedItems.value.map(item => item.id)
const response = await batchExport(ids)
// 使
const contentDisposition = response.headers['content-disposition']
const now = new Date()
const dateStr = `${now.getFullYear()}${String(now.getMonth() + 1).padStart(2, '0')}${String(now.getDate()).padStart(2, '0')}`
let fileName = `${dateStr}-批量导出.zip`
if (contentDisposition) {
const match = contentDisposition.match(/filename[^;=\n]*=((['"]).*?\2|[^;\n]*)/)
if (match && match[1]) {
fileName = match[1].replace(/['"]/g, '')
fileName = decodeURIComponent(fileName)
}
}
//
const blob = new Blob([response.data])
const url = window.URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = fileName
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
window.URL.revokeObjectURL(url)
ElMessage.success('导出成功')
selectionMode.value = false
selectedItems.value = []
} catch (error) {
ElMessage.error('导出失败')
} finally {
exporting.value = false
}
}
//
watch(() => props.projectId, () => {
pagination.pageNum = 1
fetchData()
}, { immediate: true })
defineExpose({ fetchData })
</script>
<style lang="scss" scoped>
.data-list {
display: flex;
flex-direction: column;
height: 100%;
}
.filter-bar {
display: flex;
gap: 12px;
margin-bottom: 12px;
flex-wrap: wrap;
.el-button+.el-button{
margin-left: 0;
}
}
.data-container {
flex: 1;
overflow-y: auto;
overflow-x: hidden;
:deep(.el-row) {
display: flex;
flex-wrap: wrap;
box-sizing: border-box;
.el-col{
padding: 6px;
}
}
}
.data-item {
background: #fff;
border-radius: 6px;
border: 1px solid #ebeef5;
overflow: hidden;
transition: all 0.3s;
display: flex;
flex-direction: column;
height: 100%;
margin-bottom: 12px;
position: relative;
&:hover {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
border-color: #409eff;
}
&.is-selected {
border-color: #409eff;
box-shadow: 0 0 0 2px rgba(64, 158, 255, 0.2);
}
.item-checkbox {
position: absolute;
top: 8px;
left: 8px;
width: 20px;
height: 20px;
background: rgba(255, 255, 255, 0.9);
border: 2px solid #409eff;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
z-index: 10;
.el-icon {
color: #409eff;
font-weight: bold;
opacity: 0;
}
}
&.is-selected .item-checkbox {
background: #409eff;
.el-icon {
opacity: 1;
color: #fff;
}
}
.item-preview {
position: relative;
width: 100%;
height: calc((100vh - 480px) / 3);
background: #f5f7fa;
overflow: hidden;
video {
width: 100%;
height: 100%;
object-fit: cover;
}
.item-type {
position: absolute;
top: 6px;
right: 6px;
}
}
.item-info {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 10px;
gap: 6px;
flex-shrink: 0;
.item-name {
flex: 1;
font-size: 12px;
color: #606266;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
}
.pagination-wrapper {
margin-top: 12px;
display: flex;
justify-content: flex-end;
}
</style>

@ -0,0 +1,119 @@
<template>
<el-dialog
v-model="visible"
:title="isEdit ? '编辑' : '新增'"
width="480px"
:close-on-click-modal="false"
@closed="handleClosed"
>
<el-form
ref="formRef"
:model="formData"
:rules="rules"
label-width="90px"
>
<el-form-item label="数据名称" prop="name">
<el-input v-model="formData.name" placeholder="请输入数据名称" />
</el-form-item>
<el-form-item label="场景" prop="sceneType">
<el-select v-model="formData.sceneType" placeholder="请选择场景" style="width: 100%">
<el-option label="人" :value="0" />
<el-option label="车" :value="1" />
</el-select>
</el-form-item>
<el-form-item label="数据类型" prop="fileType">
<el-select v-model="formData.fileType" placeholder="请选择数据类型" style="width: 100%">
<el-option label="图片" :value=1 />
<el-option label="视频" :value=0 />
</el-select>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="visible = false">取消</el-button>
<el-button type="primary" :loading="loading" @click="handleSubmit"></el-button>
</template>
</el-dialog>
</template>
<script setup>
import { ref, computed, watch } from 'vue'
import { ElMessage } from 'element-plus'
import { saveTreeNode, editTreeNode } from '@/api/dataset'
const props = defineProps({
modelValue: Boolean,
formData: {
type: Object,
default: () => ({})
},
isEdit: Boolean
})
const emit = defineEmits(['update:modelValue', 'success'])
const formRef = ref(null)
const loading = ref(false)
const visible = computed({
get: () => props.modelValue,
set: (val) => emit('update:modelValue', val)
})
const formData = ref({
name: '',
sceneType: '',
fileType: ''
})
const rules = {
name: [{ required: true, message: '请输入数据名称', trigger: 'blur' }],
// sceneType: [{ required: true, message: '', trigger: 'change' }],
// fileType: [{ required: true, message: '', trigger: 'change' }]
}
watch(() => props.formData, (val) => {
if (val) {
formData.value = { ...val }
}
}, { immediate: true, deep: true })
const handleSubmit = async () => {
const valid = await formRef.value.validate().catch(() => false)
if (!valid) return
loading.value = true
try {
if (props.isEdit) {
// idnamescenefileType
const editData = {
id: formData.value.id,
name: formData.value.name,
sceneType: formData.value.sceneType,
fileType: formData.value.fileType
}
await editTreeNode(editData)
ElMessage.success('编辑成功')
} else {
// parentId
const submitData = { ...formData.value }
if (!submitData.parentId) {
delete submitData.parentId
}
await saveTreeNode(submitData)
ElMessage.success('新增成功')
}
emit('success')
} catch (error) {
ElMessage.error(props.isEdit ? '编辑失败' : '新增失败')
} finally {
loading.value = false
}
}
const handleClosed = () => {
formRef.value?.resetFields()
}
</script>

@ -0,0 +1,204 @@
<template>
<div class="tree-panel">
<div class="tree-header">
<span class="tree-title">数据列表</span>
<el-button type="primary" size="small" @click="handleAdd">
<el-icon><Plus /></el-icon>
新增
</el-button>
</div>
<div class="tree-content">
<el-tree
ref="treeRef"
:data="treeData"
:props="{ children: 'children', label: 'name' }"
node-key="id"
:current-node-key="currentNodeKey"
:expand-on-click-node="false"
highlight-current
@node-click="handleNodeClick"
>
<template #default="{ node, data }">
<div class="tree-node">
<span class="node-label">{{ node.label }}</span>
<div class="node-actions">
<el-button link type="primary" size="small" @click.stop="handleAdd(data)">
<el-icon><Plus /></el-icon>
</el-button>
<el-button link type="primary" size="small" @click.stop="handleEdit(data)">
<el-icon><Edit /></el-icon>
</el-button>
<el-button link type="danger" size="small" @click.stop="handleDelete(data)">
<el-icon><Delete /></el-icon>
</el-button>
</div>
</div>
</template>
</el-tree>
</div>
<FormDialog
v-model="dialogVisible"
:form-data="currentNode"
:is-edit="isEdit"
@success="onFormSuccess"
/>
</div>
</template>
<script setup>
import { ref, watch, defineProps, defineEmits } from 'vue'
import { ElMessageBox, ElMessage } from 'element-plus'
import { deleteTreeNode } from '@/api/dataset'
import FormDialog from './FormDialog.vue'
const props = defineProps({
treeData: {
type: Array,
default: () => []
}
})
const emit = defineEmits(['select', 'refresh', 'add', 'edit'])
const treeRef = ref(null)
const dialogVisible = ref(false)
const currentNode = ref(null)
const isEdit = ref(false)
const currentNodeKey = ref(null)
//
watch(() => props.treeData, (newData) => {
if (newData && newData.length > 0) {
const firstNode = getFirstNode(newData)
if (firstNode?.id) {
currentNodeKey.value = firstNode.id
emit('select', firstNode.id)
}
}
}, { immediate: true })
//
const getFirstNode = (nodes) => {
if (!nodes || nodes.length === 0) return null
for (const node of nodes) {
if (node.id) return node
if (node.children?.length > 0) {
const child = getFirstNode(node.children)
if (child) return child
}
}
return null
}
//
const handleNodeClick = (data) => {
emit('select', data.id)
}
//
const handleAdd = (data = null) => {
currentNode.value = data ? { parentId: data.id } : {}
isEdit.value = false
dialogVisible.value = true
}
//
const handleEdit = (data) => {
currentNode.value = { ...data }
isEdit.value = true
dialogVisible.value = true
}
//
const handleDelete = (data) => {
ElMessageBox.confirm(`确定要删除"${data.name}"吗?删除后无法恢复。`, '删除确认', {
confirmButtonText: '确定删除',
cancelButtonText: '取消',
type: 'warning'
}).then(async () => {
try {
const formData = new FormData()
formData.append('id',data.id)
await deleteTreeNode(formData)
ElMessage.success('删除成功')
emit('refresh')
} catch (error) {
ElMessage.error('删除失败')
}
}).catch(() => {})
}
//
const onFormSuccess = () => {
dialogVisible.value = false
emit('refresh')
}
</script>
<style lang="scss" scoped>
.tree-panel {
background: #fff;
border-radius: 8px;
border: 1px solid #ebeef5;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.08);
display: flex;
flex-direction: column;
height: 100%;
}
.tree-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px;
border-bottom: 1px solid #ebeef5;
.tree-title {
font-size: 15px;
font-weight: 600;
color: #303133;
}
}
.tree-content {
flex: 1;
padding: 12px;
overflow-y: auto;
:deep(.el-tree-node__content) {
height: 36px;
}
}
.tree-node {
flex: 1;
display: flex;
justify-content: space-between;
align-items: center;
width: 100%;
padding-right: 8px;
.node-label {
flex: 1;
font-size: 13px;
}
.node-actions {
display: none;
gap: 4px;
.el-button {
padding: 4px;
}
.el-button+.el-button{
margin-left: 0;
}
}
}
:deep(.el-tree-node__content:hover .node-actions) {
display: flex;
}
</style>

@ -0,0 +1,265 @@
<template>
<div class="dataset-container">
<div class="dataset-header">
<span class="page-title">数据集管理</span>
</div>
<div class="dataset-content">
<!-- 左侧树形面板 -->
<div class="left-panel">
<TreePanel
:tree-data="treeData"
@select="handleTreeSelect"
@refresh="fetchTreeData"
/>
</div>
<!-- 右侧数据列表 -->
<div class="right-panel">
<div class="panel-card">
<DataList
ref="dataListRef"
:projectId="selectedCategoryId"
:isLastLevel="isLastLevel"
@add-file="handleAddFile"
/>
</div>
</div>
</div>
<!-- 上传文件弹窗 -->
<el-dialog
v-model="uploadDialogVisible"
title="上传文件"
width="500px"
:close-on-click-modal="false"
>
<el-form :model="uploadForm" label-width="90px">
<el-form-item label="关联数据">
<el-cascader
v-model="uploadForm.relatedPath"
:options="treeData"
:props="{ value: 'id', label: 'name', children: 'children' }"
disabled
style="width: 100%"
/>
</el-form-item>
<el-form-item label="上传类型">
<el-input
:value="uploadForm.fileType === 1 ? '图片' : uploadForm.fileType === 0 ? '视频' : '未设置'"
disabled
/>
</el-form-item>
<el-form-item label="上传文件" required>
<el-upload
ref="uploadRef"
:auto-upload="false"
:accept="acceptFormat"
:limit="10"
multiple
:on-change="handleFileChange"
:on-remove="handleFileRemove"
drag
>
<el-icon class="el-icon--upload"><UploadFilled /></el-icon>
<div class="el-upload__text">
拖拽文件到此处或 <em>点击上传</em>
</div>
<template #tip>
<div class="el-upload__tip">
支持上传 {{ acceptFormat }} 格式文件单个文件不超过500MB
</div>
</template>
</el-upload>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="uploadDialogVisible = false">取消</el-button>
<el-button type="primary" :loading="uploading" @click="handleUpload"></el-button>
</template>
</el-dialog>
</div>
</template>
<script setup>
import { ref, reactive, computed, onMounted } from 'vue'
import { ElMessage } from 'element-plus'
import { getTreeList, uploadMinio } from '@/api/dataset'
import TreePanel from './components/TreePanel.vue'
import DataList from './components/DataList.vue'
const treeData = ref([])
const selectedCategoryId = ref('')
const selectedNode = ref(null)
const dataListRef = ref(null)
// fileType
const isLastLevel = computed(() => {
return selectedNode.value && selectedNode.value.fileType !== undefined
})
//
const uploadDialogVisible = ref(false)
const uploading = ref(false)
const uploadRef = ref(null)
const uploadFileList = ref([])
const uploadForm = reactive({
relatedPath: [],
fileType: null,
projectId: ''
})
// fileType
const acceptFormat = computed(() => {
if (uploadForm.fileType === 1) return '.zip,.jpg,.jpeg,.png,.gif,.bmp,.webp'
if (uploadForm.fileType === 0) return '.zip,.mp4,.avi,.mov,.wmv,.flv,.mkv,.webm'
return '.zip,.jpg,.jpeg,.png,.gif,.bmp,.webp,.mp4,.avi,.mov,.wmv,.flv,.mkv,.webm'
})
//
const fetchTreeData = async () => {
try {
const res = await getTreeList()
treeData.value = res.data?.list || []
} catch (error) {
ElMessage.error('获取数据列表失败')
}
}
// ID
const findNodeById = (nodes, id) => {
for (const node of nodes) {
if (node.id === id) return node
if (node.children?.length > 0) {
const found = findNodeById(node.children, id)
if (found) return found
}
}
return null
}
//
const handleTreeSelect = (id) => {
selectedCategoryId.value = id
selectedNode.value = findNodeById(treeData.value, id)
}
//
const handleAddFile = () => {
if (!selectedNode.value) return
uploadForm.relatedPath = getNodePath(selectedNode.value.id)
uploadForm.fileType = selectedNode.value.fileType
uploadForm.projectId = selectedNode.value.id
uploadFileList.value = []
uploadDialogVisible.value = true
}
//
const getNodePath = (nodeId) => {
const path = []
const findPath = (nodes, id, currentPath) => {
for (const node of nodes) {
const newPath = [...currentPath, node.id]
if (node.id === id) {
path.push(...newPath)
return true
}
if (node.children?.length > 0) {
if (findPath(node.children, id, newPath)) return true
}
}
return false
}
findPath(treeData.value, nodeId, [])
return path
}
//
const handleFileChange = (file, files) => {
uploadFileList.value = files
}
//
const handleFileRemove = (file, files) => {
uploadFileList.value = files
}
//
const handleUpload = async () => {
if (uploadFileList.value.length === 0) {
ElMessage.warning('请选择要上传的文件')
return
}
uploading.value = true
try {
const formData = new FormData()
uploadFileList.value.forEach(file => {
formData.append('files', file.raw)
})
formData.append('projectId', uploadForm.projectId)
formData.append('fileType', uploadForm.fileType)
await uploadMinio(formData)
ElMessage.success('上传成功')
uploadDialogVisible.value = false
dataListRef.value?.fetchData?.()
} catch (error) {
ElMessage.error('上传失败')
} finally {
uploading.value = false
}
}
onMounted(() => {
fetchTreeData()
})
</script>
<style lang="scss" scoped>
.dataset-container {
height: calc(100vh - 120px);
display: flex;
flex-direction: column;
}
.dataset-header {
margin-bottom: 16px;
.page-title {
font-size: 18px;
font-weight: 600;
color: #303133;
}
}
.dataset-content {
flex: 1;
display: flex;
gap: 16px;
min-height: 0;
}
.left-panel {
width: 280px;
flex-shrink: 0;
}
.right-panel {
flex: 1;
min-width: 0;
.panel-card {
background: #fff;
border-radius: 8px;
border: 1px solid #ebeef5;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.08);
padding: 16px;
height: 100%;
overflow: hidden;
}
}
</style>

@ -0,0 +1,642 @@
<template>
<div class="detail-container">
<!-- 顶部导航 -->
<div class="detail-header">
<div class="header-left">
<el-button icon="ArrowLeft" circle @click="goBack" />
<span class="pod-name">{{ deviceInfos.groupName }}</span>
</div>
<div class="header-right">
<el-tag :type="deviceInfos.groupStatus === 1 ? 'success' : 'danger'" size="small">
{{ deviceInfos.groupStatus === 1 ? '在线' : '离线' }}
</el-tag>
</div>
</div>
<!-- 主内容区 -->
<div class="detail-content">
<!-- 左侧控制面板 -->
<div class="control-panel">
<div class="panel-section">
<div class="pod-img">
<img :src="fileHttp + deviceInfos.imgUrl" alt="">
</div>
</div>
<div class="panel-section">
<div class="section-title">方向控制</div>
<div class="direction-pad">
<div v-for="(row, rowIndex) in directionRows" :key="rowIndex" class="direction-row">
<el-button
v-for="btn in row"
:key="btn.icon"
class="dir-btn"
@mousedown="btn.isRotate ? podRotation() : controlPod(btn.x, btn.y)"
@mouseup="!btn.isRotate && controlPod(0, 0)"
>
<el-icon><component :is="btn.icon" /></el-icon>
</el-button>
</div>
</div>
</div>
<el-divider />
<div class="panel-section">
<div class="section-title">方位转速</div>
<div class="rotate-controls">
<div class="slider-item">
<span class="slider-label">旋转速度</span>
<el-slider v-model="rotateSpeed" :min="1" :max="7" :step="1" />
<span class="slider-value">{{ rotateSpeed }}</span>
</div>
</div>
</div>
<el-divider />
<div class="panel-section">
<div class="section-title">调焦控制</div>
<div class="focus-controls">
<div class="control-row">
<el-button-group>
<el-button icon="ZoomOut" @click="focusing(-1)" />
<el-button disabled class="control-label">调焦</el-button>
<el-button icon="ZoomIn" @click="focusing(1)" />
</el-button-group>
</div>
</div>
</div>
<div class="panel-section">
<div class="section-title">聚焦控制</div>
<div class="focus-controls">
<div class="control-row">
<el-button-group>
<el-button icon="ZoomOut" @click="focus(-1)" />
<el-button disabled class="control-label">聚焦</el-button>
<el-button icon="ZoomIn" @click="focus(1)" />
</el-button-group>
</div>
</div>
</div>
<div class="panel-section">
<div class="section-title">光圈控制</div>
<div class="aperture-controls">
<div class="control-row">
<el-button-group>
<el-button icon="ZoomOut" @click="aperture(-1)" />
<el-button disabled class="control-label">光圈</el-button>
<el-button icon="ZoomIn" @click="aperture(1)" />
</el-button-group>
</div>
</div>
</div>
<div class="panel-section">
<div class="section-title grid_2">
雨刷
<el-switch v-model="wiperCtrl" size="small" @change="wiper" />
</div>
</div>
<div class="panel-section">
<div class="section-title grid_2">
镜头初始化
<el-button type="primary" size="small" :disabled="initialization" :loading="initialization" @click="lensInitialization"></el-button>
</div>
</div>
</div>
<!-- 右侧视频区域 -->
<div class="video-section">
<div class="video-grid">
<!-- 可见光 -->
<div class="video-card" v-for="(item, index) in deviceInfos.cameras" :key="index">
<div class="video-card-header">
<el-icon><VideoCamera /></el-icon>
<span>{{ item.cameraLocation }}</span>
</div>
<div class="video-card-body">
<!-- <iframe src="http://10.23.22.43:8889/camera1" class="video-player"></iframe> -->
<WebRtcPlayer
ref="playerRefs"
:src="`${item.videoStreaming}/whep`"
@record-status-change="(status) => handleRecordStatus(index, status)"
@record-complete="(blob) => handleRecordComplete(blob, index)"
/>
</div>
<div class="video-card-footer">
<el-button size="small" icon="Picture" @click="handleSnapshot(index)"></el-button>
<el-button
size="small"
:icon="recordingStates[index] ? 'VideoPause' : 'VideoCamera'"
@click="toggleRecord(index)"
>
{{ recordingStates[index] ? '停止' : '录屏' }}
</el-button>
<el-button size="small" icon="FullScreen">放大</el-button>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, onMounted, onBeforeUnmount, markRaw } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import { getPodInfos } from '@/api/pod_detail'
import { newPodApi } from '@/api/user'
import { TopLeft, Top, TopRight, Back, Refresh, Right, BottomLeft, Bottom, BottomRight } from '@element-plus/icons-vue'
import fileHttp from '@/utils/fileHttp'
import WebRtcPlayer from '@/components/WebRtcPlayer.vue'
// 33
const directionRows = [
[
{ icon: markRaw(TopLeft), x: -1, y: 1, isRotate: false },
{ icon: markRaw(Top), x: 0, y: 1, isRotate: false },
{ icon: markRaw(TopRight), x: 1, y: 1, isRotate: false }
],
[
{ icon: markRaw(Back), x: -1, y: 0, isRotate: false },
{ icon: markRaw(Refresh), x: 0, y: 0, isRotate: true },
{ icon: markRaw(Right), x: 1, y: 0, isRotate: false }
],
[
{ icon: markRaw(BottomLeft), x: -1, y: -1, isRotate: false },
{ icon: markRaw(Bottom), x: 0, y: -1, isRotate: false },
{ icon: markRaw(BottomRight), x: 1, y: -1, isRotate: false }
]
]
const route = useRoute()
const router = useRouter()
//
const podId = ref(route.query.groupId || '')
const isOnline = ref(true)
//
const rotateSpeed = ref(5)
//
const deviceInfos = ref({})
const podInfo = async () => {
try {
const res = await getPodInfos(podId.value)
deviceInfos.value = res.data
} catch (error) {
ElMessage.error('获取设备信息失败')
}
}
//
const params = {
"session": 671150784,
"id": 2,
"call": {
"service": "ptz",
"method": "setPTZCmd"
},
}
//
const controlPod = (x,y) => {
const reqParams = {
...params,
"params": {
"channel": 0,
"continuousPanTiltSpace": {
"x": rotateSpeed.value * 15 * x,
"y": rotateSpeed.value * 15 * y
}
}
}
newPodApi(reqParams)
}
//
const isRotating = ref(false)
const podRotation = ()=>{
isRotating.value = !isRotating.value
const reqParams = {
...params,
"params": {
"channel": 0,
"autoPanCtrl": {
"autoPan": isRotating.value ? rotateSpeed.value * 15 : 0
}
}
}
newPodApi(reqParams)
}
//
const focusing = async(val) => {
const reqParams = {
...params,
"params": {
"channel": 0,
"continuousZoomSpace": {
"z" : val * 60
}
}
}
await newPodApi(reqParams)
reqParams.params.continuousZoomSpace.z = 0
newPodApi(reqParams)
}
//
const focus = async(val) => {
const reqParams = {
...params,
"params": {
"channel": 0,
"focusCtrl": {
"focus" : val * 60
}
}
}
await newPodApi(reqParams)
reqParams.params.focusCtrl.focus = 0
newPodApi(reqParams)
}
//
const aperture = async(val) => {
const reqParams = {
...params,
"params": {
"channel": 0,
"irisCtrl": {
"iris" : val * 60
}
}
}
await newPodApi(reqParams)
reqParams.params.irisCtrl.iris = 0
newPodApi(reqParams)
}
//
const wiperCtrl = ref(false)
const wiper = () => {
const reqParams = {
"session": 671150784,
"id": 2,
"call": {
"service": "ptz",
"method": "setWiper"
},
"params": {
"wiper": wiperCtrl.value ? "on" : "off"
}
}
newPodApi(reqParams)
}
//
const initialization = ref(false)
const lensInitialization = async() => {
initialization.value = true
const reqParams = {
"session": 671150784,
"id": 2,
"call": {
"service": "ptz",
"method": "setLensInitialize"
},
"params": {
"channel": 0
}
}
await newPodApi(reqParams)
initialization.value = false
}
//
const goBack = () => {
router.push('/home')
}
const playerRefs = ref([])
const recordingStates = ref({})
const getPlayer = (index) => {
return playerRefs.value[index]
}
// Blob
const downloadBlob = (blob, filename) => {
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a); // Firefox
a.click();
//
setTimeout(() => {
document.body.removeChild(a);
URL.revokeObjectURL(url);
}, 100);
ElMessage.success(`文件 ${filename} 已开始下载`);
};
// 1.
const handleSnapshot = async (index) => {
const player = getPlayer(index)
if (player) {
const blob = await player.takeSnapshot()
if (blob) {
const filename = `snapshot_cam${index}_${new Date().getTime()}.jpg`;
downloadBlob(blob, filename);
// TODO:
// uploadImageToServer(blob, index)
}
}
}
// 2.
const toggleRecord = (index) => {
const player = getPlayer(index)
if (!player) return
if (recordingStates.value[index]) {
player.stopRecord()
} else {
player.startRecord()
}
}
// emit emit
const handleRecordComplete = (blob, index) => {
if (blob) {
const filename = `record_cam${index}_${new Date().getTime()}.webm`;
downloadBlob(blob, filename);
// TODO:
// uploadVideoToServer(blob, index)
}
// UI
recordingStates.value[index] = false;
}
// 3.
const handleRecordStatus = (index, status) => {
recordingStates.value[index] = status
}
//
const uploadImageToServer = async (blob, index) => {
const formData = new FormData()
formData.append('file', blob, `snapshot_${Date.now()}.jpg`)
try {
// API
// await api.uploadSnapshot(formData)
ElMessage.success('截图已保存')
console.log('截图上传成功')
} catch (e) {
ElMessage.error('截图上传失败')
}
}
onMounted(() => {
podInfo()
})
onBeforeUnmount(() => {
})
</script>
<style lang="scss" scoped>
.detail-container {
height: calc(100vh - 100px);
display: flex;
flex-direction: column;
background: #f5f7fa;
overflow: hidden;
}
.detail-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 20px;
background: #fff;
border-bottom: 1px solid #ebeef5;
.header-left {
display: flex;
align-items: center;
gap: 12px;
.pod-name {
font-size: 18px;
font-weight: 600;
color: #303133;
}
}
}
.detail-content {
flex: 1;
display: flex;
gap: 15px;
padding-top: 15px;
overflow: hidden;
height: calc(100vh - 400px);
}
.control-panel {
width: 280px;
background: #fff;
border-radius: 8px;
padding: 16px;
overflow-y: auto;
border: 1px solid #ebeef5;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.08);
&::-webkit-scrollbar {
width: 4px;
}
&::-webkit-scrollbar-thumb {
background: rgba(0, 0, 0, 0.2);
border-radius: 2px;
}
.panel-section {
margin-bottom: 12px;
.pod-img{
text-align: center;
>img{
width: 80%;
}
}
.section-title {
font-size: 14px;
font-weight: 600;
color: #303133;
margin-bottom: 10px;
}
}
.grid_2{
display: flex;
align-items: center;
gap: 15px;
}
.el-divider {
margin: 12px 0;
}
}
.direction-pad {
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
.direction-row {
display: flex;
gap: 4px;
}
.dir-btn {
width: 36px;
height: 36px;
padding: 0;
&:hover {
color: #409EFF;
}
}
}
.rotate-controls {
.el-button {
width: 100%;
margin-bottom: 10px;
}
.slider-item {
display: flex;
align-items: center;
gap: 8px;
.slider-label {
font-size: 12px;
color: #606266;
width: 56px;
}
.el-slider {
flex: 1;
}
.slider-value {
width: 18px;
text-align: center;
font-size: 12px;
color: #409EFF;
font-weight: 600;
}
}
}
.focus-controls,
.aperture-controls {
.control-row {
display: flex;
justify-content: center;
}
.el-button-group {
width: 100%;
.control-label {
flex: 1.2;
cursor: default;
color: #909399;
}
}
}
.video-section {
flex: 1;
display: flex;
flex-direction: column;
min-width: 0;
background: #f5f7fa;
border-radius: 8px;
padding: 4px;
}
.video-grid {
// display: grid;
// grid-template-columns: 1fr 1fr;
display: flex;
gap: 12px;
height: 100%;
}
.video-card {
flex: 1;
display: flex;
flex-direction: column;
background: #fff;
border-radius: 8px;
border: 1px solid #ebeef5;
overflow: hidden;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.08);
.video-card-header {
display: flex;
align-items: center;
gap: 6px;
padding: 8px 12px;
background: #409EFF;
color: #fff;
font-size: 13px;
font-weight: 500;
&.infrared {
background: #E6A23C;
}
&.lowlight {
background: #7b61ff;
}
}
.video-card-body {
flex: 1;
background: #000;
position: relative;
min-height: 0;
.video-player {
width: 100%;
height: 100%;
object-fit: contain;
}
}
.video-card-footer {
display: flex;
justify-content: center;
gap: 8px;
padding: 8px;
background: #fff;
.el-button {
flex: 1;
}
}
}
.expand-video-container {
background: #000;
border-radius: 8px;
overflow: hidden;
.expand-video-player {
width: 100%;
height: 60vh;
object-fit: contain;
}
}
</style>

@ -0,0 +1,676 @@
<template>
<div class="ai-chat-panel">
<div class="panel-title">
<span>视觉大模型对话</span>
<div class="title-actions">
<span title="新建对话" @click="handleNewChat">
<el-icon><Plus /></el-icon>
</span>
<el-dropdown @command="handleHistoryCommand" trigger="click">
<span title="历史记录">
<el-icon><Timer /></el-icon>
</span>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item
v-for="item in historyList"
:key="item.id"
:command="item.conversationId">
<div class="history-item">
<span class="history-title">{{ item.title || '无标题' }}</span>
<el-icon class="delete-icon" @click.stop="handleDeleteChat(item.conversationId)"><Delete /></el-icon>
</div>
</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</div>
</div>
<div class="chat-messages" ref="messagesRef">
<div
v-for="(msg, index) in messages"
:key="index"
class="message-item"
:class="msg.role"
>
<div class="message-avatar">
<el-icon v-if="msg.role === 'ai'"><ChatLineRound /></el-icon>
<el-icon v-else><User /></el-icon>
</div>
<div class="message-content">
<!-- 文件/图片展示 -->
<div class="message-file" v-if="msg.file && msg.file.fileType == '1'">
<el-image
style="max-width: 200px; border-radius: 6px;"
:src="fileHttp + msg.file.url"
:preview-src-list="[fileHttp + msg.file.url]"
fit="cover"
/>
</div>
<div class="message-file" v-else-if="msg.file && msg.file.fileType != '1'">
<video
:src="fileHttp + msg.file.url"
controls
style="max-width: 200px; border-radius: 6px;"
/>
</div>
<div class="message-text" :class="{ typing: isTyping && index === messages.length - 1 && msg.role === 'ai' && !msg.content }">
<template v-if="isTyping && index === messages.length - 1 && msg.role === 'ai' && !msg.content">
<span class="dot"></span>
<span class="dot"></span>
<span class="dot"></span>
</template>
<template v-else>{{ msg.content }}</template>
</div>
</div>
</div>
</div>
<!-- 选中的文件展示 -->
<div class="selected-file" v-if="selectedFile">
<div class="file-preview">
<el-image
v-if="selectedFile.fileType == '1'"
style="width: 100%; height: 100%"
:src="fileHttp + selectedFile.url"
fit="cover"
/>
<video v-else :src="fileHttp + selectedFile.url" />
</div>
<div class="file-info">
<el-tag size="small" :type="selectedFile.fileType == '1' ? undefined : 'warning'">
{{ selectedFile.fileType == '1' ? '图片' : '视频' }}
</el-tag>
<span class="file-name" :title="selectedFile.name">{{ selectedFile.name }}</span>
</div>
<el-icon class="close-icon" @click="clearSelectedFile"><Close /></el-icon>
</div>
<div class="chat-input">
<el-input
v-model="inputText"
type="textarea"
:autosize="{ minRows: 1, maxRows: 3 }"
placeholder="请输入您的问题...(Shift + Enter 换行Enter 发送)"
resize="none"
@keydown.enter.exact.prevent="handleSend"
/>
<div class="upload-popover">
<el-popover :visible="popVisible" placement="top" :width="570" trigger="click">
<template #reference>
<el-icon @click="popVisible = true"><Upload /></el-icon>
</template>
<el-cascader-panel v-model="datasetValue" :options="dataSetOptions" :props="{value:'id',label:'name'}" clearable filterable />
<div style="text-align: right; margin-top:5px;">
<el-button size="small" type="danger" text @click="selectClear"></el-button>
<el-button size="small" type="primary" text style="margin: 0;" @click="handleDatasetConfirm"></el-button>
</div>
</el-popover>
</div>
<el-button type="primary" size="small" class="send-btn" :loading="isTyping" @click="handleSend">
发送
</el-button>
</div>
<!-- 文件选择弹窗 -->
<FileSelectDialog
v-model="fileSelectVisible"
:project-id="datasetValue[datasetValue.length - 1]"
@confirm="handleFileSelect"
/>
</div>
</template>
<script setup>
import { ref, nextTick, onMounted, onUnmounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { useUserStore } from '@/stores/user'
import { getAIChatHistory,getChatContent,deleteAIChat } from '@/api/face-recognition'
import { getTreeList } from '@/api/dataset'
import FileSelectDialog from './FileSelectDialog.vue'
import fileHttp from '@/utils/fileHttp'
const messagesRef = ref(null)
const inputText = ref('')
const datasetValue = ref([])
const isTyping = ref(false)
const aiDefaultReply = ref('你好,我是专业的视觉设备智能助手,有什么可以帮到您?')
const messages = ref([
{
role: 'ai',
content: aiDefaultReply.value
}
])
// IDSSE
const conversationId = ref('')
//
const historyList = ref([])
const AIChatHistory = async () => {
const response = await getAIChatHistory({
pageNum: 1,
pageSize: 999
})
if (response.code === 200) {
historyList.value = response.data.list
}
}
//
const handleDeleteChat = async (id) => {
try {
await ElMessageBox.confirm('确定要删除该会话吗?', '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
})
const response = await deleteAIChat(id)
if (response.code === 200) {
ElMessage.success('删除成功')
//
if (conversationId.value === id) {
conversationId.value = ''
messages.value = [{
role: 'ai',
content: aiDefaultReply.value
}]
}
AIChatHistory()
} else {
ElMessage.error(response.message || '删除失败')
}
} catch (error) {
if (error !== 'cancel') {
ElMessage.error('删除失败')
}
}
}
//
const handleNewChat = () => {
conversationId.value = ''
messages.value = [{
role: 'ai',
content: aiDefaultReply.value
}]
}
//
const handleHistoryCommand = async (id) => {
const response = await getChatContent({
conversationId: id
})
if (response.code === 200) {
conversationId.value = id
// role content extra.urls
messages.value = response.data.map(item => {
const role = item.role === 'assistant' ? 'ai' : item.role
const content = item.content || item.message || ''
let file = null
// extra urls
if (item.extra && item.extra.urls && item.extra.urls.length > 0) {
const url = item.extra.urls[0]
//
const isImage = /\.(jpg|jpeg|png|gif|bmp|webp)$/i.test(url)
file = {
url: url,
name: url.split('/').pop() || '文件',
fileType: isImage ? '1' : '0'
}
}
return { role, content, file }
})
}
}
// SSE
let abortController = null
//
const handleSend = async () => {
const text = inputText.value.trim()
if (!text) return
if (isTyping.value) return
//
const fileToSend = selectedFile.value
messages.value.push({ role: 'user', content: text, file: fileToSend })
inputText.value = ''
selectedFile.value = null
scrollToBottom()
// SSE
isTyping.value = true
//
const isFirstSend = !conversationId.value
// AI
const aiMessageIndex = messages.value.length
messages.value.push({ role: 'ai', content: '' })
scrollToBottom()
try {
// AbortController
abortController = new AbortController()
const userStore = useUserStore()
//
const requestData = {
platform: 'Dify',
type: 'rag',
content: text
}
// ID
if (conversationId.value) {
requestData.conversationId = conversationId.value
}
//
if (fileToSend) {
requestData.files = [ fileToSend.url ]
}
const response = await fetch(`${import.meta.env.VITE_API_BASE_URL}/aiConversation/completion`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${userStore.token || ''}`
},
body: JSON.stringify(requestData),
signal: abortController.signal
})
if (!response.ok) {
throw new Error(`请求失败: ${response.status}`)
}
// reader
const reader = response.body.getReader()
const decoder = new TextDecoder()
let fullContent = ''
//
while (true) {
const { done, value } = await reader.read()
if (done) break
const chunk = decoder.decode(value, { stream: true })
// SSE: data: {...}\n\n
const lines = chunk.split('\n')
for (const line of lines) {
if (line.startsWith('data:')) {
const dataStr = line.slice(5).trim()
if (!dataStr) continue
// `data: true`
if (dataStr === 'true') {
isTyping.value = false
//
if (isFirstSend) {
AIChatHistory()
}
continue
}
try {
const data = JSON.parse(dataStr)
// conversationId
if (data.conversationId && !conversationId.value) {
conversationId.value = data.conversationId
}
//
if (data.message && data.message.answer) {
fullContent += data.message.answer
messages.value[aiMessageIndex].content = fullContent
scrollToBottom()
}
} catch (e) {
// JSON
console.warn('SSE数据解析失败:', e)
}
}
}
}
} catch (error) {
if (error.name === 'AbortError') {
console.log('请求已取消')
} else {
console.error('SSE请求失败:', error)
ElMessage.error('AI回复失败请重试')
// AI
messages.value.splice(aiMessageIndex, 1)
}
isTyping.value = false
} finally {
abortController = null
}
}
// SSE
const cancelRequest = () => {
if (abortController) {
abortController.abort()
isTyping.value = false
}
}
//
const dataSetOptions = ref([])
const popVisible = ref(false)
const fetchTreeData = async () => {
try {
const res = await getTreeList()
dataSetOptions.value = res.data?.list || []
console.log(dataSetOptions.value)
} catch (error) {
ElMessage.error('获取数据列表失败')
}
}
//
const selectClear = () => {
datasetValue.value = []
}
//
const fileSelectVisible = ref(false)
const selectedFile = ref(null)
//
const handleDatasetConfirm = () => {
if (!datasetValue.value || datasetValue.value.length === 0) {
ElMessage.warning('请先选择数据集')
return
}
popVisible.value = false
fileSelectVisible.value = true
}
//
const handleFileSelect = (file) => {
selectedFile.value = file
ElMessage.success(`已选择文件: ${file.name}`)
}
//
const clearSelectedFile = () => {
selectedFile.value = null
}
//
const scrollToBottom = () => {
nextTick(() => {
if (messagesRef.value) {
messagesRef.value.scrollTop = messagesRef.value.scrollHeight
}
})
}
onMounted(() => {
fetchTreeData()
AIChatHistory()
scrollToBottom()
})
onUnmounted(() => {
//
cancelRequest()
})
</script>
<style lang="scss" scoped>
.ai-chat-panel {
display: flex;
flex-direction: column;
height: calc(100% - 230px);
background: #fff;
border-radius: 6px;
padding: 15px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
.panel-title {
display: flex;
align-items: center;
justify-content: space-between;
gap: 6px;
font-size: 15px;
font-weight: 600;
color: #303133;
margin-bottom: 25px;
.title-actions {
display: flex;
align-items: center;
gap: 10px;
.el-icon {
cursor: pointer;
color: #909399;
transition: color 0.3s;
&:hover {
color: #409EFF;
}
}
}
.el-icon {
color: #409EFF;
font-size: 16px;
}
}
.chat-messages {
flex: 1;
overflow-y: auto;
margin-bottom: 10px;
min-height: 0;
.message-item {
display: flex;
gap: 8px;
margin-bottom: 12px;
&.ai {
.message-avatar {
background: linear-gradient(135deg, #409EFF, #67C23A);
}
}
&.user {
flex-direction: row-reverse;
.message-avatar {
background: #909399;
}
.message-content {
align-items: flex-end;
.message-text {
background: #409EFF;
color: #fff;
}
}
}
.message-avatar {
width: 28px;
height: 28px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
color: #fff;
font-size: 14px;
flex-shrink: 0;
}
.message-content {
display: flex;
flex-direction: column;
max-width: 80%;
.message-text {
font-size: 12px;
color: #606266;
line-height: 1.6;
background: #f5f7fa;
padding: 8px 12px;
border-radius: 8px;
word-break: break-word;
white-space: pre-wrap;
&.typing {
padding: 10px 16px;
.dot {
display: inline-block;
width: 6px;
height: 6px;
background: #909399;
border-radius: 50%;
margin: 0 2px;
animation: typing 1.4s infinite;
&:nth-child(2) {
animation-delay: 0.2s;
}
&:nth-child(3) {
animation-delay: 0.4s;
}
}
}
}
}
}
}
.selected-file {
display: flex;
align-items: center;
gap: 10px;
padding: 8px 12px;
background: #f5f7fa;
border-radius: 6px;
margin-bottom: 10px;
.file-preview {
width: 60px;
height: 60px;
border-radius: 4px;
overflow: hidden;
background: #e6e6e6;
flex-shrink: 0;
.el-image, video {
width: 100%;
height: 100%;
object-fit: cover;
}
}
.file-info {
flex: 1;
display: flex;
flex-direction: column;
gap: 4px;
min-width: 0;
.file-name {
font-size: 12px;
color: #606266;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
.close-icon {
cursor: pointer;
color: #909399;
font-size: 16px;
flex-shrink: 0;
transition: color 0.3s;
&:hover {
color: #f56c6c;
}
}
}
.chat-input {
position: relative;
flex-shrink: 0;
:deep(.el-textarea__inner) {
border-radius: 6px;
padding-bottom: 30px;
font-size: 12px;
}
.upload-popover{
position: absolute;
bottom: 6px;
right: 65px;
cursor: pointer;
}
.send-btn {
position: absolute;
bottom: 6px;
right: 6px;
}
}
}
@keyframes typing {
0%, 60%, 100% {
transform: translateY(0);
opacity: 0.4;
}
30% {
transform: translateY(-4px);
opacity: 1;
}
}
</style>
<style lang="scss">
.history-item {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
max-width: 200px;
.history-title {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.delete-icon {
margin-left: 8px;
color: #909399;
font-size: 14px;
opacity: 0;
transition: opacity 0.3s;
cursor: pointer;
}
&:hover .delete-icon {
opacity: 1;
}
.delete-icon:hover {
color: #f56c6c;
}
}
</style>

@ -0,0 +1,324 @@
<template>
<div class="camera-grid">
<div
v-for="camera in cameras"
:key="camera.id"
class="camera-card"
>
<div class="camera-header">
<span class="camera-title">{{ `${camera.groupName}-${camera.defaultVideoName}` }}</span>
<!-- <el-button type="primary" link size="small">
<el-icon><VideoCamera /></el-icon>
摄像头列表
</el-button> -->
<el-dropdown @command="(item) => handleCommand(camera.id, item)">
<el-button type="primary" link size="small">
<el-icon><VideoCamera /></el-icon>
摄像头列表
<el-icon class="el-icon--right">
<arrow-down />
</el-icon>
</el-button>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item
v-for="item in camera.cameras"
:key="item.id"
:command="item"
>{{ item.cameraLocation }}</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</div>
<div class="camera-video" ref="videoContainers">
<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-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>
</template>
<script setup>
import { ref, nextTick, 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 () => {
try {
const res = await getPodList()
cameras.value = res.data || []
cameras.value.forEach(camera => {
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)
}
}
// 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)
}
}
//
defineExpose({
startFaceDetection,
stopFaceDetection
})
const getBoxStyle = (info) => ({
left: info.left,
top: info.top,
width: info.width,
height: info.height
})
const handleCommand = (cameraId, item) => {
const camera = cameras.value.find(c => c.id === cameraId)
if (camera) {
camera.defaultVideo = item.videoStreaming
camera.defaultVideoName = item.cameraLocation
}
}
onMounted(() => {
fetchPodList()
// resizecanvas
window.addEventListener('resize', initCanvas)
})
onUnmounted(() => {
stopFaceDetection()
window.removeEventListener('resize', initCanvas)
})
</script>
<style lang="scss" scoped>
.camera-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 10px;
flex: 1;
.camera-card {
display: flex;
flex-direction: column;
background: #fff;
border-radius: 6px;
padding: 10px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
.camera-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 6px;
.camera-title {
font-size: 14px;
font-weight: 600;
color: #303133;
}
.el-button {
padding: 2px 4px;
font-size: 14px;
.el-icon {
margin-right: 2px;
}
}
}
.camera-video {
flex: 1;
position: relative;
width: 100%;
aspect-ratio: 16/9;
border-radius: 4px;
overflow: hidden;
background: #000;
img {
width: 100%;
height: 100%;
object-fit: cover;
}
.face-canvas {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
pointer-events: none;
}
}
}
}
</style>

@ -0,0 +1,313 @@
<template>
<el-dialog
v-model="visible"
title="选择文件"
width="900px"
:close-on-click-modal="false"
@close="handleClose"
>
<div class="file-select-dialog">
<div class="filter-bar">
<el-input
v-model="filterParams.name"
placeholder="文件名"
clearable
style="width: 200px"
@keyup.enter="handleSearch"
/>
<!-- <el-select
v-model="filterParams.fileType"
placeholder="文件类型"
clearable
style="width: 140px"
>
<el-option label="图片" :value="1" />
<el-option label="视频" :value="0" />
</el-select> -->
<el-button type="primary" @click="handleSearch">
<el-icon><Search /></el-icon>
搜索
</el-button>
<el-button @click="handleReset">
<el-icon><Refresh /></el-icon>
重置
</el-button>
</div>
<div class="file-list-container">
<el-row :gutter="12" v-if="fileList.length > 0">
<el-col :span="6" v-for="item in fileList" :key="item.id">
<div
class="file-item"
:class="{ 'is-selected': selectedId === item.id }"
@click="handleSelect(item)"
>
<div class="item-preview">
<el-image
v-if="item.fileType == '1'"
style="width: 100%; height: 100%"
:src="fileHttp + item.url"
:preview-src-list="[fileHttp + item.url]"
show-progress
fit="cover"
/>
<video v-else :src="fileHttp + item.url" controls />
<div class="item-type">
<el-tag size="small" :type="item.fileType == '1' ? undefined : 'warning'">
{{ item.fileType == '1' ? '图片' : '视频' }}
</el-tag>
</div>
</div>
<div class="item-info">
<span class="item-name" :title="item.name">{{ item.name }}</span>
<el-icon v-if="selectedId === item.id" class="check-icon"><Check /></el-icon>
</div>
</div>
</el-col>
</el-row>
<el-empty v-if="fileList.length === 0 && !loading" description="暂无数据" />
</div>
<div class="pagination-wrapper" v-if="total > 0">
<el-pagination
v-model:current-page="pagination.pageNum"
v-model:page-size="pagination.pageSize"
:total="total"
:page-sizes="[8, 16, 24, 32]"
layout="total, sizes, prev, pager, next"
@size-change="handleSizeChange"
@current-change="handleCurrentChange"
/>
</div>
</div>
<template #footer>
<span class="dialog-footer">
<el-button @click="handleClose"></el-button>
<el-button type="primary" :disabled="!selectedItem" @click="handleConfirm"></el-button>
</span>
</template>
</el-dialog>
</template>
<script setup>
import { ref, reactive, watch } from 'vue'
import { ElMessage } from 'element-plus'
import { getDataList } from '@/api/dataset'
import fileHttp from '@/utils/fileHttp'
const props = defineProps({
modelValue: {
type: Boolean,
default: false
},
projectId: {
type: [String, Number],
default: ''
}
})
const emit = defineEmits(['update:modelValue', 'confirm'])
const visible = ref(false)
const loading = ref(false)
const fileList = ref([])
const total = ref(0)
const selectedId = ref(null)
const selectedItem = ref(null)
const pagination = reactive({
pageNum: 1,
pageSize: 8
})
const filterParams = reactive({
name: '',
fileType: ''
})
watch(() => props.modelValue, (val) => {
visible.value = val
if (val) {
//
selectedId.value = null
selectedItem.value = null
pagination.pageNum = 1
fetchData()
}
}, { immediate: true })
watch(visible, (val) => {
emit('update:modelValue', val)
})
watch(() => props.projectId, () => {
if (visible.value) {
pagination.pageNum = 1
fetchData()
}
})
const fetchData = async () => {
if (!props.projectId) {
fileList.value = []
total.value = 0
return
}
loading.value = true
try {
const params = {
projectId: props.projectId,
name: filterParams.name,
fileType: filterParams.fileType,
pageNum: pagination.pageNum,
pageSize: pagination.pageSize
}
const res = await getDataList(params)
fileList.value = res.data?.list || []
total.value = res.data?.total || 0
} catch (error) {
ElMessage.error('获取文件列表失败')
} finally {
loading.value = false
}
}
const handleSearch = () => {
pagination.pageNum = 1
fetchData()
}
const handleReset = () => {
filterParams.name = ''
filterParams.fileType = ''
pagination.pageNum = 1
fetchData()
}
const handleSizeChange = () => {
fetchData()
}
const handleCurrentChange = () => {
fetchData()
}
const handleSelect = (item) => {
selectedId.value = item.id
selectedItem.value = item
}
const handleConfirm = () => {
if (!selectedItem.value) {
ElMessage.warning('请先选择一个文件')
return
}
emit('confirm', selectedItem.value)
handleClose()
}
const handleClose = () => {
visible.value = false
}
</script>
<style lang="scss" scoped>
.file-select-dialog {
.filter-bar {
display: flex;
gap: 12px;
margin-bottom: 12px;
flex-wrap: wrap;
}
.file-list-container {
max-height: 400px;
overflow-y: auto;
overflow-x: hidden;
:deep(.el-row) {
display: flex;
flex-wrap: wrap;
box-sizing: border-box;
.el-col {
padding: 6px;
}
}
}
.file-item {
background: #fff;
border-radius: 6px;
border: 2px solid #ebeef5;
overflow: hidden;
transition: all 0.3s;
display: flex;
flex-direction: column;
height: 100%;
margin-bottom: 12px;
cursor: pointer;
&:hover {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
border-color: #409eff;
}
&.is-selected {
border-color: #409eff;
box-shadow: 0 0 0 2px rgba(64, 158, 255, 0.2);
}
.item-preview {
position: relative;
width: 100%;
height: 120px;
background: #f5f7fa;
overflow: hidden;
video {
width: 100%;
height: 100%;
object-fit: cover;
}
.item-type {
position: absolute;
top: 6px;
right: 6px;
}
}
.item-info {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 10px;
gap: 6px;
.item-name {
flex: 1;
font-size: 12px;
color: #606266;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.check-icon {
color: #409eff;
font-weight: bold;
flex-shrink: 0;
}
}
}
.pagination-wrapper {
margin-top: 12px;
display: flex;
justify-content: flex-end;
}
}
</style>

@ -0,0 +1,149 @@
<template>
<div class="quick-actions">
<div class="panel-title">快捷功能</div>
<div class="action-list">
<div
v-for="item in actions"
:key="item.key"
class="action-item"
:class="{ active: item.isActive }"
@click="handleAction(item)"
>
<div class="action-icon" :style="{ background: item.bgColor, color: item.color }">
<el-icon><component :is="item.icon" /></el-icon>
</div>
<span class="action-label">{{ item.label }}</span>
</div>
</div>
</div>
</template>
<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')
//
const isFaceRecognitionOn = ref(false)
const actions = computed(() => [
{
key: 'face',
label: isFaceRecognitionOn.value ? '关闭人脸识别' : '开启人脸识别',
icon: 'UserFilled',
bgColor: '#ecf5ff',
color: '#409EFF',
isActive: isFaceRecognitionOn.value
},
{ key: 'count', label: '开启人数统计', icon: 'Histogram', bgColor: '#f0f9eb', color: '#67C23A', isActive: false },
{ key: 'track', label: '开启追踪模式', icon: 'Position', bgColor: '#fdf6ec', color: '#E6A23C', isActive: false }
])
const handleAction = (item) => {
switch(item.key) {
case 'face': toggleFaceRecognition(); break;
case 'count': handleStartFaceCount(); break;
case 'track': handleStartFaceTrack(); break;
}
}
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 handleStartFaceCount = async () => {
console.log('开启人数统计:')
}
const handleStartFaceTrack = async () => {
console.log('开启追踪模式:')
}
</script>
<style lang="scss" scoped>
.quick-actions {
background: #fff;
border-radius: 6px;
padding: 15px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
.panel-title {
font-size: 15px;
font-weight: 600;
color: #303133;
margin-bottom: 8px;
}
.action-list {
display: flex;
flex-direction: column;
gap: 6px;
}
.action-item {
display: flex;
align-items: center;
gap: 10px;
padding: 8px 10px;
border-radius: 6px;
cursor: pointer;
transition: all 0.3s;
border: 1px solid transparent;
&:hover {
background: #f5f7fa;
}
&.active {
background: #ecf5ff;
border-color: #409EFF;
.action-label {
color: #409EFF;
font-weight: 500;
}
}
.action-icon {
width: 32px;
height: 32px;
border-radius: 6px;
display: flex;
align-items: center;
justify-content: center;
font-size: 16px;
}
.action-label {
font-size: 12px;
color: #606266;
}
}
}
</style>

@ -0,0 +1,91 @@
<template>
<div class="real-time-stats">
<div class="stats-title">
<el-icon><DataLine /></el-icon>
<span>实时统计</span>
</div>
<div class="stats-grid">
<div v-for="item in stats" :key="item.key" class="stat-item">
<el-icon class="stat-icon" :style="{ color: item.color }"><component :is="item.icon" /></el-icon>
<div class="stat-info">
<div class="stat-label">{{ item.label }}</div>
<div class="stat-value" :style="{ color: item.color }">{{ item.value }}</div>
<div class="stat-daily">今日: {{ item.daily }}</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { DataLine, User, View, UserFilled, Bell } from '@element-plus/icons-vue'
const stats = [
{ key: 'total', label: '园区总人数', value: 256, daily: '3,256', icon: 'User', color: '#409EFF' },
{ key: 'recognized', label: '识别人数', value: 128, daily: '1,024', icon: 'View', color: '#67C23A' },
{ key: 'stranger', label: '陌生人数量', value: 23, daily: '256', icon: 'UserFilled', color: '#E6A23C' },
{ key: 'alert', label: '布控告警', value: 5, daily: '32', icon: 'Bell', color: '#F56C6C' }
]
</script>
<style lang="scss" scoped>
.real-time-stats {
background: #fff;
border-radius: 6px;
padding: 10px 12px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
.stats-title {
display: flex;
align-items: center;
gap: 6px;
font-size: 14px;
font-weight: 600;
color: #303133;
margin-bottom: 10px;
.el-icon {
color: #409EFF;
font-size: 16px;
}
}
.stats-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 10px;
.stat-item {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 10px;
background: #f5f7fa;
border-radius: 6px;
.stat-icon {
font-size: 22px;
}
.stat-info {
.stat-label {
font-size: 12px;
color: #909399;
margin-bottom: 2px;
}
.stat-value {
font-size: 18px;
font-weight: 700;
margin-bottom: 1px;
}
.stat-daily {
font-size: 12px;
color: #c0c4cc;
}
}
}
}
}
</style>

@ -0,0 +1,299 @@
<template>
<div class="recognition-records">
<div class="records-title">
<el-icon><WarningFilled /></el-icon>
<span>非配合目标识别记录</span>
</div>
<!-- 当前目标 -->
<div class="current-target">
<div v-if="currentTarget" class="target-card">
<img class="target-avatar" :src="currentTarget.avatar" 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>
</div>
<div v-else class="target-detail">
<div class="stranger-tip">未匹配到内部人员</div>
<el-tag size="small" type="danger">相似度 {{ currentTarget.similarity }}</el-tag>
<div class="alert-tip">
<el-icon><WarningFilled /></el-icon>
<span>请注意陌生人员出入!</span>
</div>
</div>
</div>
</div>
</div>
<!-- 记录列表 -->
<div class="record-list">
<div
v-for="record in records"
:key="record.id"
class="record-item"
:class="{ active: currentTarget?.id === record.id }"
@click="selectTarget(record)"
>
<img class="record-avatar" :src="record.avatar" alt="avatar" />
<div class="record-info">
<div class="record-time">{{ record.time }}</div>
<div class="record-location">{{ record.location }}</div>
<div class="record-name">{{ record.name }}</div>
<div class="record-similarity">相似度 {{ record.similarity }}</div>
</div>
<el-tag
size="small"
:type="record.type === 'internal' ? 'success' : 'danger'"
class="record-tag"
>
{{ record.type === 'internal' ? '内部人员' : '陌生人员' }}
</el-tag>
</div>
</div>
<el-button class="more-btn" type="primary" plain @click="viewMore">
查看更多记录
</el-button>
</div>
</template>
<script setup>
import { ref } from 'vue'
import { WarningFilled } from '@element-plus/icons-vue'
import { ElMessage } from 'element-plus'
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'
})
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 selectTarget = (record) => {
currentTarget.value = record
}
const viewMore = () => {
ElMessage.info('更多记录功能开发中')
}
</script>
<style lang="scss" scoped>
.recognition-records {
background: #fff;
border-radius: 6px;
padding: 12px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
height: 100%;
display: flex;
flex-direction: column;
.records-title {
display: flex;
align-items: center;
gap: 6px;
font-size: 14px;
font-weight: 600;
color: #303133;
margin-bottom: 10px;
.el-icon {
color: #409EFF;
font-size: 16px;
}
}
.current-target {
margin-bottom: 10px;
.target-card {
display: flex;
gap: 10px;
padding: 10px;
background: #f5f7fa;
border-radius: 6px;
.target-avatar {
width: 50px;
height: 50px;
border-radius: 4px;
object-fit: cover;
flex-shrink: 0;
}
.target-info {
flex: 1;
min-width: 0;
.target-status {
font-size: 12px;
font-weight: 500;
margin-bottom: 2px;
&.internal {
color: #67C23A;
}
&.stranger {
color: #F56C6C;
}
}
.target-name {
font-size: 14px;
font-weight: 600;
color: #303133;
margin-bottom: 4px;
}
.target-detail {
.el-tag {
margin-bottom: 2px;
}
.target-dept,
.target-id {
font-size: 12px;
color: #606266;
line-height: 1.4;
}
.stranger-tip {
font-size: 12px;
color: #909399;
margin-bottom: 2px;
}
.alert-tip {
display: flex;
align-items: center;
gap: 4px;
margin-top: 4px;
font-size: 12px;
color: #F56C6C;
.el-icon {
font-size: 12px;
}
}
}
}
}
}
.record-list {
flex: 1;
overflow-y: auto;
margin-bottom: 8px;
.record-item {
display: flex;
align-items: center;
gap: 8px;
padding: 8px;
border-radius: 4px;
cursor: pointer;
transition: all 0.3s;
margin-bottom: 6px;
&:hover,
&.active {
background: #ecf5ff;
}
.record-avatar {
width: 36px;
height: 36px;
border-radius: 3px;
object-fit: cover;
flex-shrink: 0;
}
.record-info {
flex: 1;
min-width: 0;
.record-time {
font-size: 12px;
color: #909399;
}
.record-location {
font-size: 12px;
color: #606266;
}
.record-name {
font-size: 12px;
color: #303133;
font-weight: 500;
}
.record-similarity {
font-size: 12px;
color: #409EFF;
}
}
.record-tag {
flex-shrink: 0;
font-size: 10px;
padding: 0 4px;
}
}
}
.more-btn {
width: 100%;
padding: 8px;
}
}
</style>

@ -0,0 +1,125 @@
<template>
<div class="face-recognition-container">
<div class="main-content">
<!-- 左侧边栏 -->
<div class="left-sidebar">
<AiChatPanel />
<QuickActions />
</div>
<!-- 中间视频区域 -->
<div class="center-area">
<CameraGrid ref="cameraGridRef" />
<RealTimeStats />
</div>
<!-- 右侧记录面板 -->
<div class="right-sidebar" :class="{ collapsed: isRightCollapsed }">
<div class="collapse-btn" @click="toggleRight">
<el-icon :size="18">
<DArrowLeft v-if="isRightCollapsed" />
<DArrowRight v-else />
</el-icon>
</div>
<RecognitionRecords v-show="!isRightCollapsed" />
</div>
</div>
</div>
</template>
<script setup>
import { ref, provide } from 'vue'
import { DArrowLeft, DArrowRight } from '@element-plus/icons-vue'
import AiChatPanel from './components/AiChatPanel.vue'
import QuickActions from './components/QuickActions.vue'
import CameraGrid from './components/CameraGrid.vue'
import RealTimeStats from './components/RealTimeStats.vue'
import RecognitionRecords from './components/RecognitionRecords.vue'
const isRightCollapsed = ref(false)
const cameraGridRef = ref(null)
//
const toggleFaceDetection = (isOn) => {
if (cameraGridRef.value) {
if (isOn) {
cameraGridRef.value.startFaceDetection()
} else {
cameraGridRef.value.stopFaceDetection()
}
}
}
// 使
provide('toggleFaceDetection', toggleFaceDetection)
const toggleRight = () => {
isRightCollapsed.value = !isRightCollapsed.value
}
</script>
<style lang="scss" scoped>
.face-recognition-container {
height: 100%;
.main-content {
display: flex;
gap: 12px;
height: 100%;
.left-sidebar {
flex: 1;
min-width: 300px;
max-width: 400px;
display: flex;
flex-direction: column;
gap: 12px;
transition: max-width 0.3s ease;
height: 100%;
}
.center-area {
flex: 1;
display: flex;
flex-direction: column;
gap: 12px;
min-width: 0;
}
.right-sidebar {
position: relative;
width: 280px;
flex-shrink: 0;
transition: width 0.3s ease;
&.collapsed {
width: 30px;
}
.collapse-btn {
position: absolute;
right: 10px;
top: 20px;
transform: translateY(-50%);
width: 30px;
height: 30px;
// background: #fff;
// border-radius: 4px;
// box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
z-index: 10;
color: #606266;
transition: all 0.3s;
&:hover {
color: #409EFF;
box-shadow: 0 2px 12px rgba(64, 158, 255, 0.3);
}
}
}
}
}
</style>

@ -0,0 +1,292 @@
<template>
<el-dialog
v-model="visible"
:title="isEdit ? '编辑设备' : '添加设备'"
width="700px"
:close-on-click-modal="false"
@closed="handleClosed"
>
<el-form
ref="formRef"
:model="formData"
:rules="rules"
label-width="100px"
>
<el-form-item label="设备名称" prop="groupName">
<el-input v-model="formData.groupName" placeholder="请输入设备名称" />
</el-form-item>
<el-form-item label="设备类型" prop="groupType">
<el-select v-model="formData.groupType" placeholder="请选择设备类型" style="width: 100%">
<el-option label="吊舱" :value="0" />
<el-option label="无人机" :value="1" />
<el-option label="CCTV摄像头" :value="2" />
<el-option label="机器人" :value="3" />
</el-select>
</el-form-item>
<el-form-item label="设备图片" prop="imgFile">
<el-upload
class="image-uploader"
accept="image/*"
:show-file-list="false"
:before-upload="beforeImageUpload"
:http-request="handleImageUpload"
>
<img v-if="imageUrl" :src="imageUrl" class="uploaded-image" />
<el-icon v-else class="upload-icon"><Plus /></el-icon>
</el-upload>
</el-form-item>
<el-form-item label="设备状态" prop="groupStatus">
<el-switch
v-model="formData.groupStatus"
:active-value="1"
:inactive-value="0"
active-text="在线"
inactive-text="离线"
/>
</el-form-item>
<el-form-item label="摄像头" prop="cameras">
<div class="camera-table">
<el-table :data="formData.cameras" border size="small">
<el-table-column label="摄像头名称" min-width="120">
<template #default="{ row }">
<el-input v-model="row.cameraLocation" placeholder="请输入" size="small" />
</template>
</el-table-column>
<el-table-column label="视频流地址" min-width="180">
<template #default="{ row }">
<el-input v-model="row.videoStreaming" placeholder="请输入" size="small" />
</template>
</el-table-column>
<el-table-column label="视频流类型" width="120">
<template #default="{ row }">
<el-select v-model="row.cameraProtocol" placeholder="请选择" size="small" style="width: 100%">
<el-option label="rtsp" value="rtsp" />
<el-option label="rtmp" value="rtmp" />
<el-option label="usb" value="usb" />
<el-option label="webrtc" value="webrtc" />
</el-select>
</template>
</el-table-column>
<el-table-column label="操作" width="80" align="center">
<template #default="{ $index }">
<el-button type="danger" link size="small" @click="removeCamera($index)">
<el-icon><Delete /></el-icon>
</el-button>
</template>
</el-table-column>
</el-table>
<el-button type="primary" link size="small" class="add-camera-btn" @click="addCamera">
<el-icon><Plus /></el-icon>
</el-button>
</div>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="visible = false">取消</el-button>
<el-button type="primary" :loading="loading" @click="handleSubmit"></el-button>
</template>
</el-dialog>
</template>
<script setup>
import { ref, computed, watch } from 'vue'
import { ElMessage } from 'element-plus'
import { addPod, editPod } from '@/api/home'
import fileHttp from '@/utils/fileHttp'
const props = defineProps({
modelValue: Boolean,
isEdit: Boolean,
editData: Object
})
const emit = defineEmits(['update:modelValue', 'success', 'cancel'])
const formRef = ref(null)
const loading = ref(false)
const visible = computed({
get: () => props.modelValue,
set: (val) => emit('update:modelValue', val)
})
const defaultCamera = () => ({
cameraLocation: '',
videoStreaming: '',
cameraProtocol: ''
})
const formData = ref({
groupId: '',
groupName: '',
groupType: '',
imgFile: null,
groupStatus: 1,
cameras: [{ ...defaultCamera() }]
})
//
watch(
() => props.modelValue,
(val) => {
if (val && props.isEdit && props.editData) {
formData.value = {
groupId: props.editData.groupId || '',
groupName: props.editData.groupName || '',
groupType: props.editData.groupType ?? '',
imgFile: null,
groupStatus: props.editData.groupStatus ?? 1,
cameras: props.editData.cameras?.length ? JSON.parse(JSON.stringify(props.editData.cameras)) : [{ ...defaultCamera() }]
}
imageUrl.value = fileHttp + (props.editData.imgUrl || '')
imageFile.value = null
}
}
)
const imageUrl = ref('')
const imageFile = ref(null)
const rules = {
groupName: [{ required: true, message: '请输入设备名称', trigger: 'blur' }],
groupType: [{ required: true, message: '请选择设备类型', trigger: 'change' }]
}
const addCamera = () => {
formData.value.cameras.push({ ...defaultCamera() })
}
const removeCamera = (index) => {
if (formData.value.cameras.length > 1) {
formData.value.cameras.splice(index, 1)
} else {
ElMessage.warning('至少保留一条摄像头数据')
}
}
const beforeImageUpload = (file) => {
const isImage = ['image/jpeg', 'image/png', 'image/gif', 'image/webp'].includes(file.type)
const isLt5M = file.size / 1024 / 1024 < 5
if (!isImage) {
ElMessage.error('只能上传图片文件')
return false
}
if (!isLt5M) {
ElMessage.error('图片大小不能超过 5MB')
return false
}
return true
}
const handleImageUpload = (options) => {
const { file } = options
imageFile.value = file
formData.value.imgFile = file
imageUrl.value = URL.createObjectURL(file)
}
const handleSubmit = async () => {
const valid = await formRef.value.validate().catch(() => false)
if (!valid) return
//
const hasCompleteCamera = formData.value.cameras.some(
cam => cam.cameraLocation && cam.videoStreaming && cam.cameraProtocol
)
if (!hasCompleteCamera) {
ElMessage.error('请至少添加一条完整的摄像头数据(名称、地址、类型都不能为空)')
return
}
loading.value = true
try {
const formDataObj = new FormData()
formDataObj.append('groupName', formData.value.groupName)
formDataObj.append('groupType', formData.value.groupType)
formDataObj.append('groupStatus', formData.value.groupStatus)
if (formData.value.groupId) {
formDataObj.append('groupId', formData.value.groupId)
}
if (formData.value.imgFile) {
formDataObj.append('imgFile', formData.value.imgFile)
}
const cameras = formData.value.cameras.filter(cam => cam.cameraLocation && cam.videoStreaming && cam.cameraProtocol)
formDataObj.append('cameraListJson', JSON.stringify(cameras))
if (props.isEdit) {
await editPod(formDataObj)
ElMessage.success('编辑成功')
} else {
await addPod(formDataObj)
ElMessage.success('添加成功')
}
emit('success')
visible.value = false
} catch (error) {
ElMessage.error(props.isEdit ? '编辑失败' : '添加失败')
} finally {
loading.value = false
}
}
const handleClosed = () => {
formRef.value?.resetFields()
formData.value = {
groupId: '',
groupName: '',
groupType: '',
imgFile: null,
groupStatus: 1,
cameras: [{ ...defaultCamera() }]
}
imageUrl.value = ''
imageFile.value = null
//
emit('cancel')
}
</script>
<style lang="scss" scoped>
.image-uploader {
:deep(.el-upload) {
border: 1px dashed #d9d9d9;
border-radius: 8px;
cursor: pointer;
position: relative;
overflow: hidden;
transition: all 0.3s;
width: 120px;
height: 120px;
display: flex;
align-items: center;
justify-content: center;
&:hover {
border-color: #409eff;
}
}
.upload-icon {
font-size: 28px;
color: #8c939d;
}
.uploaded-image {
width: 100%;
height: 100%;
object-fit: cover;
}
}
.camera-table {
width: 100%;
.add-camera-btn {
margin-top: 10px;
}
}
</style>

@ -0,0 +1,591 @@
<template>
<div class="home-container">
<el-card class="welcome-card">
<template #header>
<div class="card-header">
<span>欢迎回来</span>
</div>
</template>
<div class="welcome-content">
<div class="tech-bg">
<div class="flow-lines">
<div class="line line-1"></div>
<div class="line line-2"></div>
<div class="line line-3"></div>
<div class="line line-4"></div>
</div>
<div class="glow-orb orb-1"></div>
<div class="glow-orb orb-2"></div>
</div>
<h2>欢迎使用智能视觉管理平台</h2>
<p>打造高效智能可视化的容器管理体验简化运维流程提升集群性能</p>
</div>
</el-card>
<div class="pod-summary">
<div class="summary-left">
<span class="summary-title">设备数量</span>
<span class="summary-count"> <strong>{{ podList.length }}</strong> 个设备</span>
</div>
<div class="summary-right">
<el-button type="primary" size="small" @click="showAddDialog = true">
<el-icon><Plus /></el-icon>
添加设备
</el-button>
</div>
</div>
<el-row :gutter="20" class="stats-row">
<el-col v-for="item in podList" :key="item.id" :span="12" :xs="24" :sm="12" :md="8" :lg="6">
<div class="pod-card">
<div class="pod-card-header">
<div class="pod-info">
<div class="pod-name">
{{ item.groupName }}
<el-tag size="small" :type="DeviceTypeColor[item.groupType] || 'info'">{{ DeviceTypeName[item.groupType] || '未知' }}</el-tag>
</div>
<div class="pod-id">设备ID: {{ item.id }}</div>
</div>
<div class="pod-status">
<span class="status-dot" :class="item.groupStatus === 1 ? 'online' : 'offline'"></span>
<span class="status-text">{{ item.groupStatus === 1 ? '在线' : '离线' }}</span>
</div>
</div>
<div class="pod-card-body">
<div class="stream-item" v-for="camera in item.cameras" :key="camera.id">
<div class="stream-icon video">
<el-icon><VideoCamera /></el-icon>
</div>
<div class="stream-content">
<div class="stream-label">{{ camera.cameraLocation || '--'}}</div>
<div class="stream-url">{{ camera.videoStreaming || '--' }}</div>
</div>
</div>
<!-- <div class="stream-item">
<div class="stream-icon infrared">
<el-icon><Sunny /></el-icon>
</div>
<div class="stream-content">
<div class="stream-label">红外</div>
<div class="stream-url">{{ item.cameras.find(i => i.cameraLocation === '红外')?.videoStreaming || '暂无' }}</div>
</div>
</div>
<div class="stream-item">
<div class="stream-icon lowlight">
<el-icon><Moon /></el-icon>
</div>
<div class="stream-content">
<div class="stream-label">低光</div>
<div class="stream-url">{{ item.lowlightRtsp || '暂无' }}</div>
</div>
</div> -->
</div>
<div class="pod-card-footer">
<el-button type="primary" plain size="small" @click="viewDetail(item)">
<el-icon><VideoPlay /></el-icon>
观看
</el-button>
<el-button type="warning" plain size="small" @click="editDevice(item)">
<el-icon><Edit /></el-icon>
编辑
</el-button>
<el-button type="danger" plain size="small" @click="deleteDevice(item)">
<el-icon><Delete /></el-icon>
删除
</el-button>
</div>
</div>
</el-col>
<!-- 无数据提示 -->
<el-col v-if="podList.length === 0 && !loading" :span="24">
<el-empty description="暂无设备数据" />
</el-col>
</el-row>
<!-- 添加设备弹窗 -->
<AddDeviceDialog v-model="showAddDialog" :is-edit="isEdit" :edit-data="editData" @success="handleDialogSuccess" @cancel="handleDialogCancel" />
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { useUserStore } from '@/stores/user'
import { getPodList, deletePod } from '@/api/home'
import { ElMessage, ElMessageBox } from 'element-plus'
import AddDeviceDialog from './components/AddDeviceDialog.vue'
import { Delete } from '@element-plus/icons-vue'
import { DeviceTypeName, DeviceTypeColor } from '@/enums'
const router = useRouter()
const userStore = useUserStore()
const podList = ref([])
const loading = ref(false)
const showAddDialog = ref(false)
const editData = ref(null)
const isEdit = ref(false)
//
const viewDetail = (item) => {
router.push({
path: '/detail',
query: {
groupId: item.groupId
}
})
}
//
const editDevice = (item) => {
editData.value = item
isEdit.value = true
showAddDialog.value = true
}
//
const handleDialogSuccess = () => {
fetchPodList()
//
editData.value = null
isEdit.value = false
}
// /
const handleDialogCancel = () => {
editData.value = null
isEdit.value = false
}
//
const deleteDevice = (item) => {
ElMessageBox.confirm('此操作将永久删除该设备, 是否继续?', '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}).then(async () => {
const res = await deletePod({
groupId: item.groupId
})
if (res.code === 200) {
ElMessage.success('删除成功')
fetchPodList()
}else{
ElMessage.error(res.msg)
}
})
}
const fetchPodList = async () => {
loading.value = true
try {
const res = await getPodList()
// TODO:
podList.value = res.data || []
} catch (error) {
console.error('获取设备列表失败:', error)
ElMessage.error('获取设备列表失败')
} finally {
loading.value = false
}
}
onMounted(() => {
console.log('Home page loaded, user:', userStore.userInfo)
fetchPodList()
})
</script>
<style lang="scss" scoped>
@use '@/styles/variables.scss' as *;
@use '@/styles/mixin.scss' as *;
.home-container {
.welcome-card {
margin-bottom: 20px;
position: relative;
overflow: hidden;
border: none;
background: linear-gradient(135deg, #0a1628 0%, #1a3a5c 50%, #0d2847 100%);
:deep(.el-card__header) {
border-bottom: none;
background: transparent !important;
padding: 12px 20px;
}
:deep(.el-card__header .card-header) {
font-size: 18px;
font-weight: 600;
color: #e0f0ff;
}
:deep(.el-card__body) {
background: transparent !important;
padding: 0;
}
.welcome-content {
position: relative;
text-align: center;
padding: 30px 20px;
overflow: hidden;
.tech-bg {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
overflow: hidden;
.flow-lines {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
.line {
position: absolute;
height: 1px;
background: linear-gradient(90deg, transparent, rgba(0, 212, 255, 0.8), transparent);
box-shadow: 0 0 15px rgba(0, 212, 255, 0.6);
&.line-1 {
width: 60%;
top: 20%;
left: -60%;
animation: flowRight 3s ease-in-out infinite;
}
&.line-2 {
width: 40%;
top: 40%;
left: -40%;
animation: flowRight 4s ease-in-out infinite 0.5s;
background: linear-gradient(90deg, transparent, rgba(0, 255, 255, 0.7), transparent);
box-shadow: 0 0 12px rgba(0, 255, 255, 0.5);
}
&.line-3 {
width: 50%;
top: 60%;
right: -50%;
animation: flowLeft 3.5s ease-in-out infinite 1s;
background: linear-gradient(90deg, transparent, rgba(64, 224, 255, 0.6), transparent);
box-shadow: 0 0 10px rgba(64, 224, 255, 0.4);
}
&.line-4 {
width: 70%;
top: 80%;
left: -70%;
animation: flowRight 5s ease-in-out infinite 2s;
background: linear-gradient(90deg, transparent, rgba(0, 180, 216, 0.5), transparent);
box-shadow: 0 0 8px rgba(0, 180, 216, 0.4);
}
}
}
.glow-orb {
position: absolute;
border-radius: 50%;
filter: blur(60px);
opacity: 0.5;
animation: orbFloat 6s ease-in-out infinite;
&.orb-1 {
width: 250px;
height: 250px;
background: radial-gradient(circle, rgba(0, 212, 255, 0.4) 0%, transparent 70%);
top: -80px;
right: -60px;
}
&.orb-2 {
width: 180px;
height: 180px;
background: radial-gradient(circle, rgba(0, 150, 199, 0.3) 0%, transparent 70%);
bottom: -40px;
left: 20%;
animation-delay: 3s;
}
}
}
h2 {
position: relative;
z-index: 1;
font-size: 28px;
margin-bottom: 12px;
color: #ffffff;
text-shadow: 0 0 20px rgba(0, 212, 255, 0.5);
}
p {
position: relative;
z-index: 1;
color: rgba(200, 230, 255, 0.85);
font-size: 15px;
line-height: 1.6;
}
}
}
.pod-summary {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px 20px;
background: white;
border-radius: 8px;
margin: 20px 0;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.05);
border: 1px solid $border-lighter;
.summary-left {
display: flex;
align-items: center;
gap: 12px;
.summary-title {
font-size: 16px;
font-weight: 600;
color: $text-primary;
}
.summary-count {
font-size: 14px;
color: $text-secondary;
strong {
color: $primary-color;
font-weight: 600;
}
}
}
}
.stats-row {
margin-top: 0;
.el-col{
margin-bottom: 20px;
}
.pod-card {
background: white;
border-radius: 12px;
padding: 20px;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.08);
border: 1px solid $border-lighter;
transition: all 0.3s ease;
height: 100%;
&:hover {
transform: translateY(-4px);
box-shadow: 0 8px 24px rgba(64, 158, 255, 0.15);
border-color: $primary-color;
}
.pod-card-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
padding-bottom: 12px;
border-bottom: 1px solid $border-lighter;
.pod-status {
display: flex;
align-items: center;
gap: 6px;
.status-dot {
width: 8px;
height: 8px;
border-radius: 50%;
&.online {
background: $success-color;
animation: pulse 2s infinite;
}
&.offline {
background: $danger-color;
animation: none;
}
}
.status-text {
font-size: 12px;
font-weight: 500;
.online & {
color: $success-color;
}
.offline & {
color: $danger-color;
}
}
}
.pod-name {
font-size: 16px;
font-weight: 600;
color: $text-primary;
}
.pod-id {
font-size: 12px;
color: $text-secondary;
margin-top: 2px;
}
}
.pod-card-body {
height: 190px;
overflow-y: auto;
.stream-item {
display: flex;
align-items: flex-start;
gap: 12px;
padding: 10px;
background: $bg-light;
border-radius: 8px;
margin-bottom: 10px;
transition: all 0.3s ease;
&:hover {
background: rgba(64, 158, 255, 0.08);
}
&:last-child {
margin-bottom: 0;
}
.stream-icon {
width: 36px;
height: 36px;
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
font-size: 18px;
flex-shrink: 0;
&.video {
background: rgba(64, 158, 255, 0.1);
color: $primary-color;
}
&.infrared {
background: rgba(230, 162, 60, 0.1);
color: $warning-color;
}
&.lowlight {
background: rgba(123, 97, 255, 0.1);
color: #7b61ff;
}
}
.stream-content {
flex: 1;
overflow: hidden;
.stream-label {
font-size: 13px;
color: $text-secondary;
margin-bottom: 4px;
}
.stream-url {
font-size: 12px;
color: $text-regular;
font-family: 'Courier New', monospace;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
}
}
.pod-card-footer {
display: flex;
gap: 10px;
margin-top: 16px;
padding-top: 16px;
border-top: 1px solid $border-lighter;
.el-button {
flex: 1;
}
}
}
}
}
@keyframes waveMove {
0% {
transform: translateX(0);
}
100% {
transform: translateX(33.33%);
}
}
@keyframes flowRight {
0% {
left: -60%;
opacity: 0;
}
10% {
opacity: 1;
}
90% {
opacity: 1;
}
100% {
left: 100%;
opacity: 0;
}
}
@keyframes flowLeft {
0% {
right: -50%;
opacity: 0;
}
10% {
opacity: 1;
}
90% {
opacity: 1;
}
100% {
right: 100%;
opacity: 0;
}
}
@keyframes orbFloat {
0%, 100% {
transform: translate(0, 0) scale(1);
}
50% {
transform: translate(20px, 20px) scale(1.1);
}
}
@keyframes pulse {
0%, 100% {
opacity: 1;
}
50% {
opacity: 0.5;
}
}
</style>

@ -0,0 +1,467 @@
<template>
<div class="login-container">
<AuthLogo />
<AuthBackground />
<div class="login-content">
<div class="login-box">
<div class="login-header">
<div class="logo-area">
<div class="logo-icon">
<el-icon :size="40"><Box /></el-icon>
</div>
<h1 class="logo-text">视觉管理平台</h1>
</div>
<p class="subtitle">智能 · 高效 · 可视化</p>
</div>
<el-form
ref="loginFormRef"
:model="loginForm"
:rules="loginRules"
class="login-form"
size="large"
>
<el-form-item prop="userName">
<el-input
v-model="loginForm.userName"
placeholder="请输入账号"
prefix-icon="User"
/>
</el-form-item>
<el-form-item prop="userPwd">
<el-input
v-model="loginForm.userPwd"
type="password"
placeholder="请输入密码"
prefix-icon="Lock"
show-password
@keyup.enter="handleLogin"
/>
</el-form-item>
<div class="form-options">
<el-checkbox v-model="rememberMe"></el-checkbox>
<a href="#" class="forgot-link">忘记密码</a>
</div>
<el-button
type="primary"
:loading="loading"
class="login-btn"
@click="handleLogin"
>
{{ loading ? '登录中...' : '登 录' }}
</el-button>
</el-form>
<div class="register-tip">
还没有账号
<router-link to="/register" class="register-link">立即注册</router-link>
</div>
</div>
<div class="intro-panel">
<h2 class="intro-title">欢迎使用智能视觉管理平台</h2>
<p class="intro-desc">
打造高效智能可视化的容器管理体验<br>
简化运维流程提升集群性能
</p>
<div class="feature-grid">
<div class="feature-item">
<div class="feature-icon">
<el-icon><Monitor /></el-icon>
</div>
<div class="feature-info">
<h4>实时监控</h4>
<p>全方位监控集群状态</p>
</div>
</div>
<div class="feature-item">
<div class="feature-icon">
<el-icon><Setting /></el-icon>
</div>
<div class="feature-info">
<h4>智能调度</h4>
<p>自动化资源分配</p>
</div>
</div>
<div class="feature-item">
<div class="feature-icon">
<el-icon><DataLine /></el-icon>
</div>
<div class="feature-info">
<h4>数据分析</h4>
<p>深度洞察集群性能</p>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, reactive, onMounted } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { ElMessage } from 'element-plus'
import { useUserStore } from '@/stores/user'
import { newPodApi } from '@/api/user'
import md5 from 'js-md5'
import sha256 from 'js-sha256'
import AuthLogo from '@/components/AuthLogo.vue'
import AuthBackground from '@/components/AuthBackground.vue'
const router = useRouter()
const route = useRoute()
const userStore = useUserStore()
const loginFormRef = ref(null)
const loading = ref(false)
const rememberMe = ref(false)
const loginForm = reactive({
userName: '',
userPwd: ''
})
// 7
const REMEMBER_EXPIRY_DAYS = 7
//
const loadSavedCredentials = () => {
const savedData = localStorage.getItem('rememberedCredentials')
if (savedData) {
try {
const { userName, userPwd, expiryTime } = JSON.parse(savedData)
if (Date.now() < expiryTime) {
loginForm.userName = userName
loginForm.userPwd = userPwd
rememberMe.value = true
} else {
localStorage.removeItem('rememberedCredentials')
}
} catch (e) {
localStorage.removeItem('rememberedCredentials')
}
}
}
//
const saveCredentials = () => {
const expiryTime = Date.now() + REMEMBER_EXPIRY_DAYS * 24 * 60 * 60 * 1000
localStorage.setItem('rememberedCredentials', JSON.stringify({
userName: loginForm.userName,
userPwd: loginForm.userPwd,
expiryTime
}))
}
//
const clearCredentials = () => {
localStorage.removeItem('rememberedCredentials')
}
onMounted(() => {
loadSavedCredentials()
})
const loginRules = {
userName: [
{ required: true, message: '请输入账号', trigger: 'blur' }
],
userPwd: [
{ required: true, message: '请输入密码', trigger: 'blur' }
]
}
const generateRandomString = (length = 6) => {
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'
let result = ''
for (let i = 0; i < length; i++) {
result += chars.charAt(Math.floor(Math.random() * chars.length))
}
return result
}
const handleLogin = async () => {
// const random = generateRandomString()
// const podLogin = await newPodApi({
// "session": 0,
// "id": 2,
// "call": {
// "service": "rpc",
// "method": "login"
// },
// "params": {
// "userName": "admin",
// "password": sha256(md5('abcd1234')+random),
// "random": random,
// "ip": "127.0.0.1",
// "port": 80,
// "encryptType": 1
// }
// })
// return;
if (!loginFormRef.value) return
await loginFormRef.value.validate(async (valid) => {
if (valid) {
loading.value = true
try {
const res = await userStore.login(loginForm)
if (res.success) {
//
if (rememberMe.value) {
saveCredentials()
} else {
clearCredentials()
}
ElMessage.success('登录成功')
const redirect = route.query.redirect || '/home'
router.push(redirect)
}
} catch (error) {
ElMessage.error(error.message || '登录失败')
} finally {
loading.value = false
}
}
})
}
</script>
<style lang="scss" scoped>
@use '@/styles/variables.scss' as *;
@use '@/styles/mixin.scss' as *;
.login-container {
position: relative;
width: 100%;
height: 100vh;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
}
.login-content {
position: relative;
z-index: 10;
display: flex;
width: 1000px;
height: 600px;
background: rgba(255, 255, 255, 0.95);
border-radius: 20px;
box-shadow:
0 25px 50px -12px rgba(0, 0, 0, 0.4),
0 0 0 1px rgba(255, 255, 255, 0.1);
overflow: hidden;
backdrop-filter: blur(20px);
}
.login-box {
width: 420px;
padding: 60px 50px;
display: flex;
flex-direction: column;
justify-content: center;
background: #fff;
.login-header {
text-align: center;
margin-bottom: 40px;
.logo-area {
display: flex;
align-items: center;
justify-content: center;
gap: 12px;
margin-bottom: 12px;
.logo-icon {
width: 56px;
height: 56px;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, $primary-color 0%, #764ba2 100%);
border-radius: 16px;
color: #fff;
box-shadow: 0 8px 20px rgba($primary-color, 0.3);
}
.logo-text {
font-size: 24px;
font-weight: 700;
color: $text-primary;
margin: 0;
}
}
.subtitle {
font-size: 14px;
color: $text-secondary;
margin: 0;
letter-spacing: 4px;
}
}
.login-form {
:deep(.el-form-item) {
margin-bottom: 24px;
}
:deep(.el-input__wrapper) {
padding: 14px 16px;
border-radius: 10px;
box-shadow: 0 0 0 1px $border-base;
transition: all 0.3s;
&:hover,
&.is-focus {
box-shadow: 0 0 0 2px rgba($primary-color, 0.3);
}
}
.form-options {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 28px;
:deep(.el-checkbox__label) {
color: $text-regular;
font-size: 14px;
}
.forgot-link {
color: $primary-color;
font-size: 14px;
text-decoration: none;
transition: color 0.3s;
&:hover {
color: darken($primary-color, 10%);
}
}
}
.login-btn {
width: 100%;
height: 48px;
font-size: 16px;
font-weight: 600;
border-radius: 10px;
border: none;
background: linear-gradient(135deg, $primary-color 0%, #764ba2 100%);
box-shadow: 0 8px 20px rgba($primary-color, 0.3);
transition: all 0.3s;
&:hover {
transform: translateY(-2px);
box-shadow: 0 12px 28px rgba($primary-color, 0.4);
}
&:active {
transform: translateY(0);
}
}
}
.register-tip {
text-align: center;
margin-top: 28px;
font-size: 14px;
color: $text-secondary;
.register-link {
color: $primary-color;
font-weight: 600;
text-decoration: none;
margin-left: 4px;
transition: color 0.3s;
&:hover {
color: darken($primary-color, 10%);
}
}
}
}
.intro-panel {
flex: 1;
padding: 60px;
display: flex;
flex-direction: column;
justify-content: center;
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%);
color: #fff;
.intro-title {
font-size: 28px;
font-weight: 700;
margin: 0 0 16px 0;
background: linear-gradient(135deg, #fff 0%, rgba(255, 255, 255, 0.8) 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.intro-desc {
font-size: 16px;
line-height: 1.8;
color: rgba(255, 255, 255, 0.7);
margin: 0 0 50px 0;
}
.feature-grid {
display: flex;
flex-direction: column;
gap: 24px;
.feature-item {
display: flex;
align-items: center;
gap: 20px;
padding: 20px;
background: rgba(255, 255, 255, 0.05);
border-radius: 12px;
border: 1px solid rgba(255, 255, 255, 0.1);
transition: all 0.3s;
&:hover {
background: rgba(255, 255, 255, 0.1);
transform: translateX(8px);
}
.feature-icon {
width: 48px;
height: 48px;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, $primary-color 0%, #764ba2 100%);
border-radius: 12px;
font-size: 24px;
color: #fff;
box-shadow: 0 4px 12px rgba($primary-color, 0.3);
}
.feature-info {
h4 {
font-size: 16px;
font-weight: 600;
margin: 0 0 4px 0;
color: #fff;
}
p {
font-size: 13px;
color: rgba(255, 255, 255, 0.6);
margin: 0;
}
}
}
}
}
</style>

@ -0,0 +1,294 @@
<template>
<el-dialog
v-model="visible"
:title="isEdit ? '编辑人员' : '添加人员'"
width="560px"
:close-on-click-modal="false"
@closed="handleClosed"
>
<el-form
ref="formRef"
:model="formData"
:rules="rules"
label-width="90px"
>
<el-form-item label="姓名" prop="name">
<el-input v-model="formData.name" placeholder="请输入姓名" maxlength="20" />
</el-form-item>
<el-form-item label="性别" prop="gender">
<el-select v-model="formData.gender" placeholder="请选择性别" style="width: 100%">
<el-option label="男" :value="1" />
<el-option label="女" :value="2" />
</el-select>
</el-form-item>
<el-form-item label="年龄" prop="age">
<el-input-number
v-model="formData.age"
:min="1"
:max="100"
placeholder="请输入年龄"
style="width: 100%"
/>
</el-form-item>
<el-form-item label="工号" prop="employeeId">
<el-input v-model="formData.employeeId" placeholder="请输入工号" maxlength="20" />
</el-form-item>
<el-form-item label="所属部门" prop="department">
<el-input v-model="formData.department" placeholder="请输入所属部门" maxlength="50" />
</el-form-item>
<el-form-item label="联系方式" prop="contact">
<el-input v-model="formData.contact" placeholder="请输入手机号或邮箱" />
</el-form-item>
<el-form-item label="人脸样本" prop="faceSamples" v-if="!isEdit">
<div class="face-upload-list">
<div class="face-upload-item">
<span class="face-label">正脸照片</span>
<el-upload
class="face-uploader"
:show-file-list="false"
:auto-upload="false"
:on-change="(file) => handleFaceChange(file, 'front')"
accept="image/*"
>
<img v-if="formData.faceSamples.front" :src="formData.faceSamples.front" class="face-img" />
<el-icon v-else class="face-uploader-icon"><Plus /></el-icon>
</el-upload>
</div>
<div class="face-upload-item">
<span class="face-label">左脸照片</span>
<el-upload
class="face-uploader"
:show-file-list="false"
:auto-upload="false"
:on-change="(file) => handleFaceChange(file, 'left')"
accept="image/*"
>
<img v-if="formData.faceSamples.left" :src="formData.faceSamples.left" class="face-img" />
<el-icon v-else class="face-uploader-icon"><Plus /></el-icon>
</el-upload>
</div>
<div class="face-upload-item">
<span class="face-label">右脸照片</span>
<el-upload
class="face-uploader"
:show-file-list="false"
:auto-upload="false"
:on-change="(file) => handleFaceChange(file, 'right')"
accept="image/*"
>
<img v-if="formData.faceSamples.right" :src="formData.faceSamples.right" class="face-img" />
<el-icon v-else class="face-uploader-icon"><Plus /></el-icon>
</el-upload>
</div>
</div>
<div class="face-tip">请上传清晰的正面左侧右侧人脸照片</div>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="visible = false">取消</el-button>
<el-button type="primary" :loading="loading" @click="handleSubmit"></el-button>
</template>
</el-dialog>
</template>
<script setup>
import { ref, computed, watch } from 'vue'
import { ElMessage } from 'element-plus'
const props = defineProps({
modelValue: Boolean,
formData: {
type: Object,
default: () => ({})
},
isEdit: Boolean
})
const emit = defineEmits(['update:modelValue', 'success'])
const formRef = ref(null)
const loading = ref(false)
const visible = computed({
get: () => props.modelValue,
set: (val) => emit('update:modelValue', val)
})
const initFormData = () => ({
name: '',
gender: '',
age: '',
employeeId: '',
department: '',
contact: '',
faceSamples: {
front: '',
left: '',
right: ''
}
})
const formData = ref(initFormData())
//
const validatePhone = (rule, value, callback) => {
if (!value) {
callback(new Error('请输入联系方式'))
return
}
const phoneReg = /^1[3-9]\d{9}$/
const emailReg = /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/
if (phoneReg.test(value) || emailReg.test(value)) {
callback()
} else {
callback(new Error('请输入正确的手机号或邮箱'))
}
}
const rules = {
name: [
{ required: true, message: '请输入姓名', trigger: 'blur' },
{ min: 2, max: 20, message: '姓名长度为2-20个字符', trigger: 'blur' }
],
gender: [{ required: true, message: '请选择性别', trigger: 'change' }],
age: [
{ required: true, message: '请输入年龄', trigger: 'blur' },
{ type: 'number', min: 1, max: 100, message: '年龄范围为1-100', trigger: 'blur' }
],
employeeId: [
{ required: true, message: '请输入工号', trigger: 'blur' }
],
department: [
{ required: true, message: '请输入所属部门', trigger: 'blur' }
],
contact: [
{ required: true, validator: validatePhone, trigger: 'blur' }
],
faceSamples: [
{
validator: (rule, value, callback) => {
if (props.isEdit) {
callback()
return
}
const { front, left, right } = value
if (!front || !left || !right) {
callback(new Error('请上传完整的人脸样本'))
} else {
callback()
}
},
trigger: 'change'
}
]
}
watch(() => props.formData, (val) => {
if (val && Object.keys(val).length > 0) {
formData.value = {
...val,
faceSamples: val.faceSamples || { front: '', left: '', right: '' }
}
} else {
formData.value = initFormData()
}
}, { immediate: true, deep: true })
const handleFaceChange = (file, type) => {
const url = URL.createObjectURL(file.raw)
formData.value.faceSamples[type] = url
//
formRef.value?.validateField('faceSamples')
}
const handleSubmit = async () => {
const valid = await formRef.value.validate().catch(() => false)
if (!valid) return
loading.value = true
try {
//
const submitData = {
...formData.value,
faceSamples: props.isEdit ? undefined : formData.value.faceSamples
}
console.log('提交数据:', submitData)
ElMessage.success(props.isEdit ? '编辑成功' : '添加成功')
emit('success')
visible.value = false
} catch (error) {
ElMessage.error(props.isEdit ? '编辑失败' : '添加失败')
} finally {
loading.value = false
}
}
const handleClosed = () => {
formRef.value?.resetFields()
formData.value = initFormData()
}
</script>
<style lang="scss" scoped>
.face-upload-list {
display: flex;
gap: 12px;
}
.face-upload-item {
display: flex;
flex-direction: column;
align-items: center;
gap: 6px;
.face-label {
font-size: 12px;
color: #909399;
}
}
.face-uploader {
:deep(.el-upload) {
border: 1px dashed #d9d9d9;
border-radius: 6px;
cursor: pointer;
position: relative;
overflow: hidden;
transition: border-color 0.3s;
&:hover {
border-color: #409eff;
}
}
.face-img {
width: 80px;
height: 80px;
object-fit: cover;
display: block;
}
.face-uploader-icon {
width: 80px;
height: 80px;
display: flex;
align-items: center;
justify-content: center;
font-size: 24px;
color: #8c939d;
}
}
.face-tip {
font-size: 12px;
color: #c0c4cc;
margin-top: 8px;
}
</style>

@ -0,0 +1,351 @@
<template>
<div class="personnel-container">
<div class="page-header">
<span class="page-title">已录入人员</span>
<el-button type="primary" @click="handleAdd">
<el-icon><Plus /></el-icon>
添加人员
</el-button>
</div>
<div class="card-list">
<div
v-for="item in personnelList"
:key="item.id"
class="personnel-card"
>
<div class="card-header">
<el-avatar :size="64" :src="item.avatar">
<el-icon :size="32"><UserFilled /></el-icon>
</el-avatar>
<div class="person-info">
<h3 class="person-name">{{ item.name }}</h3>
<span class="person-id">工号{{ item.employeeId }}</span>
</div>
</div>
<div class="card-body">
<div class="info-row">
<span class="label">人脸样本</span>
<div class="face-samples">
<el-avatar
v-for="(img, idx) in item.faceSamples"
:key="idx"
:size="36"
:src="img"
class="face-thumb"
/>
<span v-if="!item.faceSamples?.length" class="no-data"></span>
</div>
</div>
<div class="info-row">
<span class="label">所属部门</span>
<span class="value">{{ item.department || '-' }}</span>
</div>
<div class="info-row">
<span class="label">联系方式</span>
<span class="value">{{ item.phone || '-' }}</span>
</div>
<div class="info-row">
<span class="label">性别</span>
<span class="value">{{ item.gender === 1 ? '男' : item.gender === 2 ? '女' : '-' }}</span>
</div>
<div class="info-row">
<span class="label">年龄</span>
<span class="value">{{ item.age || '-' }}</span>
</div>
</div>
<div class="card-footer">
<el-button type="primary" plain @click="handleEdit(item)">
<el-icon><Edit /></el-icon>
编辑
</el-button>
<el-button type="danger" plain @click="handleDelete(item)">
<el-icon><Delete /></el-icon>
删除
</el-button>
</div>
</div>
<div v-if="!personnelList.length" class="empty-state">
<el-empty description="暂无人员数据" />
</div>
</div>
<div class="pagination-wrapper" v-if="total > 0">
<el-pagination
v-model:current-page="currentPage"
v-model:page-size="pageSize"
:page-sizes="[12, 24, 36, 48]"
:total="total"
layout="total, sizes, prev, pager, next, jumper"
background
@current-change="handlePageChange"
@size-change="handleSizeChange"
/>
</div>
<!-- 添加/编辑弹窗 -->
<PersonnelFormDialog
v-model="formDialogVisible"
:form-data="currentPerson"
:is-edit="isEdit"
@success="handleFormSuccess"
/>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import PersonnelFormDialog from './components/PersonnelFormDialog.vue'
const currentPage = ref(1)
const pageSize = ref(12)
const total = ref(0)
const personnelList = ref([])
//
const formDialogVisible = ref(false)
const isEdit = ref(false)
const currentPerson = ref({})
//
const mockData = [
{
id: 1,
name: '张三',
avatar: '',
employeeId: 'EMP001',
faceSamples: [],
department: '技术部',
phone: '138****1234',
gender: 1,
age: 28
},
{
id: 2,
name: '李四',
avatar: '',
employeeId: 'EMP002',
faceSamples: [],
department: '市场部',
phone: '139****5678',
gender: 2,
age: 32
},
{
id: 3,
name: '王五',
avatar: '',
employeeId: 'EMP003',
faceSamples: [],
department: '人力资源部',
phone: '137****9012',
gender: 1,
age: 26
},
{
id: 4,
name: '赵六',
avatar: '',
employeeId: 'EMP004',
faceSamples: [],
department: '财务部',
phone: '136****3456',
gender: 1,
age: 35
}
]
//
const fetchPersonnelList = () => {
//
//
const start = (currentPage.value - 1) * pageSize.value
const end = start + pageSize.value
personnelList.value = mockData.slice(start, end)
total.value = mockData.length
}
//
const handlePageChange = (page) => {
currentPage.value = page
fetchPersonnelList()
}
const handleSizeChange = (size) => {
pageSize.value = size
currentPage.value = 1
fetchPersonnelList()
}
//
const handleAdd = () => {
isEdit.value = false
currentPerson.value = {}
formDialogVisible.value = true
}
//
const handleEdit = (item) => {
isEdit.value = true
currentPerson.value = { ...item }
formDialogVisible.value = true
}
//
const handleDelete = (item) => {
console.log('删除', item)
}
//
const handleFormSuccess = () => {
fetchPersonnelList()
}
onMounted(() => {
fetchPersonnelList()
})
</script>
<style lang="scss" scoped>
.personnel-container {
height: calc(100vh - 120px);
display: flex;
flex-direction: column;
}
.page-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
.page-title {
font-size: 18px;
font-weight: 600;
color: #303133;
}
}
.card-list {
flex: 1;
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 16px;
overflow-y: auto;
padding-bottom: 20px;
align-content: start;
}
.personnel-card {
background: #fff;
border-radius: 12px;
border: 1px solid #ebeef5;
padding: 20px;
transition: all 0.3s;
&:hover {
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.08);
transform: translateY(-2px);
}
.card-header {
display: flex;
align-items: center;
gap: 12px;
padding-bottom: 16px;
border-bottom: 1px solid #f0f0f0;
margin-bottom: 16px;
.person-info {
flex: 1;
min-width: 0;
.person-name {
font-size: 16px;
font-weight: 600;
color: #303133;
margin: 0 0 4px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.person-id {
font-size: 12px;
color: #909399;
}
}
}
.card-body {
.info-row {
display: flex;
align-items: center;
padding: 8px 0;
font-size: 13px;
.label {
width: 70px;
color: #909399;
flex-shrink: 0;
}
.value {
color: #606266;
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.face-samples {
flex: 1;
display: flex;
gap: 4px;
flex-wrap: wrap;
.face-thumb {
border: 1px solid #ebeef5;
}
.no-data {
color: #c0c4cc;
font-size: 12px;
}
}
}
}
.card-footer {
display: flex;
gap: 10px;
padding-top: 16px;
border-top: 1px solid #f0f0f0;
margin-top: 8px;
.el-button {
flex: 1;
}
}
}
.empty-state {
grid-column: 1 / -1;
display: flex;
align-items: center;
justify-content: center;
min-height: 300px;
}
.pagination-wrapper {
display: flex;
justify-content: flex-end;
padding-top: 16px;
background: #fff;
border-radius: 8px;
padding: 16px;
}
</style>

@ -0,0 +1,287 @@
<template>
<div class="register-container">
<AuthLogo to="/login" />
<AuthBackground />
<div class="register-box">
<div class="register-header">
<h2>用户注册</h2>
<p>创建您的账号开始使用智能视觉管理平台</p>
</div>
<el-form
ref="registerFormRef"
:model="registerForm"
:rules="registerRules"
class="register-form"
size="large"
>
<el-form-item prop="username">
<el-input
v-model="registerForm.username"
placeholder="请输入账号"
prefix-icon="User"
/>
</el-form-item>
<el-form-item prop="nickname">
<el-input
v-model="registerForm.nickname"
placeholder="请输入昵称"
prefix-icon="UserFilled"
/>
</el-form-item>
<el-form-item prop="phone">
<el-input
v-model="registerForm.phone"
placeholder="请输入手机号"
prefix-icon="Phone"
/>
</el-form-item>
<el-form-item prop="code">
<el-input
v-model="registerForm.code"
placeholder="请输入手机验证码"
prefix-icon="Message"
>
<template #append>
<el-button
:disabled="countdown > 0"
@click="handleSendCode"
>
{{ countdown > 0 ? `${countdown}s` : '获取验证码' }}
</el-button>
</template>
</el-input>
</el-form-item>
<el-form-item prop="password">
<el-input
v-model="registerForm.password"
type="password"
placeholder="请输入密码"
prefix-icon="Lock"
show-password
/>
</el-form-item>
<el-form-item prop="confirmPassword">
<el-input
v-model="registerForm.confirmPassword"
type="password"
placeholder="请确认密码"
prefix-icon="Lock"
show-password
/>
</el-form-item>
<el-form-item>
<el-checkbox v-model="agreeAgreement">
我已阅读并同意
<a href="#" class="agreement-link">用户协议</a>
<a href="#" class="agreement-link">隐私政策</a>
</el-checkbox>
</el-form-item>
<el-form-item>
<el-button
type="primary"
:loading="loading"
class="register-btn"
@click="handleRegister"
>
</el-button>
</el-form-item>
</el-form>
<div class="login-link">
已有账号
<router-link to="/login">立即登录</router-link>
</div>
</div>
</div>
</template>
<script setup>
import { ref, reactive } from 'vue'
import AuthLogo from '@/components/AuthLogo.vue'
import AuthBackground from '@/components/AuthBackground.vue'
import { useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import { useUserStore } from '@/stores/user'
const router = useRouter()
const userStore = useUserStore()
const registerFormRef = ref(null)
const loading = ref(false)
const countdown = ref(0)
const agreeAgreement = ref(false)
const registerForm = reactive({
username: '',
nickname: '',
phone: '',
code: '',
password: '',
confirmPassword: ''
})
const validateConfirmPassword = (rule, value, callback) => {
if (value !== registerForm.password) {
callback(new Error('两次输入的密码不一致'))
} else {
callback()
}
}
const registerRules = {
username: [
{ required: true, message: '请输入账号', trigger: 'blur' },
{ min: 4, max: 20, message: '账号长度在 4 到 20 个字符', trigger: 'blur' }
],
nickname: [
{ required: true, message: '请输入昵称', trigger: 'blur' }
],
phone: [
{ required: true, message: '请输入手机号', trigger: 'blur' },
{ pattern: /^1[3-9]\d{9}$/, message: '请输入正确的手机号', trigger: 'blur' }
],
code: [
{ required: true, message: '请输入验证码', trigger: 'blur' },
{ len: 6, message: '验证码为 6 位数字', trigger: 'blur' }
],
password: [
{ required: true, message: '请输入密码', trigger: 'blur' },
{ min: 6, max: 20, message: '密码长度在 6 到 20 个字符', trigger: 'blur' }
],
confirmPassword: [
{ required: true, message: '请确认密码', trigger: 'blur' },
{ validator: validateConfirmPassword, trigger: 'blur' }
]
}
const handleSendCode = () => {
// TODO:
// await sendSmsCodeApi(registerForm.phone)
ElMessage.success('验证码已发送')
countdown.value = 60
const timer = setInterval(() => {
countdown.value--
if (countdown.value <= 0) {
clearInterval(timer)
}
}, 1000)
}
const handleRegister = async () => {
if (!registerFormRef.value) return
if (!agreeAgreement.value) {
ElMessage.warning('请先阅读并同意用户协议和隐私政策')
return
}
await registerFormRef.value.validate(async (valid) => {
if (valid) {
loading.value = true
try {
const res = await userStore.register(registerForm)
if (res.success) {
ElMessage.success('注册成功,请登录')
router.push('/login')
}
} catch (error) {
ElMessage.error(error.message || '注册失败')
} finally {
loading.value = false
}
}
})
}
</script>
<style lang="scss" scoped>
@use '@/styles/variables.scss' as *;
@use '@/styles/mixin.scss' as *;
.register-container {
width: 100%;
height: 100vh;
display: flex;
align-items: center;
justify-content: center;
position: relative;
}
.register-box {
position: relative;
z-index: 10;
width: 420px;
padding: 40px;
background: #fff;
border-radius: 12px;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
.register-header {
text-align: center;
margin-bottom: 30px;
h2 {
font-size: 28px;
font-weight: 600;
color: $text-primary;
margin-bottom: 10px;
}
p {
font-size: 14px;
color: $text-secondary;
}
}
.register-form {
.agreement-link {
color: $primary-color;
text-decoration: none;
&:hover {
text-decoration: underline;
}
}
.register-btn {
width: 100%;
font-size: 16px;
}
}
.login-link {
text-align: center;
margin-top: 20px;
font-size: 14px;
color: $text-secondary;
a {
color: $primary-color;
text-decoration: none;
&:hover {
text-decoration: underline;
}
}
}
}
:deep(.el-input-group__append) {
padding: 0 12px;
}
:deep(.el-checkbox__label) {
font-size: 13px;
}
</style>

@ -0,0 +1,24 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import { resolve } from 'path'
export default defineConfig({
plugins: [vue()],
resolve: {
alias: {
'@': resolve(__dirname, 'src')
}
},
css: {
preprocessorOptions: {
scss: {
api: 'modern-compiler'
}
}
},
server: {
host: '0.0.0.0', // 允许通过 IP 地址访问
port: 5173, // 指定端口号,可选,默认为 5173
open: false // 启动时自动打开浏览器,可选
}
})
Loading…
Cancel
Save