Compare commits
No commits in common. 'zlx' and 'main' have entirely different histories.
@ -1,8 +0,0 @@
|
|||||||
# 开发环境配置
|
|
||||||
VITE_APP_TITLE = 'SOP作业检测系统'
|
|
||||||
|
|
||||||
# 后端接口地址
|
|
||||||
VITE_API_BASE_URL = 'http://10.23.22.43:8188/api'
|
|
||||||
|
|
||||||
# 请求超时时间(毫秒)
|
|
||||||
VITE_API_TIMEOUT = 15000
|
|
||||||
@ -1,8 +0,0 @@
|
|||||||
# 生产环境配置
|
|
||||||
VITE_APP_TITLE = 'SOP作业检测系统'
|
|
||||||
|
|
||||||
# 后端接口地址
|
|
||||||
VITE_API_BASE_URL = '/api'
|
|
||||||
|
|
||||||
# 请求超时时间(毫秒)
|
|
||||||
VITE_API_TIMEOUT = 15000
|
|
||||||
@ -1,24 +0,0 @@
|
|||||||
# 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?
|
|
||||||
@ -1,13 +0,0 @@
|
|||||||
<!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
@ -1,29 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "sop-system",
|
|
||||||
"private": true,
|
|
||||||
"version": "1.0.0",
|
|
||||||
"type": "module",
|
|
||||||
"scripts": {
|
|
||||||
"dev": "vite",
|
|
||||||
"build": "vite build",
|
|
||||||
"preview": "vite preview"
|
|
||||||
},
|
|
||||||
"dependencies": {
|
|
||||||
"@element-plus/icons-vue": "^2.3.1",
|
|
||||||
"axios": "^1.18.1",
|
|
||||||
"echarts": "^6.1.0",
|
|
||||||
"element-plus": "^2.9.7",
|
|
||||||
"file-saver": "^2.0.5",
|
|
||||||
"jszip": "^3.10.1",
|
|
||||||
"pinia": "^2.3.0",
|
|
||||||
"vue": "^3.5.38",
|
|
||||||
"vue-router": "^4.5.0"
|
|
||||||
},
|
|
||||||
"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
@ -1,3 +0,0 @@
|
|||||||
allowBuilds:
|
|
||||||
'@parcel/watcher': false
|
|
||||||
vue-demi: false
|
|
||||||
@ -1,6 +0,0 @@
|
|||||||
<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>
|
|
||||||
|
Before Width: | Height: | Size: 363 B |
@ -1,11 +0,0 @@
|
|||||||
<template>
|
|
||||||
<router-view />
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup>
|
|
||||||
import { useUserStore } from '@/stores/user'
|
|
||||||
import { computed } from 'vue'
|
|
||||||
|
|
||||||
const userStore = useUserStore()
|
|
||||||
const isLoggedIn = computed(() => userStore.isLoggedIn)
|
|
||||||
</script>
|
|
||||||
@ -1,134 +0,0 @@
|
|||||||
<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>
|
|
||||||
@ -1,67 +0,0 @@
|
|||||||
<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>
|
|
||||||
@ -1,182 +0,0 @@
|
|||||||
<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>
|
|
||||||
@ -1,30 +0,0 @@
|
|||||||
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')
|
|
||||||
@ -1,53 +0,0 @@
|
|||||||
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
|
|
||||||
@ -1,15 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
})
|
|
||||||
@ -1,40 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
})
|
|
||||||
@ -1,44 +0,0 @@
|
|||||||
* {
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,26 +0,0 @@
|
|||||||
// 全局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);
|
|
||||||
@ -1,4 +0,0 @@
|
|||||||
const fileHttp = {
|
|
||||||
ptApi: '10.23.22.43:8001'
|
|
||||||
}
|
|
||||||
export default fileHttp
|
|
||||||
@ -1,87 +0,0 @@
|
|||||||
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
|
|
||||||
@ -1,48 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="control-buttons">
|
|
||||||
<el-button
|
|
||||||
:type="streamRunning ? 'danger' : 'primary'"
|
|
||||||
size="default"
|
|
||||||
@click="$emit('toggleStream')"
|
|
||||||
:disabled="streamAddressEmpty || (algorithmRunning && !streamRunning) || hasOfflineVideo"
|
|
||||||
>
|
|
||||||
{{ streamRunning ? '关闭视频流' : '启动视频流' }}
|
|
||||||
</el-button>
|
|
||||||
<el-button
|
|
||||||
:type="algorithmRunning ? 'danger' : 'success'"
|
|
||||||
size="default"
|
|
||||||
@click="$emit('toggleAlgorithm')"
|
|
||||||
:disabled="offlineVideoMissing || isLiveMode"
|
|
||||||
>
|
|
||||||
{{ algorithmRunning ? '关闭算法' : '启动算法' }}
|
|
||||||
</el-button>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup>
|
|
||||||
defineProps({
|
|
||||||
streamRunning: Boolean,
|
|
||||||
algorithmRunning: Boolean,
|
|
||||||
streamAddressEmpty: Boolean,
|
|
||||||
offlineVideoMissing: Boolean,
|
|
||||||
isLiveMode: Boolean,
|
|
||||||
hasOfflineVideo: 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>
|
|
||||||
@ -1,64 +0,0 @@
|
|||||||
<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>
|
|
||||||
@ -1,66 +0,0 @@
|
|||||||
<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>
|
|
||||||
@ -1,103 +0,0 @@
|
|||||||
<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>
|
|
||||||
@ -1,76 +0,0 @@
|
|||||||
<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>
|
|
||||||
@ -1,35 +0,0 @@
|
|||||||
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