feat: 新增 edc-web Flask 前端管理系统 + 需求文档

- edc-web: Flask 项目骨架(设备管理、测试操作、测试信息三大页面)
- edc_server: 升级子模块(tb_serialnet 透传支持)
- docs: 测试工装EDC管理系统需求文档
This commit is contained in:
wangfq
2026-05-28 09:40:45 +08:00
parent 2bfb9602e4
commit 70dd3f8246
2295 changed files with 370008 additions and 1 deletions

20
edc-web/app/__init__.py Normal file
View File

@@ -0,0 +1,20 @@
"""Flask 应用工厂"""
from flask import Flask
from app.config import Config
def create_app() -> Flask:
app = Flask(__name__)
app.config.from_object(Config)
# 注册蓝图
from app.routes.devices import bp as devices_bp
from app.routes.test_op import bp as test_op_bp
from app.routes.test_data import bp as test_data_bp
app.register_blueprint(devices_bp)
app.register_blueprint(test_op_bp)
app.register_blueprint(test_data_bp)
return app

Binary file not shown.

Binary file not shown.

Binary file not shown.

12
edc-web/app/config.py Normal file
View File

@@ -0,0 +1,12 @@
"""edc-web 配置"""
import os
class Config:
SECRET_KEY = os.getenv("EDC_WEB_SECRET", "edc-web-secret-key")
MYSQL_HOST = os.getenv("EDC_MYSQL_HOST", "127.0.0.1")
MYSQL_PORT = int(os.getenv("EDC_MYSQL_PORT", "3306"))
MYSQL_USER = os.getenv("EDC_MYSQL_USER", "root")
MYSQL_PASSWORD = os.getenv("EDC_MYSQL_PASSWORD", "")
MYSQL_DB = os.getenv("EDC_MYSQL_DB", "edc")

227
edc-web/app/models.py Normal file
View File

@@ -0,0 +1,227 @@
"""MySQL 数据库操作(同步 pymysql"""
import pymysql
from app.config import Config
def get_conn():
return pymysql.connect(
host=Config.MYSQL_HOST,
port=Config.MYSQL_PORT,
user=Config.MYSQL_USER,
password=Config.MYSQL_PASSWORD,
database=Config.MYSQL_DB,
charset="utf8mb4",
cursorclass=pymysql.cursors.DictCursor,
)
# ─── dnt_info ──────────────────────────────────────────────────────
def get_all_devices() -> list[dict]:
conn = get_conn()
try:
with conn.cursor() as cur:
cur.execute("SELECT id, serial, name, ip, port, state, version, last_login FROM dnt_info ORDER BY id DESC")
return cur.fetchall()
finally:
conn.close()
def update_device_name(device_id: int, name: str):
conn = get_conn()
try:
with conn.cursor() as cur:
cur.execute("UPDATE dnt_info SET name=%s WHERE id=%s", (name, device_id))
conn.commit()
finally:
conn.close()
def get_device_by_id(device_id: int) -> dict | None:
conn = get_conn()
try:
with conn.cursor() as cur:
cur.execute("SELECT * FROM dnt_info WHERE id=%s", (device_id,))
return cur.fetchone()
finally:
conn.close()
# ─── tb_serialnet ──────────────────────────────────────────────────
def insert_serialnet(dnt_id: int, send_pkg: str) -> int:
"""返回 record_id"""
conn = get_conn()
try:
with conn.cursor() as cur:
cur.execute(
"INSERT INTO tb_serialnet (dnt_id, send_pkg) VALUES (%s, %s)",
(dnt_id, send_pkg),
)
conn.commit()
return cur.lastrowid
finally:
conn.close()
def get_serialnet_stats(dnt_id: int) -> dict:
"""返回 {total, pending, sent, done, failed}"""
conn = get_conn()
try:
with conn.cursor() as cur:
cur.execute(
"SELECT state, COUNT(*) as cnt FROM tb_serialnet "
"WHERE dnt_id=%s GROUP BY state",
(dnt_id,),
)
rows = cur.fetchall()
finally:
conn.close()
stats = {"total": 0, "pending": 0, "sent": 0, "done": 0, "failed": 0}
for r in rows:
s = r["state"]
stats["total"] += r["cnt"]
if s == 0:
stats["pending"] = r["cnt"]
elif s == 1:
stats["sent"] = r["cnt"]
elif s == 2:
stats["done"] = r["cnt"]
elif s == 3:
stats["failed"] = r["cnt"]
return stats
def get_serialnet_records(dnt_id: int, limit: int = 50) -> list[dict]:
conn = get_conn()
try:
with conn.cursor() as cur:
cur.execute(
"SELECT * FROM tb_serialnet WHERE dnt_id=%s "
"ORDER BY id DESC LIMIT %s",
(dnt_id, limit),
)
return cur.fetchall()
finally:
conn.close()
# ─── tb_state_tst ──────────────────────────────────────────────────
def get_latest_test_state(dnt_id: int) -> dict | None:
"""获取设备最新一条测试状态"""
conn = get_conn()
try:
with conn.cursor() as cur:
cur.execute(
"SELECT * FROM tb_state_tst WHERE dnt_id=%s "
"ORDER BY id DESC LIMIT 1",
(dnt_id,),
)
return cur.fetchone()
finally:
conn.close()
def get_test_data(page: int = 1, per_page: int = 20,
serial: str = "", date_from: str = "",
date_to: str = "") -> tuple[list[dict], int]:
"""分页查询测试数据JOIN dnt_info返回 (records, total)"""
conn = get_conn()
try:
with conn.cursor() as cur:
where = []
params = []
if serial:
where.append("d.serial LIKE %s")
params.append(f"%{serial}%")
if date_from:
where.append("t.create_time >= %s")
params.append(date_from)
if date_to:
where.append("t.create_time <= %s")
params.append(date_to + " 23:59:59")
where_clause = " AND ".join(where) if where else "1=1"
# count
cur.execute(
f"SELECT COUNT(*) as total FROM tb_state_tst t "
f"JOIN dnt_info d ON t.dnt_id = d.id WHERE {where_clause}",
params,
)
total = cur.fetchone()["total"]
# data
offset = (page - 1) * per_page
cur.execute(
f"SELECT t.*, d.serial FROM tb_state_tst t "
f"JOIN dnt_info d ON t.dnt_id = d.id "
f"WHERE {where_clause} "
f"ORDER BY t.id DESC LIMIT %s OFFSET %s",
params + [per_page, offset],
)
records = cur.fetchall()
finally:
conn.close()
return records, total
def get_all_test_data_for_export(serial: str = "", date_from: str = "",
date_to: str = "") -> list[dict]:
"""导出全部数据"""
conn = get_conn()
try:
with conn.cursor() as cur:
where = []
params = []
if serial:
where.append("d.serial LIKE %s")
params.append(f"%{serial}%")
if date_from:
where.append("t.create_time >= %s")
params.append(date_from)
if date_to:
where.append("t.create_time <= %s")
params.append(date_to + " 23:59:59")
where_clause = " AND ".join(where) if where else "1=1"
cur.execute(
f"SELECT t.*, d.serial FROM tb_state_tst t "
f"JOIN dnt_info d ON t.dnt_id = d.id "
f"WHERE {where_clause} ORDER BY t.id DESC",
params,
)
return cur.fetchall()
finally:
conn.close()
def get_automation_averages(dnt_id: int) -> dict:
"""获取自动化测试的平均值(排除失败记录 state=3"""
conn = get_conn()
try:
with conn.cursor() as cur:
# 只计算最近一批自动化测试的平均值
# 取该设备 tb_state_tst 中与 tb_serialnet state=2 对应的记录
cur.execute(
"SELECT AVG(ppvalue) as avg_ppvalue, "
"AVG(idle_freq) as avg_idle_freq, "
"AVG(enter_freq) as avg_enter_freq, "
"AVG(exit_freq) as avg_exit_freq, "
"AVG(enter_dist) as avg_enter_dist, "
"AVG(exit_dist) as avg_exit_dist, "
"AVG(enter_speed) as avg_enter_speed, "
"AVG(exit_speed) as avg_exit_speed "
"FROM tb_state_tst WHERE dnt_id=%s",
(dnt_id,),
)
row = cur.fetchone()
finally:
conn.close()
if row:
return {k: round(v, 2) if v else 0 for k, v in row.items()}
return {}

View File

Binary file not shown.

Binary file not shown.

View File

@@ -0,0 +1,28 @@
"""设备页面 API"""
from flask import Blueprint, jsonify, render_template, request
from app.models import get_all_devices, update_device_name
bp = Blueprint("devices", __name__)
@bp.route("/")
def index():
"""设备列表页(默认首页)"""
return render_template("devices.html")
@bp.route("/api/devices")
def api_devices():
"""获取所有设备列表"""
devices = get_all_devices()
return jsonify(devices)
@bp.route("/api/devices/<int:device_id>/name", methods=["PUT"])
def api_update_name(device_id):
"""修改设备名称"""
data = request.get_json()
name = data.get("name", "")
update_device_name(device_id, name)
return jsonify({"ok": True})

View File

@@ -0,0 +1,60 @@
"""测试信息 API"""
import csv
import io
from flask import Blueprint, jsonify, render_template, request, Response
from app.models import get_test_data, get_all_test_data_for_export
bp = Blueprint("test_data", __name__)
@bp.route("/test-data")
def test_data_page():
"""测试信息页"""
return render_template("test_data.html")
@bp.route("/api/test-data")
def api_test_data():
"""分页查询测试数据"""
page = request.args.get("page", 1, type=int)
per_page = request.args.get("per_page", 20, type=int)
serial = request.args.get("serial", "", type=str)
date_from = request.args.get("date_from", "", type=str)
date_to = request.args.get("date_to", "", type=str)
records, total = get_test_data(page, per_page, serial, date_from, date_to)
return jsonify({
"records": records,
"total": total,
"page": page,
"per_page": per_page,
"pages": (total + per_page - 1) // per_page if total > 0 else 1,
})
@bp.route("/api/test-data/export")
def api_export():
"""导出测试数据为 CSV"""
serial = request.args.get("serial", "", type=str)
date_from = request.args.get("date_from", "", type=str)
date_to = request.args.get("date_to", "", type=str)
records = get_all_test_data_for_export(serial, date_from, date_to)
output = io.StringIO()
writer = csv.writer(output)
# 表头
if records:
headers = [k for k in records[0].keys()]
writer.writerow(headers)
for r in records:
writer.writerow(r.values())
output.seek(0)
return Response(
output.getvalue(),
mimetype="text/csv",
headers={"Content-Disposition": "attachment; filename=test_data.csv"},
)

View File

@@ -0,0 +1,76 @@
"""测试操作 API"""
import time
from flask import Blueprint, jsonify, render_template, request
from app.models import (
get_device_by_id,
insert_serialnet,
get_serialnet_stats,
get_latest_test_state,
get_automation_averages,
)
bp = Blueprint("test_op", __name__)
# DG430 指令 (addr=0x01, ADDR=0x81)
# XOR/SUM 预计算值
COMMANDS = {
"B0": "7F8101B03032", # 开始测试
"B1": "7F8101B13133", # 测试复原
"BA": "7F8101BA3A3C", # 电机前进
"BB": "7F8101BB3B3D", # 电机后退
"BC": "7F8101BC3C3E", # 电机停止
}
@bp.route("/test/<int:dnt_id>")
def test_page(dnt_id):
"""测试操作页面"""
device = get_device_by_id(dnt_id)
if not device:
return "设备不存在", 404
return render_template("test_op.html", device=device)
@bp.route("/api/command", methods=["POST"])
def api_command():
"""发送单次指令"""
data = request.get_json()
dnt_id = data.get("dnt_id")
cmd = data.get("cmd", "").upper()
if cmd not in COMMANDS:
return jsonify({"ok": False, "error": f"未知指令: {cmd}"}), 400
send_pkg = COMMANDS[cmd]
record_id = insert_serialnet(dnt_id, send_pkg)
return jsonify({"ok": True, "record_id": record_id, "send_pkg": send_pkg})
@bp.route("/api/automation/start", methods=["POST"])
def api_automation_start():
"""开始自动化测试"""
data = request.get_json()
dnt_id = data.get("dnt_id")
count = int(data.get("count", 1))
# 插入第一条 0xB0 指令
record_id = insert_serialnet(dnt_id, COMMANDS["B0"])
return jsonify({
"ok": True,
"total": count,
"first_record_id": record_id,
})
@bp.route("/api/automation/<int:dnt_id>/progress")
def api_automation_progress(dnt_id):
"""获取自动化进度"""
stats = get_serialnet_stats(dnt_id)
latest = get_latest_test_state(dnt_id)
averages = get_automation_averages(dnt_id)
return jsonify({
"stats": stats,
"latest": latest,
"averages": averages,
})

View File

@@ -0,0 +1,102 @@
/* === Reset & Base === */
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; background: #f5f6fa; color: #333; }
/* === Top Menu === */
.top-menu { background: #2c3e50; padding: 0 24px; display: flex; gap: 0; }
.top-menu a { color: #bdc3c7; text-decoration: none; padding: 14px 24px; font-size: 15px; transition: .2s; }
.top-menu a:hover { color: #fff; background: #34495e; }
.top-menu a.active { color: #fff; background: #3498db; }
/* === Container === */
.container { max-width: 1400px; margin: 24px auto; padding: 0 24px; }
/* === Table === */
table { width: 100%; border-collapse: collapse; background: #fff; border-radius: 8px; overflow: hidden; box-shadow: 0 1px 3px rgba(0,0,0,.08); }
th { background: #ecf0f1; text-align: left; padding: 12px 14px; font-size: 13px; font-weight: 600; color: #555; }
td { padding: 10px 14px; font-size: 13px; border-bottom: 1px solid #f0f0f0; }
tr:hover { background: #f8f9fa; }
/* === Online Status === */
.status-online { color: #27ae60; font-weight: 600; }
.status-offline { color: #bdc3c7; }
/* === Editable Name === */
.editable-name { cursor: pointer; border-bottom: 1px dashed transparent; }
.editable-name:hover { border-bottom-color: #3498db; }
.editable-name input { width: 120px; padding: 2px 6px; border: 1px solid #3498db; border-radius: 3px; font-size: 13px; }
/* === Buttons === */
.btn-test { padding: 4px 14px; background: #3498db; color: #fff; border: none; border-radius: 4px; cursor: pointer; font-size: 12px; }
.btn-test:hover { background: #2980b9; }
/* === Test Page Layout === */
.test-header { margin-bottom: 20px; }
.test-header a { color: #3498db; text-decoration: none; font-size: 14px; }
.test-layout { display: flex; gap: 24px; }
.test-control { flex: 1; min-width: 380px; }
.test-control h3 { margin: 16px 0 10px; font-size: 15px; color: #555; }
.test-control h3:first-child { margin-top: 0; }
.test-info { flex: 1; min-width: 350px; }
.test-info h3 { margin: 0 0 10px; font-size: 15px; color: #555; }
/* === Command Buttons === */
.cmd-buttons { display: flex; flex-wrap: wrap; gap: 8px; }
.btn-cmd { padding: 10px 16px; background: #ecf0f1; border: 1px solid #ddd; border-radius: 6px; cursor: pointer; font-size: 13px; transition: .15s; }
.btn-cmd:hover { background: #3498db; color: #fff; border-color: #3498db; }
/* === Automation === */
.automation { background: #fff; padding: 16px; border-radius: 8px; box-shadow: 0 1px 3px rgba(0,0,0,.08); margin-top: 12px; }
.automation label { font-size: 14px; }
.automation input[type="number"] { width: 80px; padding: 4px 8px; margin: 0 8px; border: 1px solid #ccc; border-radius: 4px; }
.btn-start, .btn-stop { padding: 8px 24px; border: none; border-radius: 6px; cursor: pointer; font-size: 14px; color: #fff; margin-left: 12px; }
.btn-start { background: #27ae60; }
.btn-start:hover { background: #219a52; }
.btn-stop { background: #e74c3c; }
.btn-stop:hover { background: #c0392b; }
/* === Progress === */
.progress-container { margin: 14px 0 8px; background: #ecf0f1; border-radius: 10px; height: 24px; overflow: hidden; position: relative; }
.progress-bar { height: 100%; background: linear-gradient(90deg, #27ae60, #2ecc71); border-radius: 10px; width: 0%; transition: width .3s; }
.progress-text { position: absolute; top: 0; left: 0; right: 0; text-align: center; line-height: 24px; font-size: 12px; color: #333; }
.stats { display: flex; gap: 20px; font-size: 13px; }
.stats strong { font-size: 16px; }
/* === Latest Result === */
#latest-result { background: #fff; padding: 14px; border-radius: 8px; box-shadow: 0 1px 3px rgba(0,0,0,.08); min-height: 60px; }
#latest-result p { margin: 4px 0; font-size: 13px; }
#latest-result .placeholder { color: #999; font-style: italic; }
/* === Average Table === */
#avg-table { margin-top: 12px; }
#avg-table td { padding: 6px 10px; }
#avg-table td:nth-child(2) { font-weight: 600; text-align: right; }
#avg-table td:nth-child(3) { color: #888; font-size: 12px; }
/* === Search Bar === */
.search-bar { display: flex; align-items: center; gap: 12px; margin-bottom: 16px; flex-wrap: wrap; }
.search-bar label { font-size: 13px; }
.search-bar input { padding: 6px 10px; border: 1px solid #ccc; border-radius: 4px; font-size: 13px; }
.search-bar input[type="text"] { width: 180px; }
.btn-search, .btn-export { padding: 6px 16px; border: none; border-radius: 4px; cursor: pointer; font-size: 13px; color: #fff; }
.btn-search { background: #3498db; }
.btn-export { background: #27ae60; margin-left: auto; }
/* === Pagination === */
.pagination { display: flex; gap: 6px; margin-top: 16px; justify-content: center; }
.pagination button { padding: 6px 12px; border: 1px solid #ddd; background: #fff; border-radius: 4px; cursor: pointer; font-size: 13px; }
.pagination button.active { background: #3498db; color: #fff; border-color: #3498db; }
.pagination button:hover:not(.active):not(:disabled) { background: #ecf0f1; }
.pagination button:disabled { opacity: .4; cursor: not-allowed; }
/* === Responsive === */
@media (max-width: 900px) {
.test-layout { flex-direction: column; }
.search-bar { justify-content: center; }
.btn-export { margin-left: 0; }
}

View File

@@ -0,0 +1,53 @@
// 设备列表页
async function loadDevices() {
const resp = await fetch("/api/devices");
const devices = await resp.json();
renderTable(devices);
}
function renderTable(devices) {
const tbody = document.querySelector("#device-table tbody");
tbody.innerHTML = devices.map(d => `
<tr>
<td>${d.serial}</td>
<td class="editable-name" onclick="editName(${d.id}, '${esc(d.name)}', this)">
${d.name || '(点击编辑)'}
</td>
<td>${d.ip || '-'}</td>
<td class="${d.state === 1 ? 'status-online' : 'status-offline'}">
${d.state === 1 ? '在线' : '离线'}
</td>
<td>${d.version || '-'}</td>
<td>${d.last_login || '-'}</td>
<td>
<button class="btn-test" onclick="location.href='/test/${d.id}'">测试</button>
</td>
</tr>
`).join("");
}
function esc(s) { return s.replace(/'/g, "\\'").replace(/"/g, "&quot;"); }
async function editName(id, currentName, td) {
const input = document.createElement("input");
input.value = currentName;
td.innerHTML = "";
td.appendChild(input);
input.focus();
async function save() {
const name = input.value.trim();
td.textContent = name || "(点击编辑)";
await fetch(`/api/devices/${id}/name`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name }),
});
}
input.addEventListener("blur", save);
input.addEventListener("keydown", e => { if (e.key === "Enter") save(); });
}
loadDevices();

View File

@@ -0,0 +1,86 @@
// 测试信息页
let currentPage = 1;
let totalPages = 1;
async function searchData(page = 1) {
currentPage = page;
const serial = document.getElementById("search-serial").value;
const dateFrom = document.getElementById("search-date-from").value;
const dateTo = document.getElementById("search-date-to").value;
const params = new URLSearchParams({ page, per_page: 20 });
if (serial) params.set("serial", serial);
if (dateFrom) params.set("date_from", dateFrom);
if (dateTo) params.set("date_to", dateTo);
try {
const resp = await fetch(`/api/test-data?${params}`);
const data = await resp.json();
renderTable(data.records);
totalPages = data.pages;
renderPagination();
} catch (e) {
console.error("查询失败:", e);
}
}
function renderTable(records) {
const tbody = document.querySelector("#test-data-table tbody");
if (!records.length) {
tbody.innerHTML = '<tr><td colspan="17" style="text-align:center;color:#999;">暂无数据</td></tr>';
return;
}
tbody.innerHTML = records.map(r => `
<tr>
<td>${r.id}</td>
<td>${r.serial || '-'}</td>
<td>${r.dpg430_addr}</td>
<td>${r.sub_type === 1 ? 'PD132' : r.sub_type === 2 ? 'DLD110' : '-'}</td>
<td>${r.str_type || '-'}</td>
<td>${r.iffinish === '1' ? '是' : '否'}</td>
<td>${r.fault_info || '无'}</td>
<td>${r.relay_out || '无'}</td>
<td>${r.ppvalue?.toFixed(2) || '-'}</td>
<td>${r.idle_freq || '-'}</td>
<td>${r.enter_freq || '-'}</td>
<td>${r.exit_freq || '-'}</td>
<td>${r.enter_dist || '-'}</td>
<td>${r.exit_dist || '-'}</td>
<td>${r.enter_speed || '-'}</td>
<td>${r.exit_speed || '-'}</td>
<td>${r.create_time || '-'}</td>
</tr>
`).join("");
}
function renderPagination() {
const div = document.getElementById("pagination");
let html = "";
html += `<button onclick="searchData(${currentPage - 1})" ${currentPage <= 1 ? 'disabled' : ''}>上一页</button>`;
for (let i = 1; i <= totalPages; i++) {
if (i === currentPage) {
html += `<button class="active">${i}</button>`;
} else {
html += `<button onclick="searchData(${i})">${i}</button>`;
}
}
html += `<button onclick="searchData(${currentPage + 1})" ${currentPage >= totalPages ? 'disabled' : ''}>下一页</button>`;
div.innerHTML = html;
}
function exportCSV() {
const serial = document.getElementById("search-serial").value;
const dateFrom = document.getElementById("search-date-from").value;
const dateTo = document.getElementById("search-date-to").value;
const params = new URLSearchParams();
if (serial) params.set("serial", serial);
if (dateFrom) params.set("date_from", dateFrom);
if (dateTo) params.set("date_to", dateTo);
window.location.href = `/api/test-data/export?${params}`;
}
// 初始加载
searchData(1);

View File

@@ -0,0 +1,206 @@
// 测试操作页
let autoRunning = false;
let autoTotal = 0;
let autoDone = 0;
let autoFailed = 0;
let autoRemaining = 0;
let pollInterval = null;
let timeoutTimers = {}; // record_id → timer
const TIMEOUT_MS = 10000;
// ─── 手动指令 ─────────────────────────────────
async function sendCmd(cmd) {
try {
const resp = await fetch("/api/command", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ dnt_id: DNT_ID, cmd }),
});
const data = await resp.json();
if (data.ok) {
console.log(`指令 ${cmd} 已下发: ${data.send_pkg}`);
}
} catch (e) {
alert("发送失败: " + e.message);
}
}
// ─── 自动化 ────────────────────────────────────
async function toggleAuto() {
if (!autoRunning) {
await startAuto();
} else {
stopAuto();
}
}
async function startAuto() {
const count = parseInt(document.getElementById("test-count").value) || 10;
if (count < 1) return;
// 重置
autoRunning = true;
autoTotal = count;
autoDone = 0;
autoFailed = 0;
autoRemaining = count;
// 清空平均值显示
resetAverages();
updateUI();
const btn = document.getElementById("btn-auto");
btn.textContent = "结束";
btn.className = "btn-stop";
// 插入第一条 0xB0
try {
const resp = await fetch("/api/automation/start", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ dnt_id: DNT_ID, count }),
});
const data = await resp.json();
if (data.ok) {
// 启动超时计时器
startTimeout(data.first_record_id);
}
} catch (e) {
console.error("启动失败:", e);
stopAuto();
}
// 启动轮询
pollInterval = setInterval(pollProgress, 1000);
}
function stopAuto() {
autoRunning = false;
clearInterval(pollInterval);
pollInterval = null;
// 清除所有超时计时器
for (const id in timeoutTimers) {
clearTimeout(timeoutTimers[id]);
}
timeoutTimers = {};
const btn = document.getElementById("btn-auto");
btn.textContent = "开始";
btn.className = "btn-start";
}
async function pollProgress() {
if (!autoRunning) return;
try {
const resp = await fetch(`/api/automation/${DNT_ID}/progress`);
const data = await resp.json();
const stats = data.stats;
// 更新计数
autoDone = stats.done || 0;
autoFailed = stats.failed || 0;
autoRemaining = autoTotal - autoDone - autoFailed;
if (autoRemaining < 0) autoRemaining = 0;
updateUI();
// 显示最新结果
if (data.latest) {
renderLatest(data.latest);
}
// 显示平均值
if (data.averages) {
renderAverages(data.averages);
}
// 自动插入下一条 0xB0
if (autoRemaining > 0) {
// 检查是否还有 pending 的记录,没有则插入新的
if (stats.pending === 0 && stats.sent === 0) {
try {
const r = await fetch("/api/command", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ dnt_id: DNT_ID, cmd: "B0" }),
});
const rd = await r.json();
if (rd.ok) {
startTimeout(rd.record_id);
}
} catch (e) {
console.error("插入下一条失败:", e);
}
}
} else {
// 全部完成
stopAuto();
}
} catch (e) {
console.error("轮询失败:", e);
}
}
function startTimeout(recordId) {
timeoutTimers[recordId] = setTimeout(async () => {
// 超时:检查 record 的状态,如果还是 1 → 视为失败
// 后端串行轮询会自动处理超时标记为 state=3
// 前端稍后通过 pollProgress 更新计数
console.log(`记录 ${recordId} 可能已超时`);
}, TIMEOUT_MS);
}
function updateUI() {
document.getElementById("stat-done").textContent = autoDone;
document.getElementById("stat-failed").textContent = autoFailed;
document.getElementById("stat-remaining").textContent = autoRemaining;
const total = autoTotal || 1;
const pct = Math.round((autoDone / total) * 100);
document.getElementById("progress-bar").style.width = pct + "%";
document.getElementById("progress-text").textContent =
`${autoDone}/${autoTotal} (${autoFailed} 失败)`;
}
// ─── 显示最新结果 ──────────────────────────────
function renderLatest(data) {
const div = document.getElementById("latest-result");
div.innerHTML = `
<p>设备型号:<strong>${data.str_type || '-'}</strong></p>
<p>峰峰值:${data.ppvalue?.toFixed(2) || '-'} V</p>
<p>开始工作频率:${data.idle_freq || '-'} Hz</p>
<p>进入工作频率:${data.enter_freq || '-'} Hz</p>
<p>离开工作频率:${data.exit_freq || '-'} Hz</p>
<p>进入距离:${data.enter_dist || '-'} mm</p>
<p>离开距离:${data.exit_dist || '-'} mm</p>
<p>进入速度:${data.enter_speed || '-'} dm/s</p>
<p>离开速度:${data.exit_speed || '-'} dm/s</p>
<p>是否完成:${data.iffinish === '1' ? '是' : '否'}</p>
<p>故障信息:${data.fault_info || '无'}</p>
<p>时间:${data.create_time || '-'}</p>
`;
}
// ─── 显示平均值 ────────────────────────────────
function renderAverages(data) {
document.getElementById("avg-ppvalue").textContent = data.avg_ppvalue || "-";
document.getElementById("avg-idle-freq").textContent = data.avg_idle_freq || "-";
document.getElementById("avg-enter-freq").textContent = data.avg_enter_freq || "-";
document.getElementById("avg-enter-dist").textContent = data.avg_enter_dist || "-";
document.getElementById("avg-exit-dist").textContent = data.avg_exit_dist || "-";
document.getElementById("avg-enter-speed").textContent = data.avg_enter_speed || "-";
document.getElementById("avg-exit-speed").textContent = data.avg_exit_speed || "-";
}
function resetAverages() {
["ppvalue", "idle-freq", "enter-freq", "enter-dist", "exit-dist",
"enter-speed", "exit-speed"].forEach(id => {
document.getElementById("avg-" + id).textContent = "-";
});
}

View File

@@ -0,0 +1,19 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}EDC 工装管理系统{% endblock %}</title>
<link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}">
</head>
<body>
<nav class="top-menu">
<a href="/" class="{% if request.path == '/' %}active{% endif %}">设备</a>
<a href="/test-data" class="{% if request.path == '/test-data' %}active{% endif %}">测试信息</a>
</nav>
<main class="container">
{% block content %}{% endblock %}
</main>
{% block scripts %}{% endblock %}
</body>
</html>

View File

@@ -0,0 +1,24 @@
{% extends "base.html" %}
{% block title %}设备列表 - EDC 工装管理系统{% endblock %}
{% block content %}
<h2>联网终端列表</h2>
<table id="device-table">
<thead>
<tr>
<th>设备编码</th>
<th>名称</th>
<th>IP 地址</th>
<th>在线状态</th>
<th>固件版本</th>
<th>最后上线</th>
<th>操作</th>
</tr>
</thead>
<tbody></tbody>
</table>
{% endblock %}
{% block scripts %}
<script src="{{ url_for('static', filename='js/devices.js') }}"></script>
{% endblock %}

View File

@@ -0,0 +1,52 @@
{% extends "base.html" %}
{% block title %}测试信息 - EDC 工装管理系统{% endblock %}
{% block content %}
<h2>测试信息</h2>
<div class="search-bar">
<label>
设备编码:
<input type="text" id="search-serial" placeholder="输入设备编码搜索...">
</label>
<label>
日期范围:
<input type="date" id="search-date-from">
<input type="date" id="search-date-to">
</label>
<button onclick="searchData(1)" class="btn-search">搜索</button>
<button onclick="exportCSV()" class="btn-export">导出 CSV</button>
</div>
<table id="test-data-table">
<thead>
<tr>
<th>ID</th>
<th>设备编码</th>
<th>DG430地址</th>
<th>设备型号</th>
<th>类型</th>
<th>是否完成</th>
<th>故障信息</th>
<th>继电器</th>
<th>峰峰值(V)</th>
<th>开始频率(Hz)</th>
<th>进入频率(Hz)</th>
<th>离开频率(Hz)</th>
<th>进入距离(mm)</th>
<th>离开距离(mm)</th>
<th>进入速度(dm/s)</th>
<th>离开速度(dm/s)</th>
<th>时间</th>
</tr>
</thead>
<tbody></tbody>
</table>
<div class="pagination" id="pagination"></div>
{% endblock %}
{% block scripts %}
<script src="{{ url_for('static', filename='js/test_data.js') }}"></script>
{% endblock %}

View File

@@ -0,0 +1,69 @@
{% extends "base.html" %}
{% block title %}测试操作 - {{ device.serial }} - EDC 工装管理系统{% endblock %}
{% block content %}
<div class="test-page">
<div class="test-header">
<a href="/">← 返回设备列表</a>
<h2>测试操作 — {{ device.serial }} ({{ device.name or '未命名' }})</h2>
</div>
<div class="test-layout">
<!-- 左侧:测试操作区 -->
<div class="test-control">
<h3>手动指令</h3>
<div class="cmd-buttons">
<button onclick="sendCmd('B0')" class="btn-cmd">开始测试 (0xB0)</button>
<button onclick="sendCmd('B1')" class="btn-cmd">测试复原 (0xB1)</button>
<button onclick="sendCmd('BA')" class="btn-cmd">电机前进 (0xBA)</button>
<button onclick="sendCmd('BB')" class="btn-cmd">电机后退 (0xBB)</button>
<button onclick="sendCmd('BC')" class="btn-cmd">电机停止 (0xBC)</button>
</div>
<h3>自动化测试</h3>
<div class="automation">
<label>
测试次数:
<input type="number" id="test-count" value="10" min="1" max="9999">
</label>
<button id="btn-auto" class="btn-start" onclick="toggleAuto()">开始</button>
<div class="progress-container">
<div class="progress-bar" id="progress-bar"></div>
<div class="progress-text" id="progress-text">等待开始...</div>
</div>
<div class="stats" id="stats">
<span>完成:<strong id="stat-done">0</strong></span>
<span>失败:<strong id="stat-failed">0</strong></span>
<span>剩余:<strong id="stat-remaining">0</strong></span>
</div>
</div>
</div>
<!-- 右侧:测试信息显示区 -->
<div class="test-info">
<h3>当前测试数据</h3>
<div id="latest-result">
<p class="placeholder">等待设备上报...</p>
</div>
<h3>自动化平均值</h3>
<table id="avg-table">
<tr><td>平均峰峰值</td><td id="avg-ppvalue">-</td><td>V</td></tr>
<tr><td>平均开始工作频率</td><td id="avg-idle-freq">-</td><td>Hz</td></tr>
<tr><td>平均进入工作频率</td><td id="avg-enter-freq">-</td><td>Hz</td></tr>
<tr><td>平均进入距离</td><td id="avg-enter-dist">-</td><td>mm</td></tr>
<tr><td>平均离开距离</td><td id="avg-exit-dist">-</td><td>mm</td></tr>
<tr><td>平均进入速度</td><td id="avg-enter-speed">-</td><td>dm/s</td></tr>
<tr><td>平均离开速度</td><td id="avg-exit-speed">-</td><td>dm/s</td></tr>
</table>
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
const DNT_ID = {{ device.id }};
</script>
<script src="{{ url_for('static', filename='js/test_op.js') }}"></script>
{% endblock %}