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

78
.gitignore vendored Normal file
View File

@@ -0,0 +1,78 @@
# ==============================================
# IDE 相关 (IntelliJ IDEA / PyCharm)
# ==============================================
.idea/
/shelf/
/workspace.xml
# 基于编辑器的 HTTP 客户端请求
/httpRequests/
# Datasource local storage ignored files
/dataSources/
/dataSources.local.xml
*.iml
*.iws
# ==============================================
# Python 后端相关
# ==============================================
# 字节码缓存
__pycache__/
*.py[cod]
*$py.class
# 虚拟环境
.env/
.venv/
env/
venv/
ENV/
# 环境变量文件 (包含敏感信息)
.env
.env.local
.env.*.local
# 数据库文件 (SQLite 本地缓存)
*.db
*.sqlite
*.sqlite3
# ==============================================
# Node.js / 前端相关
# ==============================================
# 依赖包 (体积大,无需提交)
frontend/node_modules/
node_modules/
# 日志
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
# 本地环境配置
.env.local
.env.development.local
.env.test.local
.env.production.local
# 构建产物
dist/
build/
*.local
# ==============================================
# 系统文件
# ==============================================
# Windows
Thumbs.db
.DS_Store
Desktop.ini
# ==============================================
# 项目自定义忽略
# ==============================================
# 启动脚本 (如果是本地个性化配置可忽略,如需共享可保留)
# start_tdh.bat
# README.md (一般需要提交,不忽略)
# sql/ (一般需要提交建表SQL不忽略)

114
README.md Normal file
View File

@@ -0,0 +1,114 @@
# HEFA-L2 PDI 管理系统
基于 **FastAPI + Oracle + OPC-UA + Vue2** 的生产计划数据管理平台。
## 功能说明
| 模块 | 说明 |
|------|------|
| PDI 计划管理 | 对 `PDI_PLTM` 表进行增删改查,支持分页、筛选 |
| 跟踪图监控 | 实时展示 `CMPT_PL_TRACKMAP` OPC 采集数据,每 3 秒刷新 |
| OPC 配置 | 可视化配置 OPC-UA 服务器地址、计数器节点、节点映射 |
## 目录结构
```
HEFA-L2/
├── backend/
│ ├── main.py # FastAPI 路由
│ ├── database.py # Oracle 连接
│ ├── models.py # Pydantic 模型
│ ├── opc_service.py # OPC-UA 轮询服务
│ ├── requirements.txt
│ └── .env.example # 环境变量模板
└── frontend/
├── src/
│ ├── main.js
│ ├── router.js
│ ├── App.vue
│ ├── api/index.js
│ └── views/
│ ├── PdiList.vue # PDI CRUD
│ ├── TrackMap.vue # OPC 跟踪图
│ └── OpcConfig.vue # OPC 配置
├── package.json
└── vue.config.js
```
## 快速启动
### 1. 后端
```bash
cd backend
# 复制并填写环境变量
copy .env.example .env
# 编辑 .env填入 Oracle 连接信息和 OPC 地址
# 安装依赖(需要先安装 Oracle Instant Client
pip install -r requirements.txt
# 启动
uvicorn main:app --host 0.0.0.0 --port 8000 --reload
```
### 2. 前端
```bash
cd frontend
npm install
npm run serve
# 访问 http://localhost:8080
```
## 环境变量说明 (.env)
| 变量 | 说明 | 示例 |
|------|------|------|
| `ORACLE_DSN` | Oracle 数据源 | `192.168.1.10:1521/ORCL` |
| `ORACLE_USER` | 用户名 | `pltm` |
| `ORACLE_PASSWORD` | 密码 | `your_password` |
| `OPC_URL` | OPC-UA 服务器地址 | `opc.tcp://192.168.1.100:4840` |
| `OPC_COUNTER_NODE` | 计数器节点 ID | `ns=2;s=PL.TRACKMAP.COUNTER` |
| `OPC_POLL_INTERVAL` | 轮询间隔(秒) | `2` |
| `OPC_NODE_position` | 位置节点映射 | `ns=2;s=PL.TRACKMAP.POSITION` |
| `OPC_NODE_coilid` | 卷号节点映射 | `ns=2;s=PL.TRACKMAP.COILID` |
## OPC 采集逻辑
1. 后端启动时自动开始轮询 OPC-UA 服务器
2. 每隔 `OPC_POLL_INTERVAL` 秒读取 `OPC_COUNTER_NODE` 节点值
3. **当 counter 值发生变化时**,读取所有在 `trackmap_nodes` 中配置的节点
4. 根据 `position` 字段更新 Oracle `PLTM.CMPT_PL_TRACKMAP` 对应行
5. 所有操作写入事件日志可在「OPC 配置」和「跟踪图监控」页面查看
## OPC 节点映射配置方式
**方式一:通过 .env 文件**
```
OPC_NODE_position=ns=2;s=PL.TRACKMAP.POSITION
OPC_NODE_coilid=ns=2;s=PL.TRACKMAP.COILID
OPC_NODE_bef_es=ns=2;s=PL.TRACKMAP.BEF_ES
OPC_NODE_es=ns=2;s=PL.TRACKMAP.ES
OPC_NODE_ent_loo=ns=2;s=PL.TRACKMAP.ENT_LOO
OPC_NODE_pl=ns=2;s=PL.TRACKMAP.PL
OPC_NODE_int_loo=ns=2;s=PL.TRACKMAP.INT_LOO
OPC_NODE_st=ns=2;s=PL.TRACKMAP.ST
OPC_NODE_exi_loo=ns=2;s=PL.TRACKMAP.EXI_LOO
OPC_NODE_run_speed_min=ns=2;s=PL.TRACKMAP.RUN_SPEED_MIN
OPC_NODE_run_speed_max=ns=2;s=PL.TRACKMAP.RUN_SPEED_MAX
```
**方式二通过前端「OPC 配置」页面**
在界面上添加列名→节点ID映射保存后立即生效。
## Oracle Instant Client 安装
安装后设置环境变量或在代码中初始化:
```python
import cx_Oracle
cx_Oracle.init_oracle_client(lib_dir=r"C:\oracle\instantclient_21_x")
```

9
backend/.env.example Normal file
View File

@@ -0,0 +1,9 @@
# Oracle Database
ORACLE_DSN=localhost:1521/orcl
ORACLE_USER=pltm
ORACLE_PASSWORD=pltm123
# OPC-UA Server
OPC_URL=opc.tcp://192.168.1.100:4840
OPC_COUNTER_NODE=ns=2;s=PL.TRACKMAP.COUNTER
OPC_POLL_INTERVAL=2

19
backend/database.py Normal file
View File

@@ -0,0 +1,19 @@
import os
import cx_Oracle
from dotenv import load_dotenv
load_dotenv()
ORACLE_DSN = os.getenv("ORACLE_DSN", "localhost:1521/ORCL")
ORACLE_USER = os.getenv("ORACLE_USER", "pltm")
ORACLE_PASSWORD = os.getenv("ORACLE_PASSWORD", "password")
def get_connection() -> cx_Oracle.Connection:
"""Return a new Oracle DB connection. Caller is responsible for closing."""
return cx_Oracle.connect(
user=ORACLE_USER,
password=ORACLE_PASSWORD,
dsn=ORACLE_DSN,
encoding="UTF-8",
)

330
backend/main.py Normal file
View File

@@ -0,0 +1,330 @@
import asyncio
import logging
import os
from contextlib import asynccontextmanager
from typing import Optional
from dotenv import load_dotenv
from fastapi import FastAPI, HTTPException, Query
from fastapi.middleware.cors import CORSMiddleware
load_dotenv()
from database import get_connection
from models import PDIPLTMCreate, PDIPLTMUpdate, OpcConfig
from opc_service import opc_service
from sqlite_sync import (
init_db, sync_all_from_oracle,
sqlite_upsert_pdi, sqlite_delete_pdi
)
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
@asynccontextmanager
async def lifespan(app: FastAPI):
# Init SQLite schema
init_db()
# Sync Oracle -> SQLite on startup (in thread pool to avoid blocking)
loop = asyncio.get_event_loop()
try:
result = await loop.run_in_executor(None, sync_all_from_oracle)
logger.info("Startup sync: %s", result)
except Exception as e:
logger.warning("Startup sync failed (Oracle may be unreachable): %s", e)
# Start OPC polling
asyncio.create_task(opc_service.start_polling())
logger.info("OPC polling task started")
yield
await opc_service.stop_polling()
logger.info("OPC polling task stopped")
app = FastAPI(title="HEFA-L2 PDI管理系统", version="1.0.0", lifespan=lifespan)
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# ─────────────────────────────────────────────
# Sync endpoint
# ─────────────────────────────────────────────
@app.post("/api/sync")
async def trigger_sync():
"""Manually trigger a full Oracle -> SQLite sync."""
loop = asyncio.get_event_loop()
try:
result = await loop.run_in_executor(None, sync_all_from_oracle)
return {"message": "同步成功", "rows": result}
except Exception as e:
raise HTTPException(status_code=500, detail=f"同步失败: {e}")
# ─────────────────────────────────────────────
# PDI_PLTM CRUD
# ─────────────────────────────────────────────
@app.get("/api/pdi")
def list_pdi(
page: int = Query(1, ge=1),
page_size: int = Query(20, ge=1, le=200),
coilid: Optional[str] = None,
status: Optional[int] = None,
steel_grade: Optional[str] = None,
):
conn = get_connection()
cursor = conn.cursor()
try:
conditions = []
params = {}
if coilid:
conditions.append("COILID LIKE :coilid")
params["coilid"] = f"%{coilid}%"
if status is not None:
conditions.append("STATUS = :status")
params["status"] = status
if steel_grade:
conditions.append("STEEL_GRADE LIKE :steel_grade")
params["steel_grade"] = f"%{steel_grade}%"
where = ("WHERE " + " AND ".join(conditions)) if conditions else ""
count_sql = f"SELECT COUNT(*) FROM PDI_PLTM {where}"
cursor.execute(count_sql, params)
total = cursor.fetchone()[0]
offset = (page - 1) * page_size
sql = f"""
SELECT * FROM (
SELECT a.*, ROWNUM rn FROM (
SELECT SID, ROLLPROGRAMNB, SEQUENCENB, STATUS, SCHEDULE_CODE,
COILID, ENTRY_COIL_THICKNESS, ENTRY_COIL_WIDTH,
ENTRY_COIL_WEIGHT, ENTRY_OF_COIL_LENGTH,
EXIT_COIL_NO, EXIT_COIL_THICKNESS, EXIT_COIL_WIDTH,
EXIT_COIL_WEIGHT, WORK_ORDER_NO, ORDER_QUALITY,
STEEL_GRADE, SG_SIGN, ORDER_THICKNESS, ORDER_WIDTH,
COILER_DIAMETER, L2_GRADE, WEIGHT_MODE,
CREATED_DT, UPDATED_DT, SEND_FLAG,
ENTRY_COIL_THICKNESS_MAX, ENTRY_COIL_THICKNESS_MIN,
ENTRY_COIL_WIDTH_MAX, ENTRY_COIL_WIDTH_MIN,
EXIT_COIL_THICKNESS_MAX, EXIT_COIL_THICKNESS_MIN,
EXIT_COIL_WIDTH_MAX, EXIT_COIL_WIDTH_MIN,
CROSS_SECTION_AREA, UNCOILER_TENSION,
LOOPER_TENSION_1, PL_TENSION,
LOOPER_TENSION_2, LOOPER_TENSION_3,
DUMMY_COIL_MRK, CUT_MODE, TRIMMING, TRIMMING_WIDTH
FROM PDI_PLTM {where}
ORDER BY COILID DESC
) a WHERE ROWNUM <= :end_row
) WHERE rn > :start_row
"""
params["end_row"] = offset + page_size
params["start_row"] = offset
cursor.execute(sql, params)
columns = [col[0].lower() for col in cursor.description]
rows = [dict(zip(columns, row)) for row in cursor.fetchall()]
for row in rows:
for k, v in row.items():
if hasattr(v, 'isoformat'):
row[k] = v.isoformat()
return {"total": total, "page": page, "page_size": page_size, "data": rows}
finally:
cursor.close()
conn.close()
@app.get("/api/pdi/{coilid}")
def get_pdi(coilid: str):
conn = get_connection()
cursor = conn.cursor()
try:
cursor.execute("SELECT * FROM PDI_PLTM WHERE COILID = :coilid", {"coilid": coilid})
columns = [col[0].lower() for col in cursor.description]
row = cursor.fetchone()
if not row:
raise HTTPException(status_code=404, detail="记录不存在")
result = dict(zip(columns, row))
for k, v in result.items():
if hasattr(v, 'isoformat'):
result[k] = v.isoformat()
return result
finally:
cursor.close()
conn.close()
@app.post("/api/pdi", status_code=201)
def create_pdi(data: PDIPLTMCreate):
conn = get_connection()
cursor = conn.cursor()
try:
fields = {k: v for k, v in data.model_dump(exclude_none=True).items()}
cols = ", ".join(f.upper() for f in fields.keys())
vals = ", ".join([f":{k}" for k in fields.keys()])
sql = f"INSERT INTO PDI_PLTM ({cols}) VALUES ({vals})"
cursor.execute(sql, fields)
conn.commit()
# Mirror to SQLite
try:
sqlite_upsert_pdi(fields)
except Exception as e:
logger.warning("SQLite mirror failed on create: %s", e)
return {"message": "创建成功", "coilid": data.coilid}
except HTTPException:
raise
except Exception as e:
conn.rollback()
raise HTTPException(status_code=400, detail=str(e))
finally:
cursor.close()
conn.close()
@app.put("/api/pdi/{coilid}")
def update_pdi(coilid: str, data: PDIPLTMUpdate):
conn = get_connection()
cursor = conn.cursor()
try:
fields = {k: v for k, v in data.model_dump(exclude_none=True).items()}
if not fields:
raise HTTPException(status_code=400, detail="无更新字段")
set_clause = ", ".join([f"{k.upper()} = :{k}" for k in fields.keys()])
fields["coilid_"] = coilid
sql = f"UPDATE PDI_PLTM SET {set_clause} WHERE COILID = :coilid_"
cursor.execute(sql, fields)
if cursor.rowcount == 0:
raise HTTPException(status_code=404, detail="记录不存在")
conn.commit()
# Mirror to SQLite: re-fetch the updated row
try:
cursor2 = conn.cursor()
cursor2.execute("SELECT * FROM PDI_PLTM WHERE COILID = :c", {"c": coilid})
cols = [d[0].lower() for d in cursor2.description]
row = cursor2.fetchone()
cursor2.close()
if row:
row_dict = dict(zip(cols, row))
for k, v in row_dict.items():
if hasattr(v, 'isoformat'):
row_dict[k] = v.isoformat()
sqlite_upsert_pdi(row_dict)
except Exception as e:
logger.warning("SQLite mirror failed on update: %s", e)
return {"message": "更新成功"}
except HTTPException:
raise
except Exception as e:
conn.rollback()
raise HTTPException(status_code=400, detail=str(e))
finally:
cursor.close()
conn.close()
@app.delete("/api/pdi/{coilid}")
def delete_pdi(coilid: str):
conn = get_connection()
cursor = conn.cursor()
try:
cursor.execute("DELETE FROM PDI_PLTM WHERE COILID = :coilid", {"coilid": coilid})
if cursor.rowcount == 0:
raise HTTPException(status_code=404, detail="记录不存在")
conn.commit()
# Mirror to SQLite
try:
sqlite_delete_pdi(coilid)
except Exception as e:
logger.warning("SQLite mirror failed on delete: %s", e)
return {"message": "删除成功"}
except HTTPException:
raise
except Exception as e:
conn.rollback()
raise HTTPException(status_code=400, detail=str(e))
finally:
cursor.close()
conn.close()
# ─────────────────────────────────────────────
# CMPT_PL_TRACKMAP
# ─────────────────────────────────────────────
@app.get("/api/trackmap")
def list_trackmap():
conn = get_connection()
cursor = conn.cursor()
try:
cursor.execute(
"SELECT POSITION, COILID, BEF_ES, ES, ENT_LOO, PL, INT_LOO, "
"ST, EXI_LOO, RUN_SPEED_MIN, RUN_SPEED_MAX, "
"WELD_SPEED_MIN, WELD_SPEED_MAX, TOC, TOM, MOP "
"FROM PLTM.CMPT_PL_TRACKMAP ORDER BY POSITION"
)
columns = [col[0].lower() for col in cursor.description]
rows = [dict(zip(columns, row)) for row in cursor.fetchall()]
for row in rows:
for k, v in row.items():
if hasattr(v, 'isoformat'):
row[k] = v.isoformat()
return rows
finally:
cursor.close()
conn.close()
# ─────────────────────────────────────────────
# OPC Configuration & Status
# ─────────────────────────────────────────────
@app.get("/api/opc/config")
def get_opc_config():
return {
"opc_url": opc_service.opc_url,
"counter_node": opc_service.counter_node,
"trackmap_nodes": opc_service.trackmap_nodes,
"poll_interval": opc_service.poll_interval,
"running": opc_service.running,
"last_counter": opc_service.last_counter,
"last_update": opc_service.last_update,
}
@app.post("/api/opc/config")
async def save_opc_config(config: OpcConfig):
await opc_service.stop_polling()
opc_service.opc_url = config.opc_url
opc_service.counter_node = config.counter_node
opc_service.trackmap_nodes = config.trackmap_nodes
opc_service.poll_interval = config.poll_interval
try:
opc_service.save_config()
except Exception as e:
logger.warning("Persist OPC config failed: %s", e)
raise HTTPException(status_code=500, detail=f"配置保存失败: {e}")
asyncio.create_task(opc_service.start_polling())
return {"message": "OPC配置已保存并重启轮询"}
@app.get("/api/opc/status")
def opc_status():
return {
"running": opc_service.running,
"last_counter": opc_service.last_counter,
"last_update": opc_service.last_update,
"log": opc_service.event_log[-50:],
}
@app.post("/api/opc/restart")
async def restart_opc():
await opc_service.stop_polling()
asyncio.create_task(opc_service.start_polling())
return {"message": "OPC服务已重启"}

184
backend/models.py Normal file
View File

@@ -0,0 +1,184 @@
from typing import Optional, List, Dict
from pydantic import BaseModel
class PDIPLTMCreate(BaseModel):
coilid: str
rollprogramnb: Optional[int] = None
sequencenb: Optional[int] = None
schedule_code: Optional[str] = None
entry_coil_thickness: Optional[float] = None
entry_coil_thickness_max: Optional[float] = None
entry_coil_thickness_min: Optional[float] = None
entry_coil_width: Optional[float] = None
entry_coil_width_max: Optional[float] = None
entry_coil_width_min: Optional[float] = None
entry_coil_weight: Optional[float] = None
entry_of_coil_length: Optional[float] = None
entry_of_coil_inner_diameter: Optional[float] = None
entry_of_coil_outer_diameter: Optional[float] = None
trimming: Optional[int] = None
trimming_width: Optional[float] = None
smp_length: Optional[float] = None
smp_num: Optional[float] = None
smp_frq: Optional[str] = None
preceding_process_code: Optional[str] = None
next_process_code: Optional[str] = None
hot_mill_delivery_temp: Optional[float] = None
finished_coil_temp: Optional[float] = None
crown_average: Optional[float] = None
coil_flatness_average: Optional[float] = None
coil_flatness_max_value: Optional[float] = None
coil_flatness_min_value: Optional[float] = None
material_yield_point: Optional[float] = None
material_tensile: Optional[float] = None
hotactfmwedgeavg: Optional[float] = None
weight_mode: Optional[str] = None
dummy_coil_mrk: Optional[str] = None
cut_mode: Optional[str] = None
off_gauge_head_length: Optional[float] = None
off_gauge_tail_length: Optional[float] = None
exit_coil_no: Optional[str] = None
exit_coil_weight: Optional[float] = None
exit_coil_weight_max: Optional[float] = None
exit_coil_weight_min: Optional[float] = None
exit_coil_thickness: Optional[float] = None
exit_coil_thickness_max: Optional[float] = None
exit_coil_thickness_min: Optional[float] = None
exit_coil_width: Optional[float] = None
exit_coil_width_max: Optional[float] = None
exit_coil_width_min: Optional[float] = None
work_order_no: Optional[str] = None
order_quality: Optional[str] = None
steel_grade: Optional[str] = None
sg_sign: Optional[str] = None
order_thickness: Optional[float] = None
order_thickness_max: Optional[float] = None
order_thickness_min: Optional[float] = None
order_width: Optional[float] = None
order_width_max: Optional[float] = None
order_width_min: Optional[float] = None
sleeve_code_of_cold_coil: Optional[str] = None
packing_type_code: Optional[str] = None
thk_ds: Optional[str] = None
ext_num_01: Optional[str] = None
# chemical elements
c: Optional[float] = None
si: Optional[float] = None
mn: Optional[float] = None
p: Optional[float] = None
s: Optional[float] = None
cu: Optional[float] = None
ni: Optional[float] = None
cr: Optional[float] = None
mo: Optional[float] = None
v: Optional[float] = None
ti: Optional[float] = None
sol_al: Optional[float] = None
fe: Optional[float] = None
nb: Optional[float] = None
n: Optional[float] = None
b: Optional[float] = None
send_flag: Optional[str] = None
work_order_no: Optional[str] = None
coiler_diameter: Optional[int] = None
l2_grade: Optional[str] = None
scrap_cut_head_len: Optional[float] = None
scrap_cut_tail_len: Optional[float] = None
meterweight: Optional[float] = None
meter_d_outside: Optional[float] = None
meter_width: Optional[float] = None
uncoiler_tension: Optional[float] = None
looper_tension_1: Optional[float] = None
pl_tension: Optional[float] = None
looper_tension_2: Optional[float] = None
looper_tension_3: Optional[float] = None
class PDIPLTMUpdate(BaseModel):
rollprogramnb: Optional[int] = None
sequencenb: Optional[int] = None
schedule_code: Optional[str] = None
entry_coil_thickness: Optional[float] = None
entry_coil_thickness_max: Optional[float] = None
entry_coil_thickness_min: Optional[float] = None
entry_coil_width: Optional[float] = None
entry_coil_width_max: Optional[float] = None
entry_coil_width_min: Optional[float] = None
entry_coil_weight: Optional[float] = None
entry_of_coil_length: Optional[float] = None
entry_of_coil_inner_diameter: Optional[float] = None
entry_of_coil_outer_diameter: Optional[float] = None
trimming: Optional[int] = None
trimming_width: Optional[float] = None
smp_length: Optional[float] = None
smp_num: Optional[float] = None
smp_frq: Optional[str] = None
preceding_process_code: Optional[str] = None
next_process_code: Optional[str] = None
hot_mill_delivery_temp: Optional[float] = None
finished_coil_temp: Optional[float] = None
crown_average: Optional[float] = None
coil_flatness_average: Optional[float] = None
material_yield_point: Optional[float] = None
material_tensile: Optional[float] = None
weight_mode: Optional[str] = None
dummy_coil_mrk: Optional[str] = None
cut_mode: Optional[str] = None
off_gauge_head_length: Optional[float] = None
off_gauge_tail_length: Optional[float] = None
exit_coil_no: Optional[str] = None
exit_coil_weight: Optional[float] = None
exit_coil_weight_max: Optional[float] = None
exit_coil_weight_min: Optional[float] = None
exit_coil_thickness: Optional[float] = None
exit_coil_thickness_max: Optional[float] = None
exit_coil_thickness_min: Optional[float] = None
exit_coil_width: Optional[float] = None
exit_coil_width_max: Optional[float] = None
exit_coil_width_min: Optional[float] = None
work_order_no: Optional[str] = None
order_quality: Optional[str] = None
steel_grade: Optional[str] = None
sg_sign: Optional[str] = None
order_thickness: Optional[float] = None
order_thickness_max: Optional[float] = None
order_thickness_min: Optional[float] = None
order_width: Optional[float] = None
order_width_max: Optional[float] = None
order_width_min: Optional[float] = None
packing_type_code: Optional[str] = None
thk_ds: Optional[str] = None
c: Optional[float] = None
si: Optional[float] = None
mn: Optional[float] = None
p: Optional[float] = None
s: Optional[float] = None
cu: Optional[float] = None
ni: Optional[float] = None
cr: Optional[float] = None
mo: Optional[float] = None
v: Optional[float] = None
ti: Optional[float] = None
sol_al: Optional[float] = None
nb: Optional[float] = None
n: Optional[float] = None
b: Optional[float] = None
send_flag: Optional[str] = None
coiler_diameter: Optional[int] = None
l2_grade: Optional[str] = None
uncoiler_tension: Optional[float] = None
looper_tension_1: Optional[float] = None
pl_tension: Optional[float] = None
looper_tension_2: Optional[float] = None
looper_tension_3: Optional[float] = None
scrap_cut_head_len: Optional[float] = None
scrap_cut_tail_len: Optional[float] = None
meterweight: Optional[float] = None
class OpcConfig(BaseModel):
opc_url: str
counter_node: str
poll_interval: int = 2
trackmap_nodes: Dict[str, str] = {}

244
backend/opc_service.py Normal file
View File

@@ -0,0 +1,244 @@
"""
OPC-UA polling service.
Logic:
1. Connect to the OPC-UA server at `opc_url`.
2. Read the counter node (`counter_node`) every `poll_interval` seconds.
3. When the counter value changes, read the trackmap nodes listed in
`trackmap_nodes` (a dict: {oracle_column -> node_id}).
4. For each row returned by the trackmap nodes build an UPDATE statement
and apply it to PLTM.CMPT_PL_TRACKMAP.
`trackmap_nodes` example (stored in .env or configured via UI):
{
"COILID": "ns=2;s=PL.TRACKMAP.P01.COILID",
"BEF_ES": "ns=2;s=PL.TRACKMAP.P01.BEF_ES",
...
}
For multi-position setups, define one entry per position column and handle
positional logic accordingly. The current implementation reads a flat set of
nodes and stores them against the POSITION value also read from OPC.
"""
import asyncio
import json
import logging
import os
from datetime import datetime
from typing import Any, Dict, List, Optional
from dotenv import load_dotenv
load_dotenv()
logger = logging.getLogger(__name__)
class OpcService:
def __init__(self):
self.config_path = os.path.join(os.path.dirname(__file__), "opc_config.json")
self.opc_url: str = os.getenv("OPC_URL", "opc.tcp://192.168.1.100:4840")
self.counter_node: str = os.getenv(
"OPC_COUNTER_NODE", "ns=2;s=PL.TRACKMAP.COUNTER"
)
self.poll_interval: int = int(os.getenv("OPC_POLL_INTERVAL", "2"))
# Mapping: oracle_column_name -> OPC node id
# Populated from .env or via API
self.trackmap_nodes: Dict[str, str] = self._load_trackmap_nodes()
# Load persisted config if present
self._load_persisted_config()
self.running: bool = False
self.last_counter: Optional[Any] = None
self.last_update: Optional[str] = None
self.event_log: List[str] = []
self._stop_event = asyncio.Event()
self._task: Optional[asyncio.Task] = None
# ------------------------------------------------------------------
def _load_trackmap_nodes(self) -> Dict[str, str]:
"""Load trackmap node mapping from env vars prefixed OPC_NODE_."""
nodes = {}
for key, val in os.environ.items():
if key.startswith("OPC_NODE_"):
col = key[len("OPC_NODE_"):].lower()
nodes[col] = val
return nodes
def _load_persisted_config(self):
"""Load OPC config from local json file if exists."""
if not os.path.exists(self.config_path):
return
try:
with open(self.config_path, "r", encoding="utf-8") as f:
cfg = json.load(f)
self.opc_url = cfg.get("opc_url", self.opc_url)
self.counter_node = cfg.get("counter_node", self.counter_node)
self.poll_interval = int(cfg.get("poll_interval", self.poll_interval))
self.trackmap_nodes = cfg.get("trackmap_nodes", self.trackmap_nodes) or {}
self._log(f"Loaded OPC config from {self.config_path}")
except Exception as exc:
logger.warning("Failed to load OPC config %s: %s", self.config_path, exc)
def save_config(self):
"""Persist current OPC config to local json file."""
cfg = {
"opc_url": self.opc_url,
"counter_node": self.counter_node,
"poll_interval": self.poll_interval,
"trackmap_nodes": self.trackmap_nodes,
}
os.makedirs(os.path.dirname(self.config_path), exist_ok=True)
with open(self.config_path, "w", encoding="utf-8") as f:
json.dump(cfg, f, ensure_ascii=False, indent=2)
def _log(self, msg: str):
ts = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
entry = f"[{ts}] {msg}"
logger.info(entry)
self.event_log.append(entry)
if len(self.event_log) > 500:
self.event_log = self.event_log[-500:]
# ------------------------------------------------------------------
async def start_polling(self):
if self.running:
return
self._stop_event.clear()
self._task = asyncio.create_task(self._poll_loop())
async def stop_polling(self):
self._stop_event.set()
if self._task:
self._task.cancel()
try:
await self._task
except asyncio.CancelledError:
pass
self.running = False
# ------------------------------------------------------------------
async def _poll_loop(self):
self.running = True
self._log(f"OPC polling started: {self.opc_url}")
try:
from opcua import Client # type: ignore
except ImportError:
self._log("opcua package not installed running in SIMULATION mode")
await self._simulate_loop()
return
while not self._stop_event.is_set():
try:
client = Client(self.opc_url)
client.connect()
self._log(f"Connected to OPC server: {self.opc_url}")
try:
while not self._stop_event.is_set():
await self._tick(client)
await asyncio.sleep(self.poll_interval)
finally:
client.disconnect()
self._log("Disconnected from OPC server")
except Exception as exc:
self._log(f"OPC connection error: {exc}. Retrying in 5s...")
await asyncio.sleep(5)
self.running = False
self._log("OPC polling stopped")
# ------------------------------------------------------------------
async def _tick(self, client):
"""Read counter; if changed, fetch trackmap nodes and update Oracle."""
try:
counter_node = client.get_node(self.counter_node)
current_counter = counter_node.get_value()
except Exception as exc:
self._log(f"Failed to read counter node: {exc}")
return
if current_counter == self.last_counter:
return # nothing changed
self._log(
f"Counter changed: {self.last_counter} -> {current_counter}. "
"Fetching trackmap data..."
)
self.last_counter = current_counter
self.last_update = datetime.now().isoformat()
if not self.trackmap_nodes:
self._log("No trackmap nodes configured skipping DB update")
return
# Read all configured nodes
data: Dict[str, Any] = {}
for col, node_id in self.trackmap_nodes.items():
try:
node = client.get_node(node_id)
data[col] = node.get_value()
except Exception as exc:
self._log(f"Failed to read node {node_id}: {exc}")
if not data:
return
# Determine POSITION from data (must be one of the mapped columns)
position = data.get("position")
if position is None:
self._log("'position' not in trackmap_nodes data cannot update row")
return
await self._update_oracle(position, data)
# ------------------------------------------------------------------
async def _update_oracle(self, position: Any, data: Dict[str, Any]):
"""Write fetched OPC values into PLTM.CMPT_PL_TRACKMAP."""
import threading
def _do_update():
try:
from database import get_connection
conn = get_connection()
cursor = conn.cursor()
try:
updatable = {k: v for k, v in data.items() if k != "position"}
if not updatable:
return
set_clause = ", ".join(
[f"{k.upper()} = :{k}" for k in updatable.keys()]
)
updatable["position_"] = position
sql = (
f"UPDATE PLTM.CMPT_PL_TRACKMAP "
f"SET {set_clause} WHERE POSITION = :position_"
)
cursor.execute(sql, updatable)
conn.commit()
self._log(
f"Updated CMPT_PL_TRACKMAP POSITION={position}: "
+ ", ".join(f"{k}={v}" for k, v in updatable.items() if k != "position_")
)
finally:
cursor.close()
conn.close()
except Exception as exc:
self._log(f"Oracle update failed: {exc}")
loop = asyncio.get_event_loop()
await loop.run_in_executor(None, _do_update)
# ------------------------------------------------------------------
async def _simulate_loop(self):
"""Simulation mode when opcua is not installed."""
counter = 0
while not self._stop_event.is_set():
await asyncio.sleep(self.poll_interval)
counter += 1
self.last_counter = counter
self.last_update = datetime.now().isoformat()
self._log(f"[SIM] Counter tick: {counter}")
self.running = False
opc_service = OpcService()

9
backend/requirements.txt Normal file
View File

@@ -0,0 +1,9 @@
fastapi==0.111.0
uvicorn[standard]==0.29.0
cx_Oracle==8.3.0
pydantic==2.7.1
python-dotenv==1.0.1
opcua==0.98.13
asyncio==3.4.3
httpx==0.27.0
python-multipart==0.0.9

303
backend/sqlite_sync.py Normal file
View File

@@ -0,0 +1,303 @@
"""
SQLite mirror for PDI_PLTM and CMPT_PL_TRACKMAP.
On startup (and on demand) the service pulls all rows from Oracle and
upserts them into a local SQLite file (hefa_l2.db).
Whenever the FastAPI endpoints write to Oracle they also call the
corresponding sqlite_* helper here so the two databases stay in sync.
"""
import sqlite3
import logging
import os
from typing import Any, Dict, List, Optional
logger = logging.getLogger(__name__)
DB_PATH = os.path.join(os.path.dirname(__file__), "hefa_l2.db")
# ─────────────────────────────────────────────────────────────
# Connection helper
# ─────────────────────────────────────────────────────────────
def get_sqlite() -> sqlite3.Connection:
conn = sqlite3.connect(DB_PATH)
conn.row_factory = sqlite3.Row
conn.execute("PRAGMA journal_mode=WAL")
return conn
# ─────────────────────────────────────────────────────────────
# Schema bootstrap
# ─────────────────────────────────────────────────────────────
PDI_DDL = """
CREATE TABLE IF NOT EXISTS PDI_PLTM (
SID INTEGER,
ROLLPROGRAMNB INTEGER,
SEQUENCENB INTEGER,
STATUS INTEGER DEFAULT 0,
SCHEDULE_CODE TEXT,
COILID TEXT NOT NULL PRIMARY KEY,
ENTRY_COIL_THICKNESS REAL,
ENTRY_COIL_THICKNESS_MAX REAL,
ENTRY_COIL_THICKNESS_MIN REAL,
ENTRY_COIL_WIDTH REAL,
ENTRY_COIL_WIDTH_MAX REAL,
ENTRY_COIL_WIDTH_MIN REAL,
ENTRY_COIL_WEIGHT REAL,
ENTRY_OF_COIL_LENGTH REAL,
ENTRY_OF_COIL_INNER_DIAMETER REAL,
ENTRY_OF_COIL_OUTER_DIAMETER REAL,
TRIMMING INTEGER,
TRIMMING_WIDTH REAL,
SMP_LENGTH REAL,
SMP_NUM REAL,
SMP_FRQ TEXT,
SMP_NUM_HEAD REAL,
SMP_NUM_MID REAL,
SMP_NUM_TAIL REAL,
PRECEDING_PROCESS_CODE TEXT,
NEXT_PROCESS_CODE TEXT,
HOT_MILL_DELIVERY_TEMP REAL,
FINISHED_COIL_TEMP REAL,
CROWN_AVERAGE REAL,
COIL_FLATNESS_AVERAGE REAL,
COIL_FLATNESS_MAX_VALUE REAL,
COIL_FLATNESS_MIN_VALUE REAL,
MATERIAL_YIELD_POINT REAL,
MATERIAL_TENSILE REAL,
HOTACTFMWEDGEAVG REAL,
WEIGHT_MODE TEXT,
DUMMY_COIL_MRK TEXT,
CUT_MODE TEXT,
OFF_GAUGE_HEAD_LENGTH REAL,
OFF_GAUGE_TAIL_LENGTH REAL,
EXIT_COIL_NO TEXT,
EXIT_COIL_WEIGHT REAL,
EXIT_COIL_WEIGHT_MAX REAL,
EXIT_COIL_WEIGHT_MIN REAL,
EXIT_COIL_THICKNESS REAL,
EXIT_COIL_THICKNESS_MAX REAL,
EXIT_COIL_THICKNESS_MIN REAL,
EXIT_COIL_WIDTH REAL,
EXIT_COIL_WIDTH_MAX REAL,
EXIT_COIL_WIDTH_MIN REAL,
WORK_ORDER_NO TEXT,
ORDER_QUALITY TEXT,
STEEL_GRADE TEXT,
SG_SIGN TEXT,
ORDER_THICKNESS REAL,
ORDER_THICKNESS_MAX REAL,
ORDER_THICKNESS_MIN REAL,
ORDER_WIDTH REAL,
ORDER_WIDTH_MAX REAL,
ORDER_WIDTH_MIN REAL,
SLEEVE_CODE_OF_COLD_COIL TEXT,
PACKING_TYPE_CODE TEXT,
THK_DS TEXT,
EXT_NUM_01 TEXT,
C REAL, SI REAL, MN REAL, P REAL, S REAL,
CU REAL, NI REAL, CR REAL, MO REAL, V REAL,
TI REAL, SOL_AL REAL, FE REAL, NB REAL, N REAL, B REAL,
SEND_FLAG TEXT,
SEND_DATE TEXT,
TRANSACTION_ID TEXT,
VERSION INTEGER,
TEXT1 TEXT, TEXT2 TEXT, TEXT3 TEXT, TEXT4 TEXT, TEXT5 TEXT,
TOC TEXT, TOM TEXT, MOP TEXT,
POSITION INTEGER DEFAULT 0,
CROSS_SECTION_AREA REAL,
UNCOILER_TENSION REAL,
LOOPER_TENSION_1 REAL,
PL_TENSION REAL,
LOOPER_TENSION_2 REAL,
LOOPER_TENSION_3 REAL,
METERWEIGHT REAL,
METER_D_OUTSIDE REAL,
METER_WIDTH REAL,
SCRAP_CUT_HEAD_LEN REAL,
SCRAP_CUT_TAIL_LEN REAL,
COILER_DIAMETER INTEGER,
L2_GRADE TEXT,
CREATED_BY TEXT,
CREATED_DT TEXT,
CREATED_BY_NAME TEXT,
UPDATED_BY TEXT,
UPDATED_DT TEXT,
UPDATED_BY_NAME TEXT
)
"""
TRACKMAP_DDL = """
CREATE TABLE IF NOT EXISTS CMPT_PL_TRACKMAP (
POSITION INTEGER PRIMARY KEY,
COILID TEXT,
BEF_ES INTEGER,
ES INTEGER,
ENT_LOO INTEGER,
PL INTEGER,
INT_LOO INTEGER,
ST INTEGER,
EXI_LOO INTEGER,
RUN_SPEED_MIN REAL,
RUN_SPEED_MAX REAL,
WELD_SPEED_MIN REAL,
WELD_SPEED_MAX REAL,
TOC TEXT,
TOM TEXT,
MOP TEXT
)
"""
def init_db():
"""Create tables if they don't exist."""
conn = get_sqlite()
try:
conn.execute(PDI_DDL)
conn.execute(TRACKMAP_DDL)
conn.commit()
logger.info("SQLite schema ready: %s", DB_PATH)
finally:
conn.close()
# ─────────────────────────────────────────────────────────────
# Full sync from Oracle → SQLite
# ─────────────────────────────────────────────────────────────
def _oracle_rows_to_dicts(cursor) -> List[Dict[str, Any]]:
columns = [col[0].upper() for col in cursor.description]
rows = []
for raw in cursor.fetchall():
row = {}
for col, val in zip(columns, raw):
if hasattr(val, 'isoformat'):
val = val.isoformat()
row[col] = val
rows.append(row)
return rows
def sync_pdi_from_oracle() -> int:
"""Pull all PDI_PLTM rows from Oracle and UPSERT into SQLite."""
from database import get_connection
oc = get_connection()
oc_cur = oc.cursor()
try:
oc_cur.execute("SELECT * FROM PDI_PLTM")
rows = _oracle_rows_to_dicts(oc_cur)
finally:
oc_cur.close()
oc.close()
if not rows:
return 0
sc = get_sqlite()
try:
cols = list(rows[0].keys())
placeholders = ", ".join([f":{c}" for c in cols])
col_list = ", ".join(cols)
sql = (
f"INSERT OR REPLACE INTO PDI_PLTM ({col_list}) "
f"VALUES ({placeholders})"
)
sc.executemany(sql, rows)
sc.commit()
logger.info("Synced %d PDI_PLTM rows to SQLite", len(rows))
return len(rows)
finally:
sc.close()
def sync_trackmap_from_oracle() -> int:
"""Pull all CMPT_PL_TRACKMAP rows from Oracle and UPSERT into SQLite."""
from database import get_connection
oc = get_connection()
oc_cur = oc.cursor()
try:
oc_cur.execute(
"SELECT POSITION, COILID, BEF_ES, ES, ENT_LOO, PL, INT_LOO, "
"ST, EXI_LOO, RUN_SPEED_MIN, RUN_SPEED_MAX, "
"WELD_SPEED_MIN, WELD_SPEED_MAX, TOC, TOM, MOP "
"FROM PLTM.CMPT_PL_TRACKMAP ORDER BY POSITION"
)
rows = _oracle_rows_to_dicts(oc_cur)
finally:
oc_cur.close()
oc.close()
if not rows:
return 0
sc = get_sqlite()
try:
cols = list(rows[0].keys())
placeholders = ", ".join([f":{c}" for c in cols])
col_list = ", ".join(cols)
sql = (
f"INSERT OR REPLACE INTO CMPT_PL_TRACKMAP ({col_list}) "
f"VALUES ({placeholders})"
)
sc.executemany(sql, rows)
sc.commit()
logger.info("Synced %d CMPT_PL_TRACKMAP rows to SQLite", len(rows))
return len(rows)
finally:
sc.close()
def sync_all_from_oracle() -> Dict[str, int]:
pdi = sync_pdi_from_oracle()
tm = sync_trackmap_from_oracle()
return {"pdi_pltm": pdi, "cmpt_pl_trackmap": tm}
# ─────────────────────────────────────────────────────────────
# Incremental write-through helpers (called after Oracle commits)
# ─────────────────────────────────────────────────────────────
def sqlite_upsert_pdi(row: Dict[str, Any]):
"""Insert or replace one PDI_PLTM row in SQLite."""
sc = get_sqlite()
try:
upper = {k.upper(): v for k, v in row.items()}
cols = list(upper.keys())
placeholders = ", ".join([f":{c}" for c in cols])
col_list = ", ".join(cols)
sc.execute(
f"INSERT OR REPLACE INTO PDI_PLTM ({col_list}) VALUES ({placeholders})",
upper
)
sc.commit()
finally:
sc.close()
def sqlite_delete_pdi(coilid: str):
sc = get_sqlite()
try:
sc.execute("DELETE FROM PDI_PLTM WHERE COILID = ?", (coilid,))
sc.commit()
finally:
sc.close()
def sqlite_upsert_trackmap(row: Dict[str, Any]):
sc = get_sqlite()
try:
upper = {k.upper(): v for k, v in row.items()}
cols = list(upper.keys())
placeholders = ", ".join([f":{c}" for c in cols])
col_list = ", ".join(cols)
sc.execute(
f"INSERT OR REPLACE INTO CMPT_PL_TRACKMAP ({col_list}) VALUES ({placeholders})",
upper
)
sc.commit()
finally:
sc.close()

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

139
sql/CMPT_PL_TRACLMAP.sql Normal file
View File

@@ -0,0 +1,139 @@
ALTER TABLE PLTM.CMPT_PL_TRACKMAP
DROP PRIMARY KEY CASCADE;
DROP TABLE PLTM.CMPT_PL_TRACKMAP CASCADE CONSTRAINTS;
CREATE TABLE PLTM.CMPT_PL_TRACKMAP
(
POSITION NUMBER(2),
COILID VARCHAR2(25 BYTE),
BEF_ES NUMBER(1),
ES NUMBER(1),
ENT_LOO NUMBER(1),
PL NUMBER(1),
INT_LOO NUMBER(1),
ST NUMBER(1),
EXI_LOO NUMBER(1),
RUN_SPEED_MIN NUMBER(3,1),
RUN_SPEED_MAX NUMBER(3,1),
WELD_SPEED_MIN NUMBER(3,1),
WELD_SPEED_MAX NUMBER(3,1),
TOC DATE,
TOM DATE,
MOP VARCHAR2(60 BYTE)
)
TABLESPACE USERS
RESULT_CACHE (MODE DEFAULT)
PCTUSED 0
PCTFREE 10
INITRANS 1
MAXTRANS 255
STORAGE (
INITIAL 64K
NEXT 1M
MAXSIZE UNLIMITED
MINEXTENTS 1
MAXEXTENTS UNLIMITED
PCTINCREASE 0
BUFFER_POOL DEFAULT
FLASH_CACHE DEFAULT
CELL_FLASH_CACHE DEFAULT
)
LOGGING
NOCOMPRESS
NOCACHE
NOPARALLEL
MONITORING;
CREATE UNIQUE INDEX PLTM.CMPT_PL_TRACKMAP_PK ON PLTM.CMPT_PL_TRACKMAP
(POSITION)
LOGGING
TABLESPACE USERS
PCTFREE 10
INITRANS 2
MAXTRANS 255
STORAGE (
INITIAL 64K
NEXT 1M
MAXSIZE UNLIMITED
MINEXTENTS 1
MAXEXTENTS UNLIMITED
PCTINCREASE 0
BUFFER_POOL DEFAULT
FLASH_CACHE DEFAULT
CELL_FLASH_CACHE DEFAULT
)
NOPARALLEL;
CREATE OR REPLACE TRIGGER PLTM.INS_B_CMPT_PL_TRACKMAP
BEFORE INSERT ON PLTM.CMPT_PL_TRACKMAP
FOR EACH ROW
DECLARE
Programm VARCHAR(60);
user VARCHAR(30);
session_id Number;
BEGIN
SELECT userenv('SESSIONID') INTO session_id FROM dual;
BEGIN
SELECT program,username INTO programm,user FROM V$SESSION WHERE audsid = session_id;
EXCEPTION WHEN NO_DATA_FOUND THEN
programm := 'UNKNOWN';
user := 'UNKNOWN';
WHEN OTHERS THEN
If (session_id = 0) Then
programm := 'JOB';
Else
programm := 'UNKNOWN';
END IF;
END;
:new.toc := sysdate;
:new.mop := substr( programm ,1 ,60);
END;
/
CREATE OR REPLACE TRIGGER PLTM.UPD_B_CMPT_PL_TRACKMAP
BEFORE UPDATE
ON PLTM.CMPT_PL_TRACKMAP
REFERENCING NEW AS NEW OLD AS OLD
FOR EACH ROW
DECLARE
Programm VARCHAR(60);
user VARCHAR(30);
session_id Number;
nPosition number;
szCoilID VARCHAR2 (20);
BEGIN
szCoilID = SUBSTR(:NEW.COILID,1,12) + '-00';
UPDATE RASTCMDB.CMPT_PL_TRACKMAP
SET COILID = szCoilID WHERE POSITION = :NEW.POSITION ;
SELECT userenv('SESSIONID') INTO session_id FROM dual;
BEGIN
SELECT program,username INTO programm,user FROM V$SESSION WHERE audsid = session_id;
EXCEPTION WHEN NO_DATA_FOUND THEN
programm := 'UNKNOWN';
user := 'UNKNOWN';
WHEN OTHERS THEN
If (session_id = 0) Then
programm := 'JOB';
Else
programm := 'UNKNOWN';
END IF;
END;
:new.tom := sysdate;
:new.mop := substr( programm ,1 ,60);
END;
/
ALTER TABLE PLTM.CMPT_PL_TRACKMAP ADD (
CONSTRAINT CMPT_PL_TRACKMAP_PK
PRIMARY KEY
(POSITION)
USING INDEX PLTM.CMPT_PL_TRACKMAP_PK
ENABLE VALIDATE);

1150
sql/PDI_PLTM.sql Normal file

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

45
start_tdh.bat Normal file
View File

@@ -0,0 +1,45 @@
@echo off
chcp 65001
color 0A
echo ==============================================
echo TDH-L2 全自动一键部署启动
echo Conda环境: tdh
echo Python版本: 3.10
echo ==============================================
echo.
:: ===================== 固定配置 =====================
set "CONDA_ENV=tdh"
set "PY_VER=3.10"
set "BACKEND_DIR=backend"
set "FRONTEND_DIR=frontend"
:: =====================================================
echo [1/4] 检查并创建 conda 环境...
conda env list | findstr %CONDA_ENV%
if %errorlevel% equ 0 (
echo 环境 tdh 已存在
) else (
echo 创建环境 tdh Python=%PY_VER%
conda create -n %CONDA_ENV% python=%PY_VER% -y
)
echo.
echo [2/4] 安装后端依赖...
call conda activate %CONDA_ENV%
cd /d "%BACKEND_DIR%"
pip install -r requirements.txt -i https://pypi.tuna.tsinghua.edu.cn/simple
cd ..
echo.
echo [4/4] 启动前后端...
start "【后端】" cmd /k "chcp 65001 & call conda activate %CONDA_ENV% & cd /d ""%BACKEND_DIR%"" & uvicorn main:app --host 0.0.0.0 --port 8000 --reload"
timeout /t 2 /nobreak >nul
start "【前端】" cmd /k "chcp 65001 & cd /d ""%FRONTEND_DIR%"" & npm run serve"
echo.
echo ================= 启动完成 =================
echo 后端http://127.0.0.1:8000
echo 前端http://localhost:8080
echo ==============================================
pause