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