Private
Public Access
1
0

重构: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:
2026-05-01 11:03:53 +08:00
parent 44847e0d40
commit f54bf1c28d
185 changed files with 7768 additions and 914 deletions

35
frontend/.eslintrc.js Normal file
View 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
View 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

View File

@@ -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);

View File

@@ -0,0 +1,2 @@
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
// This file is automatically generated. DO NOT EDIT

View File

@@ -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";

View File

@@ -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>);
}
}

View 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);

View 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";

View 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";

View 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>);
}
}

View 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";

View 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>);
}
}

View 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
View 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

File diff suppressed because it is too large Load Diff

50
frontend/package.json Normal file
View 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
View 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
View 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>

View 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()

View 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> {}
}

View File

@@ -0,0 +1,6 @@
/**
* API 统一导出
*/
export * from './types'
export * from './system'

110
frontend/src/api/system.ts Normal file
View 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()
}

View 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
View 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
}

View 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()
}
}

View 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>

View File

@@ -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>

View File

@@ -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>

View 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>

View 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>

View File

@@ -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>

View File

@@ -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>

File diff suppressed because it is too large Load Diff

View 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>

View 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>

View 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>

View 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>

View File

@@ -0,0 +1,42 @@
import { ref } from 'vue'
import { Message } from '@arco-design/web-vue'
/**
* 拷贝路径 composable3-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 }
}

View File

@@ -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 keyWindows 返回 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 }
}

View 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
}
}

View 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
}
}

View File

@@ -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

View 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
}
}

View File

@@ -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 }

File diff suppressed because it is too large Load Diff

View 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>

View 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>

View 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 = { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#039;' }
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>

View 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>

View 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>

View 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>

View 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)
/** 渲染 changelogMarkdown → 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>

View File

@@ -0,0 +1,7 @@
/**
* 全局 Composables 导出
*/
export * from './useDebounce'
export * from './useTablePage'
export * from './useApiError'

View 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
}
}

View 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)
}
}

View 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
}

View 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 - 清空所有状态
*/

View 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
}
},
}

View 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 - 加载路径历史记录
*/

View 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
}
}

View 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
View 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')

View 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
}
})

View 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,
}
})

View 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
}
})

View 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
View 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;
}

View 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
View 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 {}

View 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)
}

View 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 动态导入,避免全量打包

View 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: /[<>:"/\\|?*]/,
}

View 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()
}

View 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) }
}
}

View 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)
}

View 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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;')
}
/**
* 轻量 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
})
}

View File

@@ -0,0 +1,151 @@
/**
* 统一语言映射
* 供 highlight.jsMarkdown 预览)和 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'
}

View 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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
}
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')) {
// 从保存的原始源码恢复,而非 textContentSVG 的 textContent 是 CSS 垃圾)
el.innerHTML = el.getAttribute('data-mermaid-src') || ''
el.removeAttribute('data-processed')
}
})
await renderMermaidDiagrams()
}

View File

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

View File

@@ -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>

View 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>

View 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>

View File

@@ -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);

View File

@@ -0,0 +1,2 @@
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
// This file is automatically generated. DO NOT EDIT

View File

@@ -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";

View File

@@ -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>);
}
}

View 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);

View 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";

View 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";

View 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>);
}
}

View 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";

View 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>);
}
}

View 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);

View 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>;

View 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);
}

View 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"];
}
}
}

View 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"
}

View 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>;

View 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
View 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