add Dashboard
This commit is contained in:
@@ -13,11 +13,13 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"axios": "^1.8.4",
|
"axios": "^1.8.4",
|
||||||
"less": "^4.3.0",
|
"less": "^4.3.0",
|
||||||
|
"echarts": "^6.0.0",
|
||||||
|
"vue-echarts": "^8.0.1",
|
||||||
"music-metadata-browser": "^2.5.11",
|
"music-metadata-browser": "^2.5.11",
|
||||||
"pinia": "^3.0.2",
|
"pinia": "^3.0.2",
|
||||||
"tdesign-mobile-vue": "^1.13.2",
|
"tdesign-mobile-vue": "^1.13.2",
|
||||||
"timi-tdesign-mobile": "0.0.2",
|
"timi-tdesign-mobile": "0.0.2",
|
||||||
"timi-web": "0.0.1",
|
"timi-web": "0.0.3",
|
||||||
"ts-node": "^10.9.2",
|
"ts-node": "^10.9.2",
|
||||||
"vue": "^3.5.16",
|
"vue": "^3.5.16",
|
||||||
"vue-router": "4.5.1",
|
"vue-router": "4.5.1",
|
||||||
|
|||||||
47
pnpm-lock.yaml
generated
47
pnpm-lock.yaml
generated
@@ -11,6 +11,9 @@ importers:
|
|||||||
axios:
|
axios:
|
||||||
specifier: ^1.8.4
|
specifier: ^1.8.4
|
||||||
version: 1.14.0
|
version: 1.14.0
|
||||||
|
echarts:
|
||||||
|
specifier: ^6.0.0
|
||||||
|
version: 6.0.0
|
||||||
less:
|
less:
|
||||||
specifier: ^4.3.0
|
specifier: ^4.3.0
|
||||||
version: 4.6.4
|
version: 4.6.4
|
||||||
@@ -27,14 +30,17 @@ importers:
|
|||||||
specifier: 0.0.2
|
specifier: 0.0.2
|
||||||
version: 0.0.2(typescript@5.8.3)
|
version: 0.0.2(typescript@5.8.3)
|
||||||
timi-web:
|
timi-web:
|
||||||
specifier: 0.0.1
|
specifier: 0.0.3
|
||||||
version: 0.0.1
|
version: 0.0.3
|
||||||
ts-node:
|
ts-node:
|
||||||
specifier: ^10.9.2
|
specifier: ^10.9.2
|
||||||
version: 10.9.2(@types/node@24.12.0)(typescript@5.8.3)
|
version: 10.9.2(@types/node@24.12.0)(typescript@5.8.3)
|
||||||
vue:
|
vue:
|
||||||
specifier: ^3.5.16
|
specifier: ^3.5.16
|
||||||
version: 3.5.31(typescript@5.8.3)
|
version: 3.5.31(typescript@5.8.3)
|
||||||
|
vue-echarts:
|
||||||
|
specifier: ^8.0.1
|
||||||
|
version: 8.0.1(echarts@6.0.0)(vue@3.5.31(typescript@5.8.3))
|
||||||
vue-router:
|
vue-router:
|
||||||
specifier: 4.5.1
|
specifier: 4.5.1
|
||||||
version: 4.5.1(vue@3.5.31(typescript@5.8.3))
|
version: 4.5.1(vue@3.5.31(typescript@5.8.3))
|
||||||
@@ -946,6 +952,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==}
|
resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==}
|
||||||
engines: {node: '>= 0.4'}
|
engines: {node: '>= 0.4'}
|
||||||
|
|
||||||
|
echarts@6.0.0:
|
||||||
|
resolution: {integrity: sha512-Tte/grDQRiETQP4xz3iZWSvoHrkCQtwqd6hs+mifXcjrCuo2iKWbajFObuLJVBlDIJlOzgQPd1hsaKt/3+OMkQ==}
|
||||||
|
|
||||||
ee-first@1.1.1:
|
ee-first@1.1.1:
|
||||||
resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==}
|
resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==}
|
||||||
|
|
||||||
@@ -1823,8 +1832,8 @@ packages:
|
|||||||
resolution: {integrity: sha512-R24yS4ovWTadCmpqJz3ajikMgk8YkKiUFbOIHuw3DBaJc2NwUcvVtY7aPu6kqnT/4j4AN3DgUFY+WkZbxqEhgQ==}
|
resolution: {integrity: sha512-R24yS4ovWTadCmpqJz3ajikMgk8YkKiUFbOIHuw3DBaJc2NwUcvVtY7aPu6kqnT/4j4AN3DgUFY+WkZbxqEhgQ==}
|
||||||
engines: {node: '>=16.0.0'}
|
engines: {node: '>=16.0.0'}
|
||||||
|
|
||||||
timi-web@0.0.1:
|
timi-web@0.0.3:
|
||||||
resolution: {integrity: sha512-xYGnbkEh4y9ZHEOLDU/MetXBxPyoO8vQCsPvdGNMd6x/YeWFM0YRsMNC1zXzoaE8riCnMW436VooICfEbstfOQ==}
|
resolution: {integrity: sha512-uCJ+XQf1DydvnZHyI5NmQxKmZWvkVNPRrhqEszhMEh4CKGMPG7wrNiTl6jAPDcY3x228AaRP5MPu6TzIs9GfFQ==}
|
||||||
engines: {node: '>=16.0.0'}
|
engines: {node: '>=16.0.0'}
|
||||||
|
|
||||||
tinycolor2@1.6.0:
|
tinycolor2@1.6.0:
|
||||||
@@ -1866,6 +1875,9 @@ packages:
|
|||||||
'@swc/wasm':
|
'@swc/wasm':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
tslib@2.3.0:
|
||||||
|
resolution: {integrity: sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==}
|
||||||
|
|
||||||
tslib@2.8.1:
|
tslib@2.8.1:
|
||||||
resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
|
resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
|
||||||
|
|
||||||
@@ -2011,6 +2023,12 @@ packages:
|
|||||||
'@vue/composition-api':
|
'@vue/composition-api':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
vue-echarts@8.0.1:
|
||||||
|
resolution: {integrity: sha512-23rJTFLu1OUEGRWjJGmdGt8fP+8+ja1gVgzMYPIPaHWpXegcO1viIAaeu2H4QHESlVeHzUAHIxKXGrwjsyXAaA==}
|
||||||
|
peerDependencies:
|
||||||
|
echarts: ^6.0.0
|
||||||
|
vue: ^3.3.0
|
||||||
|
|
||||||
vue-eslint-parser@10.4.0:
|
vue-eslint-parser@10.4.0:
|
||||||
resolution: {integrity: sha512-Vxi9pJdbN3ZnVGLODVtZ7y4Y2kzAAE2Cm0CZ3ZDRvydVYxZ6VrnBhLikBsRS+dpwj4Jv4UCv21PTEwF5rQ9WXg==}
|
resolution: {integrity: sha512-Vxi9pJdbN3ZnVGLODVtZ7y4Y2kzAAE2Cm0CZ3ZDRvydVYxZ6VrnBhLikBsRS+dpwj4Jv4UCv21PTEwF5rQ9WXg==}
|
||||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||||
@@ -2097,6 +2115,9 @@ packages:
|
|||||||
zod@3.25.76:
|
zod@3.25.76:
|
||||||
resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==}
|
resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==}
|
||||||
|
|
||||||
|
zrender@6.0.0:
|
||||||
|
resolution: {integrity: sha512-41dFXEEXuJpNecuUQq6JlbybmnHaqqpGlbH1yxnA5V9MMP4SbohSVZsJIwz+zdjQXSSlR1Vc34EgH1zxyTDvhg==}
|
||||||
|
|
||||||
snapshots:
|
snapshots:
|
||||||
|
|
||||||
'@babel/helper-string-parser@7.27.1': {}
|
'@babel/helper-string-parser@7.27.1': {}
|
||||||
@@ -2888,6 +2909,11 @@ snapshots:
|
|||||||
es-errors: 1.3.0
|
es-errors: 1.3.0
|
||||||
gopd: 1.2.0
|
gopd: 1.2.0
|
||||||
|
|
||||||
|
echarts@6.0.0:
|
||||||
|
dependencies:
|
||||||
|
tslib: 2.3.0
|
||||||
|
zrender: 6.0.0
|
||||||
|
|
||||||
ee-first@1.1.1: {}
|
ee-first@1.1.1: {}
|
||||||
|
|
||||||
encodeurl@2.0.0: {}
|
encodeurl@2.0.0: {}
|
||||||
@@ -3849,7 +3875,7 @@ snapshots:
|
|||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- typescript
|
- typescript
|
||||||
|
|
||||||
timi-web@0.0.1:
|
timi-web@0.0.3:
|
||||||
dependencies:
|
dependencies:
|
||||||
axios: 1.13.5
|
axios: 1.13.5
|
||||||
less: 4.5.1
|
less: 4.5.1
|
||||||
@@ -3902,6 +3928,8 @@ snapshots:
|
|||||||
v8-compile-cache-lib: 3.0.1
|
v8-compile-cache-lib: 3.0.1
|
||||||
yn: 3.1.1
|
yn: 3.1.1
|
||||||
|
|
||||||
|
tslib@2.3.0: {}
|
||||||
|
|
||||||
tslib@2.8.1: {}
|
tslib@2.8.1: {}
|
||||||
|
|
||||||
type-check@0.4.0:
|
type-check@0.4.0:
|
||||||
@@ -4023,6 +4051,11 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
vue: 3.5.31(typescript@5.8.3)
|
vue: 3.5.31(typescript@5.8.3)
|
||||||
|
|
||||||
|
vue-echarts@8.0.1(echarts@6.0.0)(vue@3.5.31(typescript@5.8.3)):
|
||||||
|
dependencies:
|
||||||
|
echarts: 6.0.0
|
||||||
|
vue: 3.5.31(typescript@5.8.3)
|
||||||
|
|
||||||
vue-eslint-parser@10.4.0(eslint@9.26.0):
|
vue-eslint-parser@10.4.0(eslint@9.26.0):
|
||||||
dependencies:
|
dependencies:
|
||||||
debug: 4.4.3
|
debug: 4.4.3
|
||||||
@@ -4110,3 +4143,7 @@ snapshots:
|
|||||||
zod: 3.25.76
|
zod: 3.25.76
|
||||||
|
|
||||||
zod@3.25.76: {}
|
zod@3.25.76: {}
|
||||||
|
|
||||||
|
zrender@6.0.0:
|
||||||
|
dependencies:
|
||||||
|
tslib: 2.3.0
|
||||||
|
|||||||
194
src/api/system.ts
Normal file
194
src/api/system.ts
Normal file
@@ -0,0 +1,194 @@
|
|||||||
|
import axios, { AxiosError } from "axios";
|
||||||
|
import { useSettingStore } from "@/store/settingStore";
|
||||||
|
|
||||||
|
interface ApiResponse<T> {
|
||||||
|
code?: number | string;
|
||||||
|
data?: T;
|
||||||
|
msg?: string;
|
||||||
|
message?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SystemStatusSnapshotView {
|
||||||
|
serverTime: number;
|
||||||
|
sampleRateMs: number;
|
||||||
|
snapshot: SystemStatusSnapshot;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SystemStatusHistoryView {
|
||||||
|
serverTime: number;
|
||||||
|
sampleRateMs: number;
|
||||||
|
from: number;
|
||||||
|
to: number;
|
||||||
|
points: SystemStatusHistoryPoint[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SystemStatusSnapshot {
|
||||||
|
os?: {
|
||||||
|
name?: string;
|
||||||
|
bootAt?: number;
|
||||||
|
uptimeMs?: number;
|
||||||
|
};
|
||||||
|
cpu?: {
|
||||||
|
model?: string;
|
||||||
|
physicalCores?: number;
|
||||||
|
logicalCores?: number;
|
||||||
|
usagePercent?: number | null;
|
||||||
|
systemPercent?: number | null;
|
||||||
|
temperatureCelsius?: number;
|
||||||
|
};
|
||||||
|
memory?: {
|
||||||
|
totalBytes?: number;
|
||||||
|
usedBytes?: number | null;
|
||||||
|
usagePercent?: number | null;
|
||||||
|
swapTotalBytes?: number;
|
||||||
|
swapUsedBytes?: number | null;
|
||||||
|
};
|
||||||
|
jvm?: {
|
||||||
|
name?: string;
|
||||||
|
version?: string;
|
||||||
|
bootAt?: number;
|
||||||
|
heapInitBytes?: number;
|
||||||
|
heapMaxBytes?: number;
|
||||||
|
heapUsedBytes?: number | null;
|
||||||
|
heapCommittedBytes?: number | null;
|
||||||
|
gc?: {
|
||||||
|
collector?: string;
|
||||||
|
cycleCount?: number;
|
||||||
|
pauseCount?: number;
|
||||||
|
lastPauseAt?: number;
|
||||||
|
lastRecoveredBytes?: number;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
network?: {
|
||||||
|
interfaceName?: string;
|
||||||
|
mac?: string;
|
||||||
|
rxBytesPerSecond?: number;
|
||||||
|
txBytesPerSecond?: number;
|
||||||
|
rxTotalBytes?: number;
|
||||||
|
txTotalBytes?: number;
|
||||||
|
rxPacketsTotal?: number;
|
||||||
|
txPacketsTotal?: number;
|
||||||
|
inErrors?: number;
|
||||||
|
outErrors?: number;
|
||||||
|
inDrops?: number;
|
||||||
|
collisions?: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SystemStatusHistoryPoint {
|
||||||
|
at: number;
|
||||||
|
cpuUsagePercent?: number | null;
|
||||||
|
cpuSystemPercent?: number | null;
|
||||||
|
memoryUsedBytes?: number | null;
|
||||||
|
swapUsedBytes?: number | null;
|
||||||
|
heapUsedBytes?: number | null;
|
||||||
|
heapCommittedBytes?: number | null;
|
||||||
|
gcCycleTimeMs?: number | null;
|
||||||
|
gcPauseTimeMs?: number | null;
|
||||||
|
rxBytesPerSecond?: number | null;
|
||||||
|
txBytesPerSecond?: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getSystemStatus(metrics?: string): Promise<SystemStatusSnapshotView> {
|
||||||
|
const response = await axios.get<ApiResponse<SystemStatusSnapshotView> | SystemStatusSnapshotView>(`${resolveBaseURL()}/system/server/status`, {
|
||||||
|
params: {
|
||||||
|
...buildQueryParams(),
|
||||||
|
...(metrics ? { metrics } : {})
|
||||||
|
},
|
||||||
|
timeout: 15000
|
||||||
|
});
|
||||||
|
|
||||||
|
return unwrapResponse(response.data);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getSystemStatusHistory(params?: {
|
||||||
|
window?: string;
|
||||||
|
metrics?: string;
|
||||||
|
}): Promise<SystemStatusHistoryView> {
|
||||||
|
const response = await axios.get<ApiResponse<SystemStatusHistoryView> | SystemStatusHistoryView>(`${resolveBaseURL()}/system/server/status/history`, {
|
||||||
|
params: {
|
||||||
|
...buildQueryParams(),
|
||||||
|
...(params?.window ? { window: params.window } : {}),
|
||||||
|
...(params?.metrics ? { metrics: params.metrics } : {})
|
||||||
|
},
|
||||||
|
timeout: 15000
|
||||||
|
});
|
||||||
|
|
||||||
|
return unwrapResponse(response.data);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveSystemRequestErrorMessage(error: unknown): string {
|
||||||
|
if (error instanceof AxiosError) {
|
||||||
|
const message = resolveApiMessage(error.response?.data);
|
||||||
|
if (message) {
|
||||||
|
return message;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error.message) {
|
||||||
|
return error.message;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error instanceof Error && error.message) {
|
||||||
|
return error.message;
|
||||||
|
}
|
||||||
|
|
||||||
|
return "请求失败,请稍后重试";
|
||||||
|
}
|
||||||
|
|
||||||
|
function unwrapResponse<T>(payload: ApiResponse<T> | T): T {
|
||||||
|
if (payload && typeof payload === "object" && "data" in payload) {
|
||||||
|
const data = (payload as ApiResponse<T>).data;
|
||||||
|
if (data !== undefined) {
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (payload && typeof payload === "object" && ("msg" in payload || "message" in payload)) {
|
||||||
|
throw new Error(resolveApiMessage(payload) || "接口返回为空");
|
||||||
|
}
|
||||||
|
|
||||||
|
return payload as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveBaseURL(): string {
|
||||||
|
const settingStore = useSettingStore();
|
||||||
|
const connect = settingStore.connect;
|
||||||
|
const envBaseURL = typeof import.meta.env.VITE_API === "string" ? import.meta.env.VITE_API.trim() : "";
|
||||||
|
|
||||||
|
if (connect.host && connect.port) {
|
||||||
|
return `${connect.protocol}://${connect.host}:${connect.port}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return envBaseURL.replace(/\/+$/, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildQueryParams(): Record<string, string> {
|
||||||
|
const settingStore = useSettingStore();
|
||||||
|
const token = settingStore.connect.token.trim();
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
token
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveApiMessage(payload: unknown): string {
|
||||||
|
if (!payload || typeof payload !== "object") {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
const { msg, message } = payload as ApiResponse<unknown>;
|
||||||
|
if (typeof msg === "string" && msg.trim()) {
|
||||||
|
return msg.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof message === "string" && message.trim()) {
|
||||||
|
return message.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
return "";
|
||||||
|
}
|
||||||
57
src/pages/dashboard/DockerDashboard/DockerDashboard.vue
Normal file
57
src/pages/dashboard/DockerDashboard/DockerDashboard.vue
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
<template>
|
||||||
|
<div class="docker-dashboard">
|
||||||
|
<section class="card">
|
||||||
|
<p class="tag">Docker</p>
|
||||||
|
<h2 class="title">容器状态</h2>
|
||||||
|
<p class="desc">这里用于承载容器列表、运行状态、镜像占用和资源使用情况。</p>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts"></script>
|
||||||
|
|
||||||
|
<style scoped lang="less">
|
||||||
|
.docker-dashboard {
|
||||||
|
padding: 1rem;
|
||||||
|
|
||||||
|
.card {
|
||||||
|
gap: .6rem;
|
||||||
|
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,
|
||||||
|
.title,
|
||||||
|
.desc {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tag,
|
||||||
|
.desc {
|
||||||
|
color: var(--app-sub);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tag {
|
||||||
|
font-size: .875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
font-size: 1.1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.desc {
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.theme-dark) .docker-dashboard {
|
||||||
|
.card {
|
||||||
|
box-shadow: 0 .35rem 1rem rgba(0, 0, 0, .2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
653
src/pages/dashboard/ServerDashboard/ServerDashboard.vue
Normal file
653
src/pages/dashboard/ServerDashboard/ServerDashboard.vue
Normal file
@@ -0,0 +1,653 @@
|
|||||||
|
<template>
|
||||||
|
<div class="server-dashboard">
|
||||||
|
<section class="card">
|
||||||
|
<div class="head">
|
||||||
|
<h3 class="sub-title">当前状态</h3>
|
||||||
|
<t-button size="small" variant="outline" :loading="isLoading" @click="refreshDashboard">
|
||||||
|
刷新
|
||||||
|
</t-button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="isLoading && !snapshotView" class="loading-wrap">
|
||||||
|
<t-loading text="加载系统状态..." />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else-if="snapshotView" class="metrics">
|
||||||
|
<div class="metric-item">
|
||||||
|
<span class="metric-label">操作系统</span>
|
||||||
|
<span class="metric-value" v-text="osNameText" />
|
||||||
|
</div>
|
||||||
|
<div class="metric-item">
|
||||||
|
<span class="metric-label">CPU 使用率</span>
|
||||||
|
<span class="metric-value" v-text="cpuUsageText" />
|
||||||
|
</div>
|
||||||
|
<div class="metric-item">
|
||||||
|
<span class="metric-label">CPU 温度</span>
|
||||||
|
<span class="metric-value" v-text="cpuTemperatureText" />
|
||||||
|
</div>
|
||||||
|
<div class="metric-item">
|
||||||
|
<span class="metric-label">内存使用</span>
|
||||||
|
<span class="metric-value" v-text="memoryUsageText" />
|
||||||
|
</div>
|
||||||
|
<div class="metric-item">
|
||||||
|
<span class="metric-label">JVM 堆内存</span>
|
||||||
|
<span class="metric-value" v-text="heapUsageText" />
|
||||||
|
</div>
|
||||||
|
<div class="metric-item">
|
||||||
|
<span class="metric-label">网络吞吐</span>
|
||||||
|
<span class="metric-value" v-text="networkThroughputText" />
|
||||||
|
</div>
|
||||||
|
<div class="metric-item">
|
||||||
|
<span class="metric-label">系统运行时长</span>
|
||||||
|
<span class="metric-value" v-text="uptimeText" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<t-empty v-else description="暂无系统状态数据" />
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="card">
|
||||||
|
<div class="head">
|
||||||
|
<h3 class="sub-title">历史采样(30 分钟)</h3>
|
||||||
|
<t-tabs class="metric-tabs" :value="historyMetric" @change="handleHistoryMetricChange">
|
||||||
|
<t-tab-panel value="cpu" label="CPU" />
|
||||||
|
<t-tab-panel value="memory" label="内存" />
|
||||||
|
<t-tab-panel value="network" label="网络" />
|
||||||
|
</t-tabs>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="isLoading && !historyItems.length" class="loading-wrap">
|
||||||
|
<t-loading text="加载历史数据..." />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<v-chart
|
||||||
|
v-else-if="historyItems.length"
|
||||||
|
ref="historyChartRef"
|
||||||
|
class="history-chart"
|
||||||
|
:option="historyChartOption"
|
||||||
|
:autoresize="true"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<t-empty v-else description="暂无历史采样数据" />
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<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 {
|
||||||
|
getSystemStatus,
|
||||||
|
getSystemStatusHistory,
|
||||||
|
resolveSystemRequestErrorMessage,
|
||||||
|
type SystemStatusHistoryPoint,
|
||||||
|
type SystemStatusSnapshotView
|
||||||
|
} from "@/api/system";
|
||||||
|
import type { EChartsOption } from "echarts";
|
||||||
|
|
||||||
|
interface RenderHistoryItem {
|
||||||
|
at: number;
|
||||||
|
atText: string;
|
||||||
|
cpuUsage: number | null;
|
||||||
|
memoryUsed: number | null;
|
||||||
|
rxSpeed: number | null;
|
||||||
|
txSpeed: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
type HistoryMetric = "cpu" | "memory" | "network";
|
||||||
|
|
||||||
|
use([
|
||||||
|
SVGRenderer,
|
||||||
|
LineChart,
|
||||||
|
GridComponent,
|
||||||
|
TooltipComponent,
|
||||||
|
LegendComponent
|
||||||
|
]);
|
||||||
|
|
||||||
|
const isLoading = ref(false);
|
||||||
|
const snapshotView = ref<SystemStatusSnapshotView | null>(null);
|
||||||
|
const historyPoints = ref<SystemStatusHistoryPoint[]>([]);
|
||||||
|
const historyMetric = ref<HistoryMetric>("cpu");
|
||||||
|
const historyChartRef = ref<InstanceType<typeof VChart> | null>(null);
|
||||||
|
const isDarkTheme = ref(false);
|
||||||
|
let themeObserver: MutationObserver | null = null;
|
||||||
|
|
||||||
|
const dateTimeFormatter = new Intl.DateTimeFormat("zh-CN", {
|
||||||
|
month: "2-digit",
|
||||||
|
day: "2-digit",
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
second: "2-digit",
|
||||||
|
hour12: false
|
||||||
|
});
|
||||||
|
|
||||||
|
const osNameText = computed(() => snapshotView.value?.snapshot?.os?.name || "--");
|
||||||
|
|
||||||
|
const cpuUsageText = computed(() => {
|
||||||
|
const usage = snapshotView.value?.snapshot?.cpu?.usagePercent;
|
||||||
|
return formatPercentText(usage);
|
||||||
|
});
|
||||||
|
|
||||||
|
const cpuTemperatureText = computed(() => {
|
||||||
|
const temperature = snapshotView.value?.snapshot?.cpu?.temperatureCelsius;
|
||||||
|
if (typeof temperature !== "number" || Number.isNaN(temperature)) {
|
||||||
|
return "--";
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${temperature.toFixed(1)} °C`;
|
||||||
|
});
|
||||||
|
|
||||||
|
const memoryUsageText = computed(() => {
|
||||||
|
const memory = snapshotView.value?.snapshot?.memory;
|
||||||
|
const usedBytes = memory?.usedBytes;
|
||||||
|
const totalBytes = memory?.totalBytes;
|
||||||
|
if (typeof usedBytes !== "number" || typeof totalBytes !== "number" || totalBytes < 1) {
|
||||||
|
return "--";
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${formatBytes(usedBytes)} / ${formatBytes(totalBytes)} (${formatPercentText(memory.usagePercent)})`;
|
||||||
|
});
|
||||||
|
|
||||||
|
const heapUsageText = computed(() => {
|
||||||
|
const jvm = snapshotView.value?.snapshot?.jvm;
|
||||||
|
const usedBytes = jvm?.heapUsedBytes;
|
||||||
|
const maxBytes = jvm?.heapMaxBytes;
|
||||||
|
if (typeof usedBytes !== "number" || typeof maxBytes !== "number" || maxBytes < 1) {
|
||||||
|
return "--";
|
||||||
|
}
|
||||||
|
|
||||||
|
const usagePercent = usedBytes / maxBytes * 100;
|
||||||
|
return `${formatBytes(usedBytes)} / ${formatBytes(maxBytes)} (${formatPercentText(usagePercent)})`;
|
||||||
|
});
|
||||||
|
|
||||||
|
const networkThroughputText = computed(() => {
|
||||||
|
const network = snapshotView.value?.snapshot?.network;
|
||||||
|
const rx = network?.rxBytesPerSecond;
|
||||||
|
const tx = network?.txBytesPerSecond;
|
||||||
|
if (typeof rx !== "number" || typeof tx !== "number") {
|
||||||
|
return "--";
|
||||||
|
}
|
||||||
|
|
||||||
|
return `↓ ${formatSpeed(rx)} ↑ ${formatSpeed(tx)}`;
|
||||||
|
});
|
||||||
|
|
||||||
|
const uptimeText = computed(() => {
|
||||||
|
const uptime = snapshotView.value?.snapshot?.os?.uptimeMs;
|
||||||
|
if (typeof uptime !== "number" || uptime < 0) {
|
||||||
|
return "--";
|
||||||
|
}
|
||||||
|
|
||||||
|
return formatDuration(uptime);
|
||||||
|
});
|
||||||
|
|
||||||
|
const historyItems = computed<RenderHistoryItem[]>(() => {
|
||||||
|
return historyPoints.value
|
||||||
|
.slice()
|
||||||
|
.sort((left, right) => right.at - left.at)
|
||||||
|
.slice(0, 12)
|
||||||
|
.map((point) => ({
|
||||||
|
at: point.at,
|
||||||
|
atText: formatDateTime(point.at),
|
||||||
|
cpuUsage: normalizeNumber(point.cpuUsagePercent),
|
||||||
|
memoryUsed: normalizeNumber(point.memoryUsedBytes),
|
||||||
|
rxSpeed: normalizeNumber(point.rxBytesPerSecond),
|
||||||
|
txSpeed: normalizeNumber(point.txBytesPerSecond)
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
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";
|
||||||
|
|
||||||
|
if (historyMetric.value === "cpu") {
|
||||||
|
return {
|
||||||
|
tooltip: {
|
||||||
|
trigger: "axis"
|
||||||
|
},
|
||||||
|
grid: {
|
||||||
|
top: 40,
|
||||||
|
left: 8,
|
||||||
|
right: 8,
|
||||||
|
bottom: 8,
|
||||||
|
containLabel: true
|
||||||
|
},
|
||||||
|
xAxis: {
|
||||||
|
type: "category",
|
||||||
|
boundaryGap: false,
|
||||||
|
data: historyItems.value.map((item) => item.atText),
|
||||||
|
axisLabel: {
|
||||||
|
color: axisColor
|
||||||
|
},
|
||||||
|
axisLine: {
|
||||||
|
lineStyle: {
|
||||||
|
color: lineColor
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
yAxis: {
|
||||||
|
type: "value",
|
||||||
|
min: 0,
|
||||||
|
max: 100,
|
||||||
|
axisLabel: {
|
||||||
|
color: axisColor,
|
||||||
|
formatter: "{value}%"
|
||||||
|
},
|
||||||
|
splitLine: {
|
||||||
|
lineStyle: {
|
||||||
|
color: lineColor
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
series: [
|
||||||
|
{
|
||||||
|
type: "line",
|
||||||
|
name: "CPU 使用率",
|
||||||
|
showSymbol: false,
|
||||||
|
smooth: true,
|
||||||
|
lineStyle: {
|
||||||
|
width: 2
|
||||||
|
},
|
||||||
|
areaStyle: {
|
||||||
|
opacity: .18
|
||||||
|
},
|
||||||
|
data: historyItems.value.map((item) => item.cpuUsage)
|
||||||
|
}
|
||||||
|
]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (historyMetric.value === "memory") {
|
||||||
|
return {
|
||||||
|
tooltip: {
|
||||||
|
trigger: "axis"
|
||||||
|
},
|
||||||
|
grid: {
|
||||||
|
top: 40,
|
||||||
|
left: 8,
|
||||||
|
right: 8,
|
||||||
|
bottom: 8,
|
||||||
|
containLabel: true
|
||||||
|
},
|
||||||
|
xAxis: {
|
||||||
|
type: "category",
|
||||||
|
boundaryGap: false,
|
||||||
|
data: historyItems.value.map((item) => item.atText),
|
||||||
|
axisLabel: {
|
||||||
|
color: axisColor
|
||||||
|
},
|
||||||
|
axisLine: {
|
||||||
|
lineStyle: {
|
||||||
|
color: lineColor
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
yAxis: {
|
||||||
|
type: "value",
|
||||||
|
axisLabel: {
|
||||||
|
color: axisColor,
|
||||||
|
formatter: (value: number) => formatBytes(value)
|
||||||
|
},
|
||||||
|
splitLine: {
|
||||||
|
lineStyle: {
|
||||||
|
color: lineColor
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
series: [
|
||||||
|
{
|
||||||
|
type: "line",
|
||||||
|
name: "内存使用",
|
||||||
|
showSymbol: false,
|
||||||
|
smooth: true,
|
||||||
|
lineStyle: {
|
||||||
|
width: 2
|
||||||
|
},
|
||||||
|
areaStyle: {
|
||||||
|
opacity: .18
|
||||||
|
},
|
||||||
|
data: historyItems.value.map((item) => item.memoryUsed)
|
||||||
|
}
|
||||||
|
]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
tooltip: {
|
||||||
|
trigger: "axis"
|
||||||
|
},
|
||||||
|
legend: {
|
||||||
|
top: 0,
|
||||||
|
textStyle: {
|
||||||
|
color: legendColor
|
||||||
|
}
|
||||||
|
},
|
||||||
|
grid: {
|
||||||
|
top: 40,
|
||||||
|
left: 8,
|
||||||
|
right: 8,
|
||||||
|
bottom: 8,
|
||||||
|
containLabel: true
|
||||||
|
},
|
||||||
|
xAxis: {
|
||||||
|
type: "category",
|
||||||
|
boundaryGap: false,
|
||||||
|
data: historyItems.value.map((item) => item.atText),
|
||||||
|
axisLabel: {
|
||||||
|
color: axisColor
|
||||||
|
},
|
||||||
|
axisLine: {
|
||||||
|
lineStyle: {
|
||||||
|
color: lineColor
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
yAxis: {
|
||||||
|
type: "value",
|
||||||
|
axisLabel: {
|
||||||
|
color: axisColor,
|
||||||
|
formatter: (value: number) => `${formatBytes(value)}/s`
|
||||||
|
},
|
||||||
|
splitLine: {
|
||||||
|
lineStyle: {
|
||||||
|
color: lineColor
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
series: [
|
||||||
|
{
|
||||||
|
type: "line",
|
||||||
|
name: "接收",
|
||||||
|
showSymbol: false,
|
||||||
|
smooth: true,
|
||||||
|
lineStyle: {
|
||||||
|
width: 2
|
||||||
|
},
|
||||||
|
data: historyItems.value.map((item) => item.rxSpeed)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "line",
|
||||||
|
name: "发送",
|
||||||
|
showSymbol: false,
|
||||||
|
smooth: true,
|
||||||
|
lineStyle: {
|
||||||
|
width: 2
|
||||||
|
},
|
||||||
|
data: historyItems.value.map((item) => item.txSpeed)
|
||||||
|
}
|
||||||
|
]
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
syncTheme();
|
||||||
|
window.addEventListener("resize", handleWindowResize);
|
||||||
|
themeObserver = new MutationObserver(() => {
|
||||||
|
syncTheme();
|
||||||
|
});
|
||||||
|
themeObserver.observe(document.documentElement, {
|
||||||
|
attributes: true,
|
||||||
|
attributeFilter: ["class"]
|
||||||
|
});
|
||||||
|
void refreshDashboard();
|
||||||
|
});
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
window.removeEventListener("resize", handleWindowResize);
|
||||||
|
themeObserver?.disconnect();
|
||||||
|
themeObserver = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
async function refreshDashboard(): Promise<void> {
|
||||||
|
if (isLoading.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
isLoading.value = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const [statusView, historyView] = await Promise.all([
|
||||||
|
getSystemStatus("os,cpu,memory,network,jvm"),
|
||||||
|
getSystemStatusHistory({
|
||||||
|
window: "30m",
|
||||||
|
metrics: "cpu,memory,network"
|
||||||
|
})
|
||||||
|
]);
|
||||||
|
|
||||||
|
snapshotView.value = statusView;
|
||||||
|
historyPoints.value = historyView.points || [];
|
||||||
|
} catch (error) {
|
||||||
|
Toast({
|
||||||
|
theme: "error",
|
||||||
|
message: resolveSystemRequestErrorMessage(error)
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
isLoading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleHistoryMetricChange(value: string): void {
|
||||||
|
const nextValue = value as HistoryMetric;
|
||||||
|
if (nextValue === "cpu" || nextValue === "memory" || nextValue === "network") {
|
||||||
|
historyMetric.value = nextValue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncTheme(): void {
|
||||||
|
const htmlDark = document.documentElement.classList.contains("theme-dark");
|
||||||
|
const bodyDark = document.body.classList.contains("theme-dark");
|
||||||
|
isDarkTheme.value = htmlDark || bodyDark;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleWindowResize(): void {
|
||||||
|
historyChartRef.value?.resize();
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDateTime(value?: number): string {
|
||||||
|
if (!value) {
|
||||||
|
return "--";
|
||||||
|
}
|
||||||
|
|
||||||
|
return dateTimeFormatter.format(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatPercentText(value?: number | null): string {
|
||||||
|
if (typeof value !== "number" || Number.isNaN(value)) {
|
||||||
|
return "--";
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${value.toFixed(1)}%`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatSpeed(value?: number | null): string {
|
||||||
|
if (typeof value !== "number" || Number.isNaN(value) || value < 0) {
|
||||||
|
return "--";
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${formatBytes(value)}/s`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatBytes(value?: number | null): string {
|
||||||
|
if (typeof value !== "number" || Number.isNaN(value) || value < 0) {
|
||||||
|
return "--";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (value < 1024) {
|
||||||
|
return `${value} B`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const units = ["KB", "MB", "GB", "TB", "PB"];
|
||||||
|
let nextValue = value / 1024;
|
||||||
|
let index = 0;
|
||||||
|
|
||||||
|
while (1024 <= nextValue && index < units.length - 1) {
|
||||||
|
nextValue /= 1024;
|
||||||
|
index += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (10 <= nextValue) {
|
||||||
|
return `${nextValue.toFixed(0)} ${units[index]}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (1 <= nextValue) {
|
||||||
|
return `${nextValue.toFixed(1)} ${units[index]}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${nextValue.toFixed(2)} ${units[index]}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDuration(ms: number): string {
|
||||||
|
const totalSeconds = Math.floor(ms / 1000);
|
||||||
|
const secondsInDay = 24 * 3600;
|
||||||
|
const secondsInHour = 3600;
|
||||||
|
const secondsInMinute = 60;
|
||||||
|
const days = Math.floor(totalSeconds / secondsInDay);
|
||||||
|
const hours = Math.floor(totalSeconds % secondsInDay / secondsInHour);
|
||||||
|
const minutes = Math.floor(totalSeconds % secondsInHour / secondsInMinute);
|
||||||
|
const seconds = totalSeconds % secondsInMinute;
|
||||||
|
const parts: string[] = [];
|
||||||
|
|
||||||
|
if (0 < days) {
|
||||||
|
parts.push(`${days} 天`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (0 < hours || parts.length) {
|
||||||
|
parts.push(`${hours} 小时`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (0 < minutes || parts.length) {
|
||||||
|
parts.push(`${minutes} 分钟`);
|
||||||
|
}
|
||||||
|
|
||||||
|
parts.push(`${seconds} 秒`);
|
||||||
|
return parts.join(" ");
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeNumber(value?: number | null): number | null {
|
||||||
|
if (typeof value !== "number" || Number.isNaN(value)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="less">
|
||||||
|
.server-dashboard {
|
||||||
|
gap: 1rem;
|
||||||
|
display: flex;
|
||||||
|
padding: 1rem;
|
||||||
|
flex-direction: column;
|
||||||
|
|
||||||
|
.card {
|
||||||
|
gap: .75rem;
|
||||||
|
border: 1px solid var(--app-line);
|
||||||
|
display: flex;
|
||||||
|
padding: 1rem;
|
||||||
|
background: #FFF;
|
||||||
|
border-radius: 1rem;
|
||||||
|
flex-direction: column;
|
||||||
|
box-shadow: 0 .35rem 1rem rgba(17, 32, 56, .05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.intro {
|
||||||
|
gap: .5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tag,
|
||||||
|
.title,
|
||||||
|
.desc,
|
||||||
|
.time,
|
||||||
|
.sub-title {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tag,
|
||||||
|
.desc,
|
||||||
|
.time {
|
||||||
|
color: var(--app-sub);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tag {
|
||||||
|
font-size: .875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
font-size: 1.1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.desc {
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.time {
|
||||||
|
font-size: .8125rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.head {
|
||||||
|
gap: .75rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sub-title {
|
||||||
|
font-size: 1rem;
|
||||||
|
color: var(--app-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric-tabs {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-wrap {
|
||||||
|
display: flex;
|
||||||
|
min-height: 4rem;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-chart {
|
||||||
|
width: 100%;
|
||||||
|
height: 15rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metrics {
|
||||||
|
gap: .75rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric-label {
|
||||||
|
font-size: .875rem;
|
||||||
|
color: var(--app-sub);
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric-value {
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: .875rem;
|
||||||
|
color: var(--app-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.metric-tabs .t-tabs__nav) {
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.theme-dark) .server-dashboard {
|
||||||
|
.card {
|
||||||
|
box-shadow: 0 .35rem 1rem rgba(0, 0, 0, .2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
57
src/pages/dashboard/UPSDashboard/UPSDashboard.vue
Normal file
57
src/pages/dashboard/UPSDashboard/UPSDashboard.vue
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
<template>
|
||||||
|
<div class="ups-dashboard">
|
||||||
|
<section class="card">
|
||||||
|
<p class="tag">UPS</p>
|
||||||
|
<h2 class="title">供电监控</h2>
|
||||||
|
<p class="desc">这里用于展示 UPS 电池状态、负载功率、剩余续航和告警信息。</p>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts"></script>
|
||||||
|
|
||||||
|
<style scoped lang="less">
|
||||||
|
.ups-dashboard {
|
||||||
|
padding: 1rem;
|
||||||
|
|
||||||
|
.card {
|
||||||
|
gap: .6rem;
|
||||||
|
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,
|
||||||
|
.title,
|
||||||
|
.desc {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tag,
|
||||||
|
.desc {
|
||||||
|
color: var(--app-sub);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tag {
|
||||||
|
font-size: .875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
<template>
|
|
||||||
<route-placeholder title="服务日志页" description="这里保留为二级详情占位,用于验证从状态页进入日志页时的动效和返回行为。" />
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
</script>
|
|
||||||
@@ -17,9 +17,9 @@
|
|||||||
/>
|
/>
|
||||||
</section>
|
</section>
|
||||||
<div v-else-if="pageLoading" class="loading-wrap">
|
<div v-else-if="pageLoading" class="loading-wrap">
|
||||||
<t-loading text="加载目录中" />
|
<t-loading text="Loading directory" />
|
||||||
</div>
|
</div>
|
||||||
<t-empty v-else description="当前目录为空" />
|
<t-empty v-else description="Directory is empty" />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -72,7 +72,7 @@ const shouldUseRoutePath = computed(() => {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
return route.name === "FilePage" || route.name === "FileExplorerPage";
|
return route.name === "FileTab" || route.name === "FileExplorerPage";
|
||||||
});
|
});
|
||||||
|
|
||||||
const routeSegments = computed(() => normalizePath(route.params.pathMatch));
|
const routeSegments = computed(() => normalizePath(route.params.pathMatch));
|
||||||
@@ -399,4 +399,4 @@ function getDirectoryKey(pathSegments: string[]): string {
|
|||||||
box-shadow: 0 .35rem 1rem rgba(0, 0, 0, .22);
|
box-shadow: 0 .35rem 1rem rgba(0, 0, 0, .22);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
111
src/pages/tabs/DashboardTab.vue
Normal file
111
src/pages/tabs/DashboardTab.vue
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
<template>
|
||||||
|
<div class="dashboard-tab">
|
||||||
|
<div class="tabs-wrap">
|
||||||
|
<t-tabs class="tabs" :value="activeTab" @change="onChangeTab">
|
||||||
|
<t-tab-panel value="server" label="服务器" />
|
||||||
|
<t-tab-panel value="docker" label="Docker" />
|
||||||
|
<t-tab-panel value="ups" label="UPS" />
|
||||||
|
</t-tabs>
|
||||||
|
</div>
|
||||||
|
<div class="content-wrap">
|
||||||
|
<transition :name="transitionName" mode="out-in">
|
||||||
|
<component :is="activeComponent" :key="activeTab" class="dashboard-panel" />
|
||||||
|
</transition>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import type { Component } from "vue";
|
||||||
|
import ServerDashboard from "@/pages/dashboard/ServerDashboard/ServerDashboard.vue";
|
||||||
|
import DockerDashboard from "@/pages/dashboard/DockerDashboard/DockerDashboard.vue";
|
||||||
|
import UPSDashboard from "@/pages/dashboard/UPSDashboard/UPSDashboard.vue";
|
||||||
|
|
||||||
|
type DashboardTabValue = "server" | "docker" | "ups";
|
||||||
|
|
||||||
|
const tabOrder: DashboardTabValue[] = ["server", "docker", "ups"];
|
||||||
|
const dashboardMap: Record<DashboardTabValue, Component> = {
|
||||||
|
server: ServerDashboard,
|
||||||
|
docker: DockerDashboard,
|
||||||
|
ups: UPSDashboard
|
||||||
|
};
|
||||||
|
|
||||||
|
const activeTab = ref<DashboardTabValue>("server");
|
||||||
|
const transitionName = ref<"slide-left" | "slide-right">("slide-left");
|
||||||
|
const activeComponent = computed(() => dashboardMap[activeTab.value]);
|
||||||
|
|
||||||
|
function onChangeTab(value: string): void {
|
||||||
|
const nextTab = value as DashboardTabValue;
|
||||||
|
if (nextTab === activeTab.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextIndex = tabOrder.indexOf(nextTab);
|
||||||
|
const currentIndex = tabOrder.indexOf(activeTab.value);
|
||||||
|
transitionName.value = currentIndex < nextIndex ? "slide-left" : "slide-right";
|
||||||
|
activeTab.value = nextTab;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="less">
|
||||||
|
.dashboard-tab {
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
overflow: hidden;
|
||||||
|
flex-direction: column;
|
||||||
|
|
||||||
|
.tabs-wrap {
|
||||||
|
top: 0;
|
||||||
|
z-index: 9;
|
||||||
|
position: sticky;
|
||||||
|
background: var(--app-bg, #fff);
|
||||||
|
border-bottom: 1px solid var(--app-line);
|
||||||
|
|
||||||
|
.tabs {
|
||||||
|
:deep(.t-tabs__nav) {
|
||||||
|
padding: 0 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.t-tabs__content) {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.content-wrap {
|
||||||
|
flex: 1;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
|
.dashboard-panel {
|
||||||
|
height: 100%;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.slide-left-enter-active,
|
||||||
|
.slide-left-leave-active,
|
||||||
|
.slide-right-enter-active,
|
||||||
|
.slide-right-leave-active {
|
||||||
|
transition: transform .28s ease, opacity .28s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slide-left-enter-from,
|
||||||
|
.slide-right-leave-to {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateX(20%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.slide-left-leave-to,
|
||||||
|
.slide-right-enter-from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateX(-20%);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.theme-dark) .dashboard-tab {
|
||||||
|
.tabs-wrap {
|
||||||
|
background: var(--app-bg, #11161d);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="page">
|
|
||||||
<route-placeholder title="状态页" description="这里保留为服务器状态入口,用于验证底部标签切换和服务日志页前进返回动画。" />
|
|
||||||
<t-button block variant="outline" @click="openServerLogs">
|
|
||||||
查看服务日志页
|
|
||||||
</t-button>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
const router = useRouter();
|
|
||||||
|
|
||||||
async function openServerLogs(): Promise<void> {
|
|
||||||
await router.push("/server/logs");
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped lang="less">
|
|
||||||
.page {
|
|
||||||
gap: 1rem;
|
|
||||||
display: flex;
|
|
||||||
padding: 1.2rem;
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -6,7 +6,7 @@ import { DEFAULT_BODY_BACKGROUND, useGlobalUIStore } from "@/store/globalUIStore
|
|||||||
import { useSettingStore } from "@/store/settingStore";
|
import { useSettingStore } from "@/store/settingStore";
|
||||||
import NotFoundPage from "@/pages/system/NotFoundPage.vue";
|
import NotFoundPage from "@/pages/system/NotFoundPage.vue";
|
||||||
import ServerIndexPage from "@/pages/system/ServerIndexPage.vue";
|
import ServerIndexPage from "@/pages/system/ServerIndexPage.vue";
|
||||||
import FilePage from "@/pages/tabs/FilePage.vue";
|
import FileTab from "@/pages/tabs/FileTab.vue";
|
||||||
import ServerLogPage from "@/pages/detail/ServerLogPage.vue";
|
import ServerLogPage from "@/pages/detail/ServerLogPage.vue";
|
||||||
|
|
||||||
const router = createRouter({
|
const router = createRouter({
|
||||||
@@ -63,7 +63,7 @@ const router = createRouter({
|
|||||||
contentFixedHeight: true,
|
contentFixedHeight: true,
|
||||||
bodyBackground: "#F4F4F4"
|
bodyBackground: "#F4F4F4"
|
||||||
},
|
},
|
||||||
component: FilePage
|
component: FileTab
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: "/server/logs",
|
path: "/server/logs",
|
||||||
@@ -120,4 +120,4 @@ router.afterEach((to: RouteLocationNormalized) => {
|
|||||||
globalUIStore.setBodyBackground(targetBackground);
|
globalUIStore.setBodyBackground(targetBackground);
|
||||||
});
|
});
|
||||||
|
|
||||||
export default router;
|
export default router;
|
||||||
@@ -1,12 +1,12 @@
|
|||||||
import type { RouteRecordRaw } from "vue-router";
|
import type { RouteRecordRaw } from "vue-router";
|
||||||
import FilePage from "@/pages/tabs/FilePage.vue";
|
import FileTab from "@/pages/tabs/FileTab.vue";
|
||||||
import ServerStatusPage from "@/pages/tabs/ServerStatusPage.vue";
|
import DashboardTab from "@/pages/tabs/DashboardTab.vue";
|
||||||
import SettingsPage from "@/pages/tabs/SettingsPage.vue";
|
import SettingsTab from "@/pages/tabs/SettingsTab.vue";
|
||||||
|
|
||||||
const tabs: RouteRecordRaw[] = [
|
const tabs: RouteRecordRaw[] = [
|
||||||
{
|
{
|
||||||
path: "/",
|
path: "/",
|
||||||
name: "FilePage",
|
name: "FileTab",
|
||||||
meta: {
|
meta: {
|
||||||
depth: 2,
|
depth: 2,
|
||||||
navBarVisible: true,
|
navBarVisible: true,
|
||||||
@@ -17,11 +17,11 @@ const tabs: RouteRecordRaw[] = [
|
|||||||
contentFixedHeight: true,
|
contentFixedHeight: true,
|
||||||
bodyBackground: "#F4F4F4"
|
bodyBackground: "#F4F4F4"
|
||||||
},
|
},
|
||||||
component: FilePage
|
component: FileTab
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: "/server",
|
path: "/server",
|
||||||
name: "ServerStatusPage",
|
name: "DashboardTab",
|
||||||
meta: {
|
meta: {
|
||||||
depth: 2,
|
depth: 2,
|
||||||
navBarVisible: true,
|
navBarVisible: true,
|
||||||
@@ -29,13 +29,13 @@ const tabs: RouteRecordRaw[] = [
|
|||||||
tabBarVisible: true,
|
tabBarVisible: true,
|
||||||
tabBarRoot: true,
|
tabBarRoot: true,
|
||||||
tabBarPadding: true,
|
tabBarPadding: true,
|
||||||
bodyBackground: "#FFF"
|
bodyBackground: "#F4F4F4"
|
||||||
},
|
},
|
||||||
component: ServerStatusPage
|
component: DashboardTab
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: "/settings",
|
path: "/settings",
|
||||||
name: "SettingsPage",
|
name: "SettingsTab",
|
||||||
meta: {
|
meta: {
|
||||||
depth: 2,
|
depth: 2,
|
||||||
navBarVisible: true,
|
navBarVisible: true,
|
||||||
@@ -45,7 +45,7 @@ const tabs: RouteRecordRaw[] = [
|
|||||||
tabBarPadding: true,
|
tabBarPadding: true,
|
||||||
bodyBackground: "#FFF"
|
bodyBackground: "#FFF"
|
||||||
},
|
},
|
||||||
component: SettingsPage
|
component: SettingsTab
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user