feat(web): add inline point chart panel
This commit is contained in:
parent
bf548161a6
commit
920e37f759
|
|
@ -309,6 +309,21 @@ impl ConnectionManager {
|
|||
self.point_monitor_data.read().await
|
||||
}
|
||||
|
||||
pub async fn get_point_history(&self, point_id: Uuid, limit: usize) -> Vec<PointMonitorInfo> {
|
||||
if limit == 0 {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
let history_data = self.point_history_data.read().await;
|
||||
history_data
|
||||
.get(&point_id)
|
||||
.map(|ring| {
|
||||
let skip = ring.len().saturating_sub(limit);
|
||||
ring.iter().skip(skip).cloned().collect()
|
||||
})
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
async fn start_heartbeat_task(&self, source_id: Uuid) {
|
||||
let manager = self.clone();
|
||||
|
||||
|
|
|
|||
|
|
@ -27,6 +27,21 @@ pub struct PointWithMonitor {
|
|||
pub point_monitor: Option<crate::telemetry::PointMonitorInfo>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Validate)]
|
||||
pub struct GetPointHistoryQuery {
|
||||
pub limit: Option<usize>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct PointHistoryItem {
|
||||
#[serde(serialize_with = "crate::util::datetime::option_utc_to_local_str")]
|
||||
pub timestamp: Option<chrono::DateTime<chrono::Utc>>,
|
||||
pub quality: crate::telemetry::PointQuality,
|
||||
pub value: Option<crate::telemetry::DataValue>,
|
||||
pub value_text: Option<String>,
|
||||
pub value_number: Option<f64>,
|
||||
}
|
||||
|
||||
pub async fn get_point_list(
|
||||
State(state): State<AppState>,
|
||||
Query(query): Query<GetPointListQuery>,
|
||||
|
|
@ -75,6 +90,40 @@ pub async fn get_point(
|
|||
Ok(Json(point))
|
||||
}
|
||||
|
||||
pub async fn get_point_history(
|
||||
State(state): State<AppState>,
|
||||
Path(point_id): Path<Uuid>,
|
||||
Query(query): Query<GetPointHistoryQuery>,
|
||||
) -> Result<impl IntoResponse, ApiErr> {
|
||||
let pool = &state.pool;
|
||||
let point = crate::service::get_point_by_id(pool, point_id).await?;
|
||||
if point.is_none() {
|
||||
return Err(ApiErr::NotFound("Point not found".to_string(), None));
|
||||
}
|
||||
|
||||
let limit = query.limit.unwrap_or(120).clamp(1, 1000);
|
||||
let history = state
|
||||
.connection_manager
|
||||
.get_point_history(point_id, limit)
|
||||
.await;
|
||||
|
||||
let items: Vec<PointHistoryItem> = history
|
||||
.into_iter()
|
||||
.map(|item| {
|
||||
let value_number = monitor_value_to_number(&item);
|
||||
PointHistoryItem {
|
||||
timestamp: item.timestamp,
|
||||
quality: item.quality,
|
||||
value_number,
|
||||
value: item.value,
|
||||
value_text: item.value_text,
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(Json(items))
|
||||
}
|
||||
|
||||
|
||||
/// Request payload for updating editable point fields.
|
||||
#[derive(Deserialize, Validate)]
|
||||
|
|
@ -463,3 +512,14 @@ pub async fn batch_set_point_value(
|
|||
.map_err(|e| ApiErr::Internal(e, None))?;
|
||||
Ok(Json(result))
|
||||
}
|
||||
|
||||
fn monitor_value_to_number(item: &crate::telemetry::PointMonitorInfo) -> Option<f64> {
|
||||
match item.value.as_ref()? {
|
||||
crate::telemetry::DataValue::Int(v) => Some(*v as f64),
|
||||
crate::telemetry::DataValue::UInt(v) => Some(*v as f64),
|
||||
crate::telemetry::DataValue::Float(v) => Some(*v),
|
||||
crate::telemetry::DataValue::Bool(v) => Some(if *v { 1.0 } else { 0.0 }),
|
||||
crate::telemetry::DataValue::Text(v) => v.parse::<f64>().ok(),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -139,6 +139,7 @@ fn build_router(state: AppState) -> Router {
|
|||
axum::routing::post(handler::point::batch_create_points)
|
||||
.delete(handler::point::batch_delete_points),
|
||||
)
|
||||
.route("/api/point/{point_id}/history", get(handler::point::get_point_history))
|
||||
.route("/api/point/{point_id}", get(handler::point::get_point).put(handler::point::update_point).delete(handler::point::delete_point))
|
||||
.route("/api/point/batch/set-tags", put(handler::point::batch_set_point_tags))
|
||||
.route("/api/tag", get(handler::tag::get_tag_list).post(handler::tag::create_tag))
|
||||
|
|
|
|||
239
web/app.js
239
web/app.js
|
|
@ -9,6 +9,9 @@ const state = {
|
|||
pointsPage: 1,
|
||||
pointsPageSize: 100,
|
||||
pointsTotal: 0,
|
||||
chartPointId: null,
|
||||
chartPointName: '',
|
||||
chartData: [],
|
||||
};
|
||||
|
||||
const el = (id) => document.getElementById(id);
|
||||
|
|
@ -36,6 +39,10 @@ const pointsPageInfo = el('pointsPageInfo');
|
|||
const openSourceFormBtn = el('openSourceForm');
|
||||
const sourceModal = el('sourceModal');
|
||||
const closeSourceModalBtn = el('closeSourceModal');
|
||||
const chartCanvas = el('chartCanvas');
|
||||
const chartTitle = el('chartTitle');
|
||||
const chartSummary = el('chartSummary');
|
||||
const refreshChartBtn = el('refreshChart');
|
||||
|
||||
function setStatus(text) {
|
||||
statusText.textContent = text;
|
||||
|
|
@ -64,7 +71,9 @@ function renderSources() {
|
|||
if (state.selectedSourceId === source.id) {
|
||||
item.classList.add('selected');
|
||||
}
|
||||
item.onclick = () => selectSource(source.id);
|
||||
item.onclick = () => {
|
||||
selectSource(source.id).catch((err) => setStatus(err.message));
|
||||
};
|
||||
|
||||
const statusBadge = document.createElement('span');
|
||||
statusBadge.className = `badge ${source.is_connected ? '' : 'offline'}`;
|
||||
|
|
@ -86,10 +95,12 @@ function renderSources() {
|
|||
selectPointsBtn.textContent = '选入 Points';
|
||||
selectPointsBtn.onclick = (e) => {
|
||||
e.stopPropagation();
|
||||
selectSource(source.id).then(() => {
|
||||
selectSource(source.id)
|
||||
.then(() => {
|
||||
pointModal.classList.remove('hidden');
|
||||
loadTree();
|
||||
});
|
||||
return loadTree();
|
||||
})
|
||||
.catch((err) => setStatus(err.message));
|
||||
};
|
||||
|
||||
const editBtn = document.createElement('button');
|
||||
|
|
@ -105,7 +116,11 @@ function renderSources() {
|
|||
reconnectBtn.className = 'secondary';
|
||||
reconnectBtn.onclick = async (e) => {
|
||||
e.stopPropagation();
|
||||
try {
|
||||
await reconnectSource(source.id, source.name);
|
||||
} catch (err) {
|
||||
setStatus(err.message);
|
||||
}
|
||||
};
|
||||
|
||||
const deleteBtn = document.createElement('button');
|
||||
|
|
@ -113,7 +128,7 @@ function renderSources() {
|
|||
deleteBtn.className = 'danger';
|
||||
deleteBtn.onclick = (e) => {
|
||||
e.stopPropagation();
|
||||
deleteSource(source.id);
|
||||
deleteSource(source.id).catch((err) => setStatus(err.message));
|
||||
};
|
||||
|
||||
actionRow.appendChild(selectPointsBtn);
|
||||
|
|
@ -153,7 +168,7 @@ function resetSourceForm() {
|
|||
}
|
||||
|
||||
async function loadSources() {
|
||||
setStatus('加载 Source...');
|
||||
setStatus('加载数据源...');
|
||||
const data = await apiFetch('/api/source');
|
||||
state.sources = data || [];
|
||||
renderSources();
|
||||
|
|
@ -172,7 +187,7 @@ async function selectSource(sourceId) {
|
|||
|
||||
async function loadTree() {
|
||||
if (!state.selectedSourceId) {
|
||||
nodeTree.innerHTML = '<div class="muted">请选择 Source</div>';
|
||||
nodeTree.innerHTML = '<div class="muted">请选择数据源</div>';
|
||||
return;
|
||||
}
|
||||
setStatus('加载节点树...');
|
||||
|
|
@ -191,6 +206,7 @@ function renderNode(node) {
|
|||
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);
|
||||
|
|
@ -221,8 +237,7 @@ function toggleNode(node, checked) {
|
|||
}
|
||||
|
||||
function renderSelectedNodes() {
|
||||
const count = state.selectedNodeIds.size;
|
||||
selectedCount.textContent = `已选 ${count} 个节点`;
|
||||
selectedCount.textContent = `已选中 ${state.selectedNodeIds.size} 个节点`;
|
||||
}
|
||||
|
||||
async function createPoints() {
|
||||
|
|
@ -268,6 +283,21 @@ async function reconnectSource(sourceId, sourceName) {
|
|||
setStatus(`${sourceName || 'Source'} 重连完成`);
|
||||
}
|
||||
|
||||
async function openChart(pointId, pointName) {
|
||||
state.chartPointId = pointId;
|
||||
state.chartPointName = pointName || '测点';
|
||||
chartTitle.textContent = `${state.chartPointName} 曲线`;
|
||||
chartSummary.textContent = '加载中...';
|
||||
await loadPointHistory(pointId);
|
||||
}
|
||||
|
||||
async function loadPointHistory(pointId = state.chartPointId) {
|
||||
if (!pointId) return;
|
||||
const items = await apiFetch(`/api/point/${pointId}/history?limit=120`);
|
||||
state.chartData = (items || []).map(normalizeChartItem).filter(Boolean);
|
||||
renderChart();
|
||||
}
|
||||
|
||||
async function deletePoint(pointId) {
|
||||
if (!confirm('确认删除该 Point?')) return;
|
||||
await apiFetch(`/api/point/${pointId}`, { method: 'DELETE' });
|
||||
|
|
@ -281,10 +311,12 @@ async function loadPoints() {
|
|||
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.innerHTML = '<tr><td colspan="5" class="empty-state">暂无 Points</td></tr>';
|
||||
pointsPageInfo.textContent = `${state.pointsPage} / 1`;
|
||||
|
|
@ -293,12 +325,19 @@ async function loadPoints() {
|
|||
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 tr = document.createElement('tr');
|
||||
if (state.chartPointId === point.id) {
|
||||
tr.classList.add('active');
|
||||
}
|
||||
tr.onclick = () => {
|
||||
openChart(point.id, point.name).catch((err) => setStatus(err.message));
|
||||
};
|
||||
|
||||
const tdName = document.createElement('td');
|
||||
tdName.innerHTML = `<div class="point-name">${point.name}</div><div class="point-id">${point.node_id}</div>`;
|
||||
|
|
@ -323,19 +362,24 @@ async function loadPoints() {
|
|||
tdTime.appendChild(ts);
|
||||
|
||||
const tdAction = document.createElement('td');
|
||||
|
||||
const deleteBtn = document.createElement('button');
|
||||
deleteBtn.className = 'danger';
|
||||
deleteBtn.textContent = '×';
|
||||
deleteBtn.title = '删除';
|
||||
deleteBtn.style.cssText = 'width:22px;height:22px;padding:0;font-size:14px;';
|
||||
deleteBtn.onclick = (e) => { e.stopPropagation(); deletePoint(point.id); };
|
||||
tdAction.appendChild(deleteBtn);
|
||||
deleteBtn.onclick = (e) => {
|
||||
e.stopPropagation();
|
||||
deletePoint(point.id).catch((err) => setStatus(err.message));
|
||||
};
|
||||
|
||||
tdAction.append(deleteBtn);
|
||||
tr.append(tdName, tdValue, tdQuality, tdTime, tdAction);
|
||||
pointList.appendChild(tr);
|
||||
|
||||
state.pointEls.set(point.id, { box: tr, value, qualityBadge, ts });
|
||||
});
|
||||
|
||||
const totalPages = Math.max(1, Math.ceil(state.pointsTotal / state.pointsPageSize));
|
||||
pointsPageInfo.textContent = `${state.pointsPage} / ${totalPages}`;
|
||||
prevPointsBtn.disabled = state.pointsPage <= 1;
|
||||
|
|
@ -350,6 +394,7 @@ async function saveSource(event) {
|
|||
endpoint: sourceEndpoint.value.trim(),
|
||||
enabled: sourceEnabled.checked,
|
||||
};
|
||||
|
||||
if (!payload.name || !payload.endpoint) return;
|
||||
|
||||
const id = sourceIdInput.value;
|
||||
|
|
@ -394,7 +439,7 @@ function startPointSocket() {
|
|||
handlePointUpdate(payload.data);
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
// ignore invalid payloads
|
||||
}
|
||||
};
|
||||
ws.onclose = () => {
|
||||
|
|
@ -404,14 +449,19 @@ function startPointSocket() {
|
|||
|
||||
function handlePointUpdate(data) {
|
||||
if (!data || !data.point_id) return;
|
||||
const entry = state.pointEls.get(data.point_id);
|
||||
if (!entry) return;
|
||||
|
||||
const entry = state.pointEls.get(data.point_id);
|
||||
if (entry) {
|
||||
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 || '';
|
||||
}
|
||||
|
||||
if (state.chartPointId === data.point_id) {
|
||||
appendChartPoint(data);
|
||||
}
|
||||
}
|
||||
|
||||
function formatValue(monitor) {
|
||||
|
|
@ -481,11 +531,156 @@ function parseLogLine(line) {
|
|||
}
|
||||
}
|
||||
|
||||
sourceForm.addEventListener('submit', saveSource);
|
||||
function normalizeChartItem(item) {
|
||||
if (!item) return null;
|
||||
return {
|
||||
timestamp: item.timestamp || '',
|
||||
quality: (item.quality || 'unknown').toLowerCase(),
|
||||
valueText: item.value_text || formatValue(item),
|
||||
valueNumber: getNumericValue(item),
|
||||
};
|
||||
}
|
||||
|
||||
function getNumericValue(item) {
|
||||
if (typeof item.value_number === 'number' && Number.isFinite(item.value_number)) {
|
||||
return item.value_number;
|
||||
}
|
||||
if (typeof item.value === 'number' && Number.isFinite(item.value)) {
|
||||
return item.value;
|
||||
}
|
||||
if (typeof item.value === 'boolean') {
|
||||
return item.value ? 1 : 0;
|
||||
}
|
||||
if (typeof item.value_text === 'string') {
|
||||
const parsed = Number(item.value_text);
|
||||
if (Number.isFinite(parsed)) return parsed;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function appendChartPoint(item) {
|
||||
const normalized = normalizeChartItem(item);
|
||||
if (!normalized) return;
|
||||
|
||||
const last = state.chartData[state.chartData.length - 1];
|
||||
if (last && last.timestamp === normalized.timestamp && last.valueText === normalized.valueText) {
|
||||
return;
|
||||
}
|
||||
|
||||
state.chartData.push(normalized);
|
||||
if (state.chartData.length > 120) {
|
||||
state.chartData = state.chartData.slice(-120);
|
||||
}
|
||||
renderChart();
|
||||
}
|
||||
|
||||
function renderChart() {
|
||||
const ctx = chartCanvas.getContext('2d');
|
||||
const width = chartCanvas.width;
|
||||
const height = chartCanvas.height;
|
||||
ctx.clearRect(0, 0, width, height);
|
||||
|
||||
if (!state.chartPointId) {
|
||||
ctx.fillStyle = '#94a3b8';
|
||||
ctx.font = '14px Segoe UI';
|
||||
ctx.fillText('Click a point row to view its chart', 24, 40);
|
||||
chartSummary.textContent = '点击上方点位表中的某一行查看曲线';
|
||||
return;
|
||||
}
|
||||
|
||||
const numericPoints = state.chartData.filter((item) => typeof item.valueNumber === 'number');
|
||||
if (!numericPoints.length) {
|
||||
ctx.fillStyle = '#94a3b8';
|
||||
ctx.font = '14px Segoe UI';
|
||||
ctx.fillText('No numeric history available for charting', 24, 40);
|
||||
chartSummary.textContent = state.chartData.length
|
||||
? `Recent ${state.chartData.length} samples are non-numeric`
|
||||
: 'No history data';
|
||||
return;
|
||||
}
|
||||
|
||||
const padding = { top: 20, right: 20, bottom: 36, left: 52 };
|
||||
const plotWidth = width - padding.left - padding.right;
|
||||
const plotHeight = height - padding.top - padding.bottom;
|
||||
const values = numericPoints.map((item) => item.valueNumber);
|
||||
let min = Math.min(...values);
|
||||
let max = Math.max(...values);
|
||||
if (min === max) {
|
||||
const delta = min === 0 ? 1 : Math.abs(min) * 0.1;
|
||||
min -= delta;
|
||||
max += delta;
|
||||
}
|
||||
|
||||
ctx.strokeStyle = '#cbd5e1';
|
||||
ctx.lineWidth = 1;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(padding.left, padding.top);
|
||||
ctx.lineTo(padding.left, height - padding.bottom);
|
||||
ctx.lineTo(width - padding.right, height - padding.bottom);
|
||||
ctx.stroke();
|
||||
|
||||
ctx.fillStyle = '#64748b';
|
||||
ctx.font = '12px Segoe UI';
|
||||
ctx.fillText(max.toFixed(2), 8, padding.top + 4);
|
||||
ctx.fillText(min.toFixed(2), 8, height - padding.bottom);
|
||||
|
||||
for (let i = 0; i <= 4; i += 1) {
|
||||
const y = padding.top + (plotHeight / 4) * i;
|
||||
ctx.strokeStyle = '#e2e8f0';
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(padding.left, y);
|
||||
ctx.lineTo(width - padding.right, y);
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
ctx.strokeStyle = '#2563eb';
|
||||
ctx.lineWidth = 2;
|
||||
ctx.beginPath();
|
||||
numericPoints.forEach((item, index) => {
|
||||
const x = padding.left + (plotWidth * index) / Math.max(1, numericPoints.length - 1);
|
||||
const y = padding.top + ((max - item.valueNumber) / (max - min)) * plotHeight;
|
||||
if (index === 0) {
|
||||
ctx.moveTo(x, y);
|
||||
} else {
|
||||
ctx.lineTo(x, y);
|
||||
}
|
||||
});
|
||||
ctx.stroke();
|
||||
|
||||
ctx.fillStyle = '#2563eb';
|
||||
numericPoints.forEach((item, index) => {
|
||||
const x = padding.left + (plotWidth * index) / Math.max(1, numericPoints.length - 1);
|
||||
const y = padding.top + ((max - item.valueNumber) / (max - min)) * plotHeight;
|
||||
ctx.beginPath();
|
||||
ctx.arc(x, y, 2.5, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
});
|
||||
|
||||
const firstLabel = numericPoints[0].timestamp || '--';
|
||||
const lastLabel = numericPoints[numericPoints.length - 1].timestamp || '--';
|
||||
ctx.fillStyle = '#64748b';
|
||||
ctx.font = '11px Segoe UI';
|
||||
ctx.fillText(firstLabel, padding.left, height - 12);
|
||||
const lastWidth = ctx.measureText(lastLabel).width;
|
||||
ctx.fillText(lastLabel, width - padding.right - lastWidth, height - 12);
|
||||
|
||||
const latest = numericPoints[numericPoints.length - 1];
|
||||
chartSummary.textContent = `Latest ${numericPoints.length} points, current value ${latest.valueText || latest.valueNumber}`;
|
||||
}
|
||||
|
||||
sourceForm.addEventListener('submit', (event) => {
|
||||
saveSource(event).catch((err) => setStatus(err.message));
|
||||
});
|
||||
sourceReset.addEventListener('click', resetSourceForm);
|
||||
browseNodesBtn.addEventListener('click', browseNodes);
|
||||
refreshTreeBtn.addEventListener('click', loadTree);
|
||||
createPointsBtn.addEventListener('click', createPoints);
|
||||
browseNodesBtn.addEventListener('click', () => {
|
||||
browseNodes().catch((err) => setStatus(err.message));
|
||||
});
|
||||
refreshTreeBtn.addEventListener('click', () => {
|
||||
loadTree().catch((err) => setStatus(err.message));
|
||||
});
|
||||
createPointsBtn.addEventListener('click', () => {
|
||||
createPoints().catch((err) => setStatus(err.message));
|
||||
});
|
||||
closeModalBtn.addEventListener('click', () => {
|
||||
pointModal.classList.add('hidden');
|
||||
});
|
||||
|
|
@ -496,21 +691,25 @@ openSourceFormBtn.addEventListener('click', () => {
|
|||
closeSourceModalBtn.addEventListener('click', () => {
|
||||
sourceModal.classList.add('hidden');
|
||||
});
|
||||
refreshChartBtn.addEventListener('click', () => {
|
||||
loadPointHistory().catch((err) => setStatus(err.message));
|
||||
});
|
||||
prevPointsBtn.addEventListener('click', () => {
|
||||
if (state.pointsPage > 1) {
|
||||
state.pointsPage -= 1;
|
||||
loadPoints();
|
||||
loadPoints().catch((err) => setStatus(err.message));
|
||||
}
|
||||
});
|
||||
nextPointsBtn.addEventListener('click', () => {
|
||||
const totalPages = Math.max(1, Math.ceil(state.pointsTotal / state.pointsPageSize));
|
||||
if (state.pointsPage < totalPages) {
|
||||
state.pointsPage += 1;
|
||||
loadPoints();
|
||||
loadPoints().catch((err) => setStatus(err.message));
|
||||
}
|
||||
});
|
||||
|
||||
loadSources().catch((err) => setStatus(err.message));
|
||||
loadPoints().catch((err) => setStatus(err.message));
|
||||
renderChart();
|
||||
startLogs();
|
||||
startPointSocket();
|
||||
|
|
|
|||
|
|
@ -46,19 +46,30 @@
|
|||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel bottom">
|
||||
<section class="panel bottom-left">
|
||||
<div class="panel-head">
|
||||
<h2>实时日志</h2>
|
||||
</div>
|
||||
<div class="log" id="logView"></div>
|
||||
</section>
|
||||
|
||||
<section class="panel bottom-right">
|
||||
<div class="panel-head">
|
||||
<h2 id="chartTitle">测点曲线</h2>
|
||||
<button class="secondary" id="refreshChart">刷新</button>
|
||||
</div>
|
||||
<div class="chart-panel">
|
||||
<div class="muted" id="chartSummary">点击上方点位表中的某一行查看曲线</div>
|
||||
<canvas id="chartCanvas" class="chart-canvas" width="820" height="320"></canvas>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<div class="modal hidden" id="pointModal">
|
||||
<div class="modal-content">
|
||||
<div class="modal-head">
|
||||
<h3>选择节点创建 Points</h3>
|
||||
<button class="secondary" id="closeModal">✕</button>
|
||||
<button class="secondary" id="closeModal">×</button>
|
||||
</div>
|
||||
<div class="toolbar">
|
||||
<button id="browseNodes">浏览并同步节点</button>
|
||||
|
|
@ -66,7 +77,7 @@
|
|||
</div>
|
||||
<div class="tree" id="nodeTree"></div>
|
||||
<div class="modal-foot">
|
||||
<div class="muted" id="selectedCount">已选 0 个节点</div>
|
||||
<div class="muted" id="selectedCount">已选中 0 个节点</div>
|
||||
<button id="createPoints">创建 Points</button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -76,7 +87,7 @@
|
|||
<div class="modal-content modal-sm">
|
||||
<div class="modal-head">
|
||||
<h3>Source 配置</h3>
|
||||
<button class="secondary" id="closeSourceModal">✕</button>
|
||||
<button class="secondary" id="closeSourceModal">×</button>
|
||||
</div>
|
||||
<form id="sourceForm" class="form">
|
||||
<input type="hidden" id="sourceId" />
|
||||
|
|
|
|||
|
|
@ -57,17 +57,23 @@ body {
|
|||
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: 340px 1fr;
|
||||
grid-template-rows: 1fr 1fr;
|
||||
grid-template-columns: 340px minmax(0, 3fr) minmax(0, 2fr);
|
||||
grid-template-rows: 1fr 380px;
|
||||
gap: 1px;
|
||||
height: calc(100vh - var(--topbar-h));
|
||||
}
|
||||
|
||||
.panel.top-left { grid-column: 1; grid-row: 1; }
|
||||
.panel.top-right { grid-column: 2; grid-row: 1; }
|
||||
.panel.top-right { grid-column: 2 / 4; grid-row: 1; }
|
||||
|
||||
.panel.bottom {
|
||||
grid-column: 1 / -1;
|
||||
.panel.bottom-left {
|
||||
grid-column: 1 / 3;
|
||||
grid-row: 2;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.panel.bottom-right {
|
||||
grid-column: 3;
|
||||
grid-row: 2;
|
||||
min-height: 0;
|
||||
}
|
||||
|
|
@ -265,6 +271,10 @@ button.danger:hover { background: var(--danger-hover); }
|
|||
background: var(--accent-bg);
|
||||
}
|
||||
|
||||
.data-table tbody tr.active {
|
||||
background: rgba(37, 99, 235, 0.1);
|
||||
}
|
||||
|
||||
.point-name {
|
||||
font-weight: 500;
|
||||
color: var(--text);
|
||||
|
|
@ -496,6 +506,33 @@ button.danger:hover { background: var(--danger-hover); }
|
|||
align-items: center;
|
||||
}
|
||||
|
||||
.chart-meta {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.chart-panel {
|
||||
display: flex;
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
padding: 8px 10px 10px;
|
||||
}
|
||||
|
||||
.chart-canvas {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
min-height: 320px;
|
||||
flex: 1 1 auto;
|
||||
border: 1px solid var(--border);
|
||||
background:
|
||||
linear-gradient(to bottom, rgba(37, 99, 235, 0.03), rgba(37, 99, 235, 0)),
|
||||
var(--surface);
|
||||
}
|
||||
|
||||
.modal-content .tree {
|
||||
flex: 1;
|
||||
border: 1px solid var(--border);
|
||||
|
|
@ -517,11 +554,12 @@ button.danger:hover { background: var(--danger-hover); }
|
|||
@media (max-width: 900px) {
|
||||
.grid {
|
||||
grid-template-columns: 1fr;
|
||||
grid-template-rows: auto 1fr auto;
|
||||
grid-template-rows: auto 1fr auto auto;
|
||||
height: auto;
|
||||
}
|
||||
body { height: auto; overflow: auto; }
|
||||
.panel.top-left { min-height: 200px; }
|
||||
.panel.top-right { min-height: 300px; }
|
||||
.panel.bottom { max-height: none; min-height: 200px; }
|
||||
.panel.bottom-left { grid-column: 1; grid-row: 3; min-height: 200px; }
|
||||
.panel.bottom-right { grid-column: 1; grid-row: 4; min-height: 320px; }
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue