add FileExplorerPage
This commit is contained in:
463
src/pages/file/FileExplorerPage.vue
Normal file
463
src/pages/file/FileExplorerPage.vue
Normal 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>
|
||||
Reference in New Issue
Block a user