Compare commits
2 Commits
| Author | SHA1 | Date |
|---|---|---|
|
|
8ff1b05f22 | 14 hours ago |
|
|
69259d9de7 | 4 days ago |
@ -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
|
||||||
@ -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>
|
||||||
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,27 @@
|
|||||||
|
{
|
||||||
|
"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",
|
||||||
|
"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,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,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,4 @@
|
|||||||
|
const fileHttp = {
|
||||||
|
ptApi: '10.23.22.43:8001'
|
||||||
|
}
|
||||||
|
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,46 @@
|
|||||||
|
<template>
|
||||||
|
<div class="control-buttons">
|
||||||
|
<el-button
|
||||||
|
:type="streamRunning ? 'danger' : 'primary'"
|
||||||
|
size="default"
|
||||||
|
@click="$emit('toggleStream')"
|
||||||
|
:disabled="streamAddressEmpty || (algorithmRunning && !streamRunning)"
|
||||||
|
>
|
||||||
|
{{ streamRunning ? '关闭视频流' : '启动视频流' }}
|
||||||
|
</el-button>
|
||||||
|
<el-button
|
||||||
|
:type="algorithmRunning ? 'danger' : 'success'"
|
||||||
|
size="default"
|
||||||
|
@click="$emit('toggleAlgorithm')"
|
||||||
|
:disabled="offlineVideoMissing"
|
||||||
|
>
|
||||||
|
{{ algorithmRunning ? '关闭算法' : '启动算法' }}
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
defineProps({
|
||||||
|
streamRunning: Boolean,
|
||||||
|
algorithmRunning: Boolean,
|
||||||
|
streamAddressEmpty: Boolean,
|
||||||
|
offlineVideoMissing: 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,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>
|
||||||
@ -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…
Reference in New Issue