feat(desktop): add sort/filter, update center, and local sync workspace

This commit is contained in:
2026-02-18 20:07:21 +08:00
parent d4818a78d3
commit 32a66e6c77
8 changed files with 1184 additions and 39 deletions

View File

@@ -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_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_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 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_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_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 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' || COOKIE_SECURE_MODE === 'true' ||
(process.env.NODE_ENV === 'production' && COOKIE_SECURE_MODE !== 'false'); (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() { function getResolvedStorageRoot() {
const configuredRoot = process.env.STORAGE_ROOT; const configuredRoot = process.env.STORAGE_ROOT;
if (!configuredRoot) { 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 // 生成验证码API
app.get('/api/captcha', captchaRateLimitMiddleware, (req, res) => { app.get('/api/captcha', captchaRateLimitMiddleware, (req, res) => {
try { try {
@@ -8479,6 +8565,7 @@ app.get('/api/admin/settings', authMiddleware, adminMiddleware, (req, res) => {
const smtpHasPassword = !!SettingsDB.get('smtp_password'); const smtpHasPassword = !!SettingsDB.get('smtp_password');
const globalTheme = SettingsDB.get('global_theme') || 'dark'; const globalTheme = SettingsDB.get('global_theme') || 'dark';
const downloadSecurity = getDownloadSecuritySettings(); const downloadSecurity = getDownloadSecuritySettings();
const desktopUpdate = getDesktopUpdateConfig();
res.json({ res.json({
success: true, success: true,
@@ -8486,6 +8573,12 @@ app.get('/api/admin/settings', authMiddleware, adminMiddleware, (req, res) => {
max_upload_size: maxUploadSize, max_upload_size: maxUploadSize,
global_theme: globalTheme, global_theme: globalTheme,
download_security: downloadSecurity, download_security: downloadSecurity,
desktop_update: {
latest_version: desktopUpdate.latestVersion,
installer_url: desktopUpdate.installerUrl,
release_notes: desktopUpdate.releaseNotes,
force_update: desktopUpdate.mandatory
},
smtp: { smtp: {
host: smtpHost || '', host: smtpHost || '',
port: smtpPort ? parseInt(smtpPort, 10) : 465, port: smtpPort ? parseInt(smtpPort, 10) : 465,
@@ -8516,7 +8609,8 @@ app.post('/api/admin/settings',
max_upload_size, max_upload_size,
smtp, smtp,
global_theme, global_theme,
download_security download_security,
desktop_update
} = req.body; } = req.body;
if (max_upload_size !== undefined) { 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({ res.json({
success: true, success: true,
message: '系统设置已更新' message: '系统设置已更新'

View File

@@ -9,6 +9,7 @@
"version": "0.1.0", "version": "0.1.0",
"dependencies": { "dependencies": {
"@tauri-apps/api": "^2", "@tauri-apps/api": "^2",
"@tauri-apps/plugin-dialog": "^2.6.0",
"@tauri-apps/plugin-opener": "^2", "@tauri-apps/plugin-opener": "^2",
"vue": "^3.5.13" "vue": "^3.5.13"
}, },
@@ -1091,6 +1092,15 @@
"node": ">= 10" "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": { "node_modules/@tauri-apps/plugin-opener": {
"version": "2.5.3", "version": "2.5.3",
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-opener/-/plugin-opener-2.5.3.tgz", "resolved": "https://registry.npmjs.org/@tauri-apps/plugin-opener/-/plugin-opener-2.5.3.tgz",

View File

@@ -10,15 +10,16 @@
"tauri": "tauri" "tauri": "tauri"
}, },
"dependencies": { "dependencies": {
"vue": "^3.5.13",
"@tauri-apps/api": "^2", "@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": { "devDependencies": {
"@tauri-apps/cli": "^2",
"@vitejs/plugin-vue": "^5.2.1", "@vitejs/plugin-vue": "^5.2.1",
"typescript": "~5.6.2", "typescript": "~5.6.2",
"vite": "^6.0.3", "vite": "^6.0.3",
"vue-tsc": "^2.1.10", "vue-tsc": "^2.1.10"
"@tauri-apps/cli": "^2"
} }
} }

View File

@@ -688,8 +688,10 @@ dependencies = [
"serde_json", "serde_json",
"tauri", "tauri",
"tauri-build", "tauri-build",
"tauri-plugin-dialog",
"tauri-plugin-opener", "tauri-plugin-opener",
"urlencoding", "urlencoding",
"walkdir",
] ]
[[package]] [[package]]
@@ -736,6 +738,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "89a09f22a6c6069a18470eb92d2298acf25463f14256d24778e1230d789a2aec" checksum = "89a09f22a6c6069a18470eb92d2298acf25463f14256d24778e1230d789a2aec"
dependencies = [ dependencies = [
"bitflags 2.11.0", "bitflags 2.11.0",
"block2",
"libc",
"objc2", "objc2",
] ]
@@ -3162,6 +3166,30 @@ dependencies = [
"web-sys", "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]] [[package]]
name = "ring" name = "ring"
version = "0.17.14" version = "0.17.14"
@@ -3927,6 +3955,46 @@ dependencies = [
"walkdir", "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]] [[package]]
name = "tauri-plugin-opener" name = "tauri-plugin-opener"
version = "2.5.3" version = "2.5.3"

View File

@@ -20,7 +20,9 @@ tauri-build = { version = "2", features = [] }
[dependencies] [dependencies]
tauri = { version = "2", features = [] } tauri = { version = "2", features = [] }
tauri-plugin-opener = "2" tauri-plugin-opener = "2"
tauri-plugin-dialog = "2"
serde = { version = "1", features = ["derive"] } serde = { version = "1", features = ["derive"] }
serde_json = "1" 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"

View File

@@ -5,6 +5,7 @@
"windows": ["main"], "windows": ["main"],
"permissions": [ "permissions": [
"core:default", "core:default",
"opener:default" "opener:default",
"dialog:default"
] ]
} }

View File

@@ -6,7 +6,7 @@ use std::env;
use std::fs; use std::fs;
use std::io::Write; use std::io::Write;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::time::Duration; use std::time::{Duration, UNIX_EPOCH};
struct ApiState { struct ApiState {
client: reqwest::Client, 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<String>,
channel: Option<String>,
) -> Result<BridgeResponse, String> {
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<BridgeResponse, String> {
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<Value> = 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] #[tauri::command]
async fn api_upload_file( async fn api_upload_file(
state: tauri::State<'_, ApiState>, state: tauri::State<'_, ApiState>,
@@ -685,6 +801,7 @@ pub fn run() {
tauri::Builder::default() tauri::Builder::default()
.manage(ApiState { client }) .manage(ApiState { client })
.plugin(tauri_plugin_dialog::init())
.plugin(tauri_plugin_opener::init()) .plugin(tauri_plugin_opener::init())
.invoke_handler(tauri::generate_handler![ .invoke_handler(tauri::generate_handler![
api_login, api_login,
@@ -701,6 +818,8 @@ pub fn run() {
api_delete_share, api_delete_share,
api_create_direct_link, api_create_direct_link,
api_native_download, api_native_download,
api_check_client_update,
api_list_local_files,
api_upload_file api_upload_file
]) ])
.run(tauri::generate_context!()) .run(tauri::generate_context!())

File diff suppressed because it is too large Load Diff