feat(frontend): 实现16个页面源码(13模块)

管理后台: 登录/仪表盘/设备/品牌/采集地址/员工/产量/告警/系统设置/操作日志/大屏配置

大屏看板: 全屏数据展示页

包含: 路由/Mock路由镜像/axios封装/全局样式/TypeScript类型定义/ECharts按需导入/PageHeader组件

Ultraworked with Sisyphus

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
main
haoliang 1 week ago
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,58 @@
<template>
<div class="page-header" style="margin-bottom:12px">
<slot name="breadcrumb">
<!-- 默认面包屑 首页 > 当前模块 > 当前页面 -->
<el-breadcrumb separator="/" style="margin:0 0 12px 0;">
<el-breadcrumb-item>
<router-link :to="homePath">首页</router-link>
</el-breadcrumb-item>
<el-breadcrumb-item>
<router-link :to="sectionPath">{{ sectionLabel }}</router-link>
</el-breadcrumb-item>
<el-breadcrumb-item>{{ title }}</el-breadcrumb-item>
</el-breadcrumb>
</slot>
<div style="display:flex;align-items:center;gap:12px">
<el-button v-if="showBack" @click="$router.back()"><el-icon><ArrowLeft /></el-icon> </el-button>
<span style="font-size:16px;font-weight:600">{{ title }}</span>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { ArrowLeft } from '@element-plus/icons-vue'
import { useRoute } from 'vue-router'
// Props
const props = defineProps<{
title: string
showBack?: boolean
}>()
const showBack = props.showBack ?? true
// Mock
const route = useRoute()
// Mock
const isMockPath = typeof window !== 'undefined' && window.location.pathname.startsWith('/mock')
const homePath = isMockPath ? '/mock/dashboard' : '/dashboard'
// /
const pathHint = computed(() => {
const p = route.path
if (p.startsWith('/machine')) return '设备管理'
if (p.startsWith('/worker')) return '工人管理'
if (p.startsWith('/collect-address')) return '采集地址'
return '管理中心'
})
const sectionPath = computed(() => (isMockPath ? '/mock' : '') + route.path.split('?')[0])
const sectionLabel = computed(() => pathHint.value)
</script>
<style scoped>
/* 该组件保留简单样式,交由全局样式统一风格 */
.page-header {
/* 后续可扩展样式 */
}
</style>

@ -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,129 @@
import { createRouter, createWebHistory, type RouteRecordRaw } from 'vue-router'
// 管理后台Layout
const AdminLayout = () => import('@/layouts/AdminLayout.vue')
// 大屏Layout
const ScreenLayout = () => import('@/layouts/ScreenLayout.vue')
// 页面组件
const LoginPage = () => import('@/views/login/LoginPage.vue')
const DashboardPage = () => import('@/views/dashboard/DashboardPage.vue')
const MachineListPage = () => import('@/views/machine/MachineListPage.vue')
const MachineDetailPage = () => import('@/views/machine/MachineDetailPage.vue')
const BrandListPage = () => import('@/views/brand/BrandListPage.vue')
const BrandEditPage = () => import('@/views/brand/BrandEditPage.vue')
const CollectAddressListPage = () => import('@/views/collect-address/CollectAddressListPage.vue')
const CollectAddressDetailPage = () => import('@/views/collect-address/CollectAddressDetailPage.vue')
const WorkerListPage = () => import('@/views/worker/WorkerListPage.vue')
const WorkerDetailPage = () => import('@/views/worker/WorkerDetailPage.vue')
const ProductionPage = () => import('@/views/production/ProductionPage.vue')
const AlertPage = () => import('@/views/alert/AlertPage.vue')
const SettingsPage = () => import('@/views/settings/SettingsPage.vue')
const LogPage = () => import('@/views/log/LogPage.vue')
const ScreenConfigPage = () => import('@/views/screen-config/ScreenConfigPage.vue')
const ScreenPage = () => import('@/views/screen/ScreenPage.vue')
// 正常路由
const normalRoutes: RouteRecordRaw[] = [
{ path: '/login', name: 'Login', component: LoginPage, meta: { title: '登录' } },
{
path: '/',
component: AdminLayout,
redirect: '/dashboard',
children: [
{ path: 'dashboard', name: 'Dashboard', component: DashboardPage, meta: { title: '仪表盘' } },
{ path: 'machine', name: 'MachineList', component: MachineListPage, meta: { title: '设备管理' } },
{ path: 'machine/:id', name: 'MachineDetail', component: MachineDetailPage, meta: { title: '设备详情' } },
{ path: 'brand', name: 'BrandList', component: BrandListPage, meta: { title: '品牌模板' } },
{ path: 'brand/create', name: 'BrandCreate', component: BrandEditPage, meta: { title: '新增品牌' } },
{ path: 'brand/:id/edit', name: 'BrandEdit', component: BrandEditPage, meta: { title: '编辑品牌' } },
{ path: 'collect-address', name: 'CollectAddressList', component: CollectAddressListPage, meta: { title: '采集地址' } },
{ path: 'collect-address/:id', name: 'CollectAddressDetail', component: CollectAddressDetailPage, meta: { title: '采集地址详情' } },
{ path: 'worker', name: 'WorkerList', component: WorkerListPage, meta: { title: '员工管理' } },
{ path: 'worker/:id', name: 'WorkerDetail', component: WorkerDetailPage, meta: { title: '员工详情' } },
{ path: 'production', name: 'Production', component: ProductionPage, meta: { title: '产量报表' } },
{ path: 'alert', name: 'Alert', component: AlertPage, meta: { title: '告警中心' } },
{ path: 'settings', name: 'Settings', component: SettingsPage, meta: { title: '系统设置' } },
{ path: 'log', name: 'Log', component: LogPage, meta: { title: '操作日志' } },
{ path: 'screen-config', name: 'ScreenConfig', component: ScreenConfigPage, meta: { title: '大屏配置' } },
],
},
{
path: '/screen',
component: ScreenLayout,
children: [
{ path: '', name: 'Screen', component: ScreenPage, meta: { title: '大屏看板' } },
],
},
]
// Mock路由复制正常路由添加/mock前缀
function generateMockRoutes(routes: RouteRecordRaw[]): RouteRecordRaw[] {
const mockRoutes: RouteRecordRaw[] = []
for (const route of routes) {
if (route.path === '/login') {
mockRoutes.push({ path: '/mock/login', name: 'MockLogin', component: route.component!, meta: { ...route.meta, mock: true } })
} else if (route.path === '/') {
mockRoutes.push({
path: '/mock',
component: route.component,
redirect: '/mock/dashboard',
children: (route.children || []).map(child => ({
...child,
path: child.path,
name: `Mock${String(child.name)}`,
meta: { ...child.meta, mock: true },
})),
})
} else if (route.path === '/screen') {
mockRoutes.push({
path: '/mock/screen',
component: route.component,
children: (route.children || []).map(child => ({
...child,
path: child.path,
name: `Mock${String(child.name)}`,
meta: { ...child.meta, mock: true },
})),
})
}
}
return mockRoutes
}
const router = createRouter({
history: createWebHistory(),
routes: [...normalRoutes, ...generateMockRoutes(normalRoutes)],
})
// 路由守卫
router.beforeEach((to, _from, next) => {
const token = localStorage.getItem('token')
const isMock = to.path.startsWith('/mock')
// 大屏免认证
if (to.path === '/screen' || to.path === '/mock/screen') {
next()
return
}
// 访问登录页:已登录则跳转首页
if (to.path === '/login' || to.path === '/mock/login') {
if (token) {
next(isMock ? '/mock/dashboard' : '/dashboard')
} else {
next()
}
return
}
// 访问管理后台:未登录则跳转登录
if (!token) {
next(isMock ? '/mock/login' : '/login')
return
}
next()
})
export default router

@ -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,42 @@
/* 大屏看板深色主题 */
.screen-layout {
height: 100vh;
background-color: #0f0f1a;
color: #e0e0e0;
overflow: hidden;
font-family: 'Helvetica Neue', Helvetica, 'PingFang SC', 'Microsoft YaHei', Arial, sans-serif;
/* 覆盖Element Plus默认样式 */
.el-card {
background-color: #1a1a2e;
border-color: #2a2a4a;
color: #e0e0e0;
}
.el-select {
--el-fill-color-blank: #1a1a2e;
--el-text-color-regular: #e0e0e0;
--el-border-color: #2a2a4a;
}
.el-select__wrapper {
background-color: #1a1a2e !important;
box-shadow: 0 0 0 1px #2a2a4a inset !important;
}
.el-select__selected-item {
color: #e0e0e0 !important;
}
}
//
.mb-16 { margin-bottom: 16px; }
.mt-20 { margin-top: 20px; }
.gap-12 { gap: 12px; }
.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-title { font-size: 16px; font-weight: bold; }

@ -0,0 +1,225 @@
/**
* API
* @template T
* data
*/
export interface ApiResponse<T = unknown> {
/** 响应码0 表示成功,其他表示错误 */
code: number
/** 响应信息描述 */
message: string
/** 业务数据 */
data: T
}
/**
*
*/
export interface PaginatedResponse<T> {
/** 结果项集合 */
items: T[]
/** 总记录数 */
total: number
/** 当前页码 */
page: number
/** 每页条数 */
pageSize: number
}
// =================== 业务实体模型 ===================
/** 机床 */
export interface Machine {
id: number
name: string
deviceCode: string
workshopName: string
brandName: string
ipAddress: string
isOnline: boolean
isEnabled: boolean
workerName?: string
collectAddressName?: string
}
/** 机床状态 */
export interface MachineStatus {
programName: string
partCount: number
runStatus: string
operationMode: string
spindleSpeed: number
feedRate: number
spindleLoad: number
machiningStatus: string
lastCollectTime: string
}
/** 品牌 */
export interface Brand {
id: number
brandName: string
deviceField: string
tagsPath: string
isEnabled: boolean
fieldCount: number
}
/** 品牌字段映射 */
export interface BrandFieldMapping {
id?: number
standardField: string
fieldName: string
matchMethod: string
dataType: string
required: boolean
}
/** 采集地址 */
export interface CollectAddress {
id: number
name: string
url: string
brandName: string
interval: number
isEnabled: boolean
lastCollectTime: string
machineCount: number
failCount: number
}
/** 工人 */
export interface Worker {
id: number
code: string
name: string
isEnabled: boolean
machineCount: number
machines?: Machine[]
}
/** 告警 */
export interface Alert {
id: number
alertType: string
machineName: string
message: string
isResolved: boolean
createdAt: string
resolvedAt?: string
}
/** 产量记录 */
export interface ProductionRecord {
id: number
date: string
machineName: string
programName: string
quantity: number
runTime: number
cuttingTime: number
dayStatus: string
adjusted: boolean
}
/** 修正历史 */
export interface AdjustmentHistory {
id: number
adjustedAt: string
beforeQuantity: number
afterQuantity: number
reason: string
operatorName: string
}
/** 系统配置 */
export interface SysConfig {
id: number
configKey: string
configValue: string
valueType: 'string' | 'number'
description: string
isSensitive: boolean
}
/** 车间 */
export interface Workshop {
id: number
name: string
description?: string
}
/** 操作日志 */
export interface OperationLog {
id: number
timestamp: string
level: string
source: string
message: string
stackTrace?: string
extraData?: string
}
/** 大屏卡片配置 */
export interface ScreenCard {
id: number
cardName: string
cardKey: string
cardType: string
metric: string
dimension?: string
sortOrder: number
isEnabled: boolean
chartConfig?: string
}
/** 大屏筛选配置 */
export interface ScreenFilter {
id: number
filterKey: string
filterType: string
filterValues: string
isDefault: boolean
sortOrder: number
}
/** 仪表盘统计 */
export interface DashboardSummary {
onlineCount: number
totalMachines: number
todayProduction: number
activeAlerts: number
}
// 机床产量排行榜行数据
export interface MachineRankRow {
rank?: number
id?: number
machineName: string
quantity?: number
status?: number
program?: string
}
// 工人产量排行榜行数据
export interface WorkerRankRow {
rank?: number
id?: number
workerName: string
machineCount?: number
totalQuantity?: number
}
/** 采集服务状态 */
export interface CollectorStatus {
status: string
uptimeSeconds: number
lastCollectTime?: string
}
/** 产量看板汇总 */
export interface ProductionDashboardSummary {
totalQuantity: number
activeMachineCount: number
totalCuttingTime: number
avgQuantityPerMachine: number
}

@ -0,0 +1,11 @@
// ECharts 按需导入工具避免打包整个echarts库提升构建体积
// 只注册需要的组件,确保三个页面的图表渲染正常
import * as echarts from 'echarts/core'
import { BarChart, LineChart } from 'echarts/charts'
import { GridComponent, TooltipComponent, TitleComponent } from 'echarts/components'
import { CanvasRenderer } from 'echarts/renderers'
// 注册所需的图表/组件/渲染器
echarts.use([BarChart, LineChart, GridComponent, TooltipComponent, TitleComponent, CanvasRenderer])
export default echarts

@ -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,52 @@
<template>
<div>
<div class="mb-16"><el-button type="primary" @click="goAdd">+ </el-button></div>
<el-table :data="tableData" border stripe v-loading="loading">
<el-table-column prop="brandName" label="品牌名称" min-width="150"/>
<el-table-column prop="deviceField" label="device字段" width="120"/>
<el-table-column prop="tagsPath" label="tags路径" width="120"/>
<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="fieldCount" label="字段数" width="80" align="center"/>
<el-table-column label="操作" width="260" align="center"><template #default="{row}">
<el-button link type="primary" @click="goEdit(row.id)"></el-button>
<!-- 复制按钮放在编辑之后 -->
<el-button text type="default" @click="handleCopy(row)" style="margin-left:6px; display:inline-flex; align-items:center; gap:6px">
<el-icon><CopyDocument/></el-icon>
复制
</el-button>
<el-button link type="primary" @click="handleToggle(row)">{{row.isEnabled?'':''}}</el-button>
<el-button link type="danger" @click="handleDelete(row)"></el-button>
</template></el-table-column>
</el-table>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import request from '@/utils/request'
import { useMockMode } from '@/composables/useMockMode'
import { CopyDocument } from '@element-plus/icons-vue'
import type { Brand, ApiResponse } from '@/types'
const router = useRouter()
const { isMock } = useMockMode()
const loading = ref(false)
const tableData = ref<Brand[]>([])
function goAdd() { router.push(isMock.value ? '/mock/brand/create' : '/brand/create') }
function goEdit(id: number) { router.push((isMock.value ? '/mock/brand/' : '/brand/') + id + '/edit') }
async function loadData() { loading.value = true; try { const r: ApiResponse<{ items: Brand[] }> = await request.get('/admin/brand'); tableData.value = r.data?.items ?? [] } finally { loading.value = false } }
async function handleToggle(row: Brand) { await ElMessageBox.confirm('确定' + (row.isEnabled ? '禁用' : '启用') + '', '提示', { type: 'warning' }); await request.post('/admin/brand/toggle', { id: row.id }); ElMessage.success('操作成功'); loadData() }
async function handleDelete(row: Brand) { await ElMessageBox.confirm('确定删除【' + row.brandName + '】?此操作不可恢复。', '提示', { type: 'warning' }); await request.post('/admin/brand/delete', { id: row.id }); ElMessage.success('已删除'); loadData() }
//
async function handleCopy(row: Brand) {
try {
await ElMessageBox.confirm(`确定复制品牌【${row.brandName}】?`, '提示', { type: 'warning' })
await request.post('/admin/brand/copy', { sourceId: row.id })
ElMessage.success('复制成功')
loadData()
} catch {
//
}
}
onMounted(loadData)
</script>

@ -0,0 +1,84 @@
<template>
<div>
<!-- 使用 PageHeader 统一头部 -->
<PageHeader :title="`采集地址详情:${detail.name}`" :showBack="true">
<template #breadcrumb>
<el-breadcrumb separator="/" style="margin: 0 0 12px 0;">
<el-breadcrumb-item><router-link :to="homePath">首页</router-link></el-breadcrumb-item>
<el-breadcrumb-item><router-link :to="collectAddressPath">采集地址</router-link></el-breadcrumb-item>
<el-breadcrumb-item>{{ detail.name || '采集地址详情' }}</el-breadcrumb-item>
</el-breadcrumb>
</template>
</PageHeader>
<el-card shadow="hover" style="margin-bottom:20px"><template #header><span>基本信息</span></template>
<el-descriptions :column="2" border size="small">
<el-descriptions-item label="名称">{{detail.name}}</el-descriptions-item>
<el-descriptions-item label="URL">{{detail.url}}</el-descriptions-item>
<el-descriptions-item label="品牌">{{detail.brandName}}</el-descriptions-item>
<el-descriptions-item label="采集间隔">{{detail.collectInterval}}</el-descriptions-item>
<el-descriptions-item label="状态"><el-tag :type="detail.isEnabled?'success':'danger'" size="small">{{detail.isEnabled?'启用':'停用'}}</el-tag></el-descriptions-item>
<el-descriptions-item label="最后采集">{{detail.lastCollectTime||'-'}}</el-descriptions-item>
</el-descriptions>
</el-card>
<el-card shadow="hover" style="margin-bottom:20px"><template #header><span>关联机床</span></template>
<el-table :data="machines" border stripe size="small">
<el-table-column prop="machineName" label="机床名称"/><el-table-column prop="deviceCode" label="device_code"/><el-table-column prop="workshopName" label="车间"/>
<el-table-column label="在线" align="center"><template #default="{row}"><el-tag :type="row.isOnline?'success':'danger'" size="small">{{row.isOnline?'在线':'离线'}}</el-tag></template></el-table-column>
<el-table-column prop="programName" label="当前程序"/>
</el-table>
</el-card>
<el-card shadow="hover"><template #header><span>最近采集记录</span></template>
<el-table :data="records" border stripe size="small">
<el-table-column prop="requestTime" label="请求时间"/>
<el-table-column prop="duration" label="耗时(ms)" align="center"/>
<el-table-column label="状态" align="center"><template #default="{row}"><el-tag :type="row.isSuccess?'success':'danger'" size="small">{{row.isSuccess?'成功':'失败'}}</el-tag></template></el-table-column>
<el-table-column prop="machineCount" label="机床数" align="center"/>
<el-table-column label="操作" width="120" align="center"><template #default="{row}"><el-button size="small" link type="primary" @click="viewRawJson(row)">JSON</el-button></template></el-table-column>
</el-table>
</el-card>
<el-dialog v-model:visible="rawJsonDialogVisible" :title="rawJsonTitle" width="700px">
<el-input type="textarea" v-model="rawJsonContent" rows="15" :readonly="true"></el-input>
<template #footer><el-button @click="rawJsonDialogVisible=false"></el-button></template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import {ref,onMounted} from 'vue'
import {useRoute} from 'vue-router'
import request from '@/utils/request'
import type { ApiResponse, CollectAddress, Machine } from '@/types'
import PageHeader from '@/components/PageHeader.vue'
import { useMockPath } from '@/composables/useMockPath'
const { isMock } = useMockPath()
const homePath = isMock ? '/mock/dashboard' : '/dashboard'
const collectAddressPath = isMock ? '/mock/collect-address' : '/collect-address'
const route=useRoute()
const detail=ref<CollectAddress>({} as CollectAddress); type CollectMachineRow = { machineName: string; deviceCode?: string; workshopName?: string; isOnline?: boolean; programName?: string }; const machines=ref<CollectMachineRow[]>([]); const records=ref<CollectAddress[]>([])
// JSON
const rawJsonDialogVisible = ref(false)
const rawJsonContent = ref('')
const rawJsonTitle = ref('原始采集数据')
async function viewRawJson(record: Record<string,unknown>){
// JSONmock
const id = route.params.id
const resp: Record<string,unknown> = await request.get('/admin/collect-address/raw-json', { params: { id, recordId: record.requestTime } })
const raw = resp?.data?.rawJson ?? '[]'
let parsed: unknown
try { parsed = JSON.parse(raw) } catch { parsed = raw }
rawJsonContent.value = JSON.stringify(parsed, null, 2)
rawJsonTitle.value = `原始采集数据 - ${record.machineName ?? ''}`
rawJsonDialogVisible.value = true
}
async function loadData(){
const id=route.params.id
const [d,m,r]: Record<string,unknown>[] = await Promise.all([
request.get('/admin/collect-address/detail', { params: { id } }),
request.get('/admin/collect-address/machines', { params: { id } }),
request.get('/admin/collect-address/collect-records', { params: { id } })
])
detail.value = d.data ?? ({} as CollectAddress)
machines.value = m.data?.items ?? []
records.value = r.data?.items ?? []
}
onMounted(loadData)
</script>

@ -0,0 +1,71 @@
<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.brandId" clearable><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-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 prop="name" label="名称" width="150"><template #default="{row}"><el-link type="primary" @click="goDetail(row.id)">{{row.name}}</el-link></template></el-table-column>
<el-table-column prop="url" label="URL" show-overflow-tooltip/>
<el-table-column prop="brandName" label="品牌" width="100" align="center"/>
<el-table-column prop="collectInterval" label="采集间隔" width="100" align="center"/>
<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="lastCollectTime" label="最后采集" width="170"/>
<el-table-column prop="machineCount" label="机床数" width="80" align="center"/>
<el-table-column prop="failCount" label="连续失败" width="100" align="center"><template #default="{row}"><el-tag :type="row.failCount===0?'success':row.failCount<=3?'warning':'danger'" size="small">{{row.failCount}} </el-tag></template></el-table-column>
<el-table-column label="操作" width="160" align="center"><template #default="{row}">
<el-button link type="primary" @click="handleEdit(row)"></el-button>
<el-button link type="danger" @click="handleDelete(row)"></el-button>
</template></el-table-column>
</el-table>
<el-dialog v-model="dialogVisible" :title="editingId?'编辑地址':'新增地址'" width="600px" destroy-on-close>
<el-form :model="form" label-width="100px">
<el-form-item label="名称" required><el-input v-model="form.name"/></el-form-item>
<el-form-item label="URL" required><el-input v-model="form.url"/></el-form-item>
<el-form-item label="品牌" required><el-select v-model="form.brandId"><el-option v-for="b in brandList" :key="b.id" :label="b.brandName" :value="b.id"/></el-select></el-form-item>
<!-- 关联机床区域 -->
<el-form-item label="关联机床">
<el-checkbox-group v-model="form.machineIds">
<el-checkbox v-for="m in machineList" :key="m.machineId" :label="m.machineId">{{m.machineName}}</el-checkbox>
</el-checkbox-group>
</el-form-item>
<el-form-item label="采集间隔" required><el-input-number v-model="form.collectInterval" :min="5"/></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, watch} from 'vue'
import {useRouter} from 'vue-router'
import {ElMessage,ElMessageBox} from 'element-plus'
import request from '@/utils/request'
import {useMockMode} from '@/composables/useMockMode'
const router=useRouter();const{isMock}=useMockMode()
import type { ApiResponse, Brand, Workshop, CollectAddress } from '@/types'
const loading=ref(false);const tableData=ref<CollectAddress[]>([]);const brandList=ref<Brand[]>([])
const dialogVisible=ref(false);const submitting=ref(false);const editingId=ref<number|null>(null)
const query=reactive({brandId:undefined as number|undefined})
const form=reactive({name:'',url:'',brandId:undefined as number|undefined,collectInterval:30,machineIds: [] as number[]})
//
const machineList=ref<{ machineId:number; machineName:string }[]>([])
//
async function loadMachinesForBrandName(brandName:string){ if(!brandName){ machineList.value = []; return } const r: ApiResponse<{ items: { machineId:number; machineName:string }[] }> = await request.get('/admin/machine/list', { params: { brandName } }); machineList.value = r.data?.items || [] }
function resetQuery(){query.brandId=undefined;loadData()}
function goDetail(id:number){router.push((isMock.value?'/mock/collect-address/':'/collect-address/')+id)}
async function loadData(){loading.value=true;try{const r:any=await request.get('/admin/collect-address');tableData.value=r.data?.items||[]}finally{loading.value=false}}
function handleAdd(){editingId.value=null;Object.assign(form,{name:'',url:'',brandId:undefined,collectInterval:30,machineIds: []});dialogVisible.value=true}
function handleEdit(row:any){editingId.value=row.id;Object.assign(form,row);dialogVisible.value=true}
async function handleSubmit(){submitting.value=true;try{await request.post(editingId.value?'/admin/collect-address/update':'/admin/collect-address',{...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/collect-address/delete',{id:row.id});ElMessage.success('已删除');loadData()}
async function loadDrops(){const r:any=await request.get('/admin/brand/list');brandList.value=r.data?.items||[]}
onMounted(()=>{loadData();loadDrops()})
//
watch(() => form.brandId, async (newVal)=>{
const b = brandList.value.find((bb:any)=> bb.id===newVal)
const brandName = b?.brandName ?? ''
await loadMachinesForBrandName(brandName)
form.machineIds = []
}, { immediate: true })
</script>

@ -0,0 +1,260 @@
<template>
<div class="dashboard-page">
<!-- 统计卡片 -->
<el-row :gutter="20" class="stat-row">
<el-col :span="6">
<el-card shadow="hover">
<div class="stat-card">
<div class="stat-label">在线机床数</div>
<div class="stat-value">{{ summary.onlineCount }}<span class="stat-unit"> / {{ summary.totalMachines }}</span></div>
</div>
</el-card>
</el-col>
<el-col :span="6">
<el-card shadow="hover">
<div class="stat-card">
<div class="stat-label">今日总产量</div>
<div class="stat-value">{{ summary.todayProduction?.toLocaleString() }}</div>
</div>
</el-card>
</el-col>
<el-col :span="6">
<el-card shadow="hover">
<div class="stat-card">
<div class="stat-label">采集服务</div>
<div class="stat-value">
<el-tag :type="collectorStatus.status === 'running' ? 'success' : 'danger'" size="small">
{{ collectorStatus.status === 'running' ? '运行中' : '已停止' }}
</el-tag>
</div>
<div class="stat-sub" v-if="collectorStatus.status === 'running'"> {{ formatUptime(collectorStatus.uptimeSeconds) }}</div>
</div>
<!-- 采集服务控制按钮 -->
<div class="collector-actions" style="margin-top: 12px; display: flex; gap: 8px; justify-content: flex-start; flex-wrap: wrap;">
<!-- 当采集服务未运行时显示启动按钮 -->
<el-button v-if="collectorStatus.status !== 'running'" size="small" type="success" :loading="startLoading" @click="startCollector">
启动采集
</el-button>
<!-- 当采集服务运行中显示停止按钮 -->
<el-button v-if="collectorStatus.status === 'running'" size="small" type="danger" :loading="stopLoading" @click="stopCollector">
停止采集
</el-button>
<!-- 始终显示刷新配置按钮 -->
<el-button size="small" type="warning" :loading="refreshLoading" @click="refreshCollectorConfig">
刷新配置
</el-button>
</div>
</el-card>
</el-col>
<el-col :span="6">
<el-card shadow="hover">
<div class="stat-card">
<div class="stat-label">活跃告警</div>
<div class="stat-value alert-value" @click="$router.push(isMock ? '/mock/alert' : '/alert')">
{{ summary.activeAlerts }}
</div>
</div>
</el-card>
</el-col>
</el-row>
<!-- 排行表格 -->
<el-row :gutter="20" style="margin-top: 20px;">
<el-col :span="12">
<el-card shadow="hover">
<template #header>
<div class="card-header">
<span>机床产量排行 TOP10</span>
<span class="card-sub">今日</span>
</div>
</template>
<el-table :data="machineRank" stripe size="small" style="width: 100%">
<el-table-column prop="rank" label="排名" width="60" align="center" />
<!-- 机床名称跳转链接mock模式跳转到/mock/machine/{id} -->
<el-table-column label="机床名称" width="120">
<template #default="{ row }">
<router-link :to="isMock ? '/mock/machine/' + row.id : '/machine/' + row.id" class="machine-link">{{ row.machineName }}</router-link>
</template>
</el-table-column>
<el-table-column prop="program" label="当前程序" show-overflow-tooltip />
<el-table-column prop="quantity" label="产量" width="80" align="center" />
<el-table-column label="状态" width="70" align="center">
<template #default="{ row }">
<el-tag :type="row.status === 1 ? 'success' : 'danger'" size="small">{{ row.status === 1 ? '在线' : '离线' }}</el-tag>
</template>
</el-table-column>
</el-table>
</el-card>
</el-col>
<el-col :span="12">
<el-card shadow="hover">
<template #header>
<div class="card-header">
<span>工人产量排行 TOP10</span>
<span class="card-sub">今日</span>
</div>
</template>
<el-table :data="workerRank" stripe size="small" style="width: 100%">
<el-table-column prop="rank" label="排名" width="60" align="center" />
<el-table-column prop="workerName" label="工人姓名" width="100" />
<el-table-column prop="machineCount" label="绑定机床" width="80" align="center" />
<el-table-column prop="totalQuantity" label="总产量" width="100" align="center" />
</el-table>
</el-card>
</el-col>
</el-row>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, onUnmounted } from 'vue'
import request from '@/utils/request'
import { useMockMode } from '@/composables/useMockMode'
import type { ApiResponse, DashboardSummary, CollectorStatus, MachineRankRow, WorkerRankRow } from '@/types'
const { isMock } = useMockMode()
const summary = ref<DashboardSummary>({ onlineCount: 0, totalMachines: 0, todayProduction: 0, activeAlerts: 0 } as DashboardSummary)
const collectorStatus = ref<CollectorStatus>({ status: 'stopped', uptimeSeconds: 0 })
const machineRank = ref<MachineRankRow[]>([])
const workerRank = ref<WorkerRankRow[]>([])
//
const startLoading = ref(false)
const stopLoading = ref(false)
const refreshLoading = ref(false)
let refreshTimer: number | undefined
//
async function startCollector() {
if (startLoading.value) return
startLoading.value = true
try {
await request.post('/admin/collector/start')
await loadData()
} catch (e) {
//
} finally {
startLoading.value = false
}
}
//
async function stopCollector() {
if (stopLoading.value) return
stopLoading.value = true
try {
await request.post('/admin/collector/stop')
await loadData()
} catch {
} finally {
stopLoading.value = false
}
}
//
async function refreshCollectorConfig() {
if (refreshLoading.value) return
refreshLoading.value = true
try {
await request.post('/admin/collector/refresh')
await loadData()
} catch {
} finally {
refreshLoading.value = false
}
}
function formatUptime(seconds: number): string {
if (!seconds) return '-'
const days = Math.floor(seconds / 86400)
const hours = Math.floor((seconds % 86400) / 3600)
if (days > 0) return `${days}${hours}`
return `${hours}`
}
async function loadData() {
const [summaryRes, collectorRes, machineRankRes, workerRankRes]: [ApiResponse<DashboardSummary>, ApiResponse<CollectorStatus>, ApiResponse<{ items: MachineRankRow[] }>, ApiResponse<{ items: WorkerRankRow[] }> ] = await Promise.all([
request.get('/admin/dashboard/summary'),
request.get('/admin/collector/status'),
request.get('/admin/dashboard/machine-rank'),
request.get('/admin/dashboard/worker-rank'),
])
summary.value = summaryRes.data || summary.value
collectorStatus.value = collectorRes.data || collectorStatus.value
machineRank.value = machineRankRes.data?.items || []
workerRank.value = workerRankRes.data?.items || []
}
//
onMounted(() => {
loadData()
// 30
refreshTimer = window.setInterval(loadData, 30000) as unknown as number
})
onUnmounted(() => {
if (typeof refreshTimer === 'number') {
clearInterval(refreshTimer)
}
})
</script>
<style scoped lang="scss">
.dashboard-page {
.stat-row {
.el-card {
height: 100%;
}
}
.stat-card {
text-align: center;
padding: 10px 0;
.stat-label {
font-size: 14px;
color: #909399;
margin-bottom: 8px;
}
.stat-value {
font-size: 28px;
font-weight: bold;
color: #303133;
.stat-unit {
font-size: 14px;
font-weight: normal;
color: #909399;
}
}
.stat-sub {
font-size: 12px;
color: #909399;
margin-top: 4px;
}
.alert-value {
color: #e6a23c;
cursor: pointer;
}
}
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
.card-sub {
font-size: 12px;
color: #909399;
}
}
.machine-link {
color: var(--el-color-primary);
cursor: pointer;
text-decoration: none;
}
}
</style>

@ -0,0 +1,255 @@
<template>
<div>
<el-tabs v-model="activeTab" @tab-change="handleTabChange">
<!-- Tab1: 产量修正日志 -->
<el-tab-pane label="产量修正日志" name="adjustment">
<el-form :inline="true" class="mb-16">
<el-form-item label="时间范围">
<el-date-picker v-model="adjQuery.dateRange" type="daterange" value-format="YYYY-MM-DD" clearable />
</el-form-item>
<el-form-item label="目标表">
<el-select v-model="adjQuery.targetTable" clearable placeholder="全部">
<el-option label="日产量" value="daily_production" />
<el-option label="员工日汇总" value="worker_daily_summary" />
</el-select>
</el-form-item>
<el-form-item label="关键字">
<el-input v-model="adjQuery.keyword" placeholder="原因/操作IP" clearable />
</el-form-item>
<el-form-item>
<el-button type="primary" @click="loadAdjustment"></el-button>
<el-button @click="resetAdjQuery"></el-button>
<el-button type="success" @click="exportAdjustment"></el-button>
</el-form-item>
</el-form>
<el-table :data="adjList" border stripe v-loading="adjLoading">
<el-table-column prop="createdAt" label="修正时间" width="170" sortable align="center" />
<el-table-column prop="targetTable" label="目标表" width="150" show-overflow-tooltip>
<template #default="{ row }">
{{ targetTableLabel(row.targetTable) }}
</template>
</el-table-column>
<el-table-column prop="targetId" label="目标ID" width="80" align="center" />
<el-table-column prop="oldValue" label="修正前" width="80" align="center" />
<el-table-column prop="newValue" label="修正后" width="80" align="center" />
<el-table-column prop="reason" label="修正原因" show-overflow-tooltip />
<el-table-column prop="operatorIp" label="操作IP" width="130" align="center" />
</el-table>
<el-empty v-if="!adjLoading && adjList.length === 0" description="暂无产量修正记录" />
<el-pagination
v-if="adjPage.total > 0"
v-model:current-page="adjPage.page"
v-model:page-size="adjPage.pageSize"
:page-sizes="[20, 50, 100]"
:total="adjPage.total"
background
layout="total, sizes, prev, pager, next, jumper"
style="margin-top:16px"
/>
</el-tab-pane>
<!-- Tab2: 系统运行日志 -->
<el-tab-pane label="系统运行日志" name="system">
<el-form :inline="true" class="mb-16">
<el-form-item label="日志级别">
<el-select v-model="sysQuery.logLevel" clearable placeholder="全部">
<el-option label="ERROR" value="ERROR" />
<el-option label="WARN" value="WARN" />
<el-option label="INFO" value="INFO" />
<el-option label="DEBUG" value="DEBUG" />
</el-select>
</el-form-item>
<el-form-item label="来源">
<el-select v-model="sysQuery.source" clearable placeholder="全部">
<el-option label="CncCollector" value="CncCollector" />
<el-option label="WebApi" value="WebApi" />
<el-option label="Scheduler" value="Scheduler" />
</el-select>
</el-form-item>
<el-form-item label="时间范围">
<el-date-picker v-model="sysQuery.dateRange" type="daterange" value-format="YYYY-MM-DD" clearable />
</el-form-item>
<el-form-item label="关键字">
<el-input v-model="sysQuery.keyword" placeholder="消息内容" clearable />
</el-form-item>
<el-form-item>
<el-button type="primary" @click="loadSystem"></el-button>
<el-button @click="resetSysQuery"></el-button>
</el-form-item>
</el-form>
<el-table :data="sysList" border stripe v-loading="sysLoading">
<el-table-column prop="createdAt" label="时间" width="170" sortable align="center" />
<el-table-column label="级别" width="80" align="center">
<template #default="{ row }">
<el-tag :type="logLevelTag(row.logLevel)" size="small">{{ row.logLevel }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="source" label="来源" width="130" show-overflow-tooltip />
<el-table-column label="消息" show-overflow-tooltip>
<template #default="{ row }">
<span>{{ row.message }}</span>
</template>
</el-table-column>
<el-table-column label="操作" width="100" fixed="right" align="center">
<template #default="{ row }">
<el-button link type="primary" @click="viewSysDetail(row)"></el-button>
</template>
</el-table-column>
</el-table>
<el-empty v-if="!sysLoading && sysList.length === 0" description="暂无系统日志" />
<el-pagination
v-if="sysPage.total > 0"
v-model:current-page="sysPage.page"
v-model:page-size="sysPage.pageSize"
:page-sizes="[20, 50, 100]"
:total="sysPage.total"
background
layout="total, sizes, prev, pager, next, jumper"
style="margin-top:16px"
/>
<!-- 系统日志详情弹窗 -->
<el-dialog v-model="sysDetailVisible" title="日志详情" width="700px" destroy-on-close>
<el-descriptions :column="1" border size="small">
<el-descriptions-item label="时间">{{ sysDetailRow.createdAt }}</el-descriptions-item>
<el-descriptions-item label="级别">
<el-tag :type="logLevelTag(sysDetailRow.logLevel)" size="small">{{ sysDetailRow.logLevel }}</el-tag>
</el-descriptions-item>
<el-descriptions-item label="来源">{{ sysDetailRow.source }}</el-descriptions-item>
<el-descriptions-item label="消息">
<div style="white-space:pre-wrap;word-break:break-all">{{ sysDetailRow.message }}</div>
</el-descriptions-item>
<el-descriptions-item label="堆栈信息">
<div v-if="sysDetailRow.stackTrace" style="white-space:pre-wrap;word-break:break-all;background:#f5f7fa;padding:8px;border-radius:4px;font-family:monospace;font-size:12px">{{ sysDetailRow.stackTrace }}</div>
<span v-else style="color:#c0c4cc"></span>
</el-descriptions-item>
<el-descriptions-item label="附加数据">
<div v-if="sysDetailRow.extraData" style="white-space:pre-wrap;word-break:break-all;background:#f5f7fa;padding:8px;border-radius:4px;font-family:monospace;font-size:12px">{{ formatJson(sysDetailRow.extraData) }}</div>
<span v-else style="color:#c0c4cc"></span>
</el-descriptions-item>
</el-descriptions>
<template #footer><el-button @click="sysDetailVisible = false">关闭</el-button></template>
</el-dialog>
</el-tab-pane>
</el-tabs>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted } from 'vue'
import { ElMessage } from 'element-plus'
import request from '@/utils/request'
const activeTab = ref('adjustment')
// ===== Tab1: =====
const adjLoading = ref(false)
const adjList = ref<OperationLog[]>([])
const adjQuery = reactive({
dateRange: null as string[] | null,
targetTable: '',
keyword: '',
})
const adjPage = reactive({ page: 1, pageSize: 20, total: 0 })
function targetTableLabel(table: string): string {
const map: Record<string, string> = {
daily_production: '日产量',
worker_daily_summary: '员工日汇总',
}
return map[table] || table
}
async function loadAdjustment() {
adjLoading.value = true
try {
const params: Record<string,unknown> = { page: adjPage.page, pageSize: adjPage.pageSize }
if (adjQuery.dateRange?.length === 2) { params.startDate = adjQuery.dateRange[0]; params.endDate = adjQuery.dateRange[1] }
if (adjQuery.targetTable) params.targetTable = adjQuery.targetTable
if (adjQuery.keyword) params.keyword = adjQuery.keyword
const r: Record<string,unknown> = await request.get('/admin/log/adjustment', { params })
adjList.value = r.data?.items || []
adjPage.total = r.data?.total || 0
} finally { adjLoading.value = false }
}
function resetAdjQuery() {
Object.assign(adjQuery, { dateRange: null, targetTable: '', keyword: '' })
adjPage.page = 1
loadAdjustment()
}
async function exportAdjustment() {
try {
const params: Record<string,unknown> = {}
if (adjQuery.dateRange?.length === 2) { params.startDate = adjQuery.dateRange[0]; params.endDate = adjQuery.dateRange[1] }
if (adjQuery.targetTable) params.targetTable = adjQuery.targetTable
if (adjQuery.keyword) params.keyword = adjQuery.keyword
// Excel
const baseURL = (request as any).defaults?.baseURL || ''
const qs = new URLSearchParams(params).toString()
window.open(`${baseURL}/admin/log/adjustment/export?${qs}`, '_blank')
ElMessage.success('正在导出...')
} catch { ElMessage.error('导出失败') }
}
// ===== Tab2: =====
const sysLoading = ref(false)
const sysList = ref<OperationLog[]>([])
const sysQuery = reactive({
logLevel: '',
source: '',
dateRange: null as string[] | null,
keyword: '',
})
const sysPage = reactive({ page: 1, pageSize: 20, total: 0 })
const sysDetailVisible = ref(false)
const sysDetailRow = ref<OperationLog>({} as OperationLog)
function logLevelTag(level: string): string {
const map: Record<string, string> = { ERROR: 'danger', WARN: 'warning', INFO: 'success', DEBUG: 'info' }
return map[level] || 'info'
}
async function loadSystem() {
sysLoading.value = true
try {
const params: Record<string,unknown> = { page: sysPage.page, pageSize: sysPage.pageSize }
if (sysQuery.logLevel) params.logLevel = sysQuery.logLevel
if (sysQuery.source) params.source = sysQuery.source
if (sysQuery.dateRange?.length === 2) { params.startDate = sysQuery.dateRange[0]; params.endDate = sysQuery.dateRange[1] }
if (sysQuery.keyword) params.keyword = sysQuery.keyword
const r: Record<string,unknown> = await request.get('/admin/log/system', { params })
sysList.value = r.data?.items || []
sysPage.total = r.data?.total || 0
} finally { sysLoading.value = false }
}
function resetSysQuery() {
Object.assign(sysQuery, { logLevel: '', source: '', dateRange: null, keyword: '' })
sysPage.page = 1
loadSystem()
}
function viewSysDetail(row: OperationLog) {
sysDetailRow.value = row
sysDetailVisible.value = true
}
function formatJson(str: string): string {
try { return JSON.stringify(JSON.parse(str), null, 2) } catch { return str }
}
function handleTabChange(tab: string) {
if (tab === 'adjustment' && adjList.value.length === 0) loadAdjustment()
if (tab === 'system' && sysList.value.length === 0) loadSystem()
}
onMounted(() => { loadAdjustment() })
</script>

@ -0,0 +1,97 @@
<template>
<div class="login-page">
<div class="login-card">
<h2 class="login-title">CNC机床数据采集系统</h2>
<el-form ref="formRef" :model="form" :rules="rules" label-width="0" @submit.prevent="handleLogin">
<el-form-item prop="username">
<!-- 用户名输入限制最大输入长度为50字符 -->
<el-input v-model="form.username" placeholder="用户名" prefix-icon="User" size="large" clearable maxlength="50" />
</el-form-item>
<el-form-item prop="password">
<!-- 密码输入限制最大输入长度为50字符 -->
<el-input v-model="form.password" type="password" placeholder="密码" prefix-icon="Lock" size="large" show-password clearable maxlength="50" />
</el-form-item>
<el-form-item>
<el-checkbox v-model="form.rememberMe"></el-checkbox>
</el-form-item>
<el-form-item>
<el-button type="primary" size="large" :loading="loading" style="width: 100%" native-type="submit">
登录
</el-button>
</el-form-item>
</el-form>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, reactive } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { ElMessage } from 'element-plus'
import type { FormInstance, FormRules } from 'element-plus'
import request from '@/utils/request'
import type { ApiResponse } from '@/types'
const router = useRouter()
const route = useRoute()
const formRef = ref<FormInstance>()
const loading = ref(false)
const form = reactive({
username: '',
password: '',
rememberMe: false,
})
const rules: FormRules = {
username: [{ required: true, message: '请输入用户名', trigger: 'blur' }],
password: [{ required: true, message: '请输入密码', trigger: 'blur' }],
}
const handleLogin = async () => {
const valid = await formRef.value?.validate().catch(() => false)
if (!valid) return
loading.value = true
try {
const res: ApiResponse<{ token: string }> = await request.post('/admin/login', {
username: form.username,
password: form.password,
rememberMe: form.rememberMe,
})
localStorage.setItem('token', res.data.token)
ElMessage.success('登录成功')
const redirect = (route.query.redirect as string) || (route.path.startsWith('/mock') ? '/mock/dashboard' : '/dashboard')
router.push(redirect)
} catch {
//
} finally {
loading.value = false
}
}
</script>
<style scoped lang="scss">
.login-page {
height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
.login-card {
width: 400px;
padding: 40px;
background: #fff;
border-radius: 8px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15);
.login-title {
text-align: center;
margin-bottom: 30px;
color: #303133;
font-size: 22px;
}
}
}
</style>

@ -0,0 +1,107 @@
<template>
<div>
<!-- 使用 PageHeader 统一头部包含返回键标题和面包屑插槽 -->
<PageHeader :title="`机床详情:${detail.name}`" :showBack="true">
<template #breadcrumb>
<el-breadcrumb separator="/" style="margin: 0 0 12px 0;">
<el-breadcrumb-item><router-link :to="homePath">首页</router-link></el-breadcrumb-item>
<el-breadcrumb-item><router-link :to="machinePath">设备管理</router-link></el-breadcrumb-item>
<el-breadcrumb-item>{{ detail.name || '机床详情' }}</el-breadcrumb-item>
</el-breadcrumb>
</template>
</PageHeader>
<el-row :gutter="20">
<el-col :span="12"><el-card shadow="hover"><template #header><span>基本信息</span></template>
<el-descriptions :column="2" border size="small">
<el-descriptions-item label="机床名称">{{detail.name}}</el-descriptions-item>
<el-descriptions-item label="device_code">{{detail.deviceCode}}</el-descriptions-item>
<el-descriptions-item label="车间">{{detail.workshopName}}</el-descriptions-item>
<el-descriptions-item label="品牌">{{detail.brandName}}</el-descriptions-item>
<el-descriptions-item label="IP地址">{{detail.ipAddress}}</el-descriptions-item>
<el-descriptions-item label="绑定工人">{{detail.workerName||'-'}}</el-descriptions-item>
<el-descriptions-item label="在线"><el-tag :type="detail.isOnline?'success':'danger'" size="small">{{detail.isOnline?'在线':'离线'}}</el-tag></el-descriptions-item>
<el-descriptions-item label="启用"><el-tag :type="detail.isEnabled?'success':'danger'" size="small">{{detail.isEnabled?'启用':'停用'}}</el-tag></el-descriptions-item>
</el-descriptions>
</el-card></el-col>
<el-col :span="12"><el-card shadow="hover"><template #header><span>实时状态</span></template>
<el-descriptions :column="2" border size="small">
<el-descriptions-item label="NC程序名">{{status.programName||'-'}}</el-descriptions-item>
<el-descriptions-item label="零件数">{{status.partCount??'-'}}</el-descriptions-item>
<el-descriptions-item label="运行状态">{{status.runStatus||'-'}}</el-descriptions-item>
<el-descriptions-item label="操作模式">{{status.operateMode||'-'}}</el-descriptions-item>
<el-descriptions-item label="主轴设定">{{status.spindleSpeedSet??'-'}}</el-descriptions-item>
<el-descriptions-item label="进给设定">{{status.feedSpeedSet??'-'}}</el-descriptions-item>
<el-descriptions-item label="主轴实际">{{status.spindleSpeedActual??'-'}}</el-descriptions-item>
<el-descriptions-item label="主轴负载">{{status.spindleLoad??'-'}}%</el-descriptions-item>
</el-descriptions>
</el-card></el-col>
</el-row>
<el-row :gutter="20" class="mt-20">
<el-col :span="12"><el-card shadow="hover"><template #header><span>今日产量</span></template>
<el-table :data="todayProd" border stripe size="small">
<el-table-column prop="programName" label="程序名"/><el-table-column prop="quantity" label="产量" align="center"/><el-table-column prop="runTime" label="运行时间" align="center"/><el-table-column prop="cuttingTime" label="切削时间" align="center"/>
</el-table>
</el-card></el-col>
<el-col :span="12"><el-card shadow="hover"><template #header><span>7天产量趋势</span></template>
<div ref="chartRef" style="height:250px"></div>
</el-card></el-col>
</el-row>
<el-card shadow="hover" class="mt-20"><template #header><span>最近采集记录</span></template>
<el-table :data="records" border stripe size="small">
<el-table-column prop="collectTime" label="采集时间"/><el-table-column prop="programName" label="程序名"/><el-table-column prop="partCount" label="零件数" align="center"/><el-table-column prop="runStatus" label="运行状态"/>
</el-table>
</el-card>
</div>
</template>
<script setup lang="ts">
import PageHeader from '@/components/PageHeader.vue'
import { ref, onMounted, nextTick, onBeforeUnmount } from 'vue'
import { useRoute } from 'vue-router'
import request from '@/utils/request'
import echarts from '@/utils/echarts'
// ECharts 便 TS
import type { ECharts } from 'echarts/core'
const route = useRoute()
// Mock
const isMockPath = typeof window !== 'undefined' && window.location.pathname.startsWith('/mock')
const homePath = isMockPath ? '/mock/dashboard' : '/dashboard'
const machinePath = isMockPath ? '/mock/machine' : '/machine'
const detail = ref<Machine>({} as Machine)
const status = ref<Machine>({} as Machine)
let statusInterval: number | undefined
const todayProd = ref<Machine[]>([]); const records = ref<Machine[]>([])
const chartRef = ref<HTMLElement>()
let chart: ECharts | null = null
async function loadData() {
const id = route.params.id
const [d, s, t, r]: Record<string,unknown>[] = await Promise.all([
request.get('/admin/machine/detail', { params: { id } }),
request.get('/admin/machine/status', { params: { id } }),
request.get('/admin/machine/production/today', { params: { id } }),
request.get('/admin/machine/collect-records', { params: { id } }),
])
detail.value = d.data || {}; status.value = s.data || {}
todayProd.value = t.data?.items || []; records.value = r.data?.items || []
const trend: Record<string,unknown> = await request.get('/admin/machine/production/trend', { params: { id } })
await nextTick()
if (chartRef.value) {
chart = echarts.init(chartRef.value)
const items = trend.data?.items || []
chart.setOption({ xAxis: { type: 'category', data: items.map((i: Record<string,unknown>) => i.date.slice(5)) }, yAxis: { type: 'value' }, series: [{ type: 'line', data: items.map((i: Record<string,unknown>) => i.quantity), smooth: true, areaStyle: { opacity: 0.1 } }], tooltip: { trigger: 'axis' }, grid: { left: 40, right: 20, top: 10, bottom: 30 } })
}
}
async function fetchStatus() {
const id = route.params.id
const r: Record<string,unknown> = await request.get('/admin/machine/status', { params: { id } })
status.value = r.data || {}
}
onMounted(() => {
loadData()
// 10
statusInterval = window.setInterval(fetchStatus, 10000)
//
fetchStatus()
})
onBeforeUnmount(() => { chart?.dispose(); if (typeof statusInterval !== 'undefined') clearInterval(statusInterval) })
</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,233 @@
<template>
<div>
<!-- 查询条件区 -->
<el-form :inline="true" label-width="90px" class="filter-bar" style="margin-bottom:12px;" :model="filters">
<el-form-item label="日期">
<el-date-picker v-model="filters.dateRange" type="daterange" value-format="yyyy-MM-dd" range-separator="-" start-placeholder="" end-placeholder="" />
</el-form-item>
<el-form-item label="车间">
<el-select v-model="filters.workshopId" placeholder="请选择车间" clearable style="min-width:220px">
<el-option v-for="w in options.workshops" :key="w.id" :label="w.name" :value="w.id" />
</el-select>
</el-form-item>
<el-form-item label="机床">
<el-select v-model="filters.machineId" placeholder="请选择机床" clearable filterable style="min-width:220px">
<el-option v-for="m in options.machines" :key="m.id" :label="m.name" :value="m.id" />
</el-select>
</el-form-item>
<el-form-item label="工人">
<el-select v-model="filters.workerId" placeholder="请选择工人" clearable filterable style="min-width:220px">
<el-option v-for="wk in options.workers" :key="wk.id" :label="wk.name" :value="wk.id" />
</el-select>
</el-form-item>
<el-form-item label="程序名">
<el-input v-model="filters.programName" placeholder="输入程序名" style="width:240px" />
</el-form-item>
<el-form-item>
<el-button type="primary" @click="loadData"></el-button>
<el-button @click="resetFilters"></el-button>
</el-form-item>
<el-form-item style="margin-left:auto">
<el-button type="success" icon="el-icon-download" @click="exportData"></el-button>
</el-form-item>
</el-form>
<!-- 指标卡片 -->
<el-row :gutter="20" style="margin-bottom:20px">
<el-col :span="6"><el-card shadow="hover"><div style="text-align:center;padding:10px"><div style="color:#909399;font-size:14px">总产量</div><div style="font-size:28px;font-weight:bold">{{summary.totalQuantity?.toLocaleString()}}</div></div></el-card></el-col>
<el-col :span="6"><el-card shadow="hover"><div style="text-align:center;padding:10px"><div style="color:#909399;font-size:14px">运行机床</div><div style="font-size:28px;font-weight:bold">{{summary.activeMachineCount}}</div></div></el-card></el-col>
<el-col :span="6"><el-card shadow="hover"><div style="text-align:center;padding:10px"><div style="color:#909399;font-size:14px">切削总时</div><div style="font-size:28px;font-weight:bold">{{summary.totalCuttingTime}}</div></div></el-card></el-col>
<el-col :span="6"><el-card shadow="hover"><div style="text-align:center;padding:10px"><div style="color:#909399;font-size:14px">平均产量</div><div style="font-size:28px;font-weight:bold">{{summary.avgQuantityPerMachine}}</div></div></el-card></el-col>
</el-row>
<!-- 数据表格 -->
<el-table :data="tableData" border stripe v-loading="loading">
<el-table-column prop="date" label="日期" width="110"/>
<el-table-column prop="machineName" label="机床" width="120"/>
<el-table-column prop="programName" label="程序名" min-width="140" show-overflow-tooltip/>
<el-table-column label="产量" width="80" align="center"><template #default="{row}">{{row.dataStatus==='data_missing'?'-':row.quantity}}</template></el-table-column>
<el-table-column prop="runTime" label="运行时间" width="100" align="center"/>
<el-table-column prop="cuttingTime" label="切削时间" width="100" align="center"/>
<el-table-column label="日状态" width="80" align="center"><template #default="{row}"><el-tag :type="row.dataStatus==='normal'?'info':row.dataStatus==='offline'?'danger':'warning'" size="small">{{row.dataStatus==='normal'?'正常':row.dataStatus==='offline'?'离线':'缺失'}}</el-tag></template></el-table-column>
<el-table-column label="修正" width="80" align="center"><template #default="{row}">{{row.isAdjusted?'✓':'-'}}</template></el-table-column>
<el-table-column label="历史/操作" width="180" align="center"><template #default="{row}"><el-button text type="primary" @click="handleAdjust(row)"></el-button>
<el-button text @click="showHistory(row)" style="margin-left:8px">修正历史</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="historyVisible" :title="historyTitle" width="600px" destroy-on-close>
<template v-if="historyRows.length > 0">
<el-table :data="historyRows" border>
<el-table-column prop="createdAt" label="修正时间" width="180"/>
<el-table-column prop="oldValue" label="修正前产量" width="120"/>
<el-table-column prop="newValue" label="修正后产量" width="120"/>
<el-table-column prop="reason" label="修正原因"/>
<el-table-column prop="operator" label="操作人" width="120"/>
</el-table>
</template>
<template v-else>
<div style="text-align:center; padding:20px; color:#909399">暂无修正记录</div>
</template>
<template #footer>
<el-button @click="historyVisible=false"></el-button>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted, toRefs } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import request from '@/utils/request'
//
const loading = ref(false)
const tableData = ref<Array<any>>([])
const summary = ref<ProductionRecord>({} as ProductionRecord)
//
const page = reactive({ page: 1, pageSize: 20, total: 0 })
//
const filters = reactive({
dateRange: [] as string[],
workshopId: '',
machineId: '',
workerId: '',
programName: ''
})
//
const options = reactive({ workshops: [] as Array<{ id: string | number; name: string }>, machines: [] as Array<{ id: string | number; name: string }>, workers: [] as Array<{ id: string | number; name: string }> })
// /
const historyVisible = ref(false)
const historyRows = ref<Array<any>>([])
const historyTitle = ref('修正历史')
const historyLoading = ref(false)
const adjustVisible = ref(false)
const submitting = ref(false)
const adjustForm = reactive({ currentQty: 0, newQty: 0, reason: '', id: 0 })
//
async function exportData() {
if (isMockMode()) {
// Mock
ElMessage.info('Mock模式不支持导出')
return
}
const params = new URLSearchParams({
dateFrom: filters.dateRange?.[0] ?? '',
dateTo: filters.dateRange?.[1] ?? '',
workshopId: filters.workshopId ?? '',
machineId: filters.machineId ?? '',
workerId: filters.workerId ?? '',
programName: filters.programName ?? '',
page: String(page.page),
pageSize: String(page.pageSize)
}).toString()
window.open('/admin/production/export?' + params, '_blank')
}
// Mock/ Mock
const isMockMode = () => location.pathname.startsWith('/mock')
//
async function loadData() {
loading.value = true
try {
const params = {
page: page.page,
pageSize: page.pageSize,
dateFrom: filters.dateRange?.[0] ?? '',
dateTo: filters.dateRange?.[1] ?? '',
workshopId: filters.workshopId ?? '',
machineId: filters.machineId ?? '',
workerId: filters.workerId ?? '',
programName: filters.programName ?? ''
}
const [s, d] = await Promise.all([
request.get('/admin/production/daily-summary', { params }),
request.get('/admin/production/daily', { params })
])
summary.value = s.data || {}
tableData.value = d.data?.items ?? []
page.total = d.data?.total ?? 0
} finally {
loading.value = false
}
}
function resetFilters() {
const end = new Date()
const start = new Date()
start.setDate(end.getDate() - 6)
filters.dateRange = [start.toISOString().split('T')[0], end.toISOString().split('T')[0]]
filters.workshopId = ''
filters.machineId = ''
filters.workerId = ''
filters.programName = ''
//
loadData()
}
// 7
async function init() {
// 7
const end = new Date()
const start = new Date()
start.setDate(end.getDate() - 6)
filters.dateRange = [start.toISOString().split('T')[0], end.toISOString().split('T')[0]]
//
try {
const [ws, mc, wr] = await Promise.all([
request.get('/admin/workshop/list'),
request.get('/admin/machine/list'),
request.get('/admin/worker/list')
])
options.workshops = ws.data?.items ?? []
options.machines = mc.data?.items ?? []
options.workers = wr.data?.items ?? []
} catch {
//
}
//
loadData()
}
// ID
async function showHistory(row: ProductionRecord) {
historyRows.value = []
historyVisible.value = true
historyTitle.value = `修正历史 - ${row.machineName} - ${row.date}`
historyLoading.value = true
try {
const res = await request.get('/admin/production/adjustment-history', { params: { recordId: row.id } })
historyRows.value = res.data?.items ?? []
} finally {
historyLoading.value = false
}
}
//
async function doAdjust(){
await ElMessageBox.confirm('确定修正产量?修正后将记录审计日志。','提示',{type:'warning'})
submitting.value = true
try{
await request.post('/admin/production/adjust', adjustForm)
ElMessage.success('修正成功')
adjustVisible.value = false
loadData()
} finally{
submitting.value = false
}
}
//
function handleAdjust(row: ProductionRecord){
adjustForm.currentQty = row.quantity ?? 0
adjustForm.newQty = row.quantity ?? 0
adjustForm.reason = ''
adjustForm.id = row.id
adjustVisible.value = true
}
onMounted(init)
</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,529 @@
<template>
<div class="screen-page">
<!-- 顶部栏 -->
<header class="screen-header">
<div class="header-left">CNC机床数据采集系统</div>
<div class="header-right">
<span class="auto-refresh" style="color:#aaa; font-size:12px; margin-left:8px;">自动刷新: {{ refreshSecDisplay }} </span>
<span class="refresh-gear" @click="openRefreshDialog" style="cursor:pointer; margin-left:6px"></span>
<el-select v-model="filterWorkshop" placeholder="车间" size="small" style="width:120px;margin-right:8px" @change="refreshAll">
<el-option label="全部" value="" />
<el-option v-for="w in workshopOptions" :key="w" :label="w" :value="w" />
</el-select>
<el-select v-model="filterBrand" placeholder="品牌" size="small" style="width:120px;margin-right:16px" @change="refreshAll">
<el-option label="全部" value="" />
<el-option v-for="b in brandOptions" :key="b" :label="b" :value="b" />
</el-select>
<span class="header-time">{{ currentTime }}</span>
</div>
</header>
<!-- 第一行统计数字卡片 -->
<div class="stat-row">
<div class="stat-card" v-for="s in statCards" :key="s.key">
<div class="stat-label">{{ s.label }}</div>
<div class="stat-value" :style="{ color: s.color }">{{ s.value }}</div>
<div class="stat-sub" v-if="s.sub">{{ s.sub }}</div>
</div>
</div>
<!-- 第二行图表区 -->
<div class="chart-row">
<div class="chart-card">
<div class="chart-title">各车间产量</div>
<div ref="barChartRef" class="chart-container"></div>
</div>
<div class="chart-card">
<div class="chart-title">产量趋势(7)</div>
<div ref="lineChartRef" class="chart-container"></div>
</div>
</div>
<!-- 第三行排行+状态 -->
<div class="bottom-row">
<div class="rank-card">
<div class="chart-title">机床产量排行</div>
<div class="rank-list">
<div v-if="machineRank.length === 0" class="no-data"></div>
<div v-for="item in machineRank" :key="item.rank" class="rank-item">
<span class="rank-num" :class="{ 'rank-top': item.rank <= 3 }">{{ item.rank }}</span>
<span class="rank-name">{{ item.machineName }}</span>
<span class="rank-value">{{ item.quantity }}</span>
</div>
</div>
</div>
<div class="rank-card">
<div class="chart-title">工人产量排行</div>
<div class="rank-list">
<div v-if="workerRank.length === 0" class="no-data"></div>
<div v-for="item in workerRank" :key="item.rank" class="rank-item">
<span class="rank-num" :class="{ 'rank-top': item.rank <= 3 }">{{ item.rank }}</span>
<span class="rank-name">{{ item.workerName }}</span>
<span class="rank-value">{{ item.quantity }}</span>
</div>
</div>
</div>
<div class="status-card">
<div class="chart-title">机床状态总览</div>
<div class="status-grid">
<div v-if="machineStatus.length === 0" class="no-data"></div>
<div v-for="m in machineStatus" :key="m.machineId" class="status-block" :class="m.isOnline ? 'online' : 'offline'" :title="m.machineName"></div>
</div>
<div class="status-legend">
<span class="legend-item"><span class="legend-block online"></span> 在线</span>
<span class="legend-item"><span class="legend-block offline"></span> 离线</span>
</div>
</div>
</div>
</div>
<!-- 刷新间隔设置对话框 -->
<el-dialog v-model="refreshDialogVisible" title="刷新间隔设置" width="320px" >
<el-form label-width="90px">
<el-form-item label="间隔(秒)">
<el-input-number v-model="refreshSecondsInput" :min="1" :step="1" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="refreshDialogVisible = false">取消</el-button>
<el-button type="primary" @click="applyRefreshInterval"></el-button>
</template>
</el-dialog>
</template>

<script setup lang="ts">
import { ref, reactive, onMounted, onUnmounted, nextTick, computed } from 'vue'
import echarts from '@/utils/echarts'
// ECharts 便 TS
import type { ECharts } from 'echarts/core'
import { ElMessage } from 'element-plus'
import request from '@/utils/request'
import { watch } from 'vue'
const filterWorkshop = ref('')
const filterBrand = ref('')
const workshopOptions = ref<string[]>([])
const brandOptions = ref<string[]>([])
const currentTime = ref('')
let timeTimer: ReturnType<typeof setInterval> | null = null
const statCards = reactive([
{ key: 'online', label: '在线机床', value: '--', sub: '', color: '#00e5ff' },
{ key: 'production', label: '今日总产量', value: '--', sub: '', color: '#76ff03' },
{ key: 'collector', label: '采集服务', value: '--', sub: '', color: '#69f0ae' },
{ key: 'alerts', label: '活跃告警', value: '--', sub: '', color: '#ffd740' },
{ key: 'avg', label: '平均产量/台', value: '--', sub: '', color: '#e0e0e0' },
])
const barChartRef = ref<HTMLElement>()
const lineChartRef = ref<HTMLElement>()
let barChart: ECharts | null = null
let lineChart: ECharts | null = null
const machineRank = ref<DashboardSummary[]>([])
const workerRank = ref<DashboardSummary[]>([])
const machineStatus = ref<DashboardSummary[]>([])
let refreshTimer: ReturnType<typeof setInterval> | null = null
const refreshIntervalMs = ref<number>(10000)
const refreshSecondsInput = ref<number>(10)
const refreshDialogVisible = ref(false)
const refreshSecDisplay = computed(() => Math.max(1, Math.floor(refreshIntervalMs.value / 1000)))
function updateTime() {
const now = new Date()
currentTime.value = now.toLocaleString('zh-CN', {
year: 'numeric', month: '2-digit', day: '2-digit',
hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false,
})
}
function filterParams(): Record<string, string> {
const p: Record<string, string> = {}
if (filterWorkshop.value) p.workshop = filterWorkshop.value
if (filterBrand.value) p.brand = filterBrand.value
return p
}
async function loadFilters() {
try {
const r: Record<string,unknown> = await request.get('/screen/filters')
const items = r.data?.items || []
workshopOptions.value = items.filter((i: Record<string,unknown>) => i.filterType === 'workshop').map((i: Record<string,unknown>) => i.filterValue)
brandOptions.value = items.filter((i: Record<string,unknown>) => i.filterType === 'brand').map((i: Record<string,unknown>) => i.filterValue)
const defW = items.find((i: Record<string,unknown>) => i.filterType === 'workshop' && i.isDefault === 1)
if (defW) filterWorkshop.value = defW.filterValue
const defB = items.find((i: Record<string,unknown>) => i.filterType === 'brand' && i.isDefault === 1)
if (defB) filterBrand.value = defB.filterValue
} catch { /* 静默 */ }
}
async function loadSummary() {
try {
const r: Record<string,unknown> = await request.get('/screen/summary', { params: filterParams() })
const d = r.data || {}
statCards[0].value = d.onlineCount != null ? String(d.onlineCount) : '--'
statCards[0].sub = d.totalMachines ? '/ ' + d.totalMachines : ''
statCards[1].value = d.todayProduction != null ? d.todayProduction.toLocaleString() : '--'
statCards[3].value = d.activeAlerts != null ? String(d.activeAlerts) : '--'
statCards[4].value = d.avgQuantityPerMachine != null ? String(d.avgQuantityPerMachine) : '--'
} catch { /* 静默 */ }
}
async function loadCollectorStatus() {
try {
const r: Record<string,unknown> = await request.get('/screen/collector-status')
const d = r.data || {}
if (d.status === 'running') {
statCards[2].value = '运行中'
statCards[2].sub = d.uptime || ''
statCards[2].color = '#69f0ae'
} else {
statCards[2].value = '已停止'
statCards[2].sub = ''
statCards[2].color = '#ff5252'
}
} catch { /* 静默 */ }
}
async function loadWorkshopProduction() {
try {
const r: Record<string,unknown> = await request.get('/screen/workshop-production', { params: filterParams() })
const items = r.data?.items || []
if (!barChart) return
barChart.setOption({
tooltip: { trigger: 'axis' },
grid: { left: 60, right: 20, top: 20, bottom: 30 },
xAxis: { type: 'category', data: items.map((i: Record<string,unknown>) => i.name), axisLabel: { color: '#aaa' }, axisLine: { lineStyle: { color: '#333' } } },
yAxis: { type: 'value', axisLabel: { color: '#aaa' }, splitLine: { lineStyle: { color: '#222' } } },
series: [{ type: 'bar', data: items.map((i: Record<string,unknown>) => i.quantity), itemStyle: { color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [{ offset: 0, color: '#00e5ff' }, { offset: 1, color: '#006064' }]) }, barWidth: 30 }],
})
} catch { /* 静默 */ }
}
async function loadProductionTrend() {
try {
const r: Record<string,unknown> = await request.get('/screen/production-trend', { params: filterParams() })
const items = r.data?.items || []
if (!lineChart) return
lineChart.setOption({
tooltip: { trigger: 'axis' },
grid: { left: 60, right: 20, top: 20, bottom: 30 },
xAxis: { type: 'category', data: items.map((i: Record<string,unknown>) => i.date), axisLabel: { color: '#aaa' }, axisLine: { lineStyle: { color: '#333' } } },
yAxis: { type: 'value', axisLabel: { color: '#aaa' }, splitLine: { lineStyle: { color: '#222' } } },
series: [{
type: 'line', data: items.map((i: Record<string,unknown>) => i.quantity), smooth: true,
lineStyle: { color: '#76ff03', width: 2 }, itemStyle: { color: '#76ff03' },
areaStyle: { color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [{ offset: 0, color: 'rgba(118,255,3,0.3)' }, { offset: 1, color: 'rgba(118,255,3,0)' }]) },
}],
})
} catch { /* 静默 */ }
}
async function loadMachineRank() {
try {
const r: Record<string,unknown> = await request.get('/screen/machine-rank', { params: filterParams() })
machineRank.value = r.data?.items || []
} catch { /* 静默 */ }
}
async function loadWorkerRank() {
try {
const r: Record<string,unknown> = await request.get('/screen/worker-rank', { params: filterParams() })
workerRank.value = r.data?.items || []
} catch { /* 静默 */ }
}
async function loadMachineStatus() {
try {
const r: Record<string,unknown> = await request.get('/screen/machine-status', { params: filterParams() })
machineStatus.value = r.data?.items || []
} catch { /* 静默 */ }
}
function refreshAll() {
loadSummary()
loadCollectorStatus()
loadWorkshopProduction()
loadProductionTrend()
loadMachineRank()
loadWorkerRank()
loadMachineStatus()
}
function initCharts() {
if (barChartRef.value) {
barChart = echarts.init(barChartRef.value)
barChart.setOption({ backgroundColor: 'transparent' })
}
if (lineChartRef.value) {
lineChart = echarts.init(lineChartRef.value)
lineChart.setOption({ backgroundColor: 'transparent' })
}
}
function handleResize() {
barChart?.resize()
lineChart?.resize()
}
onMounted(async () => {
updateTime()
timeTimer = setInterval(updateTime, 1000)
ElMessage({ message: '按F11进入全屏模式', type: 'info', duration: 5000 })
await loadFilters()
await nextTick()
initCharts()
await fetchRefreshInterval()
refreshAll()
//
refreshTimer = setInterval(refreshAll, refreshIntervalMs.value)
window.addEventListener('resize', handleResize)
})
onUnmounted(() => {
if (timeTimer) clearInterval(timeTimer)
if (refreshTimer) clearInterval(refreshTimer)
barChart?.dispose()
lineChart?.dispose()
window.removeEventListener('resize', handleResize)
})
//
async function fetchRefreshInterval() {
try {
const r: Record<string,unknown> = await request.get('/screen/refresh-interval')
const v = r?.data?.interval ?? 10000
refreshIntervalMs.value = v
} catch {
//
}
}
watch(refreshIntervalMs, (newVal) => {
if (refreshTimer) clearInterval(refreshTimer)
refreshTimer = setInterval(refreshAll, newVal)
})
function openRefreshDialog() {
refreshSecondsInput.value = Math.max(1, Math.floor(refreshIntervalMs.value / 1000))
refreshDialogVisible.value = true
}
async function applyRefreshInterval() {
const seconds = Number(refreshSecondsInput.value)
if (!Number.isFinite(seconds) || seconds <= 0) {
ElMessage.error('请输入有效秒数')
return
}
refreshIntervalMs.value = seconds * 1000
refreshDialogVisible.value = false
}
</script>

<style scoped>
.screen-page {
background: #0f0f1a;
color: #e0e0e0;
min-height: 100vh;
display: flex;
flex-direction: column;
font-family: 'Microsoft YaHei', sans-serif;
overflow: hidden;
}
.screen-header {
height: 50px;
background: #1a1a2e;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 24px;
border-bottom: 1px solid #2a2a3e;
flex-shrink: 0;
}
.header-left {
font-size: 20px;
font-weight: bold;
color: #00e5ff;
letter-spacing: 2px;
}
.header-right {
display: flex;
align-items: center;
}
.header-time {
color: #aaa;
font-size: 14px;
font-family: 'Consolas', monospace;
}
.stat-row {
display: flex;
gap: 16px;
padding: 16px 24px;
flex-shrink: 0;
}
.stat-card {
flex: 1;
background: #1a1a2e;
border-radius: 8px;
padding: 12px 16px;
text-align: center;
border: 1px solid #2a2a3e;
transition: box-shadow 0.3s;
}
.stat-card:hover {
box-shadow: 0 0 12px rgba(0, 229, 255, 0.15);
}
.stat-label {
font-size: 13px;
color: #909399;
margin-bottom: 4px;
}
.stat-value {
font-size: 28px;
font-weight: bold;
letter-spacing: 1px;
}
.stat-sub {
font-size: 12px;
color: #606266;
margin-top: 2px;
}
.chart-row {
display: flex;
gap: 16px;
padding: 0 24px 16px;
flex-shrink: 0;
}
.chart-card {
flex: 1;
background: #1a1a2e;
border-radius: 8px;
padding: 12px;
border: 1px solid #2a2a3e;
height: 300px;
}
.chart-title {
font-size: 14px;
color: #bbb;
margin-bottom: 8px;
}
.chart-container {
width: 100%;
height: calc(100% - 30px);
}
.bottom-row {
display: flex;
gap: 16px;
padding: 0 24px 16px;
flex: 1;
min-height: 0;
}
.rank-card {
flex: 1;
background: #1a1a2e;
border-radius: 8px;
padding: 12px;
border: 1px solid #2a2a3e;
display: flex;
flex-direction: column;
}
.rank-list {
flex: 1;
overflow-y: auto;
}
.rank-item {
display: flex;
align-items: center;
padding: 6px 0;
border-bottom: 1px solid #2a2a3e;
}
.rank-item:last-child { border-bottom: none; }
.rank-num {
width: 24px;
height: 24px;
border-radius: 4px;
background: #2a2a3e;
color: #909399;
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 12px;
margin-right: 10px;
flex-shrink: 0;
}
.rank-num.rank-top {
background: #00e5ff;
color: #0f0f1a;
font-weight: bold;
}
.rank-name {
flex: 1;
font-size: 13px;
}
.rank-value {
font-size: 14px;
font-weight: bold;
color: #76ff03;
}
.status-card {
flex: 1;
background: #1a1a2e;
border-radius: 8px;
padding: 12px;
border: 1px solid #2a2a3e;
display: flex;
flex-direction: column;
}
.status-grid {
flex: 1;
display: flex;
flex-wrap: wrap;
gap: 6px;
align-content: flex-start;
overflow-y: auto;
}
.status-block {
width: 18px;
height: 18px;
border-radius: 3px;
cursor: default;
}
.status-block.online {
background: #4caf50;
box-shadow: 0 0 4px rgba(76, 175, 80, 0.5);
}
.status-block.offline {
background: #555;
}
.status-legend {
display: flex;
gap: 16px;
margin-top: 8px;
font-size: 12px;
color: #999;
}
.legend-item {
display: flex;
align-items: center;
gap: 4px;
}
.legend-block {
width: 12px;
height: 12px;
border-radius: 2px;
display: inline-block;
}
.legend-block.online {
background: #4caf50;
}
.legend-block.offline {
background: #555;
}
.no-data {
color: #666;
text-align: center;
padding: 20px;
font-size: 13px;
}
</style>

@ -0,0 +1,307 @@
<template>
<div>
<el-tabs v-model="activeTab">
<!-- Tab1: 系统配置 -->
<el-tab-pane label="系统配置" name="config">
<el-form :inline="true" class="mb-16">
<el-form-item><el-input v-model="configKeyword" placeholder="配置项/说明" clearable @input="filterConfig" /></el-form-item>
</el-form>
<el-table :data="filteredConfigs" border stripe v-loading="configLoading">
<el-table-column prop="configKey" label="配置项" width="200" show-overflow-tooltip />
<el-table-column label="配置值" show-overflow-tooltip>
<template #default="{ row }">
<span>{{ getDisplayValue(row) }}</span>
<el-button v-if="isSensitive(row.configKey)" link type="primary" @click="toggleVisibility(row.configKey)" style="margin-left:6px">
<i class="el-icon-view"></i>
</el-button>
</template>
</el-table-column>
<el-table-column prop="valueType" label="类型" width="80" align="center" />
<el-table-column prop="description" label="说明" width="200" show-overflow-tooltip />
<el-table-column label="操作" width="160" align="center">
<template #default="{ row }">
<el-button link type="primary" @click="editConfig(row)"></el-button>
<el-button v-if="isSensitive(row.configKey)" link type="warning" @click="resetToken(row)"></el-button>
</template>
</el-table-column>
</el-table>
<!-- 编辑配置弹窗 -->
<el-dialog v-model="configDialogVisible" title="编辑配置项" width="500px" destroy-on-close>
<el-form :model="configForm" label-width="100px">
<el-form-item label="配置项"><el-input v-model="configForm.configKey" disabled /></el-form-item>
<el-form-item label="配置值" required>
<template v-if="configForm.valueType === 'number'">
<el-input-number v-model="configForm.numberValue" :min="0" style="width:100%" />
</template>
<template v-else>
<div style="display:flex;gap:8px;align-items: center;">
<el-input v-model="configForm.displayValue" style="flex:1" />
<span v-if="isSensitive(configForm.configKey)" @click="toggleVisibility(configForm.configKey)" style="cursor:pointer;color:#409EFF;display:flex;align-items:center;gap:4px">
<i class="el-icon-view"></i>
<span>{{ configForm.displayValue === '••••••••' ? '显示' : '隐藏' }}</span>
</span>
</div>
</template>
</el-form-item>
<el-form-item label="说明"><el-input v-model="configForm.description" disabled /></el-form-item>
</el-form>
<template #footer>
<el-button @click="configDialogVisible = false">取消</el-button>
<el-button type="primary" :loading="configSubmitting" @click="saveConfig"></el-button>
</template>
</el-dialog>
</el-tab-pane>
<!-- Tab2: 车间管理 -->
<el-tab-pane label="车间管理" name="workshop">
<div class="mb-16"><el-button type="primary" @click="addWorkshop">+ </el-button></div>
<el-table :data="workshopList" border stripe v-loading="workshopLoading">
<el-table-column prop="name" label="车间名称" width="200" show-overflow-tooltip />
<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="160" align="center">
<template #default="{ row }">
<el-button link type="primary" @click="editWorkshop(row)"></el-button>
<el-button link type="primary" @click="toggleWorkshop(row)">{{ row.isEnabled ? '' : '' }}</el-button>
<el-button link type="danger" @click="deleteWorkshop(row)" :disabled="row.machineCount > 0">删除</el-button>
</template>
</el-table-column>
</el-table>
<!-- 新增/编辑车间弹窗 -->
<el-dialog v-model="workshopDialogVisible" :title="editingWorkshopId ? '编辑车间' : '新增车间'" width="400px" destroy-on-close>
<el-form ref="workshopFormRef" :model="workshopForm" :rules="workshopRules" label-width="100px">
<el-form-item label="车间名称" prop="name"><el-input v-model="workshopForm.name" maxlength="100" /></el-form-item>
<el-form-item label="排序号" prop="sortOrder"><el-input-number v-model="workshopForm.sortOrder" :min="0" :max="99" /></el-form-item>
</el-form>
<template #footer>
<el-button @click="workshopDialogVisible = false">取消</el-button>
<el-button type="primary" :loading="workshopSubmitting" @click="saveWorkshop"></el-button>
</template>
</el-dialog>
</el-tab-pane>
<!-- Tab3: 修改密码 -->
<el-tab-pane label="修改密码" name="password">
<el-card shadow="hover" style="max-width:500px">
<el-form ref="pwdFormRef" :model="pwdForm" :rules="pwdRules" label-width="100px">
<el-form-item label="当前密码" prop="currentPassword">
<el-input v-model="pwdForm.currentPassword" type="password" show-password />
</el-form-item>
<el-form-item label="新密码" prop="newPassword">
<el-input v-model="pwdForm.newPassword" type="password" show-password />
</el-form-item>
<el-form-item label="确认密码" prop="confirmPassword">
<el-input v-model="pwdForm.confirmPassword" type="password" show-password />
</el-form-item>
<el-form-item>
<el-button type="primary" :loading="pwdSubmitting" @click="changePassword"></el-button>
</el-form-item>
</el-form>
</el-card>
</el-tab-pane>
</el-tabs>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, computed, 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'
const router = useRouter()
const { isMock } = useMockMode()
const activeTab = ref('config')
// 1:
//
const visibleMap = ref(new Map<string, boolean>())
function isVisible(key: string): boolean {
return visibleMap.value.get(key) ?? false
}
function toggleVisibility(key: string) {
const v = visibleMap.value.get(key) ?? false
visibleMap.value.set(key, !v)
}
// /便
function getDisplayValue(row: SysConfig): string {
const key = row.configKey
if (isSensitive(key)) {
return isVisible(key) ? String(row.configValue) : '••••••••'
}
return String(row.configValue)
}
//
function isSensitive(key: string): boolean {
const k = (key || '').toLowerCase()
return k === 'api_token' || k === 'collector_api_key' || k.includes('token') || k.includes('key') || k.includes('secret') || k.includes('password')
}
// ===== Tab1: =====
const configLoading = ref(false)
const configList = ref<SysConfig[]>([])
const configKeyword = ref('')
const configDialogVisible = ref(false)
const configSubmitting = ref(false)
const configForm = reactive({ configKey: '', valueType: '', numberValue: 0, stringValue: '', description: '', id: 0, displayValue: '', originalValue: '' })
const filteredConfigs = computed(() => {
if (!configKeyword.value) return configList.value
const kw = configKeyword.value.toLowerCase()
return configList.value.filter((c: {configKey:string;description:string}) => c.configKey.toLowerCase().includes(kw) || c.description.toLowerCase().includes(kw))
})
function filterConfig() { /* computed auto-filters */ }
async function loadConfigs() {
configLoading.value = true
try { const r: Record<string,unknown> = await request.get('/admin/sys-config'); configList.value = r.data?.items || [] } finally { configLoading.value = false }
}
function editConfig(row: SysConfig) {
configForm.id = row.id
configForm.configKey = row.configKey
configForm.valueType = row.valueType
configForm.description = row.description
configForm.originalValue = row.configValue
if (row.valueType === 'number') { configForm.numberValue = Number(row.configValue) } else {
// /
if (isSensitive(row.configKey)) {
configForm.displayValue = '••••••••'
} else {
configForm.displayValue = String(row.configValue)
}
}
configDialogVisible.value = true
}
async function saveConfig() {
configSubmitting.value = true
try {
let valueToSend: string
if (configForm.valueType === 'number') {
valueToSend = String(configForm.numberValue)
} else {
const key = configForm.configKey
if (isSensitive(key) && configForm.displayValue === '••••••••') {
valueToSend = configForm.originalValue
} else {
valueToSend = configForm.displayValue
}
}
await request.post('/admin/sys-config/update', { id: configForm.id, configValue: valueToSend })
ElMessage.success('保存成功')
configDialogVisible.value = false
loadConfigs()
} finally { configSubmitting.value = false }
}
async function resetToken(row: SysConfig) {
await ElMessageBox.confirm(`确定重置${row.description}?重置后需同步更新相关服务配置。`, '提示', { type: 'warning' })
const r: Record<string,unknown> = await request.post('/admin/sys-config/reset-token', { configKey: row.configKey })
ElMessage.success('重置成功,新值:' + (r.data?.newValue || '已生成'))
loadConfigs()
}
// ===== Tab2: =====
const workshopLoading = ref(false)
const workshopList = ref<SysConfig[]>([])
const workshopDialogVisible = ref(false)
const workshopSubmitting = ref(false)
const editingWorkshopId = ref<number | null>(null)
const workshopFormRef = ref<FormInstance>()
const workshopForm = reactive({ name: '', sortOrder: 0 })
const workshopRules: FormRules = {
name: [{ required: true, message: '请输入车间名称', trigger: 'blur' }],
sortOrder: [{ required: true, message: '请输入排序号', trigger: 'blur' }],
}
async function loadWorkshops() {
workshopLoading.value = true
try { const r: Record<string,unknown> = await request.get('/admin/workshop'); workshopList.value = r.data?.items || [] } finally { workshopLoading.value = false }
}
function addWorkshop() {
editingWorkshopId.value = null
Object.assign(workshopForm, { name: '', sortOrder: workshopList.value.length + 1 })
workshopDialogVisible.value = true
}
function editWorkshop(row: SysConfig) {
editingWorkshopId.value = row.id
Object.assign(workshopForm, { name: row.name, sortOrder: row.sortOrder })
workshopDialogVisible.value = true
}
async function saveWorkshop() {
const valid = await workshopFormRef.value?.validate().catch(() => false)
if (!valid) return
workshopSubmitting.value = true
try {
await request.post(editingWorkshopId.value ? '/admin/workshop/update' : '/admin/workshop', { ...workshopForm, id: editingWorkshopId.value })
ElMessage.success('保存成功')
workshopDialogVisible.value = false
loadWorkshops()
} finally { workshopSubmitting.value = false }
}
async function toggleWorkshop(row: SysConfig) {
await ElMessageBox.confirm(`确定${row.isEnabled ? '停用' : '启用'}车间【${row.name}】?`, '提示', { type: 'warning' })
await request.post('/admin/workshop/toggle', { id: row.id })
ElMessage.success('操作成功')
loadWorkshops()
}
async function deleteWorkshop(row: SysConfig) {
await ElMessageBox.confirm(`确定删除车间【${row.name}】?此操作不可恢复。`, '提示', { type: 'warning' })
await request.post('/admin/workshop/delete', { id: row.id })
ElMessage.success('已删除')
loadWorkshops()
}
// ===== Tab3: =====
const pwdSubmitting = ref(false)
const pwdFormRef = ref<FormInstance>()
const pwdForm = reactive({ currentPassword: '', newPassword: '', confirmPassword: '' })
const validateConfirm = (_rule: unknown, value: string, callback: (err?:Error)=>void) => {
if (value !== pwdForm.newPassword) { callback(new Error('两次输入密码不一致')) } else { callback() }
}
const pwdRules: FormRules = {
currentPassword: [{ required: true, message: '请输入当前密码', trigger: 'blur' }],
newPassword: [
{ required: true, message: '请输入新密码', trigger: 'blur' },
{ min: 8, max: 32, message: '密码长度8-32位', trigger: 'blur' },
],
confirmPassword: [
{ required: true, message: '请确认密码', trigger: 'blur' },
{ validator: validateConfirm, trigger: 'blur' },
],
}
async function changePassword() {
const valid = await pwdFormRef.value?.validate().catch(() => false)
if (!valid) return
pwdSubmitting.value = true
try {
await request.post('/admin/change-password', { currentPassword: pwdForm.currentPassword, newPassword: pwdForm.newPassword })
ElMessage.success('密码修改成功,请重新登录')
localStorage.removeItem('token')
router.push(isMock.value ? '/mock/login' : '/login')
} finally { pwdSubmitting.value = false }
}
onMounted(() => { loadConfigs(); loadWorkshops() })
</script>

@ -0,0 +1,74 @@
<template>
<div>
<!-- 使用 PageHeader 统一头部包含返回键标题和面包屑插槽 -->
<PageHeader :title="`工人详情:${detail.name}`" :showBack="true">
<template #breadcrumb>
<el-breadcrumb separator="/" style="margin: 0 0 12px 0;">
<el-breadcrumb-item><router-link :to="isMock ? '/mock/dashboard' : '/dashboard'">首页</router-link></el-breadcrumb-item>
<el-breadcrumb-item><router-link :to="isMock ? '/mock/worker' : '/worker'">工人管理</router-link></el-breadcrumb-item>
<el-breadcrumb-item>{{ detail.name || '工人详情' }}</el-breadcrumb-item>
</el-breadcrumb>
</template>
</PageHeader>
<el-row :gutter="20">
<el-col :span="12"><el-card shadow="hover"><template #header><span>基本信息</span></template>
<el-descriptions :column="2" border size="small">
<el-descriptions-item label="工号">{{detail.code}}</el-descriptions-item>
<el-descriptions-item label="姓名">{{detail.name}}</el-descriptions-item>
<el-descriptions-item label="状态"><el-tag :type="detail.isEnabled?'success':'danger'" size="small">{{detail.isEnabled?'启用':'停用'}}</el-tag></el-descriptions-item>
<el-descriptions-item label="绑定机床数">{{detail.machineCount||0}}</el-descriptions-item>
</el-descriptions>
</el-card></el-col>
<el-col :span="12"><el-card shadow="hover"><template #header><span>7天产量趋势</span></template>
<div ref="chartRef" style="height:200px"></div>
</el-card></el-col>
</el-row>
<el-card shadow="hover" class="mt-20"><template #header><span>绑定机床</span></template>
<el-table :data="machines" border stripe size="small">
<el-table-column label="机床名称"><template #default="{row}"><router-link :to="machineDetailPath(row)" style="color: var(--el-color-primary); cursor: pointer; text-decoration: none;">{{row.machineName}}</router-link></template></el-table-column>
<el-table-column prop="deviceCode" label="device_code"/><el-table-column prop="workshopName" label="车间"/><el-table-column prop="brandName" label="品牌"/>
<el-table-column label="在线" align="center"><template #default="{row}"><el-tag :type="row.isOnline?'success':'danger'" size="small">{{row.isOnline?'在线':'离线'}}</el-tag></template></el-table-column>
<el-table-column prop="programName" label="当前程序"/>
</el-table>
</el-card>
<el-card shadow="hover" class="mt-20"><template #header><span>今日产量</span></template>
<el-table :data="todayProd" border stripe size="small">
<el-table-column prop="machineName" label="机床名称"/><el-table-column prop="programName" label="程序名"/><el-table-column prop="quantity" label="产量" align="center"/><el-table-column prop="runTime" label="运行时间" align="center"/><el-table-column prop="cuttingTime" label="切削时间" align="center"/>
</el-table>
</el-card>
</div>
</template>
<script setup lang="ts">
import { useMockPath } from '@/composables/useMockPath'
import PageHeader from '@/components/PageHeader.vue'
import type { ApiResponse, Worker } from '@/types'
import {ref,onMounted,nextTick,onBeforeUnmount} from 'vue'
import {useRoute} from 'vue-router'
import request from '@/utils/request'
import echarts from '@/utils/echarts'
// ECharts 便 TS
import type { ECharts } from 'echarts/core'
const route=useRoute()
const { isMock } = useMockPath()
const detail=ref<Worker>({} as Worker);const machines=ref<Array<{ machineName: string; deviceCode?: string; workshopName?: string; isOnline?: boolean; programName?: string }>>([]);const todayProd=ref<Array<{ machineName?: string; programName?: string; quantity?: number; runTime?: number }>>([])
const chartRef=ref<HTMLElement>();let chart: ECharts | null = null
async function loadData(){
const id=route.params.id
const [d,m,t] = await Promise.all([
request.get('/admin/worker/detail', { params: { id } }),
request.get('/admin/worker/machines', { params: { id } }),
request.get('/admin/worker/production/today', { params: { id } }),
])
detail.value = d.data || ({} as Worker)
machines.value = m.data?.items || []
todayProd.value = t.data?.items || []
const trend:any=await request.get('/admin/worker/production/trend',{params:{id}})
await nextTick()
if(chartRef.value){chart=echarts.init(chartRef.value);const items=trend.data?.items||[];chart.setOption({xAxis:{type:'category',data:items.map((i:any)=>i.date.slice(5))},yAxis:{type:'value'},series:[{type:'line',data:items.map((i:any)=>i.quantity),smooth:true,areaStyle:{opacity:0.1}}],tooltip:{trigger:'axis'},grid:{left:40,right:20,top:10,bottom:30}})}
}
onMounted(loadData);onBeforeUnmount(()=>{chart?.dispose()})
function machineDetailPath(row:any){
const base = window.location.pathname.startsWith('/mock') ? '/mock/machine/' : '/machine/'
return base + (row.machineId ?? row.id)
}
</script>

@ -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…
Cancel
Save