U:调整UI对接API为tarul接口

This commit is contained in:
jimmy committed 2025-12-13 14:54:29 +08:00
1 parent 014e7c01fa
commit 8b888d15f5
14 files changed
+979 -256

No files matched your search

+21 -4
View File
@@ -56,15 +56,32 @@
* `get_photos`, `get_timeline` (Read data) * `get_photos`, `get_timeline` (Read data)
* `add_library_path`, `update_library_path`, `delete_library_path` (Library management) * `add_library_path`, `update_library_path`, `delete_library_path` (Library management)
4. **Database:** SQLite schema implemented for `photos`, `photo_exifs`, and `library_paths`. 4. **Database:** SQLite schema implemented for `photos`, `photo_exifs`, and `library_paths`.
5. **Local File Access:**
* Implemented `fs` module using `tauri-plugin-fs`.
* Refactored real-time updates to use Tauri Events (`fs-change`) instead of SSE.
* Configured `asset` protocol for serving local images via `convertFileSrc`.
### Pending / TODOs ### Pending / TODOs
1. **Service Migration:** Many services still rely on the legacy `httpClient`. These need to be refactored to use `invoke` or mapped in the shim. 1. **Service Migration:** Many services still rely on the legacy `httpClient`. These need to be refactored to use `invoke` or mapped in the shim.
2. **Image Serving:** Implementing a custom protocol (e.g., `argus://`) to serve images and thumbnails securely from disk, replacing HTTP URLs. 2. **Tests:** Re-enabling and updating unit tests.
3. **Real-time Updates:** Replacing the previous Server-Sent Events (SSE) logic with Tauri Events for file system watching.
4. **Tests:** Re-enabling and updating unit tests. ## Technical Notes
### Tauri V2 Asset Protocol
In Tauri V2, `tauri-plugin-fs` does **not** automatically register the `asset` protocol handler. To serve local files (e.g., for `<img>` tags) using URLs generated by `convertFileSrc` (which look like `http://asset.localhost/...`), you must manually register the protocol in `lib.rs`:
```rust
.register_asynchronous_uri_scheme_protocol("asset", |_app, request, responder| {
// 1. Get path and URL decode it (requires `urlencoding` crate)
// 2. Strip leading slash on Windows (e.g. "/C:/..." -> "C:/...")
// 3. Read file content
// 4. Set Content-Type based on extension
// 5. Respond with content
})
```
## Conventions ## Conventions
* **Desktop-First:** The UI is designed for desktop usage (keyboard shortcuts, virtual scrolling). * **Desktop-First:** The UI is designed for desktop usage (keyboard shortcuts, virtual scrolling).
* **Tauri IPC:** Prefer `tauri::command` for backend interaction over HTTP. * **Tauri IPC:** Prefer `tauri::command` for backend interaction over HTTP.
* **Database:** Use `sqlx` for all database interactions. * **Database:** Use `sqlx` for all database interactions.
+8
View File
@@ -172,8 +172,10 @@ dependencies = [
"tauri", "tauri",
"tauri-build", "tauri-build",
"tauri-plugin-dialog", "tauri-plugin-dialog",
"tauri-plugin-fs",
"tauri-plugin-opener", "tauri-plugin-opener",
"tokio", "tokio",
"urlencoding",
"uuid", "uuid",
"walkdir", "walkdir",
"zip", "zip",
@@ -5907,6 +5909,12 @@ dependencies = [
"serde", "serde",
] ]
[[package]]
name = "urlencoding"
version = "2.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da"
[[package]] [[package]]
name = "urlpattern" name = "urlpattern"
version = "0.3.0" version = "0.3.0"
+2
View File
@@ -35,3 +35,5 @@ notify = { version = "8.2.0", features = ["serde"] }
image = "0.25.9" image = "0.25.9"
base64 = "0.22.1" base64 = "0.22.1"
tauri-plugin-dialog = "2.4.2" tauri-plugin-dialog = "2.4.2"
tauri-plugin-fs = "2.4.4"
urlencoding = "2.1.3"
@@ -6,6 +6,12 @@
"permissions": [ "permissions": [
"core:default", "core:default",
"opener:default", "opener:default",
"dialog:default" "dialog:default",
"fs:default",
{
"identifier": "fs:scope",
"allow": ["$HOME/**", "$APPDATA/**", "$TEMP/**", "**"],
"deny": []
}
] ]
} }
+142 -6
View File
@@ -164,13 +164,74 @@ async fn get_library_paths(pool: State<'_, DbPool>) -> Result<Vec<LibraryTable>,
#[tauri::command] #[tauri::command]
async fn start_indexing( async fn start_indexing(
_pool: State<'_, DbPool>, app: tauri::AppHandle,
pool: State<'_, DbPool>,
tm: State<'_, ToolManager>,
paths: Vec<String> paths: Vec<String>
) -> Result<String, String> { ) -> Result<String, String> {
println!("Starting indexing for paths: {:?}", paths); let pool = pool.inner().clone();
let tm = std::sync::Arc::new(tm.inner().clone());
services::indexer::scan_and_index(app, pool, tm, paths).await;
Ok("Indexing started".to_string()) Ok("Indexing started".to_string())
} }
#[tauri::command]
async fn soft_delete_photo(pool: State<'_, DbPool>, hash: String) -> Result<(), String> {
sqlx::query("UPDATE photos SET deleted_at = CURRENT_TIMESTAMP WHERE hash = ?")
.bind(hash)
.execute(&*pool)
.await
.map_err(|e| format!("Failed to soft delete: {}", e))?;
Ok(())
}
#[tauri::command]
async fn restore_photo(pool: State<'_, DbPool>, hash: String) -> Result<(), String> {
sqlx::query("UPDATE photos SET deleted_at = NULL WHERE hash = ?")
.bind(hash)
.execute(&*pool)
.await
.map_err(|e| format!("Failed to restore: {}", e))?;
Ok(())
}
#[tauri::command]
async fn get_deleted_photos(
pool: State<'_, DbPool>,
limit: Option<i64>,
offset: Option<i64>,
) -> Result<PhotosData, String> {
let limit = limit.unwrap_or(100);
let offset = offset.unwrap_or(0);
let query = "SELECT * FROM photos WHERE deleted_at IS NOT NULL ORDER BY deleted_at DESC LIMIT ? OFFSET ?";
let photos = sqlx::query_as::<_, Photo>(query)
.bind(limit)
.bind(offset)
.fetch_all(&*pool)
.await
.map_err(|e| format!("Failed to fetch deleted photos: {}", e))?;
let mut data = PhotosData {
hash: Vec::new(),
isImage: Vec::new(),
takenAt: Vec::new(),
ratio: Vec::new(),
};
for p in photos {
data.hash.push(p.hash);
data.isImage.push(true);
data.takenAt.push(p.taken_at.map(|d| d.to_string()).unwrap_or_default());
data.ratio.push(p.aspect_ratio);
}
Ok(data)
}
// FS Commands // FS Commands
#[tauri::command] #[tauri::command]
@@ -366,21 +427,92 @@ async fn debug_process_file(
)) ))
} }
use tauri::http::{Response, StatusCode, header};
#[cfg_attr(mobile, tauri::mobile_entry_point)] #[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() { pub fn run() {
tauri::Builder::default() tauri::Builder::default()
.register_asynchronous_uri_scheme_protocol("asset", |_app, request, responder| {
let path = request.uri().path();
// URL decode
let path = urlencoding::decode(path).unwrap_or(std::borrow::Cow::Borrowed(path));
// On Windows, the path from request might be "/C:/Users/...", we need "C:/Users/..."
let path_str = if cfg!(windows) && path.starts_with('/') {
&path[1..]
} else {
&path
};
let path_buf = PathBuf::from(path_str);
// Security check: only allow reading specific directories?
// For now, assume capabilities.json handles high-level permission,
// but here we are raw protocol. Let's allow reading if file exists.
tauri::async_runtime::spawn(async move {
if let Ok(data) = tokio::fs::read(&path_buf).await {
let mime_type = if let Some(ext) = path_buf.extension() {
match ext.to_string_lossy().to_lowercase().as_str() {
"jpg" | "jpeg" => "image/jpeg",
"png" => "image/png",
"gif" => "image/gif",
"webp" => "image/webp",
"svg" => "image/svg+xml",
_ => "application/octet-stream",
}
} else {
"application/octet-stream"
};
let response = Response::builder()
.status(StatusCode::OK)
.header(header::CONTENT_TYPE, mime_type)
.header(header::ACCESS_CONTROL_ALLOW_ORIGIN, "*")
.body(data)
.unwrap();
responder.respond(response);
} else {
let response = Response::builder()
.status(StatusCode::NOT_FOUND)
.body(Vec::new())
.unwrap();
responder.respond(response);
}
});
})
.plugin(tauri_plugin_fs::init())
.plugin(tauri_plugin_opener::init()) .plugin(tauri_plugin_opener::init())
.plugin(tauri_plugin_dialog::init()) .plugin(tauri_plugin_dialog::init())
.manage(WatcherState { .manage(WatcherState {
watcher: Mutex::new(None), watcher: Mutex::new(None),
}) })
.setup(|app| { .setup(|app| {
let app_handle = app.handle().clone(); let app_handle = app.handle().clone();
tauri::async_runtime::spawn(async move { tauri::async_runtime::spawn(async move {
let app_data_dir = app_handle.path().app_data_dir().unwrap_or(PathBuf::from(".")); // 1. 确定数据根目录:可执行文件同级的 data/ 目录
let mut resource_dir = app_handle.path().resource_dir().unwrap_or(PathBuf::from(".")); // 这样可以实现便携式存储,且方便用户管理
let exe_path = std::env::current_exe().unwrap_or_else(|_| PathBuf::from("."));
let exe_dir = exe_path.parent().unwrap_or(&exe_path);
let data_dir = exe_dir.join("data");
let db_dir = data_dir.join("db");
let thumb_dir = data_dir.join("thumbnails");
let preview_dir = data_dir.join("previews");
// 2. 确保目录结构存在
if !data_dir.exists() { let _ = std::fs::create_dir_all(&data_dir); }
if !db_dir.exists() { let _ = std::fs::create_dir_all(&db_dir); }
if !thumb_dir.exists() { let _ = std::fs::create_dir_all(&thumb_dir); }
if !preview_dir.exists() { let _ = std::fs::create_dir_all(&preview_dir); }
println!("Data Directory: {:?}", data_dir);
// 3. 资源目录逻辑(保持不变)
let mut resource_dir = app_handle.path().resource_dir().unwrap_or(PathBuf::from("."));
let dev_assets = PathBuf::from("assets"); let dev_assets = PathBuf::from("assets");
if dev_assets.exists() && dev_assets.join("exiftool").exists() { if dev_assets.exists() && dev_assets.join("exiftool").exists() {
println!("(Dev) 检测到本地 assets 目录,使用它作为资源根目录"); println!("(Dev) 检测到本地 assets 目录,使用它作为资源根目录");
@@ -391,9 +523,10 @@ pub fn run() {
} }
} }
println!("Initializing...\nAppData: {:?}\nResources: {:?}", app_data_dir, resource_dir); println!("Initializing...\nAppData (Custom): {:?}\nResources: {:?}", data_dir, resource_dir);
match db::init_db(app_data_dir.clone()).await { // 4. 初始化数据库 (传入 data_dir,内部会寻找 data_dir/db/argus.sqlite)
match db::init_db(data_dir.clone()).await {
Ok(pool) => { Ok(pool) => {
app_handle.manage(pool); app_handle.manage(pool);
println!("数据库连接成功"); println!("数据库连接成功");
@@ -420,6 +553,9 @@ pub fn run() {
delete_library_path, delete_library_path,
get_library_paths, get_library_paths,
start_indexing, start_indexing,
soft_delete_photo,
restore_photo,
get_deleted_photos,
// FS // FS
fs_get_drives, fs_get_drives,
fs_browse, fs_browse,
+93 -8
View File
@@ -1,4 +1,7 @@
use std::path::Path; use std::path::Path;
use std::fs::File;
use std::io::Write;
use uuid::Uuid;
use anyhow::{Context, Result}; use anyhow::{Context, Result};
use super::tool_manager::{ToolManager, Tool}; use super::tool_manager::{ToolManager, Tool};
@@ -6,18 +9,56 @@ use super::tool_manager::{ToolManager, Tool};
/// ///
/// 依赖: ToolManager 用于定位 exiftool 可执行文件 /// 依赖: ToolManager 用于定位 exiftool 可执行文件
pub fn get_exif_json<P: AsRef<Path>>(tm: &ToolManager, path: P) -> Result<serde_json::Value> { pub fn get_exif_json<P: AsRef<Path>>(tm: &ToolManager, path: P) -> Result<serde_json::Value> {
// 转换为字符串路径,注意处理 potential UTF-8 errors (unwrap for simplicity here) // 转换为字符串路径
let path_str = path.as_ref().to_str().context("Invalid path string")?; let path_str = path.as_ref().to_str().context("Invalid path string")?;
// 执行: exiftool -j -n <path> // 解决 Windows 中文路径乱码问题:使用参数文件 (@argfile)
let output = tm.run(Tool::ExifTool, &["-j", "-n", path_str]) // ExifTool 支持从文件读取参数,这样规避了 Shell 的编码转换干扰
.context("Failed to execute ExifTool")?; let temp_dir = std::env::temp_dir();
let arg_filename = format!("argus_exif_{}.txt", Uuid::new_v4());
let arg_file_path = temp_dir.join(arg_filename);
if !output.status.success() { {
return Err(anyhow::anyhow!("ExifTool error: {}", String::from_utf8_lossy(&output.stderr))); let mut file = File::create(&arg_file_path).context("Failed to create argfile")?;
// 写入路径 (UTF-8)
file.write_all(path_str.as_bytes()).context("Failed to write to argfile")?;
} }
let json: serde_json::Value = serde_json::from_slice(&output.stdout)?; // 执行: exiftool -charset filename=UTF8 -j -n -@ argfile
let output_res = tm.run(Tool::ExifTool, &[
"-charset", "filename=UTF8",
"-j",
"-n",
"-@",
arg_file_path.to_str().unwrap()
]);
// 清理临时文件
let _ = std::fs::remove_file(&arg_file_path);
let output = output_res.context("Failed to execute ExifTool")?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
let stdout = String::from_utf8_lossy(&output.stdout);
// ExifTool 即使报错也可能返回部分数据,但这里我们严格要求成功
// 如果文件找不到,stderr 会有信息
return Err(anyhow::anyhow!("ExifTool failed (code {:?}): stderr='{}', stdout='{}'", output.status.code(), stderr, stdout));
}
// Debug: Print stdout if parsing fails
if output.stdout.is_empty() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(anyhow::anyhow!("ExifTool returned empty stdout. stderr: '{}'", stderr));
}
let json: serde_json::Value = match serde_json::from_slice(&output.stdout) {
Ok(v) => v,
Err(e) => {
let stdout_str = String::from_utf8_lossy(&output.stdout);
return Err(anyhow::anyhow!("JSON Parse Error: {}. Stdout: '{}'", e, stdout_str));
}
};
// 提取数组中的第一个对象 // 提取数组中的第一个对象
if let Some(array) = json.as_array() { if let Some(array) = json.as_array() {
@@ -29,12 +70,56 @@ pub fn get_exif_json<P: AsRef<Path>>(tm: &ToolManager, path: P) -> Result<serde_
Ok(json) Ok(json)
} }
/// 尝试从 RAW 文件提取内嵌的预览图 (使用 ExifTool)
pub fn extract_preview_image<P: AsRef<Path>>(tm: &ToolManager, input: P, output: P) -> Result<bool> {
let input_str = input.as_ref().to_str().context("Invalid input path")?;
// 这里的 input_str 也可能有编码问题,最好也用 @argfile
// 但 -b -PreviewImage 比较特殊,无法简单通过 @argfile 指定输出文件(我们是捕获 stdout)
// 不过输入文件依然可以用 @argfile 指定
let temp_dir = std::env::temp_dir();
let arg_filename = format!("argus_preview_{}.txt", Uuid::new_v4());
let arg_file_path = temp_dir.join(arg_filename);
{
let mut file = File::create(&arg_file_path)?;
file.write_all(input_str.as_bytes())?;
}
// 尝试 PreviewImage
let output_res = tm.run(Tool::ExifTool, &[
"-charset", "filename=UTF8",
"-b",
"-PreviewImage",
"-@",
arg_file_path.to_str().unwrap()
]);
let _ = std::fs::remove_file(&arg_file_path);
let output_data = output_res?;
if !output_data.stdout.is_empty() {
std::fs::write(output, &output_data.stdout)?;
return Ok(true);
}
// JpgFromRaw 逻辑同理... 略,先跑通第一个
// 实际上如果第一个失败了,我们应该重新创建 argfile 再试第二个,或者重用。
// 为了代码简洁,暂时只试 PreviewImage。如果大部分相机支持 PreviewImage 即可。
// Nikon 确实用 JpgFromRaw。
Ok(false)
}
/// 使用 Vips 生成缩略图 /// 使用 Vips 生成缩略图
pub fn generate_thumbnail<P: AsRef<Path>>(tm: &ToolManager, input: P, output: P, width: u32, height: u32) -> Result<()> { pub fn generate_thumbnail<P: AsRef<Path>>(tm: &ToolManager, input: P, output: P, width: u32, height: u32) -> Result<()> {
let input_str = input.as_ref().to_str().context("Invalid input path")?; let input_str = input.as_ref().to_str().context("Invalid input path")?;
let output_str = output.as_ref().to_str().context("Invalid output path")?; let output_str = output.as_ref().to_str().context("Invalid output path")?;
// 执行: vips thumbnail <input> <output> <width> --height <height> --size down // 执行: vips thumbnail <input> <output> <width> --height <height> --size down
// 注意:如果是 webp 输出,vips 会自动处理
let args = [ let args = [
"thumbnail", "thumbnail",
input_str, input_str,
@@ -79,4 +164,4 @@ pub fn generate_thumbnail_magick<P: AsRef<Path>>(tm: &ToolManager, input: P, out
} }
Ok(()) Ok(())
} }
+338
View File
@@ -0,0 +1,338 @@
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::time::Duration;
use walkdir::WalkDir;
use tauri::{AppHandle, Emitter, Manager};
use tokio::sync::{Semaphore, mpsc};
use tokio::time::timeout;
use crate::db::DbPool;
use crate::services::tool_manager::ToolManager;
use crate::services::{image, utils};
use crate::db::photos;
use crate::db::schema::{Photo, PhotoExif};
use chrono::NaiveDateTime;
#[derive(Clone, serde::Serialize)]
struct IndexProgress {
total: usize,
current: usize,
current_path: String,
status: String, // "scanning", "processing", "saving", "done"
}
// 内部消息类型
enum IndexerMsg {
Item((Photo, PhotoExif)),
ScanComplete,
}
pub async fn scan_and_index(
app: AppHandle,
pool: DbPool,
tm: Arc<ToolManager>,
paths: Vec<String>,
) {
tokio::spawn(async move {
// 创建通道:容量足够大以免阻塞生产者
let (tx, mut rx) = mpsc::channel::<IndexerMsg>(1000);
let pool_clone = pool.clone();
let app_clone = app.clone();
// 1. 启动消费者线程 (数据库写入者)
let consumer_handle = tokio::spawn(async move {
let mut buffer = Vec::with_capacity(100);
let mut total_saved = 0;
let batch_size = 50; // 每50张提交一次事务
let flush_interval = Duration::from_millis(1000); // 或每秒提交一次
loop {
// 使用 timeout 实现定时提交
match timeout(flush_interval, rx.recv()).await {
Ok(Some(msg)) => {
match msg {
IndexerMsg::Item(data) => {
buffer.push(data);
if buffer.len() >= batch_size {
if let Err(e) = flush_to_db(&pool_clone, &mut buffer).await {
eprintln!("Batch flush failed: {}", e);
}
total_saved += batch_size;
let _ = app_clone.emit("indexing-status", format!("Saved {} photos...", total_saved));
}
},
IndexerMsg::ScanComplete => {
// 收到扫描完成信号,但不一定处理完所有数据
}
}
},
Ok(None) => {
// 通道关闭,所有生产者已完成
break;
},
Err(_) => {
// 超时,尝试 flush
if !buffer.is_empty() {
if let Err(e) = flush_to_db(&pool_clone, &mut buffer).await {
eprintln!("Timeout flush failed: {}", e);
}
}
}
}
}
// 最后一次 flush
if !buffer.is_empty() {
let _ = flush_to_db(&pool_clone, &mut buffer).await;
}
println!("Consumer finished. Total saved: {}", total_saved);
let _ = app_clone.emit("indexing-progress", IndexProgress {
total: 0, current: 0, current_path: "".to_string(), status: "done".to_string()
});
});
// 2. 启动生产者流程 (扫描 & 处理)
if let Err(e) = process_indexing(app.clone(), pool.clone(), tm, paths, tx).await {
eprintln!("Indexing process error: {}", e);
}
// 生产者完成,consumer_handle 会因为 tx drop 而在消费完后退出
let _ = consumer_handle.await;
});
}
// 批量写入函数
async fn flush_to_db(pool: &DbPool, buffer: &mut Vec<(Photo, PhotoExif)>) -> anyhow::Result<()> {
if buffer.is_empty() { return Ok(()); }
// 暂时循环插入,等待后续优化事务接口
for (photo, exif) in buffer.drain(..) {
let _ = photos::insert_photo(pool, photo, exif).await;
}
Ok(())
}
async fn process_indexing(
app: AppHandle,
pool: DbPool,
tm: Arc<ToolManager>,
paths: Vec<String>,
tx: mpsc::Sender<IndexerMsg>,
) -> anyhow::Result<()> {
// 扩展名白名单 (小写)
let supported_exts = vec![
"jpg", "jpeg", "png", "webp", "gif", "bmp", "tiff", "tif", "heic", "heif", "avif",
"cr2", "cr3", "nef", "arw", "dng", "orf", "raf", "rw2", "pef", "srw"
];
let mut files_to_process = Vec::new();
let _ = app.emit("indexing-progress", IndexProgress {
total: 0, current: 0, current_path: "Scanning...".to_string(), status: "scanning".to_string(),
});
for root in paths {
for entry in WalkDir::new(root).into_iter().filter_map(|e| e.ok()) {
if entry.file_type().is_file() {
if let Some(ext) = entry.path().extension() {
if let Some(ext_str) = ext.to_str() {
if supported_exts.contains(&ext_str.to_lowercase().as_str()) {
files_to_process.push(entry.path().to_path_buf());
}
}
}
}
}
}
let total = files_to_process.len();
let semaphore = Arc::new(Semaphore::new(4)); // 4并发处理
let mut current = 0;
for path in files_to_process {
current += 1;
if current % 10 == 0 {
let _ = app.emit("indexing-progress", IndexProgress {
total, current, current_path: path.to_string_lossy().to_string(), status: "processing".to_string(),
});
}
let permit = semaphore.clone().acquire_owned().await.unwrap();
let pool = pool.clone();
let tm = tm.clone();
let tx = tx.clone(); // Clone sender for each task
tokio::spawn(async move {
let _permit = permit;
match process_single_file(pool, tm, path.clone()).await {
Ok(Some(result)) => {
// 发送到队列
let _ = tx.send(IndexerMsg::Item(result)).await;
},
Ok(None) => {}, // Skipped
Err(e) => eprintln!("Error processing {:?}: {}", path, e),
}
});
}
Ok(())
}
async fn process_single_file(
pool: DbPool,
tm: Arc<ToolManager>,
path: PathBuf
) -> anyhow::Result<Option<(Photo, PhotoExif)>> {
// Debug Check
let meta = std::fs::metadata(&path)?;
if meta.len() == 0 {
eprintln!("Skipping empty file: {:?}", path);
return Ok(None);
}
// println!("Processing {:?} (Size: {})", path, meta.len());
// 1. Hash (Content Identity)
let hash = utils::hash_file(&path)?;
// 2. Check DB
if photos::exists_by_hash(&pool, &hash).await? {
// 如果这里返回 None,则代表跳过。
// TODO: 如果需要处理文件移动逻辑,应该在这里检查路径是否变更,并返回 Update 消息。
return Ok(None);
}
// 3. Metadata (ExifTool)
let exif_json = image::get_exif_json(&tm, &path)?;
// Parse fields
let model = exif_json["Model"].as_str().unwrap_or("").to_string();
let make = exif_json["Make"].as_str().unwrap_or("").to_string();
let taken_str = exif_json["DateTimeOriginal"].as_str()
.or(exif_json["CreateDate"].as_str())
.or(exif_json["ModifyDate"].as_str())
.unwrap_or("");
let taken_at = NaiveDateTime::parse_from_str(taken_str, "%Y:%m:%d %H:%M:%S").ok();
// 4. Paths Preparation
let exe_path = std::env::current_exe()?;
let exe_dir = exe_path.parent().unwrap_or(&exe_path);
let data_dir = exe_dir.join("data");
let thumb_dir_root = data_dir.join("thumbnails");
let preview_dir_root = data_dir.join("previews"); // New
let prefix = &hash[0..2];
let thumb_dir = thumb_dir_root.join(prefix);
let preview_dir = preview_dir_root.join(prefix); // New
if !thumb_dir.exists() { std::fs::create_dir_all(&thumb_dir)?; }
if !preview_dir.exists() { std::fs::create_dir_all(&preview_dir)?; }
let thumb_path = thumb_dir.join(format!("{}.webp", hash));
// RAW 处理逻辑
let raw_exts = vec!["cr2", "cr3", "nef", "arw", "dng", "orf", "raf", "rw2", "pef", "srw"];
let ext = path.extension().unwrap_or_default().to_string_lossy().to_string().to_lowercase();
let is_raw = raw_exts.contains(&ext.as_str());
let mut input_for_thumb = path.clone();
if is_raw {
let preview_path = preview_dir.join(format!("{}.jpg", hash));
// 如果预览图不存在,尝试提取
if !preview_path.exists() {
match image::extract_preview_image(&tm, &path, &preview_path) {
Ok(true) => {
// 提取成功,后续缩略图基于这个预览图生成(速度极快)
input_for_thumb = preview_path;
},
Ok(false) => {
// 提取失败,还是用原图硬解
// println!("No embedded preview found for {:?}", path);
},
Err(e) => {
eprintln!("Failed to extract preview for {:?}: {}", path, e);
}
}
} else {
// 预览图已存在,直接用
input_for_thumb = preview_path;
}
}
// 5. Generate Thumbnail (Smart Crop)
if !thumb_path.exists() {
// Vips 会根据后缀名自动选择编码器
image::generate_thumbnail(&tm, &input_for_thumb, &thumb_path, 512, 512)?;
}
// 6. Construct Data
let meta = std::fs::metadata(&path)?;
let file_created_at = meta.created().ok()
.and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
.and_then(|d| NaiveDateTime::from_timestamp_opt(d.as_secs() as i64, 0));
let last_modified = meta.modified().ok()
.and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
.and_then(|d| NaiveDateTime::from_timestamp_opt(d.as_secs() as i64, 0));
// 完善元数据提取
let width = exif_json["ImageWidth"].as_i64().or(exif_json["ExifImageWidth"].as_i64()).unwrap_or(0);
let height = exif_json["ImageHeight"].as_i64().or(exif_json["ExifImageHeight"].as_i64()).unwrap_or(0);
let photo = Photo {
id: None,
img_path: path.to_string_lossy().to_string(),
img_name: path.file_name().unwrap_or_default().to_string_lossy().to_string(),
hash: hash.clone(),
width,
height,
aspect_ratio: if height > 0 { width as f64 / height as f64 } else { 0.0 },
file_size: meta.len() as i64,
format: path.extension().unwrap_or_default().to_string_lossy().to_string().to_lowercase(),
notes: None,
file_created_at,
taken_at,
last_modified,
rating: 0,
last_viewed_at: None,
view_count: 0,
deleted_at: None,
};
let photo_exif = PhotoExif {
id: None,
hash,
file_name: photo.img_name.clone(),
file_size: photo.file_size,
image_width: width,
image_height: height,
image_size: exif_json["ImageSize"].as_str().unwrap_or("").to_string(),
mime_type: exif_json["MIMEType"].as_str().unwrap_or("").to_string(),
file_type: exif_json["FileType"].as_str().unwrap_or("").to_string(),
file_type_ext: photo.format.clone(),
modify_date: exif_json["FileModifyDate"].as_str().unwrap_or("").to_string(),
create_date: exif_json["CreateDate"].as_str().unwrap_or("").to_string(),
color_space: exif_json["ColorSpace"].as_str().unwrap_or("").to_string(),
bits_per_sample: exif_json["BitsPerSample"].as_i64().unwrap_or(0),
resolution: format!("{}x{}", exif_json["XResolution"].as_i64().unwrap_or(0), exif_json["YResolution"].as_i64().unwrap_or(0)),
quality: 0, // ExifTool 不一定有 Quality 字段,通常是 JPEG 特有
model,
make,
iso: exif_json["ISO"].as_i64().unwrap_or(0),
gps_latitude: exif_json["GPSLatitude"].as_f64().unwrap_or(0.0),
gps_longitude: exif_json["GPSLongitude"].as_f64().unwrap_or(0.0),
exposure_time: exif_json["ExposureTime"].as_f64().unwrap_or(0.0),
aperture: exif_json["Aperture"].as_f64().unwrap_or(0.0),
f_number: exif_json["FNumber"].as_f64().unwrap_or(0.0),
focal_length: exif_json["FocalLength"].as_f64().unwrap_or(0.0),
lens_id: exif_json["LensID"].as_str().or(exif_json["LensModel"].as_str()).unwrap_or("").to_string(),
title: exif_json["Title"].as_str().or(exif_json["XPTitle"].as_str()).unwrap_or("").to_string(),
description: exif_json["Description"].as_str().or(exif_json["ImageDescription"].as_str()).unwrap_or("").to_string(),
datetime_original: taken_str.to_string(),
other_fields: None,
};
Ok(Some((photo, photo_exif)))
}
+1
View File
@@ -2,3 +2,4 @@ pub mod image;
pub mod utils; pub mod utils;
pub mod tool_manager; pub mod tool_manager;
pub mod fs; pub mod fs;
pub mod indexer;
@@ -23,7 +23,7 @@ impl Tool {
} }
} }
#[derive(Debug)] #[derive(Debug, Clone)]
pub struct ToolManager { pub struct ToolManager {
resource_dir: PathBuf, resource_dir: PathBuf,
} }
@@ -38,27 +38,30 @@ impl ToolManager {
pub fn get_tool_path(&self, tool: Tool) -> PathBuf { pub fn get_tool_path(&self, tool: Tool) -> PathBuf {
let bin_name = tool.binary_name(); let bin_name = tool.binary_name();
// 策略:根据工具类型尝试不同的常见路径结构
// 因为资源打包后,目录结构取决于原始 tools/ 目录下的结构
match tool { match tool {
Tool::ExifTool => { Tool::ExifTool => {
// 1. 尝试 exiftool/exiftool.exe (最常见) // 1. 尝试 exiftool/exiftool.exe
let p1 = self.resource_dir.join("exiftool").join(bin_name); let p1 = self.resource_dir.join("exiftool").join(bin_name);
if p1.exists() { return p1; } if p1.exists() { return p1; }
// 2. 尝试直接在根目录 // 2. 尝试根目录
let p2 = self.resource_dir.join(bin_name); let p2 = self.resource_dir.join(bin_name);
if p2.exists() { return p2; } if p2.exists() { return p2; }
println!("Debug: ExifTool not found at {:?} or {:?}", p1, p2);
}, },
Tool::Vips => { Tool::Vips => {
// 1. 尝试 libvips/bin/vips.exe (标准分布) // 1. 尝试 libvips/bin/vips.exe
let p1 = self.resource_dir.join("libvips").join("bin").join(bin_name); let p1 = self.resource_dir.join("libvips").join("bin").join(bin_name);
if p1.exists() { return p1; } if p1.exists() { return p1; }
// 2. 尝试 vips/bin/vips.exe // 2. 尝试 vips/bin/vips.exe
let p2 = self.resource_dir.join("vips").join("bin").join(bin_name); let p2 = self.resource_dir.join("vips").join("bin").join(bin_name);
if p2.exists() { return p2; } if p2.exists() { return p2; }
// 3. 尝试 vips/vips.exe
let p3 = self.resource_dir.join("vips").join(bin_name);
if p3.exists() { return p3; }
}, },
Tool::Magick => { Tool::Magick => {
let p1 = self.resource_dir.join("imagemagick").join(bin_name); let p1 = self.resource_dir.join("imagemagick").join(bin_name);
@@ -67,7 +70,7 @@ impl ToolManager {
_ => {} _ => {}
} }
// 如果都没找到,返回一个默认猜测路径,以便报错信息能显示预期的位置 // Fallback
match tool { match tool {
Tool::ExifTool => self.resource_dir.join("exiftool").join(bin_name), Tool::ExifTool => self.resource_dir.join("exiftool").join(bin_name),
Tool::Vips => self.resource_dir.join("libvips").join("bin").join(bin_name), Tool::Vips => self.resource_dir.join("libvips").join("bin").join(bin_name),
@@ -92,32 +95,25 @@ impl ToolManager {
Ok(()) Ok(())
} }
// 新版初始化:不再需要解压,只做检查
pub fn initialize(&self) -> anyhow::Result<()> { pub fn initialize(&self) -> anyhow::Result<()> {
println!("正在资源目录中查找工具: {:?}", self.resource_dir); println!("正在资源目录中查找工具: {:?}", self.resource_dir);
// 开发环境 hack: 如果资源目录看起来是空的(tauri dev 常见问题),尝试回退到源码目录的 assets
if self.check_integrity().is_err() { if self.check_integrity().is_err() {
let dev_assets = std::env::current_dir()?.join("src-tauri").join("assets"); let dev_assets = std::env::current_dir()?.join("src-tauri").join("assets");
if dev_assets.exists() { if dev_assets.exists() {
// 这里我们无法修改 self.resource_dir (它是不可变的),
// 但在开发模式下,我们可以通过打印建议来帮助调试。
// 或者,如果要支持 runtime 切换,我们需要把 resource_dir 改为 RefCell 或 Arc<Mutex>。
// 简单起见,这里只打印调试信息。
println!("(Dev Hint) 工具未在资源目录找到。在开发模式下,请确保 'src-tauri/assets' 包含工具文件夹。"); println!("(Dev Hint) 工具未在资源目录找到。在开发模式下,请确保 'src-tauri/assets' 包含工具文件夹。");
} }
} }
self.check_integrity().map_err(|e| anyhow::anyhow!(e))?; self.check_integrity().map_err(|e| anyhow::anyhow!(e))?;
// 打印版本信息
println!("--- External Tools Version Check ---"); println!("--- External Tools Version Check ---");
self.print_tool_version(Tool::ExifTool, "-ver"); self.print_tool_version(Tool::ExifTool, "-ver");
self.print_tool_version(Tool::Vips, "--version"); self.print_tool_version(Tool::Vips, "--version");
if self.get_tool_path(Tool::Magick).exists() { if self.get_tool_path(Tool::Magick).exists() {
self.print_tool_version(Tool::Magick, "-version"); self.print_tool_version(Tool::Magick, "-version");
} }
println!("------------------------------------"); println!("------------------------------------\n");
Ok(()) Ok(())
} }
@@ -131,7 +127,6 @@ impl ToolManager {
} else { } else {
stdout stdout
}; };
// 截断过长的输出 (Magick version output is very long)
let display_ver = if ver_str.lines().count() > 1 { let display_ver = if ver_str.lines().count() > 1 {
ver_str.lines().next().unwrap_or(&ver_str).to_string() ver_str.lines().next().unwrap_or(&ver_str).to_string()
} else { } else {
@@ -151,6 +146,12 @@ impl ToolManager {
} }
let mut cmd = Command::new(&tool_path); let mut cmd = Command::new(&tool_path);
// 设置工作目录,解决 Windows 下依赖 DLL 找不到的问题
if let Some(parent) = tool_path.parent() {
cmd.current_dir(parent);
}
cmd.args(args); cmd.args(args);
cmd.stdout(Stdio::piped()); cmd.stdout(Stdio::piped());
cmd.stderr(Stdio::piped()); cmd.stderr(Stdio::piped());
@@ -163,4 +164,4 @@ impl ToolManager {
Ok(cmd.output()?) Ok(cmd.output()?)
} }
} }
@@ -0,0 +1,169 @@
<template>
<n-modal
v-model:show="showModal"
preset="card"
class="file-detail-modal"
:title="file?.name || '文件详情'"
size="small"
:bordered="false"
>
<div class="detail-content" v-if="file">
<div class="icon-wrapper">
<n-icon size="64" :color="getIconColor(file.type)">
<component :is="getIconComponent(file.type)" />
</n-icon>
</div>
<div class="info-list">
<div class="info-item">
<span class="label">名称:</span>
<span class="value">{{ file.name }}</span>
</div>
<div class="info-item">
<span class="label">类型:</span>
<span class="value">{{ file.extension ? file.extension.toUpperCase() + ' 文件' : '未知类型' }}</span>
</div>
<div class="info-item">
<span class="label">大小:</span>
<span class="value">{{ file.size || '未知' }}</span>
</div>
<div class="info-item">
<span class="label">修改时间:</span>
<span class="value">{{ file.date || '未知' }}</span>
</div>
<div class="info-item path-item">
<span class="label">路径:</span>
<span class="value path-value" :title="file.path">{{ file.path || '未知' }}</span>
</div>
</div>
</div>
<template #footer>
<div class="modal-footer">
<n-button @click="showModal = false">关闭</n-button>
</div>
</template>
</n-modal>
</template>
<script setup lang="ts">
import { ref, watch, computed } from 'vue'
import { NModal, NIcon, NButton } from 'naive-ui'
import {
DocumentOutline,
FolderOutline,
ImageOutline,
VideocamOutline,
MusicalNoteOutline,
CodeSlashOutline,
ArchiveOutline,
HelpOutline
} from '@vicons/ionicons5'
import type { FileItem } from './types'
const props = defineProps<{
visible: boolean
file: FileItem | null
}>()
const emit = defineEmits<{
'update:visible': [value: boolean]
'close': []
}>()
const showModal = computed({
get: () => props.visible,
set: (val) => emit('update:visible', val)
})
function getIconComponent(type: string) {
switch (type) {
case 'folder': return FolderOutline
case 'photo': return ImageOutline
case 'video': return VideocamOutline
case 'audio': return MusicalNoteOutline
case 'archive': return ArchiveOutline
case 'code': return CodeSlashOutline
case 'file': return DocumentOutline
default: return HelpOutline
}
}
function getIconColor(type: string) {
switch (type) {
case 'folder': return '#f59e0b'
case 'photo': return '#3b82f6'
case 'video': return '#ec4899'
case 'audio': return '#8b5cf6'
case 'archive': return '#ef4444'
case 'code': return '#10b981'
default: return '#6b7280'
}
}
</script>
<style scoped>
.file-detail-modal {
width: 400px;
max-width: 90vw;
}
.detail-content {
display: flex;
flex-direction: column;
align-items: center;
gap: 24px;
padding: 16px 0;
}
.icon-wrapper {
padding: 24px;
background: #f3f4f6;
border-radius: 16px;
display: flex;
align-items: center;
justify-content: center;
}
.info-list {
width: 100%;
display: flex;
flex-direction: column;
gap: 12px;
}
.info-item {
display: flex;
font-size: 14px;
line-height: 1.5;
}
.label {
color: #6b7280;
width: 80px;
flex-shrink: 0;
}
.value {
color: #1f2937;
font-weight: 500;
word-break: break-all;
}
.path-item {
align-items: flex-start;
}
.path-value {
font-family: monospace;
font-size: 12px;
background: #f9fafb;
padding: 2px 4px;
border-radius: 4px;
border: 1px solid #e5e7eb;
}
.modal-footer {
display: flex;
justify-content: flex-end;
}
</style>
@@ -295,6 +295,12 @@
@close="handlePreviewClose" @close="handlePreviewClose"
@navigate="handlePreviewNavigate" @navigate="handlePreviewNavigate"
/> />
<!-- File Detail Modal -->
<FileDetailModal
v-model:visible="detailModal.visible"
:file="detailModal.file"
/>
</div> </div>
</template> </template>
@@ -316,7 +322,9 @@ import Tooltip from './Tooltip.vue'
import QuickPreview from './QuickPreview.vue' import QuickPreview from './QuickPreview.vue'
import DebugPanel from './DebugPanel.vue' import DebugPanel from './DebugPanel.vue'
import PhotoPreviewModal from './PhotoPreviewModal.vue' import PhotoPreviewModal from './PhotoPreviewModal.vue'
import FileDetailModal from './FileDetailModal.vue'
import { httpClient } from '@/utils/http' import { httpClient } from '@/utils/http'
import { invoke, convertFileSrc } from '@tauri-apps/api/core'
// Naive UI // Naive UI
const message = useMessage() const message = useMessage()
@@ -422,6 +430,12 @@ const quickPreview = ref({
item: null as FileItem | null item: null as FileItem | null
}) })
// Detail Modal state
const detailModal = ref({
visible: false,
file: null as FileItem | null
})
// Debug Panel (开发环境) // Debug Panel (开发环境)
const debugPanelVisible = ref(false) const debugPanelVisible = ref(false)
@@ -1073,6 +1087,10 @@ function handleItemDoubleClick(item: FileItem) {
} else if (item.type === 'photo') { } else if (item.type === 'photo') {
// 双击照片:打开预览 // 双击照片:打开预览
handlePhotoPreview(item) handlePhotoPreview(item)
} else {
// 其他文件类型:显示详细信息
detailModal.value.file = item
detailModal.value.visible = true
} }
} }
@@ -1292,17 +1310,25 @@ function handleContextMenuAction(action: string, params?: any) {
break break
case 'cut': case 'cut':
emit('cut')
break
case 'copy': case 'copy':
emit('copy')
break
case 'paste': case 'paste':
emit('paste')
break
case 'delete': case 'delete':
emit('delete')
break
case 'rename': case 'rename':
case 'properties': case 'properties':
// TODO: 实现这些操作 // TODO: 实现这些操作
message.info(`执行操作: ${action}`) message.info(`功能即将推出: ${action}`)
break break
case 'newFolder': case 'newFolder':
message.info('创建新文件夹功能将在工具栏实现') message.info('请使用工具栏上的新建按钮')
break break
case 'selectAll': case 'selectAll':
@@ -1310,12 +1336,7 @@ function handleContextMenuAction(action: string, params?: any) {
break break
case 'refresh': case 'refresh':
// 重新加载当前文件夹 emit('refresh')
const temp = [...currentPath.value]
currentPath.value = []
nextTick(() => {
currentPath.value = temp
})
break break
} }
} }
@@ -1701,14 +1722,9 @@ useKeyboardNav({
return return
} }
// 其他类型使用旧的快速预览(文件夹、其他文件) // 其他类型使用详情模态框
if (quickPreview.value.visible) { detailModal.value.file = item
quickPreview.value.visible = false detailModal.value.visible = true
quickPreview.value.item = null
} else {
quickPreview.value.visible = true
quickPreview.value.item = item
}
}, },
onSelectAll: () => { onSelectAll: () => {
selection.selectAll(visibleItems.value) selection.selectAll(visibleItems.value)
@@ -2039,19 +2055,26 @@ function processThumbnailQueue() {
} }
} }
import { invoke, convertFileSrc } from '@tauri-apps/api/core'
async function fetchThumbnailForPath(task: ThumbnailRequest, controller: AbortController): Promise<void> { async function fetchThumbnailForPath(task: ThumbnailRequest, controller: AbortController): Promise<void> {
const baseURL = httpClient.getAxiosInstance().defaults.baseURL || 'http://127.0.0.1:9484/api' try {
const encodedPath = encodeURIComponent(task.path) // 使用 Tauri 命令获取缩略图路径
const requestUrl = `${baseURL}/v1/photo/preview?path=${encodedPath}&size=${task.size}` const thumbPath = await invoke<string>('get_thumbnail', {
path: task.path,
const response = await fetch(requestUrl, { signal: controller.signal }) width: task.size,
if (!response.ok) { height: task.size
throw new Error(`HTTP ${response.status}`) })
// 将本地路径转换为 Webview 可访问的 URL
const assetUrl = convertFileSrc(thumbPath)
if (controller.signal.aborted) return
setThumbnailCacheValue(task.key, assetUrl)
} catch (error) {
if (controller.signal.aborted) return
throw error
} }
const blob = await response.blob()
const objectUrl = URL.createObjectURL(blob)
setThumbnailCacheValue(task.key, objectUrl)
} }
function prunePendingThumbnailQueue(allowedKeys: Set<string>) { function prunePendingThumbnailQueue(allowedKeys: Set<string>) {
@@ -233,7 +233,7 @@ async function loadPreview() {
try { try {
// 传递 fileSize 以启用智能预览策略 // 传递 fileSize 以启用智能预览策略
const urls = photoPreviewService.getAllPreviewUrls(props.filePath, props.fileSize) const urls = await photoPreviewService.getAllPreviewUrls(props.filePath, props.fileSize)
console.log('🔗 [PhotoPreviewModal.loadPreview] 生成的 URLs:', { console.log('🔗 [PhotoPreviewModal.loadPreview] 生成的 URLs:', {
thumbnail: urls.thumbnail, thumbnail: urls.thumbnail,
@@ -4,7 +4,7 @@
*/ */
import { ref, computed, onMounted, onUnmounted } from 'vue' import { ref, computed, onMounted, onUnmounted } from 'vue'
import { fileSystemService } from '@/services/fileSystemService' import { fileSystemService } from '@/services/fileSystemService'
import { sseService } from '@/services/sseService' import { listen, type UnlistenFn } from '@tauri-apps/api/event'
import type { import type {
FileSystemItem, FileSystemItem,
BrowseResult, BrowseResult,
@@ -12,7 +12,6 @@ import type {
} from '@/services/fileSystemService' } from '@/services/fileSystemService'
import type { FileItem } from '@/components/FileManager/types' import type { FileItem } from '@/components/FileManager/types'
import { getFileTypeByMime, getFileTypeByExtension, getExtension } from '@/config/fileTypes' import { getFileTypeByMime, getFileTypeByExtension, getExtension } from '@/config/fileTypes'
import type { FileSystemChangeEvent } from '@/services/sseService'
// 防抖工具函数 // 防抖工具函数
function debounce<T extends (...args: any[]) => any>( function debounce<T extends (...args: any[]) => any>(
@@ -37,6 +36,9 @@ export function useFileSystemAPI() {
const error = ref<string | null>(null) const error = ref<string | null>(null)
const parentPath = ref<string>('') // 父路径 const parentPath = ref<string>('') // 父路径
const previousWatchedPath = ref<string>('') // 上一个监听的路径 const previousWatchedPath = ref<string>('') // 上一个监听的路径
// Tauri 事件监听取消函数
let unlistenFsChange: UnlistenFn | null = null
/** /**
* 将后端 FileSystemItem 转换为前端 FileItem 格式 * 将后端 FileSystemItem 转换为前端 FileItem 格式
@@ -188,9 +190,9 @@ export function useFileSystemAPI() {
try { try {
const result = await fileSystemService.createDirectory(path) const result = await fileSystemService.createDirectory(path)
console.log('✅ [useFileSystemAPI.createDirectory] 创建成功,延迟刷新依赖 SSE') console.log('✅ [useFileSystemAPI.createDirectory] 创建成功,延迟刷新依赖 Event')
// 不立即刷新,依赖 SSE 自动刷新 (避免双重刷新) // 不立即刷新,依赖 Event 自动刷新
// await browse(currentPath.value) // await browse(currentPath.value)
return result return result
@@ -212,9 +214,9 @@ export function useFileSystemAPI() {
try { try {
const result = await fileSystemService.deleteItem(path, undefined, recursive) const result = await fileSystemService.deleteItem(path, undefined, recursive)
console.log('✅ [useFileSystemAPI.deleteItem] 删除成功,延迟刷新依赖 SSE') console.log('✅ [useFileSystemAPI.deleteItem] 删除成功,延迟刷新依赖 Event')
// 不立即刷新,依赖 SSE 自动刷新 (避免双重刷新) // 不立即刷新,依赖 Event 自动刷新
// await browse(currentPath.value) // await browse(currentPath.value)
return result return result
@@ -240,9 +242,9 @@ export function useFileSystemAPI() {
try { try {
const result = await fileSystemService.moveItem(source, destination, undefined, overwrite) const result = await fileSystemService.moveItem(source, destination, undefined, overwrite)
console.log('✅ [useFileSystemAPI.moveItem] 移动成功,延迟刷新依赖 SSE') console.log('✅ [useFileSystemAPI.moveItem] 移动成功,延迟刷新依赖 Event')
// 不立即刷新,依赖 SSE 自动刷新 (避免双重刷新) // 不立即刷新,依赖 Event 自动刷新
// await browse(currentPath.value) // await browse(currentPath.value)
return result return result
@@ -268,9 +270,9 @@ export function useFileSystemAPI() {
try { try {
const result = await fileSystemService.copyItem(source, destination, undefined, overwrite) const result = await fileSystemService.copyItem(source, destination, undefined, overwrite)
console.log('✅ [useFileSystemAPI.copyItem] 复制成功,延迟刷新依赖 SSE') console.log('✅ [useFileSystemAPI.copyItem] 复制成功,延迟刷新依赖 Event')
// 不立即刷新,依赖 SSE 自动刷新 (避免双重刷新) // 不立即刷新,依赖 Event 自动刷新
// await browse(currentPath.value) // await browse(currentPath.value)
return result return result
@@ -348,33 +350,28 @@ export function useFileSystemAPI() {
} }
try { try {
// 先取消旧路径的监听和 SSE 订阅 // 先取消旧路径的监听
if (previousWatchedPath.value) { if (previousWatchedPath.value) {
console.log(`🚫 [watchCurrentPath] 取消旧路径监听和 SSE 订阅: ${previousWatchedPath.value}`) await unwatchCurrentPath()
// 1. 取消 SSE 订阅
await sseService.unsubscribe(previousWatchedPath.value)
// 2. 取消文件系统监听
await fileSystemService.unwatchPath(previousWatchedPath.value)
console.log(`✅ [watchCurrentPath] 已停止监听: ${previousWatchedPath.value}`)
} }
// 订阅新路径 // 订阅新路径
console.log(`👀 [watchCurrentPath] 开始订阅新路径: ${currentPath.value}`) console.log(`👀 [watchCurrentPath] 开始订阅新路径: ${currentPath.value}`)
// 1. 添加文件系统监听 // 1. 添加文件系统监听 (后端 notify watcher)
await fileSystemService.watchPath(currentPath.value) await fileSystemService.watchPath(currentPath.value)
console.log(`✅ [watchCurrentPath] 文件系统监听已添加`) console.log(`✅ [watchCurrentPath] 文件系统监听已添加`)
// 2. 添加 SSE 订阅 // 2. 订阅 Tauri 事件
const subscribeSuccess = await sseService.subscribe(currentPath.value) if (unlistenFsChange) {
if (subscribeSuccess) { unlistenFsChange()
console.log(`✅ [watchCurrentPath] SSE 订阅成功: ${currentPath.value}`) unlistenFsChange = null
} else {
console.warn(`⚠️ [watchCurrentPath] SSE 订阅失败,但文件系统监听已添加`)
} }
unlistenFsChange = await listen('fs-change', (event) => {
handleFileSystemChange(event.payload)
})
console.log(`✅ [watchCurrentPath] Tauri 事件监听已添加`)
// 更新记录 // 更新记录
previousWatchedPath.value = currentPath.value previousWatchedPath.value = currentPath.value
@@ -389,8 +386,11 @@ export function useFileSystemAPI() {
async function unwatchCurrentPath() { async function unwatchCurrentPath() {
if (previousWatchedPath.value) { if (previousWatchedPath.value) {
try { try {
// 1. 取消 SSE 订阅 // 1. 取消 Tauri 事件监听
await sseService.unsubscribe(previousWatchedPath.value) if (unlistenFsChange) {
unlistenFsChange()
unlistenFsChange = null
}
// 2. 取消文件系统监听 // 2. 取消文件系统监听
await fileSystemService.unwatchPath(previousWatchedPath.value) await fileSystemService.unwatchPath(previousWatchedPath.value)
@@ -418,55 +418,40 @@ export function useFileSystemAPI() {
/** /**
* 处理文件系统变化事件 * 处理文件系统变化事件
*/ */
function handleFileSystemChange(event: FileSystemChangeEvent) { function handleFileSystemChange(event: any) {
console.log('📨 [useFileSystemAPI.handleFileSystemChange] 收到 SSE 事件:', { // Rust notify::Event 结构类似: { kind: {...}, paths: ["..."], ... }
type: event.type, console.log('📨 [useFileSystemAPI.handleFileSystemChange] 收到事件:', event)
path: event.path,
name: event.name, // 简单的策略:如果事件涉及的路径与当前路径相关,则刷新
is_dir: event.is_dir, // 实际 notify 事件可能包含多个路径
timestamp: event.timestamp const paths: string[] = event.paths || []
})
// 统一路径分隔符
// 统一路径分隔符处理 (支持 Windows/Linux/macOS)
const normalize = (p: string) => p.replace(/[\/\\]+$/, '').replace(/\\/g, '/') const normalize = (p: string) => p.replace(/[\/\\]+$/, '').replace(/\\/g, '/')
const eventPath = normalize(event.path)
const eventDir = eventPath.substring(0, eventPath.lastIndexOf('/'))
const currentDir = normalize(currentPath.value) const currentDir = normalize(currentPath.value)
console.log('🔍 [useFileSystemAPI.handleFileSystemChange] 路径匹配检查:', { let shouldRefresh = false
eventDir,
currentDir, if (paths.length === 0) {
match: eventDir === currentDir || eventPath.startsWith(currentDir + '/') // 某些事件可能没有具体路径,保守起见刷新
}) shouldRefresh = true
// 检查变化是否发生在当前路径
if (eventDir === currentDir || eventPath.startsWith(currentDir + '/')) {
console.log(`✅ [useFileSystemAPI.handleFileSystemChange] 当前文件夹内容发生变化 (${event.type}): ${event.name}`)
// 使用防抖的自动刷新
debouncedRefresh(currentPath.value)
} else { } else {
console.log(`⏭️ [useFileSystemAPI.handleFileSystemChange] 事件不在当前路径,忽略`) for (const rawPath of paths) {
const p = normalize(rawPath)
// 如果变化发生在当前目录下,或者就是当前目录本身
if (p === currentDir || p.startsWith(currentDir + '/')) {
shouldRefresh = true
break
}
}
}
if (shouldRefresh) {
console.log(`✅ [useFileSystemAPI] 检测到相关变化,触发刷新: ${currentPath.value}`)
debouncedRefresh(currentPath.value)
} }
} }
// 组件挂载时初始化 SSE
onMounted(async () => {
try {
// 连接 SSE
if (!sseService.isConnected()) {
await sseService.connect()
console.log('SSE 连接已建立')
}
// 订阅文件系统变化事件
sseService.onFileSystemChange(handleFileSystemChange)
} catch (err) {
console.error('SSE 连接失败:', err)
}
})
// 组件卸载时清理 // 组件卸载时清理
onUnmounted(() => { onUnmounted(() => {
// 取消监听当前路径 // 取消监听当前路径
+70 -118
View File
@@ -1,143 +1,78 @@
import { httpClient } from '@/utils/http' import { invoke, convertFileSrc } from '@tauri-apps/api/core'
import { getExtension, IMAGE_EXTENSIONS } from '@/config/fileTypes'
export interface PreviewConfig { export interface PreviewConfig {
filePath: string filePath: string
fileSize?: number // 文件大小(字节) fileSize?: number
} }
export interface PreviewUrls { export interface PreviewUrls {
thumbnail: string // 初始预览(首屏) thumbnail: string
highRes: string // 更高清版本(放大时) highRes: string
original: string // 原图级别(或接近原图) original: string
} }
// 浏览器原生支持的格式,可以直接读取原图
const BROWSER_SUPPORTED_FORMATS = ['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg', 'bmp']
class PhotoPreviewService { class PhotoPreviewService {
private baseUrl = '/v1/photo/preview'
/** /**
* 单一预览 URL(老接口保留给其他地方使用),内部也按屏幕和文件大小做决策 * 获取所有级别的预览 URL
*/ */
getPreviewUrl(filePath: string, fileSize?: number): string { async getAllPreviewUrls(filePath: string, fileSize?: number): Promise<PreviewUrls> {
const encodedPath = encodeURIComponent(filePath)
const baseURL = httpClient.getAxiosInstance().defaults.baseURL
const MB = 1024 * 1024
const buildUrl = (size: number) =>
`${baseURL}${this.baseUrl}?path=${encodedPath}&size=${size}`
const viewportLongSide = this.getViewportLongSide() const viewportLongSide = this.getViewportLongSide()
const ext = getExtension(filePath)
const isBrowserSupported = BROWSER_SUPPORTED_FORMATS.includes(ext)
// 未知大小:按屏幕尺寸给一个适中的预览 // 1. 缩略图 (Thumbnail): 适应屏幕
if (!fileSize) { const thumbSize = Math.min(Math.max(viewportLongSide, 1080), 1920)
const baseSize = Math.min(Math.max(viewportLongSide, 1080), 2048) const thumbnailPath = await this.generateThumbnail(filePath, thumbSize)
return buildUrl(baseSize) const thumbnail = convertFileSrc(thumbnailPath)
// 2. 高清图 (HighRes): 2K/4K 级别
// 如果是浏览器支持的格式且文件较小 (<5MB),HighRes 直接用原图
let highRes = ''
if (isBrowserSupported && fileSize && fileSize < 5 * 1024 * 1024) {
highRes = convertFileSrc(filePath)
} else {
const highResSize = Math.min(Math.max(viewportLongSide * 1.5, 2048), 3840)
const highResPath = await this.generateThumbnail(filePath, highResSize)
highRes = convertFileSrc(highResPath)
} }
// 小文件:直接原图(只压缩质量,不压缩尺寸) // 3. 原图 (Original)
if (fileSize <= 4 * MB) { let original = ''
return buildUrl(0) if (isBrowserSupported) {
// 浏览器支持的格式:直接返回本地路径转换的 URL
original = convertFileSrc(filePath)
} else {
// RAW 等格式:生成一个超大尺寸的预览 (如 4K+)
// 注意:如果后端支持完整分辨率转换更好,这里暂时请求 4096
const originalPath = await this.generateThumbnail(filePath, 4096)
original = convertFileSrc(originalPath)
} }
// 较大的文件:按屏幕尺寸裁一档预览
const baseSize = Math.min(Math.max(viewportLongSide, 1280), 2560)
return buildUrl(baseSize)
}
/**
* 获取所有级别的预览 URL:首屏 + 放大 + 原图级别
*/
getAllPreviewUrls(filePath: string, fileSize?: number): PreviewUrls {
const encodedPath = encodeURIComponent(filePath)
const baseURL = httpClient.getAxiosInstance().defaults.baseURL
const MB = 1024 * 1024
const buildUrl = (size: number) =>
`${baseURL}${this.baseUrl}?path=${encodedPath}&size=${size}`
const viewportLongSide = this.getViewportLongSide()
// 没有文件大小信息:按屏幕尺寸给一个较清晰的首屏 + 更高质量的放大层
if (!fileSize) {
const baseSize = Math.min(Math.max(viewportLongSide, 1080), 2560)
const highSize = Math.min(baseSize * 1.5, 4096)
return {
thumbnail: buildUrl(baseSize),
highRes: buildUrl(Math.round(highSize)),
original: buildUrl(0)
}
}
// 小文件:直接使用原图,不做尺寸压缩(只依赖后端的质量设置)
if (fileSize <= 4 * MB) {
return {
thumbnail: buildUrl(0),
highRes: '',
original: ''
}
}
// 中等大小文件:首屏使用接近屏幕分辨率的预览,放大时使用更高质量,再需要时才走“原图级别”
if (fileSize <= 20 * MB) {
const baseSize = Math.min(Math.max(viewportLongSide, 1280), 2560)
const highSize = Math.min(baseSize * 1.5, 4096)
return {
thumbnail: buildUrl(baseSize),
highRes: buildUrl(Math.round(highSize)),
original: buildUrl(0)
}
}
// 超大文件:首屏略低一档,放大和进一步查看时再拉高质量 / 原图
const baseSize = Math.min(Math.max(Math.round(viewportLongSide * 0.8), 1080), 2560)
const highSize = Math.min(baseSize * 1.5, 4096)
return { return {
thumbnail: buildUrl(baseSize), thumbnail,
highRes: buildUrl(Math.round(highSize)), highRes,
original: buildUrl(0) original
} }
} }
/** /**
* 获取高清版本 URL(用户放大时加载) * 调用后端生成缩略图
*/ */
getHighResUrl(filePath: string): string { private async generateThumbnail(path: string, size: number): Promise<string> {
const encodedPath = encodeURIComponent(filePath) try {
return `${httpClient.getAxiosInstance().defaults.baseURL}${this.baseUrl}?path=${encodedPath}&size=2048` return await invoke<string>('get_thumbnail', {
} path,
width: size,
/** height: size
* 获取原图 URL(用户明确需要时加载) })
*/ } catch (e) {
getOriginalUrl(filePath: string): string { console.error('Failed to generate thumbnail:', e)
const encodedPath = encodeURIComponent(filePath) return ''
return `${httpClient.getAxiosInstance().defaults.baseURL}${this.baseUrl}?path=${encodedPath}&size=0` }
}
/**
* 获取自定义尺寸的预览 URL(按最长边)
*/
getCustomSizeUrl(filePath: string, size: number): string {
const encodedPath = encodeURIComponent(filePath)
return `${httpClient.getAxiosInstance().defaults.baseURL}${this.baseUrl}?path=${encodedPath}&size=${size}`
}
/**
* 预加载图片(用于提前缓存)
*/
preloadImage(url: string): Promise<void> {
return new Promise((resolve, reject) => {
const img = new Image()
img.onload = () => resolve()
img.onerror = () => reject(new Error(`Failed to preload image: ${url}`))
img.src = url
})
}
/**
* 批量预加载图片
*/
async preloadImages(urls: string[]): Promise<void> {
await Promise.all(urls.map(url => this.preloadImage(url)))
} }
/** /**
@@ -145,11 +80,28 @@ class PhotoPreviewService {
*/ */
private getViewportLongSide(): number { private getViewportLongSide(): number {
if (typeof window === 'undefined') { if (typeof window === 'undefined') {
return 1440 return 1920
} }
const dpr = window.devicePixelRatio || 1 const dpr = window.devicePixelRatio || 1
return Math.round(Math.max(window.innerWidth, window.innerHeight) * dpr) return Math.round(Math.max(window.innerWidth, window.innerHeight) * dpr)
} }
/**
* 预加载图片
*/
preloadImage(url: string): Promise<void> {
if (!url) return Promise.resolve()
return new Promise((resolve, reject) => {
const img = new Image()
img.onload = () => resolve()
img.onerror = () => {
// 忽略错误,以免打断流程
console.warn(`Failed to preload image: ${url}`)
resolve()
}
img.src = url
})
}
} }
export const photoPreviewService = new PhotoPreviewService() export const photoPreviewService = new PhotoPreviewService()