feat:mes流程模块

zlx
zhoulexin 3 weeks ago
parent ed0ba51647
commit 1187e67665

@ -2,7 +2,7 @@
VITE_APP_TITLE = 'SOP作业检测系统' VITE_APP_TITLE = 'SOP作业检测系统'
# 后端接口地址 # 后端接口地址
VITE_API_BASE_IP = '127.0.0.1' VITE_API_BASE_IP = '10.23.22.43'
VITE_API_BASE_URL = 'http://${VITE_API_BASE_IP}:8188/api' VITE_API_BASE_URL = 'http://${VITE_API_BASE_IP}:8188/api'
# 请求超时时间(毫秒) # 请求超时时间(毫秒)

@ -35,3 +35,66 @@ export function getHomeStatsApi() {
method: 'get' method: 'get'
}) })
} }
// ============ MES 相关 ============
/**
* 获取 SN 码对应的 Model
* @param {Object} params - { customer, serialNumber }
*/
export function getSnApi(params) {
return request({
url: '/mes/sn',
method: 'get',
params
})
}
/**
* OK to Test 检测启动
* @param {Object} params - { customerName, division, serialNumber, assemblyNumber, testerName, processStep }
*/
export function okToTestApi(params) {
return request({
url: '/mes/okToTest',
method: 'get',
params
})
}
// ============ 算法状态相关 ============
/**
* 获取算法状态
* @returns {Promise<boolean>}
*/
export function getAlgorithmStatusApi() {
return request({
url: '/algorithm/status',
method: 'get'
})
}
/**
* 设置算法状态
* @param {Object} data - { enabled: boolean }
*/
export function setAlgorithmStatusApi(data) {
return request({
url: '/algorithm/status',
method: 'post',
data
})
}
/**
* 保存检测报告流程数据
* @param {Array} data - 流程切片数据数组
*/
export function saveReportApi(data) {
return request({
url: '/save-list',
method: 'post',
data
})
}

@ -4,11 +4,7 @@
<div class="main-container" :class="{ collapsed: appStore.sidebarCollapsed }"> <div class="main-container" :class="{ collapsed: appStore.sidebarCollapsed }">
<AppHeader /> <AppHeader />
<div class="main-content"> <div class="main-content">
<router-view v-slot="{ Component }"> <router-view :key="$route.fullPath" />
<transition name="fade-slide" mode="out-in">
<component :is="Component" />
</transition>
</router-view>
</div> </div>
</div> </div>
</div> </div>

@ -55,7 +55,8 @@ const router = useRouter()
const appStore = useAppStore() const appStore = useAppStore()
const menuItems = [ const menuItems = [
{ path: '/home', title: '首页', icon: 'HomeFilled' } { path: '/mes', title: 'MES检测', icon: 'Monitor' },
{ path: '/home', title: '检测页', icon: 'HomeFilled' }
// //
] ]
@ -81,7 +82,6 @@ function handleMenuClick(item) {
z-index: 100; z-index: 100;
transition: $transition; transition: $transition;
overflow: hidden; overflow: hidden;
&.collapsed { &.collapsed {
width: 64px; width: 64px;
} }

@ -4,7 +4,7 @@ import { useUserStore } from '@/stores/user'
const routes = [ const routes = [
{ {
path: '/', path: '/',
redirect: '/home' redirect: '/mes'
}, },
{ {
path: '/login', path: '/login',
@ -16,11 +16,17 @@ const routes = [
path: '/', path: '/',
component: () => import('@/layout/MainLayout.vue'), component: () => import('@/layout/MainLayout.vue'),
children: [ children: [
{
path: 'mes',
name: 'Mes',
component: () => import('@/views/mes/index.vue'),
meta: { title: 'MES检测', icon: 'Monitor' }
},
{ {
path: 'home', path: 'home',
name: 'Home', name: 'Home',
component: () => import('@/views/home/index.vue'), component: () => import('@/views/home/index.vue'),
meta: { title: '首页', icon: 'HomeFilled' } meta: { title: '检测页', icon: 'HomeFilled' }
} }
// 后续在此添加新菜单路由 // 后续在此添加新菜单路由
] ]

@ -1,4 +1,5 @@
const fileHttp = { const fileHttp = {
ptApi: `${import.meta.env.VITE_API_BASE_IP}:8001` ptApi: `${import.meta.env.VITE_API_BASE_IP}:8001`,
mesPtApi: `${import.meta.env.VITE_API_BASE_IP}:8188`
} }
export default fileHttp export default fileHttp

@ -4,7 +4,7 @@
:type="streamRunning ? 'danger' : 'primary'" :type="streamRunning ? 'danger' : 'primary'"
size="default" size="default"
@click="$emit('toggleStream')" @click="$emit('toggleStream')"
:disabled="streamAddressEmpty || (algorithmRunning && !streamRunning) || hasOfflineVideo" :disabled="algorithmDisabled || streamAddressEmpty || (algorithmRunning && !streamRunning) || hasOfflineVideo"
> >
{{ streamRunning ? '关闭视频流' : '启动视频流' }} {{ streamRunning ? '关闭视频流' : '启动视频流' }}
</el-button> </el-button>
@ -12,7 +12,7 @@
:type="algorithmRunning ? 'danger' : 'success'" :type="algorithmRunning ? 'danger' : 'success'"
size="default" size="default"
@click="$emit('toggleAlgorithm')" @click="$emit('toggleAlgorithm')"
:disabled="!isLiveMode && offlineVideoMissing" :disabled="isLiveMode || offlineVideoMissing"
> >
{{ algorithmRunning ? '关闭算法' : '启动算法' }} {{ algorithmRunning ? '关闭算法' : '启动算法' }}
</el-button> </el-button>
@ -26,7 +26,8 @@ defineProps({
streamAddressEmpty: Boolean, streamAddressEmpty: Boolean,
offlineVideoMissing: Boolean, offlineVideoMissing: Boolean,
isLiveMode: Boolean, isLiveMode: Boolean,
hasOfflineVideo: Boolean hasOfflineVideo: Boolean,
algorithmDisabled: Boolean
}) })
defineEmits(['toggleStream', 'toggleAlgorithm']) defineEmits(['toggleStream', 'toggleAlgorithm'])

@ -4,6 +4,7 @@
<ControlButtons <ControlButtons
:stream-running="streamRunning" :stream-running="streamRunning"
:algorithm-running="algorithmRunning" :algorithm-running="algorithmRunning"
:algorithm-disabled="!algorithmEnabled"
:stream-address-empty="!streamAddress" :stream-address-empty="!streamAddress"
:offline-video-missing="!offlineVideoUrl" :offline-video-missing="!offlineVideoUrl"
:is-live-mode="isLiveMode" :is-live-mode="isLiveMode"
@ -89,12 +90,14 @@ import FlowLog from './components/FlowLog.vue'
import ChartsPanel from './components/ChartsPanel.vue' import ChartsPanel from './components/ChartsPanel.vue'
import fileHttp from '@/utils/fileHttp.js' import fileHttp from '@/utils/fileHttp.js'
import { downloadAsZip } from '@/utils/zip.js' import { downloadAsZip } from '@/utils/zip.js'
import { getAlgorithmStatusApi, setAlgorithmStatusApi, saveReportApi } from '@/api'
// ============ ============ // ============ ============
const videoMode = ref('idle') const videoMode = ref('idle')
const streamAddress = ref('rtsp://127.0.0.1:8556/camera2') const streamAddress = ref('rtsp://127.0.0.1:8556/camera2')
const webrtcUrl = ref('') const webrtcUrl = ref('')
const streamRunning = ref(false) const streamRunning = ref(false)
const algorithmEnabled = ref(false)
const isLiveMode = computed(() => videoMode.value === 'live') const isLiveMode = computed(() => videoMode.value === 'live')
const RAW_WS_URL = `ws://${fileHttp.ptApi}/ws/raw_rtsp` const RAW_WS_URL = `ws://${fileHttp.ptApi}/ws/raw_rtsp`
const DETECT_WS_URL = `ws://${fileHttp.ptApi}/ws/detect_rtsp` const DETECT_WS_URL = `ws://${fileHttp.ptApi}/ws/detect_rtsp`
@ -337,6 +340,7 @@ function connectDetectStream() {
if (exportVideoUrl.value || exportReportUrl.value) { if (exportVideoUrl.value || exportReportUrl.value) {
canExport.value = true canExport.value = true
} }
parseAndSaveReport(exportReportUrl.value)
algorithmRunning.value = false algorithmRunning.value = false
paused.value = false paused.value = false
destroyDetectWs() destroyDetectWs()
@ -400,6 +404,7 @@ function toggleStream() {
ending.value = false ending.value = false
ElMessage.info('视频流已关闭') ElMessage.info('视频流已关闭')
addLog('视频检测完成,已切换到结果视频', 'info') addLog('视频检测完成,已切换到结果视频', 'info')
disableAlgorithm()
}) })
} else { } else {
stopDetectStream() stopDetectStream()
@ -411,6 +416,7 @@ function toggleStream() {
ending.value = false ending.value = false
ElMessage.info('视频流已关闭') ElMessage.info('视频流已关闭')
addLog('视频流已关闭', 'info') addLog('视频流已关闭', 'info')
disableAlgorithm()
} }
} else { } else {
// //
@ -677,6 +683,7 @@ function toggleAlgorithm() {
exportVideoUrl.value = videoUrl exportVideoUrl.value = videoUrl
exportReportUrl.value = msg.report_url ? (HTTP_BASE.replace(/\/$/, '') + msg.report_url) : '' exportReportUrl.value = msg.report_url ? (HTTP_BASE.replace(/\/$/, '') + msg.report_url) : ''
canExport.value = true canExport.value = true
parseAndSaveReport(exportReportUrl.value)
addLog('检测结果已就绪,可点击导出按钮下载', 'success') addLog('检测结果已就绪,可点击导出按钮下载', 'success')
break break
@ -798,10 +805,48 @@ function addLog(message, type = 'info') {
} }
// ============ ============ // ============ ============
onMounted(() => { onMounted(async () => {
addLog('页面已加载,可上传离线视频或连接实时流', 'info') addLog('页面已加载,可上传离线视频或连接实时流', 'info')
try {
const enabled = await getAlgorithmStatusApi()
algorithmEnabled.value = !!enabled
} catch {
algorithmEnabled.value = false
}
}) })
async function disableAlgorithm() {
try {
await setAlgorithmStatusApi({ enabled: false })
const enabled = await getAlgorithmStatusApi()
algorithmEnabled.value = !!enabled
} catch {
addLog('更新算法状态失败', 'error')
}
}
async function parseAndSaveReport(reportUrl) {
if (!reportUrl) return
try {
const res = await fetch(reportUrl)
const text = await res.text()
// " #N:"
const sections = text.split(/(?=流程 #\d+:)/)
// " #"
const processData = sections
.filter(s => /^流程 #\d+:/.test(s.trim()))
.map(s => s.trim())
if (processData.length > 0) {
await saveReportApi(processData)
addLog('检测报告流程数据已保存', 'success')
}
} catch (error) {
addLog('读取或保存报告失败: ' + (error.message || ''), 'error')
}
}
onUnmounted(() => { onUnmounted(() => {
if (offlineVideoUrl.value) { if (offlineVideoUrl.value) {
URL.revokeObjectURL(offlineVideoUrl.value) URL.revokeObjectURL(offlineVideoUrl.value)

@ -191,7 +191,7 @@ async function handleLogin() {
password: loginForm.password password: loginForm.password
}) })
ElMessage.success('登录成功,欢迎回来!') ElMessage.success('登录成功,欢迎回来!')
const redirect = route.query.redirect || '/home' const redirect = route.query.redirect || '/mes'
router.push(redirect) router.push(redirect)
} catch (error) { } catch (error) {
ElMessage.error(error.message || '登录失败,请重试') ElMessage.error(error.message || '登录失败,请重试')

@ -0,0 +1,633 @@
<template>
<div class="mes-page">
<div class="page-header">
<h2 class="page-title">MES检测</h2>
<div class="scan-status">
<template v-if="!scanning">
<el-button type="primary" size="large" @click="startScanner">
<el-icon><Camera /></el-icon>
启动扫码枪
</el-button>
</template>
<template v-else>
<el-tag type="success" effect="dark" size="large">
<el-icon><CircleCheck /></el-icon>
扫码枪状态启动中
</el-tag>
<el-button type="danger" size="large" plain @click="stopScanner" style="margin-left: 12px">
<el-icon><Close /></el-icon>
关闭扫码功能
</el-button>
</template>
</div>
</div>
<div class="page-content">
<!-- 顶部条码区域 -->
<el-card class="code-card" shadow="hover">
<div class="code-section">
<div class="code-item">
<label class="code-label">SN码</label>
<el-input
v-model="form.snCode"
placeholder="请输入SN码"
size="large"
clearable
class="code-input"
>
<template #prefix>
<el-icon><Barcode /></el-icon>
</template>
</el-input>
</div>
<div class="code-item">
<label class="code-label">Customer</label>
<el-input
v-model="form.customer"
placeholder="请输入Customer"
size="large"
clearable
class="code-input"
>
<template #prefix>
<el-icon><User /></el-icon>
</template>
</el-input>
</div>
<div class="code-item">
<label class="code-label">Model码</label>
<div class="model-row">
<el-input
v-model="form.modelCode"
placeholder="点击右侧按钮获取"
size="large"
readonly
class="code-input"
>
<template #prefix>
<el-icon><Box /></el-icon>
</template>
</el-input>
<el-button
type="primary"
size="large"
:loading="loading"
@click="fetchModelCode"
class="fetch-btn"
>
<el-icon><Pointer /></el-icon>
获取Model码
</el-button>
</div>
</div>
</div>
</el-card>
<!-- 表单区域 -->
<el-card class="form-card" shadow="hover">
<template #header>
<div class="card-header">
<el-icon><Document /></el-icon>
<span>检测参数</span>
</div>
</template>
<el-form
ref="formRef"
:model="form"
:rules="rules"
label-position="right"
label-width="150px"
class="mes-form"
>
<el-row :gutter="24">
<el-col :span="12">
<el-form-item label="CustomerName" prop="customerName">
<el-input v-model="form.customerName" placeholder="Please enter" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="Division" prop="division">
<el-input v-model="form.division" placeholder="Please enter" />
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="24">
<el-col :span="12">
<el-form-item label="SerialNumber" prop="serialNumber">
<el-input disabled v-model="form.serialNumber" placeholder="Please enter" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="AssemblyNumber" prop="assemblyNumber">
<el-input disabled v-model="form.assemblyNumber" placeholder="Please enter" />
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="24">
<el-col :span="12">
<el-form-item label="TesterName" prop="testerName">
<el-input v-model="form.testerName" placeholder="Please enter" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="ProcessStep" prop="processStep">
<el-input v-model="form.processStep" placeholder="Please enter" />
</el-form-item>
</el-col>
</el-row>
<div class="form-actions">
<el-button
type="primary"
size="large"
:loading="invokeLoading"
@click="handleInvoke"
class="invoke-btn"
>
<el-icon><Promotion /></el-icon>
Invoke
</el-button>
<el-button size="large" @click="handleReset">
<el-icon><RefreshRight /></el-icon>
重置
</el-button>
</div>
</el-form>
</el-card>
<!-- 结果展示区域 -->
<el-card v-if="result" class="result-card" shadow="hover">
<template #header>
<div class="card-header">
<el-icon><CircleCheck /></el-icon>
<span>检测结果</span>
</div>
</template>
<el-descriptions :column="2" border>
<el-descriptions-item label="状态">{{ result.status }}</el-descriptions-item>
<el-descriptions-item label="返回码">{{ result.code }}</el-descriptions-item>
<el-descriptions-item label="消息" :span="2">{{ result.message }}</el-descriptions-item>
</el-descriptions>
</el-card>
</div>
</div>
<!-- 成功动画遮罩 -->
<transition name="fade">
<div v-if="invokeSuccess" class="success-overlay">
<div class="success-card">
<div class="success-icon">
<el-icon :size="64" color="#67c23a"><CircleCheckFilled /></el-icon>
</div>
<h2 class="success-title">{{ countdown }}s 后自动跳转至检测页</h2>
<el-progress
type="circle"
:percentage="progressPercent"
:stroke-width="6"
color="#67c23a"
:width="100"
/>
<div class="success-subtitle">检测流程已成功启动</div>
</div>
</div>
</transition>
</template>
<script setup>
import { ref, reactive, onBeforeUnmount } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import WebSocketClient from '@/utils/websocket'
import fileHttp from '@/utils/fileHttp'
import { getSnApi, okToTestApi } from '@/api'
const router = useRouter()
const loading = ref(false)
const invokeLoading = ref(false)
const result = ref(null)
const scanning = ref(false)
const wsClient = ref(null)
const invokeSuccess = ref(false)
const countdown = ref(3)
const progressPercent = ref(0)
let countdownTimer = null
const form = reactive({
snCode: '',
customer: '',
modelCode: '',
customerName: '',
division: '',
serialNumber: '',
assemblyNumber: '',
testerName: '',
processStep: ''
})
const formRef = ref()
//
const rules = {
customerName: [{ required: true, message: '请输入CustomerName', trigger: 'blur' }],
division: [{ required: true, message: '请输入Division', trigger: 'blur' }],
serialNumber: [{ required: true, message: '请输入SerialNumber', trigger: 'blur' }],
assemblyNumber: [{ required: true, message: '请输入AssemblyNumber', trigger: 'blur' }],
testerName: [{ required: true, message: '请输入TesterName', trigger: 'blur' }],
processStep: [{ required: true, message: '请输入ProcessStep', trigger: 'blur' }]
}
// WebSocket
function startScanner() {
scanning.value = true
wsClient.value = new WebSocketClient({
url: `ws://${fileHttp.mesPtApi}/api/ws/camera`,
autoReconnect: false,
debug: true
})
wsClient.value.on('open', () => {
ElMessage.success('扫码枪已连接')
})
wsClient.value.on('message', (data) => {
// data sn
const sn = typeof data === 'string' ? data : data.sn
if (sn) {
form.snCode = sn
form.serialNumber = sn
}
})
wsClient.value.on('error', () => {
ElMessage.error('扫码枪连接失败')
scanning.value = false
})
wsClient.value.on('close', () => {
scanning.value = false
})
wsClient.value.connect()
}
function stopScanner() {
if (wsClient.value) {
wsClient.value.off('open')
wsClient.value.off('message')
wsClient.value.off('error')
wsClient.value.off('close')
wsClient.value.destroy()
wsClient.value = null
}
scanning.value = false
ElMessage.info('已关闭扫码功能')
}
//
onBeforeUnmount(() => {
stopScanner()
if (countdownTimer) {
clearInterval(countdownTimer)
countdownTimer = null
}
})
// Model
async function fetchModelCode() {
if (!form.serialNumber) {
ElMessage.warning('请先输入SN码')
return
}
if (!form.customer) {
ElMessage.warning('请先输入Customer')
return
}
loading.value = true
try {
const data = await getSnApi({ customer: form.customer, serialNumber: form.serialNumber })
if (data) {
form.modelCode = data
form.assemblyNumber = data
ElMessage.success('获取Model码成功')
} else {
ElMessage.error('未获取到Model码数据')
}
} catch (error) {
ElMessage.error('获取Model码失败')
} finally {
loading.value = false
}
}
// Invoke
async function handleInvoke() {
if (!formRef.value) return
try {
await formRef.value.validate()
} catch {
return
}
invokeLoading.value = true
try {
await okToTestApi({
customerName: form.customerName,
division: form.division,
serialNumber: form.serialNumber,
assemblyNumber: form.assemblyNumber,
testerName: form.testerName,
processStep: form.processStep
})
result.value = {
status: 'Success',
code: '200',
message: '检测流程已成功启动'
}
// 3
invokeSuccess.value = true
countdown.value = 3
progressPercent.value = 0
countdownTimer = setInterval(() => {
countdown.value--
progressPercent.value = Math.round((3 - countdown.value) / 3 * 100)
if (countdown.value <= 0) {
clearInterval(countdownTimer)
countdownTimer = null
router.push({ name: 'Home' })
}
}, 1000)
} catch (error) {
ElMessage.error('Invoke调用失败')
} finally {
invokeLoading.value = false
}
}
//
function handleReset() {
form.snCode = ''
form.customer = ''
form.modelCode = ''
form.customerName = ''
form.division = ''
form.serialNumber = ''
form.assemblyNumber = ''
form.testerName = ''
form.processStep = ''
result.value = null
ElMessage.info('表单已重置')
}
</script>
<style lang="scss" scoped>
.mes-page {
padding: 20px;
max-width: 1200px;
margin: 0 auto;
}
.page-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 20px;
.page-title {
font-size: 22px;
font-weight: 600;
color: #1a1f2e;
margin: 0;
}
.scan-status {
:deep(.el-tag) {
font-size: 14px;
padding: 8px 16px;
height: auto;
.el-icon {
margin-right: 6px;
}
}
}
}
.page-content {
display: flex;
flex-direction: column;
gap: 20px;
}
.code-card {
:deep(.el-card__body) {
padding: 24px;
}
}
.code-section {
display: flex;
gap: 32px;
flex-wrap: wrap;
}
.code-item {
flex: 1;
min-width: 300px;
.code-label {
display: block;
font-size: 14px;
font-weight: 500;
color: #606266;
margin-bottom: 10px;
}
.code-input {
:deep(.el-input__inner) {
font-size: 15px;
}
}
}
.model-row {
display: flex;
gap: 12px;
.code-input {
flex: 1;
}
.fetch-btn {
white-space: nowrap;
}
}
.form-card {
:deep(.el-card__header) {
padding: 16px 24px;
border-bottom: 1px solid #ebeef5;
}
.card-header {
display: flex;
align-items: center;
gap: 8px;
font-size: 16px;
font-weight: 600;
color: #1a1f2e;
.el-icon {
color: #2d5af0;
}
}
}
.mes-form {
padding: 12px 8px;
:deep(.el-form-item__label) {
font-weight: 500;
color: #4a5568;
}
:deep(.el-input__inner) {
font-size: 14px;
}
}
.form-actions {
display: flex;
justify-content: center;
gap: 16px;
margin-top: 24px;
padding-top: 16px;
border-top: 1px dashed #ebeef5;
.invoke-btn {
min-width: 120px;
font-weight: 600;
.el-icon {
margin-right: 6px;
}
}
}
.result-card {
:deep(.el-card__header) {
padding: 16px 24px;
border-bottom: 1px solid #ebeef5;
}
.card-header {
display: flex;
align-items: center;
gap: 8px;
font-size: 16px;
font-weight: 600;
color: #1a1f2e;
.el-icon {
color: #67c23a;
}
}
}
//
.success-overlay {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 9999;
}
.success-card {
background: #fff;
border-radius: 16px;
padding: 48px 64px;
text-align: center;
box-shadow: 0 12px 48px rgba(0, 0, 0, 0.15);
animation: popIn 0.4s cubic-bezier(0.175, 0.885, 0.32, 1.275);
.success-icon {
margin-bottom: 20px;
animation: pulse 1s ease-in-out infinite;
}
.success-title {
font-size: 20px;
font-weight: 600;
color: #1a1f2e;
margin: 0 0 24px;
}
.success-subtitle {
margin-top: 20px;
font-size: 14px;
color: #909399;
}
}
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.3s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
@keyframes popIn {
0% {
transform: scale(0.6);
opacity: 0;
}
100% {
transform: scale(1);
opacity: 1;
}
}
@keyframes pulse {
0%, 100% {
transform: scale(1);
}
50% {
transform: scale(1.1);
}
}
@media (max-width: 768px) {
.mes-page {
padding: 12px;
}
.code-section {
flex-direction: column;
gap: 20px;
}
.model-row {
flex-direction: column;
.fetch-btn {
width: 100%;
}
}
.mes-form {
:deep(.el-col) {
max-width: 100%;
flex: 0 0 100%;
}
}
}
</style>
Loading…
Cancel
Save