Compare commits
23 Commits
d4bef26c96
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5e192a90e1 | ||
|
|
6f067477f0 | ||
|
|
11c1199449 | ||
|
|
42efa2b370 | ||
|
|
631122c79b | ||
|
|
9942afafa7 | ||
|
|
2f45330ebd | ||
|
|
753ab56e06 | ||
|
|
a6c89717a6 | ||
|
|
df6c6b78c9 | ||
|
|
3393cca441 | ||
|
|
b95d7fe9b6 | ||
|
|
ed2d2ef233 | ||
|
|
611830f393 | ||
|
|
489cbb5d0f | ||
|
|
d9e32c4dbe | ||
|
|
788db69bc8 | ||
|
|
594d1b4222 | ||
|
|
d819249ebf | ||
|
|
59016f492f | ||
|
|
603a503644 | ||
|
|
ded231671a | ||
|
|
2665acc885 |
12
.editorconfig
Normal file
12
.editorconfig
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
root = true
|
||||||
|
|
||||||
|
[*]
|
||||||
|
charset = utf-8
|
||||||
|
end_of_line = lf
|
||||||
|
indent_style = tab
|
||||||
|
indent_size = 4
|
||||||
|
insert_final_newline = true
|
||||||
|
trim_trailing_whitespace = true
|
||||||
|
|
||||||
|
[*.md]
|
||||||
|
trim_trailing_whitespace = false
|
||||||
1
.env.development
Normal file
1
.env.development
Normal file
@@ -0,0 +1 @@
|
|||||||
|
VITE_API=http://localhost:8091
|
||||||
44
.eslintrc.cjs
Normal file
44
.eslintrc.cjs
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
module.exports = {
|
||||||
|
root: true,
|
||||||
|
env: {
|
||||||
|
browser: true,
|
||||||
|
es2022: true,
|
||||||
|
node: true
|
||||||
|
},
|
||||||
|
extends: [
|
||||||
|
"eslint:recommended",
|
||||||
|
"plugin:vue/vue3-essential",
|
||||||
|
"plugin:@typescript-eslint/recommended",
|
||||||
|
"./.eslintrc-auto-import.json"
|
||||||
|
],
|
||||||
|
parser: "vue-eslint-parser",
|
||||||
|
parserOptions: {
|
||||||
|
parser: "@typescript-eslint/parser",
|
||||||
|
ecmaVersion: "latest",
|
||||||
|
sourceType: "module",
|
||||||
|
extraFileExtensions: [".vue"]
|
||||||
|
},
|
||||||
|
plugins: ["vue", "@typescript-eslint"],
|
||||||
|
rules: {
|
||||||
|
"camelcase": "error",
|
||||||
|
"comma-dangle": ["error", "never"],
|
||||||
|
"eqeqeq": ["error", "always"],
|
||||||
|
"max-len": ["error", { code: 220 }],
|
||||||
|
"no-console": ["warn", { allow: ["warn", "error"] }],
|
||||||
|
"no-debugger": "warn",
|
||||||
|
"no-tabs": ["error", { allowIndentationTabs: true }],
|
||||||
|
"quotes": ["error", "double"],
|
||||||
|
"semi": ["error", "always"],
|
||||||
|
"space-before-function-paren": [
|
||||||
|
"error",
|
||||||
|
{
|
||||||
|
"anonymous": "always",
|
||||||
|
"named": "never",
|
||||||
|
"asyncArrow": "always"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"vue/multi-word-component-names": "off",
|
||||||
|
"vue/no-v-html": "off",
|
||||||
|
"@typescript-eslint/no-explicit-any": "off"
|
||||||
|
}
|
||||||
|
};
|
||||||
237
.gitignore
vendored
237
.gitignore
vendored
@@ -1,228 +1,31 @@
|
|||||||
# ---> Vue
|
/AGENTS.md
|
||||||
# gitignore template for Vue.js projects
|
/.env.production
|
||||||
#
|
/.eslintrc-auto-import.json
|
||||||
# Recommended template: Node.gitignore
|
auto-imports.d.ts
|
||||||
|
components.d.ts
|
||||||
|
/.npmrc
|
||||||
|
|
||||||
# TODO: where does this rule come from?
|
|
||||||
docs/_book
|
|
||||||
|
|
||||||
# TODO: where does this rule come from?
|
|
||||||
test/
|
|
||||||
|
|
||||||
# ---> Node
|
|
||||||
# Logs
|
# Logs
|
||||||
logs
|
logs
|
||||||
*.log
|
*.log
|
||||||
npm-debug.log*
|
npm-debug.log*
|
||||||
yarn-debug.log*
|
yarn-debug.log*
|
||||||
yarn-error.log*
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
lerna-debug.log*
|
lerna-debug.log*
|
||||||
.pnpm-debug.log*
|
|
||||||
|
|
||||||
# Diagnostic reports (https://nodejs.org/api/report.html)
|
node_modules
|
||||||
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
|
|
||||||
|
|
||||||
# Runtime data
|
|
||||||
pids
|
|
||||||
*.pid
|
|
||||||
*.seed
|
|
||||||
*.pid.lock
|
|
||||||
|
|
||||||
# Directory for instrumented libs generated by jscoverage/JSCover
|
|
||||||
lib-cov
|
|
||||||
|
|
||||||
# Coverage directory used by tools like istanbul
|
|
||||||
coverage
|
|
||||||
*.lcov
|
|
||||||
|
|
||||||
# nyc test coverage
|
|
||||||
.nyc_output
|
|
||||||
|
|
||||||
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
|
|
||||||
.grunt
|
|
||||||
|
|
||||||
# Bower dependency directory (https://bower.io/)
|
|
||||||
bower_components
|
|
||||||
|
|
||||||
# node-waf configuration
|
|
||||||
.lock-wscript
|
|
||||||
|
|
||||||
# Compiled binary addons (https://nodejs.org/api/addons.html)
|
|
||||||
build/Release
|
|
||||||
|
|
||||||
# Dependency directories
|
|
||||||
node_modules/
|
|
||||||
jspm_packages/
|
|
||||||
|
|
||||||
# Snowpack dependency directory (https://snowpack.dev/)
|
|
||||||
web_modules/
|
|
||||||
|
|
||||||
# TypeScript cache
|
|
||||||
*.tsbuildinfo
|
|
||||||
|
|
||||||
# Optional npm cache directory
|
|
||||||
.npm
|
|
||||||
|
|
||||||
# Optional eslint cache
|
|
||||||
.eslintcache
|
|
||||||
|
|
||||||
# Optional stylelint cache
|
|
||||||
.stylelintcache
|
|
||||||
|
|
||||||
# Microbundle cache
|
|
||||||
.rpt2_cache/
|
|
||||||
.rts2_cache_cjs/
|
|
||||||
.rts2_cache_es/
|
|
||||||
.rts2_cache_umd/
|
|
||||||
|
|
||||||
# Optional REPL history
|
|
||||||
.node_repl_history
|
|
||||||
|
|
||||||
# Output of 'npm pack'
|
|
||||||
*.tgz
|
|
||||||
|
|
||||||
# Yarn Integrity file
|
|
||||||
.yarn-integrity
|
|
||||||
|
|
||||||
# dotenv environment variable files
|
|
||||||
.env
|
|
||||||
.env.development.local
|
|
||||||
.env.test.local
|
|
||||||
.env.production.local
|
|
||||||
.env.local
|
|
||||||
|
|
||||||
# parcel-bundler cache (https://parceljs.org/)
|
|
||||||
.cache
|
|
||||||
.parcel-cache
|
|
||||||
|
|
||||||
# Next.js build output
|
|
||||||
.next
|
|
||||||
out
|
|
||||||
|
|
||||||
# Nuxt.js build / generate output
|
|
||||||
.nuxt
|
|
||||||
dist
|
dist
|
||||||
|
dist-ssr
|
||||||
|
*.local
|
||||||
|
|
||||||
# Gatsby files
|
# Editor directories and files
|
||||||
.cache/
|
.vscode/*
|
||||||
# Comment in the public line in if your project uses Gatsby and not Next.js
|
!.vscode/extensions.json
|
||||||
# https://nextjs.org/blog/next-9-1#public-directory-support
|
.idea
|
||||||
# public
|
.DS_Store
|
||||||
|
*.suo
|
||||||
# vuepress build output
|
*.ntvs*
|
||||||
.vuepress/dist
|
*.njsproj
|
||||||
|
*.sln
|
||||||
# vuepress v2.x temp and cache directory
|
*.sw?
|
||||||
.temp
|
|
||||||
.cache
|
|
||||||
|
|
||||||
# vitepress build output
|
|
||||||
**/.vitepress/dist
|
|
||||||
|
|
||||||
# vitepress cache directory
|
|
||||||
**/.vitepress/cache
|
|
||||||
|
|
||||||
# Docusaurus cache and generated files
|
|
||||||
.docusaurus
|
|
||||||
|
|
||||||
# Serverless directories
|
|
||||||
.serverless/
|
|
||||||
|
|
||||||
# FuseBox cache
|
|
||||||
.fusebox/
|
|
||||||
|
|
||||||
# DynamoDB Local files
|
|
||||||
.dynamodb/
|
|
||||||
|
|
||||||
# TernJS port file
|
|
||||||
.tern-port
|
|
||||||
|
|
||||||
# Stores VSCode versions used for testing VSCode extensions
|
|
||||||
.vscode-test
|
|
||||||
|
|
||||||
# yarn v2
|
|
||||||
.yarn/cache
|
|
||||||
.yarn/unplugged
|
|
||||||
.yarn/build-state.yml
|
|
||||||
.yarn/install-state.gz
|
|
||||||
.pnp.*
|
|
||||||
|
|
||||||
# ---> JetBrains
|
|
||||||
# Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio, WebStorm and Rider
|
|
||||||
# Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839
|
|
||||||
|
|
||||||
# User-specific stuff
|
|
||||||
.idea/**/workspace.xml
|
|
||||||
.idea/**/tasks.xml
|
|
||||||
.idea/**/usage.statistics.xml
|
|
||||||
.idea/**/dictionaries
|
|
||||||
.idea/**/shelf
|
|
||||||
|
|
||||||
# AWS User-specific
|
|
||||||
.idea/**/aws.xml
|
|
||||||
|
|
||||||
# Generated files
|
|
||||||
.idea/**/contentModel.xml
|
|
||||||
|
|
||||||
# Sensitive or high-churn files
|
|
||||||
.idea/**/dataSources/
|
|
||||||
.idea/**/dataSources.ids
|
|
||||||
.idea/**/dataSources.local.xml
|
|
||||||
.idea/**/sqlDataSources.xml
|
|
||||||
.idea/**/dynamic.xml
|
|
||||||
.idea/**/uiDesigner.xml
|
|
||||||
.idea/**/dbnavigator.xml
|
|
||||||
|
|
||||||
# Gradle
|
|
||||||
.idea/**/gradle.xml
|
|
||||||
.idea/**/libraries
|
|
||||||
|
|
||||||
# Gradle and Maven with auto-import
|
|
||||||
# When using Gradle or Maven with auto-import, you should exclude module files,
|
|
||||||
# since they will be recreated, and may cause churn. Uncomment if using
|
|
||||||
# auto-import.
|
|
||||||
# .idea/artifacts
|
|
||||||
# .idea/compiler.xml
|
|
||||||
# .idea/jarRepositories.xml
|
|
||||||
# .idea/modules.xml
|
|
||||||
# .idea/*.iml
|
|
||||||
# .idea/modules
|
|
||||||
# *.iml
|
|
||||||
# *.ipr
|
|
||||||
|
|
||||||
# CMake
|
|
||||||
cmake-build-*/
|
|
||||||
|
|
||||||
# Mongo Explorer plugin
|
|
||||||
.idea/**/mongoSettings.xml
|
|
||||||
|
|
||||||
# File-based project format
|
|
||||||
*.iws
|
|
||||||
|
|
||||||
# IntelliJ
|
|
||||||
out/
|
|
||||||
|
|
||||||
# mpeltonen/sbt-idea plugin
|
|
||||||
.idea_modules/
|
|
||||||
|
|
||||||
# JIRA plugin
|
|
||||||
atlassian-ide-plugin.xml
|
|
||||||
|
|
||||||
# Cursive Clojure plugin
|
|
||||||
.idea/replstate.xml
|
|
||||||
|
|
||||||
# SonarLint plugin
|
|
||||||
.idea/sonarlint/
|
|
||||||
|
|
||||||
# Crashlytics plugin (for Android Studio and IntelliJ)
|
|
||||||
com_crashlytics_export_strings.xml
|
|
||||||
crashlytics.properties
|
|
||||||
crashlytics-build.properties
|
|
||||||
fabric.properties
|
|
||||||
|
|
||||||
# Editor-based Rest Client
|
|
||||||
.idea/httpRequests
|
|
||||||
|
|
||||||
# Android studio 3.1+ serialized cache file
|
|
||||||
.idea/caches/build_file_checksums.ser
|
|
||||||
|
|
||||||
|
|||||||
22
README.md
22
README.md
@@ -1,3 +1,23 @@
|
|||||||
# timi-server
|
# timi-server
|
||||||
|
|
||||||
移动端 NAS 管理
|
基于 Vue 3、Vite、TypeScript 和 TDesign Mobile Vue 的移动端 NAS 管理项目基础骨架。
|
||||||
|
|
||||||
|
## 当前已完成
|
||||||
|
|
||||||
|
- 基础工程配置:Vite、TypeScript、ESLint、Less、自动导入
|
||||||
|
- 基础布局结构:`RootLayout`、`MainLayout`、`PageTransition`
|
||||||
|
- 基础页面路由:文件管理、服务器状态、文件阅读、系统设置、登录页、占位详情页
|
||||||
|
- 基础深色模式适配
|
||||||
|
|
||||||
|
## 开发命令
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
npm run dev
|
||||||
|
npm run build
|
||||||
|
npm run lint
|
||||||
|
```
|
||||||
|
|
||||||
|
## 说明
|
||||||
|
|
||||||
|
当前仓库仅完成项目骨架和占位页面,方便后续继续补业务模块,不包含实际接口和业务逻辑。
|
||||||
|
|||||||
20
index.html
Normal file
20
index.html
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<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" />
|
||||||
|
<link rel="manifest" href="/manifest.webmanifest" />
|
||||||
|
<title>Timi Server</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.ts"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
46
package.json
Normal file
46
package.json
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
{
|
||||||
|
"name": "timi-server",
|
||||||
|
"private": true,
|
||||||
|
"version": "1.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite --host",
|
||||||
|
"build": "vue-tsc --noEmit && vite build",
|
||||||
|
"preview": "vite preview",
|
||||||
|
"lint": "eslint . --ext .ts,.vue",
|
||||||
|
"type-check": "vue-tsc --noEmit"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"axios": "1.15.0",
|
||||||
|
"less": "^4.3.0",
|
||||||
|
"echarts": "^6.0.0",
|
||||||
|
"vue-echarts": "^8.0.1",
|
||||||
|
"music-metadata-browser": "^2.5.11",
|
||||||
|
"pinia": "^3.0.2",
|
||||||
|
"tdesign-mobile-vue": "^1.13.2",
|
||||||
|
"timi-tdesign-mobile": "0.0.9",
|
||||||
|
"timi-web": "0.0.18",
|
||||||
|
"ts-node": "^10.9.2",
|
||||||
|
"vue": "^3.5.16",
|
||||||
|
"vue-router": "4.5.1",
|
||||||
|
"vue-virtual-scroller": "2.0.0-beta.8"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@tsconfig/node20": "^20.1.4",
|
||||||
|
"@types/node": "^24.2.0",
|
||||||
|
"@typescript-eslint/eslint-plugin": "^8.29.0",
|
||||||
|
"@typescript-eslint/parser": "^8.29.0",
|
||||||
|
"@vitejs/plugin-vue": "^5.2.4",
|
||||||
|
"@vue/compiler-sfc": "^3.5.16",
|
||||||
|
"@vue/eslint-config-typescript": "^14.5.0",
|
||||||
|
"@vue/tsconfig": "^0.7.0",
|
||||||
|
"eslint": "9.26.0",
|
||||||
|
"eslint-plugin-vue": "^9.33.0",
|
||||||
|
"typescript": "~5.8.3",
|
||||||
|
"unplugin-auto-import": "^19.1.1",
|
||||||
|
"unplugin-vue-components": "^28.4.1",
|
||||||
|
"vue-eslint-parser": "^9.4.3",
|
||||||
|
"vite": "^6.3.5",
|
||||||
|
"vue-tsc": "^2.2.12"
|
||||||
|
}
|
||||||
|
}
|
||||||
4149
pnpm-lock.yaml
generated
Normal file
4149
pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
10
public/manifest.webmanifest
Normal file
10
public/manifest.webmanifest
Normal 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"
|
||||||
|
}
|
||||||
47
src/Root.vue
Normal file
47
src/Root.vue
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
<template>
|
||||||
|
<router-view />
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { useGlobalUIStore } from "@/store/globalUIStore";
|
||||||
|
|
||||||
|
const globalUIStore = useGlobalUIStore();
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => globalUIStore.bodyBackground,
|
||||||
|
(background: string) => {
|
||||||
|
document.body.style.background = background;
|
||||||
|
},
|
||||||
|
{ immediate: true }
|
||||||
|
);
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => globalUIStore.themeClass,
|
||||||
|
(themeClass: string) => {
|
||||||
|
document.documentElement.classList.toggle("theme-dark", themeClass === "theme-dark");
|
||||||
|
},
|
||||||
|
{ immediate: true }
|
||||||
|
);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="less">
|
||||||
|
html,
|
||||||
|
body,
|
||||||
|
#root {
|
||||||
|
height: 100%;
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.t-overlay {
|
||||||
|
transition-duration: 500ms !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.t-popup {
|
||||||
|
animation-duration: 460ms;
|
||||||
|
animation-timing-function: var(--tui-bezier);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
28
src/api/DockerAPI.ts
Normal file
28
src/api/DockerAPI.ts
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import { axios, Text } from "timi-web";
|
||||||
|
import { useSettingStore } from "@/store/settingStore";
|
||||||
|
import type { DockerContainerHistoryView, DockerContainerStatusView, DockerContainerSummaryView } from "@/types/Docker";
|
||||||
|
|
||||||
|
async function getContainers(): Promise<DockerContainerSummaryView[]> {
|
||||||
|
const settingStore = useSettingStore();
|
||||||
|
return await axios.get(`${settingStore.resolveBaseURL()}/system/docker/container`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getContainerStatus(containerId: string): Promise<DockerContainerStatusView | null> {
|
||||||
|
const settingStore = useSettingStore();
|
||||||
|
return await axios.get(`${settingStore.resolveBaseURL()}/system/docker/container/${encodeURIComponent(containerId)}/status`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getContainerHistory(containerId: string, params?: {
|
||||||
|
window?: string;
|
||||||
|
}): Promise<DockerContainerHistoryView | null> {
|
||||||
|
const settingStore = useSettingStore();
|
||||||
|
return await axios.get(`${settingStore.resolveBaseURL()}/system/docker/container/${encodeURIComponent(containerId)}/history?${Text.urlArgs({
|
||||||
|
window: params?.window
|
||||||
|
})}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default {
|
||||||
|
getContainers,
|
||||||
|
getContainerStatus,
|
||||||
|
getContainerHistory
|
||||||
|
};
|
||||||
27
src/api/FileAPI.ts
Normal file
27
src/api/FileAPI.ts
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import { axios } from "timi-web";
|
||||||
|
import type { ServerFile } from "@/types/File";
|
||||||
|
import { useSettingStore } from "@/store/settingStore";
|
||||||
|
|
||||||
|
async function list(pathSegments: string[]): Promise<ServerFile[]> {
|
||||||
|
const settingStore = useSettingStore();
|
||||||
|
const path = pathSegments.length ? `/${pathSegments.map((segment) => encodeURIComponent(segment)).join("/")}` : "";
|
||||||
|
return await axios.get(`${settingStore.resolveBaseURL()}/system/file/list${path}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildURL(path: string, action = "download"): string {
|
||||||
|
const settingStore = useSettingStore();
|
||||||
|
const normalizedPath = path.trim();
|
||||||
|
const segments = normalizedPath.split("/").filter((segment) => !!segment);
|
||||||
|
const requestPath = segments.length ? `/${segments.map((segment) => encodeURIComponent(segment)).join("/")}` : "";
|
||||||
|
const url = new URL(`${settingStore.resolveBaseURL()}/system/file/${action}${requestPath}`);
|
||||||
|
const token = settingStore.connect.token.trim();
|
||||||
|
if (token) {
|
||||||
|
url.searchParams.set("token", token);
|
||||||
|
}
|
||||||
|
return url.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
export default {
|
||||||
|
list,
|
||||||
|
buildURL
|
||||||
|
};
|
||||||
29
src/api/SystemAPI.ts
Normal file
29
src/api/SystemAPI.ts
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import { axios, Text } from "timi-web";
|
||||||
|
import { useSettingStore } from "@/store/settingStore";
|
||||||
|
import type { SystemStatusHistoryView, SystemStatusSnapshotView } from "@/types/System";
|
||||||
|
|
||||||
|
async function getStatus(metrics?: string): Promise<SystemStatusSnapshotView> {
|
||||||
|
const settingStore = useSettingStore();
|
||||||
|
return await axios.get(`${settingStore.resolveBaseURL()}/system/server/status?${Text.urlArgs({
|
||||||
|
metrics,
|
||||||
|
})}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getStatusHistory(params?: {
|
||||||
|
window?: string;
|
||||||
|
metrics?: string;
|
||||||
|
}): Promise<SystemStatusHistoryView> {
|
||||||
|
const settingStore = useSettingStore();
|
||||||
|
return await axios.get(`${settingStore.resolveBaseURL()}/system/server/status/history`, {
|
||||||
|
params: {
|
||||||
|
...(params?.window ? { window: params.window } : {}),
|
||||||
|
...(params?.metrics ? { metrics: params.metrics } : {})
|
||||||
|
},
|
||||||
|
timeout: 15000
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export default {
|
||||||
|
getStatus,
|
||||||
|
getStatusHistory
|
||||||
|
};
|
||||||
27
src/api/UpsAPI.ts
Normal file
27
src/api/UpsAPI.ts
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import { axios } from "timi-web";
|
||||||
|
import type { UpsHistoryView, UpsStatusView } from "@/types/Ups";
|
||||||
|
import { useSettingStore } from "@/store/settingStore";
|
||||||
|
|
||||||
|
async function getStatus(): Promise<UpsStatusView | null> {
|
||||||
|
const settingStore = useSettingStore();
|
||||||
|
return await axios.get(`${settingStore.resolveBaseURL()}/system/ups/status`, {
|
||||||
|
timeout: 15000
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getStatusHistory(params?: {
|
||||||
|
window?: string;
|
||||||
|
}): Promise<UpsHistoryView> {
|
||||||
|
const settingStore = useSettingStore();
|
||||||
|
return await axios.get(`${settingStore.resolveBaseURL()}/system/ups/history`, {
|
||||||
|
params: {
|
||||||
|
...(params?.window ? { window: params.window } : {})
|
||||||
|
},
|
||||||
|
timeout: 15000
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export default {
|
||||||
|
getStatus,
|
||||||
|
getStatusHistory
|
||||||
|
};
|
||||||
198
src/components/PageTransition.vue
Normal file
198
src/components/PageTransition.vue
Normal file
@@ -0,0 +1,198 @@
|
|||||||
|
<template>
|
||||||
|
<div class="page-transition">
|
||||||
|
<router-view v-slot="{ Component }">
|
||||||
|
<transition :name="transitionName" :css="hasTransition">
|
||||||
|
<div class="pages" :key="pageKey">
|
||||||
|
<component :is="Component" />
|
||||||
|
</div>
|
||||||
|
</transition>
|
||||||
|
</router-view>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import type { RouteLocationNormalized } from "vue-router";
|
||||||
|
import { viewDepthKey } from "vue-router";
|
||||||
|
import { useGlobalUIStore } from "@/store/globalUIStore";
|
||||||
|
import { hasProgrammaticBackNavigation } from "@/utils/backNavigationSignal";
|
||||||
|
|
||||||
|
defineOptions({
|
||||||
|
name: "PageTransition"
|
||||||
|
});
|
||||||
|
|
||||||
|
const router = useRouter();
|
||||||
|
const route = useRoute();
|
||||||
|
const globalUIStore = useGlobalUIStore();
|
||||||
|
const viewDepth = inject(viewDepthKey, 0);
|
||||||
|
|
||||||
|
const transitionName = ref("");
|
||||||
|
const hasTransition = ref(false);
|
||||||
|
// iOS 左滑返回由系统接管,页面侧不应叠加离场动画。
|
||||||
|
const isIOS = /iP(ad|hone|od)/i.test(window.navigator.userAgent);
|
||||||
|
const pageBackground = computed(() => globalUIStore.bodyBackground);
|
||||||
|
const currentDepth = computed(() => Number(unref(viewDepth)));
|
||||||
|
const pageKey = computed(() => {
|
||||||
|
const depth = currentDepth.value;
|
||||||
|
const matchedRecord = route.matched[depth];
|
||||||
|
|
||||||
|
if (!matchedRecord) {
|
||||||
|
return route.fullPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (depth < route.matched.length - 1) {
|
||||||
|
return matchedRecord.path;
|
||||||
|
}
|
||||||
|
|
||||||
|
return route.fullPath;
|
||||||
|
});
|
||||||
|
|
||||||
|
const pathCache = new Map<string, number>();
|
||||||
|
|
||||||
|
function calcDepth(sourceRoute: RouteLocationNormalized): number {
|
||||||
|
if (!sourceRoute.meta.dynamicDepth) {
|
||||||
|
return Number(sourceRoute.meta.depth ?? 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
const fullPath = sourceRoute.fullPath;
|
||||||
|
const cached = pathCache.get(fullPath);
|
||||||
|
if (cached !== undefined) {
|
||||||
|
return cached;
|
||||||
|
}
|
||||||
|
|
||||||
|
const base = Number(sourceRoute.meta.baseDepth ?? 0);
|
||||||
|
const pathSegments = fullPath.split("?")[0].split("/").filter((pathSegment) => pathSegment !== "");
|
||||||
|
const depth = base + (pathSegments.length - 1);
|
||||||
|
pathCache.set(fullPath, depth);
|
||||||
|
return depth;
|
||||||
|
}
|
||||||
|
|
||||||
|
const unregisterGuard = router.beforeEach((to, from) => {
|
||||||
|
if (to.meta.tabBarRoot && from.meta.tabBarRoot) {
|
||||||
|
transitionName.value = "";
|
||||||
|
hasTransition.value = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const toDepth = calcDepth(to);
|
||||||
|
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) {
|
||||||
|
transitionName.value = "push-left";
|
||||||
|
hasTransition.value = true;
|
||||||
|
} else if (toDepth < fromDepth) {
|
||||||
|
transitionName.value = "push-right";
|
||||||
|
hasTransition.value = true;
|
||||||
|
} else {
|
||||||
|
transitionName.value = "";
|
||||||
|
hasTransition.value = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
unregisterGuard();
|
||||||
|
pathCache.clear();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="less" scoped>
|
||||||
|
@easing: cubic-bezier(.19, 1, .22, 1);
|
||||||
|
@duration: 750ms;
|
||||||
|
@brightness: .98;
|
||||||
|
@shadow-intensity: .15;
|
||||||
|
@page-offset: 25%;
|
||||||
|
|
||||||
|
.page-transition {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
display: grid;
|
||||||
|
position: relative;
|
||||||
|
min-height: 100%;
|
||||||
|
overflow-x: hidden;
|
||||||
|
|
||||||
|
.pages {
|
||||||
|
grid-area: 1 / 1;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
min-height: 100%;
|
||||||
|
position: relative;
|
||||||
|
background: v-bind(pageBackground);
|
||||||
|
overflow-x: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.push-left-enter-active,
|
||||||
|
.push-left-leave-active,
|
||||||
|
.push-right-enter-active,
|
||||||
|
.push-right-leave-active {
|
||||||
|
grid-area: 1 / 1;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
min-height: 100%;
|
||||||
|
transition: transform @duration @easing, opacity .2s linear;
|
||||||
|
backface-visibility: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.push-left-enter-active,
|
||||||
|
.push-right-leave-active {
|
||||||
|
z-index: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.push-left-leave-active,
|
||||||
|
.push-right-enter-active {
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.push-left-enter-from {
|
||||||
|
transform: translate3d(100%, 0, 0);
|
||||||
|
box-shadow: -4px 0 16px rgba(0, 0, 0, @shadow-intensity);
|
||||||
|
}
|
||||||
|
|
||||||
|
.push-left-enter-to {
|
||||||
|
transform: translate3d(0, 0, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.push-left-leave-active {
|
||||||
|
transition: all @duration @easing, filter .3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.push-left-leave-to {
|
||||||
|
filter: brightness(@brightness);
|
||||||
|
transform: translate3d(-@page-offset, 0, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.push-right-enter-active {
|
||||||
|
transition: transform @duration @easing, opacity .3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.push-right-enter-from {
|
||||||
|
transform: translate3d(-@page-offset, 0, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.push-right-enter-to {
|
||||||
|
transform: translate3d(0, 0, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.push-right-leave-active {
|
||||||
|
transition: transform @duration @easing, box-shadow .3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.push-right-leave-from {
|
||||||
|
transform: translate3d(0, 0, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.push-right-leave-to {
|
||||||
|
transform: translate3d(105%, 0, 0);
|
||||||
|
box-shadow: 4px 0 16px rgba(0, 0, 0, @shadow-intensity);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
193
src/components/ProgressGroup.vue
Normal file
193
src/components/ProgressGroup.vue
Normal file
@@ -0,0 +1,193 @@
|
|||||||
|
<template>
|
||||||
|
<div class="progress-group">
|
||||||
|
<div
|
||||||
|
class="bar"
|
||||||
|
:class="{
|
||||||
|
heap: mode === 'heap',
|
||||||
|
splice: mode === 'splice'
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<template v-if="mode === 'heap'">
|
||||||
|
<div
|
||||||
|
v-for="(item, index) in heapProgressList"
|
||||||
|
:key="`heap-${index}`"
|
||||||
|
class="layer"
|
||||||
|
:style="{
|
||||||
|
width: `${item.value}%`,
|
||||||
|
background: item.color || 'var(--td-brand-color)'
|
||||||
|
}"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
<div
|
||||||
|
v-for="(item, index) in spliceProgressList"
|
||||||
|
:key="`splice-${index}`"
|
||||||
|
class="segment"
|
||||||
|
:style="{
|
||||||
|
width: `${item.value}%`,
|
||||||
|
background: item.color || 'var(--td-brand-color)'
|
||||||
|
}"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
<div v-if="Toolkit.isNotEmpty(note)" class="health" v-text="note" />
|
||||||
|
</div>
|
||||||
|
<div v-if="Toolkit.isNotEmpty(legendList)" class="legends">
|
||||||
|
<div
|
||||||
|
v-for="(item, index) in legendList"
|
||||||
|
:key="`legend-${index}`"
|
||||||
|
class="legend"
|
||||||
|
>
|
||||||
|
<div class="block" :style="{ background: item.color }" />
|
||||||
|
<div v-text="item.legend" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import { Toolkit } from "timi-web";
|
||||||
|
|
||||||
|
export interface ProgressItem {
|
||||||
|
color?: string;
|
||||||
|
value: number;
|
||||||
|
legend?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
type ProgressMode = "heap" | "splice";
|
||||||
|
|
||||||
|
interface RenderProgressItem {
|
||||||
|
color?: string;
|
||||||
|
value: number;
|
||||||
|
legend?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
defineOptions({
|
||||||
|
name: "ProgressGroup"
|
||||||
|
});
|
||||||
|
|
||||||
|
const props = withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
mode?: ProgressMode;
|
||||||
|
maxValue?: number;
|
||||||
|
progress?: ProgressItem[];
|
||||||
|
note?: string;
|
||||||
|
}>(),
|
||||||
|
{
|
||||||
|
mode: "heap",
|
||||||
|
maxValue: 1,
|
||||||
|
progress: () => [],
|
||||||
|
note: ""
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const normalizedProgressList = computed<RenderProgressItem[]>(() => {
|
||||||
|
const maxValue = 0 < props.maxValue ? props.maxValue : 100;
|
||||||
|
return props.progress.map((item) => {
|
||||||
|
const value = Math.min(Math.max(item.value / maxValue * 100, 0), 100);
|
||||||
|
return {
|
||||||
|
...item,
|
||||||
|
value
|
||||||
|
};
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const heapProgressList = computed<RenderProgressItem[]>(() => {
|
||||||
|
return normalizedProgressList.value.filter((item) => 0 < item.value)
|
||||||
|
.slice()
|
||||||
|
.sort((left, right) => right.value - left.value);
|
||||||
|
});
|
||||||
|
|
||||||
|
const spliceProgressList = computed<RenderProgressItem[]>(() => {
|
||||||
|
let rest = 100;
|
||||||
|
return normalizedProgressList.value.filter((item) => 0 < item.value).map((item) => {
|
||||||
|
if (rest < 1) {
|
||||||
|
return {
|
||||||
|
...item,
|
||||||
|
value: 0
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const width = Math.min(item.value, rest);
|
||||||
|
rest -= width;
|
||||||
|
return {
|
||||||
|
...item,
|
||||||
|
value: width
|
||||||
|
};
|
||||||
|
}).filter((item) => 0 < item.value);
|
||||||
|
});
|
||||||
|
|
||||||
|
const legendList = computed<RenderProgressItem[]>(() => {
|
||||||
|
return normalizedProgressList.value.filter(item => Toolkit.isNotEmpty(item.legend));
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="less" scoped>
|
||||||
|
.progress-group {
|
||||||
|
gap: .5rem;
|
||||||
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
|
||||||
|
.bar {
|
||||||
|
width: 100%;
|
||||||
|
height: 1.25rem;
|
||||||
|
overflow: hidden;
|
||||||
|
position: relative;
|
||||||
|
background: var(--td-progress-track-bg-color, var(--td-bg-color-component, var(--td-gray-color-3, #e7e7e7)));
|
||||||
|
border-radius: .1875rem;
|
||||||
|
|
||||||
|
&.heap {
|
||||||
|
.layer {
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
height: 100%;
|
||||||
|
position: absolute;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&.splice {
|
||||||
|
display: flex;
|
||||||
|
|
||||||
|
.segment {
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
.layer,
|
||||||
|
.segment {
|
||||||
|
transition: width 320ms var(--tui-bezier);
|
||||||
|
}
|
||||||
|
|
||||||
|
.health {
|
||||||
|
top: 50%;
|
||||||
|
right: .25rem;
|
||||||
|
z-index: 2;
|
||||||
|
position: absolute;
|
||||||
|
font-size: .8rem;
|
||||||
|
transform: translate(0, -50%);
|
||||||
|
white-space: nowrap;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.legends {
|
||||||
|
gap: .75rem;
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
|
||||||
|
.legend {
|
||||||
|
gap: .375rem;
|
||||||
|
display: flex;
|
||||||
|
font-size: .75rem;
|
||||||
|
align-items: center;
|
||||||
|
color: var(--td-text-color-secondary, var(--app-sub, #8899a8));
|
||||||
|
|
||||||
|
.block {
|
||||||
|
width: .8rem;
|
||||||
|
height: .8rem;
|
||||||
|
border-radius: .125rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
99
src/components/TCellInfo.vue
Normal file
99
src/components/TCellInfo.vue
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
<template>
|
||||||
|
<t-cell class="t-cell-info" :class="{ 'has-description': hasDescription }">
|
||||||
|
<template #title>
|
||||||
|
<div class="content">
|
||||||
|
<div class="label" :class="{
|
||||||
|
'clip-text': clipText === 'label',
|
||||||
|
'keep-text': clipText === 'value'
|
||||||
|
}">
|
||||||
|
<slot name="label">
|
||||||
|
<span v-text="label"></span>
|
||||||
|
</slot>
|
||||||
|
</div>
|
||||||
|
<div class="value light-gray" :class="{
|
||||||
|
'clip-text': clipText === 'value',
|
||||||
|
'keep-text': clipText === 'label'
|
||||||
|
}">
|
||||||
|
<slot name="value">
|
||||||
|
<span v-text="value"></span>
|
||||||
|
</slot>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<template #description>
|
||||||
|
<slot name="description">
|
||||||
|
<slot>
|
||||||
|
<div v-if="description" class="description" v-text="description"></div>
|
||||||
|
</slot>
|
||||||
|
</slot>
|
||||||
|
</template>
|
||||||
|
</t-cell>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts" setup>
|
||||||
|
const props = withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
label?: string;
|
||||||
|
value?: string;
|
||||||
|
description?: string;
|
||||||
|
clipText?: 'label' | 'value';
|
||||||
|
}>(),
|
||||||
|
{
|
||||||
|
label: '',
|
||||||
|
value: '',
|
||||||
|
description: '',
|
||||||
|
clipText: 'value',
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const slots = useSlots();
|
||||||
|
const { label, value, description, clipText } = toRefs(props);
|
||||||
|
const hasDescription = computed(() => {
|
||||||
|
return Boolean(props.description?.trim() || slots.description || slots.default);
|
||||||
|
});
|
||||||
|
|
||||||
|
defineSlots<{
|
||||||
|
default?: () => unknown;
|
||||||
|
label?: () => unknown;
|
||||||
|
value?: () => unknown;
|
||||||
|
description?: () => unknown;
|
||||||
|
}>();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="less" scoped>
|
||||||
|
.t-cell-info {
|
||||||
|
|
||||||
|
:deep(.t-cell__title) {
|
||||||
|
margin: 0;
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.t-cell__description) {
|
||||||
|
margin: 0;
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.has-description {
|
||||||
|
:deep(.t-cell__description) {
|
||||||
|
margin-top: .25rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.content {
|
||||||
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
min-width: 0;
|
||||||
|
align-items: baseline;
|
||||||
|
justify-content: space-between;
|
||||||
|
|
||||||
|
.label {
|
||||||
|
margin-right: .5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.value {
|
||||||
|
font-size: 14px;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
303
src/layout/MainLayout.vue
Normal file
303
src/layout/MainLayout.vue
Normal file
@@ -0,0 +1,303 @@
|
|||||||
|
<template>
|
||||||
|
<div class="main-layout">
|
||||||
|
<t-navbar
|
||||||
|
ref="navBarRef"
|
||||||
|
v-if="navBarStore.isShowing"
|
||||||
|
class="nav-bar"
|
||||||
|
:title="navBarStore.title"
|
||||||
|
:left-arrow="!hasCustomLeft && !!navBarStore.canBack"
|
||||||
|
@left-click="onLeftClick"
|
||||||
|
>
|
||||||
|
<template v-if="hasCustomLeft" #left>
|
||||||
|
<div class="nav-extra nav-extra-left">
|
||||||
|
<component :is="navBarStore.leftRenderer" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<template #right>
|
||||||
|
<div class="nav-extra">
|
||||||
|
<component :is="navBarStore.rightRenderer" v-if="navBarStore.rightRenderer" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</t-navbar>
|
||||||
|
<div :class="{ 'is-fixed-height': isContentFixedHeight }" class="router-view">
|
||||||
|
<page-transition />
|
||||||
|
</div>
|
||||||
|
<!-- iOS 手势返回场景下临时关闭 tabbar 过渡,避免系统回退与页面动画叠加 -->
|
||||||
|
<t-tab-bar
|
||||||
|
:class="{ 'is-hidden': !tabBarStore.isShowing, 'skip-transition': tabBarStore.shouldSkipTransition }"
|
||||||
|
class="tab-bar glass-white"
|
||||||
|
v-model="tabVal"
|
||||||
|
shape="round"
|
||||||
|
theme="tag"
|
||||||
|
:split="false"
|
||||||
|
:disabled="!tabBarStore.isShowing"
|
||||||
|
@change="onChangeTab"
|
||||||
|
>
|
||||||
|
<t-tab-bar-item v-if="musicPlayerStore.hasQueue" class="item bg-transparent music-item" value="__music__">
|
||||||
|
<template #icon>
|
||||||
|
<t-icon name="music" />
|
||||||
|
</template>
|
||||||
|
</t-tab-bar-item>
|
||||||
|
<t-tab-bar-item class="item bg-transparent" v-for="item in tabList" :key="item.value" :value="item.value">
|
||||||
|
<template #icon>
|
||||||
|
<t-icon :name="item.icon" />
|
||||||
|
</template>
|
||||||
|
</t-tab-bar-item>
|
||||||
|
</t-tab-bar>
|
||||||
|
<music-player-popup />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { useNavBarStore } from "@/store/navBarStore";
|
||||||
|
import { useTabBarStore } from "@/store/tabBarStore";
|
||||||
|
import PageTransition from "@/components/PageTransition.vue";
|
||||||
|
import MusicPlayerPopup from "@/pages/system/MusicPlayerPopup.vue";
|
||||||
|
import { useMusicPlayerStore } from "@/store/musicPlayerStore";
|
||||||
|
|
||||||
|
const route = useRoute();
|
||||||
|
const router = useRouter();
|
||||||
|
const navBarStore = useNavBarStore();
|
||||||
|
const tabBarStore = useTabBarStore();
|
||||||
|
const musicPlayerStore = useMusicPlayerStore();
|
||||||
|
|
||||||
|
// ---------- 导航栏高度 ----------
|
||||||
|
|
||||||
|
const navBarRef = ref();
|
||||||
|
let navBarResizeObs: ResizeObserver | undefined;
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
if (navBarRef.value) {
|
||||||
|
navBarResizeObs = new ResizeObserver((entries) => {
|
||||||
|
for (const entry of entries) {
|
||||||
|
navBarStore.setHeight(entry.contentRect.height);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
navBarResizeObs.observe(navBarRef.value.$el.querySelector(".t-navbar__content"));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
if (navBarResizeObs) {
|
||||||
|
navBarResizeObs.disconnect();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------- 导航返回 ----------
|
||||||
|
|
||||||
|
const hasCustomLeft = computed(() => !!navBarStore.leftRenderer);
|
||||||
|
|
||||||
|
function doBack(): void {
|
||||||
|
if (navBarStore.backTo) {
|
||||||
|
router.push(navBarStore.backTo);
|
||||||
|
navBarStore.setBackTo(undefined);
|
||||||
|
} else {
|
||||||
|
router.back();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onLeftClick(): void {
|
||||||
|
if (hasCustomLeft.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
doBack();
|
||||||
|
}
|
||||||
|
|
||||||
|
const navBarPadding = computed(() => {
|
||||||
|
if (navBarStore.isShowing) {
|
||||||
|
return `${navBarStore.height || 48}px`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return "0";
|
||||||
|
});
|
||||||
|
|
||||||
|
const topPadding = computed(() => {
|
||||||
|
return `calc(${navBarPadding.value} + var(--safe-top))`;
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------- Tab 切换 ----------
|
||||||
|
|
||||||
|
const tabVal = ref(route.path);
|
||||||
|
const tabList = [
|
||||||
|
{ value: "/", icon: "app" },
|
||||||
|
{ value: "/server", icon: "chart" },
|
||||||
|
{ value: "/settings", icon: "setting" }
|
||||||
|
];
|
||||||
|
|
||||||
|
function resolveTabValue(path: string): string {
|
||||||
|
if (path === "/" || path.startsWith("/files/")) {
|
||||||
|
return "/";
|
||||||
|
}
|
||||||
|
return path;
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(() => route.path, (newPath: string) => {
|
||||||
|
tabVal.value = resolveTabValue(newPath);
|
||||||
|
}, { immediate: true });
|
||||||
|
|
||||||
|
async function onChangeTab(value: string): Promise<void> {
|
||||||
|
if (value === "__music__") {
|
||||||
|
musicPlayerStore.setPopupVisible(true);
|
||||||
|
tabVal.value = resolveTabValue(route.path);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (resolveTabValue(route.path) === value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await router.replace(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
const isTabBarPaddingNeeded = computed(() => {
|
||||||
|
if (!tabBarStore.isShowing) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return route.meta.tabBarPadding !== false;
|
||||||
|
});
|
||||||
|
|
||||||
|
const tabBarPadding = computed(() => {
|
||||||
|
if (isTabBarPaddingNeeded.value) {
|
||||||
|
return "6rem";
|
||||||
|
}
|
||||||
|
return "0rem";
|
||||||
|
});
|
||||||
|
|
||||||
|
const isContentFixedHeight = computed(() => {
|
||||||
|
return route.meta.contentFixedHeight === true;
|
||||||
|
});
|
||||||
|
|
||||||
|
const bodyHeight = computed(() => {
|
||||||
|
return `calc(100vh - ${tabBarPadding.value})`;
|
||||||
|
});
|
||||||
|
|
||||||
|
const contentHeight = computed(() => {
|
||||||
|
return bodyHeight.value;
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="less">
|
||||||
|
.main-layout {
|
||||||
|
--app-nav-offset: v-bind(topPadding);
|
||||||
|
--safe-top: env(safe-area-inset-top, 0px);
|
||||||
|
|
||||||
|
box-sizing: border-box;
|
||||||
|
isolation: isolate;
|
||||||
|
position: relative;
|
||||||
|
min-height: 100vh;
|
||||||
|
padding-bottom: v-bind(tabBarPadding);
|
||||||
|
transition: padding-bottom .24s ease;
|
||||||
|
|
||||||
|
.nav-bar {
|
||||||
|
--td-navbar-padding-top: var(--safe-top);
|
||||||
|
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
z-index: 1100;
|
||||||
|
position: fixed;
|
||||||
|
|
||||||
|
:deep(.t-navbar) {
|
||||||
|
|
||||||
|
&__content {
|
||||||
|
color: var(--tui-black, #000);
|
||||||
|
border-bottom: 1px solid rgba(0, 0, 0, .1);
|
||||||
|
background: rgba(250, 250, 250, .8);
|
||||||
|
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 {
|
||||||
|
gap: .35rem;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-extra-left {
|
||||||
|
justify-content: flex-start;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.router-view {
|
||||||
|
width: 100%;
|
||||||
|
position: relative;
|
||||||
|
z-index: 0;
|
||||||
|
min-height: v-bind(contentHeight);
|
||||||
|
|
||||||
|
&.is-fixed-height {
|
||||||
|
height: v-bind(contentHeight);
|
||||||
|
min-height: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-bar {
|
||||||
|
left: 1rem;
|
||||||
|
right: 1rem;
|
||||||
|
bottom: 1rem;
|
||||||
|
position: fixed;
|
||||||
|
z-index: 1000;
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
transition: opacity 520ms, transform 520ms var(--tui-bezier);
|
||||||
|
pointer-events: auto;
|
||||||
|
|
||||||
|
&.glass-white {
|
||||||
|
// 不明原因失效,重新配置
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
&.is-hidden {
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
transform: translateY(calc(100% + 1rem));
|
||||||
|
}
|
||||||
|
|
||||||
|
&.skip-transition {
|
||||||
|
// 配合 tabBarStore.shouldSkipTransition:仅本次导航禁用过渡
|
||||||
|
transition: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
--td-tab-bar-height: 52px;
|
||||||
|
|
||||||
|
:deep(.t-tab-bar-item__content) {
|
||||||
|
border-radius: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:first-child {
|
||||||
|
|
||||||
|
:deep(.t-tab-bar-item__content) {
|
||||||
|
border-radius: 99px 0 0 99px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&:last-child {
|
||||||
|
|
||||||
|
:deep(.t-tab-bar-item__content) {
|
||||||
|
border-radius: 0 99px 99px 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.theme-dark) .main-layout {
|
||||||
|
.nav-bar {
|
||||||
|
:deep(.t-navbar__content) {
|
||||||
|
background: rgba(16, 20, 24, .88);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
16
src/layout/RootLayout.vue
Normal file
16
src/layout/RootLayout.vue
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
<template>
|
||||||
|
<div class="root-layout diselect">
|
||||||
|
<page-transition />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import PageTransition from "@/components/PageTransition.vue";
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="less">
|
||||||
|
.root-layout {
|
||||||
|
width: 100%;
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
91
src/main.ts
Normal file
91
src/main.ts
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
import { createApp } from "vue";
|
||||||
|
import { createPinia } from "pinia";
|
||||||
|
import { Toast } from "tdesign-mobile-vue";
|
||||||
|
import router from "@/router";
|
||||||
|
import Root from "@/Root.vue";
|
||||||
|
|
||||||
|
import "tdesign-mobile-vue/es/style/index.css";
|
||||||
|
import "timi-web/style.css";
|
||||||
|
import "timi-tdesign-mobile/style.css";
|
||||||
|
import "vue-virtual-scroller/dist/vue-virtual-scroller.css";
|
||||||
|
import { axios } from "timi-web";
|
||||||
|
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) => {
|
||||||
|
const settingStore = useSettingStore();
|
||||||
|
const token = settingStore.connect.token.trim();
|
||||||
|
if (token) {
|
||||||
|
if (config.method === "get") {
|
||||||
|
config.params = {
|
||||||
|
token,
|
||||||
|
...config.params
|
||||||
|
};
|
||||||
|
}
|
||||||
|
config.headers.set({ "Token": token });
|
||||||
|
}
|
||||||
|
return config;
|
||||||
|
}, (error: any) => {
|
||||||
|
return Promise.reject(error);
|
||||||
|
});
|
||||||
|
|
||||||
|
export const pinia = createPinia();
|
||||||
|
|
||||||
|
const app = createApp(Root);
|
||||||
|
app.config.errorHandler = (error) => {
|
||||||
|
console.error(error);
|
||||||
|
Toast({
|
||||||
|
theme: "error",
|
||||||
|
message: "\u9875\u9762\u53d1\u751f\u5f02\u5e38\uff0c\u8bf7\u7a0d\u540e\u91cd\u8bd5"
|
||||||
|
});
|
||||||
|
};
|
||||||
|
app.use(pinia);
|
||||||
|
app.use(router);
|
||||||
|
app.mount("#root");
|
||||||
|
bindLaunchFullscreenRequest();
|
||||||
768
src/pages/dashboard/DockerDashboard/DockerContainerDetail.vue
Normal file
768
src/pages/dashboard/DockerDashboard/DockerContainerDetail.vue
Normal file
@@ -0,0 +1,768 @@
|
|||||||
|
<template>
|
||||||
|
<div class="docker-container-detail">
|
||||||
|
<t-empty v-if="!containerId" description="缺少容器 ID 参数" />
|
||||||
|
<template v-else>
|
||||||
|
<div v-if="isStatusLoading && !statusView" class="loading-wrap">
|
||||||
|
<t-loading text="加载容器状态..." />
|
||||||
|
</div>
|
||||||
|
<template v-else-if="statusView">
|
||||||
|
<t-cell-group title="容器概览" theme="card">
|
||||||
|
<t-cell title="名称" :note="Text.display(statusView?.name)" />
|
||||||
|
<t-cell title="镜像" :note="Text.display(statusView?.image)" />
|
||||||
|
<t-cell title="状态" :note="resolveStateText(statusView?.state)" />
|
||||||
|
<t-cell title="状态详情" :note="Text.display(statusView?.status)" />
|
||||||
|
<t-cell title="健康检查" :note="resolveHealthText(statusView?.healthStatus)" />
|
||||||
|
<t-cell title="容器 ID" :note="Text.display(statusView?.id?.slice(0, 12))" />
|
||||||
|
<t-cell title="镜像 ID" :note="Text.display(statusView?.imageId?.slice(0, 12))" />
|
||||||
|
<t-cell title="创建时间" :note="Time.toPassedDateTime(statusView?.createdAt)" />
|
||||||
|
<t-cell title="启动时间" :note="resolveDockerRawTime(statusView?.startedAt)" />
|
||||||
|
<t-cell title="结束时间" :note="resolveDockerRawTime(statusView?.finishedAt)" />
|
||||||
|
<t-cell title="采样时间" :note="Time.toPassedDateTime(statusView?.updatedAt)" />
|
||||||
|
</t-cell-group>
|
||||||
|
<t-cell-group class="section resource" title="资源使用" theme="card">
|
||||||
|
<t-cell-info label="CPU 使用率" :value="Text.unit(statusView?.cpuPercent, '%')">
|
||||||
|
<progress-group mode="heap" :progress="cpuProgressList" />
|
||||||
|
</t-cell-info>
|
||||||
|
<t-cell-info label="内存使用率" :value="memoryUsageText">
|
||||||
|
<progress-group mode="heap" :progress="memoryProgressList" :note="Text.unit(statusView?.memoryPercent, '%')" />
|
||||||
|
</t-cell-info>
|
||||||
|
<t-cell title="进程数" :note="Text.display(statusView?.pids)" />
|
||||||
|
<t-cell title="重启次数" :note="Text.display(statusView?.restartCount)" />
|
||||||
|
<t-cell title="退出码" :note="Text.display(statusView?.exitCode)" />
|
||||||
|
<t-cell title="OOM Kill" :note="Text.displayBool(statusView?.oomKilled, '是', '否')" />
|
||||||
|
</t-cell-group>
|
||||||
|
<t-cell-group class="section traffic" title="网络与块设备" theme="card">
|
||||||
|
<t-cell title="网络接收" :note="IOSize.format(statusView?.networkRxBytes)" />
|
||||||
|
<t-cell title="网络发送" :note="IOSize.format(statusView?.networkTxBytes)" />
|
||||||
|
<t-cell title="块设备读取" :note="IOSize.format(statusView?.blockReadBytes)" />
|
||||||
|
<t-cell title="块设备写入" :note="IOSize.format(statusView?.blockWriteBytes)" />
|
||||||
|
</t-cell-group>
|
||||||
|
</template>
|
||||||
|
<t-empty v-else description="暂无容器状态数据" />
|
||||||
|
|
||||||
|
<t-cell-group class="section history" title="历史采样" theme="card">
|
||||||
|
<t-tabs class="metric-tabs" :value="historyMetric" @change="handleHistoryMetricChange">
|
||||||
|
<t-tab-panel v-for="item in historyMetricTabs" :key="item.value" :value="item.value" :label="item.label" />
|
||||||
|
</t-tabs>
|
||||||
|
<div v-if="isHistoryLoading && !historyItems.length" class="loading-wrap">
|
||||||
|
<t-loading text="加载容器历史..." />
|
||||||
|
</div>
|
||||||
|
<template v-else-if="historyItems.length">
|
||||||
|
<v-chart
|
||||||
|
ref="chartRef"
|
||||||
|
class="chart"
|
||||||
|
:option="historyChartOption"
|
||||||
|
:update-options="historyChartUpdateOptions"
|
||||||
|
:autoresize="true"
|
||||||
|
/>
|
||||||
|
<div class="slider bg-white">
|
||||||
|
<span class="label">较早</span>
|
||||||
|
<t-slider
|
||||||
|
class="control"
|
||||||
|
:min="0"
|
||||||
|
:max="Math.max(historyItems.length - historyVisibleCount, 0)"
|
||||||
|
:step="1"
|
||||||
|
:value="historySliderValue"
|
||||||
|
@change="handleHistorySliderChange"
|
||||||
|
/>
|
||||||
|
<span class="label">最新</span>
|
||||||
|
</div>
|
||||||
|
<t-cell-group>
|
||||||
|
<t-cell v-for="item in historySummary" :key="item.label" :title="item.label" :note="item.value" />
|
||||||
|
</t-cell-group>
|
||||||
|
</template>
|
||||||
|
<t-empty v-else description="暂无容器历史数据" />
|
||||||
|
</t-cell-group>
|
||||||
|
</template>
|
||||||
|
</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 type { EChartsOption, SeriesOption } from "echarts";
|
||||||
|
import DockerAPI from "@/api/DockerAPI";
|
||||||
|
import type {
|
||||||
|
DockerContainerHistoryPointView,
|
||||||
|
DockerContainerStatusView
|
||||||
|
} from "@/types/Docker";
|
||||||
|
import { useSettingStore } from "@/store/settingStore";
|
||||||
|
import { IOSize, type LabelValue, Text, Time } from "timi-web";
|
||||||
|
import TCellInfo from "@/components/TCellInfo.vue";
|
||||||
|
import type { ProgressItem } from "@/components/ProgressGroup.vue";
|
||||||
|
import ProgressGroup from "@/components/ProgressGroup.vue";
|
||||||
|
|
||||||
|
type DockerHistoryMetric = "cpu" | "memory" | "network" | "block" | "pids";
|
||||||
|
|
||||||
|
use([
|
||||||
|
SVGRenderer,
|
||||||
|
LineChart,
|
||||||
|
GridComponent,
|
||||||
|
TooltipComponent,
|
||||||
|
LegendComponent
|
||||||
|
]);
|
||||||
|
|
||||||
|
defineOptions({
|
||||||
|
name: "DockerContainerDetail"
|
||||||
|
});
|
||||||
|
|
||||||
|
const route = useRoute();
|
||||||
|
const settingStore = useSettingStore();
|
||||||
|
|
||||||
|
const containerId = computed(() => {
|
||||||
|
const queryContainerId = route.query.containerId;
|
||||||
|
if (typeof queryContainerId === "string") {
|
||||||
|
return queryContainerId;
|
||||||
|
}
|
||||||
|
if (Array.isArray(queryContainerId) && queryContainerId.length) {
|
||||||
|
return queryContainerId[0];
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
});
|
||||||
|
|
||||||
|
const isStatusLoading = ref(false);
|
||||||
|
const isHistoryLoading = ref(false);
|
||||||
|
|
||||||
|
const statusView = ref<DockerContainerStatusView | null>(null);
|
||||||
|
const historyPoints = ref<DockerContainerHistoryPointView[]>([]);
|
||||||
|
|
||||||
|
const chartRef = ref<InstanceType<typeof VChart> | null>(null);
|
||||||
|
const historyChartUpdateOptions = Object.freeze({
|
||||||
|
notMerge: true
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------- 当前状态 ----------
|
||||||
|
|
||||||
|
const cpuProgressList = computed<ProgressItem[]>(() => {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
color: "var(--td-brand-color)",
|
||||||
|
value: normalizePercent(statusView.value?.cpuPercent)
|
||||||
|
}
|
||||||
|
];
|
||||||
|
});
|
||||||
|
const memoryUsageText = computed(() => {
|
||||||
|
const usage = IOSize.format(statusView.value?.memoryUsageBytes || 0);
|
||||||
|
const limitValue = statusView.value?.memoryLimitBytes || 0;
|
||||||
|
const limit = limitValue < 1 ? "无限制" : IOSize.format(limitValue);
|
||||||
|
return `${usage} / ${limit}`;
|
||||||
|
});
|
||||||
|
const memoryProgressList = computed<ProgressItem[]>(() => {
|
||||||
|
const limit = statusView.value?.memoryLimitBytes || 0;
|
||||||
|
const usage = statusView.value?.memoryUsageBytes || 0;
|
||||||
|
const fallback = limit < 1 ? 0 : usage / limit;
|
||||||
|
const percent = statusView.value?.memoryPercent;
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
color: "var(--td-warning-color)",
|
||||||
|
value: normalizePercent(percent, fallback)
|
||||||
|
}
|
||||||
|
];
|
||||||
|
});
|
||||||
|
|
||||||
|
function normalizePercent(percent?: number | null, fallback = 0): number {
|
||||||
|
const source = typeof percent === "number" ? percent / 100 : fallback;
|
||||||
|
if (Number.isNaN(source) || !Number.isFinite(source)) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
return Math.min(Math.max(source, 0), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveStateText(state?: string | null): string {
|
||||||
|
if (!state) {
|
||||||
|
return "-";
|
||||||
|
}
|
||||||
|
const map: Record<string, string> = {
|
||||||
|
created: "已创建",
|
||||||
|
running: "运行中",
|
||||||
|
restarting: "重启中",
|
||||||
|
exited: "已退出",
|
||||||
|
paused: "已暂停",
|
||||||
|
dead: "已停止"
|
||||||
|
};
|
||||||
|
return map[state] || state;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveHealthText(health?: string | null): string {
|
||||||
|
if (!health) {
|
||||||
|
return "未配置";
|
||||||
|
}
|
||||||
|
const map: Record<string, string> = {
|
||||||
|
healthy: "健康",
|
||||||
|
unhealthy: "异常",
|
||||||
|
starting: "启动中"
|
||||||
|
};
|
||||||
|
return map[health] || health;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveDockerRawTime(value?: string | null): string {
|
||||||
|
if (!value || value.startsWith("0001-01-01")) {
|
||||||
|
return "-";
|
||||||
|
}
|
||||||
|
const dateValue = Date.parse(value);
|
||||||
|
if (Number.isNaN(dateValue)) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
return Time.toPassedDateTime(dateValue);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 历史采样 ----------
|
||||||
|
|
||||||
|
const historyMetric = ref<DockerHistoryMetric>("cpu");
|
||||||
|
|
||||||
|
const historyMetricTabs = computed(() => {
|
||||||
|
const labelMap: Record<DockerHistoryMetric, string> = {
|
||||||
|
cpu: "CPU",
|
||||||
|
memory: "内存",
|
||||||
|
network: "网络",
|
||||||
|
block: "块设备",
|
||||||
|
pids: "进程"
|
||||||
|
};
|
||||||
|
return (Object.keys(labelMap) as DockerHistoryMetric[]).map((item) => ({
|
||||||
|
value: item,
|
||||||
|
label: labelMap[item]
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
function handleHistoryMetricChange(value: string): void {
|
||||||
|
const nextValue = value as DockerHistoryMetric;
|
||||||
|
if (nextValue === "cpu" || nextValue === "memory" || nextValue === "network" || nextValue === "block" || nextValue === "pids") {
|
||||||
|
historyMetric.value = nextValue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const historyItems = computed<DockerContainerHistoryPointView[]>(() => {
|
||||||
|
return historyPoints.value.slice().sort((left, right) => left.at - right.at);
|
||||||
|
});
|
||||||
|
const historySummary = computed<LabelValue<string, any>[]>(() => {
|
||||||
|
const lastPoint = historyItems.value[historyItems.value.length - 1];
|
||||||
|
if (!lastPoint) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
if (historyMetric.value === "cpu") {
|
||||||
|
return [
|
||||||
|
{ label: "CPU 使用率", value: Text.unit(lastPoint.cpuPercent, "%") }
|
||||||
|
];
|
||||||
|
}
|
||||||
|
if (historyMetric.value === "memory") {
|
||||||
|
return [
|
||||||
|
{ label: "内存占用", value: IOSize.format(lastPoint.memoryUsageBytes || 0) },
|
||||||
|
{ label: "内存使用率", value: Text.unit(lastPoint.memoryPercent, "%") }
|
||||||
|
];
|
||||||
|
}
|
||||||
|
if (historyMetric.value === "network") {
|
||||||
|
return [
|
||||||
|
{ label: "累计接收", value: IOSize.format(lastPoint.networkRxBytes || 0) },
|
||||||
|
{ label: "累计发送", value: IOSize.format(lastPoint.networkTxBytes || 0) }
|
||||||
|
];
|
||||||
|
}
|
||||||
|
if (historyMetric.value === "block") {
|
||||||
|
return [
|
||||||
|
{ label: "累计读取", value: IOSize.format(lastPoint.blockReadBytes || 0) },
|
||||||
|
{ label: "累计写入", value: IOSize.format(lastPoint.blockWriteBytes || 0) }
|
||||||
|
];
|
||||||
|
}
|
||||||
|
return [
|
||||||
|
{ label: "进程数", value: Text.display(lastPoint.pids) }
|
||||||
|
];
|
||||||
|
});
|
||||||
|
|
||||||
|
const isHistorySliderTouched = ref(false);
|
||||||
|
const historySliderValue = ref(0);
|
||||||
|
const HISTORY_DEFAULT_VISIBLE_COUNT = 24;
|
||||||
|
|
||||||
|
const historyVisibleCount = computed(() => {
|
||||||
|
if (historyItems.value.length < 1) {
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
return Math.min(HISTORY_DEFAULT_VISIBLE_COUNT, historyItems.value.length);
|
||||||
|
});
|
||||||
|
const historySliderMaxStart = computed(() => {
|
||||||
|
return Math.max(historyItems.value.length - historyVisibleCount.value, 0);
|
||||||
|
});
|
||||||
|
const currentHistoryRange = computed(() => {
|
||||||
|
const length = historyItems.value.length;
|
||||||
|
if (length < 1) {
|
||||||
|
return { startValue: 0, endValue: 0 };
|
||||||
|
}
|
||||||
|
const startValue = Math.min(Math.max(Math.floor(historySliderValue.value), 0), historySliderMaxStart.value);
|
||||||
|
const endValue = Math.min(startValue + historyVisibleCount.value - 1, length - 1);
|
||||||
|
return { startValue, endValue };
|
||||||
|
});
|
||||||
|
const visibleHistoryItems = computed<DockerContainerHistoryPointView[]>(() => {
|
||||||
|
if (!historyItems.value.length) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
return historyItems.value.slice(currentHistoryRange.value.startValue, currentHistoryRange.value.endValue + 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
watch(() => historyItems.value.length, (length) => {
|
||||||
|
if (length < 1) {
|
||||||
|
historySliderValue.value = 0;
|
||||||
|
isHistorySliderTouched.value = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!isHistorySliderTouched.value) {
|
||||||
|
historySliderValue.value = historySliderMaxStart.value;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
historySliderValue.value = Math.min(historySliderValue.value, historySliderMaxStart.value);
|
||||||
|
});
|
||||||
|
|
||||||
|
function handleHistorySliderChange(value: number): void {
|
||||||
|
historySliderValue.value = Math.min(Math.max(Math.floor(value), 0), historySliderMaxStart.value);
|
||||||
|
isHistorySliderTouched.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetHistorySlider(): void {
|
||||||
|
historySliderValue.value = 0;
|
||||||
|
isHistorySliderTouched.value = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 图表 ----------
|
||||||
|
|
||||||
|
const isDarkTheme = ref(false);
|
||||||
|
let themeObserver: MutationObserver | null = null;
|
||||||
|
|
||||||
|
const historyChartOption = computed((): EChartsOption => {
|
||||||
|
const axisColor = isDarkTheme.value ? "#9aa4b2" : "#7e8a9a";
|
||||||
|
const lineColor = isDarkTheme.value ? "rgba(255, 255, 255, .12)" : "rgba(18, 42, 66, .08)";
|
||||||
|
const legendColor = isDarkTheme.value ? "#d7dee8" : "#334155";
|
||||||
|
|
||||||
|
const commonOption: EChartsOption = {
|
||||||
|
tooltip: {
|
||||||
|
trigger: "axis",
|
||||||
|
confine: true,
|
||||||
|
position: (point, _params, _dom, _rect, size) => {
|
||||||
|
const gap = 6;
|
||||||
|
const fingerGap = 24;
|
||||||
|
const contentSize = size?.contentSize || [0, 0];
|
||||||
|
const viewSize = size?.viewSize || [0, 0];
|
||||||
|
const maxLeft = Math.max(viewSize[0] - contentSize[0] - gap, gap);
|
||||||
|
const maxTop = Math.max(viewSize[1] - contentSize[1] - gap, gap);
|
||||||
|
const clamp = (value: number, min: number, max: number): number => {
|
||||||
|
return Math.min(Math.max(value, min), max);
|
||||||
|
};
|
||||||
|
const preferLeft = point[0] - contentSize[0] - gap;
|
||||||
|
const preferRight = point[0] + gap;
|
||||||
|
const left = point[0] < viewSize[0] / 2
|
||||||
|
? clamp(preferRight, gap, maxLeft)
|
||||||
|
: clamp(preferLeft, gap, maxLeft);
|
||||||
|
const preferTop = point[1] - contentSize[1] - fingerGap;
|
||||||
|
const preferBottom = point[1] + fingerGap;
|
||||||
|
const top = preferTop >= gap
|
||||||
|
? clamp(preferTop, gap, maxTop)
|
||||||
|
: clamp(preferBottom, gap, maxTop);
|
||||||
|
return [left, top];
|
||||||
|
}
|
||||||
|
},
|
||||||
|
legend: {
|
||||||
|
top: 10,
|
||||||
|
textStyle: {
|
||||||
|
color: legendColor
|
||||||
|
}
|
||||||
|
},
|
||||||
|
grid: {
|
||||||
|
top: 40,
|
||||||
|
left: 8,
|
||||||
|
right: 8,
|
||||||
|
bottom: 8,
|
||||||
|
containLabel: true
|
||||||
|
},
|
||||||
|
xAxis: {
|
||||||
|
type: "category",
|
||||||
|
boundaryGap: false,
|
||||||
|
data: visibleHistoryItems.value.map((item) => item.at),
|
||||||
|
axisLabel: {
|
||||||
|
color: axisColor,
|
||||||
|
formatter: val => Time.toShortTime(Number(val))
|
||||||
|
},
|
||||||
|
axisLine: {
|
||||||
|
lineStyle: {
|
||||||
|
color: lineColor
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!historyItems.value.length) {
|
||||||
|
return commonOption;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (historyMetric.value === "cpu") {
|
||||||
|
return {
|
||||||
|
...commonOption,
|
||||||
|
tooltip: {
|
||||||
|
...commonOption.tooltip,
|
||||||
|
formatter: params => formatPercentTooltip(params, " %")
|
||||||
|
},
|
||||||
|
yAxis: {
|
||||||
|
type: "value",
|
||||||
|
min: 0,
|
||||||
|
max: 100,
|
||||||
|
axisLabel: {
|
||||||
|
color: axisColor,
|
||||||
|
formatter: "{value} %"
|
||||||
|
},
|
||||||
|
splitLine: {
|
||||||
|
lineStyle: {
|
||||||
|
color: lineColor
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
series: [
|
||||||
|
resolveLineSeries("CPU", visibleHistoryItems.value.map((item) => item.cpuPercent || 0))
|
||||||
|
]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (historyMetric.value === "memory") {
|
||||||
|
return {
|
||||||
|
...commonOption,
|
||||||
|
tooltip: {
|
||||||
|
...commonOption.tooltip,
|
||||||
|
formatter: params => {
|
||||||
|
const list = Array.isArray(params) ? params : [params];
|
||||||
|
const title = `时间:${Time.toDateTime(Number(list[0]?.name))}`;
|
||||||
|
const lines = list.map((item) => {
|
||||||
|
const currentName = item.seriesName || "";
|
||||||
|
if (currentName === "内存占用") {
|
||||||
|
return `${item.marker}${currentName}: ${IOSize.format(Number(item.value))}`;
|
||||||
|
}
|
||||||
|
return `${item.marker}${currentName}: ${Text.unit(Number(item.value), " %")}`;
|
||||||
|
});
|
||||||
|
return [title, ...lines].join("<br />");
|
||||||
|
}
|
||||||
|
},
|
||||||
|
yAxis: [
|
||||||
|
{
|
||||||
|
type: "value",
|
||||||
|
axisLabel: {
|
||||||
|
color: axisColor,
|
||||||
|
formatter: (value: number) => IOSize.format(value)
|
||||||
|
},
|
||||||
|
splitLine: {
|
||||||
|
lineStyle: {
|
||||||
|
color: lineColor
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "value",
|
||||||
|
min: 0,
|
||||||
|
max: 100,
|
||||||
|
axisLabel: {
|
||||||
|
color: axisColor,
|
||||||
|
formatter: "{value} %"
|
||||||
|
},
|
||||||
|
splitLine: {
|
||||||
|
show: false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
series: [
|
||||||
|
resolveLineSeries("内存占用", visibleHistoryItems.value.map((item) => item.memoryUsageBytes || 0)),
|
||||||
|
resolveLineSeries("内存使用率", visibleHistoryItems.value.map((item) => item.memoryPercent || 0), 1)
|
||||||
|
]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (historyMetric.value === "network") {
|
||||||
|
return {
|
||||||
|
...commonOption,
|
||||||
|
tooltip: {
|
||||||
|
...commonOption.tooltip,
|
||||||
|
formatter: params => formatSizeTooltip(params)
|
||||||
|
},
|
||||||
|
yAxis: {
|
||||||
|
type: "value",
|
||||||
|
axisLabel: {
|
||||||
|
color: axisColor,
|
||||||
|
formatter: (value: number) => IOSize.format(value)
|
||||||
|
},
|
||||||
|
splitLine: {
|
||||||
|
lineStyle: {
|
||||||
|
color: lineColor
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
series: [
|
||||||
|
resolveLineSeries("接收", visibleHistoryItems.value.map((item) => item.networkRxBytes || 0)),
|
||||||
|
resolveLineSeries("发送", visibleHistoryItems.value.map((item) => item.networkTxBytes || 0))
|
||||||
|
]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (historyMetric.value === "block") {
|
||||||
|
return {
|
||||||
|
...commonOption,
|
||||||
|
tooltip: {
|
||||||
|
...commonOption.tooltip,
|
||||||
|
formatter: params => formatSizeTooltip(params)
|
||||||
|
},
|
||||||
|
yAxis: {
|
||||||
|
type: "value",
|
||||||
|
axisLabel: {
|
||||||
|
color: axisColor,
|
||||||
|
formatter: (value: number) => IOSize.format(value)
|
||||||
|
},
|
||||||
|
splitLine: {
|
||||||
|
lineStyle: {
|
||||||
|
color: lineColor
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
series: [
|
||||||
|
resolveLineSeries("读取", visibleHistoryItems.value.map((item) => item.blockReadBytes || 0)),
|
||||||
|
resolveLineSeries("写入", visibleHistoryItems.value.map((item) => item.blockWriteBytes || 0))
|
||||||
|
]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...commonOption,
|
||||||
|
tooltip: {
|
||||||
|
...commonOption.tooltip,
|
||||||
|
formatter: params => {
|
||||||
|
const list = Array.isArray(params) ? params : [params];
|
||||||
|
const title = `时间:${Time.toDateTime(Number(list[0]?.name))}`;
|
||||||
|
const lines = list.map((item) => `${item.marker}${item.seriesName}: ${Text.display(Number(item.value))}`);
|
||||||
|
return [title, ...lines].join("<br />");
|
||||||
|
}
|
||||||
|
},
|
||||||
|
yAxis: {
|
||||||
|
type: "value",
|
||||||
|
minInterval: 1,
|
||||||
|
axisLabel: {
|
||||||
|
color: axisColor
|
||||||
|
},
|
||||||
|
splitLine: {
|
||||||
|
lineStyle: {
|
||||||
|
color: lineColor
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
series: [
|
||||||
|
resolveLineSeries("进程数", visibleHistoryItems.value.map((item) => item.pids || 0))
|
||||||
|
]
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
function formatPercentTooltip(params: unknown, unit: string): string {
|
||||||
|
const list = Array.isArray(params) ? params : [params];
|
||||||
|
const title = `时间:${Time.toDateTime(Number((list[0] as { name?: string }).name || 0))}`;
|
||||||
|
const lines = list.map((item) => {
|
||||||
|
const current = item as { marker?: string; seriesName?: string; value?: number };
|
||||||
|
return `${current.marker || ""}${current.seriesName || ""}: ${Text.unit(Number(current.value || 0), unit)}`;
|
||||||
|
});
|
||||||
|
return [title, ...lines].join("<br />");
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatSizeTooltip(params: unknown): string {
|
||||||
|
const list = Array.isArray(params) ? params : [params];
|
||||||
|
const title = `时间:${Time.toDateTime(Number((list[0] as { name?: string }).name || 0))}`;
|
||||||
|
const lines = list.map((item) => {
|
||||||
|
const current = item as { marker?: string; seriesName?: string; value?: number };
|
||||||
|
return `${current.marker || ""}${current.seriesName || ""}: ${IOSize.format(Number(current.value || 0))}`;
|
||||||
|
});
|
||||||
|
return [title, ...lines].join("<br />");
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveLineSeries(name: string, data: Array<number | null>, yAxisIndex?: number): SeriesOption {
|
||||||
|
const series: SeriesOption = {
|
||||||
|
type: "line",
|
||||||
|
name,
|
||||||
|
showSymbol: false,
|
||||||
|
smooth: true,
|
||||||
|
lineStyle: {
|
||||||
|
width: 2
|
||||||
|
},
|
||||||
|
areaStyle: {
|
||||||
|
opacity: .18
|
||||||
|
},
|
||||||
|
data
|
||||||
|
};
|
||||||
|
|
||||||
|
if (typeof yAxisIndex === "number") {
|
||||||
|
series.yAxisIndex = yAxisIndex;
|
||||||
|
}
|
||||||
|
|
||||||
|
return series;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 加载与自动刷新 ----------
|
||||||
|
|
||||||
|
let statusTimer: number | null = null;
|
||||||
|
let historyTimer: number | null = null;
|
||||||
|
|
||||||
|
watch(() => settingStore.dashboard.server, restartAutoRefresh, { deep: true });
|
||||||
|
watch(containerId, () => {
|
||||||
|
statusView.value = null;
|
||||||
|
historyPoints.value = [];
|
||||||
|
resetHistorySlider();
|
||||||
|
restartAutoRefresh();
|
||||||
|
}, { immediate: true });
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
updateThemeState();
|
||||||
|
themeObserver = new MutationObserver(() => {
|
||||||
|
updateThemeState();
|
||||||
|
});
|
||||||
|
themeObserver.observe(document.documentElement, {
|
||||||
|
attributes: true,
|
||||||
|
attributeFilter: ["class"]
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
clearAutoRefreshTimer();
|
||||||
|
if (themeObserver) {
|
||||||
|
themeObserver.disconnect();
|
||||||
|
themeObserver = null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function updateThemeState(): void {
|
||||||
|
isDarkTheme.value = document.documentElement.classList.contains("theme-dark");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshContainerStatus(): Promise<void> {
|
||||||
|
if (!containerId.value) {
|
||||||
|
statusView.value = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (isStatusLoading.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
isStatusLoading.value = true;
|
||||||
|
try {
|
||||||
|
statusView.value = await DockerAPI.getContainerStatus(containerId.value);
|
||||||
|
} catch (error) {
|
||||||
|
Toast({
|
||||||
|
theme: "error",
|
||||||
|
message: error instanceof Error ? error.message : "请求失败,请稍后重试"
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
isStatusLoading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshContainerHistory(): Promise<void> {
|
||||||
|
if (!containerId.value) {
|
||||||
|
historyPoints.value = [];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (isHistoryLoading.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
isHistoryLoading.value = true;
|
||||||
|
try {
|
||||||
|
const historyView = await DockerAPI.getContainerHistory(containerId.value, {
|
||||||
|
window: "2h"
|
||||||
|
});
|
||||||
|
historyPoints.value = historyView?.points || [];
|
||||||
|
} catch (error) {
|
||||||
|
Toast({
|
||||||
|
theme: "error",
|
||||||
|
message: error instanceof Error ? error.message : "请求失败,请稍后重试"
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
isHistoryLoading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshDashboard(): Promise<void> {
|
||||||
|
await refreshContainerStatus();
|
||||||
|
await refreshContainerHistory();
|
||||||
|
}
|
||||||
|
|
||||||
|
function restartAutoRefresh(): void {
|
||||||
|
clearAutoRefreshTimer();
|
||||||
|
if (!containerId.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
void refreshDashboard();
|
||||||
|
|
||||||
|
const snapshotIntervalMs = Math.max(settingStore.dashboard.server.snapshotRefreshSeconds, 1) * 1000;
|
||||||
|
const historyIntervalMs = Math.max(settingStore.dashboard.server.historyRefreshSeconds, 1) * 1000;
|
||||||
|
|
||||||
|
statusTimer = window.setInterval(() => {
|
||||||
|
void refreshContainerStatus();
|
||||||
|
}, snapshotIntervalMs);
|
||||||
|
|
||||||
|
historyTimer = window.setInterval(() => {
|
||||||
|
void refreshContainerHistory();
|
||||||
|
}, historyIntervalMs);
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearAutoRefreshTimer(): void {
|
||||||
|
if (statusTimer !== null) {
|
||||||
|
window.clearInterval(statusTimer);
|
||||||
|
statusTimer = null;
|
||||||
|
}
|
||||||
|
if (historyTimer !== null) {
|
||||||
|
window.clearInterval(historyTimer);
|
||||||
|
historyTimer = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="less">
|
||||||
|
.docker-container-detail {
|
||||||
|
gap: 1rem;
|
||||||
|
display: flex;
|
||||||
|
padding: var(--app-nav-offset) 0 1rem 0;
|
||||||
|
flex-direction: column;
|
||||||
|
|
||||||
|
.section {
|
||||||
|
|
||||||
|
&.history {
|
||||||
|
|
||||||
|
.metric-tabs {
|
||||||
|
width: 100%;
|
||||||
|
--td-tab-track-width: 4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart {
|
||||||
|
width: 100%;
|
||||||
|
height: 16rem;
|
||||||
|
background: #FFF;
|
||||||
|
touch-action: none;
|
||||||
|
|
||||||
|
:deep(svg),
|
||||||
|
:deep(canvas) {
|
||||||
|
touch-action: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.slider {
|
||||||
|
gap: .5rem;
|
||||||
|
display: flex;
|
||||||
|
padding: .5rem 2rem;
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
|
.label {
|
||||||
|
flex: none;
|
||||||
|
font-size: .75rem;
|
||||||
|
color: var(--app-sub);
|
||||||
|
}
|
||||||
|
|
||||||
|
.control {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-wrap {
|
||||||
|
display: flex;
|
||||||
|
min-height: 4rem;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
355
src/pages/dashboard/DockerDashboard/DockerDashboard.vue
Normal file
355
src/pages/dashboard/DockerDashboard/DockerDashboard.vue
Normal file
@@ -0,0 +1,355 @@
|
|||||||
|
<template>
|
||||||
|
<div class="docker-dashboard">
|
||||||
|
<div v-if="isListLoading && !containerItems.length" class="loading-wrap">
|
||||||
|
<t-loading text="加载 Docker 容器..." />
|
||||||
|
</div>
|
||||||
|
<template v-else-if="containerItems.length">
|
||||||
|
<t-cell-group title="状态统计" theme="card">
|
||||||
|
<div class="section overview">
|
||||||
|
<div v-for="item in containerOverviewStats" :key="item.key" class="stat bg-white">
|
||||||
|
<span class="label" v-text="item.label" />
|
||||||
|
<span :class="['value', item.className]" v-text="Text.display(item.value)" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</t-cell-group>
|
||||||
|
<t-cell-group title="容器列表" theme="card">
|
||||||
|
<div class="section list">
|
||||||
|
<button
|
||||||
|
v-for="item in containerItems"
|
||||||
|
:key="item.id"
|
||||||
|
type="button"
|
||||||
|
class="chip bg-white"
|
||||||
|
@click="handleSelectContainer(item.id)"
|
||||||
|
>
|
||||||
|
<span class="header">
|
||||||
|
<span class="name" v-text="Text.display(item.name)" />
|
||||||
|
<span :class="['state', resolveStateClass(item.state)]" v-text="resolveStateText(item.state)" />
|
||||||
|
</span>
|
||||||
|
<span class="content">
|
||||||
|
<span class="img clip-text" v-text="Text.display(item.image)" />
|
||||||
|
<span class="health keep-text" v-text="resolveHealthText(item.healthStatus)" />
|
||||||
|
</span>
|
||||||
|
<span class="bottom">
|
||||||
|
<span class="id" v-text="Text.display(item.id.slice(0, 12))" />
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</t-cell-group>
|
||||||
|
</template>
|
||||||
|
<t-empty v-else description="暂无 Docker 容器数据" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { Toast } from "tdesign-mobile-vue";
|
||||||
|
import DockerAPI from "@/api/DockerAPI";
|
||||||
|
import type { DockerContainerSummaryView } from "@/types/Docker";
|
||||||
|
import { useSettingStore } from "@/store/settingStore";
|
||||||
|
import { Text } from "timi-web";
|
||||||
|
|
||||||
|
defineOptions({
|
||||||
|
name: "DockerDashboard"
|
||||||
|
});
|
||||||
|
|
||||||
|
const router = useRouter();
|
||||||
|
const settingStore = useSettingStore();
|
||||||
|
|
||||||
|
async function handleSelectContainer(containerId: string): Promise<void> {
|
||||||
|
await router.push({
|
||||||
|
name: "DockerContainerDetail",
|
||||||
|
query: {
|
||||||
|
containerId,
|
||||||
|
tab: "docker"
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 格式化 ----------
|
||||||
|
|
||||||
|
function resolveStateClass(state?: string | null): string {
|
||||||
|
if (state === "running") {
|
||||||
|
return "ok";
|
||||||
|
}
|
||||||
|
if (state === "exited" || state === "dead") {
|
||||||
|
return "err";
|
||||||
|
}
|
||||||
|
return "wait";
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveStateText(state?: string | null): string {
|
||||||
|
if (!state) {
|
||||||
|
return "-";
|
||||||
|
}
|
||||||
|
const map: Record<string, string> = {
|
||||||
|
created: "已创建",
|
||||||
|
running: "运行中",
|
||||||
|
restarting: "重启中",
|
||||||
|
exited: "已退出",
|
||||||
|
paused: "已暂停",
|
||||||
|
dead: "已停止"
|
||||||
|
};
|
||||||
|
return map[state] || state;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveHealthText(health?: string | null): string {
|
||||||
|
if (!health) {
|
||||||
|
return "未配置";
|
||||||
|
}
|
||||||
|
const map: Record<string, string> = {
|
||||||
|
healthy: "健康",
|
||||||
|
unhealthy: "异常",
|
||||||
|
starting: "启动中"
|
||||||
|
};
|
||||||
|
return map[health] || health;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 数据加载 ----------
|
||||||
|
|
||||||
|
const isListLoading = ref(false);
|
||||||
|
const containerItems = ref<DockerContainerSummaryView[]>([]);
|
||||||
|
const containerOverviewStats = computed(() => {
|
||||||
|
const overview = {
|
||||||
|
total: containerItems.value.length,
|
||||||
|
running: 0,
|
||||||
|
stopped: 0,
|
||||||
|
other: 0
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const item of containerItems.value) {
|
||||||
|
const stateClass = resolveStateClass(item.state);
|
||||||
|
if (stateClass === "ok") {
|
||||||
|
overview.running += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (stateClass === "err") {
|
||||||
|
overview.stopped += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
overview.other += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
{ key: "total", label: "总数", value: overview.total, className: "all" },
|
||||||
|
{ key: "running", label: "运行中", value: overview.running, className: "ok" },
|
||||||
|
{ key: "stopped", label: "已停止", value: overview.stopped, className: "err" },
|
||||||
|
{ key: "other", label: "其他", value: overview.other, className: "wait" }
|
||||||
|
];
|
||||||
|
});
|
||||||
|
let listTimer: number | null = null;
|
||||||
|
|
||||||
|
watch(() => settingStore.dashboard.server, restartAutoRefresh, { deep: true });
|
||||||
|
onMounted(restartAutoRefresh);
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
clearAutoRefreshTimer();
|
||||||
|
});
|
||||||
|
|
||||||
|
async function refreshContainers(): Promise<void> {
|
||||||
|
if (isListLoading.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
isListLoading.value = true;
|
||||||
|
try {
|
||||||
|
const list = await DockerAPI.getContainers();
|
||||||
|
containerItems.value = list.slice().sort((left, right) => left.name.localeCompare(right.name));
|
||||||
|
} catch (error) {
|
||||||
|
Toast({
|
||||||
|
theme: "error",
|
||||||
|
message: error instanceof Error ? error.message : "请求失败,请稍后重试"
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
isListLoading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function restartAutoRefresh(): void {
|
||||||
|
void refreshContainers();
|
||||||
|
clearAutoRefreshTimer();
|
||||||
|
const snapshotIntervalMs = Math.max(settingStore.dashboard.server.snapshotRefreshSeconds, 1) * 1000;
|
||||||
|
listTimer = window.setInterval(() => {
|
||||||
|
void refreshContainers();
|
||||||
|
}, snapshotIntervalMs);
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearAutoRefreshTimer(): void {
|
||||||
|
if (listTimer !== null) {
|
||||||
|
window.clearInterval(listTimer);
|
||||||
|
listTimer = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="less">
|
||||||
|
.docker-dashboard {
|
||||||
|
gap: 1rem;
|
||||||
|
display: flex;
|
||||||
|
padding: 0 0 1rem 0;
|
||||||
|
flex-direction: column;
|
||||||
|
|
||||||
|
.section {
|
||||||
|
|
||||||
|
&.overview {
|
||||||
|
gap: .6rem;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
|
||||||
|
.stat {
|
||||||
|
gap: .25rem;
|
||||||
|
display: flex;
|
||||||
|
padding: .5rem;
|
||||||
|
border-radius: .5rem;
|
||||||
|
flex-direction: column;
|
||||||
|
|
||||||
|
.label {
|
||||||
|
color: var(--app-sub);
|
||||||
|
font-size: .72rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.value {
|
||||||
|
font-size: .95rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--app-text);
|
||||||
|
|
||||||
|
&.ok {
|
||||||
|
color: #047857;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.err {
|
||||||
|
color: #b42318;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.wait {
|
||||||
|
color: #8a5b00;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&.list {
|
||||||
|
gap: .6rem;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
|
||||||
|
.chip {
|
||||||
|
gap: .45rem;
|
||||||
|
border: 0;
|
||||||
|
display: flex;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: .6rem;
|
||||||
|
text-align: left;
|
||||||
|
border-radius: .75rem;
|
||||||
|
flex-direction: column;
|
||||||
|
|
||||||
|
.header,
|
||||||
|
.content {
|
||||||
|
gap: .45rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header {
|
||||||
|
|
||||||
|
.name {
|
||||||
|
flex: 1;
|
||||||
|
color: var(--app-text);
|
||||||
|
font-size: .85rem;
|
||||||
|
font-weight: 700;
|
||||||
|
overflow: hidden;
|
||||||
|
white-space: nowrap;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.state {
|
||||||
|
flex: none;
|
||||||
|
padding: .1rem .4rem;
|
||||||
|
border-radius: .25rem;
|
||||||
|
|
||||||
|
&.ok {
|
||||||
|
color: #047857;
|
||||||
|
background: rgba(16, 185, 129, .15);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.err {
|
||||||
|
color: #b42318;
|
||||||
|
background: rgba(250, 76, 76, .14);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.wait {
|
||||||
|
color: #8a5b00;
|
||||||
|
background: rgba(245, 158, 11, .18);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.img,
|
||||||
|
.health {
|
||||||
|
color: var(--app-sub);
|
||||||
|
font-size: .72rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bottom {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
|
||||||
|
.id {
|
||||||
|
color: var(--app-sub);
|
||||||
|
font-size: .72rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.goto {
|
||||||
|
color: var(--td-brand-color);
|
||||||
|
font-size: .72rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-wrap {
|
||||||
|
display: flex;
|
||||||
|
min-height: 4rem;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.theme-dark) .docker-dashboard .section.list .chip {
|
||||||
|
background: rgba(255, 255, 255, .06);
|
||||||
|
|
||||||
|
.state {
|
||||||
|
|
||||||
|
&.ok {
|
||||||
|
color: #86efac;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.err {
|
||||||
|
color: #fda4af;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.wait {
|
||||||
|
color: #fcd34d;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.theme-dark) .docker-dashboard .section.overview .stat {
|
||||||
|
background: rgba(255, 255, 255, .06);
|
||||||
|
|
||||||
|
.value {
|
||||||
|
|
||||||
|
&.ok {
|
||||||
|
color: #86efac;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.err {
|
||||||
|
color: #fda4af;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.wait {
|
||||||
|
color: #fcd34d;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
809
src/pages/dashboard/ServerDashboard/ServerDashboard.vue
Normal file
809
src/pages/dashboard/ServerDashboard/ServerDashboard.vue
Normal file
@@ -0,0 +1,809 @@
|
|||||||
|
<template>
|
||||||
|
<div class="server-dashboard">
|
||||||
|
<div v-if="isSnapshotLoading && !snapshotView" class="loading-wrap">
|
||||||
|
<t-loading text="加载系统状态..." />
|
||||||
|
</div>
|
||||||
|
<template v-else-if="snapshotView">
|
||||||
|
<t-cell-group title="系统" theme="card">
|
||||||
|
<t-cell title="操作系统" :note="Text.display(snapshotView?.snapshot?.os?.name)" />
|
||||||
|
<t-cell title="启动时间" :note="Time.toPassedDateTime(snapshotView?.snapshot?.os?.bootAt)" />
|
||||||
|
<t-cell
|
||||||
|
v-if="snapshotView?.snapshot?.os?.bootAt"
|
||||||
|
title="运行时长"
|
||||||
|
:note="Time.duration(Time.now() - snapshotView?.snapshot?.os?.bootAt)"
|
||||||
|
/>
|
||||||
|
<t-cell title="更多" arrow @click="router.push({
|
||||||
|
path: '/server/system-detail',
|
||||||
|
query: {
|
||||||
|
tab: 'server'
|
||||||
|
}
|
||||||
|
})"
|
||||||
|
/>
|
||||||
|
</t-cell-group>
|
||||||
|
<t-cell-group class="section performance" title="资源" theme="card">
|
||||||
|
<t-cell-info label="CPU" :value="Text.display(snapshotView?.snapshot?.cpu?.model)">
|
||||||
|
<progress-group
|
||||||
|
mode="heap"
|
||||||
|
:progress="cpuProgressList"
|
||||||
|
:note="Text.unit((snapshotView?.snapshot?.cpu?.usageTotal || 0) * 100, '%')"
|
||||||
|
/>
|
||||||
|
</t-cell-info>
|
||||||
|
<t-cell-info class="memory" label="内存" :value="memoryUsageText">
|
||||||
|
<progress-group
|
||||||
|
mode="splice"
|
||||||
|
:progress="memoryProgressList"
|
||||||
|
:note="Text.unit(memoryUsed * 100, '%')"
|
||||||
|
/>
|
||||||
|
</t-cell-info>
|
||||||
|
<t-cell-info class="memory-jvm" label="JVM 内存" :value="IOSize.format(jvmMemoryHeapMax)">
|
||||||
|
<progress-group
|
||||||
|
mode="heap"
|
||||||
|
:progress="jvmMemoryProgressList"
|
||||||
|
:note="Text.unit(jvmMemoryHeapCommited / jvmMemoryHeapMax * 100, '%')"
|
||||||
|
/>
|
||||||
|
</t-cell-info>
|
||||||
|
<t-cell title="网络发送速率" :note="IOSize.speed(snapshotView?.snapshot?.network?.txBytesPerSecond)">
|
||||||
|
<template #left-icon>
|
||||||
|
<t-icon name="arrow-up" />
|
||||||
|
</template>
|
||||||
|
</t-cell>
|
||||||
|
<t-cell title="网络接收速率" :note="IOSize.speed(snapshotView?.snapshot?.network?.rxBytesPerSecond)">
|
||||||
|
<template #left-icon>
|
||||||
|
<t-icon name="arrow-down" />
|
||||||
|
</template>
|
||||||
|
</t-cell>
|
||||||
|
<t-cell title="更多" arrow @click="router.push({
|
||||||
|
path: '/server/performance-detail',
|
||||||
|
query: {
|
||||||
|
tab: 'server'
|
||||||
|
}
|
||||||
|
})"
|
||||||
|
/>
|
||||||
|
</t-cell-group>
|
||||||
|
<t-cell-group class="section disk" title="磁盘" theme="card">
|
||||||
|
<t-cell-info
|
||||||
|
class="item"
|
||||||
|
v-for="item in snapshotView?.snapshot?.storagePartitions"
|
||||||
|
:key="item.uuid"
|
||||||
|
:label="item.mountPoint || item.partitionId"
|
||||||
|
:value="`${IOSize.format(item.used)} / ${IOSize.format(item.total)}`"
|
||||||
|
clip-text="label"
|
||||||
|
>
|
||||||
|
<progress-group
|
||||||
|
mode="heap"
|
||||||
|
:progress="[{
|
||||||
|
color: 'var(--td-brand-color)',
|
||||||
|
value: item.used / item.total
|
||||||
|
}]"
|
||||||
|
:note="Text.unit(item.used / item.total * 100, '%')"
|
||||||
|
/>
|
||||||
|
</t-cell-info>
|
||||||
|
<t-cell title="更多" arrow @click="router.push({
|
||||||
|
path: '/server/partitions-detail',
|
||||||
|
query: {
|
||||||
|
tab: 'server'
|
||||||
|
}
|
||||||
|
})"
|
||||||
|
/>
|
||||||
|
</t-cell-group>
|
||||||
|
</template>
|
||||||
|
<t-empty v-else description="暂无系统状态数据" />
|
||||||
|
<t-cell-group class="section history" title="历史采样" theme="card">
|
||||||
|
<t-tabs class="metric-tabs" :value="historyMetric" @change="handleHistoryMetricChange">
|
||||||
|
<t-tab-panel
|
||||||
|
v-for="item in historyMetricTabs"
|
||||||
|
:key="item.value"
|
||||||
|
:value="item.value"
|
||||||
|
:label="item.label"
|
||||||
|
/>
|
||||||
|
</t-tabs>
|
||||||
|
<div v-if="isHistoryLoading && !historyItems.length" class="loading-wrap">
|
||||||
|
<t-loading text="加载历史数据..." />
|
||||||
|
</div>
|
||||||
|
<template v-else-if="historyItems.length">
|
||||||
|
<v-chart
|
||||||
|
ref="chartRef"
|
||||||
|
class="chart"
|
||||||
|
:option="historyChartOption"
|
||||||
|
:update-options="historyChartUpdateOptions"
|
||||||
|
:autoresize="true"
|
||||||
|
/>
|
||||||
|
<div class="slider bg-white">
|
||||||
|
<span class="label">较早</span>
|
||||||
|
<t-slider
|
||||||
|
class="control"
|
||||||
|
:min="0"
|
||||||
|
:max="Math.max(historyItems.length - historyVisibleCount, 0)"
|
||||||
|
:step="1"
|
||||||
|
:value="historySliderValue"
|
||||||
|
@change="handleHistorySliderChange"
|
||||||
|
/>
|
||||||
|
<span class="label">最新</span>
|
||||||
|
</div>
|
||||||
|
<t-cell-group>
|
||||||
|
<t-cell v-for="item in currentHistorySummary" :key="item.label" :title="item.label" :note="item.value" />
|
||||||
|
</t-cell-group>
|
||||||
|
</template>
|
||||||
|
<t-empty v-else description="暂无历史采样数据" />
|
||||||
|
</t-cell-group>
|
||||||
|
</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 { useRouter } from "vue-router";
|
||||||
|
import type { EChartsOption, SeriesOption } from "echarts";
|
||||||
|
import SystemAPI from "@/api/SystemAPI";
|
||||||
|
import type { SystemStatusHistoryPoint, SystemStatusSnapshotView } from "@/types/System";
|
||||||
|
import { useSettingStore } from "@/store/settingStore";
|
||||||
|
import { IOSize, type LabelValue, Text, Time } from "timi-web";
|
||||||
|
import TCellInfo from "@/components/TCellInfo.vue";
|
||||||
|
import type { ProgressItem } from "@/components/ProgressGroup.vue";
|
||||||
|
import ProgressGroup from "@/components/ProgressGroup.vue";
|
||||||
|
|
||||||
|
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([
|
||||||
|
SVGRenderer,
|
||||||
|
LineChart,
|
||||||
|
GridComponent,
|
||||||
|
TooltipComponent,
|
||||||
|
LegendComponent
|
||||||
|
]);
|
||||||
|
|
||||||
|
defineOptions({
|
||||||
|
name: "ServerDashboard"
|
||||||
|
});
|
||||||
|
|
||||||
|
const router = useRouter();
|
||||||
|
const settingStore = useSettingStore();
|
||||||
|
|
||||||
|
const isSnapshotLoading = ref(false);
|
||||||
|
const snapshotView = ref<SystemStatusSnapshotView | null>(null);
|
||||||
|
|
||||||
|
// ---------- CPU ----------
|
||||||
|
|
||||||
|
const cpuProgressList = computed<ProgressItem[]>(() => {
|
||||||
|
const system = snapshotView.value?.snapshot?.cpu?.usageSystem || 0;
|
||||||
|
const total = snapshotView.value?.snapshot?.cpu?.usageTotal || 0;
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
color: "var(--td-brand-color)",
|
||||||
|
value: system,
|
||||||
|
legend: `系统占用(${Text.unit(system * 100, '%')})`
|
||||||
|
},
|
||||||
|
{
|
||||||
|
color: "var(--td-brand-color-5)",
|
||||||
|
value: total,
|
||||||
|
legend: `总占用(${Text.unit(total * 100, '%')})`
|
||||||
|
}
|
||||||
|
];
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------- 内存 ----------
|
||||||
|
|
||||||
|
const memoryUsed = computed(() => {
|
||||||
|
const usedBytes = snapshotView.value?.snapshot?.memory?.usedBytes || 1;
|
||||||
|
const totalBytes = snapshotView.value?.snapshot?.memory?.totalBytes || 1;
|
||||||
|
return usedBytes / totalBytes;
|
||||||
|
});
|
||||||
|
const memoryUsageText = computed(() => {
|
||||||
|
const usedBytes = snapshotView.value?.snapshot?.memory?.usedBytes;
|
||||||
|
const totalBytes = snapshotView.value?.snapshot?.memory?.totalBytes;
|
||||||
|
return `${IOSize.format(usedBytes)} / ${IOSize.format(totalBytes)}`;
|
||||||
|
});
|
||||||
|
const memoryTotal = computed(() => {
|
||||||
|
const total = snapshotView.value?.snapshot?.memory?.totalBytes || 0;
|
||||||
|
const totalSwap = snapshotView.value?.snapshot?.memory?.swapTotalBytes || 0;
|
||||||
|
return total + totalSwap;
|
||||||
|
});
|
||||||
|
const memoryProgressList = computed<ProgressItem[]>(() => {
|
||||||
|
const used = snapshotView.value?.snapshot?.memory?.usedBytes || 0;
|
||||||
|
const usedSwap = snapshotView.value?.snapshot?.memory?.swapUsedBytes || 0;
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
color: "var(--td-brand-color)",
|
||||||
|
value: used / memoryTotal.value,
|
||||||
|
legend: `物理(${IOSize.format(used)})`
|
||||||
|
},
|
||||||
|
{
|
||||||
|
color: "var(--td-brand-color-5)",
|
||||||
|
value: usedSwap / memoryTotal.value,
|
||||||
|
legend: `交换区(${IOSize.format(usedSwap)})`
|
||||||
|
}
|
||||||
|
]
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------- JVM 内存 ----------
|
||||||
|
|
||||||
|
const jvmMemoryHeapMax = computed(() => snapshotView.value?.snapshot?.jvm?.heapMaxBytes || 0);
|
||||||
|
const jvmMemoryHeapCommited = computed(() => snapshotView.value?.snapshot?.jvm?.heapCommittedBytes || 0);
|
||||||
|
const jvmMemoryHeapUsed = computed(() => snapshotView.value?.snapshot?.jvm?.heapUsedBytes || 0);
|
||||||
|
const jvmMemoryProgressList = computed<ProgressItem[]>(() => {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
color: "var(--td-brand-color)",
|
||||||
|
value: jvmMemoryHeapCommited.value / jvmMemoryHeapMax.value,
|
||||||
|
legend: `已提交(${IOSize.format(jvmMemoryHeapCommited.value)})`
|
||||||
|
},
|
||||||
|
{
|
||||||
|
color: "var(--td-brand-color-5)",
|
||||||
|
value: jvmMemoryHeapUsed.value / jvmMemoryHeapMax.value,
|
||||||
|
legend: `已使用(${IOSize.format(jvmMemoryHeapUsed.value)})`
|
||||||
|
},
|
||||||
|
]
|
||||||
|
})
|
||||||
|
|
||||||
|
// ---------- 历史采样图表 ----------
|
||||||
|
|
||||||
|
const isHistoryLoading = ref(false);
|
||||||
|
const historyPoints = ref<SystemStatusHistoryPoint[]>([]);
|
||||||
|
const chartRef = ref<InstanceType<typeof VChart> | null>(null);
|
||||||
|
const historyChartUpdateOptions = Object.freeze({
|
||||||
|
notMerge: true
|
||||||
|
});
|
||||||
|
const isDarkTheme = ref(false);
|
||||||
|
|
||||||
|
// ---------- Tab ----------
|
||||||
|
|
||||||
|
const historyMetric = ref<HistoryMetric>("cpu");
|
||||||
|
|
||||||
|
const historyMetricTabs = computed(() => {
|
||||||
|
const labelMap: Record<HistoryMetric, string> = {
|
||||||
|
cpu: "CPU",
|
||||||
|
memory: "内存",
|
||||||
|
jvm: "JVM",
|
||||||
|
network: "网络"
|
||||||
|
};
|
||||||
|
|
||||||
|
return SERVER_HISTORY_METRICS.map((metric) => ({
|
||||||
|
value: metric,
|
||||||
|
label: labelMap[metric]
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
watch(
|
||||||
|
() => historyMetricTabs.value,
|
||||||
|
(tabs) => {
|
||||||
|
if (!tabs.length) {
|
||||||
|
historyMetric.value = "cpu";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!tabs.some((item) => item.value === historyMetric.value)) {
|
||||||
|
historyMetric.value = tabs[0].value;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ immediate: true }
|
||||||
|
);
|
||||||
|
|
||||||
|
function handleHistoryMetricChange(value: string): void {
|
||||||
|
const nextValue = value as HistoryMetric;
|
||||||
|
if (nextValue === "cpu" || nextValue === "memory" || nextValue === "jvm" || nextValue === "network") {
|
||||||
|
historyMetric.value = nextValue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 图表数据 ----------
|
||||||
|
|
||||||
|
const historyItems = computed<SystemStatusHistoryPoint[]>(() => {
|
||||||
|
return historyPoints.value.slice().sort((left, right) => left.at - right.at);
|
||||||
|
});
|
||||||
|
const currentHistoryRange = computed(() => {
|
||||||
|
const length = historyItems.value.length;
|
||||||
|
if (length < 1) {
|
||||||
|
return { startValue: 0, endValue: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
const startValue = Math.min(Math.max(Math.floor(historySliderValue.value), 0), historySliderMaxStart.value);
|
||||||
|
const endValue = Math.min(startValue + historyVisibleCount.value - 1, length - 1);
|
||||||
|
return { startValue, endValue };
|
||||||
|
});
|
||||||
|
const visibleHistoryItems = computed<SystemStatusHistoryPoint[]>(() => {
|
||||||
|
if (!historyItems.value.length) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
return historyItems.value.slice(currentHistoryRange.value.startValue, currentHistoryRange.value.endValue + 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------- 调整图表数据范围 ----------
|
||||||
|
|
||||||
|
const isHistorySliderTouched = ref(false);
|
||||||
|
const historySliderValue = ref(0);
|
||||||
|
const HISTORY_DEFAULT_VISIBLE_COUNT = 30;
|
||||||
|
|
||||||
|
const historyVisibleCount = computed(() => {
|
||||||
|
if (historyItems.value.length < 1) {
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
return Math.min(HISTORY_DEFAULT_VISIBLE_COUNT, historyItems.value.length);
|
||||||
|
});
|
||||||
|
const historySliderMaxStart = computed(() => {
|
||||||
|
return Math.max(historyItems.value.length - historyVisibleCount.value, 0);
|
||||||
|
});
|
||||||
|
watch(() => historyItems.value.length, (length) => {
|
||||||
|
if (length < 1) {
|
||||||
|
historySliderValue.value = 0;
|
||||||
|
isHistorySliderTouched.value = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!isHistorySliderTouched.value) {
|
||||||
|
historySliderValue.value = historySliderMaxStart.value;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
historySliderValue.value = Math.min(historySliderValue.value, historySliderMaxStart.value);
|
||||||
|
});
|
||||||
|
|
||||||
|
function handleHistorySliderChange(value: number): void {
|
||||||
|
historySliderValue.value = Math.min(Math.max(Math.floor(value), 0), historySliderMaxStart.value);
|
||||||
|
isHistorySliderTouched.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 当前图表摘要 ----------
|
||||||
|
|
||||||
|
const currentHistorySummary = computed<LabelValue<string, any>[]>(() => {
|
||||||
|
if (!historyItems.value.length) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
const lastPoint = historyItems.value[historyItems.value.length - 1];
|
||||||
|
const summary: LabelValue<string, any>[] = [];
|
||||||
|
if (historyMetric.value === "cpu") {
|
||||||
|
summary.push(
|
||||||
|
{ label: "总使用率", value: Text.unit(lastPoint.cpuUsagePercent * 100, "%") },
|
||||||
|
{ label: "系统使用率", value: Text.unit(lastPoint.cpuSystemPercent * 100, "%") }
|
||||||
|
);
|
||||||
|
return summary;
|
||||||
|
}
|
||||||
|
if (historyMetric.value === "memory") {
|
||||||
|
summary.push(
|
||||||
|
{ label: "物理", value: IOSize.format(lastPoint.memoryUsedBytes) },
|
||||||
|
{ label: "交换区", value: IOSize.format(lastPoint.swapUsedBytes) }
|
||||||
|
);
|
||||||
|
return summary;
|
||||||
|
}
|
||||||
|
if (historyMetric.value === "jvm") {
|
||||||
|
summary.push(
|
||||||
|
{ label: "已使用", value: IOSize.format(lastPoint.heapUsedBytes) },
|
||||||
|
{ label: "已提交", value: IOSize.format(lastPoint.heapCommittedBytes) },
|
||||||
|
{ label: "GC 周期耗时", value: Text.unit(lastPoint.gcCycleTimeMs, "毫秒") },
|
||||||
|
{ label: "GC 暂停耗时", value: Text.unit(lastPoint.gcPauseTimeMs, "毫秒") }
|
||||||
|
);
|
||||||
|
return summary;
|
||||||
|
}
|
||||||
|
summary.push(
|
||||||
|
{ label: "接收", value: IOSize.speed(lastPoint.rxBytesPerSecond) },
|
||||||
|
{ label: "发送", value: IOSize.speed(lastPoint.txBytesPerSecond) }
|
||||||
|
);
|
||||||
|
return summary;
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------- 图表配置 ----------
|
||||||
|
|
||||||
|
const historyChartOption = computed((): EChartsOption => {
|
||||||
|
const axisColor = isDarkTheme.value ? "#9aa4b2" : "#7e8a9a";
|
||||||
|
const lineColor = isDarkTheme.value ? "rgba(255, 255, 255, .12)" : "rgba(18, 42, 66, .08)";
|
||||||
|
const legendColor = isDarkTheme.value ? "#d7dee8" : "#334155";
|
||||||
|
const memoryAxisMax = Math.max(snapshotView.value?.snapshot?.memory?.totalBytes || 0, 1);
|
||||||
|
|
||||||
|
// 通用配置
|
||||||
|
const commonOption: EChartsOption = {
|
||||||
|
tooltip: {
|
||||||
|
trigger: "axis",
|
||||||
|
confine: true,
|
||||||
|
position: (point, _params, _dom, _rect, size) => {
|
||||||
|
const gap = 6;
|
||||||
|
const fingerGap = 24;
|
||||||
|
const contentSize = size?.contentSize || [0, 0];
|
||||||
|
const viewSize = size?.viewSize || [0, 0];
|
||||||
|
const maxLeft = Math.max(viewSize[0] - contentSize[0] - gap, gap);
|
||||||
|
const maxTop = Math.max(viewSize[1] - contentSize[1] - gap, gap);
|
||||||
|
const clamp = (value: number, min: number, max: number): number => {
|
||||||
|
return Math.min(Math.max(value, min), max);
|
||||||
|
};
|
||||||
|
const preferLeft = point[0] - contentSize[0] - gap;
|
||||||
|
const preferRight = point[0] + gap;
|
||||||
|
const left = point[0] < viewSize[0] / 2
|
||||||
|
? clamp(preferRight, gap, maxLeft)
|
||||||
|
: clamp(preferLeft, gap, maxLeft);
|
||||||
|
const preferTop = point[1] - contentSize[1] - fingerGap;
|
||||||
|
const preferBottom = point[1] + fingerGap;
|
||||||
|
const top = preferTop >= gap
|
||||||
|
? clamp(preferTop, gap, maxTop)
|
||||||
|
: clamp(preferBottom, gap, maxTop);
|
||||||
|
return [left, top];
|
||||||
|
}
|
||||||
|
},
|
||||||
|
legend: {
|
||||||
|
top: 10,
|
||||||
|
textStyle: {
|
||||||
|
color: legendColor
|
||||||
|
}
|
||||||
|
},
|
||||||
|
grid: {
|
||||||
|
top: 40,
|
||||||
|
left: 8,
|
||||||
|
right: 8,
|
||||||
|
bottom: 8,
|
||||||
|
containLabel: true
|
||||||
|
},
|
||||||
|
xAxis: {
|
||||||
|
type: "category",
|
||||||
|
boundaryGap: false,
|
||||||
|
data: visibleHistoryItems.value.map((item) => item.at),
|
||||||
|
axisLabel: {
|
||||||
|
color: axisColor,
|
||||||
|
formatter: val => Time.toShortTime(Number(val))
|
||||||
|
},
|
||||||
|
axisLine: {
|
||||||
|
lineStyle: {
|
||||||
|
color: lineColor
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if (!historyItems.value.length) {
|
||||||
|
return commonOption;
|
||||||
|
}
|
||||||
|
if (historyMetric.value === "cpu") {
|
||||||
|
return {
|
||||||
|
...commonOption,
|
||||||
|
tooltip: {
|
||||||
|
...commonOption.tooltip,
|
||||||
|
formatter: params => {
|
||||||
|
const list = Array.isArray(params) ? params : [params];
|
||||||
|
const title = `时间:${Time.toDateTime(Number(list[0]?.name))}`
|
||||||
|
const lines = list.map(item => `${item.marker}${item.seriesName}: ${Text.unit(Number(item.value) * 100, " %")}`);
|
||||||
|
return [title, ...lines].join("<br />");
|
||||||
|
}
|
||||||
|
},
|
||||||
|
yAxis: {
|
||||||
|
type: "value",
|
||||||
|
min: 0,
|
||||||
|
max: 1,
|
||||||
|
axisLabel: {
|
||||||
|
color: axisColor,
|
||||||
|
formatter: val => `${val * 100} %`
|
||||||
|
},
|
||||||
|
splitLine: {
|
||||||
|
lineStyle: {
|
||||||
|
color: lineColor
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
series: [
|
||||||
|
resolveLineSeries("总使用率", visibleHistoryItems.value.map((item) => item.cpuUsagePercent!)),
|
||||||
|
resolveLineSeries("系统使用率", visibleHistoryItems.value.map((item) => item.cpuSystemPercent!))
|
||||||
|
]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (historyMetric.value === "memory") {
|
||||||
|
return {
|
||||||
|
...commonOption,
|
||||||
|
tooltip: {
|
||||||
|
...commonOption.tooltip,
|
||||||
|
formatter: (params) => {
|
||||||
|
const list = Array.isArray(params) ? params : [params];
|
||||||
|
const title = `时间:${Time.toDateTime(Number(list[0]?.name))}`
|
||||||
|
const lines = list.map(item => `${item.marker}${item.seriesName}: ${IOSize.format(Number(item.value))}`);
|
||||||
|
return [title, ...lines].join("<br />");
|
||||||
|
}
|
||||||
|
},
|
||||||
|
yAxis: {
|
||||||
|
type: "value",
|
||||||
|
max: memoryAxisMax,
|
||||||
|
axisLabel: {
|
||||||
|
color: axisColor,
|
||||||
|
formatter: (value: number) => IOSize.format(value)
|
||||||
|
},
|
||||||
|
splitLine: {
|
||||||
|
lineStyle: {
|
||||||
|
color: lineColor
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
series: [
|
||||||
|
resolveLineSeries("物理", visibleHistoryItems.value.map((item) => item.memoryUsedBytes!)),
|
||||||
|
resolveLineSeries("交换区", visibleHistoryItems.value.map((item) => item.swapUsedBytes!))
|
||||||
|
]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (historyMetric.value === "jvm") {
|
||||||
|
return {
|
||||||
|
...commonOption,
|
||||||
|
tooltip: {
|
||||||
|
...commonOption.tooltip,
|
||||||
|
formatter: (params) => {
|
||||||
|
const list = Array.isArray(params) ? params : [params];
|
||||||
|
const title = `时间:${Time.toDateTime(Number(list[0]?.name))}`
|
||||||
|
const lines = list.map(item => {
|
||||||
|
const seriesName = item?.seriesName || "";
|
||||||
|
if (seriesName === "GC 周期耗时" || seriesName === "GC 暂停耗时") {
|
||||||
|
return `${item.marker}${item.seriesName}: ${Text.unit(Number(item.value), " 毫秒")}`
|
||||||
|
} else {
|
||||||
|
return `${item.marker}${item.seriesName}: ${IOSize.format(Number(item.value))}`
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return [title, ...lines].join("<br />");
|
||||||
|
}
|
||||||
|
},
|
||||||
|
yAxis: [
|
||||||
|
{
|
||||||
|
type: "value",
|
||||||
|
axisLabel: {
|
||||||
|
color: axisColor,
|
||||||
|
formatter: (val: number) => IOSize.format(val)
|
||||||
|
},
|
||||||
|
splitLine: {
|
||||||
|
lineStyle: {
|
||||||
|
color: lineColor
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "value",
|
||||||
|
axisLabel: {
|
||||||
|
color: axisColor,
|
||||||
|
formatter: "{value} ms"
|
||||||
|
},
|
||||||
|
splitLine: {
|
||||||
|
show: false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
series: [
|
||||||
|
resolveLineSeries("已使用", visibleHistoryItems.value.map((item) => item.heapUsedBytes!)),
|
||||||
|
resolveLineSeries("已提交", visibleHistoryItems.value.map((item) => item.heapCommittedBytes!)),
|
||||||
|
resolveLineSeries("GC 周期", visibleHistoryItems.value.map((item) => item.gcCycleTimeMs!), 1),
|
||||||
|
resolveLineSeries("GC 暂停", visibleHistoryItems.value.map((item) => item.gcPauseTimeMs!), 1)
|
||||||
|
]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (historyMetric.value === "network") {
|
||||||
|
return {
|
||||||
|
// 网络
|
||||||
|
...commonOption,
|
||||||
|
tooltip: {
|
||||||
|
...commonOption.tooltip,
|
||||||
|
formatter: (params) => {
|
||||||
|
const list = Array.isArray(params) ? params : [params];
|
||||||
|
const title = `时间:${Time.toDateTime(Number(list[0]?.name))}`
|
||||||
|
const lines = list.map(item => `${item.marker}${item.seriesName}: ${IOSize.speed(Number(item.value))}`);
|
||||||
|
return [title, ...lines].join("<br />");
|
||||||
|
}
|
||||||
|
},
|
||||||
|
yAxis: {
|
||||||
|
type: "value",
|
||||||
|
axisLabel: {
|
||||||
|
color: axisColor,
|
||||||
|
formatter: (val: number) => IOSize.format(val)
|
||||||
|
},
|
||||||
|
splitLine: {
|
||||||
|
lineStyle: {
|
||||||
|
color: lineColor
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
series: [
|
||||||
|
resolveLineSeries("接收", visibleHistoryItems.value.map((item) => item.rxBytesPerSecond!)),
|
||||||
|
resolveLineSeries("发送", visibleHistoryItems.value.map((item) => item.txBytesPerSecond!))
|
||||||
|
]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return commonOption;
|
||||||
|
});
|
||||||
|
|
||||||
|
watch(() => settingStore.dashboard.server, restartAutoRefresh, { deep: true });
|
||||||
|
|
||||||
|
function resolveLineSeries(name: string, data: Array<number | null>, yAxisIndex?: number): SeriesOption {
|
||||||
|
const series: SeriesOption = {
|
||||||
|
type: "line",
|
||||||
|
name,
|
||||||
|
showSymbol: false,
|
||||||
|
smooth: true,
|
||||||
|
lineStyle: {
|
||||||
|
width: 2
|
||||||
|
},
|
||||||
|
areaStyle: {
|
||||||
|
opacity: .18
|
||||||
|
},
|
||||||
|
data
|
||||||
|
};
|
||||||
|
|
||||||
|
if (typeof yAxisIndex === "number") {
|
||||||
|
series.yAxisIndex = yAxisIndex;
|
||||||
|
}
|
||||||
|
|
||||||
|
return series;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 加载数据:当前状态 ----------
|
||||||
|
|
||||||
|
let snapshotTimer: number | undefined;
|
||||||
|
onUnmounted(() => {
|
||||||
|
window.clearInterval(snapshotTimer);
|
||||||
|
snapshotTimer = undefined;
|
||||||
|
});
|
||||||
|
|
||||||
|
async function refreshSnapshot(): Promise<void> {
|
||||||
|
if (isSnapshotLoading.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
isSnapshotLoading.value = true;
|
||||||
|
try {
|
||||||
|
const metrics = SERVER_SNAPSHOT_METRICS.join(",");
|
||||||
|
snapshotView.value = await SystemAPI.getStatus(metrics);
|
||||||
|
} catch (error) {
|
||||||
|
Toast({
|
||||||
|
theme: "error",
|
||||||
|
message: error instanceof Error ? error.message : "请求失败,请稍后重试"
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
isSnapshotLoading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 加载数据:历史采集 ----------
|
||||||
|
|
||||||
|
let historyTimer: number | undefined;
|
||||||
|
|
||||||
|
async function refreshHistory(): Promise<void> {
|
||||||
|
if (isHistoryLoading.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
isHistoryLoading.value = true;
|
||||||
|
try {
|
||||||
|
const metrics = SERVER_HISTORY_METRICS.join(",");
|
||||||
|
const historyView = await SystemAPI.getStatusHistory({
|
||||||
|
window: "1h",
|
||||||
|
metrics
|
||||||
|
});
|
||||||
|
historyPoints.value = historyView.points || [];
|
||||||
|
} catch (error) {
|
||||||
|
Toast({
|
||||||
|
theme: "error",
|
||||||
|
message: error instanceof Error ? error.message : "请求失败,请稍后重试"
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
isHistoryLoading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
window.clearInterval(historyTimer);
|
||||||
|
historyTimer = undefined;
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------- 自动刷新 ----------
|
||||||
|
|
||||||
|
watch(() => settingStore.dashboard.server, restartAutoRefresh, { deep: true });
|
||||||
|
|
||||||
|
async function refreshDashboard(): Promise<void> {
|
||||||
|
await Promise.all([
|
||||||
|
refreshSnapshot(),
|
||||||
|
refreshHistory()
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
function restartAutoRefresh(): void {
|
||||||
|
void refreshDashboard();
|
||||||
|
clearAutoRefreshTimer();
|
||||||
|
|
||||||
|
const snapshotIntervalMs = Math.max(settingStore.dashboard.server.snapshotRefreshSeconds, 1) * 1000;
|
||||||
|
const historyIntervalMs = Math.max(settingStore.dashboard.server.historyRefreshSeconds, 1) * 1000;
|
||||||
|
|
||||||
|
snapshotTimer = window.setInterval(() => {
|
||||||
|
void refreshSnapshot();
|
||||||
|
}, snapshotIntervalMs);
|
||||||
|
|
||||||
|
historyTimer = window.setInterval(() => {
|
||||||
|
void refreshHistory();
|
||||||
|
}, historyIntervalMs);
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearAutoRefreshTimer(): void {
|
||||||
|
if (snapshotTimer !== null) {
|
||||||
|
window.clearInterval(snapshotTimer);
|
||||||
|
snapshotTimer = undefined;
|
||||||
|
}
|
||||||
|
if (historyTimer !== null) {
|
||||||
|
window.clearInterval(historyTimer);
|
||||||
|
historyTimer = undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
onMounted(restartAutoRefresh);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="less">
|
||||||
|
.server-dashboard {
|
||||||
|
gap: 1rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
|
||||||
|
.section {
|
||||||
|
|
||||||
|
&.disk {
|
||||||
|
|
||||||
|
.collapses {
|
||||||
|
|
||||||
|
:deep(.t-cell__title) {
|
||||||
|
margin-right: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content {
|
||||||
|
width: 100%;
|
||||||
|
gap: .4rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
|
||||||
|
.header {
|
||||||
|
gap: .5rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
|
||||||
|
.title {
|
||||||
|
color: var(--app-text);
|
||||||
|
font-size: .875rem;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.health {
|
||||||
|
font-size: .75rem;
|
||||||
|
color: var(--app-sub);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&.history {
|
||||||
|
.metric-tabs {
|
||||||
|
width: 100%;
|
||||||
|
--td-tab-track-width: 4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart {
|
||||||
|
width: 100%;
|
||||||
|
height: 16rem;
|
||||||
|
background: #FFF;
|
||||||
|
touch-action: none;
|
||||||
|
|
||||||
|
:deep(svg),
|
||||||
|
:deep(canvas) {
|
||||||
|
touch-action: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.slider {
|
||||||
|
gap: .5rem;
|
||||||
|
display: flex;
|
||||||
|
padding: .5rem 2rem;
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
|
.label {
|
||||||
|
flex: none;
|
||||||
|
font-size: .75rem;
|
||||||
|
color: var(--app-sub);
|
||||||
|
}
|
||||||
|
|
||||||
|
.control {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-wrap {
|
||||||
|
display: flex;
|
||||||
|
min-height: 4rem;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
91
src/pages/dashboard/ServerDashboard/ServerDetail.vue
Normal file
91
src/pages/dashboard/ServerDashboard/ServerDetail.vue
Normal 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-info label="固件描述" :value="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 SystemAPI 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 SystemAPI.getStatus("os,hardware");
|
||||||
|
} catch (error) {
|
||||||
|
Toast({
|
||||||
|
theme: "error",
|
||||||
|
message: error instanceof Error ? error.message : "请求失败,请稍后重试"
|
||||||
|
});
|
||||||
|
} 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>
|
||||||
113
src/pages/dashboard/ServerDashboard/ServerPartitionsDetail.vue
Normal file
113
src/pages/dashboard/ServerDashboard/ServerPartitionsDetail.vue
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
<template>
|
||||||
|
<div class="server-partitions-detail">
|
||||||
|
<div v-if="isLoading && partitions.length < 1" class="loading-wrap">
|
||||||
|
<t-loading text="加载磁盘详情..." />
|
||||||
|
</div>
|
||||||
|
<template v-else-if="partitions.length">
|
||||||
|
<t-cell-group
|
||||||
|
v-for="item in partitions"
|
||||||
|
:key="item.uuid"
|
||||||
|
:title="resolvePartitionTitle(item)"
|
||||||
|
theme="card"
|
||||||
|
>
|
||||||
|
<t-cell title="磁盘名称" :note="Text.display(item.diskName)" />
|
||||||
|
<t-cell-info label="磁盘型号" :value="Text.display(item.diskModel)" />
|
||||||
|
<t-cell-info label="磁盘序列号" :value="Text.display(item.diskSerial)" />
|
||||||
|
<t-cell title="分区 ID" :note="Text.display(item.partitionId)" />
|
||||||
|
<t-cell-info label="分区名称" :value="Text.display(item.partitionName)" />
|
||||||
|
<t-cell title="分区类型" :note="Text.display(item.partitionType)" />
|
||||||
|
<t-cell-info label="UUID" :value="Text.display(item.uuid)" />
|
||||||
|
<t-cell title="总容量" :note="IOSize.format(item.total)" />
|
||||||
|
<t-cell title="已使用" :note="IOSize.format(item.used)" />
|
||||||
|
<t-cell title="可用容量" :note="IOSize.format(item.total - item.used)" />
|
||||||
|
<t-cell title="使用率" :note="Text.unit(item.used / item.total * 100, '%')" />
|
||||||
|
<t-cell
|
||||||
|
v-if="typeof item.transferTimeMs === 'number'"
|
||||||
|
title="传输耗时"
|
||||||
|
:note="Text.unit(item.transferTimeMs, '毫秒')"
|
||||||
|
/>
|
||||||
|
<t-cell v-if="item.healthStatus" title="健康状态" :note="item.healthStatus" />
|
||||||
|
</t-cell-group>
|
||||||
|
</template>
|
||||||
|
<t-empty v-else description="暂无磁盘详情数据" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { Toast } from "tdesign-mobile-vue";
|
||||||
|
import SystemAPI from "@/api/SystemAPI";
|
||||||
|
import type { SystemStatusSnapshot, SystemStatusSnapshotView } from "@/types/System";
|
||||||
|
import { IOSize, Text } from "timi-web";
|
||||||
|
|
||||||
|
type Partition = NonNullable<SystemStatusSnapshot["storagePartitions"]>[number];
|
||||||
|
|
||||||
|
defineOptions({
|
||||||
|
name: "ServerPartitionsDetail"
|
||||||
|
});
|
||||||
|
|
||||||
|
const isLoading = ref(false);
|
||||||
|
const snapshotView = ref<SystemStatusSnapshotView | null>(null);
|
||||||
|
const refreshIntervalMs = 3000;
|
||||||
|
let refreshTimer: ReturnType<typeof setInterval> | null = null;
|
||||||
|
|
||||||
|
const partitions = computed<Partition[]>(() => {
|
||||||
|
const list = snapshotView.value?.snapshot?.storagePartitions || [];
|
||||||
|
return list.slice().sort((left, right) => {
|
||||||
|
return (right.used / Math.max(right.total, 1)) - (left.used / Math.max(left.total, 1));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
void refreshDetail();
|
||||||
|
refreshTimer = setInterval(() => {
|
||||||
|
void refreshDetail();
|
||||||
|
}, refreshIntervalMs);
|
||||||
|
});
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
if (!refreshTimer) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
clearInterval(refreshTimer);
|
||||||
|
refreshTimer = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
function resolvePartitionTitle(item: Partition): string {
|
||||||
|
const mountPoint = Text.display(item.mountPoint);
|
||||||
|
const partitionId = Text.display(item.partitionId);
|
||||||
|
if (partitionId !== "-") {
|
||||||
|
return `${mountPoint} (${partitionId})`;
|
||||||
|
}
|
||||||
|
return mountPoint;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshDetail(): Promise<void> {
|
||||||
|
if (isLoading.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
isLoading.value = true;
|
||||||
|
try {
|
||||||
|
snapshotView.value = await SystemAPI.getStatus("storage");
|
||||||
|
} catch (error) {
|
||||||
|
Toast({
|
||||||
|
theme: "error",
|
||||||
|
message: error instanceof Error ? error.message : "请求失败,请稍后重试"
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
isLoading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="less">
|
||||||
|
.server-partitions-detail {
|
||||||
|
padding: var(--app-nav-offset) 0 1rem 0;
|
||||||
|
|
||||||
|
.loading-wrap {
|
||||||
|
display: flex;
|
||||||
|
min-height: 4rem;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
122
src/pages/dashboard/ServerDashboard/ServerPerformanceDetail.vue
Normal file
122
src/pages/dashboard/ServerDashboard/ServerPerformanceDetail.vue
Normal 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 SystemAPI 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 SystemAPI.getStatus("cpu,memory,jvm,network");
|
||||||
|
} catch (error) {
|
||||||
|
Toast({
|
||||||
|
theme: "error",
|
||||||
|
message: error instanceof Error ? error.message : "请求失败,请稍后重试"
|
||||||
|
});
|
||||||
|
} 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>
|
||||||
652
src/pages/dashboard/UPSDashboard/UPSDashboard.vue
Normal file
652
src/pages/dashboard/UPSDashboard/UPSDashboard.vue
Normal file
@@ -0,0 +1,652 @@
|
|||||||
|
<template>
|
||||||
|
<div class="ups-dashboard">
|
||||||
|
<div v-if="isStatusLoading && !statusView" class="loading-wrap">
|
||||||
|
<t-loading text="加载 UPS 状态..." />
|
||||||
|
</div>
|
||||||
|
<template v-else-if="statusView">
|
||||||
|
<t-cell-group title="UPS 概览" theme="card">
|
||||||
|
<t-cell title="主机" :note="Text.display(statusView?.hostName)" />
|
||||||
|
<t-cell title="厂商" :note="Text.display(statusView?.customer)" />
|
||||||
|
<t-cell title="设备 ID" :note="Text.display(statusView?.deviceId)" />
|
||||||
|
<t-cell title="UPS 类型" :note="Text.display(statusView?.upsType)" />
|
||||||
|
<t-cell title="工作模式" :note="Text.display(statusView?.workMode)" />
|
||||||
|
<t-cell title="数据时间" :note="Time.toDateTime(statusView?.upsTime)" />
|
||||||
|
<t-cell title="输出状态" :note="Text.displayBool(statusView?.outputOn, '开启', '关闭')" />
|
||||||
|
<t-cell title="充电状态" :note="Text.displayBool(statusView?.charging, '充电中', '未充电')" />
|
||||||
|
<t-cell title="旁路状态" :note="Text.displayBool(statusView?.bypassActive, '旁路运行', '正常供电')" />
|
||||||
|
<t-cell title="关机状态" :note="Text.displayBool(statusView?.shutdownActive, '执行中', '未执行')" />
|
||||||
|
</t-cell-group>
|
||||||
|
<t-cell-group class="section energy" title="电池与负载" theme="card">
|
||||||
|
<t-cell-info label="电池容量" :value="Text.unit(statusView?.batteryCapacity, '%')">
|
||||||
|
<progress-group mode="heap" :progress="batteryProgressList" />
|
||||||
|
</t-cell-info>
|
||||||
|
<t-cell-info label="输出负载" :value="Text.unit(statusView?.outputLoadPercent, '%')">
|
||||||
|
<progress-group mode="heap" :progress="loadProgressList" />
|
||||||
|
</t-cell-info>
|
||||||
|
<t-cell title="剩余时间" :note="Text.unit(statusView?.batteryRemainTime, '分钟')" />
|
||||||
|
</t-cell-group>
|
||||||
|
<t-cell-group class="section electric" title="电气参数" theme="card">
|
||||||
|
<t-cell title="输入电压" :note="Text.unit(statusView?.inputVoltage, 'V')" />
|
||||||
|
<t-cell title="输入频率" :note="Text.unit(statusView?.inputFrequency, 'Hz')" />
|
||||||
|
<t-cell title="输出电压" :note="Text.unit(statusView?.outputVoltage, 'V')" />
|
||||||
|
<t-cell title="输出频率" :note="Text.unit(statusView?.outputFrequency, 'Hz')" />
|
||||||
|
<t-cell title="电池电压" :note="Text.unit(statusView?.batteryVoltage, 'V')" />
|
||||||
|
<t-cell title="温度" :note="Text.unit(statusView?.temperature, '℃')" />
|
||||||
|
</t-cell-group>
|
||||||
|
<t-cell-group v-if="faultList.length" class="section warning" title="告警与故障" theme="card">
|
||||||
|
<t-cell v-for="(item, index) in faultList" :key="`${item}-${index}`" :title="item" />
|
||||||
|
</t-cell-group>
|
||||||
|
</template>
|
||||||
|
<t-empty v-else description="暂无 UPS 状态数据" />
|
||||||
|
<t-cell-group class="section history" title="历史采样" theme="card">
|
||||||
|
<t-tabs class="metric-tabs" :value="historyMetric" @change="handleHistoryMetricChange">
|
||||||
|
<t-tab-panel v-for="item in historyMetricTabs" :key="item.value" :value="item.value" :label="item.label" />
|
||||||
|
</t-tabs>
|
||||||
|
<div v-if="isHistoryLoading && !historyItems.length" class="loading-wrap">
|
||||||
|
<t-loading text="加载 UPS 历史..." />
|
||||||
|
</div>
|
||||||
|
<template v-else-if="historyItems.length">
|
||||||
|
<v-chart
|
||||||
|
ref="chartRef"
|
||||||
|
class="chart"
|
||||||
|
:option="historyChartOption"
|
||||||
|
:update-options="historyChartUpdateOptions"
|
||||||
|
:autoresize="true"
|
||||||
|
/>
|
||||||
|
<div class="slider bg-white">
|
||||||
|
<span class="label">较早</span>
|
||||||
|
<t-slider
|
||||||
|
class="control"
|
||||||
|
:min="0"
|
||||||
|
:max="Math.max(historyItems.length - historyVisibleCount, 0)"
|
||||||
|
:step="1"
|
||||||
|
:value="historySliderValue"
|
||||||
|
@change="handleHistorySliderChange"
|
||||||
|
/>
|
||||||
|
<span class="label">最新</span>
|
||||||
|
</div>
|
||||||
|
<t-cell-group>
|
||||||
|
<t-cell v-for="item in historySummary" :key="item.label" :title="item.label" :note="item.value" />
|
||||||
|
</t-cell-group>
|
||||||
|
</template>
|
||||||
|
<t-empty v-else description="暂无 UPS 历史数据" />
|
||||||
|
</t-cell-group>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { Toast } from "tdesign-mobile-vue";
|
||||||
|
import { use } from "echarts/core";
|
||||||
|
import { SVGRenderer } from "echarts/renderers";
|
||||||
|
import { LineChart } from "echarts/charts";
|
||||||
|
import { GridComponent, LegendComponent, TooltipComponent } from "echarts/components";
|
||||||
|
import VChart from "vue-echarts";
|
||||||
|
import type { EChartsOption, SeriesOption } from "echarts";
|
||||||
|
import UpsAPI from "@/api/UpsAPI";
|
||||||
|
import type {
|
||||||
|
UpsHistoryPointView,
|
||||||
|
UpsStatusView
|
||||||
|
} from "@/types/Ups";
|
||||||
|
import { useSettingStore } from "@/store/settingStore";
|
||||||
|
import { Text, Time } from "timi-web";
|
||||||
|
import type { LabelValue } from "timi-web";
|
||||||
|
import TCellInfo from "@/components/TCellInfo.vue";
|
||||||
|
import type { ProgressItem } from "@/components/ProgressGroup.vue";
|
||||||
|
import ProgressGroup from "@/components/ProgressGroup.vue";
|
||||||
|
|
||||||
|
type UpsHistoryMetric = "battery" | "load" | "voltage" | "temperature";
|
||||||
|
|
||||||
|
use([
|
||||||
|
SVGRenderer,
|
||||||
|
LineChart,
|
||||||
|
GridComponent,
|
||||||
|
TooltipComponent,
|
||||||
|
LegendComponent
|
||||||
|
]);
|
||||||
|
|
||||||
|
defineOptions({
|
||||||
|
name: "UPSDashboard"
|
||||||
|
});
|
||||||
|
|
||||||
|
const settingStore = useSettingStore();
|
||||||
|
|
||||||
|
const isStatusLoading = ref(false);
|
||||||
|
const isHistoryLoading = ref(false);
|
||||||
|
const statusView = ref<UpsStatusView | null>(null);
|
||||||
|
const historyPoints = ref<UpsHistoryPointView[]>([]);
|
||||||
|
const chartRef = ref<InstanceType<typeof VChart> | null>(null);
|
||||||
|
const historyChartUpdateOptions = Object.freeze({
|
||||||
|
notMerge: true
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------- 当前状态 ----------
|
||||||
|
|
||||||
|
const batteryProgressList = computed<ProgressItem[]>(() => {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
color: "var(--td-brand-color)",
|
||||||
|
value: statusView.value?.batteryCapacity! / 100,
|
||||||
|
}
|
||||||
|
];
|
||||||
|
});
|
||||||
|
const loadProgressList = computed<ProgressItem[]>(() => {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
color: "var(--td-warning-color)",
|
||||||
|
value: statusView.value?.outputLoadPercent! / 100,
|
||||||
|
}
|
||||||
|
];
|
||||||
|
});
|
||||||
|
const faultList = computed<string[]>(() => {
|
||||||
|
const list: string[] = [];
|
||||||
|
const warningList = statusView.value?.warnings || [];
|
||||||
|
if (statusView.value?.faultType) {
|
||||||
|
list.push(`故障类型:${statusView.value.faultType}`);
|
||||||
|
}
|
||||||
|
if (statusView.value?.faultKind) {
|
||||||
|
list.push(`故障明细:${statusView.value.faultKind}`);
|
||||||
|
}
|
||||||
|
for (const item of warningList) {
|
||||||
|
list.push(`告警:${item}`);
|
||||||
|
}
|
||||||
|
return list;
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------- 历史采样 ----------
|
||||||
|
|
||||||
|
const historyMetric = ref<UpsHistoryMetric>("battery");
|
||||||
|
|
||||||
|
const historyMetricTabs = computed(() => {
|
||||||
|
const labelMap: Record<UpsHistoryMetric, string> = {
|
||||||
|
battery: "电池",
|
||||||
|
load: "负载",
|
||||||
|
voltage: "电压",
|
||||||
|
temperature: "温度"
|
||||||
|
};
|
||||||
|
return (Object.keys(labelMap) as UpsHistoryMetric[]).map((item) => ({
|
||||||
|
value: item,
|
||||||
|
label: labelMap[item]
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
function handleHistoryMetricChange(value: string): void {
|
||||||
|
const nextValue = value as UpsHistoryMetric;
|
||||||
|
if (nextValue === "battery" || nextValue === "load" || nextValue === "voltage" || nextValue === "temperature") {
|
||||||
|
historyMetric.value = nextValue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const historyItems = computed<UpsHistoryPointView[]>(() => {
|
||||||
|
return historyPoints.value.slice().sort((left, right) => left.at - right.at);
|
||||||
|
});
|
||||||
|
const historySummary = computed<LabelValue<string, any>[]>(() => {
|
||||||
|
const lastPoint = historyItems.value[historyItems.value.length - 1];
|
||||||
|
if (!lastPoint) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (historyMetric.value === "battery") {
|
||||||
|
return [
|
||||||
|
{ label: "容量", value: Text.unit(lastPoint.batteryCapacity, '%') },
|
||||||
|
{ label: "剩余时间", value: Text.unit(lastPoint.batteryRemainTime, '分钟') }
|
||||||
|
];
|
||||||
|
}
|
||||||
|
if (historyMetric.value === "load") {
|
||||||
|
return [
|
||||||
|
{ label: "负载", value: Text.unit(lastPoint.outputLoadPercent, '%') },
|
||||||
|
{ label: "输出状态", value: Text.displayBool(lastPoint.outputOn, '开启', '关闭') }
|
||||||
|
];
|
||||||
|
}
|
||||||
|
if (historyMetric.value === "voltage") {
|
||||||
|
return [
|
||||||
|
{ label: "输入电压", value: Text.unit(lastPoint.inputVoltage, 'V') },
|
||||||
|
{ label: "输出电压", value: Text.unit(lastPoint.outputVoltage, 'V') }
|
||||||
|
];
|
||||||
|
}
|
||||||
|
return [
|
||||||
|
{ label: "温度", value: Text.unit(lastPoint.temperature, '℃') },
|
||||||
|
{ label: "工作模式", value: Text.display(lastPoint.workMode) }
|
||||||
|
];
|
||||||
|
});
|
||||||
|
|
||||||
|
const isHistorySliderTouched = ref(false);
|
||||||
|
const historySliderValue = ref(0);
|
||||||
|
const HISTORY_DEFAULT_VISIBLE_COUNT = 24;
|
||||||
|
|
||||||
|
const historyVisibleCount = computed(() => {
|
||||||
|
if (historyItems.value.length < 1) {
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
return Math.min(HISTORY_DEFAULT_VISIBLE_COUNT, historyItems.value.length);
|
||||||
|
});
|
||||||
|
const historySliderMaxStart = computed(() => {
|
||||||
|
return Math.max(historyItems.value.length - historyVisibleCount.value, 0);
|
||||||
|
});
|
||||||
|
const currentHistoryRange = computed(() => {
|
||||||
|
const length = historyItems.value.length;
|
||||||
|
if (length < 1) {
|
||||||
|
return { startValue: 0, endValue: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
const startValue = Math.min(Math.max(Math.floor(historySliderValue.value), 0), historySliderMaxStart.value);
|
||||||
|
const endValue = Math.min(startValue + historyVisibleCount.value - 1, length - 1);
|
||||||
|
return { startValue, endValue };
|
||||||
|
});
|
||||||
|
const visibleHistoryItems = computed<UpsHistoryPointView[]>(() => {
|
||||||
|
if (!historyItems.value.length) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
return historyItems.value.slice(currentHistoryRange.value.startValue, currentHistoryRange.value.endValue + 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
watch(() => historyItems.value.length, (length) => {
|
||||||
|
if (length < 1) {
|
||||||
|
historySliderValue.value = 0;
|
||||||
|
isHistorySliderTouched.value = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isHistorySliderTouched.value) {
|
||||||
|
historySliderValue.value = historySliderMaxStart.value;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
historySliderValue.value = Math.min(historySliderValue.value, historySliderMaxStart.value);
|
||||||
|
});
|
||||||
|
|
||||||
|
function handleHistorySliderChange(value: number): void {
|
||||||
|
historySliderValue.value = Math.min(Math.max(Math.floor(value), 0), historySliderMaxStart.value);
|
||||||
|
isHistorySliderTouched.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 图表 ----------
|
||||||
|
|
||||||
|
const isDarkTheme = ref(false);
|
||||||
|
let themeObserver: MutationObserver | null = null;
|
||||||
|
|
||||||
|
const historyChartOption = computed((): EChartsOption => {
|
||||||
|
const axisColor = isDarkTheme.value ? "#9aa4b2" : "#7e8a9a";
|
||||||
|
const lineColor = isDarkTheme.value ? "rgba(255, 255, 255, .12)" : "rgba(18, 42, 66, .08)";
|
||||||
|
const legendColor = isDarkTheme.value ? "#d7dee8" : "#334155";
|
||||||
|
|
||||||
|
const commonOption: EChartsOption = {
|
||||||
|
tooltip: {
|
||||||
|
trigger: "axis",
|
||||||
|
confine: true,
|
||||||
|
position: (point, _params, _dom, _rect, size) => {
|
||||||
|
const gap = 6;
|
||||||
|
const fingerGap = 24;
|
||||||
|
const contentSize = size?.contentSize || [0, 0];
|
||||||
|
const viewSize = size?.viewSize || [0, 0];
|
||||||
|
const maxLeft = Math.max(viewSize[0] - contentSize[0] - gap, gap);
|
||||||
|
const maxTop = Math.max(viewSize[1] - contentSize[1] - gap, gap);
|
||||||
|
const clamp = (currentValue: number, min: number, max: number): number => {
|
||||||
|
return Math.min(Math.max(currentValue, min), max);
|
||||||
|
};
|
||||||
|
const preferLeft = point[0] - contentSize[0] - gap;
|
||||||
|
const preferRight = point[0] + gap;
|
||||||
|
const left = point[0] < viewSize[0] / 2
|
||||||
|
? clamp(preferRight, gap, maxLeft)
|
||||||
|
: clamp(preferLeft, gap, maxLeft);
|
||||||
|
const preferTop = point[1] - contentSize[1] - fingerGap;
|
||||||
|
const preferBottom = point[1] + fingerGap;
|
||||||
|
const top = preferTop >= gap
|
||||||
|
? clamp(preferTop, gap, maxTop)
|
||||||
|
: clamp(preferBottom, gap, maxTop);
|
||||||
|
return [left, top];
|
||||||
|
}
|
||||||
|
},
|
||||||
|
legend: {
|
||||||
|
top: 10,
|
||||||
|
textStyle: {
|
||||||
|
color: legendColor
|
||||||
|
}
|
||||||
|
},
|
||||||
|
grid: {
|
||||||
|
top: 40,
|
||||||
|
left: 8,
|
||||||
|
right: 8,
|
||||||
|
bottom: 8,
|
||||||
|
containLabel: true
|
||||||
|
},
|
||||||
|
xAxis: {
|
||||||
|
type: "category",
|
||||||
|
boundaryGap: false,
|
||||||
|
data: visibleHistoryItems.value.map((item) => item.at),
|
||||||
|
axisLabel: {
|
||||||
|
color: axisColor,
|
||||||
|
formatter: val => Time.toShortTime(Number(val))
|
||||||
|
},
|
||||||
|
axisLine: {
|
||||||
|
lineStyle: {
|
||||||
|
color: lineColor
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!historyItems.value.length) {
|
||||||
|
return commonOption;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (historyMetric.value === "battery") {
|
||||||
|
return {
|
||||||
|
...commonOption,
|
||||||
|
tooltip: {
|
||||||
|
...commonOption.tooltip,
|
||||||
|
formatter: (params) => {
|
||||||
|
const list = Array.isArray(params) ? params : [params];
|
||||||
|
const title = `时间:${Time.toDateTime(Number(list[0]?.name))}`;
|
||||||
|
const lines = list.map((item) => {
|
||||||
|
const seriesName = item.seriesName || "";
|
||||||
|
if (seriesName === "容量") {
|
||||||
|
return `${item.marker}${seriesName}: ${Text.unit(Number(item.value), " %")}`;
|
||||||
|
}
|
||||||
|
return `${item.marker}${seriesName}: ${Text.unit(Number(item.value), '分钟')}`;
|
||||||
|
});
|
||||||
|
return [title, ...lines].join("<br />");
|
||||||
|
}
|
||||||
|
},
|
||||||
|
yAxis: [
|
||||||
|
{
|
||||||
|
type: "value",
|
||||||
|
min: 0,
|
||||||
|
max: 100,
|
||||||
|
axisLabel: {
|
||||||
|
color: axisColor,
|
||||||
|
formatter: "{value} %"
|
||||||
|
},
|
||||||
|
splitLine: {
|
||||||
|
lineStyle: {
|
||||||
|
color: lineColor
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "value",
|
||||||
|
axisLabel: {
|
||||||
|
color: axisColor,
|
||||||
|
formatter: "{value} min"
|
||||||
|
},
|
||||||
|
splitLine: {
|
||||||
|
show: false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
series: [
|
||||||
|
resolveLineSeries("容量", visibleHistoryItems.value.map((item) => item.batteryCapacity!)),
|
||||||
|
resolveLineSeries("剩余时间", visibleHistoryItems.value.map((item) => item.batteryRemainTime || 0), 1)
|
||||||
|
]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (historyMetric.value === "load") {
|
||||||
|
return {
|
||||||
|
...commonOption,
|
||||||
|
tooltip: {
|
||||||
|
...commonOption.tooltip,
|
||||||
|
formatter: (params) => {
|
||||||
|
const list = Array.isArray(params) ? params : [params];
|
||||||
|
const title = `时间:${Time.toDateTime(Number(list[0]?.name))}`;
|
||||||
|
const lines = list.map((item) => `${item.marker}${item.seriesName}: ${Text.unit(Number(item.value), " %")}`);
|
||||||
|
return [title, ...lines].join("<br />");
|
||||||
|
}
|
||||||
|
},
|
||||||
|
yAxis: {
|
||||||
|
type: "value",
|
||||||
|
min: 0,
|
||||||
|
max: 100,
|
||||||
|
axisLabel: {
|
||||||
|
color: axisColor,
|
||||||
|
formatter: "{value} %"
|
||||||
|
},
|
||||||
|
splitLine: {
|
||||||
|
lineStyle: {
|
||||||
|
color: lineColor
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
series: [
|
||||||
|
resolveLineSeries("负载", visibleHistoryItems.value.map((item) => item.outputLoadPercent))
|
||||||
|
]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (historyMetric.value === "voltage") {
|
||||||
|
return {
|
||||||
|
...commonOption,
|
||||||
|
tooltip: {
|
||||||
|
...commonOption.tooltip,
|
||||||
|
formatter: (params) => {
|
||||||
|
const list = Array.isArray(params) ? params : [params];
|
||||||
|
const title = `时间:${Time.toDateTime(Number(list[0]?.name))}`;
|
||||||
|
const lines = list.map((item) => `${item.marker}${item.seriesName}: ${Text.unit(Number(item.value), " V")}`);
|
||||||
|
return [title, ...lines].join("<br />");
|
||||||
|
}
|
||||||
|
},
|
||||||
|
yAxis: {
|
||||||
|
type: "value",
|
||||||
|
axisLabel: {
|
||||||
|
color: axisColor,
|
||||||
|
formatter: "{value} V"
|
||||||
|
},
|
||||||
|
splitLine: {
|
||||||
|
lineStyle: {
|
||||||
|
color: lineColor
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
series: [
|
||||||
|
resolveLineSeries("输入电压", visibleHistoryItems.value.map((item) => item.inputVoltage || 0)),
|
||||||
|
resolveLineSeries("输出电压", visibleHistoryItems.value.map((item) => item.outputVoltage || 0))
|
||||||
|
]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...commonOption,
|
||||||
|
tooltip: {
|
||||||
|
...commonOption.tooltip,
|
||||||
|
formatter: (params) => {
|
||||||
|
const list = Array.isArray(params) ? params : [params];
|
||||||
|
const title = `时间:${Time.toDateTime(Number(list[0]?.name))}`;
|
||||||
|
const lines = list.map((item) => `${item.marker}${item.seriesName}: ${Text.unit(Number(item.value), " ℃")}`);
|
||||||
|
return [title, ...lines].join("<br />");
|
||||||
|
}
|
||||||
|
},
|
||||||
|
yAxis: {
|
||||||
|
type: "value",
|
||||||
|
axisLabel: {
|
||||||
|
color: axisColor,
|
||||||
|
formatter: "{value} ℃"
|
||||||
|
},
|
||||||
|
splitLine: {
|
||||||
|
lineStyle: {
|
||||||
|
color: lineColor
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
series: [
|
||||||
|
resolveLineSeries("温度", visibleHistoryItems.value.map((item) => item.temperature || 0))
|
||||||
|
]
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
function resolveLineSeries(name: string, data: Array<number | null>, yAxisIndex?: number): SeriesOption {
|
||||||
|
const series: SeriesOption = {
|
||||||
|
type: "line",
|
||||||
|
name,
|
||||||
|
showSymbol: false,
|
||||||
|
smooth: true,
|
||||||
|
lineStyle: {
|
||||||
|
width: 2
|
||||||
|
},
|
||||||
|
areaStyle: {
|
||||||
|
opacity: .18
|
||||||
|
},
|
||||||
|
data
|
||||||
|
};
|
||||||
|
|
||||||
|
if (typeof yAxisIndex === "number") {
|
||||||
|
series.yAxisIndex = yAxisIndex;
|
||||||
|
}
|
||||||
|
|
||||||
|
return series;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 加载与自动刷新 ----------
|
||||||
|
|
||||||
|
let statusTimer: number | null = null;
|
||||||
|
let historyTimer: number | null = null;
|
||||||
|
|
||||||
|
watch(() => settingStore.dashboard.server, restartAutoRefresh, { deep: true });
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
updateThemeState();
|
||||||
|
restartAutoRefresh();
|
||||||
|
themeObserver = new MutationObserver(() => {
|
||||||
|
updateThemeState();
|
||||||
|
});
|
||||||
|
themeObserver.observe(document.documentElement, {
|
||||||
|
attributes: true,
|
||||||
|
attributeFilter: ["class"]
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
clearAutoRefreshTimer();
|
||||||
|
if (themeObserver) {
|
||||||
|
themeObserver.disconnect();
|
||||||
|
themeObserver = null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function updateThemeState(): void {
|
||||||
|
isDarkTheme.value = document.documentElement.classList.contains("theme-dark");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshStatus(): Promise<void> {
|
||||||
|
if (isStatusLoading.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
isStatusLoading.value = true;
|
||||||
|
try {
|
||||||
|
statusView.value = await UpsAPI.getStatus();
|
||||||
|
} catch (error) {
|
||||||
|
Toast({
|
||||||
|
theme: "error",
|
||||||
|
message: error instanceof Error ? error.message : "请求失败,请稍后重试"
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
isStatusLoading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshHistory(): Promise<void> {
|
||||||
|
if (isHistoryLoading.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
isHistoryLoading.value = true;
|
||||||
|
try {
|
||||||
|
const historyView = await UpsAPI.getStatusHistory({
|
||||||
|
window: "24h"
|
||||||
|
});
|
||||||
|
historyPoints.value = historyView.points || [];
|
||||||
|
} catch (error) {
|
||||||
|
Toast({
|
||||||
|
theme: "error",
|
||||||
|
message: error instanceof Error ? error.message : "请求失败,请稍后重试"
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
isHistoryLoading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshDashboard(): Promise<void> {
|
||||||
|
await Promise.all([
|
||||||
|
refreshStatus(),
|
||||||
|
refreshHistory()
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function restartAutoRefresh(): void {
|
||||||
|
void refreshDashboard();
|
||||||
|
clearAutoRefreshTimer();
|
||||||
|
|
||||||
|
const snapshotIntervalMs = Math.max(settingStore.dashboard.server.snapshotRefreshSeconds, 1) * 1000;
|
||||||
|
const historyIntervalMs = Math.max(settingStore.dashboard.server.historyRefreshSeconds, 1) * 1000;
|
||||||
|
|
||||||
|
statusTimer = window.setInterval(() => {
|
||||||
|
void refreshStatus();
|
||||||
|
}, snapshotIntervalMs);
|
||||||
|
|
||||||
|
historyTimer = window.setInterval(() => {
|
||||||
|
void refreshHistory();
|
||||||
|
}, historyIntervalMs);
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearAutoRefreshTimer(): void {
|
||||||
|
if (statusTimer !== null) {
|
||||||
|
window.clearInterval(statusTimer);
|
||||||
|
statusTimer = null;
|
||||||
|
}
|
||||||
|
if (historyTimer !== null) {
|
||||||
|
window.clearInterval(historyTimer);
|
||||||
|
historyTimer = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="less">
|
||||||
|
.ups-dashboard {
|
||||||
|
gap: 1rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
|
||||||
|
.section {
|
||||||
|
|
||||||
|
&.history {
|
||||||
|
.metric-tabs {
|
||||||
|
width: 100%;
|
||||||
|
--td-tab-track-width: 4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart {
|
||||||
|
width: 100%;
|
||||||
|
height: 16rem;
|
||||||
|
background: #FFF;
|
||||||
|
touch-action: none;
|
||||||
|
|
||||||
|
:deep(svg),
|
||||||
|
:deep(canvas) {
|
||||||
|
touch-action: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.slider {
|
||||||
|
gap: .5rem;
|
||||||
|
display: flex;
|
||||||
|
padding: .5rem 2rem;
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
|
.label {
|
||||||
|
flex: none;
|
||||||
|
font-size: .75rem;
|
||||||
|
color: var(--app-sub);
|
||||||
|
}
|
||||||
|
|
||||||
|
.control {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-wrap {
|
||||||
|
display: flex;
|
||||||
|
min-height: 4rem;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
183
src/pages/file/FileExplorerGrid.vue
Normal file
183
src/pages/file/FileExplorerGrid.vue
Normal file
@@ -0,0 +1,183 @@
|
|||||||
|
<template>
|
||||||
|
<recycle-scroller
|
||||||
|
class="scroller"
|
||||||
|
:items="rows"
|
||||||
|
:item-size="158"
|
||||||
|
key-field="key"
|
||||||
|
>
|
||||||
|
<template #before>
|
||||||
|
<div aria-hidden="true" class="spacer header"></div>
|
||||||
|
</template>
|
||||||
|
<template #default="{ item }">
|
||||||
|
<div class="grid-row">
|
||||||
|
<article
|
||||||
|
v-for="entry in item.items"
|
||||||
|
:key="entry.path"
|
||||||
|
:class="['card glass-white', { loading: entry.path === pendingPath }]"
|
||||||
|
@click="handleOpen(entry)"
|
||||||
|
>
|
||||||
|
<div :class="['icon', entry.type]">
|
||||||
|
<t-icon :name="entry.type === 'dir' ? 'folder' : entry.icon" />
|
||||||
|
</div>
|
||||||
|
<div class="meta">
|
||||||
|
<p class="name" v-text="entry.name"></p>
|
||||||
|
<div class="desc">
|
||||||
|
<t-loading v-if="entry.path === pendingPath" size="1rem" />
|
||||||
|
<p v-else class="desc-text" v-text="formatItemDesc(entry)"></p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
<div v-if="item.items.length < columns" class="card ghost"></div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<template #after>
|
||||||
|
<div aria-hidden="true" class="spacer content"></div>
|
||||||
|
</template>
|
||||||
|
</recycle-scroller>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from "vue";
|
||||||
|
import { RecycleScroller } from "vue-virtual-scroller";
|
||||||
|
import { formatItemDesc, type ExplorerItem } from "./fileExplorer.shared";
|
||||||
|
|
||||||
|
interface GridRow {
|
||||||
|
key: string;
|
||||||
|
items: ExplorerItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const columns = 2;
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
items: ExplorerItem[];
|
||||||
|
pendingPath?: string;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
open: [item: ExplorerItem];
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const rows = computed<GridRow[]>(() => {
|
||||||
|
const nextRows: GridRow[] = [];
|
||||||
|
|
||||||
|
for (let index = 0; index < props.items.length; index += columns) {
|
||||||
|
const chunk = props.items.slice(index, index + columns);
|
||||||
|
nextRows.push({
|
||||||
|
key: chunk.map((item) => item.path).join("|"),
|
||||||
|
items: chunk
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return nextRows;
|
||||||
|
});
|
||||||
|
|
||||||
|
function handleOpen(item: ExplorerItem): void {
|
||||||
|
if (props.pendingPath) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
emit("open", item);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="less">
|
||||||
|
.scroller {
|
||||||
|
--top-gap: var(--page-top-gap, 1rem);
|
||||||
|
--bottom-gap: 5.5rem;
|
||||||
|
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spacer {
|
||||||
|
width: 100%;
|
||||||
|
pointer-events: none;
|
||||||
|
|
||||||
|
&.header {
|
||||||
|
height: var(--top-gap);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.content {
|
||||||
|
height: var(--bottom-gap);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.grid-row {
|
||||||
|
gap: .75rem;
|
||||||
|
display: grid;
|
||||||
|
padding-bottom: .75rem;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
gap: .75rem;
|
||||||
|
display: flex;
|
||||||
|
padding: 1rem .875rem;
|
||||||
|
border: 1px solid var(--app-line);
|
||||||
|
border-radius: 1rem;
|
||||||
|
flex-direction: column;
|
||||||
|
background: var(--app-card);
|
||||||
|
box-shadow: 0 .35rem 1rem rgba(17, 32, 56, .05);
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
&.loading {
|
||||||
|
opacity: .72;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.ghost {
|
||||||
|
visibility: hidden;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon {
|
||||||
|
width: 2.75rem;
|
||||||
|
height: 2.75rem;
|
||||||
|
display: flex;
|
||||||
|
border-radius: .9rem;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: #fff;
|
||||||
|
background: linear-gradient(135deg, #4d8dff, #76a9ff);
|
||||||
|
|
||||||
|
&.dir {
|
||||||
|
background: linear-gradient(135deg, #ff9c3d, #ffbf69);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta {
|
||||||
|
gap: .3rem;
|
||||||
|
min-width: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.name,
|
||||||
|
.desc-text {
|
||||||
|
margin: 0;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
||||||
|
.name {
|
||||||
|
color: var(--app-text);
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.desc {
|
||||||
|
min-height: 1rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.desc-text {
|
||||||
|
color: var(--app-sub);
|
||||||
|
font-size: .8125rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.theme-dark) {
|
||||||
|
.card {
|
||||||
|
box-shadow: 0 .35rem 1rem rgba(0, 0, 0, .22);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
217
src/pages/file/FileExplorerList.vue
Normal file
217
src/pages/file/FileExplorerList.vue
Normal file
@@ -0,0 +1,217 @@
|
|||||||
|
<template>
|
||||||
|
<recycle-scroller
|
||||||
|
class="file-explorer-list"
|
||||||
|
:items="items"
|
||||||
|
:item-size="44"
|
||||||
|
key-field="path"
|
||||||
|
>
|
||||||
|
<template #before>
|
||||||
|
<div aria-hidden="true" class="spacer header"></div>
|
||||||
|
</template>
|
||||||
|
<template #default="{ item }">
|
||||||
|
<t-swipe-cell :disabled="!isSwipeActionVisible(item)" :left="resolveLeftActions(item)">
|
||||||
|
<t-cell
|
||||||
|
class="cell"
|
||||||
|
:title="item.name"
|
||||||
|
:arrow="item.type === 'dir'"
|
||||||
|
@click="handleOpen(item)"
|
||||||
|
>
|
||||||
|
<template #note>
|
||||||
|
<div class="health">
|
||||||
|
<t-loading v-if="item.path === pendingPath" size="1rem" />
|
||||||
|
<span v-if="item.type === 'file'" v-text="item.size"></span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<template #left-icon>
|
||||||
|
<t-icon :name="item.type === 'dir' ? 'folder' : item.icon" />
|
||||||
|
</template>
|
||||||
|
</t-cell>
|
||||||
|
</t-swipe-cell>
|
||||||
|
</template>
|
||||||
|
<template #after>
|
||||||
|
<div aria-hidden="true" class="spacer content"></div>
|
||||||
|
</template>
|
||||||
|
</recycle-scroller>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { h, resolveComponent } from "vue";
|
||||||
|
import { RecycleScroller } from "vue-virtual-scroller";
|
||||||
|
import { type ExplorerItem } from "./fileExplorer.shared";
|
||||||
|
import { isBrowserSupportedAudio } from "./fileAudio.shared";
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
items: ExplorerItem[];
|
||||||
|
pendingPath?: string;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const emit = defineEmits(["open", "queue", "play"]);
|
||||||
|
|
||||||
|
function isSwipeActionVisible(item: ExplorerItem): boolean {
|
||||||
|
return isBrowserSupportedAudio(item);
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveLeftActions(item: ExplorerItem) {
|
||||||
|
if (!isSwipeActionVisible(item)) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const buttonComponent = resolveComponent("t-button");
|
||||||
|
const iconComponent = resolveComponent("t-icon");
|
||||||
|
|
||||||
|
return () => h("div", { class: "actions" }, [
|
||||||
|
h(buttonComponent, {
|
||||||
|
class: "action-btn queue-action",
|
||||||
|
size: "small",
|
||||||
|
shape: "square",
|
||||||
|
theme: "primary",
|
||||||
|
variant: "outline",
|
||||||
|
onClick: () => handleQueue(item)
|
||||||
|
}, {
|
||||||
|
icon: () => h(iconComponent, { name: "queue" })
|
||||||
|
}),
|
||||||
|
h(buttonComponent, {
|
||||||
|
class: "action-btn play-action",
|
||||||
|
size: "small",
|
||||||
|
shape: "square",
|
||||||
|
theme: "primary",
|
||||||
|
onClick: () => handlePlay(item)
|
||||||
|
}, {
|
||||||
|
icon: () => h(iconComponent, { name: "play" })
|
||||||
|
})
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleOpen(item: ExplorerItem): void {
|
||||||
|
if (props.pendingPath) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
emit("open", item);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleQueue(item: ExplorerItem): void {
|
||||||
|
if (props.pendingPath) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
emit("queue", item);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handlePlay(item: ExplorerItem): void {
|
||||||
|
console.log("play", item);
|
||||||
|
if (props.pendingPath) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
emit("play", item);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="less">
|
||||||
|
.file-explorer-list {
|
||||||
|
--top-gap: var(--page-top-gap, 1rem);
|
||||||
|
--bottom-gap: 5.5rem;
|
||||||
|
|
||||||
|
flex: 1 1 auto;
|
||||||
|
height: 100%;
|
||||||
|
padding: 0 1rem;
|
||||||
|
display: flex;
|
||||||
|
overflow: hidden;
|
||||||
|
min-height: 0;
|
||||||
|
flex-direction: column;
|
||||||
|
|
||||||
|
&:deep(.vue-recycle-scroller__item-view) {
|
||||||
|
|
||||||
|
&:first-child {
|
||||||
|
|
||||||
|
.cell {
|
||||||
|
border-radius: var(--td-radius-large) var(--td-radius-large) 0 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&:last-child {
|
||||||
|
|
||||||
|
.cell {
|
||||||
|
border-radius: 0 0 var(--td-radius-large) var(--td-radius-large);
|
||||||
|
|
||||||
|
&:after {
|
||||||
|
border-bottom: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.spacer {
|
||||||
|
width: 100%;
|
||||||
|
pointer-events: none;
|
||||||
|
|
||||||
|
&.header {
|
||||||
|
height: var(--top-gap);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.content {
|
||||||
|
height: var(--bottom-gap);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.health {
|
||||||
|
gap: .375rem;
|
||||||
|
display: flex;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
min-height: 1rem;
|
||||||
|
align-items: center;
|
||||||
|
white-space: nowrap;
|
||||||
|
color: var(--app-sub);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:deep(.actions) {
|
||||||
|
gap: .5rem;
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
padding: 0 .75rem;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:deep(.queue-action) {
|
||||||
|
background: rgba(255, 255, 255, .92);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:deep(.play-action) {
|
||||||
|
background: var(--td-brand-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:deep(.action-btn) {
|
||||||
|
width: 2.25rem;
|
||||||
|
height: 2.25rem;
|
||||||
|
border-radius: .75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cell {
|
||||||
|
|
||||||
|
&:deep(.t-cell__content) {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
overflow: hidden;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:deep(.t-cell__title) {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
overflow: hidden;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:deep(.t-cell__title-text) {
|
||||||
|
width: 100%;
|
||||||
|
display: block;
|
||||||
|
overflow: hidden;
|
||||||
|
white-space: nowrap;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:deep(.t-cell__note) {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
396
src/pages/file/FileExplorerPage.vue
Normal file
396
src/pages/file/FileExplorerPage.vue
Normal file
@@ -0,0 +1,396 @@
|
|||||||
|
<template>
|
||||||
|
<div class="page">
|
||||||
|
<section v-if="currentItems.length" class="content">
|
||||||
|
<file-explorer-list
|
||||||
|
v-if="displayMode === 'list'"
|
||||||
|
:items="currentItems"
|
||||||
|
:pending-path="pendingFolderPath"
|
||||||
|
@open="openItem"
|
||||||
|
@queue="queueItem"
|
||||||
|
@play="playItem"
|
||||||
|
/>
|
||||||
|
<file-explorer-grid
|
||||||
|
v-else
|
||||||
|
:items="currentItems"
|
||||||
|
:pending-path="pendingFolderPath"
|
||||||
|
@open="openItem"
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
<div v-else-if="pageLoading" class="loading-wrap">
|
||||||
|
<t-loading text="Loading directory" />
|
||||||
|
</div>
|
||||||
|
<t-empty v-else description="Directory is empty" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { Toast } from "tdesign-mobile-vue";
|
||||||
|
import FileAPI from "@/api/FileAPI";
|
||||||
|
import FileExplorerGrid from "./FileExplorerGrid.vue";
|
||||||
|
import FileExplorerList from "./FileExplorerList.vue";
|
||||||
|
import { useNavBarStore } from "@/store/navBarStore";
|
||||||
|
import {
|
||||||
|
type DisplayMode,
|
||||||
|
type ExplorerItem,
|
||||||
|
mapServerFileToExplorerItem,
|
||||||
|
sortExplorerItems
|
||||||
|
} from "./fileExplorer.shared";
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<{
|
||||||
|
mode?: DisplayMode;
|
||||||
|
path?: string | string[];
|
||||||
|
useRoutePath?: boolean;
|
||||||
|
}>(), {
|
||||||
|
mode: "list",
|
||||||
|
path: "",
|
||||||
|
useRoutePath: true
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits([
|
||||||
|
"update:path",
|
||||||
|
"open-folder",
|
||||||
|
"open-file",
|
||||||
|
"queue-file",
|
||||||
|
"play-file",
|
||||||
|
"update:mode"
|
||||||
|
]);
|
||||||
|
|
||||||
|
const route = useRoute();
|
||||||
|
const router = useRouter();
|
||||||
|
const navBarStore = useNavBarStore();
|
||||||
|
const navBarRightOwner = `file-explorer-page-${Math.random().toString(36).slice(2)}`;
|
||||||
|
|
||||||
|
const displayMode = ref<DisplayMode>(props.mode);
|
||||||
|
const localSegments = ref<string[]>(normalizePath(props.path));
|
||||||
|
const directoryCache = ref<Record<string, ExplorerItem[]>>({});
|
||||||
|
const pageLoading = ref(false);
|
||||||
|
const pendingFolderPath = ref("");
|
||||||
|
let latestLoadToken = 0;
|
||||||
|
|
||||||
|
const shouldUseRoutePath = computed(() => {
|
||||||
|
if (!props.useRoutePath) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return route.name === "FileTab" || route.name === "FileExplorerPage";
|
||||||
|
});
|
||||||
|
|
||||||
|
const routeSegments = computed(() => normalizePath(route.params.pathMatch));
|
||||||
|
const currentSegments = computed(() => {
|
||||||
|
if (shouldUseRoutePath.value) {
|
||||||
|
return routeSegments.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
return localSegments.value;
|
||||||
|
});
|
||||||
|
|
||||||
|
const currentItems = computed<ExplorerItem[]>(() => {
|
||||||
|
return directoryCache.value[getDirectoryKey(currentSegments.value)] || [];
|
||||||
|
});
|
||||||
|
|
||||||
|
const currentTitle = computed(() => {
|
||||||
|
if (!currentSegments.value.length) {
|
||||||
|
return "文件";
|
||||||
|
}
|
||||||
|
|
||||||
|
return currentSegments.value[currentSegments.value.length - 1];
|
||||||
|
});
|
||||||
|
|
||||||
|
const displayModeActionText = computed(() => {
|
||||||
|
return displayMode.value === "list" ? "平铺" : "列表";
|
||||||
|
});
|
||||||
|
|
||||||
|
const navBarRightRenderer = defineComponent({
|
||||||
|
name: "file-explorer-nav-right",
|
||||||
|
setup() {
|
||||||
|
const buttonComponent = resolveComponent("t-button");
|
||||||
|
const iconComponent = resolveComponent("t-icon");
|
||||||
|
|
||||||
|
return () => h(buttonComponent, {
|
||||||
|
size: "small",
|
||||||
|
variant: "text",
|
||||||
|
theme: "primary",
|
||||||
|
class: "nav-switch",
|
||||||
|
shape: "square",
|
||||||
|
"aria-label": displayModeActionText.value,
|
||||||
|
onClick: toggleDisplayMode
|
||||||
|
}, {
|
||||||
|
icon: () => h(iconComponent, {
|
||||||
|
name: displayMode.value === "list" ? "grid-view" : "view-list",
|
||||||
|
size: "1.5rem"
|
||||||
|
})
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.mode,
|
||||||
|
(value) => {
|
||||||
|
displayMode.value = value;
|
||||||
|
},
|
||||||
|
{ immediate: true }
|
||||||
|
);
|
||||||
|
|
||||||
|
watch(
|
||||||
|
currentTitle,
|
||||||
|
(value) => {
|
||||||
|
navBarStore.setTitle(value);
|
||||||
|
},
|
||||||
|
{ immediate: true }
|
||||||
|
);
|
||||||
|
|
||||||
|
watch(
|
||||||
|
displayModeActionText,
|
||||||
|
() => {
|
||||||
|
navBarStore.setRightRenderer(navBarRightRenderer, navBarRightOwner);
|
||||||
|
},
|
||||||
|
{ immediate: true }
|
||||||
|
);
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.path,
|
||||||
|
(value) => {
|
||||||
|
if (shouldUseRoutePath.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
localSegments.value = normalizePath(value);
|
||||||
|
},
|
||||||
|
{ immediate: true }
|
||||||
|
);
|
||||||
|
|
||||||
|
watch(
|
||||||
|
currentSegments,
|
||||||
|
(segments) => {
|
||||||
|
void hydrateCurrentDirectory(segments);
|
||||||
|
},
|
||||||
|
{ immediate: true }
|
||||||
|
);
|
||||||
|
|
||||||
|
function normalizePath(source?: string | string[]): string[] {
|
||||||
|
if (Array.isArray(source)) {
|
||||||
|
return source.filter((segment) => !!segment);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!source) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return source.split("/").filter((segment) => !!segment);
|
||||||
|
}
|
||||||
|
|
||||||
|
function setDisplayMode(mode: DisplayMode): void {
|
||||||
|
if (displayMode.value === mode) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
displayMode.value = mode;
|
||||||
|
emit("update:mode", mode);
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleDisplayMode(): void {
|
||||||
|
setDisplayMode(displayMode.value === "list" ? "grid" : "list");
|
||||||
|
}
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
navBarStore.clearRight(navBarRightOwner);
|
||||||
|
});
|
||||||
|
|
||||||
|
async function openItem(item: ExplorerItem): Promise<void> {
|
||||||
|
if (pendingFolderPath.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (item.type === "dir") {
|
||||||
|
const nextSegments = normalizePath(item.path);
|
||||||
|
pendingFolderPath.value = item.path;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await ensureDirectoryLoaded(nextSegments, false);
|
||||||
|
emit("open-folder", nextSegments);
|
||||||
|
await syncPath(nextSegments);
|
||||||
|
} catch (error) {
|
||||||
|
Toast({
|
||||||
|
theme: "error",
|
||||||
|
message: "操作失败"
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
pendingFolderPath.value = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
emit("open-file", item);
|
||||||
|
}
|
||||||
|
|
||||||
|
function queueItem(item: ExplorerItem): void {
|
||||||
|
emit("queue-file", item);
|
||||||
|
}
|
||||||
|
|
||||||
|
function playItem(item: ExplorerItem): void {
|
||||||
|
console.log("play-item", item);
|
||||||
|
emit("play-file", item);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function syncPath(nextSegments: string[]): Promise<void> {
|
||||||
|
if (shouldUseRoutePath.value) {
|
||||||
|
if (!nextSegments.length) {
|
||||||
|
await router.push("/");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await router.push({
|
||||||
|
name: "FileExplorerPage",
|
||||||
|
params: {
|
||||||
|
pathMatch: nextSegments
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
localSegments.value = nextSegments;
|
||||||
|
emit("update:path", nextSegments);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function hydrateCurrentDirectory(pathSegments: string[]): Promise<void> {
|
||||||
|
try {
|
||||||
|
await ensureDirectoryLoaded(pathSegments, true);
|
||||||
|
} catch (error) {
|
||||||
|
Toast({
|
||||||
|
theme: "error",
|
||||||
|
message: "操作失败"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ensureDirectoryLoaded(pathSegments: string[], updatePageLoading: boolean): Promise<ExplorerItem[]> {
|
||||||
|
const cacheKey = getDirectoryKey(pathSegments);
|
||||||
|
const cachedItems = directoryCache.value[cacheKey];
|
||||||
|
|
||||||
|
if (cachedItems) {
|
||||||
|
return cachedItems;
|
||||||
|
}
|
||||||
|
|
||||||
|
const requestToken = latestLoadToken + 1;
|
||||||
|
latestLoadToken = requestToken;
|
||||||
|
|
||||||
|
if (updatePageLoading) {
|
||||||
|
pageLoading.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const files = await FileAPI.list(pathSegments);
|
||||||
|
const items = sortExplorerItems(files.map((item) => mapServerFileToExplorerItem(item)));
|
||||||
|
directoryCache.value = {
|
||||||
|
...directoryCache.value,
|
||||||
|
[cacheKey]: items
|
||||||
|
};
|
||||||
|
return items;
|
||||||
|
} finally {
|
||||||
|
if (updatePageLoading && requestToken === latestLoadToken) {
|
||||||
|
pageLoading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getDirectoryKey(pathSegments: string[]): string {
|
||||||
|
return pathSegments.join("/");
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="less">
|
||||||
|
.page {
|
||||||
|
--page-top-gap: calc(var(--app-nav-offset, 0px) + 1rem);
|
||||||
|
|
||||||
|
gap: 1rem;
|
||||||
|
height: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
display: flex;
|
||||||
|
overflow: hidden;
|
||||||
|
flex-direction: column;
|
||||||
|
|
||||||
|
.nav-switch {
|
||||||
|
padding: 0;
|
||||||
|
width: 2rem;
|
||||||
|
height: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.go-up {
|
||||||
|
border: 1px solid var(--app-line);
|
||||||
|
background: var(--app-card);
|
||||||
|
box-shadow: 0 .35rem 1rem rgba(17, 32, 56, .05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.go-up {
|
||||||
|
gap: .875rem;
|
||||||
|
display: flex;
|
||||||
|
padding: .875rem 1rem;
|
||||||
|
border-radius: 1rem;
|
||||||
|
align-items: center;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content,
|
||||||
|
.loading-wrap {
|
||||||
|
min-height: 0;
|
||||||
|
flex: 1 1 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content {
|
||||||
|
display: flex;
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
|
> * {
|
||||||
|
width: 100%;
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-wrap {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon {
|
||||||
|
width: 2.75rem;
|
||||||
|
height: 2.75rem;
|
||||||
|
display: flex;
|
||||||
|
border-radius: .9rem;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: #fff;
|
||||||
|
background: linear-gradient(135deg, #4d8dff, #76a9ff);
|
||||||
|
|
||||||
|
&.dir {
|
||||||
|
background: linear-gradient(135deg, #ff9c3d, #ffbf69);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta {
|
||||||
|
gap: .3rem;
|
||||||
|
min-width: 0;
|
||||||
|
display: flex;
|
||||||
|
flex: 1 1 auto;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.name,
|
||||||
|
.desc {
|
||||||
|
margin: 0;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
||||||
|
.name {
|
||||||
|
color: var(--app-text);
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.desc {
|
||||||
|
color: var(--app-sub);
|
||||||
|
font-size: .8125rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
42
src/pages/file/fileAudio.shared.ts
Normal file
42
src/pages/file/fileAudio.shared.ts
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
import type { ExplorerItem } from "./fileExplorer.shared";
|
||||||
|
|
||||||
|
const audioMimeMap: Record<string, string[]> = {
|
||||||
|
mp3: ["audio/mpeg"],
|
||||||
|
mpeg: ["audio/mpeg"],
|
||||||
|
m4a: ["audio/mp4", "audio/x-m4a"],
|
||||||
|
mp4: ["audio/mp4"],
|
||||||
|
aac: ["audio/aac", "audio/mp4"],
|
||||||
|
ogg: ["audio/ogg", "audio/vorbis"],
|
||||||
|
oga: ["audio/ogg", "audio/vorbis"],
|
||||||
|
wav: ["audio/wav", "audio/wave", "audio/x-wav"],
|
||||||
|
webm: ["audio/webm"],
|
||||||
|
flac: ["audio/flac", "audio/x-flac"]
|
||||||
|
};
|
||||||
|
|
||||||
|
function resolveAudioExtension(item: ExplorerItem): string {
|
||||||
|
const rawExtension = item.extension || item.name.split(".").pop() || "";
|
||||||
|
return rawExtension.trim().toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isAudioExplorerItem(item: ExplorerItem): boolean {
|
||||||
|
return item.type === "file" && item.rawType === "AUDIO";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isBrowserSupportedAudio(item: ExplorerItem): boolean {
|
||||||
|
if (!isAudioExplorerItem(item)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const extension = resolveAudioExtension(item);
|
||||||
|
const mimeList = audioMimeMap[extension];
|
||||||
|
if (!mimeList?.length) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof window === "undefined") {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const audio = document.createElement("audio");
|
||||||
|
return mimeList.some((mime) => !!audio.canPlayType(mime));
|
||||||
|
}
|
||||||
123
src/pages/file/fileExplorer.shared.ts
Normal file
123
src/pages/file/fileExplorer.shared.ts
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
import type { ServerFile } from "@/types/File";
|
||||||
|
|
||||||
|
export type DisplayMode = "list" | "grid";
|
||||||
|
export type FileItemType = "dir" | "file";
|
||||||
|
|
||||||
|
export interface ExplorerItem {
|
||||||
|
name: string;
|
||||||
|
type: FileItemType;
|
||||||
|
extension?: string;
|
||||||
|
size?: string;
|
||||||
|
updatedAt: string;
|
||||||
|
icon: string;
|
||||||
|
path: string;
|
||||||
|
rawType?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const fileIconMap: Record<string, string> = {
|
||||||
|
TXT: "file-text",
|
||||||
|
CODE: "code",
|
||||||
|
SCRIPT: "code",
|
||||||
|
ZIP: "file-zip",
|
||||||
|
MICROSOFT: "file-excel",
|
||||||
|
JAVA: "logo-android",
|
||||||
|
VIDEO: "video",
|
||||||
|
FONT: "font-size",
|
||||||
|
AUDIO: "music",
|
||||||
|
IMAGE: "image",
|
||||||
|
SYSTEM: "setting",
|
||||||
|
FILE: "file"
|
||||||
|
};
|
||||||
|
|
||||||
|
const dateTimeFormatter = new Intl.DateTimeFormat("zh-CN", {
|
||||||
|
year: "numeric",
|
||||||
|
month: "2-digit",
|
||||||
|
day: "2-digit",
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
hour12: false
|
||||||
|
});
|
||||||
|
|
||||||
|
export function mapServerFileToExplorerItem(file: ServerFile): ExplorerItem {
|
||||||
|
const isDirectory = file.isDirectory || file.type === "DIRECTORY";
|
||||||
|
const rawPath = typeof file.absolutePath === "string" ? file.absolutePath.trim() : "";
|
||||||
|
|
||||||
|
return {
|
||||||
|
name: file.name || "未命名",
|
||||||
|
type: isDirectory ? "dir" : "file",
|
||||||
|
extension: file.extension,
|
||||||
|
size: isDirectory ? undefined : formatFileSize(file.size),
|
||||||
|
updatedAt: formatModifiedAt(file.modifiedAt),
|
||||||
|
icon: resolveFileIcon(file, isDirectory),
|
||||||
|
path: rawPath || file.name || "",
|
||||||
|
rawType: file.type
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sortExplorerItems(items: ExplorerItem[]): ExplorerItem[] {
|
||||||
|
return [...items].sort((left, right) => {
|
||||||
|
if (left.type !== right.type) {
|
||||||
|
return left.type === "dir" ? -1 : 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
return left.name.localeCompare(right.name, "zh-CN");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatItemDesc(item: ExplorerItem): string {
|
||||||
|
if (item.type === "dir") {
|
||||||
|
return `文件夹 | ${item.updatedAt}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${item.size || "--"} | ${item.updatedAt}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveFileIcon(file: ServerFile, isDirectory: boolean): string {
|
||||||
|
if (isDirectory) {
|
||||||
|
return "folder";
|
||||||
|
}
|
||||||
|
|
||||||
|
const rawType = typeof file.type === "string" ? file.type.toUpperCase() : "FILE";
|
||||||
|
return fileIconMap[rawType] || "file";
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatModifiedAt(modifiedAt?: number): string {
|
||||||
|
if (!modifiedAt) {
|
||||||
|
return "未知时间";
|
||||||
|
}
|
||||||
|
|
||||||
|
return dateTimeFormatter.format(modifiedAt);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatFileSize(size?: number): string {
|
||||||
|
if (typeof size !== "number" || Number.isNaN(size) || size < 0) {
|
||||||
|
return "--";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (size < 1024) {
|
||||||
|
return `${size} B`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const units = ["KB", "MB", "GB", "TB"];
|
||||||
|
let nextSize = size / 1024;
|
||||||
|
let unitIndex = 0;
|
||||||
|
|
||||||
|
while (1024 <= nextSize && unitIndex < units.length - 1) {
|
||||||
|
nextSize /= 1024;
|
||||||
|
unitIndex += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${trimDecimal(nextSize)} ${units[unitIndex]}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function trimDecimal(value: number): string {
|
||||||
|
if (10 <= value || Number.isInteger(value)) {
|
||||||
|
return value.toFixed(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (1 <= value) {
|
||||||
|
return value.toFixed(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
return value.toFixed(2);
|
||||||
|
}
|
||||||
150
src/pages/setting/ConnectSetting.vue
Normal file
150
src/pages/setting/ConnectSetting.vue
Normal file
@@ -0,0 +1,150 @@
|
|||||||
|
<template>
|
||||||
|
<div class="connect-setting">
|
||||||
|
<t-cell-group theme="card">
|
||||||
|
<t-cell title="使用 HTTPS">
|
||||||
|
<template #rightIcon>
|
||||||
|
<t-switch v-model="httpsEnabled" />
|
||||||
|
</template>
|
||||||
|
</t-cell>
|
||||||
|
<t-input
|
||||||
|
v-model.trim="form.host"
|
||||||
|
clearable
|
||||||
|
label="地址"
|
||||||
|
placeholder="192.168.1.10 或 nas.local"
|
||||||
|
/>
|
||||||
|
<t-input
|
||||||
|
v-model.trim="form.port"
|
||||||
|
clearable
|
||||||
|
label="端口"
|
||||||
|
placeholder="8080"
|
||||||
|
type="number"
|
||||||
|
/>
|
||||||
|
<t-input
|
||||||
|
v-model.trim="form.token"
|
||||||
|
clearable
|
||||||
|
label="密钥"
|
||||||
|
placeholder="请输入访问密钥"
|
||||||
|
/>
|
||||||
|
</t-cell-group>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { Toast } from "tdesign-mobile-vue";
|
||||||
|
import { useNavBarStore } from "@/store/navBarStore";
|
||||||
|
import type { ConnectSetting } from "@/store/settingStore";
|
||||||
|
import { useSettingStore } from "@/store/settingStore";
|
||||||
|
|
||||||
|
defineOptions({
|
||||||
|
name: "ConnectSetting"
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
const router = useRouter();
|
||||||
|
const navBarStore = useNavBarStore();
|
||||||
|
const settingStore = useSettingStore();
|
||||||
|
|
||||||
|
// ---------- 顶部导航 ----------
|
||||||
|
|
||||||
|
const navBarOwner = `connect-setting-${Math.random().toString(36).slice(2)}`;
|
||||||
|
|
||||||
|
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
|
||||||
|
}, () => "取消");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const navBarRightRenderer = defineComponent({
|
||||||
|
name: "connect-setting-nav-right",
|
||||||
|
setup() {
|
||||||
|
const buttonComponent = resolveComponent("t-button");
|
||||||
|
|
||||||
|
return () => h(buttonComponent, {
|
||||||
|
variant: "text",
|
||||||
|
theme: "primary",
|
||||||
|
size: "small",
|
||||||
|
onClick: saveConnect
|
||||||
|
}, () => "保存");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function cancelConnect(): void {
|
||||||
|
Object.assign(form, settingStore.connect);
|
||||||
|
router.back();
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveConnect(): void {
|
||||||
|
if (!validateConnect()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
settingStore.setConnect(form);
|
||||||
|
Toast({
|
||||||
|
theme: "success",
|
||||||
|
message: "连接配置已保存"
|
||||||
|
});
|
||||||
|
router.back();
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
navBarStore.setLeftRenderer(navBarLeftRenderer, navBarOwner);
|
||||||
|
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({
|
||||||
|
theme: "warning",
|
||||||
|
message: "请完整填写连接配置"
|
||||||
|
});
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="less">
|
||||||
|
.connect-setting {
|
||||||
|
padding-top: calc(var(--app-nav-offset) + 1rem);
|
||||||
|
|
||||||
|
:deep(.t-input:last-child:after) {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
54
src/pages/setting/DashboardSetting.vue
Normal file
54
src/pages/setting/DashboardSetting.vue
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
<template>
|
||||||
|
<div class="dashboard-setting">
|
||||||
|
<t-cell-group title="刷新频率" theme="card">
|
||||||
|
<t-cell-info label="当前状态" :value="`${snapshotRefreshValue} 秒`">
|
||||||
|
<t-slider v-model="snapshotRefreshValue" :max="10" :min="1" :step="1" />
|
||||||
|
</t-cell-info>
|
||||||
|
<t-cell-info label="历史采样" :value="`${historyRefreshValue} 秒`">
|
||||||
|
<t-slider v-model="historyRefreshValue" :max="10" :min="3" :step="1" />
|
||||||
|
</t-cell-info>
|
||||||
|
</t-cell-group>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { useSettingStore } from "@/store/settingStore";
|
||||||
|
|
||||||
|
defineOptions({
|
||||||
|
name: "DashboardSetting"
|
||||||
|
});
|
||||||
|
|
||||||
|
const settingStore = useSettingStore();
|
||||||
|
|
||||||
|
const snapshotRefreshValue = computed({
|
||||||
|
get: (): number => normalizeRefreshValue(settingStore.dashboard.server.snapshotRefreshSeconds, 1, 10, 3),
|
||||||
|
set: (value: number): void => {
|
||||||
|
settingStore.setServerDashboard({
|
||||||
|
snapshotRefreshSeconds: normalizeRefreshValue(value, 1, 10, 3)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const historyRefreshValue = computed({
|
||||||
|
get: (): number => normalizeRefreshValue(settingStore.dashboard.server.historyRefreshSeconds, 3, 10, 10),
|
||||||
|
set: (value: number): void => {
|
||||||
|
settingStore.setServerDashboard({
|
||||||
|
historyRefreshSeconds: normalizeRefreshValue(value, 3, 10, 10)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function normalizeRefreshValue(value: number, min: number, max: number, fallback: number): number {
|
||||||
|
if (Number.isNaN(value)) {
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
const normalizedValue = Math.floor(value);
|
||||||
|
return Math.min(Math.max(normalizedValue, min), max);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="less">
|
||||||
|
.dashboard-setting {
|
||||||
|
padding-top: var(--app-nav-offset);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
46
src/pages/setting/ThemeSetting.vue
Normal file
46
src/pages/setting/ThemeSetting.vue
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
<template>
|
||||||
|
<div class="theme-setting">
|
||||||
|
<t-cell-group theme="card">
|
||||||
|
<t-radio
|
||||||
|
v-for="item in themeModeList"
|
||||||
|
:key="item.value"
|
||||||
|
class="block"
|
||||||
|
default-checked
|
||||||
|
allow-uncheck
|
||||||
|
:label="item.label"
|
||||||
|
icon="line"
|
||||||
|
:checked="globalUIStore.themeMode === item.value"
|
||||||
|
@click="globalUIStore.setThemeMode(item.value)"
|
||||||
|
/>
|
||||||
|
</t-cell-group>
|
||||||
|
</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">
|
||||||
|
.theme-setting {
|
||||||
|
padding-top: calc(var(--app-nav-offset) + 1rem);
|
||||||
|
|
||||||
|
:deep(.t-radio:last-child) {
|
||||||
|
|
||||||
|
.t-radio__border {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
278
src/pages/system/MusicPlayerPopup.vue
Normal file
278
src/pages/system/MusicPlayerPopup.vue
Normal file
@@ -0,0 +1,278 @@
|
|||||||
|
<template>
|
||||||
|
<t-popup
|
||||||
|
:visible="musicPlayerStore.popupVisible"
|
||||||
|
placement="bottom"
|
||||||
|
teleport="body"
|
||||||
|
:duration="460"
|
||||||
|
@visible-change="handleVisibleChange"
|
||||||
|
>
|
||||||
|
<div class="music-popup">
|
||||||
|
<div class="summary">
|
||||||
|
<div class="cover">
|
||||||
|
<img v-if="currentSong.cover" :src="currentSong.cover" alt="封面">
|
||||||
|
<t-icon v-else name="music" />
|
||||||
|
</div>
|
||||||
|
<div class="info">
|
||||||
|
<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">
|
||||||
|
关闭
|
||||||
|
</t-button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="toolbar">
|
||||||
|
<t-button variant="text" theme="danger" @click="clearQueue">
|
||||||
|
清空队列
|
||||||
|
</t-button>
|
||||||
|
<t-button variant="text" theme="primary" @click="musicPlayerStore.cyclePlayMode()">
|
||||||
|
<span v-text="musicPlayerStore.playModeLabel"></span>
|
||||||
|
</t-button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="queue">
|
||||||
|
<div class="queue-head">
|
||||||
|
<p class="queue-title">播放队列</p>
|
||||||
|
<p class="queue-count" v-text="`共 ${musicPlayerStore.queue.length} 首`"></p>
|
||||||
|
</div>
|
||||||
|
<div class="queue-list">
|
||||||
|
<button
|
||||||
|
v-for="(item, index) in musicPlayerStore.queue"
|
||||||
|
:key="item.id"
|
||||||
|
:class="{ active: index === musicPlayerStore.currentIndex }"
|
||||||
|
class="queue-item"
|
||||||
|
type="button"
|
||||||
|
@click="playItem(index)"
|
||||||
|
>
|
||||||
|
<div class="queue-main">
|
||||||
|
<p class="queue-name" v-text="item.song.title || item.file.name"></p>
|
||||||
|
<p class="queue-artist" v-text="item.song.artist || '未知艺术家'"></p>
|
||||||
|
</div>
|
||||||
|
<t-button
|
||||||
|
class="queue-remove"
|
||||||
|
variant="text"
|
||||||
|
shape="square"
|
||||||
|
@click.stop="musicPlayerStore.removeFromQueue(item.id)"
|
||||||
|
>
|
||||||
|
移除
|
||||||
|
</t-button>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="controls">
|
||||||
|
<t-button variant="outline" @click="musicPlayerStore.playPrev">
|
||||||
|
上一首
|
||||||
|
</t-button>
|
||||||
|
<t-button theme="primary" @click="musicPlayerStore.togglePlaying">
|
||||||
|
<span v-text="musicPlayerStore.isPlaying ? '暂停' : '播放'"></span>
|
||||||
|
</t-button>
|
||||||
|
<t-button variant="outline" @click="musicPlayerStore.playNext">
|
||||||
|
下一首
|
||||||
|
</t-button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="volume">
|
||||||
|
<p class="volume-label">音量</p>
|
||||||
|
<t-slider v-model="volumePercent" :min="0" :max="100" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</t-popup>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { useMusicPlayerStore } from "@/store/musicPlayerStore";
|
||||||
|
|
||||||
|
const musicPlayerStore = useMusicPlayerStore();
|
||||||
|
|
||||||
|
const currentFile = computed(() => musicPlayerStore.currentFile);
|
||||||
|
const currentSong = computed(() => musicPlayerStore.currentSong);
|
||||||
|
const volumePercent = computed({
|
||||||
|
get() {
|
||||||
|
return Math.round(musicPlayerStore.volume * 100);
|
||||||
|
},
|
||||||
|
set(value: number) {
|
||||||
|
musicPlayerStore.setVolume(value / 100);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function handleVisibleChange(visible: boolean): void {
|
||||||
|
musicPlayerStore.setPopupVisible(visible);
|
||||||
|
}
|
||||||
|
|
||||||
|
function closePopup(): void {
|
||||||
|
musicPlayerStore.setPopupVisible(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearQueue(): void {
|
||||||
|
musicPlayerStore.clearQueue();
|
||||||
|
}
|
||||||
|
|
||||||
|
function playItem(index: number): void {
|
||||||
|
musicPlayerStore.setCurrentIndex(index);
|
||||||
|
musicPlayerStore.setPlaying(true);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="less">
|
||||||
|
.music-popup {
|
||||||
|
gap: 1rem;
|
||||||
|
display: flex;
|
||||||
|
padding: 1rem 1rem calc(1rem + env(safe-area-inset-bottom, 0px));
|
||||||
|
border-radius: 1.25rem 1.25rem 0 0;
|
||||||
|
flex-direction: column;
|
||||||
|
background: linear-gradient(180deg, rgba(255, 255, 255, .98), rgba(246, 247, 250, .98));
|
||||||
|
box-shadow: 0 -.35rem 1.5rem rgba(17, 32, 56, .08);
|
||||||
|
|
||||||
|
.summary,
|
||||||
|
.toolbar,
|
||||||
|
.controls,
|
||||||
|
.volume {
|
||||||
|
gap: .75rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cover {
|
||||||
|
flex: none;
|
||||||
|
width: 3.5rem;
|
||||||
|
height: 3.5rem;
|
||||||
|
display: flex;
|
||||||
|
overflow: hidden;
|
||||||
|
border-radius: 1rem;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: #fff;
|
||||||
|
background: linear-gradient(135deg, #1f6fff, #51a2ff);
|
||||||
|
|
||||||
|
img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.info {
|
||||||
|
gap: .35rem;
|
||||||
|
min-width: 0;
|
||||||
|
display: flex;
|
||||||
|
flex: 1 1 auto;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header,
|
||||||
|
.artist,
|
||||||
|
.queue-title,
|
||||||
|
.queue-count,
|
||||||
|
.queue-name,
|
||||||
|
.queue-artist,
|
||||||
|
.volume-label {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header,
|
||||||
|
.queue-name {
|
||||||
|
color: var(--app-text);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.artist,
|
||||||
|
.queue-count,
|
||||||
|
.queue-artist,
|
||||||
|
.volume-label {
|
||||||
|
color: var(--app-sub);
|
||||||
|
font-size: .875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.close-btn {
|
||||||
|
flex: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar,
|
||||||
|
.controls {
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.queue {
|
||||||
|
gap: .75rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.queue-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.queue-list {
|
||||||
|
gap: .625rem;
|
||||||
|
display: flex;
|
||||||
|
overflow: auto;
|
||||||
|
max-height: 15rem;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.queue-item {
|
||||||
|
gap: .75rem;
|
||||||
|
border: 0;
|
||||||
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
padding: .875rem 1rem;
|
||||||
|
text-align: left;
|
||||||
|
border-radius: 1rem;
|
||||||
|
align-items: center;
|
||||||
|
background: rgba(255, 255, 255, .72);
|
||||||
|
justify-content: space-between;
|
||||||
|
|
||||||
|
&.active {
|
||||||
|
background: rgba(31, 111, 255, .12);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.queue-main {
|
||||||
|
gap: .25rem;
|
||||||
|
min-width: 0;
|
||||||
|
display: flex;
|
||||||
|
flex: 1 1 auto;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.queue-name,
|
||||||
|
.queue-artist,
|
||||||
|
.header,
|
||||||
|
.artist {
|
||||||
|
overflow: hidden;
|
||||||
|
white-space: nowrap;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.controls {
|
||||||
|
> * {
|
||||||
|
flex: 1 1 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.volume {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.theme-dark) .music-popup {
|
||||||
|
background: linear-gradient(180deg, rgba(22, 26, 31, .98), rgba(14, 18, 23, .98));
|
||||||
|
box-shadow: 0 -.35rem 1.5rem rgba(0, 0, 0, .28);
|
||||||
|
|
||||||
|
.queue-item {
|
||||||
|
background: rgba(255, 255, 255, .05);
|
||||||
|
|
||||||
|
&.active {
|
||||||
|
background: rgba(81, 162, 255, .2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
6
src/pages/system/NotFoundPage.vue
Normal file
6
src/pages/system/NotFoundPage.vue
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
<template>
|
||||||
|
<span>找不到页面</span>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
</script>
|
||||||
202
src/pages/system/ServerIndexPage.vue
Normal file
202
src/pages/system/ServerIndexPage.vue
Normal file
@@ -0,0 +1,202 @@
|
|||||||
|
<template>
|
||||||
|
<div class="server-index">
|
||||||
|
<section class="panel">
|
||||||
|
<div class="hero">
|
||||||
|
<p class="tag">NAS 连接</p>
|
||||||
|
<h1 class="header">先配置服务器连接</h1>
|
||||||
|
<p class="desc">缺少连接配置时,应用不会进入文件、状态和设置页面。</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-card">
|
||||||
|
<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" size="large" @click="submitConnect">
|
||||||
|
保存并进入
|
||||||
|
</t-button>
|
||||||
|
</div>
|
||||||
|
</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: "ServerIndexPage"
|
||||||
|
});
|
||||||
|
|
||||||
|
const route = useRoute();
|
||||||
|
const router = useRouter();
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitConnect(): Promise<void> {
|
||||||
|
if (!validateConnect()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
settingStore.setConnect(form);
|
||||||
|
|
||||||
|
const redirect = typeof route.query.redirect === "string" ? route.query.redirect : "/";
|
||||||
|
await router.replace(redirect);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="less">
|
||||||
|
.server-index {
|
||||||
|
width: 100%;
|
||||||
|
min-height: 100vh;
|
||||||
|
|
||||||
|
.panel {
|
||||||
|
gap: 1.2rem;
|
||||||
|
display: flex;
|
||||||
|
padding: 1.2rem;
|
||||||
|
min-height: 100vh;
|
||||||
|
flex-direction: column;
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at top right, rgba(0, 82, 217, .18), transparent 36%),
|
||||||
|
linear-gradient(180deg, rgba(255, 255, 255, .92), rgba(245, 247, 250, .92));
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero {
|
||||||
|
gap: .75rem;
|
||||||
|
display: flex;
|
||||||
|
padding-top: 3rem;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tag,
|
||||||
|
.label,
|
||||||
|
.desc {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--app-sub);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tag {
|
||||||
|
font-size: .875rem;
|
||||||
|
letter-spacing: .08em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 2rem;
|
||||||
|
line-height: 1.15;
|
||||||
|
}
|
||||||
|
|
||||||
|
.desc {
|
||||||
|
font-size: .95rem;
|
||||||
|
line-height: 1.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-card {
|
||||||
|
gap: 1rem;
|
||||||
|
display: flex;
|
||||||
|
padding: 1.2rem;
|
||||||
|
border-radius: 1.25rem;
|
||||||
|
flex-direction: column;
|
||||||
|
border: 1px solid var(--app-line);
|
||||||
|
background: rgba(255, 255, 255, .86);
|
||||||
|
box-shadow: 0 .75rem 2rem rgba(17, 32, 56, .08);
|
||||||
|
backdrop-filter: blur(14px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.group {
|
||||||
|
gap: .5rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.label {
|
||||||
|
font-size: .875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.protocols {
|
||||||
|
gap: .75rem;
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.theme-dark) .server-index {
|
||||||
|
.panel {
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at top right, rgba(110, 168, 255, .2), transparent 34%),
|
||||||
|
linear-gradient(180deg, rgba(16, 20, 24, .96), rgba(12, 16, 20, .96));
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-card {
|
||||||
|
background: rgba(26, 33, 41, .86);
|
||||||
|
box-shadow: 0 .75rem 2rem rgba(0, 0, 0, .28);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
187
src/pages/tabs/DashboardTab.vue
Normal file
187
src/pages/tabs/DashboardTab.vue
Normal file
@@ -0,0 +1,187 @@
|
|||||||
|
<template>
|
||||||
|
<div class="dashboard-tab">
|
||||||
|
<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" />
|
||||||
|
<t-tab-panel value="ups" label="UPS" />
|
||||||
|
</t-tabs>
|
||||||
|
</div>
|
||||||
|
<div class="content-wrap">
|
||||||
|
<transition :name="panelTransitionName" 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";
|
||||||
|
|
||||||
|
const route = useRoute();
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
// ---------- Tab ----------
|
||||||
|
|
||||||
|
type DashboardTabValue = "server" | "docker" | "ups";
|
||||||
|
type PanelTransitionName = "panel-slide-next" | "panel-slide-prev";
|
||||||
|
|
||||||
|
const dashboardMap: Record<DashboardTabValue, Component> = {
|
||||||
|
server: ServerDashboard,
|
||||||
|
docker: DockerDashboard,
|
||||||
|
ups: UPSDashboard
|
||||||
|
};
|
||||||
|
const tabOrder: DashboardTabValue[] = ["server", "docker", "ups"];
|
||||||
|
const activeComponent = computed(() => dashboardMap[activeTab.value]);
|
||||||
|
const activeTab = ref<DashboardTabValue>("server");
|
||||||
|
const panelTransitionName = ref<PanelTransitionName>("panel-slide-next");
|
||||||
|
|
||||||
|
async function onChangeTab(value: string): Promise<void> {
|
||||||
|
const nextTab = value as DashboardTabValue;
|
||||||
|
if (nextTab === activeTab.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
panelTransitionName.value = resolveTabDirection(nextTab);
|
||||||
|
activeTab.value = nextTab;
|
||||||
|
await syncRouteTab(nextTab);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Tab 同步路由 ----------
|
||||||
|
|
||||||
|
type DashboardTabQuery = {
|
||||||
|
tab?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
watch(() => route.query.tab, () => {
|
||||||
|
const nextTab = resolveRouteTab();
|
||||||
|
if (nextTab === activeTab.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
panelTransitionName.value = resolveTabDirection(nextTab);
|
||||||
|
activeTab.value = nextTab;
|
||||||
|
}, { immediate: true });
|
||||||
|
|
||||||
|
function resolveRouteTab(): DashboardTabValue {
|
||||||
|
const queryTab = route.query.tab;
|
||||||
|
if (queryTab === "server" || queryTab === "docker" || queryTab === "ups") {
|
||||||
|
return queryTab;
|
||||||
|
}
|
||||||
|
return "server";
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveTabDirection(nextTab: DashboardTabValue): PanelTransitionName {
|
||||||
|
const currentIndex = tabOrder.indexOf(activeTab.value);
|
||||||
|
const nextIndex = tabOrder.indexOf(nextTab);
|
||||||
|
if (currentIndex <= -1 || nextIndex <= -1) {
|
||||||
|
return "panel-slide-next";
|
||||||
|
}
|
||||||
|
return currentIndex < nextIndex ? "panel-slide-next" : "panel-slide-prev";
|
||||||
|
}
|
||||||
|
|
||||||
|
async function syncRouteTab(nextTab: DashboardTabValue): Promise<void> {
|
||||||
|
if (route.query.tab === nextTab) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextQuery: DashboardTabQuery = {
|
||||||
|
...(route.query as DashboardTabQuery),
|
||||||
|
tab: nextTab
|
||||||
|
};
|
||||||
|
await router.replace({
|
||||||
|
query: nextQuery
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => activeTab.value = resolveRouteTab());
|
||||||
|
|
||||||
|
// ---------- Tab 高度 ----------
|
||||||
|
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="less">
|
||||||
|
.dashboard-tab {
|
||||||
|
--dashboard-tabs-height: v-bind(tabsHeight);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
|
||||||
|
.tabs-wrap {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.t-tabs__content) {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.content-wrap {
|
||||||
|
flex: 1;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
padding-top: calc(var(--dashboard-tabs-height) + var(--app-nav-offset));
|
||||||
|
}
|
||||||
|
|
||||||
|
.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);
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-slide-next-enter-from,
|
||||||
|
.panel-slide-prev-leave-to {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateX(50%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-slide-next-leave-to,
|
||||||
|
.panel-slide-prev-enter-from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
37
src/pages/tabs/FileTab.vue
Normal file
37
src/pages/tabs/FileTab.vue
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
<template>
|
||||||
|
<file-explorer-page
|
||||||
|
@open-file="handleOpenFile"
|
||||||
|
@queue-file="handleQueueFile"
|
||||||
|
@play-file="handleOpenFile"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import FileExplorerPage from "@/pages/file/FileExplorerPage.vue";
|
||||||
|
import { isBrowserSupportedAudio } from "@/pages/file/fileAudio.shared";
|
||||||
|
import type { ExplorerItem } from "@/pages/file/fileExplorer.shared";
|
||||||
|
import { useMusicPlayerStore } from "@/store/musicPlayerStore";
|
||||||
|
|
||||||
|
const musicPlayerStore = useMusicPlayerStore();
|
||||||
|
|
||||||
|
function handleOpenFile(item: ExplorerItem): void {
|
||||||
|
console.log("handleOpenFile", item);
|
||||||
|
if (!isBrowserSupportedAudio(item)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
void musicPlayerStore.playFile(item, {
|
||||||
|
title: item.name
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleQueueFile(item: ExplorerItem): void {
|
||||||
|
if (!isBrowserSupportedAudio(item)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
musicPlayerStore.enqueue(item, {
|
||||||
|
title: item.name
|
||||||
|
});
|
||||||
|
}
|
||||||
|
</script>
|
||||||
31
src/pages/tabs/SettingsTab.vue
Normal file
31
src/pages/tabs/SettingsTab.vue
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
<template>
|
||||||
|
<div class="settings-tab">
|
||||||
|
<h2 class="title">Timi Server</h2>
|
||||||
|
<t-cell-group theme="card">
|
||||||
|
<t-cell title="连接配置" arrow @click="router.push('/settings/connect')" />
|
||||||
|
<t-cell title="仪表板" arrow @click="router.push('/settings/dashboard')" />
|
||||||
|
<t-cell title="主题" arrow @click="router.push('/settings/theme')" />
|
||||||
|
</t-cell-group>
|
||||||
|
<copyright icp="粤ICP备2025368555号-1" domain="imyeyu.com" author="夜雨" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { Copyright } from "timi-web";
|
||||||
|
|
||||||
|
defineOptions({
|
||||||
|
name: "SettingsTab"
|
||||||
|
});
|
||||||
|
|
||||||
|
const router = useRouter();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="less">
|
||||||
|
.settings-tab {
|
||||||
|
padding-top: calc(var(--app-nav-offset) + 1rem);
|
||||||
|
|
||||||
|
.title {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
275
src/router/index.ts
Normal file
275
src/router/index.ts
Normal file
@@ -0,0 +1,275 @@
|
|||||||
|
import { createRouter, createWebHistory, type RouteLocationNormalized } from "vue-router";
|
||||||
|
import RootLayout from "@/layout/RootLayout.vue";
|
||||||
|
import MainLayout from "@/layout/MainLayout.vue";
|
||||||
|
import tabs from "@/router/tabs";
|
||||||
|
import { DEFAULT_BODY_BACKGROUND, useGlobalUIStore } from "@/store/globalUIStore";
|
||||||
|
import { useSettingStore } from "@/store/settingStore";
|
||||||
|
import NotFoundPage from "@/pages/system/NotFoundPage.vue";
|
||||||
|
import ServerIndexPage from "@/pages/system/ServerIndexPage.vue";
|
||||||
|
import FileTab from "@/pages/tabs/FileTab.vue";
|
||||||
|
import ConnectSetting from "@/pages/setting/ConnectSetting.vue";
|
||||||
|
import DashboardSetting from "@/pages/setting/DashboardSetting.vue";
|
||||||
|
import ThemeSetting from "@/pages/setting/ThemeSetting.vue";
|
||||||
|
import ServerDetail from "@/pages/dashboard/ServerDashboard/ServerDetail.vue";
|
||||||
|
import ServerPerformanceDetail from "@/pages/dashboard/ServerDashboard/ServerPerformanceDetail.vue";
|
||||||
|
import ServerPartitionsDetail from "@/pages/dashboard/ServerDashboard/ServerPartitionsDetail.vue";
|
||||||
|
import DockerContainerDetail from "@/pages/dashboard/DockerDashboard/DockerContainerDetail.vue";
|
||||||
|
import {
|
||||||
|
hasProgrammaticBackNavigation,
|
||||||
|
clearProgrammaticBackNavigation,
|
||||||
|
markProgrammaticBackNavigation
|
||||||
|
} from "@/utils/backNavigationSignal";
|
||||||
|
import { useTabBarStore } from "@/store/tabBarStore";
|
||||||
|
|
||||||
|
const router = createRouter({
|
||||||
|
history: createWebHistory("/"),
|
||||||
|
scrollBehavior(_to, _from, savedPosition) {
|
||||||
|
if (savedPosition) {
|
||||||
|
return savedPosition;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
left: 0,
|
||||||
|
top: 0
|
||||||
|
};
|
||||||
|
},
|
||||||
|
routes: [
|
||||||
|
{
|
||||||
|
path: "/",
|
||||||
|
name: "RootLayout",
|
||||||
|
component: RootLayout,
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
path: "/server-index",
|
||||||
|
name: "ServerIndexPage",
|
||||||
|
component: ServerIndexPage,
|
||||||
|
meta: {
|
||||||
|
depth: 1,
|
||||||
|
ignoreConnectCheck: true,
|
||||||
|
navBarVisible: false,
|
||||||
|
tabBarVisible: false,
|
||||||
|
bodyBackground: "#FFF"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "/",
|
||||||
|
name: "MainLayout",
|
||||||
|
component: MainLayout,
|
||||||
|
meta: {
|
||||||
|
depth: 2,
|
||||||
|
bodyBackground: "#FFF"
|
||||||
|
},
|
||||||
|
children: [
|
||||||
|
...tabs,
|
||||||
|
{
|
||||||
|
path: "/files/:pathMatch(.*)+",
|
||||||
|
name: "FileExplorerPage",
|
||||||
|
meta: {
|
||||||
|
dynamicDepth: true,
|
||||||
|
baseDepth: 2,
|
||||||
|
navBarVisible: true,
|
||||||
|
navBarCanBack: true,
|
||||||
|
navBarTitle: "文件",
|
||||||
|
tabBarVisible: true,
|
||||||
|
tabBarPadding: false,
|
||||||
|
contentFixedHeight: true,
|
||||||
|
bodyBackground: "#F4F4F4"
|
||||||
|
},
|
||||||
|
component: FileTab
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "/server/system-detail",
|
||||||
|
name: "ServerDetail",
|
||||||
|
meta: {
|
||||||
|
depth: 3,
|
||||||
|
navBarVisible: true,
|
||||||
|
navBarCanBack: true,
|
||||||
|
navBarTitle: "系统详情",
|
||||||
|
tabBarVisible: false,
|
||||||
|
bodyBackground: "#F4F4F4"
|
||||||
|
},
|
||||||
|
component: ServerDetail
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "/server/performance-detail",
|
||||||
|
name: "ServerPerformanceDetail",
|
||||||
|
meta: {
|
||||||
|
depth: 3,
|
||||||
|
navBarVisible: true,
|
||||||
|
navBarCanBack: true,
|
||||||
|
navBarTitle: "资源详情",
|
||||||
|
tabBarVisible: false,
|
||||||
|
bodyBackground: "#F4F4F4"
|
||||||
|
},
|
||||||
|
component: ServerPerformanceDetail
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "/server/partitions-detail",
|
||||||
|
name: "ServerPartitionsDetail",
|
||||||
|
meta: {
|
||||||
|
depth: 3,
|
||||||
|
navBarVisible: true,
|
||||||
|
navBarCanBack: true,
|
||||||
|
navBarTitle: "磁盘详情",
|
||||||
|
tabBarVisible: false,
|
||||||
|
bodyBackground: "#F4F4F4"
|
||||||
|
},
|
||||||
|
component: ServerPartitionsDetail
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "/server/docker-container-detail",
|
||||||
|
name: "DockerContainerDetail",
|
||||||
|
meta: {
|
||||||
|
depth: 3,
|
||||||
|
navBarVisible: true,
|
||||||
|
navBarCanBack: true,
|
||||||
|
navBarTitle: "容器详情",
|
||||||
|
tabBarVisible: false,
|
||||||
|
bodyBackground: "#F4F4F4"
|
||||||
|
},
|
||||||
|
component: DockerContainerDetail
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "/settings/connect",
|
||||||
|
name: "ConnectSetting",
|
||||||
|
meta: {
|
||||||
|
depth: 3,
|
||||||
|
navBarVisible: true,
|
||||||
|
navBarCanBack: true,
|
||||||
|
navBarTitle: "连接配置",
|
||||||
|
tabBarVisible: false,
|
||||||
|
bodyBackground: "#F4F4F4"
|
||||||
|
},
|
||||||
|
component: ConnectSetting
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "/settings/dashboard",
|
||||||
|
name: "DashboardSetting",
|
||||||
|
meta: {
|
||||||
|
depth: 3,
|
||||||
|
navBarVisible: true,
|
||||||
|
navBarCanBack: true,
|
||||||
|
navBarTitle: "仪表板设置",
|
||||||
|
tabBarVisible: false,
|
||||||
|
bodyBackground: "#F4F4F4"
|
||||||
|
},
|
||||||
|
component: DashboardSetting
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "/settings/theme",
|
||||||
|
name: "ThemeSetting",
|
||||||
|
meta: {
|
||||||
|
depth: 3,
|
||||||
|
navBarVisible: true,
|
||||||
|
navBarCanBack: true,
|
||||||
|
navBarTitle: "主题设置",
|
||||||
|
tabBarVisible: false,
|
||||||
|
bodyBackground: "#F4F4F4"
|
||||||
|
},
|
||||||
|
component: ThemeSetting
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "/:pathMatch(.*)*",
|
||||||
|
name: "NotFoundPage",
|
||||||
|
meta: {
|
||||||
|
depth: 1,
|
||||||
|
ignoreConnectCheck: true,
|
||||||
|
navBarVisible: false,
|
||||||
|
tabBarVisible: false
|
||||||
|
},
|
||||||
|
component: NotFoundPage
|
||||||
|
}
|
||||||
|
]
|
||||||
|
});
|
||||||
|
|
||||||
|
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 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) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (settingStore.hasConnectConfig) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
name: "ServerIndexPage",
|
||||||
|
query: {
|
||||||
|
redirect: to.fullPath
|
||||||
|
}
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
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 targetBackground = typeof to.meta.bodyBackground === "string" ? to.meta.bodyBackground : DEFAULT_BODY_BACKGROUND;
|
||||||
|
globalUIStore.setBodyBackground(targetBackground);
|
||||||
|
});
|
||||||
|
|
||||||
|
export default router;
|
||||||
52
src/router/tabs.ts
Normal file
52
src/router/tabs.ts
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
import type { RouteRecordRaw } from "vue-router";
|
||||||
|
import FileTab from "@/pages/tabs/FileTab.vue";
|
||||||
|
import DashboardTab from "@/pages/tabs/DashboardTab.vue";
|
||||||
|
import SettingsTab from "@/pages/tabs/SettingsTab.vue";
|
||||||
|
|
||||||
|
const tabs: RouteRecordRaw[] = [
|
||||||
|
{
|
||||||
|
path: "/",
|
||||||
|
name: "FileTab",
|
||||||
|
meta: {
|
||||||
|
depth: 2,
|
||||||
|
navBarVisible: true,
|
||||||
|
navBarTitle: "文件",
|
||||||
|
tabBarVisible: true,
|
||||||
|
tabBarRoot: true,
|
||||||
|
tabBarPadding: false,
|
||||||
|
contentFixedHeight: true,
|
||||||
|
bodyBackground: "#F4F4F4"
|
||||||
|
},
|
||||||
|
component: FileTab
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "/server",
|
||||||
|
name: "DashboardTab",
|
||||||
|
meta: {
|
||||||
|
depth: 2,
|
||||||
|
navBarVisible: true,
|
||||||
|
navBarTitle: "状态",
|
||||||
|
tabBarVisible: true,
|
||||||
|
tabBarRoot: true,
|
||||||
|
tabBarPadding: true,
|
||||||
|
bodyBackground: "#F4F4F4"
|
||||||
|
},
|
||||||
|
component: DashboardTab
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "/settings",
|
||||||
|
name: "SettingsTab",
|
||||||
|
meta: {
|
||||||
|
depth: 2,
|
||||||
|
navBarVisible: true,
|
||||||
|
navBarTitle: "设置",
|
||||||
|
tabBarVisible: true,
|
||||||
|
tabBarRoot: true,
|
||||||
|
tabBarPadding: true,
|
||||||
|
bodyBackground: "#F4F4F4"
|
||||||
|
},
|
||||||
|
component: SettingsTab
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
export default tabs;
|
||||||
34
src/store/globalUIStore.ts
Normal file
34
src/store/globalUIStore.ts
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
import { defineStore } from "pinia";
|
||||||
|
|
||||||
|
export const DEFAULT_BODY_BACKGROUND = "var(--app-bg)";
|
||||||
|
|
||||||
|
export type ThemeMode = "light" | "dark" | "system";
|
||||||
|
|
||||||
|
export const useGlobalUIStore = defineStore("global-ui", () => {
|
||||||
|
const bodyBackground = ref<string>(DEFAULT_BODY_BACKGROUND);
|
||||||
|
const themeMode = ref<ThemeMode>("system");
|
||||||
|
|
||||||
|
const themeClass = computed(() => {
|
||||||
|
if (themeMode.value === "system") {
|
||||||
|
return window.matchMedia("(prefers-color-scheme: dark)").matches ? "theme-dark" : "theme-light";
|
||||||
|
}
|
||||||
|
|
||||||
|
return themeMode.value === "dark" ? "theme-dark" : "theme-light";
|
||||||
|
});
|
||||||
|
|
||||||
|
function setBodyBackground(color?: string): void {
|
||||||
|
bodyBackground.value = color || DEFAULT_BODY_BACKGROUND;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setThemeMode(mode: ThemeMode): void {
|
||||||
|
themeMode.value = mode;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
bodyBackground,
|
||||||
|
themeMode,
|
||||||
|
themeClass,
|
||||||
|
setBodyBackground,
|
||||||
|
setThemeMode
|
||||||
|
};
|
||||||
|
});
|
||||||
566
src/store/musicPlayerStore.ts
Normal file
566
src/store/musicPlayerStore.ts
Normal file
@@ -0,0 +1,566 @@
|
|||||||
|
import { defineStore } from "pinia";
|
||||||
|
import { parseBlob } from "music-metadata-browser";
|
||||||
|
import { Toast } from "tdesign-mobile-vue";
|
||||||
|
import { isBrowserSupportedAudio } from "@/pages/file/fileAudio.shared";
|
||||||
|
import type { ExplorerItem } from "@/pages/file/fileExplorer.shared";
|
||||||
|
import Storage from "@/utils/Storage";
|
||||||
|
import FileAPI from "@/api/FileAPI.ts";
|
||||||
|
|
||||||
|
const MUSIC_PLAYER_STORAGE_KEY = "timi-server.music-player";
|
||||||
|
|
||||||
|
export type MusicPlayMode = "single-loop" | "queue-loop" | "order";
|
||||||
|
|
||||||
|
export interface MusicSongInfo {
|
||||||
|
cover: string;
|
||||||
|
title: string;
|
||||||
|
artist: string;
|
||||||
|
album: string;
|
||||||
|
duration: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MusicQueueItem {
|
||||||
|
id: string;
|
||||||
|
file: ExplorerItem;
|
||||||
|
song: MusicSongInfo;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MusicPlayerState {
|
||||||
|
playMode: MusicPlayMode;
|
||||||
|
queue: MusicQueueItem[];
|
||||||
|
currentIndex: number;
|
||||||
|
isPlaying: boolean;
|
||||||
|
volume: number;
|
||||||
|
popupVisible: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const defaultSongInfo = (): MusicSongInfo => ({
|
||||||
|
cover: "",
|
||||||
|
title: "",
|
||||||
|
artist: "未知艺术家",
|
||||||
|
album: "",
|
||||||
|
duration: 0
|
||||||
|
});
|
||||||
|
|
||||||
|
const defaultState = (): MusicPlayerState => ({
|
||||||
|
playMode: "queue-loop",
|
||||||
|
queue: [],
|
||||||
|
currentIndex: -1,
|
||||||
|
isPlaying: false,
|
||||||
|
volume: .8,
|
||||||
|
popupVisible: false
|
||||||
|
});
|
||||||
|
|
||||||
|
const metadataLoadedMap = new Set<string>();
|
||||||
|
const coverURLMap = new Map<string, string>();
|
||||||
|
let playerAudio: HTMLAudioElement | undefined;
|
||||||
|
|
||||||
|
function normalizeSongInfo(song?: Partial<MusicSongInfo>, fallbackTitle = ""): MusicSongInfo {
|
||||||
|
return {
|
||||||
|
cover: song?.cover?.trim() || "",
|
||||||
|
title: song?.title?.trim() || fallbackTitle,
|
||||||
|
artist: song?.artist?.trim() || "未知艺术家",
|
||||||
|
album: song?.album?.trim() || "",
|
||||||
|
duration: typeof song?.duration === "number" && !Number.isNaN(song.duration) && 0 <= song.duration ? song.duration : 0
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeVolume(volume?: number): number {
|
||||||
|
if (typeof volume !== "number" || Number.isNaN(volume)) {
|
||||||
|
return .8;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (volume < 0) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (1 < volume) {
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
return volume;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildQueueId(file: ExplorerItem): string {
|
||||||
|
return file.path || file.name;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeState(source?: Partial<MusicPlayerState>): MusicPlayerState {
|
||||||
|
const queue = Array.isArray(source?.queue)
|
||||||
|
? source.queue
|
||||||
|
.filter((item) => !!item?.file)
|
||||||
|
.map((item) => ({
|
||||||
|
id: item.id || buildQueueId(item.file),
|
||||||
|
file: item.file,
|
||||||
|
song: normalizeSongInfo(item.song, item.file.name)
|
||||||
|
}))
|
||||||
|
: [];
|
||||||
|
const currentIndex = queue.length && typeof source?.currentIndex === "number"
|
||||||
|
? Math.min(Math.max(source.currentIndex, 0), queue.length - 1)
|
||||||
|
: queue.length ? 0 : -1;
|
||||||
|
|
||||||
|
return {
|
||||||
|
playMode: source?.playMode === "single-loop" || source?.playMode === "order" ? source.playMode : "queue-loop",
|
||||||
|
queue,
|
||||||
|
currentIndex,
|
||||||
|
isPlaying: !!queue.length && !!source?.isPlaying,
|
||||||
|
volume: normalizeVolume(source?.volume),
|
||||||
|
popupVisible: !!queue.length && !!source?.popupVisible
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useMusicPlayerStore = defineStore("music-player", () => {
|
||||||
|
const state = ref<MusicPlayerState>(normalizeState(Storage.getDefault<MusicPlayerState>(MUSIC_PLAYER_STORAGE_KEY, defaultState())));
|
||||||
|
|
||||||
|
const playMode = computed(() => state.value.playMode);
|
||||||
|
const queue = computed(() => state.value.queue);
|
||||||
|
const isPlaying = computed(() => state.value.isPlaying);
|
||||||
|
const volume = computed(() => state.value.volume);
|
||||||
|
const popupVisible = computed(() => state.value.popupVisible);
|
||||||
|
const hasQueue = computed(() => 0 < state.value.queue.length);
|
||||||
|
const currentIndex = computed(() => state.value.currentIndex);
|
||||||
|
const currentQueueItem = computed(() => {
|
||||||
|
if (state.value.currentIndex < 0) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
return state.value.queue[state.value.currentIndex];
|
||||||
|
});
|
||||||
|
const currentFile = computed(() => currentQueueItem.value?.file);
|
||||||
|
const currentSong = computed(() => currentQueueItem.value?.song || defaultSongInfo());
|
||||||
|
const playModeLabel = computed(() => {
|
||||||
|
switch (state.value.playMode) {
|
||||||
|
case "single-loop":
|
||||||
|
return "单曲循环";
|
||||||
|
case "order":
|
||||||
|
return "顺序播放";
|
||||||
|
default:
|
||||||
|
return "队列循环";
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function persist(): void {
|
||||||
|
Storage.setObject(MUSIC_PLAYER_STORAGE_KEY, state.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureAudio(): HTMLAudioElement | undefined {
|
||||||
|
if (typeof window === "undefined") {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (playerAudio) {
|
||||||
|
return playerAudio;
|
||||||
|
}
|
||||||
|
|
||||||
|
playerAudio = new Audio();
|
||||||
|
playerAudio.preload = "metadata";
|
||||||
|
playerAudio.volume = state.value.volume;
|
||||||
|
playerAudio.addEventListener("ended", () => {
|
||||||
|
handleTrackEnded();
|
||||||
|
});
|
||||||
|
playerAudio.addEventListener("loadedmetadata", () => {
|
||||||
|
const item = currentQueueItem.value;
|
||||||
|
if (!item || !playerAudio || !Number.isFinite(playerAudio.duration)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
item.song.duration = Math.max(playerAudio.duration, 0);
|
||||||
|
persist();
|
||||||
|
});
|
||||||
|
playerAudio.addEventListener("play", () => {
|
||||||
|
state.value.isPlaying = true;
|
||||||
|
persist();
|
||||||
|
});
|
||||||
|
playerAudio.addEventListener("pause", () => {
|
||||||
|
state.value.isPlaying = false;
|
||||||
|
persist();
|
||||||
|
});
|
||||||
|
playerAudio.addEventListener("volumechange", () => {
|
||||||
|
if (!playerAudio) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
state.value.volume = normalizeVolume(playerAudio.volume);
|
||||||
|
persist();
|
||||||
|
});
|
||||||
|
playerAudio.addEventListener("error", () => {
|
||||||
|
state.value.isPlaying = false;
|
||||||
|
persist();
|
||||||
|
Toast({
|
||||||
|
theme: "error",
|
||||||
|
message: "音频播放失败"
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return playerAudio;
|
||||||
|
}
|
||||||
|
|
||||||
|
function releaseCoverURL(id: string): void {
|
||||||
|
const coverURL = coverURLMap.get(id);
|
||||||
|
if (!coverURL) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
URL.revokeObjectURL(coverURL);
|
||||||
|
coverURLMap.delete(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncAudioSource(): void {
|
||||||
|
const audio = ensureAudio();
|
||||||
|
const item = currentQueueItem.value;
|
||||||
|
|
||||||
|
if (!audio || !item) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const targetURL = FileAPI.buildURL(item.file.path, "download");
|
||||||
|
if (audio.src !== targetURL) {
|
||||||
|
audio.src = targetURL;
|
||||||
|
audio.load();
|
||||||
|
}
|
||||||
|
|
||||||
|
void hydrateSongInfo(item);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function playCurrent(): Promise<void> {
|
||||||
|
const audio = ensureAudio();
|
||||||
|
if (!audio || !currentQueueItem.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
syncAudioSource();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await audio.play();
|
||||||
|
state.value.isPlaying = true;
|
||||||
|
persist();
|
||||||
|
} catch (error) {
|
||||||
|
state.value.isPlaying = false;
|
||||||
|
persist();
|
||||||
|
console.warn(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function pauseCurrent(): void {
|
||||||
|
const audio = ensureAudio();
|
||||||
|
if (!audio) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
audio.pause();
|
||||||
|
state.value.isPlaying = false;
|
||||||
|
persist();
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleTrackEnded(): void {
|
||||||
|
if (!state.value.queue.length) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state.value.playMode === "single-loop") {
|
||||||
|
const audio = ensureAudio();
|
||||||
|
if (audio) {
|
||||||
|
audio.currentTime = 0;
|
||||||
|
}
|
||||||
|
void playCurrent();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
playNext();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function hydrateSongInfo(item: MusicQueueItem): Promise<void> {
|
||||||
|
if (metadataLoadedMap.has(item.id)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
metadataLoadedMap.add(item.id);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(FileAPI.buildURL(item.file.path, "download"));
|
||||||
|
if (!response.ok) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const blob = await response.blob();
|
||||||
|
const metadata = await parseBlob(blob);
|
||||||
|
const common = metadata.common;
|
||||||
|
const cover = common.picture?.[0];
|
||||||
|
let coverURL = item.song.cover;
|
||||||
|
|
||||||
|
if (cover?.data) {
|
||||||
|
releaseCoverURL(item.id);
|
||||||
|
coverURL = URL.createObjectURL(new Blob([cover.data], { type: cover.format || "image/jpeg" }));
|
||||||
|
coverURLMap.set(item.id, coverURL);
|
||||||
|
}
|
||||||
|
|
||||||
|
item.song = normalizeSongInfo({
|
||||||
|
cover: coverURL,
|
||||||
|
title: common.title || item.song.title || item.file.name,
|
||||||
|
artist: common.artist || item.song.artist,
|
||||||
|
album: common.album || item.song.album,
|
||||||
|
duration: metadata.format.duration || item.song.duration
|
||||||
|
}, item.file.name);
|
||||||
|
persist();
|
||||||
|
} catch (error) {
|
||||||
|
console.warn(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function upsertQueueItem(file: ExplorerItem, song?: Partial<MusicSongInfo>, activate = false): number {
|
||||||
|
const queueId = buildQueueId(file);
|
||||||
|
const targetIndex = state.value.queue.findIndex((item) => item.id === queueId);
|
||||||
|
|
||||||
|
if (targetIndex < 0) {
|
||||||
|
state.value.queue.push({
|
||||||
|
id: queueId,
|
||||||
|
file,
|
||||||
|
song: normalizeSongInfo(song, file.name)
|
||||||
|
});
|
||||||
|
|
||||||
|
if (activate || state.value.currentIndex < 0) {
|
||||||
|
state.value.currentIndex = state.value.queue.length - 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
return state.value.queue.length - 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentItem = state.value.queue[targetIndex];
|
||||||
|
state.value.queue[targetIndex] = {
|
||||||
|
...currentItem,
|
||||||
|
file,
|
||||||
|
song: normalizeSongInfo(song || currentItem.song, file.name)
|
||||||
|
};
|
||||||
|
|
||||||
|
if (activate) {
|
||||||
|
state.value.currentIndex = targetIndex;
|
||||||
|
}
|
||||||
|
|
||||||
|
return targetIndex;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setPlayMode(mode: MusicPlayMode): void {
|
||||||
|
state.value.playMode = mode;
|
||||||
|
persist();
|
||||||
|
}
|
||||||
|
|
||||||
|
function cyclePlayMode(): void {
|
||||||
|
if (state.value.playMode === "single-loop") {
|
||||||
|
state.value.playMode = "queue-loop";
|
||||||
|
} else if (state.value.playMode === "queue-loop") {
|
||||||
|
state.value.playMode = "order";
|
||||||
|
} else {
|
||||||
|
state.value.playMode = "single-loop";
|
||||||
|
}
|
||||||
|
persist();
|
||||||
|
}
|
||||||
|
|
||||||
|
function setVolume(nextVolume: number): void {
|
||||||
|
state.value.volume = normalizeVolume(nextVolume);
|
||||||
|
const audio = ensureAudio();
|
||||||
|
if (audio) {
|
||||||
|
audio.volume = state.value.volume;
|
||||||
|
}
|
||||||
|
persist();
|
||||||
|
}
|
||||||
|
|
||||||
|
function setPopupVisible(visible: boolean): void {
|
||||||
|
state.value.popupVisible = visible;
|
||||||
|
persist();
|
||||||
|
}
|
||||||
|
|
||||||
|
function setPlaying(playing: boolean): void {
|
||||||
|
if (playing) {
|
||||||
|
void playCurrent();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
pauseCurrent();
|
||||||
|
}
|
||||||
|
|
||||||
|
function togglePlaying(): void {
|
||||||
|
if (!state.value.queue.length) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state.value.isPlaying) {
|
||||||
|
pauseCurrent();
|
||||||
|
} else {
|
||||||
|
void playCurrent();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function setCurrentIndex(index: number): void {
|
||||||
|
if (index < 0 || state.value.queue.length <= index) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
state.value.currentIndex = index;
|
||||||
|
syncAudioSource();
|
||||||
|
persist();
|
||||||
|
}
|
||||||
|
|
||||||
|
function setQueue(queueItems: MusicQueueItem[], nextIndex = 0): void {
|
||||||
|
state.value.queue = queueItems;
|
||||||
|
state.value.currentIndex = queueItems.length ? Math.min(Math.max(nextIndex, 0), queueItems.length - 1) : -1;
|
||||||
|
state.value.isPlaying = false;
|
||||||
|
syncAudioSource();
|
||||||
|
persist();
|
||||||
|
}
|
||||||
|
|
||||||
|
function addToQueue(file: ExplorerItem, song?: Partial<MusicSongInfo>): void {
|
||||||
|
enqueue(file, song);
|
||||||
|
}
|
||||||
|
|
||||||
|
function enqueue(file: ExplorerItem, song?: Partial<MusicSongInfo>): void {
|
||||||
|
if (!isBrowserSupportedAudio(file)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
upsertQueueItem(file, song, false);
|
||||||
|
persist();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function playFile(file: ExplorerItem, song?: Partial<MusicSongInfo>): Promise<void> {
|
||||||
|
if (!isBrowserSupportedAudio(file)) {
|
||||||
|
Toast({
|
||||||
|
theme: "warning",
|
||||||
|
message: "当前浏览器不支持这个音频格式"
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
upsertQueueItem(file, song, true);
|
||||||
|
state.value.popupVisible = true;
|
||||||
|
syncAudioSource();
|
||||||
|
persist();
|
||||||
|
await playCurrent();
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeFromQueue(id: string): void {
|
||||||
|
const targetIndex = state.value.queue.findIndex((item) => item.id === id);
|
||||||
|
if (targetIndex < 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const wasPlayingCurrent = state.value.isPlaying && state.value.currentIndex === targetIndex;
|
||||||
|
releaseCoverURL(id);
|
||||||
|
metadataLoadedMap.delete(id);
|
||||||
|
state.value.queue.splice(targetIndex, 1);
|
||||||
|
|
||||||
|
if (!state.value.queue.length) {
|
||||||
|
clearQueue();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state.value.currentIndex === targetIndex) {
|
||||||
|
state.value.currentIndex = Math.min(targetIndex, state.value.queue.length - 1);
|
||||||
|
} else if (targetIndex < state.value.currentIndex) {
|
||||||
|
state.value.currentIndex -= 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
syncAudioSource();
|
||||||
|
|
||||||
|
if (wasPlayingCurrent) {
|
||||||
|
void playCurrent();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
persist();
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearQueue(): void {
|
||||||
|
const audio = ensureAudio();
|
||||||
|
if (audio) {
|
||||||
|
audio.pause();
|
||||||
|
audio.removeAttribute("src");
|
||||||
|
audio.load();
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const item of state.value.queue) {
|
||||||
|
releaseCoverURL(item.id);
|
||||||
|
metadataLoadedMap.delete(item.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
state.value.queue = [];
|
||||||
|
state.value.currentIndex = -1;
|
||||||
|
state.value.isPlaying = false;
|
||||||
|
state.value.popupVisible = false;
|
||||||
|
persist();
|
||||||
|
}
|
||||||
|
|
||||||
|
function playPrev(): void {
|
||||||
|
if (!state.value.queue.length) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state.value.playMode === "single-loop") {
|
||||||
|
void playCurrent();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (0 < state.value.currentIndex) {
|
||||||
|
state.value.currentIndex -= 1;
|
||||||
|
} else if (state.value.playMode === "queue-loop") {
|
||||||
|
state.value.currentIndex = state.value.queue.length - 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
syncAudioSource();
|
||||||
|
void playCurrent();
|
||||||
|
}
|
||||||
|
|
||||||
|
function playNext(): void {
|
||||||
|
if (!state.value.queue.length) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state.value.playMode === "single-loop") {
|
||||||
|
void playCurrent();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state.value.currentIndex < state.value.queue.length - 1) {
|
||||||
|
state.value.currentIndex += 1;
|
||||||
|
syncAudioSource();
|
||||||
|
void playCurrent();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state.value.playMode === "queue-loop") {
|
||||||
|
state.value.currentIndex = 0;
|
||||||
|
syncAudioSource();
|
||||||
|
void playCurrent();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
pauseCurrent();
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
playMode,
|
||||||
|
queue,
|
||||||
|
isPlaying,
|
||||||
|
volume,
|
||||||
|
popupVisible,
|
||||||
|
hasQueue,
|
||||||
|
currentIndex,
|
||||||
|
currentQueueItem,
|
||||||
|
currentFile,
|
||||||
|
currentSong,
|
||||||
|
playModeLabel,
|
||||||
|
setPlayMode,
|
||||||
|
cyclePlayMode,
|
||||||
|
setVolume,
|
||||||
|
setPopupVisible,
|
||||||
|
setPlaying,
|
||||||
|
togglePlaying,
|
||||||
|
setCurrentIndex,
|
||||||
|
setQueue,
|
||||||
|
addToQueue,
|
||||||
|
enqueue,
|
||||||
|
playFile,
|
||||||
|
removeFromQueue,
|
||||||
|
clearQueue,
|
||||||
|
playPrev,
|
||||||
|
playNext
|
||||||
|
};
|
||||||
|
});
|
||||||
83
src/store/navBarStore.ts
Normal file
83
src/store/navBarStore.ts
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
import type { Component } from "vue";
|
||||||
|
import { defineStore } from "pinia";
|
||||||
|
|
||||||
|
export const useNavBarStore = defineStore("nav-bar", () => {
|
||||||
|
const route = useRoute();
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const height = ref(0);
|
||||||
|
const title = ref("");
|
||||||
|
const backTo = ref<string>();
|
||||||
|
const leftRenderer = shallowRef<Component>();
|
||||||
|
const leftOwner = ref<string>();
|
||||||
|
const rightRenderer = shallowRef<Component>();
|
||||||
|
const rightOwner = ref<string>();
|
||||||
|
|
||||||
|
const isShowing = computed(() => !!router.currentRoute.value.meta.navBarVisible);
|
||||||
|
const canBack = computed(() => !!router.currentRoute.value.meta.navBarCanBack);
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => route.meta.navBarTitle,
|
||||||
|
(value) => {
|
||||||
|
title.value = typeof value === "string" ? value : "";
|
||||||
|
},
|
||||||
|
{ immediate: true }
|
||||||
|
);
|
||||||
|
|
||||||
|
function setHeight(value: number): void {
|
||||||
|
height.value = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setBackTo(path?: string): void {
|
||||||
|
backTo.value = path;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setTitle(value?: string): void {
|
||||||
|
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 {
|
||||||
|
rightRenderer.value = renderer;
|
||||||
|
rightOwner.value = owner;
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearRight(owner?: string): void {
|
||||||
|
if (owner && rightOwner.value !== owner) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
rightRenderer.value = undefined;
|
||||||
|
rightOwner.value = undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
height,
|
||||||
|
title,
|
||||||
|
backTo,
|
||||||
|
leftRenderer,
|
||||||
|
rightRenderer,
|
||||||
|
isShowing,
|
||||||
|
canBack,
|
||||||
|
setHeight,
|
||||||
|
setBackTo,
|
||||||
|
setTitle,
|
||||||
|
setLeftRenderer,
|
||||||
|
clearLeft,
|
||||||
|
setRightRenderer,
|
||||||
|
clearRight
|
||||||
|
};
|
||||||
|
});
|
||||||
142
src/store/settingStore.ts
Normal file
142
src/store/settingStore.ts
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
import { defineStore } from "pinia";
|
||||||
|
import Storage from "@/utils/Storage";
|
||||||
|
|
||||||
|
const SETTING_STORAGE_KEY = "timi-server.setting";
|
||||||
|
|
||||||
|
export type ConnectProtocol = "http" | "https";
|
||||||
|
|
||||||
|
export interface ConnectSetting {
|
||||||
|
protocol: ConnectProtocol;
|
||||||
|
host: string;
|
||||||
|
port: string;
|
||||||
|
token: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ServerDashboardSetting {
|
||||||
|
snapshotRefreshSeconds: number;
|
||||||
|
historyRefreshSeconds: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DashboardSetting {
|
||||||
|
server: ServerDashboardSetting;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SettingState {
|
||||||
|
connect: ConnectSetting;
|
||||||
|
dashboard: DashboardSetting;
|
||||||
|
}
|
||||||
|
|
||||||
|
const defaultConnectSetting = (): ConnectSetting => ({
|
||||||
|
protocol: "http",
|
||||||
|
host: "",
|
||||||
|
port: "",
|
||||||
|
token: ""
|
||||||
|
});
|
||||||
|
|
||||||
|
const defaultServerDashboardSetting = (): ServerDashboardSetting => ({
|
||||||
|
snapshotRefreshSeconds: 3,
|
||||||
|
historyRefreshSeconds: 10
|
||||||
|
});
|
||||||
|
|
||||||
|
const defaultDashboardSetting = (): DashboardSetting => ({
|
||||||
|
server: defaultServerDashboardSetting()
|
||||||
|
});
|
||||||
|
|
||||||
|
const defaultSettingState = (): SettingState => ({
|
||||||
|
connect: defaultConnectSetting(),
|
||||||
|
dashboard: defaultDashboardSetting()
|
||||||
|
});
|
||||||
|
|
||||||
|
function normalizeConnectSetting(connect?: Partial<ConnectSetting>): ConnectSetting {
|
||||||
|
const protocol = connect?.protocol === "https" ? "https" : "http";
|
||||||
|
|
||||||
|
return {
|
||||||
|
protocol,
|
||||||
|
host: connect?.host?.trim() || "",
|
||||||
|
port: connect?.port?.trim() || "",
|
||||||
|
token: connect?.token?.trim() || ""
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeRefreshSeconds(value?: number, fallback = 3): number {
|
||||||
|
if (typeof value !== "number" || Number.isNaN(value)) {
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Math.min(Math.max(Math.floor(value), 1), 120);
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeServerDashboardSetting(setting?: Partial<ServerDashboardSetting>): ServerDashboardSetting {
|
||||||
|
const fallback = defaultServerDashboardSetting();
|
||||||
|
return {
|
||||||
|
snapshotRefreshSeconds: normalizeRefreshSeconds(setting?.snapshotRefreshSeconds, fallback.snapshotRefreshSeconds),
|
||||||
|
historyRefreshSeconds: normalizeRefreshSeconds(setting?.historyRefreshSeconds, fallback.historyRefreshSeconds)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeDashboardSetting(setting?: Partial<DashboardSetting>): DashboardSetting {
|
||||||
|
return {
|
||||||
|
server: normalizeServerDashboardSetting(setting?.server)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useSettingStore = defineStore("setting", () => {
|
||||||
|
const state = ref<SettingState>(Storage.getDefault<SettingState>(SETTING_STORAGE_KEY, defaultSettingState()));
|
||||||
|
state.value.connect = normalizeConnectSetting(state.value.connect);
|
||||||
|
state.value.dashboard = normalizeDashboardSetting(state.value.dashboard);
|
||||||
|
persist();
|
||||||
|
|
||||||
|
const connect = computed(() => state.value.connect);
|
||||||
|
const dashboard = computed(() => state.value.dashboard);
|
||||||
|
const hasConnectConfig = computed(() => {
|
||||||
|
const currentConnect = state.value.connect;
|
||||||
|
return !!currentConnect.host && !!currentConnect.port && !!currentConnect.token;
|
||||||
|
});
|
||||||
|
|
||||||
|
function persist(): void {
|
||||||
|
Storage.setObject(SETTING_STORAGE_KEY, state.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function setConnect(connectSetting: Partial<ConnectSetting>): void {
|
||||||
|
state.value.connect = normalizeConnectSetting({
|
||||||
|
...state.value.connect,
|
||||||
|
...connectSetting
|
||||||
|
});
|
||||||
|
persist();
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetConnect(): void {
|
||||||
|
state.value.connect = defaultConnectSetting();
|
||||||
|
persist();
|
||||||
|
}
|
||||||
|
|
||||||
|
function setServerDashboard(serverSetting: Partial<ServerDashboardSetting>): void {
|
||||||
|
state.value.dashboard.server = normalizeServerDashboardSetting({
|
||||||
|
...state.value.dashboard.server,
|
||||||
|
...serverSetting
|
||||||
|
});
|
||||||
|
persist();
|
||||||
|
}
|
||||||
|
|
||||||
|
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(/\/+$/, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
connect,
|
||||||
|
dashboard,
|
||||||
|
hasConnectConfig,
|
||||||
|
setConnect,
|
||||||
|
resetConnect,
|
||||||
|
setServerDashboard,
|
||||||
|
resolveBaseURL
|
||||||
|
};
|
||||||
|
});
|
||||||
24
src/store/tabBarStore.ts
Normal file
24
src/store/tabBarStore.ts
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import { defineStore } from "pinia";
|
||||||
|
|
||||||
|
export const useTabBarStore = defineStore("tab-bar", () => {
|
||||||
|
const router = useRouter();
|
||||||
|
// 仅用于“当前这一次”禁用 tabbar 过渡,导航结束后会恢复
|
||||||
|
const shouldSkipTransition = ref(false);
|
||||||
|
|
||||||
|
const isShowing = computed(() => !!router.currentRoute.value.meta.tabBarVisible);
|
||||||
|
|
||||||
|
function skipTransitionOnce(): void {
|
||||||
|
shouldSkipTransition.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function restoreTransition(): void {
|
||||||
|
shouldSkipTransition.value = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
isShowing,
|
||||||
|
shouldSkipTransition,
|
||||||
|
skipTransitionOnce,
|
||||||
|
restoreTransition
|
||||||
|
};
|
||||||
|
});
|
||||||
63
src/types/Docker.ts
Normal file
63
src/types/Docker.ts
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
export interface DockerContainerSummaryView {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
image: string;
|
||||||
|
state: string;
|
||||||
|
status: string;
|
||||||
|
healthStatus?: string;
|
||||||
|
cpuPercent?: number;
|
||||||
|
memoryUsageBytes?: number;
|
||||||
|
memoryLimitBytes?: number;
|
||||||
|
memoryPercent?: number;
|
||||||
|
networkRxBytes?: number;
|
||||||
|
networkTxBytes?: number;
|
||||||
|
updatedAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DockerContainerStatusView {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
image: string;
|
||||||
|
imageId: string;
|
||||||
|
createdAt: number;
|
||||||
|
state: string;
|
||||||
|
status: string;
|
||||||
|
healthStatus?: string;
|
||||||
|
startedAt?: string;
|
||||||
|
finishedAt?: string;
|
||||||
|
exitCode?: number;
|
||||||
|
restartCount: number;
|
||||||
|
oomKilled: boolean;
|
||||||
|
cpuPercent?: number;
|
||||||
|
memoryUsageBytes?: number;
|
||||||
|
memoryLimitBytes?: number;
|
||||||
|
memoryPercent?: number;
|
||||||
|
networkRxBytes?: number;
|
||||||
|
networkTxBytes?: number;
|
||||||
|
blockReadBytes?: number;
|
||||||
|
blockWriteBytes?: number;
|
||||||
|
pids?: number;
|
||||||
|
updatedAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DockerContainerHistoryView {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
serverTime: number;
|
||||||
|
sampleRateMs: number;
|
||||||
|
from: number;
|
||||||
|
to: number;
|
||||||
|
points: DockerContainerHistoryPointView[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DockerContainerHistoryPointView {
|
||||||
|
at: number;
|
||||||
|
cpuPercent?: number;
|
||||||
|
memoryUsageBytes?: number;
|
||||||
|
memoryPercent?: number;
|
||||||
|
networkRxBytes?: number;
|
||||||
|
networkTxBytes?: number;
|
||||||
|
blockReadBytes?: number;
|
||||||
|
blockWriteBytes?: number;
|
||||||
|
pids?: number;
|
||||||
|
}
|
||||||
12
src/types/File.ts
Normal file
12
src/types/File.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
export interface ServerFile {
|
||||||
|
name: string;
|
||||||
|
extension?: string;
|
||||||
|
absolutePath?: string;
|
||||||
|
size?: number;
|
||||||
|
modifiedAt?: number;
|
||||||
|
type?: string;
|
||||||
|
isFile?: boolean;
|
||||||
|
isDirectory?: boolean;
|
||||||
|
canPreview?: boolean;
|
||||||
|
previewURI?: string;
|
||||||
|
}
|
||||||
108
src/types/System.ts
Normal file
108
src/types/System.ts
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
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;
|
||||||
|
};
|
||||||
|
cpu?: {
|
||||||
|
model: string;
|
||||||
|
physicalCores: number;
|
||||||
|
logicalCores: number;
|
||||||
|
usageTotal: number;
|
||||||
|
usageSystem: number;
|
||||||
|
temperatureCelsius?: number;
|
||||||
|
};
|
||||||
|
memory?: {
|
||||||
|
totalBytes: number;
|
||||||
|
usedBytes: number;
|
||||||
|
swapTotalBytes: number;
|
||||||
|
swapUsedBytes: number;
|
||||||
|
};
|
||||||
|
jvm?: {
|
||||||
|
name: string;
|
||||||
|
version: string;
|
||||||
|
bootAt: number;
|
||||||
|
heapInitBytes: number;
|
||||||
|
heapMaxBytes: number;
|
||||||
|
heapUsedBytes: number;
|
||||||
|
heapCommittedBytes: number;
|
||||||
|
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;
|
||||||
|
};
|
||||||
|
hardware?: {
|
||||||
|
fanSpeeds?: Array<number>;
|
||||||
|
baseboard?: {
|
||||||
|
manufacturer?: string;
|
||||||
|
model?: string;
|
||||||
|
version?: string;
|
||||||
|
serialNumber?: string;
|
||||||
|
};
|
||||||
|
firmware?: {
|
||||||
|
manufacturer?: string;
|
||||||
|
name?: string;
|
||||||
|
description?: string;
|
||||||
|
version?: string;
|
||||||
|
releaseDate?: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
storagePartitions?: Array<{
|
||||||
|
diskName?: string;
|
||||||
|
diskModel?: string;
|
||||||
|
diskSerial?: string;
|
||||||
|
partitionId: string;
|
||||||
|
partitionName?: string;
|
||||||
|
partitionType?: string;
|
||||||
|
uuid: string;
|
||||||
|
mountPoint: string;
|
||||||
|
total: number;
|
||||||
|
used: number;
|
||||||
|
transferTimeMs?: number;
|
||||||
|
healthStatus?: string;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SystemStatusHistoryPoint {
|
||||||
|
at: number;
|
||||||
|
cpuUsagePercent: number;
|
||||||
|
cpuSystemPercent: number;
|
||||||
|
memoryUsedBytes: number;
|
||||||
|
swapUsedBytes: number;
|
||||||
|
heapUsedBytes: number;
|
||||||
|
heapCommittedBytes: number;
|
||||||
|
gcCycleTimeMs: number;
|
||||||
|
gcPauseTimeMs: number;
|
||||||
|
rxBytesPerSecond: number;
|
||||||
|
txBytesPerSecond: number;
|
||||||
|
}
|
||||||
54
src/types/Ups.ts
Normal file
54
src/types/Ups.ts
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
export interface UpsStatusView {
|
||||||
|
serverTime: number;
|
||||||
|
upsTime: number;
|
||||||
|
hostName: string;
|
||||||
|
customer: string;
|
||||||
|
version: string;
|
||||||
|
deviceId: string;
|
||||||
|
upsType: string;
|
||||||
|
morphological: string;
|
||||||
|
ioPhase: string;
|
||||||
|
workMode: string;
|
||||||
|
inputVoltage: number;
|
||||||
|
inputFrequency?: number;
|
||||||
|
outputVoltage: number;
|
||||||
|
outputFrequency: number;
|
||||||
|
outputLoadPercent: number;
|
||||||
|
batteryVoltage: number;
|
||||||
|
batteryCapacity: number;
|
||||||
|
batteryRemainTime: number;
|
||||||
|
temperature: number;
|
||||||
|
bypassActive: boolean;
|
||||||
|
shutdownActive: boolean;
|
||||||
|
outputOn: boolean;
|
||||||
|
charging: boolean;
|
||||||
|
faultType?: string;
|
||||||
|
faultKind?: string;
|
||||||
|
warnings: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpsHistoryView {
|
||||||
|
serverTime: number;
|
||||||
|
sampleRateMs: number;
|
||||||
|
from: number;
|
||||||
|
to: number;
|
||||||
|
points: UpsHistoryPointView[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpsHistoryPointView {
|
||||||
|
at: number;
|
||||||
|
upsTime: number;
|
||||||
|
workMode: string;
|
||||||
|
inputVoltage: number;
|
||||||
|
inputFrequency?: number;
|
||||||
|
outputVoltage: number;
|
||||||
|
outputFrequency: number;
|
||||||
|
outputLoadPercent: number;
|
||||||
|
batteryVoltage: number;
|
||||||
|
batteryCapacity: number;
|
||||||
|
batteryRemainTime: number;
|
||||||
|
temperature?: number;
|
||||||
|
bypassActive: boolean;
|
||||||
|
shutdownActive: boolean;
|
||||||
|
outputOn: boolean;
|
||||||
|
}
|
||||||
18
src/types/router.ts
Normal file
18
src/types/router.ts
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
declare module "vue-router" {
|
||||||
|
interface RouteMeta {
|
||||||
|
depth?: number;
|
||||||
|
dynamicDepth?: boolean;
|
||||||
|
baseDepth?: number;
|
||||||
|
ignoreConnectCheck?: boolean;
|
||||||
|
navBarVisible?: boolean;
|
||||||
|
navBarCanBack?: boolean;
|
||||||
|
navBarTitle?: string;
|
||||||
|
tabBarVisible?: boolean;
|
||||||
|
tabBarPadding?: boolean;
|
||||||
|
tabBarRoot?: boolean;
|
||||||
|
contentFixedHeight?: boolean;
|
||||||
|
bodyBackground?: string;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export {};
|
||||||
12
src/types/vue-virtual-scroller.d.ts
vendored
Normal file
12
src/types/vue-virtual-scroller.d.ts
vendored
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
declare module "vue-virtual-scroller" {
|
||||||
|
import type { App, DefineComponent, Plugin } from "vue";
|
||||||
|
|
||||||
|
export const RecycleScroller: DefineComponent;
|
||||||
|
export const DynamicScroller: DefineComponent;
|
||||||
|
export const DynamicScrollerItem: DefineComponent;
|
||||||
|
|
||||||
|
export function install(app: App): void;
|
||||||
|
|
||||||
|
const VueVirtualScroller: Plugin;
|
||||||
|
export default VueVirtualScroller;
|
||||||
|
}
|
||||||
1
src/utils/Storage.ts
Normal file
1
src/utils/Storage.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export { default } from "../../../timi-web/src/utils/Storage";
|
||||||
28
src/utils/backNavigationSignal.ts
Normal file
28
src/utils/backNavigationSignal.ts
Normal 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;
|
||||||
|
}
|
||||||
1
src/vite-env.d.ts
vendored
Normal file
1
src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
/// <reference types="vite/client" />
|
||||||
27
tsconfig.app.json
Normal file
27
tsconfig.app.json
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
{
|
||||||
|
"extends": "@vue/tsconfig/tsconfig.dom.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"baseUrl": ".",
|
||||||
|
"paths": {
|
||||||
|
"@/*": [
|
||||||
|
"src/*"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"types": [
|
||||||
|
"vite/client",
|
||||||
|
"tdesign-mobile-vue/global"
|
||||||
|
],
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"noUncheckedSideEffectImports": true
|
||||||
|
},
|
||||||
|
"include": [
|
||||||
|
"src/**/*.ts",
|
||||||
|
"src/**/*.d.ts",
|
||||||
|
"src/**/*.vue",
|
||||||
|
"auto-imports.d.ts",
|
||||||
|
"components.d.ts"
|
||||||
|
]
|
||||||
|
}
|
||||||
11
tsconfig.json
Normal file
11
tsconfig.json
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"files": [],
|
||||||
|
"references": [
|
||||||
|
{
|
||||||
|
"path": "./tsconfig.app.json"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "./tsconfig.node.json"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
13
tsconfig.node.json
Normal file
13
tsconfig.node.json
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"extends": "@tsconfig/node20/tsconfig.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"composite": true,
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "Bundler",
|
||||||
|
"allowSyntheticDefaultImports": true
|
||||||
|
},
|
||||||
|
"include": [
|
||||||
|
"vite.config.ts",
|
||||||
|
".eslintrc.cjs"
|
||||||
|
]
|
||||||
|
}
|
||||||
53
vite.config.ts
Normal file
53
vite.config.ts
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
import { fileURLToPath, URL } from "node:url";
|
||||||
|
import { defineConfig } from "vite";
|
||||||
|
import vue from "@vitejs/plugin-vue";
|
||||||
|
import AutoImport from "unplugin-auto-import/vite";
|
||||||
|
import Components from "unplugin-vue-components/vite";
|
||||||
|
import { TDesignResolver } from "unplugin-vue-components/resolvers";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
"@": fileURLToPath(new URL("./src", import.meta.url))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
plugins: [
|
||||||
|
vue(),
|
||||||
|
AutoImport({
|
||||||
|
imports: ["vue", "vue-router", "pinia"],
|
||||||
|
dts: "auto-imports.d.ts",
|
||||||
|
eslintrc: {
|
||||||
|
enabled: true,
|
||||||
|
filepath: "./.eslintrc-auto-import.json",
|
||||||
|
globalsPropValue: true
|
||||||
|
},
|
||||||
|
resolvers: [
|
||||||
|
TDesignResolver({
|
||||||
|
library: "mobile-vue"
|
||||||
|
})
|
||||||
|
]
|
||||||
|
}),
|
||||||
|
Components({
|
||||||
|
dirs: ["src/components", "src/layout"],
|
||||||
|
dts: "components.d.ts",
|
||||||
|
resolvers: [
|
||||||
|
TDesignResolver({
|
||||||
|
library: "mobile-vue"
|
||||||
|
})
|
||||||
|
]
|
||||||
|
})
|
||||||
|
],
|
||||||
|
css: {
|
||||||
|
preprocessorOptions: {
|
||||||
|
less: {
|
||||||
|
javascriptEnabled: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
server: {
|
||||||
|
port: 84,
|
||||||
|
allowedHosts: [
|
||||||
|
"server.imyeyu.dev"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user