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

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" />