18 changed files with 1431 additions and 1619 deletions
@ -0,0 +1,337 @@ |
|||||
|
* {margin: 0;padding: 0;box-sizing: border-box} |
||||
|
body {font-family: "Pretendard", sans-serif;min-height: 100vh;display: flex;background: #fff;color: #111} |
||||
|
|
||||
|
/* ───── Sidebar ───── */ |
||||
|
.sidebar {width: 260px;border-right: 1px solid #ddd;padding: 32px 24px;display: flex;flex-direction: column} |
||||
|
.sidebar h2 {font-size: 24px;margin-bottom: 32px} |
||||
|
details.nav { |
||||
|
margin-bottom: 24px |
||||
|
} |
||||
|
|
||||
|
.nav > summary { |
||||
|
list-style: none; |
||||
|
cursor: pointer; |
||||
|
display: flex; |
||||
|
align-items: center; |
||||
|
font-size: 20px |
||||
|
} |
||||
|
|
||||
|
.nav > summary::before { |
||||
|
content: ""; |
||||
|
width: 8px; |
||||
|
height: 8px; |
||||
|
margin-right: 8px; |
||||
|
border-left: 2px solid #111; |
||||
|
border-bottom: 2px solid #111; |
||||
|
transform: rotate(-135deg); |
||||
|
transition: .2s |
||||
|
} |
||||
|
|
||||
|
.nav[open] > summary::before { |
||||
|
transform: rotate(-45deg) |
||||
|
} |
||||
|
|
||||
|
.nav-items { |
||||
|
display: flex; |
||||
|
flex-direction: column; |
||||
|
margin-top: 10px; |
||||
|
padding-left: 16px; |
||||
|
gap: 6px |
||||
|
} |
||||
|
|
||||
|
.nav-items button { |
||||
|
background: none; |
||||
|
border: none; |
||||
|
cursor: pointer; |
||||
|
font-size: 16px; |
||||
|
text-align: left; |
||||
|
padding: 4px 0 |
||||
|
} |
||||
|
|
||||
|
/* ───── Top-bar ───── */ |
||||
|
.topbar { |
||||
|
height: 68px; |
||||
|
background: #d9d9d9; |
||||
|
display: flex; |
||||
|
align-items: center; |
||||
|
padding: 0 32px; |
||||
|
font-size: 28px; |
||||
|
font-weight: 500; |
||||
|
box-shadow: 0 2px 4px rgba(0, 0, 0, .15) inset |
||||
|
} |
||||
|
|
||||
|
/* ───── Main Board ───── */ |
||||
|
.content { |
||||
|
flex: 1; |
||||
|
display: flex; |
||||
|
flex-direction: column |
||||
|
} |
||||
|
|
||||
|
.board { |
||||
|
flex: 1; |
||||
|
overflow: auto; |
||||
|
padding: 40px; |
||||
|
display: flex; |
||||
|
flex-direction: column; |
||||
|
gap: 32px |
||||
|
} |
||||
|
|
||||
|
.card { |
||||
|
background: #fff; |
||||
|
border: 1px solid #e5e5e5; |
||||
|
box-shadow: 0 2px 4px rgba(0, 0, 0, .15) inset; |
||||
|
padding: 32px; |
||||
|
border-radius: 8px |
||||
|
} |
||||
|
|
||||
|
.card h3 { |
||||
|
font-size: 24px; |
||||
|
margin-bottom: 24px |
||||
|
} |
||||
|
|
||||
|
/* SSID rows */ |
||||
|
.ssid-row { |
||||
|
display: flex; |
||||
|
align-items: center; |
||||
|
gap: 8px; |
||||
|
margin-bottom: 8px; |
||||
|
} |
||||
|
|
||||
|
.ssid-row input.ssid-name, |
||||
|
.ssid-row input.ssid-pw { |
||||
|
width: 200px; |
||||
|
padding: 6px 8px; |
||||
|
font-size: 16px; |
||||
|
border: 1px solid #aaa; |
||||
|
border-radius: 4px; |
||||
|
} |
||||
|
|
||||
|
.ssid-row .toggle-pw { |
||||
|
padding: 6px 10px; |
||||
|
font-size: 14px; |
||||
|
border: 1px solid #aaa; |
||||
|
background-color: #eee; |
||||
|
border-radius: 4px; |
||||
|
cursor: pointer; |
||||
|
min-width: 60px; |
||||
|
text-align: center; |
||||
|
} |
||||
|
.ssid-row .toggle-pw:hover { |
||||
|
background-color: #cecece; |
||||
|
} |
||||
|
.ssid-row .toggle-pw:active { |
||||
|
background-color: #bdbdbd; |
||||
|
transform: scale(0.97); |
||||
|
} |
||||
|
|
||||
|
.ssid-row select { |
||||
|
width: 100px; |
||||
|
padding: 6px 8px; |
||||
|
font-size: 16px; |
||||
|
border: 1px solid #aaa; |
||||
|
border-radius: 4px; |
||||
|
} |
||||
|
|
||||
|
.ssid-row .delete-ssid { |
||||
|
padding: 6px 10px; |
||||
|
font-size: 14px; |
||||
|
background-color: #ffdddd; |
||||
|
border: 1px solid #aaa; |
||||
|
border-radius: 4px; |
||||
|
cursor: pointer; |
||||
|
} |
||||
|
|
||||
|
.ssid-row .delete-ssid:hover { |
||||
|
background-color: #ffaaaa; |
||||
|
} |
||||
|
.ssid-row .delete-ssid:active{ |
||||
|
background-color: #fd9e9e; |
||||
|
transform: scale(0.97); |
||||
|
} |
||||
|
|
||||
|
.icon-button { |
||||
|
font-size: 10px; |
||||
|
background-color: #f0f0f0; |
||||
|
border: none; |
||||
|
padding: 8px 12px; |
||||
|
border-radius: 6px; |
||||
|
cursor: pointer; |
||||
|
transition: background-color 0.2s ease; |
||||
|
} |
||||
|
|
||||
|
.icon-button:hover { |
||||
|
background-color: #dcdcdc; |
||||
|
} |
||||
|
|
||||
|
.icon-button:active { |
||||
|
background-color: #c0c0c0; |
||||
|
} |
||||
|
|
||||
|
.ssid-country-code { |
||||
|
display: flex; |
||||
|
align-items: center; |
||||
|
gap: 12px; |
||||
|
margin-top: 16px; |
||||
|
} |
||||
|
|
||||
|
.ssid-country-code label { |
||||
|
min-width: 100px; |
||||
|
font-weight: 600; |
||||
|
} |
||||
|
|
||||
|
.ssid-country-code input { |
||||
|
width: 80px; |
||||
|
padding: 6px 8px; |
||||
|
font-size: 16px; |
||||
|
border: 1px solid #aaa; |
||||
|
border-radius: 4px; |
||||
|
text-transform: uppercase; |
||||
|
} |
||||
|
|
||||
|
.separate-setting-card { |
||||
|
margin-top: 16px; |
||||
|
} |
||||
|
|
||||
|
.field-hint { |
||||
|
color: #666; |
||||
|
font-size: 13px; |
||||
|
} |
||||
|
/* 3-column grids (Network / Server) */ |
||||
|
.grid-3-col { |
||||
|
display: grid; |
||||
|
grid-template-columns: 180px 220px 100px; /* SSID와 유사하게 축소 */ |
||||
|
align-items: center; |
||||
|
gap: 12px; |
||||
|
margin: 8px 0; |
||||
|
} |
||||
|
|
||||
|
.grid-3-col input { |
||||
|
padding: 6px 8px; |
||||
|
font-size: 16px; |
||||
|
border: 1px solid #aaa; |
||||
|
border-radius: 4px; /* 기존 1px → 4px 통일 */ |
||||
|
width: 100%; /* grid item 내에서만 퍼지므로 유지 가능 */ |
||||
|
} |
||||
|
|
||||
|
.grid-3-col input::placeholder { |
||||
|
color: #999; |
||||
|
} |
||||
|
/* radio */ |
||||
|
.radio-group {display: flex;align-items: center;gap: 24px} |
||||
|
|
||||
|
.radio-label {display: inline-flex;align-items: center;gap: 4px;cursor: pointer} |
||||
|
.radio-label input {margin: 0} |
||||
|
/* I/O */ |
||||
|
.io-top {display: grid;grid-template-columns:repeat(auto-fit, minmax(360px, 1fr));gap: 40px;margin-bottom: 32px} |
||||
|
.io-bot {display: grid; /* Ethernet·LTE와 같은 2열(가변) 그리드 */grid-template-columns:repeat(auto-fit, minmax(360px, 1fr));gap: 40px;} |
||||
|
.io-group {display: flex;flex-direction: column;gap: 8px} |
||||
|
.io-group h4 {font-size: 20px;font-weight: 600;margin-bottom: 16px;display: flex;align-items: center;gap: 8px} |
||||
|
.io-group h4::before {content: "";width: 8px;height: 8px;background: #aaa;display: inline-block} |
||||
|
.io-fieldset {border: 1px solid #bbb;padding: 24px 32px;border-radius: 4px;display: flex;flex-direction: column;gap: 16px;width: 100%} |
||||
|
.io-row {display: grid;grid-template-columns:120px 12px 1fr;align-items: center;gap: 8px} |
||||
|
.io-row input, .io-row select {padding: 4px 6px;border: 1px solid #aaa;border-radius: 4px;font-size: 14px;width: 50%} |
||||
|
.io-row span.label {font-size: 14px;color: #333;display: block;width: 100%;} |
||||
|
.io-row span.label-inline {font-size: 14px;color: #333;display: inline;width: auto} |
||||
|
.io-row span.colon {font-size: 14px;color: #333;text-align: center;} |
||||
|
.reg-io-grid {display: grid;grid-template-columns: 1fr 1fr;gap: 4px} |
||||
|
.reg-io-grid .io-fieldset .io-row {display: grid;grid-template-columns: 140px 220px;align-items: center;gap: 4px;margin-bottom: 8px;} |
||||
|
|
||||
|
/* config 내부 라벨 */ |
||||
|
.reg-io-grid .io-fieldset .io-row span.label {font-size: 14px;color: #333;} |
||||
|
.reg-io-grid .io-fieldset .io-row input, |
||||
|
.reg-io-grid .io-fieldset .io-row select {padding: 4px 6px;border: 1px solid #aaa;border-radius: 4px;font-size: 14px;width: 100%;box-sizing: border-box;} |
||||
|
.reg-io-grid .io-fieldset .io-row.checkbox-group {display: flex;align-items: center;gap: 16px;grid-column: 2 / 3;} |
||||
|
.reg-io-grid .io-fieldset .io-row .checkbox-with-text {display: flex;align-items: center;gap: 10px;width: 100%;} |
||||
|
.reg-io-grid .io-fieldset .io-row .checkbox-with-text input[type="checkbox"] {width: auto;margin: 0;} |
||||
|
|
||||
|
/* 비활성 input 시각화 */ |
||||
|
.disabled {background: #f5f5f5;cursor: not-allowed} |
||||
|
/* Footer Buttons */ |
||||
|
.actions {display: flex;gap: 24px;justify-content: center;padding: 20px} |
||||
|
.actions button {min-width: 140px;padding: 10px 18px;font-size: 16px;border: 1px solid #bbb;background: #d9d9d9;border-radius: 4px;cursor: pointer} |
||||
|
.actions button:hover {background-color: #bdbdbd;} |
||||
|
.actions button:active {background-color: #bdbdbd; transform: scale(0.97);} |
||||
|
.config-actions{display: flex;gap: 24px;justify-content: center;background: #f7f7f7;border: 1px solid #bbb;border-radius: 4px;margin: 24px 0 0 0;padding: 20px 0;} |
||||
|
.config-actions button{min-width:140px;padding:10px 18px;font-size:16px;border:1px solid #bbb;background:#d9d9d9;border-radius:4px;cursor:pointer} |
||||
|
.topbar { |
||||
|
display: flex; |
||||
|
justify-content: space-between; |
||||
|
align-items: center; |
||||
|
padding: 0 32px; |
||||
|
height: 68px; |
||||
|
background: #d9d9d9; |
||||
|
font-size: 28px; |
||||
|
font-weight: 500; |
||||
|
box-shadow: 0 2px 4px rgba(0, 0, 0, .15) inset; |
||||
|
} |
||||
|
|
||||
|
.save-file { |
||||
|
font-size: 16px; |
||||
|
padding: 8px 16px; |
||||
|
background-color: #4caf50; |
||||
|
color: white; |
||||
|
border: none; |
||||
|
border-radius: 4px; |
||||
|
cursor: pointer; |
||||
|
} |
||||
|
.save-file:hover { |
||||
|
background-color: #45a049;; |
||||
|
} |
||||
|
.save-file:active { |
||||
|
background-color: #3e8e41; |
||||
|
transform: scale(0.97); |
||||
|
} |
||||
|
/* MODBUS 화면 심플 스타일 */ |
||||
|
#page-modbus .card {background: #f7f7f7;border: 1px solid #bbb;box-shadow: none;padding: 24px;border-radius: 4px;display: flex;flex-direction: column;} |
||||
|
#page-modbus table {width: 100%;border-collapse: collapse;background: #fff;border: 1px solid #bbb;} |
||||
|
#page-modbus th, #page-modbus td {border: 1px solid #bbb;padding: 6px 8px;text-align: left;background: #fff;} |
||||
|
#page-modbus th {background: #e5e5e5;font-weight: 500;} |
||||
|
#page-modbus tbody td {height: 34px;vertical-align: middle;} |
||||
|
#page-modbus .modbus-input-area {display: grid;grid-template-columns: 200px 200px 200px 200px;gap: 20px 20px;margin: 24px auto 16px auto;background: #eaeaea;padding: 16px;border-radius: 4px;margin-left: auto;margin-right: auto;} |
||||
|
#page-modbus .input-row {display: contents;} |
||||
|
#page-modbus .modbus-input-area label.nowrap {white-space: nowrap;} |
||||
|
#page-modbus .modbus-input-area label {min-width: auto;color: #444;white-space: normal;} |
||||
|
#page-modbus .modbus-input-area input {background: #e0e0e0;border: none;border-radius: 2px;padding: 4px 8px;min-width: auto;} |
||||
|
#page-modbus .modbus-actions {display: flex;gap: 24px;justify-content: center;margin: 16px 0 0 0;} |
||||
|
#page-modbus .modbus-actions button {background: #d3d3d3;border: 1px solid #aaa;border-radius: 2px;padding: 8px 24px;font-size: 16px;cursor: pointer;} |
||||
|
/* 하단 버튼 영역 */ |
||||
|
#modbus-bottom-actions {display: flex;gap: 24px;justify-content: center;background: #f7f7f7;border: 1px solid #bbb;border-radius: 4px;margin: 24px 0 0 0;padding: 20px 0;} |
||||
|
#modbus-bottom-actions button {background: #e0e0e0;border: 1px solid #aaa;border-radius: 2px;padding: 10px 18px;font-size: 16px;min-width: 140px;cursor: pointer;} |
||||
|
|
||||
|
/* OPC_UA 화면 */ |
||||
|
#page-opcua .card {background: #f7f7f7;border: 1px solid #bbb;box-shadow: none;padding: 24px;border-radius: 4px;display: flex;flex-direction: column;} |
||||
|
#page-opcua table {width: 100%;border-collapse: collapse;background: #fff;border: 1px solid #bbb;} |
||||
|
#page-opcua th, #page-opcua td {border: 1px solid #bbb;padding: 6px 8px;text-align: left;background: #fff;} |
||||
|
#page-opcua th {background: #e5e5e5;font-weight: 500;} |
||||
|
#page-opcua tbody td {height: 34px;vertical-align: middle;} |
||||
|
#page-opcua .opcua-input-area {display: grid;grid-template-columns: 200px 200px 200px 200px;gap: 20px 20px;margin: 24px auto 16px auto;background: #eaeaea;padding: 16px;border-radius: 4px;margin-left: auto;margin-right: auto;} |
||||
|
#page-opcua .input-row {display: contents;} |
||||
|
#page-opcua .opcua-input-area label.nowrap {white-space: nowrap;} |
||||
|
#page-opcua .opcua-input-area label {min-width: auto;color: #444;white-space: normal;} |
||||
|
#page-opcua .opcua-input-area input {background: #e0e0e0;border: none;border-radius: 2px;padding: 4px 8px;min-width: auto;} |
||||
|
#page-opcua .opcua-actions {display: flex;gap: 24px;justify-content: center;margin: 16px 0 0 0;} |
||||
|
#page-opcua .opcua-actions button {background: #d3d3d3;border: 1px solid #aaa;border-radius: 2px;padding: 8px 24px;font-size: 16px;cursor: pointer;} |
||||
|
#opcUa-bottom-actions {display: flex;gap: 24px;justify-content: center;background: #f7f7f7;border: 1px solid #bbb;border-radius: 4px;margin: 24px 0 0 0;padding: 20px 0;} |
||||
|
#opcUa-bottom-actions button {background: #e0e0e0;border: 1px solid #aaa;border-radius: 2px;padding: 10px 18px;font-size: 16px;min-width: 140px;cursor: pointer;} |
||||
|
|
||||
|
/* CAN 화면 스타일 */ |
||||
|
#page-can .card {background: #f7f7f7;border: 1px solid #bbb;box-shadow: none;padding: 24px;border-radius: 4px;display: flex;flex-direction: column;} |
||||
|
#page-can table {width: 100%;border-collapse: collapse;background: #fff;border: 1px solid #bbb;} |
||||
|
#page-can th, #page-can td {border: 1px solid #bbb;padding: 6px 8px;text-align: left;background: #fff;} |
||||
|
#page-can th {background: #e5e5e5;font-weight: 500;} |
||||
|
#page-can tbody td {height: 34px;vertical-align: middle;} |
||||
|
#page-can tbody td > span:empty::before {content: "\00a0";} |
||||
|
#page-can .can-input-area {display: grid;grid-template-columns: 200px 200px 200px 200px;gap: 20px 20px;margin: 24px auto 16px auto;background: #eaeaea;padding: 16px;border-radius: 4px;margin-left: auto;margin-right: auto;} |
||||
|
#page-can .input-row {display: contents;} |
||||
|
#page-can .can-input-area label.nowrap {white-space: nowrap;} |
||||
|
#page-can .can-input-area label {min-width: auto;color: #444;white-space: normal;} |
||||
|
#page-can .can-input-area input {background: #e0e0e0;border: none;border-radius: 2px;padding: 4px 8px;min-width: auto;} |
||||
|
#page-can .can-actions {display: flex;gap: 24px;justify-content: center;margin: 16px 0 0 0;} |
||||
|
#page-can .can-actions button {background: #d3d3d3;border: 1px solid #aaa;border-radius: 2px;padding: 8px 24px;font-size: 16px;cursor: pointer;} |
||||
|
#can-bottom-actions {display: flex;gap: 24px;justify-content: center;background: #f7f7f7;border: 1px solid #bbb;border-radius: 4px;margin: 24px 0 0 0;padding: 20px 0;} |
||||
|
#can-bottom-actions button {background: #e0e0e0;border: 1px solid #aaa;border-radius: 2px;padding: 10px 18px;font-size: 16px;min-width: 140px;cursor: pointer;} |
||||
|
|
||||
|
.selected-row {background: #d0e6ff !important;} |
||||
|
.selected-row > td {background: #d0e6ff !important;} |
||||
|
|
||||
|
.file-upload-section{padding: 10px;margin: 10px;text-align: center;} |
||||
File diff suppressed because it is too large
@ -0,0 +1,39 @@ |
|||||
|
async function fetchJson(url) { |
||||
|
const response = await fetch(url); |
||||
|
|
||||
|
if (response.status === 204) { |
||||
|
return null; |
||||
|
} |
||||
|
|
||||
|
if (!response.ok) { |
||||
|
throw new Error(`Request failed: ${response.status}`); |
||||
|
} |
||||
|
|
||||
|
return response.json(); |
||||
|
} |
||||
|
|
||||
|
async function postJson(url, payload) { |
||||
|
const response = await fetch(url, { |
||||
|
method: 'POST', |
||||
|
headers: { 'Content-Type': 'application/json' }, |
||||
|
body: JSON.stringify(payload), |
||||
|
}); |
||||
|
|
||||
|
return response; |
||||
|
} |
||||
|
|
||||
|
export function loadDeviceConfig() { |
||||
|
return fetchJson('/setting/get-device'); |
||||
|
} |
||||
|
|
||||
|
export function loadProtocolConfig() { |
||||
|
return fetchJson('/setting/get-protocol'); |
||||
|
} |
||||
|
|
||||
|
export function saveDeviceConfig(payload) { |
||||
|
return postJson('/setting/device', payload); |
||||
|
} |
||||
|
|
||||
|
export function saveProtocolConfig(payload) { |
||||
|
return postJson('/setting/protocol', payload); |
||||
|
} |
||||
@ -0,0 +1,99 @@ |
|||||
|
import { cloneDeviceDefaults } from '../state.js'; |
||||
|
import { byId, queryAll } from '../utils/dom.js'; |
||||
|
|
||||
|
const NUMBER_FIELDS = new Set([ |
||||
|
'can_baudrate', |
||||
|
'rs485_baudrate', |
||||
|
'log_max_size', |
||||
|
'log_max_duration', |
||||
|
]); |
||||
|
|
||||
|
export function createDeviceDataController({ ssidController }) { |
||||
|
const netFields = ['wifi_ip', 'wifi_netmask', 'wifi_gateway', 'wifi_dns1', 'wifi_dns2'] |
||||
|
.map((id) => byId(id)) |
||||
|
.filter(Boolean); |
||||
|
|
||||
|
function toggleNetInputs() { |
||||
|
const isStatic = document.querySelector('input[name="ipMode"]:checked')?.value === 'static'; |
||||
|
|
||||
|
netFields.forEach((element) => { |
||||
|
element.disabled = !isStatic; |
||||
|
element.classList.toggle('disabled', !isStatic); |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
function bindEvents() { |
||||
|
queryAll('input[name="ipMode"]').forEach((radio) => { |
||||
|
radio.addEventListener('change', toggleNetInputs); |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
function loadFromData(data = cloneDeviceDefaults()) { |
||||
|
const radio = document.querySelector( |
||||
|
`input[name="ipMode"][value="${data.wifi_static === 'on' ? 'static' : 'dhcp'}"]`, |
||||
|
); |
||||
|
|
||||
|
if (radio) { |
||||
|
radio.checked = true; |
||||
|
} |
||||
|
|
||||
|
Object.entries(data).forEach(([key, value]) => { |
||||
|
const element = byId(key); |
||||
|
if (element) { |
||||
|
element.value = value ?? ''; |
||||
|
} |
||||
|
}); |
||||
|
|
||||
|
ssidController.loadRows(data.WIFI_SSID || []); |
||||
|
toggleNetInputs(); |
||||
|
} |
||||
|
|
||||
|
function collectDeviceData() { |
||||
|
const result = cloneDeviceDefaults(); |
||||
|
|
||||
|
result.wifi_static = document.querySelector('input[name="ipMode"]:checked')?.value === 'static' |
||||
|
? 'on' |
||||
|
: 'off'; |
||||
|
|
||||
|
Object.keys(result).forEach((key) => { |
||||
|
const element = byId(key); |
||||
|
if (!element) { |
||||
|
return; |
||||
|
} |
||||
|
|
||||
|
const value = element.value.trim(); |
||||
|
result[key] = NUMBER_FIELDS.has(key) ? (value === '' ? null : Number(value)) : value; |
||||
|
}); |
||||
|
|
||||
|
result.WIFI_SSID = ssidController.gatherRows(); |
||||
|
|
||||
|
if (typeof result.wifi_country_code === 'string') { |
||||
|
result.wifi_country_code = result.wifi_country_code.trim().toUpperCase(); |
||||
|
} |
||||
|
|
||||
|
return Object.entries(result).reduce((acc, [key, value]) => { |
||||
|
if (value === null) { |
||||
|
return acc; |
||||
|
} |
||||
|
|
||||
|
if (typeof value === 'string' && value.trim() === '') { |
||||
|
return acc; |
||||
|
} |
||||
|
|
||||
|
acc[key] = value; |
||||
|
return acc; |
||||
|
}, {}); |
||||
|
} |
||||
|
|
||||
|
function initialize() { |
||||
|
bindEvents(); |
||||
|
loadFromData(); |
||||
|
} |
||||
|
|
||||
|
return { |
||||
|
collectDeviceData, |
||||
|
initialize, |
||||
|
loadFromData, |
||||
|
toggleNetInputs, |
||||
|
}; |
||||
|
} |
||||
@ -0,0 +1,63 @@ |
|||||
|
import { queryAll } from '../utils/dom.js'; |
||||
|
import { downloadJson } from '../utils/download.js'; |
||||
|
|
||||
|
function bindLocalJsonUpload(wrapperSelector, inputSelector, buttonSelector, onLoad) { |
||||
|
queryAll(wrapperSelector).forEach((wrapper) => { |
||||
|
const input = wrapper.querySelector(inputSelector); |
||||
|
const button = wrapper.querySelector(buttonSelector); |
||||
|
const fileType = wrapper.dataset.fileType; |
||||
|
|
||||
|
if (!input || !button) { |
||||
|
return; |
||||
|
} |
||||
|
|
||||
|
button.addEventListener('click', () => { |
||||
|
input.value = ''; |
||||
|
input.click(); |
||||
|
}); |
||||
|
|
||||
|
input.addEventListener('change', (event) => { |
||||
|
const [file] = event.target.files; |
||||
|
if (!file) { |
||||
|
return; |
||||
|
} |
||||
|
|
||||
|
const reader = new FileReader(); |
||||
|
reader.onload = (loadEvent) => { |
||||
|
try { |
||||
|
onLoad(JSON.parse(loadEvent.target.result)); |
||||
|
} catch (error) { |
||||
|
alert(`Error uploading file: ${error.message} (${fileType})`); |
||||
|
} finally { |
||||
|
input.value = ''; |
||||
|
} |
||||
|
}; |
||||
|
reader.readAsText(file); |
||||
|
}); |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
export function bindDeviceFileActions({ onLoadLocal, getDownloadData }) { |
||||
|
bindLocalJsonUpload('.file-upload-wrapper-device', '.upload-input-device', '.select-file-btn-device', onLoadLocal); |
||||
|
|
||||
|
queryAll('.download-device-file').forEach((button) => { |
||||
|
button.addEventListener('click', () => { |
||||
|
downloadJson(getDownloadData(), 'deviceConfig.json'); |
||||
|
}); |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
export function bindProtocolFileActions({ onLoadLocal, getDownloadData }) { |
||||
|
bindLocalJsonUpload( |
||||
|
'.file-upload-wrapper-protocol', |
||||
|
'.upload-input-protocol', |
||||
|
'.select-file-btn-protocol', |
||||
|
onLoadLocal, |
||||
|
); |
||||
|
|
||||
|
queryAll('.download-protocol-file').forEach((button) => { |
||||
|
button.addEventListener('click', () => { |
||||
|
downloadJson(getDownloadData(), 'protocolConfig.json'); |
||||
|
}); |
||||
|
}); |
||||
|
} |
||||
@ -0,0 +1,88 @@ |
|||||
|
import { cloneTemplateRow } from '../utils/dom.js'; |
||||
|
|
||||
|
export function createSsidController({ listElement, templateElement, addButton, countryCodeInput }) { |
||||
|
function createRow(values = { wifi_ssid: '', wifi_passwd: '', wifi_security: 'none' }) { |
||||
|
const row = cloneTemplateRow(templateElement); |
||||
|
|
||||
|
row.querySelector('.ssid-name').value = values.wifi_ssid ?? ''; |
||||
|
row.querySelector('.ssid-pw').value = values.wifi_passwd ?? ''; |
||||
|
row.querySelector('select').value = (values.wifi_security ?? 'none').toLowerCase(); |
||||
|
|
||||
|
const deleteButton = document.createElement('button'); |
||||
|
deleteButton.type = 'button'; |
||||
|
deleteButton.textContent = 'Delete'; |
||||
|
deleteButton.dataset.action = 'delete-ssid'; |
||||
|
deleteButton.title = 'delete ssid'; |
||||
|
deleteButton.classList.add('delete-ssid'); |
||||
|
|
||||
|
row.appendChild(deleteButton); |
||||
|
return row; |
||||
|
} |
||||
|
|
||||
|
function addRow(values) { |
||||
|
listElement.appendChild(createRow(values)); |
||||
|
} |
||||
|
|
||||
|
function removeRow(row) { |
||||
|
row?.remove(); |
||||
|
} |
||||
|
|
||||
|
function gatherRows() { |
||||
|
return Array.from(listElement.querySelectorAll('.ssid-row')).reduce((acc, row) => { |
||||
|
const wifi_ssid = row.querySelector('.ssid-name').value.trim(); |
||||
|
const wifi_passwd = row.querySelector('.ssid-pw').value.trim(); |
||||
|
const wifi_security = row.querySelector('select').value; |
||||
|
|
||||
|
if (!wifi_ssid && !wifi_passwd) { |
||||
|
return acc; |
||||
|
} |
||||
|
|
||||
|
if (wifi_ssid) { |
||||
|
acc.push({ wifi_ssid, wifi_passwd, wifi_security }); |
||||
|
} |
||||
|
|
||||
|
return acc; |
||||
|
}, []); |
||||
|
} |
||||
|
|
||||
|
function loadRows(rows = []) { |
||||
|
listElement.innerHTML = ''; |
||||
|
|
||||
|
rows.forEach((row) => addRow(row)); |
||||
|
|
||||
|
if (!listElement.children.length) { |
||||
|
addRow(); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
function bindEvents() { |
||||
|
addButton?.addEventListener('click', () => addRow()); |
||||
|
|
||||
|
listElement.addEventListener('click', (event) => { |
||||
|
if (event.target.classList.contains('toggle-pw')) { |
||||
|
const passwordInput = event.target.closest('.ssid-row').querySelector('.ssid-pw'); |
||||
|
passwordInput.type = passwordInput.type === 'password' ? 'text' : 'password'; |
||||
|
event.target.textContent = passwordInput.type === 'password' ? 'show' : 'hide'; |
||||
|
return; |
||||
|
} |
||||
|
|
||||
|
if (event.target.dataset.action === 'delete-ssid') { |
||||
|
removeRow(event.target.closest('.ssid-row')); |
||||
|
} |
||||
|
}); |
||||
|
|
||||
|
countryCodeInput?.addEventListener('input', () => { |
||||
|
countryCodeInput.value = countryCodeInput.value |
||||
|
.replace(/[^a-zA-Z]/g, '') |
||||
|
.slice(0, 2) |
||||
|
.toUpperCase(); |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
return { |
||||
|
addRow, |
||||
|
bindEvents, |
||||
|
gatherRows, |
||||
|
loadRows, |
||||
|
}; |
||||
|
} |
||||
@ -0,0 +1,103 @@ |
|||||
|
import { loadDeviceConfig, loadProtocolConfig, saveDeviceConfig, saveProtocolConfig } from './api.js'; |
||||
|
import { createNavigation } from './navigation.js'; |
||||
|
import { createDeviceDataController } from './device/deviceData.js'; |
||||
|
import { bindDeviceFileActions, bindProtocolFileActions } from './device/deviceFile.js'; |
||||
|
import { createSsidController } from './device/ssid.js'; |
||||
|
import { createCanController } from './protocol/can.js'; |
||||
|
import { createModbusController } from './protocol/modbus.js'; |
||||
|
import { createOpcUaController } from './protocol/opcua.js'; |
||||
|
import { createProtocolDataController } from './protocol/protocolData.js'; |
||||
|
import { isValidRegisterConfig } from './protocol/protocolValidation.js'; |
||||
|
import { byId, queryAll } from './utils/dom.js'; |
||||
|
|
||||
|
async function initializeApp() { |
||||
|
const navigation = createNavigation(); |
||||
|
const ssidController = createSsidController({ |
||||
|
listElement: byId('ssid-list'), |
||||
|
templateElement: byId('ssid-template'), |
||||
|
addButton: byId('add-ssid'), |
||||
|
countryCodeInput: byId('wifi_country_code'), |
||||
|
}); |
||||
|
|
||||
|
const deviceController = createDeviceDataController({ ssidController }); |
||||
|
const protocolController = createProtocolDataController({ |
||||
|
navigation, |
||||
|
modbusController: createModbusController(), |
||||
|
opcUaController: createOpcUaController(), |
||||
|
canController: createCanController(), |
||||
|
}); |
||||
|
|
||||
|
navigation.initialize(); |
||||
|
ssidController.bindEvents(); |
||||
|
deviceController.initialize(); |
||||
|
protocolController.initialize(); |
||||
|
|
||||
|
bindDeviceFileActions({ |
||||
|
onLoadLocal: (data) => deviceController.loadFromData(data), |
||||
|
getDownloadData: () => deviceController.collectDeviceData(), |
||||
|
}); |
||||
|
|
||||
|
bindProtocolFileActions({ |
||||
|
onLoadLocal: (data) => { |
||||
|
if (!isValidRegisterConfig(data)) { |
||||
|
alert('The contents of the selected file are not in a valid protocol setting file format.'); |
||||
|
return; |
||||
|
} |
||||
|
|
||||
|
protocolController.loadFromRegisterJson(data); |
||||
|
}, |
||||
|
getDownloadData: () => protocolController.collectProtocolData(), |
||||
|
}); |
||||
|
|
||||
|
queryAll('.save-file').forEach((button) => { |
||||
|
button.addEventListener('click', async () => { |
||||
|
const devicePayload = deviceController.collectDeviceData(); |
||||
|
const protocolPayload = protocolController.normalizeRegisterForPost( |
||||
|
protocolController.collectProtocolData(), |
||||
|
); |
||||
|
|
||||
|
if (!isValidRegisterConfig(protocolPayload)) { |
||||
|
alert('Register settings are currently invalid. Cannot be saved.'); |
||||
|
return; |
||||
|
} |
||||
|
|
||||
|
try { |
||||
|
const [deviceResponse, protocolResponse] = await Promise.all([ |
||||
|
saveDeviceConfig(devicePayload), |
||||
|
saveProtocolConfig(protocolPayload), |
||||
|
]); |
||||
|
|
||||
|
if (deviceResponse.ok && protocolResponse.ok) { |
||||
|
alert('saved successfully'); |
||||
|
return; |
||||
|
} |
||||
|
|
||||
|
alert('save failure'); |
||||
|
console.error('device response:', deviceResponse.status, 'protocol response:', protocolResponse.status); |
||||
|
} catch (error) { |
||||
|
console.error('Error during transfer:', error); |
||||
|
alert('save failure'); |
||||
|
} |
||||
|
}); |
||||
|
}); |
||||
|
|
||||
|
try { |
||||
|
const deviceData = await loadDeviceConfig(); |
||||
|
if (deviceData) { |
||||
|
deviceController.loadFromData(deviceData); |
||||
|
} |
||||
|
} catch (error) { |
||||
|
console.error('Device load failed:', error); |
||||
|
} |
||||
|
|
||||
|
try { |
||||
|
const protocolData = await loadProtocolConfig(); |
||||
|
if (protocolData) { |
||||
|
protocolController.loadFromRegisterJson(protocolData); |
||||
|
} |
||||
|
} catch (error) { |
||||
|
console.error('Protocol load failed:', error); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
document.addEventListener('DOMContentLoaded', initializeApp); |
||||
@ -0,0 +1,69 @@ |
|||||
|
import { byId, queryAll } from './utils/dom.js'; |
||||
|
|
||||
|
export function createNavigation() { |
||||
|
const pages = { |
||||
|
ssid: byId('page-ssid'), |
||||
|
network: byId('page-network'), |
||||
|
io: byId('page-io'), |
||||
|
log: byId('page-log'), |
||||
|
reg: byId('page-reg'), |
||||
|
modbus: byId('page-modbus'), |
||||
|
opcua: byId('page-opcua'), |
||||
|
can: byId('page-can'), |
||||
|
}; |
||||
|
|
||||
|
const titleElement = byId('page-title'); |
||||
|
const sidebarButtons = queryAll('.nav-items button'); |
||||
|
|
||||
|
function showPage(pageName) { |
||||
|
Object.values(pages).forEach((page) => { |
||||
|
if (page) { |
||||
|
page.style.display = 'none'; |
||||
|
} |
||||
|
}); |
||||
|
|
||||
|
if (pages[pageName]) { |
||||
|
pages[pageName].style.display = 'block'; |
||||
|
} |
||||
|
|
||||
|
const sourceButton = document.querySelector(`.nav-items button[data-page="${pageName}"]`); |
||||
|
titleElement.textContent = sourceButton?.textContent ?? pageName; |
||||
|
} |
||||
|
|
||||
|
function toggleProtocolMenus(protocolValue, canInputValue) { |
||||
|
const modbusSidebarBtn = byId('modbusSidebarBtn'); |
||||
|
const opcUaSidebarBtn = byId('opcUaSidebarBtn'); |
||||
|
const canSidebarBtn = byId('canSidebarBtn'); |
||||
|
|
||||
|
if (modbusSidebarBtn) { |
||||
|
modbusSidebarBtn.style.display = protocolValue === 'MODBUS' ? 'block' : 'none'; |
||||
|
} |
||||
|
|
||||
|
if (opcUaSidebarBtn) { |
||||
|
opcUaSidebarBtn.style.display = protocolValue === 'OPC_UA' ? 'block' : 'none'; |
||||
|
} |
||||
|
|
||||
|
if (canSidebarBtn) { |
||||
|
canSidebarBtn.style.display = canInputValue === 'on' ? 'block' : 'none'; |
||||
|
} |
||||
|
|
||||
|
const protocolDetails = queryAll('details.nav')[1]; |
||||
|
if (protocolDetails) { |
||||
|
protocolDetails.open = true; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
function initialize() { |
||||
|
sidebarButtons.forEach((button) => { |
||||
|
button.addEventListener('click', () => showPage(button.dataset.page)); |
||||
|
}); |
||||
|
|
||||
|
showPage('ssid'); |
||||
|
} |
||||
|
|
||||
|
return { |
||||
|
initialize, |
||||
|
showPage, |
||||
|
toggleProtocolMenus, |
||||
|
}; |
||||
|
} |
||||
@ -0,0 +1,30 @@ |
|||||
|
import { byId, createTableEditor } from '../utils/dom.js'; |
||||
|
|
||||
|
export function createCanController() { |
||||
|
return createTableEditor({ |
||||
|
listElement: byId('can-field-list'), |
||||
|
templateElement: byId('can-row-template'), |
||||
|
selectors: { |
||||
|
field: '.can-field-display', |
||||
|
id: '.can-id-display', |
||||
|
odt: '.can-odt-display', |
||||
|
dv: '.can-dv-display', |
||||
|
shift: '.can-shift-display', |
||||
|
expr: '.can-expr-display', |
||||
|
mask: '.can-mask-display', |
||||
|
}, |
||||
|
inputIds: { |
||||
|
field: 'can_field_input', |
||||
|
id: 'can_id_input', |
||||
|
odt: 'can_odt_input', |
||||
|
dv: 'can_dv_input', |
||||
|
shift: 'can_shift_input', |
||||
|
expr: 'can_expr_input', |
||||
|
mask: 'can_mask_input', |
||||
|
}, |
||||
|
addButtonId: 'can_add_btn', |
||||
|
modifyButtonId: 'can_modify_btn', |
||||
|
deleteButtonId: 'can_delete_btn', |
||||
|
emptyInputSelector: '.can-input-area input, .can-input-area select', |
||||
|
}); |
||||
|
} |
||||
@ -0,0 +1,28 @@ |
|||||
|
import { byId, createTableEditor } from '../utils/dom.js'; |
||||
|
|
||||
|
export function createModbusController() { |
||||
|
return createTableEditor({ |
||||
|
listElement: byId('modbus-field-list'), |
||||
|
templateElement: byId('modbus-row-template'), |
||||
|
selectors: { |
||||
|
field: '.field-display', |
||||
|
addr: '.addr-display', |
||||
|
idt: '.idt-display', |
||||
|
odt: '.odt-display', |
||||
|
dv: '.dv-display', |
||||
|
expr: '.expr-display', |
||||
|
}, |
||||
|
inputIds: { |
||||
|
field: 'modbus_field_input', |
||||
|
addr: 'modbus_addr_input', |
||||
|
idt: 'modbus_idt_input', |
||||
|
odt: 'modbus_odt_input', |
||||
|
dv: 'modbus_dv_input', |
||||
|
expr: 'modbus_expr_input', |
||||
|
}, |
||||
|
addButtonId: 'modbus_add_btn', |
||||
|
modifyButtonId: 'modbus_modify_btn', |
||||
|
deleteButtonId: 'modbus_delete_btn', |
||||
|
emptyInputSelector: '.modbus-input-area input, .modbus-input-area select', |
||||
|
}); |
||||
|
} |
||||
@ -0,0 +1,28 @@ |
|||||
|
import { byId, createTableEditor } from '../utils/dom.js'; |
||||
|
|
||||
|
export function createOpcUaController() { |
||||
|
return createTableEditor({ |
||||
|
listElement: byId('opcua-field-list'), |
||||
|
templateElement: byId('opcua-row-template'), |
||||
|
selectors: { |
||||
|
field: '.opcua-field-display', |
||||
|
addr: '.opcua-addr-display', |
||||
|
ns: '.opcua-ns-display', |
||||
|
odt: '.opcua-odt-display', |
||||
|
dv: '.opcua-dv-display', |
||||
|
expr: '.opcua-expr-display', |
||||
|
}, |
||||
|
inputIds: { |
||||
|
field: 'opcua_field_input', |
||||
|
addr: 'opcua_addr_input', |
||||
|
ns: 'opcua_ns_input', |
||||
|
odt: 'opcua_odt_input', |
||||
|
dv: 'opcua_dv_input', |
||||
|
expr: 'opcua_expr_input', |
||||
|
}, |
||||
|
addButtonId: 'opcua_add_btn', |
||||
|
modifyButtonId: 'opcua_modify_btn', |
||||
|
deleteButtonId: 'opcua_delete_btn', |
||||
|
emptyInputSelector: '.opcua-input-area input, .opcua-input-area select', |
||||
|
}); |
||||
|
} |
||||
@ -0,0 +1,147 @@ |
|||||
|
import { REGISTER_ID_MAP, getLastLoadedRegisterData, setLastLoadedRegisterData } from '../state.js'; |
||||
|
import { byId } from '../utils/dom.js'; |
||||
|
|
||||
|
export const normProtocol = (value) => String(value ?? '').trim().replace(/[-\s]+/g, '_').toUpperCase(); |
||||
|
export const getOpcArr = (data) => data?.OPC_UA ?? data?.['OPC-UA'] ?? []; |
||||
|
|
||||
|
const checkboxIds = ['ai0', 'ai1', 'ai2', 'ai3', 'di0', 'di1', 'di2', 'di3']; |
||||
|
|
||||
|
export function normalizeRegisterForPost(register) { |
||||
|
const normalized = JSON.parse(JSON.stringify(register || {})); |
||||
|
const toUpperSnake = (value) => String(value ?? '').trim().toUpperCase().replace(/[-\s]+/g, '_'); |
||||
|
|
||||
|
if (normalized.protocol) { |
||||
|
normalized.protocol = toUpperSnake(normalized.protocol); |
||||
|
} |
||||
|
|
||||
|
if (normalized.can_input) { |
||||
|
normalized.can_input = String(normalized.can_input).trim().toUpperCase(); |
||||
|
} |
||||
|
|
||||
|
if (normalized.MEID !== undefined && normalized.MEID !== null) { |
||||
|
const meid = String(normalized.MEID).trim(); |
||||
|
normalized.MEID = /^\d+$/.test(meid) ? Number(meid) : meid; |
||||
|
} |
||||
|
|
||||
|
['dev_type', 'equipment', 'speed_data'].forEach((key) => { |
||||
|
if (normalized[key]) { |
||||
|
normalized[key] = toUpperSnake(normalized[key]); |
||||
|
} |
||||
|
}); |
||||
|
|
||||
|
Object.keys(normalized).forEach((key) => { |
||||
|
if (normalized[key] === '') { |
||||
|
delete normalized[key]; |
||||
|
} |
||||
|
}); |
||||
|
|
||||
|
if (normalized['OPC-UA'] && !normalized.OPC_UA) { |
||||
|
normalized.OPC_UA = normalized['OPC-UA']; |
||||
|
delete normalized['OPC-UA']; |
||||
|
} |
||||
|
|
||||
|
return normalized; |
||||
|
} |
||||
|
|
||||
|
export function createProtocolDataController({ navigation, modbusController, opcUaController, canController }) { |
||||
|
const protocolSelect = byId('reg_protocol'); |
||||
|
const canInputSelect = byId('reg_can_input'); |
||||
|
|
||||
|
function syncProtocolMenus() { |
||||
|
navigation.toggleProtocolMenus(protocolSelect?.value, canInputSelect?.value); |
||||
|
} |
||||
|
|
||||
|
function bindEvents() { |
||||
|
protocolSelect?.addEventListener('change', syncProtocolMenus); |
||||
|
canInputSelect?.addEventListener('change', syncProtocolMenus); |
||||
|
} |
||||
|
|
||||
|
function collectProtocolData() { |
||||
|
const register = getLastLoadedRegisterData(); |
||||
|
|
||||
|
Object.entries(REGISTER_ID_MAP).forEach(([htmlId, dtoKey]) => { |
||||
|
const element = byId(htmlId); |
||||
|
if (element) { |
||||
|
register[dtoKey] = element.value !== null && element.value !== undefined |
||||
|
? element.value.trim() |
||||
|
: ''; |
||||
|
} |
||||
|
}); |
||||
|
|
||||
|
checkboxIds.forEach((id) => { |
||||
|
const checkbox = byId(id); |
||||
|
register[id] = checkbox && checkbox.type === 'checkbox' ? (checkbox.checked ? '1' : '0') : '0'; |
||||
|
}); |
||||
|
|
||||
|
register.MODBUS = modbusController.serializeRows('field'); |
||||
|
register.OPC_UA = opcUaController.serializeRows('field'); |
||||
|
register.CAN = canController.serializeRows('field'); |
||||
|
|
||||
|
return register; |
||||
|
} |
||||
|
|
||||
|
function loadFromRegisterJson(register) { |
||||
|
if (!register) { |
||||
|
return; |
||||
|
} |
||||
|
|
||||
|
setLastLoadedRegisterData(register); |
||||
|
|
||||
|
Object.entries(REGISTER_ID_MAP).forEach(([htmlId, dtoKey]) => { |
||||
|
const element = byId(htmlId); |
||||
|
if (!element) { |
||||
|
return; |
||||
|
} |
||||
|
|
||||
|
if (Object.prototype.hasOwnProperty.call(register, dtoKey)) { |
||||
|
const value = register[dtoKey]; |
||||
|
if (element.type === 'checkbox') { |
||||
|
element.checked = value === '1' || value === true; |
||||
|
} else { |
||||
|
element.value = value ?? ''; |
||||
|
} |
||||
|
return; |
||||
|
} |
||||
|
|
||||
|
if (element.type === 'checkbox') { |
||||
|
element.checked = false; |
||||
|
} else { |
||||
|
element.value = ''; |
||||
|
} |
||||
|
}); |
||||
|
|
||||
|
checkboxIds.forEach((id) => { |
||||
|
const checkbox = byId(id); |
||||
|
if (checkbox) { |
||||
|
checkbox.checked = register[id] === '1' || register[id] === true; |
||||
|
} |
||||
|
}); |
||||
|
|
||||
|
if (register.protocol && protocolSelect) { |
||||
|
const protocol = normProtocol(register.protocol); |
||||
|
protocolSelect.value = protocol; |
||||
|
setLastLoadedRegisterData({ ...register, protocol }); |
||||
|
} |
||||
|
|
||||
|
modbusController.renderRows(Array.isArray(register.MODBUS) ? register.MODBUS : []); |
||||
|
opcUaController.renderRows(Array.isArray(getOpcArr(register)) ? getOpcArr(register) : []); |
||||
|
canController.renderRows(Array.isArray(register.CAN) ? register.CAN : []); |
||||
|
syncProtocolMenus(); |
||||
|
} |
||||
|
|
||||
|
function initialize() { |
||||
|
modbusController.initialize(); |
||||
|
opcUaController.initialize(); |
||||
|
canController.initialize(); |
||||
|
bindEvents(); |
||||
|
syncProtocolMenus(); |
||||
|
} |
||||
|
|
||||
|
return { |
||||
|
collectProtocolData, |
||||
|
initialize, |
||||
|
loadFromRegisterJson, |
||||
|
normalizeRegisterForPost, |
||||
|
syncProtocolMenus, |
||||
|
}; |
||||
|
} |
||||
@ -0,0 +1,105 @@ |
|||||
|
import { getOpcArr, normProtocol } from './protocolData.js'; |
||||
|
|
||||
|
export function isValidRegisterConfig(data) { |
||||
|
if (typeof data !== 'object' || data === null) { |
||||
|
console.error('Validation Error: Data is not an object or is null.'); |
||||
|
return false; |
||||
|
} |
||||
|
|
||||
|
if (typeof data.protocol === 'string') { |
||||
|
data.protocol = normProtocol(data.protocol); |
||||
|
} |
||||
|
|
||||
|
const requiredFields = [ |
||||
|
'protocol', |
||||
|
'analog_input_level', |
||||
|
'can_input', |
||||
|
'dev_type', |
||||
|
'dr_on', |
||||
|
'equipment', |
||||
|
'two_byte_order', |
||||
|
'four_byte_order', |
||||
|
'speed_data', |
||||
|
'version', |
||||
|
'equipment_id', |
||||
|
'MEID', |
||||
|
]; |
||||
|
|
||||
|
for (const field of requiredFields) { |
||||
|
if (!Object.prototype.hasOwnProperty.call(data, field)) { |
||||
|
console.error(`Validation Error: Missing required top-level field: '${field}'`); |
||||
|
return false; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
const validProtocols = ['MODBUS', 'OPC_UA', 'CAN', 'NONE']; |
||||
|
if (typeof data.protocol !== 'string' || !validProtocols.includes(data.protocol)) { |
||||
|
console.error(`Validation Error: Invalid protocol '${data.protocol}'`); |
||||
|
return false; |
||||
|
} |
||||
|
|
||||
|
if (typeof data.analog_input_level !== 'string' && typeof data.analog_input_level !== 'number') { |
||||
|
console.error("Validation Error: 'analog_input_level' is not a string or number."); |
||||
|
return false; |
||||
|
} |
||||
|
|
||||
|
if (data.protocol === 'MODBUS') { |
||||
|
if (!Array.isArray(data.MODBUS)) { |
||||
|
console.error("Validation Error: 'MODBUS' must be an array when protocol is MODBUS."); |
||||
|
return false; |
||||
|
} |
||||
|
|
||||
|
for (const [index, item] of data.MODBUS.entries()) { |
||||
|
for (const key of ['field', 'idt', 'addr', 'odt', 'dv']) { |
||||
|
if (!item || String(item[key] ?? '').trim() === '') { |
||||
|
console.error(`Validation Error: MODBUS row ${index} missing '${key}'.`); |
||||
|
return false; |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
} else if (Array.isArray(data.MODBUS) && data.MODBUS.length > 0) { |
||||
|
console.error("Validation Error: 'MODBUS' must be empty when protocol is not MODBUS."); |
||||
|
return false; |
||||
|
} |
||||
|
|
||||
|
if (data.protocol === 'OPC_UA') { |
||||
|
const opcRows = getOpcArr(data); |
||||
|
if (!Array.isArray(opcRows)) { |
||||
|
console.error("Validation Error: 'OPC_UA' must be an array when protocol is OPC_UA."); |
||||
|
return false; |
||||
|
} |
||||
|
|
||||
|
for (const [index, item] of opcRows.entries()) { |
||||
|
for (const key of ['field', 'ns', 'addr', 'odt', 'dv']) { |
||||
|
if (!item || String(item[key] ?? '').trim() === '') { |
||||
|
console.error(`Validation Error: OPC_UA row ${index} missing '${key}'.`); |
||||
|
return false; |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
} else if (data.OPC_UA && !Array.isArray(data.OPC_UA)) { |
||||
|
console.error("Validation Error: 'OPC_UA' must be an array if present."); |
||||
|
return false; |
||||
|
} |
||||
|
|
||||
|
if (String(data.can_input).toLowerCase() === 'on') { |
||||
|
if (!Array.isArray(data.CAN)) { |
||||
|
console.error("Validation Error: 'CAN' is required when can_input is on."); |
||||
|
return false; |
||||
|
} |
||||
|
|
||||
|
for (const [index, item] of data.CAN.entries()) { |
||||
|
for (const key of ['field', 'id', 'odt', 'dv']) { |
||||
|
if (!item || String(item[key] ?? '').trim() === '') { |
||||
|
console.error(`Validation Error: CAN row ${index} missing '${key}'.`); |
||||
|
return false; |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
} else if (Array.isArray(data.CAN) && data.CAN.length > 0) { |
||||
|
console.error("Validation Error: 'CAN' must be empty when can_input is off."); |
||||
|
return false; |
||||
|
} |
||||
|
|
||||
|
return true; |
||||
|
} |
||||
@ -0,0 +1,74 @@ |
|||||
|
export const DEVICE_DEFAULTS = { |
||||
|
wifi_static: '', |
||||
|
wifi_ip: '', |
||||
|
wifi_netmask: '', |
||||
|
wifi_gateway: '', |
||||
|
wifi_dns1: '', |
||||
|
wifi_dns2: '', |
||||
|
wifi_country_code: '', |
||||
|
eth_ip: '', |
||||
|
eth_netmask: '', |
||||
|
eth_gateway: '', |
||||
|
protocol_server_ip: '', |
||||
|
protocol_server_port: null, |
||||
|
update_server_ip: '', |
||||
|
update_server_port: null, |
||||
|
rtcm_server_ip: '', |
||||
|
rtcm_server_port: null, |
||||
|
opc_ua_server_ip: '', |
||||
|
opc_ua_server_port: null, |
||||
|
modbus_server_ip: '', |
||||
|
modbus_server_port: null, |
||||
|
lte_server_ip: '', |
||||
|
lte_server_port: null, |
||||
|
can_bus_type: '', |
||||
|
can_baudrate: null, |
||||
|
rs485_mode: '', |
||||
|
rs485_baudrate: null, |
||||
|
rs485_databits: null, |
||||
|
rs485_parity: '', |
||||
|
rs485_stopbits: null, |
||||
|
lte_ip: '', |
||||
|
lte_netmask: '', |
||||
|
lte_gateway: '', |
||||
|
lte_port: null, |
||||
|
log_save: 'off', |
||||
|
log_max_size: null, |
||||
|
log_max_duration: null, |
||||
|
WIFI_SSID: [{ wifi_ssid: '', wifi_passwd: '', wifi_security: 'none' }], |
||||
|
}; |
||||
|
|
||||
|
export const REGISTER_ID_MAP = { |
||||
|
reg_analog_input_level: 'analog_input_level', |
||||
|
ai0: 'ai0', |
||||
|
ai1: 'ai1', |
||||
|
di0: 'di0', |
||||
|
di1: 'di1', |
||||
|
reg_can_input: 'can_input', |
||||
|
reg_device_type: 'dev_type', |
||||
|
reg_dr_on_off: 'dr_on', |
||||
|
reg_equipment: 'equipment', |
||||
|
reg_two_byte_order: 'two_byte_order', |
||||
|
reg_four_byte_order: 'four_byte_order', |
||||
|
reg_protocol: 'protocol', |
||||
|
reg_speed_data_source: 'speed_data', |
||||
|
reg_version: 'version', |
||||
|
reg_equipment_id: 'equipment_id', |
||||
|
reg_meid: 'MEID', |
||||
|
}; |
||||
|
|
||||
|
const appState = { |
||||
|
lastLoadedRegisterData: {}, |
||||
|
}; |
||||
|
|
||||
|
export function cloneDeviceDefaults() { |
||||
|
return JSON.parse(JSON.stringify(DEVICE_DEFAULTS)); |
||||
|
} |
||||
|
|
||||
|
export function getLastLoadedRegisterData() { |
||||
|
return { ...appState.lastLoadedRegisterData }; |
||||
|
} |
||||
|
|
||||
|
export function setLastLoadedRegisterData(data) { |
||||
|
appState.lastLoadedRegisterData = { ...(data || {}) }; |
||||
|
} |
||||
@ -0,0 +1,187 @@ |
|||||
|
export function byId(id) { |
||||
|
return document.getElementById(id); |
||||
|
} |
||||
|
|
||||
|
export function queryAll(selector, root = document) { |
||||
|
return Array.from(root.querySelectorAll(selector)); |
||||
|
} |
||||
|
|
||||
|
export function setElementValue(element, value) { |
||||
|
if (!element) { |
||||
|
return; |
||||
|
} |
||||
|
|
||||
|
element.value = value ?? ''; |
||||
|
} |
||||
|
|
||||
|
export function cloneTemplateRow(templateElement) { |
||||
|
return templateElement.content.firstElementChild.cloneNode(true); |
||||
|
} |
||||
|
|
||||
|
export function createTableEditor({ |
||||
|
listElement, |
||||
|
templateElement, |
||||
|
selectors, |
||||
|
inputIds, |
||||
|
addButtonId, |
||||
|
modifyButtonId, |
||||
|
deleteButtonId, |
||||
|
emptyInputSelector = 'input', |
||||
|
}) { |
||||
|
let selectedRow = null; |
||||
|
|
||||
|
function resetSelection() { |
||||
|
selectedRow = null; |
||||
|
queryAll('tr', listElement).forEach((row) => row.classList.remove('selected-row')); |
||||
|
} |
||||
|
|
||||
|
function clearInputs() { |
||||
|
Object.values(inputIds).forEach((id) => { |
||||
|
const element = byId(id); |
||||
|
if (element) { |
||||
|
element.value = ''; |
||||
|
} |
||||
|
}); |
||||
|
|
||||
|
queryAll(emptyInputSelector).forEach((element) => { |
||||
|
if (!Object.values(inputIds).includes(element.id)) { |
||||
|
element.value = ''; |
||||
|
} |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
function fillRow(row, values = {}) { |
||||
|
Object.entries(selectors).forEach(([key, selector]) => { |
||||
|
const target = row.querySelector(selector); |
||||
|
if (target) { |
||||
|
target.textContent = values[key] ?? ''; |
||||
|
} |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
function createRow(values = {}) { |
||||
|
const row = cloneTemplateRow(templateElement); |
||||
|
fillRow(row, values); |
||||
|
return row; |
||||
|
} |
||||
|
|
||||
|
function ensureEmptyRow() { |
||||
|
if (!listElement.children.length) { |
||||
|
listElement.appendChild(createRow()); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
function renderRows(rows = []) { |
||||
|
listElement.innerHTML = ''; |
||||
|
|
||||
|
if (rows.length) { |
||||
|
rows.forEach((row) => listElement.appendChild(createRow(row))); |
||||
|
} else { |
||||
|
ensureEmptyRow(); |
||||
|
} |
||||
|
|
||||
|
resetSelection(); |
||||
|
} |
||||
|
|
||||
|
function rowToObject(row) { |
||||
|
const output = {}; |
||||
|
|
||||
|
Object.entries(selectors).forEach(([key, selector]) => { |
||||
|
const value = row.querySelector(selector)?.textContent?.trim(); |
||||
|
if (value) { |
||||
|
output[key] = value; |
||||
|
} |
||||
|
}); |
||||
|
|
||||
|
return output; |
||||
|
} |
||||
|
|
||||
|
function serializeRows(requiredKey) { |
||||
|
return queryAll('tr', listElement) |
||||
|
.map((row) => rowToObject(row)) |
||||
|
.filter((row) => row[requiredKey]); |
||||
|
} |
||||
|
|
||||
|
function getInputValues() { |
||||
|
return Object.entries(inputIds).reduce((acc, [key, id]) => { |
||||
|
acc[key] = byId(id)?.value ?? ''; |
||||
|
return acc; |
||||
|
}, {}); |
||||
|
} |
||||
|
|
||||
|
function loadInputsFromRow(row) { |
||||
|
Object.entries(inputIds).forEach(([key, id]) => { |
||||
|
const value = row.querySelector(selectors[key])?.textContent ?? ''; |
||||
|
setElementValue(byId(id), value); |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
function addRowFromInputs() { |
||||
|
const values = getInputValues(); |
||||
|
const firstRow = listElement.querySelector('tr'); |
||||
|
const firstRequired = firstRow?.querySelector(selectors.field)?.textContent?.trim(); |
||||
|
|
||||
|
if (firstRow && !firstRequired) { |
||||
|
fillRow(firstRow, values); |
||||
|
} else { |
||||
|
listElement.appendChild(createRow(values)); |
||||
|
} |
||||
|
|
||||
|
clearInputs(); |
||||
|
resetSelection(); |
||||
|
} |
||||
|
|
||||
|
function modifySelectedRow() { |
||||
|
if (!selectedRow) { |
||||
|
alert('Please select the row you want to modify'); |
||||
|
return; |
||||
|
} |
||||
|
|
||||
|
fillRow(selectedRow, getInputValues()); |
||||
|
} |
||||
|
|
||||
|
function deleteSelectedRow() { |
||||
|
if (!selectedRow) { |
||||
|
alert('Select the row you want to delete'); |
||||
|
return; |
||||
|
} |
||||
|
|
||||
|
selectedRow.remove(); |
||||
|
clearInputs(); |
||||
|
resetSelection(); |
||||
|
ensureEmptyRow(); |
||||
|
} |
||||
|
|
||||
|
function bindSelection() { |
||||
|
listElement.addEventListener('click', (event) => { |
||||
|
const row = event.target.closest('tr'); |
||||
|
if (!row) { |
||||
|
return; |
||||
|
} |
||||
|
|
||||
|
resetSelection(); |
||||
|
row.classList.add('selected-row'); |
||||
|
selectedRow = row; |
||||
|
loadInputsFromRow(row); |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
function bindActions() { |
||||
|
byId(addButtonId)?.addEventListener('click', addRowFromInputs); |
||||
|
byId(modifyButtonId)?.addEventListener('click', modifySelectedRow); |
||||
|
byId(deleteButtonId)?.addEventListener('click', deleteSelectedRow); |
||||
|
} |
||||
|
|
||||
|
function initialize() { |
||||
|
bindSelection(); |
||||
|
bindActions(); |
||||
|
ensureEmptyRow(); |
||||
|
} |
||||
|
|
||||
|
return { |
||||
|
clearInputs, |
||||
|
initialize, |
||||
|
renderRows, |
||||
|
serializeRows, |
||||
|
}; |
||||
|
} |
||||
@ -0,0 +1,13 @@ |
|||||
|
export function downloadJson(data, filename) { |
||||
|
const json = JSON.stringify(data, null, 2); |
||||
|
const blob = new Blob([json], { type: 'application/json' }); |
||||
|
const url = URL.createObjectURL(blob); |
||||
|
const anchor = document.createElement('a'); |
||||
|
|
||||
|
anchor.href = url; |
||||
|
anchor.download = filename; |
||||
|
document.body.appendChild(anchor); |
||||
|
anchor.click(); |
||||
|
document.body.removeChild(anchor); |
||||
|
URL.revokeObjectURL(url); |
||||
|
} |
||||
@ -1,335 +1 @@ |
|||||
* {margin: 0;padding: 0;box-sizing: border-box} |
@import url("./css/style.css"); |
||||
body {font-family: "Pretendard", sans-serif;min-height: 100vh;display: flex;background: #fff;color: #111} |
|
||||
|
|
||||
/* ───── Sidebar ───── */ |
|
||||
.sidebar {width: 260px;border-right: 1px solid #ddd;padding: 32px 24px;display: flex;flex-direction: column} |
|
||||
.sidebar h2 {font-size: 24px;margin-bottom: 32px} |
|
||||
details.nav { |
|
||||
margin-bottom: 24px |
|
||||
} |
|
||||
|
|
||||
.nav > summary { |
|
||||
list-style: none; |
|
||||
cursor: pointer; |
|
||||
display: flex; |
|
||||
align-items: center; |
|
||||
font-size: 20px |
|
||||
} |
|
||||
|
|
||||
.nav > summary::before { |
|
||||
content: ""; |
|
||||
width: 8px; |
|
||||
height: 8px; |
|
||||
margin-right: 8px; |
|
||||
border-left: 2px solid #111; |
|
||||
border-bottom: 2px solid #111; |
|
||||
transform: rotate(-135deg); |
|
||||
transition: .2s |
|
||||
} |
|
||||
|
|
||||
.nav[open] > summary::before { |
|
||||
transform: rotate(-45deg) |
|
||||
} |
|
||||
|
|
||||
.nav-items { |
|
||||
display: flex; |
|
||||
flex-direction: column; |
|
||||
margin-top: 10px; |
|
||||
padding-left: 16px; |
|
||||
gap: 6px |
|
||||
} |
|
||||
|
|
||||
.nav-items button { |
|
||||
background: none; |
|
||||
border: none; |
|
||||
cursor: pointer; |
|
||||
font-size: 16px; |
|
||||
text-align: left; |
|
||||
padding: 4px 0 |
|
||||
} |
|
||||
|
|
||||
/* ───── Top-bar ───── */ |
|
||||
.topbar { |
|
||||
height: 68px; |
|
||||
background: #d9d9d9; |
|
||||
display: flex; |
|
||||
align-items: center; |
|
||||
padding: 0 32px; |
|
||||
font-size: 28px; |
|
||||
font-weight: 500; |
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, .15) inset |
|
||||
} |
|
||||
|
|
||||
/* ───── Main Board ───── */ |
|
||||
.content { |
|
||||
flex: 1; |
|
||||
display: flex; |
|
||||
flex-direction: column |
|
||||
} |
|
||||
|
|
||||
.board { |
|
||||
flex: 1; |
|
||||
overflow: auto; |
|
||||
padding: 40px; |
|
||||
display: flex; |
|
||||
flex-direction: column; |
|
||||
gap: 32px |
|
||||
} |
|
||||
|
|
||||
.card { |
|
||||
background: #fff; |
|
||||
border: 1px solid #e5e5e5; |
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, .15) inset; |
|
||||
padding: 32px; |
|
||||
border-radius: 8px |
|
||||
} |
|
||||
|
|
||||
.card h3 { |
|
||||
font-size: 24px; |
|
||||
margin-bottom: 24px |
|
||||
} |
|
||||
|
|
||||
/* SSID rows */ |
|
||||
.ssid-row { |
|
||||
display: flex; |
|
||||
align-items: center; |
|
||||
gap: 8px; |
|
||||
margin-bottom: 8px; |
|
||||
} |
|
||||
|
|
||||
.ssid-row input.ssid-name, |
|
||||
.ssid-row input.ssid-pw { |
|
||||
width: 200px; |
|
||||
padding: 6px 8px; |
|
||||
font-size: 16px; |
|
||||
border: 1px solid #aaa; |
|
||||
border-radius: 4px; |
|
||||
} |
|
||||
|
|
||||
.ssid-row .toggle-pw { |
|
||||
padding: 6px 10px; |
|
||||
font-size: 14px; |
|
||||
border: 1px solid #aaa; |
|
||||
background-color: #eee; |
|
||||
border-radius: 4px; |
|
||||
cursor: pointer; |
|
||||
min-width: 60px; |
|
||||
text-align: center; |
|
||||
} |
|
||||
.ssid-row .toggle-pw:hover { |
|
||||
background-color: #cecece; |
|
||||
} |
|
||||
.ssid-row .toggle-pw:active { |
|
||||
background-color: #bdbdbd; |
|
||||
transform: scale(0.97); |
|
||||
} |
|
||||
|
|
||||
.ssid-row select { |
|
||||
width: 100px; |
|
||||
padding: 6px 8px; |
|
||||
font-size: 16px; |
|
||||
border: 1px solid #aaa; |
|
||||
border-radius: 4px; |
|
||||
} |
|
||||
|
|
||||
.ssid-row .delete-ssid { |
|
||||
padding: 6px 10px; |
|
||||
font-size: 14px; |
|
||||
background-color: #ffdddd; |
|
||||
border: 1px solid #aaa; |
|
||||
border-radius: 4px; |
|
||||
cursor: pointer; |
|
||||
} |
|
||||
|
|
||||
.ssid-row .delete-ssid:hover { |
|
||||
background-color: #ffaaaa; |
|
||||
} |
|
||||
.ssid-row .delete-ssid:active{ |
|
||||
background-color: #fd9e9e; |
|
||||
transform: scale(0.97); |
|
||||
} |
|
||||
|
|
||||
.icon-button { |
|
||||
font-size: 10px; |
|
||||
background-color: #f0f0f0; |
|
||||
border: none; |
|
||||
padding: 8px 12px; |
|
||||
border-radius: 6px; |
|
||||
cursor: pointer; |
|
||||
transition: background-color 0.2s ease; |
|
||||
} |
|
||||
|
|
||||
.icon-button:hover { |
|
||||
background-color: #dcdcdc; |
|
||||
} |
|
||||
|
|
||||
.icon-button:active { |
|
||||
background-color: #c0c0c0; |
|
||||
} |
|
||||
|
|
||||
.ssid-country-code { |
|
||||
display: flex; |
|
||||
align-items: center; |
|
||||
gap: 12px; |
|
||||
margin-top: 16px; |
|
||||
} |
|
||||
|
|
||||
.ssid-country-code label { |
|
||||
min-width: 100px; |
|
||||
font-weight: 600; |
|
||||
} |
|
||||
|
|
||||
.ssid-country-code input { |
|
||||
width: 80px; |
|
||||
padding: 6px 8px; |
|
||||
font-size: 16px; |
|
||||
border: 1px solid #aaa; |
|
||||
border-radius: 4px; |
|
||||
text-transform: uppercase; |
|
||||
} |
|
||||
|
|
||||
.separate-setting-card { |
|
||||
margin-top: 16px; |
|
||||
} |
|
||||
|
|
||||
.field-hint { |
|
||||
color: #666; |
|
||||
font-size: 13px; |
|
||||
} |
|
||||
/* 3-column grids (Network / Server) */ |
|
||||
.grid-3-col { |
|
||||
display: grid; |
|
||||
grid-template-columns: 180px 220px 100px; /* SSID와 유사하게 축소 */ |
|
||||
align-items: center; |
|
||||
gap: 12px; |
|
||||
margin: 8px 0; |
|
||||
} |
|
||||
|
|
||||
.grid-3-col input { |
|
||||
padding: 6px 8px; |
|
||||
font-size: 16px; |
|
||||
border: 1px solid #aaa; |
|
||||
border-radius: 4px; /* 기존 1px → 4px 통일 */ |
|
||||
width: 100%; /* grid item 내에서만 퍼지므로 유지 가능 */ |
|
||||
} |
|
||||
|
|
||||
.grid-3-col input::placeholder { |
|
||||
color: #999; |
|
||||
} |
|
||||
/* radio */ |
|
||||
.radio-group {display: flex;align-items: center;gap: 24px} |
|
||||
|
|
||||
.radio-label {display: inline-flex;align-items: center;gap: 4px;cursor: pointer} |
|
||||
.radio-label input {margin: 0} |
|
||||
/* I/O */ |
|
||||
.io-top {display: grid;grid-template-columns:repeat(auto-fit, minmax(360px, 1fr));gap: 40px;margin-bottom: 32px} |
|
||||
.io-bot {display: grid; /* Ethernet·LTE와 같은 2열(가변) 그리드 */grid-template-columns:repeat(auto-fit, minmax(360px, 1fr));gap: 40px;} |
|
||||
.io-group {display: flex;flex-direction: column;gap: 8px} |
|
||||
.io-group h4 {font-size: 20px;font-weight: 600;margin-bottom: 16px;display: flex;align-items: center;gap: 8px} |
|
||||
.io-group h4::before {content: "";width: 8px;height: 8px;background: #aaa;display: inline-block} |
|
||||
.io-fieldset {border: 1px solid #bbb;padding: 24px 32px;border-radius: 4px;display: flex;flex-direction: column;gap: 16px;width: 100%} |
|
||||
.io-row {display: grid;grid-template-columns:120px 12px 1fr;align-items: center;gap: 8px} |
|
||||
.io-row input, .io-row select {padding: 4px 6px;border: 1px solid #aaa;border-radius: 4px;font-size: 14px;width: 50%} |
|
||||
.io-row span.label {font-size: 14px;color: #333;display: block;width: 100%;} |
|
||||
.io-row span.label-inline {font-size: 14px;color: #333;display: inline;width: auto} |
|
||||
.io-row span.colon {font-size: 14px;color: #333;text-align: center;} |
|
||||
.reg-io-grid {display: grid;grid-template-columns: 1fr 1fr;gap: 4px} |
|
||||
.reg-io-grid .io-fieldset .io-row {display: grid;grid-template-columns: 140px 220px;align-items: center;gap: 4px;margin-bottom: 8px;} |
|
||||
|
|
||||
/* config 내부 라벨 */ |
|
||||
.reg-io-grid .io-fieldset .io-row span.label {font-size: 14px;color: #333;} |
|
||||
.reg-io-grid .io-fieldset .io-row input, |
|
||||
.reg-io-grid .io-fieldset .io-row select {padding: 4px 6px;border: 1px solid #aaa;border-radius: 4px;font-size: 14px;width: 100%;box-sizing: border-box;} |
|
||||
.reg-io-grid .io-fieldset .io-row.checkbox-group {display: flex;align-items: center;gap: 16px;grid-column: 2 / 3;} |
|
||||
.reg-io-grid .io-fieldset .io-row .checkbox-with-text {display: flex;align-items: center;gap: 10px;width: 100%;} |
|
||||
.reg-io-grid .io-fieldset .io-row .checkbox-with-text input[type="checkbox"] {width: auto;margin: 0;} |
|
||||
|
|
||||
/* 비활성 input 시각화 */ |
|
||||
.disabled {background: #f5f5f5;cursor: not-allowed} |
|
||||
/* Footer Buttons */ |
|
||||
.actions {display: flex;gap: 24px;justify-content: center;padding: 20px} |
|
||||
.actions button {min-width: 140px;padding: 10px 18px;font-size: 16px;border: 1px solid #bbb;background: #d9d9d9;border-radius: 4px;cursor: pointer} |
|
||||
.actions button:hover {background-color: #bdbdbd;} |
|
||||
.actions button:active {background-color: #bdbdbd; transform: scale(0.97);} |
|
||||
.config-actions{display: flex;gap: 24px;justify-content: center;background: #f7f7f7;border: 1px solid #bbb;border-radius: 4px;margin: 24px 0 0 0;padding: 20px 0;} |
|
||||
.config-actions button{min-width:140px;padding:10px 18px;font-size:16px;border:1px solid #bbb;background:#d9d9d9;border-radius:4px;cursor:pointer} |
|
||||
.topbar { |
|
||||
display: flex; |
|
||||
justify-content: space-between; |
|
||||
align-items: center; |
|
||||
padding: 0 32px; |
|
||||
height: 68px; |
|
||||
background: #d9d9d9; |
|
||||
font-size: 28px; |
|
||||
font-weight: 500; |
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, .15) inset; |
|
||||
} |
|
||||
|
|
||||
.save-file { |
|
||||
font-size: 16px; |
|
||||
padding: 8px 16px; |
|
||||
background-color: #4caf50; |
|
||||
color: white; |
|
||||
border: none; |
|
||||
border-radius: 4px; |
|
||||
cursor: pointer; |
|
||||
} |
|
||||
.save-file:hover { |
|
||||
background-color: #45a049;; |
|
||||
} |
|
||||
.save-file:active { |
|
||||
background-color: #3e8e41; |
|
||||
transform: scale(0.97); |
|
||||
} |
|
||||
/* MODBUS 화면 심플 스타일 */ |
|
||||
#page-modbus .card {background: #f7f7f7;border: 1px solid #bbb;box-shadow: none;padding: 24px;border-radius: 4px;display: flex;flex-direction: column;} |
|
||||
#page-modbus table {width: 100%;border-collapse: collapse;background: #fff;border: 1px solid #bbb;} |
|
||||
#page-modbus th, #page-modbus td {border: 1px solid #bbb;padding: 6px 8px;text-align: left;background: #fff;} |
|
||||
#page-modbus th {background: #e5e5e5;font-weight: 500;} |
|
||||
#page-modbus tbody td {height: 34px;vertical-align: middle;} |
|
||||
#page-modbus .modbus-input-area {display: grid;grid-template-columns: 200px 200px 200px 200px;gap: 20px 20px;margin: 24px auto 16px auto;background: #eaeaea;padding: 16px;border-radius: 4px;margin-left: auto;margin-right: auto;} |
|
||||
#page-modbus .input-row {display: contents;} |
|
||||
#page-modbus .modbus-input-area label.nowrap {white-space: nowrap;} |
|
||||
#page-modbus .modbus-input-area label {min-width: auto;color: #444;white-space: normal;} |
|
||||
#page-modbus .modbus-input-area input {background: #e0e0e0;border: none;border-radius: 2px;padding: 4px 8px;min-width: auto;} |
|
||||
#page-modbus .modbus-actions {display: flex;gap: 24px;justify-content: center;margin: 16px 0 0 0;} |
|
||||
#page-modbus .modbus-actions button {background: #d3d3d3;border: 1px solid #aaa;border-radius: 2px;padding: 8px 24px;font-size: 16px;cursor: pointer;} |
|
||||
/* 하단 버튼 영역 */ |
|
||||
#modbus-bottom-actions {display: flex;gap: 24px;justify-content: center;background: #f7f7f7;border: 1px solid #bbb;border-radius: 4px;margin: 24px 0 0 0;padding: 20px 0;} |
|
||||
#modbus-bottom-actions button {background: #e0e0e0;border: 1px solid #aaa;border-radius: 2px;padding: 10px 18px;font-size: 16px;min-width: 140px;cursor: pointer;} |
|
||||
|
|
||||
/* OPC_UA 화면 */ |
|
||||
#page-opcua .card {background: #f7f7f7;border: 1px solid #bbb;box-shadow: none;padding: 24px;border-radius: 4px;display: flex;flex-direction: column;} |
|
||||
#page-opcua table {width: 100%;border-collapse: collapse;background: #fff;border: 1px solid #bbb;} |
|
||||
#page-opcua th, #page-opcua td {border: 1px solid #bbb;padding: 6px 8px;text-align: left;background: #fff;} |
|
||||
#page-opcua th {background: #e5e5e5;font-weight: 500;} |
|
||||
#page-opcua tbody td {height: 34px;vertical-align: middle;} |
|
||||
#page-opcua .opcua-input-area {display: grid;grid-template-columns: 200px 200px 200px 200px;gap: 20px 20px;margin: 24px auto 16px auto;background: #eaeaea;padding: 16px;border-radius: 4px;margin-left: auto;margin-right: auto;} |
|
||||
#page-opcua .input-row {display: contents;} |
|
||||
#page-opcua .opcua-input-area label.nowrap {white-space: nowrap;} |
|
||||
#page-opcua .opcua-input-area label {min-width: auto;color: #444;white-space: normal;} |
|
||||
#page-opcua .opcua-input-area input {background: #e0e0e0;border: none;border-radius: 2px;padding: 4px 8px;min-width: auto;} |
|
||||
#page-opcua .opcua-actions {display: flex;gap: 24px;justify-content: center;margin: 16px 0 0 0;} |
|
||||
#page-opcua .opcua-actions button {background: #d3d3d3;border: 1px solid #aaa;border-radius: 2px;padding: 8px 24px;font-size: 16px;cursor: pointer;} |
|
||||
#opcUa-bottom-actions {display: flex;gap: 24px;justify-content: center;background: #f7f7f7;border: 1px solid #bbb;border-radius: 4px;margin: 24px 0 0 0;padding: 20px 0;} |
|
||||
#opcUa-bottom-actions button {background: #e0e0e0;border: 1px solid #aaa;border-radius: 2px;padding: 10px 18px;font-size: 16px;min-width: 140px;cursor: pointer;} |
|
||||
|
|
||||
/* CAN 화면 스타일 */ |
|
||||
#page-can .card {background: #f7f7f7;border: 1px solid #bbb;box-shadow: none;padding: 24px;border-radius: 4px;display: flex;flex-direction: column;} |
|
||||
#page-can table {width: 100%;border-collapse: collapse;background: #fff;border: 1px solid #bbb;} |
|
||||
#page-can th, #page-can td {border: 1px solid #bbb;padding: 6px 8px;text-align: left;background: #fff;} |
|
||||
#page-can th {background: #e5e5e5;font-weight: 500;} |
|
||||
#page-can .can-input-area {display: grid;grid-template-columns: 200px 200px 200px 200px;gap: 20px 20px;margin: 24px auto 16px auto;background: #eaeaea;padding: 16px;border-radius: 4px;margin-left: auto;margin-right: auto;} |
|
||||
#page-can .input-row {display: contents;} |
|
||||
#page-can .can-input-area label.nowrap {white-space: nowrap;} |
|
||||
#page-can .can-input-area label {min-width: auto;color: #444;white-space: normal;} |
|
||||
#page-can .can-input-area input {background: #e0e0e0;border: none;border-radius: 2px;padding: 4px 8px;min-width: auto;} |
|
||||
#page-can .can-actions {display: flex;gap: 24px;justify-content: center;margin: 16px 0 0 0;} |
|
||||
#page-can .can-actions button {background: #d3d3d3;border: 1px solid #aaa;border-radius: 2px;padding: 8px 24px;font-size: 16px;cursor: pointer;} |
|
||||
#can-bottom-actions {display: flex;gap: 24px;justify-content: center;background: #f7f7f7;border: 1px solid #bbb;border-radius: 4px;margin: 24px 0 0 0;padding: 20px 0;} |
|
||||
#can-bottom-actions button {background: #e0e0e0;border: 1px solid #aaa;border-radius: 2px;padding: 10px 18px;font-size: 16px;min-width: 140px;cursor: pointer;} |
|
||||
|
|
||||
.selected-row {background: #d0e6ff !important;} |
|
||||
.selected-row > td {background: #d0e6ff !important;} |
|
||||
|
|
||||
.file-upload-section{padding: 10px;margin: 10px;text-align: center;} |
|
||||
|
|||||
Loading…
Reference in new issue