This commit is contained in:
qsc
2026-07-13 00:44:09 +08:00
parent 34ec65c8b5
commit ef96af7f21
22 changed files with 1827 additions and 587 deletions
+1
View File
@@ -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),
};
+384 -67
View File
@@ -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
View File
@@ -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;
+58 -27
View File
@@ -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
+24 -13
View File
@@ -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
+322 -180
View File
@@ -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>