feat: add page

This commit is contained in:
caoqianming 2026-03-11 13:23:05 +08:00
parent 1374abe550
commit efed6aa816
8 changed files with 3181 additions and 57 deletions

2094
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -9,7 +9,7 @@ tokio = { version = "1.49", features = ["full"] }
# Web framework
axum = { version = "0.8", features = ["ws"] }
tower-http = { version = "0.6", features = ["cors"] }
tower-http = { version = "0.6", features = ["cors", "fs"] }
# Database
sqlx = { version = "0.8", features = ["runtime-tokio", "postgres", "chrono", "uuid"] }
@ -43,3 +43,8 @@ validator = { version = "0.20", features = ["derive"] }
# Error handling
anyhow = "1.0"
[target.'cfg(windows)'.dependencies]
tray-icon = "0.15"
winit = "0.30"
webbrowser = "0.8"

View File

@ -48,7 +48,10 @@ impl EventManager {
}
}
ReloadEvent::SourceUpdate { source_id } => {
tracing::info!("SourceUpdate event for {}: not implemented yet", source_id);
tracing::info!("Processing SourceUpdate event for {}", source_id);
if let Err(e) = connection_manager_clone.reconnect(&pool, source_id).await {
tracing::error!("Failed to reconnect source {}: {}", source_id, e);
}
}
ReloadEvent::SourceDelete { source_id } => {
tracing::info!("Processing SourceDelete event for {}", source_id);

View File

@ -15,6 +15,7 @@ use crate::util::response::ApiErr;
use crate::{AppState, model::{Node, Source}};
use anyhow::{Context};
use sqlx::QueryBuilder;
// 树节点结构体
#[derive(Debug, Serialize, Clone)]
@ -200,6 +201,81 @@ pub async fn create_source(
Ok((StatusCode::CREATED, Json(CreateSourceRes { id: new_id })))
}
#[derive(Deserialize, Validate)]
pub struct UpdateSourceReq {
pub name: Option<String>,
pub endpoint: Option<String>,
pub enabled: Option<bool>,
pub security_policy: Option<String>,
pub security_mode: Option<String>,
pub username: Option<String>,
pub password: Option<String>,
}
pub async fn update_source(
State(state): State<AppState>,
Path(source_id): Path<Uuid>,
Json(payload): Json<UpdateSourceReq>,
) -> Result<impl IntoResponse, ApiErr> {
payload.validate()?;
if payload.name.is_none()
&& payload.endpoint.is_none()
&& payload.enabled.is_none()
&& payload.security_policy.is_none()
&& payload.security_mode.is_none()
&& payload.username.is_none()
&& payload.password.is_none()
{
return Ok(Json(serde_json::json!({"ok_msg": "No fields to update"})));
}
let pool = &state.pool;
let exists = sqlx::query("SELECT 1 FROM source WHERE id = $1")
.bind(source_id)
.fetch_optional(pool)
.await?
.is_some();
if !exists {
return Err(ApiErr::NotFound(format!("Source with id {} not found", source_id), None));
}
let mut qb = QueryBuilder::new("UPDATE source SET ");
let mut sep = qb.separated(", ");
if let Some(name) = &payload.name {
sep.push("name = ").push_bind(name);
}
if let Some(endpoint) = &payload.endpoint {
sep.push("endpoint = ").push_bind(endpoint);
}
if let Some(enabled) = payload.enabled {
sep.push("enabled = ").push_bind(enabled);
}
if let Some(security_policy) = &payload.security_policy {
sep.push("security_policy = ").push_bind(security_policy);
}
if let Some(security_mode) = &payload.security_mode {
sep.push("security_mode = ").push_bind(security_mode);
}
if let Some(username) = &payload.username {
sep.push("username = ").push_bind(username);
}
if let Some(password) = &payload.password {
sep.push("password = ").push_bind(password);
}
sep.push("updated_at = NOW()");
qb.push(" WHERE id = ").push_bind(source_id);
qb.build().execute(pool).await?;
let _ = state.event_manager.send(crate::event::ReloadEvent::SourceUpdate { source_id });
Ok(Json(serde_json::json!({"ok_msg": "Source updated successfully"})))
}
pub async fn delete_source(
State(state): State<AppState>,
Path(source_id): Path<Uuid>,

View File

@ -11,6 +11,7 @@ mod websocket;
mod telemetry;
use config::AppConfig;
use tower_http::cors::{Any, CorsLayer};
use tower_http::services::ServeDir;
use db::init_database;
use middleware::simple_logger;
use connection::ConnectionManager;
@ -20,6 +21,7 @@ use axum::{
routing::{get, put},
Router,
};
use tokio::sync::mpsc;
#[derive(Clone)]
@ -93,13 +95,24 @@ async fn main() {
tracing::info!("Starting server at http://{}", addr);
let listener = tokio::net::TcpListener::bind(addr).await.unwrap();
// comment fixed
let shutdown_signal = async move{
let ui_url = format!("http://{}:{}/ui", "localhost", config.server_port);
let (shutdown_tx, mut shutdown_rx) = mpsc::channel::<()>(1);
let shutdown_tx_ctrl = shutdown_tx.clone();
let rt_handle = tokio::runtime::Handle::current();
init_tray(ui_url, shutdown_tx.clone(), rt_handle);
let connection_manager_for_shutdown = connection_manager.clone();
tokio::spawn(async move {
tokio::signal::ctrl_c()
.await
.expect("Failed to install Ctrl+C handler");
let _ = shutdown_tx_ctrl.send(()).await;
});
let shutdown_signal = async move{
let _ = shutdown_rx.recv().await;
tracing::info!("Received shutdown signal, closing all connections...");
connection_manager.disconnect_all().await;
connection_manager_for_shutdown.disconnect_all().await;
tracing::info!("All connections closed");
};
@ -112,7 +125,7 @@ async fn main() {
fn build_router(state: AppState) -> Router {
let all_route = Router::new()
.route("/api/source", get(handler::source::get_source_list).post(handler::source::create_source))
.route("/api/source/{source_id}", axum::routing::delete(handler::source::delete_source))
.route("/api/source/{source_id}", axum::routing::delete(handler::source::delete_source).put(handler::source::update_source))
.route("/api/source/{source_id}/browse", axum::routing::post(handler::source::browse_and_save_nodes))
.route("/api/source/{source_id}/node-tree", get(handler::source::get_node_tree))
.route("/api/point", get(handler::point::get_point_list))
@ -134,6 +147,7 @@ fn build_router(state: AppState) -> Router {
Router::new()
.merge(all_route)
.nest_service("/ui", ServeDir::new("web").append_index_html_on_directories(true))
.route("/ws/public", get(websocket::public_websocket_handler))
.route("/ws/client/{client_id}", get(websocket::client_websocket_handler))
.layer(axum::middleware::from_fn(simple_logger))
@ -145,3 +159,96 @@ fn build_router(state: AppState) -> Router {
)
.with_state(state)
}
#[cfg(windows)]
fn init_tray(ui_url: String, shutdown_tx: mpsc::Sender<()>, rt_handle: tokio::runtime::Handle) {
std::thread::spawn(move || {
if let Err(e) = tray::run_tray(ui_url, shutdown_tx, rt_handle) {
tracing::warn!("Tray init failed: {}", e);
}
});
}
#[cfg(not(windows))]
fn init_tray(_ui_url: String, _shutdown_tx: mpsc::Sender<()>, _rt_handle: tokio::runtime::Handle) {}
#[cfg(windows)]
mod tray {
use std::error::Error;
use tokio::sync::mpsc;
use tray_icon::{
menu::{Menu, MenuEvent, MenuItem},
Icon, TrayIconBuilder,
};
use winit::application::ApplicationHandler;
use winit::event_loop::{ActiveEventLoop, ControlFlow, EventLoop};
use winit::platform::windows::EventLoopBuilderExtWindows;
pub fn run_tray(
ui_url: String,
shutdown_tx: mpsc::Sender<()>,
rt_handle: tokio::runtime::Handle,
) -> Result<(), Box<dyn Error>> {
let mut builder = EventLoop::builder();
builder.with_any_thread(true);
let event_loop = builder.build()?;
let menu = Menu::new();
let open_item = MenuItem::new("Open UI", true, None);
let exit_item = MenuItem::new("Exit", true, None);
menu.append(&open_item)?;
menu.append(&exit_item)?;
let icon = Icon::from_rgba(vec![0, 120, 212, 255], 1, 1)?;
let _tray = TrayIconBuilder::new()
.with_tooltip("PLC Control")
.with_menu(Box::new(menu))
.with_icon(icon)
.build()?;
let menu_rx = MenuEvent::receiver();
let mut app = TrayApp {
menu_rx,
open_id: open_item.id().clone(),
exit_id: exit_item.id().clone(),
ui_url,
shutdown_tx,
rt_handle,
};
event_loop.run_app(&mut app).map_err(|e| e.into())
}
struct TrayApp {
menu_rx: &'static tray_icon::menu::MenuEventReceiver,
open_id: tray_icon::menu::MenuId,
exit_id: tray_icon::menu::MenuId,
ui_url: String,
shutdown_tx: mpsc::Sender<()>,
rt_handle: tokio::runtime::Handle,
}
impl ApplicationHandler for TrayApp {
fn resumed(&mut self, _event_loop: &ActiveEventLoop) {}
fn window_event(
&mut self,
_event_loop: &ActiveEventLoop,
_window_id: winit::window::WindowId,
_event: winit::event::WindowEvent,
) {}
fn about_to_wait(&mut self, event_loop: &ActiveEventLoop) {
event_loop.set_control_flow(ControlFlow::Wait);
while let Ok(menu_event) = self.menu_rx.try_recv() {
if menu_event.id == self.open_id {
let _ = webbrowser::open(&self.ui_url);
}
if menu_event.id == self.exit_id {
let _ = self.rt_handle.block_on(self.shutdown_tx.send(()));
event_loop.exit();
}
}
}
}
}

499
web/app.js Normal file
View File

@ -0,0 +1,499 @@
const state = {
sources: [],
selectedSourceId: null,
tree: [],
selectedNodeIds: new Set(),
logSource: null,
points: new Map(),
pointEls: new Map(),
pointsPage: 1,
pointsPageSize: 100,
pointsTotal: 0,
};
const el = (id) => document.getElementById(id);
const statusText = el('statusText');
const sourceForm = el('sourceForm');
const sourceIdInput = el('sourceId');
const sourceName = el('sourceName');
const sourceEndpoint = el('sourceEndpoint');
const sourceEnabled = el('sourceEnabled');
const sourceSubmit = el('sourceSubmit');
const sourceReset = el('sourceReset');
const sourceList = el('sourceList');
const nodeTree = el('nodeTree');
const browseNodesBtn = el('browseNodes');
const refreshTreeBtn = el('refreshTree');
const createPointsBtn = el('createPoints');
const logView = el('logView');
const pointList = el('pointList');
const pointModal = el('pointModal');
const closeModalBtn = el('closeModal');
const selectedCount = el('selectedCount');
const prevPointsBtn = el('prevPoints');
const nextPointsBtn = el('nextPoints');
const pointsPageInfo = el('pointsPageInfo');
const openSourceFormBtn = el('openSourceForm');
const sourceModal = el('sourceModal');
const closeSourceModalBtn = el('closeSourceModal');
function setStatus(text) {
statusText.textContent = text;
}
async function apiFetch(url, options = {}) {
const res = await fetch(url, {
headers: {
'Content-Type': 'application/json',
},
...options,
});
if (!res.ok) {
const err = await res.text();
throw new Error(err || res.statusText);
}
if (res.status === 204) return null;
return res.json();
}
function renderSources() {
sourceList.innerHTML = '';
state.sources.forEach((source) => {
const item = document.createElement('div');
item.className = 'list-item';
if (state.selectedSourceId === source.id) {
item.classList.add('selected');
}
item.onclick = () => selectSource(source.id);
const statusBadge = document.createElement('span');
statusBadge.className = `badge ${source.is_connected ? '' : 'offline'}`;
statusBadge.textContent = source.is_connected ? '在线' : '离线';
const titleRow = document.createElement('div');
titleRow.className = 'row';
titleRow.innerHTML = `<strong>${source.name}</strong>`;
titleRow.appendChild(statusBadge);
const endpoint = document.createElement('div');
endpoint.className = 'muted';
endpoint.textContent = source.endpoint;
const actionRow = document.createElement('div');
actionRow.className = 'row';
const selectPointsBtn = document.createElement('button');
selectPointsBtn.textContent = '选入 Points';
selectPointsBtn.onclick = (e) => {
e.stopPropagation();
selectSource(source.id).then(() => {
pointModal.classList.remove('hidden');
loadTree();
});
};
const editBtn = document.createElement('button');
editBtn.textContent = '编辑';
editBtn.className = 'secondary';
editBtn.onclick = (e) => {
e.stopPropagation();
fillSourceForm(source);
};
const deleteBtn = document.createElement('button');
deleteBtn.textContent = '删除';
deleteBtn.className = 'danger';
deleteBtn.onclick = (e) => {
e.stopPropagation();
deleteSource(source.id);
};
actionRow.appendChild(selectPointsBtn);
actionRow.appendChild(editBtn);
actionRow.appendChild(deleteBtn);
item.appendChild(titleRow);
item.appendChild(endpoint);
if (source.last_error) {
const err = document.createElement('div');
err.style.color = 'var(--danger)';
err.textContent = source.last_error;
item.appendChild(err);
}
item.appendChild(actionRow);
sourceList.appendChild(item);
});
}
function fillSourceForm(source) {
sourceIdInput.value = source.id;
sourceName.value = source.name || '';
sourceEndpoint.value = source.endpoint || '';
sourceEnabled.checked = !!source.enabled;
sourceSubmit.textContent = '保存';
sourceModal.classList.remove('hidden');
}
function resetSourceForm() {
sourceIdInput.value = '';
sourceName.value = '';
sourceEndpoint.value = '';
sourceEnabled.checked = true;
sourceSubmit.textContent = '保存';
}
async function loadSources() {
setStatus('加载 Source...');
const data = await apiFetch('/api/source');
state.sources = data || [];
renderSources();
setStatus('Ready');
}
async function selectSource(sourceId) {
state.selectedSourceId = sourceId;
state.selectedNodeIds.clear();
state.pointsPage = 1;
renderSelectedNodes();
await loadPoints();
await loadTree();
}
async function loadTree() {
if (!state.selectedSourceId) {
nodeTree.innerHTML = '<div class="muted">请选择 Source</div>';
return;
}
setStatus('加载节点树...');
const data = await apiFetch(`/api/source/${state.selectedSourceId}/node-tree`);
state.tree = data || [];
nodeTree.innerHTML = '';
state.tree.forEach((node) => nodeTree.appendChild(renderNode(node)));
setStatus('Ready');
}
function renderNode(node) {
const details = document.createElement('details');
details.open = false;
const summary = document.createElement('summary');
if (node.children && node.children.length) {
summary.classList.add('has-children');
}
const checkbox = document.createElement('input');
checkbox.type = 'checkbox';
checkbox.checked = state.selectedNodeIds.has(node.id);
checkbox.onchange = () => toggleNode(node, checkbox.checked);
const label = document.createElement('span');
label.className = 'node-label';
label.textContent = `${node.display_name || node.browse_name} (${node.node_class})`;
summary.appendChild(checkbox);
summary.appendChild(label);
details.appendChild(summary);
if (node.children && node.children.length) {
node.children.forEach((child) => details.appendChild(renderNode(child)));
}
return details;
}
function toggleNode(node, checked) {
if (checked) {
state.selectedNodeIds.add(node.id);
} else {
state.selectedNodeIds.delete(node.id);
}
renderSelectedNodes();
}
function renderSelectedNodes() {
const count = state.selectedNodeIds.size;
selectedCount.textContent = `已选 ${count} 个节点`;
}
async function createPoints() {
if (!state.selectedNodeIds.size) return;
setStatus('创建 Points...');
await apiFetch('/api/point/batch', {
method: 'POST',
body: JSON.stringify({ node_ids: Array.from(state.selectedNodeIds) }),
});
setStatus('Points 创建完成');
state.selectedNodeIds.clear();
renderSelectedNodes();
pointModal.classList.add('hidden');
await loadPoints();
}
async function browseNodes() {
if (!state.selectedSourceId) return;
setStatus('浏览节点中...');
await apiFetch(`/api/source/${state.selectedSourceId}/browse`, {
method: 'POST',
});
await loadTree();
}
async function deleteSource(sourceId) {
if (!confirm('确认删除该 Source?')) return;
await apiFetch(`/api/source/${sourceId}`, { method: 'DELETE' });
if (state.selectedSourceId === sourceId) {
state.selectedSourceId = null;
nodeTree.innerHTML = '';
}
await loadSources();
}
async function deletePoint(pointId) {
if (!confirm('确认删除该 Point?')) return;
await apiFetch(`/api/point/${pointId}`, { method: 'DELETE' });
await loadPoints();
}
async function loadPoints() {
setStatus('加载 Points...');
const sourceQuery = state.selectedSourceId ? `&source_id=${state.selectedSourceId}` : '';
const page = state.pointsPage;
const pageSize = state.pointsPageSize;
const data = await apiFetch(`/api/point?page=${page}&page_size=${pageSize}${sourceQuery}`);
const items = data && data.data ? data.data : [];
state.pointsTotal = data && typeof data.total === 'number' ? data.total : items.length;
state.points.clear();
state.pointEls.clear();
pointList.innerHTML = '';
if (!items.length) {
pointList.textContent = '暂无 Points';
pointsPageInfo.textContent = `${state.pointsPage} 页 / 共 1 页`;
prevPointsBtn.disabled = true;
nextPointsBtn.disabled = true;
setStatus('Ready');
return;
}
items.forEach((item) => {
const point = item.point || item;
const monitor = item.point_monitor || null;
state.points.set(point.id, { point, monitor });
const box = document.createElement('div');
box.className = 'list-item';
const row = document.createElement('div');
row.className = 'row';
row.innerHTML = `<strong>${point.name}</strong>`;
const quality = monitor ? (monitor.quality || 'unknown').toLowerCase() : 'unknown';
const qualityBadge = document.createElement('span');
qualityBadge.className = `badge quality-${quality}`;
qualityBadge.textContent = quality.toUpperCase();
row.appendChild(qualityBadge);
const deleteBtn = document.createElement('button');
deleteBtn.className = 'danger';
deleteBtn.textContent = '删除';
deleteBtn.onclick = () => deletePoint(point.id);
row.appendChild(deleteBtn);
const valueRow = document.createElement('div');
valueRow.className = 'row';
const value = document.createElement('div');
value.className = 'value';
value.textContent = formatValue(monitor);
const ts = document.createElement('div');
ts.className = 'muted';
ts.textContent = monitor && monitor.timestamp ? monitor.timestamp : '';
valueRow.appendChild(value);
valueRow.appendChild(ts);
const meta = document.createElement('div');
meta.className = 'muted';
meta.textContent = `${point.id} / node: ${point.node_id}`;
box.appendChild(row);
box.appendChild(valueRow);
box.appendChild(meta);
pointList.appendChild(box);
state.pointEls.set(point.id, { box, value, qualityBadge, ts });
});
const totalPages = Math.max(1, Math.ceil(state.pointsTotal / state.pointsPageSize));
pointsPageInfo.textContent = `${state.pointsPage} 页 / 共 ${totalPages}`;
prevPointsBtn.disabled = state.pointsPage <= 1;
nextPointsBtn.disabled = state.pointsPage >= totalPages;
setStatus('Ready');
}
async function saveSource(event) {
event.preventDefault();
const payload = {
name: sourceName.value.trim(),
endpoint: sourceEndpoint.value.trim(),
enabled: sourceEnabled.checked,
};
if (!payload.name || !payload.endpoint) return;
const id = sourceIdInput.value;
if (id) {
await apiFetch(`/api/source/${id}`, {
method: 'PUT',
body: JSON.stringify(payload),
});
} else {
await apiFetch('/api/source', {
method: 'POST',
body: JSON.stringify(payload),
});
}
resetSourceForm();
sourceModal.classList.add('hidden');
await loadSources();
}
function startLogs() {
if (state.logSource) state.logSource.close();
const es = new EventSource('/api/logs/stream');
state.logSource = es;
es.addEventListener('log', (event) => {
const data = JSON.parse(event.data);
data.lines.forEach((line) => appendLog(line));
});
es.addEventListener('error', () => {
appendLog('[log stream error]');
});
}
function startPointSocket() {
const protocol = location.protocol === 'https:' ? 'wss' : 'ws';
const ws = new WebSocket(`${protocol}://${location.host}/ws/public`);
ws.onmessage = (event) => {
try {
const payload = JSON.parse(event.data);
if (payload.type === 'PointNewValue' || payload.type === 'point_new_value') {
handlePointUpdate(payload.data);
}
} catch {
// ignore
}
};
ws.onclose = () => {
setTimeout(startPointSocket, 2000);
};
}
function handlePointUpdate(data) {
if (!data || !data.point_id) return;
const entry = state.pointEls.get(data.point_id);
if (!entry) return;
const quality = (data.quality || 'unknown').toLowerCase();
entry.qualityBadge.className = `badge quality-${quality}`;
entry.qualityBadge.textContent = quality.toUpperCase();
entry.value.textContent = formatValue(data);
entry.ts.textContent = data.timestamp || '';
}
function formatValue(monitor) {
if (!monitor) return '--';
if (monitor.value_text) return monitor.value_text;
if (monitor.value === null || monitor.value === undefined) return '--';
if (typeof monitor.value === 'string') return monitor.value;
try {
return JSON.stringify(monitor.value);
} catch {
return String(monitor.value);
}
}
function appendLog(line) {
const atBottom = logView.scrollTop + logView.clientHeight >= logView.scrollHeight - 10;
const div = document.createElement('div');
div.className = 'log-line';
const parsed = parseLogLine(line);
if (parsed) {
const levelRaw = (parsed.level || '').toString();
const level = levelRaw.toLowerCase();
if (level) div.classList.add(`level-${level}`);
const levelSpan = document.createElement('span');
levelSpan.className = 'level';
levelSpan.textContent = levelRaw || 'LOG';
const timeSpan = document.createElement('span');
timeSpan.className = 'muted';
timeSpan.textContent = parsed.timestamp ? ` ${parsed.timestamp}` : '';
const targetSpan = document.createElement('span');
targetSpan.className = 'muted';
targetSpan.textContent = parsed.target ? ` ${parsed.target}` : '';
const msgSpan = document.createElement('span');
msgSpan.className = 'message';
msgSpan.textContent =
(parsed.fields && parsed.fields.message) ||
parsed.message ||
parsed.msg ||
line;
div.appendChild(levelSpan);
if (timeSpan.textContent) div.appendChild(timeSpan);
if (targetSpan.textContent) div.appendChild(targetSpan);
div.appendChild(msgSpan);
} else {
div.textContent = line;
}
logView.appendChild(div);
if (atBottom) {
logView.scrollTop = logView.scrollHeight;
}
}
function parseLogLine(line) {
const trimmed = line.trim();
if (!trimmed.startsWith('{') || !trimmed.endsWith('}')) return null;
try {
return JSON.parse(trimmed);
} catch {
return null;
}
}
sourceForm.addEventListener('submit', saveSource);
sourceReset.addEventListener('click', resetSourceForm);
browseNodesBtn.addEventListener('click', browseNodes);
refreshTreeBtn.addEventListener('click', loadTree);
createPointsBtn.addEventListener('click', createPoints);
closeModalBtn.addEventListener('click', () => {
pointModal.classList.add('hidden');
});
openSourceFormBtn.addEventListener('click', () => {
resetSourceForm();
sourceModal.classList.remove('hidden');
});
closeSourceModalBtn.addEventListener('click', () => {
sourceModal.classList.add('hidden');
});
prevPointsBtn.addEventListener('click', () => {
if (state.pointsPage > 1) {
state.pointsPage -= 1;
loadPoints();
}
});
nextPointsBtn.addEventListener('click', () => {
const totalPages = Math.max(1, Math.ceil(state.pointsTotal / state.pointsPageSize));
if (state.pointsPage < totalPages) {
state.pointsPage += 1;
loadPoints();
}
});
loadSources().catch((err) => setStatus(err.message));
loadPoints().catch((err) => setStatus(err.message));
startLogs();
startPointSocket();

88
web/index.html Normal file
View File

@ -0,0 +1,88 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>PLC Control UI</title>
<link rel="stylesheet" href="/ui/styles.css" />
</head>
<body>
<header class="topbar">
<div class="title">PLC Control</div>
<div class="status" id="statusText">Ready</div>
</header>
<main class="grid">
<section class="panel top-left">
<div class="row">
<h2>Sources</h2>
<button id="openSourceForm">新增 Source</button>
</div>
<div class="list" id="sourceList"></div>
</section>
<section class="panel top-right">
<h2>Points</h2>
<div class="list" id="pointList"></div>
<div class="pager">
<button class="secondary" id="prevPoints">上一页</button>
<div class="muted" id="pointsPageInfo">第 1 页</div>
<button class="secondary" id="nextPoints">下一页</button>
</div>
</section>
<section class="panel bottom">
<h2>实时日志</h2>
<div class="log" id="logView"></div>
</section>
</main>
<div class="modal hidden" id="pointModal">
<div class="modal-content">
<div class="row">
<h3>选择节点创建 Points</h3>
<button class="secondary" id="closeModal">关闭</button>
</div>
<div class="toolbar">
<button id="browseNodes">浏览并同步节点</button>
<button id="refreshTree">刷新树</button>
</div>
<div class="tree" id="nodeTree"></div>
<div class="actions">
<div class="muted" id="selectedCount">已选 0 个节点</div>
<button id="createPoints">创建 Points</button>
</div>
</div>
</div>
<div class="modal hidden" id="sourceModal">
<div class="modal-content">
<div class="row">
<h3>Source 配置</h3>
<button class="secondary" id="closeSourceModal">关闭</button>
</div>
<form id="sourceForm" class="form">
<input type="hidden" id="sourceId" />
<label>
名称
<input id="sourceName" required />
</label>
<label>
Endpoint
<input id="sourceEndpoint" placeholder="opc.tcp://host:port" required />
</label>
<label class="row">
<input type="checkbox" id="sourceEnabled" checked />
<span>启用</span>
</label>
<div class="actions">
<button type="submit" id="sourceSubmit">保存</button>
<button type="button" id="sourceReset">清空</button>
</div>
</form>
</div>
</div>
<script src="/ui/app.js"></script>
</body>
</html>

354
web/styles.css Normal file
View File

@ -0,0 +1,354 @@
:root {
--bg: #f5f7fb;
--panel: #ffffff;
--panel-2: #f0f3f8;
--text: #1f2a37;
--muted: #6b7a90;
--accent: #1f6feb;
--accent-2: #22c55e;
--danger: #ef4444;
--border: #d5dbe6;
--shadow: rgba(12, 22, 34, 0.08);
}
* {
box-sizing: border-box;
}
body {
margin: 0;
font-family: "Segoe UI", "Microsoft YaHei", sans-serif;
color: var(--text);
background: radial-gradient(circle at top, #ffffff 0%, #eef2f8 55%);
font-size: 12px;
min-height: 100vh;
}
.topbar {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px 24px;
border-bottom: 1px solid var(--border);
background: linear-gradient(90deg, #ffffff, #f2f5fa);
position: sticky;
top: 0;
z-index: 10;
}
.title {
font-size: 16px;
font-weight: 600;
letter-spacing: 0.5px;
}
.status {
font-size: 11px;
color: var(--muted);
}
.grid {
display: grid;
grid-template-columns: 2fr 3fr;
grid-template-rows: auto auto;
gap: 16px;
padding: 16px;
}
.panel.top-left {
grid-column: 1 / 2;
grid-row: 1 / 2;
}
.panel.top-right {
grid-column: 2 / 3;
grid-row: 1 / 2;
}
.panel.bottom {
grid-column: 1 / -1;
grid-row: 2 / 3;
}
.panel {
background: var(--panel);
border: 1px solid var(--border);
border-radius: 12px;
padding: 16px;
box-shadow: 0 10px 30px var(--shadow);
display: flex;
flex-direction: column;
gap: 12px;
height: 40vh;
overflow: hidden;
}
h2, h3 {
margin: 0;
font-weight: 600;
}
.form label {
display: flex;
flex-direction: column;
gap: 6px;
font-size: 11px;
color: var(--muted);
}
.form input[type="text"],
.form input[type="url"],
.form input[type="password"],
.form input[type="number"],
.form input:not([type]) {
padding: 8px 10px;
border-radius: 8px;
border: 1px solid var(--border);
background: var(--panel-2);
color: var(--text);
}
.form .row {
flex-direction: row;
align-items: center;
gap: 8px;
}
.actions {
display: flex;
gap: 8px;
align-items: center;
justify-content: space-between;
}
button {
background: var(--accent);
border: none;
color: #ffffff;
padding: 6px 10px;
border-radius: 8px;
cursor: pointer;
font-weight: 600;
}
button.secondary {
background: transparent;
color: var(--muted);
border: 1px solid var(--border);
}
button.danger {
background: var(--danger);
color: #ffffff;
}
.list {
display: flex;
flex-direction: column;
gap: 8px;
overflow-y: auto;
max-height: none;
flex: 1 1 auto;
}
.list-item {
padding: 8px;
border: 1px solid var(--border);
border-radius: 10px;
background: var(--panel-2);
display: flex;
flex-direction: column;
gap: 6px;
cursor: pointer;
}
.list-item.selected {
border-color: var(--accent);
box-shadow: 0 0 0 1px rgba(31, 111, 235, 0.2);
}
.list-item .row {
display: flex;
justify-content: space-between;
align-items: center;
gap: 8px;
}
.muted {
color: var(--muted);
font-size: 11px;
}
.badge {
font-size: 11px;
padding: 2px 8px;
border-radius: 999px;
background: rgba(31, 111, 235, 0.14);
color: var(--accent);
}
.badge.offline {
background: rgba(239, 68, 68, 0.14);
color: var(--danger);
}
.badge.quality-good { background: rgba(34, 197, 94, 0.16); color: #16a34a; }
.badge.quality-bad { background: rgba(239, 68, 68, 0.16); color: #dc2626; }
.badge.quality-uncertain { background: rgba(245, 158, 11, 0.16); color: #d97706; }
.badge.quality-unknown { background: rgba(148, 163, 184, 0.2); color: #64748b; }
.value {
font-weight: 600;
font-size: 11px;
}
.toolbar {
display: flex;
gap: 8px;
}
.pager {
display: flex;
align-items: center;
gap: 8px;
justify-content: center;
font-size: 11px;
}
.tree {
overflow-y: auto;
padding-right: 8px;
}
.tree details {
margin-left: 10px;
}
.tree summary {
list-style: none;
cursor: pointer;
display: flex;
align-items: center;
gap: 6px;
}
.tree summary.has-children::before {
content: "▸";
color: var(--muted);
margin-right: 4px;
}
.tree details[open] > summary.has-children::before {
content: "▾";
}
.tree summary::-webkit-details-marker {
display: none;
}
.tree .node-label {
color: var(--text);
}
.subpanel {
background: var(--panel-2);
border: 1px solid var(--border);
border-radius: 10px;
padding: 12px;
display: flex;
flex-direction: column;
gap: 8px;
}
.log {
background: #0b1117;
border-radius: 8px;
border: 1px solid var(--border);
padding: 8px;
font-family: "JetBrains Mono", "Consolas", monospace;
font-size: 11px;
color: #c9d7e8;
max-height: none;
overflow-y: auto;
flex: 1 1 auto;
display: flex;
flex-direction: column;
gap: 4px;
}
.log-line {
white-space: pre-wrap;
word-break: break-word;
}
.log-line .level {
font-weight: 700;
margin-right: 6px;
}
.log-line .message {
margin-left: 8px;
}
.log-line .muted {
margin-left: 6px;
}
.log-line.level-trace .level { color: #94a3b8; }
.log-line.level-debug .level { color: #38bdf8; }
.log-line.level-info .level { color: #22c55e; }
.log-line.level-warn .level { color: #f59e0b; }
.log-line.level-error .level { color: #ef4444; }
.modal {
position: fixed;
inset: 0;
background: rgba(5, 11, 20, 0.4);
display: flex;
align-items: center;
justify-content: center;
padding: 16px;
z-index: 50;
}
.modal.hidden {
display: none;
}
.modal-content {
width: min(920px, 96vw);
max-height: 90vh;
overflow: hidden;
background: var(--panel);
border-radius: 16px;
border: 1px solid var(--border);
padding: 16px;
box-shadow: 0 20px 60px var(--shadow);
display: flex;
flex-direction: column;
gap: 12px;
}
.modal-content .tree {
flex: 1;
border: 1px solid var(--border);
border-radius: 10px;
padding: 8px;
background: var(--panel-2);
max-height: 55vh;
}
@media (max-width: 1200px) {
.grid {
grid-template-columns: 1fr;
}
.panel {
height: auto;
}
.list {
max-height: none;
}
.log {
max-height: none;
}
}