소스 검색

添加网盘的多选删除功能

chendeben 8 달 전
부모
커밋
d2d7db2b84

+ 46 - 0
entry/src/main/cpp/napi_init.cpp

@@ -158,6 +158,18 @@ int64_t ReadInt64(napi_env env, napi_value value, const char *name)
     return result;
 }
 
+bool ReadBool(napi_env env, napi_value value, const char *name)
+{
+    napi_valuetype type = napi_undefined;
+    NapiCheck(napi_typeof(env, value, &type), "Failed to read boolean argument type");
+    if (type != napi_boolean) {
+        throw std::runtime_error(std::string(name) + " must be a boolean");
+    }
+    bool result = false;
+    NapiCheck(napi_get_value_bool(env, value, &result), "Failed to read boolean argument");
+    return result;
+}
+
 napi_value CreateInt64Value(napi_env env, int64_t value)
 {
     napi_value result = nullptr;
@@ -560,6 +572,39 @@ napi_value ReadDirectory(napi_env env, napi_callback_info info)
     }
 }
 
+napi_value DeleteEntry(napi_env env, napi_callback_info info)
+{
+    try {
+        size_t argc = 3;
+        napi_value args[3] = {nullptr};
+        NapiCheck(napi_get_cb_info(env, info, &argc, args, nullptr, nullptr), "Failed to get arguments for deleteEntry");
+        if (argc < 3) {
+            throw std::runtime_error("deleteEntry requires treeId, path and isDirectory flag");
+        }
+        int64_t treeId = ReadInt64(env, args[0], "treeId");
+        std::string path = ReadString(env, args[1], "path", false);
+        bool isDirectory = ReadBool(env, args[2], "isDirectory");
+        auto tree = RequireTree(treeId);
+        auto session = tree->session.lock();
+        auto client = session ? session->client.lock() : nullptr;
+        if (!client || client->ctx == nullptr) {
+            throw std::runtime_error("SMB client context is not available");
+        }
+        Ensure(tree->connected, "SMB tree is not connected");
+        std::string normalized = NormalizeRemotePath(path);
+        Ensure(!normalized.empty(), "path must not be empty");
+        smb2_context *ctx = client->ctx;
+        int rc = isDirectory ? smb2_rmdir(ctx, normalized.c_str()) : smb2_unlink(ctx, normalized.c_str());
+        if (rc != 0) {
+            throw std::runtime_error(BuildSmbErrorMessage(ctx, "Failed to delete remote entry"));
+        }
+        return CreateUndefined(env);
+    } catch (const std::exception &error) {
+        napi_throw_error(env, nullptr, error.what());
+        return nullptr;
+    }
+}
+
 napi_value DownloadSmbFile(napi_env env, napi_callback_info info)
 {
     try {
@@ -801,6 +846,7 @@ static napi_value Init(napi_env env, napi_value exports)
         {"connectTree", nullptr, ConnectTree, nullptr, nullptr, nullptr, napi_default, nullptr},
         {"disconnectTree", nullptr, DisconnectTree, nullptr, nullptr, nullptr, napi_default, nullptr},
         {"readDirectory", nullptr, ReadDirectory, nullptr, nullptr, nullptr, napi_default, nullptr},
+        {"deleteEntry", nullptr, DeleteEntry, nullptr, nullptr, nullptr, napi_default, nullptr},
         {"downloadSmbFile", nullptr, DownloadSmbFile, nullptr, nullptr, nullptr, napi_default, nullptr},
         {"readSmbFileRange", nullptr, ReadSmbFileRange, nullptr, nullptr, nullptr, napi_default, nullptr}
     };

+ 2 - 0
entry/src/main/cpp/types/libentry/Index.d.ts

@@ -15,6 +15,8 @@ export interface NativeModule {
   disconnectTree(treeId: number): void;
   readDirectory(treeId: number, path: string): NativeDirectoryEntry[];
   downloadSmbFile(host: string, share: string, username: string, password: string, domain: string | undefined, remotePath: string, localPath: string): void;
+  deleteEntry(treeId: number, path: string, isDirectory: boolean): void;
+  readSmbFileRange(host: string, share: string, username: string, password: string, domain: string | undefined, remotePath: string, offset: number, length: number): ArrayBuffer;
 }
 
 declare const libentry: NativeModule;

+ 39 - 0
entry/src/main/ets/common/network/BaiduPanClient.ets

@@ -108,6 +108,10 @@ export interface BaiduFileMetaResponse {
   list: BaiduFileMeta[];
 }
 
+interface BaiduFileManagerResponse {
+  errno: number;
+}
+
 async function parseJson<T>(payload: string): Promise<T> {
   try {
     return JSON.parse(payload) as T;
@@ -202,6 +206,41 @@ export async function fetchFileMetas(accessToken: string, fsIds: string[]): Prom
   return parsed.list ?? [];
 }
 
+export async function deleteFiles(accessToken: string, paths: string[]): Promise<void> {
+  if (!paths || paths.length === 0) {
+    return;
+  }
+  // 根据百度网盘API文档,opera参数应放在URL中,filelist放在RequestBody中
+  const requestUrl = `${BaiduConstants.PAN_BASE}/rest/2.0/xpan/file?method=filemanager&access_token=${accessToken}&opera=delete`;
+  const httpRequest = http.createHttp();
+  // filelist格式为JSON数组字符串,delete操作直接使用路径数组
+  const body = `async=0&filelist=${encodeURIComponent(JSON.stringify(paths))}`;
+  Logger.info(TAG, `百度网盘删除请求: ${requestUrl}`);
+  Logger.info(TAG, `百度网盘删除body: ${body}`);
+  const options: http.HttpRequestOptions = {
+    method: http.RequestMethod.POST,
+    connectTimeout: 10000,
+    readTimeout: 10000,
+    expectDataType: http.HttpDataType.STRING,
+    header: {
+      'User-Agent': BaiduConstants.USER_AGENT,
+      'Content-Type': 'application/x-www-form-urlencoded'
+    },
+    extraData: body
+  };
+  try {
+    const response = await httpRequest.request(requestUrl, options);
+    const payload = parseResponseBody(response);
+    Logger.info(TAG, `百度网盘删除响应: ${payload}`);
+    const parsed = await parseJson<BaiduFileManagerResponse>(payload);
+    if (parsed.errno !== 0) {
+      throw new Error(`删除失败 errno=${parsed.errno}`);
+    }
+  } finally {
+    httpRequest.destroy();
+  }
+}
+
 export function appendAccessTokenToDlink(dlink: string, accessToken: string): string {
   if (!dlink) {
     return '';

+ 20 - 0
entry/src/main/ets/common/network/SmbBridge.ets

@@ -12,6 +12,11 @@ export interface SmbListOptions extends SmbConnectionOptions {
   path?: string;
 }
 
+export interface SmbDeleteOptions extends SmbConnectionOptions {
+  path: string;
+  isDirectory?: boolean;
+}
+
 export interface SmbDirectoryEntry {
   name: string;
   isDirectory: boolean;
@@ -51,6 +56,12 @@ export class NativeSambaTree {
     return bridge.readDirectory(this.treeId, normalized) ?? [];
   }
 
+  deleteEntry(path?: string, isDirectory: boolean = false): void {
+    this.ensureOpen();
+    const normalized = this.normalizePath(path);
+    bridge.deleteEntry(this.treeId, normalized, isDirectory);
+  }
+
   async close(): Promise<void> {
     if (this.closed) {
       return;
@@ -144,3 +155,12 @@ export async function listSmbDirectory(options: SmbListOptions): Promise<SmbDire
     size: entry.size ?? 0
   }));
 }
+
+export async function deleteSmbEntry(options: SmbDeleteOptions): Promise<void> {
+  if (!options.path || options.path.length === 0) {
+    throw new Error('Remote path must not be empty');
+  }
+  await withSmbTree(options, async (tree: NativeSambaTree) => {
+    tree.deleteEntry(options.path, options.isDirectory === true);
+  });
+}

+ 153 - 2
entry/src/main/ets/common/util/RemoteDriveManager.ets

@@ -17,13 +17,13 @@ import { CommonConstants } from '../constants/CommonConstants';
 import { GlobalContext, PreferencesUtil } from '@pura/harmony-utils';
 import { MusicInfo, parseMusicFileName, Utility } from './Utility';
 import { RemoteDriveType } from '../enums/RemoteDriveType';
-import { listSmbDirectory, SmbDirectoryEntry } from '../network/SmbBridge';
+import { deleteSmbEntry, listSmbDirectory, SmbDirectoryEntry } from '../network/SmbBridge';
 import { NavidromeApi, NavidromeAlbumDetail, NavidromeArtist, NavidromeArtistDetail, NavidromeSong } from '../network/NavidromeApi';
 import { WebDavUrlUtil } from './WebDavUrlUtil';
 import MediaTable from './MediaTable';
 import { FtpClient, FileInfo as FtpEntryInfo, StringEncoding, AccessOptions } from '@liuzhosoft/ftp4h';
 import { BaiduConstants } from '../constants/BaiduConstants';
-import { appendAccessTokenToDlink, BaiduListEntry, BaiduFileMeta, fetchFileMetas as fetchBaiduFileMetas, listDirectory as listBaiduDirectory, refreshAccessToken as refreshBaiduAccessToken } from '../network/BaiduPanClient';
+import { appendAccessTokenToDlink, BaiduListEntry, BaiduFileMeta, deleteFiles as deleteBaiduFiles, fetchFileMetas as fetchBaiduFileMetas, listDirectory as listBaiduDirectory, refreshAccessToken as refreshBaiduAccessToken } from '../network/BaiduPanClient';
 import { ServerLogUtil } from './ServerLogUtil';
 
 const TAG = 'heanup RemoteDriveManager';
@@ -1180,6 +1180,157 @@ export class RemoteDriveManager {
     Logger.info(TAG, `百度预取任务已调度, account=${account.id}, 文件数=${entries.length}`);
   }
 
+  public async deleteRemoteSongs(account: WebDavAccount, songs: VideoItem[]): Promise<void> {
+    if (!account) {
+      throw new Error('请先选择账户');
+    }
+    if (!songs || songs.length === 0) {
+      return;
+    }
+    const deletable = songs.filter(item => item && item.filePath && item.filePath.length > 0);
+    if (deletable.length === 0) {
+      return;
+    }
+    if (account.webType === RemoteDriveType.WebDav) {
+      await this.deleteWebDavSongs(account, deletable);
+    } else if (account.webType === RemoteDriveType.Smb) {
+      await this.deleteSmbSongs(account, deletable);
+    } else if (account.webType === RemoteDriveType.Ftp) {
+      await this.deleteFtpSongs(account, deletable);
+    } else if (account.webType === RemoteDriveType.Baidu) {
+      await this.deleteBaiduSongs(account, deletable);
+    } else {
+      throw new Error('当前账户类型暂不支持删除');
+    }
+  }
+
+  private resolveWebDavDeletePath(song: VideoItem): string {
+    if (song.remote_rel_path && song.remote_rel_path.length > 0) {
+      return this.normalizeRemoteHref(song.remote_rel_path);
+    }
+    if (song.filePath) {
+      const relative = WebDavUrlUtil.extractRelativePath(song.filePath);
+      if (relative) {
+        return this.normalizeRemoteHref(relative);
+      }
+    }
+    throw new Error(`无法确定 WebDAV 文件路径: ${song.name}`);
+  }
+
+  private async deleteWebDavSongs(account: WebDavAccount, songs: VideoItem[]): Promise<void> {
+    const host = account.isUseLocalHost && account.localHost ? account.localHost : account.host;
+    if (!host || host.length === 0) {
+      throw new Error('WebDAV账户缺少服务器地址');
+    }
+    for (let i = 0; i < songs.length; i++) {
+      const song = songs[i];
+      const relativePath = this.resolveWebDavDeletePath(song);
+      try {
+        await this.rcpSocket.RcpSendDelete(
+          host,
+          account.port,
+          account.account,
+          account.password,
+          relativePath,
+          account.enableHttps
+        );
+      } catch (error) {
+        const err = error as Error;
+        throw new Error(`删除 ${song.name} 失败: ${err.message}`);
+      }
+    }
+  }
+
+  private resolveSmbSongPath(song: VideoItem, account: WebDavAccount): string {
+    if (song.remote_rel_path && song.remote_rel_path.length > 0) {
+      return this.normalizeSmbRelativePath(song.remote_rel_path, account.smbShare);
+    }
+    if (song.filePath) {
+      const withoutScheme = song.filePath.replace(/^smb:\/\//i, '').replace(/^[^/]+/i, '');
+      return this.normalizeSmbRelativePath(withoutScheme, account.smbShare);
+    }
+    throw new Error(`无法确定SMB路径: ${song.name}`);
+  }
+
+  private async deleteSmbSongs(account: WebDavAccount, songs: VideoItem[]): Promise<void> {
+    if (!account.smbShare || account.smbShare.length === 0) {
+      throw new Error('SMB账户缺少共享名称');
+    }
+    const host = account.isUseLocalHost && account.localHost ? account.localHost : account.host;
+    if (!host || host.length === 0) {
+      throw new Error('SMB账户缺少服务器地址');
+    }
+    for (let i = 0; i < songs.length; i++) {
+      const song = songs[i];
+      const relativePath = this.resolveSmbSongPath(song, account);
+      if (!relativePath || relativePath.length === 0 || relativePath === '/') {
+        throw new Error(`无法确定SMB文件路径: ${song.name}`);
+      }
+      try {
+        await deleteSmbEntry({
+          host,
+          share: account.smbShare,
+          username: account.account,
+          password: account.password,
+          domain: account.smbDomain,
+          path: relativePath,
+          isDirectory: false
+        });
+      } catch (error) {
+        const err = error as Error;
+        throw new Error(`删除 ${song.name} 失败: ${err.message}`);
+      }
+    }
+  }
+
+  private resolveFtpSongPath(song: VideoItem): string {
+    const target = song.remote_rel_path && song.remote_rel_path.length > 0 ? song.remote_rel_path : song.filePath;
+    if (!target || target.length === 0) {
+      throw new Error(`无法确定FTP路径: ${song.name}`);
+    }
+    return this.normalizeFullPath(target);
+  }
+
+  private async deleteFtpSongs(account: WebDavAccount, songs: VideoItem[]): Promise<void> {
+    const options = this.buildFtpAccessOptions(account);
+    const client = new FtpClient();
+    try {
+      await client.access(options);
+      for (let i = 0; i < songs.length; i++) {
+        const song = songs[i];
+        const remotePath = this.resolveFtpSongPath(song);
+        const success = await client.remove(remotePath);
+        if (!success) {
+          throw new Error(`删除 ${song.name} 失败`);
+        }
+      }
+    } finally {
+      await client.close();
+    }
+  }
+
+  private async deleteBaiduSongs(account: WebDavAccount, songs: VideoItem[]): Promise<void> {
+    const accessToken = await this.ensureBaiduAccessToken(account);
+    const paths: string[] = [];
+    for (let i = 0; i < songs.length; i++) {
+      const path = songs[i].remote_rel_path;
+      if (!path || path.length === 0) {
+        throw new Error(`无法确定百度网盘路径: ${songs[i].name}`);
+      }
+      paths.push(path);
+    }
+    const batchSize = 20;
+    for (let i = 0; i < paths.length; i += batchSize) {
+      const batch = paths.slice(i, i + batchSize);
+      try {
+        await deleteBaiduFiles(accessToken, batch);
+      } catch (error) {
+        const err = error as Error;
+        throw new Error(`删除文件失败: ${err.message}`);
+      }
+    }
+  }
+
   private scheduleBaiduPrefetch(account: WebDavAccount, accessToken: string, entries: BaiduListEntry[]): void {
     const fileEntries = entries.filter(entry => entry.isdir === 0 && this.isAudioFile(entry.server_filename));
     if (fileEntries.length === 0) {

+ 14 - 14
entry/src/main/ets/common/util/Utility.ets

@@ -582,24 +582,24 @@ export class Utility {
 
 
 
-  static async getFilePixelMapBig(uri:string){
-    let pixelMap:image.PixelMap|undefined = undefined
-    if(Utility.isMusicByExtension(uri)){
-      pixelMap = await Utility.getFetchMetadataFromFdSrcByPromise(uri)
-    }else{
-      pixelMap = await Utility.getFetchFrameByTime(uri)
+  static async getFilePixelMapBig(uri: string): Promise<string | undefined> {
+    let pixelMap: image.PixelMap | undefined = undefined;
+    if (Utility.isMusicByExtension(uri)) {
+      pixelMap = await Utility.getFetchMetadataFromFdSrcByPromise(uri);
+    } else {
+      pixelMap = await Utility.getFetchFrameByTime(uri);
     }
-    return  ImageUtil.pixelMapToBase64StrBig(pixelMap)
+    return pixelMap ? ImageUtil.pixelMapToBase64Str(pixelMap) : undefined;
   }
 
-  static async getFilePixelMap(uri:string){
-    let pixelMap:image.PixelMap|undefined = undefined
-    if(Utility.isMusicByExtension(uri)){
-      pixelMap = await Utility.getFetchMetadataFromFdSrcByPromise(uri)
-    }else{
-      pixelMap = await Utility.getFetchFrameByTime(uri)
+  static async getFilePixelMap(uri: string): Promise<string | undefined> {
+    let pixelMap: image.PixelMap | undefined = undefined;
+    if (Utility.isMusicByExtension(uri)) {
+      pixelMap = await Utility.getFetchMetadataFromFdSrcByPromise(uri);
+    } else {
+      pixelMap = await Utility.getFetchFrameByTime(uri);
     }
-    return  ImageUtil.pixelMapToBase64Str(pixelMap)
+    return pixelMap ? ImageUtil.pixelMapToBase64Str(pixelMap) : undefined;
   }
 
 

+ 233 - 27
entry/src/main/ets/pages/WebDavMainPage.ets

@@ -3,7 +3,7 @@ import { WebDavAccount } from '../viewmodel/WebDavAccount';
 import { Song } from '../viewmodel/Song';
 import { RemoteDriveManagerStates } from '../common/enums/RemoteDriveManagerStates';
 import Logger from '../common/util/Logger';
-import { promptAction, SymbolGlyphModifier,router, window } from '@kit.ArkUI';
+import { promptAction, SymbolGlyphModifier, router, window } from '@kit.ArkUI';
 import { CommonConstants } from '../common/constants/CommonConstants';
 import { VideoItem } from '../viewmodel/VideoItem';
 import { GlobalContext } from '../common/util/GlobalContext';
@@ -102,6 +102,10 @@ export struct WebDavMainPage {
   @State isLongNameRoLL: boolean = true//长歌名滚动
   @State sortType: number = 0 //默认排序方式
   @State listRefreshKey: number = 0 // 列表刷新标识
+  @State isMultiSelect: boolean = false
+  @State selectedSongs: VideoItem[] = []
+  @State isAllSelected: boolean = false
+  @State isDeletingSelection: boolean = false
   private readonly metadataUpdateEvent: emitter.InnerEvent = { eventId: EventConstants.EVENT_WEBDAV_METADATA_UPDATED };
 
   async onSwitchAccount(){
@@ -109,7 +113,8 @@ export struct WebDavMainPage {
     this.songs = [];
     this.visibleFoldersState = [];
     this.updateListData(this.songs)
-    
+    this.exitMultiSelect();
+
     // 注意:不再需要清空全局上下文,因为LocalMusic已改用实例变量缓存
     // 当新账户加载时,新的认证信息会自动覆盖旧的
     Logger.info(TAG, 'heanup 切换账户,准备加载新账户文件');
@@ -178,6 +183,131 @@ export struct WebDavMainPage {
     }
   }
 
+  private isSongSelected(song: VideoItem): boolean {
+    return this.selectedSongs.some(item => item.filePath === song.filePath);
+  }
+
+  private enterMultiSelect(song: VideoItem): void {
+    if (!this.isMultiSelect) {
+      this.isMultiSelect = true;
+      this.selectedSongs = [song];
+      this.isAllSelected = this.selectedSongs.length === this.songs.length && this.songs.length > 0;
+      return;
+    }
+    if (!this.isSongSelected(song)) {
+      this.toggleSongSelection(song);
+    }
+  }
+
+  private toggleSongSelection(song: VideoItem): void {
+    if (!this.isMultiSelect) {
+      this.isMultiSelect = true;
+    }
+    const exists = this.isSongSelected(song);
+    if (exists) {
+      const next = this.selectedSongs.filter(item => item.filePath !== song.filePath);
+      this.selectedSongs = next;
+      this.isAllSelected = next.length > 0 && next.length === this.songs.length;
+      if (next.length === 0) {
+        this.exitMultiSelect();
+      }
+    } else {
+      const next = [...this.selectedSongs, song];
+      this.selectedSongs = next;
+      this.isAllSelected = next.length === this.songs.length && this.songs.length > 0;
+    }
+  }
+
+  private handleCheckboxSelection(song: VideoItem, checked: boolean): void {
+    if (!this.isMultiSelect) {
+      this.isMultiSelect = true;
+    }
+    const exists = this.isSongSelected(song);
+    if (checked && !exists) {
+      this.toggleSongSelection(song);
+    } else if (!checked && exists) {
+      this.toggleSongSelection(song);
+    }
+  }
+
+  private toggleSelectAll(): void {
+    if (this.isAllSelected) {
+      this.selectedSongs = [];
+      this.isAllSelected = false;
+      return;
+    }
+    if (!this.isMultiSelect) {
+      this.isMultiSelect = true;
+    }
+    this.selectedSongs = this.songs.slice();
+    this.isAllSelected = this.selectedSongs.length > 0 && this.selectedSongs.length === this.songs.length;
+  }
+
+  private exitMultiSelect(): void {
+    this.isMultiSelect = false;
+    this.selectedSongs = [];
+    this.isAllSelected = false;
+  }
+
+  private syncSelectionAfterRefresh(): void {
+    if (!this.isMultiSelect) {
+      return;
+    }
+    const currentKeys: Set<string> = new Set(this.songs.map(item => item.filePath));
+    const filtered = this.selectedSongs.filter(item => currentKeys.has(item.filePath));
+    if (filtered.length !== this.selectedSongs.length) {
+      this.selectedSongs = filtered;
+    }
+    this.isAllSelected = filtered.length > 0 && filtered.length === this.songs.length;
+    if (filtered.length === 0) {
+      this.exitMultiSelect();
+    }
+  }
+
+  private confirmDeleteSelected(): void {
+    if (!this.selectedAccount) {
+      this.getUIContext().getPromptAction().showToast({ message: `请先选择${getRemoteDriveAccountLabel()}` });
+      return;
+    }
+    if (!this.isMultiSelect || this.selectedSongs.length === 0) {
+      this.getUIContext().getPromptAction().showToast({ message: '请先选择要删除的文件' });
+      return;
+    }
+    this.getUIContext().showAlertDialog({
+      title: '删除文件',
+      message: `确定删除选中的${this.selectedSongs.length}个文件吗?`,
+      primaryButton: {
+        value: '取消',
+        action: () => {}
+      },
+      secondaryButton: {
+        value: '删除',
+        fontColor: Color.Red,
+        action: () => {
+          void this.performDeleteSelected();
+        }
+      }
+    });
+  }
+
+  private async performDeleteSelected(): Promise<void> {
+    if (!this.selectedAccount || this.selectedSongs.length === 0) {
+      return;
+    }
+    this.isDeletingSelection = true;
+    try {
+      await this.webdavManager.deleteRemoteSongs(this.selectedAccount, this.selectedSongs.slice());
+      await this.webdavManager.loadFilesInfoFromAccount(this.selectedAccount, this.webdavManager.currentPath);
+      this.exitMultiSelect();
+      this.getUIContext().getPromptAction().showToast({ message: '删除成功' });
+    } catch (error) {
+      const err = error as Error;
+      this.getUIContext().getPromptAction().showToast({ message: `删除失败: ${err.message}` });
+    } finally {
+      this.isDeletingSelection = false;
+    }
+  }
+
   private buildSongMetaLine(song: VideoItem): string {
     const parts: string[] = [];
     if (StrUtil.isNotEmpty(song.duration)) {
@@ -238,6 +368,7 @@ export struct WebDavMainPage {
         // 更新可见文件夹列表
         this.updateVisibleFolders();
         this.breadcrumbs = this.webdavManager.getBreadcrumbs();
+        this.syncSelectionAfterRefresh();
 
         // promptAction.showToast({
         //   message: '加载成功: ' + this.webDavFiles.filter(f => f.isDirectory).length + '个文件夹, ' + this.songs.length + '首歌曲'
@@ -945,7 +1076,7 @@ export struct WebDavMainPage {
 
       }
     }
-    .padding({ top: this.topSafeHeight+10, left: 10, right: 10,bottom:2 })
+    .padding({ top: this.topSafeHeight + 10, left: 10, right: 10, bottom: 2 })
     .width('100%')
   }
 
@@ -994,22 +1125,30 @@ export struct WebDavMainPage {
       } else {
         this.buildContentView();
       }
+
+      // 顶部区域:标题栏在上,面包屑在下
       Column() {
         this.topTitleBar()
         this.breaker()
       }
+      .width('100%')
+      .position({ top: 0, left: 0 })
+      .backgroundColor($r('app.color.start_window_background'))
 
+      // 多选操作条,位于播放条之上(仅在多选模式下显示)
+      if (this.isMultiSelect || this.isDeletingSelection) {
+        this.buildSelectionOverlay()
+      }
     }
-    .alignContent(Alignment.Top)
     .width('100%')
     .height('100%')
+    .alignContent(Alignment.Bottom)
   }
 
   @Builder
   breaker() {
-    // 加载按钮和面包屑导航
+    // 面包屑导航
     Column({ space: 8 }) {
-
       // 面包屑导航
       if (this.webdavManager.currentPath !== '') {
         Row({ space: 8 }) {
@@ -1058,7 +1197,7 @@ export struct WebDavMainPage {
 
     }
     .width('100%')
-    .padding({ left: 12, right: 12,top: 10,bottom: 5 })
+    .padding({ left: 12, right: 12, top: 8, bottom: 5 })
   }
 
 
@@ -1133,7 +1272,7 @@ export struct WebDavMainPage {
           }, (item: VideoItem) =>  item.filePath + '_' + this.listRefreshKey)
         }
         .scrollBar(BarState.Off)
-        .contentStartOffset(this.topSafeHeight + 80)
+        .contentStartOffset(this.topSafeHeight + 110)
         .contentEndOffset(this.bottomSafeHeight+70)
         .layoutWeight(1)
         .margin({ top: 4 })
@@ -1157,6 +1296,65 @@ export struct WebDavMainPage {
 
   }
 
+  @Builder
+  private buildMultiSelectBar() {
+    Row({ space: 12 }) {
+      Button(this.isAllSelected ? '反选' : '全选', { type: ButtonType.Circle, stateEffect: true })
+        .width(60)
+        .height(40)
+        .fontSize(12)
+        .backgroundColor(this.themeColor)
+        .clickEffect({ level: ClickEffectLevel.MIDDLE, scale: 0.5 })
+        .onClick(() => this.toggleSelectAll())
+
+      Button('删除', { type: ButtonType.Circle, stateEffect: true })
+        .width(60)
+        .height(40)
+        .fontSize(12)
+        .backgroundColor(this.themeColor)
+        .clickEffect({ level: ClickEffectLevel.MIDDLE, scale: 0.5 })
+        .enabled(this.selectedSongs.length > 0 && !this.isDeletingSelection)
+        .onClick(() => this.confirmDeleteSelected())
+
+      Button('取消', { type: ButtonType.Circle, stateEffect: true })
+        .width(60)
+        .height(40)
+        .fontSize(12)
+        .backgroundColor(this.themeColor)
+        .clickEffect({ level: ClickEffectLevel.MIDDLE, scale: 0.5 })
+        .onClick(() => this.exitMultiSelect())
+    }
+    .width('100%')
+    .padding({ bottom: this.bottomSafeHeight + 10, top: 12 })
+    .justifyContent(FlexAlign.Center)
+    .visibility(this.isMultiSelect ? Visibility.Visible : Visibility.None)
+    .opacity(this.isMultiSelect ? 1 : 0)
+    .animation({ duration: 300, curve: 'ease-in-out' })
+  }
+
+  @Builder
+  private buildSelectionOverlay() {
+    Column() {
+      Row({ space: 10 }) {
+        LoadingProgress()
+          .width(22)
+          .height(22)
+          .color(this.themeColor)
+        Text('正在删除...')
+          .fontSize(14)
+          .fontColor($r('app.color.index_tab_font_color'))
+      }
+      .padding({ top: 12, bottom: 6 })
+      .justifyContent(FlexAlign.Center)
+      .visibility(this.isDeletingSelection ? Visibility.Visible : Visibility.None)
+
+      this.buildMultiSelectBar()
+    }
+    .width('100%')
+    .padding({ left: 12, right: 12, bottom: this.bottomSafeHeight + 120 })
+    .backgroundColor($r('app.color.start_window_background'))
+  }
+
   // 文件夹列表项
   @Builder
   buildFolderItem(folder: FileInfo) {
@@ -1219,7 +1417,11 @@ export struct WebDavMainPage {
           })
           .margin({ left: 8 })
           .onClick(() => {
-            this.playSong(song, index, true);
+            if (this.isMultiSelect) {
+              this.toggleSongSelection(song);
+            } else {
+              this.playSong(song, index, true);
+            }
           })
 
         // 歌曲信息
@@ -1254,22 +1456,19 @@ export struct WebDavMainPage {
         .padding({ right: 20 })
 
         Column() {
-          //多选按钮的Checkbox 先注释掉
-          // Checkbox({ name: 'checkbox' + index })
-          //   .select(this.selectedFiles.some(x => x.filePath === item.filePath))
-          //   .selectedColor(this.themeColor)
-          //   .shape(CheckBoxShape.CIRCLE)
-          //   .opacity(this.isMultiSelect ? 1 : 0)
-          //   .animation({
-          //     duration: 666,
-          //     curve: 'Smooth' // 可选动画曲线
-          //   })
-          //   .visibility(this.isMultiSelect ? Visibility.Visible : Visibility.None)
-          //   .onChange((checked: boolean) => this.handleFileSelection(item, checked))
-          //   .margin({ left: 20, top: 8, bottom: 8,right:18 })
-          //   .width(22)
-          //   .height(22)
-
+          Checkbox({ name: 'checkbox_' + index })
+            .select(this.isSongSelected(song))
+            .selectedColor(this.themeColor)
+            .opacity(this.isMultiSelect ? 1 : 0)
+            .animation({
+              duration: 300,
+              curve: 'Smooth'
+            })
+            .visibility(this.isMultiSelect ? Visibility.Visible : Visibility.None)
+            .onChange((checked: boolean) => this.handleCheckboxSelection(song, checked))
+            .margin({ left: 10, top: 8, bottom: 8, right: 12 })
+            .width(22)
+            .height(22)
 
           ImageAnimator()
             .images(CommonConstants.IMAGE_FRAME_INFO)// 动画数组
@@ -1279,7 +1478,7 @@ export struct WebDavMainPage {
             .fillMode(FillMode.Forwards)
             .width(18)
             .margin({ right: 12, top: 8, bottom: 8 })
-            .visibility(this.currentSong?.filePath==song.filePath ?  Visibility.Visible :
+            .visibility(this.currentSong?.filePath==song.filePath ? (this.isMultiSelect ? Visibility.None : Visibility.Visible) :
               Visibility.None)
             .height(18)
             .iterations(-1) // 播放次数
@@ -1291,7 +1490,14 @@ export struct WebDavMainPage {
     .clickEffect({ level: ClickEffectLevel.MIDDLE, scale: 0.8 })
     .backgroundColor(Color.Transparent)
     .onClick(() => {
-      this.playSong(song, index);
+      if (this.isMultiSelect) {
+        this.toggleSongSelection(song);
+      } else {
+        this.playSong(song, index);
+      }
     })
+    .gesture(LongPressGesture().onAction(() => {
+      this.enterMultiSelect(song);
+    }))
   }
 }