diff --git a/backend/server.js b/backend/server.js index ca54bd6..5d0c486 100644 --- a/backend/server.js +++ b/backend/server.js @@ -97,6 +97,9 @@ const DOWNLOAD_RESERVATION_TTL_MS = Number(process.env.DOWNLOAD_RESERVATION_TTL_ const DOWNLOAD_LOG_RECONCILE_INTERVAL_MS = Number(process.env.DOWNLOAD_LOG_RECONCILE_INTERVAL_MS || (5 * 60 * 1000)); // 5分钟 const DOWNLOAD_LOG_MAX_FILES_PER_SWEEP = Number(process.env.DOWNLOAD_LOG_MAX_FILES_PER_SWEEP || 40); const DOWNLOAD_LOG_LIST_MAX_KEYS = Number(process.env.DOWNLOAD_LOG_LIST_MAX_KEYS || 200); +const DEFAULT_DESKTOP_VERSION = process.env.DESKTOP_LATEST_VERSION || '0.1.0'; +const DEFAULT_DESKTOP_INSTALLER_URL = process.env.DESKTOP_INSTALLER_URL || ''; +const DEFAULT_DESKTOP_RELEASE_NOTES = process.env.DESKTOP_RELEASE_NOTES || ''; const RESUMABLE_UPLOAD_SESSION_TTL_MS = Number(process.env.RESUMABLE_UPLOAD_SESSION_TTL_MS || (24 * 60 * 60 * 1000)); // 24小时 const RESUMABLE_UPLOAD_CHUNK_SIZE_BYTES = Number(process.env.RESUMABLE_UPLOAD_CHUNK_SIZE_BYTES || (4 * 1024 * 1024)); // 4MB const RESUMABLE_UPLOAD_MAX_CHUNK_SIZE_BYTES = Number(process.env.RESUMABLE_UPLOAD_MAX_CHUNK_SIZE_BYTES || (32 * 1024 * 1024)); // 32MB @@ -127,6 +130,56 @@ const SHOULD_USE_SECURE_COOKIES = COOKIE_SECURE_MODE === 'true' || (process.env.NODE_ENV === 'production' && COOKIE_SECURE_MODE !== 'false'); +function normalizeVersion(rawVersion, fallback = '0.0.0') { + const value = String(rawVersion || '').trim(); + if (!value) return fallback; + return value; +} + +function compareLooseVersion(left, right) { + const normalize = (value) => normalizeVersion(value, '0.0.0') + .replace(/^v/i, '') + .split('.') + .map((part) => parseInt(part, 10)) + .map((num) => (Number.isFinite(num) ? num : 0)); + + const a = normalize(left); + const b = normalize(right); + const size = Math.max(a.length, b.length); + for (let i = 0; i < size; i += 1) { + const av = a[i] || 0; + const bv = b[i] || 0; + if (av > bv) return 1; + if (av < bv) return -1; + } + return 0; +} + +function getDesktopUpdateConfig() { + const latestVersion = normalizeVersion( + SettingsDB.get('desktop_latest_version') || DEFAULT_DESKTOP_VERSION, + DEFAULT_DESKTOP_VERSION + ); + const installerUrl = String( + SettingsDB.get('desktop_installer_url_win_x64') || + SettingsDB.get('desktop_installer_url') || + DEFAULT_DESKTOP_INSTALLER_URL || + '' + ).trim(); + const releaseNotes = String( + SettingsDB.get('desktop_release_notes') || + DEFAULT_DESKTOP_RELEASE_NOTES || + '' + ).trim(); + const mandatory = SettingsDB.get('desktop_force_update') === 'true'; + return { + latestVersion, + installerUrl, + releaseNotes, + mandatory + }; +} + function getResolvedStorageRoot() { const configuredRoot = process.env.STORAGE_ROOT; if (!configuredRoot) { @@ -3358,6 +3411,39 @@ app.get('/api/config', (req, res) => { }); }); +// 桌面客户端更新信息(无需登录) +app.get('/api/client/desktop-update', (req, res) => { + try { + const currentVersion = normalizeVersion(req.query.currentVersion || '0.0.0', '0.0.0'); + const platform = String(req.query.platform || 'windows-x64').trim(); + const channel = String(req.query.channel || 'stable').trim(); + const config = getDesktopUpdateConfig(); + const hasDownload = Boolean(config.installerUrl); + const updateAvailable = hasDownload && compareLooseVersion(currentVersion, config.latestVersion) < 0; + + res.json({ + success: true, + currentVersion, + latestVersion: config.latestVersion, + updateAvailable, + downloadUrl: config.installerUrl, + releaseNotes: config.releaseNotes, + mandatory: config.mandatory && updateAvailable, + platform, + channel, + message: hasDownload + ? (updateAvailable ? '检测到新版本' : '当前已是最新版本') + : '服务器暂未配置桌面端升级包地址' + }); + } catch (error) { + console.error('[客户端更新] 获取升级信息失败:', error); + res.status(500).json({ + success: false, + message: '获取客户端更新信息失败' + }); + } +}); + // 生成验证码API app.get('/api/captcha', captchaRateLimitMiddleware, (req, res) => { try { @@ -8479,6 +8565,7 @@ app.get('/api/admin/settings', authMiddleware, adminMiddleware, (req, res) => { const smtpHasPassword = !!SettingsDB.get('smtp_password'); const globalTheme = SettingsDB.get('global_theme') || 'dark'; const downloadSecurity = getDownloadSecuritySettings(); + const desktopUpdate = getDesktopUpdateConfig(); res.json({ success: true, @@ -8486,6 +8573,12 @@ app.get('/api/admin/settings', authMiddleware, adminMiddleware, (req, res) => { max_upload_size: maxUploadSize, global_theme: globalTheme, download_security: downloadSecurity, + desktop_update: { + latest_version: desktopUpdate.latestVersion, + installer_url: desktopUpdate.installerUrl, + release_notes: desktopUpdate.releaseNotes, + force_update: desktopUpdate.mandatory + }, smtp: { host: smtpHost || '', port: smtpPort ? parseInt(smtpPort, 10) : 465, @@ -8516,7 +8609,8 @@ app.post('/api/admin/settings', max_upload_size, smtp, global_theme, - download_security + download_security, + desktop_update } = req.body; if (max_upload_size !== undefined) { @@ -8572,6 +8666,29 @@ app.post('/api/admin/settings', } } + if (desktop_update !== undefined) { + if (!desktop_update || typeof desktop_update !== 'object') { + return res.status(400).json({ + success: false, + message: '桌面端更新配置格式错误' + }); + } + + if (desktop_update.latest_version !== undefined) { + SettingsDB.set('desktop_latest_version', normalizeVersion(desktop_update.latest_version, DEFAULT_DESKTOP_VERSION)); + } + if (desktop_update.installer_url !== undefined) { + SettingsDB.set('desktop_installer_url', String(desktop_update.installer_url || '').trim()); + SettingsDB.set('desktop_installer_url_win_x64', String(desktop_update.installer_url || '').trim()); + } + if (desktop_update.release_notes !== undefined) { + SettingsDB.set('desktop_release_notes', String(desktop_update.release_notes || '').trim()); + } + if (desktop_update.force_update !== undefined) { + SettingsDB.set('desktop_force_update', desktop_update.force_update ? 'true' : 'false'); + } + } + res.json({ success: true, message: '系统设置已更新' diff --git a/desktop-client/package-lock.json b/desktop-client/package-lock.json index 66abebd..aa70f71 100644 --- a/desktop-client/package-lock.json +++ b/desktop-client/package-lock.json @@ -9,6 +9,7 @@ "version": "0.1.0", "dependencies": { "@tauri-apps/api": "^2", + "@tauri-apps/plugin-dialog": "^2.6.0", "@tauri-apps/plugin-opener": "^2", "vue": "^3.5.13" }, @@ -1091,6 +1092,15 @@ "node": ">= 10" } }, + "node_modules/@tauri-apps/plugin-dialog": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@tauri-apps/plugin-dialog/-/plugin-dialog-2.6.0.tgz", + "integrity": "sha512-q4Uq3eY87TdcYzXACiYSPhmpBA76shgmQswGkSVio4C82Sz2W4iehe9TnKYwbq7weHiL88Yw19XZm7v28+Micg==", + "license": "MIT OR Apache-2.0", + "dependencies": { + "@tauri-apps/api": "^2.8.0" + } + }, "node_modules/@tauri-apps/plugin-opener": { "version": "2.5.3", "resolved": "https://registry.npmjs.org/@tauri-apps/plugin-opener/-/plugin-opener-2.5.3.tgz", diff --git a/desktop-client/package.json b/desktop-client/package.json index 8aa36c5..ea7dbe2 100644 --- a/desktop-client/package.json +++ b/desktop-client/package.json @@ -10,15 +10,16 @@ "tauri": "tauri" }, "dependencies": { - "vue": "^3.5.13", "@tauri-apps/api": "^2", - "@tauri-apps/plugin-opener": "^2" + "@tauri-apps/plugin-dialog": "^2.6.0", + "@tauri-apps/plugin-opener": "^2", + "vue": "^3.5.13" }, "devDependencies": { + "@tauri-apps/cli": "^2", "@vitejs/plugin-vue": "^5.2.1", "typescript": "~5.6.2", "vite": "^6.0.3", - "vue-tsc": "^2.1.10", - "@tauri-apps/cli": "^2" + "vue-tsc": "^2.1.10" } } diff --git a/desktop-client/src-tauri/Cargo.lock b/desktop-client/src-tauri/Cargo.lock index 539effd..ed986e2 100644 --- a/desktop-client/src-tauri/Cargo.lock +++ b/desktop-client/src-tauri/Cargo.lock @@ -688,8 +688,10 @@ dependencies = [ "serde_json", "tauri", "tauri-build", + "tauri-plugin-dialog", "tauri-plugin-opener", "urlencoding", + "walkdir", ] [[package]] @@ -736,6 +738,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "89a09f22a6c6069a18470eb92d2298acf25463f14256d24778e1230d789a2aec" dependencies = [ "bitflags 2.11.0", + "block2", + "libc", "objc2", ] @@ -3162,6 +3166,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" @@ -3927,6 +3955,46 @@ dependencies = [ "walkdir", ] +[[package]] +name = "tauri-plugin-dialog" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9204b425d9be8d12aa60c2a83a289cf7d1caae40f57f336ed1155b3a5c0e359b" +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.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed390cc669f937afeb8b28032ce837bac8ea023d975a2e207375ec05afaf1804" +dependencies = [ + "anyhow", + "dunce", + "glob", + "percent-encoding", + "schemars 0.8.22", + "serde", + "serde_json", + "serde_repr", + "tauri", + "tauri-plugin", + "tauri-utils", + "thiserror 2.0.18", + "toml 0.9.12+spec-1.1.0", + "url", +] + [[package]] name = "tauri-plugin-opener" version = "2.5.3" diff --git a/desktop-client/src-tauri/Cargo.toml b/desktop-client/src-tauri/Cargo.toml index 08e3c94..861a23d 100644 --- a/desktop-client/src-tauri/Cargo.toml +++ b/desktop-client/src-tauri/Cargo.toml @@ -20,7 +20,9 @@ tauri-build = { version = "2", features = [] } [dependencies] tauri = { version = "2", features = [] } tauri-plugin-opener = "2" +tauri-plugin-dialog = "2" serde = { version = "1", features = ["derive"] } serde_json = "1" reqwest = { version = "0.12", default-features = false, features = ["json", "cookies", "multipart", "stream", "rustls-tls"] } urlencoding = "2.1" +walkdir = "2.5" diff --git a/desktop-client/src-tauri/capabilities/default.json b/desktop-client/src-tauri/capabilities/default.json index 4cdbf49..778bfb5 100644 --- a/desktop-client/src-tauri/capabilities/default.json +++ b/desktop-client/src-tauri/capabilities/default.json @@ -5,6 +5,7 @@ "windows": ["main"], "permissions": [ "core:default", - "opener:default" + "opener:default", + "dialog:default" ] } diff --git a/desktop-client/src-tauri/src/lib.rs b/desktop-client/src-tauri/src/lib.rs index 4df5698..c487d8a 100644 --- a/desktop-client/src-tauri/src/lib.rs +++ b/desktop-client/src-tauri/src/lib.rs @@ -6,7 +6,7 @@ use std::env; use std::fs; use std::io::Write; use std::path::{Path, PathBuf}; -use std::time::Duration; +use std::time::{Duration, UNIX_EPOCH}; struct ApiState { client: reqwest::Client, @@ -595,6 +595,122 @@ async fn api_native_download( }) } +#[tauri::command] +async fn api_check_client_update( + state: tauri::State<'_, ApiState>, + base_url: String, + current_version: String, + platform: Option, + channel: Option, +) -> Result { + let normalized_platform = platform + .unwrap_or_else(|| "windows-x64".to_string()) + .trim() + .to_string(); + let normalized_channel = channel + .unwrap_or_else(|| "stable".to_string()) + .trim() + .to_string(); + + let api_url = format!( + "{}?currentVersion={}&platform={}&channel={}", + join_api_url(&base_url, "/api/client/desktop-update"), + urlencoding::encode(current_version.trim()), + urlencoding::encode(&normalized_platform), + urlencoding::encode(&normalized_channel) + ); + request_json(&state.client, Method::GET, api_url, None, None).await +} + +#[tauri::command] +async fn api_list_local_files(dir_path: String) -> Result { + let trimmed = dir_path.trim().to_string(); + if trimmed.is_empty() { + return Err("本地目录不能为空".to_string()); + } + + let root = PathBuf::from(&trimmed); + if !root.exists() { + return Err("本地目录不存在".to_string()); + } + if !root.is_dir() { + return Err("请选择有效的目录路径".to_string()); + } + + let mut items: Vec = Vec::new(); + for entry in walkdir::WalkDir::new(&root) + .follow_links(false) + .into_iter() + .filter_map(Result::ok) + { + if !entry.file_type().is_file() { + continue; + } + + let full_path = entry.path(); + let relative = full_path.strip_prefix(&root).unwrap_or(full_path); + let relative_path = relative.to_string_lossy().replace('\\', "/"); + if relative_path.trim().is_empty() { + continue; + } + + let metadata = match entry.metadata() { + Ok(meta) => meta, + Err(_) => continue, + }; + let modified_ms_u128 = metadata + .modified() + .ok() + .and_then(|value| value.duration_since(UNIX_EPOCH).ok()) + .map(|duration| duration.as_millis()) + .unwrap_or(0); + let modified_ms = std::cmp::min(modified_ms_u128, u128::from(u64::MAX)) as u64; + + let mut row = Map::new(); + row.insert( + "path".to_string(), + Value::String(full_path.to_string_lossy().to_string()), + ); + row.insert("relativePath".to_string(), Value::String(relative_path)); + row.insert( + "size".to_string(), + Value::Number(serde_json::Number::from(metadata.len())), + ); + row.insert( + "modifiedMs".to_string(), + Value::Number(serde_json::Number::from(modified_ms)), + ); + items.push(Value::Object(row)); + } + + items.sort_by(|a, b| { + let av = a + .get("relativePath") + .and_then(Value::as_str) + .unwrap_or_default(); + let bv = b + .get("relativePath") + .and_then(Value::as_str) + .unwrap_or_default(); + av.cmp(bv) + }); + + let mut data = Map::new(); + data.insert("success".to_string(), Value::Bool(true)); + data.insert("rootPath".to_string(), Value::String(trimmed)); + data.insert( + "count".to_string(), + Value::Number(serde_json::Number::from(items.len() as u64)), + ); + data.insert("items".to_string(), Value::Array(items)); + + Ok(BridgeResponse { + ok: true, + status: 200, + data: Value::Object(data), + }) +} + #[tauri::command] async fn api_upload_file( state: tauri::State<'_, ApiState>, @@ -685,6 +801,7 @@ pub fn run() { tauri::Builder::default() .manage(ApiState { client }) + .plugin(tauri_plugin_dialog::init()) .plugin(tauri_plugin_opener::init()) .invoke_handler(tauri::generate_handler![ api_login, @@ -701,6 +818,8 @@ pub fn run() { api_delete_share, api_create_direct_link, api_native_download, + api_check_client_update, + api_list_local_files, api_upload_file ]) .run(tauri::generate_context!()) diff --git a/desktop-client/src/App.vue b/desktop-client/src/App.vue index 2f6ba7b..8c5490b 100644 --- a/desktop-client/src/App.vue +++ b/desktop-client/src/App.vue @@ -2,10 +2,12 @@ import { computed, onBeforeUnmount, onMounted, reactive, ref, watch } from "vue"; import { invoke } from "@tauri-apps/api/core"; import { openUrl } from "@tauri-apps/plugin-opener"; +import { open as openDialog } from "@tauri-apps/plugin-dialog"; +import { getVersion } from "@tauri-apps/api/app"; import type { UnlistenFn } from "@tauri-apps/api/event"; import { getCurrentWebview } from "@tauri-apps/api/webview"; -type NavKey = "files" | "transfers" | "shares"; +type NavKey = "files" | "transfers" | "shares" | "sync" | "updates"; type FileItem = { name: string; @@ -38,6 +40,13 @@ type BridgeResponse = { data: Record; }; +type LocalSyncFileItem = { + path: string; + relativePath: string; + size: number; + modifiedMs: number; +}; + const nav = ref("files"); const authenticated = ref(false); const user = ref | null>(null); @@ -72,6 +81,36 @@ const shares = ref([]); const transferTasks = ref<{ id: string; name: string; speed: string; progress: number; status: string; note?: string }[]>([]); const sharesLoading = ref(false); +const fileViewState = reactive({ + filter: "all", + sortBy: "modifiedAt", + sortOrder: "desc" as "asc" | "desc", +}); +const syncState = reactive({ + localDir: "", + remoteBasePath: "/", + autoEnabled: false, + intervalMinutes: 15, + syncing: false, + scanning: false, + pendingCount: 0, + uploadedCount: 0, + failedCount: 0, + lastRunAt: "", + lastSummary: "", + nextRunAt: "", +}); +const updateState = reactive({ + currentVersion: "0.1.0", + latestVersion: "", + available: false, + mandatory: false, + checking: false, + downloadUrl: "", + releaseNotes: "", + lastCheckedAt: "", + message: "", +}); const contextMenu = reactive({ visible: false, x: 0, @@ -86,6 +125,7 @@ const dropState = reactive({ failed: 0, }); let unlistenDragDrop: UnlistenFn | null = null; +let syncTimer: ReturnType | null = null; const toast = reactive({ visible: false, @@ -98,6 +138,8 @@ const navItems = computed(() => [ { key: "files" as const, label: "全部文件", hint: `${files.value.length} 项` }, { key: "transfers" as const, label: "传输列表", hint: `${transferTasks.value.length} 个任务` }, { key: "shares" as const, label: "我的分享", hint: `${shares.value.length} 条` }, + { key: "sync" as const, label: "同步盘", hint: syncState.localDir ? "已配置" : "未配置" }, + { key: "updates" as const, label: "版本更新", hint: updateState.available ? "有新版本" : "最新" }, ]); const sortedShares = computed(() => { @@ -108,13 +150,62 @@ const sortedShares = computed(() => { }); }); +const fileTypeFilterOptions = [ + { value: "all", label: "全部类型" }, + { value: "directory", label: "仅文件夹" }, + { value: "file", label: "仅文件" }, + { value: "image", label: "图片" }, + { value: "video", label: "视频" }, + { value: "document", label: "文档" }, + { value: "archive", label: "压缩包" }, +]; + +const fileSortOptions = [ + { value: "modifiedAt", label: "按时间" }, + { value: "name", label: "按名称" }, + { value: "size", label: "按大小" }, + { value: "type", label: "按类型" }, +]; + const filteredFiles = computed(() => { const key = searchKeyword.value.trim().toLowerCase(); - if (!key) return files.value; - return files.value.filter((item) => { + const filtered = files.value.filter((item) => { const name = String(item.displayName || item.name || "").toLowerCase(); - return name.includes(key); + if (key && !name.includes(key)) return false; + + const typeFilter = fileViewState.filter; + if (typeFilter === "all") return true; + if (typeFilter === "directory") return Boolean(item.isDirectory || item.type === "directory"); + if (typeFilter === "file") return !item.isDirectory && item.type !== "directory"; + return matchFileTypeFilter(item, typeFilter); }); + + const orderFactor = fileViewState.sortOrder === "asc" ? 1 : -1; + const sorted = [...filtered].sort((a, b) => { + const sortBy = fileViewState.sortBy; + if (sortBy === "name") { + const av = String(a.displayName || a.name || ""); + const bv = String(b.displayName || b.name || ""); + return av.localeCompare(bv, "zh-CN", { sensitivity: "base" }) * orderFactor; + } + if (sortBy === "size") { + const av = Number(a.size || 0); + const bv = Number(b.size || 0); + return (av - bv) * orderFactor; + } + if (sortBy === "type") { + const av = a.isDirectory || a.type === "directory" ? "directory" : "file"; + const bv = b.isDirectory || b.type === "directory" ? "directory" : "file"; + if (av === bv) { + return String(a.displayName || a.name || "").localeCompare(String(b.displayName || b.name || ""), "zh-CN", { sensitivity: "base" }) * orderFactor; + } + return av.localeCompare(bv, "zh-CN", { sensitivity: "base" }) * orderFactor; + } + const av = a.modifiedAt ? new Date(a.modifiedAt).getTime() : 0; + const bv = b.modifiedAt ? new Date(b.modifiedAt).getTime() : 0; + return (av - bv) * orderFactor; + }); + return sorted; }); const breadcrumbs = computed(() => { @@ -132,6 +223,18 @@ const breadcrumbs = computed(() => { return nodes; }); +const toolbarCrumbs = computed(() => { + if (nav.value === "files") return breadcrumbs.value; + const map: Record = { + files: "全部文件", + transfers: "传输列表", + shares: "我的分享", + sync: "同步盘", + updates: "版本更新", + }; + return [{ label: "工作台", path: "/" }, { label: map[nav.value], path: "" }]; +}); + const selectedFile = computed(() => { if (!selectedFileName.value) return null; return files.value.find((item) => item.name === selectedFileName.value) || null; @@ -228,6 +331,16 @@ function fileIcon(item: FileItem) { return "📄"; } +function matchFileTypeFilter(item: FileItem, type: string) { + if (item.isDirectory || item.type === "directory") return false; + const name = String(item.name || "").toLowerCase(); + if (type === "image") return /\.(jpg|jpeg|png|webp|gif|bmp|svg)$/.test(name); + if (type === "video") return /\.(mp4|mkv|mov|avi|webm)$/.test(name); + if (type === "document") return /\.(pdf|doc|docx|xls|xlsx|ppt|pptx|txt|md|csv)$/.test(name); + if (type === "archive") return /\.(zip|rar|7z|tar|gz|bz2|xz)$/.test(name); + return true; +} + function normalizeSharePath(rawPath: string | undefined) { const normalized = normalizePath(rawPath || "/"); return normalized; @@ -299,6 +412,94 @@ function getTaskStatusLabel(status: string) { return status; } +function normalizeRelativePath(rawPath: string) { + return String(rawPath || "").replace(/\\/g, "/").replace(/^\/+/, ""); +} + +function getSyncConfigStorageKey() { + const userId = String(user.value?.id || "guest"); + return `wanwan_desktop_sync_config_v2_${userId}`; +} + +function getSyncSnapshotStorageKey(localDir: string) { + const userId = String(user.value?.id || "guest"); + return `wanwan_desktop_sync_snapshot_v2_${userId}_${encodeURIComponent(localDir || "")}`; +} + +function safeParseObject(raw: string | null) { + if (!raw) return {} as Record; + try { + const parsed = JSON.parse(raw); + return parsed && typeof parsed === "object" ? parsed as Record : {}; + } catch { + return {}; + } +} + +function loadSyncConfig() { + const key = getSyncConfigStorageKey(); + const raw = localStorage.getItem(key); + if (!raw) return; + try { + const parsed = JSON.parse(raw); + if (!parsed || typeof parsed !== "object") return; + syncState.localDir = typeof parsed.localDir === "string" ? parsed.localDir : ""; + syncState.remoteBasePath = normalizePath(typeof parsed.remoteBasePath === "string" ? parsed.remoteBasePath : "/"); + syncState.autoEnabled = Boolean(parsed.autoEnabled); + const interval = Number(parsed.intervalMinutes || 15); + syncState.intervalMinutes = Number.isFinite(interval) && interval >= 5 ? Math.floor(interval) : 15; + } catch { + // ignore invalid cache + } +} + +function saveSyncConfig() { + const key = getSyncConfigStorageKey(); + const payload = { + localDir: syncState.localDir, + remoteBasePath: normalizePath(syncState.remoteBasePath || "/"), + autoEnabled: syncState.autoEnabled, + intervalMinutes: syncState.intervalMinutes, + }; + localStorage.setItem(key, JSON.stringify(payload)); +} + +function loadSyncSnapshot(localDir: string) { + if (!localDir) return {} as Record; + const key = getSyncSnapshotStorageKey(localDir); + return safeParseObject(localStorage.getItem(key)); +} + +function saveSyncSnapshot(localDir: string, snapshot: Record) { + if (!localDir) return; + const key = getSyncSnapshotStorageKey(localDir); + localStorage.setItem(key, JSON.stringify(snapshot)); +} + +function clearSyncScheduler() { + if (syncTimer) { + clearInterval(syncTimer); + syncTimer = null; + } + syncState.nextRunAt = ""; +} + +function syncFingerprint(item: LocalSyncFileItem) { + return `${Number(item.size || 0)}:${Number(item.modifiedMs || 0)}`; +} + +function getRelativeParentPath(relativePath: string) { + const normalized = normalizeRelativePath(relativePath); + if (!normalized) return ""; + const segments = normalized.split("/").filter(Boolean); + if (segments.length <= 1) return ""; + return segments.slice(0, -1).join("/"); +} + +function toggleFileSortOrder() { + fileViewState.sortOrder = fileViewState.sortOrder === "asc" ? "desc" : "asc"; +} + async function invokeBridge(command: string, payload: Record) { try { return await invoke(command, payload); @@ -315,6 +516,219 @@ async function invokeBridge(command: string, payload: Record) { } } +async function initClientVersion() { + try { + const current = await getVersion(); + if (current) { + updateState.currentVersion = current; + } + } catch { + // keep fallback version + } +} + +async function checkClientUpdate(showResultToast = true) { + updateState.checking = true; + updateState.message = ""; + const response = await invokeBridge("api_check_client_update", { + baseUrl: appConfig.baseUrl, + currentVersion: updateState.currentVersion, + platform: "windows-x64", + channel: "stable", + }); + updateState.checking = false; + updateState.lastCheckedAt = new Date().toISOString(); + + if (response.ok && response.data?.success) { + updateState.latestVersion = String(response.data.latestVersion || updateState.currentVersion); + updateState.available = Boolean(response.data.updateAvailable); + updateState.downloadUrl = String(response.data.downloadUrl || ""); + updateState.releaseNotes = String(response.data.releaseNotes || ""); + updateState.mandatory = Boolean(response.data.mandatory); + updateState.message = String(response.data.message || ""); + if (showResultToast) { + if (updateState.available) { + showToast(`发现新版本 ${updateState.latestVersion}`, "success"); + } else { + showToast("当前已是最新版本", "info"); + } + } + return; + } + + updateState.available = false; + updateState.message = String(response.data?.message || "检查更新失败"); + if (showResultToast) { + showToast(updateState.message, "error"); + } +} + +async function openUpdateDownloadUrl() { + if (!updateState.downloadUrl) { + showToast("当前没有可用的更新下载地址", "info"); + return; + } + await openUrl(updateState.downloadUrl); +} + +async function chooseSyncDirectory() { + try { + const result = await openDialog({ + directory: true, + multiple: false, + title: "选择本地同步文件夹", + }); + if (typeof result === "string" && result.trim()) { + syncState.localDir = result.trim(); + showToast("同步目录已更新", "success"); + } + } catch { + showToast("选择目录失败", "error"); + } +} + +function rebuildSyncScheduler() { + clearSyncScheduler(); + if (!authenticated.value || !syncState.autoEnabled || !syncState.localDir.trim()) { + return; + } + const intervalMinutes = Math.max(5, Math.floor(Number(syncState.intervalMinutes) || 15)); + syncState.intervalMinutes = intervalMinutes; + syncState.nextRunAt = new Date(Date.now() + intervalMinutes * 60 * 1000).toISOString(); + syncTimer = setInterval(() => { + void runSyncOnce("auto"); + }, intervalMinutes * 60 * 1000); +} + +async function clearSyncSnapshot() { + if (!syncState.localDir.trim()) { + showToast("请先配置本地同步目录", "info"); + return; + } + localStorage.removeItem(getSyncSnapshotStorageKey(syncState.localDir.trim())); + syncState.lastSummary = "本地同步索引已重置"; + showToast("同步索引已清理,下次会全量扫描", "success"); +} + +async function runSyncOnce(trigger: "manual" | "auto" = "manual") { + if (!authenticated.value) return; + const localDir = syncState.localDir.trim(); + if (!localDir) { + if (trigger === "manual") { + showToast("请先配置本地同步目录", "info"); + } + return; + } + if (syncState.syncing || syncState.scanning) { + if (trigger === "manual") { + showToast("同步任务正在执行,请稍后", "info"); + } + return; + } + + syncState.scanning = true; + const listResp = await invokeBridge("api_list_local_files", { + dirPath: localDir, + }); + syncState.scanning = false; + if (!(listResp.ok && listResp.data?.success)) { + const message = String(listResp.data?.message || "扫描本地目录失败"); + syncState.lastSummary = message; + if (trigger === "manual") showToast(message, "error"); + return; + } + + const items = Array.isArray(listResp.data?.items) ? listResp.data.items as LocalSyncFileItem[] : []; + const previousSnapshot = loadSyncSnapshot(localDir); + const changedItems = items.filter((item) => { + const rel = normalizeRelativePath(item.relativePath); + return rel && previousSnapshot[rel] !== syncFingerprint(item); + }); + + syncState.pendingCount = changedItems.length; + syncState.uploadedCount = 0; + syncState.failedCount = 0; + syncState.syncing = true; + + if (changedItems.length === 0) { + syncState.syncing = false; + syncState.lastRunAt = new Date().toISOString(); + syncState.lastSummary = "没有检测到变更文件"; + if (trigger === "manual") { + showToast("没有检测到需要上传的变更", "info"); + } + if (syncState.autoEnabled) { + syncState.nextRunAt = new Date(Date.now() + syncState.intervalMinutes * 60 * 1000).toISOString(); + } + return; + } + + const successPaths = new Set(); + for (let index = 0; index < changedItems.length; index += 1) { + const item = changedItems[index]; + const relPath = normalizeRelativePath(item.relativePath); + const fileName = extractFileNameFromPath(relPath) || `同步文件${index + 1}`; + const parent = getRelativeParentPath(relPath); + const remoteBase = normalizePath(syncState.remoteBasePath || "/"); + const targetPath = parent ? normalizePath(`${remoteBase}/${parent}`) : remoteBase; + const taskId = `S-${Date.now()}-${index}`; + + prependTransferTask({ + id: taskId, + name: fileName, + speed: trigger === "auto" ? "自动同步" : "手动同步", + progress: 10, + status: "uploading", + note: targetPath, + }); + + const resp = await invokeBridge("api_upload_file", { + baseUrl: appConfig.baseUrl, + filePath: item.path, + targetPath, + }); + + if (resp.ok && resp.data?.success) { + syncState.uploadedCount += 1; + successPaths.add(relPath); + updateTransferTask(taskId, { speed: "-", progress: 100, status: "done", note: "同步完成" }); + } else { + syncState.failedCount += 1; + updateTransferTask(taskId, { + speed: "-", + progress: 0, + status: "failed", + note: String(resp.data?.message || "同步失败"), + }); + } + } + + const nextSnapshot: Record = {}; + for (const item of items) { + const rel = normalizeRelativePath(item.relativePath); + if (!rel) continue; + const fingerprint = syncFingerprint(item); + if (previousSnapshot[rel] === fingerprint || successPaths.has(rel)) { + nextSnapshot[rel] = fingerprint; + } + } + saveSyncSnapshot(localDir, nextSnapshot); + + syncState.syncing = false; + syncState.lastRunAt = new Date().toISOString(); + syncState.lastSummary = `变更 ${changedItems.length} 个,成功 ${syncState.uploadedCount} 个,失败 ${syncState.failedCount} 个`; + if (syncState.autoEnabled) { + syncState.nextRunAt = new Date(Date.now() + syncState.intervalMinutes * 60 * 1000).toISOString(); + } + if (trigger === "manual") { + const toastType = syncState.failedCount > 0 ? "info" : "success"; + showToast(syncState.lastSummary, toastType); + } + if (syncState.uploadedCount > 0 && nav.value === "files") { + await loadFiles(pathState.currentPath); + } +} + async function loadProfile() { const response = await invokeBridge("api_get_profile", { baseUrl: appConfig.baseUrl }); if (response.ok && response.data?.success && response.data?.user) { @@ -456,8 +870,11 @@ async function restoreSession() { const ok = await loadProfile(); if (!ok) return; authenticated.value = true; + loadSyncConfig(); + rebuildSyncScheduler(); await loadFiles("/"); await loadShares(true); + await checkClientUpdate(false); } function buildItemPath(item: FileItem) { @@ -520,10 +937,13 @@ async function handleLogin() { user.value = response.data.user || null; nav.value = "files"; showToast("登录成功,正在同步文件目录", "success"); + loadSyncConfig(); + rebuildSyncScheduler(); await loadFiles("/"); if (!user.value) { await loadProfile(); } + await checkClientUpdate(false); return; } @@ -534,12 +954,25 @@ async function handleLogin() { async function handleLogout() { await invokeBridge("api_logout", { baseUrl: appConfig.baseUrl }); + clearSyncScheduler(); authenticated.value = false; user.value = null; files.value = []; selectedFileName.value = ""; loginForm.password = ""; nav.value = "files"; + syncState.localDir = ""; + syncState.remoteBasePath = "/"; + syncState.autoEnabled = false; + syncState.intervalMinutes = 15; + syncState.syncing = false; + syncState.scanning = false; + syncState.pendingCount = 0; + syncState.uploadedCount = 0; + syncState.failedCount = 0; + syncState.lastRunAt = ""; + syncState.lastSummary = ""; + syncState.nextRunAt = ""; showToast("已退出客户端", "info"); } @@ -831,19 +1264,38 @@ async function registerDragDropListener() { watch(nav, async (next) => { if (next === "shares" && authenticated.value) { await loadShares(); + return; + } + if (next === "updates" && authenticated.value) { + await checkClientUpdate(false); } }); +watch( + () => [syncState.localDir, syncState.remoteBasePath, syncState.autoEnabled, syncState.intervalMinutes, authenticated.value], + () => { + if (!authenticated.value) return; + syncState.remoteBasePath = normalizePath(syncState.remoteBasePath || "/"); + if (syncState.intervalMinutes < 5) { + syncState.intervalMinutes = 5; + } + saveSyncConfig(); + rebuildSyncScheduler(); + }, +); + onMounted(async () => { window.addEventListener("click", handleGlobalClick); window.addEventListener("keydown", handleGlobalKey); await registerDragDropListener(); + await initClientVersion(); await restoreSession(); }); onBeforeUnmount(() => { window.removeEventListener("click", handleGlobalClick); window.removeEventListener("keydown", handleGlobalKey); + clearSyncScheduler(); if (unlistenDragDrop) { unlistenDragDrop(); unlistenDragDrop = null; @@ -935,12 +1387,12 @@ onBeforeUnmount(() => {
@@ -952,6 +1404,13 @@ onBeforeUnmount(() => { placeholder="输入关键词回车全局搜索..." @keyup.enter="runGlobalSearch" /> + + + @@ -959,6 +1418,18 @@ onBeforeUnmount(() => { + +
@@ -1057,37 +1528,215 @@ onBeforeUnmount(() => { + + + + @@ -1471,6 +2120,14 @@ select:focus { font-size: 13px; } +.compact-select { + width: 120px; + height: 36px; + border-radius: 10px; + font-size: 12px; + padding: 0 8px; +} + .solid-btn { height: 36px; min-width: 78px; @@ -1502,6 +2159,11 @@ select:focus { background: #f2f7ff; } +.action-btn:disabled { + opacity: 0.55; + cursor: not-allowed; +} + .action-btn.danger { border-color: #efc3c3; color: #b53f3f; @@ -1764,6 +2426,162 @@ select:focus { gap: 8px; } +.sync-layout, +.update-layout { + display: flex; + flex-direction: column; + gap: 12px; + min-height: 0; +} + +.sync-path-row { + display: grid; + grid-template-columns: 1fr auto; + gap: 8px; +} + +.sync-option-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + flex-wrap: wrap; +} + +.check-line { + display: inline-flex; + align-items: center; + gap: 8px; + color: #3b5574; +} + +.check-line input[type="checkbox"] { + width: 16px; + height: 16px; +} + +.sync-interval { + display: inline-flex; + align-items: center; + gap: 8px; + font-size: 12px; + color: #5c738f; +} + +.sync-summary-grid { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 10px; +} + +.sync-summary-grid > div { + border: 1px solid #d8e1ee; + border-radius: 10px; + background: #f7faff; + padding: 10px 12px; +} + +.sync-summary-grid strong { + display: block; + font-size: 15px; + color: #1f3653; +} + +.sync-summary-grid span { + font-size: 11px; + color: #60768f; +} + +.sync-meta { + border: 1px dashed #cfdced; + border-radius: 10px; + background: #f9fbff; + padding: 10px 12px; +} + +.sync-meta p { + margin: 4px 0; + font-size: 12px; + color: #48627f; +} + +.update-main { + border: 1px solid #d8e1ee; + border-radius: 12px; + background: #fff; + padding: 12px; + display: flex; + flex-direction: column; + gap: 12px; +} + +.update-version-row { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 10px; +} + +.update-version-row > div { + border: 1px solid #d8e1ee; + border-radius: 10px; + background: #f7faff; + padding: 10px 12px; + display: flex; + flex-direction: column; + gap: 4px; +} + +.update-version-row strong { + font-size: 12px; + color: #60748e; +} + +.update-version-row span { + font-size: 14px; + color: #1e3857; +} + +.update-available { + color: #1f9a63 !important; + font-weight: 700; +} + +.update-notes { + border: 1px dashed #cedbeb; + border-radius: 10px; + padding: 10px 12px; + background: #f9fbff; +} + +.update-notes h4 { + margin: 0 0 8px; + font-size: 13px; +} + +.update-notes p { + margin: 0 0 6px; + font-size: 12px; + color: #4f6984; + line-height: 1.5; + white-space: pre-wrap; +} + +.update-mandatory { + color: #c24747 !important; + font-weight: 700; +} + +.update-meta { + display: flex; + flex-direction: column; + gap: 4px; +} + +.update-meta span { + font-size: 12px; + color: #4f6984; +} + .context-mask { position: fixed; inset: 0; @@ -1949,5 +2767,14 @@ select:focus { .share-actions { justify-content: flex-start; } + + .sync-summary-grid, + .update-version-row { + grid-template-columns: 1fr 1fr; + } + + .compact-select { + width: 108px; + } }