update FileExplorer

This commit is contained in:
Timi
2026-04-03 14:58:30 +08:00
parent ded231671a
commit 603a503644
9 changed files with 335 additions and 178 deletions

View File

@@ -8,6 +8,11 @@
:left-arrow="!!navBarStore.canBack" :left-arrow="!!navBarStore.canBack"
@left-click="doBack" @left-click="doBack"
> >
<template #right>
<div class="nav-extra">
<component :is="navBarStore.rightRenderer" v-if="navBarStore.rightRenderer" />
</div>
</template>
</t-navbar> </t-navbar>
<div class="router-view"> <div class="router-view">
<page-transition /> <page-transition />
@@ -143,22 +148,11 @@ const bodyHeight = computed(() => {
-webkit-backdrop-filter: blur(10px); -webkit-backdrop-filter: blur(10px);
} }
.nav-btn { .nav-extra {
padding: 0; gap: .35rem;
border: none;
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
justify-content: center; justify-content: flex-end;
color: var(--td-text-color-primary, #333);
background: transparent;
}
.nav-text {
cursor: default;
&.clickable {
cursor: pointer;
}
} }
} }

View File

@@ -7,6 +7,7 @@ import Root from "@/Root.vue";
import "tdesign-mobile-vue/es/style/index.css"; import "tdesign-mobile-vue/es/style/index.css";
import "timi-web/style.css"; import "timi-web/style.css";
import "timi-tdesign-mobile/style.css"; import "timi-tdesign-mobile/style.css";
import "vue-virtual-scroller/dist/vue-virtual-scroller.css";
export const pinia = createPinia(); export const pinia = createPinia();

View File

@@ -0,0 +1,136 @@
<template>
<recycle-scroller
class="scroller"
: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>
</recycle-scroller>
</template>
<script setup lang="ts">
import { computed } from "vue";
import { RecycleScroller } from "vue-virtual-scroller";
import { formatItemDesc, type ExplorerItem } from "./fileExplorer.shared";
interface GridRow {
key: string;
items: ExplorerItem[];
}
const columns = 2;
const props = defineProps<{
items: ExplorerItem[];
}>();
const emit = defineEmits<{
open: [item: ExplorerItem];
}>();
const rows = computed<GridRow[]>(() => {
const nextRows: GridRow[] = [];
for (let index = 0; index < props.items.length; index += columns) {
const chunk = props.items.slice(index, index + columns);
nextRows.push({
key: chunk.map((item) => item.path).join("|"),
items: chunk
});
}
return nextRows;
});
</script>
<style scoped lang="less">
.scroller {
height: 100%;
}
.grid-row {
gap: .75rem;
display: grid;
padding-bottom: .75rem;
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.card {
gap: .75rem;
display: flex;
padding: 1rem .875rem;
border: 1px solid var(--app-line);
border-radius: 1rem;
flex-direction: column;
background: var(--app-card);
box-shadow: 0 .35rem 1rem rgba(17, 32, 56, .05);
cursor: pointer;
&.ghost {
visibility: hidden;
pointer-events: none;
}
}
.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-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) {
.card {
box-shadow: 0 .35rem 1rem rgba(0, 0, 0, .22);
}
}
</style>

View File

@@ -0,0 +1,51 @@
<template>
<t-cell-group theme="card" class="list-view">
<recycle-scroller
class="scroller"
:items="items"
:item-size="56"
key-field="path"
v-slot="{ item }"
>
<t-swipe-cell class="swipe">
<t-cell
:title="item.name"
:arrow="item.type === 'dir'"
:note="item.size"
@click="emit('open', item)"
>
<template #left-icon>
<t-icon :name="item.type === 'dir' ? 'folder' : item.icon" />
</template>
</t-cell>
</t-swipe-cell>
</recycle-scroller>
</t-cell-group>
</template>
<script setup lang="ts">
import { RecycleScroller } from "vue-virtual-scroller";
import { type ExplorerItem } from "./fileExplorer.shared";
defineProps<{
items: ExplorerItem[];
}>();
const emit = defineEmits<{
open: [item: ExplorerItem];
}>();
</script>
<style scoped lang="less">
.list-view {
overflow: hidden;
:deep(.t-cell-group) {
margin: 0;
}
}
.scroller {
height: 100%;
}
</style>

View File

@@ -1,50 +1,5 @@
<template> <template>
<div class="page"> <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"> <section v-if="currentSegments.length" class="go-up" @click="openParent">
<div class="icon dir"> <div class="icon dir">
<t-icon name="rollback" /> <t-icon name="rollback" />
@@ -55,43 +10,32 @@
</div> </div>
</section> </section>
<section :class="['list', `is-${displayMode}`]"> <section v-if="currentItems.length" class="content">
<article <file-explorer-list
v-for="item in currentItems" v-if="displayMode === 'list'"
:key="item.path" :items="currentItems"
class="item glass-white" @open="openItem"
@click="openItem(item)" />
> <file-explorer-grid
<div :class="['icon', item.type]"> v-else
<t-icon :name="item.type === 'dir' ? 'folder' : item.icon" /> :items="currentItems"
</div> @open="openItem"
<div class="meta"> />
<p class="name" v-text="item.name"></p>
<p class="desc" v-text="formatItemDesc(item)"></p>
</div>
</article>
</section> </section>
<t-empty v-if="!currentItems.length" description="当前目录为空" /> <t-empty v-else description="当前目录为空" />
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
type DisplayMode = "list" | "grid"; import FileExplorerGrid from "./FileExplorerGrid.vue";
type FileItemType = "dir" | "file"; import FileExplorerList from "./FileExplorerList.vue";
import { useNavBarStore } from "@/store/navBarStore";
interface FileEntry { import {
name: string; type DisplayMode,
type: FileItemType; type ExplorerItem,
size?: string; type FileEntry
updatedAt: string; } from "./fileExplorer.shared";
icon: string;
children?: FileEntry[];
}
interface ExplorerItem extends Omit<FileEntry, "children"> {
path: string;
}
const props = withDefaults(defineProps<{ const props = withDefaults(defineProps<{
mode?: DisplayMode; mode?: DisplayMode;
@@ -112,6 +56,8 @@ const emit = defineEmits<{
const route = useRoute(); const route = useRoute();
const router = useRouter(); const router = useRouter();
const navBarStore = useNavBarStore();
const navBarRightOwner = "file-explorer-page";
const fileTree: FileEntry[] = [ const fileTree: FileEntry[] = [
{ {
@@ -185,7 +131,7 @@ const fileTree: FileEntry[] = [
name: "系统日志.log", name: "系统日志.log",
type: "file", type: "file",
size: "428 KB", size: "428 KB",
icon: "file-text", icon: "file",
updatedAt: "今天 07:55" updatedAt: "今天 07:55"
}, },
{ {
@@ -224,6 +170,32 @@ const currentItems = computed<ExplorerItem[]>(() => {
path: buildItemPath(currentSegments.value, item.name) path: buildItemPath(currentSegments.value, item.name)
})); }));
}); });
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");
return () => h(buttonComponent, {
size: "small",
variant: "text",
theme: "primary",
class: "nav-switch",
onClick: toggleDisplayMode
}, {
default: () => displayModeActionText.value
});
}
});
watch( watch(
() => props.mode, () => props.mode,
@@ -233,6 +205,22 @@ watch(
{ immediate: true } { immediate: true }
); );
watch(
currentTitle,
(value) => {
navBarStore.setTitle(value);
},
{ immediate: true }
);
watch(
displayModeActionText,
() => {
navBarStore.setRightRenderer(navBarRightRenderer, navBarRightOwner);
},
{ immediate: true }
);
watch( watch(
() => props.path, () => props.path,
(value) => { (value) => {
@@ -277,14 +265,22 @@ function buildItemPath(pathSegments: string[], name: string): string {
} }
function setDisplayMode(mode: DisplayMode): void { function setDisplayMode(mode: DisplayMode): void {
if (displayMode.value === mode) {
return;
}
displayMode.value = mode; displayMode.value = mode;
emit("update:mode", mode); emit("update:mode", mode);
} }
async function openRoot(): Promise<void> { function toggleDisplayMode(): void {
await syncPath([]); setDisplayMode(displayMode.value === "list" ? "grid" : "list");
} }
onUnmounted(() => {
navBarStore.clearRight(navBarRightOwner);
});
async function openParent(): Promise<void> { async function openParent(): Promise<void> {
if (shouldUseRoutePath.value && route.name === "FileExplorerPage" && currentSegments.value.length === 1) { if (shouldUseRoutePath.value && route.name === "FileExplorerPage" && currentSegments.value.length === 1) {
await router.back(); await router.back();
@@ -294,10 +290,6 @@ async function openParent(): Promise<void> {
await syncPath(currentSegments.value.slice(0, -1)); 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> { async function openItem(item: ExplorerItem): Promise<void> {
if (item.type === "dir") { if (item.type === "dir") {
const nextSegments = [...currentSegments.value, item.name]; const nextSegments = [...currentSegments.value, item.name];
@@ -328,14 +320,6 @@ async function syncPath(nextSegments: string[]): Promise<void> {
localSegments.value = nextSegments; localSegments.value = nextSegments;
emit("update:path", nextSegments); emit("update:path", nextSegments);
} }
function formatItemDesc(item: ExplorerItem): string {
if (item.type === "dir") {
return `文件夹 · ${item.updatedAt}`;
}
return `${item.size || "--"} · ${item.updatedAt}`;
}
</script> </script>
<style scoped lang="less"> <style scoped lang="less">
@@ -344,48 +328,20 @@ function formatItemDesc(item: ExplorerItem): string {
height: 100%; height: 100%;
display: flex; display: flex;
padding: 1rem; padding: 1rem;
overflow: auto; overflow: hidden;
flex-direction: column; flex-direction: column;
.toolbar, .nav-switch {
.go-up, padding: 0;
.item { }
.go-up {
border: 1px solid var(--app-line); border: 1px solid var(--app-line);
background: var(--app-card); background: var(--app-card);
box-shadow: 0 .35rem 1rem rgba(17, 32, 56, .05); box-shadow: 0 .35rem 1rem rgba(17, 32, 56, .05);
} }
.toolbar { .go-up {
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; gap: .875rem;
display: flex; display: flex;
padding: .875rem 1rem; padding: .875rem 1rem;
@@ -394,23 +350,9 @@ function formatItemDesc(item: ExplorerItem): string {
cursor: pointer; cursor: pointer;
} }
.list { .content {
gap: .75rem; min-height: 0;
display: grid; flex: 1 1 auto;
&.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 { .icon {
@@ -420,11 +362,11 @@ function formatItemDesc(item: ExplorerItem): string {
border-radius: .9rem; border-radius: .9rem;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
color: #FFF; color: #fff;
background: linear-gradient(135deg, #4D8DFF, #76A9FF); background: linear-gradient(135deg, #4d8dff, #76a9ff);
&.dir { &.dir {
background: linear-gradient(135deg, #FF9C3D, #FFBF69); background: linear-gradient(135deg, #ff9c3d, #ffbf69);
} }
} }
@@ -454,9 +396,7 @@ function formatItemDesc(item: ExplorerItem): string {
} }
:global(.theme-dark) .page { :global(.theme-dark) .page {
.toolbar, .go-up {
.go-up,
.item {
box-shadow: 0 .35rem 1rem rgba(0, 0, 0, .22); box-shadow: 0 .35rem 1rem rgba(0, 0, 0, .22);
} }
} }

View File

@@ -0,0 +1,23 @@
export type DisplayMode = "list" | "grid";
export type FileItemType = "dir" | "file";
export interface FileEntry {
name: string;
type: FileItemType;
size?: string;
updatedAt: string;
icon: string;
children?: FileEntry[];
}
export interface ExplorerItem extends Omit<FileEntry, "children"> {
path: string;
}
export function formatItemDesc(item: ExplorerItem): string {
if (item.type === "dir") {
return `文件夹 · ${item.updatedAt}`;
}
return `${item.size || "--"} · ${item.updatedAt}`;
}

View File

@@ -13,7 +13,7 @@ const tabs: RouteRecordRaw[] = [
navBarTitle: "文件", navBarTitle: "文件",
tabBarVisible: true, tabBarVisible: true,
tabBarPadding: true, tabBarPadding: true,
bodyBackground: "#FFF" bodyBackground: "#F4F4F4"
}, },
component: FilePage component: FilePage
}, },

View File

@@ -1,3 +1,4 @@
import type { Component } from "vue";
import { defineStore } from "pinia"; import { defineStore } from "pinia";
export const useNavBarStore = defineStore("nav-bar", () => { export const useNavBarStore = defineStore("nav-bar", () => {
@@ -7,8 +8,8 @@ export const useNavBarStore = defineStore("nav-bar", () => {
const height = ref(0); const height = ref(0);
const title = ref(""); const title = ref("");
const backTo = ref<string>(); const backTo = ref<string>();
const rightText = ref<string>(); const rightRenderer = shallowRef<Component>();
const rightAction = ref<(() => void) | undefined>(); const rightOwner = ref<string>();
const isShowing = computed(() => !!router.currentRoute.value.meta.navBarVisible); const isShowing = computed(() => !!router.currentRoute.value.meta.navBarVisible);
const canBack = computed(() => !!router.currentRoute.value.meta.navBarCanBack); const canBack = computed(() => !!router.currentRoute.value.meta.navBarCanBack);
@@ -33,32 +34,31 @@ export const useNavBarStore = defineStore("nav-bar", () => {
title.value = value || ""; title.value = value || "";
} }
function setRightText(value?: string): void { function setRightRenderer(renderer?: Component, owner?: string): void {
rightText.value = value; rightRenderer.value = renderer;
rightOwner.value = owner;
} }
function setRightAction(action?: () => void): void { function clearRight(owner?: string): void {
rightAction.value = action; if (owner && rightOwner.value !== owner) {
} return;
}
function clearRight(): void { rightRenderer.value = undefined;
rightText.value = undefined; rightOwner.value = undefined;
rightAction.value = undefined;
} }
return { return {
height, height,
title, title,
backTo, backTo,
rightText, rightRenderer,
rightAction,
isShowing, isShowing,
canBack, canBack,
setHeight, setHeight,
setBackTo, setBackTo,
setTitle, setTitle,
setRightText, setRightRenderer,
setRightAction,
clearRight clearRight
}; };
}); });

12
src/types/vue-virtual-scroller.d.ts vendored Normal file
View File

@@ -0,0 +1,12 @@
declare module "vue-virtual-scroller" {
import type { App, DefineComponent, Plugin } from "vue";
export const RecycleScroller: DefineComponent;
export const DynamicScroller: DefineComponent;
export const DynamicScrollerItem: DefineComponent;
export function install(app: App): void;
const VueVirtualScroller: Plugin;
export default VueVirtualScroller;
}