新增: OSS 资产库与桌面本地文件读取,交互与云同步增强
This commit is contained in:
Generated
+207
-1
@@ -556,6 +556,7 @@ checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
|
||||
dependencies = [
|
||||
"block-buffer",
|
||||
"crypto-common",
|
||||
"subtle",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1279,6 +1280,15 @@ version = "0.4.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
|
||||
|
||||
[[package]]
|
||||
name = "hmac"
|
||||
version = "0.12.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e"
|
||||
dependencies = [
|
||||
"digest",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "html5ever"
|
||||
version = "0.38.0"
|
||||
@@ -1328,6 +1338,12 @@ version = "1.10.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87"
|
||||
|
||||
[[package]]
|
||||
name = "httpdate"
|
||||
version = "1.0.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9"
|
||||
|
||||
[[package]]
|
||||
name = "hyper"
|
||||
version = "1.10.1"
|
||||
@@ -1826,6 +1842,16 @@ version = "0.3.17"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
|
||||
|
||||
[[package]]
|
||||
name = "mime_guess"
|
||||
version = "2.0.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e"
|
||||
dependencies = [
|
||||
"mime",
|
||||
"unicase",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "miniz_oxide"
|
||||
version = "0.8.9"
|
||||
@@ -2075,6 +2101,7 @@ checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272"
|
||||
dependencies = [
|
||||
"bitflags 2.13.0",
|
||||
"block2",
|
||||
"libc",
|
||||
"objc2",
|
||||
"objc2-core-foundation",
|
||||
]
|
||||
@@ -2570,6 +2597,7 @@ dependencies = [
|
||||
"js-sys",
|
||||
"log",
|
||||
"mime",
|
||||
"mime_guess",
|
||||
"native-tls",
|
||||
"percent-encoding",
|
||||
"pin-project-lite",
|
||||
@@ -2625,6 +2653,30 @@ dependencies = [
|
||||
"web-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rfd"
|
||||
version = "0.16.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a15ad77d9e70a92437d8f74c35d99b4e4691128df018833e99f90bcd36152672"
|
||||
dependencies = [
|
||||
"block2",
|
||||
"dispatch2",
|
||||
"glib-sys",
|
||||
"gobject-sys",
|
||||
"gtk-sys",
|
||||
"js-sys",
|
||||
"log",
|
||||
"objc2",
|
||||
"objc2-app-kit",
|
||||
"objc2-core-foundation",
|
||||
"objc2-foundation",
|
||||
"raw-window-handle",
|
||||
"wasm-bindgen",
|
||||
"wasm-bindgen-futures",
|
||||
"web-sys",
|
||||
"windows-sys 0.60.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ring"
|
||||
version = "0.17.14"
|
||||
@@ -3009,6 +3061,17 @@ dependencies = [
|
||||
"stable_deref_trait",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sha1"
|
||||
version = "0.10.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cpufeatures",
|
||||
"digest",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sha2"
|
||||
version = "0.10.9"
|
||||
@@ -3416,6 +3479,64 @@ dependencies = [
|
||||
"tauri-utils",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tauri-plugin"
|
||||
version = "2.6.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "74be5dd4bed9afbd145e5716b5fa2ec28cbc29c34ffa61c258c9273d896c8020"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"glob",
|
||||
"plist",
|
||||
"schemars 0.8.22",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tauri-utils",
|
||||
"walkdir",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tauri-plugin-dialog"
|
||||
version = "2.7.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b2d3c1dbe38037e7f590cdf2492594d5ceebe031e7bc7e827509b22a999d2940"
|
||||
dependencies = [
|
||||
"log",
|
||||
"raw-window-handle",
|
||||
"rfd",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tauri",
|
||||
"tauri-plugin",
|
||||
"tauri-plugin-fs",
|
||||
"thiserror 2.0.18",
|
||||
"url",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tauri-plugin-fs"
|
||||
version = "2.5.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b7ecc274121aca0c036a2b42d1cbe83d368d348f54e0bb8a735c2b1548e8f371"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"dunce",
|
||||
"glob",
|
||||
"log",
|
||||
"objc2-foundation",
|
||||
"percent-encoding",
|
||||
"schemars 0.8.22",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"serde_repr",
|
||||
"tauri",
|
||||
"tauri-plugin",
|
||||
"tauri-utils",
|
||||
"thiserror 2.0.18",
|
||||
"toml 1.1.2+spec-1.1.0",
|
||||
"url",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tauri-runtime"
|
||||
version = "2.11.3"
|
||||
@@ -3954,12 +4075,17 @@ checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
|
||||
name = "u-ppt"
|
||||
version = "1.0.0"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"futures",
|
||||
"hmac",
|
||||
"httpdate",
|
||||
"reqwest 0.12.28",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha1",
|
||||
"tauri",
|
||||
"tauri-build",
|
||||
"tauri-plugin-dialog",
|
||||
"tokio",
|
||||
"ts-rs",
|
||||
]
|
||||
@@ -4005,6 +4131,12 @@ dependencies = [
|
||||
"unic-common",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "unicase"
|
||||
version = "2.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-ident"
|
||||
version = "1.0.24"
|
||||
@@ -4544,6 +4676,15 @@ dependencies = [
|
||||
"windows-targets 0.52.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-sys"
|
||||
version = "0.60.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb"
|
||||
dependencies = [
|
||||
"windows-targets 0.53.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-sys"
|
||||
version = "0.61.2"
|
||||
@@ -4577,13 +4718,30 @@ dependencies = [
|
||||
"windows_aarch64_gnullvm 0.52.6",
|
||||
"windows_aarch64_msvc 0.52.6",
|
||||
"windows_i686_gnu 0.52.6",
|
||||
"windows_i686_gnullvm",
|
||||
"windows_i686_gnullvm 0.52.6",
|
||||
"windows_i686_msvc 0.52.6",
|
||||
"windows_x86_64_gnu 0.52.6",
|
||||
"windows_x86_64_gnullvm 0.52.6",
|
||||
"windows_x86_64_msvc 0.52.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-targets"
|
||||
version = "0.53.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3"
|
||||
dependencies = [
|
||||
"windows-link 0.2.1",
|
||||
"windows_aarch64_gnullvm 0.53.1",
|
||||
"windows_aarch64_msvc 0.53.1",
|
||||
"windows_i686_gnu 0.53.1",
|
||||
"windows_i686_gnullvm 0.53.1",
|
||||
"windows_i686_msvc 0.53.1",
|
||||
"windows_x86_64_gnu 0.53.1",
|
||||
"windows_x86_64_gnullvm 0.53.1",
|
||||
"windows_x86_64_msvc 0.53.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-threading"
|
||||
version = "0.1.0"
|
||||
@@ -4614,6 +4772,12 @@ version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
|
||||
|
||||
[[package]]
|
||||
name = "windows_aarch64_gnullvm"
|
||||
version = "0.53.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53"
|
||||
|
||||
[[package]]
|
||||
name = "windows_aarch64_msvc"
|
||||
version = "0.42.2"
|
||||
@@ -4626,6 +4790,12 @@ version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
|
||||
|
||||
[[package]]
|
||||
name = "windows_aarch64_msvc"
|
||||
version = "0.53.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_gnu"
|
||||
version = "0.42.2"
|
||||
@@ -4638,12 +4808,24 @@ version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_gnu"
|
||||
version = "0.53.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_gnullvm"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_gnullvm"
|
||||
version = "0.53.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_msvc"
|
||||
version = "0.42.2"
|
||||
@@ -4656,6 +4838,12 @@ version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_msvc"
|
||||
version = "0.53.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_gnu"
|
||||
version = "0.42.2"
|
||||
@@ -4668,6 +4856,12 @@ version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_gnu"
|
||||
version = "0.53.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_gnullvm"
|
||||
version = "0.42.2"
|
||||
@@ -4680,6 +4874,12 @@ version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_gnullvm"
|
||||
version = "0.53.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_msvc"
|
||||
version = "0.42.2"
|
||||
@@ -4692,6 +4892,12 @@ version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_msvc"
|
||||
version = "0.53.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650"
|
||||
|
||||
[[package]]
|
||||
name = "winnow"
|
||||
version = "0.5.40"
|
||||
|
||||
@@ -15,12 +15,17 @@ tauri-build = { version = "2", features = [] }
|
||||
|
||||
[dependencies]
|
||||
tauri = { version = "2", features = [] }
|
||||
tauri-plugin-dialog = "2"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
ts-rs = { version = "10", features = ["no-serde-warnings"] }
|
||||
reqwest = { version = "0.12", features = ["json", "stream"] }
|
||||
reqwest = { version = "0.12", features = ["json", "stream", "multipart"] }
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
futures = "0.3"
|
||||
hmac = "0.12"
|
||||
sha1 = "0.10"
|
||||
base64 = "0.22"
|
||||
httpdate = "1"
|
||||
|
||||
[features]
|
||||
export-types = []
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
"core:default",
|
||||
"core:event:default",
|
||||
"core:event:allow-listen",
|
||||
"core:event:allow-unlisten"
|
||||
"core:event:allow-unlisten",
|
||||
"dialog:default"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
* - AI 流式通过 emit("ai-delta", token) 实时推送到前端
|
||||
* - 前端检测 __TAURI__ 环境,Web 模式回退到 TS store
|
||||
* ===================================================================== */
|
||||
use base64::Engine;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tauri::State;
|
||||
use tauri::Emitter;
|
||||
@@ -247,3 +248,124 @@ pub fn write_file(path: String, content: String) -> Result<(), String> {
|
||||
pub fn read_file(path: String) -> Result<String, String> {
|
||||
std::fs::read_to_string(&path).map_err(|e| format!("读取失败: {}", e))
|
||||
}
|
||||
|
||||
/* ---------- 本地文件读取(桌面拖拽/系统对话框选择后的路径 → 字节) ---------- */
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct LocalFileData {
|
||||
pub name: String, // 文件名(不含目录)
|
||||
pub mime: String, // 按扩展名推断,未知为空串
|
||||
pub base64: String, // 文件字节的标准 base64
|
||||
}
|
||||
|
||||
/// 按扩展名推断 MIME 类型(未知返回空串)
|
||||
fn mime_by_ext(path: &std::path::Path) -> String {
|
||||
let ext = path
|
||||
.extension()
|
||||
.and_then(|e| e.to_str())
|
||||
.unwrap_or("")
|
||||
.to_ascii_lowercase();
|
||||
match ext.as_str() {
|
||||
"png" => "image/png",
|
||||
"jpg" | "jpeg" => "image/jpeg",
|
||||
"gif" => "image/gif",
|
||||
"webp" => "image/webp",
|
||||
"svg" => "image/svg+xml",
|
||||
"bmp" => "image/bmp",
|
||||
"ico" => "image/x-icon",
|
||||
"mp4" => "video/mp4",
|
||||
"webm" => "video/webm",
|
||||
"mov" => "video/quicktime",
|
||||
"avi" => "video/x-msvideo",
|
||||
"mkv" => "video/x-matroska",
|
||||
"pdf" => "application/pdf",
|
||||
"md" => "text/markdown",
|
||||
"txt" => "text/plain",
|
||||
"docx" => "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||
"doc" => "application/msword",
|
||||
"json" => "application/json",
|
||||
_ => "",
|
||||
}
|
||||
.to_string()
|
||||
}
|
||||
|
||||
/// 目录/文件名是否应跳过(隐藏项 + 常见垃圾目录)
|
||||
fn is_junk(name: &std::ffi::OsStr) -> bool {
|
||||
let name = name.to_string_lossy();
|
||||
name.starts_with('.')
|
||||
|| matches!(
|
||||
name.as_ref(),
|
||||
"node_modules" | "target" | "__MACOSX"
|
||||
)
|
||||
}
|
||||
|
||||
/// 递归展开目录:最大深度 3,跳过隐藏/垃圾目录,文件总数上限 200
|
||||
fn collect_files(
|
||||
path: &std::path::Path,
|
||||
depth: u32,
|
||||
out: &mut Vec<std::path::PathBuf>,
|
||||
) -> Result<(), String> {
|
||||
const MAX_FILES: usize = 200;
|
||||
if out.len() >= MAX_FILES {
|
||||
return Err("文件过多(>200),请分批选择".into());
|
||||
}
|
||||
if path.is_dir() {
|
||||
if depth >= 3 {
|
||||
return Ok(()); // 超过最大深度,不再下钻
|
||||
}
|
||||
let entries = std::fs::read_dir(path).map_err(|e| format!("读取目录失败: {}", e))?;
|
||||
for entry in entries {
|
||||
let entry = entry.map_err(|e| format!("读取目录失败: {}", e))?;
|
||||
let name = entry.file_name();
|
||||
if is_junk(&name) {
|
||||
continue;
|
||||
}
|
||||
collect_files(&entry.path(), depth + 1, out)?;
|
||||
}
|
||||
} else if path.is_file() {
|
||||
out.push(path.to_path_buf());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 批量读取本地文件。路径来自系统文件对话框或拖拽事件,由用户主动选择,
|
||||
/// 读取后内容以 base64 回传前端构造 File 对象。
|
||||
/// 目录会递归展开(深度 3,跳过隐藏与垃圾目录);单个文件读取失败直接跳过,全部失败才报错。
|
||||
#[tauri::command]
|
||||
pub async fn read_files(paths: Vec<String>) -> Result<Vec<LocalFileData>, String> {
|
||||
// 目录展开是纯磁盘 IO,放阻塞线程池避免卡 async 运行时
|
||||
let expanded = tokio::task::spawn_blocking(move || -> Result<Vec<std::path::PathBuf>, String> {
|
||||
let mut files = Vec::new();
|
||||
for p in &paths {
|
||||
collect_files(std::path::Path::new(p), 0, &mut files)?;
|
||||
}
|
||||
Ok(files)
|
||||
})
|
||||
.await
|
||||
.map_err(|e| format!("任务执行失败: {}", e))??;
|
||||
|
||||
let mut out = Vec::new();
|
||||
for path in expanded {
|
||||
let name = path
|
||||
.file_name()
|
||||
.map(|n| n.to_string_lossy().into_owned())
|
||||
.unwrap_or_default();
|
||||
let mime = mime_by_ext(&path);
|
||||
// 逐个读文件是阻塞 IO,同样放阻塞线程池
|
||||
let read = tokio::task::spawn_blocking(move || std::fs::read(&path))
|
||||
.await
|
||||
.map_err(|e| format!("任务执行失败: {}", e))?;
|
||||
if let Ok(bytes) = read {
|
||||
out.push(LocalFileData {
|
||||
name,
|
||||
mime,
|
||||
base64: base64::engine::general_purpose::STANDARD.encode(&bytes),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if out.is_empty() {
|
||||
return Err("没有可读取的文件".into());
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
@@ -6,11 +6,13 @@ mod color;
|
||||
mod commands;
|
||||
mod model;
|
||||
mod op;
|
||||
mod oss;
|
||||
|
||||
pub fn run() {
|
||||
let app_state = commands::AppState::new();
|
||||
|
||||
tauri::Builder::default()
|
||||
.plugin(tauri_plugin_dialog::init())
|
||||
.manage(app_state)
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
commands::ping,
|
||||
@@ -27,6 +29,8 @@ pub fn run() {
|
||||
commands::ai_proxy_stream,
|
||||
commands::write_file,
|
||||
commands::read_file,
|
||||
commands::read_files,
|
||||
oss::oss_upload,
|
||||
])
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running tauri application");
|
||||
|
||||
@@ -0,0 +1,287 @@
|
||||
/* =====================================================================
|
||||
* oss.rs — 对象存储上传(阿里云 OSS / 七牛云 Kodo)
|
||||
* 在 Rust 端发起 HTTP,规避浏览器 CORS 与密钥暴露:
|
||||
* - 阿里云:PUT Object + Authorization 头 V1 签名(HMAC-SHA1)
|
||||
* - 七牛云:表单直传 multipart/form-data + 上传凭证 UpToken
|
||||
* 前端通过 oss_upload 命令传入 provider/配置/对象 key/文件 base64,返回可访问 URL
|
||||
* ===================================================================== */
|
||||
|
||||
use base64::engine::general_purpose::{STANDARD as B64, URL_SAFE as B64_URLSAFE};
|
||||
use base64::Engine;
|
||||
use hmac::{Hmac, Mac};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sha1::Sha1;
|
||||
use std::collections::HashMap;
|
||||
use std::sync::{Mutex, OnceLock};
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
/// 七牛区域探测结果缓存(<ak>:<bucket> -> 上传地址)。
|
||||
/// 多文件批量上传时避免每个文件都多一次探测 HTTP 往返。
|
||||
static QINIU_REGION_CACHE: OnceLock<Mutex<HashMap<String, String>>> = OnceLock::new();
|
||||
|
||||
fn qiniu_region_cache() -> &'static Mutex<HashMap<String, String>> {
|
||||
QINIU_REGION_CACHE.get_or_init(|| Mutex::new(HashMap::new()))
|
||||
}
|
||||
|
||||
type HmacSha1 = Hmac<Sha1>;
|
||||
|
||||
/// 上传参数(provider 决定使用哪组字段)
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct OssUploadArgs {
|
||||
/// "aliyun" | "qiniu"
|
||||
pub provider: String,
|
||||
/// 对象 key(前端已拼好目录前缀,如 "images/2026/xxx.png")
|
||||
pub key: String,
|
||||
/// 文件字节的 base64(标准 base64,非 urlsafe)
|
||||
pub data_base64: String,
|
||||
/// MIME 类型(如 image/png);缺省 application/octet-stream
|
||||
pub content_type: Option<String>,
|
||||
|
||||
/* ---- 阿里云 OSS ---- */
|
||||
pub access_key_id: Option<String>,
|
||||
pub access_key_secret: Option<String>,
|
||||
/// endpoint,如 oss-cn-hangzhou.aliyuncs.com(不含 bucket、不含协议)
|
||||
pub endpoint: Option<String>,
|
||||
pub bucket: Option<String>,
|
||||
|
||||
/* ---- 七牛云 Kodo ---- */
|
||||
pub access_key: Option<String>,
|
||||
pub secret_key: Option<String>,
|
||||
/// 上传域名,如 https://upload.qiniup.com(缺省用该值)
|
||||
pub up_host: Option<String>,
|
||||
/// 绑定的公开访问域名,如 https://cdn.example.com(用于拼最终 URL)
|
||||
pub domain: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct OssUploadResult {
|
||||
pub url: String,
|
||||
}
|
||||
|
||||
fn now_unix() -> u64 {
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|d| d.as_secs())
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
/// RFC1123 GMT 日期(如 "Wed, 28 Dec 2022 10:27:41 GMT")
|
||||
fn http_date() -> String {
|
||||
httpdate::fmt_http_date(SystemTime::now())
|
||||
}
|
||||
|
||||
fn hmac_sha1(key: &[u8], msg: &[u8]) -> Vec<u8> {
|
||||
let mut mac = HmacSha1::new_from_slice(key).expect("HMAC 接受任意长度 key");
|
||||
mac.update(msg);
|
||||
mac.finalize().into_bytes().to_vec()
|
||||
}
|
||||
|
||||
/// 命令入口:按 provider 分发
|
||||
#[tauri::command]
|
||||
pub async fn oss_upload(args: OssUploadArgs) -> Result<OssUploadResult, String> {
|
||||
let bytes = B64
|
||||
.decode(args.data_base64.as_bytes())
|
||||
.map_err(|e| format!("文件数据解码失败: {}", e))?;
|
||||
let content_type = args
|
||||
.content_type
|
||||
.clone()
|
||||
.unwrap_or_else(|| "application/octet-stream".to_string());
|
||||
|
||||
match args.provider.as_str() {
|
||||
"aliyun" => upload_aliyun(&args, bytes, &content_type).await,
|
||||
"qiniu" => upload_qiniu(&args, bytes, &content_type).await,
|
||||
other => Err(format!("不支持的 OSS 服务商: {}", other)),
|
||||
}
|
||||
}
|
||||
|
||||
/// 阿里云 OSS:PUT Object,Authorization 头 V1 签名
|
||||
async fn upload_aliyun(
|
||||
args: &OssUploadArgs,
|
||||
bytes: Vec<u8>,
|
||||
content_type: &str,
|
||||
) -> Result<OssUploadResult, String> {
|
||||
let ak = args.access_key_id.as_deref().unwrap_or("").trim();
|
||||
let sk = args.access_key_secret.as_deref().unwrap_or("").trim();
|
||||
let endpoint = args.endpoint.as_deref().unwrap_or("").trim();
|
||||
let bucket = args.bucket.as_deref().unwrap_or("").trim();
|
||||
if ak.is_empty() || sk.is_empty() || endpoint.is_empty() || bucket.is_empty() {
|
||||
return Err("阿里云配置不完整(需 AccessKeyId/Secret/endpoint/bucket)".into());
|
||||
}
|
||||
|
||||
let key = args.key.trim_start_matches('/');
|
||||
let date = http_date();
|
||||
// 签名串:VERB\nContent-MD5\nContent-Type\nDate\nCanonicalizedResource
|
||||
// Content-MD5 留空;无 x-oss- 头
|
||||
let canonical_resource = format!("/{}/{}", bucket, key);
|
||||
let string_to_sign = format!(
|
||||
"PUT\n\n{}\n{}\n{}",
|
||||
content_type, date, canonical_resource
|
||||
);
|
||||
let signature = B64.encode(hmac_sha1(sk.as_bytes(), string_to_sign.as_bytes()));
|
||||
let authorization = format!("OSS {}:{}", ak, signature);
|
||||
|
||||
// endpoint 可能带协议,规整为纯 host
|
||||
let host = endpoint
|
||||
.trim_start_matches("https://")
|
||||
.trim_start_matches("http://")
|
||||
.trim_end_matches('/');
|
||||
let url = format!("https://{}.{}/{}", bucket, host, key);
|
||||
|
||||
let client = reqwest::Client::new();
|
||||
let resp = client
|
||||
.put(&url)
|
||||
.header("Date", &date)
|
||||
.header("Content-Type", content_type)
|
||||
.header("Authorization", &authorization)
|
||||
.body(bytes)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("上传请求失败: {}", e))?;
|
||||
|
||||
let status = resp.status();
|
||||
if !status.is_success() {
|
||||
let body = resp.text().await.unwrap_or_default();
|
||||
return Err(format!("阿里云返回 {}:{}", status.as_u16(), body));
|
||||
}
|
||||
Ok(OssUploadResult { url })
|
||||
}
|
||||
|
||||
/// 七牛云 Kodo:表单直传 multipart/form-data + UpToken
|
||||
async fn upload_qiniu(
|
||||
args: &OssUploadArgs,
|
||||
bytes: Vec<u8>,
|
||||
content_type: &str,
|
||||
) -> Result<OssUploadResult, String> {
|
||||
let ak = args.access_key.as_deref().unwrap_or("").trim();
|
||||
let sk = args.secret_key.as_deref().unwrap_or("").trim();
|
||||
let bucket = args.bucket.as_deref().unwrap_or("").trim();
|
||||
let domain = args.domain.as_deref().unwrap_or("").trim();
|
||||
if ak.is_empty() || sk.is_empty() || bucket.is_empty() || domain.is_empty() {
|
||||
return Err("七牛云配置不完整(需 AccessKey/SecretKey/bucket/domain)".into());
|
||||
}
|
||||
let up_host = match args
|
||||
.up_host
|
||||
.as_deref()
|
||||
.map(|s| s.trim())
|
||||
.filter(|s| !s.is_empty())
|
||||
{
|
||||
Some(h) => h.to_string(),
|
||||
// 未填写上传域名时自动探测区域:硬编码华东 upload.qiniup.com 会在
|
||||
// 华南(z2)等非华东 bucket 上报 incorrect region,故走官方查询接口
|
||||
None => qiniu_detect_up_host(ak, bucket).await?,
|
||||
};
|
||||
|
||||
let key = args.key.trim_start_matches('/');
|
||||
let token = qiniu_upload_token(ak, sk, bucket, key);
|
||||
|
||||
let file_part = reqwest::multipart::Part::bytes(bytes)
|
||||
.file_name(key.to_string())
|
||||
.mime_str(content_type)
|
||||
.map_err(|e| format!("构造上传表单失败: {}", e))?;
|
||||
let form = reqwest::multipart::Form::new()
|
||||
.text("token", token)
|
||||
.text("key", key.to_string())
|
||||
.part("file", file_part);
|
||||
|
||||
let client = reqwest::Client::new();
|
||||
let resp = client
|
||||
.post(up_host)
|
||||
.multipart(form)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("上传请求失败: {}", e))?;
|
||||
|
||||
let status = resp.status();
|
||||
let body = resp.text().await.unwrap_or_default();
|
||||
if !status.is_success() {
|
||||
return Err(format!("七牛云返回 {}:{}", status.as_u16(), body));
|
||||
}
|
||||
|
||||
// 拼公开访问 URL:domain/key
|
||||
let base = domain.trim_end_matches('/');
|
||||
let url = if base.starts_with("http://") || base.starts_with("https://") {
|
||||
format!("{}/{}", base, key)
|
||||
} else {
|
||||
format!("https://{}/{}", base, key)
|
||||
};
|
||||
Ok(OssUploadResult { url })
|
||||
}
|
||||
|
||||
/// 七牛区域探测:按 ak/bucket 查询官方 uc 接口,返回上传地址(https://<域名>)
|
||||
async fn qiniu_detect_up_host(ak: &str, bucket: &str) -> Result<String, String> {
|
||||
let cache_key = format!("{}:{}", ak, bucket);
|
||||
|
||||
// 先查缓存,命中直接返回
|
||||
if let Some(host) = qiniu_region_cache()
|
||||
.lock()
|
||||
.ok()
|
||||
.and_then(|map| map.get(&cache_key).cloned())
|
||||
{
|
||||
return Ok(host);
|
||||
}
|
||||
|
||||
// 公开接口无需签名;锁内只做 map 读写不 await
|
||||
let url = format!(
|
||||
"https://uc.qiniuapi.com/v4/query?ak={}&bucket={}",
|
||||
ak, bucket
|
||||
);
|
||||
let resp = reqwest::Client::new()
|
||||
.get(&url)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("无法自动探测七牛上传区域(可手动填写上传域名):{}", e))?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
return Err(format!(
|
||||
"无法自动探测七牛上传区域(可手动填写上传域名):接口返回 {}",
|
||||
resp.status().as_u16()
|
||||
));
|
||||
}
|
||||
|
||||
let json: serde_json::Value = resp
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| format!("无法自动探测七牛上传区域(可手动填写上传域名):{}", e))?;
|
||||
|
||||
// 取 hosts[0].up.domains 中第一个非空域名,健壮处理字段缺失/为空的情况
|
||||
let domain = json["hosts"]
|
||||
.as_array()
|
||||
.and_then(|hosts| hosts.first())
|
||||
.and_then(|h| h["up"]["domains"].as_array())
|
||||
.and_then(|domains| {
|
||||
domains
|
||||
.iter()
|
||||
.filter_map(|d| d.as_str())
|
||||
.find(|d| !d.trim().is_empty())
|
||||
})
|
||||
.map(|d| d.trim().to_string());
|
||||
let domain = match domain {
|
||||
Some(d) => d,
|
||||
None => {
|
||||
return Err(
|
||||
"无法自动探测七牛上传区域(可手动填写上传域名):接口未返回可用域名".into(),
|
||||
)
|
||||
}
|
||||
};
|
||||
|
||||
let host = format!("https://{}", domain);
|
||||
if let Ok(mut map) = qiniu_region_cache().lock() {
|
||||
map.insert(cache_key, host.clone());
|
||||
}
|
||||
Ok(host)
|
||||
}
|
||||
|
||||
/// 七牛上传凭证:AccessKey:urlsafe_b64(hmac_sha1(sk, encodedPolicy)):encodedPolicy
|
||||
fn qiniu_upload_token(ak: &str, sk: &str, bucket: &str, key: &str) -> String {
|
||||
let deadline = now_unix() + 3600; // 1 小时有效
|
||||
// scope 指定为 bucket:key,前端 key 必须与之一致
|
||||
let put_policy = format!(
|
||||
"{{\"scope\":\"{}:{}\",\"deadline\":{}}}",
|
||||
bucket, key, deadline
|
||||
);
|
||||
let encoded_policy = B64_URLSAFE.encode(put_policy.as_bytes());
|
||||
let sign = hmac_sha1(sk.as_bytes(), encoded_policy.as_bytes());
|
||||
let encoded_sign = B64_URLSAFE.encode(sign);
|
||||
format!("{}:{}:{}", ak, encoded_sign, encoded_policy)
|
||||
}
|
||||
Reference in New Issue
Block a user