This commit is contained in:
@@ -32,6 +32,7 @@ export const writePointApi = {
|
||||
};
|
||||
export const historyApi = {
|
||||
tree: () => request.get("/history/tree"),
|
||||
cleanupArchives: () => request.post("/history/archive/cleanup"),
|
||||
query: (data: unknown) => request.post("/history/query", data),
|
||||
queryTable: (data: unknown) => request.post("/history/query-table", data),
|
||||
};
|
||||
|
||||
@@ -2,64 +2,404 @@
|
||||
import * as echarts from "echarts";
|
||||
import { computed, onBeforeUnmount, onMounted, ref, watch } from "vue";
|
||||
import { buildSegmentedAxis, mapValue, unmapValue } from "./segmented-axis";
|
||||
const props = defineProps<{ series: any[]; segmented: boolean }>();
|
||||
|
||||
const props = defineProps<{
|
||||
series: any[];
|
||||
segmented: boolean;
|
||||
startTime: number;
|
||||
endTime: number;
|
||||
}>();
|
||||
const emit = defineEmits<{ cursor: [any[]] }>();
|
||||
const el = ref<HTMLDivElement>();
|
||||
let chart: echarts.ECharts | undefined, observer: ResizeObserver | undefined;
|
||||
let chart: echarts.ECharts | undefined;
|
||||
let observer: ResizeObserver | undefined;
|
||||
let currentNames: string[] = [];
|
||||
let visibleNames: Record<string, boolean> = {};
|
||||
let lastCursorTime: number | null = null;
|
||||
let cursorFrame: number | undefined;
|
||||
const gridTop = 58;
|
||||
const gridBottom = 52;
|
||||
|
||||
const segments = computed(() =>
|
||||
buildSegmentedAxis(
|
||||
props.series.flatMap((s) =>
|
||||
s.data.filter((d: any) => d.value !== null).map((d: any) => d.value),
|
||||
(s.data ?? [])
|
||||
.filter((d: any) => d.value !== null && d.value !== undefined)
|
||||
.map((d: any) => d.value),
|
||||
),
|
||||
),
|
||||
);
|
||||
function render() {
|
||||
const range = computed(() => Math.max(1, props.endTime - props.startTime || 1));
|
||||
const colors = [
|
||||
"#63f04f",
|
||||
"#18d7e9",
|
||||
"#f3bd42",
|
||||
"#c68cff",
|
||||
"#ff8f66",
|
||||
"#73b7ff",
|
||||
];
|
||||
|
||||
function timestamp(value: any) {
|
||||
if (typeof value === "number") return value;
|
||||
const result = new Date(value).getTime();
|
||||
return Number.isFinite(result) ? result : NaN;
|
||||
}
|
||||
function displayValue(value: any) {
|
||||
return typeof value === "number" ? Number(value.toFixed(3)) : value;
|
||||
}
|
||||
function axisLabel(value: number) {
|
||||
const date = new Date(value);
|
||||
const parts = new Intl.DateTimeFormat("zh-CN", {
|
||||
timeZone: "Asia/Shanghai",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
second: "2-digit",
|
||||
hour12: false,
|
||||
}).formatToParts(date);
|
||||
const part = (type: string) =>
|
||||
parts.find((x) => x.type === type)?.value ?? "";
|
||||
if (range.value <= 2 * 3600000)
|
||||
return `${part("hour")}:${part("minute")}:${part("second")}`;
|
||||
return `${part("month")}-${part("day")} ${part("hour")}:${part("minute")}`;
|
||||
}
|
||||
function splitNumber() {
|
||||
if (range.value <= 2 * 3600000) return 8;
|
||||
if (range.value <= 24 * 3600000) return 10;
|
||||
if (range.value <= 7 * 24 * 3600000) return 8;
|
||||
return 7;
|
||||
}
|
||||
function seriesName(s: any, index: number, used: Set<string>) {
|
||||
const base = `${s.point_name}${s.unit ? ` (${s.unit})` : ""}`;
|
||||
if (!used.has(base)) {
|
||||
used.add(base);
|
||||
return base;
|
||||
}
|
||||
const name = `${base} #${index + 1}`;
|
||||
used.add(name);
|
||||
return name;
|
||||
}
|
||||
function pointRows(s: any) {
|
||||
return (s.data ?? [])
|
||||
.map((d: any) => ({ ...d, time: timestamp(d.ts) }))
|
||||
.filter((d: any) => Number.isFinite(d.time))
|
||||
.sort((a: any, b: any) => a.time - b.time);
|
||||
}
|
||||
function cursorRow(s: any, target: number) {
|
||||
const points = pointRows(s);
|
||||
const cursorTS = new Date(target).toISOString();
|
||||
const base = {
|
||||
pointId: s.point_id,
|
||||
pointName: s.point_name,
|
||||
ts: cursorTS,
|
||||
value: null as number | null,
|
||||
quality: "none",
|
||||
qualityReason: null as string | null,
|
||||
unit: s.unit,
|
||||
interpolated: false,
|
||||
};
|
||||
if (
|
||||
!points.length ||
|
||||
target < points[0].time ||
|
||||
target > points.at(-1).time
|
||||
) {
|
||||
return base;
|
||||
}
|
||||
let nearest = points[0];
|
||||
for (const point of points) {
|
||||
if (Math.abs(point.time - target) < Math.abs(nearest.time - target)) {
|
||||
nearest = point;
|
||||
}
|
||||
}
|
||||
if (Math.abs(nearest.time - target) <= 1) {
|
||||
return {
|
||||
...base,
|
||||
value: nearest.value ?? null,
|
||||
quality: nearest.quality ?? "none",
|
||||
qualityReason: nearest.quality_reason ?? null,
|
||||
};
|
||||
}
|
||||
const numeric = points.filter(
|
||||
(point: any) => point.value !== null && point.value !== undefined,
|
||||
);
|
||||
if (!numeric.length) {
|
||||
return {
|
||||
...base,
|
||||
quality: nearest.quality ?? "none",
|
||||
qualityReason: nearest.quality_reason ?? null,
|
||||
};
|
||||
}
|
||||
let before: any;
|
||||
let after: any;
|
||||
for (const point of numeric) {
|
||||
if (point.time <= target) before = point;
|
||||
if (point.time >= target) {
|
||||
after = point;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (before && after && before.time !== after.time) {
|
||||
const ratio = (target - before.time) / (after.time - before.time);
|
||||
const badBetween = points.some(
|
||||
(point: any) =>
|
||||
point.time > before.time &&
|
||||
point.time < after.time &&
|
||||
point.quality === "bad",
|
||||
);
|
||||
return {
|
||||
...base,
|
||||
value: before.value + (after.value - before.value) * ratio,
|
||||
quality:
|
||||
before.quality === "bad" || after.quality === "bad" || badBetween
|
||||
? "bad"
|
||||
: "good",
|
||||
qualityReason: before.quality_reason ?? after.quality_reason ?? null,
|
||||
interpolated: true,
|
||||
};
|
||||
}
|
||||
const edge = before ?? after;
|
||||
return {
|
||||
...base,
|
||||
value: edge.value ?? null,
|
||||
quality: edge.quality ?? "none",
|
||||
qualityReason: edge.quality_reason ?? null,
|
||||
interpolated: true,
|
||||
};
|
||||
}
|
||||
function cursorRows(target: number) {
|
||||
return props.series
|
||||
.map((s: any, index: number) => {
|
||||
const name = currentNames[index];
|
||||
return visibleNames[name] === false ? null : cursorRow(s, target);
|
||||
})
|
||||
.filter(Boolean);
|
||||
}
|
||||
function emitCursorAt(target: number) {
|
||||
if (!Number.isFinite(target)) return;
|
||||
lastCursorTime = Math.min(Math.max(target, props.startTime), props.endTime);
|
||||
scheduleVisualCursor(lastCursorTime);
|
||||
emit("cursor", cursorRows(lastCursorTime));
|
||||
}
|
||||
function scheduleVisualCursor(target: number) {
|
||||
if (cursorFrame !== undefined) cancelAnimationFrame(cursorFrame);
|
||||
cursorFrame = requestAnimationFrame(() => {
|
||||
cursorFrame = undefined;
|
||||
updateVisualCursor(target);
|
||||
});
|
||||
}
|
||||
function updateVisualCursor(target: number) {
|
||||
if (!chart) return;
|
||||
const colors = ["#63f04f", "#18d7e9", "#f3bd42", "#c68cff"];
|
||||
const pixel = chart.convertToPixel({ gridIndex: 0 }, [target, 0]) as number[];
|
||||
if (!Number.isFinite(pixel?.[0])) return;
|
||||
chart.setOption(
|
||||
{
|
||||
animationDuration: 350,
|
||||
graphic: [
|
||||
{
|
||||
id: "history-cursor-line",
|
||||
type: "line",
|
||||
left: pixel[0],
|
||||
top: gridTop,
|
||||
shape: {
|
||||
x1: 0,
|
||||
y1: 0,
|
||||
x2: 0,
|
||||
y2: Math.max(1, chart.getHeight() - gridTop - gridBottom),
|
||||
},
|
||||
style: {
|
||||
stroke: "#dbeee8",
|
||||
lineWidth: 1,
|
||||
lineDash: [5, 5],
|
||||
opacity: 0.9,
|
||||
},
|
||||
animation: false,
|
||||
silent: true,
|
||||
z: 100,
|
||||
},
|
||||
],
|
||||
},
|
||||
{ lazyUpdate: true },
|
||||
);
|
||||
}
|
||||
function handleMouseMove(event: any) {
|
||||
if (!chart) return;
|
||||
const x = event.zrX ?? event.offsetX;
|
||||
const y = event.zrY ?? event.offsetY;
|
||||
if (!Number.isFinite(x) || !Number.isFinite(y)) return;
|
||||
const converted = chart.convertFromPixel({ gridIndex: 0 }, [
|
||||
x,
|
||||
y,
|
||||
]) as number[];
|
||||
const target = timestamp(converted?.[0]);
|
||||
if (Number.isFinite(target)) emitCursorAt(target);
|
||||
}
|
||||
function handleLegendChange(event: any) {
|
||||
visibleNames = { ...(event.selected ?? {}) };
|
||||
if (lastCursorTime !== null) emitCursorAt(lastCursorTime);
|
||||
}
|
||||
function render() {
|
||||
if (!chart) return;
|
||||
const option = (chart.getOption?.() as any) ?? {};
|
||||
const previous = option.legend?.[0]?.selected ?? {};
|
||||
const used = new Set<string>();
|
||||
currentNames = props.series.map((s: any, i: number) =>
|
||||
seriesName(s, i, used),
|
||||
);
|
||||
const selected: Record<string, boolean> = {};
|
||||
currentNames.forEach((name) => {
|
||||
selected[name] = previous[name] !== false;
|
||||
});
|
||||
visibleNames = selected;
|
||||
const chartSeries: any[] = [];
|
||||
props.series.forEach((s: any, i: number) => {
|
||||
const solid: any[] = [];
|
||||
const bad: any[] = [];
|
||||
(s.data ?? []).forEach((d: any) => {
|
||||
const numeric = d.value !== null && d.value !== undefined;
|
||||
const mapped = numeric
|
||||
? props.segmented
|
||||
? mapValue(d.value, segments.value)
|
||||
: d.value
|
||||
: null;
|
||||
const row = [
|
||||
d.ts,
|
||||
mapped,
|
||||
d.ts,
|
||||
d.value,
|
||||
d.quality,
|
||||
s.unit,
|
||||
s.point_name,
|
||||
s.point_id,
|
||||
];
|
||||
if (d.quality === "bad") {
|
||||
solid.push([
|
||||
d.ts,
|
||||
null,
|
||||
d.ts,
|
||||
null,
|
||||
d.quality,
|
||||
s.unit,
|
||||
s.point_name,
|
||||
s.point_id,
|
||||
]);
|
||||
bad.push([
|
||||
d.ts,
|
||||
numeric ? mapped : null,
|
||||
d.ts,
|
||||
d.value,
|
||||
d.quality,
|
||||
s.unit,
|
||||
s.point_name,
|
||||
s.point_id,
|
||||
]);
|
||||
} else {
|
||||
solid.push(row);
|
||||
bad.push([
|
||||
d.ts,
|
||||
null,
|
||||
d.ts,
|
||||
null,
|
||||
d.quality,
|
||||
s.unit,
|
||||
s.point_name,
|
||||
s.point_id,
|
||||
]);
|
||||
}
|
||||
});
|
||||
const color = colors[i % colors.length];
|
||||
chartSeries.push(
|
||||
{
|
||||
id: `${s.point_id}-good`,
|
||||
name: currentNames[i],
|
||||
type: "line",
|
||||
showSymbol: false,
|
||||
connectNulls: false,
|
||||
data: solid,
|
||||
lineStyle: { width: 1.8, color },
|
||||
itemStyle: { color },
|
||||
emphasis: { focus: "series" },
|
||||
},
|
||||
{
|
||||
id: `${s.point_id}-bad`,
|
||||
name: currentNames[i],
|
||||
type: "line",
|
||||
showSymbol: true,
|
||||
symbolSize: 5,
|
||||
connectNulls: false,
|
||||
data: bad,
|
||||
lineStyle: { width: 1.8, type: "dashed", color },
|
||||
itemStyle: { color },
|
||||
emphasis: { focus: "series" },
|
||||
},
|
||||
);
|
||||
});
|
||||
chart.setOption(
|
||||
{
|
||||
animation: true,
|
||||
animationDuration: 260,
|
||||
animationDurationUpdate: 260,
|
||||
animationEasingUpdate: "cubicOut",
|
||||
color: colors,
|
||||
grid: { left: 58, right: 26, top: 58, bottom: 46 },
|
||||
legend: { top: 12, textStyle: { color: "#a9bbc0" } },
|
||||
grid: {
|
||||
left: 62,
|
||||
right: 28,
|
||||
top: currentNames.length ? 58 : 30,
|
||||
bottom: 52,
|
||||
},
|
||||
graphic: [],
|
||||
legend: {
|
||||
show: currentNames.length > 0,
|
||||
data: currentNames,
|
||||
selected,
|
||||
top: 12,
|
||||
textStyle: { color: "#a9bbc0" },
|
||||
itemWidth: 18,
|
||||
itemHeight: 8,
|
||||
},
|
||||
tooltip: {
|
||||
trigger: "axis",
|
||||
transitionDuration: 0.12,
|
||||
axisPointer: {
|
||||
type: "line",
|
||||
lineStyle: { color: "#dbeee8", type: "dashed" },
|
||||
type: "none",
|
||||
},
|
||||
backgroundColor: "#07141af2",
|
||||
borderColor: "#35505a",
|
||||
textStyle: { color: "#dce9e5" },
|
||||
formatter: (params: any) => {
|
||||
emit(
|
||||
"cursor",
|
||||
params.map((p: any) => ({
|
||||
pointName: p.seriesName,
|
||||
ts: p.data?.[2],
|
||||
value: p.data?.[3],
|
||||
quality: p.data?.[4],
|
||||
unit: p.data?.[5],
|
||||
})),
|
||||
);
|
||||
return params
|
||||
const rows = Array.isArray(params) ? params : [params];
|
||||
const axisValue = rows[0]?.axisValue;
|
||||
const target =
|
||||
lastCursorTime ?? timestamp(axisValue ?? rows[0]?.data?.[2]);
|
||||
if (!Number.isFinite(target)) return "";
|
||||
emitCursorAt(target);
|
||||
return cursorRows(target)
|
||||
.map(
|
||||
(p: any) =>
|
||||
`${p.marker}${p.seriesName}<br/>${p.data?.[3] ?? "—"} ${p.data?.[5] ?? ""} · ${p.data?.[4]}`,
|
||||
(row: any) =>
|
||||
`${row.pointName}<br/>${displayValue(row.value) ?? "—"} ${row.unit ?? ""} · ${row.quality}`,
|
||||
)
|
||||
.join("<br/>");
|
||||
.join("<br/>\n");
|
||||
},
|
||||
},
|
||||
xAxis: {
|
||||
type: "time",
|
||||
min: props.startTime,
|
||||
max: props.endTime,
|
||||
splitNumber: splitNumber(),
|
||||
minInterval: range.value / 24,
|
||||
maxInterval: range.value / 3,
|
||||
axisLine: { lineStyle: { color: "#35505a" } },
|
||||
axisLabel: { color: "#718990" },
|
||||
axisLabel: {
|
||||
color: "#718990",
|
||||
hideOverlap: true,
|
||||
formatter: axisLabel,
|
||||
},
|
||||
splitLine: { show: true, lineStyle: { color: "#142b33" } },
|
||||
},
|
||||
yAxis: {
|
||||
type: "value",
|
||||
min: props.segmented ? 0 : undefined,
|
||||
max: props.segmented ? 1 : undefined,
|
||||
scale: !props.segmented,
|
||||
axisLabel: {
|
||||
color: "#789098",
|
||||
formatter: (v: number) =>
|
||||
@@ -69,66 +409,43 @@ function render() {
|
||||
},
|
||||
splitLine: { lineStyle: { color: "#173039", type: "dashed" } },
|
||||
},
|
||||
series: props.series.flatMap((s: any, i: number) => {
|
||||
const solid: any[] = [];
|
||||
const bad: any[] = [];
|
||||
s.data.forEach((d: any) => {
|
||||
const row = [
|
||||
d.ts,
|
||||
d.value === null
|
||||
? null
|
||||
: props.segmented
|
||||
? mapValue(d.value, segments.value)
|
||||
: d.value,
|
||||
d.ts,
|
||||
d.value,
|
||||
d.quality,
|
||||
s.unit,
|
||||
];
|
||||
(d.quality === "bad" && d.value !== null ? bad : solid).push(row);
|
||||
});
|
||||
return [
|
||||
{
|
||||
name: `${s.point_name}${s.unit ? ` (${s.unit})` : ""}`,
|
||||
type: "line",
|
||||
showSymbol: false,
|
||||
connectNulls: false,
|
||||
data: solid,
|
||||
lineStyle: { width: 1.6, color: colors[i % colors.length] },
|
||||
},
|
||||
{
|
||||
name: `${s.point_name} · bad`,
|
||||
type: "line",
|
||||
showSymbol: true,
|
||||
symbolSize: 5,
|
||||
connectNulls: false,
|
||||
data: bad,
|
||||
lineStyle: { width: 1.4, type: "dashed", color: "#ff665c" },
|
||||
itemStyle: { color: "#ff665c" },
|
||||
},
|
||||
];
|
||||
}),
|
||||
series: chartSeries,
|
||||
},
|
||||
true,
|
||||
);
|
||||
if (lastCursorTime !== null) scheduleVisualCursor(lastCursorTime);
|
||||
}
|
||||
onMounted(() => {
|
||||
chart = echarts.init(el.value!);
|
||||
observer = new ResizeObserver(() => chart?.resize());
|
||||
observer = new ResizeObserver(() => {
|
||||
chart?.resize();
|
||||
if (chart && lastCursorTime !== null) scheduleVisualCursor(lastCursorTime);
|
||||
});
|
||||
observer.observe(el.value!);
|
||||
chart.getZr().on("mousemove", handleMouseMove);
|
||||
chart.on("legendselectchanged", handleLegendChange);
|
||||
render();
|
||||
});
|
||||
watch(() => [props.series, props.segmented], render, { deep: true });
|
||||
watch(
|
||||
() => [props.series, props.segmented, props.startTime, props.endTime],
|
||||
render,
|
||||
{ deep: true },
|
||||
);
|
||||
onBeforeUnmount(() => {
|
||||
if (cursorFrame !== undefined) cancelAnimationFrame(cursorFrame);
|
||||
observer?.disconnect();
|
||||
chart?.getZr().off("mousemove", handleMouseMove);
|
||||
chart?.off("legendselectchanged", handleLegendChange);
|
||||
chart?.dispose();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template><div ref="el" class="history-chart" /></template>
|
||||
|
||||
<style scoped>
|
||||
.history-chart {
|
||||
height: 450px;
|
||||
width: 100%;
|
||||
height: 520px;
|
||||
background:
|
||||
linear-gradient(#0a1a21aa, #07151baa),
|
||||
repeating-linear-gradient(0deg, transparent 0 31px, #122a321f 32px);
|
||||
|
||||
+78
-1
@@ -209,6 +209,18 @@ main {
|
||||
.btn:hover {
|
||||
border-color: #3f5d67;
|
||||
}
|
||||
.btn:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.55;
|
||||
}
|
||||
.spin {
|
||||
animation: spin 0.8s linear infinite;
|
||||
}
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
.panel {
|
||||
background: rgba(9, 23, 29, 0.9);
|
||||
border: 1px solid var(--line);
|
||||
@@ -332,7 +344,7 @@ main {
|
||||
font-size: 12px;
|
||||
color: #9db0b6;
|
||||
}
|
||||
.field input,
|
||||
.field input:not([type="checkbox"]),
|
||||
.field select {
|
||||
width: 100%;
|
||||
}
|
||||
@@ -348,6 +360,71 @@ main {
|
||||
.switch {
|
||||
accent-color: var(--green);
|
||||
}
|
||||
.check-field {
|
||||
min-height: 42px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 0 12px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 5px;
|
||||
background: #0b1920;
|
||||
color: #c7d8d5;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
transition:
|
||||
border-color 0.14s ease,
|
||||
background 0.14s ease,
|
||||
color 0.14s ease;
|
||||
}
|
||||
.check-field-wide {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
.check-field input {
|
||||
position: absolute;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
.check-box {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
flex: 0 0 18px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border: 1px solid #45616a;
|
||||
border-radius: 4px;
|
||||
background: #07141a;
|
||||
transition:
|
||||
border-color 0.14s ease,
|
||||
background 0.14s ease,
|
||||
box-shadow 0.14s ease;
|
||||
}
|
||||
.check-box::after {
|
||||
content: "";
|
||||
width: 8px;
|
||||
height: 4px;
|
||||
border-left: 2px solid #041006;
|
||||
border-bottom: 2px solid #041006;
|
||||
transform: rotate(-45deg) scale(0);
|
||||
transition: transform 0.14s ease;
|
||||
}
|
||||
.check-field:has(input:checked) {
|
||||
border-color: rgba(99, 240, 79, 0.55);
|
||||
background: rgba(99, 240, 79, 0.09);
|
||||
color: #dff8dc;
|
||||
}
|
||||
.check-field:has(input:checked) .check-box {
|
||||
border-color: var(--green);
|
||||
background: var(--green);
|
||||
box-shadow: 0 0 12px rgba(99, 240, 79, 0.22);
|
||||
}
|
||||
.check-field:has(input:checked) .check-box::after {
|
||||
transform: rotate(-45deg) scale(1);
|
||||
}
|
||||
.check-field:focus-within {
|
||||
outline: 2px solid rgba(99, 240, 79, 0.35);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
.collection-layout {
|
||||
display: grid;
|
||||
grid-template-columns: 220px 1fr;
|
||||
|
||||
@@ -3,6 +3,7 @@ import { onMounted, reactive, ref } from "vue";
|
||||
import {
|
||||
Download,
|
||||
Plus,
|
||||
RefreshCw,
|
||||
Search,
|
||||
Upload,
|
||||
FolderPlus,
|
||||
@@ -17,6 +18,7 @@ import {
|
||||
} from "@/api/platform";
|
||||
const items = ref<any[]>([]),
|
||||
devices = ref<any[]>([]),
|
||||
loading = ref(false),
|
||||
keyword = ref(""),
|
||||
show = ref(false),
|
||||
editing = ref<string | null>(null);
|
||||
@@ -52,33 +54,45 @@ async function loadGroups() {
|
||||
const r: any = await collectionApi.groups();
|
||||
groups.value = r.data.groups;
|
||||
}
|
||||
async function refresh() {
|
||||
if (loading.value) return;
|
||||
loading.value = true;
|
||||
try {
|
||||
await Promise.all([loadGroups(), load()]);
|
||||
} catch {
|
||||
alert("刷新数据失败,请稍后重试");
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
async function addGroup() {
|
||||
const name = prompt("请输入新分组名称")?.trim();
|
||||
if (!name) return;
|
||||
await collectionApi.createGroup(name);
|
||||
selectedGroup.value = name;
|
||||
await loadGroups();
|
||||
await load();
|
||||
await refresh();
|
||||
}
|
||||
async function editGroup(g: any) {
|
||||
const name = prompt("修改分组名称", g.name)?.trim();
|
||||
if (!name || name === g.name) return;
|
||||
await collectionApi.updateGroup(g.name, name);
|
||||
if (selectedGroup.value === g.name) selectedGroup.value = name;
|
||||
await loadGroups();
|
||||
await load();
|
||||
await refresh();
|
||||
}
|
||||
async function deleteGroup(g: any) {
|
||||
if (g.name === "default") {
|
||||
alert("default 分组不可删除");
|
||||
return;
|
||||
}
|
||||
if (!confirm(`删除分组“${g.name}”后,里面的采集点也会跟着删除,是否继续?`))
|
||||
if (
|
||||
!confirm(
|
||||
`删除分组“${g.name}”后,里面的采集点和写入点会自动转移到 default 分组,是否继续?`,
|
||||
)
|
||||
)
|
||||
return;
|
||||
await collectionApi.removeGroup(g.name);
|
||||
if (selectedGroup.value === g.name) selectedGroup.value = "";
|
||||
await loadGroups();
|
||||
await load();
|
||||
await refresh();
|
||||
}
|
||||
function open(p?: any) {
|
||||
editing.value = p?.id ?? null;
|
||||
@@ -102,16 +116,24 @@ function open(p?: any) {
|
||||
show.value = true;
|
||||
}
|
||||
async function save() {
|
||||
editing.value
|
||||
? await collectionApi.update(editing.value, form)
|
||||
: await collectionApi.create(form);
|
||||
show.value = false;
|
||||
await load();
|
||||
try {
|
||||
editing.value
|
||||
? await collectionApi.update(editing.value, form)
|
||||
: await collectionApi.create(form);
|
||||
show.value = false;
|
||||
await refresh();
|
||||
} catch (e) {
|
||||
alert(e instanceof Error ? e.message : "保存采集点失败");
|
||||
}
|
||||
}
|
||||
async function remove(id: string) {
|
||||
if (confirm("确认删除该采集点?")) {
|
||||
await collectionApi.remove(id);
|
||||
await load();
|
||||
try {
|
||||
await collectionApi.remove(id);
|
||||
await refresh();
|
||||
} catch (e) {
|
||||
alert(e instanceof Error ? e.message : "删除采集点失败");
|
||||
}
|
||||
}
|
||||
}
|
||||
async function upload(e: Event) {
|
||||
@@ -121,14 +143,18 @@ async function upload(e: Event) {
|
||||
alert(
|
||||
`导入完成:新增${r.data.created},更新${r.data.updated},失败${r.data.failed}`,
|
||||
);
|
||||
await load();
|
||||
await refresh();
|
||||
}
|
||||
onMounted(async () => {
|
||||
const r: any = await deviceApi.list({ page_size: 100 });
|
||||
devices.value = r.data.items;
|
||||
await loadGroups();
|
||||
await load();
|
||||
await refresh();
|
||||
});
|
||||
function formatTime(value?: string) {
|
||||
return value
|
||||
? new Date(value).toLocaleString("zh-CN", { hour12: false })
|
||||
: "—";
|
||||
}
|
||||
</script>
|
||||
<template>
|
||||
<section>
|
||||
@@ -194,6 +220,9 @@ onMounted(async () => {
|
||||
<button class="btn" @click="exportConfig('collection-points')">
|
||||
<Download />导出CSV
|
||||
</button>
|
||||
<button class="btn" :disabled="loading" @click="refresh">
|
||||
<RefreshCw :class="loading && 'spin'" />刷新数据
|
||||
</button>
|
||||
<button class="btn btn-primary" @click="open()">
|
||||
<Plus />新增采集点
|
||||
</button>
|
||||
@@ -219,6 +248,7 @@ onMounted(async () => {
|
||||
<th>历史</th>
|
||||
<th>最新值</th>
|
||||
<th>质量</th>
|
||||
<th>更新时间</th>
|
||||
<th>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -245,6 +275,7 @@ onMounted(async () => {
|
||||
>
|
||||
{{ p.latest_value?.quality ?? "none" }}
|
||||
</td>
|
||||
<td>{{ formatTime(p.latest_value?.ts) }}</td>
|
||||
<td>
|
||||
<button class="btn" @click="open(p)">编辑</button>
|
||||
<button class="btn" @click="remove(p.id)">删除</button>
|
||||
@@ -309,16 +340,16 @@ onMounted(async () => {
|
||||
min="1"
|
||||
max="1440"
|
||||
/></label>
|
||||
<label class="field"
|
||||
><span
|
||||
><input v-model="form.enabled" type="checkbox" /> 启用</span
|
||||
></label
|
||||
><label class="field"
|
||||
><span
|
||||
><input v-model="form.store_history" type="checkbox" />
|
||||
存储历史</span
|
||||
></label
|
||||
>
|
||||
<label class="check-field">
|
||||
<input v-model="form.enabled" type="checkbox" />
|
||||
<span class="check-box" aria-hidden="true"></span>
|
||||
<span class="check-text">启用采集</span>
|
||||
</label>
|
||||
<label class="check-field">
|
||||
<input v-model="form.store_history" type="checkbox" />
|
||||
<span class="check-box" aria-hidden="true"></span>
|
||||
<span class="check-text">存储历史</span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="modal-actions">
|
||||
<button type="button" class="btn" @click="show = false">取消</button
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, reactive, ref } from "vue";
|
||||
import { Download, Plus, Search, Upload } from "lucide-vue-next";
|
||||
import { Download, Plus, RefreshCw, Search, Upload } from "lucide-vue-next";
|
||||
import { deviceApi, exportConfig, importConfig } from "@/api/platform";
|
||||
type Device = {
|
||||
id: string;
|
||||
@@ -76,16 +76,24 @@ async function save() {
|
||||
? { rack: form.rack, slot: form.slot }
|
||||
: { unit_id: 1, float32_order: "ABCD" },
|
||||
};
|
||||
editing.value
|
||||
? await deviceApi.update(editing.value, data)
|
||||
: await deviceApi.create(data);
|
||||
show.value = false;
|
||||
await load();
|
||||
try {
|
||||
editing.value
|
||||
? await deviceApi.update(editing.value, data)
|
||||
: await deviceApi.create(data);
|
||||
show.value = false;
|
||||
await load();
|
||||
} catch (e) {
|
||||
alert(e instanceof Error ? e.message : "保存设备失败");
|
||||
}
|
||||
}
|
||||
async function remove(id: string) {
|
||||
if (confirm("确认删除?该设备下所有采集点和写入点将同步逻辑删除。")) {
|
||||
await deviceApi.remove(id);
|
||||
await load();
|
||||
try {
|
||||
await deviceApi.remove(id);
|
||||
await load();
|
||||
} catch (e) {
|
||||
alert(e instanceof Error ? e.message : "删除设备失败");
|
||||
}
|
||||
}
|
||||
}
|
||||
onMounted(load);
|
||||
@@ -136,6 +144,9 @@ function formatTime(value?: string) {
|
||||
<button class="btn" @click="exportConfig('devices')">
|
||||
<Download />导出CSV
|
||||
</button>
|
||||
<button class="btn" :disabled="loading" @click="load">
|
||||
<RefreshCw :class="loading && 'spin'" />刷新数据
|
||||
</button>
|
||||
<button class="btn btn-primary" @click="open()">
|
||||
<Plus />新增设备
|
||||
</button>
|
||||
@@ -239,11 +250,11 @@ function formatTime(value?: string) {
|
||||
type="number"
|
||||
min="1"
|
||||
max="3600" /></label
|
||||
><label class="field field-wide"
|
||||
><span
|
||||
><input v-model="form.enabled" type="checkbox" /> 启用设备</span
|
||||
></label
|
||||
>
|
||||
><label class="check-field check-field-wide">
|
||||
<input v-model="form.enabled" type="checkbox" />
|
||||
<span class="check-box" aria-hidden="true"></span>
|
||||
<span class="check-text">启用设备</span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="modal-actions">
|
||||
<button type="button" class="btn" @click="show = false">取消</button
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,81 +1,159 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, reactive, ref } from "vue";
|
||||
import { Download, Plus, Upload, ShieldAlert } from "lucide-vue-next";
|
||||
import {
|
||||
writePointApi,
|
||||
Download,
|
||||
FolderPlus,
|
||||
Pencil,
|
||||
Plus,
|
||||
RefreshCw,
|
||||
Search,
|
||||
ShieldAlert,
|
||||
Trash2,
|
||||
Upload,
|
||||
} from "lucide-vue-next";
|
||||
import {
|
||||
collectionApi,
|
||||
deviceApi,
|
||||
exportConfig,
|
||||
importConfig,
|
||||
writePointApi,
|
||||
} from "@/api/platform";
|
||||
|
||||
const items = ref<any[]>([]),
|
||||
logs = ref<any[]>([]),
|
||||
tab = ref<"points" | "logs">("points"),
|
||||
devices = ref<any[]>([]),
|
||||
groups = ref<any[]>([]),
|
||||
selectedGroup = ref(""),
|
||||
keyword = ref(""),
|
||||
loading = ref(false),
|
||||
show = ref(false),
|
||||
editing = ref<string | null>(null);
|
||||
|
||||
const form = reactive({
|
||||
name: "",
|
||||
group_name: "default",
|
||||
device_id: "",
|
||||
enabled: true,
|
||||
write_enabled: false,
|
||||
address: "",
|
||||
data_type: "REAL",
|
||||
unit: "",
|
||||
readback_tolerance: 0.0001,
|
||||
});
|
||||
|
||||
async function load() {
|
||||
try {
|
||||
const [a, b]: any = await Promise.all([
|
||||
writePointApi.list({ page_size: 100 }),
|
||||
writePointApi.list({
|
||||
keyword: keyword.value,
|
||||
page_size: 100,
|
||||
group_name: selectedGroup.value,
|
||||
}),
|
||||
writePointApi.logs({ page_size: 100 }),
|
||||
]);
|
||||
items.value = a.data.items;
|
||||
logs.value = b.data.items;
|
||||
} catch {
|
||||
items.value = [];
|
||||
logs.value = [];
|
||||
}
|
||||
}
|
||||
|
||||
async function loadGroups() {
|
||||
const r: any = await collectionApi.groups();
|
||||
groups.value = r.data.groups;
|
||||
}
|
||||
async function refresh() {
|
||||
if (loading.value) return;
|
||||
loading.value = true;
|
||||
try {
|
||||
await Promise.all([loadGroups(), load()]);
|
||||
} catch {
|
||||
alert("刷新数据失败,请稍后重试");
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function addGroup() {
|
||||
const name = prompt("请输入新分组名称")?.trim();
|
||||
if (!name) return;
|
||||
await collectionApi.createGroup(name);
|
||||
selectedGroup.value = name;
|
||||
await refresh();
|
||||
}
|
||||
|
||||
async function editGroup(g: any) {
|
||||
const name = prompt("修改分组名称", g.name)?.trim();
|
||||
if (!name || name === g.name) return;
|
||||
await collectionApi.updateGroup(g.name, name);
|
||||
if (selectedGroup.value === g.name) selectedGroup.value = name;
|
||||
await refresh();
|
||||
}
|
||||
|
||||
async function deleteGroup(g: any) {
|
||||
if (g.name === "default") {
|
||||
alert("default 分组不可删除");
|
||||
return;
|
||||
}
|
||||
if (
|
||||
!confirm(
|
||||
`删除分组“${g.name}”后,里面的采集点和写入点会自动转移到 default 分组,是否继续?`,
|
||||
)
|
||||
)
|
||||
return;
|
||||
await collectionApi.removeGroup(g.name);
|
||||
if (selectedGroup.value === g.name) selectedGroup.value = "";
|
||||
await refresh();
|
||||
}
|
||||
|
||||
function open(p?: any) {
|
||||
editing.value = p?.id ?? null;
|
||||
Object.assign(form, {
|
||||
name: p?.name ?? "",
|
||||
group_name: p?.group_name ?? groups.value[0]?.name ?? "default",
|
||||
device_id: p?.device_id ?? devices.value[0]?.id ?? "",
|
||||
write_enabled: p?.write_enabled ?? false,
|
||||
address: p?.address ?? "",
|
||||
data_type: p?.data_type ?? "REAL",
|
||||
unit: p?.unit ?? "",
|
||||
});
|
||||
show.value = true;
|
||||
}
|
||||
|
||||
async function save() {
|
||||
try {
|
||||
editing.value
|
||||
? await writePointApi.update(editing.value, form)
|
||||
: await writePointApi.create(form);
|
||||
show.value = false;
|
||||
await refresh();
|
||||
} catch (e) {
|
||||
alert(e instanceof Error ? e.message : "保存写入点失败");
|
||||
}
|
||||
}
|
||||
|
||||
async function execute(p: any) {
|
||||
const raw = prompt(`向 ${p.name} (${p.address}) 写入值:`);
|
||||
if (raw === null) return;
|
||||
const value = p.data_type === "BOOL" ? raw === "true" : Number(raw);
|
||||
const reason = prompt("写入原因(可选):") ?? "";
|
||||
await writePointApi.write(p.id, { value, reason });
|
||||
alert("写入及回读验证成功");
|
||||
await load();
|
||||
}
|
||||
function open(p?: any) {
|
||||
editing.value = p?.id ?? null;
|
||||
Object.assign(
|
||||
form,
|
||||
p ?? {
|
||||
name: "",
|
||||
group_name: "default",
|
||||
device_id: devices.value[0]?.id ?? "",
|
||||
enabled: true,
|
||||
write_enabled: false,
|
||||
address: "",
|
||||
data_type: "REAL",
|
||||
unit: "",
|
||||
readback_tolerance: 0.0001,
|
||||
},
|
||||
);
|
||||
show.value = true;
|
||||
}
|
||||
async function save() {
|
||||
editing.value
|
||||
? await writePointApi.update(editing.value, form)
|
||||
: await writePointApi.create(form);
|
||||
show.value = false;
|
||||
await load();
|
||||
const r: any = await writePointApi.write(p.id, { value, reason });
|
||||
const readback = r.data?.readback_value;
|
||||
alert(`写入成功,回读值:${readback ?? "—"}`);
|
||||
await refresh();
|
||||
}
|
||||
|
||||
async function remove(id: string) {
|
||||
if (confirm("确认删除该写入点?")) {
|
||||
await writePointApi.remove(id);
|
||||
await load();
|
||||
try {
|
||||
await writePointApi.remove(id);
|
||||
await refresh();
|
||||
} catch (e) {
|
||||
alert(e instanceof Error ? e.message : "删除写入点失败");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function upload(e: Event) {
|
||||
const file = (e.target as HTMLInputElement).files?.[0];
|
||||
if (!file) return;
|
||||
@@ -83,176 +161,240 @@ async function upload(e: Event) {
|
||||
alert(
|
||||
`导入完成:新增${r.data.created},更新${r.data.updated},失败${r.data.failed}`,
|
||||
);
|
||||
await load();
|
||||
await refresh();
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
const r: any = await deviceApi.list({ page_size: 100 });
|
||||
devices.value = r.data.items;
|
||||
await load();
|
||||
const deviceResult: any = await deviceApi.list({ page_size: 100 });
|
||||
devices.value = deviceResult.data.items;
|
||||
await refresh();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section>
|
||||
<div class="page-head">
|
||||
<div>
|
||||
<h2>数据写入</h2>
|
||||
<p>人工写入、回读验证与完整操作审计</p>
|
||||
</div>
|
||||
<div class="toolbar">
|
||||
<label class="btn"
|
||||
><Upload />导入CSV<input
|
||||
hidden
|
||||
type="file"
|
||||
accept=".csv"
|
||||
@change="upload"
|
||||
/></label>
|
||||
<button class="btn" @click="exportConfig('write-points')">
|
||||
<Download />导出CSV
|
||||
<div class="collection-layout">
|
||||
<aside class="group-sidebar panel">
|
||||
<div class="group-header">
|
||||
<span>写入点分组</span>
|
||||
<button class="icon-btn" title="添加分组" @click="addGroup">
|
||||
<FolderPlus />
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
:class="['group-item', !selectedGroup && 'active']"
|
||||
@click="
|
||||
selectedGroup = '';
|
||||
load();
|
||||
"
|
||||
>
|
||||
全部分组
|
||||
<span>{{ groups.reduce((sum, g) => sum + g.write_count, 0) }}</span>
|
||||
</button>
|
||||
<button class="btn btn-primary" @click="open()">
|
||||
<Plus />新增写入点
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="toolbar" style="margin-bottom: 12px">
|
||||
<button
|
||||
:class="['btn', tab === 'points' && 'btn-primary']"
|
||||
@click="tab = 'points'"
|
||||
>
|
||||
写入点</button
|
||||
><button
|
||||
:class="['btn', tab === 'logs' && 'btn-primary']"
|
||||
@click="tab = 'logs'"
|
||||
>
|
||||
操作日志</button
|
||||
><span style="margin-left: auto; color: var(--warn); font-size: 12px"
|
||||
><ShieldAlert style="width: 15px; vertical-align: middle" />
|
||||
写入必须启用并通过回读验证</span
|
||||
>
|
||||
</div>
|
||||
<div class="panel">
|
||||
<table v-if="tab === 'points'" class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>名称</th>
|
||||
<th>分组</th>
|
||||
<th>设备</th>
|
||||
<th>地址 / 类型</th>
|
||||
<th>单位</th>
|
||||
<th>回读容差</th>
|
||||
<th>允许写入</th>
|
||||
<th>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="p in items" :key="p.id">
|
||||
<td>{{ p.name }}</td>
|
||||
<td>{{ p.group_name }}</td>
|
||||
<td>{{ p.device_name }}</td>
|
||||
<td>
|
||||
{{ p.address }} <span class="tag">{{ p.data_type }}</span>
|
||||
</td>
|
||||
<td>{{ p.unit || "—" }}</td>
|
||||
<td>{{ p.readback_tolerance }}</td>
|
||||
<td :class="p.write_enabled ? 'quality-good' : ''">
|
||||
{{ p.write_enabled ? "已启用" : "已锁定" }}
|
||||
</td>
|
||||
<td>
|
||||
<button
|
||||
class="btn"
|
||||
:disabled="!p.write_enabled"
|
||||
@click="execute(p)"
|
||||
>
|
||||
执行写入
|
||||
</button>
|
||||
<button class="btn" @click="open(p)">编辑</button
|
||||
><button class="btn" @click="remove(p.id)">删除</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<table v-else class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>时间</th>
|
||||
<th>点位</th>
|
||||
<th>目标值</th>
|
||||
<th>回读值</th>
|
||||
<th>结果</th>
|
||||
<th>原因</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="l in logs" :key="l.id">
|
||||
<td>{{ l.created_at }}</td>
|
||||
<td>{{ l.point_name }}</td>
|
||||
<td>{{ l.target_value }}</td>
|
||||
<td>{{ l.readback_value ?? "—" }}</td>
|
||||
<td
|
||||
:class="l.result === 'success' ? 'quality-good' : 'quality-bad'"
|
||||
<div
|
||||
v-for="g in groups"
|
||||
:key="g.name"
|
||||
:class="['group-item-wrap', selectedGroup === g.name && 'active']"
|
||||
>
|
||||
<button
|
||||
:class="['group-item', selectedGroup === g.name && 'active']"
|
||||
@click="
|
||||
selectedGroup = g.name;
|
||||
load();
|
||||
"
|
||||
>
|
||||
<span>{{ g.name }}</span>
|
||||
<span>{{ g.write_count }}</span>
|
||||
</button>
|
||||
<span class="group-actions">
|
||||
<button class="icon-btn" title="修改分组" @click="editGroup(g)">
|
||||
<Pencil />
|
||||
</button>
|
||||
<button
|
||||
class="icon-btn danger"
|
||||
title="删除分组"
|
||||
@click="deleteGroup(g)"
|
||||
>
|
||||
{{ l.result }}
|
||||
</td>
|
||||
<td>{{ l.reason || "—" }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div v-if="!(tab === 'points' ? items : logs).length" class="empty">
|
||||
暂无数据
|
||||
<Trash2 />
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<div class="collection-main">
|
||||
<div class="page-head">
|
||||
<div>
|
||||
<h2>数据写入</h2>
|
||||
<p>人工写入、回读数据与完整操作审计</p>
|
||||
</div>
|
||||
<div class="toolbar">
|
||||
<label class="btn">
|
||||
<Upload />导入CSV
|
||||
<input hidden type="file" accept=".csv" @change="upload" />
|
||||
</label>
|
||||
<button class="btn" @click="exportConfig('write-points')">
|
||||
<Download />导出CSV
|
||||
</button>
|
||||
<button class="btn" :disabled="loading" @click="refresh">
|
||||
<RefreshCw :class="loading && 'spin'" />刷新数据
|
||||
</button>
|
||||
<button class="btn btn-primary" @click="open()">
|
||||
<Plus />新增写入点
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="toolbar" style="margin-bottom: 12px">
|
||||
<input v-model="keyword" class="input" placeholder="搜索名称或分组" />
|
||||
<button class="btn" @click="load"><Search />查询</button>
|
||||
</div>
|
||||
|
||||
<div class="toolbar" style="margin-bottom: 12px">
|
||||
<button
|
||||
:class="['btn', tab === 'points' && 'btn-primary']"
|
||||
@click="tab = 'points'"
|
||||
>
|
||||
写入点
|
||||
</button>
|
||||
<button
|
||||
:class="['btn', tab === 'logs' && 'btn-primary']"
|
||||
@click="tab = 'logs'"
|
||||
>
|
||||
操作日志
|
||||
</button>
|
||||
<span style="margin-left: auto; color: var(--warn); font-size: 12px">
|
||||
<ShieldAlert style="width: 15px; vertical-align: middle" />
|
||||
写入必须允许写入并完成回读验证
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="panel">
|
||||
<table v-if="tab === 'points'" class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>名称</th>
|
||||
<th>分组</th>
|
||||
<th>所属设备</th>
|
||||
<th>地址</th>
|
||||
<th>类型</th>
|
||||
<th>单位</th>
|
||||
<th>回读数据</th>
|
||||
<th>允许写入</th>
|
||||
<th>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="p in items" :key="p.id">
|
||||
<td>{{ p.name }}</td>
|
||||
<td>{{ p.group_name }}</td>
|
||||
<td>{{ p.device_name }}</td>
|
||||
<td>{{ p.address }}</td>
|
||||
<td>
|
||||
<span class="tag">{{ p.data_type }}</span>
|
||||
</td>
|
||||
<td>{{ p.unit || "—" }}</td>
|
||||
<td>{{ p.readback_value ?? "—" }}</td>
|
||||
<td :class="p.write_enabled ? 'quality-good' : 'quality-bad'">
|
||||
{{ p.write_enabled ? "允许" : "禁止" }}
|
||||
</td>
|
||||
<td>
|
||||
<button
|
||||
class="btn"
|
||||
:disabled="!p.write_enabled"
|
||||
@click="execute(p)"
|
||||
>
|
||||
执行写入
|
||||
</button>
|
||||
<button class="btn" @click="open(p)">编辑</button>
|
||||
<button class="btn" @click="remove(p.id)">删除</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<table v-else class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>时间</th>
|
||||
<th>点位</th>
|
||||
<th>目标值</th>
|
||||
<th>回读值</th>
|
||||
<th>结果</th>
|
||||
<th>原因</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="l in logs" :key="l.id">
|
||||
<td>{{ l.created_at }}</td>
|
||||
<td>{{ l.point_name }}</td>
|
||||
<td>{{ l.target_value }}</td>
|
||||
<td>{{ l.readback_value ?? "—" }}</td>
|
||||
<td
|
||||
:class="
|
||||
l.result === 'success' ? 'quality-good' : 'quality-bad'
|
||||
"
|
||||
>
|
||||
{{ l.result }}
|
||||
</td>
|
||||
<td>{{ l.reason || "—" }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div v-if="!(tab === 'points' ? items : logs).length" class="empty">
|
||||
暂无数据
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="show" class="modal-mask">
|
||||
<form class="modal" @submit.prevent="save">
|
||||
<h3>{{ editing ? "编辑写入点" : "新增写入点" }}</h3>
|
||||
<div class="form-grid">
|
||||
<label class="field"
|
||||
>名称<input v-model="form.name" class="input" required /></label
|
||||
><label class="field"
|
||||
>分组<input v-model="form.group_name" class="input" required
|
||||
/></label>
|
||||
<label class="field"
|
||||
>设备<select v-model="form.device_id" class="select" required>
|
||||
<label class="field">
|
||||
名称<input v-model="form.name" class="input" required />
|
||||
</label>
|
||||
<label class="field">
|
||||
分组<select v-model="form.group_name" class="select" required>
|
||||
<option v-for="g in groups" :key="g.name" :value="g.name">
|
||||
{{ g.name }}
|
||||
</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="field">
|
||||
设备<select v-model="form.device_id" class="select" required>
|
||||
<option v-for="d in devices" :key="d.id" :value="d.id">
|
||||
{{ d.name }}
|
||||
</option>
|
||||
</select></label
|
||||
><label class="field"
|
||||
>数据类型<select v-model="form.data_type" class="select">
|
||||
</select>
|
||||
</label>
|
||||
<label class="field">
|
||||
数据类型<select v-model="form.data_type" class="select">
|
||||
<option>BOOL</option>
|
||||
<option>INT</option>
|
||||
<option>REAL</option>
|
||||
</select></label
|
||||
>
|
||||
<label class="field"
|
||||
>地址<input
|
||||
</select>
|
||||
</label>
|
||||
<label class="field">
|
||||
地址<input
|
||||
v-model="form.address"
|
||||
class="input"
|
||||
required
|
||||
placeholder="MD540" /></label
|
||||
><label class="field"
|
||||
>单位<input v-model="form.unit" class="input" /></label
|
||||
><label class="field"
|
||||
>回读容差<input
|
||||
v-model.number="form.readback_tolerance"
|
||||
class="input"
|
||||
type="number"
|
||||
min="0"
|
||||
step="any"
|
||||
/></label>
|
||||
<label class="field"
|
||||
><span
|
||||
><input v-model="form.enabled" type="checkbox" /> 启用</span
|
||||
></label
|
||||
><label class="field"
|
||||
><span
|
||||
><input v-model="form.write_enabled" type="checkbox" />
|
||||
允许写入</span
|
||||
></label
|
||||
>
|
||||
placeholder="MD540"
|
||||
/>
|
||||
</label>
|
||||
<label class="field">
|
||||
单位<input v-model="form.unit" class="input" />
|
||||
</label>
|
||||
<label class="check-field check-field-wide">
|
||||
<input v-model="form.write_enabled" type="checkbox" />
|
||||
<span class="check-box" aria-hidden="true"></span>
|
||||
<span class="check-text">允许写入</span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="modal-actions">
|
||||
<button type="button" class="btn" @click="show = false">取消</button
|
||||
><button class="btn btn-primary">保存</button>
|
||||
<button type="button" class="btn" @click="show = false">取消</button>
|
||||
<button class="btn btn-primary">保存</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user