Private
Public Access
1
0

重构:Wails升级/mermaid主题切换/代码高亮修复/文件系统UI重构

- Wails v2.12.0升级(App绑定新增API、runtime类型扩展)
- 修复mermaid暗色主题切换渲染失败(SVG textContent污染→data-mermaid-src保存源码)
- 修复代码高亮全语言失效(languageMap静态白名单替代运行时hljs检查)
- 文件系统:FileListPanel重写、FileItemRow合并删除、Toolbar简化
- 新增剪贴板图片粘贴(Ctrl+V粘贴图片到当前目录)
- 死代码清理:DeviceTest/errorHandler/useLocalStorage移除
- MarkdownEditor优化、theme store增强、CodeMirror加载器精简
This commit is contained in:
2026-04-11 16:46:43 +08:00
parent efc042fcd3
commit 7dbd57a8b6
40 changed files with 1274 additions and 1404 deletions

67
app.go
View File

@@ -6,11 +6,13 @@ import (
"net/http" "net/http"
"os" "os"
"path/filepath" "path/filepath"
"regexp"
stdruntime "runtime" stdruntime "runtime"
"strings" "strings"
"time" "time"
"github.com/jung-kurt/gofpdf" "github.com/jung-kurt/gofpdf"
"golang.org/x/sys/windows/registry"
"u-desk/internal/api" "u-desk/internal/api"
"u-desk/internal/common" "u-desk/internal/common"
"u-desk/internal/database" "u-desk/internal/database"
@@ -22,6 +24,9 @@ import (
"github.com/wailsapp/wails/v2/pkg/runtime" "github.com/wailsapp/wails/v2/pkg/runtime"
) )
// PDF 有序列表正则(包级变量,避免循环内重复编译)
var orderedListRe = regexp.MustCompile(`^\d+\.\s+`)
// App 应用结构体 // App 应用结构体
type App struct { type App struct {
ctx context.Context ctx context.Context
@@ -37,6 +42,10 @@ type App struct {
isAlwaysOnTop bool isAlwaysOnTop bool
} }
// App 方法命名约定:
// - 多参数操作 → XxxRequest 结构体Wails 自动生成 TS 类型)
// - 单参数查询/简单操作 → 直接参数
// NewApp 创建新的应用实例 // NewApp 创建新的应用实例
func NewApp() *App { func NewApp() *App {
return &App{} return &App{}
@@ -286,6 +295,17 @@ func (a *App) WriteFile(req WriteFileRequest) error {
return a.filesystem.WriteFile(req.Path, req.Content) return a.filesystem.WriteFile(req.Path, req.Content)
} }
// SaveBase64FileRequest 保存 Base64 编码的二进制文件
type SaveBase64FileRequest struct {
Path string `json:"path"`
Content string `json:"content"` // base64 编码的文件内容
}
// SaveBase64File 将 base64 内容解码后写入文件(用于图片等二进制数据)
func (a *App) SaveBase64File(req SaveBase64FileRequest) error {
return a.filesystem.SaveBase64File(req.Path, req.Content)
}
// ListDir 列出目录 // ListDir 列出目录
func (a *App) ListDir(path string) ([]map[string]interface{}, error) { func (a *App) ListDir(path string) ([]map[string]interface{}, error) {
return a.filesystem.ListDir(path) return a.filesystem.ListDir(path)
@@ -393,6 +413,31 @@ func (a *App) ResolveShortcut(lnkPath string) (map[string]interface{}, error) {
}, nil }, nil
} }
// getWindowsSpecialFolder 从注册表读取 Windows 特殊文件夹的真实路径
// 用户可通过系统设置修改下载/桌面/文档等目录位置,注册表记录实际路径
func getWindowsSpecialFolder(guid string, fallbackName string) string {
key, err := registry.OpenKey(registry.CURRENT_USER,
`Software\Microsoft\Windows\CurrentVersion\Explorer\User Shell Folders`,
registry.READ)
if err != nil {
return ""
}
defer key.Close()
val, _, err := key.GetStringValue(guid)
if err != nil || val == "" {
return ""
}
// 展开 %USERPROFILE% 等环境变量
path := os.ExpandEnv(val)
// 验证路径存在
if _, err := os.Stat(path); err != nil {
return ""
}
return path
}
// GetCommonPaths 获取常用系统路径 // GetCommonPaths 获取常用系统路径
func (a *App) GetCommonPaths() (map[string]string, error) { func (a *App) GetCommonPaths() (map[string]string, error) {
homeDir, err := os.UserHomeDir() homeDir, err := os.UserHomeDir()
@@ -401,10 +446,22 @@ func (a *App) GetCommonPaths() (map[string]string, error) {
} }
paths := map[string]string{ paths := map[string]string{
"home": homeDir, "home": homeDir,
"desktop": filepath.Join(homeDir, "Desktop"), }
"documents": filepath.Join(homeDir, "Documents"),
"downloads": filepath.Join(homeDir, "Downloads"), // Windows: 从注册表读取特殊文件夹真实路径(用户可能已修改位置)
folderGUIDs := map[string]string{
"desktop": "{B4BFCC3A-DB2C-424C-B029-7FE99A87C641}",
"documents": "{D20B4C7F-5EA7-40D4B25E-039F6F1FCC8A}",
"downloads": "{374DE290-123F-4565-9164-39C4925E467B}",
}
for name, guid := range folderGUIDs {
if p := getWindowsSpecialFolder(guid, name); p != "" {
paths[name] = p
} else {
// folderGUIDs 的 key 均为 ASCII无需 Unicode 处理
paths[name] = filepath.Join(homeDir, strings.ToUpper(name[:1])+name[1:])
}
} }
// Windows: 动态添加所有盘符 // Windows: 动态添加所有盘符
@@ -978,7 +1035,7 @@ func (a *App) ExportMarkdownToPDF(markdownContent string) (string, error) {
pdf.Cell(10, 7, "•") pdf.Cell(10, 7, "•")
pdf.Cell(0, 7, strings.TrimPrefix(line, "- ")) pdf.Cell(0, 7, strings.TrimPrefix(line, "- "))
pdf.Ln(7) pdf.Ln(7)
} else if strings.HasPrefix(line, "1. ") || strings.HasPrefix(line, "2. ") || strings.HasPrefix(line, "3. ") { } else if orderedListRe.MatchString(line) {
// 有序列表 // 有序列表
pdf.SetFont("Arial", "", 12) pdf.SetFont("Arial", "", 12)
pdf.Cell(10, 7, strings.TrimSpace(strings.SplitN(line, ".", 2)[0]) + ".") pdf.Cell(10, 7, strings.TrimSpace(strings.SplitN(line, ".", 2)[0]) + ".")

5
go.mod
View File

@@ -10,15 +10,17 @@ require (
github.com/jung-kurt/gofpdf v1.16.2 github.com/jung-kurt/gofpdf v1.16.2
github.com/redis/go-redis/v9 v9.17.3 github.com/redis/go-redis/v9 v9.17.3
github.com/shirou/gopsutil/v3 v3.24.5 github.com/shirou/gopsutil/v3 v3.24.5
github.com/wailsapp/wails/v2 v2.11.0 github.com/wailsapp/wails/v2 v2.12.0
github.com/yuin/goldmark v1.8.2 github.com/yuin/goldmark v1.8.2
go.mongodb.org/mongo-driver/v2 v2.5.0 go.mongodb.org/mongo-driver/v2 v2.5.0
golang.org/x/sys v0.40.0
gorm.io/driver/mysql v1.6.0 gorm.io/driver/mysql v1.6.0
gorm.io/gorm v1.31.1 gorm.io/gorm v1.31.1
) )
require ( require (
filippo.io/edwards25519 v1.1.0 // indirect filippo.io/edwards25519 v1.1.0 // indirect
git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3 // indirect
github.com/bep/debounce v1.2.1 // indirect github.com/bep/debounce v1.2.1 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/chromedp/sysutil v1.1.0 // indirect github.com/chromedp/sysutil v1.1.0 // indirect
@@ -70,7 +72,6 @@ require (
golang.org/x/exp v0.0.0-20260112195511-716be5621a96 // indirect golang.org/x/exp v0.0.0-20260112195511-716be5621a96 // indirect
golang.org/x/net v0.49.0 // indirect golang.org/x/net v0.49.0 // indirect
golang.org/x/sync v0.19.0 // indirect golang.org/x/sync v0.19.0 // indirect
golang.org/x/sys v0.40.0 // indirect
golang.org/x/text v0.33.0 // indirect golang.org/x/text v0.33.0 // indirect
modernc.org/libc v1.67.7 // indirect modernc.org/libc v1.67.7 // indirect
modernc.org/mathutil v1.7.1 // indirect modernc.org/mathutil v1.7.1 // indirect

6
go.sum
View File

@@ -1,5 +1,7 @@
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3 h1:N3IGoHHp9pb6mj1cbXbuaSXV/UMKwmbKLf53nQmtqMA=
git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3/go.mod h1:QtOLZGz8olr4qH2vWK0QH0w0O4T9fEIjMuWpKUsH7nc=
github.com/bep/debounce v1.2.1 h1:v67fRdBA9UQu2NhLFXrSg0Brw7CexQekrBwDMM8bzeY= github.com/bep/debounce v1.2.1 h1:v67fRdBA9UQu2NhLFXrSg0Brw7CexQekrBwDMM8bzeY=
github.com/bep/debounce v1.2.1/go.mod h1:H8yggRPQKLUhUoqrJC1bO2xNya7vanpDl7xR3ISbCJ0= github.com/bep/debounce v1.2.1/go.mod h1:H8yggRPQKLUhUoqrJC1bO2xNya7vanpDl7xR3ISbCJ0=
github.com/boombuler/barcode v1.0.0/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8= github.com/boombuler/barcode v1.0.0/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8=
@@ -134,8 +136,8 @@ github.com/wailsapp/go-webview2 v1.0.23 h1:jmv8qhz1lHibCc79bMM/a/FqOnnzOGEisLav+
github.com/wailsapp/go-webview2 v1.0.23/go.mod h1:qJmWAmAmaniuKGZPWwne+uor3AHMB5PFhqiK0Bbj8kc= github.com/wailsapp/go-webview2 v1.0.23/go.mod h1:qJmWAmAmaniuKGZPWwne+uor3AHMB5PFhqiK0Bbj8kc=
github.com/wailsapp/mimetype v1.4.1 h1:pQN9ycO7uo4vsUUuPeHEYoUkLVkaRntMnHJxVwYhwHs= github.com/wailsapp/mimetype v1.4.1 h1:pQN9ycO7uo4vsUUuPeHEYoUkLVkaRntMnHJxVwYhwHs=
github.com/wailsapp/mimetype v1.4.1/go.mod h1:9aV5k31bBOv5z6u+QP8TltzvNGJPmNJD4XlAL3U+j3o= github.com/wailsapp/mimetype v1.4.1/go.mod h1:9aV5k31bBOv5z6u+QP8TltzvNGJPmNJD4XlAL3U+j3o=
github.com/wailsapp/wails/v2 v2.11.0 h1:seLacV8pqupq32IjS4Y7V8ucab0WZwtK6VvUVxSBtqQ= github.com/wailsapp/wails/v2 v2.12.0 h1:BHO/kLNWFHYjCzucxbzAYZWUjub1Tvb4cSguQozHn5c=
github.com/wailsapp/wails/v2 v2.11.0/go.mod h1:jrf0ZaM6+GBc1wRmXsM8cIvzlg0karYin3erahI4+0k= github.com/wailsapp/wails/v2 v2.12.0/go.mod h1:mo1bzK1DEJrobt7YrBjgxvb5Sihb1mhAY09hppbibQg=
github.com/xdg-go/pbkdf2 v1.0.0 h1:Su7DPu48wXMwC3bs7MCNG+z4FhcyEuz5dlvchbq0B0c= github.com/xdg-go/pbkdf2 v1.0.0 h1:Su7DPu48wXMwC3bs7MCNG+z4FhcyEuz5dlvchbq0B0c=
github.com/xdg-go/pbkdf2 v1.0.0/go.mod h1:jrpuAogTd400dnrH08LKmI/xc1MbPOebTwRqcT5RDeI= github.com/xdg-go/pbkdf2 v1.0.0/go.mod h1:jrpuAogTd400dnrH08LKmI/xc1MbPOebTwRqcT5RDeI=
github.com/xdg-go/scram v1.2.0 h1:bYKF2AEwG5rqd1BumT4gAnvwU/M9nBp2pTSxeZw7Wvs= github.com/xdg-go/scram v1.2.0 h1:bYKF2AEwG5rqd1BumT4gAnvwU/M9nBp2pTSxeZw7Wvs=

View File

@@ -41,8 +41,14 @@ var (
es6ImportFromRegex = regexp.MustCompile(`import\s+([\s\S]*?)\s+from\s+["']([^"']+)["']`) es6ImportFromRegex = regexp.MustCompile(`import\s+([\s\S]*?)\s+from\s+["']([^"']+)["']`)
es6DynamicImport = regexp.MustCompile(`import\s*\(\s*["']([^"']+)["']\s*\)`) es6DynamicImport = regexp.MustCompile(`import\s*\(\s*["']([^"']+)["']\s*\)`)
es6BareImport = regexp.MustCompile(`(?m)^\s*import\s+["']([^"']+)["']`) es6BareImport = regexp.MustCompile(`(?m)^\s*import\s+["']([^"']+)["']`)
// HTML 预览路径修复
locationPathRegex = regexp.MustCompile(`\blocation\.pathname\b`)
) )
// HTML 属性正则缓存(避免 replaceHtmlTagAttribute 中重复编译)
var attrRegexCache sync.Map // map[string]*regexp.Regexp
// LocalFileServer 本地文件服务器(独立的 HTTP 服务器) // LocalFileServer 本地文件服务器(独立的 HTTP 服务器)
type LocalFileServer struct { type LocalFileServer struct {
server *http.Server server *http.Server
@@ -501,6 +507,11 @@ func handleHtmlPreviewRequest(w http.ResponseWriter, r *http.Request) {
// 解析参数 // 解析参数
filePath := r.URL.Query().Get("path") 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
}
theme := r.URL.Query().Get("theme") theme := r.URL.Query().Get("theme")
if theme == "" { if theme == "" {
theme = "light" theme = "light"
@@ -536,6 +547,12 @@ func handleHtmlPreviewRequest(w http.ResponseWriter, r *http.Request) {
// 转换资源路径(将相对路径和绝对路径都转换为完整的本地文件服务器 URL // 转换资源路径(将相对路径和绝对路径都转换为完整的本地文件服务器 URL
processedContent := transformHtmlResourcePaths(string(content), baseDir) processedContent := transformHtmlResourcePaths(string(content), baseDir)
// 修复 JS 中基于 location.pathname 的相对路径计算
// 预览模式下 location.pathname = "/localfs/html-preview",与实际文件路径不一致
// ⚠️ 会替换所有出现位置含JS字符串内HTML预览场景下可接受
correctPathname := `"/localfs/` + strings.ReplaceAll(baseDir, "\\", "/") + `/`
processedContent = locationPathRegex.ReplaceAllString(processedContent, correctPathname)
// 注入链接点击拦截脚本 // 注入链接点击拦截脚本
finalContent := injectLinkInterceptor(processedContent) finalContent := injectLinkInterceptor(processedContent)
@@ -616,8 +633,14 @@ func transformHtmlResourcePaths(htmlContent string, baseDir string) string {
// replaceHtmlTagAttribute 替换 HTML 标签中的属性路径 // replaceHtmlTagAttribute 替换 HTML 标签中的属性路径
func replaceHtmlTagAttribute(html string, pattern *regexp.Regexp, attrName string, baseDir string) string { func replaceHtmlTagAttribute(html string, pattern *regexp.Regexp, attrName string, baseDir string) string {
return pattern.ReplaceAllStringFunc(html, func(match string) string { return pattern.ReplaceAllStringFunc(html, func(match string) string {
// 提取属性值 // 提取属性值(使用缓存的正则)
attrRegex := regexp.MustCompile(fmt.Sprintf(`%s=["']([^"']+)["']`, attrName)) var attrRegex *regexp.Regexp
if v, ok := attrRegexCache.Load(attrName); ok {
attrRegex = v.(*regexp.Regexp)
} else {
attrRegex = regexp.MustCompile(fmt.Sprintf(`%s=["']([^"']+)["']`, attrName))
attrRegexCache.Store(attrName, attrRegex)
}
attrMatch := attrRegex.FindStringSubmatch(match) attrMatch := attrRegex.FindStringSubmatch(match)
if attrMatch == nil { if attrMatch == nil {
return match return match

View File

@@ -6,6 +6,7 @@ import (
"path/filepath" "path/filepath"
"runtime" "runtime"
"strings" "strings"
"sync"
) )
// PathValidator 路径验证器接口 // PathValidator 路径验证器接口
@@ -180,16 +181,25 @@ func (v *DefaultPathValidator) isSensitivePath(path string) bool {
return false return false
} }
// 默认路径验证器(缓存,避免每次调用重复初始化)
var (
defaultValidatorOnce sync.Once
defaultValidator PathValidator
)
func getDefaultValidator() PathValidator {
defaultValidatorOnce.Do(func() {
defaultValidator = NewPathValidator(DefaultConfig())
})
return defaultValidator
}
// isSafePath 兼容函数:保持向后兼容 // isSafePath 兼容函数:保持向后兼容
// 使用默认配置的路径验证器
func isSafePath(path string) bool { func isSafePath(path string) bool {
validator := NewPathValidator(DefaultConfig()) return getDefaultValidator().IsSafe(path)
return validator.IsSafe(path)
} }
// isSensitivePath 兼容函数:保持向后兼容 // isSensitivePath 兼容函数:保持向后兼容
// 使用默认配置检查敏感路径
func isSensitivePath(path string) bool { func isSensitivePath(path string) bool {
validator := NewPathValidator(DefaultConfig()) return getDefaultValidator().IsSensitive(path)
return validator.IsSensitive(path)
} }

View File

@@ -2,17 +2,22 @@ package filesystem
import ( import (
"context" "context"
"encoding/base64"
"errors"
"fmt" "fmt"
"os" "os"
"os/exec" "os/exec"
"path/filepath" "path/filepath"
"runtime" "runtime"
"strings"
"sync" "sync"
"time" "time"
"u-desk/internal/common" "u-desk/internal/common"
) )
const maxReadWriteSize = 10 * 1024 * 1024 // 10MB 读写上限
// FileOperationResult 文件操作结果 // FileOperationResult 文件操作结果
type FileOperationResult struct { type FileOperationResult struct {
Path string `json:"path"` Path string `json:"path"`
@@ -131,9 +136,8 @@ func (s *FileSystemService) ReadFile(path string) (string, error) {
if err != nil { if err != nil {
return "", fmt.Errorf("获取文件信息失败: %v", err) return "", fmt.Errorf("获取文件信息失败: %v", err)
} }
const maxReadSize = 10 * 1024 * 1024 // 10MB if info.Size() > maxReadWriteSize {
if info.Size() > maxReadSize { return "", fmt.Errorf("文件过大 (%.1f MB),超过读取上限 (%d MB)", float64(info.Size())/1024/1024, maxReadWriteSize/1024/1024)
return "", fmt.Errorf("文件过大 (%.1f MB),超过读取上限 (%d MB)", float64(info.Size())/1024/1024, maxReadSize/1024/1024)
} }
// 读取文件 // 读取文件
@@ -151,30 +155,43 @@ func (s *FileSystemService) Write(path, content string) error {
return s.WriteFile(path, content) return s.WriteFile(path, content)
} }
// WriteFile 写入文件 // writeFile 内部写入实现(路径验证+大小检查+写入+日志)
func (s *FileSystemService) WriteFile(path, content string) error { func (s *FileSystemService) writeFileWithLog(path string, data []byte) error {
// 路径验证
if err := s.validatePath(path); err != nil { if err := s.validatePath(path); err != nil {
return err return err
} }
// 创建目录
dir := filepath.Dir(path) dir := filepath.Dir(path)
if err := os.MkdirAll(dir, DefaultDirPermissions); err != nil { if err := os.MkdirAll(dir, DefaultDirPermissions); err != nil {
return fmt.Errorf("创建目录失败: %v", err) return fmt.Errorf("创建目录失败: %v", err)
} }
if len(data) > maxReadWriteSize {
// 写入文件 return fmt.Errorf("文件过大 (%.1f MB),超过写入上限 (%d MB)", float64(len(data))/1024/1024, maxReadWriteSize/1024/1024)
data := []byte(content) }
if err := os.WriteFile(path, data, DefaultFilePermissions); err != nil { if err := os.WriteFile(path, data, DefaultFilePermissions); err != nil {
s.logWrite(path, int64(len(data)), err) s.logWrite(path, int64(len(data)), err)
return fmt.Errorf("写入文件失败: %v", err) return fmt.Errorf("写入文件失败: %v", err)
} }
s.logWrite(path, int64(len(data)), nil) s.logWrite(path, int64(len(data)), nil)
return nil return nil
} }
// WriteFile 写入文件
func (s *FileSystemService) WriteFile(path, content string) error {
return s.writeFileWithLog(path, []byte(content))
}
// SaveBase64File 将 base64 编码内容解码后写入二进制文件
func (s *FileSystemService) SaveBase64File(path, base64Content string) error {
if strings.TrimSpace(base64Content) == "" {
return errors.New("base64 内容不能为空")
}
data, err := base64.StdEncoding.DecodeString(base64Content)
if err != nil {
return fmt.Errorf("base64 解码失败: %v", err)
}
return s.writeFileWithLog(path, data)
}
// List 列出目录内容(实现 FileService 接口) // List 列出目录内容(实现 FileService 接口)
func (s *FileSystemService) List(path string) ([]map[string]interface{}, error) { func (s *FileSystemService) List(path string) ([]map[string]interface{}, error) {
return s.ListDir(path) return s.ListDir(path)

View File

@@ -4,6 +4,7 @@
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>U-Desk</title> <title>U-Desk</title>
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>🖥️</text></svg>" />
</head> </head>
<body> <body>
<div id="app"></div> <div id="app"></div>

6
web/package-lock.json generated
View File

@@ -28,8 +28,6 @@
"@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",
"@types/highlight.js": "^9.12.4",
"@types/mermaid": "^9.1.0",
"highlight.js": "^11.11.1", "highlight.js": "^11.11.1",
"mammoth": "^1.11.0", "mammoth": "^1.11.0",
"marked": "^17.0.1", "marked": "^17.0.1",
@@ -39,6 +37,8 @@
"xlsx": "^0.18.5" "xlsx": "^0.18.5"
}, },
"devDependencies": { "devDependencies": {
"@types/highlight.js": "^9.12.4",
"@types/mermaid": "^9.1.0",
"@vitejs/plugin-vue": "^6.0.3", "@vitejs/plugin-vue": "^6.0.3",
"unplugin-auto-import": "^0.18.3", "unplugin-auto-import": "^0.18.3",
"unplugin-vue-components": "^0.27.4", "unplugin-vue-components": "^0.27.4",
@@ -1440,12 +1440,14 @@
"version": "9.12.4", "version": "9.12.4",
"resolved": "https://registry.npmmirror.com/@types/highlight.js/-/highlight.js-9.12.4.tgz", "resolved": "https://registry.npmmirror.com/@types/highlight.js/-/highlight.js-9.12.4.tgz",
"integrity": "sha512-t2szdkwmg2JJyuCM20e8kR2X59WCE5Zkl4bzm1u1Oukjm79zpbiAv+QjnwLnuuV0WHEcX2NgUItu0pAMKuOPww==", "integrity": "sha512-t2szdkwmg2JJyuCM20e8kR2X59WCE5Zkl4bzm1u1Oukjm79zpbiAv+QjnwLnuuV0WHEcX2NgUItu0pAMKuOPww==",
"dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/mermaid": { "node_modules/@types/mermaid": {
"version": "9.1.0", "version": "9.1.0",
"resolved": "https://registry.npmmirror.com/@types/mermaid/-/mermaid-9.1.0.tgz", "resolved": "https://registry.npmmirror.com/@types/mermaid/-/mermaid-9.1.0.tgz",
"integrity": "sha512-rc8QqhveKAY7PouzY/p8ljS+eBSNCv7o79L97RSub/Ic2SQ34ph1Ng3s8wFLWVjvaEt6RLOWtSCsgYWd95NY8A==", "integrity": "sha512-rc8QqhveKAY7PouzY/p8ljS+eBSNCv7o79L97RSub/Ic2SQ34ph1Ng3s8wFLWVjvaEt6RLOWtSCsgYWd95NY8A==",
"dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/trusted-types": { "node_modules/@types/trusted-types": {

View File

@@ -28,8 +28,6 @@
"@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",
"@types/highlight.js": "^9.12.4",
"@types/mermaid": "^9.1.0",
"highlight.js": "^11.11.1", "highlight.js": "^11.11.1",
"mammoth": "^1.11.0", "mammoth": "^1.11.0",
"marked": "^17.0.1", "marked": "^17.0.1",
@@ -39,6 +37,8 @@
"xlsx": "^0.18.5" "xlsx": "^0.18.5"
}, },
"devDependencies": { "devDependencies": {
"@types/highlight.js": "^9.12.4",
"@types/mermaid": "^9.1.0",
"@vitejs/plugin-vue": "^6.0.3", "@vitejs/plugin-vue": "^6.0.3",
"unplugin-auto-import": "^0.18.3", "unplugin-auto-import": "^0.18.3",
"unplugin-vue-components": "^0.27.4", "unplugin-vue-components": "^0.27.4",

View File

@@ -1 +1 @@
11e4d92d4ca3da6546d1516713da71a8 0e1fafcbb6b28922a38f6c5316932015

View File

@@ -168,6 +168,8 @@ onMounted(() => {
onUnmounted(() => { onUnmounted(() => {
document.removeEventListener('wheel', preventZoom) document.removeEventListener('wheel', preventZoom)
updateStore.removeEventListeners() updateStore.removeEventListeners()
// 兜底清除所有 Wails 事件监听器,防止泄漏
window.runtime?.EventsOffAll?.()
}) })
// 窗口控制方法 // 窗口控制方法

View File

@@ -12,7 +12,8 @@ import { debugError } from '@/utils/debugLog'
function transformFile(file: any): File { function transformFile(file: any): File {
return { return {
...file, ...file,
isDir: file.is_dir isDir: file.is_dir,
modified_time: file.mod_time
} }
} }
@@ -99,6 +100,22 @@ export async function writeFile(path: string, content: string): Promise<void> {
}) })
} }
/**
* 保存 Base64 编码的二进制文件(图片等)
*/
export async function saveBase64File(path: string, base64Content: string): Promise<void> {
if (!window.go?.main?.App?.SaveBase64File) {
throw new Error('SaveBase64File API 不可用')
}
if (!base64Content) {
throw new Error('无效的 base64 内容')
}
await window.go.main.App.SaveBase64File({
path: String(path),
content: base64Content
})
}
/** /**
* 删除文件或目录 * 删除文件或目录
*/ */

View File

@@ -105,4 +105,5 @@ export interface File {
size: number size: number
isDir: boolean isDir: boolean
modified?: string modified?: string
modified_time?: string
} }

View File

@@ -1,725 +0,0 @@
<template>
<div class="device-test">
<!-- 系统信息 -->
<a-card class="test-card" title="系统信息">
<a-space direction="vertical" :size="16" style="width: 100%">
<a-button type="primary" @click="refreshSystemInfo">刷新系统信息</a-button>
<a-row :gutter="16">
<a-col :span="8">
<a-card size="small" title="CPU 信息">
<div v-if="cpuInfo">
<p>核心数: {{ cpuInfo.cores }}</p>
<p>型号: {{ cpuInfo.model }}</p>
<p>使用率: {{ cpuInfo.usage }}</p>
</div>
<div v-else>加载中...</div>
</a-card>
</a-col>
<a-col :span="8">
<a-card size="small" title="内存信息">
<div v-if="memoryInfo">
<p>总内存: {{ memoryInfo.total_str }}</p>
<p>已用: {{ memoryInfo.used_str }}</p>
<p>可用: {{ memoryInfo.available_str }}</p>
<p>使用率: {{ memoryInfo.usage }}</p>
</div>
<div v-else>加载中...</div>
</a-card>
</a-col>
<a-col :span="8">
<a-card size="small" title="系统信息">
<div v-if="systemInfo">
<p>操作系统: {{ systemInfo.os }}</p>
<p>架构: {{ systemInfo.arch }}</p>
<p>主机名: {{ systemInfo.hostname }}</p>
<p>平台: {{ systemInfo.platform }}</p>
</div>
<div v-else>加载中...</div>
</a-card>
</a-col>
</a-row>
<a-card size="small" title="磁盘信息" v-if="diskInfo && diskInfo.length > 0">
<a-table
:columns="diskColumns"
:data="diskInfo"
:pagination="false"
size="small"
/>
</a-card>
</a-space>
</a-card>
<!-- 文件系统操作 -->
<a-card class="test-card" title="文件系统操作">
<a-space direction="vertical" :size="16" style="width: 100%">
<a-input-group>
<a-auto-complete
v-model="filePath"
:data="pathHistory"
placeholder="输入文件或目录路径"
style="flex: 1"
@select="onPathSelect"
/>
<a-button @click="browseDirectory">浏览</a-button>
<a-button type="primary" @click="listDirectory">列出目录</a-button>
</a-input-group>
<!-- 收藏的文件 -->
<a-card size="small" title="⭐ 收藏的文件" v-if="favoriteFiles.length > 0">
<a-space wrap>
<a-tag
v-for="fav in favoriteFiles"
:key="fav.path"
closable
@close="removeFavorite(fav.path)"
@click="openFavoriteFile(fav.path)"
style="cursor: pointer; margin-bottom: 4px"
>
<template #icon>
<span>{{ fav.isDir ? '📁' : '📄' }}</span>
</template>
{{ fav.name }}
</a-tag>
</a-space>
</a-card>
<!-- 文件列表和内容区域 -->
<div class="file-panels-container">
<!-- 文件列表面板 -->
<div
class="file-panel-left"
:style="{ width: filePanelWidth.left + '%' }"
>
<a-card size="small" title="文件列表">
<template #extra>
<span style="font-size: 12px; color: #999;">
宽度: {{ filePanelWidth.left.toFixed(1) }}%
</span>
</template>
<a-list
:data="fileList"
:loading="fileLoading"
style="max-height: 300px; overflow-y: auto"
>
<template #item="{ item }">
<a-list-item>
<a-list-item-meta>
<template #title>
<a-space>
<span>{{ item.is_dir ? '📁' : '📄' }}</span>
<a @click="selectFile(item.path)">{{ item.name }}</a>
<a-button
type="text"
size="small"
@click.stop="toggleFavorite(item)"
:style="{ color: isFavorite(item.path) ? '#ffcd00' : '' }"
>
{{ isFavorite(item.path) ? '⭐' : '☆' }}
</a-button>
</a-space>
</template>
<template #description>
<span v-if="!item.is_dir">大小: {{ formatBytes(item.size) }}</span>
<span>修改时间: {{ item.mod_time }}</span>
</template>
</a-list-item-meta>
</a-list-item>
</template>
</a-list>
</a-card>
</div>
<!-- 水平拖拽条 -->
<div
class="resize-handle-horizontal"
@mousedown="startHorizontalResize"
title="← 拖拽调整宽度 →"
>
<div class="resize-handle-bar-horizontal"></div>
<div class="resize-handle-bar-horizontal"></div>
</div>
<!-- 文件内容面板 -->
<div
class="file-panel-right"
:style="{ width: filePanelWidth.right + '%' }"
>
<a-card size="small" title="文件内容">
<template #extra>
<span style="font-size: 12px; color: #999;">
宽度: {{ filePanelWidth.right.toFixed(1) }}%
</span>
</template>
<a-space direction="vertical" :size="8" style="width: 100%">
<div
class="file-content-wrapper"
:style="{ height: fileContentHeight + 'px' }"
>
<a-textarea
v-model="fileContent"
class="file-content-textarea"
placeholder="文件内容将显示在这里"
/>
</div>
<div
class="resize-handle"
@mousedown="startResize"
title="拖拽调整高度"
>
<div class="resize-handle-bar"></div>
</div>
<a-space>
<a-button type="primary" @click="readFile" :loading="fileLoading">读取文件</a-button>
<a-button @click="writeFile" :loading="fileLoading" v-if="canSaveFile">写入文件</a-button>
<a-button danger @click="deleteFile" :loading="fileLoading">删除</a-button>
<a-button @click="clearContent" v-if="canClearContent">清空</a-button>
</a-space>
</a-space>
</a-card>
</div>
</div>
</a-space>
</a-card>
<!-- 环境变量 -->
<a-card class="test-card" title="环境变量">
<a-button @click="loadEnvVars" :loading="envLoading">加载环境变量</a-button>
<a-table
v-if="envVars"
:columns="envColumns"
:data="envTableData"
:pagination="{ pageSize: 20 }"
style="margin-top: 16px"
size="small"
/>
</a-card>
</div>
</template>
<script setup>
// 定义组件名称,用于 KeepAlive 缓存
defineOptions({
name: 'DeviceTest'
})
import {computed, onMounted, ref} from 'vue'
import {Message, Modal} from '@arco-design/web-vue'
import {
getSystemInfo,
getCPUInfo,
getMemoryInfo,
getDiskInfo,
getEnvVars,
listDir,
readFile as readFileApi
} from '@/api'
// 导入公共工具函数和常量
import { STORAGE_KEYS, DEFAULTS } from '@/utils/constants'
import { formatBytes, sortFileList } from '@/utils/fileUtils'
// 导入 composables
import { useFileOperations } from '@/composables/useFileOperations'
import { useFavoriteFiles } from '@/composables/useFavoriteFiles'
// ========== 使用 Composables ==========
// 文件操作
const {
filePath,
fileContent,
fileList,
fileLoading,
writeFile,
deleteFile,
} = useFileOperations({
onSuccess: (operation, data) => {
// 成功回调
},
onError: (operation, error) => {
console.error(`[DeviceTest] ${operation} 失败:`, error)
}
})
// 收藏功能
const {
favoriteFiles,
isFavorite,
toggleFavorite,
removeFavorite,
} = useFavoriteFiles(STORAGE_KEYS.DEVICE_TEST.FAVORITE_FILES)
// localStorage管理
const fileContentHeight = ref(DEFAULTS.DEFAULT_CONTENT_HEIGHT)
const filePanelWidth = ref({ left: 50, right: 50 })
const pathHistory = ref([])
// 从 localStorage 恢复
try {
const h = localStorage.getItem(STORAGE_KEYS.DEVICE_TEST.FILE_CONTENT_HEIGHT)
if (h) fileContentHeight.value = JSON.parse(h)
const w = localStorage.getItem(STORAGE_KEYS.DEVICE_TEST.PANEL_WIDTH)
if (w) filePanelWidth.value = JSON.parse(w)
const p = localStorage.getItem(STORAGE_KEYS.DEVICE_TEST.PATH_HISTORY)
if (p) pathHistory.value = JSON.parse(p)
} catch (e) {
console.error('[DeviceTest] 加载 localStorage 失败:', e)
}
// ========== 立即清理旧的文件内容缓存 ==========
// 在组件初始化之前清理,防止加载大文件导致空白
try {
const oldContent = localStorage.getItem(STORAGE_KEYS.DEVICE_TEST.FILE_CONTENT)
if (oldContent) {
localStorage.removeItem(STORAGE_KEYS.DEVICE_TEST.FILE_CONTENT)
}
} catch (error) {
console.error('[DeviceTest] 清理缓存失败:', error)
}
// ========== DeviceTest 特有功能 ==========
const systemInfo = ref(null)
const cpuInfo = ref(null)
const memoryInfo = ref(null)
const diskInfo = ref(null)
const envVars = ref(null)
const envLoading = ref(false)
const isBinaryFile = ref(false) // 是否为二进制文件信息展示
const diskColumns = [
{title: '设备', dataIndex: 'device', width: 120},
{title: '挂载点', dataIndex: 'mountpoint', width: 200},
{title: '总容量', dataIndex: 'total_str', width: 100},
{title: '已用', dataIndex: 'used_str', width: 100},
{title: '可用', dataIndex: 'free_str', width: 100},
{title: '使用率', dataIndex: 'usage', width: 80}
]
const envColumns = [
{title: '变量名', dataIndex: 'key', width: 200},
{title: '值', dataIndex: 'value'}
]
const envTableData = computed(() => {
if (!envVars.value) return []
return Object.keys(envVars.value).map(key => ({
key,
value: envVars.value[key]
}))
})
// ========== 系统信息功能 ==========
const refreshSystemInfo = async () => {
try {
systemInfo.value = await getSystemInfo()
cpuInfo.value = await getCPUInfo()
memoryInfo.value = await getMemoryInfo()
diskInfo.value = await getDiskInfo()
} catch (error) {
console.error('获取系统信息失败:', error)
Message.error('获取系统信息失败: ' + (error.message || error))
}
}
const loadEnvVars = async () => {
envLoading.value = true
try {
envVars.value = await getEnvVars()
} catch (error) {
console.error('加载环境变量失败:', error)
Message.error('加载环境变量失败: ' + (error.message || error))
} finally {
envLoading.value = false
}
}
// ========== 列出目录(重写以添加历史记录) ==========
const listDirectory = async () => {
if (!filePath.value) {
Message.error('请输入目录路径')
return
}
// 添加到历史记录
addToHistory(filePath.value)
fileLoading.value = true
try {
fileList.value = await listDir(filePath.value)
fileList.value = sortFileList(fileList.value)
} catch (error) {
console.error('列出目录失败:', error)
Message.error('列出目录失败: ' + (error.message || error))
} finally {
fileLoading.value = false
}
}
// ========== 路径操作 ==========
const onPathSelect = (value) => {
filePath.value = value
listDirectory()
}
const browseDirectory = () => {
const path = prompt('请输入目录路径(例如: C:\\Users')
if (path) {
filePath.value = path
listDirectory()
}
}
// ========== 路径历史记录 ==========
const addToHistory = (path) => {
if (!path || path.trim() === '') return
const index = pathHistory.value.indexOf(path)
if (index > -1) {
pathHistory.value.splice(index, 1)
}
pathHistory.value.unshift(path)
if (pathHistory.value.length > 20) {
pathHistory.value = pathHistory.value.slice(0, 20)
}
try {
localStorage.setItem(STORAGE_KEYS.DEVICE_TEST.PATH_HISTORY, JSON.stringify(pathHistory.value))
} catch (e) {
console.error('[DeviceTest] 保存路径历史失败:', e)
}
}
// ========== 文件选择(重写以添加历史记录) ==========
const selectFile = (path) => {
if (!path) return
filePath.value = path
addToHistory(path)
const item = fileList.value.find(f => f.path === path)
// 如果 fileList 为空或找不到该文件,尝试读取
if (!item) {
readFile()
return
}
if (item.is_dir) {
listDirectory()
} else {
readFile()
}
}
// ========== 文件读取(重写以跳过二进制文件) ==========
const readFile = async () => {
if (!filePath.value) {
Message.error('请输入文件路径')
return
}
addToHistory(filePath.value)
// 检查文件扩展名
const ext = filePath.value.split('.').pop()?.toLowerCase() || ''
const binaryExts = ['exe', 'dll', 'so', 'dylib', 'zip', 'rar', '7z', 'tar', 'gz', 'pdf', 'doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx', 'mp3', 'mp4', 'avi', 'mkv', 'png', 'jpg', 'jpeg', 'gif', 'bmp', 'ico']
if (binaryExts.includes(ext)) {
showBinaryFileInfo(ext)
return
}
fileLoading.value = true
isBinaryFile.value = false // 标记为文本文件
try {
const content = await readFileApi(filePath.value)
// 检查文件大小提高到2MB合理的大文件支持
const maxDisplaySize = 2 * 1024 * 1024 // 2MB
if (content.length > maxDisplaySize) {
fileContent.value = content.substring(0, maxDisplaySize) + '\n\n... (文件过大,已截断,仅显示前 2MB) ...'
// 大文件警告改为控制台日志
console.warn(`文件过大 (${(content.length / 1024).toFixed(2)} KB),已截断显示`)
} else {
fileContent.value = content
}
// 文件读取成功,静默无提示
} catch (error) {
Message.error('读取文件失败: ' + error.message)
} finally {
fileLoading.value = false
}
}
// ========== 显示二进制文件信息 ==========
const showBinaryFileInfo = (ext) => {
const file = fileList.value.find(f => f.path === filePath.value)
if (!file) {
Message.warning('无法找到文件信息')
return
}
// 设置为二进制文件信息展示状态
isBinaryFile.value = true
const extDisplay = ext.toUpperCase()
const sizeDisplay = formatBytes(file.size)
// 判断文件类型
let fileType = '二进制文件'
if (['png', 'jpg', 'jpeg', 'gif', 'bmp', 'ico'].includes(ext)) fileType = '图片文件'
else if (['mp3', 'wav', 'flac'].includes(ext)) fileType = '音频文件'
else if (['mp4', 'avi', 'mkv', 'mov'].includes(ext)) fileType = '视频文件'
else if (['exe', 'dll', 'so'].includes(ext)) fileType = '可执行文件'
else if (['zip', 'rar', '7z', 'tar', 'gz'].includes(ext)) fileType = '压缩文件'
else if (['pdf', 'doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx'].includes(ext)) fileType = '文档文件'
fileContent.value = `╔════════════════════════════════════════════════════════════╗
║ 📄 ${fileType} - ${extDisplay}
╠════════════════════════════════════════════════════════════╣
║ ║
║ 📁 文件名: ${file.name.padEnd(40)}
║ 📂 完整路径: ${filePath.value}
║ ║
║ 📊 大小: ${sizeDisplay.padEnd(10)} (${file.size.toLocaleString()} 字节) ║
║ 📅 修改时间: ${file.mod_time}
║ 🏷️ 类型: ${fileType.padEnd(15)} (${extDisplay}) ║
║ ║
这是二进制文件,不支持文本预览 ║
║ 如需查看或编辑,请使用专门的工具 ║
║ ║
╚════════════════════════════════════════════════════════════╝`
Message.info(`已加载 ${fileType} 信息`)
}
// ========== 打开收藏的文件 ==========
const openFavoriteFile = (path) => {
filePath.value = path
addToHistory(path)
const fav = favoriteFiles.value.find(f => f.path === path)
if (fav && fav.isDir) {
listDirectory()
} else {
readFile()
}
}
// ========== 计算属性:按钮显示控制 ==========
// 是否可以保存文件(只有文本文件可以保存)
const canSaveFile = computed(() => {
return !isBinaryFile.value && fileContent.value !== ''
})
// 是否可以清空内容
const canClearContent = computed(() => {
return !isBinaryFile.value && fileContent.value !== ''
})
// ========== 拖拽调整高度 ==========
const startResize = (e) => {
const startY = e.clientY
const startHeight = fileContentHeight.value
const onMouseMove = (moveEvent) => {
const deltaY = moveEvent.clientY - startY
const newHeight = startHeight + deltaY
if (newHeight >= 100 && newHeight <= 800) {
fileContentHeight.value = newHeight
}
}
const onMouseUp = () => {
document.removeEventListener('mousemove', onMouseMove)
document.removeEventListener('mouseup', onMouseUp)
localStorage.setItem(
STORAGE_KEYS.DEVICE_TEST.FILE_CONTENT_HEIGHT,
fileContentHeight.value.toString()
)
}
document.addEventListener('mousemove', onMouseMove)
document.addEventListener('mouseup', onMouseUp)
}
// ========== 水平拖拽调整面板宽度 ==========
const startHorizontalResize = (e) => {
const container = e.target.closest('.file-panels-container')
if (!container) return
const startX = e.clientX
const containerWidth = container.offsetWidth
const startLeftWidth = (filePanelWidth.value.left / 100) * containerWidth
const onMouseMove = (moveEvent) => {
const deltaX = moveEvent.clientX - startX
const newLeftWidth = startLeftWidth + deltaX
const newLeftPercent = (newLeftWidth / containerWidth) * 100
if (newLeftPercent >= 20 && newLeftPercent <= 80) {
filePanelWidth.value.left = newLeftPercent
filePanelWidth.value.right = 100 - newLeftPercent
}
}
const onMouseUp = () => {
document.removeEventListener('mousemove', onMouseMove)
document.removeEventListener('mouseup', onMouseUp)
localStorage.setItem(
STORAGE_KEYS.DEVICE_TEST.PANEL_WIDTH,
JSON.stringify(filePanelWidth.value)
)
}
document.addEventListener('mousemove', onMouseMove)
document.addEventListener('mouseup', onMouseUp)
}
// ========== 初始化 ==========
onMounted(() => {
refreshSystemInfo()
})
</script>
<style scoped>
.device-test {
display: flex;
flex-direction: column;
gap: 16px;
}
.test-card {
margin-bottom: 16px;
}
/* 文件面板容器 */
.file-panels-container {
display: flex;
gap: 0;
align-items: stretch;
min-height: 400px;
}
/* 左侧面板 */
.file-panel-left {
flex-shrink: 0;
overflow: hidden;
}
/* 右侧面板 */
.file-panel-right {
flex-shrink: 0;
overflow: hidden;
}
/* 水平拖拽手柄 */
.resize-handle-horizontal {
width: 8px;
cursor: col-resize;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
background: transparent;
flex-shrink: 0;
position: relative;
z-index: 10;
margin-left: -4px;
margin-right: -4px;
}
.resize-handle-horizontal::before {
content: '';
position: absolute;
width: 3px;
height: 100%;
background: var(--color-border-2);
border-left: 1px solid var(--color-border-2);
border-right: 1px solid var(--color-border-2);
transition: all 0.2s;
}
.resize-handle-horizontal:hover::before {
background: var(--color-fill-2);
border-color: rgb(var(--primary-6));
}
.resize-handle-horizontal::after {
content: '';
position: absolute;
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
width: 2px;
height: 40px;
background: var(--color-border-3);
border-radius: 1px;
opacity: 0;
transition: opacity 0.2s;
}
.resize-handle-horizontal:hover::after {
opacity: 1;
}
/* 水平拖拽手柄的视觉指示条(已删除,改用 ::after 伪元素)*/
/* 文件内容区域容器 */
.file-content-wrapper {
position: relative;
overflow: hidden;
transition: height 0.1s ease;
}
/* 文件内容文本框 */
.file-content-textarea {
width: 100%;
height: 100%;
resize: none;
}
/* 拖拽手柄 */
.resize-handle {
height: 8px;
cursor: ns-resize;
display: flex;
align-items: center;
justify-content: center;
background: var(--color-fill-2);
border-radius: 4px;
margin: 4px 0;
transition: background 0.2s;
}
.resize-handle:hover {
background: var(--color-fill-3);
}
/* 拖拽手柄的视觉指示条 */
.resize-handle-bar {
width: 40px;
height: 3px;
background: var(--color-border-3);
border-radius: 2px;
}
.resize-handle:hover .resize-handle-bar {
background: rgb(var(--primary-6));
}
</style>

View File

@@ -349,7 +349,8 @@ import { Message } from '@arco-design/web-vue'
import { IconSave, IconEdit, IconEye, IconUndo, IconCopy, IconFilePdf, IconFullscreen, IconFullscreenExit } from '@arco-design/web-vue/es/icon' import { IconSave, IconEdit, IconEye, IconUndo, IconCopy, IconFilePdf, IconFullscreen, IconFullscreenExit } from '@arco-design/web-vue/es/icon'
import { getFileName, escapeHtml } from '@/utils/fileUtils' import { getFileName, escapeHtml } from '@/utils/fileUtils'
import type { FileEditorPanelConfig } from '@/types/file-system' import type { FileEditorPanelConfig } from '@/types/file-system'
import { renderMermaidDiagrams } from '@/utils/markedExtensions' import { renderMermaidDiagrams, rerenderMermaidDiagrams } from '@/utils/markedExtensions'
import { useThemeStore } from '@/stores/theme'
import { previewExcel, previewWord, previewCsv } from '@/utils/filePreviewHandlers' import { previewExcel, previewWord, previewCsv } from '@/utils/filePreviewHandlers'
// 异步加载 CodeEditor 组件,减少初始包大小 // 异步加载 CodeEditor 组件,减少初始包大小
@@ -589,6 +590,18 @@ watch(() => props.config.isEditMode, async (newVal, oldVal) => {
} }
}) })
// 监听主题变化,重新渲染 mermaid 图表
const themeStore = useThemeStore()
watch(() => themeStore.isDark, async () => {
if (!props.config.isEditMode && markdownPreviewRef.value) {
try {
// 等 DOM 更新完成后再重新渲染
await nextTick()
await rerenderMermaidDiagrams(markdownPreviewRef.value)
} catch { /* 忽略 */ }
}
})
// 监听 Excel 文件变化,触发预览 // 监听 Excel 文件变化,触发预览
watch(() => [props.config.isExcelFile, props.config.currentFileFullPath] as const, async ([isExcel, filePath]) => { watch(() => [props.config.isExcelFile, props.config.currentFileFullPath] as const, async ([isExcel, filePath]) => {
if (isExcel && filePath && excelPreviewRef.value) { if (isExcel && filePath && excelPreviewRef.value) {

View File

@@ -1,255 +0,0 @@
<template>
<div
class="file-item-row"
:class="{
'file-item-selected': isSelected,
'file-item-editing': isEditing
}"
:data-file-path="file.path"
@click="handleClick"
@dblclick="handleDoubleClick"
@contextmenu.prevent="handleContextMenu"
>
<!-- 文件图标 -->
<span class="file-item-icon">{{ icon }}</span>
<!-- 编辑状态 -->
<a-input
v-if="isEditing"
:model-value="editingName"
size="mini"
class="file-name-edit-input"
@update:model-value="handleNameUpdate"
@blur="handleSave"
@keyup.enter="handleSave"
@keyup.esc="handleCancel"
@click.stop
ref="inputRef"
/>
<!-- 正常显示状态 -->
<span v-else class="file-item-name" :title="file.name">{{ file.name }}</span>
<!-- 文件大小 -->
<span v-if="!file.isDir && !isEditing" class="file-item-size">
{{ formattedSize }}
</span>
<!-- 收藏按钮 -->
<a-button
v-if="!isEditing"
type="text"
size="mini"
@click.stop="handleToggleFavorite"
class="file-item-fav"
>
<template #icon>
<icon-star-fill v-if="isFavorited" :style="{ color: '#ffcd00' }" />
<icon-star v-else />
</template>
</a-button>
</div>
</template>
<script setup lang="ts">
import { ref, computed, nextTick, watch } from 'vue'
import { IconStar, IconStarFill } from '@arco-design/web-vue/es/icon'
import { formatBytes, getFileIcon } from '@/utils/fileUtils'
import type { FileItem } from '@/types/file-system'
// Props
interface Props {
file: FileItem
isSelected: boolean
isEditing: boolean
editingName?: string
isFavorited: boolean
}
const props = withDefaults(defineProps<Props>(), {
editingName: ''
})
// Emits
interface Emits {
(e: 'click', file: FileItem): void
(e: 'doubleClick', file: FileItem): void
(e: 'toggleFavorite', file: FileItem): void
(e: 'save', newName: string): void
(e: 'cancel'): void
(e: 'nameUpdate', newName: string): void
(e: 'contextMenu', event: MouseEvent, file: FileItem): void
}
const emit = defineEmits<Emits>()
// Refs
const inputRef = ref()
// 监听编辑状态变化,自动聚焦
watch(() => props.isEditing, (newVal) => {
if (newVal) {
nextTick(() => {
focusInput()
})
}
})
// 计算属性
const icon = computed(() => getFileIcon(props.file))
const formattedSize = computed(() => formatBytes(props.file.size))
// 事件处理
const handleClick = () => {
emit('click', props.file)
}
const handleDoubleClick = () => {
emit('doubleClick', props.file)
}
const handleToggleFavorite = () => {
emit('toggleFavorite', props.file)
}
const handleNameUpdate = (value: string) => {
emit('nameUpdate', value)
}
const handleSave = () => {
emit('save', props.editingName || props.file.name)
}
const handleCancel = () => {
emit('cancel')
}
const handleContextMenu = (event: MouseEvent) => {
emit('contextMenu', event, props.file)
}
// 聚焦到输入框并选中文本
const focusInput = () => {
const input = inputRef.value?.$el?.querySelector('input')
if (input) {
input.focus()
// 选中文件名部分(不包括扩展名)
const value = input.value
const lastDotIndex = value.lastIndexOf('.')
// 如果有扩展名,只选中文件名部分;否则选中全部
if (lastDotIndex > 0) {
input.setSelectionRange(0, lastDotIndex)
} else {
input.select()
}
}
}
// 暴露方法供父组件调用
const focus = () => {
nextTick(() => {
focusInput()
})
}
const selectAll = () => {
nextTick(() => {
const input = inputRef.value?.$el?.querySelector('input')
if (input) {
input.select()
}
})
}
defineExpose({
focus,
selectAll
})
</script>
<style scoped>
.file-item-row {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 12px;
cursor: pointer;
transition: background 0.15s;
border-bottom: 1px solid var(--color-border-2);
user-select: none;
}
.file-item-row:hover {
background: var(--color-fill-2);
}
.file-item-row.file-item-selected {
background: var(--color-fill-3) !important;
font-weight: 500;
}
.file-item-row:last-child {
border-bottom: none;
}
.file-item-row.file-item-editing {
background: var(--color-fill-1);
}
.file-item-icon {
font-size: 16px;
flex-shrink: 0;
width: 20px;
text-align: center;
}
.file-item-name {
flex: 1;
font-size: 13px;
color: var(--color-text-2);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
min-width: 0;
}
.file-item-size {
font-size: 11px;
color: var(--color-text-3);
flex-shrink: 0;
}
.file-item-fav {
flex-shrink: 0;
opacity: 0.6;
transition: opacity 0.2s;
}
.file-item-row:hover .file-item-fav {
opacity: 1;
}
.file-name-edit-input {
flex: 1;
font-size: 13px;
min-width: 0;
}
.file-name-edit-input :deep(.arco-input) {
font-size: 13px;
padding: 0 8px;
height: 24px;
line-height: 24px;
}
/* 编辑状态下的样式调整 */
.file-item-row.file-item-editing .file-item-fav {
display: none;
}
.file-item-row.file-item-editing .file-item-size {
display: none;
}
</style>

View File

@@ -2,40 +2,62 @@
<div class="file-list-panel" :style="{ width: width + '%' }"> <div class="file-list-panel" :style="{ width: width + '%' }">
<div class="panel-header"> <div class="panel-header">
<span class="panel-title">📋 文件列表</span> <span class="panel-title">📋 文件列表</span>
<span class="panel-count">{{ config.fileList.length }} </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-button size="mini" type="text" class="settings-btn">
<icon-more />
</a-button>
<template #content>
<div class="col-setting-title">列设置</div>
<div class="col-setting-item" style="cursor: default;">
<span class="drag-handle"></span>
<a-checkbox :model-value="showHeader" @change="(val: boolean) => { showHeader = val; localStorage.setItem(SHOW_HEADER_KEY, String(val)) }">
显示表头
</a-checkbox>
</div>
<div
v-for="(col, idx) in orderedColumns"
:key="col.key"
class="col-setting-item"
draggable="true"
@dragstart="onDragStart($event, idx)"
@dragover.prevent
@drop="onDrop($event, idx)"
>
<span class="drag-handle"></span>
<a-checkbox
:model-value="col.visible"
:disabled="col.key === 'name' && visibleCount <= 1"
@change="(val: boolean) => toggleColumn(col.key, val)"
>{{ col.label }}</a-checkbox>
</div>
</template>
</a-popover>
</div>
</div> </div>
<div <div
class="file-list-wrapper" class="file-list-wrapper"
@contextmenu.prevent="handleContextMenu" @contextmenu.prevent="handleWrapperContextMenu"
> >
<!-- 文件列表 --> <!-- 文件列表a-table -->
<a-list <a-table
v-if="config.fileList.length > 0 || config.fileLoading" v-if="config.fileList.length > 0 || config.fileLoading"
:columns="tableColumns"
:data="config.fileList" :data="config.fileList"
:loading="config.fileLoading" :loading="config.fileLoading"
:bordered="false"
:pagination="false" :pagination="false"
class="compact-list" :bordered="false"
> :show-header="showHeader"
<template #item="{ item }"> size="mini"
<FileItemRow :row-class-name="getRowClassName"
:file="item" :scroll="{ y: 'auto' }"
:is-selected="isSelected(item)" class="file-table"
:is-editing="isEditing(item)" @row-click="handleRowClick"
:editing-name="props.config.editingFileName" @row-dblclick="handleRowDoubleClick"
:is-favorited="isFavorited(item.path)" @row-contextmenu="handleRowContextMenu"
@click="handleFileClick" />
@double-click="handleFileDoubleClick"
@toggle-favorite="handleToggleFavorite"
@save="handleSaveEditing"
@cancel="handleCancelEditing"
@name-update="handleNameUpdate"
@context-menu="handleItemContextMenu"
ref="fileItemRefs"
/>
</template>
</a-list>
<!-- 空状态 --> <!-- 空状态 -->
<div v-if="config.fileList.length === 0 && !config.fileLoading" class="empty-state"> <div v-if="config.fileList.length === 0 && !config.fileLoading" class="empty-state">
@@ -47,8 +69,11 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref } from 'vue' import { h, computed, nextTick, ref } from 'vue'
import FileItemRow from './FileItemRow.vue' import { Input, Button } from '@arco-design/web-vue'
import { IconStar, IconStarFill, IconSort, IconSortAscending, IconSortDescending, IconMore } from '@arco-design/web-vue/es/icon'
import { formatBytes, formatFileTime, getFileIcon, getExt } from '@/utils/fileUtils'
import { STORAGE_KEYS } from '@/utils/constants'
import type { FileListPanelConfig, FileItem } from '@/types/file-system' import type { FileListPanelConfig, FileItem } from '@/types/file-system'
// Props // Props
@@ -56,6 +81,8 @@ interface Props {
config: FileListPanelConfig config: FileListPanelConfig
width: number width: number
favorites: string[] favorites: string[]
sortBy: string
sortOrder: string
} }
const props = defineProps<Props>() const props = defineProps<Props>()
@@ -70,96 +97,258 @@ interface Emits {
(e: 'cancelEditing'): void (e: 'cancelEditing'): void
(e: 'contextMenu', event: MouseEvent, file: FileItem | null): void (e: 'contextMenu', event: MouseEvent, file: FileItem | null): void
(e: 'nameUpdate', newName: string): void (e: 'nameUpdate', newName: string): void
(e: 'sort', field: string): void
} }
const emit = defineEmits<Emits>() const emit = defineEmits<Emits>()
// Refs // ========== 列配置(支持显隐 + 排序) ==========
const fileItemRefs = ref() const COL_SETTINGS_KEY = STORAGE_KEYS.FILESYSTEM.COL_SETTINGS
const SHOW_HEADER_KEY = STORAGE_KEYS.FILESYSTEM.SHOW_HEADER
// 计算辅助方法 interface ColumnConfig {
const isSelected = (item: FileItem): boolean => { key: string
return props.config.selectedFileItem?.path === item.path label: string
visible: boolean
order: number
} }
const isEditing = (item: FileItem): boolean => { const defaultColumns: ColumnConfig[] = [
return props.config.editingFilePath === item.path { key: 'icon', label: '图标(T)', visible: true, order: 0 },
} { key: 'name', label: '名称', visible: true, order: 1 },
{ key: 'time', label: '时间', visible: true, order: 2 },
{ key: 'size', label: '大小', visible: true, order: 3 },
{ key: 'fav', label: '收藏', visible: true, order: 4 }
]
const isFavorited = (path: string): boolean => { // 从 localStorage 恢复或使用默认值(按 key 匹合,允许列数变化)
return props.favorites.includes(path) function loadColSettings(): ColumnConfig[] {
} try {
const saved = localStorage.getItem(COL_SETTINGS_KEY)
// 事件处理 if (saved) {
const handleFileClick = (file: FileItem) => { const parsed = JSON.parse(saved) as ColumnConfig[]
emit('fileClick', file) if (Array.isArray(parsed)) {
} // 以 defaultColumns 为基准,合并已保存的 visible/order
return defaultColumns.map((def, i) => {
const handleFileDoubleClick = (file: FileItem) => { const existing = parsed.find(p => p.key === def.key)
emit('fileDoubleClick', file) return existing ? { ...def, visible: existing.visible ?? true, order: existing.order ?? i } : { ...def }
} })
}
const handleToggleFavorite = (file: FileItem) => {
emit('toggleFavorite', file)
}
const handleNameUpdate = (newName: string) => {
emit('nameUpdate', newName)
}
const handleSaveEditing = (newName: string) => {
if (props.config.editingFilePath) {
emit('saveEditing', props.config.editingFilePath, newName)
}
}
const handleCancelEditing = () => {
emit('cancelEditing')
}
const handleItemContextMenu = (event: MouseEvent, file: FileItem) => {
emit('contextMenu', event, file)
}
const handleContextMenu = (event: MouseEvent) => {
// 检查点击的是哪个文件项
const target = event.target as HTMLElement
const listItem = target.closest('.arco-list-item')
if (listItem) {
// 找到对应的文件索引
const items = document.querySelectorAll('.arco-list-item')
const index = Array.from(items).indexOf(listItem)
if (index !== -1 && index < props.config.fileList.length) {
const clickedFile = props.config.fileList[index]
emit('contextMenu', event, clickedFile)
return
} }
} } catch { /* localStorage 不可用则使用默认列配置 */ }
return [...defaultColumns]
}
// 如果没有点击文件项,传递空白区域事件 const colSettings = ref<ColumnConfig[]>(loadColSettings())
const showHeader = ref(localStorage.getItem(SHOW_HEADER_KEY) !== 'false')
// 手动持久化(避免 deep watch 频繁写入)
function saveColSettings() {
localStorage.setItem(COL_SETTINGS_KEY, JSON.stringify(colSettings.value))
}
// 排序后的列配置
const orderedColumns = computed(() =>
[...colSettings.value].sort((a, b) => a.order - b.order)
)
// 可见列数量
const visibleCount = computed(() =>
colSettings.value.filter(c => c.visible).length
)
// 切换单列显隐
const toggleColumn = (key: string, visible: boolean) => {
const col = colSettings.value.find(c => c.key === key)
if (col) { col.visible = visible; saveColSettings() }
}
// HTML5 拖拽排序
const dragIdx = ref(-1)
const onDragStart = (_e: DragEvent, idx: number) => { dragIdx.value = idx }
const onDrop = (_e: DragEvent, idx: number) => {
if (dragIdx.value === -1 || dragIdx.value === idx) return
const list = [...orderedColumns.value]
const [moved] = list.splice(dragIdx.value, 1)
list.splice(idx, 0, moved)
// 更新 order 值
list.forEach((c, i) => {
const target = colSettings.value.find(x => x.key === c.key)
if (target) target.order = i
})
dragIdx.value = -1
saveColSettings()
}
// 排序图标渲染
const sortIcon = (field: string) => {
if (props.sortBy !== field) return () => h(IconSort, { style: { fontSize: '12px', color: 'var(--color-text-4)' } })
return () => props.sortOrder === 'asc'
? h(IconSortAscending, { style: { fontSize: '12px', color: 'rgb(var(--primary-6))' } })
: h(IconSortDescending, { style: { fontSize: '12px', color: 'rgb(var(--primary-6))' } })
}
// 根据配置构建单列定义
function buildColumn(key: string, editPath: string | undefined) {
switch (key) {
case 'icon':
return {
title: () => h('div', {
class: 'th-sortable th-sort-center',
onClick: () => emit('sort', 'type')
}, [
h('span', { style: { fontWeight: 600, fontSize: '11px', marginRight: '2px' } }, 'T'),
sortIcon('type')()
]),
width: 32,
bodyCellClass: 'col-icon',
render: ({ record }: { record: FileItem }) => {
const ext = getExt(record.name)
return h('span', {
class: 'file-item-icon',
title: ext ? `.${ext.toUpperCase()} : ${record.name}` : record.name
}, getFileIcon(record))
}
}
case 'name':
return {
title: () => h('div', {
class: 'th-sortable',
onClick: () => emit('sort', 'name')
}, [
h('span', { style: { fontWeight: 600 } }, '名称'),
sortIcon('name')()
]),
dataIndex: 'name',
ellipsis: true,
render: ({ record }: { record: FileItem }) => {
const isEditing = editPath === record.path
if (isEditing) {
return h(Input, {
modelValue: props.config.editingFileName || record.name,
size: 'mini',
class: 'file-name-edit-input',
'onUpdate:modelValue': (val: string) => emit('nameUpdate', val),
onBlur: () => emit('saveEditing', editPath!, props.config.editingFileName || record.name),
onKeyup: (ev: KeyboardEvent) => {
if (ev.key === 'Enter') emit('saveEditing', editPath!, props.config.editingFileName || record.name)
else if (ev.key === 'Escape') emit('cancelEditing')
},
onClick: (ev: Event) => ev.stopPropagation()
})
}
return h('span', { class: 'file-item-name', title: record.name }, record.name)
}
}
case 'time':
return {
title: () => h('div', {
class: 'th-sortable th-sort-right',
onClick: () => emit('sort', 'modified_time')
}, [
h('span', { style: { fontWeight: 600 } }, '时间'),
sortIcon('modified_time')()
]),
dataIndex: 'modified_time',
width: 125,
align: 'right' as const,
render: ({ record }: { record: FileItem }) => {
if (editPath === record.path || !record.modified_time) return null
return h('span', { class: 'file-item-time' }, formatFileTime(record.modified_time))
}
}
case 'size':
return {
title: () => h('div', {
class: 'th-sortable th-sort-right',
onClick: () => emit('sort', 'size')
}, [
h('span', { style: { fontWeight: 600 } }, '大小'),
sortIcon('size')()
]),
dataIndex: 'size',
width: 70,
align: 'right' as const,
render: ({ record }: { record: FileItem }) => {
if (record.isDir || editPath === record.path) return null
return h('span', { class: 'file-item-size' }, formatBytes(record.size))
}
}
case 'fav':
return {
title: '',
width: 28,
render: ({ record }: { record: FileItem }) => {
if (editPath === record.path) return null
const favorited = props.favorites.includes(record.path)
return h(Button, {
type: 'text',
size: 'mini',
class: 'file-item-fav',
onClick: (ev: Event) => { ev.stopPropagation(); emit('toggleFavorite', record) }
}, {
icon: () => favorited
? h(IconStarFill, { style: { color: '#ffcd00' } })
: h(IconStar)
})
}
}
default:
return null
}
}
// ========== 动态表格列 ==========
const tableColumns = computed(() => {
const editPath = props.config.editingFilePath
return orderedColumns.value
.filter(c => c.visible)
.map(c => buildColumn(c.key, editPath))
.filter(Boolean)
})
// ========== 行事件处理 ==========
const handleRowClick = (record: FileItem, ev: Event) => {
const target = ev.target as HTMLElement
if (target.closest('.arco-btn') || target.closest('.arco-input-wrapper')) return
emit('fileClick', record)
}
const handleRowContextMenu = (record: FileItem, ev: Event) => {
ev.preventDefault()
emit('contextMenu', ev as MouseEvent, record)
}
const getRowClassName = (record: FileItem): string => [
props.config.selectedFileItem?.path === record.path && 'row-selected',
props.config.editingFilePath === record.path && 'row-editing'
].filter(Boolean).join(' ')
const handleWrapperContextMenu = (event: MouseEvent) => {
emit('contextMenu', event, null) emit('contextMenu', event, null)
} }
// 暴露方法供父组件调用
const focusEditingItem = () => { const focusEditingItem = () => {
const index = props.config.fileList.findIndex( nextTick(() => {
item => item.path === props.config.editingFilePath const input = document.querySelector('.file-table .file-name-edit-input input') as HTMLInputElement | null
) if (!input) return
if (index !== -1 && fileItemRefs.value?.[index]) { input.focus()
const item = fileItemRefs.value[index] const val = input.value
item.focus?.() const dot = val.lastIndexOf('.')
item.selectAll?.() input.setSelectionRange(0, dot > 0 ? dot : val.length)
} })
} }
defineExpose({ defineExpose({ focusEditingItem })
focusEditingItem
})
</script> </script>
<style scoped> <style scoped>
/* ====== 布局 ====== */
.file-list-panel { .file-list-panel {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -168,37 +357,164 @@ defineExpose({
} }
.panel-header { .panel-header {
padding: 10px 12px; padding: 6px 12px;
border-bottom: 1px solid var(--color-border); border-bottom: 1px solid var(--color-border);
background: var(--color-bg-2);
flex-shrink: 0;
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
background: var(--color-bg-2);
flex-shrink: 0;
} }
.panel-title { .panel-header-right {
font-size: 13px; display: flex;
font-weight: 600; align-items: center;
color: var(--color-text-1); gap: 8px;
} }
.panel-count { .panel-title { font-size: 13px; font-weight: 600; color: var(--color-text-1); }
font-size: 12px; .panel-count { font-size: 12px; color: var(--color-text-3); }
.settings-btn {
color: var(--color-text-3); color: var(--color-text-3);
padding: 2px 4px;
}
.settings-btn:hover {
color: var(--color-text-2);
} }
/* 滚动容器 */
.file-list-wrapper { .file-list-wrapper {
flex: 1; flex: 1;
overflow-y: auto; overflow-y: auto;
padding: 4px; overflow-x: hidden;
padding: 0 2px;
} }
.compact-list :deep(.arco-list-item) { /* ====== Table 全局覆盖 ====== */
padding: 0; .file-table :deep(.arco-table) {
border: none; font-size: 13px;
table-layout: fixed;
} }
.file-table :deep(.arco-table-cell) {
padding: 5px 2px !important;
}
/* 表头样式 */
.file-table :deep(.arco-table-header) {
border-bottom: 1px solid var(--color-border);
}
.file-table :deep(.arco-table-th) {
font-size: 11px;
color: var(--color-text-3);
background: var(--color-bg-2);
font-weight: normal;
user-select: none;
white-space: nowrap;
}
/* 可排序列头 */
.file-table :deep(.th-sortable) {
display: inline-flex;
align-items: center;
gap: 2px;
cursor: pointer;
border-radius: 3px;
padding: 4px 8px;
transition: background 0.15s;
}
.file-table :deep(.th-sortable:hover) {
background: var(--color-fill-2);
}
.file-table :deep(.th-sort-right) { justify-content: flex-end; }
.file-table :deep(.th-sort-center) { justify-content: center; }
/* 表体行 */
.file-table :deep(.arco-table-tbody .arco-table-tr) {
cursor: pointer;
transition: background 0.15s;
}
.file-table :deep(.arco-table-tbody .arco-table-tr:hover:not(.row-selected)) {
background: var(--color-fill-2);
}
/* 数据单元格 */
.file-table :deep(.arco-table-td) {
border-bottom: 1px solid var(--color-border-2);
vertical-align: middle;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* 行状态 */
.file-table :deep(.arco-table-tr.row-selected) {
background: var(--color-fill-3) !important;
}
.file-table :deep(.arco-table-tr.row-selected .file-item-name) {
font-weight: 500;
}
.file-table :deep(.arco-table-tr.row-editing) {
background: var(--color-fill-1);
}
/* ====== 列内容 ====== */
.col-icon { text-align: center; vertical-align: middle !important; }
.file-item-icon {
font-size: 16px;
line-height: 1;
display: inline-block;
font-weight: 500;
}
.file-item-name { font-size: 13px; color: var(--color-text-2); }
.file-item-size,
.file-item-time { font-size: 11px; color: var(--color-text-3); }
/* 收藏星标 */
.file-item-fav { opacity: 0.5; transition: opacity 0.2s; }
.file-table :deep(.arco-table-tr:hover .file-item-fav) { opacity: 1; }
/* 编辑输入框 */
.file-name-edit-input :deep(.arco-input) {
font-size: 13px;
padding: 0 8px;
height: 24px;
line-height: 24px;
}
/* ====== 列设置面板 ====== */
.col-setting-title {
font-size: 12px;
font-weight: 600;
color: var(--color-text-2);
padding: 4px 8px 6px;
border-bottom: 1px solid var(--color-border-2);
margin-bottom: 2px;
}
.col-setting-item {
display: flex;
align-items: center;
gap: 4px;
padding: 3px 6px;
border-radius: 3px;
cursor: grab;
transition: background 0.15s;
}
.col-setting-item:active { cursor: grabbing; }
.col-setting-item:hover { background: var(--color-fill-1); }
.drag-handle {
color: var(--color-text-4);
font-size: 14px;
user-select: none;
flex-shrink: 0;
line-height: 1;
}
/* 空状态 */
.empty-state { .empty-state {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -208,8 +524,5 @@ defineExpose({
color: var(--color-text-3); color: var(--color-text-3);
gap: 8px; gap: 8px;
} }
.empty-state span:nth-child(2) { font-size: 14px; }
.empty-state span:nth-child(2) {
font-size: 14px;
}
</style> </style>

View File

@@ -109,7 +109,6 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue'
import { IconForward, IconHistory, IconRefresh, IconMenu, IconClose, IconRight, IconCopy } from '@arco-design/web-vue/es/icon' import { IconForward, IconHistory, IconRefresh, IconMenu, IconClose, IconRight, IconCopy } from '@arco-design/web-vue/es/icon'
import type { ToolbarConfig } from '@/types/file-system' import type { ToolbarConfig } from '@/types/file-system'
import PathBreadcrumb from './PathBreadcrumb.vue' import PathBreadcrumb from './PathBreadcrumb.vue'

View File

@@ -31,12 +31,14 @@
/> />
<!-- 文件列表和编辑器区域 --> <!-- 文件列表和编辑器区域 -->
<div class="file-workspace"> <div ref="workspaceRef" class="file-workspace">
<!-- 文件列表面板 --> <!-- 文件列表面板 -->
<FileListPanel <FileListPanel
:config="fileListPanelConfig" :config="fileListPanelConfig"
:width="panelWidth.left" :width="panelWidth.left"
:favorites="favoritePaths" :favorites="favoritePaths"
:sort-by="sortBy"
:sort-order="sortOrder"
@file-click="handleFileClick" @file-click="handleFileClick"
@file-double-click="handleFileDoubleClick" @file-double-click="handleFileDoubleClick"
@toggle-favorite="handleToggleFavorite" @toggle-favorite="handleToggleFavorite"
@@ -45,11 +47,12 @@
@cancel-editing="handleCancelEditing" @cancel-editing="handleCancelEditing"
@name-update="handleNameUpdate" @name-update="handleNameUpdate"
@context-menu="handleContextMenu" @context-menu="handleContextMenu"
@sort="setSort"
ref="fileListPanelRef" ref="fileListPanelRef"
/> />
<!-- 分隔条 --> <!-- 分隔条 -->
<div class="resizer" @mousedown="startResizeHorizontal"></div> <div class="resizer" @mousedown="handleHorizontalResize"></div>
<!-- 文件编辑器面板 --> <!-- 文件编辑器面板 -->
<FileEditorPanel <FileEditorPanel
@@ -102,7 +105,8 @@
import { ref, computed, onMounted, onUnmounted, watch, nextTick } from 'vue' import { ref, computed, onMounted, onUnmounted, watch, nextTick } from 'vue'
import { getPathSeparator } from '@/utils/fileUtils' import { getPathSeparator } from '@/utils/fileUtils'
import { Message, Modal } from '@arco-design/web-vue' import { Message, Modal } from '@arco-design/web-vue'
import { marked, renderMermaidDiagrams } from '@/utils/markedExtensions' import { marked, renderMermaidDiagrams, rerenderMermaidDiagrams } from '@/utils/markedExtensions'
import { useThemeStore } from '@/stores/theme'
// 导入子组件 // 导入子组件
import Toolbar from './components/Toolbar.vue' import Toolbar from './components/Toolbar.vue'
@@ -122,8 +126,9 @@ import { useCommonPaths } from './composables/useCommonPaths'
// 导入工具函数 // 导入工具函数
import { getFileName, sortFileList } from '@/utils/fileUtils' import { getFileName, sortFileList } from '@/utils/fileUtils'
import { isImageFile, isVideoFile, isAudioFile, isPdfFile, isHtmlFile, isMarkdownFile, isExcelFile, isWordFile, isCsvFile } from '@/utils/fileTypeHelpers' import { isImageFile, isVideoFile, isAudioFile, isPdfFile, isHtmlFile, isMarkdownFile, isExcelFile, isWordFile, isCsvFile } from '@/utils/fileTypeHelpers'
import { listDir } from '@/api/system' import { listDir, saveBase64File } from '@/api/system'
import { STORAGE_KEYS, DEFAULTS, UI_TEXT, VALIDATION_RULES, FILE_EXTENSIONS, FILE_SIZE_THRESHOLDS } from '@/utils/constants' import { STORAGE_KEYS, DEFAULTS, UI_TEXT, VALIDATION_RULES, FILE_EXTENSIONS, FILE_SIZE_THRESHOLDS } from '@/utils/constants'
import { createResizeHandler } from '@/utils/resize'
// 导入类型 // 导入类型
import type { FileItem, FavoriteFile, ContextMenuConfig, ShortcutPath } from '@/types/file-system' import type { FileItem, FavoriteFile, ContextMenuConfig, ShortcutPath } from '@/types/file-system'
@@ -148,6 +153,30 @@ const fileList = ref<FileItem[]>([])
const fileLoading = ref(false) const fileLoading = ref(false)
const selectedFileItem = ref<FileItem | null>(null) const selectedFileItem = ref<FileItem | null>(null)
// 排序状态(带 localStorage 持久化)
const SORT_STORAGE_KEY = STORAGE_KEYS.FILESYSTEM.SORT
type SortField = 'name' | 'size' | 'type' | 'modified_time'
const defaultSort: { sortBy: SortField; sortOrder: 'asc' | 'desc' } = { sortBy: 'name', sortOrder: 'asc' }
let savedSort: typeof defaultSort | null = null
try { savedSort = JSON.parse(localStorage.getItem(SORT_STORAGE_KEY) || '') } catch { /* localStorage 不可用则使用默认排序 */ }
const sortBy = ref<SortField>(savedSort?.sortBy || defaultSort.sortBy)
const sortOrder = ref(savedSort?.sortOrder || defaultSort.sortOrder)
const doSort = () => {
fileList.value = sortFileList(fileList.value, { sortBy: sortBy.value, sortOrder: sortOrder.value })
localStorage.setItem(SORT_STORAGE_KEY, JSON.stringify({ sortBy: sortBy.value, sortOrder: sortOrder.value }))
}
const setSort = (field: SortField) => {
if (sortBy.value === field) {
sortOrder.value = sortOrder.value === 'asc' ? 'desc' : 'asc'
} else {
sortBy.value = field
sortOrder.value = 'asc'
}
doSort()
}
// 导航锁:防止同时执行多个导航操作 // 导航锁:防止同时执行多个导航操作
const isNavigating = ref(false) const isNavigating = ref(false)
@@ -186,6 +215,7 @@ const savePanelWidth = (width: { left: number; right: number }) => {
} }
const panelWidth = ref(restorePanelWidth()) const panelWidth = ref(restorePanelWidth())
const workspaceRef = ref<HTMLElement | null>(null)
// 系统路径(使用 composable // 系统路径(使用 composable
const { commonPaths, systemPaths, loadCommonPaths } = useCommonPaths() const { commonPaths, systemPaths, loadCommonPaths } = useCommonPaths()
@@ -263,7 +293,9 @@ const toolbarConfig = computed(() => ({
zipFileName: '', zipFileName: '',
zipBreadcrumbs: [], zipBreadcrumbs: [],
fileLoading: fileLoading.value, fileLoading: fileLoading.value,
showSidebar: showSidebar.value showSidebar: showSidebar.value,
sortBy: sortBy.value,
sortOrder: sortOrder.value
})) }))
// 侧边栏配置 // 侧边栏配置
@@ -384,21 +416,22 @@ const handleOpenFile = async (path: string) => {
} }
} }
// 处理 Markdown 预览中的本地文件链接点击 // 处理 HTML/Markdown 预览中的本地文件链接点击
const handleOpenLocalFile = async (link: string) => { const handleOpenLocalFile = async (link: string) => {
if (!link) return if (!link) return
try { try {
let targetPath = link let targetPath = link
// 如果是相对路径,基于当前 MD 文件所在目录解析 // 剥离 /localfs/ 前缀HTML 预览模式下 JS 生成的路径带此前缀)
if (!link.match(/^[A-Za-z]:/) && !link.startsWith('/')) { if (link.startsWith('/localfs/')) {
// 使用当前预览文件的完整路径 targetPath = link.replace(/^\/localfs\//, '').replace(/\//g, '\\')
}
// 如果是相对路径,基于当前预览文件所在目录解析
else if (!link.match(/^[A-Za-z]:/) && !link.startsWith('/')) {
const currentFilePath = fileEditorPanelConfig.value.currentFileFullPath const currentFilePath = fileEditorPanelConfig.value.currentFileFullPath
if (currentFilePath) { if (currentFilePath) {
// 获取当前文件所在目录
const currentDir = getParentPath(currentFilePath) const currentDir = getParentPath(currentFilePath)
// 解析相对路径
targetPath = resolveRelativePath(currentDir, link) targetPath = resolveRelativePath(currentDir, link)
} }
} }
@@ -910,25 +943,16 @@ const handleReset = () => {
resetContent() resetContent()
} }
const handleStartResize = (event: MouseEvent) => { const handleStartResize = createResizeHandler(
event.preventDefault() () => workspaceRef.value,
() => fileContentHeight.value,
const startY = event.clientY {
const startHeight = fileContentHeight.value direction: 'vertical',
outputMode: 'pixels',
const onMouseMove = (e: MouseEvent) => { minPixels: 200,
const deltaY = e.clientY - startY onResize: (px) => { fileContentHeight.value = px },
fileContentHeight.value = Math.max(200, startHeight + deltaY)
} }
)
const onMouseUp = () => {
window.removeEventListener('mousemove', onMouseMove)
window.removeEventListener('mouseup', onMouseUp)
}
window.addEventListener('mousemove', onMouseMove)
window.addEventListener('mouseup', onMouseUp)
}
const handleContentUpdate = (content: string) => { const handleContentUpdate = (content: string) => {
// useFileEdit 内部会检查版本号和时间,防止过期更新 // useFileEdit 内部会检查版本号和时间,防止过期更新
@@ -1052,7 +1076,7 @@ const loadDirectory = async (path: string) => {
fileLoading.value = true fileLoading.value = true
try { try {
fileList.value = await fileOps.listDirectory(path) fileList.value = await fileOps.listDirectory(path)
fileList.value = sortFileList(fileList.value) doSort()
} catch (error) { } catch (error) {
Message.error(`加载目录失败: ${error}`) Message.error(`加载目录失败: ${error}`)
} finally { } finally {
@@ -1064,7 +1088,8 @@ const loadDirectory = async (path: string) => {
* 添加文件到列表(保持排序) * 添加文件到列表(保持排序)
*/ */
const addFileToList = (item: FileItem) => { const addFileToList = (item: FileItem) => {
fileList.value = sortFileList([...fileList.value, { ...item, is_favorite: false }]) fileList.value = [...fileList.value, { ...item, is_favorite: false }]
doSort()
} }
/** /**
@@ -1120,7 +1145,7 @@ const loadZipDirectoryContents = async (zipPath: string, currentDir: string): Pr
is_favorite: false is_favorite: false
})) }))
return sortFileList(result) return sortFileList(result, { sortBy: sortBy.value, sortOrder: sortOrder.value })
} catch (error) { } catch (error) {
console.error('加载 ZIP 目录失败:', error) console.error('加载 ZIP 目录失败:', error)
Message.error(`加载 ZIP 目录失败: ${error}`) Message.error(`加载 ZIP 目录失败: ${error}`)
@@ -1160,38 +1185,19 @@ const extractZipTextAndRead = async (zipPath: string, filePath: string): Promise
} }
} }
const startResizeHorizontal = (event: MouseEvent) => { const handleHorizontalResize = createResizeHandler(
event.preventDefault() () => workspaceRef.value,
() => panelWidth.value.left,
const startX = event.clientX {
const container = event.currentTarget as HTMLElement direction: 'horizontal',
const containerRect = container.parentElement?.getBoundingClientRect() minPercent: DEFAULTS.MIN_PANEL_WIDTH,
if (!containerRect) return maxPercent: 100 - DEFAULTS.MIN_PANEL_WIDTH,
onResize: (percent) => {
const startLeftWidth = (panelWidth.value.left / 100) * containerRect.width panelWidth.value = { left: percent, right: 100 - percent }
},
const onMouseMove = (e: MouseEvent) => { onResizeEnd: () => savePanelWidth(panelWidth.value),
const deltaX = e.clientX - startX
const minPx = (DEFAULTS.MIN_PANEL_WIDTH / 100) * containerRect.width
const newLeftWidth = Math.max(minPx, Math.min(containerRect.width - minPx, startLeftWidth + deltaX))
const newLeftPercent = (newLeftWidth / containerRect.width) * 100
panelWidth.value = {
left: newLeftPercent,
right: 100 - newLeftPercent
}
} }
)
const onMouseUp = () => {
window.removeEventListener('mousemove', onMouseMove)
window.removeEventListener('mouseup', onMouseUp)
// 保存调整后的宽度
savePanelWidth(panelWidth.value)
}
window.addEventListener('mousemove', onMouseMove)
window.addEventListener('mouseup', onMouseUp)
}
// ========== 生命周期 ========== // ========== 生命周期 ==========
@@ -1212,11 +1218,15 @@ onMounted(() => {
// 添加键盘快捷键 // 添加键盘快捷键
window.addEventListener('keydown', handleKeyDown) window.addEventListener('keydown', handleKeyDown)
window.addEventListener('click', hideContextMenu) window.addEventListener('click', hideContextMenu)
// 添加粘贴事件监听(剪贴板图片)
window.addEventListener('paste', handlePaste)
}) })
onUnmounted(() => { onUnmounted(() => {
window.removeEventListener('keydown', handleKeyDown) window.removeEventListener('keydown', handleKeyDown)
window.removeEventListener('click', hideContextMenu) window.removeEventListener('click', hideContextMenu)
window.removeEventListener('paste', handlePaste)
}) })
// 键盘快捷键 // 键盘快捷键
@@ -1347,6 +1357,74 @@ const handleKeyDown = async (event: KeyboardEvent) => {
} }
} }
// 粘贴剪贴板图片到当前目录
const handlePaste = async (event: ClipboardEvent) => {
// 忽略输入框内的粘贴
const target = event.target as HTMLElement
if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable) return
const items = event.clipboardData?.items
if (!items) return
const imageItem = Array.from(items).find(item => item.type.startsWith('image/'))
if (imageItem) {
event.preventDefault()
await pasteImageToFile(imageItem)
}
}
// 将剪贴板图片保存为文件
const pasteImageToFile = async (item: DataTransferItem) => {
if (!filePath.value) {
Message.warning('请先选择目标目录')
return
}
try {
const file = item.getAsFile()
if (!file) {
Message.error('无法获取剪贴板图片')
return
}
// 生成文件名clipboard_YYYYMMDD_HHmmss.ext
const now = new Date()
const pad = (n: number) => String(n).padStart(2, '0')
const ts = `${now.getFullYear()}${pad(now.getMonth()+1)}${pad(now.getDate())}_${pad(now.getHours())}${pad(now.getMinutes())}${pad(now.getSeconds())}`
const ext = file.type.split('/')[1] || 'png'
const fileName = `clipboard_${ts}.${ext}`
const separator = getPathSeparator(filePath.value)
const fullPath = filePath.value + separator + fileName
// 转换为 base64 并保存
const reader = new FileReader()
reader.onload = async () => {
const result = reader.result as string
// 移除 data:image/xxx;base64, 前缀,只保留纯 base64 内容
const parts = result.split(',')
const base64Data = parts.length > 1 ? parts[1] : ''
if (!base64Data) {
Message.error('图片数据格式无效')
return
}
Message.loading({ content: `正在保存 ${fileName}...`, duration: 0 })
try {
await saveBase64File(fullPath, base64Data)
Message.clear()
Message.success(`已保存: ${fileName}`)
loadDirectory(filePath.value)
} catch (err: any) {
Message.clear()
Message.error('保存失败: ' + (err?.message || err))
}
}
reader.readAsDataURL(file)
} catch (err: any) {
Message.error('粘贴失败: ' + (err?.message || err))
}
}
// 渲染 Mermaid 图表 // 渲染 Mermaid 图表
watch(async () => { watch(async () => {
if (isMarkdownFile(selectedFileItem.value?.name || '') && computeRendered.value) { if (isMarkdownFile(selectedFileItem.value?.name || '') && computeRendered.value) {
@@ -1357,6 +1435,18 @@ watch(async () => {
await nextTick() await nextTick()
} }
}, { immediate: true }) }, { immediate: true })
// 主题变化时重新渲染 mermaid 图表(跟随暗色/亮色)
const themeStore = useThemeStore()
watch(() => themeStore.isDark, async () => {
if (isMarkdownFile(selectedFileItem.value?.name || '') && computeRendered.value) {
try {
// 等 DOM 更新完成后再重新渲染,确保 isDarkTheme() 能读到正确的主题属性
await nextTick()
await rerenderMermaidDiagrams()
} catch { /* 忽略 */ }
}
})
</script> </script>
<style scoped> <style scoped>

View File

@@ -26,8 +26,8 @@
</div> </div>
</div> </div>
<div class="editor-content" :class="{ 'fullscreen': isFullscreen }"> <div ref="editorContentRef" class="editor-content" :class="{ 'fullscreen': isFullscreen }">
<div class="editor-panel" :class="{ 'expanded': isEditorExpanded }"> <div class="editor-panel" :class="{ 'expanded': isEditorExpanded }" :style="{ width: editorWidthPercent + '%' }">
<div class="panel-header"> <div class="panel-header">
<span>编辑</span> <span>编辑</span>
<div class="panel-controls"> <div class="panel-controls">
@@ -64,9 +64,9 @@ console.log('Hello, World!')
</div> </div>
</div> </div>
<div class="resizer" @mousedown="startResize"></div> <div class="resizer" @mousedown="handleResize"></div>
<div class="preview-panel" :class="{ 'expanded': isPreviewExpanded }"> <div class="preview-panel" :class="{ 'expanded': isPreviewExpanded }" :style="{ width: (100 - editorWidthPercent) + '%' }">
<div class="panel-header"> <div class="panel-header">
<span>预览</span> <span>预览</span>
<div class="panel-controls"> <div class="panel-controls">
@@ -83,7 +83,7 @@ console.log('Hello, World!')
</a-tooltip> </a-tooltip>
</div> </div>
</div> </div>
<div class="preview-wrapper"> <div ref="previewRef" class="preview-wrapper">
<MarkdownPreview :content="markdownContent" /> <MarkdownPreview :content="markdownContent" />
</div> </div>
</div> </div>
@@ -123,6 +123,7 @@ import IconSync from '@arco-design/web-vue/es/icon/icon-sync'
import IconSave from '@arco-design/web-vue/es/icon/icon-save' import IconSave from '@arco-design/web-vue/es/icon/icon-save'
import IconEye from '@arco-design/web-vue/es/icon/icon-eye' import IconEye from '@arco-design/web-vue/es/icon/icon-eye'
import IconAlignLeft from '@arco-design/web-vue/es/icon/icon-align-left' import IconAlignLeft from '@arco-design/web-vue/es/icon/icon-align-left'
import { createResizeHandler } from '@/utils/resize'
export default { export default {
name: 'MarkdownEditor', name: 'MarkdownEditor',
@@ -154,6 +155,9 @@ export default {
const isEditorExpanded = ref(false) const isEditorExpanded = ref(false)
const isPreviewExpanded = ref(false) const isPreviewExpanded = ref(false)
const showPreview = ref(true) const showPreview = ref(true)
const editorWidthPercent = ref(50)
const editorContentRef = ref(null)
const previewRef = ref<HTMLElement | null>(null)
// 计算属性 // 计算属性
const wordCount = computed(() => { const wordCount = computed(() => {
@@ -180,12 +184,12 @@ export default {
const handleKeydown = (event) => { const handleKeydown = (event) => {
// Ctrl + S 保存 // Ctrl + S 保存
if (event.ctrlKey && event.key === 's') { if ((event.ctrlKey || event.metaKey) && event.key === 's') {
event.preventDefault() event.preventDefault()
saveContent() saveContent()
} }
// Ctrl + / 切换预览 // Ctrl + / 切换预览
if (event.ctrlKey && event.key === '/') { if ((event.ctrlKey || event.metaKey) && event.key === '/') {
event.preventDefault() event.preventDefault()
togglePreview() togglePreview()
} }
@@ -212,33 +216,18 @@ export default {
} }
} }
// 窗口大小调整 // 分割拖拽调整宽度
const startResize = (event) => { const handleResize = createResizeHandler(
if (!showPreview.value) return () => editorContentRef.value,
() => editorWidthPercent.value,
const startX = event.clientX {
const startWidth = document.querySelector('.editor-panel').offsetWidth direction: 'horizontal',
const startPreviewWidth = document.querySelector('.preview-panel').offsetWidth minPercent: 15,
maxPercent: 85,
const doResize = (moveEvent) => { minPixels: 100,
const deltaX = moveEvent.clientX - startX onResize: (percent) => { editorWidthPercent.value = percent },
const newEditorWidth = startWidth + deltaX
const newPreviewWidth = startPreviewWidth - deltaX
if (newEditorWidth > 100 && newPreviewWidth > 100) {
document.querySelector('.editor-panel').style.width = newEditorWidth + 'px'
document.querySelector('.preview-panel').style.width = newPreviewWidth + 'px'
}
} }
)
const stopResize = () => {
document.removeEventListener('mousemove', doResize)
document.removeEventListener('mouseup', stopResize)
}
document.addEventListener('mousemove', doResize)
document.addEventListener('mouseup', stopResize)
}
// 切换功能 // 切换功能
const togglePreview = () => { const togglePreview = () => {
@@ -294,11 +283,10 @@ export default {
const renderPreview = () => { const renderPreview = () => {
// 强制重新渲染预览 // 强制重新渲染预览
const previewElement = document.querySelector('.preview-wrapper') if (previewRef.value) {
if (previewElement) { previewRef.value.style.opacity = '0'
previewElement.style.opacity = '0'
nextTick(() => { nextTick(() => {
previewElement.style.opacity = '1' if (previewRef.value) previewRef.value.style.opacity = '1'
}) })
} }
} }
@@ -316,10 +304,11 @@ export default {
}, 5000) }, 5000)
} }
// 调整高度 // 调整高度
// computeRendered 是 computed ref值变化即触发无需 deep
nextTick(() => { nextTick(() => {
adjustTextareaHeight() adjustTextareaHeight()
}) })
}, { deep: true }) })
// 初始化 // 初始化
onMounted(() => { onMounted(() => {
@@ -361,7 +350,6 @@ export default {
onExportComplete, onExportComplete,
getMarkdownContent, getMarkdownContent,
setMarkdownContent, setMarkdownContent,
startResize,
togglePreview, togglePreview,
toggleFullscreen, toggleFullscreen,
toggleEditorExpand, toggleEditorExpand,
@@ -439,10 +427,10 @@ export default {
.editor-panel, .editor-panel,
.preview-panel { .preview-panel {
flex: 1;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
min-width: 100px; min-width: 100px;
overflow: hidden;
} }
.editor-panel.expanded { .editor-panel.expanded {

View File

@@ -2,7 +2,6 @@
* 全局 Composables 导出 * 全局 Composables 导出
*/ */
export * from './useLocalStorage'
export * from './useDebounce' export * from './useDebounce'
export * from './useTablePage' export * from './useTablePage'
export * from './useApiError' export * from './useApiError'

View File

@@ -1,7 +1,6 @@
import { createApp } from 'vue' import { createApp } from 'vue'
import { createPinia } from 'pinia' import { createPinia } from 'pinia'
// Arco Design 样式(组件按需自动引入 // Arco Design 组件 CSS 按需加载sideEffect: true
import '@arco-design/web-vue/dist/arco.css'
import './style.css' import './style.css'
import App from './App.vue' import App from './App.vue'
import { useThemeStore } from './stores/theme' import { useThemeStore } from './stores/theme'

View File

@@ -32,6 +32,15 @@ export const useThemeStore = defineStore('theme', () => {
const method = newTheme === 'dark' ? 'setAttribute' : 'removeAttribute' const method = newTheme === 'dark' ? 'setAttribute' : 'removeAttribute'
document.body[method]('arco-theme', 'dark') document.body[method]('arco-theme', 'dark')
// 同步 Wails 窗口标题栏主题
try {
if (newTheme === 'dark') {
window.runtime?.WindowSetDarkTheme?.()
} else {
window.runtime?.WindowSetLightTheme?.()
}
} catch { /* 非 Wails 环境忽略 */ }
// 持久化 // 持久化
localStorage.setItem(THEME_STORAGE_KEY, newTheme) localStorage.setItem(THEME_STORAGE_KEY, newTheme)
} }

View File

@@ -72,6 +72,14 @@ export const useUpdateStore = defineStore('update', () => {
updateInfo.value = result.data updateInfo.value = result.data
showUpdate.value = true showUpdate.value = true
// 系统通知:发现新版本
try {
window.runtime?.SendNotification?.({
title: 'U-Desk',
body: `发现新版本 ${result.data.latest_version},点击查看`
})
} catch { /* 通知不可用时忽略 */ }
if (!silent) { if (!silent) {
Message.success('发现新版本!') Message.success('发现新版本!')
} }
@@ -202,6 +210,14 @@ export const useUpdateStore = defineStore('update', () => {
// 完成下载 // 完成下载
downloadProgress.value = 100 downloadProgress.value = 100
downloadStatus.value = 'success' downloadStatus.value = 'success'
// 系统通知:下载完成
try {
window.runtime?.SendNotification?.({
title: 'U-Desk',
body: `更新包下载完成 (${formatFileSize(fileSize)}),正在安装...`
})
} catch { /* 通知不可用时忽略 */ }
const fileSize = (data.file_size as number) || 0 const fileSize = (data.file_size as number) || 0
progressInfo.value = { progressInfo.value = {
speed: 0, speed: 0,
@@ -228,6 +244,9 @@ export const useUpdateStore = defineStore('update', () => {
return return
} }
// 初始化系统通知(幂等调用,重复无副作用)
window.runtime?.InitializeNotifications?.()
window.runtime.EventsOn('download-progress', onDownloadProgress) window.runtime.EventsOn('download-progress', onDownloadProgress)
window.runtime.EventsOn('download-complete', onDownloadComplete) window.runtime.EventsOn('download-complete', onDownloadComplete)
} }

View File

@@ -371,6 +371,12 @@ html {
scroll-behavior: smooth; scroll-behavior: smooth;
} }
/* 拖拽分割条时禁止文本选中 */
body.resizing {
user-select: none !important;
cursor: inherit !important;
}
/* Tooltip 全局样式 */ /* Tooltip 全局样式 */
.arco-tooltip { .arco-tooltip {
font-size: 12px !important; font-size: 12px !important;

View File

@@ -111,6 +111,10 @@ export interface ToolbarConfig {
fileLoading: boolean fileLoading: boolean
/** 是否显示侧边栏 */ /** 是否显示侧边栏 */
showSidebar: boolean showSidebar: boolean
/** 排序字段 */
sortBy: string
/** 排序方向 */
sortOrder: string
} }
/** /**

View File

@@ -1,87 +1,99 @@
/** /**
* CodeMirror 语言包加载器 * CodeMirror 语言包加载器(动态导入,按需加载)
* 使用统一导出避免多实例问题
*/ */
import {
javascript, json, yaml, html, css,
cpp, rust, go, python, php, sql, markdown, java,
shell, powerShell, dart, StreamLanguage
} from './codemirrorExports'
import { getCmLanguage } from './languageMap' import { getCmLanguage } from './languageMap'
const languageCache = new Map() const languageCache = new Map()
/** /**
* 获取语言扩展 * 获取语言扩展(异步,首次调用会动态加载对应语言包)
* @param {string} language - 语言名称 * @param {string} language - 语言名称
* @returns {Extension|null} CodeMirror 语言扩展 * @returns {Promise<Extension|null>} CodeMirror 语言扩展
*/ */
export function loadLanguageExtension(language) { export async function loadLanguageExtension(language) {
if (languageCache.has(language)) { if (languageCache.has(language)) {
return languageCache.get(language) return languageCache.get(language)
} }
let extension = null let mod, extension = null
switch (language) { switch (language) {
case 'javascript': case 'javascript':
extension = javascript({ jsx: true }) mod = await import('@codemirror/lang-javascript')
extension = mod.javascript({ jsx: true })
break break
case 'typescript': case 'typescript':
extension = javascript({ typescript: true, jsx: true }) mod = await import('@codemirror/lang-javascript')
extension = mod.javascript({ typescript: true, jsx: true })
break break
case 'json': case 'json':
extension = json() ;({ json: extension } = await import('@codemirror/lang-json'))
extension = extension()
break break
case 'yaml': case 'yaml':
extension = yaml() ;({ yaml: extension } = await import('@codemirror/lang-yaml'))
extension = extension()
break break
case 'html': case 'html':
extension = html() ;({ html: extension } = await import('@codemirror/lang-html'))
extension = extension()
break break
case 'css': case 'css':
extension = css() ;({ css: extension } = await import('@codemirror/lang-css'))
extension = extension()
break break
case 'cpp': case 'cpp':
case 'c': case 'c':
extension = cpp() ;({ cpp: extension } = await import('@codemirror/lang-cpp'))
extension = extension()
break break
case 'rust': case 'rust':
extension = rust() ;({ rust: extension } = await import('@codemirror/lang-rust'))
extension = extension()
break break
case 'go': case 'go':
extension = go() ;({ go: extension } = await import('@codemirror/lang-go'))
extension = extension()
break break
case 'python': case 'python':
extension = python() ;({ python: extension } = await import('@codemirror/lang-python'))
extension = extension()
break break
case 'php': case 'php':
extension = php() ;({ php: extension } = await import('@codemirror/lang-php'))
extension = extension()
break break
case 'sql': case 'sql':
extension = sql() ;({ sql: extension } = await import('@codemirror/lang-sql'))
extension = extension()
break break
case 'markdown': case 'markdown':
extension = markdown() ;({ markdown: extension } = await import('@codemirror/lang-markdown'))
extension = extension()
break break
case 'java': case 'java':
extension = java() ;({ java: extension } = await import('@codemirror/lang-java'))
extension = extension()
break break
case 'shell': case 'shell':
case 'bash': case 'bash':
case 'sh': case 'sh':
case 'dockerfile': {
const { StreamLanguage, shell } = await import('@codemirror/legacy-modes/mode/shell')
extension = StreamLanguage.define(shell) extension = StreamLanguage.define(shell)
break break
case 'powershell': }
case 'powershell': {
const { StreamLanguage, powerShell } = await import('@codemirror/legacy-modes/mode/powershell')
extension = StreamLanguage.define(powerShell) extension = StreamLanguage.define(powerShell)
break break
case 'dart': }
case 'dart': {
const { StreamLanguage, clike: dart } = await import('@codemirror/legacy-modes/mode/clike')
extension = StreamLanguage.define(dart) extension = StreamLanguage.define(dart)
break break
case 'dockerfile': }
extension = StreamLanguage.define(shell)
break
default: default:
return null return null
} }

View File

@@ -10,22 +10,4 @@ export { defaultKeymap, history, historyKeymap } from '@codemirror/commands'
export { bracketMatching, defaultHighlightStyle, syntaxHighlighting, StreamLanguage } from '@codemirror/language' export { bracketMatching, defaultHighlightStyle, syntaxHighlighting, StreamLanguage } from '@codemirror/language'
export { oneDark } from '@codemirror/theme-one-dark' export { oneDark } from '@codemirror/theme-one-dark'
// Language packages // 语言包通过 codeMirrorLoader 动态导入,避免全量打包
export { javascript } from '@codemirror/lang-javascript'
export { json } from '@codemirror/lang-json'
export { yaml } from '@codemirror/lang-yaml'
export { html } from '@codemirror/lang-html'
export { css } from '@codemirror/lang-css'
export { cpp } from '@codemirror/lang-cpp'
export { rust } from '@codemirror/lang-rust'
export { go } from '@codemirror/lang-go'
export { python } from '@codemirror/lang-python'
export { php } from '@codemirror/lang-php'
export { sql } from '@codemirror/lang-sql'
export { markdown } from '@codemirror/lang-markdown'
export { java } from '@codemirror/lang-java'
// Legacy language modes (shell, powershell, dart)
export { shell } from '@codemirror/legacy-modes/mode/shell'
export { powerShell } from '@codemirror/legacy-modes/mode/powershell'
export { dart } from '@codemirror/legacy-modes/mode/clike'

View File

@@ -27,6 +27,9 @@ export const STORAGE_KEYS = {
FAVORITE_FILES: 'app-filesystem-favorite-files', FAVORITE_FILES: 'app-filesystem-favorite-files',
EDIT_MODE: 'app-filesystem-edit-mode', // HTML/Markdown 编辑模式状态 EDIT_MODE: 'app-filesystem-edit-mode', // HTML/Markdown 编辑模式状态
FILE_DRAFT: 'app-filesystem-file-draft', // 文件草稿 FILE_DRAFT: 'app-filesystem-file-draft', // 文件草稿
SORT: 'app-filesystem-sort', // 排序状态
COL_SETTINGS: 'app-filesystem-col-settings', // 列配置(显隐+顺序)
SHOW_HEADER: 'app-filesystem-show-header', // 表头显隐
}, },
// 设备测试模块 // 设备测试模块

View File

@@ -1,63 +0,0 @@
/**
* 错误处理工具函数
*
* @module utils/errorHandler
* @description 统一的错误处理,避免代码重复
*/
import { Message } from '@arco-design/web-vue'
/**
* 统一的错误处理
* @param {Error} error - 错误对象
* @param {string} context - 操作上下文(用于日志)
*/
export function handleError(error, context = '') {
// 1. 记录日志
console.error(`[${context}]`, error)
// 2. 显示用户提示
const message = error?.message || '操作失败'
Message.error(message)
}
/**
* 包装异步函数,自动处理错误
* @param {Function} fn - 异步函数
* @param {string} context - 操作上下文
* @returns {Function} 包装后的函数
*/
export function withErrorHandling(fn, context = '') {
return async (...args) => {
try {
return await fn(...args)
} catch (error) {
handleError(error, context)
throw error // 重新抛出,让调用者决定是否继续
}
}
}
/**
* 显示成功提示
* @param {string} message - 成功消息
*/
export function showSuccess(message) {
Message.success(message)
}
/**
* 显示警告提示
* @param {string} message - 警告消息
*/
export function showWarning(message) {
Message.warning(message)
}
/**
* 显示信息提示
* @param {string} message - 信息消息
*/
export function showInfo(message) {
Message.info(message)
}

View File

@@ -321,22 +321,58 @@ export function sanitizeFileName(filename, replacement = '_') {
} }
/** /**
* 文件列表排序:文件夹优先,同类型按名称排序 * 文件列表排序:文件夹优先,支持多字段排序
* @param {Array} fileList - 文件列表 * @param {Array} fileList - 文件列表
* @param {Object} options - 排序选项 { sortBy, sortOrder }
* @returns {Array} 排序后的文件列表 * @returns {Array} 排序后的文件列表
* *
* @example * @example
* sortFileList([{name: 'b.txt', isDir: false}, {name: 'a', isDir: true}]) * sortFileList(fileList, { sortBy: 'name', sortOrder: 'asc' })
* // [{name: 'a', isDir: true}, {name: 'b.txt', isDir: false}]
*/ */
export function sortFileList(fileList) {
/**
* 格式化文件修改时间
* @param {string} t - 时间字符串
* @returns {string} 格式化后的时间,如 2026/04/11 14:30
*/
export function formatFileTime(t) {
if (!t) return ''
const d = new Date(t)
if (isNaN(d.getTime())) return t
const pad = n => String(n).padStart(2, '0')
return `${d.getFullYear()}/${pad(d.getMonth()+1)}/${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`
}
export function sortFileList(fileList, options = {}) {
if (!Array.isArray(fileList)) return fileList if (!Array.isArray(fileList)) return fileList
const { sortBy = 'name', sortOrder = 'asc' } = options
const dir = sortOrder === 'desc' ? 1 : -1
return fileList.sort((a, b) => { return fileList.sort((a, b) => {
// API 层已转换,直接使用 isDir // 文件夹始终排在前面
if (a.isDir === b.isDir) { if (a.isDir !== b.isDir) {
return a.name.localeCompare(b.name) return a.isDir ? -1 : 1
} }
return a.isDir ? -1 : 1
let cmp = 0
switch (sortBy) {
case 'size':
cmp = (a.size || 0) - (b.size || 0)
break
case 'type': {
cmp = getExt(a.name).localeCompare(getExt(b.name))
break
}
case 'modified_time': {
const ta = a.modified_time ? new Date(a.modified_time).getTime() : 0
const tb = b.modified_time ? new Date(b.modified_time).getTime() : 0
cmp = ta - tb
break
}
default: // name
cmp = a.name.localeCompare(b.name)
}
return cmp * dir
}) })
} }

View File

@@ -103,6 +103,24 @@ const extensionToLanguage: Record<string, { hljs?: string; cm?: string }> = {
msg: { cm: 'text' }, msg: { cm: 'text' },
} }
// 从映射表中收集所有已知的 hljs 语言名
const knownHljsLanguages = new Set(
Object.values(extensionToLanguage)
.map(v => v.hljs)
.filter(Boolean) as string[]
)
// highlight.js lib/common 内置的常用语言名(代码块标记直接使用)
const commonLangNames = new Set([
'javascript', 'typescript', 'python', 'java', 'go', 'rust', 'c', 'cpp',
'csharp', 'php', 'ruby', 'swift', 'kotlin', 'scala', 'dart', 'lua',
'r', 'bash', 'shell', 'powershell', 'dos', 'cmd', 'sql', 'yaml', 'json', 'xml',
'markdown', 'css', 'scss', 'less', 'html', 'ini', 'makefile', 'dockerfile',
'cmake', 'latex', 'plaintext', 'diff', 'graphql', 'nginx', 'perl',
'objectivec', 'haskell', 'elixir', 'erlang', 'clojure', 'ocaml',
'vbnet', 'wasm', 'fsharp', 'groovy', 'julia', 'matlab', 'zig'
])
/** /**
* 获取 hljs 语言标识(带别名解析) * 获取 hljs 语言标识(带别名解析)
*/ */
@@ -110,12 +128,15 @@ export function getHljsLanguage(langOrExt: string): string {
if (!langOrExt) return 'plaintext' if (!langOrExt) return 'plaintext'
const lower = langOrExt.toLowerCase() const lower = langOrExt.toLowerCase()
// 先查扩展名映射 // 1. 直接是已知语言名(代码块 ```python / ```typescript 等)
const mapped = extensionToLanguage[lower] if (commonLangNames.has(lower)) return lower
if (mapped?.hljs) return mapped.hljs
// 再查 hljs 直接注册名 // 2. 扩展名映射(.ts → typescript, .py → python 等)
if (typeof hljs !== 'undefined' && hljs.getLanguage(lower)) return lower const mapped = extensionToLanguage[lower]?.hljs
if (mapped) return mapped
// 3. 已在 known 集合中的映射值
if (knownHljsLanguages.has(lower)) return lower
return 'plaintext' return 'plaintext'
} }

View File

@@ -28,44 +28,40 @@ async function loadMermaid() {
return mermaidInstance return mermaidInstance
} }
try { if (!mermaidInstance) {
const mermaid = await import('mermaid') const m = await import('mermaid')
mermaid.default.initialize({ mermaidInstance = m.default
startOnLoad: false,
theme: currentTheme,
securityLevel: 'strict',
themeVariables: currentTheme === 'dark' ? {
primaryColor: '#165DFF',
primaryTextColor: '#ffffff',
primaryBorderColor: '#4080FF',
lineColor: '#4E5969',
secondaryColor: '#0E42D2',
tertiaryColor: '#0FC6C2',
mainBkg: '#17171A',
nodeBorder: '#165DFF',
clusterBkg: '#232324',
titleColor: '#FFFFFF',
edgeLabelBackground: '#232324'
} : {
primaryColor: '#165DFF',
primaryTextColor: '#ffffff',
primaryBorderColor: '#4080FF',
lineColor: '#86909C',
secondaryColor: '#E8F3FF',
tertiaryColor: '#722ED1',
mainBkg: '#F2F3F5',
nodeBorder: '#165DFF',
clusterBkg: '#F7F8FA',
titleColor: '#1D2129',
edgeLabelBackground: '#F2F3F5'
}
})
mermaidTheme = currentTheme
mermaidInstance = mermaid.default
return mermaidInstance
} catch {
return null
} }
mermaidInstance.initialize({
startOnLoad: false,
theme: currentTheme,
securityLevel: 'strict',
themeVariables: Object.assign({
primaryColor: '#165DFF',
primaryTextColor: '#ffffff',
primaryBorderColor: '#4080FF'
}, currentTheme === 'dark' ? {
lineColor: '#4E5969',
secondaryColor: '#0E42D2',
tertiaryColor: '#0FC6C2',
mainBkg: '#17171A',
nodeBorder: '#165DFF',
clusterBkg: '#232324',
titleColor: '#FFFFFF',
edgeLabelBackground: '#232324'
} : {
lineColor: '#86909C',
secondaryColor: '#E8F3FF',
tertiaryColor: '#722ED1',
mainBkg: '#F2F3F5',
nodeBorder: '#165DFF',
clusterBkg: '#F7F8FA',
titleColor: '#1D2129',
edgeLabelBackground: '#F2F3F5'
})
})
mermaidTheme = currentTheme
return mermaidInstance
} }
const renderer = new marked.Renderer() const renderer = new marked.Renderer()
@@ -77,7 +73,12 @@ renderer.code = function(token: any) {
const lang = getHljsLanguage(token.lang) const lang = getHljsLanguage(token.lang)
const highlighted = hljs.highlight(token.text, { language: lang }).value let highlighted: string
try {
highlighted = hljs.highlight(token.text, { language: lang }).value
} catch {
highlighted = token.text.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
}
return `<pre><code class="hljs language-${lang}" data-theme="auto">${highlighted}</code></pre>` return `<pre><code class="hljs language-${lang}" data-theme="auto">${highlighted}</code></pre>`
} }
@@ -115,7 +116,7 @@ renderer.link = function(token: any) {
const titleAttr = title ? ` title="${title}"` : '' const titleAttr = title ? ` title="${title}"` : ''
if (href.startsWith('#')) { if (href.startsWith('#')) {
return `<a href="${href}"${titleAttr}>${text}</a>` return `<a href="${href}${titleAttr}">${text}</a>`
} }
if (isLocalFileLink(href)) { if (isLocalFileLink(href)) {
@@ -132,6 +133,28 @@ export { marked }
export async function renderMermaidDiagrams() { export async function renderMermaidDiagrams() {
const mermaid = await loadMermaid() const mermaid = await loadMermaid()
if (mermaid) { if (mermaid) {
// 渲染前保存原始源码textContent 在 SVG 渲染后会变成 CSS 垃圾)
document.querySelectorAll('.mermaid:not([data-mermaid-src])').forEach(pre => {
;(pre as HTMLElement).setAttribute('data-mermaid-src', pre.textContent || '')
})
await mermaid.run() await mermaid.run()
} }
} }
/** 清除已渲染内容并重新渲染(用于主题切换后刷新) */
export async function rerenderMermaidDiagrams(container?: HTMLElement | null) {
// 强制重新加载(清除缓存,让下次 loadMermaid 重新初始化新主题)
mermaidInstance = null
mermaidTheme = null
const target = container || document
target.querySelectorAll('.mermaid').forEach(pre => {
const el = pre as HTMLElement
if (el.getAttribute('data-processed')) {
// 从保存的原始源码恢复,而非 textContentSVG 的 textContent 是 CSS 垃圾)
el.innerHTML = el.getAttribute('data-mermaid-src') || ''
el.removeAttribute('data-processed')
}
})
await renderMermaidDiagrams()
}

107
web/src/utils/resize.ts Normal file
View File

@@ -0,0 +1,107 @@
export interface ResizeOptions {
/** 拖拽方向,默认垂直 */
direction?: 'horizontal' | 'vertical'
/** 最小百分比,默认 20 */
minPercent?: number
/** 最大百分比,默认 80 */
maxPercent?: number
/** 最小像素值,默认 150 */
minPixels?: number
/** 输出模式percent默认或 pixels。pixels 模式下 onResize/onResizeEnd 接收像素值而非百分比 */
outputMode?: 'percent' | 'pixels'
/** 拖拽中回调(值类型由 outputMode 决定) */
onResize?: (value: number) => void
/** 拖拽结束回调(用于持久化等) */
onResizeEnd?: (value: number) => void
}
/**
* 通用分割拖拽处理器工厂
*
* 用法:
* ```ts
* const handleResize = createResizeHandler(
* () => containerRef.value, // gettermousedown 时才读取
* () => percent.value,
* { direction: 'horizontal', onResize: (p) => { percent.value = p } }
* )
* // template: <div class="resizer" @mousedown="handleResize"></div>
* ```
*/
export function createResizeHandler(
getContainer: () => HTMLElement | null,
getInitialPercentage: () => number,
options: ResizeOptions = {}
): (e: MouseEvent) => void {
const {
direction = 'vertical',
minPercent = 20,
maxPercent = 80,
minPixels = 150,
outputMode = 'percent',
onResize,
onResizeEnd,
} = options
const isHorizontal = direction === 'horizontal'
const usePixels = outputMode === 'pixels'
return (e: MouseEvent) => {
e.preventDefault()
const container = getContainer()
if (!container) return
// 初始值pixels 模式下从 getter 获取像素值percent 模式下获取百分比
let startValue = getInitialPercentage()
if (usePixels) {
// pixels 模式:将初始像素值转换为百分比用于拖拽计算
const rect = container.getBoundingClientRect()
const containerSize = isHorizontal ? rect.width : rect.height
if (containerSize > 0) {
startValue = (startValue / containerSize) * 100
}
}
const handleMouseMove = (moveEvent: MouseEvent) => {
const currentRect = container.getBoundingClientRect()
let rawValue: number
if (isHorizontal) {
rawValue = ((moveEvent.clientX - currentRect.left) / currentRect.width) * 100
} else {
rawValue = ((moveEvent.clientY - currentRect.top) / currentRect.height) * 100
}
const minPercentFromPixels = (minPixels / (isHorizontal ? currentRect.width : currentRect.height)) * 100
const clamped = Math.max(
Math.max(minPercent, minPercentFromPixels),
Math.min(maxPercent, rawValue)
)
if (usePixels) {
// 转回像素值传给回调
const containerSize = isHorizontal ? currentRect.width : currentRect.height
onResize?.(Math.round((clamped / 100) * containerSize))
} else {
onResize?.(clamped)
}
}
const handleMouseUp = () => {
document.removeEventListener('mousemove', handleMouseMove)
document.removeEventListener('mouseup', handleMouseUp)
document.body.classList.remove('resizing')
if (usePixels) {
// pixels 模式传回当前像素值onResize 已更新,读 getter 获取最新值)
onResizeEnd?.(getInitialPercentage())
} else {
onResizeEnd?.(getInitialPercentage())
}
}
document.addEventListener('mousemove', handleMouseMove)
document.addEventListener('mouseup', handleMouseUp)
document.body.classList.add('resizing')
}
}

View File

@@ -110,6 +110,8 @@ export function RestoreFromRecycleBin(arg1:string):Promise<void>;
export function SaveAppConfig(arg1:main.SaveAppConfigRequest):Promise<Record<string, any>>; export function SaveAppConfig(arg1:main.SaveAppConfigRequest):Promise<Record<string, any>>;
export function SaveBase64File(arg1:main.SaveBase64FileRequest):Promise<void>;
export function SaveDbConnection(arg1:api.SaveConnectionRequest):Promise<void>; export function SaveDbConnection(arg1:api.SaveConnectionRequest):Promise<void>;
export function SaveResult(arg1:number,arg2:string,arg3:string,arg4:string,arg5:any,arg6:Array<string>,arg7:number,arg8:number):Promise<Record<string, any>>; export function SaveResult(arg1:number,arg2:string,arg3:string,arg4:string,arg5:any,arg6:Array<string>,arg7:number,arg8:number):Promise<Record<string, any>>;

View File

@@ -214,6 +214,10 @@ export function SaveAppConfig(arg1) {
return window['go']['main']['App']['SaveAppConfig'](arg1); return window['go']['main']['App']['SaveAppConfig'](arg1);
} }
export function SaveBase64File(arg1) {
return window['go']['main']['App']['SaveBase64File'](arg1);
}
export function SaveDbConnection(arg1) { export function SaveDbConnection(arg1) {
return window['go']['main']['App']['SaveDbConnection'](arg1); return window['go']['main']['App']['SaveDbConnection'](arg1);
} }

View File

@@ -186,6 +186,20 @@ export namespace main {
return a; return a;
} }
} }
export class SaveBase64FileRequest {
path: string;
content: string;
static createFrom(source: any = {}) {
return new SaveBase64FileRequest(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.path = source["path"];
this.content = source["content"];
}
}
export class WriteFileRequest { export class WriteFileRequest {
path: string; path: string;
content: string; content: string;

View File

@@ -246,4 +246,85 @@ export function OnFileDropOff() :void
export function CanResolveFilePaths(): boolean; export function CanResolveFilePaths(): boolean;
// Resolves file paths for an array of files // Resolves file paths for an array of files
export function ResolveFilePaths(files: File[]): void export function ResolveFilePaths(files: File[]): void
// Notification types
export interface NotificationOptions {
id: string;
title: string;
subtitle?: string; // macOS and Linux only
body?: string;
categoryId?: string;
data?: { [key: string]: any };
}
export interface NotificationAction {
id?: string;
title?: string;
destructive?: boolean; // macOS-specific
}
export interface NotificationCategory {
id?: string;
actions?: NotificationAction[];
hasReplyField?: boolean;
replyPlaceholder?: string;
replyButtonTitle?: string;
}
// [InitializeNotifications](https://wails.io/docs/reference/runtime/notification#initializenotifications)
// Initializes the notification service for the application.
// This must be called before sending any notifications.
export function InitializeNotifications(): Promise<void>;
// [CleanupNotifications](https://wails.io/docs/reference/runtime/notification#cleanupnotifications)
// Cleans up notification resources and releases any held connections.
export function CleanupNotifications(): Promise<void>;
// [IsNotificationAvailable](https://wails.io/docs/reference/runtime/notification#isnotificationavailable)
// Checks if notifications are available on the current platform.
export function IsNotificationAvailable(): Promise<boolean>;
// [RequestNotificationAuthorization](https://wails.io/docs/reference/runtime/notification#requestnotificationauthorization)
// Requests notification authorization from the user (macOS only).
export function RequestNotificationAuthorization(): Promise<boolean>;
// [CheckNotificationAuthorization](https://wails.io/docs/reference/runtime/notification#checknotificationauthorization)
// Checks the current notification authorization status (macOS only).
export function CheckNotificationAuthorization(): Promise<boolean>;
// [SendNotification](https://wails.io/docs/reference/runtime/notification#sendnotification)
// Sends a basic notification with the given options.
export function SendNotification(options: NotificationOptions): Promise<void>;
// [SendNotificationWithActions](https://wails.io/docs/reference/runtime/notification#sendnotificationwithactions)
// Sends a notification with action buttons. Requires a registered category.
export function SendNotificationWithActions(options: NotificationOptions): Promise<void>;
// [RegisterNotificationCategory](https://wails.io/docs/reference/runtime/notification#registernotificationcategory)
// Registers a notification category that can be used with SendNotificationWithActions.
export function RegisterNotificationCategory(category: NotificationCategory): Promise<void>;
// [RemoveNotificationCategory](https://wails.io/docs/reference/runtime/notification#removenotificationcategory)
// Removes a previously registered notification category.
export function RemoveNotificationCategory(categoryId: string): Promise<void>;
// [RemoveAllPendingNotifications](https://wails.io/docs/reference/runtime/notification#removeallpendingnotifications)
// Removes all pending notifications from the notification center.
export function RemoveAllPendingNotifications(): Promise<void>;
// [RemovePendingNotification](https://wails.io/docs/reference/runtime/notification#removependingnotification)
// Removes a specific pending notification by its identifier.
export function RemovePendingNotification(identifier: string): Promise<void>;
// [RemoveAllDeliveredNotifications](https://wails.io/docs/reference/runtime/notification#removealldeliverednotifications)
// Removes all delivered notifications from the notification center.
export function RemoveAllDeliveredNotifications(): Promise<void>;
// [RemoveDeliveredNotification](https://wails.io/docs/reference/runtime/notification#removedeliverednotification)
// Removes a specific delivered notification by its identifier.
export function RemoveDeliveredNotification(identifier: string): Promise<void>;
// [RemoveNotification](https://wails.io/docs/reference/runtime/notification#removenotification)
// Removes a notification by its identifier (cross-platform convenience function).
export function RemoveNotification(identifier: string): Promise<void>;

View File

@@ -239,4 +239,60 @@ export function CanResolveFilePaths() {
export function ResolveFilePaths(files) { export function ResolveFilePaths(files) {
return window.runtime.ResolveFilePaths(files); return window.runtime.ResolveFilePaths(files);
}
export function InitializeNotifications() {
return window.runtime.InitializeNotifications();
}
export function CleanupNotifications() {
return window.runtime.CleanupNotifications();
}
export function IsNotificationAvailable() {
return window.runtime.IsNotificationAvailable();
}
export function RequestNotificationAuthorization() {
return window.runtime.RequestNotificationAuthorization();
}
export function CheckNotificationAuthorization() {
return window.runtime.CheckNotificationAuthorization();
}
export function SendNotification(options) {
return window.runtime.SendNotification(options);
}
export function SendNotificationWithActions(options) {
return window.runtime.SendNotificationWithActions(options);
}
export function RegisterNotificationCategory(category) {
return window.runtime.RegisterNotificationCategory(category);
}
export function RemoveNotificationCategory(categoryId) {
return window.runtime.RemoveNotificationCategory(categoryId);
}
export function RemoveAllPendingNotifications() {
return window.runtime.RemoveAllPendingNotifications();
}
export function RemovePendingNotification(identifier) {
return window.runtime.RemovePendingNotification(identifier);
}
export function RemoveAllDeliveredNotifications() {
return window.runtime.RemoveAllDeliveredNotifications();
}
export function RemoveDeliveredNotification(identifier) {
return window.runtime.RemoveDeliveredNotification(identifier);
}
export function RemoveNotification(identifier) {
return window.runtime.RemoveNotification(identifier);
} }

View File

@@ -9,7 +9,7 @@ export default defineConfig({
plugins: [ plugins: [
vue(), vue(),
AutoImport({ AutoImport({
imports: ['vue', 'vue-router'], imports: ['vue'],
dts: 'src/auto-imports.d.ts', dts: 'src/auto-imports.d.ts',
}), }),
Components({ Components({