- 路径校验提取validateFilePath+sentinel error替代字符串匹配 - requireUpdateAPI收敛7处重复nil检查 - 端口18765统一为8073,消除分散魔法数字 - CodeMirror添加搜索功能+滚动位置LRU缓存恢复 - 文件列表新增列排序+搜索过滤 - Toolbar重排:快捷访问内嵌+搜索框集成+历史改图标 - 重命名零闪烁:updateFilePath草稿迁移 - changelog用marked渲染+sanitizeHtml防XSS - MigrateTabConfig扩展map驱动覆盖openclaw-manager→version迁移 Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
273 lines
7.6 KiB
JavaScript
273 lines
7.6 KiB
JavaScript
/**
|
||
* 文件工具函数集合
|
||
*
|
||
* @module utils/fileUtils
|
||
* @description 提供文件相关的通用工具函数,避免代码重复
|
||
*/
|
||
|
||
import { FILE_ICON_MAP, FILE_ICONS, BYTE_UNITS, FILE_SIZE_FORMAT } from './constants'
|
||
|
||
/**
|
||
* 路径分隔符正则(匹配 Windows 和 Unix 风格)
|
||
* @type {RegExp}
|
||
*/
|
||
const PATH_SEPARATOR_REGEX = /[/\\]/
|
||
|
||
/**
|
||
* 规范化路径分隔符(统一为正斜杠)
|
||
* @param {string} path - 文件路径
|
||
* @returns {string} 规范化后的路径
|
||
*/
|
||
export const normalizePathSeparators = (path) => {
|
||
if (!path) return ''
|
||
return path.replace(/\\/g, '/')
|
||
}
|
||
|
||
/**
|
||
* HTML 转义,防止 XSS
|
||
* @param {string} str - 原始字符串
|
||
* @returns {string} 转义后的字符串
|
||
*/
|
||
export const escapeHtml = (str) => {
|
||
if (str == null) return ''
|
||
return String(str)
|
||
.replace(/&/g, '&')
|
||
.replace(/</g, '<')
|
||
.replace(/>/g, '>')
|
||
.replace(/"/g, '"')
|
||
.replace(/'/g, ''')
|
||
}
|
||
|
||
/**
|
||
* 轻量 HTML 消毒(用于渲染远程 Markdown 等不可信 HTML 片段)
|
||
* 移除 script/iframe/object/embed 标签和 on* 事件属性
|
||
*/
|
||
export const sanitizeHtml = (html) => {
|
||
if (!html) return ''
|
||
return String(html)
|
||
.replace(/<script\b[^<]*(?:<\/script>|$)/gi, '')
|
||
.replace(/<iframe\b[^<]*(?:<\/iframe>|$)/gi, '')
|
||
.replace(/<object\b[^<]*(?:<\/object>|$)/gi, '')
|
||
.replace(/<embed\b[^>]*\/?>/gi, '')
|
||
.replace(/\s+on\w+\s*=\s*["'][^"']*["']/gi, '')
|
||
.replace(/\s+on\w+\s*=\s*[^\s>]*/gi, '')
|
||
}
|
||
|
||
/**
|
||
* 获取文件扩展名(路径安全)
|
||
* @param {string} path - 文件路径
|
||
* @returns {string} 扩展名(小写,不含点号)
|
||
*/
|
||
export const getExt = (path) => {
|
||
if (!path) return ''
|
||
const dot = path.lastIndexOf('.')
|
||
const slash = Math.max(path.lastIndexOf('/'), path.lastIndexOf('\\'))
|
||
if (dot === -1 || dot < slash) return ''
|
||
return path.slice(dot + 1).toLowerCase()
|
||
}
|
||
|
||
/**
|
||
* 格式化文件大小
|
||
* @param {number} bytes - 文件大小(字节)
|
||
* @returns {string} 格式化后的文件大小字符串
|
||
*
|
||
* @example
|
||
* formatBytes(1024) // "1.00 KB"
|
||
* formatBytes(1048576) // "1.00 MB"
|
||
* formatBytes(0) // "0 B"
|
||
*/
|
||
export function formatBytes(bytes) {
|
||
if (!bytes || bytes === 0) return '0 B'
|
||
if (typeof bytes !== 'number' || isNaN(bytes)) return '0 B'
|
||
|
||
const unit = FILE_SIZE_FORMAT.UNIT
|
||
const decimals = FILE_SIZE_FORMAT.DECIMAL_PLACES
|
||
|
||
if (bytes < unit) return bytes + ' B'
|
||
|
||
const exp = Math.min(Math.floor(Math.log(bytes) / Math.log(unit)), BYTE_UNITS.length - 1)
|
||
const value = bytes / Math.pow(unit, exp)
|
||
const unitSymbol = BYTE_UNITS[exp]
|
||
|
||
return value.toFixed(decimals) + ' ' + unitSymbol
|
||
}
|
||
|
||
/**
|
||
* 从文件路径中提取文件名
|
||
* @param {string} path - 文件路径
|
||
* @returns {string} 文件名
|
||
*
|
||
* @example
|
||
* getFileName('/home/user/docs/file.txt') // "file.txt"
|
||
* getFileName('C:\\Users\\user\\file.txt') // "file.txt"
|
||
* getFileName('file.txt') // "file.txt"
|
||
*/
|
||
export function getFileName(path) {
|
||
if (!path) return ''
|
||
const parts = path.split(PATH_SEPARATOR_REGEX)
|
||
return parts[parts.length - 1] || path
|
||
}
|
||
|
||
|
||
/**
|
||
* 根据文件信息获取对应的图标
|
||
* @param {Object} fileInfo - 文件信息对象
|
||
* @param {boolean} fileInfo.is_dir - 是否为目录
|
||
* @param {string} fileInfo.name - 文件名
|
||
* @returns {string} 文件图标(emoji)
|
||
*
|
||
* @example
|
||
* getFileIcon({ is_dir: true }) // "📁"
|
||
* getFileIcon({ is_dir: false, name: 'image.png' }) // "🖼️"
|
||
* getFileIcon({ is_dir: false, name: 'document.pdf' }) // "📕"
|
||
*/
|
||
export function getFileIcon(fileInfo) {
|
||
if (!fileInfo) return FILE_ICONS.FILE
|
||
|
||
// 如果是目录
|
||
if (fileInfo.is_dir) {
|
||
return FILE_ICONS.FOLDER
|
||
}
|
||
|
||
// 获取文件扩展名
|
||
const ext = getExt(fileInfo.name)
|
||
|
||
// 从映射表中查找图标
|
||
return FILE_ICON_MAP.get(ext) || FILE_ICONS.FILE
|
||
}
|
||
|
||
/**
|
||
* 规范化文件路径(将反斜杠转换为正斜杠,并进行URL编码)
|
||
* @param {string} path - 原始路径
|
||
* @param {boolean} encode - 是否进行URL编码(用于URL路径)
|
||
* @returns {string} 规范化后的路径
|
||
*
|
||
* @example
|
||
* normalizeFilePath('C:\\Users\\user\\file.txt') // "C:/Users/user/file.txt"
|
||
* normalizeFilePath('/home/user/file.txt') // "/home/user/file.txt"
|
||
* normalizeFilePath('E:/中文路径/file.pdf', true) // "E:/%E4%B8%AD%E6%96%87%E8%B7%AF%E5%BE%84/file.pdf"
|
||
*/
|
||
export function normalizeFilePath(path, encode = false) {
|
||
if (!path) return ''
|
||
const normalized = normalizePathSeparators(path)
|
||
|
||
// 如果需要编码,则使用 encodeURIComponent
|
||
if (encode) {
|
||
const parts = normalized.split('/')
|
||
// 只对包含需要编码字符的路径段进行编码
|
||
// Windows 路径格式: E:/path/to/file,第一部分是盘符(如 E:),不应编码
|
||
return parts.map((segment, index) => {
|
||
// 盘符部分(如 "E:")不编码
|
||
if (index === 0 && /^[A-Za-z]:$/.test(segment)) {
|
||
return segment
|
||
}
|
||
// 检查是否需要编码(包含非ASCII字符或特殊字符)
|
||
if (/[^A-Za-z0-9\-_.~]/.test(segment)) {
|
||
return encodeURIComponent(segment)
|
||
}
|
||
return segment
|
||
}).join('/')
|
||
}
|
||
|
||
return normalized
|
||
}
|
||
|
||
/**
|
||
* 获取路径使用的分隔符(Windows 反斜杠或 Unix 正斜杠)
|
||
* @param {string} path - 文件路径
|
||
* @returns {string} 分隔符 '\\' 或 '/'
|
||
*/
|
||
export function getPathSeparator(path) {
|
||
return path.includes('\\') ? '\\' : '/'
|
||
}
|
||
|
||
/**
|
||
* 获取父目录路径
|
||
* @param {string} path - 文件路径
|
||
* @returns {string} 父目录路径
|
||
*
|
||
* @example
|
||
* getParentPath('/home/user/docs/file.txt') // "/home/user/docs"
|
||
* getParentPath('/home/user/docs/') // "/home/user"
|
||
*/
|
||
export function getParentPath(path) {
|
||
if (!path) return ''
|
||
|
||
const normalizedPath = normalizePathSeparators(path)
|
||
const lastSlashIndex = normalizedPath.lastIndexOf('/')
|
||
|
||
if (lastSlashIndex <= 0) {
|
||
if (/^[A-Za-z]:$/.test(normalizedPath)) {
|
||
return normalizedPath + '/'
|
||
}
|
||
return normalizedPath || '/'
|
||
}
|
||
|
||
const parentPath = normalizedPath.substring(0, lastSlashIndex)
|
||
|
||
// 盘符根目录下文件:E:/file.txt → E:/
|
||
if (/^[A-Za-z]:$/.test(parentPath)) {
|
||
return parentPath + '/'
|
||
}
|
||
|
||
return parentPath || '/'
|
||
}
|
||
|
||
|
||
/**
|
||
* 文件列表排序:文件夹优先,支持多字段排序
|
||
* @param {Array} fileList - 文件列表
|
||
* @param {Object} options - 排序选项 { sortBy, sortOrder }
|
||
* @returns {Array} 排序后的文件列表
|
||
*
|
||
* @example
|
||
* sortFileList(fileList, { sortBy: 'name', sortOrder: 'asc' })
|
||
*/
|
||
|
||
/**
|
||
* 格式化文件修改时间
|
||
* @param {string} t - 时间字符串
|
||
* @returns {string} 格式化后的时间,如 2026/04/11 14:30
|
||
*/
|
||
export function formatFileTime(t) {
|
||
if (!t) return ''
|
||
const d = new Date(t)
|
||
if (isNaN(d.getTime())) return t
|
||
const pad = n => String(n).padStart(2, '0')
|
||
return `${d.getFullYear()}/${pad(d.getMonth()+1)}/${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`
|
||
}
|
||
|
||
export function sortFileList(fileList, options = {}) {
|
||
if (!Array.isArray(fileList)) return fileList
|
||
|
||
const { sortBy = 'name', sortOrder = 'asc' } = options
|
||
const dir = sortOrder === 'desc' ? 1 : -1
|
||
|
||
return fileList.sort((a, b) => {
|
||
// 文件夹始终排在前面
|
||
if (a.isDir !== b.isDir) {
|
||
return a.isDir ? -1 : 1
|
||
}
|
||
|
||
let cmp = 0
|
||
switch (sortBy) {
|
||
case 'size':
|
||
cmp = (a.size || 0) - (b.size || 0)
|
||
break
|
||
case 'type': {
|
||
cmp = getExt(a.name).localeCompare(getExt(b.name))
|
||
break
|
||
}
|
||
case 'modified_time': {
|
||
const ta = a.modified_time ? new Date(a.modified_time).getTime() : 0
|
||
const tb = b.modified_time ? new Date(b.modified_time).getTime() : 0
|
||
cmp = ta - tb
|
||
break
|
||
}
|
||
default: // name
|
||
cmp = a.name.localeCompare(b.name)
|
||
}
|
||
return cmp * dir
|
||
})
|
||
}
|