fix MainLayout.vue content height

This commit is contained in:
Timi
2026-04-03 23:44:46 +08:00
parent d819249ebf
commit 594d1b4222
9 changed files with 531 additions and 177 deletions

View File

@@ -4,25 +4,35 @@
:items="rows"
:item-size="158"
key-field="key"
v-slot="{ item }"
>
<div class="grid-row">
<article
v-for="entry in item.items"
:key="entry.path"
class="card glass-white"
@click="emit('open', entry)"
>
<div :class="['icon', entry.type]">
<t-icon :name="entry.type === 'dir' ? 'folder' : entry.icon" />
</div>
<div class="meta">
<p class="name" v-text="entry.name"></p>
<p class="desc" v-text="formatItemDesc(entry)"></p>
</div>
</article>
<div v-if="item.items.length < columns" class="card ghost"></div>
</div>
<template #before>
<div aria-hidden="true" class="spacer top"></div>
</template>
<template #default="{ item }">
<div class="grid-row">
<article
v-for="entry in item.items"
:key="entry.path"
:class="['card glass-white', { loading: entry.path === pendingPath }]"
@click="handleOpen(entry)"
>
<div :class="['icon', entry.type]">
<t-icon :name="entry.type === 'dir' ? 'folder' : entry.icon" />
</div>
<div class="meta">
<p class="name" v-text="entry.name"></p>
<div class="desc">
<t-loading v-if="entry.path === pendingPath" size="1rem" />
<p v-else class="desc-text" v-text="formatItemDesc(entry)"></p>
</div>
</div>
</article>
<div v-if="item.items.length < columns" class="card ghost"></div>
</div>
</template>
<template #after>
<div aria-hidden="true" class="spacer bottom"></div>
</template>
</recycle-scroller>
</template>
@@ -40,6 +50,7 @@ const columns = 2;
const props = defineProps<{
items: ExplorerItem[];
pendingPath?: string;
}>();
const emit = defineEmits<{
@@ -59,11 +70,37 @@ const rows = computed<GridRow[]>(() => {
return nextRows;
});
function handleOpen(item: ExplorerItem): void {
if (props.pendingPath) {
return;
}
emit("open", item);
}
</script>
<style scoped lang="less">
.scroller {
--top-gap: var(--page-top-gap, 1rem);
--bottom-gap: 5.5rem;
width: 100%;
height: 100%;
min-height: 0;
}
.spacer {
width: 100%;
pointer-events: none;
&.top {
height: var(--top-gap);
}
&.bottom {
height: var(--bottom-gap);
}
}
.grid-row {
@@ -84,6 +121,10 @@ const rows = computed<GridRow[]>(() => {
box-shadow: 0 .35rem 1rem rgba(17, 32, 56, .05);
cursor: pointer;
&.loading {
opacity: .72;
}
&.ghost {
visibility: hidden;
pointer-events: none;
@@ -113,7 +154,7 @@ const rows = computed<GridRow[]>(() => {
}
.name,
.desc {
.desc-text {
margin: 0;
word-break: break-all;
}
@@ -124,6 +165,12 @@ const rows = computed<GridRow[]>(() => {
}
.desc {
min-height: 1rem;
display: flex;
align-items: center;
}
.desc-text {
color: var(--app-sub);
font-size: .8125rem;
}

View File

@@ -1,58 +1,135 @@
<template>
<t-cell-group theme="card" class="file-explorer-list">
<recycle-scroller
class="scroller"
:items="items"
:item-size="56"
key-field="path"
v-slot="{ item }"
>
<recycle-scroller
class="file-explorer-list"
:items="items"
:item-size="56"
key-field="path"
>
<template #before>
<div aria-hidden="true" class="spacer top"></div>
</template>
<template #default="{ item }">
<t-swipe-cell>
<t-cell
class="cell"
:title="item.name"
:arrow="item.type === 'dir'"
:note="item.size"
@click="emit('open', item)"
@click="handleOpen(item)"
>
<template #note>
<div class="note">
<t-loading v-if="item.path === pendingPath" size="1rem" />
<span v-if="item.type === 'file'" v-text="item.size"></span>
</div>
</template>
<template #left-icon>
<t-icon :name="item.type === 'dir' ? 'folder' : item.icon" />
</template>
</t-cell>
</t-swipe-cell>
</recycle-scroller>
</t-cell-group>
</template>
<template #after>
<div aria-hidden="true" class="spacer bottom"></div>
</template>
</recycle-scroller>
</template>
<script setup lang="ts">
import { RecycleScroller } from "vue-virtual-scroller";
import { type ExplorerItem } from "./fileExplorer.shared";
defineProps<{
const props = defineProps<{
items: ExplorerItem[];
pendingPath?: string;
}>();
const emit = defineEmits<{
open: [item: ExplorerItem];
}>();
function handleOpen(item: ExplorerItem): void {
if (props.pendingPath) {
return;
}
emit("open", item);
}
</script>
<style scoped lang="less">
.file-explorer-list {
overflow: hidden;
--top-gap: var(--page-top-gap, 1rem);
--bottom-gap: 5.5rem;
:deep(.t-cell-group) {
margin: 0;
flex: 1 1 auto;
height: 100%;
padding: 0 1rem;
display: flex;
overflow: hidden;
min-height: 0;
flex-direction: column;
&:deep(.vue-recycle-scroller__item-view) {
&:first-child {
.cell {
border-radius: var(--td-radius-large) var(--td-radius-large) 0 0;
}
}
&:last-child {
.cell {
border-radius: 0 0 var(--td-radius-large) var(--td-radius-large);
&:after {
border-bottom: 0;
}
}
}
}
.scroller {
height: 100%;
.spacer {
width: 100%;
pointer-events: none;
.vue-recycle-scroller__item-view {
&.top {
height: var(--top-gap);
}
&:last-child .cell:after {
border-bottom: 0;
}
&.bottom {
height: var(--bottom-gap);
}
}
.note {
gap: .375rem;
display: flex;
flex: 0 0 auto;
min-height: 1rem;
align-items: center;
white-space: nowrap;
color: var(--app-sub);
}
.cell {
&:deep(.t-cell__title) {
flex: 1 1 auto;
overflow: hidden;
min-width: 0;
}
&:deep(.t-cell__title-text) {
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
&:deep(.t-cell__note) {
flex: 0 0 auto;
white-space: nowrap;
}
}
}

View File

@@ -4,26 +4,34 @@
<file-explorer-list
v-if="displayMode === 'list'"
:items="currentItems"
:pending-path="pendingFolderPath"
@open="openItem"
/>
<file-explorer-grid
v-else
:items="currentItems"
:pending-path="pendingFolderPath"
@open="openItem"
/>
</section>
<div v-else-if="pageLoading" class="loading-wrap">
<t-loading text="加载目录中" />
</div>
<t-empty v-else description="当前目录为空" />
</div>
</template>
<script setup lang="ts">
import { Toast } from "tdesign-mobile-vue";
import { listServerFiles, resolveRequestErrorMessage } from "@/api/file";
import FileExplorerGrid from "./FileExplorerGrid.vue";
import FileExplorerList from "./FileExplorerList.vue";
import { useNavBarStore } from "@/store/navBarStore";
import {
type DisplayMode,
type ExplorerItem,
type FileEntry
mapServerFileToExplorerItem,
sortExplorerItems
} from "./fileExplorer.shared";
const props = withDefaults(defineProps<{
@@ -48,92 +56,12 @@ const router = useRouter();
const navBarStore = useNavBarStore();
const navBarRightOwner = `file-explorer-page-${Math.random().toString(36).slice(2)}`;
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",
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 directoryCache = ref<Record<string, ExplorerItem[]>>({});
const pageLoading = ref(false);
const pendingFolderPath = ref("");
let latestLoadToken = 0;
const shouldUseRoutePath = computed(() => {
if (!props.useRoutePath) {
@@ -153,12 +81,9 @@ const currentSegments = computed(() => {
});
const currentItems = computed<ExplorerItem[]>(() => {
const target = resolveEntries(currentSegments.value);
return target.map((item) => ({
...item,
path: buildItemPath(currentSegments.value, item.name)
}));
return directoryCache.value[getDirectoryKey(currentSegments.value)] || [];
});
const currentTitle = computed(() => {
if (!currentSegments.value.length) {
return "文件";
@@ -166,9 +91,11 @@ const currentTitle = computed(() => {
return currentSegments.value[currentSegments.value.length - 1];
});
const displayModeActionText = computed(() => {
return displayMode.value === "list" ? "平铺" : "列表";
});
const navBarRightRenderer = defineComponent({
name: "file-explorer-nav-right",
setup() {
@@ -228,6 +155,14 @@ watch(
{ 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);
@@ -240,25 +175,6 @@ function normalizePath(source?: string | string[]): string[] {
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 {
if (displayMode.value === mode) {
return;
@@ -276,20 +192,28 @@ onUnmounted(() => {
navBarStore.clearRight(navBarRightOwner);
});
async function openParent(): Promise<void> {
if (shouldUseRoutePath.value && route.name === "FileExplorerPage" && currentSegments.value.length === 1) {
await router.back();
async function openItem(item: ExplorerItem): Promise<void> {
if (pendingFolderPath.value) {
return;
}
await syncPath(currentSegments.value.slice(0, -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);
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: resolveRequestErrorMessage(error)
});
} finally {
pendingFolderPath.value = "";
}
return;
}
@@ -315,14 +239,61 @@ async function syncPath(nextSegments: string[]): Promise<void> {
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: resolveRequestErrorMessage(error)
});
}
}
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 listServerFiles(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: calc(100% - 2rem);
height: 100%;
box-sizing: border-box;
display: flex;
padding: 1rem;
overflow: hidden;
flex-direction: column;
@@ -347,11 +318,29 @@ async function syncPath(nextSegments: string[]): Promise<void> {
cursor: pointer;
}
.content {
.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;

View File

@@ -1,23 +1,132 @@
export type DisplayMode = "list" | "grid";
export type FileItemType = "dir" | "file";
export interface FileEntry {
export interface ServerFile {
name: string;
extension?: string;
absolutePath?: string;
size?: number;
modifiedAt?: number;
type?: string;
isFile?: boolean;
isDirectory?: boolean;
canPreview?: boolean;
previewURI?: string;
}
export interface ExplorerItem {
name: string;
type: FileItemType;
size?: string;
updatedAt: string;
icon: string;
children?: FileEntry[];
path: string;
rawType?: string;
}
export interface ExplorerItem extends Omit<FileEntry, "children"> {
path: string;
const fileIconMap: Record<string, string> = {
TXT: "file-text",
CODE: "code",
SCRIPT: "code",
ZIP: "file-zip",
MICROSOFT: "file-excel",
JAVA: "logo-android",
VIDEO: "video",
FONT: "font-size",
AUDIO: "music",
IMAGE: "image",
SYSTEM: "setting",
FILE: "file"
};
const dateTimeFormatter = new Intl.DateTimeFormat("zh-CN", {
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
hour12: false
});
export function mapServerFileToExplorerItem(file: ServerFile): ExplorerItem {
const isDirectory = file.isDirectory || file.type === "DIRECTORY";
const rawPath = typeof file.absolutePath === "string" ? file.absolutePath.trim() : "";
return {
name: file.name || "未命名",
type: isDirectory ? "dir" : "file",
size: isDirectory ? undefined : formatFileSize(file.size),
updatedAt: formatModifiedAt(file.modifiedAt),
icon: resolveFileIcon(file, isDirectory),
path: rawPath || file.name || "",
rawType: file.type
};
}
export function sortExplorerItems(items: ExplorerItem[]): ExplorerItem[] {
return [...items].sort((left, right) => {
if (left.type !== right.type) {
return left.type === "dir" ? -1 : 1;
}
return left.name.localeCompare(right.name, "zh-CN");
});
}
export function formatItemDesc(item: ExplorerItem): string {
if (item.type === "dir") {
return `文件夹 · ${item.updatedAt}`;
return `文件夹 | ${item.updatedAt}`;
}
return `${item.size || "--"} · ${item.updatedAt}`;
return `${item.size || "--"} | ${item.updatedAt}`;
}
function resolveFileIcon(file: ServerFile, isDirectory: boolean): string {
if (isDirectory) {
return "folder";
}
const rawType = typeof file.type === "string" ? file.type.toUpperCase() : "FILE";
return fileIconMap[rawType] || "file";
}
function formatModifiedAt(modifiedAt?: number): string {
if (!modifiedAt) {
return "未知时间";
}
return dateTimeFormatter.format(modifiedAt);
}
function formatFileSize(size?: number): string {
if (typeof size !== "number" || Number.isNaN(size) || size < 0) {
return "--";
}
if (size < 1024) {
return `${size} B`;
}
const units = ["KB", "MB", "GB", "TB"];
let nextSize = size / 1024;
let unitIndex = 0;
while (1024 <= nextSize && unitIndex < units.length - 1) {
nextSize /= 1024;
unitIndex += 1;
}
return `${trimDecimal(nextSize)} ${units[unitIndex]}`;
}
function trimDecimal(value: number): string {
if (10 <= value || Number.isInteger(value)) {
return value.toFixed(0);
}
if (1 <= value) {
return value.toFixed(1);
}
return value.toFixed(2);
}