feat(desktop): remember login in sqlite and streamline update flow
This commit is contained in:
@@ -101,7 +101,7 @@ const DOWNLOAD_SIGNED_URL_EXPIRES_SECONDS = Math.max(
|
|||||||
10,
|
10,
|
||||||
Math.min(3600, Number(process.env.DOWNLOAD_SIGNED_URL_EXPIRES_SECONDS || 30))
|
Math.min(3600, Number(process.env.DOWNLOAD_SIGNED_URL_EXPIRES_SECONDS || 30))
|
||||||
);
|
);
|
||||||
const DEFAULT_DESKTOP_VERSION = process.env.DESKTOP_LATEST_VERSION || '0.1.4';
|
const DEFAULT_DESKTOP_VERSION = process.env.DESKTOP_LATEST_VERSION || '0.1.5';
|
||||||
const DEFAULT_DESKTOP_INSTALLER_URL = process.env.DESKTOP_INSTALLER_URL || '';
|
const DEFAULT_DESKTOP_INSTALLER_URL = process.env.DESKTOP_INSTALLER_URL || '';
|
||||||
const DEFAULT_DESKTOP_RELEASE_NOTES = process.env.DESKTOP_RELEASE_NOTES || '';
|
const DEFAULT_DESKTOP_RELEASE_NOTES = process.env.DESKTOP_RELEASE_NOTES || '';
|
||||||
const DESKTOP_INSTALLERS_DIR = path.resolve(__dirname, '../frontend/downloads');
|
const DESKTOP_INSTALLERS_DIR = path.resolve(__dirname, '../frontend/downloads');
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "desktop-client",
|
"name": "desktop-client",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.1.4",
|
"version": "0.1.5",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|||||||
76
desktop-client/src-tauri/Cargo.lock
generated
76
desktop-client/src-tauri/Cargo.lock
generated
@@ -8,6 +8,18 @@ version = "2.0.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
|
checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "ahash"
|
||||||
|
version = "0.8.12"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75"
|
||||||
|
dependencies = [
|
||||||
|
"cfg-if",
|
||||||
|
"once_cell",
|
||||||
|
"version_check",
|
||||||
|
"zerocopy",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "aho-corasick"
|
name = "aho-corasick"
|
||||||
version = "1.1.4"
|
version = "1.1.4"
|
||||||
@@ -681,9 +693,10 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "desktop-client"
|
name = "desktop-client"
|
||||||
version = "0.1.4"
|
version = "0.1.5"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"reqwest 0.12.28",
|
"reqwest 0.12.28",
|
||||||
|
"rusqlite",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"tauri",
|
"tauri",
|
||||||
@@ -917,6 +930,18 @@ dependencies = [
|
|||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "fallible-iterator"
|
||||||
|
version = "0.3.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "fallible-streaming-iterator"
|
||||||
|
version = "0.1.9"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "fastrand"
|
name = "fastrand"
|
||||||
version = "2.3.0"
|
version = "2.3.0"
|
||||||
@@ -1423,6 +1448,15 @@ version = "0.12.3"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888"
|
checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "hashbrown"
|
||||||
|
version = "0.14.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1"
|
||||||
|
dependencies = [
|
||||||
|
"ahash",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "hashbrown"
|
name = "hashbrown"
|
||||||
version = "0.15.5"
|
version = "0.15.5"
|
||||||
@@ -1438,6 +1472,15 @@ version = "0.16.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100"
|
checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "hashlink"
|
||||||
|
version = "0.9.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "6ba4ff7128dee98c7dc9794b6a411377e1404dba1c97deb8d1a55297bd25d8af"
|
||||||
|
dependencies = [
|
||||||
|
"hashbrown 0.14.5",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "heck"
|
name = "heck"
|
||||||
version = "0.4.1"
|
version = "0.4.1"
|
||||||
@@ -1957,6 +2000,17 @@ dependencies = [
|
|||||||
"libc",
|
"libc",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "libsqlite3-sys"
|
||||||
|
version = "0.28.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "0c10584274047cb335c23d3e61bcef8e323adae7c5c8c760540f73610177fc3f"
|
||||||
|
dependencies = [
|
||||||
|
"cc",
|
||||||
|
"pkg-config",
|
||||||
|
"vcpkg",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "linux-raw-sys"
|
name = "linux-raw-sys"
|
||||||
version = "0.11.0"
|
version = "0.11.0"
|
||||||
@@ -3204,6 +3258,20 @@ dependencies = [
|
|||||||
"windows-sys 0.52.0",
|
"windows-sys 0.52.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rusqlite"
|
||||||
|
version = "0.31.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b838eba278d213a8beaf485bd313fd580ca4505a00d5871caeb1457c55322cae"
|
||||||
|
dependencies = [
|
||||||
|
"bitflags 2.11.0",
|
||||||
|
"fallible-iterator",
|
||||||
|
"fallible-streaming-iterator",
|
||||||
|
"hashlink",
|
||||||
|
"libsqlite3-sys",
|
||||||
|
"smallvec",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rustc-hash"
|
name = "rustc-hash"
|
||||||
version = "2.1.1"
|
version = "2.1.1"
|
||||||
@@ -4624,6 +4692,12 @@ dependencies = [
|
|||||||
"wasm-bindgen",
|
"wasm-bindgen",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "vcpkg"
|
||||||
|
version = "0.2.15"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "version-compare"
|
name = "version-compare"
|
||||||
version = "0.2.1"
|
version = "0.2.1"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "desktop-client"
|
name = "desktop-client"
|
||||||
version = "0.1.4"
|
version = "0.1.5"
|
||||||
description = "A Tauri App"
|
description = "A Tauri App"
|
||||||
authors = ["you"]
|
authors = ["you"]
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
@@ -26,3 +26,4 @@ serde_json = "1"
|
|||||||
reqwest = { version = "0.12", default-features = false, features = ["json", "cookies", "multipart", "stream", "rustls-tls"] }
|
reqwest = { version = "0.12", default-features = false, features = ["json", "cookies", "multipart", "stream", "rustls-tls"] }
|
||||||
urlencoding = "2.1"
|
urlencoding = "2.1"
|
||||||
walkdir = "2.5"
|
walkdir = "2.5"
|
||||||
|
rusqlite = { version = "0.31", features = ["bundled"] }
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
use reqwest::Method;
|
use reqwest::Method;
|
||||||
use reqwest::StatusCode;
|
use reqwest::StatusCode;
|
||||||
|
use rusqlite::{params, Connection};
|
||||||
use serde::Serialize;
|
use serde::Serialize;
|
||||||
use serde_json::{Map, Value};
|
use serde_json::{Map, Value};
|
||||||
use std::env;
|
use std::env;
|
||||||
@@ -8,7 +9,7 @@ use std::io::Write;
|
|||||||
use std::io::{Read, Seek, SeekFrom};
|
use std::io::{Read, Seek, SeekFrom};
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
use std::process::Command;
|
use std::process::Command;
|
||||||
use std::time::{Duration, Instant, UNIX_EPOCH};
|
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
|
||||||
use tauri::Emitter;
|
use tauri::Emitter;
|
||||||
|
|
||||||
struct ApiState {
|
struct ApiState {
|
||||||
@@ -34,6 +35,16 @@ struct NativeDownloadProgressPayload {
|
|||||||
done: bool,
|
done: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Clone)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
struct NativeUploadProgressPayload {
|
||||||
|
task_id: String,
|
||||||
|
uploaded_bytes: u64,
|
||||||
|
total_bytes: u64,
|
||||||
|
progress: f64,
|
||||||
|
done: bool,
|
||||||
|
}
|
||||||
|
|
||||||
fn emit_native_download_progress(
|
fn emit_native_download_progress(
|
||||||
window: &tauri::WebviewWindow,
|
window: &tauri::WebviewWindow,
|
||||||
task_id: &str,
|
task_id: &str,
|
||||||
@@ -64,6 +75,32 @@ fn emit_native_download_progress(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn emit_native_upload_progress(
|
||||||
|
window: &tauri::WebviewWindow,
|
||||||
|
task_id: &str,
|
||||||
|
uploaded_bytes: u64,
|
||||||
|
total_bytes: u64,
|
||||||
|
done: bool,
|
||||||
|
) {
|
||||||
|
if task_id.trim().is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let normalized_total = total_bytes.max(1);
|
||||||
|
let progress = (uploaded_bytes as f64 / normalized_total as f64) * 100.0;
|
||||||
|
let payload = NativeUploadProgressPayload {
|
||||||
|
task_id: task_id.to_string(),
|
||||||
|
uploaded_bytes,
|
||||||
|
total_bytes,
|
||||||
|
progress,
|
||||||
|
done,
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Err(err) = window.emit("native-upload-progress", payload) {
|
||||||
|
eprintln!("emit native-upload-progress failed: {}", err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn normalize_base_url(base_url: &str) -> String {
|
fn normalize_base_url(base_url: &str) -> String {
|
||||||
let trimmed = base_url.trim();
|
let trimmed = base_url.trim();
|
||||||
if trimmed.is_empty() {
|
if trimmed.is_empty() {
|
||||||
@@ -163,6 +200,81 @@ fn build_download_resume_temp_path(download_dir: &Path, preferred_name: &str, ur
|
|||||||
download_dir.join(temp_name)
|
download_dir.join(temp_name)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn resolve_local_state_dir() -> PathBuf {
|
||||||
|
if let Some(appdata) = env::var_os("APPDATA") {
|
||||||
|
return PathBuf::from(appdata).join("wanwan-cloud-desktop");
|
||||||
|
}
|
||||||
|
if let Some(home) = env::var_os("HOME") {
|
||||||
|
return PathBuf::from(home).join(".wanwan-cloud-desktop");
|
||||||
|
}
|
||||||
|
PathBuf::from(".").join(".wanwan-cloud-desktop")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn open_local_state_db() -> Result<Connection, String> {
|
||||||
|
let state_dir = resolve_local_state_dir();
|
||||||
|
fs::create_dir_all(&state_dir).map_err(|err| format!("创建本地状态目录失败: {}", err))?;
|
||||||
|
let db_path = state_dir.join("client_state.db");
|
||||||
|
let conn = Connection::open(db_path).map_err(|err| format!("打开本地状态数据库失败: {}", err))?;
|
||||||
|
conn.execute(
|
||||||
|
"CREATE TABLE IF NOT EXISTS login_state (
|
||||||
|
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||||
|
base_url TEXT NOT NULL,
|
||||||
|
username TEXT NOT NULL,
|
||||||
|
password TEXT NOT NULL,
|
||||||
|
updated_at INTEGER NOT NULL
|
||||||
|
)",
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
.map_err(|err| format!("初始化本地状态表失败: {}", err))?;
|
||||||
|
Ok(conn)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn save_login_state_record(base_url: &str, username: &str, password: &str) -> Result<(), String> {
|
||||||
|
let conn = open_local_state_db()?;
|
||||||
|
let now = SystemTime::now()
|
||||||
|
.duration_since(UNIX_EPOCH)
|
||||||
|
.map(|duration| duration.as_secs() as i64)
|
||||||
|
.unwrap_or_default();
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO login_state (id, base_url, username, password, updated_at)
|
||||||
|
VALUES (1, ?1, ?2, ?3, ?4)
|
||||||
|
ON CONFLICT(id) DO UPDATE SET
|
||||||
|
base_url = excluded.base_url,
|
||||||
|
username = excluded.username,
|
||||||
|
password = excluded.password,
|
||||||
|
updated_at = excluded.updated_at",
|
||||||
|
params![base_url, username, password, now],
|
||||||
|
)
|
||||||
|
.map_err(|err| format!("保存登录状态失败: {}", err))?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn load_login_state_record() -> Result<Option<(String, String, String)>, String> {
|
||||||
|
let conn = open_local_state_db()?;
|
||||||
|
let mut stmt = conn
|
||||||
|
.prepare("SELECT base_url, username, password FROM login_state WHERE id = 1 LIMIT 1")
|
||||||
|
.map_err(|err| format!("读取登录状态失败: {}", err))?;
|
||||||
|
let row = stmt.query_row([], |record| {
|
||||||
|
Ok((
|
||||||
|
record.get::<_, String>(0)?,
|
||||||
|
record.get::<_, String>(1)?,
|
||||||
|
record.get::<_, String>(2)?,
|
||||||
|
))
|
||||||
|
});
|
||||||
|
match row {
|
||||||
|
Ok(value) => Ok(Some(value)),
|
||||||
|
Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
|
||||||
|
Err(err) => Err(format!("读取登录状态失败: {}", err)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn clear_login_state_record() -> Result<(), String> {
|
||||||
|
let conn = open_local_state_db()?;
|
||||||
|
conn.execute("DELETE FROM login_state WHERE id = 1", [])
|
||||||
|
.map_err(|err| format!("清除登录状态失败: {}", err))?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
async fn parse_response_as_bridge(response: reqwest::Response) -> Result<BridgeResponse, String> {
|
async fn parse_response_as_bridge(response: reqwest::Response) -> Result<BridgeResponse, String> {
|
||||||
let status = response.status();
|
let status = response.status();
|
||||||
let text = response
|
let text = response
|
||||||
@@ -314,6 +426,70 @@ async fn api_get_profile(
|
|||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
fn api_save_login_state(
|
||||||
|
base_url: String,
|
||||||
|
username: String,
|
||||||
|
password: String,
|
||||||
|
) -> Result<BridgeResponse, String> {
|
||||||
|
let normalized_base = normalize_base_url(&base_url);
|
||||||
|
let normalized_user = username.trim().to_string();
|
||||||
|
if normalized_base.is_empty() {
|
||||||
|
return Err("服务地址不能为空".to_string());
|
||||||
|
}
|
||||||
|
if normalized_user.is_empty() {
|
||||||
|
return Err("用户名不能为空".to_string());
|
||||||
|
}
|
||||||
|
if password.trim().is_empty() {
|
||||||
|
return Err("密码不能为空".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
save_login_state_record(&normalized_base, &normalized_user, &password)?;
|
||||||
|
|
||||||
|
let mut data = Map::new();
|
||||||
|
data.insert("success".to_string(), Value::Bool(true));
|
||||||
|
data.insert("message".to_string(), Value::String("登录状态已保存".to_string()));
|
||||||
|
Ok(BridgeResponse {
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
data: Value::Object(data),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
fn api_load_login_state() -> Result<BridgeResponse, String> {
|
||||||
|
let state = load_login_state_record()?;
|
||||||
|
let mut data = Map::new();
|
||||||
|
data.insert("success".to_string(), Value::Bool(true));
|
||||||
|
if let Some((base_url, username, password)) = state {
|
||||||
|
data.insert("hasState".to_string(), Value::Bool(true));
|
||||||
|
data.insert("baseUrl".to_string(), Value::String(base_url));
|
||||||
|
data.insert("username".to_string(), Value::String(username));
|
||||||
|
data.insert("password".to_string(), Value::String(password));
|
||||||
|
} else {
|
||||||
|
data.insert("hasState".to_string(), Value::Bool(false));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(BridgeResponse {
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
data: Value::Object(data),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
fn api_clear_login_state() -> Result<BridgeResponse, String> {
|
||||||
|
clear_login_state_record()?;
|
||||||
|
let mut data = Map::new();
|
||||||
|
data.insert("success".to_string(), Value::Bool(true));
|
||||||
|
data.insert("message".to_string(), Value::String("登录状态已清除".to_string()));
|
||||||
|
Ok(BridgeResponse {
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
data: Value::Object(data),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
async fn api_list_files(
|
async fn api_list_files(
|
||||||
state: tauri::State<'_, ApiState>,
|
state: tauri::State<'_, ApiState>,
|
||||||
@@ -820,6 +996,79 @@ fn api_launch_installer(installer_path: String) -> Result<BridgeResponse, String
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
fn api_silent_install_and_restart(installer_path: String) -> Result<BridgeResponse, String> {
|
||||||
|
let path_text = installer_path.trim().to_string();
|
||||||
|
if path_text.is_empty() {
|
||||||
|
return Err("安装包路径不能为空".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
let installer = PathBuf::from(&path_text);
|
||||||
|
if !installer.exists() {
|
||||||
|
return Err("安装包不存在,请重新下载".to_string());
|
||||||
|
}
|
||||||
|
if !installer.is_file() {
|
||||||
|
return Err("安装包路径无效".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
{
|
||||||
|
let current_exe = env::current_exe().map_err(|err| format!("获取当前程序路径失败: {}", err))?;
|
||||||
|
let temp_dir = env::temp_dir().join("wanwan-cloud-desktop");
|
||||||
|
fs::create_dir_all(&temp_dir).map_err(|err| format!("创建更新脚本目录失败: {}", err))?;
|
||||||
|
let script_stamp = SystemTime::now()
|
||||||
|
.duration_since(UNIX_EPOCH)
|
||||||
|
.map(|duration| duration.as_millis())
|
||||||
|
.unwrap_or_default();
|
||||||
|
let script_path = temp_dir.join(format!("silent-update-{}.cmd", script_stamp));
|
||||||
|
|
||||||
|
let installer_text = installer.to_string_lossy().replace('"', "\"\"");
|
||||||
|
let app_text = current_exe.to_string_lossy().replace('"', "\"\"");
|
||||||
|
let script_content = format!(
|
||||||
|
"@echo off\r\n\
|
||||||
|
setlocal\r\n\
|
||||||
|
set \"INSTALLER={installer}\"\r\n\
|
||||||
|
set \"APP_EXE={app_exe}\"\r\n\
|
||||||
|
timeout /t 2 /nobreak >nul\r\n\
|
||||||
|
start \"\" /wait \"%INSTALLER%\" /S\r\n\
|
||||||
|
start \"\" \"%APP_EXE%\"\r\n\
|
||||||
|
del \"%~f0\" >nul 2>nul\r\n",
|
||||||
|
installer = installer_text,
|
||||||
|
app_exe = app_text
|
||||||
|
);
|
||||||
|
fs::write(&script_path, script_content).map_err(|err| format!("写入更新脚本失败: {}", err))?;
|
||||||
|
|
||||||
|
let script_arg = script_path.to_string_lossy().to_string();
|
||||||
|
Command::new("cmd")
|
||||||
|
.args(["/C", "start", "", "/min", &script_arg])
|
||||||
|
.spawn()
|
||||||
|
.map_err(|err| format!("启动静默更新流程失败: {}", err))?;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(target_os = "windows"))]
|
||||||
|
{
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
let spawn_result = Command::new("open").arg(&installer).spawn();
|
||||||
|
#[cfg(all(not(target_os = "windows"), not(target_os = "macos")))]
|
||||||
|
let spawn_result = Command::new("xdg-open").arg(&installer).spawn();
|
||||||
|
spawn_result.map_err(|err| format!("启动安装程序失败: {}", err))?;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut data = Map::new();
|
||||||
|
data.insert("success".to_string(), Value::Bool(true));
|
||||||
|
data.insert(
|
||||||
|
"message".to_string(),
|
||||||
|
Value::String("静默安装流程已启动,安装完成后将自动重启".to_string()),
|
||||||
|
);
|
||||||
|
data.insert("installerPath".to_string(), Value::String(path_text));
|
||||||
|
|
||||||
|
Ok(BridgeResponse {
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
data: Value::Object(data),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
async fn api_check_client_update(
|
async fn api_check_client_update(
|
||||||
state: tauri::State<'_, ApiState>,
|
state: tauri::State<'_, ApiState>,
|
||||||
@@ -939,10 +1188,12 @@ async fn api_list_local_files(dir_path: String) -> Result<BridgeResponse, String
|
|||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
async fn api_upload_file_resumable(
|
async fn api_upload_file_resumable(
|
||||||
state: tauri::State<'_, ApiState>,
|
state: tauri::State<'_, ApiState>,
|
||||||
|
window: tauri::WebviewWindow,
|
||||||
base_url: String,
|
base_url: String,
|
||||||
file_path: String,
|
file_path: String,
|
||||||
target_path: String,
|
target_path: String,
|
||||||
chunk_size: Option<u64>,
|
chunk_size: Option<u64>,
|
||||||
|
task_id: Option<String>,
|
||||||
) -> Result<BridgeResponse, String> {
|
) -> Result<BridgeResponse, String> {
|
||||||
let trimmed_path = file_path.trim().to_string();
|
let trimmed_path = file_path.trim().to_string();
|
||||||
if trimmed_path.is_empty() {
|
if trimmed_path.is_empty() {
|
||||||
@@ -1030,7 +1281,18 @@ async fn api_upload_file_resumable(
|
|||||||
})
|
})
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
|
|
||||||
|
let mut uploaded_bytes = uploaded_chunks.iter().fold(0_u64, |sum, chunk_index| {
|
||||||
|
let offset = chunk_index.saturating_mul(server_chunk_size);
|
||||||
|
let remaining = file_size.saturating_sub(offset);
|
||||||
|
let bytes = std::cmp::min(remaining, server_chunk_size);
|
||||||
|
sum.saturating_add(bytes)
|
||||||
|
});
|
||||||
|
if let Some(ref id) = task_id {
|
||||||
|
emit_native_upload_progress(&window, id, uploaded_bytes, file_size, false);
|
||||||
|
}
|
||||||
|
|
||||||
let mut source = fs::File::open(&source_path).map_err(|err| format!("打开文件失败: {}", err))?;
|
let mut source = fs::File::open(&source_path).map_err(|err| format!("打开文件失败: {}", err))?;
|
||||||
|
let mut last_emit = Instant::now();
|
||||||
for chunk_index in 0..total_chunks {
|
for chunk_index in 0..total_chunks {
|
||||||
if uploaded_chunks.contains(&chunk_index) {
|
if uploaded_chunks.contains(&chunk_index) {
|
||||||
continue;
|
continue;
|
||||||
@@ -1078,26 +1340,44 @@ async fn api_upload_file_resumable(
|
|||||||
if !chunk_bridge.ok || !chunk_bridge.data.get("success").and_then(Value::as_bool).unwrap_or(false) {
|
if !chunk_bridge.ok || !chunk_bridge.data.get("success").and_then(Value::as_bool).unwrap_or(false) {
|
||||||
return Ok(chunk_bridge);
|
return Ok(chunk_bridge);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
uploaded_bytes = uploaded_bytes.saturating_add(read_size as u64).min(file_size);
|
||||||
|
if let Some(ref id) = task_id {
|
||||||
|
if last_emit.elapsed() >= Duration::from_millis(120) {
|
||||||
|
emit_native_upload_progress(&window, id, uploaded_bytes, file_size, false);
|
||||||
|
last_emit = Instant::now();
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut complete_body = Map::new();
|
let mut complete_body = Map::new();
|
||||||
complete_body.insert("session_id".to_string(), Value::String(session_id));
|
complete_body.insert("session_id".to_string(), Value::String(session_id));
|
||||||
request_json(
|
let complete_resp = request_json(
|
||||||
&state.client,
|
&state.client,
|
||||||
Method::POST,
|
Method::POST,
|
||||||
join_api_url(&base_url, "/api/upload/resumable/complete"),
|
join_api_url(&base_url, "/api/upload/resumable/complete"),
|
||||||
Some(Value::Object(complete_body)),
|
Some(Value::Object(complete_body)),
|
||||||
csrf_token,
|
csrf_token,
|
||||||
)
|
)
|
||||||
.await
|
.await?;
|
||||||
|
|
||||||
|
if complete_resp.ok && complete_resp.data.get("success").and_then(Value::as_bool).unwrap_or(false) {
|
||||||
|
if let Some(ref id) = task_id {
|
||||||
|
emit_native_upload_progress(&window, id, file_size, file_size, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(complete_resp)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
async fn api_upload_file(
|
async fn api_upload_file(
|
||||||
state: tauri::State<'_, ApiState>,
|
state: tauri::State<'_, ApiState>,
|
||||||
|
window: tauri::WebviewWindow,
|
||||||
base_url: String,
|
base_url: String,
|
||||||
file_path: String,
|
file_path: String,
|
||||||
target_path: String,
|
target_path: String,
|
||||||
|
task_id: Option<String>,
|
||||||
) -> Result<BridgeResponse, String> {
|
) -> Result<BridgeResponse, String> {
|
||||||
let trimmed_path = file_path.trim().to_string();
|
let trimmed_path = file_path.trim().to_string();
|
||||||
if trimmed_path.is_empty() {
|
if trimmed_path.is_empty() {
|
||||||
@@ -1111,6 +1391,9 @@ async fn api_upload_file(
|
|||||||
if !source_path.is_file() {
|
if !source_path.is_file() {
|
||||||
return Err("仅支持上传文件,不支持文件夹".to_string());
|
return Err("仅支持上传文件,不支持文件夹".to_string());
|
||||||
}
|
}
|
||||||
|
let file_size = fs::metadata(&source_path)
|
||||||
|
.map(|meta| meta.len())
|
||||||
|
.unwrap_or(0);
|
||||||
|
|
||||||
let file_name = source_path
|
let file_name = source_path
|
||||||
.file_name()
|
.file_name()
|
||||||
@@ -1129,6 +1412,10 @@ async fn api_upload_file(
|
|||||||
return Err("API 地址不能为空".to_string());
|
return Err("API 地址不能为空".to_string());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if let Some(ref id) = task_id {
|
||||||
|
emit_native_upload_progress(&window, id, 0, file_size.max(1), false);
|
||||||
|
}
|
||||||
|
|
||||||
// 使用流式 multipart 上传,避免大文件整块读入内存导致占用暴涨。
|
// 使用流式 multipart 上传,避免大文件整块读入内存导致占用暴涨。
|
||||||
let file_part = reqwest::multipart::Part::file(&source_path)
|
let file_part = reqwest::multipart::Part::file(&source_path)
|
||||||
.await
|
.await
|
||||||
@@ -1164,6 +1451,12 @@ async fn api_upload_file(
|
|||||||
Ok(parsed) => parsed,
|
Ok(parsed) => parsed,
|
||||||
Err(_) => fallback_json(status, &text),
|
Err(_) => fallback_json(status, &text),
|
||||||
};
|
};
|
||||||
|
let success = status.is_success() && data.get("success").and_then(Value::as_bool).unwrap_or(false);
|
||||||
|
if success {
|
||||||
|
if let Some(ref id) = task_id {
|
||||||
|
emit_native_upload_progress(&window, id, file_size, file_size.max(1), true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Ok(BridgeResponse {
|
Ok(BridgeResponse {
|
||||||
ok: status.is_success(),
|
ok: status.is_success(),
|
||||||
@@ -1186,6 +1479,9 @@ pub fn run() {
|
|||||||
.plugin(tauri_plugin_opener::init())
|
.plugin(tauri_plugin_opener::init())
|
||||||
.invoke_handler(tauri::generate_handler![
|
.invoke_handler(tauri::generate_handler![
|
||||||
api_login,
|
api_login,
|
||||||
|
api_save_login_state,
|
||||||
|
api_load_login_state,
|
||||||
|
api_clear_login_state,
|
||||||
api_get_profile,
|
api_get_profile,
|
||||||
api_list_files,
|
api_list_files,
|
||||||
api_logout,
|
api_logout,
|
||||||
@@ -1200,6 +1496,7 @@ pub fn run() {
|
|||||||
api_create_direct_link,
|
api_create_direct_link,
|
||||||
api_native_download,
|
api_native_download,
|
||||||
api_launch_installer,
|
api_launch_installer,
|
||||||
|
api_silent_install_and_restart,
|
||||||
api_check_client_update,
|
api_check_client_update,
|
||||||
api_list_local_files,
|
api_list_local_files,
|
||||||
api_upload_file_resumable,
|
api_upload_file_resumable,
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://schema.tauri.app/config/2",
|
"$schema": "https://schema.tauri.app/config/2",
|
||||||
"productName": "wanwan-cloud-desktop",
|
"productName": "wanwan-cloud-desktop",
|
||||||
"version": "0.1.4",
|
"version": "0.1.5",
|
||||||
"identifier": "cn.workyai.wanwancloud.desktop",
|
"identifier": "cn.workyai.wanwancloud.desktop",
|
||||||
"build": {
|
"build": {
|
||||||
"beforeDevCommand": "npm run dev",
|
"beforeDevCommand": "npm run dev",
|
||||||
|
|||||||
@@ -50,6 +50,14 @@ type NativeDownloadProgressEvent = {
|
|||||||
done?: boolean;
|
done?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type NativeUploadProgressEvent = {
|
||||||
|
taskId?: string;
|
||||||
|
uploadedBytes?: number;
|
||||||
|
totalBytes?: number;
|
||||||
|
progress?: number;
|
||||||
|
done?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
type LocalSyncFileItem = {
|
type LocalSyncFileItem = {
|
||||||
path: string;
|
path: string;
|
||||||
relativePath: string;
|
relativePath: string;
|
||||||
@@ -85,6 +93,7 @@ const loginForm = reactive({
|
|||||||
username: "",
|
username: "",
|
||||||
password: "",
|
password: "",
|
||||||
captcha: "",
|
captcha: "",
|
||||||
|
remember: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
const loginState = reactive({
|
const loginState = reactive({
|
||||||
@@ -132,7 +141,7 @@ const syncState = reactive({
|
|||||||
nextRunAt: "",
|
nextRunAt: "",
|
||||||
});
|
});
|
||||||
const updateState = reactive({
|
const updateState = reactive({
|
||||||
currentVersion: "0.1.4",
|
currentVersion: "0.1.5",
|
||||||
latestVersion: "",
|
latestVersion: "",
|
||||||
available: false,
|
available: false,
|
||||||
mandatory: false,
|
mandatory: false,
|
||||||
@@ -144,6 +153,19 @@ const updateState = reactive({
|
|||||||
});
|
});
|
||||||
const updateRuntime = reactive({
|
const updateRuntime = reactive({
|
||||||
downloading: false,
|
downloading: false,
|
||||||
|
installing: false,
|
||||||
|
taskId: "",
|
||||||
|
downloadedBytes: 0,
|
||||||
|
totalBytes: 0,
|
||||||
|
progress: 0,
|
||||||
|
speed: "-",
|
||||||
|
lastMeasureAt: 0,
|
||||||
|
lastMeasureBytes: 0,
|
||||||
|
installerPath: "",
|
||||||
|
});
|
||||||
|
const updatePrompt = reactive({
|
||||||
|
visible: false,
|
||||||
|
loading: false,
|
||||||
});
|
});
|
||||||
const contextMenu = reactive({
|
const contextMenu = reactive({
|
||||||
visible: false,
|
visible: false,
|
||||||
@@ -163,9 +185,22 @@ const dropState = reactive({
|
|||||||
done: 0,
|
done: 0,
|
||||||
failed: 0,
|
failed: 0,
|
||||||
});
|
});
|
||||||
|
const uploadRuntime = reactive({
|
||||||
|
active: false,
|
||||||
|
taskId: "",
|
||||||
|
fileName: "",
|
||||||
|
uploadedBytes: 0,
|
||||||
|
totalBytes: 0,
|
||||||
|
progress: 0,
|
||||||
|
speed: "-",
|
||||||
|
lastMeasureAt: 0,
|
||||||
|
lastMeasureBytes: 0,
|
||||||
|
});
|
||||||
let unlistenDragDrop: UnlistenFn | null = null;
|
let unlistenDragDrop: UnlistenFn | null = null;
|
||||||
let unlistenNativeDownloadProgress: UnlistenFn | null = null;
|
let unlistenNativeDownloadProgress: UnlistenFn | null = null;
|
||||||
|
let unlistenNativeUploadProgress: UnlistenFn | null = null;
|
||||||
let syncTimer: ReturnType<typeof setInterval> | null = null;
|
let syncTimer: ReturnType<typeof setInterval> | null = null;
|
||||||
|
let hasCheckedUpdateAfterAuth = false;
|
||||||
|
|
||||||
const toast = reactive({
|
const toast = reactive({
|
||||||
visible: false,
|
visible: false,
|
||||||
@@ -363,6 +398,46 @@ function fileTypeLabel(item: FileItem) {
|
|||||||
return "文件";
|
return "文件";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatSpeed(bytesPerSecond: number) {
|
||||||
|
if (!Number.isFinite(bytesPerSecond) || bytesPerSecond <= 0) return "-";
|
||||||
|
return `${formatBytes(bytesPerSecond)}/s`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function measureRate(
|
||||||
|
currentBytes: number,
|
||||||
|
state: { lastMeasureAt: number; lastMeasureBytes: number },
|
||||||
|
) {
|
||||||
|
const now = Date.now();
|
||||||
|
if (!state.lastMeasureAt) {
|
||||||
|
state.lastMeasureAt = now;
|
||||||
|
state.lastMeasureBytes = currentBytes;
|
||||||
|
return "-";
|
||||||
|
}
|
||||||
|
const elapsedMs = now - state.lastMeasureAt;
|
||||||
|
if (elapsedMs < 180) {
|
||||||
|
return "-";
|
||||||
|
}
|
||||||
|
const deltaBytes = Math.max(0, currentBytes - state.lastMeasureBytes);
|
||||||
|
state.lastMeasureAt = now;
|
||||||
|
state.lastMeasureBytes = currentBytes;
|
||||||
|
if (deltaBytes <= 0) return "0 B/s";
|
||||||
|
const bytesPerSecond = (deltaBytes * 1000) / elapsedMs;
|
||||||
|
return formatSpeed(bytesPerSecond);
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetUpdateRuntime() {
|
||||||
|
updateRuntime.downloading = false;
|
||||||
|
updateRuntime.installing = false;
|
||||||
|
updateRuntime.taskId = "";
|
||||||
|
updateRuntime.downloadedBytes = 0;
|
||||||
|
updateRuntime.totalBytes = 0;
|
||||||
|
updateRuntime.progress = 0;
|
||||||
|
updateRuntime.speed = "-";
|
||||||
|
updateRuntime.lastMeasureAt = 0;
|
||||||
|
updateRuntime.lastMeasureBytes = 0;
|
||||||
|
updateRuntime.installerPath = "";
|
||||||
|
}
|
||||||
|
|
||||||
function applyNativeDownloadProgress(payload: NativeDownloadProgressEvent) {
|
function applyNativeDownloadProgress(payload: NativeDownloadProgressEvent) {
|
||||||
const taskId = String(payload?.taskId || "").trim();
|
const taskId = String(payload?.taskId || "").trim();
|
||||||
if (!taskId) return;
|
if (!taskId) return;
|
||||||
@@ -378,6 +453,23 @@ function applyNativeDownloadProgress(payload: NativeDownloadProgressEvent) {
|
|||||||
? Math.max(1, Math.min(payload?.done ? 100 : 99.8, calculatedProgress))
|
? Math.max(1, Math.min(payload?.done ? 100 : 99.8, calculatedProgress))
|
||||||
: NaN;
|
: NaN;
|
||||||
|
|
||||||
|
if (taskId === updateRuntime.taskId) {
|
||||||
|
updateRuntime.downloadedBytes = downloadedBytes;
|
||||||
|
updateRuntime.totalBytes = Number.isFinite(totalBytes) ? Math.max(0, totalBytes) : 0;
|
||||||
|
updateRuntime.progress = Number.isFinite(boundedProgress)
|
||||||
|
? Number(boundedProgress.toFixed(1))
|
||||||
|
: updateRuntime.progress;
|
||||||
|
const speedText = measureRate(downloadedBytes, updateRuntime);
|
||||||
|
if (speedText !== "-") {
|
||||||
|
updateRuntime.speed = speedText;
|
||||||
|
}
|
||||||
|
if (payload?.done) {
|
||||||
|
updateRuntime.progress = 100;
|
||||||
|
updateRuntime.speed = "-";
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const patch: Partial<TransferTask> = {
|
const patch: Partial<TransferTask> = {
|
||||||
speed: "下载中",
|
speed: "下载中",
|
||||||
status: "downloading",
|
status: "downloading",
|
||||||
@@ -392,6 +484,45 @@ function applyNativeDownloadProgress(payload: NativeDownloadProgressEvent) {
|
|||||||
updateTransferTask(taskId, patch);
|
updateTransferTask(taskId, patch);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function applyNativeUploadProgress(payload: NativeUploadProgressEvent) {
|
||||||
|
const taskId = String(payload?.taskId || "").trim();
|
||||||
|
if (!taskId) return;
|
||||||
|
|
||||||
|
const uploadedBytes = Number(payload?.uploadedBytes || 0);
|
||||||
|
const totalBytes = Math.max(1, Number(payload?.totalBytes || 0));
|
||||||
|
const eventProgress = Number(payload?.progress);
|
||||||
|
const progressValue = Number.isFinite(eventProgress)
|
||||||
|
? eventProgress
|
||||||
|
: (uploadedBytes / totalBytes) * 100;
|
||||||
|
const boundedProgress = Math.max(1, Math.min(payload?.done ? 100 : 99.8, progressValue));
|
||||||
|
let transferSpeed = "上传中";
|
||||||
|
if (taskId === uploadRuntime.taskId) {
|
||||||
|
const sampledSpeed = measureRate(uploadedBytes, uploadRuntime);
|
||||||
|
if (sampledSpeed !== "-") {
|
||||||
|
transferSpeed = sampledSpeed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
updateTransferTask(taskId, {
|
||||||
|
status: "uploading",
|
||||||
|
speed: transferSpeed,
|
||||||
|
progress: Number(boundedProgress.toFixed(1)),
|
||||||
|
note: `${formatBytes(uploadedBytes)} / ${formatBytes(totalBytes)}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (taskId === uploadRuntime.taskId) {
|
||||||
|
uploadRuntime.uploadedBytes = uploadedBytes;
|
||||||
|
uploadRuntime.totalBytes = totalBytes;
|
||||||
|
uploadRuntime.progress = Number(boundedProgress.toFixed(1));
|
||||||
|
if (transferSpeed !== "上传中") {
|
||||||
|
uploadRuntime.speed = transferSpeed;
|
||||||
|
}
|
||||||
|
if (payload?.done) {
|
||||||
|
uploadRuntime.progress = 100;
|
||||||
|
uploadRuntime.speed = "-";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function fileIcon(item: FileItem) {
|
function fileIcon(item: FileItem) {
|
||||||
if (item.isDirectory || item.type === "directory") return "📁";
|
if (item.isDirectory || item.type === "directory") return "📁";
|
||||||
const name = String(item.name || "").toLowerCase();
|
const name = String(item.name || "").toLowerCase();
|
||||||
@@ -690,8 +821,52 @@ async function checkClientUpdate(showResultToast = true): Promise<boolean> {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getUpdateSkipStorageKey() {
|
||||||
|
return `wanwan_desktop_skip_update_${String(user.value?.id || "guest")}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function shouldSkipCurrentUpdatePrompt() {
|
||||||
|
const latest = String(updateState.latestVersion || "").trim();
|
||||||
|
if (!latest) return false;
|
||||||
|
return localStorage.getItem(getUpdateSkipStorageKey()) === latest;
|
||||||
|
}
|
||||||
|
|
||||||
|
function skipCurrentUpdatePrompt() {
|
||||||
|
const latest = String(updateState.latestVersion || "").trim();
|
||||||
|
if (!latest) return;
|
||||||
|
localStorage.setItem(getUpdateSkipStorageKey(), latest);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function checkUpdateAfterLogin() {
|
||||||
|
if (!authenticated.value || hasCheckedUpdateAfterAuth) return;
|
||||||
|
hasCheckedUpdateAfterAuth = true;
|
||||||
|
const checked = await checkClientUpdate(false);
|
||||||
|
if (!checked || !updateState.available || !updateState.downloadUrl) return;
|
||||||
|
if (shouldSkipCurrentUpdatePrompt()) return;
|
||||||
|
updatePrompt.visible = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function dismissUpdatePrompt(ignoreThisVersion = false) {
|
||||||
|
if (ignoreThisVersion) {
|
||||||
|
skipCurrentUpdatePrompt();
|
||||||
|
}
|
||||||
|
updatePrompt.visible = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function confirmUpdateFromPrompt() {
|
||||||
|
if (updatePrompt.loading) return;
|
||||||
|
updatePrompt.loading = true;
|
||||||
|
updatePrompt.visible = false;
|
||||||
|
try {
|
||||||
|
nav.value = "updates";
|
||||||
|
await installLatestUpdate();
|
||||||
|
} finally {
|
||||||
|
updatePrompt.loading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function installLatestUpdate(): Promise<boolean> {
|
async function installLatestUpdate(): Promise<boolean> {
|
||||||
if (updateRuntime.downloading) {
|
if (updateRuntime.downloading || updateRuntime.installing) {
|
||||||
showToast("更新包正在下载,请稍候", "info");
|
showToast("更新包正在下载,请稍候", "info");
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -701,20 +876,13 @@ async function installLatestUpdate(): Promise<boolean> {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
resetUpdateRuntime();
|
||||||
updateRuntime.downloading = true;
|
updateRuntime.downloading = true;
|
||||||
const taskId = `UPD-${Date.now()}`;
|
const taskId = `UPD-${Date.now()}`;
|
||||||
const installerName = `wanwan-cloud-desktop_v${updateState.latestVersion || updateState.currentVersion}.exe`;
|
const installerName = `wanwan-cloud-desktop_v${updateState.latestVersion || updateState.currentVersion}.exe`;
|
||||||
prependTransferTask({
|
updateRuntime.taskId = taskId;
|
||||||
id: taskId,
|
updateRuntime.progress = 1;
|
||||||
kind: "download",
|
updateRuntime.speed = "准备下载";
|
||||||
name: installerName,
|
|
||||||
speed: "更新包下载",
|
|
||||||
progress: 2,
|
|
||||||
status: "downloading",
|
|
||||||
note: "正在下载升级安装包",
|
|
||||||
downloadUrl: updateState.downloadUrl,
|
|
||||||
fileName: installerName,
|
|
||||||
});
|
|
||||||
|
|
||||||
const response = await invokeBridge("api_native_download", {
|
const response = await invokeBridge("api_native_download", {
|
||||||
url: updateState.downloadUrl,
|
url: updateState.downloadUrl,
|
||||||
@@ -724,25 +892,18 @@ async function installLatestUpdate(): Promise<boolean> {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
if (response.ok && response.data?.success) {
|
if (response.ok && response.data?.success) {
|
||||||
updateTransferTask(taskId, {
|
updateRuntime.downloading = false;
|
||||||
speed: "-",
|
updateRuntime.installing = true;
|
||||||
progress: 100,
|
updateRuntime.progress = 100;
|
||||||
status: "done",
|
updateRuntime.speed = "-";
|
||||||
note: "更新包已下载,准备启动安装",
|
|
||||||
});
|
|
||||||
const savePath = String(response.data?.savePath || "").trim();
|
const savePath = String(response.data?.savePath || "").trim();
|
||||||
|
updateRuntime.installerPath = savePath;
|
||||||
if (savePath) {
|
if (savePath) {
|
||||||
const launchResponse = await invokeBridge("api_launch_installer", {
|
const launchResponse = await invokeBridge("api_silent_install_and_restart", {
|
||||||
installerPath: savePath,
|
installerPath: savePath,
|
||||||
});
|
});
|
||||||
if (launchResponse.ok && launchResponse.data?.success) {
|
if (launchResponse.ok && launchResponse.data?.success) {
|
||||||
updateTransferTask(taskId, {
|
showToast("静默安装已启动,完成后会自动重启客户端", "success");
|
||||||
speed: "-",
|
|
||||||
progress: 100,
|
|
||||||
status: "done",
|
|
||||||
note: "安装程序已启动,客户端即将退出",
|
|
||||||
});
|
|
||||||
showToast("安装程序已启动,客户端即将退出", "success");
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
void getCurrentWindow().close();
|
void getCurrentWindow().close();
|
||||||
}, 400);
|
}, 400);
|
||||||
@@ -756,22 +917,22 @@ async function installLatestUpdate(): Promise<boolean> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
showToast("更新包已下载,请手动运行安装程序", "info");
|
showToast("更新包已下载,请手动运行安装程序", "info");
|
||||||
|
setTimeout(() => {
|
||||||
|
resetUpdateRuntime();
|
||||||
|
}, 1200);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
const message = String(response.data?.message || "下载更新包失败");
|
const message = String(response.data?.message || "下载更新包失败");
|
||||||
updateTransferTask(taskId, {
|
resetUpdateRuntime();
|
||||||
speed: "-",
|
|
||||||
progress: 0,
|
|
||||||
status: "failed",
|
|
||||||
note: message,
|
|
||||||
});
|
|
||||||
showToast(message, "error");
|
showToast(message, "error");
|
||||||
return false;
|
return false;
|
||||||
} finally {
|
} finally {
|
||||||
|
if (!updateRuntime.installing) {
|
||||||
updateRuntime.downloading = false;
|
updateRuntime.downloading = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function chooseSyncDirectory() {
|
async function chooseSyncDirectory() {
|
||||||
try {
|
try {
|
||||||
@@ -789,12 +950,13 @@ async function chooseSyncDirectory() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function uploadFileWithResume(filePath: string, targetPath: string) {
|
async function uploadFileWithResume(filePath: string, targetPath: string, taskId?: string) {
|
||||||
const resumableResponse = await invokeBridge("api_upload_file_resumable", {
|
const resumableResponse = await invokeBridge("api_upload_file_resumable", {
|
||||||
baseUrl: appConfig.baseUrl,
|
baseUrl: appConfig.baseUrl,
|
||||||
filePath,
|
filePath,
|
||||||
targetPath,
|
targetPath,
|
||||||
chunkSize: 4 * 1024 * 1024,
|
chunkSize: 4 * 1024 * 1024,
|
||||||
|
taskId: taskId || null,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (resumableResponse.ok && resumableResponse.data?.success) {
|
if (resumableResponse.ok && resumableResponse.data?.success) {
|
||||||
@@ -811,6 +973,7 @@ async function uploadFileWithResume(filePath: string, targetPath: string) {
|
|||||||
baseUrl: appConfig.baseUrl,
|
baseUrl: appConfig.baseUrl,
|
||||||
filePath,
|
filePath,
|
||||||
targetPath,
|
targetPath,
|
||||||
|
taskId: taskId || null,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -918,7 +1081,7 @@ async function runSyncOnce(trigger: "manual" | "auto" = "manual") {
|
|||||||
});
|
});
|
||||||
updateTransferTask(taskId, { status: "uploading", speed: "上传中", progress: 10 });
|
updateTransferTask(taskId, { status: "uploading", speed: "上传中", progress: 10 });
|
||||||
|
|
||||||
const resp = await uploadFileWithResume(item.path, targetPath);
|
const resp = await uploadFileWithResume(item.path, targetPath, taskId);
|
||||||
|
|
||||||
if (resp.ok && resp.data?.success) {
|
if (resp.ok && resp.data?.success) {
|
||||||
syncState.uploadedCount += 1;
|
syncState.uploadedCount += 1;
|
||||||
@@ -1142,12 +1305,59 @@ async function openShareLink(share: ShareItem) {
|
|||||||
|
|
||||||
async function restoreSession() {
|
async function restoreSession() {
|
||||||
const ok = await loadProfile();
|
const ok = await loadProfile();
|
||||||
if (!ok) return;
|
if (!ok) return false;
|
||||||
authenticated.value = true;
|
authenticated.value = true;
|
||||||
loadSyncConfig();
|
loadSyncConfig();
|
||||||
rebuildSyncScheduler();
|
rebuildSyncScheduler();
|
||||||
await loadFiles("/");
|
await loadFiles("/");
|
||||||
await loadShares(true);
|
await loadShares(true);
|
||||||
|
await checkUpdateAfterLogin();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function tryAutoLoginFromSavedState() {
|
||||||
|
if (authenticated.value) return true;
|
||||||
|
const stateResponse = await invokeBridge("api_load_login_state", {});
|
||||||
|
if (!(stateResponse.ok && stateResponse.data?.success && stateResponse.data?.hasState)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const savedBase = String(stateResponse.data?.baseUrl || "").trim();
|
||||||
|
const savedUsername = String(stateResponse.data?.username || "").trim();
|
||||||
|
const savedPassword = String(stateResponse.data?.password || "");
|
||||||
|
if (!savedBase || !savedUsername || !savedPassword) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
appConfig.baseUrl = savedBase.replace(/\/+$/, "");
|
||||||
|
loginForm.username = savedUsername;
|
||||||
|
loginState.loading = true;
|
||||||
|
const loginResponse = await invokeBridge("api_login", {
|
||||||
|
baseUrl: appConfig.baseUrl,
|
||||||
|
username: savedUsername,
|
||||||
|
password: savedPassword,
|
||||||
|
captcha: null,
|
||||||
|
});
|
||||||
|
loginState.loading = false;
|
||||||
|
if (!(loginResponse.ok && loginResponse.data?.success)) {
|
||||||
|
await invokeBridge("api_clear_login_state", {});
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
authenticated.value = true;
|
||||||
|
user.value = loginResponse.data.user || null;
|
||||||
|
nav.value = "files";
|
||||||
|
loginForm.password = savedPassword;
|
||||||
|
loginForm.remember = true;
|
||||||
|
loadSyncConfig();
|
||||||
|
rebuildSyncScheduler();
|
||||||
|
await loadFiles("/");
|
||||||
|
if (!user.value) {
|
||||||
|
await loadProfile();
|
||||||
|
}
|
||||||
|
await checkUpdateAfterLogin();
|
||||||
|
showToast("已恢复登录状态", "success");
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildItemPath(item: FileItem) {
|
function buildItemPath(item: FileItem) {
|
||||||
@@ -1210,12 +1420,23 @@ async function handleLogin() {
|
|||||||
user.value = response.data.user || null;
|
user.value = response.data.user || null;
|
||||||
nav.value = "files";
|
nav.value = "files";
|
||||||
showToast("登录成功,正在同步文件目录", "success");
|
showToast("登录成功,正在同步文件目录", "success");
|
||||||
|
hasCheckedUpdateAfterAuth = false;
|
||||||
loadSyncConfig();
|
loadSyncConfig();
|
||||||
rebuildSyncScheduler();
|
rebuildSyncScheduler();
|
||||||
await loadFiles("/");
|
await loadFiles("/");
|
||||||
if (!user.value) {
|
if (!user.value) {
|
||||||
await loadProfile();
|
await loadProfile();
|
||||||
}
|
}
|
||||||
|
if (loginForm.remember) {
|
||||||
|
await invokeBridge("api_save_login_state", {
|
||||||
|
baseUrl: appConfig.baseUrl,
|
||||||
|
username: loginForm.username.trim(),
|
||||||
|
password: loginForm.password,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
await invokeBridge("api_clear_login_state", {});
|
||||||
|
}
|
||||||
|
await checkUpdateAfterLogin();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1226,6 +1447,7 @@ async function handleLogin() {
|
|||||||
|
|
||||||
async function handleLogout() {
|
async function handleLogout() {
|
||||||
await invokeBridge("api_logout", { baseUrl: appConfig.baseUrl });
|
await invokeBridge("api_logout", { baseUrl: appConfig.baseUrl });
|
||||||
|
await invokeBridge("api_clear_login_state", {});
|
||||||
clearSyncScheduler();
|
clearSyncScheduler();
|
||||||
authenticated.value = false;
|
authenticated.value = false;
|
||||||
user.value = null;
|
user.value = null;
|
||||||
@@ -1248,6 +1470,9 @@ async function handleLogout() {
|
|||||||
syncState.lastSummary = "";
|
syncState.lastSummary = "";
|
||||||
syncState.nextRunAt = "";
|
syncState.nextRunAt = "";
|
||||||
updateRuntime.downloading = false;
|
updateRuntime.downloading = false;
|
||||||
|
updateRuntime.installing = false;
|
||||||
|
updatePrompt.visible = false;
|
||||||
|
hasCheckedUpdateAfterAuth = false;
|
||||||
showToast("已退出客户端", "info");
|
showToast("已退出客户端", "info");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1454,7 +1679,7 @@ async function retryTransferTask(taskId: string) {
|
|||||||
}
|
}
|
||||||
await waitForTransferQueue();
|
await waitForTransferQueue();
|
||||||
updateTransferTask(taskId, { status: "uploading", speed: "重试上传", progress: 10, note: "正在重试" });
|
updateTransferTask(taskId, { status: "uploading", speed: "重试上传", progress: 10, note: "正在重试" });
|
||||||
const response = await uploadFileWithResume(task.filePath, task.targetPath);
|
const response = await uploadFileWithResume(task.filePath, task.targetPath, taskId);
|
||||||
if (response.ok && response.data?.success) {
|
if (response.ok && response.data?.success) {
|
||||||
updateTransferTask(taskId, { status: "done", speed: "-", progress: 100, note: "重试成功" });
|
updateTransferTask(taskId, { status: "done", speed: "-", progress: 100, note: "重试成功" });
|
||||||
if (nav.value === "files") {
|
if (nav.value === "files") {
|
||||||
@@ -1576,6 +1801,14 @@ function handleGlobalKey(event: KeyboardEvent) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleGlobalContextMenu(event: MouseEvent) {
|
||||||
|
const target = event.target as HTMLElement | null;
|
||||||
|
if (!target) return;
|
||||||
|
if (target.closest(".context-menu")) return;
|
||||||
|
if (target.closest("input, textarea, [contenteditable=\"true\"]")) return;
|
||||||
|
event.preventDefault();
|
||||||
|
}
|
||||||
|
|
||||||
function extractFileNameFromPath(filePath: string) {
|
function extractFileNameFromPath(filePath: string) {
|
||||||
const trimmed = String(filePath || "").trim();
|
const trimmed = String(filePath || "").trim();
|
||||||
if (!trimmed) return "";
|
if (!trimmed) return "";
|
||||||
@@ -1625,11 +1858,24 @@ async function uploadDroppedFiles(paths: string[]) {
|
|||||||
});
|
});
|
||||||
updateTransferTask(taskId, { status: "uploading", speed: "上传中", progress: 10 });
|
updateTransferTask(taskId, { status: "uploading", speed: "上传中", progress: 10 });
|
||||||
|
|
||||||
const response = await uploadFileWithResume(filePath, pathState.currentPath);
|
uploadRuntime.active = true;
|
||||||
|
uploadRuntime.taskId = taskId;
|
||||||
|
uploadRuntime.fileName = displayName;
|
||||||
|
uploadRuntime.uploadedBytes = 0;
|
||||||
|
uploadRuntime.totalBytes = 0;
|
||||||
|
uploadRuntime.progress = 1;
|
||||||
|
uploadRuntime.speed = "准备上传";
|
||||||
|
uploadRuntime.lastMeasureAt = 0;
|
||||||
|
uploadRuntime.lastMeasureBytes = 0;
|
||||||
|
|
||||||
|
const response = await uploadFileWithResume(filePath, pathState.currentPath, taskId);
|
||||||
|
|
||||||
if (response.ok && response.data?.success) {
|
if (response.ok && response.data?.success) {
|
||||||
successCount += 1;
|
successCount += 1;
|
||||||
dropState.done += 1;
|
dropState.done += 1;
|
||||||
|
uploadRuntime.progress = 100;
|
||||||
|
uploadRuntime.speed = "-";
|
||||||
|
uploadRuntime.uploadedBytes = Math.max(uploadRuntime.uploadedBytes, uploadRuntime.totalBytes);
|
||||||
updateTransferTask(taskId, {
|
updateTransferTask(taskId, {
|
||||||
speed: "-",
|
speed: "-",
|
||||||
progress: 100,
|
progress: 100,
|
||||||
@@ -1639,6 +1885,7 @@ async function uploadDroppedFiles(paths: string[]) {
|
|||||||
} else {
|
} else {
|
||||||
dropState.failed += 1;
|
dropState.failed += 1;
|
||||||
const message = String(response.data?.message || "上传失败");
|
const message = String(response.data?.message || "上传失败");
|
||||||
|
uploadRuntime.speed = "-";
|
||||||
updateTransferTask(taskId, {
|
updateTransferTask(taskId, {
|
||||||
speed: "-",
|
speed: "-",
|
||||||
progress: 0,
|
progress: 0,
|
||||||
@@ -1649,6 +1896,10 @@ async function uploadDroppedFiles(paths: string[]) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
dropState.uploading = false;
|
dropState.uploading = false;
|
||||||
|
setTimeout(() => {
|
||||||
|
uploadRuntime.active = false;
|
||||||
|
uploadRuntime.taskId = "";
|
||||||
|
}, 1600);
|
||||||
const failedMessage = dropState.failed > 0 ? `,失败 ${dropState.failed} 个` : "";
|
const failedMessage = dropState.failed > 0 ? `,失败 ${dropState.failed} 个` : "";
|
||||||
showToast(`上传完成:成功 ${dropState.done} 个${failedMessage}`, dropState.failed > 0 ? "info" : "success");
|
showToast(`上传完成:成功 ${dropState.done} 个${failedMessage}`, dropState.failed > 0 ? "info" : "success");
|
||||||
if (successCount > 0) {
|
if (successCount > 0) {
|
||||||
@@ -1699,6 +1950,16 @@ async function registerNativeDownloadProgressListener() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function registerNativeUploadProgressListener() {
|
||||||
|
try {
|
||||||
|
unlistenNativeUploadProgress = await listen<NativeUploadProgressEvent>("native-upload-progress", (event) => {
|
||||||
|
applyNativeUploadProgress(event.payload || {});
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("register native upload progress listener failed", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
watch(nav, async (next) => {
|
watch(nav, async (next) => {
|
||||||
if (next === "shares" && authenticated.value) {
|
if (next === "shares" && authenticated.value) {
|
||||||
await loadShares();
|
await loadShares();
|
||||||
@@ -1725,15 +1986,21 @@ watch(
|
|||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
window.addEventListener("click", handleGlobalClick);
|
window.addEventListener("click", handleGlobalClick);
|
||||||
window.addEventListener("keydown", handleGlobalKey);
|
window.addEventListener("keydown", handleGlobalKey);
|
||||||
|
window.addEventListener("contextmenu", handleGlobalContextMenu);
|
||||||
await registerDragDropListener();
|
await registerDragDropListener();
|
||||||
await registerNativeDownloadProgressListener();
|
await registerNativeDownloadProgressListener();
|
||||||
|
await registerNativeUploadProgressListener();
|
||||||
await initClientVersion();
|
await initClientVersion();
|
||||||
await restoreSession();
|
const restored = await restoreSession();
|
||||||
|
if (!restored) {
|
||||||
|
await tryAutoLoginFromSavedState();
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
onBeforeUnmount(() => {
|
onBeforeUnmount(() => {
|
||||||
window.removeEventListener("click", handleGlobalClick);
|
window.removeEventListener("click", handleGlobalClick);
|
||||||
window.removeEventListener("keydown", handleGlobalKey);
|
window.removeEventListener("keydown", handleGlobalKey);
|
||||||
|
window.removeEventListener("contextmenu", handleGlobalContextMenu);
|
||||||
clearSyncScheduler();
|
clearSyncScheduler();
|
||||||
if (unlistenDragDrop) {
|
if (unlistenDragDrop) {
|
||||||
unlistenDragDrop();
|
unlistenDragDrop();
|
||||||
@@ -1743,6 +2010,10 @@ onBeforeUnmount(() => {
|
|||||||
unlistenNativeDownloadProgress();
|
unlistenNativeDownloadProgress();
|
||||||
unlistenNativeDownloadProgress = null;
|
unlistenNativeDownloadProgress = null;
|
||||||
}
|
}
|
||||||
|
if (unlistenNativeUploadProgress) {
|
||||||
|
unlistenNativeUploadProgress();
|
||||||
|
unlistenNativeUploadProgress = null;
|
||||||
|
}
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -1783,6 +2054,10 @@ onBeforeUnmount(() => {
|
|||||||
密码
|
密码
|
||||||
<input v-model="loginForm.password" type="password" placeholder="请输入密码" @keyup.enter="handleLogin" />
|
<input v-model="loginForm.password" type="password" placeholder="请输入密码" @keyup.enter="handleLogin" />
|
||||||
</label>
|
</label>
|
||||||
|
<label class="check-line">
|
||||||
|
<input v-model="loginForm.remember" type="checkbox" />
|
||||||
|
<span>记住登录状态(本机 SQLite)</span>
|
||||||
|
</label>
|
||||||
<label v-if="loginState.needCaptcha">
|
<label v-if="loginState.needCaptcha">
|
||||||
验证码
|
验证码
|
||||||
<input v-model="loginForm.captcha" type="text" placeholder="当前服务要求验证码" />
|
<input v-model="loginForm.captcha" type="text" placeholder="当前服务要求验证码" />
|
||||||
@@ -1891,7 +2166,7 @@ onBeforeUnmount(() => {
|
|||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<main class="main-grid">
|
<main class="main-grid" :class="{ 'focus-content': nav === 'files' }">
|
||||||
<section class="panel content-panel">
|
<section class="panel content-panel">
|
||||||
<template v-if="nav === 'files'">
|
<template v-if="nav === 'files'">
|
||||||
<div class="panel-head">
|
<div class="panel-head">
|
||||||
@@ -1899,7 +2174,7 @@ onBeforeUnmount(() => {
|
|||||||
<span>{{ pathState.mode === "search" ? "搜索结果" : "当前目录" }} {{ pathState.currentPath }}</span>
|
<span>{{ pathState.mode === "search" ? "搜索结果" : "当前目录" }} {{ pathState.currentPath }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="file-drop-surface" :class="{ active: dropState.active || dropState.uploading }">
|
<div class="file-drop-surface" :class="{ active: dropState.active }">
|
||||||
<div v-if="pathState.loading" class="empty-tip">正在加载目录...</div>
|
<div v-if="pathState.loading" class="empty-tip">正在加载目录...</div>
|
||||||
<div v-else-if="pathState.error" class="empty-tip error">{{ pathState.error }}</div>
|
<div v-else-if="pathState.error" class="empty-tip error">{{ pathState.error }}</div>
|
||||||
<div v-else-if="filteredFiles.length === 0" class="empty-tip">当前目录暂无文件</div>
|
<div v-else-if="filteredFiles.length === 0" class="empty-tip">当前目录暂无文件</div>
|
||||||
@@ -1923,12 +2198,10 @@ onBeforeUnmount(() => {
|
|||||||
<div class="file-time">{{ formatDate(item.modifiedAt) }}</div>
|
<div class="file-time">{{ formatDate(item.modifiedAt) }}</div>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="dropState.active || dropState.uploading" class="drop-overlay">
|
<div v-if="dropState.active" class="drop-overlay">
|
||||||
<div class="drop-overlay-card">
|
<div class="drop-overlay-card">
|
||||||
<strong v-if="!dropState.uploading">拖拽到此处上传到当前目录</strong>
|
<strong>拖拽到此处上传到当前目录</strong>
|
||||||
<strong v-else>正在上传 {{ dropState.done + dropState.failed }}/{{ dropState.total }}</strong>
|
<span>仅支持文件,文件夹会自动跳过</span>
|
||||||
<span v-if="!dropState.uploading">仅支持文件,文件夹会自动跳过</span>
|
|
||||||
<span v-else>成功 {{ dropState.done }} 个,失败 {{ dropState.failed }} 个</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -2247,6 +2520,51 @@ onBeforeUnmount(() => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div v-if="updatePrompt.visible" class="confirm-mask" @click="dismissUpdatePrompt()">
|
||||||
|
<div class="confirm-card" @click.stop>
|
||||||
|
<h4>发现新版本 v{{ updateState.latestVersion || "-" }}</h4>
|
||||||
|
<p>
|
||||||
|
已在登录后检测到可用更新,是否现在进行静默升级?
|
||||||
|
升级完成后将自动重启客户端。
|
||||||
|
</p>
|
||||||
|
<div class="confirm-actions">
|
||||||
|
<button class="action-btn" :disabled="updatePrompt.loading" @click="dismissUpdatePrompt(true)">稍后提醒</button>
|
||||||
|
<button class="action-btn danger" :disabled="updatePrompt.loading" @click="confirmUpdateFromPrompt()">
|
||||||
|
{{ updatePrompt.loading ? "处理中..." : "立即更新" }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="uploadRuntime.active || updateRuntime.downloading || updateRuntime.installing" class="status-stack">
|
||||||
|
<div v-if="uploadRuntime.active" class="status-card">
|
||||||
|
<div class="status-head">
|
||||||
|
<strong>上传中</strong>
|
||||||
|
<span>{{ Math.max(0, Math.min(100, Math.round(uploadRuntime.progress))) }}%</span>
|
||||||
|
</div>
|
||||||
|
<div class="status-name" :title="uploadRuntime.fileName">{{ uploadRuntime.fileName || "正在上传文件" }}</div>
|
||||||
|
<div class="progress compact">
|
||||||
|
<div class="bar" :style="{ width: `${uploadRuntime.progress}%` }" />
|
||||||
|
</div>
|
||||||
|
<small>{{ formatBytes(uploadRuntime.uploadedBytes) }} / {{ formatBytes(uploadRuntime.totalBytes) }} · {{ uploadRuntime.speed }}</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="updateRuntime.downloading || updateRuntime.installing" class="status-card">
|
||||||
|
<div class="status-head">
|
||||||
|
<strong>{{ updateRuntime.installing ? "安装更新" : "下载更新" }}</strong>
|
||||||
|
<span>{{ Math.max(0, Math.min(100, Math.round(updateRuntime.progress))) }}%</span>
|
||||||
|
</div>
|
||||||
|
<div class="status-name">
|
||||||
|
{{ updateRuntime.installing ? "静默安装中,完成后自动重启" : `v${updateState.latestVersion || "-"}` }}
|
||||||
|
</div>
|
||||||
|
<div class="progress compact">
|
||||||
|
<div class="bar" :style="{ width: `${updateRuntime.progress}%` }" />
|
||||||
|
</div>
|
||||||
|
<small v-if="updateRuntime.downloading">{{ formatBytes(updateRuntime.downloadedBytes) }} / {{ formatBytes(updateRuntime.totalBytes) }} · {{ updateRuntime.speed }}</small>
|
||||||
|
<small v-else>请稍候,应用将自动退出并重启</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div v-if="toast.visible" class="toast" :class="toast.type">{{ toast.message }}</div>
|
<div v-if="toast.visible" class="toast" :class="toast.type">{{ toast.message }}</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -2268,7 +2586,7 @@ onBeforeUnmount(() => {
|
|||||||
.desktop-root {
|
.desktop-root {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
padding: 18px;
|
padding: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.login-shell {
|
.login-shell {
|
||||||
@@ -2701,6 +3019,14 @@ select:focus {
|
|||||||
min-height: 0;
|
min-height: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.main-grid.focus-content {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.main-grid.focus-content .detail-panel {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
.content-panel,
|
.content-panel,
|
||||||
.detail-panel {
|
.detail-panel {
|
||||||
padding: 16px;
|
padding: 16px;
|
||||||
@@ -2932,6 +3258,10 @@ select:focus {
|
|||||||
transition: width 0.14s linear;
|
transition: width 0.14s linear;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.progress.compact {
|
||||||
|
height: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
.task-percent {
|
.task-percent {
|
||||||
justify-self: end;
|
justify-self: end;
|
||||||
color: #3f5f86;
|
color: #3f5f86;
|
||||||
@@ -3253,6 +3583,56 @@ select:focus {
|
|||||||
gap: 8px;
|
gap: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.status-stack {
|
||||||
|
position: fixed;
|
||||||
|
right: 14px;
|
||||||
|
bottom: 14px;
|
||||||
|
z-index: 1250;
|
||||||
|
width: min(360px, calc(100vw - 24px));
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-card {
|
||||||
|
border-radius: 12px;
|
||||||
|
border: 1px solid #d3dfef;
|
||||||
|
background: rgba(255, 255, 255, 0.96);
|
||||||
|
box-shadow: 0 10px 20px rgba(31, 56, 92, 0.16);
|
||||||
|
padding: 10px 12px;
|
||||||
|
display: grid;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-head {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-head strong {
|
||||||
|
font-size: 13px;
|
||||||
|
color: #203754;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-head span {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #456892;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-name {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #4f6784;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-card small {
|
||||||
|
color: #5d7898;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
.detail-panel h3 {
|
.detail-panel h3 {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
|
|||||||
Reference in New Issue
Block a user