feat: 新增 edc-web Flask 前端管理系统 + 需求文档
- edc-web: Flask 项目骨架(设备管理、测试操作、测试信息三大页面) - edc_server: 升级子模块(tb_serialnet 透传支持) - docs: 测试工装EDC管理系统需求文档
This commit is contained in:
102
edc-web/app/static/css/style.css
Normal file
102
edc-web/app/static/css/style.css
Normal 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; }
|
||||
}
|
||||
53
edc-web/app/static/js/devices.js
Normal file
53
edc-web/app/static/js/devices.js
Normal 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, """); }
|
||||
|
||||
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();
|
||||
86
edc-web/app/static/js/test_data.js
Normal file
86
edc-web/app/static/js/test_data.js
Normal 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);
|
||||
206
edc-web/app/static/js/test_op.js
Normal file
206
edc-web/app/static/js/test_op.js
Normal 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 = "-";
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user