Files
timi-server/src/pages/file/FileExplorerPage.vue
2026-04-13 16:11:37 +08:00

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>