feat(frontend): 实现16个页面源码(13模块)
管理后台: 登录/仪表盘/设备/品牌/采集地址/员工/产量/告警/系统设置/操作日志/大屏配置 大屏看板: 全屏数据展示页 包含: 路由/Mock路由镜像/axios封装/全局样式/TypeScript类型定义/ECharts按需导入/PageHeader组件 Ultraworked with Sisyphus Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>main
parent
3de3b13011
commit
9a99b926ac
@ -0,0 +1,6 @@
|
||||
<template>
|
||||
<router-view />
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
</script>
|
||||
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 8.5 KiB |
@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="37.07" height="36" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 198"><path fill="#41B883" d="M204.8 0H256L128 220.8L0 0h97.92L128 51.2L157.44 0h47.36Z"></path><path fill="#41B883" d="m0 0l128 220.8L256 0h-51.2L128 132.48L50.56 0H0Z"></path><path fill="#35495E" d="M50.56 0L128 133.12L204.8 0h-47.36L128 51.2L97.92 0H50.56Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 496 B |
@ -0,0 +1,95 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import viteLogo from '../assets/vite.svg'
|
||||
import heroImg from '../assets/hero.png'
|
||||
import vueLogo from '../assets/vue.svg'
|
||||
|
||||
const count = ref(0)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section id="center">
|
||||
<div class="hero">
|
||||
<img :src="heroImg" class="base" width="170" height="179" alt="" />
|
||||
<img :src="vueLogo" class="framework" alt="Vue logo" />
|
||||
<img :src="viteLogo" class="vite" alt="Vite logo" />
|
||||
</div>
|
||||
<div>
|
||||
<h1>Get started</h1>
|
||||
<p>Edit <code>src/App.vue</code> and save to test <code>HMR</code></p>
|
||||
</div>
|
||||
<button type="button" class="counter" @click="count++">
|
||||
Count is {{ count }}
|
||||
</button>
|
||||
</section>
|
||||
|
||||
<div class="ticks"></div>
|
||||
|
||||
<section id="next-steps">
|
||||
<div id="docs">
|
||||
<svg class="icon" role="presentation" aria-hidden="true">
|
||||
<use href="/icons.svg#documentation-icon"></use>
|
||||
</svg>
|
||||
<h2>Documentation</h2>
|
||||
<p>Your questions, answered</p>
|
||||
<ul>
|
||||
<li>
|
||||
<a href="https://vite.dev/" target="_blank">
|
||||
<img class="logo" :src="viteLogo" alt="" />
|
||||
Explore Vite
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="https://vuejs.org/" target="_blank">
|
||||
<img class="button-icon" :src="vueLogo" alt="" />
|
||||
Learn more
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div id="social">
|
||||
<svg class="icon" role="presentation" aria-hidden="true">
|
||||
<use href="/icons.svg#social-icon"></use>
|
||||
</svg>
|
||||
<h2>Connect with us</h2>
|
||||
<p>Join the Vite community</p>
|
||||
<ul>
|
||||
<li>
|
||||
<a href="https://github.com/vitejs/vite" target="_blank">
|
||||
<svg class="button-icon" role="presentation" aria-hidden="true">
|
||||
<use href="/icons.svg#github-icon"></use>
|
||||
</svg>
|
||||
GitHub
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="https://chat.vite.dev/" target="_blank">
|
||||
<svg class="button-icon" role="presentation" aria-hidden="true">
|
||||
<use href="/icons.svg#discord-icon"></use>
|
||||
</svg>
|
||||
Discord
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="https://x.com/vite_js" target="_blank">
|
||||
<svg class="button-icon" role="presentation" aria-hidden="true">
|
||||
<use href="/icons.svg#x-icon"></use>
|
||||
</svg>
|
||||
X.com
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="https://bsky.app/profile/vite.dev" target="_blank">
|
||||
<svg class="button-icon" role="presentation" aria-hidden="true">
|
||||
<use href="/icons.svg#bluesky-icon"></use>
|
||||
</svg>
|
||||
Bluesky
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="ticks"></div>
|
||||
<section id="spacer"></section>
|
||||
</template>
|
||||
@ -0,0 +1,12 @@
|
||||
import { computed } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
|
||||
/**
|
||||
* Mock模式检测
|
||||
* 根据route.path是否以/mock开头判断
|
||||
*/
|
||||
export function useMockMode() {
|
||||
const route = useRoute()
|
||||
const isMock = computed(() => route.path.startsWith('/mock'))
|
||||
return { isMock }
|
||||
}
|
||||
@ -0,0 +1,8 @@
|
||||
// 提供统一的 Mock 路径前缀判断与路径构建 helpers
|
||||
export function useMockPath() {
|
||||
const isMock = typeof window !== 'undefined' && window.location.pathname.startsWith('/mock')
|
||||
function mockPath(path: string): string {
|
||||
return isMock ? `/mock${path}` : path
|
||||
}
|
||||
return { isMock, mockPath }
|
||||
}
|
||||
@ -0,0 +1,227 @@
|
||||
<template>
|
||||
<div class="admin-layout">
|
||||
<!-- 顶部栏 -->
|
||||
<header class="admin-header">
|
||||
<div class="header-left">
|
||||
<span class="system-title">CNC机床数据采集系统</span>
|
||||
</div>
|
||||
<div class="header-right">
|
||||
<el-dropdown trigger="click" @command="handleCommand">
|
||||
<span class="admin-dropdown">
|
||||
管理员 <el-icon><ArrowDown /></el-icon>
|
||||
</span>
|
||||
<template #dropdown>
|
||||
<el-dropdown-menu>
|
||||
<el-dropdown-item command="password">修改密码</el-dropdown-item>
|
||||
<el-dropdown-item command="logout" divided>退出登录</el-dropdown-item>
|
||||
</el-dropdown-menu>
|
||||
</template>
|
||||
</el-dropdown>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="admin-body">
|
||||
<!-- 侧边栏 -->
|
||||
<aside class="admin-sidebar" :class="{ collapsed: isCollapsed }">
|
||||
<el-menu
|
||||
:default-active="currentRoute"
|
||||
:collapse="isCollapsed"
|
||||
background-color="#304156"
|
||||
text-color="#bfcbd9"
|
||||
active-text-color="#409EFF"
|
||||
router
|
||||
>
|
||||
<el-menu-item :index="menuPath('/dashboard')">
|
||||
<el-icon><Odometer /></el-icon>
|
||||
<template #title>仪表盘</template>
|
||||
</el-menu-item>
|
||||
<el-menu-item :index="menuPath('/machine')">
|
||||
<el-icon><Monitor /></el-icon>
|
||||
<template #title>设备管理</template>
|
||||
</el-menu-item>
|
||||
<el-menu-item :index="menuPath('/brand')">
|
||||
<el-icon><PriceTag /></el-icon>
|
||||
<template #title>品牌模板</template>
|
||||
</el-menu-item>
|
||||
<el-menu-item :index="menuPath('/collect-address')">
|
||||
<el-icon><Link /></el-icon>
|
||||
<template #title>采集地址</template>
|
||||
</el-menu-item>
|
||||
<el-menu-item :index="menuPath('/worker')">
|
||||
<el-icon><User /></el-icon>
|
||||
<template #title>员工管理</template>
|
||||
</el-menu-item>
|
||||
<el-menu-item :index="menuPath('/production')">
|
||||
<el-icon><DataAnalysis /></el-icon>
|
||||
<template #title>产量报表</template>
|
||||
</el-menu-item>
|
||||
<el-menu-item :index="menuPath('/alert')">
|
||||
<el-icon><Bell /></el-icon>
|
||||
<template #title>告警中心</template>
|
||||
</el-menu-item>
|
||||
<el-menu-item :index="menuPath('/settings')">
|
||||
<el-icon><Setting /></el-icon>
|
||||
<template #title>系统设置</template>
|
||||
</el-menu-item>
|
||||
<el-menu-item :index="menuPath('/log')">
|
||||
<el-icon><Document /></el-icon>
|
||||
<template #title>操作日志</template>
|
||||
</el-menu-item>
|
||||
<el-menu-item :index="menuPath('/screen-config')">
|
||||
<el-icon><FullScreen /></el-icon>
|
||||
<template #title>大屏配置</template>
|
||||
</el-menu-item>
|
||||
</el-menu>
|
||||
<div class="sidebar-toggle" @click="isCollapsed = !isCollapsed">
|
||||
<el-icon v-if="isCollapsed"><Expand /></el-icon>
|
||||
<el-icon v-else><Fold /></el-icon>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<!-- 内容区 -->
|
||||
<main class="admin-content">
|
||||
<div class="breadcrumb-area">
|
||||
<el-breadcrumb separator="/">
|
||||
<el-breadcrumb-item :to="{ path: isMock ? '/mock/dashboard' : '/dashboard' }">首页</el-breadcrumb-item>
|
||||
<el-breadcrumb-item v-if="route.meta.title && route.name !== 'Dashboard' && route.name !== 'MockDashboard'">
|
||||
{{ route.meta.title }}
|
||||
</el-breadcrumb-item>
|
||||
</el-breadcrumb>
|
||||
</div>
|
||||
<div class="content-area">
|
||||
<router-view />
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { ElMessageBox, ElMessage } from 'element-plus'
|
||||
import { ArrowDown } from '@element-plus/icons-vue'
|
||||
import { useMockMode } from '@/composables/useMockMode'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const { isMock } = useMockMode()
|
||||
const isCollapsed = ref(false)
|
||||
|
||||
// 根据当前模式生成菜单路径
|
||||
function menuPath(path: string): string {
|
||||
return isMock.value ? `/mock${path}` : path
|
||||
}
|
||||
|
||||
const currentRoute = computed(() => route.path)
|
||||
|
||||
function handleCommand(command: string) {
|
||||
if (command === 'password') {
|
||||
router.push(isMock.value ? '/mock/settings' : '/settings')
|
||||
} else if (command === 'logout') {
|
||||
ElMessageBox.confirm('确定退出登录?', '提示', {
|
||||
confirmButtonText: '确定',
|
||||
cancelButtonText: '取消',
|
||||
type: 'warning',
|
||||
}).then(() => {
|
||||
localStorage.removeItem('token')
|
||||
router.push(isMock.value ? '/mock/login' : '/login')
|
||||
ElMessage.success('已退出登录')
|
||||
}).catch(() => {})
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.admin-layout {
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.admin-header {
|
||||
height: 48px;
|
||||
background-color: #409EFF;
|
||||
color: #fff;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 20px;
|
||||
flex-shrink: 0;
|
||||
|
||||
.header-left {
|
||||
.system-title {
|
||||
font-size: 16px;
|
||||
font-weight: bold;
|
||||
}
|
||||
}
|
||||
|
||||
.header-right {
|
||||
.admin-dropdown {
|
||||
color: #fff;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.admin-body {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.admin-sidebar {
|
||||
width: 200px;
|
||||
background-color: #304156;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
transition: width 0.3s;
|
||||
flex-shrink: 0;
|
||||
|
||||
&.collapsed {
|
||||
width: 64px;
|
||||
}
|
||||
|
||||
.el-menu {
|
||||
flex: 1;
|
||||
border-right: none;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.sidebar-toggle {
|
||||
height: 40px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #bfcbd9;
|
||||
cursor: pointer;
|
||||
border-top: 1px solid #263445;
|
||||
|
||||
&:hover {
|
||||
color: #409EFF;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.admin-content {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background-color: #f0f2f5;
|
||||
overflow: hidden;
|
||||
|
||||
.breadcrumb-area {
|
||||
padding: 12px 20px 0;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.content-area {
|
||||
flex: 1;
|
||||
padding: 20px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@ -0,0 +1,17 @@
|
||||
<template>
|
||||
<div class="screen-layout">
|
||||
<router-view />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.screen-layout {
|
||||
height: 100vh;
|
||||
background-color: #0f0f1a;
|
||||
color: #e0e0e0;
|
||||
overflow: hidden;
|
||||
}
|
||||
</style>
|
||||
@ -0,0 +1,19 @@
|
||||
import { createApp } from 'vue'
|
||||
import ElementPlus from 'element-plus'
|
||||
import 'element-plus/dist/index.css'
|
||||
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 './styles/admin.scss'
|
||||
|
||||
const app = createApp(App)
|
||||
|
||||
// 注册所有Element Plus图标
|
||||
for (const [key, component] of Object.entries(ElementPlusIconsVue)) {
|
||||
app.component(key, component)
|
||||
}
|
||||
|
||||
app.use(ElementPlus, { locale: zhCn })
|
||||
app.use(router)
|
||||
app.mount('#app')
|
||||
@ -0,0 +1,296 @@
|
||||
:root {
|
||||
--text: #6b6375;
|
||||
--text-h: #08060d;
|
||||
--bg: #fff;
|
||||
--border: #e5e4e7;
|
||||
--code-bg: #f4f3ec;
|
||||
--accent: #aa3bff;
|
||||
--accent-bg: rgba(170, 59, 255, 0.1);
|
||||
--accent-border: rgba(170, 59, 255, 0.5);
|
||||
--social-bg: rgba(244, 243, 236, 0.5);
|
||||
--shadow:
|
||||
rgba(0, 0, 0, 0.1) 0 10px 15px -3px, rgba(0, 0, 0, 0.05) 0 4px 6px -2px;
|
||||
|
||||
--sans: system-ui, 'Segoe UI', Roboto, sans-serif;
|
||||
--heading: system-ui, 'Segoe UI', Roboto, sans-serif;
|
||||
--mono: ui-monospace, Consolas, monospace;
|
||||
|
||||
font: 18px/145% var(--sans);
|
||||
letter-spacing: 0.18px;
|
||||
color-scheme: light dark;
|
||||
color: var(--text);
|
||||
background: var(--bg);
|
||||
font-synthesis: none;
|
||||
text-rendering: optimizeLegibility;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
font-size: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--text: #9ca3af;
|
||||
--text-h: #f3f4f6;
|
||||
--bg: #16171d;
|
||||
--border: #2e303a;
|
||||
--code-bg: #1f2028;
|
||||
--accent: #c084fc;
|
||||
--accent-bg: rgba(192, 132, 252, 0.15);
|
||||
--accent-border: rgba(192, 132, 252, 0.5);
|
||||
--social-bg: rgba(47, 48, 58, 0.5);
|
||||
--shadow:
|
||||
rgba(0, 0, 0, 0.4) 0 10px 15px -3px, rgba(0, 0, 0, 0.25) 0 4px 6px -2px;
|
||||
}
|
||||
|
||||
#social .button-icon {
|
||||
filter: invert(1) brightness(2);
|
||||
}
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
h1,
|
||||
h2 {
|
||||
font-family: var(--heading);
|
||||
font-weight: 500;
|
||||
color: var(--text-h);
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 56px;
|
||||
letter-spacing: -1.68px;
|
||||
margin: 32px 0;
|
||||
@media (max-width: 1024px) {
|
||||
font-size: 36px;
|
||||
margin: 20px 0;
|
||||
}
|
||||
}
|
||||
h2 {
|
||||
font-size: 24px;
|
||||
line-height: 118%;
|
||||
letter-spacing: -0.24px;
|
||||
margin: 0 0 8px;
|
||||
@media (max-width: 1024px) {
|
||||
font-size: 20px;
|
||||
}
|
||||
}
|
||||
p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
code,
|
||||
.counter {
|
||||
font-family: var(--mono);
|
||||
display: inline-flex;
|
||||
border-radius: 4px;
|
||||
color: var(--text-h);
|
||||
}
|
||||
|
||||
code {
|
||||
font-size: 15px;
|
||||
line-height: 135%;
|
||||
padding: 4px 8px;
|
||||
background: var(--code-bg);
|
||||
}
|
||||
|
||||
.counter {
|
||||
font-size: 16px;
|
||||
padding: 5px 10px;
|
||||
border-radius: 5px;
|
||||
color: var(--accent);
|
||||
background: var(--accent-bg);
|
||||
border: 2px solid transparent;
|
||||
transition: border-color 0.3s;
|
||||
margin-bottom: 24px;
|
||||
|
||||
&:hover {
|
||||
border-color: var(--accent-border);
|
||||
}
|
||||
&:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
.hero {
|
||||
position: relative;
|
||||
|
||||
.base,
|
||||
.framework,
|
||||
.vite {
|
||||
inset-inline: 0;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.base {
|
||||
width: 170px;
|
||||
position: relative;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
.framework,
|
||||
.vite {
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
.framework {
|
||||
z-index: 1;
|
||||
top: 34px;
|
||||
height: 28px;
|
||||
transform: perspective(2000px) rotateZ(300deg) rotateX(44deg) rotateY(39deg)
|
||||
scale(1.4);
|
||||
}
|
||||
|
||||
.vite {
|
||||
z-index: 0;
|
||||
top: 107px;
|
||||
height: 26px;
|
||||
width: auto;
|
||||
transform: perspective(2000px) rotateZ(300deg) rotateX(40deg) rotateY(39deg)
|
||||
scale(0.8);
|
||||
}
|
||||
}
|
||||
|
||||
#app {
|
||||
width: 1126px;
|
||||
max-width: 100%;
|
||||
margin: 0 auto;
|
||||
text-align: center;
|
||||
border-inline: 1px solid var(--border);
|
||||
min-height: 100svh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
#center {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 25px;
|
||||
place-content: center;
|
||||
place-items: center;
|
||||
flex-grow: 1;
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
padding: 32px 20px 24px;
|
||||
gap: 18px;
|
||||
}
|
||||
}
|
||||
|
||||
#next-steps {
|
||||
display: flex;
|
||||
border-top: 1px solid var(--border);
|
||||
text-align: left;
|
||||
|
||||
& > div {
|
||||
flex: 1 1 0;
|
||||
padding: 32px;
|
||||
@media (max-width: 1024px) {
|
||||
padding: 24px 20px;
|
||||
}
|
||||
}
|
||||
|
||||
.icon {
|
||||
margin-bottom: 16px;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
}
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
flex-direction: column;
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
|
||||
#docs {
|
||||
border-right: 1px solid var(--border);
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
border-right: none;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
}
|
||||
|
||||
#next-steps ul {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin: 32px 0 0;
|
||||
|
||||
.logo {
|
||||
height: 18px;
|
||||
}
|
||||
|
||||
a {
|
||||
color: var(--text-h);
|
||||
font-size: 16px;
|
||||
border-radius: 6px;
|
||||
background: var(--social-bg);
|
||||
display: flex;
|
||||
padding: 6px 12px;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
text-decoration: none;
|
||||
transition: box-shadow 0.3s;
|
||||
|
||||
&:hover {
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
.button-icon {
|
||||
height: 18px;
|
||||
width: 18px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
margin-top: 20px;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
|
||||
li {
|
||||
flex: 1 1 calc(50% - 8px);
|
||||
}
|
||||
|
||||
a {
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#spacer {
|
||||
height: 88px;
|
||||
border-top: 1px solid var(--border);
|
||||
@media (max-width: 1024px) {
|
||||
height: 48px;
|
||||
}
|
||||
}
|
||||
|
||||
.ticks {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
|
||||
&::before,
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: -4.5px;
|
||||
border: 5px solid transparent;
|
||||
}
|
||||
|
||||
&::before {
|
||||
left: 0;
|
||||
border-left-color: var(--border);
|
||||
}
|
||||
&::after {
|
||||
right: 0;
|
||||
border-right-color: var(--border);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,59 @@
|
||||
/* 管理后台全局样式 */
|
||||
|
||||
/* 重置默认样式 */
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html, body, #app {
|
||||
height: 100%;
|
||||
font-family: 'Helvetica Neue', Helvetica, 'PingFang SC', 'Hiragino Sans GB',
|
||||
'Microsoft YaHei', '微软雅黑', Arial, sans-serif;
|
||||
}
|
||||
|
||||
/* Element Plus表格统一样式 */
|
||||
.el-table {
|
||||
--el-table-header-bg-color: #f5f7fa;
|
||||
}
|
||||
|
||||
/* 确认框警告图标颜色 */
|
||||
.el-message-box__status.el-icon {
|
||||
color: var(--el-color-warning) !important;
|
||||
}
|
||||
|
||||
/* 行内表单中的下拉框最小宽度,确保文字正常显示 */
|
||||
.el-form--inline .el-select {
|
||||
min-width: 120px;
|
||||
}
|
||||
|
||||
/* 分页组件间距 */
|
||||
.el-pagination {
|
||||
margin-top: 16px;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
/* 通用间距 */
|
||||
.mb-16 { margin-bottom: 16px; }
|
||||
.mt-20 { margin-top: 20px; }
|
||||
.gap-12 { gap: 12px; }
|
||||
|
||||
/* Flex布局 */
|
||||
.flex-center { display: flex; align-items: center; }
|
||||
.flex-between { display: flex; justify-content: space-between; align-items: center; }
|
||||
.flex-gap { display: flex; align-items: center; gap: 12px; }
|
||||
|
||||
/* 卡片间距 */
|
||||
.page-card { margin-bottom: 16px; }
|
||||
.card + .card { margin-top: 20px; }
|
||||
|
||||
/* 表单操作栏 */
|
||||
.action-bar { display: flex; align-items: center; gap: 8px; margin-bottom: 16px; }
|
||||
|
||||
/* 页面标题 */
|
||||
.page-title { font-size: 16px; font-weight: bold; }
|
||||
|
||||
/* 筛选栏 */
|
||||
.filter-bar { margin-bottom: 16px; }
|
||||
.filter-bar .el-form-item { margin-bottom: 0; }
|
||||
@ -0,0 +1,66 @@
|
||||
import axios, { type AxiosResponse, type InternalAxiosRequestConfig } from 'axios'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import router from '@/router'
|
||||
|
||||
// 创建axios实例
|
||||
const service = axios.create({
|
||||
timeout: 30000,
|
||||
})
|
||||
|
||||
// 请求拦截器
|
||||
service.interceptors.request.use(
|
||||
(config: InternalAxiosRequestConfig) => {
|
||||
// 根据当前路由判断是否为Mock模式
|
||||
const isMock = window.location.pathname.startsWith('/mock')
|
||||
config.baseURL = isMock ? '/mock-api' : '/api'
|
||||
|
||||
// 自动添加Token
|
||||
const token = localStorage.getItem('token')
|
||||
if (token) {
|
||||
config.headers.Authorization = `Bearer ${token}`
|
||||
}
|
||||
return config
|
||||
},
|
||||
(error) => Promise.reject(error)
|
||||
)
|
||||
|
||||
// 响应拦截器
|
||||
service.interceptors.response.use(
|
||||
(response: AxiosResponse) => {
|
||||
const res = response.data
|
||||
if (res.code === 0) {
|
||||
return res
|
||||
}
|
||||
|
||||
// Token无效/过期
|
||||
if (res.code === 40101) {
|
||||
localStorage.removeItem('token')
|
||||
const isMock = window.location.pathname.startsWith('/mock')
|
||||
router.push(isMock ? '/mock/login' : '/login')
|
||||
ElMessage.warning('登录已过期')
|
||||
return Promise.reject(new Error(res.message))
|
||||
}
|
||||
|
||||
// 其他业务错误
|
||||
const errorMessages: Record<number, string> = {
|
||||
40001: res.message || '参数校验失败',
|
||||
40002: res.message || '资源不存在',
|
||||
40003: res.message || '资源已存在',
|
||||
50001: '服务器错误',
|
||||
50002: '采集服务未响应',
|
||||
}
|
||||
const msg = errorMessages[res.code] || res.message || '请求失败'
|
||||
ElMessage.error(msg)
|
||||
return Promise.reject(new Error(msg))
|
||||
},
|
||||
(error) => {
|
||||
if (error.response) {
|
||||
ElMessage.error('网络异常')
|
||||
} else if (error.code === 'ECONNABORTED') {
|
||||
ElMessage.error('请求超时')
|
||||
}
|
||||
return Promise.reject(error)
|
||||
}
|
||||
)
|
||||
|
||||
export default service
|
||||
@ -0,0 +1,201 @@
|
||||
<template>
|
||||
<div>
|
||||
<!-- 统计区 -->
|
||||
<el-row :gutter="16" style="margin-bottom:20px">
|
||||
<el-col :span="4"><el-card shadow="hover"><div style="text-align:center;padding:8px"><div style="color:#909399;font-size:12px">未处理</div><div style="font-size:24px;font-weight:bold;color:#e6a23c">{{ stats.unresolved }}</div></div></el-card></el-col>
|
||||
<el-col :span="4"><el-card shadow="hover"><div style="text-align:center;padding:8px"><div style="color:#909399;font-size:12px">采集失败</div><div style="font-size:24px;font-weight:bold">{{ stats.collectFail }}</div></div></el-card></el-col>
|
||||
<el-col :span="4"><el-card shadow="hover"><div style="text-align:center;padding:8px"><div style="color:#909399;font-size:12px">设备离线</div><div style="font-size:24px;font-weight:bold">{{ stats.deviceOffline }}</div></div></el-card></el-col>
|
||||
<el-col :span="4"><el-card shadow="hover"><div style="text-align:center;padding:8px"><div style="color:#909399;font-size:12px">产量异常</div><div style="font-size:24px;font-weight:bold">{{ stats.productionAnomaly }}</div></div></el-card></el-col>
|
||||
<el-col :span="4"><el-card shadow="hover"><div style="text-align:center;padding:8px"><div style="color:#909399;font-size:12px">未知设备</div><div style="font-size:24px;font-weight:bold">{{ stats.unknownDevice }}</div></div></el-card></el-col>
|
||||
</el-row>
|
||||
|
||||
<!-- 查询条件 -->
|
||||
<el-form :inline="true" class="mb-16">
|
||||
<el-form-item label="告警类型">
|
||||
<el-select v-model="query.alertType" clearable placeholder="全部">
|
||||
<el-option label="采集失败" value="collect_fail" />
|
||||
<el-option label="设备离线" value="device_offline" />
|
||||
<el-option label="产量异常" value="production_anomaly" />
|
||||
<el-option label="未知设备" value="unknown_device" />
|
||||
<el-option label="服务错误" value="service_error" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="处理状态">
|
||||
<el-select v-model="query.isResolved" clearable placeholder="全部">
|
||||
<el-option label="未处理" :value="0" />
|
||||
<el-option label="已处理" :value="1" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="时间范围">
|
||||
<el-date-picker v-model="query.dateRange" type="daterange" value-format="YYYY-MM-DD" clearable />
|
||||
</el-form-item>
|
||||
<el-form-item label="机床">
|
||||
<el-select v-model="query.machineId" filterable clearable placeholder="全部">
|
||||
<el-option v-for="m in machineList" :key="m.id" :label="m.name" :value="m.id" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-input v-model="query.keyword" placeholder="标题/详情" clearable />
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" @click="loadData">查询</el-button>
|
||||
<el-button @click="resetQuery">重置</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
<!-- 操作栏 -->
|
||||
<div style="margin-bottom:12px">
|
||||
<el-button type="primary" @click="batchResolve" :disabled="!selectedRows.length || !selectedRows.some(r => !r.isResolved)">批量标记已处理</el-button>
|
||||
</div>
|
||||
|
||||
<!-- 列表 -->
|
||||
<el-table :data="tableData" border stripe v-loading="loading" @selection-change="(rows: Record<string,unknown>[]) => selectedRows = rows">
|
||||
<el-table-column type="selection" width="40" fixed="left" align="center" />
|
||||
<el-table-column prop="createdAt" label="告警时间" width="170" sortable />
|
||||
<el-table-column label="告警类型" width="100" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="alertTypeTag(row.alertType)" size="small">{{ alertTypeLabel(row.alertType) }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="title" label="标题" width="160" show-overflow-tooltip />
|
||||
<el-table-column prop="machineName" label="机床" width="100" show-overflow-tooltip align="center" />
|
||||
<el-table-column prop="detail" label="详情" show-overflow-tooltip />
|
||||
<el-table-column label="处理状态" width="80" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.isResolved ? 'success' : 'danger'" size="small">{{ row.isResolved ? '已处理' : '未处理' }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="120" fixed="right" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-button v-if="!row.isResolved" link type="primary" @click="handleResolve(row)">标记已处理</el-button>
|
||||
<el-button v-else link type="primary" @click="viewDetail(row)">查看</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<!-- 分页 -->
|
||||
<el-pagination
|
||||
v-model:current-page="page.page"
|
||||
v-model:page-size="page.pageSize"
|
||||
:page-sizes="[20, 50, 100]"
|
||||
:total="page.total"
|
||||
background
|
||||
layout="total, sizes, prev, pager, next, jumper"
|
||||
/>
|
||||
|
||||
<!-- 告警详情弹窗 -->
|
||||
<el-dialog v-model="detailVisible" title="告警详情" width="600px" destroy-on-close>
|
||||
<el-descriptions :column="1" border size="small">
|
||||
<el-descriptions-item label="告警时间">{{ detailRow.createdAt }}</el-descriptions-item>
|
||||
<el-descriptions-item label="告警类型">
|
||||
<el-tag :type="alertTypeTag(detailRow.alertType)" size="small">{{ alertTypeLabel(detailRow.alertType) }}</el-tag>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="标题">{{ detailRow.title }}</el-descriptions-item>
|
||||
<el-descriptions-item label="机床">{{ detailRow.machineName || '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="详情">{{ detailRow.detail }}</el-descriptions-item>
|
||||
<el-descriptions-item label="处理状态">
|
||||
<el-tag :type="detailRow.isResolved ? 'success' : 'danger'" size="small">{{ detailRow.isResolved ? '已处理' : '未处理' }}</el-tag>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="处理时间">{{ detailRow.resolvedAt || '-' }}</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
<template #footer><el-button @click="detailVisible = false">关闭</el-button></template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import request from '@/utils/request'
|
||||
|
||||
const loading = ref(false)
|
||||
const tableData = ref<Alert[]>([])
|
||||
const selectedRows = ref<Alert[]>([])
|
||||
const stats = ref<Alert>({} as Alert)
|
||||
const machineList = ref<Alert[]>([])
|
||||
const detailVisible = ref(false)
|
||||
const detailRow = ref<Alert>({} as Alert)
|
||||
|
||||
const query = reactive({
|
||||
alertType: '',
|
||||
isResolved: 0 as number | undefined,
|
||||
dateRange: null as string[] | null,
|
||||
machineId: undefined as number | undefined,
|
||||
keyword: '',
|
||||
})
|
||||
|
||||
const page = reactive({ page: 1, pageSize: 20, total: 0 })
|
||||
|
||||
// 告警类型标签映射
|
||||
function alertTypeTag(type: string): string {
|
||||
const map: Record<string, string> = {
|
||||
collect_fail: 'warning',
|
||||
device_offline: 'danger',
|
||||
production_anomaly: 'warning',
|
||||
unknown_device: 'info',
|
||||
service_error: 'danger',
|
||||
}
|
||||
return map[type] || 'info'
|
||||
}
|
||||
|
||||
function alertTypeLabel(type: string): string {
|
||||
const map: Record<string, string> = {
|
||||
collect_fail: '采集失败',
|
||||
device_offline: '设备离线',
|
||||
production_anomaly: '产量异常',
|
||||
unknown_device: '未知设备',
|
||||
service_error: '服务错误',
|
||||
}
|
||||
return map[type] || type
|
||||
}
|
||||
|
||||
async function loadData() {
|
||||
loading.value = true
|
||||
try {
|
||||
const [s, d]: Record<string,unknown>[] = await Promise.all([
|
||||
request.get('/admin/alert/statistics'),
|
||||
request.get('/admin/alert', { params: { ...query, ...page } }),
|
||||
])
|
||||
stats.value = s.data || {}
|
||||
tableData.value = d.data?.items || []
|
||||
page.total = d.data?.total || 0
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function resetQuery() {
|
||||
Object.assign(query, { alertType: '', isResolved: 0, dateRange: null, machineId: undefined, keyword: '' })
|
||||
loadData()
|
||||
}
|
||||
|
||||
async function handleResolve(row: Alert) {
|
||||
await ElMessageBox.confirm('确定标记为已处理?', '提示', { type: 'warning' })
|
||||
await request.post('/admin/alert/resolve', { id: row.id })
|
||||
ElMessage.success('已标记为已处理')
|
||||
loadData()
|
||||
}
|
||||
|
||||
async function batchResolve() {
|
||||
const unresolved = selectedRows.value.filter(r => !r.isResolved)
|
||||
await ElMessageBox.confirm(`确定对选中的${unresolved.length}项标记为已处理?`, '提示', { type: 'warning' })
|
||||
await request.post('/admin/alert/batch-resolve', { ids: unresolved.map(r => r.id) })
|
||||
ElMessage.success('批量标记成功')
|
||||
loadData()
|
||||
}
|
||||
|
||||
function viewDetail(row: Alert) {
|
||||
detailRow.value = row
|
||||
detailVisible.value = true
|
||||
}
|
||||
|
||||
async function loadDrops() {
|
||||
const r: Record<string,unknown> = await request.get('/admin/machine/list')
|
||||
machineList.value = r.data?.items || []
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadData()
|
||||
loadDrops()
|
||||
})
|
||||
</script>
|
||||
@ -0,0 +1,56 @@
|
||||
<template>
|
||||
<div>
|
||||
<div style="display:flex;align-items:center;gap:12px;margin-bottom:20px">
|
||||
<el-button @click="$router.back()"><el-icon><ArrowLeft /></el-icon> 返回</el-button>
|
||||
<span style="font-size:16px;font-weight:bold">{{ isEdit ? '编辑品牌' : '新增品牌' }}</span>
|
||||
</div>
|
||||
<el-card shadow="hover" style="margin-bottom:20px">
|
||||
<el-form :model="form" label-width="100px">
|
||||
<el-form-item label="品牌名称" required><el-input v-model="form.brandName" maxlength="50" /></el-form-item>
|
||||
<el-form-item label="device字段" required><el-input v-model="form.deviceField" /></el-form-item>
|
||||
<el-form-item label="tags路径" required><el-input v-model="form.tagsPath" /></el-form-item>
|
||||
</el-form>
|
||||
</el-card>
|
||||
<el-card shadow="hover">
|
||||
<template #header><div style="display:flex;justify-content:space-between"><span>字段映射列表</span><el-button size="small" @click="addMapping">+ 新增映射</el-button></div></template>
|
||||
<el-table :data="form.mappings" border stripe size="small">
|
||||
<el-table-column label="标准字段" width="160"><template #default="{row}"><el-select v-model="row.standardField"><el-option v-for="f in standardFields" :key="f" :label="f" :value="f" /></el-select></template></el-table-column>
|
||||
<el-table-column label="字段名" width="120"><template #default="{row}"><el-input v-model="row.fieldName" /></template></el-table-column>
|
||||
<el-table-column label="匹配方式" width="100"><template #default="{row}"><el-select v-model="row.matchBy"><el-option label="id" value="id" /><el-option label="desc" value="desc" /></el-select></template></el-table-column>
|
||||
<el-table-column label="数据类型" width="100"><template #default="{row}"><el-select v-model="row.dataType"><el-option label="string" value="string" /><el-option label="number" value="number" /></el-select></template></el-table-column>
|
||||
<el-table-column label="必填" width="60" align="center"><template #default="{row}"><el-checkbox v-model="row.isRequired" :true-value="1" :false-value="0" /></template></el-table-column>
|
||||
<el-table-column label="操作" width="80" align="center"><template #default="{ $index }"><el-button link type="danger" @click="form.mappings.splice($index, 1)">删除</el-button></template></el-table-column>
|
||||
</el-table>
|
||||
</el-card>
|
||||
<div style="margin-top:20px;text-align:right">
|
||||
<el-button @click="$router.back()">取消</el-button>
|
||||
<el-button type="primary" :loading="submitting" @click="handleSave">保存</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import request from '@/utils/request'
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
import type { Brand, ApiResponse } from '@/types'
|
||||
type BrandMappingForm = { standardField: string; fieldName: string; matchBy: string; dataType: string; isRequired: number }
|
||||
const isEdit = !!route.params.id
|
||||
const submitting = ref(false)
|
||||
const standardFields = ['program_name','part_count','device_status','run_status','operate_mode','spindle_speed_set','feed_speed_set','spindle_speed_actual','feed_speed_actual','spindle_load','spindle_override','power_on_time','run_time','cutting_time','cycle_time','machining_status']
|
||||
const form = reactive({ brandName: '', deviceField: 'device', tagsPath: 'tags', mappings: [] as BrandMappingForm[] })
|
||||
function addMapping() { form.mappings.push({ standardField: '', fieldName: '', matchBy: 'id', dataType: 'string', isRequired: 0 }) }
|
||||
async function loadData() {
|
||||
if (!isEdit) return
|
||||
const r: Record<string,unknown> = await request.get('/admin/brand/detail', { params: { id: route.params.id } })
|
||||
if (r.data) { form.brandName = r.data.brandName; form.deviceField = r.data.deviceField; form.tagsPath = r.data.tagsPath; form.mappings = r.data.mappings || [] }
|
||||
}
|
||||
async function handleSave() {
|
||||
await ElMessageBox.confirm('品牌模板修改不影响历史数据,确定保存?', '提示', { type: 'warning' })
|
||||
submitting.value = true
|
||||
try { await request.post(isEdit ? '/admin/brand/update' : '/admin/brand', { ...form, id: isEdit ? route.params.id : undefined }); ElMessage.success('保存成功'); router.back() } finally { submitting.value = false }
|
||||
}
|
||||
onMounted(loadData)
|
||||
</script>
|
||||
@ -0,0 +1,298 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="mb-16">
|
||||
<el-button type="primary" @click="handleAdd">+ 新增机床</el-button>
|
||||
<el-button @click="importVisible = true">导入</el-button>
|
||||
<el-button type="success" @click="handleExport">导出</el-button>
|
||||
</div>
|
||||
|
||||
<el-form :inline="true" class="mb-16">
|
||||
<el-form-item label="车间">
|
||||
<el-select v-model="query.workshopId" clearable placeholder="全部">
|
||||
<el-option v-for="w in workshopList" :key="w.id" :label="w.name" :value="w.id" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="在线状态">
|
||||
<el-select v-model="query.isOnline" clearable placeholder="全部">
|
||||
<el-option label="在线" :value="1" />
|
||||
<el-option label="离线" :value="0" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="品牌">
|
||||
<el-select v-model="query.brandId" clearable placeholder="全部">
|
||||
<el-option v-for="b in brandList" :key="b.id" :label="b.brandName" :value="b.id" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-input v-model="query.keyword" placeholder="机床名称/device_code" clearable />
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" @click="loadData">查询</el-button>
|
||||
<el-button @click="resetQuery">重置</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
<el-table :data="tableData" border stripe v-loading="loading" @selection-change="(rows: Record<string,unknown>[]) => selectedRows = rows">
|
||||
<el-table-column type="selection" width="40" fixed="left" align="center" />
|
||||
<el-table-column label="机床名称" min-width="120" fixed="left" show-overflow-tooltip>
|
||||
<template #default="{ row }">
|
||||
<el-link type="primary" @click="goDetail(row.id)">{{ row.name }}</el-link>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="deviceCode" label="device_code" width="140" show-overflow-tooltip sortable />
|
||||
<el-table-column prop="workshopName" label="车间" width="80" align="center" sortable />
|
||||
<el-table-column prop="brandName" label="品牌" width="100" align="center" sortable />
|
||||
<el-table-column prop="ipAddress" label="IP地址" width="120" />
|
||||
<el-table-column label="在线状态" width="80" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.isOnline ? 'success' : 'info'" size="small">{{ row.isOnline ? '在线' : '离线' }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="状态" width="80" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.isEnabled ? 'success' : 'danger'" size="small">{{ row.isEnabled ? '启用' : '停用' }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="workerName" label="绑定工人" width="100" align="center">
|
||||
<template #default="{ row }">{{ row.workerName || '-' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="120" fixed="right" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-button type="primary" plain @click="handleViewDetail(row)" style="margin-right:6px">
|
||||
<el-icon><View/></el-icon>
|
||||
查看详情
|
||||
</el-button>
|
||||
<el-button type="primary" plain @click="handleEdit(row)">编辑</el-button>
|
||||
<el-button link type="danger" @click="handleDelete(row)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<div v-if="selectedRows.length" style="margin-top:12px;padding:8px;background:#f5f7fa;border-radius:4px;display:flex;align-items:center;gap:12px">
|
||||
<span>已选{{ selectedRows.length }}项</span>
|
||||
<el-button size="small" @click="batchToggle(0)" :disabled="!selectedRows.some(r => r.isEnabled)">批量停用</el-button>
|
||||
<el-button size="small" type="primary" @click="batchToggle(1)" :disabled="!selectedRows.some(r => !r.isEnabled)">批量启用</el-button>
|
||||
</div>
|
||||
|
||||
<el-pagination
|
||||
v-model:current-page="page.page"
|
||||
v-model:page-size="page.pageSize"
|
||||
:page-sizes="[20, 50, 100]"
|
||||
:total="page.total"
|
||||
background
|
||||
layout="total, sizes, prev, pager, next, jumper"
|
||||
style="margin-top:16px"
|
||||
/>
|
||||
|
||||
<!-- 新增/编辑弹窗 -->
|
||||
<el-dialog v-model="dialogVisible" :title="editingId ? '编辑机床' : '新增机床'" width="600px" destroy-on-close>
|
||||
<el-form ref="formRef" :model="form" :rules="formRules" label-width="100px">
|
||||
<el-form-item label="机床名称" prop="name"><el-input v-model="form.name" maxlength="100" /></el-form-item>
|
||||
<el-form-item label="device_code" prop="deviceCode"><el-input v-model="form.deviceCode" maxlength="100" :disabled="!!editingId" /></el-form-item>
|
||||
<el-form-item label="所属车间" prop="workshopId">
|
||||
<el-select v-model="form.workshopId"><el-option v-for="w in workshopList" :key="w.id" :label="w.name" :value="w.id" /></el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="采集地址" prop="collectAddressId">
|
||||
<el-select v-model="form.collectAddressId" @change="onAddressChange">
|
||||
<el-option v-for="a in addressList" :key="a.id" :label="a.name" :value="a.id" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="品牌"><el-input v-model="form.brandName" disabled /></el-form-item>
|
||||
<el-form-item label="IP地址" prop="ipAddress"><el-input v-model="form.ipAddress" /></el-form-item>
|
||||
<el-form-item label="绑定工人">
|
||||
<el-select v-model="form.workerId" clearable>
|
||||
<el-option v-for="w in workerList" :key="w.id" :label="w.name" :value="w.id" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="dialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="submitting" @click="handleSubmit">保存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 导入弹窗 -->
|
||||
<el-dialog v-model="importVisible" title="导入机床" width="500px" destroy-on-close>
|
||||
<el-button type="primary" link @click="downloadTemplate">下载模板</el-button>
|
||||
<el-upload ref="uploadRef" accept=".xlsx" :limit="1" :auto-upload="false" :on-change="onFileChange" style="margin:16px 0">
|
||||
<el-button>选择文件</el-button>
|
||||
</el-upload>
|
||||
<div v-if="importResult">
|
||||
<el-tag type="success">成功{{ importResult.successCount }}条</el-tag>
|
||||
<el-tag v-if="importResult.failCount" type="danger" style="margin-left:8px">失败{{ importResult.failCount }}条</el-tag>
|
||||
<div v-if="importResult.failDetails?.length" style="margin-top:8px;color:#f56c6c;font-size:12px">
|
||||
<div v-for="(d, i) in importResult.failDetails" :key="i">{{ d }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<template #footer>
|
||||
<el-button @click="importVisible = false">关闭</el-button>
|
||||
<el-button type="primary" :loading="importing" @click="handleImport">确认导入</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import type { FormInstance, FormRules } from 'element-plus'
|
||||
import request from '@/utils/request'
|
||||
import { useMockMode } from '@/composables/useMockMode'
|
||||
import { View } from '@element-plus/icons-vue'
|
||||
import type { ApiResponse, Machine, Workshop, Brand, CollectAddress, Worker } from '@/types'
|
||||
|
||||
const router = useRouter()
|
||||
const { isMock } = useMockMode()
|
||||
|
||||
const loading = ref(false)
|
||||
const tableData = ref<Machine[]>([])
|
||||
const selectedRows = ref<Machine[]>([])
|
||||
const workshopList = ref<Workshop[]>([])
|
||||
const brandList = ref<Brand[]>([])
|
||||
const addressList = ref<CollectAddress[]>([])
|
||||
const workerList = ref<Worker[]>([])
|
||||
|
||||
const query = reactive({
|
||||
workshopId: undefined as number | undefined,
|
||||
isOnline: undefined as number | undefined,
|
||||
brandId: undefined as number | undefined,
|
||||
keyword: '',
|
||||
})
|
||||
const page = reactive({ page: 1, pageSize: 20, total: 0 })
|
||||
|
||||
// 弹窗
|
||||
const dialogVisible = ref(false)
|
||||
const submitting = ref(false)
|
||||
const editingId = ref<number | null>(null)
|
||||
const formRef = ref<FormInstance>()
|
||||
const form = reactive({
|
||||
name: '', deviceCode: '', workshopId: undefined as number | undefined,
|
||||
collectAddressId: undefined as number | undefined, brandName: '',
|
||||
ipAddress: '', workerId: undefined as number | undefined,
|
||||
})
|
||||
|
||||
const formRules: FormRules = {
|
||||
name: [{ required: true, message: '请输入机床名称', trigger: 'blur' }],
|
||||
deviceCode: [{ required: true, message: '请输入device_code', trigger: 'blur' }],
|
||||
workshopId: [{ required: true, message: '请选择车间', trigger: 'change' }],
|
||||
collectAddressId: [{ required: true, message: '请选择采集地址', trigger: 'change' }],
|
||||
ipAddress: [{ required: true, message: '请输入IP地址', trigger: 'blur' }],
|
||||
}
|
||||
|
||||
// 导入
|
||||
const importVisible = ref(false)
|
||||
const importing = ref(false)
|
||||
const importFile = ref<File | null>(null)
|
||||
const importResult = ref<any>(null)
|
||||
|
||||
function goDetail(id: number) {
|
||||
router.push((isMock.value ? '/mock/machine/' : '/machine/') + id)
|
||||
}
|
||||
|
||||
/** 查看详情:在普通路由和 Mock 路径下跳转到详情页 */
|
||||
function handleViewDetail(row: Machine) {
|
||||
const currentPath = window.location.pathname
|
||||
const isMockPath = currentPath.startsWith('/mock')
|
||||
const prefix = isMockPath ? '/mock' : ''
|
||||
router.push(`${prefix}/machine/${row.id}`)
|
||||
}
|
||||
|
||||
async function loadData() {
|
||||
loading.value = true
|
||||
try {
|
||||
const r: ApiResponse<{ items: Machine[]; total?: number }> = await request.get('/admin/machine', { params: { ...query, page: page.page, pageSize: page.pageSize } })
|
||||
tableData.value = r.data?.items || []
|
||||
page.total = r.data?.total ?? 0
|
||||
} finally { loading.value = false }
|
||||
}
|
||||
|
||||
function resetQuery() {
|
||||
Object.assign(query, { workshopId: undefined, isOnline: undefined, brandId: undefined, keyword: '' })
|
||||
page.page = 1
|
||||
loadData()
|
||||
}
|
||||
|
||||
function handleAdd() {
|
||||
editingId.value = null
|
||||
Object.assign(form, { name: '', deviceCode: '', workshopId: undefined, collectAddressId: undefined, brandName: '', ipAddress: '', workerId: undefined })
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
function handleEdit(row: Machine) {
|
||||
editingId.value = row.id
|
||||
Object.assign(form, {
|
||||
name: row.name, deviceCode: row.deviceCode, workshopId: row.workshopId,
|
||||
collectAddressId: row.collectAddressId, brandName: row.brandName,
|
||||
ipAddress: row.ipAddress, workerId: row.workerId,
|
||||
})
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
function onAddressChange(addressId: number) {
|
||||
const addr = addressList.value.find((a: CollectAddress) => a.id === addressId)
|
||||
form.brandName = addr ? addr.brandName : ''
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
const valid = await formRef.value?.validate().catch(() => false)
|
||||
if (!valid) return
|
||||
submitting.value = true
|
||||
try {
|
||||
await request.post(editingId.value ? '/admin/machine/update' : '/admin/machine', { ...form, id: editingId.value })
|
||||
ElMessage.success('保存成功')
|
||||
dialogVisible.value = false
|
||||
loadData()
|
||||
} finally { submitting.value = false }
|
||||
}
|
||||
|
||||
async function handleDelete(row: Machine) {
|
||||
await ElMessageBox.confirm(`确定删除【${row.name}】?此操作不可恢复。`, '提示', { type: 'warning' })
|
||||
await request.post('/admin/machine/delete', { id: row.id })
|
||||
ElMessage.success('已删除')
|
||||
loadData()
|
||||
}
|
||||
|
||||
async function batchToggle(isEnabled: number) {
|
||||
await ElMessageBox.confirm(`确定对选中的${selectedRows.value.length}项操作?`, '提示', { type: 'warning' })
|
||||
await request.post('/admin/machine/batch-toggle', { ids: selectedRows.value.map((r: {id:number}) => r.id), isEnabled })
|
||||
ElMessage.success('操作成功')
|
||||
loadData()
|
||||
}
|
||||
|
||||
async function handleExport() {
|
||||
const baseURL = (request as any).defaults?.baseURL || ''
|
||||
const qs = new URLSearchParams(query as any).toString()
|
||||
window.open(`${baseURL}/admin/machine/export?${qs}`, '_blank')
|
||||
ElMessage.success('正在导出...')
|
||||
}
|
||||
|
||||
function onFileChange(file: Record<string,unknown>) { importFile.value = file.raw }
|
||||
function downloadTemplate() { window.open('/api/admin/machine/import-template', '_blank') }
|
||||
|
||||
async function handleImport() {
|
||||
if (!importFile.value) { ElMessage.warning('请选择文件'); return }
|
||||
importing.value = true
|
||||
try {
|
||||
const formData = new FormData()
|
||||
formData.append('file', importFile.value)
|
||||
const r: Record<string,unknown> = await request.post('/admin/machine/import', formData)
|
||||
importResult.value = r.data
|
||||
if (r.data?.successCount) ElMessage.success(`成功导入${r.data.successCount}条`)
|
||||
loadData()
|
||||
} finally { importing.value = false }
|
||||
}
|
||||
|
||||
async function loadDrops() {
|
||||
const w: ApiResponse<{ items: Workshop[] }> = await request.get('/admin/workshop')
|
||||
const b: ApiResponse<{ items: Brand[] }> = await request.get('/admin/brand/list')
|
||||
const a: ApiResponse<{ items: CollectAddress[] }> = await request.get('/admin/collect-address')
|
||||
const wk: ApiResponse<{ items: Worker[] }> = await request.get('/admin/worker/list')
|
||||
workshopList.value = w.data?.items ?? []
|
||||
brandList.value = b.data?.items ?? []
|
||||
addressList.value = a.data?.items ?? []
|
||||
workerList.value = wk.data?.items ?? []
|
||||
}
|
||||
|
||||
onMounted(() => { loadData(); loadDrops() })
|
||||
</script>
|
||||
@ -0,0 +1,334 @@
|
||||
<template>
|
||||
<div>
|
||||
<!-- 卡片配置区 -->
|
||||
<h3 style="margin-bottom:12px">卡片配置</h3>
|
||||
<div style="margin-bottom:12px">
|
||||
<el-button type="primary" @click="addCard">+ 新增卡片</el-button>
|
||||
</div>
|
||||
<el-table :data="cardList" border stripe v-loading="cardLoading" style="margin-bottom:32px" :row-class-name="getCardRowClass">
|
||||
<!-- 拖拽手柄列 -->
|
||||
<el-table-column label="拖拽" width="48" align="center">
|
||||
<template #default="{ row, $index }">
|
||||
<i class="el-icon-rank" draggable="true" @dragstart="onCardDragStart($event, $index)" @dragover.prevent @dragenter.prevent @drop="onCardDrop($event, $index)" style="cursor:grab"></i>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="title" label="卡片名称" min-width="150" show-overflow-tooltip />
|
||||
<el-table-column label="卡片类型" width="120" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag size="small">{{ cardTypeLabel(row.cardType) }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="metric" label="指标" width="120" show-overflow-tooltip>
|
||||
<template #default="{ row }">{{ metricLabel(row.metric) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="sortOrder" label="排序" width="80" align="center" sortable />
|
||||
<el-table-column label="状态" width="80" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.isEnabled ? 'success' : 'danger'" size="small">{{ row.isEnabled ? '启用' : '停用' }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="200" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-button link type="primary" @click="editCard(row)">编辑</el-button>
|
||||
<el-button link type="primary" @click="toggleCard(row)">{{ row.isEnabled ? '禁用' : '启用' }}</el-button>
|
||||
<el-button link type="danger" @click="deleteCard(row)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<el-empty v-if="!cardLoading && cardList.length === 0" description="暂无卡片配置,点击新增卡片添加" />
|
||||
|
||||
<!-- 筛选配置区 -->
|
||||
<h3 style="margin-bottom:12px">筛选配置</h3>
|
||||
<div style="margin-bottom:12px">
|
||||
<el-button type="primary" @click="addFilter">+ 新增筛选</el-button>
|
||||
</div>
|
||||
<el-table :data="filterList" border stripe v-loading="filterLoading">
|
||||
<el-table-column prop="screenKey" label="筛选标识" width="120" show-overflow-tooltip />
|
||||
<el-table-column label="筛选类型" width="120" align="center">
|
||||
<template #default="{ row }">{{ filterTypeLabel(row.filterType) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="filterValue" label="筛选值" min-width="150" show-overflow-tooltip />
|
||||
<el-table-column label="默认" width="60" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.isDefault ? 'success' : 'info'" size="small">{{ row.isDefault ? '是' : '否' }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="sortOrder" label="排序" width="80" align="center" sortable />
|
||||
<el-table-column label="操作" width="120" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-button link type="primary" @click="editFilter(row)">编辑</el-button>
|
||||
<el-button link type="danger" @click="deleteFilter(row)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<el-empty v-if="!filterLoading && filterList.length === 0" description="暂无筛选配置" />
|
||||
|
||||
<!-- 卡片弹窗 -->
|
||||
<el-dialog v-model="cardDialogVisible" :title="editingCardId ? '编辑卡片' : '新增卡片'" width="600px" destroy-on-close>
|
||||
<el-form ref="cardFormRef" :model="cardForm" :rules="cardRules" label-width="100px">
|
||||
<el-form-item label="卡片名称" prop="title"><el-input v-model="cardForm.title" maxlength="100" /></el-form-item>
|
||||
<el-form-item label="卡片标识" prop="cardKey"><el-input v-model="cardForm.cardKey" maxlength="50" :disabled="!!editingCardId" /></el-form-item>
|
||||
<el-form-item label="卡片类型" prop="cardType">
|
||||
<el-select v-model="cardForm.cardType" @change="onCardTypeChange">
|
||||
<el-option label="统计数字" value="stat_number" />
|
||||
<el-option label="柱状图" value="bar_chart" />
|
||||
<el-option label="折线图" value="line_chart" />
|
||||
<el-option label="饼图" value="pie_chart" />
|
||||
<el-option label="状态网格" value="status_grid" />
|
||||
<el-option label="排行榜" value="rank_list" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="指标" prop="metric">
|
||||
<el-select v-model="cardForm.metric" clearable>
|
||||
<el-option label="在线数量" value="online_count" />
|
||||
<el-option label="零件计数" value="part_count" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="维度" :required="needsDimension" :prop="needsDimension ? 'dimension' : ''">
|
||||
<el-select v-model="cardForm.dimension" clearable>
|
||||
<el-option label="车间" value="workshop" />
|
||||
<el-option label="工人" value="worker" />
|
||||
<el-option label="机床" value="machine" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="排序号" prop="sortOrder"><el-input-number v-model="cardForm.sortOrder" :min="1" :max="99" /></el-form-item>
|
||||
<el-form-item v-if="isChartType" label="图表配置" prop="chartConfig">
|
||||
<el-input v-model="cardForm.chartConfig" type="textarea" :rows="4" placeholder='{"xAxis":"workshop","yAxis":"part_count"}' />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="cardDialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="cardSubmitting" @click="saveCard">保存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 筛选弹窗 -->
|
||||
<el-dialog v-model="filterDialogVisible" :title="editingFilterId ? '编辑筛选' : '新增筛选'" width="500px" destroy-on-close>
|
||||
<el-form ref="filterFormRef" :model="filterForm" :rules="filterRules" label-width="100px">
|
||||
<el-form-item label="筛选标识" prop="screenKey"><el-input v-model="filterForm.screenKey" maxlength="50" /></el-form-item>
|
||||
<el-form-item label="筛选类型" prop="filterType">
|
||||
<el-select v-model="filterForm.filterType">
|
||||
<el-option label="车间" value="workshop" />
|
||||
<el-option label="品牌" value="brand" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="筛选值" prop="filterValue"><el-input v-model="filterForm.filterValue" maxlength="100" /></el-form-item>
|
||||
<el-form-item label="默认选中"><el-switch v-model="filterForm.isDefault" /></el-form-item>
|
||||
<el-form-item label="排序号" prop="sortOrder"><el-input-number v-model="filterForm.sortOrder" :min="1" :max="99" /></el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="filterDialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="filterSubmitting" @click="saveFilter">保存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, computed, onMounted } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import type { FormInstance, FormRules } from 'element-plus'
|
||||
import request from '@/utils/request'
|
||||
import { onBeforeUnmount } from 'vue'
|
||||
|
||||
// ===== 卡片配置 =====
|
||||
const cardLoading = ref(false)
|
||||
const cardList = ref<ScreenCard[]>([])
|
||||
const cardDialogVisible = ref(false)
|
||||
const cardSubmitting = ref(false)
|
||||
const editingCardId = ref<number | null>(null)
|
||||
const cardFormRef = ref<FormInstance>()
|
||||
const cardForm = reactive({
|
||||
title: '', cardKey: '', cardType: 'stat_number', metric: '', dimension: '', sortOrder: 1, chartConfig: '',
|
||||
})
|
||||
|
||||
const needsDimension = computed(() => ['bar_chart', 'rank_list'].includes(cardForm.cardType))
|
||||
const isChartType = computed(() => ['bar_chart', 'line_chart', 'pie_chart'].includes(cardForm.cardType))
|
||||
|
||||
const cardRules: FormRules = {
|
||||
title: [{ required: true, message: '请输入卡片名称', trigger: 'blur' }],
|
||||
cardKey: [{ required: true, message: '请输入卡片标识', trigger: 'blur' }],
|
||||
cardType: [{ required: true, message: '请选择卡片类型', trigger: 'change' }],
|
||||
metric: [{ required: true, message: '请选择指标', trigger: 'change' }],
|
||||
sortOrder: [{ required: true, message: '请输入排序号', trigger: 'blur' }],
|
||||
}
|
||||
|
||||
function cardTypeLabel(type: string): string {
|
||||
const map: Record<string, string> = { stat_number: '统计数字', bar_chart: '柱状图', line_chart: '折线图', pie_chart: '饼图', status_grid: '状态网格', rank_list: '排行榜' }
|
||||
return map[type] || type
|
||||
}
|
||||
|
||||
function metricLabel(metric: string | null): string {
|
||||
if (!metric) return '-'
|
||||
const map: Record<string, string> = { online_count: '在线数量', part_count: '零件计数' }
|
||||
return map[metric] || metric
|
||||
}
|
||||
|
||||
function filterTypeLabel(type: string): string {
|
||||
const map: Record<string, string> = { workshop: '车间', brand: '品牌' }
|
||||
return map[type] || type
|
||||
}
|
||||
|
||||
function onCardTypeChange() {
|
||||
if (!needsDimension.value) cardForm.dimension = ''
|
||||
if (!isChartType.value) cardForm.chartConfig = ''
|
||||
}
|
||||
|
||||
async function loadCards() {
|
||||
cardLoading.value = true
|
||||
try { const r: Record<string,unknown> = await request.get('/admin/screen-config'); cardList.value = r.data?.items || [] } finally { cardLoading.value = false }
|
||||
}
|
||||
|
||||
// ===== 拖拽排序相关 =====
|
||||
const draggingCardIndex = ref<number | null>(null)
|
||||
const dragOverCardIndex = ref<number | null>(null)
|
||||
|
||||
function onCardDragStart(e: DragEvent, index: number) {
|
||||
draggingCardIndex.value = index
|
||||
if (e.dataTransfer) {
|
||||
e.dataTransfer.effectAllowed = 'move'
|
||||
e.dataTransfer.setData('text/plain', String(index))
|
||||
}
|
||||
}
|
||||
|
||||
function onCardDrop(e: DragEvent, targetIndex: number) {
|
||||
const from = draggingCardIndex.value
|
||||
if (from == null || from === targetIndex) { draggingCardIndex.value = null; dragOverCardIndex.value = null; return }
|
||||
const moved = cardList.value.splice(from, 1)[0]
|
||||
cardList.value.splice(targetIndex, 0, moved)
|
||||
// 重排排序号并提交排序 API
|
||||
const payload = cardList.value.map((c, idx) => ({ id: c.id, sortOrder: idx + 1 }))
|
||||
request.post('/admin/screen-config/sort', payload).then(() => {
|
||||
loadCards()
|
||||
}).finally(() => {
|
||||
draggingCardIndex.value = null
|
||||
dragOverCardIndex.value = null
|
||||
})
|
||||
}
|
||||
|
||||
function onCardDragOver(index: number) {
|
||||
dragOverCardIndex.value = index
|
||||
}
|
||||
|
||||
function getCardRowClass(params: { row: Record<string,unknown>; rowIndex: number }) {
|
||||
return dragOverCardIndex.value === params.rowIndex ? 'drag-over' : ''
|
||||
}
|
||||
|
||||
function addCard() {
|
||||
editingCardId.value = null
|
||||
Object.assign(cardForm, { title: '', cardKey: '', cardType: 'stat_number', metric: '', dimension: '', sortOrder: cardList.value.length + 1, chartConfig: '' })
|
||||
cardDialogVisible.value = true
|
||||
}
|
||||
|
||||
function editCard(row: ScreenCard) {
|
||||
editingCardId.value = row.id
|
||||
Object.assign(cardForm, {
|
||||
title: row.title, cardKey: row.cardKey, cardType: row.cardType, metric: row.metric || '',
|
||||
dimension: row.dimension || '', sortOrder: row.sortOrder, chartConfig: row.chartConfig || '',
|
||||
})
|
||||
cardDialogVisible.value = true
|
||||
}
|
||||
|
||||
async function saveCard() {
|
||||
const valid = await cardFormRef.value?.validate().catch(() => false)
|
||||
if (!valid) return
|
||||
// JSON校验
|
||||
if (isChartType.value && cardForm.chartConfig) {
|
||||
try { JSON.parse(cardForm.chartConfig) } catch { ElMessage.error('图表配置必须是有效的JSON格式'); return }
|
||||
}
|
||||
cardSubmitting.value = true
|
||||
try {
|
||||
const payload: Record<string,unknown> = { ...cardForm, isDefault: false, isEnabled: 1 }
|
||||
if (editingCardId.value) {
|
||||
await request.post('/admin/screen-config/update', { ...payload, id: editingCardId.value })
|
||||
} else {
|
||||
await request.post('/admin/screen-config', payload)
|
||||
}
|
||||
ElMessage.success('保存成功')
|
||||
cardDialogVisible.value = false
|
||||
loadCards()
|
||||
} finally { cardSubmitting.value = false }
|
||||
}
|
||||
|
||||
async function toggleCard(row: ScreenCard) {
|
||||
await ElMessageBox.confirm(`确定${row.isEnabled ? '禁用' : '启用'}卡片【${row.title}】?`, '提示', { type: 'warning' })
|
||||
await request.post('/admin/screen-config/toggle', { id: row.id })
|
||||
ElMessage.success('操作成功')
|
||||
loadCards()
|
||||
}
|
||||
|
||||
async function deleteCard(row: ScreenCard) {
|
||||
await ElMessageBox.confirm(`确定删除卡片【${row.title}】?此操作不可恢复。`, '提示', { type: 'warning' })
|
||||
await request.post('/admin/screen-config/delete', { id: row.id })
|
||||
ElMessage.success('已删除')
|
||||
loadCards()
|
||||
}
|
||||
|
||||
// ===== 筛选配置 =====
|
||||
const filterLoading = ref(false)
|
||||
const filterList = ref<ScreenCard[]>([])
|
||||
const filterDialogVisible = ref(false)
|
||||
const filterSubmitting = ref(false)
|
||||
const editingFilterId = ref<number | null>(null)
|
||||
const filterFormRef = ref<FormInstance>()
|
||||
const filterForm = reactive({
|
||||
screenKey: '', filterType: 'workshop', filterValue: '', isDefault: false, sortOrder: 1,
|
||||
})
|
||||
|
||||
const filterRules: FormRules = {
|
||||
screenKey: [{ required: true, message: '请输入筛选标识', trigger: 'blur' }],
|
||||
filterType: [{ required: true, message: '请选择筛选类型', trigger: 'change' }],
|
||||
filterValue: [{ required: true, message: '请输入筛选值', trigger: 'blur' }],
|
||||
sortOrder: [{ required: true, message: '请输入排序号', trigger: 'blur' }],
|
||||
}
|
||||
|
||||
async function loadFilters() {
|
||||
filterLoading.value = true
|
||||
try { const r: Record<string,unknown> = await request.get('/admin/screen-filter'); filterList.value = r.data?.items || [] } finally { filterLoading.value = false }
|
||||
}
|
||||
|
||||
function addFilter() {
|
||||
editingFilterId.value = null
|
||||
Object.assign(filterForm, { screenKey: 'screen_main', filterType: 'workshop', filterValue: '', isDefault: false, sortOrder: filterList.value.length + 1 })
|
||||
filterDialogVisible.value = true
|
||||
}
|
||||
|
||||
function editFilter(row: ScreenCard) {
|
||||
editingFilterId.value = row.id
|
||||
Object.assign(filterForm, { screenKey: row.screenKey, filterType: row.filterType, filterValue: row.filterValue, isDefault: !!row.isDefault, sortOrder: row.sortOrder })
|
||||
filterDialogVisible.value = true
|
||||
}
|
||||
|
||||
async function saveFilter() {
|
||||
const valid = await filterFormRef.value?.validate().catch(() => false)
|
||||
if (!valid) return
|
||||
filterSubmitting.value = true
|
||||
try {
|
||||
const payload: Record<string,unknown> = { ...filterForm, isDefault: filterForm.isDefault ? 1 : 0 }
|
||||
if (editingFilterId.value) {
|
||||
await request.post('/admin/screen-filter/update', { ...payload, id: editingFilterId.value })
|
||||
} else {
|
||||
await request.post('/admin/screen-filter', payload)
|
||||
}
|
||||
ElMessage.success('保存成功')
|
||||
filterDialogVisible.value = false
|
||||
loadFilters()
|
||||
} finally { filterSubmitting.value = false }
|
||||
}
|
||||
|
||||
async function deleteFilter(row: ScreenCard) {
|
||||
await ElMessageBox.confirm(`确定删除该筛选配置?此操作不可恢复。`, '提示', { type: 'warning' })
|
||||
await request.post('/admin/screen-filter/delete', { id: row.id })
|
||||
ElMessage.success('已删除')
|
||||
loadFilters()
|
||||
}
|
||||
|
||||
onMounted(() => { loadCards(); loadFilters() })
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.drag-over {
|
||||
outline: 2px dashed #4fc3f7;
|
||||
background-color: rgba(76, 195, 247, 0.08);
|
||||
}
|
||||
</style>
|
||||
@ -0,0 +1,98 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="mb-16"><el-button type="primary" @click="handleAdd">+ 新增工人</el-button></div>
|
||||
<el-form :inline="true" class="mb-16">
|
||||
<el-form-item label="状态"><el-select v-model="query.isEnabled" clearable><el-option label="启用" :value="1"/><el-option label="停用" :value="0"/></el-select></el-form-item>
|
||||
<el-form-item><el-input v-model="query.keyword" placeholder="工号/姓名" clearable/></el-form-item>
|
||||
<el-form-item><el-button type="primary" @click="loadData">查询</el-button><el-button @click="resetQuery">重置</el-button></el-form-item>
|
||||
</el-form>
|
||||
<el-table :data="tableData" border stripe v-loading="loading">
|
||||
<el-table-column type="selection" width="40"/>
|
||||
<el-table-column prop="code" label="工号" width="120"/>
|
||||
<el-table-column prop="name" label="姓名" width="120"><template #default="{row}"><el-link type="primary" @click="goDetail(row.id)">{{row.name}}</el-link></template></el-table-column>
|
||||
<el-table-column label="状态" width="80" align="center"><template #default="{row}"><el-tag :type="row.isEnabled?'success':'danger'" size="small">{{row.isEnabled?'启用':'停用'}}</el-tag></template></el-table-column>
|
||||
<el-table-column prop="machineCount" label="绑定机床数" width="100" align="center"/>
|
||||
<el-table-column prop="machineNames" label="绑定机床" show-overflow-tooltip/>
|
||||
<el-table-column label="操作" width="120" align="center"><template #default="{row}">
|
||||
<el-button link type="primary" @click="handleEdit(row)">编辑</el-button>
|
||||
<el-button link type="danger" @click="handleDelete(row)" :disabled="row.machineCount>0">删除</el-button>
|
||||
</template></el-table-column>
|
||||
</el-table>
|
||||
<div v-if="selectedRows.length" style="margin-top:12px;padding:8px 12px;background:#ecf5ff;border-radius:4px">
|
||||
已选{{selectedRows.length}}项 <el-button size="small" @click="batchStatus(1)">批量启用</el-button><el-button size="small" @click="batchStatus(0)">批量停用</el-button>
|
||||
</div>
|
||||
<!-- 分页控件 -->
|
||||
<div style="margin-top:12px;display:flex;justify-content:flex-end;align-items:center">
|
||||
<el-pagination
|
||||
:total="pagination.total"
|
||||
:page-size="pagination.pageSize"
|
||||
:page-sizes="[20, 50, 100]"
|
||||
layout="total, sizes, prev, pager, next, jumper"
|
||||
:current-page="pagination.currentPage"
|
||||
@current-change="handlePageChange"
|
||||
@size-change="handleSizeChange"
|
||||
/>
|
||||
</div>
|
||||
<el-dialog v-model="dialogVisible" :title="editingId?'编辑工人':'新增工人'" width="500px" destroy-on-close>
|
||||
<el-form :model="form" :rules="rules" ref="workerForm" label-width="100px">
|
||||
<el-form-item label="工号" prop="code">
|
||||
<el-input v-model="form.code" maxlength="50" @blur="()=>workerForm.value?.validateField('code')"/>
|
||||
</el-form-item>
|
||||
<el-form-item label="姓名" prop="name">
|
||||
<el-input v-model="form.name" maxlength="50"/>
|
||||
</el-form-item>
|
||||
<el-form-item label="绑定机床" prop="machineIds">
|
||||
<el-select v-model="form.machineIds" multiple filterable>
|
||||
<el-option v-for="m in availableMachines" :key="m.id" :label="m.name" :value="m.id"/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer><el-button @click="dialogVisible=false">取消</el-button><el-button type="primary" :loading="submitting" @click="handleSubmit">保存</el-button></template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import {ref, reactive,onMounted} from 'vue'
|
||||
import {useRouter} from 'vue-router'
|
||||
import {ElMessage,ElMessageBox} from 'element-plus'
|
||||
import request from '@/utils/request'
|
||||
import type { FormInstance, FormRules } from 'element-plus'
|
||||
import {useMockMode} from '@/composables/useMockMode'
|
||||
import type { ApiResponse, Worker, Workshop, CollectAddress } from '@/types'
|
||||
const router=useRouter();const{isMock}=useMockMode()
|
||||
const loading=ref(false);const tableData=ref<Worker[]>([]);const selectedRows=ref<Worker[]>([])
|
||||
// 分页信息
|
||||
const pagination=ref<{ currentPage: number; pageSize: number; total: number }>({ currentPage: 1, pageSize: 20, total: 0 })
|
||||
const dialogVisible=ref(false);const submitting=ref(false);const editingId=ref<number|null>(null)
|
||||
const availableMachines=ref<{ id: number; name: string }[]>([])
|
||||
const query=reactive({isEnabled:undefined as number | undefined,keyword:''})
|
||||
const form=reactive({code:'',name:'',machineIds:[] as number[]})
|
||||
const workerForm=ref<FormInstance | null>(null)
|
||||
// 工号唯一性校验规则
|
||||
const rules=reactive({
|
||||
code: [] as any[],
|
||||
})
|
||||
function validateWorkerCode(rule: unknown, value: string, callback: (err?:Error)=>void){
|
||||
const v = (value ?? '').toString().trim()
|
||||
if(!v){ callback(new Error('工号为必填项')) ; return }
|
||||
// 新增模式下检查重复
|
||||
if(editingId.value === null){
|
||||
const exists = tableData.value.some((r:any)=> r.code === v)
|
||||
if(exists){ callback(new Error('该工号已存在')) ; return }
|
||||
}
|
||||
callback()
|
||||
}
|
||||
// 将校验函数挂载到 rules
|
||||
rules.code = [{ validator: validateWorkerCode, trigger: 'blur' }]
|
||||
function resetQuery(){query.isEnabled=undefined;query.keyword='';loadData()}
|
||||
function goDetail(id:number){router.push((isMock.value?'/mock/worker/':'/worker/')+id)}
|
||||
async function loadData(){loading.value=true;try{const ps=pagination.value.pageSize;const cp=pagination.value.currentPage;const r: ApiResponse<{ items: Worker[]; total?: number }> = await request.get('/admin/worker',{params:{...query,page:cp,pageSize:ps}});tableData.value=r.data?.items||[];pagination.value.total= r.data?.total ?? (r.data?.items?.length ?? 0)}finally{loading.value=false}}
|
||||
function handleAdd(){editingId.value=null;Object.assign(form,{code:'',name:'',machineIds:[]});dialogVisible.value=true}
|
||||
async function handleSubmit(){submitting.value=true;try{const ok = await (workerForm.value?.validate ? new Promise<boolean>((resolve)=>workerForm.value!.validate((valid:boolean)=>resolve(valid))) : Promise.resolve(true)); if(!ok){return} await request.post(editingId.value?'/admin/worker/update':'/admin/worker',{...form,id:editingId.value});ElMessage.success('保存成功');dialogVisible.value=false;loadData()}finally{submitting.value=false}}
|
||||
async function handleDelete(row:any){await ElMessageBox.confirm('确定删除【'+row.name+'】?此操作不可恢复。','提示',{type:'warning'});await request.post('/admin/worker/delete',{id:row.id});ElMessage.success('已删除');loadData()}
|
||||
async function batchStatus(isEnabled:number){await ElMessageBox.confirm('确定对选中的'+selectedRows.value.length+'项操作?','提示',{type:'warning'});await request.post('/admin/worker/batch-status',{ids:selectedRows.value.map((r:any)=>r.id),isEnabled});ElMessage.success('操作成功');loadData()}
|
||||
async function loadDrops(){const r: ApiResponse<{ items: Array<{ id: number; name: string }> }> = await request.get('/admin/worker/available-machines'); availableMachines.value = r.data?.items ?? []}
|
||||
function handlePageChange(page:number){pagination.value.currentPage=page;loadData()}
|
||||
function handleSizeChange(size:number){pagination.value.pageSize=size;pagination.value.currentPage=1;loadData()}
|
||||
onMounted(()=>{loadData();loadDrops()})
|
||||
</script>
|
||||
Loading…
Reference in New Issue