更新记录
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 |
操作完成时始终触发 |
注意:
listFiles的success回调参数为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 |
否 | 完成回调 |
ListResult(success 回调参数):
| 字段 | 类型 | 说明 |
|---|---|---|
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 |
否 | 完成回调 |
ProgressResult(onProgress 回调参数):
| 字段 | 类型 | 说明 |
|---|---|---|
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 返回 totalFiles、successCount、failCount。
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)、successCount、failCount、localPath。
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
listFiles 的 success 回调专用结果。
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
success |
boolean |
是 | 操作是否成功 |
message |
string? |
否 | 结果描述 |
directory |
string |
是 | 规范化后的目录路径 |
files |
FileItem[] |
是 | 文件列表 |
FileItem
文件条目信息。
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
name |
string |
是 | 文件名 |
size |
number |
是 | 文件大小(字节) |
formatSize |
string |
是 | 格式化后的文件大小 |
directory |
boolean |
是 | 是否为目录 |
timestamp |
number |
是 | 修改时间(毫秒时间戳) |
formatTimestamp |
string |
是 | 格式化后的修改时间 |
ProgressResult
uploadFile / downloadFile 的 onProgress 回调参数。
| 字段 | 类型 | 说明 |
|---|---|---|
transferred |
number |
已传输字节数 |
total |
number |
总字节数 |
percentage |
number |
百分比(0-100) |
currentFile |
string |
当前传输的文件名 |
currentFilePath |
string |
当前传输的文件完整路径 |
PermissionResult
checkAllFilesAccessPermission 的参数类型。
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
success |
boolean |
是 | 操作是否成功 |
hasPermission |
boolean |
是 | 是否已拥有权限 |
message |
string? |
否 | 结果描述 |
注意事项
- 所有 API 均为异步 — 内部在新线程执行,通过回调返回结果,不阻塞 UI 线程
- 必须先调用
connect连接成功 后才能进行文件操作 complete回调始终触发(无论成功/失败),适合做清理工作fail回调返回FtpResult,success和complete同样返回FtpResult- 上传/下载目录时 完成回调的
totalFiles反映实际处理的文件总数,successCount+failCount=totalFiles - 目录上传 自动在远程创建对应的文件夹层级
- 目录下载 保留远程目录结构到本地,自动创建本地父目录
- 删除目录 时递归删除其下所有文件和子目录
- 控制编码当前固定为
GBK,含中文文件名时需注意 FTP 服务器编码匹配 - 使用被动模式(PASV) 进行数据传输
- 进度回调 基于时间间隔触发(当前为每 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+ 全文件访问权限(可选) |

收藏人数:
购买源码授权版(
试用
赞赏(0)
下载 7
赞赏 0
下载 12347191
赞赏 1925
赞赏
京公网安备:11010802035340号