新增: SFTP直连+网站预览+OSS区域嗅探+热键+BGM播放
This commit is contained in:
@@ -21,12 +21,28 @@ import * as filesystem$0 from "./internal/filesystem/models.js";
|
||||
// @ts-ignore: Unused imports
|
||||
import * as $models from "./models.js";
|
||||
|
||||
/**
|
||||
* BgmGetPlaylist 获取播放列表
|
||||
*/
|
||||
export function BgmGetPlaylist(): $CancellablePromise<$models.BgmPlaylistItem[]> {
|
||||
return $Call.ByID(3200870077).then(($result: any) => {
|
||||
return $$createType1($result);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* BgmSavePlaylist 全量保存播放列表(前端调用时传完整列表)
|
||||
*/
|
||||
export function BgmSavePlaylist(items: $models.BgmPlaylistItem[]): $CancellablePromise<void> {
|
||||
return $Call.ByID(2929660002, items);
|
||||
}
|
||||
|
||||
/**
|
||||
* CheckUpdate 检查更新
|
||||
*/
|
||||
export function CheckUpdate(): $CancellablePromise<{ [_ in string]?: any }> {
|
||||
return $Call.ByID(586574094).then(($result: any) => {
|
||||
return $$createType0($result);
|
||||
return $$createType2($result);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -42,7 +58,7 @@ export function ClearCache(): $CancellablePromise<void> {
|
||||
*/
|
||||
export function CreateDir(path: string): $CancellablePromise<filesystem$0.FileOperationResult | null> {
|
||||
return $Call.ByID(632035444, path).then(($result: any) => {
|
||||
return $$createType2($result);
|
||||
return $$createType4($result);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -51,7 +67,7 @@ export function CreateDir(path: string): $CancellablePromise<filesystem$0.FileOp
|
||||
*/
|
||||
export function CreateFile(path: string): $CancellablePromise<filesystem$0.FileOperationResult | null> {
|
||||
return $Call.ByID(3418645411, path).then(($result: any) => {
|
||||
return $$createType2($result);
|
||||
return $$createType4($result);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -64,7 +80,7 @@ export function DeleteConnectionProfile(id: number): $CancellablePromise<void> {
|
||||
*/
|
||||
export function DeletePath(path: string): $CancellablePromise<filesystem$0.FileOperationResult | null> {
|
||||
return $Call.ByID(1564637217, path).then(($result: any) => {
|
||||
return $$createType2($result);
|
||||
return $$createType4($result);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -80,7 +96,7 @@ export function DeletePermanently(recyclePath: string): $CancellablePromise<void
|
||||
*/
|
||||
export function DetectFileTypeByContent(path: string): $CancellablePromise<{ [_ in string]?: any }> {
|
||||
return $Call.ByID(3067282982, path).then(($result: any) => {
|
||||
return $$createType0($result);
|
||||
return $$createType2($result);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -89,7 +105,7 @@ export function DetectFileTypeByContent(path: string): $CancellablePromise<{ [_
|
||||
*/
|
||||
export function DownloadUpdate(downloadURL: string): $CancellablePromise<{ [_ in string]?: any }> {
|
||||
return $Call.ByID(115027584, downloadURL).then(($result: any) => {
|
||||
return $$createType0($result);
|
||||
return $$createType2($result);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -105,7 +121,7 @@ export function EmptyRecycleBin(): $CancellablePromise<void> {
|
||||
*/
|
||||
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);
|
||||
return $$createType2($result);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -128,7 +144,7 @@ export function ExtractFileFromZipToTemp(zipPath: string, filePath: string): $Ca
|
||||
*/
|
||||
export function GetAppConfig(): $CancellablePromise<{ [_ in string]?: any }> {
|
||||
return $Call.ByID(2006534548).then(($result: any) => {
|
||||
return $$createType0($result);
|
||||
return $$createType2($result);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -137,7 +153,7 @@ export function GetAppConfig(): $CancellablePromise<{ [_ in string]?: any }> {
|
||||
*/
|
||||
export function GetAuditLogs(limit: number): $CancellablePromise<{ [_ in string]?: any }[]> {
|
||||
return $Call.ByID(3554903517, limit).then(($result: any) => {
|
||||
return $$createType3($result);
|
||||
return $$createType5($result);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -146,7 +162,7 @@ export function GetAuditLogs(limit: number): $CancellablePromise<{ [_ in string]
|
||||
*/
|
||||
export function GetCPUInfo(): $CancellablePromise<{ [_ in string]?: any }> {
|
||||
return $Call.ByID(2509681007).then(($result: any) => {
|
||||
return $$createType0($result);
|
||||
return $$createType2($result);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -155,7 +171,7 @@ export function GetCPUInfo(): $CancellablePromise<{ [_ in string]?: any }> {
|
||||
*/
|
||||
export function GetCommonPaths(): $CancellablePromise<{ [_ in string]?: string }> {
|
||||
return $Call.ByID(3953343786).then(($result: any) => {
|
||||
return $$createType4($result);
|
||||
return $$createType6($result);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -164,7 +180,7 @@ export function GetCommonPaths(): $CancellablePromise<{ [_ in string]?: string }
|
||||
*/
|
||||
export function GetCurrentVersion(): $CancellablePromise<{ [_ in string]?: any }> {
|
||||
return $Call.ByID(1827245900).then(($result: any) => {
|
||||
return $$createType0($result);
|
||||
return $$createType2($result);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -173,7 +189,7 @@ export function GetCurrentVersion(): $CancellablePromise<{ [_ in string]?: any }
|
||||
*/
|
||||
export function GetDiskInfo(): $CancellablePromise<{ [_ in string]?: any }[]> {
|
||||
return $Call.ByID(3756377758).then(($result: any) => {
|
||||
return $$createType3($result);
|
||||
return $$createType5($result);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -182,7 +198,7 @@ export function GetDiskInfo(): $CancellablePromise<{ [_ in string]?: any }[]> {
|
||||
*/
|
||||
export function GetEnvVars(): $CancellablePromise<{ [_ in string]?: string }> {
|
||||
return $Call.ByID(363814436).then(($result: any) => {
|
||||
return $$createType4($result);
|
||||
return $$createType6($result);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -191,7 +207,7 @@ export function GetEnvVars(): $CancellablePromise<{ [_ in string]?: string }> {
|
||||
*/
|
||||
export function GetFileInfo(path: string): $CancellablePromise<{ [_ in string]?: any }> {
|
||||
return $Call.ByID(2071650585, path).then(($result: any) => {
|
||||
return $$createType0($result);
|
||||
return $$createType2($result);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -204,7 +220,7 @@ export function GetFileServerURL(): $CancellablePromise<string> {
|
||||
|
||||
export function GetLocalSystemInfo(): $CancellablePromise<{ [_ in string]?: any }> {
|
||||
return $Call.ByID(2203542363).then(($result: any) => {
|
||||
return $$createType0($result);
|
||||
return $$createType2($result);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -213,7 +229,7 @@ export function GetLocalSystemInfo(): $CancellablePromise<{ [_ in string]?: any
|
||||
*/
|
||||
export function GetMemoryInfo(): $CancellablePromise<{ [_ in string]?: any }> {
|
||||
return $Call.ByID(2096905876).then(($result: any) => {
|
||||
return $$createType0($result);
|
||||
return $$createType2($result);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -222,7 +238,7 @@ export function GetMemoryInfo(): $CancellablePromise<{ [_ in string]?: any }> {
|
||||
*/
|
||||
export function GetRecycleBinEntries(): $CancellablePromise<{ [_ in string]?: any }[]> {
|
||||
return $Call.ByID(2312855399).then(($result: any) => {
|
||||
return $$createType3($result);
|
||||
return $$createType5($result);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -231,7 +247,7 @@ export function GetRecycleBinEntries(): $CancellablePromise<{ [_ in string]?: an
|
||||
*/
|
||||
export function GetSystemInfo(): $CancellablePromise<{ [_ in string]?: any }> {
|
||||
return $Call.ByID(1347250254).then(($result: any) => {
|
||||
return $$createType0($result);
|
||||
return $$createType2($result);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -240,7 +256,7 @@ export function GetSystemInfo(): $CancellablePromise<{ [_ in string]?: any }> {
|
||||
*/
|
||||
export function GetUpdateConfig(): $CancellablePromise<{ [_ in string]?: any }> {
|
||||
return $Call.ByID(680804904).then(($result: any) => {
|
||||
return $$createType0($result);
|
||||
return $$createType2($result);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -249,16 +265,23 @@ export function GetUpdateConfig(): $CancellablePromise<{ [_ in string]?: any }>
|
||||
*/
|
||||
export function GetZipFileInfo(zipPath: string, filePath: string): $CancellablePromise<{ [_ in string]?: any }> {
|
||||
return $Call.ByID(2031617692, zipPath, filePath).then(($result: any) => {
|
||||
return $$createType0($result);
|
||||
return $$createType2($result);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* HandleHotkey 处理全局热键回调:切换 BgmBar 显示/隐藏
|
||||
*/
|
||||
export function HandleHotkey(): $CancellablePromise<void> {
|
||||
return $Call.ByID(420101833);
|
||||
}
|
||||
|
||||
/**
|
||||
* InstallUpdate 安装更新包
|
||||
*/
|
||||
export function InstallUpdate(installerPath: string, autoRestart: boolean): $CancellablePromise<{ [_ in string]?: any }> {
|
||||
return $Call.ByID(2443992793, installerPath, autoRestart).then(($result: any) => {
|
||||
return $$createType0($result);
|
||||
return $$createType2($result);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -267,7 +290,7 @@ export function InstallUpdate(installerPath: string, autoRestart: boolean): $Can
|
||||
*/
|
||||
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);
|
||||
return $$createType2($result);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -276,7 +299,7 @@ export function InstallUpdateWithHash(installerPath: string, autoRestart: boolea
|
||||
*/
|
||||
export function ListDir(path: string): $CancellablePromise<{ [_ in string]?: any }[]> {
|
||||
return $Call.ByID(2120475736, path).then(($result: any) => {
|
||||
return $$createType3($result);
|
||||
return $$createType5($result);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -285,13 +308,13 @@ export function ListDir(path: string): $CancellablePromise<{ [_ in string]?: any
|
||||
*/
|
||||
export function ListZipContents(zipPath: string): $CancellablePromise<{ [_ in string]?: any }[]> {
|
||||
return $Call.ByID(3013109042, zipPath).then(($result: any) => {
|
||||
return $$createType3($result);
|
||||
return $$createType5($result);
|
||||
});
|
||||
}
|
||||
|
||||
export function LoadConnectionProfiles(): $CancellablePromise<{ [_ in string]?: any }[]> {
|
||||
return $Call.ByID(454364767).then(($result: any) => {
|
||||
return $$createType3($result);
|
||||
return $$createType5($result);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -311,7 +334,7 @@ export function OssConnect(req: $models.OssConnectRequest): $CancellablePromise<
|
||||
*/
|
||||
export function OssCreateDir(connID: string, dirPath: string): $CancellablePromise<filesystem$0.FileOperationResult | null> {
|
||||
return $Call.ByID(605668951, connID, dirPath).then(($result: any) => {
|
||||
return $$createType2($result);
|
||||
return $$createType4($result);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -320,7 +343,7 @@ export function OssCreateDir(connID: string, dirPath: string): $CancellablePromi
|
||||
*/
|
||||
export function OssCreateFile(connID: string, filePath: string): $CancellablePromise<filesystem$0.FileOperationResult | null> {
|
||||
return $Call.ByID(4148593430, connID, filePath).then(($result: any) => {
|
||||
return $$createType2($result);
|
||||
return $$createType4($result);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -329,7 +352,7 @@ export function OssCreateFile(connID: string, filePath: string): $CancellablePro
|
||||
*/
|
||||
export function OssDeletePath(connID: string, key: string): $CancellablePromise<filesystem$0.FileOperationResult | null> {
|
||||
return $Call.ByID(4285234744, connID, key).then(($result: any) => {
|
||||
return $$createType2($result);
|
||||
return $$createType4($result);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -340,6 +363,13 @@ export function OssDisconnect(connID: string): $CancellablePromise<void> {
|
||||
return $Call.ByID(3427288622, connID);
|
||||
}
|
||||
|
||||
/**
|
||||
* OssDownloadSiteForPreview OSS 下载 HTML 及其引用的资源到临时目录
|
||||
*/
|
||||
export function OssDownloadSiteForPreview(connID: string, key: string): $CancellablePromise<string> {
|
||||
return $Call.ByID(1387550222, connID, key);
|
||||
}
|
||||
|
||||
/**
|
||||
* OssDownloadToTemp OSS 下载到临时文件
|
||||
*/
|
||||
@@ -347,12 +377,19 @@ export function OssDownloadToTemp(connID: string, key: string): $CancellableProm
|
||||
return $Call.ByID(370656471, connID, key);
|
||||
}
|
||||
|
||||
/**
|
||||
* OssDownloadToTempCached 带缓存的 OSS 下载(命中缓存直接返回本地路径)
|
||||
*/
|
||||
export function OssDownloadToTempCached(connID: string, key: string, fileSize: number, modTime: string): $CancellablePromise<string> {
|
||||
return $Call.ByID(1312098141, connID, key, fileSize, modTime);
|
||||
}
|
||||
|
||||
/**
|
||||
* OssGetCommonPaths OSS 获取常用路径
|
||||
*/
|
||||
export function OssGetCommonPaths(connID: string): $CancellablePromise<{ [_ in string]?: string }> {
|
||||
return $Call.ByID(3525024115, connID).then(($result: any) => {
|
||||
return $$createType4($result);
|
||||
return $$createType6($result);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -361,7 +398,7 @@ export function OssGetCommonPaths(connID: string): $CancellablePromise<{ [_ in s
|
||||
*/
|
||||
export function OssGetFileInfo(connID: string, key: string): $CancellablePromise<{ [_ in string]?: any }> {
|
||||
return $Call.ByID(852430614, connID, key).then(($result: any) => {
|
||||
return $$createType0($result);
|
||||
return $$createType2($result);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -377,7 +414,7 @@ export function OssGetSignedURL(connID: string, key: string): $CancellablePromis
|
||||
*/
|
||||
export function OssListDir(connID: string, prefix: string): $CancellablePromise<{ [_ in string]?: any }[]> {
|
||||
return $Call.ByID(3013212019, connID, prefix).then(($result: any) => {
|
||||
return $$createType3($result);
|
||||
return $$createType5($result);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -393,7 +430,7 @@ export function OssReadFile(connID: string, key: string): $CancellablePromise<st
|
||||
*/
|
||||
export function OssRenamePath(req: $models.OssRenamePathRequest): $CancellablePromise<filesystem$0.FileOperationResult | null> {
|
||||
return $Call.ByID(4218061693, req).then(($result: any) => {
|
||||
return $$createType2($result);
|
||||
return $$createType4($result);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -418,6 +455,13 @@ export function ReadFile(path: string): $CancellablePromise<string> {
|
||||
return $Call.ByID(1160596971, path);
|
||||
}
|
||||
|
||||
/**
|
||||
* RegisterGlobalHotkey 注册 Ctrl+Shift+B 全局热键(需在窗口创建后调用)
|
||||
*/
|
||||
export function RegisterGlobalHotkey(): $CancellablePromise<void> {
|
||||
return $Call.ByID(2089930789);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reload 重新加载窗口(用于菜单项)
|
||||
*/
|
||||
@@ -430,7 +474,7 @@ export function Reload(): $CancellablePromise<void> {
|
||||
*/
|
||||
export function RenamePath(req: $models.RenamePathRequest): $CancellablePromise<filesystem$0.FileOperationResult | null> {
|
||||
return $Call.ByID(1959759948, req).then(($result: any) => {
|
||||
return $$createType2($result);
|
||||
return $$createType4($result);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -439,7 +483,7 @@ export function RenamePath(req: $models.RenamePathRequest): $CancellablePromise<
|
||||
*/
|
||||
export function ResolveShortcut(lnkPath: string): $CancellablePromise<{ [_ in string]?: any }> {
|
||||
return $Call.ByID(4051288361, lnkPath).then(($result: any) => {
|
||||
return $$createType0($result);
|
||||
return $$createType2($result);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -455,7 +499,7 @@ export function RestoreFromRecycleBin(recyclePath: string): $CancellablePromise<
|
||||
*/
|
||||
export function SaveAppConfig(req: $models.SaveAppConfigRequest): $CancellablePromise<{ [_ in string]?: any }> {
|
||||
return $Call.ByID(1942219977, req).then(($result: any) => {
|
||||
return $$createType0($result);
|
||||
return $$createType2($result);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -468,7 +512,7 @@ export function SaveBase64File(req: $models.SaveBase64FileRequest): $Cancellable
|
||||
|
||||
export function SaveConnectionProfile(req: $models.SaveProfileRequest): $CancellablePromise<{ [_ in string]?: any }> {
|
||||
return $Call.ByID(3622685069, req).then(($result: any) => {
|
||||
return $$createType0($result);
|
||||
return $$createType2($result);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -491,7 +535,7 @@ export function SetMainWindow(w: application$0.WebviewWindow | null): $Cancellab
|
||||
*/
|
||||
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);
|
||||
return $$createType2($result);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -514,7 +558,7 @@ export function SftpConnect(req: $models.SftpConnectRequest): $CancellablePromis
|
||||
*/
|
||||
export function SftpCreateDir(connID: string, dirPath: string): $CancellablePromise<filesystem$0.FileOperationResult | null> {
|
||||
return $Call.ByID(586600875, connID, dirPath).then(($result: any) => {
|
||||
return $$createType2($result);
|
||||
return $$createType4($result);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -523,7 +567,7 @@ export function SftpCreateDir(connID: string, dirPath: string): $CancellableProm
|
||||
*/
|
||||
export function SftpCreateFile(connID: string, filePath: string): $CancellablePromise<filesystem$0.FileOperationResult | null> {
|
||||
return $Call.ByID(623026146, connID, filePath).then(($result: any) => {
|
||||
return $$createType2($result);
|
||||
return $$createType4($result);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -532,7 +576,7 @@ export function SftpCreateFile(connID: string, filePath: string): $CancellablePr
|
||||
*/
|
||||
export function SftpDeletePath(connID: string, filePath: string): $CancellablePromise<filesystem$0.FileOperationResult | null> {
|
||||
return $Call.ByID(1833619836, connID, filePath).then(($result: any) => {
|
||||
return $$createType2($result);
|
||||
return $$createType4($result);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -543,6 +587,13 @@ export function SftpDisconnect(connID: string): $CancellablePromise<void> {
|
||||
return $Call.ByID(597628874, connID);
|
||||
}
|
||||
|
||||
/**
|
||||
* SftpDownloadSiteForPreview 下载 HTML 及其网站资源到本地临时目录
|
||||
*/
|
||||
export function SftpDownloadSiteForPreview(connID: string, remotePath: string): $CancellablePromise<string> {
|
||||
return $Call.ByID(1591575570, connID, remotePath);
|
||||
}
|
||||
|
||||
/**
|
||||
* SftpDownloadToTemp 下载远程文件到本地临时目录(用于预览)
|
||||
*/
|
||||
@@ -550,12 +601,19 @@ export function SftpDownloadToTemp(connID: string, remotePath: string): $Cancell
|
||||
return $Call.ByID(1159267603, connID, remotePath);
|
||||
}
|
||||
|
||||
/**
|
||||
* SftpDownloadToTempCached 带缓存的 SFTP 下载(命中缓存直接返回本地路径)
|
||||
*/
|
||||
export function SftpDownloadToTempCached(connID: string, remotePath: string, fileSize: number, modTime: string): $CancellablePromise<string> {
|
||||
return $Call.ByID(3935472409, connID, remotePath, fileSize, modTime);
|
||||
}
|
||||
|
||||
/**
|
||||
* SftpGetCommonPaths 获取 SFTP 远程主机常用路径
|
||||
*/
|
||||
export function SftpGetCommonPaths(connID: string): $CancellablePromise<{ [_ in string]?: string }> {
|
||||
return $Call.ByID(2874386183, connID).then(($result: any) => {
|
||||
return $$createType4($result);
|
||||
return $$createType6($result);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -564,7 +622,7 @@ export function SftpGetCommonPaths(connID: string): $CancellablePromise<{ [_ in
|
||||
*/
|
||||
export function SftpGetFileInfo(connID: string, filePath: string): $CancellablePromise<{ [_ in string]?: any }> {
|
||||
return $Call.ByID(1959840482, connID, filePath).then(($result: any) => {
|
||||
return $$createType0($result);
|
||||
return $$createType2($result);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -573,7 +631,7 @@ export function SftpGetFileInfo(connID: string, filePath: string): $CancellableP
|
||||
*/
|
||||
export function SftpGetSystemInfo(connID: string): $CancellablePromise<{ [_ in string]?: any }> {
|
||||
return $Call.ByID(1950143653, connID).then(($result: any) => {
|
||||
return $$createType0($result);
|
||||
return $$createType2($result);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -582,7 +640,7 @@ export function SftpGetSystemInfo(connID: string): $CancellablePromise<{ [_ in s
|
||||
*/
|
||||
export function SftpListDir(connID: string, dirPath: string): $CancellablePromise<{ [_ in string]?: any }[]> {
|
||||
return $Call.ByID(2061863855, connID, dirPath).then(($result: any) => {
|
||||
return $$createType3($result);
|
||||
return $$createType5($result);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -598,7 +656,7 @@ export function SftpReadFile(connID: string, filePath: string): $CancellableProm
|
||||
*/
|
||||
export function SftpRenamePath(req: $models.SftpRenamePathRequest): $CancellablePromise<filesystem$0.FileOperationResult | null> {
|
||||
return $Call.ByID(183173937, req).then(($result: any) => {
|
||||
return $$createType2($result);
|
||||
return $$createType4($result);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -621,7 +679,7 @@ export function SftpWriteFile(req: $models.SftpWriteFileRequest): $CancellablePr
|
||||
*/
|
||||
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);
|
||||
return $$createType2($result);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -668,8 +726,10 @@ export function WriteFile(req: $models.WriteFileRequest): $CancellablePromise<vo
|
||||
}
|
||||
|
||||
// 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);
|
||||
const $$createType0 = $models.BgmPlaylistItem.createFrom;
|
||||
const $$createType1 = $Create.Array($$createType0);
|
||||
const $$createType2 = $Create.Map($Create.Any, $Create.Any);
|
||||
const $$createType3 = filesystem$0.FileOperationResult.createFrom;
|
||||
const $$createType4 = $Create.Nullable($$createType3);
|
||||
const $$createType5 = $Create.Array($$createType2);
|
||||
const $$createType6 = $Create.Map($Create.Any, $Create.Any);
|
||||
|
||||
@@ -7,6 +7,7 @@ export {
|
||||
};
|
||||
|
||||
export {
|
||||
BgmPlaylistItem,
|
||||
OssConnectRequest,
|
||||
OssRenamePathRequest,
|
||||
RenamePathRequest,
|
||||
|
||||
@@ -9,6 +9,38 @@ import { Create as $Create } from "@wailsio/runtime";
|
||||
// @ts-ignore: Unused imports
|
||||
import * as api$0 from "./internal/api/models.js";
|
||||
|
||||
/**
|
||||
* BgmPlaylistItem 播放列表条目
|
||||
*/
|
||||
export class BgmPlaylistItem {
|
||||
"name": string;
|
||||
"path": string;
|
||||
"profile_id": string;
|
||||
|
||||
/** Creates a new BgmPlaylistItem instance. */
|
||||
constructor($$source: Partial<BgmPlaylistItem> = {}) {
|
||||
if (!("name" in $$source)) {
|
||||
this["name"] = "";
|
||||
}
|
||||
if (!("path" in $$source)) {
|
||||
this["path"] = "";
|
||||
}
|
||||
if (!("profile_id" in $$source)) {
|
||||
this["profile_id"] = "";
|
||||
}
|
||||
|
||||
Object.assign(this, $$source);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new BgmPlaylistItem instance from a string or object.
|
||||
*/
|
||||
static createFrom($$source: any = {}): BgmPlaylistItem {
|
||||
let $$parsedSource = typeof $$source === 'string' ? JSON.parse($$source) : $$source;
|
||||
return new BgmPlaylistItem($$parsedSource as Partial<BgmPlaylistItem>);
|
||||
}
|
||||
}
|
||||
|
||||
export class OssConnectRequest {
|
||||
"provider": string;
|
||||
"access_key": string;
|
||||
@@ -183,6 +215,7 @@ export class SaveProfileRequest {
|
||||
"password": string;
|
||||
"key_path": string;
|
||||
"type": string;
|
||||
"provider": string;
|
||||
"token": string;
|
||||
"access_key": string;
|
||||
"secret_key": string;
|
||||
@@ -217,6 +250,9 @@ export class SaveProfileRequest {
|
||||
if (!("type" in $$source)) {
|
||||
this["type"] = "";
|
||||
}
|
||||
if (!("provider" in $$source)) {
|
||||
this["provider"] = "";
|
||||
}
|
||||
if (!("token" in $$source)) {
|
||||
this["token"] = "";
|
||||
}
|
||||
|
||||
@@ -14,7 +14,8 @@ import {
|
||||
SftpGetSystemInfo, GetLocalSystemInfo,
|
||||
} from '@bindings/u-desk/app'
|
||||
|
||||
export type ConnectionType = 'local' | 'remote' | 'sftp' | 'qiniu' | 'aliyun'
|
||||
export type ConnectionType = 'local' | 'remote' | 'sftp' | 'oss'
|
||||
export type OssProvider = 'qiniu' | 'aliyun'
|
||||
|
||||
export interface ConnectionProfile {
|
||||
id: string | number
|
||||
@@ -23,6 +24,7 @@ export interface ConnectionProfile {
|
||||
port: number
|
||||
token: string
|
||||
type: ConnectionType
|
||||
provider?: OssProvider
|
||||
username?: string
|
||||
password?: string
|
||||
keyPath?: string
|
||||
@@ -129,6 +131,7 @@ class ConnectionManagerImpl {
|
||||
password: profile.password || '',
|
||||
keyPath: profile.keyPath || '',
|
||||
type: profile.type,
|
||||
provider: profile.provider || '',
|
||||
token: profile.token || '',
|
||||
access_key: profile.accessKey || '',
|
||||
secret_key: profile.secretKey || '',
|
||||
@@ -200,7 +203,7 @@ class ConnectionManagerImpl {
|
||||
|
||||
isRemote(): boolean {
|
||||
const t = this.activeProfile?.type
|
||||
return t === 'remote' || t === 'sftp' || t === 'qiniu' || t === 'aliyun'
|
||||
return t === 'remote' || t === 'sftp' || t === 'oss'
|
||||
}
|
||||
|
||||
getSystemInfo(profileId: string): SystemInfo | undefined {
|
||||
@@ -221,7 +224,7 @@ class ConnectionManagerImpl {
|
||||
const data = await SftpGetSystemInfo(t.sessionId)
|
||||
if (data) Object.assign(info, snakeToCamel(data))
|
||||
}
|
||||
} else if (profile.type === 'qiniu' || profile.type === 'aliyun') {
|
||||
} else if (profile.type === 'oss') {
|
||||
// OSS 无系统信息可采集
|
||||
info.diskUsage = '-'
|
||||
info.cpuUsage = '-'
|
||||
@@ -363,8 +366,8 @@ class ConnectionManagerImpl {
|
||||
return
|
||||
}
|
||||
|
||||
// OSS (qiniu / aliyun)
|
||||
if (profile.type === 'qiniu' || profile.type === 'aliyun') {
|
||||
// OSS
|
||||
if (profile.type === 'oss') {
|
||||
this.setState('connecting')
|
||||
try {
|
||||
const t = new OssTransport(profile)
|
||||
|
||||
@@ -30,10 +30,5 @@ export function getFileServerBaseURL(): string {
|
||||
return _cachedURL || FALLBACK_URL
|
||||
}
|
||||
|
||||
/** 获取带 /localfs 后缀的完整 base */
|
||||
export function getLocalFsBaseURL(): string {
|
||||
return `${getFileServerBaseURL()}/localfs`
|
||||
}
|
||||
|
||||
/** 启动时自动初始化 */
|
||||
initFileServerURL().catch(() => {})
|
||||
|
||||
@@ -133,4 +133,8 @@ export class HttpTransport implements FsTransport {
|
||||
async deletePermanently(_path: string): Promise<void> {}
|
||||
|
||||
async emptyRecycleBin(): Promise<void> {}
|
||||
|
||||
async downloadForPreview(path: string): Promise<string> {
|
||||
return path
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,8 +9,8 @@ import type { ConnectionProfile } from './connection-manager'
|
||||
import {
|
||||
OssConnect, OssDisconnect, OssListDir, OssReadFile,
|
||||
OssWriteFile, OssGetFileInfo, OssCreateDir, OssCreateFile,
|
||||
OssDeletePath, OssRenamePath, OssDownloadToTemp, OssGetCommonPaths,
|
||||
OssWriteBase64File, OssGetSignedURL,
|
||||
OssDeletePath, OssRenamePath, OssDownloadToTemp, OssDownloadSiteForPreview,
|
||||
OssGetCommonPaths, OssWriteBase64File, OssGetSignedURL,
|
||||
} from '@bindings/u-desk/app'
|
||||
|
||||
function transformFile(file: any): FileItem {
|
||||
@@ -21,13 +21,9 @@ function transformFileList(files: any[]): FileItem[] {
|
||||
return files.map(transformFile)
|
||||
}
|
||||
|
||||
const PREVIEW_CACHE_MAX = 50
|
||||
|
||||
export class OssTransport implements FsTransport {
|
||||
private profile: ConnectionProfile
|
||||
private connID: string | null = null
|
||||
private previewCache = new Map<string, string>()
|
||||
private previewOrder: string[] = []
|
||||
|
||||
constructor(profile: ConnectionProfile) {
|
||||
this.profile = profile
|
||||
@@ -35,7 +31,7 @@ export class OssTransport implements FsTransport {
|
||||
|
||||
async connect(): Promise<string> {
|
||||
const result = await OssConnect({
|
||||
provider: this.profile.type,
|
||||
provider: this.profile.provider || 'qiniu',
|
||||
access_key: this.profile.accessKey || '',
|
||||
secret_key: this.profile.secretKey || '',
|
||||
endpoint: this.profile.endpoint || '',
|
||||
@@ -53,8 +49,6 @@ export class OssTransport implements FsTransport {
|
||||
}
|
||||
this.connID = null
|
||||
}
|
||||
this.previewCache.clear()
|
||||
this.previewOrder = []
|
||||
}
|
||||
|
||||
private requireConn(): string {
|
||||
@@ -169,22 +163,14 @@ export class OssTransport implements FsTransport {
|
||||
|
||||
// ====== 预览辅助 ======
|
||||
|
||||
/** 下载远程文件到本地临时目录用于预览(缓存由 Go 后端管理) */
|
||||
async downloadForPreview(remotePath: string): Promise<string> {
|
||||
if (this.previewCache.has(remotePath)) {
|
||||
this.previewOrder = this.previewOrder.filter(p => p !== remotePath)
|
||||
this.previewOrder.push(remotePath)
|
||||
return this.previewCache.get(remotePath)!
|
||||
}
|
||||
const localPath = await OssDownloadToTemp(this.requireConn(), remotePath)
|
||||
return OssDownloadToTemp(this.requireConn(), remotePath)
|
||||
}
|
||||
|
||||
if (this.previewOrder.length >= PREVIEW_CACHE_MAX) {
|
||||
const oldest = this.previewOrder.shift()!
|
||||
this.previewCache.delete(oldest)
|
||||
}
|
||||
|
||||
this.previewCache.set(remotePath, localPath)
|
||||
this.previewOrder.push(remotePath)
|
||||
return localPath
|
||||
/** 下载 HTML 及其引用的资源到临时目录用于网站预览 */
|
||||
async downloadSiteForPreview(remotePath: string): Promise<string> {
|
||||
return OssDownloadSiteForPreview(this.requireConn(), remotePath)
|
||||
}
|
||||
|
||||
/** 获取预签名 URL(用于直接预览) */
|
||||
|
||||
@@ -9,8 +9,8 @@ import type { ConnectionProfile } from './connection-manager'
|
||||
import {
|
||||
SftpConnect, SftpDisconnect, SftpListDir, SftpReadFile,
|
||||
SftpWriteFile, SftpGetFileInfo, SftpCreateDir, SftpCreateFile,
|
||||
SftpDeletePath, SftpRenamePath, SftpDownloadToTemp, SftpGetCommonPaths,
|
||||
SftpWriteBase64File,
|
||||
SftpDeletePath, SftpRenamePath, SftpDownloadToTemp, SftpDownloadSiteForPreview,
|
||||
SftpGetCommonPaths, SftpWriteBase64File,
|
||||
} from '@bindings/u-desk/app'
|
||||
|
||||
function transformFile(file: any): FileItem {
|
||||
@@ -21,13 +21,9 @@ function transformFileList(files: any[]): FileItem[] {
|
||||
return files.map(transformFile)
|
||||
}
|
||||
|
||||
const PREVIEW_CACHE_MAX = 50
|
||||
|
||||
export class SftpTransport implements FsTransport {
|
||||
private profile: ConnectionProfile
|
||||
private connID: string | null = null
|
||||
private previewCache = new Map<string, string>() // remotePath -> localTempPath (LRU)
|
||||
private previewOrder: string[] = [] // LRU 排序键列表
|
||||
|
||||
constructor(profile: ConnectionProfile) {
|
||||
this.profile = profile
|
||||
@@ -55,8 +51,6 @@ export class SftpTransport implements FsTransport {
|
||||
}
|
||||
this.connID = null
|
||||
}
|
||||
this.previewCache.clear()
|
||||
this.previewOrder = []
|
||||
}
|
||||
|
||||
private requireConn(): string {
|
||||
@@ -176,24 +170,13 @@ export class SftpTransport implements FsTransport {
|
||||
|
||||
// ====== 预览辅助 ======
|
||||
|
||||
/** 下载远程文件到本地临时目录用于预览(带 LRU 缓存,上限 50) */
|
||||
/** 下载远程文件到本地临时目录用于预览(缓存由 Go 后端管理) */
|
||||
async downloadForPreview(remotePath: string): Promise<string> {
|
||||
// 命中:移到队尾(最近使用)
|
||||
if (this.previewCache.has(remotePath)) {
|
||||
this.previewOrder = this.previewOrder.filter(p => p !== remotePath)
|
||||
this.previewOrder.push(remotePath)
|
||||
return this.previewCache.get(remotePath)!
|
||||
}
|
||||
const localPath = await SftpDownloadToTemp(this.requireConn(), remotePath)
|
||||
return SftpDownloadToTemp(this.requireConn(), remotePath)
|
||||
}
|
||||
|
||||
// 淘汰最旧条目
|
||||
if (this.previewOrder.length >= PREVIEW_CACHE_MAX) {
|
||||
const oldest = this.previewOrder.shift()!
|
||||
this.previewCache.delete(oldest)
|
||||
}
|
||||
|
||||
this.previewCache.set(remotePath, localPath)
|
||||
this.previewOrder.push(remotePath)
|
||||
return localPath
|
||||
/** 下载 HTML 及其网站资源到临时目录用于预览 */
|
||||
async downloadSiteForPreview(remotePath: string): Promise<string> {
|
||||
return SftpDownloadSiteForPreview(this.requireConn(), remotePath)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -68,4 +68,12 @@ export interface FsTransport {
|
||||
restoreFromRecycleBin(path: string): Promise<void>
|
||||
deletePermanently(path: string): Promise<void>
|
||||
emptyRecycleBin(): Promise<void>
|
||||
|
||||
// 预览辅助
|
||||
/** 下载远程文件到本地临时目录,本地/HTTP 直接返回原路径 */
|
||||
downloadForPreview(path: string): Promise<string>
|
||||
/** 下载 HTML 及其引用的资源到临时目录(OSS 实现) */
|
||||
downloadSiteForPreview?(path: string): Promise<string>
|
||||
/** 获取预签名 URL(仅 OSS 实现,其他返回空串) */
|
||||
getSignedUrl?(path: string): Promise<string>
|
||||
}
|
||||
|
||||
@@ -116,4 +116,8 @@ export class WailsTransport implements FsTransport {
|
||||
async emptyRecycleBin(): Promise<void> {
|
||||
await EmptyRecycleBin()
|
||||
}
|
||||
|
||||
async downloadForPreview(path: string): Promise<string> {
|
||||
return path
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
<!-- OSS 厂商(表单行,仅在选中云OSS时显示) -->
|
||||
<div v-if="category === 'oss'" style="display: flex; align-items: center; gap: 8px">
|
||||
<label style="font-size: 13px; width: 36px; flex-shrink: 0">厂商</label>
|
||||
<a-radio-group v-model="form.type" type="button" size="small">
|
||||
<a-radio-group v-model="form.provider" type="button" size="small">
|
||||
<a-radio value="qiniu">七牛云</a-radio>
|
||||
<a-radio value="aliyun">阿里云</a-radio>
|
||||
</a-radio-group>
|
||||
@@ -33,7 +33,7 @@
|
||||
</div>
|
||||
|
||||
<!-- OSS 认证字段 -->
|
||||
<template v-if="form.type === 'qiniu' || form.type === 'aliyun'">
|
||||
<template v-if="form.type === 'oss'">
|
||||
<div style="display: flex; align-items: center; gap: 8px">
|
||||
<label style="font-size: 13px; width: 36px; flex-shrink: 0">AK</label>
|
||||
<a-input v-model="form.accessKey" placeholder="AccessKey" style="flex: 1" />
|
||||
@@ -93,7 +93,7 @@ import { Message } from '@arco-design/web-vue'
|
||||
import { Dialogs } from '@wailsio/runtime'
|
||||
import { GetEnvVars } from '@bindings/u-desk/app'
|
||||
import { connectionManager } from '@/api/connection-manager'
|
||||
import type { ConnectionType } from '@/api/connection-manager'
|
||||
import type { ConnectionType, OssProvider } from '@/api/connection-manager'
|
||||
|
||||
const props = defineProps<{ visible: boolean }>()
|
||||
const emit = defineEmits<{ (e: 'update:visible', val: boolean): void; (e: 'close'): void }>()
|
||||
@@ -113,6 +113,7 @@ const form = reactive({
|
||||
port: 22,
|
||||
token: '',
|
||||
type: 'sftp' as ConnectionType,
|
||||
provider: 'qiniu' as OssProvider,
|
||||
username: 'root',
|
||||
password: '',
|
||||
keyPath: '',
|
||||
@@ -124,12 +125,9 @@ const form = reactive({
|
||||
})
|
||||
|
||||
const category = computed({
|
||||
get: () => {
|
||||
if (form.type === 'qiniu' || form.type === 'aliyun') return 'oss'
|
||||
return form.type
|
||||
},
|
||||
get: () => form.type,
|
||||
set: (v: string) => {
|
||||
if (v === 'oss') form.type = 'qiniu'
|
||||
if (v === 'oss') form.type = 'oss'
|
||||
else form.type = v as ConnectionType
|
||||
},
|
||||
})
|
||||
@@ -140,6 +138,7 @@ watch(() => props.visible, (val) => {
|
||||
Object.assign(form, {
|
||||
name: '', host: '', port: 22, token: '',
|
||||
type: 'sftp' as ConnectionType,
|
||||
provider: 'qiniu' as OssProvider,
|
||||
username: 'root', password: '', keyPath: '',
|
||||
accessKey: '', secretKey: '', bucket: '', region: '', endpoint: '',
|
||||
})
|
||||
@@ -153,7 +152,7 @@ watch(() => form.type, (t) => {
|
||||
|
||||
async function handleOk(): Promise<boolean> {
|
||||
if (!form.name?.trim()) { Message.warning('请输入名称'); return false }
|
||||
const isOss = form.type === 'qiniu' || form.type === 'aliyun'
|
||||
const isOss = form.type === 'oss'
|
||||
if (isOss) {
|
||||
if (!form.accessKey?.trim()) { Message.warning('请输入 AccessKey'); return false }
|
||||
if (!form.secretKey?.trim()) { Message.warning('请输入 SecretKey'); return false }
|
||||
@@ -200,6 +199,7 @@ function editProfile(id: string) {
|
||||
port: profile.port,
|
||||
token: profile.token || '',
|
||||
type: profile.type || 'remote',
|
||||
provider: (profile.provider as OssProvider) || 'qiniu',
|
||||
username: profile.username || 'root',
|
||||
password: profile.password || '',
|
||||
keyPath: profile.keyPath || '',
|
||||
|
||||
@@ -4,10 +4,46 @@
|
||||
<icon-cloud />
|
||||
</div>
|
||||
|
||||
<!-- 有远程配置:完整标签 + 下拉菜单 -->
|
||||
<div v-else class="connection-indicator" @click.stop="showMenu = !showMenu">
|
||||
<!-- 有远程配置:完整标签 + 悬停目录列表 + 点击连接菜单 -->
|
||||
<div v-else class="connection-indicator"
|
||||
@click.stop="toggleMenu"
|
||||
@mouseenter="onDirHover"
|
||||
@mouseleave="onDirLeave">
|
||||
<span class="label">{{ label }}</span>
|
||||
|
||||
<!-- 悬停弹出:根目录子目录列表 -->
|
||||
<Transition name="dropdown-fade">
|
||||
<div v-if="showDirDropdown"
|
||||
class="dir-dropdown"
|
||||
@mouseenter="onDirMenuEnter"
|
||||
@mouseleave="onDirMenuLeave"
|
||||
@click.stop>
|
||||
<div v-if="dirLoading" class="dropdown-loading">
|
||||
<a-spin :size="16" />
|
||||
<span>加载中...</span>
|
||||
</div>
|
||||
<div v-else-if="dirError" class="dropdown-error">
|
||||
<icon-exclamation-circle />
|
||||
<span>{{ dirError }}</span>
|
||||
</div>
|
||||
<div v-else-if="!dirChildren.length" class="dropdown-empty">
|
||||
<icon-folder />
|
||||
<span>空文件夹</span>
|
||||
</div>
|
||||
<template v-else>
|
||||
<DropdownItem
|
||||
v-for="child in dirChildren"
|
||||
:key="child.path"
|
||||
:item="child"
|
||||
:level="1"
|
||||
@navigate="onDirNavigate"
|
||||
@openFile="onDirOpenFile"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
</Transition>
|
||||
|
||||
<!-- 点击弹出:连接切换菜单 -->
|
||||
<div v-if="showMenu" class="menu" @click.stop>
|
||||
<div class="menu-header">远程连接</div>
|
||||
<div
|
||||
@@ -39,58 +75,176 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, shallowRef, onMounted, onUnmounted } from 'vue'
|
||||
import { IconCloud } from '@arco-design/web-vue/es/icon'
|
||||
import { ref, computed, shallowRef, onMounted, onUnmounted, provide, watch } from 'vue'
|
||||
import { IconCloud, IconFolder, IconExclamationCircle } from '@arco-design/web-vue/es/icon'
|
||||
import { Message } from '@arco-design/web-vue'
|
||||
import { connectionManager } from '@/api/connection-manager'
|
||||
import { listDir } from '@/api/system'
|
||||
import { sortFileList } from '@/utils/fileUtils'
|
||||
import { useTimeout } from '@/composables/useTimeout'
|
||||
import DropdownItem from './DropdownItem.vue'
|
||||
|
||||
const emit = defineEmits<{ (e: 'add'): void; (e: 'select', id: string): void; (e: 'edit', id: string): void }>()
|
||||
const props = defineProps<{
|
||||
filePath?: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'add'): void
|
||||
(e: 'select', id: string): void
|
||||
(e: 'edit', id: string): void
|
||||
(e: 'navigate', path: string): void
|
||||
(e: 'openFile', path: string): void
|
||||
}>()
|
||||
|
||||
const { setTimeout: delay, clearTimeout: clearDelay } = useTimeout()
|
||||
|
||||
// === 连接菜单(原有逻辑) ===
|
||||
const showMenu = ref(false)
|
||||
const moreOpenId = ref<string | null>(null)
|
||||
const profiles = shallowRef(connectionManager.profiles)
|
||||
const activeId = shallowRef(connectionManager.activeProfile?.id ?? '')
|
||||
|
||||
// 是否有远程/SFTP profile(决定显示模式)
|
||||
const hasRemote = computed(() => profiles.value.some(p => p.type !== 'local'))
|
||||
|
||||
// 防抖:避免 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) => {
|
||||
connectionManager.onStateChange(() => {
|
||||
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
|
||||
}
|
||||
})
|
||||
|
||||
// 点击外部关闭菜单
|
||||
// === 悬停目录列表(新增) ===
|
||||
const openMenus = ref<Map<number, string>>(new Map())
|
||||
|
||||
const closeMenuFn = (level: number) => {
|
||||
const newMap = new Map(openMenus.value)
|
||||
newMap.delete(level)
|
||||
openMenus.value = newMap
|
||||
}
|
||||
|
||||
const closeAllMenusFn = () => {
|
||||
openMenus.value = new Map()
|
||||
}
|
||||
|
||||
provide('openMenus', openMenus)
|
||||
provide('closeMenu', closeMenuFn)
|
||||
provide('closeAllMenus', closeAllMenusFn)
|
||||
|
||||
const showDirDropdown = ref(false)
|
||||
const dirLoading = ref(false)
|
||||
const dirError = ref('')
|
||||
const dirChildren = ref<Array<{ name: string; path: string; isDir: boolean }>>([])
|
||||
const dirLastLoadedPath = ref('')
|
||||
|
||||
const dirHoverTimer = ref<NodeJS.Timeout | null>(null)
|
||||
const dirCloseTimer = ref<NodeJS.Timeout | null>(null)
|
||||
|
||||
const rootPath = computed(() => {
|
||||
const path = props.filePath?.replace(/\\/g, '/') || ''
|
||||
if (/^[A-Za-z]:/.test(path)) return path.substring(0, 2) + '/'
|
||||
return '/'
|
||||
})
|
||||
|
||||
const loadRootChildren = async () => {
|
||||
const path = rootPath.value
|
||||
if (path === dirLastLoadedPath.value) return
|
||||
|
||||
dirLoading.value = true
|
||||
dirError.value = ''
|
||||
|
||||
try {
|
||||
const files = await listDir(path)
|
||||
dirLastLoadedPath.value = path
|
||||
dirChildren.value = sortFileList(files.map(f => ({
|
||||
name: f.name,
|
||||
path: f.path,
|
||||
isDir: f.isDir
|
||||
})))
|
||||
} catch (err) {
|
||||
console.error('[ConnectionIndicator] 加载根目录失败:', err)
|
||||
dirError.value = '加载失败'
|
||||
} finally {
|
||||
dirLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const onDirHover = () => {
|
||||
if (dirHoverTimer.value) clearDelay(dirHoverTimer.value)
|
||||
if (dirCloseTimer.value) clearDelay(dirCloseTimer.value)
|
||||
|
||||
dirHoverTimer.value = delay(() => {
|
||||
showDirDropdown.value = true
|
||||
showMenu.value = false
|
||||
closeAllMenusFn()
|
||||
loadRootChildren()
|
||||
}, 200)
|
||||
}
|
||||
|
||||
const onDirLeave = () => {
|
||||
if (dirHoverTimer.value) clearDelay(dirHoverTimer.value)
|
||||
|
||||
dirCloseTimer.value = delay(() => {
|
||||
showDirDropdown.value = false
|
||||
closeAllMenusFn()
|
||||
}, 100)
|
||||
}
|
||||
|
||||
const onDirMenuEnter = () => {
|
||||
if (dirHoverTimer.value) clearDelay(dirHoverTimer.value)
|
||||
if (dirCloseTimer.value) clearDelay(dirCloseTimer.value)
|
||||
}
|
||||
|
||||
const onDirMenuLeave = () => {
|
||||
if (dirHoverTimer.value) clearDelay(dirHoverTimer.value)
|
||||
|
||||
dirCloseTimer.value = delay(() => {
|
||||
showDirDropdown.value = false
|
||||
closeAllMenusFn()
|
||||
}, 100)
|
||||
}
|
||||
|
||||
const onDirNavigate = (path: string) => {
|
||||
showDirDropdown.value = false
|
||||
closeAllMenusFn()
|
||||
emit('navigate', path)
|
||||
}
|
||||
|
||||
const onDirOpenFile = (path: string) => {
|
||||
showDirDropdown.value = false
|
||||
closeAllMenusFn()
|
||||
emit('openFile', path)
|
||||
}
|
||||
|
||||
// === 点击切换菜单 ===
|
||||
const toggleMenu = () => {
|
||||
showMenu.value = !showMenu.value
|
||||
if (showMenu.value) {
|
||||
showDirDropdown.value = false
|
||||
closeAllMenusFn()
|
||||
}
|
||||
}
|
||||
|
||||
// === 点击外部关闭 ===
|
||||
function handleClickOutside(e: MouseEvent) {
|
||||
const el = e.target as HTMLElement
|
||||
if (!el.closest('.connection-indicator')) {
|
||||
showMenu.value = false
|
||||
moreOpenId.value = null
|
||||
showDirDropdown.value = false
|
||||
closeAllMenusFn()
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => document.addEventListener('click', handleClickOutside))
|
||||
onUnmounted(() => {
|
||||
document.removeEventListener('click', handleClickOutside)
|
||||
if (_stateTimer) clearTimeout(_stateTimer)
|
||||
})
|
||||
|
||||
// === 原有方法 ===
|
||||
async function handleSelect(p: { id: string }) {
|
||||
showMenu.value = false
|
||||
try {
|
||||
@@ -120,9 +274,17 @@ function handleDelete(p: { id: string; name: string }) {
|
||||
function dotClass(p: { type: string }): string {
|
||||
if (p.type === 'sftp') return 'sftp'
|
||||
if (p.type === 'remote') return 'remote'
|
||||
if (p.type === 'qiniu' || p.type === 'aliyun') return 'oss'
|
||||
if (p.type === 'oss') return 'oss'
|
||||
return 'local'
|
||||
}
|
||||
|
||||
// 路径变化时重置目录列表状态
|
||||
watch(() => props.filePath, () => {
|
||||
showDirDropdown.value = false
|
||||
dirChildren.value = []
|
||||
dirLastLoadedPath.value = ''
|
||||
openMenus.value = new Map()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@@ -280,6 +442,65 @@ function dotClass(p: { type: string }): string {
|
||||
}
|
||||
.add-btn:hover { background: var(--color-primary-light-1); }
|
||||
|
||||
/* 悬停目录列表下拉 */
|
||||
.dir-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);
|
||||
}
|
||||
|
||||
/* 滚动条 */
|
||||
.dir-dropdown::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
.dir-dropdown::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.dir-dropdown::-webkit-scrollbar-thumb {
|
||||
background: var(--color-fill-3);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.dir-dropdown::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--color-fill-4);
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.4; }
|
||||
|
||||
@@ -0,0 +1,600 @@
|
||||
<template>
|
||||
<div>
|
||||
<!-- 歌词面板 -->
|
||||
<div v-if="src && lyrics.length && lyricsOpen" class="bgm-lyrics" ref="lyricsWrapRef">
|
||||
<div
|
||||
v-for="(line, i) in lyrics" :key="i"
|
||||
class="bgm-lyric-line"
|
||||
:class="{ active: i === currentLyricIdx }"
|
||||
@click="seekToLyric(line.time)"
|
||||
>{{ line.text || '...' }}</div>
|
||||
</div>
|
||||
<div class="bgm-bar" ref="bgmBarEl">
|
||||
<span class="bgm-icon">📻</span>
|
||||
<template v-if="src">
|
||||
<button class="bgm-btn" @click="playPrev" title="上一首">⏮</button>
|
||||
<button class="bgm-btn" @click="togglePlay">{{ playing ? '⏸' : '▶' }}</button>
|
||||
<button class="bgm-btn" @click="playNext" title="下一首">⏭</button>
|
||||
<span class="bgm-time">{{ currentFmt }} / {{ durationFmt }}</span>
|
||||
<div class="bgm-progress" @click="seek">
|
||||
<div class="bgm-progress-filled" :style="{ width: progress + '%' }"></div>
|
||||
</div>
|
||||
<span class="bgm-title">{{ title }}</span>
|
||||
<button class="bgm-btn" @click="cyclePlayMode" :title="playModeLabel">{{ playModeIcon }}</button>
|
||||
<button v-if="lyrics.length" class="bgm-btn bgm-btn-text" @click="lyricsOpen = !lyricsOpen"
|
||||
:title="lyricsOpen ? '收起歌词' : '展开歌词'" :style="{ color: lyricsOpen ? 'rgb(var(--primary-6))' : undefined }">词</button>
|
||||
</template>
|
||||
<template v-else>
|
||||
<button class="bgm-btn" disabled>⏮</button>
|
||||
<button class="bgm-btn" disabled>▶</button>
|
||||
<button class="bgm-btn" disabled>⏭</button>
|
||||
<span class="bgm-time">0:00 / 0:00</span>
|
||||
<div class="bgm-progress">
|
||||
<div class="bgm-progress-filled" style="width:0%"></div>
|
||||
</div>
|
||||
<span class="bgm-title bgm-title-idle">暂无播放音乐</span>
|
||||
<button class="bgm-btn" disabled title="播放模式">🔁</button>
|
||||
</template>
|
||||
<button class="bgm-btn" @click="playlistOpen = !playlistOpen" title="播放列表">🎵</button>
|
||||
<button class="bgm-btn bgm-btn-close" @click="emit('stop')" title="关闭 BGM">✕</button>
|
||||
<div v-if="playlistOpen" class="bgm-playlist">
|
||||
<div class="bgm-playlist-header">播放列表</div>
|
||||
<div v-for="(track, idx) in playlist" :key="track.path"
|
||||
class="bgm-playlist-item"
|
||||
:class="{ active: track.path === currentPath || (!currentPath && track.path === lastPlayedPath) }"
|
||||
@click="playTrack(track)"
|
||||
>
|
||||
<span class="bgm-pl-name">{{ track.name }}</span>
|
||||
<span class="bgm-pl-remove" @click.stop="removeTrack(idx)">✕</span>
|
||||
</div>
|
||||
<div v-if="playlist.length === 0" class="bgm-playlist-empty">暂无记录</div>
|
||||
</div>
|
||||
</div>
|
||||
<audio
|
||||
ref="audioRef"
|
||||
:src="src"
|
||||
style="display:none"
|
||||
@ended="onEnded"
|
||||
@timeupdate="onTimeUpdate"
|
||||
@loadedmetadata="onTimeUpdate"
|
||||
@play="playing = true"
|
||||
@pause="playing = false"
|
||||
@error="onAudioError"
|
||||
></audio>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
export interface BgmTrack { name: string; path: string; url: string; profileId?: string }
|
||||
</script>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch, nextTick, onMounted, onUnmounted } from 'vue'
|
||||
import type { LrcLine } from '@/utils/lrcParser'
|
||||
import { BgmGetPlaylist, BgmSavePlaylist } from '@bindings/u-desk/app'
|
||||
import { connectionManager } from '@/api/connection-manager'
|
||||
|
||||
const props = defineProps<{
|
||||
src: string
|
||||
currentPath: string
|
||||
title: string
|
||||
lyrics: LrcLine[]
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'stop'): void
|
||||
(e: 'switch', track: BgmTrack): void
|
||||
}>()
|
||||
|
||||
const audioRef = ref<HTMLAudioElement | null>(null)
|
||||
const bgmBarEl = ref<HTMLElement | null>(null)
|
||||
const pendingTimer = ref<ReturnType<typeof setTimeout>>()
|
||||
const playing = ref(false)
|
||||
const currentTime = ref(0)
|
||||
const duration = ref(0)
|
||||
const playlistOpen = ref(false)
|
||||
const lyricsOpen = ref(false)
|
||||
const currentLyricIdx = ref(-1)
|
||||
const lyricsWrapRef = ref<HTMLElement | null>(null)
|
||||
const playingPath = ref('')
|
||||
|
||||
// 播放模式
|
||||
type PlayMode = 'single' | 'sequential' | 'random' | 'loop'
|
||||
const PLAY_MODE_ICONS: Record<PlayMode, string> = { single: '🔂', sequential: '⏩', random: '🔀', loop: '🔁' }
|
||||
const PLAY_MODE_LABELS: Record<PlayMode, string> = { single: '单曲播放', sequential: '顺序播放', random: '随机播放', loop: '循环播放' }
|
||||
const PLAY_MODE_ORDER: PlayMode[] = ['single', 'sequential', 'random', 'loop']
|
||||
const VALID_MODES = new Set<PlayMode>(PLAY_MODE_ORDER)
|
||||
const _initPlayMode = (): PlayMode => {
|
||||
const v = localStorage.getItem('desk:bgmPlayMode')
|
||||
return v && VALID_MODES.has(v as PlayMode) ? (v as PlayMode) : 'loop'
|
||||
}
|
||||
const playMode = ref<PlayMode>(_initPlayMode())
|
||||
const playModeIcon = computed(() => PLAY_MODE_ICONS[playMode.value])
|
||||
const playModeLabel = computed(() => PLAY_MODE_LABELS[playMode.value])
|
||||
|
||||
watch(playMode, (v) => localStorage.setItem('desk:bgmPlayMode', v)) // 播放模式保留 localStorage(轻量偏好)
|
||||
const cyclePlayMode = () => {
|
||||
const idx = PLAY_MODE_ORDER.indexOf(playMode.value)
|
||||
playMode.value = PLAY_MODE_ORDER[(idx + 1) % PLAY_MODE_ORDER.length]
|
||||
}
|
||||
|
||||
// 播放列表(持久化到 SQLite,url 运行时生成)
|
||||
const PLAYLIST_MAX = 20
|
||||
const NEXT_TRACK_DELAY = 1500
|
||||
const playlist = ref<BgmTrack[]>([])
|
||||
|
||||
type PlaylistEntry = Pick<BgmTrack, 'name' | 'path'>
|
||||
|
||||
const savePlaylist = async () => {
|
||||
const entries = playlist.value.map(t => ({ name: t.name, path: t.path, profile_id: t.profileId || '' }))
|
||||
try { await BgmSavePlaylist(entries) } catch (e) { console.debug('[BgmBar] 保存播放列表失败:', e) }
|
||||
}
|
||||
|
||||
// 从 SQLite 恢复播放列表(可选 resolver 解析 URL,无 resolver 则 url 留空延迟解析)
|
||||
const restorePlaylist = async (resolver?: (path: string) => Promise<string | undefined>) => {
|
||||
try {
|
||||
const entries = await BgmGetPlaylist()
|
||||
if (!entries?.length) return
|
||||
if (resolver) {
|
||||
const results = await Promise.all(entries.map(e => resolver(e.path)))
|
||||
playlist.value = entries
|
||||
.map((e, i) => ({ name: e.name, path: e.path, url: results[i] || '' }))
|
||||
.filter((_, i) => results[i])
|
||||
} else {
|
||||
playlist.value = entries.map(e => ({ name: e.name, path: e.path, url: '', profileId: (e as any).profile_id || '' }))
|
||||
}
|
||||
} catch (e) { console.debug('[BgmBar] 恢复播放列表失败:', e) }
|
||||
}
|
||||
|
||||
// 启动时自动恢复(url 延迟解析),并选中上次播放的曲目
|
||||
const lastPlayedPath = ref('')
|
||||
const _restoreLastPlayed = () => {
|
||||
const saved = localStorage.getItem('desk:bgmLastPlayed')
|
||||
if (!saved) return
|
||||
const track = playlist.value.find(t => t.path === saved)
|
||||
if (!track) return
|
||||
lastPlayedPath.value = saved
|
||||
playingPath.value = saved
|
||||
emit('switch', track)
|
||||
}
|
||||
restorePlaylist().then(_restoreLastPlayed)
|
||||
|
||||
const addToPlaylist = (name: string, path: string, url: string) => {
|
||||
const profileId = connectionManager.activeProfile?.id
|
||||
playlist.value = playlist.value.filter(t => t.path !== path)
|
||||
playlist.value.unshift({ name, path, url, profileId })
|
||||
if (playlist.value.length > PLAYLIST_MAX) playlist.value.length = PLAYLIST_MAX
|
||||
playingPath.value = path
|
||||
savePlaylist()
|
||||
}
|
||||
|
||||
const addBatch = (tracks: BgmTrack[]) => {
|
||||
const profileId = connectionManager.activeProfile?.id
|
||||
const existPaths = new Set(playlist.value.map(t => t.path))
|
||||
for (const t of tracks) {
|
||||
if (existPaths.has(t.path)) continue
|
||||
if (!t.profileId) t.profileId = profileId
|
||||
playlist.value.unshift(t)
|
||||
existPaths.add(t.path)
|
||||
}
|
||||
if (playlist.value.length > PLAYLIST_MAX) playlist.value.length = PLAYLIST_MAX
|
||||
savePlaylist()
|
||||
}
|
||||
|
||||
const removeTrack = (idx: number) => {
|
||||
playlist.value.splice(idx, 1)
|
||||
savePlaylist()
|
||||
}
|
||||
|
||||
// 时间格式化
|
||||
const formatTime = (s: number): string => {
|
||||
if (!s || !isFinite(s)) return '0:00'
|
||||
const m = Math.floor(s / 60)
|
||||
const sec = Math.floor(s % 60)
|
||||
return `${m}:${sec.toString().padStart(2, '0')}`
|
||||
}
|
||||
|
||||
const currentFmt = computed(() => formatTime(currentTime.value))
|
||||
const durationFmt = computed(() => formatTime(duration.value))
|
||||
const progress = computed(() => duration.value ? Math.min((currentTime.value / duration.value) * 100, 100) : 0)
|
||||
|
||||
// 歌词同步
|
||||
const syncLyrics = (t: number) => {
|
||||
if (!props.lyrics.length) { currentLyricIdx.value = -1; return }
|
||||
const lines = props.lyrics
|
||||
let idx = -1
|
||||
for (let i = lines.length - 1; i >= 0; i--) {
|
||||
if (t >= lines[i].time) { idx = i; break }
|
||||
}
|
||||
if (idx !== currentLyricIdx.value) {
|
||||
currentLyricIdx.value = idx
|
||||
// 自动滚动
|
||||
if (idx >= 0 && lyricsWrapRef.value) {
|
||||
const el = lyricsWrapRef.value.children[idx] as HTMLElement | undefined
|
||||
if (el) el.scrollIntoView({ behavior: 'smooth', block: 'center' })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const onTimeUpdate = () => {
|
||||
if (audioRef.value) {
|
||||
currentTime.value = audioRef.value.currentTime
|
||||
duration.value = audioRef.value.duration || 0
|
||||
syncLyrics(currentTime.value)
|
||||
}
|
||||
}
|
||||
|
||||
const seekToLyric = (time: number) => {
|
||||
if (audioRef.value) audioRef.value.currentTime = time
|
||||
}
|
||||
|
||||
const togglePlay = () => {
|
||||
if (!audioRef.value) return
|
||||
if (audioRef.value.paused) {
|
||||
audioRef.value.play().catch(() => {})
|
||||
} else {
|
||||
audioRef.value.pause()
|
||||
}
|
||||
}
|
||||
|
||||
const seek = (e: MouseEvent) => {
|
||||
if (!audioRef.value || !duration.value) return
|
||||
const bar = e.currentTarget as HTMLElement
|
||||
const rect = bar.getBoundingClientRect()
|
||||
const ratio = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width))
|
||||
audioRef.value.currentTime = ratio * duration.value
|
||||
}
|
||||
|
||||
const playTrack = (track: BgmTrack) => {
|
||||
playlistOpen.value = false
|
||||
lastPlayedPath.value = ''
|
||||
playingPath.value = track.path
|
||||
localStorage.setItem('desk:bgmLastPlayed', track.path)
|
||||
emit('switch', track)
|
||||
}
|
||||
|
||||
const curIdx = () => {
|
||||
if (playingPath.value) {
|
||||
const idx = playlist.value.findIndex(t => t.path === playingPath.value)
|
||||
if (idx >= 0) return idx
|
||||
}
|
||||
if (props.currentPath) {
|
||||
const idx = playlist.value.findIndex(t => t.path === props.currentPath)
|
||||
if (idx >= 0) return idx
|
||||
}
|
||||
return playlist.value.findIndex(t => t.url === props.src)
|
||||
}
|
||||
|
||||
const getNextIdx = (): number => {
|
||||
const idx = curIdx()
|
||||
if (idx === -1) return 0
|
||||
if (playMode.value === 'random') {
|
||||
if (playlist.value.length <= 1) return 0
|
||||
let next = idx
|
||||
while (next === idx) next = Math.floor(Math.random() * playlist.value.length)
|
||||
return next
|
||||
}
|
||||
// single/sequential/loop: 顺序前进,区别在 onEnded 处理
|
||||
return (idx + 1) % playlist.value.length
|
||||
}
|
||||
|
||||
const getPrevIdx = (): number => {
|
||||
const idx = curIdx()
|
||||
if (idx === -1) return 0
|
||||
if (playMode.value === 'random') {
|
||||
if (playlist.value.length <= 1) return 0
|
||||
let prev = idx
|
||||
while (prev === idx) prev = Math.floor(Math.random() * playlist.value.length)
|
||||
return prev
|
||||
}
|
||||
return (idx - 1 + playlist.value.length) % playlist.value.length
|
||||
}
|
||||
|
||||
const playPrev = () => {
|
||||
if (!playlist.value.length) return
|
||||
const idx = getPrevIdx()
|
||||
playTrack(playlist.value[idx])
|
||||
}
|
||||
|
||||
const playNext = () => {
|
||||
if (!playlist.value.length) return
|
||||
const idx = getNextIdx()
|
||||
playTrack(playlist.value[idx])
|
||||
}
|
||||
|
||||
const onEnded = () => {
|
||||
const mode = playMode.value
|
||||
if (mode === 'single') {
|
||||
emit('stop')
|
||||
return
|
||||
}
|
||||
// 只有一首歌时,loop 直接重播,其他模式停止
|
||||
if (playlist.value.length <= 1) {
|
||||
if (mode === 'loop') {
|
||||
nextTick(() => {
|
||||
if (audioRef.value) { audioRef.value.currentTime = 0; audioRef.value.play().catch(() => {}) }
|
||||
})
|
||||
} else {
|
||||
emit('stop')
|
||||
}
|
||||
return
|
||||
}
|
||||
const idx = curIdx()
|
||||
// sequential: 到末尾停止
|
||||
if (mode === 'sequential' && idx === playlist.value.length - 1) {
|
||||
emit('stop')
|
||||
return
|
||||
}
|
||||
const nextIdx = getNextIdx()
|
||||
clearTimeout(pendingTimer.value)
|
||||
pendingTimer.value = setTimeout(() => playTrack(playlist.value[nextIdx]), NEXT_TRACK_DELAY)
|
||||
}
|
||||
|
||||
// 播放失败自动跳到下一首(不删除,下次重试下载)
|
||||
const _failedUrls = new Set<string>()
|
||||
const onAudioError = () => {
|
||||
if (!props.src) return
|
||||
_failedUrls.add(props.src)
|
||||
console.warn('[BgmBar] 播放失败,跳过:', props.src)
|
||||
const len = playlist.value.length
|
||||
if (len <= 1) { emit('stop'); return }
|
||||
// 最多尝试 len 次,避免无限循环
|
||||
for (let attempt = 0; attempt < len; attempt++) {
|
||||
const nextIdx = getNextIdx()
|
||||
const next = playlist.value[nextIdx]
|
||||
if (next && !_failedUrls.has(next.url)) {
|
||||
clearTimeout(pendingTimer.value)
|
||||
pendingTimer.value = setTimeout(() => playTrack(next), NEXT_TRACK_DELAY)
|
||||
return
|
||||
}
|
||||
}
|
||||
emit('stop')
|
||||
}
|
||||
|
||||
watch(() => props.src, (url) => {
|
||||
console.debug('[BgmBar] src 变化:', url, 'title:', props.title)
|
||||
currentLyricIdx.value = -1
|
||||
_failedUrls.clear()
|
||||
if (!url) { playingPath.value = ''; return }
|
||||
if (playingPath.value) {
|
||||
const track = playlist.value.find(t => t.path === playingPath.value)
|
||||
if (track) track.url = url
|
||||
}
|
||||
})
|
||||
|
||||
function playFrom(currentTime?: number) {
|
||||
console.debug('[BgmBar] playFrom, src:', props.src, 'time:', currentTime)
|
||||
nextTick(() => {
|
||||
if (audioRef.value) {
|
||||
if (currentTime !== undefined) audioRef.value.currentTime = currentTime
|
||||
audioRef.value.play().catch(() => {})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function getAudioElement() {
|
||||
return audioRef.value
|
||||
}
|
||||
|
||||
onUnmounted(() => {
|
||||
clearTimeout(pendingTimer.value)
|
||||
if (audioRef.value) {
|
||||
audioRef.value.pause()
|
||||
audioRef.value.src = ''
|
||||
}
|
||||
document.removeEventListener('click', handleClickOutside)
|
||||
})
|
||||
|
||||
const handleClickOutside = (e: MouseEvent) => {
|
||||
if (playlistOpen.value && bgmBarEl.value && !bgmBarEl.value.contains(e.target as Node)) {
|
||||
playlistOpen.value = false
|
||||
}
|
||||
}
|
||||
onMounted(() => {
|
||||
document.addEventListener('click', handleClickOutside)
|
||||
// 防止浏览器恢复媒体会话导致自动播放
|
||||
if (audioRef.value) audioRef.value.pause()
|
||||
})
|
||||
|
||||
defineExpose({ playFrom, getAudioElement, addToPlaylist, addBatch, restorePlaylist })
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.bgm-lyrics {
|
||||
max-height: 180px;
|
||||
overflow-y: auto;
|
||||
padding: 10px 16px;
|
||||
text-align: center;
|
||||
background: var(--color-bg-3);
|
||||
border-top: 1px solid var(--color-border-2);
|
||||
mask-image: linear-gradient(to bottom, transparent 0%, black 12%, black 88%, transparent 100%);
|
||||
-webkit-mask-image: linear-gradient(to bottom, transparent 0%, black 12%, black 88%, transparent 100%);
|
||||
}
|
||||
|
||||
.bgm-lyric-line {
|
||||
padding: 5px 8px;
|
||||
font-size: 13px;
|
||||
color: var(--color-text-3);
|
||||
line-height: 1.8;
|
||||
cursor: pointer;
|
||||
border-radius: 4px;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.bgm-lyric-line:hover {
|
||||
background: var(--color-fill-2);
|
||||
}
|
||||
|
||||
.bgm-lyric-line.active {
|
||||
color: rgb(var(--primary-6));
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.bgm-bar {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 2px 10px;
|
||||
background: var(--color-bg-3);
|
||||
border-top: 1px solid var(--color-border-2);
|
||||
z-index: 10;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.bgm-icon {
|
||||
font-size: 14px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.bgm-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
padding: 2px 4px;
|
||||
color: var(--color-text-2);
|
||||
line-height: 1;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.bgm-btn:hover {
|
||||
color: var(--color-text-1);
|
||||
}
|
||||
|
||||
.bgm-btn-text {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
font-family: system-ui, sans-serif;
|
||||
}
|
||||
|
||||
.bgm-btn-close:hover {
|
||||
color: var(--color-danger-6);
|
||||
}
|
||||
|
||||
.bgm-time {
|
||||
font-size: 11px;
|
||||
color: var(--color-text-3);
|
||||
font-variant-numeric: tabular-nums;
|
||||
flex-shrink: 0;
|
||||
min-width: 72px;
|
||||
}
|
||||
|
||||
.bgm-progress {
|
||||
flex: 1;
|
||||
height: 4px;
|
||||
background: var(--color-fill-3);
|
||||
border-radius: 2px;
|
||||
cursor: pointer;
|
||||
min-width: 80px;
|
||||
transition: height 0.15s;
|
||||
}
|
||||
|
||||
.bgm-progress:hover {
|
||||
height: 6px;
|
||||
}
|
||||
|
||||
.bgm-progress-filled {
|
||||
height: 100%;
|
||||
background: rgb(var(--primary-6));
|
||||
border-radius: 2px;
|
||||
transition: width 0.1s linear;
|
||||
}
|
||||
|
||||
.bgm-title {
|
||||
font-size: 12px;
|
||||
color: var(--color-text-2);
|
||||
max-width: 140px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
flex-shrink: 1;
|
||||
}
|
||||
|
||||
.bgm-title-idle {
|
||||
font-style: italic;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.bgm-playlist {
|
||||
position: absolute;
|
||||
bottom: 100%;
|
||||
right: 0;
|
||||
width: 280px;
|
||||
max-height: 300px;
|
||||
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: 20;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.bgm-playlist-header {
|
||||
padding: 6px 10px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: var(--color-text-2);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
background: var(--color-bg-popup);
|
||||
}
|
||||
|
||||
.bgm-playlist-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 5px 10px;
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
color: var(--color-text-1);
|
||||
transition: background 0.12s;
|
||||
}
|
||||
|
||||
.bgm-playlist-item:hover {
|
||||
background: var(--color-fill-2);
|
||||
}
|
||||
|
||||
.bgm-playlist-item.active {
|
||||
color: rgb(var(--primary-6));
|
||||
background: var(--color-primary-light-1);
|
||||
}
|
||||
|
||||
.bgm-pl-name {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.bgm-pl-remove {
|
||||
flex-shrink: 0;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 10px;
|
||||
color: var(--color-text-4);
|
||||
border-radius: 50%;
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
|
||||
.bgm-playlist-item:hover .bgm-pl-remove {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.bgm-pl-remove:hover {
|
||||
color: var(--color-danger-6);
|
||||
background: var(--color-fill-3);
|
||||
}
|
||||
|
||||
.bgm-playlist-empty {
|
||||
padding: 16px;
|
||||
text-align: center;
|
||||
color: var(--color-text-4);
|
||||
font-size: 12px;
|
||||
}
|
||||
</style>
|
||||
@@ -83,10 +83,22 @@
|
||||
|
||||
<!-- 音频预览 -->
|
||||
<div v-else-if="config.isAudioView" class="media-preview">
|
||||
<audio :src="config.previewUrl" controls class="preview-audio" @error="handleMediaError('音频')" @canplay="mediaErrorMsg = ''"></audio>
|
||||
<audio
|
||||
ref="audioRef"
|
||||
:src="config.previewUrl"
|
||||
:loop="audioLoop"
|
||||
controls
|
||||
preload="none"
|
||||
class="preview-audio"
|
||||
@error="handleMediaError('音频')"
|
||||
@canplay="mediaErrorMsg = ''"
|
||||
></audio>
|
||||
<div v-if="mediaErrorMsg" class="media-error">{{ mediaErrorMsg }}</div>
|
||||
<div class="media-meta">
|
||||
<a-tag color="green">🎵 音频</a-tag>
|
||||
<a-button v-if="!audioBGM" size="mini" :type="audioLoop ? 'primary' : 'outline'" @click="audioLoop = !audioLoop">🔁 循环</a-button>
|
||||
<a-button size="mini" :type="audioBGM ? 'primary' : 'outline'" @click="toggleBGM">📻 BGM</a-button>
|
||||
<a-button v-if="audioBGM" size="mini" type="outline" @click="scanAndAddAll" :loading="scanLoading" title="扫描当前目录音频文件并添加到播放列表">🎵+ 全部</a-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -388,6 +400,18 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- BGM 背景播放条 -->
|
||||
<BgmBar
|
||||
v-show="!bgmHidden"
|
||||
ref="bgmBarRef"
|
||||
:src="bgmSrc"
|
||||
:current-path="config.currentFileFullPath"
|
||||
:title="bgmTitle"
|
||||
:lyrics="bgmLyrics"
|
||||
@stop="stopBGM"
|
||||
@switch="onBgmSwitch"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -395,15 +419,21 @@
|
||||
import { computed, watch, nextTick, defineAsyncComponent, ref, onMounted, onUnmounted } from 'vue'
|
||||
import { Message } from '@arco-design/web-vue'
|
||||
import { IconSave, IconEdit, IconEye, IconUndo, IconCopy, IconCheck, IconFilePdf, IconFullscreen, IconFullscreenExit } from '@arco-design/web-vue/es/icon'
|
||||
import { getFileName, escapeHtml } from '@/utils/fileUtils'
|
||||
import { getFileName, escapeHtml, getParentPath, getExt } from '@/utils/fileUtils'
|
||||
import { useClipboardCopy } from '../composables/useClipboardCopy'
|
||||
import type { FileEditorPanelConfig } from '@/types/file-system'
|
||||
import { renderMermaidDiagrams, rerenderMermaidDiagrams } from '@/utils/markedExtensions'
|
||||
import { useThemeStore } from '@/stores/theme'
|
||||
import { previewExcel, previewWord, previewCsv } from '@/utils/filePreviewHandlers'
|
||||
import { getFileCategory } from '@/utils/fileTypeHelpers'
|
||||
import { getFileCategory, isAudioFile } from '@/utils/fileTypeHelpers'
|
||||
import { isDirty } from '../composables/useMultiPreview'
|
||||
import { On, Off } from '@wailsio/events'
|
||||
import { connectionManager } from '@/api/connection-manager'
|
||||
import { listDir, readFile } from '@/api/system'
|
||||
import { resolveFileUrl, resolveFileUrlForProfile } from '../composables/useFilePreview'
|
||||
import { getFileServerBaseURL } from '@/api/file-server'
|
||||
import { parseLrc, type LrcLine } from '@/utils/lrcParser'
|
||||
import BgmBar, { type BgmTrack } from './FileEditor/BgmBar.vue'
|
||||
|
||||
// 异步加载 CodeEditor 组件,减少初始包大小
|
||||
const AsyncCodeEditor = defineAsyncComponent({
|
||||
@@ -485,28 +515,38 @@ const getFileTypeIcon = (filename: string): string => {
|
||||
return CATEGORY_ICONS[getFileCategory(filename)] || '📄'
|
||||
}
|
||||
|
||||
// HTML 预览 URL(实时从 connectionManager 读取,不缓存)
|
||||
function resolveHtmlPreviewBase(): string {
|
||||
if (!connectionManager.isRemote()) return 'http://localhost:2652'
|
||||
const base = connectionManager.getFileServerBaseURL()
|
||||
if (!base) return 'http://localhost:2652'
|
||||
// 远程模式需要完整代理路径前缀 /api/v1/proxy/localfs(htmlPreviewUrl 会替换为 html-preview)
|
||||
return base.replace(/\/$/, '') + '/api/v1/proxy/localfs'
|
||||
}
|
||||
// HTML 预览 URL(OSS 需要先下载到本地再预览)
|
||||
const htmlPreviewUrl = ref('')
|
||||
|
||||
const htmlPreviewUrl = computed(() => {
|
||||
if (!props.config.currentFileFullPath || !props.config.isHtmlFile) return ''
|
||||
const encodedPath = encodeURIComponent(props.config.currentFileFullPath)
|
||||
const isRemote = connectionManager.isRemote()
|
||||
const base = resolveHtmlPreviewBase()
|
||||
if (isRemote) {
|
||||
// 远程模式:走 /api/v1/proxy/html-preview 路由
|
||||
const baseUrl = base.replace(/\/proxy\/localfs\/?$/, '')
|
||||
return `${baseUrl}/proxy/html-preview?path=${encodedPath}`
|
||||
}
|
||||
// 本地模式:直连文件服务器
|
||||
return `${base}/localfs/html-preview?path=${encodedPath}`
|
||||
})
|
||||
watch(
|
||||
() => [props.config.currentFileFullPath, props.config.isHtmlFile],
|
||||
async ([path, isHtml]) => {
|
||||
htmlPreviewUrl.value = ''
|
||||
if (!path || !isHtml) return
|
||||
|
||||
// 下载到临时目录后是本地文件,统一用本地文件服务器
|
||||
const base = getFileServerBaseURL()
|
||||
const makeUrl = (localPath: string) => `${base}/localfs/html-preview?path=${encodeURIComponent(localPath)}`
|
||||
|
||||
if (connectionManager.isRemote()) {
|
||||
try {
|
||||
const transport = connectionManager.getTransport()
|
||||
if (transport?.downloadSiteForPreview) {
|
||||
const tempPath = await transport.downloadSiteForPreview(path)
|
||||
htmlPreviewUrl.value = makeUrl(tempPath)
|
||||
} else if (transport?.downloadForPreview) {
|
||||
const tempPath = await transport.downloadForPreview(path)
|
||||
htmlPreviewUrl.value = makeUrl(tempPath)
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('[HTML预览] 下载失败:', path, e)
|
||||
}
|
||||
} else {
|
||||
htmlPreviewUrl.value = makeUrl(path)
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
// 计算属性:判断文件是否在当前目录
|
||||
const isFileInCurrentDirectory = computed(() => {
|
||||
@@ -566,10 +606,172 @@ const handleImageError = () => {
|
||||
}
|
||||
|
||||
const mediaErrorMsg = ref('')
|
||||
watch(() => props.config.previewUrl, () => { mediaErrorMsg.value = '' })
|
||||
const handleMediaError = (type: string) => {
|
||||
mediaErrorMsg.value = `${type}文件加载失败,请检查网络连接或文件权限`
|
||||
}
|
||||
|
||||
// 音频控制
|
||||
const audioRef = ref<HTMLAudioElement | null>(null)
|
||||
|
||||
// 防止浏览器恢复媒体会话导致自动播放
|
||||
let suppressAudioAutoPlay = true
|
||||
watch(audioRef, (el) => {
|
||||
if (el && suppressAudioAutoPlay) {
|
||||
el.pause()
|
||||
suppressAudioAutoPlay = false
|
||||
}
|
||||
})
|
||||
const bgmBarRef = ref<InstanceType<typeof BgmBar> | null>(null)
|
||||
const audioLoop = ref(localStorage.getItem('desk:audioLoop') === 'true')
|
||||
const audioBGM = ref(false)
|
||||
const bgmHidden = ref(true)
|
||||
const bgmSrc = ref('')
|
||||
const bgmTitle = ref('')
|
||||
const bgmLyrics = ref<LrcLine[]>([])
|
||||
|
||||
// 构造 .lrc 文件路径
|
||||
const buildLrcPath = (audioPath: string): string => {
|
||||
const parent = getParentPath(audioPath)
|
||||
const name = getFileName(audioPath)
|
||||
const ext = getExt(name)
|
||||
const baseName = ext ? name.slice(0, -(ext.length + 1)) : name
|
||||
// getParentPath 始终返回 / 分隔的路径(内部已 normalizePathSeparators)
|
||||
// 所以统一用 / 拼接,兼容 OSS/SFTP/本地路径
|
||||
const sep = parent.endsWith('/') ? '' : '/'
|
||||
return parent + sep + baseName + '.lrc'
|
||||
}
|
||||
|
||||
// 加载歌词(防重复:同文件不重复请求)
|
||||
let _lastLrcAudioPath = ''
|
||||
const loadLyrics = async (audioPath: string) => {
|
||||
bgmLyrics.value = []
|
||||
if (!audioPath) return
|
||||
if (_lastLrcAudioPath === audioPath) return
|
||||
_lastLrcAudioPath = audioPath
|
||||
try {
|
||||
const lrcPath = buildLrcPath(audioPath)
|
||||
console.debug('[BGM] 加载歌词:', lrcPath, '<- audioPath:', audioPath)
|
||||
const content = await readFile(lrcPath)
|
||||
const data = parseLrc(content)
|
||||
console.debug('[BGM] 歌词解析完成:', data.lines.length, '行')
|
||||
bgmLyrics.value = data.lines
|
||||
} catch (e) {
|
||||
console.debug('[BGM] 无歌词文件:', audioPath, e)
|
||||
}
|
||||
}
|
||||
|
||||
watch(audioLoop, (v) => localStorage.setItem('desk:audioLoop', String(v)))
|
||||
|
||||
// BGM 模式:将当前音频转为后台播放
|
||||
const toggleBGM = () => {
|
||||
if (!audioBGM.value) {
|
||||
bgmSrc.value = props.config.previewUrl
|
||||
bgmTitle.value = props.config.currentFileName
|
||||
audioBGM.value = true
|
||||
bgmHidden.value = false
|
||||
if (props.config.currentFileFullPath) loadLyrics(props.config.currentFileFullPath)
|
||||
// 暂停内联播放器,切换到 BgmBar
|
||||
const t = audioRef.value?.currentTime || 0
|
||||
if (audioRef.value) audioRef.value.pause()
|
||||
nextTick(() => bgmBarRef.value?.playFrom(t))
|
||||
} else {
|
||||
stopBGM()
|
||||
}
|
||||
}
|
||||
|
||||
const stopBGM = () => {
|
||||
bgmHidden.value = true
|
||||
}
|
||||
|
||||
// 外部调用:直接将音频加入 BGM 播放列表并播放(不打开 tab)
|
||||
const playAudioAsBGM = (name: string, path: string, url: string) => {
|
||||
bgmSrc.value = url
|
||||
bgmTitle.value = name
|
||||
audioBGM.value = true
|
||||
bgmHidden.value = false
|
||||
nextTick(() => {
|
||||
bgmBarRef.value?.addToPlaylist(name, path, url)
|
||||
bgmBarRef.value?.playFrom(0)
|
||||
})
|
||||
if (path) loadLyrics(path)
|
||||
}
|
||||
|
||||
const onBgmSwitch = async (track: BgmTrack) => {
|
||||
bgmTitle.value = track.name
|
||||
audioBGM.value = true
|
||||
bgmHidden.value = false
|
||||
try {
|
||||
if (track.url) {
|
||||
bgmSrc.value = track.url
|
||||
} else if (track.path) {
|
||||
bgmSrc.value = await resolveFileUrlForProfile(track.path, track.profileId, true)
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('[BGM] 切歌 URL 解析失败:', e)
|
||||
bgmSrc.value = ''
|
||||
return
|
||||
}
|
||||
if (track.path) {
|
||||
_lastLrcAudioPath = ''
|
||||
loadLyrics(track.path)
|
||||
}
|
||||
nextTick(() => bgmBarRef.value?.playFrom(0))
|
||||
}
|
||||
|
||||
const scanLoading = ref(false)
|
||||
const scanAndAddAll = async () => {
|
||||
if (connectionManager.isRemote()) {
|
||||
Message.warning('远程模式下暂不支持批量扫描')
|
||||
return
|
||||
}
|
||||
const dir = props.currentDirectory || getParentPath(props.config.currentFileFullPath)
|
||||
if (!dir) return
|
||||
console.debug('[BGM] 扫描目录:', dir)
|
||||
scanLoading.value = true
|
||||
try {
|
||||
const files = await listDir(dir)
|
||||
const audioFiles = files.filter(f => !f.isDir && isAudioFile(f.name))
|
||||
if (!audioFiles.length) {
|
||||
Message.info('当前目录未发现音频文件')
|
||||
return
|
||||
}
|
||||
const results = await Promise.allSettled(audioFiles.map(f => resolveFileUrl(f.path, true)))
|
||||
const tracks: BgmTrack[] = []
|
||||
for (let i = 0; i < audioFiles.length; i++) {
|
||||
const r = results[i]
|
||||
if (r.status === 'fulfilled' && r.value) {
|
||||
tracks.push({ name: audioFiles[i].name, path: audioFiles[i].path, url: r.value })
|
||||
}
|
||||
}
|
||||
if (!tracks.length) {
|
||||
Message.warning('音频文件 URL 解析均失败')
|
||||
return
|
||||
}
|
||||
bgmBarRef.value?.addBatch(tracks)
|
||||
Message.success(`已添加 ${tracks.length} 首音频到播放列表`)
|
||||
} catch (e) {
|
||||
Message.error(`扫描失败: ${e?.message || e}`)
|
||||
} finally {
|
||||
scanLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 切换文件时自动替换 BGM 内容(仅在 BGM 已激活时)
|
||||
watch(() => props.config.previewUrl, (url) => {
|
||||
mediaErrorMsg.value = ''
|
||||
if (!props.config.isAudioView || !url || !audioBGM.value) return
|
||||
if (url !== bgmSrc.value) {
|
||||
const audioEl = bgmBarRef.value?.getAudioElement()
|
||||
const wasPlaying = audioEl && !audioEl.paused
|
||||
bgmSrc.value = url
|
||||
bgmTitle.value = props.config.currentFileName
|
||||
bgmBarRef.value?.addToPlaylist(props.config.currentFileName, props.config.currentFileFullPath, url)
|
||||
if (props.config.currentFileFullPath) loadLyrics(props.config.currentFileFullPath)
|
||||
if (wasPlaying) {
|
||||
nextTick(() => bgmBarRef.value?.playFrom())
|
||||
}
|
||||
}
|
||||
})
|
||||
const handlePdfLoad = (event: Event) => {
|
||||
const iframe = event.target as HTMLIFrameElement
|
||||
try {
|
||||
@@ -879,7 +1081,7 @@ const handleHtmlIframeMessage = (event: MessageEvent) => {
|
||||
const allowedOrigins = [
|
||||
window.location.origin,
|
||||
'null',
|
||||
resolveHtmlPreviewBase(), // 动态:本地 localhost:2652 或远程代理地址
|
||||
getFileServerBaseURL(),
|
||||
]
|
||||
if (!allowedOrigins.includes(event.origin)) {
|
||||
return
|
||||
@@ -896,6 +1098,9 @@ onMounted(() => {
|
||||
window.addEventListener('message', handleHtmlIframeMessage)
|
||||
document.addEventListener('fullscreenchange', onFullscreenChange)
|
||||
document.addEventListener('keydown', onKeyDown)
|
||||
On('toggle-bgm-bar', () => {
|
||||
bgmHidden.value = !bgmHidden.value
|
||||
})
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
@@ -905,8 +1110,11 @@ onUnmounted(() => {
|
||||
window.removeEventListener('message', handleHtmlIframeMessage)
|
||||
document.removeEventListener('fullscreenchange', onFullscreenChange)
|
||||
document.removeEventListener('keydown', onKeyDown)
|
||||
Off('toggle-bgm-bar')
|
||||
copyCleanup()
|
||||
})
|
||||
|
||||
defineExpose({ toggleBGM, toggleBgmVisibility: () => { bgmHidden.value = !bgmHidden.value }, playAudioAsBGM })
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
<template>
|
||||
<transition name="slide">
|
||||
<div v-show="config.visible" class="sidebar">
|
||||
<template v-for="section in sectionOrder" :key="section">
|
||||
<!-- 服务器区块 -->
|
||||
<div class="sidebar-section" :class="{ 'section-on-top': settingsOpen || moreOpenId }">
|
||||
<div v-if="section === 'server' && showServer" class="sidebar-section" :class="{ 'section-on-top': settingsOpen || moreOpenId }">
|
||||
<div class="section-header" @click="serverCollapsed = !serverCollapsed">
|
||||
<span class="section-title">🖥️ 服务器</span>
|
||||
<icon-down v-if="!serverCollapsed" class="section-toggle" />
|
||||
<icon-right v-else class="section-toggle" />
|
||||
</div>
|
||||
<div class="section-content server-content" :class="{ collapsed: serverCollapsed }">
|
||||
<div class="section-content-wrap" :class="{ collapsed: serverCollapsed }">
|
||||
<div class="section-content server-content">
|
||||
<!-- 表头 -->
|
||||
<div class="server-table-head">
|
||||
<span class="col-name">名称</span>
|
||||
@@ -19,16 +21,16 @@
|
||||
</div>
|
||||
<!-- 表格行 -->
|
||||
<div
|
||||
v-for="p in profiles"
|
||||
v-for="p in visibleProfiles"
|
||||
:key="p.id"
|
||||
class="server-table-row"
|
||||
:class="{ active: p.id === activeId }"
|
||||
@click="handleSelect(p)"
|
||||
>
|
||||
<span class="col-name" :title="stateText(p.id)"><span :class="['dot', stateDotClass(p.id)]" />{{ p.name }}</span>
|
||||
<span class="col-metric" :title="metricTooltip(p.id, 'disk')">{{ sysInfo(p.id)?.diskUsage || '-' }}</span>
|
||||
<span class="col-metric" :title="metricTooltip(p.id, 'cpu')">{{ sysInfo(p.id)?.cpuUsage || '-' }}</span>
|
||||
<span class="col-metric" :title="metricTooltip(p.id, 'mem')">{{ sysInfo(p.id)?.memUsage || '-' }}</span>
|
||||
<span class="col-metric" :class="metricWarnClass(p.id, 'disk')" :title="metricTooltip(p.id, 'disk')">{{ sysInfo(p.id)?.diskUsage || '-' }}</span>
|
||||
<span class="col-metric" :class="metricWarnClass(p.id, 'cpu')" :title="metricTooltip(p.id, 'cpu')">{{ sysInfo(p.id)?.cpuUsage || '-' }}</span>
|
||||
<span class="col-metric" :class="metricWarnClass(p.id, 'mem')" :title="metricTooltip(p.id, 'mem')">{{ sysInfo(p.id)?.memUsage || '-' }}</span>
|
||||
<span
|
||||
v-if="p.type !== 'local'"
|
||||
class="col-action more-btn"
|
||||
@@ -44,8 +46,7 @@
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- 管理设置面板(放在 section-content/overflow 容器外部) -->
|
||||
</div>
|
||||
<div v-if="settingsOpen" class="settings-panel" @click.stop>
|
||||
<div class="settings-item" @click="$emit('openConnectionDialog'); settingsOpen = false">
|
||||
<icon-plus /> 添加服务器
|
||||
@@ -58,18 +59,30 @@
|
||||
<icon-check-circle :style="{ opacity: autoRefresh ? 1 : 0.3 }" />
|
||||
自动刷新系统信息 (15s)
|
||||
</div>
|
||||
<div class="settings-divider" />
|
||||
<div class="settings-label">显示服务器</div>
|
||||
<div
|
||||
v-for="p in profiles"
|
||||
:key="'vis-' + p.id"
|
||||
class="settings-item"
|
||||
@click="toggleServerVisibility(p.id)"
|
||||
>
|
||||
<icon-check-circle :style="{ opacity: !hiddenServerIds.includes(String(p.id)) ? 1 : 0.3 }" />
|
||||
{{ p.name }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 收藏夹区块 -->
|
||||
<div class="sidebar-section">
|
||||
<div v-if="section === 'favorites' && showFavorites" class="sidebar-section fav-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 fav-content" :class="{ collapsed: favCollapsed }">
|
||||
<div class="section-content-wrap" :class="{ collapsed: favCollapsed }">
|
||||
<div class="section-content fav-content">
|
||||
<div
|
||||
v-for="(fav, index) in config.favoriteFiles"
|
||||
:key="fav.path"
|
||||
@@ -124,22 +137,26 @@
|
||||
<span class="sidebar-hint">点击文件列表中的星标收藏</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 帮助文档区块 -->
|
||||
<div class="sidebar-section">
|
||||
<div v-if="section === 'help' && showHelp" 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="section-content-wrap" :class="{ collapsed: helpCollapsed }">
|
||||
<div class="section-content help-content">
|
||||
<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>
|
||||
</template>
|
||||
</div>
|
||||
</transition>
|
||||
</template>
|
||||
@@ -149,10 +166,13 @@ import { ref, computed, shallowRef, onMounted, onUnmounted } from 'vue'
|
||||
import type { SidebarConfig, FavoriteFile } from '@/types/file-system'
|
||||
import type { SystemInfo } from '@/api/connection-manager'
|
||||
import { connectionManager } from '@/api/connection-manager'
|
||||
import { useConfigStore } from '@/stores/config'
|
||||
import { IconStar, IconClose, IconPushpin, IconDown, IconRight, IconStorage, IconComputer, IconDesktop, IconPlus, IconCheckCircle } from '@arco-design/web-vue/es/icon'
|
||||
import { getFileIcon } from '@/utils/fileUtils'
|
||||
import { Message } from '@arco-design/web-vue'
|
||||
|
||||
const configStore = useConfigStore()
|
||||
|
||||
// Props
|
||||
interface Props {
|
||||
config: SidebarConfig
|
||||
@@ -165,10 +185,18 @@ const serverCollapsed = ref(false)
|
||||
const favCollapsed = ref(false)
|
||||
const helpCollapsed = ref(false)
|
||||
|
||||
// 侧边栏区块可见性(从配置读取)
|
||||
const showServer = computed(() => configStore.appConfig.sidebarSections?.includes('server') ?? true)
|
||||
const showFavorites = computed(() => configStore.appConfig.sidebarSections?.includes('favorites') ?? true)
|
||||
const showHelp = computed(() => configStore.appConfig.sidebarSections?.includes('help') ?? true)
|
||||
// 区块排序
|
||||
const sectionOrder = computed(() => configStore.appConfig.sidebarSections || ['server', 'favorites', 'help'])
|
||||
|
||||
// 管理设置
|
||||
const settingsOpen = ref(false)
|
||||
const autoConnect = ref(localStorage.getItem('desk:autoConnect') !== 'false')
|
||||
const autoRefresh = ref(localStorage.getItem('desk:autoRefresh') === 'true')
|
||||
const hiddenServerIds = ref<string[]>(JSON.parse(localStorage.getItem('desk:hiddenServers') || '[]'))
|
||||
let refreshTimer: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
function toggleAutoConnect() {
|
||||
@@ -189,7 +217,7 @@ function toggleAutoRefresh() {
|
||||
function startAutoRefresh() {
|
||||
stopAutoRefresh()
|
||||
refreshTimer = setInterval(() => {
|
||||
profiles.value.forEach(p => { connectionManager.fetchSystemInfo(p.id).catch(() => {}) })
|
||||
visibleProfiles.value.forEach(p => { connectionManager.fetchSystemInfo(p.id).catch(() => {}) })
|
||||
}, 15000)
|
||||
}
|
||||
|
||||
@@ -221,6 +249,21 @@ const activeId = shallowRef(connectionManager.activeProfile?.id ?? '')
|
||||
const moreOpenId = ref<string | null>(null)
|
||||
const sysInfoMap = ref<Record<string, SystemInfo>>({})
|
||||
|
||||
const visibleProfiles = computed(() =>
|
||||
profiles.value.filter(p => !hiddenServerIds.value.includes(String(p.id)))
|
||||
)
|
||||
|
||||
function toggleServerVisibility(id: string) {
|
||||
const sid = String(id)
|
||||
const idx = hiddenServerIds.value.indexOf(sid)
|
||||
if (idx >= 0) {
|
||||
hiddenServerIds.value.splice(idx, 1)
|
||||
} else {
|
||||
hiddenServerIds.value.push(sid)
|
||||
}
|
||||
localStorage.setItem('desk:hiddenServers', JSON.stringify(hiddenServerIds.value))
|
||||
}
|
||||
|
||||
// 监听连接变化 + 系统信息变化
|
||||
connectionManager.onStateChange(() => {
|
||||
profiles.value = connectionManager.profiles
|
||||
@@ -345,6 +388,23 @@ function metricTooltip(profileId: string, type: 'disk' | 'cpu' | 'mem'): string
|
||||
return '-'
|
||||
}
|
||||
|
||||
function metricWarnClass(profileId: string, type: 'disk' | 'cpu' | 'mem'): string {
|
||||
const info = sysInfoMap.value[profileId]
|
||||
if (!info) return ''
|
||||
if (type === 'disk') {
|
||||
const pct = parseFloat(info.diskUsage || '')
|
||||
const freeGB = info.diskTotal != null && info.diskUsed != null ? (info.diskTotal - info.diskUsed) / 1073741824 : Infinity
|
||||
if (!isNaN(pct) && pct >= 90 || freeGB < 10) return 'metric-warn'
|
||||
} else if (type === 'cpu') {
|
||||
const pct = parseFloat(info.cpuUsage || '')
|
||||
if (!isNaN(pct) && pct >= 90) return 'metric-warn'
|
||||
} else {
|
||||
const pct = parseFloat(info.memUsage || '')
|
||||
if (!isNaN(pct) && pct >= 90) return 'metric-warn'
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
function formatBytes(n: number): string {
|
||||
if (n < 1024) return `${n} B`
|
||||
if (n < 1048576) return `${(n / 1024).toFixed(0)} KB`
|
||||
@@ -411,6 +471,36 @@ function handleDelete(p: { id: string; name: string }) {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* 服务器区块不收缩 */
|
||||
.sidebar-section:first-child {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* 收藏夹区块弹性填充剩余空间 */
|
||||
.fav-section {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* 收藏夹 section-content-wrap 由 flex 控制高度 */
|
||||
.fav-section > .section-content-wrap {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.fav-section > .section-content-wrap.collapsed {
|
||||
flex: 0;
|
||||
}
|
||||
|
||||
.fav-section > .section-content-wrap > .section-content {
|
||||
overflow-y: auto;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.sidebar-section.section-on-top {
|
||||
z-index: 30;
|
||||
}
|
||||
@@ -454,19 +544,19 @@ function handleDelete(p: { id: string; name: string }) {
|
||||
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;
|
||||
/* 区块折叠容器 - grid 动画精确匹配内容高度 */
|
||||
.section-content-wrap {
|
||||
display: grid;
|
||||
grid-template-rows: 1fr;
|
||||
transition: grid-template-rows 0.2s ease;
|
||||
}
|
||||
|
||||
.section-content.collapsed {
|
||||
max-height: 0;
|
||||
opacity: 0;
|
||||
padding-top: 0;
|
||||
padding-bottom: 0;
|
||||
.section-content-wrap.collapsed {
|
||||
grid-template-rows: 0fr;
|
||||
}
|
||||
|
||||
.section-content-wrap > .section-content {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* 收藏夹内容 - 内部独立滚动 */
|
||||
@@ -581,6 +671,10 @@ function handleDelete(p: { id: string; name: string }) {
|
||||
color: var(--color-text-2);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.col-metric.metric-warn {
|
||||
color: #e53e3e;
|
||||
font-weight: 600;
|
||||
}
|
||||
.col-action {
|
||||
width: 20px;
|
||||
flex-shrink: 0;
|
||||
@@ -659,6 +753,17 @@ function handleDelete(p: { id: string; name: string }) {
|
||||
color: var(--color-text-2);
|
||||
}
|
||||
.settings-item:hover { background: var(--color-fill-1); }
|
||||
.settings-divider {
|
||||
height: 1px;
|
||||
background: var(--color-border-2);
|
||||
margin: 4px 12px;
|
||||
}
|
||||
.settings-label {
|
||||
padding: 4px 12px 2px;
|
||||
font-size: 11px;
|
||||
color: var(--color-text-3);
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
/* 区块操作图标 */
|
||||
.section-action {
|
||||
@@ -675,7 +780,7 @@ function handleDelete(p: { id: string; name: string }) {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 12px;
|
||||
padding: 6px;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
|
||||
@@ -28,7 +28,7 @@
|
||||
<!-- 正常模式:连接指示器 + 面包屑导航(融合布局) -->
|
||||
<div v-else class="path-breadcrumb-wrapper">
|
||||
<!-- 连接指示器(紧凑标签样式,作为面包屑首段) -->
|
||||
<ConnectionIndicator @add="showConnectionDialog = true" @select="onConnectionChanged" @edit="onEditProfile" />
|
||||
<ConnectionIndicator :file-path="config.filePath" @add="showConnectionDialog = true" @select="onConnectionChanged" @edit="onEditProfile" @navigate="handleGoToPath" @openFile="handleOpenFile" />
|
||||
<span class="breadcrumb-sep">›</span>
|
||||
<!-- 路径面包屑 -->
|
||||
<PathBreadcrumb
|
||||
|
||||
@@ -7,6 +7,7 @@ import { ref } from 'vue'
|
||||
import { STORAGE_KEYS, DEFAULTS } from '@/utils/constants'
|
||||
import { getPathSeparator } from '@/utils/fileUtils'
|
||||
import { Message } from '@arco-design/web-vue'
|
||||
import { connectionManager } from '@/api/connection-manager'
|
||||
import type { FavoriteFile, FileItem, DraggingState } from '@/types/file-system'
|
||||
|
||||
export function useFavorites() {
|
||||
@@ -37,15 +38,33 @@ export function useFavorites() {
|
||||
const stored = localStorage.getItem(STORAGE_KEYS.FAVORITE_FILES)
|
||||
if (stored) {
|
||||
const loaded = JSON.parse(stored) as FavoriteFile[]
|
||||
let migrated = false
|
||||
|
||||
// 数据迁移:将旧字段 is_dir 转换为 isDir
|
||||
favorites.value = loaded.map(fav => ({
|
||||
...fav,
|
||||
isDir: fav.isDir ?? (fav as any).is_dir ?? false
|
||||
}))
|
||||
// 数据迁移:将旧字段 is_dir 转换为 isDir,补充缺失的 profileId
|
||||
favorites.value = loaded.map(fav => {
|
||||
const fixed = {
|
||||
...fav,
|
||||
isDir: fav.isDir ?? (fav as any).is_dir ?? false
|
||||
}
|
||||
// 旧收藏无 profileId,根据路径格式推断
|
||||
if (!fixed.profileId) {
|
||||
const isLocalPath = /^[A-Za-z]:/.test(fixed.path) || fixed.path.startsWith('\\\\')
|
||||
if (!isLocalPath) {
|
||||
// 远程路径 → 找到第一个远程/OSS profile
|
||||
const remoteProfile = connectionManager.profiles.find(p =>
|
||||
p.type === 'remote' || p.type === 'sftp' || p.type === 'oss'
|
||||
)
|
||||
if (remoteProfile) {
|
||||
fixed.profileId = remoteProfile.id
|
||||
migrated = true
|
||||
}
|
||||
}
|
||||
}
|
||||
return fixed
|
||||
})
|
||||
|
||||
// 仅排序(置顶项归组到前面),保持用户拖拽顺序
|
||||
sortFavorites()
|
||||
if (migrated) saveFavorites()
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载收藏列表失败:', error)
|
||||
@@ -83,11 +102,19 @@ export function useFavorites() {
|
||||
return false
|
||||
}
|
||||
|
||||
favorites.value.push({
|
||||
const newFav: FavoriteFile = {
|
||||
...file,
|
||||
addedAt: Date.now()
|
||||
} as FavoriteFile)
|
||||
sortFavorites()
|
||||
addedAt: Date.now(),
|
||||
profileId: connectionManager.activeProfile?.id
|
||||
}
|
||||
|
||||
// 插入到第一个非置顶项位置(置顶项之后、非置顶项之前)
|
||||
const insertIdx = favorites.value.findIndex(f => !f.pinnedAt)
|
||||
if (insertIdx === -1) {
|
||||
favorites.value.push(newFav)
|
||||
} else {
|
||||
favorites.value.splice(insertIdx, 0, newFav)
|
||||
}
|
||||
saveFavorites()
|
||||
return true
|
||||
}
|
||||
@@ -165,7 +192,7 @@ export function useFavorites() {
|
||||
}
|
||||
|
||||
// 拖拽方法
|
||||
let longPressTimer = ref<ReturnType<typeof setTimeout> | null>(null)
|
||||
let longPressTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
const onLongPressStart = (event: MouseEvent | TouchEvent, index: number) => {
|
||||
if (event instanceof MouseEvent && event.button !== 0) return
|
||||
|
||||
@@ -127,9 +127,9 @@ export function useFileEdit(options: UseFileEditOptions = {}) {
|
||||
* 计算属性:文件内容是否改变
|
||||
*/
|
||||
const contentChanged = computed(() => {
|
||||
return fileContent.value !== '' &&
|
||||
originalContent.value !== undefined &&
|
||||
originalContent.value !== fileContent.value
|
||||
if (fileContent.value === '' || originalContent.value === undefined) return false
|
||||
// 统一 CRLF → LF 再比较,避免编辑器内部 \n 与文件原始 \r\n 产生误判
|
||||
return originalContent.value.replace(/\r\n/g, '\n') !== fileContent.value.replace(/\r\n/g, '\n')
|
||||
})
|
||||
|
||||
/**
|
||||
@@ -428,9 +428,13 @@ ${ext ? `文件类型: ${fileTypeDesc}\n` : ''}
|
||||
return
|
||||
}
|
||||
|
||||
// 恢复草稿内容
|
||||
fileContent.value = draft.content
|
||||
Message.info('已恢复未保存的草稿')
|
||||
// 恢复草稿内容(仅当内容不同时才覆盖,避免无意义的脏标记)
|
||||
if (draft.content !== fileContent.value) {
|
||||
fileContent.value = draft.content
|
||||
Message.info('已恢复未保存的草稿')
|
||||
} else {
|
||||
clearDraft()
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载草稿失败:', error)
|
||||
@@ -484,6 +488,7 @@ ${ext ? `文件类型: ${fileTypeDesc}\n` : ''}
|
||||
const resetContent = () => {
|
||||
if (originalContent.value !== undefined) {
|
||||
fileContent.value = originalContent.value
|
||||
clearDraft()
|
||||
Message.info('已恢复原始内容')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,15 +8,13 @@ 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 { SftpTransport } from '@/api/sftp-transport'
|
||||
import { OssTransport } from '@/api/oss-transport'
|
||||
import { getFileServerBaseURL } from '@/api/file-server'
|
||||
import {
|
||||
isImageFile, isVideoFile, isAudioFile, isPdfFile,
|
||||
isHtmlFile, isMarkdownFile, isPreviewable as isPreviewableType,
|
||||
isHtmlFile, isMarkdownFile,
|
||||
isTextEditable, isConfigFile
|
||||
} from '@/utils/fileTypeHelpers'
|
||||
import type { FilePreviewMetadata, FileType } from '@/types/file-system'
|
||||
import type { FileType } from '@/types/file-system'
|
||||
|
||||
// 内容检测大小限制(与后端一致)
|
||||
const CONTENT_DETECT_MAX_SIZE = 500 * 1024 // 500KB
|
||||
@@ -25,26 +23,73 @@ 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
|
||||
}
|
||||
// ====== URL 工具函数(导出供外部使用) ======
|
||||
|
||||
function getLocalServerURL(): string {
|
||||
return getFileServerBaseURL()
|
||||
}
|
||||
|
||||
function resolveFileServerBase(): string {
|
||||
// 单一数据源:从 connectionManager 实时读取,不缓存
|
||||
if (!connectionManager.isRemote()) return getLocalServerURL()
|
||||
/** 解析文件服务器 base URL(区分本地/远程模式) */
|
||||
export function resolveFileServerBase(): string {
|
||||
if (!connectionManager.isRemote()) return getFileServerBaseURL()
|
||||
const base = connectionManager.getFileServerBaseURL()
|
||||
if (!base) return getLocalServerURL()
|
||||
// 远程模式需要完整代理路径前缀 /api/v1/proxy/localfs
|
||||
if (!base) return getFileServerBaseURL()
|
||||
return base.replace(/\/$/, '') + '/api/v1/proxy/localfs'
|
||||
}
|
||||
|
||||
export function useFilePreview(options: UseFilePreviewOptions = {}) {
|
||||
const { filePath = ref(''), isBrowsingZip = ref(false) } = options
|
||||
/** 拼接本地文件服务器 URL(远程文件下载到临时目录后使用) */
|
||||
export function buildLocalUrl(localPath: string): string {
|
||||
const base = getFileServerBaseURL()
|
||||
let normalized = normalizeFilePath(localPath, true)
|
||||
if (normalized.startsWith('/')) normalized = normalized.slice(1)
|
||||
const sep = base.endsWith('/') ? '' : '/'
|
||||
return `${base}${sep}localfs/${normalized}`
|
||||
}
|
||||
|
||||
/** 统一 URL 解析核心(传入 transport 实例,消除 instanceof 分支) */
|
||||
async function resolveWithTransport(
|
||||
transport: import('@/api/transport').FsTransport,
|
||||
path: string,
|
||||
stream: boolean
|
||||
): Promise<string> {
|
||||
// 需要下载到本地的传输类型
|
||||
if (transport.downloadForPreview) {
|
||||
// OSS 支持签名 URL 流式播放
|
||||
if (stream && transport.getSignedUrl) {
|
||||
return await transport.getSignedUrl(path)
|
||||
}
|
||||
const tempPath = await transport.downloadForPreview(path)
|
||||
if (tempPath !== path) return buildLocalUrl(tempPath)
|
||||
// downloadForPreview 返回原路径 → 本地/HTTP,直接拼 URL
|
||||
}
|
||||
// 本地/HTTP: 直接拼文件服务器 URL
|
||||
const base = resolveFileServerBase()
|
||||
let normalized = normalizeFilePath(path, true)
|
||||
if (normalized.startsWith('/')) normalized = normalized.slice(1)
|
||||
const sep = base.endsWith('/') ? '' : '/'
|
||||
return `${base}${sep}${connectionManager.isRemote() ? '' : 'localfs/'}${normalized}`
|
||||
}
|
||||
|
||||
/**
|
||||
* 统一文件 URL 解析(使用当前活跃 transport)
|
||||
*/
|
||||
export async function resolveFileUrl(path: string, stream = false): Promise<string> {
|
||||
if (!path) return ''
|
||||
return resolveWithTransport(connectionManager.getTransport(), path, stream)
|
||||
}
|
||||
|
||||
/**
|
||||
* 按指定 profileId 解析文件 URL(播放列表恢复用)
|
||||
* 不切换当前活跃连接,直接从连接池取对应 transport
|
||||
*/
|
||||
export async function resolveFileUrlForProfile(
|
||||
path: string, profileId: string | undefined, stream = false
|
||||
): Promise<string> {
|
||||
if (!path) return ''
|
||||
if (profileId) {
|
||||
const transport = connectionManager.getTransportFor(profileId)
|
||||
if (transport) return resolveWithTransport(transport, path, stream)
|
||||
}
|
||||
return resolveFileUrl(path, stream)
|
||||
}
|
||||
|
||||
export function useFilePreview() {
|
||||
|
||||
// 预览 URL
|
||||
const previewUrl = ref('')
|
||||
@@ -55,46 +100,24 @@ export function useFilePreview(options: UseFilePreviewOptions = {}) {
|
||||
|
||||
/**
|
||||
* 获取预览 URL(本地/远程/SFTP 自适应,每次实时计算)
|
||||
* 本地: {fileServerBaseURL}/localfs/{encoded_path}
|
||||
* 远程(HTTP): {baseUrl}/api/v1/proxy/localfs/{raw_path}
|
||||
* SFTP: 下载到本地临时目录 → {fileServerBaseURL}/localfs/{temp_path}
|
||||
*/
|
||||
const getPreviewUrl = (path: string): string => {
|
||||
if (!path) return ''
|
||||
const isSftp = connectionManager.isSftp()
|
||||
const isRemote = connectionManager.isRemote()
|
||||
|
||||
// SFTP 模式:需要先下载到本地临时目录
|
||||
// 注意:这里返回的是同步路径,实际下载在 updatePreviewUrl 中异步完成
|
||||
// 对于 SFTP 模式,getPreviewUrl 返回的 URL 会在 updatePreviewUrl 中被覆盖为临时文件路径
|
||||
if (isSftp) {
|
||||
const base = getLocalServerURL()
|
||||
let normalized = normalizeFilePath(path, true)
|
||||
const sep = base.endsWith('/') ? '' : '/'
|
||||
return `${base}${sep}localfs/${normalized}`
|
||||
}
|
||||
|
||||
const base = resolveFileServerBase()
|
||||
let normalized = normalizeFilePath(path, true)
|
||||
if (isRemote && normalized.startsWith('/')) normalized = normalized.slice(1)
|
||||
if (normalized.startsWith('/')) normalized = normalized.slice(1)
|
||||
const sep = base.endsWith('/') ? '' : '/'
|
||||
return `${base}${sep}${isRemote ? '' : 'localfs/'}${normalized}`
|
||||
return `${base}${sep}${connectionManager.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
|
||||
}
|
||||
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
|
||||
}
|
||||
if (cached && Date.now() - cached.timestamp < CACHE_TTL) return cached.result
|
||||
|
||||
try {
|
||||
const result = await detectFileTypeByContent(path)
|
||||
@@ -108,28 +131,15 @@ export function useFilePreview(options: UseFilePreviewOptions = {}) {
|
||||
|
||||
/**
|
||||
* 更新预览 URL
|
||||
* SFTP/OSS:下载到本地临时目录后用本地文件服务器预览
|
||||
*/
|
||||
const updatePreviewUrl = async (path: string) => {
|
||||
if (!path) { previewUrl.value = ''; return }
|
||||
const transport = connectionManager.getTransport()
|
||||
|
||||
// SFTP / OSS:下载到本地临时目录后用本地文件服务器预览
|
||||
if (transport instanceof SftpTransport || transport instanceof OssTransport) {
|
||||
try {
|
||||
const tempPath = await transport.downloadForPreview(path)
|
||||
// 临时文件通过本地文件服务器提供,始终用 localfs 路径
|
||||
const base = getLocalServerURL()
|
||||
const normalized = normalizeFilePath(tempPath, true)
|
||||
const sep = base.endsWith('/') ? '' : '/'
|
||||
previewUrl.value = `${base}${sep}localfs/${normalized}`
|
||||
return
|
||||
} catch {
|
||||
// 下载失败,回退
|
||||
}
|
||||
try {
|
||||
previewUrl.value = await resolveFileUrl(path)
|
||||
} catch (e) {
|
||||
console.warn('[Preview] 下载失败:', path, e)
|
||||
previewUrl.value = ''
|
||||
}
|
||||
|
||||
previewUrl.value = getPreviewUrl(path)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -151,22 +161,11 @@ export function useFilePreview(options: UseFilePreviewOptions = {}) {
|
||||
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 (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) ||
|
||||
@@ -187,71 +186,28 @@ export function useFilePreview(options: UseFilePreviewOptions = {}) {
|
||||
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,
|
||||
|
||||
// 文件类型判断(同步,基于扩展名)
|
||||
resolveFileUrl,
|
||||
getFileType,
|
||||
isPreviewable,
|
||||
isEditable,
|
||||
|
||||
// 内容检测(异步,基于文件内容)
|
||||
detectByContent,
|
||||
|
||||
// 事件处理
|
||||
onImageLoad,
|
||||
onImageError,
|
||||
startImageLoad,
|
||||
|
||||
// 工具方法
|
||||
getMediaMetadata
|
||||
startImageLoad
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import { STORAGE_KEYS } from '@/utils/constants'
|
||||
import { connectionManager } from '@/api/connection-manager'
|
||||
import type { FileItem } from '@/types/file-system'
|
||||
|
||||
export interface PreviewTab {
|
||||
@@ -12,6 +13,8 @@ export interface PreviewTab {
|
||||
id: string
|
||||
/** 文件信息 */
|
||||
fileItem: FileItem
|
||||
/** 所属连接 profileId */
|
||||
profileId?: string
|
||||
/** 缓存的预览 URL */
|
||||
previewUrl: string
|
||||
/** 缓存的文件内容 */
|
||||
@@ -30,6 +33,7 @@ export interface PreviewTab {
|
||||
interface PersistedTab {
|
||||
path: string
|
||||
active: boolean
|
||||
profileId?: string
|
||||
/** 未保存的内容(有修改时才存) */
|
||||
unsavedContent?: string
|
||||
originalContent?: string
|
||||
@@ -48,11 +52,13 @@ interface UnsavedEntry {
|
||||
interface RestoredSession {
|
||||
paths: string[]
|
||||
activePath: string | null
|
||||
profileMap: Map<string, string | undefined>
|
||||
unsavedMap: Map<string, UnsavedEntry>
|
||||
}
|
||||
|
||||
function pathToId(path: string): string {
|
||||
return path.replace(/\\/g, '/').toLowerCase()
|
||||
const normalized = path.replace(/\\/g, '/')
|
||||
return connectionManager.isRemote() ? normalized : normalized.toLowerCase()
|
||||
}
|
||||
|
||||
export function isDirty(tab: PreviewTab): boolean {
|
||||
@@ -69,9 +75,10 @@ export function useMultiPreview() {
|
||||
})
|
||||
|
||||
/** 创建一个新 tab */
|
||||
const createTab = (fileItem: FileItem): PreviewTab => ({
|
||||
const createTab = (fileItem: FileItem, profileId?: string): PreviewTab => ({
|
||||
id: pathToId(fileItem.path),
|
||||
fileItem,
|
||||
profileId,
|
||||
previewUrl: '',
|
||||
fileContent: '',
|
||||
originalContent: '',
|
||||
@@ -85,19 +92,21 @@ export function useMultiPreview() {
|
||||
/** 从 localStorage 恢复会话 */
|
||||
const restoreSession = (): RestoredSession => {
|
||||
const unsavedMap = new Map<string, UnsavedEntry>()
|
||||
const profileMap = new Map<string, string | undefined>()
|
||||
let activePath: string | null = null
|
||||
const paths: string[] = []
|
||||
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY)
|
||||
if (!raw) return { paths, activePath, unsavedMap }
|
||||
if (!raw) return { paths, activePath, profileMap, unsavedMap }
|
||||
|
||||
const persisted: PersistedTab[] = JSON.parse(raw)
|
||||
if (!Array.isArray(persisted)) return { paths, activePath, unsavedMap }
|
||||
if (!Array.isArray(persisted)) return { paths, activePath, profileMap, unsavedMap }
|
||||
|
||||
for (const p of persisted) {
|
||||
if (!p.path) continue
|
||||
paths.push(p.path)
|
||||
profileMap.set(pathToId(p.path), p.profileId)
|
||||
if (p.active) activePath = p.path
|
||||
if (p.unsavedContent !== undefined) {
|
||||
unsavedMap.set(pathToId(p.path), {
|
||||
@@ -111,18 +120,19 @@ export function useMultiPreview() {
|
||||
localStorage.removeItem(STORAGE_KEY)
|
||||
}
|
||||
|
||||
return { paths, activePath, unsavedMap }
|
||||
return { paths, activePath, profileMap, unsavedMap }
|
||||
}
|
||||
|
||||
/** 保存会话到 localStorage */
|
||||
const persistSession = () => {
|
||||
const persisted: PersistedTab[] = tabs.value.map(tab => {
|
||||
const hasUnsaved = tab.fileContent && tab.originalContent !== undefined && tab.fileContent !== tab.originalContent
|
||||
const hasUnsaved = isDirty(tab)
|
||||
// 限制存储大小,超过 100KB 的内容不存入 localStorage
|
||||
const canSave = hasUnsaved && tab.fileContent.length <= 100_000
|
||||
return {
|
||||
path: tab.fileItem.path,
|
||||
active: tab.id === activeTabId.value,
|
||||
profileId: tab.profileId,
|
||||
unsavedContent: canSave ? tab.fileContent : undefined,
|
||||
originalContent: canSave ? tab.originalContent : undefined,
|
||||
isEditMode: canSave ? tab.isEditMode : undefined
|
||||
@@ -148,7 +158,7 @@ export function useMultiPreview() {
|
||||
}
|
||||
|
||||
/** 添加或激活 tab,返回 { tab, isNew } */
|
||||
const addTab = (fileItem: FileItem): { tab: PreviewTab; isNew: boolean } => {
|
||||
const addTab = (fileItem: FileItem, profileId?: string): { tab: PreviewTab; isNew: boolean } => {
|
||||
const id = pathToId(fileItem.path)
|
||||
const existing = tabs.value.find(t => t.id === id)
|
||||
if (existing) {
|
||||
@@ -162,7 +172,7 @@ export function useMultiPreview() {
|
||||
if (victimIdx !== -1) tabs.value.splice(victimIdx, 1)
|
||||
}
|
||||
|
||||
const tab = createTab(fileItem)
|
||||
const tab = createTab(fileItem, profileId)
|
||||
tabs.value.push(tab)
|
||||
activeTabId.value = tab.id
|
||||
return { tab, isNew: true }
|
||||
|
||||
@@ -10,6 +10,8 @@ import type { PathHistory } from '@/types/file-system'
|
||||
|
||||
export interface UsePathNavigationOptions {
|
||||
onListDirectory?: (path: string) => Promise<void>
|
||||
/** 获取当前 profileId,用于历史记录绑定 */
|
||||
getCurrentProfileId?: () => string | undefined
|
||||
initialPath?: string
|
||||
}
|
||||
|
||||
@@ -20,7 +22,6 @@ const restoreLastPath = (): string | null => {
|
||||
try {
|
||||
const lastPath = localStorage.getItem(STORAGE_KEYS.FILESYSTEM.FILE_PATH)
|
||||
if (lastPath) {
|
||||
// 规范化旧路径(可能包含反斜杠)
|
||||
return normalizePathSeparators(lastPath)
|
||||
}
|
||||
return lastPath
|
||||
@@ -42,33 +43,31 @@ const saveLastPath = (path: string) => {
|
||||
}
|
||||
|
||||
export function usePathNavigation(options: UsePathNavigationOptions = {}) {
|
||||
const { onListDirectory, initialPath = '' } = options
|
||||
const { onListDirectory, getCurrentProfileId, initialPath = '' } = options
|
||||
|
||||
// 尝试恢复上次的路径,如果没有则使用初始路径
|
||||
const savedPath = restoreLastPath()
|
||||
const filePath = ref(savedPath || initialPath)
|
||||
|
||||
// 历史记录
|
||||
// 历史记录(每条路径绑定 profileId)
|
||||
const history = ref<PathHistory>({
|
||||
paths: [],
|
||||
profileIds: [],
|
||||
currentIndex: -1
|
||||
})
|
||||
|
||||
/**
|
||||
* 导航到指定路径(带错误处理)
|
||||
* 导航到指定路径
|
||||
*/
|
||||
const navigate = async (path: string) => {
|
||||
if (!path || path === filePath.value) return
|
||||
|
||||
try {
|
||||
// 路径规范化(处理反斜杠并统一为正斜杠)
|
||||
const normalizedPath = normalizePathSeparators(path)
|
||||
filePath.value = normalizedPath
|
||||
|
||||
// 添加到历史记录
|
||||
addToHistory(normalizedPath)
|
||||
const profileId = getCurrentProfileId?.()
|
||||
addToHistory(normalizedPath, profileId)
|
||||
|
||||
// 触发目录列出
|
||||
if (onListDirectory) {
|
||||
await onListDirectory(normalizedPath)
|
||||
}
|
||||
@@ -78,32 +77,28 @@ export function usePathNavigation(options: UsePathNavigationOptions = {}) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 添加到历史记录
|
||||
*/
|
||||
const addToHistory = (path: string) => {
|
||||
const { paths, currentIndex } = history.value
|
||||
/** 添加到历史记录 */
|
||||
const addToHistory = (path: string, profileId?: string) => {
|
||||
const { paths, profileIds, currentIndex } = history.value
|
||||
|
||||
// 如果当前不在历史记录末尾,删除当前位置之后的所有记录
|
||||
if (currentIndex < paths.length - 1) {
|
||||
history.value.paths = paths.slice(0, currentIndex + 1)
|
||||
history.value.profileIds = profileIds.slice(0, currentIndex + 1)
|
||||
}
|
||||
|
||||
// 避免重复添加相同路径
|
||||
const lastPath = history.value.paths[history.value.paths.length - 1]
|
||||
if (lastPath !== path) {
|
||||
history.value.paths.push(path)
|
||||
history.value.profileIds.push(profileId)
|
||||
history.value.currentIndex = history.value.paths.length - 1
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 后退(带错误处理)
|
||||
*/
|
||||
const back = async () => {
|
||||
/** 后退,返回目标 profileId */
|
||||
const back = async (): Promise<string | undefined> => {
|
||||
const { paths, currentIndex } = history.value
|
||||
|
||||
if (currentIndex <= 0) return
|
||||
if (currentIndex <= 0) return undefined
|
||||
|
||||
try {
|
||||
const newIndex = currentIndex - 1
|
||||
@@ -113,19 +108,18 @@ export function usePathNavigation(options: UsePathNavigationOptions = {}) {
|
||||
if (onListDirectory) {
|
||||
await onListDirectory(paths[newIndex])
|
||||
}
|
||||
return history.value.profileIds[newIndex]
|
||||
} catch (error) {
|
||||
console.error('后退失败:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 前进(带错误处理)
|
||||
*/
|
||||
const forward = async () => {
|
||||
/** 前进,返回目标 profileId */
|
||||
const forward = async (): Promise<string | undefined> => {
|
||||
const { paths, currentIndex } = history.value
|
||||
|
||||
if (currentIndex >= paths.length - 1) return
|
||||
if (currentIndex >= paths.length - 1) return undefined
|
||||
|
||||
try {
|
||||
const newIndex = currentIndex + 1
|
||||
@@ -135,45 +129,42 @@ export function usePathNavigation(options: UsePathNavigationOptions = {}) {
|
||||
if (onListDirectory) {
|
||||
await onListDirectory(paths[newIndex])
|
||||
}
|
||||
return history.value.profileIds[newIndex]
|
||||
} catch (error) {
|
||||
console.error('前进失败:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 路径输入选择
|
||||
*/
|
||||
/** 获取指定历史索引的 profileId */
|
||||
const getHistoryProfileId = (index: number): string | undefined => {
|
||||
return history.value.profileIds[index]
|
||||
}
|
||||
|
||||
/** 通过路径查历史 profileId */
|
||||
const getProfileIdForPath = (path: string): string | undefined => {
|
||||
const idx = history.value.paths.indexOf(path)
|
||||
return idx !== -1 ? history.value.profileIds[idx] : undefined
|
||||
}
|
||||
|
||||
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) {
|
||||
@@ -181,35 +172,22 @@ export function usePathNavigation(options: UsePathNavigationOptions = {}) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 路径规范化(统一为正斜杠)
|
||||
*/
|
||||
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() // 最新的在前
|
||||
return history.value.paths.slice().reverse()
|
||||
})
|
||||
|
||||
// 监听路径变化,自动保存到 localStorage
|
||||
watch(filePath, (newPath) => {
|
||||
if (newPath) {
|
||||
saveLastPath(newPath)
|
||||
@@ -217,31 +195,23 @@ export function usePathNavigation(options: UsePathNavigationOptions = {}) {
|
||||
})
|
||||
|
||||
return {
|
||||
// 状态
|
||||
filePath,
|
||||
history,
|
||||
|
||||
// 导航方法
|
||||
navigate,
|
||||
back,
|
||||
forward,
|
||||
goUp,
|
||||
browseDirectory,
|
||||
|
||||
// 事件处理
|
||||
onPathSelect,
|
||||
onPathEnter,
|
||||
|
||||
// 工具方法
|
||||
getParentPath,
|
||||
normalizePath,
|
||||
|
||||
// 计算属性
|
||||
canGoBack,
|
||||
canGoForward,
|
||||
getPathHistory
|
||||
getPathHistory,
|
||||
getHistoryProfileId,
|
||||
getProfileIdForPath
|
||||
}
|
||||
}
|
||||
|
||||
// 导出类型(用于外部使用)
|
||||
export type { PathHistory }
|
||||
|
||||
@@ -64,6 +64,7 @@
|
||||
|
||||
<!-- 文件编辑器面板(始终显示,无选中文件时为空白预览区) -->
|
||||
<FileEditorPanel
|
||||
ref="fileEditorPanelRef"
|
||||
:config="fileEditorPanelConfig"
|
||||
:width="panelWidth.right"
|
||||
:current-directory="filePath"
|
||||
@@ -129,7 +130,7 @@ import ContextMenu from './components/ContextMenu.vue'
|
||||
import { useFileOperations } from './composables/useFileOperations'
|
||||
import { useFavorites } from './composables/useFavorites'
|
||||
import { usePathNavigation } from './composables/usePathNavigation'
|
||||
import { useFilePreview } from './composables/useFilePreview'
|
||||
import { useFilePreview, resolveFileUrl, resolveFileServerBase } from './composables/useFilePreview'
|
||||
import { useFileEdit } from './composables/useFileEdit'
|
||||
import { useCommonPaths } from './composables/useCommonPaths'
|
||||
import { useMultiPreview, type PreviewTab, isDirty } from './composables/useMultiPreview'
|
||||
@@ -165,6 +166,7 @@ const fileList = ref<FileItem[]>([])
|
||||
const fileLoading = ref(false)
|
||||
const selectedFileItem = ref<FileItem | null>(null)
|
||||
const toolbarRef = ref<InstanceType<typeof import('./components/Toolbar.vue').default> | null>(null)
|
||||
const fileEditorPanelRef = ref<InstanceType<typeof FileEditorPanel> | null>(null)
|
||||
const triggerConnectionDialog = ref(0)
|
||||
const pendingEditProfileId = ref<string | null>(null)
|
||||
|
||||
@@ -294,18 +296,17 @@ const fileOps = useFileOperations({
|
||||
const { favorites, draggingState, toggleFavorite: toggleFav, removeFavorite: removeFav, isFavorite, togglePin, updateFavoritePath, onLongPressStart, onLongPressCancel, onDragStart, onDragOver, onDrop, onDragEnd } = useFavorites()
|
||||
|
||||
// 路径导航
|
||||
const { filePath, history, navigate, back, forward, onPathSelect, onPathEnter, browseDirectory, getParentPath } =
|
||||
const { filePath, history, navigate, back, forward, onPathSelect, onPathEnter, browseDirectory, getParentPath, getProfileIdForPath } =
|
||||
usePathNavigation({
|
||||
onListDirectory: async (path) => {
|
||||
await loadDirectory(path)
|
||||
}
|
||||
},
|
||||
getCurrentProfileId: () => connectionManager.activeProfile?.id
|
||||
})
|
||||
|
||||
// 文件预览
|
||||
const { previewUrl, updatePreviewUrl, imageLoading, currentImageDimensions, detectByContent } =
|
||||
useFilePreview({
|
||||
filePath
|
||||
})
|
||||
useFilePreview()
|
||||
|
||||
// 文件编辑
|
||||
const { fileContent, originalContent, isEditMode, fileContentHeight, contentChanged, canSaveFile, canResetContent, loadFile, saveFile, resetContent, clearContent, updateFilePath, toggleEditMode, updateContent, setEditorHeight, isBinaryFile: isBinaryFileRef, fileVersion } =
|
||||
@@ -326,7 +327,7 @@ const hasSelectedFile = computed(() => selectedFileItem.value !== null)
|
||||
// 工具栏配置
|
||||
const toolbarConfig = computed(() => ({
|
||||
filePath: filePath.value || '',
|
||||
pathHistory: history.value.paths.slice(-10),
|
||||
pathHistory: history.value.paths.slice(-50),
|
||||
commonPaths: commonPaths.value,
|
||||
isBrowsingZip: false,
|
||||
displayPath: '',
|
||||
@@ -373,10 +374,8 @@ const computeRendered = computed(() => {
|
||||
|
||||
// 设置文件服务器 Base URL
|
||||
const isRemote = connectionManager.isRemote()
|
||||
const base = isRemote
|
||||
? (connectionManager.getFileServerBaseURL().replace(/\/$/, '') + '/api/v1/proxy/localfs')
|
||||
: 'http://localhost:2652/localfs'
|
||||
setFileServerBase(base)
|
||||
const base = resolveFileServerBase()
|
||||
setFileServerBase(isRemote ? base : base + '/localfs')
|
||||
|
||||
return marked.parse(content) as string
|
||||
} catch (error) {
|
||||
@@ -441,9 +440,12 @@ const handleRefresh = async () => {
|
||||
await loadDirectory(filePath.value)
|
||||
}
|
||||
|
||||
// 程序化切换 profile 时抑制自动导航(如打开收藏/tab切换)
|
||||
let _suppressAutoNav = false
|
||||
|
||||
// 连接切换后重置路径并刷新文件列表
|
||||
connectionManager.onStateChange(async (state) => {
|
||||
if (state === 'connected') {
|
||||
if (state === 'connected' && !_suppressAutoNav) {
|
||||
await loadCommonPaths()
|
||||
const targetPath = connectionManager.isRemote() ? '/' : 'C:/'
|
||||
filePath.value = targetPath
|
||||
@@ -462,6 +464,12 @@ const handleConnectionChanged = async () => {
|
||||
}
|
||||
|
||||
const handleGoToPath = async (path: string) => {
|
||||
// 历史记录下拉选择时,检查是否需要切换 profile
|
||||
const targetProfileId = getProfileIdForPath(path)
|
||||
if (targetProfileId && targetProfileId !== connectionManager.activeProfile?.id) {
|
||||
_suppressAutoNav = true
|
||||
try { await connectionManager.connect(targetProfileId) } finally { _suppressAutoNav = false }
|
||||
}
|
||||
await navigate(path)
|
||||
}
|
||||
|
||||
@@ -478,6 +486,10 @@ const handleOpenFile = async (path: string) => {
|
||||
if (targetFile.isDir) {
|
||||
// 是目录,导航进入
|
||||
await navigate(path)
|
||||
} else if (isAudioFile(targetFile.name)) {
|
||||
// 音频文件:加入 BGM 播放列表,不打开 tab
|
||||
const url = await resolveFileUrl(path, true)
|
||||
fileEditorPanelRef.value?.playAudioAsBGM(targetFile.name, path, url)
|
||||
} else {
|
||||
// 是文件,先加载内容,再更新选中状态(避免闪烁)
|
||||
await loadFileContent(path)
|
||||
@@ -558,29 +570,54 @@ const handleShowMessage = (message: string, type: 'success' | 'error' | 'warning
|
||||
|
||||
// 侧边栏事件
|
||||
const handleOpenFavorite = async (file: FavoriteFile) => {
|
||||
// 根据路径格式自动切换连接(Linux 路径 → 远程,Windows 路径 → 本地)
|
||||
const isLinuxPath = /^[\/]/.test(file.path) && !/^[A-Za-z]:/.test(file.path)
|
||||
const shouldBeRemote = isLinuxPath
|
||||
const isCurrentlyRemote = connectionManager.isRemote()
|
||||
const currentProfileId = connectionManager.activeProfile?.id
|
||||
const needSwitch = file.profileId && file.profileId !== currentProfileId
|
||||
|
||||
if (shouldBeRemote !== isCurrentlyRemote) {
|
||||
// 需要切换连接
|
||||
if (shouldBeRemote) {
|
||||
// 切换到远程:找第一个 remote profile
|
||||
const remoteProfile = connectionManager.profiles.find(p => p.type === 'remote')
|
||||
if (remoteProfile) {
|
||||
connectionManager.connect(remoteProfile.id)
|
||||
}
|
||||
} else {
|
||||
// 切换到本地
|
||||
connectionManager.disconnect()
|
||||
if (needSwitch) {
|
||||
_suppressAutoNav = true
|
||||
try {
|
||||
await connectionManager.connect(file.profileId)
|
||||
await loadCommonPaths()
|
||||
} catch (e) {
|
||||
console.error('切换连接失败:', e)
|
||||
} finally {
|
||||
_suppressAutoNav = false
|
||||
}
|
||||
} else if (!file.profileId) {
|
||||
// 无 profileId(旧数据),按路径格式自动切换连接
|
||||
const isLinuxPath = /^[\/]/.test(file.path) && !/^[A-Za-z]:/.test(file.path)
|
||||
const shouldBeRemote = isLinuxPath
|
||||
const isCurrentlyRemote = connectionManager.isRemote()
|
||||
|
||||
if (shouldBeRemote !== isCurrentlyRemote) {
|
||||
_suppressAutoNav = true
|
||||
try {
|
||||
if (shouldBeRemote) {
|
||||
const remoteProfile = connectionManager.profiles.find(p =>
|
||||
p.type === 'remote' || p.type === 'sftp' || p.type === 'oss'
|
||||
)
|
||||
if (remoteProfile) {
|
||||
await connectionManager.connect(remoteProfile.id)
|
||||
}
|
||||
} else {
|
||||
// disconnect() 也会触发 onStateChange,需要 suppress
|
||||
connectionManager.disconnect()
|
||||
}
|
||||
await loadCommonPaths()
|
||||
} finally {
|
||||
_suppressAutoNav = false
|
||||
}
|
||||
}
|
||||
await loadCommonPaths()
|
||||
}
|
||||
|
||||
if (file.isDir) {
|
||||
await navigate(file.path)
|
||||
} else {
|
||||
// 先导航到父目录,再选中文件
|
||||
const parentPath = file.path.substring(0, Math.max(file.path.lastIndexOf('/'), file.path.lastIndexOf('\\')))
|
||||
if (parentPath && parentPath !== filePath.value) {
|
||||
await navigate(parentPath)
|
||||
}
|
||||
await selectFile(file.path)
|
||||
}
|
||||
}
|
||||
@@ -621,6 +658,9 @@ const handleDragEnd = () => {
|
||||
const handleFileClick = async (file: FileItem) => {
|
||||
if (file.isDir) {
|
||||
await navigate(file.path)
|
||||
} else if (isAudioFile(file.name)) {
|
||||
const url = await resolveFileUrl(file.path, true)
|
||||
fileEditorPanelRef.value?.playAudioAsBGM(file.name, file.path, url)
|
||||
} else {
|
||||
openFileAsTab(file)
|
||||
}
|
||||
@@ -1065,19 +1105,33 @@ const isMediaPreviewable = (filename: string): boolean => {
|
||||
|
||||
/** 激活 tab:设置选中项 + 加载或恢复内容 */
|
||||
const activateTab = async (tab: PreviewTab) => {
|
||||
// 如果 tab 属于不同 profile,自动切换连接
|
||||
if (tab.profileId && tab.profileId !== connectionManager.activeProfile?.id) {
|
||||
_suppressAutoNav = true
|
||||
try {
|
||||
await connectionManager.connect(tab.profileId)
|
||||
} catch (e) {
|
||||
console.error('切换连接失败:', e)
|
||||
} finally {
|
||||
_suppressAutoNav = false
|
||||
}
|
||||
}
|
||||
selectedFileItem.value = tab.fileItem
|
||||
if (tab.loaded) {
|
||||
restoreTabState(tab)
|
||||
} else {
|
||||
await loadFileContent(tab.fileItem.path)
|
||||
tab.loaded = true
|
||||
// 首次加载完成后确保 dirty 状态正确(加载过程中 fileContent/originalContent 可能不同步)
|
||||
tab.originalContent = fileContent.value
|
||||
}
|
||||
}
|
||||
|
||||
/** 文件 → 添加到 tab 并激活 */
|
||||
const openFileAsTab = async (file: FileItem) => {
|
||||
cacheCurrentTabState()
|
||||
const { tab } = multiPreview.addTab(file)
|
||||
const currentProfileId = connectionManager.activeProfile?.id
|
||||
const { tab } = multiPreview.addTab(file, currentProfileId)
|
||||
await activateTab(tab)
|
||||
}
|
||||
|
||||
@@ -1382,10 +1436,20 @@ onMounted(async () => {
|
||||
|
||||
// 恢复多文件预览会话
|
||||
const session = multiPreview.restoreSession()
|
||||
if (session.paths.length > 0 && !connectionManager.isRemote()) {
|
||||
if (session.paths.length > 0) {
|
||||
// 找到激活 tab 的 profileId,先切换到该连接
|
||||
const activeId = session.activePath
|
||||
? session.profileMap.get(session.activePath.replace(/\\/g, '/').toLowerCase())
|
||||
: undefined
|
||||
if (activeId && activeId !== connectionManager.activeProfile?.id) {
|
||||
_suppressAutoNav = true
|
||||
try { await connectionManager.connect(activeId) } catch (e) { console.error('session恢复连接失败:', e) } finally { _suppressAutoNav = false }
|
||||
}
|
||||
|
||||
for (const path of session.paths) {
|
||||
const name = path.split(/[/\\]/).pop() || path
|
||||
const { tab } = multiPreview.addTab({ path, name, isDir: false, size: 0, modified_time: '' })
|
||||
const tabProfileId = session.profileMap.get(path.replace(/\\/g, '/').toLowerCase())
|
||||
const { tab } = multiPreview.addTab({ path, name, isDir: false, size: 0, modified_time: '' }, tabProfileId)
|
||||
if (path === session.activePath || path.replace(/\\/g, '/').toLowerCase() === (session.activePath || '').replace(/\\/g, '/').toLowerCase()) {
|
||||
multiPreview.activeTabId.value = tab.id
|
||||
}
|
||||
@@ -1499,6 +1563,13 @@ const handleKeyDown = async (event: KeyboardEvent) => {
|
||||
handleToggleEditMode()
|
||||
return
|
||||
}
|
||||
|
||||
// Ctrl+Shift+B 切换 BGM 播放条显隐
|
||||
if (driveLetter === 'B') {
|
||||
event.preventDefault()
|
||||
fileEditorPanelRef.value?.toggleBgmVisibility()
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Ctrl+S 保存
|
||||
@@ -1541,7 +1612,11 @@ const handleKeyDown = async (event: KeyboardEvent) => {
|
||||
event.preventDefault()
|
||||
isNavigating.value = true
|
||||
try {
|
||||
await back()
|
||||
const targetProfileId = await back()
|
||||
if (targetProfileId && targetProfileId !== connectionManager.activeProfile?.id) {
|
||||
_suppressAutoNav = true
|
||||
try { await connectionManager.connect(targetProfileId) } finally { _suppressAutoNav = false }
|
||||
}
|
||||
} finally {
|
||||
isNavigating.value = false
|
||||
}
|
||||
@@ -1553,7 +1628,11 @@ const handleKeyDown = async (event: KeyboardEvent) => {
|
||||
event.preventDefault()
|
||||
isNavigating.value = true
|
||||
try {
|
||||
await forward()
|
||||
const targetProfileId = await forward()
|
||||
if (targetProfileId && targetProfileId !== connectionManager.activeProfile?.id) {
|
||||
_suppressAutoNav = true
|
||||
try { await connectionManager.connect(targetProfileId) } finally { _suppressAutoNav = false }
|
||||
}
|
||||
} finally {
|
||||
isNavigating.value = false
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
>
|
||||
<a-tabs default-active-key="tab-config">
|
||||
<!-- Tab 配置 -->
|
||||
<a-tab-pane key="tab-config" title="Tab 配置">
|
||||
<a-tab-pane key="tab-config" title="功能配置">
|
||||
<a-space direction="vertical" style="width: 100%" :size="16">
|
||||
|
||||
<!-- 说明文字 -->
|
||||
@@ -76,6 +76,47 @@
|
||||
至少需要保留一个可见的 Tab
|
||||
</a-alert>
|
||||
|
||||
<!-- 侧边栏区块配置 -->
|
||||
<a-divider>侧边栏区块</a-divider>
|
||||
<a-alert type="info" :show-icon="true">
|
||||
控制左侧边栏各区块的显示和顺序
|
||||
</a-alert>
|
||||
<div class="tab-config-list">
|
||||
<div
|
||||
v-for="(sectionKey, index) in localConfig.sidebarSections"
|
||||
:key="sectionKey"
|
||||
class="tab-config-item"
|
||||
draggable="true"
|
||||
@dragstart="handleSidebarDragStart(index, $event)"
|
||||
@dragover.prevent="handleSidebarDragOver(index, $event)"
|
||||
@drop="handleSidebarDrop(index, $event)"
|
||||
@dragend="handleSidebarDragEnd"
|
||||
>
|
||||
<icon-drag-arrow class="drag-handle" />
|
||||
<a-checkbox
|
||||
:model-value="true"
|
||||
@change="(value) => handleSidebarVisibilityChange(sectionKey, value)"
|
||||
/>
|
||||
<span class="tab-title">{{ getSidebarTitle(sectionKey) }}</span>
|
||||
</div>
|
||||
<template v-if="hiddenSidebarSections.length > 0">
|
||||
<a-divider>已隐藏</a-divider>
|
||||
<div
|
||||
v-for="sectionKey in hiddenSidebarSections"
|
||||
:key="'hidden-' + sectionKey"
|
||||
class="tab-config-item hidden"
|
||||
>
|
||||
<icon-drag-arrow class="drag-handle disabled" />
|
||||
<a-checkbox
|
||||
:model-value="false"
|
||||
@change="(value) => handleSidebarVisibilityChange(sectionKey, value)"
|
||||
/>
|
||||
<span class="tab-title">{{ getSidebarTitle(sectionKey) }}</span>
|
||||
<span class="hidden-tag">已隐藏</span>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- 保存按钮 -->
|
||||
<a-space>
|
||||
<a-button type="primary" @click="handleSave" :loading="saving">
|
||||
@@ -133,7 +174,8 @@ const visible = computed({
|
||||
const localConfig = ref({
|
||||
tabs: [],
|
||||
visibleTabs: [],
|
||||
defaultTab: ''
|
||||
defaultTab: '',
|
||||
sidebarSections: []
|
||||
})
|
||||
|
||||
const saving = ref(false)
|
||||
@@ -155,7 +197,8 @@ watch(() => props.config, (newConfig) => {
|
||||
localConfig.value = {
|
||||
tabs: [...newConfig.tabs],
|
||||
visibleTabs: [...newConfig.visibleTabs],
|
||||
defaultTab: newConfig.defaultTab
|
||||
defaultTab: newConfig.defaultTab,
|
||||
sidebarSections: [...(newConfig.sidebarSections || ['server', 'favorites', 'help'])]
|
||||
}
|
||||
}
|
||||
}, { immediate: true, deep: true })
|
||||
@@ -266,19 +309,13 @@ const handleSave = async () => {
|
||||
const configToSave = {
|
||||
tabs: syncedTabs,
|
||||
visibleTabs: [...localConfig.value.visibleTabs],
|
||||
defaultTab: localConfig.value.defaultTab
|
||||
defaultTab: localConfig.value.defaultTab,
|
||||
sidebarSections: [...localConfig.value.sidebarSections]
|
||||
}
|
||||
|
||||
saving.value = true
|
||||
|
||||
try {
|
||||
await emit('save', configToSave)
|
||||
} catch (error) {
|
||||
console.error('保存配置失败:', error)
|
||||
Message.error('保存配置失败:' + (error.message || error))
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
emit('save', configToSave)
|
||||
saving.value = false
|
||||
}
|
||||
|
||||
// 重置配置
|
||||
@@ -287,11 +324,59 @@ const handleReset = () => {
|
||||
localConfig.value = {
|
||||
tabs: [...props.config.tabs],
|
||||
visibleTabs: [...props.config.visibleTabs],
|
||||
defaultTab: props.config.defaultTab
|
||||
defaultTab: props.config.defaultTab,
|
||||
sidebarSections: [...(props.config.sidebarSections || ['server', 'favorites', 'help'])]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ========== 侧边栏区块配置 ==========
|
||||
const allSidebarSections = ['server', 'favorites', 'help']
|
||||
const sidebarTitles = { server: '🖥️ 服务器', favorites: '⭐ 收藏夹', help: '📖 帮助' }
|
||||
|
||||
const getSidebarTitle = (key) => sidebarTitles[key] || key
|
||||
|
||||
const hiddenSidebarSections = computed(() => {
|
||||
return allSidebarSections.filter(s => !localConfig.value.sidebarSections.includes(s))
|
||||
})
|
||||
|
||||
const handleSidebarVisibilityChange = (sectionKey, visible) => {
|
||||
if (visible) {
|
||||
if (!localConfig.value.sidebarSections.includes(sectionKey)) {
|
||||
localConfig.value.sidebarSections.push(sectionKey)
|
||||
}
|
||||
} else {
|
||||
localConfig.value.sidebarSections = localConfig.value.sidebarSections.filter(s => s !== sectionKey)
|
||||
}
|
||||
}
|
||||
|
||||
const sidebarDraggedIndex = ref(null)
|
||||
|
||||
const handleSidebarDragStart = (index, event) => {
|
||||
sidebarDraggedIndex.value = index
|
||||
event.dataTransfer.effectAllowed = 'move'
|
||||
event.target.classList.add('dragging')
|
||||
}
|
||||
|
||||
const handleSidebarDragOver = (index, event) => {
|
||||
event.preventDefault()
|
||||
event.dataTransfer.dropEffect = 'move'
|
||||
}
|
||||
|
||||
const handleSidebarDrop = (index, event) => {
|
||||
event.preventDefault()
|
||||
if (sidebarDraggedIndex.value === null || sidebarDraggedIndex.value === index) return
|
||||
const list = [...localConfig.value.sidebarSections]
|
||||
const [removed] = list.splice(sidebarDraggedIndex.value, 1)
|
||||
list.splice(index, 0, removed)
|
||||
localConfig.value.sidebarSections = list
|
||||
}
|
||||
|
||||
const handleSidebarDragEnd = (event) => {
|
||||
event.target.classList.remove('dragging')
|
||||
sidebarDraggedIndex.value = null
|
||||
}
|
||||
|
||||
// 打开版本历史
|
||||
const handleOpenVersionHistory = () => {
|
||||
emit('open-version-history')
|
||||
|
||||
@@ -20,6 +20,7 @@ export interface AppConfig {
|
||||
tabs: TabConfig[]
|
||||
visibleTabs: string[]
|
||||
defaultTab: string
|
||||
sidebarSections: string[]
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -31,7 +32,8 @@ export const useConfigStore = defineStore('config', () => {
|
||||
const appConfig = ref<AppConfig>({
|
||||
tabs: [],
|
||||
visibleTabs: [],
|
||||
defaultTab: 'file-system'
|
||||
defaultTab: 'file-system',
|
||||
sidebarSections: ['server', 'favorites', 'help']
|
||||
})
|
||||
|
||||
const loading = ref(false)
|
||||
@@ -79,7 +81,7 @@ export const useConfigStore = defineStore('config', () => {
|
||||
const result = await GetAppConfig()
|
||||
if (!result.success) throw new Error(result.message)
|
||||
|
||||
const { tabs = [], visibleTabs = [], defaultTab = 'file-system' } = result.data
|
||||
const { tabs = [], visibleTabs = [], defaultTab = 'file-system', sidebarSections } = result.data
|
||||
|
||||
// 一级 Tab 只有文件管理和数据库,其他功能(Markdown、版本历史)不作为独立 Tab
|
||||
const allKeys = ['file-system']
|
||||
@@ -89,10 +91,16 @@ export const useConfigStore = defineStore('config', () => {
|
||||
? visibleTabs.filter(k => allKeys.includes(k))
|
||||
: allKeys
|
||||
|
||||
const defaultSidebar = ['server', 'favorites', 'help']
|
||||
const validSections = ['server', 'favorites', 'help']
|
||||
|
||||
appConfig.value = {
|
||||
tabs: mergedTabs.map(tab => ({ ...tab, visible: mergedVisible.includes(tab.key) })),
|
||||
visibleTabs: mergedVisible,
|
||||
defaultTab: defaultTab || 'file-system'
|
||||
defaultTab: defaultTab || 'file-system',
|
||||
sidebarSections: Array.isArray(sidebarSections)
|
||||
? sidebarSections.filter((s: string) => validSections.includes(s))
|
||||
: defaultSidebar
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载配置失败:', error)
|
||||
@@ -111,7 +119,8 @@ export const useConfigStore = defineStore('config', () => {
|
||||
{ key: 'file-system', title: '文件管理', visible: true, enabled: true }
|
||||
],
|
||||
visibleTabs: ['file-system'],
|
||||
defaultTab: 'file-system'
|
||||
defaultTab: 'file-system',
|
||||
sidebarSections: ['server', 'favorites', 'help']
|
||||
}
|
||||
}
|
||||
|
||||
@@ -125,7 +134,8 @@ export const useConfigStore = defineStore('config', () => {
|
||||
const result = await SaveAppConfig({
|
||||
tabs: config.tabs,
|
||||
visibleTabs: config.visibleTabs,
|
||||
defaultTab: config.defaultTab
|
||||
defaultTab: config.defaultTab,
|
||||
sidebarSections: config.sidebarSections
|
||||
})
|
||||
|
||||
if (!result.success) {
|
||||
@@ -137,7 +147,8 @@ export const useConfigStore = defineStore('config', () => {
|
||||
appConfig.value = {
|
||||
tabs: [...config.tabs],
|
||||
visibleTabs: [...config.visibleTabs],
|
||||
defaultTab: config.defaultTab
|
||||
defaultTab: config.defaultTab,
|
||||
sidebarSections: [...config.sidebarSections]
|
||||
}
|
||||
|
||||
Message.success('配置保存成功')
|
||||
|
||||
@@ -31,6 +31,8 @@ export interface FavoriteFile extends FileItem {
|
||||
addedAt: number
|
||||
/** 置顶时间(时间戳),undefined 表示未置顶 */
|
||||
pinnedAt?: number
|
||||
/** 关联的连接配置 ID,用于打开收藏时自动切换连接 */
|
||||
profileId?: string
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -248,6 +250,8 @@ export interface FileOperationResult {
|
||||
export interface PathHistory {
|
||||
/** 历史记录数组 */
|
||||
paths: string[]
|
||||
/** 每条路径对应的 profileId */
|
||||
profileIds: (string | undefined)[]
|
||||
/** 当前索引 */
|
||||
currentIndex: number
|
||||
}
|
||||
|
||||
48
frontend/src/utils/lrcParser.ts
Normal file
48
frontend/src/utils/lrcParser.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
export interface LrcLine {
|
||||
time: number
|
||||
text: string
|
||||
}
|
||||
|
||||
export interface LrcData {
|
||||
title?: string
|
||||
artist?: string
|
||||
lines: LrcLine[]
|
||||
}
|
||||
|
||||
export function parseLrc(content: string): LrcData {
|
||||
const result: LrcData = { lines: [] }
|
||||
const lines = content.split('\n')
|
||||
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim()
|
||||
if (!trimmed) continue
|
||||
|
||||
// 元信息
|
||||
const metaTitle = trimmed.match(/^\[ti:(.*?)\]$/i)
|
||||
if (metaTitle) { result.title = metaTitle[1].trim(); continue }
|
||||
const metaArtist = trimmed.match(/^\[ar:(.*?)\]$/i)
|
||||
if (metaArtist) { result.artist = metaArtist[1].trim(); continue }
|
||||
|
||||
// 时间标签
|
||||
const timeTags: number[] = []
|
||||
let text = trimmed
|
||||
const tagRegex = /\[(\d{2}):(\d{2})([.:])(\d{2,3})\]/g
|
||||
let m
|
||||
while ((m = tagRegex.exec(trimmed)) !== null) {
|
||||
const min = parseInt(m[1])
|
||||
const sec = parseInt(m[2])
|
||||
const ms = parseInt(m[4].padEnd(3, '0'))
|
||||
timeTags.push(min * 60 + sec + ms / 1000)
|
||||
}
|
||||
|
||||
if (timeTags.length === 0) continue
|
||||
|
||||
text = trimmed.replace(/\[\d{2}:\d{2}[.:]\d{2,3}\]/g, '').trim()
|
||||
for (const t of timeTags) {
|
||||
result.lines.push({ time: t, text })
|
||||
}
|
||||
}
|
||||
|
||||
result.lines.sort((a, b) => a.time - b.time)
|
||||
return result
|
||||
}
|
||||
Reference in New Issue
Block a user