feat(web): 条目列表页 /entries 与总条数统计
All checks were successful
Secrets MCP — Build & Release / 检查 / 构建 / 发版 (push) Successful in 3m56s
Secrets MCP — Build & Release / 部署 secrets-mcp (push) Successful in 6s

- 新增受会话保护的 GET /entries,仅展示 entries 非敏感列
- search: list_entries、count_entries 共享筛选条件;分页与计数不读 secrets
- 侧边栏在 dashboard/audit 增加「条目」入口
- secrets-mcp 0.3.4(tag 尚未存在)

Made-with: Cursor
This commit is contained in:
2026-04-02 11:26:51 +08:00
parent 1b11f7e976
commit 87a29af82d
7 changed files with 353 additions and 48 deletions

View File

@@ -19,6 +19,7 @@ use secrets_core::crypto::hex;
use secrets_core::service::{
api_key::{ensure_api_key, regenerate_api_key},
audit_log::list_for_user,
search::{SearchParams, count_entries, list_entries},
user::{
OAuthProfile, bind_oauth_account, find_or_create_user, get_user_by_id,
unbind_oauth_account, update_user_key_setup,
@@ -78,6 +79,33 @@ struct AuditEntryView {
detail: String,
}
#[derive(Template)]
#[template(path = "entries.html")]
struct EntriesPageTemplate {
user_name: String,
user_email: String,
entries: Vec<EntryListItemView>,
total_count: i64,
shown_count: usize,
limit: u32,
version: &'static str,
}
/// Non-sensitive fields only (no `secrets` / ciphertext).
struct EntryListItemView {
folder: String,
entry_type: String,
name: String,
notes: String,
tags: String,
metadata: String,
/// RFC3339 UTC for `<time datetime>`; localized in entries.html.
updated_at_iso: String,
}
/// Cap for HTML list (avoids loading unbounded rows into memory).
const ENTRIES_PAGE_LIMIT: u32 = 5_000;
// ── App state helpers ─────────────────────────────────────────────────────────
fn google_cfg(state: &AppState) -> Option<&OAuthConfig> {
@@ -149,6 +177,7 @@ pub fn web_router() -> Router<AppState> {
.route("/auth/google/callback", get(auth_google_callback))
.route("/auth/logout", post(auth_logout))
.route("/dashboard", get(dashboard))
.route("/entries", get(entries_page))
.route("/audit", get(audit_page))
.route("/account/bind/google", get(account_bind_google))
.route(
@@ -478,6 +507,72 @@ async fn dashboard(
render_template(tmpl)
}
async fn entries_page(
State(state): State<AppState>,
session: Session,
) -> Result<Response, StatusCode> {
let Some(user_id) = current_user_id(&session).await else {
return Ok(Redirect::to("/login").into_response());
};
let user = match get_user_by_id(&state.pool, user_id).await.map_err(|e| {
tracing::error!(error = %e, %user_id, "failed to load user for entries page");
StatusCode::INTERNAL_SERVER_ERROR
})? {
Some(u) => u,
None => return Ok(Redirect::to("/login").into_response()),
};
let params = SearchParams {
folder: None,
entry_type: None,
name: None,
tags: &[],
query: None,
sort: "updated",
limit: ENTRIES_PAGE_LIMIT,
offset: 0,
user_id: Some(user_id),
};
let total_count = count_entries(&state.pool, &params).await.map_err(|e| {
tracing::error!(error = %e, "failed to count entries for web");
StatusCode::INTERNAL_SERVER_ERROR
})?;
let rows = list_entries(&state.pool, params).await.map_err(|e| {
tracing::error!(error = %e, "failed to load entries list for web");
StatusCode::INTERNAL_SERVER_ERROR
})?;
let shown_count = rows.len();
let entries = rows
.into_iter()
.map(|e| EntryListItemView {
folder: e.folder,
entry_type: e.entry_type,
name: e.name,
notes: e.notes,
tags: e.tags.join(", "),
metadata: serde_json::to_string_pretty(&e.metadata)
.unwrap_or_else(|_| "{}".to_string()),
updated_at_iso: e.updated_at.to_rfc3339_opts(SecondsFormat::Secs, true),
})
.collect();
let tmpl = EntriesPageTemplate {
user_name: user.name.clone(),
user_email: user.email.clone().unwrap_or_default(),
entries,
total_count,
shown_count,
limit: ENTRIES_PAGE_LIMIT,
version: env!("CARGO_PKG_VERSION"),
};
render_template(tmpl)
}
async fn audit_page(
State(state): State<AppState>,
session: Session,