Compare commits

..

8 Commits

Author SHA1 Message Date
Timi
5e192a90e1 block scroll when touch chart 2026-04-16 11:08:21 +08:00
Timi
6f067477f0 fix iOS tabbar transition 2026-04-14 16:07:41 +08:00
Timi
11c1199449 fix iOS back transition 2026-04-14 15:47:03 +08:00
Timi
42efa2b370 fix iOS t-navbar style 2026-04-14 14:38:48 +08:00
Timi
631122c79b add manifest 2026-04-13 18:29:36 +08:00
Timi
9942afafa7 update DashboardSetting.vue style 2026-04-13 17:20:57 +08:00
Timi
2f45330ebd update ThemeSetting.vue style 2026-04-13 16:45:48 +08:00
Timi
753ab56e06 update ConnectSetting.vue 2026-04-13 16:11:37 +08:00
21 changed files with 478 additions and 607 deletions

View File

@@ -3,7 +3,14 @@
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" /> <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<meta name="application-name" content="Timi Server" />
<meta name="mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<meta name="apple-mobile-web-app-title" content="Timi Server" />
<meta name="format-detection" content="telephone=no" />
<meta name="theme-color" content="#ffffff" /> <meta name="theme-color" content="#ffffff" />
<link rel="manifest" href="/manifest.webmanifest" />
<title>Timi Server</title> <title>Timi Server</title>
</head> </head>
<body> <body>

View File

@@ -19,7 +19,7 @@
"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.9", "timi-tdesign-mobile": "0.0.9",
"timi-web": "0.0.17", "timi-web": "0.0.18",
"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",

10
pnpm-lock.yaml generated
View File

@@ -30,8 +30,8 @@ importers:
specifier: 0.0.9 specifier: 0.0.9
version: 0.0.9(typescript@5.8.3) version: 0.0.9(typescript@5.8.3)
timi-web: timi-web:
specifier: 0.0.17 specifier: 0.0.18
version: 0.0.17 version: 0.0.18
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)
@@ -1832,8 +1832,8 @@ packages:
resolution: {integrity: sha512-SV8m/FsLsEabSmJ/NvI650fLyb/JCLz7JUNQrkWeoo5xICElsN4x39L3n4lTzu6ARidjpT6uA+XEmA4wnyQmuA==} resolution: {integrity: sha512-SV8m/FsLsEabSmJ/NvI650fLyb/JCLz7JUNQrkWeoo5xICElsN4x39L3n4lTzu6ARidjpT6uA+XEmA4wnyQmuA==}
engines: {node: '>=16.0.0'} engines: {node: '>=16.0.0'}
timi-web@0.0.17: timi-web@0.0.18:
resolution: {integrity: sha512-6ycsqfnl+zeQkgOICjhhJYHoo3CtCxnhaNFFirNLVfX/VrrTDjW7DiR+/W8bouZzx6AR19fZKyG+UE0BKKBURg==} resolution: {integrity: sha512-e7QDPZbt7ZVvghtYwgpZrWJ0onIB4WNMhUaODCYyOdeUmi6PGQk4Aax/Gyo1M12MflrjaBUmlCF+9kPAqKGdtQ==}
engines: {node: '>=16.0.0'} engines: {node: '>=16.0.0'}
tinycolor2@1.6.0: tinycolor2@1.6.0:
@@ -3875,7 +3875,7 @@ snapshots:
transitivePeerDependencies: transitivePeerDependencies:
- typescript - typescript
timi-web@0.0.17: timi-web@0.0.18:
dependencies: dependencies:
axios: 1.13.5 axios: 1.13.5
less: 4.5.1 less: 4.5.1

View File

@@ -0,0 +1,10 @@
{
"name": "Timi Server",
"short_name": "Timi",
"lang": "zh-CN",
"start_url": "/",
"scope": "/",
"display": "standalone",
"background_color": "#ffffff",
"theme_color": "#ffffff"
}

View File

@@ -14,6 +14,7 @@
import type { RouteLocationNormalized } from "vue-router"; import type { RouteLocationNormalized } from "vue-router";
import { viewDepthKey } from "vue-router"; import { viewDepthKey } from "vue-router";
import { useGlobalUIStore } from "@/store/globalUIStore"; import { useGlobalUIStore } from "@/store/globalUIStore";
import { hasProgrammaticBackNavigation } from "@/utils/backNavigationSignal";
defineOptions({ defineOptions({
name: "PageTransition" name: "PageTransition"
@@ -26,6 +27,8 @@ const viewDepth = inject(viewDepthKey, 0);
const transitionName = ref(""); const transitionName = ref("");
const hasTransition = ref(false); const hasTransition = ref(false);
// iOS 左滑返回由系统接管,页面侧不应叠加离场动画。
const isIOS = /iP(ad|hone|od)/i.test(window.navigator.userAgent);
const pageBackground = computed(() => globalUIStore.bodyBackground); const pageBackground = computed(() => globalUIStore.bodyBackground);
const currentDepth = computed(() => Number(unref(viewDepth))); const currentDepth = computed(() => Number(unref(viewDepth)));
const pageKey = computed(() => { const pageKey = computed(() => {
@@ -72,6 +75,17 @@ const unregisterGuard = router.beforeEach((to, from) => {
const toDepth = calcDepth(to); const toDepth = calcDepth(to);
const fromDepth = calcDepth(from); const fromDepth = calcDepth(from);
const isBackwardNavigation = toDepth < fromDepth;
// 主动触发的返回(如导航栏返回)保留动画。
const isProgrammaticBack = hasProgrammaticBackNavigation();
// 仅在 iOS 系统返回手势下关闭动画。
const shouldDisableTransition = isIOS && isBackwardNavigation && !isProgrammaticBack;
if (shouldDisableTransition) {
transitionName.value = "";
hasTransition.value = false;
return;
}
if (fromDepth < toDepth) { if (fromDepth < toDepth) {
transitionName.value = "push-left"; transitionName.value = "push-left";
@@ -102,8 +116,8 @@ onUnmounted(() => {
width: 100%; width: 100%;
height: 100%; height: 100%;
display: grid; display: grid;
min-height: 100%;
position: relative; position: relative;
min-height: 100%;
overflow-x: hidden; overflow-x: hidden;
.pages { .pages {

View File

@@ -5,9 +5,14 @@
v-if="navBarStore.isShowing" v-if="navBarStore.isShowing"
class="nav-bar" class="nav-bar"
:title="navBarStore.title" :title="navBarStore.title"
:left-arrow="!!navBarStore.canBack" :left-arrow="!hasCustomLeft && !!navBarStore.canBack"
@left-click="doBack" @left-click="onLeftClick"
> >
<template v-if="hasCustomLeft" #left>
<div class="nav-extra nav-extra-left">
<component :is="navBarStore.leftRenderer" />
</div>
</template>
<template #right> <template #right>
<div class="nav-extra"> <div class="nav-extra">
<component :is="navBarStore.rightRenderer" v-if="navBarStore.rightRenderer" /> <component :is="navBarStore.rightRenderer" v-if="navBarStore.rightRenderer" />
@@ -17,8 +22,9 @@
<div :class="{ 'is-fixed-height': isContentFixedHeight }" class="router-view"> <div :class="{ 'is-fixed-height': isContentFixedHeight }" class="router-view">
<page-transition /> <page-transition />
</div> </div>
<!-- iOS 手势返回场景下临时关闭 tabbar 过渡避免系统回退与页面动画叠加 -->
<t-tab-bar <t-tab-bar
:class="{ 'is-hidden': !tabBarStore.isShowing }" :class="{ 'is-hidden': !tabBarStore.isShowing, 'skip-transition': tabBarStore.shouldSkipTransition }"
class="tab-bar glass-white" class="tab-bar glass-white"
v-model="tabVal" v-model="tabVal"
shape="round" shape="round"
@@ -80,6 +86,8 @@ onUnmounted(() => {
// ---------- 导航返回 ---------- // ---------- 导航返回 ----------
const hasCustomLeft = computed(() => !!navBarStore.leftRenderer);
function doBack(): void { function doBack(): void {
if (navBarStore.backTo) { if (navBarStore.backTo) {
router.push(navBarStore.backTo); router.push(navBarStore.backTo);
@@ -89,6 +97,14 @@ function doBack(): void {
} }
} }
function onLeftClick(): void {
if (hasCustomLeft.value) {
return;
}
doBack();
}
const navBarPadding = computed(() => { const navBarPadding = computed(() => {
if (navBarStore.isShowing) { if (navBarStore.isShowing) {
return `${navBarStore.height || 48}px`; return `${navBarStore.height || 48}px`;
@@ -114,33 +130,29 @@ function resolveTabValue(path: string): string {
if (path === "/" || path.startsWith("/files/")) { if (path === "/" || path.startsWith("/files/")) {
return "/"; return "/";
} }
return path; return path;
} }
watch( watch(() => route.path, (newPath: string) => {
() => route.path,
(newPath: string) => {
tabVal.value = resolveTabValue(newPath); tabVal.value = resolveTabValue(newPath);
}, }, { immediate: true });
{ immediate: true }
);
function onChangeTab(value: string): void { async function onChangeTab(value: string): Promise<void> {
if (value === "__music__") { if (value === "__music__") {
musicPlayerStore.setPopupVisible(true); musicPlayerStore.setPopupVisible(true);
tabVal.value = resolveTabValue(route.path); tabVal.value = resolveTabValue(route.path);
return; return;
} }
if (resolveTabValue(route.path) === value) {
router.push(value); return;
}
await router.replace(value);
} }
const isTabBarPaddingNeeded = computed(() => { const isTabBarPaddingNeeded = computed(() => {
if (!tabBarStore.isShowing) { if (!tabBarStore.isShowing) {
return false; return false;
} }
return route.meta.tabBarPadding !== false; return route.meta.tabBarPadding !== false;
}); });
@@ -148,7 +160,6 @@ const tabBarPadding = computed(() => {
if (isTabBarPaddingNeeded.value) { if (isTabBarPaddingNeeded.value) {
return "6rem"; return "6rem";
} }
return "0rem"; return "0rem";
}); });
@@ -169,6 +180,7 @@ const contentHeight = computed(() => {
.main-layout { .main-layout {
--app-nav-offset: v-bind(topPadding); --app-nav-offset: v-bind(topPadding);
--safe-top: env(safe-area-inset-top, 0px); --safe-top: env(safe-area-inset-top, 0px);
box-sizing: border-box; box-sizing: border-box;
isolation: isolate; isolation: isolate;
position: relative; position: relative;
@@ -177,6 +189,8 @@ const contentHeight = computed(() => {
transition: padding-bottom .24s ease; transition: padding-bottom .24s ease;
.nav-bar { .nav-bar {
--td-navbar-padding-top: var(--safe-top);
top: 0; top: 0;
left: 0; left: 0;
right: 0; right: 0;
@@ -184,11 +198,8 @@ const contentHeight = computed(() => {
position: fixed; position: fixed;
:deep(.t-navbar) { :deep(.t-navbar) {
z-index: 1;
position: relative;
}
:deep(.t-navbar__content) { &__content {
color: var(--tui-black, #000); color: var(--tui-black, #000);
border-bottom: 1px solid rgba(0, 0, 0, .1); border-bottom: 1px solid rgba(0, 0, 0, .1);
background: rgba(250, 250, 250, .8); background: rgba(250, 250, 250, .8);
@@ -196,12 +207,23 @@ const contentHeight = computed(() => {
-webkit-backdrop-filter: blur(10px); -webkit-backdrop-filter: blur(10px);
} }
&__left,
&__right {
top: var(----td-navbar-padding-top);
height: var(--td-navbar-height, 48px);
}
}
.nav-extra { .nav-extra {
gap: .35rem; gap: .35rem;
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
justify-content: flex-end; justify-content: flex-end;
} }
.nav-extra-left {
justify-content: flex-start;
}
} }
.router-view { .router-view {
@@ -218,9 +240,9 @@ const contentHeight = computed(() => {
} }
.tab-bar { .tab-bar {
bottom: 1rem;
left: 1rem; left: 1rem;
right: 1rem; right: 1rem;
bottom: 1rem;
position: fixed; position: fixed;
z-index: 1000; z-index: 1000;
opacity: 1; opacity: 1;
@@ -240,6 +262,11 @@ const contentHeight = computed(() => {
transform: translateY(calc(100% + 1rem)); transform: translateY(calc(100% + 1rem));
} }
&.skip-transition {
// 配合 tabBarStore.shouldSkipTransition仅本次导航禁用过渡
transition: none;
}
.item { .item {
margin: 0; margin: 0;
padding: 0; padding: 0;

View File

@@ -11,6 +11,53 @@ import "vue-virtual-scroller/dist/vue-virtual-scroller.css";
import { axios } from "timi-web"; import { axios } from "timi-web";
import { useSettingStore } from "@/store/settingStore.ts"; import { useSettingStore } from "@/store/settingStore.ts";
type FullscreenElement = HTMLElement & {
webkitRequestFullscreen?: () => Promise<void> | void;
msRequestFullscreen?: () => Promise<void> | void;
};
type StandaloneNavigator = Navigator & {
standalone?: boolean;
};
const requestLaunchFullscreen = async (): Promise<void> => {
if (document.fullscreenElement) {
return;
}
const navigatorInfo = window.navigator as StandaloneNavigator;
const isStandaloneMode = window.matchMedia("(display-mode: standalone)").matches || navigatorInfo.standalone === true;
if (isStandaloneMode) {
return;
}
const rootElement = document.documentElement as FullscreenElement;
if (rootElement.requestFullscreen) {
await rootElement.requestFullscreen();
return;
}
if (rootElement.webkitRequestFullscreen) {
await rootElement.webkitRequestFullscreen();
return;
}
if (rootElement.msRequestFullscreen) {
await rootElement.msRequestFullscreen();
}
};
const bindLaunchFullscreenRequest = (): void => {
const onFirstInteraction = async (): Promise<void> => {
try {
await requestLaunchFullscreen();
} catch (error) {
console.warn("\u5168\u5c4f\u8bf7\u6c42\u88ab\u6d4f\u89c8\u5668\u62d2\u7edd\u6216\u4e0d\u652f\u6301", error);
}
};
window.addEventListener("pointerdown", onFirstInteraction, { once: true, passive: true });
window.addEventListener("keydown", onFirstInteraction, { once: true });
};
axios.interceptors.request.use((config) => { axios.interceptors.request.use((config) => {
const settingStore = useSettingStore(); const settingStore = useSettingStore();
const token = settingStore.connect.token.trim(); const token = settingStore.connect.token.trim();
@@ -35,9 +82,10 @@ app.config.errorHandler = (error) => {
console.error(error); console.error(error);
Toast({ Toast({
theme: "error", theme: "error",
message: "页面发生异常,请稍后重试" message: "\u9875\u9762\u53d1\u751f\u5f02\u5e38\uff0c\u8bf7\u7a0d\u540e\u91cd\u8bd5"
}); });
}; };
app.use(pinia); app.use(pinia);
app.use(router); app.use(router);
app.mount("#root"); app.mount("#root");
bindLaunchFullscreenRequest();

View File

@@ -731,6 +731,12 @@ function clearAutoRefreshTimer(): void {
width: 100%; width: 100%;
height: 16rem; height: 16rem;
background: #FFF; background: #FFF;
touch-action: none;
:deep(svg),
:deep(canvas) {
touch-action: none;
}
} }
.slider { .slider {

View File

@@ -140,14 +140,16 @@ import { useRouter } from "vue-router";
import type { EChartsOption, SeriesOption } from "echarts"; import type { EChartsOption, SeriesOption } from "echarts";
import SystemAPI from "@/api/SystemAPI"; import SystemAPI from "@/api/SystemAPI";
import type { SystemStatusHistoryPoint, SystemStatusSnapshotView } from "@/types/System"; import type { SystemStatusHistoryPoint, SystemStatusSnapshotView } from "@/types/System";
import type { DashboardHistoryMetric } from "@/store/settingStore";
import { useSettingStore } from "@/store/settingStore"; import { useSettingStore } from "@/store/settingStore";
import { IOSize, type LabelValue, Text, Time } from "timi-web"; import { IOSize, type LabelValue, Text, Time } from "timi-web";
import TCellInfo from "@/components/TCellInfo.vue"; import TCellInfo from "@/components/TCellInfo.vue";
import type { ProgressItem } from "@/components/ProgressGroup.vue"; import type { ProgressItem } from "@/components/ProgressGroup.vue";
import ProgressGroup from "@/components/ProgressGroup.vue"; import ProgressGroup from "@/components/ProgressGroup.vue";
type HistoryMetric = DashboardHistoryMetric; type HistoryMetric = "cpu" | "memory" | "jvm" | "network";
const SERVER_SNAPSHOT_METRICS = ["os", "cpu", "memory", "jvm", "network", "hardware", "storage"] as const;
const SERVER_HISTORY_METRICS = ["cpu", "memory", "jvm", "network"] as const;
use([ use([
SVGRenderer, SVGRenderer,
@@ -262,7 +264,7 @@ const historyMetricTabs = computed(() => {
network: "网络" network: "网络"
}; };
return settingStore.dashboard.server.historyMetrics.map((metric) => ({ return SERVER_HISTORY_METRICS.map((metric) => ({
value: metric, value: metric,
label: labelMap[metric] label: labelMap[metric]
})); }));
@@ -635,7 +637,7 @@ async function refreshSnapshot(): Promise<void> {
} }
isSnapshotLoading.value = true; isSnapshotLoading.value = true;
try { try {
const metrics = settingStore.dashboard.server.snapshotMetrics.join(","); const metrics = SERVER_SNAPSHOT_METRICS.join(",");
snapshotView.value = await SystemAPI.getStatus(metrics); snapshotView.value = await SystemAPI.getStatus(metrics);
} catch (error) { } catch (error) {
Toast({ Toast({
@@ -657,7 +659,7 @@ async function refreshHistory(): Promise<void> {
} }
isHistoryLoading.value = true; isHistoryLoading.value = true;
try { try {
const metrics = settingStore.dashboard.server.historyMetrics.join(","); const metrics = SERVER_HISTORY_METRICS.join(",");
const historyView = await SystemAPI.getStatusHistory({ const historyView = await SystemAPI.getStatusHistory({
window: "1h", window: "1h",
metrics metrics
@@ -770,6 +772,12 @@ onMounted(restartAutoRefresh);
width: 100%; width: 100%;
height: 16rem; height: 16rem;
background: #FFF; background: #FFF;
touch-action: none;
:deep(svg),
:deep(canvas) {
touch-action: none;
}
} }
.slider { .slider {

View File

@@ -615,6 +615,12 @@ function clearAutoRefreshTimer(): void {
width: 100%; width: 100%;
height: 16rem; height: 16rem;
background: #FFF; background: #FFF;
touch-action: none;
:deep(svg),
:deep(canvas) {
touch-action: none;
}
} }
.slider { .slider {

View File

@@ -393,10 +393,4 @@ function getDirectoryKey(pathSegments: string[]): string {
font-size: .8125rem; font-size: .8125rem;
} }
} }
:global(.theme-dark) .page {
.go-up {
box-shadow: 0 .35rem 1rem rgba(0, 0, 0, .22);
}
}
</style> </style>

View File

@@ -1,103 +1,84 @@
<template> <template>
<div class="page"> <div class="connect-setting">
<section class="card"> <t-cell-group theme="card">
<div class="head"> <t-cell title="使用 HTTPS">
<p class="tag">连接配置</p> <template #rightIcon>
<h2 class="header">服务器连接</h2> <t-switch v-model="httpsEnabled" />
<p class="desc">这里的配置会持久化保存缺失时应用会强制回到连接引导页</p> </template>
</div> </t-cell>
<t-input
<div class="group"> v-model.trim="form.host"
<p class="label">协议</p> clearable
<div class="protocols"> label="地址"
<t-button placeholder="192.168.1.10 或 nas.local"
size="small" />
theme="primary" <t-input
:variant="form.protocol === 'http' ? 'base' : 'outline'" v-model.trim="form.port"
@click="setProtocol('http')" clearable
> label="端口"
HTTP placeholder="8080"
</t-button> type="number"
<t-button />
size="small" <t-input
theme="primary" v-model.trim="form.token"
:variant="form.protocol === 'https' ? 'base' : 'outline'" clearable
@click="setProtocol('https')" label="密钥"
> placeholder="请输入访问密钥"
HTTPS />
</t-button> </t-cell-group>
</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> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { Toast } from "tdesign-mobile-vue"; import { Toast } from "tdesign-mobile-vue";
import type { ConnectProtocol, ConnectSetting } from "@/store/settingStore"; import { useNavBarStore } from "@/store/navBarStore";
import type { ConnectSetting } from "@/store/settingStore";
import { useSettingStore } from "@/store/settingStore"; import { useSettingStore } from "@/store/settingStore";
defineOptions({ defineOptions({
name: "ConnectSetting" name: "ConnectSetting"
}); });
const router = useRouter();
const navBarStore = useNavBarStore();
const settingStore = useSettingStore(); const settingStore = useSettingStore();
const form = reactive<ConnectSetting>({ // ---------- 顶部导航 ----------
protocol: "http",
host: "", const navBarOwner = `connect-setting-${Math.random().toString(36).slice(2)}`;
port: "",
token: "" const navBarLeftRenderer = defineComponent({
name: "connect-setting-nav-left",
setup() {
const buttonComponent = resolveComponent("t-button");
return () => h(buttonComponent, {
variant: "text",
theme: "default",
size: "small",
onClick: cancelConnect
}, () => "取消");
}
}); });
watch( const navBarRightRenderer = defineComponent({
() => settingStore.connect, name: "connect-setting-nav-right",
(connect) => { setup() {
Object.assign(form, connect); const buttonComponent = resolveComponent("t-button");
},
{ immediate: true }
);
function setProtocol(protocol: ConnectProtocol): void { return () => h(buttonComponent, {
form.protocol = protocol; variant: "text",
} theme: "primary",
size: "small",
function validateConnect(): boolean { onClick: saveConnect
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 cancelConnect(): void {
Object.assign(form, settingStore.connect);
router.back();
} }
function saveConnect(): void { function saveConnect(): void {
@@ -110,71 +91,60 @@ function saveConnect(): void {
theme: "success", theme: "success",
message: "连接配置已保存" message: "连接配置已保存"
}); });
router.back();
} }
function resetConnect(): void { onMounted(() => {
settingStore.resetConnect(); navBarStore.setLeftRenderer(navBarLeftRenderer, navBarOwner);
Object.assign(form, settingStore.connect); navBarStore.setRightRenderer(navBarRightRenderer, navBarOwner);
});
onUnmounted(() => {
navBarStore.clearLeft(navBarOwner);
navBarStore.clearRight(navBarOwner);
});
// ---------- 表单 ----------
const form = reactive<ConnectSetting>({
protocol: "http",
host: "",
port: "",
token: ""
});
watch(() => settingStore.connect, (connect) => {
Object.assign(form, connect);
}, { immediate: true });
const httpsEnabled = computed({
get: () => form.protocol === "https",
set: (value: boolean) => {
form.protocol = value ? "https" : "http";
}
});
function validateConnect(): boolean {
const host = form.host.trim();
const port = form.port.trim();
const token = form.token.trim();
if (!host || !port || !token) {
Toast({ Toast({
theme: "success", theme: "warning",
message: "连接配置已清空" message: "请完整填写连接配置"
}); });
return false;
}
return true;
} }
</script> </script>
<style scoped lang="less"> <style scoped lang="less">
.page { .connect-setting {
padding: 1rem; padding-top: calc(var(--app-nav-offset) + 1rem);
.card { :deep(.t-input:last-child:after) {
gap: 1rem; background: transparent;
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> </style>

View File

@@ -1,88 +1,18 @@
<template> <template>
<div class="page"> <div class="dashboard-setting">
<section class="card"> <t-cell-group title="刷新频率" theme="card">
<div class="head"> <t-cell-info label="当前状态" :value="`${snapshotRefreshValue} 秒`">
<p class="tag">服务器</p> <t-slider v-model="snapshotRefreshValue" :max="10" :min="1" :step="1" />
<h2 class="header">数据刷新与采集</h2> </t-cell-info>
<p class="desc">用于配置服务器仪表板的请求频率和 metrics 参数</p> <t-cell-info label="历史采样" :value="`${historyRefreshValue} 秒`">
</div> <t-slider v-model="historyRefreshValue" :max="10" :min="3" :step="1" />
</t-cell-info>
<div class="group"> </t-cell-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> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { Toast } from "tdesign-mobile-vue"; import { useSettingStore } from "@/store/settingStore";
import {
useSettingStore,
type DashboardHistoryMetric,
type DashboardSnapshotMetric,
type ServerDashboardSetting
} from "@/store/settingStore";
defineOptions({ defineOptions({
name: "DashboardSetting" name: "DashboardSetting"
@@ -90,176 +20,35 @@ defineOptions({
const settingStore = useSettingStore(); const settingStore = useSettingStore();
const snapshotMetricOptions: Array<{ label: string; value: DashboardSnapshotMetric }> = [ const snapshotRefreshValue = computed({
{ label: "系统", value: "os" }, get: (): number => normalizeRefreshValue(settingStore.dashboard.server.snapshotRefreshSeconds, 1, 10, 3),
{ label: "CPU", value: "cpu" }, set: (value: number): void => {
{ 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({ settingStore.setServerDashboard({
snapshotRefreshSeconds: 3, snapshotRefreshSeconds: normalizeRefreshValue(value, 1, 10, 3)
historyRefreshSeconds: 10,
snapshotMetrics: ["os", "cpu", "memory", "jvm", "network", "hardware", "storage"],
historyMetrics: ["cpu", "memory", "jvm", "network"]
}); });
}
});
Toast({ const historyRefreshValue = computed({
theme: "success", get: (): number => normalizeRefreshValue(settingStore.dashboard.server.historyRefreshSeconds, 3, 10, 10),
message: "已恢复默认配置" set: (value: number): void => {
settingStore.setServerDashboard({
historyRefreshSeconds: normalizeRefreshValue(value, 3, 10, 10)
}); });
} }
});
function normalizeSecondValue(value: string, fallback: number): number { function normalizeRefreshValue(value: number, min: number, max: number, fallback: number): number {
const numberValue = Number(value); if (Number.isNaN(value)) {
if (Number.isNaN(numberValue)) {
return fallback; return fallback;
} }
const normalizedValue = Math.floor(value);
return Math.min(Math.max(Math.floor(numberValue), 1), 120); return Math.min(Math.max(normalizedValue, min), max);
} }
</script> </script>
<style scoped lang="less"> <style scoped lang="less">
.page { .dashboard-setting {
gap: 1rem; padding-top: var(--app-nav-offset);
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> </style>

View File

@@ -1,24 +1,18 @@
<template> <template>
<div class="page"> <div class="theme-setting">
<section class="card"> <t-cell-group theme="card">
<div class="head"> <t-radio
<p class="tag">主题</p>
<h2 class="header">界面模式</h2>
<p class="desc">当前提供浅色深色和跟随系统三种模式</p>
</div>
<div class="modes">
<t-button
v-for="item in themeModeList" v-for="item in themeModeList"
:key="item.value" :key="item.value"
theme="primary" class="block"
:variant="globalUIStore.themeMode === item.value ? 'base' : 'outline'" default-checked
allow-uncheck
:label="item.label"
icon="line"
:checked="globalUIStore.themeMode === item.value"
@click="globalUIStore.setThemeMode(item.value)" @click="globalUIStore.setThemeMode(item.value)"
> />
<span v-text="item.label" /> </t-cell-group>
</t-button>
</div>
</section>
</div> </div>
</template> </template>
@@ -39,55 +33,14 @@ const themeModeList: Array<{ label: string; value: ThemeMode }> = [
</script> </script>
<style scoped lang="less"> <style scoped lang="less">
.page { .theme-setting {
padding: 1rem; padding-top: calc(var(--app-nav-offset) + 1rem);
.card { :deep(.t-radio:last-child) {
gap: 1rem;
display: flex; .t-radio__border {
padding: 1rem; display: none;
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> </style>

View File

@@ -1,99 +1,31 @@
<template> <template>
<div class="page"> <div class="settings-tab">
<section class="card"> <h2 class="title">Timi Server</h2>
<div class="head"> <t-cell-group theme="card">
<p class="tag">系统设置</p> <t-cell title="连接配置" arrow @click="router.push('/settings/connect')" />
<h2 class="header">配置入口</h2> <t-cell title="仪表板" arrow @click="router.push('/settings/dashboard')" />
<p class="desc">设置页只保留入口具体配置在独立页面中维护</p> <t-cell title="主题" arrow @click="router.push('/settings/theme')" />
</div> </t-cell-group>
<div class="entries"> <copyright icp="粤ICP备2025368555号-1" domain="imyeyu.com" author="夜雨" />
<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>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { Copyright } from "timi-web";
defineOptions({ defineOptions({
name: "SettingsTab" name: "SettingsTab"
}); });
const router = useRouter(); const router = useRouter();
function openConnectSetting(): void {
void router.push("/settings/connect");
}
function openDashboardSetting(): void {
void router.push("/settings/dashboard");
}
function openThemeSetting(): void {
void router.push("/settings/theme");
}
</script> </script>
<style scoped lang="less"> <style scoped lang="less">
.page { .settings-tab {
gap: 1rem; padding-top: calc(var(--app-nav-offset) + 1rem);
display: flex;
padding: 1rem;
flex-direction: column;
.card { .title {
gap: 1rem; text-align: center;
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;
}
.entries {
gap: .75rem;
display: flex;
flex-direction: column;
}
}
:global(.theme-dark) .page {
.card {
box-shadow: 0 .35rem 1rem rgba(0, 0, 0, .2);
} }
} }
</style> </style>

View File

@@ -14,6 +14,12 @@ import ServerDetail from "@/pages/dashboard/ServerDashboard/ServerDetail.vue";
import ServerPerformanceDetail from "@/pages/dashboard/ServerDashboard/ServerPerformanceDetail.vue"; import ServerPerformanceDetail from "@/pages/dashboard/ServerDashboard/ServerPerformanceDetail.vue";
import ServerPartitionsDetail from "@/pages/dashboard/ServerDashboard/ServerPartitionsDetail.vue"; import ServerPartitionsDetail from "@/pages/dashboard/ServerDashboard/ServerPartitionsDetail.vue";
import DockerContainerDetail from "@/pages/dashboard/DockerDashboard/DockerContainerDetail.vue"; import DockerContainerDetail from "@/pages/dashboard/DockerDashboard/DockerContainerDetail.vue";
import {
hasProgrammaticBackNavigation,
clearProgrammaticBackNavigation,
markProgrammaticBackNavigation
} from "@/utils/backNavigationSignal";
import { useTabBarStore } from "@/store/tabBarStore";
const router = createRouter({ const router = createRouter({
history: createWebHistory("/"), history: createWebHistory("/"),
@@ -131,7 +137,8 @@ const router = createRouter({
navBarVisible: true, navBarVisible: true,
navBarCanBack: true, navBarCanBack: true,
navBarTitle: "连接配置", navBarTitle: "连接配置",
tabBarVisible: false tabBarVisible: false,
bodyBackground: "#F4F4F4"
}, },
component: ConnectSetting component: ConnectSetting
}, },
@@ -143,7 +150,8 @@ const router = createRouter({
navBarVisible: true, navBarVisible: true,
navBarCanBack: true, navBarCanBack: true,
navBarTitle: "仪表板设置", navBarTitle: "仪表板设置",
tabBarVisible: false tabBarVisible: false,
bodyBackground: "#F4F4F4"
}, },
component: DashboardSetting component: DashboardSetting
}, },
@@ -155,7 +163,8 @@ const router = createRouter({
navBarVisible: true, navBarVisible: true,
navBarCanBack: true, navBarCanBack: true,
navBarTitle: "主题设置", navBarTitle: "主题设置",
tabBarVisible: false tabBarVisible: false,
bodyBackground: "#F4F4F4"
}, },
component: ThemeSetting component: ThemeSetting
} }
@@ -177,8 +186,60 @@ const router = createRouter({
] ]
}); });
router.beforeEach((to: RouteLocationNormalized) => { const rawBack = router.back.bind(router);
const rawGo = router.go.bind(router);
const isIOS = /iP(ad|hone|od)/i.test(window.navigator.userAgent);
// 与 MainLayout 的 tabbar 过渡时长保持一致,额外预留一点缓冲时间
const TAB_BAR_TRANSITION_MS = 520;
const TAB_BAR_SKIP_EXTRA_MS = 180;
const depthCache = new Map<string, number>();
function calcDepth(sourceRoute: RouteLocationNormalized): number {
if (!sourceRoute.meta.dynamicDepth) {
return Number(sourceRoute.meta.depth ?? 0);
}
const fullPath = sourceRoute.fullPath;
const cachedDepth = depthCache.get(fullPath);
if (cachedDepth !== undefined) {
return cachedDepth;
}
const baseDepth = Number(sourceRoute.meta.baseDepth ?? 0);
const pathSegments = fullPath.split("?")[0].split("/").filter((pathSegment) => pathSegment !== "");
const routeDepth = baseDepth + (pathSegments.length - 1);
depthCache.set(fullPath, routeDepth);
return routeDepth;
}
// 全局代理:所有代码触发的后退都自动标记为“主动返回”。
router.back = () => {
markProgrammaticBackNavigation();
rawBack();
};
// 覆盖 go(-1 / -n) 场景,保证与 back() 行为一致。
router.go = (delta) => {
if (delta < 0) {
markProgrammaticBackNavigation();
}
rawGo(delta);
};
router.beforeEach((to: RouteLocationNormalized, from: RouteLocationNormalized) => {
const tabBarStore = useTabBarStore();
const settingStore = useSettingStore(); const settingStore = useSettingStore();
const toDepth = calcDepth(to);
const fromDepth = calcDepth(from);
const isBackwardNavigation = toDepth < fromDepth;
// 代码主动调用 back/go(-1) 的返回,仍保留 tabbar 进入动画
const isProgrammaticBack = hasProgrammaticBackNavigation();
const isTabBarEntering = !from.meta.tabBarVisible && !!to.meta.tabBarVisible;
// 仅 iOS 手势返回(非代码触发)且无 tabbar -> 有 tabbar 时,禁用本次 tabbar 进入动画
const shouldSkipTabBarEnterTransition = isIOS && isBackwardNavigation && !isProgrammaticBack && isTabBarEntering;
if (shouldSkipTabBarEnterTransition) {
tabBarStore.skipTransitionOnce();
}
if (to.meta.ignoreConnectCheck) { if (to.meta.ignoreConnectCheck) {
return true; return true;
@@ -197,6 +258,15 @@ router.beforeEach((to: RouteLocationNormalized) => {
}); });
router.afterEach((to: RouteLocationNormalized) => { router.afterEach((to: RouteLocationNormalized) => {
const tabBarStore = useTabBarStore();
if (tabBarStore.shouldSkipTransition) {
// 本次导航稳定后再恢复过渡,避免手势返回期间出现二次进入动画
window.setTimeout(() => {
tabBarStore.restoreTransition();
}, TAB_BAR_TRANSITION_MS + TAB_BAR_SKIP_EXTRA_MS);
}
// 一次导航结束后清理信号,防止影响后续动画判定。
clearProgrammaticBackNavigation();
const globalUIStore = useGlobalUIStore(); const globalUIStore = useGlobalUIStore();
const targetBackground = typeof to.meta.bodyBackground === "string" ? to.meta.bodyBackground : DEFAULT_BODY_BACKGROUND; const targetBackground = typeof to.meta.bodyBackground === "string" ? to.meta.bodyBackground : DEFAULT_BODY_BACKGROUND;
globalUIStore.setBodyBackground(targetBackground); globalUIStore.setBodyBackground(targetBackground);

View File

@@ -43,7 +43,7 @@ const tabs: RouteRecordRaw[] = [
tabBarVisible: true, tabBarVisible: true,
tabBarRoot: true, tabBarRoot: true,
tabBarPadding: true, tabBarPadding: true,
bodyBackground: "#FFF" bodyBackground: "#F4F4F4"
}, },
component: SettingsTab component: SettingsTab
} }

View File

@@ -8,6 +8,8 @@ export const useNavBarStore = defineStore("nav-bar", () => {
const height = ref(0); const height = ref(0);
const title = ref(""); const title = ref("");
const backTo = ref<string>(); const backTo = ref<string>();
const leftRenderer = shallowRef<Component>();
const leftOwner = ref<string>();
const rightRenderer = shallowRef<Component>(); const rightRenderer = shallowRef<Component>();
const rightOwner = ref<string>(); const rightOwner = ref<string>();
@@ -34,6 +36,20 @@ export const useNavBarStore = defineStore("nav-bar", () => {
title.value = value || ""; title.value = value || "";
} }
function setLeftRenderer(renderer?: Component, owner?: string): void {
leftRenderer.value = renderer;
leftOwner.value = owner;
}
function clearLeft(owner?: string): void {
if (owner && leftOwner.value !== owner) {
return;
}
leftRenderer.value = undefined;
leftOwner.value = undefined;
}
function setRightRenderer(renderer?: Component, owner?: string): void { function setRightRenderer(renderer?: Component, owner?: string): void {
rightRenderer.value = renderer; rightRenderer.value = renderer;
rightOwner.value = owner; rightOwner.value = owner;
@@ -52,12 +68,15 @@ export const useNavBarStore = defineStore("nav-bar", () => {
height, height,
title, title,
backTo, backTo,
leftRenderer,
rightRenderer, rightRenderer,
isShowing, isShowing,
canBack, canBack,
setHeight, setHeight,
setBackTo, setBackTo,
setTitle, setTitle,
setLeftRenderer,
clearLeft,
setRightRenderer, setRightRenderer,
clearRight clearRight
}; };

View File

@@ -12,14 +12,9 @@ export interface ConnectSetting {
token: string; token: string;
} }
export type DashboardSnapshotMetric = "os" | "cpu" | "memory" | "jvm" | "network" | "hardware" | "storage";
export type DashboardHistoryMetric = "cpu" | "memory" | "jvm" | "network";
export interface ServerDashboardSetting { export interface ServerDashboardSetting {
snapshotRefreshSeconds: number; snapshotRefreshSeconds: number;
historyRefreshSeconds: number; historyRefreshSeconds: number;
snapshotMetrics: DashboardSnapshotMetric[];
historyMetrics: DashboardHistoryMetric[];
} }
export interface DashboardSetting { export interface DashboardSetting {
@@ -40,9 +35,7 @@ const defaultConnectSetting = (): ConnectSetting => ({
const defaultServerDashboardSetting = (): ServerDashboardSetting => ({ const defaultServerDashboardSetting = (): ServerDashboardSetting => ({
snapshotRefreshSeconds: 3, snapshotRefreshSeconds: 3,
historyRefreshSeconds: 10, historyRefreshSeconds: 10
snapshotMetrics: ["os", "cpu", "memory", "jvm", "network", "hardware", "storage"],
historyMetrics: ["cpu", "memory", "jvm", "network"]
}); });
const defaultDashboardSetting = (): DashboardSetting => ({ const defaultDashboardSetting = (): DashboardSetting => ({
@@ -65,20 +58,6 @@ function normalizeConnectSetting(connect?: Partial<ConnectSetting>): ConnectSett
}; };
} }
function normalizeSnapshotMetrics(metrics?: DashboardSnapshotMetric[]): DashboardSnapshotMetric[] {
const validMetrics: DashboardSnapshotMetric[] = ["os", "cpu", "memory", "jvm", "network", "hardware", "storage"];
const metricList = Array.isArray(metrics) ? metrics : [];
const normalizedMetrics = validMetrics.filter((metric) => metricList.includes(metric));
return normalizedMetrics.length ? normalizedMetrics : defaultServerDashboardSetting().snapshotMetrics;
}
function normalizeHistoryMetrics(metrics?: DashboardHistoryMetric[]): DashboardHistoryMetric[] {
const validMetrics: DashboardHistoryMetric[] = ["cpu", "memory", "jvm", "network"];
const metricList = Array.isArray(metrics) ? metrics : [];
const normalizedMetrics = validMetrics.filter((metric) => metricList.includes(metric));
return normalizedMetrics.length ? normalizedMetrics : defaultServerDashboardSetting().historyMetrics;
}
function normalizeRefreshSeconds(value?: number, fallback = 3): number { function normalizeRefreshSeconds(value?: number, fallback = 3): number {
if (typeof value !== "number" || Number.isNaN(value)) { if (typeof value !== "number" || Number.isNaN(value)) {
return fallback; return fallback;
@@ -91,9 +70,7 @@ function normalizeServerDashboardSetting(setting?: Partial<ServerDashboardSettin
const fallback = defaultServerDashboardSetting(); const fallback = defaultServerDashboardSetting();
return { return {
snapshotRefreshSeconds: normalizeRefreshSeconds(setting?.snapshotRefreshSeconds, fallback.snapshotRefreshSeconds), snapshotRefreshSeconds: normalizeRefreshSeconds(setting?.snapshotRefreshSeconds, fallback.snapshotRefreshSeconds),
historyRefreshSeconds: normalizeRefreshSeconds(setting?.historyRefreshSeconds, fallback.historyRefreshSeconds), historyRefreshSeconds: normalizeRefreshSeconds(setting?.historyRefreshSeconds, fallback.historyRefreshSeconds)
snapshotMetrics: normalizeSnapshotMetrics(setting?.snapshotMetrics),
historyMetrics: normalizeHistoryMetrics(setting?.historyMetrics)
}; };
} }

View File

@@ -2,10 +2,23 @@ import { defineStore } from "pinia";
export const useTabBarStore = defineStore("tab-bar", () => { export const useTabBarStore = defineStore("tab-bar", () => {
const router = useRouter(); const router = useRouter();
// 仅用于“当前这一次”禁用 tabbar 过渡,导航结束后会恢复
const shouldSkipTransition = ref(false);
const isShowing = computed(() => !!router.currentRoute.value.meta.tabBarVisible); const isShowing = computed(() => !!router.currentRoute.value.meta.tabBarVisible);
function skipTransitionOnce(): void {
shouldSkipTransition.value = true;
}
function restoreTransition(): void {
shouldSkipTransition.value = false;
}
return { return {
isShowing isShowing,
shouldSkipTransition,
skipTransitionOnce,
restoreTransition
}; };
}); });

View File

@@ -0,0 +1,28 @@
let programmaticBackAt = 0;
// 主动返回信号有效期,避免历史信号污染后续导航
const PROGRAMMATIC_BACK_SIGNAL_TTL_MS = 1200;
// 由代码触发 router.back / router.go(-1) 时打标
export function markProgrammaticBackNavigation(): void {
programmaticBackAt = Date.now();
}
// 读取主动返回信号,超时后自动清理
export function hasProgrammaticBackNavigation(): boolean {
if (programmaticBackAt < 1) {
return false;
}
const hasSignal = Date.now() - programmaticBackAt <= PROGRAMMATIC_BACK_SIGNAL_TTL_MS;
if (!hasSignal) {
programmaticBackAt = 0;
}
return hasSignal;
}
// 在导航结束后统一清理信号
export function clearProgrammaticBackNavigation(): void {
programmaticBackAt = 0;
}