feat(secrets-mcp): /entries 按 tags 筛选,发版 0.5.27
- Web:tags 查询参数、folder 计数 SQL、分页与 tab 保留 tags;模板与 i18n - README / AGENTS / CHANGELOG;plans/web-tags-filter 等计划文档
This commit is contained in:
@@ -1,5 +1,11 @@
|
||||
本文档在构建时嵌入 Web 的 `/changelog` 页面,并由服务端渲染为 HTML。
|
||||
|
||||
## [0.5.27] - 2026-04-11
|
||||
|
||||
### Added
|
||||
|
||||
- Web **`/entries`**:按 **tags** 筛选(逗号分隔、trim、多标签 **AND** 语义,与 `SearchParams` / MCP 一致);folder 标签计数、分页与筛选栏状态同步保留 `tags`。
|
||||
|
||||
## [0.5.26] - 2026-04-11
|
||||
|
||||
### Fixed
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "secrets-mcp"
|
||||
version = "0.5.26"
|
||||
version = "0.5.27"
|
||||
edition.workspace = true
|
||||
|
||||
[[bin]]
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -543,6 +543,10 @@
|
||||
<label for="filter-name" data-i18n="filterNameLabel">名称</label>
|
||||
<input id="filter-name" name="name" type="text" value="{{ filter_name }}" data-i18n-ph="filterNamePlaceholder" placeholder="输入关键字" autocomplete="off">
|
||||
</div>
|
||||
<div class="filter-field">
|
||||
<label for="filter-tags" data-i18n="filterTagsLabel">标签</label>
|
||||
<input id="filter-tags" name="tags" type="text" value="{{ filter_tags }}" data-i18n-ph="filterTagsPlaceholder" placeholder="多个标签用逗号分隔" autocomplete="off">
|
||||
</div>
|
||||
<div class="filter-field">
|
||||
<label for="filter-metadata-query" data-i18n="filterMetadataLabel">元数据值</label>
|
||||
<input id="filter-metadata-query" name="metadata_query" type="text" value="{{ filter_metadata_query }}" data-i18n-ph="filterMetadataPlaceholder" placeholder="搜索元数据值" autocomplete="off">
|
||||
@@ -643,13 +647,13 @@
|
||||
{% if total_count > 0 %}
|
||||
<div class="pagination">
|
||||
{% if current_page > 1 %}
|
||||
<a href="?{% if !filter_folder.is_empty() %}folder={{ filter_folder | urlencode }}&{% endif %}{% if !filter_type.is_empty() %}type={{ filter_type | urlencode }}&{% endif %}{% if !filter_name.is_empty() %}name={{ filter_name | urlencode }}&{% endif %}{% if !filter_metadata_query.is_empty() %}metadata_query={{ filter_metadata_query | urlencode }}&{% endif %}page={{ current_page - 1 }}" class="page-btn" data-i18n="prevPage">上一页</a>
|
||||
<a href="?{% if !filter_folder.is_empty() %}folder={{ filter_folder | urlencode }}&{% endif %}{% if !filter_type.is_empty() %}type={{ filter_type | urlencode }}&{% endif %}{% if !filter_name.is_empty() %}name={{ filter_name | urlencode }}&{% endif %}{% if !filter_tags.is_empty() %}tags={{ filter_tags | urlencode }}&{% endif %}{% if !filter_metadata_query.is_empty() %}metadata_query={{ filter_metadata_query | urlencode }}&{% endif %}page={{ current_page - 1 }}" class="page-btn" data-i18n="prevPage">上一页</a>
|
||||
{% else %}
|
||||
<span class="page-btn page-btn-disabled" data-i18n="prevPage">上一页</span>
|
||||
{% endif %}
|
||||
<span class="page-info">{{ current_page }} / {{ total_pages }}</span>
|
||||
{% if current_page < total_pages %}
|
||||
<a href="?{% if !filter_folder.is_empty() %}folder={{ filter_folder | urlencode }}&{% endif %}{% if !filter_type.is_empty() %}type={{ filter_type | urlencode }}&{% endif %}{% if !filter_name.is_empty() %}name={{ filter_name | urlencode }}&{% endif %}{% if !filter_metadata_query.is_empty() %}metadata_query={{ filter_metadata_query | urlencode }}&{% endif %}page={{ current_page + 1 }}" class="page-btn" data-i18n="nextPage">下一页</a>
|
||||
<a href="?{% if !filter_folder.is_empty() %}folder={{ filter_folder | urlencode }}&{% endif %}{% if !filter_type.is_empty() %}type={{ filter_type | urlencode }}&{% endif %}{% if !filter_name.is_empty() %}name={{ filter_name | urlencode }}&{% endif %}{% if !filter_tags.is_empty() %}tags={{ filter_tags | urlencode }}&{% endif %}{% if !filter_metadata_query.is_empty() %}metadata_query={{ filter_metadata_query | urlencode }}&{% endif %}page={{ current_page + 1 }}" class="page-btn" data-i18n="nextPage">下一页</a>
|
||||
{% else %}
|
||||
<span class="page-btn page-btn-disabled" data-i18n="nextPage">下一页</span>
|
||||
{% endif %}
|
||||
@@ -713,6 +717,8 @@ var SECRET_TYPE_OPTIONS = JSON.parse(document.getElementById('secret-type-option
|
||||
allTab: '全部',
|
||||
filterNameLabel: '名称',
|
||||
filterNamePlaceholder: '输入关键字',
|
||||
filterTagsLabel: '标签',
|
||||
filterTagsPlaceholder: '多个标签用逗号分隔',
|
||||
filterMetadataLabel: '元数据值',
|
||||
filterMetadataPlaceholder: '搜索元数据值',
|
||||
filterTypeLabel: '类型',
|
||||
@@ -799,6 +805,8 @@ var SECRET_TYPE_OPTIONS = JSON.parse(document.getElementById('secret-type-option
|
||||
allTab: '全部',
|
||||
filterNameLabel: '名稱',
|
||||
filterNamePlaceholder: '輸入關鍵字',
|
||||
filterTagsLabel: '標籤',
|
||||
filterTagsPlaceholder: '多個標籤用逗號分隔',
|
||||
filterMetadataLabel: '中繼資料值',
|
||||
filterMetadataPlaceholder: '搜尋中繼資料值',
|
||||
filterTypeLabel: '類型',
|
||||
@@ -885,6 +893,8 @@ var SECRET_TYPE_OPTIONS = JSON.parse(document.getElementById('secret-type-option
|
||||
allTab: 'All',
|
||||
filterNameLabel: 'Name',
|
||||
filterNamePlaceholder: 'Enter keywords',
|
||||
filterTagsLabel: 'Tags',
|
||||
filterTagsPlaceholder: 'Comma-separated tags',
|
||||
filterMetadataLabel: 'Metadata value',
|
||||
filterMetadataPlaceholder: 'Search metadata values',
|
||||
filterTypeLabel: 'Type',
|
||||
|
||||
Reference in New Issue
Block a user