diff --git a/package-lock.json b/package-lock.json index fde9714..b1404c8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,7 @@ "version": "1.0.0", "dependencies": { "@tauri-apps/api": "^2.11.1", + "@tauri-apps/plugin-dialog": "^2.7.2", "dompurify": "^3.4.14", "mammoth": "^1.12.0", "marked": "^18.0.10", @@ -1594,6 +1595,15 @@ "node": ">= 10" } }, + "node_modules/@tauri-apps/plugin-dialog": { + "version": "2.7.2", + "resolved": "https://registry.npmmirror.com/@tauri-apps/plugin-dialog/-/plugin-dialog-2.7.2.tgz", + "integrity": "sha512-pX0IGm1I3I6wc+zeKYcq1GSqogK6okCNX5fOdaNU5ab1AjGS6l1E5wFNjEb7meg7ZFSp0JUs+0jQGQNyOvLrsg==", + "license": "MIT OR Apache-2.0", + "dependencies": { + "@tauri-apps/api": "^2.11.0" + } + }, "node_modules/@types/chai": { "version": "5.2.3", "resolved": "https://registry.npmmirror.com/@types/chai/-/chai-5.2.3.tgz", diff --git a/package.json b/package.json index 170e712..7301ce5 100644 --- a/package.json +++ b/package.json @@ -16,6 +16,7 @@ }, "dependencies": { "@tauri-apps/api": "^2.11.1", + "@tauri-apps/plugin-dialog": "^2.7.2", "dompurify": "^3.4.14", "mammoth": "^1.12.0", "marked": "^18.0.10", diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 60db29d..eaf8ee6 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -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" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index effb197..f199696 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -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 = [] diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json index df30d26..3844061 100644 --- a/src-tauri/capabilities/default.json +++ b/src-tauri/capabilities/default.json @@ -7,6 +7,7 @@ "core:default", "core:event:default", "core:event:allow-listen", - "core:event:allow-unlisten" + "core:event:allow-unlisten", + "dialog:default" ] } diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 0ad4196..14d14c4 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -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 { 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, +) -> 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) -> Result, String> { + // 目录展开是纯磁盘 IO,放阻塞线程池避免卡 async 运行时 + let expanded = tokio::task::spawn_blocking(move || -> Result, 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) +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 00edc4e..41a1257 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -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"); diff --git a/src-tauri/src/oss.rs b/src-tauri/src/oss.rs new file mode 100644 index 0000000..18839d8 --- /dev/null +++ b/src-tauri/src/oss.rs @@ -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}; + +/// 七牛区域探测结果缓存(: -> 上传地址)。 +/// 多文件批量上传时避免每个文件都多一次探测 HTTP 往返。 +static QINIU_REGION_CACHE: OnceLock>> = OnceLock::new(); + +fn qiniu_region_cache() -> &'static Mutex> { + QINIU_REGION_CACHE.get_or_init(|| Mutex::new(HashMap::new())) +} + +type HmacSha1 = Hmac; + +/// 上传参数(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, + + /* ---- 阿里云 OSS ---- */ + pub access_key_id: Option, + pub access_key_secret: Option, + /// endpoint,如 oss-cn-hangzhou.aliyuncs.com(不含 bucket、不含协议) + pub endpoint: Option, + pub bucket: Option, + + /* ---- 七牛云 Kodo ---- */ + pub access_key: Option, + pub secret_key: Option, + /// 上传域名,如 https://upload.qiniup.com(缺省用该值) + pub up_host: Option, + /// 绑定的公开访问域名,如 https://cdn.example.com(用于拼最终 URL) + pub domain: Option, +} + +#[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 { + 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 { + 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, + content_type: &str, +) -> Result { + 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, + content_type: &str, +) -> Result { + 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 { + 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) +} diff --git a/src/App.vue b/src/App.vue index a1a80d4..9e4f55b 100644 --- a/src/App.vue +++ b/src/App.vue @@ -6,6 +6,8 @@ import { ref, computed, watch, nextTick, onMounted, onUnmounted } from 'vue' import { store } from './core/store' import { readAsDataURL, fileToImageElement } from './core/importer' import { checkImageQuota } from './core/ai' +import { putAsset, isOssEnabled } from './core/assets' +import { isTauri, readLocalFiles } from './core/bridge' import AppDialog from './components/common/AppDialog.vue' import FileDock from './components/common/FileDock.vue' import FilePreviewDrawer from './components/common/FilePreviewDrawer.vue' @@ -16,6 +18,7 @@ import Canvas from './components/editor/Canvas.vue' import PropsPanel from './components/editor/PropsPanel.vue' import AiPanel from './components/ai/AiPanel.vue' import SettingsModal from './components/modals/SettingsModal.vue' +import OssSettingsModal from './components/modals/OssSettingsModal.vue' import LibraryModal from './components/modals/LibraryModal.vue' import TemplateModal from './components/modals/TemplateModal.vue' import ImportModal from './components/modals/ImportModal.vue' @@ -36,6 +39,7 @@ function switchTab(name: 'props' | 'ai') { /* ---------- 弹窗 ---------- */ const settingsVisible = ref(false) +const ossVisible = ref(false) const libraryVisible = ref(false) const templateVisible = ref(false) const importVisible = ref(false) @@ -43,8 +47,8 @@ const printVisible = ref(false) const deckLoaded = ref(false) // 文库/导入是否加载了新 deck,防止误切 const importModalRef = ref | null>(null) -function dropFilesIntoImport(fl: FileList) { - importModalRef.value?.acceptDroppedFiles(fl) +function dropFilesIntoImport(files: File[]) { + importModalRef.value?.acceptDroppedFiles(files) } /* ---------- toast ---------- */ @@ -128,7 +132,40 @@ function onNewBlank() { mode.value = 'editor' } -function onImportMaterials() { importVisible.value = true } +function onImportMaterials() { + if (isTauri()) { + void (async () => { + try { + const { open } = await import('@tauri-apps/plugin-dialog') + const { ACCEPT_ATTR } = await import('./core/importer') + const sel = await open({ + multiple: true, + directory: false, + filters: [{ name: '资料', extensions: ACCEPT_ATTR.split(',').map(s => s.slice(1)) }] + }) + if (!sel) return // 用户取消 + const paths = Array.isArray(sel) ? sel : [sel] + const files = await readLocalFiles(paths) + if (!files.length) { toast('未能读取所选文件'); return } + // 单个 json 与拖拽同分流 + if (files.length === 1 && /\.json$/i.test(files[0].name)) { await importJsonFile(files[0]); return } + importVisible.value = true + await nextTick() + dropFilesIntoImport(files) + return + } catch (e: any) { + toast('打开文件选择器失败:' + (e?.message || String(e))) + } + importVisible.value = true + })() + return + } + importVisible.value = true +} + +function onHome() { + mode.value = 'home' +} function onImportClose() { importVisible.value = false @@ -215,6 +252,19 @@ onMounted(() => { window.addEventListener('dragover', onGlobalDragOver) window.addEventListener('drop', onGlobalDrop) window.addEventListener('dragleave', onGlobalDragLeave) + // 桌面原生拖拽通道(动态 import,Web 版不受影响) + if (isTauri()) { + void (async () => { + try { + const { getCurrentWebview } = await import('@tauri-apps/api/webview') + await getCurrentWebview().onDragDropEvent((ev) => { + if (ev.payload.type === 'drop') void onNativeDrop(ev.payload.paths) + }) + } catch (e: any) { + console.warn('原生拖拽监听失败:', e) + } + })() + } }) /* ---------- 系统剪贴板粘贴:按内容类型分发(截图/图片URL/文本) ---------- */ @@ -234,6 +284,14 @@ async function onPaste(e: ClipboardEvent) { const file = item.getAsFile() if (!file) return try { + // OSS 启用:粘贴图片走资产库(上云或离线暂存),content 存引用 + if (isOssEnabled()) { + const ref = await putAsset(file) + const el = fileToImageElement(file, ref) + store.addElement('image', { content: ref, x: el.x, y: el.y, w: el.w, h: el.h, style: el.style }) + toast('已插入剪贴板图片') + return + } const dataUrl = await readAsDataURL(file) const quotaErr = checkImageQuota(dataUrl) if (quotaErr) { toast(quotaErr); return } @@ -298,6 +356,18 @@ async function onPaste(e: ClipboardEvent) { } } +/* ---------- JSON 文件导入(浏览器选择器与桌面拖拽共用) ---------- */ +async function importJsonFile(file: File) { + const text = await file.text() + try { + store.importJSON(text) + toast('已导入') + if (mode.value === 'home') mode.value = 'editor' + } catch (err: any) { + toast('导入失败:' + (err?.message || String(err))) + } +} + /* ---------- 全局拖放:JSON 直接导入,其他文件进资料导入弹窗 ---------- */ const globalDragOver = ref(false) @@ -314,6 +384,8 @@ function onGlobalDragLeave(e: DragEvent) { } async function onGlobalDrop(e: DragEvent) { + // 桌面拖拽全权交给原生事件通道,防止双触发 + if (isTauri()) { e.preventDefault(); return } const fl = e.dataTransfer?.files if (!fl || fl.length === 0) return e.preventDefault() @@ -321,24 +393,32 @@ async function onGlobalDrop(e: DragEvent) { // 单个 .json 文件 → 直接导入 deck(与工具栏 📥 导入同路径) if (fl.length === 1 && /\.json$/i.test(fl[0].name)) { - const reader = new FileReader() - reader.onload = () => { - try { - store.importJSON(reader.result as string) - toast('已导入') - if (mode.value === 'home') mode.value = 'editor' - } catch (err: any) { - toast('导入失败:' + (err?.message || String(err))) - } - } - reader.readAsText(fl[0]) + await importJsonFile(fl[0]) return } // 其他文件 → 打开资料导入弹窗并预填 importVisible.value = true await nextTick() - dropFilesIntoImport(fl) + dropFilesIntoImport(Array.from(fl)) +} + +/* ---------- 桌面原生拖拽(Tauri onDragDropEvent 通道) ---------- */ +async function onNativeDrop(paths: string[]) { + if (!paths || paths.length === 0) return + const files = await readLocalFiles(paths) + if (!files.length) { toast('未能读取所选文件'); return } + + // 单个 .json → 直接导入 deck,与浏览器拖拽同分流 + if (files.length === 1 && /\.json$/i.test(files[0].name)) { + await importJsonFile(files[0]) + return + } + + // 其他文件 → 打开资料导入弹窗并预填 + importVisible.value = true + await nextTick() + dropFilesIntoImport(files) } /* 持久化失败告警 → toast(防止静默丢数据) */ @@ -373,6 +453,7 @@ onUnmounted(() => { @ai-create="onNewBlank(); switchTab('ai')" @toast="toast" @open-settings="settingsVisible = true" + @open-oss="ossVisible = true" /> @@ -382,10 +463,12 @@ onUnmounted(() => { @present="onPresent" @open-library="libraryVisible = true" @open-settings="settingsVisible = true" + @open-oss="ossVisible = true" @save="onSave" @open-templates="templateVisible = true" @export-json="onExportJson" @import-materials="onImportMaterials" + @home="onHome" @import-json="onImportJson" @export-pdf="onExportPdf" /> @@ -430,6 +513,11 @@ onUnmounted(() => { @close="settingsVisible = false" @toast="toast" /> + {
+
diff --git a/src/components/editor/ElementView.vue b/src/components/editor/ElementView.vue index 118841d..41dcd42 100644 --- a/src/components/editor/ElementView.vue +++ b/src/components/editor/ElementView.vue @@ -6,6 +6,7 @@ import { computed } from 'vue' import type { SlideElement, BgKey } from '../../core/types' import { store, resolveColor, isDarkBg } from '../../core/store' +import { resolveRef } from '../../core/assets' import { segmentsToHtml, markdownToSegments, hasFormatting } from '../../core/richtext' import ChartView from './ChartView.vue' @@ -85,6 +86,9 @@ const boxStyle = computed(() => { const dataList = computed(() => (props.el.content || '').split('\n')) +/** 媒体源解析:把 "asset:" 引用解析为可渲染 URL(本地 ObjectURL 或云端 URL),其余原样 */ +const mediaSrc = computed(() => resolveRef(props.el.content)) + /** 表格解析 */ const tableRows = computed(() => { const lines = (props.el.content || '').split('\n').map(l => l.trim()).filter(Boolean) @@ -272,7 +276,7 @@ function onBlur(e: Event, field: string) { 🖼 拖入图片 · 属性面板「本地图片」或 AI 配图 - + @@ -290,7 +294,7 @@ function onBlur(e: Event, field: string) {