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} |
|||
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;} |
|||
@import url("./css/style.css"); |
|||
|
|||
Loading…
Reference in new issue