从零开始-文件资源管理器-03-文件夹视图

Node.js

安装依赖

$ cd /explorer-manager

$ pnpm i @types/node@20.10.4 -D

安装 node types 类型描述,20+,不影响实际运行,提升开发体验。

获取指定路径下文件夹、文件方法

使用 Node.js 的 fs.readdirSync 同步方法读取指定路径下的内容

功能实现

文件树

explorer-manager
└── src
    ├── format-path.mjs
    ├── main.mjs
    └── type.ts

文件路径:/explorer-manager/src/format-path.mjs

import sys_path from 'path'

export const BASE_EXPLORER_PATH = process.env.EXPLORER_PATH || process.env.HOME

export const formatPath = (...path) => {
  return sys_path
    .join(BASE_EXPLORER_PATH, ...path)
    .split('/')
    .map((text) => {
      try {
        return decodeURIComponent(text)
      } catch (e) {
        return text
      }
    })
    .join('/')
}

export const resetPath = (path) => {
  return path.replace(BASE_EXPLORER_PATH, '')
}

formatPath 用于组合路径,可通过环境变量 EXPLORER_PATH 设置起始位置。默认为当前用户文件夹。

resetPath 用于还原原始路径。

文件路径:/explorer-manager/src/main.mjs

import fs from 'fs'
import { formatPath } from './format-path.mjs'

const checkIsHideExp = /^\./

/**
 * @param path {string}
 * @param only_dir {only_dir:'0'|'1'}
 * @param only_file {only_file:'0'|'1'}
 * @param show_hide {show_hide:'0'|'1'}
 * @param has_file_stat {has_file_stat:'0'|'1'}
 * @returns {import('./type').ReaddirListType}
 */
export const readdir = (path = '.', { only_dir = '0', only_file = '0', show_hide = '0', has_file_stat = '0' } = {}) => {
  return fs.readdirSync(formatPath(path), { withFileTypes: true }).reduce((dir_list, Dirent) => {
    const is_directory = !Dirent.isFile()
    const name = Dirent.name
    let stat = {}

    if (has_file_stat === '1') {
      stat = fs.statSync(formatPath(path, name))
    }

    if (show_hide === '0' && checkIsHideExp.test(name)) {
      return dir_list
    }

    if (only_dir === '1' && !is_directory) {
      return dir_list
    }

    if (only_file === '1') {
      if (is_directory) {
        return dir_list
      } else if (has_file_stat !== '1') {
        stat = fs.statSync(formatPath(path, name))
      }
    }

    dir_list.push({
      name,
      is_directory,
      stat: { ...stat },
    })

    return dir_list
  }, [])
}

使用 .mjs 作为后缀名(Node.js 13.2.0 版本后可以识别运行 ES Modules)。方便使用 node *.mjs 进行测试。

可在 Next.js 内 import *.mjs 文件

类型描述文件

文件路径:/explorer-manager/src/type.ts

import { Stats } from 'fs'

export type ReaddirItemType = { is_directory: boolean; name: string; stat?: Stats }

export type ReaddirListType = ReaddirItemType[]

Next.js

页面路由设计

使用 Next.js 动态路由进行跳转,格式为

http[s]://domina/path/[[…path]]

Next.js 不可在根目录进行动态路由配置,必须要添加一级/path/目录、路径。

[[…path]] 可动态识别例如

domian/path/media/video-1

dir 将是 { path: ['media', 'video-1'] }

参考链接 - Optional Catch-all Segments

使用 Next.js 的 <Link/> 标签进行跳转。会使用 React 的流式传输改变页面内容。

参考链接 - Server React DOM APIs

功能实现

首先将 explorer-manager monorepo 加入 Next.js 别名引用中,方便之后引用。

/explorer/tsconfig.json 添加下面内容
compilerOptions.paths['@/explorer-manager/*'] = ["../explorer-manager/*"]

{
...
    "compilerOptions": {
    ...
        "paths": {
            "@/*": ["./src/*"],
            "@/explorer-manager/*": ["../explorer-manager/*"]
        }
    }
...
}

文件树

在 /explorer/src/app/path/ 目录下创建下面文件夹与文件

├── [[...path]]
│   ├── card-display.tsx
│   ├── change-display-type.tsx
│   ├── layout.tsx
│   ├── page.tsx
│   └── tale-display.tsx
├── context.tsx
└── loading.tsx
  • loading.tsx:用于 <Link /> 标签跳转时 loading 效果
  • context.tsx:react context 组件,用于跨组件快速访问数据
  • [[...path]]/layout.tsx:根据 url 内 pathname 读取指定目录下内容
  • [[...path]]/page.tsx:页面组件
  • [[...path]]/card-display.tsx:卡片呈现
  • [[...path]]/tale-display.tsx:表格呈现
  • [[...path]]/change-display-type.tsx:在 card 与 table 之间切换呈现样式

文件路径:path/loading.tsx

import Loading from '@/components/loading'

export default Loading

loading 组件很简单,直接引用公共组件内的 loading

文件路径:path/context.tsx

'use client'
import React, { createContext, useContext, useState } from 'react'
import { ReaddirListType } from '@/explorer-manager/src/type'

type PathContextType = {
  readdir: ReaddirListType
  display_type: 'card' | 'table'
  changeDisplayType: React.Dispatch<React.SetStateAction<PathContextType['display_type']>>
}

const PathContext = createContext<PathContextType>(null!)

export const usePathContext = () => {
  return useContext(PathContext)
}

export const PathContextProvider: React.FC<React.ProviderProps<ReaddirListType>> = ({ value, children }) => {
  const [display_type, changeDisplayType] = useState<'card' | 'table'>('card')

  return (
    <PathContext.Provider value={{ readdir: value, display_type: display_type, changeDisplayType: changeDisplayType }}>
      {children}
    </PathContext.Provider>
  )
}

context 中注册

  • readdir:当前目标目录文件、文件夹信息
  • display_type:显示类型
  • changeDisplayType:改变现实类型

将显示类型与改变显示类型也放入上下文中,方便在更深组件上改变呈现样式

会出现只需要 readdir 数据的组件会被 display_type 改变时触发不必要的 rerender,会影响页面的性能。后续会有相关解决方案的文章

文件路径:path/[[...path]]/layout.tsx

import React from 'react'
import { readdir } from '@/explorer-manager/src/main.mjs'
import { PathContextProvider } from '@/app/path/context'

const Layout: React.FC<React.PropsWithChildren & { params: { path: string[] } }> = ({
  children,
  params: { path = [] },
}) => {
  const readdirList = readdir(path.join('/'), { only_dir: '0', only_file: '0', has_file_stat: '1' })

  return <PathContextProvider value={readdirList}>{children}</PathContextProvider>
}

export default Layout
  • 这里使用了 Optional Catch-all Segments 路由,组件会多接受一个 params 的入参,内容为当前目录名 [[…path]] 内的 path。数据内容为一个将 URL 以 ’/‘ 分割的的数组字符串
  • 将 readdir 读取到的目标目录内容加入 PathContext 上下文中,方便组件消费数据

文件路径:path/[[...path]]/page.tsx

'use client'
import React from 'react'
import { Card } from 'antd'
import CardDisplay from '@/app/path/[[...path]]/card-display'
import TableDisplay from '@/app/path/[[...path]]/tale-display'
import ChangeDisplayType from '@/app/path/[[...path]]/change-display-type'
import { usePathContext } from '@/app/path/context'

const Page: React.FC = () => {
  const { display_type } = usePathContext()

  return <Card extra={<ChangeDisplayType />}>{display_type === 'table' ? <TableDisplay /> : <CardDisplay />}</Card>
}

export default Page

读取 display_type ,分别呈现对应的样式

文件路径:path/[[...path]]/card-display.tsx

'use client'
import React from 'react'
import { usePathContext } from '@/app/path/context'
import { Card, Flex, List } from 'antd'
import { FileOutlined, FolderOutlined } from '@ant-design/icons'
import Link from 'next/link'
import { usePathname } from 'next/navigation'

const CardDisplay: React.FC = () => {
  const pathname = usePathname()
  const { readdir } = usePathContext()

  return (
    <List
      grid={{ gutter: 0, xs: 3, md: 4, lg: 5, xl: 6, xxl: 7 }}
      dataSource={readdir}
      renderItem={(item) => {
        return (
          <List.Item style={{ padding: '0 8px' }}>
            <Card title={item.name}>
              <Link
                href={item.is_directory ? `${pathname}/${encodeURIComponent(item.name)}` : `${pathname}`}
                prefetch={false}
              >
                <Flex
                  justify={'center'}
                  align={'center'}
                  style={{ fontSize: '3em', padding: `${61.8 / 2}% 0`, position: 'relative' }}
                >
                  <span style={{ position: 'absolute' }}>
                    {item.is_directory ? <FolderOutlined /> : <FileOutlined />}
                  </span>
                </Flex>
              </Link>
            </Card>
          </List.Item>
        )
      }}
    />
  )
}

export default CardDisplay

使用 List 组件封装,用于不同屏幕大小控制不同的列数。

内部为 Card 组件
标题为当前文件、文件夹名字
内容如果是文件夹将使用文件夹的📂,文件则为文件📃。并使用上下边距将内容撑开,维持一个 1:0.618 的比例,并将内容上下垂直居中。并设置为 absolute 状态,避免破坏长宽比。

文件路径:path/[[...path]]/tale-display.tsx

'use client'
import React from 'react'
import { usePathContext } from '@/app/path/context'
import { Space, Table } from 'antd'
import Link from 'next/link'
import { usePathname } from 'next/navigation'
import { FileOutlined, FolderOutlined } from '@ant-design/icons'

const TableDisplay: React.FC = () => {
  const pathname = usePathname()
  const { readdir } = usePathContext()

  return (
    <Table
      rowKey={'name'}
      columns={[
        {
          title: '文件名',
          dataIndex: 'name',
          key: 'name',
          render: (name, item) => {
            return (
              <Space>
                {item.is_directory ? <FolderOutlined /> : <FileOutlined />}
                <Link
                  href={item.is_directory ? `${pathname}/${encodeURIComponent(name)}` : `${pathname}`}
                  prefetch={false}
                >
                  {name}
                </Link>
              </Space>
            )
          },
        },
        {
          title: '大小',
          dataIndex: ['stat', 'size'],
          width: '160px',
          sorter: (a, b) => (a?.stat?.size ?? 0) - (b?.stat?.size ?? 0),
          render: (size) => {
            return size
          },
        },
        {
          title: '修改时间',
          dataIndex: ['stat', 'mtimeMs'],
          width: '160px',
          sorter: (a, b) => (a?.stat?.mtimeMs ?? 0) - (b?.stat?.mtimeMs ?? 0),
          render: (time) => {
            return time
          },
        },
      ]}
      dataSource={readdir}
    />
  )
}

export default TableDisplay

表格视图,分为 标题、大小、修改时间 三列

其中标题部分会根据当前文件夹 OR 文件使用不同的 ICON 区分

文件路径:[[...path]]/change-display-type.tsx

import React from 'react'
import { Button } from 'antd'
import { usePathContext } from '@/app/path/context'

const ChangeDisplayType: React.FC = () => {
  const { changeDisplayType, display_type } = usePathContext()

  return (
    <Button
      onClick={() => {
        changeDisplayType(display_type === 'table' ? 'card' : 'table')
      }}
    >
      display
    </Button>
  )
}

export default ChangeDisplayType

使用 PathContext 上下文中的方法,在 card 与 table 之间切换

结果

卡片视图


截屏2023-12-12 09.56.13.png

表格视图


截屏2023-12-12 09.56.31.png

git-repo

yangWs29/share-explorer

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 215,634评论 6 497
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 91,951评论 3 391
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 161,427评论 0 351
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 57,770评论 1 290
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 66,835评论 6 388
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 50,799评论 1 294
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,768评论 3 416
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 38,544评论 0 271
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,979评论 1 308
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 37,271评论 2 331
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 39,427评论 1 345
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 35,121评论 5 340
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 40,756评论 3 324
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 31,375评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,579评论 1 268
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 47,410评论 2 368
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 44,315评论 2 352

推荐阅读更多精彩内容