implement UPS

This commit is contained in:
Timi
2026-04-12 22:55:32 +08:00
parent ed2d2ef233
commit b95d7fe9b6
15 changed files with 807 additions and 283 deletions

View File

@@ -119,11 +119,7 @@ import { GridComponent, LegendComponent, TooltipComponent } from "echarts/compon
import VChart from "vue-echarts";
import { useRouter } from "vue-router";
import type { EChartsOption, SeriesOption } from "echarts";
import {
getSystemStatus,
getSystemStatusHistory,
resolveSystemRequestErrorMessage
} from "@/api/SystemAPI";
import SystemAPI from "@/api/SystemAPI";
import type { SystemStatusHistoryPoint, SystemStatusSnapshotView } from "@/types/System";
import type { DashboardHistoryMetric } from "@/store/settingStore";
import { useSettingStore } from "@/store/settingStore";
@@ -624,11 +620,11 @@ async function refreshSnapshot(): Promise<void> {
isSnapshotLoading.value = true;
try {
const metrics = settingStore.dashboard.server.snapshotMetrics.join(",");
snapshotView.value = await getSystemStatus(metrics);
snapshotView.value = await SystemAPI.getStatus(metrics);
} catch (error) {
Toast({
theme: "error",
message: resolveSystemRequestErrorMessage(error)
message: error instanceof Error ? error.message : "请求失败,请稍后重试"
});
} finally {
isSnapshotLoading.value = false;
@@ -644,7 +640,7 @@ async function refreshHistory(): Promise<void> {
isHistoryLoading.value = true;
try {
const metrics = settingStore.dashboard.server.historyMetrics.join(",");
const historyView = await getSystemStatusHistory({
const historyView = await SystemAPI.getStatusHistory({
window: "1h",
metrics
});
@@ -652,7 +648,7 @@ async function refreshHistory(): Promise<void> {
} catch (error) {
Toast({
theme: "error",
message: resolveSystemRequestErrorMessage(error)
message: error instanceof Error ? error.message : "请求失败,请稍后重试"
});
} finally {
isHistoryLoading.value = false;

View File

@@ -28,7 +28,7 @@
<script setup lang="ts">
import { Toast } from "tdesign-mobile-vue";
import { getSystemStatus, resolveSystemRequestErrorMessage } from "@/api/SystemAPI";
import SystemAPI from "@/api/SystemAPI";
import type { SystemStatusSnapshotView } from "@/types/System";
import { Time } from "timi-web";
@@ -65,11 +65,11 @@ async function refreshDetail(): Promise<void> {
}
isLoading.value = true;
try {
snapshotView.value = await getSystemStatus("os,hardware");
snapshotView.value = await SystemAPI.getStatus("os,hardware");
} catch (error) {
Toast({
theme: "error",
message: resolveSystemRequestErrorMessage(error)
message: error instanceof Error ? error.message : "请求失败,请稍后重试"
});
} finally {
isLoading.value = false;

View File

@@ -35,7 +35,7 @@
<script setup lang="ts">
import { Toast } from "tdesign-mobile-vue";
import { getSystemStatus, resolveSystemRequestErrorMessage } from "@/api/SystemAPI";
import SystemAPI from "@/api/SystemAPI";
import type { SystemStatusSnapshot, SystemStatusSnapshotView } from "@/types/System";
import { IOSize, Text } from "timi-web";
@@ -98,11 +98,11 @@ async function refreshDetail(): Promise<void> {
}
isLoading.value = true;
try {
snapshotView.value = await getSystemStatus("storage");
snapshotView.value = await SystemAPI.getStatus("storage");
} catch (error) {
Toast({
theme: "error",
message: resolveSystemRequestErrorMessage(error)
message: error instanceof Error ? error.message : "请求失败,请稍后重试"
});
} finally {
isLoading.value = false;

View File

@@ -57,7 +57,7 @@
<script setup lang="ts">
import { Toast } from "tdesign-mobile-vue";
import { getSystemStatus, resolveSystemRequestErrorMessage } from "@/api/SystemAPI";
import SystemAPI from "@/api/SystemAPI";
import type { SystemStatusSnapshotView } from "@/types/System";
import { IOSize, Text, Time } from "timi-web";
@@ -96,11 +96,11 @@ async function refreshDetail(): Promise<void> {
}
isLoading.value = true;
try {
snapshotView.value = await getSystemStatus("cpu,memory,jvm,network");
snapshotView.value = await SystemAPI.getStatus("cpu,memory,jvm,network");
} catch (error) {
Toast({
theme: "error",
message: resolveSystemRequestErrorMessage(error)
message: error instanceof Error ? error.message : "请求失败,请稍后重试"
});
} finally {
isLoading.value = false;

View File

@@ -1,57 +1,646 @@
<template>
<div class="ups-dashboard">
<section class="card">
<p class="tag">UPS</p>
<h2 class="header">供电监控</h2>
<p class="desc">这里用于展示 UPS 电池状态负载功率剩余续航和告警信息</p>
</section>
<div v-if="isStatusLoading && !statusView" class="loading-wrap">
<t-loading text="加载 UPS 状态..." />
</div>
<template v-else-if="statusView">
<t-cell-group title="UPS 概览" theme="card">
<t-cell title="主机" :note="Text.display(statusView?.hostName)" />
<t-cell title="厂商" :note="Text.display(statusView?.customer)" />
<t-cell title="设备 ID" :note="Text.display(statusView?.deviceId)" />
<t-cell title="UPS 类型" :note="Text.display(statusView?.upsType)" />
<t-cell title="工作模式" :note="Text.display(statusView?.workMode)" />
<t-cell title="数据时间" :note="Time.toPassedDateTime(statusView?.upsTime)" />
<t-cell title="输出状态" :note="Text.displayBool(statusView?.outputOn, '开启', '关闭')" />
<t-cell title="充电状态" :note="Text.displayBool(statusView?.charging, '充电中', '未充电')" />
<t-cell title="旁路状态" :note="Text.displayBool(statusView?.bypassActive, '旁路运行', '正常供电')" />
<t-cell title="关机状态" :note="Text.displayBool(statusView?.shutdownActive, '执行中', '未执行')" />
</t-cell-group>
<t-cell-group class="section energy" title="电池与负载" theme="card">
<t-cell-info label="电池容量" :value="Text.unit(statusView?.batteryCapacity, '%')">
<progress-group mode="heap" :progress="batteryProgressList" />
</t-cell-info>
<t-cell-info label="输出负载" :value="Text.unit(statusView?.outputLoadPercent, '%')">
<progress-group mode="heap" :progress="loadProgressList" />
</t-cell-info>
<t-cell title="剩余时间" :note="Text.unit(statusView?.batteryRemainTime, '分钟')" />
</t-cell-group>
<t-cell-group class="section electric" title="电气参数" theme="card">
<t-cell title="输入电压" :note="Text.unit(statusView?.inputVoltage, 'V')" />
<t-cell title="输入频率" :note="Text.unit(statusView?.inputFrequency, 'Hz')" />
<t-cell title="输出电压" :note="Text.unit(statusView?.outputVoltage, 'V')" />
<t-cell title="输出频率" :note="Text.unit(statusView?.outputFrequency, 'Hz')" />
<t-cell title="电池电压" :note="Text.unit(statusView?.batteryVoltage, 'V')" />
<t-cell title="温度" :note="Text.unit(statusView?.temperature, '℃')" />
</t-cell-group>
<t-cell-group v-if="faultList.length" class="section warning" title="告警与故障" theme="card">
<t-cell v-for="(item, index) in faultList" :key="`${item}-${index}`" :title="item" />
</t-cell-group>
</template>
<t-empty v-else description="暂无 UPS 状态数据" />
<t-cell-group class="section history" title="历史采样" theme="card">
<t-tabs class="metric-tabs" :value="historyMetric" @change="handleHistoryMetricChange">
<t-tab-panel v-for="item in historyMetricTabs" :key="item.value" :value="item.value" :label="item.label" />
</t-tabs>
<div v-if="isHistoryLoading && !historyItems.length" class="loading-wrap">
<t-loading text="加载 UPS 历史..." />
</div>
<template v-else-if="historyItems.length">
<v-chart
ref="chartRef"
class="chart"
:option="historyChartOption"
:update-options="historyChartUpdateOptions"
:autoresize="true"
/>
<div class="slider bg-white">
<span class="label">较早</span>
<t-slider
class="control"
:min="0"
:max="Math.max(historyItems.length - historyVisibleCount, 0)"
:step="1"
:value="historySliderValue"
@change="handleHistorySliderChange"
/>
<span class="label">最新</span>
</div>
<t-cell-group>
<t-cell v-for="item in historySummary" :key="item.label" :title="item.label" :note="item.value" />
</t-cell-group>
</template>
<t-empty v-else description="暂无 UPS 历史数据" />
</t-cell-group>
</div>
</template>
<script setup lang="ts"></script>
<script setup lang="ts">
import { Toast } from "tdesign-mobile-vue";
import { use } from "echarts/core";
import { SVGRenderer } from "echarts/renderers";
import { LineChart } from "echarts/charts";
import { GridComponent, LegendComponent, TooltipComponent } from "echarts/components";
import VChart from "vue-echarts";
import type { EChartsOption, SeriesOption } from "echarts";
import UpsAPI from "@/api/UpsAPI";
import type {
UpsHistoryPointView,
UpsStatusView
} from "@/types/Ups";
import { useSettingStore } from "@/store/settingStore";
import { Text, Time } from "timi-web";
import type { LabelValue } from "timi-web";
import TCellInfo from "@/components/TCellInfo.vue";
import type { ProgressItem } from "@/components/ProgressGroup.vue";
import ProgressGroup from "@/components/ProgressGroup.vue";
type UpsHistoryMetric = "battery" | "load" | "voltage" | "temperature";
use([
SVGRenderer,
LineChart,
GridComponent,
TooltipComponent,
LegendComponent
]);
defineOptions({
name: "UPSDashboard"
});
const settingStore = useSettingStore();
const isStatusLoading = ref(false);
const isHistoryLoading = ref(false);
const statusView = ref<UpsStatusView | null>(null);
const historyPoints = ref<UpsHistoryPointView[]>([]);
const chartRef = ref<InstanceType<typeof VChart> | null>(null);
const historyChartUpdateOptions = Object.freeze({
notMerge: true
});
// ---------- 当前状态 ----------
const batteryProgressList = computed<ProgressItem[]>(() => {
return [
{
color: "var(--td-brand-color)",
value: statusView.value?.batteryCapacity! / 100,
}
];
});
const loadProgressList = computed<ProgressItem[]>(() => {
return [
{
color: "var(--td-warning-color)",
value: statusView.value?.outputLoadPercent! / 100,
}
];
});
const faultList = computed<string[]>(() => {
const list: string[] = [];
const warningList = statusView.value?.warnings || [];
if (statusView.value?.faultType) {
list.push(`故障类型:${statusView.value.faultType}`);
}
if (statusView.value?.faultKind) {
list.push(`故障明细:${statusView.value.faultKind}`);
}
for (const item of warningList) {
list.push(`告警:${item}`);
}
return list;
});
// ---------- 历史采样 ----------
const historyMetric = ref<UpsHistoryMetric>("battery");
const historyMetricTabs = computed(() => {
const labelMap: Record<UpsHistoryMetric, string> = {
battery: "电池",
load: "负载",
voltage: "电压",
temperature: "温度"
};
return (Object.keys(labelMap) as UpsHistoryMetric[]).map((item) => ({
value: item,
label: labelMap[item]
}));
});
function handleHistoryMetricChange(value: string): void {
const nextValue = value as UpsHistoryMetric;
if (nextValue === "battery" || nextValue === "load" || nextValue === "voltage" || nextValue === "temperature") {
historyMetric.value = nextValue;
}
}
const historyItems = computed<UpsHistoryPointView[]>(() => {
return historyPoints.value.slice().sort((left, right) => left.at - right.at);
});
const historySummary = computed<LabelValue<string, any>[]>(() => {
const lastPoint = historyItems.value[historyItems.value.length - 1];
if (!lastPoint) {
return [];
}
if (historyMetric.value === "battery") {
return [
{ label: "容量", value: Text.unit(lastPoint.batteryCapacity, '%') },
{ label: "剩余时间", value: Text.unit(lastPoint.batteryRemainTime, '分钟') }
];
}
if (historyMetric.value === "load") {
return [
{ label: "负载", value: Text.unit(lastPoint.outputLoadPercent, '%') },
{ label: "输出状态", value: Text.displayBool(lastPoint.outputOn, '开启', '关闭') }
];
}
if (historyMetric.value === "voltage") {
return [
{ label: "输入电压", value: Text.unit(lastPoint.inputVoltage, 'V') },
{ label: "输出电压", value: Text.unit(lastPoint.outputVoltage, 'V') }
];
}
return [
{ label: "温度", value: Text.unit(lastPoint.temperature, '℃') },
{ label: "工作模式", value: Text.display(lastPoint.workMode) }
];
});
const isHistorySliderTouched = ref(false);
const historySliderValue = ref(0);
const HISTORY_DEFAULT_VISIBLE_COUNT = 24;
const historyVisibleCount = computed(() => {
if (historyItems.value.length < 1) {
return 1;
}
return Math.min(HISTORY_DEFAULT_VISIBLE_COUNT, historyItems.value.length);
});
const historySliderMaxStart = computed(() => {
return Math.max(historyItems.value.length - historyVisibleCount.value, 0);
});
const currentHistoryRange = computed(() => {
const length = historyItems.value.length;
if (length < 1) {
return { startValue: 0, endValue: 0 };
}
const startValue = Math.min(Math.max(Math.floor(historySliderValue.value), 0), historySliderMaxStart.value);
const endValue = Math.min(startValue + historyVisibleCount.value - 1, length - 1);
return { startValue, endValue };
});
const visibleHistoryItems = computed<UpsHistoryPointView[]>(() => {
if (!historyItems.value.length) {
return [];
}
return historyItems.value.slice(currentHistoryRange.value.startValue, currentHistoryRange.value.endValue + 1);
});
watch(() => historyItems.value.length, (length) => {
if (length < 1) {
historySliderValue.value = 0;
isHistorySliderTouched.value = false;
return;
}
if (!isHistorySliderTouched.value) {
historySliderValue.value = historySliderMaxStart.value;
return;
}
historySliderValue.value = Math.min(historySliderValue.value, historySliderMaxStart.value);
});
function handleHistorySliderChange(value: number): void {
historySliderValue.value = Math.min(Math.max(Math.floor(value), 0), historySliderMaxStart.value);
isHistorySliderTouched.value = true;
}
// ---------- 图表 ----------
const isDarkTheme = ref(false);
let themeObserver: MutationObserver | null = null;
const historyChartOption = computed((): EChartsOption => {
const axisColor = isDarkTheme.value ? "#9aa4b2" : "#7e8a9a";
const lineColor = isDarkTheme.value ? "rgba(255, 255, 255, .12)" : "rgba(18, 42, 66, .08)";
const legendColor = isDarkTheme.value ? "#d7dee8" : "#334155";
const commonOption: EChartsOption = {
tooltip: {
trigger: "axis",
confine: true,
position: (point, _params, _dom, _rect, size) => {
const gap = 6;
const fingerGap = 24;
const contentSize = size?.contentSize || [0, 0];
const viewSize = size?.viewSize || [0, 0];
const maxLeft = Math.max(viewSize[0] - contentSize[0] - gap, gap);
const maxTop = Math.max(viewSize[1] - contentSize[1] - gap, gap);
const clamp = (currentValue: number, min: number, max: number): number => {
return Math.min(Math.max(currentValue, min), max);
};
const preferLeft = point[0] - contentSize[0] - gap;
const preferRight = point[0] + gap;
const left = point[0] < viewSize[0] / 2
? clamp(preferRight, gap, maxLeft)
: clamp(preferLeft, gap, maxLeft);
const preferTop = point[1] - contentSize[1] - fingerGap;
const preferBottom = point[1] + fingerGap;
const top = preferTop >= gap
? clamp(preferTop, gap, maxTop)
: clamp(preferBottom, gap, maxTop);
return [left, top];
}
},
legend: {
top: 10,
textStyle: {
color: legendColor
}
},
grid: {
top: 40,
left: 8,
right: 8,
bottom: 8,
containLabel: true
},
xAxis: {
type: "category",
boundaryGap: false,
data: visibleHistoryItems.value.map((item) => item.at),
axisLabel: {
color: axisColor,
formatter: val => Time.toShortTime(Number(val))
},
axisLine: {
lineStyle: {
color: lineColor
}
}
}
};
if (!historyItems.value.length) {
return commonOption;
}
if (historyMetric.value === "battery") {
return {
...commonOption,
tooltip: {
...commonOption.tooltip,
formatter: (params) => {
const list = Array.isArray(params) ? params : [params];
const title = `时间:${Time.toDateTime(Number(list[0]?.name))}`;
const lines = list.map((item) => {
const seriesName = item.seriesName || "";
if (seriesName === "容量") {
return `${item.marker}${seriesName}: ${Text.unit(Number(item.value), " %")}`;
}
return `${item.marker}${seriesName}: ${Text.unit(Number(item.value), '分钟')}`;
});
return [title, ...lines].join("<br />");
}
},
yAxis: [
{
type: "value",
min: 0,
max: 100,
axisLabel: {
color: axisColor,
formatter: "{value} %"
},
splitLine: {
lineStyle: {
color: lineColor
}
}
},
{
type: "value",
axisLabel: {
color: axisColor,
formatter: "{value} min"
},
splitLine: {
show: false
}
}
],
series: [
resolveLineSeries("容量", visibleHistoryItems.value.map((item) => item.batteryCapacity!)),
resolveLineSeries("剩余时间", visibleHistoryItems.value.map((item) => item.batteryRemainTime || 0), 1)
]
};
}
if (historyMetric.value === "load") {
return {
...commonOption,
tooltip: {
...commonOption.tooltip,
formatter: (params) => {
const list = Array.isArray(params) ? params : [params];
const title = `时间:${Time.toDateTime(Number(list[0]?.name))}`;
const lines = list.map((item) => `${item.marker}${item.seriesName}: ${Text.unit(Number(item.value), " %")}`);
return [title, ...lines].join("<br />");
}
},
yAxis: {
type: "value",
min: 0,
max: 100,
axisLabel: {
color: axisColor,
formatter: "{value} %"
},
splitLine: {
lineStyle: {
color: lineColor
}
}
},
series: [
resolveLineSeries("负载", visibleHistoryItems.value.map((item) => item.outputLoadPercent))
]
};
}
if (historyMetric.value === "voltage") {
return {
...commonOption,
tooltip: {
...commonOption.tooltip,
formatter: (params) => {
const list = Array.isArray(params) ? params : [params];
const title = `时间:${Time.toDateTime(Number(list[0]?.name))}`;
const lines = list.map((item) => `${item.marker}${item.seriesName}: ${Text.unit(Number(item.value), " V")}`);
return [title, ...lines].join("<br />");
}
},
yAxis: {
type: "value",
axisLabel: {
color: axisColor,
formatter: "{value} V"
},
splitLine: {
lineStyle: {
color: lineColor
}
}
},
series: [
resolveLineSeries("输入电压", visibleHistoryItems.value.map((item) => item.inputVoltage || 0)),
resolveLineSeries("输出电压", visibleHistoryItems.value.map((item) => item.outputVoltage || 0))
]
};
}
return {
...commonOption,
tooltip: {
...commonOption.tooltip,
formatter: (params) => {
const list = Array.isArray(params) ? params : [params];
const title = `时间:${Time.toDateTime(Number(list[0]?.name))}`;
const lines = list.map((item) => `${item.marker}${item.seriesName}: ${Text.unit(Number(item.value), " ℃")}`);
return [title, ...lines].join("<br />");
}
},
yAxis: {
type: "value",
axisLabel: {
color: axisColor,
formatter: "{value} ℃"
},
splitLine: {
lineStyle: {
color: lineColor
}
}
},
series: [
resolveLineSeries("温度", visibleHistoryItems.value.map((item) => item.temperature || 0))
]
};
});
function resolveLineSeries(name: string, data: Array<number | null>, yAxisIndex?: number): SeriesOption {
const series: SeriesOption = {
type: "line",
name,
showSymbol: false,
smooth: true,
lineStyle: {
width: 2
},
areaStyle: {
opacity: .18
},
data
};
if (typeof yAxisIndex === "number") {
series.yAxisIndex = yAxisIndex;
}
return series;
}
// ---------- 加载与自动刷新 ----------
let statusTimer: number | null = null;
let historyTimer: number | null = null;
watch(() => settingStore.dashboard.server, restartAutoRefresh, { deep: true });
onMounted(() => {
updateThemeState();
restartAutoRefresh();
themeObserver = new MutationObserver(() => {
updateThemeState();
});
themeObserver.observe(document.documentElement, {
attributes: true,
attributeFilter: ["class"]
});
});
onUnmounted(() => {
clearAutoRefreshTimer();
if (themeObserver) {
themeObserver.disconnect();
themeObserver = null;
}
});
function updateThemeState(): void {
isDarkTheme.value = document.documentElement.classList.contains("theme-dark");
}
async function refreshStatus(): Promise<void> {
if (isStatusLoading.value) {
return;
}
isStatusLoading.value = true;
try {
statusView.value = await UpsAPI.getStatus();
} catch (error) {
Toast({
theme: "error",
message: error instanceof Error ? error.message : "请求失败,请稍后重试"
});
} finally {
isStatusLoading.value = false;
}
}
async function refreshHistory(): Promise<void> {
if (isHistoryLoading.value) {
return;
}
isHistoryLoading.value = true;
try {
const historyView = await UpsAPI.getStatusHistory({
window: "24h"
});
historyPoints.value = historyView.points || [];
} catch (error) {
Toast({
theme: "error",
message: error instanceof Error ? error.message : "请求失败,请稍后重试"
});
} finally {
isHistoryLoading.value = false;
}
}
async function refreshDashboard(): Promise<void> {
await Promise.all([
refreshStatus(),
refreshHistory()
]);
}
function restartAutoRefresh(): void {
void refreshDashboard();
clearAutoRefreshTimer();
const snapshotIntervalMs = Math.max(settingStore.dashboard.server.snapshotRefreshSeconds, 1) * 1000;
const historyIntervalMs = Math.max(settingStore.dashboard.server.historyRefreshSeconds, 1) * 1000;
statusTimer = window.setInterval(() => {
void refreshStatus();
}, snapshotIntervalMs);
historyTimer = window.setInterval(() => {
void refreshHistory();
}, historyIntervalMs);
}
function clearAutoRefreshTimer(): void {
if (statusTimer !== null) {
window.clearInterval(statusTimer);
statusTimer = null;
}
if (historyTimer !== null) {
window.clearInterval(historyTimer);
historyTimer = null;
}
}
</script>
<style scoped lang="less">
.ups-dashboard {
padding: 1rem;
gap: 1rem;
display: flex;
flex-direction: column;
.card {
gap: .6rem;
.section {
&.history {
.metric-tabs {
width: 100%;
--td-tab-track-width: 4rem;
}
.chart {
width: 100%;
height: 16rem;
background: #FFF;
}
.slider {
gap: .5rem;
display: flex;
padding: .5rem 2rem;
align-items: center;
.label {
flex: none;
font-size: .75rem;
color: var(--app-sub);
}
.control {
flex: 1;
}
}
}
}
.loading-wrap {
display: flex;
padding: 1rem;
border-radius: 1rem;
flex-direction: column;
border: 1px solid var(--app-line);
background: var(--app-card);
box-shadow: 0 .35rem 1rem rgba(17, 32, 56, .05);
}
.tag,
.header,
.desc {
margin: 0;
}
.tag,
.desc {
color: var(--app-sub);
}
.tag {
font-size: .875rem;
}
.header {
font-size: 1.1rem;
}
.desc {
line-height: 1.6;
}
}
:global(.theme-dark) .ups-dashboard {
.card {
box-shadow: 0 .35rem 1rem rgba(0, 0, 0, .2);
min-height: 4rem;
align-items: center;
justify-content: center;
}
}
</style>