feat: 初始化HEFA-L2 PDI管理系统项目
添加前端Vue2项目结构,包括ElementUI集成、路由配置和API模块 实现后端FastAPI服务,包含Oracle数据库连接和PDI CRUD接口 添加OPC-UA轮询服务,支持跟踪图数据同步到Oracle 提供SQLite镜像数据库用于本地开发和快速查询 包含完整的部署脚本和文档说明
This commit is contained in:
78
.gitignore
vendored
Normal file
78
.gitignore
vendored
Normal 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
114
README.md
Normal 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
9
backend/.env.example
Normal 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
19
backend/database.py
Normal 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
330
backend/main.py
Normal 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
184
backend/models.py
Normal 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
244
backend/opc_service.py
Normal 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
9
backend/requirements.txt
Normal 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
303
backend/sqlite_sync.py
Normal 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
13825
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
19
frontend/package.json
Normal file
19
frontend/package.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
11
frontend/public/index.html
Normal file
11
frontend/public/index.html
Normal 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
321
frontend/src/App.vue
Normal 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
45
frontend/src/api/index.js
Normal 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
13
frontend/src/main.js
Normal 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
16
frontend/src/router.js
Normal 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配置' } }
|
||||||
|
]
|
||||||
|
})
|
||||||
124
frontend/src/views/OpcConfig.vue
Normal file
124
frontend/src/views/OpcConfig.vue
Normal 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>
|
||||||
553
frontend/src/views/PdiList.vue
Normal file
553
frontend/src/views/PdiList.vue
Normal 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">同步Oracle→SQLite</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>
|
||||||
104
frontend/src/views/TrackMap.vue
Normal file
104
frontend/src/views/TrackMap.vue
Normal 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
11
frontend/vue.config.js
Normal 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
139
sql/CMPT_PL_TRACLMAP.sql
Normal 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
1150
sql/PDI_PLTM.sql
Normal file
File diff suppressed because it is too large
Load Diff
1154
sql/PLTM.PDI_PLTM 表结构(1).txt
Normal file
1154
sql/PLTM.PDI_PLTM 表结构(1).txt
Normal file
File diff suppressed because it is too large
Load Diff
45
start_tdh.bat
Normal file
45
start_tdh.bat
Normal 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
|
||||||
Reference in New Issue
Block a user