重构:Wails v3 迁移 + 前端目录规范化 + Sidebar滚动优化
- web/ → frontend/ 目录重命名(Wails v3 标准结构) - main.go: Middleware 修复 custom.js 404 + DevTools 延迟启动 - Sidebar: 收藏夹内部独立滚动 + 帮助区块固定底部 - useFavorites.ts: longPressTimer const→let 修复 TypeError - App.vue: Arco Tabs padding-top 覆盖 - build: config.yml / Taskfile.yml 对齐官方模板,devtools build tag - 新增 v3 bindings、vite.config.js、跨平台构建配置 Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,114 @@
|
||||
/**
|
||||
* CodeMirror 语言包加载器(动态导入,按需加载)
|
||||
*/
|
||||
|
||||
import { getCmLanguage } from './languageMap'
|
||||
|
||||
const languageCache = new Map()
|
||||
|
||||
/**
|
||||
* 获取语言扩展(异步,首次调用会动态加载对应语言包)
|
||||
* @param {string} language - 语言名称
|
||||
* @returns {Promise<Extension|null>} CodeMirror 语言扩展
|
||||
*/
|
||||
export async function loadLanguageExtension(language) {
|
||||
if (languageCache.has(language)) {
|
||||
return languageCache.get(language)
|
||||
}
|
||||
|
||||
let mod, extension = null
|
||||
|
||||
switch (language) {
|
||||
case 'javascript':
|
||||
mod = await import('@codemirror/lang-javascript')
|
||||
extension = mod.javascript({ jsx: true })
|
||||
break
|
||||
case 'typescript':
|
||||
mod = await import('@codemirror/lang-javascript')
|
||||
extension = mod.javascript({ typescript: true, jsx: true })
|
||||
break
|
||||
case 'json':
|
||||
;({ json: extension } = await import('@codemirror/lang-json'))
|
||||
extension = extension()
|
||||
break
|
||||
case 'yaml':
|
||||
;({ yaml: extension } = await import('@codemirror/lang-yaml'))
|
||||
extension = extension()
|
||||
break
|
||||
case 'html':
|
||||
;({ html: extension } = await import('@codemirror/lang-html'))
|
||||
extension = extension()
|
||||
break
|
||||
case 'css':
|
||||
;({ css: extension } = await import('@codemirror/lang-css'))
|
||||
extension = extension()
|
||||
break
|
||||
case 'cpp':
|
||||
case 'c':
|
||||
;({ cpp: extension } = await import('@codemirror/lang-cpp'))
|
||||
extension = extension()
|
||||
break
|
||||
case 'rust':
|
||||
;({ rust: extension } = await import('@codemirror/lang-rust'))
|
||||
extension = extension()
|
||||
break
|
||||
case 'go':
|
||||
;({ go: extension } = await import('@codemirror/lang-go'))
|
||||
extension = extension()
|
||||
break
|
||||
case 'python':
|
||||
;({ python: extension } = await import('@codemirror/lang-python'))
|
||||
extension = extension()
|
||||
break
|
||||
case 'php':
|
||||
;({ php: extension } = await import('@codemirror/lang-php'))
|
||||
extension = extension()
|
||||
break
|
||||
case 'sql':
|
||||
;({ sql: extension } = await import('@codemirror/lang-sql'))
|
||||
extension = extension()
|
||||
break
|
||||
case 'markdown':
|
||||
;({ markdown: extension } = await import('@codemirror/lang-markdown'))
|
||||
extension = extension()
|
||||
break
|
||||
case 'java':
|
||||
;({ java: extension } = await import('@codemirror/lang-java'))
|
||||
extension = extension()
|
||||
break
|
||||
case 'shell':
|
||||
case 'bash':
|
||||
case 'sh':
|
||||
case 'dockerfile': {
|
||||
const { StreamLanguage, shell } = await import('@codemirror/legacy-modes/mode/shell')
|
||||
extension = StreamLanguage.define(shell)
|
||||
break
|
||||
}
|
||||
case 'powershell': {
|
||||
const { StreamLanguage, powerShell } = await import('@codemirror/legacy-modes/mode/powershell')
|
||||
extension = StreamLanguage.define(powerShell)
|
||||
break
|
||||
}
|
||||
case 'dart': {
|
||||
const { StreamLanguage, clike: dart } = await import('@codemirror/legacy-modes/mode/clike')
|
||||
extension = StreamLanguage.define(dart)
|
||||
break
|
||||
}
|
||||
default:
|
||||
return null
|
||||
}
|
||||
|
||||
if (extension) {
|
||||
languageCache.set(language, extension)
|
||||
}
|
||||
return extension
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据文件扩展名获取语言名称
|
||||
* @param {string} extension - 文件扩展名
|
||||
* @returns {string} 语言名称
|
||||
*/
|
||||
export function getLanguageFromExtension(extension) {
|
||||
return getCmLanguage(extension)
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
/**
|
||||
* CodeMirror 统一导出
|
||||
* 确保所有模块使用同一个 CodeMirror 实例,避免多实例问题
|
||||
*/
|
||||
|
||||
// Core
|
||||
export { EditorView, lineNumbers, highlightActiveLineGutter, keymap } from '@codemirror/view'
|
||||
export { EditorState, Compartment } from '@codemirror/state'
|
||||
export { defaultKeymap, history, historyKeymap } from '@codemirror/commands'
|
||||
export { bracketMatching, defaultHighlightStyle, syntaxHighlighting } from '@codemirror/language'
|
||||
export { oneDark } from '@codemirror/theme-one-dark'
|
||||
|
||||
// 查找替换
|
||||
export { openSearchPanel, closeSearchPanel, search, searchKeymap, SearchQuery } from '@codemirror/search'
|
||||
|
||||
// 语言包通过 codeMirrorLoader 动态导入,避免全量打包
|
||||
@@ -0,0 +1,394 @@
|
||||
/**
|
||||
* 应用全局常量配置
|
||||
*
|
||||
* @module utils/constants
|
||||
* @description 集中管理所有应用常量,避免硬编码和重复定义
|
||||
*/
|
||||
|
||||
/**
|
||||
* localStorage 键名管理
|
||||
* @description 统一的localStorage键名规范,避免冲突和重复
|
||||
*
|
||||
* 命名规范:app-{feature}-{key}
|
||||
* - app: 应用级前缀
|
||||
* - feature: 功能模块标识(filesystem/device-test等)
|
||||
* - key: 具体的数据项
|
||||
*/
|
||||
export const STORAGE_KEYS = {
|
||||
// 文件系统模块
|
||||
FILESYSTEM: {
|
||||
FILE_PATH: 'app-filesystem-file-path',
|
||||
FILE_LIST: 'app-filesystem-file-list',
|
||||
FILE_CONTENT: 'app-filesystem-file-content',
|
||||
PATH_HISTORY: 'app-filesystem-path-history',
|
||||
FILE_CONTENT_HEIGHT: 'app-filesystem-file-content-height',
|
||||
PANEL_WIDTH: 'app-filesystem-panel-width',
|
||||
SIDEBAR_VISIBLE: 'app-filesystem-sidebar-visible',
|
||||
FAVORITE_FILES: 'app-filesystem-favorite-files',
|
||||
EDIT_MODE: 'app-filesystem-edit-mode', // HTML/Markdown 编辑模式状态
|
||||
FILE_DRAFT: 'app-filesystem-file-draft', // 文件草稿
|
||||
SORT: 'app-filesystem-sort', // 排序状态
|
||||
COL_SETTINGS: 'app-filesystem-col-settings', // 列配置(显隐+顺序)
|
||||
SHOW_HEADER: 'app-filesystem-show-header', // 表头显隐
|
||||
LAST_OPENED_FILE: 'app-filesystem-last-opened-file', // 上次打开的文件路径
|
||||
},
|
||||
|
||||
// 设备测试模块
|
||||
DEVICE_TEST: {
|
||||
FILE_PATH: 'app-device-test-file-path',
|
||||
FILE_LIST: 'app-device-test-file-list',
|
||||
FILE_CONTENT: 'app-device-test-file-content',
|
||||
PATH_HISTORY: 'app-device-test-path-history',
|
||||
FILE_CONTENT_HEIGHT: 'app-device-test-file-content-height',
|
||||
PANEL_WIDTH: 'app-device-test-panel-width',
|
||||
FAVORITE_FILES: 'app-device-test-favorite-files',
|
||||
},
|
||||
|
||||
// 通用配置
|
||||
COMMON: {
|
||||
THEME: 'app-common-theme',
|
||||
LANGUAGE: 'app-common-language',
|
||||
},
|
||||
}
|
||||
|
||||
/**
|
||||
* 文件扩展名分类
|
||||
* @description 用于文件类型识别和图标映射
|
||||
*/
|
||||
export const FILE_EXTENSIONS = {
|
||||
// 图片文件
|
||||
IMAGE: ['jpg', 'jpeg', 'png', 'gif', 'bmp', 'svg', 'webp', 'ico', 'heic', 'heif'],
|
||||
|
||||
// 视频文件
|
||||
VIDEO_BROWSER: ['mp4', 'webm', 'ogg', 'mov', 'm4v'], // 浏览器原生支持
|
||||
VIDEO_EXTERNAL: ['avi', 'mkv', 'wmv', 'flv', 'rmvb', '3gp', 'mts'], // 需要外部播放器(注意:不用 'ts' 避免 TypeScript 冲突)
|
||||
// 音频文件
|
||||
AUDIO: ['mp3', 'wav', 'flac', 'aac', 'ogg', 'm4a', 'opus', 'webm'],
|
||||
|
||||
// 文档文件
|
||||
DOCUMENT: ['doc', 'docx', 'pdf', 'xls', 'xlsx', 'ppt', 'pptx', 'txt', 'rtf', 'odt', 'ods', 'odp'],
|
||||
|
||||
// 压缩文件
|
||||
ARCHIVE: ['zip', 'rar', '7z', 'tar', 'gz', 'bz2', 'xz', 'z', 'cab', 'iso'],
|
||||
|
||||
// 代码文件
|
||||
CODE: [
|
||||
'js', 'ts', 'jsx', 'tsx', 'cts', 'mts', 'cjs', 'mjs',
|
||||
'vue', 'py', 'java', 'c', 'cpp', 'h', 'go', 'rs', 'php', 'rb', 'cs', 'swift', 'kt',
|
||||
'scala', 'dart', 'css', 'scss', 'sass', 'less', 'sql', 'sh', 'bat', 'ps1',
|
||||
'flow', 'pch', 'cc', 'cxx', 'hpp', 'hxx', 'tcc', 'defs', 'makefile', 'mk', 'cmake',
|
||||
'dockerfile', 'm', 'r', 'matlab'
|
||||
],
|
||||
|
||||
// 配置文件(可编辑的文本格式)
|
||||
CONFIG: [
|
||||
// 数据格式
|
||||
'json', 'xml', 'yaml', 'yml',
|
||||
// 配置文件
|
||||
'toml', 'ini', 'cfg', 'conf',
|
||||
// 环境变量/属性
|
||||
'props', 'env', 'dotenv',
|
||||
// 其他
|
||||
'manifest', 'lock', 'ignore'
|
||||
],
|
||||
|
||||
// 纯文本文件
|
||||
TEXT: ['txt', 'text', 'log', 'md', 'markdown', 'rst', 'adoc', 'tex', 'msg', 'csv', 'tsv'],
|
||||
|
||||
// 数据库文件
|
||||
DATABASE: ['db', 'sqlite', 'mdb', 'accdb'],
|
||||
|
||||
// 可执行文件
|
||||
EXECUTABLE: ['exe', 'msi', 'app', 'dmg', 'deb', 'rpm', 'dll', 'so', 'jsa', 'jar'],
|
||||
|
||||
// 字体文件
|
||||
FONT: ['ttf', 'otf', 'woff', 'woff2', 'eot'],
|
||||
}
|
||||
|
||||
/**
|
||||
* 文件类型图标映射
|
||||
* @description 根据文件扩展名返回对应的图标
|
||||
*/
|
||||
export const FILE_ICONS = {
|
||||
// 图片
|
||||
IMAGE: '🖼️',
|
||||
|
||||
// 视频
|
||||
VIDEO: '🎬',
|
||||
|
||||
// 音频
|
||||
AUDIO: '🎵',
|
||||
|
||||
// 文档
|
||||
PDF: '📕',
|
||||
DOC: '📘',
|
||||
XLS: '📗',
|
||||
PPT: '📙',
|
||||
TXT: '📃',
|
||||
DOCUMENT: '📄',
|
||||
|
||||
// 压缩包
|
||||
ARCHIVE: '📦',
|
||||
|
||||
// 代码
|
||||
CODE: '💻',
|
||||
|
||||
// 编程语言特定图标
|
||||
JAVA: '☕',
|
||||
JAR: '🏺',
|
||||
JSA: '📦',
|
||||
GO: '🐹',
|
||||
PYTHON: '🐍',
|
||||
JAVASCRIPT: '📜',
|
||||
TYPESCRIPT: '💠',
|
||||
HTML: '🌐',
|
||||
CSS: '🎨',
|
||||
SQL: '🗃️',
|
||||
JSON: '📋',
|
||||
XML: '📰',
|
||||
YAML: '⚙️',
|
||||
SHELL: '🐚',
|
||||
C: '🔷',
|
||||
CPP: '🔶',
|
||||
RUST: '🦀',
|
||||
PHP: '🐘',
|
||||
RUBY: '💎',
|
||||
DART: '🎯',
|
||||
DOCKERFILE: '🐳',
|
||||
VUE: '💚',
|
||||
|
||||
// 数据库
|
||||
DATABASE: '🗄️',
|
||||
|
||||
// 可执行文件
|
||||
EXECUTABLE: '⚙️',
|
||||
|
||||
// 字体
|
||||
FONT: '🔤',
|
||||
|
||||
// 文件夹
|
||||
FOLDER: '📁',
|
||||
|
||||
// 默认文件
|
||||
FILE: '📄',
|
||||
}
|
||||
|
||||
/**
|
||||
* 文件类型到图标的映射表
|
||||
* @description 扩展名 -> 图标 的快速查找表
|
||||
*/
|
||||
export const FILE_ICON_MAP = new Map()
|
||||
|
||||
// 初始化图标映射表
|
||||
const initIconMap = () => {
|
||||
// 图片
|
||||
FILE_EXTENSIONS.IMAGE.forEach(ext => FILE_ICON_MAP.set(ext, FILE_ICONS.IMAGE))
|
||||
|
||||
// 视频
|
||||
FILE_EXTENSIONS.VIDEO_BROWSER.forEach(ext => FILE_ICON_MAP.set(ext, FILE_ICONS.VIDEO))
|
||||
FILE_EXTENSIONS.VIDEO_EXTERNAL.forEach(ext => FILE_ICON_MAP.set(ext, FILE_ICONS.VIDEO))
|
||||
|
||||
// 音频
|
||||
FILE_EXTENSIONS.AUDIO.forEach(ext => FILE_ICON_MAP.set(ext, FILE_ICONS.AUDIO))
|
||||
|
||||
// 文档
|
||||
FILE_EXTENSIONS.DOCUMENT.forEach(ext => {
|
||||
if (ext === 'pdf') FILE_ICON_MAP.set(ext, FILE_ICONS.PDF)
|
||||
else if (['doc', 'docx'].includes(ext)) FILE_ICON_MAP.set(ext, FILE_ICONS.DOC)
|
||||
else if (['xls', 'xlsx'].includes(ext)) FILE_ICON_MAP.set(ext, FILE_ICONS.XLS)
|
||||
else if (['ppt', 'pptx'].includes(ext)) FILE_ICON_MAP.set(ext, FILE_ICONS.PPT)
|
||||
else if (ext === 'txt') FILE_ICON_MAP.set(ext, FILE_ICONS.TXT)
|
||||
else FILE_ICON_MAP.set(ext, FILE_ICONS.DOCUMENT)
|
||||
})
|
||||
|
||||
// 压缩文件
|
||||
FILE_EXTENSIONS.ARCHIVE.forEach(ext => FILE_ICON_MAP.set(ext, FILE_ICONS.ARCHIVE))
|
||||
|
||||
// 代码文件(通用)
|
||||
FILE_EXTENSIONS.CODE.forEach(ext => FILE_ICON_MAP.set(ext, FILE_ICONS.CODE))
|
||||
|
||||
// 配置文件(使用特定图标)
|
||||
const configIcons = {
|
||||
'json': FILE_ICONS.JSON,
|
||||
'xml': FILE_ICONS.XML,
|
||||
'yaml': FILE_ICONS.YAML,
|
||||
'yml': FILE_ICONS.YAML
|
||||
}
|
||||
FILE_EXTENSIONS.CONFIG.forEach(ext => {
|
||||
FILE_ICON_MAP.set(ext, configIcons[ext] || FILE_ICONS.YAML)
|
||||
})
|
||||
|
||||
// 编程语言特定图标
|
||||
const langIcons = {
|
||||
// Java
|
||||
'java': FILE_ICONS.JAVA,
|
||||
'jar': FILE_ICONS.JAR,
|
||||
'jsa': FILE_ICONS.JSA,
|
||||
// Go
|
||||
'go': FILE_ICONS.GO,
|
||||
// Python
|
||||
'py': FILE_ICONS.PYTHON,
|
||||
'pyw': FILE_ICONS.PYTHON,
|
||||
// JavaScript/TypeScript
|
||||
'js': FILE_ICONS.JAVASCRIPT,
|
||||
'jsx': FILE_ICONS.JAVASCRIPT,
|
||||
'ts': FILE_ICONS.TYPESCRIPT,
|
||||
'tsx': FILE_ICONS.TYPESCRIPT,
|
||||
'mjs': FILE_ICONS.JAVASCRIPT,
|
||||
'cjs': FILE_ICONS.JAVASCRIPT,
|
||||
// Web
|
||||
'html': FILE_ICONS.HTML,
|
||||
'htm': FILE_ICONS.HTML,
|
||||
'xhtml': FILE_ICONS.HTML,
|
||||
'css': FILE_ICONS.CSS,
|
||||
'scss': FILE_ICONS.CSS,
|
||||
'sass': FILE_ICONS.CSS,
|
||||
'less': FILE_ICONS.CSS,
|
||||
// Shell
|
||||
'sh': FILE_ICONS.SHELL,
|
||||
'bash': FILE_ICONS.SHELL,
|
||||
'zsh': FILE_ICONS.SHELL,
|
||||
'fish': FILE_ICONS.SHELL,
|
||||
'cmd': FILE_ICONS.SHELL,
|
||||
'bat': FILE_ICONS.SHELL,
|
||||
'ps1': FILE_ICONS.SHELL,
|
||||
// C/C++
|
||||
'c': FILE_ICONS.C,
|
||||
'h': FILE_ICONS.C,
|
||||
'cpp': FILE_ICONS.CPP,
|
||||
'hpp': FILE_ICONS.CPP,
|
||||
'cc': FILE_ICONS.CPP,
|
||||
'cxx': FILE_ICONS.CPP,
|
||||
// Rust
|
||||
'rs': FILE_ICONS.RUST,
|
||||
// PHP
|
||||
'php': FILE_ICONS.PHP,
|
||||
// Ruby
|
||||
'rb': FILE_ICONS.RUBY,
|
||||
'gem': FILE_ICONS.RUBY,
|
||||
// SQL
|
||||
'sql': FILE_ICONS.SQL,
|
||||
// Dart
|
||||
'dart': FILE_ICONS.DART,
|
||||
// Dockerfile
|
||||
'dockerfile': FILE_ICONS.DOCKERFILE,
|
||||
// Vue
|
||||
'vue': FILE_ICONS.VUE,
|
||||
}
|
||||
|
||||
Object.keys(langIcons).forEach(ext => FILE_ICON_MAP.set(ext, langIcons[ext]))
|
||||
|
||||
// 数据库
|
||||
FILE_EXTENSIONS.DATABASE.forEach(ext => FILE_ICON_MAP.set(ext, FILE_ICONS.DATABASE))
|
||||
|
||||
// 可执行文件
|
||||
FILE_EXTENSIONS.EXECUTABLE.slice(0, 6).forEach(ext => FILE_ICON_MAP.set(ext, FILE_ICONS.EXECUTABLE))
|
||||
|
||||
// 字体
|
||||
FILE_EXTENSIONS.FONT.forEach(ext => FILE_ICON_MAP.set(ext, FILE_ICONS.FONT))
|
||||
}
|
||||
|
||||
// 执行初始化
|
||||
initIconMap()
|
||||
|
||||
/**
|
||||
* 常用路径快捷方式
|
||||
* @description 系统常用路径的emoji标识
|
||||
*/
|
||||
export const PATH_ICONS = {
|
||||
DESKTOP: '🖥️',
|
||||
DOCUMENTS: '📁',
|
||||
DOWNLOADS: '📥',
|
||||
HOME: '🏠',
|
||||
ROOT: '📂',
|
||||
DRIVE: '💿',
|
||||
}
|
||||
|
||||
/**
|
||||
* 文件大小单位
|
||||
* @description 用于文件大小格式化的单位数组
|
||||
*/
|
||||
export const BYTE_UNITS = ['B', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB']
|
||||
|
||||
/**
|
||||
* 默认配置值
|
||||
*/
|
||||
export const DEFAULTS = {
|
||||
// 路径历史最大记录数
|
||||
MAX_HISTORY_LENGTH: 20,
|
||||
|
||||
// 收藏夹最大数量
|
||||
MAX_FAVORITES_LENGTH: 50,
|
||||
|
||||
// 文件内容高度范围(px)
|
||||
MIN_CONTENT_HEIGHT: 100,
|
||||
MAX_CONTENT_HEIGHT: 800,
|
||||
DEFAULT_CONTENT_HEIGHT: 200,
|
||||
|
||||
// 面板宽度范围(%)
|
||||
MIN_PANEL_WIDTH: 20,
|
||||
MAX_PANEL_WIDTH: 80,
|
||||
DEFAULT_PANEL_WIDTH: 50,
|
||||
|
||||
// 侧边栏宽度(px)
|
||||
SIDEBAR_WIDTH: 220,
|
||||
}
|
||||
|
||||
/**
|
||||
* 文件大小格式化配置
|
||||
*/
|
||||
export const FILE_SIZE_FORMAT = {
|
||||
UNIT: 1024, // 使用1024进制(KiB, MiB等)
|
||||
DECIMAL_PLACES: 2, // 保留小数位数
|
||||
}
|
||||
|
||||
/**
|
||||
* 文件大小阈值配置
|
||||
* @description 用于文件处理逻辑的大小限制
|
||||
*/
|
||||
export const FILE_SIZE_THRESHOLDS = {
|
||||
LARGE_FILE: 100 * 1024, // 100KB - 大文件检测阈值
|
||||
MAX_TEXT_DISPLAY: 5 * 1024 * 1024, // 5MB - 文本文件最大显示大小
|
||||
}
|
||||
|
||||
/**
|
||||
* UI 文本常量
|
||||
* @description 界面上显示的固定文本
|
||||
*/
|
||||
export const UI_TEXT = {
|
||||
// 对话框标题
|
||||
CREATE_FILE: '📄 新建文件',
|
||||
CREATE_FOLDER: '📁 新建文件夹',
|
||||
RENAME_FILE: '重命名文件',
|
||||
DELETE_CONFIRM: '确认删除',
|
||||
|
||||
// 按钮文本
|
||||
CONFIRM: '确定',
|
||||
CANCEL: '取消',
|
||||
CREATE: '创建',
|
||||
SAVE: '保存',
|
||||
DELETE: '删除',
|
||||
|
||||
// 提示信息
|
||||
FILE_NAME_EMPTY: '请输入内容',
|
||||
FILE_NAME_INVALID: '文件名包含非法字符',
|
||||
FOLDER_NAME_INVALID: '文件夹名包含非法字符',
|
||||
FILE_EXISTS: '文件已存在',
|
||||
FOLDER_EXISTS: '文件夹已存在',
|
||||
SELECT_DIRECTORY: '请先选择一个目录',
|
||||
CREATE_SUCCESS: '创建成功',
|
||||
CREATE_FAILED: '创建失败',
|
||||
|
||||
// 输入提示
|
||||
ENTER_FILE_NAME: '请输入文件名(如: todo.md)',
|
||||
ENTER_FOLDER_NAME: '请输入文件夹名称',
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证规则
|
||||
* @description 数据验证的正则表达式规则
|
||||
*/
|
||||
export const VALIDATION_RULES = {
|
||||
// Windows 文件名非法字符
|
||||
ILLEGAL_FILE_NAME_CHARS: /[<>:"/\\|?*]/,
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
/**
|
||||
* 调试日志工具
|
||||
*
|
||||
* 通过环境变量控制调试日志输出
|
||||
* 开发环境:输出详细日志
|
||||
* 生产环境:仅输出错误
|
||||
*/
|
||||
|
||||
// 检测是否为开发环境
|
||||
const isDevelopment = import.meta.env.DEV || import.meta.env.MODE === 'development'
|
||||
|
||||
/**
|
||||
* 调试日志输出(仅开发环境)
|
||||
* @param {...any} args - 要输出的参数
|
||||
*/
|
||||
export const debugLog = (...args) => {
|
||||
if (isDevelopment) {
|
||||
console.log('[FileSystem]', ...args)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 警告日志(所有环境)
|
||||
* @param {...any} args - 要输出的参数
|
||||
*/
|
||||
export const debugWarn = (...args) => {
|
||||
console.warn('[FileSystem]', ...args)
|
||||
}
|
||||
|
||||
/**
|
||||
* 错误日志(所有环境)
|
||||
* @param {...any} args - 要输出的参数
|
||||
*/
|
||||
export const debugError = (...args) => {
|
||||
console.error('[FileSystem]', ...args)
|
||||
}
|
||||
|
||||
/**
|
||||
* 分组日志开始(仅开发环境)
|
||||
* @param {string} label - 分组标签
|
||||
*/
|
||||
export const debugGroup = (label) => {
|
||||
if (isDevelopment) {
|
||||
console.group(`[FileSystem] ${label}`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 分组日志结束(仅开发环境)
|
||||
*/
|
||||
export const debugGroupEnd = () => {
|
||||
if (isDevelopment) {
|
||||
console.groupEnd()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 条件日志(仅在满足条件时输出)
|
||||
* @param {boolean} condition - 是否输出
|
||||
* @param {...any} args - 要输出的参数
|
||||
*/
|
||||
export const debugIf = (condition, ...args) => {
|
||||
if (isDevelopment && condition) {
|
||||
console.log('[FileSystem]', ...args)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 性能日志(仅开发环境)
|
||||
* @param {string} label - 性能标签
|
||||
* @param {Function} fn - 要测量的函数
|
||||
*/
|
||||
export const debugTime = (label, fn) => {
|
||||
if (isDevelopment) {
|
||||
console.time(`[FileSystem] ${label}`)
|
||||
const result = fn()
|
||||
console.timeEnd(`[FileSystem] ${label}`)
|
||||
return result
|
||||
}
|
||||
return fn()
|
||||
}
|
||||
@@ -0,0 +1,331 @@
|
||||
/**
|
||||
* Office 文件预览处理器
|
||||
*/
|
||||
|
||||
import { escapeHtml } from './fileUtils'
|
||||
import { isExcelFile, isWordFile, isOfficeFile, isCsvFile } from './fileTypeHelpers'
|
||||
|
||||
// 每批加载行数
|
||||
const BATCH_ROWS = 200
|
||||
const LOAD_MORE_THRESHOLD = 100
|
||||
|
||||
// Excel 预览处理器
|
||||
export async function previewExcel(file, container) {
|
||||
const XLSX = await import('xlsx')
|
||||
const arrayBuffer = await file.arrayBuffer()
|
||||
const workbook = XLSX.read(arrayBuffer, { type: 'array' })
|
||||
|
||||
// 渲染标签页
|
||||
const tabs = workbook.SheetNames.map((name, i) =>
|
||||
`<button class="excel-tab ${i === 0 ? 'active' : ''}" data-idx="${i}">${name}</button>`
|
||||
).join('')
|
||||
|
||||
container.innerHTML = `
|
||||
<div class="excel-preview">
|
||||
<div class="excel-tabs">${tabs}</div>
|
||||
<div class="excel-info"></div>
|
||||
<div class="excel-content"></div>
|
||||
</div>
|
||||
<style>
|
||||
.excel-preview{display:flex;flex-direction:column;height:100%;overflow:hidden}
|
||||
.excel-tabs{display:flex;flex-wrap:wrap;gap:4px;padding:8px 12px;background:var(--color-fill-1);border-bottom:1px solid var(--color-border-2)}
|
||||
.excel-tab{padding:6px 14px;border:none;background:transparent;color:var(--color-text-2);font-size:13px;border-radius:4px;cursor:pointer;transition:all .2s}
|
||||
.excel-tab:hover{background:var(--color-fill-3)}
|
||||
.excel-tab.active{background:rgb(var(--primary-6));color:#fff;font-weight:500}
|
||||
.excel-info{padding:4px 12px;background:var(--color-fill-1);font-size:12px;color:var(--color-text-3);border-bottom:1px solid var(--color-border-2)}
|
||||
.excel-content{flex:1;overflow:auto;padding:12px}
|
||||
.excel-content table{width:100%;border-collapse:collapse;font-size:13px}
|
||||
.excel-content td,.excel-content th{border:1px solid var(--color-border-2);padding:6px 10px;text-align:left;white-space:nowrap;color:var(--color-text-1)}
|
||||
.excel-content th{background:var(--color-fill-2);font-weight:600;position:sticky;top:0;z-index:2}
|
||||
.excel-content .row-num{background:var(--color-fill-1);color:var(--color-text-3);text-align:center;min-width:10px;position:sticky;left:0;z-index:1}
|
||||
.excel-content th.row-num{z-index:3;top:0;left:0}
|
||||
.excel-content tr:hover td{background:var(--color-fill-1)}
|
||||
.excel-content tr:hover .row-num{background:var(--color-fill-2)}
|
||||
</style>
|
||||
`
|
||||
|
||||
const contentEl = container.querySelector('.excel-content')
|
||||
const infoEl = container.querySelector('.excel-info')
|
||||
const tabsEl = container.querySelector('.excel-tabs')
|
||||
|
||||
// 当前 sheet 状态
|
||||
let currentSheet = { idx: 0, data: null, renderedRows: 0, totalRows: 0 }
|
||||
|
||||
// 获取 sheet 数据
|
||||
const getSheetData = (idx) => {
|
||||
const ws = workbook.Sheets[workbook.SheetNames[idx]]
|
||||
return XLSX.utils.sheet_to_json(ws, { header: 1, defval: '' })
|
||||
}
|
||||
|
||||
// 渲染表格(带行号)
|
||||
const renderTable = (data, startRow = 0) => {
|
||||
let html = '<table><thead><tr><th class="row-num">#</th>'
|
||||
if (data[0]) {
|
||||
data[0].forEach((cell, i) => {
|
||||
html += `<th>${escapeHtml(cell)}</th>`
|
||||
})
|
||||
}
|
||||
html += '</tr></thead><tbody>'
|
||||
|
||||
for (let i = 1; i < data.length && i <= startRow + BATCH_ROWS; i++) {
|
||||
html += `<tr><td class="row-num">${i}</td>`
|
||||
if (data[i]) {
|
||||
data[i].forEach(cell => {
|
||||
html += `<td>${escapeHtml(cell)}</td>`
|
||||
})
|
||||
}
|
||||
html += '</tr>'
|
||||
}
|
||||
html += '</tbody></table>'
|
||||
return html
|
||||
}
|
||||
|
||||
// 追加行
|
||||
const appendRows = (data, fromRow, toRow) => {
|
||||
const tbody = contentEl.querySelector('tbody')
|
||||
if (!tbody) return
|
||||
|
||||
for (let i = fromRow; i <= toRow && i < data.length; i++) {
|
||||
let html = `<tr><td class="row-num">${i}</td>`
|
||||
if (data[i]) {
|
||||
data[i].forEach(cell => {
|
||||
html += `<td>${escapeHtml(cell)}</td>`
|
||||
})
|
||||
}
|
||||
html += '</tr>'
|
||||
tbody.insertAdjacentHTML('beforeend', html)
|
||||
}
|
||||
}
|
||||
|
||||
// 更新信息栏
|
||||
const updateInfo = () => {
|
||||
const { renderedRows, totalRows } = currentSheet
|
||||
if (renderedRows >= totalRows) {
|
||||
infoEl.textContent = `共 ${totalRows} 行`
|
||||
} else {
|
||||
infoEl.textContent = `已加载 ${renderedRows}/${totalRows} 行(滚动加载更多)`
|
||||
}
|
||||
}
|
||||
|
||||
// 切换 sheet
|
||||
const loadSheet = (idx) => {
|
||||
currentSheet = {
|
||||
idx,
|
||||
data: getSheetData(idx),
|
||||
renderedRows: BATCH_ROWS,
|
||||
totalRows: 0
|
||||
}
|
||||
currentSheet.totalRows = currentSheet.data.length
|
||||
|
||||
const displayData = currentSheet.data.slice(0, BATCH_ROWS + 1)
|
||||
contentEl.innerHTML = renderTable(displayData)
|
||||
updateInfo()
|
||||
}
|
||||
|
||||
// 滚动加载更多
|
||||
contentEl.onscroll = () => {
|
||||
const { scrollTop, scrollHeight, clientHeight } = contentEl
|
||||
if (scrollTop + clientHeight >= scrollHeight - LOAD_MORE_THRESHOLD) {
|
||||
const { data, renderedRows, totalRows } = currentSheet
|
||||
if (renderedRows < totalRows - 1) {
|
||||
const newEnd = Math.min(renderedRows + BATCH_ROWS, totalRows - 1)
|
||||
appendRows(data, renderedRows + 1, newEnd)
|
||||
currentSheet.renderedRows = newEnd
|
||||
updateInfo()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
loadSheet(0)
|
||||
|
||||
// 标签页切换
|
||||
tabsEl.onclick = (e) => {
|
||||
const tab = e.target.closest('.excel-tab')
|
||||
if (!tab) return
|
||||
tabsEl.querySelectorAll('.excel-tab').forEach(t => t.classList.remove('active'))
|
||||
tab.classList.add('active')
|
||||
loadSheet(parseInt(tab.dataset.idx, 10))
|
||||
}
|
||||
|
||||
return { success: true }
|
||||
}
|
||||
|
||||
// Word 预览处理器
|
||||
export async function previewWord(file, container) {
|
||||
const mammoth = await import('mammoth')
|
||||
const arrayBuffer = await file.arrayBuffer()
|
||||
const result = await mammoth.convertToHtml({ arrayBuffer })
|
||||
|
||||
const warnings = result.messages.length > 0
|
||||
? `<details class="word-warnings"><summary>转换警告 (${result.messages.length})</summary><ul>${result.messages.map(m => `<li>${m.message}</li>`).join('')}</ul></details>`
|
||||
: ''
|
||||
|
||||
container.innerHTML = `
|
||||
<div class="word-preview">
|
||||
<div class="word-content">${result.value}</div>
|
||||
${warnings}
|
||||
</div>
|
||||
<style>
|
||||
.word-preview{padding:20px;height:100%;overflow:auto;line-height:1.6;color:var(--color-text-1)}
|
||||
.word-content h1,.word-content h2,.word-content h3{margin:1em 0 .5em;font-weight:600}
|
||||
.word-content h1{font-size:2em}.word-content h2{font-size:1.5em}.word-content h3{font-size:1.25em}
|
||||
.word-content p{margin:.5em 0}
|
||||
.word-content ul,.word-content ol{margin:.5em 0;padding-left:2em}
|
||||
.word-content table{border-collapse:collapse;width:100%;margin:1em 0}
|
||||
.word-content td,.word-content th{border:1px solid var(--color-border-2);padding:6px 10px}
|
||||
.word-content th{background:var(--color-fill-2);font-weight:600}
|
||||
.word-content img{max-width:100%;height:auto}
|
||||
.word-content a{color:rgb(var(--primary-6));text-decoration:none}
|
||||
.word-content a:hover{text-decoration:underline}
|
||||
.word-warnings{margin-top:20px;padding:12px;background:var(--color-warning-light-1);border:1px solid var(--color-warning-3);border-radius:6px;font-size:12px}
|
||||
.word-warnings summary{cursor:pointer;font-weight:600}
|
||||
.word-warnings ul{margin:8px 0 0 20px}
|
||||
</style>
|
||||
`
|
||||
|
||||
return { success: true }
|
||||
}
|
||||
|
||||
// 文件类型判断(从 fileTypeHelpers 导入)
|
||||
export { isOfficeFile, isExcelFile, isWordFile, isCsvFile }
|
||||
|
||||
// CSV/TSV 预览处理器(原生实现,支持滚动加载)
|
||||
export async function previewCsv(file, container) {
|
||||
try {
|
||||
if (!container) {
|
||||
return { success: false, error: '容器不存在' }
|
||||
}
|
||||
|
||||
const text = await file.text()
|
||||
const lines = text.split(/\r?\n/).filter(line => line.trim())
|
||||
if (lines.length === 0) {
|
||||
throw new Error('文件为空')
|
||||
}
|
||||
|
||||
// 解析 CSV 行
|
||||
const parseLine = (line, delimiter) => {
|
||||
const cells = []
|
||||
let cell = ''
|
||||
let inQuotes = false
|
||||
|
||||
for (let i = 0; i < line.length; i++) {
|
||||
const char = line[i]
|
||||
if (char === '"') {
|
||||
if (inQuotes && line[i + 1] === '"') {
|
||||
cell += '"'
|
||||
i++
|
||||
} else {
|
||||
inQuotes = !inQuotes
|
||||
}
|
||||
} else if (char === delimiter && !inQuotes) {
|
||||
cells.push(cell)
|
||||
cell = ''
|
||||
} else {
|
||||
cell += char
|
||||
}
|
||||
}
|
||||
cells.push(cell)
|
||||
return cells
|
||||
}
|
||||
|
||||
const delimiter = file.name.endsWith('.tsv') ? '\t' : ','
|
||||
const rows = lines.map(line => parseLine(line, delimiter))
|
||||
|
||||
container.innerHTML = `
|
||||
<div class="csv-preview">
|
||||
<div class="csv-info">📋 ${file.name}</div>
|
||||
<div class="csv-content"></div>
|
||||
</div>
|
||||
<style>
|
||||
.csv-preview{display:flex;flex-direction:column;height:100%;overflow:hidden}
|
||||
.csv-info{padding:4px 12px;background:var(--color-fill-1);font-size:12px;color:var(--color-text-3);border-bottom:1px solid var(--color-border-2)}
|
||||
.csv-content{flex:1;overflow:auto;padding:12px}
|
||||
.csv-content table{width:100%;border-collapse:collapse;font-size:13px}
|
||||
.csv-content td,.csv-content th{border:1px solid var(--color-border-2);padding:6px 10px;text-align:left;white-space:nowrap;color:var(--color-text-1)}
|
||||
.csv-content th{background:var(--color-fill-2);font-weight:600;position:sticky;top:0;z-index:2}
|
||||
.csv-content .row-num{background:var(--color-fill-1);color:var(--color-text-3);text-align:center;min-width:10px;position:sticky;left:0;z-index:1}
|
||||
.csv-content th.row-num{z-index:3;top:0;left:0}
|
||||
.csv-content tr:hover td{background:var(--color-fill-1)}
|
||||
.csv-content tr:hover .row-num{background:var(--color-fill-2)}
|
||||
</style>
|
||||
`
|
||||
|
||||
const contentEl = container.querySelector('.csv-content')
|
||||
const infoEl = container.querySelector('.csv-info')
|
||||
|
||||
// 状态
|
||||
let renderedRows = 0
|
||||
const totalRows = rows.length
|
||||
|
||||
// 渲染表格
|
||||
const renderTable = (startRow = 0) => {
|
||||
const endRow = Math.min(startRow + BATCH_ROWS, totalRows)
|
||||
let html = '<table>'
|
||||
|
||||
// 表头(第一行)
|
||||
if (startRow === 0 && rows[0]) {
|
||||
html += '<thead><tr><th class="row-num">#</th>'
|
||||
rows[0].forEach(cell => { html += `<th>${escapeHtml(cell)}</th>` })
|
||||
html += '</tr></thead><tbody>'
|
||||
}
|
||||
|
||||
// 数据行
|
||||
for (let i = Math.max(1, startRow); i < endRow; i++) {
|
||||
html += `<tr><td class="row-num">${i}</td>`
|
||||
if (rows[i]) {
|
||||
rows[i].forEach(cell => { html += `<td>${escapeHtml(cell)}</td>` })
|
||||
}
|
||||
html += '</tr>'
|
||||
}
|
||||
html += '</tbody></table>'
|
||||
return html
|
||||
}
|
||||
|
||||
// 追加行
|
||||
const appendRows = (fromRow) => {
|
||||
const endRow = Math.min(fromRow + BATCH_ROWS, totalRows)
|
||||
const tbody = contentEl.querySelector('tbody')
|
||||
if (!tbody) return
|
||||
|
||||
for (let i = fromRow; i < endRow; i++) {
|
||||
let html = `<tr><td class="row-num">${i}</td>`
|
||||
if (rows[i]) {
|
||||
rows[i].forEach(cell => { html += `<td>${escapeHtml(cell)}</td>` })
|
||||
}
|
||||
html += '</tr>'
|
||||
tbody.insertAdjacentHTML('beforeend', html)
|
||||
}
|
||||
return endRow
|
||||
}
|
||||
|
||||
// 更新信息
|
||||
const updateInfo = () => {
|
||||
if (renderedRows >= totalRows) {
|
||||
infoEl.textContent = `📋 ${file.name}(共 ${totalRows - 1} 行)`
|
||||
} else {
|
||||
infoEl.textContent = `📋 ${file.name}(已加载 ${renderedRows}/${totalRows - 1} 行)`
|
||||
}
|
||||
}
|
||||
|
||||
// 初始渲染
|
||||
contentEl.innerHTML = renderTable(0)
|
||||
renderedRows = Math.min(BATCH_ROWS, totalRows)
|
||||
updateInfo()
|
||||
|
||||
// 滚动加载
|
||||
contentEl.onscroll = () => {
|
||||
const { scrollTop, scrollHeight, clientHeight } = contentEl
|
||||
if (scrollTop + clientHeight >= scrollHeight - LOAD_MORE_THRESHOLD) {
|
||||
if (renderedRows < totalRows) {
|
||||
renderedRows = appendRows(renderedRows)
|
||||
updateInfo()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { success: true }
|
||||
} catch (err) {
|
||||
console.error('[previewCsv] 错误:', err)
|
||||
return { success: false, error: err?.message || String(err) }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
/**
|
||||
* 文件类型判断工具函数
|
||||
*
|
||||
* @module utils/fileTypeHelpers
|
||||
* @description 统一文件类型判断逻辑,避免内联重复定义
|
||||
*/
|
||||
|
||||
import { FILE_EXTENSIONS } from './constants'
|
||||
import { getExt } from './fileUtils'
|
||||
|
||||
/**
|
||||
* 可预览的文件类型(有专门的预览处理)
|
||||
* @type {string[]}
|
||||
*/
|
||||
export const PREVIEWABLE_TYPES = [
|
||||
...FILE_EXTENSIONS.IMAGE,
|
||||
...FILE_EXTENSIONS.VIDEO_BROWSER,
|
||||
...FILE_EXTENSIONS.AUDIO,
|
||||
'pdf', 'html', 'htm', 'md', 'markdown',
|
||||
// Office 文件支持预览
|
||||
'xlsx', 'xls', 'docx', 'doc',
|
||||
// CSV/TSV 表格文件
|
||||
'csv', 'tsv'
|
||||
]
|
||||
|
||||
/**
|
||||
* 已知二进制文件类型(直接显示二进制文件信息)
|
||||
* @type {string[]}
|
||||
*/
|
||||
export const KNOWN_BINARY_TYPES = [
|
||||
// 可执行文件
|
||||
'exe', 'dll', 'so', 'bin',
|
||||
// 压缩文件
|
||||
'zip', 'rar', '7z', 'tar', 'gz', 'bz2', 'xz', 'iso', 'img', 'dmg',
|
||||
// Office PowerPoint(暂不支持预览)
|
||||
'ppt', 'pptx',
|
||||
// 其他二进制
|
||||
'pdb', 'idb', 'lib', 'obj', 'o', 'a'
|
||||
]
|
||||
|
||||
/**
|
||||
* 文本可编辑类型(包括代码、配置和文本文件)
|
||||
* @type {string[]}
|
||||
*/
|
||||
export const TEXT_EDITABLE_TYPES = [
|
||||
...FILE_EXTENSIONS.CODE,
|
||||
...FILE_EXTENSIONS.CONFIG,
|
||||
...FILE_EXTENSIONS.TEXT
|
||||
]
|
||||
|
||||
/**
|
||||
* 判断是否为图片文件
|
||||
* @param {string} path - 文件路径或扩展名
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export const isImageFile = (path) => {
|
||||
const ext = getExt(path)
|
||||
return FILE_EXTENSIONS.IMAGE.includes(ext)
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断是否为视频文件
|
||||
* @param {string} path - 文件路径或扩展名
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export const isVideoFile = (path) => {
|
||||
const ext = getExt(path)
|
||||
return FILE_EXTENSIONS.VIDEO_BROWSER.includes(ext)
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断是否为音频文件
|
||||
* @param {string} path - 文件路径或扩展名
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export const isAudioFile = (path) => {
|
||||
const ext = getExt(path)
|
||||
return FILE_EXTENSIONS.AUDIO.includes(ext)
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断是否为 PDF 文件
|
||||
* @param {string} path - 文件路径或扩展名
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export const isPdfFile = (path) => {
|
||||
const ext = getExt(path)
|
||||
return ext === 'pdf'
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断是否为 HTML 文件
|
||||
* @param {string} path - 文件路径或扩展名
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export const isHtmlFile = (path) => {
|
||||
const ext = getExt(path)
|
||||
return ['html', 'htm'].includes(ext)
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断是否为 Markdown 文件
|
||||
* @param {string} path - 文件路径或扩展名
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export const isMarkdownFile = (path) => {
|
||||
const ext = getExt(path)
|
||||
return ['md', 'markdown'].includes(ext)
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断文件是否支持预览模式
|
||||
* @param {string} path - 文件路径
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export const isPreviewable = (path) => {
|
||||
const ext = getExt(path)
|
||||
return PREVIEWABLE_TYPES.includes(ext)
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断文件是否为已知二进制类型
|
||||
* @param {string} path - 文件路径
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export const isKnownBinary = (path) => {
|
||||
const ext = getExt(path)
|
||||
return KNOWN_BINARY_TYPES.includes(ext)
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断文件是否可文本编辑
|
||||
* @param {string} path - 文件路径
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export const isTextEditable = (path) => {
|
||||
const ext = getExt(path)
|
||||
return TEXT_EDITABLE_TYPES.includes(ext)
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断是否为配置文件
|
||||
* @param {string} path - 文件路径
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export const isConfigFile = (path) => {
|
||||
const ext = getExt(path)
|
||||
return FILE_EXTENSIONS.CONFIG.includes(ext)
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取文件类型分类
|
||||
* @param {string} path - 文件路径
|
||||
* @returns {string} 类型分类:'image' | 'video' | 'audio' | 'pdf' | 'html' | 'markdown' | 'text' | 'binary' | 'unknown'
|
||||
*/
|
||||
export const getFileCategory = (path) => {
|
||||
if (isImageFile(path)) return 'image'
|
||||
if (isVideoFile(path)) return 'video'
|
||||
if (isAudioFile(path)) return 'audio'
|
||||
if (isPdfFile(path)) return 'pdf'
|
||||
if (isHtmlFile(path)) return 'html'
|
||||
if (isMarkdownFile(path)) return 'markdown'
|
||||
if (isTextEditable(path)) return 'text'
|
||||
if (isKnownBinary(path)) return 'binary'
|
||||
return 'unknown'
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断是否为 Excel 文件
|
||||
* @param {string} path - 文件路径
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export const isExcelFile = (path) => {
|
||||
const ext = getExt(path)
|
||||
return ['xlsx', 'xls'].includes(ext)
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断是否为 Word 文件
|
||||
* @param {string} path - 文件路径
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export const isWordFile = (path) => {
|
||||
const ext = getExt(path)
|
||||
return ['docx', 'doc'].includes(ext)
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断是否为 Office 文件
|
||||
* @param {string} path - 文件路径
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export const isOfficeFile = (path) => {
|
||||
return isExcelFile(path) || isWordFile(path) || ['ppt', 'pptx'].includes(getExt(path))
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断是否为 CSV/TSV 文件
|
||||
* @param {string} path - 文件路径
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export const isCsvFile = (path) => {
|
||||
const ext = getExt(path)
|
||||
return ['csv', 'tsv'].includes(ext)
|
||||
}
|
||||
@@ -0,0 +1,272 @@
|
||||
/**
|
||||
* 文件工具函数集合
|
||||
*
|
||||
* @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
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
/**
|
||||
* 统一语言映射
|
||||
* 供 highlight.js(Markdown 预览)和 CodeMirror(代码编辑器)共用
|
||||
*/
|
||||
|
||||
/**
|
||||
* 文件扩展名/缩写 → 语言标识符
|
||||
* - hljs: 用于 markedExtensions.ts 的代码块高亮
|
||||
* - cm: 用于 codeMirrorLoader.js 的编辑器语言
|
||||
* 值为 false 表示该扩展名不对应任何编程语言
|
||||
*/
|
||||
const extensionToLanguage: Record<string, { hljs?: string; cm?: string }> = {
|
||||
// === JavaScript / TypeScript ===
|
||||
js: { hljs: 'javascript', cm: 'javascript' },
|
||||
jsx: { hljs: 'javascript', cm: 'javascript' },
|
||||
mjs: { hljs: 'javascript', cm: 'javascript' },
|
||||
cjs: { hljs: 'javascript', cm: 'javascript' },
|
||||
ts: { hljs: 'typescript', cm: 'typescript' },
|
||||
tsx: { hljs: 'typescript', cm: 'typescript' },
|
||||
cts: { hljs: 'typescript', cm: 'typescript' },
|
||||
mts: { hljs: 'typescript', cm: 'typescript' },
|
||||
|
||||
// === Web ===
|
||||
html: { hljs: 'xml', cm: 'html' },
|
||||
htm: { hljs: 'xml', cm: 'html' },
|
||||
css: { hljs: 'css', cm: 'css' },
|
||||
scss: { hljs: 'scss', cm: 'css' },
|
||||
sass: { hljs: 'scss', cm: 'css' },
|
||||
less: { hljs: 'less', cm: 'css' },
|
||||
vue: { hljs: 'xml', cm: 'html' },
|
||||
|
||||
// === 数据格式 ===
|
||||
json: { hljs: 'json', cm: 'json' },
|
||||
xml: { hljs: 'xml', cm: 'html' },
|
||||
yaml: { hljs: 'yaml', cm: 'yaml' },
|
||||
yml: { hljs: 'yaml', cm: 'yaml' },
|
||||
toml: { cm: 'text' },
|
||||
csv: { cm: 'text' },
|
||||
tsv: { cm: 'text' },
|
||||
|
||||
// === C / C++ / 系统编程 ===
|
||||
c: { hljs: 'c', cm: 'cpp' },
|
||||
cpp: { hljs: 'cpp', cm: 'cpp' },
|
||||
cc: { hljs: 'cpp', cm: 'cpp' },
|
||||
cxx: { hljs: 'cpp', cm: 'cpp' },
|
||||
h: { hljs: 'cpp', cm: 'cpp' },
|
||||
hpp: { hljs: 'cpp', cm: 'cpp' },
|
||||
hxx: { hljs: 'cpp', cm: 'cpp' },
|
||||
cs: { hljs: 'csharp', cm: 'text' },
|
||||
swift: { hljs: 'swift', cm: 'text' },
|
||||
kt: { hljs: 'kotlin', cm: 'text' },
|
||||
rs: { hljs: 'rust', cm: 'rust' },
|
||||
go: { hljs: 'go', cm: 'go' },
|
||||
java: { hljs: 'java', cm: 'java' },
|
||||
pch: { hljs: 'cpp', cm: 'cpp' },
|
||||
tcc: { hljs: 'cpp', cm: 'cpp' },
|
||||
|
||||
// === 脚本 ===
|
||||
py: { hljs: 'python', cm: 'python' },
|
||||
pyw: { hljs: 'python', cm: 'python' },
|
||||
rb: { hljs: 'ruby', cm: 'text' },
|
||||
php: { hljs: 'php', cm: 'php' },
|
||||
sh: { hljs: 'bash', cm: 'shell' },
|
||||
bash: { hljs: 'bash', cm: 'shell' },
|
||||
shell: { hljs: 'bash', cm: 'shell' },
|
||||
zsh: { hljs: 'bash', cm: 'shell' },
|
||||
ps1: { hljs: 'powershell', cm: 'powershell' },
|
||||
bat: { hljs: 'dos', cm: 'text' },
|
||||
ahk: { hljs: 'autohotkey', cm: 'text' },
|
||||
lua: { hljs: 'lua', cm: 'text' },
|
||||
r: { hljs: 'r', cm: 'text' },
|
||||
m: { hljs: 'objectivec', cm: 'text' },
|
||||
scala: { hljs: 'scala', cm: 'text' },
|
||||
dart: { hljs: 'dart', cm: 'dart' },
|
||||
|
||||
// === 数据库 / 标记 ===
|
||||
sql: { hljs: 'sql', cm: 'sql' },
|
||||
md: { hljs: 'markdown', cm: 'markdown' },
|
||||
markdown: { hljs: 'markdown', cm: 'markdown' },
|
||||
tex: { hljs: 'latex', cm: 'text' },
|
||||
rst: { hljs: 'plaintext', cm: 'text' },
|
||||
adoc: { hljs: 'plaintext', cm: 'text' },
|
||||
|
||||
// === 构建工具 / 配置 ===
|
||||
// CodeMirror 6 无内置 Dockerfile 支持,用 shell 模式近似(Dockerfile 本质是类 shell 指令)
|
||||
dockerfile: { hljs: 'dockerfile', cm: 'shell' },
|
||||
makefile: { hljs: 'makefile', cm: 'text' },
|
||||
mk: { hljs: 'makefile', cm: 'text' },
|
||||
cmake: { hljs: 'cmake', cm: 'text' },
|
||||
ini: { hljs: 'ini', cm: 'text' },
|
||||
cfg: { hljs: 'ini', cm: 'text' },
|
||||
conf: { hljs: 'ini', cm: 'text' },
|
||||
env: { cm: 'text' },
|
||||
props: { cm: 'text' },
|
||||
manifest: { cm: 'text' },
|
||||
lock: { cm: 'text' },
|
||||
ignore: { cm: 'text' },
|
||||
|
||||
// === 纯文本 ===
|
||||
txt: { cm: 'text' },
|
||||
text: { cm: 'text' },
|
||||
log: { cm: 'text' },
|
||||
msg: { cm: 'text' },
|
||||
}
|
||||
|
||||
// 从映射表中收集所有已知的 hljs 语言名
|
||||
const knownHljsLanguages = new Set(
|
||||
Object.values(extensionToLanguage)
|
||||
.map(v => v.hljs)
|
||||
.filter(Boolean) as string[]
|
||||
)
|
||||
|
||||
// highlight.js lib/common 内置的常用语言名(代码块标记直接使用)
|
||||
const commonLangNames = new Set([
|
||||
'javascript', 'typescript', 'python', 'java', 'go', 'rust', 'c', 'cpp',
|
||||
'csharp', 'php', 'ruby', 'swift', 'kotlin', 'scala', 'dart', 'lua',
|
||||
'r', 'bash', 'shell', 'powershell', 'dos', 'cmd', 'sql', 'yaml', 'json', 'xml',
|
||||
'markdown', 'css', 'scss', 'less', 'html', 'ini', 'makefile', 'dockerfile',
|
||||
'cmake', 'latex', 'plaintext', 'diff', 'graphql', 'nginx', 'perl',
|
||||
'objectivec', 'haskell', 'elixir', 'erlang', 'clojure', 'ocaml',
|
||||
'vbnet', 'wasm', 'fsharp', 'groovy', 'julia', 'matlab', 'zig'
|
||||
])
|
||||
|
||||
/**
|
||||
* 获取 hljs 语言标识(带别名解析)
|
||||
*/
|
||||
export function getHljsLanguage(langOrExt: string): string {
|
||||
if (!langOrExt) return 'plaintext'
|
||||
const lower = langOrExt.toLowerCase()
|
||||
|
||||
// 1. 直接是已知语言名(代码块 ```python / ```typescript 等)
|
||||
if (commonLangNames.has(lower)) return lower
|
||||
|
||||
// 2. 扩展名映射(.ts → typescript, .py → python 等)
|
||||
const mapped = extensionToLanguage[lower]?.hljs
|
||||
if (mapped) return mapped
|
||||
|
||||
// 3. 已在 known 集合中的映射值
|
||||
if (knownHljsLanguages.has(lower)) return lower
|
||||
|
||||
return 'plaintext'
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取 CodeMirror 语言标识
|
||||
*/
|
||||
export function getCmLanguage(extension: string): string {
|
||||
if (!extension) return 'text'
|
||||
const lower = extension.toLowerCase()
|
||||
return extensionToLanguage[lower]?.cm || 'text'
|
||||
}
|
||||
@@ -0,0 +1,251 @@
|
||||
import { marked } from 'marked'
|
||||
import hljs from 'highlight.js'
|
||||
import 'highlight.js/lib/common'
|
||||
// 按需导入 common 包不包含的语言
|
||||
import 'highlight.js/lib/languages/powershell'
|
||||
import 'highlight.js/lib/languages/dos'
|
||||
import 'highlight.js/lib/languages/autohotkey'
|
||||
import 'highlight.js/lib/languages/latex'
|
||||
import 'highlight.js/lib/languages/dockerfile'
|
||||
import 'highlight.js/lib/languages/cmake'
|
||||
import 'highlight.js/lib/languages/scala'
|
||||
import 'highlight.js/lib/languages/dart'
|
||||
import { getHljsLanguage } from './languageMap'
|
||||
|
||||
let mermaidInstance: typeof import('mermaid').default | null = null
|
||||
let mermaidTheme: string | null = null
|
||||
|
||||
// 检测当前是否为暗色主题
|
||||
function isDarkTheme(): boolean {
|
||||
if (typeof document === 'undefined') return false
|
||||
return document.body.getAttribute('arco-theme')?.includes('dark') ?? false
|
||||
}
|
||||
|
||||
async function loadMermaid() {
|
||||
const currentTheme = isDarkTheme() ? 'dark' : 'default'
|
||||
|
||||
if (mermaidInstance && mermaidTheme === currentTheme) {
|
||||
return mermaidInstance
|
||||
}
|
||||
|
||||
if (!mermaidInstance) {
|
||||
const m = await import('mermaid')
|
||||
mermaidInstance = m.default
|
||||
}
|
||||
mermaidInstance.initialize({
|
||||
startOnLoad: false,
|
||||
theme: currentTheme,
|
||||
securityLevel: 'strict',
|
||||
themeVariables: Object.assign({
|
||||
primaryColor: '#165DFF',
|
||||
primaryTextColor: '#ffffff',
|
||||
primaryBorderColor: '#4080FF'
|
||||
}, currentTheme === 'dark' ? {
|
||||
lineColor: '#4E5969',
|
||||
secondaryColor: '#0E42D2',
|
||||
tertiaryColor: '#0FC6C2',
|
||||
mainBkg: '#17171A',
|
||||
nodeBorder: '#165DFF',
|
||||
clusterBkg: '#232324',
|
||||
titleColor: '#FFFFFF',
|
||||
edgeLabelBackground: '#232324'
|
||||
} : {
|
||||
lineColor: '#86909C',
|
||||
secondaryColor: '#E8F3FF',
|
||||
tertiaryColor: '#722ED1',
|
||||
mainBkg: '#F2F3F5',
|
||||
nodeBorder: '#165DFF',
|
||||
clusterBkg: '#F7F8FA',
|
||||
titleColor: '#1D2129',
|
||||
edgeLabelBackground: '#F2F3F5'
|
||||
})
|
||||
})
|
||||
mermaidTheme = currentTheme
|
||||
return mermaidInstance
|
||||
}
|
||||
|
||||
const renderer = new marked.Renderer()
|
||||
|
||||
renderer.code = function(token: any) {
|
||||
if (token.lang === 'mermaid') {
|
||||
return `<pre class="mermaid">${token.text}</pre>`
|
||||
}
|
||||
|
||||
const lang = getHljsLanguage(token.lang)
|
||||
|
||||
let highlighted: string
|
||||
try {
|
||||
highlighted = hljs.highlight(token.text, { language: lang }).value
|
||||
} catch {
|
||||
highlighted = token.text.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
||||
}
|
||||
return `<pre><code class="hljs language-${lang}" data-theme="auto">${highlighted}</code></pre>`
|
||||
}
|
||||
|
||||
renderer.heading = function(token: any) {
|
||||
const raw = token.raw || ''
|
||||
const depth = token.depth || 1
|
||||
const text = token.text || ''
|
||||
|
||||
const id = raw
|
||||
.toLowerCase()
|
||||
.replace(/[^\u4e00-\u9fa5a-z0-9\s-]/g, '')
|
||||
.trim()
|
||||
.replace(/\s+/g, '-')
|
||||
.replace(/-+/g, '-')
|
||||
.replace(/^-+|-+$/g, '') || `heading-${Math.random().toString(36).slice(2, 11)}`
|
||||
|
||||
return `<h${depth} id="${id}" class="heading">
|
||||
${text}<a href="#${id}" class="heading-anchor" aria-hidden="true" title="跳转到此标题">#</a>
|
||||
</h${depth}>`
|
||||
}
|
||||
|
||||
// ========== 图片相对路径转换支持 ==========
|
||||
// 当前 Markdown 文件所在目录(由调用方在渲染前设置)
|
||||
let _currentFileDir: string = ''
|
||||
// 文件服务器 Base URL(由调用方在渲染前设置)
|
||||
let _fileServerBase: string = 'http://localhost:8073/localfs'
|
||||
|
||||
/**
|
||||
* 设置当前 Markdown 文件所在目录(用于图片相对路径→文件服务器 URL 转换)
|
||||
* @param dir 文件所在目录的绝对路径,如 "D:/docs" 或 "/"(根目录)
|
||||
*/
|
||||
export function setCurrentFileDir(dir: string): void {
|
||||
_currentFileDir = dir
|
||||
}
|
||||
|
||||
/** 获取当前设置的文件目录 */
|
||||
export function getCurrentFileDir(): string {
|
||||
return _currentFileDir
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置文件服务器 Base URL(用于图片相对路径转换)
|
||||
* @param base 完整的 base URL 前缀,如 "http://localhost:8073/localfs" 或 "https://host:port/api/v1/proxy/localfs"
|
||||
*/
|
||||
export function setFileServerBase(base: string): void {
|
||||
_fileServerBase = base
|
||||
}
|
||||
|
||||
/**
|
||||
* 将相对路径图片 src 解析为文件服务器 URL
|
||||
* - 绝对路径(Windows: D:/...、Unix: /usr/...)、网络URL、data URI → 不转换
|
||||
* - 相对路径 → 基于当前文件目录解析为绝对路径,再编码为文件服务器 URL
|
||||
*/
|
||||
function resolveImageUrl(src: string, fileServerBase: string): string {
|
||||
if (!src) return src
|
||||
// 不转换:绝对路径(Windows 盘符)、网络协议、锚点、data URI
|
||||
if (/^(?:[a-zA-Z]:[/\\]|\/(?:[^/]|$)|https?:|ftp:|data:|#)/i.test(src)) return src
|
||||
|
||||
// 解析相对路径(处理 ../ 和 ./)
|
||||
const dir = _currentFileDir || '/'
|
||||
const sep = dir.includes('\\') ? '\\' : '/'
|
||||
let resolved = normalizeRelativePath(dir, src, sep)
|
||||
|
||||
// 编码路径(保留 / 分隔符)
|
||||
const encoded = encodeURIComponent(resolved).replace(/%2F/gi, '/').replace(/%5C/gi, '\\')
|
||||
return `${fileServerBase}/${encoded}`
|
||||
}
|
||||
|
||||
/**
|
||||
* 规范化相对路径,处理 .. 和 . 段
|
||||
*/
|
||||
function normalizeRelativePath(base: string, relative: string, sep: string): string {
|
||||
// 确保基础路径不以分隔符结尾
|
||||
let baseNormalized = base.replace(/[\\/]+$/, '')
|
||||
if (!baseNormalized) baseNormalized = sep === '/' ? '/' : 'C:\\'
|
||||
|
||||
const baseParts = baseNormalized.split(sep).filter(Boolean)
|
||||
const relParts = relative.split(/[\\/]/).filter(Boolean)
|
||||
|
||||
for (const part of relParts) {
|
||||
if (part === '..') {
|
||||
baseParts.pop() // 向上一级
|
||||
} else if (part !== '.') {
|
||||
baseParts.push(part)
|
||||
}
|
||||
}
|
||||
|
||||
// 重建路径:Windows 绝对路径保留盘符前缀
|
||||
if (/^[a-zA-Z]:$/i.test(baseNormalized.split(sep)[0] || '')) {
|
||||
return baseParts.join(sep)
|
||||
}
|
||||
// Unix 风格:以 / 开头
|
||||
return sep + baseParts.join(sep)
|
||||
}
|
||||
|
||||
// 判断是否为本地文件链接(相对路径或本地绝对路径)
|
||||
const isLocalFileLink = (href: string): boolean => {
|
||||
if (!href) return false
|
||||
if (/^(https?|ftp|mailto|tel|data):/i.test(href)) return false
|
||||
if (href.startsWith('#')) return false
|
||||
return true
|
||||
}
|
||||
|
||||
// 自定义图片渲染器 - 转换相对路径为文件服务器 URL
|
||||
renderer.image = function(token: any) {
|
||||
const src = token.href || ''
|
||||
const title = token.title || ''
|
||||
const alt = token.text || ''
|
||||
const titleAttr = title ? ` title="${title}"` : ''
|
||||
|
||||
// 判断是否需要转换(仅处理相对路径,且当前目录已设置)
|
||||
if (_currentFileDir && !/^(?:[a-zA-Z]:[/\\]|\/(?:[^/]|$)|https?:|ftp:|data:|#)/i.test(src)) {
|
||||
const resolvedSrc = resolveImageUrl(src, _fileServerBase)
|
||||
return `<img src="${resolvedSrc}" alt="${alt}"${titleAttr}>`
|
||||
}
|
||||
|
||||
// 默认渲染(绝对路径 / 网络 URL / data URI / 未设置目录时原样输出)
|
||||
return `<img src="${src}" alt="${alt}"${titleAttr}>`
|
||||
}
|
||||
|
||||
// 自定义链接渲染器 - 支持本地文件链接
|
||||
renderer.link = function(token: any) {
|
||||
const href = token.href || ''
|
||||
const text = this.parser.parseInline(token.tokens) || token.text || ''
|
||||
const title = token.title || ''
|
||||
const titleAttr = title ? ` title="${title}"` : ''
|
||||
|
||||
if (href.startsWith('#')) {
|
||||
return `<a href="${href}${titleAttr}">${text}</a>`
|
||||
}
|
||||
|
||||
if (isLocalFileLink(href)) {
|
||||
return `<a href="javascript:void(0)" data-local-link="${href}" class="local-file-link"${titleAttr}>${text}</a>`
|
||||
}
|
||||
|
||||
return `<a href="${href}" target="_blank" rel="noopener noreferrer"${titleAttr}>${text}</a>`
|
||||
}
|
||||
|
||||
marked.use({ renderer, breaks: true, gfm: true, async: false })
|
||||
|
||||
export { marked }
|
||||
|
||||
export async function renderMermaidDiagrams() {
|
||||
const mermaid = await loadMermaid()
|
||||
if (mermaid) {
|
||||
// 渲染前保存原始源码(textContent 在 SVG 渲染后会变成 CSS 垃圾)
|
||||
document.querySelectorAll('.mermaid:not([data-mermaid-src])').forEach(pre => {
|
||||
;(pre as HTMLElement).setAttribute('data-mermaid-src', pre.textContent || '')
|
||||
})
|
||||
await mermaid.run()
|
||||
}
|
||||
}
|
||||
|
||||
/** 清除已渲染内容并重新渲染(用于主题切换后刷新) */
|
||||
export async function rerenderMermaidDiagrams(container?: HTMLElement | null) {
|
||||
// 强制重新加载(清除缓存,让下次 loadMermaid 重新初始化新主题)
|
||||
mermaidInstance = null
|
||||
mermaidTheme = null
|
||||
|
||||
const target = container || document
|
||||
target.querySelectorAll('.mermaid').forEach(pre => {
|
||||
const el = pre as HTMLElement
|
||||
if (el.getAttribute('data-processed')) {
|
||||
// 从保存的原始源码恢复,而非 textContent(SVG 的 textContent 是 CSS 垃圾)
|
||||
el.innerHTML = el.getAttribute('data-mermaid-src') || ''
|
||||
el.removeAttribute('data-processed')
|
||||
}
|
||||
})
|
||||
await renderMermaidDiagrams()
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
export interface ResizeOptions {
|
||||
/** 拖拽方向,默认垂直 */
|
||||
direction?: 'horizontal' | 'vertical'
|
||||
/** 最小百分比,默认 20 */
|
||||
minPercent?: number
|
||||
/** 最大百分比,默认 80 */
|
||||
maxPercent?: number
|
||||
/** 最小像素值,默认 150 */
|
||||
minPixels?: number
|
||||
/** 输出模式:percent(默认)或 pixels。pixels 模式下 onResize/onResizeEnd 接收像素值而非百分比 */
|
||||
outputMode?: 'percent' | 'pixels'
|
||||
/** 拖拽中回调(值类型由 outputMode 决定) */
|
||||
onResize?: (value: number) => void
|
||||
/** 拖拽结束回调(用于持久化等) */
|
||||
onResizeEnd?: (value: number) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* 通用分割拖拽处理器工厂
|
||||
*
|
||||
* 用法:
|
||||
* ```ts
|
||||
* const handleResize = createResizeHandler(
|
||||
* () => containerRef.value, // getter,mousedown 时才读取
|
||||
* () => percent.value,
|
||||
* { direction: 'horizontal', onResize: (p) => { percent.value = p } }
|
||||
* )
|
||||
* // template: <div class="resizer" @mousedown="handleResize"></div>
|
||||
* ```
|
||||
*/
|
||||
export function createResizeHandler(
|
||||
getContainer: () => HTMLElement | null,
|
||||
getInitialPercentage: () => number,
|
||||
options: ResizeOptions = {}
|
||||
): (e: MouseEvent) => void {
|
||||
const {
|
||||
direction = 'vertical',
|
||||
minPercent = 20,
|
||||
maxPercent = 80,
|
||||
minPixels = 150,
|
||||
outputMode = 'percent',
|
||||
onResize,
|
||||
onResizeEnd,
|
||||
} = options
|
||||
|
||||
const isHorizontal = direction === 'horizontal'
|
||||
const usePixels = outputMode === 'pixels'
|
||||
|
||||
return (e: MouseEvent) => {
|
||||
e.preventDefault()
|
||||
|
||||
const container = getContainer()
|
||||
if (!container) return
|
||||
|
||||
// 初始值:pixels 模式下从 getter 获取像素值,percent 模式下获取百分比
|
||||
let startValue = getInitialPercentage()
|
||||
if (usePixels) {
|
||||
// pixels 模式:将初始像素值转换为百分比用于拖拽计算
|
||||
const rect = container.getBoundingClientRect()
|
||||
const containerSize = isHorizontal ? rect.width : rect.height
|
||||
if (containerSize > 0) {
|
||||
startValue = (startValue / containerSize) * 100
|
||||
}
|
||||
}
|
||||
|
||||
const handleMouseMove = (moveEvent: MouseEvent) => {
|
||||
const currentRect = container.getBoundingClientRect()
|
||||
let rawValue: number
|
||||
|
||||
if (isHorizontal) {
|
||||
rawValue = ((moveEvent.clientX - currentRect.left) / currentRect.width) * 100
|
||||
} else {
|
||||
rawValue = ((moveEvent.clientY - currentRect.top) / currentRect.height) * 100
|
||||
}
|
||||
|
||||
const minPercentFromPixels = (minPixels / (isHorizontal ? currentRect.width : currentRect.height)) * 100
|
||||
const clamped = Math.max(
|
||||
Math.max(minPercent, minPercentFromPixels),
|
||||
Math.min(maxPercent, rawValue)
|
||||
)
|
||||
|
||||
if (usePixels) {
|
||||
// 转回像素值传给回调
|
||||
const containerSize = isHorizontal ? currentRect.width : currentRect.height
|
||||
onResize?.(Math.round((clamped / 100) * containerSize))
|
||||
} else {
|
||||
onResize?.(clamped)
|
||||
}
|
||||
}
|
||||
|
||||
const handleMouseUp = () => {
|
||||
document.removeEventListener('mousemove', handleMouseMove)
|
||||
document.removeEventListener('mouseup', handleMouseUp)
|
||||
document.body.classList.remove('resizing')
|
||||
if (usePixels) {
|
||||
// pixels 模式:传回当前像素值(onResize 已更新,读 getter 获取最新值)
|
||||
onResizeEnd?.(getInitialPercentage())
|
||||
} else {
|
||||
onResizeEnd?.(getInitialPercentage())
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('mousemove', handleMouseMove)
|
||||
document.addEventListener('mouseup', handleMouseUp)
|
||||
document.body.classList.add('resizing')
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user