Просмотр исходного кода

新增备份以及恢复功能

chendeben 6 месяцев назад
Родитель
Сommit
cd636fb814
20 измененных файлов с 3263 добавлено и 257 удалено
  1. 1289 0
      entry/src/main/ets/common/util/PlaylistBackupManager.ets
  2. 234 0
      entry/src/main/ets/common/util/PlaylistTable.ets
  3. 94 0
      entry/src/main/ets/dialog/ImportConflictDialog.ets
  4. 4 0
      entry/src/main/ets/entryability/EntryAbility.ets
  5. 47 0
      entry/src/main/ets/pages/SettingPage.ets
  6. 996 0
      entry/src/main/ets/view/BackupManageView.ets
  7. 141 0
      entry/src/main/ets/viewmodel/PlaylistBackup.ets
  8. 0 20
      openspec/changes/improve-git-collaboration-workflow/change-summary.md
  9. 0 82
      openspec/changes/improve-git-collaboration-workflow/design.md
  10. 0 27
      openspec/changes/improve-git-collaboration-workflow/proposal.md
  11. 0 34
      openspec/changes/improve-git-collaboration-workflow/specs/conflict-prevention-baseline/spec.md
  12. 0 34
      openspec/changes/improve-git-collaboration-workflow/specs/git-collaboration-policy/spec.md
  13. 0 34
      openspec/changes/improve-git-collaboration-workflow/specs/local-change-protection/spec.md
  14. 0 25
      openspec/changes/improve-git-collaboration-workflow/tasks.md
  15. 1 1
      openspec/changes/playlist-backup/.openspec.yaml
  16. 143 0
      openspec/changes/playlist-backup/design.md
  17. 30 0
      openspec/changes/playlist-backup/proposal.md
  18. 93 0
      openspec/changes/playlist-backup/specs/playlist-export-import/spec.md
  19. 117 0
      openspec/changes/playlist-backup/specs/playlist-remote-backup/spec.md
  20. 74 0
      openspec/changes/playlist-backup/tasks.md

+ 1289 - 0
entry/src/main/ets/common/util/PlaylistBackupManager.ets

@@ -0,0 +1,1289 @@
+import { Context } from '@kit.AbilityKit';
+import { fileIo } from '@kit.CoreFileKit';
+import { picker } from '@kit.CoreFileKit';
+import { http } from '@kit.NetworkKit';
+import { rcp } from '@kit.RemoteCommunicationKit';
+import { buffer, util } from '@kit.ArkTS';
+import { cryptoFramework } from '@kit.CryptoArchitectureKit';
+import { PreferencesUtil, ToastUtil, AppUtil } from '@pura/harmony-utils';
+import Logger from './Logger';
+import PlaylistTable from './PlaylistTable';
+import {
+  PlaylistBackupData,
+  ImportResult,
+  BackupHistoryItem,
+  BACKUP_FORMAT_VERSION,
+  WebDavAccountBackupItem,
+  SettingsBackupData,
+  SettingBoolItem,
+  SettingNumberItem,
+  SettingStringItem
+} from '../../viewmodel/PlaylistBackup';
+import { RemoteDriveManager } from './RemoteDriveManager';
+import { WebDavAccount } from '../../viewmodel/WebDavAccount';
+import { RemoteDriveType } from '../enums/RemoteDriveType';
+
+const TAG = 'heanup PlaylistBackupManager';
+const BACKUP_HISTORY_KEY = 'playlist_backup_history';
+const AUTO_BACKUP_ENABLED_KEY = 'playlist_auto_backup_enabled';
+const MAX_HISTORY_ITEMS = 20;
+const AUTO_BACKUP_DELAY_MS = 5 * 60 * 1000; // 5分钟
+
+/**
+ * 备份JSON校验结果
+ */
+export interface ValidateResult {
+  valid: boolean;
+  errorMessage: string;
+  data: PlaylistBackupData | null;
+}
+
+/**
+ * 网盘账户导入统计
+ */
+interface AccountImportResult {
+  imported: number;
+  skipped: number;
+}
+
+/**
+ * 加密结果
+ */
+interface EncryptionResult {
+  cipherText: string; // base64
+  salt: string;       // base64
+  iv: string;         // base64
+  tag: string;        // base64
+}
+
+/**
+ * 歌单备份管理器
+ */
+export class PlaylistBackupManager {
+  private static instance: PlaylistBackupManager | null = null;
+  private context: Context | null = null;
+  private autoBackupTimer: number = -1;
+
+  private constructor() {
+  }
+
+  static getInstance(): PlaylistBackupManager {
+    if (!PlaylistBackupManager.instance) {
+      PlaylistBackupManager.instance = new PlaylistBackupManager();
+    }
+    return PlaylistBackupManager.instance;
+  }
+
+  setContext(context: Context): void {
+    this.context = context;
+    // 注册自动备份回调到 PlaylistTable(避免循环依赖)
+    PlaylistTable.registerAutoBackupCallback(() => {
+      this.scheduleAutoBackup();
+    });
+  }
+
+  // ========== 序列化 ==========
+
+  /**
+   * 导出完整备份为 JSON 字符串(v2:歌单 + 网盘配置 + 设置项)
+   * @param passphrase 可选加密密码,不为空时对密码字段加密
+   */
+  async exportToJson(passphrase: string = ''): Promise<string> {
+    if (!this.context) {
+      Logger.error(TAG, 'exportToJson: context 未设置');
+      return '';
+    }
+
+    try {
+      // 1. 导出歌单
+      const playlistTable = new PlaylistTable(this.context);
+      const playlistData = await playlistTable.exportAllPlaylists();
+
+      // 2. 导出网盘账户
+      const accounts = this.exportWebDavAccounts();
+
+      // 3. 导出用户设置
+      const settings = this.exportUserSettings();
+
+      // 4. 构建 v2 备份数据
+      const backupData: PlaylistBackupData = {
+        version: BACKUP_FORMAT_VERSION,
+        exportTime: new Date().toISOString(),
+        appVersion: AppUtil.getVersionName(),
+        playlists: playlistData.playlists,
+        webDavAccounts: accounts,
+        userSettings: settings
+      };
+
+      // 5. 加密敏感数据(密码)
+      if (passphrase.length > 0 && accounts.length > 0) {
+        const passwords: string[] = [];
+        for (let i = 0; i < accounts.length; i++) {
+          passwords.push(accounts[i].password);
+          accounts[i].password = ''; // 清除明文密码
+        }
+
+        const encResult = await this.encryptData(JSON.stringify(passwords), passphrase);
+        backupData.encrypted = true;
+        backupData.encryptionSalt = encResult.salt;
+        backupData.encryptionIv = encResult.iv;
+        backupData.encryptionTag = encResult.tag;
+        backupData.encryptedPasswords = encResult.cipherText;
+        Logger.info(TAG, 'exportToJson: 密码已加密');
+      }
+
+      const jsonStr = JSON.stringify(backupData);
+      Logger.info(TAG, `exportToJson: 导出成功, ${backupData.playlists.length} 个歌单, ${accounts.length} 个网盘账户, 加密=${backupData.encrypted === true}, ${jsonStr.length} 字节`);
+      return jsonStr;
+    } catch (error) {
+      Logger.error(TAG, `exportToJson: 导出失败: ${(error as Error).message}`);
+      return '';
+    }
+  }
+
+  /**
+   * 检查备份 JSON 是否已加密
+   */
+  isBackupEncrypted(jsonStr: string): boolean {
+    try {
+      const parsed = JSON.parse(jsonStr) as PlaylistBackupData;
+      return parsed.encrypted === true;
+    } catch (e) {
+      return false;
+    }
+  }
+
+  /**
+   * 导出所有网盘账户
+   */
+  private exportWebDavAccounts(): WebDavAccountBackupItem[] {
+    const backupItems: WebDavAccountBackupItem[] = [];
+    try {
+      const manager = RemoteDriveManager.getInstance();
+      const allAccounts = manager.getAllWebDavAccounts();
+      for (let i = 0; i < allAccounts.length; i++) {
+        const acct = allAccounts[i];
+        const item: WebDavAccountBackupItem = {
+          name: acct.name,
+          isActivate: acct.isActivate,
+          sortOrder: acct.sortOrder,
+          host: acct.host,
+          localHost: acct.localHost,
+          isUseLocalHost: acct.isUseLocalHost,
+          port: acct.port,
+          enableHttps: acct.enableHttps,
+          filepath: acct.filepath,
+          imageFilePath: acct.imageFilePath,
+          lyricFilePath: acct.lyricFilePath,
+          uploadFilePath: acct.uploadFilePath,
+          coverPath: acct.coverPath || '',
+          account: acct.account,
+          password: acct.password,
+          webType: acct.webType,
+          smbShare: acct.smbShare,
+          smbDomain: acct.smbDomain,
+          navidromeBasePath: acct.navidromeBasePath,
+          jellyfinBasePath: acct.jellyfinBasePath,
+          embyBasePath: acct.embyBasePath,
+          ftpEncoding: acct.ftpEncoding
+        };
+        backupItems.push(item);
+      }
+      Logger.info(TAG, `exportWebDavAccounts: 导出 ${backupItems.length} 个网盘账户`);
+    } catch (error) {
+      Logger.error(TAG, `exportWebDavAccounts: 失败: ${(error as Error).message}`);
+    }
+    return backupItems;
+  }
+
+  /**
+   * 导出用户设置
+   */
+  private exportUserSettings(): SettingsBackupData {
+    const boolKeys: string[] = [
+      'isBgPlayOpen', 'isAutoRatate', 'isMemoryPlay', 'isMusicMemoryPlay',
+      'isStartAutoPlay', 'isMemoryLastPlay', 'isSameTimePlay', 'isSavePlayMode',
+      'isMediacodec', 'preload_next_song', 'is_customize_bg', 'isMusicBGCover',
+      'is_grid_music', 'isCoverRectangle', 'isCircleBtn', 'isAutoScrollHide',
+      'IS_SHOW_ALLBAR', 'IS_SHOW_TITLTBAR', 'IS_SHOW_PLAYPAGE_BACK', 'IS_SHOW_SLLYRIC',
+      'IS_SHOW_HEADER', 'IS_COVER_TOP_BIG', 'IS_PLAYLIST_BG_GRASS', 'IS_AUTO_HIDE_PROGRESS',
+      'isSwipe', 'isShowSimi', 'isShowFAV', 'isShowHistory',
+      'isShowPrecious', 'isShowBackFast', 'openSkipSongAnimate', 'isShowFileName',
+      'isLongNameRoLL', 'showFindLocation', 'showZMIndex', 'autoHideTitle',
+      'autoParseMusicName', 'isNoJumpToHome', 'isCopyFileToDownLoad', 'isDeleteYuan',
+      'isDeletePicture', 'isDeleteLrc', 'webdavUploadAutoClear', 'webdavUploadAllowMobile',
+      'volumeSmall'
+    ];
+
+    const numberKeys: string[] = [
+      'longPressSpeed', 'twoFingerType', 'sonTwoFingerType', 'musicSortType',
+      'webDavSortType', 'navidromeSortType', 'customize_bg_blur', 'bg_brightness',
+      'lastColumns', 'defalut_home_type', 'themeMode', 'webdavUploadRetryCount'
+    ];
+
+    const stringKeys: string[] = [
+      'THEME_COLOR', 'is_customize_bg_path', 'fastForwardSeconds',
+      'webdavUploadDuplicateAction', 'LRC_API', 'COVER_API'
+    ];
+
+    const boolSettings: SettingBoolItem[] = [];
+    for (let i = 0; i < boolKeys.length; i++) {
+      const key = boolKeys[i];
+      boolSettings.push({ key: key, value: PreferencesUtil.getBooleanSync(key, false) });
+    }
+
+    const numberSettings: SettingNumberItem[] = [];
+    for (let i = 0; i < numberKeys.length; i++) {
+      const key = numberKeys[i];
+      numberSettings.push({ key: key, value: PreferencesUtil.getNumberSync(key, 0) });
+    }
+
+    const stringSettings: SettingStringItem[] = [];
+    for (let i = 0; i < stringKeys.length; i++) {
+      const key = stringKeys[i];
+      const value = PreferencesUtil.getStringSync(key, '');
+      // 跳过未设置的 string(空字符串),避免恢复时覆盖默认值
+      if (value.length > 0) {
+        stringSettings.push({ key: key, value: value });
+      }
+    }
+
+    Logger.info(TAG, `exportUserSettings: 导出 ${boolSettings.length + numberSettings.length + stringSettings.length} 个设置项`);
+
+    return {
+      booleanSettings: boolSettings,
+      numberSettings: numberSettings,
+      stringSettings: stringSettings
+    };
+  }
+
+  /**
+   * 校验备份 JSON 字符串
+   */
+  validateBackupJson(jsonStr: string): ValidateResult {
+    const result: ValidateResult = {
+      valid: false,
+      errorMessage: '',
+      data: null
+    };
+
+    if (!jsonStr || jsonStr.length === 0) {
+      result.errorMessage = '备份文件内容为空';
+      return result;
+    }
+
+    try {
+      const parsed = JSON.parse(jsonStr) as PlaylistBackupData;
+
+      if (parsed.version === undefined || parsed.version === null) {
+        result.errorMessage = '备份文件缺少版本号';
+        return result;
+      }
+
+      if (parsed.version > BACKUP_FORMAT_VERSION) {
+        result.errorMessage = `备份文件版本 (${parsed.version}) 不兼容,当前支持版本 ${BACKUP_FORMAT_VERSION}`;
+        return result;
+      }
+
+      if (!parsed.playlists || !Array.isArray(parsed.playlists)) {
+        result.errorMessage = '备份文件格式无效:缺少 playlists 字段';
+        return result;
+      }
+
+      if (!parsed.exportTime) {
+        result.errorMessage = '备份文件格式无效:缺少 exportTime 字段';
+        return result;
+      }
+
+      // 校验每个歌单的必要字段
+      for (let i = 0; i < parsed.playlists.length; i++) {
+        const playlist = parsed.playlists[i];
+        if (!playlist.name) {
+          result.errorMessage = `备份文件格式无效:第 ${i + 1} 个歌单缺少名称`;
+          return result;
+        }
+        if (!playlist.songs || !Array.isArray(playlist.songs)) {
+          result.errorMessage = `备份文件格式无效:歌单 "${playlist.name}" 缺少 songs 字段`;
+          return result;
+        }
+      }
+
+      // v2 格式校验网盘账户(可选字段)
+      if (parsed.version >= 2 && parsed.webDavAccounts !== undefined) {
+        if (!Array.isArray(parsed.webDavAccounts)) {
+          result.errorMessage = '备份文件格式无效:webDavAccounts 必须是数组';
+          return result;
+        }
+        for (let i = 0; i < parsed.webDavAccounts.length; i++) {
+          const acct = parsed.webDavAccounts[i];
+          if (!acct.host || acct.port === undefined) {
+            result.errorMessage = `备份文件格式无效:第 ${i + 1} 个网盘账户缺少必要字段`;
+            return result;
+          }
+        }
+      }
+
+      // v2 格式校验用户设置(可选字段)
+      if (parsed.version >= 2 && parsed.userSettings !== undefined) {
+        const settings = parsed.userSettings;
+        if (!settings.booleanSettings || !settings.numberSettings || !settings.stringSettings) {
+          result.errorMessage = '备份文件格式无效:userSettings 缺少必要字段';
+          return result;
+        }
+      }
+
+      result.valid = true;
+      result.data = parsed;
+      return result;
+    } catch (error) {
+      result.errorMessage = `备份文件不是有效的 JSON 格式: ${(error as Error).message}`;
+      return result;
+    }
+  }
+
+  /**
+   * 从 JSON 字符串导入完整备份(歌单 + 网盘配置 + 设置项)
+   * @param passphrase 解密密码(若备份已加密则必填)
+   */
+  async importFromJson(
+    jsonStr: string,
+    conflictsToSkip: Set<string>,
+    conflictsToOverwrite: Set<string>,
+    conflictsToRename: Set<string>,
+    passphrase: string = ''
+  ): Promise<ImportResult | null> {
+    if (!this.context) {
+      Logger.error(TAG, 'importFromJson: context 未设置');
+      return null;
+    }
+
+    const validateResult = this.validateBackupJson(jsonStr);
+    if (!validateResult.valid || !validateResult.data) {
+      ToastUtil.showToast(validateResult.errorMessage);
+      return null;
+    }
+
+    const backupData = validateResult.data;
+
+    // 解密密码字段
+    if (backupData.encrypted === true && backupData.encryptedPasswords &&
+      backupData.webDavAccounts && backupData.webDavAccounts.length > 0) {
+      if (passphrase.length === 0) {
+        ToastUtil.showToast('该备份已加密,请输入备份密码');
+        return null;
+      }
+      try {
+        const decryptedJson = await this.decryptData(
+          backupData.encryptedPasswords,
+          passphrase,
+          backupData.encryptionSalt || '',
+          backupData.encryptionIv || '',
+          backupData.encryptionTag || ''
+        );
+        const passwords = JSON.parse(decryptedJson) as string[];
+        for (let i = 0; i < backupData.webDavAccounts.length && i < passwords.length; i++) {
+          backupData.webDavAccounts[i].password = passwords[i];
+        }
+        Logger.info(TAG, 'importFromJson: 密码解密成功');
+      } catch (error) {
+        Logger.error(TAG, `importFromJson: 密码解密失败: ${(error as Error).message}`);
+        ToastUtil.showToast('备份密码错误,解密失败');
+        return null;
+      }
+    }
+
+    try {
+      // 1. 导入歌单
+      const playlistTable = new PlaylistTable(this.context);
+      const playlistResult = await playlistTable.importPlaylists(
+        backupData,
+        conflictsToSkip,
+        conflictsToOverwrite,
+        conflictsToRename
+      );
+
+      const result: ImportResult = {
+        totalPlaylists: playlistResult.totalPlaylists,
+        importedPlaylists: playlistResult.importedPlaylists,
+        skippedPlaylists: playlistResult.skippedPlaylists,
+        overwrittenPlaylists: playlistResult.overwrittenPlaylists,
+        renamedPlaylists: playlistResult.renamedPlaylists,
+        totalSongs: playlistResult.totalSongs,
+        importedSongs: playlistResult.importedSongs,
+        importedAccounts: 0,
+        skippedAccounts: 0,
+        settingsImported: false
+      };
+
+      // 2. 导入网盘账户(v2)
+      if (backupData.version >= 2 && backupData.webDavAccounts && backupData.webDavAccounts.length > 0) {
+        const accountResult = await this.importWebDavAccounts(backupData.webDavAccounts);
+        result.importedAccounts = accountResult.imported;
+        result.skippedAccounts = accountResult.skipped;
+      }
+
+      // 3. 导入用户设置(v2)
+      if (backupData.version >= 2 && backupData.userSettings) {
+        result.settingsImported = this.importUserSettings(backupData.userSettings);
+      }
+
+      Logger.info(TAG, `importFromJson: 导入完成 - 歌单 ${result.importedPlaylists}, 网盘账户 ${result.importedAccounts}, 设置 ${result.settingsImported}`);
+      return result;
+    } catch (error) {
+      Logger.error(TAG, `importFromJson: 导入失败: ${(error as Error).message}`);
+      return null;
+    }
+  }
+
+  /**
+   * 导入网盘账户(按 host+port+account 去重)
+   */
+  private async importWebDavAccounts(accounts: WebDavAccountBackupItem[]): Promise<AccountImportResult> {
+    const result: AccountImportResult = { imported: 0, skipped: 0 };
+    try {
+      const manager = RemoteDriveManager.getInstance();
+      const existingAccounts = manager.getAllWebDavAccounts();
+
+      for (let i = 0; i < accounts.length; i++) {
+        const backupAcct = accounts[i];
+
+        // 检查重复
+        let isDuplicate = false;
+        for (let j = 0; j < existingAccounts.length; j++) {
+          const existing = existingAccounts[j];
+          if (existing.host === backupAcct.host &&
+            existing.port === backupAcct.port &&
+            existing.account === backupAcct.account) {
+            isDuplicate = true;
+            break;
+          }
+        }
+
+        if (isDuplicate) {
+          result.skipped++;
+          Logger.info(TAG, `importWebDavAccounts: 跳过重复账户 ${backupAcct.name} (${backupAcct.host}:${backupAcct.port})`);
+          continue;
+        }
+
+        // 插入账户(注意参数顺序与 RemoteDriveManager.insertAccount 一致)
+        await manager.insertAccount(
+          backupAcct.name,
+          backupAcct.host,
+          backupAcct.localHost,
+          backupAcct.isUseLocalHost,
+          backupAcct.port,
+          backupAcct.filepath,
+          backupAcct.lyricFilePath,
+          backupAcct.uploadFilePath,
+          backupAcct.imageFilePath,
+          backupAcct.account,
+          backupAcct.password,
+          backupAcct.enableHttps,
+          backupAcct.coverPath,
+          backupAcct.webType,
+          backupAcct.smbShare,
+          backupAcct.smbDomain,
+          backupAcct.navidromeBasePath,
+          backupAcct.jellyfinBasePath,
+          backupAcct.embyBasePath,
+          backupAcct.ftpEncoding,
+          '', '', 0, // baiduAccessToken, baiduRefreshToken, baiduTokenExpiresAt(不恢复临时凭证)
+          backupAcct.sortOrder
+        );
+
+        result.imported++;
+        Logger.info(TAG, `importWebDavAccounts: 导入账户 ${backupAcct.name}`);
+      }
+
+      Logger.info(TAG, `importWebDavAccounts: 完成 - 导入 ${result.imported}, 跳过 ${result.skipped}`);
+    } catch (error) {
+      Logger.error(TAG, `importWebDavAccounts: 失败: ${(error as Error).message}`);
+    }
+    return result;
+  }
+
+  /**
+   * 导入用户设置
+   */
+  private importUserSettings(settings: SettingsBackupData): boolean {
+    try {
+      let count = 0;
+
+      // 导入 boolean 设置
+      for (let i = 0; i < settings.booleanSettings.length; i++) {
+        const item = settings.booleanSettings[i];
+        PreferencesUtil.putSync(item.key, item.value);
+        count++;
+      }
+
+      // 导入 number 设置
+      for (let i = 0; i < settings.numberSettings.length; i++) {
+        const item = settings.numberSettings[i];
+        PreferencesUtil.putSync(item.key, item.value);
+        count++;
+      }
+
+      // 导入 string 设置
+      for (let i = 0; i < settings.stringSettings.length; i++) {
+        const item = settings.stringSettings[i];
+        // 跳过空字符串,避免覆盖默认值(如主题色)
+        if (item.value.length === 0) {
+          continue;
+        }
+        PreferencesUtil.putSync(item.key, item.value);
+        // 特殊处理:THEME_COLOR 需要同步到 AppStorage 的 themeColor
+        if (item.key === 'THEME_COLOR') {
+          AppStorage.setOrCreate('themeColor', item.value);
+        }
+        count++;
+      }
+
+      Logger.info(TAG, `importUserSettings: 导入 ${count} 个设置项`);
+      return true;
+    } catch (error) {
+      Logger.error(TAG, `importUserSettings: 失败: ${(error as Error).message}`);
+      return false;
+    }
+  }
+
+  /**
+   * 检测冲突
+   */
+  async detectConflicts(jsonStr: string): Promise<string[]> {
+    if (!this.context) {
+      return [];
+    }
+
+    const validateResult = this.validateBackupJson(jsonStr);
+    if (!validateResult.valid || !validateResult.data) {
+      return [];
+    }
+
+    const playlistTable = new PlaylistTable(this.context);
+    return playlistTable.detectConflicts(validateResult.data);
+  }
+
+  // ========== 本地文件操作 ==========
+
+  /**
+   * 导出备份到本地文件
+   * @param passphrase 可选加密密码
+   */
+  async saveToLocal(passphrase: string = ''): Promise<boolean> {
+    try {
+      const jsonStr = await this.exportToJson(passphrase);
+      if (!jsonStr || jsonStr.length === 0) {
+        ToastUtil.showToast('导出备份数据失败');
+        return false;
+      }
+
+      const now = new Date();
+      const year = now.getFullYear();
+      const month = String(now.getMonth() + 1).padStart(2, '0');
+      const day = String(now.getDate()).padStart(2, '0');
+      const hours = String(now.getHours()).padStart(2, '0');
+      const minutes = String(now.getMinutes()).padStart(2, '0');
+      const seconds = String(now.getSeconds()).padStart(2, '0');
+      const fileName = `ttmusic_backup_${year}${month}${day}_${hours}${minutes}${seconds}.json`;
+
+      const documentSaveOptions = new picker.DocumentSaveOptions();
+      documentSaveOptions.newFileNames = [fileName];
+      documentSaveOptions.fileSuffixChoices = ['.json'];
+
+      const documentViewPicker = new picker.DocumentViewPicker();
+      const saveResult = await documentViewPicker.save(documentSaveOptions);
+
+      if (!saveResult || saveResult.length === 0) {
+        Logger.info(TAG, 'saveToLocal: 用户取消了文件选择');
+        return false;
+      }
+
+      const uri = saveResult[0];
+      const file = fileIo.openSync(uri, fileIo.OpenMode.WRITE_ONLY | fileIo.OpenMode.CREATE);
+      fileIo.writeSync(file.fd, jsonStr);
+      fileIo.closeSync(file.fd);
+
+      // 记录历史
+      const validateResult = this.validateBackupJson(jsonStr);
+      const data = validateResult.data;
+      const playlistCount = data ? data.playlists.length : 0;
+      const accountCount = (data && data.webDavAccounts) ? data.webDavAccounts.length : 0;
+      const hasSettings = !!(data && data.userSettings);
+      this.addHistoryRecord('local', playlistCount, fileName, accountCount, hasSettings);
+
+      ToastUtil.showToast(`备份成功: ${fileName}`);
+      Logger.info(TAG, `saveToLocal: 文件保存成功: ${uri}`);
+      return true;
+    } catch (error) {
+      const err = error as Error;
+      Logger.error(TAG, `saveToLocal: 保存失败: ${err.message}`);
+      ToastUtil.showToast(`备份失败: ${err.message}`);
+      return false;
+    }
+  }
+
+  /**
+   * 从本地文件加载备份
+   */
+  async loadFromLocal(): Promise<string> {
+    try {
+      const documentSelectOptions = new picker.DocumentSelectOptions();
+      documentSelectOptions.fileSuffixFilters = ['.json'];
+      documentSelectOptions.maxSelectNumber = 1;
+
+      const documentViewPicker = new picker.DocumentViewPicker();
+      const selectResult = await documentViewPicker.select(documentSelectOptions);
+
+      if (!selectResult || selectResult.length === 0) {
+        Logger.info(TAG, 'loadFromLocal: 用户取消了文件选择');
+        return '';
+      }
+
+      const uri = selectResult[0];
+      const file = fileIo.openSync(uri, fileIo.OpenMode.READ_ONLY);
+      const stat = fileIo.statSync(file.fd);
+      const buffer = new ArrayBuffer(stat.size);
+      fileIo.readSync(file.fd, buffer);
+      fileIo.closeSync(file.fd);
+
+      const textDecoder = new util.TextDecoder('utf-8');
+      const jsonStr = textDecoder.decodeWithStream(new Uint8Array(buffer));
+
+      Logger.info(TAG, `loadFromLocal: 文件读取成功, ${jsonStr.length} 字节`);
+      return jsonStr;
+    } catch (error) {
+      const err = error as Error;
+      Logger.error(TAG, `loadFromLocal: 读取失败: ${err.message}`);
+      ToastUtil.showToast(`读取备份文件失败: ${err.message}`);
+      return '';
+    }
+  }
+
+  // ========== WebDAV 备份 ==========
+
+  /**
+   * 生成备份文件名
+   */
+  private generateBackupFileName(): string {
+    const now = new Date();
+    const year = now.getFullYear();
+    const month = String(now.getMonth() + 1).padStart(2, '0');
+    const day = String(now.getDate()).padStart(2, '0');
+    const hours = String(now.getHours()).padStart(2, '0');
+    const minutes = String(now.getMinutes()).padStart(2, '0');
+    const seconds = String(now.getSeconds()).padStart(2, '0');
+    return `ttmusic_backup_${year}${month}${day}_${hours}${minutes}${seconds}.json`;
+  }
+
+  /**
+   * 获取 WebDAV 账户的基准路径
+   */
+  private getWebDavBasePath(account: WebDavAccount): string {
+    const base = account.filepath || '/';
+    if (base.length === 0) {
+      return '/';
+    }
+    // 确保以 / 开头并以 / 结尾(用于拼接)
+    let normalized = base;
+    if (!normalized.startsWith('/')) {
+      normalized = '/' + normalized;
+    }
+    return normalized.replace(/\/+$/, '');
+  }
+
+  /**
+   * 确保 WebDAV 备份目录存在
+   */
+  private async ensureWebDavBackupDir(account: WebDavAccount): Promise<void> {
+    const manager = RemoteDriveManager.getInstance();
+    const basePath = this.getWebDavBasePath(account);
+    try {
+      await manager.createWebDavFolder(account, 'TTMusic', basePath);
+    } catch (e) {
+      // 目录可能已存在
+    }
+    try {
+      await manager.createWebDavFolder(account, 'backups', basePath + '/TTMusic');
+    } catch (e) {
+      // 目录可能已存在
+    }
+  }
+
+  /**
+   * 上传备份到 WebDAV
+   * @param passphrase 可选加密密码
+   */
+  async uploadToWebDav(account: WebDavAccount, passphrase: string = ''): Promise<boolean> {
+    try {
+      const jsonStr = await this.exportToJson(passphrase);
+      if (!jsonStr || jsonStr.length === 0) {
+        ToastUtil.showToast('导出备份数据失败');
+        return false;
+      }
+
+      if (!this.context) {
+        ToastUtil.showToast('上下文未初始化');
+        return false;
+      }
+
+      const manager = RemoteDriveManager.getInstance();
+
+      // 确保备份目录存在
+      await this.ensureWebDavBackupDir(account);
+
+      const fileName = this.generateBackupFileName();
+
+      // 写入临时文件
+      const tempPath = this.context.cacheDir + '/' + fileName;
+      const tempFile = fileIo.openSync(tempPath, fileIo.OpenMode.WRITE_ONLY | fileIo.OpenMode.CREATE);
+      fileIo.writeSync(tempFile.fd, jsonStr);
+      fileIo.closeSync(tempFile.fd);
+
+      // 通过 rcpSocket 上传
+      const basePath = this.getWebDavBasePath(account);
+      const remotePath = `${basePath}/TTMusic/backups/${fileName}`;
+      const host = account.isUseLocalHost && account.localHost ? account.localHost : account.host;
+      await manager.rcpSocket.uploadFile(
+        tempPath,
+        remotePath,
+        host,
+        account.port,
+        account.account,
+        account.password,
+        account.enableHttps,
+        () => {} // 备份文件很小,不需要进度回调
+      );
+
+      // 清理临时文件
+      try {
+        fileIo.unlinkSync(tempPath);
+      } catch (e) {
+        // 忽略
+      }
+
+      // 记录历史
+      const validateResult = this.validateBackupJson(jsonStr);
+      const data = validateResult.data;
+      const playlistCount = data ? data.playlists.length : 0;
+      const accountCount = (data && data.webDavAccounts) ? data.webDavAccounts.length : 0;
+      const hasSettings = !!(data && data.userSettings);
+      this.addHistoryRecord('webdav', playlistCount, fileName, accountCount, hasSettings);
+
+      ToastUtil.showToast(`WebDAV备份成功: ${fileName}`);
+      Logger.info(TAG, `uploadToWebDav: 上传成功: ${remotePath}`);
+      return true;
+    } catch (error) {
+      const err = error as Error;
+      Logger.error(TAG, `uploadToWebDav: 上传失败: ${err.message}`);
+      ToastUtil.showToast(`WebDAV备份失败: ${err.message}`);
+      return false;
+    }
+  }
+
+  /**
+   * 列出 WebDAV 上的备份文件
+   */
+  async listWebDavBackups(account: WebDavAccount): Promise<string[]> {
+    try {
+      const manager = RemoteDriveManager.getInstance();
+      const basePath = this.getWebDavBasePath(account);
+      const files = await manager.rcpSocket.getFileList(
+        account.host,
+        account.localHost,
+        account.isUseLocalHost,
+        account.port,
+        `${basePath}/TTMusic/backups/`,
+        account.account,
+        account.password,
+        account.enableHttps
+      );
+
+      const jsonFiles: string[] = [];
+      for (let i = 0; i < files.length; i++) {
+        const file = files[i];
+        const name = file.fileName ? file.fileName : '';
+        if (name.endsWith('.json')) {
+          jsonFiles.push(name);
+        }
+      }
+
+      // 按文件名倒序排列(最新的在前)
+      jsonFiles.sort((a, b) => b.localeCompare(a));
+      Logger.info(TAG, `listWebDavBackups: 找到 ${jsonFiles.length} 个备份文件`);
+      return jsonFiles;
+    } catch (error) {
+      Logger.error(TAG, `listWebDavBackups: 列表失败: ${(error as Error).message}`);
+      return [];
+    }
+  }
+
+  /**
+   * 从 WebDAV 下载备份文件(通过 rcp HTTP GET)
+   */
+  async downloadFromWebDav(account: WebDavAccount, fileName: string): Promise<string> {
+    try {
+      if (!this.context) {
+        return '';
+      }
+
+      const basePath = this.getWebDavBasePath(account);
+      const remotePath = `${basePath}/TTMusic/backups/${fileName}`;
+      const host = account.isUseLocalHost && account.localHost ? account.localHost : account.host;
+      const protocol = account.enableHttps ? 'https' : 'http';
+      const url = `${protocol}://${host}:${account.port}${remotePath}`;
+
+      const encodedCredentials = buffer.from(`${account.account}:${account.password}`).toString('base64');
+
+      const secCfg: rcp.SecurityConfiguration = { remoteValidation: 'skip' };
+      const reqCfg: rcp.Configuration = {
+        security: secCfg,
+        transfer: {
+          timeout: { connectMs: 15000 }
+        }
+      };
+      const sessionCfg: rcp.SessionConfiguration = { requestConfiguration: reqCfg };
+      const rcpSession = rcp.createSession(sessionCfg);
+
+      const headers: rcp.RequestHeaders = {
+        'Authorization': `Basic ${encodedCredentials}`,
+        'Accept': '*/*'
+      };
+      const req = new rcp.Request(url, 'GET', headers);
+
+      const response = await rcpSession.fetch(req);
+      rcpSession.close();
+
+      if (response.statusCode !== 200) {
+        throw new Error(`HTTP ${response.statusCode}`);
+      }
+
+      let jsonStr = '';
+      if (response.body) {
+        const textDecoder = new util.TextDecoder('utf-8');
+        jsonStr = textDecoder.decodeWithStream(new Uint8Array(response.body as ArrayBuffer));
+      }
+
+      Logger.info(TAG, `downloadFromWebDav: 下载成功: ${fileName}, ${jsonStr.length} 字节`);
+      return jsonStr;
+    } catch (error) {
+      Logger.error(TAG, `downloadFromWebDav: 下载失败: ${(error as Error).message}`);
+      ToastUtil.showToast(`下载备份失败: ${(error as Error).message}`);
+      return '';
+    }
+  }
+
+  // ========== 服务器备份(VIP) ==========
+
+  /**
+   * 检查 VIP 状态
+   */
+  isVipUser(): boolean {
+    return PreferencesUtil.getBooleanSync('hasActiveSubscription', false);
+  }
+
+  /**
+   * 上传备份到服务器
+   * @param passphrase 可选加密密码
+   */
+  async uploadToServer(passphrase: string = ''): Promise<boolean> {
+    if (!this.isVipUser()) {
+      ToastUtil.showToast('该功能仅限VIP用户使用');
+      return false;
+    }
+
+    try {
+      const jsonStr = await this.exportToJson(passphrase);
+      if (!jsonStr || jsonStr.length === 0) {
+        ToastUtil.showToast('导出备份数据失败');
+        return false;
+      }
+
+      const token = PreferencesUtil.getStringSync('userToken', '');
+      if (!token) {
+        ToastUtil.showToast('请先登录');
+        return false;
+      }
+
+      const httpRequest = http.createHttp();
+      const options: http.HttpRequestOptions = {
+        method: http.RequestMethod.POST,
+        readTimeout: 10000,
+        connectTimeout: 10000,
+        header: {
+          'Content-Type': 'application/json'
+        },
+        extraData: JSON.stringify({
+          token: token,
+          backup_data: jsonStr
+        })
+      };
+
+      const response = await httpRequest.request(
+        'https://pay.ss5.xyz/backup/playlist/upload',
+        options
+      );
+
+      if (response.responseCode === 200) {
+        const res = response.result as string;
+        const json = JSON.parse(res) as ServerBackupResponse;
+        if (json.code === 0) {
+          const validateResult = this.validateBackupJson(jsonStr);
+          const data = validateResult.data;
+          const playlistCount = data ? data.playlists.length : 0;
+          const accountCount = (data && data.webDavAccounts) ? data.webDavAccounts.length : 0;
+          const hasSettings = !!(data && data.userSettings);
+          this.addHistoryRecord('server', playlistCount, '', accountCount, hasSettings);
+          ToastUtil.showToast('服务器备份成功');
+          Logger.info(TAG, 'uploadToServer: 上传成功');
+          return true;
+        } else if (json.code === 40002 || json.code === 40003) {
+          ToastUtil.showToast('登录已过期,请重新登录');
+          this.pauseAutoBackup();
+          return false;
+        } else {
+          ToastUtil.showToast(`服务器备份失败: ${json.msg || '未知错误'}`);
+          return false;
+        }
+      } else {
+        ToastUtil.showToast('服务器备份请求失败');
+        return false;
+      }
+    } catch (error) {
+      Logger.error(TAG, `uploadToServer: 上传失败: ${(error as Error).message}`);
+      ToastUtil.showToast(`服务器备份失败: ${(error as Error).message}`);
+      return false;
+    }
+  }
+
+  /**
+   * 从服务器下载备份
+   */
+  async downloadFromServer(backupId: string): Promise<string> {
+    if (!this.isVipUser()) {
+      ToastUtil.showToast('该功能仅限VIP用户使用');
+      return '';
+    }
+
+    try {
+      const token = PreferencesUtil.getStringSync('userToken', '');
+      if (!token) {
+        ToastUtil.showToast('请先登录');
+        return '';
+      }
+
+      const httpRequest = http.createHttp();
+      const url = `https://pay.ss5.xyz/backup/playlist/download?token=${encodeURIComponent(token)}&id=${encodeURIComponent(backupId)}`;
+      const options: http.HttpRequestOptions = {
+        method: http.RequestMethod.GET,
+        readTimeout: 10000,
+        connectTimeout: 10000,
+      };
+
+      const response = await httpRequest.request(url, options);
+
+      if (response.responseCode === 200) {
+        const res = response.result as string;
+        const json = JSON.parse(res) as ServerBackupDownloadResponse;
+        if (json.code === 0 && json.data && json.data.backup_data) {
+          Logger.info(TAG, 'downloadFromServer: 下载成功');
+          return json.data.backup_data;
+        } else if (json.code === 40002 || json.code === 40003) {
+          ToastUtil.showToast('登录已过期,请重新登录');
+          return '';
+        } else {
+          ToastUtil.showToast(`下载失败: ${json.msg || '未知错误'}`);
+          return '';
+        }
+      } else {
+        ToastUtil.showToast('下载请求失败');
+        return '';
+      }
+    } catch (error) {
+      Logger.error(TAG, `downloadFromServer: 下载失败: ${(error as Error).message}`);
+      ToastUtil.showToast(`下载失败: ${(error as Error).message}`);
+      return '';
+    }
+  }
+
+  /**
+   * 获取服务器备份列表
+   */
+  async listServerBackups(): Promise<ServerBackupItem[]> {
+    if (!this.isVipUser()) {
+      return [];
+    }
+
+    try {
+      const token = PreferencesUtil.getStringSync('userToken', '');
+      if (!token) {
+        return [];
+      }
+
+      const httpRequest = http.createHttp();
+      const url = `https://pay.ss5.xyz/backup/playlist/lists?token=${encodeURIComponent(token)}`;
+      const options: http.HttpRequestOptions = {
+        method: http.RequestMethod.GET,
+        readTimeout: 6000,
+        connectTimeout: 6000,
+      };
+
+      const response = await httpRequest.request(url, options);
+
+      if (response.responseCode === 200) {
+        const res = response.result as string;
+        const json = JSON.parse(res) as ServerBackupListResponse;
+        if (json.code === 0 && json.data && json.data.backups) {
+          Logger.info(TAG, `listServerBackups: 获取到 ${json.data.backups.length} 条备份`);
+          return json.data.backups;
+        }
+      }
+      return [];
+    } catch (error) {
+      Logger.error(TAG, `listServerBackups: 获取列表失败: ${(error as Error).message}`);
+      return [];
+    }
+  }
+
+  // ========== 自动备份防抖 ==========
+
+  /**
+   * 调度自动备份(防抖)
+   */
+  scheduleAutoBackup(): void {
+    if (!this.isAutoBackupEnabled()) {
+      return;
+    }
+
+    if (!this.isVipUser()) {
+      this.setAutoBackupEnabled(false);
+      Logger.info(TAG, 'scheduleAutoBackup: VIP已过期,关闭自动备份');
+      return;
+    }
+
+    // 清除之前的定时器
+    if (this.autoBackupTimer !== -1) {
+      clearTimeout(this.autoBackupTimer);
+    }
+
+    // 设置新的 5 分钟定时器
+    this.autoBackupTimer = setTimeout(() => {
+      this.autoBackupTimer = -1;
+      this.uploadToServer().then((success: boolean) => {
+        if (success) {
+          Logger.info(TAG, 'scheduleAutoBackup: 自动备份完成');
+        }
+      }).catch((error: Error) => {
+        Logger.error(TAG, `scheduleAutoBackup: 自动备份失败: ${error.message}`);
+      });
+    }, AUTO_BACKUP_DELAY_MS);
+
+    Logger.info(TAG, 'scheduleAutoBackup: 已设置 5 分钟后自动备份');
+  }
+
+  /**
+   * 暂停自动备份
+   */
+  private pauseAutoBackup(): void {
+    if (this.autoBackupTimer !== -1) {
+      clearTimeout(this.autoBackupTimer);
+      this.autoBackupTimer = -1;
+    }
+  }
+
+  isAutoBackupEnabled(): boolean {
+    return PreferencesUtil.getBooleanSync(AUTO_BACKUP_ENABLED_KEY, false);
+  }
+
+  setAutoBackupEnabled(enabled: boolean): void {
+    PreferencesUtil.putSync(AUTO_BACKUP_ENABLED_KEY, enabled);
+  }
+
+  // ========== 备份历史 ==========
+
+  /**
+   * 添加备份历史记录
+   */
+  addHistoryRecord(type: string, playlistCount: number, filePath: string, accountCount: number = 0, hasSettings: boolean = false): void {
+    try {
+      const history = this.getBackupHistory();
+      const record: BackupHistoryItem = {
+        type: type,
+        timestamp: new Date().toISOString(),
+        playlistCount: playlistCount,
+        filePath: filePath,
+        accountCount: accountCount,
+        hasSettings: hasSettings
+      };
+
+      history.unshift(record);
+
+      // 保留最多 20 条
+      while (history.length > MAX_HISTORY_ITEMS) {
+        history.pop();
+      }
+
+      PreferencesUtil.putSync(BACKUP_HISTORY_KEY, JSON.stringify(history));
+    } catch (error) {
+      Logger.error(TAG, `addHistoryRecord: 保存历史失败: ${(error as Error).message}`);
+    }
+  }
+
+  /**
+   * 获取备份历史
+   */
+  getBackupHistory(): BackupHistoryItem[] {
+    try {
+      const jsonStr = PreferencesUtil.getStringSync(BACKUP_HISTORY_KEY, '[]');
+      return JSON.parse(jsonStr) as BackupHistoryItem[];
+    } catch (error) {
+      return [];
+    }
+  }
+
+  /**
+   * 获取仅WebDAV类型的账户列表
+   */
+  getWebDavAccounts(): WebDavAccount[] {
+    const manager = RemoteDriveManager.getInstance();
+    const allAccounts = manager.getAllWebDavAccounts();
+    const webdavAccounts: WebDavAccount[] = [];
+    for (let i = 0; i < allAccounts.length; i++) {
+      if (allAccounts[i].webType === RemoteDriveType.WebDav) {
+        webdavAccounts.push(allAccounts[i]);
+      }
+    }
+    return webdavAccounts;
+  }
+
+  // ========== 加密/解密(AES-256-GCM) ==========
+
+  /**
+   * 使用 AES-256-GCM 加密数据
+   * 密钥由 passphrase + 随机 salt 通过 SHA-256 派生
+   */
+  private async encryptData(plaintext: string, passphrase: string): Promise<EncryptionResult> {
+    // 生成随机 salt (16字节) 和 IV (12字节)
+    const random = cryptoFramework.createRandom();
+    const saltBlob = await random.generateRandom(16);
+    const ivBlob = await random.generateRandom(12);
+
+    // 派生密钥: SHA-256(passphrase + salt)
+    const symKey = await this.deriveKey(passphrase, saltBlob);
+
+    // AES-256-GCM 加密
+    const cipher = cryptoFramework.createCipher('AES256|GCM|NoPadding');
+    const gcmParams: cryptoFramework.GcmParamsSpec = {
+      iv: { data: ivBlob.data },
+      aad: { data: new Uint8Array(0) },
+      authTag: { data: new Uint8Array(16) },
+      algName: 'GcmParamsSpec'
+    };
+    await cipher.init(cryptoFramework.CryptoMode.ENCRYPT_MODE, symKey, gcmParams);
+
+    const encoder = new util.TextEncoder();
+    const plaintextBytes = encoder.encodeInto(plaintext);
+    // GCM doFinal 输出 = 密文 + authTag(末尾16字节)
+    const cipherOutput = await cipher.doFinal({ data: plaintextBytes });
+    const fullOutput = new Uint8Array(cipherOutput.data);
+    const tagLen = 16;
+    const cipherBytes = fullOutput.slice(0, fullOutput.byteLength - tagLen);
+    const tagBytes = fullOutput.slice(fullOutput.byteLength - tagLen);
+
+    // 使用 Base64Helper 编码
+    const base64Helper = new util.Base64Helper();
+    const result: EncryptionResult = {
+      cipherText: base64Helper.encodeToStringSync(cipherBytes),
+      salt: base64Helper.encodeToStringSync(saltBlob.data),
+      iv: base64Helper.encodeToStringSync(ivBlob.data),
+      tag: base64Helper.encodeToStringSync(tagBytes)
+    };
+    Logger.info(TAG, `encryptData: fullOutput=${fullOutput.byteLength}B, cipher=${cipherBytes.byteLength}B, tag=${tagBytes.byteLength}B, tagHead=[${tagBytes[0]},${tagBytes[1]},${tagBytes[2]},${tagBytes[3]}]`);
+    return result;
+  }
+
+  /**
+   * 使用 AES-256-GCM 解密数据
+   */
+  private async decryptData(
+    cipherTextB64: string,
+    passphrase: string,
+    saltB64: string,
+    ivB64: string,
+    tagB64: string
+  ): Promise<string> {
+    // 使用 Base64Helper 解码,避免 buffer 内部池导致的数据错位
+    const base64Helper = new util.Base64Helper();
+    const saltData = base64Helper.decodeSync(saltB64);
+    const ivData = base64Helper.decodeSync(ivB64);
+    const tagData = base64Helper.decodeSync(tagB64);
+    const cipherData = base64Helper.decodeSync(cipherTextB64);
+
+    Logger.info(TAG, `decryptData: cipher=${cipherData.byteLength}B, salt=${saltData.byteLength}B, iv=${ivData.byteLength}B, tag=${tagData.byteLength}B`);
+
+    // 同样方式派生密钥
+    const symKey = await this.deriveKey(passphrase, { data: saltData });
+
+    // AES-256-GCM 解密
+    const cipher = cryptoFramework.createCipher('AES256|GCM|NoPadding');
+    const gcmParams: cryptoFramework.GcmParamsSpec = {
+      iv: { data: ivData },
+      aad: { data: new Uint8Array(0) },
+      authTag: { data: tagData },
+      algName: 'GcmParamsSpec'
+    };
+    await cipher.init(cryptoFramework.CryptoMode.DECRYPT_MODE, symKey, gcmParams);
+
+    // GCM 解密:密文(不含authTag)传入 doFinal,authTag 通过 gcmParams 传入
+    const decryptOutput = await cipher.doFinal({ data: cipherData });
+
+    const textDecoder = new util.TextDecoder('utf-8');
+    return textDecoder.decodeWithStream(new Uint8Array(decryptOutput.data));
+  }
+
+  /**
+   * 从 passphrase + salt 派生 AES-256 密钥(SHA-256)
+   */
+  private async deriveKey(passphrase: string, salt: cryptoFramework.DataBlob): Promise<cryptoFramework.SymKey> {
+    const md = cryptoFramework.createMd('SHA256');
+    const encoder = new util.TextEncoder();
+    await md.update({ data: encoder.encodeInto(passphrase) });
+    await md.update(salt);
+    const hash = await md.digest();
+
+    const keyGenerator = cryptoFramework.createSymKeyGenerator('AES256');
+    return await keyGenerator.convertKey(hash);
+  }
+}
+
+// ========== 服务器API响应接口 ==========
+
+interface ServerBackupResponse {
+  code: number;
+  msg: string;
+}
+
+export interface ServerBackupItem {
+  id: string;
+  timestamp: string;
+  playlist_count: number;
+}
+
+interface ServerBackupDownloadResponse {
+  code: number;
+  msg: string;
+  data: ServerBackupDownloadData;
+}
+
+interface ServerBackupDownloadData {
+  backup_data: string;
+}
+
+interface ServerBackupListResponse {
+  code: number;
+  msg: string;
+  data: ServerBackupListData;
+}
+
+interface ServerBackupListData {
+  backups: ServerBackupItem[];
+}

+ 234 - 0
entry/src/main/ets/common/util/PlaylistTable.ets

@@ -3,6 +3,15 @@ import { Context } from '@kit.AbilityKit';
 import Logger from './Logger';
 import RdbUtils from './RdbUtils';
 import { Playlist, PlaylistSong } from '../../viewmodel/Playlist';
+import {
+  PlaylistBackupData,
+  PlaylistBackupItem,
+  PlaylistSongBackupItem,
+  ImportResult,
+  ConflictResolution,
+  BACKUP_FORMAT_VERSION
+} from '../../viewmodel/PlaylistBackup';
+import { AppUtil } from '@pura/harmony-utils';
 
 /**
  * 歌单数据库操作类
@@ -92,6 +101,7 @@ export default class PlaylistTable {
 
       await this.rdbStore.executeSql(sql, params);
       Logger.info('heanup PlaylistTable', `歌单创建成功: ${name}`);
+      this.triggerAutoBackup();
       return true;
     } catch (error) {
       Logger.error('heanup PlaylistTable', `创建歌单失败: ${error.message}`);
@@ -117,6 +127,7 @@ export default class PlaylistTable {
       await this.rdbStore.executeSql(deletePlaylistSql, [playlistId]);
       
       Logger.info('heanup PlaylistTable', `歌单删除成功: ${playlistId}`);
+      this.triggerAutoBackup();
       return true;
     } catch (error) {
       Logger.error('heanup PlaylistTable', `删除歌单失败: ${error.message}`);
@@ -158,6 +169,7 @@ export default class PlaylistTable {
       await this.rdbStore.executeSql(sql, params);
       
       Logger.info('heanup PlaylistTable', `歌单更新成功: ${playlistId}`);
+      this.triggerAutoBackup();
       return true;
     } catch (error) {
       Logger.error('heanup PlaylistTable', `更新歌单失败: ${error.message}`);
@@ -318,6 +330,7 @@ export default class PlaylistTable {
       await this.updatePlaylistSongCount(playlistId);
 
       Logger.info('heanup PlaylistTable', `歌曲添加到歌单成功: ${songFilePath}`);
+      this.triggerAutoBackup();
       return true;
     } catch (error) {
       Logger.error('heanup PlaylistTable', `添加歌曲到歌单失败: ${error.message}`);
@@ -400,6 +413,7 @@ export default class PlaylistTable {
       await this.updatePlaylistSongCount(playlistId);
 
       Logger.info('heanup PlaylistTable', `removeSongFromPlaylist: 歌曲从歌单移除成功: ${songFilePath}`);
+      this.triggerAutoBackup();
       return true;
     } catch (error) {
       Logger.error('heanup PlaylistTable', `removeSongFromPlaylist: 从歌单移除歌曲失败: ${error.message}`);
@@ -643,4 +657,224 @@ export default class PlaylistTable {
       return false;
     }
   }
+
+  /**
+   * 导出所有歌单及其歌曲为备份数据
+   */
+  async exportAllPlaylists(): Promise<PlaylistBackupData> {
+    await this.ensureInitialized();
+
+    const backupData: PlaylistBackupData = {
+      version: BACKUP_FORMAT_VERSION,
+      exportTime: new Date().toISOString(),
+      appVersion: AppUtil.getVersionName(),
+      playlists: []
+    };
+
+    if (!this.rdbStore) {
+      Logger.error('heanup PlaylistTable', 'exportAllPlaylists: 数据库未初始化');
+      return backupData;
+    }
+
+    try {
+      const playlists = await this.queryAllPlaylists();
+      Logger.info('heanup PlaylistTable', `exportAllPlaylists: 开始导出 ${playlists.length} 个歌单`);
+
+      for (let i = 0; i < playlists.length; i++) {
+        const playlist = playlists[i];
+        const songs = await this.queryPlaylistSongs(playlist.id);
+
+        const songBackups: PlaylistSongBackupItem[] = [];
+        for (let j = 0; j < songs.length; j++) {
+          const song = songs[j];
+          songBackups.push({
+            songFilePath: song.songFilePath,
+            addTime: song.addTime,
+            sortOrder: song.sortOrder
+          });
+        }
+
+        const playlistBackup: PlaylistBackupItem = {
+          id: playlist.id,
+          name: playlist.name,
+          coverPath: playlist.coverPath || '',
+          description: playlist.description || '',
+          createTime: playlist.createTime,
+          updateTime: playlist.updateTime,
+          songCount: playlist.songCount,
+          sortOrder: playlist.sortOrder,
+          songs: songBackups
+        };
+        backupData.playlists.push(playlistBackup);
+      }
+
+      Logger.info('heanup PlaylistTable', `exportAllPlaylists: 导出完成, ${backupData.playlists.length} 个歌单`);
+      return backupData;
+    } catch (error) {
+      Logger.error('heanup PlaylistTable', `exportAllPlaylists: 导出失败: ${(error as Error).message}`);
+      return backupData;
+    }
+  }
+
+  /**
+   * 从备份数据导入歌单
+   * @param backupData 备份数据
+   * @param conflictStrategy 冲突解决策略(null 表示需要逐一询问,由调用方处理)
+   * @param conflictsToSkip 需要跳过的歌单名称集合
+   * @param conflictsToOverwrite 需要覆盖的歌单名称集合
+   * @param conflictsToRename 需要重命名的歌单名称集合
+   */
+  async importPlaylists(
+    backupData: PlaylistBackupData,
+    conflictsToSkip: Set<string>,
+    conflictsToOverwrite: Set<string>,
+    conflictsToRename: Set<string>
+  ): Promise<ImportResult> {
+    await this.ensureInitialized();
+
+    const result: ImportResult = {
+      totalPlaylists: backupData.playlists.length,
+      importedPlaylists: 0,
+      skippedPlaylists: 0,
+      overwrittenPlaylists: 0,
+      renamedPlaylists: 0,
+      totalSongs: 0,
+      importedSongs: 0,
+      importedAccounts: 0,
+      skippedAccounts: 0,
+      settingsImported: false
+    };
+
+    if (!this.rdbStore) {
+      Logger.error('heanup PlaylistTable', 'importPlaylists: 数据库未初始化');
+      return result;
+    }
+
+    try {
+      for (let i = 0; i < backupData.playlists.length; i++) {
+        const playlistItem = backupData.playlists[i];
+        result.totalSongs += playlistItem.songs.length;
+
+        const existingPlaylist = await this.queryPlaylistByName(playlistItem.name);
+
+        if (existingPlaylist) {
+          // 存在同名歌单,按策略处理
+          if (conflictsToSkip.has(playlistItem.name)) {
+            result.skippedPlaylists++;
+            Logger.info('heanup PlaylistTable', `importPlaylists: 跳过歌单: ${playlistItem.name}`);
+            continue;
+          } else if (conflictsToOverwrite.has(playlistItem.name)) {
+            // 覆盖:先删除旧歌单
+            await this.deletePlaylist(existingPlaylist.id);
+            result.overwrittenPlaylists++;
+            Logger.info('heanup PlaylistTable', `importPlaylists: 覆盖歌单: ${playlistItem.name}`);
+          } else if (conflictsToRename.has(playlistItem.name)) {
+            // 重命名
+            const newName = await this.generateUniqueName(playlistItem.name);
+            playlistItem.name = newName;
+            result.renamedPlaylists++;
+            Logger.info('heanup PlaylistTable', `importPlaylists: 重命名歌单为: ${newName}`);
+          } else {
+            // 默认跳过
+            result.skippedPlaylists++;
+            continue;
+          }
+        }
+
+        // 创建歌单
+        const created = await this.createPlaylist(
+          playlistItem.name,
+          playlistItem.description,
+          playlistItem.coverPath
+        );
+
+        if (!created) {
+          Logger.error('heanup PlaylistTable', `importPlaylists: 创建歌单失败: ${playlistItem.name}`);
+          continue;
+        }
+
+        // 查询刚创建的歌单
+        const newPlaylist = await this.queryPlaylistByName(playlistItem.name);
+        if (!newPlaylist) {
+          Logger.error('heanup PlaylistTable', `importPlaylists: 找不到新建歌单: ${playlistItem.name}`);
+          continue;
+        }
+
+        result.importedPlaylists++;
+
+        // 导入歌曲关联
+        const songPaths: string[] = [];
+        for (let j = 0; j < playlistItem.songs.length; j++) {
+          songPaths.push(playlistItem.songs[j].songFilePath);
+        }
+
+        if (songPaths.length > 0) {
+          const addResult = await this.addSongsToPlaylist(newPlaylist.id, songPaths);
+          if (addResult) {
+            result.importedSongs += songPaths.length;
+          }
+        }
+
+        Logger.info('heanup PlaylistTable', `importPlaylists: 导入歌单成功: ${playlistItem.name}, ${songPaths.length} 首歌曲`);
+      }
+
+      Logger.info('heanup PlaylistTable', `importPlaylists: 导入完成, 共 ${result.importedPlaylists} 个歌单, ${result.importedSongs} 首歌曲`);
+      return result;
+    } catch (error) {
+      Logger.error('heanup PlaylistTable', `importPlaylists: 导入失败: ${(error as Error).message}`);
+      return result;
+    }
+  }
+
+  /**
+   * 生成不重复的歌单名称
+   */
+  private async generateUniqueName(baseName: string): Promise<string> {
+    let suffix = 2;
+    let candidateName = `${baseName} (${suffix})`;
+    while (true) {
+      const existing = await this.queryPlaylistByName(candidateName);
+      if (!existing) {
+        return candidateName;
+      }
+      suffix++;
+      candidateName = `${baseName} (${suffix})`;
+    }
+  }
+
+  /**
+   * 检测备份数据中与现有歌单的冲突
+   */
+  async detectConflicts(backupData: PlaylistBackupData): Promise<string[]> {
+    await this.ensureInitialized();
+    const conflicts: string[] = [];
+
+    for (let i = 0; i < backupData.playlists.length; i++) {
+      const playlistItem = backupData.playlists[i];
+      const existing = await this.queryPlaylistByName(playlistItem.name);
+      if (existing) {
+        conflicts.push(playlistItem.name);
+      }
+    }
+
+    return conflicts;
+  }
+
+  /**
+   * 自动备份回调(由 PlaylistBackupManager 注册,避免循环依赖)
+   */
+  private static autoBackupCallback: (() => void) | null = null;
+
+  static registerAutoBackupCallback(callback: () => void): void {
+    PlaylistTable.autoBackupCallback = callback;
+  }
+
+  /**
+   * 触发自动备份
+   */
+  private triggerAutoBackup(): void {
+    if (PlaylistTable.autoBackupCallback) {
+      PlaylistTable.autoBackupCallback();
+    }
+  }
 }

+ 94 - 0
entry/src/main/ets/dialog/ImportConflictDialog.ets

@@ -0,0 +1,94 @@
+import { CommonConstants } from '../common/constants/CommonConstants';
+import { ConflictResolution } from '../viewmodel/PlaylistBackup';
+
+@CustomDialog
+export struct ImportConflictDialog {
+  controller?: CustomDialogController;
+  onResolve?: (resolution: ConflictResolution, applyToAll: boolean) => void;
+  onCancel?: () => void;
+  conflictName: string = '';
+  themeColor: string = CommonConstants.DEFAULT_THEME_COLOR;
+
+  @State applyToAll: boolean = false;
+
+  private handleResolve(resolution: ConflictResolution): void {
+    this.onResolve?.(resolution, this.applyToAll);
+    this.controller?.close();
+  }
+
+  private handleCancel(): void {
+    this.onCancel?.();
+    this.controller?.close();
+  }
+
+  build() {
+    Column() {
+      Text('导入冲突')
+        .fontSize(20)
+        .fontWeight(FontWeight.Medium)
+        .margin({ bottom: 8 });
+
+      Text(`歌单「${this.conflictName}」已存在,请选择处理方式:`)
+        .fontSize(14)
+        .fontColor(Color.Gray)
+        .margin({ bottom: 16 })
+        .textAlign(TextAlign.Center);
+
+      // 三个操作按钮
+      Button('覆盖现有歌单')
+        .width('100%')
+        .height(44)
+        .backgroundColor(this.themeColor || CommonConstants.DEFAULT_THEME_COLOR)
+        .fontColor(Color.White)
+        .borderRadius(12)
+        .margin({ bottom: 8 })
+        .onClick(() => this.handleResolve(ConflictResolution.OVERWRITE));
+
+      Button('重命名导入')
+        .width('100%')
+        .height(44)
+        .backgroundColor(Color.Transparent)
+        .fontColor(this.themeColor || CommonConstants.DEFAULT_THEME_COLOR)
+        .border({ color: this.themeColor || CommonConstants.DEFAULT_THEME_COLOR, width: 1, radius: 12 })
+        .borderRadius(12)
+        .margin({ bottom: 8 })
+        .onClick(() => this.handleResolve(ConflictResolution.RENAME));
+
+      Button('跳过')
+        .width('100%')
+        .height(44)
+        .backgroundColor(Color.Transparent)
+        .fontColor(Color.Gray)
+        .border({ color: Color.Gray, width: 1, radius: 12 })
+        .borderRadius(12)
+        .margin({ bottom: 12 })
+        .onClick(() => this.handleResolve(ConflictResolution.SKIP));
+
+      // 应用到全部复选框
+      Row() {
+        Checkbox()
+          .select(this.applyToAll)
+          .selectedColor(this.themeColor || CommonConstants.DEFAULT_THEME_COLOR)
+          .onChange((value: boolean) => {
+            this.applyToAll = value;
+          });
+
+        Text('应用到所有冲突')
+          .fontSize(14)
+          .fontColor(Color.Gray)
+          .margin({ left: 8 });
+      }
+      .margin({ bottom: 8 });
+
+      // 取消按钮
+      Text('取消导入')
+        .fontSize(14)
+        .fontColor(Color.Gray)
+        .onClick(() => this.handleCancel());
+    }
+    .padding(24)
+    .width('100%')
+    .backgroundColor($r('app.color.start_window_background'))
+    .borderRadius(24);
+  }
+}

+ 4 - 0
entry/src/main/ets/entryability/EntryAbility.ets

@@ -28,6 +28,7 @@ import { smartMobilityCommon } from '@kit.CarKit';
 import { url } from '@kit.ArkTS';
 import { display } from '@kit.ArkUI';
 import { ServerLogUtil } from '../common/util/ServerLogUtil';
+import { PlaylistBackupManager } from '../common/util/PlaylistBackupManager';
 /**
  * 主Ability类,继承自UIAbility
  * 负责:
@@ -100,6 +101,9 @@ export default class EntryAbility extends UIAbility {
         // 初始化WebDAV管理器
         this.initWebDAV();
 
+        // 初始化歌单备份管理器
+        PlaylistBackupManager.getInstance().setContext(this.context);
+
         setTimeout(async ()=>{
             this.loadDoWant(want)
             await this.handleParam(want)

+ 47 - 0
entry/src/main/ets/pages/SettingPage.ets

@@ -27,6 +27,7 @@ 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'
 
 @Preview
@@ -195,6 +196,7 @@ export struct SettingPage {
   @State customColor: string = PreferencesUtil.getStringSync('THEME_COLOR', CommonConstants.DEFAULT_THEME_COLOR);
   @State isThemeSheet: boolean = false // 主题设置弹窗显示状态
   @State isEqualizerSheet: boolean = false // 均衡器设置弹窗显示状态
+  @State isBackupSheet: boolean = false // 备份与恢复弹窗显示状态
   @State colorRows: Array<Array<ThemeColorItem>> = chunkArray(SettingPage.THEME_COLOR_LIST, 2);
   @State colorGroup: string = "group"
   @StorageProp('topRectHeight') topRectHeight: number = 0;
@@ -605,6 +607,13 @@ export struct SettingPage {
     .height('100%')
   }
 
+  @Builder
+  backupSheetBuilder() {
+    BackupManageView()
+      .width('100%')
+      .height('100%')
+  }
+
   @Builder
   themeSheetBuilder() {
     Column() {
@@ -1835,6 +1844,44 @@ export struct SettingPage {
             .clickEffect({ level: ClickEffectLevel.HEAVY })
             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.externaldrive'))
+                  .fontSize(20)
+                  .fontColor([this.themeColor])
+                  .alignSelf(ItemAlign.Center)
+                  .margin({ left: 15 })
+                Text('备份与恢复')
+                  .margin({ left: 8 })
+                  .fontSize(15)
+                  .fontColor(Color.Gray)
+                  .fontWeight(480)
+                  .layoutWeight(1)
+                Image($r('app.media.arrow_right'))
+                  .width(22)
+                  .height(22)
+                  .margin({ right: 18 })
+              }
+            }
+            .backgroundColor(Color.Transparent)
+            .height(55)
+            .width('100%')
+            .clickEffect({ level: ClickEffectLevel.HEAVY })
+            .onClick(() => {
+              this.isBackupSheet = true;
+            })
+            .bindSheet($$this.isBackupSheet, this.backupSheetBuilder(), {
+              height: this.isLandscape ? '95%' : '85%',
+              dragBar: true,
+              preferType: this.currentBreakpoint !== BreakpointTypeEnum.SM ? SheetType.CENTER : SheetType.BOTTOM,
+              showClose: true,
+              blurStyle: BlurStyle.Thin,
+              backgroundColor: Color.Transparent,
+              title: { title: '备份与恢复' }
+            })
+            Line().width('100%').height(0.3).backgroundColor($r('app.color.index_tab_unselected_font_color'))
+
             // 日志上传开关
             Row() {
               SymbolGlyph($r('sys.symbol.cloud'))

+ 996 - 0
entry/src/main/ets/view/BackupManageView.ets

@@ -0,0 +1,996 @@
+import { CommonConstants } from '../common/constants/CommonConstants';
+import { PreferencesUtil, ToastUtil } from '@pura/harmony-utils';
+import Logger from '../common/util/Logger';
+import { PlaylistBackupManager, ServerBackupItem, ValidateResult } from '../common/util/PlaylistBackupManager';
+import { BackupHistoryItem, ConflictResolution, ImportResult } from '../viewmodel/PlaylistBackup';
+import { WebDavAccount } from '../viewmodel/WebDavAccount';
+import { Utility } from '../common/util/Utility';
+import { ImportConflictDialog } from '../dialog/ImportConflictDialog';
+
+const TAG = 'heanup BackupManageView';
+
+@Component
+export struct BackupManageView {
+  @StorageProp('themeColor') themeColor: string =
+    PreferencesUtil.getStringSync('THEME_COLOR', CommonConstants.DEFAULT_THEME_COLOR);
+  @State isLoading: boolean = false;
+  @State loadingText: string = '';
+  @State historyList: BackupHistoryItem[] = [];
+  @State autoBackupEnabled: boolean = false;
+  @State webDavAccounts: WebDavAccount[] = [];
+  @State webDavBackupFiles: string[] = [];
+  @State serverBackups: ServerBackupItem[] = [];
+  @State showWebDavAccountPicker: boolean = false;
+  @State showWebDavBackupList: boolean = false;
+  @State showServerBackupList: boolean = false;
+  @State webDavPickerMode: string = 'upload'; // 'upload' | 'download'
+  @State showPasswordInput: boolean = false;
+  @State passwordInputValue: string = '';
+  @State passwordInputMode: string = ''; // 'localExport' | 'webdavExport' | 'serverExport' | 'import'
+  private manager: PlaylistBackupManager = PlaylistBackupManager.getInstance();
+  private selectedWebDavAccount: WebDavAccount | null = null;
+  private pendingPassphrase: string = '';
+
+  // 冲突对话框
+  private conflictDialogController: CustomDialogController | null = null;
+  private pendingJsonStr: string = '';
+  private conflictsToSkip: Set<string> = new Set();
+  private conflictsToOverwrite: Set<string> = new Set();
+  private conflictsToRename: Set<string> = new Set();
+  private pendingConflicts: string[] = [];
+  private currentConflictIndex: number = 0;
+  private applyToAllResolution: ConflictResolution | null = null;
+
+  aboutToAppear(): void {
+    this.loadHistory();
+    this.autoBackupEnabled = this.manager.isAutoBackupEnabled();
+    this.webDavAccounts = this.manager.getWebDavAccounts();
+  }
+
+  private loadHistory(): void {
+    this.historyList = this.manager.getBackupHistory();
+  }
+
+  private setLoading(loading: boolean, text: string = ''): void {
+    this.isLoading = loading;
+    this.loadingText = text;
+  }
+
+  // ========== 密码输入 → 触发导出/导入 ==========
+  private showPasswordDialog(mode: string): void {
+    this.passwordInputValue = '';
+    this.passwordInputMode = mode;
+    this.showPasswordInput = true;
+  }
+
+  private onPasswordConfirm(): void {
+    const passphrase = this.passwordInputValue;
+    this.showPasswordInput = false;
+    this.passwordInputValue = '';
+    const mode = this.passwordInputMode;
+    this.passwordInputMode = '';
+
+    if (mode === 'localExport') {
+      this.doLocalExport(passphrase);
+    } else if (mode === 'webdavExport') {
+      if (this.selectedWebDavAccount) {
+        this.doWebDavUpload(this.selectedWebDavAccount, passphrase);
+      }
+    } else if (mode === 'serverExport') {
+      this.doServerUpload(passphrase);
+    } else if (mode === 'import') {
+      this.pendingPassphrase = passphrase;
+      this.processImport(this.pendingJsonStr);
+    }
+  }
+
+  // ========== 本地导出 ==========
+  private handleLocalExport(): void {
+    this.showPasswordDialog('localExport');
+  }
+
+  private async doLocalExport(passphrase: string): Promise<void> {
+    this.setLoading(true, '正在导出...');
+    const success = await this.manager.saveToLocal(passphrase);
+    this.setLoading(false);
+    if (success) {
+      this.loadHistory();
+    }
+  }
+
+  // ========== 本地导入 ==========
+  private async handleLocalImport(): Promise<void> {
+    this.setLoading(true, '正在读取文件...');
+    const jsonStr = await this.manager.loadFromLocal();
+    if (!jsonStr || jsonStr.length === 0) {
+      this.setLoading(false);
+      return;
+    }
+
+    // 检测是否加密,加密则弹密码输入
+    if (this.manager.isBackupEncrypted(jsonStr)) {
+      this.pendingJsonStr = jsonStr;
+      this.pendingPassphrase = '';
+      this.showPasswordDialog('import');
+      return;
+    }
+
+    this.pendingPassphrase = '';
+    await this.processImport(jsonStr);
+  }
+
+  // ========== 导入处理(含冲突检测) ==========
+  private async processImport(jsonStr: string): Promise<void> {
+    this.setLoading(true, '正在检测冲突...');
+    const conflicts = await this.manager.detectConflicts(jsonStr);
+
+    if (conflicts.length === 0) {
+      // 无冲突,直接导入
+      this.setLoading(true, '正在导入...');
+      const result = await this.manager.importFromJson(
+        jsonStr,
+        new Set<string>(),
+        new Set<string>(),
+        new Set<string>(),
+        this.pendingPassphrase
+      );
+      this.setLoading(false);
+      if (result) {
+        this.showImportResult(result);
+        this.loadHistory();
+      }
+      return;
+    }
+
+    // 有冲突,逐个处理
+    this.pendingJsonStr = jsonStr;
+    this.conflictsToSkip = new Set<string>();
+    this.conflictsToOverwrite = new Set<string>();
+    this.conflictsToRename = new Set<string>();
+    this.pendingConflicts = conflicts;
+    this.currentConflictIndex = 0;
+    this.applyToAllResolution = null;
+    this.setLoading(false);
+    this.showNextConflict();
+  }
+
+  private showNextConflict(): void {
+    if (this.currentConflictIndex >= this.pendingConflicts.length) {
+      // 所有冲突已处理,执行导入
+      this.executeImportWithResolutions();
+      return;
+    }
+
+    const conflictName = this.pendingConflicts[this.currentConflictIndex];
+
+    // 如果已选择了"应用到全部"
+    if (this.applyToAllResolution !== null) {
+      this.applyResolution(conflictName, this.applyToAllResolution);
+      this.currentConflictIndex++;
+      this.showNextConflict();
+      return;
+    }
+
+    this.conflictDialogController = new CustomDialogController({
+      builder: ImportConflictDialog({
+        conflictName: conflictName,
+        themeColor: this.themeColor,
+        onResolve: (resolution: ConflictResolution, applyToAll: boolean) => {
+          this.applyResolution(conflictName, resolution);
+          if (applyToAll) {
+            this.applyToAllResolution = resolution;
+          }
+          this.currentConflictIndex++;
+          this.showNextConflict();
+        },
+        onCancel: () => {
+          // 取消导入
+          ToastUtil.showToast('已取消导入');
+        }
+      }),
+      autoCancel: false,
+      alignment: DialogAlignment.Center
+    });
+    this.conflictDialogController.open();
+  }
+
+  private applyResolution(name: string, resolution: ConflictResolution): void {
+    if (resolution === ConflictResolution.SKIP) {
+      this.conflictsToSkip.add(name);
+    } else if (resolution === ConflictResolution.OVERWRITE) {
+      this.conflictsToOverwrite.add(name);
+    } else if (resolution === ConflictResolution.RENAME) {
+      this.conflictsToRename.add(name);
+    }
+  }
+
+  private async executeImportWithResolutions(): Promise<void> {
+    this.setLoading(true, '正在导入...');
+    const result = await this.manager.importFromJson(
+      this.pendingJsonStr,
+      this.conflictsToSkip,
+      this.conflictsToOverwrite,
+      this.conflictsToRename,
+      this.pendingPassphrase
+    );
+    this.setLoading(false);
+    if (result) {
+      this.showImportResult(result);
+      this.loadHistory();
+    }
+  }
+
+  private showImportResult(result: ImportResult): void {
+    let msg = `导入完成: ${result.importedPlaylists} 个歌单`;
+    if (result.skippedPlaylists > 0) {
+      msg += `,跳过 ${result.skippedPlaylists} 个`;
+    }
+    if (result.overwrittenPlaylists > 0) {
+      msg += `,覆盖 ${result.overwrittenPlaylists} 个`;
+    }
+    if (result.renamedPlaylists > 0) {
+      msg += `,重命名 ${result.renamedPlaylists} 个`;
+    }
+    if (result.importedAccounts > 0 || result.skippedAccounts > 0) {
+      msg += `\n网盘: 导入 ${result.importedAccounts} 个`;
+      if (result.skippedAccounts > 0) {
+        msg += `,跳过 ${result.skippedAccounts} 个重复`;
+      }
+    }
+    if (result.settingsImported) {
+      msg += '\n设置项已恢复';
+    }
+    ToastUtil.showToast(msg);
+  }
+
+  // ========== WebDAV 操作 ==========
+  private handleWebDavUpload(): void {
+    const accounts = this.webDavAccounts;
+    if (accounts.length === 0) {
+      ToastUtil.showToast('请先添加 WebDAV 账户');
+      return;
+    }
+    if (accounts.length === 1) {
+      this.selectedWebDavAccount = accounts[0];
+      this.showPasswordDialog('webdavExport');
+    } else {
+      this.webDavPickerMode = 'upload';
+      this.showWebDavAccountPicker = true;
+    }
+  }
+
+  private async doWebDavUpload(account: WebDavAccount, passphrase: string = ''): Promise<void> {
+    this.setLoading(true, '正在上传到 WebDAV...');
+    const success = await this.manager.uploadToWebDav(account, passphrase);
+    this.setLoading(false);
+    if (success) {
+      this.loadHistory();
+    }
+  }
+
+  private handleWebDavDownload(): void {
+    const accounts = this.webDavAccounts;
+    if (accounts.length === 0) {
+      ToastUtil.showToast('请先添加 WebDAV 账户');
+      return;
+    }
+    if (accounts.length === 1) {
+      this.loadWebDavBackupList(accounts[0]);
+    } else {
+      this.webDavPickerMode = 'download';
+      this.showWebDavAccountPicker = true;
+    }
+  }
+
+  private async loadWebDavBackupList(account: WebDavAccount): Promise<void> {
+    this.selectedWebDavAccount = account;
+    this.setLoading(true, '正在获取备份列表...');
+    this.webDavBackupFiles = await this.manager.listWebDavBackups(account);
+    this.setLoading(false);
+    if (this.webDavBackupFiles.length === 0) {
+      ToastUtil.showToast('暂无备份文件');
+      return;
+    }
+    this.showWebDavBackupList = true;
+  }
+
+  private async doWebDavDownloadAndImport(fileName: string): Promise<void> {
+    if (!this.selectedWebDavAccount) {
+      return;
+    }
+    this.showWebDavBackupList = false;
+    this.setLoading(true, '正在下载备份...');
+    const jsonStr = await this.manager.downloadFromWebDav(this.selectedWebDavAccount, fileName);
+    if (!jsonStr || jsonStr.length === 0) {
+      this.setLoading(false);
+      return;
+    }
+
+    // 检测是否加密
+    if (this.manager.isBackupEncrypted(jsonStr)) {
+      this.setLoading(false);
+      this.pendingJsonStr = jsonStr;
+      this.pendingPassphrase = '';
+      this.showPasswordDialog('import');
+      return;
+    }
+
+    this.pendingPassphrase = '';
+    await this.processImport(jsonStr);
+  }
+
+  // ========== 服务器操作(VIP) ==========
+  private handleServerUpload(): void {
+    if (!this.manager.isVipUser()) {
+      ToastUtil.showToast('该功能仅限VIP用户使用');
+      return;
+    }
+    this.showPasswordDialog('serverExport');
+  }
+
+  private async doServerUpload(passphrase: string = ''): Promise<void> {
+    this.setLoading(true, '正在上传到服务器...');
+    const success = await this.manager.uploadToServer(passphrase);
+    this.setLoading(false);
+    if (success) {
+      this.loadHistory();
+    }
+  }
+
+  private async handleServerRestore(): Promise<void> {
+    if (!this.manager.isVipUser()) {
+      ToastUtil.showToast('该功能仅限VIP用户使用');
+      return;
+    }
+    this.setLoading(true, '正在获取备份列表...');
+    this.serverBackups = await this.manager.listServerBackups();
+    this.setLoading(false);
+    if (this.serverBackups.length === 0) {
+      ToastUtil.showToast('暂无服务器备份');
+      return;
+    }
+    this.showServerBackupList = true;
+  }
+
+  private async doServerDownloadAndImport(backupId: string): Promise<void> {
+    this.showServerBackupList = false;
+    this.setLoading(true, '正在下载备份...');
+    const jsonStr = await this.manager.downloadFromServer(backupId);
+    if (!jsonStr || jsonStr.length === 0) {
+      this.setLoading(false);
+      return;
+    }
+
+    // 检测是否加密
+    if (this.manager.isBackupEncrypted(jsonStr)) {
+      this.setLoading(false);
+      this.pendingJsonStr = jsonStr;
+      this.pendingPassphrase = '';
+      this.showPasswordDialog('import');
+      return;
+    }
+
+    this.pendingPassphrase = '';
+    await this.processImport(jsonStr);
+  }
+
+  private formatTimestamp(timestamp: string): string {
+    try {
+      const date = new Date(timestamp);
+      const year = date.getFullYear();
+      const month = String(date.getMonth() + 1).padStart(2, '0');
+      const day = String(date.getDate()).padStart(2, '0');
+      const hours = String(date.getHours()).padStart(2, '0');
+      const minutes = String(date.getMinutes()).padStart(2, '0');
+      return `${year}-${month}-${day} ${hours}:${minutes}`;
+    } catch (e) {
+      return timestamp;
+    }
+  }
+
+  private getTypeLabel(type: string): string {
+    if (type === 'local') {
+      return '本地';
+    } else if (type === 'webdav') {
+      return 'WebDAV';
+    } else if (type === 'server') {
+      return '服务器';
+    }
+    return type;
+  }
+
+  private getTypeIcon(type: string): Resource {
+    if (type === 'local') {
+      return $r('sys.symbol.folder');
+    } else if (type === 'webdav') {
+      return $r('sys.symbol.cloud');
+    } else {
+      return $r('sys.symbol.cloud');
+    }
+  }
+
+  private formatHistoryDetail(item: BackupHistoryItem): string {
+    let detail = `${this.formatTimestamp(item.timestamp)} · ${item.playlistCount} 个歌单`;
+    if (item.accountCount > 0) {
+      detail += ` · ${item.accountCount} 个网盘`;
+    }
+    if (item.hasSettings) {
+      detail += ' · 设置';
+    }
+    return detail;
+  }
+
+  private parseBackupFileTime(fileName: string): string {
+    // 从文件名 playlist_backup_YYYYMMDD_HHmmss.json 解析时间
+    try {
+      const match = fileName.match(/(\d{4})(\d{2})(\d{2})_(\d{2})(\d{2})(\d{2})/);
+      if (match && match.length >= 7) {
+        return `${match[1]}-${match[2]}-${match[3]} ${match[4]}:${match[5]}:${match[6]}`;
+      }
+    } catch (e) {
+      Logger.error(TAG, `parseBackupFileTime error: ${e}`);
+    }
+    return '';
+  }
+
+  private getAccountDisplayInfo(account: WebDavAccount): string {
+    const protocol = account.enableHttps ? 'https' : 'http';
+    let info = `${protocol}://${account.host}`;
+    if (account.port > 0) {
+      info += `:${account.port}`;
+    }
+    if (account.filepath && account.filepath.length > 0) {
+      info += account.filepath;
+    }
+    return info;
+  }
+
+  build() {
+    Stack() {
+      Scroll() {
+        Column() {
+          // ========== 本地备份区域 ==========
+          this.sectionTitle('本地备份')
+
+          this.menuButton('导出到本地文件', $r('sys.symbol.save'), () => {
+            this.handleLocalExport();
+          })
+
+          this.menuButton('从本地文件导入', $r('sys.symbol.arrow_down_circle'), () => {
+            this.handleLocalImport();
+          })
+
+          // ========== WebDAV 备份区域 ==========
+          this.sectionTitle('WebDAV 备份')
+
+          this.menuButton('备份到 WebDAV', $r('sys.symbol.cloud'), () => {
+            this.handleWebDavUpload();
+          })
+
+          this.menuButton('从 WebDAV 恢复', $r('sys.symbol.cloud'), () => {
+            this.handleWebDavDownload();
+          })
+
+          // ========== 服务器备份(VIP) ==========
+          this.sectionTitle('服务器备份')
+
+          this.menuButtonWithVip('备份到服务器', $r('sys.symbol.paperplane'), () => {
+            this.handleServerUpload();
+          })
+
+          this.menuButtonWithVip('从服务器恢复', $r('sys.symbol.paperplane'), () => {
+            this.handleServerRestore();
+          })
+
+          // 自动备份开关
+          Row() {
+            SymbolGlyph($r('sys.symbol.clock'))
+              .fontSize(20)
+              .fontColor([this.themeColor])
+              .margin({ left: 15 })
+            Column({ space: 4 }) {
+              Row() {
+                Text('自动备份')
+                  .fontSize(15)
+                  .fontColor(Color.Gray)
+                  .fontWeight(480)
+                if (!this.manager.isVipUser()) {
+                  Text('VIP')
+                    .fontSize(10)
+                    .fontColor(Color.White)
+                    .backgroundColor(this.themeColor)
+                    .borderRadius(4)
+                    .padding({ left: 4, right: 4, top: 1, bottom: 1 })
+                    .margin({ left: 6 })
+                }
+              }
+
+              Text('歌单变化后自动备份到服务器')
+                .fontSize(12)
+                .fontColor(Color.Gray)
+            }
+            .layoutWeight(1)
+            .alignItems(HorizontalAlign.Start)
+            .margin({ left: 8 })
+
+            Toggle({ type: ToggleType.Switch, isOn: this.autoBackupEnabled })
+              .selectedColor(this.themeColor)
+              .switchPointColor(Color.White)
+              .margin({ right: 18 })
+              .width(50)
+              .height(30)
+              .onChange((checked: boolean) => {
+                if (checked && !this.manager.isVipUser()) {
+                  this.autoBackupEnabled = false;
+                  ToastUtil.showToast('该功能仅限VIP用户使用');
+                  return;
+                }
+                this.autoBackupEnabled = checked;
+                this.manager.setAutoBackupEnabled(checked);
+                if (checked) {
+                  ToastUtil.showToast('已开启自动备份');
+                } else {
+                  ToastUtil.showToast('已关闭自动备份');
+                }
+              })
+          }
+          .height(70)
+          .width('100%')
+
+          Line().width('100%').height(0.3).backgroundColor($r('app.color.index_tab_unselected_font_color'))
+
+          // ========== 备份历史 ==========
+          this.sectionTitle('备份历史')
+
+          if (this.historyList.length === 0) {
+            Text('暂无备份记录,赶快创建第一个备份吧')
+              .fontSize(13)
+              .fontColor(Color.Gray)
+              .padding({ left: 15, top: 8, bottom: 16 })
+          } else {
+            ForEach(this.historyList, (item: BackupHistoryItem) => {
+              Row() {
+                SymbolGlyph(this.getTypeIcon(item.type))
+                  .fontSize(18)
+                  .fontColor([this.themeColor])
+                  .margin({ left: 15 })
+
+                Column({ space: 2 }) {
+                  Text(`${this.getTypeLabel(item.type)}备份`)
+                    .fontSize(14)
+                    .fontColor(Color.Gray)
+                    .fontWeight(480)
+                  Text(this.formatHistoryDetail(item))
+                    .fontSize(12)
+                    .fontColor(Color.Gray)
+                }
+                .layoutWeight(1)
+                .alignItems(HorizontalAlign.Start)
+                .margin({ left: 8 })
+              }
+              .height(55)
+              .width('100%')
+
+              Line().width('100%').height(0.3).backgroundColor($r('app.color.index_tab_unselected_font_color'))
+            })
+          }
+
+          // 底部间距
+          Blank().height(40)
+        }
+        .width('100%')
+      }
+      .width('100%')
+      .height('100%')
+
+      // Loading 遮罩
+      if (this.isLoading) {
+        Column() {
+          LoadingProgress()
+            .width(48)
+            .height(48)
+            .color(this.themeColor)
+          Text(this.loadingText)
+            .fontSize(14)
+            .fontColor(Color.Gray)
+            .margin({ top: 8 })
+        }
+        .width('100%')
+        .height('100%')
+        .justifyContent(FlexAlign.Center)
+        .backgroundColor('#80000000')
+      }
+
+      // WebDAV 账户选择弹层
+      if (this.showWebDavAccountPicker) {
+        this.webDavAccountPickerOverlay()
+      }
+
+      // WebDAV 备份文件列表弹层
+      if (this.showWebDavBackupList) {
+        this.webDavBackupListOverlay()
+      }
+
+      // 服务器备份列表弹层
+      if (this.showServerBackupList) {
+        this.serverBackupListOverlay()
+      }
+
+      // 密码输入弹层
+      if (this.showPasswordInput) {
+        this.passwordInputOverlay()
+      }
+    }
+    .width('100%')
+    .height('100%')
+  }
+
+  @Builder
+  sectionTitle(title: string) {
+    Text(title)
+      .fontSize(13)
+      .fontColor(this.themeColor)
+      .fontWeight(FontWeight.Medium)
+      .padding({ left: 15, top: 16, bottom: 8 })
+      .width('100%')
+  }
+
+  @Builder
+  menuButton(title: string, icon: Resource, onClick: () => void) {
+    Button({ type: ButtonType.Normal, stateEffect: true }) {
+      Row() {
+        SymbolGlyph(icon)
+          .fontSize(20)
+          .fontColor([this.themeColor])
+          .margin({ left: 15 })
+        Text(title)
+          .margin({ left: 8 })
+          .fontSize(15)
+          .fontColor(Color.Gray)
+          .fontWeight(480)
+          .layoutWeight(1)
+        Image($r('app.media.arrow_right'))
+          .width(22)
+          .height(22)
+          .margin({ right: 18 })
+      }
+    }
+    .backgroundColor(Color.Transparent)
+    .height(55)
+    .width('100%')
+    .clickEffect({ level: ClickEffectLevel.HEAVY })
+    .onClick(() => onClick())
+
+    Line().width('100%').height(0.3).backgroundColor($r('app.color.index_tab_unselected_font_color'))
+  }
+
+  @Builder
+  menuButtonWithVip(title: string, icon: Resource, onClick: () => void) {
+    Button({ type: ButtonType.Normal, stateEffect: true }) {
+      Row() {
+        SymbolGlyph(icon)
+          .fontSize(20)
+          .fontColor([this.themeColor])
+          .margin({ left: 15 })
+        Row() {
+          Text(title)
+            .fontSize(15)
+            .fontColor(Color.Gray)
+            .fontWeight(480)
+          if (!this.manager.isVipUser()) {
+            Text('VIP')
+              .fontSize(10)
+              .fontColor(Color.White)
+              .backgroundColor(this.themeColor)
+              .borderRadius(4)
+              .padding({ left: 4, right: 4, top: 1, bottom: 1 })
+              .margin({ left: 6 })
+          }
+        }
+        .margin({ left: 8 })
+        .layoutWeight(1)
+
+        Image($r('app.media.arrow_right'))
+          .width(22)
+          .height(22)
+          .margin({ right: 18 })
+      }
+    }
+    .backgroundColor(Color.Transparent)
+    .height(55)
+    .width('100%')
+    .clickEffect({ level: ClickEffectLevel.HEAVY })
+    .onClick(() => onClick())
+
+    Line().width('100%').height(0.3).backgroundColor($r('app.color.index_tab_unselected_font_color'))
+  }
+
+  @Builder
+  webDavAccountPickerOverlay() {
+    Column() {
+      Column() {
+        Text('选择 WebDAV 账户')
+          .fontSize(18)
+          .fontWeight(FontWeight.Medium)
+          .margin({ bottom: 12 })
+
+        ForEach(this.webDavAccounts, (account: WebDavAccount) => {
+          Button({ type: ButtonType.Normal, stateEffect: true }) {
+            Row() {
+              SymbolGlyph($r('sys.symbol.cloud'))
+                .fontSize(20)
+                .fontColor([this.themeColor])
+              Column({ space: 2 }) {
+                Text(account.name || account.host)
+                  .fontSize(15)
+                  .fontColor(Color.Gray)
+                  .fontWeight(480)
+                  .maxLines(1)
+                  .textOverflow({ overflow: TextOverflow.Ellipsis })
+                Text(this.getAccountDisplayInfo(account))
+                  .fontSize(12)
+                  .fontColor(Color.Gray)
+                  .maxLines(1)
+                  .textOverflow({ overflow: TextOverflow.Ellipsis })
+              }
+              .alignItems(HorizontalAlign.Start)
+              .margin({ left: 8 })
+              .layoutWeight(1)
+            }
+            .padding({ left: 12, right: 12 })
+          }
+          .backgroundColor(Color.Transparent)
+          .height(60)
+          .width('100%')
+          .borderRadius(12)
+          .clickEffect({ level: ClickEffectLevel.HEAVY })
+          .onClick(() => {
+            this.showWebDavAccountPicker = false;
+            if (this.webDavPickerMode === 'upload') {
+              this.selectedWebDavAccount = account;
+              this.showPasswordDialog('webdavExport');
+            } else {
+              this.loadWebDavBackupList(account);
+            }
+          })
+        })
+
+        Button('取消')
+          .width('100%')
+          .height(44)
+          .backgroundColor(Color.Transparent)
+          .fontColor(Color.Gray)
+          .margin({ top: 8 })
+          .onClick(() => {
+            this.showWebDavAccountPicker = false;
+          })
+      }
+      .padding(24)
+      .backgroundColor($r('app.color.start_window_background'))
+      .borderRadius(24)
+      .width('85%')
+    }
+    .width('100%')
+    .height('100%')
+    .justifyContent(FlexAlign.Center)
+    .backgroundColor('#80000000')
+    .onClick(() => {
+      this.showWebDavAccountPicker = false;
+    })
+  }
+
+  @Builder
+  webDavBackupListOverlay() {
+    Column() {
+      Column() {
+        Text('选择备份文件')
+          .fontSize(18)
+          .fontWeight(FontWeight.Medium)
+          .margin({ bottom: 12 })
+
+        Scroll() {
+          Column() {
+            ForEach(this.webDavBackupFiles, (fileName: string) => {
+              Button({ type: ButtonType.Normal, stateEffect: true }) {
+                Row() {
+                  SymbolGlyph($r('sys.symbol.doc'))
+                    .fontSize(18)
+                    .fontColor([this.themeColor])
+                  Column({ space: 2 }) {
+                    Text(fileName)
+                      .fontSize(14)
+                      .fontColor(Color.Gray)
+                      .fontWeight(480)
+                      .maxLines(2)
+                      .textOverflow({ overflow: TextOverflow.Ellipsis })
+                    if (this.parseBackupFileTime(fileName).length > 0) {
+                      Text(`创建时间: ${this.parseBackupFileTime(fileName)}`)
+                        .fontSize(12)
+                        .fontColor(Color.Gray)
+                    }
+                  }
+                  .alignItems(HorizontalAlign.Start)
+                  .margin({ left: 8 })
+                  .layoutWeight(1)
+                }
+                .padding({ left: 12, right: 12 })
+              }
+              .backgroundColor(Color.Transparent)
+              .height(60)
+              .width('100%')
+              .borderRadius(12)
+              .clickEffect({ level: ClickEffectLevel.HEAVY })
+              .onClick(() => {
+                this.doWebDavDownloadAndImport(fileName);
+              })
+            })
+          }
+        }
+        .constraintSize({ maxHeight: 300 })
+
+        Button('取消')
+          .width('100%')
+          .height(44)
+          .backgroundColor(Color.Transparent)
+          .fontColor(Color.Gray)
+          .margin({ top: 8 })
+          .onClick(() => {
+            this.showWebDavBackupList = false;
+          })
+      }
+      .padding(24)
+      .backgroundColor($r('app.color.start_window_background'))
+      .borderRadius(24)
+      .width('85%')
+    }
+    .width('100%')
+    .height('100%')
+    .justifyContent(FlexAlign.Center)
+    .backgroundColor('#80000000')
+    .onClick(() => {
+      this.showWebDavBackupList = false;
+    })
+  }
+
+  @Builder
+  serverBackupListOverlay() {
+    Column() {
+      Column() {
+        Text('选择服务器备份')
+          .fontSize(18)
+          .fontWeight(FontWeight.Medium)
+          .margin({ bottom: 12 })
+
+        if (this.serverBackups.length === 0) {
+          Text('暂无服务器备份')
+            .fontSize(14)
+            .fontColor(Color.Gray)
+            .padding({ top: 16, bottom: 16 })
+        } else {
+          Scroll() {
+            Column() {
+              ForEach(this.serverBackups, (backup: ServerBackupItem) => {
+                Button({ type: ButtonType.Normal, stateEffect: true }) {
+                  Row() {
+                    SymbolGlyph($r('sys.symbol.paperplane'))
+                      .fontSize(18)
+                      .fontColor([this.themeColor])
+                    Column({ space: 2 }) {
+                      Text(this.formatTimestamp(backup.timestamp))
+                        .fontSize(14)
+                        .fontColor(Color.Gray)
+                      Text(`${backup.playlist_count} 个歌单`)
+                        .fontSize(12)
+                        .fontColor(Color.Gray)
+                    }
+                    .alignItems(HorizontalAlign.Start)
+                    .margin({ left: 8 })
+                    .layoutWeight(1)
+                  }
+                  .padding({ left: 12, right: 12 })
+                }
+                .backgroundColor(Color.Transparent)
+                .height(55)
+                .width('100%')
+                .borderRadius(12)
+                .clickEffect({ level: ClickEffectLevel.HEAVY })
+                .onClick(() => {
+                  this.doServerDownloadAndImport(backup.id);
+                })
+              })
+            }
+          }
+          .constraintSize({ maxHeight: 300 })
+        }
+
+        Button('取消')
+          .width('100%')
+          .height(44)
+          .backgroundColor(Color.Transparent)
+          .fontColor(Color.Gray)
+          .margin({ top: 8 })
+          .onClick(() => {
+            this.showServerBackupList = false;
+          })
+      }
+      .padding(24)
+      .backgroundColor($r('app.color.start_window_background'))
+      .borderRadius(24)
+      .width('85%')
+    }
+    .width('100%')
+    .height('100%')
+    .justifyContent(FlexAlign.Center)
+    .backgroundColor('#80000000')
+    .onClick(() => {
+      this.showServerBackupList = false;
+    })
+  }
+
+  @Builder
+  passwordInputOverlay() {
+    Column() {
+      Column() {
+        Text(this.passwordInputMode === 'import' ? '输入备份密码' : '设置备份密码')
+          .fontSize(18)
+          .fontWeight(FontWeight.Medium)
+          .margin({ bottom: 4 })
+
+        Text(this.passwordInputMode === 'import' ? '该备份已加密,请输入密码解锁' : '设置密码可加密保护敏感信息,留空则不加密')
+          .fontSize(13)
+          .fontColor(Color.Gray)
+          .margin({ bottom: 16 })
+          .textAlign(TextAlign.Center)
+
+        TextInput({ placeholder: '请输入密码', text: this.passwordInputValue })
+          .type(InputType.Password)
+          .width('100%')
+          .height(44)
+          .borderRadius(10)
+          .onChange((value: string) => {
+            this.passwordInputValue = value;
+          })
+
+        Row({ space: 12 }) {
+          Button('取消')
+            .layoutWeight(1)
+            .height(40)
+            .backgroundColor(Color.Transparent)
+            .fontColor(Color.Gray)
+            .borderWidth(0.5)
+            .borderColor(Color.Gray)
+            .borderRadius(10)
+            .onClick(() => {
+              this.showPasswordInput = false;
+              this.passwordInputValue = '';
+              this.passwordInputMode = '';
+            })
+
+          Button('确定')
+            .layoutWeight(1)
+            .height(40)
+            .backgroundColor(this.themeColor)
+            .fontColor(Color.White)
+            .borderRadius(10)
+            .onClick(() => {
+              if (this.passwordInputMode === 'import' && this.passwordInputValue.length === 0) {
+                ToastUtil.showToast('请输入密码');
+                return;
+              }
+              this.onPasswordConfirm();
+            })
+        }
+        .width('100%')
+        .margin({ top: 16 })
+      }
+      .padding(24)
+      .backgroundColor($r('app.color.start_window_background'))
+      .borderRadius(24)
+      .width('85%')
+    }
+    .width('100%')
+    .height('100%')
+    .justifyContent(FlexAlign.Center)
+    .backgroundColor('#80000000')
+  }
+}

+ 141 - 0
entry/src/main/ets/viewmodel/PlaylistBackup.ets

@@ -0,0 +1,141 @@
+/**
+ * 备份数据格式定义(v2:歌单 + 网盘配置 + 设置项)
+ */
+
+/**
+ * 歌单备份中的歌曲条目
+ */
+export interface PlaylistSongBackupItem {
+  songFilePath: string;
+  addTime: string;
+  sortOrder: number;
+}
+
+/**
+ * 歌单备份中的歌单条目
+ */
+export interface PlaylistBackupItem {
+  id: string;
+  name: string;
+  coverPath: string;
+  description: string;
+  createTime: string;
+  updateTime: string;
+  songCount: number;
+  sortOrder: number;
+  songs: PlaylistSongBackupItem[];
+}
+
+/**
+ * 网盘账户备份条目(v2 新增)
+ * 不备份: baiduAccessToken, baiduRefreshToken, baiduTokenExpiresAt(临时凭证)
+ * 不备份: lyricFilePaths[], imageFilePaths[](运行时数据,数据库不存储)
+ */
+export interface WebDavAccountBackupItem {
+  name: string;
+  isActivate: boolean;
+  sortOrder: number;
+  host: string;
+  localHost: string;
+  isUseLocalHost: boolean;
+  port: number;
+  enableHttps: boolean;
+  filepath: string;
+  imageFilePath: string;
+  lyricFilePath: string;
+  uploadFilePath: string;
+  coverPath: string;
+  account: string;
+  password: string;
+  webType: number;
+  smbShare: string;
+  smbDomain: string;
+  navidromeBasePath: string;
+  jellyfinBasePath: string;
+  embyBasePath: string;
+  ftpEncoding: string;
+}
+
+/**
+ * 设置项备份 - 使用类型化数组(兼容 ArkTS,无 any/unknown)
+ */
+export interface SettingBoolItem {
+  key: string;
+  value: boolean;
+}
+
+export interface SettingNumberItem {
+  key: string;
+  value: number;
+}
+
+export interface SettingStringItem {
+  key: string;
+  value: string;
+}
+
+export interface SettingsBackupData {
+  booleanSettings: SettingBoolItem[];
+  numberSettings: SettingNumberItem[];
+  stringSettings: SettingStringItem[];
+}
+
+/**
+ * 备份文件顶层结构(v2 扩展)
+ */
+export interface PlaylistBackupData {
+  version: number;
+  exportTime: string;
+  appVersion: string;
+  playlists: PlaylistBackupItem[];
+  webDavAccounts?: WebDavAccountBackupItem[];
+  userSettings?: SettingsBackupData;
+  // 加密相关(密码等敏感字段加密后存储)
+  encrypted?: boolean;
+  encryptionSalt?: string;
+  encryptionIv?: string;
+  encryptionTag?: string;
+  encryptedPasswords?: string; // 加密后的密码 JSON 数组(base64)
+}
+
+/**
+ * 导入结果统计
+ */
+export interface ImportResult {
+  totalPlaylists: number;
+  importedPlaylists: number;
+  skippedPlaylists: number;
+  overwrittenPlaylists: number;
+  renamedPlaylists: number;
+  totalSongs: number;
+  importedSongs: number;
+  importedAccounts: number;
+  skippedAccounts: number;
+  settingsImported: boolean;
+}
+
+/**
+ * 备份历史记录条目
+ */
+export interface BackupHistoryItem {
+  type: string; // 'local' | 'webdav' | 'server'
+  timestamp: string;
+  playlistCount: number;
+  filePath: string;
+  accountCount: number;
+  hasSettings: boolean;
+}
+
+/**
+ * 冲突解决策略
+ */
+export enum ConflictResolution {
+  OVERWRITE = 'overwrite',
+  SKIP = 'skip',
+  RENAME = 'rename'
+}
+
+/**
+ * 备份文件当前格式版本号
+ */
+export const BACKUP_FORMAT_VERSION: number = 2;

+ 0 - 20
openspec/changes/improve-git-collaboration-workflow/change-summary.md

@@ -1,20 +0,0 @@
-# 变更说明(实施结果)
-
-## 最终采用规则
-
-- 采用“主干受保护 + 短生命周期功能分支”模式。
-- 统一同步流程:`git status -> git fetch -> git rebase/merge`。
-- pull/rebase 前必须先处理本地未提交改动(commit/stash/backup 分支)。
-- 高风险命令(`reset --hard`、破坏性 checkout/clean)需满足恢复点与二次确认前置条件。
-- 通过 `.gitattributes` 统一文本归一化,并对二进制文件采用非文本合并策略。
-
-## 已知限制
-
-- 当前主要依赖文档与人工执行,尚未引入强制自动化门禁。
-- 试运行数据尚未沉淀,部分规则需要基于真实冲突样本进一步收敛。
-
-## 后续优化计划
-
-- 在 PR 模板与 CI 检查中强化流程校验。
-- 建立冲突案例统计与热点文件治理机制。
-- 根据试运行反馈迭代规范,发布 v1.1。

+ 0 - 82
openspec/changes/improve-git-collaboration-workflow/design.md

@@ -1,82 +0,0 @@
-/## Context
-
-当前仓库在多人协作时缺少统一 Git 操作约束,开发者在 `pull`、`rebase`、`merge`、`stash` 和分支切换中的行为不一致,导致两类核心问题:
-- 冲突频发且处理方式不一致,重复消耗时间。
-- 本地未提交改动在高风险操作中被覆盖或丢失。
-
-本次设计需要在不改变业务运行时行为的前提下,给出可执行、可落地、可审计的协作机制,覆盖日常开发与异常处置场景。
-
-## Goals / Non-Goals
-
-**Goals:**
-- 形成统一的分支与同步策略,降低无效冲突。
-- 建立 pull 前本地改动保护流程,显式避免“误覆盖”。
-- 通过仓库基线配置(如 `.gitattributes`)减少跨平台伪冲突。
-- 提供冲突与覆盖风险的标准化处置手册,提升团队一致性。
-
-**Non-Goals:**
-- 不改动业务代码逻辑与运行时接口。
-- 不引入复杂的 Git 托管平台自动化体系(如强制机器人流程改造)。
-- 不在本阶段处理历史提交重写或大规模分支清理迁移。
-
-## Decisions
-
-1. 采用“受保护主干 + 短生命周期功能分支”策略。
-- 选择:`main/master` 仅通过 PR 合入,个人开发在 `feature/*`、`fix/*` 分支进行。
-- 原因:隔离日常开发与集成风险,减少直接在主干上产生冲突。
-- 备选:继续允许直接向主干提交。
-- 不选原因:无法有效约束冲突来源,且回溯困难。
-
-2. 统一日常同步基线为“先检查本地改动,再同步远端”。
-- 选择:pull 前执行状态检查(`git status`),发现未提交改动时必须先 `commit` 或 `stash`。
-- 原因:把风险前置,避免 merge/rebase 时混入脏工作区。
-- 备选:允许直接 `git pull` 自动处理。
-- 不选原因:高概率引入隐式冲突和覆盖风险。
-
-3. 默认以 rebase 同步个人分支(保持线性历史),主干集成仍通过 PR merge 策略控制。
-- 选择:开发者本地 `fetch + rebase` 为主,减少无意义 merge commit。
-- 原因:冲突定位更清晰,历史可读性更高。
-- 备选:全面 merge 同步。
-- 不选原因:历史噪音大,冲突来源不直观。
-
-4. 建立“本地改动保护”硬性规则。
-- 选择:禁止在未理解后果时使用 `reset --hard`、`checkout -- .` 等破坏性命令;提供 stash 命名规范和恢复流程。
-- 原因:覆盖问题主要来源于破坏性命令与无保护 pull。
-- 备选:仅口头提醒。
-- 不选原因:不可审计,执行一致性低。
-
-5. 增加仓库冲突预防基线。
-- 选择:补充 `.gitattributes`(统一文本归一化、二进制标注、必要的 merge 策略)与 `.gitignore` 建议项。
-- 原因:减少跨系统换行差异和二进制误合并引发的伪冲突。
-- 备选:维持默认 Git 行为。
-- 不选原因:跨平台团队中冲突噪音无法下降。
-
-6. 文档化并模板化操作流程。
-- 选择:在 `doc/` 增加“日常同步 SOP”“冲突处理 SOP”“本地改动恢复 SOP”。
-- 原因:将经验固化为可执行步骤,降低新人上手成本。
-- 备选:分散在聊天记录或口头传递。
-- 不选原因:知识不可追踪,执行偏差大。
-
-## Risks / Trade-offs
-
-- [Risk] 团队成员对新流程不熟悉,短期操作成本上升 -> Mitigation:提供最小命令清单与示例,PR 阶段进行轻量检查。
-- [Risk] rebase 使用不当可能改写本地历史 -> Mitigation:明确“仅在个人未共享分支 rebase”的规则,并提供回滚指令。
-- [Risk] `.gitattributes` 调整后可能触发一次性大 diff -> Mitigation:分阶段引入并在独立 PR 中完成,避免与业务改动混合。
-- [Risk] 规则过严影响紧急修复效率 -> Mitigation:定义紧急通道,但要求事后补齐规范流程与记录。
-
-## Migration Plan
-
-1. 在文档中发布新协作规范与高风险命令红线。
-2. 引入 `.gitattributes`/`.gitignore` 基线变更,并单独评审。
-3. 团队试运行 1-2 个迭代,收集冲突率与恢复案例。
-4. 根据试运行反馈调整 SOP 细节。
-5. 将规范纳入 PR 模板或开发检查清单。
-
-回滚策略:若新基线导致异常,可先回退文档强制项与 `.gitattributes` 改动,保留最小安全规则(pull 前检查与本地改动保护)。
-
-## Open Questions
-
-- 是否统一要求 `pull.rebase=true`,还是按分支类型区分?
-- 是否需要在仓库内提供一键安全同步脚本(如 `safe-pull`)?
-- PR 模板中哪些检查项必须强制,哪些可选?
-- 是否需要为二进制资源(如媒体文件)制定单独的分支与提交流程?

+ 0 - 27
openspec/changes/improve-git-collaboration-workflow/proposal.md

@@ -1,27 +0,0 @@
-## Why
-
-当前项目缺少统一、可执行的 Git 协作规范,导致 `git pull` 后频繁出现可避免冲突,以及本地未提交文件被覆盖或误处理。需要尽快建立分支、同步、提交与冲突处理的标准流程,降低协作成本并提升代码安全性。
-
-## What Changes
-
-- 新增团队级 Git 协作与同步规范,覆盖分支命名、日常同步、提交与推送、冲突处理。
-- 新增“本地改动保护”机制要求,包括 pull 前检查、stash/临时分支保护策略和高风险命令禁用约定。
-- 新增统一的 `.gitattributes` 与基础忽略策略建议,用于减少跨平台与文本归一化引发的伪冲突。
-- 新增仓库内操作手册与故障排查流程,明确遇到冲突、覆盖风险时的标准处置步骤。
-
-## Capabilities
-
-### New Capabilities
-- `git-collaboration-policy`: 定义团队分支策略、提交规范、同步与合并基线流程。
-- `local-change-protection`: 定义本地改动保护与 pull 前安全检查、回滚与恢复流程。
-- `conflict-prevention-baseline`: 定义减少无效冲突的仓库基线配置(如 `.gitattributes` 与相关规范)。
-
-### Modified Capabilities
-- 无
-
-## Impact
-
-- 影响代码与文档:`doc/` 下新增或更新协作手册,可能新增仓库根目录 `.gitattributes` 与 `.gitignore` 规则。
-- 影响开发流程:所有开发者的日常 pull/rebase/merge 操作将遵循统一流程。
-- 影响 CI/质量门禁:可选接入提交前检查或 PR 模板校验,确保流程落地。
-- 对运行时 API 与业务功能无直接破坏性变更。

+ 0 - 34
openspec/changes/improve-git-collaboration-workflow/specs/conflict-prevention-baseline/spec.md

@@ -1,34 +0,0 @@
-## ADDED Requirements
-
-### Requirement: Repository text normalization baseline
-The repository MUST define a `.gitattributes` baseline that normalizes text handling rules across developer environments.
-
-#### Scenario: Text file is committed from different operating systems
-- **WHEN** contributors commit text files from mixed platforms
-- **THEN** line-ending behavior MUST follow repository-defined normalization rules to avoid platform-only diffs
-
-#### Scenario: New text-like file type is introduced
-- **WHEN** a file type with line-based content is added to the project
-- **THEN** the baseline policy MUST define whether it is treated as text and how normalization applies
-
-### Requirement: Binary and generated artifact merge safety
-The repository baseline MUST identify binary or generated artifacts that should not use normal text merge semantics.
-
-#### Scenario: Binary asset is modified in parallel branches
-- **WHEN** a merge involves binary files with conflicting revisions
-- **THEN** the repository rules MUST avoid unsafe text merge behavior and require explicit conflict resolution
-
-#### Scenario: Generated output appears in a pull request
-- **WHEN** generated artifacts are changed
-- **THEN** repository ignore and tracking rules MUST determine whether those files are versioned or excluded
-
-### Requirement: Conflict resolution operational guidance
-The project MUST provide a documented conflict-resolution procedure that developers can follow consistently.
-
-#### Scenario: Developer encounters merge conflict
-- **WHEN** a sync or integration action results in conflict markers
-- **THEN** the developer MUST follow the documented resolution steps and verification checklist before completing the merge
-
-#### Scenario: Team identifies recurring conflict hotspot
-- **WHEN** the same files repeatedly conflict across iterations
-- **THEN** the process MUST require adding mitigation actions (ownership, file split, or baseline rule update) to reduce recurrence

+ 0 - 34
openspec/changes/improve-git-collaboration-workflow/specs/git-collaboration-policy/spec.md

@@ -1,34 +0,0 @@
-## ADDED Requirements
-
-### Requirement: Protected trunk and short-lived working branches
-The repository workflow MUST require all feature development to happen on short-lived branches, and trunk branches MUST be integrated through reviewed pull requests.
-
-#### Scenario: Developer starts new work item
-- **WHEN** a developer starts implementation for a new task
-- **THEN** the developer MUST create and use a `feature/*` or `fix/*` branch instead of committing directly to trunk
-
-#### Scenario: Code is merged to trunk
-- **WHEN** a branch is ready for integration
-- **THEN** the change MUST be merged through a pull request with review instead of direct push to trunk
-
-### Requirement: Standard daily sync flow
-The team sync process MUST use a consistent sequence (`status` check, `fetch`, then branch update) to reduce avoidable merge conflicts.
-
-#### Scenario: Branch is up to date before coding
-- **WHEN** a developer begins or resumes work
-- **THEN** the developer MUST verify working tree status and synchronize the branch using the documented standard sequence
-
-#### Scenario: Shared branch update occurs
-- **WHEN** remote commits are detected on the tracked base branch
-- **THEN** the developer MUST update local branch history using the defined sync strategy before continuing new commits
-
-### Requirement: Consistent commit and integration checkpoints
-The workflow MUST define minimum checkpoints for commit quality and pull request readiness before integration.
-
-#### Scenario: Developer prepares to push
-- **WHEN** local commits are ready to publish
-- **THEN** commit messages and change grouping MUST follow the repository contribution policy
-
-#### Scenario: Pull request is opened
-- **WHEN** a pull request is submitted for review
-- **THEN** it MUST include required context (scope, impact, validation evidence) defined by the collaboration policy

+ 0 - 34
openspec/changes/improve-git-collaboration-workflow/specs/local-change-protection/spec.md

@@ -1,34 +0,0 @@
-## ADDED Requirements
-
-### Requirement: Pull safety check for local modifications
-The workflow MUST require developers to inspect and protect local modifications before any pull, rebase, or branch switch operation.
-
-#### Scenario: Working tree contains uncommitted changes
-- **WHEN** a developer is about to run a sync operation and local changes exist
-- **THEN** the process MUST require explicit protection through commit or stash before sync continues
-
-#### Scenario: Working tree is clean
-- **WHEN** a developer runs pre-sync checks and no local changes exist
-- **THEN** the sync operation MAY proceed directly using the standard update flow
-
-### Requirement: Recoverable stash and temporary backup strategy
-The workflow MUST define a recoverable backup path for temporary local work, including naming and restore steps.
-
-#### Scenario: Developer parks in-progress work
-- **WHEN** a developer cannot commit current changes but must synchronize first
-- **THEN** the workflow MUST support creating a named stash or temporary backup branch with restoration instructions
-
-#### Scenario: Developer restores protected changes
-- **WHEN** sync is complete and in-progress work needs to be resumed
-- **THEN** the workflow MUST provide deterministic restore steps and conflict-handling guidance
-
-### Requirement: High-risk command guardrails
-The collaboration policy MUST define restricted destructive Git commands and required safeguards before any forced history or workspace reset action.
-
-#### Scenario: Developer considers destructive cleanup
-- **WHEN** a developer plans to use commands that can discard local changes
-- **THEN** the workflow MUST require explicit confirmation and a documented recovery point first
-
-#### Scenario: Accidental overwrite risk is identified
-- **WHEN** a potential overwrite or data-loss situation is detected
-- **THEN** the workflow MUST direct the developer to the documented recovery SOP instead of continuing destructive operations

+ 0 - 25
openspec/changes/improve-git-collaboration-workflow/tasks.md

@@ -1,25 +0,0 @@
-## 1. 协作规范与文档落地
-
-- [x] 1.1 在 `doc/` 新增 Git 协作总览文档,明确分支模型(`feature/*`、`fix/*`、主干 PR 合入)。
-- [x] 1.2 编写“日常同步 SOP”,固化 `status -> fetch -> rebase/merge` 标准流程与适用边界。
-- [x] 1.3 编写“冲突处理 SOP”,定义冲突定位、手工解决、验证与提交步骤。
-- [x] 1.4 编写“本地改动恢复 SOP”,覆盖 `stash` 命名、恢复、误操作回滚与应急路径。
-
-## 2. 仓库基线配置
-
-- [x] 2.1 新增或更新仓库根目录 `.gitattributes`,定义文本归一化和关键文件类型策略。
-- [x] 2.2 审核并更新 `.gitignore`,补充生成物与本地环境噪音文件规则。
-- [x] 2.3 对二进制或不可安全文本合并文件补充策略说明,并在文档中标注处理方式。
-
-## 3. 本地改动保护机制
-
-- [x] 3.1 在协作文档中定义 pull/rebase 前强制检查项(工作区是否干净、是否需要 commit/stash)。
-- [x] 3.2 定义高风险命令红线(如 `reset --hard`、破坏性 checkout)和使用前置条件。
-- [x] 3.3 补充“临时备份分支”与“命名 stash”规范,确保未完成改动可追踪、可恢复。
-
-## 4. 团队流程对齐与验证
-
-- [x] 4.1 更新 PR 模板或检查清单,加入协作流程必填项(同步方式、冲突处理、验证记录)。
-- [ ] 4.2 组织一次试运行(1-2 个迭代),收集团队冲突案例与本地覆盖风险案例。
-- [ ] 4.3 根据试运行反馈修订文档与策略,形成 v1.0 稳定协作规范。
-- [x] 4.4 在变更说明中记录最终采用规则、已知限制与后续优化计划。

+ 1 - 1
openspec/changes/improve-git-collaboration-workflow/.openspec.yaml → openspec/changes/playlist-backup/.openspec.yaml

@@ -1,2 +1,2 @@
 schema: spec-driven
-created: 2026-02-06
+created: 2026-02-08

+ 143 - 0
openspec/changes/playlist-backup/design.md

@@ -0,0 +1,143 @@
+## Context
+
+TTMusic 的歌单数据存储在两个本地 RDB 数据库中:
+- `PlaylistStore.db`:包含 `playlistTable`(歌单信息)和 `playlistSongTable`(歌单-歌曲关联)
+- `mediaDB.db`:包含 `mediaTable`(歌曲元数据),歌单通过 `songFilePath` 关联到歌曲
+
+当前没有任何备份机制。应用已有完善的远程网盘集成(`RemoteDriveManager` 支持 WebDAV、SMB、百度网盘等 13 种协议),以及基于服务器 API(`pay.ss5.xyz`)的 VIP 会员体系。VIP 状态通过 `PreferencesUtil.getBooleanSync('hasActiveSubscription')` 判断。
+
+## Goals / Non-Goals
+
+**Goals:**
+- 用户可以将全部歌单数据导出为单个 JSON 文件到本地存储
+- 用户可以从 JSON 备份文件导入/恢复歌单数据
+- 用户可以将备份文件上传到已配置的 WebDAV 账户
+- 用户可以从 WebDAV 账户下载并恢复备份
+- VIP 用户可以启用自动备份到服务器
+- 在设置页面提供统一的备份管理入口
+
+**Non-Goals:**
+- 不备份歌曲音频文件本身,仅备份歌单结构和歌曲元数据
+- 不支持增量备份,每次备份为全量快照
+- 不支持实时多设备同步(仅手动或定时备份+恢复)
+- 不支持非 WebDAV 类型的远程网盘备份(SMB/FTP/百度等留待后续扩展)
+- 不修改现有数据库表结构
+
+## Decisions
+
+### D1: 备份文件格式 — JSON
+
+**选择**: 使用单个 JSON 文件作为备份格式
+
+**备选方案**:
+- SQLite DB 文件直接拷贝:简单但不可读、版本兼容性差
+- Protocol Buffers:性能好但增加依赖、不可人工编辑
+- JSON:可读、无额外依赖、易于调试和版本迁移
+
+**理由**: JSON 格式与项目现有的数据交换模式一致(ConfigManager、API 响应均为 JSON),ArkTS 原生支持 `JSON.stringify`/`JSON.parse`,无需引入新依赖。
+
+**格式结构**:
+```json
+{
+  "version": 1,
+  "exportTime": "2026-02-08T12:00:00.000Z",
+  "appVersion": "1.0.0",
+  "playlists": [
+    {
+      "id": "...",
+      "name": "...",
+      "coverPath": "...",
+      "description": "...",
+      "createTime": "...",
+      "updateTime": "...",
+      "songCount": 10,
+      "sortOrder": 0,
+      "songs": [
+        {
+          "songFilePath": "...",
+          "addTime": "...",
+          "sortOrder": 0
+        }
+      ]
+    }
+  ]
+}
+```
+
+### D2: 导入合并策略 — 按名称匹配 + 用户选择
+
+**选择**: 导入时按歌单名称检测冲突,让用户选择「覆盖」「跳过」或「重命名」
+
+**备选方案**:
+- 按 ID 匹配:ID 是 `Date.now() + random`,不同设备几乎不会冲突,但无法识别逻辑重复
+- 全量覆盖:简单但会丢失用户修改
+- 按名称匹配 + 用户选择:最灵活
+
+**理由**: 歌单名称对用户有实际意义,同名歌单大概率是同一歌单的不同版本。提供选择权避免意外数据丢失。
+
+### D3: WebDAV 备份路径 — 固定子目录
+
+**选择**: 备份文件存放在 WebDAV 账户的 `/TTMusic/backups/` 固定目录下
+
+**理由**: 使用固定路径避免用户需要手动选择目录,同时与用户的音乐文件隔离。文件名格式为 `playlist_backup_YYYYMMDD_HHmmss.json`,便于识别和管理。
+
+### D4: 服务器自动备份 — 复用已有 API 体系
+
+**选择**: 通过 `pay.ss5.xyz` 服务器 API 上传/下载备份数据,需要 `userToken` 认证
+
+**API 设计**:
+- `POST /backup/playlist/upload` — 上传备份 JSON(需 token + VIP)
+- `GET /backup/playlist/download` — 下载最新备份(需 token + VIP)
+- `GET /backup/playlist/list` — 获取备份历史列表(需 token + VIP)
+
+**理由**: 复用已有的用户认证体系(`userToken`)和服务器架构,与现有 VIP 功能(会员信息、支付等)保持一致。
+
+### D5: 自动备份触发时机 — 歌单变更后延迟上传
+
+**选择**: 在歌单增删改操作后设置 5 分钟延迟窗口,窗口内多次变更合并为一次上传
+
+**备选方案**:
+- 即时上传:频繁操作时产生大量请求
+- 固定间隔轮询:浪费资源且实时性差
+- 延迟合并:兼顾实时性和效率
+
+**理由**: 用户整理歌单时通常连续操作(添加多首歌、创建多个歌单),延迟合并避免频繁网络请求。使用 `setTimeout` + 标志位实现防抖。
+
+### D6: 核心模块设计 — PlaylistBackupManager
+
+**选择**: 新建 `PlaylistBackupManager` 类统一管理所有备份相关逻辑
+
+**模块职责**:
+- `exportToJson()`: 从 PlaylistTable 读取全部歌单数据,序列化为 JSON
+- `importFromJson()`: 解析 JSON,校验格式版本,写入 PlaylistTable
+- `saveToLocal()`: 使用 `DocumentViewPicker` 让用户选择保存位置
+- `loadFromLocal()`: 使用 `DocumentViewPicker` 让用户选择备份文件
+- `uploadToWebDav()`: 通过 `RemoteDriveManager` 的 `rcpSocket` 上传到 WebDAV
+- `downloadFromWebDav()`: 从 WebDAV 下载备份文件
+- `uploadToServer()`: VIP 用户上传到服务器 API
+- `downloadFromServer()`: VIP 用户从服务器下载
+- `scheduleAutoBackup()`: 管理自动备份的防抖定时器
+
+**理由**: 集中管理避免备份逻辑散落在多个文件中,便于测试和维护。
+
+## Risks / Trade-offs
+
+**[风险] 大量歌单导出的内存占用** → 分批序列化歌单数据,避免一次性将所有歌单加载到内存中。对于超过 100 个歌单的场景进行分页查询。
+
+**[风险] WebDAV 服务器兼容性** → 不同 WebDAV 服务器对大文件上传行为不一致。备份文件通常较小(几十 KB 到几 MB),风险可控。使用现有 `rcpSocket.uploadFile` 方法,已验证兼容性。
+
+**[风险] 备份文件版本不兼容** → JSON 格式包含 `version` 字段。导入时检查版本号,不兼容的版本给出明确提示。当前版本为 1,预留升级空间。
+
+**[风险] 自动备份消耗流量** → 仅 VIP 用户启用,且使用防抖合并。备份数据为纯文本 JSON,体积较小。设置页面提供开关让用户控制。
+
+**[风险] 服务器 API 需要后端配合** → 服务器端 API 需要新增三个端点。可以先实现本地导出/导入 + WebDAV 备份,服务器自动备份作为第二阶段上线。
+
+**[取舍] 不备份歌曲音频文件** → 降低了备份完整性,但大幅减少备份体积和时间。歌曲文件可以从原始来源重新获取(本地扫描或 WebDAV)。
+
+**[取舍] 不支持增量备份** → 实现简单,但每次备份为全量。鉴于歌单数据量通常较小(几十个歌单,几千首歌曲关联),全量备份的开销完全可接受。
+
+## Open Questions
+
+- Q1: 服务器自动备份的 API 端点是否需要额外的签名验证机制(类似 `deleteAccount` 中的签名方式)?
+- Q2: 备份文件中是否需要包含部分歌曲元数据(如歌名、艺术家)以便在目标设备没有对应歌曲文件时仍能显示歌单内容?
+- Q3: WebDAV 备份是否应该支持自动定时备份(不仅限于服务器备份),还是仅支持手动触发?

+ 30 - 0
openspec/changes/playlist-backup/proposal.md

@@ -0,0 +1,30 @@
+## Why
+
+用户创建的歌单数据(歌单信息、歌曲关联)仅存储在本地 RDB 数据库中,一旦卸载应用或更换设备,所有歌单数据将丢失。用户需要一种可靠的方式将歌单备份到远程存储,并在需要时恢复。同时,自动备份到服务器作为 VIP 增值功能,可以增强产品价值。
+
+## What Changes
+
+- 新增歌单导出功能:将歌单数据序列化为 JSON 格式文件,支持手动导出到本地存储
+- 新增歌单导入功能:从 JSON 备份文件恢复歌单数据(歌单信息 + 歌曲关联)
+- 新增 WebDAV 备份功能:将歌单备份文件上传到用户已配置的 WebDAV 账户(复用现有 `RemoteDriveManager`)
+- 新增 WebDAV 恢复功能:从 WebDAV 账户下载备份文件并恢复歌单
+- 新增自动备份功能(VIP):歌单变更时自动备份到服务器端(通过远程 API),VIP 用户专享
+- 新增备份管理 UI:在设置页面添加备份/恢复入口,显示备份历史和状态
+
+## Capabilities
+
+### New Capabilities
+- `playlist-export-import`: 歌单数据的本地导出和导入,包括 JSON 序列化格式定义、文件选择器集成、数据校验与合并策略
+- `playlist-remote-backup`: 歌单数据的远程备份与恢复,包括 WebDAV 备份上传/下载、服务器端自动备份(VIP)、备份历史管理
+
+### Modified Capabilities
+(无需修改现有规格)
+
+## Impact
+
+- **数据模型**: `PlaylistTable` 需要新增导出/导入方法,将歌单及关联歌曲序列化为 JSON
+- **远程存储**: 复用 `RemoteDriveManager` 和现有 WebDAV/远程网盘账户体系(`RemoteDriveType`),新增备份文件的上传下载逻辑
+- **VIP 系统**: 集成现有 VIP 权限检查(`VipPage`、`UserUtil`),自动备份功能仅限 VIP 用户
+- **配置系统**: 通过 `ConfigManager` 获取服务器端备份 API 地址等配置
+- **UI 层**: 在 `SettingPage` 新增备份管理入口,新建备份管理页面/对话框
+- **依赖**: 无新外部依赖,使用系统 `@kit.CoreFileKit` 进行文件操作,使用 `@kit.NetworkKit` 进行网络请求

+ 93 - 0
openspec/changes/playlist-backup/specs/playlist-export-import/spec.md

@@ -0,0 +1,93 @@
+## ADDED Requirements
+
+### Requirement: Backup JSON format definition
+The system SHALL use a versioned JSON format for playlist backup files. The JSON structure SHALL contain the following top-level fields:
+- `version` (number): Format version, currently `1`
+- `exportTime` (string): ISO 8601 timestamp of export
+- `appVersion` (string): Application version at export time
+- `playlists` (array): Array of playlist objects
+
+Each playlist object SHALL contain: `id`, `name`, `coverPath`, `description`, `createTime`, `updateTime`, `songCount`, `sortOrder`, and a `songs` array. Each song entry SHALL contain: `songFilePath`, `addTime`, `sortOrder`.
+
+#### Scenario: Valid backup file structure
+- **WHEN** a backup file is generated
+- **THEN** the file SHALL be valid JSON containing `version`, `exportTime`, `appVersion`, and `playlists` fields
+- **THEN** each playlist entry SHALL include its complete metadata and associated songs array
+
+#### Scenario: Empty playlist database
+- **WHEN** the user has no playlists and triggers an export
+- **THEN** the system SHALL generate a valid JSON file with an empty `playlists` array
+
+### Requirement: Export playlists to JSON file
+The system SHALL allow users to export all playlists and their associated songs to a single JSON file saved to local storage. The system SHALL use the system `DocumentViewPicker` to let the user choose the save location. The exported file name SHALL follow the pattern `playlist_backup_YYYYMMDD_HHmmss.json`.
+
+#### Scenario: Successful export to local storage
+- **WHEN** the user triggers playlist export from the backup management UI
+- **THEN** the system SHALL read all playlists and their songs from `PlaylistTable`
+- **THEN** the system SHALL serialize the data to the defined JSON format
+- **THEN** the system SHALL open a `DocumentViewPicker` for the user to choose a save location
+- **THEN** the system SHALL write the JSON file to the chosen location
+- **THEN** the system SHALL display a success toast with the file name
+
+#### Scenario: Export with large dataset
+- **WHEN** the database contains more than 100 playlists
+- **THEN** the system SHALL query playlists in batches to avoid excessive memory usage
+- **THEN** the export SHALL complete successfully without out-of-memory errors
+
+#### Scenario: Export failure
+- **WHEN** the user cancels the file picker or a write error occurs
+- **THEN** the system SHALL display an error toast describing the failure
+- **THEN** no partial file SHALL be left on disk
+
+### Requirement: Import playlists from JSON file
+The system SHALL allow users to import playlists from a previously exported JSON backup file. The system SHALL use `DocumentViewPicker` to let the user select the backup file.
+
+#### Scenario: Successful import with no conflicts
+- **WHEN** the user selects a valid backup JSON file
+- **THEN** the system SHALL parse and validate the JSON structure and version
+- **THEN** the system SHALL create all playlists and song associations from the backup
+- **THEN** the system SHALL display a success toast with the count of imported playlists
+
+#### Scenario: Import with version validation
+- **WHEN** the user selects a backup file with an unsupported `version` number
+- **THEN** the system SHALL display an error message stating the file version is not compatible
+- **THEN** no data SHALL be imported
+
+#### Scenario: Import with malformed JSON
+- **WHEN** the user selects a file that is not valid JSON or missing required fields
+- **THEN** the system SHALL display an error message indicating the file format is invalid
+- **THEN** no data SHALL be imported
+
+### Requirement: Import conflict resolution
+When importing playlists, the system SHALL detect name conflicts with existing playlists and present the user with resolution options: "overwrite", "skip", or "rename" (append a numeric suffix).
+
+#### Scenario: Conflicting playlist name — user chooses overwrite
+- **WHEN** the imported backup contains a playlist with the same name as an existing local playlist
+- **AND** the user selects "overwrite"
+- **THEN** the system SHALL delete the existing playlist and its song associations
+- **THEN** the system SHALL create the playlist from the backup data
+
+#### Scenario: Conflicting playlist name — user chooses skip
+- **WHEN** the imported backup contains a playlist with the same name as an existing local playlist
+- **AND** the user selects "skip"
+- **THEN** the system SHALL not modify the existing playlist
+- **THEN** the system SHALL proceed to import the remaining playlists
+
+#### Scenario: Conflicting playlist name — user chooses rename
+- **WHEN** the imported backup contains a playlist with the same name as an existing local playlist
+- **AND** the user selects "rename"
+- **THEN** the system SHALL create the playlist with a suffixed name (e.g., "My Playlist (2)")
+- **THEN** the system SHALL import all associated songs under the renamed playlist
+
+#### Scenario: Multiple conflicts — apply to all
+- **WHEN** multiple playlists in the backup conflict with existing playlists
+- **THEN** the conflict resolution dialog SHALL provide an "apply to all" checkbox
+- **THEN** choosing "apply to all" SHALL apply the selected resolution strategy to all remaining conflicts
+
+### Requirement: Backup management UI entry
+The system SHALL provide a "Backup & Restore" entry in the Settings page that navigates to a dedicated backup management interface.
+
+#### Scenario: Navigate to backup management
+- **WHEN** the user taps "Backup & Restore" in Settings
+- **THEN** the system SHALL navigate to the backup management page
+- **THEN** the page SHALL display options for "Export to File", "Import from File", "Backup to WebDAV", and "Restore from WebDAV"

+ 117 - 0
openspec/changes/playlist-backup/specs/playlist-remote-backup/spec.md

@@ -0,0 +1,117 @@
+## ADDED Requirements
+
+### Requirement: Upload backup to WebDAV
+The system SHALL allow users to upload a playlist backup JSON file to a selected WebDAV account. The backup file SHALL be stored at the path `/TTMusic/backups/` on the WebDAV server. The system SHALL create the directory if it does not exist.
+
+#### Scenario: Successful WebDAV backup upload
+- **WHEN** the user triggers "Backup to WebDAV" and selects a WebDAV account
+- **THEN** the system SHALL generate a backup JSON from the current playlist database
+- **THEN** the system SHALL create the `/TTMusic/backups/` directory on the WebDAV server if absent
+- **THEN** the system SHALL upload the file with name `playlist_backup_YYYYMMDD_HHmmss.json`
+- **THEN** the system SHALL display a success toast with the backup file name
+
+#### Scenario: WebDAV account selection
+- **WHEN** the user triggers "Backup to WebDAV"
+- **AND** multiple WebDAV accounts are configured
+- **THEN** the system SHALL present a picker dialog listing only WebDAV-type accounts
+- **THEN** the user SHALL select the target account before upload proceeds
+
+#### Scenario: No WebDAV account configured
+- **WHEN** the user triggers "Backup to WebDAV"
+- **AND** no WebDAV accounts are configured
+- **THEN** the system SHALL display a message prompting the user to add a WebDAV account first
+
+#### Scenario: WebDAV upload failure
+- **WHEN** a network error or authentication failure occurs during upload
+- **THEN** the system SHALL display an error toast with the failure reason
+- **THEN** no incomplete file SHALL remain on the server
+
+### Requirement: Download and restore backup from WebDAV
+The system SHALL allow users to browse and download backup files from a WebDAV account's `/TTMusic/backups/` directory and restore playlists from the selected file.
+
+#### Scenario: Successful WebDAV restore
+- **WHEN** the user triggers "Restore from WebDAV" and selects a WebDAV account
+- **THEN** the system SHALL list all `.json` files in the `/TTMusic/backups/` directory
+- **THEN** the user SHALL select a backup file from the list
+- **THEN** the system SHALL download and parse the file
+- **THEN** the system SHALL import the playlists using the same conflict resolution logic as local import
+
+#### Scenario: No backups found on WebDAV
+- **WHEN** the `/TTMusic/backups/` directory is empty or does not exist on the WebDAV server
+- **THEN** the system SHALL display a message indicating no backups were found
+
+#### Scenario: WebDAV download failure
+- **WHEN** a network error occurs during backup file download
+- **THEN** the system SHALL display an error toast with the failure reason
+- **THEN** no data SHALL be imported
+
+### Requirement: Server auto-backup for VIP users
+The system SHALL provide VIP users with automatic server backup functionality. When enabled, the system SHALL upload the playlist backup to the server API after playlist changes, using a 5-minute debounce window to merge consecutive changes into a single upload.
+
+#### Scenario: VIP user enables auto-backup
+- **WHEN** a VIP user toggles the auto-backup switch ON in backup settings
+- **THEN** the system SHALL persist the preference
+- **THEN** the system SHALL begin monitoring playlist changes (create, delete, update, add/remove songs)
+
+#### Scenario: Auto-backup triggered by playlist change
+- **WHEN** auto-backup is enabled and a playlist change occurs
+- **THEN** the system SHALL start a 5-minute debounce timer
+- **WHEN** additional changes occur within the 5-minute window
+- **THEN** the system SHALL reset the timer
+- **WHEN** the timer expires with no further changes
+- **THEN** the system SHALL generate a backup JSON and upload it to `POST /backup/playlist/upload` with the user's token
+
+#### Scenario: Non-VIP user attempts to enable auto-backup
+- **WHEN** a non-VIP user attempts to toggle the auto-backup switch
+- **THEN** the system SHALL display a prompt directing the user to the VIP subscription page
+- **THEN** the auto-backup switch SHALL remain OFF
+
+#### Scenario: VIP subscription expires while auto-backup is enabled
+- **WHEN** a user's VIP subscription expires
+- **THEN** the system SHALL disable auto-backup
+- **THEN** the system SHALL display a notification informing the user that auto-backup has been disabled
+
+### Requirement: Restore from server backup
+The system SHALL allow VIP users to download and restore their latest server backup or select from backup history.
+
+#### Scenario: VIP user restores latest server backup
+- **WHEN** a VIP user triggers "Restore from Server"
+- **THEN** the system SHALL call `GET /backup/playlist/list` to retrieve backup history
+- **THEN** the system SHALL display the list of available backups with timestamps
+- **THEN** the user SHALL select a backup to restore
+- **THEN** the system SHALL call `GET /backup/playlist/download` with the selected backup identifier
+- **THEN** the system SHALL import playlists using the standard conflict resolution logic
+
+#### Scenario: No server backups available
+- **WHEN** a VIP user triggers "Restore from Server"
+- **AND** no backups exist on the server
+- **THEN** the system SHALL display a message indicating no server backups are available
+
+#### Scenario: Non-VIP user attempts server restore
+- **WHEN** a non-VIP user attempts to access "Restore from Server"
+- **THEN** the system SHALL display a prompt directing the user to the VIP subscription page
+
+### Requirement: Server backup upload with authentication
+All server backup API requests SHALL include the user's `userToken` for authentication. The system SHALL handle token expiry or invalid token responses by prompting the user to re-login.
+
+#### Scenario: Successful authenticated upload
+- **WHEN** the system uploads a backup to the server
+- **THEN** the request SHALL include the `userToken` in the request parameters
+- **THEN** the server SHALL respond with a success confirmation
+
+#### Scenario: Token expired during upload
+- **WHEN** the server returns an authentication error (invalid or expired token)
+- **THEN** the system SHALL display a message prompting the user to re-login
+- **THEN** the auto-backup timer SHALL be paused until the user re-authenticates
+
+### Requirement: Backup history display
+The backup management page SHALL display a history of recent backups, showing the backup source (local/WebDAV/server), timestamp, and playlist count for each entry.
+
+#### Scenario: View backup history
+- **WHEN** the user opens the backup management page
+- **THEN** the system SHALL display a list of recent backup operations
+- **THEN** each entry SHALL show: backup type icon (local/WebDAV/server), date and time, and number of playlists in the backup
+
+#### Scenario: Empty backup history
+- **WHEN** the user has never performed a backup
+- **THEN** the system SHALL display a placeholder message encouraging the user to create their first backup

+ 74 - 0
openspec/changes/playlist-backup/tasks.md

@@ -0,0 +1,74 @@
+## 1. 数据模型与核心序列化
+
+- [x] 1.1 定义备份 JSON 数据接口:在 `viewmodel/` 下新建 `PlaylistBackup.ets`,定义 `PlaylistBackupData`、`PlaylistBackupItem`、`PlaylistSongBackupItem` 接口,包含 `version`、`exportTime`、`appVersion`、`playlists` 字段
+- [x] 1.2 在 `PlaylistTable` 中新增 `exportAllPlaylists()` 方法:查询所有歌单及其关联歌曲,返回 `PlaylistBackupData` 对象。超过 100 个歌单时分批查询
+- [x] 1.3 在 `PlaylistTable` 中新增 `importPlaylists()` 方法:接受 `PlaylistBackupData`,逐一创建歌单和歌曲关联,返回导入结果统计
+
+## 2. PlaylistBackupManager 核心类
+
+- [x] 2.1 新建 `common/util/PlaylistBackupManager.ets`,实现单例模式和基本结构
+- [x] 2.2 实现 `exportToJson()` 方法:调用 `PlaylistTable.exportAllPlaylists()`,序列化为 JSON 字符串
+- [x] 2.3 实现 `validateBackupJson()` 方法:解析 JSON 并校验 `version`、必要字段完整性,返回校验结果
+- [x] 2.4 实现 `importFromJson()` 方法:解析并校验 JSON,调用 `PlaylistTable.importPlaylists()` 写入数据
+
+## 3. 本地文件导出导入
+
+- [x] 3.1 实现 `saveToLocal()` 方法:使用 `DocumentViewPicker` 保存 JSON 文件,文件名格式 `playlist_backup_YYYYMMDD_HHmmss.json`
+- [x] 3.2 实现 `loadFromLocal()` 方法:使用 `DocumentViewPicker` 选择 `.json` 文件,读取并返回文件内容
+- [x] 3.3 导出失败和取消处理:用户取消文件选择器或写入失败时显示错误 toast,确保无残留文件
+
+## 4. 导入冲突解决
+
+- [x] 4.1 新建 `dialog/ImportConflictDialog.ets`:冲突解决对话框,显示冲突歌单名称,提供「覆盖」「跳过」「重命名」三个选项和「应用到全部」复选框
+- [x] 4.2 在 `importFromJson()` 中集成冲突检测:导入前按歌单名称查询现有歌单,发现冲突时弹出对话框
+- [x] 4.3 实现覆盖逻辑:删除现有歌单及其歌曲关联,重新创建
+- [x] 4.4 实现重命名逻辑:自动生成后缀名称(如 "我的歌单 (2)"),检查后缀名也不冲突
+- [x] 4.5 实现「应用到全部」:记录用户选择,后续冲突自动应用同一策略
+
+## 5. WebDAV 备份上传
+
+- [x] 5.1 实现 `uploadToWebDav()` 方法:接受 WebDAV 账户参数,生成备份 JSON,通过 `RemoteDriveManager` 的 `rcpSocket` 上传到 `/TTMusic/backups/`
+- [x] 5.2 实现目录自动创建:上传前检查并创建 `/TTMusic/backups/` 目录(使用 `createWebDavFolder` 或 `createDirectory`)
+- [x] 5.3 新建 WebDAV 账户选择器对话框:过滤仅显示 `RemoteDriveType.WebDav` 类型的账户,无账户时提示用户添加(集成在 BackupManageView 弹层中)
+- [x] 5.4 错误处理:网络错误和认证失败时显示具体错误信息
+
+## 6. WebDAV 备份恢复
+
+- [x] 6.1 实现 `listWebDavBackups()` 方法:列出 WebDAV 服务器 `/TTMusic/backups/` 下的所有 `.json` 文件
+- [x] 6.2 实现 `downloadFromWebDav()` 方法:下载选中的备份文件内容
+- [x] 6.3 新建备份文件选择列表 UI:显示 WebDAV 上的备份文件列表(文件名 + 时间),用户点击后下载并触发导入流程(集成在 BackupManageView 弹层中)
+- [x] 6.4 目录不存在或为空时显示「暂无备份」提示
+
+## 7. 服务器自动备份(VIP)
+
+- [x] 7.1 实现 `uploadToServer()` 方法:通过 `POST /backup/playlist/upload` 上传备份 JSON,请求携带 `userToken`
+- [x] 7.2 实现 `downloadFromServer()` 方法:通过 `GET /backup/playlist/download` 下载指定备份
+- [x] 7.3 实现 `listServerBackups()` 方法:通过 `GET /backup/playlist/list` 获取备份历史列表
+- [x] 7.4 实现 VIP 权限检查:在自动备份和服务器恢复入口检查 `hasActiveSubscription`,非 VIP 引导到 VipPage
+- [x] 7.5 实现 token 过期处理:服务器返回认证错误时提示用户重新登录,暂停自动备份
+
+## 8. 自动备份防抖机制
+
+- [x] 8.1 实现 `scheduleAutoBackup()` 方法:使用 `setTimeout` 实现 5 分钟防抖,窗口内多次调用只触发一次上传
+- [x] 8.2 在 `PlaylistTable` 的增删改方法中添加自动备份触发点:`createPlaylist`、`deletePlaylist`、`updatePlaylist`、`addSongToPlaylist`、`removeSongFromPlaylist` 操作完成后调用 `scheduleAutoBackup()`
+- [x] 8.3 实现自动备份偏好持久化:使用 `PreferencesUtil` 保存自动备份开关状态
+- [x] 8.4 实现 VIP 过期自动关闭:检测到 VIP 状态变化时关闭自动备份并通知用户
+
+## 9. 备份管理 UI
+
+- [x] 9.1 新建 `view/BackupManageView.ets`:备份管理视图组件,包含导出、导入、WebDAV 备份、WebDAV 恢复、服务器备份(VIP)、服务器恢复(VIP)功能入口
+- [x] 9.2 在 `SettingPage.ets` 中添加「备份与恢复」菜单项,通过 Sheet 弹层打开 BackupManageView
+- [x] 9.3 实现备份历史列表 UI:显示最近备份记录(类型图标、时间、歌单数量),无记录时显示引导提示
+- [x] 9.4 实现自动备份开关 UI(VIP 标识):开关控件带 VIP 标签,非 VIP 点击引导到会员页面
+- [x] 9.5 实现操作进度展示:导出/导入/上传/下载过程中显示 loading 状态和进度提示
+
+## 10. 备份历史记录持久化
+
+- [x] 10.1 定义备份记录数据模型:包含备份类型(local/webdav/server)、时间戳、歌单数量、文件路径/标识
+- [x] 10.2 使用 `PreferencesUtil` 存储最近备份记录列表(JSON 数组,最多保留 20 条)
+- [x] 10.3 在每次备份/恢复操作完成后写入历史记录
+
+## 11. 页面路由注册
+
+- [x] 11.1 备份管理以 Sheet 弹层方式集成在 SettingPage 中,无需单独路由注册
+- [x] 11.2 从 SettingPage 点击「备份与恢复」菜单项即可打开备份管理界面