文件列表转换为树
测试代码
const files = [
{
id: 1,
name: '.env',
type: 'file',
path: '.env',
},
{
id: 2,
name: '.env.development',
type: 'file',
path: '.env.development',
},
{
id: 7,
name: '.env.production',
type: 'file',
path: '.env.production',
},
{
id: 8,
name: '.gitignore',
type: 'file',
path: '.gitignore',
},
{
id: 9,
name: '.npmrc',
type: 'file',
path: '.npmrc',
},
{
id: 10,
name: 'README.md',
type: 'file',
path: 'README.md',
},
{
id: 11,
name: 'docs',
type: 'directory',
path: 'docs',
},
{
id: 12,
name: 'env-setup.md',
type: 'file',
path: 'docs/env-setup.md',
},
{
id: 13,
name: 'eslint.config.js',
type: 'file',
path: 'eslint.config.js',
},
{
id: 14,
name: 'index.html',
type: 'file',
path: 'index.html',
},
{
id: 15,
name: 'mock',
type: 'directory',
path: 'mock',
},
{
id: 16,
name: 'index.ts',
type: 'file',
path: 'mock/index.ts',
},
{
id: 17,
name: 'package.json',
type: 'file',
path: 'package.json',
},
{
id: 18,
name: 'pnpm-lock.yaml',
type: 'file',
path: 'pnpm-lock.yaml',
},
{
id: 19,
name: 'pnpm-workspace.yaml',
type: 'file',
path: 'pnpm-workspace.yaml',
},
{
id: 20,
name: 'public',
type: 'directory',
path: 'public',
},
{
id: 21,
name: 'favicon.ico',
type: 'file',
path: 'public/favicon.ico',
},
{
id: 22,
name: 'vite.svg',
type: 'file',
path: 'public/vite.svg',
},
{
id: 23,
name: 'scripts',
type: 'directory',
path: 'scripts',
},
{
id: 24,
name: 'create-env-files.js',
type: 'file',
path: 'scripts/create-env-files.js',
},
{
id: 25,
name: 'src',
type: 'directory',
path: 'src',
},
{
id: 26,
name: 'App.css',
type: 'file',
path: 'src/App.css',
},
{
id: 27,
name: 'App.tsx',
type: 'file',
path: 'src/App.tsx',
},
{
id: 28,
name: 'assets',
type: 'directory',
path: 'src/assets',
},
{
id: 29,
name: 'react.svg',
type: 'file',
path: 'src/assets/react.svg',
},
{
id: 30,
name: 'config',
type: 'directory',
path: 'src/config',
},
{
id: 31,
name: 'entryConfig.ts',
type: 'file',
path: 'src/config/entryConfig.ts',
},
{
id: 32,
name: 'env.ts',
type: 'file',
path: 'src/config/env.ts',
},
{
id: 33,
name: 'contexts',
type: 'directory',
path: 'src/contexts',
},
{
id: 34,
name: 'UserContext.tsx',
type: 'file',
path: 'src/contexts/UserContext.tsx',
},
{
id: 35,
name: 'index.css',
type: 'file',
path: 'src/index.css',
},
{
id: 36,
name: 'layouts',
type: 'directory',
path: 'src/layouts',
},
{
id: 37,
name: 'AdminLayout.tsx',
type: 'file',
path: 'src/layouts/AdminLayout.tsx',
},
{
id: 38,
name: 'main.tsx',
type: 'file',
path: 'src/main.tsx',
},
{
id: 39,
name: 'pages',
type: 'directory',
path: 'src/pages',
},
{
id: 40,
name: 'Dashboard.tsx',
type: 'file',
path: 'src/pages/Dashboard.tsx',
},
{
id: 41,
name: 'Orders.tsx',
type: 'file',
path: 'src/pages/Orders.tsx',
},
{
id: 42,
name: 'Products.tsx',
type: 'file',
path: 'src/pages/Products.tsx',
},
{
id: 43,
name: 'Settings.tsx',
type: 'file',
path: 'src/pages/Settings.tsx',
},
{
id: 44,
name: 'Users.tsx',
type: 'file',
path: 'src/pages/Users.tsx',
},
{
id: 45,
name: 'router',
type: 'directory',
path: 'src/router',
},
{
id: 46,
name: 'index.tsx',
type: 'file',
path: 'src/router/index.tsx',
},
{
id: 47,
name: 'services',
type: 'directory',
path: 'src/services',
},
{
id: 48,
name: 'app.ts',
type: 'file',
path: 'src/services/app.ts',
},
{
id: 49,
name: 'menuService.ts',
type: 'file',
path: 'src/services/menuService.ts',
},
{
id: 50,
name: 'types',
type: 'directory',
path: 'src/types',
},
{
id: 51,
name: 'api.ts',
type: 'file',
path: 'src/types/api.ts',
},
{
id: 52,
name: 'env.d.ts',
type: 'file',
path: 'src/types/env.d.ts',
},
{
id: 53,
name: 'menu.ts',
type: 'file',
path: 'src/types/menu.ts',
},
{
id: 54,
name: 'utils',
type: 'directory',
path: 'src/utils',
},
{
id: 55,
name: 'menuUtils.tsx',
type: 'file',
path: 'src/utils/menuUtils.tsx',
},
{
id: 56,
name: 'request.example.ts',
type: 'file',
path: 'src/utils/request.example.ts',
},
{
id: 57,
name: 'request.ts',
type: 'file',
path: 'src/utils/request.ts',
},
{
id: 58,
name: 'statusCode.ts',
type: 'file',
path: 'src/utils/statusCode.ts',
},
{
id: 59,
name: 'tsconfig.app.json',
type: 'file',
path: 'tsconfig.app.json',
},
{
id: 60,
name: 'tsconfig.json',
type: 'file',
path: 'tsconfig.json',
},
{
id: 61,
name: 'tsconfig.node.json',
type: 'file',
path: 'tsconfig.node.json',
},
{
id: 62,
name: 'vite.config.ts',
type: 'file',
path: 'vite.config.ts',
},
];
const fileTree = convertTreeNodeToFileNode(files);
console.log('fileTree', fileTree);
export interface FileNode {
id: string;
name: string;
path: string;
type: 'file' | 'directory';
children?: FileNode[];
}
/**
* 规范化路径,去除末尾的斜杠,确保路径一致性
* @param {string} path - 要规范化的路径
* @returns {string} 规范化后的路径
*/
const normalizePath = (path: string) => path.replace(/\/+$/g, '');
/**
* 排序规则:目录在前、文件在后,同类型按名称字母序排列
* @param {FileNode[]} nodes - 要排序的节点数组
* @returns {FileNode[]} 排序后的节点数组
*/
const sortChildren = (nodes: FileNode[]): FileNode[] => {
return nodes.sort((a, b) => {
if (a.type !== b.type) return a.type === 'directory' ? -1 : 1;
return a.name.localeCompare(b.name);
});
};
/**
* 将API返回的扁平化TreeNode数组转换为嵌套的FileNode树形结构
*
* 处理流程:
* 1. 遍历所有节点,创建 path -> FileNode 的映射表
* 2. 再次遍历,根据路径层级关系将子节点挂载到父节点的 children 中
* 3. 对于父目录缺失的情况,自动创建中间目录节点
* 4. 最终对所有层级进行排序(目录优先,同类型按名称字母序)
*
* @param {Omit<FileNode, 'children'>[]} nodes - API 返回的扁平化节点数组
* @returns {Omit<FileNode, 'children'>[]} - 树形结构数组
*/
export const convertTreeNodeToFileNode = (nodes: Omit<FileNode, 'children'>[]): FileNode[] => {
// path -> FileNode 映射表,用于快速查找父节点
const nodeMap = new Map<string, FileNode>();
// 存放最顶层的根节点
const rootNodes: FileNode[] = [];
// 第一轮遍历:将所有扁平节点转换为 FileNode 并存入映射表
nodes.forEach(node => {
const normalizedPath = normalizePath(node.path);
const fileNode: FileNode = {
id: node.id,
name: node.name,
path: normalizedPath,
type: node.type,
...(node.type === 'directory' ? { children: [] } : {}),
};
nodeMap.set(normalizedPath, fileNode);
});
// 第二轮遍历:根据路径层级构建父子关系
nodes.forEach(node => {
const normalizedPath = normalizePath(node.path);
const currentNode = nodeMap.get(normalizedPath);
if (!currentNode) return;
const pathParts = normalizedPath.split('/');
if (pathParts.length === 1) {
// 路径无 '/',说明是根级节点,直接加入 rootNodes
rootNodes.push(currentNode);
} else {
// 截取父级路径,例如 "src/utils/index.ts" -> "src/utils"
const parentPath = pathParts.slice(0, -1).join('/');
const parentNode = nodeMap.get(parentPath);
if (parentNode && parentNode.children) {
// 父节点存在,直接挂载
parentNode.children.push(currentNode);
} else {
// 父节点不在返回数据中,需要逐级创建缺失的中间目录
let currentPath = '';
for (let i = 0; i < pathParts.length - 1; i++) {
const part = pathParts[i];
const newPath = currentPath ? `${currentPath}/${part}` : part;
if (!nodeMap.has(newPath)) {
// 创建缺失的中间目录节点
const missingParent: FileNode = {
id: newPath,
name: part,
path: newPath,
type: 'directory',
children: [],
};
nodeMap.set(newPath, missingParent);
// 将新建的中间目录挂载到其上级或根级
if (currentPath === '') {
rootNodes.push(missingParent);
} else {
const grandParent = nodeMap.get(currentPath);
if (grandParent && grandParent.children) {
grandParent.children.push(missingParent);
}
}
}
currentPath = newPath;
}
// 中间目录补全后,将当前节点挂载到直接父级
const finalParent = nodeMap.get(parentPath);
if (finalParent && finalParent.children) {
finalParent.children.push(currentNode);
}
}
}
});
// 递归地对每一层级的子节点应用排序
const sortAllChildren = (nodes: FileNode[]): FileNode[] => {
return sortChildren(nodes).map(node => {
if (node.children) node.children = sortAllChildren(node.children);
else delete node.children;
return node;
});
};
return sortAllChildren(rootNodes);
};