今天我们来聊聊Tauri中一个超级重要的功能 - 文件系统操作。这可是Web应用和桌面应用最大的区别之一。在浏览器里,出于安全考虑,我们对文件系统的访问被限制得死死的。但在Tauri桌面应用中,我们可以安全地访问用户的文件系统,这简直打开了新世界的大门!
Tauri文件系统API:两种方式任你选
Tauri给我们提供了两种处理文件系统的方式:
- JavaScript API:通过
@tauri-apps/plugin-fs
提供的前端API直接操作文件,这种方式对前端开发者来说特别友好 - Rust命令:通过在Rust后端定义命令,然后从前端调用,这种方式更灵活但需要写点Rust代码
作为前端开发者,第一种方式用起来更顺手,但了解两种方式都很有必要,这样你就能根据具体需求选择最合适的方案。
权限配置:安全第一
在使用文件系统API之前,我们需要在tauri.conf.json
中配置相应的权限。在Tauri 2.0+中,权限配置使用了全新的capabilities系统,让我们来看看如何正确配置:
配置文件系统权限
Tauri 2.0+引入了capabilities系统来精确控制应用能访问哪些路径以及允许哪些操作。这是更安全的权限管理方式。
在tauri.conf.json
的app.security.capabilities
部分,你需要这样配置文件系统权限:
{"app": {"security": {"capabilities": [{"name": "path","path": {"allow": ["$APP/*", "$DOCUMENT/myapp/*","$HOME/Desktop/myapp-files/*"],"deny": ["$HOME/Desktop/myapp-files/sensitive-data/*"],"requireLiteralLeadingDot": false}},{"name": "fs","fs": {"scope": ["$APP/*", "$DOCUMENT/myapp/*"],"allow": ["read", "write", "readDir", "copyFile", "createDir", "removeDir", "removeFile", "renameFile", "exists"]}},{"name": "dialog","dialog": {"open": true,"save": true}}]}}
}
路径变量详解
path
权限中的allow
和deny
配置支持以下变量,用于定义允许或拒绝访问的路径:
变量 | 描述 | 示例 |
---|---|---|
$APP | 应用数据目录 | Windows: C:\Users\用户名\AppData\Roaming\应用名\ |
$DOCUMENT | 文档目录 | Windows: C:\Users\用户名\Documents\ |
$HOME | 用户主目录 | Windows: C:\Users\用户名\ |
$TEMP | 临时文件目录 | Windows: C:\Users\用户名\AppData\Local\Temp\ |
$AUDIO | 音频目录 | Windows: C:\Users\用户名\Music\ |
$VIDEO | 视频目录 | Windows: C:\Users\用户名\Videos\ |
$PICTURE | 图片目录 | Windows: C:\Users\用户名\Pictures\ |
$DOWNLOAD | 下载目录 | Windows: C:\Users\用户名\Downloads\ |
$DESKTOP | 桌面目录 | Windows: C:\Users\用户名\Desktop\ |
$CONFIG | 配置目录 | Windows: C:\Users\用户名\AppData\Roaming\ |
$DATA | 数据目录 | Windows: C:\Users\用户名\AppData\Roaming\ |
$CACHE | 缓存目录 | Windows: C:\Users\用户名\AppData\Local\Cache\ |
$LOG | 日志目录 | Windows: C:\Users\用户名\AppData\Local\Logs\ |
$RESOURCE | 应用资源目录 | 打包后的资源目录 |
注意: 使用/*
表示匹配该目录下的所有文件,而*
则是通配符。
文件系统操作权限
fs
权限中的allow
数组可以包含以下操作权限:
操作名称 | 描述 | 对应API |
---|---|---|
read | 读取文件权限 | readTextFile , readBinaryFile |
write | 写入文件权限 | writeTextFile , writeBinaryFile |
readDir | 读取目录内容权限 | readDir |
createDir | 创建目录权限 | createDir |
removeDir | 删除目录权限 | removeDir |
copyFile | 复制文件权限 | copyFile |
removeFile | 删除文件权限 | removeFile |
renameFile | 重命名文件/目录权限 | renameFile |
exists | 检查文件/目录是否存在权限 | exists |
metadata | 获取文件/目录元数据权限 | metadata |
权限设置最佳实践
-
最小权限原则
只启用应用实际需要的权限,不要授予应用不必要的权限。例如,如果应用只需要读取文件而不需要写入,就只启用
read
权限。 -
合理设置路径范围
使用
path
权限精确控制应用可以访问的路径,避免不必要的安全风险:"path": {"allow": ["$APP/*","$DOCUMENT/myapp/*"],"deny": ["$DOCUMENT/myapp/sensitive/*"] }
-
使用拒绝列表进一步限制权限
deny
列表可以在allow
列表的基础上进一步限制特定路径的访问。 -
对于用户选择的文件,使用文件对话框
当需要用户选择任意位置的文件时,使用
dialog
插件的文件选择对话框,这样即使没有全局文件系统权限,也能访问用户选择的文件:import { open } from '@tauri-apps/plugin-dialog';async function openUserSelectedFile() {try {const selected = await open({multiple: false,filters: [{name: 'Text',extensions: ['txt', 'md']}]});if (selected) {// 即使没有全局文件系统权限,也可以读取用户选择的文件const content = await readTextFile(selected);console.log('文件内容:', content);}} catch (error) {console.error('打开文件失败:', error);} }
-
使用相对路径
优先使用相对于权限范围的路径,而不是绝对路径:
// 推荐 await writeTextFile('config/settings.json', JSON.stringify(settings));// 不推荐 await writeTextFile('/Users/username/AppData/Roaming/myapp/config/settings.json', JSON.stringify(settings));
使用相对路径时,Tauri会基于应用的根目录解析路径,更符合安全最佳实践。
正确配置文件系统权限不仅可以增强应用安全性,还能提供更好的用户体验。用户会更愿意信任只请求必要权限的应用,而不是要求完全访问文件系统的应用。
读写文件:基础操作
读取文件:文本和二进制
import { readTextFile, readBinaryFile } from '@tauri-apps/plugin-fs';// 读取文本文件(比如配置文件)
async function loadConfig() {try {const configText = await readTextFile('config.json');return JSON.parse(configText);} catch (error) {console.error('加载配置文件失败:', error);return null;}
}// 读取二进制文件(比如图片)
async function loadImage() {try {const imageData = await readBinaryFile('assets/image.png');// 创建Blob对象,用于显示图片const blob = new Blob([imageData]);const imageUrl = URL.createObjectURL(blob);return imageUrl;} catch (error) {console.error('加载图片失败:', error);return null;}
}// 读取大文件(分块读取)
async function readLargeFile(path, chunkSize = 1024 * 1024) {try {const fileSize = await getFileSize(path);const chunks = [];for (let offset = 0; offset < fileSize; offset += chunkSize) {const chunk = await readBinaryFile(path, {offset,length: Math.min(chunkSize, fileSize - offset)});chunks.push(chunk);}return new Uint8Array(chunks.reduce((acc, chunk) => acc + chunk.length, 0));} catch (error) {console.error('读取大文件失败:', error);return null;}
}
写入文件:文本和二进制
import { writeTextFile, writeBinaryFile } from '@tauri-apps/plugin-fs';// 写入文本文件
async function saveConfig(config) {try {const configText = JSON.stringify(config, null, 2);await writeTextFile('config.json', configText);return true;} catch (error) {console.error('保存配置文件失败:', error);return false;}
}// 写入二进制文件
async function saveImage(imageBlob) {try {const arrayBuffer = await imageBlob.arrayBuffer();await writeBinaryFile('output.png', new Uint8Array(arrayBuffer));return true;} catch (error) {console.error('保存图片失败:', error);return false;}
}// 追加写入文件
async function appendToLog(message) {try {const timestamp = new Date().toISOString();const logMessage = `[${timestamp}] ${message}\n`;await writeTextFile('app.log', logMessage, { append: true });return true;} catch (error) {console.error('写入日志失败:', error);return false;}
}
目录操作:管理文件和文件夹
读取目录内容
import { readDir } from '@tauri-apps/plugin-fs';// 读取目录内容
async function listDirectory(path) {try {const entries = await readDir(path, { recursive: false });return entries.map(entry => ({name: entry.name ?? '未知文件',path: entry.path,isDirectory: entry.children !== undefined,size: entry.size ?? 0,modified: entry.modified ?? new Date()}));} catch (error) {console.error(`读取目录失败 ${path}:`, error);return [];}
}// 获取目录统计信息
async function getDirectoryStats(path) {try {const entries = await readDir(path, { recursive: true });let totalFiles = 0;let totalSize = 0;function countFiles(entries) {for (const entry of entries) {if (entry.children) {countFiles(entry.children);} else {totalFiles++;totalSize += entry.size ?? 0;}}}countFiles(entries);return { totalFiles, totalSize };} catch (error) {console.error(`获取目录统计信息失败 ${path}:`, error);return { totalFiles: 0, totalSize: 0 };}
}
递归遍历目录
// 查找所有文件
async function findAllFiles(directory) {const files = [];async function traverse(dir) {const entries = await readDir(dir);for (const entry of entries) {if (entry.children) {await traverse(entry.path);} else if (entry.name) {files.push({path: entry.path,name: entry.name,size: entry.size ?? 0,modified: entry.modified ?? new Date()});}}}await traverse(directory);return files;
}// 按扩展名查找文件
async function findFilesByExtension(directory, extensions) {const allFiles = await findAllFiles(directory);return allFiles.filter(file => {const ext = file.name.split('.').pop()?.toLowerCase();return ext && extensions.includes(ext);});
}
创建和删除目录
import { createDir, removeDir } from '@tauri-apps/plugin-fs';// 创建目录
async function createDirectory(path) {try {await createDir(path, { recursive: true });console.log(`目录创建成功: ${path}`);return true;} catch (error) {console.error(`创建目录失败 ${path}:`, error);return false;}
}// 删除目录
async function removeDirectory(path) {try {await removeDir(path, { recursive: true });console.log(`目录删除成功: ${path}`);return true;} catch (error) {console.error(`删除目录失败 ${path}:`, error);return false;}
}// 清空目录
async function clearDirectory(path) {try {const entries = await readDir(path);for (const entry of entries) {if (entry.children) {await removeDirectory(entry.path);} else {await removeFile(entry.path);}}console.log(`目录清空成功: ${path}`);return true;} catch (error) {console.error(`清空目录失败 ${path}:`, error);return false;}
}
文件对话框:让用户选择文件
Tauri提供了原生的文件对话框,让用户选择文件或保存位置,这比浏览器里的文件选择器好用多了!
打开文件对话框
import { open } from '@tauri-apps/plugin-dialog';// 选择单个文件
async function selectFile() {try {const selected = await open({multiple: false,filters: [{name: '图片文件',extensions: ['png', 'jpg', 'jpeg', 'gif', 'webp']}, {name: '所有文件',extensions: ['*']}],title: '请选择文件',defaultPath: 'C:/Users/Downloads' // 默认打开的目录});if (selected === null) {console.log('用户取消了选择');return null;}return selected;} catch (error) {console.error('打开文件对话框失败:', error);return null;}
}// 选择多个文件
async function selectMultipleFiles() {try {const selected = await open({multiple: true,filters: [{name: '文档文件',extensions: ['pdf', 'doc', 'docx', 'txt', 'md']}],title: '请选择多个文件',defaultPath: 'C:/Users/Documents'});if (selected === null) {return [];}// 选择多个文件时,结果是字符串数组return Array.isArray(selected) ? selected : [selected];} catch (error) {console.error('打开文件对话框失败:', error);return [];}
}// 选择目录
async function selectDirectory() {try {const selected = await open({directory: true,multiple: false,title: '请选择目录',defaultPath: 'C:/Users'});if (selected === null) {console.log('用户取消了选择');return null;}return selected;} catch (error) {console.error('打开目录对话框失败:', error);return null;}
}
保存文件对话框
import { save } from '@tauri-apps/plugin-dialog';// 保存文件
async function saveFileAs(defaultPath) {try {const filePath = await save({defaultPath,filters: [{name: '文本文件',extensions: ['txt', 'md']}, {name: '所有文件',extensions: ['*']}],title: '保存文件',defaultPath: 'C:/Users/Documents/untitled.txt'});if (filePath === null) {console.log('用户取消了保存');return null;}return filePath;} catch (error) {console.error('打开保存对话框失败:', error);return null;}
}// 保存图片
async function saveImageAs(imageData, defaultName = 'image.png') {try {const filePath = await save({defaultPath: `C:/Users/Pictures/${defaultName}`,filters: [{name: '图片文件',extensions: ['png', 'jpg', 'jpeg', 'gif', 'webp']}],title: '保存图片'});if (filePath === null) {return null;}// 保存图片数据await writeBinaryFile(filePath, imageData);return filePath;} catch (error) {console.error('保存图片失败:', error);return null;}
}
文件拖放:让用户轻松上传文件
Tauri支持标准的HTML5拖放API,这意味着你可以轻松实现文件拖放功能,让用户直接把文件拖到你的应用里!
import { readBinaryFile } from '@tauri-apps/plugin-fs';// 设置文件拖放区域
function setupFileDrop(dropElement) {// 添加拖放区域的样式dropElement.classList.add('drop-zone');// 拖拽进入区域dropElement.addEventListener('dragover', (e) => {e.preventDefault();e.stopPropagation();dropElement.classList.add('drag-over');});// 拖拽离开区域dropElement.addEventListener('dragleave', () => {dropElement.classList.remove('drag-over');});// 放置文件dropElement.addEventListener('drop', async (e) => {e.preventDefault();e.stopPropagation();dropElement.classList.remove('drag-over');// 获取拖放的文件const files = Array.from(e.dataTransfer.files);for (const file of files) {try {// 在Tauri中,File对象会有path属性const filePath = file.path;if (filePath) {// 读取文件内容const content = await readBinaryFile(filePath);console.log(`成功加载文件: ${file.name}, 大小: ${content.byteLength} 字节`);// 处理文件内容handleDroppedFile(file, content);}} catch (error) {console.error(`处理文件失败 ${file.name}:`, error);}}});
}// 处理拖放的文件
async function handleDroppedFile(file, content) {// 根据文件类型处理if (file.type.startsWith('image/')) {// 处理图片const blob = new Blob([content]);const imageUrl = URL.createObjectURL(blob);displayImage(imageUrl);} else if (file.type.startsWith('text/')) {// 处理文本文件const text = new TextDecoder().decode(content);displayText(text);} else {// 其他类型文件console.log(`不支持的文件类型: ${file.type}`);}
}// 显示图片
function displayImage(imageUrl) {const img = document.createElement('img');img.src = imageUrl;img.style.maxWidth = '100%';img.style.maxHeight = '300px';document.getElementById('preview').appendChild(img);
}// 显示文本
function displayText(text) {const pre = document.createElement('pre');pre.textContent = text;document.getElementById('preview').appendChild(pre);
}// 添加拖放区域的样式
const style = document.createElement('style');
style.textContent = `.drop-zone {border: 2px dashed #ccc;border-radius: 4px;padding: 20px;text-align: center;transition: all 0.3s;}.drag-over {border-color: #409EFF;background-color: rgba(64, 158, 255, 0.1);}
`;
document.head.appendChild(style);
监控文件变化:实时追踪文件变动
监控文件或目录的变化是桌面应用中常见的需求,比如自动刷新、实时预览等功能。在Tauri中,我们需要通过Rust后端来实现这个功能。
Rust后端实现文件监控
use notify::{RecommendedWatcher, RecursiveMode, Watcher, Config};
use std::sync::mpsc::channel;
use std::path::Path;
use std::collections::HashMap;
use std::sync::Mutex;
use tauri::State;// 存储活动的监控器
struct WatcherRegistry(Mutex<HashMap<String, RecommendedWatcher>>);#[tauri::command]
async fn watch_path(path: String, window: tauri::Window,watcher_registry: State<'_, WatcherRegistry>
) -> Result<(), String> {let (tx, rx) = channel();// 创建监控器let mut watcher = notify::recommended_watcher(tx).map_err(|e| e.to_string())?;// 开始监控watcher.watch(Path::new(&path), RecursiveMode::Recursive).map_err(|e| e.to_string())?;// 将监控器保存到注册表中{let mut registry = watcher_registry.0.lock().unwrap();registry.insert(path.clone(), watcher);}// 在后台线程中处理事件tauri::async_runtime::spawn(async move {for res in rx {match res {Ok(event) => {// 发送事件到前端window.emit("file-change", event).ok();}Err(error) => {window.emit("file-watch-error", error.to_string()).ok();}}}});Ok(())
}#[tauri::command]
fn stop_watching(path: String,watcher_registry: State<'_, WatcherRegistry>
) -> Result<(), String> {let mut registry = watcher_registry.0.lock().unwrap();registry.remove(&path);Ok(())
}fn main() {tauri::Builder::default().manage(WatcherRegistry(Mutex::new(HashMap::new()))).invoke_handler(tauri::generate_handler![watch_path, stop_watching]).run(tauri::generate_context!()).expect("error while running tauri application");
}
前端调用文件监控
import { invoke } from '@tauri-apps/api/tauri';
import { listen } from '@tauri-apps/api/event';// 开始监控
async function startWatching(path) {try {await invoke('watch_path', { path });console.log(`开始监控: ${path}`);return true;} catch (error) {console.error(`监控失败 ${path}:`, error);return false;}
}// 停止监控
async function stopWatching(path) {try {await invoke('stop_watching', { path });console.log(`停止监控: ${path}`);return true;} catch (error) {console.error(`停止监控失败 ${path}:`, error);return false;}
}// 监听文件变化事件
function setupFileChangeListener(callback) {return listen('file-change', (event) => {console.log('检测到文件变化:', event.payload);callback(event.payload);});
}// 监听错误事件
function setupErrorListener(callback) {return listen('file-watch-error', (event) => {console.error('文件监控错误:', event.payload);callback(event.payload);});
}// 使用示例
async function setupFileWatcher() {// 选择要监控的目录const directory = await selectDirectory();if (!directory) return;// 开始监控const success = await startWatching(directory);if (!success) return;// 设置事件监听器const unlistenChange = await setupFileChangeListener((event) => {// 处理文件变化事件handleFileChange(event);});const unlistenError = await setupErrorListener((error) => {// 处理错误showError(error);});// 返回清理函数return () => {unlistenChange();unlistenError();stopWatching(directory);};
}// 处理文件变化
function handleFileChange(event) {// 根据事件类型处理switch (event.kind) {case 'create':console.log(`新文件创建: ${event.paths[0]}`);break;case 'modify':console.log(`文件修改: ${event.paths[0]}`);break;case 'remove':console.log(`文件删除: ${event.paths[0]}`);break;default:console.log('未知的文件变化类型:', event);}
}// 显示错误
function showError(error) {// 在实际应用中,你可能想要显示一个错误提示alert(`文件监控错误: ${error}`);
}
处理路径:跨平台无忧
Tauri提供了一套处理文件路径的工具,帮助我们解决跨平台路径问题。不同操作系统使用不同的路径分隔符(Windows用\
,Unix用/
),有了这些工具,我们就不用担心这些差异了!
import { appDataDir, appConfigDir,appCacheDir,appLogDir,join, normalize,dirname,basename,extname,resolve,sep
} from '@tauri-apps/api/path';// 获取应用数据目录
async function getAppDataPath() {try {const appData = await appDataDir();console.log('应用数据目录:', appData);return appData;} catch (error) {console.error('获取应用数据目录失败:', error);return null;}
}// 获取应用配置目录
async function getAppConfigPath() {try {const configDir = await appConfigDir();console.log('应用配置目录:', configDir);return configDir;} catch (error) {console.error('获取应用配置目录失败:', error);return null;}
}// 拼接路径
async function joinPaths(...paths) {try {const result = await join(...paths);console.log('拼接后的路径:', result);return result;} catch (error) {console.error('路径拼接失败:', error);return null;}
}// 标准化路径
async function normalizePath(path) {try {const normalized = await normalize(path);console.log('标准化后的路径:', normalized);return normalized;} catch (error) {console.error('路径标准化失败:', error);return null;}
}// 获取文件信息
async function getFileInfo(path) {try {const dir = await dirname(path);const name = await basename(path);const ext = await extname(path);return {directory: dir,filename: name,extension: ext,fullPath: path};} catch (error) {console.error('获取文件信息失败:', error);return null;}
}// 解析相对路径
async function resolvePath(basePath, relativePath) {try {const absolutePath = await resolve(basePath, relativePath);console.log('解析后的绝对路径:', absolutePath);return absolutePath;} catch (error) {console.error('路径解析失败:', error);return null;}
}// 获取路径分隔符
function getPathSeparator() {return sep;
}// 使用示例:构建应用目录结构
async function setupAppPaths() {// 获取应用数据目录const appData = await getAppDataPath();if (!appData) return;// 创建配置目录const configDir = await joinPaths(appData, 'config');await createDirectory(configDir);// 创建日志目录const logDir = await joinPaths(appData, 'logs');await createDirectory(logDir);// 创建缓存目录const cacheDir = await joinPaths(appData, 'cache');await createDirectory(cacheDir);return {configDir,logDir,cacheDir};
}// 示例:处理用户选择的文件路径
async function handleUserFilePath(userPath) {// 标准化路径const normalizedPath = await normalizePath(userPath);if (!normalizedPath) return null;// 获取文件信息const fileInfo = await getFileInfo(normalizedPath);if (!fileInfo) return null;// 检查文件扩展名const allowedExtensions = ['.txt', '.md', '.json'];if (!allowedExtensions.includes(fileInfo.extension.toLowerCase())) {console.error('不支持的文件类型:', fileInfo.extension);return null;}return fileInfo;
}
各个路径API详解
Tauri的路径API主要包含两类:
1. 系统特殊目录获取
这些API让你获取跨平台的特殊目录路径,避免硬编码路径:
API | 描述 | 示例结果 (Windows) | 示例结果 (macOS) |
---|---|---|---|
appDataDir() | 应用数据目录 | C:\Users\用户名\AppData\Roaming\应用名\ | /Users/用户名/Library/Application Support/应用名/ |
appConfigDir() | 应用配置目录 | C:\Users\用户名\AppData\Roaming\应用名\config\ | /Users/用户名/Library/Application Support/应用名/ |
appCacheDir() | 应用缓存目录 | C:\Users\用户名\AppData\Local\应用名\cache\ | /Users/用户名/Library/Caches/应用名/ |
appLogDir() | 应用日志目录 | C:\Users\用户名\AppData\Local\应用名\logs\ | /Users/用户名/Library/Logs/应用名/ |
这些特殊目录函数非常重要,因为:
- 它们是跨平台的,不需要为不同操作系统写不同代码
- 它们尊重各操作系统的约定,遵循最佳实践
- 它们自动使用应用名称创建适当的子目录
- 它们返回的路径已经获得了权限(如果你在
tauri.conf.json
中配置了scope
)
2. 路径操作函数
这些API用于操作路径字符串,帮助你处理路径而不必担心平台差异:
API | 描述 | 示例 |
---|---|---|
join(...paths) | 拼接多个路径段 | join('folder', 'file.txt') → 'folder/file.txt' |
normalize(path) | 标准化路径,解析 . 和 .. | normalize('folder/../other/./file.txt') → 'other/file.txt' |
dirname(path) | 获取路径的目录部分 | dirname('/folder/file.txt') → '/folder' |
basename(path) | 获取路径的文件名部分 | basename('/folder/file.txt') → 'file.txt' |
extname(path) | 获取路径的扩展名 | extname('/folder/file.txt') → '.txt' |
resolve(basePath, relativePath) | 将相对路径解析为绝对路径 | resolve('/base', '../other') → '/other' |
sep | 当前平台的路径分隔符 | Windows: '\\' , Unix: '/' |
最佳实践
-
永远使用异步API
所有Tauri路径API都是异步的,返回Promise,务必使用
await
:// 正确 const configDir = await appConfigDir();// 错误 const configDir = appConfigDir(); // 这会得到一个Promise,而不是实际路径
-
构建合理的应用目录结构
使用特殊目录API构建清晰的应用目录结构:
async function initializeAppDirectories() {const dataDir = await appDataDir();const configDir = await appConfigDir();const cacheDir = await appCacheDir();const logDir = await appLogDir();// 确保这些目录存在await createDir(configDir, { recursive: true });await createDir(join(dataDir, 'userData'), { recursive: true });await createDir(join(cacheDir, 'images'), { recursive: true });await createDir(logDir, { recursive: true });return { dataDir, configDir, cacheDir, logDir }; }
-
总是使用路径API来操作路径,不要手动拼接
// 正确 const filePath = await join(configDir, 'settings.json');// 错误(不跨平台) const filePath = configDir + '/settings.json'; // 在Windows上会出问题
-
处理URL和路径的转换
在Web和桌面应用混合开发中,经常需要处理URL和文件路径的转换:
// 文件路径转URL(用于在应用中显示本地文件) function pathToUrl(filePath) {return `file://${filePath.replace(/\\/g, '/')}`; }// 从URL提取路径部分 function urlToPath(url) {return url.replace('file://', '').replace(/\//g, sep); }
通过这些路径API,你可以构建跨平台的应用,而无需担心Windows和Unix之间的路径差异,大大简化了开发过程。
实际应用:打造一个现代化的文件浏览器
让我们把前面学到的知识综合起来,创建一个功能完整的文件浏览器组件。这个组件将包含以下功能:
- 浏览文件和目录
- 显示文件信息
- 支持文件预览
- 支持文件操作(复制、移动、删除等)
<template><div class="file-browser"><!-- 顶部工具栏 --><div class="toolbar"><el-button-group><el-button @click="navigateUp" :disabled="currentPath === '/'"><el-icon><ArrowUp /></el-icon>上级目录</el-button><el-button @click="refresh"><el-icon><Refresh /></el-icon>刷新</el-button><el-button @click="createNewFolder"><el-icon><FolderAdd /></el-icon>新建文件夹</el-button></el-button-group><div class="path-input"><el-input v-model="currentPath" @keyup.enter="navigateTo"placeholder="输入路径或按回车跳转"><template #append><el-button @click="navigateTo">跳转</el-button></template></el-input></div></div><!-- 文件列表 --><div class="file-list"><div v-if="loading" class="loading"><el-spinner /><span>加载中...</span></div><template v-else><!-- 父目录 --><div v-if="currentPath !== '/'" class="file-item" @click="navigateUp"><el-icon><ArrowUp /></el-icon><span>上级目录</span></div><!-- 目录和文件 --><div v-for="item in items" :key="item.path" class="file-item"@click="item.isDirectory ? navigateTo(item.path) : openFile(item.path)"@contextmenu.prevent="showContextMenu($event, item)"><el-icon><Folder v-if="item.isDirectory" /><Document v-else /></el-icon><span class="name">{{ item.name }}</span><span class="size">{{ formatSize(item.size) }}</span><span class="modified">{{ formatDate(item.modified) }}</span></div></template></div><!-- 右键菜单 --><el-dropdown-menu v-if="contextMenu.show" :style="{ left: contextMenu.x + 'px', top: contextMenu.y + 'px' }"@click="handleContextMenuClick"><el-dropdown-item v-if="contextMenu.item" :command="'open'">打开</el-dropdown-item><el-dropdown-item :command="'copy'">复制</el-dropdown-item><el-dropdown-item :command="'cut'">剪切</el-dropdown-item><el-dropdown-item :command="'delete'">删除</el-dropdown-item><el-dropdown-item :command="'rename'">重命名</el-dropdown-item></el-dropdown-menu><!-- 文件预览对话框 --><el-dialogv-model="previewDialog.visible":title="previewDialog.title"width="80%"><div v-if="previewDialog.type === 'text'" class="preview-text"><pre>{{ previewDialog.content }}</pre></div><div v-else-if="previewDialog.type === 'image'" class="preview-image"><img :src="previewDialog.content" alt="预览图片" /></div><div v-else class="preview-unsupported">不支持预览此类型的文件</div></el-dialog></div>
</template><script setup>
import { ref, onMounted } from 'vue';
import { readDir, readTextFile, readBinaryFile,removeFile,renameFile,copyFile
} from '@tauri-apps/plugin-fs';
import { join, dirname, basename } from '@tauri-apps/api/path';
import { open } from '@tauri-apps/plugin-dialog';
import { ElMessage, ElMessageBox } from 'element-plus';const currentPath = ref('/');
const items = ref([]);
const loading = ref(false);
const contextMenu = ref({show: false,x: 0,y: 0,item: null
});
const previewDialog = ref({visible: false,title: '',type: '',content: ''
});// 加载目录内容
async function loadDirectory(path) {loading.value = true;try {const entries = await readDir(path);currentPath.value = path;items.value = entries.map(entry => ({name: entry.name || '未知文件',path: entry.path,isDirectory: entry.children !== undefined,size: entry.size ?? 0,modified: entry.modified ?? new Date()})).sort((a, b) => {// 目录排在前面if (a.isDirectory && !b.isDirectory) return -1;if (!a.isDirectory && b.isDirectory) return 1;return a.name.localeCompare(b.name);});} catch (error) {console.error(`读取目录失败: ${path}`, error);ElMessage.error('读取目录失败');items.value = [];} finally {loading.value = false;}
}// 导航到指定路径
async function navigateTo(path) {if (!path) return;await loadDirectory(typeof path === 'string' ? path : currentPath.value);
}// 返回上级目录
async function navigateUp() {const parent = await dirname(currentPath.value);await loadDirectory(parent);
}// 刷新当前目录
async function refresh() {await loadDirectory(currentPath.value);
}// 创建新文件夹
async function createNewFolder() {try {const name = await ElMessageBox.prompt('请输入文件夹名称', '新建文件夹', {confirmButtonText: '确定',cancelButtonText: '取消',inputPattern: /^[^\\/:*?"<>|]+$/,inputErrorMessage: '文件夹名称不能包含特殊字符'});if (name.value) {const newPath = await join(currentPath.value, name.value);await createDirectory(newPath);await refresh();}} catch (error) {if (error !== 'cancel') {console.error('创建文件夹失败:', error);ElMessage.error('创建文件夹失败');}}
}// 打开文件
async function openFile(path) {try {const name = await basename(path);const ext = path.split('.').pop()?.toLowerCase();// 根据文件类型处理if (['txt', 'md', 'json', 'js', 'ts', 'html', 'css'].includes(ext)) {// 文本文件预览const content = await readTextFile(path);previewDialog.value = {visible: true,title: name,type: 'text',content};} else if (['png', 'jpg', 'jpeg', 'gif', 'webp'].includes(ext)) {// 图片预览const content = await readBinaryFile(path);const blob = new Blob([content]);const url = URL.createObjectURL(blob);previewDialog.value = {visible: true,title: name,type: 'image',content: url};} else {ElMessage.info('不支持预览此类型的文件');}} catch (error) {console.error(`打开文件失败: ${path}`, error);ElMessage.error('打开文件失败');}
}// 显示右键菜单
function showContextMenu(event, item) {contextMenu.value = {show: true,x: event.clientX,y: event.clientY,item};// 点击其他地方关闭菜单document.addEventListener('click', () => {contextMenu.value.show = false;}, { once: true });
}// 处理右键菜单点击
async function handleContextMenuClick(command) {const item = contextMenu.value.item;if (!item) return;try {switch (command) {case 'open':if (item.isDirectory) {await navigateTo(item.path);} else {await openFile(item.path);}break;case 'copy':// 实现复制功能break;case 'cut':// 实现剪切功能break;case 'delete':await ElMessageBox.confirm(`确定要删除 ${item.name} 吗?`,'删除确认',{confirmButtonText: '确定',cancelButtonText: '取消',type: 'warning'});if (item.isDirectory) {await removeDirectory(item.path);} else {await removeFile(item.path);}await refresh();break;case 'rename':const newName = await ElMessageBox.prompt('请输入新名称','重命名',{confirmButtonText: '确定',cancelButtonText: '取消',inputValue: item.name});if (newName.value) {const newPath = await join(await dirname(item.path), newName.value);await renameFile(item.path, newPath);await refresh();}break;}} catch (error) {if (error !== 'cancel') {console.error('操作失败:', error);ElMessage.error('操作失败');}}
}// 格式化文件大小
function formatSize(bytes) {if (bytes === 0) return '0 B';const k = 1024;const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];const i = Math.floor(Math.log(bytes) / Math.log(k));return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
}// 格式化日期
function formatDate(date) {return new Date(date).toLocaleString();
}onMounted(async () => {// 初始加载用户主目录const homeDir = await appDataDir();await loadDirectory(homeDir);
});
</script>## 小结:文件系统操作全掌握通过这篇文章,我们已经全面了解了Tauri中的文件系统操作。作为前端开发者,Tauri的文件系统API为我们提供了几乎所有需要的文件操作能力,同时保持了安全性和易用性的平衡。### 主要功能回顾1. **文件读写**- 读取文本和二进制文件- 写入文本和二进制文件- 追加写入文件- 大文件分块读取2. **目录操作**- 创建和删除目录- 读取目录内容- 递归遍历目录- 获取目录统计信息3. **文件对话框**- 打开文件对话框- 保存文件对话框- 选择目录对话框- 自定义文件过滤器4. **文件拖放**- 设置拖放区域- 处理拖放事件- 预览拖放的文件- 自定义拖放样式5. **文件监控**- 监控文件变化- 处理文件事件- 错误处理- 清理监控器6. **路径处理**- 跨平台路径处理- 路径拼接和解析- 获取文件信息- 应用目录管理### 实际应用示例我们还创建了一个功能完整的文件浏览器组件,展示了如何将这些功能组合起来创建一个实用的桌面应用。这个组件包含了:- 现代化的用户界面
- 完整的文件操作功能
- 文件预览功能
- 右键菜单操作
- 错误处理和用户提示### 最佳实践建议1. **安全性**- 只启用必要的文件系统权限- 使用`scope`来限制文件系统访问范围- 避免使用`"all": true`,这是安全大忌- 定期审查和更新权限配置2. **性能**- 使用分块读取处理大文件- 异步处理文件操作- 合理使用文件缓存- 及时释放资源3. **用户体验**- 提供清晰的操作反馈- 实现文件预览功能- 支持拖放操作- 添加错误提示### 下一步学习在下一篇文章中,我们将探讨Tauri的窗口管理功能,学习如何:- 创建和管理应用窗口
- 自定义窗口样式
- 实现窗口间通信
- 处理窗口事件