Compare commits
2 Commits
| Author | SHA1 | Date |
|---|---|---|
|
|
8ff1b05f22 | 8 hours ago |
|
|
69259d9de7 | 3 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