feat: 移除PDI和订单号字段,新增设备巡检模块
- 从物料跟踪页面移除订单号列和表单字段 - 从导航菜单移除PDI管理,添加设备巡检 - 新增InspectionLocation和InspectionRecord后端模型和API - 新增设备巡检前端页面(左侧点位列表,右侧设备和历史记录)
This commit is contained in:
18
frontend/src/App.vue
Normal file
18
frontend/src/App.vue
Normal file
@@ -0,0 +1,18 @@
|
||||
<template>
|
||||
<div id="app">
|
||||
<router-view />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default { name: 'App' }
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
@import '@/assets/styles/global';
|
||||
|
||||
#app { height: 100vh; overflow: hidden; display: flex; flex-direction: column; }
|
||||
|
||||
// NProgress 颜色适配
|
||||
#nprogress .bar { background: var(--sms-highlight) !important; }
|
||||
</style>
|
||||
5
frontend/src/api/auth.js
Normal file
5
frontend/src/api/auth.js
Normal file
@@ -0,0 +1,5 @@
|
||||
import request from './request'
|
||||
|
||||
export const login = data => request.post('/auth/login', data)
|
||||
export const getMe = () => request.get('/auth/me')
|
||||
export const createUser = data => request.post('/auth/users', data)
|
||||
64
frontend/src/api/index.js
Normal file
64
frontend/src/api/index.js
Normal file
@@ -0,0 +1,64 @@
|
||||
import request from './request'
|
||||
|
||||
// 看板
|
||||
export const getDashboardSummary = () => request.get('/dashboard/summary')
|
||||
|
||||
// 物料跟踪
|
||||
export const getCoils = params => request.get('/material/coils', { params })
|
||||
export const getCoil = coilNo => request.get(`/material/coils/${coilNo}`)
|
||||
export const createCoil = data => request.post('/material/coils', data)
|
||||
export const updateCoil = (coilNo, data) => request.put(`/material/coils/${coilNo}`, data)
|
||||
export const getTracking = params => request.get('/material/tracking', { params })
|
||||
|
||||
// 实绩管理
|
||||
export const getProductionRecords = params => request.get('/production/', { params })
|
||||
export const createProductionRecord = data => request.post('/production/', data)
|
||||
export const updateProductionRecord = (id, data) => request.put(`/production/${id}`, data)
|
||||
|
||||
// 计划管理
|
||||
export const getPlans = params => request.get('/plan/', { params })
|
||||
export const createPlan = data => request.post('/plan/', data)
|
||||
export const updatePlan = (id, data) => request.put(`/plan/${id}`, data)
|
||||
export const confirmPlan = id => request.patch(`/plan/${id}/confirm`)
|
||||
|
||||
// 停机管理
|
||||
export const getDowntimeCategories = () => request.get('/downtime/categories')
|
||||
export const getDowntimeRecords = params => request.get('/downtime/', { params })
|
||||
export const createDowntime = data => request.post('/downtime/', data)
|
||||
export const updateDowntime = (id, data) => request.put(`/downtime/${id}`, data)
|
||||
|
||||
// 设备管理
|
||||
export const getEquipments = params => request.get('/equipment/', { params })
|
||||
export const createEquipment = data => request.post('/equipment/', data)
|
||||
export const updateEquipment = (id, data) => request.put(`/equipment/${id}`, data)
|
||||
export const getEquipmentMaintenance = (id, params) => request.get(`/equipment/${id}/maintenance`, { params })
|
||||
export const createMaintenance = data => request.post('/equipment/maintenance', data)
|
||||
|
||||
// 报文监控
|
||||
export const getMessageLogs = params => request.get('/message/logs', { params })
|
||||
export const getMessageLog = id => request.get(`/message/logs/${id}`)
|
||||
|
||||
// 工艺预测模型
|
||||
export const predictAcidSpeed = data => request.post('/prediction/acid-speed', data)
|
||||
export const predictTension = data => request.post('/prediction/tension', data)
|
||||
export const predictQuality = data => request.post('/prediction/quality', data)
|
||||
export const predictConsumption = data => request.post('/prediction/consumption', data)
|
||||
|
||||
// 模型校准
|
||||
export const getCalibration = () => request.get('/prediction/calibration')
|
||||
export const calibrateAcidSpeed = data => request.post('/prediction/calibration/acid-speed', data)
|
||||
export const calibrateTension = data => request.post('/prediction/calibration/tension', data)
|
||||
export const calibrateQuality = data => request.post('/prediction/calibration/quality', data)
|
||||
export const resetCalibration = model => request.post(`/prediction/calibration/reset/${model}`)
|
||||
|
||||
// 设备巡检
|
||||
export const getInspectionLocations = () => request.get('/inspection/locations')
|
||||
export const createInspectionLocation = data => request.post('/inspection/locations', data)
|
||||
export const getInspectionRecords = params => request.get('/inspection/records', { params })
|
||||
export const createInspectionRecord = data => request.post('/inspection/records', data)
|
||||
|
||||
// 质量管理
|
||||
export const getQualityList = params => request.get('/quality/', { params })
|
||||
export const createQuality = data => request.post('/quality/', data)
|
||||
export const updateQuality = (id, data) => request.put(`/quality/${id}`, data)
|
||||
export const getQualitySummary = () => request.get('/quality/summary')
|
||||
38
frontend/src/api/request.js
Normal file
38
frontend/src/api/request.js
Normal file
@@ -0,0 +1,38 @@
|
||||
import axios from 'axios'
|
||||
import { Message } from 'element-ui'
|
||||
import store from '@/store'
|
||||
import router from '@/router'
|
||||
|
||||
const request = axios.create({
|
||||
baseURL: '/api',
|
||||
timeout: 15000,
|
||||
})
|
||||
|
||||
request.interceptors.request.use(config => {
|
||||
const token = store.getters['auth/token']
|
||||
if (token) config.headers.Authorization = `Bearer ${token}`
|
||||
return config
|
||||
})
|
||||
|
||||
request.interceptors.response.use(
|
||||
res => {
|
||||
const data = res.data
|
||||
if (data.code && data.code !== 200) {
|
||||
Message.error(data.msg || '请求失败')
|
||||
return Promise.reject(new Error(data.msg))
|
||||
}
|
||||
return data
|
||||
},
|
||||
err => {
|
||||
if (err.response?.status === 401) {
|
||||
store.dispatch('auth/logout')
|
||||
router.push('/login')
|
||||
Message.error('登录已过期,请重新登录')
|
||||
} else {
|
||||
Message.error(err.response?.data?.detail || err.message || '网络异常')
|
||||
}
|
||||
return Promise.reject(err)
|
||||
}
|
||||
)
|
||||
|
||||
export default request
|
||||
260
frontend/src/assets/styles/global.scss
Normal file
260
frontend/src/assets/styles/global.scss
Normal file
@@ -0,0 +1,260 @@
|
||||
@import './variables';
|
||||
|
||||
:root {
|
||||
--bg-primary: #{$bg-primary};
|
||||
--bg-secondary: #{$bg-secondary};
|
||||
--bg-card: #{$bg-card};
|
||||
--bg-panel: #{$bg-panel};
|
||||
--bg-input: #{$bg-input};
|
||||
--border: #{$border};
|
||||
--text-primary: #{$text-primary};
|
||||
--text-secondary:#{$text-secondary};
|
||||
--text-muted: #{$text-muted};
|
||||
--accent-green: #{$accent-green};
|
||||
--accent-yellow: #{$accent-yellow};
|
||||
--accent-red: #{$accent-red};
|
||||
--accent-cyan: #{$accent-cyan};
|
||||
--sms-blue: #{$sms-blue};
|
||||
--sms-highlight: #{$sms-highlight};
|
||||
--status-run: #{$status-run};
|
||||
--status-warn: #{$status-warn};
|
||||
--status-fault: #{$status-fault};
|
||||
--font-mono: #{$font-mono};
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
|
||||
body {
|
||||
font-family: $font-main;
|
||||
background: $bg-primary;
|
||||
color: $text-primary;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
// ─── 卡片 ───
|
||||
.card {
|
||||
background: $bg-card;
|
||||
border: 1px solid $border;
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
|
||||
&-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 14px;
|
||||
background: $bg-panel;
|
||||
border-bottom: 1px solid $border;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: $sms-highlight;
|
||||
letter-spacing: .4px;
|
||||
|
||||
.ch-badge {
|
||||
margin-left: auto;
|
||||
font-size: 10px;
|
||||
padding: 1px 8px;
|
||||
border-radius: 8px;
|
||||
background: rgba(0,200,255,.1);
|
||||
color: $sms-highlight;
|
||||
border: 1px solid rgba(0,200,255,.3);
|
||||
}
|
||||
}
|
||||
|
||||
&-body { padding: 12px 14px; }
|
||||
}
|
||||
|
||||
// ─── 指标卡 ───
|
||||
.metric-box {
|
||||
background: $bg-panel;
|
||||
border: 1px solid $border;
|
||||
border-radius: 5px;
|
||||
padding: 10px 14px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
|
||||
.mb-label { font-size: 11px; color: $text-secondary; }
|
||||
.mb-value {
|
||||
font-size: 22px;
|
||||
font-family: $font-mono;
|
||||
font-weight: 700;
|
||||
color: $sms-highlight;
|
||||
line-height: 1;
|
||||
}
|
||||
.mb-unit { font-size: 11px; color: $text-muted; }
|
||||
}
|
||||
|
||||
// ─── 数据表格 ───
|
||||
.data-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 12px;
|
||||
|
||||
th {
|
||||
background: $bg-panel;
|
||||
color: $text-secondary;
|
||||
font-weight: 600;
|
||||
padding: 7px 10px;
|
||||
text-align: left;
|
||||
border-bottom: 1px solid $border;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
td {
|
||||
padding: 6px 10px;
|
||||
border-bottom: 1px solid rgba(48,54,61,.5);
|
||||
color: $text-primary;
|
||||
font-family: $font-mono;
|
||||
}
|
||||
|
||||
tr:hover td { background: rgba(255,255,255,.02); }
|
||||
|
||||
.td-num { color: $sms-highlight; }
|
||||
.td-ok { color: $accent-green; }
|
||||
.td-warn { color: $accent-yellow; }
|
||||
.td-err { color: $accent-red; }
|
||||
.td-muted{ color: $text-muted; }
|
||||
}
|
||||
|
||||
.table-scroll {
|
||||
overflow-x: auto;
|
||||
&::-webkit-scrollbar { height: 4px; }
|
||||
&::-webkit-scrollbar-thumb { background: $border; }
|
||||
}
|
||||
|
||||
// ─── Badge ───
|
||||
.badge {
|
||||
display: inline-block;
|
||||
padding: 1px 8px;
|
||||
border-radius: 8px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
|
||||
&-green { background: #1a3a1f; color: $accent-green; border: 1px solid $accent-green; }
|
||||
&-yellow { background: #3a2a00; color: $accent-yellow; border: 1px solid $accent-yellow; }
|
||||
&-red { background: #3a0a0a; color: $accent-red; border: 1px solid $accent-red; }
|
||||
&-blue { background: rgba(0,120,212,.15); color: $sms-highlight; border: 1px solid rgba(0,200,255,.3); }
|
||||
&-gray { background: #222; color: $text-muted; border: 1px solid $border; }
|
||||
}
|
||||
|
||||
// ─── 按钮 ───
|
||||
.btn {
|
||||
padding: 5px 14px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all .15s;
|
||||
user-select: none;
|
||||
font-family: $font-main;
|
||||
|
||||
&-primary { background: $sms-blue; border-color: $sms-blue; color: #fff; &:hover { background: #1086e0; } }
|
||||
&-success { background: #1a3a1f; border-color: $accent-green; color: $accent-green; &:hover { background: $accent-green; color: #000; } }
|
||||
&-danger { background: #3a0a0a; border-color: $accent-red; color: $accent-red; }
|
||||
&-outline { background: transparent; border-color: $border; color: $text-secondary; &:hover { border-color: $sms-highlight; color: $sms-highlight; } }
|
||||
&.fw { width: 100%; }
|
||||
}
|
||||
|
||||
// ─── 输入框 ───
|
||||
.kv-input {
|
||||
background: $bg-input;
|
||||
border: 1px solid $border;
|
||||
border-radius: 3px;
|
||||
color: $text-primary;
|
||||
font-family: $font-mono;
|
||||
font-size: 12px;
|
||||
padding: 3px 7px;
|
||||
width: 100%;
|
||||
outline: none;
|
||||
transition: border-color .15s;
|
||||
&:focus { border-color: $accent-blue; }
|
||||
}
|
||||
|
||||
// ─── KV 参数行 ───
|
||||
.kv-grid { display: grid; grid-template-columns: auto 1fr; gap: 4px 14px; align-items: center; }
|
||||
.kv-label { color: $text-secondary; font-size: 12px; white-space: nowrap; }
|
||||
.kv-value { font-family: $font-mono; font-size: 12px; color: $sms-highlight; font-weight: 600; }
|
||||
.kv-unit { color: $text-muted; font-size: 11px; }
|
||||
|
||||
// ─── 进度条 ───
|
||||
.prog-bar-wrap { background: #111; border-radius: 3px; height: 6px; overflow: hidden; }
|
||||
.prog-bar-fill { height: 100%; border-radius: 3px; transition: width .4s; }
|
||||
|
||||
// ─── 分区标题 ───
|
||||
.sec-title {
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
color: $text-muted;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1.2px;
|
||||
margin-bottom: 8px;
|
||||
padding-bottom: 4px;
|
||||
border-bottom: 1px solid $border;
|
||||
}
|
||||
|
||||
// ─── Grid helpers ───
|
||||
.grid-2 { display: grid; grid-template-columns: 1fr 1fr; gap: 14px; }
|
||||
.grid-3 { display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 14px; }
|
||||
.grid-4 { display: grid; grid-template-columns: repeat(4,1fr); gap: 14px; }
|
||||
.grid-5 { display: grid; grid-template-columns: repeat(5,1fr); gap: 14px; }
|
||||
.section-row { display: flex; gap: 14px; > .card { flex: 1; min-width: 0; } }
|
||||
.flex-row { display: flex; gap: 10px; align-items: center; }
|
||||
.flex-col { display: flex; flex-direction: column; gap: 8px; }
|
||||
.flex-between{ display: flex; justify-content: space-between; align-items: center; }
|
||||
.mt8 { margin-top: 8px; }
|
||||
.mt12 { margin-top: 12px; }
|
||||
.fw { width: 100%; }
|
||||
|
||||
// ─── Element UI 暗色覆写 ───
|
||||
.el-dialog {
|
||||
background: $bg-card !important;
|
||||
border: 1px solid $border !important;
|
||||
border-radius: 6px !important;
|
||||
|
||||
&__header { background: $bg-panel; border-bottom: 1px solid $border; padding: 12px 16px; }
|
||||
&__title { color: $sms-highlight !important; font-size: 13px; font-weight: 600; }
|
||||
&__headerbtn .el-dialog__close { color: $text-secondary !important; }
|
||||
&__body { background: $bg-card; color: $text-primary; padding: 16px; }
|
||||
&__footer { background: $bg-panel; border-top: 1px solid $border; padding: 10px 16px; }
|
||||
}
|
||||
|
||||
.el-form-item__label { color: $text-secondary !important; font-size: 12px; }
|
||||
|
||||
.el-input__inner, .el-textarea__inner, .el-select .el-input__inner {
|
||||
background: $bg-input !important;
|
||||
border-color: $border !important;
|
||||
color: $text-primary !important;
|
||||
font-family: $font-mono;
|
||||
font-size: 12px;
|
||||
&:focus { border-color: $sms-blue !important; }
|
||||
}
|
||||
|
||||
.el-select-dropdown {
|
||||
background: $bg-panel !important;
|
||||
border-color: $border !important;
|
||||
.el-select-dropdown__item { color: $text-secondary; &.selected, &:hover { color: $sms-highlight; background: rgba(0,200,255,.08); } }
|
||||
}
|
||||
|
||||
.el-date-editor .el-range-input,
|
||||
.el-date-editor .el-range-separator { background: transparent !important; color: $text-secondary !important; }
|
||||
|
||||
.el-pagination {
|
||||
.el-pager li { background: $bg-panel; color: $text-secondary; border: 1px solid $border;
|
||||
&.active { color: $sms-highlight; border-color: $sms-highlight; } }
|
||||
button { background: $bg-panel !important; color: $text-secondary !important; border: 1px solid $border; }
|
||||
}
|
||||
|
||||
.el-input-number .el-input__inner { text-align: left; }
|
||||
|
||||
.el-radio__label { color: $text-secondary; font-size: 12px; }
|
||||
.el-radio__inner { background: $bg-input; border-color: $border; }
|
||||
.el-radio__input.is-checked .el-radio__inner { background: $sms-blue; border-color: $sms-blue; }
|
||||
|
||||
.el-message-box {
|
||||
background: $bg-card !important;
|
||||
border-color: $border !important;
|
||||
&__title { color: $text-primary !important; }
|
||||
&__content { color: $text-secondary !important; }
|
||||
}
|
||||
31
frontend/src/assets/styles/variables.scss
Normal file
31
frontend/src/assets/styles/variables.scss
Normal file
@@ -0,0 +1,31 @@
|
||||
// ─── 色彩系统(与参考HTML完全一致)───
|
||||
$bg-primary: #0d1117;
|
||||
$bg-secondary: #161b22;
|
||||
$bg-card: #1c2230;
|
||||
$bg-panel: #212936;
|
||||
$bg-input: #0d1117;
|
||||
$border: #30363d;
|
||||
$border-active: #1f6feb;
|
||||
|
||||
$text-primary: #e6edf3;
|
||||
$text-secondary: #8b949e;
|
||||
$text-muted: #6e7681;
|
||||
|
||||
$accent-blue: #1f6feb;
|
||||
$accent-cyan: #00b4d8;
|
||||
$accent-green: #28a745;
|
||||
$accent-yellow: #f0a500;
|
||||
$accent-orange: #e05a00;
|
||||
$accent-red: #da3633;
|
||||
$accent-purple: #8957e5;
|
||||
|
||||
$sms-blue: #0078d4;
|
||||
$sms-highlight: #00c8ff;
|
||||
|
||||
$status-run: #28a745;
|
||||
$status-stop: #6e7681;
|
||||
$status-warn: #f0a500;
|
||||
$status-fault: #da3633;
|
||||
|
||||
$font-main: 'Segoe UI', 'Microsoft YaHei', sans-serif;
|
||||
$font-mono: 'Consolas', 'Courier New', monospace;
|
||||
30
frontend/src/main.js
Normal file
30
frontend/src/main.js
Normal file
@@ -0,0 +1,30 @@
|
||||
import Vue from 'vue'
|
||||
import App from './App.vue'
|
||||
import router from './router'
|
||||
import store from './store'
|
||||
import ElementUI from 'element-ui'
|
||||
import 'element-ui/lib/theme-chalk/index.css'
|
||||
import NProgress from 'nprogress'
|
||||
import 'nprogress/nprogress.css'
|
||||
|
||||
Vue.use(ElementUI, { size: 'small', zIndex: 3000 })
|
||||
|
||||
Vue.config.productionTip = false
|
||||
|
||||
// 路由守卫
|
||||
router.beforeEach((to, from, next) => {
|
||||
NProgress.start()
|
||||
const token = store.getters['auth/token']
|
||||
if (to.meta.requiresAuth && !token) {
|
||||
next('/login')
|
||||
} else {
|
||||
next()
|
||||
}
|
||||
})
|
||||
router.afterEach(() => NProgress.done())
|
||||
|
||||
new Vue({
|
||||
router,
|
||||
store,
|
||||
render: h => h(App),
|
||||
}).$mount('#app')
|
||||
100
frontend/src/router/index.js
Normal file
100
frontend/src/router/index.js
Normal file
@@ -0,0 +1,100 @@
|
||||
import Vue from 'vue'
|
||||
import VueRouter from 'vue-router'
|
||||
|
||||
Vue.use(VueRouter)
|
||||
|
||||
const routes = [
|
||||
{
|
||||
path: '/login',
|
||||
name: 'Login',
|
||||
component: () => import('@/views/Login.vue'),
|
||||
meta: { title: '登录' }
|
||||
},
|
||||
{
|
||||
path: '/',
|
||||
component: () => import('@/views/Layout.vue'),
|
||||
meta: { requiresAuth: true },
|
||||
redirect: '/dashboard',
|
||||
children: [
|
||||
{
|
||||
path: 'dashboard',
|
||||
name: 'Dashboard',
|
||||
component: () => import('@/views/Dashboard.vue'),
|
||||
meta: { title: '生产看板', icon: 'el-icon-monitor', requiresAuth: true }
|
||||
},
|
||||
{
|
||||
path: 'material',
|
||||
name: 'Material',
|
||||
component: () => import('@/views/Material.vue'),
|
||||
meta: { title: '物料跟踪', icon: 'el-icon-box', requiresAuth: true }
|
||||
},
|
||||
{
|
||||
path: 'production',
|
||||
name: 'Production',
|
||||
component: () => import('@/views/Production.vue'),
|
||||
meta: { title: '实绩管理', icon: 'el-icon-data-analysis', requiresAuth: true }
|
||||
},
|
||||
{
|
||||
path: 'plan',
|
||||
name: 'Plan',
|
||||
component: () => import('@/views/Plan.vue'),
|
||||
meta: { title: '计划管理', icon: 'el-icon-date', requiresAuth: true }
|
||||
},
|
||||
{
|
||||
path: 'downtime',
|
||||
name: 'Downtime',
|
||||
component: () => import('@/views/Downtime.vue'),
|
||||
meta: { title: '停机管理', icon: 'el-icon-warning-outline', requiresAuth: true }
|
||||
},
|
||||
{
|
||||
path: 'equipment',
|
||||
name: 'Equipment',
|
||||
component: () => import('@/views/Equipment.vue'),
|
||||
meta: { title: '设备管理', icon: 'el-icon-set-up', requiresAuth: true }
|
||||
},
|
||||
{
|
||||
path: 'message',
|
||||
name: 'Message',
|
||||
component: () => import('@/views/Message.vue'),
|
||||
meta: { title: '报文监控', icon: 'el-icon-connection', requiresAuth: true }
|
||||
},
|
||||
{
|
||||
path: 'process-model',
|
||||
name: 'ProcessModel',
|
||||
component: () => import('@/views/ProcessModel.vue'),
|
||||
meta: { title: '工艺段模型', icon: 'el-icon-cpu', requiresAuth: true }
|
||||
},
|
||||
{
|
||||
path: 'tension-model',
|
||||
name: 'TensionModel',
|
||||
component: () => import('@/views/TensionModel.vue'),
|
||||
meta: { title: '张力设定', icon: 'el-icon-odometer', requiresAuth: true }
|
||||
},
|
||||
{
|
||||
path: 'inspection',
|
||||
name: 'Inspection',
|
||||
component: () => import('@/views/Inspection.vue'),
|
||||
meta: { title: '设备巡检', requiresAuth: true }
|
||||
},
|
||||
{
|
||||
path: 'quality',
|
||||
name: 'Quality',
|
||||
component: () => import('@/views/Quality.vue'),
|
||||
meta: { title: '质量管理', icon: 'el-icon-medal', requiresAuth: true }
|
||||
},
|
||||
{
|
||||
path: 'capacity',
|
||||
name: 'Capacity',
|
||||
component: () => import('@/views/Capacity.vue'),
|
||||
meta: { title: '产能分析', icon: 'el-icon-s-data', requiresAuth: true }
|
||||
},
|
||||
]
|
||||
},
|
||||
{ path: '*', redirect: '/' }
|
||||
]
|
||||
|
||||
export default new VueRouter({
|
||||
mode: 'history',
|
||||
base: process.env.BASE_URL,
|
||||
routes
|
||||
})
|
||||
10
frontend/src/store/index.js
Normal file
10
frontend/src/store/index.js
Normal file
@@ -0,0 +1,10 @@
|
||||
import Vue from 'vue'
|
||||
import Vuex from 'vuex'
|
||||
import auth from './modules/auth'
|
||||
|
||||
Vue.use(Vuex)
|
||||
|
||||
export default new Vuex.Store({
|
||||
modules: { auth },
|
||||
strict: process.env.NODE_ENV !== 'production'
|
||||
})
|
||||
45
frontend/src/store/modules/auth.js
Normal file
45
frontend/src/store/modules/auth.js
Normal file
@@ -0,0 +1,45 @@
|
||||
import { login, getMe } from '@/api/auth'
|
||||
|
||||
const TOKEN_KEY = 'mes_token'
|
||||
|
||||
export default {
|
||||
namespaced: true,
|
||||
state: {
|
||||
token: localStorage.getItem(TOKEN_KEY) || '',
|
||||
user: null,
|
||||
},
|
||||
getters: {
|
||||
token: s => s.token,
|
||||
user: s => s.user,
|
||||
isLoggedIn: s => !!s.token,
|
||||
},
|
||||
mutations: {
|
||||
SET_TOKEN(state, token) {
|
||||
state.token = token
|
||||
localStorage.setItem(TOKEN_KEY, token)
|
||||
},
|
||||
SET_USER(state, user) {
|
||||
state.user = user
|
||||
},
|
||||
LOGOUT(state) {
|
||||
state.token = ''
|
||||
state.user = null
|
||||
localStorage.removeItem(TOKEN_KEY)
|
||||
},
|
||||
},
|
||||
actions: {
|
||||
async login({ commit }, { username, password }) {
|
||||
const res = await login({ username, password })
|
||||
commit('SET_TOKEN', res.data.access_token)
|
||||
commit('SET_USER', { username: res.data.username, role: res.data.role })
|
||||
return res
|
||||
},
|
||||
async fetchMe({ commit }) {
|
||||
const res = await getMe()
|
||||
commit('SET_USER', res.data)
|
||||
},
|
||||
logout({ commit }) {
|
||||
commit('LOGOUT')
|
||||
},
|
||||
}
|
||||
}
|
||||
413
frontend/src/views/Capacity.vue
Normal file
413
frontend/src/views/Capacity.vue
Normal file
@@ -0,0 +1,413 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="sec-title">产能分析</div>
|
||||
|
||||
<!-- ─── KPI摘要 ─── -->
|
||||
<div class="grid-5">
|
||||
<div class="metric-box">
|
||||
<div class="mb-label">年产量目标</div>
|
||||
<div class="mb-value">70</div>
|
||||
<div class="mb-unit">万吨</div>
|
||||
</div>
|
||||
<div class="metric-box">
|
||||
<div class="mb-label">本年实际完成</div>
|
||||
<div class="mb-value" style="color:#28a745;">{{ kpi.actual_yt }}</div>
|
||||
<div class="mb-unit">万吨</div>
|
||||
</div>
|
||||
<div class="metric-box">
|
||||
<div class="mb-label">完成率</div>
|
||||
<div class="mb-value" :style="{ color: kpi.completion_rate >= 85 ? '#28a745' : '#f0a500' }">
|
||||
{{ kpi.completion_rate }}
|
||||
</div>
|
||||
<div class="mb-unit">%</div>
|
||||
</div>
|
||||
<div class="metric-box">
|
||||
<div class="mb-label">日均产量</div>
|
||||
<div class="mb-value">{{ kpi.daily_avg }}</div>
|
||||
<div class="mb-unit">吨/天</div>
|
||||
</div>
|
||||
<div class="metric-box">
|
||||
<div class="mb-label">OEE</div>
|
||||
<div class="mb-value" :style="{ color: kpi.oee >= 85 ? '#28a745' : '#f0a500' }">
|
||||
{{ kpi.oee }}
|
||||
</div>
|
||||
<div class="mb-unit">%</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ─── 图表区 ─── -->
|
||||
<div class="section-row">
|
||||
<!-- 月产量柱状图 -->
|
||||
<div class="card" style="flex:3;">
|
||||
<div class="card-header">月度产量趋势(万吨)</div>
|
||||
<div class="card-body">
|
||||
<canvas ref="monthChart" height="180"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 速度分布直方图 -->
|
||||
<div class="card" style="flex:2;">
|
||||
<div class="card-header">运行速度分布 (m/min)</div>
|
||||
<div class="card-body">
|
||||
<canvas ref="speedChart" height="180"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ─── 消耗预测面板 + 班次对比 ─── -->
|
||||
<div class="section-row">
|
||||
<!-- 消耗预测 -->
|
||||
<div class="card" style="flex:1;">
|
||||
<div class="card-header">消耗量预测</div>
|
||||
<div class="card-body">
|
||||
<div class="flex-col" style="margin-bottom:12px;">
|
||||
<div class="form-field">
|
||||
<div class="kv-label">计划产量 (t)</div>
|
||||
<input v-model.number="cons.planned_weight" type="number" class="kv-input" step="100" min="100" />
|
||||
</div>
|
||||
<div class="form-field">
|
||||
<div class="kv-label">再生装置</div>
|
||||
<select v-model="cons.has_regen" class="kv-input">
|
||||
<option :value="true">有再生装置</option>
|
||||
<option :value="false">无再生装置</option>
|
||||
</select>
|
||||
</div>
|
||||
<button class="btn btn-primary fw" :disabled="consLoading" @click="doCons">
|
||||
{{ consLoading ? '计算中...' : '预测消耗' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="consResult">
|
||||
<div class="sec-title">预测结果</div>
|
||||
<div class="kv-grid">
|
||||
<span class="kv-label">盐酸消耗</span>
|
||||
<span class="kv-value">{{ consResult.acid_consumption_kg }} <span class="kv-unit">kg ({{ consResult.acid_unit_kg_per_t }} kg/t)</span></span>
|
||||
<span class="kv-label">蒸汽消耗</span>
|
||||
<span class="kv-value">{{ consResult.steam_consumption_kg }} <span class="kv-unit">kg ({{ consResult.steam_unit_kg_per_t }} kg/t)</span></span>
|
||||
<span class="kv-label">电力消耗</span>
|
||||
<span class="kv-value">{{ consResult.power_consumption_kwh }} <span class="kv-unit">kWh ({{ consResult.power_unit_kwh_per_t }} kWh/t)</span></span>
|
||||
<span class="kv-label">冷却水</span>
|
||||
<span class="kv-value">{{ consResult.cooling_water_m3 }} <span class="kv-unit">m³ ({{ consResult.cooling_water_unit_m3_per_t }} m³/t)</span></span>
|
||||
<span class="kv-label">计划产量</span>
|
||||
<span class="kv-value">{{ consResult.coil_weight_t }} <span class="kv-unit">t</span></span>
|
||||
</div>
|
||||
|
||||
<!-- 单耗对比条 -->
|
||||
<div class="mt8 sec-title">单耗指标对比</div>
|
||||
<div class="unit-bar-row" v-for="item in unitItems" :key="item.label">
|
||||
<span class="unit-bar-label">{{ item.label }}</span>
|
||||
<div class="prog-bar-wrap" style="flex:1;margin:0 8px;">
|
||||
<div class="prog-bar-fill" :style="{ width: item.pct + '%', background: item.color }"></div>
|
||||
</div>
|
||||
<span class="unit-bar-val">{{ item.val }} {{ item.unit }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 班次对比表 -->
|
||||
<div class="card" style="flex:2;">
|
||||
<div class="card-header">班次产能对比</div>
|
||||
<div class="table-scroll">
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>班次</th>
|
||||
<th>日期</th>
|
||||
<th>产量(t)</th>
|
||||
<th>平均速度</th>
|
||||
<th>卷数</th>
|
||||
<th>停机时长(h)</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="row in shiftData" :key="row.id">
|
||||
<td>
|
||||
<span :class="['badge', shiftBadge(row.shift)]">{{ row.shift }}</span>
|
||||
</td>
|
||||
<td class="td-muted">{{ row.date }}</td>
|
||||
<td class="td-num" style="color:#00c8ff;">{{ row.production }}</td>
|
||||
<td class="td-num">{{ row.avg_speed }} m/min</td>
|
||||
<td class="td-num">{{ row.coil_count }}</td>
|
||||
<td :class="row.stop_h > 1.5 ? 'td-warn' : 'td-num'">{{ row.stop_h }}</td>
|
||||
</tr>
|
||||
<tr v-if="!shiftData.length">
|
||||
<td colspan="6" class="td-muted" style="text-align:center;padding:20px;">暂无班次数据</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { predictConsumption, getProductionRecords, getDowntimeRecords } from '@/api'
|
||||
|
||||
const MONTHS = ['1月','2月','3月','4月','5月','6月','7月','8月','9月','10月','11月','12月']
|
||||
const SPEED_BINS = ['20-40','40-60','60-80','80-100','100-120','120-140','140-160','160-180']
|
||||
const SPEED_BIN_COLORS = ['#30363d','#1f6feb','#0078d4','#28a745','#28a745','#00c8ff','#f0a500','#da3633']
|
||||
|
||||
export default {
|
||||
name: 'Capacity',
|
||||
data() {
|
||||
return {
|
||||
kpi: {
|
||||
actual_yt: '—',
|
||||
completion_rate: '—',
|
||||
daily_avg: '—',
|
||||
oee: '—',
|
||||
},
|
||||
cons: { planned_weight: 1000, has_regen: true },
|
||||
consLoading: false,
|
||||
consResult: null,
|
||||
shiftData: [],
|
||||
monthData: Array(12).fill(0),
|
||||
speedFreq: Array(8).fill(0),
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
unitItems() {
|
||||
if (!this.consResult) return []
|
||||
return [
|
||||
{ label: '酸耗', val: this.consResult.acid_unit_kg_per_t, unit: 'kg/t', pct: Math.min(this.consResult.acid_unit_kg_per_t / 5 * 100, 100), color: '#0078d4' },
|
||||
{ label: '蒸汽', val: this.consResult.steam_unit_kg_per_t, unit: 'kg/t', pct: Math.min(this.consResult.steam_unit_kg_per_t / 60 * 100, 100), color: '#00c8ff' },
|
||||
{ label: '电力', val: this.consResult.power_unit_kwh_per_t, unit: 'kWh/t', pct: Math.min(this.consResult.power_unit_kwh_per_t / 20 * 100, 100), color: '#f0a500' },
|
||||
{ label: '冷却水', val: this.consResult.cooling_water_unit_m3_per_t, unit: 'm³/t', pct: Math.min(this.consResult.cooling_water_unit_m3_per_t / 2 * 100, 100), color: '#28a745' },
|
||||
]
|
||||
}
|
||||
},
|
||||
async mounted() {
|
||||
await this.fetchData()
|
||||
},
|
||||
methods: {
|
||||
async fetchData() {
|
||||
try {
|
||||
const [prodRes, dtRes] = await Promise.all([
|
||||
getProductionRecords({ page_size: 500 }),
|
||||
getDowntimeRecords({ page_size: 500 }),
|
||||
])
|
||||
const prods = prodRes.data?.items || []
|
||||
const dts = dtRes.data?.items || []
|
||||
|
||||
// ── KPI ──
|
||||
const totalWeightT = prods.reduce((s, p) => s + (p.coil_weight_t || 0), 0)
|
||||
const actualYt = (totalWeightT / 10000).toFixed(2)
|
||||
const TARGET_YT = 70
|
||||
const completionRate = totalWeightT > 0 ? ((totalWeightT / 10000) / TARGET_YT * 100).toFixed(1) : '—'
|
||||
|
||||
// Daily average: group by date
|
||||
const dayMap = {}
|
||||
prods.forEach(p => {
|
||||
const d = (p.start_time || '').slice(0, 10)
|
||||
if (!d) return
|
||||
if (!dayMap[d]) dayMap[d] = 0
|
||||
dayMap[d] += (p.coil_weight_t || 0)
|
||||
})
|
||||
const days = Object.values(dayMap)
|
||||
const dailyAvg = days.length ? (days.reduce((a, b) => a + b, 0) / days.length).toFixed(0) : '—'
|
||||
|
||||
// OEE: runtime / (runtime + downtime) × 100 — simplified as production hours / shift hours
|
||||
const totalDtMin = dts.reduce((s, d) => s + (d.duration_min || 0), 0)
|
||||
const SHIFT_H = 8
|
||||
const shifts = new Set(prods.map(p => (p.start_time || '').slice(0, 13))).size || 1
|
||||
const totalShiftMin = shifts * SHIFT_H * 60
|
||||
const oee = totalShiftMin > 0 ? Math.min(((totalShiftMin - totalDtMin) / totalShiftMin * 100).toFixed(1), 100) : '—'
|
||||
|
||||
this.kpi = {
|
||||
actual_yt: actualYt,
|
||||
completion_rate: completionRate,
|
||||
daily_avg: dailyAvg,
|
||||
oee,
|
||||
}
|
||||
|
||||
// ── 月度产量 ──
|
||||
const monthMap = Array(12).fill(0)
|
||||
prods.forEach(p => {
|
||||
if (p.start_time) {
|
||||
const m = new Date(p.start_time).getMonth()
|
||||
monthMap[m] += (p.coil_weight_t || 0)
|
||||
}
|
||||
})
|
||||
this.monthData = monthMap.map(v => +(v / 10000).toFixed(2))
|
||||
|
||||
// ── 速度分布 ──
|
||||
const speedBins = [0, 0, 0, 0, 0, 0, 0, 0]
|
||||
const edges = [20, 40, 60, 80, 100, 120, 140, 160, 180]
|
||||
prods.forEach(p => {
|
||||
const v = p.avg_speed
|
||||
if (!v) return
|
||||
for (let i = 0; i < edges.length - 1; i++) {
|
||||
if (v >= edges[i] && v < edges[i + 1]) { speedBins[i]++; break }
|
||||
}
|
||||
})
|
||||
this.speedFreq = speedBins
|
||||
|
||||
// ── 班次对比 ──
|
||||
const shiftKey = p => `${(p.start_time || '').slice(0, 10)}_${p.shift || '—'}`
|
||||
const sMap = {}
|
||||
prods.forEach(p => {
|
||||
const k = shiftKey(p)
|
||||
if (!sMap[k]) sMap[k] = { shift: p.shift || '—', date: (p.start_time || '').slice(0, 10), weight: 0, speeds: [], count: 0 }
|
||||
sMap[k].weight += (p.coil_weight_t || 0)
|
||||
sMap[k].count++
|
||||
if (p.avg_speed) sMap[k].speeds.push(p.avg_speed)
|
||||
})
|
||||
|
||||
// Attach downtime per shift-date
|
||||
const dtDayMap = {}
|
||||
dts.forEach(d => {
|
||||
const day = (d.start_time || '').slice(0, 10)
|
||||
if (!day) return
|
||||
dtDayMap[day] = (dtDayMap[day] || 0) + (d.duration_min || 0)
|
||||
})
|
||||
|
||||
this.shiftData = Object.values(sMap)
|
||||
.sort((a, b) => b.date.localeCompare(a.date))
|
||||
.slice(0, 12)
|
||||
.map((s, i) => ({
|
||||
id: i,
|
||||
shift: s.shift,
|
||||
date: s.date,
|
||||
production: s.weight.toFixed(1),
|
||||
avg_speed: s.speeds.length ? (s.speeds.reduce((a, b) => a + b, 0) / s.speeds.length).toFixed(1) : '—',
|
||||
coil_count: s.count,
|
||||
stop_h: s.date && dtDayMap[s.date] ? (dtDayMap[s.date] / 60 / 3).toFixed(1) : '0.0',
|
||||
}))
|
||||
} catch (e) {
|
||||
// leave defaults
|
||||
}
|
||||
this.$nextTick(() => {
|
||||
this.drawMonthChart()
|
||||
this.drawSpeedChart()
|
||||
})
|
||||
},
|
||||
shiftBadge(s) {
|
||||
const m = { '甲班': 'badge-blue', '乙班': 'badge-green', '丙班': 'badge-yellow', '丁班': 'badge-gray' }
|
||||
return m[s] || 'badge-gray'
|
||||
},
|
||||
async doCons() {
|
||||
this.consLoading = true
|
||||
try {
|
||||
const res = await predictConsumption({
|
||||
thickness: 3.0,
|
||||
width: 1000,
|
||||
coil_weight_kg: this.cons.planned_weight * 1000,
|
||||
has_regen_station: this.cons.has_regen,
|
||||
})
|
||||
this.consResult = res.data
|
||||
} catch (e) {
|
||||
this.$message.error('预测失败:' + (e.response?.data?.detail || e.message))
|
||||
} finally {
|
||||
this.consLoading = false
|
||||
}
|
||||
},
|
||||
drawMonthChart() {
|
||||
const canvas = this.$refs.monthChart
|
||||
if (!canvas) return
|
||||
const W = canvas.offsetWidth || 500
|
||||
const H = 180
|
||||
canvas.width = W
|
||||
canvas.height = H
|
||||
const ctx = canvas.getContext('2d')
|
||||
ctx.clearRect(0, 0, W, H)
|
||||
const data = this.monthData
|
||||
const maxVal = Math.max(...data, 1)
|
||||
const barW = Math.floor((W - 40) / 12) - 4
|
||||
const xOff = 20
|
||||
const barArea = H - 40
|
||||
|
||||
data.forEach((v, i) => {
|
||||
const x = xOff + i * (barW + 4)
|
||||
const bh = Math.floor(v / maxVal * barArea)
|
||||
const y = H - 24 - bh
|
||||
ctx.fillStyle = v > 0 ? '#0078d4' : '#30363d'
|
||||
ctx.globalAlpha = 0.85
|
||||
ctx.fillRect(x, y, barW, bh)
|
||||
ctx.globalAlpha = 1
|
||||
if (v > 0) {
|
||||
ctx.fillStyle = '#e6edf3'
|
||||
ctx.font = '10px Consolas'
|
||||
ctx.textAlign = 'center'
|
||||
ctx.fillText(v, x + barW / 2, y - 4)
|
||||
}
|
||||
ctx.fillStyle = '#8b949e'
|
||||
ctx.font = '10px "Microsoft YaHei"'
|
||||
ctx.fillText(MONTHS[i], x + barW / 2, H - 6)
|
||||
})
|
||||
|
||||
const TARGET_PER_MONTH = 5.83
|
||||
const targetY = H - 24 - Math.floor(TARGET_PER_MONTH / maxVal * barArea)
|
||||
ctx.strokeStyle = '#f0a500'
|
||||
ctx.lineWidth = 1.5
|
||||
ctx.setLineDash([4, 4])
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(xOff, targetY)
|
||||
ctx.lineTo(W - xOff, targetY)
|
||||
ctx.stroke()
|
||||
ctx.setLineDash([])
|
||||
ctx.fillStyle = '#f0a500'
|
||||
ctx.font = '10px Consolas'
|
||||
ctx.textAlign = 'left'
|
||||
ctx.fillText('目标 5.83', W - 58, targetY - 4)
|
||||
},
|
||||
drawSpeedChart() {
|
||||
const canvas = this.$refs.speedChart
|
||||
if (!canvas) return
|
||||
const W = canvas.offsetWidth || 320
|
||||
const H = 180
|
||||
canvas.width = W
|
||||
canvas.height = H
|
||||
const ctx = canvas.getContext('2d')
|
||||
ctx.clearRect(0, 0, W, H)
|
||||
const data = this.speedFreq
|
||||
const maxVal = Math.max(...data, 1)
|
||||
const barW = Math.floor((W - 20) / data.length) - 2
|
||||
const xOff = 10
|
||||
|
||||
data.forEach((v, i) => {
|
||||
const x = xOff + i * (barW + 2)
|
||||
const bh = Math.floor(v / maxVal * (H - 40))
|
||||
const y = H - 24 - bh
|
||||
ctx.fillStyle = SPEED_BIN_COLORS[i]
|
||||
ctx.globalAlpha = 0.85
|
||||
ctx.fillRect(x, y, barW, bh)
|
||||
ctx.globalAlpha = 1
|
||||
ctx.fillStyle = '#8b949e'
|
||||
ctx.font = '9px Consolas'
|
||||
ctx.textAlign = 'center'
|
||||
ctx.fillText(SPEED_BINS[i], x + barW / 2, H - 6)
|
||||
if (v > 0) {
|
||||
ctx.fillStyle = '#e6edf3'
|
||||
ctx.font = '9px Consolas'
|
||||
ctx.fillText(v, x + barW / 2, y - 3)
|
||||
}
|
||||
})
|
||||
},
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@import '@/assets/styles/variables';
|
||||
.form-field { display: flex; flex-direction: column; gap: 5px; }
|
||||
.mt8 { margin-top: 8px; }
|
||||
.unit-bar-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-top: 6px;
|
||||
gap: 4px;
|
||||
}
|
||||
.unit-bar-label {
|
||||
font-size: 11px;
|
||||
color: $text-secondary;
|
||||
width: 44px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.unit-bar-val {
|
||||
font-size: 11px;
|
||||
color: $sms-highlight;
|
||||
font-family: $font-mono;
|
||||
white-space: nowrap;
|
||||
}
|
||||
</style>
|
||||
246
frontend/src/views/Dashboard.vue
Normal file
246
frontend/src/views/Dashboard.vue
Normal file
@@ -0,0 +1,246 @@
|
||||
<template>
|
||||
<div>
|
||||
<!-- 指标卡 -->
|
||||
<div class="grid-5" style="gap:10px;">
|
||||
<div class="metric-box" v-for="c in statCards" :key="c.label">
|
||||
<div class="mb-label">{{ c.label }}</div>
|
||||
<div class="mb-value" :style="{ color: c.color || 'var(--sms-highlight)' }">{{ c.value }}</div>
|
||||
<div class="mb-unit">{{ c.unit }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 当前在线卷 -->
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
当前在线钢卷状态
|
||||
<span class="ch-badge">实时</span>
|
||||
</div>
|
||||
<div class="table-scroll">
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>卷号</th><th>钢种</th><th>厚度(mm)</th><th>宽度(mm)</th>
|
||||
<th>当前位置</th><th>速度(m/min)</th><th>入线时间</th><th>状态</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="r in onlineCoils" :key="r.coil_no">
|
||||
<td class="td-num">{{ r.coil_no }}</td>
|
||||
<td>{{ r.steel_grade || '—' }}</td>
|
||||
<td class="td-num">{{ r.spec_thickness || '—' }}</td>
|
||||
<td class="td-num">{{ r.spec_width || '—' }}</td>
|
||||
<td>{{ r.position || '酸洗段' }}</td>
|
||||
<td class="td-num">{{ r.speed || '85.3' }}</td>
|
||||
<td class="td-muted">{{ formatTime(r.created_at) }}</td>
|
||||
<td><span class="badge badge-green">在线</span></td>
|
||||
</tr>
|
||||
<tr v-if="!onlineCoils.length">
|
||||
<td colspan="8" class="td-muted" style="text-align:center;padding:20px;">暂无在线钢卷</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 趋势 + 停机统计 -->
|
||||
<div class="section-row">
|
||||
<div class="card" style="flex:2;">
|
||||
<div class="card-header">今日产量趋势(卷/小时)</div>
|
||||
<div class="card-body">
|
||||
<canvas ref="trendChart" style="width:100%;height:140px;display:block;"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card" style="flex:1;">
|
||||
<div class="card-header">今日停机统计</div>
|
||||
<div class="card-body" style="padding:0;">
|
||||
<table class="data-table">
|
||||
<thead><tr><th>停机类别</th><th>次数</th><th>时长(min)</th></tr></thead>
|
||||
<tbody>
|
||||
<tr v-for="d in downtimeStat" :key="d.name">
|
||||
<td>{{ d.name }}</td>
|
||||
<td class="td-num">{{ d.count }}</td>
|
||||
<td class="td-warn">{{ d.duration }}</td>
|
||||
</tr>
|
||||
<tr v-if="!downtimeStat.length">
|
||||
<td colspan="3" class="td-muted" style="text-align:center;padding:16px;">今日无停机记录</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 班次实绩 -->
|
||||
<div class="card">
|
||||
<div class="card-header">今日班次实绩汇总</div>
|
||||
<div class="table-scroll">
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr><th>班次</th><th>计划(卷)</th><th>实际(卷)</th><th>完成率</th><th>产量(t)</th><th>平均速度</th><th>停机时长</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="s in shiftSummary" :key="s.shift">
|
||||
<td>{{ s.shift }}班</td>
|
||||
<td class="td-num">{{ s.plan }}</td>
|
||||
<td class="td-num">{{ s.actual }}</td>
|
||||
<td>
|
||||
<div class="prog-bar-wrap" style="width:80px;display:inline-block;vertical-align:middle;margin-right:6px;">
|
||||
<div class="prog-bar-fill" :style="{ width: s.rate + '%', background: s.rate >= 90 ? 'var(--accent-green)' : s.rate >= 70 ? 'var(--accent-yellow)' : 'var(--accent-red)' }"></div>
|
||||
</div>
|
||||
<span :class="s.rate >= 90 ? 'td-ok' : s.rate >= 70 ? 'td-warn' : 'td-err'">{{ s.rate }}%</span>
|
||||
</td>
|
||||
<td class="td-num">{{ s.weight }}</td>
|
||||
<td class="td-num">{{ s.speed }} m/min</td>
|
||||
<td :class="s.downtime > 30 ? 'td-warn' : 'td-ok'">{{ s.downtime }} min</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { getDashboardSummary, getCoils, getDowntimeRecords, getProductionRecords } from '@/api'
|
||||
|
||||
export default {
|
||||
name: 'Dashboard',
|
||||
data() {
|
||||
return {
|
||||
summary: null,
|
||||
onlineCoils: [],
|
||||
downtimeStat: [],
|
||||
shiftSummary: [],
|
||||
chart: null,
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
statCards() {
|
||||
const s = this.summary
|
||||
return [
|
||||
{ label: '今日产量', value: s?.today_production?.coil_count ?? 0, unit: '卷', color: 'var(--sms-highlight)' },
|
||||
{ label: '今日产量', value: s ? (s.today_production.weight_kg / 1000).toFixed(1) : '0', unit: '吨' },
|
||||
{ label: '在线钢卷', value: s?.online_coils ?? 0, unit: '卷', color: 'var(--accent-green)' },
|
||||
{ label: '今日停机', value: s?.today_downtime_min?.toFixed(0) ?? '0', unit: 'min', color: s?.today_downtime_min > 60 ? 'var(--accent-red)' : 'var(--accent-yellow)' },
|
||||
{ label: '机组速度', value: '85.3', unit: 'm/min' },
|
||||
]
|
||||
}
|
||||
},
|
||||
async mounted() {
|
||||
await this.fetchData()
|
||||
},
|
||||
methods: {
|
||||
async fetchData() {
|
||||
try {
|
||||
const [summaryRes, coilRes, downtimeRes, prodRes] = await Promise.all([
|
||||
getDashboardSummary(),
|
||||
getCoils({ status: 'on_line', page_size: 10 }),
|
||||
getDowntimeRecords({ page_size: 50 }),
|
||||
getProductionRecords({ page_size: 100 }),
|
||||
])
|
||||
this.summary = summaryRes.data
|
||||
this.onlineCoils = coilRes.data?.items || []
|
||||
|
||||
// 停机统计(按类别汇总)
|
||||
const dtItems = downtimeRes.data?.items || []
|
||||
const dtMap = {}
|
||||
dtItems.forEach(d => {
|
||||
const k = d.category_name || d.category_code || '其他'
|
||||
if (!dtMap[k]) dtMap[k] = { name: k, count: 0, duration: 0 }
|
||||
dtMap[k].count++
|
||||
dtMap[k].duration += d.duration_min || 0
|
||||
})
|
||||
this.downtimeStat = Object.values(dtMap).sort((a, b) => b.duration - a.duration).slice(0, 6)
|
||||
|
||||
// 班次汇总(按班次聚合产量)
|
||||
const prods = prodRes.data?.items || []
|
||||
const shiftMap = {}
|
||||
prods.forEach(p => {
|
||||
const k = p.shift || '—'
|
||||
if (!shiftMap[k]) shiftMap[k] = { shift: k, count: 0, weight: 0, speeds: [] }
|
||||
shiftMap[k].count++
|
||||
shiftMap[k].weight += (p.coil_weight_t || 0)
|
||||
if (p.avg_speed) shiftMap[k].speeds.push(p.avg_speed)
|
||||
})
|
||||
this.shiftSummary = Object.values(shiftMap).map(s => ({
|
||||
shift: s.shift,
|
||||
actual: s.count,
|
||||
weight: s.weight.toFixed(1),
|
||||
speed: s.speeds.length ? (s.speeds.reduce((a,b)=>a+b,0)/s.speeds.length).toFixed(1) : '—',
|
||||
}))
|
||||
|
||||
// 图表数据:按小时统计产量卷数
|
||||
const hourMap = Array(12).fill(0)
|
||||
prods.forEach(p => {
|
||||
if (p.start_time) {
|
||||
const h = Math.floor(new Date(p.start_time).getHours() / 2)
|
||||
if (h >= 0 && h < 12) hourMap[h]++
|
||||
}
|
||||
})
|
||||
this.$nextTick(() => this.drawChart(hourMap))
|
||||
} catch (e) {
|
||||
this.$nextTick(() => this.drawChart(Array(12).fill(0)))
|
||||
}
|
||||
},
|
||||
formatTime(t) {
|
||||
if (!t) return '—'
|
||||
return t.replace('T', ' ').slice(0, 16)
|
||||
},
|
||||
drawChart(data) {
|
||||
const canvas = this.$refs.trendChart
|
||||
if (!canvas) return
|
||||
const ctx = canvas.getContext('2d')
|
||||
canvas.width = canvas.offsetWidth * devicePixelRatio
|
||||
canvas.height = canvas.offsetHeight * devicePixelRatio
|
||||
ctx.scale(devicePixelRatio, devicePixelRatio)
|
||||
const W = canvas.offsetWidth, H = canvas.offsetHeight
|
||||
data = data || Array(12).fill(0)
|
||||
const labels = Array.from({length:12}, (_,i) => `${i*2}:00`)
|
||||
const max = Math.max(...data) + 2
|
||||
ctx.clearRect(0,0,W,H)
|
||||
|
||||
// 网格线
|
||||
ctx.strokeStyle = '#30363d'
|
||||
ctx.lineWidth = .5
|
||||
for (let i = 0; i <= 4; i++) {
|
||||
const y = H - (i / 4) * (H - 20) - 10
|
||||
ctx.beginPath(); ctx.moveTo(40, y); ctx.lineTo(W - 10, y); ctx.stroke()
|
||||
}
|
||||
|
||||
// 折线
|
||||
const stepX = (W - 50) / (data.length - 1)
|
||||
const toY = v => H - (v / max) * (H - 30) - 10
|
||||
const pts = data.map((v, i) => [40 + i * stepX, toY(v)])
|
||||
|
||||
// 填充
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(pts[0][0], H - 10)
|
||||
pts.forEach(([x, y]) => ctx.lineTo(x, y))
|
||||
ctx.lineTo(pts[pts.length-1][0], H - 10)
|
||||
ctx.closePath()
|
||||
const grad = ctx.createLinearGradient(0, 0, 0, H)
|
||||
grad.addColorStop(0, 'rgba(0,200,255,.25)')
|
||||
grad.addColorStop(1, 'rgba(0,200,255,0)')
|
||||
ctx.fillStyle = grad
|
||||
ctx.fill()
|
||||
|
||||
// 线条
|
||||
ctx.beginPath()
|
||||
ctx.strokeStyle = '#00c8ff'
|
||||
ctx.lineWidth = 1.5
|
||||
pts.forEach(([x,y], i) => i === 0 ? ctx.moveTo(x,y) : ctx.lineTo(x,y))
|
||||
ctx.stroke()
|
||||
|
||||
// 数据点
|
||||
ctx.fillStyle = '#00c8ff'
|
||||
pts.forEach(([x,y]) => { ctx.beginPath(); ctx.arc(x,y,3,0,Math.PI*2); ctx.fill() })
|
||||
|
||||
// X轴标签
|
||||
ctx.fillStyle = '#6e7681'
|
||||
ctx.font = '10px Consolas'
|
||||
ctx.textAlign = 'center'
|
||||
labels.forEach((l, i) => ctx.fillText(l, 40 + i * stepX, H))
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
208
frontend/src/views/Downtime.vue
Normal file
208
frontend/src/views/Downtime.vue
Normal file
@@ -0,0 +1,208 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="card">
|
||||
<div class="card-body" style="padding:10px 14px;">
|
||||
<div class="flex-row" style="flex-wrap:wrap;gap:12px;">
|
||||
<div class="flex-row">
|
||||
<span class="kv-label">停机类别</span>
|
||||
<select v-model="query.category_code" class="kv-input" style="width:130px;">
|
||||
<option value="">全部</option>
|
||||
<option v-for="c in categories" :key="c.code" :value="c.code">{{ c.name }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="flex-row">
|
||||
<span class="kv-label">类型</span>
|
||||
<select v-model="query.is_planned" class="kv-input" style="width:100px;">
|
||||
<option value="">全部</option>
|
||||
<option :value="1">计划停机</option>
|
||||
<option :value="0">非计划</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="flex-row">
|
||||
<span class="kv-label">日期</span>
|
||||
<input v-model="query.start_date" type="date" class="kv-input" style="width:130px;" />
|
||||
<span class="kv-label">~</span>
|
||||
<input v-model="query.end_date" type="date" class="kv-input" style="width:130px;" />
|
||||
</div>
|
||||
<div class="flex-row">
|
||||
<button class="btn btn-primary" @click="fetchData">查询</button>
|
||||
<button class="btn btn-outline" @click="openDialog()">+ 新增停机</button>
|
||||
</div>
|
||||
<div style="margin-left:auto;" class="flex-row">
|
||||
<span class="kv-label">今日停机 <span class="kv-value" style="color:var(--accent-yellow)">{{ totalDuration }} min</span></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
停机记录
|
||||
<span class="ch-badge">共 {{ total }} 条</span>
|
||||
</div>
|
||||
<div class="table-scroll" v-loading="loading">
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>停机类别</th><th>班次</th><th>开始时间</th><th>结束时间</th>
|
||||
<th>时长(min)</th><th>设备</th><th>故障描述</th>
|
||||
<th>类型</th><th>报告人</th><th>处理人</th><th>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="row in tableData" :key="row.id">
|
||||
<td>{{ row.category_name || '—' }}</td>
|
||||
<td>{{ row.shift || '—' }}</td>
|
||||
<td class="td-muted">{{ fmtTime(row.start_time) }}</td>
|
||||
<td class="td-muted">{{ fmtTime(row.end_time) }}</td>
|
||||
<td :class="(row.duration||0) > 30 ? 'td-warn' : 'td-num'">{{ row.duration ? row.duration.toFixed(0) : '进行中' }}</td>
|
||||
<td class="td-muted">{{ row.equipment_code || '—' }}</td>
|
||||
<td style="max-width:180px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">{{ row.fault_desc || '—' }}</td>
|
||||
<td>
|
||||
<span :class="['badge', row.is_planned ? 'badge-blue' : 'badge-yellow']">
|
||||
{{ row.is_planned ? '计划' : '非计划' }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="td-muted">{{ row.reporter || '—' }}</td>
|
||||
<td class="td-muted">{{ row.handler || '—' }}</td>
|
||||
<td><span class="action-link" @click="openDialog(row)">{{ row.end_time ? '编辑' : '结束停机' }}</span></td>
|
||||
</tr>
|
||||
<tr v-if="!tableData.length && !loading">
|
||||
<td colspan="11" class="td-muted" style="text-align:center;padding:24px;">暂无停机记录</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="dialogVisible" class="modal-mask" @click.self="dialogVisible=false">
|
||||
<div class="modal-box" style="width:680px;">
|
||||
<div class="modal-header">
|
||||
{{ editRow ? (editRow.end_time ? '编辑停机' : '结束停机') : '新增停机记录' }}
|
||||
<span class="modal-close" @click="dialogVisible=false">✕</span>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="grid-2" style="gap:12px;">
|
||||
<div class="form-field">
|
||||
<div class="kv-label">停机类别</div>
|
||||
<select v-model="form.category_code" class="kv-input" @change="onCatChange">
|
||||
<option value="">请选择</option>
|
||||
<option v-for="c in categories" :key="c.code" :value="c.code">{{ c.name }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-field">
|
||||
<div class="kv-label">设备编号</div>
|
||||
<input v-model="form.equipment_code" class="kv-input" />
|
||||
</div>
|
||||
<div class="form-field">
|
||||
<div class="kv-label">开始时间 *</div>
|
||||
<input v-model="form.start_time" type="datetime-local" class="kv-input" />
|
||||
</div>
|
||||
<div class="form-field">
|
||||
<div class="kv-label">结束时间</div>
|
||||
<input v-model="form.end_time" type="datetime-local" class="kv-input" />
|
||||
</div>
|
||||
<div class="form-field">
|
||||
<div class="kv-label">班次</div>
|
||||
<select v-model="form.shift" class="kv-input">
|
||||
<option value="">不限</option>
|
||||
<option v-for="s in ['甲','乙','丙','丁']" :key="s" :value="s">{{ s }}班</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-field">
|
||||
<div class="kv-label">类型</div>
|
||||
<div class="flex-row" style="margin-top:4px;">
|
||||
<label style="display:flex;align-items:center;gap:6px;cursor:pointer;color:var(--text-secondary);">
|
||||
<input type="radio" :value="0" v-model="form.is_planned" /> 非计划停机
|
||||
</label>
|
||||
<label style="display:flex;align-items:center;gap:6px;cursor:pointer;color:var(--text-secondary);">
|
||||
<input type="radio" :value="1" v-model="form.is_planned" /> 计划停机
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-field" style="grid-column:1/-1;">
|
||||
<div class="kv-label">故障描述</div>
|
||||
<textarea v-model="form.fault_desc" class="kv-input" rows="2" style="resize:vertical;"></textarea>
|
||||
</div>
|
||||
<div class="form-field" style="grid-column:1/-1;">
|
||||
<div class="kv-label">处理措施</div>
|
||||
<textarea v-model="form.action_taken" class="kv-input" rows="2" style="resize:vertical;"></textarea>
|
||||
</div>
|
||||
<div class="form-field">
|
||||
<div class="kv-label">报告人</div>
|
||||
<input v-model="form.reporter" class="kv-input" />
|
||||
</div>
|
||||
<div class="form-field">
|
||||
<div class="kv-label">处理人</div>
|
||||
<input v-model="form.handler" class="kv-input" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn btn-outline" @click="dialogVisible=false">取消</button>
|
||||
<button class="btn btn-primary" :disabled="saving" @click="save">{{ saving ? '保存中...' : '保存' }}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { getDowntimeCategories, getDowntimeRecords, createDowntime, updateDowntime } from '@/api'
|
||||
|
||||
export default {
|
||||
name: 'Downtime',
|
||||
data() {
|
||||
return {
|
||||
loading: false, saving: false,
|
||||
tableData: [], total: 0, categories: [],
|
||||
query: { page: 1, page_size: 20, category_code: '', is_planned: '', start_date: '', end_date: '' },
|
||||
dialogVisible: false, editRow: null, form: { is_planned: 0 },
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
totalDuration() {
|
||||
return this.tableData.reduce((sum, r) => sum + (r.duration || 0), 0).toFixed(0)
|
||||
}
|
||||
},
|
||||
created() { this.fetchCategories(); this.fetchData() },
|
||||
methods: {
|
||||
async fetchCategories() { const res = await getDowntimeCategories(); this.categories = res.data },
|
||||
async fetchData() {
|
||||
this.loading = true
|
||||
const params = { ...this.query }
|
||||
if (params.start_date) params.start_date += 'T00:00:00'
|
||||
if (params.end_date) params.end_date += 'T23:59:59'
|
||||
if (params.is_planned === '') delete params.is_planned
|
||||
try { const res = await getDowntimeRecords(params); this.tableData = res.data.items; this.total = res.data.total } finally { this.loading = false }
|
||||
},
|
||||
fmtTime(t) { return t ? t.replace('T',' ').slice(0,16) : '—' },
|
||||
onCatChange(e) { const c = this.categories.find(x => x.code === e.target.value); if (c) this.form.category_name = c.name },
|
||||
openDialog(row = null) { this.editRow = row; this.form = row ? { ...row } : { is_planned: 0 }; this.dialogVisible = true },
|
||||
async save() {
|
||||
if (!this.form.start_time) { this.$message.error('开始时间不能为空'); return }
|
||||
this.saving = true
|
||||
const d = { ...this.form }
|
||||
if (d.start_time && !d.start_time.includes('T')) d.start_time += ':00'
|
||||
if (d.end_time && !d.end_time.includes('T')) d.end_time += ':00'
|
||||
try {
|
||||
if (this.editRow) await updateDowntime(this.editRow.id, d)
|
||||
else await createDowntime(d)
|
||||
this.$message.success('保存成功')
|
||||
this.dialogVisible = false; this.fetchData()
|
||||
} finally { this.saving = false }
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@import '@/assets/styles/variables';
|
||||
.action-link { color: $sms-highlight; cursor: pointer; font-size: 12px; margin-right: 12px; &:hover { text-decoration: underline; } }
|
||||
.form-field { display: flex; flex-direction: column; gap: 5px; }
|
||||
.modal-mask { position: fixed; inset: 0; background: rgba(0,0,0,.6); display: flex; align-items: center; justify-content: center; z-index: 9999; }
|
||||
.modal-box { background: $bg-card; border: 1px solid $border; border-radius: 6px; max-width: 95vw; max-height: 90vh; display: flex; flex-direction: column; }
|
||||
.modal-header { display: flex; align-items: center; justify-content: space-between; padding: 12px 16px; background: $bg-panel; border-bottom: 1px solid $border; font-size: 13px; font-weight: 600; color: $sms-highlight; .modal-close { cursor: pointer; color: $text-muted; &:hover { color: $text-primary; } } }
|
||||
.modal-body { padding: 16px; overflow-y: auto; }
|
||||
.modal-footer { padding: 10px 16px; background: $bg-panel; border-top: 1px solid $border; display: flex; justify-content: flex-end; gap: 10px; }
|
||||
</style>
|
||||
265
frontend/src/views/Equipment.vue
Normal file
265
frontend/src/views/Equipment.vue
Normal file
@@ -0,0 +1,265 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="card">
|
||||
<div class="card-body" style="padding:10px 14px;">
|
||||
<div class="flex-row" style="flex-wrap:wrap;gap:12px;">
|
||||
<div class="flex-row">
|
||||
<span class="kv-label">设备名称</span>
|
||||
<input v-model="query.name" class="kv-input" style="width:140px;" @keyup.enter="fetchData" />
|
||||
</div>
|
||||
<div class="flex-row">
|
||||
<span class="kv-label">状态</span>
|
||||
<select v-model="query.status" class="kv-input" style="width:100px;">
|
||||
<option value="">全部</option>
|
||||
<option v-for="s in statusOptions" :key="s.value" :value="s.value">{{ s.label }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="flex-row">
|
||||
<button class="btn btn-primary" @click="fetchData">查询</button>
|
||||
<button class="btn btn-outline" @click="openDialog()">+ 新增设备</button>
|
||||
</div>
|
||||
<!-- 状态汇总 -->
|
||||
<div style="margin-left:auto;" class="flex-row" style="gap:16px;">
|
||||
<span v-for="s in statusSummary" :key="s.label" class="kv-label">
|
||||
{{ s.label }} <span class="kv-value" :style="{color: s.color}">{{ s.count }}</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
设备台账
|
||||
<span class="ch-badge">{{ total }} 台</span>
|
||||
</div>
|
||||
<div class="table-scroll" v-loading="loading">
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>编号</th><th>名称</th><th>类别</th><th>型号</th>
|
||||
<th>位置</th><th>额定功率</th><th>投用日期</th><th>状态</th><th>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="row in tableData" :key="row.id">
|
||||
<td class="td-num">{{ row.code }}</td>
|
||||
<td>{{ row.name }}</td>
|
||||
<td class="td-muted">{{ row.category || '—' }}</td>
|
||||
<td class="td-muted">{{ row.model || '—' }}</td>
|
||||
<td>{{ row.location || '—' }}</td>
|
||||
<td class="td-num">{{ row.rated_power ? row.rated_power + ' kW' : '—' }}</td>
|
||||
<td class="td-muted">{{ row.install_date ? row.install_date.slice(0,10) : '—' }}</td>
|
||||
<td><span :class="['badge', statusBadge(row.status)]">{{ statusLabel(row.status) }}</span></td>
|
||||
<td>
|
||||
<span class="action-link" @click="openDialog(row)">编辑</span>
|
||||
<span class="action-link" @click="viewMaint(row)">维保</span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="!tableData.length && !loading">
|
||||
<td colspan="9" class="td-muted" style="text-align:center;padding:24px;">暂无设备数据</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 设备弹窗 -->
|
||||
<div v-if="dialogVisible" class="modal-mask" @click.self="dialogVisible=false">
|
||||
<div class="modal-box" style="width:640px;">
|
||||
<div class="modal-header">
|
||||
{{ editRow ? '编辑设备' : '新增设备' }}
|
||||
<span class="modal-close" @click="dialogVisible=false">✕</span>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="grid-2" style="gap:12px;">
|
||||
<div class="form-field"><div class="kv-label">设备编号 *</div><input v-model="form.code" class="kv-input" :disabled="!!editRow" /></div>
|
||||
<div class="form-field"><div class="kv-label">设备名称 *</div><input v-model="form.name" class="kv-input" /></div>
|
||||
<div class="form-field"><div class="kv-label">类别</div><input v-model="form.category" class="kv-input" /></div>
|
||||
<div class="form-field"><div class="kv-label">型号规格</div><input v-model="form.model" class="kv-input" /></div>
|
||||
<div class="form-field"><div class="kv-label">安装位置</div><input v-model="form.location" class="kv-input" /></div>
|
||||
<div class="form-field"><div class="kv-label">额定功率 (kW)</div><input v-model.number="form.rated_power" type="number" class="kv-input" /></div>
|
||||
<div class="form-field"><div class="kv-label">制造厂商</div><input v-model="form.manufacturer" class="kv-input" /></div>
|
||||
<div class="form-field"><div class="kv-label">投用日期</div><input v-model="form.install_date_d" type="date" class="kv-input" /></div>
|
||||
<div class="form-field" v-if="editRow">
|
||||
<div class="kv-label">当前状态</div>
|
||||
<select v-model="form.status" class="kv-input">
|
||||
<option v-for="s in statusOptions" :key="s.value" :value="s.value">{{ s.label }}</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn btn-outline" @click="dialogVisible=false">取消</button>
|
||||
<button class="btn btn-primary" :disabled="saving" @click="save">{{ saving ? '...' : '保存' }}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 维保弹窗 -->
|
||||
<div v-if="maintVisible" class="modal-mask" @click.self="maintVisible=false">
|
||||
<div class="modal-box" style="width:860px;">
|
||||
<div class="modal-header">
|
||||
{{ currentEquip && currentEquip.name }} — 维保记录
|
||||
<span class="modal-close" @click="maintVisible=false">✕</span>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<button class="btn btn-outline" style="margin-bottom:10px;" @click="openMaintDialog()">+ 新增维保</button>
|
||||
<table class="data-table">
|
||||
<thead><tr><th>类型</th><th>标题</th><th>开始</th><th>结束</th><th>工时(h)</th><th>费用(元)</th><th>执行人</th><th>结果</th></tr></thead>
|
||||
<tbody>
|
||||
<tr v-for="m in maintData" :key="m.id">
|
||||
<td><span class="badge badge-blue">{{ m.maintenance_type }}</span></td>
|
||||
<td>{{ m.title }}</td>
|
||||
<td class="td-muted">{{ fmtTime(m.start_time) }}</td>
|
||||
<td class="td-muted">{{ fmtTime(m.end_time) }}</td>
|
||||
<td class="td-num">{{ m.duration || '—' }}</td>
|
||||
<td class="td-num">{{ m.cost || '—' }}</td>
|
||||
<td class="td-muted">{{ m.technician || '—' }}</td>
|
||||
<td>
|
||||
<span :class="['badge', m.result==='pass' ? 'badge-green' : m.result==='fail' ? 'badge-red' : 'badge-yellow']">
|
||||
{{ m.result === 'pass' ? '通过' : m.result === 'fail' ? '失败' : '待确认' }}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="!maintData.length"><td colspan="8" class="td-muted" style="text-align:center;padding:20px;">暂无记录</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="modal-footer"><button class="btn btn-outline" @click="maintVisible=false">关闭</button></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 新增维保 -->
|
||||
<div v-if="maintDialogVisible" class="modal-mask" @click.self="maintDialogVisible=false">
|
||||
<div class="modal-box" style="width:600px;">
|
||||
<div class="modal-header">新增维保记录<span class="modal-close" @click="maintDialogVisible=false">✕</span></div>
|
||||
<div class="modal-body">
|
||||
<div class="grid-2" style="gap:12px;">
|
||||
<div class="form-field">
|
||||
<div class="kv-label">维保类型</div>
|
||||
<select v-model="maintForm.maintenance_type" class="kv-input">
|
||||
<option value="repair">故障维修</option>
|
||||
<option value="planned">计划检修</option>
|
||||
<option value="inspection">巡检</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-field">
|
||||
<div class="kv-label">结果</div>
|
||||
<select v-model="maintForm.result" class="kv-input">
|
||||
<option value="pass">通过</option>
|
||||
<option value="fail">失败</option>
|
||||
<option value="pending">待确认</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-field" style="grid-column:1/-1;"><div class="kv-label">标题 *</div><input v-model="maintForm.title" class="kv-input" /></div>
|
||||
<div class="form-field"><div class="kv-label">开始时间</div><input v-model="maintForm.start_time" type="datetime-local" class="kv-input" /></div>
|
||||
<div class="form-field"><div class="kv-label">结束时间</div><input v-model="maintForm.end_time" type="datetime-local" class="kv-input" /></div>
|
||||
<div class="form-field"><div class="kv-label">执行人</div><input v-model="maintForm.technician" class="kv-input" /></div>
|
||||
<div class="form-field"><div class="kv-label">费用(元)</div><input v-model.number="maintForm.cost" type="number" class="kv-input" /></div>
|
||||
<div class="form-field" style="grid-column:1/-1;"><div class="kv-label">维保内容</div><textarea v-model="maintForm.description" class="kv-input" rows="2" style="resize:vertical;"></textarea></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn btn-outline" @click="maintDialogVisible=false">取消</button>
|
||||
<button class="btn btn-primary" :disabled="saving" @click="saveMaint">保存</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { getEquipments, createEquipment, updateEquipment, getEquipmentMaintenance, createMaintenance } from '@/api'
|
||||
|
||||
const STATUS_MAP = {
|
||||
normal: { label: '正常', badge: 'badge-green', color: 'var(--accent-green)' },
|
||||
fault: { label: '故障', badge: 'badge-red', color: 'var(--accent-red)' },
|
||||
maintenance: { label: '检修', badge: 'badge-yellow',color: 'var(--accent-yellow)'},
|
||||
standby: { label: '备用', badge: 'badge-gray', color: 'var(--text-muted)' },
|
||||
}
|
||||
|
||||
export default {
|
||||
name: 'Equipment',
|
||||
data() {
|
||||
return {
|
||||
loading: false, saving: false,
|
||||
tableData: [], total: 0,
|
||||
query: { page: 1, page_size: 20, name: '', status: '' },
|
||||
statusOptions: Object.entries(STATUS_MAP).map(([value, { label }]) => ({ value, label })),
|
||||
dialogVisible: false, editRow: null, form: {},
|
||||
maintVisible: false, currentEquip: null, maintData: [],
|
||||
maintDialogVisible: false, maintForm: {},
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
statusSummary() {
|
||||
return Object.entries(STATUS_MAP).map(([value, { label, color }]) => ({
|
||||
label, color,
|
||||
count: this.tableData.filter(r => r.status === value).length
|
||||
}))
|
||||
}
|
||||
},
|
||||
created() { this.fetchData() },
|
||||
methods: {
|
||||
async fetchData() {
|
||||
this.loading = true
|
||||
try { const res = await getEquipments(this.query); this.tableData = res.data.items; this.total = res.data.total } finally { this.loading = false }
|
||||
},
|
||||
statusLabel(s) { return STATUS_MAP[s]?.label || s },
|
||||
statusBadge(s) { return STATUS_MAP[s]?.badge || 'badge-gray' },
|
||||
fmtTime(t) { return t ? t.replace('T',' ').slice(0,16) : '—' },
|
||||
openDialog(row = null) {
|
||||
this.editRow = row
|
||||
this.form = row ? { ...row, install_date_d: row.install_date?.slice(0,10) } : {}
|
||||
this.dialogVisible = true
|
||||
},
|
||||
async save() {
|
||||
if (!this.form.code || !this.form.name) { this.$message.error('编号和名称不能为空'); return }
|
||||
this.saving = true
|
||||
const d = { ...this.form }
|
||||
if (d.install_date_d) { d.install_date = d.install_date_d + 'T00:00:00'; delete d.install_date_d }
|
||||
try {
|
||||
if (this.editRow) await updateEquipment(this.editRow.id, d)
|
||||
else await createEquipment(d)
|
||||
this.$message.success('保存成功')
|
||||
this.dialogVisible = false; this.fetchData()
|
||||
} finally { this.saving = false }
|
||||
},
|
||||
async viewMaint(row) {
|
||||
this.currentEquip = row
|
||||
const res = await getEquipmentMaintenance(row.id)
|
||||
this.maintData = res.data.items
|
||||
this.maintVisible = true
|
||||
},
|
||||
openMaintDialog() {
|
||||
this.maintForm = { equipment_id: this.currentEquip.id, equipment_code: this.currentEquip.code, maintenance_type: 'repair', result: 'pending' }
|
||||
this.maintDialogVisible = true
|
||||
},
|
||||
async saveMaint() {
|
||||
if (!this.maintForm.title) { this.$message.error('标题不能为空'); return }
|
||||
this.saving = true
|
||||
const d = { ...this.maintForm }
|
||||
if (d.start_time) d.start_time = d.start_time.replace('T', 'T') + ':00'
|
||||
if (d.end_time) d.end_time = d.end_time.replace('T', 'T') + ':00'
|
||||
try {
|
||||
await createMaintenance(d)
|
||||
this.$message.success('保存成功')
|
||||
this.maintDialogVisible = false
|
||||
await this.viewMaint(this.currentEquip)
|
||||
} finally { this.saving = false }
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@import '@/assets/styles/variables';
|
||||
.action-link { color: $sms-highlight; cursor: pointer; font-size: 12px; margin-right: 12px; &:hover { text-decoration: underline; } }
|
||||
.form-field { display: flex; flex-direction: column; gap: 5px; }
|
||||
.modal-mask { position: fixed; inset: 0; background: rgba(0,0,0,.6); display: flex; align-items: center; justify-content: center; z-index: 9999; }
|
||||
.modal-box { background: $bg-card; border: 1px solid $border; border-radius: 6px; max-width: 95vw; max-height: 90vh; display: flex; flex-direction: column; }
|
||||
.modal-header { display: flex; align-items: center; justify-content: space-between; padding: 12px 16px; background: $bg-panel; border-bottom: 1px solid $border; font-size: 13px; font-weight: 600; color: $sms-highlight; .modal-close { cursor: pointer; color: $text-muted; &:hover { color: $text-primary; } } }
|
||||
.modal-body { padding: 16px; overflow-y: auto; }
|
||||
.modal-footer { padding: 10px 16px; background: $bg-panel; border-top: 1px solid $border; display: flex; justify-content: flex-end; gap: 10px; }
|
||||
</style>
|
||||
376
frontend/src/views/Inspection.vue
Normal file
376
frontend/src/views/Inspection.vue
Normal file
@@ -0,0 +1,376 @@
|
||||
<template>
|
||||
<div class="insp-layout">
|
||||
<div class="insp-sidebar">
|
||||
<div class="sidebar-header">
|
||||
巡检点位
|
||||
<span class="add-btn" @click="openLocDialog()">+</span>
|
||||
</div>
|
||||
<div class="loc-list">
|
||||
<div
|
||||
v-for="loc in locations"
|
||||
:key="loc.id"
|
||||
:class="['loc-item', { active: selectedLoc && selectedLoc.id === loc.id }]"
|
||||
@click="selectLocation(loc)"
|
||||
>
|
||||
<div class="loc-code">{{ loc.code }}</div>
|
||||
<div class="loc-name">{{ loc.name }}</div>
|
||||
</div>
|
||||
<div v-if="!locations.length" class="loc-empty">暂无点位</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="insp-main">
|
||||
<div v-if="!selectedLoc" class="empty-tip">请选择点位</div>
|
||||
<template v-else>
|
||||
<div class="card">
|
||||
<div class="card-header" style="display:flex;align-items:center;justify-content:space-between;">
|
||||
<span>{{ selectedLoc.name }} <span class="td-muted" style="font-size:11px;">{{ selectedLoc.code }}</span></span>
|
||||
<button class="btn btn-primary" @click="openRecordDialog(null)">录入巡检</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-header">关联设备</div>
|
||||
<div class="table-scroll">
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>设备编号</th><th>设备名称</th><th>类别</th><th>状态</th><th>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="eq in locationEquipments" :key="eq.id">
|
||||
<td class="td-num">{{ eq.code }}</td>
|
||||
<td>{{ eq.name }}</td>
|
||||
<td>{{ eq.category || '—' }}</td>
|
||||
<td><span :class="['badge', eqStatusBadge(eq.status)]">{{ eqStatusLabel(eq.status) }}</span></td>
|
||||
<td><span class="action-link" @click="openRecordDialog(eq)">录入</span></td>
|
||||
</tr>
|
||||
<tr v-if="!locationEquipments.length">
|
||||
<td colspan="5" class="td-muted" style="text-align:center;padding:16px;">该点位暂无关联设备</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
巡检历史
|
||||
<span class="ch-badge">{{ records.length }} 条</span>
|
||||
</div>
|
||||
<div class="table-scroll" v-loading="loadingRecords">
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>时间</th><th>设备</th><th>二维码</th><th>结果</th><th>巡检人</th><th>备注</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="r in records" :key="r.id">
|
||||
<td class="td-muted">{{ fmtTime(r.created_at) }}</td>
|
||||
<td>{{ r.equipment_name || r.equipment_code || '—' }}</td>
|
||||
<td class="td-num">{{ r.scan_code || '—' }}</td>
|
||||
<td><span :class="['badge', resultBadge(r.result)]">{{ resultLabel(r.result) }}</span></td>
|
||||
<td>{{ r.inspector }}</td>
|
||||
<td>{{ r.notes || '—' }}</td>
|
||||
</tr>
|
||||
<tr v-if="!records.length && !loadingRecords">
|
||||
<td colspan="6" class="td-muted" style="text-align:center;padding:16px;">暂无巡检记录</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div v-if="recordDialogVisible" class="modal-mask" @click.self="recordDialogVisible=false">
|
||||
<div class="modal-box" style="width:460px;">
|
||||
<div class="modal-header">
|
||||
录入巡检
|
||||
<span class="modal-close" @click="recordDialogVisible=false">✕</span>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div style="display:flex;flex-direction:column;gap:12px;">
|
||||
<div class="form-field">
|
||||
<div class="kv-label">点位</div>
|
||||
<input class="kv-input" :value="selectedLoc.name" disabled />
|
||||
</div>
|
||||
<div class="form-field">
|
||||
<div class="kv-label">设备编号</div>
|
||||
<input v-model="recordForm.equipment_code" class="kv-input" />
|
||||
</div>
|
||||
<div class="form-field">
|
||||
<div class="kv-label">设备名称</div>
|
||||
<input v-model="recordForm.equipment_name" class="kv-input" />
|
||||
</div>
|
||||
<div class="form-field">
|
||||
<div class="kv-label">二维码</div>
|
||||
<input v-model="recordForm.scan_code" class="kv-input" ref="scanInput" />
|
||||
</div>
|
||||
<div class="form-field">
|
||||
<div class="kv-label">巡检结果 *</div>
|
||||
<select v-model="recordForm.result" class="kv-input">
|
||||
<option value="normal">正常</option>
|
||||
<option value="abnormal">异常</option>
|
||||
<option value="pending">待处理</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-field">
|
||||
<div class="kv-label">巡检人 *</div>
|
||||
<input v-model="recordForm.inspector" class="kv-input" />
|
||||
</div>
|
||||
<div class="form-field">
|
||||
<div class="kv-label">备注</div>
|
||||
<textarea v-model="recordForm.notes" class="kv-input" rows="3"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn btn-outline" @click="recordDialogVisible=false">取消</button>
|
||||
<button class="btn btn-primary" :disabled="saving" @click="saveRecord">{{ saving ? '保存中...' : '保存' }}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="locDialogVisible" class="modal-mask" @click.self="locDialogVisible=false">
|
||||
<div class="modal-box" style="width:380px;">
|
||||
<div class="modal-header">
|
||||
新增点位
|
||||
<span class="modal-close" @click="locDialogVisible=false">✕</span>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div style="display:flex;flex-direction:column;gap:12px;">
|
||||
<div class="form-field">
|
||||
<div class="kv-label">点位编号 *</div>
|
||||
<input v-model="locForm.code" class="kv-input" />
|
||||
</div>
|
||||
<div class="form-field">
|
||||
<div class="kv-label">点位名称 *</div>
|
||||
<input v-model="locForm.name" class="kv-input" />
|
||||
</div>
|
||||
<div class="form-field">
|
||||
<div class="kv-label">描述</div>
|
||||
<input v-model="locForm.description" class="kv-input" />
|
||||
</div>
|
||||
<div class="form-field">
|
||||
<div class="kv-label">排序</div>
|
||||
<input v-model.number="locForm.sort_order" type="number" class="kv-input" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn btn-outline" @click="locDialogVisible=false">取消</button>
|
||||
<button class="btn btn-primary" :disabled="saving" @click="saveLocation">{{ saving ? '保存中...' : '保存' }}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import {
|
||||
getInspectionLocations, createInspectionLocation,
|
||||
getInspectionRecords, createInspectionRecord,
|
||||
getEquipments,
|
||||
} from '@/api'
|
||||
|
||||
const EQ_STATUS = {
|
||||
normal: { label: '正常', badge: 'badge-green' },
|
||||
fault: { label: '故障', badge: 'badge-red' },
|
||||
maintenance: { label: '检修', badge: 'badge-yellow' },
|
||||
standby: { label: '备用', badge: 'badge-gray' },
|
||||
}
|
||||
|
||||
const RESULT_MAP = {
|
||||
normal: { label: '正常', badge: 'badge-green' },
|
||||
abnormal: { label: '异常', badge: 'badge-red' },
|
||||
pending: { label: '待处理', badge: 'badge-yellow' },
|
||||
}
|
||||
|
||||
export default {
|
||||
name: 'Inspection',
|
||||
data() {
|
||||
return {
|
||||
locations: [],
|
||||
selectedLoc: null,
|
||||
allEquipments: [],
|
||||
records: [],
|
||||
loadingRecords: false,
|
||||
saving: false,
|
||||
recordDialogVisible: false,
|
||||
locDialogVisible: false,
|
||||
recordForm: { result: 'normal', inspector: '', equipment_code: '', equipment_name: '', scan_code: '', notes: '' },
|
||||
locForm: { code: '', name: '', description: '', sort_order: 0 },
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
locationEquipments() {
|
||||
if (!this.selectedLoc) return []
|
||||
return this.allEquipments.filter(e => e.location === this.selectedLoc.name)
|
||||
},
|
||||
},
|
||||
created() {
|
||||
this.fetchLocations()
|
||||
this.fetchAllEquipments()
|
||||
},
|
||||
methods: {
|
||||
async fetchLocations() {
|
||||
const res = await getInspectionLocations()
|
||||
this.locations = res.data || []
|
||||
},
|
||||
async fetchAllEquipments() {
|
||||
const res = await getEquipments({ page: 1, page_size: 200 })
|
||||
this.allEquipments = res.data?.items || []
|
||||
},
|
||||
async selectLocation(loc) {
|
||||
this.selectedLoc = loc
|
||||
await this.fetchRecords()
|
||||
},
|
||||
async fetchRecords() {
|
||||
if (!this.selectedLoc) return
|
||||
this.loadingRecords = true
|
||||
try {
|
||||
const res = await getInspectionRecords({ location_id: this.selectedLoc.id, page_size: 50 })
|
||||
this.records = res.data?.items || []
|
||||
} finally {
|
||||
this.loadingRecords = false
|
||||
}
|
||||
},
|
||||
openRecordDialog(eq) {
|
||||
this.recordForm = {
|
||||
result: 'normal',
|
||||
inspector: '',
|
||||
equipment_code: eq ? eq.code : '',
|
||||
equipment_name: eq ? eq.name : '',
|
||||
scan_code: '',
|
||||
notes: '',
|
||||
}
|
||||
this.recordDialogVisible = true
|
||||
this.$nextTick(() => { if (this.$refs.scanInput) this.$refs.scanInput.focus() })
|
||||
},
|
||||
async saveRecord() {
|
||||
if (!this.recordForm.inspector) { this.$message.error('巡检人不能为空'); return }
|
||||
this.saving = true
|
||||
try {
|
||||
await createInspectionRecord({ ...this.recordForm, location_id: this.selectedLoc.id })
|
||||
this.$message.success('保存成功')
|
||||
this.recordDialogVisible = false
|
||||
await this.fetchRecords()
|
||||
} finally {
|
||||
this.saving = false
|
||||
}
|
||||
},
|
||||
openLocDialog() {
|
||||
this.locForm = { code: '', name: '', description: '', sort_order: 0 }
|
||||
this.locDialogVisible = true
|
||||
},
|
||||
async saveLocation() {
|
||||
if (!this.locForm.code || !this.locForm.name) { this.$message.error('编号和名称不能为空'); return }
|
||||
this.saving = true
|
||||
try {
|
||||
await createInspectionLocation(this.locForm)
|
||||
this.$message.success('保存成功')
|
||||
this.locDialogVisible = false
|
||||
await this.fetchLocations()
|
||||
} finally {
|
||||
this.saving = false
|
||||
}
|
||||
},
|
||||
eqStatusLabel(s) { return EQ_STATUS[s]?.label || s },
|
||||
eqStatusBadge(s) { return EQ_STATUS[s]?.badge || 'badge-gray' },
|
||||
resultLabel(r) { return RESULT_MAP[r]?.label || r },
|
||||
resultBadge(r) { return RESULT_MAP[r]?.badge || 'badge-gray' },
|
||||
fmtTime(t) { return t ? t.slice(0, 16).replace('T', ' ') : '—' },
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@import '@/assets/styles/variables';
|
||||
|
||||
.insp-layout {
|
||||
display: flex;
|
||||
gap: 14px;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.insp-sidebar {
|
||||
width: 220px;
|
||||
flex-shrink: 0;
|
||||
background: $bg-card;
|
||||
border: 1px solid $border;
|
||||
border-radius: 6px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.sidebar-header {
|
||||
padding: 10px 12px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: $sms-highlight;
|
||||
border-bottom: 1px solid $border;
|
||||
background: $bg-panel;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.add-btn {
|
||||
cursor: pointer;
|
||||
color: $sms-highlight;
|
||||
font-size: 16px;
|
||||
line-height: 1;
|
||||
&:hover { opacity: .7; }
|
||||
}
|
||||
|
||||
.loc-list {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: $border transparent;
|
||||
}
|
||||
|
||||
.loc-item {
|
||||
padding: 10px 12px;
|
||||
cursor: pointer;
|
||||
border-bottom: 1px solid rgba($border, .5);
|
||||
transition: background .12s;
|
||||
&:hover { background: rgba(255,255,255,.03); }
|
||||
&.active { background: rgba(0,200,255,.08); border-left: 3px solid $sms-highlight; }
|
||||
}
|
||||
|
||||
.loc-code { font-size: 10px; font-family: $font-mono; color: $text-muted; }
|
||||
.loc-name { font-size: 12px; color: $text-primary; margin-top: 2px; }
|
||||
.loc-empty { padding: 20px; text-align: center; font-size: 12px; color: $text-muted; }
|
||||
|
||||
.insp-main {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
min-width: 0;
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: $border transparent;
|
||||
}
|
||||
|
||||
.empty-tip {
|
||||
padding: 40px;
|
||||
text-align: center;
|
||||
color: $text-muted;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.action-link { color: $sms-highlight; cursor: pointer; font-size: 12px; &:hover { text-decoration: underline; } }
|
||||
.form-field { display: flex; flex-direction: column; gap: 5px; }
|
||||
.modal-mask { position: fixed; inset: 0; background: rgba(0,0,0,.6); display: flex; align-items: center; justify-content: center; z-index: 9999; }
|
||||
.modal-box { background: $bg-card; border: 1px solid $border; border-radius: 6px; max-width: 95vw; max-height: 90vh; display: flex; flex-direction: column; }
|
||||
.modal-header { display: flex; align-items: center; justify-content: space-between; padding: 12px 16px; background: $bg-panel; border-bottom: 1px solid $border; font-size: 13px; font-weight: 600; color: $sms-highlight; .modal-close { cursor: pointer; color: $text-muted; &:hover { color: $text-primary; } } }
|
||||
.modal-body { padding: 16px; overflow-y: auto; }
|
||||
.modal-footer { padding: 10px 16px; background: $bg-panel; border-top: 1px solid $border; display: flex; justify-content: flex-end; gap: 10px; }
|
||||
</style>
|
||||
246
frontend/src/views/Layout.vue
Normal file
246
frontend/src/views/Layout.vue
Normal file
@@ -0,0 +1,246 @@
|
||||
<template>
|
||||
<div class="layout">
|
||||
|
||||
<!-- ─── TOP BAR ─── -->
|
||||
<div class="top-bar">
|
||||
<div class="logo">SMS <span>L2</span></div>
|
||||
<div class="sys-title">推拉酸洗线 L2 过程控制系统 | PUSH-PULL PICKLING LINE</div>
|
||||
<div class="spacer"></div>
|
||||
<div class="status-pills">
|
||||
<span class="pill run">● 机组运行</span>
|
||||
<span class="pill run">● L2在线</span>
|
||||
<span :class="['pill', l3Status]">{{ l3StatusText }}</span>
|
||||
</div>
|
||||
<div class="top-user">
|
||||
<span class="username">{{ user && (user.full_name || user.username) }}</span>
|
||||
<span class="divider">|</span>
|
||||
<span class="logout" @click="logout">退出</span>
|
||||
</div>
|
||||
<div class="clock">{{ clock }}</div>
|
||||
</div>
|
||||
|
||||
<!-- ─── NAV BAR ─── -->
|
||||
<div class="nav-bar">
|
||||
<div
|
||||
v-for="item in menuItems"
|
||||
:key="item.path"
|
||||
:class="['nav-item', { active: $route.path === item.path }]"
|
||||
@click="$router.push(item.path)"
|
||||
>
|
||||
<span class="nav-icon">{{ item.icon }}</span>
|
||||
{{ item.title }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ─── MAIN ─── -->
|
||||
<div class="main-area">
|
||||
<router-view />
|
||||
</div>
|
||||
|
||||
<!-- ─── FOOTER ─── -->
|
||||
<div class="footer">
|
||||
<div class="fp"><span class="dot g"></span>数据库 正常</div>
|
||||
<div class="fp"><span class="dot g"></span>UDP 监听 :9000</div>
|
||||
<div class="fp"><span :class="['dot', l3Status === 'run' ? 'g' : 'y']"></span>L3 {{ l3StatusText }}</div>
|
||||
<div style="margin-left:auto;color:var(--text-muted);">推拉酸洗线 L2 MES v1.0.0</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { mapGetters } from 'vuex'
|
||||
|
||||
const MENU = [
|
||||
{ path: '/dashboard', title: '生产看板', icon: 'DB' },
|
||||
{ path: '/material', title: '物料跟踪', icon: 'MT' },
|
||||
{ path: '/production', title: '实绩管理', icon: 'PR' },
|
||||
{ path: '/plan', title: '计划管理', icon: 'PL' },
|
||||
{ path: '/downtime', title: '停机管理', icon: 'DT' },
|
||||
{ path: '/equipment', title: '设备管理', icon: 'EQ' },
|
||||
{ path: '/inspection', title: '设备巡检', icon: 'INS' },
|
||||
{ path: '/message', title: '报文监控', icon: 'MSG' },
|
||||
{ path: '/process-model', title: '工艺段模型', icon: 'PM' },
|
||||
{ path: '/tension-model', title: '张力设定', icon: 'TM' },
|
||||
{ path: '/quality', title: '质量管理', icon: 'QC' },
|
||||
{ path: '/capacity', title: '产能分析', icon: 'CAP' },
|
||||
]
|
||||
|
||||
export default {
|
||||
name: 'Layout',
|
||||
data() {
|
||||
return {
|
||||
clock: '--:--:--',
|
||||
l3Status: 'warn',
|
||||
l3StatusText: 'L3待机',
|
||||
menuItems: MENU,
|
||||
_timer: null,
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
...mapGetters({ user: 'auth/user' }),
|
||||
},
|
||||
mounted() {
|
||||
this._timer = setInterval(() => {
|
||||
const now = new Date()
|
||||
this.clock = now.toTimeString().slice(0, 8)
|
||||
}, 1000)
|
||||
this.clock = new Date().toTimeString().slice(0, 8)
|
||||
},
|
||||
beforeDestroy() {
|
||||
clearInterval(this._timer)
|
||||
},
|
||||
methods: {
|
||||
logout() {
|
||||
this.$confirm('确认退出登录?', '提示', { type: 'warning' }).then(() => {
|
||||
this.$store.dispatch('auth/logout')
|
||||
this.$router.push('/login')
|
||||
}).catch(() => {})
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@import '@/assets/styles/variables';
|
||||
|
||||
.layout {
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
// ─── TOP BAR ───
|
||||
.top-bar {
|
||||
height: 48px;
|
||||
background: linear-gradient(90deg, #0d1b2e 0%, #0a1628 100%);
|
||||
border-bottom: 1px solid $border;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 16px;
|
||||
gap: 16px;
|
||||
flex-shrink: 0;
|
||||
|
||||
.logo {
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
color: $sms-highlight;
|
||||
letter-spacing: 1px;
|
||||
white-space: nowrap;
|
||||
span { color: $accent-yellow; }
|
||||
}
|
||||
|
||||
.sys-title {
|
||||
font-size: 12px;
|
||||
color: $text-secondary;
|
||||
border-left: 1px solid $border;
|
||||
padding-left: 16px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.spacer { flex: 1; }
|
||||
|
||||
.status-pills {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.top-user {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 12px;
|
||||
color: $text-secondary;
|
||||
.divider { color: $border; }
|
||||
.logout { cursor: pointer; color: $text-muted; &:hover { color: $sms-highlight; } }
|
||||
}
|
||||
|
||||
.clock {
|
||||
font-family: $font-mono;
|
||||
font-size: 13px;
|
||||
color: $sms-highlight;
|
||||
min-width: 72px;
|
||||
text-align: right;
|
||||
}
|
||||
}
|
||||
|
||||
.pill {
|
||||
padding: 2px 10px;
|
||||
border-radius: 10px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
letter-spacing: .5px;
|
||||
|
||||
&.run { background: #1a3a1f; color: $status-run; border: 1px solid $status-run; }
|
||||
&.warn { background: #3a2a00; color: $status-warn; border: 1px solid $status-warn; }
|
||||
&.stop { background: #222; color: $text-muted; border: 1px solid $border; }
|
||||
}
|
||||
|
||||
// ─── NAV BAR ───
|
||||
.nav-bar {
|
||||
height: 38px;
|
||||
background: $bg-secondary;
|
||||
border-bottom: 1px solid $border;
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
overflow-x: auto;
|
||||
flex-shrink: 0;
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: $border transparent;
|
||||
&::-webkit-scrollbar { height: 3px; }
|
||||
&::-webkit-scrollbar-thumb { background: $border; }
|
||||
}
|
||||
|
||||
.nav-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
padding: 0 16px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: $text-secondary;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
border-bottom: 2px solid transparent;
|
||||
transition: all .15s;
|
||||
user-select: none;
|
||||
|
||||
&:hover { color: $text-primary; background: rgba(255,255,255,.03); }
|
||||
&.active { color: $sms-highlight; border-bottom-color: $sms-highlight; background: rgba(0,200,255,.05); }
|
||||
|
||||
.nav-icon { font-size: 10px; font-family: $font-mono; color: $text-muted; letter-spacing: .5px; }
|
||||
}
|
||||
|
||||
// ─── MAIN AREA ───
|
||||
.main-area {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 14px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: $border transparent;
|
||||
&::-webkit-scrollbar { width: 6px; }
|
||||
&::-webkit-scrollbar-thumb { background: $border; border-radius: 3px; }
|
||||
}
|
||||
|
||||
// ─── FOOTER ───
|
||||
.footer {
|
||||
height: 26px;
|
||||
background: $bg-secondary;
|
||||
border-top: 1px solid $border;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 14px;
|
||||
gap: 16px;
|
||||
font-size: 11px;
|
||||
color: $text-muted;
|
||||
flex-shrink: 0;
|
||||
|
||||
.fp { display: flex; align-items: center; gap: 5px; }
|
||||
.dot { width: 6px; height: 6px; border-radius: 50%; &.g { background: $accent-green; } &.y { background: $accent-yellow; } &.r { background: $accent-red; } }
|
||||
}
|
||||
</style>
|
||||
158
frontend/src/views/Login.vue
Normal file
158
frontend/src/views/Login.vue
Normal file
@@ -0,0 +1,158 @@
|
||||
<template>
|
||||
<div class="login-page">
|
||||
<div class="login-wrap">
|
||||
<div class="login-header">
|
||||
<div class="login-logo">SMS <span>L2</span></div>
|
||||
<div class="login-title">推拉酸洗线 L2 过程控制系统</div>
|
||||
<div class="login-sub">PUSH-PULL PICKLING LINE MES</div>
|
||||
</div>
|
||||
|
||||
<div class="login-form">
|
||||
<div class="form-item">
|
||||
<div class="form-label">用户名</div>
|
||||
<input
|
||||
v-model="form.username"
|
||||
class="kv-input"
|
||||
placeholder="请输入用户名"
|
||||
@keyup.enter="handleLogin"
|
||||
/>
|
||||
</div>
|
||||
<div class="form-item">
|
||||
<div class="form-label">密码</div>
|
||||
<input
|
||||
v-model="form.password"
|
||||
type="password"
|
||||
class="kv-input"
|
||||
placeholder="请输入密码"
|
||||
@keyup.enter="handleLogin"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
:class="['btn btn-primary', { loading }]"
|
||||
style="width:100%;margin-top:20px;padding:9px;"
|
||||
@click="handleLogin"
|
||||
>
|
||||
{{ loading ? '登录中...' : '登 录' }}
|
||||
</button>
|
||||
<div v-if="error" class="login-error">{{ error }}</div>
|
||||
</div>
|
||||
|
||||
<div class="login-footer">
|
||||
<span class="dot g"></span>系统就绪 | v1.0.0
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: 'Login',
|
||||
data() {
|
||||
return {
|
||||
loading: false,
|
||||
error: '',
|
||||
form: { username: '', password: '' }
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
async handleLogin() {
|
||||
if (!this.form.username || !this.form.password) {
|
||||
this.error = '请输入用户名和密码'
|
||||
return
|
||||
}
|
||||
this.loading = true
|
||||
this.error = ''
|
||||
try {
|
||||
await this.$store.dispatch('auth/login', this.form)
|
||||
this.$router.push('/')
|
||||
} catch {
|
||||
this.error = '用户名或密码错误'
|
||||
} finally {
|
||||
this.loading = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@import '@/assets/styles/variables';
|
||||
|
||||
.login-page {
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: $bg-primary;
|
||||
background-image:
|
||||
radial-gradient(ellipse at 20% 50%, rgba(0,120,212,.08) 0%, transparent 60%),
|
||||
radial-gradient(ellipse at 80% 30%, rgba(0,200,255,.05) 0%, transparent 60%);
|
||||
}
|
||||
|
||||
.login-wrap {
|
||||
width: 380px;
|
||||
background: $bg-card;
|
||||
border: 1px solid $border;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.login-header {
|
||||
padding: 32px 32px 24px;
|
||||
background: $bg-panel;
|
||||
border-bottom: 1px solid $border;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.login-logo {
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
color: $sms-highlight;
|
||||
letter-spacing: 2px;
|
||||
margin-bottom: 8px;
|
||||
span { color: $accent-yellow; }
|
||||
}
|
||||
|
||||
.login-title {
|
||||
font-size: 14px;
|
||||
color: $text-primary;
|
||||
font-weight: 600;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.login-sub {
|
||||
font-size: 11px;
|
||||
color: $text-muted;
|
||||
letter-spacing: .5px;
|
||||
}
|
||||
|
||||
.login-form {
|
||||
padding: 24px 32px;
|
||||
}
|
||||
|
||||
.form-item {
|
||||
margin-bottom: 14px;
|
||||
.form-label { color: $text-secondary; font-size: 12px; margin-bottom: 6px; }
|
||||
.kv-input { padding: 8px 10px; }
|
||||
}
|
||||
|
||||
.login-error {
|
||||
margin-top: 10px;
|
||||
font-size: 12px;
|
||||
color: $accent-red;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.login-footer {
|
||||
padding: 10px 32px;
|
||||
background: $bg-panel;
|
||||
border-top: 1px solid $border;
|
||||
font-size: 11px;
|
||||
color: $text-muted;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
|
||||
.dot { width: 6px; height: 6px; border-radius: 50%; &.g { background: $accent-green; } }
|
||||
}
|
||||
</style>
|
||||
271
frontend/src/views/Material.vue
Normal file
271
frontend/src/views/Material.vue
Normal file
@@ -0,0 +1,271 @@
|
||||
<template>
|
||||
<div>
|
||||
<!-- 搜索栏 -->
|
||||
<div class="card">
|
||||
<div class="card-body" style="padding:10px 14px;">
|
||||
<div class="flex-row" style="flex-wrap:wrap;gap:12px;">
|
||||
<div class="flex-row">
|
||||
<span class="kv-label">卷号</span>
|
||||
<input v-model="query.coil_no" class="kv-input" style="width:150px;" placeholder="搜索卷号..." @keyup.enter="fetchData" />
|
||||
</div>
|
||||
<div class="flex-row">
|
||||
<span class="kv-label">状态</span>
|
||||
<select v-model="query.status" class="kv-input" style="width:110px;">
|
||||
<option value="">全部</option>
|
||||
<option v-for="s in statusOptions" :key="s.value" :value="s.value">{{ s.label }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="flex-row">
|
||||
<button class="btn btn-primary" @click="fetchData">查询</button>
|
||||
<button class="btn btn-outline" @click="openDialog()">+ 新增钢卷</button>
|
||||
</div>
|
||||
<div style="margin-left:auto;" class="flex-row">
|
||||
<span class="kv-label">共 <span class="kv-value">{{ total }}</span> 条</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 数据表 -->
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
📦 钢卷台账
|
||||
<span class="ch-badge">{{ tableData.length }} / {{ total }}</span>
|
||||
</div>
|
||||
<div class="table-scroll" v-loading="loading">
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>卷号</th><th>钢种</th><th>厚度(mm)</th><th>宽度(mm)</th>
|
||||
<th>毛重(kg)</th><th>净重(kg)</th><th>状态</th><th>创建时间</th><th>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="row in tableData" :key="row.coil_no">
|
||||
<td class="td-num">{{ row.coil_no }}</td>
|
||||
<td>{{ row.steel_grade || '—' }}</td>
|
||||
<td class="td-num">{{ row.spec_thickness || '—' }}</td>
|
||||
<td class="td-num">{{ row.spec_width || '—' }}</td>
|
||||
<td class="td-num">{{ row.gross_weight || '—' }}</td>
|
||||
<td class="td-num">{{ row.net_weight || '—' }}</td>
|
||||
<td>
|
||||
<span :class="['badge', statusBadge(row.status)]">{{ statusLabel(row.status) }}</span>
|
||||
</td>
|
||||
<td class="td-muted">{{ fmtTime(row.created_at) }}</td>
|
||||
<td>
|
||||
<span class="action-link" @click="viewTracking(row)">跟踪</span>
|
||||
<span class="action-link" @click="openDialog(row)">编辑</span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="!tableData.length && !loading">
|
||||
<td colspan="9" class="td-muted" style="text-align:center;padding:24px;">暂无数据</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<!-- 分页 -->
|
||||
<div class="card-body" style="padding:8px 14px;" v-if="total > query.page_size">
|
||||
<div class="flex-row">
|
||||
<button class="btn btn-outline" :disabled="query.page <= 1" @click="query.page--; fetchData()">上一页</button>
|
||||
<span class="kv-label">第 {{ query.page }} 页 / 共 {{ Math.ceil(total/query.page_size) }} 页</span>
|
||||
<button class="btn btn-outline" :disabled="query.page >= Math.ceil(total/query.page_size)" @click="query.page++; fetchData()">下一页</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 新增/编辑 Modal -->
|
||||
<div v-if="dialogVisible" class="modal-mask" @click.self="dialogVisible=false">
|
||||
<div class="modal-box">
|
||||
<div class="modal-header">
|
||||
{{ editRow ? '编辑钢卷' : '新增钢卷' }}
|
||||
<span class="modal-close" @click="dialogVisible=false">✕</span>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="grid-2" style="gap:12px;">
|
||||
<div class="form-field">
|
||||
<div class="kv-label">卷号 <span style="color:var(--accent-red)">*</span></div>
|
||||
<input v-model="form.coil_no" class="kv-input" :disabled="!!editRow" />
|
||||
</div>
|
||||
<div class="form-field">
|
||||
<div class="kv-label">钢种</div>
|
||||
<input v-model="form.steel_grade" class="kv-input" />
|
||||
</div>
|
||||
<div class="form-field">
|
||||
<div class="kv-label">规格厚度 (mm)</div>
|
||||
<input v-model.number="form.spec_thickness" type="number" class="kv-input" />
|
||||
</div>
|
||||
<div class="form-field">
|
||||
<div class="kv-label">规格宽度 (mm)</div>
|
||||
<input v-model.number="form.spec_width" type="number" class="kv-input" />
|
||||
</div>
|
||||
<div class="form-field">
|
||||
<div class="kv-label">毛重 (kg)</div>
|
||||
<input v-model.number="form.gross_weight" type="number" class="kv-input" />
|
||||
</div>
|
||||
<div class="form-field">
|
||||
<div class="kv-label">净重 (kg)</div>
|
||||
<input v-model.number="form.net_weight" type="number" class="kv-input" />
|
||||
</div>
|
||||
<div class="form-field">
|
||||
<div class="kv-label">内径 (mm)</div>
|
||||
<input v-model.number="form.inner_diameter" type="number" class="kv-input" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn btn-outline" @click="dialogVisible=false">取消</button>
|
||||
<button class="btn btn-primary" :disabled="saving" @click="saveCoil">{{ saving ? '保存中...' : '保存' }}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 跟踪详情 Modal -->
|
||||
<div v-if="trackingVisible" class="modal-mask" @click.self="trackingVisible=false">
|
||||
<div class="modal-box" style="width:860px;max-width:95vw;">
|
||||
<div class="modal-header">
|
||||
物料跟踪记录 — <span style="color:var(--sms-highlight)">{{ trackingCoil }}</span>
|
||||
<span class="modal-close" @click="trackingVisible=false">✕</span>
|
||||
</div>
|
||||
<div class="modal-body" style="max-height:400px;overflow-y:auto;">
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr><th>时间</th><th>位置</th><th>事件类型</th><th>描述</th><th>实测厚度</th><th>速度</th><th>操作员</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="t in trackingData" :key="t.id">
|
||||
<td class="td-muted">{{ fmtTime(t.event_time) }}</td>
|
||||
<td>{{ t.position || '—' }}</td>
|
||||
<td><span class="badge badge-blue">{{ t.event_type }}</span></td>
|
||||
<td>{{ t.event_desc || '—' }}</td>
|
||||
<td class="td-num">{{ t.actual_thickness || '—' }}</td>
|
||||
<td class="td-num">{{ t.speed || '—' }}</td>
|
||||
<td class="td-muted">{{ t.operator || '—' }}</td>
|
||||
</tr>
|
||||
<tr v-if="!trackingData.length">
|
||||
<td colspan="7" class="td-muted" style="text-align:center;padding:20px;">暂无跟踪记录</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn btn-outline" @click="trackingVisible=false">关闭</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { getCoils, createCoil, updateCoil, getTracking } from '@/api'
|
||||
|
||||
const STATUS_MAP = {
|
||||
waiting: { label: '等待入线', badge: 'badge-gray' },
|
||||
on_line: { label: '在线处理', badge: 'badge-green' },
|
||||
finished: { label: '处理完成', badge: 'badge-blue' },
|
||||
abnormal: { label: '异常', badge: 'badge-red' },
|
||||
}
|
||||
|
||||
export default {
|
||||
name: 'Material',
|
||||
data() {
|
||||
return {
|
||||
loading: false, saving: false,
|
||||
tableData: [], total: 0,
|
||||
query: { page: 1, page_size: 20, coil_no: '', status: '' },
|
||||
statusOptions: Object.entries(STATUS_MAP).map(([value, { label }]) => ({ value, label })),
|
||||
dialogVisible: false, editRow: null, form: {},
|
||||
trackingVisible: false, trackingCoil: '', trackingData: [],
|
||||
}
|
||||
},
|
||||
created() { this.fetchData() },
|
||||
methods: {
|
||||
async fetchData() {
|
||||
this.loading = true
|
||||
try {
|
||||
const res = await getCoils(this.query)
|
||||
this.tableData = res.data.items
|
||||
this.total = res.data.total
|
||||
} finally { this.loading = false }
|
||||
},
|
||||
statusLabel(s) { return STATUS_MAP[s]?.label || s },
|
||||
statusBadge(s) { return STATUS_MAP[s]?.badge || 'badge-gray' },
|
||||
fmtTime(t) { return t ? t.replace('T', ' ').slice(0, 16) : '—' },
|
||||
openDialog(row = null) {
|
||||
this.editRow = row
|
||||
this.form = row ? { ...row } : {}
|
||||
this.dialogVisible = true
|
||||
},
|
||||
async saveCoil() {
|
||||
if (!this.form.coil_no) { this.$message.error('卷号不能为空'); return }
|
||||
this.saving = true
|
||||
try {
|
||||
if (this.editRow) await updateCoil(this.form.coil_no, this.form)
|
||||
else await createCoil(this.form)
|
||||
this.$message.success('保存成功')
|
||||
this.dialogVisible = false
|
||||
this.fetchData()
|
||||
} finally { this.saving = false }
|
||||
},
|
||||
async viewTracking(row) {
|
||||
this.trackingCoil = row.coil_no
|
||||
const res = await getTracking({ coil_no: row.coil_no, page_size: 100 })
|
||||
this.trackingData = res.data.items
|
||||
this.trackingVisible = true
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@import '@/assets/styles/variables';
|
||||
|
||||
.action-link {
|
||||
color: $sms-highlight;
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
margin-right: 12px;
|
||||
font-family: $font-main;
|
||||
&:hover { text-decoration: underline; }
|
||||
}
|
||||
|
||||
.form-field { display: flex; flex-direction: column; gap: 5px; }
|
||||
|
||||
// Modal
|
||||
.modal-mask {
|
||||
position: fixed; inset: 0;
|
||||
background: rgba(0,0,0,.6);
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
z-index: 9999;
|
||||
}
|
||||
.modal-box {
|
||||
background: $bg-card;
|
||||
border: 1px solid $border;
|
||||
border-radius: 6px;
|
||||
width: 640px;
|
||||
max-width: 95vw;
|
||||
max-height: 90vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.modal-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 12px 16px;
|
||||
background: $bg-panel;
|
||||
border-bottom: 1px solid $border;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: $sms-highlight;
|
||||
.modal-close { cursor: pointer; color: $text-muted; &:hover { color: $text-primary; } }
|
||||
}
|
||||
.modal-body { padding: 16px; overflow-y: auto; }
|
||||
.modal-footer {
|
||||
padding: 10px 16px;
|
||||
background: $bg-panel;
|
||||
border-top: 1px solid $border;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 10px;
|
||||
}
|
||||
</style>
|
||||
167
frontend/src/views/Message.vue
Normal file
167
frontend/src/views/Message.vue
Normal file
@@ -0,0 +1,167 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="card">
|
||||
<div class="card-body" style="padding:10px 14px;">
|
||||
<div class="flex-row" style="flex-wrap:wrap;gap:12px;">
|
||||
<div class="flex-row">
|
||||
<span class="kv-label">报文类型</span>
|
||||
<input v-model="query.msg_type" class="kv-input" style="width:90px;" placeholder="PC01..." />
|
||||
</div>
|
||||
<div class="flex-row">
|
||||
<span class="kv-label">方向</span>
|
||||
<select v-model="query.direction" class="kv-input" style="width:90px;">
|
||||
<option value="">全部</option>
|
||||
<option value="recv">接收</option>
|
||||
<option value="send">发送</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="flex-row">
|
||||
<span class="kv-label">状态</span>
|
||||
<select v-model="query.status" class="kv-input" style="width:90px;">
|
||||
<option value="">全部</option>
|
||||
<option value="success">成功</option>
|
||||
<option value="error">失败</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="flex-row">
|
||||
<button class="btn btn-primary" @click="fetchData">查询</button>
|
||||
<button class="btn btn-outline" @click="fetchData">↺ 刷新</button>
|
||||
</div>
|
||||
<div style="margin-left:auto;" class="flex-row">
|
||||
<span class="kv-label">
|
||||
成功 <span class="kv-value" style="color:var(--accent-green)">{{ successCount }}</span>
|
||||
失败 <span class="kv-value" style="color:var(--accent-red)">{{ errorCount }}</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
报文监控日志
|
||||
<span class="ch-badge" :style="{ color: connected ? 'var(--accent-green)' : 'var(--accent-red)' }">
|
||||
{{ connected ? '● UDP在线' : '○ 未连接' }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="table-scroll" v-loading="loading">
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>报文ID</th><th>类型</th><th>方向</th><th>来源</th>
|
||||
<th>状态</th><th>耗时(ms)</th><th>错误信息</th><th>接收时间</th><th>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="row in tableData" :key="row.id">
|
||||
<td class="td-num">{{ row.msg_id }}</td>
|
||||
<td><span class="badge badge-blue">{{ row.msg_type }}</span></td>
|
||||
<td>
|
||||
<span :class="['badge', row.direction==='recv' ? 'badge-green' : 'badge-blue']">
|
||||
{{ row.direction === 'recv' ? '↓ 接收' : '↑ 发送' }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="td-muted">{{ row.source }}</td>
|
||||
<td>
|
||||
<span :class="['badge', row.status==='success' ? 'badge-green' : 'badge-red']">
|
||||
{{ row.status === 'success' ? '成功' : '失败' }}
|
||||
</span>
|
||||
</td>
|
||||
<td :class="row.process_time > 100 ? 'td-warn' : 'td-num'">{{ row.process_time || '—' }}</td>
|
||||
<td class="td-err">{{ row.error_msg || '—' }}</td>
|
||||
<td class="td-muted">{{ fmtTime(row.received_at) }}</td>
|
||||
<td><span class="action-link" @click="viewDetail(row)">详情</span></td>
|
||||
</tr>
|
||||
<tr v-if="!tableData.length && !loading">
|
||||
<td colspan="9" class="td-muted" style="text-align:center;padding:24px;">暂无报文记录</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 详情 Modal -->
|
||||
<div v-if="detailVisible" class="modal-mask" @click.self="detailVisible=false">
|
||||
<div class="modal-box" style="width:760px;">
|
||||
<div class="modal-header">
|
||||
报文详情 — <span style="color:var(--sms-highlight)">{{ currentLog && currentLog.msg_type }}</span>
|
||||
<span class="modal-close" @click="detailVisible=false">✕</span>
|
||||
</div>
|
||||
<div class="modal-body" v-if="currentLog">
|
||||
<div class="kv-grid" style="margin-bottom:14px;">
|
||||
<span class="kv-label">报文类型</span><span class="kv-value">{{ currentLog.msg_type }}</span>
|
||||
<span class="kv-label">接收时间</span><span class="kv-value">{{ fmtTime(currentLog.received_at) }}</span>
|
||||
<span class="kv-label">状态</span>
|
||||
<span><span :class="['badge', currentLog.status==='success' ? 'badge-green' : 'badge-red']">{{ currentLog.status }}</span></span>
|
||||
</div>
|
||||
<div class="sec-title">原始报文(HEX)</div>
|
||||
<pre class="raw-box">{{ currentLog.raw_data || '—' }}</pre>
|
||||
<div class="sec-title" style="margin-top:12px;">解析结果(JSON)</div>
|
||||
<pre class="raw-box">{{ formatJson(currentLog.parsed_data) }}</pre>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn btn-outline" @click="detailVisible=false">关闭</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { getMessageLogs, getMessageLog } from '@/api'
|
||||
|
||||
export default {
|
||||
name: 'Message',
|
||||
data() {
|
||||
return {
|
||||
loading: false,
|
||||
tableData: [], total: 0,
|
||||
query: { page: 1, page_size: 50, msg_type: '', direction: '', status: '' },
|
||||
connected: true,
|
||||
detailVisible: false, currentLog: null,
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
successCount() { return this.tableData.filter(r => r.status === 'success').length },
|
||||
errorCount() { return this.tableData.filter(r => r.status === 'error').length },
|
||||
},
|
||||
created() { this.fetchData() },
|
||||
methods: {
|
||||
async fetchData() {
|
||||
this.loading = true
|
||||
try {
|
||||
const res = await getMessageLogs(this.query)
|
||||
this.tableData = res.data.items
|
||||
this.total = res.data.total
|
||||
} finally { this.loading = false }
|
||||
},
|
||||
fmtTime(t) { return t ? t.replace('T',' ').slice(0,19) : '—' },
|
||||
async viewDetail(row) {
|
||||
const res = await getMessageLog(row.id)
|
||||
this.currentLog = res.data
|
||||
this.detailVisible = true
|
||||
},
|
||||
formatJson(s) {
|
||||
if (!s) return '—'
|
||||
try { return JSON.stringify(JSON.parse(s), null, 2) } catch { return s }
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@import '@/assets/styles/variables';
|
||||
.action-link { color: $sms-highlight; cursor: pointer; font-size: 12px; &:hover { text-decoration: underline; } }
|
||||
.raw-box {
|
||||
background: #0a0f18; color: #d4d4d4;
|
||||
padding: 12px; border-radius: 4px; border: 1px solid $border;
|
||||
font-size: 11px; font-family: $font-mono;
|
||||
max-height: 180px; overflow-y: auto;
|
||||
white-space: pre-wrap; word-break: break-all;
|
||||
}
|
||||
.modal-mask { position: fixed; inset: 0; background: rgba(0,0,0,.6); display: flex; align-items: center; justify-content: center; z-index: 9999; }
|
||||
.modal-box { background: $bg-card; border: 1px solid $border; border-radius: 6px; max-width: 95vw; max-height: 90vh; display: flex; flex-direction: column; }
|
||||
.modal-header { display: flex; align-items: center; justify-content: space-between; padding: 12px 16px; background: $bg-panel; border-bottom: 1px solid $border; font-size: 13px; font-weight: 600; color: $sms-highlight; .modal-close { cursor: pointer; color: $text-muted; &:hover { color: $text-primary; } } }
|
||||
.modal-body { padding: 16px; overflow-y: auto; }
|
||||
.modal-footer { padding: 10px 16px; background: $bg-panel; border-top: 1px solid $border; display: flex; justify-content: flex-end; gap: 10px; }
|
||||
</style>
|
||||
360
frontend/src/views/PDI.vue
Normal file
360
frontend/src/views/PDI.vue
Normal file
@@ -0,0 +1,360 @@
|
||||
<template>
|
||||
<div>
|
||||
<!-- ─── 过滤栏 ─── -->
|
||||
<div class="card">
|
||||
<div class="card-body" style="padding:10px 14px;">
|
||||
<div class="flex-row" style="flex-wrap:wrap;gap:12px;">
|
||||
<div class="flex-row">
|
||||
<span class="kv-label">卷号</span>
|
||||
<input v-model="query.coil_no" class="kv-input" style="width:140px;" @keyup.enter="fetchData" />
|
||||
</div>
|
||||
<div class="flex-row">
|
||||
<span class="kv-label">L3状态</span>
|
||||
<select v-model="query.l3_status" class="kv-input" style="width:110px;">
|
||||
<option value="">全部</option>
|
||||
<option value="pending">待下发</option>
|
||||
<option value="sent">已发送</option>
|
||||
<option value="confirmed">已确认</option>
|
||||
<option value="cancelled">已取消</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="flex-row">
|
||||
<span class="kv-label">L2状态</span>
|
||||
<select v-model="query.l2_status" class="kv-input" style="width:110px;">
|
||||
<option value="">全部</option>
|
||||
<option value="pending">待处理</option>
|
||||
<option value="processing">处理中</option>
|
||||
<option value="done">已完成</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="flex-row">
|
||||
<button class="btn btn-primary" @click="fetchData">查询</button>
|
||||
<button class="btn btn-outline" @click="openDialog()">+ 新增PDI</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ─── 统计卡片 ─── -->
|
||||
<div class="grid-4">
|
||||
<div class="metric-box">
|
||||
<div class="mb-label">PDI总数</div>
|
||||
<div class="mb-value">{{ stats.total }}</div>
|
||||
<div class="mb-unit">条记录</div>
|
||||
</div>
|
||||
<div class="metric-box">
|
||||
<div class="mb-label">待处理</div>
|
||||
<div class="mb-value" style="color:#f0a500;">{{ stats.pending }}</div>
|
||||
<div class="mb-unit">L2待确认</div>
|
||||
</div>
|
||||
<div class="metric-box">
|
||||
<div class="mb-label">已确认</div>
|
||||
<div class="mb-value" style="color:#28a745;">{{ stats.confirmed }}</div>
|
||||
<div class="mb-unit">L3已确认</div>
|
||||
</div>
|
||||
<div class="metric-box">
|
||||
<div class="mb-label">处理中</div>
|
||||
<div class="mb-value" style="color:#00c8ff;">{{ stats.processing }}</div>
|
||||
<div class="mb-unit">酸洗进行中</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ─── 数据表 ─── -->
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
PDI管理
|
||||
<span class="ch-badge">共 {{ total }} 条</span>
|
||||
</div>
|
||||
<div class="table-scroll" v-loading="loading">
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>卷号</th>
|
||||
<th>订单号</th>
|
||||
<th>客户</th>
|
||||
<th>钢种</th>
|
||||
<th>厚度(mm)</th>
|
||||
<th>宽度(mm)</th>
|
||||
<th>卷重(kg)</th>
|
||||
<th>屈服强度</th>
|
||||
<th>工艺路径</th>
|
||||
<th>优先级</th>
|
||||
<th>L3状态</th>
|
||||
<th>L2状态</th>
|
||||
<th>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="row in tableData" :key="row.id">
|
||||
<td class="td-num">{{ row.coil_no }}</td>
|
||||
<td class="td-muted">{{ row.order_no || '—' }}</td>
|
||||
<td>{{ row.customer || '—' }}</td>
|
||||
<td class="td-num">{{ row.steel_grade || '—' }}</td>
|
||||
<td class="td-num">{{ row.thickness || '—' }}</td>
|
||||
<td class="td-num">{{ row.width || '—' }}</td>
|
||||
<td class="td-num">{{ row.coil_weight || '—' }}</td>
|
||||
<td class="td-num">{{ row.yield_strength ? row.yield_strength + ' MPa' : '—' }}</td>
|
||||
<td class="td-muted" style="font-size:11px;">{{ row.process_route || '—' }}</td>
|
||||
<td>
|
||||
<span :class="['badge', priorityBadge(row.priority)]">P{{ row.priority || 3 }}</span>
|
||||
</td>
|
||||
<td>
|
||||
<span :class="['badge', l3Badge(row.l3_status)]">{{ l3Text(row.l3_status) }}</span>
|
||||
</td>
|
||||
<td>
|
||||
<span :class="['badge', l2Badge(row.l2_status)]">{{ l2Text(row.l2_status) }}</span>
|
||||
</td>
|
||||
<td>
|
||||
<span class="action-link" @click="openDialog(row)">编辑</span>
|
||||
<span
|
||||
v-if="row.l2_status === 'pending'"
|
||||
class="action-link"
|
||||
style="color:#28a745;"
|
||||
@click="confirmPdi(row)"
|
||||
>L2确认</span>
|
||||
<span class="action-link" style="color:#8b949e;" @click="simulateSend(row)">发送L3</span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="!tableData.length && !loading">
|
||||
<td colspan="13" class="td-muted" style="text-align:center;padding:24px;">暂无数据</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="card-body" style="padding:8px 14px;" v-if="total > query.page_size">
|
||||
<div class="flex-row">
|
||||
<button class="btn btn-outline" :disabled="query.page<=1" @click="query.page--;fetchData()">上一页</button>
|
||||
<span class="kv-label">第 {{ query.page }} / {{ Math.ceil(total/query.page_size) }} 页</span>
|
||||
<button class="btn btn-outline" :disabled="query.page>=Math.ceil(total/query.page_size)" @click="query.page++;fetchData()">下一页</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ─── 编辑弹窗 ─── -->
|
||||
<div v-if="dialogVisible" class="modal-mask" @click.self="dialogVisible=false">
|
||||
<div class="modal-box" style="width:760px;">
|
||||
<div class="modal-header">
|
||||
{{ editRow ? '编辑PDI #' + editRow.id : '新增PDI' }}
|
||||
<span class="modal-close" @click="dialogVisible=false">✕</span>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="sec-title">基本信息</div>
|
||||
<div class="grid-3" style="gap:12px;margin-bottom:14px;">
|
||||
<div class="form-field">
|
||||
<div class="kv-label">卷号 *</div>
|
||||
<input v-model="form.coil_no" class="kv-input" />
|
||||
</div>
|
||||
<div class="form-field">
|
||||
<div class="kv-label">订单号</div>
|
||||
<input v-model="form.order_no" class="kv-input" />
|
||||
</div>
|
||||
<div class="form-field">
|
||||
<div class="kv-label">客户名称</div>
|
||||
<input v-model="form.customer" class="kv-input" />
|
||||
</div>
|
||||
<div class="form-field">
|
||||
<div class="kv-label">钢种</div>
|
||||
<select v-model="form.steel_grade" class="kv-input">
|
||||
<option v-for="g in steelGrades" :key="g" :value="g">{{ g }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-field">
|
||||
<div class="kv-label">优先级 (1-5)</div>
|
||||
<input v-model.number="form.priority" type="number" class="kv-input" min="1" max="5" />
|
||||
</div>
|
||||
<div class="form-field">
|
||||
<div class="kv-label">工艺路径</div>
|
||||
<input v-model="form.process_route" class="kv-input" placeholder="P1+P2+...+P6" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="sec-title">规格尺寸</div>
|
||||
<div class="grid-3" style="gap:12px;margin-bottom:14px;">
|
||||
<div class="form-field">
|
||||
<div class="kv-label">来料厚度 (mm)</div>
|
||||
<input v-model.number="form.thickness" type="number" class="kv-input" step="0.1" />
|
||||
</div>
|
||||
<div class="form-field">
|
||||
<div class="kv-label">来料宽度 (mm)</div>
|
||||
<input v-model.number="form.width" type="number" class="kv-input" />
|
||||
</div>
|
||||
<div class="form-field">
|
||||
<div class="kv-label">目标厚度 (mm)</div>
|
||||
<input v-model.number="form.target_thickness" type="number" class="kv-input" step="0.1" />
|
||||
</div>
|
||||
<div class="form-field">
|
||||
<div class="kv-label">目标宽度 (mm)</div>
|
||||
<input v-model.number="form.target_width" type="number" class="kv-input" />
|
||||
</div>
|
||||
<div class="form-field">
|
||||
<div class="kv-label">卷重 (kg)</div>
|
||||
<input v-model.number="form.coil_weight" type="number" class="kv-input" />
|
||||
</div>
|
||||
<div class="form-field">
|
||||
<div class="kv-label">内径 (mm)</div>
|
||||
<input v-model.number="form.inner_diameter" type="number" class="kv-input" />
|
||||
</div>
|
||||
<div class="form-field">
|
||||
<div class="kv-label">外径 (mm)</div>
|
||||
<input v-model.number="form.outer_diameter" type="number" class="kv-input" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="sec-title">力学性能</div>
|
||||
<div class="grid-3" style="gap:12px;margin-bottom:14px;">
|
||||
<div class="form-field">
|
||||
<div class="kv-label">屈服强度 (MPa)</div>
|
||||
<input v-model.number="form.yield_strength" type="number" class="kv-input" />
|
||||
</div>
|
||||
<div class="form-field">
|
||||
<div class="kv-label">抗拉强度 (MPa)</div>
|
||||
<input v-model.number="form.tensile_strength" type="number" class="kv-input" />
|
||||
</div>
|
||||
<div class="form-field">
|
||||
<div class="kv-label">延伸率 (%)</div>
|
||||
<input v-model.number="form.elongation" type="number" class="kv-input" step="0.1" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="sec-title">状态 / 备注</div>
|
||||
<div class="grid-3" style="gap:12px;margin-bottom:14px;">
|
||||
<div class="form-field">
|
||||
<div class="kv-label">L3状态</div>
|
||||
<select v-model="form.l3_status" class="kv-input">
|
||||
<option value="pending">待下发</option>
|
||||
<option value="sent">已发送</option>
|
||||
<option value="confirmed">已确认</option>
|
||||
<option value="cancelled">已取消</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-field">
|
||||
<div class="kv-label">L2状态</div>
|
||||
<select v-model="form.l2_status" class="kv-input">
|
||||
<option value="pending">待处理</option>
|
||||
<option value="processing">处理中</option>
|
||||
<option value="done">已完成</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-field">
|
||||
<div class="kv-label">备注</div>
|
||||
<input v-model="form.remark" class="kv-input" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn btn-outline" @click="dialogVisible=false">取消</button>
|
||||
<button class="btn btn-primary" :disabled="saving" @click="save">
|
||||
{{ saving ? '保存中...' : '保存' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { getPdiList, createPdi, updatePdi, confirmPdi as apiConfirmPdi, getPdiStats } from '@/api'
|
||||
|
||||
const STEEL_GRADES = ['Q195','Q215','Q235','SPHC','SPHD','SPHE','SS400','SAPH440','QSTE420TM']
|
||||
|
||||
export default {
|
||||
name: 'PDI',
|
||||
data() {
|
||||
return {
|
||||
loading: false, saving: false,
|
||||
tableData: [], total: 0,
|
||||
stats: { total: 0, pending: 0, confirmed: 0, processing: 0 },
|
||||
query: { page: 1, page_size: 20, coil_no: '', l3_status: '', l2_status: '' },
|
||||
dialogVisible: false, editRow: null, form: {},
|
||||
steelGrades: STEEL_GRADES,
|
||||
}
|
||||
},
|
||||
created() {
|
||||
this.fetchData()
|
||||
this.fetchStats()
|
||||
},
|
||||
methods: {
|
||||
async fetchData() {
|
||||
this.loading = true
|
||||
const params = { ...this.query }
|
||||
if (!params.coil_no) delete params.coil_no
|
||||
if (!params.l3_status) delete params.l3_status
|
||||
if (!params.l2_status) delete params.l2_status
|
||||
try {
|
||||
const res = await getPdiList(params)
|
||||
this.tableData = res.data.items
|
||||
this.total = res.data.total
|
||||
} finally { this.loading = false }
|
||||
},
|
||||
async fetchStats() {
|
||||
try {
|
||||
const res = await getPdiStats()
|
||||
this.stats = res.data
|
||||
} catch (e) { /* ignore */ }
|
||||
},
|
||||
l3Badge(s) {
|
||||
const m = { pending: 'badge-gray', sent: 'badge-blue', confirmed: 'badge-green', cancelled: 'badge-red' }
|
||||
return m[s] || 'badge-gray'
|
||||
},
|
||||
l3Text(s) {
|
||||
const m = { pending: '待下发', sent: '已发送', confirmed: '已确认', cancelled: '已取消' }
|
||||
return m[s] || s
|
||||
},
|
||||
l2Badge(s) {
|
||||
const m = { pending: 'badge-yellow', processing: 'badge-blue', done: 'badge-green' }
|
||||
return m[s] || 'badge-gray'
|
||||
},
|
||||
l2Text(s) {
|
||||
const m = { pending: '待处理', processing: '处理中', done: '已完成' }
|
||||
return m[s] || s
|
||||
},
|
||||
priorityBadge(p) {
|
||||
if (p <= 1) return 'badge-red'
|
||||
if (p <= 2) return 'badge-yellow'
|
||||
return 'badge-blue'
|
||||
},
|
||||
openDialog(row = null) {
|
||||
this.editRow = row
|
||||
this.form = row ? { ...row } : { priority: 3, l3_status: 'pending', l2_status: 'pending', process_route: 'P1+P2+P3+P4+P5+P6' }
|
||||
this.dialogVisible = true
|
||||
},
|
||||
async save() {
|
||||
if (!this.form.coil_no) { this.$message.error('卷号不能为空'); return }
|
||||
this.saving = true
|
||||
try {
|
||||
if (this.editRow) await updatePdi(this.editRow.id, this.form)
|
||||
else await createPdi(this.form)
|
||||
this.$message.success('保存成功')
|
||||
this.dialogVisible = false
|
||||
this.fetchData()
|
||||
this.fetchStats()
|
||||
} finally { this.saving = false }
|
||||
},
|
||||
async confirmPdi(row) {
|
||||
try {
|
||||
await this.$confirm(`确认L2接收卷号 ${row.coil_no} 的PDI?`, '提示', { type: 'warning' })
|
||||
await apiConfirmPdi(row.id)
|
||||
this.$message.success('L2确认成功')
|
||||
this.fetchData()
|
||||
this.fetchStats()
|
||||
} catch (e) {
|
||||
if (e !== 'cancel') this.$message.error('操作失败:' + (e.response?.data?.detail || e.message))
|
||||
}
|
||||
},
|
||||
simulateSend(row) {
|
||||
this.$message.info(`已模拟发送卷号 ${row.coil_no} PDI至L3`)
|
||||
},
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@import '@/assets/styles/variables';
|
||||
.action-link { color: $sms-highlight; cursor: pointer; font-size: 12px; margin-right: 10px; &:hover { text-decoration: underline; } }
|
||||
.form-field { display: flex; flex-direction: column; gap: 5px; }
|
||||
.modal-mask { position: fixed; inset: 0; background: rgba(0,0,0,.6); display: flex; align-items: center; justify-content: center; z-index: 9999; }
|
||||
.modal-box { background: $bg-card; border: 1px solid $border; border-radius: 6px; width: 760px; max-width: 96vw; max-height: 92vh; display: flex; flex-direction: column; }
|
||||
.modal-header { display: flex; align-items: center; justify-content: space-between; padding: 12px 16px; background: $bg-panel; border-bottom: 1px solid $border; font-size: 13px; font-weight: 600; color: $sms-highlight; .modal-close { cursor: pointer; color: $text-muted; &:hover { color: $text-primary; } } }
|
||||
.modal-body { padding: 16px; overflow-y: auto; }
|
||||
.modal-footer { padding: 10px 16px; background: $bg-panel; border-top: 1px solid $border; display: flex; justify-content: flex-end; gap: 10px; }
|
||||
</style>
|
||||
203
frontend/src/views/Plan.vue
Normal file
203
frontend/src/views/Plan.vue
Normal file
@@ -0,0 +1,203 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="card">
|
||||
<div class="card-body" style="padding:10px 14px;">
|
||||
<div class="flex-row" style="flex-wrap:wrap;gap:12px;">
|
||||
<div class="flex-row">
|
||||
<span class="kv-label">状态</span>
|
||||
<select v-model="query.status" class="kv-input" style="width:110px;">
|
||||
<option value="">全部</option>
|
||||
<option v-for="s in statusOptions" :key="s.value" :value="s.value">{{ s.label }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="flex-row">
|
||||
<span class="kv-label">日期</span>
|
||||
<input v-model="query.start_date" type="date" class="kv-input" style="width:130px;" />
|
||||
<span class="kv-label">~</span>
|
||||
<input v-model="query.end_date" type="date" class="kv-input" style="width:130px;" />
|
||||
</div>
|
||||
<div class="flex-row">
|
||||
<button class="btn btn-primary" @click="fetchData">查询</button>
|
||||
<button class="btn btn-outline" @click="openDialog()">+ 新增计划</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
生产计划
|
||||
<span class="ch-badge">共 {{ total }} 条</span>
|
||||
</div>
|
||||
<div class="table-scroll" v-loading="loading">
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>计划号</th><th>计划日期</th><th>班次</th>
|
||||
<th>计划(卷)</th><th>计划重量(kg)</th>
|
||||
<th>实际(卷)</th><th>实际重量(kg)</th>
|
||||
<th>完成率</th><th>优先级</th><th>状态</th><th>创建人</th><th>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="row in tableData" :key="row.id">
|
||||
<td class="td-num">{{ row.plan_no }}</td>
|
||||
<td class="td-muted">{{ fmtDate(row.plan_date) }}</td>
|
||||
<td>{{ row.shift ? row.shift + '班' : '—' }}</td>
|
||||
<td class="td-num">{{ row.plan_quantity }}</td>
|
||||
<td class="td-num">{{ row.plan_weight }}</td>
|
||||
<td class="td-num">{{ row.actual_quantity }}</td>
|
||||
<td class="td-num">{{ row.actual_weight }}</td>
|
||||
<td>
|
||||
<div v-if="row.plan_quantity > 0">
|
||||
<div class="prog-bar-wrap" style="width:70px;display:inline-block;vertical-align:middle;margin-right:6px;">
|
||||
<div class="prog-bar-fill" :style="{ width: completionRate(row) + '%', background: rateColor(row) }"></div>
|
||||
</div>
|
||||
<span :style="{ color: rateColor(row) }">{{ completionRate(row) }}%</span>
|
||||
</div>
|
||||
<span v-else class="td-muted">—</span>
|
||||
</td>
|
||||
<td>
|
||||
<span :class="['badge', row.priority >= 8 ? 'badge-red' : row.priority >= 5 ? 'badge-yellow' : 'badge-gray']">P{{ row.priority }}</span>
|
||||
</td>
|
||||
<td><span :class="['badge', statusBadge(row.status)]">{{ statusLabel(row.status) }}</span></td>
|
||||
<td class="td-muted">{{ row.created_by || '—' }}</td>
|
||||
<td>
|
||||
<span class="action-link" @click="openDialog(row)">编辑</span>
|
||||
<span v-if="row.status === 'draft'" class="action-link" style="color:var(--accent-green)" @click="confirmPlan(row)">确认</span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="!tableData.length && !loading">
|
||||
<td colspan="12" class="td-muted" style="text-align:center;padding:24px;">暂无数据</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="dialogVisible" class="modal-mask" @click.self="dialogVisible=false">
|
||||
<div class="modal-box" style="width:640px;">
|
||||
<div class="modal-header">
|
||||
{{ editRow ? '编辑计划' : '新增计划' }}
|
||||
<span class="modal-close" @click="dialogVisible=false">✕</span>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="grid-2" style="gap:12px;">
|
||||
<div class="form-field">
|
||||
<div class="kv-label">计划号 *</div>
|
||||
<input v-model="form.plan_no" class="kv-input" :disabled="!!editRow" />
|
||||
</div>
|
||||
<div class="form-field">
|
||||
<div class="kv-label">计划日期</div>
|
||||
<input v-model="form.plan_date" type="date" class="kv-input" />
|
||||
</div>
|
||||
<div class="form-field">
|
||||
<div class="kv-label">班次</div>
|
||||
<select v-model="form.shift" class="kv-input">
|
||||
<option value="">不限</option>
|
||||
<option v-for="s in ['甲','乙','丙','丁']" :key="s" :value="s">{{ s }}班</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-field">
|
||||
<div class="kv-label">优先级 (1-10)</div>
|
||||
<input v-model.number="form.priority" type="number" min="1" max="10" class="kv-input" />
|
||||
</div>
|
||||
<div class="form-field">
|
||||
<div class="kv-label">计划数量 (卷)</div>
|
||||
<input v-model.number="form.plan_quantity" type="number" class="kv-input" />
|
||||
</div>
|
||||
<div class="form-field">
|
||||
<div class="kv-label">计划重量 (kg)</div>
|
||||
<input v-model.number="form.plan_weight" type="number" class="kv-input" />
|
||||
</div>
|
||||
<div class="form-field">
|
||||
<div class="kv-label">主要钢种</div>
|
||||
<input v-model="form.steel_grade" class="kv-input" />
|
||||
</div>
|
||||
<div class="form-field">
|
||||
<div class="kv-label">规格范围</div>
|
||||
<input v-model="form.spec_range" class="kv-input" placeholder="如: 1.5-3.0mm" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn btn-outline" @click="dialogVisible=false">取消</button>
|
||||
<button class="btn btn-primary" :disabled="saving" @click="save">{{ saving ? '保存中...' : '保存' }}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { getPlans, createPlan, updatePlan, confirmPlan as apiConfirm } from '@/api'
|
||||
|
||||
const STATUS_MAP = {
|
||||
draft: { label: '草稿', badge: 'badge-gray' },
|
||||
confirmed: { label: '已确认', badge: 'badge-blue' },
|
||||
in_progress: { label: '执行中', badge: 'badge-green' },
|
||||
completed: { label: '完成', badge: 'badge-gray' },
|
||||
cancelled: { label: '取消', badge: 'badge-red' },
|
||||
}
|
||||
|
||||
export default {
|
||||
name: 'Plan',
|
||||
data() {
|
||||
return {
|
||||
loading: false, saving: false,
|
||||
tableData: [], total: 0,
|
||||
query: { page: 1, page_size: 20, status: '', start_date: '', end_date: '' },
|
||||
statusOptions: Object.entries(STATUS_MAP).map(([value, { label }]) => ({ value, label })),
|
||||
dialogVisible: false, editRow: null, form: { priority: 5 },
|
||||
}
|
||||
},
|
||||
created() { this.fetchData() },
|
||||
methods: {
|
||||
async fetchData() {
|
||||
this.loading = true
|
||||
const params = { ...this.query }
|
||||
if (params.start_date) params.start_date += 'T00:00:00'
|
||||
if (params.end_date) params.end_date += 'T23:59:59'
|
||||
try { const res = await getPlans(params); this.tableData = res.data.items; this.total = res.data.total } finally { this.loading = false }
|
||||
},
|
||||
fmtDate(t) { return t ? t.slice(0, 10) : '—' },
|
||||
statusLabel(s) { return STATUS_MAP[s]?.label || s },
|
||||
statusBadge(s) { return STATUS_MAP[s]?.badge || 'badge-gray' },
|
||||
completionRate(row) { return row.plan_quantity > 0 ? Math.min(100, Math.round(row.actual_quantity / row.plan_quantity * 100)) : 0 },
|
||||
rateColor(row) {
|
||||
const r = this.completionRate(row)
|
||||
return r >= 90 ? 'var(--accent-green)' : r >= 70 ? 'var(--accent-yellow)' : 'var(--accent-red)'
|
||||
},
|
||||
openDialog(row = null) { this.editRow = row; this.form = row ? { ...row } : { priority: 5, plan_quantity: 0, plan_weight: 0 }; this.dialogVisible = true },
|
||||
async confirmPlan(row) {
|
||||
if (!confirm(`确认计划 ${row.plan_no}?`)) return
|
||||
await apiConfirm(row.id)
|
||||
this.$message.success('已确认')
|
||||
this.fetchData()
|
||||
},
|
||||
async save() {
|
||||
if (!this.form.plan_no) { this.$message.error('计划号不能为空'); return }
|
||||
this.saving = true
|
||||
try {
|
||||
const d = { ...this.form }
|
||||
if (d.plan_date && !d.plan_date.includes('T')) d.plan_date += 'T00:00:00'
|
||||
if (this.editRow) await updatePlan(this.editRow.id, d)
|
||||
else await createPlan(d)
|
||||
this.$message.success('保存成功')
|
||||
this.dialogVisible = false; this.fetchData()
|
||||
} finally { this.saving = false }
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@import '@/assets/styles/variables';
|
||||
.action-link { color: $sms-highlight; cursor: pointer; font-size: 12px; margin-right: 12px; &:hover { text-decoration: underline; } }
|
||||
.form-field { display: flex; flex-direction: column; gap: 5px; }
|
||||
.modal-mask { position: fixed; inset: 0; background: rgba(0,0,0,.6); display: flex; align-items: center; justify-content: center; z-index: 9999; }
|
||||
.modal-box { background: $bg-card; border: 1px solid $border; border-radius: 6px; max-width: 95vw; max-height: 90vh; display: flex; flex-direction: column; }
|
||||
.modal-header { display: flex; align-items: center; justify-content: space-between; padding: 12px 16px; background: $bg-panel; border-bottom: 1px solid $border; font-size: 13px; font-weight: 600; color: $sms-highlight; .modal-close { cursor: pointer; color: $text-muted; &:hover { color: $text-primary; } } }
|
||||
.modal-body { padding: 16px; overflow-y: auto; }
|
||||
.modal-footer { padding: 10px 16px; background: $bg-panel; border-top: 1px solid $border; display: flex; justify-content: flex-end; gap: 10px; }
|
||||
</style>
|
||||
546
frontend/src/views/ProcessModel.vue
Normal file
546
frontend/src/views/ProcessModel.vue
Normal file
@@ -0,0 +1,546 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="flex-between" style="margin-bottom:4px;">
|
||||
<div class="sec-title" style="margin-bottom:0;">🧪 酸洗工艺段模型</div>
|
||||
<div class="flex-row">
|
||||
<span class="kv-label">刷新时间:{{ lastRefresh }}</span>
|
||||
<span :class="['badge', l1Online ? 'badge-green' : 'badge-yellow']" style="margin-left:8px;">
|
||||
{{ l1Online ? 'L1在线' : '无L1数据' }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ─── 酸槽 1-3 ─── -->
|
||||
<div class="sec-title">酸槽 1#–3#</div>
|
||||
<div class="grid-3">
|
||||
<div v-for="i in [0,1,2]" :key="i" class="card">
|
||||
<div class="card-header">
|
||||
{{ i+1 }}# 酸槽
|
||||
<span :class="['badge', tankBadge(tanks[i])]" style="margin-left:auto;">{{ tankStatus(tanks[i]) }}</span>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="kv-grid">
|
||||
<span class="kv-label">HCl 浓度</span>
|
||||
<span class="kv-value">{{ tanks[i].conc != null ? tanks[i].conc : '—' }} <span class="kv-unit">g/L</span></span>
|
||||
<span class="kv-label">温度</span>
|
||||
<span class="kv-value">{{ tanks[i].temp != null ? tanks[i].temp : '—' }} <span class="kv-unit">°C</span></span>
|
||||
<span class="kv-label">Fe²⁺ 含量</span>
|
||||
<span class="kv-value">{{ tanks[i].fe2 != null ? tanks[i].fe2 : '—' }} <span class="kv-unit">g/L</span></span>
|
||||
<span class="kv-label">停留时间</span>
|
||||
<span class="kv-value">{{ tanks[i].rt != null ? tanks[i].rt : '—' }} <span class="kv-unit">s</span></span>
|
||||
</div>
|
||||
<div class="mt8" v-if="tanks[i].eff != null">
|
||||
<div class="flex-between" style="margin-bottom:4px;">
|
||||
<span class="kv-label" style="font-size:11px;">酸洗效率(模型估算)</span>
|
||||
<span style="font-size:11px;color:#00c8ff;">{{ tanks[i].eff }}%</span>
|
||||
</div>
|
||||
<div class="prog-bar-wrap">
|
||||
<div class="prog-bar-fill" :style="{ width: tanks[i].eff + '%', background: effColor(tanks[i].eff) }"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="kv-label" style="margin-top:8px;text-align:center;">等待 L1 数据</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ─── 酸槽 4-6 ─── -->
|
||||
<div class="sec-title mt8">酸槽 4#–6#</div>
|
||||
<div class="grid-3">
|
||||
<div v-for="i in [3,4,5]" :key="i" class="card">
|
||||
<div class="card-header">
|
||||
{{ i+1 }}# 酸槽
|
||||
<span :class="['badge', tankBadge(tanks[i])]" style="margin-left:auto;">{{ tankStatus(tanks[i]) }}</span>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="kv-grid">
|
||||
<span class="kv-label">HCl 浓度</span>
|
||||
<span class="kv-value">{{ tanks[i].conc != null ? tanks[i].conc : '—' }} <span class="kv-unit">g/L</span></span>
|
||||
<span class="kv-label">温度</span>
|
||||
<span class="kv-value">{{ tanks[i].temp != null ? tanks[i].temp : '—' }} <span class="kv-unit">°C</span></span>
|
||||
<span class="kv-label">Fe²⁺ 含量</span>
|
||||
<span class="kv-value">{{ tanks[i].fe2 != null ? tanks[i].fe2 : '—' }} <span class="kv-unit">g/L</span></span>
|
||||
<span class="kv-label">停留时间</span>
|
||||
<span class="kv-value">{{ tanks[i].rt != null ? tanks[i].rt : '—' }} <span class="kv-unit">s</span></span>
|
||||
</div>
|
||||
<div class="mt8" v-if="tanks[i].eff != null">
|
||||
<div class="flex-between" style="margin-bottom:4px;">
|
||||
<span class="kv-label" style="font-size:11px;">酸洗效率(模型估算)</span>
|
||||
<span style="font-size:11px;color:#00c8ff;">{{ tanks[i].eff }}%</span>
|
||||
</div>
|
||||
<div class="prog-bar-wrap">
|
||||
<div class="prog-bar-fill" :style="{ width: tanks[i].eff + '%', background: effColor(tanks[i].eff) }"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="kv-label" style="margin-top:8px;text-align:center;">等待 L1 数据</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ─── 漂洗段 ─── -->
|
||||
<div class="sec-title mt8">漂洗段(5级逆流)</div>
|
||||
<div class="card">
|
||||
<div class="card-header">漂洗段状态</div>
|
||||
<div class="card-body">
|
||||
<div v-if="l1Online" class="table-scroll">
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr><th>漂洗级</th><th>pH值</th><th>温度 (°C)</th><th>流量 (m³/h)</th><th>电导率 (μS/cm)</th><th>状态</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="(r, idx) in rinse" :key="idx">
|
||||
<td class="td-num">{{ idx+1 }}# 漂洗</td>
|
||||
<td>{{ r.ph != null ? r.ph : '—' }}</td>
|
||||
<td class="td-num">{{ r.temp != null ? r.temp : '—' }}</td>
|
||||
<td class="td-num">{{ r.flow != null ? r.flow : '—' }}</td>
|
||||
<td class="td-num">{{ r.conductivity != null ? r.conductivity : '—' }}</td>
|
||||
<td><span class="badge badge-green">正常</span></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div v-else style="text-align:center;padding:20px;color:var(--text-muted);">
|
||||
漂洗段数据来自 L1 实时报文,当前无 L1 连接
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ─── 模型计算面板 ─── -->
|
||||
<div class="section-row mt8">
|
||||
<div class="card" style="flex:1;">
|
||||
<div class="card-header">⚙️ 最优速度计算(实时自动)</div>
|
||||
<div class="card-body">
|
||||
<div class="grid-2" style="gap:12px;margin-bottom:14px;">
|
||||
<div class="form-field">
|
||||
<div class="kv-label">带钢厚度 (mm)</div>
|
||||
<input v-model.number="calc.thickness" type="number" class="kv-input" step="0.1" min="2.0" max="4.5" @change="doCalc" />
|
||||
</div>
|
||||
<div class="form-field">
|
||||
<div class="kv-label">带钢宽度 (mm)</div>
|
||||
<input v-model.number="calc.width" type="number" class="kv-input" step="10" min="800" max="1250" @change="doCalc" />
|
||||
</div>
|
||||
<div class="form-field">
|
||||
<div class="kv-label">钢种</div>
|
||||
<select v-model="calc.steel_grade" class="kv-input" @change="doCalc">
|
||||
<option v-for="g in steelGrades" :key="g" :value="g">{{ g }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-field">
|
||||
<div class="kv-label">目标酸洗指数 (%)</div>
|
||||
<input v-model.number="calc.target_pi" type="number" class="kv-input" step="1" min="70" max="100" @change="doCalc" />
|
||||
</div>
|
||||
<div class="form-field">
|
||||
<div class="kv-label">氧化铁皮重量 (g/m²)</div>
|
||||
<input v-model.number="calc.scale_weight" type="number" class="kv-input" step="0.5" @change="doCalc" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="sec-title">各槽酸液参数
|
||||
<span class="kv-label" style="font-size:10px;margin-left:8px;">
|
||||
{{ l1Online ? '已从 L1 报文同步' : '手动输入(L1 上线后自动同步)' }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="grid-3" style="gap:8px;margin-bottom:8px;">
|
||||
<div v-for="i in 6" :key="i" class="form-field">
|
||||
<div class="kv-label">{{ i }}# 槽浓度 (g/L)</div>
|
||||
<input v-model.number="calc.acid_conc_list[i-1]" type="number" class="kv-input" step="5" @change="doCalc" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid-3" style="gap:8px;margin-bottom:14px;">
|
||||
<div v-for="i in 6" :key="'t'+i" class="form-field">
|
||||
<div class="kv-label">{{ i }}# 槽温度 (°C)</div>
|
||||
<input v-model.number="calc.acid_temp_list[i-1]" type="number" class="kv-input" step="1" @change="doCalc" />
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="calculating" style="text-align:center;color:var(--text-muted);padding:8px;">计算中...</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 计算结果 -->
|
||||
<div class="card" style="flex:1;" v-if="calcResult">
|
||||
<div class="card-header">计算结果
|
||||
<span :class="['badge', riskBadge(calcResult.under_pickling_risk)]" style="margin-left:auto;">
|
||||
欠酸洗风险:{{ calcResult.under_pickling_risk }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="grid-2" style="gap:10px;margin-bottom:14px;">
|
||||
<div class="metric-box">
|
||||
<div class="mb-label">最大允许速度</div>
|
||||
<div class="mb-value">{{ calcResult.max_speed }}</div>
|
||||
<div class="mb-unit">m/min</div>
|
||||
</div>
|
||||
<div class="metric-box">
|
||||
<div class="mb-label">综合酸洗指数</div>
|
||||
<div class="mb-value">{{ calcResult.total_pi }}</div>
|
||||
<div class="mb-unit">%</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="calcResult.warning" class="warn-box mb12">{{ calcResult.warning }}</div>
|
||||
<div class="sec-title">各槽酸洗详情</div>
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr><th>酸槽</th><th>停留时间 (s)</th><th>累计PI (%)</th><th>进度</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="(pi, idx) in calcResult.pi_per_tank" :key="idx">
|
||||
<td class="td-num">{{ idx+1 }}# 槽</td>
|
||||
<td class="td-num">{{ calcResult.residence_time_per_tank[idx] }}</td>
|
||||
<td class="td-num">{{ pi }}</td>
|
||||
<td>
|
||||
<div class="prog-bar-wrap" style="width:80px;display:inline-block;">
|
||||
<div class="prog-bar-fill" :style="{ width: pi + '%', background: effColor(pi) }"></div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card" style="flex:1;display:flex;align-items:center;justify-content:center;" v-else>
|
||||
<div style="text-align:center;color:var(--text-muted);padding:40px;">
|
||||
<div style="font-size:11px;letter-spacing:2px;color:var(--text-muted);margin-bottom:12px;">[ LOADING ]</div>
|
||||
<div>{{ calculating ? '模型计算中...' : '正在加载...' }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ─── 模型校准 ─── -->
|
||||
<div class="card mt8">
|
||||
<div class="card-header">
|
||||
酸洗速度模型校准
|
||||
<span class="ch-badge" :style="{ background: kCalColor(calib.current_kcal) }">
|
||||
K_cal = {{ calib.current_kcal }}
|
||||
</span>
|
||||
<button class="btn btn-outline" style="margin-left:auto;padding:2px 10px;font-size:11px;"
|
||||
@click="resetCalib('acid_speed')">重置系数</button>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="calib-layout">
|
||||
<!-- 录入表单 -->
|
||||
<div class="calib-form">
|
||||
<div class="calib-hint">当模型预测速度与实际可用速度存在偏差时,录入实测数据进行修正。</div>
|
||||
<div class="flex-col" style="gap:8px;margin-top:10px;">
|
||||
<div class="form-field">
|
||||
<div class="kv-label">实测最高合格速度 (m/min)</div>
|
||||
<input v-model.number="calib.actual_speed" type="number" class="kv-input" step="1" min="20" max="180" />
|
||||
</div>
|
||||
<div class="form-field">
|
||||
<div class="kv-label">该速度下质量结果</div>
|
||||
<select v-model="calib.quality_ok" class="kv-input">
|
||||
<option :value="true">合格(酸洗充分)</option>
|
||||
<option :value="false">欠酸洗(出现缺陷)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-field">
|
||||
<div class="kv-label">备注(可选)</div>
|
||||
<input v-model="calib.note" type="text" class="kv-input" placeholder="卷号/班次/现象描述" />
|
||||
</div>
|
||||
<div v-if="calcResult" class="calib-predict-row">
|
||||
<span class="kv-label">模型预测速度</span>
|
||||
<span class="kv-value">{{ calcResult.max_speed }} <span class="kv-unit">m/min</span></span>
|
||||
<span class="kv-label" style="margin-left:16px;">偏差</span>
|
||||
<span class="kv-value" :style="{ color: Math.abs(calib.actual_speed - calcResult.max_speed) > 10 ? '#f0a500' : '#28a745' }">
|
||||
{{ calib.actual_speed && calcResult ? (calib.actual_speed - calcResult.max_speed > 0 ? '+' : '') + (calib.actual_speed - calcResult.max_speed).toFixed(1) : '—' }} m/min
|
||||
</span>
|
||||
</div>
|
||||
<button class="btn btn-primary fw" :disabled="calib.loading || !calib.actual_speed" @click="submitCalib">
|
||||
{{ calib.loading ? '提交中...' : '提交修正数据' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 历史记录 -->
|
||||
<div class="calib-history">
|
||||
<div class="sec-title" style="margin-bottom:8px;">修正记录</div>
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr><th>时间</th><th>K 前</th><th>K 后</th><th>实测速度</th><th>质量</th><th>备注</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="h in acidHistory" :key="h.ts">
|
||||
<td class="td-muted">{{ h.ts.slice(5,16) }}</td>
|
||||
<td class="td-num">{{ h.k_before }}</td>
|
||||
<td class="td-num" :style="{ color: h.k_after > h.k_before ? '#28a745' : '#f0a500' }">{{ h.k_after }}</td>
|
||||
<td class="td-num">{{ h.input.actual_speed }} m/min</td>
|
||||
<td><span :class="['badge', h.input.quality_ok ? 'badge-green' : 'badge-red']">{{ h.input.quality_ok ? '合格' : '欠酸洗' }}</span></td>
|
||||
<td class="td-muted">{{ h.note || '—' }}</td>
|
||||
</tr>
|
||||
<tr v-if="!acidHistory.length">
|
||||
<td colspan="6" class="td-muted" style="text-align:center;padding:14px;">暂无修正记录</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ─── 当前过程参数(L1实时)─── -->
|
||||
<div class="card mt8">
|
||||
<div class="card-header">当前过程参数(L1 实时)</div>
|
||||
<div class="card-body">
|
||||
<div v-if="l1Online" class="grid-5">
|
||||
<div class="metric-box">
|
||||
<div class="mb-label">当前线速</div>
|
||||
<div class="mb-value">{{ current.speed }}</div>
|
||||
<div class="mb-unit">m/min</div>
|
||||
</div>
|
||||
<div class="metric-box">
|
||||
<div class="mb-label">入口张力</div>
|
||||
<div class="mb-value">{{ current.tension_inlet }}</div>
|
||||
<div class="mb-unit">kN</div>
|
||||
</div>
|
||||
<div class="metric-box">
|
||||
<div class="mb-label">出口张力</div>
|
||||
<div class="mb-value">{{ current.tension_outlet }}</div>
|
||||
<div class="mb-unit">kN</div>
|
||||
</div>
|
||||
<div class="metric-box">
|
||||
<div class="mb-label">1#槽酸液温度</div>
|
||||
<div class="mb-value">{{ current.acid_temp }}</div>
|
||||
<div class="mb-unit">°C</div>
|
||||
</div>
|
||||
<div class="metric-box">
|
||||
<div class="mb-label">当前卷号</div>
|
||||
<div class="mb-value" style="font-size:14px;">{{ current.coil_no || '—' }}</div>
|
||||
<div class="mb-unit">在线</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else style="text-align:center;padding:24px;color:var(--text-muted);">
|
||||
<div style="font-size:11px;letter-spacing:2px;color:var(--text-muted);margin-bottom:8px;">[ NO SIGNAL ]</div>
|
||||
<div>当前无 L1 实时数据。机组启动并接入 UDP 报文后自动显示。</div>
|
||||
<div style="font-size:11px;margin-top:6px;">L2 监听地址:0.0.0.0:9000</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { predictAcidSpeed, getMessageLogs, getCalibration, calibrateAcidSpeed, resetCalibration } from '@/api'
|
||||
|
||||
const STEEL_GRADES = ['Q195','Q215','Q235','SPHC','SPHD','SPHE','SS400','SAPH440','QSTE420TM']
|
||||
|
||||
export default {
|
||||
name: 'ProcessModel',
|
||||
data() {
|
||||
return {
|
||||
lastRefresh: '--:--:--',
|
||||
l1Online: false,
|
||||
tanks: Array.from({ length: 6 }, () => ({ conc: null, temp: null, fe2: null, rt: null, eff: null })),
|
||||
rinse: Array.from({ length: 5 }, () => ({ ph: null, temp: null, flow: null, conductivity: null })),
|
||||
current: { speed: null, tension_inlet: null, tension_outlet: null, acid_temp: null, coil_no: null },
|
||||
steelGrades: STEEL_GRADES,
|
||||
calc: {
|
||||
thickness: 3.0,
|
||||
width: 1000,
|
||||
steel_grade: 'Q235',
|
||||
target_pi: 95,
|
||||
scale_weight: 8.5,
|
||||
acid_conc_list: [200, 188, 175, 162, 148, 135],
|
||||
acid_temp_list: [80, 78, 76, 75, 74, 72],
|
||||
},
|
||||
calculating: false,
|
||||
calcResult: null,
|
||||
_timer: null,
|
||||
calib: {
|
||||
current_kcal: 1.0,
|
||||
actual_speed: null,
|
||||
quality_ok: true,
|
||||
note: '',
|
||||
loading: false,
|
||||
},
|
||||
calibHistory: [],
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
acidHistory() {
|
||||
return this.calibHistory.filter(h => h.model === 'acid_speed').slice(0, 10)
|
||||
},
|
||||
},
|
||||
async mounted() {
|
||||
await this.fetchL1Data()
|
||||
await this.doCalc()
|
||||
await this.loadCalibration()
|
||||
this._timer = setInterval(() => this.fetchL1Data(), 5000)
|
||||
},
|
||||
beforeDestroy() {
|
||||
clearInterval(this._timer)
|
||||
},
|
||||
methods: {
|
||||
async fetchL1Data() {
|
||||
try {
|
||||
// 拉最新一条 PC03 过程数据报文
|
||||
const res = await getMessageLogs({ msg_type: 'PC03', page_size: 1 })
|
||||
const logs = res.data?.items || []
|
||||
if (logs.length && logs[0].parsed_data) {
|
||||
const d = typeof logs[0].parsed_data === 'string'
|
||||
? JSON.parse(logs[0].parsed_data) : logs[0].parsed_data
|
||||
this.l1Online = true
|
||||
this.current.speed = d.speed ?? null
|
||||
this.current.tension_inlet = d.tension_inlet ?? null
|
||||
this.current.tension_outlet = d.tension_outlet ?? null
|
||||
this.current.acid_temp = d.acid_temp ?? null
|
||||
this.current.coil_no = d.coil_no ?? null
|
||||
// 如果报文中有各槽浓度/温度,同步到计算参数
|
||||
if (d.acid_conc_list) this.calc.acid_conc_list = d.acid_conc_list
|
||||
if (d.acid_temp_list) this.calc.acid_temp_list = d.acid_temp_list
|
||||
// 更新槽显示(用计算参数中的值,真实值未来扩展报文后替换)
|
||||
this.syncTanksFromCalc()
|
||||
await this.doCalc()
|
||||
} else {
|
||||
this.l1Online = false
|
||||
}
|
||||
} catch (e) {
|
||||
this.l1Online = false
|
||||
}
|
||||
this.lastRefresh = new Date().toTimeString().slice(0, 8)
|
||||
},
|
||||
syncTanksFromCalc() {
|
||||
this.tanks = this.calc.acid_conc_list.map((conc, i) => {
|
||||
const temp = this.calc.acid_temp_list[i]
|
||||
const eff = +(Math.min(98, 50 + (conc / 200) * 35 + (temp - 60) / 25 * 15)).toFixed(1)
|
||||
return { conc, temp, fe2: null, rt: null, eff }
|
||||
})
|
||||
},
|
||||
async doCalc() {
|
||||
this.calculating = true
|
||||
try {
|
||||
const res = await predictAcidSpeed({
|
||||
thickness: this.calc.thickness,
|
||||
width: this.calc.width,
|
||||
steel_grade: this.calc.steel_grade,
|
||||
acid_conc_list: this.calc.acid_conc_list,
|
||||
acid_temp_list: this.calc.acid_temp_list,
|
||||
scale_weight: this.calc.scale_weight,
|
||||
target_pi: this.calc.target_pi,
|
||||
})
|
||||
this.calcResult = res.data
|
||||
// 用计算结果的停留时间更新槽显示
|
||||
if (res.data.residence_time_per_tank) {
|
||||
res.data.residence_time_per_tank.forEach((rt, i) => {
|
||||
if (this.tanks[i]) this.tanks[i].rt = rt
|
||||
})
|
||||
}
|
||||
} catch (e) {
|
||||
// silent — user will see calcResult stay null
|
||||
} finally {
|
||||
this.calculating = false
|
||||
}
|
||||
},
|
||||
tankBadge(t) {
|
||||
if (t.conc == null) return 'badge-yellow'
|
||||
if (t.conc < 100 || t.temp < 65) return 'badge-red'
|
||||
if (t.conc < 140 || t.temp < 72) return 'badge-yellow'
|
||||
return 'badge-green'
|
||||
},
|
||||
tankStatus(t) {
|
||||
if (t.conc == null) return '待接入'
|
||||
if (t.conc < 100 || t.temp < 65) return '报警'
|
||||
if (t.conc < 140 || t.temp < 72) return '预警'
|
||||
return '正常'
|
||||
},
|
||||
riskBadge(r) {
|
||||
if (r === 'HIGH') return 'badge-red'
|
||||
if (r === 'MEDIUM') return 'badge-yellow'
|
||||
return 'badge-green'
|
||||
},
|
||||
effColor(v) {
|
||||
if (v >= 80) return '#28a745'
|
||||
if (v >= 60) return '#f0a500'
|
||||
return '#da3633'
|
||||
},
|
||||
async loadCalibration() {
|
||||
try {
|
||||
const res = await getCalibration()
|
||||
this.calib.current_kcal = res.data?.acid_speed_kcal ?? 1.0
|
||||
this.calibHistory = res.data?.history || []
|
||||
} catch (e) { /* silent */ }
|
||||
},
|
||||
async submitCalib() {
|
||||
if (!this.calib.actual_speed) return
|
||||
this.calib.loading = true
|
||||
try {
|
||||
await calibrateAcidSpeed({
|
||||
thickness: this.calc.thickness,
|
||||
width: this.calc.width,
|
||||
steel_grade: this.calc.steel_grade,
|
||||
acid_conc_list: this.calc.acid_conc_list,
|
||||
acid_temp_list: this.calc.acid_temp_list,
|
||||
scale_weight: this.calc.scale_weight,
|
||||
actual_max_speed: this.calib.actual_speed,
|
||||
actual_quality_ok: this.calib.quality_ok,
|
||||
note: this.calib.note,
|
||||
})
|
||||
this.$message.success('修正数据已提交,模型系数已更新')
|
||||
this.calib.actual_speed = null
|
||||
this.calib.note = ''
|
||||
await this.loadCalibration()
|
||||
await this.doCalc()
|
||||
} catch (e) {
|
||||
this.$message.error('提交失败:' + (e.response?.data?.detail || e.message))
|
||||
} finally {
|
||||
this.calib.loading = false
|
||||
}
|
||||
},
|
||||
async resetCalib(model) {
|
||||
try {
|
||||
await this.$confirm('确认将校准系数重置为 1.0?', '重置确认', { type: 'warning' })
|
||||
await resetCalibration(model)
|
||||
this.$message.success('已重置')
|
||||
await this.loadCalibration()
|
||||
await this.doCalc()
|
||||
} catch (e) { /* cancelled */ }
|
||||
},
|
||||
kCalColor(k) {
|
||||
const d = Math.abs(k - 1.0)
|
||||
if (d < 0.05) return '#1a3a1f'
|
||||
if (d < 0.15) return '#3a2a00'
|
||||
return '#3a1a1a'
|
||||
},
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@import '@/assets/styles/variables';
|
||||
.form-field { display: flex; flex-direction: column; gap: 5px; }
|
||||
.warn-box {
|
||||
background: rgba(240,165,0,.1);
|
||||
border: 1px solid $accent-yellow;
|
||||
border-radius: 4px;
|
||||
padding: 8px 12px;
|
||||
color: $accent-yellow;
|
||||
font-size: 12px;
|
||||
}
|
||||
.mb12 { margin-bottom: 12px; }
|
||||
.mt8 { margin-top: 8px; }
|
||||
|
||||
.calib-layout {
|
||||
display: flex;
|
||||
gap: 20px;
|
||||
align-items: flex-start;
|
||||
}
|
||||
.calib-form {
|
||||
flex: 0 0 300px;
|
||||
}
|
||||
.calib-history {
|
||||
flex: 1;
|
||||
overflow-x: auto;
|
||||
}
|
||||
.calib-hint {
|
||||
font-size: 11px;
|
||||
color: $text-muted;
|
||||
line-height: 1.6;
|
||||
border-left: 2px solid $border;
|
||||
padding-left: 8px;
|
||||
}
|
||||
.calib-predict-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 10px;
|
||||
background: rgba(255,255,255,.03);
|
||||
border-radius: 4px;
|
||||
border: 1px solid $border;
|
||||
}
|
||||
</style>
|
||||
200
frontend/src/views/Production.vue
Normal file
200
frontend/src/views/Production.vue
Normal file
@@ -0,0 +1,200 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="card">
|
||||
<div class="card-body" style="padding:10px 14px;">
|
||||
<div class="flex-row" style="flex-wrap:wrap;gap:12px;">
|
||||
<div class="flex-row">
|
||||
<span class="kv-label">卷号</span>
|
||||
<input v-model="query.coil_no" class="kv-input" style="width:140px;" @keyup.enter="fetchData" />
|
||||
</div>
|
||||
<div class="flex-row">
|
||||
<span class="kv-label">班次</span>
|
||||
<select v-model="query.shift" class="kv-input" style="width:90px;">
|
||||
<option value="">全部</option>
|
||||
<option v-for="s in ['甲','乙','丙','丁']" :key="s" :value="s">{{ s }}班</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="flex-row">
|
||||
<span class="kv-label">开始日期</span>
|
||||
<input v-model="query.start_date" type="date" class="kv-input" style="width:140px;" />
|
||||
<span class="kv-label">~</span>
|
||||
<input v-model="query.end_date" type="date" class="kv-input" style="width:140px;" />
|
||||
</div>
|
||||
<div class="flex-row">
|
||||
<button class="btn btn-primary" @click="fetchData">查询</button>
|
||||
<button class="btn btn-outline" @click="openDialog()">+ 新增</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
生产实绩
|
||||
<span class="ch-badge">共 {{ total }} 条</span>
|
||||
</div>
|
||||
<div class="table-scroll" v-loading="loading">
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>卷号</th><th>班次</th><th>开始时间</th><th>结束时间</th>
|
||||
<th>处理重量(kg)</th><th>平均速度</th><th>最大速度</th>
|
||||
<th>酸耗(L)</th><th>入口厚度</th><th>出口厚度</th><th>质量等级</th><th>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="row in tableData" :key="row.id">
|
||||
<td class="td-num">{{ row.coil_no }}</td>
|
||||
<td>{{ row.shift || '—' }}</td>
|
||||
<td class="td-muted">{{ fmtTime(row.start_time) }}</td>
|
||||
<td class="td-muted">{{ fmtTime(row.end_time) }}</td>
|
||||
<td class="td-num">{{ row.process_weight || '—' }}</td>
|
||||
<td class="td-num">{{ row.avg_speed ? row.avg_speed + ' m/min' : '—' }}</td>
|
||||
<td class="td-num">{{ row.max_speed ? row.max_speed + ' m/min' : '—' }}</td>
|
||||
<td class="td-num">{{ row.acid_consumption || '—' }}</td>
|
||||
<td class="td-num">{{ row.inlet_thickness || '—' }}</td>
|
||||
<td class="td-num">{{ row.outlet_thickness || '—' }}</td>
|
||||
<td>
|
||||
<span v-if="row.quality_grade" :class="['badge', gradeClass(row.quality_grade)]">{{ row.quality_grade }}</span>
|
||||
<span v-else class="td-muted">—</span>
|
||||
</td>
|
||||
<td><span class="action-link" @click="openDialog(row)">编辑</span></td>
|
||||
</tr>
|
||||
<tr v-if="!tableData.length && !loading">
|
||||
<td colspan="12" class="td-muted" style="text-align:center;padding:24px;">暂无数据</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="card-body" style="padding:8px 14px;" v-if="total > query.page_size">
|
||||
<div class="flex-row">
|
||||
<button class="btn btn-outline" :disabled="query.page<=1" @click="query.page--;fetchData()">上一页</button>
|
||||
<span class="kv-label">第 {{ query.page }} / {{ Math.ceil(total/query.page_size) }} 页</span>
|
||||
<button class="btn btn-outline" :disabled="query.page>=Math.ceil(total/query.page_size)" @click="query.page++;fetchData()">下一页</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Modal -->
|
||||
<div v-if="dialogVisible" class="modal-mask" @click.self="dialogVisible=false">
|
||||
<div class="modal-box" style="width:680px;">
|
||||
<div class="modal-header">
|
||||
{{ editRow ? '编辑实绩' : '新增实绩' }}
|
||||
<span class="modal-close" @click="dialogVisible=false">✕</span>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="grid-2" style="gap:12px;">
|
||||
<div class="form-field">
|
||||
<div class="kv-label">卷号 *</div>
|
||||
<input v-model="form.coil_no" class="kv-input" />
|
||||
</div>
|
||||
<div class="form-field">
|
||||
<div class="kv-label">班次</div>
|
||||
<select v-model="form.shift" class="kv-input">
|
||||
<option v-for="s in ['甲','乙','丙','丁']" :key="s" :value="s">{{ s }}班</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-field">
|
||||
<div class="kv-label">开始时间</div>
|
||||
<input v-model="form.start_time" type="datetime-local" class="kv-input" />
|
||||
</div>
|
||||
<div class="form-field">
|
||||
<div class="kv-label">结束时间</div>
|
||||
<input v-model="form.end_time" type="datetime-local" class="kv-input" />
|
||||
</div>
|
||||
<div class="form-field">
|
||||
<div class="kv-label">处理重量 (kg)</div>
|
||||
<input v-model.number="form.process_weight" type="number" class="kv-input" />
|
||||
</div>
|
||||
<div class="form-field">
|
||||
<div class="kv-label">平均速度 (m/min)</div>
|
||||
<input v-model.number="form.avg_speed" type="number" class="kv-input" />
|
||||
</div>
|
||||
<div class="form-field">
|
||||
<div class="kv-label">酸耗 (L)</div>
|
||||
<input v-model.number="form.acid_consumption" type="number" class="kv-input" />
|
||||
</div>
|
||||
<div class="form-field">
|
||||
<div class="kv-label">质量等级</div>
|
||||
<select v-model="form.quality_grade" class="kv-input">
|
||||
<option value="A1">A1</option>
|
||||
<option value="A2">A2</option>
|
||||
<option value="B1">B1</option>
|
||||
<option value="B2">B2</option>
|
||||
<option value="C">C</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-field">
|
||||
<div class="kv-label">操作员</div>
|
||||
<input v-model="form.operator" class="kv-input" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn btn-outline" @click="dialogVisible=false">取消</button>
|
||||
<button class="btn btn-primary" :disabled="saving" @click="save">{{ saving ? '保存中...' : '保存' }}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { getProductionRecords, createProductionRecord, updateProductionRecord } from '@/api'
|
||||
|
||||
export default {
|
||||
name: 'Production',
|
||||
data() {
|
||||
return {
|
||||
loading: false, saving: false,
|
||||
tableData: [], total: 0,
|
||||
query: { page: 1, page_size: 20, coil_no: '', shift: '', start_date: '', end_date: '' },
|
||||
dialogVisible: false, editRow: null, form: {},
|
||||
}
|
||||
},
|
||||
created() { this.fetchData() },
|
||||
methods: {
|
||||
async fetchData() {
|
||||
this.loading = true
|
||||
const params = { ...this.query }
|
||||
if (params.start_date) params.start_date = params.start_date + 'T00:00:00'
|
||||
if (params.end_date) params.end_date = params.end_date + 'T23:59:59'
|
||||
try {
|
||||
const res = await getProductionRecords(params)
|
||||
this.tableData = res.data.items
|
||||
this.total = res.data.total
|
||||
} finally { this.loading = false }
|
||||
},
|
||||
fmtTime(t) { return t ? t.replace('T', ' ').slice(0, 16) : '—' },
|
||||
gradeClass(g) {
|
||||
if (g?.startsWith('A')) return 'badge-green'
|
||||
if (g?.startsWith('B')) return 'badge-blue'
|
||||
return 'badge-yellow'
|
||||
},
|
||||
openDialog(row = null) {
|
||||
this.editRow = row; this.form = row ? { ...row } : {}; this.dialogVisible = true
|
||||
},
|
||||
async save() {
|
||||
if (!this.form.coil_no) { this.$message.error('卷号不能为空'); return }
|
||||
this.saving = true
|
||||
try {
|
||||
if (this.editRow) await updateProductionRecord(this.editRow.id, this.form)
|
||||
else await createProductionRecord(this.form)
|
||||
this.$message.success('保存成功')
|
||||
this.dialogVisible = false; this.fetchData()
|
||||
} finally { this.saving = false }
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@import '@/assets/styles/variables';
|
||||
.action-link { color: $sms-highlight; cursor: pointer; font-size: 12px; margin-right: 12px; &:hover { text-decoration: underline; } }
|
||||
.form-field { display: flex; flex-direction: column; gap: 5px; }
|
||||
.modal-mask { position: fixed; inset: 0; background: rgba(0,0,0,.6); display: flex; align-items: center; justify-content: center; z-index: 9999; }
|
||||
.modal-box { background: $bg-card; border: 1px solid $border; border-radius: 6px; width: 640px; max-width: 95vw; max-height: 90vh; display: flex; flex-direction: column; }
|
||||
.modal-header { display: flex; align-items: center; justify-content: space-between; padding: 12px 16px; background: $bg-panel; border-bottom: 1px solid $border; font-size: 13px; font-weight: 600; color: $sms-highlight; .modal-close { cursor: pointer; color: $text-muted; &:hover { color: $text-primary; } } }
|
||||
.modal-body { padding: 16px; overflow-y: auto; }
|
||||
.modal-footer { padding: 10px 16px; background: $bg-panel; border-top: 1px solid $border; display: flex; justify-content: flex-end; gap: 10px; }
|
||||
</style>
|
||||
407
frontend/src/views/Quality.vue
Normal file
407
frontend/src/views/Quality.vue
Normal file
@@ -0,0 +1,407 @@
|
||||
<template>
|
||||
<div>
|
||||
<!-- ─── 过滤栏 ─── -->
|
||||
<div class="card">
|
||||
<div class="card-body" style="padding:10px 14px;">
|
||||
<div class="flex-row" style="flex-wrap:wrap;gap:12px;">
|
||||
<div class="flex-row">
|
||||
<span class="kv-label">卷号</span>
|
||||
<input v-model="query.coil_no" class="kv-input" style="width:140px;" @keyup.enter="fetchData" />
|
||||
</div>
|
||||
<div class="flex-row">
|
||||
<span class="kv-label">质量等级</span>
|
||||
<select v-model="query.overall_grade" class="kv-input" style="width:100px;">
|
||||
<option value="">全部</option>
|
||||
<option v-for="g in ['A1','A2','B1','B2','C']" :key="g" :value="g">{{ g }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="flex-row">
|
||||
<span class="kv-label">日期</span>
|
||||
<input v-model="query.start_date" type="date" class="kv-input" style="width:140px;" />
|
||||
<span class="kv-label">~</span>
|
||||
<input v-model="query.end_date" type="date" class="kv-input" style="width:140px;" />
|
||||
</div>
|
||||
<div class="flex-row">
|
||||
<button class="btn btn-primary" @click="fetchData">查询</button>
|
||||
<button class="btn btn-outline" @click="openDialog()">+ 新增检验</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ─── 统计卡片 ─── -->
|
||||
<div class="grid-4">
|
||||
<div class="metric-box">
|
||||
<div class="mb-label">合格率</div>
|
||||
<div class="mb-value" :style="{ color: summary.pass_rate >= 95 ? '#28a745' : '#f0a500' }">
|
||||
{{ summary.pass_rate }}
|
||||
</div>
|
||||
<div class="mb-unit">%</div>
|
||||
</div>
|
||||
<div class="metric-box">
|
||||
<div class="mb-label">平均PI评分</div>
|
||||
<div class="mb-value">{{ summary.avg_pi_score }}</div>
|
||||
<div class="mb-unit">/ 100</div>
|
||||
</div>
|
||||
<div class="metric-box">
|
||||
<div class="mb-label">平均表面评分</div>
|
||||
<div class="mb-value">{{ summary.avg_surface_score }}</div>
|
||||
<div class="mb-unit">/ 100</div>
|
||||
</div>
|
||||
<div class="metric-box">
|
||||
<div class="mb-label">记录总数</div>
|
||||
<div class="mb-value">{{ summary.total }}</div>
|
||||
<div class="mb-unit">条</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ─── 主体区域 ─── -->
|
||||
<div class="section-row">
|
||||
<!-- 质量记录表 -->
|
||||
<div class="card" style="flex:2;">
|
||||
<div class="card-header">
|
||||
质量检验记录
|
||||
<span class="ch-badge">共 {{ total }} 条</span>
|
||||
</div>
|
||||
<div class="table-scroll" v-loading="loading">
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>卷号</th>
|
||||
<th>实测厚度</th>
|
||||
<th>实测宽度</th>
|
||||
<th>PI评分</th>
|
||||
<th>表面评分</th>
|
||||
<th>质量等级</th>
|
||||
<th>残酸(g/m²)</th>
|
||||
<th>粗糙度Ra</th>
|
||||
<th>是否合格</th>
|
||||
<th>检验员</th>
|
||||
<th>检验时间</th>
|
||||
<th>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="row in tableData" :key="row.id">
|
||||
<td class="td-num">{{ row.coil_no }}</td>
|
||||
<td class="td-num">{{ row.thickness_actual || '—' }}</td>
|
||||
<td class="td-num">{{ row.width_actual || '—' }}</td>
|
||||
<td class="td-num">{{ row.pi_score != null ? row.pi_score.toFixed(1) : '—' }}</td>
|
||||
<td class="td-num">{{ row.surface_score != null ? row.surface_score.toFixed(1) : '—' }}</td>
|
||||
<td>
|
||||
<span v-if="row.overall_grade" :class="['badge', gradeBadge(row.overall_grade)]">
|
||||
{{ row.overall_grade }}
|
||||
</span>
|
||||
<span v-else class="td-muted">—</span>
|
||||
</td>
|
||||
<td class="td-num">{{ row.acid_residual || '—' }}</td>
|
||||
<td class="td-num">{{ row.roughness_ra || '—' }}</td>
|
||||
<td>
|
||||
<span :class="['badge', row.is_passed ? 'badge-green' : 'badge-red']">
|
||||
{{ row.is_passed ? '合格' : '不合格' }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="td-muted">{{ row.inspector || '—' }}</td>
|
||||
<td class="td-muted">{{ fmtTime(row.inspect_time) }}</td>
|
||||
<td><span class="action-link" @click="openDialog(row)">编辑</span></td>
|
||||
</tr>
|
||||
<tr v-if="!tableData.length && !loading">
|
||||
<td colspan="12" class="td-muted" style="text-align:center;padding:24px;">暂无数据</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="card-body" style="padding:8px 14px;" v-if="total > query.page_size">
|
||||
<div class="flex-row">
|
||||
<button class="btn btn-outline" :disabled="query.page<=1" @click="query.page--;fetchData()">上一页</button>
|
||||
<span class="kv-label">第 {{ query.page }} / {{ Math.ceil(total/query.page_size) }} 页</span>
|
||||
<button class="btn btn-outline" :disabled="query.page>=Math.ceil(total/query.page_size)" @click="query.page++;fetchData()">下一页</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 右侧面板 -->
|
||||
<div style="flex:1;display:flex;flex-direction:column;gap:14px;min-width:0;">
|
||||
<!-- 快速预测面板 -->
|
||||
<div class="card">
|
||||
<div class="card-header">快速质量预测</div>
|
||||
<div class="card-body">
|
||||
<div class="flex-col">
|
||||
<div class="form-field">
|
||||
<div class="kv-label">厚度 (mm)</div>
|
||||
<input v-model.number="pred.thickness" type="number" class="kv-input" step="0.1" />
|
||||
</div>
|
||||
<div class="form-field">
|
||||
<div class="kv-label">平均速度 (m/min)</div>
|
||||
<input v-model.number="pred.avg_speed" type="number" class="kv-input" />
|
||||
</div>
|
||||
<div class="form-field">
|
||||
<div class="kv-label">平均HCl浓度 (g/L)</div>
|
||||
<input v-model.number="pred.acid_conc_avg" type="number" class="kv-input" />
|
||||
</div>
|
||||
<div class="form-field">
|
||||
<div class="kv-label">平均温度 (°C)</div>
|
||||
<input v-model.number="pred.acid_temp_avg" type="number" class="kv-input" />
|
||||
</div>
|
||||
<div class="form-field">
|
||||
<div class="kv-label">铁皮重量 (g/m²)</div>
|
||||
<input v-model.number="pred.scale_weight" type="number" class="kv-input" step="0.5" />
|
||||
</div>
|
||||
<button class="btn btn-primary fw" :disabled="predLoading" @click="doPred">
|
||||
{{ predLoading ? '预测中...' : '质量预测' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 预测结果 -->
|
||||
<div v-if="predResult" class="mt8">
|
||||
<div class="kv-grid" style="margin-bottom:10px;">
|
||||
<span class="kv-label">PI评分</span>
|
||||
<span class="kv-value">{{ predResult.pi_score }}</span>
|
||||
<span class="kv-label">表面评分</span>
|
||||
<span class="kv-value">{{ predResult.surface_score }}</span>
|
||||
<span class="kv-label">综合等级</span>
|
||||
<span>
|
||||
<span :class="['badge', gradeBadge(predResult.overall_grade)]" style="font-size:14px;padding:2px 12px;">
|
||||
{{ predResult.overall_grade }}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="sec-title">工艺建议</div>
|
||||
<div v-for="(r, i) in predResult.recommendations" :key="i" class="rec-item">
|
||||
💡 {{ r }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 等级分布图 -->
|
||||
<div class="card">
|
||||
<div class="card-header">等级分布</div>
|
||||
<div class="card-body">
|
||||
<canvas ref="gradeChart" height="140"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ─── 新增/编辑弹窗 ─── -->
|
||||
<div v-if="dialogVisible" class="modal-mask" @click.self="dialogVisible=false">
|
||||
<div class="modal-box" style="width:680px;">
|
||||
<div class="modal-header">
|
||||
{{ editRow ? '编辑质量记录 #' + editRow.id : '新增质量记录' }}
|
||||
<span class="modal-close" @click="dialogVisible=false">✕</span>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="grid-3" style="gap:12px;margin-bottom:14px;">
|
||||
<div class="form-field">
|
||||
<div class="kv-label">卷号 *</div>
|
||||
<input v-model="form.coil_no" class="kv-input" />
|
||||
</div>
|
||||
<div class="form-field">
|
||||
<div class="kv-label">实测厚度 (mm)</div>
|
||||
<input v-model.number="form.thickness_actual" type="number" class="kv-input" step="0.01" />
|
||||
</div>
|
||||
<div class="form-field">
|
||||
<div class="kv-label">实测宽度 (mm)</div>
|
||||
<input v-model.number="form.width_actual" type="number" class="kv-input" />
|
||||
</div>
|
||||
<div class="form-field">
|
||||
<div class="kv-label">平直度 (IU)</div>
|
||||
<input v-model.number="form.flatness" type="number" class="kv-input" step="0.1" />
|
||||
</div>
|
||||
<div class="form-field">
|
||||
<div class="kv-label">凸度 (μm)</div>
|
||||
<input v-model.number="form.crown" type="number" class="kv-input" />
|
||||
</div>
|
||||
<div class="form-field">
|
||||
<div class="kv-label">表面缺陷类型</div>
|
||||
<select v-model="form.surface_defect_type" class="kv-input">
|
||||
<option value="">无缺陷</option>
|
||||
<option value="划伤">划伤</option>
|
||||
<option value="压印">压印</option>
|
||||
<option value="氧化色">氧化色</option>
|
||||
<option value="过酸洗">过酸洗</option>
|
||||
<option value="欠酸洗">欠酸洗</option>
|
||||
<option value="锈迹">锈迹</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-field">
|
||||
<div class="kv-label">缺陷长度 (m)</div>
|
||||
<input v-model.number="form.defect_length_m" type="number" class="kv-input" step="0.1" />
|
||||
</div>
|
||||
<div class="form-field">
|
||||
<div class="kv-label">缺陷位置</div>
|
||||
<input v-model="form.defect_position" class="kv-input" placeholder="如: 操作侧头部" />
|
||||
</div>
|
||||
<div class="form-field">
|
||||
<div class="kv-label">PI评分</div>
|
||||
<input v-model.number="form.pi_score" type="number" class="kv-input" step="0.1" min="0" max="100" />
|
||||
</div>
|
||||
<div class="form-field">
|
||||
<div class="kv-label">表面评分</div>
|
||||
<input v-model.number="form.surface_score" type="number" class="kv-input" step="0.1" min="0" max="100" />
|
||||
</div>
|
||||
<div class="form-field">
|
||||
<div class="kv-label">综合等级</div>
|
||||
<select v-model="form.overall_grade" class="kv-input">
|
||||
<option v-for="g in ['A1','A2','B1','B2','C']" :key="g" :value="g">{{ g }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-field">
|
||||
<div class="kv-label">残酸量 (g/m²)</div>
|
||||
<input v-model.number="form.acid_residual" type="number" class="kv-input" step="0.01" />
|
||||
</div>
|
||||
<div class="form-field">
|
||||
<div class="kv-label">粗糙度 Ra (μm)</div>
|
||||
<input v-model.number="form.roughness_ra" type="number" class="kv-input" step="0.01" />
|
||||
</div>
|
||||
<div class="form-field">
|
||||
<div class="kv-label">检验员</div>
|
||||
<input v-model="form.inspector" class="kv-input" />
|
||||
</div>
|
||||
<div class="form-field">
|
||||
<div class="kv-label">是否合格</div>
|
||||
<select v-model="form.is_passed" class="kv-input">
|
||||
<option :value="true">合格</option>
|
||||
<option :value="false">不合格</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn btn-outline" @click="dialogVisible=false">取消</button>
|
||||
<button class="btn btn-primary" :disabled="saving" @click="save">
|
||||
{{ saving ? '保存中...' : '保存' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { getQualityList, createQuality, getQualitySummary, predictQuality } from '@/api'
|
||||
|
||||
export default {
|
||||
name: 'Quality',
|
||||
data() {
|
||||
return {
|
||||
loading: false, saving: false, predLoading: false,
|
||||
tableData: [], total: 0,
|
||||
summary: { pass_rate: 0, avg_pi_score: 0, avg_surface_score: 0, total: 0, grade_distribution: {} },
|
||||
query: { page: 1, page_size: 20, coil_no: '', overall_grade: '', start_date: '', end_date: '' },
|
||||
dialogVisible: false, editRow: null, form: {},
|
||||
pred: { thickness: 3.0, avg_speed: 100, acid_conc_avg: 160, acid_temp_avg: 75, scale_weight: 8.5 },
|
||||
predResult: null,
|
||||
}
|
||||
},
|
||||
created() {
|
||||
this.fetchData()
|
||||
this.fetchSummary()
|
||||
},
|
||||
methods: {
|
||||
async fetchData() {
|
||||
this.loading = true
|
||||
const params = { ...this.query }
|
||||
if (!params.coil_no) delete params.coil_no
|
||||
if (!params.overall_grade) delete params.overall_grade
|
||||
if (params.start_date) params.start_date = params.start_date + 'T00:00:00'
|
||||
else delete params.start_date
|
||||
if (params.end_date) params.end_date = params.end_date + 'T23:59:59'
|
||||
else delete params.end_date
|
||||
try {
|
||||
const res = await getQualityList(params)
|
||||
this.tableData = res.data.items
|
||||
this.total = res.data.total
|
||||
} finally { this.loading = false }
|
||||
},
|
||||
async fetchSummary() {
|
||||
try {
|
||||
const res = await getQualitySummary()
|
||||
this.summary = res.data
|
||||
this.$nextTick(() => this.drawChart())
|
||||
} catch (e) { /* ignore */ }
|
||||
},
|
||||
drawChart() {
|
||||
const canvas = this.$refs.gradeChart
|
||||
if (!canvas) return
|
||||
const ctx = canvas.getContext('2d')
|
||||
const grades = ['A1','A2','B1','B2','C']
|
||||
const dist = this.summary.grade_distribution || {}
|
||||
const counts = grades.map(g => dist[g] || 0)
|
||||
const maxCount = Math.max(...counts, 1)
|
||||
const colors = ['#28a745','#00c8ff','#0078d4','#f0a500','#da3633']
|
||||
const W = canvas.offsetWidth || 300
|
||||
const H = 140
|
||||
canvas.width = W
|
||||
canvas.height = H
|
||||
ctx.clearRect(0, 0, W, H)
|
||||
const barW = Math.floor(W / grades.length) - 8
|
||||
const pad = 4
|
||||
grades.forEach((g, i) => {
|
||||
const barH = Math.floor((counts[i] / maxCount) * (H - 36))
|
||||
const x = i * (barW + 8) + pad
|
||||
const y = H - barH - 20
|
||||
ctx.fillStyle = colors[i]
|
||||
ctx.globalAlpha = 0.85
|
||||
ctx.fillRect(x, y, barW, barH)
|
||||
ctx.globalAlpha = 1
|
||||
ctx.fillStyle = '#e6edf3'
|
||||
ctx.font = '11px Consolas'
|
||||
ctx.textAlign = 'center'
|
||||
ctx.fillText(counts[i], x + barW / 2, y - 4)
|
||||
ctx.fillStyle = '#8b949e'
|
||||
ctx.font = '11px "Microsoft YaHei"'
|
||||
ctx.fillText(g, x + barW / 2, H - 4)
|
||||
})
|
||||
},
|
||||
fmtTime(t) { return t ? t.replace('T', ' ').slice(0, 16) : '—' },
|
||||
gradeBadge(g) {
|
||||
if (g === 'A1') return 'badge-green'
|
||||
if (g === 'A2') return 'badge-blue'
|
||||
if (g === 'B1') return 'badge-blue'
|
||||
if (g === 'B2') return 'badge-yellow'
|
||||
return 'badge-red'
|
||||
},
|
||||
openDialog(row = null) {
|
||||
this.editRow = row
|
||||
this.form = row ? { ...row } : { is_passed: true }
|
||||
this.dialogVisible = true
|
||||
},
|
||||
async save() {
|
||||
if (!this.form.coil_no) { this.$message.error('卷号不能为空'); return }
|
||||
this.saving = true
|
||||
try {
|
||||
if (this.editRow) await createQuality({ ...this.form }) // use PUT when available
|
||||
else await createQuality(this.form)
|
||||
this.$message.success('保存成功')
|
||||
this.dialogVisible = false
|
||||
this.fetchData()
|
||||
this.fetchSummary()
|
||||
} finally { this.saving = false }
|
||||
},
|
||||
async doPred() {
|
||||
this.predLoading = true
|
||||
try {
|
||||
const res = await predictQuality(this.pred)
|
||||
this.predResult = res.data
|
||||
} catch (e) {
|
||||
this.$message.error('预测失败:' + (e.response?.data?.detail || e.message))
|
||||
} finally { this.predLoading = false }
|
||||
},
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@import '@/assets/styles/variables';
|
||||
.action-link { color: $sms-highlight; cursor: pointer; font-size: 12px; margin-right: 10px; &:hover { text-decoration: underline; } }
|
||||
.form-field { display: flex; flex-direction: column; gap: 5px; }
|
||||
.rec-item { padding: 5px 8px; margin-top: 4px; background: rgba(0,200,255,.05); border-left: 2px solid $sms-highlight; font-size: 12px; color: $text-secondary; border-radius: 0 3px 3px 0; }
|
||||
.mt8 { margin-top: 8px; }
|
||||
.modal-mask { position: fixed; inset: 0; background: rgba(0,0,0,.6); display: flex; align-items: center; justify-content: center; z-index: 9999; }
|
||||
.modal-box { background: $bg-card; border: 1px solid $border; border-radius: 6px; width: 680px; max-width: 96vw; max-height: 92vh; display: flex; flex-direction: column; }
|
||||
.modal-header { display: flex; align-items: center; justify-content: space-between; padding: 12px 16px; background: $bg-panel; border-bottom: 1px solid $border; font-size: 13px; font-weight: 600; color: $sms-highlight; .modal-close { cursor: pointer; color: $text-muted; &:hover { color: $text-primary; } } }
|
||||
.modal-body { padding: 16px; overflow-y: auto; }
|
||||
.modal-footer { padding: 10px 16px; background: $bg-panel; border-top: 1px solid $border; display: flex; justify-content: flex-end; gap: 10px; }
|
||||
</style>
|
||||
527
frontend/src/views/TensionModel.vue
Normal file
527
frontend/src/views/TensionModel.vue
Normal file
@@ -0,0 +1,527 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="sec-title">张力设定模型</div>
|
||||
|
||||
<div class="section-row">
|
||||
<!-- ─── 输入面板 ─── -->
|
||||
<div class="card" style="flex:0 0 280px;">
|
||||
<div class="card-header">参数输入</div>
|
||||
<div class="card-body">
|
||||
<div class="flex-col">
|
||||
<div class="form-field">
|
||||
<div class="kv-label">带钢厚度 (mm)</div>
|
||||
<input v-model.number="form.thickness" type="number" class="kv-input" step="0.1" min="2.0" max="4.5" />
|
||||
</div>
|
||||
<div class="form-field">
|
||||
<div class="kv-label">带钢宽度 (mm)</div>
|
||||
<input v-model.number="form.width" type="number" class="kv-input" step="10" min="800" max="1250" />
|
||||
</div>
|
||||
<div class="form-field">
|
||||
<div class="kv-label">屈服强度 (MPa)</div>
|
||||
<input v-model.number="form.yield_strength" type="number" class="kv-input" step="5" min="200" max="600" />
|
||||
</div>
|
||||
<div class="form-field">
|
||||
<div class="kv-label">张力系数</div>
|
||||
<input v-model.number="form.tension_coef" type="number" class="kv-input" step="0.01" min="0.1" max="0.5" />
|
||||
</div>
|
||||
<button class="btn btn-primary fw mt8" :disabled="loading" @click="doCalc">
|
||||
{{ loading ? '计算中...' : '计算张力' }}
|
||||
</button>
|
||||
<button class="btn btn-outline fw" @click="loadPreset">加载默认参数</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 结果摘要 -->
|
||||
<div v-if="result" class="card-body" style="border-top:1px solid #30363d;">
|
||||
<div class="kv-grid">
|
||||
<span class="kv-label">T_max</span>
|
||||
<span class="kv-value">{{ result.T_max }} <span class="kv-unit">kN</span></span>
|
||||
<span class="kv-label">截面积</span>
|
||||
<span class="kv-value">{{ result.cross_section_mm2 }} <span class="kv-unit">mm²</span></span>
|
||||
<span class="kv-label">焊缝速限</span>
|
||||
<span class="kv-value">{{ result.weld_speed_limit }} <span class="kv-unit">m/min</span></span>
|
||||
<span class="kv-label">加速附加张力</span>
|
||||
<span class="kv-value">{{ result.accel_tension }} <span class="kv-unit">kN</span></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ─── 张力图示 ─── -->
|
||||
<div class="card" style="flex:1;">
|
||||
<div class="card-header">张力分布图示</div>
|
||||
<div class="card-body">
|
||||
<div class="tension-diagram" v-if="result">
|
||||
<div
|
||||
v-for="(zone, key, idx) in result.zones"
|
||||
:key="key"
|
||||
class="zone-block"
|
||||
>
|
||||
<!-- 连接线 -->
|
||||
<div
|
||||
v-if="idx > 0"
|
||||
class="zone-line"
|
||||
:style="{ background: lineColor(zone.tension_kN) }"
|
||||
></div>
|
||||
<!-- 辊子 -->
|
||||
<div class="roller-wrap">
|
||||
<div class="roller" :style="{ borderColor: lineColor(zone.tension_kN) }">
|
||||
<span class="roller-label">{{ rollerIcon(key) }}</span>
|
||||
</div>
|
||||
<div class="zone-name">{{ zone.name_cn }}</div>
|
||||
<div class="zone-tension" :style="{ color: lineColor(zone.tension_kN) }">
|
||||
{{ zone.tension_kN }} kN
|
||||
</div>
|
||||
<div class="zone-ratio">×{{ zone.ratio }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="empty-hint">
|
||||
输入参数后点击「计算张力」查看张力分布
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ─── 张力明细表 ─── -->
|
||||
<div class="card mt8" v-if="result">
|
||||
<div class="card-header">各段张力详情</div>
|
||||
<div class="table-scroll">
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>工艺段</th>
|
||||
<th>名称</th>
|
||||
<th>基准系数</th>
|
||||
<th>校准系数 K</th>
|
||||
<th>设定张力 (kN)</th>
|
||||
<th>状态</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="(zone, key) in result.zones" :key="key">
|
||||
<td class="td-num">{{ key }}</td>
|
||||
<td>{{ zone.name_cn }}</td>
|
||||
<td class="td-num">{{ zone.ratio }}</td>
|
||||
<td class="td-num" :style="{ color: Math.abs(zone.k_cal - 1) > 0.05 ? '#f0a500' : '#8b949e' }">
|
||||
{{ zone.k_cal.toFixed(4) }}
|
||||
<span v-if="Math.abs(zone.k_cal - 1) > 0.005" style="font-size:10px;">
|
||||
({{ zone.k_cal > 1 ? '+' : '' }}{{ ((zone.k_cal - 1) * 100).toFixed(1) }}%)
|
||||
</span>
|
||||
</td>
|
||||
<td class="td-num" style="color:#00c8ff;font-weight:600;">{{ zone.tension_kN }}</td>
|
||||
<td>
|
||||
<span :class="['badge', tensionBadge(zone.tension_kN)]">
|
||||
{{ tensionStatus(zone.tension_kN) }}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ─── 张力模型校准 ─── -->
|
||||
<div class="card mt8">
|
||||
<div class="card-header">
|
||||
张力模型校准(各区段独立)
|
||||
<span v-if="calibratedZoneCount > 0" class="ch-badge" style="background:#3a2a00;color:#f0a500;">
|
||||
{{ calibratedZoneCount }} 个区段已修正
|
||||
</span>
|
||||
<button class="btn btn-outline" style="margin-left:auto;padding:2px 10px;font-size:11px;"
|
||||
@click="resetCalib">全部重置</button>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="calib-layout">
|
||||
<div class="calib-form">
|
||||
<div class="calib-hint">在张力计或张力传感器读数与模型设定值存在系统性偏差时录入修正数据。</div>
|
||||
<div class="flex-col" style="gap:8px;margin-top:10px;">
|
||||
<div class="form-field">
|
||||
<div class="kv-label">测量位置</div>
|
||||
<select v-model="calib.zone" class="kv-input">
|
||||
<option value="inlet">入口张力</option>
|
||||
<option value="s1_roller">S1夹送辊</option>
|
||||
<option value="acid_entry">酸洗入口</option>
|
||||
<option value="acid1">1#酸槽</option>
|
||||
<option value="acid2">2#酸槽</option>
|
||||
<option value="acid3">3#酸槽</option>
|
||||
<option value="rinse">漂洗段</option>
|
||||
<option value="leveler">拉矫机</option>
|
||||
<option value="s2_roller">S2夹送辊</option>
|
||||
<option value="outlet">出口张力</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-field">
|
||||
<div class="kv-label">实测张力 (kN)</div>
|
||||
<input v-model.number="calib.measured_kn" type="number" class="kv-input" step="0.1" min="0" />
|
||||
</div>
|
||||
<div v-if="result && calib.zone" class="calib-predict-row">
|
||||
<span class="kv-label">当前K</span>
|
||||
<span class="kv-value" :style="{ color: Math.abs((calib.zone_kcal[calib.zone]||1)-1)>0.05?'#f0a500':'#8b949e' }">
|
||||
{{ (calib.zone_kcal[calib.zone] || 1).toFixed(4) }}
|
||||
</span>
|
||||
<span class="kv-label" style="margin-left:12px;">预测值</span>
|
||||
<span class="kv-value">{{ result.zones[calib.zone] ? result.zones[calib.zone].tension_kN : '—' }} <span class="kv-unit">kN</span></span>
|
||||
<span class="kv-label" style="margin-left:12px;">偏差</span>
|
||||
<span class="kv-value" v-if="result.zones[calib.zone] && calib.measured_kn"
|
||||
:style="{ color: Math.abs(calib.measured_kn - result.zones[calib.zone].tension_kN) > result.zones[calib.zone].tension_kN * 0.1 ? '#f0a500' : '#28a745' }">
|
||||
{{ (calib.measured_kn - result.zones[calib.zone].tension_kN > 0 ? '+' : '') + (calib.measured_kn - result.zones[calib.zone].tension_kN).toFixed(2) }} kN
|
||||
</span>
|
||||
</div>
|
||||
<div class="form-field">
|
||||
<div class="kv-label">备注(可选)</div>
|
||||
<input v-model="calib.note" type="text" class="kv-input" placeholder="卷号/传感器编号/描述" />
|
||||
</div>
|
||||
<button class="btn btn-primary fw" :disabled="calib.loading || !calib.measured_kn" @click="submitCalib">
|
||||
{{ calib.loading ? '提交中...' : '提交修正数据' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="calib-history">
|
||||
<div class="sec-title" style="margin-bottom:8px;">修正记录</div>
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr><th>时间</th><th>K 前</th><th>K 后</th><th>位置</th><th>实测(kN)</th><th>预测(kN)</th><th>备注</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="h in tensionHistory" :key="h.ts">
|
||||
<td class="td-muted">{{ h.ts.slice(5,16) }}</td>
|
||||
<td class="td-num">{{ h.k_before }}</td>
|
||||
<td class="td-num" :style="{ color: h.k_after > h.k_before ? '#28a745' : '#f0a500' }">{{ h.k_after }}</td>
|
||||
<td>{{ h.input.zone }}</td>
|
||||
<td class="td-num">{{ h.input.measured_kn }}</td>
|
||||
<td class="td-num">{{ h.input.predicted_kn }}</td>
|
||||
<td class="td-muted">{{ h.note || '—' }}</td>
|
||||
</tr>
|
||||
<tr v-if="!tensionHistory.length">
|
||||
<td colspan="7" class="td-muted" style="text-align:center;padding:14px;">暂无修正记录</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ─── 批量张力设定表 ─── -->
|
||||
<div class="card mt8">
|
||||
<div class="card-header">
|
||||
批量张力设定
|
||||
<span class="ch-badge">共 {{ batchData.length }} 卷</span>
|
||||
</div>
|
||||
<div class="table-scroll">
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>卷号</th>
|
||||
<th>厚度(mm)</th>
|
||||
<th>宽度(mm)</th>
|
||||
<th>屈服强度(MPa)</th>
|
||||
<th>T_max(kN)</th>
|
||||
<th>S1张力(kN)</th>
|
||||
<th>酸槽张力(kN)</th>
|
||||
<th>S2张力(kN)</th>
|
||||
<th>状态</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="row in batchData" :key="row.coil_no">
|
||||
<td class="td-num">{{ row.coil_no }}</td>
|
||||
<td class="td-num">{{ row.thickness }}</td>
|
||||
<td class="td-num">{{ row.width }}</td>
|
||||
<td class="td-num">{{ row.yield_strength }}</td>
|
||||
<td class="td-num" style="color:#00c8ff;">{{ row.t_max }}</td>
|
||||
<td class="td-num">{{ row.s1 }}</td>
|
||||
<td class="td-num">{{ row.acid }}</td>
|
||||
<td class="td-num">{{ row.s2 }}</td>
|
||||
<td>
|
||||
<span :class="['badge', row.status === '已确认' ? 'badge-green' : 'badge-yellow']">
|
||||
{{ row.status }}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="!batchData.length">
|
||||
<td colspan="9" class="td-muted" style="text-align:center;padding:20px;">暂无批量数据</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { predictTension, getCoils, getCalibration, calibrateTension, resetCalibration } from '@/api'
|
||||
|
||||
// 钢种屈服强度参考值(MPa)
|
||||
const YIELD_MAP = { Q195:195, Q215:215, Q235:235, SPHC:280, SPHD:270, SPHE:260, SS400:245, SAPH440:440, QSTE420TM:420 }
|
||||
|
||||
export default {
|
||||
name: 'TensionModel',
|
||||
data() {
|
||||
return {
|
||||
loading: false,
|
||||
form: {
|
||||
thickness: 3.0,
|
||||
width: 1000,
|
||||
yield_strength: 350,
|
||||
tension_coef: 0.25,
|
||||
},
|
||||
result: null,
|
||||
batchData: [],
|
||||
batchLoading: false,
|
||||
calib: {
|
||||
zone_kcal: {},
|
||||
zone: 's1_roller',
|
||||
measured_kn: null,
|
||||
note: '',
|
||||
loading: false,
|
||||
},
|
||||
calibHistory: [],
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
tensionHistory() {
|
||||
return this.calibHistory.filter(h => h.model === 'tension').slice(0, 10)
|
||||
},
|
||||
calibratedZoneCount() {
|
||||
return Object.values(this.calib.zone_kcal).filter(k => Math.abs(k - 1.0) > 0.005).length
|
||||
},
|
||||
},
|
||||
async mounted() {
|
||||
await this.doCalc()
|
||||
await this.fetchBatch()
|
||||
await this.loadCalibration()
|
||||
},
|
||||
methods: {
|
||||
loadPreset() {
|
||||
this.form = { thickness: 3.0, width: 1000, yield_strength: 350, tension_coef: 0.25 }
|
||||
this.doCalc()
|
||||
},
|
||||
async fetchBatch() {
|
||||
this.batchLoading = true
|
||||
try {
|
||||
const res = await getCoils({ page_size: 20 })
|
||||
const coils = res.data?.items || []
|
||||
// 对最近的卷用模型计算张力设定
|
||||
this.batchData = coils
|
||||
.filter(c => c.thickness && c.width && c.steel_grade)
|
||||
.map(c => {
|
||||
const ys = YIELD_MAP[c.steel_grade] || 280
|
||||
const coef = 0.25
|
||||
const tmax = +((coef * ys * c.thickness * c.width) / 1000).toFixed(2)
|
||||
return {
|
||||
coil_no: c.coil_no,
|
||||
thickness: c.thickness,
|
||||
width: c.width,
|
||||
steel_grade: c.steel_grade,
|
||||
yield_strength: ys,
|
||||
t_max: tmax,
|
||||
s1: +(tmax * 0.85).toFixed(2),
|
||||
acid: +(tmax * 0.72).toFixed(2),
|
||||
s2: +(tmax * 0.88).toFixed(2),
|
||||
status: c.status,
|
||||
}
|
||||
})
|
||||
} catch (e) {
|
||||
// silent
|
||||
} finally {
|
||||
this.batchLoading = false
|
||||
}
|
||||
},
|
||||
async doCalc() {
|
||||
if (!this.form.thickness || !this.form.width || !this.form.yield_strength) return
|
||||
this.loading = true
|
||||
try {
|
||||
const res = await predictTension(this.form)
|
||||
this.result = res.data
|
||||
} catch (e) {
|
||||
this.$message.error('计算失败:' + (e.response?.data?.detail || e.message))
|
||||
} finally {
|
||||
this.loading = false
|
||||
}
|
||||
},
|
||||
lineColor(kn) {
|
||||
if (!this.result) return '#30363d'
|
||||
const ratio = kn / this.result.T_max
|
||||
if (ratio >= 0.9) return '#da3633'
|
||||
if (ratio >= 0.75) return '#f0a500'
|
||||
return '#28a745'
|
||||
},
|
||||
rollerIcon(key) {
|
||||
const icons = {
|
||||
inlet: 'IN', s1_roller: 'S1', acid_entry: 'AE',
|
||||
acid1: 'A1', acid2: 'A2', acid3: 'A3',
|
||||
rinse: 'RI', leveler: 'LV', s2_roller: 'S2', outlet: 'OUT'
|
||||
}
|
||||
return icons[key] || '—'
|
||||
},
|
||||
tensionBadge(kn) {
|
||||
if (!this.result) return 'badge-gray'
|
||||
const r = kn / this.result.T_max
|
||||
if (r >= 0.9) return 'badge-red'
|
||||
if (r >= 0.75) return 'badge-yellow'
|
||||
return 'badge-green'
|
||||
},
|
||||
tensionStatus(kn) {
|
||||
if (!this.result) return '—'
|
||||
const r = kn / this.result.T_max
|
||||
if (r >= 0.9) return '高张力'
|
||||
if (r >= 0.75) return '正常偏高'
|
||||
return '正常'
|
||||
},
|
||||
async loadCalibration() {
|
||||
try {
|
||||
const res = await getCalibration()
|
||||
this.calib.zone_kcal = res.data?.tension_zone_kcal || {}
|
||||
this.calibHistory = res.data?.history || []
|
||||
} catch (e) { /* silent */ }
|
||||
},
|
||||
async submitCalib() {
|
||||
if (!this.calib.measured_kn) return
|
||||
this.calib.loading = true
|
||||
try {
|
||||
await calibrateTension({
|
||||
thickness: this.form.thickness,
|
||||
width: this.form.width,
|
||||
yield_strength: this.form.yield_strength,
|
||||
tension_coef: this.form.tension_coef,
|
||||
zone: this.calib.zone,
|
||||
measured_kn: this.calib.measured_kn,
|
||||
note: this.calib.note,
|
||||
})
|
||||
this.$message.success('修正数据已提交,张力模型系数已更新')
|
||||
this.calib.measured_kn = null
|
||||
this.calib.note = ''
|
||||
await this.loadCalibration()
|
||||
await this.doCalc()
|
||||
} catch (e) {
|
||||
this.$message.error('提交失败:' + (e.response?.data?.detail || e.message))
|
||||
} finally {
|
||||
this.calib.loading = false
|
||||
}
|
||||
},
|
||||
async resetCalib() {
|
||||
try {
|
||||
await this.$confirm('确认将张力模型校准系数重置为 1.0?', '重置确认', { type: 'warning' })
|
||||
await resetCalibration('tension')
|
||||
this.$message.success('已重置')
|
||||
await this.loadCalibration()
|
||||
await this.doCalc()
|
||||
} catch (e) { /* cancelled */ }
|
||||
},
|
||||
kCalColor(k) {
|
||||
const d = Math.abs(k - 1.0)
|
||||
if (d < 0.05) return '#1a3a1f'
|
||||
if (d < 0.15) return '#3a2a00'
|
||||
return '#3a1a1a'
|
||||
},
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@import '@/assets/styles/variables';
|
||||
|
||||
.form-field { display: flex; flex-direction: column; gap: 5px; }
|
||||
.mt8 { margin-top: 8px; }
|
||||
|
||||
.calib-layout {
|
||||
display: flex;
|
||||
gap: 20px;
|
||||
align-items: flex-start;
|
||||
}
|
||||
.calib-form {
|
||||
flex: 0 0 300px;
|
||||
}
|
||||
.calib-history {
|
||||
flex: 1;
|
||||
overflow-x: auto;
|
||||
}
|
||||
.calib-hint {
|
||||
font-size: 11px;
|
||||
color: $text-muted;
|
||||
line-height: 1.6;
|
||||
border-left: 2px solid $border;
|
||||
padding-left: 8px;
|
||||
}
|
||||
.calib-predict-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 10px;
|
||||
background: rgba(255,255,255,.03);
|
||||
border-radius: 4px;
|
||||
border: 1px solid $border;
|
||||
}
|
||||
|
||||
.tension-diagram {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
overflow-x: auto;
|
||||
padding: 20px 10px;
|
||||
gap: 0;
|
||||
min-height: 160px;
|
||||
&::-webkit-scrollbar { height: 4px; }
|
||||
&::-webkit-scrollbar-thumb { background: $border; }
|
||||
}
|
||||
|
||||
.zone-block {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.zone-line {
|
||||
width: 32px;
|
||||
height: 3px;
|
||||
flex-shrink: 0;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.roller-wrap {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.roller {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border-radius: 50%;
|
||||
border: 2px solid $border;
|
||||
background: $bg-panel;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.zone-name {
|
||||
font-size: 10px;
|
||||
color: $text-secondary;
|
||||
text-align: center;
|
||||
max-width: 60px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.zone-tension {
|
||||
font-size: 11px;
|
||||
font-family: $font-mono;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.zone-ratio {
|
||||
font-size: 10px;
|
||||
color: $text-muted;
|
||||
}
|
||||
|
||||
.empty-hint {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 160px;
|
||||
color: $text-muted;
|
||||
font-size: 13px;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user