Compare commits

...

16 Commits
main ... zlx

Author SHA1 Message Date
zhoulexin 74698ae327 fix:mes表单默认值 6 days ago
zhoulexin 3d8ff93898 fix:更新代码 1 week ago
leohyper 5ffc99f255 fix:更新 2 weeks ago
zhoulexin 70f0113dcc fix:分割扫描器数据 2 weeks ago
zhoulexin cd669cd62f fix:更新优化新视频流窗口 2 weeks ago
zhoulexin e8cb4e399c fix:新增侧相机视频窗口 2 weeks ago
zhoulexin 0bc2be5a42 fix:优化切换判定 2 weeks ago
zhoulexin 06b15e74ea fix:优化代码 2 weeks ago
zhoulexin 1187e67665 feat:mes流程模块 3 weeks ago
zhoulexin ed0ba51647 fix:优化代码 4 weeks ago
zhoulexin 913472fa39 fix:优化图表 1 month ago
zhoulexin e59a59eec8 fix:优化警告标题 1 month ago
zhoulexin f9e89d5bf9 fix:完成实时流检测联调 1 month ago
zhoulexin 4bbb398c46 fix:导出功能 1 month ago
zhoulexin 8ff1b05f22 fix:联调算法 1 month ago
zhoulexin 69259d9de7 feat:初始化代码 1 month ago

@ -0,0 +1,9 @@
# 开发环境配置
VITE_APP_TITLE = 'SOP作业检测系统'
# 后端接口地址
VITE_API_BASE_IP = '127.0.0.1'
VITE_API_BASE_URL = 'http://${VITE_API_BASE_IP}:8288/api'
# 请求超时时间(毫秒)
VITE_API_TIMEOUT = 15000

@ -0,0 +1,8 @@
# 生产环境配置
VITE_APP_TITLE = 'SOP作业检测系统'
# 后端接口地址
VITE_API_BASE_URL = '/api'
# 请求超时时间(毫秒)
VITE_API_TIMEOUT = 15000

24
.gitignore vendored

@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

@ -0,0 +1,13 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>SOP作业检测系统</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>

3015
package-lock.json generated

File diff suppressed because it is too large Load Diff

@ -0,0 +1,30 @@
{
"name": "sop-system",
"private": true,
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"@element-plus/icons-vue": "^2.3.1",
"axios": "^1.18.1",
"echarts": "^6.1.0",
"element-plus": "^2.9.7",
"file-saver": "^2.0.5",
"jszip": "^3.10.1",
"pinia": "^2.3.0",
"vue": "^3.5.38",
"vue-router": "^4.5.0",
"whammy": "^0.0.1"
},
"devDependencies": {
"@vitejs/plugin-vue": "^6.0.7",
"sass": "^1.83.0",
"unplugin-auto-import": "^0.19.0",
"unplugin-vue-components": "^0.28.0",
"vite": "^8.1.0"
}
}

File diff suppressed because it is too large Load Diff

@ -0,0 +1,3 @@
allowBuilds:
'@parcel/watcher': false
vue-demi: false

@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" fill="none">
<rect width="32" height="32" rx="6" fill="#2d5af0"/>
<path d="M8 10h6v12H8V10zm10 0h6v12h-6V10z" fill="white" opacity="0.9"/>
<path d="M11 14h12M11 18h12" stroke="white" stroke-width="1.5" stroke-linecap="round" opacity="0.5"/>
<circle cx="20" cy="12" r="2" fill="#22c55e"/>
</svg>

After

Width:  |  Height:  |  Size: 363 B

@ -0,0 +1,11 @@
<template>
<router-view />
</template>
<script setup>
import { useUserStore } from '@/stores/user'
import { computed } from 'vue'
const userStore = useUserStore()
const isLoggedIn = computed(() => userStore.isLoggedIn)
</script>

@ -0,0 +1,100 @@
import request from '@/utils/request'
// ============ 登录相关 ============
/**
* 账号密码登录
* @param {Object} data - { username, password }
*/
export function loginApi(data) {
return request({
url: '/auth/login',
method: 'post',
data
})
}
/**
* 获取用户信息
*/
export function getUserInfoApi() {
return request({
url: '/user/info',
method: 'get'
})
}
// ============ 后续在此添加业务接口 ============
/**
* 示例获取首页统计数据
*/
export function getHomeStatsApi() {
return request({
url: '/home/stats',
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
})
}

@ -0,0 +1,238 @@
<template>
<div class="webrtc-wrapper">
<video
ref="videoRef"
autoplay
muted
playsinline
class="video-player"
></video>
<div v-if="loading" class="loading-overlay">
<span class="spinner"></span>
<span>{{ loadingText }}</span>
</div>
<div v-if="error" class="error-overlay">{{ error }}</div>
</div>
</template>
<script setup>
import { ref, onMounted, onUnmounted, watch } from 'vue'
const props = defineProps({
src: { type: String, required: true }
})
const emit = defineEmits(['connection-status'])
const videoRef = ref(null)
const error = ref('')
const loading = ref(false)
const loadingText = ref('连接中...')
let pc = null
let sessionUrl = ''
let queuedCandidates = []
let offerData = null
let retryTimer = null
let retryCount = 0
const MAX_RETRIES = 30
const RETRY_INTERVAL = 2000
let destroyed = false
const whepUrl = () => {
const base = props.src.replace(/\/+$/, '')
return base + '/whep'
}
function parseOffer(sdp) {
const ret = { iceUfrag: '', icePwd: '', medias: [] }
for (const line of sdp.split('\r\n')) {
if (line.startsWith('m=')) ret.medias.push(line.slice('m='.length))
else if (ret.iceUfrag === '' && line.startsWith('a=ice-ufrag:')) ret.iceUfrag = line.slice('a=ice-ufrag:'.length)
else if (ret.icePwd === '' && line.startsWith('a=ice-pwd:')) ret.icePwd = line.slice('a=ice-pwd:'.length)
}
return ret
}
function generateSdpFragment(od, candidates) {
const byMedia = {}
for (const c of candidates) {
const mid = c.sdpMLineIndex
if (!byMedia[mid]) byMedia[mid] = []
byMedia[mid].push(c)
}
let frag = 'a=ice-ufrag:' + od.iceUfrag + '\r\n' + 'a=ice-pwd:' + od.icePwd + '\r\n'
let mid = 0
for (const media of od.medias) {
if (byMedia[mid]) {
frag += 'm=' + media + '\r\n' + 'a=mid:' + mid + '\r\n'
for (const c of byMedia[mid]) frag += 'a=' + c.candidate + '\r\n'
}
mid++
}
return frag
}
function linkToIceServers(links) {
if (!links) return []
return links.split(', ').map((link) => {
const m = link.match(/^<(.+?)>; rel="ice-server"(; username="(.*?)"; credential="(.*?)"; credential-type="password")?/i)
const ret = { urls: [m[1]] }
if (m[3] !== undefined) {
ret.username = JSON.parse(`"${m[3]}"`)
ret.credential = JSON.parse(`"${m[4]}"`)
ret.credentialType = 'password'
}
return ret
})
}
function sendLocalCandidates(candidates) {
fetch(sessionUrl, {
method: 'PATCH',
headers: { 'Content-Type': 'application/trickle-ice-sdpfrag', 'If-Match': '*' },
body: generateSdpFragment(offerData, candidates)
}).catch(() => {})
}
function cleanupConnection() {
if (pc) { pc.close(); pc = null }
if (videoRef.value) videoRef.value.srcObject = null
sessionUrl = ''
queuedCandidates = []
offerData = null
}
function cancelRetry() {
if (retryTimer) {
clearTimeout(retryTimer)
retryTimer = null
}
}
async function connect() {
if (!props.src || destroyed) return
cancelRetry()
cleanupConnection()
loading.value = true
loadingText.value = retryCount > 0 ? `连接中... (第 ${retryCount} 次重试)` : '连接中...'
error.value = ''
try {
const optionsRes = await fetch(whepUrl(), { method: 'OPTIONS' })
const iceServers = linkToIceServers(optionsRes.headers.get('Link'))
pc = new RTCPeerConnection({ iceServers, sdpSemantics: 'unified-plan' })
pc.addTransceiver('video', { direction: 'sendrecv' })
pc.addTransceiver('audio', { direction: 'sendrecv' })
pc.ontrack = (event) => {
if (videoRef.value && event.streams[0]) {
videoRef.value.srcObject = event.streams[0]
emit('connection-status', true)
retryCount = 0
loading.value = false
error.value = ''
}
}
pc.onicecandidate = (evt) => {
if (evt.candidate !== null) {
if (sessionUrl) sendLocalCandidates([evt.candidate])
else queuedCandidates.push(evt.candidate)
}
}
const offer = await pc.createOffer()
offerData = parseOffer(offer.sdp)
await pc.setLocalDescription(offer)
const res = await fetch(whepUrl(), {
method: 'POST',
headers: { 'Content-Type': 'application/sdp' },
body: offer.sdp
})
if (!res.ok) {
const errBody = await res.text().catch(() => '')
const isNotPublishing = errBody.includes('no one is publishing') || errBody.includes('no stream is available')
if (isNotPublishing) throw new Error('NOT_PUBLISHING')
throw new Error(errBody || `HTTP ${res.status}`)
}
const location = res.headers.get('location')
if (location) sessionUrl = new URL(location, whepUrl()).toString()
const answer = await res.text()
await pc.setRemoteDescription({ type: 'answer', sdp: answer })
if (queuedCandidates.length > 0 && sessionUrl) {
sendLocalCandidates(queuedCandidates)
queuedCandidates = []
}
retryCount = 0
} catch (e) {
const isNotPublishing = e.message === 'NOT_PUBLISHING'
cleanupConnection()
if (isNotPublishing && retryCount < MAX_RETRIES && !destroyed) {
retryCount++
loadingText.value = `等待推流中... (${retryCount}/${MAX_RETRIES})`
retryTimer = setTimeout(() => connect(), RETRY_INTERVAL)
} else {
error.value = isNotPublishing ? '视频流尚未就绪,请稍后重试' : '连接失败,请检查视频地址'
emit('connection-status', false)
loading.value = false
console.error('[WebRTC]', e.message === 'NOT_PUBLISHING' ? 'stream not publishing' : e)
}
}
}
watch(() => props.src, (newSrc, oldSrc) => {
if (newSrc && newSrc !== oldSrc) {
retryCount = 0
cancelRetry()
connect()
}
})
onMounted(() => connect())
onUnmounted(() => {
destroyed = true
cancelRetry()
cleanupConnection()
})
</script>
<style scoped>
.webrtc-wrapper { width: 100%; height: 100%; position: relative; background: #000; }
.video-player { width: 100%; height: 100%; object-fit: contain; }
.error-overlay { position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); color: white; }
.loading-overlay {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
color: rgba(255, 255, 255, 0.85);
font-size: 14px;
display: flex;
align-items: center;
gap: 8px;
}
.spinner {
width: 20px;
height: 20px;
border: 2px solid rgba(255, 255, 255, 0.3);
border-top-color: #409eff;
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
</style>

@ -0,0 +1,134 @@
<template>
<header class="app-header">
<div class="header-left">
<div class="breadcrumb">
<el-icon class="breadcrumb-icon" :size="16"><HomeFilled /></el-icon>
<span class="breadcrumb-text">工作台</span>
</div>
</div>
<div class="header-right">
<el-dropdown trigger="click" placement="bottom-end">
<div class="user-info">
<el-avatar :size="32" class="user-avatar">
<el-icon :size="18"><UserFilled /></el-icon>
</el-avatar>
<span class="user-name">{{ userStore.realName || userStore.username }}</span>
<el-icon class="arrow-down" :size="14"><ArrowDown /></el-icon>
</div>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item divided @click="handleLogout">
<el-icon><SwitchButton /></el-icon>
退出登录
</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</div>
</header>
</template>
<script setup>
import { useRouter } from 'vue-router'
import { useUserStore } from '@/stores/user'
import { useAppStore } from '@/stores/app'
const router = useRouter()
const userStore = useUserStore()
const appStore = useAppStore()
function handleLogout() {
userStore.logout()
router.push('/login')
}
</script>
<style lang="scss" scoped>
.app-header {
height: $header-height;
background: $bg-white;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 24px;
border-bottom: 1px solid $border-color;
flex-shrink: 0;
}
.header-left {
.breadcrumb {
display: flex;
align-items: center;
gap: 6px;
.breadcrumb-icon {
color: $primary-color;
}
.breadcrumb-text {
font-size: 14px;
color: $text-primary;
font-weight: 500;
}
}
}
.header-right {
display: flex;
align-items: center;
gap: 16px;
.header-actions {
.action-btn {
width: 36px;
height: 36px;
display: flex;
align-items: center;
justify-content: center;
border-radius: $radius-sm;
cursor: pointer;
transition: $transition;
color: $text-secondary;
&:hover {
background: $bg-color;
color: $primary-color;
}
.badge-item {
display: flex;
align-items: center;
justify-content: center;
}
}
}
.user-info {
display: flex;
align-items: center;
gap: 8px;
padding: 4px 10px 4px 4px;
border-radius: $radius-md;
cursor: pointer;
transition: $transition;
&:hover {
background: $bg-color;
}
.user-avatar {
background: linear-gradient(135deg, $primary-color, $primary-light);
}
.user-name {
font-size: 14px;
font-weight: 500;
color: $text-primary;
}
.arrow-down {
color: $text-light;
}
}
}
</style>

@ -0,0 +1,63 @@
<template>
<div class="main-layout">
<SideMenu />
<div class="main-container" :class="{ collapsed: appStore.sidebarCollapsed }">
<AppHeader />
<div class="main-content">
<router-view :key="$route.fullPath" />
</div>
</div>
</div>
</template>
<script setup>
import SideMenu from './SideMenu.vue'
import AppHeader from './AppHeader.vue'
import { useAppStore } from '@/stores/app'
const appStore = useAppStore()
</script>
<style lang="scss" scoped>
.main-layout {
display: flex;
width: 100%;
height: 100vh;
overflow: hidden;
}
.main-container {
flex: 1;
display: flex;
flex-direction: column;
margin-left: $sidebar-width;
transition: $transition;
min-width: 0;
&.collapsed {
margin-left: 64px;
}
}
.main-content {
flex: 1;
padding: 20px;
overflow-y: auto;
background-color: $bg-color;
}
.fade-slide-enter-active,
.fade-slide-leave-active {
transition: all 0.3s ease;
}
.fade-slide-enter-from {
opacity: 0;
transform: translateY(8px);
}
.fade-slide-leave-to {
opacity: 0;
transform: translateY(-8px);
}
</style>

@ -0,0 +1,182 @@
<template>
<aside class="sidebar" :class="{ collapsed: appStore.sidebarCollapsed }">
<div class="logo-area">
<div class="logo-icon">
<svg viewBox="0 0 40 40" width="36" height="36">
<rect width="40" height="40" rx="10" fill="url(#logoGrad)"/>
<path d="M11 14h7v12H11V14zm12 0h7v12h-7V14z" fill="white" opacity="0.85"/>
<path d="M14 18h12M14 22h12" stroke="white" stroke-width="1.5" stroke-linecap="round" opacity="0.45"/>
<defs>
<linearGradient id="logoGrad" x1="0" y1="0" x2="40" y2="40">
<stop offset="0%" stop-color="#2d5af0"/>
<stop offset="100%" stop-color="#6366f1"/>
</linearGradient>
</defs>
</svg>
</div>
<transition name="fade">
<span v-show="!appStore.sidebarCollapsed" class="logo-title">SOP</span>
</transition>
</div>
<nav class="menu-list">
<div
v-for="item in menuItems"
:key="item.path"
class="menu-item"
:class="{ active: activeMenu === item.path }"
@click="handleMenuClick(item)"
>
<el-icon class="menu-icon" :size="20">
<component :is="item.icon" />
</el-icon>
<span v-show="!appStore.sidebarCollapsed" class="menu-label">{{ item.title }}</span>
</div>
</nav>
<div class="sidebar-footer">
<div class="collapse-btn" @click="appStore.toggleSidebar()">
<el-icon :size="18">
<Fold v-if="!appStore.sidebarCollapsed" />
<Expand v-else />
</el-icon>
</div>
</div>
</aside>
</template>
<script setup>
import { computed } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useAppStore } from '@/stores/app'
const route = useRoute()
const router = useRouter()
const appStore = useAppStore()
const menuItems = [
{ path: '/mes', title: 'MES检测', icon: 'Monitor' },
{ path: '/home', title: '检测页', icon: 'HomeFilled' }
//
]
const activeMenu = computed(() => {
return route.path
})
function handleMenuClick(item) {
router.push(item.path)
}
</script>
<style lang="scss" scoped>
.sidebar {
position: fixed;
left: 0;
top: 0;
bottom: 0;
width: $sidebar-width;
background: linear-gradient(180deg, #1a1f2e 0%, #1e2538 100%);
display: flex;
flex-direction: column;
z-index: 100;
transition: $transition;
overflow: hidden;
&.collapsed {
width: 64px;
}
}
.logo-area {
display: flex;
align-items: center;
gap: 10px;
padding: 18px 16px;
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
.logo-icon {
flex-shrink: 0;
}
.logo-title {
font-size: 16px;
font-weight: 700;
color: #ffffff;
white-space: nowrap;
letter-spacing: 0.5px;
}
}
.menu-list {
flex: 1;
padding: 12px 8px;
display: flex;
flex-direction: column;
gap: 4px;
}
.menu-item {
display: flex;
align-items: center;
gap: 10px;
padding: 12px 14px;
border-radius: $radius-md;
color: rgba(255, 255, 255, 0.6);
cursor: pointer;
transition: $transition;
white-space: nowrap;
.menu-icon {
flex-shrink: 0;
}
.menu-label {
font-size: 14px;
font-weight: 500;
}
&:hover {
background: rgba(255, 255, 255, 0.08);
color: rgba(255, 255, 255, 0.9);
}
&.active {
background: rgba(45, 90, 240, 0.25);
color: #ffffff;
.menu-icon {
color: #7b9ffa;
}
}
}
.sidebar-footer {
padding: 12px 16px;
border-top: 1px solid rgba(255, 255, 255, 0.06);
.collapse-btn {
display: flex;
align-items: center;
justify-content: center;
padding: 8px;
border-radius: $radius-sm;
color: rgba(255, 255, 255, 0.4);
cursor: pointer;
transition: $transition;
&:hover {
background: rgba(255, 255, 255, 0.08);
color: rgba(255, 255, 255, 0.8);
}
}
}
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.2s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
</style>

@ -0,0 +1,30 @@
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import ElementPlus from 'element-plus'
import zhCn from 'element-plus/es/locale/lang/zh-cn'
import * as ElementPlusIconsVue from '@element-plus/icons-vue'
import App from './App.vue'
import router from './router'
import { createPersistPlugin } from '@/stores/plugins/persist'
import './styles/global.scss'
import 'element-plus/dist/index.css'
const app = createApp(App)
// Pinia 实例化并注册持久化插件
const pinia = createPinia()
pinia.use(createPersistPlugin({
whiteList: ['token', 'userInfo']
}))
// 全局注册 Element Plus Icon
for (const [key, component] of Object.entries(ElementPlusIconsVue)) {
app.component(key, component)
}
app.use(pinia)
app.use(router)
app.use(ElementPlus, { locale: zhCn })
app.mount('#app')

@ -0,0 +1,59 @@
import { createRouter, createWebHistory } from 'vue-router'
import { useUserStore } from '@/stores/user'
const routes = [
{
path: '/',
redirect: '/mes'
},
{
path: '/login',
name: 'Login',
component: () => import('@/views/login/index.vue'),
meta: { title: '登录', noAuth: true }
},
{
path: '/',
component: () => import('@/layout/MainLayout.vue'),
children: [
{
path: 'mes',
name: 'Mes',
component: () => import('@/views/mes/index.vue'),
meta: { title: 'MES检测', icon: 'Monitor' }
},
{
path: 'home',
name: 'Home',
component: () => import('@/views/home/index.vue'),
meta: { title: '检测页', icon: 'HomeFilled' }
}
// 后续在此添加新菜单路由
]
}
]
const router = createRouter({
history: createWebHistory(),
routes
})
router.beforeEach((to, from, next) => {
const userStore = useUserStore()
document.title = `${to.meta.title || ''} - SOP作业检测系统`
if (to.meta.noAuth) {
next()
return
}
if (!userStore.isLoggedIn) {
next({ name: 'Login', query: { redirect: to.fullPath } })
return
}
next()
})
export default router

@ -0,0 +1,15 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
export const useAppStore = defineStore('app', () => {
const sidebarCollapsed = ref(false)
function toggleSidebar() {
sidebarCollapsed.value = !sidebarCollapsed.value
}
return {
sidebarCollapsed,
toggleSidebar
}
})

@ -0,0 +1,64 @@
/**
* Pinia 持久化插件
* 将指定 store 的状态自动同步到 localStorage刷新页面不丢失
*/
const STORAGE_PREFIX = 'sop_'
function getStorageKey(storeId) {
return STORAGE_PREFIX + storeId
}
export function createPersistPlugin(options = {}) {
const {
keyPrefix = STORAGE_PREFIX,
whiteList = [], // 需要持久化的字段,为空则全部持久化
storage = localStorage
} = options
return ({ store }) => {
const storageKey = getStorageKey(store.$id)
// 初始化:从 storage 恢复状态
try {
const stored = storage.getItem(storageKey)
if (stored) {
const parsed = JSON.parse(stored)
Object.keys(parsed).forEach((key) => {
if (whiteList.length === 0 || whiteList.includes(key)) {
store[key] = parsed[key]
}
})
}
} catch (e) {
console.error(`[persist] 读取 ${storageKey} 失败:`, e)
}
// 监听变化:状态更新时写入 storage
store.$subscribe((mutation, state) => {
try {
const toSave = {}
const keys = whiteList.length > 0 ? whiteList : Object.keys(state)
keys.forEach((key) => {
if (key in state && !key.startsWith('$')) {
toSave[key] = state[key]
}
})
storage.setItem(storageKey, JSON.stringify(toSave))
} catch (e) {
console.error(`[persist] 写入 ${storageKey} 失败:`, e)
}
})
// 提供清除方法
store.$persistClear = () => {
try {
storage.removeItem(storageKey)
} catch (e) {
console.error(`[persist] 清除 ${storageKey} 失败:`, e)
}
}
}
}

@ -0,0 +1,40 @@
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import { loginApi } from '@/api'
export const useUserStore = defineStore('user', () => {
const token = ref('')
const userInfo = ref(null)
const isLoggedIn = computed(() => !!token.value)
const username = computed(() => userInfo.value?.username || '')
const realName = computed(() => userInfo.value?.realName || '')
/**
* 登录
* @param {Object} credentials - { username, password }
*/
async function login(credentials) {
const data = await loginApi(credentials)
// 后端返回的 data 结构:
// { token, tokenType, userId, username, realName, avatar, role, roleName, schoolName }
token.value = data.token
userInfo.value = data
return data
}
function logout() {
token.value = ''
userInfo.value = null
}
return {
token,
userInfo,
isLoggedIn,
username,
realName,
login,
logout
}
})

@ -0,0 +1,44 @@
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
html, body {
width: 100%;
height: 100%;
font-family: 'Inter', 'PingFang SC', 'Microsoft YaHei', -apple-system, sans-serif;
font-size: 14px;
color: #1e293b;
background-color: #f0f4f8;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
#app {
width: 100%;
height: 100%;
}
a {
text-decoration: none;
color: inherit;
}
::-webkit-scrollbar {
width: 6px;
height: 6px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: #cbd5e1;
border-radius: 3px;
&:hover {
background: #94a3b8;
}
}

@ -0,0 +1,26 @@
// SCSS
$primary-color: #2d5af0;
$primary-light: #5b7ff5;
$primary-dark: #1a3fb8;
$success-color: #22c55e;
$warning-color: #f59e0b;
$danger-color: #ef4444;
$info-color: #6366f1;
$bg-color: #f0f4f8;
$bg-white: #ffffff;
$text-primary: #1e293b;
$text-secondary: #64748b;
$text-light: #94a3b8;
$border-color: #e2e8f0;
$sidebar-width: 240px;
$header-height: 56px;
$radius-sm: 6px;
$radius-md: 10px;
$radius-lg: 16px;
$shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.06);
$shadow-md: 0 4px 12px rgba(0, 0, 0, 0.08);
$shadow-lg: 0 8px 24px rgba(0, 0, 0, 0.1);
$transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);

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

@ -0,0 +1,87 @@
import axios from 'axios'
import { ElMessage } from 'element-plus'
import { useUserStore } from '@/stores/user'
import router from '@/router'
// 创建 axios 实例
const request = axios.create({
baseURL: import.meta.env.VITE_API_BASE_URL,
timeout: Number(import.meta.env.VITE_API_TIMEOUT) || 15000,
headers: {
'Content-Type': 'application/json'
}
})
// 请求拦截器
request.interceptors.request.use(
(config) => {
const userStore = useUserStore()
// 如果有 token 则携带
if (userStore.token) {
config.headers.Authorization = `Bearer ${userStore.token}`
}
return config
},
(error) => {
console.error('请求错误:', error)
return Promise.reject(error)
}
)
// 响应拦截器
request.interceptors.response.use(
(response) => {
const res = response.data
// 根据后端约定的数据结构判断
// 假设后端返回格式: { code: 200, data: {...}, message: 'success' }
if (res.code === 200 || res.code === 0) {
return res.data
}
// 业务错误
ElMessage.error(res.message || '请求失败')
return Promise.reject(new Error(res.message || '请求失败'))
},
(error) => {
const { response } = error
if (response) {
const { status, data } = response
switch (status) {
case 401:
// token 过期或未登录
ElMessage.error('登录已过期,请重新登录')
const userStore = useUserStore()
userStore.logout()
router.push({
name: 'Login',
query: { redirect: router.currentRoute.value.fullPath }
})
break
case 403:
ElMessage.error('没有权限访问该资源')
break
case 404:
ElMessage.error('请求的资源不存在')
break
case 500:
ElMessage.error('服务器内部错误')
break
default:
ElMessage.error(data?.message || `请求错误 (${status})`)
}
} else if (error.code === 'ECONNABORTED') {
ElMessage.error('请求超时,请检查网络连接')
} else {
ElMessage.error('网络异常,请检查网络连接')
}
return Promise.reject(error)
}
)
export default request

@ -0,0 +1,400 @@
import { ElMessage } from 'element-plus'
import { useUserStore } from '@/stores/user'
/**
* WebSocket 长连接管理类
* 支持心跳检测自动重连消息分发等功能
*/
class WebSocketClient {
constructor(options = {}) {
this.url = options.url || '' // WebSocket 地址
this.protocols = options.protocols || [] // 子协议
this.reconnectInterval = options.reconnectInterval || 3000 // 重连间隔ms
this.maxReconnectTimes = options.maxReconnectTimes || 10 // 最大重连次数
this.heartbeatInterval = options.heartbeatInterval || 30000 // 心跳间隔ms
this.heartbeatTimeout = options.heartbeatTimeout || 5000 // 心跳超时时间ms
this.autoReconnect = options.autoReconnect !== false // 是否自动重连,默认 true
this.debug = options.debug || false // 调试模式
this.binaryType = options.binaryType || 'arraybuffer' // 二进制数据类型
this.ws = null // WebSocket 实例
this.reconnectTimes = 0 // 当前重连次数
this.reconnectTimer = null // 重连定时器
this.heartbeatTimer = null // 心跳定时器
this.heartbeatCheckTimer = null // 心跳检测定时器
this.isConnected = false // 是否已连接
this.isDestroyed = false // 是否已销毁
this.pendingMessages = [] // 待发送消息队列
// 事件监听器
this.listeners = {
open: [],
close: [],
error: [],
message: [],
reconnect: [],
heartbeat: []
}
// 心跳消息内容
this.pingMessage = options.pingMessage || JSON.stringify({ type: 'ping' })
this.pongMessage = options.pongMessage || JSON.stringify({ type: 'pong' })
// 绑定 this
this._onOpen = this._onOpen.bind(this)
this._onClose = this._onClose.bind(this)
this._onError = this._onError.bind(this)
this._onMessage = this._onMessage.bind(this)
}
/**
* 连接 WebSocket
* @param {string} url - 可选的连接地址不传则使用初始化时的地址
*/
connect(url) {
if (this.isDestroyed) return
if (url) {
this.url = url
}
if (!this.url) {
console.error('[WebSocket] 连接地址不能为空')
return
}
// 如果已有连接,先关闭
if (this.ws) {
this.close()
}
this._log('正在连接 WebSocket:', this.url)
try {
if (this.protocols && this.protocols.length > 0) {
this.ws = new WebSocket(this.url, this.protocols)
} else {
this.ws = new WebSocket(this.url)
}
this.ws.onopen = this._onOpen
this.ws.onclose = this._onClose
this.ws.onerror = this._onError
this.ws.onmessage = this._onMessage
this.ws.binaryType = this.binaryType
} catch (err) {
console.error('[WebSocket] 创建连接失败:', err)
this._emit('error', err)
this._tryReconnect()
}
}
/**
* 关闭连接
*/
close() {
this._clearHeartbeat()
this._clearReconnectTimer()
if (this.ws) {
try {
this.ws.onopen = null
this.ws.onclose = null
this.ws.onerror = null
this.ws.onmessage = null
this.ws.close(1000, '客户端主动关闭')
} catch (err) {
this._log('关闭连接出错:', err)
}
this.ws = null
}
this.isConnected = false
}
/**
* 销毁实例彻底释放资源
*/
destroy() {
this.isDestroyed = true
this.close()
this.listeners.open = []
this.listeners.close = []
this.listeners.error = []
this.listeners.message = []
this.listeners.reconnect = []
this.listeners.heartbeat = []
this.pendingMessages = []
}
/**
* 发送消息
* @param {string|Object} data - 要发送的数据
*/
send(data) {
const message = typeof data === 'object' ? JSON.stringify(data) : String(data)
if (this.isConnected && this.ws && this.ws.readyState === WebSocket.OPEN) {
this.ws.send(message)
return true
}
// 未连接时加入待发送队列
this.pendingMessages.push(message)
this._log('消息加入待发送队列,当前队列长度:', this.pendingMessages.length)
return false
}
/**
* 发送二进制数据ArrayBuffer用于文件切片上传等场景
* @param {ArrayBuffer} buffer - 二进制数据
*/
sendBinary(buffer) {
if (this.isConnected && this.ws && this.ws.readyState === WebSocket.OPEN) {
this.ws.send(buffer)
return true
}
return false
}
// ==================== 事件监听 ====================
on(event, callback) {
if (this.listeners[event]) {
this.listeners[event].push(callback)
}
return this
}
off(event, callback) {
if (this.listeners[event]) {
if (callback) {
this.listeners[event] = this.listeners[event].filter((cb) => cb !== callback)
} else {
this.listeners[event] = []
}
}
return this
}
// ==================== 内置事件处理 ====================
_onOpen(event) {
this.isConnected = true
this.reconnectTimes = 0
this._log('WebSocket 已连接')
this._startHeartbeat()
this._emit('open', event)
// 发送认证信息(带 token
// this._sendAuthMessage()
// 发送待发送队列中的消息
this._flushPendingMessages()
}
_onClose(event) {
this.isConnected = false
this._clearHeartbeat()
this._log('WebSocket 已关闭, code:', event.code, 'reason:', event.reason)
this._emit('close', event)
// 非主动关闭时自动重连code 1000 表示主动关闭)
if (event.code !== 1000 && this.autoReconnect && !this.isDestroyed) {
this._tryReconnect()
}
}
_onError(event) {
this._log('WebSocket 发生错误:', event)
this._emit('error', event)
}
_onMessage(event) {
try {
const data = JSON.parse(event.data)
this._log('收到消息:', data)
// 任何服务端消息都视为心跳信号,重置超时检测
if (this.heartbeatCheckTimer) {
clearTimeout(this.heartbeatCheckTimer)
this.heartbeatCheckTimer = null
}
// 处理心跳回复
if (data.type === 'pong') {
return
}
// 处理服务端 ping
if (data.type === 'ping') {
this.send(this.pongMessage)
return
}
this._emit('message', data)
} catch {
// 非 JSON 格式如二进制文件chunk接收也重置心跳
if (this.heartbeatCheckTimer) {
clearTimeout(this.heartbeatCheckTimer)
this.heartbeatCheckTimer = null
}
this._emit('message', event.data)
}
}
// ==================== 认证 ====================
_sendAuthMessage() {
try {
const userStore = useUserStore()
if (userStore.token) {
this.send({
type: 'auth',
token: userStore.token
})
}
} catch {
this._log('获取 token 失败,跳过认证')
}
}
// ==================== 心跳机制 ====================
_startHeartbeat() {
this._clearHeartbeat()
// heartbeatInterval 为 0 或非正值时关闭主动心跳
if (!this.heartbeatInterval || this.heartbeatInterval <= 0) return
// 定时发送 ping
this.heartbeatTimer = setInterval(() => {
if (this.isConnected && this.ws) {
this.ws.send(this.pingMessage)
this._log('发送心跳 ping')
// 设置超时检测
this.heartbeatCheckTimer = setTimeout(() => {
this._log('心跳超时,准备重连')
this._emit('heartbeat', { status: 'timeout' })
this.close()
this._tryReconnect()
}, this.heartbeatTimeout)
}
}, this.heartbeatInterval)
}
_clearHeartbeat() {
if (this.heartbeatTimer) {
clearInterval(this.heartbeatTimer)
this.heartbeatTimer = null
}
if (this.heartbeatCheckTimer) {
clearTimeout(this.heartbeatCheckTimer)
this.heartbeatCheckTimer = null
}
}
// ==================== 重连机制 ====================
_tryReconnect() {
if (this.isDestroyed || !this.autoReconnect) return
if (this.reconnectTimes >= this.maxReconnectTimes) {
this._log('已达到最大重连次数,停止重连')
ElMessage.error('连接服务器失败,请刷新页面重试')
return
}
this.reconnectTimes++
this._log(`${this.reconnectTimes} 次重连...`)
this._emit('reconnect', {
times: this.reconnectTimes,
maxTimes: this.maxReconnectTimes
})
// 指数退避:重连间隔逐渐增大(最大 30 秒)
const interval = Math.min(
this.reconnectInterval * Math.pow(1.5, this.reconnectTimes - 1),
30000
)
this._clearReconnectTimer()
this.reconnectTimer = setTimeout(() => {
this.connect()
}, interval)
}
_clearReconnectTimer() {
if (this.reconnectTimer) {
clearTimeout(this.reconnectTimer)
this.reconnectTimer = null
}
}
// ==================== 内部工具方法 ====================
_emit(event, data) {
if (this.listeners[event]) {
this.listeners[event].forEach((callback) => {
try {
callback(data)
} catch (err) {
console.error(`[WebSocket] ${event} 事件回调出错:`, err)
}
})
}
}
_flushPendingMessages() {
while (this.pendingMessages.length > 0) {
const msg = this.pendingMessages.shift()
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
this.ws.send(msg)
}
}
}
_log(...args) {
if (this.debug) {
console.log('[WebSocket]', ...args)
}
}
/**
* 获取当前连接状态
* @returns {number} 0-未连接, 1-已连接, 2-正在连接, 3-正在关闭
*/
getReadyState() {
return this.ws ? this.ws.readyState : WebSocket.CLOSED
}
/**
* 是否已连接
*/
get connected() {
return this.isConnected
}
}
// 单例模式 - 全局默认 WebSocket 实例(可选使用)
let defaultInstance = null
/**
* 获取或创建全局默认 WebSocket 实例
* @param {Object} options - 配置选项
* @returns {WebSocketClient}
*/
export function useWebSocket(options = {}) {
if (!defaultInstance || options.forceNew) {
if (defaultInstance && options.forceNew) {
defaultInstance.destroy()
}
defaultInstance = new WebSocketClient(options)
}
return defaultInstance
}
export default WebSocketClient

@ -0,0 +1,41 @@
import JSZip from 'jszip'
import { saveAs } from 'file-saver'
/**
* URL 获取文件数据返回 ArrayBuffer
*/
async function fetchFileAsArrayBuffer(url) {
const response = await fetch(url)
if (!response.ok) {
throw new Error(`下载文件失败: ${url} (${response.status})`)
}
return await response.arrayBuffer()
}
/**
* 将多个文件打包成 ZIP 并触发下载
* @param {Array<{name: string, url: string}>} files 文件名和远程 URL
* @param {string} outputFilename 输出的 ZIP 文件名 "检测结果_20260628.zip"
*/
export async function downloadAsZip(files, outputFilename) {
const zip = new JSZip()
// 并行下载所有文件
const results = await Promise.allSettled(
files.map(async (file) => {
const data = await fetchFileAsArrayBuffer(file.url)
zip.file(file.name, data, { binary: true })
})
)
// 检查是否有下载失败
const failures = results.filter(r => r.status === 'rejected')
if (failures.length > 0) {
const errors = failures.map(r => r.reason.message).join('; ')
throw new Error(`部分文件下载失败: ${errors}`)
}
// 生成 ZIP 并触发下载
const blob = await zip.generateAsync({ type: 'blob' })
saveAs(blob, outputFilename)
}

@ -0,0 +1,174 @@
<template>
<div class="charts-panel">
<div class="chart-box">
<div ref="pieChartRef" class="chart"></div>
</div>
<div class="chart-box">
<div ref="barChartRef" class="chart"></div>
</div>
</div>
</template>
<script setup>
import { ref, watch, onMounted, onUnmounted, nextTick } from 'vue'
import * as echarts from 'echarts/core'
import { PieChart, BarChart } from 'echarts/charts'
import { TitleComponent, TooltipComponent, LegendComponent, GridComponent, MarkLineComponent } from 'echarts/components'
import { CanvasRenderer } from 'echarts/renderers'
echarts.use([PieChart, BarChart, TitleComponent, TooltipComponent, LegendComponent, GridComponent, MarkLineComponent, CanvasRenderer])
const props = defineProps({
processStats: { type: Object, default: () => ({ completed: 0, incomplete: 0 }) },
counts: { type: Array, default: () => [] }
})
const pieChartRef = ref(null)
const barChartRef = ref(null)
let pieInstance = null
let barInstance = null
function initCharts() {
if (pieChartRef.value) {
pieInstance = echarts.init(pieChartRef.value)
}
if (barChartRef.value) {
barInstance = echarts.init(barChartRef.value)
}
}
function updatePieChart() {
if (!pieInstance) return
const { completed, incomplete } = props.processStats
pieInstance.setOption({
title: { text: '流程完成状态', left: 'center', textStyle: { fontSize: 14 } },
tooltip: { trigger: 'item', formatter: '{b}: {c} ({d}%)' },
legend: { bottom: 25 },
series: [{
type: 'pie',
radius: ['30%', '60%'],
center: ['50%', '50%'],
data: [
{ name: '已完成', value: completed, itemStyle: { color: '#67c23a' } },
{ name: '未完成', value: incomplete, itemStyle: { color: '#e6a23c' } }
],
label: { show: true, formatter: '{b}\n{c}个' }
}]
})
}
function lightenColor(hex, amount) {
const num = parseInt(hex.slice(1), 16)
const r = Math.min(255, (num >> 16) + Math.round((255 - (num >> 16)) * amount))
const g = Math.min(255, ((num >> 8) & 0xff) + Math.round((255 - ((num >> 8) & 0xff)) * amount))
const b = Math.min(255, (num & 0xff) + Math.round((255 - (num & 0xff)) * amount))
return `rgb(${r},${g},${b})`
}
function updateBarChart() {
if (!barInstance) return
const names = props.counts.map(item => item.name)
const colors = ['#ff4500', '#FFA500', '#C0C0C0', '#32CD32', '#00CED1', '#4169E1', '#9932CC', '#FF1493', '#8B4513']
//
const actualData = props.counts.map((item, idx) => ({
value: item.count,
itemStyle: { color: colors[idx % colors.length], borderRadius: [0, 0, 4, 4] }
}))
// 9 -
const remainData = props.counts.map((item, idx) => ({
value: Math.max(0, 10 - item.count),
itemStyle: { color: lightenColor(colors[idx % colors.length], 0.7), borderRadius: [4, 4, 0, 0] }
}))
barInstance.setOption({
title: { text: '检测计数', left: 'center', textStyle: { fontSize: 14 } },
tooltip: {
trigger: 'axis',
formatter(params) {
const actual = params.find(p => p.seriesName === '实际值')
if (!actual) return ''
// return `${actual.name}<br/>${actual.marker} : ${actual.value}/9`
return `${actual.name}<br/>${actual.marker} 检测数: ${actual.value}`
}
},
grid: { left: '5%', right: '5%', bottom: '5%', top: '15%', containLabel: true },
xAxis: { type: 'category', data: names, axisLabel: { rotate: 30, fontSize: 12 } },
yAxis: {
type: 'value',
minInterval: 1,
// max: 9,
splitLine: { lineStyle: { type: 'dashed', color: '#eee' } }
},
series: [
{
type: 'bar',
name: '实际值',
stack: 'total',
barWidth: 22,
data: actualData,
// markLine: {
// silent: true,
// symbol: 'none',
// lineStyle: { color: '#e6a23c', type: 'dashed', width: 2 },
// label: {
// formatter: ': 9',
// color: '#e6a23c',
// fontSize: 12,
// position: 'insideEndTop'
// },
// data: [{ yAxis: 9 }]
// }
},
{
type: 'bar',
name: '',
stack: 'total',
barWidth: 22,
data: remainData
}
]
})
}
watch(() => ({ ...props.processStats }), updatePieChart, { deep: true })
watch(() => [...props.counts], updateBarChart, { deep: true })
onMounted(() => {
nextTick(() => {
initCharts()
updatePieChart()
updateBarChart()
})
})
onUnmounted(() => {
pieInstance?.dispose()
barInstance?.dispose()
})
</script>
<style lang="scss" scoped>
.charts-panel {
flex: 1;
display: flex;
flex-direction: column;
gap: 12px;
min-height: 0;
.chart-box {
flex: 1;
background: $bg-white;
border-radius: $radius-md;
box-shadow: $shadow-sm;
overflow: hidden;
min-height: 0;
.chart {
width: 100%;
height: 100%;
min-height: 200px;
}
}
}
</style>

@ -0,0 +1,49 @@
<template>
<div class="control-buttons">
<el-button
:type="streamRunning ? 'danger' : 'primary'"
size="default"
@click="$emit('toggleStream')"
:disabled="algorithmDisabled || streamAddressEmpty || (algorithmRunning && !streamRunning) || hasOfflineVideo"
>
{{ streamRunning ? '关闭视频流' : '启动视频流' }}
</el-button>
<el-button
:type="algorithmRunning ? 'danger' : 'success'"
size="default"
@click="$emit('toggleAlgorithm')"
:disabled="isLiveMode || offlineVideoMissing"
>
{{ algorithmRunning ? '关闭算法' : '启动算法' }}
</el-button>
</div>
</template>
<script setup>
defineProps({
streamRunning: Boolean,
algorithmRunning: Boolean,
streamAddressEmpty: Boolean,
offlineVideoMissing: Boolean,
isLiveMode: Boolean,
hasOfflineVideo: Boolean,
algorithmDisabled: Boolean
})
defineEmits(['toggleStream', 'toggleAlgorithm'])
</script>
<style lang="scss" scoped>
.control-buttons {
display: flex;
gap: 12px;
background: $bg-white;
padding: 16px;
border-radius: $radius-md;
box-shadow: $shadow-sm;
.el-button {
flex: 1;
}
}
</style>

@ -0,0 +1,64 @@
<template>
<div class="counts-card" v-if="hasCounts">
<div class="counts-header">检测计数</div>
<div class="counts-body">
<div class="count-item" v-for="(item, index) in counts" :key="index">
<span class="count-label">{{ item.name }}</span>
<span class="count-value">{{ item.count }}</span>
</div>
</div>
</div>
</template>
<script setup>
import { computed } from 'vue'
const props = defineProps({
counts: { type: Array, default: () => [] }
})
const hasCounts = computed(() => props.counts.length > 0)
</script>
<style lang="scss" scoped>
.counts-card {
background: $bg-white;
border-radius: $radius-md;
box-shadow: $shadow-sm;
overflow: hidden;
.counts-header {
padding: 10px 16px;
background: #e3f2fd;
font-size: 14px;
font-weight: 600;
color: #1565c0;
}
.counts-body {
padding: 10px 16px;
display: flex;
flex-wrap: wrap;
gap: 6px;
.count-item {
flex: 1 1 calc(50% - 6px);
display: flex;
justify-content: space-between;
padding: 6px 10px;
background: #f5f5f5;
border-radius: 6px;
font-size: 13px;
.count-label {
color: $text-secondary;
}
.count-value {
font-weight: 700;
color: $primary-color;
}
}
}
}
</style>

@ -0,0 +1,66 @@
<template>
<div class="video-bottom-controls" v-if="visible">
<div class="detect-progress">
<div class="progress-info">
<span>{{ statusText || '检测中...' }}</span>
<span class="progress-pct">{{ progress.toFixed(1) }}%</span>
</div>
<el-progress :percentage="Math.round(progress)" :stroke-width="8" />
<div class="progress-actions">
<el-button size="small" :type="paused ? 'warning' : 'info'" @click="$emit('togglePause')" :disabled="ending">
<el-icon :size="14"><component :is="paused ? 'VideoPlay' : 'VideoPause'" /></el-icon>
{{ paused ? '恢复处理' : '暂停处理' }}
</el-button>
</div>
</div>
</div>
</template>
<script setup>
defineProps({
visible: Boolean,
progress: { type: Number, default: 0 },
statusText: String,
paused: Boolean,
ending: Boolean
})
defineEmits(['togglePause'])
</script>
<style lang="scss" scoped>
.video-bottom-controls {
display: flex;
justify-content: center;
gap: 16px;
padding: 8px 0;
.detect-progress {
flex: 1;
display: flex;
flex-direction: column;
gap: 6px;
.progress-info {
display: flex;
justify-content: space-between;
font-size: 12px;
color: $text-secondary;
.progress-pct {
color: $primary-color;
font-weight: 600;
}
}
.progress-actions {
display: flex;
gap: 8px;
.el-button {
flex: 1;
}
}
}
}
</style>

@ -0,0 +1,103 @@
<template>
<div class="log-section">
<div class="log-header">流程日志</div>
<div class="log-body" ref="logBodyRef">
<div
v-for="(log, index) in logs"
:key="index"
class="log-item"
:class="'log-' + log.type"
>
<span class="log-time">{{ log.time }}</span>
<div class="log-msg">{{ log.message }}</div>
</div>
<div v-if="logs.length === 0" class="log-empty"></div>
</div>
</div>
</template>
<script setup>
import { ref, watch, nextTick } from 'vue'
const props = defineProps({
logs: { type: Array, default: () => [] }
})
const logBodyRef = ref(null)
watch(() => props.logs.length, () => {
nextTick(() => {
if (logBodyRef.value) {
logBodyRef.value.scrollTop = logBodyRef.value.scrollHeight
}
})
})
</script>
<style lang="scss" scoped>
.log-section {
flex: 1;
display: flex;
flex-direction: column;
background: #fffacd;
border-radius: $radius-md;
overflow: hidden;
box-shadow: $shadow-sm;
min-height: 0;
.log-header {
padding: 10px 16px;
background: #fff176;
font-size: 14px;
font-weight: 600;
color: $text-primary;
}
.log-body {
flex: 1;
padding: 10px 16px;
overflow-y: auto;
font-family: 'Consolas', 'Monaco', monospace;
font-size: 12px;
line-height: 1.6;
.log-item {
width: 200%;
padding: 4px 0;
border-bottom: 1px dashed rgba(0, 0, 0, 0.1);
&:last-child {
border-bottom: none;
}
.log-time {
color: #666;
margin-right: 8px;
}
.log-msg {
color: $text-primary;
white-space: pre-wrap;
}
&.log-success .log-msg {
color: $success-color;
}
&.log-error .log-msg {
color: $danger-color;
}
&.log-warning .log-msg {
color: $warning-color;
}
}
.log-empty {
text-align: center;
color: #999;
padding: 20px 0;
}
}
}
</style>

@ -0,0 +1,333 @@
<template>
<div class="video-container" ref="containerRef">
<!-- 视频标题 -->
<div v-if="title" class="video-title" :class="{ 'title-secondary': title === '侧相机' }">{{ title }}</div>
<!-- 实时流模式 -->
<WebRtcPlayer
v-if="videoMode === 'live'"
:src="webrtcUrl"
/>
<!-- 离线视频模式 -->
<div v-else-if="videoMode === 'offline'" class="offline-player">
<video
ref="offlineVideoRef"
:src="offlineVideoUrl"
class="video-element"
controls
></video>
</div>
<!-- 空状态 -->
<div v-else class="empty-video">
<el-icon :size="48"><VideoCamera /></el-icon>
<p class="empty-title">暂无视频画面</p>
<p class="empty-hint">点击上方切换加载实时流或上传离线视频</p>
</div>
<!-- 截图/录屏工具栏仅对第二个视频窗口显示 -->
<div v-if="enableScreenshot || enableRecording" class="media-toolbar">
<el-tooltip content="截图" placement="top">
<el-button size="small" circle @click="takeScreenshot">
<el-icon><Camera /></el-icon>
</el-button>
</el-tooltip>
<el-tooltip :content="recording ? '停止录屏' : '录屏'" placement="top">
<el-button
size="small"
circle
:type="recording ? 'danger' : 'default'"
@click="toggleRecording"
>
<el-icon><VideoCameraFilled /></el-icon>
</el-button>
</el-tooltip>
</div>
<!-- 录屏倒计时/状态提示 -->
<div v-if="recording" class="recording-indicator">
<span class="rec-dot"></span>
<span>REC {{ recordingTime }}</span>
</div>
</div>
</template>
<script setup>
import { ref, watch, nextTick, onUnmounted } from 'vue'
import { ElMessage } from 'element-plus'
import { Camera, VideoCameraFilled } from '@element-plus/icons-vue'
import WebRtcPlayer from '@/components/WebRtcPlayer.vue'
const props = defineProps({
videoMode: String,
webrtcUrl: String,
offlineVideoUrl: String,
videoAutoplayTrigger: Number,
title: { type: String, default: '' },
enableScreenshot: { type: Boolean, default: false },
enableRecording: { type: Boolean, default: false }
})
defineEmits(['connectionChange'])
const offlineVideoRef = ref(null)
const containerRef = ref(null)
// ============ ============
function takeScreenshot() {
const container = containerRef.value
if (!container) return
// WebRTC video
let videoEl = container.querySelector('video')
if (!videoEl) return
const canvas = document.createElement('canvas')
canvas.width = videoEl.videoWidth || 640
canvas.height = videoEl.videoHeight || 480
const ctx = canvas.getContext('2d')
ctx.drawImage(videoEl, 0, 0, canvas.width, canvas.height)
const link = document.createElement('a')
link.download = `screenshot_${Date.now()}.png`
link.href = canvas.toDataURL('image/png')
link.click()
}
// ============ ============
const recording = ref(false)
const mediaRecorder = ref(null)
const recordedChunks = ref([])
const recordingTime = ref('00:00')
let recordingTimer = null
let recordingStartTime = 0
function updateRecordingTime() {
const elapsed = Math.floor((Date.now() - recordingStartTime) / 1000)
const m = String(Math.floor(elapsed / 60)).padStart(2, '0')
const s = String(elapsed % 60).padStart(2, '0')
recordingTime.value = `${m}:${s}`
}
async function toggleRecording() {
if (recording.value) {
stopRecording()
} else {
await startRecording()
}
}
async function startRecording() {
const container = containerRef.value
if (!container) return
// video
const videoEl = container.querySelector('video')
if (!videoEl) return
try {
// WebRTC video
let stream = videoEl.captureStream ? videoEl.captureStream(30) : null
if (!stream && videoEl.srcObject) {
stream = videoEl.srcObject
}
if (!stream) {
ElMessage.warning('无法获取视频流进行录制')
return
}
// MP4 退 WebM
const mimeTypes = [
'video/mp4;codecs=avc1',
'video/webm;codecs=vp9',
'video/webm;codecs=vp8',
'video/webm'
]
let selectedMimeType = ''
for (const mt of mimeTypes) {
if (MediaRecorder.isTypeSupported(mt)) {
selectedMimeType = mt
break
}
}
if (!selectedMimeType) {
ElMessage.warning('当前浏览器不支持录屏')
return
}
const isMp4 = selectedMimeType.startsWith('video/mp4')
recordedChunks.value = []
const recorder = new MediaRecorder(stream, { mimeType: selectedMimeType })
recorder.ondataavailable = (e) => {
if (e.data.size > 0) recordedChunks.value.push(e.data)
}
recorder.onstop = () => {
const blob = new Blob(recordedChunks.value, { type: selectedMimeType })
const link = document.createElement('a')
link.download = `recording_${Date.now()}.${isMp4 ? 'mp4' : 'webm'}`
link.href = URL.createObjectURL(blob)
link.click()
URL.revokeObjectURL(link.href)
}
recorder.start(100)
mediaRecorder.value = recorder
recording.value = true
recordingStartTime = Date.now()
recordingTimer = setInterval(updateRecordingTime, 1000)
} catch (e) {
ElMessage.error('录屏启动失败: ' + e.message)
}
}
function stopRecording() {
if (mediaRecorder.value && mediaRecorder.value.state !== 'inactive') {
mediaRecorder.value.stop()
}
recording.value = false
if (recordingTimer) {
clearInterval(recordingTimer)
recordingTimer = null
}
recordingTime.value = '00:00'
}
//
watch(() => props.videoAutoplayTrigger, () => {
if (props.videoMode === 'offline') {
nextTick(() => offlineVideoRef.value?.play())
}
})
onUnmounted(() => {
if (recordingTimer) clearInterval(recordingTimer)
if (mediaRecorder.value && mediaRecorder.value.state !== 'inactive') {
mediaRecorder.value.stop()
}
})
</script>
<style lang="scss" scoped>
.video-container {
flex: 1;
background: #1a1a2e;
border-radius: $radius-md;
overflow: hidden;
position: relative;
min-height: 300px;
.video-title {
position: absolute;
top: 8px;
left: 8px;
z-index: 10;
background: linear-gradient(135deg, rgba(64, 158, 255, 0.9), rgba(64, 158, 255, 0.6));
color: #fff;
font-size: 13px;
font-weight: 600;
padding: 3px 12px;
border-radius: 4px;
letter-spacing: 1px;
backdrop-filter: blur(4px);
box-shadow: 0 2px 8px rgba(64, 158, 255, 0.3);
&.title-secondary {
background: linear-gradient(135deg, rgba(103, 194, 58, 0.9), rgba(103, 194, 58, 0.6));
box-shadow: 0 2px 8px rgba(103, 194, 58, 0.3);
}
}
:deep(.webrtc-wrapper) {
width: 100%;
height: 100%;
}
.offline-player {
width: 100%;
height: 100%;
position: relative;
display: flex;
flex-direction: column;
.video-element {
flex: 1;
width: 100%;
height: 100%;
min-height: 0;
object-fit: contain;
background: #000;
}
}
.empty-video {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
color: rgba(255, 255, 255, 0.5);
.empty-title {
margin-top: 16px;
font-size: 16px;
font-weight: 500;
color: rgba(255, 255, 255, 0.45);
}
.empty-hint {
margin-top: 8px;
font-size: 13px;
color: rgba(255, 255, 255, 0.3);
}
}
}
.media-toolbar {
position: absolute;
top: 8px;
right: 8px;
display: flex;
gap: 6px;
z-index: 10;
.el-button {
background: rgba(0, 0, 0, 0.6);
border: none;
color: #fff;
&:hover {
background: rgba(0, 0, 0, 0.8);
}
&.el-button--danger {
background: rgba(245, 108, 108, 0.8);
}
}
}
.recording-indicator {
position: absolute;
top: 8px;
left: 8px;
display: flex;
align-items: center;
gap: 6px;
background: rgba(0, 0, 0, 0.6);
color: #f56c6c;
font-size: 13px;
font-weight: 600;
padding: 4px 10px;
border-radius: 4px;
z-index: 10;
.rec-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: #f56c6c;
animation: blink 1s step-end infinite;
}
}
@keyframes blink {
50% { opacity: 0; }
}
</style>

@ -0,0 +1,76 @@
<template>
<div class="video-toolbar">
<div class="stream-info">
<span class="label">实时视频流:</span>
<el-input
:model-value="streamAddress"
size="small"
placeholder="输入地址如: 10.23.22.xx"
class="stream-input"
@update:model-value="$emit('update:streamAddress', $event)"
/>
<el-button type="primary" size="small" @click="$emit('switchStream')" :disabled="disabled">切换</el-button>
</div>
<div class="upload-area">
<el-upload
:auto-upload="false"
:show-file-list="false"
accept="video/*"
:on-change="handleUpload"
:disabled="disabled"
>
<el-button size="small">
<el-icon :size="14"><Upload /></el-icon>
离线视频
</el-button>
</el-upload>
</div>
</div>
</template>
<script setup>
const props = defineProps({
streamAddress: String,
disabled: Boolean
})
const emit = defineEmits(['update:streamAddress', 'switchStream', 'uploadVideo'])
function handleUpload(file) {
emit('uploadVideo', file)
}
</script>
<style lang="scss" scoped>
.video-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
background: $bg-white;
padding: 12px 16px;
border-radius: $radius-md;
box-shadow: $shadow-sm;
.stream-info {
display: flex;
align-items: center;
gap: 8px;
.label {
font-size: 13px;
color: $text-secondary;
white-space: nowrap;
}
.stream-input {
width: 180px;
}
}
.upload-area {
:deep(.el-upload) {
display: block;
}
}
}
</style>

File diff suppressed because it is too large Load Diff

@ -0,0 +1,512 @@
<template>
<div class="login-page">
<!-- 背景装饰 -->
<div class="bg-decoration">
<div class="bg-circle circle-1"></div>
<div class="bg-circle circle-2"></div>
<div class="bg-circle circle-3"></div>
<div class="bg-grid"></div>
</div>
<!-- 登录卡片 -->
<div class="login-container">
<div class="login-card">
<!-- 左侧品牌区 -->
<div class="brand-section">
<div class="brand-content">
<div class="brand-logo">
<svg viewBox="0 0 60 60" width="60" height="60">
<rect width="60" height="60" rx="16" fill="white"/>
<path d="M16 20h10v20H16V20zm18 0h10v20H34V20z" fill="#2d5af0"/>
<path d="M20 26h20M20 32h20" stroke="#2d5af0" stroke-width="2" stroke-linecap="round"/>
<circle cx="35" cy="23" r="3.5" fill="#22c55e"/>
</svg>
</div>
<h1 class="brand-title">SOP作业检测系统</h1>
<p class="brand-desc">
智能化作业工序检测精准把控每一道工序<br/>
确保产品装配零遗漏
</p>
<div class="brand-features">
<div class="feature-item">
<div class="feature-icon">
<el-icon :size="18"><Checked /></el-icon>
</div>
<span>工序步骤实时检测</span>
</div>
<div class="feature-item">
<div class="feature-icon">
<el-icon :size="18"><Box /></el-icon>
</div>
<span>配件完整性校验</span>
</div>
<div class="feature-item">
<div class="feature-icon">
<el-icon :size="18"><DocumentChecked /></el-icon>
</div>
<span>检测报告自动生成</span>
</div>
</div>
</div>
<div class="brand-decoration">
<div class="decoration-line"></div>
<div class="decoration-line"></div>
<div class="decoration-line"></div>
</div>
</div>
<!-- 右侧表单区 -->
<div class="form-section">
<div class="form-header">
<h2>欢迎回来</h2>
<p>请登录您的账号以继续使用</p>
</div>
<el-form
ref="loginFormRef"
:model="loginForm"
:rules="rules"
class="login-form"
@keyup.enter="handleLogin"
>
<el-form-item prop="username">
<el-input
v-model="loginForm.username"
placeholder="请输入账号"
:prefix-icon="User"
size="large"
clearable
/>
</el-form-item>
<el-form-item prop="password">
<el-input
v-model="loginForm.password"
type="password"
placeholder="请输入密码"
:prefix-icon="Lock"
size="large"
show-password
/>
</el-form-item>
<div class="form-options">
<el-checkbox v-model="rememberMe"></el-checkbox>
</div>
<el-button
type="primary"
size="large"
class="login-btn"
:loading="loading"
@click="handleLogin"
>
{{ loading ? '登录中...' : '登 录' }}
</el-button>
</el-form>
</div>
</div>
<!-- 底部版权 -->
<div class="login-copyright">
© 2026 SOP作业检测系统 · 智能化工序管理平台
</div>
</div>
</div>
</template>
<script setup>
import { reactive, ref, onMounted } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { useUserStore } from '@/stores/user'
import { ElMessage } from 'element-plus'
const router = useRouter()
const route = useRoute()
const userStore = useUserStore()
const REMEMBER_KEY = 'sop_remember_account'
const loginFormRef = ref(null)
const loading = ref(false)
const rememberMe = ref(false)
const loginForm = reactive({
username: '',
password: ''
})
//
onMounted(() => {
try {
const saved = localStorage.getItem(REMEMBER_KEY)
if (saved) {
const data = JSON.parse(saved)
loginForm.username = data.username || ''
rememberMe.value = data.remember || false
}
} catch {
// ignore
}
})
const rules = {
username: [
{ required: true, message: '请输入账号', trigger: 'blur' }
],
password: [
{ required: true, message: '请输入密码', trigger: 'blur' },
{ min: 6, message: '密码长度不少于6位', trigger: 'blur' }
]
}
// /
function saveAccount() {
try {
if (rememberMe.value && loginForm.username) {
localStorage.setItem(REMEMBER_KEY, JSON.stringify({
username: loginForm.username,
remember: true
}))
} else {
localStorage.removeItem(REMEMBER_KEY)
}
} catch {
// ignore
}
}
async function handleLogin() {
if (!loginFormRef.value) return
try {
await loginFormRef.value.validate()
loading.value = true
//
saveAccount()
await userStore.login({
username: loginForm.username,
password: loginForm.password
})
ElMessage.success('登录成功,欢迎回来!')
const redirect = route.query.redirect || '/mes'
router.push(redirect)
} catch (error) {
ElMessage.error(error.message || '登录失败,请重试')
//
saveAccount()
} finally {
loading.value = false
}
}
</script>
<style lang="scss" scoped>
.login-page {
width: 100%;
height: 100vh;
display: flex;
align-items: center;
justify-content: center;
position: relative;
overflow: hidden;
background: linear-gradient(135deg, #f0f4ff 0%, #e8eeff 30%, #f5f7fc 70%, #eef2fb 100%);
}
//
.bg-decoration {
position: absolute;
inset: 0;
overflow: hidden;
pointer-events: none;
.bg-circle {
position: absolute;
border-radius: 50%;
opacity: 0.12;
&.circle-1 {
width: 600px;
height: 600px;
background: linear-gradient(135deg, #2d5af0, #6366f1);
top: -200px;
right: -150px;
animation: float 8s ease-in-out infinite;
}
&.circle-2 {
width: 400px;
height: 400px;
background: linear-gradient(135deg, #22c55e, #10b981);
bottom: -120px;
left: -80px;
animation: float 10s ease-in-out infinite reverse;
}
&.circle-3 {
width: 250px;
height: 250px;
background: linear-gradient(135deg, #f59e0b, #f97316);
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
animation: float 7s ease-in-out infinite;
opacity: 0.05;
}
}
.bg-grid {
position: absolute;
inset: 0;
background-image:
linear-gradient(rgba(45, 90, 240, 0.03) 1px, transparent 1px),
linear-gradient(90deg, rgba(45, 90, 240, 0.03) 1px, transparent 1px);
background-size: 60px 60px;
}
}
@keyframes float {
0%, 100% { transform: translateY(0) rotate(0deg); }
50% { transform: translateY(-20px) rotate(3deg); }
}
//
.login-container {
position: relative;
z-index: 1;
display: flex;
flex-direction: column;
align-items: center;
gap: 24px;
}
//
.login-card {
display: flex;
width: 900px;
min-height: 520px;
background: rgba(255, 255, 255, 0.85);
backdrop-filter: blur(20px);
border-radius: 20px;
box-shadow:
0 4px 24px rgba(0, 0, 0, 0.06),
0 1px 4px rgba(0, 0, 0, 0.04);
overflow: hidden;
border: 1px solid rgba(255, 255, 255, 0.6);
}
//
.brand-section {
width: 420px;
background: linear-gradient(160deg, #1e3a8a 0%, #2d5af0 40%, #4f46e5 100%);
position: relative;
display: flex;
align-items: center;
justify-content: center;
padding: 48px 40px;
overflow: hidden;
.brand-content {
position: relative;
z-index: 1;
text-align: center;
}
.brand-logo {
margin-bottom: 24px;
}
.brand-title {
font-size: 24px;
font-weight: 700;
color: #ffffff;
margin-bottom: 12px;
letter-spacing: 1px;
}
.brand-desc {
font-size: 13px;
color: rgba(255, 255, 255, 0.7);
line-height: 1.8;
margin-bottom: 36px;
}
.brand-features {
display: flex;
flex-direction: column;
gap: 12px;
.feature-item {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 16px;
background: rgba(255, 255, 255, 0.1);
border-radius: 10px;
transition: $transition;
&:hover {
background: rgba(255, 255, 255, 0.18);
}
.feature-icon {
width: 32px;
height: 32px;
background: rgba(255, 255, 255, 0.15);
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
color: #ffffff;
flex-shrink: 0;
}
span {
color: rgba(255, 255, 255, 0.9);
font-size: 13px;
font-weight: 500;
text-align: left;
}
}
}
.brand-decoration {
position: absolute;
right: 0;
top: 50%;
transform: translateY(-50%);
display: flex;
flex-direction: column;
gap: 40px;
opacity: 0.08;
.decoration-line {
width: 160px;
height: 2px;
background: #ffffff;
border-radius: 1px;
&:nth-child(2) {
width: 120px;
}
&:nth-child(3) {
width: 80px;
}
}
}
}
//
.form-section {
flex: 1;
display: flex;
flex-direction: column;
justify-content: center;
padding: 48px 48px 40px;
.form-header {
margin-bottom: 36px;
h2 {
font-size: 26px;
font-weight: 700;
color: $text-primary;
margin-bottom: 8px;
}
p {
font-size: 14px;
color: $text-secondary;
}
}
}
.login-form {
.el-form-item {
margin-bottom: 20px;
:deep(.el-input__wrapper) {
border-radius: $radius-md;
box-shadow: 0 0 0 1px $border-color inset;
transition: $transition;
padding: 2px 12px;
&:hover {
box-shadow: 0 0 0 1px $primary-light inset;
}
&.is-focus {
box-shadow: 0 0 0 1px $primary-color inset, 0 0 0 3px rgba(45, 90, 240, 0.1);
}
}
:deep(.el-input__prefix) {
color: $text-light;
margin-right: 6px;
}
}
.form-options {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 28px;
:deep(.el-checkbox__label) {
font-size: 13px;
color: $text-secondary;
}
}
.login-btn {
width: 100%;
height: 46px;
font-size: 16px;
font-weight: 600;
letter-spacing: 4px;
border-radius: $radius-md;
background: linear-gradient(135deg, #2d5af0, #4f46e5);
border: none;
transition: $transition;
&:hover {
transform: translateY(-1px);
box-shadow: 0 6px 20px rgba(45, 90, 240, 0.35);
}
&:active {
transform: translateY(0);
}
}
}
//
.login-copyright {
font-size: 12px;
color: $text-light;
letter-spacing: 0.5px;
}
//
@media (max-width: 960px) {
.login-card {
width: 90vw;
flex-direction: column;
.brand-section {
width: 100%;
padding: 32px 24px;
.brand-features {
display: none;
}
}
.form-section {
padding: 32px 24px;
}
}
}
</style>

@ -0,0 +1,761 @@
<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">MFD SN</label>
<el-input
v-model="form.snCode"
placeholder="扫码自动获取"
size="large"
clearable
class="code-input"
readonly
>
<template #prefix>
<el-icon><Barcode /></el-icon>
</template>
</el-input>
</div>
<div class="code-item">
<label class="code-label">SKU</label>
<el-input
v-model="bCode"
placeholder="扫码自动获取"
size="large"
clearable
class="code-input"
readonly
>
<template #prefix>
<el-icon><Barcode /></el-icon>
</template>
</el-input>
</div>
<div class="code-item">
<label class="code-label">EAN</label>
<el-input
v-model="cCode"
placeholder="扫码自动获取"
size="large"
clearable
class="code-input"
readonly
>
<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="自动填入A码" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="AssemblyNumber" prop="assemblyNumber">
<el-input disabled v-model="form.assemblyNumber" placeholder="自动获取Model码" />
</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, saveReportApi } 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
// ABC
const bCode = ref('')
const cCode = ref('')
// /
const scanStartTime = ref('')
const scanEndTime = ref('')
const form = reactive({
snCode: '',
customer: 'sumup',
modelCode: '',
customerName: 'sumup',
division: 'sumup',
serialNumber: '',
assemblyNumber: '',
testerName: 'CNHUASUMIE111',
processStep: 'LINK'
})
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' }]
}
// ============ ============
function classifyCode(code) {
if (!code) return null
// POS SKU
if (code.startsWith('POS')) return 'B'
// 4 EAN
if (code.startsWith('4')) return 'C'
// 620
if (code.startsWith('620') || code.startsWith('Z0') || code.startsWith('ZO')) return 'IGNORE'
// A
return 'A'
}
// WebSocket
function startScanner() {
scanning.value = true
//
scanStartTime.value = formatNow()
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
const code = data.data
console.log(typeof data,'code是什么')
if (!code) return
const type = classifyCode(code)
switch (type) {
case 'A':
form.snCode = code
form.serialNumber = code
// ElMessage.success(`ASN: ${code}`)
break
case 'B':
bCode.value = code
//ElMessage.success(`B: ${code}`)
break
case 'C':
cCode.value = code
//ElMessage.success(`C: ${code}`)
break
case 'IGNORE':
// 620
break
}
})
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('已关闭扫码功能')
}
function formatNow() {
const d = new Date()
const pad = (n) => String(n).padStart(2, '0')
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`
}
// ============ B/C ============
function getModelByBCode() {
// B "POS-REG-019" STI-01173 STI-01176
return bCode.value === 'POS-REG-019' ? 'STI-01173' : 'STI-01176'
}
function getModelByCCode() {
// C "4260325425247" STI-01174 SIT-01175
return cCode.value === '4260325425247' ? 'STI-01174' : 'SIT-01175'
}
//
onBeforeUnmount(() => {
stopScanner()
if (countdownTimer) {
clearInterval(countdownTimer)
countdownTimer = null
}
})
// Model
async function fetchModelCode() {
if (!form.snCode) {
ElMessage.warning('请先输入SN码')
return
}
if (!form.customer) {
ElMessage.warning('请先输入Customer')
return
}
loading.value = true
try {
const data = await getSnApi({ customer: form.customer, serialNumber: form.snCode })
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
}
//
scanEndTime.value = formatNow()
invokeLoading.value = true
try {
// okToTestApi
await okToTestApi({
customerName: form.customerName,
division: form.division,
serialNumber: form.serialNumber,
assemblyNumber: form.assemblyNumber,
testerName: form.testerName,
processStep: form.processStep
})
//
const modelByB = getModelByBCode()
const modelByC = getModelByCCode()
const ruleContent = `
${form.snCode}
${form.customerName}
${form.testerName}
${form.processStep}
BayE168
P
${scanStartTime.value}
${scanEndTime.value}
${bCode.value}
${modelByB}
${cCode.value}
${modelByC}`
// saveReportApi
await saveReportApi({
message: [ruleContent],
sn: form.snCode,
sku: form.bCode
})
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', query: { sn: form.snCode } })
}
}, 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 = ''
bCode.value = ''
cCode.value = ''
scanStartTime.value = ''
scanEndTime.value = ''
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: 280px;
.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>

@ -0,0 +1,431 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>SOP WebSocket 前端测试</title>
<style>
body {
margin: 0;
font-family: Arial, "Microsoft YaHei", sans-serif;
background: #f5f7fb;
color: #1f2937;
}
.container {
max-width: 1100px;
margin: 0 auto;
padding: 24px;
}
.card {
background: #fff;
border-radius: 12px;
box-shadow: 0 8px 24px rgba(15, 23, 42, 0.08);
padding: 20px;
margin-bottom: 16px;
}
h1 {
margin: 0 0 16px;
font-size: 24px;
}
label {
display: block;
margin: 10px 0 6px;
font-weight: 600;
}
input, button {
font-size: 14px;
}
input[type="text"], input[type="number"] {
width: 100%;
box-sizing: border-box;
border: 1px solid #d1d5db;
border-radius: 8px;
padding: 10px;
}
input[type="file"] {
width: 100%;
padding: 10px 0;
}
.grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 12px;
}
.buttons {
display: flex;
gap: 10px;
margin-top: 16px;
flex-wrap: wrap;
}
button {
border: 0;
border-radius: 8px;
padding: 10px 16px;
cursor: pointer;
color: #fff;
background: #2563eb;
}
button:disabled {
cursor: not-allowed;
background: #9ca3af;
}
.danger {
background: #dc2626;
}
.status {
padding: 10px 12px;
border-radius: 8px;
background: #eef2ff;
color: #3730a3;
margin-bottom: 12px;
white-space: pre-wrap;
}
.progress-wrap {
height: 16px;
background: #e5e7eb;
border-radius: 999px;
overflow: hidden;
}
.progress-bar {
height: 100%;
width: 0%;
background: linear-gradient(90deg, #22c55e, #16a34a);
transition: width 0.2s;
}
.counts {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 8px;
}
.count-item {
padding: 10px;
border-radius: 8px;
background: #f3f4f6;
display: flex;
justify-content: space-between;
}
.log {
height: 280px;
overflow: auto;
background: #111827;
color: #d1d5db;
border-radius: 8px;
padding: 12px;
font-family: Consolas, monospace;
font-size: 13px;
white-space: pre-wrap;
}
video {
width: 100%;
max-height: 520px;
background: #000;
border-radius: 8px;
}
a {
color: #2563eb;
word-break: break-all;
}
</style>
</head>
<body>
<div class="container">
<div class="card">
<h1>SOP 视频检测 WebSocket 前端测试</h1>
<div id="status" class="status">未连接</div>
<label>WebSocket 地址</label>
<input id="wsUrl" type="text" value="ws://10.21.221.41:8000/ws/detect" />
<label>HTTP 服务地址,用于播放结果视频</label>
<input id="httpBase" type="text" value="http://10.21.221.41:8000" />
<label>选择要上传的视频</label>
<input id="videoFile" type="file" accept="video/*" />
<div class="grid">
<div>
<label>conf</label>
<input id="conf" type="number" value="0.5" min="0" max="1" step="0.01" />
</div>
<div>
<label>iou</label>
<input id="iou" type="number" value="0.45" min="0" max="1" step="0.01" />
</div>
<div>
<label>skip</label>
<input id="skip" type="number" value="5" min="1" step="1" />
</div>
<div>
<label>max_frames0 表示完整视频</label>
<input id="maxFrames" type="number" value="0" min="0" step="1" />
</div>
</div>
<div class="buttons">
<button id="startBtn">上传并开始检测</button>
<button id="pauseBtn" disabled>暂停处理</button>
<button id="endBtn" class="danger" disabled>结束检测</button>
<button id="stopBtn" class="danger" disabled>关闭连接</button>
</div>
</div>
<div class="card">
<h2>实时进度</h2>
<div class="progress-wrap"><div id="progressBar" class="progress-bar"></div></div>
<p id="progressText">0%</p>
</div>
<div class="card">
<h2>实时计数</h2>
<div id="counts" class="counts">暂无计数</div>
</div>
<div class="card">
<h2>实时日志</h2>
<div id="log" class="log"></div>
</div>
<div class="card">
<h2>处理后视频</h2>
<video id="resultVideo" controls></video>
<p id="resultLinks"></p>
</div>
</div>
<script>
const statusEl = document.getElementById("status");
const logEl = document.getElementById("log");
const countsEl = document.getElementById("counts");
const progressBar = document.getElementById("progressBar");
const progressText = document.getElementById("progressText");
const resultVideo = document.getElementById("resultVideo");
const resultLinks = document.getElementById("resultLinks");
const startBtn = document.getElementById("startBtn");
const pauseBtn = document.getElementById("pauseBtn");
const endBtn = document.getElementById("endBtn");
const stopBtn = document.getElementById("stopBtn");
let ws = null;
let paused = false;
let ending = false;
function setStatus(text) {
statusEl.textContent = text;
}
function appendLog(text) {
logEl.textContent += text + "\n";
logEl.scrollTop = logEl.scrollHeight;
}
function renderCounts(counts) {
const entries = Object.entries(counts || {});
if (!entries.length) {
countsEl.textContent = "暂无计数";
return;
}
countsEl.innerHTML = entries.map(([name, value]) => (
`<div class="count-item"><span>${name}</span><strong>${value}</strong></div>`
)).join("");
}
function renderProgress(msg) {
const progress = Number(msg.progress || 0);
progressBar.style.width = `${Math.max(0, Math.min(100, progress))}%`;
progressText.textContent = `${progress.toFixed(1)}% | ${msg.frame || 0}/${msg.total_frames || 0}`;
}
async function sendFileInChunks(socket, file) {
const chunkSize = 1024 * 1024;
let offset = 0;
while (offset < file.size) {
const chunk = file.slice(offset, offset + chunkSize);
const buffer = await chunk.arrayBuffer();
socket.send(buffer);
offset += chunkSize;
}
}
startBtn.onclick = async () => {
const file = document.getElementById("videoFile").files[0];
const wsUrl = document.getElementById("wsUrl").value.trim();
if (!file) {
alert("请先选择视频文件");
return;
}
if (!wsUrl.startsWith("ws://") && !wsUrl.startsWith("wss://")) {
alert("WebSocket 地址必须以 ws:// 或 wss:// 开头");
return;
}
logEl.textContent = "";
resultLinks.innerHTML = "";
resultVideo.removeAttribute("src");
resultVideo.load();
renderCounts({});
renderProgress({ progress: 0, frame: 0, total_frames: 0 });
startBtn.disabled = true;
pauseBtn.disabled = true;
pauseBtn.textContent = "暂停处理";
paused = false;
ending = false;
endBtn.disabled = true;
stopBtn.disabled = false;
setStatus("正在连接 WebSocket...");
ws = new WebSocket(wsUrl);
ws.binaryType = "arraybuffer";
ws.onopen = () => {
setStatus("已连接,正在打开算法开关...");
ws.send(JSON.stringify({ type: "switch", enabled: true }));
};
ws.onmessage = async (event) => {
const msg = JSON.parse(event.data);
if (msg.type === "ready") {
appendLog("服务端 ready");
return;
}
if (msg.type === "switch") {
appendLog("算法开关已开启");
ws.send(JSON.stringify({
type: "start",
filename: file.name,
options: {
conf: Number(document.getElementById("conf").value),
iou: Number(document.getElementById("iou").value),
skip: Number(document.getElementById("skip").value),
imgsz: 640,
max_frames: Number(document.getElementById("maxFrames").value),
save_video: true
}
}));
return;
}
if (msg.type === "upload_started") {
setStatus("正在上传视频...");
appendLog("开始上传视频");
await sendFileInChunks(ws, file);
ws.send(JSON.stringify({ type: "end" }));
appendLog("视频上传完成,等待服务端处理");
return;
}
if (msg.type === "processing_started") {
setStatus("服务端正在处理视频...");
appendLog("检测开始");
pauseBtn.disabled = false;
endBtn.disabled = false;
return;
}
if (msg.type === "stopping") {
ending = true;
pauseBtn.disabled = true;
endBtn.disabled = true;
setStatus(msg.message || "正在结束检测...");
appendLog(msg.message || "已请求结束检测");
return;
}
if (msg.type === "cancelled") {
ending = true;
pauseBtn.disabled = true;
endBtn.disabled = true;
setStatus(msg.message || "检测已结束,正在生成当前结果...");
appendLog(msg.message || "检测已结束");
return;
}
if (msg.type === "pause") {
paused = Boolean(msg.paused);
pauseBtn.textContent = paused ? "恢复处理" : "暂停处理";
setStatus(paused ? "检测已暂停,服务仍在运行" : "服务端正在处理视频...");
appendLog(msg.message || (paused ? "检测已暂停" : "检测已恢复"));
return;
}
if (msg.type === "progress") {
renderProgress(msg);
return;
}
if (msg.type === "counts") {
renderCounts(msg.counts);
return;
}
if (msg.type === "log") {
appendLog(msg.message || "");
return;
}
if (msg.type === "done") {
const httpBase = document.getElementById("httpBase").value.replace(/\/$/, "");
const videoUrl = httpBase + msg.output_video_url;
const reportUrl = httpBase + msg.report_url;
setStatus("检测完成");
pauseBtn.disabled = true;
endBtn.disabled = true;
renderCounts(msg.counts || {});
resultVideo.src = videoUrl;
resultLinks.innerHTML = `结果视频:<a href="${videoUrl}" target="_blank">${videoUrl}</a><br>` +
`检测报告:<a href="${reportUrl}" target="_blank">${reportUrl}</a>`;
appendLog("检测完成");
ws.close();
return;
}
if (msg.type === "error") {
setStatus("服务端错误:" + msg.message);
appendLog("错误:" + msg.message);
pauseBtn.disabled = true;
endBtn.disabled = true;
ws.close();
}
};
ws.onerror = () => {
setStatus("WebSocket 连接失败,请检查服务地址、端口、防火墙和服务是否以 0.0.0.0 启动");
};
ws.onclose = () => {
startBtn.disabled = false;
pauseBtn.disabled = true;
pauseBtn.textContent = "暂停处理";
endBtn.disabled = true;
stopBtn.disabled = true;
};
};
pauseBtn.onclick = () => {
if (!ws || ws.readyState !== WebSocket.OPEN) return;
paused = !paused;
ws.send(JSON.stringify({ type: "pause", paused }));
pauseBtn.textContent = paused ? "恢复处理" : "暂停处理";
};
endBtn.onclick = () => {
if (!ws || ws.readyState !== WebSocket.OPEN || ending) return;
ending = true;
pauseBtn.disabled = true;
endBtn.disabled = true;
setStatus("已发送结束检测请求,等待服务端生成当前结果...");
appendLog("前端请求结束检测");
ws.send(JSON.stringify({ type: "stop" }));
};
stopBtn.onclick = () => {
if (ws) ws.close();
setStatus("连接已关闭");
};
</script>
</body>
</html>

@ -0,0 +1,368 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>SOP WebSocket 前端测试</title>
<style>
body {
margin: 0;
font-family: Arial, "Microsoft YaHei", sans-serif;
background: #f5f7fb;
color: #1f2937;
}
.container {
max-width: 1100px;
margin: 0 auto;
padding: 24px;
}
.card {
background: #fff;
border-radius: 12px;
box-shadow: 0 8px 24px rgba(15, 23, 42, 0.08);
padding: 20px;
margin-bottom: 16px;
}
h1 {
margin: 0 0 16px;
font-size: 24px;
}
label {
display: block;
margin: 10px 0 6px;
font-weight: 600;
}
input, button {
font-size: 14px;
}
input[type="text"], input[type="number"] {
width: 100%;
box-sizing: border-box;
border: 1px solid #d1d5db;
border-radius: 8px;
padding: 10px;
}
input[type="file"] {
width: 100%;
padding: 10px 0;
}
.grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 12px;
}
.buttons {
display: flex;
gap: 10px;
margin-top: 16px;
flex-wrap: wrap;
}
button {
border: 0;
border-radius: 8px;
padding: 10px 16px;
cursor: pointer;
color: #fff;
background: #2563eb;
}
button:disabled {
cursor: not-allowed;
background: #9ca3af;
}
.danger {
background: #dc2626;
}
.status {
padding: 10px 12px;
border-radius: 8px;
background: #eef2ff;
color: #3730a3;
margin-bottom: 12px;
white-space: pre-wrap;
}
.progress-wrap {
height: 16px;
background: #e5e7eb;
border-radius: 999px;
overflow: hidden;
}
.progress-bar {
height: 100%;
width: 0%;
background: linear-gradient(90deg, #22c55e, #16a34a);
transition: width 0.2s;
}
.counts {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 8px;
}
.count-item {
padding: 10px;
border-radius: 8px;
background: #f3f4f6;
display: flex;
justify-content: space-between;
}
.log {
height: 280px;
overflow: auto;
background: #111827;
color: #d1d5db;
border-radius: 8px;
padding: 12px;
font-family: Consolas, monospace;
font-size: 13px;
white-space: pre-wrap;
}
video {
width: 100%;
max-height: 520px;
background: #000;
border-radius: 8px;
}
a {
color: #2563eb;
word-break: break-all;
}
</style>
</head>
<body>
<div class="container">
<div class="card">
<h1>SOP 视频检测 WebSocket 前端测试</h1>
<div id="status" class="status">未连接</div>
<label>WebSocket 地址</label>
<input id="wsUrl" type="text" value="ws://10.21.221.41:8000/ws/detect" />
<label>HTTP 服务地址,用于播放结果视频</label>
<input id="httpBase" type="text" value="http://10.21.221.41:8000" />
<label>选择要上传的视频</label>
<input id="videoFile" type="file" accept="video/*" />
<div class="grid">
<div>
<label>conf</label>
<input id="conf" type="number" value="0.5" min="0" max="1" step="0.01" />
</div>
<div>
<label>iou</label>
<input id="iou" type="number" value="0.45" min="0" max="1" step="0.01" />
</div>
<div>
<label>skip</label>
<input id="skip" type="number" value="5" min="1" step="1" />
</div>
<div>
<label>max_frames0 表示完整视频</label>
<input id="maxFrames" type="number" value="0" min="0" step="1" />
</div>
</div>
<div class="buttons">
<button id="startBtn">上传并开始检测</button>
<button id="stopBtn" class="danger" disabled>关闭连接</button>
</div>
</div>
<div class="card">
<h2>实时进度</h2>
<div class="progress-wrap"><div id="progressBar" class="progress-bar"></div></div>
<p id="progressText">0%</p>
</div>
<div class="card">
<h2>实时计数</h2>
<div id="counts" class="counts">暂无计数</div>
</div>
<div class="card">
<h2>实时日志</h2>
<div id="log" class="log"></div>
</div>
<div class="card">
<h2>处理后视频</h2>
<video id="resultVideo" controls></video>
<p id="resultLinks"></p>
</div>
</div>
<script>
const statusEl = document.getElementById("status");
const logEl = document.getElementById("log");
const countsEl = document.getElementById("counts");
const progressBar = document.getElementById("progressBar");
const progressText = document.getElementById("progressText");
const resultVideo = document.getElementById("resultVideo");
const resultLinks = document.getElementById("resultLinks");
const startBtn = document.getElementById("startBtn");
const stopBtn = document.getElementById("stopBtn");
let ws = null;
function setStatus(text) {
statusEl.textContent = text;
}
function appendLog(text) {
logEl.textContent += text + "\n";
logEl.scrollTop = logEl.scrollHeight;
}
function renderCounts(counts) {
const entries = Object.entries(counts || {});
if (!entries.length) {
countsEl.textContent = "暂无计数";
return;
}
countsEl.innerHTML = entries.map(([name, value]) => (
`<div class="count-item"><span>${name}</span><strong>${value}</strong></div>`
)).join("");
}
function renderProgress(msg) {
const progress = Number(msg.progress || 0);
progressBar.style.width = `${Math.max(0, Math.min(100, progress))}%`;
progressText.textContent = `${progress.toFixed(1)}% | ${msg.frame || 0}/${msg.total_frames || 0}`;
}
async function sendFileInChunks(socket, file) {
const chunkSize = 1024 * 1024;
let offset = 0;
while (offset < file.size) {
const chunk = file.slice(offset, offset + chunkSize);
const buffer = await chunk.arrayBuffer();
socket.send(buffer);
offset += chunkSize;
}
}
startBtn.onclick = async () => {
const file = document.getElementById("videoFile").files[0];
const wsUrl = document.getElementById("wsUrl").value.trim();
if (!file) {
alert("请先选择视频文件");
return;
}
if (!wsUrl.startsWith("ws://") && !wsUrl.startsWith("wss://")) {
alert("WebSocket 地址必须以 ws:// 或 wss:// 开头");
return;
}
logEl.textContent = "";
resultLinks.innerHTML = "";
resultVideo.removeAttribute("src");
resultVideo.load();
renderCounts({});
renderProgress({ progress: 0, frame: 0, total_frames: 0 });
startBtn.disabled = true;
stopBtn.disabled = false;
setStatus("正在连接 WebSocket...");
ws = new WebSocket(wsUrl);
ws.binaryType = "arraybuffer";
ws.onopen = () => {
setStatus("已连接,正在打开算法开关...");
ws.send(JSON.stringify({ type: "switch", enabled: true }));
};
ws.onmessage = async (event) => {
const msg = JSON.parse(event.data);
if (msg.type === "ready") {
appendLog("服务端 ready");
return;
}
if (msg.type === "switch") {
appendLog("算法开关已开启");
ws.send(JSON.stringify({
type: "start",
filename: file.name,
options: {
conf: Number(document.getElementById("conf").value),
iou: Number(document.getElementById("iou").value),
skip: Number(document.getElementById("skip").value),
imgsz: 640,
max_frames: Number(document.getElementById("maxFrames").value),
save_video: true
}
}));
return;
}
if (msg.type === "upload_started") {
setStatus("正在上传视频...");
appendLog("开始上传视频");
await sendFileInChunks(ws, file);
ws.send(JSON.stringify({ type: "end" }));
appendLog("视频上传完成,等待服务端处理");
return;
}
if (msg.type === "processing_started") {
setStatus("服务端正在处理视频...");
appendLog("检测开始");
return;
}
if (msg.type === "progress") {
renderProgress(msg);
return;
}
if (msg.type === "counts") {
renderCounts(msg.counts);
return;
}
if (msg.type === "log") {
appendLog(msg.message || "");
return;
}
if (msg.type === "done") {
const httpBase = document.getElementById("httpBase").value.replace(/\/$/, "");
const videoUrl = httpBase + msg.output_video_url;
const reportUrl = httpBase + msg.report_url;
setStatus("检测完成");
renderCounts(msg.counts || {});
resultVideo.src = videoUrl;
resultLinks.innerHTML = `结果视频:<a href="${videoUrl}" target="_blank">${videoUrl}</a><br>` +
`检测报告:<a href="${reportUrl}" target="_blank">${reportUrl}</a>`;
appendLog("检测完成");
ws.close();
return;
}
if (msg.type === "error") {
setStatus("服务端错误:" + msg.message);
appendLog("错误:" + msg.message);
ws.close();
}
};
ws.onerror = () => {
setStatus("WebSocket 连接失败,请检查服务地址、端口、防火墙和服务是否以 0.0.0.0 启动");
};
ws.onclose = () => {
startBtn.disabled = false;
stopBtn.disabled = true;
};
};
stopBtn.onclick = () => {
if (ws) ws.close();
setStatus("连接已关闭");
};
</script>
</body>
</html>

@ -0,0 +1,35 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import AutoImport from 'unplugin-auto-import/vite'
import Components from 'unplugin-vue-components/vite'
import { ElementPlusResolver } from 'unplugin-vue-components/resolvers'
import { fileURLToPath, URL } from 'node:url'
export default defineConfig({
plugins: [
vue(),
AutoImport({
resolvers: [ElementPlusResolver()]
}),
Components({
resolvers: [ElementPlusResolver({ importStyle: 'sass' })]
})
],
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url))
}
},
css: {
preprocessorOptions: {
scss: {
additionalData: `@use "@/styles/variables.scss" as *;`
}
}
},
server: {
host: '0.0.0.0',
port: 3001,
open: true
}
})
Loading…
Cancel
Save