重构: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:
lxy
2026-05-01 11:03:53 +08:00
co-authored by Claude Opus 4.7
parent 44847e0d40
commit f54bf1c28d
185 changed files with 7768 additions and 914 deletions
+114
View File
@@ -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)
}
+16
View File
@@ -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 动态导入,避免全量打包
+394
View File
@@ -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: /[<>:"/\\|?*]/,
}
+81
View File
@@ -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()
}
+331
View File
@@ -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) }
}
}
+205
View File
@@ -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)
}
+272
View File
@@ -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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;')
}
/**
* 轻量 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
})
}
+151
View File
@@ -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'
}
+251
View File
@@ -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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
}
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()
}
+107
View File
@@ -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')
}
}