重构:Wails v3 迁移 + 前端目录规范化 + Sidebar滚动优化
- web/ → frontend/ 目录重命名(Wails v3 标准结构) - main.go: Middleware 修复 custom.js 404 + DevTools 延迟启动 - Sidebar: 收藏夹内部独立滚动 + 帮助区块固定底部 - useFavorites.ts: longPressTimer const→let 修复 TypeError - App.vue: Arco Tabs padding-top 覆盖 - build: config.yml / Taskfile.yml 对齐官方模板,devtools build tag - 新增 v3 bindings、vite.config.js、跨平台构建配置 Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
35
frontend/.eslintrc.js
Normal file
35
frontend/.eslintrc.js
Normal file
@@ -0,0 +1,35 @@
|
||||
module.exports = {
|
||||
root: true,
|
||||
env: {
|
||||
browser: true,
|
||||
es2021: true,
|
||||
node: true
|
||||
},
|
||||
extends: [
|
||||
'eslint:recommended',
|
||||
'plugin:vue/vue3-recommended',
|
||||
'plugin:@typescript-eslint/recommended'
|
||||
],
|
||||
parser: 'vue-eslint-parser',
|
||||
parserOptions: {
|
||||
ecmaVersion: 'latest',
|
||||
parser: '@typescript-eslint/parser',
|
||||
sourceType: 'module'
|
||||
},
|
||||
plugins: [
|
||||
'vue',
|
||||
'@typescript-eslint'
|
||||
],
|
||||
rules: {
|
||||
// 发现未使用的变量
|
||||
'no-unused-vars': 'error',
|
||||
'@typescript-eslint/no-unused-vars': 'error',
|
||||
|
||||
// 禁止变量在声明前使用
|
||||
'no-use-before-define': 'error',
|
||||
|
||||
// Vue 规则
|
||||
'vue/multi-word-component-names': 'off',
|
||||
'vue/no-unused-vars': 'error'
|
||||
}
|
||||
}
|
||||
31
frontend/.gitignore
vendored
Normal file
31
frontend/.gitignore
vendored
Normal file
@@ -0,0 +1,31 @@
|
||||
# 依赖
|
||||
node_modules/
|
||||
|
||||
# 构建产物
|
||||
dist/
|
||||
build/
|
||||
|
||||
# 自动生成的类型声明文件
|
||||
auto-imports.d.ts
|
||||
components.d.ts
|
||||
|
||||
# 缓存
|
||||
*.log
|
||||
*.cache
|
||||
.vite/
|
||||
|
||||
# 编辑器
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea/
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
|
||||
# 系统文件
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# 环境变量
|
||||
.env.local
|
||||
.env.*.local
|
||||
@@ -0,0 +1,9 @@
|
||||
//@ts-check
|
||||
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
|
||||
// This file is automatically generated. DO NOT EDIT
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore: Unused imports
|
||||
import { Create as $Create } from "@wailsio/runtime";
|
||||
|
||||
Object.freeze($Create.Events);
|
||||
2
frontend/bindings/github.com/wailsapp/wails/v3/internal/eventdata.d.ts
vendored
Normal file
2
frontend/bindings/github.com/wailsapp/wails/v3/internal/eventdata.d.ts
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
|
||||
// This file is automatically generated. DO NOT EDIT
|
||||
@@ -0,0 +1,6 @@
|
||||
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
|
||||
// This file is automatically generated. DO NOT EDIT
|
||||
|
||||
export {
|
||||
WebviewWindow
|
||||
} from "./models.js";
|
||||
@@ -0,0 +1,23 @@
|
||||
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
|
||||
// This file is automatically generated. DO NOT EDIT
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore: Unused imports
|
||||
import { Create as $Create } from "@wailsio/runtime";
|
||||
|
||||
export class WebviewWindow {
|
||||
|
||||
/** Creates a new WebviewWindow instance. */
|
||||
constructor($$source: Partial<WebviewWindow> = {}) {
|
||||
|
||||
Object.assign(this, $$source);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new WebviewWindow instance from a string or object.
|
||||
*/
|
||||
static createFrom($$source: any = {}): WebviewWindow {
|
||||
let $$parsedSource = typeof $$source === 'string' ? JSON.parse($$source) : $$source;
|
||||
return new WebviewWindow($$parsedSource as Partial<WebviewWindow>);
|
||||
}
|
||||
}
|
||||
430
frontend/bindings/u-desk/app.ts
Normal file
430
frontend/bindings/u-desk/app.ts
Normal file
@@ -0,0 +1,430 @@
|
||||
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
|
||||
// This file is automatically generated. DO NOT EDIT
|
||||
|
||||
/**
|
||||
* App 应用结构体
|
||||
* @module
|
||||
*/
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore: Unused imports
|
||||
import { Call as $Call, CancellablePromise as $CancellablePromise, Create as $Create } from "@wailsio/runtime";
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore: Unused imports
|
||||
import * as application$0 from "../github.com/wailsapp/wails/v3/pkg/application/models.js";
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore: Unused imports
|
||||
import * as filesystem$0 from "./internal/filesystem/models.js";
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore: Unused imports
|
||||
import * as $models from "./models.js";
|
||||
|
||||
/**
|
||||
* CheckUpdate 检查更新
|
||||
*/
|
||||
export function CheckUpdate(): $CancellablePromise<{ [_ in string]?: any }> {
|
||||
return $Call.ByID(586574094).then(($result: any) => {
|
||||
return $$createType0($result);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* ClearCache 清理本地缓存(用于菜单项)
|
||||
*/
|
||||
export function ClearCache(): $CancellablePromise<void> {
|
||||
return $Call.ByID(1413834504);
|
||||
}
|
||||
|
||||
/**
|
||||
* CreateDir 创建目录
|
||||
*/
|
||||
export function CreateDir(path: string): $CancellablePromise<filesystem$0.FileOperationResult | null> {
|
||||
return $Call.ByID(632035444, path).then(($result: any) => {
|
||||
return $$createType2($result);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* CreateFile 创建文件
|
||||
*/
|
||||
export function CreateFile(path: string): $CancellablePromise<filesystem$0.FileOperationResult | null> {
|
||||
return $Call.ByID(3418645411, path).then(($result: any) => {
|
||||
return $$createType2($result);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* DeletePath 删除文件或目录
|
||||
*/
|
||||
export function DeletePath(path: string): $CancellablePromise<filesystem$0.FileOperationResult | null> {
|
||||
return $Call.ByID(1564637217, path).then(($result: any) => {
|
||||
return $$createType2($result);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* DeletePermanently 永久删除回收站中的文件
|
||||
*/
|
||||
export function DeletePermanently(recyclePath: string): $CancellablePromise<void> {
|
||||
return $Call.ByID(1697000327, recyclePath);
|
||||
}
|
||||
|
||||
/**
|
||||
* DetectFileTypeByContent 通过文件内容检测文件类型
|
||||
*/
|
||||
export function DetectFileTypeByContent(path: string): $CancellablePromise<{ [_ in string]?: any }> {
|
||||
return $Call.ByID(3067282982, path).then(($result: any) => {
|
||||
return $$createType0($result);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* DownloadUpdate 下载更新包
|
||||
*/
|
||||
export function DownloadUpdate(downloadURL: string): $CancellablePromise<{ [_ in string]?: any }> {
|
||||
return $Call.ByID(115027584, downloadURL).then(($result: any) => {
|
||||
return $$createType0($result);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* EmptyRecycleBin 清空回收站
|
||||
*/
|
||||
export function EmptyRecycleBin(): $CancellablePromise<void> {
|
||||
return $Call.ByID(4176312624);
|
||||
}
|
||||
|
||||
/**
|
||||
* ExportPDF 导出PDF文件
|
||||
*/
|
||||
export function ExportPDF(content: string, title: string, fileName: string, fontSize: number, pageWidth: number, pageHeight: number): $CancellablePromise<{ [_ in string]?: any }> {
|
||||
return $Call.ByID(1770450987, content, title, fileName, fontSize, pageWidth, pageHeight).then(($result: any) => {
|
||||
return $$createType0($result);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* ExtractFileFromZip 从 zip 文件中提取单个文件内容
|
||||
*/
|
||||
export function ExtractFileFromZip(zipPath: string, filePath: string): $CancellablePromise<string> {
|
||||
return $Call.ByID(1578144127, zipPath, filePath);
|
||||
}
|
||||
|
||||
/**
|
||||
* ExtractFileFromZipToTemp 从 zip 文件中提取单个文件到临时目录
|
||||
*/
|
||||
export function ExtractFileFromZipToTemp(zipPath: string, filePath: string): $CancellablePromise<string> {
|
||||
return $Call.ByID(1720007904, zipPath, filePath);
|
||||
}
|
||||
|
||||
/**
|
||||
* GetAppConfig 获取应用配置
|
||||
*/
|
||||
export function GetAppConfig(): $CancellablePromise<{ [_ in string]?: any }> {
|
||||
return $Call.ByID(2006534548).then(($result: any) => {
|
||||
return $$createType0($result);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* GetAuditLogs 获取审计日志
|
||||
*/
|
||||
export function GetAuditLogs(limit: number): $CancellablePromise<{ [_ in string]?: any }[]> {
|
||||
return $Call.ByID(3554903517, limit).then(($result: any) => {
|
||||
return $$createType3($result);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* GetCPUInfo 获取 CPU 信息
|
||||
*/
|
||||
export function GetCPUInfo(): $CancellablePromise<{ [_ in string]?: any }> {
|
||||
return $Call.ByID(2509681007).then(($result: any) => {
|
||||
return $$createType0($result);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* GetCommonPaths 获取常用系统路径
|
||||
*/
|
||||
export function GetCommonPaths(): $CancellablePromise<{ [_ in string]?: string }> {
|
||||
return $Call.ByID(3953343786).then(($result: any) => {
|
||||
return $$createType4($result);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* GetCurrentVersion 获取当前版本号
|
||||
*/
|
||||
export function GetCurrentVersion(): $CancellablePromise<{ [_ in string]?: any }> {
|
||||
return $Call.ByID(1827245900).then(($result: any) => {
|
||||
return $$createType0($result);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* GetDiskInfo 获取磁盘信息
|
||||
*/
|
||||
export function GetDiskInfo(): $CancellablePromise<{ [_ in string]?: any }[]> {
|
||||
return $Call.ByID(3756377758).then(($result: any) => {
|
||||
return $$createType3($result);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* GetEnvVars 获取环境变量
|
||||
*/
|
||||
export function GetEnvVars(): $CancellablePromise<{ [_ in string]?: string }> {
|
||||
return $Call.ByID(363814436).then(($result: any) => {
|
||||
return $$createType4($result);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* GetFileInfo 获取文件信息
|
||||
*/
|
||||
export function GetFileInfo(path: string): $CancellablePromise<{ [_ in string]?: any }> {
|
||||
return $Call.ByID(2071650585, path).then(($result: any) => {
|
||||
return $$createType0($result);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* GetFileServerURL 获取本地文件服务器的URL
|
||||
*/
|
||||
export function GetFileServerURL(): $CancellablePromise<string> {
|
||||
return $Call.ByID(4117667287);
|
||||
}
|
||||
|
||||
/**
|
||||
* GetMemoryInfo 获取内存信息
|
||||
*/
|
||||
export function GetMemoryInfo(): $CancellablePromise<{ [_ in string]?: any }> {
|
||||
return $Call.ByID(2096905876).then(($result: any) => {
|
||||
return $$createType0($result);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* GetRecycleBinEntries 获取回收站条目
|
||||
*/
|
||||
export function GetRecycleBinEntries(): $CancellablePromise<{ [_ in string]?: any }[]> {
|
||||
return $Call.ByID(2312855399).then(($result: any) => {
|
||||
return $$createType3($result);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* GetSystemInfo 获取系统信息
|
||||
*/
|
||||
export function GetSystemInfo(): $CancellablePromise<{ [_ in string]?: any }> {
|
||||
return $Call.ByID(1347250254).then(($result: any) => {
|
||||
return $$createType0($result);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* GetUpdateConfig 获取更新配置
|
||||
*/
|
||||
export function GetUpdateConfig(): $CancellablePromise<{ [_ in string]?: any }> {
|
||||
return $Call.ByID(680804904).then(($result: any) => {
|
||||
return $$createType0($result);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* GetZipFileInfo 获取 zip 文件中特定文件的信息
|
||||
*/
|
||||
export function GetZipFileInfo(zipPath: string, filePath: string): $CancellablePromise<{ [_ in string]?: any }> {
|
||||
return $Call.ByID(2031617692, zipPath, filePath).then(($result: any) => {
|
||||
return $$createType0($result);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* InstallUpdate 安装更新包
|
||||
*/
|
||||
export function InstallUpdate(installerPath: string, autoRestart: boolean): $CancellablePromise<{ [_ in string]?: any }> {
|
||||
return $Call.ByID(2443992793, installerPath, autoRestart).then(($result: any) => {
|
||||
return $$createType0($result);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* InstallUpdateWithHash 安装更新包(带哈希验证)
|
||||
*/
|
||||
export function InstallUpdateWithHash(installerPath: string, autoRestart: boolean, expectedHash: string, hashType: string): $CancellablePromise<{ [_ in string]?: any }> {
|
||||
return $Call.ByID(3787276601, installerPath, autoRestart, expectedHash, hashType).then(($result: any) => {
|
||||
return $$createType0($result);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* ListDir 列出目录
|
||||
*/
|
||||
export function ListDir(path: string): $CancellablePromise<{ [_ in string]?: any }[]> {
|
||||
return $Call.ByID(2120475736, path).then(($result: any) => {
|
||||
return $$createType3($result);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* ListZipContents 列出 zip 文件内容
|
||||
*/
|
||||
export function ListZipContents(zipPath: string): $CancellablePromise<{ [_ in string]?: any }[]> {
|
||||
return $Call.ByID(3013109042, zipPath).then(($result: any) => {
|
||||
return $$createType3($result);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* OpenPath 使用系统默认程序打开文件或目录
|
||||
*/
|
||||
export function OpenPath(path: string): $CancellablePromise<void> {
|
||||
return $Call.ByID(1591734570, path);
|
||||
}
|
||||
|
||||
/**
|
||||
* ReadFile 读取文件
|
||||
*/
|
||||
export function ReadFile(path: string): $CancellablePromise<string> {
|
||||
return $Call.ByID(1160596971, path);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reload 重新加载窗口(用于菜单项)
|
||||
*/
|
||||
export function Reload(): $CancellablePromise<void> {
|
||||
return $Call.ByID(2733532980);
|
||||
}
|
||||
|
||||
/**
|
||||
* RenamePath 重命名文件或目录
|
||||
*/
|
||||
export function RenamePath(req: $models.RenamePathRequest): $CancellablePromise<filesystem$0.FileOperationResult | null> {
|
||||
return $Call.ByID(1959759948, req).then(($result: any) => {
|
||||
return $$createType2($result);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* ResolveShortcut 解析快捷方式文件,返回目标路径信息
|
||||
*/
|
||||
export function ResolveShortcut(lnkPath: string): $CancellablePromise<{ [_ in string]?: any }> {
|
||||
return $Call.ByID(4051288361, lnkPath).then(($result: any) => {
|
||||
return $$createType0($result);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* RestoreFromRecycleBin 从回收站恢复文件
|
||||
*/
|
||||
export function RestoreFromRecycleBin(recyclePath: string): $CancellablePromise<void> {
|
||||
return $Call.ByID(3682437655, recyclePath);
|
||||
}
|
||||
|
||||
/**
|
||||
* SaveAppConfig 保存应用配置
|
||||
*/
|
||||
export function SaveAppConfig(req: $models.SaveAppConfigRequest): $CancellablePromise<{ [_ in string]?: any }> {
|
||||
return $Call.ByID(1942219977, req).then(($result: any) => {
|
||||
return $$createType0($result);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* SaveBase64File 将 base64 内容解码后写入文件(用于图片等二进制数据)
|
||||
*/
|
||||
export function SaveBase64File(req: $models.SaveBase64FileRequest): $CancellablePromise<void> {
|
||||
return $Call.ByID(1355120553, req);
|
||||
}
|
||||
|
||||
/**
|
||||
* SelectPDFSaveDirectory 选择PDF保存目录
|
||||
*/
|
||||
export function SelectPDFSaveDirectory(): $CancellablePromise<string> {
|
||||
return $Call.ByID(1403263131);
|
||||
}
|
||||
|
||||
/**
|
||||
* SetMainWindow 设置主窗口引用(由 main.go 在创建窗口后调用)
|
||||
*/
|
||||
export function SetMainWindow(w: application$0.WebviewWindow | null): $CancellablePromise<void> {
|
||||
return $Call.ByID(843697430, w);
|
||||
}
|
||||
|
||||
/**
|
||||
* SetUpdateConfig 设置更新配置
|
||||
*/
|
||||
export function SetUpdateConfig(autoCheckEnabled: boolean, checkIntervalMinutes: number, checkURL: string): $CancellablePromise<{ [_ in string]?: any }> {
|
||||
return $Call.ByID(4271731092, autoCheckEnabled, checkIntervalMinutes, checkURL).then(($result: any) => {
|
||||
return $$createType0($result);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* SetWindowTitleBarColor 设置原生标题栏颜色 + 主题模式(0x00BBGGRR 格式)
|
||||
*/
|
||||
export function SetWindowTitleBarColor(color: number, isDark: boolean): $CancellablePromise<void> {
|
||||
return $Call.ByID(1570627619, color, isDark);
|
||||
}
|
||||
|
||||
/**
|
||||
* VerifyUpdateFile 验证更新文件哈希值
|
||||
*/
|
||||
export function VerifyUpdateFile(filePath: string, expectedHash: string, hashType: string): $CancellablePromise<{ [_ in string]?: any }> {
|
||||
return $Call.ByID(2181909867, filePath, expectedHash, hashType).then(($result: any) => {
|
||||
return $$createType0($result);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* WindowClose 关闭窗口
|
||||
*/
|
||||
export function WindowClose(): $CancellablePromise<void> {
|
||||
return $Call.ByID(1474073651);
|
||||
}
|
||||
|
||||
/**
|
||||
* WindowIsMaximized 检查窗口是否最大化
|
||||
*/
|
||||
export function WindowIsMaximized(): $CancellablePromise<boolean> {
|
||||
return $Call.ByID(854232017);
|
||||
}
|
||||
|
||||
/**
|
||||
* WindowMaximize 最大化/还原窗口
|
||||
*/
|
||||
export function WindowMaximize(): $CancellablePromise<void> {
|
||||
return $Call.ByID(2739663967);
|
||||
}
|
||||
|
||||
/**
|
||||
* WindowMinimize 最小化窗口
|
||||
*/
|
||||
export function WindowMinimize(): $CancellablePromise<void> {
|
||||
return $Call.ByID(1846147565);
|
||||
}
|
||||
|
||||
/**
|
||||
* WindowToggleAlwaysOnTop 切换窗口置顶
|
||||
*/
|
||||
export function WindowToggleAlwaysOnTop(): $CancellablePromise<boolean> {
|
||||
return $Call.ByID(3391208916);
|
||||
}
|
||||
|
||||
/**
|
||||
* WriteFile 写入文件
|
||||
*/
|
||||
export function WriteFile(req: $models.WriteFileRequest): $CancellablePromise<void> {
|
||||
return $Call.ByID(3562730546, req);
|
||||
}
|
||||
|
||||
// Private type creation functions
|
||||
const $$createType0 = $Create.Map($Create.Any, $Create.Any);
|
||||
const $$createType1 = filesystem$0.FileOperationResult.createFrom;
|
||||
const $$createType2 = $Create.Nullable($$createType1);
|
||||
const $$createType3 = $Create.Array($$createType0);
|
||||
const $$createType4 = $Create.Map($Create.Any, $Create.Any);
|
||||
14
frontend/bindings/u-desk/index.ts
Normal file
14
frontend/bindings/u-desk/index.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
|
||||
// This file is automatically generated. DO NOT EDIT
|
||||
|
||||
import * as App from "./app.js";
|
||||
export {
|
||||
App
|
||||
};
|
||||
|
||||
export {
|
||||
RenamePathRequest,
|
||||
SaveAppConfigRequest,
|
||||
SaveBase64FileRequest,
|
||||
WriteFileRequest
|
||||
} from "./models.js";
|
||||
6
frontend/bindings/u-desk/internal/api/index.ts
Normal file
6
frontend/bindings/u-desk/internal/api/index.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
|
||||
// This file is automatically generated. DO NOT EDIT
|
||||
|
||||
export {
|
||||
AppTabDefinition
|
||||
} from "./models.js";
|
||||
42
frontend/bindings/u-desk/internal/api/models.ts
Normal file
42
frontend/bindings/u-desk/internal/api/models.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
|
||||
// This file is automatically generated. DO NOT EDIT
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore: Unused imports
|
||||
import { Create as $Create } from "@wailsio/runtime";
|
||||
|
||||
/**
|
||||
* AppTabDefinition 应用 Tab 定义(前端格式)
|
||||
*/
|
||||
export class AppTabDefinition {
|
||||
"key": string;
|
||||
"title": string;
|
||||
"visible": boolean;
|
||||
"enabled": boolean;
|
||||
|
||||
/** Creates a new AppTabDefinition instance. */
|
||||
constructor($$source: Partial<AppTabDefinition> = {}) {
|
||||
if (!("key" in $$source)) {
|
||||
this["key"] = "";
|
||||
}
|
||||
if (!("title" in $$source)) {
|
||||
this["title"] = "";
|
||||
}
|
||||
if (!("visible" in $$source)) {
|
||||
this["visible"] = false;
|
||||
}
|
||||
if (!("enabled" in $$source)) {
|
||||
this["enabled"] = false;
|
||||
}
|
||||
|
||||
Object.assign(this, $$source);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new AppTabDefinition instance from a string or object.
|
||||
*/
|
||||
static createFrom($$source: any = {}): AppTabDefinition {
|
||||
let $$parsedSource = typeof $$source === 'string' ? JSON.parse($$source) : $$source;
|
||||
return new AppTabDefinition($$parsedSource as Partial<AppTabDefinition>);
|
||||
}
|
||||
}
|
||||
6
frontend/bindings/u-desk/internal/filesystem/index.ts
Normal file
6
frontend/bindings/u-desk/internal/filesystem/index.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
|
||||
// This file is automatically generated. DO NOT EDIT
|
||||
|
||||
export {
|
||||
FileOperationResult
|
||||
} from "./models.js";
|
||||
55
frontend/bindings/u-desk/internal/filesystem/models.ts
Normal file
55
frontend/bindings/u-desk/internal/filesystem/models.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
|
||||
// This file is automatically generated. DO NOT EDIT
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore: Unused imports
|
||||
import { Create as $Create } from "@wailsio/runtime";
|
||||
|
||||
/**
|
||||
* FileOperationResult 文件操作结果
|
||||
*/
|
||||
export class FileOperationResult {
|
||||
"path": string;
|
||||
"name": string;
|
||||
"size": number;
|
||||
"size_str"?: string;
|
||||
"is_dir": boolean;
|
||||
"mod_time"?: string;
|
||||
"mode"?: string;
|
||||
|
||||
/**
|
||||
* 仅重命名操作时有值
|
||||
*/
|
||||
"old_path"?: string;
|
||||
|
||||
/**
|
||||
* 仅删除操作时有值
|
||||
*/
|
||||
"deleted"?: boolean;
|
||||
|
||||
/** Creates a new FileOperationResult instance. */
|
||||
constructor($$source: Partial<FileOperationResult> = {}) {
|
||||
if (!("path" in $$source)) {
|
||||
this["path"] = "";
|
||||
}
|
||||
if (!("name" in $$source)) {
|
||||
this["name"] = "";
|
||||
}
|
||||
if (!("size" in $$source)) {
|
||||
this["size"] = 0;
|
||||
}
|
||||
if (!("is_dir" in $$source)) {
|
||||
this["is_dir"] = false;
|
||||
}
|
||||
|
||||
Object.assign(this, $$source);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new FileOperationResult instance from a string or object.
|
||||
*/
|
||||
static createFrom($$source: any = {}): FileOperationResult {
|
||||
let $$parsedSource = typeof $$source === 'string' ? JSON.parse($$source) : $$source;
|
||||
return new FileOperationResult($$parsedSource as Partial<FileOperationResult>);
|
||||
}
|
||||
}
|
||||
143
frontend/bindings/u-desk/models.ts
Normal file
143
frontend/bindings/u-desk/models.ts
Normal file
@@ -0,0 +1,143 @@
|
||||
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
|
||||
// This file is automatically generated. DO NOT EDIT
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore: Unused imports
|
||||
import { Create as $Create } from "@wailsio/runtime";
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore: Unused imports
|
||||
import * as api$0 from "./internal/api/models.js";
|
||||
|
||||
/**
|
||||
* RenamePathRequest 重命名文件或目录请求结构体
|
||||
*/
|
||||
export class RenamePathRequest {
|
||||
"oldPath": string;
|
||||
"newPath": string;
|
||||
|
||||
/** Creates a new RenamePathRequest instance. */
|
||||
constructor($$source: Partial<RenamePathRequest> = {}) {
|
||||
if (!("oldPath" in $$source)) {
|
||||
this["oldPath"] = "";
|
||||
}
|
||||
if (!("newPath" in $$source)) {
|
||||
this["newPath"] = "";
|
||||
}
|
||||
|
||||
Object.assign(this, $$source);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new RenamePathRequest instance from a string or object.
|
||||
*/
|
||||
static createFrom($$source: any = {}): RenamePathRequest {
|
||||
let $$parsedSource = typeof $$source === 'string' ? JSON.parse($$source) : $$source;
|
||||
return new RenamePathRequest($$parsedSource as Partial<RenamePathRequest>);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* SaveAppConfigRequest 保存应用配置请求
|
||||
*/
|
||||
export class SaveAppConfigRequest {
|
||||
"tabs": api$0.AppTabDefinition[];
|
||||
"visibleTabs": string[];
|
||||
"defaultTab": string;
|
||||
|
||||
/** Creates a new SaveAppConfigRequest instance. */
|
||||
constructor($$source: Partial<SaveAppConfigRequest> = {}) {
|
||||
if (!("tabs" in $$source)) {
|
||||
this["tabs"] = [];
|
||||
}
|
||||
if (!("visibleTabs" in $$source)) {
|
||||
this["visibleTabs"] = [];
|
||||
}
|
||||
if (!("defaultTab" in $$source)) {
|
||||
this["defaultTab"] = "";
|
||||
}
|
||||
|
||||
Object.assign(this, $$source);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new SaveAppConfigRequest instance from a string or object.
|
||||
*/
|
||||
static createFrom($$source: any = {}): SaveAppConfigRequest {
|
||||
const $$createField0_0 = $$createType1;
|
||||
const $$createField1_0 = $$createType2;
|
||||
let $$parsedSource = typeof $$source === 'string' ? JSON.parse($$source) : $$source;
|
||||
if ("tabs" in $$parsedSource) {
|
||||
$$parsedSource["tabs"] = $$createField0_0($$parsedSource["tabs"]);
|
||||
}
|
||||
if ("visibleTabs" in $$parsedSource) {
|
||||
$$parsedSource["visibleTabs"] = $$createField1_0($$parsedSource["visibleTabs"]);
|
||||
}
|
||||
return new SaveAppConfigRequest($$parsedSource as Partial<SaveAppConfigRequest>);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* SaveBase64FileRequest 保存 Base64 编码的二进制文件
|
||||
*/
|
||||
export class SaveBase64FileRequest {
|
||||
"path": string;
|
||||
|
||||
/**
|
||||
* base64 编码的文件内容
|
||||
*/
|
||||
"content": string;
|
||||
|
||||
/** Creates a new SaveBase64FileRequest instance. */
|
||||
constructor($$source: Partial<SaveBase64FileRequest> = {}) {
|
||||
if (!("path" in $$source)) {
|
||||
this["path"] = "";
|
||||
}
|
||||
if (!("content" in $$source)) {
|
||||
this["content"] = "";
|
||||
}
|
||||
|
||||
Object.assign(this, $$source);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new SaveBase64FileRequest instance from a string or object.
|
||||
*/
|
||||
static createFrom($$source: any = {}): SaveBase64FileRequest {
|
||||
let $$parsedSource = typeof $$source === 'string' ? JSON.parse($$source) : $$source;
|
||||
return new SaveBase64FileRequest($$parsedSource as Partial<SaveBase64FileRequest>);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* WriteFileRequest 写入文件请求结构体
|
||||
*/
|
||||
export class WriteFileRequest {
|
||||
"path": string;
|
||||
"content": string;
|
||||
|
||||
/** Creates a new WriteFileRequest instance. */
|
||||
constructor($$source: Partial<WriteFileRequest> = {}) {
|
||||
if (!("path" in $$source)) {
|
||||
this["path"] = "";
|
||||
}
|
||||
if (!("content" in $$source)) {
|
||||
this["content"] = "";
|
||||
}
|
||||
|
||||
Object.assign(this, $$source);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new WriteFileRequest instance from a string or object.
|
||||
*/
|
||||
static createFrom($$source: any = {}): WriteFileRequest {
|
||||
let $$parsedSource = typeof $$source === 'string' ? JSON.parse($$source) : $$source;
|
||||
return new WriteFileRequest($$parsedSource as Partial<WriteFileRequest>);
|
||||
}
|
||||
}
|
||||
|
||||
// Private type creation functions
|
||||
const $$createType0 = api$0.AppTabDefinition.createFrom;
|
||||
const $$createType1 = $Create.Array($$createType0);
|
||||
const $$createType2 = $Create.Array($Create.Any);
|
||||
14
frontend/index.html
Normal file
14
frontend/index.html
Normal file
@@ -0,0 +1,14 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<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>
|
||||
<script type="module" src="/src/main.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
4392
frontend/package-lock.json
generated
Normal file
4392
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
50
frontend/package.json
Normal file
50
frontend/package.json
Normal file
@@ -0,0 +1,50 @@
|
||||
{
|
||||
"name": "u-desk-web",
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"build:dev": "vite build --mode development",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@arco-design/web-vue": "^2.54.0",
|
||||
"@codemirror/commands": "^6.10.1",
|
||||
"@codemirror/lang-cpp": "^6.0.3",
|
||||
"@codemirror/lang-css": "^6.3.1",
|
||||
"@codemirror/lang-go": "^6.0.1",
|
||||
"@codemirror/lang-html": "^6.4.11",
|
||||
"@codemirror/lang-java": "^6.0.2",
|
||||
"@codemirror/lang-javascript": "^6.2.4",
|
||||
"@codemirror/lang-json": "^6.0.2",
|
||||
"@codemirror/lang-markdown": "^6.5.0",
|
||||
"@codemirror/lang-php": "^6.0.2",
|
||||
"@codemirror/lang-python": "^6.2.1",
|
||||
"@codemirror/lang-rust": "^6.0.2",
|
||||
"@codemirror/lang-sql": "^6.10.0",
|
||||
"@codemirror/lang-yaml": "^6.1.2",
|
||||
"@codemirror/language": "^6.12.1",
|
||||
"@codemirror/legacy-modes": "^6.5.2",
|
||||
"@codemirror/search": "^6.6.0",
|
||||
"@codemirror/state": "^6.5.3",
|
||||
"@codemirror/theme-one-dark": "^6.1.3",
|
||||
"@codemirror/view": "^6.39.8",
|
||||
"highlight.js": "^11.11.1",
|
||||
"mammoth": "^1.11.0",
|
||||
"marked": "^17.0.1",
|
||||
"mermaid": "^11.12.2",
|
||||
"@wailsio/runtime": "latest",
|
||||
"pinia": "^3.0.4",
|
||||
"vue": "^3.5.26",
|
||||
"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",
|
||||
"vite": "^7.3.0"
|
||||
}
|
||||
}
|
||||
27
frontend/scripts/check.sh
Normal file
27
frontend/scripts/check.sh
Normal file
@@ -0,0 +1,27 @@
|
||||
#!/bin/bash
|
||||
|
||||
echo "🔍 开始代码质量检查..."
|
||||
echo ""
|
||||
|
||||
# 1. TypeScript 类型检查
|
||||
echo "1️⃣ TypeScript 类型检查"
|
||||
npx vue-tsc --noEmit 2>&1 | tee type-errors.log | grep -E "error TS" | wc -l
|
||||
echo ""
|
||||
|
||||
# 2. ESLint 检查
|
||||
echo "2️⃣ ESLint 静态分析"
|
||||
npx eslint src --ext .vue,.ts,.js 2>&1 | tee eslint-errors.log | wc -l
|
||||
echo ""
|
||||
|
||||
# 3. 统计错误类型
|
||||
echo "📊 错误统计:"
|
||||
echo "TypeScript 错误:"
|
||||
grep "error TS" type-errors.log | awk '{print $2}' | sort | uniq -c | sort -rn | head -10
|
||||
echo ""
|
||||
|
||||
echo "ESLint 错误:"
|
||||
cat eslint-errors.log | head -20
|
||||
echo ""
|
||||
|
||||
echo "✅ 检查完成!"
|
||||
echo "详细日志:type-errors.log, eslint-errors.log"
|
||||
373
frontend/src/App.vue
Normal file
373
frontend/src/App.vue
Normal file
@@ -0,0 +1,373 @@
|
||||
<template>
|
||||
<a-layout class="layout">
|
||||
<a-layout-header class="header" @dblclick="onHeaderDblClick">
|
||||
<div class="header-content">
|
||||
<div class="header-left">
|
||||
<h2>U-Desk</h2>
|
||||
</div>
|
||||
<a-tabs v-model:active-key="activeTab" class="header-tabs">
|
||||
<a-tab-pane
|
||||
v-for="tab in visibleTabs"
|
||||
:key="tab.key"
|
||||
:title="tab.title"
|
||||
/>
|
||||
</a-tabs>
|
||||
<div class="header-actions">
|
||||
<a-tooltip content="设置">
|
||||
<a-button type="text" @click="showSettings = true">
|
||||
<template #icon>
|
||||
<IconSettings/>
|
||||
</template>
|
||||
</a-button>
|
||||
</a-tooltip>
|
||||
<a-tooltip :content="isPinned ? '取消置顶' : '窗口置顶'">
|
||||
<a-button type="text" :class="{ 'pin-active': isPinned }" @click="handleTogglePin">
|
||||
<template #icon>
|
||||
<IconPushpin :class="{ pinned: isPinned }"/>
|
||||
</template>
|
||||
</a-button>
|
||||
</a-tooltip>
|
||||
<ThemeToggle/>
|
||||
|
||||
<!-- 窗口控制按钮 -->
|
||||
<div class="window-controls">
|
||||
<div class="window-control-btn" @click="handleMinimize" title="最小化">
|
||||
<svg width="12" height="12" viewBox="0 0 12 12">
|
||||
<rect x="0" y="5" width="12" height="2" fill="currentColor"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="window-control-btn" @click="handleMaximize" :title="isMaximized ? '还原' : '最大化'">
|
||||
<svg v-if="!isMaximized" width="12" height="12" viewBox="0 0 12 12">
|
||||
<rect x="1" y="1" width="10" height="10" fill="none" stroke="currentColor" stroke-width="1.5"/>
|
||||
</svg>
|
||||
<svg v-else width="12" height="12" viewBox="0 0 12 12">
|
||||
<rect x="2" y="0" width="10" height="10" fill="none" stroke="currentColor" stroke-width="1.5"/>
|
||||
<rect x="0" y="2" width="10" height="10" fill="none" stroke="currentColor" stroke-width="1.5"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="window-control-btn close-btn" @click="handleClose" title="关闭">
|
||||
<svg width="12" height="12" viewBox="0 0 12 12">
|
||||
<path d="M1 1L11 11M11 1L1 11" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</a-layout-header>
|
||||
<a-layout-content class="content">
|
||||
<!-- 动态渲染 Tab 内容 -->
|
||||
<!-- 使用 KeepAlive 缓存组件状态,避免切换时重新加载 -->
|
||||
<KeepAlive include="FileSystem">
|
||||
<component :is="getComponent(activeTab)"/>
|
||||
</KeepAlive>
|
||||
</a-layout-content>
|
||||
|
||||
<!-- 设置抽屉 -->
|
||||
<SettingsPanel
|
||||
v-model="showSettings"
|
||||
:config="configStore.appConfig"
|
||||
@save="handleSaveConfig"
|
||||
@open-version-history="showVersionHistory = true"
|
||||
/>
|
||||
|
||||
<!-- 版本历史抽屉 -->
|
||||
<a-drawer
|
||||
v-model:visible="showVersionHistory"
|
||||
:width="720"
|
||||
:footer="false"
|
||||
:unmount-on-close="false"
|
||||
title="版本历史"
|
||||
>
|
||||
<VersionHistory />
|
||||
</a-drawer>
|
||||
|
||||
<!-- 升级提示弹窗 -->
|
||||
<UpdateNotification
|
||||
v-model="updateStore.showUpdate"
|
||||
:update-info="updateStore.updateInfo"
|
||||
@install="updateStore.installUpdate"
|
||||
/>
|
||||
</a-layout>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {computed, onMounted, onUnmounted, ref, watch} from 'vue'
|
||||
import {IconSettings, IconPushpin} from '@arco-design/web-vue/es/icon'
|
||||
import MarkdownEditor from './views/markdown-editor/index.vue'
|
||||
import VersionHistory from './views/version/index.vue'
|
||||
import ThemeToggle from './components/ThemeToggle.vue'
|
||||
import FileSystem from './components/FileSystem/index.vue'
|
||||
import SettingsPanel from './components/SettingsPanel.vue'
|
||||
import UpdateNotification from './components/UpdateNotification.vue'
|
||||
import {useUpdateStore} from './stores/update'
|
||||
import {useConfigStore, type AppConfig} from './stores/config'
|
||||
import {
|
||||
WindowMinimize, WindowToggleAlwaysOnTop,
|
||||
WindowMaximize, WindowIsMaximized, WindowClose
|
||||
} from './wailsjs/v3-bindings/u-desk/app'
|
||||
import { OffAll } from '@wailsio/events'
|
||||
|
||||
// 存储键
|
||||
const ACTIVE_TAB_STORAGE_KEY = 'app-active-tab'
|
||||
|
||||
// 从 localStorage 恢复上次打开的区域,默认为 'file-system'
|
||||
// 兼容旧版:'user' 是 v0.2.x 之前的 tab key,已废弃需迁移
|
||||
const savedTab = localStorage.getItem(ACTIVE_TAB_STORAGE_KEY)
|
||||
const activeTab = ref((savedTab === 'user' ? 'file-system' : savedTab) || 'file-system')
|
||||
const showSettings = ref(false)
|
||||
const showVersionHistory = ref(false)
|
||||
const isMaximized = ref(false)
|
||||
const isPinned = ref(false)
|
||||
|
||||
// 使用 stores
|
||||
const updateStore = useUpdateStore()
|
||||
const configStore = useConfigStore()
|
||||
|
||||
// 应用配置(从 store 获取)
|
||||
const appConfig = computed(() => configStore.appConfig)
|
||||
|
||||
// 可见 Tabs(从 store 获取)
|
||||
const visibleTabs = computed(() => configStore.visibleTabs)
|
||||
|
||||
// 保存配置
|
||||
const handleSaveConfig = async (config: AppConfig) => {
|
||||
try {
|
||||
await configStore.saveConfig(config)
|
||||
showSettings.value = false
|
||||
|
||||
// 如果当前激活的 Tab 被隐藏,切换到默认 Tab
|
||||
if (!config.visibleTabs.includes(activeTab.value)) {
|
||||
activeTab.value = config.defaultTab
|
||||
}
|
||||
} catch (error) {
|
||||
// 错误已在 store 中处理
|
||||
console.error('保存配置失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// 加载配置(调用 store 方法)
|
||||
const loadConfig = async () => {
|
||||
await configStore.loadConfig()
|
||||
// 设置默认 Tab
|
||||
activeTab.value = configStore.defaultTab
|
||||
}
|
||||
|
||||
// 获取组件
|
||||
const getComponent = (key: string) => {
|
||||
const components = {
|
||||
'file-system': FileSystem,
|
||||
'markdown-editor': MarkdownEditor
|
||||
}
|
||||
return components[key] || null
|
||||
}
|
||||
|
||||
// 组件挂载时加载配置
|
||||
// 禁止 Ctrl+滚轮缩放
|
||||
const preventZoom = (e: WheelEvent) => {
|
||||
if (e.ctrlKey) e.preventDefault()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadConfig()
|
||||
|
||||
// 设置更新事件监听
|
||||
updateStore.setupEventListeners()
|
||||
|
||||
// 禁止 Ctrl+滚轮缩放
|
||||
document.addEventListener('wheel', preventZoom, { passive: false })
|
||||
|
||||
// 延迟检查更新(启动后 3 秒,静默模式)
|
||||
setTimeout(() => {
|
||||
updateStore.checkForUpdates(true)
|
||||
}, 3000)
|
||||
})
|
||||
|
||||
// 组件卸载时清理事件监听
|
||||
onUnmounted(() => {
|
||||
document.removeEventListener('wheel', preventZoom)
|
||||
updateStore.removeEventListeners()
|
||||
// 兜底清除所有 Wails 事件监听器,防止泄漏
|
||||
OffAll()
|
||||
})
|
||||
|
||||
// 窗口控制方法
|
||||
const handleMinimize = async () => {
|
||||
try { await WindowMinimize() } catch (e) { console.error('最小化窗口失败:', e) }
|
||||
}
|
||||
|
||||
const handleTogglePin = async () => {
|
||||
try { isPinned.value = await WindowToggleAlwaysOnTop() } catch (e) { console.error('切换置顶失败:', e) }
|
||||
}
|
||||
|
||||
const handleMaximize = async () => {
|
||||
try {
|
||||
await WindowMaximize()
|
||||
isMaximized.value = await WindowIsMaximized()
|
||||
} catch (e) { console.error('最大化窗口失败:', e) }
|
||||
}
|
||||
|
||||
// 双击标题栏区域最大化(排除按钮/Tab 区域)
|
||||
const onHeaderDblClick = (e: MouseEvent) => {
|
||||
const target = e.target as HTMLElement
|
||||
if (target.closest('.window-controls, .header-actions, .arco-tabs')) return
|
||||
handleMaximize()
|
||||
}
|
||||
|
||||
const handleClose = async () => {
|
||||
try { await WindowClose() } catch (e) { console.error('关闭窗口失败:', e) }
|
||||
}
|
||||
|
||||
// 监听 activeTab 变化,如果当前 Tab 不在可见列表中,切换到默认 Tab
|
||||
watch(activeTab, (newTab) => {
|
||||
// 保存到 localStorage
|
||||
localStorage.setItem(ACTIVE_TAB_STORAGE_KEY, newTab)
|
||||
|
||||
// 检查一级 Tab 是否在可见列表中
|
||||
const isVisible = appConfig.value.visibleTabs.includes(newTab)
|
||||
if (!isVisible && appConfig.value.visibleTabs.length > 0 && newTab !== appConfig.value.defaultTab) {
|
||||
activeTab.value = appConfig.value.defaultTab
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.layout {
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.header {
|
||||
background: var(--color-bg-2);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
user-select: none;
|
||||
--wails-draggable: drag; /* Wails 拖拽属性 */
|
||||
}
|
||||
|
||||
.header-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
height: 100%;
|
||||
padding: 0 20px;
|
||||
}
|
||||
|
||||
.header-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-width: 150px;
|
||||
--wails-draggable: drag; /* 左侧标题区域可拖拽 */
|
||||
}
|
||||
|
||||
.header-content h2 {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
font-weight: 500;
|
||||
color: var(--color-text-1);
|
||||
}
|
||||
|
||||
.header-tabs {
|
||||
flex: 1;
|
||||
margin-left: 40px;
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-left: 20px;
|
||||
min-width: 200px;
|
||||
justify-content: flex-end;
|
||||
--wails-draggable: no-drag; /* 按钮区域不响应拖拽 */
|
||||
}
|
||||
|
||||
.window-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-left: 12px;
|
||||
padding-left: 12px;
|
||||
border-left: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.window-control-btn {
|
||||
width: 40px;
|
||||
height: 28px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.15s;
|
||||
color: var(--color-text-2);
|
||||
--wails-draggable: no-drag; /* 窗口控制按钮不响应拖拽 */
|
||||
}
|
||||
|
||||
.window-control-btn:hover {
|
||||
background: var(--color-fill-2);
|
||||
color: var(--color-text-1);
|
||||
}
|
||||
|
||||
.window-control-btn.close-btn:hover {
|
||||
background: rgb(var(--danger-6));
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.pin-active {
|
||||
color: rgb(var(--primary-6)) !important;
|
||||
}
|
||||
|
||||
.pin-active :deep(svg) {
|
||||
transform: none !important;
|
||||
opacity: 1 !important;
|
||||
}
|
||||
|
||||
.header-actions :deep(.arco-icon-pushpin) {
|
||||
transform: rotate(45deg);
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.header-actions :deep(.arco-icon-pushpin.pinned) {
|
||||
transform: none;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.window-control-btn svg {
|
||||
display: block;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.content {
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
background: var(--color-bg-1);
|
||||
}
|
||||
</style>
|
||||
|
||||
<!-- Wails 拖拽样式 -->
|
||||
<style>
|
||||
/* 所有按钮类元素都不可拖拽 */
|
||||
.header-actions,
|
||||
.window-control-btn {
|
||||
--wails-draggable: no-drag;
|
||||
}
|
||||
|
||||
/* tabs 的具体 tab 项不可拖拽,但空白区域可以拖拽 */
|
||||
.arco-tabs-tab {
|
||||
--wails-draggable: no-drag;
|
||||
}
|
||||
|
||||
.arco-tabs-content {
|
||||
padding-top: 0;
|
||||
}
|
||||
|
||||
/* Arco Design 按钮不可拖拽 */
|
||||
.arco-btn,
|
||||
.arco-select,
|
||||
.arco-tooltip {
|
||||
--wails-draggable: no-drag;
|
||||
}
|
||||
|
||||
/* 桌面应用:禁止 html/body 级别滚动条,所有滚动由内部组件自行处理 */
|
||||
html, body {
|
||||
overflow: hidden !important;
|
||||
}
|
||||
</style>
|
||||
199
frontend/src/api/connection-manager.ts
Normal file
199
frontend/src/api/connection-manager.ts
Normal file
@@ -0,0 +1,199 @@
|
||||
/**
|
||||
* 连接管理器 — 管理本地/远程传输层切换
|
||||
*/
|
||||
|
||||
import type { FsTransport } from './transport'
|
||||
import { WailsTransport } from './wails-transport'
|
||||
import { HttpTransport } from './http-transport'
|
||||
|
||||
export type ConnectionType = 'local' | 'remote'
|
||||
|
||||
export interface ConnectionProfile {
|
||||
id: string
|
||||
name: string
|
||||
host: string
|
||||
port: number
|
||||
token: string
|
||||
type: ConnectionType
|
||||
lastConnected?: number
|
||||
}
|
||||
|
||||
export type ConnectionState = 'disconnected' | 'connecting' | 'connected' | 'error'
|
||||
|
||||
const PROFILES_KEY = 'fs_connection_profiles'
|
||||
const ACTIVE_KEY = 'fs_active_connection'
|
||||
|
||||
class ConnectionManagerImpl {
|
||||
private _transport: FsTransport | null = null
|
||||
private _profiles: ConnectionProfile[] = []
|
||||
private _activeId: string | null = null
|
||||
private _state: ConnectionState = 'disconnected'
|
||||
private _stateChangeCallbacks: ((state: ConnectionState) => void)[] = []
|
||||
private _connectSeq = 0
|
||||
|
||||
constructor() {
|
||||
this.loadProfiles()
|
||||
this.initDefaultLocal()
|
||||
}
|
||||
|
||||
private initDefaultLocal() {
|
||||
const localProfile: ConnectionProfile = {
|
||||
id: 'local-default',
|
||||
name: '本地',
|
||||
host: '',
|
||||
port: 0,
|
||||
token: '',
|
||||
type: 'local',
|
||||
}
|
||||
if (!this._profiles.find(p => p.id === localProfile.id)) {
|
||||
this._profiles.unshift(localProfile)
|
||||
}
|
||||
// 默认连接本地
|
||||
if (!this._activeId) {
|
||||
this._activeId = localProfile.id
|
||||
}
|
||||
this.applyActive()
|
||||
}
|
||||
|
||||
private loadProfiles() {
|
||||
try {
|
||||
const raw = localStorage.getItem(PROFILES_KEY)
|
||||
if (raw) this._profiles = JSON.parse(raw)
|
||||
this._activeId = localStorage.getItem(ACTIVE_KEY)
|
||||
} catch { /* 首次使用 */ }
|
||||
}
|
||||
|
||||
private saveProfiles() {
|
||||
localStorage.setItem(PROFILES_KEY, JSON.stringify(this._profiles))
|
||||
if (this._activeId) {
|
||||
localStorage.setItem(ACTIVE_KEY, this._activeId)
|
||||
}
|
||||
}
|
||||
|
||||
private setState(state: ConnectionState) {
|
||||
this._state = state
|
||||
this.notifyChange()
|
||||
}
|
||||
|
||||
private notifyChange() {
|
||||
this._stateChangeCallbacks.forEach(cb => cb(this._state))
|
||||
}
|
||||
|
||||
onStateChange(cb: (state: ConnectionState) => void) {
|
||||
this._stateChangeCallbacks.push(cb)
|
||||
}
|
||||
|
||||
get state(): ConnectionState {
|
||||
return this._state
|
||||
}
|
||||
|
||||
get profiles(): ConnectionProfile[] {
|
||||
return [...this._profiles]
|
||||
}
|
||||
|
||||
get activeProfile(): ConnectionProfile | null {
|
||||
return this._profiles.find(p => p.id === this._activeId) ?? null
|
||||
}
|
||||
|
||||
getTransport(): FsTransport {
|
||||
if (!this._transport) {
|
||||
this.applyActive()
|
||||
}
|
||||
return this._transport!
|
||||
}
|
||||
|
||||
getFileServerBaseURL(): string {
|
||||
if (this._transport instanceof HttpTransport) {
|
||||
const profile = this.activeProfile
|
||||
if (!profile) return ''
|
||||
const scheme = profile.port === 443 ? 'https' : 'http'
|
||||
const port = (profile.port === 80 || profile.port === 443) ? '' : `:${profile.port}`
|
||||
return `${scheme}://${profile.host}${port}`
|
||||
}
|
||||
// Wails 模式返回空字符串,让 useFilePreview 走原有逻辑
|
||||
return ''
|
||||
}
|
||||
|
||||
isRemote(): boolean {
|
||||
return this.activeProfile?.type === 'remote'
|
||||
}
|
||||
|
||||
connect(profileId: string): void {
|
||||
const profile = this._profiles.find(p => p.id === profileId)
|
||||
if (!profile) return
|
||||
|
||||
this._activeId = profileId
|
||||
this.saveProfiles()
|
||||
this.applyActive()
|
||||
}
|
||||
|
||||
disconnect(): void {
|
||||
this._activeId = 'local-default'
|
||||
this.saveProfiles()
|
||||
this.applyActive()
|
||||
}
|
||||
|
||||
addProfile(profile: Omit<ConnectionProfile, 'id'>): ConnectionProfile {
|
||||
const newProfile: ConnectionProfile = {
|
||||
...profile,
|
||||
id: crypto.randomUUID(),
|
||||
}
|
||||
this._profiles.push(newProfile)
|
||||
this.saveProfiles()
|
||||
this.notifyChange()
|
||||
return newProfile
|
||||
}
|
||||
|
||||
updateProfile(id: string, updates: Partial<ConnectionProfile>): void {
|
||||
const idx = this._profiles.findIndex(p => p.id === id)
|
||||
if (idx >= 0) {
|
||||
this._profiles[idx] = { ...this._profiles[idx], ...updates }
|
||||
this.saveProfiles()
|
||||
// 仅当连接参数变化时重新应用(避免 lastConnected 等元数据更新触发死循环)
|
||||
const REAPPLY_KEYS = ['host', 'port', 'token'] as const
|
||||
const needsReapply = REAPPLY_KEYS.some(k => k in updates)
|
||||
if (needsReapply && id === this._activeId) {
|
||||
this.applyActive()
|
||||
}
|
||||
this.notifyChange()
|
||||
}
|
||||
}
|
||||
|
||||
removeProfile(id: string): void {
|
||||
if (id === 'local-default') return // 不允许删除本地配置
|
||||
this._profiles = this._profiles.filter(p => p.id !== id)
|
||||
if (this._activeId === id) {
|
||||
this._activeId = 'local-default'
|
||||
}
|
||||
this.saveProfiles()
|
||||
this.applyActive()
|
||||
this.notifyChange()
|
||||
}
|
||||
|
||||
private applyActive() {
|
||||
const profile = this.activeProfile
|
||||
const seq = ++this._connectSeq
|
||||
if (!profile || profile.type === 'local') {
|
||||
this._transport = new WailsTransport()
|
||||
this.setState('connected')
|
||||
} else {
|
||||
this.setState('connecting')
|
||||
try {
|
||||
this._transport = new HttpTransport(profile.host, profile.port, profile.token)
|
||||
// 快速连通性检查(用轻量 ping 代替 getCommonPaths)
|
||||
this._transport.getFileInfo('/').then(() => {
|
||||
if (seq !== this._connectSeq) return // 已被后续连接覆盖
|
||||
this.setState('connected')
|
||||
this.updateProfile(profile.id!, { lastConnected: Date.now() })
|
||||
}).catch(() => {
|
||||
if (seq !== this._connectSeq) return
|
||||
this.setState('error')
|
||||
})
|
||||
} catch {
|
||||
this.setState('error')
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const connectionManager = new ConnectionManagerImpl()
|
||||
136
frontend/src/api/http-transport.ts
Normal file
136
frontend/src/api/http-transport.ts
Normal file
@@ -0,0 +1,136 @@
|
||||
/**
|
||||
* Http Transport — 远程文件操作(通过 u-fs-agent REST API)
|
||||
*/
|
||||
|
||||
import type { FsTransport, FileItem, FileOperationResult, DetectTypeResult } from './transport'
|
||||
|
||||
const CONTENT_TYPE = 'application/json'
|
||||
|
||||
export class HttpTransport implements FsTransport {
|
||||
private baseUrl: string
|
||||
private token: string
|
||||
|
||||
constructor(host: string, port: number, token: string) {
|
||||
const scheme = port === 443 ? 'https' : 'http'
|
||||
this.baseUrl = `${scheme}://${host}${port === 80 || port === 443 ? '' : ':' + port}`
|
||||
this.token = token
|
||||
}
|
||||
|
||||
private headers(): HeadersInit {
|
||||
const h: Record<string, string> = { 'Content-Type': CONTENT_TYPE }
|
||||
if (this.token) h['Authorization'] = `Bearer ${this.token}`
|
||||
return h
|
||||
}
|
||||
|
||||
private async request<T>(method: string, path: string, params?: Record<string, string>, body?: any): Promise<T> {
|
||||
const url = `${this.baseUrl}${path}`
|
||||
const searchParams = params ? '?' + new URLSearchParams(params).toString() : ''
|
||||
const opts: RequestInit = {
|
||||
method,
|
||||
headers: this.headers(),
|
||||
}
|
||||
if (body !== undefined) opts.body = JSON.stringify(body)
|
||||
|
||||
const res = await fetch(url + searchParams, opts)
|
||||
if (!res.ok) {
|
||||
const text = await res.text().catch(() => '')
|
||||
throw new Error(`HTTP ${res.status}: ${text.slice(0, 200)}`)
|
||||
}
|
||||
const data = await res.json()
|
||||
|
||||
if (data.code >= 400) {
|
||||
throw new Error(data.message || `请求失败 (code=${data.code})`)
|
||||
}
|
||||
return data.data ?? data
|
||||
}
|
||||
|
||||
async listDir(path: string): Promise<FileItem[]> {
|
||||
return this.request<FileItem[]>('GET', '/api/v1/fs', { path })
|
||||
}
|
||||
|
||||
async getFileInfo(path: string): Promise<Record<string, any>> {
|
||||
return this.request<Record<string, any>>('GET', '/api/v1/fs', { path, get: 'stat' })
|
||||
}
|
||||
|
||||
async readFile(path: string): Promise<string> {
|
||||
const data = await this.request<{ content: string }>('GET', '/api/v1/fs/read', { path })
|
||||
return data.content
|
||||
}
|
||||
|
||||
async writeFile(path: string, content: string): Promise<void> {
|
||||
await this.request('PUT', '/api/v1/fs/write', { path }, { content })
|
||||
}
|
||||
|
||||
async saveBase64File(path: string, content: string): Promise<void> {
|
||||
if (!content) throw new Error('无效的 base64 内容')
|
||||
await this.request('POST', '/api/v1/fs/upload', { path }, { content })
|
||||
}
|
||||
|
||||
async createFile(dirPath: string, filename: string): Promise<FileOperationResult> {
|
||||
return this.request<FileOperationResult>('POST', '/api/v1/fs/create', { path: dirPath }, { type: 'file', name: filename })
|
||||
}
|
||||
|
||||
async createDir(parentPath: string, dirname: string): Promise<FileOperationResult> {
|
||||
return this.request<FileOperationResult>('POST', '/api/v1/fs/create', { path: parentPath }, { type: 'dir', name: dirname })
|
||||
}
|
||||
|
||||
async deletePath(path: string): Promise<FileOperationResult> {
|
||||
return this.request<FileOperationResult>('DELETE', '/api/v1/fs/delete', { path })
|
||||
}
|
||||
|
||||
async renamePath(oldPath: string, newPath: string): Promise<FileOperationResult> {
|
||||
return this.request<FileOperationResult>('PATCH', '/api/v1/fs/rename', { path: oldPath }, { new_path: newPath })
|
||||
}
|
||||
|
||||
async listZipContents(zipPath: string): Promise<FileItem[]> {
|
||||
// Wave 3 实现
|
||||
throw new Error('ZIP 操作在远程模式暂未实现')
|
||||
}
|
||||
|
||||
async extractFileFromZip(_zipPath: string, _filePath: string): Promise<string> {
|
||||
throw new Error('ZIP 操作在远程模式暂未实现')
|
||||
}
|
||||
|
||||
async extractFileFromZipToTemp(_zipPath: string, _filePath: string): Promise<string> {
|
||||
throw new Error('ZIP 操作在远程模式暂未实现')
|
||||
}
|
||||
|
||||
async getZipFileInfo(_zipPath: string, _filePath: string): Promise<FileItem> {
|
||||
throw new Error('ZIP 操作在远程模式暂未实现')
|
||||
}
|
||||
|
||||
async openPath(_path: string): Promise<void> {
|
||||
throw new Error('远程模式不支持打开本地路径')
|
||||
}
|
||||
|
||||
async getFileServerURL(): Promise<string> {
|
||||
return `${this.baseUrl}/api/v1/proxy/localfs`
|
||||
}
|
||||
|
||||
/** 远程模式预览用的认证 token(拼接到 URL query) */
|
||||
getPreviewToken(): string {
|
||||
return this.token
|
||||
}
|
||||
|
||||
async resolveShortcut(_lnkPath: string): Promise<any> {
|
||||
return null
|
||||
}
|
||||
|
||||
async detectFileTypeByContent(path: string): Promise<DetectTypeResult> {
|
||||
return this.request<DetectTypeResult>('GET', '/api/v1/fs/detect', { path })
|
||||
}
|
||||
|
||||
async getCommonPaths(): Promise<Record<string, string>> {
|
||||
return this.request<Record<string, string>>('GET', '/api/v1/system/common-paths')
|
||||
}
|
||||
|
||||
async getRecycleBinEntries(): Promise<any[]> {
|
||||
return []
|
||||
}
|
||||
|
||||
async restoreFromRecycleBin(_path: string): Promise<void> {}
|
||||
|
||||
async deletePermanently(_path: string): Promise<void> {}
|
||||
|
||||
async emptyRecycleBin(): Promise<void> {}
|
||||
}
|
||||
6
frontend/src/api/index.ts
Normal file
6
frontend/src/api/index.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
/**
|
||||
* API 统一导出
|
||||
*/
|
||||
|
||||
export * from './types'
|
||||
export * from './system'
|
||||
110
frontend/src/api/system.ts
Normal file
110
frontend/src/api/system.ts
Normal file
@@ -0,0 +1,110 @@
|
||||
/**
|
||||
* 系统信息相关 API — 委托给 Transport 层
|
||||
* 本地模式走 Wails IPC,远程模式走 HTTP REST API
|
||||
*/
|
||||
|
||||
import type { File } from './types'
|
||||
import { connectionManager } from './connection-manager'
|
||||
|
||||
/**
|
||||
* 转换后端文件数据格式(蛇形 → 驼峰)
|
||||
*/
|
||||
function transformFile(file: any): File {
|
||||
return { ...file, isDir: file.is_dir, modified_time: file.mod_time }
|
||||
}
|
||||
|
||||
function transformFileList(files: any[]): File[] {
|
||||
return files.map(transformFile)
|
||||
}
|
||||
|
||||
const t = () => connectionManager.getTransport()
|
||||
|
||||
export async function getSystemInfo() { return t().getFileInfo('/') }
|
||||
|
||||
export async function getCPUInfo() {
|
||||
if (connectionManager.isRemote()) return {}
|
||||
try { return await (window.go?.main?.App?.GetCPUInfo?.()) ?? {} } catch { return {} }
|
||||
}
|
||||
|
||||
export async function getMemoryInfo() {
|
||||
if (connectionManager.isRemote()) return {}
|
||||
try { return await (window.go?.main?.App?.GetMemoryInfo?.()) ?? {} } catch { return {} }
|
||||
}
|
||||
|
||||
export async function getDiskInfo() {
|
||||
if (connectionManager.isRemote()) return {}
|
||||
try { return await (window.go?.main?.App?.GetDiskInfo?.()) ?? {} } catch { return {} }
|
||||
}
|
||||
|
||||
export async function listDir(path: string): Promise<File[]> {
|
||||
return transformFileList(await t().listDir(path))
|
||||
}
|
||||
|
||||
export async function readFile(path: string): Promise<string> {
|
||||
return t().readFile(path)
|
||||
}
|
||||
|
||||
export async function writeFile(path: string, content: string): Promise<void> {
|
||||
await t().writeFile(path, String(content))
|
||||
}
|
||||
|
||||
export async function saveBase64File(path: string, base64Content: string): Promise<void> {
|
||||
if (!base64Content) throw new Error('无效的 base64 内容')
|
||||
await t().saveBase64File(path, base64Content)
|
||||
}
|
||||
|
||||
export async function deletePath(path: string): Promise<any> {
|
||||
return t().deletePath(path)
|
||||
}
|
||||
|
||||
export async function createDir(parentPath: string, dirname: string): Promise<any> {
|
||||
return t().createDir(parentPath, dirname)
|
||||
}
|
||||
|
||||
export async function createFile(dirPath: string, filename: string): Promise<any> {
|
||||
return t().createFile(dirPath, filename)
|
||||
}
|
||||
|
||||
export async function renamePath(oldPath: string, newPath: string): Promise<any> {
|
||||
return t().renamePath(oldPath, String(newPath))
|
||||
}
|
||||
|
||||
export async function getEnvVars(): Promise<Record<string, string>> {
|
||||
try { return await (window.go?.main?.App?.GetEnvVars?.()) ?? {} } catch { return {} }
|
||||
}
|
||||
|
||||
export async function listZipContents(zipPath: string): Promise<File[]> {
|
||||
return transformFileList(await t().listZipContents(zipPath))
|
||||
}
|
||||
|
||||
export async function extractFileFromZip(zipPath: string, filePath: string): Promise<string> {
|
||||
return t().extractFileFromZip(zipPath, filePath)
|
||||
}
|
||||
|
||||
export async function extractFileFromZipToTemp(zipPath: string, filePath: string): Promise<string> {
|
||||
return t().extractFileFromZipToTemp(zipPath, filePath)
|
||||
}
|
||||
|
||||
export async function getZipFileInfo(zipPath: string, filePath: string): Promise<File> {
|
||||
return transformFile(await t().getZipFileInfo(zipPath, filePath))
|
||||
}
|
||||
|
||||
export async function openPath(path: string): Promise<void> {
|
||||
await t().openPath(path)
|
||||
}
|
||||
|
||||
export async function getFileServerURL(): Promise<string> {
|
||||
return t().getFileServerURL()
|
||||
}
|
||||
|
||||
export async function resolveShortcut(lnkPath: string): Promise<any> {
|
||||
return t().resolveShortcut(lnkPath)
|
||||
}
|
||||
|
||||
export async function detectFileTypeByContent(path: string) {
|
||||
return t().detectFileTypeByContent(path)
|
||||
}
|
||||
|
||||
export async function getCommonPaths() {
|
||||
return t().getCommonPaths()
|
||||
}
|
||||
71
frontend/src/api/transport.ts
Normal file
71
frontend/src/api/transport.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
/**
|
||||
* 文件系统传输层接口
|
||||
* 本地模式走 Wails IPC,远程模式走 HTTP REST API
|
||||
* Composable 和组件不感知底层差异
|
||||
*/
|
||||
|
||||
export type FileItem = {
|
||||
name: string
|
||||
path: string
|
||||
size: number
|
||||
size_str?: string
|
||||
is_dir: boolean
|
||||
mod_time?: string
|
||||
mode?: string
|
||||
}
|
||||
|
||||
export type FileOperationResult = {
|
||||
path: string
|
||||
name: string
|
||||
size: number
|
||||
size_str?: string
|
||||
is_dir: boolean
|
||||
mod_time?: string
|
||||
mode?: string
|
||||
old_path?: string
|
||||
deleted?: boolean
|
||||
}
|
||||
|
||||
export type DetectTypeResult = {
|
||||
extension: string
|
||||
category: string
|
||||
mime_type: string
|
||||
confidence: number
|
||||
}
|
||||
|
||||
export interface FsTransport {
|
||||
// 文件列表与信息
|
||||
listDir(path: string): Promise<FileItem[]>
|
||||
getFileInfo(path: string): Promise<Record<string, any>>
|
||||
|
||||
// 文件读写
|
||||
readFile(path: string): Promise<string>
|
||||
writeFile(path: string, content: string): Promise<void>
|
||||
saveBase64File(path: string, content: string): Promise<void>
|
||||
|
||||
// 文件操作
|
||||
createFile(dirPath: string, filename: string): Promise<FileOperationResult>
|
||||
createDir(parentPath: string, dirname: string): Promise<FileOperationResult>
|
||||
deletePath(path: string): Promise<FileOperationResult>
|
||||
renamePath(oldPath: string, newPath: string): Promise<FileOperationResult>
|
||||
|
||||
// ZIP 操作(Wave 3)
|
||||
listZipContents(zipPath: string): Promise<FileItem[]>
|
||||
extractFileFromZip(zipPath: string, filePath: string): Promise<string>
|
||||
extractFileFromZipToTemp(zipPath: string, filePath: string): Promise<string>
|
||||
getZipFileInfo(zipPath: string, filePath: string): Promise<FileItem>
|
||||
|
||||
// 系统操作
|
||||
openPath(path: string): Promise<void>
|
||||
getFileServerURL(): Promise<string>
|
||||
getPreviewToken(): string
|
||||
resolveShortcut(lnkPath: string): Promise<any>
|
||||
detectFileTypeByContent(path: string): Promise<DetectTypeResult>
|
||||
getCommonPaths(): Promise<Record<string, string>>
|
||||
|
||||
// 回收站(Wave 3)
|
||||
getRecycleBinEntries(): Promise<any[]>
|
||||
restoreFromRecycleBin(path: string): Promise<void>
|
||||
deletePermanently(path: string): Promise<void>
|
||||
emptyRecycleBin(): Promise<void>
|
||||
}
|
||||
40
frontend/src/api/types.ts
Normal file
40
frontend/src/api/types.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
/**
|
||||
* API 类型定义
|
||||
*/
|
||||
|
||||
// 系统信息
|
||||
export interface SystemInfo {
|
||||
os: string
|
||||
arch: string
|
||||
version: string
|
||||
}
|
||||
|
||||
export interface CPU {
|
||||
model: string
|
||||
cores: number
|
||||
usage: number
|
||||
}
|
||||
|
||||
export interface Memory {
|
||||
total: number
|
||||
used: number
|
||||
free: number
|
||||
usage: number
|
||||
}
|
||||
|
||||
export interface Disk {
|
||||
path: string
|
||||
total: number
|
||||
used: number
|
||||
free: number
|
||||
usage: number
|
||||
}
|
||||
|
||||
export interface File {
|
||||
name: string
|
||||
path: string
|
||||
size: number
|
||||
isDir: boolean
|
||||
modified?: string
|
||||
modified_time?: string
|
||||
}
|
||||
119
frontend/src/api/wails-transport.ts
Normal file
119
frontend/src/api/wails-transport.ts
Normal file
@@ -0,0 +1,119 @@
|
||||
/**
|
||||
* Wails Transport — 本地文件操作(通过 Wails v3 IPC)
|
||||
*/
|
||||
|
||||
import type { FsTransport, FileItem, FileOperationResult, DetectTypeResult } from './transport'
|
||||
import {
|
||||
ListDir, GetFileInfo, ReadFile, WriteFile, SaveBase64File,
|
||||
CreateFile, CreateDir, DeletePath, RenamePath,
|
||||
ListZipContents, ExtractFileFromZip, ExtractFileFromZipToTemp, GetZipFileInfo,
|
||||
OpenPath, GetFileServerURL, ResolveShortcut, DetectFileTypeByContent,
|
||||
GetCommonPaths, GetRecycleBinEntries, RestoreFromRecycleBin,
|
||||
DeletePermanently, EmptyRecycleBin
|
||||
} from '../wailsjs/v3-bindings/u-desk/app'
|
||||
|
||||
function transformFile(file: any): FileItem {
|
||||
return { ...file, is_dir: file.is_dir, mod_time: file.mod_time }
|
||||
}
|
||||
|
||||
function transformFileList(files: any[]): FileItem[] {
|
||||
return files.map(transformFile)
|
||||
}
|
||||
|
||||
export class WailsTransport implements FsTransport {
|
||||
async listDir(path: string): Promise<FileItem[]> {
|
||||
return transformFileList(await ListDir(path))
|
||||
}
|
||||
|
||||
async getFileInfo(path: string): Promise<Record<string, any>> {
|
||||
return GetFileInfo(path)
|
||||
}
|
||||
|
||||
async readFile(path: string): Promise<string> {
|
||||
return ReadFile(path)
|
||||
}
|
||||
|
||||
async writeFile(path: string, content: string): Promise<void> {
|
||||
await WriteFile({ path: String(path), content: String(content) })
|
||||
}
|
||||
|
||||
async saveBase64File(path: string, content: string): Promise<void> {
|
||||
if (!content) throw new Error('无效的 base64 内容')
|
||||
await SaveBase64File({ path: String(path), content })
|
||||
}
|
||||
|
||||
async createFile(dirPath: string, filename: string): Promise<FileOperationResult> {
|
||||
const fullPath = dirPath.replace(/\/$/, '') + '/' + filename
|
||||
return CreateFile(fullPath)
|
||||
}
|
||||
|
||||
async createDir(parentPath: string, dirname: string): Promise<FileOperationResult> {
|
||||
const fullPath = parentPath.replace(/\/$/, '') + '/' + dirname
|
||||
return CreateDir(fullPath)
|
||||
}
|
||||
|
||||
async deletePath(path: string): Promise<FileOperationResult> {
|
||||
return DeletePath(path)
|
||||
}
|
||||
|
||||
async renamePath(oldPath: string, newPath: string): Promise<FileOperationResult> {
|
||||
return RenamePath({ oldPath: String(oldPath), newPath: String(newPath) })
|
||||
}
|
||||
|
||||
async listZipContents(zipPath: string): Promise<FileItem[]> {
|
||||
return transformFileList(await ListZipContents(zipPath))
|
||||
}
|
||||
|
||||
async extractFileFromZip(zipPath: string, filePath: string): Promise<string> {
|
||||
return ExtractFileFromZip(zipPath, filePath)
|
||||
}
|
||||
|
||||
async extractFileFromZipToTemp(zipPath: string, filePath: string): Promise<string> {
|
||||
return ExtractFileFromZipToTemp(zipPath, filePath)
|
||||
}
|
||||
|
||||
async getZipFileInfo(zipPath: string, filePath: string): Promise<FileItem> {
|
||||
return transform(await GetZipFileInfo(zipPath, filePath))
|
||||
}
|
||||
|
||||
async openPath(path: string): Promise<void> {
|
||||
await OpenPath(path)
|
||||
}
|
||||
|
||||
async getFileServerURL(): Promise<string> {
|
||||
return GetFileServerURL()
|
||||
}
|
||||
|
||||
getPreviewToken(): string {
|
||||
return '' // 本地模式无需 token
|
||||
}
|
||||
|
||||
async resolveShortcut(lnkPath: string): Promise<any> {
|
||||
return ResolveShortcut(lnkPath)
|
||||
}
|
||||
|
||||
async detectFileTypeByContent(path: string): Promise<DetectTypeResult> {
|
||||
const result = await DetectFileTypeByContent(path)
|
||||
return result as unknown as DetectTypeResult
|
||||
}
|
||||
|
||||
async getCommonPaths(): Promise<Record<string, string>> {
|
||||
return GetCommonPaths()
|
||||
}
|
||||
|
||||
async getRecycleBinEntries(): Promise<any[]> {
|
||||
return GetRecycleBinEntries()
|
||||
}
|
||||
|
||||
async restoreFromRecycleBin(path: string): Promise<void> {
|
||||
await RestoreFromRecycleBin(path)
|
||||
}
|
||||
|
||||
async deletePermanently(path: string): Promise<void> {
|
||||
await DeletePermanently(path)
|
||||
}
|
||||
|
||||
async emptyRecycleBin(): Promise<void> {
|
||||
await EmptyRecycleBin()
|
||||
}
|
||||
}
|
||||
303
frontend/src/components/CodeEditor.vue
Normal file
303
frontend/src/components/CodeEditor.vue
Normal file
@@ -0,0 +1,303 @@
|
||||
<template>
|
||||
<div ref="editorContainer" class="codemirror-editor"></div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, watch, onBeforeUnmount, nextTick } from 'vue'
|
||||
import {
|
||||
EditorView, lineNumbers, highlightActiveLineGutter, keymap,
|
||||
EditorState, Compartment,
|
||||
defaultKeymap, history,
|
||||
bracketMatching, defaultHighlightStyle, syntaxHighlighting,
|
||||
oneDark,
|
||||
openSearchPanel, search
|
||||
} from '@/utils/codemirrorExports'
|
||||
import { useThemeStore } from '@/stores/theme'
|
||||
import { loadLanguageExtension, getLanguageFromExtension } from '@/utils/codeMirrorLoader'
|
||||
|
||||
// ==================== Props & Emits ====================
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: { type: String, required: true },
|
||||
fileExtension: { type: String, default: '' },
|
||||
filePath: { type: String, default: '' },
|
||||
fileMtime: { type: String, default: '' }
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:modelValue'])
|
||||
|
||||
// ==================== 状态管理 ====================
|
||||
|
||||
const themeStore = useThemeStore()
|
||||
const editorContainer = ref(null)
|
||||
let view = null
|
||||
|
||||
// 滚动位置缓存:LRU 最多 5 份,每份 3 分钟过期
|
||||
const MAX_SCROLL_CACHE = 5
|
||||
const SCROLL_CACHE_TTL = 3 * 60 * 1000 // 3 分钟
|
||||
const fileScrollPositions = new Map() // filePath → { scrollTop, anchor, timestamp }
|
||||
let currentFilePath = ''
|
||||
let saveScrollTimer = null
|
||||
|
||||
// 清理过期缓存 + LRU 淘汰,保持最多 MAX_SCROLL_CACHE 条
|
||||
const cleanScrollCache = () => {
|
||||
const now = Date.now()
|
||||
// 清理过期的
|
||||
for (const [key, val] of fileScrollPositions) {
|
||||
if (now - val.timestamp > SCROLL_CACHE_TTL) {
|
||||
fileScrollPositions.delete(key)
|
||||
}
|
||||
}
|
||||
// LRU:超出上限时删除最旧的
|
||||
if (fileScrollPositions.size > MAX_SCROLL_CACHE) {
|
||||
let oldestKey = null
|
||||
let oldestTime = Infinity
|
||||
for (const [key, val] of fileScrollPositions) {
|
||||
if (val.timestamp < oldestTime) {
|
||||
oldestTime = val.timestamp
|
||||
oldestKey = key
|
||||
}
|
||||
}
|
||||
if (oldestKey) fileScrollPositions.delete(oldestKey)
|
||||
}
|
||||
}
|
||||
|
||||
// 使用 Compartment 实现动态切换,避免重建编辑器
|
||||
const themeCompartment = new Compartment()
|
||||
const languageCompartment = new Compartment()
|
||||
|
||||
// ==================== 防抖处理 ====================
|
||||
|
||||
let emitTimeout = null
|
||||
const debouncedEmit = (value) => {
|
||||
if (emitTimeout) {
|
||||
clearTimeout(emitTimeout)
|
||||
}
|
||||
emitTimeout = setTimeout(() => {
|
||||
emit('update:modelValue', value)
|
||||
}, 150)
|
||||
}
|
||||
|
||||
// 获取当前主题扩展
|
||||
const getThemeExtension = () => {
|
||||
if (themeStore.isDark) {
|
||||
return [oneDark]
|
||||
} else {
|
||||
// 亮色主题:使用默认语法高亮样式
|
||||
return [
|
||||
EditorView.theme({
|
||||
'&': { backgroundColor: '#ffffff' },
|
||||
'.cm-gutters': { backgroundColor: '#f7f7f7', color: '#999', border: 'none' },
|
||||
'.cm-activeLineGutter': { backgroundColor: '#e8e8e8', color: '#333' },
|
||||
'.cm-line': { caretColor: '#000' },
|
||||
'.cm-selection': { backgroundColor: '#d9d9d9' },
|
||||
'.cm-cursor': { borderLeftColor: '#000' }
|
||||
}),
|
||||
syntaxHighlighting(defaultHighlightStyle)
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 扩展配置 ====================
|
||||
|
||||
const createExtensions = () => {
|
||||
const extensions = [
|
||||
lineNumbers(),
|
||||
highlightActiveLineGutter(),
|
||||
history(),
|
||||
keymap.of(defaultKeymap),
|
||||
bracketMatching(),
|
||||
|
||||
// 查找替换(Ctrl+F / Ctrl+H)
|
||||
search(),
|
||||
|
||||
// 内容更新监听(带防抖)
|
||||
EditorView.updateListener.of((update) => {
|
||||
if (update.docChanged) {
|
||||
debouncedEmit(update.state.doc.toString())
|
||||
}
|
||||
}),
|
||||
|
||||
// 基础样式
|
||||
EditorView.theme({
|
||||
'&': { height: '100%', fontSize: '13px' },
|
||||
'.cm-scroller': { overflow: 'auto', fontFamily: 'Consolas, Monaco, Courier New, monospace' },
|
||||
'.cm-content': { padding: '8px' },
|
||||
'.cm-line': { padding: '0 0' },
|
||||
'&.cm-focused': { outline: 'none' }
|
||||
}),
|
||||
|
||||
// 使用 Compartment 支持动态切换主题
|
||||
themeCompartment.of(getThemeExtension()),
|
||||
|
||||
// 使用 Compartment 支持动态切换语言
|
||||
languageCompartment.of([])
|
||||
]
|
||||
|
||||
return extensions
|
||||
}
|
||||
|
||||
// ==================== 语言管理 ====================
|
||||
|
||||
const initLanguage = async () => {
|
||||
const language = getLanguageFromExtension(props.fileExtension)
|
||||
if (language === 'text') return
|
||||
|
||||
try {
|
||||
const langExtension = await loadLanguageExtension(language)
|
||||
if (langExtension && view) {
|
||||
view.dispatch({
|
||||
effects: languageCompartment.reconfigure(langExtension)
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn(`[CodeEditor] 加载语言包失败: ${language}`, error)
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 编辑器创建 ====================
|
||||
|
||||
const createEditor = (docContent = '') => {
|
||||
if (!editorContainer.value) return
|
||||
|
||||
const state = EditorState.create({
|
||||
doc: docContent,
|
||||
extensions: createExtensions()
|
||||
})
|
||||
|
||||
view = new EditorView({ state, parent: editorContainer.value })
|
||||
|
||||
// 滚动时防抖保存位置
|
||||
view.scrollDOM.addEventListener('scroll', () => {
|
||||
if (saveScrollTimer) clearTimeout(saveScrollTimer)
|
||||
saveScrollTimer = setTimeout(saveScrollPosition, 200)
|
||||
}, { passive: true })
|
||||
|
||||
// 初始化语言
|
||||
initLanguage()
|
||||
}
|
||||
|
||||
// ==================== 生命周期 ====================
|
||||
|
||||
onMounted(() => {
|
||||
createEditor(props.modelValue || '')
|
||||
|
||||
// 确保主题正确应用(在下一 tick)
|
||||
nextTick(() => {
|
||||
if (view) {
|
||||
view.dispatch({
|
||||
effects: themeCompartment.reconfigure(getThemeExtension())
|
||||
})
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (emitTimeout) clearTimeout(emitTimeout)
|
||||
if (saveScrollTimer) clearTimeout(saveScrollTimer)
|
||||
if (view?.scrollDOM) {
|
||||
view.scrollDOM.removeEventListener('scroll', saveScrollPosition)
|
||||
}
|
||||
view?.destroy()
|
||||
view = null
|
||||
})
|
||||
|
||||
// ==================== 监听器 ====================
|
||||
|
||||
// 保存当前文件滚动位置(防抖)
|
||||
const saveScrollPosition = () => {
|
||||
if (!view || !currentFilePath) return
|
||||
const scroller = view.scrollDOM
|
||||
if (!scroller) return
|
||||
fileScrollPositions.set(currentFilePath, {
|
||||
scrollTop: scroller.scrollTop,
|
||||
anchor: view.state.selection.main.anchor,
|
||||
timestamp: Date.now()
|
||||
})
|
||||
cleanScrollCache()
|
||||
}
|
||||
|
||||
// 监听外部内容变化(切换文件/文件变更时触发)
|
||||
watch([() => props.modelValue, () => props.fileMtime], ([newValue, newMtime], [oldValue, oldMtime]) => {
|
||||
// 文件修改时间变了 → 说明磁盘内容有变更 → 强制刷新
|
||||
const mtimeChanged = newMtime && oldMtime && newMtime !== oldMtime
|
||||
if (view && (mtimeChanged || newValue !== view.state.doc.toString())) {
|
||||
// 先保存旧文件的滚动位置
|
||||
saveScrollPosition()
|
||||
|
||||
const newPath = props.filePath || ''
|
||||
const isSameFile = currentFilePath && currentFilePath === newPath
|
||||
|
||||
view.dispatch({
|
||||
changes: { from: 0, to: view.state.doc.length, insert: newValue || '' },
|
||||
selection: { anchor: 0 }
|
||||
})
|
||||
|
||||
currentFilePath = newPath
|
||||
|
||||
if (isSameFile && fileScrollPositions.has(newPath)) {
|
||||
// 同一文件 → 检查是否过期,未过期则恢复位置
|
||||
const saved = fileScrollPositions.get(newPath)
|
||||
if (saved && Date.now() - saved.timestamp <= SCROLL_CACHE_TTL) {
|
||||
nextTick(() => {
|
||||
if (view) {
|
||||
view.dispatch({
|
||||
selection: { anchor: saved.anchor },
|
||||
effects: EditorView.scrollIntoView(saved.anchor)
|
||||
})
|
||||
view.scrollDOM.scrollTop = saved.scrollTop
|
||||
}
|
||||
})
|
||||
} else {
|
||||
// 过期了 → 强制滚动到顶部
|
||||
nextTick(() => {
|
||||
if (view) view.scrollDOM.scrollTop = 0
|
||||
})
|
||||
}
|
||||
} else {
|
||||
// 不同文件 → 强制滚动到顶部(scrollIntoView 不一定重置 DOM scrollTop)
|
||||
nextTick(() => {
|
||||
if (view) {
|
||||
view.scrollDOM.scrollTop = 0
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// 监听主题变化(使用 Compartment 重建,不丢失状态)
|
||||
watch(() => themeStore.isDark, () => {
|
||||
if (view) {
|
||||
view.dispatch({
|
||||
effects: themeCompartment.reconfigure(getThemeExtension())
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// 监听文件扩展名变化(重新加载语言)
|
||||
watch(() => props.fileExtension, () => {
|
||||
initLanguage()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.codemirror-editor {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.codemirror-editor :deep(.cm-editor) {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.codemirror-editor :deep(.cm-scroller) {
|
||||
overflow: auto;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.codemirror-editor :deep(.cm-content) {
|
||||
/* 不设 height,让 CodeMirror 虚拟滚动自行计算文档高度 */
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,77 @@
|
||||
<template>
|
||||
<a-modal :visible="props.visible" :title="editingId ? '编辑连接' : '添加服务器'" unmount-on-close @cancel="emit('update:visible', false)" @before-ok="handleOk" :ok-loading="submitting">
|
||||
<div style="display: flex; flex-direction: column; gap: 16px; max-width: 400px">
|
||||
<div>
|
||||
<div style="margin-bottom: 4px; font-size: 14px">名称</div>
|
||||
<a-input v-model="form.name" placeholder="如:生产服务器" />
|
||||
</div>
|
||||
<div>
|
||||
<div style="margin-bottom: 4px; font-size: 14px">地址</div>
|
||||
<a-input v-model="form.host" placeholder="192.168.1.100" />
|
||||
</div>
|
||||
<div>
|
||||
<div style="margin-bottom: 4px; font-size: 14px">端口</div>
|
||||
<a-input-number v-model="form.port" :min="1" :max="65535" placeholder="9876" style="width: 100%" />
|
||||
</div>
|
||||
<div>
|
||||
<div style="margin-bottom: 4px; font-size: 14px">
|
||||
Token <span style="color: var(--color-text-3); font-size: 12px">API 认证令牌(与服务器配置一致)</span>
|
||||
</div>
|
||||
<a-input v-model="form.token" type="password" placeholder="留空则不认证" allow-clear />
|
||||
</div>
|
||||
</div>
|
||||
</a-modal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { reactive, ref, watch } from 'vue'
|
||||
import { Message } from '@arco-design/web-vue'
|
||||
import { connectionManager } from '@/api/connection-manager'
|
||||
|
||||
const props = defineProps<{ visible: boolean }>()
|
||||
const emit = defineEmits<{ (e: 'update:visible', val: boolean): void; (e: 'close'): void }>()
|
||||
|
||||
const editingId = ref<string | null>(null)
|
||||
const submitting = ref(false)
|
||||
|
||||
const form = reactive({
|
||||
name: '',
|
||||
host: '',
|
||||
port: 9876,
|
||||
token: '',
|
||||
})
|
||||
|
||||
watch(() => props.visible, (val) => {
|
||||
if (!val) return
|
||||
editingId.value = null
|
||||
Object.assign(form, { name: '', host: '', port: 9876, token: '' })
|
||||
})
|
||||
|
||||
async function handleOk(): Promise<boolean> {
|
||||
if (!form.name?.trim()) { Message.warning('请输入名称'); return false }
|
||||
if (!form.host?.trim()) { Message.warning('请输入地址'); return false }
|
||||
|
||||
submitting.value = true
|
||||
try {
|
||||
if (editingId.value) {
|
||||
connectionManager.updateProfile(editingId.value, { ...form })
|
||||
Message.success('已更新')
|
||||
} else {
|
||||
connectionManager.addProfile({ ...form, type: 'remote' })
|
||||
Message.success('已添加')
|
||||
}
|
||||
return true
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function editProfile(id: string) {
|
||||
const profile = connectionManager.profiles.find(p => p.id === id)
|
||||
if (!profile) return
|
||||
editingId.value = id
|
||||
Object.assign(form, { name: profile.name, host: profile.host, port: profile.port, token: profile.token || '' })
|
||||
}
|
||||
|
||||
defineExpose({ editProfile })
|
||||
</script>
|
||||
@@ -0,0 +1,270 @@
|
||||
<template>
|
||||
<!-- 无远程配置:极简入口按钮 -->
|
||||
<div v-if="!hasRemote" class="connection-indicator mini" @click="$emit('add')">
|
||||
<icon-cloud />
|
||||
</div>
|
||||
|
||||
<!-- 有远程配置:完整标签 + 下拉菜单 -->
|
||||
<div v-else class="connection-indicator" @click.stop="showMenu = !showMenu">
|
||||
<span :class="['dot', state]" />
|
||||
<span class="label">{{ label }}</span>
|
||||
|
||||
<div v-if="showMenu" class="menu" @click.stop>
|
||||
<div class="menu-header">远程连接</div>
|
||||
<div
|
||||
v-for="p in profiles"
|
||||
:key="p.id"
|
||||
:class="['menu-item', { active: p.id === activeId }]"
|
||||
@click="handleSelect(p)"
|
||||
>
|
||||
<span :class="['dot', p.type === 'remote' ? 'remote' : 'local']"></span>
|
||||
<span class="menu-name">{{ p.name }}</span>
|
||||
<span
|
||||
v-if="p.type === 'remote'"
|
||||
class="more-btn"
|
||||
title="更多操作"
|
||||
@click.stop="toggleMore(p)"
|
||||
>···</span>
|
||||
<!-- 更多操作子菜单 -->
|
||||
<div v-if="moreOpenId === p.id" class="more-menu" @click.stop>
|
||||
<div class="more-item" @click="handleEdit(p)">编辑</div>
|
||||
<div class="more-item danger" @click="handleDelete(p)">删除</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="menu-divider" />
|
||||
<button class="menu-item add-btn" @click="$emit('add')">
|
||||
+ 添加服务器
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
||||
import { IconCloud } from '@arco-design/web-vue/es/icon'
|
||||
import { connectionManager } from '@/api/connection-manager'
|
||||
|
||||
const emit = defineEmits<{ (e: 'add'): void; (e: 'select', id: string): void; (e: 'edit', id: string): void }>()
|
||||
|
||||
const showMenu = ref(false)
|
||||
const moreOpenId = ref<string | null>(null)
|
||||
const profiles = shallowRef(connectionManager.profiles)
|
||||
const activeId = shallowRef(connectionManager.activeProfile?.id ?? '')
|
||||
|
||||
// 是否有远程 profile(决定显示模式)
|
||||
const hasRemote = computed(() => profiles.value.some(p => p.type === 'remote'))
|
||||
|
||||
// 防抖:避免 connecting→connected 快速切换导致闪烁
|
||||
const displayState = ref(connectionManager.state)
|
||||
let _stateTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
const state = computed(() => displayState.value)
|
||||
const label = computed(() => {
|
||||
const p = profiles.value.find(p => p.id === activeId.value)
|
||||
if (!p || p.type === 'local') return '本地'
|
||||
return p.name
|
||||
})
|
||||
|
||||
// 监听连接变化,主动触发更新(带防抖)
|
||||
connectionManager.onStateChange((newState) => {
|
||||
profiles.value = connectionManager.profiles
|
||||
activeId.value = connectionManager.activeProfile?.id ?? ''
|
||||
if (_stateTimer) clearTimeout(_stateTimer)
|
||||
if (newState === 'connecting') {
|
||||
_stateTimer = setTimeout(() => { displayState.value = newState }, 300)
|
||||
} else {
|
||||
displayState.value = newState
|
||||
}
|
||||
})
|
||||
|
||||
// 点击外部关闭菜单
|
||||
function handleClickOutside(e: MouseEvent) {
|
||||
const el = e.target as HTMLElement
|
||||
if (!el.closest('.connection-indicator')) {
|
||||
showMenu.value = false
|
||||
moreOpenId.value = null
|
||||
}
|
||||
}
|
||||
onMounted(() => document.addEventListener('click', handleClickOutside))
|
||||
onUnmounted(() => document.removeEventListener('click', handleClickOutside))
|
||||
|
||||
function handleSelect(p: { id: string }) {
|
||||
connectionManager.connect(p.id)
|
||||
showMenu.value = false
|
||||
emit('select', p.id)
|
||||
}
|
||||
|
||||
function toggleMore(p: { id: string }) {
|
||||
moreOpenId.value = moreOpenId.value === p.id ? null : p.id
|
||||
}
|
||||
|
||||
function handleEdit(p: { id: string }) {
|
||||
moreOpenId.value = null
|
||||
showMenu.value = false
|
||||
emit('edit', p.id)
|
||||
}
|
||||
|
||||
function handleDelete(p: { id: string; name: string }) {
|
||||
connectionManager.removeProfile(p.id)
|
||||
moreOpenId.value = null
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.connection-indicator {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
padding: 2px 10px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
color: var(--color-text-2);
|
||||
transition: background 0.15s, border-color 0.15s;
|
||||
border: 1px solid transparent;
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.connection-indicator:hover {
|
||||
background: var(--color-fill-2);
|
||||
border-color: var(--color-border-2);
|
||||
}
|
||||
|
||||
/* 极简模式:仅图标 */
|
||||
.connection-indicator.mini {
|
||||
padding: 3px 6px;
|
||||
border: none;
|
||||
gap: 0;
|
||||
}
|
||||
.connection-indicator.mini:hover {
|
||||
background: var(--color-fill-2);
|
||||
border-color: transparent;
|
||||
}
|
||||
.connection-indicator.mini :deep(.arco-icon) {
|
||||
font-size: 14px;
|
||||
color: var(--color-text-3);
|
||||
}
|
||||
|
||||
.dot {
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.dot.connected { background: rgb(var(--green-6)); }
|
||||
.dot.connecting { background: #f5a623; animation: pulse 1.5s infinite; }
|
||||
.dot.disconnected { background: var(--color-danger-6); }
|
||||
.dot.error { background: var(--color-danger-6); }
|
||||
.dot.local { background: var(--color-text-3); }
|
||||
.dot.remote { background: #165dff; }
|
||||
|
||||
.label {
|
||||
max-width: 70px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.menu {
|
||||
position: absolute;
|
||||
top: calc(100% + 4px);
|
||||
left: 0;
|
||||
min-width: 180px;
|
||||
background: var(--color-bg-popup);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 6px;
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.12);
|
||||
z-index: 1000;
|
||||
padding: 4px 0;
|
||||
}
|
||||
|
||||
.menu-header {
|
||||
padding: 8px 12px;
|
||||
font-size: 12px;
|
||||
color: var(--color-text-3);
|
||||
border-bottom: 1px solid var(--color-fill-1);
|
||||
}
|
||||
|
||||
.menu-item {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 12px;
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
.menu-item:hover { background: var(--color-fill-1); }
|
||||
.menu-item.active { background: var(--color-primary-light-1); color: var(--color-primary-6); }
|
||||
|
||||
.menu-name {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.more-btn {
|
||||
margin-left: auto;
|
||||
opacity: 0;
|
||||
font-size: 14px;
|
||||
color: var(--color-text-3);
|
||||
cursor: pointer;
|
||||
padding: 0 4px;
|
||||
line-height: 1;
|
||||
letter-spacing: 1px;
|
||||
transition: opacity 0.15s;
|
||||
user-select: none;
|
||||
}
|
||||
.menu-item:hover .more-btn { opacity: 1; }
|
||||
|
||||
/* 更多操作子菜单 */
|
||||
.more-menu {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
top: 0;
|
||||
transform: translateX(100%);
|
||||
min-width: 90px;
|
||||
background: var(--color-bg-popup);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 4px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
z-index: 10;
|
||||
padding: 2px 0;
|
||||
}
|
||||
|
||||
.more-item {
|
||||
padding: 6px 14px;
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
.more-item:hover { background: var(--color-fill-1); }
|
||||
.more-item.danger { color: var(--color-danger-6); }
|
||||
|
||||
.menu-divider {
|
||||
height: 1px;
|
||||
background: var(--color-fill-1);
|
||||
margin: 4px 8px;
|
||||
}
|
||||
|
||||
.add-btn {
|
||||
width: 100%;
|
||||
border: none;
|
||||
background: transparent;
|
||||
text-align: left;
|
||||
color: var(--color-primary-6);
|
||||
font-size: 13px;
|
||||
padding: 8px 12px;
|
||||
}
|
||||
.add-btn:hover { background: var(--color-primary-light-1); }
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.4; }
|
||||
}
|
||||
</style>
|
||||
192
frontend/src/components/FileSystem/components/ContextMenu.vue
Normal file
192
frontend/src/components/FileSystem/components/ContextMenu.vue
Normal file
@@ -0,0 +1,192 @@
|
||||
<template>
|
||||
<div
|
||||
v-if="config.visible"
|
||||
ref="menuRef"
|
||||
class="context-menu"
|
||||
:style="menuStyle"
|
||||
@click.stop
|
||||
>
|
||||
<!-- 空白区域菜单 -->
|
||||
<template v-if="config.context === 'blank'">
|
||||
<div class="context-menu-item" @click="handleCreateFile">
|
||||
<span class="context-menu-icon">📄</span>
|
||||
<span>新建文件</span>
|
||||
<span class="context-menu-shortcut">Ctrl+N</span>
|
||||
</div>
|
||||
<div class="context-menu-item" @click="handleCreateDir">
|
||||
<span class="context-menu-icon">📁</span>
|
||||
<span>新建文件夹</span>
|
||||
<span class="context-menu-shortcut">Ctrl+Shift+N</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- 文件菜单 -->
|
||||
<template v-else-if="config.context === 'file' && config.selectedFile">
|
||||
<div class="context-menu-item" @click="handleCreateFile">
|
||||
<span class="context-menu-icon">📄</span>
|
||||
<span>新建文件</span>
|
||||
<span class="context-menu-shortcut">Ctrl+N</span>
|
||||
</div>
|
||||
<div class="context-menu-item" @click="handleCreateDir">
|
||||
<span class="context-menu-icon">📁</span>
|
||||
<span>新建文件夹</span>
|
||||
<span class="context-menu-shortcut">Ctrl+Shift+N</span>
|
||||
</div>
|
||||
<div class="context-menu-divider"></div>
|
||||
<div
|
||||
v-if="!config.selectedFile.is_dir && isOfficeFile(config.selectedFile.name)"
|
||||
class="context-menu-item"
|
||||
@click="handleOpenWithSystem"
|
||||
>
|
||||
<span class="context-menu-icon">🚀</span>
|
||||
<span>系统默认程序打开</span>
|
||||
</div>
|
||||
<div class="context-menu-divider"></div>
|
||||
<div class="context-menu-item" @click="handleRename">
|
||||
<span class="context-menu-icon">✏️</span>
|
||||
<span>重命名</span>
|
||||
<span class="context-menu-shortcut">F2</span>
|
||||
</div>
|
||||
<div class="context-menu-item danger" @click="handleDelete">
|
||||
<span class="context-menu-icon">🗑️</span>
|
||||
<span>删除</span>
|
||||
<span class="context-menu-shortcut">Del</span>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch, nextTick } from 'vue'
|
||||
import type { ContextMenuConfig, FileItem } from '@/types/file-system'
|
||||
import { isOfficeFile } from '@/utils/fileTypeHelpers'
|
||||
|
||||
const menuRef = ref<HTMLElement>()
|
||||
|
||||
// Props
|
||||
interface Props {
|
||||
config: ContextMenuConfig
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
|
||||
// Emits
|
||||
interface Emits {
|
||||
(e: 'action', action: string, payload?: any): void
|
||||
(e: 'close'): void
|
||||
}
|
||||
|
||||
const emit = defineEmits<Emits>()
|
||||
|
||||
const menuStyle = computed(() => {
|
||||
return { left: props.config.x + 'px', top: props.config.y + 'px' }
|
||||
})
|
||||
|
||||
// 位置修正:渲染后检查菜单是否超出视口,自动调整位置
|
||||
watch(() => props.config.visible, (visible) => {
|
||||
if (!visible) return
|
||||
nextTick(() => {
|
||||
const el = menuRef.value
|
||||
if (!el) return
|
||||
const rect = el.getBoundingClientRect()
|
||||
if (rect.right > window.innerWidth) {
|
||||
el.style.left = (window.innerWidth - rect.width - 4) + 'px'
|
||||
}
|
||||
if (rect.bottom > window.innerHeight) {
|
||||
el.style.top = (window.innerHeight - rect.height - 4) + 'px'
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* 处理菜单项点击
|
||||
*/
|
||||
const handleCreateFile = () => {
|
||||
emit('action', 'createFile')
|
||||
emit('close')
|
||||
}
|
||||
|
||||
const handleCreateDir = () => {
|
||||
emit('action', 'createDir')
|
||||
emit('close')
|
||||
}
|
||||
|
||||
const handleOpenWithSystem = () => {
|
||||
if (props.config.selectedFile) {
|
||||
emit('action', 'openWithSystem', props.config.selectedFile)
|
||||
}
|
||||
emit('close')
|
||||
}
|
||||
|
||||
const handleRename = () => {
|
||||
if (props.config.selectedFile) {
|
||||
emit('action', 'rename', props.config.selectedFile)
|
||||
}
|
||||
emit('close')
|
||||
}
|
||||
|
||||
const handleDelete = () => {
|
||||
if (props.config.selectedFile) {
|
||||
emit('action', 'delete', props.config.selectedFile)
|
||||
}
|
||||
emit('close')
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.context-menu {
|
||||
position: fixed;
|
||||
background: var(--color-bg-2);
|
||||
border: 1px solid var(--color-border-2);
|
||||
border-radius: 6px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
z-index: 1000;
|
||||
min-width: 200px;
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
.context-menu-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 12px;
|
||||
cursor: pointer;
|
||||
border-radius: 4px;
|
||||
font-size: 13px;
|
||||
user-select: none;
|
||||
color: var(--color-text-1);
|
||||
}
|
||||
|
||||
.context-menu-item:hover {
|
||||
background: var(--color-fill-2);
|
||||
}
|
||||
|
||||
.context-menu-item.danger {
|
||||
color: rgb(var(--danger-6));
|
||||
}
|
||||
|
||||
.context-menu-item.danger:hover {
|
||||
background: rgb(var(--danger-1));
|
||||
}
|
||||
|
||||
.context-menu-icon {
|
||||
font-size: 16px;
|
||||
width: 20px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.context-menu-shortcut {
|
||||
margin-left: auto;
|
||||
font-size: 11px;
|
||||
color: var(--color-text-3);
|
||||
background: var(--color-fill-2);
|
||||
padding: 2px 6px;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.context-menu-divider {
|
||||
height: 1px;
|
||||
background: var(--color-border-2);
|
||||
margin: 4px 0;
|
||||
}
|
||||
</style>
|
||||
275
frontend/src/components/FileSystem/components/DropdownItem.vue
Normal file
275
frontend/src/components/FileSystem/components/DropdownItem.vue
Normal file
@@ -0,0 +1,275 @@
|
||||
<template>
|
||||
<div
|
||||
class="dropdown-item"
|
||||
@mouseenter="onHover"
|
||||
@mouseleave="onLeave"
|
||||
@click="onClick"
|
||||
>
|
||||
<div class="item-content">
|
||||
<icon-folder v-if="item.isDir" />
|
||||
<icon-file v-else />
|
||||
<span class="item-name">{{ item.name }}</span>
|
||||
<icon-right v-if="item.isDir" class="item-arrow" />
|
||||
</div>
|
||||
|
||||
<!-- 子级菜单(递归) -->
|
||||
<Transition name="dropdown-fade">
|
||||
<div
|
||||
v-if="visible"
|
||||
class="sub-dropdown"
|
||||
:style="style"
|
||||
@mouseenter="onSubmenuHover"
|
||||
@mouseleave="onSubmenuLeave"
|
||||
>
|
||||
<div v-if="loading" class="dropdown-loading">
|
||||
<a-spin :size="16" />
|
||||
<span>加载中...</span>
|
||||
</div>
|
||||
<div v-else-if="error" class="dropdown-error">
|
||||
<icon-exclamation-circle />
|
||||
<span>{{ error }}</span>
|
||||
</div>
|
||||
<div v-else-if="!children.length" class="dropdown-empty">
|
||||
<icon-folder />
|
||||
<span>空文件夹</span>
|
||||
</div>
|
||||
<template v-else>
|
||||
<DropdownItem
|
||||
v-for="child in children"
|
||||
:key="child.path"
|
||||
:item="child"
|
||||
:level="level + 1"
|
||||
@navigate="emitNavigate"
|
||||
@openFile="emitOpenFile"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, inject, watch, type Ref } from 'vue'
|
||||
import { IconRight, IconFolder, IconFile, IconExclamationCircle } from '@arco-design/web-vue/es/icon'
|
||||
import { listDir } from '@/api/system'
|
||||
import { sortFileList } from '@/utils/fileUtils'
|
||||
import { useTimeout } from '@/composables/useTimeout'
|
||||
|
||||
interface Props {
|
||||
item: {
|
||||
name: string
|
||||
path: string
|
||||
isDir: boolean
|
||||
}
|
||||
level: number
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
|
||||
interface Emits {
|
||||
(e: 'navigate', path: string): void
|
||||
(e: 'openFile', path: string): void
|
||||
}
|
||||
|
||||
const emit = defineEmits<Emits>()
|
||||
|
||||
const { setTimeout: delay, clearTimeout } = useTimeout()
|
||||
|
||||
const openMenus = inject<Ref<Map<number, string>>>('openMenus', ref(new Map()))
|
||||
const closeMenu = inject<(level: number) => void>('closeMenu', () => {})
|
||||
|
||||
const visible = ref(false)
|
||||
const loading = ref(false)
|
||||
const error = ref('')
|
||||
const children = ref<Array<{ name: string; path: string; isDir: boolean }>>([])
|
||||
const style = ref<{ top: string; left: string }>({ top: '0px', left: '0px' })
|
||||
|
||||
const hoverTimer = ref<NodeJS.Timeout | null>(null)
|
||||
const leaveTimer = ref<NodeJS.Timeout | null>(null)
|
||||
const hoveringMenu = ref(false)
|
||||
|
||||
const menuKey = `menu-${props.item.path}-${props.level}`
|
||||
|
||||
watch(openMenus, (map) => {
|
||||
visible.value = map.get(props.level) === menuKey
|
||||
}, { deep: true })
|
||||
|
||||
const loadChildren = async () => {
|
||||
if (!props.item.isDir) return
|
||||
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
|
||||
try {
|
||||
const files = await listDir(props.item.path)
|
||||
children.value = sortFileList(files.map(f => ({
|
||||
name: f.name,
|
||||
path: f.path,
|
||||
isDir: f.isDir
|
||||
})))
|
||||
} catch (err) {
|
||||
console.error('[DropdownItem] 加载失败:', err)
|
||||
error.value = '加载失败'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const openMenu = (rect: DOMRect) => {
|
||||
closeMenu(props.level)
|
||||
|
||||
const newMap = new Map(openMenus.value)
|
||||
newMap.set(props.level, menuKey)
|
||||
openMenus.value = newMap
|
||||
|
||||
style.value = {
|
||||
top: `${rect.top}px`,
|
||||
left: `${rect.right + 4}px`
|
||||
}
|
||||
|
||||
visible.value = true
|
||||
loadChildren()
|
||||
}
|
||||
|
||||
const scheduleClose = (ms: number) => {
|
||||
return delay(() => {
|
||||
if (!hoveringMenu.value) closeMenu(props.level)
|
||||
}, ms)
|
||||
}
|
||||
|
||||
const onHover = (event: MouseEvent) => {
|
||||
if (!props.item.isDir) return
|
||||
|
||||
hoveringMenu.value = false
|
||||
if (leaveTimer.value) clearTimeout(leaveTimer.value)
|
||||
|
||||
const rect = (event.currentTarget as HTMLElement).getBoundingClientRect()
|
||||
hoverTimer.value = delay(() => openMenu(rect), 200)
|
||||
}
|
||||
|
||||
const onLeave = () => {
|
||||
if (hoverTimer.value) clearTimeout(hoverTimer.value)
|
||||
leaveTimer.value = scheduleClose(200)
|
||||
}
|
||||
|
||||
const onSubmenuHover = () => {
|
||||
hoveringMenu.value = true
|
||||
if (leaveTimer.value) clearTimeout(leaveTimer.value)
|
||||
}
|
||||
|
||||
const onSubmenuLeave = () => {
|
||||
hoveringMenu.value = false
|
||||
leaveTimer.value = scheduleClose(100)
|
||||
}
|
||||
|
||||
const onClick = (event: MouseEvent) => {
|
||||
if (leaveTimer.value) clearTimeout(leaveTimer.value)
|
||||
|
||||
// 阻止事件冒泡,避免触发父级 breadcrumb-segment 的点击
|
||||
event.stopPropagation()
|
||||
|
||||
const eventType = props.item.isDir ? 'navigate' : 'openFile'
|
||||
emit(eventType, props.item.path)
|
||||
}
|
||||
|
||||
const emitNavigate = (path: string) => emit('navigate', path)
|
||||
const emitOpenFile = (path: string) => emit('openFile', path)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.dropdown-item {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 6px 8px;
|
||||
border-radius: var(--border-radius-small);
|
||||
cursor: pointer;
|
||||
transition: all 0.1s;
|
||||
font-size: 13px;
|
||||
color: var(--color-text-1);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.item-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.item-name {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.item-arrow {
|
||||
font-size: 12px;
|
||||
color: var(--color-text-3);
|
||||
flex-shrink: 0;
|
||||
margin-left: 4px;
|
||||
}
|
||||
|
||||
.dropdown-item:hover {
|
||||
background: var(--color-fill-2);
|
||||
}
|
||||
|
||||
/* 子级菜单 */
|
||||
.sub-dropdown {
|
||||
position: fixed;
|
||||
min-width: 200px;
|
||||
max-width: 280px;
|
||||
max-height: 320px;
|
||||
overflow-y: auto;
|
||||
background: var(--color-bg-popup);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--border-radius-small);
|
||||
box-shadow: var(--shadow2-dropdown);
|
||||
z-index: calc(1000 + var(--level, 0));
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
.dropdown-loading,
|
||||
.dropdown-error,
|
||||
.dropdown-empty {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
padding: 16px;
|
||||
color: var(--color-text-3);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
/* 动画 */
|
||||
.dropdown-fade-enter-active,
|
||||
.dropdown-fade-leave-active {
|
||||
transition: opacity 0.15s, transform 0.15s;
|
||||
}
|
||||
|
||||
.dropdown-fade-enter-from,
|
||||
.dropdown-fade-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateY(-4px);
|
||||
}
|
||||
|
||||
/* 滚动条 */
|
||||
.sub-dropdown::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
.sub-dropdown::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.sub-dropdown::-webkit-scrollbar-thumb {
|
||||
background: var(--color-fill-3);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.sub-dropdown::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--color-fill-4);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,102 @@
|
||||
<template>
|
||||
<div class="binary-info">
|
||||
<div class="info-header">
|
||||
<span class="info-icon">ℹ️</span>
|
||||
<span class="info-title">二进制文件</span>
|
||||
</div>
|
||||
|
||||
<div class="info-content">
|
||||
<pre>{{ content }}</pre>
|
||||
</div>
|
||||
|
||||
<div class="info-tips">
|
||||
<p>💡 提示:</p>
|
||||
<ul>
|
||||
<li>右键菜单 → "使用系统程序打开" 在默认应用中打开</li>
|
||||
<li>右键菜单 → "在资源管理器中显示" 查看文件位置</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
// Props
|
||||
interface Props {
|
||||
content: string
|
||||
}
|
||||
|
||||
defineProps<Props>()
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.binary-info {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 20px;
|
||||
background: var(--color-fill-1);
|
||||
border-radius: 4px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.info-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 16px;
|
||||
padding-bottom: 12px;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.info-icon {
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.info-title {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-1);
|
||||
}
|
||||
|
||||
.info-content {
|
||||
flex: 1;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.info-content pre {
|
||||
margin: 0;
|
||||
padding: 12px;
|
||||
background: var(--color-fill-2);
|
||||
border-radius: 4px;
|
||||
font-family: 'Consolas', 'Monaco', monospace;
|
||||
font-size: 12px;
|
||||
color: var(--color-text-2);
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.info-tips {
|
||||
padding: 12px;
|
||||
background: var(--color-fill-2);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.info-tips p {
|
||||
margin: 0 0 8px 0;
|
||||
font-size: 13px;
|
||||
color: var(--color-text-1);
|
||||
}
|
||||
|
||||
.info-tips ul {
|
||||
margin: 0;
|
||||
padding-left: 20px;
|
||||
font-size: 12px;
|
||||
color: var(--color-text-2);
|
||||
}
|
||||
|
||||
.info-tips li {
|
||||
margin: 4px 0;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,113 @@
|
||||
<template>
|
||||
<div class="media-preview">
|
||||
<!-- 图片预览 -->
|
||||
<div v-if="type === 'image'" class="image-preview">
|
||||
<img
|
||||
:src="url"
|
||||
:alt="'图片预览'"
|
||||
@load="handleLoad"
|
||||
@error="handleError"
|
||||
/>
|
||||
<div v-if="dimensions" class="image-info">
|
||||
{{ dimensions }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 视频预览 -->
|
||||
<div v-else-if="type === 'video'" class="video-preview">
|
||||
<video :src="url" controls>
|
||||
您的浏览器不支持视频播放
|
||||
</video>
|
||||
</div>
|
||||
|
||||
<!-- 音频预览 -->
|
||||
<div v-else-if="type === 'audio'" class="audio-preview">
|
||||
<audio :src="url" controls>
|
||||
您的浏览器不支持音频播放
|
||||
</audio>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
|
||||
// Props
|
||||
interface Props {
|
||||
url: string
|
||||
type: 'image' | 'video' | 'audio'
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
|
||||
// Emits
|
||||
interface Emits {
|
||||
(e: 'load', dimensions: string): void
|
||||
(e: 'error'): void
|
||||
}
|
||||
|
||||
const emit = defineEmits<Emits>()
|
||||
|
||||
// 图片尺寸信息
|
||||
const dimensions = ref('')
|
||||
|
||||
// 图片加载完成
|
||||
const handleLoad = (event: Event) => {
|
||||
const img = event.target as HTMLImageElement
|
||||
if (img.naturalWidth && img.naturalHeight) {
|
||||
dimensions.value = `${img.naturalWidth} × ${img.naturalHeight}`
|
||||
emit('load', dimensions.value)
|
||||
}
|
||||
}
|
||||
|
||||
// 图片加载错误
|
||||
const handleError = () => {
|
||||
dimensions.value = ''
|
||||
emit('error')
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.media-preview {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.image-preview {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.image-preview img {
|
||||
max-width: 100%;
|
||||
max-height: calc(100% - 20px);
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.image-info {
|
||||
margin-top: 8px;
|
||||
font-size: 12px;
|
||||
color: var(--color-text-3);
|
||||
}
|
||||
|
||||
.video-preview,
|
||||
.audio-preview {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
video,
|
||||
audio {
|
||||
max-width: 100%;
|
||||
max-height: 100%;
|
||||
}
|
||||
</style>
|
||||
1433
frontend/src/components/FileSystem/components/FileEditorPanel.vue
Normal file
1433
frontend/src/components/FileSystem/components/FileEditorPanel.vue
Normal file
File diff suppressed because it is too large
Load Diff
636
frontend/src/components/FileSystem/components/FileListPanel.vue
Normal file
636
frontend/src/components/FileSystem/components/FileListPanel.vue
Normal file
@@ -0,0 +1,636 @@
|
||||
<template>
|
||||
<div class="file-list-panel" :style="{ width: width + '%' }">
|
||||
<div class="panel-header">
|
||||
<span class="panel-title">📋 文件列表</span>
|
||||
<div class="panel-header-right">
|
||||
<a-popover trigger="click" position="bottom" :content-style="{ padding: '4px', width: '170px' }">
|
||||
<a-button size="mini" type="text" class="settings-btn">
|
||||
<icon-more />
|
||||
</a-button>
|
||||
<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>
|
||||
<!-- 可排序列:点击图标排序 -->
|
||||
<span
|
||||
v-if="colSortMap[col.key]"
|
||||
class="col-sort-icon"
|
||||
:class="{ 'col-sort-active': sortBy === colSortMap[col.key] }"
|
||||
:title="sortBy === colSortMap[col.key] ? (sortOrder === 'asc' ? '升序 → 点击降序' : '降序 → 点击升序') : `按${col.label}排序`"
|
||||
@click.stop="emit('sort', colSortMap[col.key])"
|
||||
>
|
||||
<IconSort v-if="sortBy !== colSortMap[col.key]" />
|
||||
<IconSortAscending v-else-if="sortOrder === 'asc'" />
|
||||
<IconSortDescending v-else />
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
</a-popover>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="file-list-wrapper thin-dark-scrollbar"
|
||||
@contextmenu.prevent="handleWrapperContextMenu"
|
||||
>
|
||||
<!-- 文件列表(滚动区域) -->
|
||||
<a-table
|
||||
v-if="config.fileList.length > 0 || config.fileLoading"
|
||||
:columns="tableColumns"
|
||||
:data="pagedFileList"
|
||||
:loading="config.fileLoading"
|
||||
:pagination="false"
|
||||
:bordered="false"
|
||||
:show-header="showHeader"
|
||||
size="mini"
|
||||
:row-class-name="getRowClassName"
|
||||
class="file-table"
|
||||
@row-click="handleRowClick"
|
||||
@row-contextmenu="handleRowContextMenu"
|
||||
/>
|
||||
|
||||
<!-- 空状态 -->
|
||||
<div v-if="config.fileList.length === 0 && !config.fileLoading" class="empty-state">
|
||||
<span style="font-size: 32px">📭</span>
|
||||
<span>此文件夹为空</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 分页栏(固定在面板底部,不随内容滚动) -->
|
||||
<div v-if="config.fileList.length > 0" class="pagination-bar">
|
||||
<span class="pagination-total">共 {{ config.fileList.length }} 项</span>
|
||||
<span class="pagination-nav">
|
||||
<span class="page-btn" :class="{ disabled: currentPage <= 1 }" @click="onPageChange(currentPage - 1)">
|
||||
<icon-left />
|
||||
</span>
|
||||
<span class="page-info">{{ currentPage }} / {{ totalPages }}</span>
|
||||
<span class="page-btn" :class="{ disabled: currentPage >= totalPages }" @click="onPageChange(currentPage + 1)">
|
||||
<icon-right />
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { h, computed, nextTick, ref, watch } from 'vue'
|
||||
import { Input, Button } from '@arco-design/web-vue'
|
||||
import { IconStar, IconStarFill, IconSort, IconSortAscending, IconSortDescending, IconMore, IconLeft, IconRight } 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
|
||||
interface Props {
|
||||
config: FileListPanelConfig
|
||||
width: number
|
||||
favorites: string[]
|
||||
sortBy: string
|
||||
sortOrder: string
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
|
||||
// Emits
|
||||
interface Emits {
|
||||
(e: 'fileClick', file: FileItem): void
|
||||
(e: 'fileDoubleClick', file: FileItem): void
|
||||
(e: 'toggleFavorite', file: FileItem): void
|
||||
(e: 'startEditing', path: string, name: string): void
|
||||
(e: 'saveEditing', path: string, newName: string): void
|
||||
(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>()
|
||||
|
||||
// 列 key → 排序字段映射
|
||||
const colSortMap: Record<string, string> = {
|
||||
icon: 'type',
|
||||
name: 'name',
|
||||
time: 'modified_time',
|
||||
size: 'size'
|
||||
}
|
||||
|
||||
// ========== 列配置(支持显隐 + 排序) ==========
|
||||
const COL_SETTINGS_KEY = STORAGE_KEYS.FILESYSTEM.COL_SETTINGS
|
||||
const SHOW_HEADER_KEY = STORAGE_KEYS.FILESYSTEM.SHOW_HEADER
|
||||
|
||||
interface ColumnConfig {
|
||||
key: string
|
||||
label: string
|
||||
visible: boolean
|
||||
order: number
|
||||
}
|
||||
|
||||
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 }
|
||||
]
|
||||
|
||||
// 从 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())
|
||||
// 默认隐藏表头(localStorage 无值时默认不显示)
|
||||
const showHeader = ref(localStorage.getItem(SHOW_HEADER_KEY) === 'true')
|
||||
|
||||
// 手动持久化(避免 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 currentPage = ref(1)
|
||||
const pageSize = 100
|
||||
|
||||
const pagedFileList = computed(() => {
|
||||
const list = props.config.fileList
|
||||
const start = (currentPage.value - 1) * pageSize
|
||||
return list.slice(start, start + pageSize)
|
||||
})
|
||||
|
||||
const totalPages = computed(() => Math.max(1, Math.ceil(props.config.fileList.length / pageSize)))
|
||||
|
||||
const onPageChange = (page: number) => {
|
||||
if (page < 1 || page > totalPages.value) return
|
||||
currentPage.value = page
|
||||
}
|
||||
|
||||
// 当文件列表变化时重置到第1页
|
||||
watch(() => props.config.fileList.length, () => { currentPage.value = 1 })
|
||||
|
||||
// ========== 行事件处理 ==========
|
||||
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 = () => {
|
||||
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 })
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* ====== 布局 ====== */
|
||||
.file-list-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1; /* 父级是 flex 容器,用 flex:1 而非 height:100% */
|
||||
min-height: 0; /* 允许收缩到小于内容高度 */
|
||||
background: var(--color-bg-1);
|
||||
}
|
||||
|
||||
.panel-header {
|
||||
padding: 3px 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;
|
||||
}
|
||||
|
||||
.panel-header-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.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);
|
||||
}
|
||||
|
||||
/* 列项排序图标 */
|
||||
.col-sort-icon {
|
||||
margin-left: auto;
|
||||
font-size: 12px;
|
||||
color: var(--color-text-4);
|
||||
cursor: pointer;
|
||||
padding: 2px;
|
||||
border-radius: 3px;
|
||||
flex-shrink: 0;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
.col-sort-icon:hover {
|
||||
background: var(--color-fill-2);
|
||||
color: var(--color-text-2);
|
||||
}
|
||||
.col-sort-active {
|
||||
color: rgb(var(--primary-6));
|
||||
}
|
||||
|
||||
/* 滚动容器(table + 分页 的统一滚动层) */
|
||||
.file-list-wrapper {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
padding: 0 2px;
|
||||
}
|
||||
|
||||
/* ====== Table ====== */
|
||||
.file-table {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
.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;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 60px 20px;
|
||||
color: var(--color-text-3);
|
||||
gap: 8px;
|
||||
}
|
||||
.empty-state span:nth-child(2) { font-size: 14px; }
|
||||
|
||||
/* 分页栏(固定底部) */
|
||||
.pagination-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 4px 10px;
|
||||
background: var(--color-bg-2);
|
||||
border-top: 1px solid var(--color-border);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.pagination-total {
|
||||
font-size: 12px;
|
||||
color: var(--color-text-3);
|
||||
}
|
||||
.pagination-nav {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 12px;
|
||||
}
|
||||
.page-btn {
|
||||
cursor: pointer;
|
||||
color: var(--color-text-2);
|
||||
padding: 0 3px;
|
||||
line-height: 1;
|
||||
user-select: none;
|
||||
}
|
||||
.page-btn:hover:not(.disabled) { color: rgb(var(--primary-6)); }
|
||||
.page-btn.disabled { color: var(--color-text-4); cursor: default; }
|
||||
.page-info { color: var(--color-text-2); min-width: 28px; text-align: center; }
|
||||
</style>
|
||||
307
frontend/src/components/FileSystem/components/PathBreadcrumb.vue
Normal file
307
frontend/src/components/FileSystem/components/PathBreadcrumb.vue
Normal file
@@ -0,0 +1,307 @@
|
||||
<template>
|
||||
<div class="path-breadcrumb">
|
||||
<div class="breadcrumb-items">
|
||||
<template v-for="(segment, index) in segments" :key="index">
|
||||
<!-- 路径段 -->
|
||||
<div
|
||||
class="breadcrumb-segment"
|
||||
:class="{ 'is-hoverable': index < segments.length - 1 || segments.length === 1 }"
|
||||
@mouseenter="onHover(segment, index)"
|
||||
@mouseleave="onLeave"
|
||||
@click="onClick(segment)"
|
||||
>
|
||||
<span class="segment-text">{{ segment.name }}</span>
|
||||
|
||||
<!-- 悬停弹出菜单 -->
|
||||
<Transition name="dropdown-fade">
|
||||
<div
|
||||
v-if="activeIndex === index"
|
||||
class="siblings-dropdown main-dropdown"
|
||||
@mouseenter="onMenuEnter"
|
||||
@mouseleave="onMenuLeave"
|
||||
>
|
||||
<div v-if="loading" class="dropdown-loading">
|
||||
<a-spin :size="16" />
|
||||
<span>加载中...</span>
|
||||
</div>
|
||||
<div v-else-if="error" class="dropdown-error">
|
||||
<icon-exclamation-circle />
|
||||
<span>{{ error }}</span>
|
||||
</div>
|
||||
<div v-else-if="!children.length" class="dropdown-empty">
|
||||
<icon-folder />
|
||||
<span>空文件夹</span>
|
||||
</div>
|
||||
<template v-else>
|
||||
<DropdownItem
|
||||
v-for="child in children"
|
||||
:key="child.path"
|
||||
:item="child"
|
||||
:level="1"
|
||||
@navigate="onNavigate"
|
||||
@openFile="onOpenFile"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
|
||||
<!-- 分隔符 -->
|
||||
<icon-right v-if="index < segments.length - 1" class="breadcrumb-separator" />
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch, provide, type Ref } from 'vue'
|
||||
import { IconRight, IconFolder } from '@arco-design/web-vue/es/icon'
|
||||
import { listDir } from '@/api/system'
|
||||
import { sortFileList } from '@/utils/fileUtils'
|
||||
import { useTimeout } from '@/composables/useTimeout'
|
||||
import DropdownItem from './DropdownItem.vue'
|
||||
|
||||
const { setTimeout: delay, clearTimeout } = useTimeout()
|
||||
|
||||
const openMenus = ref<Map<number, string>>(new Map())
|
||||
|
||||
const closeMenu = (level: number) => {
|
||||
const newMap = new Map(openMenus.value)
|
||||
newMap.delete(level)
|
||||
openMenus.value = newMap
|
||||
}
|
||||
|
||||
const closeAllMenus = () => {
|
||||
openMenus.value = new Map()
|
||||
}
|
||||
|
||||
provide('openMenus', openMenus)
|
||||
provide('closeMenu', closeMenu)
|
||||
provide('closeAllMenus', closeAllMenus)
|
||||
|
||||
interface Props {
|
||||
path: string
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
|
||||
interface Emits {
|
||||
(e: 'navigate', path: string): void
|
||||
(e: 'openFile', path: string): void
|
||||
}
|
||||
|
||||
const emit = defineEmits<Emits>()
|
||||
|
||||
interface PathSegment {
|
||||
name: string
|
||||
path: string
|
||||
}
|
||||
|
||||
const segments = computed<PathSegment[]>(() => {
|
||||
if (!props.path) return []
|
||||
|
||||
const path = props.path.replace(/\\/g, '/')
|
||||
|
||||
// 根目录
|
||||
if (/^[A-Za-z]:\/?$/.test(path)) {
|
||||
const drive = path[0] + ':'
|
||||
return [{ name: drive, path: drive + '/' }]
|
||||
}
|
||||
|
||||
return path.split('/').filter(Boolean).reduce<PathSegment[]>((acc, part, i) => {
|
||||
const prev = acc[i - 1]?.path || ''
|
||||
const current = part.endsWith(':') ? part + '/' : prev + (prev.endsWith('/') ? '' : '/') + part
|
||||
acc.push({ name: part, path: current })
|
||||
return acc
|
||||
}, [])
|
||||
})
|
||||
|
||||
const activeIndex = ref<number | null>(null)
|
||||
const hoverTimer = ref<NodeJS.Timeout | null>(null)
|
||||
const closeTimer = ref<NodeJS.Timeout | null>(null)
|
||||
const children = ref<Array<{ name: string; path: string; isDir: boolean }>>([])
|
||||
const loading = ref(false)
|
||||
const error = ref('')
|
||||
const lastLoadedPath = ref('')
|
||||
|
||||
const loadChildren = async (path: string) => {
|
||||
if (path === lastLoadedPath.value) return
|
||||
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
|
||||
try {
|
||||
const files = await listDir(path)
|
||||
lastLoadedPath.value = path
|
||||
children.value = sortFileList(files.map(f => ({
|
||||
name: f.name,
|
||||
path: f.path,
|
||||
isDir: f.isDir
|
||||
})))
|
||||
} catch (err) {
|
||||
console.error('[Breadcrumb] 加载子目录失败:', err)
|
||||
error.value = '加载失败'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const resetAndClose = () => {
|
||||
activeIndex.value = null
|
||||
closeAllMenus()
|
||||
}
|
||||
|
||||
const onHover = (segment: PathSegment, index: number) => {
|
||||
// 根目录(如 C:)只有一段,也允许悬停弹出子目录
|
||||
if (index === segments.value.length - 1 && segments.value.length > 1) return
|
||||
|
||||
if (hoverTimer.value) clearTimeout(hoverTimer.value)
|
||||
if (closeTimer.value) clearTimeout(closeTimer.value)
|
||||
|
||||
hoverTimer.value = delay(() => {
|
||||
activeIndex.value = index
|
||||
loadChildren(segment.path)
|
||||
}, 200)
|
||||
}
|
||||
|
||||
const onMenuEnter = () => {
|
||||
if (hoverTimer.value) clearTimeout(hoverTimer.value)
|
||||
if (closeTimer.value) clearTimeout(closeTimer.value)
|
||||
}
|
||||
|
||||
const onMenuLeave = () => {
|
||||
if (hoverTimer.value) clearTimeout(hoverTimer.value)
|
||||
closeTimer.value = delay(() => {
|
||||
resetAndClose()
|
||||
}, 100)
|
||||
}
|
||||
|
||||
const onClick = (segment: PathSegment) => {
|
||||
emit('navigate', segment.path)
|
||||
resetAndClose()
|
||||
}
|
||||
|
||||
const onNavigate = (path: string) => {
|
||||
emit('navigate', path)
|
||||
resetAndClose()
|
||||
}
|
||||
|
||||
const onOpenFile = (path: string) => {
|
||||
emit('openFile', path)
|
||||
resetAndClose()
|
||||
}
|
||||
|
||||
watch(() => props.path, () => {
|
||||
activeIndex.value = null
|
||||
children.value = []
|
||||
lastLoadedPath.value = ''
|
||||
openMenus.value = new Map()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.path-breadcrumb {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.breadcrumb-items {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.breadcrumb-segment {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
cursor: default;
|
||||
transition: background 0.2s;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.breadcrumb-segment.is-hoverable {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.breadcrumb-segment.is-hoverable:hover {
|
||||
background: var(--color-fill-2);
|
||||
}
|
||||
|
||||
.segment-text {
|
||||
font-size: 13px;
|
||||
color: var(--color-text-1);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.breadcrumb-separator {
|
||||
color: var(--color-text-3);
|
||||
font-size: 12px;
|
||||
flex-shrink: 0;
|
||||
margin: 0 1px;
|
||||
}
|
||||
|
||||
/* 弹出菜单 */
|
||||
.siblings-dropdown {
|
||||
position: absolute;
|
||||
top: calc(100% + 4px);
|
||||
left: 0;
|
||||
min-width: 200px;
|
||||
max-width: 280px;
|
||||
max-height: 320px;
|
||||
overflow-y: auto;
|
||||
background: var(--color-bg-popup);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--border-radius-small);
|
||||
box-shadow: var(--shadow2-dropdown);
|
||||
z-index: 1000;
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
.dropdown-loading,
|
||||
.dropdown-error,
|
||||
.dropdown-empty {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
padding: 16px;
|
||||
color: var(--color-text-3);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
/* 动画 */
|
||||
.dropdown-fade-enter-active,
|
||||
.dropdown-fade-leave-active {
|
||||
transition: opacity 0.15s, transform 0.15s;
|
||||
}
|
||||
|
||||
.dropdown-fade-enter-from,
|
||||
.dropdown-fade-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateY(-4px);
|
||||
}
|
||||
|
||||
/* 滚动条样式 */
|
||||
.siblings-dropdown::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
.siblings-dropdown::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.siblings-dropdown::-webkit-scrollbar-thumb {
|
||||
background: var(--color-fill-3);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.siblings-dropdown::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--color-fill-4);
|
||||
}
|
||||
</style>
|
||||
404
frontend/src/components/FileSystem/components/Sidebar.vue
Normal file
404
frontend/src/components/FileSystem/components/Sidebar.vue
Normal file
@@ -0,0 +1,404 @@
|
||||
<template>
|
||||
<transition name="slide">
|
||||
<div v-show="config.visible" class="sidebar">
|
||||
<!-- 收藏夹区块 -->
|
||||
<div class="sidebar-section">
|
||||
<div class="section-header" @click="favCollapsed = !favCollapsed">
|
||||
<span class="section-title">⭐ 收藏夹</span>
|
||||
<span class="section-count">共{{ config.favoriteFiles.length }}项</span>
|
||||
<icon-down v-if="!favCollapsed" class="section-toggle" />
|
||||
<icon-right v-else class="section-toggle" />
|
||||
</div>
|
||||
<div class="section-content" :class="{ collapsed: favCollapsed }">
|
||||
<div
|
||||
v-for="(fav, index) in config.favoriteFiles"
|
||||
:key="fav.path"
|
||||
class="sidebar-item"
|
||||
:class="{
|
||||
'sidebar-item-pinned': fav.pinnedAt,
|
||||
'sidebar-item-pinned-first': index === firstPinnedIndex,
|
||||
'sidebar-item-pinned-last': index === lastPinnedIndex,
|
||||
'sidebar-item-dragging': config.draggingState.isDragging && config.draggingState.draggedIndex === index,
|
||||
'sidebar-item-drag-over': config.draggingState.isDragging && config.draggingState.draggedIndex !== index
|
||||
}"
|
||||
:draggable="config.draggingState.pressedIndex === index || config.draggingState.isDragging"
|
||||
@click="handleOpenFavorite(fav)"
|
||||
@mousedown="handleLongPressStart($event, index)"
|
||||
@mouseup="handleLongPressCancel"
|
||||
@mouseleave="handleLongPressCancel"
|
||||
@touchstart="handleLongPressStart($event, index)"
|
||||
@touchend="handleLongPressCancel"
|
||||
@touchcancel="handleLongPressCancel"
|
||||
@dragstart="handleDragStart($event, index)"
|
||||
@dragover="handleDragOver($event)"
|
||||
@drop="handleDrop($event, index)"
|
||||
@dragend="handleDragEnd"
|
||||
>
|
||||
<span class="sidebar-item-icon">{{ getFileIcon(fav) }}</span>
|
||||
<span class="sidebar-item-name" :title="fav.name">{{ fav.name }}</span>
|
||||
<a-button
|
||||
type="text"
|
||||
size="mini"
|
||||
@click.stop="handleTogglePin(fav)"
|
||||
class="sidebar-item-pin"
|
||||
:class="{ 'is-pinned': fav.pinnedAt }"
|
||||
>
|
||||
<template #icon>
|
||||
<icon-pushpin :style="{ opacity: fav.pinnedAt ? 1 : 0.4 }" />
|
||||
</template>
|
||||
</a-button>
|
||||
<a-button
|
||||
type="text"
|
||||
size="mini"
|
||||
@click.stop="handleRemoveFavorite(fav)"
|
||||
class="sidebar-item-remove"
|
||||
>
|
||||
<template #icon>
|
||||
<icon-close />
|
||||
</template>
|
||||
</a-button>
|
||||
</div>
|
||||
<div v-if="config.favoriteFiles.length === 0" class="sidebar-empty">
|
||||
<icon-star />
|
||||
<span>暂无收藏</span>
|
||||
<span class="sidebar-hint">点击文件列表中的星标收藏</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 帮助文档区块 -->
|
||||
<div class="sidebar-section">
|
||||
<div class="section-header" @click="helpCollapsed = !helpCollapsed">
|
||||
<span class="section-title">📖 帮助</span>
|
||||
<icon-down v-if="!helpCollapsed" class="section-toggle" />
|
||||
<icon-right v-else class="section-toggle" />
|
||||
</div>
|
||||
<div class="section-content help-content" :class="{ collapsed: helpCollapsed }">
|
||||
<div class="help-item" v-for="item in helpItems" :key="item.key">
|
||||
<span class="help-key">{{ item.key }}</span>
|
||||
<span class="help-desc">{{ item.desc }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</transition>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import type { SidebarConfig, FavoriteFile } from '@/types/file-system'
|
||||
|
||||
// Props
|
||||
interface Props {
|
||||
config: SidebarConfig
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
|
||||
// 折叠状态(组件内部,不污染父组件)
|
||||
const favCollapsed = ref(false)
|
||||
const helpCollapsed = ref(false)
|
||||
|
||||
// 计算第一个和最后一个置顶项的索引
|
||||
const pinnedIndices = computed(() => {
|
||||
return props.config.favoriteFiles
|
||||
.map((fav, index) => fav.pinnedAt ? index : -1)
|
||||
.filter(i => i !== -1)
|
||||
})
|
||||
|
||||
const firstPinnedIndex = computed(() => pinnedIndices.value[0] ?? -1)
|
||||
const lastPinnedIndex = computed(() => pinnedIndices.value[pinnedIndices.value.length - 1] ?? -1)
|
||||
|
||||
// 帮助内容
|
||||
const helpItems = [
|
||||
{ key: 'Ctrl+B', desc: '切换侧边栏' },
|
||||
{ key: 'Ctrl+H', desc: '历史记录' },
|
||||
{ key: 'Ctrl+F', desc: '聚焦搜索' },
|
||||
{ key: 'Click ⭐', desc: '收藏文件' },
|
||||
{ key: 'Drag', desc: '排序收藏' },
|
||||
]
|
||||
|
||||
// Emits
|
||||
interface Emits {
|
||||
(e: 'openFavorite', file: FavoriteFile): void
|
||||
(e: 'removeFavorite', path: string): void
|
||||
(e: 'togglePin', path: string): void
|
||||
(e: 'longPressStart', event: MouseEvent | TouchEvent, index: number): void
|
||||
(e: 'longPressCancel'): void
|
||||
(e: 'dragStart', event: DragEvent, index: number): void
|
||||
(e: 'dragOver', event: DragEvent): void
|
||||
(e: 'drop', event: DragEvent, targetIndex: number): void
|
||||
(e: 'dragEnd'): void
|
||||
}
|
||||
|
||||
const emit = defineEmits<Emits>()
|
||||
|
||||
// 图标导入
|
||||
import { IconStar, IconClose, IconPushpin, IconDown, IconRight } from '@arco-design/web-vue/es/icon'
|
||||
import { getFileIcon } from '@/utils/fileUtils'
|
||||
|
||||
// 事件处理
|
||||
const handleOpenFavorite = (file: FavoriteFile) => {
|
||||
emit('openFavorite', file)
|
||||
}
|
||||
|
||||
const handleRemoveFavorite = (file: FavoriteFile) => {
|
||||
emit('removeFavorite', file.path)
|
||||
}
|
||||
|
||||
const handleTogglePin = (file: FavoriteFile) => {
|
||||
emit('togglePin', file.path)
|
||||
}
|
||||
|
||||
const handleLongPressStart = (event: MouseEvent | TouchEvent, index: number) => {
|
||||
emit('longPressStart', event, index)
|
||||
}
|
||||
|
||||
const handleLongPressCancel = () => {
|
||||
emit('longPressCancel')
|
||||
}
|
||||
|
||||
const handleDragStart = (event: DragEvent, index: number) => {
|
||||
emit('dragStart', event, index)
|
||||
}
|
||||
|
||||
const handleDragOver = (event: DragEvent) => {
|
||||
emit('dragOver', event)
|
||||
}
|
||||
|
||||
const handleDrop = (event: DragEvent, targetIndex: number) => {
|
||||
emit('drop', event, targetIndex)
|
||||
}
|
||||
|
||||
const handleDragEnd = () => {
|
||||
emit('dragEnd')
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.sidebar {
|
||||
width: 220px;
|
||||
height: 100%;
|
||||
background: var(--color-bg-1);
|
||||
border-right: 1px solid var(--color-border);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex-shrink: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* 区块 */
|
||||
.sidebar-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* 帮助区块固定在底部,不被推出窗口 */
|
||||
.sidebar-section:last-child {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* 区块头部 - 可点击折叠 */
|
||||
.section-header {
|
||||
padding: 5px 12px;
|
||||
background: var(--color-bg-2);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.section-header:hover {
|
||||
background: var(--color-fill-1);
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--color-text-1);
|
||||
}
|
||||
|
||||
.section-count {
|
||||
font-size: 12px;
|
||||
color: var(--color-text-3);
|
||||
}
|
||||
|
||||
.section-toggle {
|
||||
font-size: 12px;
|
||||
color: var(--color-text-3);
|
||||
margin-left: auto;
|
||||
transition: transform 0.2s;
|
||||
}
|
||||
|
||||
/* 区块内容 - 可折叠 */
|
||||
.section-content {
|
||||
overflow: hidden;
|
||||
transition: max-height 0.25s ease, opacity 0.2s ease;
|
||||
max-height: calc(100vh - 80px);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.section-content.collapsed {
|
||||
max-height: 0;
|
||||
opacity: 0;
|
||||
padding-top: 0;
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
/* 收藏夹内容 - 内部独立滚动 */
|
||||
.section-content:not(.help-content) {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
padding: 4px 8px 0;
|
||||
}
|
||||
|
||||
/* 帮助内容 */
|
||||
.help-content {
|
||||
padding: 4px 12px;
|
||||
}
|
||||
|
||||
.help-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 4px 0;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.help-key {
|
||||
font-family: 'Consolas', 'Monaco', monospace;
|
||||
background: var(--color-fill-2);
|
||||
padding: 1px 6px;
|
||||
border-radius: 3px;
|
||||
color: var(--color-text-2);
|
||||
white-space: nowrap;
|
||||
min-width: 56px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.help-desc {
|
||||
color: var(--color-text-3);
|
||||
}
|
||||
|
||||
/* 收藏项 */
|
||||
.sidebar-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 12px;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.sidebar-item:hover {
|
||||
background: var(--color-fill-2);
|
||||
}
|
||||
|
||||
.sidebar-item-dragging {
|
||||
opacity: 0.5;
|
||||
background: var(--color-fill-1);
|
||||
}
|
||||
|
||||
.sidebar-item-drag-over {
|
||||
background: var(--color-fill-3);
|
||||
border: 2px dashed var(--color-border-3);
|
||||
}
|
||||
|
||||
.sidebar-item-icon {
|
||||
font-size: 18px;
|
||||
width: 24px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.sidebar-item-name {
|
||||
flex: 1;
|
||||
font-size: 13px;
|
||||
color: var(--color-text-2);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.sidebar-item-remove {
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
|
||||
.sidebar-item:hover .sidebar-item-remove {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.sidebar-item-pinned {
|
||||
background: var(--color-fill-1);
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.sidebar-item-pinned-first {
|
||||
border-radius: 6px 6px 0 0;
|
||||
}
|
||||
|
||||
.sidebar-item-pinned-last {
|
||||
border-radius: 0 0 6px 6px;
|
||||
}
|
||||
|
||||
.sidebar-item-pinned-first.sidebar-item-pinned-last {
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.sidebar-item-pin {
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
|
||||
.sidebar-item:hover .sidebar-item-pin {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* 空状态 */
|
||||
.sidebar-empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 32px 16px;
|
||||
text-align: center;
|
||||
color: var(--color-text-3);
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.sidebar-empty :first-child {
|
||||
font-size: 48px;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.sidebar-empty :nth-child(2) {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.sidebar-hint {
|
||||
font-size: 12px;
|
||||
color: var(--color-text-4);
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
/* 侧边栏整体滑入滑出动画 */
|
||||
.slide-enter-active,
|
||||
.slide-leave-active {
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.slide-enter-from {
|
||||
transform: translateX(-100%);
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.slide-leave-to {
|
||||
transform: translateX(-100%);
|
||||
opacity: 0;
|
||||
}
|
||||
</style>
|
||||
364
frontend/src/components/FileSystem/components/Toolbar.vue
Normal file
364
frontend/src/components/FileSystem/components/Toolbar.vue
Normal file
@@ -0,0 +1,364 @@
|
||||
<template>
|
||||
<div class="toolbar">
|
||||
<div class="toolbar-left">
|
||||
<!-- 路径输入 -->
|
||||
<div class="path-input-wrapper">
|
||||
<!-- ZIP 浏览模式:显示 ZIP 路径和面包屑 -->
|
||||
<div v-if="config.isBrowsingZip" class="zip-breadcrumb">
|
||||
<a-tag size="small" class="zip-file-tag" @click="handleNavigateToZipRoot">
|
||||
📦 {{ config.zipFileName }}
|
||||
</a-tag>
|
||||
<template v-if="config.zipBreadcrumbs && config.zipBreadcrumbs.length > 0">
|
||||
<icon-right class="breadcrumb-sep" />
|
||||
<a-tag
|
||||
v-for="(crumb, index) in config.zipBreadcrumbs"
|
||||
:key="index"
|
||||
size="small"
|
||||
class="breadcrumb-tag"
|
||||
@click="handleNavigateToZipDirectory(crumb.path)"
|
||||
>
|
||||
{{ crumb.name }}
|
||||
</a-tag>
|
||||
</template>
|
||||
<a-button size="small" type="outline" @click="handleExitZip">
|
||||
<template #icon><icon-close /></template>
|
||||
退出 ZIP
|
||||
</a-button>
|
||||
</div>
|
||||
<!-- 正常模式:连接指示器 + 面包屑导航(融合布局) -->
|
||||
<div v-else class="path-breadcrumb-wrapper">
|
||||
<!-- 连接指示器(紧凑标签样式,作为面包屑首段) -->
|
||||
<ConnectionIndicator @add="showConnectionDialog = true" @select="onConnectionChanged" @edit="onEditProfile" />
|
||||
<span class="breadcrumb-sep">›</span>
|
||||
<!-- 路径面包屑 -->
|
||||
<PathBreadcrumb
|
||||
:path="config.filePath"
|
||||
@navigate="handleGoToPath"
|
||||
@openFile="handleOpenFile"
|
||||
/>
|
||||
<!-- 右侧操作:快捷路径 + 复制 -->
|
||||
<div class="breadcrumb-right-actions">
|
||||
<a-tooltip content="快捷路径" position="bottom">
|
||||
<a-dropdown>
|
||||
<a-button size="mini" type="text" class="shortcut-btn">
|
||||
<template #icon><icon-forward /></template>
|
||||
</a-button>
|
||||
<template #content>
|
||||
<a-doption
|
||||
v-for="shortcut in config.commonPaths"
|
||||
:key="shortcut.path"
|
||||
@click="handleGoToPath(shortcut.path)"
|
||||
>
|
||||
<template #icon>{{ (shortcut.name || '').split(' ')[0] }}</template>
|
||||
{{ (shortcut.name || '').substring(2) }}
|
||||
</a-doption>
|
||||
</template>
|
||||
</a-dropdown>
|
||||
</a-tooltip>
|
||||
<a-tooltip :content="copied ? '已复制' : '复制路径'" position="top">
|
||||
<a-button
|
||||
size="mini"
|
||||
type="text"
|
||||
:status="copied ? 'success' : 'normal'"
|
||||
class="toolbar-copy-btn"
|
||||
@click="handleCopyPath"
|
||||
>
|
||||
<icon-copy v-if="!copied" />
|
||||
<icon-check v-else />
|
||||
</a-button>
|
||||
</a-tooltip>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="toolbar-right">
|
||||
<!-- 搜索框 -->
|
||||
<a-input-search
|
||||
:model-value="config.searchKeyword"
|
||||
placeholder="搜索文件..."
|
||||
size="small"
|
||||
class="toolbar-search"
|
||||
allow-clear
|
||||
@search="handleSearch"
|
||||
@update:model-value="handleSearch"
|
||||
@keyup.escape="handleClearSearch"
|
||||
/>
|
||||
|
||||
<!-- 刷新按钮 -->
|
||||
<a-button
|
||||
size="small"
|
||||
:loading="config.fileLoading"
|
||||
@click="handleRefresh"
|
||||
>
|
||||
<template #icon>
|
||||
<icon-refresh />
|
||||
</template>
|
||||
刷新
|
||||
</a-button>
|
||||
|
||||
<!-- 历史记录下拉(仅图标,Ctrl+H) -->
|
||||
<a-dropdown
|
||||
v-model:popup-visible="historyPopupVisible"
|
||||
>
|
||||
<a-tooltip content="历史记录 (Ctrl+H)" position="left">
|
||||
<a-button size="small">
|
||||
<template #icon><icon-history /></template>
|
||||
</a-button>
|
||||
</a-tooltip>
|
||||
<template #content>
|
||||
<div class="history-dropdown-content">
|
||||
<a-doption
|
||||
v-for="path in config.pathHistory.slice(0, 10)"
|
||||
:key="path"
|
||||
@click="handleGoToPath(path)"
|
||||
>
|
||||
<span class="history-path-text">{{ path }}</span>
|
||||
</a-doption>
|
||||
<a-doption v-if="config.pathHistory.length === 0" disabled>暂无历史</a-doption>
|
||||
</div>
|
||||
</template>
|
||||
</a-dropdown>
|
||||
|
||||
<!-- 切换侧边栏 -->
|
||||
<a-button
|
||||
size="small"
|
||||
:type="config.showSidebar ? 'primary' : 'text'"
|
||||
@click="handleToggleSidebar"
|
||||
>
|
||||
<template #icon>
|
||||
<icon-menu />
|
||||
</template>
|
||||
</a-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ConnectionDialog ref="connectionDialogRef" v-model:visible="showConnectionDialog" />
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { nextTick } from 'vue'
|
||||
import { IconForward, IconHistory, IconRefresh, IconMenu, IconClose, IconRight, IconCopy, IconCheck } from '@arco-design/web-vue/es/icon'
|
||||
import type { ToolbarConfig } from '@/types/file-system'
|
||||
import PathBreadcrumb from './PathBreadcrumb.vue'
|
||||
import { useClipboardCopy } from '../composables/useClipboardCopy'
|
||||
import ConnectionIndicator from './ConnectionIndicator.vue'
|
||||
import ConnectionDialog from './ConnectionDialog.vue'
|
||||
|
||||
// Props
|
||||
interface Props {
|
||||
config: ToolbarConfig
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
|
||||
// Emits
|
||||
interface Emits {
|
||||
(e: 'update:filePath', path: string): void
|
||||
(e: 'update:showSidebar', show: boolean): void
|
||||
(e: 'update:searchKeyword', keyword: string): void
|
||||
(e: 'refresh'): void
|
||||
(e: 'exitZip'): void
|
||||
(e: 'goToPath', path: string): void
|
||||
(e: 'openFile', path: string): void
|
||||
(e: 'navigateToZipDirectory', path: string): void
|
||||
(e: 'connectionChanged'): void
|
||||
}
|
||||
|
||||
const emit = defineEmits<Emits>()
|
||||
|
||||
// 连接对话框
|
||||
const showConnectionDialog = ref(false)
|
||||
const connectionDialogRef = ref<InstanceType<typeof ConnectionDialog>>()
|
||||
const onConnectionChanged = async (_id: string) => {
|
||||
emit('connectionChanged')
|
||||
}
|
||||
|
||||
const onEditProfile = (id: string) => {
|
||||
showConnectionDialog.value = true
|
||||
// 等待 DOM 更新后调用 editProfile 填充表单
|
||||
nextTick(() => connectionDialogRef.value?.editProfile(id))
|
||||
}
|
||||
|
||||
// 历史记录下拉显隐(供父组件 Ctrl+H 调用)
|
||||
const historyPopupVisible = ref(false)
|
||||
|
||||
// 事件处理
|
||||
const handleGoToPath = (path: string) => {
|
||||
emit('goToPath', path)
|
||||
}
|
||||
|
||||
const handleOpenFile = (path: string) => {
|
||||
emit('openFile', path)
|
||||
}
|
||||
|
||||
const handleRefresh = () => {
|
||||
emit('refresh')
|
||||
}
|
||||
|
||||
const handleExitZip = () => {
|
||||
emit('exitZip')
|
||||
}
|
||||
|
||||
const handleNavigateToZipRoot = () => {
|
||||
emit('navigateToZipDirectory', '')
|
||||
}
|
||||
|
||||
const handleToggleSidebar = () => {
|
||||
emit('update:showSidebar', !props.config.showSidebar)
|
||||
}
|
||||
|
||||
const handleSearch = (keyword: string) => {
|
||||
emit('update:searchKeyword', keyword)
|
||||
}
|
||||
|
||||
const handleClearSearch = () => {
|
||||
emit('update:searchKeyword', '')
|
||||
}
|
||||
|
||||
// 切换历史记录下拉面板(供父组件 Ctrl+H 调用)
|
||||
const toggleHistoryDropdown = () => {
|
||||
historyPopupVisible.value = !historyPopupVisible.value
|
||||
}
|
||||
|
||||
const { copied, copy: copyPath } = useClipboardCopy()
|
||||
|
||||
// 暴露方法给父组件
|
||||
defineExpose({ toggleHistoryDropdown })
|
||||
|
||||
const handleCopyPath = async () => {
|
||||
await copyPath(props.config.filePath)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.toolbar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 8px 12px;
|
||||
background: var(--color-bg-2);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.toolbar-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.toolbar-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.toolbar-right :deep(.arco-btn-size-small),
|
||||
.toolbar-right :deep(.arco-input-wrapper) {
|
||||
height: 34px;
|
||||
}
|
||||
|
||||
.toolbar-search {
|
||||
width: 180px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.path-input-wrapper {
|
||||
flex: 1;
|
||||
min-width: 200px;
|
||||
}
|
||||
|
||||
.path-breadcrumb-wrapper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex: 1;
|
||||
min-width: 200px;
|
||||
gap: 4px;
|
||||
padding: 4px 8px;
|
||||
background: var(--color-fill-1);
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--color-border);
|
||||
transition: border-color 0.2s;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.path-breadcrumb-wrapper:hover {
|
||||
border-color: var(--color-border-2);
|
||||
}
|
||||
|
||||
.breadcrumb-sep {
|
||||
color: var(--color-text-3);
|
||||
font-size: 12px;
|
||||
flex-shrink: 0;
|
||||
line-height: 1;
|
||||
margin: 0 1px;
|
||||
}
|
||||
|
||||
.breadcrumb-right-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
margin-left: auto;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.shortcut-btn {
|
||||
padding: 1px 3px;
|
||||
}
|
||||
|
||||
.toolbar-copy-btn {
|
||||
padding: 2px 4px;
|
||||
}
|
||||
|
||||
.zip-breadcrumb {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.zip-file-tag {
|
||||
cursor: pointer;
|
||||
font-family: 'Consolas', 'Monaco', monospace;
|
||||
font-size: 12px;
|
||||
background: var(--color-fill-2);
|
||||
border-color: var(--color-border-2);
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.zip-file-tag:hover {
|
||||
background: var(--color-fill-3);
|
||||
border-color: rgb(var(--primary-6));
|
||||
}
|
||||
|
||||
.breadcrumb-tag {
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
background: var(--color-fill-1);
|
||||
border-color: var(--color-border-2);
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.breadcrumb-tag:hover {
|
||||
background: var(--color-fill-2);
|
||||
border-color: rgb(var(--primary-6));
|
||||
}
|
||||
|
||||
/* 历史记录下拉 */
|
||||
.history-dropdown-content {
|
||||
max-width: 420px;
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.history-path-text {
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
max-width: 380px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,42 @@
|
||||
import { ref } from 'vue'
|
||||
import { Message } from '@arco-design/web-vue'
|
||||
|
||||
/**
|
||||
* 拷贝路径 composable(3-tier fallback: Wails native → clipboard API → execCommand)
|
||||
*/
|
||||
export function useClipboardCopy() {
|
||||
const copied = ref(false)
|
||||
let copyTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
const copy = async (path: string) => {
|
||||
if (!path || copied.value) return
|
||||
|
||||
try {
|
||||
await navigator.clipboard.writeText(path)
|
||||
copied.value = true
|
||||
} catch {
|
||||
try {
|
||||
const input = document.createElement('input')
|
||||
input.style.position = 'fixed'
|
||||
input.style.opacity = '0'
|
||||
input.value = path
|
||||
document.body.appendChild(input)
|
||||
input.select()
|
||||
document.execCommand('copy')
|
||||
document.body.removeChild(input)
|
||||
copied.value = true
|
||||
} catch {
|
||||
Message.error('复制失败')
|
||||
}
|
||||
}
|
||||
|
||||
if (copyTimer) clearTimeout(copyTimer)
|
||||
copyTimer = setTimeout(() => { copied.value = false }, 2000)
|
||||
}
|
||||
|
||||
const cleanup = () => {
|
||||
if (copyTimer) { clearTimeout(copyTimer); copyTimer = null }
|
||||
}
|
||||
|
||||
return { copied, copy, cleanup }
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
/**
|
||||
* 系统常用路径 Composable
|
||||
* 提供系统路径获取和快捷访问路径管理
|
||||
*/
|
||||
|
||||
import { ref } from 'vue'
|
||||
import { PATH_ICONS } from '@/utils/constants'
|
||||
import { getCommonPaths } from '@/api/system'
|
||||
import { connectionManager } from '@/api/connection-manager'
|
||||
import type { ShortcutPath } from '@/types/file-system'
|
||||
|
||||
export function useCommonPaths() {
|
||||
const commonPaths = ref<ShortcutPath[]>([])
|
||||
const systemPaths = ref<Record<string, string>>({})
|
||||
|
||||
const loadCommonPaths = async () => {
|
||||
try {
|
||||
const paths = await getCommonPaths()
|
||||
if (!paths) throw new Error('无法获取系统路径')
|
||||
|
||||
systemPaths.value = paths
|
||||
const pathList: ShortcutPath[] = []
|
||||
// 根据返回数据判断平台(Linux agent 返回 root key,Windows 返回 root_ 前缀)
|
||||
const isWin = !!Object.keys(paths).find(k => k.startsWith('root_'))
|
||||
|
||||
if (isWin) {
|
||||
if (paths.desktop) pathList.push({ name: `${PATH_ICONS.DESKTOP} 桌面`, path: paths.desktop })
|
||||
if (paths.documents) pathList.push({ name: `${PATH_ICONS.DOCUMENTS} 文档`, path: paths.documents })
|
||||
if (paths.downloads) pathList.push({ name: `${PATH_ICONS.DOWNLOADS} 下载`, path: paths.downloads })
|
||||
if (paths.home) pathList.push({ name: `${PATH_ICONS.HOME} 用户目录`, path: paths.home })
|
||||
const drives: Array<{ letter: string; path: string }> = []
|
||||
for (const key in paths) {
|
||||
if (key.startsWith('root_')) {
|
||||
drives.push({ letter: key.substring(5), path: paths[key] })
|
||||
}
|
||||
}
|
||||
drives.sort((a, b) => a.letter.localeCompare(b.letter))
|
||||
drives.forEach(d => pathList.push({ name: `${PATH_ICONS.DRIVE} ${d.letter}盘`, path: d.path }))
|
||||
} else {
|
||||
// Linux 远程模式
|
||||
if (paths.home) pathList.push({ name: `${PATH_ICONS.HOME} 主目录`, path: paths.home })
|
||||
if (paths.root) pathList.push({ name: `${PATH_ICONS.ROOT} 根目录`, path: '/' })
|
||||
if (paths.users) pathList.push({ name: `👥 /home`, path: paths.users })
|
||||
}
|
||||
|
||||
commonPaths.value = pathList.length > 0 ? pathList : (
|
||||
connectionManager.isRemote()
|
||||
? [{ name: `${PATH_ICONS.ROOT} 根目录`, path: '/' }]
|
||||
: [{ name: '💿 C盘', path: 'C:\\' }, { name: '💿 D盘', path: 'D:\\' }]
|
||||
)
|
||||
} catch (error) {
|
||||
console.error('加载系统路径失败:', error)
|
||||
commonPaths.value = connectionManager.isRemote()
|
||||
? [{ name: `${PATH_ICONS.ROOT} 根目录`, path: '/' }]
|
||||
: [{ name: '💿 C盘', path: 'C:\\' }, { name: '💿 D盘', path: 'D:\\' }]
|
||||
}
|
||||
}
|
||||
|
||||
return { commonPaths, systemPaths, loadCommonPaths }
|
||||
}
|
||||
257
frontend/src/components/FileSystem/composables/useFavorites.ts
Normal file
257
frontend/src/components/FileSystem/composables/useFavorites.ts
Normal file
@@ -0,0 +1,257 @@
|
||||
/**
|
||||
* 收藏夹管理 Composable
|
||||
* 提供收藏文件的添加、删除、排序等功能
|
||||
*/
|
||||
|
||||
import { ref } from 'vue'
|
||||
import { STORAGE_KEYS, DEFAULTS } from '@/utils/constants'
|
||||
import { getPathSeparator } from '@/utils/fileUtils'
|
||||
import { Message } from '@arco-design/web-vue'
|
||||
import type { FavoriteFile, FileItem, DraggingState } from '@/types/file-system'
|
||||
|
||||
export function useFavorites() {
|
||||
// 收藏列表
|
||||
const favorites = ref<FavoriteFile[]>([])
|
||||
|
||||
// 拖拽状态
|
||||
const draggingState = ref<DraggingState>({
|
||||
isDragging: false,
|
||||
draggedIndex: -1,
|
||||
pressedIndex: -1
|
||||
})
|
||||
|
||||
/**
|
||||
* 排序收藏列表:置顶项归到前面,组内保持原有顺序(尊重拖拽)
|
||||
*/
|
||||
const sortFavorites = () => {
|
||||
const pinned = favorites.value.filter(f => f.pinnedAt)
|
||||
const unpinned = favorites.value.filter(f => !f.pinnedAt)
|
||||
favorites.value = [...pinned, ...unpinned]
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 localStorage 加载收藏列表
|
||||
*/
|
||||
const loadFavorites = () => {
|
||||
try {
|
||||
const stored = localStorage.getItem(STORAGE_KEYS.FAVORITE_FILES)
|
||||
if (stored) {
|
||||
const loaded = JSON.parse(stored) as FavoriteFile[]
|
||||
|
||||
// 数据迁移:将旧字段 is_dir 转换为 isDir
|
||||
favorites.value = loaded.map(fav => ({
|
||||
...fav,
|
||||
isDir: fav.isDir ?? (fav as any).is_dir ?? false
|
||||
}))
|
||||
|
||||
// 仅排序(置顶项归组到前面),保持用户拖拽顺序
|
||||
sortFavorites()
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载收藏列表失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存收藏列表到 localStorage
|
||||
*/
|
||||
const saveFavorites = () => {
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEYS.FAVORITE_FILES, JSON.stringify(favorites.value))
|
||||
} catch (error) {
|
||||
console.error('保存收藏列表失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 标准化路径用于比较(Windows 大小写不敏感)
|
||||
*/
|
||||
const normalizePath = (path: string): string => {
|
||||
return path.toLowerCase()
|
||||
}
|
||||
|
||||
/**
|
||||
* 添加收藏
|
||||
*/
|
||||
const addFavorite = (file: FileItem) => {
|
||||
if (isFavorite(file.path)) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (favorites.value.length >= DEFAULTS.MAX_FAVORITES_LENGTH) {
|
||||
Message.warning(`收藏夹已满,最多收藏 ${DEFAULTS.MAX_FAVORITES_LENGTH} 项`)
|
||||
return false
|
||||
}
|
||||
|
||||
favorites.value.push({
|
||||
...file,
|
||||
addedAt: Date.now()
|
||||
} as FavoriteFile)
|
||||
sortFavorites()
|
||||
saveFavorites()
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除收藏
|
||||
*/
|
||||
const removeFavorite = (path: string) => {
|
||||
const normalizedPath = normalizePath(path)
|
||||
const index = favorites.value.findIndex(fav => normalizePath(fav.path) === normalizedPath)
|
||||
if (index !== -1) {
|
||||
favorites.value.splice(index, 1)
|
||||
saveFavorites()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 切换收藏状态
|
||||
*/
|
||||
const toggleFavorite = (file: FileItem) => {
|
||||
if (isFavorite(file.path)) {
|
||||
removeFavorite(file.path)
|
||||
return false
|
||||
}
|
||||
addFavorite(file)
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查是否已收藏
|
||||
*/
|
||||
const isFavorite = (path: string): boolean => {
|
||||
const normalizedPath = normalizePath(path)
|
||||
return favorites.value.some(fav => normalizePath(fav.path) === normalizedPath)
|
||||
}
|
||||
|
||||
/**
|
||||
* 切换置顶状态
|
||||
*/
|
||||
const togglePin = (path: string) => {
|
||||
const normalizedPath = normalizePath(path)
|
||||
const fav = favorites.value.find(f => normalizePath(f.path) === normalizedPath)
|
||||
if (fav) {
|
||||
fav.pinnedAt = fav.pinnedAt ? undefined : Date.now()
|
||||
sortFavorites()
|
||||
saveFavorites()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查是否已置顶
|
||||
*/
|
||||
const isPinned = (path: string): boolean => {
|
||||
const normalizedPath = normalizePath(path)
|
||||
const fav = favorites.value.find(f => normalizePath(f.path) === normalizedPath)
|
||||
return !!fav?.pinnedAt
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新收藏项路径(重命名时使用,保留置顶状态和添加时间)
|
||||
*/
|
||||
const updateFavoritePath = (oldPath: string, newName: string) => {
|
||||
const normalizedOld = normalizePath(oldPath)
|
||||
const fav = favorites.value.find(f => normalizePath(f.path) === normalizedOld)
|
||||
if (!fav) return
|
||||
|
||||
const separator = getPathSeparator(oldPath)
|
||||
const parentPath = oldPath.substring(
|
||||
0,
|
||||
Math.max(oldPath.lastIndexOf('\\'), oldPath.lastIndexOf('/'))
|
||||
)
|
||||
fav.path = parentPath + separator + newName
|
||||
fav.name = newName
|
||||
saveFavorites()
|
||||
}
|
||||
|
||||
// 拖拽方法
|
||||
let longPressTimer = ref<ReturnType<typeof setTimeout> | null>(null)
|
||||
|
||||
const onLongPressStart = (event: MouseEvent | TouchEvent, index: number) => {
|
||||
if (event instanceof MouseEvent && event.button !== 0) return
|
||||
if (!(event instanceof MouseEvent) && !(event instanceof TouchEvent)) return
|
||||
|
||||
longPressTimer = setTimeout(() => {
|
||||
draggingState.value.pressedIndex = index
|
||||
draggingState.value.draggedIndex = index
|
||||
}, 200)
|
||||
}
|
||||
|
||||
const onLongPressCancel = () => {
|
||||
if (longPressTimer) {
|
||||
clearTimeout(longPressTimer)
|
||||
longPressTimer = null
|
||||
}
|
||||
if (!draggingState.value.isDragging) {
|
||||
draggingState.value.pressedIndex = -1
|
||||
draggingState.value.draggedIndex = -1
|
||||
}
|
||||
}
|
||||
|
||||
const onDragStart = (event: DragEvent, index: number) => {
|
||||
draggingState.value.isDragging = true
|
||||
draggingState.value.draggedIndex = index
|
||||
if (event.dataTransfer) {
|
||||
event.dataTransfer.effectAllowed = 'move'
|
||||
}
|
||||
}
|
||||
|
||||
const onDragOver = (event: DragEvent) => {
|
||||
event.preventDefault()
|
||||
if (event.dataTransfer) {
|
||||
event.dataTransfer.dropEffect = 'move'
|
||||
}
|
||||
}
|
||||
|
||||
const onDrop = (event: DragEvent, targetIndex: number) => {
|
||||
event.preventDefault()
|
||||
|
||||
const fromIndex = draggingState.value.draggedIndex
|
||||
if (fromIndex === targetIndex || fromIndex === -1) {
|
||||
resetDragging()
|
||||
return
|
||||
}
|
||||
|
||||
const item = favorites.value.splice(fromIndex, 1)[0]
|
||||
favorites.value.splice(targetIndex, 0, item)
|
||||
saveFavorites()
|
||||
resetDragging()
|
||||
}
|
||||
|
||||
const onDragEnd = () => {
|
||||
resetDragging()
|
||||
}
|
||||
|
||||
const resetDragging = () => {
|
||||
draggingState.value.isDragging = false
|
||||
draggingState.value.draggedIndex = -1
|
||||
draggingState.value.pressedIndex = -1
|
||||
}
|
||||
|
||||
// 组件挂载时加载收藏列表
|
||||
loadFavorites()
|
||||
|
||||
return {
|
||||
favorites,
|
||||
draggingState,
|
||||
|
||||
addFavorite,
|
||||
removeFavorite,
|
||||
toggleFavorite,
|
||||
isFavorite,
|
||||
togglePin,
|
||||
isPinned,
|
||||
updateFavoritePath,
|
||||
|
||||
onLongPressStart,
|
||||
onLongPressCancel,
|
||||
onDragStart,
|
||||
onDragOver,
|
||||
onDrop,
|
||||
onDragEnd,
|
||||
|
||||
loadFavorites,
|
||||
saveFavorites,
|
||||
resetDragging
|
||||
}
|
||||
}
|
||||
610
frontend/src/components/FileSystem/composables/useFileEdit.ts
Normal file
610
frontend/src/components/FileSystem/composables/useFileEdit.ts
Normal file
@@ -0,0 +1,610 @@
|
||||
/**
|
||||
* 文件编辑 Composable
|
||||
* 提供文件编辑相关的逻辑,包括草稿管理、保存、撤销等
|
||||
*/
|
||||
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import { Message } from '@arco-design/web-vue'
|
||||
import { STORAGE_KEYS, FILE_EXTENSIONS, FILE_SIZE_THRESHOLDS } from '@/utils/constants'
|
||||
import { getExt } from '@/utils/fileUtils'
|
||||
import {
|
||||
isImageFile, isVideoFile, isAudioFile, isPdfFile,
|
||||
isExcelFile, isWordFile, isCsvFile,
|
||||
isTextEditable, isConfigFile
|
||||
} from '@/utils/fileTypeHelpers'
|
||||
import { useFileOperations } from './useFileOperations'
|
||||
import type { FileItem } from '@/types/file-system'
|
||||
|
||||
export interface UseFileEditOptions {
|
||||
currentFilePath?: import('vue').Ref<FileItem | null>
|
||||
currentDirectory?: import('vue').Ref<string>
|
||||
}
|
||||
|
||||
// 文件大小限制(5MB)
|
||||
const MAX_TEXT_FILE_SIZE = 5 * 1024 * 1024 // 5MB
|
||||
|
||||
export function useFileEdit(options: UseFileEditOptions = {}) {
|
||||
const { currentFilePath = ref(''), currentDirectory = ref('') } = options
|
||||
|
||||
// 文件内容
|
||||
const fileContent = ref('')
|
||||
const originalContent = ref('')
|
||||
|
||||
// 当前文件路径(用于验证更新是否来自当前文件)
|
||||
const currentFilePathRef = ref('')
|
||||
|
||||
// 编辑状态
|
||||
const isEditMode = ref(false)
|
||||
const fileContentHeight = ref(400)
|
||||
const isBinaryFile = ref(false)
|
||||
|
||||
// 草稿管理
|
||||
const draftKey = ref('')
|
||||
|
||||
// 保存状态
|
||||
const isSaving = ref(false)
|
||||
|
||||
// 文件版本跟踪(用于防止切换文件后的过期更新)
|
||||
const fileVersion = ref(0)
|
||||
|
||||
// 使用文件操作 composable
|
||||
const { readFile, writeFile } = useFileOperations({
|
||||
onSuccess: (operation, data) => {
|
||||
// 可以在这里添加成功处理逻辑
|
||||
},
|
||||
onError: (operation, error) => {
|
||||
Message.error(`${operation} 失败: ${error.message}`)
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* 获取文件路径(从 FileItem 对象或字符串中提取)
|
||||
*/
|
||||
const getFilePath = (input: any): string => {
|
||||
if (!input) return ''
|
||||
if (typeof input === 'string') return input
|
||||
if (input.path) return input.path
|
||||
return ''
|
||||
}
|
||||
|
||||
// 已知二进制扩展名(无需读取内容即可判定)
|
||||
const KNOWN_BINARY_EXTS = new Set([
|
||||
'exe', 'dll', 'so', 'bin', 'dat', 'db', 'sqlite', 'pdb', 'idb',
|
||||
'lib', 'obj', 'o', 'a', 'class', 'pyc', 'pyo', 'wasm',
|
||||
'zip', 'rar', '7z', 'tar', 'gz', 'bz2', 'xz', 'iso', 'img', 'dmg',
|
||||
'msi', 'jar', 'war', 'ear', 'apk'
|
||||
])
|
||||
|
||||
/**
|
||||
* 判断是否为二进制文件(基于扩展名)
|
||||
* 返回值: true=已知二进制, false=已知非二进制(文本/媒体/Office), null=未知需检测
|
||||
*/
|
||||
const isBinaryFileByExt = (filepath: string | FileItem): boolean | null => {
|
||||
const path = getFilePath(filepath)
|
||||
const ext = getExt(path)
|
||||
if (!ext) return null // 无扩展名返回 null,表示需要进一步检测
|
||||
|
||||
// 已知二进制扩展名 → 直接判定
|
||||
if (KNOWN_BINARY_EXTS.has(ext)) return true
|
||||
|
||||
// 媒体文件(可预览,不算二进制)
|
||||
const isMediaFile = isImageFile(path) ||
|
||||
isVideoFile(path) ||
|
||||
isAudioFile(path) ||
|
||||
isPdfFile(path) ||
|
||||
['html', 'htm', 'md', 'markdown'].includes(ext)
|
||||
|
||||
// Office 文件和 CSV(可预览)
|
||||
const isOfficeFile = isExcelFile(path) || isWordFile(path) || isCsvFile(path)
|
||||
|
||||
// 文本或代码文件(可编辑)
|
||||
const isTextFile = isTextEditable(path) || isConfigFile(path) ||
|
||||
FILE_EXTENSIONS.CODE.includes(ext)
|
||||
|
||||
// 如果是媒体文件、Office 文件或文本文件,就不是二进制
|
||||
if (isMediaFile || isOfficeFile || isTextFile) return false
|
||||
|
||||
// 其他扩展名未知,需要内容检测
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算属性:当前视图是否可编辑
|
||||
* 图片、视频、音频、PDF、二进制文件不可编辑
|
||||
*/
|
||||
const isEditableView = computed(() => {
|
||||
const path = getFilePath(currentFilePath.value)
|
||||
if (!path) return false
|
||||
const binaryCheck = isBinaryFileByExt(path)
|
||||
return !isImageFile(path) &&
|
||||
!isVideoFile(path) &&
|
||||
!isAudioFile(path) &&
|
||||
!isPdfFile(path) &&
|
||||
binaryCheck !== true // true 表示是二进制,不可编辑;false 或 null 表示可尝试编辑
|
||||
})
|
||||
|
||||
/**
|
||||
* 计算属性:文件内容是否改变
|
||||
*/
|
||||
const contentChanged = computed(() => {
|
||||
return fileContent.value !== '' &&
|
||||
originalContent.value !== undefined &&
|
||||
originalContent.value !== fileContent.value
|
||||
})
|
||||
|
||||
/**
|
||||
* 计算属性:是否可以保存
|
||||
*/
|
||||
const canSaveFile = computed(() => {
|
||||
return isEditableView.value && contentChanged.value
|
||||
})
|
||||
|
||||
/**
|
||||
* 计算属性:是否可以重置
|
||||
*/
|
||||
const canResetContent = computed(() => {
|
||||
return contentChanged.value && originalContent.value !== undefined
|
||||
})
|
||||
|
||||
/**
|
||||
* 检测文件内容是否为二进制
|
||||
*/
|
||||
const detectBinaryContent = (content: string): boolean => {
|
||||
if (!content || content.length === 0) return false
|
||||
|
||||
// 检查前 1000 个字符中二进制字符的比例
|
||||
const checkLength = Math.min(content.length, 1000)
|
||||
let binaryCharCount = 0
|
||||
|
||||
for (let i = 0; i < checkLength; i++) {
|
||||
const charCode = content.charCodeAt(i)
|
||||
// 空字节肯定是二进制
|
||||
// 控制字符(charCode < 32)除了 Tab(9)、LF(10)、CR(13) 外都是二进制
|
||||
if (charCode === 0 || (charCode < 32 && charCode !== 9 && charCode !== 10 && charCode !== 13)) {
|
||||
binaryCharCount++
|
||||
}
|
||||
}
|
||||
|
||||
// 如果二进制字符超过 5%,认为是二进制文件
|
||||
const binaryRatio = binaryCharCount / checkLength
|
||||
return binaryRatio > 0.05
|
||||
}
|
||||
|
||||
/**
|
||||
* 读取文件内容
|
||||
*/
|
||||
const loadFile = async (path: string) => {
|
||||
try {
|
||||
isBinaryFile.value = false
|
||||
|
||||
// 记录当前加载的文件路径,用于后续验证更新
|
||||
currentFilePathRef.value = path
|
||||
|
||||
// 增加文件版本号,使之前的过期更新失效
|
||||
fileVersion.value++
|
||||
|
||||
// 注意:不再清空内容,避免 HTML 预览切换时闪烁
|
||||
// 新内容加载完成后会直接替换旧内容
|
||||
|
||||
const filename = getFilePath(path)
|
||||
const ext = getExt(filename)
|
||||
|
||||
// Office 文件直接读取内容进行预览,跳过二进制检测
|
||||
if (isExcelFile(filename) || isWordFile(filename)) {
|
||||
const content = await readFile(path)
|
||||
fileContent.value = content
|
||||
originalContent.value = content
|
||||
isEditMode.value = false
|
||||
return
|
||||
}
|
||||
|
||||
// 先检查扩展名,如果是已知的二进制文件,直接生成提示信息
|
||||
const binaryCheck = isBinaryFileByExt(filename)
|
||||
if (binaryCheck === true) {
|
||||
isBinaryFile.value = true
|
||||
|
||||
const fileTypeDescriptions: Record<string, string> = {
|
||||
'exe': '可执行文件',
|
||||
'dll': '动态链接库',
|
||||
'so': '共享库',
|
||||
'bin': '二进制文件',
|
||||
'dat': '数据文件',
|
||||
'db': '数据库文件',
|
||||
'sqlite': 'SQLite 数据库',
|
||||
'zip': 'ZIP 压缩文件',
|
||||
'rar': 'RAR 压缩文件',
|
||||
'7z': '7Z 压缩文件',
|
||||
'tar': 'TAR 归档文件',
|
||||
'gz': 'GZ 压缩文件',
|
||||
'bz2': 'BZ2 压缩文件',
|
||||
'xz': 'XZ 压缩文件',
|
||||
'iso': '光盘镜像',
|
||||
'img': '磁盘镜像',
|
||||
'dmg': 'DMG 镜像',
|
||||
'pdb': '程序数据库',
|
||||
'idb': 'IDA 数据库',
|
||||
'lib': '库文件',
|
||||
'obj': '目标文件',
|
||||
'o': '目标文件',
|
||||
'a': '静态库'
|
||||
}
|
||||
|
||||
const fileTypeDesc = fileTypeDescriptions[ext] || `${ext.toUpperCase()} 文件`
|
||||
const fileName = filename.split('\\').pop() || filename.split('/').pop() || filename
|
||||
|
||||
fileContent.value = `================================================================
|
||||
文件信息:${fileTypeDesc}
|
||||
================================================================
|
||||
|
||||
文件名: ${fileName}
|
||||
完整路径: ${filename}
|
||||
文件类型: ${fileTypeDesc}
|
||||
|
||||
================================================================
|
||||
ℹ️ 这是已知的二进制文件类型,不支持文本预览
|
||||
|
||||
💡 提示:
|
||||
• 右键菜单 → "使用系统程序打开" 在默认应用中打开
|
||||
• 右键菜单 → "在资源管理器中显示" 查看文件位置
|
||||
================================================================`
|
||||
originalContent.value = fileContent.value
|
||||
isEditMode.value = false
|
||||
return
|
||||
}
|
||||
|
||||
// 对于无扩展名或未知类型文件,先尝试读取
|
||||
const content = await readFile(path)
|
||||
|
||||
// 检查文件大小
|
||||
const fileSize = content.length // UTF-16 字符数
|
||||
if (fileSize > MAX_TEXT_FILE_SIZE) {
|
||||
const sizeMB = (fileSize / 1024 / 1024).toFixed(2)
|
||||
const fileName = filename.split('\\').pop() || filename.split('/').pop() || filename
|
||||
|
||||
fileContent.value = `================================================================
|
||||
⚠️ 文件过大 (${sizeMB} MB)
|
||||
================================================================
|
||||
|
||||
文件名: ${fileName}
|
||||
完整路径: ${filename}
|
||||
文件大小: ${sizeMB} MB
|
||||
|
||||
================================================================
|
||||
当前文件大小超过 5MB,不适合在编辑器中打开。
|
||||
|
||||
💡 建议:
|
||||
• 使用命令行工具查看部分内容
|
||||
• 将文件拆分成多个小文件
|
||||
• 使用专门的工具处理大文件
|
||||
================================================================`
|
||||
originalContent.value = fileContent.value
|
||||
isEditMode.value = false
|
||||
return
|
||||
}
|
||||
|
||||
// 检测是否为二进制内容
|
||||
if (detectBinaryContent(content)) {
|
||||
isBinaryFile.value = true
|
||||
const fileTypeDesc = ext ? `${ext.toUpperCase()} 文件` : '未知类型文件'
|
||||
const fileName = filename.split('\\').pop() || filename.split('/').pop() || filename
|
||||
|
||||
// 根据是否有扩展名,显示不同提示
|
||||
const isUnknownType = !ext
|
||||
const messageTitle = isUnknownType ? '文件信息(未知类型)' : `文件信息:${fileTypeDesc}`
|
||||
const messageDesc = isUnknownType
|
||||
? '此文件没有扩展名,且内容检测显示为二进制格式'
|
||||
: `此文件扩展名为 .${ext},但内容检测显示为二进制格式`
|
||||
|
||||
fileContent.value = `================================================================
|
||||
${messageTitle}
|
||||
================================================================
|
||||
|
||||
文件名: ${fileName}
|
||||
完整路径: ${filename}
|
||||
${ext ? `文件类型: ${fileTypeDesc}\n` : ''}
|
||||
|
||||
================================================================
|
||||
ℹ️ ${messageDesc},不支持文本预览
|
||||
|
||||
💡 提示:
|
||||
• 右键菜单 → "使用系统程序打开" 在默认应用中打开
|
||||
• 右键菜单 → "在资源管理器中显示" 查看文件位置
|
||||
================================================================`
|
||||
originalContent.value = fileContent.value
|
||||
isEditMode.value = false
|
||||
return
|
||||
}
|
||||
|
||||
// 正常文本文件
|
||||
fileContent.value = content
|
||||
originalContent.value = content
|
||||
|
||||
// 加载草稿(如果存在)
|
||||
loadDraft(path)
|
||||
} catch (error) {
|
||||
Message.error(`读取文件失败: ${error}`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存文件内容
|
||||
*/
|
||||
const saveFile = async (path?: string, isShortcut: boolean = false) => {
|
||||
// 获取目标路径(优先使用传入的 path,否则从 currentFilePath 中提取)
|
||||
let targetPath = path
|
||||
if (!targetPath && currentFilePath.value) {
|
||||
targetPath = getFilePath(currentFilePath.value)
|
||||
}
|
||||
|
||||
if (!targetPath) {
|
||||
Message.error('没有选中的文件')
|
||||
return
|
||||
}
|
||||
|
||||
// 检查内容是否真的改变了
|
||||
if (fileContent.value === originalContent.value) {
|
||||
if (!isShortcut) {
|
||||
Message.info('文件内容未变更')
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
isSaving.value = true
|
||||
|
||||
try {
|
||||
await writeFile(targetPath, fileContent.value)
|
||||
originalContent.value = fileContent.value
|
||||
|
||||
// 清除草稿
|
||||
clearDraft()
|
||||
|
||||
if (!isShortcut) {
|
||||
Message.success('保存成功')
|
||||
}
|
||||
} catch (error) {
|
||||
Message.error(`保存失败: ${error}`)
|
||||
} finally {
|
||||
// 延迟清除保存状态
|
||||
setTimeout(() => {
|
||||
isSaving.value = false
|
||||
}, isShortcut ? 300 : 500)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存草稿
|
||||
*/
|
||||
const saveDraft = () => {
|
||||
if (!currentFilePath.value) return
|
||||
|
||||
// Office 文件不支持草稿功能
|
||||
const path = getFilePath(currentFilePath.value)
|
||||
if (isExcelFile(path) || isWordFile(path)) {
|
||||
return
|
||||
}
|
||||
|
||||
const key = `${STORAGE_KEYS.FILESYSTEM.FILE_DRAFT}-${currentFilePath.value}`
|
||||
const draft = {
|
||||
content: fileContent.value,
|
||||
savedAt: Date.now()
|
||||
}
|
||||
|
||||
try {
|
||||
localStorage.setItem(key, JSON.stringify(draft))
|
||||
draftKey.value = key
|
||||
} catch (error) {
|
||||
console.error('保存草稿失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 加载草稿
|
||||
*/
|
||||
const loadDraft = (path: string) => {
|
||||
// Office 文件不支持草稿功能,并清除已有的草稿
|
||||
if (isExcelFile(path) || isWordFile(path)) {
|
||||
const key = `${STORAGE_KEYS.FILESYSTEM.FILE_DRAFT}-${path}`
|
||||
try {
|
||||
localStorage.removeItem(key)
|
||||
console.debug('[useFileEdit] 已清除 Office 文件草稿:', path)
|
||||
} catch (error) {
|
||||
console.error('清除草稿失败:', error)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
const key = `${STORAGE_KEYS.FILESYSTEM.FILE_DRAFT}-${path}`
|
||||
draftKey.value = key
|
||||
|
||||
try {
|
||||
const stored = localStorage.getItem(key)
|
||||
if (stored) {
|
||||
const draft = JSON.parse(stored)
|
||||
const ageInHours = (Date.now() - draft.savedAt) / (1000 * 60 * 60)
|
||||
|
||||
// 如果草稿超过 24 小时,自动清除
|
||||
if (ageInHours > 24) {
|
||||
clearDraft()
|
||||
return
|
||||
}
|
||||
|
||||
// 恢复草稿内容
|
||||
fileContent.value = draft.content
|
||||
Message.info('已恢复未保存的草稿')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载草稿失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 清除草稿
|
||||
*/
|
||||
const clearDraft = () => {
|
||||
if (!draftKey.value) return
|
||||
|
||||
try {
|
||||
localStorage.removeItem(draftKey.value)
|
||||
draftKey.value = ''
|
||||
} catch (error) {
|
||||
console.error('清除草稿失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 仅更新文件路径(重命名场景:内容不变,只切换路径关联)
|
||||
* 迁移草稿 key,更新 currentFilePathRef
|
||||
*/
|
||||
const updateFilePath = (newPath: string) => {
|
||||
const oldPath = currentFilePathRef.value
|
||||
|
||||
// 迁移草稿(旧 key → 新 key)
|
||||
if (draftKey.value && oldPath !== newPath) {
|
||||
try {
|
||||
const draft = localStorage.getItem(draftKey.value)
|
||||
if (draft) {
|
||||
const newKey = `${STORAGE_KEYS.FILESYSTEM.FILE_DRAFT}-${newPath}`
|
||||
localStorage.setItem(newKey, draft)
|
||||
localStorage.removeItem(draftKey.value)
|
||||
draftKey.value = newKey
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('[useFileEdit] 草稿迁移失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// 只更新内部路径字符串引用,不触碰 currentFilePath(它是 FileItem 对象,由父组件管理)
|
||||
// 这样不会触发 watch → clearDraft
|
||||
currentFilePathRef.value = newPath
|
||||
}
|
||||
|
||||
/**
|
||||
* 重置文件内容
|
||||
*/
|
||||
const resetContent = () => {
|
||||
if (originalContent.value !== undefined) {
|
||||
fileContent.value = originalContent.value
|
||||
Message.info('已恢复原始内容')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 清空文件内容
|
||||
*/
|
||||
const clearContent = () => {
|
||||
fileContent.value = ''
|
||||
originalContent.value = ''
|
||||
}
|
||||
|
||||
/**
|
||||
* 切换编辑模式
|
||||
*/
|
||||
const toggleEditMode = () => {
|
||||
isEditMode.value = !isEditMode.value
|
||||
}
|
||||
|
||||
/**
|
||||
* 进入编辑模式
|
||||
*/
|
||||
const enterEditMode = () => {
|
||||
isEditMode.value = true
|
||||
}
|
||||
|
||||
/**
|
||||
* 退出编辑模式
|
||||
*/
|
||||
const exitEditMode = () => {
|
||||
// 如果有未保存的更改,提示用户
|
||||
if (contentChanged.value) {
|
||||
// 这里可以添加确认对话框
|
||||
// 暂时直接退出
|
||||
}
|
||||
isEditMode.value = false
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新文件内容(仅版本号匹配时接受,防止快速切换文件时旧更新覆盖新内容)
|
||||
*/
|
||||
const updateContent = (content: string, expectedVersion?: number) => {
|
||||
if (expectedVersion !== undefined && expectedVersion !== fileVersion.value) {
|
||||
console.debug('[useFileEdit] 忽略过期更新(版本不匹配):', {
|
||||
expected: expectedVersion,
|
||||
current: fileVersion.value,
|
||||
content: content.substring(0, 50)
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (fileContent.value !== content) {
|
||||
fileContent.value = content
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置编辑器高度
|
||||
*/
|
||||
const setEditorHeight = (height: number) => {
|
||||
fileContentHeight.value = Math.max(200, height)
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断文件是否在当前目录
|
||||
*/
|
||||
const isFileInCurrentDirectory = (filePathInput: any): boolean => {
|
||||
const filePath = getFilePath(filePathInput)
|
||||
if (!filePath || !currentDirectory.value) {
|
||||
return true
|
||||
}
|
||||
return filePath.startsWith(currentDirectory.value)
|
||||
}
|
||||
|
||||
// 监听文件路径变化,清除草稿
|
||||
watch(currentFilePath, (newPath, oldPath) => {
|
||||
if (newPath !== oldPath) {
|
||||
clearDraft()
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
// 状态
|
||||
fileContent,
|
||||
originalContent,
|
||||
isEditMode,
|
||||
fileContentHeight,
|
||||
isSaving,
|
||||
isBinaryFile,
|
||||
draftKey,
|
||||
fileVersion,
|
||||
|
||||
// 计算属性
|
||||
contentChanged,
|
||||
canSaveFile,
|
||||
canResetContent,
|
||||
isEditableView,
|
||||
|
||||
// 文件操作
|
||||
loadFile,
|
||||
saveFile,
|
||||
updateContent,
|
||||
|
||||
// 草稿管理
|
||||
saveDraft,
|
||||
loadDraft,
|
||||
clearDraft,
|
||||
|
||||
// 编辑模式
|
||||
toggleEditMode,
|
||||
enterEditMode,
|
||||
exitEditMode,
|
||||
|
||||
// 其他
|
||||
resetContent,
|
||||
clearContent,
|
||||
updateFilePath,
|
||||
setEditorHeight,
|
||||
|
||||
// 文件类型检查
|
||||
isBinaryFileByExt,
|
||||
isFileInCurrentDirectory
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,267 @@
|
||||
/**
|
||||
* 文件操作 Composable
|
||||
* 提供文件读取、写入、删除等基础操作,包括 ZIP 文件浏览
|
||||
*/
|
||||
|
||||
import { Message } from '@arco-design/web-vue'
|
||||
import { getPathSeparator } from '@/utils/fileUtils'
|
||||
import {
|
||||
listDir,
|
||||
readFile as readFileApi,
|
||||
writeFile as writeFileApi,
|
||||
deletePath as deletePathApi,
|
||||
createFile,
|
||||
createDir,
|
||||
renamePath as renamePathApi,
|
||||
listZipContents as listZipContentsApi,
|
||||
extractFileFromZip,
|
||||
extractFileFromZipToTemp as extractZipToTempApi,
|
||||
getFileServerURL as getFileServerUrlApi
|
||||
} from '@/api'
|
||||
import type { FileItem, FileOperationResult } from '@/types/file-system'
|
||||
|
||||
export interface UseFileOperationsOptions {
|
||||
onSuccess?: (operation: string, data: any) => void
|
||||
onError?: (operation: string, error: Error) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* 文件操作结果
|
||||
*/
|
||||
export function useFileOperations(options: UseFileOperationsOptions = {}) {
|
||||
const { onSuccess, onError } = options
|
||||
|
||||
/**
|
||||
* 列出目录内容
|
||||
*/
|
||||
const listDirectory = async (path: string): Promise<FileItem[]> => {
|
||||
try {
|
||||
const result = await listDir(path)
|
||||
onSuccess?.('listDirectory', result)
|
||||
return result
|
||||
} catch (error) {
|
||||
const err = error as Error
|
||||
onError?.('listDirectory', err)
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 读取文件内容
|
||||
*/
|
||||
const readFile = async (path: string): Promise<string> => {
|
||||
try {
|
||||
const content = await readFileApi(path)
|
||||
onSuccess?.('readFile', { path, size: content.length })
|
||||
return content
|
||||
} catch (error) {
|
||||
const err = error as Error
|
||||
onError?.('readFile', err)
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 写入文件内容
|
||||
*/
|
||||
const writeFile = async (
|
||||
path: string,
|
||||
content: string,
|
||||
createBackup: boolean = false
|
||||
): Promise<void> => {
|
||||
try {
|
||||
await writeFileApi(path, content)
|
||||
onSuccess?.('writeFile', { path, size: content.length })
|
||||
} catch (error) {
|
||||
const err = error as Error
|
||||
onError?.('writeFile', err)
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除路径(文件或目录),返回被删除的文件信息
|
||||
*/
|
||||
const deletePath = async (path: string): Promise<FileItem> => {
|
||||
try {
|
||||
const result = await deletePathApi(path)
|
||||
onSuccess?.('deletePath', { path, result })
|
||||
return result as FileItem
|
||||
} catch (error) {
|
||||
const err = error as Error
|
||||
onError?.('deletePath', err)
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建新文件,返回创建的文件信息
|
||||
*/
|
||||
const createNewFile = async (
|
||||
dirPath: string,
|
||||
filename: string
|
||||
): Promise<FileItem> => {
|
||||
try {
|
||||
const result = await createFile(dirPath, filename)
|
||||
onSuccess?.('createFile', { dirPath, filename, result })
|
||||
return result as FileItem
|
||||
} catch (error) {
|
||||
const err = error as Error
|
||||
onError?.('createFile', err)
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建新目录,返回创建的目录信息
|
||||
*/
|
||||
const createNewDir = async (parentPath: string, dirname: string): Promise<FileItem> => {
|
||||
try {
|
||||
const result = await createDir(parentPath, dirname)
|
||||
onSuccess?.('createDir', { parentPath, dirname, result })
|
||||
return result as FileItem
|
||||
} catch (error) {
|
||||
const err = error as Error
|
||||
onError?.('createDir', err)
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 重命名文件或目录,返回新文件信息
|
||||
*/
|
||||
const rename = async (oldPath: string, newName: string): Promise<FileItem> => {
|
||||
// 构造新路径
|
||||
const separator = getPathSeparator(oldPath)
|
||||
const parentPath = oldPath.substring(
|
||||
0,
|
||||
Math.max(oldPath.lastIndexOf('\\'), oldPath.lastIndexOf('/'))
|
||||
)
|
||||
const newPath = parentPath + separator + newName
|
||||
|
||||
try {
|
||||
const result = await renamePathApi(oldPath, newPath)
|
||||
onSuccess?.('rename', { oldPath, newPath, result })
|
||||
return result as FileItem
|
||||
} catch (error) {
|
||||
const err = error as Error
|
||||
onError?.('rename', err)
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 复制文件或目录
|
||||
*/
|
||||
const copy = async (fromPath: string, toPath: string): Promise<void> => {
|
||||
try {
|
||||
// TODO: 实现复制逻辑
|
||||
Message.warning('复制功能暂未实现')
|
||||
onSuccess?.('copy', { fromPath, toPath })
|
||||
} catch (error) {
|
||||
const err = error as Error
|
||||
onError?.('copy', err)
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 移动文件或目录
|
||||
*/
|
||||
const move = async (fromPath: string, toPath: string): Promise<void> => {
|
||||
try {
|
||||
// TODO: 实现移动逻辑
|
||||
Message.warning('移动功能暂未实现')
|
||||
onSuccess?.('move', { fromPath, toPath })
|
||||
} catch (error) {
|
||||
const err = error as Error
|
||||
onError?.('move', err)
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 列出 ZIP 文件内容
|
||||
*/
|
||||
const listZipContents = async (zipPath: string): Promise<FileItem[]> => {
|
||||
try {
|
||||
const result = await listZipContentsApi(zipPath)
|
||||
onSuccess?.('listZipContents', { zipPath, count: result.length })
|
||||
return result
|
||||
} catch (error) {
|
||||
const err = error as Error
|
||||
onError?.('listZipContents', err)
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 ZIP 中提取文件内容(文本)
|
||||
*/
|
||||
const extractZipFile = async (zipPath: string, filePath: string): Promise<string> => {
|
||||
try {
|
||||
const content = await extractFileFromZip(zipPath, filePath)
|
||||
onSuccess?.('extractZipFile', { zipPath, filePath, size: content.length })
|
||||
return content
|
||||
} catch (error) {
|
||||
const err = error as Error
|
||||
onError?.('extractZipFile', err)
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 ZIP 中提取文件到临时目录(二进制文件,如图片)
|
||||
*/
|
||||
const extractZipFileToTemp = async (zipPath: string, filePath: string): Promise<string> => {
|
||||
try {
|
||||
const tempPath = await extractZipToTempApi(zipPath, filePath)
|
||||
onSuccess?.('extractZipFileToTemp', { zipPath, filePath, tempPath })
|
||||
return tempPath
|
||||
} catch (error) {
|
||||
const err = error as Error
|
||||
onError?.('extractZipFileToTemp', err)
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取文件服务器 URL
|
||||
*/
|
||||
const getFileServerURL = async (): Promise<string> => {
|
||||
try {
|
||||
const url = await getFileServerUrlApi()
|
||||
onSuccess?.('getFileServerURL', { url })
|
||||
return url
|
||||
} catch (error) {
|
||||
const err = error as Error
|
||||
onError?.('getFileServerURL', err)
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
// 基础操作
|
||||
listDirectory,
|
||||
readFile,
|
||||
writeFile,
|
||||
deletePath,
|
||||
|
||||
// 创建操作
|
||||
createNewFile,
|
||||
createNewDir,
|
||||
|
||||
// 高级操作
|
||||
rename,
|
||||
copy,
|
||||
move,
|
||||
|
||||
// ZIP 操作
|
||||
listZipContents,
|
||||
extractZipFile,
|
||||
extractZipFileToTemp,
|
||||
getFileServerURL
|
||||
}
|
||||
}
|
||||
|
||||
// 注意:FileItem 类型已统一定义在 @/types/file-system.ts
|
||||
223
frontend/src/components/FileSystem/composables/useFilePreview.ts
Normal file
223
frontend/src/components/FileSystem/composables/useFilePreview.ts
Normal file
@@ -0,0 +1,223 @@
|
||||
/**
|
||||
* 文件预览 Composable
|
||||
* 提供文件预览 URL 生成、媒体元数据获取等功能
|
||||
*/
|
||||
|
||||
import { ref } from 'vue'
|
||||
import { FILE_EXTENSIONS, FILE_SIZE_THRESHOLDS } from '@/utils/constants'
|
||||
import { normalizeFilePath, getExt } from '@/utils/fileUtils'
|
||||
import { detectFileTypeByContent } from '@/api/system'
|
||||
import { connectionManager } from '@/api/connection-manager'
|
||||
import {
|
||||
isImageFile, isVideoFile, isAudioFile, isPdfFile,
|
||||
isHtmlFile, isMarkdownFile, isPreviewable as isPreviewableType,
|
||||
isTextEditable, isConfigFile
|
||||
} from '@/utils/fileTypeHelpers'
|
||||
import type { FilePreviewMetadata, FileType } from '@/types/file-system'
|
||||
|
||||
// 内容检测大小限制(与后端一致)
|
||||
const CONTENT_DETECT_MAX_SIZE = 500 * 1024 // 500KB
|
||||
|
||||
// 缓存检测结果
|
||||
const contentDetectCache = new Map<string, { timestamp: number; result: any }>()
|
||||
const CACHE_TTL = 60000 // 1分钟缓存
|
||||
|
||||
export interface UseFilePreviewOptions {
|
||||
filePath?: string
|
||||
isBrowsingZip?: boolean
|
||||
}
|
||||
|
||||
function getLocalServerURL(): string {
|
||||
return 'http://localhost:8073'
|
||||
}
|
||||
|
||||
function resolveFileServerBase(): string {
|
||||
// 单一数据源:从 connectionManager 实时读取,不缓存
|
||||
if (!connectionManager.isRemote()) return getLocalServerURL()
|
||||
const base = connectionManager.getFileServerBaseURL()
|
||||
if (!base) return getLocalServerURL()
|
||||
// 远程模式需要完整代理路径前缀 /api/v1/proxy/localfs
|
||||
return base.replace(/\/$/, '') + '/api/v1/proxy/localfs'
|
||||
}
|
||||
|
||||
export function useFilePreview(options: UseFilePreviewOptions = {}) {
|
||||
const { filePath = ref(''), isBrowsingZip = ref(false) } = options
|
||||
|
||||
// 预览 URL
|
||||
const previewUrl = ref('')
|
||||
|
||||
// 媒体加载状态
|
||||
const imageLoading = ref(false)
|
||||
const currentImageDimensions = ref('')
|
||||
|
||||
/**
|
||||
* 获取预览 URL(本地/远程自适应,每次实时计算)
|
||||
* 本地: http://localhost:8073/localfs/{encoded_path}
|
||||
* 远程: {baseUrl}/api/v1/proxy/localfs/{raw_path}(Cookie 自动携带认证)
|
||||
*/
|
||||
const getPreviewUrl = (path: string): string => {
|
||||
if (!path) return ''
|
||||
const isRemote = connectionManager.isRemote()
|
||||
const base = resolveFileServerBase()
|
||||
let normalized = normalizeFilePath(path, true)
|
||||
// 远程模式去掉前导 /,避免与 URL 基础路径拼接产生双斜杠(导致 307 重定向)
|
||||
if (isRemote && normalized.startsWith('/')) normalized = normalized.slice(1)
|
||||
const sep = base.endsWith('/') ? '' : '/'
|
||||
return `${base}${sep}${isRemote ? '' : 'localfs/'}${normalized}`
|
||||
}
|
||||
|
||||
/**
|
||||
* 通过内容检测文件类型(用于小文件)
|
||||
*/
|
||||
const detectByContent = async (path: string, fileSize?: number): Promise<{ category: string; ext: string } | null> => {
|
||||
// 如果文件太大,跳过内容检测
|
||||
if (fileSize !== undefined && fileSize > CONTENT_DETECT_MAX_SIZE) {
|
||||
return null
|
||||
}
|
||||
|
||||
// 检查缓存
|
||||
const cached = contentDetectCache.get(path)
|
||||
if (cached && Date.now() - cached.timestamp < CACHE_TTL) {
|
||||
return cached.result
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await detectFileTypeByContent(path)
|
||||
const data = { category: result.category, ext: result.extension }
|
||||
contentDetectCache.set(path, { timestamp: Date.now(), result: data })
|
||||
return data
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新预览 URL
|
||||
*/
|
||||
const updatePreviewUrl = async (path: string) => {
|
||||
previewUrl.value = getPreviewUrl(path)
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取文件类型
|
||||
*/
|
||||
const getFileType = (filename: string): FileType => {
|
||||
if (!filename || typeof filename !== 'string') return 'Binary' as FileType
|
||||
|
||||
if (isImageFile(filename)) return 'Image' as FileType
|
||||
if (isVideoFile(filename)) return 'Video' as FileType
|
||||
if (isAudioFile(filename)) return 'Audio' as FileType
|
||||
if (isPdfFile(filename)) return 'Pdf' as FileType
|
||||
if (isHtmlFile(filename)) return 'Html' as FileType
|
||||
if (isMarkdownFile(filename)) return 'Markdown' as FileType
|
||||
if (FILE_EXTENSIONS.CODE.includes(getExt(filename))) return 'Code' as FileType
|
||||
if (isConfigFile(filename)) return 'Code' as FileType
|
||||
if (isTextEditable(filename)) return 'Text' as FileType
|
||||
|
||||
return 'Binary' as FileType
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断文件是否可预览
|
||||
*/
|
||||
const isPreviewable = (filename: string): boolean => {
|
||||
if (!filename || typeof filename !== 'string') return false
|
||||
return isPreviewableType(filename)
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断文件是否可编辑
|
||||
*/
|
||||
const isEditable = (filename: string, fileSize: number): boolean => {
|
||||
if (fileSize > FILE_SIZE_THRESHOLDS.BIG_FILE) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (!filename || typeof filename !== 'string') return false
|
||||
const ext = getExt(filename)
|
||||
return FILE_EXTENSIONS.CODE.includes(ext) ||
|
||||
isTextEditable(filename) ||
|
||||
isConfigFile(filename) ||
|
||||
isHtmlFile(filename) ||
|
||||
isMarkdownFile(filename)
|
||||
}
|
||||
|
||||
/**
|
||||
* 图片加载完成
|
||||
*/
|
||||
const onImageLoad = (event: Event) => {
|
||||
const img = event.target as HTMLImageElement
|
||||
if (img.naturalWidth && img.naturalHeight) {
|
||||
currentImageDimensions.value = `${img.naturalWidth} × ${img.naturalHeight}`
|
||||
}
|
||||
imageLoading.value = false
|
||||
}
|
||||
|
||||
/**
|
||||
* 图片加载失败
|
||||
*/
|
||||
const onImageError = () => {
|
||||
imageLoading.value = false
|
||||
currentImageDimensions.value = ''
|
||||
}
|
||||
|
||||
/**
|
||||
* 开始加载图片
|
||||
*/
|
||||
const startImageLoad = () => {
|
||||
imageLoading.value = true
|
||||
currentImageDimensions.value = ''
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取媒体元数据
|
||||
*/
|
||||
const getMediaMetadata = async (url: string): Promise<FilePreviewMetadata> => {
|
||||
const metadata: FilePreviewMetadata = {}
|
||||
|
||||
// 对于图片,使用 Image 对象
|
||||
if (url.match(/\.(jpg|jpeg|png|gif|webp|svg)$/i)) {
|
||||
return new Promise((resolve) => {
|
||||
const img = new Image()
|
||||
img.onload = () => {
|
||||
metadata.width = img.naturalWidth
|
||||
metadata.height = img.naturalHeight
|
||||
resolve(metadata)
|
||||
}
|
||||
img.onerror = () => resolve(metadata)
|
||||
img.src = url
|
||||
})
|
||||
}
|
||||
|
||||
// 对于视频/音频,可以使用 Video/Audio 对象
|
||||
// 但由于跨域等问题,这里简化处理
|
||||
return metadata
|
||||
}
|
||||
|
||||
return {
|
||||
// 状态
|
||||
previewUrl,
|
||||
imageLoading,
|
||||
currentImageDimensions,
|
||||
|
||||
// URL 相关
|
||||
getPreviewUrl,
|
||||
updatePreviewUrl,
|
||||
|
||||
// 文件类型判断(同步,基于扩展名)
|
||||
getFileType,
|
||||
isPreviewable,
|
||||
isEditable,
|
||||
|
||||
// 内容检测(异步,基于文件内容)
|
||||
detectByContent,
|
||||
|
||||
// 事件处理
|
||||
onImageLoad,
|
||||
onImageError,
|
||||
startImageLoad,
|
||||
|
||||
// 工具方法
|
||||
getMediaMetadata
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,247 @@
|
||||
/**
|
||||
* 路径导航 Composable
|
||||
* 提供路径输入、历史记录、前进/后退等功能
|
||||
*/
|
||||
|
||||
import { ref, watch, computed } from 'vue'
|
||||
import { STORAGE_KEYS } from '@/utils/constants'
|
||||
import { normalizePathSeparators } from '@/utils/fileUtils'
|
||||
import type { PathHistory } from '@/types/file-system'
|
||||
|
||||
export interface UsePathNavigationOptions {
|
||||
onListDirectory?: (path: string) => Promise<void>
|
||||
initialPath?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 localStorage 恢复上次的路径
|
||||
*/
|
||||
const restoreLastPath = (): string | null => {
|
||||
try {
|
||||
const lastPath = localStorage.getItem(STORAGE_KEYS.FILESYSTEM.FILE_PATH)
|
||||
if (lastPath) {
|
||||
// 规范化旧路径(可能包含反斜杠)
|
||||
return normalizePathSeparators(lastPath)
|
||||
}
|
||||
return lastPath
|
||||
} catch (error) {
|
||||
console.error('恢复路径失败:', error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存路径到 localStorage
|
||||
*/
|
||||
const saveLastPath = (path: string) => {
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEYS.FILESYSTEM.FILE_PATH, path)
|
||||
} catch (error) {
|
||||
console.error('保存路径失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
export function usePathNavigation(options: UsePathNavigationOptions = {}) {
|
||||
const { onListDirectory, initialPath = '' } = options
|
||||
|
||||
// 尝试恢复上次的路径,如果没有则使用初始路径
|
||||
const savedPath = restoreLastPath()
|
||||
const filePath = ref(savedPath || initialPath)
|
||||
|
||||
// 历史记录
|
||||
const history = ref<PathHistory>({
|
||||
paths: [],
|
||||
currentIndex: -1
|
||||
})
|
||||
|
||||
/**
|
||||
* 导航到指定路径(带错误处理)
|
||||
*/
|
||||
const navigate = async (path: string) => {
|
||||
if (!path || path === filePath.value) return
|
||||
|
||||
try {
|
||||
// 路径规范化(处理反斜杠并统一为正斜杠)
|
||||
const normalizedPath = normalizePathSeparators(path)
|
||||
filePath.value = normalizedPath
|
||||
|
||||
// 添加到历史记录
|
||||
addToHistory(normalizedPath)
|
||||
|
||||
// 触发目录列出
|
||||
if (onListDirectory) {
|
||||
await onListDirectory(normalizedPath)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('导航失败:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 添加到历史记录
|
||||
*/
|
||||
const addToHistory = (path: string) => {
|
||||
const { paths, currentIndex } = history.value
|
||||
|
||||
// 如果当前不在历史记录末尾,删除当前位置之后的所有记录
|
||||
if (currentIndex < paths.length - 1) {
|
||||
history.value.paths = paths.slice(0, currentIndex + 1)
|
||||
}
|
||||
|
||||
// 避免重复添加相同路径
|
||||
const lastPath = history.value.paths[history.value.paths.length - 1]
|
||||
if (lastPath !== path) {
|
||||
history.value.paths.push(path)
|
||||
history.value.currentIndex = history.value.paths.length - 1
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 后退(带错误处理)
|
||||
*/
|
||||
const back = async () => {
|
||||
const { paths, currentIndex } = history.value
|
||||
|
||||
if (currentIndex <= 0) return
|
||||
|
||||
try {
|
||||
const newIndex = currentIndex - 1
|
||||
history.value.currentIndex = newIndex
|
||||
filePath.value = paths[newIndex]
|
||||
|
||||
if (onListDirectory) {
|
||||
await onListDirectory(paths[newIndex])
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('后退失败:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 前进(带错误处理)
|
||||
*/
|
||||
const forward = async () => {
|
||||
const { paths, currentIndex } = history.value
|
||||
|
||||
if (currentIndex >= paths.length - 1) return
|
||||
|
||||
try {
|
||||
const newIndex = currentIndex + 1
|
||||
history.value.currentIndex = newIndex
|
||||
filePath.value = paths[newIndex]
|
||||
|
||||
if (onListDirectory) {
|
||||
await onListDirectory(paths[newIndex])
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('前进失败:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 路径输入选择
|
||||
*/
|
||||
const onPathSelect = (value: string) => {
|
||||
navigate(value)
|
||||
}
|
||||
|
||||
/**
|
||||
* 路径输入回车
|
||||
*/
|
||||
const onPathEnter = (value: string) => {
|
||||
navigate(value)
|
||||
}
|
||||
|
||||
/**
|
||||
* 浏览目录(双击或回车)
|
||||
*/
|
||||
const browseDirectory = async (path: string) => {
|
||||
await navigate(path)
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取父目录路径
|
||||
*/
|
||||
const getParentPath = (path: string): string => {
|
||||
const separator = path.includes('\\') ? '\\' : '/'
|
||||
const lastSeparator = path.lastIndexOf(separator)
|
||||
return lastSeparator > 0 ? path.substring(0, lastSeparator) : path
|
||||
}
|
||||
|
||||
/**
|
||||
* 上级目录
|
||||
*/
|
||||
const goUp = async () => {
|
||||
const parentPath = getParentPath(filePath.value)
|
||||
if (parentPath !== filePath.value) {
|
||||
await navigate(parentPath)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 路径规范化(统一为正斜杠)
|
||||
*/
|
||||
const normalizePath = (path: string): string => {
|
||||
return normalizePathSeparators(path)
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断是否可以后退
|
||||
*/
|
||||
const canGoBack = computed(() => {
|
||||
return history.value.currentIndex > 0
|
||||
})
|
||||
|
||||
/**
|
||||
* 判断是否可以前进
|
||||
*/
|
||||
const canGoForward = computed(() => {
|
||||
return history.value.currentIndex < history.value.paths.length - 1
|
||||
})
|
||||
|
||||
/**
|
||||
* 获取历史记录列表(用于自动完成)
|
||||
*/
|
||||
const getPathHistory = computed(() => {
|
||||
return history.value.paths.slice().reverse() // 最新的在前
|
||||
})
|
||||
|
||||
// 监听路径变化,自动保存到 localStorage
|
||||
watch(filePath, (newPath) => {
|
||||
if (newPath) {
|
||||
saveLastPath(newPath)
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
// 状态
|
||||
filePath,
|
||||
history,
|
||||
|
||||
// 导航方法
|
||||
navigate,
|
||||
back,
|
||||
forward,
|
||||
goUp,
|
||||
browseDirectory,
|
||||
|
||||
// 事件处理
|
||||
onPathSelect,
|
||||
onPathEnter,
|
||||
|
||||
// 工具方法
|
||||
getParentPath,
|
||||
normalizePath,
|
||||
|
||||
// 计算属性
|
||||
canGoBack,
|
||||
canGoForward,
|
||||
getPathHistory
|
||||
}
|
||||
}
|
||||
|
||||
// 导出类型(用于外部使用)
|
||||
export type { PathHistory }
|
||||
1575
frontend/src/components/FileSystem/index.vue
Normal file
1575
frontend/src/components/FileSystem/index.vue
Normal file
File diff suppressed because it is too large
Load Diff
534
frontend/src/components/MarkdownEditor.vue
Normal file
534
frontend/src/components/MarkdownEditor.vue
Normal file
@@ -0,0 +1,534 @@
|
||||
<template>
|
||||
<div class="markdown-editor-container">
|
||||
<div class="editor-header">
|
||||
<div class="title">
|
||||
<icon-file />
|
||||
<span>Markdown 编辑器</span>
|
||||
<a-divider type="vertical" />
|
||||
<a-tooltip content="自动保存已启用">
|
||||
<span class="save-status" :class="{ 'saved': !hasChanges }">
|
||||
{{ hasChanges ? '未保存' : '已保存' }}
|
||||
</span>
|
||||
</a-tooltip>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<a-tooltip content="清空内容">
|
||||
<a-button size="small" type="outline" @click="clearContent">
|
||||
<icon-delete />
|
||||
</a-button>
|
||||
</a-tooltip>
|
||||
<a-tooltip content="全屏编辑">
|
||||
<a-button size="small" type="outline" @click="toggleFullscreen">
|
||||
<icon-expand />
|
||||
</a-button>
|
||||
</a-tooltip>
|
||||
<PdfExportButton @export-complete="onExportComplete" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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">
|
||||
<a-tooltip content="展开编辑器">
|
||||
<a-button size="small" type="text" @click="toggleEditorExpand">
|
||||
<icon-align-left v-if="!isEditorExpanded" />
|
||||
<icon-shrink v-else />
|
||||
</a-button>
|
||||
</a-tooltip>
|
||||
</div>
|
||||
</div>
|
||||
<div class="editor-wrapper">
|
||||
<textarea
|
||||
ref="textarea"
|
||||
v-model="markdownContent"
|
||||
class="markdown-textarea"
|
||||
placeholder="在这里输入 Markdown 内容...
|
||||
|
||||
# 标题
|
||||
## 二级标题
|
||||
**粗体** *斜体*
|
||||
|
||||
- 列表项 1
|
||||
- 列表项 2
|
||||
|
||||
\`\`\`javascript
|
||||
console.log('Hello, World!')
|
||||
\`\`\`
|
||||
|
||||
> 引用内容"
|
||||
@input="handleInput"
|
||||
@keydown="handleKeydown"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="resizer" @mousedown="handleResize"></div>
|
||||
|
||||
<div class="preview-panel" :class="{ 'expanded': isPreviewExpanded }" :style="{ width: (100 - editorWidthPercent) + '%' }">
|
||||
<div class="panel-header">
|
||||
<span>预览</span>
|
||||
<div class="panel-controls">
|
||||
<a-tooltip content="展开预览">
|
||||
<a-button size="small" type="text" @click="togglePreviewExpand">
|
||||
<icon-align-left v-if="!isPreviewExpanded" />
|
||||
<icon-shrink v-else />
|
||||
</a-button>
|
||||
</a-tooltip>
|
||||
<a-tooltip content="刷新预览">
|
||||
<a-button size="small" type="text" @click="renderPreview">
|
||||
<icon-sync />
|
||||
</a-button>
|
||||
</a-tooltip>
|
||||
</div>
|
||||
</div>
|
||||
<div ref="previewRef" class="preview-wrapper">
|
||||
<MarkdownPreview :content="markdownContent" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="editor-footer">
|
||||
<div class="status">
|
||||
<span>{{ wordCount }} 字符 | {{ lineCount }} 行 | {{ readingTime }} 分钟阅读</span>
|
||||
</div>
|
||||
<div class="shortcuts">
|
||||
<a-tooltip content="快捷键: Ctrl + S 保存">
|
||||
<a-button size="small" @click="saveContent" :disabled="!hasChanges">
|
||||
<icon-save />
|
||||
保存
|
||||
</a-button>
|
||||
</a-tooltip>
|
||||
<a-tooltip content="快捷键: Ctrl + / 切换预览">
|
||||
<a-button size="small" @click="togglePreview">
|
||||
<icon-eye />
|
||||
</a-button>
|
||||
</a-tooltip>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { ref, computed, onMounted, onUnmounted, nextTick, watch } from 'vue'
|
||||
import { Message, Modal } from '@arco-design/web-vue'
|
||||
import MarkdownPreview from './MarkdownPreview.vue'
|
||||
import PdfExportButton from './PdfExportButton.vue'
|
||||
import IconFile from '@arco-design/web-vue/es/icon/icon-file'
|
||||
import IconDelete from '@arco-design/web-vue/es/icon/icon-delete'
|
||||
import IconExpand from '@arco-design/web-vue/es/icon/icon-expand'
|
||||
import IconShrink from '@arco-design/web-vue/es/icon/icon-shrink'
|
||||
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',
|
||||
components: {
|
||||
MarkdownPreview,
|
||||
PdfExportButton,
|
||||
IconFile,
|
||||
IconDelete,
|
||||
IconExpand,
|
||||
IconShrink,
|
||||
IconSync,
|
||||
IconSave,
|
||||
IconEye,
|
||||
IconAlignLeft
|
||||
},
|
||||
emits: ['content-change', 'update:content', 'save'],
|
||||
props: {
|
||||
content: {
|
||||
type: String,
|
||||
default: ''
|
||||
}
|
||||
},
|
||||
setup(props, { emit }) {
|
||||
const markdownContent = ref(props.content)
|
||||
const textarea = ref(null)
|
||||
const hasChanges = ref(false)
|
||||
const lastSavedContent = ref('')
|
||||
const isFullscreen = ref(false)
|
||||
const isEditorExpanded = ref(false)
|
||||
const isPreviewExpanded = ref(false)
|
||||
const editorWidthPercent = ref(50)
|
||||
const editorContentRef = ref(null)
|
||||
const previewRef = ref<HTMLElement | null>(null)
|
||||
|
||||
// 计算属性
|
||||
const wordCount = computed(() => {
|
||||
return markdownContent.value.length
|
||||
})
|
||||
|
||||
const lineCount = computed(() => {
|
||||
return markdownContent.value.split('\n').length
|
||||
})
|
||||
|
||||
const readingTime = computed(() => {
|
||||
// 平均阅读速度:每分钟 200 字符
|
||||
const wordsPerMinute = 200
|
||||
const minutes = Math.ceil(wordCount.value / wordsPerMinute)
|
||||
return minutes
|
||||
})
|
||||
|
||||
// 方法
|
||||
const handleInput = () => {
|
||||
hasChanges.value = markdownContent.value !== lastSavedContent.value
|
||||
emit('content-change', markdownContent.value)
|
||||
emit('update:content', markdownContent.value)
|
||||
}
|
||||
|
||||
const handleKeydown = (event) => {
|
||||
// Ctrl + S 保存
|
||||
if ((event.ctrlKey || event.metaKey) && event.key === 's') {
|
||||
event.preventDefault()
|
||||
saveContent()
|
||||
}
|
||||
// Ctrl + / 切换预览
|
||||
if ((event.ctrlKey || event.metaKey) && event.key === '/') {
|
||||
event.preventDefault()
|
||||
togglePreview()
|
||||
}
|
||||
}
|
||||
|
||||
const saveContent = () => {
|
||||
lastSavedContent.value = markdownContent.value
|
||||
hasChanges.value = false
|
||||
emit('save', markdownContent.value)
|
||||
Message.success('内容已保存')
|
||||
// 保存到本地存储
|
||||
localStorage.setItem('u-desk-markdown-content', markdownContent.value)
|
||||
}
|
||||
|
||||
const onExportComplete = () => {
|
||||
Message.success('PDF 导出完成')
|
||||
}
|
||||
|
||||
// 自动调整 textarea 高度
|
||||
const adjustTextareaHeight = () => {
|
||||
if (textarea.value) {
|
||||
textarea.value.style.height = 'auto'
|
||||
textarea.value.style.height = textarea.value.scrollHeight + 'px'
|
||||
}
|
||||
}
|
||||
|
||||
// 分割拖拽调整宽度
|
||||
const handleResize = createResizeHandler(
|
||||
() => editorContentRef.value,
|
||||
() => editorWidthPercent.value,
|
||||
{
|
||||
direction: 'horizontal',
|
||||
minPercent: 15,
|
||||
maxPercent: 85,
|
||||
minPixels: 100,
|
||||
onResize: (percent) => { editorWidthPercent.value = percent },
|
||||
}
|
||||
)
|
||||
|
||||
// 切换功能
|
||||
const togglePreview = () => {
|
||||
// 预览面板始终显示,保留快捷键兼容性
|
||||
nextTick(() => {
|
||||
adjustTextareaHeight()
|
||||
})
|
||||
}
|
||||
|
||||
const toggleFullscreen = () => {
|
||||
isFullscreen.value = !isFullscreen.value
|
||||
if (isFullscreen.value) {
|
||||
document.body.style.overflow = 'hidden'
|
||||
} else {
|
||||
document.body.style.overflow = ''
|
||||
}
|
||||
}
|
||||
|
||||
const toggleEditorExpand = () => {
|
||||
isEditorExpanded.value = !isEditorExpanded.value
|
||||
if (isEditorExpanded.value && isPreviewExpanded.value) {
|
||||
isPreviewExpanded.value = false
|
||||
}
|
||||
nextTick(() => {
|
||||
adjustTextareaHeight()
|
||||
})
|
||||
}
|
||||
|
||||
const togglePreviewExpand = () => {
|
||||
isPreviewExpanded.value = !isPreviewExpanded.value
|
||||
if (isPreviewExpanded.value && isEditorExpanded.value) {
|
||||
isEditorExpanded.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const clearContent = () => {
|
||||
Modal.confirm({
|
||||
title: '确认清空',
|
||||
content: '确定要清空所有内容吗?此操作不可恢复。',
|
||||
okButtonProps: { status: 'danger' },
|
||||
onOk: () => {
|
||||
markdownContent.value = ''
|
||||
hasChanges.value = true
|
||||
lastSavedContent.value = ''
|
||||
emit('content-change', '')
|
||||
Message.success('内容已清空')
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const renderPreview = () => {
|
||||
// 强制重新渲染预览
|
||||
if (previewRef.value) {
|
||||
previewRef.value.style.opacity = '0'
|
||||
nextTick(() => {
|
||||
if (previewRef.value) previewRef.value.style.opacity = '1'
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 自动保存定时器
|
||||
let autoSaveTimer = null
|
||||
|
||||
// 监听内容变化:自动保存 + 调整高度
|
||||
watch(markdownContent, () => {
|
||||
// 自动保存
|
||||
if (hasChanges.value) {
|
||||
clearTimeout(autoSaveTimer)
|
||||
autoSaveTimer = setTimeout(() => {
|
||||
saveContent()
|
||||
}, 5000)
|
||||
}
|
||||
// 调整高度
|
||||
// computeRendered 是 computed ref,值变化即触发,无需 deep
|
||||
nextTick(() => {
|
||||
adjustTextareaHeight()
|
||||
})
|
||||
})
|
||||
|
||||
// 初始化
|
||||
onMounted(() => {
|
||||
nextTick(() => {
|
||||
adjustTextareaHeight()
|
||||
})
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (autoSaveTimer) {
|
||||
clearTimeout(autoSaveTimer)
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
markdownContent,
|
||||
textarea,
|
||||
hasChanges,
|
||||
wordCount,
|
||||
lineCount,
|
||||
readingTime,
|
||||
isFullscreen,
|
||||
isEditorExpanded,
|
||||
isPreviewExpanded,
|
||||
handleInput,
|
||||
handleKeydown,
|
||||
saveContent,
|
||||
onExportComplete,
|
||||
togglePreview,
|
||||
toggleFullscreen,
|
||||
toggleEditorExpand,
|
||||
togglePreviewExpand,
|
||||
clearContent,
|
||||
renderPreview
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.markdown-editor-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
background: var(--color-bg-2);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.markdown-editor-container.fullscreen {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
z-index: 1000;
|
||||
border-radius: 0;
|
||||
height: 100vh;
|
||||
}
|
||||
|
||||
.editor-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 12px 16px;
|
||||
background: var(--color-bg-1);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-1);
|
||||
}
|
||||
|
||||
.save-status {
|
||||
font-size: 12px;
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
background: var(--color-warning-light-1);
|
||||
color: var(--color-warning-6);
|
||||
}
|
||||
|
||||
.save-status.saved {
|
||||
background: var(--color-success-light-1);
|
||||
color: var(--color-success-6);
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.editor-content {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.editor-panel,
|
||||
.preview-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 100px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.editor-panel.expanded {
|
||||
flex: 2;
|
||||
}
|
||||
|
||||
.preview-panel.expanded {
|
||||
flex: 2;
|
||||
}
|
||||
|
||||
.editor-panel {
|
||||
border-right: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.panel-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 8px 16px;
|
||||
background: var(--color-fill-2);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: var(--color-text-2);
|
||||
}
|
||||
|
||||
.panel-controls {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.editor-wrapper {
|
||||
flex: 1;
|
||||
padding: 16px;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.preview-wrapper {
|
||||
flex: 1;
|
||||
padding: 16px;
|
||||
overflow: auto;
|
||||
background: var(--color-bg-1);
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
|
||||
.resizer {
|
||||
width: 4px;
|
||||
cursor: col-resize;
|
||||
background: var(--color-border);
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
.resizer:hover {
|
||||
background: var(--color-primary-light-3);
|
||||
}
|
||||
|
||||
.markdown-textarea {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-height: 200px;
|
||||
padding: 12px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 4px;
|
||||
font-family: 'Consolas', 'Monaco', 'Courier New', monospace;
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
resize: none;
|
||||
outline: none;
|
||||
background: var(--color-bg-1);
|
||||
color: var(--color-text-1);
|
||||
}
|
||||
|
||||
.markdown-textarea:focus {
|
||||
border-color: var(--color-primary-6);
|
||||
box-shadow: 0 0 0 2px rgba(22, 93, 255, 0.2);
|
||||
}
|
||||
|
||||
.markdown-textarea::placeholder {
|
||||
color: var(--color-text-3);
|
||||
}
|
||||
|
||||
.editor-footer {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 8px 16px;
|
||||
background: var(--color-bg-1);
|
||||
border-top: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.status {
|
||||
font-size: 12px;
|
||||
color: var(--color-text-2);
|
||||
}
|
||||
|
||||
.shortcuts {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
/* 响应式设计 */
|
||||
@media (max-width: 768px) {
|
||||
.editor-content {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.editor-panel {
|
||||
border-right: none;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.resizer {
|
||||
height: 4px;
|
||||
width: 100%;
|
||||
cursor: row-resize;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
45
frontend/src/components/MarkdownPreview.vue
Normal file
45
frontend/src/components/MarkdownPreview.vue
Normal file
@@ -0,0 +1,45 @@
|
||||
<template>
|
||||
<div class="md-preview">
|
||||
<div v-html="renderedMarkdown" class="markdown-body"></div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { marked } from '@/utils/markedExtensions'
|
||||
|
||||
function sanitizeHtml(html) {
|
||||
return html
|
||||
.replace(/<script[\s\S]*?<\/script>/gi, '')
|
||||
.replace(/<script[^>]*>/gi, '')
|
||||
.replace(/\son\w+\s*=\s*("[^"]*"|'[^']*'|[^\s>]*)/gi, '')
|
||||
.replace(/javascript\s*:/gi, 'blocked:')
|
||||
.replace(/<iframe[\s\S]*?<\/iframe>/gi, '')
|
||||
.replace(/<object[\s\S]*?<\/object>/gi, '')
|
||||
.replace(/<embed[^>]*>/gi, '')
|
||||
.replace(/<form[\s\S]*?<\/form>/gi, '')
|
||||
}
|
||||
|
||||
export default {
|
||||
name: 'MarkdownPreview',
|
||||
props: {
|
||||
content: {
|
||||
type: String,
|
||||
default: ''
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
renderedMarkdown() {
|
||||
return sanitizeHtml(marked(this.content))
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.md-preview {
|
||||
padding: 20px;
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
min-height: 200px;
|
||||
}
|
||||
</style>
|
||||
262
frontend/src/components/PdfExportButton.vue
Normal file
262
frontend/src/components/PdfExportButton.vue
Normal file
@@ -0,0 +1,262 @@
|
||||
<template>
|
||||
<a-tooltip content="导出" position="bottom">
|
||||
<a-button
|
||||
size="small"
|
||||
type="outline"
|
||||
@click="exportPDF"
|
||||
:loading="exporting"
|
||||
>
|
||||
<template #icon>
|
||||
<icon-file-pdf />
|
||||
</template>
|
||||
</a-button>
|
||||
</a-tooltip>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { ref } from 'vue'
|
||||
import { Message } from '@arco-design/web-vue'
|
||||
|
||||
export default {
|
||||
name: 'PdfExportButton',
|
||||
emits: ['export-start', 'export-complete'],
|
||||
props: {
|
||||
title: {
|
||||
type: String,
|
||||
default: '文档'
|
||||
},
|
||||
containerSelector: {
|
||||
type: String,
|
||||
default: '.markdown-body'
|
||||
}
|
||||
},
|
||||
setup(props, { emit }) {
|
||||
const exporting = ref(false)
|
||||
|
||||
function escapeHtml(str) {
|
||||
const map = { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }
|
||||
return str.replace(/[&<>"']/g, c => map[c])
|
||||
}
|
||||
|
||||
function stripScripts(html) {
|
||||
return html
|
||||
.replace(/<script[\s\S]*?<\/script>/gi, '')
|
||||
.replace(/<script[^>]*>/gi, '')
|
||||
.replace(/\son\w+\s*=\s*("[^"]*"|'[^']*'|[^\s>]*)/gi, '')
|
||||
.replace(/<iframe[\s\S]*?<\/iframe>/gi, '')
|
||||
.replace(/<object[\s\S]*?<\/object>/gi, '')
|
||||
.replace(/<embed[^>]*>/gi, '')
|
||||
}
|
||||
|
||||
const exportPDF = async () => {
|
||||
if (exporting.value) return
|
||||
|
||||
exporting.value = true
|
||||
emit('export-start')
|
||||
|
||||
try {
|
||||
// 获取渲染后的 Markdown 内容
|
||||
const contentElement = document.querySelector(props.containerSelector)
|
||||
|
||||
if (!contentElement) {
|
||||
Message.error('没有可导出的内容')
|
||||
exporting.value = false
|
||||
return
|
||||
}
|
||||
|
||||
const htmlContent = stripScripts(contentElement.innerHTML)
|
||||
|
||||
if (!htmlContent || !htmlContent.trim()) {
|
||||
Message.error('内容为空,无法导出')
|
||||
exporting.value = false
|
||||
return
|
||||
}
|
||||
|
||||
// 打开打印窗口
|
||||
const printWindow = window.open('', '_blank', 'width=800,height=600')
|
||||
|
||||
if (!printWindow) {
|
||||
Message.error('无法打开打印窗口,请检查浏览器弹窗设置')
|
||||
exporting.value = false
|
||||
return
|
||||
}
|
||||
|
||||
// 写入打印内容
|
||||
printWindow.document.write(`
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>${escapeHtml(props.title)}</title>
|
||||
<style>
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif;
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
color: #333;
|
||||
padding: 40px;
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
h1, h2, h3, h4, h5, h6 {
|
||||
margin-top: 24px;
|
||||
margin-bottom: 16px;
|
||||
font-weight: 600;
|
||||
line-height: 1.25;
|
||||
page-break-after: avoid;
|
||||
}
|
||||
|
||||
h1 { font-size: 2em; border-bottom: 1px solid #eaecef; padding-bottom: 0.3em; }
|
||||
h2 { font-size: 1.5em; border-bottom: 1px solid #eaecef; padding-bottom: 0.3em; }
|
||||
h3 { font-size: 1.25em; }
|
||||
h4 { font-size: 1em; }
|
||||
h5 { font-size: 0.875em; }
|
||||
h6 { font-size: 0.85em; color: #6a737d; }
|
||||
|
||||
p {
|
||||
margin-top: 0;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
ul, ol {
|
||||
padding-left: 2em;
|
||||
margin-top: 0;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
li {
|
||||
margin-top: 0.25em;
|
||||
margin-bottom: 0.25em;
|
||||
}
|
||||
|
||||
blockquote {
|
||||
padding: 0 1em;
|
||||
color: #6a737d;
|
||||
border-left: 0.25em solid #dfe2e5;
|
||||
margin: 0 0 16px 0;
|
||||
}
|
||||
|
||||
code {
|
||||
padding: 0.2em 0.4em;
|
||||
margin: 0;
|
||||
font-size: 85%;
|
||||
background-color: rgba(27, 31, 35, 0.05);
|
||||
border-radius: 3px;
|
||||
font-family: 'SF Mono', Monaco, Consolas, 'Courier New', monospace;
|
||||
}
|
||||
|
||||
pre {
|
||||
padding: 16px;
|
||||
overflow: auto;
|
||||
font-size: 85%;
|
||||
line-height: 1.45;
|
||||
background-color: #f6f8fa;
|
||||
border-radius: 6px;
|
||||
margin-top: 0;
|
||||
margin-bottom: 16px;
|
||||
page-break-inside: avoid;
|
||||
}
|
||||
|
||||
pre code {
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
background-color: transparent;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
table {
|
||||
border-spacing: 0;
|
||||
border-collapse: collapse;
|
||||
margin-top: 0;
|
||||
margin-bottom: 16px;
|
||||
width: 100%;
|
||||
page-break-inside: avoid;
|
||||
}
|
||||
|
||||
table th,
|
||||
table td {
|
||||
padding: 6px 13px;
|
||||
border: 1px solid #dfe2e5;
|
||||
}
|
||||
|
||||
table th {
|
||||
font-weight: 600;
|
||||
background-color: #f6f8fa;
|
||||
}
|
||||
|
||||
table tr:nth-child(even) {
|
||||
background-color: #f8f8f8;
|
||||
}
|
||||
|
||||
img {
|
||||
max-width: 100%;
|
||||
page-break-inside: avoid;
|
||||
}
|
||||
|
||||
hr {
|
||||
height: 0.25em;
|
||||
padding: 0;
|
||||
margin: 24px 0;
|
||||
background-color: #e1e4e8;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
@media print {
|
||||
body {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
@page {
|
||||
margin: 15mm;
|
||||
size: A4;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
${htmlContent}
|
||||
</body>
|
||||
</html>
|
||||
`)
|
||||
|
||||
printWindow.document.close()
|
||||
|
||||
// 等待内容加载完成后自动打印
|
||||
let printTriggered = false
|
||||
printWindow.onload = () => {
|
||||
printTriggered = true
|
||||
printWindow.print()
|
||||
}
|
||||
|
||||
// 兼容性处理:如果 onload 未触发
|
||||
setTimeout(() => {
|
||||
if (!printTriggered && printWindow && !printWindow.closed) {
|
||||
printWindow.print()
|
||||
}
|
||||
}, 500)
|
||||
|
||||
Message.success('请在打印对话框中选择"另存为 PDF"')
|
||||
emit('export-complete')
|
||||
|
||||
} catch (error) {
|
||||
console.error('PDF导出失败:', error)
|
||||
Message.error(`PDF导出失败:${error.message || '未知错误'}`)
|
||||
} finally {
|
||||
exporting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
exporting,
|
||||
exportPDF
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
374
frontend/src/components/SettingsPanel.vue
Normal file
374
frontend/src/components/SettingsPanel.vue
Normal file
@@ -0,0 +1,374 @@
|
||||
<template>
|
||||
<a-drawer
|
||||
v-model:visible="visible"
|
||||
title="设置"
|
||||
width="600px"
|
||||
:footer="false"
|
||||
unmount-on-close
|
||||
>
|
||||
<a-tabs default-active-key="tab-config">
|
||||
<!-- Tab 配置 -->
|
||||
<a-tab-pane key="tab-config" title="Tab 配置">
|
||||
<a-space direction="vertical" style="width: 100%" :size="16">
|
||||
|
||||
<!-- 说明文字 -->
|
||||
<a-alert type="info" :show-icon="true">
|
||||
拖拽可调整 Tab 顺序,勾选复选框控制显示,单选按钮设置默认打开的 Tab
|
||||
</a-alert>
|
||||
|
||||
<!-- 统一的 Tab 配置列表 -->
|
||||
<div class="tab-config-list">
|
||||
<div
|
||||
v-for="(tabKey, index) in localConfig.visibleTabs"
|
||||
:key="tabKey"
|
||||
class="tab-config-item"
|
||||
draggable="true"
|
||||
@dragstart="handleDragStart(index, $event)"
|
||||
@dragover.prevent="handleDragOver(index, $event)"
|
||||
@drop="handleDrop(index, $event)"
|
||||
@dragend="handleDragEnd"
|
||||
>
|
||||
<!-- 拖拽手柄 -->
|
||||
<icon-drag-arrow class="drag-handle" />
|
||||
|
||||
<!-- 显示/隐藏复选框 -->
|
||||
<a-checkbox
|
||||
:model-value="isTabVisible(tabKey)"
|
||||
:disabled="!isTabEnabled(tabKey) || isLastVisibleTab(tabKey)"
|
||||
@change="(value) => handleTabVisibilityChange(tabKey, value)"
|
||||
/>
|
||||
|
||||
<!-- Tab 标题 -->
|
||||
<span class="tab-title">{{ getTabTitle(tabKey) }}</span>
|
||||
|
||||
<!-- 默认 Tab 单选按钮 -->
|
||||
<a-radio
|
||||
:model-value="localConfig.defaultTab"
|
||||
:value="tabKey"
|
||||
@change="() => localConfig.defaultTab = tabKey"
|
||||
>
|
||||
默认
|
||||
</a-radio>
|
||||
</div>
|
||||
|
||||
<!-- 隐藏的 Tabs -->
|
||||
<a-divider v-if="hasHiddenTabs">隐藏的 Tabs</a-divider>
|
||||
<div
|
||||
v-for="tab in hiddenTabs"
|
||||
:key="'hidden-' + tab.key"
|
||||
class="tab-config-item hidden"
|
||||
>
|
||||
<icon-drag-arrow class="drag-handle disabled" />
|
||||
<a-checkbox
|
||||
:model-value="false"
|
||||
:disabled="!tab.enabled"
|
||||
@change="(value) => handleTabVisibilityChange(tab.key, value)"
|
||||
/>
|
||||
<span class="tab-title">{{ tab.title }}</span>
|
||||
<span class="hidden-tag">已隐藏</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<a-alert
|
||||
v-if="localConfig.visibleTabs.length === 0"
|
||||
type="warning"
|
||||
>
|
||||
至少需要保留一个可见的 Tab
|
||||
</a-alert>
|
||||
|
||||
<!-- 保存按钮 -->
|
||||
<a-space>
|
||||
<a-button type="primary" @click="handleSave" :loading="saving">
|
||||
<template #icon>
|
||||
<icon-check />
|
||||
</template>
|
||||
保存配置
|
||||
</a-button>
|
||||
<a-button @click="handleReset">
|
||||
<template #icon>
|
||||
<icon-refresh />
|
||||
</template>
|
||||
重置
|
||||
</a-button>
|
||||
</a-space>
|
||||
|
||||
</a-space>
|
||||
</a-tab-pane>
|
||||
|
||||
<!-- 版本更新 -->
|
||||
<a-tab-pane key="update" title="版本更新">
|
||||
<UpdatePanel @open-version-history="handleOpenVersionHistory" />
|
||||
</a-tab-pane>
|
||||
</a-tabs>
|
||||
</a-drawer>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, watch, computed } from 'vue'
|
||||
import { Message } from '@arco-design/web-vue'
|
||||
import { IconDragArrow, IconCheck, IconRefresh } from '@arco-design/web-vue/es/icon'
|
||||
import UpdatePanel from './UpdatePanel.vue'
|
||||
|
||||
// Props
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
config: {
|
||||
type: Object,
|
||||
required: true
|
||||
}
|
||||
})
|
||||
|
||||
// Emits
|
||||
const emit = defineEmits(['update:modelValue', 'save', 'open-version-history'])
|
||||
|
||||
// 状态
|
||||
const visible = computed({
|
||||
get: () => props.modelValue,
|
||||
set: (val) => emit('update:modelValue', val)
|
||||
})
|
||||
|
||||
const localConfig = ref({
|
||||
tabs: [],
|
||||
visibleTabs: [],
|
||||
defaultTab: ''
|
||||
})
|
||||
|
||||
const saving = ref(false)
|
||||
const draggedIndex = ref(null)
|
||||
|
||||
// 隐藏的 Tabs(不在 visibleTabs 中)
|
||||
const hiddenTabs = computed(() => {
|
||||
return localConfig.value.tabs.filter(tab => !localConfig.value.visibleTabs.includes(tab.key))
|
||||
})
|
||||
|
||||
// 是否有隐藏的 Tabs
|
||||
const hasHiddenTabs = computed(() => {
|
||||
return hiddenTabs.value.length > 0
|
||||
})
|
||||
|
||||
// 初始化本地配置
|
||||
watch(() => props.config, (newConfig) => {
|
||||
if (newConfig && newConfig.tabs) {
|
||||
localConfig.value = {
|
||||
tabs: [...newConfig.tabs],
|
||||
visibleTabs: [...newConfig.visibleTabs],
|
||||
defaultTab: newConfig.defaultTab
|
||||
}
|
||||
}
|
||||
}, { immediate: true, deep: true })
|
||||
|
||||
// 获取 Tab 标题
|
||||
const getTabTitle = (key) => {
|
||||
const tab = localConfig.value.tabs.find(t => t.key === key)
|
||||
return tab ? tab.title : key
|
||||
}
|
||||
|
||||
// 判断 Tab 是否可见
|
||||
const isTabVisible = (key) => {
|
||||
return localConfig.value.visibleTabs.includes(key)
|
||||
}
|
||||
|
||||
// 判断 Tab 是否启用
|
||||
const isTabEnabled = (key) => {
|
||||
const tab = localConfig.value.tabs.find(t => t.key === key)
|
||||
return tab ? tab.enabled : false
|
||||
}
|
||||
|
||||
// 判断是否是最后一个可见 Tab
|
||||
const isLastVisibleTab = (key) => {
|
||||
return localConfig.value.visibleTabs.length === 1 && localConfig.value.visibleTabs[0] === key
|
||||
}
|
||||
|
||||
// 处理单个 Tab 可见性变化
|
||||
const handleTabVisibilityChange = (tabKey, visible) => {
|
||||
if (visible) {
|
||||
// 显示 Tab:添加到 visibleTabs 末尾
|
||||
if (!localConfig.value.visibleTabs.includes(tabKey)) {
|
||||
localConfig.value.visibleTabs.push(tabKey)
|
||||
}
|
||||
} else {
|
||||
// 隐藏 Tab:从 visibleTabs 中移除
|
||||
// 至少保留一个 Tab
|
||||
if (localConfig.value.visibleTabs.length <= 1) {
|
||||
Message.warning('至少需要保留一个可见的 Tab')
|
||||
return
|
||||
}
|
||||
|
||||
// 如果隐藏的是默认 Tab,需要更改默认 Tab
|
||||
if (localConfig.value.defaultTab === tabKey) {
|
||||
const remainingTabs = localConfig.value.visibleTabs.filter(k => k !== tabKey)
|
||||
localConfig.value.defaultTab = remainingTabs[0] || ''
|
||||
}
|
||||
|
||||
localConfig.value.visibleTabs = localConfig.value.visibleTabs.filter(k => k !== tabKey)
|
||||
}
|
||||
|
||||
// 同步更新 tabs 数组中的 visible 属性
|
||||
localConfig.value.tabs = localConfig.value.tabs.map(tab => ({
|
||||
...tab,
|
||||
visible: localConfig.value.visibleTabs.includes(tab.key)
|
||||
}))
|
||||
}
|
||||
|
||||
// 拖拽开始
|
||||
const handleDragStart = (index, event) => {
|
||||
draggedIndex.value = index
|
||||
event.dataTransfer.effectAllowed = 'move'
|
||||
event.target.classList.add('dragging')
|
||||
}
|
||||
|
||||
// 拖拽经过
|
||||
const handleDragOver = (index, event) => {
|
||||
event.preventDefault()
|
||||
event.dataTransfer.dropEffect = 'move'
|
||||
}
|
||||
|
||||
// 放置
|
||||
const handleDrop = (index, event) => {
|
||||
event.preventDefault()
|
||||
if (draggedIndex.value === null || draggedIndex.value === index) return
|
||||
|
||||
const list = [...localConfig.value.visibleTabs]
|
||||
const [removed] = list.splice(draggedIndex.value, 1)
|
||||
list.splice(index, 0, removed)
|
||||
localConfig.value.visibleTabs = list
|
||||
}
|
||||
|
||||
// 拖拽结束
|
||||
const handleDragEnd = (event) => {
|
||||
event.target.classList.remove('dragging')
|
||||
draggedIndex.value = null
|
||||
}
|
||||
|
||||
// 保存配置
|
||||
const handleSave = async () => {
|
||||
// 验证:至少保留一个可见 Tab
|
||||
if (localConfig.value.visibleTabs.length < 1) {
|
||||
Message.error('至少需要保留一个可见的 Tab')
|
||||
return
|
||||
}
|
||||
|
||||
// 验证:默认 Tab 必须在可见列表中
|
||||
if (!localConfig.value.visibleTabs.includes(localConfig.value.defaultTab)) {
|
||||
Message.error('默认 Tab 必须在可见列表中')
|
||||
return
|
||||
}
|
||||
|
||||
// 确保 tabs 数组中的 visible 属性与 visibleTabs 完全同步
|
||||
const syncedTabs = localConfig.value.tabs.map(tab => ({
|
||||
...tab,
|
||||
visible: localConfig.value.visibleTabs.includes(tab.key)
|
||||
}))
|
||||
|
||||
const configToSave = {
|
||||
tabs: syncedTabs,
|
||||
visibleTabs: [...localConfig.value.visibleTabs],
|
||||
defaultTab: localConfig.value.defaultTab
|
||||
}
|
||||
|
||||
saving.value = true
|
||||
|
||||
try {
|
||||
await emit('save', configToSave)
|
||||
} catch (error) {
|
||||
console.error('保存配置失败:', error)
|
||||
Message.error('保存配置失败:' + (error.message || error))
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 重置配置
|
||||
const handleReset = () => {
|
||||
if (props.config) {
|
||||
localConfig.value = {
|
||||
tabs: [...props.config.tabs],
|
||||
visibleTabs: [...props.config.visibleTabs],
|
||||
defaultTab: props.config.defaultTab
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 打开版本历史
|
||||
const handleOpenVersionHistory = () => {
|
||||
emit('open-version-history')
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.tab-config-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.tab-config-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 12px 16px;
|
||||
background: var(--color-fill-1);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 6px;
|
||||
cursor: move;
|
||||
transition: all 0.2s;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.tab-config-item.dragging {
|
||||
opacity: 0.5;
|
||||
background: var(--color-fill-2);
|
||||
transform: scale(0.98);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.tab-config-item:hover {
|
||||
background: var(--color-fill-2);
|
||||
border-color: var(--color-border-2);
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.tab-config-item.hidden {
|
||||
opacity: 0.6;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.tab-config-item.hidden:hover {
|
||||
border-color: var(--color-border);
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.drag-handle {
|
||||
color: var(--color-text-3);
|
||||
cursor: grab;
|
||||
flex-shrink: 0;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.drag-handle.disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.3;
|
||||
}
|
||||
|
||||
.tab-config-item:hover .drag-handle:not(.disabled) {
|
||||
color: var(--color-text-1);
|
||||
}
|
||||
|
||||
.tab-title {
|
||||
flex: 1;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: var(--color-text-1);
|
||||
}
|
||||
|
||||
.hidden-tag {
|
||||
font-size: 12px;
|
||||
color: var(--color-text-3);
|
||||
padding: 2px 8px;
|
||||
background: var(--color-fill-3);
|
||||
border-radius: 4px;
|
||||
}
|
||||
</style>
|
||||
45
frontend/src/components/ThemeToggle.vue
Normal file
45
frontend/src/components/ThemeToggle.vue
Normal file
@@ -0,0 +1,45 @@
|
||||
<template>
|
||||
<a-tooltip :content="themeStore.tooltipText" position="bottom">
|
||||
<div
|
||||
class="theme-toggle-btn"
|
||||
@click="handleToggle"
|
||||
>
|
||||
{{ themeStore.isDark ? '🌙' : '☀️' }}
|
||||
</div>
|
||||
</a-tooltip>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { useThemeStore } from '../stores/theme'
|
||||
|
||||
const themeStore = useThemeStore()
|
||||
|
||||
const handleToggle = () => {
|
||||
themeStore.toggleTheme()
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.theme-toggle-btn {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
font-size: 16px;
|
||||
cursor: pointer;
|
||||
border-radius: var(--border-radius-small, 2px);
|
||||
user-select: none;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
box-sizing: border-box;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
.theme-toggle-btn:hover {
|
||||
background: var(--color-bg-2);
|
||||
}
|
||||
|
||||
.theme-toggle-btn:active {
|
||||
background: var(--color-bg-3);
|
||||
}
|
||||
</style>
|
||||
324
frontend/src/components/UpdateNotification.vue
Normal file
324
frontend/src/components/UpdateNotification.vue
Normal file
@@ -0,0 +1,324 @@
|
||||
<template>
|
||||
<!-- 升级提示弹窗 -->
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, watch, onMounted, onUnmounted, h, nextTick } from 'vue'
|
||||
import { Modal, Message, Progress } from '@arco-design/web-vue'
|
||||
import { useUpdateStore } from '../stores/update'
|
||||
import { marked } from '../utils/markedExtensions'
|
||||
import { sanitizeHtml } from '@/utils/fileUtils'
|
||||
import { DownloadUpdate } from '../wailsjs/v3-bindings/u-desk/app'
|
||||
import { On, Off } from '@wailsio/events'
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
updateInfo: {
|
||||
type: Object,
|
||||
default: () => ({})
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:modelValue'])
|
||||
|
||||
// 使用更新管理 store
|
||||
const updateStore = useUpdateStore()
|
||||
|
||||
// 模态框实例
|
||||
let confirmModalInstance = null
|
||||
let progressModalInstance = null
|
||||
|
||||
// Computed
|
||||
const currentVersion = computed(() => props.updateInfo?.current_version || '0.1.0')
|
||||
const latestVersion = computed(() => props.updateInfo?.latest_version || '')
|
||||
const changelog = computed(() => props.updateInfo?.changelog || '')
|
||||
const forceUpdate = computed(() => props.updateInfo?.force_update || false)
|
||||
|
||||
// Watch
|
||||
watch(() => props.modelValue, (val) => {
|
||||
if (val) {
|
||||
showUpdateModal()
|
||||
} else {
|
||||
closeModals()
|
||||
}
|
||||
})
|
||||
|
||||
// Utility functions
|
||||
const formatDate = (dateStr) => {
|
||||
if (!dateStr) return ''
|
||||
try {
|
||||
const date = new Date(dateStr)
|
||||
if (!isNaN(date.getTime())) {
|
||||
return date.toLocaleDateString('zh-CN', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit'
|
||||
}).replace(/\//g, '-')
|
||||
}
|
||||
} catch {}
|
||||
return dateStr
|
||||
}
|
||||
|
||||
// 显示更新确认弹窗
|
||||
const showUpdateModal = () => {
|
||||
if (confirmModalInstance) return
|
||||
|
||||
confirmModalInstance = Modal.confirm({
|
||||
title: forceUpdate.value ? '重要更新' : '发现新版本',
|
||||
content: () => {
|
||||
const elements = [
|
||||
h('div', { style: { marginBottom: '8px' } }, [
|
||||
h('span', { style: { fontSize: '13px', color: 'var(--color-text-2)' } }, '版本:'),
|
||||
h('span', { style: { fontSize: '14px', color: 'var(--color-text-1)', marginLeft: '8px' } }, currentVersion.value),
|
||||
h('span', { style: { fontSize: '14px', color: 'var(--color-text-2)', marginLeft: '12px', marginRight: '12px' } }, '→'),
|
||||
h('span', { style: { fontSize: '16px', color: 'rgb(var(--primary-6))', fontWeight: '500' } }, latestVersion.value)
|
||||
])
|
||||
]
|
||||
|
||||
// 更新日志
|
||||
if (changelog.value) {
|
||||
const changelogHtml = (() => { try { return sanitizeHtml(String(marked.parse(changelog.value))) } catch { return changelog.value } })()
|
||||
elements.push(
|
||||
h('div', { style: { marginBottom: '8px' } }, [
|
||||
h('div', { style: { fontSize: '12px', color: 'var(--color-text-2)', marginBottom: '4px' } }, '更新内容:'),
|
||||
h('div', {
|
||||
style: {
|
||||
fontSize: '12px',
|
||||
color: 'var(--color-text-2)',
|
||||
lineHeight: '1.6',
|
||||
padding: '10px 12px',
|
||||
background: 'var(--color-fill-1)',
|
||||
borderRadius: '4px',
|
||||
maxHeight: '240px',
|
||||
overflowY: 'auto'
|
||||
},
|
||||
innerHTML: changelogHtml
|
||||
})
|
||||
])
|
||||
)
|
||||
}
|
||||
|
||||
// 发布日期和文件大小
|
||||
const metadata = []
|
||||
if (props.updateInfo?.release_date) {
|
||||
metadata.push(formatDate(props.updateInfo.release_date))
|
||||
}
|
||||
if (props.updateInfo?.file_size) {
|
||||
metadata.push(updateStore.formatFileSize(props.updateInfo.file_size))
|
||||
}
|
||||
if (metadata.length > 0) {
|
||||
elements.push(
|
||||
h('div', { style: { marginBottom: '4px', fontSize: '12px', color: 'var(--color-text-3)' } }, metadata.join(' · '))
|
||||
)
|
||||
}
|
||||
|
||||
// 强制更新提示
|
||||
if (forceUpdate.value) {
|
||||
elements.push(
|
||||
h('div', {
|
||||
style: {
|
||||
marginTop: '12px',
|
||||
padding: '12px',
|
||||
background: 'var(--color-danger-light-1)',
|
||||
border: '1px solid var(--color-danger-light-3)',
|
||||
borderRadius: '4px',
|
||||
color: 'rgb(var(--danger-6))',
|
||||
fontSize: '13px'
|
||||
}
|
||||
}, '⚠️ 此版本包含重要的安全更新和问题修复,为保障正常使用,请完成更新后再继续。')
|
||||
)
|
||||
}
|
||||
|
||||
return elements
|
||||
},
|
||||
okText: '立即更新',
|
||||
cancelText: '稍后提醒',
|
||||
closable: !forceUpdate.value,
|
||||
maskClosable: !forceUpdate.value,
|
||||
onOk: async () => {
|
||||
confirmModalInstance = null
|
||||
await handleDownload()
|
||||
},
|
||||
onCancel: () => {
|
||||
confirmModalInstance = null
|
||||
emit('update:modelValue', false)
|
||||
},
|
||||
onBeforeCancel: () => {
|
||||
if (forceUpdate.value) {
|
||||
Message.warning('此版本为强制更新,无法跳过')
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 生成进度弹窗内容
|
||||
const getProgressModalContent = () => {
|
||||
// 下载中状态
|
||||
if (updateStore.downloading) {
|
||||
const progressValue = Math.min(100, Math.max(0, updateStore.downloadProgress || 0))
|
||||
const finalProgress = progressValue / 100
|
||||
|
||||
const { downloaded, total, speed } = updateStore.progressInfo
|
||||
const sizeText = total > 0
|
||||
? `${updateStore.formatFileSize(downloaded)} / ${updateStore.formatFileSize(total)}`
|
||||
: updateStore.downloadProgress > 0 ? '计算文件大小...' : '准备下载...'
|
||||
|
||||
const speedElement = speed > 0
|
||||
? h('div', { style: { fontSize: '12px', color: 'var(--color-text-3)', marginTop: '4px' } },
|
||||
`下载速度: ${updateStore.formatSpeed(speed)}`
|
||||
)
|
||||
: null
|
||||
|
||||
return [
|
||||
h('div', { style: { marginBottom: '16px' } }, [
|
||||
h('div', { style: { marginBottom: '8px', fontSize: '14px', color: 'var(--color-text-2)' } }, '正在下载更新包...')
|
||||
]),
|
||||
h('div', { style: { marginBottom: '8px' } }, [
|
||||
h(Progress, { percent: finalProgress, showText: true })
|
||||
]),
|
||||
h('div', { style: { fontSize: '12px', color: 'var(--color-text-3)', marginTop: '8px' } }, sizeText),
|
||||
speedElement
|
||||
]
|
||||
}
|
||||
|
||||
// 安装中状态
|
||||
if (updateStore.installing) {
|
||||
return [
|
||||
h('div', { style: { marginBottom: '16px' } }, [
|
||||
h('div', { style: { marginBottom: '8px', fontSize: '14px', color: 'var(--color-text-2)' } }, '正在安装更新...')
|
||||
]),
|
||||
h('div', { style: { fontSize: '12px', color: 'var(--color-text-3)' } }, '请稍候,应用将在安装完成后自动重启...')
|
||||
]
|
||||
}
|
||||
|
||||
// 完成状态
|
||||
return [
|
||||
h('div', { style: { marginBottom: '16px', textAlign: 'center' } }, [
|
||||
h('div', { style: { fontSize: '16px', color: 'rgb(var(--success-6))', marginBottom: '8px' } }, '✓ 更新完成')
|
||||
]),
|
||||
h('div', { style: { fontSize: '14px', color: 'var(--color-text-2)' } }, '应用将在几秒后自动重启...')
|
||||
]
|
||||
}
|
||||
|
||||
// 更新进度弹窗内容
|
||||
const updateProgressModal = async () => {
|
||||
if (!progressModalInstance) return
|
||||
await nextTick()
|
||||
progressModalInstance.update({
|
||||
content: () => getProgressModalContent()
|
||||
})
|
||||
}
|
||||
|
||||
// 显示进度弹窗
|
||||
const showProgressModal = async () => {
|
||||
if (progressModalInstance) {
|
||||
progressModalInstance.close()
|
||||
progressModalInstance = null
|
||||
}
|
||||
|
||||
progressModalInstance = Modal.info({
|
||||
title: '更新进度',
|
||||
content: () => getProgressModalContent(),
|
||||
closable: false,
|
||||
maskClosable: false,
|
||||
footer: false
|
||||
})
|
||||
|
||||
await nextTick()
|
||||
|
||||
// 监听 store 状态变化
|
||||
const stopWatcher = watch(
|
||||
[
|
||||
() => updateStore.downloadProgress,
|
||||
() => updateStore.downloading,
|
||||
() => updateStore.installing,
|
||||
() => updateStore.progressInfo.total,
|
||||
() => updateStore.progressInfo.downloaded,
|
||||
() => updateStore.progressInfo.speed
|
||||
],
|
||||
async () => {
|
||||
await nextTick(updateProgressModal)
|
||||
},
|
||||
{ immediate: true, flush: 'post' }
|
||||
)
|
||||
|
||||
if (progressModalInstance) {
|
||||
progressModalInstance._stopWatcher = stopWatcher
|
||||
}
|
||||
}
|
||||
|
||||
// 关闭进度弹窗
|
||||
const closeProgressModal = () => {
|
||||
if (progressModalInstance) {
|
||||
if (progressModalInstance._stopWatcher) {
|
||||
progressModalInstance._stopWatcher()
|
||||
delete progressModalInstance._stopWatcher
|
||||
}
|
||||
progressModalInstance.close()
|
||||
progressModalInstance = null
|
||||
}
|
||||
}
|
||||
|
||||
// 关闭所有弹窗
|
||||
const closeModals = () => {
|
||||
if (confirmModalInstance) {
|
||||
confirmModalInstance.close()
|
||||
confirmModalInstance = null
|
||||
}
|
||||
closeProgressModal()
|
||||
}
|
||||
|
||||
// 下载更新
|
||||
const handleDownload = async () => {
|
||||
await showProgressModal()
|
||||
|
||||
try {
|
||||
const result = await DownloadUpdate(props.updateInfo.download_url)
|
||||
if (result.success) return
|
||||
|
||||
closeProgressModal()
|
||||
Message.error(result.message || '下载启动失败')
|
||||
} catch (error) {
|
||||
console.error('下载失败:', error)
|
||||
closeProgressModal()
|
||||
Message.error('下载失败:' + (error.message || error))
|
||||
}
|
||||
}
|
||||
|
||||
// 下载完成处理(本地覆盖:关闭弹窗)
|
||||
const onDownloadComplete = async (event) => {
|
||||
const data = typeof event === 'string' ? JSON.parse(event) : event
|
||||
|
||||
if (data.error) {
|
||||
closeProgressModal()
|
||||
Message.error('下载失败:' + data.error)
|
||||
return
|
||||
}
|
||||
|
||||
if (!data.success || !data.file_path) {
|
||||
closeProgressModal()
|
||||
Message.error('下载完成但数据不完整')
|
||||
return
|
||||
}
|
||||
|
||||
// 等待安装完成
|
||||
await new Promise(resolve => setTimeout(resolve, 3000))
|
||||
closeProgressModal()
|
||||
emit('update:modelValue', false)
|
||||
}
|
||||
|
||||
// 生命周期
|
||||
onMounted(() => {
|
||||
On('download-complete', onDownloadComplete)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
Off('download-complete')
|
||||
closeModals()
|
||||
})
|
||||
</script>
|
||||
360
frontend/src/components/UpdatePanel.vue
Normal file
360
frontend/src/components/UpdatePanel.vue
Normal file
@@ -0,0 +1,360 @@
|
||||
<template>
|
||||
<div class="update-panel">
|
||||
<a-space direction="vertical" style="width: 100%" :size="20">
|
||||
|
||||
<!-- 当前版本信息 -->
|
||||
<a-card title="版本信息" :bordered="false">
|
||||
<template #extra>
|
||||
<a-button type="text" size="small" @click="$emit('open-version-history')">
|
||||
<template #icon><icon-history /></template>
|
||||
版本历史
|
||||
</a-button>
|
||||
</template>
|
||||
<a-row :gutter="16">
|
||||
<a-col :span="12">
|
||||
<div class="info-item">
|
||||
<div class="info-label">当前版本</div>
|
||||
<div class="info-value">{{ currentVersion }}</div>
|
||||
</div>
|
||||
</a-col>
|
||||
<a-col :span="12">
|
||||
<div class="info-item">
|
||||
<div class="info-label">最后检查</div>
|
||||
<div class="info-value">{{ lastCheckTime }}</div>
|
||||
</div>
|
||||
</a-col>
|
||||
</a-row>
|
||||
|
||||
<!-- 更新说明 -->
|
||||
<div v-if="updateInfo && updateInfo.changelog" class="changelog-section">
|
||||
<div class="changelog-title">
|
||||
{{ updateInfo.has_update ? '最新版本更新内容' : '当前版本更新内容' }}
|
||||
</div>
|
||||
<div class="changelog" v-html="renderChangelog(updateInfo.changelog)" />
|
||||
</div>
|
||||
</a-card>
|
||||
|
||||
<!-- 检查更新 -->
|
||||
<a-card title="检查更新" :bordered="false">
|
||||
<a-space>
|
||||
<a-button type="primary" @click="handleCheckUpdate" :loading="checking">
|
||||
<template #icon>
|
||||
<icon-search />
|
||||
</template>
|
||||
检查更新
|
||||
</a-button>
|
||||
</a-space>
|
||||
|
||||
<!-- 更新信息 -->
|
||||
<a-alert
|
||||
v-if="updateInfo"
|
||||
:type="updateInfo.has_update ? 'info' : 'success'"
|
||||
style="margin-top: 16px"
|
||||
>
|
||||
<template #title>
|
||||
{{ updateInfo.has_update ? '发现新版本' : '已是最新版本' }}
|
||||
</template>
|
||||
<div v-if="updateInfo.has_update">
|
||||
<p><strong>最新版本:</strong>{{ updateInfo.latest_version }}</p>
|
||||
<p><strong>发布日期:</strong>{{ updateInfo.release_date }}</p>
|
||||
<a-space style="margin-top: 12px">
|
||||
<a-button
|
||||
type="primary"
|
||||
@click="handleDownload"
|
||||
:loading="downloading"
|
||||
:disabled="!!downloadProgress"
|
||||
>
|
||||
<template #icon>
|
||||
<icon-download />
|
||||
</template>
|
||||
{{ downloadProgress > 0 ? '下载中...' : '下载更新' }}
|
||||
</a-button>
|
||||
<a-button
|
||||
v-if="downloadedFile"
|
||||
type="primary"
|
||||
status="success"
|
||||
@click="handleInstall"
|
||||
:loading="installing"
|
||||
>
|
||||
<template #icon>
|
||||
<icon-check-circle />
|
||||
</template>
|
||||
立即安装
|
||||
</a-button>
|
||||
</a-space>
|
||||
</div>
|
||||
</a-alert>
|
||||
|
||||
<!-- 下载进度 -->
|
||||
<div v-if="downloadProgress > 0 || downloading" class="download-progress">
|
||||
<a-progress
|
||||
:percent="downloadProgress"
|
||||
:status="downloadStatus"
|
||||
/>
|
||||
<div class="progress-info">
|
||||
<span>{{ updateStore.formatFileSize(progressInfo.downloaded) }} / {{ updateStore.formatFileSize(progressInfo.total) }}</span>
|
||||
<span v-if="progressInfo.speed > 0">{{ updateStore.formatSpeed(progressInfo.speed) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 安装结果 -->
|
||||
<a-alert
|
||||
v-if="installResult"
|
||||
:type="installResult.success ? 'success' : 'error'"
|
||||
style="margin-top: 16px"
|
||||
>
|
||||
<template #title>{{ installResult.success ? '安装成功' : '安装失败' }}</template>
|
||||
<p>{{ installResult.message }}</p>
|
||||
</a-alert>
|
||||
</a-card>
|
||||
|
||||
</a-space>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onUnmounted } from 'vue'
|
||||
import { Message, Modal } from '@arco-design/web-vue'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { IconHistory } from '@arco-design/web-vue/es/icon'
|
||||
import { useUpdateStore } from '../stores/update'
|
||||
import { marked } from '../utils/markedExtensions'
|
||||
import { sanitizeHtml } from '@/utils/fileUtils'
|
||||
import { GetCurrentVersion, GetUpdateConfig, InstallUpdate } from '../wailsjs/v3-bindings/u-desk/app'
|
||||
import { On, Off } from '@wailsio/events'
|
||||
|
||||
// Emits
|
||||
defineEmits(['open-version-history'])
|
||||
|
||||
// 使用更新管理 store
|
||||
const updateStore = useUpdateStore()
|
||||
|
||||
// 使用 storeToRefs 解构以保持响应性
|
||||
const { checking, downloading, installing, downloadProgress, downloadStatus, progressInfo, updateInfo } = storeToRefs(updateStore)
|
||||
|
||||
// 本地状态
|
||||
const currentVersion = ref('-')
|
||||
const lastCheckTime = ref('-')
|
||||
const installResult = ref(null)
|
||||
const downloadedFile = ref(null)
|
||||
|
||||
/** 渲染 changelog(Markdown → HTML) */
|
||||
function renderChangelog(text: string): string {
|
||||
if (!text) return ''
|
||||
try { return sanitizeHtml(marked.parse(text) as string) } catch { return text }
|
||||
}
|
||||
|
||||
// 加载当前版本
|
||||
const loadCurrentVersion = async () => {
|
||||
try {
|
||||
const result = await GetCurrentVersion()
|
||||
if (!result.success) return
|
||||
|
||||
currentVersion.value = result.data?.version || '-'
|
||||
} catch (error) {
|
||||
console.error('获取版本失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// 加载配置
|
||||
const loadConfig = async () => {
|
||||
try {
|
||||
const result = await GetUpdateConfig()
|
||||
if (!result.success) return
|
||||
|
||||
const { last_check_time = '-' } = result.data || {}
|
||||
lastCheckTime.value = last_check_time
|
||||
} catch (error) {
|
||||
console.error('加载配置失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// 检查更新
|
||||
const handleCheckUpdate = async () => {
|
||||
installResult.value = null
|
||||
|
||||
// 使用 store 的检查方法(非静默模式,显示消息)
|
||||
await updateStore.checkForUpdates(false)
|
||||
|
||||
// 刷新最后检查时间
|
||||
await loadConfig()
|
||||
}
|
||||
|
||||
// 下载更新
|
||||
const handleDownload = async () => {
|
||||
// 使用 store 的下载方法,会自动管理状态和事件监听
|
||||
await updateStore.downloadUpdate()
|
||||
}
|
||||
|
||||
// 安装更新
|
||||
const handleInstall = async () => {
|
||||
if (!downloadedFile.value) {
|
||||
Message.warning('请先下载更新包')
|
||||
return
|
||||
}
|
||||
|
||||
Modal.confirm({
|
||||
title: '确认安装',
|
||||
content: '安装更新后应用将自动重启,是否继续?',
|
||||
onOk: async () => {
|
||||
installing.value = true
|
||||
installResult.value = null
|
||||
|
||||
try {
|
||||
const result = await InstallUpdate(downloadedFile.value, true)
|
||||
installResult.value = result.data || result
|
||||
|
||||
const success = result.success || result.data?.success
|
||||
if (!success) {
|
||||
Message.error(result.message || '安装失败')
|
||||
return
|
||||
}
|
||||
|
||||
Message.success({
|
||||
content: '安装成功!应用将在几秒后重启...',
|
||||
duration: 3000
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('安装失败:', error)
|
||||
const errorMsg = '安装失败:' + (error.message || error)
|
||||
installResult.value = { success: false, message: errorMsg }
|
||||
Message.error(errorMsg)
|
||||
} finally {
|
||||
installing.value = false
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 监听下载完成事件(本地覆盖:记录下载文件路径)
|
||||
const onDownloadComplete = (event) => {
|
||||
let data: any
|
||||
try {
|
||||
data = typeof event === 'string' ? JSON.parse(event) : event
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
|
||||
if (data.success && data.file_path) {
|
||||
downloadedFile.value = data.file_path
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await loadCurrentVersion()
|
||||
await loadConfig()
|
||||
|
||||
// 监听下载完成事件(仅用于记录文件路径)
|
||||
On('download-complete', onDownloadComplete)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
Off('download-complete')
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.update-panel {
|
||||
max-width: 900px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.info-item {
|
||||
padding: 12px;
|
||||
background: var(--color-fill-1);
|
||||
border-radius: 6px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.info-label {
|
||||
font-size: 12px;
|
||||
color: var(--color-text-3);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.info-value {
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
color: var(--color-text-1);
|
||||
}
|
||||
|
||||
.changelog-section {
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.changelog-title {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--color-text-2);
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.changelog {
|
||||
background: var(--color-fill-1);
|
||||
padding: 10px 12px;
|
||||
border-radius: 4px;
|
||||
margin: 0;
|
||||
max-height: 280px;
|
||||
overflow-y: auto;
|
||||
font-size: 12px;
|
||||
line-height: 1.65;
|
||||
color: var(--color-text-2);
|
||||
}
|
||||
|
||||
.changelog :deep(h4) {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-1);
|
||||
margin: 8px 0 3px;
|
||||
}
|
||||
|
||||
.changelog :deep(h4:first-child) {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.changelog :deep(ul) {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 1px 0;
|
||||
}
|
||||
|
||||
.changelog :deep(li) {
|
||||
position: relative;
|
||||
padding-left: 14px;
|
||||
margin: 1px 0;
|
||||
}
|
||||
|
||||
.changelog :deep(li::before) {
|
||||
content: '·';
|
||||
position: absolute;
|
||||
left: 4px;
|
||||
color: var(--color-text-4);
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.changelog :deep(code) {
|
||||
background: var(--color-fill-3);
|
||||
padding: 0 4px;
|
||||
border-radius: 3px;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.changelog :deep(p) {
|
||||
margin: 2px 0;
|
||||
}
|
||||
|
||||
.download-progress {
|
||||
margin-top: 16px;
|
||||
padding: 16px;
|
||||
background: var(--color-fill-1);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.progress-info {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin-top: 8px;
|
||||
font-size: 12px;
|
||||
color: var(--color-text-3);
|
||||
}
|
||||
</style>
|
||||
7
frontend/src/composables/index.ts
Normal file
7
frontend/src/composables/index.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
/**
|
||||
* 全局 Composables 导出
|
||||
*/
|
||||
|
||||
export * from './useDebounce'
|
||||
export * from './useTablePage'
|
||||
export * from './useApiError'
|
||||
61
frontend/src/composables/useApiError.ts
Normal file
61
frontend/src/composables/useApiError.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
/**
|
||||
* API Error handling composable
|
||||
* 统一的 API 错误处理
|
||||
*/
|
||||
|
||||
import { ref } from 'vue'
|
||||
import { Message } from '@arco-design/web-vue'
|
||||
|
||||
export interface ApiErrorState {
|
||||
hasError: boolean
|
||||
message: string
|
||||
code?: string | number
|
||||
}
|
||||
|
||||
export function useApiError() {
|
||||
const error = ref<ApiErrorState>({
|
||||
hasError: false,
|
||||
message: ''
|
||||
})
|
||||
|
||||
const setError = (err: Error | string | unknown, defaultMessage: string = '操作失败') => {
|
||||
let message = defaultMessage
|
||||
let code: string | number | undefined
|
||||
|
||||
if (err instanceof Error) {
|
||||
message = err.message || defaultMessage
|
||||
} else if (typeof err === 'string') {
|
||||
message = err
|
||||
} else if (err && typeof err === 'object' && 'message' in err) {
|
||||
message = (err as any).message || defaultMessage
|
||||
if ('code' in err) code = (err as any).code
|
||||
}
|
||||
|
||||
error.value = {
|
||||
hasError: true,
|
||||
message,
|
||||
code
|
||||
}
|
||||
|
||||
return { message, code }
|
||||
}
|
||||
|
||||
const showError = (err: Error | string | unknown, defaultMessage: string = '操作失败') => {
|
||||
const { message } = setError(err, defaultMessage)
|
||||
Message.error(message)
|
||||
}
|
||||
|
||||
const clearError = () => {
|
||||
error.value = {
|
||||
hasError: false,
|
||||
message: ''
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
error,
|
||||
setError,
|
||||
showError,
|
||||
clearError
|
||||
}
|
||||
}
|
||||
34
frontend/src/composables/useDebounce.ts
Normal file
34
frontend/src/composables/useDebounce.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
/**
|
||||
* Debounce composable
|
||||
* 防抖函数
|
||||
*/
|
||||
|
||||
import { ref, watch, type Ref, type ComputedRef } from 'vue'
|
||||
|
||||
export function useDebounce<T>(value: Ref<T> | ComputedRef<T>, delay: number = 300): Ref<T> {
|
||||
const debouncedValue = ref<T>(value.value) as Ref<T>
|
||||
let timeout: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
watch(value, (newValue) => {
|
||||
if (timeout) clearTimeout(timeout)
|
||||
timeout = setTimeout(() => {
|
||||
debouncedValue.value = newValue
|
||||
}, delay)
|
||||
})
|
||||
|
||||
return debouncedValue
|
||||
}
|
||||
|
||||
export function debounceFn<T extends (...args: any[]) => any>(
|
||||
fn: T,
|
||||
delay: number = 300
|
||||
): (...args: Parameters<T>) => void {
|
||||
let timeout: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
return (...args: Parameters<T>) => {
|
||||
if (timeout) clearTimeout(timeout)
|
||||
timeout = setTimeout(() => {
|
||||
fn(...args)
|
||||
}, delay)
|
||||
}
|
||||
}
|
||||
354
frontend/src/composables/useFavoriteFiles.js
Normal file
354
frontend/src/composables/useFavoriteFiles.js
Normal file
@@ -0,0 +1,354 @@
|
||||
/**
|
||||
* 收藏夹管理 composable
|
||||
*
|
||||
* @module composables/useFavoriteFiles
|
||||
* @description 封装收藏夹的增删改查逻辑,支持持久化存储
|
||||
*/
|
||||
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { Message } from '@arco-design/web-vue'
|
||||
|
||||
/**
|
||||
* 收藏夹 composable
|
||||
* @param {string} storageKey - localStorage 键名
|
||||
* @param {Object} [options] - 配置选项
|
||||
* @param {number} [options.maxLength=50] - 最大收藏数量
|
||||
* @param {Function} [options.onAdd] - 添加收藏回调
|
||||
* @param {Function} [options.onRemove] - 移除收藏回调
|
||||
* @returns {UseFavoriteFilesReturn} 收藏夹操作API
|
||||
*
|
||||
* @example
|
||||
* const {
|
||||
* favoriteFiles,
|
||||
* isFavorite,
|
||||
* toggleFavorite,
|
||||
* removeFavorite,
|
||||
* clearAll
|
||||
* } = useFavoriteFiles('app-favorites')
|
||||
*
|
||||
* // 在模板中使用
|
||||
* <a-button @click="toggleFavorite(file)">
|
||||
* <icon-star-fill v-if="isFavorite(file.path)" />
|
||||
* <icon-star v-else />
|
||||
* </a-button>
|
||||
*/
|
||||
export function useFavoriteFiles(storageKey, options = {}) {
|
||||
const {
|
||||
maxLength = 50,
|
||||
onAdd = () => {},
|
||||
onRemove = () => {},
|
||||
} = options
|
||||
|
||||
// 收藏列表
|
||||
const favoriteFiles = ref([])
|
||||
|
||||
const load = () => {
|
||||
try {
|
||||
const stored = localStorage.getItem(storageKey)
|
||||
if (stored) {
|
||||
favoriteFiles.value = JSON.parse(stored)
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('加载收藏列表失败:', e)
|
||||
}
|
||||
}
|
||||
|
||||
const save = (data) => {
|
||||
try {
|
||||
localStorage.setItem(storageKey, JSON.stringify(data || favoriteFiles.value))
|
||||
} catch (e) {
|
||||
console.error('保存收藏列表失败:', e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断文件/目录是否已收藏
|
||||
* @param {string} path - 文件/目录路径
|
||||
* @returns {boolean} 是否已收藏
|
||||
*/
|
||||
const isFavorite = (path) => {
|
||||
if (!path || !Array.isArray(favoriteFiles.value)) {
|
||||
return false
|
||||
}
|
||||
return favoriteFiles.value.some(fav => fav.path === path)
|
||||
}
|
||||
|
||||
/**
|
||||
* 排序收藏列表(按创建时间倒序,最新的在上面)
|
||||
*/
|
||||
const sortFavorites = () => {
|
||||
if (!Array.isArray(favoriteFiles.value)) {
|
||||
return
|
||||
}
|
||||
favoriteFiles.value.sort((a, b) => {
|
||||
const timeA = a.addedAt || 0
|
||||
const timeB = b.addedAt || 0
|
||||
return timeB - timeA // 倒序:最新的在上面
|
||||
})
|
||||
save()
|
||||
}
|
||||
|
||||
/**
|
||||
* 切换收藏状态
|
||||
* @param {Object} item - 文件/目录信息
|
||||
* @param {string} item.path - 路径
|
||||
* @param {string} item.name - 名称
|
||||
* @param {boolean} item.is_dir - 是否为目录
|
||||
* @returns {boolean} 操作后是否为收藏状态
|
||||
*/
|
||||
const toggleFavorite = (item) => {
|
||||
if (!item || !item.path) {
|
||||
Message.warning('无效的文件信息')
|
||||
return false
|
||||
}
|
||||
|
||||
const index = favoriteFiles.value.findIndex(fav => fav.path === item.path)
|
||||
|
||||
if (index > -1) {
|
||||
// 已收藏,执行取消收藏
|
||||
favoriteFiles.value.splice(index, 1)
|
||||
save(favoriteFiles.value) // 直接保存,不重新排序
|
||||
|
||||
onRemove(item)
|
||||
Message.info(`已取消收藏: ${item.name}`)
|
||||
return false
|
||||
} else {
|
||||
// 未收藏,执行添加收藏
|
||||
if (favoriteFiles.value.length >= maxLength) {
|
||||
Message.warning(`收藏夹已满,最多只能收藏 ${maxLength} 项`)
|
||||
return false
|
||||
}
|
||||
|
||||
favoriteFiles.value.push({
|
||||
path: item.path,
|
||||
name: item.name,
|
||||
isDir: item.isDir || false,
|
||||
addedAt: Date.now(),
|
||||
})
|
||||
|
||||
save(favoriteFiles.value) // 直接保存,不重新排序(新项目添加到末尾)
|
||||
|
||||
onAdd(item)
|
||||
Message.success(`已收藏: ${item.name}`)
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 移除收藏
|
||||
* @param {string} path - 文件/目录路径
|
||||
* @returns {boolean} 是否成功移除
|
||||
*/
|
||||
const removeFavorite = (path) => {
|
||||
if (!path) {
|
||||
Message.warning('请提供有效的路径')
|
||||
return false
|
||||
}
|
||||
|
||||
const index = favoriteFiles.value.findIndex(fav => fav.path === path)
|
||||
|
||||
if (index === -1) {
|
||||
Message.warning('该路径不在收藏夹中')
|
||||
return false
|
||||
}
|
||||
|
||||
const item = favoriteFiles.value[index]
|
||||
favoriteFiles.value.splice(index, 1)
|
||||
|
||||
save(favoriteFiles.value) // 直接保存,不重新排序
|
||||
|
||||
onRemove(item)
|
||||
Message.info(`已取消收藏: ${item.name}`)
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* 打开收藏的文件/目录
|
||||
* @param {string} path - 文件/目录路径
|
||||
* @param {Function} onOpen - 打开回调函数
|
||||
* @returns {Promise<boolean>} 是否成功打开
|
||||
*/
|
||||
const openFavorite = async (path, onOpen) => {
|
||||
if (!path || !onOpen) {
|
||||
return false
|
||||
}
|
||||
|
||||
const item = favoriteFiles.value.find(fav => fav.path === path)
|
||||
if (!item) {
|
||||
Message.warning('收藏项不存在')
|
||||
return false
|
||||
}
|
||||
|
||||
return await onOpen(item)
|
||||
}
|
||||
|
||||
/**
|
||||
* 清空所有收藏
|
||||
* @param {boolean} [confirm=true] - 是否需要确认
|
||||
* @returns {boolean} 是否成功清空
|
||||
*/
|
||||
const clearAll = (confirm = true) => {
|
||||
const executeClear = () => {
|
||||
const count = favoriteFiles.value.length
|
||||
favoriteFiles.value = []
|
||||
save([])
|
||||
|
||||
Message.success(`已清空 ${count} 个收藏项`)
|
||||
return true
|
||||
}
|
||||
|
||||
if (!confirm) {
|
||||
return executeClear()
|
||||
}
|
||||
|
||||
// 使用原生 confirm(简单场景)
|
||||
if (window.confirm(`确定要清空所有 ${favoriteFiles.value.length} 个收藏项吗?`)) {
|
||||
return executeClear()
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取收藏列表(按创建时间排序)
|
||||
* @param {string} [order='desc'] - 排序方式:'asc'或'desc'
|
||||
* @returns {Array} 排序后的收藏列表
|
||||
*/
|
||||
const getSortedFavorites = (order = 'desc') => {
|
||||
const sorted = [...favoriteFiles.value]
|
||||
sorted.sort((a, b) => {
|
||||
const timeA = a.addedAt || 0
|
||||
const timeB = b.addedAt || 0
|
||||
return order === 'desc' ? timeB - timeA : timeA - timeB
|
||||
})
|
||||
return sorted
|
||||
}
|
||||
|
||||
/**
|
||||
* 按名称搜索收藏
|
||||
* @param {string} keyword - 搜索关键词
|
||||
* @returns {Array} 匹配的收藏列表
|
||||
*/
|
||||
const searchFavorites = (keyword) => {
|
||||
if (!keyword || !keyword.trim()) {
|
||||
return favoriteFiles.value
|
||||
}
|
||||
|
||||
const lowerKeyword = keyword.toLowerCase().trim()
|
||||
return favoriteFiles.value.filter(fav =>
|
||||
fav.name.toLowerCase().includes(lowerKeyword) ||
|
||||
fav.path.toLowerCase().includes(lowerKeyword)
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 重新排序收藏列表(拖拽排序)
|
||||
* @param {number} fromIndex - 源索引
|
||||
* @param {number} toIndex - 目标索引
|
||||
* @returns {boolean} 是否成功重排序
|
||||
*/
|
||||
const reorderFavorites = (fromIndex, toIndex) => {
|
||||
if (!Array.isArray(favoriteFiles.value)) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (fromIndex < 0 || fromIndex >= favoriteFiles.value.length ||
|
||||
toIndex < 0 || toIndex >= favoriteFiles.value.length) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (fromIndex === toIndex) {
|
||||
return false
|
||||
}
|
||||
|
||||
// 移动数组元素
|
||||
const [movedItem] = favoriteFiles.value.splice(fromIndex, 1)
|
||||
favoriteFiles.value.splice(toIndex, 0, movedItem)
|
||||
|
||||
// 保存新顺序
|
||||
save(favoriteFiles.value)
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// 旧字段名 → 新字段名迁移(一次性,迁移后自动回写)
|
||||
const migrateFieldNames = (list) => {
|
||||
if (!Array.isArray(list)) return
|
||||
const map = { is_dir: 'isDir', created_at: 'addedAt' }
|
||||
let changed = false
|
||||
list.forEach(item => {
|
||||
for (const [old, newKey] of Object.entries(map)) {
|
||||
if (old in item) {
|
||||
if (!(newKey in item)) item[newKey] = item[old]
|
||||
delete item[old]
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
})
|
||||
if (changed) save(list)
|
||||
}
|
||||
|
||||
// 组件挂载时加载数据并迁移旧字段
|
||||
onMounted(() => {
|
||||
load()
|
||||
migrateFieldNames(favoriteFiles.value)
|
||||
})
|
||||
|
||||
return {
|
||||
// 状态
|
||||
favoriteFiles,
|
||||
|
||||
// 方法
|
||||
isFavorite,
|
||||
toggleFavorite,
|
||||
removeFavorite,
|
||||
openFavorite,
|
||||
clearAll,
|
||||
getSortedFavorites,
|
||||
searchFavorites,
|
||||
sortFavorites,
|
||||
reorderFavorites,
|
||||
load,
|
||||
save,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @typedef {Object} UseFavoriteFilesReturn
|
||||
* @property {Ref<Array>} favoriteFiles - 收藏列表(自动按时间倒序排列)
|
||||
* @property {Function} isFavorite - 判断是否已收藏
|
||||
* @property {Function} toggleFavorite - 切换收藏状态
|
||||
* @property {Function} removeFavorite - 移除收藏
|
||||
* @property {Function} openFavorite - 打开收藏项
|
||||
* @property {Function} clearAll - 清空所有收藏
|
||||
* @property {Function} getSortedFavorites - 获取排序后的列表
|
||||
* @property {Function} searchFavorites - 搜索收藏
|
||||
* @property {Function} sortFavorites - 手动排序收藏列表
|
||||
* @property {Function} reorderFavorites - 拖拽重新排序
|
||||
* @property {Function} load - 手动加载数据
|
||||
* @property {Function} save - 手动保存数据
|
||||
*/
|
||||
|
||||
/**
|
||||
* 创建多个收藏夹管理实例
|
||||
* @param {Object} config - 配置对象
|
||||
* @returns {Object} 收藏夹管理实例集合
|
||||
*
|
||||
* @example
|
||||
* const {
|
||||
* filesystemFavs,
|
||||
* deviceTestFavs
|
||||
* } = createMultipleFavorites({
|
||||
* filesystem: 'app-filesystem-favorites',
|
||||
* deviceTest: 'app-device-test-favorites'
|
||||
* })
|
||||
*/
|
||||
export function createMultipleFavorites(config) {
|
||||
const result = {}
|
||||
|
||||
Object.keys(config).forEach(key => {
|
||||
result[key] = useFavoriteFiles(config[key])
|
||||
})
|
||||
|
||||
return result
|
||||
}
|
||||
373
frontend/src/composables/useFileOperations.js
Normal file
373
frontend/src/composables/useFileOperations.js
Normal file
@@ -0,0 +1,373 @@
|
||||
/**
|
||||
* 文件操作逻辑封装
|
||||
*
|
||||
* @module composables/useFileOperations
|
||||
* @description 封装所有文件操作逻辑,提供统一的错误处理和状态管理
|
||||
*/
|
||||
|
||||
import { ref, watch } from 'vue'
|
||||
import { Message, Modal } from '@arco-design/web-vue'
|
||||
import {
|
||||
listDir,
|
||||
readFile as readFileApi,
|
||||
writeFile as writeFileApi,
|
||||
deletePath as deletePathApi,
|
||||
} from '@/api'
|
||||
|
||||
/**
|
||||
* LocalStorage 键名
|
||||
*/
|
||||
const STORAGE_KEY_LAST_PATH = 'app-filesystem-last-path'
|
||||
|
||||
/**
|
||||
* 文件操作 composable
|
||||
* @param {Object} [options] - 配置选项
|
||||
* @param {Function} [options.onSuccess] - 操作成功回调
|
||||
* @param {Function} [options.onError] - 操作失败回调
|
||||
* @returns {UseFileOperationsReturn} 文件操作API
|
||||
*
|
||||
* @example
|
||||
* const {
|
||||
* filePath,
|
||||
* fileContent,
|
||||
* fileList,
|
||||
* fileLoading,
|
||||
* listDirectory,
|
||||
* readFile,
|
||||
* writeFile,
|
||||
* deleteFile
|
||||
* } = useFileOperations({
|
||||
* onSuccess: (operation, data) => console.log(operation, data),
|
||||
* onError: (operation, error) => console.error(operation, error)
|
||||
* })
|
||||
*/
|
||||
export function useFileOperations(options = {}) {
|
||||
const { onSuccess = () => {}, onError = () => {} } = options
|
||||
|
||||
// ========== 响应式状态 ==========
|
||||
|
||||
/**
|
||||
* 当前文件/目录路径
|
||||
* @type {Ref<string>}
|
||||
*/
|
||||
// 从 localStorage 恢复上次路径
|
||||
const savedPath = localStorage.getItem(STORAGE_KEY_LAST_PATH)
|
||||
const filePath = ref(savedPath || '')
|
||||
|
||||
/**
|
||||
* 文件内容
|
||||
* @type {Ref<string>}
|
||||
*/
|
||||
const fileContent = ref('')
|
||||
|
||||
/**
|
||||
* 文件列表
|
||||
* @type {Ref<Array>}
|
||||
*/
|
||||
const fileList = ref([])
|
||||
|
||||
/**
|
||||
* 加载状态
|
||||
* @type {Ref<boolean>}
|
||||
*/
|
||||
const fileLoading = ref(false)
|
||||
|
||||
/**
|
||||
* 正在删除状态(防止并发删除)
|
||||
* @type {Ref<boolean>}
|
||||
*/
|
||||
const isDeleting = ref(false)
|
||||
|
||||
// ========== 文件操作方法 ==========
|
||||
|
||||
/**
|
||||
* 列出目录内容
|
||||
* @param {string} [path] - 目录路径,不传则使用当前路径
|
||||
* @returns {Promise<boolean>} 是否成功
|
||||
*/
|
||||
const listDirectory = async (path) => {
|
||||
const targetPath = path || filePath.value
|
||||
|
||||
if (!targetPath) {
|
||||
Message.error('请输入目录路径')
|
||||
return false
|
||||
}
|
||||
|
||||
fileLoading.value = true
|
||||
try {
|
||||
const result = await listDir(targetPath)
|
||||
fileList.value = result
|
||||
|
||||
if (!path) {
|
||||
// 如果没有传参,更新当前路径
|
||||
filePath.value = targetPath
|
||||
}
|
||||
|
||||
onSuccess('listDirectory', { path: targetPath, count: result.length })
|
||||
return true
|
||||
} catch (error) {
|
||||
onError('listDirectory', error)
|
||||
const errorMsg = error.message || error || '未知错误'
|
||||
Message.error(`列出目录失败 [${targetPath}]: ${errorMsg}`)
|
||||
return false
|
||||
} finally {
|
||||
fileLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 读取文件内容
|
||||
* @param {string} [path] - 文件路径,不传则使用当前路径
|
||||
* @returns {Promise<boolean>} 是否成功
|
||||
*/
|
||||
const readFile = async (path) => {
|
||||
const targetPath = path || filePath.value
|
||||
|
||||
if (!targetPath) {
|
||||
Message.error('请输入文件路径')
|
||||
return false
|
||||
}
|
||||
|
||||
fileLoading.value = true
|
||||
try {
|
||||
const content = await readFileApi(targetPath)
|
||||
fileContent.value = content
|
||||
|
||||
if (!path) {
|
||||
filePath.value = targetPath
|
||||
}
|
||||
|
||||
onSuccess('readFile', { path: targetPath })
|
||||
// 文件读取成功,静默无提示
|
||||
return true
|
||||
} catch (error) {
|
||||
onError('readFile', error)
|
||||
Message.error(`读取文件失败: ${error.message || error}`)
|
||||
return false
|
||||
} finally {
|
||||
fileLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 写入文件内容
|
||||
* @param {string} [content] - 要写入的内容,不传则使用当前内容
|
||||
* @param {string} [path] - 文件路径,不传则使用当前路径
|
||||
* @param {string} [fileName] - 文件名,用于成功提示显示
|
||||
* @param {boolean} [isShortcut=false] - 是否是快捷键触发(快捷键不显示提示)
|
||||
* @returns {Promise<boolean>} 是否成功
|
||||
*/
|
||||
const writeFile = async (content, path, fileName, isShortcut = false) => {
|
||||
// 忽略事件对象(当点击按钮时 Vue 会传递事件对象)
|
||||
const targetContent = (content !== undefined && typeof content === 'string') ? content : fileContent.value
|
||||
const targetPath = (path !== undefined && typeof path === 'string') ? path : filePath.value
|
||||
|
||||
if (!targetPath) {
|
||||
Message.error('请输入文件路径')
|
||||
return false
|
||||
}
|
||||
|
||||
fileLoading.value = true
|
||||
try {
|
||||
await writeFileApi(targetPath, targetContent)
|
||||
|
||||
if (content !== undefined) {
|
||||
fileContent.value = targetContent
|
||||
}
|
||||
if (path) {
|
||||
filePath.value = path
|
||||
}
|
||||
|
||||
onSuccess('writeFile', { path: targetPath })
|
||||
|
||||
// 差异化反馈:快捷键不显示提示,按钮点击显示轻提示
|
||||
if (!isShortcut) {
|
||||
// 按钮点击保存:显示轻量 Toast 提示
|
||||
if (fileName && typeof fileName === 'string') {
|
||||
Message.success({
|
||||
content: `✓ ${fileName} 已保存`,
|
||||
duration: 1500, // 1.5秒后自动消失
|
||||
position: 'bottom' // 底部显示,不打断操作
|
||||
})
|
||||
} else {
|
||||
Message.success({
|
||||
content: '文件已保存',
|
||||
duration: 1500,
|
||||
position: 'bottom'
|
||||
})
|
||||
}
|
||||
}
|
||||
// 快捷键保存:无提示(静默成功)
|
||||
|
||||
return true
|
||||
} catch (error) {
|
||||
onError('writeFile', error)
|
||||
// 保存失败:总是显示醒目错误提示(需手动关闭)
|
||||
Message.error({
|
||||
content: `文件保存失败: ${error.message || error}`,
|
||||
duration: 5000, // 错误提示显示更久
|
||||
closable: true // 允许手动关闭
|
||||
})
|
||||
return false
|
||||
} finally {
|
||||
fileLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除文件或目录
|
||||
* 🔒 安全修复:移除 confirm 参数,始终需要用户确认,防止绕过
|
||||
* 🔒 安全修复:添加并发删除检查,防止批量删除攻击
|
||||
* @param {string} [path] - 文件路径,不传则使用当前路径
|
||||
* @returns {Promise<boolean>} 用户是否确认及操作是否成功
|
||||
*/
|
||||
const deleteFile = async (path) => {
|
||||
const targetPath = path || filePath.value
|
||||
|
||||
if (!targetPath) {
|
||||
Message.error('请输入文件路径')
|
||||
return false
|
||||
}
|
||||
|
||||
// 🔒 安全修复:添加调用频率限制(防止批量删除攻击)
|
||||
if (isDeleting.value) {
|
||||
Message.warning('正在删除中,请稍候...')
|
||||
return false
|
||||
}
|
||||
|
||||
const executeDelete = async () => {
|
||||
isDeleting.value = true
|
||||
fileLoading.value = true
|
||||
try {
|
||||
await deletePathApi(targetPath)
|
||||
|
||||
// 清空状态
|
||||
filePath.value = ''
|
||||
fileContent.value = ''
|
||||
fileList.value = []
|
||||
|
||||
onSuccess('deleteFile', { path: targetPath })
|
||||
Message.success('删除成功')
|
||||
return true
|
||||
} catch (error) {
|
||||
onError('deleteFile', error)
|
||||
Message.error(`删除失败: ${error.message || error}`)
|
||||
return false
|
||||
} finally {
|
||||
fileLoading.value = false
|
||||
isDeleting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 🔒 安全修复:始终显示确认对话框,无法绕过
|
||||
return new Promise((resolve) => {
|
||||
Modal.confirm({
|
||||
title: '⚠️ 确认删除',
|
||||
content: `确定要删除 ${targetPath} 吗?此操作不可恢复!`,
|
||||
okText: '确定删除',
|
||||
cancelText: '取消',
|
||||
okButtonProps: { status: 'danger' }, // 红色按钮提醒危险
|
||||
onOk: async () => {
|
||||
const result = await executeDelete()
|
||||
resolve(result)
|
||||
},
|
||||
onCancel: () => {
|
||||
resolve(false)
|
||||
},
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 选择文件(智能判断是文件还是目录)
|
||||
* @param {string} path - 文件/目录路径
|
||||
* @param {Array} fileListData - 文件列表数据
|
||||
* @returns {Promise<boolean>} 是否成功
|
||||
*/
|
||||
const selectFile = async (path, fileListData) => {
|
||||
if (!path) return false
|
||||
|
||||
filePath.value = path
|
||||
|
||||
// 从文件列表中查找该项
|
||||
const item = fileListData.find(f => f.path === path)
|
||||
|
||||
if (!item) {
|
||||
// 如果列表中找不到,尝试根据路径判断
|
||||
// 简单判断:路径以 / 或 \ 结尾可能是目录
|
||||
const isDir = path.endsWith('/') || path.endsWith('\\')
|
||||
if (isDir) {
|
||||
return await listDirectory(path)
|
||||
} else {
|
||||
return await readFile(path)
|
||||
}
|
||||
}
|
||||
|
||||
if (item.is_dir) {
|
||||
// 是目录,列出内容
|
||||
return await listDirectory(path)
|
||||
} else {
|
||||
// 是文件,读取内容
|
||||
return await readFile(path)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 清空所有状态
|
||||
*/
|
||||
const clearAll = () => {
|
||||
filePath.value = ''
|
||||
fileContent.value = ''
|
||||
fileList.value = []
|
||||
}
|
||||
|
||||
// ========== 持久化 ==========
|
||||
|
||||
/**
|
||||
* 监听路径变化,自动保存到 localStorage
|
||||
* 用于下次启动时恢复上次访问的路径
|
||||
*/
|
||||
watch(filePath, (newPath) => {
|
||||
try {
|
||||
if (newPath) {
|
||||
localStorage.setItem(STORAGE_KEY_LAST_PATH, newPath)
|
||||
} else {
|
||||
localStorage.removeItem(STORAGE_KEY_LAST_PATH)
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('[useFileOperations] 保存路径失败:', e)
|
||||
}
|
||||
})
|
||||
|
||||
// ========== 返回公共API ==========
|
||||
|
||||
return {
|
||||
// 状态
|
||||
filePath,
|
||||
fileContent,
|
||||
fileList,
|
||||
fileLoading,
|
||||
|
||||
// 方法
|
||||
listDirectory,
|
||||
readFile,
|
||||
writeFile,
|
||||
deleteFile,
|
||||
selectFile,
|
||||
clearAll,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @typedef {Object} UseFileOperationsReturn
|
||||
* @property {Ref<string>} filePath - 当前文件路径
|
||||
* @property {Ref<string>} fileContent - 文件内容
|
||||
* @property {Ref<Array>} fileList - 文件列表
|
||||
* @property {Ref<boolean>} fileLoading - 加载状态
|
||||
* @property {Function} listDirectory - 列出目录
|
||||
* @property {Function} readFile - 读取文件
|
||||
* @property {Function} writeFile - 写入文件
|
||||
* @property {Function} deleteFile - 删除文件
|
||||
* @property {Function} selectFile - 智能选择文件
|
||||
* @property {Function} clearAll - 清空所有状态
|
||||
*/
|
||||
258
frontend/src/composables/useLocalStorage.js
Normal file
258
frontend/src/composables/useLocalStorage.js
Normal file
@@ -0,0 +1,258 @@
|
||||
/**
|
||||
* localStorage 响应式封装
|
||||
*
|
||||
* @module composables/useLocalStorage
|
||||
* @description 提供响应式的 localStorage 数据持久化能力,自动同步数据变化
|
||||
*/
|
||||
|
||||
import { ref, watch, onMounted } from 'vue'
|
||||
|
||||
/**
|
||||
* 创建响应式的 localStorage 绑定
|
||||
* @param {string} key - localStorage 键名
|
||||
* @param {*} defaultValue - 默认值
|
||||
* @param {Object} [options] - 配置选项
|
||||
* @param {boolean} [options.deep=true] - 是否深度监听对象变化
|
||||
* @param {boolean} [options.immediate=true] - 是否立即加载
|
||||
* @returns {UseLocalStorageReturn} 响应式数据和操作方法
|
||||
*
|
||||
* @example
|
||||
* // 基础用法
|
||||
* const { storedValue, load, save, clear } = useLocalStorage('app-user-name', 'Guest')
|
||||
*
|
||||
* // 对象用法
|
||||
* const { storedValue } = useLocalStorage('app-settings', { theme: 'light' })
|
||||
*
|
||||
* @see {@link https://vueuse.org/core/useLocalStorage/} 参考 VueUse 的实现
|
||||
*/
|
||||
export function useLocalStorage(key, defaultValue, options = {}) {
|
||||
const {
|
||||
deep = true, // 深度监听
|
||||
immediate = true, // 立即加载
|
||||
serializer = JSON, // 序列化器
|
||||
onError = (error) => console.error('localStorage操作失败:', error),
|
||||
} = options
|
||||
|
||||
// 响应式数据
|
||||
const storedValue = ref(defaultValue)
|
||||
|
||||
/**
|
||||
* 从 localStorage 加载数据
|
||||
* @returns {boolean} 是否加载成功
|
||||
*/
|
||||
const load = () => {
|
||||
try {
|
||||
const item = localStorage.getItem(key)
|
||||
if (item === null || item === undefined) {
|
||||
storedValue.value = defaultValue
|
||||
return false
|
||||
}
|
||||
|
||||
// 解析数据
|
||||
const parsed = serializer.parse(item)
|
||||
storedValue.value = parsed
|
||||
return true
|
||||
} catch (error) {
|
||||
onError(error)
|
||||
storedValue.value = defaultValue
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存数据到 localStorage
|
||||
* @param {*} value - 要保存的值
|
||||
* @returns {boolean} 是否保存成功
|
||||
*/
|
||||
const save = (value) => {
|
||||
try {
|
||||
const serialized = serializer.stringify(value)
|
||||
localStorage.setItem(key, serialized)
|
||||
return true
|
||||
} catch (error) {
|
||||
onError(error)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 清除 localStorage 中的数据
|
||||
* @returns {boolean} 是否清除成功
|
||||
*/
|
||||
const clear = () => {
|
||||
try {
|
||||
localStorage.removeItem(key)
|
||||
storedValue.value = defaultValue
|
||||
return true
|
||||
} catch (error) {
|
||||
onError(error)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// 监听数据变化自动保存
|
||||
watch(
|
||||
storedValue,
|
||||
(newValue) => {
|
||||
save(newValue)
|
||||
},
|
||||
{ deep }
|
||||
)
|
||||
|
||||
// 组件挂载时加载数据
|
||||
if (immediate) {
|
||||
onMounted(() => {
|
||||
load()
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
/**
|
||||
* 响应式数据值
|
||||
* @type {Ref<any>}
|
||||
*/
|
||||
storedValue,
|
||||
|
||||
/**
|
||||
* 手动加载数据
|
||||
* @type {() => boolean}
|
||||
*/
|
||||
load,
|
||||
|
||||
/**
|
||||
* 手动保存数据
|
||||
* @type {(value: any) => boolean}
|
||||
*/
|
||||
save,
|
||||
|
||||
/**
|
||||
* 清除数据
|
||||
* @type {() => boolean}
|
||||
*/
|
||||
clear,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @typedef {Object} UseLocalStorageReturn
|
||||
* @property {Ref<any>} storedValue - 响应式数据
|
||||
* @property {() => boolean} load - 手动加载函数
|
||||
* @property {(value: any) => boolean} save - 手动保存函数
|
||||
* @property {() => boolean} clear - 清除数据函数
|
||||
*/
|
||||
|
||||
/**
|
||||
* 批量管理多个 localStorage 键
|
||||
* @param {Object} config - 配置对象,键为localStorage键名,值为默认值
|
||||
* @returns {Object} 响应式数据对象
|
||||
*
|
||||
* @example
|
||||
* const {
|
||||
* theme: themeRef,
|
||||
* language: languageRef,
|
||||
* settings: settingsRef
|
||||
* } = useMultiLocalStorage({
|
||||
* 'app-theme': 'light',
|
||||
* 'app-language': 'zh-CN',
|
||||
* 'app-settings': { fontSize: 14 }
|
||||
* })
|
||||
*/
|
||||
export function useMultiLocalStorage(config) {
|
||||
const result = {}
|
||||
|
||||
Object.keys(config).forEach(key => {
|
||||
const { storedValue } = useLocalStorage(key, config[key])
|
||||
result[key] = storedValue
|
||||
})
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* localStorage 辅助工具函数
|
||||
*/
|
||||
export const localStorageHelpers = {
|
||||
/**
|
||||
* 检查 localStorage 是否可用
|
||||
* @returns {boolean}
|
||||
*/
|
||||
isAvailable() {
|
||||
try {
|
||||
const test = '__localStorage_test__'
|
||||
localStorage.setItem(test, test)
|
||||
localStorage.removeItem(test)
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* 获取 localStorage 使用大小(近似值)
|
||||
* @returns {number} 大小(字节)
|
||||
*/
|
||||
getSize() {
|
||||
let total = 0
|
||||
for (let key in localStorage) {
|
||||
if (localStorage.hasOwnProperty(key)) {
|
||||
total += localStorage[key].length + key.length
|
||||
}
|
||||
}
|
||||
return total
|
||||
},
|
||||
|
||||
/**
|
||||
* 清空所有 localStorage 数据(谨慎使用)
|
||||
* @param {string[]} [excludeKeys] - 要排除的键名列表
|
||||
*/
|
||||
clearAll(excludeKeys = []) {
|
||||
const keysToRemove = []
|
||||
for (let i = 0; i < localStorage.length; i++) {
|
||||
const key = localStorage.key(i)
|
||||
if (!excludeKeys.includes(key)) {
|
||||
keysToRemove.push(key)
|
||||
}
|
||||
}
|
||||
keysToRemove.forEach(key => localStorage.removeItem(key))
|
||||
},
|
||||
|
||||
/**
|
||||
* 导出所有 localStorage 数据为 JSON
|
||||
* @returns {string} JSON 字符串
|
||||
*/
|
||||
exportToJSON() {
|
||||
const data = {}
|
||||
for (let i = 0; i < localStorage.length; i++) {
|
||||
const key = localStorage.key(i)
|
||||
data[key] = localStorage.getItem(key)
|
||||
}
|
||||
return JSON.stringify(data, null, 2)
|
||||
},
|
||||
|
||||
/**
|
||||
* 从 JSON 导入 localStorage 数据
|
||||
* @param {string} jsonString - JSON 字符串
|
||||
* @param {boolean} [merge=false] - 是否合并(false则清空后导入)
|
||||
* @returns {number} 导入的键数量
|
||||
*/
|
||||
importFromJSON(jsonString, merge = false) {
|
||||
try {
|
||||
const data = JSON.parse(jsonString)
|
||||
|
||||
if (!merge) {
|
||||
localStorageHelpers.clearAll()
|
||||
}
|
||||
|
||||
let count = 0
|
||||
Object.keys(data).forEach(key => {
|
||||
localStorage.setItem(key, data[key])
|
||||
count++
|
||||
})
|
||||
|
||||
return count
|
||||
} catch (error) {
|
||||
console.error('导入失败:', error)
|
||||
return 0
|
||||
}
|
||||
},
|
||||
}
|
||||
273
frontend/src/composables/useNavigation.js
Normal file
273
frontend/src/composables/useNavigation.js
Normal file
@@ -0,0 +1,273 @@
|
||||
/**
|
||||
* 导航和路径管理 composable
|
||||
*
|
||||
* @module composables/useNavigation
|
||||
* @description 封装文件系统的导航历史、路径操作等逻辑
|
||||
*/
|
||||
|
||||
import { ref, computed } from 'vue'
|
||||
import { Message } from '@arco-design/web-vue'
|
||||
|
||||
/**
|
||||
* 路径历史 localStorage 键
|
||||
*/
|
||||
const STORAGE_KEY_PATH_HISTORY = 'app-filesystem-path-history'
|
||||
|
||||
/**
|
||||
* 导航管理 composable
|
||||
* @param {Object} options - 配置选项
|
||||
* @param {Ref<string>} options.filePath - 当前路径 ref
|
||||
* @param {Function} options.onListDirectory - 列出目录的函数
|
||||
* @param {Function} options.onExitZipMode - 退出 ZIP 模式的函数
|
||||
* @returns {UseNavigationReturn} 导航操作 API
|
||||
*/
|
||||
export function useNavigation(options = {}) {
|
||||
const {
|
||||
filePath,
|
||||
onListDirectory,
|
||||
onExitZipMode,
|
||||
} = options
|
||||
|
||||
// ========== 导航历史记录(支持后退/前进) ==========
|
||||
|
||||
/**
|
||||
* 导航历史栈
|
||||
* @type {Ref<Array<string>>}
|
||||
*/
|
||||
const navHistory = ref([])
|
||||
|
||||
/**
|
||||
* 当前在历史栈中的位置
|
||||
* @type {Ref<number>}
|
||||
*/
|
||||
const navIndex = ref(-1)
|
||||
|
||||
/**
|
||||
* 是否正在导航(防止重复记录)
|
||||
* @type {Ref<boolean>}
|
||||
*/
|
||||
const isNavigating = ref(false)
|
||||
|
||||
/**
|
||||
* 路径历史记录(用于下拉列表)
|
||||
* @type {Ref<Array<string>>}
|
||||
*/
|
||||
const pathHistory = ref([])
|
||||
|
||||
// ========== 计算属性 ==========
|
||||
|
||||
/**
|
||||
* 是否可以后退
|
||||
*/
|
||||
const canGoBack = computed(() => navIndex.value > 0)
|
||||
|
||||
/**
|
||||
* 是否可以前进
|
||||
*/
|
||||
const canGoForward = computed(() => navIndex.value < navHistory.value.length - 1)
|
||||
|
||||
// ========== 导航操作 ==========
|
||||
|
||||
/**
|
||||
* 添加到路径历史记录
|
||||
* @param {string} path - 路径
|
||||
*/
|
||||
const addToHistory = (path) => {
|
||||
if (!path || path === filePath.value) return
|
||||
|
||||
// 去重:如果路径已在历史中,先删除
|
||||
const index = pathHistory.value.indexOf(path)
|
||||
if (index > -1) {
|
||||
pathHistory.value.splice(index, 1)
|
||||
}
|
||||
|
||||
// 添加到开头
|
||||
pathHistory.value.unshift(path)
|
||||
|
||||
// 限制历史记录数量
|
||||
if (pathHistory.value.length > 50) {
|
||||
pathHistory.value = pathHistory.value.slice(0, 50)
|
||||
}
|
||||
|
||||
// 持久化
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY_PATH_HISTORY, JSON.stringify(pathHistory.value))
|
||||
} catch (e) {
|
||||
// 忽略 localStorage 错误
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 推送到导航历史栈
|
||||
* @param {string} path - 路径
|
||||
*/
|
||||
const pushNav = (path) => {
|
||||
if (isNavigating.value) {
|
||||
return
|
||||
}
|
||||
|
||||
// 如果当前位置不在历史末尾,删除后续历史
|
||||
if (navIndex.value < navHistory.value.length - 1) {
|
||||
navHistory.value = navHistory.value.slice(0, navIndex.value + 1)
|
||||
}
|
||||
|
||||
// 添加到历史
|
||||
navHistory.value.push(path)
|
||||
navIndex.value = navHistory.value.length - 1
|
||||
|
||||
// 同时添加到路径历史
|
||||
addToHistory(path)
|
||||
}
|
||||
|
||||
/**
|
||||
* 后退
|
||||
* @returns {Promise<boolean>} 是否成功
|
||||
*/
|
||||
const goBack = async () => {
|
||||
if (!canGoBack.value) {
|
||||
return false
|
||||
}
|
||||
|
||||
isNavigating.value = true
|
||||
try {
|
||||
navIndex.value--
|
||||
const path = navHistory.value[navIndex.value]
|
||||
await onListDirectory(path)
|
||||
return true
|
||||
} catch (error) {
|
||||
Message.error(`后退失败: ${error.message || error}`)
|
||||
return false
|
||||
} finally {
|
||||
isNavigating.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 前进
|
||||
* @returns {Promise<boolean>} 是否成功
|
||||
*/
|
||||
const goForward = async () => {
|
||||
if (!canGoForward.value) {
|
||||
return false
|
||||
}
|
||||
|
||||
isNavigating.value = true
|
||||
try {
|
||||
navIndex.value++
|
||||
const path = navHistory.value[navIndex.value]
|
||||
await onListDirectory(path)
|
||||
return true
|
||||
} catch (error) {
|
||||
Message.error(`前进失败: ${error.message || error}`)
|
||||
return false
|
||||
} finally {
|
||||
isNavigating.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// ========== 路径操作 ==========
|
||||
|
||||
/**
|
||||
* 路径选择(从下拉列表)
|
||||
* @param {string} value - 选中的路径
|
||||
*/
|
||||
const onPathSelect = (value) => {
|
||||
if (value && value !== filePath.value) {
|
||||
goToPath(value)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 路径输入框回车事件
|
||||
*/
|
||||
const onPathEnter = () => {
|
||||
const path = filePath.value?.trim()
|
||||
if (path) {
|
||||
goToPath(path)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 跳转到指定路径
|
||||
* @param {string} path - 目标路径
|
||||
* @returns {Promise<boolean>} 是否成功
|
||||
*/
|
||||
const goToPath = async (path) => {
|
||||
if (!path) {
|
||||
return false
|
||||
}
|
||||
|
||||
// 退出 ZIP 模式
|
||||
if (onExitZipMode) {
|
||||
onExitZipMode()
|
||||
}
|
||||
|
||||
return await onListDirectory(path)
|
||||
}
|
||||
|
||||
/**
|
||||
* 浏览目录(打开系统文件选择对话框)
|
||||
*/
|
||||
const browseDirectory = async () => {
|
||||
Message.info('请手动输入目录路径')
|
||||
}
|
||||
|
||||
// ========== 初始化 ==========
|
||||
|
||||
/**
|
||||
* 加载路径历史记录
|
||||
*/
|
||||
const loadPathHistory = () => {
|
||||
try {
|
||||
const saved = localStorage.getItem(STORAGE_KEY_PATH_HISTORY)
|
||||
if (saved) {
|
||||
pathHistory.value = JSON.parse(saved)
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('[useNavigation] 加载路径历史失败:', e)
|
||||
}
|
||||
}
|
||||
|
||||
// 初始化
|
||||
loadPathHistory()
|
||||
|
||||
return {
|
||||
// 状态
|
||||
navHistory,
|
||||
navIndex,
|
||||
isNavigating,
|
||||
pathHistory,
|
||||
canGoBack,
|
||||
canGoForward,
|
||||
|
||||
// 方法
|
||||
addToHistory,
|
||||
pushNav,
|
||||
goBack,
|
||||
goForward,
|
||||
onPathSelect,
|
||||
onPathEnter,
|
||||
goToPath,
|
||||
browseDirectory,
|
||||
loadPathHistory,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @typedef {Object} UseNavigationReturn
|
||||
* @property {Ref<Array<string>>} navHistory - 导航历史栈
|
||||
* @property {Ref<number>} navIndex - 当前在历史栈中的位置
|
||||
* @property {Ref<boolean>} isNavigating - 是否正在导航
|
||||
* @property {Ref<Array<string>>} pathHistory - 路径历史记录(下拉列表)
|
||||
* @property {ComputedRef<boolean>} canGoBack - 是否可以后退
|
||||
* @property {ComputedRef<boolean>} canGoForward - 是否可以前进
|
||||
* @property {Function} addToHistory - 添加到路径历史记录
|
||||
* @property {Function} pushNav - 推送到导航历史栈
|
||||
* @property {Function} goBack - 后退
|
||||
* @property {Function} goForward - 前进
|
||||
* @property {Function} onPathSelect - 路径选择
|
||||
* @property {Function} onPathEnter - 路径输入框回车事件
|
||||
* @property {Function} goToPath - 跳转到指定路径
|
||||
* @property {Function} browseDirectory - 浏览目录
|
||||
* @property {Function} loadPathHistory - 加载路径历史记录
|
||||
*/
|
||||
60
frontend/src/composables/useTablePage.ts
Normal file
60
frontend/src/composables/useTablePage.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
/**
|
||||
* Table Pagination composable
|
||||
* 表格分页逻辑
|
||||
*/
|
||||
|
||||
import { ref, computed } from 'vue'
|
||||
|
||||
export interface PaginationOptions {
|
||||
pageSize?: number
|
||||
initialPage?: number
|
||||
}
|
||||
|
||||
export function useTablePage(options: PaginationOptions = {}) {
|
||||
const { pageSize = 10, initialPage = 1 } = options
|
||||
|
||||
const currentPage = ref(initialPage)
|
||||
const currentPageSize = ref(pageSize)
|
||||
|
||||
const canGoPrev = computed(() => currentPage.value > 1)
|
||||
const canGoNext = computed(() => currentPage.value * currentPageSize.value < totalItems.value)
|
||||
|
||||
const totalItems = ref(0)
|
||||
const totalPages = computed(() => Math.ceil(totalItems.value / currentPageSize.value))
|
||||
|
||||
const nextPage = () => {
|
||||
if (canGoNext.value) currentPage.value++
|
||||
}
|
||||
|
||||
const prevPage = () => {
|
||||
if (canGoPrev.value) currentPage.value--
|
||||
}
|
||||
|
||||
const goToPage = (page: number) => {
|
||||
if (page >= 1 && page <= totalPages.value) {
|
||||
currentPage.value = page
|
||||
}
|
||||
}
|
||||
|
||||
const reset = () => {
|
||||
currentPage.value = initialPage
|
||||
}
|
||||
|
||||
const setTotalItems = (total: number) => {
|
||||
totalItems.value = total
|
||||
}
|
||||
|
||||
return {
|
||||
currentPage,
|
||||
currentPageSize,
|
||||
canGoPrev,
|
||||
canGoNext,
|
||||
totalItems,
|
||||
totalPages,
|
||||
nextPage,
|
||||
prevPage,
|
||||
goToPage,
|
||||
reset,
|
||||
setTotalItems
|
||||
}
|
||||
}
|
||||
117
frontend/src/composables/useTimeout.ts
Normal file
117
frontend/src/composables/useTimeout.ts
Normal file
@@ -0,0 +1,117 @@
|
||||
/**
|
||||
* 定时器管理 Hook
|
||||
* 自动管理定时器生命周期,防止内存泄漏
|
||||
*
|
||||
* @module composables/useTimeout
|
||||
* @description 提供类型安全的定时器管理,组件卸载时自动清理所有定时器
|
||||
*/
|
||||
|
||||
import { ref, onUnmounted } from 'vue'
|
||||
|
||||
export interface TimeoutOptions {
|
||||
/**
|
||||
* 是否在组件卸载时自动清理所有定时器
|
||||
* @default true
|
||||
*/
|
||||
autoCleanup?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* 定时器管理 Hook
|
||||
*
|
||||
* @param options - 配置选项
|
||||
* @returns 定时器管理方法
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const { setTimeout, clearTimeout, clearAll } = useTimeout()
|
||||
*
|
||||
* // 设置延迟执行
|
||||
* const timer = setTimeout(() => {
|
||||
* console.log('延迟执行')
|
||||
* }, 1000)
|
||||
*
|
||||
* // 清除特定定时器
|
||||
* clearTimeout(timer)
|
||||
*
|
||||
* // 清除所有定时器
|
||||
* clearAll()
|
||||
* ```
|
||||
*/
|
||||
export function useTimeout(options: TimeoutOptions = {}) {
|
||||
const { autoCleanup = true } = options
|
||||
|
||||
// 使用 Set 存储所有定时器 ID
|
||||
const timers = ref<Set<NodeJS.Timeout>>(new Set())
|
||||
|
||||
/**
|
||||
* 设置定时器(自动管理生命周期)
|
||||
* @param callback - 要执行的回调函数
|
||||
* @param delay - 延迟时间(毫秒)
|
||||
* @returns 定时器 ID
|
||||
*/
|
||||
const setTimeout = <T = void>(
|
||||
callback: () => T,
|
||||
delay: number
|
||||
): NodeJS.Timeout => {
|
||||
const timer = window.setTimeout(() => {
|
||||
try {
|
||||
callback()
|
||||
} finally {
|
||||
// 执行完成后自动从集合中移除
|
||||
timers.value.delete(timer)
|
||||
}
|
||||
}, delay)
|
||||
|
||||
// 添加到集合中
|
||||
timers.value.add(timer)
|
||||
|
||||
return timer
|
||||
}
|
||||
|
||||
/**
|
||||
* 清除特定定时器
|
||||
* @param timer - 要清除的定时器 ID
|
||||
*/
|
||||
const clearTimeout = (timer: NodeJS.Timeout) => {
|
||||
window.clearTimeout(timer)
|
||||
timers.value.delete(timer)
|
||||
}
|
||||
|
||||
/**
|
||||
* 清除所有定时器
|
||||
*/
|
||||
const clearAll = () => {
|
||||
timers.value.forEach((timer) => {
|
||||
window.clearTimeout(timer)
|
||||
})
|
||||
timers.value.clear()
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前活跃的定时器数量
|
||||
*/
|
||||
const getActiveCount = () => timers.value.size
|
||||
|
||||
// 组件卸载时自动清理
|
||||
if (autoCleanup) {
|
||||
onUnmounted(() => {
|
||||
clearAll()
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
setTimeout,
|
||||
clearTimeout,
|
||||
clearAll,
|
||||
getActiveCount
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 定时器管理 Hook 的别名
|
||||
* 便于语义化使用(如延迟执行、防抖等场景)
|
||||
*/
|
||||
export const useDelay = useTimeout
|
||||
|
||||
export default useTimeout
|
||||
18
frontend/src/main.js
Normal file
18
frontend/src/main.js
Normal file
@@ -0,0 +1,18 @@
|
||||
import { createApp } from 'vue'
|
||||
import { createPinia } from 'pinia'
|
||||
// Arco Design 组件 CSS 按需加载(sideEffect: true)
|
||||
import './style.css'
|
||||
import App from './App.vue'
|
||||
import { useThemeStore } from './stores/theme'
|
||||
|
||||
const app = createApp(App)
|
||||
const pinia = createPinia()
|
||||
|
||||
app.use(pinia)
|
||||
|
||||
// 在应用挂载前初始化主题(需要先初始化 Pinia)
|
||||
const themeStore = useThemeStore()
|
||||
themeStore.initTheme()
|
||||
|
||||
app.mount('#app')
|
||||
|
||||
186
frontend/src/stores/config.ts
Normal file
186
frontend/src/stores/config.ts
Normal file
@@ -0,0 +1,186 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref, computed } from 'vue'
|
||||
import { Message } from '@arco-design/web-vue'
|
||||
import { GetAppConfig, SaveAppConfig } from '../wailsjs/v3-bindings/u-desk/app'
|
||||
|
||||
/**
|
||||
* Tab 配置类型
|
||||
*/
|
||||
interface TabConfig {
|
||||
key: string
|
||||
title: string
|
||||
visible: boolean
|
||||
enabled: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* 应用配置类型
|
||||
*/
|
||||
export interface AppConfig {
|
||||
tabs: TabConfig[]
|
||||
visibleTabs: string[]
|
||||
defaultTab: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 应用配置管理 Store
|
||||
* 统一管理应用配置(标签页、默认页等)
|
||||
*/
|
||||
export const useConfigStore = defineStore('config', () => {
|
||||
// ==================== 状态 ====================
|
||||
const appConfig = ref<AppConfig>({
|
||||
tabs: [],
|
||||
visibleTabs: [],
|
||||
defaultTab: 'file-system'
|
||||
})
|
||||
|
||||
const loading = ref(false)
|
||||
|
||||
// ==================== 计算属性 ====================
|
||||
/**
|
||||
* 可见 Tabs(根据配置动态生成)
|
||||
*/
|
||||
const visibleTabs = computed(() => {
|
||||
const tabs = appConfig.value.tabs
|
||||
|
||||
if (!tabs?.length) {
|
||||
return [
|
||||
{ key: 'file-system', title: '文件管理' }
|
||||
]
|
||||
}
|
||||
|
||||
const { visibleTabs: order } = appConfig.value
|
||||
return tabs
|
||||
.filter(tab => tab.visible)
|
||||
.sort((a, b) => order.indexOf(a.key) - order.indexOf(b.key))
|
||||
})
|
||||
|
||||
/**
|
||||
* 所有可用 Tabs
|
||||
*/
|
||||
const allTabs = computed(() => appConfig.value.tabs)
|
||||
|
||||
/**
|
||||
* 默认 Tab
|
||||
*/
|
||||
const defaultTab = computed(() => appConfig.value.defaultTab)
|
||||
|
||||
// ==================== 核心方法 ====================
|
||||
let _retryCount = 0
|
||||
const MAX_RETRIES = 30 // 最多重试30次(约30秒)
|
||||
|
||||
/**
|
||||
* 加载配置
|
||||
*/
|
||||
const loadConfig = async () => {
|
||||
loading.value = true
|
||||
|
||||
try {
|
||||
const result = await GetAppConfig()
|
||||
if (!result.success) throw new Error(result.message)
|
||||
|
||||
const { tabs = [], visibleTabs = [], defaultTab = 'file-system' } = result.data
|
||||
|
||||
// 一级 Tab 只有文件管理和数据库,其他功能(Markdown、版本历史)不作为独立 Tab
|
||||
const allKeys = ['file-system']
|
||||
const tabTitles: Record<string, string> = { 'file-system': '文件管理' }
|
||||
const mergedTabs = allKeys.map(key => tabs.find(t => t.key === key) || { key, title: tabTitles[key] || key, enabled: true })
|
||||
const mergedVisible = visibleTabs.length
|
||||
? visibleTabs.filter(k => allKeys.includes(k))
|
||||
: allKeys
|
||||
|
||||
appConfig.value = {
|
||||
tabs: mergedTabs.map(tab => ({ ...tab, visible: mergedVisible.includes(tab.key) })),
|
||||
visibleTabs: mergedVisible,
|
||||
defaultTab: defaultTab || 'file-system'
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载配置失败:', error)
|
||||
useDefaultConfig()
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 使用默认配置
|
||||
*/
|
||||
const useDefaultConfig = () => {
|
||||
appConfig.value = {
|
||||
tabs: [
|
||||
{ key: 'file-system', title: '文件管理', visible: true, enabled: true }
|
||||
],
|
||||
visibleTabs: ['file-system'],
|
||||
defaultTab: 'file-system'
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存配置
|
||||
*/
|
||||
const saveConfig = async (config: AppConfig) => {
|
||||
loading.value = true
|
||||
|
||||
try {
|
||||
const result = await SaveAppConfig({
|
||||
tabs: config.tabs,
|
||||
visibleTabs: config.visibleTabs,
|
||||
defaultTab: config.defaultTab
|
||||
})
|
||||
|
||||
if (!result.success) {
|
||||
Message.error(result.message || '保存配置失败')
|
||||
throw new Error(result.message)
|
||||
}
|
||||
|
||||
// 更新本地配置
|
||||
appConfig.value = {
|
||||
tabs: [...config.tabs],
|
||||
visibleTabs: [...config.visibleTabs],
|
||||
defaultTab: config.defaultTab
|
||||
}
|
||||
|
||||
Message.success('配置保存成功')
|
||||
return true
|
||||
} catch (error) {
|
||||
console.error('保存配置失败:', error)
|
||||
const message = error instanceof Error ? error.message : '保存配置失败'
|
||||
Message.error('保存配置失败:' + message)
|
||||
throw error
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查 Tab 是否可见
|
||||
*/
|
||||
const isTabVisible = (tabKey: string) => {
|
||||
return appConfig.value.visibleTabs.includes(tabKey)
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取 Tab 配置
|
||||
*/
|
||||
const getTab = (tabKey: string) => {
|
||||
return appConfig.value.tabs.find(tab => tab.key === tabKey)
|
||||
}
|
||||
|
||||
// ==================== 返回 ====================
|
||||
return {
|
||||
// 状态
|
||||
appConfig,
|
||||
loading,
|
||||
|
||||
// 计算属性
|
||||
visibleTabs,
|
||||
allTabs,
|
||||
defaultTab,
|
||||
|
||||
// 方法
|
||||
loadConfig,
|
||||
saveConfig,
|
||||
isTabVisible,
|
||||
getTab
|
||||
}
|
||||
})
|
||||
44
frontend/src/stores/connection.ts
Normal file
44
frontend/src/stores/connection.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
/**
|
||||
* 连接状态 Pinia Store
|
||||
*/
|
||||
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref, computed } from 'vue'
|
||||
import { connectionManager, type ConnectionProfile, type ConnectionState } from '@/api/connection-manager'
|
||||
|
||||
export const useConnectionStore = defineStore('connection', () => {
|
||||
const state = ref<ConnectionState>(connectionManager.state)
|
||||
const activeProfile = ref<ConnectionProfile | null>(connectionManager.activeProfile)
|
||||
|
||||
connectionManager.onStateChange((s) => { state.value = s })
|
||||
|
||||
const isConnected = computed(() => state.value === 'connected')
|
||||
const isRemote = computed(() => connectionManager.isRemote())
|
||||
const fileServerBaseURL = computed(() => connectionManager.getFileServerBaseURL())
|
||||
|
||||
function connect(id: string) {
|
||||
connectionManager.connect(id)
|
||||
activeProfile.value = connectionManager.activeProfile
|
||||
}
|
||||
|
||||
function disconnect() {
|
||||
connectionManager.disconnect()
|
||||
activeProfile.value = connectionManager.activeProfile
|
||||
}
|
||||
|
||||
function refresh() {
|
||||
activeProfile.value = connectionManager.activeProfile
|
||||
state.value = connectionManager.state
|
||||
}
|
||||
|
||||
return {
|
||||
state,
|
||||
activeProfile,
|
||||
isConnected,
|
||||
isRemote,
|
||||
fileServerBaseURL,
|
||||
connect,
|
||||
disconnect,
|
||||
refresh,
|
||||
}
|
||||
})
|
||||
99
frontend/src/stores/theme.ts
Normal file
99
frontend/src/stores/theme.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref, computed } from 'vue'
|
||||
import { SetWindowTitleBarColor } from '../wailsjs/v3-bindings/u-desk/app'
|
||||
|
||||
type Theme = 'light' | 'dark'
|
||||
|
||||
const THEME_STORAGE_KEY = 'app-theme'
|
||||
|
||||
// 标题栏颜色(0x00BBGGRR)
|
||||
const TITLE_BAR_LIGHT = 0xF0F0F0 // #F0F0F0 近白
|
||||
const TITLE_BAR_DARK = 0x2D2D2D // #2D2D2D 深灰
|
||||
|
||||
/**
|
||||
* 主题管理 Store
|
||||
* 统一管理应用主题(亮色/暗色)及标题栏颜色
|
||||
*/
|
||||
export const useThemeStore = defineStore('theme', () => {
|
||||
// ==================== 状态 ====================
|
||||
const theme = ref<Theme>('light')
|
||||
let systemThemeListener: (() => void) | null = null
|
||||
|
||||
// ==================== 计算属性 ====================
|
||||
const isDark = computed(() => theme.value === 'dark')
|
||||
const tooltipText = computed(() =>
|
||||
isDark.value ? '切换到亮色主题' : '切换到夜间主题'
|
||||
)
|
||||
|
||||
// ==================== 核心方法 ====================
|
||||
/**
|
||||
* 应用主题到 DOM + 同步标题栏颜色
|
||||
*/
|
||||
const applyTheme = (newTheme: Theme) => {
|
||||
theme.value = newTheme
|
||||
|
||||
// 更新 DOM 属性
|
||||
const method = newTheme === 'dark' ? 'setAttribute' : 'removeAttribute'
|
||||
document.body[method]('arco-theme', 'dark')
|
||||
|
||||
// 同步 Windows 原生标题栏颜色 + 主题模式
|
||||
try {
|
||||
const color = newTheme === 'dark' ? TITLE_BAR_DARK : TITLE_BAR_LIGHT
|
||||
SetWindowTitleBarColor(color, newTheme === 'dark')
|
||||
} catch { /* 非 Windows 环境忽略 */ }
|
||||
|
||||
// 持久化
|
||||
localStorage.setItem(THEME_STORAGE_KEY, newTheme)
|
||||
}
|
||||
|
||||
/**
|
||||
* 切换主题
|
||||
*/
|
||||
const toggleTheme = () => {
|
||||
const newTheme: Theme = theme.value === 'light' ? 'dark' : 'light'
|
||||
applyTheme(newTheme)
|
||||
}
|
||||
|
||||
/**
|
||||
* 初始化主题(应用启动时调用)
|
||||
*/
|
||||
const initTheme = () => {
|
||||
// 加载保存的主题或使用系统偏好
|
||||
const savedTheme = localStorage.getItem(THEME_STORAGE_KEY) as Theme
|
||||
const isValidTheme = savedTheme === 'light' || savedTheme === 'dark'
|
||||
|
||||
if (isValidTheme) {
|
||||
applyTheme(savedTheme)
|
||||
} else {
|
||||
const prefersDark = window.matchMedia?.('(prefers-color-scheme: dark)').matches
|
||||
applyTheme(prefersDark ? 'dark' : 'light')
|
||||
}
|
||||
|
||||
// 监听系统主题变化(仅在未手动设置时)
|
||||
if (!window.matchMedia) return
|
||||
|
||||
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)')
|
||||
const handleChange = (e: MediaQueryListEvent) => {
|
||||
if (!localStorage.getItem(THEME_STORAGE_KEY)) {
|
||||
applyTheme(e.matches ? 'dark' : 'light')
|
||||
}
|
||||
}
|
||||
|
||||
mediaQuery.addEventListener('change', handleChange)
|
||||
systemThemeListener = () => mediaQuery.removeEventListener('change', handleChange)
|
||||
}
|
||||
|
||||
// ==================== 返回 ====================
|
||||
return {
|
||||
// 状态
|
||||
theme,
|
||||
|
||||
// 计算属性
|
||||
isDark,
|
||||
tooltipText,
|
||||
|
||||
// 方法
|
||||
toggleTheme,
|
||||
initTheme
|
||||
}
|
||||
})
|
||||
286
frontend/src/stores/update.ts
Normal file
286
frontend/src/stores/update.ts
Normal file
@@ -0,0 +1,286 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
import { Message } from '@arco-design/web-vue'
|
||||
import { formatBytes as formatFileSize } from '@/utils/fileUtils'
|
||||
import {
|
||||
CheckUpdate, GetUpdateConfig, DownloadUpdate, InstallUpdate
|
||||
} from '../wailsjs/v3-bindings/u-desk/app'
|
||||
import { On, Off } from '@wailsio/events'
|
||||
|
||||
/**
|
||||
* 更新管理 Store
|
||||
* 统一管理版本检查、下载、安装等更新相关逻辑
|
||||
*/
|
||||
export const useUpdateStore = defineStore('update', () => {
|
||||
// ==================== 状态 ====================
|
||||
const updateInfo = ref<UpdateInfo | null>(null)
|
||||
const showUpdate = ref(false)
|
||||
const checking = ref(false)
|
||||
const downloading = ref(false)
|
||||
const installing = ref(false)
|
||||
const downloadProgress = ref(0)
|
||||
const downloadStatus = ref<'active' | 'exception' | 'success'>('active')
|
||||
const progressInfo = ref({
|
||||
speed: 0,
|
||||
downloaded: 0,
|
||||
total: 0
|
||||
})
|
||||
|
||||
// 节流:防止过度更新
|
||||
let lastUpdateTime = 0
|
||||
const UPDATE_THROTTLE = 100 // 100ms 最小更新间隔
|
||||
|
||||
// 最小显示时间:确保进度条至少显示 5 秒
|
||||
let downloadStartTime = 0
|
||||
const MIN_DISPLAY_TIME = 5000 // 5 秒最小显示时间
|
||||
|
||||
// ==================== 工具函数 ====================
|
||||
const parseEventData = (event: unknown) => {
|
||||
try {
|
||||
return typeof event === 'string' ? JSON.parse(event) : (event as Record<string, unknown>)
|
||||
} catch {
|
||||
return {}
|
||||
}
|
||||
}
|
||||
|
||||
const formatSpeed = (bytesPerSecond: number): string => {
|
||||
return formatFileSize(bytesPerSecond) + '/s'
|
||||
}
|
||||
|
||||
// ==================== 核心方法 ====================
|
||||
/**
|
||||
* 检查更新
|
||||
* @param silent 是否静默模式(不显示消息)
|
||||
*/
|
||||
const checkForUpdates = async (silent = false) => {
|
||||
if (checking.value) return
|
||||
|
||||
checking.value = true
|
||||
|
||||
try {
|
||||
const configResult = await GetUpdateConfig()
|
||||
if (!configResult.success) return
|
||||
|
||||
const { auto_check_enabled } = configResult.data || {}
|
||||
if (!auto_check_enabled) return
|
||||
|
||||
const result = await CheckUpdate()
|
||||
if (result.success && result.data?.has_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('发现新版本!')
|
||||
}
|
||||
} else if (!silent) {
|
||||
Message.success('已是最新版本')
|
||||
}
|
||||
} catch (error) {
|
||||
if (!silent) {
|
||||
console.error('检查更新失败:', error)
|
||||
Message.error('检查更新失败:' + (error as Error).message)
|
||||
}
|
||||
} finally {
|
||||
checking.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 下载更新
|
||||
*/
|
||||
const downloadUpdate = async () => {
|
||||
const url = updateInfo.value?.download_url
|
||||
if (!url) {
|
||||
Message.warning('下载地址不存在')
|
||||
return
|
||||
}
|
||||
|
||||
// 记录开始时间
|
||||
downloadStartTime = Date.now()
|
||||
|
||||
// 重置下载状态
|
||||
downloading.value = true
|
||||
downloadProgress.value = 1 // 设置为 1 而不是 0,确保进度条显示
|
||||
downloadStatus.value = 'active'
|
||||
progressInfo.value = { speed: 0, downloaded: 0, total: 0 }
|
||||
|
||||
try {
|
||||
const result = await DownloadUpdate(url)
|
||||
if (!result.success) {
|
||||
downloadStatus.value = 'exception'
|
||||
downloading.value = false
|
||||
Message.error(result.message || '下载启动失败')
|
||||
return
|
||||
}
|
||||
Message.success('下载请求已发送,等待后端发送进度事件...')
|
||||
} catch (error) {
|
||||
console.error('下载失败:', error)
|
||||
downloadStatus.value = 'exception'
|
||||
downloading.value = false
|
||||
Message.error('下载失败:' + (error as Error).message)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 安装更新
|
||||
*/
|
||||
const installUpdate = async (filePath: string) => {
|
||||
if (!filePath) {
|
||||
Message.warning('请先下载更新包')
|
||||
return
|
||||
}
|
||||
|
||||
installing.value = true
|
||||
|
||||
try {
|
||||
const result = await InstallUpdate(filePath, true)
|
||||
if (result.success) {
|
||||
Message.success({
|
||||
content: '安装成功!应用将在几秒后重启...',
|
||||
duration: 3000
|
||||
})
|
||||
return
|
||||
}
|
||||
Message.error(result.message || '安装失败')
|
||||
} catch (error) {
|
||||
Message.error('安装失败:' + (error as Error).message)
|
||||
} finally {
|
||||
installing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 下载进度处理
|
||||
*/
|
||||
const onDownloadProgress = (event: unknown) => {
|
||||
const now = Date.now()
|
||||
if (now - lastUpdateTime < UPDATE_THROTTLE) {
|
||||
return
|
||||
}
|
||||
|
||||
lastUpdateTime = now
|
||||
const data = parseEventData(event)
|
||||
|
||||
progressInfo.value = {
|
||||
speed: (data.speed as number) || 0,
|
||||
downloaded: (data.downloaded as number) || 0,
|
||||
total: (data.total as number) || 0
|
||||
}
|
||||
|
||||
const rawProgress = Number(data.progress) || 0
|
||||
const safeProgress = Math.min(100, Math.max(0, Math.round(rawProgress)))
|
||||
downloadProgress.value = safeProgress
|
||||
}
|
||||
|
||||
/**
|
||||
* 下载完成处理
|
||||
*/
|
||||
const onDownloadComplete = (event: unknown) => {
|
||||
const data = parseEventData(event)
|
||||
|
||||
// 错误处理
|
||||
if (data.error) {
|
||||
console.error('下载失败:', data.error)
|
||||
downloadStatus.value = 'exception'
|
||||
downloading.value = false
|
||||
Message.error('下载失败:' + data.error)
|
||||
return
|
||||
}
|
||||
|
||||
// 数据验证
|
||||
if (!data.success || !data.file_path) {
|
||||
console.error('下载数据不完整:', data)
|
||||
downloadStatus.value = 'exception'
|
||||
downloading.value = false
|
||||
Message.error('下载完成但数据不完整')
|
||||
return
|
||||
}
|
||||
|
||||
// 完成下载
|
||||
downloadProgress.value = 100
|
||||
downloadStatus.value = 'success'
|
||||
|
||||
const fileSize = (data.file_size as number) || 0
|
||||
|
||||
// 系统通知:下载完成
|
||||
try {
|
||||
window.runtime?.SendNotification?.({
|
||||
title: 'U-Desk',
|
||||
body: `更新包下载完成 (${formatFileSize(fileSize)}),正在安装...`
|
||||
})
|
||||
} catch { /* 通知不可用时忽略 */ }
|
||||
progressInfo.value = {
|
||||
speed: 0,
|
||||
downloaded: fileSize,
|
||||
total: fileSize
|
||||
}
|
||||
|
||||
// 计算已经显示的时间
|
||||
const elapsed = Date.now() - downloadStartTime
|
||||
const remainingTime = Math.max(0, MIN_DISPLAY_TIME - elapsed)
|
||||
|
||||
// 确保进度条至少显示 3 秒
|
||||
setTimeout(() => {
|
||||
downloading.value = false // 安装前才关闭下载状态
|
||||
installUpdate(data.file_path as string)
|
||||
}, remainingTime)
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置事件监听
|
||||
*/
|
||||
const setupEventListeners = () => {
|
||||
On('download-progress', onDownloadProgress)
|
||||
On('download-complete', onDownloadComplete)
|
||||
}
|
||||
|
||||
/**
|
||||
* 移除事件监听
|
||||
*/
|
||||
const removeEventListeners = () => {
|
||||
Off('download-progress')
|
||||
Off('download-complete')
|
||||
}
|
||||
|
||||
// ==================== 返回 ====================
|
||||
return {
|
||||
// 状态
|
||||
updateInfo,
|
||||
showUpdate,
|
||||
checking,
|
||||
downloading,
|
||||
installing,
|
||||
downloadProgress,
|
||||
downloadStatus,
|
||||
progressInfo,
|
||||
|
||||
// 方法
|
||||
checkForUpdates,
|
||||
downloadUpdate,
|
||||
installUpdate,
|
||||
setupEventListeners,
|
||||
removeEventListeners,
|
||||
formatFileSize,
|
||||
formatSpeed
|
||||
}
|
||||
})
|
||||
|
||||
// ==================== 类型定义 ====================
|
||||
interface UpdateInfo {
|
||||
has_update: boolean
|
||||
current_version: string
|
||||
latest_version: string
|
||||
download_url: string
|
||||
changelog: string
|
||||
force_update: boolean
|
||||
release_date: string
|
||||
file_size: number
|
||||
}
|
||||
431
frontend/src/style.css
Normal file
431
frontend/src/style.css
Normal file
@@ -0,0 +1,431 @@
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
#app {
|
||||
width: 100%;
|
||||
height: 100vh;
|
||||
}
|
||||
|
||||
/* 滚动条样式优化 */
|
||||
/* Webkit浏览器 (Chrome, Safari, Edge) */
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: var(--color-border-2, rgba(0, 0, 0, 0.1));
|
||||
border-radius: 4px;
|
||||
border: 2px solid transparent;
|
||||
background-clip: padding-box;
|
||||
transition: background-color 0.2s ease;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--color-border-3, rgba(0, 0, 0, 0.2));
|
||||
background-clip: padding-box;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-corner {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
/* Firefox */
|
||||
* {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: var(--color-border-2, rgba(0, 0, 0, 0.1)) transparent;
|
||||
}
|
||||
|
||||
/* 暗色细滚动条(用于编辑器/预览区) */
|
||||
.thin-dark-scrollbar {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: var(--color-border-3) transparent;
|
||||
}
|
||||
|
||||
.thin-dark-scrollbar::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
.thin-dark-scrollbar::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.thin-dark-scrollbar::-webkit-scrollbar-thumb {
|
||||
background: var(--color-border-3);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.thin-dark-scrollbar::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--color-border-2);
|
||||
}
|
||||
|
||||
/* Highlight.js CSS */
|
||||
.hljs {
|
||||
display: block;
|
||||
overflow-x: auto;
|
||||
padding: 1em;
|
||||
background: #f6f8fa;
|
||||
border-radius: 6px;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.hljs-comment,
|
||||
.hljs-quote {
|
||||
color: #6a737d;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.hljs-keyword,
|
||||
.hljs-selector-tag,
|
||||
.hljs-addition {
|
||||
color: #d73a49;
|
||||
}
|
||||
|
||||
.hljs-number,
|
||||
.hljs-string,
|
||||
.hljs-meta .hljs-string,
|
||||
.hljs-literal,
|
||||
.hljs-doctag,
|
||||
.hljs-regexp {
|
||||
color: #032f62;
|
||||
}
|
||||
|
||||
.hljs-title,
|
||||
.hljs-section,
|
||||
.hljs-name,
|
||||
.hljs-selector-id,
|
||||
.hljs-selector-class {
|
||||
color: #6f42c1;
|
||||
}
|
||||
|
||||
.hljs-attribute,
|
||||
.hljs-attr,
|
||||
.hljs-variable,
|
||||
.hljs-template-variable,
|
||||
.hljs-class .hljs-title,
|
||||
.hljs-type {
|
||||
color: #005cc5;
|
||||
}
|
||||
|
||||
.hljs-symbol,
|
||||
.hljs-bullet,
|
||||
.hljs-subst,
|
||||
.hljs-meta,
|
||||
.hljs-meta .hljs-keyword,
|
||||
.hljs-selector-attr,
|
||||
.hljs-selector-pseudo,
|
||||
.hljs-link {
|
||||
color: #735c0f;
|
||||
}
|
||||
|
||||
.hljs-built_in,
|
||||
.hljs-deletion {
|
||||
color: #d73a49;
|
||||
}
|
||||
|
||||
.hljs-formula {
|
||||
background: #f6f8fa;
|
||||
}
|
||||
|
||||
.hljs-emphasis {
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.hljs-strong {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
/* GitHub 风格的 Markdown 预览样式 */
|
||||
.markdown-body {
|
||||
line-height: 1.6;
|
||||
color: #333;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.markdown-body h1,
|
||||
.markdown-body h2,
|
||||
.markdown-body h3,
|
||||
.markdown-body h4,
|
||||
.markdown-body h5,
|
||||
.markdown-body h6 {
|
||||
margin-top: 24px;
|
||||
margin-bottom: 16px;
|
||||
font-weight: 600;
|
||||
line-height: 1.25;
|
||||
}
|
||||
|
||||
.markdown-body h1 { font-size: 2em; border-bottom: 1px solid #eaecef; padding-bottom: 0.3em; }
|
||||
.markdown-body h2 { font-size: 1.5em; border-bottom: 1px solid #eaecef; padding-bottom: 0.3em; }
|
||||
.markdown-body h3 { font-size: 1.25em; }
|
||||
.markdown-body h4 { font-size: 1em; }
|
||||
.markdown-body h5 { font-size: 0.875em; }
|
||||
.markdown-body h6 { font-size: 0.85em; color: #6a737d; }
|
||||
|
||||
.markdown-body p {
|
||||
margin-top: 0;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.markdown-body ul,
|
||||
.markdown-body ol {
|
||||
padding-left: 2em;
|
||||
margin-top: 0;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.markdown-body li {
|
||||
margin-top: 0.25em;
|
||||
margin-bottom: 0.25em;
|
||||
}
|
||||
|
||||
.markdown-body blockquote {
|
||||
padding: 0 1em;
|
||||
color: #6a737d;
|
||||
border-left: 0.25em solid #dfe2e5;
|
||||
margin: 0 0 16px 0;
|
||||
}
|
||||
|
||||
.markdown-body code {
|
||||
padding: 0.2em 0.4em;
|
||||
margin: 0;
|
||||
font-size: 85%;
|
||||
background-color: rgba(27, 31, 35, 0.05);
|
||||
border-radius: 3px;
|
||||
font-family: 'SF Mono', Monaco, Consolas, 'Courier New', monospace;
|
||||
}
|
||||
|
||||
.markdown-body pre {
|
||||
padding: 16px;
|
||||
overflow: auto;
|
||||
font-size: 85%;
|
||||
line-height: 1.45;
|
||||
background-color: #f6f8fa;
|
||||
border-radius: 6px;
|
||||
margin-top: 0;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.markdown-body pre code {
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
background-color: transparent;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.markdown-body table {
|
||||
border-spacing: 0;
|
||||
border-collapse: collapse;
|
||||
margin-top: 0;
|
||||
margin-bottom: 16px;
|
||||
display: block;
|
||||
width: 100%;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.markdown-body table th,
|
||||
.markdown-body table td {
|
||||
padding: 6px 13px;
|
||||
border: 1px solid #dfe2e5;
|
||||
}
|
||||
|
||||
.markdown-body table th {
|
||||
font-weight: 600;
|
||||
background-color: #f6f8fa;
|
||||
}
|
||||
|
||||
.markdown-body table tr:nth-child(even) {
|
||||
background-color: #f8f8f8;
|
||||
}
|
||||
|
||||
.markdown-body img {
|
||||
max-width: 100%;
|
||||
box-sizing: content-box;
|
||||
background-color: #fff;
|
||||
}
|
||||
|
||||
.markdown-body hr {
|
||||
height: 0.25em;
|
||||
padding: 0;
|
||||
margin: 24px 0;
|
||||
background-color: #e1e4e8;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
/* 打印样式 */
|
||||
@media print {
|
||||
.no-print {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.markdown-body {
|
||||
max-width: 100%;
|
||||
margin: 0;
|
||||
padding: 20px;
|
||||
font-size: 12pt;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.markdown-body h1 {
|
||||
font-size: 24pt;
|
||||
margin-bottom: 12pt;
|
||||
border-bottom: 1px solid #ccc;
|
||||
}
|
||||
|
||||
.markdown-body h2 {
|
||||
font-size: 18pt;
|
||||
margin-bottom: 10pt;
|
||||
border-bottom: 1px solid #eee;
|
||||
}
|
||||
|
||||
.markdown-body h3 {
|
||||
font-size: 14pt;
|
||||
margin-bottom: 8pt;
|
||||
}
|
||||
|
||||
.markdown-body p {
|
||||
margin-bottom: 10pt;
|
||||
}
|
||||
|
||||
.markdown-body ul,
|
||||
.markdown-body ol {
|
||||
margin-bottom: 10pt;
|
||||
}
|
||||
|
||||
.markdown-body li {
|
||||
margin-bottom: 4pt;
|
||||
}
|
||||
|
||||
.markdown-body table {
|
||||
border-collapse: collapse;
|
||||
margin-bottom: 12pt;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.markdown-body th,
|
||||
.markdown-body td {
|
||||
border: 1px solid #ddd;
|
||||
padding: 8px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.markdown-body th {
|
||||
background-color: #f5f5f5;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.markdown-body img {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.markdown-body blockquote {
|
||||
border-left: 4px solid #ddd;
|
||||
margin: 16px 0;
|
||||
padding: 10px 20px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.markdown-body code {
|
||||
background-color: #f5f5f5;
|
||||
padding: 2px 4px;
|
||||
border-radius: 3px;
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
.markdown-body pre {
|
||||
background-color: #f5f5f5;
|
||||
padding: 12px;
|
||||
border-radius: 4px;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.markdown-body pre code {
|
||||
background-color: transparent;
|
||||
padding: 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* Markdown 标题锚点链接样式 */
|
||||
.heading {
|
||||
position: relative;
|
||||
scroll-margin-top: 20px; /* 锚点跳转时的顶部偏移 */
|
||||
}
|
||||
|
||||
.heading-anchor {
|
||||
opacity: 0;
|
||||
margin-left: 8px;
|
||||
color: rgb(var(--primary-6));
|
||||
text-decoration: none;
|
||||
font-size: 0.8em;
|
||||
font-weight: normal;
|
||||
transition: opacity 0.2s ease;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.heading:hover .heading-anchor {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.heading-anchor:hover {
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.heading-anchor:focus {
|
||||
opacity: 1;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
/* 平滑滚动 */
|
||||
html {
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
|
||||
/* 拖拽分割条时禁止文本选中 */
|
||||
body.resizing {
|
||||
user-select: none !important;
|
||||
cursor: inherit !important;
|
||||
}
|
||||
|
||||
/* Tooltip 全局样式 */
|
||||
.arco-tooltip {
|
||||
font-size: 12px !important;
|
||||
}
|
||||
|
||||
.arco-tooltip-content {
|
||||
background: var(--color-bg-5) !important;
|
||||
color: var(--color-text-1) !important;
|
||||
padding: 6px 10px !important;
|
||||
border-radius: 6px !important;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.12) !important;
|
||||
max-width: 240px;
|
||||
line-height: 1.5;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.arco-tooltip-content::before {
|
||||
background: var(--color-bg-5) !important;
|
||||
}
|
||||
|
||||
.arco-tooltip-content-white {
|
||||
background: var(--color-bg-1) !important;
|
||||
border: 1px solid var(--color-border-2) !important;
|
||||
color: var(--color-text-1) !important;
|
||||
}
|
||||
|
||||
.arco-tooltip-content-white::before {
|
||||
background: var(--color-bg-1) !important;
|
||||
}
|
||||
311
frontend/src/types/file-system.ts
Normal file
311
frontend/src/types/file-system.ts
Normal file
@@ -0,0 +1,311 @@
|
||||
/**
|
||||
* 文件系统类型定义
|
||||
* @module file-system
|
||||
*/
|
||||
|
||||
/**
|
||||
* 文件项
|
||||
*/
|
||||
export interface FileItem {
|
||||
/** 文件名 */
|
||||
name: string
|
||||
/** 完整路径 */
|
||||
path: string
|
||||
/** 文件大小(字节) */
|
||||
size: number
|
||||
/** 是否为目录 */
|
||||
isDir: boolean
|
||||
/** 修改时间 */
|
||||
modified_time?: string
|
||||
/** 是否被收藏(运行时属性) */
|
||||
is_favorite?: boolean
|
||||
/** 旧路径(仅重命名操作时存在) */
|
||||
old_path?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 收藏文件
|
||||
*/
|
||||
export interface FavoriteFile extends FileItem {
|
||||
/** 添加时间(时间戳) */
|
||||
addedAt: number
|
||||
/** 置顶时间(时间戳),undefined 表示未置顶 */
|
||||
pinnedAt?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* 文件类型枚举
|
||||
*/
|
||||
export enum FileType {
|
||||
/** 图片 */
|
||||
Image = 'image',
|
||||
/** 视频 */
|
||||
Video = 'video',
|
||||
/** 音频 */
|
||||
Audio = 'audio',
|
||||
/** PDF */
|
||||
Pdf = 'pdf',
|
||||
/** HTML */
|
||||
Html = 'html',
|
||||
/** Markdown */
|
||||
Markdown = 'markdown',
|
||||
/** 代码 */
|
||||
Code = 'code',
|
||||
/** 文本 */
|
||||
Text = 'text',
|
||||
/** 二进制 */
|
||||
Binary = 'binary'
|
||||
}
|
||||
|
||||
/**
|
||||
* 拖拽状态
|
||||
*/
|
||||
export interface DraggingState {
|
||||
/** 是否正在拖拽 */
|
||||
isDragging: boolean
|
||||
/** 被拖拽项的索引 */
|
||||
draggedIndex: number
|
||||
/** 按下的项索引 */
|
||||
pressedIndex: number
|
||||
}
|
||||
|
||||
/**
|
||||
* 面板宽度配置
|
||||
*/
|
||||
export interface PanelWidth {
|
||||
/** 左侧面板宽度(百分比) */
|
||||
left: number
|
||||
/** 右侧面板宽度(百分比) */
|
||||
right: number
|
||||
}
|
||||
|
||||
/**
|
||||
* 快捷路径
|
||||
*/
|
||||
export interface ShortcutPath {
|
||||
/** 显示名称 */
|
||||
name: string
|
||||
/** 路径 */
|
||||
path: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 工具栏配置
|
||||
*/
|
||||
export interface ToolbarConfig {
|
||||
/** 当前文件路径 */
|
||||
filePath: string
|
||||
/** 路径历史记录 */
|
||||
pathHistory: string[]
|
||||
/** 常用路径列表 */
|
||||
commonPaths: ShortcutPath[]
|
||||
/** 是否在 ZIP 浏览模式 */
|
||||
isBrowsingZip: boolean
|
||||
/** 显示路径(ZIP 模式下) */
|
||||
displayPath: string
|
||||
/** ZIP 文件名 */
|
||||
zipFileName: string
|
||||
/** ZIP 面包屑 */
|
||||
zipBreadcrumbs: ZipBreadcrumbItem[]
|
||||
/** 文件加载中 */
|
||||
fileLoading: boolean
|
||||
/** 是否显示侧边栏 */
|
||||
showSidebar: boolean
|
||||
/** 排序字段 */
|
||||
sortBy: string
|
||||
/** 排序方向 */
|
||||
sortOrder: string
|
||||
/** 搜索关键词 */
|
||||
searchKeyword: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 侧边栏配置
|
||||
*/
|
||||
export interface SidebarConfig {
|
||||
/** 是否可见 */
|
||||
visible: boolean
|
||||
/** 收藏文件列表 */
|
||||
favoriteFiles: FavoriteFile[]
|
||||
/** 拖拽状态 */
|
||||
draggingState: DraggingState
|
||||
}
|
||||
|
||||
/**
|
||||
* 文件列表面板配置
|
||||
*/
|
||||
export interface FileListPanelConfig {
|
||||
/** 文件列表 */
|
||||
fileList: FileItem[]
|
||||
/** 文件加载中 */
|
||||
fileLoading: boolean
|
||||
/** 选中的文件项 */
|
||||
selectedFileItem: FileItem | null
|
||||
/** 正在编辑的文件路径 */
|
||||
editingFilePath: string
|
||||
/** 编辑中的文件名 */
|
||||
editingFileName: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 文件编辑器面板配置
|
||||
*/
|
||||
export interface FileEditorPanelConfig {
|
||||
/** 当前文件名 */
|
||||
currentFileName: string
|
||||
/** 当前文件完整路径 */
|
||||
currentFileFullPath: string
|
||||
/** 预览 URL */
|
||||
previewUrl: string
|
||||
/** 文件内容 */
|
||||
fileContent: string
|
||||
/** 渲染后的内容(HTML/Markdown) */
|
||||
rendered: string
|
||||
/** 是否在编辑模式 */
|
||||
isEditMode: boolean
|
||||
/** 文件内容区域高度 */
|
||||
fileContentHeight: number
|
||||
/** 是否为图片视图 */
|
||||
isImageView: boolean
|
||||
/** 是否为视频视图 */
|
||||
isVideoView: boolean
|
||||
/** 是否为音频视图 */
|
||||
isAudioView: boolean
|
||||
/** 是否为 PDF 文件 */
|
||||
isPdfFile: boolean
|
||||
/** 是否为 HTML 文件 */
|
||||
isHtmlFile: boolean
|
||||
/** 是否为 Markdown 文件 */
|
||||
isMarkdownFile: boolean
|
||||
/** 是否为 Excel 文件 */
|
||||
isExcelFile: boolean
|
||||
/** 是否为 Word 文件 */
|
||||
isWordFile: boolean
|
||||
/** 是否为 CSV/TSV 文件 */
|
||||
isCsvFile: boolean
|
||||
/** Office 文件加载中 */
|
||||
officeLoading: boolean
|
||||
/** Office 文件加载错误 */
|
||||
officeError: string | null
|
||||
/** 是否可以保存 */
|
||||
canSaveFile: boolean
|
||||
/** 是否可以重置 */
|
||||
canResetContent: boolean
|
||||
/** 是否可以预览 */
|
||||
canPreviewFile: boolean
|
||||
/** 图片加载中 */
|
||||
imageLoading: boolean
|
||||
/** 当前图片尺寸 */
|
||||
currentImageDimensions: string
|
||||
/** 当前文件扩展名 */
|
||||
currentFileExtension: string
|
||||
/** 是否为二进制文件 */
|
||||
isBinaryFile: boolean
|
||||
/** 文件修改时间(用于检测外部变更) */
|
||||
fileMtime: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 右键菜单上下文类型
|
||||
*/
|
||||
export type ContextMenuContext = 'file-list' | 'editor' | 'empty'
|
||||
|
||||
/**
|
||||
* 右键菜单配置
|
||||
*/
|
||||
export interface ContextMenuConfig {
|
||||
/** 是否可见 */
|
||||
visible: boolean
|
||||
/** X 坐标 */
|
||||
x: number
|
||||
/** Y 坐标 */
|
||||
y: number
|
||||
/** 上下文类型 */
|
||||
context: ContextMenuContext
|
||||
/** 选中的文件(file-list 上下文) */
|
||||
selectedFile?: FileItem
|
||||
}
|
||||
|
||||
/**
|
||||
* 文件操作结果
|
||||
*/
|
||||
export interface FileOperationResult {
|
||||
/** 是否成功 */
|
||||
success: boolean
|
||||
/** 错误信息 */
|
||||
error?: string
|
||||
/** 数据 */
|
||||
data?: any
|
||||
}
|
||||
|
||||
/**
|
||||
* 路径导航历史
|
||||
*/
|
||||
export interface PathHistory {
|
||||
/** 历史记录数组 */
|
||||
paths: string[]
|
||||
/** 当前索引 */
|
||||
currentIndex: number
|
||||
}
|
||||
|
||||
/**
|
||||
* 文件预览元数据
|
||||
*/
|
||||
export interface FilePreviewMetadata {
|
||||
/** 宽度 */
|
||||
width?: number
|
||||
/** 高度 */
|
||||
height?: number
|
||||
/** 时长(视频/音频) */
|
||||
duration?: number
|
||||
/** MIME 类型 */
|
||||
mimeType?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 编辑器配置
|
||||
*/
|
||||
export interface EditorConfig {
|
||||
/** 是否可编辑 */
|
||||
editable: boolean
|
||||
/** 是否显示行号 */
|
||||
showLineNumbers: boolean
|
||||
/** 是否显示折叠按钮 */
|
||||
showFoldButtons: boolean
|
||||
/** 主题 */
|
||||
theme?: string
|
||||
/** 字体大小 */
|
||||
fontSize?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* 文件保存选项
|
||||
*/
|
||||
export interface FileSaveOptions {
|
||||
/** 是否创建备份 */
|
||||
createBackup?: boolean
|
||||
/** 是否保留原文件时间戳 */
|
||||
preserveTimestamp?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* ZIP 文件信息
|
||||
*/
|
||||
export interface ZipFileInfo {
|
||||
/** ZIP 文件路径 */
|
||||
zipPath: string
|
||||
/** ZIP 内部的当前路径 */
|
||||
currentPath: string
|
||||
/** ZIP 文件列表 */
|
||||
files: FileItem[]
|
||||
}
|
||||
|
||||
/**
|
||||
* ZIP 面包屑项
|
||||
*/
|
||||
export interface ZipBreadcrumbItem {
|
||||
/** 目录名 */
|
||||
name: string
|
||||
/** 目录路径 */
|
||||
path: string
|
||||
}
|
||||
14
frontend/src/types/window.d.ts
vendored
Normal file
14
frontend/src/types/window.d.ts
vendored
Normal file
@@ -0,0 +1,14 @@
|
||||
/**
|
||||
* 全局 Window 类型声明
|
||||
* Wails v3 使用 @wailsio/runtime + 生成绑定函数
|
||||
* 无需 window.go.main.App 类型扩展
|
||||
*/
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
// v3 runtime 由 @wailsio/runtime 注入
|
||||
_wails: any
|
||||
}
|
||||
}
|
||||
|
||||
export {}
|
||||
114
frontend/src/utils/codeMirrorLoader.js
Normal file
114
frontend/src/utils/codeMirrorLoader.js
Normal file
@@ -0,0 +1,114 @@
|
||||
/**
|
||||
* CodeMirror 语言包加载器(动态导入,按需加载)
|
||||
*/
|
||||
|
||||
import { getCmLanguage } from './languageMap'
|
||||
|
||||
const languageCache = new Map()
|
||||
|
||||
/**
|
||||
* 获取语言扩展(异步,首次调用会动态加载对应语言包)
|
||||
* @param {string} language - 语言名称
|
||||
* @returns {Promise<Extension|null>} CodeMirror 语言扩展
|
||||
*/
|
||||
export async function loadLanguageExtension(language) {
|
||||
if (languageCache.has(language)) {
|
||||
return languageCache.get(language)
|
||||
}
|
||||
|
||||
let mod, extension = null
|
||||
|
||||
switch (language) {
|
||||
case 'javascript':
|
||||
mod = await import('@codemirror/lang-javascript')
|
||||
extension = mod.javascript({ jsx: true })
|
||||
break
|
||||
case 'typescript':
|
||||
mod = await import('@codemirror/lang-javascript')
|
||||
extension = mod.javascript({ typescript: true, jsx: true })
|
||||
break
|
||||
case 'json':
|
||||
;({ json: extension } = await import('@codemirror/lang-json'))
|
||||
extension = extension()
|
||||
break
|
||||
case 'yaml':
|
||||
;({ yaml: extension } = await import('@codemirror/lang-yaml'))
|
||||
extension = extension()
|
||||
break
|
||||
case 'html':
|
||||
;({ html: extension } = await import('@codemirror/lang-html'))
|
||||
extension = extension()
|
||||
break
|
||||
case 'css':
|
||||
;({ css: extension } = await import('@codemirror/lang-css'))
|
||||
extension = extension()
|
||||
break
|
||||
case 'cpp':
|
||||
case 'c':
|
||||
;({ cpp: extension } = await import('@codemirror/lang-cpp'))
|
||||
extension = extension()
|
||||
break
|
||||
case 'rust':
|
||||
;({ rust: extension } = await import('@codemirror/lang-rust'))
|
||||
extension = extension()
|
||||
break
|
||||
case 'go':
|
||||
;({ go: extension } = await import('@codemirror/lang-go'))
|
||||
extension = extension()
|
||||
break
|
||||
case 'python':
|
||||
;({ python: extension } = await import('@codemirror/lang-python'))
|
||||
extension = extension()
|
||||
break
|
||||
case 'php':
|
||||
;({ php: extension } = await import('@codemirror/lang-php'))
|
||||
extension = extension()
|
||||
break
|
||||
case 'sql':
|
||||
;({ sql: extension } = await import('@codemirror/lang-sql'))
|
||||
extension = extension()
|
||||
break
|
||||
case 'markdown':
|
||||
;({ markdown: extension } = await import('@codemirror/lang-markdown'))
|
||||
extension = extension()
|
||||
break
|
||||
case 'java':
|
||||
;({ java: extension } = await import('@codemirror/lang-java'))
|
||||
extension = extension()
|
||||
break
|
||||
case 'shell':
|
||||
case 'bash':
|
||||
case 'sh':
|
||||
case 'dockerfile': {
|
||||
const { StreamLanguage, shell } = await import('@codemirror/legacy-modes/mode/shell')
|
||||
extension = StreamLanguage.define(shell)
|
||||
break
|
||||
}
|
||||
case 'powershell': {
|
||||
const { StreamLanguage, powerShell } = await import('@codemirror/legacy-modes/mode/powershell')
|
||||
extension = StreamLanguage.define(powerShell)
|
||||
break
|
||||
}
|
||||
case 'dart': {
|
||||
const { StreamLanguage, clike: dart } = await import('@codemirror/legacy-modes/mode/clike')
|
||||
extension = StreamLanguage.define(dart)
|
||||
break
|
||||
}
|
||||
default:
|
||||
return null
|
||||
}
|
||||
|
||||
if (extension) {
|
||||
languageCache.set(language, extension)
|
||||
}
|
||||
return extension
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据文件扩展名获取语言名称
|
||||
* @param {string} extension - 文件扩展名
|
||||
* @returns {string} 语言名称
|
||||
*/
|
||||
export function getLanguageFromExtension(extension) {
|
||||
return getCmLanguage(extension)
|
||||
}
|
||||
16
frontend/src/utils/codemirrorExports.js
Normal file
16
frontend/src/utils/codemirrorExports.js
Normal file
@@ -0,0 +1,16 @@
|
||||
/**
|
||||
* CodeMirror 统一导出
|
||||
* 确保所有模块使用同一个 CodeMirror 实例,避免多实例问题
|
||||
*/
|
||||
|
||||
// Core
|
||||
export { EditorView, lineNumbers, highlightActiveLineGutter, keymap } from '@codemirror/view'
|
||||
export { EditorState, Compartment } from '@codemirror/state'
|
||||
export { defaultKeymap, history, historyKeymap } from '@codemirror/commands'
|
||||
export { bracketMatching, defaultHighlightStyle, syntaxHighlighting } from '@codemirror/language'
|
||||
export { oneDark } from '@codemirror/theme-one-dark'
|
||||
|
||||
// 查找替换
|
||||
export { openSearchPanel, closeSearchPanel, search, searchKeymap, SearchQuery } from '@codemirror/search'
|
||||
|
||||
// 语言包通过 codeMirrorLoader 动态导入,避免全量打包
|
||||
394
frontend/src/utils/constants.js
Normal file
394
frontend/src/utils/constants.js
Normal file
@@ -0,0 +1,394 @@
|
||||
/**
|
||||
* 应用全局常量配置
|
||||
*
|
||||
* @module utils/constants
|
||||
* @description 集中管理所有应用常量,避免硬编码和重复定义
|
||||
*/
|
||||
|
||||
/**
|
||||
* localStorage 键名管理
|
||||
* @description 统一的localStorage键名规范,避免冲突和重复
|
||||
*
|
||||
* 命名规范:app-{feature}-{key}
|
||||
* - app: 应用级前缀
|
||||
* - feature: 功能模块标识(filesystem/device-test等)
|
||||
* - key: 具体的数据项
|
||||
*/
|
||||
export const STORAGE_KEYS = {
|
||||
// 文件系统模块
|
||||
FILESYSTEM: {
|
||||
FILE_PATH: 'app-filesystem-file-path',
|
||||
FILE_LIST: 'app-filesystem-file-list',
|
||||
FILE_CONTENT: 'app-filesystem-file-content',
|
||||
PATH_HISTORY: 'app-filesystem-path-history',
|
||||
FILE_CONTENT_HEIGHT: 'app-filesystem-file-content-height',
|
||||
PANEL_WIDTH: 'app-filesystem-panel-width',
|
||||
SIDEBAR_VISIBLE: 'app-filesystem-sidebar-visible',
|
||||
FAVORITE_FILES: 'app-filesystem-favorite-files',
|
||||
EDIT_MODE: 'app-filesystem-edit-mode', // HTML/Markdown 编辑模式状态
|
||||
FILE_DRAFT: 'app-filesystem-file-draft', // 文件草稿
|
||||
SORT: 'app-filesystem-sort', // 排序状态
|
||||
COL_SETTINGS: 'app-filesystem-col-settings', // 列配置(显隐+顺序)
|
||||
SHOW_HEADER: 'app-filesystem-show-header', // 表头显隐
|
||||
LAST_OPENED_FILE: 'app-filesystem-last-opened-file', // 上次打开的文件路径
|
||||
},
|
||||
|
||||
// 设备测试模块
|
||||
DEVICE_TEST: {
|
||||
FILE_PATH: 'app-device-test-file-path',
|
||||
FILE_LIST: 'app-device-test-file-list',
|
||||
FILE_CONTENT: 'app-device-test-file-content',
|
||||
PATH_HISTORY: 'app-device-test-path-history',
|
||||
FILE_CONTENT_HEIGHT: 'app-device-test-file-content-height',
|
||||
PANEL_WIDTH: 'app-device-test-panel-width',
|
||||
FAVORITE_FILES: 'app-device-test-favorite-files',
|
||||
},
|
||||
|
||||
// 通用配置
|
||||
COMMON: {
|
||||
THEME: 'app-common-theme',
|
||||
LANGUAGE: 'app-common-language',
|
||||
},
|
||||
}
|
||||
|
||||
/**
|
||||
* 文件扩展名分类
|
||||
* @description 用于文件类型识别和图标映射
|
||||
*/
|
||||
export const FILE_EXTENSIONS = {
|
||||
// 图片文件
|
||||
IMAGE: ['jpg', 'jpeg', 'png', 'gif', 'bmp', 'svg', 'webp', 'ico', 'heic', 'heif'],
|
||||
|
||||
// 视频文件
|
||||
VIDEO_BROWSER: ['mp4', 'webm', 'ogg', 'mov', 'm4v'], // 浏览器原生支持
|
||||
VIDEO_EXTERNAL: ['avi', 'mkv', 'wmv', 'flv', 'rmvb', '3gp', 'mts'], // 需要外部播放器(注意:不用 'ts' 避免 TypeScript 冲突)
|
||||
// 音频文件
|
||||
AUDIO: ['mp3', 'wav', 'flac', 'aac', 'ogg', 'm4a', 'opus', 'webm'],
|
||||
|
||||
// 文档文件
|
||||
DOCUMENT: ['doc', 'docx', 'pdf', 'xls', 'xlsx', 'ppt', 'pptx', 'txt', 'rtf', 'odt', 'ods', 'odp'],
|
||||
|
||||
// 压缩文件
|
||||
ARCHIVE: ['zip', 'rar', '7z', 'tar', 'gz', 'bz2', 'xz', 'z', 'cab', 'iso'],
|
||||
|
||||
// 代码文件
|
||||
CODE: [
|
||||
'js', 'ts', 'jsx', 'tsx', 'cts', 'mts', 'cjs', 'mjs',
|
||||
'vue', 'py', 'java', 'c', 'cpp', 'h', 'go', 'rs', 'php', 'rb', 'cs', 'swift', 'kt',
|
||||
'scala', 'dart', 'css', 'scss', 'sass', 'less', 'sql', 'sh', 'bat', 'ps1',
|
||||
'flow', 'pch', 'cc', 'cxx', 'hpp', 'hxx', 'tcc', 'defs', 'makefile', 'mk', 'cmake',
|
||||
'dockerfile', 'm', 'r', 'matlab'
|
||||
],
|
||||
|
||||
// 配置文件(可编辑的文本格式)
|
||||
CONFIG: [
|
||||
// 数据格式
|
||||
'json', 'xml', 'yaml', 'yml',
|
||||
// 配置文件
|
||||
'toml', 'ini', 'cfg', 'conf',
|
||||
// 环境变量/属性
|
||||
'props', 'env', 'dotenv',
|
||||
// 其他
|
||||
'manifest', 'lock', 'ignore'
|
||||
],
|
||||
|
||||
// 纯文本文件
|
||||
TEXT: ['txt', 'text', 'log', 'md', 'markdown', 'rst', 'adoc', 'tex', 'msg', 'csv', 'tsv'],
|
||||
|
||||
// 数据库文件
|
||||
DATABASE: ['db', 'sqlite', 'mdb', 'accdb'],
|
||||
|
||||
// 可执行文件
|
||||
EXECUTABLE: ['exe', 'msi', 'app', 'dmg', 'deb', 'rpm', 'dll', 'so', 'jsa', 'jar'],
|
||||
|
||||
// 字体文件
|
||||
FONT: ['ttf', 'otf', 'woff', 'woff2', 'eot'],
|
||||
}
|
||||
|
||||
/**
|
||||
* 文件类型图标映射
|
||||
* @description 根据文件扩展名返回对应的图标
|
||||
*/
|
||||
export const FILE_ICONS = {
|
||||
// 图片
|
||||
IMAGE: '🖼️',
|
||||
|
||||
// 视频
|
||||
VIDEO: '🎬',
|
||||
|
||||
// 音频
|
||||
AUDIO: '🎵',
|
||||
|
||||
// 文档
|
||||
PDF: '📕',
|
||||
DOC: '📘',
|
||||
XLS: '📗',
|
||||
PPT: '📙',
|
||||
TXT: '📃',
|
||||
DOCUMENT: '📄',
|
||||
|
||||
// 压缩包
|
||||
ARCHIVE: '📦',
|
||||
|
||||
// 代码
|
||||
CODE: '💻',
|
||||
|
||||
// 编程语言特定图标
|
||||
JAVA: '☕',
|
||||
JAR: '🏺',
|
||||
JSA: '📦',
|
||||
GO: '🐹',
|
||||
PYTHON: '🐍',
|
||||
JAVASCRIPT: '📜',
|
||||
TYPESCRIPT: '💠',
|
||||
HTML: '🌐',
|
||||
CSS: '🎨',
|
||||
SQL: '🗃️',
|
||||
JSON: '📋',
|
||||
XML: '📰',
|
||||
YAML: '⚙️',
|
||||
SHELL: '🐚',
|
||||
C: '🔷',
|
||||
CPP: '🔶',
|
||||
RUST: '🦀',
|
||||
PHP: '🐘',
|
||||
RUBY: '💎',
|
||||
DART: '🎯',
|
||||
DOCKERFILE: '🐳',
|
||||
VUE: '💚',
|
||||
|
||||
// 数据库
|
||||
DATABASE: '🗄️',
|
||||
|
||||
// 可执行文件
|
||||
EXECUTABLE: '⚙️',
|
||||
|
||||
// 字体
|
||||
FONT: '🔤',
|
||||
|
||||
// 文件夹
|
||||
FOLDER: '📁',
|
||||
|
||||
// 默认文件
|
||||
FILE: '📄',
|
||||
}
|
||||
|
||||
/**
|
||||
* 文件类型到图标的映射表
|
||||
* @description 扩展名 -> 图标 的快速查找表
|
||||
*/
|
||||
export const FILE_ICON_MAP = new Map()
|
||||
|
||||
// 初始化图标映射表
|
||||
const initIconMap = () => {
|
||||
// 图片
|
||||
FILE_EXTENSIONS.IMAGE.forEach(ext => FILE_ICON_MAP.set(ext, FILE_ICONS.IMAGE))
|
||||
|
||||
// 视频
|
||||
FILE_EXTENSIONS.VIDEO_BROWSER.forEach(ext => FILE_ICON_MAP.set(ext, FILE_ICONS.VIDEO))
|
||||
FILE_EXTENSIONS.VIDEO_EXTERNAL.forEach(ext => FILE_ICON_MAP.set(ext, FILE_ICONS.VIDEO))
|
||||
|
||||
// 音频
|
||||
FILE_EXTENSIONS.AUDIO.forEach(ext => FILE_ICON_MAP.set(ext, FILE_ICONS.AUDIO))
|
||||
|
||||
// 文档
|
||||
FILE_EXTENSIONS.DOCUMENT.forEach(ext => {
|
||||
if (ext === 'pdf') FILE_ICON_MAP.set(ext, FILE_ICONS.PDF)
|
||||
else if (['doc', 'docx'].includes(ext)) FILE_ICON_MAP.set(ext, FILE_ICONS.DOC)
|
||||
else if (['xls', 'xlsx'].includes(ext)) FILE_ICON_MAP.set(ext, FILE_ICONS.XLS)
|
||||
else if (['ppt', 'pptx'].includes(ext)) FILE_ICON_MAP.set(ext, FILE_ICONS.PPT)
|
||||
else if (ext === 'txt') FILE_ICON_MAP.set(ext, FILE_ICONS.TXT)
|
||||
else FILE_ICON_MAP.set(ext, FILE_ICONS.DOCUMENT)
|
||||
})
|
||||
|
||||
// 压缩文件
|
||||
FILE_EXTENSIONS.ARCHIVE.forEach(ext => FILE_ICON_MAP.set(ext, FILE_ICONS.ARCHIVE))
|
||||
|
||||
// 代码文件(通用)
|
||||
FILE_EXTENSIONS.CODE.forEach(ext => FILE_ICON_MAP.set(ext, FILE_ICONS.CODE))
|
||||
|
||||
// 配置文件(使用特定图标)
|
||||
const configIcons = {
|
||||
'json': FILE_ICONS.JSON,
|
||||
'xml': FILE_ICONS.XML,
|
||||
'yaml': FILE_ICONS.YAML,
|
||||
'yml': FILE_ICONS.YAML
|
||||
}
|
||||
FILE_EXTENSIONS.CONFIG.forEach(ext => {
|
||||
FILE_ICON_MAP.set(ext, configIcons[ext] || FILE_ICONS.YAML)
|
||||
})
|
||||
|
||||
// 编程语言特定图标
|
||||
const langIcons = {
|
||||
// Java
|
||||
'java': FILE_ICONS.JAVA,
|
||||
'jar': FILE_ICONS.JAR,
|
||||
'jsa': FILE_ICONS.JSA,
|
||||
// Go
|
||||
'go': FILE_ICONS.GO,
|
||||
// Python
|
||||
'py': FILE_ICONS.PYTHON,
|
||||
'pyw': FILE_ICONS.PYTHON,
|
||||
// JavaScript/TypeScript
|
||||
'js': FILE_ICONS.JAVASCRIPT,
|
||||
'jsx': FILE_ICONS.JAVASCRIPT,
|
||||
'ts': FILE_ICONS.TYPESCRIPT,
|
||||
'tsx': FILE_ICONS.TYPESCRIPT,
|
||||
'mjs': FILE_ICONS.JAVASCRIPT,
|
||||
'cjs': FILE_ICONS.JAVASCRIPT,
|
||||
// Web
|
||||
'html': FILE_ICONS.HTML,
|
||||
'htm': FILE_ICONS.HTML,
|
||||
'xhtml': FILE_ICONS.HTML,
|
||||
'css': FILE_ICONS.CSS,
|
||||
'scss': FILE_ICONS.CSS,
|
||||
'sass': FILE_ICONS.CSS,
|
||||
'less': FILE_ICONS.CSS,
|
||||
// Shell
|
||||
'sh': FILE_ICONS.SHELL,
|
||||
'bash': FILE_ICONS.SHELL,
|
||||
'zsh': FILE_ICONS.SHELL,
|
||||
'fish': FILE_ICONS.SHELL,
|
||||
'cmd': FILE_ICONS.SHELL,
|
||||
'bat': FILE_ICONS.SHELL,
|
||||
'ps1': FILE_ICONS.SHELL,
|
||||
// C/C++
|
||||
'c': FILE_ICONS.C,
|
||||
'h': FILE_ICONS.C,
|
||||
'cpp': FILE_ICONS.CPP,
|
||||
'hpp': FILE_ICONS.CPP,
|
||||
'cc': FILE_ICONS.CPP,
|
||||
'cxx': FILE_ICONS.CPP,
|
||||
// Rust
|
||||
'rs': FILE_ICONS.RUST,
|
||||
// PHP
|
||||
'php': FILE_ICONS.PHP,
|
||||
// Ruby
|
||||
'rb': FILE_ICONS.RUBY,
|
||||
'gem': FILE_ICONS.RUBY,
|
||||
// SQL
|
||||
'sql': FILE_ICONS.SQL,
|
||||
// Dart
|
||||
'dart': FILE_ICONS.DART,
|
||||
// Dockerfile
|
||||
'dockerfile': FILE_ICONS.DOCKERFILE,
|
||||
// Vue
|
||||
'vue': FILE_ICONS.VUE,
|
||||
}
|
||||
|
||||
Object.keys(langIcons).forEach(ext => FILE_ICON_MAP.set(ext, langIcons[ext]))
|
||||
|
||||
// 数据库
|
||||
FILE_EXTENSIONS.DATABASE.forEach(ext => FILE_ICON_MAP.set(ext, FILE_ICONS.DATABASE))
|
||||
|
||||
// 可执行文件
|
||||
FILE_EXTENSIONS.EXECUTABLE.slice(0, 6).forEach(ext => FILE_ICON_MAP.set(ext, FILE_ICONS.EXECUTABLE))
|
||||
|
||||
// 字体
|
||||
FILE_EXTENSIONS.FONT.forEach(ext => FILE_ICON_MAP.set(ext, FILE_ICONS.FONT))
|
||||
}
|
||||
|
||||
// 执行初始化
|
||||
initIconMap()
|
||||
|
||||
/**
|
||||
* 常用路径快捷方式
|
||||
* @description 系统常用路径的emoji标识
|
||||
*/
|
||||
export const PATH_ICONS = {
|
||||
DESKTOP: '🖥️',
|
||||
DOCUMENTS: '📁',
|
||||
DOWNLOADS: '📥',
|
||||
HOME: '🏠',
|
||||
ROOT: '📂',
|
||||
DRIVE: '💿',
|
||||
}
|
||||
|
||||
/**
|
||||
* 文件大小单位
|
||||
* @description 用于文件大小格式化的单位数组
|
||||
*/
|
||||
export const BYTE_UNITS = ['B', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB']
|
||||
|
||||
/**
|
||||
* 默认配置值
|
||||
*/
|
||||
export const DEFAULTS = {
|
||||
// 路径历史最大记录数
|
||||
MAX_HISTORY_LENGTH: 20,
|
||||
|
||||
// 收藏夹最大数量
|
||||
MAX_FAVORITES_LENGTH: 50,
|
||||
|
||||
// 文件内容高度范围(px)
|
||||
MIN_CONTENT_HEIGHT: 100,
|
||||
MAX_CONTENT_HEIGHT: 800,
|
||||
DEFAULT_CONTENT_HEIGHT: 200,
|
||||
|
||||
// 面板宽度范围(%)
|
||||
MIN_PANEL_WIDTH: 20,
|
||||
MAX_PANEL_WIDTH: 80,
|
||||
DEFAULT_PANEL_WIDTH: 50,
|
||||
|
||||
// 侧边栏宽度(px)
|
||||
SIDEBAR_WIDTH: 220,
|
||||
}
|
||||
|
||||
/**
|
||||
* 文件大小格式化配置
|
||||
*/
|
||||
export const FILE_SIZE_FORMAT = {
|
||||
UNIT: 1024, // 使用1024进制(KiB, MiB等)
|
||||
DECIMAL_PLACES: 2, // 保留小数位数
|
||||
}
|
||||
|
||||
/**
|
||||
* 文件大小阈值配置
|
||||
* @description 用于文件处理逻辑的大小限制
|
||||
*/
|
||||
export const FILE_SIZE_THRESHOLDS = {
|
||||
LARGE_FILE: 100 * 1024, // 100KB - 大文件检测阈值
|
||||
MAX_TEXT_DISPLAY: 5 * 1024 * 1024, // 5MB - 文本文件最大显示大小
|
||||
}
|
||||
|
||||
/**
|
||||
* UI 文本常量
|
||||
* @description 界面上显示的固定文本
|
||||
*/
|
||||
export const UI_TEXT = {
|
||||
// 对话框标题
|
||||
CREATE_FILE: '📄 新建文件',
|
||||
CREATE_FOLDER: '📁 新建文件夹',
|
||||
RENAME_FILE: '重命名文件',
|
||||
DELETE_CONFIRM: '确认删除',
|
||||
|
||||
// 按钮文本
|
||||
CONFIRM: '确定',
|
||||
CANCEL: '取消',
|
||||
CREATE: '创建',
|
||||
SAVE: '保存',
|
||||
DELETE: '删除',
|
||||
|
||||
// 提示信息
|
||||
FILE_NAME_EMPTY: '请输入内容',
|
||||
FILE_NAME_INVALID: '文件名包含非法字符',
|
||||
FOLDER_NAME_INVALID: '文件夹名包含非法字符',
|
||||
FILE_EXISTS: '文件已存在',
|
||||
FOLDER_EXISTS: '文件夹已存在',
|
||||
SELECT_DIRECTORY: '请先选择一个目录',
|
||||
CREATE_SUCCESS: '创建成功',
|
||||
CREATE_FAILED: '创建失败',
|
||||
|
||||
// 输入提示
|
||||
ENTER_FILE_NAME: '请输入文件名(如: todo.md)',
|
||||
ENTER_FOLDER_NAME: '请输入文件夹名称',
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证规则
|
||||
* @description 数据验证的正则表达式规则
|
||||
*/
|
||||
export const VALIDATION_RULES = {
|
||||
// Windows 文件名非法字符
|
||||
ILLEGAL_FILE_NAME_CHARS: /[<>:"/\\|?*]/,
|
||||
}
|
||||
81
frontend/src/utils/debugLog.js
Normal file
81
frontend/src/utils/debugLog.js
Normal file
@@ -0,0 +1,81 @@
|
||||
/**
|
||||
* 调试日志工具
|
||||
*
|
||||
* 通过环境变量控制调试日志输出
|
||||
* 开发环境:输出详细日志
|
||||
* 生产环境:仅输出错误
|
||||
*/
|
||||
|
||||
// 检测是否为开发环境
|
||||
const isDevelopment = import.meta.env.DEV || import.meta.env.MODE === 'development'
|
||||
|
||||
/**
|
||||
* 调试日志输出(仅开发环境)
|
||||
* @param {...any} args - 要输出的参数
|
||||
*/
|
||||
export const debugLog = (...args) => {
|
||||
if (isDevelopment) {
|
||||
console.log('[FileSystem]', ...args)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 警告日志(所有环境)
|
||||
* @param {...any} args - 要输出的参数
|
||||
*/
|
||||
export const debugWarn = (...args) => {
|
||||
console.warn('[FileSystem]', ...args)
|
||||
}
|
||||
|
||||
/**
|
||||
* 错误日志(所有环境)
|
||||
* @param {...any} args - 要输出的参数
|
||||
*/
|
||||
export const debugError = (...args) => {
|
||||
console.error('[FileSystem]', ...args)
|
||||
}
|
||||
|
||||
/**
|
||||
* 分组日志开始(仅开发环境)
|
||||
* @param {string} label - 分组标签
|
||||
*/
|
||||
export const debugGroup = (label) => {
|
||||
if (isDevelopment) {
|
||||
console.group(`[FileSystem] ${label}`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 分组日志结束(仅开发环境)
|
||||
*/
|
||||
export const debugGroupEnd = () => {
|
||||
if (isDevelopment) {
|
||||
console.groupEnd()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 条件日志(仅在满足条件时输出)
|
||||
* @param {boolean} condition - 是否输出
|
||||
* @param {...any} args - 要输出的参数
|
||||
*/
|
||||
export const debugIf = (condition, ...args) => {
|
||||
if (isDevelopment && condition) {
|
||||
console.log('[FileSystem]', ...args)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 性能日志(仅开发环境)
|
||||
* @param {string} label - 性能标签
|
||||
* @param {Function} fn - 要测量的函数
|
||||
*/
|
||||
export const debugTime = (label, fn) => {
|
||||
if (isDevelopment) {
|
||||
console.time(`[FileSystem] ${label}`)
|
||||
const result = fn()
|
||||
console.timeEnd(`[FileSystem] ${label}`)
|
||||
return result
|
||||
}
|
||||
return fn()
|
||||
}
|
||||
331
frontend/src/utils/filePreviewHandlers.js
Normal file
331
frontend/src/utils/filePreviewHandlers.js
Normal file
@@ -0,0 +1,331 @@
|
||||
/**
|
||||
* Office 文件预览处理器
|
||||
*/
|
||||
|
||||
import { escapeHtml } from './fileUtils'
|
||||
import { isExcelFile, isWordFile, isOfficeFile, isCsvFile } from './fileTypeHelpers'
|
||||
|
||||
// 每批加载行数
|
||||
const BATCH_ROWS = 200
|
||||
const LOAD_MORE_THRESHOLD = 100
|
||||
|
||||
// Excel 预览处理器
|
||||
export async function previewExcel(file, container) {
|
||||
const XLSX = await import('xlsx')
|
||||
const arrayBuffer = await file.arrayBuffer()
|
||||
const workbook = XLSX.read(arrayBuffer, { type: 'array' })
|
||||
|
||||
// 渲染标签页
|
||||
const tabs = workbook.SheetNames.map((name, i) =>
|
||||
`<button class="excel-tab ${i === 0 ? 'active' : ''}" data-idx="${i}">${name}</button>`
|
||||
).join('')
|
||||
|
||||
container.innerHTML = `
|
||||
<div class="excel-preview">
|
||||
<div class="excel-tabs">${tabs}</div>
|
||||
<div class="excel-info"></div>
|
||||
<div class="excel-content"></div>
|
||||
</div>
|
||||
<style>
|
||||
.excel-preview{display:flex;flex-direction:column;height:100%;overflow:hidden}
|
||||
.excel-tabs{display:flex;flex-wrap:wrap;gap:4px;padding:8px 12px;background:var(--color-fill-1);border-bottom:1px solid var(--color-border-2)}
|
||||
.excel-tab{padding:6px 14px;border:none;background:transparent;color:var(--color-text-2);font-size:13px;border-radius:4px;cursor:pointer;transition:all .2s}
|
||||
.excel-tab:hover{background:var(--color-fill-3)}
|
||||
.excel-tab.active{background:rgb(var(--primary-6));color:#fff;font-weight:500}
|
||||
.excel-info{padding:4px 12px;background:var(--color-fill-1);font-size:12px;color:var(--color-text-3);border-bottom:1px solid var(--color-border-2)}
|
||||
.excel-content{flex:1;overflow:auto;padding:12px}
|
||||
.excel-content table{width:100%;border-collapse:collapse;font-size:13px}
|
||||
.excel-content td,.excel-content th{border:1px solid var(--color-border-2);padding:6px 10px;text-align:left;white-space:nowrap;color:var(--color-text-1)}
|
||||
.excel-content th{background:var(--color-fill-2);font-weight:600;position:sticky;top:0;z-index:2}
|
||||
.excel-content .row-num{background:var(--color-fill-1);color:var(--color-text-3);text-align:center;min-width:10px;position:sticky;left:0;z-index:1}
|
||||
.excel-content th.row-num{z-index:3;top:0;left:0}
|
||||
.excel-content tr:hover td{background:var(--color-fill-1)}
|
||||
.excel-content tr:hover .row-num{background:var(--color-fill-2)}
|
||||
</style>
|
||||
`
|
||||
|
||||
const contentEl = container.querySelector('.excel-content')
|
||||
const infoEl = container.querySelector('.excel-info')
|
||||
const tabsEl = container.querySelector('.excel-tabs')
|
||||
|
||||
// 当前 sheet 状态
|
||||
let currentSheet = { idx: 0, data: null, renderedRows: 0, totalRows: 0 }
|
||||
|
||||
// 获取 sheet 数据
|
||||
const getSheetData = (idx) => {
|
||||
const ws = workbook.Sheets[workbook.SheetNames[idx]]
|
||||
return XLSX.utils.sheet_to_json(ws, { header: 1, defval: '' })
|
||||
}
|
||||
|
||||
// 渲染表格(带行号)
|
||||
const renderTable = (data, startRow = 0) => {
|
||||
let html = '<table><thead><tr><th class="row-num">#</th>'
|
||||
if (data[0]) {
|
||||
data[0].forEach((cell, i) => {
|
||||
html += `<th>${escapeHtml(cell)}</th>`
|
||||
})
|
||||
}
|
||||
html += '</tr></thead><tbody>'
|
||||
|
||||
for (let i = 1; i < data.length && i <= startRow + BATCH_ROWS; i++) {
|
||||
html += `<tr><td class="row-num">${i}</td>`
|
||||
if (data[i]) {
|
||||
data[i].forEach(cell => {
|
||||
html += `<td>${escapeHtml(cell)}</td>`
|
||||
})
|
||||
}
|
||||
html += '</tr>'
|
||||
}
|
||||
html += '</tbody></table>'
|
||||
return html
|
||||
}
|
||||
|
||||
// 追加行
|
||||
const appendRows = (data, fromRow, toRow) => {
|
||||
const tbody = contentEl.querySelector('tbody')
|
||||
if (!tbody) return
|
||||
|
||||
for (let i = fromRow; i <= toRow && i < data.length; i++) {
|
||||
let html = `<tr><td class="row-num">${i}</td>`
|
||||
if (data[i]) {
|
||||
data[i].forEach(cell => {
|
||||
html += `<td>${escapeHtml(cell)}</td>`
|
||||
})
|
||||
}
|
||||
html += '</tr>'
|
||||
tbody.insertAdjacentHTML('beforeend', html)
|
||||
}
|
||||
}
|
||||
|
||||
// 更新信息栏
|
||||
const updateInfo = () => {
|
||||
const { renderedRows, totalRows } = currentSheet
|
||||
if (renderedRows >= totalRows) {
|
||||
infoEl.textContent = `共 ${totalRows} 行`
|
||||
} else {
|
||||
infoEl.textContent = `已加载 ${renderedRows}/${totalRows} 行(滚动加载更多)`
|
||||
}
|
||||
}
|
||||
|
||||
// 切换 sheet
|
||||
const loadSheet = (idx) => {
|
||||
currentSheet = {
|
||||
idx,
|
||||
data: getSheetData(idx),
|
||||
renderedRows: BATCH_ROWS,
|
||||
totalRows: 0
|
||||
}
|
||||
currentSheet.totalRows = currentSheet.data.length
|
||||
|
||||
const displayData = currentSheet.data.slice(0, BATCH_ROWS + 1)
|
||||
contentEl.innerHTML = renderTable(displayData)
|
||||
updateInfo()
|
||||
}
|
||||
|
||||
// 滚动加载更多
|
||||
contentEl.onscroll = () => {
|
||||
const { scrollTop, scrollHeight, clientHeight } = contentEl
|
||||
if (scrollTop + clientHeight >= scrollHeight - LOAD_MORE_THRESHOLD) {
|
||||
const { data, renderedRows, totalRows } = currentSheet
|
||||
if (renderedRows < totalRows - 1) {
|
||||
const newEnd = Math.min(renderedRows + BATCH_ROWS, totalRows - 1)
|
||||
appendRows(data, renderedRows + 1, newEnd)
|
||||
currentSheet.renderedRows = newEnd
|
||||
updateInfo()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
loadSheet(0)
|
||||
|
||||
// 标签页切换
|
||||
tabsEl.onclick = (e) => {
|
||||
const tab = e.target.closest('.excel-tab')
|
||||
if (!tab) return
|
||||
tabsEl.querySelectorAll('.excel-tab').forEach(t => t.classList.remove('active'))
|
||||
tab.classList.add('active')
|
||||
loadSheet(parseInt(tab.dataset.idx, 10))
|
||||
}
|
||||
|
||||
return { success: true }
|
||||
}
|
||||
|
||||
// Word 预览处理器
|
||||
export async function previewWord(file, container) {
|
||||
const mammoth = await import('mammoth')
|
||||
const arrayBuffer = await file.arrayBuffer()
|
||||
const result = await mammoth.convertToHtml({ arrayBuffer })
|
||||
|
||||
const warnings = result.messages.length > 0
|
||||
? `<details class="word-warnings"><summary>转换警告 (${result.messages.length})</summary><ul>${result.messages.map(m => `<li>${m.message}</li>`).join('')}</ul></details>`
|
||||
: ''
|
||||
|
||||
container.innerHTML = `
|
||||
<div class="word-preview">
|
||||
<div class="word-content">${result.value}</div>
|
||||
${warnings}
|
||||
</div>
|
||||
<style>
|
||||
.word-preview{padding:20px;height:100%;overflow:auto;line-height:1.6;color:var(--color-text-1)}
|
||||
.word-content h1,.word-content h2,.word-content h3{margin:1em 0 .5em;font-weight:600}
|
||||
.word-content h1{font-size:2em}.word-content h2{font-size:1.5em}.word-content h3{font-size:1.25em}
|
||||
.word-content p{margin:.5em 0}
|
||||
.word-content ul,.word-content ol{margin:.5em 0;padding-left:2em}
|
||||
.word-content table{border-collapse:collapse;width:100%;margin:1em 0}
|
||||
.word-content td,.word-content th{border:1px solid var(--color-border-2);padding:6px 10px}
|
||||
.word-content th{background:var(--color-fill-2);font-weight:600}
|
||||
.word-content img{max-width:100%;height:auto}
|
||||
.word-content a{color:rgb(var(--primary-6));text-decoration:none}
|
||||
.word-content a:hover{text-decoration:underline}
|
||||
.word-warnings{margin-top:20px;padding:12px;background:var(--color-warning-light-1);border:1px solid var(--color-warning-3);border-radius:6px;font-size:12px}
|
||||
.word-warnings summary{cursor:pointer;font-weight:600}
|
||||
.word-warnings ul{margin:8px 0 0 20px}
|
||||
</style>
|
||||
`
|
||||
|
||||
return { success: true }
|
||||
}
|
||||
|
||||
// 文件类型判断(从 fileTypeHelpers 导入)
|
||||
export { isOfficeFile, isExcelFile, isWordFile, isCsvFile }
|
||||
|
||||
// CSV/TSV 预览处理器(原生实现,支持滚动加载)
|
||||
export async function previewCsv(file, container) {
|
||||
try {
|
||||
if (!container) {
|
||||
return { success: false, error: '容器不存在' }
|
||||
}
|
||||
|
||||
const text = await file.text()
|
||||
const lines = text.split(/\r?\n/).filter(line => line.trim())
|
||||
if (lines.length === 0) {
|
||||
throw new Error('文件为空')
|
||||
}
|
||||
|
||||
// 解析 CSV 行
|
||||
const parseLine = (line, delimiter) => {
|
||||
const cells = []
|
||||
let cell = ''
|
||||
let inQuotes = false
|
||||
|
||||
for (let i = 0; i < line.length; i++) {
|
||||
const char = line[i]
|
||||
if (char === '"') {
|
||||
if (inQuotes && line[i + 1] === '"') {
|
||||
cell += '"'
|
||||
i++
|
||||
} else {
|
||||
inQuotes = !inQuotes
|
||||
}
|
||||
} else if (char === delimiter && !inQuotes) {
|
||||
cells.push(cell)
|
||||
cell = ''
|
||||
} else {
|
||||
cell += char
|
||||
}
|
||||
}
|
||||
cells.push(cell)
|
||||
return cells
|
||||
}
|
||||
|
||||
const delimiter = file.name.endsWith('.tsv') ? '\t' : ','
|
||||
const rows = lines.map(line => parseLine(line, delimiter))
|
||||
|
||||
container.innerHTML = `
|
||||
<div class="csv-preview">
|
||||
<div class="csv-info">📋 ${file.name}</div>
|
||||
<div class="csv-content"></div>
|
||||
</div>
|
||||
<style>
|
||||
.csv-preview{display:flex;flex-direction:column;height:100%;overflow:hidden}
|
||||
.csv-info{padding:4px 12px;background:var(--color-fill-1);font-size:12px;color:var(--color-text-3);border-bottom:1px solid var(--color-border-2)}
|
||||
.csv-content{flex:1;overflow:auto;padding:12px}
|
||||
.csv-content table{width:100%;border-collapse:collapse;font-size:13px}
|
||||
.csv-content td,.csv-content th{border:1px solid var(--color-border-2);padding:6px 10px;text-align:left;white-space:nowrap;color:var(--color-text-1)}
|
||||
.csv-content th{background:var(--color-fill-2);font-weight:600;position:sticky;top:0;z-index:2}
|
||||
.csv-content .row-num{background:var(--color-fill-1);color:var(--color-text-3);text-align:center;min-width:10px;position:sticky;left:0;z-index:1}
|
||||
.csv-content th.row-num{z-index:3;top:0;left:0}
|
||||
.csv-content tr:hover td{background:var(--color-fill-1)}
|
||||
.csv-content tr:hover .row-num{background:var(--color-fill-2)}
|
||||
</style>
|
||||
`
|
||||
|
||||
const contentEl = container.querySelector('.csv-content')
|
||||
const infoEl = container.querySelector('.csv-info')
|
||||
|
||||
// 状态
|
||||
let renderedRows = 0
|
||||
const totalRows = rows.length
|
||||
|
||||
// 渲染表格
|
||||
const renderTable = (startRow = 0) => {
|
||||
const endRow = Math.min(startRow + BATCH_ROWS, totalRows)
|
||||
let html = '<table>'
|
||||
|
||||
// 表头(第一行)
|
||||
if (startRow === 0 && rows[0]) {
|
||||
html += '<thead><tr><th class="row-num">#</th>'
|
||||
rows[0].forEach(cell => { html += `<th>${escapeHtml(cell)}</th>` })
|
||||
html += '</tr></thead><tbody>'
|
||||
}
|
||||
|
||||
// 数据行
|
||||
for (let i = Math.max(1, startRow); i < endRow; i++) {
|
||||
html += `<tr><td class="row-num">${i}</td>`
|
||||
if (rows[i]) {
|
||||
rows[i].forEach(cell => { html += `<td>${escapeHtml(cell)}</td>` })
|
||||
}
|
||||
html += '</tr>'
|
||||
}
|
||||
html += '</tbody></table>'
|
||||
return html
|
||||
}
|
||||
|
||||
// 追加行
|
||||
const appendRows = (fromRow) => {
|
||||
const endRow = Math.min(fromRow + BATCH_ROWS, totalRows)
|
||||
const tbody = contentEl.querySelector('tbody')
|
||||
if (!tbody) return
|
||||
|
||||
for (let i = fromRow; i < endRow; i++) {
|
||||
let html = `<tr><td class="row-num">${i}</td>`
|
||||
if (rows[i]) {
|
||||
rows[i].forEach(cell => { html += `<td>${escapeHtml(cell)}</td>` })
|
||||
}
|
||||
html += '</tr>'
|
||||
tbody.insertAdjacentHTML('beforeend', html)
|
||||
}
|
||||
return endRow
|
||||
}
|
||||
|
||||
// 更新信息
|
||||
const updateInfo = () => {
|
||||
if (renderedRows >= totalRows) {
|
||||
infoEl.textContent = `📋 ${file.name}(共 ${totalRows - 1} 行)`
|
||||
} else {
|
||||
infoEl.textContent = `📋 ${file.name}(已加载 ${renderedRows}/${totalRows - 1} 行)`
|
||||
}
|
||||
}
|
||||
|
||||
// 初始渲染
|
||||
contentEl.innerHTML = renderTable(0)
|
||||
renderedRows = Math.min(BATCH_ROWS, totalRows)
|
||||
updateInfo()
|
||||
|
||||
// 滚动加载
|
||||
contentEl.onscroll = () => {
|
||||
const { scrollTop, scrollHeight, clientHeight } = contentEl
|
||||
if (scrollTop + clientHeight >= scrollHeight - LOAD_MORE_THRESHOLD) {
|
||||
if (renderedRows < totalRows) {
|
||||
renderedRows = appendRows(renderedRows)
|
||||
updateInfo()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { success: true }
|
||||
} catch (err) {
|
||||
console.error('[previewCsv] 错误:', err)
|
||||
return { success: false, error: err?.message || String(err) }
|
||||
}
|
||||
}
|
||||
205
frontend/src/utils/fileTypeHelpers.js
Normal file
205
frontend/src/utils/fileTypeHelpers.js
Normal file
@@ -0,0 +1,205 @@
|
||||
/**
|
||||
* 文件类型判断工具函数
|
||||
*
|
||||
* @module utils/fileTypeHelpers
|
||||
* @description 统一文件类型判断逻辑,避免内联重复定义
|
||||
*/
|
||||
|
||||
import { FILE_EXTENSIONS } from './constants'
|
||||
import { getExt } from './fileUtils'
|
||||
|
||||
/**
|
||||
* 可预览的文件类型(有专门的预览处理)
|
||||
* @type {string[]}
|
||||
*/
|
||||
export const PREVIEWABLE_TYPES = [
|
||||
...FILE_EXTENSIONS.IMAGE,
|
||||
...FILE_EXTENSIONS.VIDEO_BROWSER,
|
||||
...FILE_EXTENSIONS.AUDIO,
|
||||
'pdf', 'html', 'htm', 'md', 'markdown',
|
||||
// Office 文件支持预览
|
||||
'xlsx', 'xls', 'docx', 'doc',
|
||||
// CSV/TSV 表格文件
|
||||
'csv', 'tsv'
|
||||
]
|
||||
|
||||
/**
|
||||
* 已知二进制文件类型(直接显示二进制文件信息)
|
||||
* @type {string[]}
|
||||
*/
|
||||
export const KNOWN_BINARY_TYPES = [
|
||||
// 可执行文件
|
||||
'exe', 'dll', 'so', 'bin',
|
||||
// 压缩文件
|
||||
'zip', 'rar', '7z', 'tar', 'gz', 'bz2', 'xz', 'iso', 'img', 'dmg',
|
||||
// Office PowerPoint(暂不支持预览)
|
||||
'ppt', 'pptx',
|
||||
// 其他二进制
|
||||
'pdb', 'idb', 'lib', 'obj', 'o', 'a'
|
||||
]
|
||||
|
||||
/**
|
||||
* 文本可编辑类型(包括代码、配置和文本文件)
|
||||
* @type {string[]}
|
||||
*/
|
||||
export const TEXT_EDITABLE_TYPES = [
|
||||
...FILE_EXTENSIONS.CODE,
|
||||
...FILE_EXTENSIONS.CONFIG,
|
||||
...FILE_EXTENSIONS.TEXT
|
||||
]
|
||||
|
||||
/**
|
||||
* 判断是否为图片文件
|
||||
* @param {string} path - 文件路径或扩展名
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export const isImageFile = (path) => {
|
||||
const ext = getExt(path)
|
||||
return FILE_EXTENSIONS.IMAGE.includes(ext)
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断是否为视频文件
|
||||
* @param {string} path - 文件路径或扩展名
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export const isVideoFile = (path) => {
|
||||
const ext = getExt(path)
|
||||
return FILE_EXTENSIONS.VIDEO_BROWSER.includes(ext)
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断是否为音频文件
|
||||
* @param {string} path - 文件路径或扩展名
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export const isAudioFile = (path) => {
|
||||
const ext = getExt(path)
|
||||
return FILE_EXTENSIONS.AUDIO.includes(ext)
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断是否为 PDF 文件
|
||||
* @param {string} path - 文件路径或扩展名
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export const isPdfFile = (path) => {
|
||||
const ext = getExt(path)
|
||||
return ext === 'pdf'
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断是否为 HTML 文件
|
||||
* @param {string} path - 文件路径或扩展名
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export const isHtmlFile = (path) => {
|
||||
const ext = getExt(path)
|
||||
return ['html', 'htm'].includes(ext)
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断是否为 Markdown 文件
|
||||
* @param {string} path - 文件路径或扩展名
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export const isMarkdownFile = (path) => {
|
||||
const ext = getExt(path)
|
||||
return ['md', 'markdown'].includes(ext)
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断文件是否支持预览模式
|
||||
* @param {string} path - 文件路径
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export const isPreviewable = (path) => {
|
||||
const ext = getExt(path)
|
||||
return PREVIEWABLE_TYPES.includes(ext)
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断文件是否为已知二进制类型
|
||||
* @param {string} path - 文件路径
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export const isKnownBinary = (path) => {
|
||||
const ext = getExt(path)
|
||||
return KNOWN_BINARY_TYPES.includes(ext)
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断文件是否可文本编辑
|
||||
* @param {string} path - 文件路径
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export const isTextEditable = (path) => {
|
||||
const ext = getExt(path)
|
||||
return TEXT_EDITABLE_TYPES.includes(ext)
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断是否为配置文件
|
||||
* @param {string} path - 文件路径
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export const isConfigFile = (path) => {
|
||||
const ext = getExt(path)
|
||||
return FILE_EXTENSIONS.CONFIG.includes(ext)
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取文件类型分类
|
||||
* @param {string} path - 文件路径
|
||||
* @returns {string} 类型分类:'image' | 'video' | 'audio' | 'pdf' | 'html' | 'markdown' | 'text' | 'binary' | 'unknown'
|
||||
*/
|
||||
export const getFileCategory = (path) => {
|
||||
if (isImageFile(path)) return 'image'
|
||||
if (isVideoFile(path)) return 'video'
|
||||
if (isAudioFile(path)) return 'audio'
|
||||
if (isPdfFile(path)) return 'pdf'
|
||||
if (isHtmlFile(path)) return 'html'
|
||||
if (isMarkdownFile(path)) return 'markdown'
|
||||
if (isTextEditable(path)) return 'text'
|
||||
if (isKnownBinary(path)) return 'binary'
|
||||
return 'unknown'
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断是否为 Excel 文件
|
||||
* @param {string} path - 文件路径
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export const isExcelFile = (path) => {
|
||||
const ext = getExt(path)
|
||||
return ['xlsx', 'xls'].includes(ext)
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断是否为 Word 文件
|
||||
* @param {string} path - 文件路径
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export const isWordFile = (path) => {
|
||||
const ext = getExt(path)
|
||||
return ['docx', 'doc'].includes(ext)
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断是否为 Office 文件
|
||||
* @param {string} path - 文件路径
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export const isOfficeFile = (path) => {
|
||||
return isExcelFile(path) || isWordFile(path) || ['ppt', 'pptx'].includes(getExt(path))
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断是否为 CSV/TSV 文件
|
||||
* @param {string} path - 文件路径
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export const isCsvFile = (path) => {
|
||||
const ext = getExt(path)
|
||||
return ['csv', 'tsv'].includes(ext)
|
||||
}
|
||||
272
frontend/src/utils/fileUtils.js
Normal file
272
frontend/src/utils/fileUtils.js
Normal file
@@ -0,0 +1,272 @@
|
||||
/**
|
||||
* 文件工具函数集合
|
||||
*
|
||||
* @module utils/fileUtils
|
||||
* @description 提供文件相关的通用工具函数,避免代码重复
|
||||
*/
|
||||
|
||||
import { FILE_ICON_MAP, FILE_ICONS, BYTE_UNITS, FILE_SIZE_FORMAT } from './constants'
|
||||
|
||||
/**
|
||||
* 路径分隔符正则(匹配 Windows 和 Unix 风格)
|
||||
* @type {RegExp}
|
||||
*/
|
||||
const PATH_SEPARATOR_REGEX = /[/\\]/
|
||||
|
||||
/**
|
||||
* 规范化路径分隔符(统一为正斜杠)
|
||||
* @param {string} path - 文件路径
|
||||
* @returns {string} 规范化后的路径
|
||||
*/
|
||||
export const normalizePathSeparators = (path) => {
|
||||
if (!path) return ''
|
||||
return path.replace(/\\/g, '/')
|
||||
}
|
||||
|
||||
/**
|
||||
* HTML 转义,防止 XSS
|
||||
* @param {string} str - 原始字符串
|
||||
* @returns {string} 转义后的字符串
|
||||
*/
|
||||
export const escapeHtml = (str) => {
|
||||
if (str == null) return ''
|
||||
return String(str)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''')
|
||||
}
|
||||
|
||||
/**
|
||||
* 轻量 HTML 消毒(用于渲染远程 Markdown 等不可信 HTML 片段)
|
||||
* 移除 script/iframe/object/embed 标签和 on* 事件属性
|
||||
*/
|
||||
export const sanitizeHtml = (html) => {
|
||||
if (!html) return ''
|
||||
return String(html)
|
||||
.replace(/<script\b[^<]*(?:<\/script>|$)/gi, '')
|
||||
.replace(/<iframe\b[^<]*(?:<\/iframe>|$)/gi, '')
|
||||
.replace(/<object\b[^<]*(?:<\/object>|$)/gi, '')
|
||||
.replace(/<embed\b[^>]*\/?>/gi, '')
|
||||
.replace(/\s+on\w+\s*=\s*["'][^"']*["']/gi, '')
|
||||
.replace(/\s+on\w+\s*=\s*[^\s>]*/gi, '')
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取文件扩展名(路径安全)
|
||||
* @param {string} path - 文件路径
|
||||
* @returns {string} 扩展名(小写,不含点号)
|
||||
*/
|
||||
export const getExt = (path) => {
|
||||
if (!path) return ''
|
||||
const dot = path.lastIndexOf('.')
|
||||
const slash = Math.max(path.lastIndexOf('/'), path.lastIndexOf('\\'))
|
||||
if (dot === -1 || dot < slash) return ''
|
||||
return path.slice(dot + 1).toLowerCase()
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化文件大小
|
||||
* @param {number} bytes - 文件大小(字节)
|
||||
* @returns {string} 格式化后的文件大小字符串
|
||||
*
|
||||
* @example
|
||||
* formatBytes(1024) // "1.00 KB"
|
||||
* formatBytes(1048576) // "1.00 MB"
|
||||
* formatBytes(0) // "0 B"
|
||||
*/
|
||||
export function formatBytes(bytes) {
|
||||
if (!bytes || bytes === 0) return '0 B'
|
||||
if (typeof bytes !== 'number' || isNaN(bytes)) return '0 B'
|
||||
|
||||
const unit = FILE_SIZE_FORMAT.UNIT
|
||||
const decimals = FILE_SIZE_FORMAT.DECIMAL_PLACES
|
||||
|
||||
if (bytes < unit) return bytes + ' B'
|
||||
|
||||
const exp = Math.min(Math.floor(Math.log(bytes) / Math.log(unit)), BYTE_UNITS.length - 1)
|
||||
const value = bytes / Math.pow(unit, exp)
|
||||
const unitSymbol = BYTE_UNITS[exp]
|
||||
|
||||
return value.toFixed(decimals) + ' ' + unitSymbol
|
||||
}
|
||||
|
||||
/**
|
||||
* 从文件路径中提取文件名
|
||||
* @param {string} path - 文件路径
|
||||
* @returns {string} 文件名
|
||||
*
|
||||
* @example
|
||||
* getFileName('/home/user/docs/file.txt') // "file.txt"
|
||||
* getFileName('C:\\Users\\user\\file.txt') // "file.txt"
|
||||
* getFileName('file.txt') // "file.txt"
|
||||
*/
|
||||
export function getFileName(path) {
|
||||
if (!path) return ''
|
||||
const parts = path.split(PATH_SEPARATOR_REGEX)
|
||||
return parts[parts.length - 1] || path
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 根据文件信息获取对应的图标
|
||||
* @param {Object} fileInfo - 文件信息对象
|
||||
* @param {boolean} fileInfo.is_dir - 是否为目录
|
||||
* @param {string} fileInfo.name - 文件名
|
||||
* @returns {string} 文件图标(emoji)
|
||||
*
|
||||
* @example
|
||||
* getFileIcon({ is_dir: true }) // "📁"
|
||||
* getFileIcon({ is_dir: false, name: 'image.png' }) // "🖼️"
|
||||
* getFileIcon({ is_dir: false, name: 'document.pdf' }) // "📕"
|
||||
*/
|
||||
export function getFileIcon(fileInfo) {
|
||||
if (!fileInfo) return FILE_ICONS.FILE
|
||||
|
||||
// 如果是目录
|
||||
if (fileInfo.is_dir) {
|
||||
return FILE_ICONS.FOLDER
|
||||
}
|
||||
|
||||
// 获取文件扩展名
|
||||
const ext = getExt(fileInfo.name)
|
||||
|
||||
// 从映射表中查找图标
|
||||
return FILE_ICON_MAP.get(ext) || FILE_ICONS.FILE
|
||||
}
|
||||
|
||||
/**
|
||||
* 规范化文件路径(将反斜杠转换为正斜杠,并进行URL编码)
|
||||
* @param {string} path - 原始路径
|
||||
* @param {boolean} encode - 是否进行URL编码(用于URL路径)
|
||||
* @returns {string} 规范化后的路径
|
||||
*
|
||||
* @example
|
||||
* normalizeFilePath('C:\\Users\\user\\file.txt') // "C:/Users/user/file.txt"
|
||||
* normalizeFilePath('/home/user/file.txt') // "/home/user/file.txt"
|
||||
* normalizeFilePath('E:/中文路径/file.pdf', true) // "E:/%E4%B8%AD%E6%96%87%E8%B7%AF%E5%BE%84/file.pdf"
|
||||
*/
|
||||
export function normalizeFilePath(path, encode = false) {
|
||||
if (!path) return ''
|
||||
const normalized = normalizePathSeparators(path)
|
||||
|
||||
// 如果需要编码,则使用 encodeURIComponent
|
||||
if (encode) {
|
||||
const parts = normalized.split('/')
|
||||
// 只对包含需要编码字符的路径段进行编码
|
||||
// Windows 路径格式: E:/path/to/file,第一部分是盘符(如 E:),不应编码
|
||||
return parts.map((segment, index) => {
|
||||
// 盘符部分(如 "E:")不编码
|
||||
if (index === 0 && /^[A-Za-z]:$/.test(segment)) {
|
||||
return segment
|
||||
}
|
||||
// 检查是否需要编码(包含非ASCII字符或特殊字符)
|
||||
if (/[^A-Za-z0-9\-_.~]/.test(segment)) {
|
||||
return encodeURIComponent(segment)
|
||||
}
|
||||
return segment
|
||||
}).join('/')
|
||||
}
|
||||
|
||||
return normalized
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取路径使用的分隔符(Windows 反斜杠或 Unix 正斜杠)
|
||||
* @param {string} path - 文件路径
|
||||
* @returns {string} 分隔符 '\\' 或 '/'
|
||||
*/
|
||||
export function getPathSeparator(path) {
|
||||
return path.includes('\\') ? '\\' : '/'
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取父目录路径
|
||||
* @param {string} path - 文件路径
|
||||
* @returns {string} 父目录路径
|
||||
*
|
||||
* @example
|
||||
* getParentPath('/home/user/docs/file.txt') // "/home/user/docs"
|
||||
* getParentPath('/home/user/docs/') // "/home/user"
|
||||
*/
|
||||
export function getParentPath(path) {
|
||||
if (!path) return ''
|
||||
|
||||
const normalizedPath = normalizePathSeparators(path)
|
||||
const lastSlashIndex = normalizedPath.lastIndexOf('/')
|
||||
|
||||
if (lastSlashIndex <= 0) {
|
||||
if (/^[A-Za-z]:$/.test(normalizedPath)) {
|
||||
return normalizedPath + '/'
|
||||
}
|
||||
return normalizedPath || '/'
|
||||
}
|
||||
|
||||
const parentPath = normalizedPath.substring(0, lastSlashIndex)
|
||||
|
||||
// 盘符根目录下文件:E:/file.txt → E:/
|
||||
if (/^[A-Za-z]:$/.test(parentPath)) {
|
||||
return parentPath + '/'
|
||||
}
|
||||
|
||||
return parentPath || '/'
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 文件列表排序:文件夹优先,支持多字段排序
|
||||
* @param {Array} fileList - 文件列表
|
||||
* @param {Object} options - 排序选项 { sortBy, sortOrder }
|
||||
* @returns {Array} 排序后的文件列表
|
||||
*
|
||||
* @example
|
||||
* sortFileList(fileList, { sortBy: 'name', sortOrder: 'asc' })
|
||||
*/
|
||||
|
||||
/**
|
||||
* 格式化文件修改时间
|
||||
* @param {string} t - 时间字符串
|
||||
* @returns {string} 格式化后的时间,如 2026/04/11 14:30
|
||||
*/
|
||||
export function formatFileTime(t) {
|
||||
if (!t) return ''
|
||||
const d = new Date(t)
|
||||
if (isNaN(d.getTime())) return t
|
||||
const pad = n => String(n).padStart(2, '0')
|
||||
return `${d.getFullYear()}/${pad(d.getMonth()+1)}/${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`
|
||||
}
|
||||
|
||||
export function sortFileList(fileList, options = {}) {
|
||||
if (!Array.isArray(fileList)) return fileList
|
||||
|
||||
const { sortBy = 'name', sortOrder = 'asc' } = options
|
||||
const dir = sortOrder === 'desc' ? 1 : -1
|
||||
|
||||
return fileList.sort((a, b) => {
|
||||
// 文件夹始终排在前面
|
||||
if (a.isDir !== b.isDir) {
|
||||
return a.isDir ? -1 : 1
|
||||
}
|
||||
|
||||
let cmp = 0
|
||||
switch (sortBy) {
|
||||
case 'size':
|
||||
cmp = (a.size || 0) - (b.size || 0)
|
||||
break
|
||||
case 'type': {
|
||||
cmp = getExt(a.name).localeCompare(getExt(b.name))
|
||||
break
|
||||
}
|
||||
case 'modified_time': {
|
||||
const ta = a.modified_time ? new Date(a.modified_time).getTime() : 0
|
||||
const tb = b.modified_time ? new Date(b.modified_time).getTime() : 0
|
||||
cmp = ta - tb
|
||||
break
|
||||
}
|
||||
default: // name
|
||||
cmp = a.name.localeCompare(b.name)
|
||||
}
|
||||
return cmp * dir
|
||||
})
|
||||
}
|
||||
151
frontend/src/utils/languageMap.ts
Normal file
151
frontend/src/utils/languageMap.ts
Normal file
@@ -0,0 +1,151 @@
|
||||
/**
|
||||
* 统一语言映射
|
||||
* 供 highlight.js(Markdown 预览)和 CodeMirror(代码编辑器)共用
|
||||
*/
|
||||
|
||||
/**
|
||||
* 文件扩展名/缩写 → 语言标识符
|
||||
* - hljs: 用于 markedExtensions.ts 的代码块高亮
|
||||
* - cm: 用于 codeMirrorLoader.js 的编辑器语言
|
||||
* 值为 false 表示该扩展名不对应任何编程语言
|
||||
*/
|
||||
const extensionToLanguage: Record<string, { hljs?: string; cm?: string }> = {
|
||||
// === JavaScript / TypeScript ===
|
||||
js: { hljs: 'javascript', cm: 'javascript' },
|
||||
jsx: { hljs: 'javascript', cm: 'javascript' },
|
||||
mjs: { hljs: 'javascript', cm: 'javascript' },
|
||||
cjs: { hljs: 'javascript', cm: 'javascript' },
|
||||
ts: { hljs: 'typescript', cm: 'typescript' },
|
||||
tsx: { hljs: 'typescript', cm: 'typescript' },
|
||||
cts: { hljs: 'typescript', cm: 'typescript' },
|
||||
mts: { hljs: 'typescript', cm: 'typescript' },
|
||||
|
||||
// === Web ===
|
||||
html: { hljs: 'xml', cm: 'html' },
|
||||
htm: { hljs: 'xml', cm: 'html' },
|
||||
css: { hljs: 'css', cm: 'css' },
|
||||
scss: { hljs: 'scss', cm: 'css' },
|
||||
sass: { hljs: 'scss', cm: 'css' },
|
||||
less: { hljs: 'less', cm: 'css' },
|
||||
vue: { hljs: 'xml', cm: 'html' },
|
||||
|
||||
// === 数据格式 ===
|
||||
json: { hljs: 'json', cm: 'json' },
|
||||
xml: { hljs: 'xml', cm: 'html' },
|
||||
yaml: { hljs: 'yaml', cm: 'yaml' },
|
||||
yml: { hljs: 'yaml', cm: 'yaml' },
|
||||
toml: { cm: 'text' },
|
||||
csv: { cm: 'text' },
|
||||
tsv: { cm: 'text' },
|
||||
|
||||
// === C / C++ / 系统编程 ===
|
||||
c: { hljs: 'c', cm: 'cpp' },
|
||||
cpp: { hljs: 'cpp', cm: 'cpp' },
|
||||
cc: { hljs: 'cpp', cm: 'cpp' },
|
||||
cxx: { hljs: 'cpp', cm: 'cpp' },
|
||||
h: { hljs: 'cpp', cm: 'cpp' },
|
||||
hpp: { hljs: 'cpp', cm: 'cpp' },
|
||||
hxx: { hljs: 'cpp', cm: 'cpp' },
|
||||
cs: { hljs: 'csharp', cm: 'text' },
|
||||
swift: { hljs: 'swift', cm: 'text' },
|
||||
kt: { hljs: 'kotlin', cm: 'text' },
|
||||
rs: { hljs: 'rust', cm: 'rust' },
|
||||
go: { hljs: 'go', cm: 'go' },
|
||||
java: { hljs: 'java', cm: 'java' },
|
||||
pch: { hljs: 'cpp', cm: 'cpp' },
|
||||
tcc: { hljs: 'cpp', cm: 'cpp' },
|
||||
|
||||
// === 脚本 ===
|
||||
py: { hljs: 'python', cm: 'python' },
|
||||
pyw: { hljs: 'python', cm: 'python' },
|
||||
rb: { hljs: 'ruby', cm: 'text' },
|
||||
php: { hljs: 'php', cm: 'php' },
|
||||
sh: { hljs: 'bash', cm: 'shell' },
|
||||
bash: { hljs: 'bash', cm: 'shell' },
|
||||
shell: { hljs: 'bash', cm: 'shell' },
|
||||
zsh: { hljs: 'bash', cm: 'shell' },
|
||||
ps1: { hljs: 'powershell', cm: 'powershell' },
|
||||
bat: { hljs: 'dos', cm: 'text' },
|
||||
ahk: { hljs: 'autohotkey', cm: 'text' },
|
||||
lua: { hljs: 'lua', cm: 'text' },
|
||||
r: { hljs: 'r', cm: 'text' },
|
||||
m: { hljs: 'objectivec', cm: 'text' },
|
||||
scala: { hljs: 'scala', cm: 'text' },
|
||||
dart: { hljs: 'dart', cm: 'dart' },
|
||||
|
||||
// === 数据库 / 标记 ===
|
||||
sql: { hljs: 'sql', cm: 'sql' },
|
||||
md: { hljs: 'markdown', cm: 'markdown' },
|
||||
markdown: { hljs: 'markdown', cm: 'markdown' },
|
||||
tex: { hljs: 'latex', cm: 'text' },
|
||||
rst: { hljs: 'plaintext', cm: 'text' },
|
||||
adoc: { hljs: 'plaintext', cm: 'text' },
|
||||
|
||||
// === 构建工具 / 配置 ===
|
||||
// CodeMirror 6 无内置 Dockerfile 支持,用 shell 模式近似(Dockerfile 本质是类 shell 指令)
|
||||
dockerfile: { hljs: 'dockerfile', cm: 'shell' },
|
||||
makefile: { hljs: 'makefile', cm: 'text' },
|
||||
mk: { hljs: 'makefile', cm: 'text' },
|
||||
cmake: { hljs: 'cmake', cm: 'text' },
|
||||
ini: { hljs: 'ini', cm: 'text' },
|
||||
cfg: { hljs: 'ini', cm: 'text' },
|
||||
conf: { hljs: 'ini', cm: 'text' },
|
||||
env: { cm: 'text' },
|
||||
props: { cm: 'text' },
|
||||
manifest: { cm: 'text' },
|
||||
lock: { cm: 'text' },
|
||||
ignore: { cm: 'text' },
|
||||
|
||||
// === 纯文本 ===
|
||||
txt: { cm: 'text' },
|
||||
text: { cm: 'text' },
|
||||
log: { cm: 'text' },
|
||||
msg: { cm: 'text' },
|
||||
}
|
||||
|
||||
// 从映射表中收集所有已知的 hljs 语言名
|
||||
const knownHljsLanguages = new Set(
|
||||
Object.values(extensionToLanguage)
|
||||
.map(v => v.hljs)
|
||||
.filter(Boolean) as string[]
|
||||
)
|
||||
|
||||
// highlight.js lib/common 内置的常用语言名(代码块标记直接使用)
|
||||
const commonLangNames = new Set([
|
||||
'javascript', 'typescript', 'python', 'java', 'go', 'rust', 'c', 'cpp',
|
||||
'csharp', 'php', 'ruby', 'swift', 'kotlin', 'scala', 'dart', 'lua',
|
||||
'r', 'bash', 'shell', 'powershell', 'dos', 'cmd', 'sql', 'yaml', 'json', 'xml',
|
||||
'markdown', 'css', 'scss', 'less', 'html', 'ini', 'makefile', 'dockerfile',
|
||||
'cmake', 'latex', 'plaintext', 'diff', 'graphql', 'nginx', 'perl',
|
||||
'objectivec', 'haskell', 'elixir', 'erlang', 'clojure', 'ocaml',
|
||||
'vbnet', 'wasm', 'fsharp', 'groovy', 'julia', 'matlab', 'zig'
|
||||
])
|
||||
|
||||
/**
|
||||
* 获取 hljs 语言标识(带别名解析)
|
||||
*/
|
||||
export function getHljsLanguage(langOrExt: string): string {
|
||||
if (!langOrExt) return 'plaintext'
|
||||
const lower = langOrExt.toLowerCase()
|
||||
|
||||
// 1. 直接是已知语言名(代码块 ```python / ```typescript 等)
|
||||
if (commonLangNames.has(lower)) return lower
|
||||
|
||||
// 2. 扩展名映射(.ts → typescript, .py → python 等)
|
||||
const mapped = extensionToLanguage[lower]?.hljs
|
||||
if (mapped) return mapped
|
||||
|
||||
// 3. 已在 known 集合中的映射值
|
||||
if (knownHljsLanguages.has(lower)) return lower
|
||||
|
||||
return 'plaintext'
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取 CodeMirror 语言标识
|
||||
*/
|
||||
export function getCmLanguage(extension: string): string {
|
||||
if (!extension) return 'text'
|
||||
const lower = extension.toLowerCase()
|
||||
return extensionToLanguage[lower]?.cm || 'text'
|
||||
}
|
||||
251
frontend/src/utils/markedExtensions.ts
Normal file
251
frontend/src/utils/markedExtensions.ts
Normal file
@@ -0,0 +1,251 @@
|
||||
import { marked } from 'marked'
|
||||
import hljs from 'highlight.js'
|
||||
import 'highlight.js/lib/common'
|
||||
// 按需导入 common 包不包含的语言
|
||||
import 'highlight.js/lib/languages/powershell'
|
||||
import 'highlight.js/lib/languages/dos'
|
||||
import 'highlight.js/lib/languages/autohotkey'
|
||||
import 'highlight.js/lib/languages/latex'
|
||||
import 'highlight.js/lib/languages/dockerfile'
|
||||
import 'highlight.js/lib/languages/cmake'
|
||||
import 'highlight.js/lib/languages/scala'
|
||||
import 'highlight.js/lib/languages/dart'
|
||||
import { getHljsLanguage } from './languageMap'
|
||||
|
||||
let mermaidInstance: typeof import('mermaid').default | null = null
|
||||
let mermaidTheme: string | null = null
|
||||
|
||||
// 检测当前是否为暗色主题
|
||||
function isDarkTheme(): boolean {
|
||||
if (typeof document === 'undefined') return false
|
||||
return document.body.getAttribute('arco-theme')?.includes('dark') ?? false
|
||||
}
|
||||
|
||||
async function loadMermaid() {
|
||||
const currentTheme = isDarkTheme() ? 'dark' : 'default'
|
||||
|
||||
if (mermaidInstance && mermaidTheme === currentTheme) {
|
||||
return mermaidInstance
|
||||
}
|
||||
|
||||
if (!mermaidInstance) {
|
||||
const m = await import('mermaid')
|
||||
mermaidInstance = m.default
|
||||
}
|
||||
mermaidInstance.initialize({
|
||||
startOnLoad: false,
|
||||
theme: currentTheme,
|
||||
securityLevel: 'strict',
|
||||
themeVariables: Object.assign({
|
||||
primaryColor: '#165DFF',
|
||||
primaryTextColor: '#ffffff',
|
||||
primaryBorderColor: '#4080FF'
|
||||
}, currentTheme === 'dark' ? {
|
||||
lineColor: '#4E5969',
|
||||
secondaryColor: '#0E42D2',
|
||||
tertiaryColor: '#0FC6C2',
|
||||
mainBkg: '#17171A',
|
||||
nodeBorder: '#165DFF',
|
||||
clusterBkg: '#232324',
|
||||
titleColor: '#FFFFFF',
|
||||
edgeLabelBackground: '#232324'
|
||||
} : {
|
||||
lineColor: '#86909C',
|
||||
secondaryColor: '#E8F3FF',
|
||||
tertiaryColor: '#722ED1',
|
||||
mainBkg: '#F2F3F5',
|
||||
nodeBorder: '#165DFF',
|
||||
clusterBkg: '#F7F8FA',
|
||||
titleColor: '#1D2129',
|
||||
edgeLabelBackground: '#F2F3F5'
|
||||
})
|
||||
})
|
||||
mermaidTheme = currentTheme
|
||||
return mermaidInstance
|
||||
}
|
||||
|
||||
const renderer = new marked.Renderer()
|
||||
|
||||
renderer.code = function(token: any) {
|
||||
if (token.lang === 'mermaid') {
|
||||
return `<pre class="mermaid">${token.text}</pre>`
|
||||
}
|
||||
|
||||
const lang = getHljsLanguage(token.lang)
|
||||
|
||||
let highlighted: string
|
||||
try {
|
||||
highlighted = hljs.highlight(token.text, { language: lang }).value
|
||||
} catch {
|
||||
highlighted = token.text.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
||||
}
|
||||
return `<pre><code class="hljs language-${lang}" data-theme="auto">${highlighted}</code></pre>`
|
||||
}
|
||||
|
||||
renderer.heading = function(token: any) {
|
||||
const raw = token.raw || ''
|
||||
const depth = token.depth || 1
|
||||
const text = token.text || ''
|
||||
|
||||
const id = raw
|
||||
.toLowerCase()
|
||||
.replace(/[^\u4e00-\u9fa5a-z0-9\s-]/g, '')
|
||||
.trim()
|
||||
.replace(/\s+/g, '-')
|
||||
.replace(/-+/g, '-')
|
||||
.replace(/^-+|-+$/g, '') || `heading-${Math.random().toString(36).slice(2, 11)}`
|
||||
|
||||
return `<h${depth} id="${id}" class="heading">
|
||||
${text}<a href="#${id}" class="heading-anchor" aria-hidden="true" title="跳转到此标题">#</a>
|
||||
</h${depth}>`
|
||||
}
|
||||
|
||||
// ========== 图片相对路径转换支持 ==========
|
||||
// 当前 Markdown 文件所在目录(由调用方在渲染前设置)
|
||||
let _currentFileDir: string = ''
|
||||
// 文件服务器 Base URL(由调用方在渲染前设置)
|
||||
let _fileServerBase: string = 'http://localhost:8073/localfs'
|
||||
|
||||
/**
|
||||
* 设置当前 Markdown 文件所在目录(用于图片相对路径→文件服务器 URL 转换)
|
||||
* @param dir 文件所在目录的绝对路径,如 "D:/docs" 或 "/"(根目录)
|
||||
*/
|
||||
export function setCurrentFileDir(dir: string): void {
|
||||
_currentFileDir = dir
|
||||
}
|
||||
|
||||
/** 获取当前设置的文件目录 */
|
||||
export function getCurrentFileDir(): string {
|
||||
return _currentFileDir
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置文件服务器 Base URL(用于图片相对路径转换)
|
||||
* @param base 完整的 base URL 前缀,如 "http://localhost:8073/localfs" 或 "https://host:port/api/v1/proxy/localfs"
|
||||
*/
|
||||
export function setFileServerBase(base: string): void {
|
||||
_fileServerBase = base
|
||||
}
|
||||
|
||||
/**
|
||||
* 将相对路径图片 src 解析为文件服务器 URL
|
||||
* - 绝对路径(Windows: D:/...、Unix: /usr/...)、网络URL、data URI → 不转换
|
||||
* - 相对路径 → 基于当前文件目录解析为绝对路径,再编码为文件服务器 URL
|
||||
*/
|
||||
function resolveImageUrl(src: string, fileServerBase: string): string {
|
||||
if (!src) return src
|
||||
// 不转换:绝对路径(Windows 盘符)、网络协议、锚点、data URI
|
||||
if (/^(?:[a-zA-Z]:[/\\]|\/(?:[^/]|$)|https?:|ftp:|data:|#)/i.test(src)) return src
|
||||
|
||||
// 解析相对路径(处理 ../ 和 ./)
|
||||
const dir = _currentFileDir || '/'
|
||||
const sep = dir.includes('\\') ? '\\' : '/'
|
||||
let resolved = normalizeRelativePath(dir, src, sep)
|
||||
|
||||
// 编码路径(保留 / 分隔符)
|
||||
const encoded = encodeURIComponent(resolved).replace(/%2F/gi, '/').replace(/%5C/gi, '\\')
|
||||
return `${fileServerBase}/${encoded}`
|
||||
}
|
||||
|
||||
/**
|
||||
* 规范化相对路径,处理 .. 和 . 段
|
||||
*/
|
||||
function normalizeRelativePath(base: string, relative: string, sep: string): string {
|
||||
// 确保基础路径不以分隔符结尾
|
||||
let baseNormalized = base.replace(/[\\/]+$/, '')
|
||||
if (!baseNormalized) baseNormalized = sep === '/' ? '/' : 'C:\\'
|
||||
|
||||
const baseParts = baseNormalized.split(sep).filter(Boolean)
|
||||
const relParts = relative.split(/[\\/]/).filter(Boolean)
|
||||
|
||||
for (const part of relParts) {
|
||||
if (part === '..') {
|
||||
baseParts.pop() // 向上一级
|
||||
} else if (part !== '.') {
|
||||
baseParts.push(part)
|
||||
}
|
||||
}
|
||||
|
||||
// 重建路径:Windows 绝对路径保留盘符前缀
|
||||
if (/^[a-zA-Z]:$/i.test(baseNormalized.split(sep)[0] || '')) {
|
||||
return baseParts.join(sep)
|
||||
}
|
||||
// Unix 风格:以 / 开头
|
||||
return sep + baseParts.join(sep)
|
||||
}
|
||||
|
||||
// 判断是否为本地文件链接(相对路径或本地绝对路径)
|
||||
const isLocalFileLink = (href: string): boolean => {
|
||||
if (!href) return false
|
||||
if (/^(https?|ftp|mailto|tel|data):/i.test(href)) return false
|
||||
if (href.startsWith('#')) return false
|
||||
return true
|
||||
}
|
||||
|
||||
// 自定义图片渲染器 - 转换相对路径为文件服务器 URL
|
||||
renderer.image = function(token: any) {
|
||||
const src = token.href || ''
|
||||
const title = token.title || ''
|
||||
const alt = token.text || ''
|
||||
const titleAttr = title ? ` title="${title}"` : ''
|
||||
|
||||
// 判断是否需要转换(仅处理相对路径,且当前目录已设置)
|
||||
if (_currentFileDir && !/^(?:[a-zA-Z]:[/\\]|\/(?:[^/]|$)|https?:|ftp:|data:|#)/i.test(src)) {
|
||||
const resolvedSrc = resolveImageUrl(src, _fileServerBase)
|
||||
return `<img src="${resolvedSrc}" alt="${alt}"${titleAttr}>`
|
||||
}
|
||||
|
||||
// 默认渲染(绝对路径 / 网络 URL / data URI / 未设置目录时原样输出)
|
||||
return `<img src="${src}" alt="${alt}"${titleAttr}>`
|
||||
}
|
||||
|
||||
// 自定义链接渲染器 - 支持本地文件链接
|
||||
renderer.link = function(token: any) {
|
||||
const href = token.href || ''
|
||||
const text = this.parser.parseInline(token.tokens) || token.text || ''
|
||||
const title = token.title || ''
|
||||
const titleAttr = title ? ` title="${title}"` : ''
|
||||
|
||||
if (href.startsWith('#')) {
|
||||
return `<a href="${href}${titleAttr}">${text}</a>`
|
||||
}
|
||||
|
||||
if (isLocalFileLink(href)) {
|
||||
return `<a href="javascript:void(0)" data-local-link="${href}" class="local-file-link"${titleAttr}>${text}</a>`
|
||||
}
|
||||
|
||||
return `<a href="${href}" target="_blank" rel="noopener noreferrer"${titleAttr}>${text}</a>`
|
||||
}
|
||||
|
||||
marked.use({ renderer, breaks: true, gfm: true, async: false })
|
||||
|
||||
export { marked }
|
||||
|
||||
export async function renderMermaidDiagrams() {
|
||||
const mermaid = await loadMermaid()
|
||||
if (mermaid) {
|
||||
// 渲染前保存原始源码(textContent 在 SVG 渲染后会变成 CSS 垃圾)
|
||||
document.querySelectorAll('.mermaid:not([data-mermaid-src])').forEach(pre => {
|
||||
;(pre as HTMLElement).setAttribute('data-mermaid-src', pre.textContent || '')
|
||||
})
|
||||
await mermaid.run()
|
||||
}
|
||||
}
|
||||
|
||||
/** 清除已渲染内容并重新渲染(用于主题切换后刷新) */
|
||||
export async function rerenderMermaidDiagrams(container?: HTMLElement | null) {
|
||||
// 强制重新加载(清除缓存,让下次 loadMermaid 重新初始化新主题)
|
||||
mermaidInstance = null
|
||||
mermaidTheme = null
|
||||
|
||||
const target = container || document
|
||||
target.querySelectorAll('.mermaid').forEach(pre => {
|
||||
const el = pre as HTMLElement
|
||||
if (el.getAttribute('data-processed')) {
|
||||
// 从保存的原始源码恢复,而非 textContent(SVG 的 textContent 是 CSS 垃圾)
|
||||
el.innerHTML = el.getAttribute('data-mermaid-src') || ''
|
||||
el.removeAttribute('data-processed')
|
||||
}
|
||||
})
|
||||
await renderMermaidDiagrams()
|
||||
}
|
||||
107
frontend/src/utils/resize.ts
Normal file
107
frontend/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')
|
||||
}
|
||||
}
|
||||
220
frontend/src/views/MarkdownViewer.vue
Normal file
220
frontend/src/views/MarkdownViewer.vue
Normal file
@@ -0,0 +1,220 @@
|
||||
<template>
|
||||
<div class="markdown-viewer-container">
|
||||
<div class="viewer-header">
|
||||
<div class="title">
|
||||
<icon-file-text />
|
||||
<span>{{ currentFile?.name || 'Markdown 文档' }}</span>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<PdfExportButton @export-complete="onExportComplete" />
|
||||
<a-button @click="handleBackToList" type="outline">
|
||||
<icon-arrow-left />
|
||||
返回列表
|
||||
</a-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="viewer-content">
|
||||
<MarkdownEditor
|
||||
:content="fileContent"
|
||||
@content-change="handleContentChange"
|
||||
@save="handleSave"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 底部状态栏 -->
|
||||
<div class="status-bar">
|
||||
<div class="file-info">
|
||||
<span>{{ currentFile?.path }}</span>
|
||||
</div>
|
||||
<div class="content-info">
|
||||
<span>{{ wordCount }} 字符 | {{ lineCount }} 行</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { Message } from '@arco-design/web-vue'
|
||||
import MarkdownEditor from '@/components/MarkdownEditor.vue'
|
||||
import PdfExportButton from '@/components/PdfExportButton.vue'
|
||||
import { useFileOperations } from '@/composables/useFileOperations'
|
||||
|
||||
export default {
|
||||
name: 'MarkdownViewer',
|
||||
components: {
|
||||
MarkdownEditor,
|
||||
PdfExportButton
|
||||
},
|
||||
props: {
|
||||
filePath: {
|
||||
type: String,
|
||||
required: true
|
||||
}
|
||||
},
|
||||
emits: ['back'],
|
||||
setup(props, { emit }) {
|
||||
const fileOperations = useFileOperations()
|
||||
const fileContent = ref('')
|
||||
const currentFile = ref(null)
|
||||
const hasChanges = ref(false)
|
||||
const lastSavedContent = ref('')
|
||||
|
||||
// 计算属性
|
||||
const wordCount = computed(() => {
|
||||
return fileContent.value.length
|
||||
})
|
||||
|
||||
const lineCount = computed(() => {
|
||||
return fileContent.value.split('\n').length
|
||||
})
|
||||
|
||||
// 方法
|
||||
const loadFile = async () => {
|
||||
try {
|
||||
const response = await fileOperations.readFile(props.filePath)
|
||||
fileContent.value = response.content
|
||||
lastSavedContent.value = response.content
|
||||
hasChanges.value = false
|
||||
|
||||
// 获取文件信息
|
||||
const fileName = props.filePath.split('/').pop() || props.filePath.split('\\').pop()
|
||||
currentFile.value = {
|
||||
name: fileName,
|
||||
path: props.filePath
|
||||
}
|
||||
} catch (error) {
|
||||
Message.error(`读取文件失败: ${error?.message ?? String(error)}`)
|
||||
}
|
||||
}
|
||||
|
||||
const handleContentChange = (content) => {
|
||||
hasChanges.value = content !== lastSavedContent.value
|
||||
}
|
||||
|
||||
const handleSave = async () => {
|
||||
try {
|
||||
await fileOperations.saveFile(props.filePath, fileContent.value)
|
||||
lastSavedContent.value = fileContent.value
|
||||
hasChanges.value = false
|
||||
Message.success('文件已保存')
|
||||
} catch (error) {
|
||||
Message.error(`保存文件失败: ${error?.message ?? String(error)}`)
|
||||
}
|
||||
}
|
||||
|
||||
const onExportComplete = () => {
|
||||
Message.success('PDF 导出完成')
|
||||
}
|
||||
|
||||
const handleBackToList = () => {
|
||||
emit('back')
|
||||
}
|
||||
|
||||
// 初始化
|
||||
onMounted(() => {
|
||||
loadFile()
|
||||
})
|
||||
|
||||
return {
|
||||
fileContent,
|
||||
currentFile,
|
||||
hasChanges,
|
||||
wordCount,
|
||||
lineCount,
|
||||
handleContentChange,
|
||||
handleSave,
|
||||
onExportComplete,
|
||||
handleBackToList
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.markdown-viewer-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100vh;
|
||||
background: #f5f5f5;
|
||||
}
|
||||
|
||||
.viewer-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 16px 20px;
|
||||
background: white;
|
||||
border-bottom: 1px solid #e8e8e8;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #1a1a1a;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.viewer-content {
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
background: white;
|
||||
margin: 16px;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.status-bar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 8px 20px;
|
||||
background: white;
|
||||
border-top: 1px solid #e8e8e8;
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.file-info {
|
||||
font-family: monospace;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.content-info {
|
||||
color: #666;
|
||||
}
|
||||
|
||||
/* 响应式设计 */
|
||||
@media (max-width: 768px) {
|
||||
.viewer-header {
|
||||
padding: 12px 16px;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.actions {
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.viewer-content {
|
||||
margin: 8px;
|
||||
}
|
||||
|
||||
.status-bar {
|
||||
padding: 6px 16px;
|
||||
font-size: 11px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
113
frontend/src/views/markdown-editor/index.vue
Normal file
113
frontend/src/views/markdown-editor/index.vue
Normal file
@@ -0,0 +1,113 @@
|
||||
<template>
|
||||
<div class="markdown-editor-page">
|
||||
<div class="editor-container">
|
||||
<MarkdownEditor
|
||||
v-model:content="markdownContent"
|
||||
@content-change="handleContentChange"
|
||||
@save="handleSave"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import MarkdownEditor from '../../components/MarkdownEditor.vue'
|
||||
import { Message } from '@arco-design/web-vue'
|
||||
|
||||
const markdownContent = ref('')
|
||||
|
||||
// 初始化示例内容
|
||||
const initSampleContent = () => {
|
||||
markdownContent.value = `# 欢迎使用 Markdown 编辑器
|
||||
|
||||
这是一个功能强大的 Markdown 编辑器,支持实时预览和 PDF 导出功能。
|
||||
|
||||
## 功能特性
|
||||
|
||||
- ✅ **实时预览** - 输入内容即时显示预览效果
|
||||
- ✅ **语法高亮** - 支持 GitHub 风格的 Markdown 语法
|
||||
- ✅ **PDF 导出** - 一键导出为格式化的 PDF 文档
|
||||
- ✅ **自动保存** - 支持 Ctrl + S 快捷键保存
|
||||
- ✅ **字数统计** - 实时显示字符数和行数
|
||||
|
||||
## 使用说明
|
||||
|
||||
### 基本语法
|
||||
|
||||
\`\`\`markdown
|
||||
# 一级标题
|
||||
## 二级标题
|
||||
### 三级标题
|
||||
|
||||
**粗体文本** 和 *斜体文本*
|
||||
|
||||
- 无序列表项 1
|
||||
- 无序列表项 2
|
||||
- 嵌套列表项 1
|
||||
|
||||
1. 有序列表项 1
|
||||
2. 有序列表项 2
|
||||
|
||||
\`\`\`
|
||||
|
||||
### 代码块
|
||||
|
||||
\`\`\`javascript
|
||||
function hello() {
|
||||
console.log('Hello, World!')
|
||||
}
|
||||
\`\`\`
|
||||
|
||||
### 表格
|
||||
|
||||
| 列 1 | 列 2 | 列 3 |
|
||||
|------|------|------|
|
||||
| 数据 1 | 数据 2 | 数据 3 |
|
||||
| 数据 4 | 数据 5 | 数据 6 |
|
||||
|
||||
### 引用
|
||||
|
||||
> 这是一个引用示例
|
||||
> 可以包含多行内容
|
||||
|
||||
---
|
||||
|
||||
**开始创作吧!**`
|
||||
}
|
||||
|
||||
const handleContentChange = (content) => {
|
||||
// 内容变化时的处理
|
||||
}
|
||||
|
||||
const handleSave = (content) => {
|
||||
// 保存处理
|
||||
console.log('Content saved:', content)
|
||||
Message.success('内容已保存到本地存储')
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
// 从本地存储加载之前保存的内容
|
||||
const savedContent = localStorage.getItem('u-desk-markdown-content')
|
||||
if (savedContent) {
|
||||
markdownContent.value = savedContent
|
||||
} else {
|
||||
// 没有保存的内容时显示示例内容
|
||||
initSampleContent()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.markdown-editor-page {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
padding: 20px;
|
||||
background: var(--color-bg-1);
|
||||
}
|
||||
|
||||
.editor-container {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
</style>
|
||||
136
frontend/src/views/version/index.vue
Normal file
136
frontend/src/views/version/index.vue
Normal file
@@ -0,0 +1,136 @@
|
||||
<template>
|
||||
<div class="version-history">
|
||||
<!-- 顶部操作栏 -->
|
||||
<div class="version-header">
|
||||
<div class="header-right">
|
||||
<a-button type="text" size="small" @click="handleRefresh" :loading="refreshing">
|
||||
<template #icon><icon-refresh /></template>
|
||||
刷新
|
||||
</a-button>
|
||||
<a-button type="text" size="small" @click="handleOpenExternal">
|
||||
<template #icon><icon-export /></template>
|
||||
在浏览器打开
|
||||
</a-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- iframe 嵌入远程页面 -->
|
||||
<div class="iframe-wrapper">
|
||||
<iframe
|
||||
ref="iframeRef"
|
||||
:src="iframeSrc"
|
||||
class="version-iframe"
|
||||
frameborder="0"
|
||||
@load="onIframeLoad"
|
||||
/>
|
||||
<div v-if="loading" class="iframe-loading">
|
||||
<a-spin size="32" />
|
||||
<p>加载中...</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { IconRefresh, IconExport } from '@arco-design/web-vue/es/icon'
|
||||
|
||||
// ==================== 常量 ====================
|
||||
const BASE_URL = 'https://c.1216.top/u-desk/version.html'
|
||||
|
||||
// ==================== 状态 ====================
|
||||
const currentVersion = ref('')
|
||||
const loading = ref(true)
|
||||
const refreshing = ref(false)
|
||||
const iframeRef = ref<HTMLIFrameElement | null>(null)
|
||||
|
||||
// ==================== 计算属性 ====================
|
||||
const iframeSrc = computed(() => {
|
||||
if (currentVersion.value) {
|
||||
return `${BASE_URL}?v=${currentVersion.value}&vis=u-desk`
|
||||
}
|
||||
return `${BASE_URL}?vis=u-desk`
|
||||
})
|
||||
|
||||
// ==================== 操作 ====================
|
||||
function handleRefresh(): void {
|
||||
refreshing.value = true
|
||||
loading.value = true
|
||||
if (iframeRef.value) {
|
||||
iframeRef.value.src = iframeSrc.value
|
||||
}
|
||||
}
|
||||
|
||||
function handleOpenExternal(): void {
|
||||
window.open(iframeSrc.value, '_blank')
|
||||
}
|
||||
|
||||
function onIframeLoad(): void {
|
||||
loading.value = false
|
||||
refreshing.value = false
|
||||
}
|
||||
|
||||
// ==================== 生命周期 ====================
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const result = await window.go?.main?.App?.GetCurrentVersion?.()
|
||||
if (result?.success) {
|
||||
currentVersion.value = result.data?.version || ''
|
||||
}
|
||||
} catch {
|
||||
// Wails 未就绪时忽略
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.version-history {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
padding: 0 4px;
|
||||
}
|
||||
|
||||
/* ====== 顶部栏 ====== */
|
||||
.version-header {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
align-items: center;
|
||||
margin-bottom: 8px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.header-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
/* ====== iframe ====== */
|
||||
.iframe-wrapper {
|
||||
flex: 1;
|
||||
position: relative;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.version-iframe {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
background: var(--color-bg-1);
|
||||
}
|
||||
|
||||
.iframe-loading {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 12px;
|
||||
color: var(--color-text-3);
|
||||
background: var(--color-bg-1);
|
||||
border-radius: 8px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,9 @@
|
||||
//@ts-check
|
||||
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
|
||||
// This file is automatically generated. DO NOT EDIT
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore: Unused imports
|
||||
import { Create as $Create } from "@wailsio/runtime";
|
||||
|
||||
Object.freeze($Create.Events);
|
||||
2
frontend/src/wailsjs/v3-bindings/github.com/wailsapp/wails/v3/internal/eventdata.d.ts
vendored
Normal file
2
frontend/src/wailsjs/v3-bindings/github.com/wailsapp/wails/v3/internal/eventdata.d.ts
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
|
||||
// This file is automatically generated. DO NOT EDIT
|
||||
@@ -0,0 +1,6 @@
|
||||
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
|
||||
// This file is automatically generated. DO NOT EDIT
|
||||
|
||||
export {
|
||||
WebviewWindow
|
||||
} from "./models.js";
|
||||
@@ -0,0 +1,23 @@
|
||||
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
|
||||
// This file is automatically generated. DO NOT EDIT
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore: Unused imports
|
||||
import { Create as $Create } from "@wailsio/runtime";
|
||||
|
||||
export class WebviewWindow {
|
||||
|
||||
/** Creates a new WebviewWindow instance. */
|
||||
constructor($$source: Partial<WebviewWindow> = {}) {
|
||||
|
||||
Object.assign(this, $$source);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new WebviewWindow instance from a string or object.
|
||||
*/
|
||||
static createFrom($$source: any = {}): WebviewWindow {
|
||||
let $$parsedSource = typeof $$source === 'string' ? JSON.parse($$source) : $$source;
|
||||
return new WebviewWindow($$parsedSource as Partial<WebviewWindow>);
|
||||
}
|
||||
}
|
||||
430
frontend/src/wailsjs/v3-bindings/u-desk/app.ts
Normal file
430
frontend/src/wailsjs/v3-bindings/u-desk/app.ts
Normal file
@@ -0,0 +1,430 @@
|
||||
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
|
||||
// This file is automatically generated. DO NOT EDIT
|
||||
|
||||
/**
|
||||
* App 应用结构体
|
||||
* @module
|
||||
*/
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore: Unused imports
|
||||
import { Call as $Call, CancellablePromise as $CancellablePromise, Create as $Create } from "@wailsio/runtime";
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore: Unused imports
|
||||
import * as application$0 from "../github.com/wailsapp/wails/v3/pkg/application/models.js";
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore: Unused imports
|
||||
import * as filesystem$0 from "./internal/filesystem/models.js";
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore: Unused imports
|
||||
import * as $models from "./models.js";
|
||||
|
||||
/**
|
||||
* CheckUpdate 检查更新
|
||||
*/
|
||||
export function CheckUpdate(): $CancellablePromise<{ [_ in string]?: any }> {
|
||||
return $Call.ByID(586574094).then(($result: any) => {
|
||||
return $$createType0($result);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* ClearCache 清理本地缓存(用于菜单项)
|
||||
*/
|
||||
export function ClearCache(): $CancellablePromise<void> {
|
||||
return $Call.ByID(1413834504);
|
||||
}
|
||||
|
||||
/**
|
||||
* CreateDir 创建目录
|
||||
*/
|
||||
export function CreateDir(path: string): $CancellablePromise<filesystem$0.FileOperationResult | null> {
|
||||
return $Call.ByID(632035444, path).then(($result: any) => {
|
||||
return $$createType2($result);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* CreateFile 创建文件
|
||||
*/
|
||||
export function CreateFile(path: string): $CancellablePromise<filesystem$0.FileOperationResult | null> {
|
||||
return $Call.ByID(3418645411, path).then(($result: any) => {
|
||||
return $$createType2($result);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* DeletePath 删除文件或目录
|
||||
*/
|
||||
export function DeletePath(path: string): $CancellablePromise<filesystem$0.FileOperationResult | null> {
|
||||
return $Call.ByID(1564637217, path).then(($result: any) => {
|
||||
return $$createType2($result);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* DeletePermanently 永久删除回收站中的文件
|
||||
*/
|
||||
export function DeletePermanently(recyclePath: string): $CancellablePromise<void> {
|
||||
return $Call.ByID(1697000327, recyclePath);
|
||||
}
|
||||
|
||||
/**
|
||||
* DetectFileTypeByContent 通过文件内容检测文件类型
|
||||
*/
|
||||
export function DetectFileTypeByContent(path: string): $CancellablePromise<{ [_ in string]?: any }> {
|
||||
return $Call.ByID(3067282982, path).then(($result: any) => {
|
||||
return $$createType0($result);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* DownloadUpdate 下载更新包
|
||||
*/
|
||||
export function DownloadUpdate(downloadURL: string): $CancellablePromise<{ [_ in string]?: any }> {
|
||||
return $Call.ByID(115027584, downloadURL).then(($result: any) => {
|
||||
return $$createType0($result);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* EmptyRecycleBin 清空回收站
|
||||
*/
|
||||
export function EmptyRecycleBin(): $CancellablePromise<void> {
|
||||
return $Call.ByID(4176312624);
|
||||
}
|
||||
|
||||
/**
|
||||
* ExportPDF 导出PDF文件
|
||||
*/
|
||||
export function ExportPDF(content: string, title: string, fileName: string, fontSize: number, pageWidth: number, pageHeight: number): $CancellablePromise<{ [_ in string]?: any }> {
|
||||
return $Call.ByID(1770450987, content, title, fileName, fontSize, pageWidth, pageHeight).then(($result: any) => {
|
||||
return $$createType0($result);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* ExtractFileFromZip 从 zip 文件中提取单个文件内容
|
||||
*/
|
||||
export function ExtractFileFromZip(zipPath: string, filePath: string): $CancellablePromise<string> {
|
||||
return $Call.ByID(1578144127, zipPath, filePath);
|
||||
}
|
||||
|
||||
/**
|
||||
* ExtractFileFromZipToTemp 从 zip 文件中提取单个文件到临时目录
|
||||
*/
|
||||
export function ExtractFileFromZipToTemp(zipPath: string, filePath: string): $CancellablePromise<string> {
|
||||
return $Call.ByID(1720007904, zipPath, filePath);
|
||||
}
|
||||
|
||||
/**
|
||||
* GetAppConfig 获取应用配置
|
||||
*/
|
||||
export function GetAppConfig(): $CancellablePromise<{ [_ in string]?: any }> {
|
||||
return $Call.ByID(2006534548).then(($result: any) => {
|
||||
return $$createType0($result);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* GetAuditLogs 获取审计日志
|
||||
*/
|
||||
export function GetAuditLogs(limit: number): $CancellablePromise<{ [_ in string]?: any }[]> {
|
||||
return $Call.ByID(3554903517, limit).then(($result: any) => {
|
||||
return $$createType3($result);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* GetCPUInfo 获取 CPU 信息
|
||||
*/
|
||||
export function GetCPUInfo(): $CancellablePromise<{ [_ in string]?: any }> {
|
||||
return $Call.ByID(2509681007).then(($result: any) => {
|
||||
return $$createType0($result);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* GetCommonPaths 获取常用系统路径
|
||||
*/
|
||||
export function GetCommonPaths(): $CancellablePromise<{ [_ in string]?: string }> {
|
||||
return $Call.ByID(3953343786).then(($result: any) => {
|
||||
return $$createType4($result);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* GetCurrentVersion 获取当前版本号
|
||||
*/
|
||||
export function GetCurrentVersion(): $CancellablePromise<{ [_ in string]?: any }> {
|
||||
return $Call.ByID(1827245900).then(($result: any) => {
|
||||
return $$createType0($result);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* GetDiskInfo 获取磁盘信息
|
||||
*/
|
||||
export function GetDiskInfo(): $CancellablePromise<{ [_ in string]?: any }[]> {
|
||||
return $Call.ByID(3756377758).then(($result: any) => {
|
||||
return $$createType3($result);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* GetEnvVars 获取环境变量
|
||||
*/
|
||||
export function GetEnvVars(): $CancellablePromise<{ [_ in string]?: string }> {
|
||||
return $Call.ByID(363814436).then(($result: any) => {
|
||||
return $$createType4($result);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* GetFileInfo 获取文件信息
|
||||
*/
|
||||
export function GetFileInfo(path: string): $CancellablePromise<{ [_ in string]?: any }> {
|
||||
return $Call.ByID(2071650585, path).then(($result: any) => {
|
||||
return $$createType0($result);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* GetFileServerURL 获取本地文件服务器的URL
|
||||
*/
|
||||
export function GetFileServerURL(): $CancellablePromise<string> {
|
||||
return $Call.ByID(4117667287);
|
||||
}
|
||||
|
||||
/**
|
||||
* GetMemoryInfo 获取内存信息
|
||||
*/
|
||||
export function GetMemoryInfo(): $CancellablePromise<{ [_ in string]?: any }> {
|
||||
return $Call.ByID(2096905876).then(($result: any) => {
|
||||
return $$createType0($result);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* GetRecycleBinEntries 获取回收站条目
|
||||
*/
|
||||
export function GetRecycleBinEntries(): $CancellablePromise<{ [_ in string]?: any }[]> {
|
||||
return $Call.ByID(2312855399).then(($result: any) => {
|
||||
return $$createType3($result);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* GetSystemInfo 获取系统信息
|
||||
*/
|
||||
export function GetSystemInfo(): $CancellablePromise<{ [_ in string]?: any }> {
|
||||
return $Call.ByID(1347250254).then(($result: any) => {
|
||||
return $$createType0($result);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* GetUpdateConfig 获取更新配置
|
||||
*/
|
||||
export function GetUpdateConfig(): $CancellablePromise<{ [_ in string]?: any }> {
|
||||
return $Call.ByID(680804904).then(($result: any) => {
|
||||
return $$createType0($result);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* GetZipFileInfo 获取 zip 文件中特定文件的信息
|
||||
*/
|
||||
export function GetZipFileInfo(zipPath: string, filePath: string): $CancellablePromise<{ [_ in string]?: any }> {
|
||||
return $Call.ByID(2031617692, zipPath, filePath).then(($result: any) => {
|
||||
return $$createType0($result);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* InstallUpdate 安装更新包
|
||||
*/
|
||||
export function InstallUpdate(installerPath: string, autoRestart: boolean): $CancellablePromise<{ [_ in string]?: any }> {
|
||||
return $Call.ByID(2443992793, installerPath, autoRestart).then(($result: any) => {
|
||||
return $$createType0($result);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* InstallUpdateWithHash 安装更新包(带哈希验证)
|
||||
*/
|
||||
export function InstallUpdateWithHash(installerPath: string, autoRestart: boolean, expectedHash: string, hashType: string): $CancellablePromise<{ [_ in string]?: any }> {
|
||||
return $Call.ByID(3787276601, installerPath, autoRestart, expectedHash, hashType).then(($result: any) => {
|
||||
return $$createType0($result);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* ListDir 列出目录
|
||||
*/
|
||||
export function ListDir(path: string): $CancellablePromise<{ [_ in string]?: any }[]> {
|
||||
return $Call.ByID(2120475736, path).then(($result: any) => {
|
||||
return $$createType3($result);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* ListZipContents 列出 zip 文件内容
|
||||
*/
|
||||
export function ListZipContents(zipPath: string): $CancellablePromise<{ [_ in string]?: any }[]> {
|
||||
return $Call.ByID(3013109042, zipPath).then(($result: any) => {
|
||||
return $$createType3($result);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* OpenPath 使用系统默认程序打开文件或目录
|
||||
*/
|
||||
export function OpenPath(path: string): $CancellablePromise<void> {
|
||||
return $Call.ByID(1591734570, path);
|
||||
}
|
||||
|
||||
/**
|
||||
* ReadFile 读取文件
|
||||
*/
|
||||
export function ReadFile(path: string): $CancellablePromise<string> {
|
||||
return $Call.ByID(1160596971, path);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reload 重新加载窗口(用于菜单项)
|
||||
*/
|
||||
export function Reload(): $CancellablePromise<void> {
|
||||
return $Call.ByID(2733532980);
|
||||
}
|
||||
|
||||
/**
|
||||
* RenamePath 重命名文件或目录
|
||||
*/
|
||||
export function RenamePath(req: $models.RenamePathRequest): $CancellablePromise<filesystem$0.FileOperationResult | null> {
|
||||
return $Call.ByID(1959759948, req).then(($result: any) => {
|
||||
return $$createType2($result);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* ResolveShortcut 解析快捷方式文件,返回目标路径信息
|
||||
*/
|
||||
export function ResolveShortcut(lnkPath: string): $CancellablePromise<{ [_ in string]?: any }> {
|
||||
return $Call.ByID(4051288361, lnkPath).then(($result: any) => {
|
||||
return $$createType0($result);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* RestoreFromRecycleBin 从回收站恢复文件
|
||||
*/
|
||||
export function RestoreFromRecycleBin(recyclePath: string): $CancellablePromise<void> {
|
||||
return $Call.ByID(3682437655, recyclePath);
|
||||
}
|
||||
|
||||
/**
|
||||
* SaveAppConfig 保存应用配置
|
||||
*/
|
||||
export function SaveAppConfig(req: $models.SaveAppConfigRequest): $CancellablePromise<{ [_ in string]?: any }> {
|
||||
return $Call.ByID(1942219977, req).then(($result: any) => {
|
||||
return $$createType0($result);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* SaveBase64File 将 base64 内容解码后写入文件(用于图片等二进制数据)
|
||||
*/
|
||||
export function SaveBase64File(req: $models.SaveBase64FileRequest): $CancellablePromise<void> {
|
||||
return $Call.ByID(1355120553, req);
|
||||
}
|
||||
|
||||
/**
|
||||
* SelectPDFSaveDirectory 选择PDF保存目录
|
||||
*/
|
||||
export function SelectPDFSaveDirectory(): $CancellablePromise<string> {
|
||||
return $Call.ByID(1403263131);
|
||||
}
|
||||
|
||||
/**
|
||||
* SetMainWindow 设置主窗口引用(由 main.go 在创建窗口后调用)
|
||||
*/
|
||||
export function SetMainWindow(w: application$0.WebviewWindow | null): $CancellablePromise<void> {
|
||||
return $Call.ByID(843697430, w);
|
||||
}
|
||||
|
||||
/**
|
||||
* SetUpdateConfig 设置更新配置
|
||||
*/
|
||||
export function SetUpdateConfig(autoCheckEnabled: boolean, checkIntervalMinutes: number, checkURL: string): $CancellablePromise<{ [_ in string]?: any }> {
|
||||
return $Call.ByID(4271731092, autoCheckEnabled, checkIntervalMinutes, checkURL).then(($result: any) => {
|
||||
return $$createType0($result);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* SetWindowTitleBarColor 设置原生标题栏颜色 + 主题模式(0x00BBGGRR 格式)
|
||||
*/
|
||||
export function SetWindowTitleBarColor(color: number, isDark: boolean): $CancellablePromise<void> {
|
||||
return $Call.ByID(1570627619, color, isDark);
|
||||
}
|
||||
|
||||
/**
|
||||
* VerifyUpdateFile 验证更新文件哈希值
|
||||
*/
|
||||
export function VerifyUpdateFile(filePath: string, expectedHash: string, hashType: string): $CancellablePromise<{ [_ in string]?: any }> {
|
||||
return $Call.ByID(2181909867, filePath, expectedHash, hashType).then(($result: any) => {
|
||||
return $$createType0($result);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* WindowClose 关闭窗口
|
||||
*/
|
||||
export function WindowClose(): $CancellablePromise<void> {
|
||||
return $Call.ByID(1474073651);
|
||||
}
|
||||
|
||||
/**
|
||||
* WindowIsMaximized 检查窗口是否最大化
|
||||
*/
|
||||
export function WindowIsMaximized(): $CancellablePromise<boolean> {
|
||||
return $Call.ByID(854232017);
|
||||
}
|
||||
|
||||
/**
|
||||
* WindowMaximize 最大化/还原窗口
|
||||
*/
|
||||
export function WindowMaximize(): $CancellablePromise<void> {
|
||||
return $Call.ByID(2739663967);
|
||||
}
|
||||
|
||||
/**
|
||||
* WindowMinimize 最小化窗口
|
||||
*/
|
||||
export function WindowMinimize(): $CancellablePromise<void> {
|
||||
return $Call.ByID(1846147565);
|
||||
}
|
||||
|
||||
/**
|
||||
* WindowToggleAlwaysOnTop 切换窗口置顶
|
||||
*/
|
||||
export function WindowToggleAlwaysOnTop(): $CancellablePromise<boolean> {
|
||||
return $Call.ByID(3391208916);
|
||||
}
|
||||
|
||||
/**
|
||||
* WriteFile 写入文件
|
||||
*/
|
||||
export function WriteFile(req: $models.WriteFileRequest): $CancellablePromise<void> {
|
||||
return $Call.ByID(3562730546, req);
|
||||
}
|
||||
|
||||
// Private type creation functions
|
||||
const $$createType0 = $Create.Map($Create.Any, $Create.Any);
|
||||
const $$createType1 = filesystem$0.FileOperationResult.createFrom;
|
||||
const $$createType2 = $Create.Nullable($$createType1);
|
||||
const $$createType3 = $Create.Array($$createType0);
|
||||
const $$createType4 = $Create.Map($Create.Any, $Create.Any);
|
||||
14
frontend/src/wailsjs/v3-bindings/u-desk/index.ts
Normal file
14
frontend/src/wailsjs/v3-bindings/u-desk/index.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
|
||||
// This file is automatically generated. DO NOT EDIT
|
||||
|
||||
import * as App from "./app.js";
|
||||
export {
|
||||
App
|
||||
};
|
||||
|
||||
export {
|
||||
RenamePathRequest,
|
||||
SaveAppConfigRequest,
|
||||
SaveBase64FileRequest,
|
||||
WriteFileRequest
|
||||
} from "./models.js";
|
||||
@@ -0,0 +1,6 @@
|
||||
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
|
||||
// This file is automatically generated. DO NOT EDIT
|
||||
|
||||
export {
|
||||
AppTabDefinition
|
||||
} from "./models.js";
|
||||
@@ -0,0 +1,42 @@
|
||||
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
|
||||
// This file is automatically generated. DO NOT EDIT
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore: Unused imports
|
||||
import { Create as $Create } from "@wailsio/runtime";
|
||||
|
||||
/**
|
||||
* AppTabDefinition 应用 Tab 定义(前端格式)
|
||||
*/
|
||||
export class AppTabDefinition {
|
||||
"key": string;
|
||||
"title": string;
|
||||
"visible": boolean;
|
||||
"enabled": boolean;
|
||||
|
||||
/** Creates a new AppTabDefinition instance. */
|
||||
constructor($$source: Partial<AppTabDefinition> = {}) {
|
||||
if (!("key" in $$source)) {
|
||||
this["key"] = "";
|
||||
}
|
||||
if (!("title" in $$source)) {
|
||||
this["title"] = "";
|
||||
}
|
||||
if (!("visible" in $$source)) {
|
||||
this["visible"] = false;
|
||||
}
|
||||
if (!("enabled" in $$source)) {
|
||||
this["enabled"] = false;
|
||||
}
|
||||
|
||||
Object.assign(this, $$source);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new AppTabDefinition instance from a string or object.
|
||||
*/
|
||||
static createFrom($$source: any = {}): AppTabDefinition {
|
||||
let $$parsedSource = typeof $$source === 'string' ? JSON.parse($$source) : $$source;
|
||||
return new AppTabDefinition($$parsedSource as Partial<AppTabDefinition>);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
|
||||
// This file is automatically generated. DO NOT EDIT
|
||||
|
||||
export {
|
||||
FileOperationResult
|
||||
} from "./models.js";
|
||||
@@ -0,0 +1,55 @@
|
||||
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
|
||||
// This file is automatically generated. DO NOT EDIT
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore: Unused imports
|
||||
import { Create as $Create } from "@wailsio/runtime";
|
||||
|
||||
/**
|
||||
* FileOperationResult 文件操作结果
|
||||
*/
|
||||
export class FileOperationResult {
|
||||
"path": string;
|
||||
"name": string;
|
||||
"size": number;
|
||||
"size_str"?: string;
|
||||
"is_dir": boolean;
|
||||
"mod_time"?: string;
|
||||
"mode"?: string;
|
||||
|
||||
/**
|
||||
* 仅重命名操作时有值
|
||||
*/
|
||||
"old_path"?: string;
|
||||
|
||||
/**
|
||||
* 仅删除操作时有值
|
||||
*/
|
||||
"deleted"?: boolean;
|
||||
|
||||
/** Creates a new FileOperationResult instance. */
|
||||
constructor($$source: Partial<FileOperationResult> = {}) {
|
||||
if (!("path" in $$source)) {
|
||||
this["path"] = "";
|
||||
}
|
||||
if (!("name" in $$source)) {
|
||||
this["name"] = "";
|
||||
}
|
||||
if (!("size" in $$source)) {
|
||||
this["size"] = 0;
|
||||
}
|
||||
if (!("is_dir" in $$source)) {
|
||||
this["is_dir"] = false;
|
||||
}
|
||||
|
||||
Object.assign(this, $$source);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new FileOperationResult instance from a string or object.
|
||||
*/
|
||||
static createFrom($$source: any = {}): FileOperationResult {
|
||||
let $$parsedSource = typeof $$source === 'string' ? JSON.parse($$source) : $$source;
|
||||
return new FileOperationResult($$parsedSource as Partial<FileOperationResult>);
|
||||
}
|
||||
}
|
||||
143
frontend/src/wailsjs/v3-bindings/u-desk/models.ts
Normal file
143
frontend/src/wailsjs/v3-bindings/u-desk/models.ts
Normal file
@@ -0,0 +1,143 @@
|
||||
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
|
||||
// This file is automatically generated. DO NOT EDIT
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore: Unused imports
|
||||
import { Create as $Create } from "@wailsio/runtime";
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore: Unused imports
|
||||
import * as api$0 from "./internal/api/models.js";
|
||||
|
||||
/**
|
||||
* RenamePathRequest 重命名文件或目录请求结构体
|
||||
*/
|
||||
export class RenamePathRequest {
|
||||
"oldPath": string;
|
||||
"newPath": string;
|
||||
|
||||
/** Creates a new RenamePathRequest instance. */
|
||||
constructor($$source: Partial<RenamePathRequest> = {}) {
|
||||
if (!("oldPath" in $$source)) {
|
||||
this["oldPath"] = "";
|
||||
}
|
||||
if (!("newPath" in $$source)) {
|
||||
this["newPath"] = "";
|
||||
}
|
||||
|
||||
Object.assign(this, $$source);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new RenamePathRequest instance from a string or object.
|
||||
*/
|
||||
static createFrom($$source: any = {}): RenamePathRequest {
|
||||
let $$parsedSource = typeof $$source === 'string' ? JSON.parse($$source) : $$source;
|
||||
return new RenamePathRequest($$parsedSource as Partial<RenamePathRequest>);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* SaveAppConfigRequest 保存应用配置请求
|
||||
*/
|
||||
export class SaveAppConfigRequest {
|
||||
"tabs": api$0.AppTabDefinition[];
|
||||
"visibleTabs": string[];
|
||||
"defaultTab": string;
|
||||
|
||||
/** Creates a new SaveAppConfigRequest instance. */
|
||||
constructor($$source: Partial<SaveAppConfigRequest> = {}) {
|
||||
if (!("tabs" in $$source)) {
|
||||
this["tabs"] = [];
|
||||
}
|
||||
if (!("visibleTabs" in $$source)) {
|
||||
this["visibleTabs"] = [];
|
||||
}
|
||||
if (!("defaultTab" in $$source)) {
|
||||
this["defaultTab"] = "";
|
||||
}
|
||||
|
||||
Object.assign(this, $$source);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new SaveAppConfigRequest instance from a string or object.
|
||||
*/
|
||||
static createFrom($$source: any = {}): SaveAppConfigRequest {
|
||||
const $$createField0_0 = $$createType1;
|
||||
const $$createField1_0 = $$createType2;
|
||||
let $$parsedSource = typeof $$source === 'string' ? JSON.parse($$source) : $$source;
|
||||
if ("tabs" in $$parsedSource) {
|
||||
$$parsedSource["tabs"] = $$createField0_0($$parsedSource["tabs"]);
|
||||
}
|
||||
if ("visibleTabs" in $$parsedSource) {
|
||||
$$parsedSource["visibleTabs"] = $$createField1_0($$parsedSource["visibleTabs"]);
|
||||
}
|
||||
return new SaveAppConfigRequest($$parsedSource as Partial<SaveAppConfigRequest>);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* SaveBase64FileRequest 保存 Base64 编码的二进制文件
|
||||
*/
|
||||
export class SaveBase64FileRequest {
|
||||
"path": string;
|
||||
|
||||
/**
|
||||
* base64 编码的文件内容
|
||||
*/
|
||||
"content": string;
|
||||
|
||||
/** Creates a new SaveBase64FileRequest instance. */
|
||||
constructor($$source: Partial<SaveBase64FileRequest> = {}) {
|
||||
if (!("path" in $$source)) {
|
||||
this["path"] = "";
|
||||
}
|
||||
if (!("content" in $$source)) {
|
||||
this["content"] = "";
|
||||
}
|
||||
|
||||
Object.assign(this, $$source);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new SaveBase64FileRequest instance from a string or object.
|
||||
*/
|
||||
static createFrom($$source: any = {}): SaveBase64FileRequest {
|
||||
let $$parsedSource = typeof $$source === 'string' ? JSON.parse($$source) : $$source;
|
||||
return new SaveBase64FileRequest($$parsedSource as Partial<SaveBase64FileRequest>);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* WriteFileRequest 写入文件请求结构体
|
||||
*/
|
||||
export class WriteFileRequest {
|
||||
"path": string;
|
||||
"content": string;
|
||||
|
||||
/** Creates a new WriteFileRequest instance. */
|
||||
constructor($$source: Partial<WriteFileRequest> = {}) {
|
||||
if (!("path" in $$source)) {
|
||||
this["path"] = "";
|
||||
}
|
||||
if (!("content" in $$source)) {
|
||||
this["content"] = "";
|
||||
}
|
||||
|
||||
Object.assign(this, $$source);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new WriteFileRequest instance from a string or object.
|
||||
*/
|
||||
static createFrom($$source: any = {}): WriteFileRequest {
|
||||
let $$parsedSource = typeof $$source === 'string' ? JSON.parse($$source) : $$source;
|
||||
return new WriteFileRequest($$parsedSource as Partial<WriteFileRequest>);
|
||||
}
|
||||
}
|
||||
|
||||
// Private type creation functions
|
||||
const $$createType0 = api$0.AppTabDefinition.createFrom;
|
||||
const $$createType1 = $Create.Array($$createType0);
|
||||
const $$createType2 = $Create.Array($Create.Any);
|
||||
98
frontend/src/wailsjs/wailsjs/go/main/App.d.ts
vendored
Normal file
98
frontend/src/wailsjs/wailsjs/go/main/App.d.ts
vendored
Normal file
@@ -0,0 +1,98 @@
|
||||
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
|
||||
// This file is automatically generated. DO NOT EDIT
|
||||
import {filesystem} from '../models';
|
||||
import {main} from '../models';
|
||||
|
||||
export function CheckUpdate():Promise<Record<string, any>>;
|
||||
|
||||
export function ClearCache():Promise<void>;
|
||||
|
||||
export function CreateDir(arg1:string):Promise<filesystem.FileOperationResult>;
|
||||
|
||||
export function CreateFile(arg1:string):Promise<filesystem.FileOperationResult>;
|
||||
|
||||
export function DeletePath(arg1:string):Promise<filesystem.FileOperationResult>;
|
||||
|
||||
export function DeletePermanently(arg1:string):Promise<void>;
|
||||
|
||||
export function DetectFileTypeByContent(arg1:string):Promise<Record<string, any>>;
|
||||
|
||||
export function DownloadUpdate(arg1:string):Promise<Record<string, any>>;
|
||||
|
||||
export function EmptyRecycleBin():Promise<void>;
|
||||
|
||||
export function ExportPDF(arg1:string,arg2:string,arg3:string,arg4:number,arg5:number,arg6:number):Promise<Record<string, any>>;
|
||||
|
||||
export function ExtractFileFromZip(arg1:string,arg2:string):Promise<string>;
|
||||
|
||||
export function ExtractFileFromZipToTemp(arg1:string,arg2:string):Promise<string>;
|
||||
|
||||
export function GetAppConfig():Promise<Record<string, any>>;
|
||||
|
||||
export function GetAuditLogs(arg1:number):Promise<Array<Record<string, any>>>;
|
||||
|
||||
export function GetCPUInfo():Promise<Record<string, any>>;
|
||||
|
||||
export function GetCommonPaths():Promise<Record<string, string>>;
|
||||
|
||||
export function GetCurrentVersion():Promise<Record<string, any>>;
|
||||
|
||||
export function GetDiskInfo():Promise<Array<Record<string, any>>>;
|
||||
|
||||
export function GetEnvVars():Promise<Record<string, string>>;
|
||||
|
||||
export function GetFileInfo(arg1:string):Promise<Record<string, any>>;
|
||||
|
||||
export function GetFileServerURL():Promise<string>;
|
||||
|
||||
export function GetMemoryInfo():Promise<Record<string, any>>;
|
||||
|
||||
export function GetRecycleBinEntries():Promise<Array<Record<string, any>>>;
|
||||
|
||||
export function GetSystemInfo():Promise<Record<string, any>>;
|
||||
|
||||
export function GetUpdateConfig():Promise<Record<string, any>>;
|
||||
|
||||
export function GetZipFileInfo(arg1:string,arg2:string):Promise<Record<string, any>>;
|
||||
|
||||
export function InstallUpdate(arg1:string,arg2:boolean):Promise<Record<string, any>>;
|
||||
|
||||
export function InstallUpdateWithHash(arg1:string,arg2:boolean,arg3:string,arg4:string):Promise<Record<string, any>>;
|
||||
|
||||
export function ListDir(arg1:string):Promise<Array<Record<string, any>>>;
|
||||
|
||||
export function ListZipContents(arg1:string):Promise<Array<Record<string, any>>>;
|
||||
|
||||
export function OpenPath(arg1:string):Promise<void>;
|
||||
|
||||
export function ReadFile(arg1:string):Promise<string>;
|
||||
|
||||
export function Reload():Promise<void>;
|
||||
|
||||
export function RenamePath(arg1:main.RenamePathRequest):Promise<filesystem.FileOperationResult>;
|
||||
|
||||
export function ResolveShortcut(arg1:string):Promise<Record<string, any>>;
|
||||
|
||||
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 SelectPDFSaveDirectory():Promise<string>;
|
||||
|
||||
export function SetUpdateConfig(arg1:boolean,arg2:number,arg3:string):Promise<Record<string, any>>;
|
||||
|
||||
export function VerifyUpdateFile(arg1:string,arg2:string,arg3:string):Promise<Record<string, any>>;
|
||||
|
||||
export function WindowClose():Promise<void>;
|
||||
|
||||
export function WindowIsMaximized():Promise<boolean>;
|
||||
|
||||
export function WindowMaximize():Promise<void>;
|
||||
|
||||
export function WindowMinimize():Promise<void>;
|
||||
|
||||
export function WindowToggleAlwaysOnTop():Promise<boolean>;
|
||||
|
||||
export function WriteFile(arg1:main.WriteFileRequest):Promise<void>;
|
||||
191
frontend/src/wailsjs/wailsjs/go/main/App.js
Normal file
191
frontend/src/wailsjs/wailsjs/go/main/App.js
Normal file
@@ -0,0 +1,191 @@
|
||||
// @ts-check
|
||||
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
|
||||
// This file is automatically generated. DO NOT EDIT
|
||||
|
||||
export function CheckUpdate() {
|
||||
return window['go']['main']['App']['CheckUpdate']();
|
||||
}
|
||||
|
||||
export function ClearCache() {
|
||||
return window['go']['main']['App']['ClearCache']();
|
||||
}
|
||||
|
||||
export function CreateDir(arg1) {
|
||||
return window['go']['main']['App']['CreateDir'](arg1);
|
||||
}
|
||||
|
||||
export function CreateFile(arg1) {
|
||||
return window['go']['main']['App']['CreateFile'](arg1);
|
||||
}
|
||||
|
||||
export function DeletePath(arg1) {
|
||||
return window['go']['main']['App']['DeletePath'](arg1);
|
||||
}
|
||||
|
||||
export function DeletePermanently(arg1) {
|
||||
return window['go']['main']['App']['DeletePermanently'](arg1);
|
||||
}
|
||||
|
||||
export function DetectFileTypeByContent(arg1) {
|
||||
return window['go']['main']['App']['DetectFileTypeByContent'](arg1);
|
||||
}
|
||||
|
||||
export function DownloadUpdate(arg1) {
|
||||
return window['go']['main']['App']['DownloadUpdate'](arg1);
|
||||
}
|
||||
|
||||
export function EmptyRecycleBin() {
|
||||
return window['go']['main']['App']['EmptyRecycleBin']();
|
||||
}
|
||||
|
||||
export function ExportPDF(arg1, arg2, arg3, arg4, arg5, arg6) {
|
||||
return window['go']['main']['App']['ExportPDF'](arg1, arg2, arg3, arg4, arg5, arg6);
|
||||
}
|
||||
|
||||
export function ExtractFileFromZip(arg1, arg2) {
|
||||
return window['go']['main']['App']['ExtractFileFromZip'](arg1, arg2);
|
||||
}
|
||||
|
||||
export function ExtractFileFromZipToTemp(arg1, arg2) {
|
||||
return window['go']['main']['App']['ExtractFileFromZipToTemp'](arg1, arg2);
|
||||
}
|
||||
|
||||
export function GetAppConfig() {
|
||||
return window['go']['main']['App']['GetAppConfig']();
|
||||
}
|
||||
|
||||
export function GetAuditLogs(arg1) {
|
||||
return window['go']['main']['App']['GetAuditLogs'](arg1);
|
||||
}
|
||||
|
||||
export function GetCPUInfo() {
|
||||
return window['go']['main']['App']['GetCPUInfo']();
|
||||
}
|
||||
|
||||
export function GetCommonPaths() {
|
||||
return window['go']['main']['App']['GetCommonPaths']();
|
||||
}
|
||||
|
||||
export function GetCurrentVersion() {
|
||||
return window['go']['main']['App']['GetCurrentVersion']();
|
||||
}
|
||||
|
||||
export function GetDiskInfo() {
|
||||
return window['go']['main']['App']['GetDiskInfo']();
|
||||
}
|
||||
|
||||
export function GetEnvVars() {
|
||||
return window['go']['main']['App']['GetEnvVars']();
|
||||
}
|
||||
|
||||
export function GetFileInfo(arg1) {
|
||||
return window['go']['main']['App']['GetFileInfo'](arg1);
|
||||
}
|
||||
|
||||
export function GetFileServerURL() {
|
||||
return window['go']['main']['App']['GetFileServerURL']();
|
||||
}
|
||||
|
||||
export function GetMemoryInfo() {
|
||||
return window['go']['main']['App']['GetMemoryInfo']();
|
||||
}
|
||||
|
||||
export function GetRecycleBinEntries() {
|
||||
return window['go']['main']['App']['GetRecycleBinEntries']();
|
||||
}
|
||||
|
||||
export function GetSystemInfo() {
|
||||
return window['go']['main']['App']['GetSystemInfo']();
|
||||
}
|
||||
|
||||
export function GetUpdateConfig() {
|
||||
return window['go']['main']['App']['GetUpdateConfig']();
|
||||
}
|
||||
|
||||
export function GetZipFileInfo(arg1, arg2) {
|
||||
return window['go']['main']['App']['GetZipFileInfo'](arg1, arg2);
|
||||
}
|
||||
|
||||
export function InstallUpdate(arg1, arg2) {
|
||||
return window['go']['main']['App']['InstallUpdate'](arg1, arg2);
|
||||
}
|
||||
|
||||
export function InstallUpdateWithHash(arg1, arg2, arg3, arg4) {
|
||||
return window['go']['main']['App']['InstallUpdateWithHash'](arg1, arg2, arg3, arg4);
|
||||
}
|
||||
|
||||
export function ListDir(arg1) {
|
||||
return window['go']['main']['App']['ListDir'](arg1);
|
||||
}
|
||||
|
||||
export function ListZipContents(arg1) {
|
||||
return window['go']['main']['App']['ListZipContents'](arg1);
|
||||
}
|
||||
|
||||
export function OpenPath(arg1) {
|
||||
return window['go']['main']['App']['OpenPath'](arg1);
|
||||
}
|
||||
|
||||
export function ReadFile(arg1) {
|
||||
return window['go']['main']['App']['ReadFile'](arg1);
|
||||
}
|
||||
|
||||
export function Reload() {
|
||||
return window['go']['main']['App']['Reload']();
|
||||
}
|
||||
|
||||
export function RenamePath(arg1) {
|
||||
return window['go']['main']['App']['RenamePath'](arg1);
|
||||
}
|
||||
|
||||
export function ResolveShortcut(arg1) {
|
||||
return window['go']['main']['App']['ResolveShortcut'](arg1);
|
||||
}
|
||||
|
||||
export function RestoreFromRecycleBin(arg1) {
|
||||
return window['go']['main']['App']['RestoreFromRecycleBin'](arg1);
|
||||
}
|
||||
|
||||
export function SaveAppConfig(arg1) {
|
||||
return window['go']['main']['App']['SaveAppConfig'](arg1);
|
||||
}
|
||||
|
||||
export function SaveBase64File(arg1) {
|
||||
return window['go']['main']['App']['SaveBase64File'](arg1);
|
||||
}
|
||||
|
||||
export function SelectPDFSaveDirectory() {
|
||||
return window['go']['main']['App']['SelectPDFSaveDirectory']();
|
||||
}
|
||||
|
||||
export function SetUpdateConfig(arg1, arg2, arg3) {
|
||||
return window['go']['main']['App']['SetUpdateConfig'](arg1, arg2, arg3);
|
||||
}
|
||||
|
||||
export function VerifyUpdateFile(arg1, arg2, arg3) {
|
||||
return window['go']['main']['App']['VerifyUpdateFile'](arg1, arg2, arg3);
|
||||
}
|
||||
|
||||
export function WindowClose() {
|
||||
return window['go']['main']['App']['WindowClose']();
|
||||
}
|
||||
|
||||
export function WindowIsMaximized() {
|
||||
return window['go']['main']['App']['WindowIsMaximized']();
|
||||
}
|
||||
|
||||
export function WindowMaximize() {
|
||||
return window['go']['main']['App']['WindowMaximize']();
|
||||
}
|
||||
|
||||
export function WindowMinimize() {
|
||||
return window['go']['main']['App']['WindowMinimize']();
|
||||
}
|
||||
|
||||
export function WindowToggleAlwaysOnTop() {
|
||||
return window['go']['main']['App']['WindowToggleAlwaysOnTop']();
|
||||
}
|
||||
|
||||
export function WriteFile(arg1) {
|
||||
return window['go']['main']['App']['WriteFile'](arg1);
|
||||
}
|
||||
137
frontend/src/wailsjs/wailsjs/go/models.ts
Normal file
137
frontend/src/wailsjs/wailsjs/go/models.ts
Normal file
@@ -0,0 +1,137 @@
|
||||
export namespace api {
|
||||
|
||||
export class AppTabDefinition {
|
||||
key: string;
|
||||
title: string;
|
||||
visible: boolean;
|
||||
enabled: boolean;
|
||||
|
||||
static createFrom(source: any = {}) {
|
||||
return new AppTabDefinition(source);
|
||||
}
|
||||
|
||||
constructor(source: any = {}) {
|
||||
if ('string' === typeof source) source = JSON.parse(source);
|
||||
this.key = source["key"];
|
||||
this.title = source["title"];
|
||||
this.visible = source["visible"];
|
||||
this.enabled = source["enabled"];
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export namespace filesystem {
|
||||
|
||||
export class FileOperationResult {
|
||||
path: string;
|
||||
name: string;
|
||||
size: number;
|
||||
size_str?: string;
|
||||
is_dir: boolean;
|
||||
mod_time?: string;
|
||||
mode?: string;
|
||||
old_path?: string;
|
||||
deleted?: boolean;
|
||||
|
||||
static createFrom(source: any = {}) {
|
||||
return new FileOperationResult(source);
|
||||
}
|
||||
|
||||
constructor(source: any = {}) {
|
||||
if ('string' === typeof source) source = JSON.parse(source);
|
||||
this.path = source["path"];
|
||||
this.name = source["name"];
|
||||
this.size = source["size"];
|
||||
this.size_str = source["size_str"];
|
||||
this.is_dir = source["is_dir"];
|
||||
this.mod_time = source["mod_time"];
|
||||
this.mode = source["mode"];
|
||||
this.old_path = source["old_path"];
|
||||
this.deleted = source["deleted"];
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export namespace main {
|
||||
|
||||
export class RenamePathRequest {
|
||||
oldPath: string;
|
||||
newPath: string;
|
||||
|
||||
static createFrom(source: any = {}) {
|
||||
return new RenamePathRequest(source);
|
||||
}
|
||||
|
||||
constructor(source: any = {}) {
|
||||
if ('string' === typeof source) source = JSON.parse(source);
|
||||
this.oldPath = source["oldPath"];
|
||||
this.newPath = source["newPath"];
|
||||
}
|
||||
}
|
||||
export class SaveAppConfigRequest {
|
||||
tabs: api.AppTabDefinition[];
|
||||
visibleTabs: string[];
|
||||
defaultTab: string;
|
||||
|
||||
static createFrom(source: any = {}) {
|
||||
return new SaveAppConfigRequest(source);
|
||||
}
|
||||
|
||||
constructor(source: any = {}) {
|
||||
if ('string' === typeof source) source = JSON.parse(source);
|
||||
this.tabs = this.convertValues(source["tabs"], api.AppTabDefinition);
|
||||
this.visibleTabs = source["visibleTabs"];
|
||||
this.defaultTab = source["defaultTab"];
|
||||
}
|
||||
|
||||
convertValues(a: any, classs: any, asMap: boolean = false): any {
|
||||
if (!a) {
|
||||
return a;
|
||||
}
|
||||
if (a.slice && a.map) {
|
||||
return (a as any[]).map(elem => this.convertValues(elem, classs));
|
||||
} else if ("object" === typeof a) {
|
||||
if (asMap) {
|
||||
for (const key of Object.keys(a)) {
|
||||
a[key] = new classs(a[key]);
|
||||
}
|
||||
return a;
|
||||
}
|
||||
return new classs(a);
|
||||
}
|
||||
return a;
|
||||
}
|
||||
}
|
||||
export class SaveBase64FileRequest {
|
||||
path: string;
|
||||
content: string;
|
||||
|
||||
static createFrom(source: any = {}) {
|
||||
return new SaveBase64FileRequest(source);
|
||||
}
|
||||
|
||||
constructor(source: any = {}) {
|
||||
if ('string' === typeof source) source = JSON.parse(source);
|
||||
this.path = source["path"];
|
||||
this.content = source["content"];
|
||||
}
|
||||
}
|
||||
export class WriteFileRequest {
|
||||
path: string;
|
||||
content: string;
|
||||
|
||||
static createFrom(source: any = {}) {
|
||||
return new WriteFileRequest(source);
|
||||
}
|
||||
|
||||
constructor(source: any = {}) {
|
||||
if ('string' === typeof source) source = JSON.parse(source);
|
||||
this.path = source["path"];
|
||||
this.content = source["content"];
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
24
frontend/src/wailsjs/wailsjs/runtime/package.json
Normal file
24
frontend/src/wailsjs/wailsjs/runtime/package.json
Normal file
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"name": "@wailsapp/runtime",
|
||||
"version": "2.0.0",
|
||||
"description": "Wails Javascript runtime library",
|
||||
"main": "runtime.js",
|
||||
"types": "runtime.d.ts",
|
||||
"scripts": {
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/wailsapp/wails.git"
|
||||
},
|
||||
"keywords": [
|
||||
"Wails",
|
||||
"Javascript",
|
||||
"Go"
|
||||
],
|
||||
"author": "Lea Anthony <lea.anthony@gmail.com>",
|
||||
"license": "MIT",
|
||||
"bugs": {
|
||||
"url": "https://github.com/wailsapp/wails/issues"
|
||||
},
|
||||
"homepage": "https://github.com/wailsapp/wails#readme"
|
||||
}
|
||||
330
frontend/src/wailsjs/wailsjs/runtime/runtime.d.ts
vendored
Normal file
330
frontend/src/wailsjs/wailsjs/runtime/runtime.d.ts
vendored
Normal file
@@ -0,0 +1,330 @@
|
||||
/*
|
||||
_ __ _ __
|
||||
| | / /___ _(_) /____
|
||||
| | /| / / __ `/ / / ___/
|
||||
| |/ |/ / /_/ / / (__ )
|
||||
|__/|__/\__,_/_/_/____/
|
||||
The electron alternative for Go
|
||||
(c) Lea Anthony 2019-present
|
||||
*/
|
||||
|
||||
export interface Position {
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
export interface Size {
|
||||
w: number;
|
||||
h: number;
|
||||
}
|
||||
|
||||
export interface Screen {
|
||||
isCurrent: boolean;
|
||||
isPrimary: boolean;
|
||||
width : number
|
||||
height : number
|
||||
}
|
||||
|
||||
// Environment information such as platform, buildtype, ...
|
||||
export interface EnvironmentInfo {
|
||||
buildType: string;
|
||||
platform: string;
|
||||
arch: string;
|
||||
}
|
||||
|
||||
// [EventsEmit](https://wails.io/docs/reference/runtime/events#eventsemit)
|
||||
// emits the given event. Optional data may be passed with the event.
|
||||
// This will trigger any event listeners.
|
||||
export function EventsEmit(eventName: string, ...data: any): void;
|
||||
|
||||
// [EventsOn](https://wails.io/docs/reference/runtime/events#eventson) sets up a listener for the given event name.
|
||||
export function EventsOn(eventName: string, callback: (...data: any) => void): () => void;
|
||||
|
||||
// [EventsOnMultiple](https://wails.io/docs/reference/runtime/events#eventsonmultiple)
|
||||
// sets up a listener for the given event name, but will only trigger a given number times.
|
||||
export function EventsOnMultiple(eventName: string, callback: (...data: any) => void, maxCallbacks: number): () => void;
|
||||
|
||||
// [EventsOnce](https://wails.io/docs/reference/runtime/events#eventsonce)
|
||||
// sets up a listener for the given event name, but will only trigger once.
|
||||
export function EventsOnce(eventName: string, callback: (...data: any) => void): () => void;
|
||||
|
||||
// [EventsOff](https://wails.io/docs/reference/runtime/events#eventsoff)
|
||||
// unregisters the listener for the given event name.
|
||||
export function EventsOff(eventName: string, ...additionalEventNames: string[]): void;
|
||||
|
||||
// [EventsOffAll](https://wails.io/docs/reference/runtime/events#eventsoffall)
|
||||
// unregisters all listeners.
|
||||
export function EventsOffAll(): void;
|
||||
|
||||
// [LogPrint](https://wails.io/docs/reference/runtime/log#logprint)
|
||||
// logs the given message as a raw message
|
||||
export function LogPrint(message: string): void;
|
||||
|
||||
// [LogTrace](https://wails.io/docs/reference/runtime/log#logtrace)
|
||||
// logs the given message at the `trace` log level.
|
||||
export function LogTrace(message: string): void;
|
||||
|
||||
// [LogDebug](https://wails.io/docs/reference/runtime/log#logdebug)
|
||||
// logs the given message at the `debug` log level.
|
||||
export function LogDebug(message: string): void;
|
||||
|
||||
// [LogError](https://wails.io/docs/reference/runtime/log#logerror)
|
||||
// logs the given message at the `error` log level.
|
||||
export function LogError(message: string): void;
|
||||
|
||||
// [LogFatal](https://wails.io/docs/reference/runtime/log#logfatal)
|
||||
// logs the given message at the `fatal` log level.
|
||||
// The application will quit after calling this method.
|
||||
export function LogFatal(message: string): void;
|
||||
|
||||
// [LogInfo](https://wails.io/docs/reference/runtime/log#loginfo)
|
||||
// logs the given message at the `info` log level.
|
||||
export function LogInfo(message: string): void;
|
||||
|
||||
// [LogWarning](https://wails.io/docs/reference/runtime/log#logwarning)
|
||||
// logs the given message at the `warning` log level.
|
||||
export function LogWarning(message: string): void;
|
||||
|
||||
// [WindowReload](https://wails.io/docs/reference/runtime/window#windowreload)
|
||||
// Forces a reload by the main application as well as connected browsers.
|
||||
export function WindowReload(): void;
|
||||
|
||||
// [WindowReloadApp](https://wails.io/docs/reference/runtime/window#windowreloadapp)
|
||||
// Reloads the application frontend.
|
||||
export function WindowReloadApp(): void;
|
||||
|
||||
// [WindowSetAlwaysOnTop](https://wails.io/docs/reference/runtime/window#windowsetalwaysontop)
|
||||
// Sets the window AlwaysOnTop or not on top.
|
||||
export function WindowSetAlwaysOnTop(b: boolean): void;
|
||||
|
||||
// [WindowSetSystemDefaultTheme](https://wails.io/docs/next/reference/runtime/window#windowsetsystemdefaulttheme)
|
||||
// *Windows only*
|
||||
// Sets window theme to system default (dark/light).
|
||||
export function WindowSetSystemDefaultTheme(): void;
|
||||
|
||||
// [WindowSetLightTheme](https://wails.io/docs/next/reference/runtime/window#windowsetlighttheme)
|
||||
// *Windows only*
|
||||
// Sets window to light theme.
|
||||
export function WindowSetLightTheme(): void;
|
||||
|
||||
// [WindowSetDarkTheme](https://wails.io/docs/next/reference/runtime/window#windowsetdarktheme)
|
||||
// *Windows only*
|
||||
// Sets window to dark theme.
|
||||
export function WindowSetDarkTheme(): void;
|
||||
|
||||
// [WindowCenter](https://wails.io/docs/reference/runtime/window#windowcenter)
|
||||
// Centers the window on the monitor the window is currently on.
|
||||
export function WindowCenter(): void;
|
||||
|
||||
// [WindowSetTitle](https://wails.io/docs/reference/runtime/window#windowsettitle)
|
||||
// Sets the text in the window title bar.
|
||||
export function WindowSetTitle(title: string): void;
|
||||
|
||||
// [WindowFullscreen](https://wails.io/docs/reference/runtime/window#windowfullscreen)
|
||||
// Makes the window full screen.
|
||||
export function WindowFullscreen(): void;
|
||||
|
||||
// [WindowUnfullscreen](https://wails.io/docs/reference/runtime/window#windowunfullscreen)
|
||||
// Restores the previous window dimensions and position prior to full screen.
|
||||
export function WindowUnfullscreen(): void;
|
||||
|
||||
// [WindowIsFullscreen](https://wails.io/docs/reference/runtime/window#windowisfullscreen)
|
||||
// Returns the state of the window, i.e. whether the window is in full screen mode or not.
|
||||
export function WindowIsFullscreen(): Promise<boolean>;
|
||||
|
||||
// [WindowSetSize](https://wails.io/docs/reference/runtime/window#windowsetsize)
|
||||
// Sets the width and height of the window.
|
||||
export function WindowSetSize(width: number, height: number): void;
|
||||
|
||||
// [WindowGetSize](https://wails.io/docs/reference/runtime/window#windowgetsize)
|
||||
// Gets the width and height of the window.
|
||||
export function WindowGetSize(): Promise<Size>;
|
||||
|
||||
// [WindowSetMaxSize](https://wails.io/docs/reference/runtime/window#windowsetmaxsize)
|
||||
// Sets the maximum window size. Will resize the window if the window is currently larger than the given dimensions.
|
||||
// Setting a size of 0,0 will disable this constraint.
|
||||
export function WindowSetMaxSize(width: number, height: number): void;
|
||||
|
||||
// [WindowSetMinSize](https://wails.io/docs/reference/runtime/window#windowsetminsize)
|
||||
// Sets the minimum window size. Will resize the window if the window is currently smaller than the given dimensions.
|
||||
// Setting a size of 0,0 will disable this constraint.
|
||||
export function WindowSetMinSize(width: number, height: number): void;
|
||||
|
||||
// [WindowSetPosition](https://wails.io/docs/reference/runtime/window#windowsetposition)
|
||||
// Sets the window position relative to the monitor the window is currently on.
|
||||
export function WindowSetPosition(x: number, y: number): void;
|
||||
|
||||
// [WindowGetPosition](https://wails.io/docs/reference/runtime/window#windowgetposition)
|
||||
// Gets the window position relative to the monitor the window is currently on.
|
||||
export function WindowGetPosition(): Promise<Position>;
|
||||
|
||||
// [WindowHide](https://wails.io/docs/reference/runtime/window#windowhide)
|
||||
// Hides the window.
|
||||
export function WindowHide(): void;
|
||||
|
||||
// [WindowShow](https://wails.io/docs/reference/runtime/window#windowshow)
|
||||
// Shows the window, if it is currently hidden.
|
||||
export function WindowShow(): void;
|
||||
|
||||
// [WindowMaximise](https://wails.io/docs/reference/runtime/window#windowmaximise)
|
||||
// Maximises the window to fill the screen.
|
||||
export function WindowMaximise(): void;
|
||||
|
||||
// [WindowToggleMaximise](https://wails.io/docs/reference/runtime/window#windowtogglemaximise)
|
||||
// Toggles between Maximised and UnMaximised.
|
||||
export function WindowToggleMaximise(): void;
|
||||
|
||||
// [WindowUnmaximise](https://wails.io/docs/reference/runtime/window#windowunmaximise)
|
||||
// Restores the window to the dimensions and position prior to maximising.
|
||||
export function WindowUnmaximise(): void;
|
||||
|
||||
// [WindowIsMaximised](https://wails.io/docs/reference/runtime/window#windowismaximised)
|
||||
// Returns the state of the window, i.e. whether the window is maximised or not.
|
||||
export function WindowIsMaximised(): Promise<boolean>;
|
||||
|
||||
// [WindowMinimise](https://wails.io/docs/reference/runtime/window#windowminimise)
|
||||
// Minimises the window.
|
||||
export function WindowMinimise(): void;
|
||||
|
||||
// [WindowUnminimise](https://wails.io/docs/reference/runtime/window#windowunminimise)
|
||||
// Restores the window to the dimensions and position prior to minimising.
|
||||
export function WindowUnminimise(): void;
|
||||
|
||||
// [WindowIsMinimised](https://wails.io/docs/reference/runtime/window#windowisminimised)
|
||||
// Returns the state of the window, i.e. whether the window is minimised or not.
|
||||
export function WindowIsMinimised(): Promise<boolean>;
|
||||
|
||||
// [WindowIsNormal](https://wails.io/docs/reference/runtime/window#windowisnormal)
|
||||
// Returns the state of the window, i.e. whether the window is normal or not.
|
||||
export function WindowIsNormal(): Promise<boolean>;
|
||||
|
||||
// [WindowSetBackgroundColour](https://wails.io/docs/reference/runtime/window#windowsetbackgroundcolour)
|
||||
// Sets the background colour of the window to the given RGBA colour definition. This colour will show through for all transparent pixels.
|
||||
export function WindowSetBackgroundColour(R: number, G: number, B: number, A: number): void;
|
||||
|
||||
// [ScreenGetAll](https://wails.io/docs/reference/runtime/window#screengetall)
|
||||
// Gets the all screens. Call this anew each time you want to refresh data from the underlying windowing system.
|
||||
export function ScreenGetAll(): Promise<Screen[]>;
|
||||
|
||||
// [BrowserOpenURL](https://wails.io/docs/reference/runtime/browser#browseropenurl)
|
||||
// Opens the given URL in the system browser.
|
||||
export function BrowserOpenURL(url: string): void;
|
||||
|
||||
// [Environment](https://wails.io/docs/reference/runtime/intro#environment)
|
||||
// Returns information about the environment
|
||||
export function Environment(): Promise<EnvironmentInfo>;
|
||||
|
||||
// [Quit](https://wails.io/docs/reference/runtime/intro#quit)
|
||||
// Quits the application.
|
||||
export function Quit(): void;
|
||||
|
||||
// [Hide](https://wails.io/docs/reference/runtime/intro#hide)
|
||||
// Hides the application.
|
||||
export function Hide(): void;
|
||||
|
||||
// [Show](https://wails.io/docs/reference/runtime/intro#show)
|
||||
// Shows the application.
|
||||
export function Show(): void;
|
||||
|
||||
// [ClipboardGetText](https://wails.io/docs/reference/runtime/clipboard#clipboardgettext)
|
||||
// Returns the current text stored on clipboard
|
||||
export function ClipboardGetText(): Promise<string>;
|
||||
|
||||
// [ClipboardSetText](https://wails.io/docs/reference/runtime/clipboard#clipboardsettext)
|
||||
// Sets a text on the clipboard
|
||||
export function ClipboardSetText(text: string): Promise<boolean>;
|
||||
|
||||
// [OnFileDrop](https://wails.io/docs/reference/runtime/draganddrop#onfiledrop)
|
||||
// OnFileDrop listens to drag and drop events and calls the callback with the coordinates of the drop and an array of path strings.
|
||||
export function OnFileDrop(callback: (x: number, y: number ,paths: string[]) => void, useDropTarget: boolean) :void
|
||||
|
||||
// [OnFileDropOff](https://wails.io/docs/reference/runtime/draganddrop#dragandddropoff)
|
||||
// OnFileDropOff removes the drag and drop listeners and handlers.
|
||||
export function OnFileDropOff() :void
|
||||
|
||||
// Check if the file path resolver is available
|
||||
export function CanResolveFilePaths(): boolean;
|
||||
|
||||
// Resolves file paths for an array of files
|
||||
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>;
|
||||
298
frontend/src/wailsjs/wailsjs/runtime/runtime.js
Normal file
298
frontend/src/wailsjs/wailsjs/runtime/runtime.js
Normal file
@@ -0,0 +1,298 @@
|
||||
/*
|
||||
_ __ _ __
|
||||
| | / /___ _(_) /____
|
||||
| | /| / / __ `/ / / ___/
|
||||
| |/ |/ / /_/ / / (__ )
|
||||
|__/|__/\__,_/_/_/____/
|
||||
The electron alternative for Go
|
||||
(c) Lea Anthony 2019-present
|
||||
*/
|
||||
|
||||
export function LogPrint(message) {
|
||||
window.runtime.LogPrint(message);
|
||||
}
|
||||
|
||||
export function LogTrace(message) {
|
||||
window.runtime.LogTrace(message);
|
||||
}
|
||||
|
||||
export function LogDebug(message) {
|
||||
window.runtime.LogDebug(message);
|
||||
}
|
||||
|
||||
export function LogInfo(message) {
|
||||
window.runtime.LogInfo(message);
|
||||
}
|
||||
|
||||
export function LogWarning(message) {
|
||||
window.runtime.LogWarning(message);
|
||||
}
|
||||
|
||||
export function LogError(message) {
|
||||
window.runtime.LogError(message);
|
||||
}
|
||||
|
||||
export function LogFatal(message) {
|
||||
window.runtime.LogFatal(message);
|
||||
}
|
||||
|
||||
export function EventsOnMultiple(eventName, callback, maxCallbacks) {
|
||||
return window.runtime.EventsOnMultiple(eventName, callback, maxCallbacks);
|
||||
}
|
||||
|
||||
export function EventsOn(eventName, callback) {
|
||||
return EventsOnMultiple(eventName, callback, -1);
|
||||
}
|
||||
|
||||
export function EventsOff(eventName, ...additionalEventNames) {
|
||||
return window.runtime.EventsOff(eventName, ...additionalEventNames);
|
||||
}
|
||||
|
||||
export function EventsOffAll() {
|
||||
return window.runtime.EventsOffAll();
|
||||
}
|
||||
|
||||
export function EventsOnce(eventName, callback) {
|
||||
return EventsOnMultiple(eventName, callback, 1);
|
||||
}
|
||||
|
||||
export function EventsEmit(eventName) {
|
||||
let args = [eventName].slice.call(arguments);
|
||||
return window.runtime.EventsEmit.apply(null, args);
|
||||
}
|
||||
|
||||
export function WindowReload() {
|
||||
window.runtime.WindowReload();
|
||||
}
|
||||
|
||||
export function WindowReloadApp() {
|
||||
window.runtime.WindowReloadApp();
|
||||
}
|
||||
|
||||
export function WindowSetAlwaysOnTop(b) {
|
||||
window.runtime.WindowSetAlwaysOnTop(b);
|
||||
}
|
||||
|
||||
export function WindowSetSystemDefaultTheme() {
|
||||
window.runtime.WindowSetSystemDefaultTheme();
|
||||
}
|
||||
|
||||
export function WindowSetLightTheme() {
|
||||
window.runtime.WindowSetLightTheme();
|
||||
}
|
||||
|
||||
export function WindowSetDarkTheme() {
|
||||
window.runtime.WindowSetDarkTheme();
|
||||
}
|
||||
|
||||
export function WindowCenter() {
|
||||
window.runtime.WindowCenter();
|
||||
}
|
||||
|
||||
export function WindowSetTitle(title) {
|
||||
window.runtime.WindowSetTitle(title);
|
||||
}
|
||||
|
||||
export function WindowFullscreen() {
|
||||
window.runtime.WindowFullscreen();
|
||||
}
|
||||
|
||||
export function WindowUnfullscreen() {
|
||||
window.runtime.WindowUnfullscreen();
|
||||
}
|
||||
|
||||
export function WindowIsFullscreen() {
|
||||
return window.runtime.WindowIsFullscreen();
|
||||
}
|
||||
|
||||
export function WindowGetSize() {
|
||||
return window.runtime.WindowGetSize();
|
||||
}
|
||||
|
||||
export function WindowSetSize(width, height) {
|
||||
window.runtime.WindowSetSize(width, height);
|
||||
}
|
||||
|
||||
export function WindowSetMaxSize(width, height) {
|
||||
window.runtime.WindowSetMaxSize(width, height);
|
||||
}
|
||||
|
||||
export function WindowSetMinSize(width, height) {
|
||||
window.runtime.WindowSetMinSize(width, height);
|
||||
}
|
||||
|
||||
export function WindowSetPosition(x, y) {
|
||||
window.runtime.WindowSetPosition(x, y);
|
||||
}
|
||||
|
||||
export function WindowGetPosition() {
|
||||
return window.runtime.WindowGetPosition();
|
||||
}
|
||||
|
||||
export function WindowHide() {
|
||||
window.runtime.WindowHide();
|
||||
}
|
||||
|
||||
export function WindowShow() {
|
||||
window.runtime.WindowShow();
|
||||
}
|
||||
|
||||
export function WindowMaximise() {
|
||||
window.runtime.WindowMaximise();
|
||||
}
|
||||
|
||||
export function WindowToggleMaximise() {
|
||||
window.runtime.WindowToggleMaximise();
|
||||
}
|
||||
|
||||
export function WindowUnmaximise() {
|
||||
window.runtime.WindowUnmaximise();
|
||||
}
|
||||
|
||||
export function WindowIsMaximised() {
|
||||
return window.runtime.WindowIsMaximised();
|
||||
}
|
||||
|
||||
export function WindowMinimise() {
|
||||
window.runtime.WindowMinimise();
|
||||
}
|
||||
|
||||
export function WindowUnminimise() {
|
||||
window.runtime.WindowUnminimise();
|
||||
}
|
||||
|
||||
export function WindowSetBackgroundColour(R, G, B, A) {
|
||||
window.runtime.WindowSetBackgroundColour(R, G, B, A);
|
||||
}
|
||||
|
||||
export function ScreenGetAll() {
|
||||
return window.runtime.ScreenGetAll();
|
||||
}
|
||||
|
||||
export function WindowIsMinimised() {
|
||||
return window.runtime.WindowIsMinimised();
|
||||
}
|
||||
|
||||
export function WindowIsNormal() {
|
||||
return window.runtime.WindowIsNormal();
|
||||
}
|
||||
|
||||
export function BrowserOpenURL(url) {
|
||||
window.runtime.BrowserOpenURL(url);
|
||||
}
|
||||
|
||||
export function Environment() {
|
||||
return window.runtime.Environment();
|
||||
}
|
||||
|
||||
export function Quit() {
|
||||
window.runtime.Quit();
|
||||
}
|
||||
|
||||
export function Hide() {
|
||||
window.runtime.Hide();
|
||||
}
|
||||
|
||||
export function Show() {
|
||||
window.runtime.Show();
|
||||
}
|
||||
|
||||
export function ClipboardGetText() {
|
||||
return window.runtime.ClipboardGetText();
|
||||
}
|
||||
|
||||
export function ClipboardSetText(text) {
|
||||
return window.runtime.ClipboardSetText(text);
|
||||
}
|
||||
|
||||
/**
|
||||
* Callback for OnFileDrop returns a slice of file path strings when a drop is finished.
|
||||
*
|
||||
* @export
|
||||
* @callback OnFileDropCallback
|
||||
* @param {number} x - x coordinate of the drop
|
||||
* @param {number} y - y coordinate of the drop
|
||||
* @param {string[]} paths - A list of file paths.
|
||||
*/
|
||||
|
||||
/**
|
||||
* OnFileDrop listens to drag and drop events and calls the callback with the coordinates of the drop and an array of path strings.
|
||||
*
|
||||
* @export
|
||||
* @param {OnFileDropCallback} callback - Callback for OnFileDrop returns a slice of file path strings when a drop is finished.
|
||||
* @param {boolean} [useDropTarget=true] - Only call the callback when the drop finished on an element that has the drop target style. (--wails-drop-target)
|
||||
*/
|
||||
export function OnFileDrop(callback, useDropTarget) {
|
||||
return window.runtime.OnFileDrop(callback, useDropTarget);
|
||||
}
|
||||
|
||||
/**
|
||||
* OnFileDropOff removes the drag and drop listeners and handlers.
|
||||
*/
|
||||
export function OnFileDropOff() {
|
||||
return window.runtime.OnFileDropOff();
|
||||
}
|
||||
|
||||
export function CanResolveFilePaths() {
|
||||
return window.runtime.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);
|
||||
}
|
||||
32
frontend/tsconfig.json
Normal file
32
frontend/tsconfig.json
Normal file
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"module": "ESNext",
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"jsx": "preserve",
|
||||
"moduleResolution": "bundler",
|
||||
"resolveJsonModule": true,
|
||||
"allowJs": true,
|
||||
"strict": false,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"useDefineForClassFields": true,
|
||||
"sourceMap": true,
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["src/*"]
|
||||
}
|
||||
},
|
||||
"include": [
|
||||
"src/**/*.ts",
|
||||
"src/**/*.tsx",
|
||||
"src/**/*.vue",
|
||||
"src/**/*.d.ts"
|
||||
],
|
||||
"exclude": [
|
||||
"node_modules",
|
||||
"dist"
|
||||
]
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user