397 lines
7.7 KiB
Vue
397 lines
7.7 KiB
Vue
<template>
|
|
<div class="page">
|
|
<section v-if="currentItems.length" class="content">
|
|
<file-explorer-list
|
|
v-if="displayMode === 'list'"
|
|
:items="currentItems"
|
|
:pending-path="pendingFolderPath"
|
|
@open="openItem"
|
|
@queue="queueItem"
|
|
@play="playItem"
|
|
/>
|
|
<file-explorer-grid
|
|
v-else
|
|
:items="currentItems"
|
|
:pending-path="pendingFolderPath"
|
|
@open="openItem"
|
|
/>
|
|
</section>
|
|
<div v-else-if="pageLoading" class="loading-wrap">
|
|
<t-loading text="Loading directory" />
|
|
</div>
|
|
<t-empty v-else description="Directory is empty" />
|
|
</div>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import { Toast } from "tdesign-mobile-vue";
|
|
import FileAPI from "@/api/FileAPI";
|
|
import FileExplorerGrid from "./FileExplorerGrid.vue";
|
|
import FileExplorerList from "./FileExplorerList.vue";
|
|
import { useNavBarStore } from "@/store/navBarStore";
|
|
import {
|
|
type DisplayMode,
|
|
type ExplorerItem,
|
|
mapServerFileToExplorerItem,
|
|
sortExplorerItems
|
|
} from "./fileExplorer.shared";
|
|
|
|
const props = withDefaults(defineProps<{
|
|
mode?: DisplayMode;
|
|
path?: string | string[];
|
|
useRoutePath?: boolean;
|
|
}>(), {
|
|
mode: "list",
|
|
path: "",
|
|
useRoutePath: true
|
|
});
|
|
|
|
const emit = defineEmits([
|
|
"update:path",
|
|
"open-folder",
|
|
"open-file",
|
|
"queue-file",
|
|
"play-file",
|
|
"update:mode"
|
|
]);
|
|
|
|
const route = useRoute();
|
|
const router = useRouter();
|
|
const navBarStore = useNavBarStore();
|
|
const navBarRightOwner = `file-explorer-page-${Math.random().toString(36).slice(2)}`;
|
|
|
|
const displayMode = ref<DisplayMode>(props.mode);
|
|
const localSegments = ref<string[]>(normalizePath(props.path));
|
|
const directoryCache = ref<Record<string, ExplorerItem[]>>({});
|
|
const pageLoading = ref(false);
|
|
const pendingFolderPath = ref("");
|
|
let latestLoadToken = 0;
|
|
|
|
const shouldUseRoutePath = computed(() => {
|
|
if (!props.useRoutePath) {
|
|
return false;
|
|
}
|
|
|
|
return route.name === "FileTab" || 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[]>(() => {
|
|
return directoryCache.value[getDirectoryKey(currentSegments.value)] || [];
|
|
});
|
|
|
|
const currentTitle = computed(() => {
|
|
if (!currentSegments.value.length) {
|
|
return "文件";
|
|
}
|
|
|
|
return currentSegments.value[currentSegments.value.length - 1];
|
|
});
|
|
|
|
const displayModeActionText = computed(() => {
|
|
return displayMode.value === "list" ? "平铺" : "列表";
|
|
});
|
|
|
|
const navBarRightRenderer = defineComponent({
|
|
name: "file-explorer-nav-right",
|
|
setup() {
|
|
const buttonComponent = resolveComponent("t-button");
|
|
const iconComponent = resolveComponent("t-icon");
|
|
|
|
return () => h(buttonComponent, {
|
|
size: "small",
|
|
variant: "text",
|
|
theme: "primary",
|
|
class: "nav-switch",
|
|
shape: "square",
|
|
"aria-label": displayModeActionText.value,
|
|
onClick: toggleDisplayMode
|
|
}, {
|
|
icon: () => h(iconComponent, {
|
|
name: displayMode.value === "list" ? "grid-view" : "view-list",
|
|
size: "1.5rem"
|
|
})
|
|
});
|
|
}
|
|
});
|
|
|
|
watch(
|
|
() => props.mode,
|
|
(value) => {
|
|
displayMode.value = value;
|
|
},
|
|
{ immediate: true }
|
|
);
|
|
|
|
watch(
|
|
currentTitle,
|
|
(value) => {
|
|
navBarStore.setTitle(value);
|
|
},
|
|
{ immediate: true }
|
|
);
|
|
|
|
watch(
|
|
displayModeActionText,
|
|
() => {
|
|
navBarStore.setRightRenderer(navBarRightRenderer, navBarRightOwner);
|
|
},
|
|
{ immediate: true }
|
|
);
|
|
|
|
watch(
|
|
() => props.path,
|
|
(value) => {
|
|
if (shouldUseRoutePath.value) {
|
|
return;
|
|
}
|
|
|
|
localSegments.value = normalizePath(value);
|
|
},
|
|
{ immediate: true }
|
|
);
|
|
|
|
watch(
|
|
currentSegments,
|
|
(segments) => {
|
|
void hydrateCurrentDirectory(segments);
|
|
},
|
|
{ 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 setDisplayMode(mode: DisplayMode): void {
|
|
if (displayMode.value === mode) {
|
|
return;
|
|
}
|
|
|
|
displayMode.value = mode;
|
|
emit("update:mode", mode);
|
|
}
|
|
|
|
function toggleDisplayMode(): void {
|
|
setDisplayMode(displayMode.value === "list" ? "grid" : "list");
|
|
}
|
|
|
|
onUnmounted(() => {
|
|
navBarStore.clearRight(navBarRightOwner);
|
|
});
|
|
|
|
async function openItem(item: ExplorerItem): Promise<void> {
|
|
if (pendingFolderPath.value) {
|
|
return;
|
|
}
|
|
|
|
if (item.type === "dir") {
|
|
const nextSegments = normalizePath(item.path);
|
|
pendingFolderPath.value = item.path;
|
|
|
|
try {
|
|
await ensureDirectoryLoaded(nextSegments, false);
|
|
emit("open-folder", nextSegments);
|
|
await syncPath(nextSegments);
|
|
} catch (error) {
|
|
Toast({
|
|
theme: "error",
|
|
message: "操作失败"
|
|
});
|
|
} finally {
|
|
pendingFolderPath.value = "";
|
|
}
|
|
|
|
return;
|
|
}
|
|
|
|
emit("open-file", item);
|
|
}
|
|
|
|
function queueItem(item: ExplorerItem): void {
|
|
emit("queue-file", item);
|
|
}
|
|
|
|
function playItem(item: ExplorerItem): void {
|
|
console.log("play-item", item);
|
|
emit("play-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);
|
|
}
|
|
|
|
async function hydrateCurrentDirectory(pathSegments: string[]): Promise<void> {
|
|
try {
|
|
await ensureDirectoryLoaded(pathSegments, true);
|
|
} catch (error) {
|
|
Toast({
|
|
theme: "error",
|
|
message: "操作失败"
|
|
});
|
|
}
|
|
}
|
|
|
|
async function ensureDirectoryLoaded(pathSegments: string[], updatePageLoading: boolean): Promise<ExplorerItem[]> {
|
|
const cacheKey = getDirectoryKey(pathSegments);
|
|
const cachedItems = directoryCache.value[cacheKey];
|
|
|
|
if (cachedItems) {
|
|
return cachedItems;
|
|
}
|
|
|
|
const requestToken = latestLoadToken + 1;
|
|
latestLoadToken = requestToken;
|
|
|
|
if (updatePageLoading) {
|
|
pageLoading.value = true;
|
|
}
|
|
|
|
try {
|
|
const files = await FileAPI.list(pathSegments);
|
|
const items = sortExplorerItems(files.map((item) => mapServerFileToExplorerItem(item)));
|
|
directoryCache.value = {
|
|
...directoryCache.value,
|
|
[cacheKey]: items
|
|
};
|
|
return items;
|
|
} finally {
|
|
if (updatePageLoading && requestToken === latestLoadToken) {
|
|
pageLoading.value = false;
|
|
}
|
|
}
|
|
}
|
|
|
|
function getDirectoryKey(pathSegments: string[]): string {
|
|
return pathSegments.join("/");
|
|
}
|
|
</script>
|
|
|
|
<style scoped lang="less">
|
|
.page {
|
|
--page-top-gap: calc(var(--app-nav-offset, 0px) + 1rem);
|
|
|
|
gap: 1rem;
|
|
height: 100%;
|
|
box-sizing: border-box;
|
|
display: flex;
|
|
overflow: hidden;
|
|
flex-direction: column;
|
|
|
|
.nav-switch {
|
|
padding: 0;
|
|
width: 2rem;
|
|
height: 2rem;
|
|
}
|
|
|
|
.go-up {
|
|
border: 1px solid var(--app-line);
|
|
background: var(--app-card);
|
|
box-shadow: 0 .35rem 1rem rgba(17, 32, 56, .05);
|
|
}
|
|
|
|
.go-up {
|
|
gap: .875rem;
|
|
display: flex;
|
|
padding: .875rem 1rem;
|
|
border-radius: 1rem;
|
|
align-items: center;
|
|
cursor: pointer;
|
|
}
|
|
|
|
.content,
|
|
.loading-wrap {
|
|
min-height: 0;
|
|
flex: 1 1 auto;
|
|
}
|
|
|
|
.content {
|
|
display: flex;
|
|
overflow: hidden;
|
|
|
|
> * {
|
|
width: 100%;
|
|
flex: 1 1 auto;
|
|
min-height: 0;
|
|
}
|
|
}
|
|
|
|
.loading-wrap {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
}
|
|
|
|
.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;
|
|
}
|
|
}
|
|
</style>
|