update ServerDashboard

This commit is contained in:
Timi
2026-04-12 00:15:54 +08:00
parent 489cbb5d0f
commit 611830f393
30 changed files with 2078 additions and 892 deletions

View File

@@ -2,7 +2,7 @@
<div class="docker-dashboard">
<section class="card">
<p class="tag">Docker</p>
<h2 class="title">容器状态</h2>
<h2 class="header">容器状态</h2>
<p class="desc">这里用于承载容器列表运行状态镜像占用和资源使用情况</p>
</section>
</div>
@@ -26,7 +26,7 @@
}
.tag,
.title,
.header,
.desc {
margin: 0;
}
@@ -40,7 +40,7 @@
font-size: .875rem;
}
.title {
.header {
font-size: 1.1rem;
}
@@ -54,4 +54,4 @@
box-shadow: 0 .35rem 1rem rgba(0, 0, 0, .2);
}
}
</style>
</style>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,91 @@
<template>
<div class="server-detail">
<div v-if="isLoading && !snapshotView" class="loading-wrap">
<t-loading text="加载系统详情..." />
</div>
<template v-else-if="snapshotView">
<t-cell-group title="系统" theme="card">
<t-cell title="服务器时间" :note="Time.toDateTime(snapshotView.serverTime)" />
<t-cell title="采样周期" :note="`${snapshotView.sampleRateMs} ms`" />
<t-cell title="操作系统" :note="os?.name" />
<t-cell title="启动时间" :note="Time.toPassedDateTime(os?.bootAt)" />
</t-cell-group>
<t-cell-group title="硬件信息" theme="card">
<t-cell title="主板厂商" :note="hardware?.baseboard?.manufacturer" />
<t-cell title="主板型号" :note="hardware?.baseboard?.model" />
<t-cell title="主板版本" :note="hardware?.baseboard?.version" />
<t-cell title="主板序列号" :note="hardware?.baseboard?.serialNumber" />
<t-cell title="固件厂商" :note="hardware?.firmware?.manufacturer" />
<t-cell title="固件名称" :note="hardware?.firmware?.name" />
<t-cell title="固件描述" :note="hardware?.firmware?.description" />
<t-cell title="固件版本" :note="hardware?.firmware?.version" />
<t-cell title="固件发布日期" :note="hardware?.firmware?.releaseDate" />
</t-cell-group>
</template>
<t-empty v-else description="暂无系统详情数据" />
</div>
</template>
<script setup lang="ts">
import { Toast } from "tdesign-mobile-vue";
import { getSystemStatus, resolveSystemRequestErrorMessage } from "@/api/SystemAPI";
import type { SystemStatusSnapshotView } from "@/types/System";
import { Time } from "timi-web";
defineOptions({
name: "ServerDetail"
});
const isLoading = ref(false);
const snapshotView = ref<SystemStatusSnapshotView | null>(null);
const refreshIntervalMs = 3000;
let refreshTimer: ReturnType<typeof setInterval> | null = null;
const os = computed(() => snapshotView.value?.snapshot?.os);
const hardware = computed(() => snapshotView.value?.snapshot?.hardware);
onMounted(() => {
void refreshDetail();
refreshTimer = setInterval(() => {
void refreshDetail();
}, refreshIntervalMs);
});
onUnmounted(() => {
if (!refreshTimer) {
return;
}
clearInterval(refreshTimer);
refreshTimer = null;
});
async function refreshDetail(): Promise<void> {
if (isLoading.value) {
return;
}
isLoading.value = true;
try {
snapshotView.value = await getSystemStatus("os,hardware");
} catch (error) {
Toast({
theme: "error",
message: resolveSystemRequestErrorMessage(error)
});
} finally {
isLoading.value = false;
}
}
</script>
<style scoped lang="less">
.server-detail {
padding: var(--app-nav-offset) 0 1rem 0;
.loading-wrap {
display: flex;
min-height: 4rem;
align-items: center;
justify-content: center;
}
}
</style>

View File

@@ -0,0 +1,122 @@
<template>
<div class="server-performance-detail">
<div v-if="isLoading && !snapshotView" class="loading-wrap">
<t-loading text="加载资源详情..." />
</div>
<template v-else-if="snapshotView">
<t-cell-group v-if="cpu" title="CPU" theme="card">
<t-cell-info label="型号" :value="cpu.model" />
<t-cell title="物理核心" :note="String(cpu.physicalCores)" />
<t-cell title="逻辑核心" :note="String(cpu.logicalCores)" />
<t-cell title="使用率" :note="Text.unit(cpu.usageTotal * 100, '%')" />
<t-cell title="系统使用率" :note="Text.unit(cpu.usageSystem * 100, '%')" />
<t-cell title="温度" :note="Text.unit(cpu.temperatureCelsius || 0, '℃')"></t-cell>
</t-cell-group>
<t-cell-group v-if="memory" title="内存" theme="card">
<t-cell title="物理内存" :note="IOSize.format(memory.totalBytes)" />
<t-cell title="已使用" :note="IOSize.format(memory.usedBytes)" />
<t-cell title="使用率" :note="Text.unit(memory.usedBytes / memory.totalBytes * 100, '%')" />
<t-cell title="交换区内存" :note="IOSize.format(memory.swapTotalBytes)" />
<t-cell title="交换区已使用" :note="IOSize.format(memory.swapUsedBytes)" />
<t-cell title="交换区使用率" :note="Text.unit(memory.swapUsedBytes / memory.swapTotalBytes * 100, '%')" />
</t-cell-group>
<t-cell-group v-if="jvm" title="JVM" theme="card">
<t-cell title="名称" :note="jvm.name" />
<t-cell title="版本" :note="jvm.version" />
<t-cell title="启动于" :note="Time.toPassedDateTime(jvm.bootAt)" />
<t-cell title="堆内存最大大小" :note="IOSize.format(jvm.heapMaxBytes)" />
<t-cell title="堆内存初始大小" :note="IOSize.format(jvm.heapInitBytes)" />
<t-cell title="堆内存已提交" :note="IOSize.format(jvm.heapCommittedBytes)" />
<t-cell title="堆内存申请率" :note="Text.unit(jvm.heapCommittedBytes / jvm.heapMaxBytes * 100, '%')" />
<t-cell title="堆内存已使用" :note="IOSize.format(jvm.heapUsedBytes)" />
<t-cell title="堆内存使用率" :note="Text.unit(jvm.heapUsedBytes / jvm.heapCommittedBytes * 100, '%')" />
<t-cell-info label="GC 收集器" :value="jvm.gc.collector" />
<t-cell title="GC 周期次数" :note="String(jvm.gc.cycleCount)" />
<t-cell title="GC 暂停次数" :note="String(jvm.gc.pauseCount)" />
<t-cell title="最近暂停时间" :note="Time.toPassedDateTime(jvm.gc.lastPauseAt)" />
<t-cell title="最近回收内存" :note="IOSize.format(jvm.gc.lastRecoveredBytes)" />
</t-cell-group>
<t-cell-group v-if="network" title="网络" theme="card">
<t-cell-info label="网卡" :value="network.interfaceName" />
<t-cell title="MAC" :note="network.mac" />
<t-cell title="接收速率" :note="IOSize.speed(network.rxBytesPerSecond)" />
<t-cell title="发送速率" :note="IOSize.speed(network.txBytesPerSecond)" />
<t-cell title="累计接收" :note="IOSize.format(network.rxTotalBytes)" />
<t-cell title="累计发送" :note="IOSize.format(network.txTotalBytes)" />
<t-cell title="累计接收包" :note="network.rxPacketsTotal.toLocaleString()" />
<t-cell title="累计发送包" :note="network.txPacketsTotal.toLocaleString()" />
<t-cell title="输入错误包" :note="String(network.inErrors)" />
<t-cell title="输出错误包" :note="String(network.outErrors)" />
<t-cell title="输入丢包" :note="String(network.inDrops)" />
<t-cell title="累计冲突" :note="String(network.collisions)" />
</t-cell-group>
</template>
<t-empty v-else description="暂无资源详情数据" />
</div>
</template>
<script setup lang="ts">
import { Toast } from "tdesign-mobile-vue";
import { getSystemStatus, resolveSystemRequestErrorMessage } from "@/api/SystemAPI";
import type { SystemStatusSnapshotView } from "@/types/System";
import { IOSize, Text, Time } from "timi-web";
defineOptions({
name: "ServerPerformanceDetail"
});
const isLoading = ref(false);
const snapshotView = ref<SystemStatusSnapshotView | null>(null);
const refreshIntervalMs = 3000;
let refreshTimer: ReturnType<typeof setInterval> | null = null;
const cpu = computed(() => snapshotView.value?.snapshot?.cpu);
const memory = computed(() => snapshotView.value?.snapshot?.memory);
const jvm = computed(() => snapshotView.value?.snapshot?.jvm);
const network = computed(() => snapshotView.value?.snapshot?.network);
onMounted(() => {
void refreshDetail();
refreshTimer = setInterval(() => {
void refreshDetail();
}, refreshIntervalMs);
});
onUnmounted(() => {
if (!refreshTimer) {
return;
}
clearInterval(refreshTimer);
refreshTimer = null;
});
async function refreshDetail(): Promise<void> {
if (isLoading.value) {
return;
}
isLoading.value = true;
try {
snapshotView.value = await getSystemStatus("cpu,memory,jvm,network");
} catch (error) {
Toast({
theme: "error",
message: resolveSystemRequestErrorMessage(error)
});
} finally {
isLoading.value = false;
}
}
</script>
<style scoped lang="less">
.server-performance-detail {
padding: var(--app-nav-offset) 0 1rem 0;
.loading-wrap {
display: flex;
min-height: 4rem;
align-items: center;
justify-content: center;
}
}
</style>

View File

@@ -2,7 +2,7 @@
<div class="ups-dashboard">
<section class="card">
<p class="tag">UPS</p>
<h2 class="title">供电监控</h2>
<h2 class="header">供电监控</h2>
<p class="desc">这里用于展示 UPS 电池状态负载功率剩余续航和告警信息</p>
</section>
</div>
@@ -26,7 +26,7 @@
}
.tag,
.title,
.header,
.desc {
margin: 0;
}
@@ -40,7 +40,7 @@
font-size: .875rem;
}
.title {
.header {
font-size: 1.1rem;
}
@@ -54,4 +54,4 @@
box-shadow: 0 .35rem 1rem rgba(0, 0, 0, .2);
}
}
</style>
</style>

View File

@@ -2,7 +2,7 @@
<recycle-scroller
class="file-explorer-list"
:items="items"
:item-size="56"
:item-size="44"
key-field="path"
>
<template #before>

View File

@@ -25,7 +25,7 @@
<script setup lang="ts">
import { Toast } from "tdesign-mobile-vue";
import { listServerFiles, resolveRequestErrorMessage } from "@/api/file";
import { listServerFiles, resolveRequestErrorMessage } from "@/api/FileAPI";
import FileExplorerGrid from "./FileExplorerGrid.vue";
import FileExplorerList from "./FileExplorerList.vue";
import { useNavBarStore } from "@/store/navBarStore";
@@ -399,4 +399,4 @@ function getDirectoryKey(pathSegments: string[]): string {
box-shadow: 0 .35rem 1rem rgba(0, 0, 0, .22);
}
}
</style>
</style>

View File

@@ -1,19 +1,8 @@
import type { ServerFile } from "@/types/File";
export type DisplayMode = "list" | "grid";
export type FileItemType = "dir" | "file";
export interface ServerFile {
name: string;
extension?: string;
absolutePath?: string;
size?: number;
modifiedAt?: number;
type?: string;
isFile?: boolean;
isDirectory?: boolean;
canPreview?: boolean;
previewURI?: string;
}
export interface ExplorerItem {
name: string;
type: FileItemType;

View File

@@ -0,0 +1,180 @@
<template>
<div class="page">
<section class="card">
<div class="head">
<p class="tag">连接配置</p>
<h2 class="header">服务器连接</h2>
<p class="desc">这里的配置会持久化保存缺失时应用会强制回到连接引导页</p>
</div>
<div class="group">
<p class="label">协议</p>
<div class="protocols">
<t-button
size="small"
theme="primary"
:variant="form.protocol === 'http' ? 'base' : 'outline'"
@click="setProtocol('http')"
>
HTTP
</t-button>
<t-button
size="small"
theme="primary"
:variant="form.protocol === 'https' ? 'base' : 'outline'"
@click="setProtocol('https')"
>
HTTPS
</t-button>
</div>
</div>
<div class="group">
<p class="label">主机地址</p>
<t-input v-model="form.host" clearable placeholder="例如 192.168.1.100 或 nas.local" />
</div>
<div class="group">
<p class="label">端口</p>
<t-input v-model="form.port" clearable type="number" placeholder="例如 8080" />
</div>
<div class="group">
<p class="label">访问令牌</p>
<t-input v-model="form.token" clearable placeholder="请输入 token" />
</div>
<t-button block theme="primary" @click="saveConnect">
保存连接配置
</t-button>
<t-button block variant="outline" @click="resetConnect">
清空连接配置
</t-button>
</section>
</div>
</template>
<script setup lang="ts">
import { Toast } from "tdesign-mobile-vue";
import type { ConnectProtocol, ConnectSetting } from "@/store/settingStore";
import { useSettingStore } from "@/store/settingStore";
defineOptions({
name: "ConnectSetting"
});
const settingStore = useSettingStore();
const form = reactive<ConnectSetting>({
protocol: "http",
host: "",
port: "",
token: ""
});
watch(
() => settingStore.connect,
(connect) => {
Object.assign(form, connect);
},
{ immediate: true }
);
function setProtocol(protocol: ConnectProtocol): void {
form.protocol = protocol;
}
function validateConnect(): boolean {
const host = form.host.trim();
const port = form.port.trim();
const token = form.token.trim();
if (!host || !port || !token) {
Toast({
theme: "warning",
message: "请完整填写连接配置"
});
return false;
}
return true;
}
function saveConnect(): void {
if (!validateConnect()) {
return;
}
settingStore.setConnect(form);
Toast({
theme: "success",
message: "连接配置已保存"
});
}
function resetConnect(): void {
settingStore.resetConnect();
Object.assign(form, settingStore.connect);
Toast({
theme: "success",
message: "连接配置已清空"
});
}
</script>
<style scoped lang="less">
.page {
padding: 1rem;
.card {
gap: 1rem;
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);
}
.head,
.group {
gap: .5rem;
display: flex;
flex-direction: column;
}
.tag,
.label,
.desc {
margin: 0;
color: var(--app-sub);
}
.tag,
.label {
font-size: .875rem;
}
.header {
margin: 0;
font-size: 1.25rem;
}
.desc {
line-height: 1.6;
}
.protocols {
gap: .75rem;
display: flex;
flex-wrap: wrap;
}
}
:global(.theme-dark) .page {
.card {
box-shadow: 0 .35rem 1rem rgba(0, 0, 0, .2);
}
}
</style>

View File

@@ -0,0 +1,265 @@
<template>
<div class="page">
<section class="card">
<div class="head">
<p class="tag">服务器</p>
<h2 class="header">数据刷新与采集</h2>
<p class="desc">用于配置服务器仪表板的请求频率和 metrics 参数</p>
</div>
<div class="group">
<p class="label">当前状态刷新频率</p>
<t-input v-model="snapshotRefreshText" type="number" clearable placeholder="默认 3 秒" />
</div>
<div class="group">
<p class="label">历史采样刷新频率</p>
<t-input v-model="historyRefreshText" type="number" clearable placeholder="默认 10 秒" />
</div>
<div class="group">
<p class="label">当前状态采集指标</p>
<div class="metrics">
<t-button
v-for="metric in snapshotMetricOptions"
:key="metric.value"
size="small"
theme="primary"
:variant="isSnapshotMetricChecked(metric.value) ? 'base' : 'outline'"
@click="toggleSnapshotMetric(metric.value)"
>
<span v-text="metric.label" />
</t-button>
</div>
</div>
<div class="group">
<p class="label">历史采样采集指标</p>
<div class="metrics">
<t-button
v-for="metric in historyMetricOptions"
:key="metric.value"
size="small"
theme="primary"
:variant="isHistoryMetricChecked(metric.value) ? 'base' : 'outline'"
@click="toggleHistoryMetric(metric.value)"
>
<span v-text="metric.label" />
</t-button>
</div>
</div>
<t-button block theme="primary" @click="saveServerDashboardSetting">
保存服务器配置
</t-button>
<t-button block variant="outline" @click="resetServerDashboardSetting">
恢复默认
</t-button>
</section>
<section class="card">
<div class="head">
<p class="tag">Docker</p>
<h2 class="header">配置待定</h2>
<p class="desc">后续将支持 Docker 仪表板采集项和展示策略配置</p>
</div>
</section>
<section class="card">
<div class="head">
<p class="tag">UPS</p>
<h2 class="header">配置待定</h2>
<p class="desc">后续将支持 UPS 仪表板采集项和告警策略配置</p>
</div>
</section>
</div>
</template>
<script setup lang="ts">
import { Toast } from "tdesign-mobile-vue";
import {
useSettingStore,
type DashboardHistoryMetric,
type DashboardSnapshotMetric,
type ServerDashboardSetting
} from "@/store/settingStore";
defineOptions({
name: "DashboardSetting"
});
const settingStore = useSettingStore();
const snapshotMetricOptions: Array<{ label: string; value: DashboardSnapshotMetric }> = [
{ label: "系统", value: "os" },
{ label: "CPU", value: "cpu" },
{ label: "内存", value: "memory" },
{ label: "JVM", value: "jvm" },
{ label: "网络", value: "network" },
{ label: "硬件", value: "hardware" },
{ label: "磁盘", value: "storage" }
];
const historyMetricOptions: Array<{ label: string; value: DashboardHistoryMetric }> = [
{ label: "CPU", value: "cpu" },
{ label: "内存", value: "memory" },
{ label: "JVM", value: "jvm" },
{ label: "网络", value: "network" }
];
const snapshotRefreshText = ref("");
const historyRefreshText = ref("");
const selectedSnapshotMetrics = ref<DashboardSnapshotMetric[]>([]);
const selectedHistoryMetrics = ref<DashboardHistoryMetric[]>([]);
watch(
() => settingStore.dashboard.server,
(setting) => {
snapshotRefreshText.value = String(setting.snapshotRefreshSeconds);
historyRefreshText.value = String(setting.historyRefreshSeconds);
selectedSnapshotMetrics.value = [...setting.snapshotMetrics];
selectedHistoryMetrics.value = [...setting.historyMetrics];
},
{ immediate: true }
);
function isSnapshotMetricChecked(metric: DashboardSnapshotMetric): boolean {
return selectedSnapshotMetrics.value.includes(metric);
}
function isHistoryMetricChecked(metric: DashboardHistoryMetric): boolean {
return selectedHistoryMetrics.value.includes(metric);
}
function toggleSnapshotMetric(metric: DashboardSnapshotMetric): void {
if (isSnapshotMetricChecked(metric)) {
if (selectedSnapshotMetrics.value.length <= 1) {
Toast({
theme: "warning",
message: "当前状态至少保留一个采集指标"
});
return;
}
selectedSnapshotMetrics.value = selectedSnapshotMetrics.value.filter((item) => item !== metric);
return;
}
selectedSnapshotMetrics.value = [...selectedSnapshotMetrics.value, metric];
}
function toggleHistoryMetric(metric: DashboardHistoryMetric): void {
if (isHistoryMetricChecked(metric)) {
if (selectedHistoryMetrics.value.length <= 1) {
Toast({
theme: "warning",
message: "历史采样至少保留一个采集指标"
});
return;
}
selectedHistoryMetrics.value = selectedHistoryMetrics.value.filter((item) => item !== metric);
return;
}
selectedHistoryMetrics.value = [...selectedHistoryMetrics.value, metric];
}
function saveServerDashboardSetting(): void {
const nextSetting: Partial<ServerDashboardSetting> = {
snapshotRefreshSeconds: normalizeSecondValue(snapshotRefreshText.value, 3),
historyRefreshSeconds: normalizeSecondValue(historyRefreshText.value, 10),
snapshotMetrics: selectedSnapshotMetrics.value,
historyMetrics: selectedHistoryMetrics.value
};
settingStore.setServerDashboard(nextSetting);
Toast({
theme: "success",
message: "服务器仪表板配置已保存"
});
}
function resetServerDashboardSetting(): void {
settingStore.setServerDashboard({
snapshotRefreshSeconds: 3,
historyRefreshSeconds: 10,
snapshotMetrics: ["os", "cpu", "memory", "jvm", "network", "hardware", "storage"],
historyMetrics: ["cpu", "memory", "jvm", "network"]
});
Toast({
theme: "success",
message: "已恢复默认配置"
});
}
function normalizeSecondValue(value: string, fallback: number): number {
const numberValue = Number(value);
if (Number.isNaN(numberValue)) {
return fallback;
}
return Math.min(Math.max(Math.floor(numberValue), 1), 120);
}
</script>
<style scoped lang="less">
.page {
gap: 1rem;
display: flex;
padding: 1rem;
flex-direction: column;
.card {
gap: 1rem;
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);
}
.head,
.group {
gap: .5rem;
display: flex;
flex-direction: column;
}
.tag,
.label,
.desc {
margin: 0;
color: var(--app-sub);
}
.tag,
.label {
font-size: .875rem;
}
.header {
margin: 0;
font-size: 1.25rem;
}
.desc {
line-height: 1.6;
}
.metrics {
gap: .75rem;
display: flex;
flex-wrap: wrap;
}
}
:global(.theme-dark) .page {
.card {
box-shadow: 0 .35rem 1rem rgba(0, 0, 0, .2);
}
}
</style>

View File

@@ -0,0 +1,93 @@
<template>
<div class="page">
<section class="card">
<div class="head">
<p class="tag">主题</p>
<h2 class="header">界面模式</h2>
<p class="desc">当前提供浅色深色和跟随系统三种模式</p>
</div>
<div class="modes">
<t-button
v-for="item in themeModeList"
:key="item.value"
theme="primary"
:variant="globalUIStore.themeMode === item.value ? 'base' : 'outline'"
@click="globalUIStore.setThemeMode(item.value)"
>
<span v-text="item.label" />
</t-button>
</div>
</section>
</div>
</template>
<script setup lang="ts">
import { useGlobalUIStore, type ThemeMode } from "@/store/globalUIStore";
defineOptions({
name: "ThemeSetting"
});
const globalUIStore = useGlobalUIStore();
const themeModeList: Array<{ label: string; value: ThemeMode }> = [
{ label: "浅色", value: "light" },
{ label: "深色", value: "dark" },
{ label: "跟随系统", value: "system" }
];
</script>
<style scoped lang="less">
.page {
padding: 1rem;
.card {
gap: 1rem;
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);
}
.head {
gap: .5rem;
display: flex;
flex-direction: column;
}
.tag,
.desc {
margin: 0;
color: var(--app-sub);
}
.tag {
font-size: .875rem;
}
.header {
margin: 0;
font-size: 1.25rem;
}
.desc {
line-height: 1.6;
}
.modes {
gap: .75rem;
display: flex;
flex-wrap: wrap;
}
}
:global(.theme-dark) .page {
.card {
box-shadow: 0 .35rem 1rem rgba(0, 0, 0, .2);
}
}
</style>

View File

@@ -13,7 +13,7 @@
<t-icon v-else name="music" />
</div>
<div class="info">
<p class="title" v-text="currentSong.title || currentFile?.name || '未选择歌曲'"></p>
<p class="header" v-text="currentSong.title || currentFile?.name || '未选择歌曲'"></p>
<p class="artist" v-text="currentSong.artist || '未知艺术家'"></p>
</div>
<t-button class="close-btn" variant="text" shape="square" @click="closePopup">
@@ -164,7 +164,7 @@ function playItem(index: number): void {
flex-direction: column;
}
.title,
.header,
.artist,
.queue-title,
.queue-count,
@@ -174,7 +174,7 @@ function playItem(index: number): void {
margin: 0;
}
.title,
.header,
.queue-name {
color: var(--app-text);
font-weight: 600;
@@ -244,7 +244,7 @@ function playItem(index: number): void {
.queue-name,
.queue-artist,
.title,
.header,
.artist {
overflow: hidden;
white-space: nowrap;

View File

@@ -1,5 +1,5 @@
<template>
<route-placeholder title="NotFoundPage" description="未匹配到路由时显示的占位页,避免空白页面。" />
<span>找不到页面</span>
</template>
<script setup lang="ts">

View File

@@ -3,7 +3,7 @@
<section class="panel">
<div class="hero">
<p class="tag">NAS 连接</p>
<h1 class="title">先配置服务器连接</h1>
<h1 class="header">先配置服务器连接</h1>
<p class="desc">缺少连接配置时应用不会进入文件状态和设置页面</p>
</div>
@@ -148,7 +148,7 @@ async function submitConnect(): Promise<void> {
letter-spacing: .08em;
}
.title {
.header {
margin: 0;
font-size: 2rem;
line-height: 1.15;

View File

@@ -1,6 +1,6 @@
<template>
<div class="dashboard-tab">
<div class="tabs-wrap">
<div ref="tabsWrapRef" class="tabs-wrap">
<t-tabs class="tabs" :value="activeTab" @change="onChangeTab">
<t-tab-panel value="server" label="服务器" />
<t-tab-panel value="docker" label="Docker" />
@@ -8,7 +8,7 @@
</t-tabs>
</div>
<div class="content-wrap">
<transition :name="transitionName" mode="out-in">
<transition :name="panelTransitionName" mode="out-in">
<component :is="activeComponent" :key="activeTab" class="dashboard-panel" />
</transition>
</div>
@@ -22,17 +22,47 @@ import DockerDashboard from "@/pages/dashboard/DockerDashboard/DockerDashboard.v
import UPSDashboard from "@/pages/dashboard/UPSDashboard/UPSDashboard.vue";
type DashboardTabValue = "server" | "docker" | "ups";
type PanelTransitionName = "panel-slide-next" | "panel-slide-prev";
const tabOrder: DashboardTabValue[] = ["server", "docker", "ups"];
const dashboardMap: Record<DashboardTabValue, Component> = {
server: ServerDashboard,
docker: DockerDashboard,
ups: UPSDashboard
};
const tabOrder: DashboardTabValue[] = ["server", "docker", "ups"];
const activeTab = ref<DashboardTabValue>("server");
const transitionName = ref<"slide-left" | "slide-right">("slide-left");
const panelTransitionName = ref<PanelTransitionName>("panel-slide-next");
const activeComponent = computed(() => dashboardMap[activeTab.value]);
const tabsWrapRef = ref<HTMLElement>();
const tabsHeight = ref("0px");
let tabsResizeObs: ResizeObserver | undefined;
function updateTabsHeight(): void {
if (!tabsWrapRef.value) {
tabsHeight.value = "0px";
return;
}
tabsHeight.value = `${tabsWrapRef.value.offsetHeight}px`;
}
onMounted(() => {
updateTabsHeight();
if (!tabsWrapRef.value) {
return;
}
tabsResizeObs = new ResizeObserver(() => {
updateTabsHeight();
});
tabsResizeObs.observe(tabsWrapRef.value);
});
onUnmounted(() => {
if (tabsResizeObs) {
tabsResizeObs.disconnect();
}
});
function onChangeTab(value: string): void {
const nextTab = value as DashboardTabValue;
@@ -40,28 +70,35 @@ function onChangeTab(value: string): void {
return;
}
const nextIndex = tabOrder.indexOf(nextTab);
const currentIndex = tabOrder.indexOf(activeTab.value);
transitionName.value = currentIndex < nextIndex ? "slide-left" : "slide-right";
const nextIndex = tabOrder.indexOf(nextTab);
if (currentIndex <= -1 || nextIndex <= -1) {
panelTransitionName.value = "panel-slide-next";
} else {
panelTransitionName.value = currentIndex < nextIndex ? "panel-slide-next" : "panel-slide-prev";
}
activeTab.value = nextTab;
}
</script>
<style scoped lang="less">
.dashboard-tab {
height: 100%;
--dashboard-tabs-height: v-bind(tabsHeight);
display: flex;
overflow: hidden;
flex-direction: column;
.tabs-wrap {
top: 0;
z-index: 9;
position: sticky;
top: var(--app-nav-offset, 0px);
left: 0;
right: 0;
z-index: 1090;
position: fixed;
background: var(--app-bg, #fff);
border-bottom: 1px solid var(--app-line);
.tabs {
--td-tab-track-width: 4rem;
:deep(.t-tabs__nav) {
padding: 0 1rem;
}
@@ -76,36 +113,26 @@ function onChangeTab(value: string): void {
flex: 1;
position: relative;
overflow: hidden;
.dashboard-panel {
height: 100%;
overflow: auto;
}
padding-top: calc(var(--dashboard-tabs-height) + var(--app-nav-offset));
}
.slide-left-enter-active,
.slide-left-leave-active,
.slide-right-enter-active,
.slide-right-leave-active {
transition: transform .28s ease, opacity .28s ease;
.panel-slide-next-enter-active,
.panel-slide-next-leave-active,
.panel-slide-prev-enter-active,
.panel-slide-prev-leave-active {
transition: opacity 260ms var(--tui-bezier), transform 260ms var(--tui-bezier);
}
.slide-left-enter-from,
.slide-right-leave-to {
.panel-slide-next-enter-from,
.panel-slide-prev-leave-to {
opacity: 0;
transform: translateX(20%);
transform: translateX(50%);
}
.slide-left-leave-to,
.slide-right-enter-from {
.panel-slide-next-leave-to,
.panel-slide-prev-enter-from {
opacity: 0;
transform: translateX(-20%);
}
}
:global(.theme-dark) .dashboard-tab {
.tabs-wrap {
background: var(--app-bg, #11161d);
transform: translateX(-50%);
}
}
</style>

View File

@@ -2,72 +2,19 @@
<div class="page">
<section class="card">
<div class="head">
<p class="tag">连接配</p>
<h2 class="title">服务器连接</h2>
<p class="desc">这里的配置会持久化保存缺失时应用会强制回到连接引导页</p>
<p class="tag">系统设</p>
<h2 class="header">配置入口</h2>
<p class="desc">设置页只保留入口具体配置在独立页面中维护</p>
</div>
<div class="group">
<p class="label">协议</p>
<div class="protocols">
<t-button
size="small"
theme="primary"
:variant="form.protocol === 'http' ? 'base' : 'outline'"
@click="setProtocol('http')"
>
HTTP
</t-button>
<t-button
size="small"
theme="primary"
:variant="form.protocol === 'https' ? 'base' : 'outline'"
@click="setProtocol('https')"
>
HTTPS
</t-button>
</div>
</div>
<div class="group">
<p class="label">主机地址</p>
<t-input v-model="form.host" clearable placeholder="例如 192.168.1.100 或 nas.local" />
</div>
<div class="group">
<p class="label">端口</p>
<t-input v-model="form.port" clearable type="number" placeholder="例如 8080" />
</div>
<div class="group">
<p class="label">访问令牌</p>
<t-input v-model="form.token" clearable placeholder="请输入 token" />
</div>
<t-button block theme="primary" @click="saveConnect">
保存连接配置
</t-button>
<t-button block variant="outline" @click="resetConnect">
清空连接配置
</t-button>
</section>
<section class="card">
<div class="head">
<p class="tag">主题</p>
<h2 class="title">界面模式</h2>
<p class="desc">当前仅提供浅色深色和跟随系统三种模式</p>
</div>
<div class="modes">
<t-button
v-for="item in themeModeList"
:key="item.value"
theme="primary"
:variant="globalUIStore.themeMode === item.value ? 'base' : 'outline'"
@click="globalUIStore.setThemeMode(item.value)"
>
<span v-text="item.label"></span>
<div class="entries">
<t-button block variant="outline" @click="openConnectSetting">
连接配置
</t-button>
<t-button block variant="outline" @click="openDashboardSetting">
仪表板
</t-button>
<t-button block variant="outline" @click="openThemeSetting">
主题
</t-button>
</div>
</section>
@@ -75,74 +22,22 @@
</template>
<script setup lang="ts">
import { Toast } from "tdesign-mobile-vue";
import { useGlobalUIStore, type ThemeMode } from "@/store/globalUIStore";
import type { ConnectProtocol, ConnectSetting } from "@/store/settingStore";
import { useSettingStore } from "@/store/settingStore";
const globalUIStore = useGlobalUIStore();
const settingStore = useSettingStore();
const themeModeList: Array<{ label: string; value: ThemeMode }> = [
{ label: "浅色", value: "light" },
{ label: "深色", value: "dark" },
{ label: "跟随系统", value: "system" }
];
const form = reactive<ConnectSetting>({
protocol: "http",
host: "",
port: "",
token: ""
defineOptions({
name: "SettingsTab"
});
watch(
() => settingStore.connect,
(connect) => {
Object.assign(form, connect);
},
{ immediate: true }
);
const router = useRouter();
function setProtocol(protocol: ConnectProtocol): void {
form.protocol = protocol;
function openConnectSetting(): void {
void router.push("/settings/connect");
}
function validateConnect(): boolean {
const host = form.host.trim();
const port = form.port.trim();
const token = form.token.trim();
if (!host || !port || !token) {
Toast({
theme: "warning",
message: "请完整填写连接配置"
});
return false;
}
return true;
function openDashboardSetting(): void {
void router.push("/settings/dashboard");
}
function saveConnect(): void {
if (!validateConnect()) {
return;
}
settingStore.setConnect(form);
Toast({
theme: "success",
message: "连接配置已保存"
});
}
function resetConnect(): void {
settingStore.resetConnect();
Object.assign(form, settingStore.connect);
Toast({
theme: "success",
message: "连接配置已清空"
});
function openThemeSetting(): void {
void router.push("/settings/theme");
}
</script>
@@ -164,26 +59,23 @@ function resetConnect(): void {
box-shadow: 0 .35rem 1rem rgba(17, 32, 56, .05);
}
.head,
.group {
.head {
gap: .5rem;
display: flex;
flex-direction: column;
}
.tag,
.label,
.desc {
margin: 0;
color: var(--app-sub);
}
.tag,
.label {
.tag {
font-size: .875rem;
}
.title {
.header {
margin: 0;
font-size: 1.25rem;
}
@@ -192,11 +84,10 @@ function resetConnect(): void {
line-height: 1.6;
}
.protocols,
.modes {
.entries {
gap: .75rem;
display: flex;
flex-wrap: wrap;
flex-direction: column;
}
}