chendeben 9 місяців тому
батько
коміт
7e27ae74aa

+ 29 - 0
AGENTS.md

@@ -0,0 +1,29 @@
+# Repository Guidelines
+# 回复语言
+所有回复必须使用中文
+
+## 项目结构与模块划分
+核心 ArkTS 代码位于 `entry/src/main/ets`,其中 `view/` 负责 UI 组件(如 `LocalMusic.ets`),`viewmodel/` 管理数据模型(如 `VideoItem`),`common/` 存放工具类与常量(`RemoteDriveManager`、`RcpSocketUtil` 等),`controller/` 承接 Ability 调度。鸿蒙资源位于 `entry/src/main/resources`。本地播放器内核与 FFmpeg 构建脚本存放在 `ijkplayer/`,复用的二进制库放在 `lib/`。流程文档、架构图与配置说明集中在 `doc/`。三方 OpenHarmony 依赖通过 `oh_modules/` 与 `ohpm` 管理。
+
+## 构建、测试与开发命令
+- `ohpm install`:根据 `oh-package.json5` 安装 ArkTS 依赖,新增/升级模块后必跑。
+- `hvigor --mode module assemble entry`:编译 `entry` 模块并生成默认 HAP,必要时追加 `--product-name default`。
+- `hvigor --mode module clean`:清理构建缓存,确保可重复构建。
+- DevEco Studio 的 “Run > Run Entry” 等价于 assemble 并自动部署到真机/模拟器。
+
+## 代码风格与命名规范
+ArkTS/ETS 采用两个空格缩进、PascalCase 文件名(如 `UploadMusicPage.ets`)、camelCase 成员命名。UI 逻辑保持声明式,业务流程尽量下沉到 `common/` 或 `viewmodel/`。除非调试底层连接,优先使用 `Logger` 封装而非裸 `console`。原生模块遵循仓库 `.clang-format`(LLVM 风格,两空格)与 `.clang-tidy` 配置,禁止混用 Tab/空格。提交前通过 DevEco Studio 运行 ESLint/ArkTS 检查。
+
+## 测试指南
+Hypium 自动化尚未完备,现阶段以真机冒烟为主:播放本地音频、执行 WebDAV 上传(`UploadMusicPage`)、验证后台任务。新增复杂逻辑时,在 `entry/src/main/test/` 下补充 Hypium 用例,命名为 `<Feature>Spec.ets`。CI 覆盖前,请在 PR 描述中列出手工测试步骤和结果。
+
+## 提交与 PR 规范
+历史提交常见中文简述配合 Conventional Commits 前缀(如 `feat(remote-drive): ...`、`fix: ...`)。主题维持祈使句、72 字符内,涉及特定模块请加 scope。提交 PR 时需:
+1. 说明用户可见改动及涉及模块。
+2. 关联 Issue/需求编号。
+3. UI 或网络行为变更附上截图/日志(如 WebDAV 日志)。
+4. 列出执行的手工或自动化测试。
+避免在一次 PR 中混入无关重构,必要时拆分。
+
+## 安全与配置提示
+切勿提交真实 WebDAV 凭据或 VIP 秘钥,统一通过 `PreferencesUtil` 注入或在 `doc/` 示例中使用假数据。大体积媒体应放在 `entry/src/main/resources/rawfile` 或 `.gitignore` 指定的外部包。修改 `ijkplayer/` 相关内容时,本地执行 `prebuild.sh` 生成产物,勿直接提交二进制。

+ 3 - 3
entry/src/main/ets/common/util/ConfigManager.ets

@@ -49,7 +49,7 @@ export class ConfigManager {
   public static async initConfig(): Promise<boolean> {
     try {
       Logger.info(ConfigManager.TAG, '开始初始化配置...');
-      
+
       const configData = await ConfigManager.fetchConfigFromAPI();
       if (!configData) {
         Logger.error(ConfigManager.TAG, '获取配置数据失败');
@@ -87,7 +87,7 @@ export class ConfigManager {
       if (response.responseCode === 200) {
         const responseData = response.result as string;
         const configResponse: ConfigResponse = JSON.parse(responseData);
-        
+
         if (configResponse.code === 0) {
           Logger.info(ConfigManager.TAG, `成功获取${configResponse.data.length}个配置项`);
           return configResponse.data;
@@ -113,7 +113,7 @@ export class ConfigManager {
     try {
       configData.forEach(config => {
         let processedValue: ConfigValue = config.value;
-        
+
         // 根据类型处理值
         switch (config.type) {
           case 'json':

+ 44 - 7
entry/src/main/ets/common/util/RcpSocketUtil.ets

@@ -20,6 +20,43 @@ export class RcpSocket {
     console.info(UtilName, 'testTag', 'RcpSocketUtil单例已创建')
   }
 
+  private encodeUrlPath(path?: string): string {
+    if (!path || path.length === 0) {
+      return '/';
+    }
+    let normalized = path.replace(/\\/g, '/');
+    if (!normalized.startsWith('/')) {
+      normalized = `/${normalized}`;
+    }
+    normalized = normalized.replace(/\/+/g, '/');
+    const segments = normalized.split('/').map((segment) => {
+      if (!segment || segment.length === 0) {
+        return '';
+      }
+      let decoded = segment;
+      try {
+        decoded = decodeURIComponent(segment);
+      } catch (_err) {
+        // ignore decode errors and keep raw segment
+      }
+      return encodeURIComponent(decoded);
+    });
+    let encodedPath = segments.join('/');
+    if (!encodedPath.startsWith('/')) {
+      encodedPath = `/${encodedPath}`;
+    }
+    if (encodedPath.length === 0) {
+      encodedPath = '/';
+    }
+    return encodedPath;
+  }
+
+  private buildRequestUrl(host: string, port: number, path: string, enableHttps: boolean): string {
+    const protocol = enableHttps ? "https" : "http";
+    const encodedPath = this.encodeUrlPath(path);
+    return `${protocol}://${host}:${port}${encodedPath}`;
+  }
+
   static getInstance(): RcpSocket {
     if (!RcpSocket.instance) {
       RcpSocket.instance = new RcpSocket();
@@ -30,7 +67,7 @@ export class RcpSocket {
   public RcpSendHead(host: string, port: number, account: string, password: string, path: string,
     enableHttps: boolean): Promise<number> {
     return new Promise<number>((resolve, reject) => {
-      const url = `${enableHttps ? "https" : "http"}://${host}:${port}${path}`;
+      const url = this.buildRequestUrl(host, port, path, enableHttps);
       const timeoutDuration: number = 10000;
       const speedThreshold: number = 5000; // 设置速度测试的时间阈值
       console.info(UtilName, 'testTag', '发送HEAD的url:' + url)
@@ -110,7 +147,7 @@ export class RcpSocket {
   public RcpSendDelete(host: string, port: number, account: string, password: string, path: string,
     enableHttps: boolean): Promise<void> {
     return new Promise<void>((resolve, reject) => {
-      const url = `${enableHttps ? "https" : "http"}://${host}:${port}${path}`;
+      const url = this.buildRequestUrl(host, port, path, enableHttps);
       const timeoutDuration: number = 10000;
       console.info(UtilName, 'testTag', '发送Delete的url:' + url)
       // 创建 RCP 会话配置
@@ -173,8 +210,8 @@ export class RcpSocket {
   public RcpSendMove(host: string, port: number, account: string, password: string, path: string, enableHttps: boolean,
     newPath: string): Promise<void> {
     return new Promise<void>((resolve, reject) => {
-      const url = `${enableHttps ? "https" : "http"}://${host}:${port}${path}`;
-      const destinationUrl = `${enableHttps ? "https" : "http"}://${host}:${port}${newPath}`
+      const url = this.buildRequestUrl(host, port, path, enableHttps);
+      const destinationUrl = this.buildRequestUrl(host, port, newPath, enableHttps)
       const timeoutDuration: number = 10000;
       console.info(UtilName, 'testTag', '发送MOVE的url:' + url)
       // 创建 RCP 会话配置
@@ -245,7 +282,7 @@ export class RcpSocket {
     enableHttps: boolean
   ): Promise<FileInfo[]> {
     return new Promise(async (resolve, reject) => {
-      const url = `${enableHttps ? "https" : "http"}://${host}:${port.toString()}${path}`;
+      const url = this.buildRequestUrl(host, port, path, enableHttps);
       const timeoutDuration: number = 10000;
       console.info(UtilName, 'testTag', '发送PROPFIND请求的url:' + url)
       // 创建 RCP 会话配置
@@ -373,7 +410,7 @@ export class RcpSocket {
     cachePath: string
   ): Promise<FileInfo[]> {
     return new Promise(async (resolve, reject) => {
-      const url = `${enableHttps ? "https" : "http"}://${host}:${port.toString()}${path}`;
+      const url = this.buildRequestUrl(host, port, path, enableHttps);
       const timeoutDuration: number = 10000;
       console.info(UtilName, 'testTag', '发送PROPFIND递归请求的url:' + url)
       let cacheFileInfos_str: string = ''
@@ -820,7 +857,7 @@ export class RcpSocket {
     onProgress?: (uploaded: number, total: number) => void
   ): Promise<void> {
     return new Promise<void>(async (resolve, reject) => {
-      const url = `${enableHttps ? "https" : "http"}://${host}:${port}${remotePath}`;
+      const url = this.buildRequestUrl(host, port, remotePath, enableHttps);
       const timeoutDuration: number = 120000; // 上传超时时间设置为120秒
       console.info(UtilName, 'testTag', '开始上传文件到:', url);
 

+ 22 - 5
entry/src/main/ets/common/util/RemoteDriveManager.ets

@@ -46,6 +46,7 @@ export interface TransferTask {
   song: VideoItem;
   account: WebDavAccount;
   retryCount?: number; // 已重试次数
+  customUploadPath?: string; // 自定义上传路径(可选)
 }
 
 @Observed
@@ -1471,11 +1472,13 @@ export class RemoteDriveManager {
    * 添加歌曲到上传队列
    * @param songs 要上传的歌曲列表
    * @param account 目标WebDAV账户
+   * @param customUploadPath 自定义上传路径(可选,如果不提供则使用账户默认路径)
    */
-  public addToUploadQueue(songs: VideoItem[], account: WebDavAccount): void {
+  public addToUploadQueue(songs: VideoItem[], account: WebDavAccount, customUploadPath?: string): void {
     Logger.info(TAG, '========== 添加上传任务 ==========');
     Logger.info(TAG, `请求添加: ${songs.length} 个文件`);
     Logger.info(TAG, `目标账户: ${account.name} (ID: ${account.id})`);
+    Logger.info(TAG, `自定义上传路径: ${customUploadPath || '未指定,使用账户默认路径'}`);
     Logger.info(TAG, `当前队列: ${this.uploadQueue.length}/${this.MAX_UPLOAD_QUEUE_SIZE}`);
     
     // 检查队列大小限制
@@ -1490,9 +1493,14 @@ export class RemoteDriveManager {
     const songsToAdd = songs.length > availableSlots ? songs.slice(0, availableSlots) : songs;
     
     Logger.info(TAG, `实际添加: ${songsToAdd.length} 个文件`);
+    
     for (let i = 0; i < songsToAdd.length; i++) {
       const song = songsToAdd[i];
-      const task: TransferTask = { song, account };
+      const task: TransferTask = { 
+        song, 
+        account,
+        customUploadPath: customUploadPath // 保存自定义上传路径
+      };
       this.uploadQueue.push(task);
       Logger.info(TAG, `[${i + 1}/${songsToAdd.length}] ${song.name}`);
     }
@@ -1786,11 +1794,20 @@ export class RemoteDriveManager {
       
       this.notifyObservers(RemoteDriveManagerStates.UploadStart);
 
-      // 构建远程路径
-      const uploadPath = account.uploadFilePath || '/';
+      // 构建远程路径 - 优先使用任务中的自定义路径,否则使用账户默认路径
+      const uploadPath = task.customUploadPath || account.uploadFilePath || '/';
       const normalizedPath = this.normalizeFullPath(uploadPath);
       const remotePath = `${normalizedPath === '/' ? '' : normalizedPath}/${song.fileName || song.name}`;
-      Logger.info(TAG, `目标路径: ${remotePath}`);
+      Logger.info(TAG, '---------- 路径信息 ----------');
+      Logger.info(TAG, `任务自定义路径: ${task.customUploadPath || '未指定'}`);
+      Logger.info(TAG, `账户默认路径: ${account.uploadFilePath || '未配置'}`);
+      Logger.info(TAG, `实际使用路径: ${uploadPath}`);
+      Logger.info(TAG, `规范化后的路径: ${normalizedPath}`);
+      Logger.info(TAG, `文件名 (fileName): ${song.fileName}`);
+      Logger.info(TAG, `文件名 (name): ${song.name}`);
+      Logger.info(TAG, `最终远程路径: ${remotePath}`);
+      Logger.info(TAG, `完整URL将是: ${account.enableHttps ? 'https' : 'http'}://${account.host}:${account.port}${remotePath}`);
+      Logger.info(TAG, '------------------------------');
 
       // 检查文件是否存在
       Logger.info(TAG, '检查远程文件是否存在...');

+ 112 - 89
entry/src/main/ets/pages/UploadMusicPage.ets

@@ -6,6 +6,7 @@ import { RemoteDriveManagerStates } from '../common/enums/RemoteDriveManagerStat
 import Logger from '../common/util/Logger';
 import { router } from '@kit.ArkUI';
 import { CommonConstants } from '../common/constants/CommonConstants';
+import { Constants } from '../Constants';
 import { picker } from '@kit.CoreFileKit';
 import { BusinessError } from '@kit.BasicServicesKit';
 import { fileUri } from '@kit.CoreFileKit';
@@ -300,7 +301,7 @@ export struct UploadMusicPage {
       const documentSelectOptions = new picker.DocumentSelectOptions();
       
       // 设置音频文件过滤器
-      documentSelectOptions.fileSuffixFilters = CommonConstants.AUDIO_EXTENSIONS;
+      documentSelectOptions.fileSuffixFilters = Constants.AUDIO_EXTENSIONS;
       
       // 支持多选
       documentSelectOptions.maxSelectNumber = 100;
@@ -478,13 +479,31 @@ export struct UploadMusicPage {
       // 构建VideoItem列表
       const songs: VideoItem[] = [];
       for (let i = 0; i < this.selectedFiles.length; i++) {
-        const fileUri = this.selectedFiles[i];
-        const fileName = this.selectedFileNames[i];
+        const selectedUri = this.selectedFiles[i];
+        let displayName = this.selectedFileNames[i];
+        let resolvedPath = '';
+
+        try {
+          const fileUriObj = new fileUri.FileUri(selectedUri);
+          resolvedPath = fileUriObj.path || '';
+          if (!displayName) {
+            displayName = fileUriObj.name || selectedUri;
+          }
+        } catch (error) {
+          const err = error as Error;
+          Logger.error(TAG, `解析文件路径失败: ${err.message}`);
+          continue;
+        }
+
+        if (!resolvedPath) {
+          Logger.error(TAG, `无法获取本地路径,跳过文件: ${selectedUri}`);
+          continue;
+        }
 
         const videoItem = new VideoItem(
-          fileName,
-          fileUri,
-          fileUri,
+          displayName,
+          selectedUri,
+          resolvedPath,
           CommonConstants.TYPE_LOCAL,
           0,
           '',
@@ -493,14 +512,20 @@ export struct UploadMusicPage {
           undefined,
           undefined,
           undefined,
-          fileName
+          displayName
         );
 
         songs.push(videoItem);
       }
 
-      // 添加到上传队列
-      this.webdavManager.addToUploadQueue(songs, this.selectedAccount);
+      if (songs.length === 0) {
+        Logger.warn(TAG, '未找到可上传的有效文件,取消任务');
+        return;
+      }
+
+      // 添加到上传队列,传递用户选择的上传路径
+      Logger.info(TAG, `使用上传路径: ${this.uploadPath}`);
+      this.webdavManager.addToUploadQueue(songs, this.selectedAccount, this.uploadPath);
       this.totalUploadCount = songs.length;
       this.uploadedCount = 0;
 
@@ -546,95 +571,93 @@ export struct UploadMusicPage {
    */
   @Builder
   UploadProgressView() {
-    if (!this.isUploading || !this.currentTask) {
-      return;
-    }
-
-    Column() {
-      Text('上传进度')
-        .fontSize(16)
-        .fontWeight(FontWeight.Medium)
-        .fontColor(this.isDarkMode ? '#E5FFFFFF' : '#E5000000')
-        .margin({ bottom: 12 })
-        .alignSelf(ItemAlign.Start)
+    if (this.isUploading && this.currentTask) {
+      Column() {
+        Text('上传进度')
+          .fontSize(16)
+          .fontWeight(FontWeight.Medium)
+          .fontColor(this.isDarkMode ? '#E5FFFFFF' : '#E5000000')
+          .margin({ bottom: 12 })
+          .alignSelf(ItemAlign.Start)
 
-      // 当前文件名
-      Text(`当前文件: ${this.currentTask.name}`)
-        .fontSize(14)
-        .fontColor(this.isDarkMode ? '#99FFFFFF' : '#99000000')
-        .margin({ bottom: 8 })
-        .maxLines(1)
-        .textOverflow({ overflow: TextOverflow.Ellipsis })
+        // 当前文件名
+        Text(`当前文件: ${this.currentTask.name}`)
+          .fontSize(14)
+          .fontColor(this.isDarkMode ? '#99FFFFFF' : '#99000000')
+          .margin({ bottom: 8 })
+          .maxLines(1)
+          .textOverflow({ overflow: TextOverflow.Ellipsis })
+
+        // 进度条
+        Progress({ value: this.uploadProgress, total: 100, type: ProgressType.Linear })
+          .width('100%')
+          .color(this.themeColor)
+          .backgroundColor(this.isDarkMode ? '#33FFFFFF' : '#19000000')
+          .margin({ bottom: 8 })
+
+        // 进度百分比和速度信息行
+        Row() {
+          Text(`${this.uploadProgress}%`)
+            .fontSize(14)
+            .fontWeight(FontWeight.Medium)
+            .fontColor(this.themeColor)
+            .layoutWeight(1)
 
-      // 进度条
-      Progress({ value: this.uploadProgress, total: 100, type: ProgressType.Linear })
+          Text(`${this.uploadSpeed}`)
+            .fontSize(14)
+            .fontColor(this.isDarkMode ? '#66FFFFFF' : '#66000000')
+        }
         .width('100%')
-        .color(this.themeColor)
-        .backgroundColor(this.isDarkMode ? '#33FFFFFF' : '#19000000')
         .margin({ bottom: 8 })
 
-      // 进度百分比和速度信息行
-      Row() {
-        Text(`${this.uploadProgress}%`)
-          .fontSize(14)
-          .fontWeight(FontWeight.Medium)
-          .fontColor(this.themeColor)
-          .layoutWeight(1)
-
-        Text(`${this.uploadSpeed}`)
+        // 已上传数量
+        Text(`已上传: ${this.uploadedCount}/${this.totalUploadCount}`)
           .fontSize(14)
-          .fontColor(this.isDarkMode ? '#66FFFFFF' : '#66000000')
-      }
-      .width('100%')
-      .margin({ bottom: 8 })
-
-      // 已上传数量
-      Text(`已上传: ${this.uploadedCount}/${this.totalUploadCount}`)
-        .fontSize(14)
-        .fontColor(this.isDarkMode ? '#99FFFFFF' : '#99000000')
-        .margin({ bottom: 16 })
+          .fontColor(this.isDarkMode ? '#99FFFFFF' : '#99000000')
+          .margin({ bottom: 16 })
 
-      // 控制按钮
-      Row({ space: 12 }) {
-        Button(this.webdavManager.isPauseUpload ? '恢复' : '暂停')
-          .fontSize(14)
-          .height(44)
-          .layoutWeight(1)
-          .backgroundColor(this.themeColor)
-          .fontColor('#FFFFFF')
-          .borderRadius(8)
-          .onClick(() => {
-            if (this.webdavManager.isPauseUpload) {
-              this.resumeUpload();
-            } else {
-              this.pauseUpload();
-            }
-          })
+        // 控制按钮
+        Row({ space: 12 }) {
+          Button(this.webdavManager.isPauseUpload ? '恢复' : '暂停')
+            .fontSize(14)
+            .height(44)
+            .layoutWeight(1)
+            .backgroundColor(this.themeColor)
+            .fontColor('#FFFFFF')
+            .borderRadius(8)
+            .onClick(() => {
+              if (this.webdavManager.isPauseUpload) {
+                this.resumeUpload();
+              } else {
+                this.pauseUpload();
+              }
+            })
 
-        Button('取消')
-          .fontSize(14)
-          .height(44)
-          .layoutWeight(1)
-          .backgroundColor(this.isDarkMode ? '#D94838' : '#E84026')
-          .fontColor('#FFFFFF')
-          .borderRadius(8)
-          .onClick(() => {
-            this.cancelUpload();
-          })
+          Button('取消')
+            .fontSize(14)
+            .height(44)
+            .layoutWeight(1)
+            .backgroundColor(this.isDarkMode ? '#D94838' : '#E84026')
+            .fontColor('#FFFFFF')
+            .borderRadius(8)
+            .onClick(() => {
+              this.cancelUpload();
+            })
+        }
+        .width('100%')
       }
       .width('100%')
+      .padding(16)
+      .backgroundColor(this.isDarkMode ? '#23272E' : '#FFFFFF')
+      .borderRadius(12)
+      .shadow({
+        radius: this.isDarkMode ? 8 : 12,
+        color: this.isDarkMode ? '#0C000000' : '#19000000',
+        offsetX: 0,
+        offsetY: 2
+      })
+      .margin({ bottom: 16 })
     }
-    .width('100%')
-    .padding(16)
-    .backgroundColor(this.isDarkMode ? '#23272E' : '#FFFFFF')
-    .borderRadius(12)
-    .shadow({
-      radius: this.isDarkMode ? 8 : 12,
-      color: this.isDarkMode ? '#0C000000' : '#19000000',
-      offsetX: 0,
-      offsetY: 2
-    })
-    .margin({ bottom: 16 })
   }
 
   /**
@@ -1057,7 +1080,7 @@ export struct UploadMusicPage {
       .backgroundColor(this.isDarkMode ? '#23272E' : '#FFFFFF')
     }
     .width('90%')
-    .maxWidth(600)
+    .constraintSize({ maxWidth: 600 })
     .backgroundColor(this.isDarkMode ? '#191A1C' : '#FFFFFF')
     .borderRadius(16)
     .shadow({
@@ -1257,7 +1280,7 @@ export struct UploadMusicPage {
         }, (fileName: string, index: number) => `${index}-${fileName}`)
       }
       .width('100%')
-      .maxHeight(300)
+      .constraintSize({ maxHeight: 300 })
       .divider({
         strokeWidth: 1,
         color: this.isDarkMode ? '#19FFFFFF' : '#0C000000'

+ 2 - 1
entry/src/main/resources/base/profile/main_pages.json

@@ -8,6 +8,7 @@
     "pages/VipPage",
     "pages/Demo",
     "pages/PlaylistDetailPage",
-    "pages/SmbTestPage"
+    "pages/SmbTestPage",
+    "pages/UploadMusicPage"
   ]
 }