更新记录

1.0.0(2026-05-11)

  • 发布插件

平台兼容性

uni-app(5.08)

Vue2 Vue3 Chrome Safari app-vue app-nvue Android Android插件版本 iOS 鸿蒙
- - - - - - 5.0 1.0.0 - -
微信小程序 支付宝小程序 抖音小程序 百度小程序 快手小程序 京东小程序 鸿蒙元服务 QQ小程序 飞书小程序 小红书小程序 快应用-华为 快应用-联盟
- - - - - - - - - - - -

uni-app x(5.08)

Chrome Safari Android Android插件版本 iOS 鸿蒙 微信小程序
- - 5.0 1.0.0 - - -

特性

  • 🚀 支持 FTP连接
  • 📤 文件上传/下载,实时进度监听
  • 📁 目录操作(创建、删除、切换)
  • 📋 文件列表获取,包含详细信息
  • 🔄 支持主动/被动模式切换
  • 🌐 支持多种编码格式

1. 初始化

import { CreateFtpContext } from '@/uni_modules/yuange-ftp'

// 创建 FTP 上下文
const ftpContext = CreateFtpContext()

if (ftpContext == null) {
    console.error('FTP上下文初始化失败')
    return
}

2. 连接服务器

ftpContext.connect({
    host: 'ftp.example.com',      // 服务器地址
    port: 21,                     // 端口,默认21
    username: 'your_username',     // 用户名
    password: 'your_password',     // 密码
    isPassive: true,               // 是否被动模式,默认为 true
    timeout: 30000,               // 超时时间(毫秒)
    encoding: 'UTF-8'              // 编码格式,默认 UTF-8
}, {
    success: (res) => {
        console.log('连接成功:', res)
    },
    fail: (res) => {
        console.error('连接失败:', res)
    }
})

3. 获取文件列表

// 获取当前目录文件列表
ftpContext.list('', {
    success: (res) => {
        console.log('文件列表:', res.files)
    },
    fail: (res) => {
        console.error('获取失败:', res)
    }
})

// 获取指定目录文件列表
ftpContext.list('/images', {
    success: (res) => {
        console.log('文件列表:', res.files)
    },
    fail: (res) => {
        console.error('获取失败:', res)
    }
})

4. 上传文件

// 简单上传
ftpContext.uploadFile(
    '/sdcard/xxxxxx/file.txt',      // 本地文件路径
    '/remote/file.txt',             // 远程保存路径
    {
        success: (res) => {
            console.log('上传成功')
        },
        fail: (res) => {
            console.error('上传失败')
        }
    }
)

// 带进度上传
ftpContext.uploadFileWithProgress(
    '/path/to/local/file.txt',
    '/remote/file.txt',
    {
        progress: (progress) => {
            console.log('上传进度:', {
                fileName: progress.fileName,        // 文件名
                transferred: progress.transferred,   // 已传输字节
                total: progress.total,            // 文件总大小
                speed: progress.speed,            // 传输速度
                currentIndex: progress.currentIndex,  // 当前文件索引
                totalFiles: progress.totalFiles      // 总文件数
            })
        }
    },
    {
        success: (res) => {
            console.log('上传成功')
        },
        fail: (res) => {
            console.error('上传失败')
        }
    }
)

5. 下载文件

// 简单下载
ftpContext.downloadFile(
    '/remote/file.txt',             // 远程文件路径
    '/path/to/local/file.txt',      // 本地保存路径
    {
        success: (res) => {
            console.log('下载成功')
        },
        fail: (res) => {
            console.error('下载失败')
        }
    }
)

// 带进度下载
ftpContext.downloadFileWithProgress(
    '/remote/file.txt',
    '/path/to/local/file.txt',
    {
        progress: (progress) => {
            console.log('下载进度:', {
                fileName: progress.fileName,
                transferred: progress.transferred,
                total: progress.total,
                speed: progress.speed
            })
        }
    },
    {
        success: (res) => {
            console.log('下载成功')
        },
        fail: (res) => {
            console.error('下载失败')
        }
    }
)

6. 断开连接

ftpContext.close({
    success: (res) => {
        console.log('已断开连接')
    },
    fail: (res) => {
        console.error('断开失败')
    }
})

// 销毁上下文(释放资源)
ftpContext.destroy()

API 接口文档

全局函数

CreateFtpContext()

创建 FTP 上下文实例。

返回值

类型 说明
IFtpContext \| null FTP 上下文实例,失败返回 null

示例

const ftpContext = CreateFtpContext()

IFtpContext 接口

所有 FTP 操作都通过 IFtpContext 接口提供。

connect(options, callback)

连接 FTP 服务器。

参数

参数名 类型 必填 说明
options FtpConnectOptions 是 连接配置
callback FtpCallbackOptions 是 回调函数

FtpConnectOptions

属性 类型 默认值 说明
host string - FTP 服务器地址
port number 21 端口号
username string - 用户名
password string - 密码
isPassive boolean true 是否使用被动模式
timeout number 30000 连接超时时间(毫秒)
encoding string UTF-8 编码格式

示例

ftpContext.connect({
    host: 'ftp.example.com',
    port: 21,
    username: 'user',
    password: 'pass',
    isPassive: true,
    timeout: 30000,
    encoding: 'UTF-8'
}, {
    success: (res) => { /* 成功处理 */ },
    fail: (res) => { /* 失败处理 */ }
})

close(callback)

关闭 FTP 连接。

参数

参数名 类型 必填 说明
callback FtpCallbackOptions 是 回调函数

pwd(callback)

获取当前工作目录。

参数

参数名 类型 必填 说明
callback FtpCallbackOptions 是 回调函数

返回数据

{
    code: 200,
    msg: '获取当前目录成功',
    path: '/current/directory'
}

cd(path, callback)

切换工作目录。

参数

参数名 类型 必填 说明
path string 是 目录路径
callback FtpCallbackOptions 是 回调函数

cdup(callback)

返回上级目录。

参数

参数名 类型 必填 说明
callback FtpCallbackOptions 是 回调函数

list(path, callback)

获取文件列表。

参数

参数名 类型 必填 说明
path string 是 目录路径(空字符串表示当前目录)
callback FtpCallbackOptions 是 回调函数

返回数据

{
    code: 200,
    msg: '获取文件列表成功',
    files: [
        {
            name: 'example.txt',           // 文件名
            path: '/example.txt',          // 文件路径
            size: 1024,                    // 文件大小(字节)
            isDirectory: false,           // 是否目录
            lastModified: 1699999999999,   // 最后修改时间(时间戳)
            permissions: 'rw-r--r--',     // 权限
            owner: 'user',                 // 所有者
            group: 'group'                // 文件组
        },
        {
            name: 'subdir',
            path: '/subdir',
            size: 4096,
            isDirectory: true,
            lastModified: 1699999999999,
            permissions: 'rwxr-xr-x',
            owner: 'user',
            group: 'group'
        }
    ]
}

mkdir(path, callback)

创建目录。

参数

参数名 类型 必填 说明
path string 是 目录路径
callback FtpCallbackOptions 是 回调函数

rmdir(path, callback)

删除空目录。

参数

参数名 类型 必填 说明
path string 是 目录路径
callback FtpCallbackOptions 是 回调函数

delete(path, callback)

删除文件。

参数

参数名 类型 必填 说明
path string 是 文件路径
callback FtpCallbackOptions 是 回调函数

rename(oldPath, newPath, callback)

重命名文件或目录。

参数

参数名 类型 必填 说明
oldPath string 是 原路径
newPath string 是 新路径
callback FtpCallbackOptions 是 回调函数

uploadFile(localPath, remotePath, callback)

上传单个文件(无进度)。

参数

参数名 类型 必填 说明
localPath string 是 本地文件路径
remotePath string 是 远程保存路径
callback FtpCallbackOptions 是 回调函数

uploadFileWithProgress(localPath, remotePath, progressCallback, callback)

上传单个文件(带进度)。

参数

参数名 类型 必填 说明
localPath string 是 本地文件路径
remotePath string 是 远程保存路径
progressCallback FtpProgressOptions 是 进度回调
callback FtpCallbackOptions 是 完成回调

progressCallback

{
    progress: (progress) => {
        // progress 类型为 FtpProgressInfo
    }
}

uploadDirectory(localPath, remotePath, progressCallback, callback)

上传整个目录。

参数

参数名 类型 必填 说明
localPath string 是 本地目录路径
remotePath string 是 远程保存路径
progressCallback FtpProgressOptions 是 进度回调
callback FtpCallbackOptions 是 完成回调

downloadFile(remotePath, localPath, callback)

下载单个文件(无进度)。

参数

参数名 类型 必填 说明
remotePath string 是 远程文件路径
localPath string 是 本地保存路径
callback FtpCallbackOptions 是 回调函数

downloadFileWithProgress(remotePath, localPath, progressCallback, callback)

下载单个文件(带进度)。

参数

参数名 类型 必填 说明
remotePath string 是 远程文件路径
localPath string 是 本地保存路径
progressCallback FtpProgressOptions 是 进度回调
callback FtpCallbackOptions 是 完成回调

downloadDirectory(remotePath, localPath, progressCallback, callback)

下载整个目录。

参数

参数名 类型 必填 说明
remotePath string 是 远程目录路径
localPath string 是 本地保存路径
progressCallback FtpProgressOptions 是 进度回调
callback FtpCallbackOptions 是 完成回调

setTransferMode(mode, callback)

设置传输模式。

参数

参数名 类型 必填 说明
mode 'BINARY' \| 'ASCII' 是 传输模式
callback FtpCallbackOptions 是 回调函数

exists(path, callback)

检查文件或目录是否存在。

参数

参数名 类型 必填 说明
path string 是 文件或目录路径
callback FtpCallbackOptions 是 回调函数

返回数据

{
    code: 200,
    msg: '文件存在',
    exists: true
}

size(path, callback)

获取文件大小。

参数

参数名 类型 必填 说明
path string 是 文件路径
callback FtpCallbackOptions 是 回调函数

返回数据

{
    code: 200,
    msg: '获取文件大小成功',
    size: 1024
}

modifiedTime(path, callback)

获取文件最后修改时间。

参数

参数名 类型 必填 说明
path string 是 文件路径
callback FtpCallbackOptions 是 回调函数

返回数据

{
    code: 200,
    msg: '获取修改时间成功',
    time: 1699999999999
}

destroy()

销毁 FTP 上下文,释放资源。

注意:调用此方法后,上下文将不可用,需要重新调用 CreateFtpContext() 创建新实例。


类型定义

FtpProgressInfo

进度信息类型。

interface FtpProgressInfo {
    fileName: string      // 当前传输的文件名
    transferred: number    // 已传输字节数
    total: number         // 文件总大小(字节)
    currentIndex: number  // 当前传输的文件索引
    totalFiles: number   // 总文件数
    speed: number        // 传输速度(字节/秒)
}

FtpFileInfo

文件信息类型。

interface FtpFileInfo {
    name: string          // 文件名
    path: string          // 文件路径
    size: number         // 文件大小(字节)
    isDirectory: boolean // 是否目录
    lastModified: number // 最后修改时间(时间戳,毫秒)
    permissions: string  // 文件权限
    owner: string        // 所有者
    group: string         // 文件组
}

FtpCallbackOptions

回调选项类型。

type FtpCallbackOptions = {
    success: (msg: string) => void  // 成功回调
    fail: (msg: string) => void     // 失败回调
}

FtpProgressOptions

进度回调选项类型。

type FtpProgressOptions = {
    progress: (progress: FtpProgressInfo) => void
}

完整Demo示例 ftp-demo.vue

<template>
  <view class="container">
    <!-- 连接设置区域 -->
    <view class="section">
      <text class="section-title">FTP服务器设置</text>

      <view class="form-item">
        <text class="label">服务器地址</text>
        <input class="input" type="text" v-model="ftpConfig.host" placeholder="例如: 192.168.1.100" />
      </view>

      <view class="form-item">
        <text class="label">端口</text>
        <input class="input" type="number" v-model="ftpConfig.port" placeholder="21" />
      </view>

      <view class="form-item">
        <text class="label">用户名</text>
        <input class="input" type="text" v-model="ftpConfig.username" placeholder="anonymous" />
      </view>

      <view class="form-item">
        <text class="label">密码</text>
        <input class="input" type="text" v-model="ftpConfig.password" placeholder="密码" />
      </view>

      <view class="form-item">
        <text class="label">被动模式</text>
        <switch class="switch" :checked="ftpConfig.isPassive" @change="ftpConfig.isPassive = $event.detail.value" />
      </view>

      <view class="button-row">
        <button class="btn btn-primary" @click="connectFtp" :disabled="isConnected">
          {{ isConnected ? '已连接' : '连接' }}
        </button>
        <button class="btn btn-danger" @click="disconnectFtp" :disabled="!isConnected">
          断开
        </button>
      </view>
    </view>

    <!-- 文件操作区域 -->
    <view class="section" v-if="isConnected">
      <text class="section-title">文件操作</text>

      <view class="current-path">
        <text class="label">当前路径:</text>
        <text class="path">{{ currentPath }}</text>
      </view>

      <view class="form-item">
        <text class="label">获取文件列表路径</text>
        <input class="input" type="text" v-model="listPath" placeholder="留空则获取当前目录,例如: / 或 /images" />
      </view>
      <view class="button-row">
        <button class="btn btn-small" @click="refreshList">获取列表</button>
        <button class="btn btn-small" @click="goParent">上级目录</button>
      </view>

      <!-- 文件列表 -->
      <view class="file-list">
        <view v-if="isLoading" class="loading">
          <text>加载中...</text>
        </view>

        <view v-else-if="fileList.length === 0" class="empty">
          <text>目录为空</text>
        </view>

        <view v-else>
          <view
            v-for="(file, index) in fileList"
            :key="index"
            class="file-item"
            @click="onFileClick(file)"
          >
            <view class="file-icon">
              <text>{{ file.isDirectory ? '📁' : '📄' }}</text>
            </view>
            <view class="file-info">
              <text class="file-name">{{ file.name }}</text>
              <text class="file-size" v-if="!file.isDirectory">{{ formatSize(file.size) }}</text>
            </view>
            <view class="file-actions">
              <text class="action-btn" @click.stop="deleteFile(file)">删除</text>
            </view>
          </view>
        </view>
      </view>

      <!-- 新建目录 -->
      <view class="form-item">
        <text class="label">新建目录</text>
        <view class="input-row">
          <input class="input" type="text" v-model="newDirName" placeholder="目录名称" />
          <button class="btn btn-small" @click="createDir">创建</button>
        </view>
      </view>
    </view>

    <!-- 上传下载区域 -->
    <view class="section" v-if="isConnected">
      <text class="section-title">上传下载</text>

      <!-- 上传 -->
      <view class="transfer-section">
        <text class="label">上传文件</text>
        <view class="input-row">
          <input class="input" type="text" v-model="localFilePath" placeholder="本地文件路径" />
          <button class="btn btn-small btn-primary" @click="uploadFile">上传</button>
        </view>
        <view class="input-row">
          <input class="input" type="text" v-model="remoteFilePath" placeholder="远程保存路径" />
        </view>
      </view>

      <!-- 下载 -->
      <view class="transfer-section">
        <text class="label">下载文件</text>
        <view class="input-row">
          <input class="input" type="text" v-model="downloadRemotePath" placeholder="远程文件路径" />
          <button class="btn btn-small btn-primary" @click="downloadFile">下载</button>
        </view>
        <view class="input-row">
          <input class="input" type="text" v-model="downloadLocalPath" placeholder="本地保存路径" />
        </view>
      </view>

      <!-- 传输进度 -->
      <view v-if="transferProgress" class="progress-section">
        <text class="label">传输进度</text>
        <view class="progress-info">
          <text>文件: {{ transferProgress.fileName }}</text>
          <text>进度: {{ transferProgress.transferred }} / {{ transferProgress.total }} ({{ Math.floor(transferProgress.transferred / (transferProgress.total != 0 ? transferProgress.total : 1) * 100) }}%)</text>
          <text>速度: {{ formatSize(transferProgress.speed) }}/s</text>
        </view>
        <view class="progress-bar">
          <view class="progress-fill" :style="{ width: (transferProgress.transferred / (transferProgress.total != 0 ? transferProgress.total : 1) * 100) + '%' }"></view>
        </view>
      </view>
    </view>

    <!-- 日志区域 -->
    <view class="section log-section">
      <text class="section-title">操作日志</text>
      <view class="log-container">
        <view v-for="(log, index) in logs" :key="index" :class="['log-item', 'log-' + log.type]">
          <text>{{ log.time }} {{ log.message }}</text>
        </view>
      </view>
      <view class="button-row">
        <button class="btn btn-small" @click="clearLogs">清空日志</button>
      </view>
    </view>
  </view>
</template>

<script>
// 导入 FTP 接口定义
import { CreateFtpContext } from '@/uni_modules/yuange-ftp'

export default {
  data() {
    return {
      ftpContext: null,
      ftpConfig: {
        host: '192.168.1.122',
        port: 21,
        username: 'testftp',
        password: 'Ftp123456',
        isPassive: true
      },
      isConnected: false,
      isLoading: false,
      currentPath: '/',
      listPath: '',
      fileList: [],
      newDirName: '',
      localFilePath: '',
      remoteFilePath: '',
      downloadRemotePath: '',
      downloadLocalPath: '',
      transferProgress: null,
      logs: []
    }
  },

  onLoad() {
    this.addLog('info', '页面加载完成')
    this.requestPermissions()
    this.initFtpContext()
  },

  methods: {
    requestPermissions() {
      // #ifdef APP-PLUS
      const permissions = [
        'android.permission.READ_EXTERNAL_STORAGE',
        'android.permission.WRITE_EXTERNAL_STORAGE'
      ]
      plus.android.requestPermissions(permissions, (result) => {
        console.log('权限申请结果:', result)
        if (result.granted.length > 0) {
          this.addLog('success', '文件读写权限已授权')
        } else {
          this.addLog('warning', '部分权限未授权,可能影响文件操作')
        }
      }, (error) => {
        console.error('权限申请失败:', error)
        this.addLog('error', '权限申请失败')
      })
      // #endif
    },

    onUnload() {
      if (this.ftpContext != null) {
        this.ftpContext.destroy()
        this.ftpContext = null
      }
    },

    formatSize(bytes) {
      if (bytes === 0) return '0 B'
      const units = ['B', 'KB', 'MB', 'GB', 'TB']
      const k = 1024
      const i = Math.floor(Math.log(bytes) / Math.log(k))
      return (bytes / Math.pow(k, i)).toFixed(2) + ' ' + units[i]
    },

    addLog(type, message) {
      const now = new Date()
      const time = `${now.getHours().toString().padStart(2, '0')}:${now.getMinutes().toString().padStart(2, '0')}:${now.getSeconds().toString().padStart(2, '0')}`
      const item = {
        type: type,
        time: time,
        message: message
      }
      this.logs.unshift(item)
      if (this.logs.length > 100) {
        this.logs.pop()
      }
    },

    clearLogs() {
      this.logs = []
    },

    getMsg(res) {
      if (res && res.msg) {
        return res.msg
      }
      if (res && res.getString) {
        return res.getString('msg') || '未知错误'
      }
      return '未知错误'
    },

    initFtpContext() {
      try {
        console.log('正在初始化FTP上下文...')
        // 通过 import 导入的 CreateFtpContext 创建上下文
        const ctx = CreateFtpContext()
        if (ctx == null) {
          this.addLog('error', 'FTP上下文初始化失败')
          return
        }
        this.ftpContext = ctx
        this.addLog('success', 'FTP插件已就绪')
      } catch (e) {
        console.error('FTP初始化异常:', e)
        this.addLog('error', `FTP初始化异常: ${e.message || e}`)
      }
    },

    connectFtp() {
      if (this.ftpContext == null) {
        this.addLog('error', 'FTP上下文未初始化')
        return
      }
      this.addLog('info', `正在连接 ${this.ftpConfig.host}:${this.ftpConfig.port}...`)
      this.ftpContext.connect({
        host: this.ftpConfig.host,
        port: parseInt(this.ftpConfig.port.toString()),
        username: this.ftpConfig.username,
        password: this.ftpConfig.password,
        isPassive: this.ftpConfig.isPassive,
        timeout: 30000,
        encoding: 'UTF-8'
      }, {
        success: (res) => {
          this.addLog('success', '连接成功')
          this.isConnected = true
          this.refreshList()
        },
        fail: (res) => {
          this.addLog('error', `连接失败: ${this.getMsg(res)}`)
        }
      })
    },

    disconnectFtp() {
      if (this.ftpContext == null) {
        return
      }
      this.ftpContext.close({
        success: (res) => {
          this.addLog('info', '已断开连接')
          this.isConnected = false
          this.fileList = []
          this.currentPath = '/'
        },
        fail: (res) => {
          this.addLog('error', `断开失败: ${this.getMsg(res)}`)
        }
      })
    },

    refreshList() {
      if (this.ftpContext == null) {
        return
      }
      this.isLoading = true
      const targetPath = this.listPath.trim() !== '' ? this.listPath.trim() : this.currentPath
      this.addLog('info', `正在获取文件列表: ${targetPath}`)
      this.ftpContext.list(targetPath, {
        success: (res) => {
          this.isLoading = false
          try {
            // res 可能是 JSON 字符串,需要解析
            let data = res;
            if (typeof res === 'string') {
              data = JSON.parse(res);
            }
            let files = data.files;
            if (files) {
              const newList = []
              for (let i = 0; i < files.length; i++) {
                const f = files[i]
                newList.push({
                  name: f.name || '',
                  path: f.path || '',
                  size: f.size || 0,
                  isDirectory: f.isDirectory || false,
                  lastModified: f.lastModified || 0,
                  permissions: f.permissions || '',
                  owner: f.owner || '',
                  group: f.group || ''
                })
              }
              this.fileList = newList
            }
            this.addLog('info', `获取文件列表成功,共 ${this.fileList.length} 项`)
          } catch (e) {
            this.addLog('error', `解析文件列表失败: ${e.message || e}`)
          }
        },
        fail: (res) => {
          this.isLoading = false
          this.addLog('error', `获取文件列表失败: ${this.getMsg(res)}`)
        }
      })
    },

    goParent() {
      if (this.ftpContext == null) {
        return
      }
      this.ftpContext.cdup({
        success: (res) => {
          this.addLog('info', '已进入上级目录')
          this.refreshList()
          this.ftpContext.pwd({
            success: (pwdRes) => {
              this.currentPath = pwdRes.path || '/'
            },
            fail: () => {}
          })
        },
        fail: (res) => {
          this.addLog('error', `进入上级目录失败: ${this.getMsg(res)}`)
        }
      })
    },

    onFileClick(file) {
      if (file.isDirectory) {
        if (this.ftpContext != null) {
          this.ftpContext.cd(file.name, {
            success: (res) => {
              this.currentPath = file.path
              this.addLog('info', `进入目录: ${file.name}`)
              this.refreshList()
            },
            fail: (res) => {
              this.addLog('error', `进入目录失败: ${this.getMsg(res)}`)
            }
          })
        }
      } else {
        uni.showModal({
          title: '文件操作',
          content: `文件名: ${file.name}\n大小: ${this.formatSize(file.size)}`,
          confirmText: '下载',
          cancelText: '取消',
          success: (res) => {
            if (res.confirm) {
              this.downloadRemotePath = file.path
              this.downloadLocalPath = `/sdcard/download/${file.name}`
              this.downloadFile()
            }
          }
        })
      }
    },

    createDir() {
      if (this.ftpContext == null || this.newDirName.trim() === '') {
        return
      }
      this.ftpContext.mkdir(this.newDirName.trim(), {
        success: (res) => {
          this.addLog('success', `目录已创建: ${this.newDirName}`)
          this.newDirName = ''
          this.refreshList()
        },
        fail: (res) => {
          this.addLog('error', `创建目录失败: ${this.getMsg(res)}`)
        }
      })
    },

    deleteFile(file) {
      uni.showModal({
        title: '确认删除',
        content: `确定要删除 ${file.isDirectory ? '目录' : '文件'}: ${file.name} 吗?`,
        confirmText: '删除',
        cancelText: '取消',
        success: (res) => {
          if (res.confirm) {
            if (this.ftpContext == null) {
              return
            }
            if (file.isDirectory) {
              this.ftpContext.rmdir(file.path, {
                success: (res) => {
                  this.addLog('success', `目录已删除: ${file.name}`)
                  this.refreshList()
                },
                fail: (res) => {
                  this.addLog('error', `删除目录失败: ${this.getMsg(res)}`)
                }
              })
            } else {
              this.ftpContext.delete(file.path, {
                success: (res) => {
                  this.addLog('success', `文件已删除: ${file.name}`)
                  this.refreshList()
                },
                fail: (res) => {
                  this.addLog('error', `删除文件失败: ${this.getMsg(res)}`)
                }
              })
            }
          }
        }
      })
    },

    uploadFile() {
      if (this.ftpContext == null || this.localFilePath.trim() === '') {
        this.addLog('error', '请输入本地文件路径')
        return
      }
      const remotePath = this.remoteFilePath.trim() !== '' ? this.remoteFilePath.trim() : this.currentPath + '/' + this.localFilePath.split('/').pop()
      this.addLog('info', `开始上传: ${this.localFilePath} -> ${remotePath}`)
      this.transferProgress = null
      this.ftpContext.uploadFileWithProgress(this.localFilePath.trim(), remotePath, {
        progress: (progress) => {
          this.transferProgress = progress
        }
      }, {
        success: (res) => {
          this.addLog('success', '上传成功')
          this.transferProgress = null
          this.refreshList()
        },
        fail: (res) => {
          this.addLog('error', `上传失败: ${this.getMsg(res)}`)
          this.transferProgress = null
        }
      })
    },

    downloadFile() {
      if (this.ftpContext == null || this.downloadRemotePath.trim() === '') {
        this.addLog('error', '请输入远程文件路径')
        return
      }
      if (this.downloadLocalPath.trim() === '') {
        this.addLog('error', '请输入本地保存路径')
        return
      }
      this.addLog('info', `开始下载: ${this.downloadRemotePath} -> ${this.downloadLocalPath}`)
      this.transferProgress = null
      this.ftpContext.downloadFileWithProgress(this.downloadRemotePath.trim(), this.downloadLocalPath.trim(), {
        progress: (progress) => {
          this.transferProgress = progress
        }
      }, {
        success: (res) => {
          // 下载成功后检查文件是否存在
          this.checkFileExists(this.downloadLocalPath.trim())
          this.transferProgress = null
        },
        fail: (res) => {
          this.addLog('error', `下载失败: ${this.getMsg(res)}`)
          this.transferProgress = null
        }
      })
    },

    checkFileExists(filePath) {
      // #ifdef APP-PLUS
      const main = plus.android.runtimeMainActivity()
      const Intent = plus.android.importClass('android.content.Intent')
      const Uri = plus.android.importClass('android.net.Uri')
      const File = plus.android.importClass('java.io.File')

      try {
        const file = new File(filePath)
        if (file.exists()) {
          const size = file.length()
          this.addLog('success', `文件下载成功!大小: ${this.formatSize(size)}, 路径: ${filePath}`)

          // 尝试通过 MediaStore 刷新(Android 10+)
          const context = plus.android.runtimeMainActivity()
          const applicationContext = context.getApplicationContext()

          // 通知系统刷新媒体库
          const mediaScanIntent = new Intent(Intent.ACTION_MEDIA_SCANNER_SCAN_FILE)
          const contentUri = Uri.fromFile(file)
          mediaScanIntent.setData(contentUri)
          applicationContext.sendBroadcast(mediaScanIntent)

          this.addLog('info', '已通知系统刷新文件')
        } else {
          this.addLog('error', `文件不存在: ${filePath}`)
        }
      } catch (e) {
        this.addLog('warning', `检查文件失败: ${e.message || e}`)
        this.addLog('success', `下载回调成功,请手动检查路径: ${filePath}`)
      }
      // #endif
      // #ifndef APP-PLUS
      this.addLog('success', '下载成功(非App环境)')
      // #endif
    }
  }
}
</script>

<style lang="scss">
.container {
  padding: 20rpx;
  background-color: #f5f5f5;
  min-height: 100vh;
}

.section {
  background-color: #ffffff;
  border-radius: 16rpx;
  padding: 24rpx;
  margin-bottom: 20rpx;
  box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.05);
}

.section-title {
  font-size: 32rpx;
  font-weight: bold;
  color: #333333;
  margin-bottom: 24rpx;
  display: block;
}

.form-item {
  margin-bottom: 20rpx;
}

.label {
  font-size: 28rpx;
  color: #666666;
  margin-bottom: 8rpx;
  display: block;
}

.input {
  width: 100%;
  height: 72rpx;
  border: 1px solid #e0e0e0;
  border-radius: 8rpx;
  padding: 0 20rpx;
  font-size: 28rpx;
  background-color: #fafafa;
}

.input-row {
  display: flex;
  flex-direction: row;
  align-items: center;
  gap: 16rpx;
}

.input-row .input {
  flex: 1;
}

.switch {
  transform: scale(0.8);
}

.button-row {
  display: flex;
  flex-direction: row;
  gap: 16rpx;
  margin: 16rpx 0;
}

.btn, .btn-primary, .btn-danger, .btn-small {
  margin: 0;
  padding: 0;
  border-radius: 8rpx;
  font-size: 28rpx;
  border: none;
  background-color: #007aff;
  color: #ffffff;
}

.btn, .btn-primary, .btn-danger {
  flex: 1;
  height: 80rpx;
  line-height: 80rpx;
  text-align: center;
}

.btn:disabled, .btn-primary:disabled, .btn-danger:disabled {
  background-color: #cccccc;
  color: #ffffff;
}

.btn-primary {
  background-color: #007aff;
}

.btn-danger {
  background-color: #ff3b30;
}

.btn-small {
  flex: 0 0 auto;
  min-width: 120rpx;
  height: 64rpx;
  line-height: 64rpx;
  font-size: 26rpx;
  padding: 0 24rpx;
  white-space: nowrap;
}

.current-path {
  display: flex;
  flex-direction: row;
  align-items: center;
  margin-bottom: 16rpx;
  padding: 16rpx;
  background-color: #f0f0f0;
  border-radius: 8rpx;
}

.current-path .path {
  color: #007aff;
  font-size: 28rpx;
  margin-left: 8rpx;
  word-break: break-all;
}

.file-list {
  margin: 16rpx 0;
  max-height: 500rpx;
  overflow-y: auto;
}

.loading, .empty {
  text-align: center;
  padding: 40rpx;
  color: #999999;
}

.file-item {
  display: flex;
  flex-direction: row;
  align-items: center;
  padding: 20rpx;
  border-bottom: 1px solid #f0f0f0;
}

.file-item:active {
  background-color: #f5f5f5;
}

.file-icon {
  width: 60rpx;
  font-size: 36rpx;
}

.file-info {
  flex: 1;
  margin-left: 16rpx;
}

.file-name {
  font-size: 28rpx;
  color: #333333;
  display: block;
}

.file-size {
  font-size: 24rpx;
  color: #999999;
  margin-top: 4rpx;
  display: block;
}

.file-actions {
  width: 100rpx;
  text-align: right;
}

.action-btn {
  color: #ff3b30;
  font-size: 26rpx;
}

.transfer-section {
  margin: 20rpx 0;
  padding: 20rpx;
  background-color: #fafafa;
  border-radius: 8rpx;
}

.progress-section {
  margin-top: 20rpx;
  padding: 20rpx;
  background-color: #f0f9ff;
  border-radius: 8rpx;
}

.progress-info {
  margin: 12rpx 0;
}

.progress-info text {
  display: block;
  font-size: 24rpx;
  color: #666666;
  line-height: 1.6;
}

.progress-bar {
  height: 16rpx;
  background-color: #e0e0e0;
  border-radius: 8rpx;
  overflow: hidden;
}

.progress-fill {
  height: 100%;
  background-color: #007aff;
  border-radius: 8rpx;
  transition: width 0.3s ease;
}

.log-section {
  max-height: 400rpx;
}

.log-container {
  max-height: 280rpx;
  overflow-y: auto;
  background-color: #1e1e1e;
  border-radius: 8rpx;
  padding: 16rpx;
}

.log-item {
  font-size: 22rpx;
  font-family: 'Courier New', monospace;
  line-height: 1.6;
  margin-bottom: 4rpx;
}

.log-info {
  color: #4fc3f7;
}

.log-success {
  color: #81c784;
}

.log-error {
  color: #e57373;
}

.log-warning {
  color: #fff176;
}
</style>

隐私、权限声明

1. 本插件需要申请的系统权限列表:

android.permission.READ_EXTERNAL_STORAGE android.permission.WRITE_EXTERNAL_STORAGE android.permission.MANAGE_EXTERNAL_STORAGE

2. 本插件采集的数据、发送的服务器地址、以及数据用途说明:

插件不采集任何数据

3. 本插件是否包含广告,如包含需详细说明广告表达方式、展示频率:

无

暂无用户评论。