feat: 初始化HEFA-L2 PDI管理系统项目

添加前端Vue2项目结构,包括ElementUI集成、路由配置和API模块
实现后端FastAPI服务,包含Oracle数据库连接和PDI CRUD接口
添加OPC-UA轮询服务,支持跟踪图数据同步到Oracle
提供SQLite镜像数据库用于本地开发和快速查询
包含完整的部署脚本和文档说明
This commit is contained in:
2026-04-09 16:05:20 +08:00
commit d8b142bb4a
24 changed files with 18820 additions and 0 deletions

13825
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

19
frontend/package.json Normal file
View File

@@ -0,0 +1,19 @@
{
"name": "hefa-l2-frontend",
"version": "1.0.0",
"private": true,
"scripts": {
"serve": "vue-cli-service serve",
"build": "vue-cli-service build"
},
"dependencies": {
"axios": "^1.6.8",
"element-ui": "^2.15.14",
"vue": "^2.7.16",
"vue-router": "^3.6.5"
},
"devDependencies": {
"@vue/cli-service": "^5.0.8",
"vue-template-compiler": "^2.7.16"
}
}

View File

@@ -0,0 +1,11 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1.0" />
<title>HEFA-L2 PDI管理系统</title>
</head>
<body>
<div id="app"></div>
</body>
</html>

321
frontend/src/App.vue Normal file
View File

@@ -0,0 +1,321 @@
<template>
<div id="app">
<div class="layout">
<!-- Sidebar -->
<nav class="sidebar">
<div class="brand">
<span class="brand-mark">TDH</span>
<span class="brand-name">天地和金属制品-L2</span>
</div>
<ul class="nav-list">
<li v-for="item in nav" :key="item.path"
class="nav-item"
:class="{ active: $route.path === item.path }"
@click="$router.push(item.path)">
<i :class="item.icon"></i>
<span>{{ item.label }}</span>
</li>
</ul>
<div class="sidebar-footer">{{ now }}</div>
</nav>
<!-- Content -->
<div class="main">
<header class="topbar">
<span class="page-title">{{ $route.meta.title }}</span>
</header>
<div class="page-body">
<router-view />
</div>
</div>
</div>
</div>
</template>
<script>
export default {
name: 'App',
data() {
return {
now: '',
nav: [
{ path: '/pdi', label: 'PDI 计划管理', icon: 'el-icon-document' },
{ path: '/trackmap', label: '跟踪图监控', icon: 'el-icon-monitor' },
{ path: '/opc', label: 'OPC 配置', icon: 'el-icon-setting' }
]
}
},
mounted() {
this.tick()
setInterval(this.tick, 1000)
},
methods: {
tick() { this.now = new Date().toLocaleString('zh-CN') }
}
}
</script>
<style>
/* ── Reset ── */
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif;
font-size: 13px;
background: #f6f6f6;
color: #1a1a1a;
-webkit-font-smoothing: antialiased;
}
#app, .layout { height: 100vh; display: flex; }
/* ── Sidebar ── */
.sidebar {
width: 200px;
flex-shrink: 0;
background: #e8e8e8;
border-right: 1px solid #c8c8c8;
display: flex;
flex-direction: column;
}
.brand {
padding: 18px 16px 14px;
border-bottom: 1px solid #c8c8c8;
display: flex;
align-items: center;
gap: 10px;
}
.brand-mark {
font-size: 11px;
font-weight: 700;
letter-spacing: 1px;
background: #3a3a3a;
color: #f6f6f6;
padding: 3px 7px;
border-radius: 2px;
}
.brand-name {
font-size: 14px;
font-weight: 600;
color: #2a2a2a;
letter-spacing: 1px;
}
.nav-list {
list-style: none;
flex: 1;
padding: 8px 0;
}
.nav-item {
padding: 10px 16px;
cursor: pointer;
color: #555;
display: flex;
align-items: center;
gap: 9px;
font-size: 13px;
border-left: 3px solid transparent;
transition: background 0.12s, color 0.12s;
}
.nav-item:hover { background: #ddd; color: #222; }
.nav-item.active {
background: #d4d4d4;
color: #111;
border-left-color: #3a3a3a;
font-weight: 600;
}
.sidebar-footer {
padding: 10px 16px;
font-size: 11px;
color: #888;
border-top: 1px solid #c8c8c8;
font-variant-numeric: tabular-nums;
}
/* ── Main ── */
.main { flex: 1; display: flex; flex-direction: column; overflow: hidden; }
.topbar {
height: 46px;
background: #f0f0f0;
border-bottom: 1px solid #d0d0d0;
display: flex;
align-items: center;
padding: 0 20px;
}
.page-title {
font-size: 13px;
font-weight: 600;
color: #222;
letter-spacing: 0.5px;
text-transform: uppercase;
}
.page-body {
flex: 1;
overflow-y: auto;
padding: 18px 20px;
background: #f6f6f6;
}
/* ── Element-UI overrides: flat metal ── */
.el-table {
font-size: 12px;
color: #1a1a1a !important;
background: #fff !important;
}
.el-table th.el-table__cell {
background: #efefef !important;
color: #444 !important;
font-weight: 600;
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.5px;
border-bottom: 1px solid #d8d8d8 !important;
border-color: #e0e0e0 !important;
}
.el-table td.el-table__cell {
border-color: #ebebeb !important;
background: transparent !important;
}
.el-table--striped .el-table__body tr.el-table__row--striped td.el-table__cell {
background: #fafafa !important;
}
.el-table__body tr:hover > td.el-table__cell {
background: #f2f2f2 !important;
}
.el-table--border { border-color: #d8d8d8 !important; }
.el-table--border::after, .el-table--border::before,
.el-table::before, .el-table::after { background: #d8d8d8 !important; }
/* Dialog */
.el-dialog {
border-radius: 3px !important;
box-shadow: 0 2px 12px rgba(0,0,0,0.12) !important;
}
.el-dialog__header { border-bottom: 1px solid #e8e8e8; padding: 14px 20px; }
.el-dialog__title { font-size: 14px; font-weight: 600; color: #222; }
.el-dialog__body { padding: 20px; }
.el-dialog__footer { border-top: 1px solid #e8e8e8; padding: 12px 20px; }
/* Form */
.el-form-item__label { color: #555 !important; font-size: 12px; }
.el-input__inner {
background: #fff !important;
border-color: #d0d0d0 !important;
border-radius: 2px !important;
color: #1a1a1a !important;
height: 30px !important;
line-height: 30px !important;
}
.el-input__inner:focus { border-color: #666 !important; box-shadow: none !important; }
.el-input-number .el-input__inner { text-align: left; }
/* Buttons */
.el-button {
border-radius: 2px !important;
font-size: 12px !important;
}
.el-button--primary {
background: #3a3a3a !important;
border-color: #3a3a3a !important;
color: #fff !important;
}
.el-button--primary:hover {
background: #555 !important;
border-color: #555 !important;
}
.el-button--danger {
background: #c0392b !important;
border-color: #c0392b !important;
}
.el-button--warning {
background: #b7780a !important;
border-color: #b7780a !important;
color: #fff !important;
}
.el-button--default {
background: #f0f0f0 !important;
border-color: #c8c8c8 !important;
color: #333 !important;
}
.el-button--default:hover {
background: #e4e4e4 !important;
}
.el-button--text { color: #333 !important; }
.el-button--text:hover { color: #000 !important; }
/* Pagination */
.el-pagination { background: transparent; }
.el-pagination .el-pager li {
background: #f0f0f0 !important;
border: 1px solid #d8d8d8 !important;
border-radius: 2px !important;
color: #444 !important;
min-width: 28px;
height: 28px;
line-height: 26px;
font-size: 12px;
}
.el-pagination .el-pager li.active {
background: #3a3a3a !important;
border-color: #3a3a3a !important;
color: #fff !important;
}
.el-pagination button {
background: #f0f0f0 !important;
border: 1px solid #d8d8d8 !important;
border-radius: 2px !important;
color: #444 !important;
}
/* Tags */
.el-tag { border-radius: 2px !important; font-size: 11px !important; }
.el-tag--success { background: #e8f5e9 !important; color: #2e7d32 !important; border-color: #c8e6c9 !important; }
.el-tag--danger { background: #fdecea !important; color: #c0392b !important; border-color: #f5c6c2 !important; }
.el-tag--warning { background: #fff8e1 !important; color: #8a6000 !important; border-color: #ffe082 !important; }
.el-tag--info { background: #f5f5f5 !important; color: #666 !important; border-color: #d8d8d8 !important; }
/* Select dropdown */
.el-select-dropdown {
border-color: #d0d0d0 !important;
border-radius: 2px !important;
}
.el-select-dropdown__item { font-size: 12px; color: #333 !important; }
.el-select-dropdown__item.hover,
.el-select-dropdown__item:hover { background: #f2f2f2 !important; }
/* Loading */
.el-loading-mask { background: rgba(246,246,246,0.8) !important; }
/* Utility classes */
.panel {
background: #fff;
border: 1px solid #e0e0e0;
border-radius: 2px;
padding: 14px 16px;
margin-bottom: 14px;
}
.panel-title {
font-size: 11px;
font-weight: 700;
color: #444;
text-transform: uppercase;
letter-spacing: 0.8px;
margin-bottom: 12px;
padding-bottom: 8px;
border-bottom: 1px solid #ebebeb;
}
.section-title {
font-size: 11px;
font-weight: 700;
color: #555;
text-transform: uppercase;
letter-spacing: 0.8px;
margin: 14px 0 8px;
padding-left: 8px;
border-left: 2px solid #888;
}
</style>

45
frontend/src/api/index.js Normal file
View File

@@ -0,0 +1,45 @@
import axios from 'axios'
const http = axios.create({
baseURL: '/api',
timeout: 15000
})
http.interceptors.response.use(
res => res.data,
err => {
const msg = err.response?.data?.detail || err.message || '请求失败'
return Promise.reject(new Error(msg))
}
)
export const pdiApi = {
list: (params) => http.get('/pdi', { params }),
get: (coilid) => http.get(`/pdi/${encodeURIComponent(coilid)}`),
create: (data) => http.post('/pdi', data),
update: (coilid, data) => http.put(`/pdi/${encodeURIComponent(coilid)}`, data),
delete: (coilid) => http.delete(`/pdi/${encodeURIComponent(coilid)}`)
}
export const trackmapApi = {
list: () => http.get('/trackmap')
}
export const opcApi = {
getConfig: () => http.get('/opc/config'),
saveConfig: (data) => http.post('/opc/config', data),
getStatus: () => http.get('/opc/status'),
restart: () => http.post('/opc/restart')
}
export const syncApi = {
sync: () => http.post('/sync')
}
// 钢种查询 API
export const gradeApi = {
getEntryGrades: () => http.get('/grades/entry'),
getProductGrades: () => http.get('/grades/product'),
getL2ModelGrades: () => http.get('/grades/l2model'),
getNextNumbers: () => http.get('/pdi/next-numbers')
}

13
frontend/src/main.js Normal file
View File

@@ -0,0 +1,13 @@
import Vue from 'vue'
import ElementUI from 'element-ui'
import 'element-ui/lib/theme-chalk/index.css'
import App from './App.vue'
import router from './router'
Vue.use(ElementUI)
Vue.config.productionTip = false
new Vue({
router,
render: h => h(App)
}).$mount('#app')

16
frontend/src/router.js Normal file
View File

@@ -0,0 +1,16 @@
import Vue from 'vue'
import VueRouter from 'vue-router'
import PdiList from './views/PdiList.vue'
import TrackMap from './views/TrackMap.vue'
import OpcConfig from './views/OpcConfig.vue'
Vue.use(VueRouter)
export default new VueRouter({
routes: [
{ path: '/', redirect: '/pdi' },
{ path: '/pdi', component: PdiList, meta: { title: 'PDI计划管理' } },
{ path: '/trackmap', component: TrackMap, meta: { title: '跟踪图监控' } },
{ path: '/opc', component: OpcConfig, meta: { title: 'OPC配置' } }
]
})

View File

@@ -0,0 +1,124 @@
<template>
<div>
<div class="panel">
<div class="panel-title">OPC-UA 服务器配置</div>
<el-form :model="form" label-width="155px" size="small">
<el-form-item label="服务器地址">
<el-input v-model="form.opc_url" placeholder="opc.tcp://192.168.1.100:4840" style="width:360px" />
<span class="hint">opc.tcp://IP:PORT</span>
</el-form-item>
<el-form-item label="计数器节点 ID">
<el-input v-model="form.counter_node" placeholder="ns=2;s=PL.TRACKMAP.COUNTER" style="width:360px" />
<span class="hint">节点值变化时触发采集</span>
</el-form-item>
<el-form-item label="轮询间隔(秒)">
<el-input-number v-model="form.poll_interval" :min="1" :max="60" style="width:120px" />
</el-form-item>
</el-form>
<div class="panel-title" style="margin-top:16px">跟踪图节点映射</div>
<div style="font-size:12px;color:#888;margin-bottom:10px">
Oracle列名 OPC节点ID必须包含 <b>position</b>
</div>
<div v-for="(item,idx) in nodeList" :key="idx" class="node-row">
<el-input v-model="item.col" placeholder="列名" size="small" style="width:160px" />
<span class="arrow"></span>
<el-input v-model="item.node" placeholder="节点ID" size="small" style="width:320px" />
<el-button type="text" size="mini" style="color:#c0392b" @click="removeNode(idx)">删除</el-button>
</div>
<el-button size="mini" icon="el-icon-plus" @click="addNode" style="margin-top:8px">添加映射</el-button>
<div style="margin-top:20px;display:flex;gap:10px">
<el-button type="primary" size="small" @click="saveConfig" :loading="saving">保存并重启OPC</el-button>
<el-button size="small" @click="loadConfig" :loading="loading">重新加载</el-button>
<el-button size="small" @click="restartOpc">仅重启OPC</el-button>
</div>
</div>
<div class="panel">
<div class="panel-title">运行状态</div>
<div style="display:flex;gap:24px;font-size:12px;margin-bottom:12px">
<span>服务<el-tag :type="status.running?'success':'danger'" size="mini">{{ status.running?'运行中':'已停止' }}</el-tag></span>
<span>计数器<b>{{ status.last_counter??'--' }}</b></span>
<span style="color:#888">更新{{ status.last_update||'--' }}</span>
</div>
<div class="panel-title">最新日志</div>
<div class="log-box" ref="logBox">
<div v-for="(line,i) in statusLog" :key="i" class="log-line">{{ line }}</div>
<div v-if="!statusLog.length" style="color:#aaa">暂无日志</div>
</div>
</div>
</div>
</template>
<script>
import { opcApi } from '../api/index'
export default {
name: 'OpcConfig',
data() {
return {
loading: false, saving: false,
form: { opc_url: '', counter_node: '', poll_interval: 2 },
nodeList: [],
status: { running: false, last_counter: null, last_update: null, log: [] },
statusTimer: null
}
},
computed: {
statusLog() { return this.status.log || [] }
},
created() { this.loadConfig(); this.loadStatus() },
mounted() { this.statusTimer = setInterval(this.loadStatus, 3000) },
beforeDestroy() { clearInterval(this.statusTimer) },
methods: {
async loadConfig() {
this.loading = true
try {
const cfg = await opcApi.getConfig()
this.form.opc_url = cfg.opc_url
this.form.counter_node = cfg.counter_node
this.form.poll_interval = cfg.poll_interval
this.nodeList = Object.entries(cfg.trackmap_nodes || {}).map(([col, node]) => ({ col, node }))
} catch (e) { this.$message.error('加载失败: ' + e.message) }
finally { this.loading = false }
},
async loadStatus() {
try {
this.status = await opcApi.getStatus()
this.$nextTick(() => {
if (this.$refs.logBox) this.$refs.logBox.scrollTop = this.$refs.logBox.scrollHeight
})
} catch (e) { /* silent */ }
},
addNode() { this.nodeList.push({ col: '', node: '' }) },
removeNode(idx) { this.nodeList.splice(idx, 1) },
async saveConfig() {
const trackmap_nodes = {}
for (const item of this.nodeList) {
if (item.col.trim() && item.node.trim()) trackmap_nodes[item.col.trim()] = item.node.trim()
}
this.saving = true
try {
await opcApi.saveConfig({ ...this.form, trackmap_nodes })
this.$message.success('配置已保存OPC服务已重启')
this.loadStatus()
} catch (e) { this.$message.error('保存失败: ' + e.message) }
finally { this.saving = false }
},
async restartOpc() {
try {
await opcApi.restart()
this.$message.success('OPC服务已重启')
} catch (e) { this.$message.error(e.message) }
}
}
}
</script>
<style scoped>
.hint { font-size:11px; color:#999; margin-left:10px; }
.node-row { display:flex; align-items:center; gap:8px; margin-bottom:7px; padding:5px 8px; background:#fafafa; border:1px solid #eee; border-radius:2px; }
.arrow { font-size:14px; color:#888; font-weight:600; }
.log-box { background:#fafafa; border:1px solid #e8e8e8; border-radius:2px; padding:8px 12px; height:180px; overflow-y:auto; font-family:monospace; }
.log-line { font-size:11px; color:#555; line-height:1.7; border-bottom:1px solid #f0f0f0; }
</style>

View File

@@ -0,0 +1,553 @@
<template>
<div>
<!-- Toolbar -->
<div class="panel" style="display:flex;align-items:center;gap:10px;flex-wrap:wrap">
<el-input v-model="query.coilid" placeholder="卷号 COILID" clearable size="small" style="width:150px" />
<el-input v-model="query.steel_grade" placeholder="钢种" clearable size="small" style="width:130px" />
<el-select v-model="query.status" placeholder="状态" clearable size="small" style="width:100px">
<el-option label="计划" :value="0" />
<el-option label="鞍座" :value="1" />
<el-option label="开卷" :value="2" />
<el-option label="完成" :value="3" />
<el-option label="焊接" :value="4" />
<el-option label="待轧" :value="5" />
<el-option label="轧制" :value="6" />
<el-option label="拒绝" :value="9" />
</el-select>
<el-button size="small" @click="loadData(1)">查询</el-button>
<el-button size="small" @click="resetQuery">重置</el-button>
<span style="flex:1"></span>
<el-button type="primary" size="small" icon="el-icon-plus" @click="openCreate">新增</el-button>
<el-button size="small" icon="el-icon-refresh" :loading="syncing" @click="doSync">同步OracleSQLite</el-button>
<!-- 定时刷新配置 -->
<el-checkbox v-model="config.autoRefresh" size="small" @change="onAutoRefreshChange">定时刷新</el-checkbox>
<el-input-number :controls="false" v-if="config.autoRefresh" v-model="config.refreshInterval" size="small" :min="5" :max="300" :step="5" style="width:80px" @change="onAutoRefreshChange" />
<span v-if="config.autoRefresh" style="font-size:12px;color:#666">({{ autoRefreshCountdown }}s)</span>
<span style="flex:1"></span>
<span style="font-size:11px;color:#888"> {{ total }} </span>
</div>
<!-- Table -->
<div class="panel" style="padding:0">
<el-table :data="rows" stripe border size="small" v-loading="loading" element-loading-text="加载中..."
style="width:100%" @row-dblclick="openEdit" height="calc(100vh - 220px)">
<el-table-column prop="coilid" label="卷号" width="148" fixed />
<el-table-column prop="rollprogramnb" label="轧制程序号" width="108" />
<el-table-column prop="sequencenb" label="顺序号" width="75" />
<el-table-column label="状态" width="85">
<template slot-scope="{row}">
<el-tag :type="statusTag(row.status)" size="mini">{{ statusLabel(row.status) }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="schedule_code" label="计划号" width="115" />
<el-table-column prop="steel_grade" label="钢种" width="105" />
<el-table-column prop="l2_grade" label="L2钢种" width="105" />
<el-table-column prop="work_order_no" label="合同号" width="125" />
<el-table-column prop="entry_coil_thickness" label="入口厚" width="80" />
<el-table-column prop="entry_coil_width" label="入口宽" width="80" />
<el-table-column prop="exit_coil_thickness" label="出口厚" width="80" />
<el-table-column prop="exit_coil_width" label="出口宽" width="80" />
<el-table-column prop="order_thickness" label="订单厚" width="80" />
<el-table-column prop="coiler_diameter" label="卷筒径" width="75" />
<el-table-column prop="send_flag" label="发送" width="60" />
<el-table-column prop="created_dt" label="创建时间" width="150" />
<el-table-column label="操作" width="110" fixed="right">
<template slot-scope="{row}">
<el-button type="text" size="mini" @click.stop="openEdit(row)">编辑</el-button>
<el-button type="text" size="mini" style="color:#c0392b" @click.stop="doDelete(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
</div>
<!-- Pagination -->
<div style="text-align:right;margin-top:10px">
<el-pagination background layout="total, sizes, prev, pager, next" :total="total" :page-size="query.page_size"
:current-page="query.page" :page-sizes="[20, 50, 100]" @size-change="onSizeChange" @current-change="loadData" />
</div>
<!-- Dialog -->
<el-dialog :title="dialogTitle" :visible.sync="dialogVisible" width="1080px" top="4vh"
:close-on-click-modal="false">
<div style="display:flex;gap:20px;">
<!-- 左侧表单 -->
<div style="flex:1;">
<el-form :model="form" :rules="rules" ref="pdiForm" label-width="100px" size="small">
<div class="section-title">基本信息</div>
<el-row :gutter="14">
<el-col :span="12"><el-form-item label="卷号" prop="coilid">
<el-input v-model="form.coilid" :disabled="isEdit" />
</el-form-item></el-col>
<el-col :span="12"><el-form-item label="计划号">
<el-input v-model="form.schedule_code" />
</el-form-item></el-col>
<el-col :span="12"><el-form-item label="轧制程序号">
<el-input-number v-model="form.rollprogramnb" :controls="false" style="width:100%" />
</el-form-item></el-col>
<el-col :span="12"><el-form-item label="顺序号">
<el-input-number v-model="form.sequencenb" :controls="false" style="width:100%" />
</el-form-item></el-col>
<el-col :span="12"><el-form-item label="来料钢种">
<el-select v-model="form.steel_grade" placeholder="请选择来料钢种" clearable filterable style="width:100%">
<el-option v-for="g in entryGrades" :key="g" :label="g" :value="g" />
</el-select>
</el-form-item></el-col>
<el-col :span="12"><el-form-item label="二级模型钢种">
<el-select v-model="form.l2_grade" placeholder="请选择二级模型钢种" clearable filterable style="width:100%">
<el-option v-for="g in l2ModelGrades" :key="g" :label="g" :value="g" />
</el-select>
</el-form-item></el-col>
<el-col :span="12"><el-form-item label="合同号">
<el-input v-model="form.work_order_no" />
</el-form-item></el-col>
<el-col :span="12"><el-form-item label="成品钢种">
<el-select v-model="form.sg_sign" placeholder="请选择成品钢种" clearable filterable style="width:100%">
<el-option v-for="g in productGrades" :key="g" :label="g" :value="g" />
</el-select>
</el-form-item></el-col>
<el-col :span="12">
<el-form-item label="包装类型">
<el-input v-model="form.packing_type_code" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="卷筒直径">
<el-input-number v-model="form.coiler_diameter" :controls="false" style="width:100%" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="重量模式">
<el-input v-model="form.weight_mode" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="订单质量">
<el-input v-model="form.order_quality" />
</el-form-item>
</el-col>
</el-row>
<div class="section-title">入口参数</div>
<el-row :gutter="14">
<el-col :span="8"><el-form-item label="厚度(mm)">
<el-input-number v-model="form.entry_coil_thickness" :precision="3" :controls="false"
style="width:100%" />
</el-form-item></el-col>
<el-col :span="8"><el-form-item label="厚度最大">
<el-input-number v-model="form.entry_coil_thickness_max" :precision="3" :controls="false"
style="width:100%" />
</el-form-item></el-col>
<el-col :span="8"><el-form-item label="厚度最小">
<el-input-number v-model="form.entry_coil_thickness_min" :precision="3" :controls="false"
style="width:100%" />
</el-form-item></el-col>
<el-col :span="8"><el-form-item label="宽度(mm)">
<el-input-number v-model="form.entry_coil_width" :precision="3" :controls="false"
style="width:100%" />
</el-form-item></el-col>
<el-col :span="8"><el-form-item label="宽度最大">
<el-input-number v-model="form.entry_coil_width_max" :precision="3" :controls="false"
style="width:100%" />
</el-form-item></el-col>
<el-col :span="8"><el-form-item label="宽度最小">
<el-input-number v-model="form.entry_coil_width_min" :precision="3" :controls="false"
style="width:100%" />
</el-form-item></el-col>
<el-col :span="8"><el-form-item label="重量(kg)">
<el-input-number v-model="form.entry_coil_weight" :precision="3" :controls="false"
style="width:100%" />
</el-form-item></el-col>
<el-col :span="8"><el-form-item label="长度(m)">
<el-input-number v-model="form.entry_of_coil_length" :precision="3" :controls="false"
style="width:100%" />
</el-form-item></el-col>
<el-col :span="8"><el-form-item label="外径(mm)">
<el-input-number v-model="form.entry_of_coil_outer_diameter" :precision="3" :controls="false"
style="width:100%" />
</el-form-item></el-col>
</el-row>
<div class="section-title">出口 / 订单参数</div>
<el-row :gutter="14">
<el-col :span="8"><el-form-item label="出口卷号">
<el-input v-model="form.exit_coil_no" />
</el-form-item></el-col>
<el-col :span="8"><el-form-item label="出口厚度(mm)">
<el-input-number v-model="form.exit_coil_thickness" :precision="3" :controls="false"
style="width:100%" />
</el-form-item></el-col>
<el-col :span="8"><el-form-item label="出口宽度(mm)">
<el-input-number v-model="form.exit_coil_width" :precision="3" :controls="false" style="width:100%" />
</el-form-item></el-col>
<el-col :span="8"><el-form-item label="出口重量(kg)">
<el-input-number v-model="form.exit_coil_weight" :precision="3" :controls="false"
style="width:100%" />
</el-form-item></el-col>
<el-col :span="8"><el-form-item label="订单厚度(mm)">
<el-input-number v-model="form.order_thickness" :precision="3" :controls="false" style="width:100%" />
</el-form-item></el-col>
<el-col :span="8"><el-form-item label="订单宽度(mm)">
<el-input-number v-model="form.order_width" :precision="3" :controls="false" style="width:100%" />
</el-form-item></el-col>
</el-row>
<div class="section-title">张力参数</div>
<el-row :gutter="14">
<el-col :span="8"><el-form-item label="开卷张力(kN)">
<el-input-number v-model="form.uncoiler_tension" :precision="3" :controls="false"
style="width:100%" />
</el-form-item></el-col>
<el-col :span="8"><el-form-item label="活套1张力(kN)">
<el-input-number v-model="form.looper_tension_1" :precision="3" :controls="false"
style="width:100%" />
</el-form-item></el-col>
<el-col :span="8"><el-form-item label="平整张力(kN)">
<el-input-number v-model="form.pl_tension" :precision="3" :controls="false" style="width:100%" />
</el-form-item></el-col>
<el-col :span="8"><el-form-item label="活套2张力(kN)">
<el-input-number v-model="form.looper_tension_2" :precision="3" :controls="false"
style="width:100%" />
</el-form-item></el-col>
<el-col :span="8"><el-form-item label="活套3张力(kN)">
<el-input-number v-model="form.looper_tension_3" :precision="3" :controls="false"
style="width:100%" />
</el-form-item></el-col>
</el-row>
<div class="section-title">化学成分 (%)</div>
<el-row :gutter="14">
<el-col :span="6" v-for="el in chemElements" :key="el">
<el-form-item :label="el.toUpperCase()" label-width="60px">
<el-input-number v-model="form[el]" :precision="3" :controls="false" style="width:100%" />
</el-form-item>
</el-col>
</el-row>
</el-form>
</div>
<!-- 右侧历史记录 -->
<div style="width:280px;overflow-y:auto;">
<div class="section-title" style="margin-bottom:10px;">历史预设</div>
<div v-for="(item, index) in formHistory" :key="index" shadow="hover" style="margin-bottom: 4px; padding: 6px; border-radius: 4px; border: 1px solid #e4e7ed;">
<div style="font-size:14px;font-weight:bold;margin-bottom:5px;">钢卷号{{ item.coilid }}</div>
<div style="font-size:12px;color:#666;margin-bottom: 2px;">
钢种{{ item.steel_grade || '-' }} / 计划号{{ item.schedule_code || '-' }}
</div>
<el-button type="primary" size="mini" style="width:100%;" @click="selectHistory(item)">
选择此预设
</el-button>
</div>
<div v-if="formHistory.length === 0" style="text-align:center;color:#999;padding:20px;">
暂无历史预设
</div>
</div>
</div>
<div slot="footer">
<el-checkbox v-model="config.quickAdd" style="margin-right:20px;">不关闭弹窗继续新增下一个</el-checkbox>
<el-checkbox v-model="config.saveHistory">保存为历史预设</el-checkbox>
<el-button style="margin-left: 20px;" size="small" @click="dialogVisible = false">取消</el-button>
<el-button type="primary" size="small" @click="doSave" :loading="saving">保存</el-button>
</div>
</el-dialog>
</div>
</template>
<script>
import { pdiApi, syncApi, gradeApi } from '../api/index'
const EMPTY = () => ({
coilid: '', rollprogramnb: null, sequencenb: null,
schedule_code: '', steel_grade: '', l2_grade: '',
work_order_no: '', order_quality: '', sg_sign: '',
packing_type_code: '', coiler_diameter: null, weight_mode: '',
entry_coil_thickness: null, entry_coil_thickness_max: null, entry_coil_thickness_min: null,
entry_coil_width: null, entry_coil_width_max: null, entry_coil_width_min: null,
entry_coil_weight: null, entry_of_coil_length: null, entry_of_coil_outer_diameter: null,
exit_coil_no: '', exit_coil_thickness: null, exit_coil_width: null, exit_coil_weight: null,
order_thickness: null, order_width: null,
uncoiler_tension: null, looper_tension_1: null, pl_tension: null,
looper_tension_2: null, looper_tension_3: null,
c: null, si: null, mn: null, p: null, s: null, cu: null,
ni: null, cr: null, mo: null, v: null, ti: null, sol_al: null,
nb: null, n: null, b: null, fe: null
})
export default {
name: 'PdiList',
data() {
return {
loading: false, saving: false, syncing: false,
rows: [], total: 0,
query: { page: 1, page_size: 20, coilid: '', steel_grade: '', status: null },
dialogVisible: false, isEdit: false,
form: EMPTY(),
formHistory: [],
// 配置项
config: {
historyLimit: 10, // 历史记录最大数量
quickAdd: false, // 不关闭弹窗,继续新增
saveHistory: true, // 保存为历史预设
autoRefresh: false, // 是否开启定时刷新
refreshInterval: 30 // 刷新间隔(秒)
},
autoRefreshTimer: null, // 定时器句柄
autoRefreshCountdown: 0, // 倒计时秒数
rules: {
coilid: [
{ required: true, message: '卷号不能为空', trigger: 'blur' },
{ min: 12, max: 12, message: '卷号必须为12位', trigger: 'blur' }
]
},
chemElements: ['c', 'si', 'mn', 'p', 's', 'cu', 'ni', 'cr', 'mo', 'v', 'ti', 'sol_al', 'nb', 'n', 'b', 'fe'],
// 钢种下拉选项
entryGrades: [], // 来料钢种
productGrades: [], // 成品钢种
l2ModelGrades: [] // 二级模型钢种
}
},
computed: {
dialogTitle() { return this.isEdit ? '编辑 PDI 记录' : '新增 PDI 记录' }
},
created() {
this.loadData(1)
this.loadGradeOptions()
},
methods: {
async loadData(page) {
if (page) this.query.page = page
this.loading = true
try {
const params = { ...this.query }
if (params.status === null) delete params.status
if (!params.coilid) delete params.coilid
if (!params.steel_grade) delete params.steel_grade
const res = await pdiApi.list(params)
this.rows = res.data
this.total = res.total
} catch (e) {
this.$message.error(e.message)
} finally { this.loading = false }
},
resetQuery() {
this.query = { page: 1, page_size: 20, coilid: '', steel_grade: '', status: null }
this.loadData(1)
},
onSizeChange(size) { this.query.page_size = size; this.loadData(1) },
async openCreate() {
this.isEdit = false
// 尝试从 localStorage 恢复历史记录
const history = JSON.parse(localStorage.getItem('pdi_form_history') || '[]')
this.formHistory = history
// 获取下一个批次编号和顺序号
const nextNums = await this.loadNextNumbers()
if (history.length > 0) {
// 获取最近的一条记录
const lastRecord = history[0]
// 复制历史记录的数据
this.form = { ...EMPTY(), ...lastRecord }
// 卷号自动+1
if (lastRecord.coilid) {
const coilid = lastRecord.coilid
// 提取数字部分并+1
const match = coilid.match(/(\d+)$/)
if (match) {
const num = parseInt(match[1])
const newNum = num + 1
const newCoilid = coilid.replace(/\d+$/, String(newNum).padStart(match[1].length, '0'))
this.form.coilid = newCoilid
}
}
// 批次编号:如果是新批次则使用自动生成的,否则+1
if (nextNums.rollprogramnb) {
this.form.rollprogramnb = nextNums.rollprogramnb
}
// 顺序号:使用自动生成的顺序号
this.form.sequencenb = nextNums.sequencenb
} else {
this.form = EMPTY()
// 批次编号:使用自动生成的批次编号
if (nextNums.rollprogramnb) {
this.form.rollprogramnb = nextNums.rollprogramnb
}
// 顺序号默认为1
this.form.sequencenb = nextNums.sequencenb
}
this.dialogVisible = true
this.$nextTick(() => this.$refs.pdiForm && this.$refs.pdiForm.clearValidate())
},
openEdit(row) {
this.isEdit = true
pdiApi.get(row.coilid).then(res => {
this.form = { ...EMPTY(), ...res }
this.dialogVisible = true
this.$nextTick(() => this.$refs.pdiForm && this.$refs.pdiForm.clearValidate())
})
},
doSave() {
this.$refs.pdiForm.validate(async valid => {
if (!valid) return
this.saving = true
try {
const payload = {}
for (const [k, v] of Object.entries(this.form)) {
if (v !== null && v !== '' && v !== undefined) payload[k] = v
}
if (this.isEdit) {
const { coilid, ...rest } = payload
await pdiApi.update(this.form.coilid, rest)
} else {
await pdiApi.create(payload)
}
this.$message.success('保存成功 (Oracle + SQLite)')
// 保存为历史预设
if (this.config.saveHistory) {
// 保存本次填写的数据到 localStorage包含 coilid
const formToSave = { ...this.form }
// 获取历史记录,保存最近 N 条
const history = JSON.parse(localStorage.getItem('pdi_form_history') || '[]')
// 移除重复记录(如果存在相同卷号)
const filteredHistory = history.filter(item => item.coilid !== formToSave.coilid)
// 添加到历史记录开头LRU策略
filteredHistory.unshift(formToSave)
// 只保留最近 N 条
const newHistory = filteredHistory.slice(0, this.config.historyLimit)
localStorage.setItem('pdi_form_history', JSON.stringify(newHistory))
// 刷新预设列表
this.formHistory = newHistory
}
if (this.config.quickAdd) {
// 不关闭弹窗,继续新增下一个
// 获取下一个批次编号和顺序号
const nextNums = await this.loadNextNumbers()
// 卷号自动+1
if (this.form.coilid) {
const coilid = this.form.coilid
const match = coilid.match(/(\d+)$/)
if (match) {
const num = parseInt(match[1])
const newNum = num + 1
const newCoilid = coilid.replace(/\d+$/, String(newNum).padStart(match[1].length, '0'))
this.form.coilid = newCoilid
}
}
// 批次编号和顺序号使用自动生成的
if (nextNums.rollprogramnb) {
this.form.rollprogramnb = nextNums.rollprogramnb
}
this.form.sequencenb = nextNums.sequencenb
// 清空表单验证
this.$nextTick(() => this.$refs.pdiForm && this.$refs.pdiForm.clearValidate())
} else {
// 关闭弹窗
this.dialogVisible = false
}
this.loadData()
} catch (e) {
this.$message.error(e.message)
} finally { this.saving = false }
})
},
doDelete(row) {
this.$confirm(`确认删除卷号 [${row.coilid}]`, '删除确认', {
confirmButtonText: '确认删除', cancelButtonText: '取消', type: 'warning'
}).then(async () => {
try {
await pdiApi.delete(row.coilid)
this.$message.success('删除成功')
this.loadData()
} catch (e) { this.$message.error(e.message) }
}).catch(() => { })
},
async doSync() {
this.syncing = true
try {
const res = await syncApi.sync()
this.$message.success(`同步完成PDI ${res.rows.pdi_pltm} 条,跟踪图 ${res.rows.cmpt_pl_trackmap}`)
this.loadData()
} catch (e) {
this.$message.error('同步失败:' + e.message)
} finally { this.syncing = false }
},
statusLabel(s) { return ({ 0: '计划', 1: '鞍座', 2: '开卷', 3: '完成', 4: '焊接', 5: '待轧', 6: '轧制', 9: '拒绝' })[s] ?? String(s) },
statusTag(s) { return ({ 0: 'info', 1: 'info', 2: 'info' })[s] ?? 'info' },
selectHistory(item) {
// 复制历史记录的数据,但不填入钢卷号和顺序号
const { coilid, sequencenb, ...rest } = item
const currentCoilid = this.form.coilid
const currentSequencenb = this.form.sequencenb
this.form = { ...EMPTY(), ...rest, coilid: currentCoilid, sequencenb: currentSequencenb }
this.$nextTick(() => this.$refs.pdiForm && this.$refs.pdiForm.clearValidate())
},
// 定时刷新相关方法
onAutoRefreshChange() {
this.stopAutoRefresh()
if (this.config.autoRefresh && this.config.refreshInterval >= 5) {
this.startAutoRefresh()
}
},
startAutoRefresh() {
this.autoRefreshCountdown = this.config.refreshInterval
this.autoRefreshTimer = setInterval(() => {
this.autoRefreshCountdown--
if (this.autoRefreshCountdown <= 0) {
this.loadData()
this.autoRefreshCountdown = this.config.refreshInterval
}
}, 1000)
},
stopAutoRefresh() {
if (this.autoRefreshTimer) {
clearInterval(this.autoRefreshTimer)
this.autoRefreshTimer = null
}
this.autoRefreshCountdown = 0
},
// 加载钢种下拉选项
async loadGradeOptions() {
try {
const [entry, product, l2model] = await Promise.all([
gradeApi.getEntryGrades(),
gradeApi.getProductGrades(),
gradeApi.getL2ModelGrades()
])
this.entryGrades = entry.data || []
this.productGrades = product.data || []
this.l2ModelGrades = l2model.data || []
} catch (e) {
console.warn('加载钢种选项失败:', e)
}
},
// 获取下一个批次编号和顺序号
async loadNextNumbers() {
try {
const res = await gradeApi.getNextNumbers()
if (res.data) {
return res.data
}
return { rollprogramnb: null, sequencenb: 1 }
} catch (e) {
console.warn('获取下一编号失败:', e)
return { rollprogramnb: null, sequencenb: 1 }
}
}
},
beforeDestroy() {
this.stopAutoRefresh()
}
}
</script>
<style>
v-deep .el-form-item--mini.el-form-item, .el-form-item--small.el-form-item {
margin-bottom: 4px;
}
</style>

View File

@@ -0,0 +1,104 @@
<template>
<div>
<div class="panel" style="display:flex;align-items:center;gap:12px">
<span style="font-size:12px;color:#555">OPC状态<el-tag :type="opcRunning?'success':'danger'" size="mini">{{ opcRunning?'运行中':'已停止' }}</el-tag></span>
<span style="font-size:12px;color:#555">计数器<b>{{ lastCounter??'--' }}</b></span>
<span style="font-size:12px;color:#888">更新{{ lastUpdate||'--' }}</span>
<span style="flex:1"></span>
<el-button size="mini" icon="el-icon-refresh" @click="loadAll">刷新</el-button>
</div>
<div class="panel">
<div class="panel-title">位置跟踪图</div>
<div class="track-line">
<div class="track-station" v-for="pos in trackRows" :key="pos.position">
<div class="st-pos">P{{ pos.position }}</div>
<div class="st-coil" :class="{occupied: pos.coilid}">{{ pos.coilid||'—' }}</div>
<div class="st-flags">
<span :class="['fl', pos.bef_es?'on':'off']">ES前</span>
<span :class="['fl', pos.es?'on':'off']">ES</span>
<span :class="['fl', pos.ent_loo?'on':'off']">入套</span>
<span :class="['fl', pos.pl?'on':'off']">PL</span>
<span :class="['fl', pos.int_loo?'on':'off']">中套</span>
<span :class="['fl', pos.st?'on':'off']">ST</span>
<span :class="['fl', pos.exi_loo?'on':'off']">出套</span>
</div>
<div class="st-speed">{{ pos.run_speed_min }}~{{ pos.run_speed_max }} m/min</div>
</div>
</div>
</div>
<div class="panel" style="padding:0">
<el-table :data="trackRows" stripe border size="small" v-loading="loading">
<el-table-column prop="position" label="位置" width="65" />
<el-table-column prop="coilid" label="卷号" width="155" />
<el-table-column label="BEF_ES" width="72"><template slot-scope="{row}"><el-tag :type="row.bef_es?'success':'info'" size="mini">{{ row.bef_es }}</el-tag></template></el-table-column>
<el-table-column label="ES" width="65"><template slot-scope="{row}"><el-tag :type="row.es?'success':'info'" size="mini">{{ row.es }}</el-tag></template></el-table-column>
<el-table-column label="ENT_LOO" width="75"><template slot-scope="{row}"><el-tag :type="row.ent_loo?'success':'info'" size="mini">{{ row.ent_loo }}</el-tag></template></el-table-column>
<el-table-column label="PL" width="65"><template slot-scope="{row}"><el-tag :type="row.pl?'success':'info'" size="mini">{{ row.pl }}</el-tag></template></el-table-column>
<el-table-column label="INT_LOO" width="75"><template slot-scope="{row}"><el-tag :type="row.int_loo?'success':'info'" size="mini">{{ row.int_loo }}</el-tag></template></el-table-column>
<el-table-column label="ST" width="65"><template slot-scope="{row}"><el-tag :type="row.st?'success':'info'" size="mini">{{ row.st }}</el-tag></template></el-table-column>
<el-table-column label="EXI_LOO" width="75"><template slot-scope="{row}"><el-tag :type="row.exi_loo?'success':'info'" size="mini">{{ row.exi_loo }}</el-tag></template></el-table-column>
<el-table-column prop="run_speed_min" label="运行Min" width="85" />
<el-table-column prop="run_speed_max" label="运行Max" width="85" />
<el-table-column prop="weld_speed_min" label="焊速Min" width="80" />
<el-table-column prop="weld_speed_max" label="焊速Max" width="80" />
<el-table-column prop="toc" label="创建" width="150" />
<el-table-column prop="tom" label="更新" width="150" />
</el-table>
</div>
<div class="panel">
<div class="panel-title">OPC 事件日志</div>
<div class="log-box" ref="logBox">
<div v-for="(line,i) in opcLog" :key="i" class="log-line">{{ line }}</div>
<div v-if="!opcLog.length" style="color:#aaa">暂无日志</div>
</div>
</div>
</div>
</template>
<script>
import { trackmapApi, opcApi } from '../api/index'
export default {
name: 'TrackMap',
data() {
return { loading: false, trackRows: [], opcRunning: false,
lastCounter: null, lastUpdate: null, opcLog: [], timer: null }
},
created() { this.loadAll() },
mounted() { this.timer = setInterval(this.loadAll, 3000) },
beforeDestroy() { clearInterval(this.timer) },
methods: {
async loadAll() {
this.loading = true
try {
const [rows, status] = await Promise.all([trackmapApi.list(), opcApi.getStatus()])
this.trackRows = rows
this.opcRunning = status.running
this.lastCounter = status.last_counter
this.lastUpdate = status.last_update
this.opcLog = status.log || []
this.$nextTick(() => {
if (this.$refs.logBox) this.$refs.logBox.scrollTop = this.$refs.logBox.scrollHeight
})
} catch (e) { /* silent */ } finally { this.loading = false }
}
}
}
</script>
<style scoped>
.track-line { display:flex; gap:10px; overflow-x:auto; padding-bottom:4px; }
.track-station { min-width:116px; background:#f9f9f9; border:1px solid #e0e0e0; border-radius:2px; padding:8px; flex-shrink:0; }
.st-pos { font-size:11px; font-weight:700; color:#333; margin-bottom:4px; }
.st-coil { font-size:11px; background:#fff; border:1px solid #e0e0e0; padding:3px 5px; margin-bottom:4px; min-height:24px; color:#222; word-break:break-all; }
.st-coil.occupied { border-color:#888; background:#f0f0f0; font-weight:600; }
.st-flags { display:flex; flex-wrap:wrap; gap:3px; margin-bottom:4px; }
.fl { font-size:10px; padding:1px 4px; border-radius:1px; font-weight:600; }
.fl.on { background:#e8f5e9; color:#2e7d32; border:1px solid #c8e6c9; }
.fl.off { background:#f5f5f5; color:#bbb; border:1px solid #e0e0e0; }
.st-speed { font-size:10px; color:#888; }
.log-box { background:#fafafa; border:1px solid #e8e8e8; border-radius:2px; padding:8px 12px; height:160px; overflow-y:auto; font-family:monospace; }
.log-line { font-size:11px; color:#555; line-height:1.7; border-bottom:1px solid #f0f0f0; }
</style>

11
frontend/vue.config.js Normal file
View File

@@ -0,0 +1,11 @@
module.exports = {
devServer: {
port: 8080,
proxy: {
'/api': {
target: 'http://localhost:8000',
changeOrigin: true
}
}
}
}