From 1860cce86c5aab6b472e31930c8c538d15b864ad Mon Sep 17 00:00:00 2001 From: voson Date: Sun, 5 Apr 2026 10:45:33 +0800 Subject: [PATCH] =?UTF-8?q?release(secrets-mcp):=200.5.3=20=E2=80=94=20?= =?UTF-8?q?=E5=AE=A1=E8=AE=A1=E6=97=A5=E5=BF=97=E5=88=86=E9=A1=B5=E4=B8=8E?= =?UTF-8?q?=20Web=EF=BC=9BCONTRIBUTING=EF=BC=9B=E6=96=87=E6=A1=A3=E4=B8=8E?= =?UTF-8?q?=E6=A8=A1=E6=9D=BF=E4=BF=AE=E6=AD=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- AGENTS.md | 48 ++++++++- CONTRIBUTING.md | 55 ++++++++++ Cargo.lock | 2 +- crates/secrets-core/src/service/audit_log.rs | 19 +++- crates/secrets-mcp/Cargo.toml | 2 +- crates/secrets-mcp/src/main.rs | 108 ++++++++++++++----- crates/secrets-mcp/src/tools.rs | 33 +++++- crates/secrets-mcp/src/web.rs | 76 +++++++++++-- crates/secrets-mcp/templates/audit.html | 65 ++++++++++- crates/secrets-mcp/templates/entries.html | 49 +++++++-- deploy/.env.example | 5 - scripts/release-check.sh | 2 +- 12 files changed, 405 insertions(+), 59 deletions(-) create mode 100644 CONTRIBUTING.md diff --git a/AGENTS.md b/AGENTS.md index 49ff7e7..817982d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,12 +2,37 @@ 本仓库为 **MCP SaaS**:`secrets-core`(业务与持久化)+ `secrets-mcp`(Streamable HTTP MCP、Web、OAuth、API Key)。对外入口见 `crates/secrets-mcp`。 +## 版本控制 + +本仓库使用 **[Jujutsu (jj)](https://jj-vcs.dev/)** 作为版本控制系统(纯 jj 模式,无 `.git` 目录)。 + +### 常用 jj 命令对照 + +| 操作 | jj 命令 | +|------|---------| +| 查看历史 | `jj log` / `jj log 'all()'` | +| 查看状态 | `jj status` | +| 新建提交 | `jj commit` | +| 创建新变更 | `jj new` | +| 变基 | `jj rebase` | +| 合并提交 | `jj squash` | +| 撤销操作 | `jj undo` | +| 查看标签 | `jj tag list` | +| 查看分支 | `jj bookmark list` | +| 推送远端 | `jj git push` | +| 拉取远端 | `jj git fetch` | + +### 注意事项 +- 本仓库为**纯 jj 模式**,无 `.git` 目录;本地不要使用 `git` 命令 +- CI/CD(Gitea Actions)仍通过 Git 协议拉取代码,Runner 侧自动使用 `git`,无需修改 +- 检查标签是否存在时使用 `jj log --no-graph --revisions "tag(${tag})"` 而非 `git rev-parse` + ## 提交 / 推送硬规则(优先于下文) **每次提交和推送前必须执行以下检查,无论是否明确「发版」:** 1. 涉及 `crates/**`、根目录 `Cargo.toml`/`Cargo.lock`、`secrets-mcp` 行为变更的提交,默认视为**需要发版**,除非明确说明「本次不发版」。 -2. 提交前检查 `crates/secrets-mcp/Cargo.toml` 的 `version`,再查 tag:`git tag -l 'secrets-mcp-*'`。若当前版本对应 tag 已存在且有代码变更,**必须 bump 版本号**并 `cargo build` 同步 `Cargo.lock`。 +2. 提交前检查 `crates/secrets-mcp/Cargo.toml` 的 `version`,再查 tag:`jj tag list`。若当前版本对应 tag 已存在且有代码变更,**必须 bump 版本号**并 `cargo build` 同步 `Cargo.lock`。 3. 提交前运行 `./scripts/release-check.sh`(版本/tag + `fmt` + `clippy --locked` + `test --locked`)。若脚本不存在或不可用,至少运行 `cargo fmt -- --check && cargo clippy --locked -- -D warnings && cargo test --locked`。 ## 项目结构 @@ -112,7 +137,9 @@ oauth_accounts ( ### MCP 消歧(AI 调用) -按 `name` 定位条目的工具(`get` / `update` / 单条 `delete` / `history` / `rollback`):若该用户下仅一条匹配则直接执行;若多条(同 `name`、不同 `folder`)则返回错误并提示补全 `folder`。`secrets_delete` 的 `dry_run=true` 与真实删除使用相同消歧规则。 +按 `name` 定位条目的工具(`secrets_update` / `secrets_history` / `secrets_rollback` / `secrets_delete` 单条模式):若该用户下仅一条匹配则直接执行;若多条(同 `name`、不同 `folder`)则返回错误并提示补全 `folder`。也可直接传 `id`(UUID)跳过消歧。 + +注意:`secrets_get` 只接受 UUID `id`(来自 `secrets_find` 结果),不支持按 `name` 定位。 ### 字段职责 @@ -144,6 +171,14 @@ oauth_accounts ( - 加密:密钥由用户密码短语通过 **PBKDF2-SHA256(600k 次)** 在客户端派生,服务端只存 `key_salt`/`key_check`/`key_params`,不持有原始密钥。Web 客户端在浏览器本地完成加解密;MCP 客户端通过 `X-Encryption-Key` 请求头传递密钥,服务端临时解密后返回明文。 - MCP:tools 参数与 JSON Schema(`schemars`)保持同步,鉴权以请求扩展中的用户上下文为准。 +## 生产 CORS + +生产环境 CORS 使用显式请求头白名单(`build_cors_layer`),而非 `allow_headers(Any)`, +因为 `tower-http` 禁止 `allow_credentials(true)` 与 `allow_headers(Any)` 同时使用。 + +**维护约束**:若 MCP 协议或客户端新增自定义请求头,必须同步更新 `production_allowed_headers()`。 +当前允许的请求头:`Authorization`、`Content-Type`、`X-Encryption-Key`、`mcp-session-id`、`x-mcp-session`。 + ## 提交前检查 ```bash @@ -162,7 +197,7 @@ cargo test --locked ```bash grep '^version' crates/secrets-mcp/Cargo.toml -git tag -l 'secrets-mcp-*' +jj tag list ``` ## CI/CD @@ -182,10 +217,17 @@ git tag -l 'secrets-mcp-*' | `SECRETS_DATABASE_URL` | **必填**。PostgreSQL URL。 | | `SECRETS_DATABASE_SSL_MODE` | 可选但强烈建议生产必填。推荐 `verify-full`(至少 `verify-ca`)。 | | `SECRETS_DATABASE_SSL_ROOT_CERT` | 可选。私有 CA 或自签链路时指定 CA 根证书路径。 | +| `SECRETS_DATABASE_POOL_SIZE` | 可选。连接池最大连接数,默认 `10`。 | +| `SECRETS_DATABASE_ACQUIRE_TIMEOUT` | 可选。获取连接超时秒数,默认 `5`。 | | `SECRETS_ENV` | 可选。设为 `prod` / `production` 时会拒绝弱 PostgreSQL TLS 模式。 | | `BASE_URL` | 对外基址;OAuth 回调 `${BASE_URL}/auth/google/callback`。 | | `SECRETS_MCP_BIND` | 监听地址,默认 `127.0.0.1:9315`(容器/远程直接暴露时需改为 `0.0.0.0:9315`)。 | | `GOOGLE_CLIENT_ID` / `GOOGLE_CLIENT_SECRET` | 可选;仅运行时配置。 | | `RUST_LOG` | 如 `secrets_mcp=debug`。 | +| `RATE_LIMIT_GLOBAL_PER_SECOND` | 可选。全局限流速率,默认 `100` req/s。 | +| `RATE_LIMIT_GLOBAL_BURST` | 可选。全局限流突发量,默认 `200`。 | +| `RATE_LIMIT_IP_PER_SECOND` | 可选。单 IP 限流速率,默认 `20` req/s。 | +| `RATE_LIMIT_IP_BURST` | 可选。单 IP 限流突发量,默认 `40`。 | +| `TRUST_PROXY` | 可选。设为 `1`/`true`/`yes` 时从 `X-Forwarded-For` / `X-Real-IP` 提取客户端 IP。 | > `SERVER_MASTER_KEY` 已不再需要。新架构下密钥由用户密码短语在客户端派生,服务端不持有。 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..8339501 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,55 @@ +# Contributing + +## 版本控制 + +本仓库使用 **[Jujutsu (jj)](https://jj-vcs.dev/)**。请勿使用 `git` 命令。 + +```bash +jj log # 查看历史 +jj status # 查看状态 +jj new # 创建新变更 +jj commit # 提交 +jj rebase # 变基 +jj squash # 合并提交 +jj git push # 推送到远端 +``` + +详见 [AGENTS.md](AGENTS.md) 的「版本控制」章节。 + +## 本地开发 + +```bash +# 复制环境变量 +cp deploy/.env.example .env + +# 填写数据库连接等配置后 +cargo build +cargo test --locked +``` + +## 提交前检查 + +每次提交前必须通过: + +```bash +cargo fmt -- --check +cargo clippy --locked -- -D warnings +cargo test --locked +``` + +或使用脚本: + +```bash +./scripts/release-check.sh +``` + +## 发版规则 + +涉及 `crates/**`、根目录 `Cargo.toml`/`Cargo.lock`、`secrets-mcp` 行为变更的提交,默认需要发版。 + +1. 检查 `crates/secrets-mcp/Cargo.toml` 的 `version` +2. 运行 `jj tag list` 确认对应 tag 是否已存在 +3. 若 tag 已存在且有代码变更,**必须 bump 版本**并 `cargo build` 同步 `Cargo.lock` +4. 通过 release-check 后再提交 + +详见 [AGENTS.md](AGENTS.md) 的「提交 / 推送硬规则」章节。 diff --git a/Cargo.lock b/Cargo.lock index 0f305f1..d688929 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2065,7 +2065,7 @@ dependencies = [ [[package]] name = "secrets-mcp" -version = "0.5.2" +version = "0.5.3" dependencies = [ "anyhow", "askama", diff --git a/crates/secrets-core/src/service/audit_log.rs b/crates/secrets-core/src/service/audit_log.rs index 52a59dd..6d644ba 100644 --- a/crates/secrets-core/src/service/audit_log.rs +++ b/crates/secrets-core/src/service/audit_log.rs @@ -4,7 +4,12 @@ use uuid::Uuid; use crate::models::AuditLogEntry; -pub async fn list_for_user(pool: &PgPool, user_id: Uuid, limit: i64) -> Result> { +pub async fn list_for_user( + pool: &PgPool, + user_id: Uuid, + limit: i64, + offset: i64, +) -> Result> { let limit = limit.clamp(1, 200); let rows = sqlx::query_as( @@ -12,12 +17,22 @@ pub async fn list_for_user(pool: &PgPool, user_id: Uuid, limit: i64) -> Result Result { + let count: i64 = + sqlx::query_scalar("SELECT COUNT(*)::bigint FROM audit_log WHERE user_id = $1") + .bind(user_id) + .fetch_one(pool) + .await?; + Ok(count) +} diff --git a/crates/secrets-mcp/Cargo.toml b/crates/secrets-mcp/Cargo.toml index 323f610..bdbe817 100644 --- a/crates/secrets-mcp/Cargo.toml +++ b/crates/secrets-mcp/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "secrets-mcp" -version = "0.5.2" +version = "0.5.3" edition.workspace = true [[bin]] diff --git a/crates/secrets-mcp/src/main.rs b/crates/secrets-mcp/src/main.rs index f924882..9679fc0 100644 --- a/crates/secrets-mcp/src/main.rs +++ b/crates/secrets-mcp/src/main.rs @@ -165,30 +165,7 @@ async fn main() -> Result<()> { Some("prod" | "production") ); - let cors = if is_production { - // Only use the origin part (scheme://host:port) of BASE_URL for CORS. - // Browsers send Origin without path, so including a path would cause mismatches. - let allowed_origin = if let Ok(parsed) = base_url.parse::() { - let origin = parsed.origin().ascii_serialization(); - origin - .parse::() - .unwrap_or_else(|_| panic!("invalid BASE_URL origin: {}", origin)) - } else { - base_url - .parse::() - .unwrap_or_else(|_| panic!("invalid BASE_URL: {}", base_url)) - }; - CorsLayer::new() - .allow_origin(allowed_origin) - .allow_methods(Any) - .allow_headers(Any) - .allow_credentials(true) - } else { - CorsLayer::new() - .allow_origin(Any) - .allow_methods(Any) - .allow_headers(Any) - }; + let cors = build_cors_layer(&base_url, is_production); // Rate limiting let rate_limit_state = rate_limit::RateLimitState::new(); @@ -257,3 +234,86 @@ async fn shutdown_signal() { tracing::info!("Shutting down gracefully..."); } + +/// Production CORS allowed headers. +/// +/// When adding a new custom header to the MCP or Web API, this list must be +/// updated accordingly — otherwise browsers will block the request during +/// the CORS preflight check. +fn production_allowed_headers() -> [axum::http::HeaderName; 5] { + [ + axum::http::header::AUTHORIZATION, + axum::http::header::CONTENT_TYPE, + axum::http::HeaderName::from_static("x-encryption-key"), + axum::http::HeaderName::from_static("mcp-session-id"), + axum::http::HeaderName::from_static("x-mcp-session"), + ] +} + +/// Build the CORS layer for the application. +/// +/// In production mode the origin is restricted to the BASE_URL origin +/// (scheme://host:port, path stripped) and credentials are allowed. +/// `allow_headers` uses an explicit whitelist to avoid the tower-http +/// restriction on `allow_credentials(true)` + `allow_headers(Any)`. +/// +/// In development mode all origins, methods and headers are allowed. +fn build_cors_layer(base_url: &str, is_production: bool) -> CorsLayer { + if is_production { + let allowed_origin = if let Ok(parsed) = base_url.parse::() { + let origin = parsed.origin().ascii_serialization(); + origin + .parse::() + .unwrap_or_else(|_| panic!("invalid BASE_URL origin: {}", origin)) + } else { + base_url + .parse::() + .unwrap_or_else(|_| panic!("invalid BASE_URL: {}", base_url)) + }; + CorsLayer::new() + .allow_origin(allowed_origin) + .allow_methods(Any) + .allow_headers(production_allowed_headers()) + .allow_credentials(true) + } else { + CorsLayer::new() + .allow_origin(Any) + .allow_methods(Any) + .allow_headers(Any) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn production_cors_does_not_panic() { + let layer = build_cors_layer("https://secrets.example.com/app", true); + let _ = layer; + } + + #[test] + fn production_cors_headers_include_all_required() { + let headers = production_allowed_headers(); + let names: Vec<&str> = headers.iter().map(|h| h.as_str()).collect(); + assert!(names.contains(&"authorization")); + assert!(names.contains(&"content-type")); + assert!(names.contains(&"x-encryption-key")); + assert!(names.contains(&"mcp-session-id")); + assert!(names.contains(&"x-mcp-session")); + } + + #[test] + fn production_cors_normalizes_base_url_with_path() { + let url = url::Url::parse("https://secrets.example.com/secrets/app").unwrap(); + let origin = url.origin().ascii_serialization(); + assert_eq!(origin, "https://secrets.example.com"); + } + + #[test] + fn development_cors_allows_everything() { + let layer = build_cors_layer("http://localhost:9315", false); + let _ = layer; + } +} diff --git a/crates/secrets-mcp/src/tools.rs b/crates/secrets-mcp/src/tools.rs index 28e8525..a3710ed 100644 --- a/crates/secrets-mcp/src/tools.rs +++ b/crates/secrets-mcp/src/tools.rs @@ -335,6 +335,9 @@ struct FindInput { #[schemars(description = "Max results (default 20)")] #[serde(default, deserialize_with = "deser::option_u32_from_string")] limit: Option, + #[schemars(description = "Offset for pagination (default 0)")] + #[serde(default, deserialize_with = "deser::option_u32_from_string")] + offset: Option, } #[derive(Debug, Deserialize, JsonSchema)] @@ -680,13 +683,33 @@ impl SecretsService { query: input.query.as_deref(), sort: "name", limit: input.limit.unwrap_or(20), - offset: 0, + offset: input.offset.unwrap_or(0), user_id: Some(user_id), }, ) .await .map_err(|e| mcp_err_internal_logged("secrets_find", Some(user_id), e))?; + let count_params = SearchParams { + folder: input.folder.as_deref(), + entry_type: input.entry_type.as_deref(), + name: input.name.as_deref(), + name_query: input.name_query.as_deref(), + tags: &tags, + query: input.query.as_deref(), + sort: "name", + limit: 0, + offset: 0, + user_id: Some(user_id), + }; + + let total_count = secrets_core::service::search::count_entries(&self.pool, &count_params) + .await + .inspect_err( + |e| tracing::warn!(tool = "secrets_find", error = %e, "count_entries failed"), + ) + .unwrap_or(0); + let entries: Vec = result .entries .iter() @@ -719,14 +742,20 @@ impl SecretsService { }) .collect(); + let output = serde_json::json!({ + "total_count": total_count, + "entries": entries, + }); + tracing::info!( tool = "secrets_find", ?user_id, result_count = entries.len(), + total_count, elapsed_ms = t.elapsed().as_millis(), "tool call ok", ); - let json = serde_json::to_string_pretty(&entries).unwrap_or_else(|_| "[]".to_string()); + let json = serde_json::to_string_pretty(&output).unwrap_or_else(|_| "{}".to_string()); Ok(CallToolResult::success(vec![Content::text(json)])) } diff --git a/crates/secrets-mcp/src/web.rs b/crates/secrets-mcp/src/web.rs index 78cb49e..f9813b5 100644 --- a/crates/secrets-mcp/src/web.rs +++ b/crates/secrets-mcp/src/web.rs @@ -20,9 +20,9 @@ use secrets_core::crypto::hex; use secrets_core::error::AppError; use secrets_core::service::{ api_key::{ensure_api_key, regenerate_api_key}, - audit_log::list_for_user, + audit_log::{count_for_user, list_for_user}, delete::delete_by_id, - search::{SearchParams, fetch_secret_schemas, ilike_pattern, list_entries}, + search::{SearchParams, count_entries, fetch_secret_schemas, ilike_pattern, list_entries}, update::{UpdateEntryFieldsByIdParams, update_fields_by_id}, user::{ OAuthProfile, bind_oauth_account, find_or_create_user, get_user_by_id, @@ -72,6 +72,9 @@ struct AuditPageTemplate { user_name: String, user_email: String, entries: Vec, + current_page: u32, + total_pages: u32, + total_count: i64, version: &'static str, } @@ -95,6 +98,9 @@ struct EntriesPageTemplate { filter_folder: String, filter_name: String, filter_type: String, + current_page: u32, + total_pages: u32, + total_count: i64, version: &'static str, } @@ -131,7 +137,8 @@ struct FolderTabView { } /// Cap for HTML list (avoids loading unbounded rows into memory). -const ENTRIES_PAGE_LIMIT: u32 = 5_000; +const ENTRIES_PAGE_LIMIT: u32 = 50; +const AUDIT_PAGE_LIMIT: i64 = 10; #[derive(Deserialize)] struct EntriesQuery { @@ -140,6 +147,7 @@ struct EntriesQuery { /// URL query key is `type` (maps to DB column `entries.type`). #[serde(rename = "type")] entry_type: Option, + page: Option, } // ── App state helpers ───────────────────────────────────────────────────────── @@ -596,6 +604,8 @@ async fn entries_page( .map(|s| s.trim()) .filter(|s| !s.is_empty()) .map(|s| s.to_string()); + let page = q.page.unwrap_or(1).max(1); + let offset = (page - 1) * ENTRIES_PAGE_LIMIT; let params = SearchParams { folder: folder_filter.as_deref(), entry_type: type_filter.as_deref(), @@ -605,10 +615,17 @@ async fn entries_page( query: None, sort: "updated", limit: ENTRIES_PAGE_LIMIT, - offset: 0, + offset, user_id: Some(user_id), }; + let total_count = count_entries(&state.pool, ¶ms) + .await + .inspect_err(|e| tracing::warn!(error = %e, "count_entries failed for web entries page")) + .unwrap_or(0); + let total_pages = (total_count as u32).div_ceil(ENTRIES_PAGE_LIMIT).max(1); + let current_page = page.min(total_pages); + 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 @@ -681,7 +698,12 @@ async fn entries_page( type_options.sort_unstable(); } - fn entries_href(folder: Option<&str>, entry_type: Option<&str>, name: Option<&str>) -> String { + fn entries_href( + folder: Option<&str>, + entry_type: Option<&str>, + name: Option<&str>, + page: Option, + ) -> String { let mut pairs: Vec = Vec::new(); if let Some(f) = folder && !f.is_empty() @@ -698,6 +720,9 @@ async fn entries_page( { pairs.push(format!("name={}", urlencoding::encode(n))); } + if let Some(p) = page { + pairs.push(format!("page={}", p)); + } if pairs.is_empty() { "/entries".to_string() } else { @@ -710,13 +735,23 @@ async fn entries_page( folder_tabs.push(FolderTabView { name: "全部".to_string(), count: all_count, - href: entries_href(None, type_filter.as_deref(), name_filter.as_deref()), + href: entries_href( + None, + type_filter.as_deref(), + name_filter.as_deref(), + Some(1), + ), active: folder_filter.is_none(), }); for r in folder_rows { let name = r.folder; folder_tabs.push(FolderTabView { - href: entries_href(Some(&name), type_filter.as_deref(), name_filter.as_deref()), + href: entries_href( + Some(&name), + type_filter.as_deref(), + name_filter.as_deref(), + Some(1), + ), active: folder_filter.as_deref() == Some(name.as_str()), name, count: r.count, @@ -773,15 +808,24 @@ async fn entries_page( filter_folder: folder_filter.unwrap_or_default(), filter_name: name_filter.unwrap_or_default(), filter_type: type_filter.unwrap_or_default(), + current_page, + total_pages, + total_count, version: env!("CARGO_PKG_VERSION"), }; render_template(tmpl) } +#[derive(Deserialize)] +struct AuditQuery { + page: Option, +} + async fn audit_page( State(state): State, session: Session, + Query(aq): Query, ) -> Result { let Some(user_id) = current_user_id(&session).await else { return Ok(Redirect::to("/login").into_response()); @@ -795,7 +839,20 @@ async fn audit_page( None => return Ok(Redirect::to("/login").into_response()), }; - let rows = list_for_user(&state.pool, user_id, 100) + let page = aq.page.unwrap_or(1).max(1); + + let total_count = count_for_user(&state.pool, user_id).await.map_err(|e| { + tracing::error!(error = %e, "failed to count audit log for user"); + StatusCode::INTERNAL_SERVER_ERROR + })?; + + let total_pages = (total_count as u32) + .div_ceil(AUDIT_PAGE_LIMIT as u32) + .max(1); + let current_page = page.min(total_pages); + let actual_offset = ((current_page - 1) as i64) * AUDIT_PAGE_LIMIT; + + let rows = list_for_user(&state.pool, user_id, AUDIT_PAGE_LIMIT, actual_offset) .await .map_err(|e| { tracing::error!(error = %e, "failed to load audit log for user"); @@ -816,6 +873,9 @@ async fn audit_page( user_name: user.name.clone(), user_email: user.email.clone().unwrap_or_default(), entries, + current_page, + total_pages, + total_count, version: env!("CARGO_PKG_VERSION"), }; diff --git a/crates/secrets-mcp/templates/audit.html b/crates/secrets-mcp/templates/audit.html index 549608d..f6922ad 100644 --- a/crates/secrets-mcp/templates/audit.html +++ b/crates/secrets-mcp/templates/audit.html @@ -50,7 +50,25 @@ .main { padding: 32px 24px 40px; flex: 1; } .card { background: var(--surface); border: 1px solid var(--border); border-radius: 12px; padding: 24px; width: 100%; max-width: 1180px; margin: 0 auto; } - .card-title { font-size: 20px; font-weight: 600; margin-bottom: 20px; } + .card-title-row { + display: flex; align-items: center; flex-wrap: wrap; gap: 8px; + margin-bottom: 20px; + } + .card-title { font-size: 20px; font-weight: 600; margin: 0; } + .card-title-count { + display: inline-flex; + align-items: center; + min-height: 24px; + padding: 0 8px; + border: 1px solid var(--border); + border-radius: 999px; + background: var(--bg); + color: var(--text-muted); + font-size: 12px; + font-weight: 600; + line-height: 1; + font-family: 'JetBrains Mono', monospace; + } .empty { color: var(--text-muted); font-size: 14px; padding: 20px 0; } table { width: 100%; border-collapse: collapse; } th, td { text-align: left; vertical-align: top; padding: 12px 10px; border-top: 1px solid var(--border); } @@ -84,6 +102,24 @@ } .detail { max-width: none; } } + .pagination { + display: flex; align-items: center; gap: 8px; margin-top: 20px; + justify-content: center; padding: 12px 0; + } + .page-btn { + padding: 6px 14px; border-radius: 6px; border: 1px solid var(--border); + background: var(--surface); color: var(--text); text-decoration: none; + font-size: 13px; cursor: pointer; + } + .page-btn:hover { background: var(--surface2); } + .page-btn-disabled { + padding: 6px 14px; border-radius: 6px; border: 1px solid var(--border); + background: var(--surface); color: var(--text-muted); font-size: 13px; + opacity: 0.5; cursor: not-allowed; + } + .page-info { + color: var(--text-muted); font-size: 13px; font-family: 'JetBrains Mono', monospace; + } @@ -113,7 +149,10 @@
-
我的审计
+
+
我的审计
+ {{ total_count }} +
{% if entries.is_empty() %}
暂无审计记录。
@@ -138,6 +177,22 @@ {% endfor %} + + {% if total_count > 0 %} + + {% endif %} {% endif %}
@@ -147,9 +202,9 @@