update FileExplorer
This commit is contained in:
@@ -8,6 +8,11 @@
|
||||
:left-arrow="!!navBarStore.canBack"
|
||||
@left-click="doBack"
|
||||
>
|
||||
<template #right>
|
||||
<div class="nav-extra">
|
||||
<component :is="navBarStore.rightRenderer" v-if="navBarStore.rightRenderer" />
|
||||
</div>
|
||||
</template>
|
||||
</t-navbar>
|
||||
<div class="router-view">
|
||||
<page-transition />
|
||||
@@ -143,22 +148,11 @@ const bodyHeight = computed(() => {
|
||||
-webkit-backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
.nav-btn {
|
||||
padding: 0;
|
||||
border: none;
|
||||
.nav-extra {
|
||||
gap: .35rem;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--td-text-color-primary, #333);
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.nav-text {
|
||||
cursor: default;
|
||||
|
||||
&.clickable {
|
||||
cursor: pointer;
|
||||
}
|
||||
justify-content: flex-end;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import Root from "@/Root.vue";
|
||||
import "tdesign-mobile-vue/es/style/index.css";
|
||||
import "timi-web/style.css";
|
||||
import "timi-tdesign-mobile/style.css";
|
||||
import "vue-virtual-scroller/dist/vue-virtual-scroller.css";
|
||||
|
||||
export const pinia = createPinia();
|
||||
|
||||
|
||||
136
src/pages/file/FileExplorerGrid.vue
Normal file
136
src/pages/file/FileExplorerGrid.vue
Normal 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>
|
||||
51
src/pages/file/FileExplorerList.vue
Normal file
51
src/pages/file/FileExplorerList.vue
Normal 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>
|
||||
@@ -1,50 +1,5 @@
|
||||
<template>
|
||||
<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">
|
||||
<div class="icon dir">
|
||||
<t-icon name="rollback" />
|
||||
@@ -55,43 +10,32 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section :class="['list', `is-${displayMode}`]">
|
||||
<article
|
||||
v-for="item in currentItems"
|
||||
:key="item.path"
|
||||
class="item glass-white"
|
||||
@click="openItem(item)"
|
||||
>
|
||||
<div :class="['icon', item.type]">
|
||||
<t-icon :name="item.type === 'dir' ? 'folder' : item.icon" />
|
||||
</div>
|
||||
<div class="meta">
|
||||
<p class="name" v-text="item.name"></p>
|
||||
<p class="desc" v-text="formatItemDesc(item)"></p>
|
||||
</div>
|
||||
</article>
|
||||
<section v-if="currentItems.length" class="content">
|
||||
<file-explorer-list
|
||||
v-if="displayMode === 'list'"
|
||||
:items="currentItems"
|
||||
@open="openItem"
|
||||
/>
|
||||
<file-explorer-grid
|
||||
v-else
|
||||
:items="currentItems"
|
||||
@open="openItem"
|
||||
/>
|
||||
</section>
|
||||
|
||||
<t-empty v-if="!currentItems.length" description="当前目录为空" />
|
||||
<t-empty v-else description="当前目录为空" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
type DisplayMode = "list" | "grid";
|
||||
type FileItemType = "dir" | "file";
|
||||
|
||||
interface FileEntry {
|
||||
name: string;
|
||||
type: FileItemType;
|
||||
size?: string;
|
||||
updatedAt: string;
|
||||
icon: string;
|
||||
children?: FileEntry[];
|
||||
}
|
||||
|
||||
interface ExplorerItem extends Omit<FileEntry, "children"> {
|
||||
path: string;
|
||||
}
|
||||
import FileExplorerGrid from "./FileExplorerGrid.vue";
|
||||
import FileExplorerList from "./FileExplorerList.vue";
|
||||
import { useNavBarStore } from "@/store/navBarStore";
|
||||
import {
|
||||
type DisplayMode,
|
||||
type ExplorerItem,
|
||||
type FileEntry
|
||||
} from "./fileExplorer.shared";
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
mode?: DisplayMode;
|
||||
@@ -112,6 +56,8 @@ const emit = defineEmits<{
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const navBarStore = useNavBarStore();
|
||||
const navBarRightOwner = "file-explorer-page";
|
||||
|
||||
const fileTree: FileEntry[] = [
|
||||
{
|
||||
@@ -185,7 +131,7 @@ const fileTree: FileEntry[] = [
|
||||
name: "系统日志.log",
|
||||
type: "file",
|
||||
size: "428 KB",
|
||||
icon: "file-text",
|
||||
icon: "file",
|
||||
updatedAt: "今天 07:55"
|
||||
},
|
||||
{
|
||||
@@ -224,6 +170,32 @@ const currentItems = computed<ExplorerItem[]>(() => {
|
||||
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(
|
||||
() => props.mode,
|
||||
@@ -233,6 +205,22 @@ watch(
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
watch(
|
||||
currentTitle,
|
||||
(value) => {
|
||||
navBarStore.setTitle(value);
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
watch(
|
||||
displayModeActionText,
|
||||
() => {
|
||||
navBarStore.setRightRenderer(navBarRightRenderer, navBarRightOwner);
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
watch(
|
||||
() => props.path,
|
||||
(value) => {
|
||||
@@ -277,14 +265,22 @@ function buildItemPath(pathSegments: string[], name: string): string {
|
||||
}
|
||||
|
||||
function setDisplayMode(mode: DisplayMode): void {
|
||||
if (displayMode.value === mode) {
|
||||
return;
|
||||
}
|
||||
|
||||
displayMode.value = mode;
|
||||
emit("update:mode", mode);
|
||||
}
|
||||
|
||||
async function openRoot(): Promise<void> {
|
||||
await syncPath([]);
|
||||
function toggleDisplayMode(): void {
|
||||
setDisplayMode(displayMode.value === "list" ? "grid" : "list");
|
||||
}
|
||||
|
||||
onUnmounted(() => {
|
||||
navBarStore.clearRight(navBarRightOwner);
|
||||
});
|
||||
|
||||
async function openParent(): Promise<void> {
|
||||
if (shouldUseRoutePath.value && route.name === "FileExplorerPage" && currentSegments.value.length === 1) {
|
||||
await router.back();
|
||||
@@ -294,10 +290,6 @@ async function openParent(): Promise<void> {
|
||||
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> {
|
||||
if (item.type === "dir") {
|
||||
const nextSegments = [...currentSegments.value, item.name];
|
||||
@@ -328,14 +320,6 @@ async function syncPath(nextSegments: string[]): Promise<void> {
|
||||
localSegments.value = nextSegments;
|
||||
emit("update:path", nextSegments);
|
||||
}
|
||||
|
||||
function formatItemDesc(item: ExplorerItem): string {
|
||||
if (item.type === "dir") {
|
||||
return `文件夹 · ${item.updatedAt}`;
|
||||
}
|
||||
|
||||
return `${item.size || "--"} · ${item.updatedAt}`;
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
@@ -344,48 +328,20 @@ function formatItemDesc(item: ExplorerItem): string {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
padding: 1rem;
|
||||
overflow: auto;
|
||||
overflow: hidden;
|
||||
flex-direction: column;
|
||||
|
||||
.toolbar,
|
||||
.go-up,
|
||||
.item {
|
||||
.nav-switch {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.go-up {
|
||||
border: 1px solid var(--app-line);
|
||||
background: var(--app-card);
|
||||
box-shadow: 0 .35rem 1rem rgba(17, 32, 56, .05);
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
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 {
|
||||
.go-up {
|
||||
gap: .875rem;
|
||||
display: flex;
|
||||
padding: .875rem 1rem;
|
||||
@@ -394,23 +350,9 @@ function formatItemDesc(item: ExplorerItem): string {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.list {
|
||||
gap: .75rem;
|
||||
display: grid;
|
||||
|
||||
&.is-grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
|
||||
.item {
|
||||
padding: 1rem .875rem;
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
|
||||
.meta {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
}
|
||||
.content {
|
||||
min-height: 0;
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
|
||||
.icon {
|
||||
@@ -420,11 +362,11 @@ function formatItemDesc(item: ExplorerItem): string {
|
||||
border-radius: .9rem;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #FFF;
|
||||
background: linear-gradient(135deg, #4D8DFF, #76A9FF);
|
||||
color: #fff;
|
||||
background: linear-gradient(135deg, #4d8dff, #76a9ff);
|
||||
|
||||
&.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 {
|
||||
.toolbar,
|
||||
.go-up,
|
||||
.item {
|
||||
.go-up {
|
||||
box-shadow: 0 .35rem 1rem rgba(0, 0, 0, .22);
|
||||
}
|
||||
}
|
||||
|
||||
23
src/pages/file/fileExplorer.shared.ts
Normal file
23
src/pages/file/fileExplorer.shared.ts
Normal 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}`;
|
||||
}
|
||||
@@ -13,7 +13,7 @@ const tabs: RouteRecordRaw[] = [
|
||||
navBarTitle: "文件",
|
||||
tabBarVisible: true,
|
||||
tabBarPadding: true,
|
||||
bodyBackground: "#FFF"
|
||||
bodyBackground: "#F4F4F4"
|
||||
},
|
||||
component: FilePage
|
||||
},
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { Component } from "vue";
|
||||
import { defineStore } from "pinia";
|
||||
|
||||
export const useNavBarStore = defineStore("nav-bar", () => {
|
||||
@@ -7,8 +8,8 @@ export const useNavBarStore = defineStore("nav-bar", () => {
|
||||
const height = ref(0);
|
||||
const title = ref("");
|
||||
const backTo = ref<string>();
|
||||
const rightText = ref<string>();
|
||||
const rightAction = ref<(() => void) | undefined>();
|
||||
const rightRenderer = shallowRef<Component>();
|
||||
const rightOwner = ref<string>();
|
||||
|
||||
const isShowing = computed(() => !!router.currentRoute.value.meta.navBarVisible);
|
||||
const canBack = computed(() => !!router.currentRoute.value.meta.navBarCanBack);
|
||||
@@ -33,32 +34,31 @@ export const useNavBarStore = defineStore("nav-bar", () => {
|
||||
title.value = value || "";
|
||||
}
|
||||
|
||||
function setRightText(value?: string): void {
|
||||
rightText.value = value;
|
||||
function setRightRenderer(renderer?: Component, owner?: string): void {
|
||||
rightRenderer.value = renderer;
|
||||
rightOwner.value = owner;
|
||||
}
|
||||
|
||||
function setRightAction(action?: () => void): void {
|
||||
rightAction.value = action;
|
||||
}
|
||||
function clearRight(owner?: string): void {
|
||||
if (owner && rightOwner.value !== owner) {
|
||||
return;
|
||||
}
|
||||
|
||||
function clearRight(): void {
|
||||
rightText.value = undefined;
|
||||
rightAction.value = undefined;
|
||||
rightRenderer.value = undefined;
|
||||
rightOwner.value = undefined;
|
||||
}
|
||||
|
||||
return {
|
||||
height,
|
||||
title,
|
||||
backTo,
|
||||
rightText,
|
||||
rightAction,
|
||||
rightRenderer,
|
||||
isShowing,
|
||||
canBack,
|
||||
setHeight,
|
||||
setBackTo,
|
||||
setTitle,
|
||||
setRightText,
|
||||
setRightAction,
|
||||
setRightRenderer,
|
||||
clearRight
|
||||
};
|
||||
});
|
||||
|
||||
12
src/types/vue-virtual-scroller.d.ts
vendored
Normal file
12
src/types/vue-virtual-scroller.d.ts
vendored
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user