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

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