feat: 移除PDI和订单号字段,新增设备巡检模块

- 从物料跟踪页面移除订单号列和表单字段
- 从导航菜单移除PDI管理,添加设备巡检
- 新增InspectionLocation和InspectionRecord后端模型和API
- 新增设备巡检前端页面(左侧点位列表,右侧设备和历史记录)
This commit is contained in:
2026-05-27 16:38:40 +08:00
commit 193da0018f
86 changed files with 11379 additions and 0 deletions

18
frontend/src/App.vue Normal file
View 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
View 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
View 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')

View 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

View 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; }
}

View 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
View 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')

View 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
})

View 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'
})

View 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')
},
}
}

View 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"> ({{ consResult.cooling_water_unit_m3_per_t }} /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>

View 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>

View 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>

View 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>

View 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 }}&nbsp;<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>

View 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 过程控制系统 &nbsp;|&nbsp; 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 &nbsp;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>

View 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>系统就绪 &nbsp;|&nbsp; 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>

View 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>

View 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>
&nbsp; 失败 <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
View 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
View 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>

View 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>流量 (/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/)</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>

View 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>

View 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/)</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/)</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/)</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>

View 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>