init project
This commit is contained in:
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=https://api.imyeyu.dev
|
||||
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
|
||||
# gitignore template for Vue.js projects
|
||||
#
|
||||
# Recommended template: Node.gitignore
|
||||
/AGENTS.md
|
||||
/.env.production
|
||||
/.eslintrc-auto-import.json
|
||||
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
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
.pnpm-debug.log*
|
||||
|
||||
# Diagnostic reports (https://nodejs.org/api/report.html)
|
||||
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
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Gatsby files
|
||||
.cache/
|
||||
# Comment in the public line in if your project uses Gatsby and not Next.js
|
||||
# https://nextjs.org/blog/next-9-1#public-directory-support
|
||||
# public
|
||||
|
||||
# vuepress build output
|
||||
.vuepress/dist
|
||||
|
||||
# vuepress v2.x temp and cache directory
|
||||
.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
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
|
||||
22
README.md
22
README.md
@@ -1,3 +1,23 @@
|
||||
# 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
|
||||
```
|
||||
|
||||
## 说明
|
||||
|
||||
当前仓库仅完成项目骨架和占位页面,方便后续继续补业务模块,不包含实际接口和业务逻辑。
|
||||
|
||||
13
index.html
Normal file
13
index.html
Normal file
@@ -0,0 +1,13 @@
|
||||
<!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="theme-color" content="#ffffff" />
|
||||
<title>Timi Server</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
44
package.json
Normal file
44
package.json
Normal file
@@ -0,0 +1,44 @@
|
||||
{
|
||||
"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.8.4",
|
||||
"less": "^4.3.0",
|
||||
"music-metadata-browser": "^2.5.11",
|
||||
"pinia": "^3.0.2",
|
||||
"tdesign-mobile-vue": "^1.13.2",
|
||||
"timi-tdesign-mobile": "0.0.2",
|
||||
"timi-web": "0.0.1",
|
||||
"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"
|
||||
}
|
||||
}
|
||||
4112
pnpm-lock.yaml
generated
Normal file
4112
pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
25
src/Root.vue
Normal file
25
src/Root.vue
Normal file
@@ -0,0 +1,25 @@
|
||||
<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>
|
||||
156
src/components/PageTransition.vue
Normal file
156
src/components/PageTransition.vue
Normal file
@@ -0,0 +1,156 @@
|
||||
<template>
|
||||
<div class="page-transition">
|
||||
<router-view v-slot="{ Component }">
|
||||
<transition v-if="hasTransition" :name="transitionName">
|
||||
<div class="pages" :key="route.fullPath">
|
||||
<component :is="Component" />
|
||||
</div>
|
||||
</transition>
|
||||
<div v-else class="pages" :key="route.fullPath">
|
||||
<component :is="Component" />
|
||||
</div>
|
||||
</router-view>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import type { RouteLocationNormalized } from "vue-router";
|
||||
import { useGlobalUIStore } from "@/store/globalUIStore";
|
||||
|
||||
defineOptions({
|
||||
name: "PageTransition"
|
||||
});
|
||||
|
||||
const router = useRouter();
|
||||
const route = useRoute();
|
||||
const globalUIStore = useGlobalUIStore();
|
||||
|
||||
const transitionName = ref("push-left");
|
||||
const hasTransition = computed(() => transitionName.value !== "");
|
||||
const pageBackground = computed(() => globalUIStore.bodyBackground);
|
||||
|
||||
// ---------- 路由深度计算 ----------
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
// beforeEach 返回注销函数,组件卸载时必须调用,否则守卫会一直存在。
|
||||
const unregisterGuard = router.beforeEach((to, from) => {
|
||||
const toDepth = calcDepth(to);
|
||||
const fromDepth = calcDepth(from);
|
||||
|
||||
if (fromDepth < toDepth) {
|
||||
transitionName.value = "push-left";
|
||||
} else if (toDepth < fromDepth) {
|
||||
transitionName.value = "push-right";
|
||||
} else {
|
||||
transitionName.value = "";
|
||||
}
|
||||
});
|
||||
|
||||
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%;
|
||||
position: relative;
|
||||
overflow-x: hidden;
|
||||
|
||||
// 这个 wrapper 必须独立,不能把 class=pages 直接挂在 <component> 上。
|
||||
// 否则页面根节点会和动画容器变成同一个 DOM 节点,容易导致 fixed 布局溢出视口。
|
||||
.pages {
|
||||
height: 100%;
|
||||
background: v-bind(pageBackground);
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
// ---------- push-left / push-right 公共过渡状态 ----------
|
||||
|
||||
.push-left-enter-active,
|
||||
.push-left-leave-active,
|
||||
.push-right-enter-active,
|
||||
.push-right-leave-active {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
position: fixed;
|
||||
transition: transform @duration @easing, opacity .2s linear;
|
||||
backface-visibility: hidden;
|
||||
}
|
||||
|
||||
// ---------- 前进:下一页从右滑入,当前页退到左侧 ----------
|
||||
|
||||
.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 {
|
||||
z-index: -1;
|
||||
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>
|
||||
63
src/components/RoutePlaceholder.vue
Normal file
63
src/components/RoutePlaceholder.vue
Normal file
@@ -0,0 +1,63 @@
|
||||
<template>
|
||||
<section class="placeholder">
|
||||
<div class="box">
|
||||
<p class="tag">当前组件</p>
|
||||
<h1 class="title" v-text="title"></h1>
|
||||
<p class="desc" v-text="description"></p>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
defineProps<{
|
||||
title: string;
|
||||
description: string;
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
.placeholder {
|
||||
width: 100%;
|
||||
min-height: 100%;
|
||||
padding: 1.2rem;
|
||||
|
||||
.box {
|
||||
gap: .9rem;
|
||||
display: flex;
|
||||
min-height: 12rem;
|
||||
padding: 1.2rem;
|
||||
border-radius: 1rem;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
border: 1px solid var(--app-line);
|
||||
background: linear-gradient(160deg, rgba(255, 255, 255, .92), rgba(255, 255, 255, .72));
|
||||
box-shadow: 0 .4rem 1.2rem rgba(17, 32, 56, .08);
|
||||
}
|
||||
|
||||
.tag {
|
||||
margin: 0;
|
||||
color: var(--app-primary);
|
||||
font-size: .875rem;
|
||||
}
|
||||
|
||||
.title {
|
||||
margin: 0;
|
||||
font-size: 1.6rem;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.desc {
|
||||
margin: 0;
|
||||
color: var(--app-sub);
|
||||
font-size: .95rem;
|
||||
line-height: 1.6;
|
||||
}
|
||||
}
|
||||
|
||||
:global(.theme-dark) .placeholder {
|
||||
.box {
|
||||
background: linear-gradient(160deg, rgba(29, 37, 47, .96), rgba(22, 28, 36, .88));
|
||||
box-shadow: 0 .4rem 1.2rem rgba(0, 0, 0, .28);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
191
src/layout/MainLayout.vue
Normal file
191
src/layout/MainLayout.vue
Normal file
@@ -0,0 +1,191 @@
|
||||
<template>
|
||||
<div class="main-layout">
|
||||
<t-navbar
|
||||
ref="navBarRef"
|
||||
v-if="navBarStore.isShowing"
|
||||
class="nav-bar"
|
||||
:title="navBarStore.title"
|
||||
:left-arrow="!!navBarStore.canBack"
|
||||
@left-click="doBack"
|
||||
>
|
||||
</t-navbar>
|
||||
<div class="router-view">
|
||||
<page-transition />
|
||||
</div>
|
||||
<t-tab-bar
|
||||
:class="{ 'is-hidden': !tabBarStore.isShowing }"
|
||||
class="tab-bar glass-white"
|
||||
v-model="tabVal"
|
||||
shape="round"
|
||||
theme="tag"
|
||||
:split="false"
|
||||
:disabled="!tabBarStore.isShowing"
|
||||
@change="onChangeTab"
|
||||
>
|
||||
<t-tab-bar-item class="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>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useNavBarStore } from "@/store/navBarStore";
|
||||
import { useTabBarStore } from "@/store/tabBarStore";
|
||||
import PageTransition from "@/components/PageTransition.vue";
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const navBarStore = useNavBarStore();
|
||||
const tabBarStore = useTabBarStore();
|
||||
|
||||
// ---------- 导航栏高度 ----------
|
||||
|
||||
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();
|
||||
}
|
||||
});
|
||||
|
||||
// ---------- 导航返回 ----------
|
||||
|
||||
function doBack(): void {
|
||||
if (navBarStore.backTo) {
|
||||
router.push(navBarStore.backTo);
|
||||
navBarStore.setBackTo(undefined);
|
||||
} else {
|
||||
router.back();
|
||||
}
|
||||
}
|
||||
|
||||
const navBarPadding = computed(() => {
|
||||
if (navBarStore.isShowing) {
|
||||
return "3rem";
|
||||
}
|
||||
|
||||
return "0";
|
||||
});
|
||||
|
||||
// ---------- Tab 切换 ----------
|
||||
|
||||
const tabVal = ref(route.path);
|
||||
const tabList = [
|
||||
{ value: "/", icon: "app" },
|
||||
{ value: "/server", icon: "chart" },
|
||||
{ value: "/settings", icon: "setting" }
|
||||
];
|
||||
|
||||
watch(
|
||||
() => route.path,
|
||||
(newPath: string) => {
|
||||
tabVal.value = newPath;
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
function onChangeTab(value: string): void {
|
||||
router.push(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 bodyHeight = computed(() => {
|
||||
return `calc(100% - ${navBarPadding.value} - ${tabBarPadding.value})`;
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
.main-layout {
|
||||
height: v-bind(bodyHeight);
|
||||
padding-top: v-bind(navBarPadding);
|
||||
padding-bottom: v-bind(tabBarPadding);
|
||||
transition: padding-bottom .24s ease;
|
||||
|
||||
.nav-bar {
|
||||
z-index: 999;
|
||||
|
||||
:deep(.t-navbar__content) {
|
||||
color: var(--tui-black, #000);
|
||||
border-bottom: 1px solid var(--app-line);
|
||||
background: rgba(250, 250, 250, .8);
|
||||
backdrop-filter: blur(10px);
|
||||
-webkit-backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
.nav-btn {
|
||||
padding: 0;
|
||||
border: none;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--td-text-color-primary, #333);
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.nav-text {
|
||||
cursor: default;
|
||||
|
||||
&.clickable {
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.router-view {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.tab-bar {
|
||||
bottom: 1rem;
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
transition: opacity 520ms, transform 520ms var(--tui-bezier);
|
||||
pointer-events: auto;
|
||||
|
||||
&.is-hidden {
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transform: translateY(calc(100% + 1rem));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
: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">
|
||||
<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>
|
||||
23
src/main.ts
Normal file
23
src/main.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
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";
|
||||
|
||||
export const pinia = createPinia();
|
||||
|
||||
const app = createApp(Root);
|
||||
app.config.errorHandler = (error) => {
|
||||
console.error(error);
|
||||
Toast({
|
||||
theme: "error",
|
||||
message: "页面发生异常,请稍后重试"
|
||||
});
|
||||
};
|
||||
app.use(pinia);
|
||||
app.use(router);
|
||||
app.mount("#root");
|
||||
6
src/pages/detail/FileDetailPage.vue
Normal file
6
src/pages/detail/FileDetailPage.vue
Normal file
@@ -0,0 +1,6 @@
|
||||
<template>
|
||||
<route-placeholder title="文件详情页" description="这里保留为二级详情占位,用于验证从文件页进入和返回时的滑动方向。" />
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
</script>
|
||||
6
src/pages/detail/ServerLogPage.vue
Normal file
6
src/pages/detail/ServerLogPage.vue
Normal file
@@ -0,0 +1,6 @@
|
||||
<template>
|
||||
<route-placeholder title="服务日志页" description="这里保留为二级详情占位,用于验证从状态页进入日志页时的动效和返回行为。" />
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
</script>
|
||||
21
src/pages/system/LoginPage.vue
Normal file
21
src/pages/system/LoginPage.vue
Normal file
@@ -0,0 +1,21 @@
|
||||
<template>
|
||||
<div class="page">
|
||||
<route-placeholder title="LoginPage" description="登录页占位,当前仅保留独立路由入口,不引入业务表单。" />
|
||||
<t-button block theme="primary" @click="router.replace('/')">
|
||||
进入应用首页
|
||||
</t-button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const router = useRouter();
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
.page {
|
||||
gap: 1rem;
|
||||
display: flex;
|
||||
padding: 1.2rem;
|
||||
flex-direction: column;
|
||||
}
|
||||
</style>
|
||||
6
src/pages/system/NotFoundPage.vue
Normal file
6
src/pages/system/NotFoundPage.vue
Normal file
@@ -0,0 +1,6 @@
|
||||
<template>
|
||||
<route-placeholder title="NotFoundPage" description="未匹配到路由时显示的占位页,避免空白页面。" />
|
||||
</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="title">先配置服务器连接</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;
|
||||
}
|
||||
|
||||
.title {
|
||||
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>
|
||||
25
src/pages/tabs/FilePage.vue
Normal file
25
src/pages/tabs/FilePage.vue
Normal file
@@ -0,0 +1,25 @@
|
||||
<template>
|
||||
<div class="page">
|
||||
<route-placeholder title="文件页" description="这里保留为文件管理入口,用于验证首页标签、布局容器和二级详情页切换。" />
|
||||
<t-button block theme="primary" @click="openFileDetail">
|
||||
打开文件详情页
|
||||
</t-button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const router = useRouter();
|
||||
|
||||
async function openFileDetail(): Promise<void> {
|
||||
await router.push("/files/detail/demo-file");
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
.page {
|
||||
gap: 1rem;
|
||||
display: flex;
|
||||
padding: 1.2rem;
|
||||
flex-direction: column;
|
||||
}
|
||||
</style>
|
||||
6
src/pages/tabs/ReaderPage.vue
Normal file
6
src/pages/tabs/ReaderPage.vue
Normal file
@@ -0,0 +1,6 @@
|
||||
<template>
|
||||
<route-placeholder title="阅读页" description="该页面暂时不在 tab 中展示,只保留占位,后续可按需恢复。" />
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
</script>
|
||||
25
src/pages/tabs/ServerStatusPage.vue
Normal file
25
src/pages/tabs/ServerStatusPage.vue
Normal file
@@ -0,0 +1,25 @@
|
||||
<template>
|
||||
<div class="page">
|
||||
<route-placeholder title="状态页" description="这里保留为服务器状态入口,用于验证底部标签切换和服务日志页前进返回动画。" />
|
||||
<t-button block variant="outline" @click="openServerLogs">
|
||||
查看服务日志页
|
||||
</t-button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const router = useRouter();
|
||||
|
||||
async function openServerLogs(): Promise<void> {
|
||||
await router.push("/server/logs");
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
.page {
|
||||
gap: 1rem;
|
||||
display: flex;
|
||||
padding: 1.2rem;
|
||||
flex-direction: column;
|
||||
}
|
||||
</style>
|
||||
208
src/pages/tabs/SettingsPage.vue
Normal file
208
src/pages/tabs/SettingsPage.vue
Normal file
@@ -0,0 +1,208 @@
|
||||
<template>
|
||||
<div class="page">
|
||||
<section class="card">
|
||||
<div class="head">
|
||||
<p class="tag">连接配置</p>
|
||||
<h2 class="title">服务器连接</h2>
|
||||
<p class="desc">这里的配置会持久化保存,缺失时应用会强制回到连接引导页。</p>
|
||||
</div>
|
||||
|
||||
<div class="group">
|
||||
<p class="label">协议</p>
|
||||
<div class="protocols">
|
||||
<t-button
|
||||
size="small"
|
||||
theme="primary"
|
||||
:variant="form.protocol === 'http' ? 'base' : 'outline'"
|
||||
@click="setProtocol('http')"
|
||||
>
|
||||
HTTP
|
||||
</t-button>
|
||||
<t-button
|
||||
size="small"
|
||||
theme="primary"
|
||||
:variant="form.protocol === 'https' ? 'base' : 'outline'"
|
||||
@click="setProtocol('https')"
|
||||
>
|
||||
HTTPS
|
||||
</t-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="group">
|
||||
<p class="label">主机地址</p>
|
||||
<t-input v-model="form.host" clearable placeholder="例如 192.168.1.100 或 nas.local" />
|
||||
</div>
|
||||
|
||||
<div class="group">
|
||||
<p class="label">端口</p>
|
||||
<t-input v-model="form.port" clearable type="number" placeholder="例如 8080" />
|
||||
</div>
|
||||
|
||||
<div class="group">
|
||||
<p class="label">访问令牌</p>
|
||||
<t-input v-model="form.token" clearable placeholder="请输入 token" />
|
||||
</div>
|
||||
|
||||
<t-button block theme="primary" @click="saveConnect">
|
||||
保存连接配置
|
||||
</t-button>
|
||||
<t-button block variant="outline" @click="resetConnect">
|
||||
清空连接配置
|
||||
</t-button>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<div class="head">
|
||||
<p class="tag">主题</p>
|
||||
<h2 class="title">界面模式</h2>
|
||||
<p class="desc">当前仅提供浅色、深色和跟随系统三种模式。</p>
|
||||
</div>
|
||||
|
||||
<div class="modes">
|
||||
<t-button
|
||||
v-for="item in themeModeList"
|
||||
:key="item.value"
|
||||
theme="primary"
|
||||
:variant="globalUIStore.themeMode === item.value ? 'base' : 'outline'"
|
||||
@click="globalUIStore.setThemeMode(item.value)"
|
||||
>
|
||||
<span v-text="item.label"></span>
|
||||
</t-button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Toast } from "tdesign-mobile-vue";
|
||||
import { useGlobalUIStore, type ThemeMode } from "@/store/globalUIStore";
|
||||
import type { ConnectProtocol, ConnectSetting } from "@/store/settingStore";
|
||||
import { useSettingStore } from "@/store/settingStore";
|
||||
|
||||
const globalUIStore = useGlobalUIStore();
|
||||
const settingStore = useSettingStore();
|
||||
|
||||
const themeModeList: Array<{ label: string; value: ThemeMode }> = [
|
||||
{ label: "浅色", value: "light" },
|
||||
{ label: "深色", value: "dark" },
|
||||
{ label: "跟随系统", value: "system" }
|
||||
];
|
||||
|
||||
const form = reactive<ConnectSetting>({
|
||||
protocol: "http",
|
||||
host: "",
|
||||
port: "",
|
||||
token: ""
|
||||
});
|
||||
|
||||
watch(
|
||||
() => settingStore.connect,
|
||||
(connect) => {
|
||||
Object.assign(form, connect);
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
function setProtocol(protocol: ConnectProtocol): void {
|
||||
form.protocol = protocol;
|
||||
}
|
||||
|
||||
function validateConnect(): boolean {
|
||||
const host = form.host.trim();
|
||||
const port = form.port.trim();
|
||||
const token = form.token.trim();
|
||||
|
||||
if (!host || !port || !token) {
|
||||
Toast({
|
||||
theme: "warning",
|
||||
message: "请完整填写连接配置"
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
function saveConnect(): void {
|
||||
if (!validateConnect()) {
|
||||
return;
|
||||
}
|
||||
|
||||
settingStore.setConnect(form);
|
||||
Toast({
|
||||
theme: "success",
|
||||
message: "连接配置已保存"
|
||||
});
|
||||
}
|
||||
|
||||
function resetConnect(): void {
|
||||
settingStore.resetConnect();
|
||||
Object.assign(form, settingStore.connect);
|
||||
Toast({
|
||||
theme: "success",
|
||||
message: "连接配置已清空"
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
.page {
|
||||
gap: 1rem;
|
||||
display: flex;
|
||||
padding: 1rem;
|
||||
flex-direction: column;
|
||||
|
||||
.card {
|
||||
gap: 1rem;
|
||||
display: flex;
|
||||
padding: 1rem;
|
||||
border-radius: 1rem;
|
||||
flex-direction: column;
|
||||
border: 1px solid var(--app-line);
|
||||
background: var(--app-card);
|
||||
box-shadow: 0 .35rem 1rem rgba(17, 32, 56, .05);
|
||||
}
|
||||
|
||||
.head,
|
||||
.group {
|
||||
gap: .5rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.tag,
|
||||
.label,
|
||||
.desc {
|
||||
margin: 0;
|
||||
color: var(--app-sub);
|
||||
}
|
||||
|
||||
.tag,
|
||||
.label {
|
||||
font-size: .875rem;
|
||||
}
|
||||
|
||||
.title {
|
||||
margin: 0;
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
|
||||
.desc {
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.protocols,
|
||||
.modes {
|
||||
gap: .75rem;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
}
|
||||
|
||||
:global(.theme-dark) .page {
|
||||
.card {
|
||||
box-shadow: 0 .35rem 1rem rgba(0, 0, 0, .2);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
132
src/router/index.ts
Normal file
132
src/router/index.ts
Normal file
@@ -0,0 +1,132 @@
|
||||
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 LoginPage from "@/pages/system/LoginPage.vue";
|
||||
import NotFoundPage from "@/pages/system/NotFoundPage.vue";
|
||||
import ServerIndexPage from "@/pages/system/ServerIndexPage.vue";
|
||||
import FileDetailPage from "@/pages/detail/FileDetailPage.vue";
|
||||
import ServerLogPage from "@/pages/detail/ServerLogPage.vue";
|
||||
|
||||
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: "/login",
|
||||
name: "LoginPage",
|
||||
meta: {
|
||||
depth: 1,
|
||||
ignoreConnectCheck: true,
|
||||
navBarVisible: false,
|
||||
tabBarVisible: false,
|
||||
bodyBackground: "#FFF"
|
||||
},
|
||||
component: LoginPage
|
||||
},
|
||||
{
|
||||
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/detail/:id",
|
||||
name: "FileDetailPage",
|
||||
meta: {
|
||||
depth: 3,
|
||||
navBarVisible: true,
|
||||
navBarCanBack: true,
|
||||
navBarTitle: "文件详情",
|
||||
tabBarVisible: false
|
||||
},
|
||||
component: FileDetailPage
|
||||
},
|
||||
{
|
||||
path: "/server/logs",
|
||||
name: "ServerLogPage",
|
||||
meta: {
|
||||
depth: 3,
|
||||
navBarVisible: true,
|
||||
navBarCanBack: true,
|
||||
navBarTitle: "服务日志",
|
||||
tabBarVisible: false
|
||||
},
|
||||
component: ServerLogPage
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
path: "/:pathMatch(.*)*",
|
||||
name: "NotFoundPage",
|
||||
meta: {
|
||||
depth: 1,
|
||||
ignoreConnectCheck: true,
|
||||
navBarVisible: false,
|
||||
tabBarVisible: false
|
||||
},
|
||||
component: NotFoundPage
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
router.beforeEach((to: RouteLocationNormalized) => {
|
||||
const settingStore = useSettingStore();
|
||||
|
||||
if (to.meta.ignoreConnectCheck) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (settingStore.hasConnectConfig) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return {
|
||||
name: "ServerIndexPage",
|
||||
query: {
|
||||
redirect: to.fullPath
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
router.afterEach((to: RouteLocationNormalized) => {
|
||||
const globalUIStore = useGlobalUIStore();
|
||||
const targetBackground = typeof to.meta.bodyBackground === "string" ? to.meta.bodyBackground : DEFAULT_BODY_BACKGROUND;
|
||||
globalUIStore.setBodyBackground(targetBackground);
|
||||
});
|
||||
|
||||
export default router;
|
||||
46
src/router/tabs.ts
Normal file
46
src/router/tabs.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import type { RouteRecordRaw } from "vue-router";
|
||||
import FilePage from "@/pages/tabs/FilePage.vue";
|
||||
import ServerStatusPage from "@/pages/tabs/ServerStatusPage.vue";
|
||||
import SettingsPage from "@/pages/tabs/SettingsPage.vue";
|
||||
|
||||
const tabs: RouteRecordRaw[] = [
|
||||
{
|
||||
path: "/",
|
||||
name: "FilePage",
|
||||
meta: {
|
||||
depth: 2,
|
||||
navBarVisible: true,
|
||||
navBarTitle: "文件",
|
||||
tabBarVisible: true,
|
||||
tabBarPadding: true
|
||||
},
|
||||
component: FilePage
|
||||
},
|
||||
{
|
||||
path: "/server",
|
||||
name: "ServerStatusPage",
|
||||
meta: {
|
||||
depth: 2,
|
||||
navBarVisible: true,
|
||||
navBarTitle: "状态",
|
||||
tabBarVisible: true,
|
||||
tabBarPadding: true
|
||||
},
|
||||
component: ServerStatusPage
|
||||
},
|
||||
{
|
||||
path: "/settings",
|
||||
name: "SettingsPage",
|
||||
meta: {
|
||||
depth: 2,
|
||||
navBarVisible: true,
|
||||
navBarTitle: "设置",
|
||||
tabBarVisible: true,
|
||||
tabBarPadding: true,
|
||||
bodyBackground: "var(--app-bg)"
|
||||
},
|
||||
component: SettingsPage
|
||||
}
|
||||
];
|
||||
|
||||
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
|
||||
};
|
||||
});
|
||||
64
src/store/navBarStore.ts
Normal file
64
src/store/navBarStore.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
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 rightText = ref<string>();
|
||||
const rightAction = ref<(() => void) | undefined>();
|
||||
|
||||
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 setRightText(value?: string): void {
|
||||
rightText.value = value;
|
||||
}
|
||||
|
||||
function setRightAction(action?: () => void): void {
|
||||
rightAction.value = action;
|
||||
}
|
||||
|
||||
function clearRight(): void {
|
||||
rightText.value = undefined;
|
||||
rightAction.value = undefined;
|
||||
}
|
||||
|
||||
return {
|
||||
height,
|
||||
title,
|
||||
backTo,
|
||||
rightText,
|
||||
rightAction,
|
||||
isShowing,
|
||||
canBack,
|
||||
setHeight,
|
||||
setBackTo,
|
||||
setTitle,
|
||||
setRightText,
|
||||
setRightAction,
|
||||
clearRight
|
||||
};
|
||||
});
|
||||
73
src/store/settingStore.ts
Normal file
73
src/store/settingStore.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
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;
|
||||
}
|
||||
|
||||
interface SettingState {
|
||||
connect: ConnectSetting;
|
||||
}
|
||||
|
||||
const defaultConnectSetting = (): ConnectSetting => ({
|
||||
protocol: "http",
|
||||
host: "",
|
||||
port: "",
|
||||
token: ""
|
||||
});
|
||||
|
||||
const defaultSettingState = (): SettingState => ({
|
||||
connect: defaultConnectSetting()
|
||||
});
|
||||
|
||||
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() || ""
|
||||
};
|
||||
}
|
||||
|
||||
export const useSettingStore = defineStore("setting", () => {
|
||||
const state = ref<SettingState>(Storage.getDefault<SettingState>(SETTING_STORAGE_KEY, defaultSettingState()));
|
||||
|
||||
const connect = computed(() => state.value.connect);
|
||||
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();
|
||||
}
|
||||
|
||||
return {
|
||||
connect,
|
||||
hasConnectConfig,
|
||||
setConnect,
|
||||
resetConnect
|
||||
};
|
||||
});
|
||||
11
src/store/tabBarStore.ts
Normal file
11
src/store/tabBarStore.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { defineStore } from "pinia";
|
||||
|
||||
export const useTabBarStore = defineStore("tab-bar", () => {
|
||||
const router = useRouter();
|
||||
|
||||
const isShowing = computed(() => !!router.currentRoute.value.meta.tabBarVisible);
|
||||
|
||||
return {
|
||||
isShowing
|
||||
};
|
||||
});
|
||||
16
src/types/router.ts
Normal file
16
src/types/router.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
declare module "vue-router" {
|
||||
interface RouteMeta {
|
||||
depth?: number;
|
||||
dynamicDepth?: boolean;
|
||||
baseDepth?: number;
|
||||
ignoreConnectCheck?: boolean;
|
||||
navBarVisible?: boolean;
|
||||
navBarCanBack?: boolean;
|
||||
navBarTitle?: string;
|
||||
tabBarVisible?: boolean;
|
||||
tabBarPadding?: boolean;
|
||||
bodyBackground?: string;
|
||||
}
|
||||
}
|
||||
|
||||
export {};
|
||||
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";
|
||||
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"
|
||||
]
|
||||
}
|
||||
50
vite.config.ts
Normal file
50
vite.config.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
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: 5174
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user