Initial commit
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=视觉管理平台
|
||||||
@ -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>
|
||||||
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,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,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,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,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…
Reference in New Issue