优化:文件服务器安全重构+编辑器增强+搜索排序+更新面板Markdown渲染
- 路径校验提取validateFilePath+sentinel error替代字符串匹配 - requireUpdateAPI收敛7处重复nil检查 - 端口18765统一为8073,消除分散魔法数字 - CodeMirror添加搜索功能+滚动位置LRU缓存恢复 - 文件列表新增列排序+搜索过滤 - Toolbar重排:快捷访问内嵌+搜索框集成+历史改图标 - 重命名零闪烁:updateFilePath草稿迁移 - changelog用marked渲染+sanitizeHtml防XSS - MigrateTabConfig扩展map驱动覆盖openclaw-manager→version迁移 Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
72
app.go
72
app.go
@@ -3,7 +3,6 @@ package main
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
stdruntime "runtime"
|
||||
@@ -30,7 +29,6 @@ type App struct {
|
||||
updateAPI *api.UpdateAPI
|
||||
configAPI *api.ConfigAPI
|
||||
pdfAPI *api.PdfAPI
|
||||
fileServer *http.Server
|
||||
filesystem *filesystem.FileSystemService
|
||||
isAlwaysOnTop bool
|
||||
}
|
||||
@@ -194,7 +192,7 @@ func (a *App) startFileServer() {
|
||||
return
|
||||
}
|
||||
|
||||
fmt.Println("[文件服务器] 启动在 http://localhost:18765")
|
||||
fmt.Println("[文件服务器] 启动在 http://localhost:8073")
|
||||
}
|
||||
|
||||
// Shutdown 应用关闭时调用
|
||||
@@ -415,7 +413,7 @@ func (a *App) GetCommonPaths() (map[string]string, error) {
|
||||
// Windows: 从注册表读取特殊文件夹真实路径(用户可能已修改位置)
|
||||
folderGUIDs := map[string]string{
|
||||
"desktop": "{B4BFCC3A-DB2C-424C-B029-7FE99A87C641}",
|
||||
"documents": "{D20B4C7F-5EA7-40D4B25E-039F6F1FCC8A}",
|
||||
"documents": "{D20B4C7F-5EA7-424C-B25E-039F6F1FCC8A}",
|
||||
"downloads": "{374DE290-123F-4565-9164-39C4925E467B}",
|
||||
}
|
||||
for name, guid := range folderGUIDs {
|
||||
@@ -603,68 +601,84 @@ func (a *App) ListSqlTabs() ([]map[string]interface{}, error) {
|
||||
|
||||
// ========== 版本更新管理接口 ==========
|
||||
|
||||
// CheckUpdate 检查更新(UpdateAPI 可能尚未初始化完成)
|
||||
func (a *App) CheckUpdate() (map[string]interface{}, error) {
|
||||
// requireUpdateAPI 检查 updateAPI 是否已初始化,未初始化返回统一错误
|
||||
func (a *App) requireUpdateAPI() (*api.UpdateAPI, error) {
|
||||
if a.updateAPI == nil {
|
||||
return nil, fmt.Errorf("更新功能正在初始化中")
|
||||
}
|
||||
return a.updateAPI.CheckUpdate()
|
||||
return a.updateAPI, nil
|
||||
}
|
||||
|
||||
// CheckUpdate 检查更新(UpdateAPI 可能尚未初始化完成)
|
||||
func (a *App) CheckUpdate() (map[string]interface{}, error) {
|
||||
api, err := a.requireUpdateAPI()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return api.CheckUpdate()
|
||||
}
|
||||
|
||||
// GetCurrentVersion 获取当前版本号
|
||||
func (a *App) GetCurrentVersion() (map[string]interface{}, error) {
|
||||
if a.updateAPI == nil {
|
||||
return nil, fmt.Errorf("更新功能正在初始化中")
|
||||
api, err := a.requireUpdateAPI()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return a.updateAPI.GetCurrentVersion()
|
||||
return api.GetCurrentVersion()
|
||||
}
|
||||
|
||||
// GetUpdateConfig 获取更新配置
|
||||
func (a *App) GetUpdateConfig() (map[string]interface{}, error) {
|
||||
if a.updateAPI == nil {
|
||||
return nil, fmt.Errorf("更新功能正在初始化中")
|
||||
api, err := a.requireUpdateAPI()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return a.updateAPI.GetUpdateConfig()
|
||||
return api.GetUpdateConfig()
|
||||
}
|
||||
|
||||
// SetUpdateConfig 设置更新配置
|
||||
func (a *App) SetUpdateConfig(autoCheckEnabled bool, checkIntervalMinutes int, checkURL string) (map[string]interface{}, error) {
|
||||
if a.updateAPI == nil {
|
||||
return nil, fmt.Errorf("更新功能正在初始化中")
|
||||
api, err := a.requireUpdateAPI()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return a.updateAPI.SetUpdateConfig(autoCheckEnabled, checkIntervalMinutes, checkURL)
|
||||
return api.SetUpdateConfig(autoCheckEnabled, checkIntervalMinutes, checkURL)
|
||||
}
|
||||
|
||||
// DownloadUpdate 下载更新包
|
||||
func (a *App) DownloadUpdate(downloadURL string) (map[string]interface{}, error) {
|
||||
if a.updateAPI == nil {
|
||||
return nil, fmt.Errorf("更新功能正在初始化中")
|
||||
api, err := a.requireUpdateAPI()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return a.updateAPI.DownloadUpdate(downloadURL)
|
||||
return api.DownloadUpdate(downloadURL)
|
||||
}
|
||||
|
||||
// InstallUpdate 安装更新包
|
||||
func (a *App) InstallUpdate(installerPath string, autoRestart bool) (map[string]interface{}, error) {
|
||||
if a.updateAPI == nil {
|
||||
return nil, fmt.Errorf("更新功能正在初始化中")
|
||||
api, err := a.requireUpdateAPI()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return a.updateAPI.InstallUpdate(installerPath, autoRestart)
|
||||
return api.InstallUpdate(installerPath, autoRestart)
|
||||
}
|
||||
|
||||
// InstallUpdateWithHash 安装更新包(带哈希验证)
|
||||
func (a *App) InstallUpdateWithHash(installerPath string, autoRestart bool, expectedHash string, hashType string) (map[string]interface{}, error) {
|
||||
if a.updateAPI == nil {
|
||||
return nil, fmt.Errorf("更新功能正在初始化中")
|
||||
api, err := a.requireUpdateAPI()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return a.updateAPI.InstallUpdateWithHash(installerPath, autoRestart, expectedHash, hashType)
|
||||
return api.InstallUpdateWithHash(installerPath, autoRestart, expectedHash, hashType)
|
||||
}
|
||||
|
||||
// VerifyUpdateFile 验证更新文件哈希值
|
||||
func (a *App) VerifyUpdateFile(filePath string, expectedHash string, hashType string) (map[string]interface{}, error) {
|
||||
if a.updateAPI == nil {
|
||||
return nil, fmt.Errorf("更新功能正在初始化中")
|
||||
api, err := a.requireUpdateAPI()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return a.updateAPI.VerifyUpdateFile(filePath, expectedHash, hashType)
|
||||
return api.VerifyUpdateFile(filePath, expectedHash, hashType)
|
||||
}
|
||||
|
||||
// startAutoUpdateCheck 启动自动更新检查
|
||||
@@ -753,7 +767,7 @@ func (a *App) GetAuditLogs(limit int) ([]map[string]interface{}, error) {
|
||||
|
||||
// GetFileServerURL 获取本地文件服务器的URL
|
||||
func (a *App) GetFileServerURL() string {
|
||||
return "http://localhost:18765"
|
||||
return "http://localhost:8073"
|
||||
}
|
||||
|
||||
// DetectFileTypeByContent 通过文件内容检测文件类型(用于小文件)
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 130 KiB After Width: | Height: | Size: 35 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 21 KiB After Width: | Height: | Size: 53 KiB |
@@ -136,40 +136,66 @@ func (api *ConfigAPI) SaveAppConfig(req SaveAppConfigRequest) (map[string]interf
|
||||
}, nil
|
||||
}
|
||||
|
||||
// MigrateTabConfig 迁移旧配置
|
||||
// MigrateTabConfig 迁移旧配置(device 移除 + openclaw-manager 重命名)
|
||||
func (api *ConfigAPI) MigrateTabConfig() error {
|
||||
config, _ := api.configService.GetTabConfig()
|
||||
if config == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
// 检查是否包含 device
|
||||
hasDevice := false
|
||||
needMigrate := false
|
||||
|
||||
// 检查是否包含需要迁移的旧 key
|
||||
for _, tab := range config.AvailableTabs {
|
||||
if tab.Key == "device" {
|
||||
hasDevice = true
|
||||
if tab.Key == "device" || tab.Key == "openclaw-manager" {
|
||||
needMigrate = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !hasDevice {
|
||||
if !needMigrate {
|
||||
return nil
|
||||
}
|
||||
|
||||
// 过滤掉 device
|
||||
// 映射:旧 key → 新 key(不需要的移除)
|
||||
keyMap := map[string]string{
|
||||
"openclaw-manager": "version",
|
||||
// "device": "" // 直接过滤
|
||||
}
|
||||
|
||||
newTabs := make([]service.TabDefinition, 0, len(config.AvailableTabs))
|
||||
newVisible := make([]string, 0, len(config.VisibleTabs))
|
||||
seenKeys := map[string]bool{}
|
||||
|
||||
for _, tab := range config.AvailableTabs {
|
||||
if tab.Key != "device" {
|
||||
newKey, shouldRename := keyMap[tab.Key]
|
||||
if shouldRename {
|
||||
if newKey == "" {
|
||||
continue // 移除(如 device)
|
||||
}
|
||||
if seenKeys[newKey] {
|
||||
continue // 避免重复
|
||||
}
|
||||
seenKeys[newKey] = true
|
||||
newTabs = append(newTabs, service.TabDefinition{Key: newKey, Title: tab.Title, Enabled: tab.Enabled})
|
||||
} else {
|
||||
newTabs = append(newTabs, tab)
|
||||
}
|
||||
}
|
||||
for _, key := range config.VisibleTabs {
|
||||
if key != "device" {
|
||||
if newKey, ok := keyMap[key]; ok {
|
||||
if newKey != "" && !seenKeys[newKey] {
|
||||
newVisible = append(newVisible, newKey)
|
||||
}
|
||||
// newKey == "" 时跳过(如 device)
|
||||
} else {
|
||||
newVisible = append(newVisible, key)
|
||||
}
|
||||
}
|
||||
|
||||
defaultTab := config.DefaultTab
|
||||
if newKey, ok := keyMap[defaultTab]; ok && newKey != "" {
|
||||
defaultTab = newKey
|
||||
}
|
||||
if defaultTab == "device" {
|
||||
defaultTab = "file-system"
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package filesystem
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
@@ -48,6 +49,35 @@ var (
|
||||
// HTML 属性正则缓存(避免 replaceHtmlTagAttribute 中重复编译)
|
||||
var attrRegexCache sync.Map // map[string]*regexp.Regexp
|
||||
|
||||
// 路径校验 sentinel error(用 errors.Is 匹配,不依赖字符串)
|
||||
var (
|
||||
ErrPathInvalidEncoding = fmt.Errorf("invalid path encoding")
|
||||
ErrPathTraversal = fmt.Errorf("path traversal detected")
|
||||
ErrPathUnsafe = fmt.Errorf("unsafe path")
|
||||
)
|
||||
|
||||
// validateFilePath 校验文件路径安全性(URL解码 + 路径遍历检测 + 安全检查)
|
||||
// 返回清理后的绝对路径,或 sentinel error
|
||||
func validateFilePath(rawPath string, logPrefix string) (string, error) {
|
||||
decodedPath, err := url.QueryUnescape(rawPath)
|
||||
if err != nil {
|
||||
return "", ErrPathInvalidEncoding
|
||||
}
|
||||
|
||||
if strings.Contains(decodedPath, "..") {
|
||||
return "", ErrPathTraversal
|
||||
}
|
||||
|
||||
filePath := strings.ReplaceAll(decodedPath, "/", "\\")
|
||||
filePath = filepath.Clean(filePath)
|
||||
|
||||
if !isSafePath(filePath) {
|
||||
return "", ErrPathUnsafe
|
||||
}
|
||||
|
||||
return filePath, nil
|
||||
}
|
||||
|
||||
// LocalFileServer 本地文件服务器(独立的 HTTP 服务器)
|
||||
type LocalFileServer struct {
|
||||
server *http.Server
|
||||
@@ -75,7 +105,7 @@ func StartLocalFileServer() (string, error) {
|
||||
|
||||
// 创建服务器(固定端口)
|
||||
server := &http.Server{
|
||||
Addr: "localhost:18765",
|
||||
Addr: "localhost:8073",
|
||||
Handler: mux,
|
||||
}
|
||||
|
||||
@@ -90,7 +120,7 @@ func StartLocalFileServer() (string, error) {
|
||||
|
||||
localFileServer = &LocalFileServer{
|
||||
server: server,
|
||||
addr: "localhost:18765",
|
||||
addr: "localhost:8073",
|
||||
}
|
||||
|
||||
log.Printf("[LocalFileServer] 已启动,监听: %s", localFileServer.addr)
|
||||
@@ -125,7 +155,6 @@ func handleLocalFileRequest(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
// 从 URL 路径获取文件路径(移除 /localfs/ 前缀)
|
||||
pathPart := strings.TrimPrefix(r.URL.Path, "/localfs/")
|
||||
log.Printf("[LocalFileHandler] TrimPrefix 后: %s", pathPart)
|
||||
|
||||
if pathPart == "" || pathPart == r.URL.Path {
|
||||
log.Printf("[LocalFileHandler] 路径前缀无效")
|
||||
@@ -133,33 +162,23 @@ func handleLocalFileRequest(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
// 🔒 修复:先进行URL解码,防止路径遍历攻击
|
||||
decodedPath, err := url.QueryUnescape(pathPart)
|
||||
// 校验路径安全性(URL解码 + 路径遍历检测 + 安全检查)
|
||||
filePath, err := validateFilePath(pathPart, "[LocalFileHandler]")
|
||||
if err != nil {
|
||||
log.Printf("[LocalFileHandler] URL解码失败: %v", err)
|
||||
log.Printf("[LocalFileHandler] 路径校验失败: %v (%s)", err, pathPart)
|
||||
switch {
|
||||
case errors.Is(err, ErrPathInvalidEncoding):
|
||||
http.Error(w, "Invalid path encoding", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
log.Printf("[LocalFileHandler] URL解码后: %s", decodedPath)
|
||||
|
||||
// 🔒 修复:在路径转换前检查是否包含危险字符
|
||||
if strings.Contains(decodedPath, "..") {
|
||||
log.Printf("[LocalFileHandler] 检测到路径遍历尝试")
|
||||
case errors.Is(err, ErrPathTraversal):
|
||||
http.Error(w, "Path traversal detected", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
// 路径转换(统一使用反斜杠)
|
||||
filePath := strings.ReplaceAll(decodedPath, "/", "\\")
|
||||
filePath = filepath.Clean(filePath)
|
||||
log.Printf("[LocalFileHandler] 最终路径: %s", filePath)
|
||||
|
||||
// 安全检查
|
||||
if !isSafePath(filePath) {
|
||||
log.Printf("[LocalFileHandler] 路径未通过安全检查: %s", filePath)
|
||||
case errors.Is(err, ErrPathUnsafe):
|
||||
http.Error(w, "Unsafe path", http.StatusForbidden)
|
||||
default:
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
}
|
||||
return
|
||||
}
|
||||
log.Printf("[LocalFileHandler] 最终路径: %s", filePath)
|
||||
|
||||
// 🔒 文件类型白名单检查
|
||||
ext := strings.ToLower(filepath.Ext(filePath))
|
||||
@@ -459,33 +478,31 @@ func handleHtmlPreviewRequest(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
// 解析参数
|
||||
filePath := r.URL.Query().Get("path")
|
||||
var err error
|
||||
if filePath, err = url.QueryUnescape(filePath); err != nil {
|
||||
http.Error(w, "Invalid path encoding", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
rawPath := r.URL.Query().Get("path")
|
||||
theme := r.URL.Query().Get("theme")
|
||||
if theme == "" {
|
||||
theme = "light"
|
||||
}
|
||||
|
||||
log.Printf("[HtmlPreview] 收到请求: path=%s, theme=%s", filePath, theme)
|
||||
|
||||
// 安全检查
|
||||
if !isSafePath(filePath) {
|
||||
log.Printf("[HtmlPreview] 路径未通过安全检查: %s", filePath)
|
||||
http.Error(w, "Unsafe path", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
// 检查路径遍历攻击
|
||||
if strings.Contains(filePath, "..") {
|
||||
log.Printf("[HtmlPreview] 检测到路径遍历尝试: %s", filePath)
|
||||
// 校验路径安全性(URL解码 + 路径遍历检测 + 安全检查)
|
||||
filePath, err := validateFilePath(rawPath, "[HtmlPreview]")
|
||||
if err != nil {
|
||||
log.Printf("[HtmlPreview] 路径校验失败: %v (%s)", err, rawPath)
|
||||
switch {
|
||||
case errors.Is(err, ErrPathInvalidEncoding):
|
||||
http.Error(w, "Invalid path encoding", http.StatusBadRequest)
|
||||
case errors.Is(err, ErrPathTraversal):
|
||||
http.Error(w, "Path traversal detected", http.StatusForbidden)
|
||||
case errors.Is(err, ErrPathUnsafe):
|
||||
http.Error(w, "Unsafe path", http.StatusForbidden)
|
||||
default:
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("[HtmlPreview] 收到请求: path=%s, theme=%s", filePath, theme)
|
||||
|
||||
// 读取文件
|
||||
content, err := os.ReadFile(filePath)
|
||||
if err != nil {
|
||||
|
||||
@@ -44,9 +44,9 @@ var defaultTabConfig = TabConfig{
|
||||
{Key: "file-system", Title: "文件管理", Enabled: true},
|
||||
{Key: "db-cli", Title: "数据库", Enabled: true},
|
||||
{Key: "markdown-editor", Title: "Markdown", Enabled: true},
|
||||
{Key: "openclaw-manager", Title: "OpenClaw", Enabled: true},
|
||||
{Key: "version", Title: "版本历史", Enabled: true},
|
||||
},
|
||||
VisibleTabs: []string{"file-system", "db-cli", "markdown-editor", "openclaw-manager"},
|
||||
VisibleTabs: []string{"file-system", "db-cli", "markdown-editor", "version"},
|
||||
DefaultTab: "file-system",
|
||||
}
|
||||
|
||||
|
||||
12
web/package-lock.json
generated
12
web/package-lock.json
generated
@@ -25,6 +25,7 @@
|
||||
"@codemirror/lang-yaml": "^6.1.2",
|
||||
"@codemirror/language": "^6.12.1",
|
||||
"@codemirror/legacy-modes": "^6.5.2",
|
||||
"@codemirror/search": "^6.6.0",
|
||||
"@codemirror/state": "^6.5.3",
|
||||
"@codemirror/theme-one-dark": "^6.1.3",
|
||||
"@codemirror/view": "^6.39.8",
|
||||
@@ -414,6 +415,17 @@
|
||||
"crelt": "^1.0.5"
|
||||
}
|
||||
},
|
||||
"node_modules/@codemirror/search": {
|
||||
"version": "6.6.0",
|
||||
"resolved": "https://registry.npmmirror.com/@codemirror/search/-/search-6.6.0.tgz",
|
||||
"integrity": "sha512-koFuNXcDvyyotWcgOnZGmY7LZqEOXZaaxD/j6n18TCLx2/9HieZJ5H6hs1g8FiRxBD0DNfs0nXn17g872RmYdw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@codemirror/state": "^6.0.0",
|
||||
"@codemirror/view": "^6.37.0",
|
||||
"crelt": "^1.0.5"
|
||||
}
|
||||
},
|
||||
"node_modules/@codemirror/state": {
|
||||
"version": "6.5.3",
|
||||
"resolved": "https://registry.npmmirror.com/@codemirror/state/-/state-6.5.3.tgz",
|
||||
|
||||
@@ -25,6 +25,7 @@
|
||||
"@codemirror/lang-yaml": "^6.1.2",
|
||||
"@codemirror/language": "^6.12.1",
|
||||
"@codemirror/legacy-modes": "^6.5.2",
|
||||
"@codemirror/search": "^6.6.0",
|
||||
"@codemirror/state": "^6.5.3",
|
||||
"@codemirror/theme-one-dark": "^6.1.3",
|
||||
"@codemirror/view": "^6.39.8",
|
||||
|
||||
@@ -1 +1 @@
|
||||
0e1fafcbb6b28922a38f6c5316932015
|
||||
c0e9e27e045c6118704c87fcf34a03de
|
||||
@@ -101,12 +101,13 @@ import FileSystem from './components/FileSystem/index.vue'
|
||||
import SettingsPanel from './components/SettingsPanel.vue'
|
||||
import UpdateNotification from './components/UpdateNotification.vue'
|
||||
import {useUpdateStore} from './stores/update'
|
||||
import {useConfigStore} from './stores/config'
|
||||
import {useConfigStore, type AppConfig} from './stores/config'
|
||||
|
||||
// 存储键
|
||||
const ACTIVE_TAB_STORAGE_KEY = 'app-active-tab'
|
||||
|
||||
// 从 localStorage 恢复上次打开的区域,默认为 'file-system'
|
||||
// 兼容旧版:'user' 是 v0.2.x 之前的 tab key,已废弃需迁移
|
||||
const savedTab = localStorage.getItem(ACTIVE_TAB_STORAGE_KEY)
|
||||
const activeTab = ref((savedTab === 'user' ? 'file-system' : savedTab) || 'file-system')
|
||||
const showSettings = ref(false)
|
||||
@@ -125,7 +126,7 @@ const appConfig = computed(() => configStore.appConfig)
|
||||
const visibleTabs = computed(() => configStore.visibleTabs)
|
||||
|
||||
// 保存配置
|
||||
const handleSaveConfig = async (config) => {
|
||||
const handleSaveConfig = async (config: AppConfig) => {
|
||||
try {
|
||||
await configStore.saveConfig(config)
|
||||
showSettings.value = false
|
||||
@@ -148,7 +149,7 @@ const loadConfig = async () => {
|
||||
}
|
||||
|
||||
// 获取组件
|
||||
const getComponent = (key) => {
|
||||
const getComponent = (key: string) => {
|
||||
const components = {
|
||||
'file-system': FileSystem,
|
||||
'db-cli': DbCli,
|
||||
@@ -376,4 +377,9 @@ watch(activeTab, (newTab) => {
|
||||
.arco-tooltip {
|
||||
--wails-draggable: no-drag;
|
||||
}
|
||||
|
||||
/* 桌面应用:禁止 html/body 级别滚动条,所有滚动由内部组件自行处理 */
|
||||
html, body {
|
||||
overflow: hidden !important;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -3,34 +3,25 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, watch, onBeforeUnmount, computed, nextTick } from 'vue'
|
||||
import { ref, onMounted, watch, onBeforeUnmount, nextTick } from 'vue'
|
||||
import {
|
||||
EditorView, lineNumbers, highlightActiveLineGutter, keymap,
|
||||
EditorState, Compartment,
|
||||
defaultKeymap, history,
|
||||
bracketMatching, defaultHighlightStyle, syntaxHighlighting,
|
||||
oneDark
|
||||
oneDark,
|
||||
openSearchPanel, search
|
||||
} from '@/utils/codemirrorExports'
|
||||
import { useThemeStore } from '@/stores/theme'
|
||||
import { loadLanguageExtension, getLanguageFromExtension } from '@/utils/codeMirrorLoader'
|
||||
|
||||
// ==================== 主题定义 ====================
|
||||
|
||||
// 亮色主题的基础样式
|
||||
const lightTheme = EditorView.theme({
|
||||
'&': { backgroundColor: '#ffffff' },
|
||||
'.cm-gutters': { backgroundColor: '#f7f7f7', color: '#999', border: 'none' },
|
||||
'.cm-activeLineGutter': { backgroundColor: '#e8e8e8', color: '#333' },
|
||||
'.cm-line': { caretColor: '#000' },
|
||||
'.cm-selection': { backgroundColor: '#d9d9d9' },
|
||||
'.cm-cursor': { borderLeftColor: '#000' }
|
||||
})
|
||||
|
||||
// ==================== Props & Emits ====================
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: { type: String, required: true },
|
||||
fileExtension: { type: String, default: '' }
|
||||
fileExtension: { type: String, default: '' },
|
||||
filePath: { type: String, default: '' },
|
||||
fileMtime: { type: String, default: '' }
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:modelValue'])
|
||||
@@ -41,6 +32,36 @@ const themeStore = useThemeStore()
|
||||
const editorContainer = ref(null)
|
||||
let view = null
|
||||
|
||||
// 滚动位置缓存:LRU 最多 5 份,每份 3 分钟过期
|
||||
const MAX_SCROLL_CACHE = 5
|
||||
const SCROLL_CACHE_TTL = 3 * 60 * 1000 // 3 分钟
|
||||
const fileScrollPositions = new Map() // filePath → { scrollTop, anchor, timestamp }
|
||||
let currentFilePath = ''
|
||||
let saveScrollTimer = null
|
||||
|
||||
// 清理过期缓存 + LRU 淘汰,保持最多 MAX_SCROLL_CACHE 条
|
||||
const cleanScrollCache = () => {
|
||||
const now = Date.now()
|
||||
// 清理过期的
|
||||
for (const [key, val] of fileScrollPositions) {
|
||||
if (now - val.timestamp > SCROLL_CACHE_TTL) {
|
||||
fileScrollPositions.delete(key)
|
||||
}
|
||||
}
|
||||
// LRU:超出上限时删除最旧的
|
||||
if (fileScrollPositions.size > MAX_SCROLL_CACHE) {
|
||||
let oldestKey = null
|
||||
let oldestTime = Infinity
|
||||
for (const [key, val] of fileScrollPositions) {
|
||||
if (val.timestamp < oldestTime) {
|
||||
oldestTime = val.timestamp
|
||||
oldestKey = key
|
||||
}
|
||||
}
|
||||
if (oldestKey) fileScrollPositions.delete(oldestKey)
|
||||
}
|
||||
}
|
||||
|
||||
// 使用 Compartment 实现动态切换,避免重建编辑器
|
||||
const themeCompartment = new Compartment()
|
||||
const languageCompartment = new Compartment()
|
||||
@@ -87,6 +108,9 @@ const createExtensions = () => {
|
||||
keymap.of(defaultKeymap),
|
||||
bracketMatching(),
|
||||
|
||||
// 查找替换(Ctrl+F / Ctrl+H)
|
||||
search(),
|
||||
|
||||
// 内容更新监听(带防抖)
|
||||
EditorView.updateListener.of((update) => {
|
||||
if (update.docChanged) {
|
||||
@@ -98,7 +122,7 @@ const createExtensions = () => {
|
||||
EditorView.theme({
|
||||
'&': { height: '100%', fontSize: '13px' },
|
||||
'.cm-scroller': { overflow: 'auto', fontFamily: 'Consolas, Monaco, Courier New, monospace' },
|
||||
'.cm-content': { padding: '8px', minHeight: '100%' },
|
||||
'.cm-content': { padding: '8px' },
|
||||
'.cm-line': { padding: '0 0' },
|
||||
'&.cm-focused': { outline: 'none' }
|
||||
}),
|
||||
@@ -143,6 +167,12 @@ const createEditor = (docContent = '') => {
|
||||
|
||||
view = new EditorView({ state, parent: editorContainer.value })
|
||||
|
||||
// 滚动时防抖保存位置
|
||||
view.scrollDOM.addEventListener('scroll', () => {
|
||||
if (saveScrollTimer) clearTimeout(saveScrollTimer)
|
||||
saveScrollTimer = setTimeout(saveScrollPosition, 200)
|
||||
}, { passive: true })
|
||||
|
||||
// 初始化语言
|
||||
initLanguage()
|
||||
}
|
||||
@@ -163,8 +193,10 @@ onMounted(() => {
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (emitTimeout) {
|
||||
clearTimeout(emitTimeout)
|
||||
if (emitTimeout) clearTimeout(emitTimeout)
|
||||
if (saveScrollTimer) clearTimeout(saveScrollTimer)
|
||||
if (view?.scrollDOM) {
|
||||
view.scrollDOM.removeEventListener('scroll', saveScrollPosition)
|
||||
}
|
||||
view?.destroy()
|
||||
view = null
|
||||
@@ -172,12 +204,64 @@ onBeforeUnmount(() => {
|
||||
|
||||
// ==================== 监听器 ====================
|
||||
|
||||
// 监听外部内容变化
|
||||
watch(() => props.modelValue, (newValue) => {
|
||||
if (view && newValue !== view.state.doc.toString()) {
|
||||
view.dispatch({
|
||||
changes: { from: 0, to: view.state.doc.length, insert: newValue || '' }
|
||||
// 保存当前文件滚动位置(防抖)
|
||||
const saveScrollPosition = () => {
|
||||
if (!view || !currentFilePath) return
|
||||
const scroller = view.scrollDOM
|
||||
if (!scroller) return
|
||||
fileScrollPositions.set(currentFilePath, {
|
||||
scrollTop: scroller.scrollTop,
|
||||
anchor: view.state.selection.main.anchor,
|
||||
timestamp: Date.now()
|
||||
})
|
||||
cleanScrollCache()
|
||||
}
|
||||
|
||||
// 监听外部内容变化(切换文件/文件变更时触发)
|
||||
watch([() => props.modelValue, () => props.fileMtime], ([newValue, newMtime], [oldValue, oldMtime]) => {
|
||||
// 文件修改时间变了 → 说明磁盘内容有变更 → 强制刷新
|
||||
const mtimeChanged = newMtime && oldMtime && newMtime !== oldMtime
|
||||
if (view && (mtimeChanged || newValue !== view.state.doc.toString())) {
|
||||
// 先保存旧文件的滚动位置
|
||||
saveScrollPosition()
|
||||
|
||||
const newPath = props.filePath || ''
|
||||
const isSameFile = currentFilePath && currentFilePath === newPath
|
||||
|
||||
view.dispatch({
|
||||
changes: { from: 0, to: view.state.doc.length, insert: newValue || '' },
|
||||
selection: { anchor: 0 }
|
||||
})
|
||||
|
||||
currentFilePath = newPath
|
||||
|
||||
if (isSameFile && fileScrollPositions.has(newPath)) {
|
||||
// 同一文件 → 检查是否过期,未过期则恢复位置
|
||||
const saved = fileScrollPositions.get(newPath)
|
||||
if (saved && Date.now() - saved.timestamp <= SCROLL_CACHE_TTL) {
|
||||
nextTick(() => {
|
||||
if (view) {
|
||||
view.dispatch({
|
||||
selection: { anchor: saved.anchor },
|
||||
effects: EditorView.scrollIntoView(saved.anchor)
|
||||
})
|
||||
view.scrollDOM.scrollTop = saved.scrollTop
|
||||
}
|
||||
})
|
||||
} else {
|
||||
// 过期了 → 强制滚动到顶部
|
||||
nextTick(() => {
|
||||
if (view) view.scrollDOM.scrollTop = 0
|
||||
})
|
||||
}
|
||||
} else {
|
||||
// 不同文件 → 强制滚动到顶部(scrollIntoView 不一定重置 DOM scrollTop)
|
||||
nextTick(() => {
|
||||
if (view) {
|
||||
view.scrollDOM.scrollTop = 0
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@@ -214,6 +298,6 @@ watch(() => props.fileExtension, () => {
|
||||
}
|
||||
|
||||
.codemirror-editor :deep(.cm-content) {
|
||||
height: 100%;
|
||||
/* 不设 height,让 CodeMirror 虚拟滚动自行计算文档高度 */
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -155,6 +155,8 @@
|
||||
<AsyncCodeEditor
|
||||
:model-value="config.fileContent"
|
||||
:file-extension="config.currentFileExtension"
|
||||
:file-path="config.currentFileFullPath"
|
||||
:file-mtime="config.fileMtime"
|
||||
@update:model-value="handleContentUpdate"
|
||||
class="code-editor"
|
||||
/>
|
||||
@@ -218,6 +220,8 @@
|
||||
<AsyncCodeEditor
|
||||
:model-value="config.fileContent"
|
||||
:file-extension="config.currentFileExtension"
|
||||
:file-path="config.currentFileFullPath"
|
||||
:file-mtime="config.fileMtime"
|
||||
@update:model-value="handleContentUpdate"
|
||||
class="code-editor"
|
||||
/>
|
||||
@@ -284,6 +288,8 @@
|
||||
<AsyncCodeEditor
|
||||
:model-value="config.fileContent"
|
||||
:file-extension="config.currentFileExtension"
|
||||
:file-path="config.currentFileFullPath"
|
||||
:file-mtime="config.fileMtime"
|
||||
@update:model-value="handleContentUpdate"
|
||||
class="code-editor"
|
||||
/>
|
||||
@@ -337,6 +343,8 @@
|
||||
<AsyncCodeEditor
|
||||
:model-value="config.fileContent"
|
||||
:file-extension="config.currentFileExtension"
|
||||
:file-path="config.currentFileFullPath"
|
||||
:file-mtime="config.fileMtime"
|
||||
@update:model-value="handleContentUpdate"
|
||||
class="code-editor"
|
||||
/>
|
||||
@@ -430,7 +438,7 @@ const htmlPreviewUrl = computed(() => {
|
||||
return ''
|
||||
}
|
||||
const encodedPath = encodeURIComponent(props.config.currentFileFullPath)
|
||||
return `http://localhost:18765/localfs/html-preview?path=${encodedPath}`
|
||||
return `http://localhost:8073/localfs/html-preview?path=${encodedPath}`
|
||||
})
|
||||
|
||||
// 计算属性:判断文件是否在当前目录
|
||||
@@ -775,11 +783,11 @@ watch([markdownPreviewRef, () => props.config.isEditMode], ([refVal, isEditMode]
|
||||
// 处理 HTML iframe 发送的消息(链接点击)
|
||||
const handleHtmlIframeMessage = (event: MessageEvent) => {
|
||||
// 安全检查:接受来自本地文件服务器或同源的消息
|
||||
// Wails 应用的 origin 可能是 wails://...,而 iframe 来自 http://localhost:18765
|
||||
// Wails 应用的 origin 可能是 wails://...,而 iframe 来自 http://localhost:8073
|
||||
const allowedOrigins = [
|
||||
window.location.origin,
|
||||
'null', // about:blank 或 data: URL
|
||||
'http://localhost:18765', // 本地文件服务器
|
||||
'http://localhost:8073', // 本地文件服务器
|
||||
]
|
||||
if (!allowedOrigins.includes(event.origin)) {
|
||||
return
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
<span class="panel-title">📋 文件列表</span>
|
||||
<div class="panel-header-right">
|
||||
<span class="panel-count">{{ config.fileList.length }} 项</span>
|
||||
<a-popover trigger="click" position="bottom" :content-style="{ padding: '4px', width: '150px' }">
|
||||
<a-popover trigger="click" position="bottom" :content-style="{ padding: '4px', width: '170px' }">
|
||||
<a-button size="mini" type="text" class="settings-btn">
|
||||
<icon-more />
|
||||
</a-button>
|
||||
@@ -31,6 +31,18 @@
|
||||
:disabled="col.key === 'name' && visibleCount <= 1"
|
||||
@change="(val: boolean) => toggleColumn(col.key, val)"
|
||||
>{{ col.label }}</a-checkbox>
|
||||
<!-- 可排序列:点击图标排序 -->
|
||||
<span
|
||||
v-if="colSortMap[col.key]"
|
||||
class="col-sort-icon"
|
||||
:class="{ 'col-sort-active': sortBy === colSortMap[col.key] }"
|
||||
:title="sortBy === colSortMap[col.key] ? (sortOrder === 'asc' ? '升序 → 点击降序' : '降序 → 点击升序') : `按${col.label}排序`"
|
||||
@click.stop="emit('sort', colSortMap[col.key])"
|
||||
>
|
||||
<IconSort v-if="sortBy !== colSortMap[col.key]" />
|
||||
<IconSortAscending v-else-if="sortOrder === 'asc'" />
|
||||
<IconSortDescending v-else />
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
</a-popover>
|
||||
@@ -101,6 +113,14 @@ interface Emits {
|
||||
|
||||
const emit = defineEmits<Emits>()
|
||||
|
||||
// 列 key → 排序字段映射
|
||||
const colSortMap: Record<string, string> = {
|
||||
icon: 'type',
|
||||
name: 'name',
|
||||
time: 'modified_time',
|
||||
size: 'size'
|
||||
}
|
||||
|
||||
// ========== 列配置(支持显隐 + 排序) ==========
|
||||
const COL_SETTINGS_KEY = STORAGE_KEYS.FILESYSTEM.COL_SETTINGS
|
||||
const SHOW_HEADER_KEY = STORAGE_KEYS.FILESYSTEM.SHOW_HEADER
|
||||
@@ -139,6 +159,7 @@ function loadColSettings(): ColumnConfig[] {
|
||||
}
|
||||
|
||||
const colSettings = ref<ColumnConfig[]>(loadColSettings())
|
||||
// 默认显示表头(localStorage 无值时兼容旧行为)
|
||||
const showHeader = ref(localStorage.getItem(SHOW_HEADER_KEY) !== 'false')
|
||||
|
||||
// 手动持久化(避免 deep watch 频繁写入)
|
||||
@@ -382,6 +403,25 @@ defineExpose({ focusEditingItem })
|
||||
color: var(--color-text-2);
|
||||
}
|
||||
|
||||
/* 列项排序图标 */
|
||||
.col-sort-icon {
|
||||
margin-left: auto;
|
||||
font-size: 12px;
|
||||
color: var(--color-text-4);
|
||||
cursor: pointer;
|
||||
padding: 2px;
|
||||
border-radius: 3px;
|
||||
flex-shrink: 0;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
.col-sort-icon:hover {
|
||||
background: var(--color-fill-2);
|
||||
color: var(--color-text-2);
|
||||
}
|
||||
.col-sort-active {
|
||||
color: rgb(var(--primary-6));
|
||||
}
|
||||
|
||||
/* 滚动容器 */
|
||||
.file-list-wrapper {
|
||||
flex: 1;
|
||||
|
||||
@@ -27,6 +27,22 @@
|
||||
</div>
|
||||
<!-- 正常模式:面包屑导航 -->
|
||||
<div v-else class="path-breadcrumb-wrapper">
|
||||
<!-- 快捷访问(仅图标,面包屑前) -->
|
||||
<a-dropdown>
|
||||
<a-button size="mini" type="text">
|
||||
<template #icon><icon-forward /></template>
|
||||
</a-button>
|
||||
<template #content>
|
||||
<a-doption
|
||||
v-for="shortcut in config.commonPaths"
|
||||
:key="shortcut.path"
|
||||
@click="handleGoToPath(shortcut.path)"
|
||||
>
|
||||
<template #icon>{{ (shortcut.name || '').split(' ')[0] }}</template>
|
||||
{{ (shortcut.name || '').substring(2) }}
|
||||
</a-doption>
|
||||
</template>
|
||||
</a-dropdown>
|
||||
<PathBreadcrumb
|
||||
:path="config.filePath"
|
||||
@navigate="handleGoToPath"
|
||||
@@ -49,45 +65,17 @@
|
||||
</div>
|
||||
|
||||
<div class="toolbar-right">
|
||||
<!-- 快捷路径下拉 -->
|
||||
<a-dropdown v-if="!config.isBrowsingZip">
|
||||
<a-button size="small">
|
||||
<template #icon>
|
||||
<icon-forward />
|
||||
</template>
|
||||
快捷访问
|
||||
</a-button>
|
||||
<template #content>
|
||||
<a-doption
|
||||
v-for="shortcut in config.commonPaths"
|
||||
:key="shortcut.path"
|
||||
@click="handleGoToPath(shortcut.path)"
|
||||
>
|
||||
<template #icon>{{ (shortcut.name || '').split(' ')[0] }}</template>
|
||||
{{ (shortcut.name || '').substring(2) }}
|
||||
</a-doption>
|
||||
</template>
|
||||
</a-dropdown>
|
||||
|
||||
<!-- 历史记录下拉 -->
|
||||
<a-dropdown>
|
||||
<a-button size="small">
|
||||
<template #icon>
|
||||
<icon-history />
|
||||
</template>
|
||||
历史
|
||||
</a-button>
|
||||
<template #content>
|
||||
<a-doption
|
||||
v-for="path in config.pathHistory.slice(0, 10)"
|
||||
:key="path"
|
||||
@click="handleGoToPath(path)"
|
||||
>
|
||||
{{ path }}
|
||||
</a-doption>
|
||||
<a-doption v-if="config.pathHistory.length === 0" disabled>暂无历史</a-doption>
|
||||
</template>
|
||||
</a-dropdown>
|
||||
<!-- 搜索框 -->
|
||||
<a-input-search
|
||||
:model-value="config.searchKeyword"
|
||||
placeholder="搜索文件..."
|
||||
size="small"
|
||||
class="toolbar-search"
|
||||
allow-clear
|
||||
@search="handleSearch"
|
||||
@update:model-value="handleSearchInput"
|
||||
@keyup.escape="handleClearSearch"
|
||||
/>
|
||||
|
||||
<!-- 刷新按钮 -->
|
||||
<a-button
|
||||
@@ -101,6 +89,29 @@
|
||||
刷新
|
||||
</a-button>
|
||||
|
||||
<!-- 历史记录下拉(仅图标,Ctrl+H) -->
|
||||
<a-dropdown
|
||||
v-model:popup-visible="historyPopupVisible"
|
||||
>
|
||||
<a-tooltip content="历史记录 (Ctrl+H)" position="left">
|
||||
<a-button size="small">
|
||||
<template #icon><icon-history /></template>
|
||||
</a-button>
|
||||
</a-tooltip>
|
||||
<template #content>
|
||||
<div class="history-dropdown-content">
|
||||
<a-doption
|
||||
v-for="path in config.pathHistory.slice(0, 10)"
|
||||
:key="path"
|
||||
@click="handleGoToPath(path)"
|
||||
>
|
||||
<span class="history-path-text">{{ path }}</span>
|
||||
</a-doption>
|
||||
<a-doption v-if="config.pathHistory.length === 0" disabled>暂无历史</a-doption>
|
||||
</div>
|
||||
</template>
|
||||
</a-dropdown>
|
||||
|
||||
<!-- 切换侧边栏 -->
|
||||
<a-button
|
||||
size="small"
|
||||
@@ -132,6 +143,7 @@ const props = defineProps<Props>()
|
||||
interface Emits {
|
||||
(e: 'update:filePath', path: string): void
|
||||
(e: 'update:showSidebar', show: boolean): void
|
||||
(e: 'update:searchKeyword', keyword: string): void
|
||||
(e: 'refresh'): void
|
||||
(e: 'exitZip'): void
|
||||
(e: 'goToPath', path: string): void
|
||||
@@ -141,6 +153,9 @@ interface Emits {
|
||||
|
||||
const emit = defineEmits<Emits>()
|
||||
|
||||
// 历史记录下拉显隐(供父组件 Ctrl+H 调用)
|
||||
const historyPopupVisible = ref(false)
|
||||
|
||||
// 事件处理
|
||||
const handleGoToPath = (path: string) => {
|
||||
emit('goToPath', path)
|
||||
@@ -170,8 +185,28 @@ const handleToggleSidebar = () => {
|
||||
emit('update:showSidebar', !props.config.showSidebar)
|
||||
}
|
||||
|
||||
const handleSearch = (keyword: string) => {
|
||||
emit('update:searchKeyword', keyword)
|
||||
}
|
||||
|
||||
const handleSearchInput = (keyword: string) => {
|
||||
emit('update:searchKeyword', keyword)
|
||||
}
|
||||
|
||||
const handleClearSearch = () => {
|
||||
emit('update:searchKeyword', '')
|
||||
}
|
||||
|
||||
// 切换历史记录下拉面板(供父组件 Ctrl+H 调用)
|
||||
const toggleHistoryDropdown = () => {
|
||||
historyPopupVisible.value = !historyPopupVisible.value
|
||||
}
|
||||
|
||||
const { copied, copy: copyPath } = useClipboardCopy()
|
||||
|
||||
// 暴露方法给父组件
|
||||
defineExpose({ toggleHistoryDropdown })
|
||||
|
||||
const handleCopyPath = async () => {
|
||||
await copyPath(props.config.filePath)
|
||||
}
|
||||
@@ -202,6 +237,11 @@ const handleCopyPath = async () => {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.toolbar-search {
|
||||
width: 180px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.path-input-wrapper {
|
||||
flex: 1;
|
||||
min-width: 200px;
|
||||
@@ -280,4 +320,19 @@ const handleCopyPath = async () => {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
/* 历史记录下拉 */
|
||||
.history-dropdown-content {
|
||||
max-width: 420px;
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.history-path-text {
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
max-width: 380px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -13,10 +13,11 @@ import {
|
||||
isTextEditable, isConfigFile
|
||||
} from '@/utils/fileTypeHelpers'
|
||||
import { useFileOperations } from './useFileOperations'
|
||||
import type { FileItem } from '@/types/file-system'
|
||||
|
||||
export interface UseFileEditOptions {
|
||||
currentFilePath?: any
|
||||
currentDirectory?: any
|
||||
currentFilePath?: import('vue').Ref<FileItem | null>
|
||||
currentDirectory?: import('vue').Ref<string>
|
||||
}
|
||||
|
||||
// 文件大小限制(5MB)
|
||||
@@ -46,9 +47,6 @@ export function useFileEdit(options: UseFileEditOptions = {}) {
|
||||
// 文件版本跟踪(用于防止切换文件后的过期更新)
|
||||
const fileVersion = ref(0)
|
||||
|
||||
// 最后一次文件加载的时间戳,用于过滤过期更新
|
||||
const lastLoadTime = ref(0)
|
||||
|
||||
// 使用文件操作 composable
|
||||
const { readFile, writeFile } = useFileOperations({
|
||||
onSuccess: (operation, data) => {
|
||||
@@ -81,7 +79,7 @@ export function useFileEdit(options: UseFileEditOptions = {}) {
|
||||
* 判断是否为二进制文件(基于扩展名)
|
||||
* 返回值: true=已知二进制, false=已知非二进制(文本/媒体/Office), null=未知需检测
|
||||
*/
|
||||
const isBinaryFileByExt = (filepath: any): boolean | null => {
|
||||
const isBinaryFileByExt = (filepath: string | FileItem): boolean | null => {
|
||||
const path = getFilePath(filepath)
|
||||
const ext = getExt(path)
|
||||
if (!ext) return null // 无扩展名返回 null,表示需要进一步检测
|
||||
@@ -185,9 +183,6 @@ export function useFileEdit(options: UseFileEditOptions = {}) {
|
||||
// 增加文件版本号,使之前的过期更新失效
|
||||
fileVersion.value++
|
||||
|
||||
// 记录加载时间戳,用于过滤过期更新
|
||||
lastLoadTime.value = Date.now()
|
||||
|
||||
// 注意:不再清空内容,避免 HTML 预览切换时闪烁
|
||||
// 新内容加载完成后会直接替换旧内容
|
||||
|
||||
@@ -456,6 +451,33 @@ ${ext ? `文件类型: ${fileTypeDesc}\n` : ''}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 仅更新文件路径(重命名场景:内容不变,只切换路径关联)
|
||||
* 迁移草稿 key,更新 currentFilePathRef
|
||||
*/
|
||||
const updateFilePath = (newPath: string) => {
|
||||
const oldPath = currentFilePathRef.value
|
||||
|
||||
// 迁移草稿(旧 key → 新 key)
|
||||
if (draftKey.value && oldPath !== newPath) {
|
||||
try {
|
||||
const draft = localStorage.getItem(draftKey.value)
|
||||
if (draft) {
|
||||
const newKey = `${STORAGE_KEYS.FILESYSTEM.FILE_DRAFT}-${newPath}`
|
||||
localStorage.setItem(newKey, draft)
|
||||
localStorage.removeItem(draftKey.value)
|
||||
draftKey.value = newKey
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('[useFileEdit] 草稿迁移失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// 只更新内部路径字符串引用,不触碰 currentFilePath(它是 FileItem 对象,由父组件管理)
|
||||
// 这样不会触发 watch → clearDraft
|
||||
currentFilePathRef.value = newPath
|
||||
}
|
||||
|
||||
/**
|
||||
* 重置文件内容
|
||||
*/
|
||||
@@ -501,14 +523,10 @@ ${ext ? `文件类型: ${fileTypeDesc}\n` : ''}
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新文件内容
|
||||
* 注意:需要确保更新后 fileContent 和 originalContent 保持正确的同步关系
|
||||
* 更新文件内容(仅版本号匹配时接受,防止快速切换文件时旧更新覆盖新内容)
|
||||
*/
|
||||
const updateContent = (content: string, expectedVersion?: number) => {
|
||||
// 如果提供了期望的版本号,检查是否匹配
|
||||
// 这用于防止快速切换文件时,旧文件的防抖更新覆盖新文件的内容
|
||||
if (expectedVersion !== undefined && expectedVersion !== fileVersion.value) {
|
||||
// 版本不匹配,这是一个过期的更新,忽略它
|
||||
console.debug('[useFileEdit] 忽略过期更新(版本不匹配):', {
|
||||
expected: expectedVersion,
|
||||
current: fileVersion.value,
|
||||
@@ -517,25 +535,9 @@ ${ext ? `文件类型: ${fileTypeDesc}\n` : ''}
|
||||
return
|
||||
}
|
||||
|
||||
// 额外检查:如果更新是在文件加载后的短时间内,可能是过期更新
|
||||
// 防抖时间是 150ms,我们使用 300ms 的安全边际
|
||||
const timeSinceLoad = Date.now() - lastLoadTime.value
|
||||
if (timeSinceLoad < 300) {
|
||||
console.debug('[useFileEdit] 忽略过期更新(时间窗口内):', {
|
||||
timeSinceLoad,
|
||||
content: content.substring(0, 50)
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// 确保只有在内容真正改变时才更新
|
||||
if (fileContent.value !== content) {
|
||||
fileContent.value = content
|
||||
}
|
||||
|
||||
// 自动保存草稿(防抖)
|
||||
// 实际实现应该使用防抖函数
|
||||
// saveDraft()
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -556,12 +558,6 @@ ${ext ? `文件类型: ${fileTypeDesc}\n` : ''}
|
||||
return filePath.startsWith(currentDirectory.value)
|
||||
}
|
||||
|
||||
// 监听文件内容变化,自动保存草稿
|
||||
watch(fileContent, () => {
|
||||
// 实际实现应该使用防抖
|
||||
// saveDraft()
|
||||
}, { deep: true })
|
||||
|
||||
// 监听文件路径变化,清除草稿
|
||||
watch(currentFilePath, (newPath, oldPath) => {
|
||||
if (newPath !== oldPath) {
|
||||
@@ -604,6 +600,7 @@ ${ext ? `文件类型: ${fileTypeDesc}\n` : ''}
|
||||
// 其他
|
||||
resetContent,
|
||||
clearContent,
|
||||
updateFilePath,
|
||||
setEditorHeight,
|
||||
|
||||
// 文件类型检查
|
||||
|
||||
@@ -29,8 +29,17 @@ export interface UseFilePreviewOptions {
|
||||
export function useFilePreview(options: UseFilePreviewOptions = {}) {
|
||||
const { filePath = ref(''), isBrowsingZip = ref(false) } = options
|
||||
|
||||
// 文件服务器 URL(硬编码,与旧版本保持一致)
|
||||
const fileServerURL = 'http://localhost:18765'
|
||||
// 文件服务器 URL(优先从后端获取,降级到默认值)
|
||||
let _fileServerURL = 'http://localhost:8073'
|
||||
const initFileServerURL = async () => {
|
||||
try {
|
||||
const url = await window.go.main.App.GetFileServerURL()
|
||||
if (url) _fileServerURL = url
|
||||
} catch { /* 使用默认值 */ }
|
||||
}
|
||||
initFileServerURL()
|
||||
|
||||
const getFileServerURL = () => _fileServerURL
|
||||
|
||||
// 预览 URL
|
||||
const previewUrl = ref('')
|
||||
@@ -45,7 +54,7 @@ export function useFilePreview(options: UseFilePreviewOptions = {}) {
|
||||
const getPreviewUrl = (path: string): string => {
|
||||
if (!path) return ''
|
||||
// 使用与旧版本相同的 URL 格式:/localfs/ 路径 + 规范化路径
|
||||
return `${fileServerURL}/localfs/${normalizeFilePath(path, true)}`
|
||||
return `${getFileServerURL()}/localfs/${normalizeFilePath(path, true)}`
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -188,12 +197,6 @@ export function useFilePreview(options: UseFilePreviewOptions = {}) {
|
||||
|
||||
// 文件类型判断(同步,基于扩展名)
|
||||
getFileType,
|
||||
isImageFile,
|
||||
isVideoFile,
|
||||
isAudioFile,
|
||||
isPdfFile,
|
||||
isHtmlFile,
|
||||
isMarkdownFile,
|
||||
isPreviewable,
|
||||
isEditable,
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
<div class="file-system-container">
|
||||
<!-- 顶部工具栏 -->
|
||||
<Toolbar
|
||||
ref="toolbarRef"
|
||||
:config="toolbarConfig"
|
||||
@update:file-path="handleFilePathUpdate"
|
||||
@update:show-sidebar="handleSidebarToggle"
|
||||
@@ -10,6 +11,7 @@
|
||||
@go-to-path="handleGoToPath"
|
||||
@open-file="handleOpenFile"
|
||||
@navigate-to-zip-directory="handleNavigateToZipDirectory"
|
||||
@update:search-keyword="handleSearchKeywordUpdate"
|
||||
@show-message="handleShowMessage"
|
||||
/>
|
||||
|
||||
@@ -152,6 +154,7 @@ const isEditableWithPreview = (filename: string): boolean => {
|
||||
const fileList = ref<FileItem[]>([])
|
||||
const fileLoading = ref(false)
|
||||
const selectedFileItem = ref<FileItem | null>(null)
|
||||
const toolbarRef = ref<InstanceType<typeof import('./components/Toolbar.vue').default> | null>(null)
|
||||
|
||||
// 排序状态(带 localStorage 持久化)
|
||||
const SORT_STORAGE_KEY = STORAGE_KEYS.FILESYSTEM.SORT
|
||||
@@ -187,6 +190,18 @@ const editingFileName = ref('')
|
||||
// 侧边栏
|
||||
const showSidebar = ref(true)
|
||||
|
||||
// 搜索
|
||||
const searchKeyword = ref('')
|
||||
|
||||
// 过滤后的文件列表(基于搜索关键词)
|
||||
const filteredFileList = computed(() => {
|
||||
const keyword = searchKeyword.value.trim().toLowerCase()
|
||||
if (!keyword) return fileList.value
|
||||
return fileList.value.filter(item =>
|
||||
item.name.toLowerCase().includes(keyword)
|
||||
)
|
||||
})
|
||||
|
||||
// 面板宽度(带 localStorage 持久化)
|
||||
const restorePanelWidth = (): { left: number; right: number } => {
|
||||
try {
|
||||
@@ -271,7 +286,7 @@ const { previewUrl, updatePreviewUrl, imageLoading, currentImageDimensions, dete
|
||||
})
|
||||
|
||||
// 文件编辑
|
||||
const { fileContent, originalContent, isEditMode, fileContentHeight, contentChanged, canSaveFile, canResetContent, loadFile, saveFile, resetContent, clearContent, toggleEditMode, updateContent, setEditorHeight, isBinaryFile: isBinaryFileRef, fileVersion } =
|
||||
const { fileContent, originalContent, isEditMode, fileContentHeight, contentChanged, canSaveFile, canResetContent, loadFile, saveFile, resetContent, clearContent, updateFilePath, toggleEditMode, updateContent, setEditorHeight, isBinaryFile: isBinaryFileRef, fileVersion } =
|
||||
useFileEdit({
|
||||
currentFilePath: selectedFileItem,
|
||||
currentDirectory: filePath
|
||||
@@ -295,7 +310,8 @@ const toolbarConfig = computed(() => ({
|
||||
fileLoading: fileLoading.value,
|
||||
showSidebar: showSidebar.value,
|
||||
sortBy: sortBy.value,
|
||||
sortOrder: sortOrder.value
|
||||
sortOrder: sortOrder.value,
|
||||
searchKeyword: searchKeyword.value
|
||||
}))
|
||||
|
||||
// 侧边栏配置
|
||||
@@ -307,7 +323,7 @@ const sidebarConfig = computed(() => ({
|
||||
|
||||
// 文件列表面板配置
|
||||
const fileListPanelConfig = computed(() => ({
|
||||
fileList: fileList.value,
|
||||
fileList: filteredFileList.value,
|
||||
fileLoading: fileLoading.value,
|
||||
selectedFileItem: selectedFileItem.value,
|
||||
editingFilePath: editingFilePath.value,
|
||||
@@ -363,7 +379,8 @@ const fileEditorPanelConfig = computed(() => {
|
||||
imageLoading: imageLoading.value,
|
||||
currentImageDimensions: currentImageDimensions.value,
|
||||
currentFileExtension,
|
||||
isBinaryFile: isBinaryFileRef.value
|
||||
isBinaryFile: isBinaryFileRef.value,
|
||||
fileMtime: selectedFileItem.value?.modified_time || ''
|
||||
}
|
||||
})
|
||||
|
||||
@@ -382,6 +399,10 @@ const handleRefresh = async () => {
|
||||
await loadDirectory(filePath.value)
|
||||
}
|
||||
|
||||
const handleSearchKeywordUpdate = (keyword: string) => {
|
||||
searchKeyword.value = keyword
|
||||
}
|
||||
|
||||
const handleGoToPath = async (path: string) => {
|
||||
await navigate(path)
|
||||
}
|
||||
@@ -619,24 +640,12 @@ const handleSaveEditing = async (oldPath: string, newName: string) => {
|
||||
}
|
||||
|
||||
try {
|
||||
// 如果重命名的是当前打开的文件,先关闭编辑器和预览
|
||||
if (selectedFileItem.value?.path === oldPath) {
|
||||
// 如果是文件(不是文件夹),才需要关闭编辑器
|
||||
if (!selectedFileItem.value.isDir) {
|
||||
// 清空编辑器内容
|
||||
await clearContent()
|
||||
// 标记是否需要重命名后仅更新路径(内容不变,零闪烁)
|
||||
let needUpdatePath = false
|
||||
|
||||
// 清空预览URL
|
||||
if (previewUrl.value) {
|
||||
previewUrl.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
// 取消选中状态
|
||||
selectedFileItem.value = null
|
||||
|
||||
// 等待文件句柄释放(文件需要更长时间)
|
||||
await new Promise(resolve => setTimeout(resolve, 300))
|
||||
// 如果重命名的是当前打开的文件
|
||||
if (selectedFileItem.value?.path === oldPath && !selectedFileItem.value.isDir) {
|
||||
needUpdatePath = true
|
||||
}
|
||||
|
||||
const renamedFile = await fileOps.rename(oldPath, trimmedName)
|
||||
@@ -650,6 +659,13 @@ const handleSaveEditing = async (oldPath: string, newName: string) => {
|
||||
}
|
||||
|
||||
Message.success(`✓ 重命名成功: ${trimmedName}`)
|
||||
|
||||
// 仅更新路径关联,不重新加载内容(编辑器内容不变,零闪烁)
|
||||
if (needUpdatePath && !renamedFile.isDir) {
|
||||
selectedFileItem.value = renamedFile
|
||||
updateFilePath(newPath)
|
||||
updatePreviewUrl(newPath)
|
||||
}
|
||||
} catch (error: any) {
|
||||
// 提取错误信息
|
||||
let errorMsg = error?.message || error?.toString() || '未知错误'
|
||||
@@ -1237,12 +1253,23 @@ const handleKeyDown = async (event: KeyboardEvent) => {
|
||||
return
|
||||
}
|
||||
|
||||
// F5 刷新文件列表
|
||||
// F5 刷新文件列表 + 重载当前预览文件
|
||||
if (event.key === 'F5') {
|
||||
event.preventDefault()
|
||||
if (filePath.value) {
|
||||
loadDirectory(filePath.value)
|
||||
await loadDirectory(filePath.value)
|
||||
// 如果有正在预览的文件,同时重新加载其内容(类似重新点击一次)
|
||||
if (selectedFileItem.value && !selectedFileItem.value.isDir) {
|
||||
await loadFileContent(selectedFileItem.value.path)
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Ctrl+H 打开历史记录面板
|
||||
if ((event.ctrlKey || event.metaKey) && event.key === 'h') {
|
||||
event.preventDefault()
|
||||
toolbarRef.value?.toggleHistoryDropdown?.()
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,8 @@
|
||||
import { computed, watch, onMounted, onUnmounted, h, nextTick } from 'vue'
|
||||
import { Modal, Message, Progress } from '@arco-design/web-vue'
|
||||
import { useUpdateStore } from '../stores/update'
|
||||
import { marked } from '../utils/markedExtensions'
|
||||
import { sanitizeHtml } from '@/utils/fileUtils'
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
@@ -66,8 +68,8 @@ const showUpdateModal = () => {
|
||||
title: forceUpdate.value ? '重要更新' : '发现新版本',
|
||||
content: () => {
|
||||
const elements = [
|
||||
h('div', { style: { marginBottom: '12px' } }, [
|
||||
h('span', { style: { fontSize: '14px', color: 'var(--color-text-2)' } }, '版本:'),
|
||||
h('div', { style: { marginBottom: '8px' } }, [
|
||||
h('span', { style: { fontSize: '13px', color: 'var(--color-text-2)' } }, '版本:'),
|
||||
h('span', { style: { fontSize: '14px', color: 'var(--color-text-1)', marginLeft: '8px' } }, currentVersion.value),
|
||||
h('span', { style: { fontSize: '14px', color: 'var(--color-text-2)', marginLeft: '12px', marginRight: '12px' } }, '→'),
|
||||
h('span', { style: { fontSize: '16px', color: 'rgb(var(--primary-6))', fontWeight: '500' } }, latestVersion.value)
|
||||
@@ -76,20 +78,23 @@ const showUpdateModal = () => {
|
||||
|
||||
// 更新日志
|
||||
if (changelog.value) {
|
||||
const changelogHtml = (() => { try { return sanitizeHtml(String(marked.parse(changelog.value))) } catch { return changelog.value } })()
|
||||
elements.push(
|
||||
h('div', { style: { marginBottom: '12px' } }, [
|
||||
h('div', { style: { fontSize: '13px', color: 'var(--color-text-2)', marginBottom: '8px' } }, '更新内容:'),
|
||||
h('div', { style: { marginBottom: '8px' } }, [
|
||||
h('div', { style: { fontSize: '12px', color: 'var(--color-text-2)', marginBottom: '4px' } }, '更新内容:'),
|
||||
h('div', {
|
||||
style: {
|
||||
fontSize: '13px',
|
||||
fontSize: '12px',
|
||||
color: 'var(--color-text-2)',
|
||||
lineHeight: '1.8',
|
||||
padding: '12px',
|
||||
lineHeight: '1.6',
|
||||
padding: '10px 12px',
|
||||
background: 'var(--color-fill-1)',
|
||||
borderRadius: '4px',
|
||||
whiteSpace: 'pre-wrap'
|
||||
}
|
||||
}, changelog.value)
|
||||
maxHeight: '240px',
|
||||
overflowY: 'auto'
|
||||
},
|
||||
innerHTML: changelogHtml
|
||||
})
|
||||
])
|
||||
)
|
||||
}
|
||||
@@ -104,7 +109,7 @@ const showUpdateModal = () => {
|
||||
}
|
||||
if (metadata.length > 0) {
|
||||
elements.push(
|
||||
h('div', { style: { marginBottom: '12px', fontSize: '13px', color: 'var(--color-text-3)' } }, metadata.join(' · '))
|
||||
h('div', { style: { marginBottom: '4px', fontSize: '12px', color: 'var(--color-text-3)' } }, metadata.join(' · '))
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -30,7 +30,7 @@
|
||||
<div class="changelog-title">
|
||||
{{ updateInfo.has_update ? '最新版本更新内容' : '当前版本更新内容' }}
|
||||
</div>
|
||||
<div class="changelog">{{ updateInfo.changelog }}</div>
|
||||
<div class="changelog" v-html="renderChangelog(updateInfo.changelog)" />
|
||||
</div>
|
||||
</a-card>
|
||||
|
||||
@@ -92,8 +92,8 @@
|
||||
:status="downloadStatus"
|
||||
/>
|
||||
<div class="progress-info">
|
||||
<span>{{ formatFileSize(progressInfo.downloaded) }} / {{ formatFileSize(progressInfo.total) }}</span>
|
||||
<span v-if="progressInfo.speed > 0">{{ formatSpeed(progressInfo.speed) }}</span>
|
||||
<span>{{ updateStore.formatFileSize(progressInfo.downloaded) }} / {{ updateStore.formatFileSize(progressInfo.total) }}</span>
|
||||
<span v-if="progressInfo.speed > 0">{{ updateStore.formatSpeed(progressInfo.speed) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -118,6 +118,8 @@ import { Message, Modal } from '@arco-design/web-vue'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { IconHistory } from '@arco-design/web-vue/es/icon'
|
||||
import { useUpdateStore } from '../stores/update'
|
||||
import { marked } from '../utils/markedExtensions'
|
||||
import { sanitizeHtml } from '@/utils/fileUtils'
|
||||
|
||||
// Emits
|
||||
defineEmits(['open-version-history'])
|
||||
@@ -134,13 +136,10 @@ const lastCheckTime = ref('-')
|
||||
const installResult = ref(null)
|
||||
const downloadedFile = ref(null)
|
||||
|
||||
// 工具函数
|
||||
const formatFileSize = (bytes) => {
|
||||
return updateStore.formatFileSize(bytes)
|
||||
}
|
||||
|
||||
const formatSpeed = (bytesPerSecond) => {
|
||||
return updateStore.formatSpeed(bytesPerSecond)
|
||||
/** 渲染 changelog(Markdown → HTML) */
|
||||
function renderChangelog(text: string): string {
|
||||
if (!text) return ''
|
||||
try { return sanitizeHtml(marked.parse(text) as string) } catch { return text }
|
||||
}
|
||||
|
||||
// 加载当前版本
|
||||
@@ -283,29 +282,70 @@ onUnmounted(() => {
|
||||
}
|
||||
|
||||
.changelog-section {
|
||||
margin-top: 16px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.changelog-title {
|
||||
font-size: 14px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--color-text-1);
|
||||
margin-bottom: 8px;
|
||||
color: var(--color-text-2);
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.changelog {
|
||||
background: var(--color-fill-2);
|
||||
padding: 12px;
|
||||
background: var(--color-fill-1);
|
||||
padding: 10px 12px;
|
||||
border-radius: 4px;
|
||||
white-space: pre-wrap;
|
||||
margin: 8px 0;
|
||||
max-height: 200px;
|
||||
margin: 0;
|
||||
max-height: 280px;
|
||||
overflow-y: auto;
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
font-size: 12px;
|
||||
line-height: 1.65;
|
||||
color: var(--color-text-2);
|
||||
}
|
||||
|
||||
.changelog :deep(h4) {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-1);
|
||||
margin: 8px 0 3px;
|
||||
}
|
||||
|
||||
.changelog :deep(h4:first-child) {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.changelog :deep(ul) {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 1px 0;
|
||||
}
|
||||
|
||||
.changelog :deep(li) {
|
||||
position: relative;
|
||||
padding-left: 14px;
|
||||
margin: 1px 0;
|
||||
}
|
||||
|
||||
.changelog :deep(li::before) {
|
||||
content: '·';
|
||||
position: absolute;
|
||||
left: 4px;
|
||||
color: var(--color-text-4);
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.changelog :deep(code) {
|
||||
background: var(--color-fill-3);
|
||||
padding: 0 4px;
|
||||
border-radius: 3px;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.changelog :deep(p) {
|
||||
margin: 2px 0;
|
||||
}
|
||||
|
||||
.download-progress {
|
||||
margin-top: 16px;
|
||||
padding: 16px;
|
||||
|
||||
@@ -15,7 +15,7 @@ interface TabConfig {
|
||||
/**
|
||||
* 应用配置类型
|
||||
*/
|
||||
interface AppConfig {
|
||||
export interface AppConfig {
|
||||
tabs: TabConfig[]
|
||||
visibleTabs: string[]
|
||||
defaultTab: string
|
||||
|
||||
@@ -115,6 +115,8 @@ export interface ToolbarConfig {
|
||||
sortBy: string
|
||||
/** 排序方向 */
|
||||
sortOrder: string
|
||||
/** 搜索关键词 */
|
||||
searchKeyword: string
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -199,6 +201,8 @@ export interface FileEditorPanelConfig {
|
||||
currentFileExtension: string
|
||||
/** 是否为二进制文件 */
|
||||
isBinaryFile: boolean
|
||||
/** 文件修改时间(用于检测外部变更) */
|
||||
fileMtime: string
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -10,4 +10,7 @@ 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 动态导入,避免全量打包
|
||||
|
||||
@@ -38,6 +38,21 @@ export const escapeHtml = (str) => {
|
||||
.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 - 文件路径
|
||||
|
||||
Reference in New Issue
Block a user