U:调整UI对接API为tarul接口
This commit is contained in:
1 parent
014e7c01fa
commit
8b888d15f5
14 files changed
+971
-248
No files matched your search
+20
-3
@@ -56,12 +56,29 @@
|
||||
* `get_photos`, `get_timeline` (Read data)
|
||||
* `add_library_path`, `update_library_path`, `delete_library_path` (Library management)
|
||||
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
|
||||
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.
|
||||
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.
|
||||
2. **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
|
||||
|
||||
|
||||
Generated
+8
@@ -172,8 +172,10 @@ dependencies = [
|
||||
"tauri",
|
||||
"tauri-build",
|
||||
"tauri-plugin-dialog",
|
||||
"tauri-plugin-fs",
|
||||
"tauri-plugin-opener",
|
||||
"tokio",
|
||||
"urlencoding",
|
||||
"uuid",
|
||||
"walkdir",
|
||||
"zip",
|
||||
@@ -5907,6 +5909,12 @@ dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "urlencoding"
|
||||
version = "2.1.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da"
|
||||
|
||||
[[package]]
|
||||
name = "urlpattern"
|
||||
version = "0.3.0"
|
||||
|
||||
@@ -35,3 +35,5 @@ notify = { version = "8.2.0", features = ["serde"] }
|
||||
image = "0.25.9"
|
||||
base64 = "0.22.1"
|
||||
tauri-plugin-dialog = "2.4.2"
|
||||
tauri-plugin-fs = "2.4.4"
|
||||
urlencoding = "2.1.3"
|
||||
@@ -6,6 +6,12 @@
|
||||
"permissions": [
|
||||
"core:default",
|
||||
"opener:default",
|
||||
"dialog:default"
|
||||
"dialog:default",
|
||||
"fs:default",
|
||||
{
|
||||
"identifier": "fs:scope",
|
||||
"allow": ["$HOME/**", "$APPDATA/**", "$TEMP/**", "**"],
|
||||
"deny": []
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -164,13 +164,74 @@ async fn get_library_paths(pool: State<'_, DbPool>) -> Result<Vec<LibraryTable>,
|
||||
|
||||
#[tauri::command]
|
||||
async fn start_indexing(
|
||||
_pool: State<'_, DbPool>,
|
||||
app: tauri::AppHandle,
|
||||
pool: State<'_, DbPool>,
|
||||
tm: State<'_, ToolManager>,
|
||||
paths: Vec<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())
|
||||
}
|
||||
|
||||
#[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
|
||||
|
||||
#[tauri::command]
|
||||
@@ -366,21 +427,92 @@ async fn debug_process_file(
|
||||
))
|
||||
}
|
||||
|
||||
use tauri::http::{Response, StatusCode, header};
|
||||
|
||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||
pub fn run() {
|
||||
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_dialog::init())
|
||||
.manage(WatcherState {
|
||||
watcher: Mutex::new(None),
|
||||
})
|
||||
|
||||
.setup(|app| {
|
||||
let app_handle = app.handle().clone();
|
||||
|
||||
tauri::async_runtime::spawn(async move {
|
||||
let app_data_dir = app_handle.path().app_data_dir().unwrap_or(PathBuf::from("."));
|
||||
let mut resource_dir = app_handle.path().resource_dir().unwrap_or(PathBuf::from("."));
|
||||
// 1. 确定数据根目录:可执行文件同级的 data/ 目录
|
||||
// 这样可以实现便携式存储,且方便用户管理
|
||||
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");
|
||||
if dev_assets.exists() && dev_assets.join("exiftool").exists() {
|
||||
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) => {
|
||||
app_handle.manage(pool);
|
||||
println!("数据库连接成功");
|
||||
@@ -420,6 +553,9 @@ pub fn run() {
|
||||
delete_library_path,
|
||||
get_library_paths,
|
||||
start_indexing,
|
||||
soft_delete_photo,
|
||||
restore_photo,
|
||||
get_deleted_photos,
|
||||
// FS
|
||||
fs_get_drives,
|
||||
fs_browse,
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
use std::path::Path;
|
||||
use std::fs::File;
|
||||
use std::io::Write;
|
||||
use uuid::Uuid;
|
||||
use anyhow::{Context, Result};
|
||||
use super::tool_manager::{ToolManager, Tool};
|
||||
|
||||
@@ -6,18 +9,56 @@ use super::tool_manager::{ToolManager, Tool};
|
||||
///
|
||||
/// 依赖: ToolManager 用于定位 exiftool 可执行文件
|
||||
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")?;
|
||||
|
||||
// 执行: exiftool -j -n <path>
|
||||
let output = tm.run(Tool::ExifTool, &["-j", "-n", path_str])
|
||||
.context("Failed to execute ExifTool")?;
|
||||
// 解决 Windows 中文路径乱码问题:使用参数文件 (@argfile)
|
||||
// ExifTool 支持从文件读取参数,这样规避了 Shell 的编码转换干扰
|
||||
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() {
|
||||
@@ -29,12 +70,56 @@ pub fn get_exif_json<P: AsRef<Path>>(tm: &ToolManager, path: P) -> Result<serde_
|
||||
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 生成缩略图
|
||||
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 output_str = output.as_ref().to_str().context("Invalid output path")?;
|
||||
|
||||
// 执行: vips thumbnail <input> <output> <width> --height <height> --size down
|
||||
// 注意:如果是 webp 输出,vips 会自动处理
|
||||
let args = [
|
||||
"thumbnail",
|
||||
input_str,
|
||||
|
||||
@@ -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)))
|
||||
}
|
||||
@@ -2,3 +2,4 @@ pub mod image;
|
||||
pub mod utils;
|
||||
pub mod tool_manager;
|
||||
pub mod fs;
|
||||
pub mod indexer;
|
||||
@@ -23,7 +23,7 @@ impl Tool {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ToolManager {
|
||||
resource_dir: PathBuf,
|
||||
}
|
||||
@@ -38,27 +38,30 @@ impl ToolManager {
|
||||
pub fn get_tool_path(&self, tool: Tool) -> PathBuf {
|
||||
let bin_name = tool.binary_name();
|
||||
|
||||
// 策略:根据工具类型尝试不同的常见路径结构
|
||||
// 因为资源打包后,目录结构取决于原始 tools/ 目录下的结构
|
||||
|
||||
match tool {
|
||||
Tool::ExifTool => {
|
||||
// 1. 尝试 exiftool/exiftool.exe (最常见)
|
||||
// 1. 尝试 exiftool/exiftool.exe
|
||||
let p1 = self.resource_dir.join("exiftool").join(bin_name);
|
||||
if p1.exists() { return p1; }
|
||||
|
||||
// 2. 尝试直接在根目录
|
||||
// 2. 尝试根目录
|
||||
let p2 = self.resource_dir.join(bin_name);
|
||||
if p2.exists() { return p2; }
|
||||
|
||||
println!("Debug: ExifTool not found at {:?} or {:?}", p1, p2);
|
||||
},
|
||||
Tool::Vips => {
|
||||
// 1. 尝试 libvips/bin/vips.exe (标准分布)
|
||||
// 1. 尝试 libvips/bin/vips.exe
|
||||
let p1 = self.resource_dir.join("libvips").join("bin").join(bin_name);
|
||||
if p1.exists() { return p1; }
|
||||
|
||||
// 2. 尝试 vips/bin/vips.exe
|
||||
let p2 = self.resource_dir.join("vips").join("bin").join(bin_name);
|
||||
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 => {
|
||||
let p1 = self.resource_dir.join("imagemagick").join(bin_name);
|
||||
@@ -67,7 +70,7 @@ impl ToolManager {
|
||||
_ => {}
|
||||
}
|
||||
|
||||
// 如果都没找到,返回一个默认猜测路径,以便报错信息能显示预期的位置
|
||||
// Fallback
|
||||
match tool {
|
||||
Tool::ExifTool => self.resource_dir.join("exiftool").join(bin_name),
|
||||
Tool::Vips => self.resource_dir.join("libvips").join("bin").join(bin_name),
|
||||
@@ -92,32 +95,25 @@ impl ToolManager {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// 新版初始化:不再需要解压,只做检查
|
||||
pub fn initialize(&self) -> anyhow::Result<()> {
|
||||
println!("正在资源目录中查找工具: {:?}", self.resource_dir);
|
||||
|
||||
// 开发环境 hack: 如果资源目录看起来是空的(tauri dev 常见问题),尝试回退到源码目录的 assets
|
||||
if self.check_integrity().is_err() {
|
||||
let dev_assets = std::env::current_dir()?.join("src-tauri").join("assets");
|
||||
if dev_assets.exists() {
|
||||
// 这里我们无法修改 self.resource_dir (它是不可变的),
|
||||
// 但在开发模式下,我们可以通过打印建议来帮助调试。
|
||||
// 或者,如果要支持 runtime 切换,我们需要把 resource_dir 改为 RefCell 或 Arc<Mutex>。
|
||||
// 简单起见,这里只打印调试信息。
|
||||
println!("(Dev Hint) 工具未在资源目录找到。在开发模式下,请确保 'src-tauri/assets' 包含工具文件夹。");
|
||||
}
|
||||
}
|
||||
|
||||
self.check_integrity().map_err(|e| anyhow::anyhow!(e))?;
|
||||
|
||||
// 打印版本信息
|
||||
println!("--- External Tools Version Check ---");
|
||||
self.print_tool_version(Tool::ExifTool, "-ver");
|
||||
self.print_tool_version(Tool::Vips, "--version");
|
||||
if self.get_tool_path(Tool::Magick).exists() {
|
||||
self.print_tool_version(Tool::Magick, "-version");
|
||||
}
|
||||
println!("------------------------------------");
|
||||
println!("------------------------------------\n");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -131,7 +127,6 @@ impl ToolManager {
|
||||
} else {
|
||||
stdout
|
||||
};
|
||||
// 截断过长的输出 (Magick version output is very long)
|
||||
let display_ver = if ver_str.lines().count() > 1 {
|
||||
ver_str.lines().next().unwrap_or(&ver_str).to_string()
|
||||
} else {
|
||||
@@ -151,6 +146,12 @@ impl ToolManager {
|
||||
}
|
||||
|
||||
let mut cmd = Command::new(&tool_path);
|
||||
|
||||
// 设置工作目录,解决 Windows 下依赖 DLL 找不到的问题
|
||||
if let Some(parent) = tool_path.parent() {
|
||||
cmd.current_dir(parent);
|
||||
}
|
||||
|
||||
cmd.args(args);
|
||||
cmd.stdout(Stdio::piped());
|
||||
cmd.stderr(Stdio::piped());
|
||||
|
||||
@@ -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"
|
||||
@navigate="handlePreviewNavigate"
|
||||
/>
|
||||
|
||||
<!-- File Detail Modal -->
|
||||
<FileDetailModal
|
||||
v-model:visible="detailModal.visible"
|
||||
:file="detailModal.file"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -316,7 +322,9 @@ import Tooltip from './Tooltip.vue'
|
||||
import QuickPreview from './QuickPreview.vue'
|
||||
import DebugPanel from './DebugPanel.vue'
|
||||
import PhotoPreviewModal from './PhotoPreviewModal.vue'
|
||||
import FileDetailModal from './FileDetailModal.vue'
|
||||
import { httpClient } from '@/utils/http'
|
||||
import { invoke, convertFileSrc } from '@tauri-apps/api/core'
|
||||
|
||||
// Naive UI
|
||||
const message = useMessage()
|
||||
@@ -422,6 +430,12 @@ const quickPreview = ref({
|
||||
item: null as FileItem | null
|
||||
})
|
||||
|
||||
// Detail Modal state
|
||||
const detailModal = ref({
|
||||
visible: false,
|
||||
file: null as FileItem | null
|
||||
})
|
||||
|
||||
// Debug Panel (开发环境)
|
||||
const debugPanelVisible = ref(false)
|
||||
|
||||
@@ -1073,6 +1087,10 @@ function handleItemDoubleClick(item: FileItem) {
|
||||
} else if (item.type === 'photo') {
|
||||
// 双击照片:打开预览
|
||||
handlePhotoPreview(item)
|
||||
} else {
|
||||
// 其他文件类型:显示详细信息
|
||||
detailModal.value.file = item
|
||||
detailModal.value.visible = true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1292,17 +1310,25 @@ function handleContextMenuAction(action: string, params?: any) {
|
||||
break
|
||||
|
||||
case 'cut':
|
||||
emit('cut')
|
||||
break
|
||||
case 'copy':
|
||||
emit('copy')
|
||||
break
|
||||
case 'paste':
|
||||
emit('paste')
|
||||
break
|
||||
case 'delete':
|
||||
emit('delete')
|
||||
break
|
||||
case 'rename':
|
||||
case 'properties':
|
||||
// TODO: 实现这些操作
|
||||
message.info(`执行操作: ${action}`)
|
||||
message.info(`功能即将推出: ${action}`)
|
||||
break
|
||||
|
||||
case 'newFolder':
|
||||
message.info('创建新文件夹功能将在工具栏实现')
|
||||
message.info('请使用工具栏上的新建按钮')
|
||||
break
|
||||
|
||||
case 'selectAll':
|
||||
@@ -1310,12 +1336,7 @@ function handleContextMenuAction(action: string, params?: any) {
|
||||
break
|
||||
|
||||
case 'refresh':
|
||||
// 重新加载当前文件夹
|
||||
const temp = [...currentPath.value]
|
||||
currentPath.value = []
|
||||
nextTick(() => {
|
||||
currentPath.value = temp
|
||||
})
|
||||
emit('refresh')
|
||||
break
|
||||
}
|
||||
}
|
||||
@@ -1701,14 +1722,9 @@ useKeyboardNav({
|
||||
return
|
||||
}
|
||||
|
||||
// 其他类型使用旧的快速预览(文件夹、其他文件)
|
||||
if (quickPreview.value.visible) {
|
||||
quickPreview.value.visible = false
|
||||
quickPreview.value.item = null
|
||||
} else {
|
||||
quickPreview.value.visible = true
|
||||
quickPreview.value.item = item
|
||||
}
|
||||
// 其他类型使用详情模态框
|
||||
detailModal.value.file = item
|
||||
detailModal.value.visible = true
|
||||
},
|
||||
onSelectAll: () => {
|
||||
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> {
|
||||
const baseURL = httpClient.getAxiosInstance().defaults.baseURL || 'http://127.0.0.1:9484/api'
|
||||
const encodedPath = encodeURIComponent(task.path)
|
||||
const requestUrl = `${baseURL}/v1/photo/preview?path=${encodedPath}&size=${task.size}`
|
||||
try {
|
||||
// 使用 Tauri 命令获取缩略图路径
|
||||
const thumbPath = await invoke<string>('get_thumbnail', {
|
||||
path: task.path,
|
||||
width: task.size,
|
||||
height: task.size
|
||||
})
|
||||
|
||||
const response = await fetch(requestUrl, { signal: controller.signal })
|
||||
if (!response.ok) {
|
||||
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>) {
|
||||
|
||||
@@ -233,7 +233,7 @@ async function loadPreview() {
|
||||
|
||||
try {
|
||||
// 传递 fileSize 以启用智能预览策略
|
||||
const urls = photoPreviewService.getAllPreviewUrls(props.filePath, props.fileSize)
|
||||
const urls = await photoPreviewService.getAllPreviewUrls(props.filePath, props.fileSize)
|
||||
|
||||
console.log('🔗 [PhotoPreviewModal.loadPreview] 生成的 URLs:', {
|
||||
thumbnail: urls.thumbnail,
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
*/
|
||||
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
||||
import { fileSystemService } from '@/services/fileSystemService'
|
||||
import { sseService } from '@/services/sseService'
|
||||
import { listen, type UnlistenFn } from '@tauri-apps/api/event'
|
||||
import type {
|
||||
FileSystemItem,
|
||||
BrowseResult,
|
||||
@@ -12,7 +12,6 @@ import type {
|
||||
} from '@/services/fileSystemService'
|
||||
import type { FileItem } from '@/components/FileManager/types'
|
||||
import { getFileTypeByMime, getFileTypeByExtension, getExtension } from '@/config/fileTypes'
|
||||
import type { FileSystemChangeEvent } from '@/services/sseService'
|
||||
|
||||
// 防抖工具函数
|
||||
function debounce<T extends (...args: any[]) => any>(
|
||||
@@ -38,6 +37,9 @@ export function useFileSystemAPI() {
|
||||
const parentPath = ref<string>('') // 父路径
|
||||
const previousWatchedPath = ref<string>('') // 上一个监听的路径
|
||||
|
||||
// Tauri 事件监听取消函数
|
||||
let unlistenFsChange: UnlistenFn | null = null
|
||||
|
||||
/**
|
||||
* 将后端 FileSystemItem 转换为前端 FileItem 格式
|
||||
*/
|
||||
@@ -188,9 +190,9 @@ export function useFileSystemAPI() {
|
||||
try {
|
||||
const result = await fileSystemService.createDirectory(path)
|
||||
|
||||
console.log('✅ [useFileSystemAPI.createDirectory] 创建成功,延迟刷新依赖 SSE')
|
||||
console.log('✅ [useFileSystemAPI.createDirectory] 创建成功,延迟刷新依赖 Event')
|
||||
|
||||
// 不立即刷新,依赖 SSE 自动刷新 (避免双重刷新)
|
||||
// 不立即刷新,依赖 Event 自动刷新
|
||||
// await browse(currentPath.value)
|
||||
|
||||
return result
|
||||
@@ -212,9 +214,9 @@ export function useFileSystemAPI() {
|
||||
try {
|
||||
const result = await fileSystemService.deleteItem(path, undefined, recursive)
|
||||
|
||||
console.log('✅ [useFileSystemAPI.deleteItem] 删除成功,延迟刷新依赖 SSE')
|
||||
console.log('✅ [useFileSystemAPI.deleteItem] 删除成功,延迟刷新依赖 Event')
|
||||
|
||||
// 不立即刷新,依赖 SSE 自动刷新 (避免双重刷新)
|
||||
// 不立即刷新,依赖 Event 自动刷新
|
||||
// await browse(currentPath.value)
|
||||
|
||||
return result
|
||||
@@ -240,9 +242,9 @@ export function useFileSystemAPI() {
|
||||
try {
|
||||
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)
|
||||
|
||||
return result
|
||||
@@ -268,9 +270,9 @@ export function useFileSystemAPI() {
|
||||
try {
|
||||
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)
|
||||
|
||||
return result
|
||||
@@ -348,34 +350,29 @@ export function useFileSystemAPI() {
|
||||
}
|
||||
|
||||
try {
|
||||
// 先取消旧路径的监听和 SSE 订阅
|
||||
// 先取消旧路径的监听
|
||||
if (previousWatchedPath.value) {
|
||||
console.log(`🚫 [watchCurrentPath] 取消旧路径监听和 SSE 订阅: ${previousWatchedPath.value}`)
|
||||
|
||||
// 1. 取消 SSE 订阅
|
||||
await sseService.unsubscribe(previousWatchedPath.value)
|
||||
|
||||
// 2. 取消文件系统监听
|
||||
await fileSystemService.unwatchPath(previousWatchedPath.value)
|
||||
|
||||
console.log(`✅ [watchCurrentPath] 已停止监听: ${previousWatchedPath.value}`)
|
||||
await unwatchCurrentPath()
|
||||
}
|
||||
|
||||
// 订阅新路径
|
||||
console.log(`👀 [watchCurrentPath] 开始订阅新路径: ${currentPath.value}`)
|
||||
|
||||
// 1. 添加文件系统监听
|
||||
// 1. 添加文件系统监听 (后端 notify watcher)
|
||||
await fileSystemService.watchPath(currentPath.value)
|
||||
console.log(`✅ [watchCurrentPath] 文件系统监听已添加`)
|
||||
|
||||
// 2. 添加 SSE 订阅
|
||||
const subscribeSuccess = await sseService.subscribe(currentPath.value)
|
||||
if (subscribeSuccess) {
|
||||
console.log(`✅ [watchCurrentPath] SSE 订阅成功: ${currentPath.value}`)
|
||||
} else {
|
||||
console.warn(`⚠️ [watchCurrentPath] SSE 订阅失败,但文件系统监听已添加`)
|
||||
// 2. 订阅 Tauri 事件
|
||||
if (unlistenFsChange) {
|
||||
unlistenFsChange()
|
||||
unlistenFsChange = null
|
||||
}
|
||||
|
||||
unlistenFsChange = await listen('fs-change', (event) => {
|
||||
handleFileSystemChange(event.payload)
|
||||
})
|
||||
console.log(`✅ [watchCurrentPath] Tauri 事件监听已添加`)
|
||||
|
||||
// 更新记录
|
||||
previousWatchedPath.value = currentPath.value
|
||||
} catch (err: any) {
|
||||
@@ -389,8 +386,11 @@ export function useFileSystemAPI() {
|
||||
async function unwatchCurrentPath() {
|
||||
if (previousWatchedPath.value) {
|
||||
try {
|
||||
// 1. 取消 SSE 订阅
|
||||
await sseService.unsubscribe(previousWatchedPath.value)
|
||||
// 1. 取消 Tauri 事件监听
|
||||
if (unlistenFsChange) {
|
||||
unlistenFsChange()
|
||||
unlistenFsChange = null
|
||||
}
|
||||
|
||||
// 2. 取消文件系统监听
|
||||
await fileSystemService.unwatchPath(previousWatchedPath.value)
|
||||
@@ -418,55 +418,40 @@ export function useFileSystemAPI() {
|
||||
/**
|
||||
* 处理文件系统变化事件
|
||||
*/
|
||||
function handleFileSystemChange(event: FileSystemChangeEvent) {
|
||||
console.log('📨 [useFileSystemAPI.handleFileSystemChange] 收到 SSE 事件:', {
|
||||
type: event.type,
|
||||
path: event.path,
|
||||
name: event.name,
|
||||
is_dir: event.is_dir,
|
||||
timestamp: event.timestamp
|
||||
})
|
||||
function handleFileSystemChange(event: any) {
|
||||
// Rust notify::Event 结构类似: { kind: {...}, paths: ["..."], ... }
|
||||
console.log('📨 [useFileSystemAPI.handleFileSystemChange] 收到事件:', event)
|
||||
|
||||
// 统一路径分隔符处理 (支持 Windows/Linux/macOS)
|
||||
// 简单的策略:如果事件涉及的路径与当前路径相关,则刷新
|
||||
// 实际 notify 事件可能包含多个路径
|
||||
const paths: string[] = event.paths || []
|
||||
|
||||
// 统一路径分隔符
|
||||
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)
|
||||
|
||||
console.log('🔍 [useFileSystemAPI.handleFileSystemChange] 路径匹配检查:', {
|
||||
eventDir,
|
||||
currentDir,
|
||||
match: eventDir === currentDir || eventPath.startsWith(currentDir + '/')
|
||||
})
|
||||
let shouldRefresh = false
|
||||
|
||||
// 检查变化是否发生在当前路径
|
||||
if (eventDir === currentDir || eventPath.startsWith(currentDir + '/')) {
|
||||
console.log(`✅ [useFileSystemAPI.handleFileSystemChange] 当前文件夹内容发生变化 (${event.type}): ${event.name}`)
|
||||
|
||||
// 使用防抖的自动刷新
|
||||
debouncedRefresh(currentPath.value)
|
||||
if (paths.length === 0) {
|
||||
// 某些事件可能没有具体路径,保守起见刷新
|
||||
shouldRefresh = true
|
||||
} 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(() => {
|
||||
// 取消监听当前路径
|
||||
|
||||
@@ -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 {
|
||||
filePath: string
|
||||
fileSize?: number // 文件大小(字节)
|
||||
fileSize?: number
|
||||
}
|
||||
|
||||
export interface PreviewUrls {
|
||||
thumbnail: string // 初始预览(首屏)
|
||||
highRes: string // 更高清版本(放大时)
|
||||
original: string // 原图级别(或接近原图)
|
||||
thumbnail: string
|
||||
highRes: string
|
||||
original: string
|
||||
}
|
||||
|
||||
// 浏览器原生支持的格式,可以直接读取原图
|
||||
const BROWSER_SUPPORTED_FORMATS = ['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg', 'bmp']
|
||||
|
||||
class PhotoPreviewService {
|
||||
private baseUrl = '/v1/photo/preview'
|
||||
|
||||
/**
|
||||
* 单一预览 URL(老接口保留给其他地方使用),内部也按屏幕和文件大小做决策
|
||||
* 获取所有级别的预览 URL
|
||||
*/
|
||||
getPreviewUrl(filePath: string, fileSize?: number): string {
|
||||
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}`
|
||||
|
||||
async getAllPreviewUrls(filePath: string, fileSize?: number): Promise<PreviewUrls> {
|
||||
const viewportLongSide = this.getViewportLongSide()
|
||||
const ext = getExtension(filePath)
|
||||
const isBrowserSupported = BROWSER_SUPPORTED_FORMATS.includes(ext)
|
||||
|
||||
// 未知大小:按屏幕尺寸给一个适中的预览
|
||||
if (!fileSize) {
|
||||
const baseSize = Math.min(Math.max(viewportLongSide, 1080), 2048)
|
||||
return buildUrl(baseSize)
|
||||
// 1. 缩略图 (Thumbnail): 适应屏幕
|
||||
const thumbSize = Math.min(Math.max(viewportLongSide, 1080), 1920)
|
||||
const thumbnailPath = await this.generateThumbnail(filePath, thumbSize)
|
||||
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)
|
||||
}
|
||||
|
||||
// 小文件:直接原图(只压缩质量,不压缩尺寸)
|
||||
if (fileSize <= 4 * MB) {
|
||||
return buildUrl(0)
|
||||
// 3. 原图 (Original)
|
||||
let original = ''
|
||||
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 {
|
||||
thumbnail: buildUrl(baseSize),
|
||||
highRes: buildUrl(Math.round(highSize)),
|
||||
original: buildUrl(0)
|
||||
thumbnail,
|
||||
highRes,
|
||||
original
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取高清版本 URL(用户放大时加载)
|
||||
* 调用后端生成缩略图
|
||||
*/
|
||||
getHighResUrl(filePath: string): string {
|
||||
const encodedPath = encodeURIComponent(filePath)
|
||||
return `${httpClient.getAxiosInstance().defaults.baseURL}${this.baseUrl}?path=${encodedPath}&size=2048`
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取原图 URL(用户明确需要时加载)
|
||||
*/
|
||||
getOriginalUrl(filePath: string): string {
|
||||
const encodedPath = encodeURIComponent(filePath)
|
||||
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)))
|
||||
private async generateThumbnail(path: string, size: number): Promise<string> {
|
||||
try {
|
||||
return await invoke<string>('get_thumbnail', {
|
||||
path,
|
||||
width: size,
|
||||
height: size
|
||||
})
|
||||
} catch (e) {
|
||||
console.error('Failed to generate thumbnail:', e)
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -145,11 +80,28 @@ class PhotoPreviewService {
|
||||
*/
|
||||
private getViewportLongSide(): number {
|
||||
if (typeof window === 'undefined') {
|
||||
return 1440
|
||||
return 1920
|
||||
}
|
||||
const dpr = window.devicePixelRatio || 1
|
||||
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()
|
||||
|
||||
Reference in new issue
Block a user