feat:初始化代码

zlx
zhoulexin 8 hours ago
parent fe6c35645e
commit 69259d9de7

@ -0,0 +1,8 @@
# 开发环境配置
VITE_APP_TITLE = 'SOP作业检测系统'
# 后端接口地址
VITE_API_BASE_URL = 'http://10.23.22.43:8188/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>

2847
package-lock.json generated

File diff suppressed because it is too large Load Diff

@ -0,0 +1,26 @@
{
"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",
"element-plus": "^2.9.7",
"pinia": "^2.3.0",
"vue": "^3.5.38",
"vue-router": "^4.5.0"
},
"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,37 @@
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'
})
}

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

@ -0,0 +1,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,67 @@
<template>
<div class="main-layout">
<SideMenu />
<div class="main-container" :class="{ collapsed: appStore.sidebarCollapsed }">
<AppHeader />
<div class="main-content">
<router-view v-slot="{ Component }">
<transition name="fade-slide" mode="out-in">
<component :is="Component" />
</transition>
</router-view>
</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: '/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,53 @@
import { createRouter, createWebHistory } from 'vue-router'
import { useUserStore } from '@/stores/user'
const routes = [
{
path: '/',
redirect: '/home'
},
{
path: '/login',
name: 'Login',
component: () => import('@/views/login/index.vue'),
meta: { title: '登录', noAuth: true }
},
{
path: '/',
component: () => import('@/layout/MainLayout.vue'),
children: [
{
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,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,383 @@
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.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
} 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
}
// ==================== 事件监听 ====================
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 (data.type === 'pong') {
this._handlePong()
return
}
// 处理服务端 ping
if (data.type === 'ping') {
this.send(this.pongMessage)
return
}
this._emit('message', data)
} catch {
// 非 JSON 格式,透传原始消息
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()
// 定时发送 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)
}
_handlePong() {
this._log('收到心跳 pong')
// 清除超时检测
if (this.heartbeatCheckTimer) {
clearTimeout(this.heartbeatCheckTimer)
this.heartbeatCheckTimer = null
}
}
_clearHeartbeat() {
if (this.heartbeatTimer) {
clearInterval(this.heartbeatTimer)
this.heartbeatTimer = null
}
if (this.heartbeatCheckTimer) {
clearTimeout(this.heartbeatCheckTimer)
this.heartbeatCheckTimer = null
}
}
// ==================== 重连机制 ====================
_tryReconnect() {
if (this.isDestroyed) 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,710 @@
<template>
<div class="home-page">
<!-- 左侧视频区 -->
<div class="left-section">
<!-- 顶部工具栏 -->
<div class="video-toolbar">
<div class="stream-info">
<span class="label">实时视频流:</span>
<el-input
v-model="streamAddress"
size="small"
placeholder="输入地址如: 10.23.22.xx"
class="stream-input"
/>
<el-button type="primary" size="small" @click="switchStream"></el-button>
</div>
<div class="upload-area">
<el-upload
:auto-upload="false"
:show-file-list="false"
accept="video/*"
:on-change="handleVideoUpload"
>
<el-button size="small">
<el-icon :size="14"><Upload /></el-icon>
离线视频
</el-button>
</el-upload>
</div>
</div>
<!-- 视频画面 -->
<div class="video-container">
<!-- 实时流模式 -->
<WebRtcPlayer
v-if="videoMode === 'live'"
ref="webrtcPlayerRef"
:src="webrtcUrl"
@connection-status="onConnectionChange"
/>
<!-- 离线视频模式 -->
<div v-else-if="videoMode === 'offline'" class="offline-player">
<video
ref="offlineVideoRef"
:src="offlineVideoUrl"
class="video-element"
@loadedmetadata="onVideoLoaded"
@timeupdate="onTimeUpdate"
@ended="onVideoEnded"
></video>
<!-- 进度条 -->
<div class="video-controls" v-if="videoDuration > 0">
<div class="progress-wrapper">
<div class="progress-track" @click="seekVideo">
<div class="progress-fill" :style="{ width: progressPercent + '%' }"></div>
</div>
<span class="time-display">{{ formatTime(currentVideoTime) }} / {{ formatTime(videoDuration) }}</span>
</div>
</div>
</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>
<!-- 底部控制按钮离线视频时显示 -->
<div class="video-bottom-controls" v-if="videoMode === 'offline' && offlineVideoUrl">
<el-button type="primary" size="small" @click="togglePlay">
<el-icon :size="14"><component :is="isPlaying ? 'VideoPause' : 'VideoPlay'" /></el-icon>
{{ isPlaying ? '暂停' : '播放' }}
</el-button>
</div>
</div>
<!-- 右侧控制面板 -->
<div class="right-section">
<!-- 顶部功能按钮 -->
<div class="control-buttons">
<el-button
:type="streamRunning ? 'danger' : 'primary'"
size="default"
@click="toggleStream"
>
{{ streamRunning ? '关闭视频流' : '启动视频流' }}
</el-button>
<el-button
:type="algorithmRunning ? 'danger' : 'success'"
size="default"
@click="toggleAlgorithm"
>
{{ algorithmRunning ? '关闭算法' : '启动算法' }}
</el-button>
</div>
<!-- 配件信息卡片 -->
<div class="parts-card">
<div class="parts-row">
<span class="parts-label">配件名称:</span>
<span class="parts-value">充电线充电头隔板信封...</span>
</div>
<div class="parts-row">
<span class="parts-label">已检查配件数量:</span>
<span class="parts-value">
充电线{{ partsCount.chargerLine }}充电头{{ partsCount.chargerHead }}隔板{{ partsCount.divider }}信封{{ partsCount.envelope }}
</span>
</div>
</div>
<!-- 统计面板 -->
<div class="stats-panel">
<div class="stat-box">
<div class="stat-label">已完成的流程数量</div>
<div class="stat-value success">{{ completedCount }}</div>
</div>
<div class="stat-box">
<div class="stat-label">未完成的流程数量</div>
<div class="stat-value warning">{{ uncompletedCount }}</div>
</div>
<div class="stat-box detail-box" @click="showCompletedDetail">
<div class="stat-label">查看流程详情</div>
<div class="stat-value detail-arrow">
<el-icon :size="22"><ArrowRight /></el-icon>
</div>
</div>
</div>
<!-- 流程日志区域 -->
<div class="log-section">
<div class="log-header">流程日志</div>
<div class="log-body" ref="logBodyRef">
<div
v-for="(log, index) in flowLogs"
:key="index"
class="log-item"
:class="'log-' + log.type"
>
<span class="log-time">{{ log.time }}</span>
<span class="log-msg">{{ log.message }}</span>
</div>
<div v-if="flowLogs.length === 0" class="log-empty"></div>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, reactive, computed, onMounted, onUnmounted, watch, nextTick } from 'vue'
import { ElMessage } from 'element-plus'
import WebRtcPlayer from '@/components/WebRtcPlayer.vue'
import WebSocketClient from '@/utils/websocket'
// ============ ============
const videoMode = ref('idle') // 'idle' | 'live' | 'offline'
const streamAddress = ref('10.23.22.xx')
const webrtcUrl = computed(() => {
return import.meta.env.VITE_WEBRTC_URL || `http://${streamAddress.value}/stream`
})
const streamRunning = ref(false)
const webrtcPlayerRef = ref(null)
function switchStream() {
if (!streamAddress.value) {
ElMessage.warning('请输入视频流地址')
return
}
videoMode.value = 'live'
ElMessage.success('已切换到实时视频流')
}
function toggleStream() {
streamRunning.value = !streamRunning.value
if (streamRunning.value) {
videoMode.value = 'live'
ElMessage.success('视频流已启动')
} else {
videoMode.value = 'idle'
ElMessage.info('视频流已关闭')
}
}
function onConnectionChange(status) {
streamRunning.value = status
}
// ============ 线 ============
const offlineVideoRef = ref(null)
const offlineVideoUrl = ref('')
const videoDuration = ref(0)
const currentVideoTime = ref(0)
const isPlaying = ref(false)
const progressPercent = computed(() => {
if (videoDuration.value === 0) return 0
return (currentVideoTime.value / videoDuration.value) * 100
})
function handleVideoUpload(file) {
if (offlineVideoUrl.value) {
URL.revokeObjectURL(offlineVideoUrl.value)
}
offlineVideoUrl.value = URL.createObjectURL(file.raw)
videoMode.value = 'offline'
isPlaying.value = false
ElMessage.success('离线视频已加载')
}
function onVideoLoaded() {
if (offlineVideoRef.value) {
videoDuration.value = offlineVideoRef.value.duration
}
}
function onTimeUpdate() {
if (offlineVideoRef.value) {
currentVideoTime.value = offlineVideoRef.value.currentTime
}
}
function onVideoEnded() {
isPlaying.value = false
}
function togglePlay() {
if (!offlineVideoRef.value) return
if (isPlaying.value) {
offlineVideoRef.value.pause()
isPlaying.value = false
} else {
offlineVideoRef.value.play()
isPlaying.value = true
}
}
function seekVideo(e) {
if (!offlineVideoRef.value || videoDuration.value === 0) return
const rect = e.currentTarget.getBoundingClientRect()
const ratio = (e.clientX - rect.left) / rect.width
offlineVideoRef.value.currentTime = ratio * videoDuration.value
}
function formatTime(seconds) {
const m = Math.floor(seconds / 60)
const s = Math.floor(seconds % 60)
return `${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`
}
// ============ ============
const algorithmRunning = ref(false)
let wsClient = null
function toggleAlgorithm() {
algorithmRunning.value = !algorithmRunning.value
if (algorithmRunning.value) {
// WebSocket
wsClient = new WebSocketClient({
url: 'ws://10.21.221.41:8000/ws/detect',
debug: true,
autoReconnect: true
})
wsClient.on('open', () => {
ElMessage.success('算法已启动')
addLog('WebSocket 算法连接成功', 'info')
console.log('[WebSocket] 算法连接成功')
})
wsClient.on('message', (data) => {
console.log('[WebSocket] 收到算法检测数据:', data)
addLog('收到检测数据: ' + JSON.stringify(data), 'info')
})
wsClient.on('close', (event) => {
console.log('[WebSocket] 连接关闭:', event.code, event.reason)
addLog('算法连接已关闭', 'warning')
})
wsClient.on('error', (err) => {
console.error('[WebSocket] 连接出错:', err)
addLog('算法连接出错', 'error')
})
wsClient.on('reconnect', ({ times, maxTimes }) => {
console.log(`[WebSocket] 第 ${times}/${maxTimes} 次重连中...`)
addLog(`${times}/${maxTimes} 次重连中...`, 'warning')
})
wsClient.on('heartbeat', () => {
console.warn('[WebSocket] 心跳超时,即将重连')
addLog('算法连接心跳超时', 'error')
})
wsClient.connect()
} else {
// WebSocket
if (wsClient) {
wsClient.destroy()
wsClient = null
}
ElMessage.info('算法已关闭')
addLog('算法已关闭', 'info')
console.log('[WebSocket] 算法连接已主动关闭')
}
}
// ============ ============
const partsCount = reactive({
chargerLine: 30,
chargerHead: 15,
divider: 0,
envelope: 0
})
// ============ ============
const flowLogs = ref([
{ time: '10:05:12', message: '开始检测流程', type: 'info' },
{ time: '10:05:15', message: '检测到产品A', type: 'success' },
{ time: '10:05:18', message: '检测到产品B', type: 'success' },
{ time: '10:05:22', message: '检测隔板...', type: 'warning' },
{ time: '10:05:25', message: '隔板未检测到', type: 'error' },
])
const logBodyRef = ref(null)
function addLog(message, type = 'info') {
const now = new Date()
const time = `${String(now.getHours()).padStart(2, '0')}:${String(now.getMinutes()).padStart(2, '0')}:${String(now.getSeconds()).padStart(2, '0')}`
flowLogs.value.push({ time, message, type })
//
if (flowLogs.value.length > 100) {
flowLogs.value.shift()
}
//
nextTick(() => {
if (logBodyRef.value) {
logBodyRef.value.scrollTop = logBodyRef.value.scrollHeight
}
})
}
// ============ ============
const completedCount = ref(12)
const uncompletedCount = ref(3)
function showCompletedDetail() {
ElMessage.info('查看已完成流程详情')
}
// ============ ============
let logTimer = null
onMounted(() => {
//
logTimer = setInterval(() => {
if (algorithmRunning.value && Math.random() > 0.7) {
const msgs = [
{ msg: '检测到充电线', type: 'success' },
{ msg: '检测到充电头', type: 'success' },
{ msg: '正在识别产品...', type: 'info' },
{ msg: '流程完成', type: 'success' }
]
const item = msgs[Math.floor(Math.random() * msgs.length)]
addLog(item.msg, item.type)
}
}, 3000)
})
onUnmounted(() => {
if (logTimer) clearInterval(logTimer)
if (offlineVideoUrl.value) {
URL.revokeObjectURL(offlineVideoUrl.value)
}
if (wsClient) {
wsClient.destroy()
wsClient = null
}
})
//
watch(flowLogs, () => {
nextTick(() => {
if (logBodyRef.value) {
logBodyRef.value.scrollTop = logBodyRef.value.scrollHeight
}
})
}, { deep: true })
</script>
<style lang="scss" scoped>
.home-page {
display: flex;
gap: 16px;
height: calc(100vh - #{$header-height} - 32px);
padding: 0;
}
//
.left-section {
flex: 1;
display: flex;
flex-direction: column;
gap: 12px;
min-width: 0;
min-height: 0;
}
.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;
}
}
}
.video-container {
flex: 1;
background: #1a1a2e;
border-radius: $radius-md;
overflow: hidden;
position: relative;
min-height: 300px;
: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;
}
.video-controls {
padding: 8px 16px;
background: rgba(0, 0, 0, 0.8);
.progress-wrapper {
display: flex;
align-items: center;
gap: 12px;
.progress-track {
flex: 1;
height: 4px;
background: rgba(255, 255, 255, 0.3);
border-radius: 2px;
cursor: pointer;
&:hover {
height: 6px;
}
.progress-fill {
height: 100%;
background: $primary-color;
border-radius: 2px;
transition: width 0.1s linear;
}
}
.time-display {
font-size: 12px;
color: rgba(255, 255, 255, 0.8);
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
}
}
}
.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);
}
}
}
.video-bottom-controls {
display: flex;
justify-content: center;
gap: 16px;
padding: 8px 0;
}
//
.right-section {
width: 400px;
flex-shrink: 0;
display: flex;
flex-direction: column;
gap: 12px;
}
.control-buttons {
display: flex;
gap: 12px;
background: $bg-white;
padding: 16px;
border-radius: $radius-md;
box-shadow: $shadow-sm;
.el-button {
flex: 1;
}
}
.parts-card {
background: $bg-white;
padding: 16px;
border-radius: $radius-md;
box-shadow: $shadow-sm;
.parts-row {
display: flex;
gap: 8px;
font-size: 13px;
line-height: 1.6;
&:not(:last-child) {
margin-bottom: 8px;
}
.parts-label {
color: $text-secondary;
white-space: nowrap;
}
.parts-value {
color: $text-primary;
font-weight: 500;
}
}
}
.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 {
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;
}
&.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;
}
}
}
.stats-panel {
display: flex;
align-items: center;
gap: 12px;
.stat-box {
background: $bg-white;
padding: 10px 16px;
border-radius: $radius-md;
box-shadow: $shadow-sm;
text-align: center;
min-width: 120px;
.stat-label {
font-size: 12px;
color: $text-secondary;
margin-bottom: 4px;
}
.stat-value {
font-size: 22px;
font-weight: 700;
&.success {
color: $success-color;
}
&.warning {
color: $warning-color;
}
}
&.detail-box {
cursor: pointer;
transition: $transition;
.detail-arrow {
color: $primary-color;
transition: transform 0.2s;
}
&:hover {
background: #f8f9ff;
box-shadow: 0 2px 8px rgba(45, 90, 240, 0.1);
.detail-arrow {
transform: translateX(4px);
}
}
&:active {
background: #f0f2ff;
}
}
}
}
</style>

@ -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 || '/home'
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,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