更新记录

1.0.0(2026-06-27)

插件发布


平台兼容性

uni-app(3.7.13)

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

uni-app x(3.7.13)

Chrome Safari Android iOS 鸿蒙 微信小程序
× × 5.0 × × ×

zy-ftpclient

FTP 客户端 UTS 插件,适用于 uni-app Android 平台

目录


导入

import {
  connect,
  disconnect,
  listFiles,
  makeDirectory,
  uploadFile,
  downloadFile,
  deleteFile,
  release,
  checkAllFilesAccessPermission
} from '@/uni_modules/zy-ftpclient'

回调约定

所有 API 均遵循统一的异步回调模式:

回调 参数类型 说明
success (res: FtpResult) => void 操作成功时触发
fail (res: FtpResult) => void 操作失败时触发
complete (res: FtpResult) => void 操作完成时始终触发

注意listFilessuccess 回调参数为 ListResult,其余为 FtpResult


API

connect(options)

连接 FTP 服务器。自动断开已有连接,控制编码固定为 GBK,使用被动模式(PASV)。

参数 类型 必填 说明
host string 服务器地址
port number 端口号
username string 用户名
password string 密码
success (res: FtpResult) => void 连接成功回调
fail (res: FtpResult) => void 连接失败回调
complete (res: FtpResult) => void 完成回调(始终触发)
connect({
  host: '192.168.1.100',
  port: 21,
  username: 'admin',
  password: '123456',
  success: (res) => {
    console.log('连接成功', res.message)
  },
  fail: (err) => {
    console.error('连接失败', err.message)
  }
})

disconnect(options)

断开 FTP 连接。内部执行 logout + disconnect

参数 类型 必填 说明
success (res: FtpResult) => void 断开成功回调
fail (res: FtpResult) => void 断开失败回调
complete (res: FtpResult) => void 完成回调
disconnect({
  success: () => {
    console.log('已断开连接')
  }
})

listFiles(options)

获取远程目录文件列表。路径自动补全 / 前缀和 / 后缀,返回结果已过滤 ... 条目。

参数 类型 必填 说明
path string 远程目录路径,如 "/""/downloads"
success (res: ListResult) => void 成功回调
fail (res: FtpResult) => void 失败回调
complete (res: FtpResult) => void 完成回调

ListResultsuccess 回调参数):

字段 类型 说明
success boolean 操作是否成功
message string 结果描述
directory string 规范化后的目录路径
files FileItem[] 文件列表

FileItem

字段 类型 说明
name string 文件名
size number 文件大小(字节)
formatSize string 格式化后的文件大小(如 "1.5 MB"
directory boolean 是否为目录
timestamp number 修改时间(毫秒时间戳)
formatTimestamp string 格式化后的修改时间(如 "2024-01-15 14:30:00"
listFiles({
  path: '/downloads',
  success: (res) => {
    res.files.forEach(item => {
      if (item.directory) {
        console.log('[目录]', item.name)
      } else {
        console.log('[文件]', item.name, item.size)
      }
    })
  },
  fail: (err) => {
    console.error('获取列表失败', err.message)
  }
})

makeDirectory(options)

在远程服务器上创建目录。路径自动去除首尾 /,按相对路径逐级创建。

路径规范化规则:去除所有前导 / 和尾部 /(除非路径仅为根目录),以相对路径方式逐级 makeDirectory

参数 类型 必填 说明
path string 目录路径
success (res: FtpResult) => void 成功回调
fail (res: FtpResult) => void 失败回调
complete (res: FtpResult) => void 完成回调
// 创建多级目录:自动逐级创建 a → a/b → a/b/c
makeDirectory({
  path: '/a/b/c',
  success: () => {
    console.log('目录创建成功')
  },
  fail: (err) => {
    console.error('创建失败', err.message)
  }
})

uploadFile(options)

上传文件目录到 FTP 服务器。

  • localPath 为本地目录路径时自动递归上传目录
  • remotePath/ 结尾表示上传到目录中(自动追加本地文件名),否则作为完整目标路径
参数 类型 必填 说明
localPath string 本地文件或目录路径
remotePath string 远程目标路径
fileName string 自定义远程文件名(仅单文件有效,目录上传忽略)
onProgress (res: ProgressResult) => void 进度回调(每 1000ms 触发一次)
success (res: FtpResult) => void 成功回调
fail (res: FtpResult) => void 失败回调
complete (res: FtpResult) => void 完成回调

ProgressResultonProgress 回调参数):

字段 类型 说明
transferred number 已传输字节数
total number 总字节数
percentage number 百分比(0-100)
currentFile string 当前传输的文件名
currentFilePath string 当前传输的文件完整路径

上传单个文件

uploadFile({
  localPath: '/storage/emulated/0/photo.jpg',
  remotePath: '/uploads/',       // 上传到 /uploads/photo.jpg
  fileName: 'photo.jpg',         // 可选,覆盖文件名
  : (p) => {
    console.log('上传进度', p.percentage + '%')
  },
  success: (res) => {
    console.log('上传成功', res.remotePath)
  },
  fail: (err) => {
    console.error('上传失败', err.message)
  }
})

上传目录(递归)

uploadFile({
  localPath: '/storage/emulated/0/myfolder',   // 本地目录
  remotePath: '/backup/',                       // 远程父目录
  : (p) => {
    console.log(`正在上传 ${p.currentFile}: ${p.percentage}%`)
  },
  success: (res) => {
    console.log(`目录上传完成,成功 ${res.successCount}/${res.totalFiles}`)
  },
  fail: (err) => {
    console.error('上传失败', err.message)
  }
})

上传目录时,本地文件夹名作为远程子目录创建(/backup/myfolder/),其下保持完整目录结构。完成时 success 返回 totalFilessuccessCountfailCount


downloadFile(options)

从 FTP 服务器下载文件目录remotePath/ 结尾时自动触发目录递归下载。

参数 类型 必填 说明
remotePath string 远程路径(以 / 结尾 = 目录下载)
localPath string 本地保存路径
onProgress (res: ProgressResult) => void 进度回调(每 1000ms 触发一次)
success (res: FtpResult) => void 成功回调
fail (res: FtpResult) => void 失败回调
complete (res: FtpResult) => void 完成回调

下载单个文件

downloadFile({
  remotePath: '/files/document.pdf',
  localPath: '/storage/emulated/0/Download/document.pdf',
  : (p) => {
    console.log('下载进度', p.percentage + '%')
  },
  success: (res) => {
    console.log('下载成功', res.localPath)
  },
  fail: (err) => {
    console.error('下载失败', err.message)
  }
})

下载目录(递归)

downloadFile({
  remotePath: '/backup/2024/',   // 以 / 结尾 = 目录下载
  localPath: '/storage/emulated/0/Download/backup',
  : (p) => {
    console.log(`正在下载 ${p.currentFile}: ${p.percentage}%`)
  },
  success: (res) => {
    console.log(`目录下载完成,成功 ${res.successCount}/${res.totalFiles}`)
  },
  fail: (err) => {
    console.error('下载失败', err.message)
  }
})

下载目录时递归下载所有子文件和子目录,本地保留相同目录结构。完成时 success 返回 totalFiles(= successCount + failCount)、successCountfailCountlocalPath


deleteFile(options)

删除远程文件目录(递归删除目录下所有内容)。

参数 类型 必填 说明
path string 远程路径(自动补全 / 前缀)
success (res: FtpResult) => void 成功回调
fail (res: FtpResult) => void 失败回调
complete (res: FtpResult) => void 完成回调
deleteFile({
  path: '/old_folder',
  success: () => {
    console.log('删除成功')
  },
  fail: (err) => {
    console.error('删除失败', err.message)
  }
})

release(options)

释放 FTP 资源,断开所有连接并清空客户端实例。建议在页面 onUnload 或应用退出时调用。

参数 类型 必填 说明
success (res: FtpResult) => void 成功回调
fail (res: FtpResult) => void 失败回调
complete (res: FtpResult) => void 完成回调
release({
  success: () => {
    console.log('FTP 资源已释放')
  }
})

checkAllFilesAccessPermission(options)

检查 Android "所有文件访问权限"(MANAGE_EXTERNAL_STORAGE),无权限时自动跳转到系统设置页面。

  • Android 11+(API 30)需要此权限以访问外部存储文件
  • Android 10 及以下版本无需此权限,直接返回 hasPermission: true
参数 类型 必填 说明
success (res: PermissionResult) => void 成功回调
fail (res: PermissionResult) => void 失败回调
complete (res: PermissionResult) => void 完成回调

PermissionResult(所有回调参数):

字段 类型 说明
success boolean 操作是否成功
hasPermission boolean 是否已拥有权限
message string? 结果描述(可选)
checkAllFilesAccessPermission({
  success: (res) => {
    if (res.hasPermission) {
      console.log('已拥有所有文件访问权限')
    } else {
      console.log(res.message)
    }
  },
  fail: (err) => {
    console.error('权限检查失败', err.message)
  }
})

示例代码

以下是一个完整的 FTP 使用示例,涵盖连接、文件列表、创建目录、上传、下载和断开等操作。

<template>
    <view class="page">
        <view class="header">
            <view class="page-title">FTP 客户端</view>
            <view class="page-subtitle">{{ connected ? '已连接 ' + host : '请输入连接信息' }}</view>
        </view>

        <!-- 连接面板 -->
        <view class="section" v-if="!connected">
            <view class="section-title">服务器连接</view>
            <view class="form-item">
                <text class="form-label">主机地址</text>
                <input class="form-input" v-model="host" placeholder="例如 192.168.1.216" />
            </view>
            <view class="form-item">
                <text class="form-label">端口</text>
                <input class="form-input" v-model="port" placeholder="21" type="number" />
            </view>
            <view class="form-item">
                <text class="form-label">用户名</text>
                <input class="form-input" v-model="username" placeholder="anonymous" />
            </view>
            <view class="form-item">
                <text class="form-label">密码</text>
                <input class="form-input" v-model="password" placeholder="密码" type="password" />
            </view>
            <button class="btn btn-primary" :disabled="loading" @click="doConnect">{{ loading ? '连接中...' : '连 接' }}</button>
            <button class="btn btn-outline btn-permission" :disabled="checkingPermission" @click="doCheckPermission">
                {{ checkingPermission ? '检查中...' : '文件权限检查' }}
            </button>
        </view>

        <!-- 已连接面板 -->
        <view v-else>
            <!-- 状态栏 -->
            <view class="status-bar">
                <view class="status-info">
                    <text class="status-dot"></text>
                    <text class="status-text">{{ host }}:{{ port }}</text>
                </view>
                <button class="btn btn-sm btn-outline" @click="doDisconnect">断开</button>
            </view>

            <!-- 路径导航 -->
            <view class="path-bar">
                <text class="path-segment" @click="navigateTo('/')">/</text>
                <text class="path-sep" v-for="(seg, i) in pathSegments" :key="i">
                    <text class="path-arrow">›</text>
                    <text class="path-segment" @click="navigateTo(seg.path)">{{ seg.name }}</text>
                </text>
            </view>

            <!-- 操作按钮 -->
            <view class="action-bar">
                <button class="btn btn-sm btn-primary" @click="showUpload = true">上传</button>
                <button class="btn btn-sm btn-outline" @click="showMkdir = true">新建目录</button>
                <button class="btn btn-sm btn-outline" @click="refreshList">刷新</button>
                <button class="btn btn-sm btn-outline" @click="goUp" :disabled="currentPath === '/'">上一级</button>
            </view>

            <!-- 新建目录输入 -->
            <view class="section" v-if="showMkdir">
                <view class="row">
                    <input class="form-input flex-1" v-model="newDirName" placeholder="目录名称" />
                    <button class="btn btn-sm btn-primary" @click="doMkdir">确定</button>
                    <button class="btn btn-sm btn-outline" @click="showMkdir = false; newDirName = ''">取消</button>
                </view>
            </view>

            <!-- 上传输入 -->
            <view class="section" v-if="showUpload">
                <view class="section-title">上传设置</view>
                <view class="form-item">
                    <text class="form-label">本地路径</text>
                    <input class="form-input" v-model="uploadLocalPath" placeholder="/storage/emulated/0/..." />
                </view>
                <view class="form-item">
                    <text class="form-label">远程文件名(可选)</text>
                    <input class="form-input" v-model="uploadFileName" placeholder="留空则使用原名" />
                </view>
                <view class="row" style="margin-bottom: 20rpx;">
                    <text class="form-label" style="margin-bottom: 0;">上传类型:</text>
                    <button class="btn btn-sm" :class="uploadMode === 'file' ? 'btn-primary' : 'btn-outline'" @click="uploadMode = 'file'" style="width: auto; margin: 0;">文件</button>
                    <button class="btn btn-sm" :class="uploadMode === 'dir' ? 'btn-primary' : 'btn-outline'" @click="uploadMode = 'dir'" style="width: auto; margin: 0;">目录</button>
                </view>
                <view class="row">
                    <button class="btn btn-sm btn-primary flex-1" @click="startUpload" :disabled="loading">开始上传</button>
                    <button class="btn btn-sm btn-outline" @click="showUpload = false; uploadLocalPath = ''; uploadFileName = ''">取消</button>
                </view>
            </view>

            <!-- 文件列表 -->
            <view class="file-list">
                <view class="file-item header-row">
                    <text class="file-icon"></text>
                    <text class="file-name">名称</text>
                    <text class="file-size">大小</text>
                    <text class="file-date">修改时间</text>
                    <text class="file-actions">操作</text>
                </view>
                <view class="file-item" v-for="item in files" :key="item.name"
                    @click="onFileClick(item)">
                    <text class="file-icon">{{ item.directory ? '📁' : '📄' }}</text>
                    <text class="file-name">{{ item.name }}</text>
                    <text class="file-size">{{ item.directory ? '-' : formatSize(item.size) }}</text>
                    <text class="file-date">{{ formatTime(item.timestamp) }}</text>
                    <view class="file-actions">
                        <text class="action-link" @click.stop="doDownload(item)">下载</text>
                        <text class="action-link danger" @click.stop="doDelete(item)">删除</text>
                    </view>
                </view>
                <view class="empty-state" v-if="files.length === 0 && !loading">
                    <text>目录为空</text>
                </view>
            </view>

            <!-- 进度条 -->
            <view class="progress-panel" v-if="progress.visible">
                <text class="progress-label">{{ progress.currentFile }}</text>
                <view class="progress-track">
                    <view class="progress-fill" :style="{ width: progress.percentage + '%' }"></view>
                </view>
                <text class="progress-pct">{{ progress.percentage }}%</text>
            </view>
        </view>

        <!-- 状态消息 -->
        <view class="toast" v-if="statusMsg" :class="'toast-' + statusType">
            <text>{{ statusMsg }}</text>
        </view>
    </view>
</template>

<script>
    import {
        connect,
        disconnect,
        listFiles,
        makeDirectory,
        uploadFile,
        downloadFile,
        deleteFile,
        checkAllFilesAccessPermission
    } from '@/uni_modules/zy-ftpclient'

    export default {
        data() {
            return {
                host: '192.168.1.216',
                port: '21',
                username: 'admin',
                password: '123456',
                connected: false,
                checkingPermission: false,
                currentPath: '/',
                files: [],
                loading: false,
                showMkdir: false,
                newDirName: '',
                showUpload: false,
                uploadLocalPath: '',
                uploadFileName: '',
                uploadMode: 'file',
                progress: {
                    visible: false,
                    percentage: 0,
                    currentFile: ''
                },
                statusMsg: '',
                statusType: 'info',
                statusTimer: null,
                lastRefresh: 0
            }
        },

        computed: {
            pathSegments() {
                if (this.currentPath === '/') return []
                const parts = this.currentPath.split('/').filter(Boolean)
                let accumulated = ''
                return parts.map(name => {
                    accumulated += '/' + name
                    return { name, path: accumulated }
                })
            }
        },

        methods: {
            showStatus(msg, type) {
                if (this.statusTimer) clearTimeout(this.statusTimer)
                this.statusMsg = msg
                this.statusType = type || 'info'
                this.statusTimer = setTimeout(() => {
                    this.statusMsg = ''
                }, 3000)
            },

            doConnect() {
                if (!this.host || !this.port) {
                    this.showStatus('请输入主机地址和端口', 'error')
                    return
                }
                this.loading = true
                this.showStatus('正在连接...', 'info')
                connect({
                    host: this.host,
                    port: parseInt(this.port),
                    username: this.username || 'anonymous',
                    password: this.password,
                    success: (res) => {
                        console.log('[FTP] connect success', res)
                        this.connected = true
                        this.loading = false
                        this.showStatus('连接成功', 'success')
                        this.refreshList()
                    },
                    fail: (err) => {
                        console.log('[FTP] connect fail', err)
                        this.loading = false
                        this.showStatus('连接失败: ' + (err.errMsg || '未知错误'), 'error')
                    },
                    complete: (res) => {
                        console.log('[FTP] connect complete', res)
                        this.loading = false
                    }
                })
            },

            doCheckPermission() {
                this.checkingPermission = true
                checkAllFilesAccessPermission({
                    success: (res) => {
                        if (res.hasPermission) {
                            this.showStatus('已拥有所有文件访问权限', 'success')
                        } else {
                            this.showStatus(res.message || '请在设置中授予权限', 'info')
                        }
                    },
                    fail: (err) => {
                        this.showStatus('权限检查失败: ' + (err.message || '未知错误'), 'error')
                    },
                    complete: () => {
                        this.checkingPermission = false
                    }
                })
            },

            doDisconnect() {
                disconnect({
                    success: (res) => {
                        console.log('[FTP] disconnect success', res)
                        this.connected = false
                        this.files = []
                        this.currentPath = '/'
                        this.showStatus('已断开连接', 'info')
                    },
                    fail: (err) => {
                        console.log('[FTP] disconnect fail', err)
                        this.showStatus('断开失败: ' + (err.errMsg || '未知错误'), 'error')
                    },
                    complete: (res) => {
                        console.log('[FTP] disconnect complete', res)
                    }
                })
            },

            refreshList() {
                this.loading = true
                listFiles({
                    path: this.currentPath,
                    success: (res) => {
                        console.log('[FTP] listFiles success', res)
                        this.files = res.files || []
                        this.lastRefresh = Date.now()
                    },
                    fail: (err) => {
                        console.log('[FTP] listFiles fail', err)
                        this.showStatus('获取文件列表失败: ' + (err.errMsg || '未知错误'), 'error')
                    },
                    complete: (res) => {
                        console.log('[FTP] listFiles complete', res)
                        this.loading = false
                    }
                })
            },

            navigateTo(path) {
                this.currentPath = path
                this.refreshList()
            },

            goUp() {
                if (this.currentPath === '/') return
                const parent = this.currentPath.substring(0, this.currentPath.lastIndexOf('/'))
                this.currentPath = parent || '/'
                this.refreshList()
            },

            onFileClick(item) {
                if (item.directory) {
                    let path = this.currentPath
                    if (!path.endsWith('/')) path += '/'
                    this.navigateTo(path + item.name)
                }
            },

            doMkdir() {
                if (!this.newDirName) {
                    this.showStatus('请输入目录名称', 'error')
                    return
                }
                let path = this.currentPath
                if (!path.endsWith('/')) path += '/'
                makeDirectory({
                    path: path + this.newDirName,
                    success: (res) => {
                        console.log('[FTP] makeDirectory success', res)
                        this.showStatus('目录创建成功', 'success')
                        this.showMkdir = false
                        this.newDirName = ''
                        this.refreshList()
                    },
                    fail: (err) => {
                        console.log('[FTP] makeDirectory fail', err)
                        this.showStatus('创建目录失败: ' + (err.errMsg || '未知错误'), 'error')
                    },
                    complete: (res) => {
                        console.log('[FTP] makeDirectory complete', res)
                    }
                })
            },

            startUpload() {
                if (!this.uploadLocalPath) {
                    this.showStatus('请输入本地路径', 'error')
                    return
                }
                const fileName = this.uploadFileName || ''
                const localPath = this.uploadLocalPath
                console.log('[FTP] upload localPath:', localPath)
                console.log('[FTP] upload fileName:', fileName || '(使用原名)')
                this.progress.visible = true
                this.progress.percentage = 0
                this.progress.currentFile = '正在上传: ' + (fileName || localPath.split('/').pop())

                let remotePath = this.currentPath
                if (!remotePath.endsWith('/')) remotePath += '/'

                const uploadOpts = {
                    localPath: localPath,
                    remotePath: remotePath,
                    : (p) => {
                        console.log('[FTP] uploadFile progress', p)
                        this.progress.percentage = p.percentage
                        this.progress.currentFile = '上传中: ' + p.currentFile
                    },
                    success: (res) => {
                        console.log('[FTP] uploadFile success', res)
                        this.showStatus('上传成功: ' + (fileName || localPath.split('/').pop()), 'success')
                        this.showUpload = false
                        this.uploadLocalPath = ''
                        this.uploadFileName = ''
                        this.refreshList()
                    },
                    fail: (err) => {
                        console.log('[FTP] uploadFile fail', err)
                        this.showStatus('上传失败: ' + (err.errMsg || '未知错误'), 'error')
                    },
                    complete: (res) => {
                        console.log('[FTP] uploadFile complete', res)
                        setTimeout(() => {
                            this.progress.visible = false
                        }, 1000)
                    }
                }
                if (fileName) {
                    uploadOpts.fileName = fileName
                }
                uploadFile(uploadOpts)
            },

            doDownload(item) {
                const isDir = item.directory
                const name = item.name

                if (isDir) {
                    uni.showModal({
                        title: '下载目录',
                        content: '确定要下载目录 "' + name + '" 及其所有子文件和子目录吗?',
                        success: (confirmRes) => {
                            if (confirmRes.confirm) {
                                this.startDownload(name, true)
                            }
                        }
                    })
                } else {
                    this.startDownload(name, false)
                }
            },

            startDownload(name, isDir) {
                this.progress.visible = true
                this.progress.percentage = 0
                this.progress.currentFile = '正在下载: ' + name

                let remotePath = this.currentPath
                if (!remotePath.endsWith('/')) remotePath += '/'
                remotePath += name
                if (isDir && !remotePath.endsWith('/')) remotePath += '/'

                const downloadDir = plus.io.convertLocalFileSystemURL('_downloads/')
                const localPath = isDir ? downloadDir + name + '/' : downloadDir + name

                downloadFile({
                    remotePath: remotePath,
                    localPath: localPath,
                    : (p) => {
                        console.log('[FTP] downloadFile progress', p)
                        this.progress.percentage = p.percentage
                        this.progress.currentFile = '下载中: ' + p.currentFile
                    },
                    success: (res) => {
                        console.log('[FTP] downloadFile success', res)
                        this.showStatus('下载成功: ' + name, 'success')
                    },
                    fail: (err) => {
                        console.log('[FTP] downloadFile fail', err)
                        this.showStatus('下载失败: ' + (err.errMsg || '未知错误'), 'error')
                    },
                    complete: (res) => {
                        console.log('[FTP] downloadFile complete', res)
                        setTimeout(() => {
                            this.progress.visible = false
                        }, 1000)
                    }
                })
            },

            doDelete(item) {
                uni.showModal({
                    title: '确认删除',
                    content: '确定要删除 ' + item.name + ' 吗?',
                    success: (confirmRes) => {
                        if (confirmRes.confirm) {
                            let path = this.currentPath
                            if (!path.endsWith('/')) path += '/'
                            path += item.name
                            console.log(path)
                            deleteFile({
                                path: path,
                                success: (res) => {
                                    console.log('[FTP] deleteFile success', res)
                                    this.showStatus('删除成功', 'success')
                                    this.refreshList()
                                },
                                fail: (err) => {
                                    console.log('[FTP] deleteFile fail', err)
                                    this.showStatus('删除失败: ' + (err.errMsg || '未知错误'), 'error')
                                },
                                complete: (res) => {
                                    console.log('[FTP] deleteFile complete', res)
                                }
                            })
                        }
                    }
                })
            },

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

            formatTime(ts) {
                if (!ts || ts === 0) return '-'
                const d = new Date(ts)
                const pad = n => n < 10 ? '0' + n : n
                return d.getFullYear() + '-' + pad(d.getMonth() + 1) + '-' + pad(d.getDate()) +
                    ' ' + pad(d.getHours()) + ':' + pad(d.getMinutes())
            }
        }
    }
</script>

<style>
    .page {height: 100vh;background: linear-gradient(180deg, #f0f2f5 0%, #e8ecf1 100%);overflow-y: auto;padding-bottom: 40rpx;}
    .header {padding: 60rpx 40rpx 40rpx;background: linear-gradient(135deg, #0f766e 0%, #14b8a6 50%, #2dd4bf 100%);position: relative;overflow: hidden;}
    .header::after {content: '';position: absolute;top: -40%;right: -20%;width: 300rpx;height: 300rpx;border-radius: 50%;background: rgba(255, 255, 255, .08);}
    .page-title {font-size: 44rpx;font-weight: 700;color: #fff;position: relative;z-index: 1;}
    .page-subtitle {font-size: 24rpx;color: rgba(255, 255, 255, .75);margin-top: 10rpx;position: relative;z-index: 1;}
    .section {background: #fff;border-radius: 20rpx;margin: 20rpx 30rpx 0;padding: 28rpx;box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, .04);}
    .section-title {font-size: 28rpx;font-weight: 600;color: #1a1a2e;margin-bottom: 24rpx;padding-bottom: 16rpx;border-bottom: 2rpx solid #f0f2f5;}
    .form-item {margin-bottom: 20rpx;}
    .form-label {font-size: 26rpx;color: #555;margin-bottom: 8rpx;display: block;}
    .form-input {height: 80rpx;background: #f5f7fa;border-radius: 12rpx;padding: 0 24rpx;font-size: 28rpx;color: #333;border: 2rpx solid transparent;width: 100%;box-sizing: border-box;}
    .form-input:focus {border-color: #14b8a6;background: #fff;}
    .row {display: flex;align-items: center;gap: 16rpx;}
    .flex-1 {flex: 1;}
    .btn {height: 80rpx;border-radius: 12rpx;font-size: 28rpx;font-weight: 500;display: flex;align-items: center;justify-content: center;border: none;padding: 0 32rpx;transition: opacity .2s;}
    .btn:active {opacity: .85;}
    .btn-primary {background: linear-gradient(135deg, #0f766e, #14b8a6);color: #fff;width: 100%;margin-top: 8rpx;}
    .btn-primary[disabled] {opacity: .6;}
    .btn-permission {margin-top: 16rpx;width: 100%;}
    .btn-outline {background: transparent;border: 2rpx solid #14b8a6;color: #14b8a6;}
    .btn-sm {height: 60rpx;font-size: 24rpx;padding: 0 24rpx;width: auto;}

    .status-bar {display: flex;align-items: center;justify-content: space-between;background: #fff;border-radius: 20rpx;margin: 20rpx 30rpx 0;padding: 20rpx 28rpx;box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, .04);}
    .status-info {display: flex;align-items: center;gap: 12rpx;}
    .status-dot {width: 16rpx;height: 16rpx;border-radius: 50%;background: #22c55e;}
    .status-text {font-size: 26rpx;color: #333;font-weight: 500;}
    .path-bar {display: flex;align-items: center;flex-wrap: wrap;background: #fff;border-radius: 20rpx;margin: 16rpx 30rpx 0;padding: 16rpx 28rpx;box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, .04);gap: 4rpx;}
    .path-segment {font-size: 24rpx;color: #14b8a6;padding: 4rpx 8rpx;border-radius: 6rpx;}
    .path-segment:active {background: #f0fdfa;}
    .path-arrow {color: #aaa;margin: 0 4rpx;}
    .path-sep {display: inline;}
    .action-bar {display: flex;gap: 16rpx;margin: 16rpx 30rpx 0;flex-wrap: wrap;}
    .file-list {background: #fff;border-radius: 20rpx;margin: 16rpx 30rpx 0;box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, .04);overflow: hidden;}
    .file-item {display: flex;align-items: center;padding: 20rpx 24rpx;border-bottom: 2rpx solid #f5f5f5;transition: background .2s;}
    .file-item:active {background: #f0fdfa;}
    .file-item.header-row {background: #fafafa;font-weight: 600;font-size: 22rpx;color: #8899aa;padding: 16rpx 24rpx;}
    .file-icon {width: 50rpx;font-size: 32rpx;text-align: center;flex-shrink: 0;}
    .file-name {flex: 1;font-size: 26rpx;color: #333;padding: 0 12rpx;overflow: hidden;text-overflow: ellipsis;white-space: nowrap;}
    .file-size {width: 120rpx;font-size: 24rpx;color: #8899aa;text-align: right;flex-shrink: 0;}
    .file-date {width: 200rpx;font-size: 22rpx;color: #8899aa;text-align: center;flex-shrink: 0;}
    .file-actions {width: 140rpx;display: flex;gap: 16rpx;justify-content: flex-end;flex-shrink: 0;}
    .action-link {font-size: 22rpx;color: #14b8a6;padding: 4rpx 10rpx;border-radius: 6rpx;}
    .action-link:active {background: #f0fdfa;}
    .action-link.danger {color: #ef4444;}
    .action-link.danger:active {background: #fef2f2;}
    .empty-state {padding: 60rpx;text-align: center;font-size: 26rpx;color: #aaa;}
    .progress-panel {display: flex;align-items: center;gap: 16rpx;background: #fff;border-radius: 20rpx;margin: 16rpx 30rpx 0;padding: 20rpx 28rpx;box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, .04);}
    .progress-label {font-size: 22rpx;color: #555;width: 180rpx;overflow: hidden;text-overflow: ellipsis;white-space: nowrap;flex-shrink: 0;}
    .progress-track {flex: 1;height: 12rpx;background: #e5e7eb;border-radius: 6rpx;overflow: hidden;}
    .progress-fill {height: 100%;background: linear-gradient(90deg, #0f766e, #2dd4bf);border-radius: 6rpx;transition: width .3s ease;}
    .progress-pct {font-size: 22rpx;color: #14b8a6;font-weight: 600;width: 60rpx;text-align: right;flex-shrink: 0;}
    .toast {position: fixed;left: 60rpx;right: 60rpx;bottom: 80rpx;padding: 24rpx 32rpx;border-radius: 16rpx;font-size: 26rpx;color: #fff;text-align: center;z-index: 999;box-shadow: 0 4rpx 24rpx rgba(0, 0, 0, .15);animation: toastIn .3s ease;}
    .toast-info {background: #333;}
    .toast-error {background: #ef4444;}
    @keyframes toastIn {from {opacity: 0;transform: translateY(20rpx);}to {opacity: 1;transform: translateY(0);}}
</style>

类型定义

FtpResult

通用返回结果,根据操作类型不同附带可选字段。

字段 类型 必填 说明
success boolean 操作是否成功
message string 结果或错误描述
remotePath string? 远程路径(上传时返回)
localPath string? 本地路径(下载时返回)
directory string? 查询的目录路径(listFiles 时返回)
files FileItem[]? 文件列表(listFiles 时返回)
totalFiles number? 总文件数(目录操作时返回)
successCount number? 成功文件数
failCount number? 失败文件数

ListResult

listFilessuccess 回调专用结果。

字段 类型 必填 说明
success boolean 操作是否成功
message string? 结果描述
directory string 规范化后的目录路径
files FileItem[] 文件列表

FileItem

文件条目信息。

字段 类型 必填 说明
name string 文件名
size number 文件大小(字节)
formatSize string 格式化后的文件大小
directory boolean 是否为目录
timestamp number 修改时间(毫秒时间戳)
formatTimestamp string 格式化后的修改时间

ProgressResult

uploadFile / downloadFileonProgress 回调参数。

字段 类型 说明
transferred number 已传输字节数
total number 总字节数
percentage number 百分比(0-100)
currentFile string 当前传输的文件名
currentFilePath string 当前传输的文件完整路径

PermissionResult

checkAllFilesAccessPermission 的参数类型。

字段 类型 必填 说明
success boolean 操作是否成功
hasPermission boolean 是否已拥有权限
message string? 结果描述

注意事项

  1. 所有 API 均为异步 — 内部在新线程执行,通过回调返回结果,不阻塞 UI 线程
  2. 必须先调用 connect 连接成功 后才能进行文件操作
  3. complete 回调始终触发(无论成功/失败),适合做清理工作
  4. fail 回调返回 FtpResultsuccesscomplete 同样返回 FtpResult
  5. 上传/下载目录时 完成回调的 totalFiles 反映实际处理的文件总数,successCount + failCount = totalFiles
  6. 目录上传 自动在远程创建对应的文件夹层级
  7. 目录下载 保留远程目录结构到本地,自动创建本地父目录
  8. 删除目录 时递归删除其下所有文件和子目录
  9. 控制编码当前固定为 GBK,含中文文件名时需注意 FTP 服务器编码匹配
  10. 使用被动模式(PASV) 进行数据传输
  11. 进度回调 基于时间间隔触发(当前为每 1000ms),适合大文件传输进度展示

所需权限

插件需要在 AndroidManifest.xml 中声明以下权限:

<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.MANAGE_EXTERNAL_STORAGE" />
权限 用途
INTERNET 连接 FTP 服务器
ACCESS_NETWORK_STATE 检测网络状态
READ_EXTERNAL_STORAGE 读取本地文件进行上传
WRITE_EXTERNAL_STORAGE 将下载的文件保存到本地
MANAGE_EXTERNAL_STORAGE Android 11+ 全文件访问权限(可选)

隐私、权限声明

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

<uses-permission android:name="android.permission.INTERNET" /> <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" /> <uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" /> <uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" /> <uses-permission android:name="android.permission.MANAGE_EXTERNAL_STORAGE" />

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

插件不采集任何数据

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

暂无用户评论。