Private
Public Access
1
0

新增: SFTP直连+网站预览+OSS区域嗅探+热键+BGM播放

This commit is contained in:
2026-05-12 11:06:28 +08:00
parent 545d7a864d
commit 2a363fd729
62 changed files with 6687 additions and 660 deletions

View File

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

View File

@@ -7,6 +7,7 @@ export {
};
export {
BgmPlaylistItem,
OssConnectRequest,
OssRenamePathRequest,
RenamePathRequest,

View File

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

View File

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

View File

@@ -30,10 +30,5 @@ export function getFileServerBaseURL(): string {
return _cachedURL || FALLBACK_URL
}
/** 获取带 /localfs 后缀的完整 base */
export function getLocalFsBaseURL(): string {
return `${getFileServerBaseURL()}/localfs`
}
/** 启动时自动初始化 */
initFileServerURL().catch(() => {})

View File

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

View File

@@ -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用于直接预览 */

View File

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

View File

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

View File

@@ -116,4 +116,8 @@ export class WailsTransport implements FsTransport {
async emptyRecycleBin(): Promise<void> {
await EmptyRecycleBin()
}
async downloadForPreview(path: string): Promise<string> {
return path
}
}

View File

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

View File

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

View File

@@ -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]
}
// 播放列表(持久化到 SQLiteurl 运行时生成)
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>

View File

@@ -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/localfshtmlPreviewUrl 会替换为 html-preview
return base.replace(/\/$/, '') + '/api/v1/proxy/localfs'
}
// HTML 预览 URLOSS 需要先下载到本地再预览
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>

View File

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

View File

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

View File

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

View File

@@ -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('已恢复原始内容')
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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('配置保存成功')

View File

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

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