diff --git a/src-tauri/src/service/asset.rs b/src-tauri/src/service/asset.rs index 7fdd2310d..74f7548d5 100644 --- a/src-tauri/src/service/asset.rs +++ b/src-tauri/src/service/asset.rs @@ -2,6 +2,9 @@ use crate::api::endpoint; use crate::api::request::{ApiRequestClient, ApiResponse}; use log::info; use serde::{Deserialize, Serialize}; +use serde_json::{json, Value}; +use std::cmp::Ordering; +use std::collections::HashSet; use url::Url; #[derive(Debug, Serialize, Deserialize, Clone, Copy, Default)] @@ -12,11 +15,21 @@ pub enum Category { Windows, #[serde(rename = "windows_ad")] WindowsAd, + Unix, + Other, Database, Device, Web, } +#[derive(Debug, Deserialize)] +struct AssetListResponse { + count: usize, + next: Option, + previous: Option, + results: Vec, +} + #[derive(Serialize, Deserialize, Default, Debug, Clone)] pub struct AssetQuery { #[serde(rename = "type", skip_serializing_if = "Option::is_none")] @@ -48,9 +61,12 @@ impl AssetQuery { // r#type 这种形式是因为 type 是 Rust 关键字,所以要写成 r#type let (r#type, category) = match asset_type { Category::Database | Category::Device => (None, Some(asset_type)), - Category::Linux | Category::Windows | Category::WindowsAd | Category::Web => { - (Some(asset_type), None) - } + Category::Web => (None, Some(asset_type)), + Category::Linux + | Category::Windows + | Category::WindowsAd + | Category::Unix + | Category::Other => (Some(asset_type), None), }; Self { @@ -111,30 +127,31 @@ impl AssetService { ); info!("query: {:?}", query); - let (r#type, category) = if favorite { - (None, None) - } else { - match query.get_category() { - Category::Linux => (Some(Category::Linux), None), - Category::Windows => (Some(Category::Windows), None), - Category::WindowsAd => (Some(Category::WindowsAd), None), - Category::Database => (None, Some(Category::Database)), - Category::Device => (None, Some(Category::Device)), - Category::Web => (None, Some(Category::Web)), - } - }; + if favorite { + let favorite_query = AssetQuery { + r#type: None, + category: None, + offset: Some(query.offset.unwrap_or(0)), + limit: Some(query.limit.unwrap_or(20)), + search: Some(query.search.clone().unwrap_or_default()), + order: Some(query.order.clone().unwrap_or_default()), + oid: query.oid.clone(), + }; - let query = AssetQuery { - r#type, - category, - offset: Some(query.offset.unwrap_or(0)), - limit: Some(query.limit.unwrap_or(20)), - search: Some(query.search.clone().unwrap_or_default()), - order: Some(query.order.clone().unwrap_or_default()), - oid: query.oid.clone(), - }; + return self + .api + .get_with_query_response(&url, &favorite_query) + .await; + } + + let queries = Self::expand_queries(query); - self.api.get_with_query_response(&url, &query).await + if queries.len() == 1 { + return self.api.get_with_query_response(&url, &queries[0]).await; + } + + self.get_combined_category_assets(&url, query, &queries) + .await } /// 获取当前用户收藏的资产列表 @@ -184,4 +201,188 @@ impl AssetService { self.api.post_json_with_response(&url, &body).await } + + fn expand_queries(query: &AssetQuery) -> Vec { + let offset = Some(query.offset.unwrap_or(0)); + let limit = Some(query.limit.unwrap_or(20)); + let search = Some(query.search.clone().unwrap_or_default()); + let order = Some(query.order.clone().unwrap_or_default()); + let oid = query.oid.clone(); + + let exact = |r#type: Option, category: Option| AssetQuery { + r#type, + category, + offset, + limit, + search: search.clone(), + order: order.clone(), + oid: oid.clone(), + }; + + // 菜单和 JumpServer 的原始 type/category 不是一一对应: + // Windows 要合并 windows + windows_ad,Other 要合并 unix + other。 + match query.get_category() { + Category::Linux => vec![exact(Some(Category::Linux), None)], + Category::Windows => vec![ + exact(Some(Category::Windows), None), + exact(Some(Category::WindowsAd), None), + ], + Category::WindowsAd => vec![exact(Some(Category::WindowsAd), None)], + Category::Unix => vec![exact(Some(Category::Unix), None)], + Category::Other => vec![ + exact(Some(Category::Unix), None), + exact(Some(Category::Other), None), + ], + Category::Database => vec![exact(None, Some(Category::Database))], + Category::Device => vec![exact(None, Some(Category::Device))], + Category::Web => vec![exact(None, Some(Category::Web))], + } + } + + async fn get_combined_category_assets( + &self, + url: &str, + query: &AssetQuery, + queries: &[AssetQuery], + ) -> ApiResponse { + let mut combined: Vec = Vec::new(); + let mut seen_ids = HashSet::new(); + + for sub_query in queries { + let response = self.fetch_all_assets(url, sub_query).await; + let list = match response { + Ok(list) => list, + Err(err) => return err, + }; + + for item in list { + let asset_id = item + .get("id") + .and_then(Value::as_str) + .unwrap_or_default() + .to_string(); + + // 合并多个接口结果时按资产 ID 去重,避免未来后端类型收敛后重复展示。 + if asset_id.is_empty() || seen_ids.insert(asset_id) { + combined.push(item); + } + } + } + + // 多个独立查询拼起来后需要重新做一次全量排序,否则列表会按子查询分段。 + Self::sort_assets(&mut combined, query.order.as_deref()); + + let total = combined.len(); + let offset = query.offset.unwrap_or(0) as usize; + let limit = query.limit.unwrap_or(20) as usize; + let results = combined + .into_iter() + .skip(offset) + .take(limit) + .collect::>(); + + ApiResponse::ok( + 200, + json!({ + "count": total, + "next": Value::Null, + "previous": Value::Null, + "results": results, + }) + .to_string(), + ) + } + + async fn fetch_all_assets( + &self, + url: &str, + query: &AssetQuery, + ) -> Result, ApiResponse> { + const BATCH_LIMIT: u32 = 200; + + let mut offset = 0; + let mut all_results = Vec::new(); + + loop { + let page_query = AssetQuery { + r#type: query.r#type, + category: query.category, + offset: Some(offset), + limit: Some(BATCH_LIMIT), + search: Some(query.search.clone().unwrap_or_default()), + order: Some(query.order.clone().unwrap_or_default()), + oid: query.oid.clone(), + }; + + let response = self.api.get_with_query_response(url, &page_query).await; + if !response.success { + return Err(response); + } + + let payload: AssetListResponse = match serde_json::from_str(&response.data) { + Ok(payload) => payload, + Err(error) => { + return Err(ApiResponse::failed(format!( + "parse asset list response failed: {}", + error + ))) + } + }; + + let AssetListResponse { + count, + next: _next, + previous: _previous, + results, + } = payload; + + let page_size = results.len(); + all_results.extend(results); + + // 这里主动翻完后端分页,前端菜单才能在“组合类型”场景下拿到准确总数和切片。 + if page_size == 0 || all_results.len() >= count { + break; + } + + offset += page_size as u32; + } + + Ok(all_results) + } + + fn sort_assets(results: &mut [Value], order: Option<&str>) { + let normalized = order + .map(str::trim) + .filter(|value| !value.is_empty()) + .unwrap_or("name"); + + // 当前客户端只有 name / date_updated 这几种排序,空值时退回 name 保证混合结果稳定。 + let descending = normalized.starts_with('-'); + let field = normalized.trim_start_matches('-'); + + results.sort_by(|left, right| { + let ordering = Self::compare_asset_field(left, right, field); + if descending { + ordering.reverse() + } else { + ordering + } + }); + } + + fn compare_asset_field(left: &Value, right: &Value, field: &str) -> Ordering { + let left_value = Self::extract_asset_field(left, field); + let right_value = Self::extract_asset_field(right, field); + + left_value.cmp(&right_value).then_with(|| { + Self::extract_asset_field(left, "id").cmp(&Self::extract_asset_field(right, "id")) + }) + } + + fn extract_asset_field(item: &Value, field: &str) -> String { + item.get(field) + .and_then(Value::as_str) + .unwrap_or_default() + .to_lowercase() + } } diff --git a/src-tauri/src/service/config.rs b/src-tauri/src/service/config.rs index 85224da44..c27790d2a 100644 --- a/src-tauri/src/service/config.rs +++ b/src-tauri/src/service/config.rs @@ -312,12 +312,7 @@ impl ConfigService { .parent() .ok_or_else(|| "invalid config directory".to_string())?; return PluginService::update_selection( - app, - config_dir, - category, - protocol, - name, - new_path, + app, config_dir, category, protocol, name, new_path, ); } diff --git a/src-tauri/src/service/mod.rs b/src-tauri/src/service/mod.rs index 2411010e2..2a25950a1 100644 --- a/src-tauri/src/service/mod.rs +++ b/src-tauri/src/service/mod.rs @@ -1,8 +1,8 @@ pub(crate) mod asset; pub(crate) mod config; -pub(crate) mod plugin; pub(crate) mod connect; pub(crate) mod oauth; +pub(crate) mod plugin; pub(crate) mod setting; pub(crate) mod token; pub(crate) mod user; diff --git a/src-tauri/src/service/plugin.rs b/src-tauri/src/service/plugin.rs index bf586fcf8..c0da3fe5e 100644 --- a/src-tauri/src/service/plugin.rs +++ b/src-tauri/src/service/plugin.rs @@ -36,7 +36,9 @@ impl PluginService { cwd.join("../plugins/builtin"), cwd.join("../../plugins/builtin"), ]; - candidates.into_iter().find(|p| p.join("index.json").is_file()) + candidates + .into_iter() + .find(|p| p.join("index.json").is_file()) } fn resolve_defaults_path(app: &AppHandle) -> Option { @@ -69,12 +71,12 @@ impl PluginService { } fn launch_to_arg_format(launch: &Value) -> (String, Option) { - let launch_type = launch.get("type").and_then(|v| v.as_str()).unwrap_or("args"); + let launch_type = launch + .get("type") + .and_then(|v| v.as_str()) + .unwrap_or("args"); match launch_type { - "autoit" => ( - String::new(), - launch.get("steps").cloned(), - ), + "autoit" => (String::new(), launch.get("steps").cloned()), "file" => { let template = launch .get("arg_template") @@ -120,15 +122,13 @@ impl PluginService { .unwrap_or("") .to_string(); - let path = user_path - .clone() - .unwrap_or_else(|| { - if !default_path.is_empty() { - default_path - } else { - exec_default - } - }); + let path = user_path.clone().unwrap_or_else(|| { + if !default_path.is_empty() { + default_path + } else { + exec_default + } + }); let is_internal = platform_defaults .get("is_internal") @@ -219,9 +219,7 @@ impl PluginService { let connect = Self::read_json(&plugin_dir.join("connect.json"))?; let defaults = Self::read_json(&plugin_dir.join("defaults.json")).unwrap_or(json!({})); - let platform_connect = connect - .get("platforms") - .and_then(|p| p.get(os_key)); + let platform_connect = connect.get("platforms").and_then(|p| p.get(os_key)); let Some(platform_connect) = platform_connect else { return Ok(None); }; @@ -247,9 +245,7 @@ impl PluginService { .or_else(|| manifest.get("display_name").and_then(|v| v.as_str())) .unwrap_or(plugin_id); - let launch = platform_connect - .get("launch") - .unwrap_or(&Value::Null); + let launch = platform_connect.get("launch").unwrap_or(&Value::Null); let (arg_format, autoit) = Self::launch_to_arg_format(launch); let (path, is_set, is_internal) = @@ -337,10 +333,7 @@ impl PluginService { .get("id") .and_then(|v| v.as_str()) .ok_or_else(|| "plugin entry missing id".to_string())?; - let category = entry - .get("category") - .and_then(|v| v.as_str()) - .unwrap_or(""); + let category = entry.get("category").and_then(|v| v.as_str()).unwrap_or(""); let plugin_dir = builtin_dir.join(plugin_id); if !plugin_dir.is_dir() { @@ -348,13 +341,9 @@ impl PluginService { continue; } - if let Some(item) = Self::plugin_to_app_item( - plugin_id, - &plugin_dir, - os_key, - &selections, - &user_state, - )? { + if let Some(item) = + Self::plugin_to_app_item(plugin_id, &plugin_dir, os_key, &selections, &user_state)? + { if let Some(list) = per_category.get_mut(category) { list.push(item); } @@ -401,9 +390,7 @@ impl PluginService { else { continue; }; - let Some(platform_defaults) = defaults - .get("platforms") - .and_then(|p| p.get(os_key)) + let Some(platform_defaults) = defaults.get("platforms").and_then(|p| p.get(os_key)) else { continue; }; @@ -478,16 +465,15 @@ impl PluginService { ) -> Result { let builtin_dir = Self::resolve_builtin_dir(app) .ok_or_else(|| "plugins/builtin not found".to_string())?; - let plugin_id = Self::find_plugin_id_by_name(&builtin_dir, name, category).ok_or_else(|| { - format!("plugin '{name}' not found in category '{category}'") - })?; + let plugin_id = Self::find_plugin_id_by_name(&builtin_dir, name, category) + .ok_or_else(|| format!("plugin '{name}' not found in category '{category}'"))?; let state_path = config_dir.join("plugins-state.json"); let mut state = Self::load_user_state(app, config_dir); let connect = Self::read_json(&builtin_dir.join(&plugin_id).join("connect.json"))?; - let defaults = - Self::read_json(&builtin_dir.join(&plugin_id).join("defaults.json")).unwrap_or(json!({})); + let defaults = Self::read_json(&builtin_dir.join(&plugin_id).join("defaults.json")) + .unwrap_or(json!({})); let os_key = Self::os_key(); let platform_connect = connect .get("platforms") diff --git a/ui/components/Card/AssetIcon/assetIcon.vue b/ui/components/Card/AssetIcon/assetIcon.vue index acd2526dd..80be2c4af 100644 --- a/ui/components/Card/AssetIcon/assetIcon.vue +++ b/ui/components/Card/AssetIcon/assetIcon.vue @@ -1,8 +1,8 @@ + + diff --git a/ui/pages/windows_ad.vue b/ui/pages/windows_ad.vue index d7dd51a37..74c4fee5d 100644 --- a/ui/pages/windows_ad.vue +++ b/ui/pages/windows_ad.vue @@ -1,5 +1,5 @@ - + diff --git a/ui/types/index.ts b/ui/types/index.ts index 2baa6ffbd..3612ff680 100644 --- a/ui/types/index.ts +++ b/ui/types/index.ts @@ -8,7 +8,7 @@ export type LangType = "zh" | "en"; export type LanguagePreference = LangType | "system"; export type CharsetType = "default" | "utf8" | "gbk" | "gb2312" | "ios-8859-1"; export type ResolutionType = "auto" | "1024x768" | "1366x768" | "1600x900" | "1920x1080"; -export type AssetPageType = "linux" | "windows" | "windows_ad" | "database" | "device" | "web" | "favorite"; +export type AssetPageType = "linux" | "windows" | "windows_ad" | "other" | "database" | "device" | "web" | "favorite"; export interface ActionItem { key: string