重构: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:
67
app.go
67
app.go
@@ -6,11 +6,13 @@ import (
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
stdruntime "runtime"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/jung-kurt/gofpdf"
|
||||
"golang.org/x/sys/windows/registry"
|
||||
"u-desk/internal/api"
|
||||
"u-desk/internal/common"
|
||||
"u-desk/internal/database"
|
||||
@@ -22,6 +24,9 @@ import (
|
||||
"github.com/wailsapp/wails/v2/pkg/runtime"
|
||||
)
|
||||
|
||||
// PDF 有序列表正则(包级变量,避免循环内重复编译)
|
||||
var orderedListRe = regexp.MustCompile(`^\d+\.\s+`)
|
||||
|
||||
// App 应用结构体
|
||||
type App struct {
|
||||
ctx context.Context
|
||||
@@ -37,6 +42,10 @@ type App struct {
|
||||
isAlwaysOnTop bool
|
||||
}
|
||||
|
||||
// App 方法命名约定:
|
||||
// - 多参数操作 → XxxRequest 结构体(Wails 自动生成 TS 类型)
|
||||
// - 单参数查询/简单操作 → 直接参数
|
||||
|
||||
// NewApp 创建新的应用实例
|
||||
func NewApp() *App {
|
||||
return &App{}
|
||||
@@ -286,6 +295,17 @@ func (a *App) WriteFile(req WriteFileRequest) error {
|
||||
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 列出目录
|
||||
func (a *App) ListDir(path string) ([]map[string]interface{}, error) {
|
||||
return a.filesystem.ListDir(path)
|
||||
@@ -393,6 +413,31 @@ func (a *App) ResolveShortcut(lnkPath string) (map[string]interface{}, error) {
|
||||
}, 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 获取常用系统路径
|
||||
func (a *App) GetCommonPaths() (map[string]string, error) {
|
||||
homeDir, err := os.UserHomeDir()
|
||||
@@ -401,10 +446,22 @@ func (a *App) GetCommonPaths() (map[string]string, error) {
|
||||
}
|
||||
|
||||
paths := map[string]string{
|
||||
"home": homeDir,
|
||||
"desktop": filepath.Join(homeDir, "Desktop"),
|
||||
"documents": filepath.Join(homeDir, "Documents"),
|
||||
"downloads": filepath.Join(homeDir, "Downloads"),
|
||||
"home": homeDir,
|
||||
}
|
||||
|
||||
// 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: 动态添加所有盘符
|
||||
@@ -978,7 +1035,7 @@ func (a *App) ExportMarkdownToPDF(markdownContent string) (string, error) {
|
||||
pdf.Cell(10, 7, "•")
|
||||
pdf.Cell(0, 7, strings.TrimPrefix(line, "- "))
|
||||
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.Cell(10, 7, strings.TrimSpace(strings.SplitN(line, ".", 2)[0]) + ".")
|
||||
|
||||
5
go.mod
5
go.mod
@@ -10,15 +10,17 @@ require (
|
||||
github.com/jung-kurt/gofpdf v1.16.2
|
||||
github.com/redis/go-redis/v9 v9.17.3
|
||||
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
|
||||
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/gorm v1.31.1
|
||||
)
|
||||
|
||||
require (
|
||||
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/cespare/xxhash/v2 v2.3.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/net v0.49.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
|
||||
modernc.org/libc v1.67.7 // indirect
|
||||
modernc.org/mathutil v1.7.1 // indirect
|
||||
|
||||
6
go.sum
6
go.sum
@@ -1,5 +1,7 @@
|
||||
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
|
||||
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/go.mod h1:H8yggRPQKLUhUoqrJC1bO2xNya7vanpDl7xR3ISbCJ0=
|
||||
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/mimetype v1.4.1 h1:pQN9ycO7uo4vsUUuPeHEYoUkLVkaRntMnHJxVwYhwHs=
|
||||
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.11.0/go.mod h1:jrf0ZaM6+GBc1wRmXsM8cIvzlg0karYin3erahI4+0k=
|
||||
github.com/wailsapp/wails/v2 v2.12.0 h1:BHO/kLNWFHYjCzucxbzAYZWUjub1Tvb4cSguQozHn5c=
|
||||
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/go.mod h1:jrpuAogTd400dnrH08LKmI/xc1MbPOebTwRqcT5RDeI=
|
||||
github.com/xdg-go/scram v1.2.0 h1:bYKF2AEwG5rqd1BumT4gAnvwU/M9nBp2pTSxeZw7Wvs=
|
||||
|
||||
@@ -41,8 +41,14 @@ var (
|
||||
es6ImportFromRegex = regexp.MustCompile(`import\s+([\s\S]*?)\s+from\s+["']([^"']+)["']`)
|
||||
es6DynamicImport = regexp.MustCompile(`import\s*\(\s*["']([^"']+)["']\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 服务器)
|
||||
type LocalFileServer struct {
|
||||
server *http.Server
|
||||
@@ -501,6 +507,11 @@ func handleHtmlPreviewRequest(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
// 解析参数
|
||||
filePath := r.URL.Query().Get("path")
|
||||
var err error
|
||||
if filePath, err = url.QueryUnescape(filePath); err != nil {
|
||||
http.Error(w, "Invalid path encoding", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
theme := r.URL.Query().Get("theme")
|
||||
if theme == "" {
|
||||
theme = "light"
|
||||
@@ -536,6 +547,12 @@ func handleHtmlPreviewRequest(w http.ResponseWriter, r *http.Request) {
|
||||
// 转换资源路径(将相对路径和绝对路径都转换为完整的本地文件服务器 URL)
|
||||
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)
|
||||
|
||||
@@ -616,8 +633,14 @@ func transformHtmlResourcePaths(htmlContent string, baseDir string) string {
|
||||
// replaceHtmlTagAttribute 替换 HTML 标签中的属性路径
|
||||
func replaceHtmlTagAttribute(html string, pattern *regexp.Regexp, attrName string, baseDir 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)
|
||||
if attrMatch == nil {
|
||||
return match
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// PathValidator 路径验证器接口
|
||||
@@ -180,16 +181,25 @@ func (v *DefaultPathValidator) isSensitivePath(path string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// 默认路径验证器(缓存,避免每次调用重复初始化)
|
||||
var (
|
||||
defaultValidatorOnce sync.Once
|
||||
defaultValidator PathValidator
|
||||
)
|
||||
|
||||
func getDefaultValidator() PathValidator {
|
||||
defaultValidatorOnce.Do(func() {
|
||||
defaultValidator = NewPathValidator(DefaultConfig())
|
||||
})
|
||||
return defaultValidator
|
||||
}
|
||||
|
||||
// isSafePath 兼容函数:保持向后兼容
|
||||
// 使用默认配置的路径验证器
|
||||
func isSafePath(path string) bool {
|
||||
validator := NewPathValidator(DefaultConfig())
|
||||
return validator.IsSafe(path)
|
||||
return getDefaultValidator().IsSafe(path)
|
||||
}
|
||||
|
||||
// isSensitivePath 兼容函数:保持向后兼容
|
||||
// 使用默认配置检查敏感路径
|
||||
func isSensitivePath(path string) bool {
|
||||
validator := NewPathValidator(DefaultConfig())
|
||||
return validator.IsSensitive(path)
|
||||
return getDefaultValidator().IsSensitive(path)
|
||||
}
|
||||
|
||||
@@ -2,17 +2,22 @@ package filesystem
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"u-desk/internal/common"
|
||||
)
|
||||
|
||||
const maxReadWriteSize = 10 * 1024 * 1024 // 10MB 读写上限
|
||||
|
||||
// FileOperationResult 文件操作结果
|
||||
type FileOperationResult struct {
|
||||
Path string `json:"path"`
|
||||
@@ -131,9 +136,8 @@ func (s *FileSystemService) ReadFile(path string) (string, error) {
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("获取文件信息失败: %v", err)
|
||||
}
|
||||
const maxReadSize = 10 * 1024 * 1024 // 10MB
|
||||
if info.Size() > maxReadSize {
|
||||
return "", fmt.Errorf("文件过大 (%.1f MB),超过读取上限 (%d MB)", float64(info.Size())/1024/1024, maxReadSize/1024/1024)
|
||||
if info.Size() > maxReadWriteSize {
|
||||
return "", fmt.Errorf("文件过大 (%.1f MB),超过读取上限 (%d MB)", float64(info.Size())/1024/1024, maxReadWriteSize/1024/1024)
|
||||
}
|
||||
|
||||
// 读取文件
|
||||
@@ -151,30 +155,43 @@ func (s *FileSystemService) Write(path, content string) error {
|
||||
return s.WriteFile(path, content)
|
||||
}
|
||||
|
||||
// WriteFile 写入文件
|
||||
func (s *FileSystemService) WriteFile(path, content string) error {
|
||||
// 路径验证
|
||||
// writeFile 内部写入实现(路径验证+大小检查+写入+日志)
|
||||
func (s *FileSystemService) writeFileWithLog(path string, data []byte) error {
|
||||
if err := s.validatePath(path); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 创建目录
|
||||
dir := filepath.Dir(path)
|
||||
if err := os.MkdirAll(dir, DefaultDirPermissions); err != nil {
|
||||
return fmt.Errorf("创建目录失败: %v", err)
|
||||
}
|
||||
|
||||
// 写入文件
|
||||
data := []byte(content)
|
||||
if len(data) > maxReadWriteSize {
|
||||
return fmt.Errorf("文件过大 (%.1f MB),超过写入上限 (%d MB)", float64(len(data))/1024/1024, maxReadWriteSize/1024/1024)
|
||||
}
|
||||
if err := os.WriteFile(path, data, DefaultFilePermissions); err != nil {
|
||||
s.logWrite(path, int64(len(data)), err)
|
||||
return fmt.Errorf("写入文件失败: %v", err)
|
||||
}
|
||||
|
||||
s.logWrite(path, int64(len(data)), 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 接口)
|
||||
func (s *FileSystemService) List(path string) ([]map[string]interface{}, error) {
|
||||
return s.ListDir(path)
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<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>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
|
||||
6
web/package-lock.json
generated
6
web/package-lock.json
generated
@@ -28,8 +28,6 @@
|
||||
"@codemirror/state": "^6.5.3",
|
||||
"@codemirror/theme-one-dark": "^6.1.3",
|
||||
"@codemirror/view": "^6.39.8",
|
||||
"@types/highlight.js": "^9.12.4",
|
||||
"@types/mermaid": "^9.1.0",
|
||||
"highlight.js": "^11.11.1",
|
||||
"mammoth": "^1.11.0",
|
||||
"marked": "^17.0.1",
|
||||
@@ -39,6 +37,8 @@
|
||||
"xlsx": "^0.18.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/highlight.js": "^9.12.4",
|
||||
"@types/mermaid": "^9.1.0",
|
||||
"@vitejs/plugin-vue": "^6.0.3",
|
||||
"unplugin-auto-import": "^0.18.3",
|
||||
"unplugin-vue-components": "^0.27.4",
|
||||
@@ -1440,12 +1440,14 @@
|
||||
"version": "9.12.4",
|
||||
"resolved": "https://registry.npmmirror.com/@types/highlight.js/-/highlight.js-9.12.4.tgz",
|
||||
"integrity": "sha512-t2szdkwmg2JJyuCM20e8kR2X59WCE5Zkl4bzm1u1Oukjm79zpbiAv+QjnwLnuuV0WHEcX2NgUItu0pAMKuOPww==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/mermaid": {
|
||||
"version": "9.1.0",
|
||||
"resolved": "https://registry.npmmirror.com/@types/mermaid/-/mermaid-9.1.0.tgz",
|
||||
"integrity": "sha512-rc8QqhveKAY7PouzY/p8ljS+eBSNCv7o79L97RSub/Ic2SQ34ph1Ng3s8wFLWVjvaEt6RLOWtSCsgYWd95NY8A==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/trusted-types": {
|
||||
|
||||
@@ -28,8 +28,6 @@
|
||||
"@codemirror/state": "^6.5.3",
|
||||
"@codemirror/theme-one-dark": "^6.1.3",
|
||||
"@codemirror/view": "^6.39.8",
|
||||
"@types/highlight.js": "^9.12.4",
|
||||
"@types/mermaid": "^9.1.0",
|
||||
"highlight.js": "^11.11.1",
|
||||
"mammoth": "^1.11.0",
|
||||
"marked": "^17.0.1",
|
||||
@@ -39,6 +37,8 @@
|
||||
"xlsx": "^0.18.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/highlight.js": "^9.12.4",
|
||||
"@types/mermaid": "^9.1.0",
|
||||
"@vitejs/plugin-vue": "^6.0.3",
|
||||
"unplugin-auto-import": "^0.18.3",
|
||||
"unplugin-vue-components": "^0.27.4",
|
||||
|
||||
@@ -1 +1 @@
|
||||
11e4d92d4ca3da6546d1516713da71a8
|
||||
0e1fafcbb6b28922a38f6c5316932015
|
||||
@@ -168,6 +168,8 @@ onMounted(() => {
|
||||
onUnmounted(() => {
|
||||
document.removeEventListener('wheel', preventZoom)
|
||||
updateStore.removeEventListeners()
|
||||
// 兜底清除所有 Wails 事件监听器,防止泄漏
|
||||
window.runtime?.EventsOffAll?.()
|
||||
})
|
||||
|
||||
// 窗口控制方法
|
||||
|
||||
@@ -12,7 +12,8 @@ import { debugError } from '@/utils/debugLog'
|
||||
function transformFile(file: any): File {
|
||||
return {
|
||||
...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
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除文件或目录
|
||||
*/
|
||||
|
||||
@@ -105,4 +105,5 @@ export interface File {
|
||||
size: number
|
||||
isDir: boolean
|
||||
modified?: string
|
||||
modified_time?: string
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
@@ -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 { getFileName, escapeHtml } from '@/utils/fileUtils'
|
||||
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'
|
||||
|
||||
// 异步加载 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 文件变化,触发预览
|
||||
watch(() => [props.config.isExcelFile, props.config.currentFileFullPath] as const, async ([isExcel, filePath]) => {
|
||||
if (isExcel && filePath && excelPreviewRef.value) {
|
||||
|
||||
@@ -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>
|
||||
@@ -2,40 +2,62 @@
|
||||
<div class="file-list-panel" :style="{ width: width + '%' }">
|
||||
<div class="panel-header">
|
||||
<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
|
||||
class="file-list-wrapper"
|
||||
@contextmenu.prevent="handleContextMenu"
|
||||
@contextmenu.prevent="handleWrapperContextMenu"
|
||||
>
|
||||
<!-- 文件列表 -->
|
||||
<a-list
|
||||
<!-- 文件列表(a-table) -->
|
||||
<a-table
|
||||
v-if="config.fileList.length > 0 || config.fileLoading"
|
||||
:columns="tableColumns"
|
||||
:data="config.fileList"
|
||||
:loading="config.fileLoading"
|
||||
:bordered="false"
|
||||
:pagination="false"
|
||||
class="compact-list"
|
||||
>
|
||||
<template #item="{ item }">
|
||||
<FileItemRow
|
||||
:file="item"
|
||||
:is-selected="isSelected(item)"
|
||||
:is-editing="isEditing(item)"
|
||||
:editing-name="props.config.editingFileName"
|
||||
:is-favorited="isFavorited(item.path)"
|
||||
@click="handleFileClick"
|
||||
@double-click="handleFileDoubleClick"
|
||||
@toggle-favorite="handleToggleFavorite"
|
||||
@save="handleSaveEditing"
|
||||
@cancel="handleCancelEditing"
|
||||
@name-update="handleNameUpdate"
|
||||
@context-menu="handleItemContextMenu"
|
||||
ref="fileItemRefs"
|
||||
/>
|
||||
</template>
|
||||
</a-list>
|
||||
:bordered="false"
|
||||
:show-header="showHeader"
|
||||
size="mini"
|
||||
:row-class-name="getRowClassName"
|
||||
:scroll="{ y: 'auto' }"
|
||||
class="file-table"
|
||||
@row-click="handleRowClick"
|
||||
@row-dblclick="handleRowDoubleClick"
|
||||
@row-contextmenu="handleRowContextMenu"
|
||||
/>
|
||||
|
||||
<!-- 空状态 -->
|
||||
<div v-if="config.fileList.length === 0 && !config.fileLoading" class="empty-state">
|
||||
@@ -47,8 +69,11 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import FileItemRow from './FileItemRow.vue'
|
||||
import { h, computed, nextTick, ref } from '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'
|
||||
|
||||
// Props
|
||||
@@ -56,6 +81,8 @@ interface Props {
|
||||
config: FileListPanelConfig
|
||||
width: number
|
||||
favorites: string[]
|
||||
sortBy: string
|
||||
sortOrder: string
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
@@ -70,96 +97,258 @@ interface Emits {
|
||||
(e: 'cancelEditing'): void
|
||||
(e: 'contextMenu', event: MouseEvent, file: FileItem | null): void
|
||||
(e: 'nameUpdate', newName: string): void
|
||||
(e: 'sort', field: string): void
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
// 计算辅助方法
|
||||
const isSelected = (item: FileItem): boolean => {
|
||||
return props.config.selectedFileItem?.path === item.path
|
||||
interface ColumnConfig {
|
||||
key: string
|
||||
label: string
|
||||
visible: boolean
|
||||
order: number
|
||||
}
|
||||
|
||||
const isEditing = (item: FileItem): boolean => {
|
||||
return props.config.editingFilePath === item.path
|
||||
}
|
||||
const defaultColumns: ColumnConfig[] = [
|
||||
{ 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 => {
|
||||
return props.favorites.includes(path)
|
||||
}
|
||||
|
||||
// 事件处理
|
||||
const handleFileClick = (file: FileItem) => {
|
||||
emit('fileClick', file)
|
||||
}
|
||||
|
||||
const handleFileDoubleClick = (file: FileItem) => {
|
||||
emit('fileDoubleClick', file)
|
||||
}
|
||||
|
||||
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
|
||||
// 从 localStorage 恢复或使用默认值(按 key 匹合,允许列数变化)
|
||||
function loadColSettings(): ColumnConfig[] {
|
||||
try {
|
||||
const saved = localStorage.getItem(COL_SETTINGS_KEY)
|
||||
if (saved) {
|
||||
const parsed = JSON.parse(saved) as ColumnConfig[]
|
||||
if (Array.isArray(parsed)) {
|
||||
// 以 defaultColumns 为基准,合并已保存的 visible/order
|
||||
return defaultColumns.map((def, i) => {
|
||||
const existing = parsed.find(p => p.key === def.key)
|
||||
return existing ? { ...def, visible: existing.visible ?? true, order: existing.order ?? i } : { ...def }
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
} 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)
|
||||
}
|
||||
|
||||
// 暴露方法供父组件调用
|
||||
const focusEditingItem = () => {
|
||||
const index = props.config.fileList.findIndex(
|
||||
item => item.path === props.config.editingFilePath
|
||||
)
|
||||
if (index !== -1 && fileItemRefs.value?.[index]) {
|
||||
const item = fileItemRefs.value[index]
|
||||
item.focus?.()
|
||||
item.selectAll?.()
|
||||
}
|
||||
nextTick(() => {
|
||||
const input = document.querySelector('.file-table .file-name-edit-input input') as HTMLInputElement | null
|
||||
if (!input) return
|
||||
input.focus()
|
||||
const val = input.value
|
||||
const dot = val.lastIndexOf('.')
|
||||
input.setSelectionRange(0, dot > 0 ? dot : val.length)
|
||||
})
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
focusEditingItem
|
||||
})
|
||||
defineExpose({ focusEditingItem })
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* ====== 布局 ====== */
|
||||
.file-list-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -168,37 +357,164 @@ defineExpose({
|
||||
}
|
||||
|
||||
.panel-header {
|
||||
padding: 10px 12px;
|
||||
padding: 6px 12px;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
background: var(--color-bg-2);
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
background: var(--color-bg-2);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.panel-title {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-1);
|
||||
.panel-header-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.panel-count {
|
||||
font-size: 12px;
|
||||
.panel-title { font-size: 13px; font-weight: 600; color: var(--color-text-1); }
|
||||
.panel-count { font-size: 12px; color: var(--color-text-3); }
|
||||
|
||||
.settings-btn {
|
||||
color: var(--color-text-3);
|
||||
padding: 2px 4px;
|
||||
}
|
||||
.settings-btn:hover {
|
||||
color: var(--color-text-2);
|
||||
}
|
||||
|
||||
/* 滚动容器 */
|
||||
.file-list-wrapper {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 4px;
|
||||
overflow-x: hidden;
|
||||
padding: 0 2px;
|
||||
}
|
||||
|
||||
.compact-list :deep(.arco-list-item) {
|
||||
padding: 0;
|
||||
border: none;
|
||||
/* ====== Table 全局覆盖 ====== */
|
||||
.file-table :deep(.arco-table) {
|
||||
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 {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -208,8 +524,5 @@ defineExpose({
|
||||
color: var(--color-text-3);
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.empty-state span:nth-child(2) {
|
||||
font-size: 14px;
|
||||
}
|
||||
.empty-state span:nth-child(2) { font-size: 14px; }
|
||||
</style>
|
||||
|
||||
@@ -109,7 +109,6 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { IconForward, IconHistory, IconRefresh, IconMenu, IconClose, IconRight, IconCopy } from '@arco-design/web-vue/es/icon'
|
||||
import type { ToolbarConfig } from '@/types/file-system'
|
||||
import PathBreadcrumb from './PathBreadcrumb.vue'
|
||||
|
||||
@@ -31,12 +31,14 @@
|
||||
/>
|
||||
|
||||
<!-- 文件列表和编辑器区域 -->
|
||||
<div class="file-workspace">
|
||||
<div ref="workspaceRef" class="file-workspace">
|
||||
<!-- 文件列表面板 -->
|
||||
<FileListPanel
|
||||
:config="fileListPanelConfig"
|
||||
:width="panelWidth.left"
|
||||
:favorites="favoritePaths"
|
||||
:sort-by="sortBy"
|
||||
:sort-order="sortOrder"
|
||||
@file-click="handleFileClick"
|
||||
@file-double-click="handleFileDoubleClick"
|
||||
@toggle-favorite="handleToggleFavorite"
|
||||
@@ -45,11 +47,12 @@
|
||||
@cancel-editing="handleCancelEditing"
|
||||
@name-update="handleNameUpdate"
|
||||
@context-menu="handleContextMenu"
|
||||
@sort="setSort"
|
||||
ref="fileListPanelRef"
|
||||
/>
|
||||
|
||||
<!-- 分隔条 -->
|
||||
<div class="resizer" @mousedown="startResizeHorizontal"></div>
|
||||
<div class="resizer" @mousedown="handleHorizontalResize"></div>
|
||||
|
||||
<!-- 文件编辑器面板 -->
|
||||
<FileEditorPanel
|
||||
@@ -102,7 +105,8 @@
|
||||
import { ref, computed, onMounted, onUnmounted, watch, nextTick } from 'vue'
|
||||
import { getPathSeparator } from '@/utils/fileUtils'
|
||||
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'
|
||||
@@ -122,8 +126,9 @@ import { useCommonPaths } from './composables/useCommonPaths'
|
||||
// 导入工具函数
|
||||
import { getFileName, sortFileList } from '@/utils/fileUtils'
|
||||
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 { createResizeHandler } from '@/utils/resize'
|
||||
|
||||
// 导入类型
|
||||
import type { FileItem, FavoriteFile, ContextMenuConfig, ShortcutPath } from '@/types/file-system'
|
||||
@@ -148,6 +153,30 @@ const fileList = ref<FileItem[]>([])
|
||||
const fileLoading = ref(false)
|
||||
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)
|
||||
|
||||
@@ -186,6 +215,7 @@ const savePanelWidth = (width: { left: number; right: number }) => {
|
||||
}
|
||||
|
||||
const panelWidth = ref(restorePanelWidth())
|
||||
const workspaceRef = ref<HTMLElement | null>(null)
|
||||
|
||||
// 系统路径(使用 composable)
|
||||
const { commonPaths, systemPaths, loadCommonPaths } = useCommonPaths()
|
||||
@@ -263,7 +293,9 @@ const toolbarConfig = computed(() => ({
|
||||
zipFileName: '',
|
||||
zipBreadcrumbs: [],
|
||||
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) => {
|
||||
if (!link) return
|
||||
|
||||
try {
|
||||
let targetPath = link
|
||||
|
||||
// 如果是相对路径,基于当前 MD 文件所在目录解析
|
||||
if (!link.match(/^[A-Za-z]:/) && !link.startsWith('/')) {
|
||||
// 使用当前预览文件的完整路径
|
||||
// 剥离 /localfs/ 前缀(HTML 预览模式下 JS 生成的路径带此前缀)
|
||||
if (link.startsWith('/localfs/')) {
|
||||
targetPath = link.replace(/^\/localfs\//, '').replace(/\//g, '\\')
|
||||
}
|
||||
// 如果是相对路径,基于当前预览文件所在目录解析
|
||||
else if (!link.match(/^[A-Za-z]:/) && !link.startsWith('/')) {
|
||||
const currentFilePath = fileEditorPanelConfig.value.currentFileFullPath
|
||||
if (currentFilePath) {
|
||||
// 获取当前文件所在目录
|
||||
const currentDir = getParentPath(currentFilePath)
|
||||
// 解析相对路径
|
||||
targetPath = resolveRelativePath(currentDir, link)
|
||||
}
|
||||
}
|
||||
@@ -910,25 +943,16 @@ const handleReset = () => {
|
||||
resetContent()
|
||||
}
|
||||
|
||||
const handleStartResize = (event: MouseEvent) => {
|
||||
event.preventDefault()
|
||||
|
||||
const startY = event.clientY
|
||||
const startHeight = fileContentHeight.value
|
||||
|
||||
const onMouseMove = (e: MouseEvent) => {
|
||||
const deltaY = e.clientY - startY
|
||||
fileContentHeight.value = Math.max(200, startHeight + deltaY)
|
||||
const handleStartResize = createResizeHandler(
|
||||
() => workspaceRef.value,
|
||||
() => fileContentHeight.value,
|
||||
{
|
||||
direction: 'vertical',
|
||||
outputMode: 'pixels',
|
||||
minPixels: 200,
|
||||
onResize: (px) => { fileContentHeight.value = px },
|
||||
}
|
||||
|
||||
const onMouseUp = () => {
|
||||
window.removeEventListener('mousemove', onMouseMove)
|
||||
window.removeEventListener('mouseup', onMouseUp)
|
||||
}
|
||||
|
||||
window.addEventListener('mousemove', onMouseMove)
|
||||
window.addEventListener('mouseup', onMouseUp)
|
||||
}
|
||||
)
|
||||
|
||||
const handleContentUpdate = (content: string) => {
|
||||
// useFileEdit 内部会检查版本号和时间,防止过期更新
|
||||
@@ -1052,7 +1076,7 @@ const loadDirectory = async (path: string) => {
|
||||
fileLoading.value = true
|
||||
try {
|
||||
fileList.value = await fileOps.listDirectory(path)
|
||||
fileList.value = sortFileList(fileList.value)
|
||||
doSort()
|
||||
} catch (error) {
|
||||
Message.error(`加载目录失败: ${error}`)
|
||||
} finally {
|
||||
@@ -1064,7 +1088,8 @@ const loadDirectory = async (path: string) => {
|
||||
* 添加文件到列表(保持排序)
|
||||
*/
|
||||
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
|
||||
}))
|
||||
|
||||
return sortFileList(result)
|
||||
return sortFileList(result, { sortBy: sortBy.value, sortOrder: sortOrder.value })
|
||||
} catch (error) {
|
||||
console.error('加载 ZIP 目录失败:', error)
|
||||
Message.error(`加载 ZIP 目录失败: ${error}`)
|
||||
@@ -1160,38 +1185,19 @@ const extractZipTextAndRead = async (zipPath: string, filePath: string): Promise
|
||||
}
|
||||
}
|
||||
|
||||
const startResizeHorizontal = (event: MouseEvent) => {
|
||||
event.preventDefault()
|
||||
|
||||
const startX = event.clientX
|
||||
const container = event.currentTarget as HTMLElement
|
||||
const containerRect = container.parentElement?.getBoundingClientRect()
|
||||
if (!containerRect) return
|
||||
|
||||
const startLeftWidth = (panelWidth.value.left / 100) * containerRect.width
|
||||
|
||||
const onMouseMove = (e: MouseEvent) => {
|
||||
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 handleHorizontalResize = createResizeHandler(
|
||||
() => workspaceRef.value,
|
||||
() => panelWidth.value.left,
|
||||
{
|
||||
direction: 'horizontal',
|
||||
minPercent: DEFAULTS.MIN_PANEL_WIDTH,
|
||||
maxPercent: 100 - DEFAULTS.MIN_PANEL_WIDTH,
|
||||
onResize: (percent) => {
|
||||
panelWidth.value = { left: percent, right: 100 - percent }
|
||||
},
|
||||
onResizeEnd: () => savePanelWidth(panelWidth.value),
|
||||
}
|
||||
|
||||
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('click', hideContextMenu)
|
||||
|
||||
// 添加粘贴事件监听(剪贴板图片)
|
||||
window.addEventListener('paste', handlePaste)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('keydown', handleKeyDown)
|
||||
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 图表
|
||||
watch(async () => {
|
||||
if (isMarkdownFile(selectedFileItem.value?.name || '') && computeRendered.value) {
|
||||
@@ -1357,6 +1435,18 @@ watch(async () => {
|
||||
await nextTick()
|
||||
}
|
||||
}, { 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>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -26,8 +26,8 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="editor-content" :class="{ 'fullscreen': isFullscreen }">
|
||||
<div class="editor-panel" :class="{ 'expanded': isEditorExpanded }">
|
||||
<div ref="editorContentRef" class="editor-content" :class="{ 'fullscreen': isFullscreen }">
|
||||
<div class="editor-panel" :class="{ 'expanded': isEditorExpanded }" :style="{ width: editorWidthPercent + '%' }">
|
||||
<div class="panel-header">
|
||||
<span>编辑</span>
|
||||
<div class="panel-controls">
|
||||
@@ -64,9 +64,9 @@ console.log('Hello, World!')
|
||||
</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">
|
||||
<span>预览</span>
|
||||
<div class="panel-controls">
|
||||
@@ -83,7 +83,7 @@ console.log('Hello, World!')
|
||||
</a-tooltip>
|
||||
</div>
|
||||
</div>
|
||||
<div class="preview-wrapper">
|
||||
<div ref="previewRef" class="preview-wrapper">
|
||||
<MarkdownPreview :content="markdownContent" />
|
||||
</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 IconEye from '@arco-design/web-vue/es/icon/icon-eye'
|
||||
import IconAlignLeft from '@arco-design/web-vue/es/icon/icon-align-left'
|
||||
import { createResizeHandler } from '@/utils/resize'
|
||||
|
||||
export default {
|
||||
name: 'MarkdownEditor',
|
||||
@@ -154,6 +155,9 @@ export default {
|
||||
const isEditorExpanded = ref(false)
|
||||
const isPreviewExpanded = ref(false)
|
||||
const showPreview = ref(true)
|
||||
const editorWidthPercent = ref(50)
|
||||
const editorContentRef = ref(null)
|
||||
const previewRef = ref<HTMLElement | null>(null)
|
||||
|
||||
// 计算属性
|
||||
const wordCount = computed(() => {
|
||||
@@ -180,12 +184,12 @@ export default {
|
||||
|
||||
const handleKeydown = (event) => {
|
||||
// Ctrl + S 保存
|
||||
if (event.ctrlKey && event.key === 's') {
|
||||
if ((event.ctrlKey || event.metaKey) && event.key === 's') {
|
||||
event.preventDefault()
|
||||
saveContent()
|
||||
}
|
||||
// Ctrl + / 切换预览
|
||||
if (event.ctrlKey && event.key === '/') {
|
||||
if ((event.ctrlKey || event.metaKey) && event.key === '/') {
|
||||
event.preventDefault()
|
||||
togglePreview()
|
||||
}
|
||||
@@ -212,33 +216,18 @@ export default {
|
||||
}
|
||||
}
|
||||
|
||||
// 窗口大小调整
|
||||
const startResize = (event) => {
|
||||
if (!showPreview.value) return
|
||||
|
||||
const startX = event.clientX
|
||||
const startWidth = document.querySelector('.editor-panel').offsetWidth
|
||||
const startPreviewWidth = document.querySelector('.preview-panel').offsetWidth
|
||||
|
||||
const doResize = (moveEvent) => {
|
||||
const deltaX = moveEvent.clientX - startX
|
||||
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 handleResize = createResizeHandler(
|
||||
() => editorContentRef.value,
|
||||
() => editorWidthPercent.value,
|
||||
{
|
||||
direction: 'horizontal',
|
||||
minPercent: 15,
|
||||
maxPercent: 85,
|
||||
minPixels: 100,
|
||||
onResize: (percent) => { editorWidthPercent.value = percent },
|
||||
}
|
||||
|
||||
const stopResize = () => {
|
||||
document.removeEventListener('mousemove', doResize)
|
||||
document.removeEventListener('mouseup', stopResize)
|
||||
}
|
||||
|
||||
document.addEventListener('mousemove', doResize)
|
||||
document.addEventListener('mouseup', stopResize)
|
||||
}
|
||||
)
|
||||
|
||||
// 切换功能
|
||||
const togglePreview = () => {
|
||||
@@ -294,11 +283,10 @@ export default {
|
||||
|
||||
const renderPreview = () => {
|
||||
// 强制重新渲染预览
|
||||
const previewElement = document.querySelector('.preview-wrapper')
|
||||
if (previewElement) {
|
||||
previewElement.style.opacity = '0'
|
||||
if (previewRef.value) {
|
||||
previewRef.value.style.opacity = '0'
|
||||
nextTick(() => {
|
||||
previewElement.style.opacity = '1'
|
||||
if (previewRef.value) previewRef.value.style.opacity = '1'
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -316,10 +304,11 @@ export default {
|
||||
}, 5000)
|
||||
}
|
||||
// 调整高度
|
||||
// computeRendered 是 computed ref,值变化即触发,无需 deep
|
||||
nextTick(() => {
|
||||
adjustTextareaHeight()
|
||||
})
|
||||
}, { deep: true })
|
||||
})
|
||||
|
||||
// 初始化
|
||||
onMounted(() => {
|
||||
@@ -361,7 +350,6 @@ export default {
|
||||
onExportComplete,
|
||||
getMarkdownContent,
|
||||
setMarkdownContent,
|
||||
startResize,
|
||||
togglePreview,
|
||||
toggleFullscreen,
|
||||
toggleEditorExpand,
|
||||
@@ -439,10 +427,10 @@ export default {
|
||||
|
||||
.editor-panel,
|
||||
.preview-panel {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 100px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.editor-panel.expanded {
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
* 全局 Composables 导出
|
||||
*/
|
||||
|
||||
export * from './useLocalStorage'
|
||||
export * from './useDebounce'
|
||||
export * from './useTablePage'
|
||||
export * from './useApiError'
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { createApp } from 'vue'
|
||||
import { createPinia } from 'pinia'
|
||||
// Arco Design 样式(组件按需自动引入)
|
||||
import '@arco-design/web-vue/dist/arco.css'
|
||||
// Arco Design 组件 CSS 按需加载(sideEffect: true)
|
||||
import './style.css'
|
||||
import App from './App.vue'
|
||||
import { useThemeStore } from './stores/theme'
|
||||
|
||||
@@ -32,6 +32,15 @@ export const useThemeStore = defineStore('theme', () => {
|
||||
const method = newTheme === 'dark' ? 'setAttribute' : 'removeAttribute'
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -72,6 +72,14 @@ export const useUpdateStore = defineStore('update', () => {
|
||||
updateInfo.value = result.data
|
||||
showUpdate.value = true
|
||||
|
||||
// 系统通知:发现新版本
|
||||
try {
|
||||
window.runtime?.SendNotification?.({
|
||||
title: 'U-Desk',
|
||||
body: `发现新版本 ${result.data.latest_version},点击查看`
|
||||
})
|
||||
} catch { /* 通知不可用时忽略 */ }
|
||||
|
||||
if (!silent) {
|
||||
Message.success('发现新版本!')
|
||||
}
|
||||
@@ -202,6 +210,14 @@ export const useUpdateStore = defineStore('update', () => {
|
||||
// 完成下载
|
||||
downloadProgress.value = 100
|
||||
downloadStatus.value = 'success'
|
||||
|
||||
// 系统通知:下载完成
|
||||
try {
|
||||
window.runtime?.SendNotification?.({
|
||||
title: 'U-Desk',
|
||||
body: `更新包下载完成 (${formatFileSize(fileSize)}),正在安装...`
|
||||
})
|
||||
} catch { /* 通知不可用时忽略 */ }
|
||||
const fileSize = (data.file_size as number) || 0
|
||||
progressInfo.value = {
|
||||
speed: 0,
|
||||
@@ -228,6 +244,9 @@ export const useUpdateStore = defineStore('update', () => {
|
||||
return
|
||||
}
|
||||
|
||||
// 初始化系统通知(幂等调用,重复无副作用)
|
||||
window.runtime?.InitializeNotifications?.()
|
||||
|
||||
window.runtime.EventsOn('download-progress', onDownloadProgress)
|
||||
window.runtime.EventsOn('download-complete', onDownloadComplete)
|
||||
}
|
||||
|
||||
@@ -371,6 +371,12 @@ html {
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
|
||||
/* 拖拽分割条时禁止文本选中 */
|
||||
body.resizing {
|
||||
user-select: none !important;
|
||||
cursor: inherit !important;
|
||||
}
|
||||
|
||||
/* Tooltip 全局样式 */
|
||||
.arco-tooltip {
|
||||
font-size: 12px !important;
|
||||
|
||||
@@ -111,6 +111,10 @@ export interface ToolbarConfig {
|
||||
fileLoading: boolean
|
||||
/** 是否显示侧边栏 */
|
||||
showSidebar: boolean
|
||||
/** 排序字段 */
|
||||
sortBy: string
|
||||
/** 排序方向 */
|
||||
sortOrder: string
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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'
|
||||
|
||||
const languageCache = new Map()
|
||||
|
||||
/**
|
||||
* 获取语言扩展
|
||||
* 获取语言扩展(异步,首次调用会动态加载对应语言包)
|
||||
* @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)) {
|
||||
return languageCache.get(language)
|
||||
}
|
||||
|
||||
let extension = null
|
||||
let mod, extension = null
|
||||
|
||||
switch (language) {
|
||||
case 'javascript':
|
||||
extension = javascript({ jsx: true })
|
||||
mod = await import('@codemirror/lang-javascript')
|
||||
extension = mod.javascript({ jsx: true })
|
||||
break
|
||||
case 'typescript':
|
||||
extension = javascript({ typescript: true, jsx: true })
|
||||
mod = await import('@codemirror/lang-javascript')
|
||||
extension = mod.javascript({ typescript: true, jsx: true })
|
||||
break
|
||||
case 'json':
|
||||
extension = json()
|
||||
;({ json: extension } = await import('@codemirror/lang-json'))
|
||||
extension = extension()
|
||||
break
|
||||
case 'yaml':
|
||||
extension = yaml()
|
||||
;({ yaml: extension } = await import('@codemirror/lang-yaml'))
|
||||
extension = extension()
|
||||
break
|
||||
case 'html':
|
||||
extension = html()
|
||||
;({ html: extension } = await import('@codemirror/lang-html'))
|
||||
extension = extension()
|
||||
break
|
||||
case 'css':
|
||||
extension = css()
|
||||
;({ css: extension } = await import('@codemirror/lang-css'))
|
||||
extension = extension()
|
||||
break
|
||||
case 'cpp':
|
||||
case 'c':
|
||||
extension = cpp()
|
||||
;({ cpp: extension } = await import('@codemirror/lang-cpp'))
|
||||
extension = extension()
|
||||
break
|
||||
case 'rust':
|
||||
extension = rust()
|
||||
;({ rust: extension } = await import('@codemirror/lang-rust'))
|
||||
extension = extension()
|
||||
break
|
||||
case 'go':
|
||||
extension = go()
|
||||
;({ go: extension } = await import('@codemirror/lang-go'))
|
||||
extension = extension()
|
||||
break
|
||||
case 'python':
|
||||
extension = python()
|
||||
;({ python: extension } = await import('@codemirror/lang-python'))
|
||||
extension = extension()
|
||||
break
|
||||
case 'php':
|
||||
extension = php()
|
||||
;({ php: extension } = await import('@codemirror/lang-php'))
|
||||
extension = extension()
|
||||
break
|
||||
case 'sql':
|
||||
extension = sql()
|
||||
;({ sql: extension } = await import('@codemirror/lang-sql'))
|
||||
extension = extension()
|
||||
break
|
||||
case 'markdown':
|
||||
extension = markdown()
|
||||
;({ markdown: extension } = await import('@codemirror/lang-markdown'))
|
||||
extension = extension()
|
||||
break
|
||||
case 'java':
|
||||
extension = java()
|
||||
;({ java: extension } = await import('@codemirror/lang-java'))
|
||||
extension = extension()
|
||||
break
|
||||
case 'shell':
|
||||
case 'bash':
|
||||
case 'sh':
|
||||
case 'dockerfile': {
|
||||
const { StreamLanguage, shell } = await import('@codemirror/legacy-modes/mode/shell')
|
||||
extension = StreamLanguage.define(shell)
|
||||
break
|
||||
case 'powershell':
|
||||
}
|
||||
case 'powershell': {
|
||||
const { StreamLanguage, powerShell } = await import('@codemirror/legacy-modes/mode/powershell')
|
||||
extension = StreamLanguage.define(powerShell)
|
||||
break
|
||||
case 'dart':
|
||||
}
|
||||
case 'dart': {
|
||||
const { StreamLanguage, clike: dart } = await import('@codemirror/legacy-modes/mode/clike')
|
||||
extension = StreamLanguage.define(dart)
|
||||
break
|
||||
case 'dockerfile':
|
||||
extension = StreamLanguage.define(shell)
|
||||
break
|
||||
}
|
||||
default:
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -10,22 +10,4 @@ export { defaultKeymap, history, historyKeymap } from '@codemirror/commands'
|
||||
export { bracketMatching, defaultHighlightStyle, syntaxHighlighting, StreamLanguage } from '@codemirror/language'
|
||||
export { oneDark } from '@codemirror/theme-one-dark'
|
||||
|
||||
// Language packages
|
||||
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'
|
||||
// 语言包通过 codeMirrorLoader 动态导入,避免全量打包
|
||||
|
||||
@@ -27,6 +27,9 @@ export const STORAGE_KEYS = {
|
||||
FAVORITE_FILES: 'app-filesystem-favorite-files',
|
||||
EDIT_MODE: 'app-filesystem-edit-mode', // HTML/Markdown 编辑模式状态
|
||||
FILE_DRAFT: 'app-filesystem-file-draft', // 文件草稿
|
||||
SORT: 'app-filesystem-sort', // 排序状态
|
||||
COL_SETTINGS: 'app-filesystem-col-settings', // 列配置(显隐+顺序)
|
||||
SHOW_HEADER: 'app-filesystem-show-header', // 表头显隐
|
||||
},
|
||||
|
||||
// 设备测试模块
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -321,22 +321,58 @@ export function sanitizeFileName(filename, replacement = '_') {
|
||||
}
|
||||
|
||||
/**
|
||||
* 文件列表排序:文件夹优先,同类型按名称排序
|
||||
* 文件列表排序:文件夹优先,支持多字段排序
|
||||
* @param {Array} fileList - 文件列表
|
||||
* @param {Object} options - 排序选项 { sortBy, sortOrder }
|
||||
* @returns {Array} 排序后的文件列表
|
||||
*
|
||||
* @example
|
||||
* sortFileList([{name: 'b.txt', isDir: false}, {name: 'a', isDir: true}])
|
||||
* // [{name: 'a', isDir: true}, {name: 'b.txt', isDir: false}]
|
||||
* sortFileList(fileList, { sortBy: 'name', sortOrder: 'asc' })
|
||||
*/
|
||||
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
|
||||
|
||||
const { sortBy = 'name', sortOrder = 'asc' } = options
|
||||
const dir = sortOrder === 'desc' ? 1 : -1
|
||||
|
||||
return fileList.sort((a, b) => {
|
||||
// API 层已转换,直接使用 isDir
|
||||
if (a.isDir === b.isDir) {
|
||||
return a.name.localeCompare(b.name)
|
||||
// 文件夹始终排在前面
|
||||
if (a.isDir !== b.isDir) {
|
||||
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
|
||||
})
|
||||
}
|
||||
|
||||
@@ -103,6 +103,24 @@ const extensionToLanguage: Record<string, { hljs?: string; cm?: string }> = {
|
||||
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 语言标识(带别名解析)
|
||||
*/
|
||||
@@ -110,12 +128,15 @@ export function getHljsLanguage(langOrExt: string): string {
|
||||
if (!langOrExt) return 'plaintext'
|
||||
const lower = langOrExt.toLowerCase()
|
||||
|
||||
// 先查扩展名映射
|
||||
const mapped = extensionToLanguage[lower]
|
||||
if (mapped?.hljs) return mapped.hljs
|
||||
// 1. 直接是已知语言名(代码块 ```python / ```typescript 等)
|
||||
if (commonLangNames.has(lower)) return lower
|
||||
|
||||
// 再查 hljs 直接注册名
|
||||
if (typeof hljs !== 'undefined' && hljs.getLanguage(lower)) return lower
|
||||
// 2. 扩展名映射(.ts → typescript, .py → python 等)
|
||||
const mapped = extensionToLanguage[lower]?.hljs
|
||||
if (mapped) return mapped
|
||||
|
||||
// 3. 已在 known 集合中的映射值
|
||||
if (knownHljsLanguages.has(lower)) return lower
|
||||
|
||||
return 'plaintext'
|
||||
}
|
||||
|
||||
@@ -28,44 +28,40 @@ async function loadMermaid() {
|
||||
return mermaidInstance
|
||||
}
|
||||
|
||||
try {
|
||||
const mermaid = await import('mermaid')
|
||||
mermaid.default.initialize({
|
||||
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
|
||||
if (!mermaidInstance) {
|
||||
const m = await import('mermaid')
|
||||
mermaidInstance = m.default
|
||||
}
|
||||
mermaidInstance.initialize({
|
||||
startOnLoad: false,
|
||||
theme: currentTheme,
|
||||
securityLevel: 'strict',
|
||||
themeVariables: Object.assign({
|
||||
primaryColor: '#165DFF',
|
||||
primaryTextColor: '#ffffff',
|
||||
primaryBorderColor: '#4080FF'
|
||||
}, currentTheme === 'dark' ? {
|
||||
lineColor: '#4E5969',
|
||||
secondaryColor: '#0E42D2',
|
||||
tertiaryColor: '#0FC6C2',
|
||||
mainBkg: '#17171A',
|
||||
nodeBorder: '#165DFF',
|
||||
clusterBkg: '#232324',
|
||||
titleColor: '#FFFFFF',
|
||||
edgeLabelBackground: '#232324'
|
||||
} : {
|
||||
lineColor: '#86909C',
|
||||
secondaryColor: '#E8F3FF',
|
||||
tertiaryColor: '#722ED1',
|
||||
mainBkg: '#F2F3F5',
|
||||
nodeBorder: '#165DFF',
|
||||
clusterBkg: '#F7F8FA',
|
||||
titleColor: '#1D2129',
|
||||
edgeLabelBackground: '#F2F3F5'
|
||||
})
|
||||
})
|
||||
mermaidTheme = currentTheme
|
||||
return mermaidInstance
|
||||
}
|
||||
|
||||
const renderer = new marked.Renderer()
|
||||
@@ -77,7 +73,12 @@ renderer.code = function(token: any) {
|
||||
|
||||
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, '&').replace(/</g, '<').replace(/>/g, '>')
|
||||
}
|
||||
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}"` : ''
|
||||
|
||||
if (href.startsWith('#')) {
|
||||
return `<a href="${href}"${titleAttr}>${text}</a>`
|
||||
return `<a href="${href}${titleAttr}">${text}</a>`
|
||||
}
|
||||
|
||||
if (isLocalFileLink(href)) {
|
||||
@@ -132,6 +133,28 @@ export { marked }
|
||||
export async function renderMermaidDiagrams() {
|
||||
const mermaid = await loadMermaid()
|
||||
if (mermaid) {
|
||||
// 渲染前保存原始源码(textContent 在 SVG 渲染后会变成 CSS 垃圾)
|
||||
document.querySelectorAll('.mermaid:not([data-mermaid-src])').forEach(pre => {
|
||||
;(pre as HTMLElement).setAttribute('data-mermaid-src', pre.textContent || '')
|
||||
})
|
||||
await mermaid.run()
|
||||
}
|
||||
}
|
||||
|
||||
/** 清除已渲染内容并重新渲染(用于主题切换后刷新) */
|
||||
export async function rerenderMermaidDiagrams(container?: HTMLElement | null) {
|
||||
// 强制重新加载(清除缓存,让下次 loadMermaid 重新初始化新主题)
|
||||
mermaidInstance = null
|
||||
mermaidTheme = null
|
||||
|
||||
const target = container || document
|
||||
target.querySelectorAll('.mermaid').forEach(pre => {
|
||||
const el = pre as HTMLElement
|
||||
if (el.getAttribute('data-processed')) {
|
||||
// 从保存的原始源码恢复,而非 textContent(SVG 的 textContent 是 CSS 垃圾)
|
||||
el.innerHTML = el.getAttribute('data-mermaid-src') || ''
|
||||
el.removeAttribute('data-processed')
|
||||
}
|
||||
})
|
||||
await renderMermaidDiagrams()
|
||||
}
|
||||
|
||||
107
web/src/utils/resize.ts
Normal file
107
web/src/utils/resize.ts
Normal file
@@ -0,0 +1,107 @@
|
||||
export interface ResizeOptions {
|
||||
/** 拖拽方向,默认垂直 */
|
||||
direction?: 'horizontal' | 'vertical'
|
||||
/** 最小百分比,默认 20 */
|
||||
minPercent?: number
|
||||
/** 最大百分比,默认 80 */
|
||||
maxPercent?: number
|
||||
/** 最小像素值,默认 150 */
|
||||
minPixels?: number
|
||||
/** 输出模式:percent(默认)或 pixels。pixels 模式下 onResize/onResizeEnd 接收像素值而非百分比 */
|
||||
outputMode?: 'percent' | 'pixels'
|
||||
/** 拖拽中回调(值类型由 outputMode 决定) */
|
||||
onResize?: (value: number) => void
|
||||
/** 拖拽结束回调(用于持久化等) */
|
||||
onResizeEnd?: (value: number) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* 通用分割拖拽处理器工厂
|
||||
*
|
||||
* 用法:
|
||||
* ```ts
|
||||
* const handleResize = createResizeHandler(
|
||||
* () => containerRef.value, // getter,mousedown 时才读取
|
||||
* () => percent.value,
|
||||
* { direction: 'horizontal', onResize: (p) => { percent.value = p } }
|
||||
* )
|
||||
* // template: <div class="resizer" @mousedown="handleResize"></div>
|
||||
* ```
|
||||
*/
|
||||
export function createResizeHandler(
|
||||
getContainer: () => HTMLElement | null,
|
||||
getInitialPercentage: () => number,
|
||||
options: ResizeOptions = {}
|
||||
): (e: MouseEvent) => void {
|
||||
const {
|
||||
direction = 'vertical',
|
||||
minPercent = 20,
|
||||
maxPercent = 80,
|
||||
minPixels = 150,
|
||||
outputMode = 'percent',
|
||||
onResize,
|
||||
onResizeEnd,
|
||||
} = options
|
||||
|
||||
const isHorizontal = direction === 'horizontal'
|
||||
const usePixels = outputMode === 'pixels'
|
||||
|
||||
return (e: MouseEvent) => {
|
||||
e.preventDefault()
|
||||
|
||||
const container = getContainer()
|
||||
if (!container) return
|
||||
|
||||
// 初始值:pixels 模式下从 getter 获取像素值,percent 模式下获取百分比
|
||||
let startValue = getInitialPercentage()
|
||||
if (usePixels) {
|
||||
// pixels 模式:将初始像素值转换为百分比用于拖拽计算
|
||||
const rect = container.getBoundingClientRect()
|
||||
const containerSize = isHorizontal ? rect.width : rect.height
|
||||
if (containerSize > 0) {
|
||||
startValue = (startValue / containerSize) * 100
|
||||
}
|
||||
}
|
||||
|
||||
const handleMouseMove = (moveEvent: MouseEvent) => {
|
||||
const currentRect = container.getBoundingClientRect()
|
||||
let rawValue: number
|
||||
|
||||
if (isHorizontal) {
|
||||
rawValue = ((moveEvent.clientX - currentRect.left) / currentRect.width) * 100
|
||||
} else {
|
||||
rawValue = ((moveEvent.clientY - currentRect.top) / currentRect.height) * 100
|
||||
}
|
||||
|
||||
const minPercentFromPixels = (minPixels / (isHorizontal ? currentRect.width : currentRect.height)) * 100
|
||||
const clamped = Math.max(
|
||||
Math.max(minPercent, minPercentFromPixels),
|
||||
Math.min(maxPercent, rawValue)
|
||||
)
|
||||
|
||||
if (usePixels) {
|
||||
// 转回像素值传给回调
|
||||
const containerSize = isHorizontal ? currentRect.width : currentRect.height
|
||||
onResize?.(Math.round((clamped / 100) * containerSize))
|
||||
} else {
|
||||
onResize?.(clamped)
|
||||
}
|
||||
}
|
||||
|
||||
const handleMouseUp = () => {
|
||||
document.removeEventListener('mousemove', handleMouseMove)
|
||||
document.removeEventListener('mouseup', handleMouseUp)
|
||||
document.body.classList.remove('resizing')
|
||||
if (usePixels) {
|
||||
// pixels 模式:传回当前像素值(onResize 已更新,读 getter 获取最新值)
|
||||
onResizeEnd?.(getInitialPercentage())
|
||||
} else {
|
||||
onResizeEnd?.(getInitialPercentage())
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('mousemove', handleMouseMove)
|
||||
document.addEventListener('mouseup', handleMouseUp)
|
||||
document.body.classList.add('resizing')
|
||||
}
|
||||
}
|
||||
2
web/src/wailsjs/wailsjs/go/main/App.d.ts
vendored
2
web/src/wailsjs/wailsjs/go/main/App.d.ts
vendored
@@ -110,6 +110,8 @@ export function RestoreFromRecycleBin(arg1:string):Promise<void>;
|
||||
|
||||
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 SaveResult(arg1:number,arg2:string,arg3:string,arg4:string,arg5:any,arg6:Array<string>,arg7:number,arg8:number):Promise<Record<string, any>>;
|
||||
|
||||
@@ -214,6 +214,10 @@ export function SaveAppConfig(arg1) {
|
||||
return window['go']['main']['App']['SaveAppConfig'](arg1);
|
||||
}
|
||||
|
||||
export function SaveBase64File(arg1) {
|
||||
return window['go']['main']['App']['SaveBase64File'](arg1);
|
||||
}
|
||||
|
||||
export function SaveDbConnection(arg1) {
|
||||
return window['go']['main']['App']['SaveDbConnection'](arg1);
|
||||
}
|
||||
|
||||
@@ -186,6 +186,20 @@ export namespace main {
|
||||
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 {
|
||||
path: string;
|
||||
content: string;
|
||||
|
||||
83
web/src/wailsjs/wailsjs/runtime/runtime.d.ts
vendored
83
web/src/wailsjs/wailsjs/runtime/runtime.d.ts
vendored
@@ -246,4 +246,85 @@ export function OnFileDropOff() :void
|
||||
export function CanResolveFilePaths(): boolean;
|
||||
|
||||
// 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>;
|
||||
@@ -239,4 +239,60 @@ export function CanResolveFilePaths() {
|
||||
|
||||
export function 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);
|
||||
}
|
||||
@@ -9,7 +9,7 @@ export default defineConfig({
|
||||
plugins: [
|
||||
vue(),
|
||||
AutoImport({
|
||||
imports: ['vue', 'vue-router'],
|
||||
imports: ['vue'],
|
||||
dts: 'src/auto-imports.d.ts',
|
||||
}),
|
||||
Components({
|
||||
|
||||
Reference in New Issue
Block a user