feat(secrets-mcp): /entries 按 tags 筛选,发版 0.5.27
All checks were successful
Secrets MCP — Build & Release / 检查 / 构建 / 发版 (push) Successful in 5m48s
Secrets MCP — Build & Release / 部署 secrets-mcp (push) Successful in 5s

- Web:tags 查询参数、folder 计数 SQL、分页与 tab 保留 tags;模板与 i18n
- README / AGENTS / CHANGELOG;plans/web-tags-filter 等计划文档
This commit is contained in:
voson
2026-04-11 21:38:26 +08:00
parent 43d6164a15
commit f86d12b80e
11 changed files with 364 additions and 113 deletions

View File

@@ -45,6 +45,7 @@ struct EntriesPageTemplate {
filter_folder: String,
filter_name: String,
filter_metadata_query: String,
filter_tags: String,
filter_type: String,
current_page: u32,
total_pages: u32,
@@ -125,6 +126,8 @@ pub(super) struct EntriesQuery {
/// URL query key is `type` (maps to DB column `entries.type`).
#[serde(rename = "type")]
entry_type: Option<String>,
/// Comma-separated tags (AND semantics); matches `SearchParams.tags`.
tags: Option<String>,
page: Option<u32>,
}
@@ -252,6 +255,18 @@ fn relation_views(items: &[RelationEntrySummary]) -> Vec<RelationSummaryView> {
.collect()
}
/// Parse Web `tags` query: comma-separated, trim, drop empties (AND semantics via `SearchParams`).
fn parse_tags_filter(raw: Option<&str>) -> Vec<String> {
let Some(s) = raw else {
return Vec::new();
};
s.split(',')
.map(str::trim)
.filter(|t| !t.is_empty())
.map(std::string::ToString::to_string)
.collect()
}
// ── Handlers ──────────────────────────────────────────────────────────────────
pub(super) async fn entries_page(
@@ -289,13 +304,15 @@ pub(super) async fn entries_page(
.map(|s| s.trim())
.filter(|s| !s.is_empty())
.map(|s| s.to_string());
let filter_tags = q.tags.clone().unwrap_or_default();
let tag_vec = parse_tags_filter(q.tags.as_deref());
let page = q.page.unwrap_or(1).max(1);
let count_params = SearchParams {
folder: folder_filter.as_deref(),
entry_type: type_filter.as_deref(),
name: None,
name_query: name_filter.as_deref(),
tags: &[],
tags: tag_vec.as_slice(),
query: None,
metadata_query: metadata_query_filter.as_deref(),
sort: "updated",
@@ -328,6 +345,16 @@ pub(super) async fn entries_page(
));
bind_idx += 1;
}
if !tag_vec.is_empty() {
let placeholders: Vec<String> = (0..tag_vec.len())
.map(|i| format!("${}", bind_idx + i as i32))
.collect();
folder_sql.push_str(&format!(
" AND tags @> ARRAY[{}]::text[]",
placeholders.join(", ")
));
bind_idx += tag_vec.len() as i32;
}
let _ = bind_idx;
folder_sql.push_str(" GROUP BY folder ORDER BY folder");
let mut folder_query = sqlx::query_as::<_, FolderCountRow>(&folder_sql).bind(user_id);
@@ -340,6 +367,9 @@ pub(super) async fn entries_page(
if let Some(v) = metadata_query_filter.as_deref() {
folder_query = folder_query.bind(ilike_pattern(v));
}
for t in &tag_vec {
folder_query = folder_query.bind(t);
}
#[derive(sqlx::FromRow)]
struct TypeOptionRow {
@@ -414,6 +444,7 @@ pub(super) async fn entries_page(
entry_type: Option<&str>,
name: Option<&str>,
metadata_query: Option<&str>,
tags: Option<&str>,
page: Option<u32>,
) -> String {
let mut pairs: Vec<String> = Vec::new();
@@ -437,6 +468,11 @@ pub(super) async fn entries_page(
{
pairs.push(format!("metadata_query={}", urlencoding::encode(v)));
}
if let Some(tg) = tags
&& !tg.is_empty()
{
pairs.push(format!("tags={}", urlencoding::encode(tg)));
}
if let Some(p) = page {
pairs.push(format!("page={}", p));
}
@@ -447,6 +483,7 @@ pub(super) async fn entries_page(
}
}
let tags_for_href = (!filter_tags.is_empty()).then_some(filter_tags.as_str());
let all_count: i64 = folder_rows.iter().map(|r| r.count).sum();
let mut folder_tabs: Vec<FolderTabView> = Vec::with_capacity(folder_rows.len() + 1);
folder_tabs.push(FolderTabView {
@@ -457,6 +494,7 @@ pub(super) async fn entries_page(
type_filter.as_deref(),
name_filter.as_deref(),
metadata_query_filter.as_deref(),
tags_for_href,
Some(1),
),
active: folder_filter.is_none(),
@@ -469,6 +507,7 @@ pub(super) async fn entries_page(
type_filter.as_deref(),
name_filter.as_deref(),
metadata_query_filter.as_deref(),
tags_for_href,
Some(1),
),
active: folder_filter.as_deref() == Some(name.as_str()),
@@ -534,6 +573,7 @@ pub(super) async fn entries_page(
filter_folder: folder_filter.unwrap_or_default(),
filter_name: name_filter.unwrap_or_default(),
filter_metadata_query: metadata_query_filter.unwrap_or_default(),
filter_tags,
filter_type: type_filter.unwrap_or_default(),
current_page,
total_pages,
@@ -1302,3 +1342,19 @@ pub(super) async fn api_entry_secrets_decrypt(
Ok(Json(json!({ "ok": true, "secrets": secrets })))
}
#[cfg(test)]
mod tags_filter_tests {
use super::parse_tags_filter;
#[test]
fn parse_tags_comma_trim_skip_empty() {
let v = parse_tags_filter(Some(" prod , aliyun ,, "));
assert_eq!(v, vec!["prod".to_string(), "aliyun".to_string()]);
}
#[test]
fn parse_tags_none_empty() {
assert!(parse_tags_filter(None).is_empty());
}
}