fix MainLayout.vue content height
This commit is contained in:
@@ -1 +1 @@
|
|||||||
VITE_API=https://api.imyeyu.dev
|
VITE_API=http://localhost:8091
|
||||||
|
|||||||
13
src/Root.vue
13
src/Root.vue
@@ -23,3 +23,16 @@ watch(
|
|||||||
{ immediate: true }
|
{ immediate: true }
|
||||||
);
|
);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<style lang="less">
|
||||||
|
html,
|
||||||
|
body,
|
||||||
|
#root {
|
||||||
|
height: 100%;
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
102
src/api/file.ts
Normal file
102
src/api/file.ts
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
import axios, { AxiosError } from "axios";
|
||||||
|
import { useSettingStore } from "@/store/settingStore";
|
||||||
|
import type { ServerFile } from "@/pages/file/fileExplorer.shared";
|
||||||
|
|
||||||
|
interface ApiResponse<T> {
|
||||||
|
code?: number | string;
|
||||||
|
data?: T;
|
||||||
|
list?: T;
|
||||||
|
msg?: string;
|
||||||
|
message?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listServerFiles(pathSegments: string[]): Promise<ServerFile[]> {
|
||||||
|
const response = await axios.get<ApiResponse<ServerFile[]> | ServerFile[]>(buildListURL(pathSegments), {
|
||||||
|
params: buildQueryParams(),
|
||||||
|
timeout: 15000
|
||||||
|
});
|
||||||
|
|
||||||
|
return unwrapServerFileList(response.data);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveRequestErrorMessage(error: unknown): string {
|
||||||
|
if (error instanceof AxiosError) {
|
||||||
|
const message = resolveApiMessage(error.response?.data);
|
||||||
|
if (message) {
|
||||||
|
return message;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error.message) {
|
||||||
|
return error.message;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error instanceof Error && error.message) {
|
||||||
|
return error.message;
|
||||||
|
}
|
||||||
|
|
||||||
|
return "请求失败,请稍后重试";
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildListURL(pathSegments: string[]): string {
|
||||||
|
const path = pathSegments.length ? `/${pathSegments.map((segment) => encodeURIComponent(segment)).join("/")}` : "";
|
||||||
|
return `${resolveBaseURL()}/system/file/list${path}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveBaseURL(): string {
|
||||||
|
const settingStore = useSettingStore();
|
||||||
|
const connect = settingStore.connect;
|
||||||
|
const envBaseURL = typeof import.meta.env.VITE_API === "string" ? import.meta.env.VITE_API.trim() : "";
|
||||||
|
|
||||||
|
if (connect.host && connect.port) {
|
||||||
|
return `${connect.protocol}://${connect.host}:${connect.port}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return envBaseURL.replace(/\/+$/, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildQueryParams(): Record<string, string> {
|
||||||
|
const settingStore = useSettingStore();
|
||||||
|
const token = settingStore.connect.token.trim();
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
token
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function unwrapServerFileList(payload: ApiResponse<ServerFile[]> | ServerFile[]): ServerFile[] {
|
||||||
|
if (Array.isArray(payload)) {
|
||||||
|
return payload;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Array.isArray(payload.data)) {
|
||||||
|
return payload.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Array.isArray(payload.list)) {
|
||||||
|
return payload.list;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(resolveApiMessage(payload) || "文件列表返回格式不正确");
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveApiMessage(payload: unknown): string {
|
||||||
|
if (!payload || typeof payload !== "object") {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
const { msg, message } = payload as ApiResponse<unknown>;
|
||||||
|
if (typeof msg === "string" && msg.trim()) {
|
||||||
|
return msg.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof message === "string" && message.trim()) {
|
||||||
|
return message.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
return "";
|
||||||
|
}
|
||||||
@@ -100,6 +100,7 @@ onUnmounted(() => {
|
|||||||
|
|
||||||
.page-transition {
|
.page-transition {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
display: grid;
|
display: grid;
|
||||||
min-height: 100%;
|
min-height: 100%;
|
||||||
position: relative;
|
position: relative;
|
||||||
@@ -108,6 +109,7 @@ onUnmounted(() => {
|
|||||||
.pages {
|
.pages {
|
||||||
grid-area: 1 / 1;
|
grid-area: 1 / 1;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
min-height: 100%;
|
min-height: 100%;
|
||||||
position: relative;
|
position: relative;
|
||||||
background: v-bind(pageBackground);
|
background: v-bind(pageBackground);
|
||||||
@@ -120,6 +122,7 @@ onUnmounted(() => {
|
|||||||
.push-right-leave-active {
|
.push-right-leave-active {
|
||||||
grid-area: 1 / 1;
|
grid-area: 1 / 1;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
min-height: 100%;
|
min-height: 100%;
|
||||||
transition: transform @duration @easing, opacity .2s linear;
|
transition: transform @duration @easing, opacity .2s linear;
|
||||||
backface-visibility: hidden;
|
backface-visibility: hidden;
|
||||||
|
|||||||
@@ -27,7 +27,7 @@
|
|||||||
:disabled="!tabBarStore.isShowing"
|
:disabled="!tabBarStore.isShowing"
|
||||||
@change="onChangeTab"
|
@change="onChangeTab"
|
||||||
>
|
>
|
||||||
<t-tab-bar-item class="item g-transparent" v-for="item in tabList" :key="item.value" :value="item.value">
|
<t-tab-bar-item class="item bg-transparent" v-for="item in tabList" :key="item.value" :value="item.value">
|
||||||
<template #icon>
|
<template #icon>
|
||||||
<t-icon :name="item.icon" />
|
<t-icon :name="item.icon" />
|
||||||
</template>
|
</template>
|
||||||
@@ -88,6 +88,10 @@ const navBarPadding = computed(() => {
|
|||||||
return "0";
|
return "0";
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const topPadding = computed(() => {
|
||||||
|
return `calc(${navBarPadding.value} + var(--safe-top))`;
|
||||||
|
});
|
||||||
|
|
||||||
// ---------- Tab 切换 ----------
|
// ---------- Tab 切换 ----------
|
||||||
|
|
||||||
const tabVal = ref(route.path);
|
const tabVal = ref(route.path);
|
||||||
@@ -138,17 +142,22 @@ const isContentFixedHeight = computed(() => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const bodyHeight = computed(() => {
|
const bodyHeight = computed(() => {
|
||||||
return `calc(100vh - ${navBarPadding.value} - ${tabBarPadding.value})`;
|
return `calc(100vh - ${tabBarPadding.value})`;
|
||||||
|
});
|
||||||
|
|
||||||
|
const contentHeight = computed(() => {
|
||||||
|
return bodyHeight.value;
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped lang="less">
|
<style scoped lang="less">
|
||||||
.main-layout {
|
.main-layout {
|
||||||
|
--app-nav-offset: v-bind(topPadding);
|
||||||
--safe-top: env(safe-area-inset-top, 0px);
|
--safe-top: env(safe-area-inset-top, 0px);
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
|
isolation: isolate;
|
||||||
position: relative;
|
position: relative;
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
padding-top: calc(v-bind(navBarPadding) + var(--safe-top));
|
|
||||||
padding-bottom: v-bind(tabBarPadding);
|
padding-bottom: v-bind(tabBarPadding);
|
||||||
transition: padding-bottom .24s ease;
|
transition: padding-bottom .24s ease;
|
||||||
|
|
||||||
@@ -157,7 +166,12 @@ const bodyHeight = computed(() => {
|
|||||||
left: 0;
|
left: 0;
|
||||||
right: 0;
|
right: 0;
|
||||||
position: fixed;
|
position: fixed;
|
||||||
z-index: 999;
|
z-index: 1100;
|
||||||
|
|
||||||
|
:deep(.t-navbar) {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
:deep(.t-navbar__content) {
|
:deep(.t-navbar__content) {
|
||||||
color: var(--tui-black, #000);
|
color: var(--tui-black, #000);
|
||||||
@@ -178,11 +192,11 @@ const bodyHeight = computed(() => {
|
|||||||
.router-view {
|
.router-view {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
position: relative;
|
position: relative;
|
||||||
z-index: 1;
|
z-index: 0;
|
||||||
min-height: calc(v-bind(bodyHeight) - var(--safe-top));
|
min-height: v-bind(contentHeight);
|
||||||
|
|
||||||
&.is-fixed-height {
|
&.is-fixed-height {
|
||||||
height: calc(v-bind(bodyHeight) - var(--safe-top));
|
height: v-bind(contentHeight);
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,25 +4,35 @@
|
|||||||
:items="rows"
|
:items="rows"
|
||||||
:item-size="158"
|
:item-size="158"
|
||||||
key-field="key"
|
key-field="key"
|
||||||
v-slot="{ item }"
|
|
||||||
>
|
>
|
||||||
|
<template #before>
|
||||||
|
<div aria-hidden="true" class="spacer top"></div>
|
||||||
|
</template>
|
||||||
|
<template #default="{ item }">
|
||||||
<div class="grid-row">
|
<div class="grid-row">
|
||||||
<article
|
<article
|
||||||
v-for="entry in item.items"
|
v-for="entry in item.items"
|
||||||
:key="entry.path"
|
:key="entry.path"
|
||||||
class="card glass-white"
|
:class="['card glass-white', { loading: entry.path === pendingPath }]"
|
||||||
@click="emit('open', entry)"
|
@click="handleOpen(entry)"
|
||||||
>
|
>
|
||||||
<div :class="['icon', entry.type]">
|
<div :class="['icon', entry.type]">
|
||||||
<t-icon :name="entry.type === 'dir' ? 'folder' : entry.icon" />
|
<t-icon :name="entry.type === 'dir' ? 'folder' : entry.icon" />
|
||||||
</div>
|
</div>
|
||||||
<div class="meta">
|
<div class="meta">
|
||||||
<p class="name" v-text="entry.name"></p>
|
<p class="name" v-text="entry.name"></p>
|
||||||
<p class="desc" v-text="formatItemDesc(entry)"></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>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
<div v-if="item.items.length < columns" class="card ghost"></div>
|
<div v-if="item.items.length < columns" class="card ghost"></div>
|
||||||
</div>
|
</div>
|
||||||
|
</template>
|
||||||
|
<template #after>
|
||||||
|
<div aria-hidden="true" class="spacer bottom"></div>
|
||||||
|
</template>
|
||||||
</recycle-scroller>
|
</recycle-scroller>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -40,6 +50,7 @@ const columns = 2;
|
|||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
items: ExplorerItem[];
|
items: ExplorerItem[];
|
||||||
|
pendingPath?: string;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
@@ -59,11 +70,37 @@ const rows = computed<GridRow[]>(() => {
|
|||||||
|
|
||||||
return nextRows;
|
return nextRows;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function handleOpen(item: ExplorerItem): void {
|
||||||
|
if (props.pendingPath) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
emit("open", item);
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped lang="less">
|
<style scoped lang="less">
|
||||||
.scroller {
|
.scroller {
|
||||||
|
--top-gap: var(--page-top-gap, 1rem);
|
||||||
|
--bottom-gap: 5.5rem;
|
||||||
|
|
||||||
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spacer {
|
||||||
|
width: 100%;
|
||||||
|
pointer-events: none;
|
||||||
|
|
||||||
|
&.top {
|
||||||
|
height: var(--top-gap);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.bottom {
|
||||||
|
height: var(--bottom-gap);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.grid-row {
|
.grid-row {
|
||||||
@@ -84,6 +121,10 @@ const rows = computed<GridRow[]>(() => {
|
|||||||
box-shadow: 0 .35rem 1rem rgba(17, 32, 56, .05);
|
box-shadow: 0 .35rem 1rem rgba(17, 32, 56, .05);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
|
||||||
|
&.loading {
|
||||||
|
opacity: .72;
|
||||||
|
}
|
||||||
|
|
||||||
&.ghost {
|
&.ghost {
|
||||||
visibility: hidden;
|
visibility: hidden;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
@@ -113,7 +154,7 @@ const rows = computed<GridRow[]>(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.name,
|
.name,
|
||||||
.desc {
|
.desc-text {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
word-break: break-all;
|
word-break: break-all;
|
||||||
}
|
}
|
||||||
@@ -124,6 +165,12 @@ const rows = computed<GridRow[]>(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.desc {
|
.desc {
|
||||||
|
min-height: 1rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.desc-text {
|
||||||
color: var(--app-sub);
|
color: var(--app-sub);
|
||||||
font-size: .8125rem;
|
font-size: .8125rem;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,59 +1,136 @@
|
|||||||
<template>
|
<template>
|
||||||
<t-cell-group theme="card" class="file-explorer-list">
|
|
||||||
<recycle-scroller
|
<recycle-scroller
|
||||||
class="scroller"
|
class="file-explorer-list"
|
||||||
:items="items"
|
:items="items"
|
||||||
:item-size="56"
|
:item-size="56"
|
||||||
key-field="path"
|
key-field="path"
|
||||||
v-slot="{ item }"
|
|
||||||
>
|
>
|
||||||
|
<template #before>
|
||||||
|
<div aria-hidden="true" class="spacer top"></div>
|
||||||
|
</template>
|
||||||
|
<template #default="{ item }">
|
||||||
<t-swipe-cell>
|
<t-swipe-cell>
|
||||||
<t-cell
|
<t-cell
|
||||||
class="cell"
|
class="cell"
|
||||||
:title="item.name"
|
:title="item.name"
|
||||||
:arrow="item.type === 'dir'"
|
:arrow="item.type === 'dir'"
|
||||||
:note="item.size"
|
@click="handleOpen(item)"
|
||||||
@click="emit('open', 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>
|
<template #left-icon>
|
||||||
<t-icon :name="item.type === 'dir' ? 'folder' : item.icon" />
|
<t-icon :name="item.type === 'dir' ? 'folder' : item.icon" />
|
||||||
</template>
|
</template>
|
||||||
</t-cell>
|
</t-cell>
|
||||||
</t-swipe-cell>
|
</t-swipe-cell>
|
||||||
|
</template>
|
||||||
|
<template #after>
|
||||||
|
<div aria-hidden="true" class="spacer bottom"></div>
|
||||||
|
</template>
|
||||||
</recycle-scroller>
|
</recycle-scroller>
|
||||||
</t-cell-group>
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { RecycleScroller } from "vue-virtual-scroller";
|
import { RecycleScroller } from "vue-virtual-scroller";
|
||||||
import { type ExplorerItem } from "./fileExplorer.shared";
|
import { type ExplorerItem } from "./fileExplorer.shared";
|
||||||
|
|
||||||
defineProps<{
|
const props = defineProps<{
|
||||||
items: ExplorerItem[];
|
items: ExplorerItem[];
|
||||||
|
pendingPath?: string;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
open: [item: ExplorerItem];
|
open: [item: ExplorerItem];
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
|
function handleOpen(item: ExplorerItem): void {
|
||||||
|
if (props.pendingPath) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
emit("open", item);
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped lang="less">
|
<style scoped lang="less">
|
||||||
.file-explorer-list {
|
.file-explorer-list {
|
||||||
overflow: hidden;
|
--top-gap: var(--page-top-gap, 1rem);
|
||||||
|
--bottom-gap: 5.5rem;
|
||||||
|
|
||||||
:deep(.t-cell-group) {
|
flex: 1 1 auto;
|
||||||
margin: 0;
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.scroller {
|
&:last-child {
|
||||||
height: 100%;
|
|
||||||
|
|
||||||
.vue-recycle-scroller__item-view {
|
.cell {
|
||||||
|
border-radius: 0 0 var(--td-radius-large) var(--td-radius-large);
|
||||||
|
|
||||||
&:last-child .cell:after {
|
&:after {
|
||||||
border-bottom: 0;
|
border-bottom: 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.spacer {
|
||||||
|
width: 100%;
|
||||||
|
pointer-events: none;
|
||||||
|
|
||||||
|
&.top {
|
||||||
|
height: var(--top-gap);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -4,26 +4,34 @@
|
|||||||
<file-explorer-list
|
<file-explorer-list
|
||||||
v-if="displayMode === 'list'"
|
v-if="displayMode === 'list'"
|
||||||
:items="currentItems"
|
:items="currentItems"
|
||||||
|
:pending-path="pendingFolderPath"
|
||||||
@open="openItem"
|
@open="openItem"
|
||||||
/>
|
/>
|
||||||
<file-explorer-grid
|
<file-explorer-grid
|
||||||
v-else
|
v-else
|
||||||
:items="currentItems"
|
:items="currentItems"
|
||||||
|
:pending-path="pendingFolderPath"
|
||||||
@open="openItem"
|
@open="openItem"
|
||||||
/>
|
/>
|
||||||
</section>
|
</section>
|
||||||
|
<div v-else-if="pageLoading" class="loading-wrap">
|
||||||
|
<t-loading text="加载目录中" />
|
||||||
|
</div>
|
||||||
<t-empty v-else description="当前目录为空" />
|
<t-empty v-else description="当前目录为空" />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { Toast } from "tdesign-mobile-vue";
|
||||||
|
import { listServerFiles, resolveRequestErrorMessage } from "@/api/file";
|
||||||
import FileExplorerGrid from "./FileExplorerGrid.vue";
|
import FileExplorerGrid from "./FileExplorerGrid.vue";
|
||||||
import FileExplorerList from "./FileExplorerList.vue";
|
import FileExplorerList from "./FileExplorerList.vue";
|
||||||
import { useNavBarStore } from "@/store/navBarStore";
|
import { useNavBarStore } from "@/store/navBarStore";
|
||||||
import {
|
import {
|
||||||
type DisplayMode,
|
type DisplayMode,
|
||||||
type ExplorerItem,
|
type ExplorerItem,
|
||||||
type FileEntry
|
mapServerFileToExplorerItem,
|
||||||
|
sortExplorerItems
|
||||||
} from "./fileExplorer.shared";
|
} from "./fileExplorer.shared";
|
||||||
|
|
||||||
const props = withDefaults(defineProps<{
|
const props = withDefaults(defineProps<{
|
||||||
@@ -48,92 +56,12 @@ const router = useRouter();
|
|||||||
const navBarStore = useNavBarStore();
|
const navBarStore = useNavBarStore();
|
||||||
const navBarRightOwner = `file-explorer-page-${Math.random().toString(36).slice(2)}`;
|
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 displayMode = ref<DisplayMode>(props.mode);
|
||||||
const localSegments = ref<string[]>(normalizePath(props.path));
|
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(() => {
|
const shouldUseRoutePath = computed(() => {
|
||||||
if (!props.useRoutePath) {
|
if (!props.useRoutePath) {
|
||||||
@@ -153,12 +81,9 @@ const currentSegments = computed(() => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const currentItems = computed<ExplorerItem[]>(() => {
|
const currentItems = computed<ExplorerItem[]>(() => {
|
||||||
const target = resolveEntries(currentSegments.value);
|
return directoryCache.value[getDirectoryKey(currentSegments.value)] || [];
|
||||||
return target.map((item) => ({
|
|
||||||
...item,
|
|
||||||
path: buildItemPath(currentSegments.value, item.name)
|
|
||||||
}));
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const currentTitle = computed(() => {
|
const currentTitle = computed(() => {
|
||||||
if (!currentSegments.value.length) {
|
if (!currentSegments.value.length) {
|
||||||
return "文件";
|
return "文件";
|
||||||
@@ -166,9 +91,11 @@ const currentTitle = computed(() => {
|
|||||||
|
|
||||||
return currentSegments.value[currentSegments.value.length - 1];
|
return currentSegments.value[currentSegments.value.length - 1];
|
||||||
});
|
});
|
||||||
|
|
||||||
const displayModeActionText = computed(() => {
|
const displayModeActionText = computed(() => {
|
||||||
return displayMode.value === "list" ? "平铺" : "列表";
|
return displayMode.value === "list" ? "平铺" : "列表";
|
||||||
});
|
});
|
||||||
|
|
||||||
const navBarRightRenderer = defineComponent({
|
const navBarRightRenderer = defineComponent({
|
||||||
name: "file-explorer-nav-right",
|
name: "file-explorer-nav-right",
|
||||||
setup() {
|
setup() {
|
||||||
@@ -228,6 +155,14 @@ watch(
|
|||||||
{ immediate: true }
|
{ immediate: true }
|
||||||
);
|
);
|
||||||
|
|
||||||
|
watch(
|
||||||
|
currentSegments,
|
||||||
|
(segments) => {
|
||||||
|
void hydrateCurrentDirectory(segments);
|
||||||
|
},
|
||||||
|
{ immediate: true }
|
||||||
|
);
|
||||||
|
|
||||||
function normalizePath(source?: string | string[]): string[] {
|
function normalizePath(source?: string | string[]): string[] {
|
||||||
if (Array.isArray(source)) {
|
if (Array.isArray(source)) {
|
||||||
return source.filter((segment) => !!segment);
|
return source.filter((segment) => !!segment);
|
||||||
@@ -240,25 +175,6 @@ function normalizePath(source?: string | string[]): string[] {
|
|||||||
return source.split("/").filter((segment) => !!segment);
|
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 {
|
function setDisplayMode(mode: DisplayMode): void {
|
||||||
if (displayMode.value === mode) {
|
if (displayMode.value === mode) {
|
||||||
return;
|
return;
|
||||||
@@ -276,20 +192,28 @@ onUnmounted(() => {
|
|||||||
navBarStore.clearRight(navBarRightOwner);
|
navBarStore.clearRight(navBarRightOwner);
|
||||||
});
|
});
|
||||||
|
|
||||||
async function openParent(): Promise<void> {
|
async function openItem(item: ExplorerItem): Promise<void> {
|
||||||
if (shouldUseRoutePath.value && route.name === "FileExplorerPage" && currentSegments.value.length === 1) {
|
if (pendingFolderPath.value) {
|
||||||
await router.back();
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
await syncPath(currentSegments.value.slice(0, -1));
|
|
||||||
}
|
|
||||||
|
|
||||||
async function openItem(item: ExplorerItem): Promise<void> {
|
|
||||||
if (item.type === "dir") {
|
if (item.type === "dir") {
|
||||||
const nextSegments = [...currentSegments.value, item.name];
|
const nextSegments = normalizePath(item.path);
|
||||||
|
pendingFolderPath.value = item.path;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await ensureDirectoryLoaded(nextSegments, false);
|
||||||
emit("open-folder", nextSegments);
|
emit("open-folder", nextSegments);
|
||||||
await syncPath(nextSegments);
|
await syncPath(nextSegments);
|
||||||
|
} catch (error) {
|
||||||
|
Toast({
|
||||||
|
theme: "error",
|
||||||
|
message: resolveRequestErrorMessage(error)
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
pendingFolderPath.value = "";
|
||||||
|
}
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -315,14 +239,61 @@ async function syncPath(nextSegments: string[]): Promise<void> {
|
|||||||
localSegments.value = nextSegments;
|
localSegments.value = nextSegments;
|
||||||
emit("update:path", 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>
|
</script>
|
||||||
|
|
||||||
<style scoped lang="less">
|
<style scoped lang="less">
|
||||||
.page {
|
.page {
|
||||||
|
--page-top-gap: calc(var(--app-nav-offset, 0px) + 1rem);
|
||||||
|
|
||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
height: calc(100% - 2rem);
|
height: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
display: flex;
|
display: flex;
|
||||||
padding: 1rem;
|
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|
||||||
@@ -347,11 +318,29 @@ async function syncPath(nextSegments: string[]): Promise<void> {
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
.content {
|
.content,
|
||||||
|
.loading-wrap {
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
flex: 1 1 auto;
|
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 {
|
.icon {
|
||||||
width: 2.75rem;
|
width: 2.75rem;
|
||||||
height: 2.75rem;
|
height: 2.75rem;
|
||||||
|
|||||||
@@ -1,23 +1,132 @@
|
|||||||
export type DisplayMode = "list" | "grid";
|
export type DisplayMode = "list" | "grid";
|
||||||
export type FileItemType = "dir" | "file";
|
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;
|
name: string;
|
||||||
type: FileItemType;
|
type: FileItemType;
|
||||||
size?: string;
|
size?: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
icon: string;
|
icon: string;
|
||||||
children?: FileEntry[];
|
path: string;
|
||||||
|
rawType?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ExplorerItem extends Omit<FileEntry, "children"> {
|
const fileIconMap: Record<string, string> = {
|
||||||
path: 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 {
|
export function formatItemDesc(item: ExplorerItem): string {
|
||||||
if (item.type === "dir") {
|
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);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user