add FileExplorerPage

This commit is contained in:
Timi
2026-04-03 13:43:31 +08:00
parent 2665acc885
commit ded231671a
8 changed files with 519 additions and 93 deletions

View File

@@ -1,20 +1,18 @@
<template> <template>
<div class="page-transition"> <div class="page-transition">
<router-view v-slot="{ Component }"> <router-view v-slot="{ Component }">
<transition v-if="hasTransition" :name="transitionName"> <transition :name="transitionName" :css="hasTransition">
<div class="pages" :key="route.fullPath"> <div class="pages" :key="pageKey">
<component :is="Component" /> <component :is="Component" />
</div> </div>
</transition> </transition>
<div v-else class="pages" :key="route.fullPath">
<component :is="Component" />
</div>
</router-view> </router-view>
</div> </div>
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import type { RouteLocationNormalized } from "vue-router"; import type { RouteLocationNormalized } from "vue-router";
import { viewDepthKey } from "vue-router";
import { useGlobalUIStore } from "@/store/globalUIStore"; import { useGlobalUIStore } from "@/store/globalUIStore";
defineOptions({ defineOptions({
@@ -24,12 +22,26 @@ defineOptions({
const router = useRouter(); const router = useRouter();
const route = useRoute(); const route = useRoute();
const globalUIStore = useGlobalUIStore(); const globalUIStore = useGlobalUIStore();
const viewDepth = inject(viewDepthKey, 0);
const transitionName = ref("push-left"); const transitionName = ref("");
const hasTransition = computed(() => transitionName.value !== ""); const hasTransition = ref(false);
const pageBackground = computed(() => globalUIStore.bodyBackground); const pageBackground = computed(() => globalUIStore.bodyBackground);
const currentDepth = computed(() => Number(unref(viewDepth)));
const pageKey = computed(() => {
const depth = currentDepth.value;
const matchedRecord = route.matched[depth];
// ---------- 路由深度计算 ---------- if (!matchedRecord) {
return route.fullPath;
}
if (depth < route.matched.length - 1) {
return matchedRecord.path;
}
return route.fullPath;
});
const pathCache = new Map<string, number>(); const pathCache = new Map<string, number>();
@@ -51,17 +63,25 @@ function calcDepth(sourceRoute: RouteLocationNormalized): number {
return depth; return depth;
} }
// beforeEach 返回注销函数,组件卸载时必须调用,否则守卫会一直存在。
const unregisterGuard = router.beforeEach((to, from) => { const unregisterGuard = router.beforeEach((to, from) => {
if (to.meta.tabBarVisible && from.meta.tabBarVisible) {
transitionName.value = "";
hasTransition.value = false;
return;
}
const toDepth = calcDepth(to); const toDepth = calcDepth(to);
const fromDepth = calcDepth(from); const fromDepth = calcDepth(from);
if (fromDepth < toDepth) { if (fromDepth < toDepth) {
transitionName.value = "push-left"; transitionName.value = "push-left";
hasTransition.value = true;
} else if (toDepth < fromDepth) { } else if (toDepth < fromDepth) {
transitionName.value = "push-right"; transitionName.value = "push-right";
hasTransition.value = true;
} else { } else {
transitionName.value = ""; transitionName.value = "";
hasTransition.value = false;
} }
}); });
@@ -84,28 +104,35 @@ onUnmounted(() => {
position: relative; position: relative;
overflow-x: hidden; overflow-x: hidden;
// 这个 wrapper 必须独立,不能把 class=pages 直接挂在 <component> 上。
// 否则页面根节点会和动画容器变成同一个 DOM 节点,容易导致 fixed 布局溢出视口。
.pages { .pages {
inset: 0;
height: 100%; height: 100%;
position: absolute;
background: v-bind(pageBackground); background: v-bind(pageBackground);
overflow-x: hidden; overflow-x: hidden;
} }
// ---------- push-left / push-right 公共过渡状态 ----------
.push-left-enter-active, .push-left-enter-active,
.push-left-leave-active, .push-left-leave-active,
.push-right-enter-active, .push-right-enter-active,
.push-right-leave-active { .push-right-leave-active {
inset: 0;
width: 100%; width: 100%;
height: 100%; height: 100%;
position: fixed; position: absolute;
transition: transform @duration @easing, opacity .2s linear; transition: transform @duration @easing, opacity .2s linear;
backface-visibility: hidden; backface-visibility: hidden;
} }
// ---------- 前进:下一页从右滑入,当前页退到左侧 ---------- .push-left-enter-active,
.push-right-leave-active {
z-index: 2;
}
.push-left-leave-active,
.push-right-enter-active {
z-index: 1;
}
.push-left-enter-from { .push-left-enter-from {
transform: translate3d(100%, 0, 0); transform: translate3d(100%, 0, 0);
@@ -125,10 +152,7 @@ onUnmounted(() => {
transform: translate3d(-@page-offset, 0, 0); transform: translate3d(-@page-offset, 0, 0);
} }
// ---------- 返回:当前页从右滑出,上一页回到原位 ----------
.push-right-enter-active { .push-right-enter-active {
z-index: -1;
transition: transform @duration @easing, opacity .3s; transition: transform @duration @easing, opacity .3s;
} }

View File

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

View File

@@ -0,0 +1,463 @@
<template>
<div class="page">
<section class="toolbar glass-white">
<div class="path">
<t-button
size="small"
variant="text"
theme="primary"
:disabled="!currentSegments.length"
@click="openRoot"
>
根目录
</t-button>
<span v-for="(segment, index) in currentSegments" :key="`${index}-${segment}`" class="crumb-wrap">
<span class="sep">/</span>
<t-button
size="small"
variant="text"
theme="primary"
class="crumb"
@click="openByIndex(index)"
>
<span v-text="segment"></span>
</t-button>
</span>
</div>
<div class="actions">
<t-button
size="small"
theme="primary"
:variant="displayMode === 'list' ? 'base' : 'outline'"
@click="setDisplayMode('list')"
>
列表
</t-button>
<t-button
size="small"
theme="primary"
:variant="displayMode === 'grid' ? 'base' : 'outline'"
@click="setDisplayMode('grid')"
>
平铺
</t-button>
</div>
</section>
<section v-if="currentSegments.length" class="go-up" @click="openParent">
<div class="icon dir">
<t-icon name="rollback" />
</div>
<div class="meta">
<p class="name">返回上一级</p>
<p class="desc">当前目录的父级目录</p>
</div>
</section>
<section :class="['list', `is-${displayMode}`]">
<article
v-for="item in currentItems"
:key="item.path"
class="item glass-white"
@click="openItem(item)"
>
<div :class="['icon', item.type]">
<t-icon :name="item.type === 'dir' ? 'folder' : item.icon" />
</div>
<div class="meta">
<p class="name" v-text="item.name"></p>
<p class="desc" v-text="formatItemDesc(item)"></p>
</div>
</article>
</section>
<t-empty v-if="!currentItems.length" description="当前目录为空" />
</div>
</template>
<script setup lang="ts">
type DisplayMode = "list" | "grid";
type FileItemType = "dir" | "file";
interface FileEntry {
name: string;
type: FileItemType;
size?: string;
updatedAt: string;
icon: string;
children?: FileEntry[];
}
interface ExplorerItem extends Omit<FileEntry, "children"> {
path: string;
}
const props = withDefaults(defineProps<{
mode?: DisplayMode;
path?: string | string[];
useRoutePath?: boolean;
}>(), {
mode: "list",
path: "",
useRoutePath: true
});
const emit = defineEmits<{
"update:path": [value: string[]];
"open-folder": [value: string[]];
"open-file": [value: ExplorerItem];
"update:mode": [value: DisplayMode];
}>();
const route = useRoute();
const router = useRouter();
const fileTree: FileEntry[] = [
{
name: "文档",
type: "dir",
icon: "folder",
updatedAt: "今天 09:24",
children: [
{
name: "项目说明.md",
type: "file",
size: "18 KB",
icon: "file-text",
updatedAt: "今天 09:20"
},
{
name: "设计稿",
type: "dir",
icon: "folder",
updatedAt: "昨天 18:10",
children: [
{
name: "home.png",
type: "file",
size: "1.2 MB",
icon: "image",
updatedAt: "昨天 18:08"
},
{
name: "file.png",
type: "file",
size: "984 KB",
icon: "image",
updatedAt: "昨天 18:03"
}
]
}
]
},
{
name: "媒体",
type: "dir",
icon: "folder",
updatedAt: "今天 08:16",
children: [
{
name: "音乐",
type: "dir",
icon: "folder",
updatedAt: "今天 08:11",
children: [
{
name: "demo.mp3",
type: "file",
size: "6.8 MB",
icon: "music",
updatedAt: "今天 08:10"
}
]
},
{
name: "封面.jpg",
type: "file",
size: "2.4 MB",
icon: "image",
updatedAt: "昨天 22:40"
}
]
},
{
name: "系统日志.log",
type: "file",
size: "428 KB",
icon: "file-text",
updatedAt: "今天 07:55"
},
{
name: "backup.zip",
type: "file",
size: "126 MB",
icon: "file-zip",
updatedAt: "昨天 23:18"
}
];
const displayMode = ref<DisplayMode>(props.mode);
const localSegments = ref<string[]>(normalizePath(props.path));
const shouldUseRoutePath = computed(() => {
if (!props.useRoutePath) {
return false;
}
return route.name === "FilePage" || route.name === "FileExplorerPage";
});
const routeSegments = computed(() => normalizePath(route.params.pathMatch));
const currentSegments = computed(() => {
if (shouldUseRoutePath.value) {
return routeSegments.value;
}
return localSegments.value;
});
const currentItems = computed<ExplorerItem[]>(() => {
const target = resolveEntries(currentSegments.value);
return target.map((item) => ({
...item,
path: buildItemPath(currentSegments.value, item.name)
}));
});
watch(
() => props.mode,
(value) => {
displayMode.value = value;
},
{ immediate: true }
);
watch(
() => props.path,
(value) => {
if (shouldUseRoutePath.value) {
return;
}
localSegments.value = normalizePath(value);
},
{ immediate: true }
);
function normalizePath(source?: string | string[]): string[] {
if (Array.isArray(source)) {
return source.filter((segment) => !!segment);
}
if (!source) {
return [];
}
return source.split("/").filter((segment) => !!segment);
}
function resolveEntries(pathSegments: string[]): FileEntry[] {
let entries = fileTree;
for (const segment of pathSegments) {
const next = entries.find((item) => item.type === "dir" && item.name === segment);
if (!next || !next.children) {
return [];
}
entries = next.children;
}
return entries;
}
function buildItemPath(pathSegments: string[], name: string): string {
return [...pathSegments, name].join("/");
}
function setDisplayMode(mode: DisplayMode): void {
displayMode.value = mode;
emit("update:mode", mode);
}
async function openRoot(): Promise<void> {
await syncPath([]);
}
async function openParent(): Promise<void> {
if (shouldUseRoutePath.value && route.name === "FileExplorerPage" && currentSegments.value.length === 1) {
await router.back();
return;
}
await syncPath(currentSegments.value.slice(0, -1));
}
async function openByIndex(index: number): Promise<void> {
await syncPath(currentSegments.value.slice(0, index + 1));
}
async function openItem(item: ExplorerItem): Promise<void> {
if (item.type === "dir") {
const nextSegments = [...currentSegments.value, item.name];
emit("open-folder", nextSegments);
await syncPath(nextSegments);
return;
}
emit("open-file", item);
}
async function syncPath(nextSegments: string[]): Promise<void> {
if (shouldUseRoutePath.value) {
if (!nextSegments.length) {
await router.push("/");
return;
}
await router.push({
name: "FileExplorerPage",
params: {
pathMatch: nextSegments
}
});
return;
}
localSegments.value = nextSegments;
emit("update:path", nextSegments);
}
function formatItemDesc(item: ExplorerItem): string {
if (item.type === "dir") {
return `文件夹 · ${item.updatedAt}`;
}
return `${item.size || "--"} · ${item.updatedAt}`;
}
</script>
<style scoped lang="less">
.page {
gap: 1rem;
height: 100%;
display: flex;
padding: 1rem;
overflow: auto;
flex-direction: column;
.toolbar,
.go-up,
.item {
border: 1px solid var(--app-line);
background: var(--app-card);
box-shadow: 0 .35rem 1rem rgba(17, 32, 56, .05);
}
.toolbar {
gap: .75rem;
display: flex;
padding: .875rem;
border-radius: 1rem;
flex-direction: column;
.path,
.actions {
gap: .25rem;
display: flex;
flex-wrap: wrap;
align-items: center;
}
.crumb-wrap {
display: inline-flex;
align-items: center;
}
.sep {
color: var(--app-sub);
}
.crumb {
padding: 0;
}
}
.go-up,
.item {
gap: .875rem;
display: flex;
padding: .875rem 1rem;
border-radius: 1rem;
align-items: center;
cursor: pointer;
}
.list {
gap: .75rem;
display: grid;
&.is-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
.item {
padding: 1rem .875rem;
align-items: flex-start;
flex-direction: column;
.meta {
width: 100%;
}
}
}
}
.icon {
width: 2.75rem;
height: 2.75rem;
display: flex;
border-radius: .9rem;
align-items: center;
justify-content: center;
color: #FFF;
background: linear-gradient(135deg, #4D8DFF, #76A9FF);
&.dir {
background: linear-gradient(135deg, #FF9C3D, #FFBF69);
}
}
.meta {
gap: .3rem;
min-width: 0;
display: flex;
flex: 1 1 auto;
flex-direction: column;
}
.name,
.desc {
margin: 0;
word-break: break-all;
}
.name {
color: var(--app-text);
font-size: 1rem;
}
.desc {
color: var(--app-sub);
font-size: .8125rem;
}
}
:global(.theme-dark) .page {
.toolbar,
.go-up,
.item {
box-shadow: 0 .35rem 1rem rgba(0, 0, 0, .22);
}
}
</style>

View File

@@ -1,21 +0,0 @@
<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

@@ -1,25 +1,7 @@
<template> <template>
<div class="page"> <file-explorer-page />
<route-placeholder title="文件页" description="这里保留为文件管理入口,用于验证首页标签、布局容器和二级详情页切换。" />
<t-button block theme="primary" @click="openFileDetail">
打开文件详情页
</t-button>
</div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
const router = useRouter(); import FileExplorerPage from "@/pages/file/FileExplorerPage.vue";
async function openFileDetail(): Promise<void> {
await router.push("/files/detail/demo-file");
}
</script> </script>
<style scoped lang="less">
.page {
gap: 1rem;
display: flex;
padding: 1.2rem;
flex-direction: column;
}
</style>

View File

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

View File

@@ -4,10 +4,9 @@ import MainLayout from "@/layout/MainLayout.vue";
import tabs from "@/router/tabs"; import tabs from "@/router/tabs";
import { DEFAULT_BODY_BACKGROUND, useGlobalUIStore } from "@/store/globalUIStore"; import { DEFAULT_BODY_BACKGROUND, useGlobalUIStore } from "@/store/globalUIStore";
import { useSettingStore } from "@/store/settingStore"; import { useSettingStore } from "@/store/settingStore";
import LoginPage from "@/pages/system/LoginPage.vue";
import NotFoundPage from "@/pages/system/NotFoundPage.vue"; import NotFoundPage from "@/pages/system/NotFoundPage.vue";
import ServerIndexPage from "@/pages/system/ServerIndexPage.vue"; import ServerIndexPage from "@/pages/system/ServerIndexPage.vue";
import FileDetailPage from "@/pages/detail/FileDetailPage.vue"; import FileExplorerPage from "@/pages/file/FileExplorerPage.vue";
import ServerLogPage from "@/pages/detail/ServerLogPage.vue"; import ServerLogPage from "@/pages/detail/ServerLogPage.vue";
const router = createRouter({ const router = createRouter({
@@ -28,18 +27,6 @@ const router = createRouter({
name: "RootLayout", name: "RootLayout",
component: RootLayout, component: RootLayout,
children: [ children: [
{
path: "/login",
name: "LoginPage",
meta: {
depth: 1,
ignoreConnectCheck: true,
navBarVisible: false,
tabBarVisible: false,
bodyBackground: "#FFF"
},
component: LoginPage
},
{ {
path: "/server-index", path: "/server-index",
name: "ServerIndexPage", name: "ServerIndexPage",
@@ -63,16 +50,17 @@ const router = createRouter({
children: [ children: [
...tabs, ...tabs,
{ {
path: "/files/detail/:id", path: "/files/:pathMatch(.*)+",
name: "FileDetailPage", name: "FileExplorerPage",
meta: { meta: {
depth: 3, dynamicDepth: true,
baseDepth: 2,
navBarVisible: true, navBarVisible: true,
navBarCanBack: true, navBarCanBack: true,
navBarTitle: "文件详情", navBarTitle: "文件",
tabBarVisible: false tabBarVisible: false
}, },
component: FileDetailPage component: FileExplorerPage
}, },
{ {
path: "/server/logs", path: "/server/logs",

View File

@@ -12,7 +12,8 @@ const tabs: RouteRecordRaw[] = [
navBarVisible: true, navBarVisible: true,
navBarTitle: "文件", navBarTitle: "文件",
tabBarVisible: true, tabBarVisible: true,
tabBarPadding: true tabBarPadding: true,
bodyBackground: "#FFF"
}, },
component: FilePage component: FilePage
}, },
@@ -24,7 +25,8 @@ const tabs: RouteRecordRaw[] = [
navBarVisible: true, navBarVisible: true,
navBarTitle: "状态", navBarTitle: "状态",
tabBarVisible: true, tabBarVisible: true,
tabBarPadding: true tabBarPadding: true,
bodyBackground: "#FFF"
}, },
component: ServerStatusPage component: ServerStatusPage
}, },
@@ -37,7 +39,7 @@ const tabs: RouteRecordRaw[] = [
navBarTitle: "设置", navBarTitle: "设置",
tabBarVisible: true, tabBarVisible: true,
tabBarPadding: true, tabBarPadding: true,
bodyBackground: "var(--app-bg)" bodyBackground: "#FFF"
}, },
component: SettingsPage component: SettingsPage
} }