diff --git a/AGENTS.md b/AGENTS.md index a8a17c1..0aba73e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -182,6 +182,8 @@ oauth_accounts ( 列表仅展示非敏感字段;**名称**与**操作**列为固定列(不可在「显示列」中关闭)。**文件夹**(对应 `entries.folder`)、类型、备注、标签、关联、密文等为**可选列**,由用户在「显示列」面板中勾选;可见性保存在浏览器 `localStorage`,键为 **`entries_col_vis`**。新增列会并入默认:若用户曾保存过旧版配置,缺失的列键会按当前默认补齐。**文件夹**列默认**显示**,便于在「全部」等跨 folder 视图下区分条目所属隔离空间。 +筛选栏支持查询参数 **`tags`**(逗号分隔,多标签 **AND**,语义同 `SearchParams.tags` / `tags @> ARRAY[...]`);分页与 folder 标签计数与当前筛选一致。 + ### 导出 / 导入文件 JSON/TOML/YAML 导出可在每条目上包含 `secret_types`(secret 名 → `text` / `password` / `key` 等),导入时写回 `secrets.type`;**旧版导出无该字段**时导入仍成功,类型按 **`text`** 默认。 diff --git a/Cargo.lock b/Cargo.lock index 5dc7cc5..080fc96 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2105,7 +2105,7 @@ dependencies = [ [[package]] name = "secrets-mcp" -version = "0.5.26" +version = "0.5.27" dependencies = [ "anyhow", "askama", diff --git a/README.md b/README.md index a97e2ed..e11dc5e 100644 --- a/README.md +++ b/README.md @@ -47,7 +47,7 @@ SECRETS_DATABASE_SSL_ROOT_CERT=/etc/secrets/pg-ca.crt SECRETS_ENV=production ``` -- **Web**:`BASE_URL`(登录、Dashboard、设置密码短语、创建 API Key)。**变更记录**页 **`/changelog`**:内容来自 `crates/secrets-mcp/CHANGELOG.md`(构建时嵌入并以 Markdown 渲染);首页页脚与 Dashboard(MCP)页脚均提供入口。**条目**页 `/entries` 支持 folder 标签与条件筛选;表格列可在「显示列」中开关(名称与操作固定),**文件夹**列为可选列且默认显示。列可见性持久化见 [AGENTS.md](AGENTS.md)「Web 条目页表格列」。 +- **Web**:`BASE_URL`(登录、Dashboard、设置密码短语、创建 API Key)。**变更记录**页 **`/changelog`**:内容来自 `crates/secrets-mcp/CHANGELOG.md`(构建时嵌入并以 Markdown 渲染);首页页脚与 Dashboard(MCP)页脚均提供入口。**条目**页 `/entries` 支持 folder 标签与条件筛选(含 **`tags`** 逗号分隔、多标签同时匹配);表格列可在「显示列」中开关(名称与操作固定),**文件夹**列为可选列且默认显示。列可见性持久化见 [AGENTS.md](AGENTS.md)「Web 条目页表格列」。 - **MCP**:Streamable HTTP 基址 `{BASE_URL}/mcp`,需 `Authorization: Bearer ` + `X-Encryption-Key: ` 请求头(读密文工具须带密钥)。 ## PostgreSQL TLS 加固 diff --git a/crates/secrets-mcp/CHANGELOG.md b/crates/secrets-mcp/CHANGELOG.md index 78ff07e..2053c54 100644 --- a/crates/secrets-mcp/CHANGELOG.md +++ b/crates/secrets-mcp/CHANGELOG.md @@ -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 diff --git a/crates/secrets-mcp/Cargo.toml b/crates/secrets-mcp/Cargo.toml index 2d849f5..e6c817d 100644 --- a/crates/secrets-mcp/Cargo.toml +++ b/crates/secrets-mcp/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "secrets-mcp" -version = "0.5.26" +version = "0.5.27" edition.workspace = true [[bin]] diff --git a/crates/secrets-mcp/src/web/entries.rs b/crates/secrets-mcp/src/web/entries.rs index 3fc1a2c..6c6621d 100644 --- a/crates/secrets-mcp/src/web/entries.rs +++ b/crates/secrets-mcp/src/web/entries.rs @@ -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, + /// Comma-separated tags (AND semantics); matches `SearchParams.tags`. + tags: Option, page: Option, } @@ -252,6 +255,18 @@ fn relation_views(items: &[RelationEntrySummary]) -> Vec { .collect() } +/// Parse Web `tags` query: comma-separated, trim, drop empties (AND semantics via `SearchParams`). +fn parse_tags_filter(raw: Option<&str>) -> Vec { + 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 = (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, ) -> String { let mut pairs: Vec = 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 = 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()); + } +} diff --git a/crates/secrets-mcp/templates/entries.html b/crates/secrets-mcp/templates/entries.html index 17d698c..d741b2e 100644 --- a/crates/secrets-mcp/templates/entries.html +++ b/crates/secrets-mcp/templates/entries.html @@ -543,6 +543,10 @@ +
+ + +
@@ -643,13 +647,13 @@ {% if total_count > 0 %}