更新记录

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. 本插件是否包含广告,如包含需详细说明广告表达方式、展示频率:

暂无用户评论。