init project

This commit is contained in:
Timi
2026-04-03 12:02:34 +08:00
parent d4bef26c96
commit 2665acc885
36 changed files with 5725 additions and 218 deletions

12
.editorconfig Normal file
View 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
View File

@@ -0,0 +1 @@
VITE_API=https://api.imyeyu.dev

44
.eslintrc.cjs Normal file
View 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
View File

@@ -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

View File

@@ -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
```
## 说明
当前仓库仅完成项目骨架和占位页面,方便后续继续补业务模块,不包含实际接口和业务逻辑。

13
index.html Normal file
View 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
View 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

File diff suppressed because it is too large Load Diff

25
src/Root.vue Normal file
View 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>

View 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>

View 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
View 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
View 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
View 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");

View File

@@ -0,0 +1,6 @@
<template>
<route-placeholder title="文件详情页" description="这里保留为二级详情占位,用于验证从文件页进入和返回时的滑动方向。" />
</template>
<script setup lang="ts">
</script>

View File

@@ -0,0 +1,6 @@
<template>
<route-placeholder title="服务日志页" description="这里保留为二级详情占位,用于验证从状态页进入日志页时的动效和返回行为。" />
</template>
<script setup lang="ts">
</script>

View 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>

View File

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

View 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>

View 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>

View File

@@ -0,0 +1,6 @@
<template>
<route-placeholder title="阅读页" description="该页面暂时不在 tab 中展示,只保留占位,后续可按需恢复。" />
</template>
<script setup lang="ts">
</script>

View 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>

View 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
View 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
View 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;

View 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
View 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
View 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
View 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
View 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
View File

@@ -0,0 +1 @@
export { default } from "../../../timi-web/src/utils/Storage";

1
src/vite-env.d.ts vendored Normal file
View File

@@ -0,0 +1 @@
/// <reference types="vite/client" />

27
tsconfig.app.json Normal file
View 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
View File

@@ -0,0 +1,11 @@
{
"files": [],
"references": [
{
"path": "./tsconfig.app.json"
},
{
"path": "./tsconfig.node.json"
}
]
}

13
tsconfig.node.json Normal file
View 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
View 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
}
});