Pārlūkot izejas kodu

优化发现页跳转的动画效果
缓存管理实现

onecold 4 mēneši atpakaļ
vecāks
revīzija
59005d6394

+ 337 - 0
entry/src/main/ets/common/util/CacheManageService.ets

@@ -0,0 +1,337 @@
+import { common } from '@kit.AbilityKit'
+import { fileIo } from '@kit.CoreFileKit'
+import { AppUtil, FileUtil, StrUtil } from '@pura/harmony-utils'
+import Logger from './Logger'
+
+const TAG: string = 'CacheManageService'
+
+export interface CacheDirectoryInfo {
+  key: string
+  title: string
+  path: string
+  removable: boolean
+  description: string
+  size: number
+}
+
+export interface CacheBrowserItem {
+  path: string
+  name: string
+  isDirectory: boolean
+  size: number
+  modifiedTime: number
+}
+
+type ClearStrategy = 'self' | 'children'
+
+interface CacheRule {
+  key: string
+  title: string
+  path: string
+  removable: boolean
+  description: string
+  clearStrategy: ClearStrategy
+}
+
+class CacheManageService {
+  private static instance: CacheManageService
+  private context?: common.Context
+
+  static getInstance(): CacheManageService {
+    if (!CacheManageService.instance) {
+      CacheManageService.instance = new CacheManageService()
+    }
+    return CacheManageService.instance
+  }
+
+  init(context: common.Context): void {
+    const filesDir: string = this.normalizePath(context.filesDir ?? '')
+    const cacheDir: string = this.normalizePath(context.cacheDir ?? '')
+    if (filesDir.length <= 0 && cacheDir.length <= 0) {
+      Logger.warn(TAG, '忽略缓存服务初始化,filesDir 和 cacheDir 都为空')
+      return
+    }
+    this.context = context
+  }
+
+  private ensureContext(): common.Context {
+    if (!this.context) {
+      const appContext: common.Context = AppUtil.getContext() as common.Context
+      if (!appContext) {
+        throw new Error('应用上下文未初始化')
+      }
+      this.context = appContext
+    }
+    return this.context
+  }
+
+  private normalizePath(path: string): string {
+    if (StrUtil.isEmpty(path)) {
+      return ''
+    }
+    if (path.length > 1 && path.endsWith(FileUtil.separator)) {
+      return path.substring(0, path.length - 1)
+    }
+    return path
+  }
+
+  private buildPath(basePath: string, childName: string): string {
+    const safeBasePath: string = this.normalizePath(basePath)
+    if (safeBasePath.length === 0) {
+      return childName
+    }
+    return `${safeBasePath}${FileUtil.separator}${childName}`
+  }
+
+  private buildCacheRules(): Array<CacheRule> {
+    const context: common.Context = this.ensureContext()
+    const filesDir: string = this.normalizePath(context.filesDir ?? '')
+    const cacheDir: string = this.normalizePath(context.cacheDir ?? '')
+    const rules: Array<CacheRule> = []
+
+    if (filesDir.length > 0) {
+      rules.push({
+        key: 'remote_cache',
+        title: '网盘',
+        path: this.buildPath(filesDir, 'remote_cache'),
+        removable: true,
+        description: '远程歌曲缓存',
+        clearStrategy: 'self'
+      })
+      rules.push({
+        key: 'remote_thumbs',
+        title: '缩略图',
+        path: this.buildPath(filesDir, 'remote_thumbs'),
+        removable: true,
+        description: '网盘列表缩略图缓存',
+        clearStrategy: 'self'
+      })
+    }
+
+    if (cacheDir.length > 0) {
+      rules.push({
+        key: 'system_cache',
+        title: '系统',
+        path: cacheDir,
+        removable: true,
+        description: '应用临时缓存',
+        clearStrategy: 'children'
+      })
+    }
+
+    return rules
+  }
+
+  private isInsideRoot(rootPath: string, candidatePath: string): boolean {
+    const normalizedRoot: string = this.normalizePath(rootPath)
+    const normalizedCandidate: string = this.normalizePath(candidatePath)
+    if (normalizedRoot.length === 0 || normalizedCandidate.length === 0) {
+      return false
+    }
+    if (normalizedRoot === normalizedCandidate) {
+      return true
+    }
+    return normalizedCandidate.startsWith(`${normalizedRoot}${FileUtil.separator}`)
+  }
+
+  private isAllowedPath(candidatePath: string): boolean {
+    const rules: Array<CacheRule> = this.buildCacheRules()
+    for (let i = 0; i < rules.length; i++) {
+      if (this.isInsideRoot(rules[i].path, candidatePath)) {
+        return true
+      }
+    }
+    return false
+  }
+
+  private normalizeBrowsePath(path: string): string {
+    const normalizedPath: string = this.normalizePath(path)
+    if (normalizedPath.length === 0) {
+      throw new Error('目标路径为空')
+    }
+    if (!this.isAllowedPath(normalizedPath)) {
+      throw new Error('目标路径不在缓存管理范围内')
+    }
+    return normalizedPath
+  }
+
+  private ensureDir(path: string): void {
+    if (StrUtil.isEmpty(path) || FileUtil.accessSync(path)) {
+      return
+    }
+    fileIo.mkdirSync(path)
+  }
+
+  private async statPath(path: string): Promise<fileIo.Stat | null> {
+    try {
+      return await fileIo.stat(path)
+    } catch (_error) {
+      return null
+    }
+  }
+
+  private async calcPathSize(path: string): Promise<number> {
+    const statInfo: fileIo.Stat | null = await this.statPath(path)
+    if (!statInfo) {
+      return 0
+    }
+    if (!statInfo.isDirectory()) {
+      return Number(statInfo.size)
+    }
+    let totalSize: number = 0
+    let names: Array<string> = []
+    try {
+      names = fileIo.listFileSync(path)
+    } catch (_error) {
+      return 0
+    }
+    for (let i = 0; i < names.length; i++) {
+      totalSize += await this.calcPathSize(this.buildPath(path, names[i]))
+    }
+    return totalSize
+  }
+
+  private async removeRecursively(path: string): Promise<void> {
+    const statInfo: fileIo.Stat | null = await this.statPath(path)
+    if (!statInfo) {
+      return
+    }
+    if (!statInfo.isDirectory()) {
+      FileUtil.unlinkSync(path)
+      return
+    }
+    let names: Array<string> = []
+    try {
+      names = fileIo.listFileSync(path)
+    } catch (_error) {
+      names = []
+    }
+    for (let i = 0; i < names.length; i++) {
+      await this.removeRecursively(this.buildPath(path, names[i]))
+    }
+    FileUtil.rmdirSync(path)
+  }
+
+  async deletePath(path: string): Promise<number> {
+    const targetPath: string = this.normalizeBrowsePath(path)
+    const statInfo: fileIo.Stat | null = await this.statPath(targetPath)
+    if (!statInfo) {
+      return 0
+    }
+    const size: number = await this.calcPathSize(targetPath)
+    await this.removeRecursively(targetPath)
+    return size
+  }
+
+  async deletePaths(paths: Array<string>): Promise<number> {
+    let releasedSize: number = 0
+    const pathSet: Set<string> = new Set<string>()
+    for (let i = 0; i < paths.length; i++) {
+      const path: string = paths[i]
+      if (StrUtil.isEmpty(path) || pathSet.has(path)) {
+        continue
+      }
+      pathSet.add(path)
+      releasedSize += await this.deletePath(path)
+    }
+    return releasedSize
+  }
+
+  async listBrowserItems(path: string): Promise<Array<CacheBrowserItem>> {
+    const targetPath: string = this.normalizeBrowsePath(path)
+    const statInfo: fileIo.Stat | null = await this.statPath(targetPath)
+    if (!statInfo || !statInfo.isDirectory()) {
+      return []
+    }
+    let names: Array<string> = []
+    try {
+      names = fileIo.listFileSync(targetPath)
+    } catch (error) {
+      const err: Error = error as Error
+      Logger.warn(TAG, `读取缓存目录失败 path=${targetPath}, error=${err.message}`)
+      return []
+    }
+
+    const itemList: Array<CacheBrowserItem> = []
+    for (let i = 0; i < names.length; i++) {
+      const name: string = names[i]
+      const itemPath: string = this.buildPath(targetPath, name)
+      const itemStat: fileIo.Stat | null = await this.statPath(itemPath)
+      if (!itemStat) {
+        continue
+      }
+      const isDirectory: boolean = itemStat.isDirectory()
+      const size: number = isDirectory ? await this.calcPathSize(itemPath) : Number(itemStat.size)
+      itemList.push({
+        path: itemPath,
+        name,
+        isDirectory,
+        size,
+        modifiedTime: typeof itemStat.mtime === 'number' ? itemStat.mtime : Date.now()
+      })
+    }
+
+    itemList.sort((left: CacheBrowserItem, right: CacheBrowserItem): number => {
+      if (left.isDirectory !== right.isDirectory) {
+        return left.isDirectory ? -1 : 1
+      }
+      return left.name.localeCompare(right.name)
+    })
+    return itemList
+  }
+
+  async getCacheDirectoryList(): Promise<Array<CacheDirectoryInfo>> {
+    const rules: Array<CacheRule> = this.buildCacheRules()
+    const list: Array<CacheDirectoryInfo> = []
+    for (let i = 0; i < rules.length; i++) {
+      const rule: CacheRule = rules[i]
+      const size: number = FileUtil.accessSync(rule.path) ? await this.calcPathSize(rule.path) : 0
+      list.push({
+        key: rule.key,
+        title: rule.title,
+        path: rule.path,
+        removable: rule.removable,
+        description: rule.description,
+        size
+      })
+    }
+    return list
+  }
+
+  private async clearRule(rule: CacheRule): Promise<number> {
+    if (!FileUtil.accessSync(rule.path)) {
+      return 0
+    }
+    if (rule.clearStrategy === 'self') {
+      const releasedSize: number = await this.deletePath(rule.path)
+      this.ensureDir(rule.path)
+      return releasedSize
+    }
+
+    let releasedSize: number = 0
+    let names: Array<string> = []
+    try {
+      names = fileIo.listFileSync(rule.path)
+    } catch (_error) {
+      return 0
+    }
+    for (let i = 0; i < names.length; i++) {
+      releasedSize += await this.deletePath(this.buildPath(rule.path, names[i]))
+    }
+    return releasedSize
+  }
+
+  async clearRemovableCaches(): Promise<number> {
+    const rules: Array<CacheRule> = this.buildCacheRules()
+    let releasedSize: number = 0
+    for (let i = 0; i < rules.length; i++) {
+      if (!rules[i].removable) {
+        continue
+      }
+      releasedSize += await this.clearRule(rules[i])
+    }
+    return releasedSize
+  }
+}
+
+export default CacheManageService.getInstance()

+ 80 - 6
entry/src/main/ets/pages/SettingPage.ets

@@ -14,21 +14,22 @@ import { BreakpointTypeEnum } from '../common/util/BreakpointSystem'
 import { Utility } from '../common/util/Utility'
 import { HSBColorPicker } from '@keke/color-picker'
 import { DialogHelper } from '@pura/harmony-dialog'
-import { bundleManager } from '@kit.AbilityKit'
+import { bundleManager, common as AbilityCommon } from '@kit.AbilityKit'
 import { hilog } from '@kit.PerformanceAnalysisKit'
 import { SelectItem } from './SelectItem'
 import { FastForwardSecondInterface } from './FastForwardSecondInterface'
 import { ButtonFancyModifier, ShadowModifier, SymbolGlyphFancyModifier } from '../common/util/AttributeModifierUtil'
 import { CustomizeICON, Icon } from '../view/CustomizeICON'
 import { appInfoManager } from '@kit.StoreKit'
-import { clearWebDavCacheByAccount, clearWebDavCaches, clearAllRemoteCaches } from '../common/network/RemoteSongCache';
-import { RemoteDriveManager } from '../common/util/RemoteDriveManager';
+import { clearAllRemoteCaches } from '../common/network/RemoteSongCache';
 import { LogCollector } from '../common/util/LogCollector';
 import { Uploader, UploadConfig } from '../common/network/Uploader';
 import { UpTokenUtil } from '../common/util/UpTokenUtil';
 import { ServerLogUtil } from '../common/util/ServerLogUtil';
 import { BackupManageView } from '../view/BackupManageView';
 import { audio } from '@kit.AudioKit'
+import cacheManageService, { CacheDirectoryInfo } from '../common/util/CacheManageService'
+import { CacheManageSheet } from '../view/CacheManageSheet'
 
 @Preview
 // @Entry
@@ -63,6 +64,9 @@ export struct SettingPage {
   @State bluetoothLyricEnabled: boolean = false // 蓝牙歌词显示
   @State bluetoothLyricMode: number = 0 // 蓝牙歌词显示模式: 0=歌词在artist, 1=歌词在title
   @State isClearingCache: boolean = false
+  @State isShowCacheManageSheet: boolean = false
+  @State cacheDirectoryList: Array<CacheDirectoryInfo> = []
+  @State removableCacheSize: number = 0
   @Consume isShowDrawer: boolean;
   @Consume offsetX: number;
   @Consume mType: number;
@@ -273,6 +277,8 @@ export struct SettingPage {
 
   // 组件生命周期
   aboutToAppear() {
+    cacheManageService.init(this.context as AbilityCommon.Context)
+    void this.loadCacheSummary()
     this.default_layout =  PreferencesUtil.getNumberSync('default_layout', 0)
     this.defalut_home_type = PreferencesUtil.getNumberSync('defalut_home_type', 0)
     this.showFindLocation  = PreferencesUtil.getBooleanSync('showFindLocation', true)
@@ -345,6 +351,33 @@ export struct SettingPage {
     this.initIcon()
   }
 
+  private async loadCacheSummary(): Promise<void> {
+    try {
+      this.cacheDirectoryList = await cacheManageService.getCacheDirectoryList()
+      let removableSize: number = 0
+      for (let i = 0; i < this.cacheDirectoryList.length; i++) {
+        const item: CacheDirectoryInfo = this.cacheDirectoryList[i]
+        if (item.removable) {
+          removableSize += item.size
+        }
+      }
+      this.removableCacheSize = removableSize
+    } catch (error) {
+      const err: Error = error as Error
+      LogUtil.error('SettingPage', `加载缓存统计失败: ${err.message}`)
+    }
+  }
+
+  @Builder
+  private cacheManageSheetBuilder(): void {
+    CacheManageSheet({
+      context: this.context as AbilityCommon.Context,
+      onCacheChanged: () => {
+        void this.loadCacheSummary()
+      }
+    })
+  }
+
   initIcon(){
     this.iconCurrentID = PreferencesUtil.getStringSync('iconCurrentID', 'default')
     const iconItem:Icon = {
@@ -418,9 +451,6 @@ export struct SettingPage {
       return;
     }
     this.isClearingCache = true;
-    const manager = RemoteDriveManager.getInstance();
-    const account = manager?.currentAccount;
-    const accountId = account?.id;
 
     // 清除所有类型的网盘缓存(WebDAV、SMB、FTP、百度、Navidrome、Jellyfin、Emby)
     this.logNetDisk('info', `开始清除所有网盘缓存`);
@@ -436,6 +466,7 @@ export struct SettingPage {
     }).finally(() => {
       this.isClearingCache = false;
       this.logNetDisk('info', `网盘缓存清理结束`);
+      void this.loadCacheSummary()
     });
   }
 
@@ -569,6 +600,7 @@ export struct SettingPage {
       ToastUtil.showToast('清除失败,请稍后重试');
     } finally {
       this.isClearingLogs = false;
+      void this.loadCacheSummary()
     }
   }
 
@@ -2083,6 +2115,48 @@ export struct SettingPage {
               Line().width('100%').height(0.3).backgroundColor($r('app.color.index_tab_unselected_font_color'))
             }
 
+            Button({ type: ButtonType.Normal, stateEffect: true }) {
+              Row() {
+                SymbolGlyph($r('sys.symbol.trash'))
+                  .fontSize(20)
+                  .fontColor([this.themeColor])
+                  .alignSelf(ItemAlign.Center)
+                  .margin({ left: 15 })
+                Column() {
+                  Text('缓存管理')
+                    .fontSize(15)
+                    .fontColor(Color.Gray)
+                    .fontWeight(480)
+                  Text(`可清理缓存 ${Utility.formatFileSize(this.removableCacheSize)}`)
+                    .fontSize(12)
+                    .fontColor(Color.Gray)
+                    .margin({ top: 2 })
+                }
+                .margin({ left: 8 })
+                .alignItems(HorizontalAlign.Start)
+                .layoutWeight(1)
+                Text('进入管理')
+                  .fontSize(13)
+                  .fontColor(this.themeColor)
+                  .margin({ right: 18 })
+              }
+            }
+            .backgroundColor(Color.Transparent)
+            .height(60)
+            .clickEffect({ level: ClickEffectLevel.HEAVY })
+            .onClick(() => {
+              this.isShowCacheManageSheet = true
+            })
+            .bindSheet($$this.isShowCacheManageSheet, this.cacheManageSheetBuilder(), {
+              height: '100%',
+              dragBar: true,
+              preferType: this.currentBreakpoint !== BreakpointTypeEnum.SM ? SheetType.CENTER : SheetType.BOTTOM,
+              showClose: true,
+              blurStyle: BlurStyle.Thin,
+              title: { title: '缓存管理' }
+            })
+            Line().width('100%').height(0.3).backgroundColor($r('app.color.index_tab_unselected_font_color'))
+
             Row() {
               SymbolGlyph($r('sys.symbol.trash'))
                 .fontSize(20)

+ 679 - 0
entry/src/main/ets/view/CacheManageSheet.ets

@@ -0,0 +1,679 @@
+import { common } from '@kit.AbilityKit'
+import { promptAction, SegmentButton, SegmentButtonItemTuple, SegmentButtonOptions } from '@kit.ArkUI'
+import { DialogAction, DialogHelper } from '@pura/harmony-dialog'
+import { CommonConstants } from '../common/constants/CommonConstants'
+import cacheManageService, { CacheBrowserItem, CacheDirectoryInfo } from '../common/util/CacheManageService'
+import { Utility } from '../common/util/Utility'
+import { ButtonFancyModifier, ShadowModifier, SymbolGlyphFancyModifier } from '../common/util/AttributeModifierUtil'
+
+interface CacheRootSegmentItem {
+  text: string
+}
+
+interface CacheRootGroup {
+  key: string
+  title: string
+  path: string
+  description: string
+  removableSize: number
+}
+
+@Component
+export struct CacheManageSheet {
+  @StorageProp('themeColor') themeColor: string = CommonConstants.DEFAULT_THEME_COLOR
+
+  @State private directoryList: Array<CacheDirectoryInfo> = []
+  @State private rootGroups: Array<CacheRootGroup> = []
+  @State private currentPath: string = ''
+  @State private activeRootPath: string = ''
+  @State private activeRootTitle: string = ''
+  @State private browserItems: Array<CacheBrowserItem> = []
+  @State private selectedPaths: Array<string> = []
+  @State private isLoading: boolean = false
+  @State private isDeleting: boolean = false
+  @State private totalCacheSize: number = 0
+  @State @Watch('onRootSegmentChanged') private selectedRootIndexes: number[] = [0]
+
+  @Prop context?: common.Context
+  onCacheChanged: () => void = () => {
+  }
+
+  aboutToAppear(): void {
+    if (this.context) {
+      cacheManageService.init(this.context)
+    }
+    void this.loadDirectoryList()
+  }
+
+  private async loadDirectoryList(): Promise<void> {
+    this.isLoading = true
+    try {
+      this.directoryList = await cacheManageService.getCacheDirectoryList()
+      this.rootGroups = this.buildRootGroups(this.directoryList)
+      this.totalCacheSize = this.calcTotalCacheSize(this.directoryList)
+      if (this.rootGroups.length === 0) {
+        this.currentPath = ''
+        this.activeRootPath = ''
+        this.activeRootTitle = ''
+        this.browserItems = []
+        this.selectedPaths = []
+        this.syncRootSegmentSelection('')
+        return
+      }
+      if (this.activeRootPath.length === 0 || this.resolveRootGroupIndexByPath(this.activeRootPath) < 0) {
+        this.activeRootPath = this.rootGroups[0].path
+        this.activeRootTitle = this.rootGroups[0].title
+      }
+      if (this.currentPath.length === 0 || !this.isPathInsideRoot(this.activeRootPath, this.currentPath)) {
+        this.currentPath = this.activeRootPath
+      }
+      await this.loadBrowserItems(this.currentPath, this.activeRootPath, this.activeRootTitle)
+    } catch (error) {
+      const err: Error = error as Error
+      promptAction.showToast({ message: `加载缓存目录失败:${err.message}` })
+    } finally {
+      this.isLoading = false
+    }
+  }
+
+  private calcTotalCacheSize(list: Array<CacheDirectoryInfo>): number {
+    let totalSize: number = 0
+    for (let i = 0; i < list.length; i++) {
+      if (list[i].removable) {
+        totalSize += list[i].size
+      }
+    }
+    return totalSize
+  }
+
+  private buildRootGroups(list: Array<CacheDirectoryInfo>): Array<CacheRootGroup> {
+    const groups: Array<CacheRootGroup> = []
+    for (let i = 0; i < list.length; i++) {
+      const item: CacheDirectoryInfo = list[i]
+      groups.push({
+        key: item.key,
+        title: item.title,
+        path: item.path,
+        description: item.description,
+        removableSize: item.removable ? item.size : 0
+      })
+    }
+    return groups
+  }
+
+  private isPathInsideRoot(rootPath: string, path: string): boolean {
+    if (rootPath.length === 0 || path.length === 0) {
+      return false
+    }
+    return path === rootPath || path.startsWith(`${rootPath}/`)
+  }
+
+  private resolveRootGroupIndexByPath(path: string): number {
+    if (path.length === 0 || this.rootGroups.length === 0) {
+      return -1
+    }
+    let matchedIndex: number = -1
+    let matchedLength: number = -1
+    for (let i = 0; i < this.rootGroups.length; i++) {
+      const item: CacheRootGroup = this.rootGroups[i]
+      if (!this.isPathInsideRoot(item.path, path)) {
+        continue
+      }
+      if (item.path.length > matchedLength) {
+        matchedIndex = i
+        matchedLength = item.path.length
+      }
+    }
+    return matchedIndex
+  }
+
+  private syncRootSegmentSelection(path: string): void {
+    let nextIndex: number = this.resolveRootGroupIndexByPath(path)
+    if (nextIndex < 0) {
+      nextIndex = 0
+    }
+    const currentIndex: number = this.selectedRootIndexes.length > 0 ? this.selectedRootIndexes[0] : -1
+    if (currentIndex !== nextIndex) {
+      this.selectedRootIndexes = [nextIndex]
+    }
+  }
+
+  private onRootSegmentChanged(): void {
+    if (this.selectedRootIndexes.length === 0 || this.rootGroups.length === 0) {
+      return
+    }
+    const nextIndex: number = this.selectedRootIndexes[0]
+    if (nextIndex < 0 || nextIndex >= this.rootGroups.length) {
+      return
+    }
+    const currentRootIndex: number = this.resolveRootGroupIndexByPath(this.activeRootPath)
+    if (currentRootIndex === nextIndex) {
+      return
+    }
+    const item: CacheRootGroup = this.rootGroups[nextIndex]
+    void this.loadBrowserItems(item.path, item.path, item.title)
+  }
+
+  private getCurrentGroup(): CacheRootGroup | null {
+    const currentIndex: number = this.resolveRootGroupIndexByPath(this.activeRootPath)
+    if (currentIndex >= 0 && currentIndex < this.rootGroups.length) {
+      return this.rootGroups[currentIndex]
+    }
+    if (this.rootGroups.length > 0) {
+      return this.rootGroups[0]
+    }
+    return null
+  }
+
+  private getCurrentGroupDescription(): string {
+    const group: CacheRootGroup | null = this.getCurrentGroup()
+    if (!group) {
+      return ''
+    }
+    if (group.removableSize > 0) {
+      return `${group.description} · ${Utility.formatFileSize(group.removableSize)}`
+    }
+    return group.description
+  }
+
+  private buildRootSegmentOptions(): SegmentButtonOptions {
+    const buttons: Array<CacheRootSegmentItem> = []
+    if (this.rootGroups.length === 0) {
+      buttons.push({ text: '暂无' })
+    } else {
+      for (let i = 0; i < this.rootGroups.length; i++) {
+        buttons.push({ text: this.rootGroups[i].title })
+      }
+    }
+    return SegmentButtonOptions.capsule({
+      buttons: buttons as SegmentButtonItemTuple,
+      backgroundColor: $r('app.color.index_background'),
+      selectedBackgroundColor: $r('app.color.start_window_background'),
+      selectedFontColor: $r('app.color.text_color'),
+      buttonPadding: { top: 10, bottom: 10 },
+      multiply: false
+    })
+  }
+
+  private async loadBrowserItems(path: string, rootPath: string = '', rootTitle: string = ''): Promise<void> {
+    this.isLoading = true
+    this.selectedPaths = []
+    try {
+      this.currentPath = path
+      if (rootPath.length > 0) {
+        this.activeRootPath = rootPath
+      } else if (this.activeRootPath.length === 0) {
+        this.activeRootPath = path
+      }
+      if (rootTitle.length > 0) {
+        this.activeRootTitle = rootTitle
+      }
+      this.syncRootSegmentSelection(this.activeRootPath.length > 0 ? this.activeRootPath : path)
+      this.browserItems = await cacheManageService.listBrowserItems(path)
+    } catch (error) {
+      const err: Error = error as Error
+      promptAction.showToast({ message: `读取目录失败:${err.message}` })
+    } finally {
+      this.isLoading = false
+    }
+  }
+
+  private getParentPath(path: string): string {
+    const index: number = path.lastIndexOf('/')
+    if (index <= 0) {
+      return path
+    }
+    return path.substring(0, index)
+  }
+
+  private async goBackDirectory(): Promise<void> {
+    if (this.currentPath.length === 0 || this.activeRootPath.length === 0) {
+      return
+    }
+    if (this.currentPath === this.activeRootPath) {
+      return
+    }
+    const parentPath: string = this.getParentPath(this.currentPath)
+    const nextPath: string = parentPath.length < this.activeRootPath.length ? this.activeRootPath : parentPath
+    await this.loadBrowserItems(nextPath, this.activeRootPath, this.activeRootTitle)
+  }
+
+  private isRootPath(): boolean {
+    if (this.activeRootPath.length === 0) {
+      return true
+    }
+    return this.currentPath === this.activeRootPath
+  }
+
+  private toggleSelection(path: string, isOn: boolean): void {
+    if (isOn) {
+      if (!this.selectedPaths.includes(path)) {
+        this.selectedPaths = this.selectedPaths.concat([path])
+      }
+      return
+    }
+    const nextList: Array<string> = []
+    for (let i = 0; i < this.selectedPaths.length; i++) {
+      if (this.selectedPaths[i] !== path) {
+        nextList.push(this.selectedPaths[i])
+      }
+    }
+    this.selectedPaths = nextList
+  }
+
+  private isSelected(path: string): boolean {
+    return this.selectedPaths.includes(path)
+  }
+
+  private isAllVisibleSelected(): boolean {
+    if (this.browserItems.length === 0) {
+      return false
+    }
+    for (let i = 0; i < this.browserItems.length; i++) {
+      if (!this.selectedPaths.includes(this.browserItems[i].path)) {
+        return false
+      }
+    }
+    return true
+  }
+
+  private canToggleVisibleSelection(): boolean {
+    return !this.isDeleting && this.browserItems.length > 0
+  }
+
+  private toggleVisibleSelection(): void {
+    if (!this.canToggleVisibleSelection()) {
+      return
+    }
+    if (this.isAllVisibleSelected()) {
+      this.selectedPaths = []
+      return
+    }
+    const nextList: Array<string> = []
+    for (let i = 0; i < this.browserItems.length; i++) {
+      nextList.push(this.browserItems[i].path)
+    }
+    this.selectedPaths = nextList
+  }
+
+  private async refreshAfterDelete(): Promise<void> {
+    this.directoryList = await cacheManageService.getCacheDirectoryList()
+    this.rootGroups = this.buildRootGroups(this.directoryList)
+    this.totalCacheSize = this.calcTotalCacheSize(this.directoryList)
+    if (this.rootGroups.length === 0) {
+      this.currentPath = ''
+      this.activeRootPath = ''
+      this.activeRootTitle = ''
+      this.browserItems = []
+      this.selectedPaths = []
+      this.syncRootSegmentSelection('')
+      this.onCacheChanged()
+      return
+    }
+    if (this.activeRootPath.length === 0 || this.resolveRootGroupIndexByPath(this.activeRootPath) < 0) {
+      this.activeRootPath = this.rootGroups[0].path
+      this.activeRootTitle = this.rootGroups[0].title
+    }
+    if (this.currentPath.length === 0 || !this.isPathInsideRoot(this.activeRootPath, this.currentPath)) {
+      this.currentPath = this.activeRootPath
+    }
+    this.syncRootSegmentSelection(this.activeRootPath)
+    this.browserItems = await cacheManageService.listBrowserItems(this.currentPath)
+    this.selectedPaths = []
+    this.onCacheChanged()
+  }
+
+  private confirmDeleteSelected(): void {
+    if (this.selectedPaths.length === 0) {
+      promptAction.showToast({ message: '请先选择文件或目录' })
+      return
+    }
+    DialogHelper.showAlertDialog({
+      content: `确定删除已选 ${this.selectedPaths.length} 项吗?`,
+      onAction: (action: DialogAction) => {
+        if (action === DialogAction.TWO) {
+          void this.deleteSelectedPaths()
+        }
+      }
+    })
+  }
+
+  private async deleteSelectedPaths(): Promise<void> {
+    if (this.isDeleting || this.selectedPaths.length === 0) {
+      return
+    }
+    this.isDeleting = true
+    try {
+      const releasedSize: number = await cacheManageService.deletePaths(this.selectedPaths)
+      await this.refreshAfterDelete()
+      promptAction.showToast({ message: `已删除,释放 ${Utility.formatFileSize(releasedSize)}` })
+    } catch (error) {
+      const err: Error = error as Error
+      promptAction.showToast({ message: `删除失败:${err.message}` })
+    } finally {
+      this.isDeleting = false
+    }
+  }
+
+  private async clearAllRemovableCaches(): Promise<void> {
+    if (this.isDeleting) {
+      return
+    }
+    this.isDeleting = true
+    try {
+      const releasedSize: number = await cacheManageService.clearRemovableCaches()
+      await this.refreshAfterDelete()
+      promptAction.showToast({ message: `缓存已清理,释放 ${Utility.formatFileSize(releasedSize)}` })
+    } catch (error) {
+      const err: Error = error as Error
+      promptAction.showToast({ message: `清理失败:${err.message}` })
+    } finally {
+      this.isDeleting = false
+    }
+  }
+
+  private canDeleteSelected(): boolean {
+    return !this.isDeleting && this.selectedPaths.length > 0
+  }
+
+  private getSelectedSize(): number {
+    if (this.selectedPaths.length === 0 || this.browserItems.length === 0) {
+      return 0
+    }
+    let totalSize: number = 0
+    for (let i = 0; i < this.browserItems.length; i++) {
+      const item: CacheBrowserItem = this.browserItems[i]
+      if (this.selectedPaths.includes(item.path)) {
+        totalSize += item.size
+      }
+    }
+    return totalSize
+  }
+
+  @Builder
+  private clearAllButtonBuilder(): void {
+    Button({ type: ButtonType.Normal, stateEffect: true }) {
+      Row() {
+        SymbolGlyph($r('sys.symbol.trash'))
+          .fontColor([this.themeColor])
+          .fontSize(16)
+        Text(this.isDeleting ? '处理中' : '一键清理')
+          .fontSize(13)
+          .fontWeight(600)
+          .fontColor($r('app.color.text_color'))
+          .margin({ left: 6 })
+      }
+    }
+    .enabled(!this.isDeleting)
+    .padding({ left: 12, right: 12 })
+    .attributeModifier(new ButtonFancyModifier(126, 40))
+    .attributeModifier(new ShadowModifier())
+    .opacity(this.isDeleting ? 0.65 : 1)
+    .onClick(() => {
+      DialogHelper.showAlertDialog({
+        content: '确定清理所有可再生缓存吗?不会删除歌曲、歌单和已保存设置。',
+        onAction: (action: DialogAction) => {
+          if (action === DialogAction.TWO) {
+            void this.clearAllRemovableCaches()
+          }
+        }
+      })
+    })
+  }
+
+  @Builder
+  private backButtonBuilder(): void {
+    Button({ type: ButtonType.Normal, stateEffect: true }) {
+      Row() {
+        SymbolGlyph($r('sys.symbol.chevron_left'))
+          .attributeModifier(new SymbolGlyphFancyModifier(18, '', ''))
+        Text('返回')
+          .fontSize(13)
+          .fontWeight(600)
+          .fontColor($r('app.color.text_color'))
+          .margin({ left: 4 })
+      }
+    }
+    .enabled(!this.isRootPath() && !this.isLoading)
+    .padding({ left: 10, right: 12 })
+    .attributeModifier(new ButtonFancyModifier(86, 40))
+    .attributeModifier(new ShadowModifier())
+    .opacity(!this.isRootPath() && !this.isLoading ? 1 : 0.55)
+    .onClick(() => {
+      void this.goBackDirectory()
+    })
+  }
+
+  @Builder
+  private openDirectoryButtonBuilder(path: string): void {
+    Button({ type: ButtonType.Normal, stateEffect: true }) {
+      Row() {
+        Text('查看')
+          .fontSize(12)
+          .fontWeight(600)
+          .fontColor(this.themeColor)
+        SymbolGlyph($r('sys.symbol.chevron_right'))
+          .fontSize(14)
+          .fontColor([this.themeColor])
+          .margin({ left: 3 })
+      }
+    }
+    .padding({ left: 10, right: 10 })
+    .height(34)
+    .backgroundColor($r('app.color.index_background'))
+    .border({ width: 1, color: $r('app.color.start_window_background') })
+    .borderRadius(14)
+    .clickEffect({ level: ClickEffectLevel.MIDDLE, scale: 0.85 })
+    .onClick(() => {
+      void this.loadBrowserItems(path, this.activeRootPath, this.activeRootTitle)
+    })
+  }
+
+  @Builder
+  private selectToggleButtonBuilder(): void {
+    Button({ type: ButtonType.Normal, stateEffect: true }) {
+      Row() {
+        SymbolGlyph($r('sys.symbol.checkmark_square_on_square'))
+          .fontSize(15)
+          .fontColor([this.themeColor])
+        Text(this.isAllVisibleSelected() ? '反选' : '全选')
+          .fontSize(12)
+          .fontWeight(600)
+          .fontColor($r('app.color.text_color'))
+          .margin({ left: 6 })
+      }
+    }
+    .enabled(this.canToggleVisibleSelection())
+    .padding({ left: 12, right: 12 })
+    .height(36)
+    .backgroundColor($r('app.color.index_background'))
+    .border({ width: 1, color: $r('app.color.start_window_background') })
+    .borderRadius(14)
+    .opacity(this.canToggleVisibleSelection() ? 1 : 0.55)
+    .clickEffect({ level: ClickEffectLevel.MIDDLE, scale: 0.85 })
+    .onClick(() => {
+      this.toggleVisibleSelection()
+    })
+  }
+
+  @Builder
+  private deleteSelectedButtonBuilder(): void {
+    Button({ type: ButtonType.Normal, stateEffect: true }) {
+      Row() {
+        SymbolGlyph($r('sys.symbol.trash'))
+          .fontSize(15)
+          .fontColor([Color.White])
+        Text(this.isDeleting ? '删除中' : '删除所选')
+          .fontSize(13)
+          .fontWeight(600)
+          .fontColor(Color.White)
+          .margin({ left: 6 })
+      }
+    }
+    .enabled(this.canDeleteSelected())
+    .padding({ left: 14, right: 14 })
+    .height(42)
+    .backgroundColor(this.canDeleteSelected() ? '#E84A4A' : '#CFCFCF')
+    .borderRadius(16)
+    .clickEffect({ level: ClickEffectLevel.MIDDLE, scale: 0.85 })
+    .onClick(() => {
+      this.confirmDeleteSelected()
+    })
+  }
+
+  @Builder
+  private headerCardBuilder(): void {
+    Column({ space: 14 }) {
+      Row({ space: 12 }) {
+        Column({ space: 4 }) {
+          Text('缓存管理')
+            .fontSize(14)
+            .fontWeight(600)
+            .fontColor($r('app.color.text_color'))
+          Text(Utility.formatFileSize(this.totalCacheSize))
+            .fontSize(28)
+            .fontWeight(700)
+            .fontColor($r('app.color.text_color'))
+        }
+        .layoutWeight(1)
+        .alignItems(HorizontalAlign.Start)
+
+        this.clearAllButtonBuilder()
+      }
+      .width('100%')
+
+      SegmentButton({
+        options: this.buildRootSegmentOptions(),
+        selectedIndexes: $selectedRootIndexes
+      })
+        .width('100%')
+
+      Row({ space: 10 }) {
+        this.backButtonBuilder()
+
+        Text(this.currentPath.length > 0 ? this.currentPath : '暂无缓存目录')
+          .fontSize(11)
+          .fontColor(Color.Gray)
+          .maxLines(1)
+          .textOverflow({ overflow: TextOverflow.MARQUEE })
+          .layoutWeight(1)
+      }
+      .width('100%')
+      .alignItems(VerticalAlign.Center)
+    }
+    .padding(16)
+    .margin({ left: 12, right: 12, top: 12 })
+    .backgroundColor($r('app.color.start_window_background'))
+    .borderRadius(20)
+  }
+
+  @Builder
+  private emptyStateBuilder(): void {
+    Column({ space: 10 }) {
+      SymbolGlyph($r('sys.symbol.folder'))
+        .fontSize(32)
+        .fontColor([this.themeColor])
+      Text(this.currentPath.length > 0 ? '当前目录为空' : '暂无缓存目录')
+        .fontSize(14)
+        .fontWeight(600)
+        .fontColor($r('app.color.text_color'))
+      Text(this.currentPath.length > 0 ? '可切换上方分类,或点击返回继续查看。' : '暂未扫描到可管理的缓存目录。')
+        .fontSize(12)
+        .fontColor(Color.Gray)
+    }
+    .layoutWeight(1)
+    .justifyContent(FlexAlign.Center)
+    .width('100%')
+  }
+
+  build() {
+    Column({ space: 10 }) {
+      this.headerCardBuilder()
+
+      if (this.isLoading) {
+        Column() {
+          LoadingProgress()
+            .width(28)
+            .height(28)
+          Text('正在读取缓存目录...')
+            .fontSize(12)
+            .fontColor(Color.Gray)
+            .margin({ top: 8 })
+        }
+        .layoutWeight(1)
+        .justifyContent(FlexAlign.Center)
+        .width('100%')
+      } else if (this.browserItems.length === 0) {
+        this.emptyStateBuilder()
+      } else {
+        List() {
+          ForEach(this.browserItems, (item: CacheBrowserItem) => {
+            ListItem() {
+              Column() {
+                Row() {
+                  Toggle({ type: ToggleType.Checkbox, isOn: this.isSelected(item.path) })
+                    .selectedColor(this.themeColor)
+                    .margin({ right: 10 })
+                    .onChange((isOn: boolean) => {
+                      this.toggleSelection(item.path, isOn)
+                    })
+                  SymbolGlyph(item.isDirectory ? $r('sys.symbol.folder') : $r('sys.symbol.doc'))
+                    .fontSize(20)
+                    .fontColor([this.themeColor])
+                    .margin({ right: 10 })
+                  Column() {
+                    Text(item.name)
+                      .fontSize(14)
+                      .fontColor($r('app.color.text_color'))
+                      .maxLines(1)
+                      .textOverflow({ overflow: TextOverflow.Ellipsis })
+                    Text(`${item.isDirectory ? '目录' : '文件'} · ${Utility.formatFileSize(item.size)}`)
+                      .fontSize(11)
+                      .fontColor(Color.Gray)
+                      .margin({ top: 2 })
+                  }
+                  .layoutWeight(1)
+                  .alignItems(HorizontalAlign.Start)
+                  if (item.isDirectory) {
+                    this.openDirectoryButtonBuilder(item.path)
+                  }
+                }
+                .width('100%')
+              }
+              .padding({ left: 16, right: 16, top: 14, bottom: 14 })
+              .backgroundColor($r('app.color.start_window_background'))
+              .borderRadius(18)
+              .margin({ left: 12, right: 12, top: 6, bottom: 6 })
+            }
+          }, (item: CacheBrowserItem): string => item.path)
+        }
+        .layoutWeight(1)
+        .width('100%')
+        .divider({ strokeWidth: 0 })
+      }
+
+      Row() {
+        Row({ space: 6 }) {
+          this.selectToggleButtonBuilder()
+          Text(`已选 ${Utility.formatFileSize(this.getSelectedSize())}`)
+            .fontSize(12)
+            .fontColor(Color.Gray)
+          Text(`已选 ${this.selectedPaths.length} 项`)
+            .fontSize(13)
+            .fontWeight(600)
+            .fontColor($r('app.color.text_color'))
+        }
+        .layoutWeight(1)
+
+        this.deleteSelectedButtonBuilder()
+      }
+      .padding({ left: 16, right: 16, top: 14, bottom: 14 })
+      .margin({ left: 12, right: 12, bottom: 12 })
+      .backgroundColor($r('app.color.start_window_background'))
+      .borderRadius(20)
+    }
+    .width('100%')
+    .height('100%')
+    .backgroundColor($r('app.color.index_background'))
+  }
+}

+ 28 - 7
entry/src/main/ets/view/FindView.ets

@@ -930,8 +930,11 @@ export struct FindView {
     this.currentAlbumCoverPath = coverSong?.pixelMapPath ?? ''
     this.currentAlbumSourceLabel = sourceLabel
     this.currentAlbumSongs = songs.slice()
-    this.isAlbumMode = true
     this.isSearchMode = false
+    this.getUIContext()?.animateTo({ duration: 500 }, () => {
+      this.isAlbumMode = true
+    })
+
   }
 
   private openAlbumDetail(album: FindAlbumGroup): void {
@@ -946,7 +949,9 @@ export struct FindView {
   }
 
   private exitAlbumMode(): void {
-    this.isAlbumMode = false
+    this.getUIContext()?.animateTo({ duration: 500 }, () => {
+      this.isAlbumMode = false
+    })
     this.currentAlbumId = ''
     this.currentAlbumTitle = ''
     this.currentAlbumArtist = ''
@@ -1006,12 +1011,21 @@ export struct FindView {
 
   private applySearchHistory(keyword: string): void {
     this.searchText = keyword
+    this.searchResults = []
+    if (!this.isSearchMode) {
+      this.isSearchMode = true
+    }
+    this.isSearchLoading = true
     this.searchController.stopEditing()
-    void this.onSearchInput(keyword)
+    setTimeout(() => {
+      void this.onSearchInput(keyword)
+    }, 16)
   }
 
   private exitSearchMode(): void {
-    this.isSearchMode = false
+    this.getUIContext()?.animateTo({ duration: 555 }, () => {
+      this.isSearchMode = false
+    })
     this.searchText = ''
     this.searchResults = []
     this.isSearchLoading = false
@@ -1066,6 +1080,11 @@ export struct FindView {
     }
 
     this.isSearchLoading = true
+    await new Promise<void>((resolve: () => void) => {
+      setTimeout(() => {
+        resolve()
+      }, 16)
+    })
     await this.ensureSearchSourceReady()
     if (currentTicket !== this.searchTicket) {
       return
@@ -1419,7 +1438,7 @@ export struct FindView {
           .clickEffect({ level: ClickEffectLevel.MIDDLE, scale: 0.6 })
           .animation({ duration: 300, curve: Curve.Ease })
           .onClick(() => {
-            this.getUIContext().animateTo({ duration: 555 }, () => {
+            this.getUIContext().animateTo({ duration: 500 }, () => {
               this.isShowDrawer = !this.isShowDrawer
               this.offsetX = 0
             })
@@ -1517,8 +1536,10 @@ export struct FindView {
           .attributeModifier(new ShadowModifier())
           .zIndex(0)
           .onClick(() => {
-            this.isSearchMode = true
             this.isAlbumMode = false
+            this.getUIContext()?.animateTo({ duration: 500 }, () => {
+              this.isSearchMode = true
+            })
             this.loadSearchHistory()
             void this.ensureSearchSourceReady()
           })
@@ -1595,7 +1616,7 @@ export struct FindView {
     Text(keyword)
       .fontSize(12)
       .fontColor($r('app.color.text_color'))
-      .padding({ left: 5, right: 5, top: 3, bottom: 3 })
+      .padding({ left: 8, right: 8, top: 5, bottom: 5 })
   }
 
   @Builder