init project
This commit is contained in:
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" />
|
||||
Reference in New Issue
Block a user