Przeglądaj źródła

feat: 增强统计图表和 Session 监控功能

主要改进:
- 统计页面新增 mixed 模式,非 Admin 用户可查看自己的密钥明细和其他用户汇总
- Admin 用户可在图表中选择要显示的用户,支持全选/清空操作
- Session 监控页面分离活跃和非活跃 Session,提升可读性
- 错误详情弹窗增加"查看详情"按钮,直接跳转到 messages 页面
- 系统配置 allowGlobalUsageView 默认改为 false,提升隐私保护

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <[email protected]>
ding113 3 miesięcy temu
rodzic
commit
9e89e7062c

+ 1 - 0
drizzle/0002_fancy_preak.sql

@@ -0,0 +1 @@
+ALTER TABLE "system_settings" ALTER COLUMN "allow_global_usage_view" SET DEFAULT false;

+ 951 - 0
drizzle/meta/0002_snapshot.json

@@ -0,0 +1,951 @@
+{
+  "id": "69a0a9f6-2d19-400d-bf4f-8b2692f05aed",
+  "prevId": "3dbb5906-275d-4782-95b5-17f8feb4bced",
+  "version": "7",
+  "dialect": "postgresql",
+  "tables": {
+    "public.keys": {
+      "name": "keys",
+      "schema": "",
+      "columns": {
+        "id": {
+          "name": "id",
+          "type": "serial",
+          "primaryKey": true,
+          "notNull": true
+        },
+        "user_id": {
+          "name": "user_id",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true
+        },
+        "key": {
+          "name": "key",
+          "type": "varchar",
+          "primaryKey": false,
+          "notNull": true
+        },
+        "name": {
+          "name": "name",
+          "type": "varchar",
+          "primaryKey": false,
+          "notNull": true
+        },
+        "is_enabled": {
+          "name": "is_enabled",
+          "type": "boolean",
+          "primaryKey": false,
+          "notNull": false,
+          "default": true
+        },
+        "expires_at": {
+          "name": "expires_at",
+          "type": "timestamp",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "limit_5h_usd": {
+          "name": "limit_5h_usd",
+          "type": "numeric(10, 2)",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "limit_weekly_usd": {
+          "name": "limit_weekly_usd",
+          "type": "numeric(10, 2)",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "limit_monthly_usd": {
+          "name": "limit_monthly_usd",
+          "type": "numeric(10, 2)",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "limit_concurrent_sessions": {
+          "name": "limit_concurrent_sessions",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": false,
+          "default": 0
+        },
+        "created_at": {
+          "name": "created_at",
+          "type": "timestamp with time zone",
+          "primaryKey": false,
+          "notNull": false,
+          "default": "now()"
+        },
+        "updated_at": {
+          "name": "updated_at",
+          "type": "timestamp with time zone",
+          "primaryKey": false,
+          "notNull": false,
+          "default": "now()"
+        },
+        "deleted_at": {
+          "name": "deleted_at",
+          "type": "timestamp with time zone",
+          "primaryKey": false,
+          "notNull": false
+        }
+      },
+      "indexes": {
+        "idx_keys_user_id": {
+          "name": "idx_keys_user_id",
+          "columns": [
+            {
+              "expression": "user_id",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            }
+          ],
+          "isUnique": false,
+          "concurrently": false,
+          "method": "btree",
+          "with": {}
+        },
+        "idx_keys_created_at": {
+          "name": "idx_keys_created_at",
+          "columns": [
+            {
+              "expression": "created_at",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            }
+          ],
+          "isUnique": false,
+          "concurrently": false,
+          "method": "btree",
+          "with": {}
+        },
+        "idx_keys_deleted_at": {
+          "name": "idx_keys_deleted_at",
+          "columns": [
+            {
+              "expression": "deleted_at",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            }
+          ],
+          "isUnique": false,
+          "concurrently": false,
+          "method": "btree",
+          "with": {}
+        }
+      },
+      "foreignKeys": {},
+      "compositePrimaryKeys": {},
+      "uniqueConstraints": {},
+      "policies": {},
+      "checkConstraints": {},
+      "isRLSEnabled": false
+    },
+    "public.message_request": {
+      "name": "message_request",
+      "schema": "",
+      "columns": {
+        "id": {
+          "name": "id",
+          "type": "serial",
+          "primaryKey": true,
+          "notNull": true
+        },
+        "provider_id": {
+          "name": "provider_id",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true
+        },
+        "user_id": {
+          "name": "user_id",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true
+        },
+        "key": {
+          "name": "key",
+          "type": "varchar",
+          "primaryKey": false,
+          "notNull": true
+        },
+        "model": {
+          "name": "model",
+          "type": "varchar(128)",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "duration_ms": {
+          "name": "duration_ms",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "cost_usd": {
+          "name": "cost_usd",
+          "type": "numeric(21, 15)",
+          "primaryKey": false,
+          "notNull": false,
+          "default": "'0'"
+        },
+        "session_id": {
+          "name": "session_id",
+          "type": "varchar(64)",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "provider_chain": {
+          "name": "provider_chain",
+          "type": "jsonb",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "status_code": {
+          "name": "status_code",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "api_type": {
+          "name": "api_type",
+          "type": "varchar(20)",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "input_tokens": {
+          "name": "input_tokens",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "output_tokens": {
+          "name": "output_tokens",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "cache_creation_input_tokens": {
+          "name": "cache_creation_input_tokens",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "cache_read_input_tokens": {
+          "name": "cache_read_input_tokens",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "error_message": {
+          "name": "error_message",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "created_at": {
+          "name": "created_at",
+          "type": "timestamp with time zone",
+          "primaryKey": false,
+          "notNull": false,
+          "default": "now()"
+        },
+        "updated_at": {
+          "name": "updated_at",
+          "type": "timestamp with time zone",
+          "primaryKey": false,
+          "notNull": false,
+          "default": "now()"
+        },
+        "deleted_at": {
+          "name": "deleted_at",
+          "type": "timestamp with time zone",
+          "primaryKey": false,
+          "notNull": false
+        }
+      },
+      "indexes": {
+        "idx_message_request_user_date_cost": {
+          "name": "idx_message_request_user_date_cost",
+          "columns": [
+            {
+              "expression": "user_id",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            },
+            {
+              "expression": "created_at",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            },
+            {
+              "expression": "cost_usd",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            }
+          ],
+          "isUnique": false,
+          "where": "\"message_request\".\"deleted_at\" IS NULL",
+          "concurrently": false,
+          "method": "btree",
+          "with": {}
+        },
+        "idx_message_request_user_query": {
+          "name": "idx_message_request_user_query",
+          "columns": [
+            {
+              "expression": "user_id",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            },
+            {
+              "expression": "created_at",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            }
+          ],
+          "isUnique": false,
+          "where": "\"message_request\".\"deleted_at\" IS NULL",
+          "concurrently": false,
+          "method": "btree",
+          "with": {}
+        },
+        "idx_message_request_session_id": {
+          "name": "idx_message_request_session_id",
+          "columns": [
+            {
+              "expression": "session_id",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            }
+          ],
+          "isUnique": false,
+          "where": "\"message_request\".\"deleted_at\" IS NULL",
+          "concurrently": false,
+          "method": "btree",
+          "with": {}
+        },
+        "idx_message_request_provider_id": {
+          "name": "idx_message_request_provider_id",
+          "columns": [
+            {
+              "expression": "provider_id",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            }
+          ],
+          "isUnique": false,
+          "concurrently": false,
+          "method": "btree",
+          "with": {}
+        },
+        "idx_message_request_user_id": {
+          "name": "idx_message_request_user_id",
+          "columns": [
+            {
+              "expression": "user_id",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            }
+          ],
+          "isUnique": false,
+          "concurrently": false,
+          "method": "btree",
+          "with": {}
+        },
+        "idx_message_request_key": {
+          "name": "idx_message_request_key",
+          "columns": [
+            {
+              "expression": "key",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            }
+          ],
+          "isUnique": false,
+          "concurrently": false,
+          "method": "btree",
+          "with": {}
+        },
+        "idx_message_request_created_at": {
+          "name": "idx_message_request_created_at",
+          "columns": [
+            {
+              "expression": "created_at",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            }
+          ],
+          "isUnique": false,
+          "concurrently": false,
+          "method": "btree",
+          "with": {}
+        },
+        "idx_message_request_deleted_at": {
+          "name": "idx_message_request_deleted_at",
+          "columns": [
+            {
+              "expression": "deleted_at",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            }
+          ],
+          "isUnique": false,
+          "concurrently": false,
+          "method": "btree",
+          "with": {}
+        }
+      },
+      "foreignKeys": {},
+      "compositePrimaryKeys": {},
+      "uniqueConstraints": {},
+      "policies": {},
+      "checkConstraints": {},
+      "isRLSEnabled": false
+    },
+    "public.model_prices": {
+      "name": "model_prices",
+      "schema": "",
+      "columns": {
+        "id": {
+          "name": "id",
+          "type": "serial",
+          "primaryKey": true,
+          "notNull": true
+        },
+        "model_name": {
+          "name": "model_name",
+          "type": "varchar",
+          "primaryKey": false,
+          "notNull": true
+        },
+        "price_data": {
+          "name": "price_data",
+          "type": "jsonb",
+          "primaryKey": false,
+          "notNull": true
+        },
+        "created_at": {
+          "name": "created_at",
+          "type": "timestamp with time zone",
+          "primaryKey": false,
+          "notNull": false,
+          "default": "now()"
+        },
+        "updated_at": {
+          "name": "updated_at",
+          "type": "timestamp with time zone",
+          "primaryKey": false,
+          "notNull": false,
+          "default": "now()"
+        }
+      },
+      "indexes": {
+        "idx_model_prices_latest": {
+          "name": "idx_model_prices_latest",
+          "columns": [
+            {
+              "expression": "model_name",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            },
+            {
+              "expression": "created_at",
+              "isExpression": false,
+              "asc": false,
+              "nulls": "last"
+            }
+          ],
+          "isUnique": false,
+          "concurrently": false,
+          "method": "btree",
+          "with": {}
+        },
+        "idx_model_prices_model_name": {
+          "name": "idx_model_prices_model_name",
+          "columns": [
+            {
+              "expression": "model_name",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            }
+          ],
+          "isUnique": false,
+          "concurrently": false,
+          "method": "btree",
+          "with": {}
+        },
+        "idx_model_prices_created_at": {
+          "name": "idx_model_prices_created_at",
+          "columns": [
+            {
+              "expression": "created_at",
+              "isExpression": false,
+              "asc": false,
+              "nulls": "last"
+            }
+          ],
+          "isUnique": false,
+          "concurrently": false,
+          "method": "btree",
+          "with": {}
+        }
+      },
+      "foreignKeys": {},
+      "compositePrimaryKeys": {},
+      "uniqueConstraints": {},
+      "policies": {},
+      "checkConstraints": {},
+      "isRLSEnabled": false
+    },
+    "public.providers": {
+      "name": "providers",
+      "schema": "",
+      "columns": {
+        "id": {
+          "name": "id",
+          "type": "serial",
+          "primaryKey": true,
+          "notNull": true
+        },
+        "name": {
+          "name": "name",
+          "type": "varchar",
+          "primaryKey": false,
+          "notNull": true
+        },
+        "description": {
+          "name": "description",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "url": {
+          "name": "url",
+          "type": "varchar",
+          "primaryKey": false,
+          "notNull": true
+        },
+        "key": {
+          "name": "key",
+          "type": "varchar",
+          "primaryKey": false,
+          "notNull": true
+        },
+        "is_enabled": {
+          "name": "is_enabled",
+          "type": "boolean",
+          "primaryKey": false,
+          "notNull": true,
+          "default": true
+        },
+        "weight": {
+          "name": "weight",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "default": 1
+        },
+        "priority": {
+          "name": "priority",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "default": 0
+        },
+        "cost_multiplier": {
+          "name": "cost_multiplier",
+          "type": "numeric(10, 4)",
+          "primaryKey": false,
+          "notNull": false,
+          "default": "'1.0'"
+        },
+        "group_tag": {
+          "name": "group_tag",
+          "type": "varchar(50)",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "provider_type": {
+          "name": "provider_type",
+          "type": "varchar(20)",
+          "primaryKey": false,
+          "notNull": true,
+          "default": "'claude'"
+        },
+        "model_redirects": {
+          "name": "model_redirects",
+          "type": "jsonb",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "limit_5h_usd": {
+          "name": "limit_5h_usd",
+          "type": "numeric(10, 2)",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "limit_weekly_usd": {
+          "name": "limit_weekly_usd",
+          "type": "numeric(10, 2)",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "limit_monthly_usd": {
+          "name": "limit_monthly_usd",
+          "type": "numeric(10, 2)",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "limit_concurrent_sessions": {
+          "name": "limit_concurrent_sessions",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": false,
+          "default": 0
+        },
+        "tpm": {
+          "name": "tpm",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": false,
+          "default": 0
+        },
+        "rpm": {
+          "name": "rpm",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": false,
+          "default": 0
+        },
+        "rpd": {
+          "name": "rpd",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": false,
+          "default": 0
+        },
+        "cc": {
+          "name": "cc",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": false,
+          "default": 0
+        },
+        "created_at": {
+          "name": "created_at",
+          "type": "timestamp with time zone",
+          "primaryKey": false,
+          "notNull": false,
+          "default": "now()"
+        },
+        "updated_at": {
+          "name": "updated_at",
+          "type": "timestamp with time zone",
+          "primaryKey": false,
+          "notNull": false,
+          "default": "now()"
+        },
+        "deleted_at": {
+          "name": "deleted_at",
+          "type": "timestamp with time zone",
+          "primaryKey": false,
+          "notNull": false
+        }
+      },
+      "indexes": {
+        "idx_providers_enabled_priority": {
+          "name": "idx_providers_enabled_priority",
+          "columns": [
+            {
+              "expression": "is_enabled",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            },
+            {
+              "expression": "priority",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            },
+            {
+              "expression": "weight",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            }
+          ],
+          "isUnique": false,
+          "where": "\"providers\".\"deleted_at\" IS NULL",
+          "concurrently": false,
+          "method": "btree",
+          "with": {}
+        },
+        "idx_providers_group": {
+          "name": "idx_providers_group",
+          "columns": [
+            {
+              "expression": "group_tag",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            }
+          ],
+          "isUnique": false,
+          "where": "\"providers\".\"deleted_at\" IS NULL",
+          "concurrently": false,
+          "method": "btree",
+          "with": {}
+        },
+        "idx_providers_created_at": {
+          "name": "idx_providers_created_at",
+          "columns": [
+            {
+              "expression": "created_at",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            }
+          ],
+          "isUnique": false,
+          "concurrently": false,
+          "method": "btree",
+          "with": {}
+        },
+        "idx_providers_deleted_at": {
+          "name": "idx_providers_deleted_at",
+          "columns": [
+            {
+              "expression": "deleted_at",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            }
+          ],
+          "isUnique": false,
+          "concurrently": false,
+          "method": "btree",
+          "with": {}
+        }
+      },
+      "foreignKeys": {},
+      "compositePrimaryKeys": {},
+      "uniqueConstraints": {},
+      "policies": {},
+      "checkConstraints": {},
+      "isRLSEnabled": false
+    },
+    "public.system_settings": {
+      "name": "system_settings",
+      "schema": "",
+      "columns": {
+        "id": {
+          "name": "id",
+          "type": "serial",
+          "primaryKey": true,
+          "notNull": true
+        },
+        "site_title": {
+          "name": "site_title",
+          "type": "varchar(128)",
+          "primaryKey": false,
+          "notNull": true,
+          "default": "'Claude Code Hub'"
+        },
+        "allow_global_usage_view": {
+          "name": "allow_global_usage_view",
+          "type": "boolean",
+          "primaryKey": false,
+          "notNull": true,
+          "default": false
+        },
+        "created_at": {
+          "name": "created_at",
+          "type": "timestamp with time zone",
+          "primaryKey": false,
+          "notNull": false,
+          "default": "now()"
+        },
+        "updated_at": {
+          "name": "updated_at",
+          "type": "timestamp with time zone",
+          "primaryKey": false,
+          "notNull": false,
+          "default": "now()"
+        }
+      },
+      "indexes": {},
+      "foreignKeys": {},
+      "compositePrimaryKeys": {},
+      "uniqueConstraints": {},
+      "policies": {},
+      "checkConstraints": {},
+      "isRLSEnabled": false
+    },
+    "public.users": {
+      "name": "users",
+      "schema": "",
+      "columns": {
+        "id": {
+          "name": "id",
+          "type": "serial",
+          "primaryKey": true,
+          "notNull": true
+        },
+        "name": {
+          "name": "name",
+          "type": "varchar",
+          "primaryKey": false,
+          "notNull": true
+        },
+        "description": {
+          "name": "description",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "role": {
+          "name": "role",
+          "type": "varchar",
+          "primaryKey": false,
+          "notNull": false,
+          "default": "'user'"
+        },
+        "rpm_limit": {
+          "name": "rpm_limit",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": false,
+          "default": 60
+        },
+        "daily_limit_usd": {
+          "name": "daily_limit_usd",
+          "type": "numeric(10, 2)",
+          "primaryKey": false,
+          "notNull": false,
+          "default": "'100.00'"
+        },
+        "provider_group": {
+          "name": "provider_group",
+          "type": "varchar(50)",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "created_at": {
+          "name": "created_at",
+          "type": "timestamp with time zone",
+          "primaryKey": false,
+          "notNull": false,
+          "default": "now()"
+        },
+        "updated_at": {
+          "name": "updated_at",
+          "type": "timestamp with time zone",
+          "primaryKey": false,
+          "notNull": false,
+          "default": "now()"
+        },
+        "deleted_at": {
+          "name": "deleted_at",
+          "type": "timestamp with time zone",
+          "primaryKey": false,
+          "notNull": false
+        }
+      },
+      "indexes": {
+        "idx_users_active_role_sort": {
+          "name": "idx_users_active_role_sort",
+          "columns": [
+            {
+              "expression": "deleted_at",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            },
+            {
+              "expression": "role",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            },
+            {
+              "expression": "id",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            }
+          ],
+          "isUnique": false,
+          "where": "\"users\".\"deleted_at\" IS NULL",
+          "concurrently": false,
+          "method": "btree",
+          "with": {}
+        },
+        "idx_users_created_at": {
+          "name": "idx_users_created_at",
+          "columns": [
+            {
+              "expression": "created_at",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            }
+          ],
+          "isUnique": false,
+          "concurrently": false,
+          "method": "btree",
+          "with": {}
+        },
+        "idx_users_deleted_at": {
+          "name": "idx_users_deleted_at",
+          "columns": [
+            {
+              "expression": "deleted_at",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            }
+          ],
+          "isUnique": false,
+          "concurrently": false,
+          "method": "btree",
+          "with": {}
+        }
+      },
+      "foreignKeys": {},
+      "compositePrimaryKeys": {},
+      "uniqueConstraints": {},
+      "policies": {},
+      "checkConstraints": {},
+      "isRLSEnabled": false
+    }
+  },
+  "enums": {},
+  "schemas": {},
+  "sequences": {},
+  "roles": {},
+  "policies": {},
+  "views": {},
+  "_meta": {
+    "columns": {},
+    "schemas": {},
+    "tables": {}
+  }
+}

+ 7 - 0
drizzle/meta/_journal.json

@@ -15,6 +15,13 @@
       "when": 1761113092032,
       "tag": "0001_ambiguous_bromley",
       "breakpoints": true
+    },
+    {
+      "idx": 2,
+      "version": "7",
+      "when": 1761148729518,
+      "tag": "0002_fancy_preak",
+      "breakpoints": true
     }
   ]
 }

+ 45 - 0
src/actions/active-sessions.ts

@@ -24,6 +24,31 @@ export async function getActiveSessions(): Promise<ActionResult<ActiveSessionInf
   }
 }
 
+/**
+ * 获取所有 session(包括活跃和非活跃的)
+ * 用于实时监控页面的完整视图
+ */
+export async function getAllSessions(): Promise<
+  ActionResult<{
+    active: ActiveSessionInfo[];
+    inactive: ActiveSessionInfo[];
+  }>
+> {
+  try {
+    const sessions = await SessionManager.getAllSessionsWithExpiry();
+    return {
+      ok: true,
+      data: sessions,
+    };
+  } catch (error) {
+    console.error('Failed to get all sessions:', error);
+    return {
+      ok: false,
+      error: '获取 session 列表失败',
+    };
+  }
+}
+
 /**
  * 获取指定 session 的 messages 内容
  * 仅当 STORE_SESSION_MESSAGES=true 时可用
@@ -49,3 +74,23 @@ export async function getSessionMessages(sessionId: string): Promise<ActionResul
     };
   }
 }
+
+/**
+ * 检查指定 session 是否有 messages 数据
+ * 用于判断是否显示"查看详情"按钮
+ */
+export async function hasSessionMessages(sessionId: string): Promise<ActionResult<boolean>> {
+  try {
+    const messages = await SessionManager.getSessionMessages(sessionId);
+    return {
+      ok: true,
+      data: messages !== null,
+    };
+  } catch (error) {
+    console.error('Failed to check session messages:', error);
+    return {
+      ok: true,
+      data: false, // 出错时默认返回 false,避免显示无效按钮
+    };
+  }
+}

+ 48 - 14
src/actions/statistics.ts

@@ -6,6 +6,7 @@ import {
   getActiveUsersFromDB,
   getKeyStatisticsFromDB,
   getActiveKeysForUserFromDB,
+  getMixedStatisticsFromDB,
 } from "@/repository/statistics";
 import { getSystemSettings } from "@/repository/system-config";
 import type {
@@ -50,21 +51,54 @@ export async function getUserStatistics(
 
     const settings = await getSystemSettings();
     const isAdmin = session.user.role === 'admin';
-    const mode: 'users' | 'keys' = isAdmin || settings.allowGlobalUsageView
+
+    // 确定显示模式
+    const mode: 'users' | 'keys' | 'mixed' = isAdmin
       ? 'users'
-      : 'keys';
-
-    const prefix = mode === 'users' ? 'user' : 'key';
-
-    const [statsData, entities] = mode === 'users'
-      ? await Promise.all([
-          getUserStatisticsFromDB(timeRange),
-          getActiveUsersFromDB(),
-        ]) as [DatabaseStatRow[], DatabaseUser[]]
-      : await Promise.all([
-          getKeyStatisticsFromDB(session.user.id, timeRange),
-          getActiveKeysForUserFromDB(session.user.id),
-        ]) as [DatabaseKeyStatRow[], DatabaseKey[]];
+      : settings.allowGlobalUsageView
+        ? 'mixed'
+        : 'keys';
+
+    const prefix = mode === 'mixed' ? 'key' : mode === 'users' ? 'user' : 'key';
+
+    let statsData: Array<DatabaseStatRow | DatabaseKeyStatRow>;
+    let entities: Array<DatabaseUser | DatabaseKey>;
+
+    if (mode === 'users') {
+      // Admin: 显示所有用户
+      const [userStats, userList] = await Promise.all([
+        getUserStatisticsFromDB(timeRange),
+        getActiveUsersFromDB(),
+      ]);
+      statsData = userStats;
+      entities = userList;
+    } else if (mode === 'mixed') {
+      // 非 Admin + allowGlobalUsageView: 自己的密钥明细 + 其他用户汇总
+      const [ownKeysList, mixedData] = await Promise.all([
+        getActiveKeysForUserFromDB(session.user.id),
+        getMixedStatisticsFromDB(session.user.id, timeRange),
+      ]);
+
+      // 合并数据:自己的密钥 + 其他用户的虚拟条目
+      statsData = [
+        ...mixedData.ownKeys,
+        ...mixedData.othersAggregate,
+      ];
+
+      // 合并实体列表:自己的密钥 + 其他用户虚拟实体
+      entities = [
+        ...ownKeysList,
+        { id: -1, name: '其他用户' },
+      ];
+    } else {
+      // 非 Admin + !allowGlobalUsageView: 仅显示自己的密钥
+      const [keyStats, keyList] = await Promise.all([
+        getKeyStatisticsFromDB(session.user.id, timeRange),
+        getActiveKeysForUserFromDB(session.user.id),
+      ]);
+      statsData = keyStats;
+      entities = keyList;
+    }
 
     // 将数据转换为适合图表的格式
     const dataByDate = new Map<string, ChartDataItem>();

+ 117 - 34
src/app/dashboard/_components/statistics/chart.tsx

@@ -65,6 +65,45 @@ export interface UserStatisticsChartProps {
 export function UserStatisticsChart({ data, onTimeRangeChange }: UserStatisticsChartProps) {
   const [activeChart, setActiveChart] = React.useState<"cost" | "calls">("cost")
 
+  // 用户选择状态(仅 Admin 用 users 模式时启用)
+  const [selectedUserIds, setSelectedUserIds] = React.useState<Set<number>>(
+    () => new Set(data.users.map(u => u.id))
+  )
+
+  // 重置选择状态(当 data.users 变化时)
+  React.useEffect(() => {
+    setSelectedUserIds(new Set(data.users.map(u => u.id)))
+  }, [data.users])
+
+  const isAdminMode = data.mode === 'users'
+  const enableUserFilter = isAdminMode && data.users.length > 1
+
+  const toggleUserSelection = (userId: number) => {
+    setSelectedUserIds(prev => {
+      const next = new Set(prev)
+      if (next.has(userId)) {
+        // 至少保留一个用户
+        if (next.size > 1) {
+          next.delete(userId)
+        }
+      } else {
+        next.add(userId)
+      }
+      return next
+    })
+  }
+
+  const selectAllUsers = () => {
+    setSelectedUserIds(new Set(data.users.map(u => u.id)))
+  }
+
+  const deselectAllUsers = () => {
+    // 保留第一个用户
+    if (data.users.length > 0) {
+      setSelectedUserIds(new Set([data.users[0].id]))
+    }
+  }
+
   // 动态生成图表配置
   const chartConfig = React.useMemo(() => {
     const config: ChartConfig = {
@@ -90,11 +129,20 @@ export function UserStatisticsChart({ data, onTimeRangeChange }: UserStatisticsC
     return new Map(data.users.map((user) => [user.dataKey, user]))
   }, [data.users])
 
+  // 过滤可见用户(如果启用过滤)
+  const visibleUsers = React.useMemo(() => {
+    if (!enableUserFilter) {
+      return data.users
+    }
+    return data.users.filter(u => selectedUserIds.has(u.id))
+  }, [data.users, selectedUserIds, enableUserFilter])
+
   const numericChartData = React.useMemo(() => {
     return data.chartData.map((day) => {
       const normalized: Record<string, string | number> = { ...day };
 
-      data.users.forEach((user) => {
+      // 只处理可见用户的数据
+      visibleUsers.forEach((user) => {
         const costKey = `${user.dataKey}_cost`;
         const costDecimal = toDecimal(day[costKey]);
         normalized[costKey] = costDecimal
@@ -108,9 +156,9 @@ export function UserStatisticsChart({ data, onTimeRangeChange }: UserStatisticsC
 
       return normalized;
     });
-  }, [data.chartData, data.users]);
+  }, [data.chartData, visibleUsers]);
 
-  // 计算每个用户的总数据
+  // 计算每个用户的总数据(包括所有用户,用于 legend 排序)
   const userTotals = React.useMemo(() => {
     const totals: Record<string, { cost: Decimal; calls: number }> = {}
 
@@ -135,6 +183,27 @@ export function UserStatisticsChart({ data, onTimeRangeChange }: UserStatisticsC
     return totals
   }, [data.chartData, data.users])
 
+  // 计算可见用户的总计(用于顶部统计卡片)
+  const visibleTotals = React.useMemo(() => {
+    const costTotal = data.chartData.reduce((sum, day) => {
+      const dayTotal = visibleUsers.reduce((daySum, user) => {
+        const costValue = toDecimal(day[`${user.dataKey}_cost`])
+        return costValue ? daySum.plus(costValue) : daySum
+      }, new Decimal(0))
+      return sum.plus(dayTotal)
+    }, new Decimal(0))
+
+    const callsTotal = data.chartData.reduce((sum, day) => {
+      const dayTotal = visibleUsers.reduce((daySum, user) => {
+        const callsValue = day[`${user.dataKey}_calls`]
+        return daySum + (typeof callsValue === 'number' ? callsValue : 0);
+      }, 0)
+      return sum + dayTotal
+    }, 0)
+
+    return { cost: costTotal, calls: callsTotal }
+  }, [data.chartData, visibleUsers])
+
   const sortedLegendUsers = React.useMemo(() => {
     return data.users
       .map((user, index) => ({ user, index }))
@@ -161,27 +230,6 @@ export function UserStatisticsChart({ data, onTimeRangeChange }: UserStatisticsC
       })
   }, [data.users, userTotals, activeChart])
 
-  // 计算总计
-  const totals = React.useMemo(() => {
-    const costTotal = data.chartData.reduce((sum, day) => {
-      const dayTotal = data.users.reduce((daySum, user) => {
-        const costValue = toDecimal(day[`${user.dataKey}_cost`])
-        return costValue ? daySum.plus(costValue) : daySum
-      }, new Decimal(0))
-      return sum.plus(dayTotal)
-    }, new Decimal(0))
-
-    const callsTotal = data.chartData.reduce((sum, day) => {
-      const dayTotal = data.users.reduce((daySum, user) => {
-        const callsValue = day[`${user.dataKey}_calls`]
-        return daySum + (typeof callsValue === 'number' ? callsValue : 0);
-      }, 0)
-      return sum + dayTotal
-    }, 0)
-
-    return { cost: costTotal, calls: callsTotal }
-  }, [data.chartData, data.users])
-
   // 格式化日期显示(根据分辨率)
   const formatDate = (dateStr: string) => {
     const date = new Date(dateStr)
@@ -232,9 +280,13 @@ export function UserStatisticsChart({ data, onTimeRangeChange }: UserStatisticsC
   }
 
   const getAggregationLabel = () => {
-    return data.mode === 'keys'
-      ? '仅显示您名下各密钥的使用统计'
-      : '展示所有用户的使用统计'
+    if (data.mode === 'keys') {
+      return '仅显示您名下各密钥的使用统计'
+    } else if (data.mode === 'mixed') {
+      return '展示您的密钥明细和其他用户汇总'
+    } else {
+      return '展示所有用户的使用统计'
+    }
   }
 
   return (
@@ -271,7 +323,7 @@ export function UserStatisticsChart({ data, onTimeRangeChange }: UserStatisticsC
                 总消费金额
               </span>
               <span className="text-lg leading-none font-bold sm:text-3xl">
-                {formatCurrency(totals.cost)}
+                {formatCurrency(visibleTotals.cost)}
               </span>
             </button>
             <button
@@ -283,7 +335,7 @@ export function UserStatisticsChart({ data, onTimeRangeChange }: UserStatisticsC
                 总API调用次数
               </span>
               <span className="text-lg leading-none font-bold sm:text-3xl">
-                {totals.calls.toLocaleString()}
+                {visibleTotals.calls.toLocaleString()}
               </span>
             </button>
           </div>
@@ -301,7 +353,7 @@ export function UserStatisticsChart({ data, onTimeRangeChange }: UserStatisticsC
               总消费金额
             </span>
             <span className="text-lg leading-none font-bold sm:text-xl">
-              {formatCurrency(totals.cost)}
+              {formatCurrency(visibleTotals.cost)}
             </span>
           </button>
           <button
@@ -313,7 +365,7 @@ export function UserStatisticsChart({ data, onTimeRangeChange }: UserStatisticsC
               总API调用次数
             </span>
             <span className="text-lg leading-none font-bold sm:text-xl">
-              {totals.calls.toLocaleString()}
+              {visibleTotals.calls.toLocaleString()}
             </span>
           </button>
         </div>
@@ -437,8 +489,9 @@ export function UserStatisticsChart({ data, onTimeRangeChange }: UserStatisticsC
                 )
               }}
             />
-            {data.users.map((user, index) => {
-              const color = getUserColor(index)
+            {visibleUsers.map((user, index) => {
+              const originalIndex = data.users.findIndex(u => u.id === user.id)
+              const color = getUserColor(originalIndex)
               return (
                 <Area
                   key={user.dataKey}
@@ -454,15 +507,45 @@ export function UserStatisticsChart({ data, onTimeRangeChange }: UserStatisticsC
             <ChartLegend
               content={() => (
                 <div className="px-1">
+                  {/* 全选/清空按钮 (仅 Admin 且用户数 > 1 时显示) */}
+                  {enableUserFilter && (
+                    <div className="flex items-center justify-center gap-2 mb-2">
+                      <button
+                        onClick={selectAllUsers}
+                        className="text-xs text-muted-foreground hover:text-foreground px-2 py-1 rounded hover:bg-muted/50 transition-colors"
+                      >
+                        全选 ({data.users.length})
+                      </button>
+                      <span className="text-muted-foreground">·</span>
+                      <button
+                        onClick={deselectAllUsers}
+                        className="text-xs text-muted-foreground hover:text-foreground px-2 py-1 rounded hover:bg-muted/50 transition-colors"
+                      >
+                        清空
+                      </button>
+                      <span className="text-muted-foreground">·</span>
+                      <span className="text-xs text-muted-foreground">
+                        已选 {selectedUserIds.size}/{data.users.length}
+                      </span>
+                    </div>
+                  )}
                   <div className="flex flex-wrap justify-center gap-1">
                     {sortedLegendUsers.map(({ user, index }) => {
                       const color = getUserColor(index)
                       const userTotal = userTotals[user.dataKey] ?? { cost: new Decimal(0), calls: 0 }
+                      const isSelected = selectedUserIds.has(user.id)
 
                       return (
                         <div
                           key={user.dataKey}
-                          className="bg-muted/30 rounded-md px-3 py-2 text-center transition-all hover:bg-muted/50 min-w-16"
+                          onClick={() => enableUserFilter && toggleUserSelection(user.id)}
+                          className={cn(
+                            "rounded-md px-3 py-2 text-center transition-all min-w-16",
+                            enableUserFilter && "cursor-pointer",
+                            isSelected
+                              ? "bg-muted/50 hover:bg-muted/70 ring-1 ring-border"
+                              : "bg-muted/10 hover:bg-muted/30 opacity-50"
+                          )}
                         >
                           {/* 上方:颜色点 + 用户名 */}
                           <div className="flex items-center justify-center gap-1 mb-1">

+ 58 - 12
src/app/dashboard/logs/_components/error-details-dialog.tsx

@@ -1,6 +1,7 @@
 "use client";
 
-import { useState } from "react";
+import { useState, useEffect } from "react";
+import Link from "next/link";
 import {
   Dialog,
   DialogContent,
@@ -11,8 +12,9 @@ import {
 } from "@/components/ui/dialog";
 import { Badge } from "@/components/ui/badge";
 import { Button } from "@/components/ui/button";
-import { AlertCircle, CheckCircle, ChevronRight } from "lucide-react";
+import { AlertCircle, CheckCircle, ChevronRight, ExternalLink, Loader2 } from "lucide-react";
 import type { ProviderChainItem } from "@/types/message";
+import { hasSessionMessages } from "@/actions/active-sessions";
 
 interface ErrorDetailsDialogProps {
   statusCode: number | null;
@@ -41,12 +43,38 @@ export function ErrorDetailsDialog({
   sessionId,
 }: ErrorDetailsDialogProps) {
   const [open, setOpen] = useState(false);
+  const [hasMessages, setHasMessages] = useState(false);
+  const [checkingMessages, setCheckingMessages] = useState(false);
 
   const isSuccess = statusCode && statusCode >= 200 && statusCode < 300;
   const isError = statusCode && (statusCode >= 400 || statusCode < 200);
+  const isInProgress = !statusCode; // 没有状态码表示请求进行中
+
+  // 检查 session 是否有 messages 数据
+  useEffect(() => {
+    if (open && sessionId) {
+      setCheckingMessages(true);
+      hasSessionMessages(sessionId)
+        .then((result) => {
+          if (result.ok) {
+            setHasMessages(result.data);
+          }
+        })
+        .catch((err) => {
+          console.error('Failed to check session messages:', err);
+        })
+        .finally(() => {
+          setCheckingMessages(false);
+        });
+    } else {
+      // 弹窗关闭时重置状态
+      setHasMessages(false);
+      setCheckingMessages(false);
+    }
+  }, [open, sessionId]);
 
   const getStatusBadgeVariant = () => {
-    if (!statusCode) return "secondary";
+    if (isInProgress) return "outline"; // 请求中使用 outline 样式
     if (isSuccess) return "default";
     if (isError) return "destructive";
     return "secondary";
@@ -60,22 +88,26 @@ export function ErrorDetailsDialog({
           className="h-auto p-0 font-normal hover:bg-transparent"
         >
           <Badge variant={getStatusBadgeVariant()} className="cursor-pointer">
-            {statusCode || "-"}
+            {isInProgress ? "请求中" : statusCode}
           </Badge>
         </Button>
       </DialogTrigger>
       <DialogContent className="max-w-3xl max-h-[80vh] overflow-y-auto">
         <DialogHeader>
           <DialogTitle className="flex items-center gap-2">
-            {isSuccess ? (
+            {isInProgress ? (
+              <Loader2 className="h-5 w-5 text-muted-foreground animate-spin" />
+            ) : isSuccess ? (
               <CheckCircle className="h-5 w-5 text-green-600" />
             ) : (
               <AlertCircle className="h-5 w-5 text-destructive" />
             )}
-            请求详情 - 状态码 {statusCode || "未知"}
+            请求详情 - 状态码 {isInProgress ? "请求中" : statusCode || "未知"}
           </DialogTitle>
           <DialogDescription>
-            {isSuccess
+            {isInProgress
+              ? "请求正在进行中,尚未完成"
+              : isSuccess
               ? "请求成功完成"
               : "请求失败,以下是详细的错误信息和供应商决策链"}
           </DialogDescription>
@@ -86,10 +118,20 @@ export function ErrorDetailsDialog({
           {sessionId && (
             <div className="space-y-2">
               <h4 className="font-semibold text-sm">会话 ID</h4>
-              <div className="rounded-md border bg-muted/50 p-3">
-                <code className="text-xs font-mono break-all">
-                  {sessionId}
-                </code>
+              <div className="flex items-center gap-3">
+                <div className="flex-1 rounded-md border bg-muted/50 p-3">
+                  <code className="text-xs font-mono break-all">
+                    {sessionId}
+                  </code>
+                </div>
+                {hasMessages && !checkingMessages && (
+                  <Link href={`/dashboard/sessions/${sessionId}/messages`}>
+                    <Button variant="outline" size="sm">
+                      <ExternalLink className="h-4 w-4 mr-2" />
+                      查看详情
+                    </Button>
+                  </Link>
+                )}
               </div>
             </div>
           )}
@@ -204,7 +246,11 @@ export function ErrorDetailsDialog({
           {/* 无错误信息的情况 */}
           {!errorMessage && (!providerChain || providerChain.length === 0) && (
             <div className="text-center py-8 text-muted-foreground">
-              {isSuccess ? "请求成功,无错误信息" : "暂无详细错误信息"}
+              {isInProgress
+                ? "请求正在处理中,等待响应..."
+                : isSuccess
+                ? "请求成功,无错误信息"
+                : "暂无详细错误信息"}
             </div>
           )}
         </div>

+ 11 - 2
src/app/dashboard/sessions/_components/active-sessions-table.tsx

@@ -12,11 +12,13 @@ import { Badge } from "@/components/ui/badge";
 import { Button } from "@/components/ui/button";
 import { Eye } from "lucide-react";
 import Link from "next/link";
+import { cn } from "@/lib/utils";
 import type { ActiveSessionInfo } from "@/types/session";
 
 interface ActiveSessionsTableProps {
   sessions: ActiveSessionInfo[];
   isLoading: boolean;
+  inactive?: boolean; // 标记是否为非活跃 session
 }
 
 function formatDuration(durationMs: number | undefined): string {
@@ -46,6 +48,7 @@ function getStatusBadge(status: 'in_progress' | 'completed' | 'error', statusCod
 export function ActiveSessionsTable({
   sessions,
   isLoading,
+  inactive = false,
 }: ActiveSessionsTableProps) {
   // 按开始时间降序排序(最新的在前)
   const sortedSessions = [...sessions].sort((a, b) => b.startTime - a.startTime);
@@ -54,7 +57,8 @@ export function ActiveSessionsTable({
     <div className="space-y-4">
       <div className="flex items-center justify-between">
         <div className="text-sm text-muted-foreground">
-          共 {sessions.length} 个活跃 Session
+          共 {sessions.length} 个{inactive ? '非活跃' : '活跃'} Session
+          {inactive && <span className="ml-2 text-xs">(不计入并发数)</span>}
         </div>
         {isLoading && (
           <div className="text-sm text-muted-foreground animate-pulse">
@@ -63,7 +67,12 @@ export function ActiveSessionsTable({
         )}
       </div>
 
-      <div className="rounded-md border">
+      <div
+        className={cn(
+          "rounded-md border",
+          inactive && "opacity-60" // 非活跃 session 半透明显示
+        )}
+      >
         <Table>
           <TableHeader>
             <TableRow>

+ 49 - 26
src/app/dashboard/sessions/page.tsx

@@ -6,16 +6,19 @@ import { Section } from "@/components/section";
 import { Button } from "@/components/ui/button";
 import { ArrowLeft } from "lucide-react";
 import { useRouter } from "next/navigation";
-import { getActiveSessions } from "@/actions/active-sessions";
+import { getAllSessions } from "@/actions/active-sessions";
 import { ActiveSessionsTable } from "./_components/active-sessions-table";
 import type { ActiveSessionInfo } from "@/types/session";
 
 const REFRESH_INTERVAL = 3000; // 3秒刷新一次
 
-async function fetchActiveSessions(): Promise<ActiveSessionInfo[]> {
-  const result = await getActiveSessions();
+async function fetchAllSessions(): Promise<{
+  active: ActiveSessionInfo[];
+  inactive: ActiveSessionInfo[];
+}> {
+  const result = await getAllSessions();
   if (!result.ok) {
-    throw new Error(result.error || '获取活跃 session 失败');
+    throw new Error(result.error || '获取 session 列表失败');
   }
   return result.data;
 }
@@ -26,43 +29,63 @@ async function fetchActiveSessions(): Promise<ActiveSessionInfo[]> {
 export default function ActiveSessionsPage() {
   const router = useRouter();
 
-  const { data: sessions = [], isLoading, error } = useQuery<ActiveSessionInfo[], Error>({
-    queryKey: ["active-sessions"],
-    queryFn: fetchActiveSessions,
+  const {
+    data,
+    isLoading,
+    error,
+  } = useQuery<
+    { active: ActiveSessionInfo[]; inactive: ActiveSessionInfo[] },
+    Error
+  >({
+    queryKey: ["all-sessions"],
+    queryFn: fetchAllSessions,
     refetchInterval: REFRESH_INTERVAL,
   });
 
+  const activeSessions = data?.active || [];
+  const inactiveSessions = data?.inactive || [];
+
   return (
     <div className="space-y-6">
       <div className="flex items-center gap-4">
-        <Button
-          variant="outline"
-          size="sm"
-          onClick={() => router.back()}
-        >
+        <Button variant="outline" size="sm" onClick={() => router.back()}>
           <ArrowLeft className="h-4 w-4 mr-2" />
           返回
         </Button>
         <div>
-          <h1 className="text-2xl font-bold">活跃 Session 监控</h1>
+          <h1 className="text-2xl font-bold">Session 监控</h1>
           <p className="text-sm text-muted-foreground">
-            实时显示最近 5 分钟内的活跃请求(每 3 秒自动刷新)
+            实时显示活跃和非活跃 Session(每 3 秒自动刷新)
           </p>
         </div>
       </div>
 
-      <Section title="活跃 Session 列表">
-        {error ? (
-          <div className="text-center text-destructive py-8">
-            加载失败: {error.message}
-          </div>
-        ) : (
-          <ActiveSessionsTable
-            sessions={sessions}
-            isLoading={isLoading}
-          />
-        )}
-      </Section>
+      {error ? (
+        <div className="text-center text-destructive py-8">
+          加载失败: {error.message}
+        </div>
+      ) : (
+        <>
+          {/* 活跃 Session 区域 */}
+          <Section title="活跃 Session(最近 5 分钟)">
+            <ActiveSessionsTable
+              sessions={activeSessions}
+              isLoading={isLoading}
+            />
+          </Section>
+
+          {/* 非活跃 Session 区域 */}
+          {inactiveSessions.length > 0 && (
+            <Section title="非活跃 Session(超过 5 分钟,仅供查看)">
+              <ActiveSessionsTable
+                sessions={inactiveSessions}
+                isLoading={isLoading}
+                inactive
+              />
+            </Section>
+          )}
+        </>
+      )}
     </div>
   );
 }

+ 6 - 0
src/app/v1/[...route]/route.ts

@@ -2,9 +2,15 @@ import { Hono } from "hono";
 import { handle } from "hono/vercel";
 import { handleProxyRequest } from "@/app/v1/_lib/proxy-handler";
 import { handleChatCompletions } from "@/app/v1/_lib/codex/chat-completions-handler";
+import { SessionTracker } from "@/lib/session-tracker";
 
 export const runtime = "nodejs";
 
+// 初始化 SessionTracker(清理旧 Set 格式数据)
+SessionTracker.initialize().catch((err) => {
+  console.error('[App] SessionTracker initialization failed:', err);
+});
+
 const app = new Hono().basePath("/v1");
 
 // OpenAI Compatible API 路由

+ 1 - 1
src/drizzle/schema.ts

@@ -169,7 +169,7 @@ export const modelPrices = pgTable('model_prices', {
 export const systemSettings = pgTable('system_settings', {
   id: serial('id').primaryKey(),
   siteTitle: varchar('site_title', { length: 128 }).notNull().default('Claude Code Hub'),
-  allowGlobalUsageView: boolean('allow_global_usage_view').notNull().default(true),
+  allowGlobalUsageView: boolean('allow_global_usage_view').notNull().default(false),
   createdAt: timestamp('created_at', { withTimezone: true }).defaultNow(),
   updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow(),
 });

+ 155 - 37
src/lib/session-manager.ts

@@ -418,6 +418,57 @@ export class SessionManager {
     }
   }
 
+  /**
+   * 辅助方法:从 Redis Hash 数据构建 ActiveSessionInfo 对象
+   *
+   * @private
+   */
+  private static buildSessionInfo(
+    sessionId: string,
+    info: Record<string, string>,
+    usage: Record<string, string>
+  ): ActiveSessionInfo {
+    const startTime = parseInt(info.startTime || '0', 10);
+    const now = Date.now();
+
+    const session: ActiveSessionInfo = {
+      sessionId,
+      userName: info.userName || 'unknown',
+      userId: parseInt(info.userId || '0', 10),
+      keyId: parseInt(info.keyId || '0', 10),
+      keyName: info.keyName || 'unknown',
+      providerId: info.providerId ? parseInt(info.providerId, 10) : null,
+      providerName: info.providerName || null,
+      model: info.model || null,
+      apiType: (info.apiType as 'chat' | 'codex') || 'chat',
+      startTime,
+      status: (usage.status || info.status || 'in_progress') as 'in_progress' | 'completed' | 'error',
+      durationMs: startTime > 0 ? now - startTime : undefined,
+    };
+
+    // 添加 usage 数据(如果存在)
+    if (usage && Object.keys(usage).length > 0) {
+      if (usage.inputTokens) session.inputTokens = parseInt(usage.inputTokens, 10);
+      if (usage.outputTokens) session.outputTokens = parseInt(usage.outputTokens, 10);
+      if (usage.cacheCreationInputTokens)
+        session.cacheCreationInputTokens = parseInt(usage.cacheCreationInputTokens, 10);
+      if (usage.cacheReadInputTokens)
+        session.cacheReadInputTokens = parseInt(usage.cacheReadInputTokens, 10);
+      if (usage.costUsd) session.costUsd = usage.costUsd;
+      if (usage.statusCode) session.statusCode = parseInt(usage.statusCode, 10);
+      if (usage.errorMessage) session.errorMessage = usage.errorMessage;
+
+      // 计算总 token
+      const input = session.inputTokens || 0;
+      const output = session.outputTokens || 0;
+      const cacheCreate = session.cacheCreationInputTokens || 0;
+      const cacheRead = session.cacheReadInputTokens || 0;
+      session.totalTokens = input + output + cacheCreate + cacheRead;
+    }
+
+    return session;
+  }
+
   /**
    * 获取活跃 session 列表(用于实时监控页面)
    */
@@ -469,43 +520,8 @@ export class SessionManager {
         // 跳过空的 info(session 可能已过期)
         if (!info || Object.keys(info).length === 0) continue;
 
-        // 解析并构建 ActiveSessionInfo
-        const startTime = parseInt(info.startTime || '0', 10);
-        const now = Date.now();
-
-        const session: ActiveSessionInfo = {
-          sessionId: sessionIds[i],
-          userName: info.userName || 'unknown',
-          userId: parseInt(info.userId || '0', 10),
-          keyId: parseInt(info.keyId || '0', 10),
-          keyName: info.keyName || 'unknown',
-          providerId: info.providerId ? parseInt(info.providerId, 10) : null,
-          providerName: info.providerName || null,
-          model: info.model || null,
-          apiType: (info.apiType as 'chat' | 'codex') || 'chat',
-          startTime,
-          status: (usage.status || info.status || 'in_progress') as 'in_progress' | 'completed' | 'error',
-          durationMs: startTime > 0 ? now - startTime : undefined,
-        };
-
-        // 添加 usage 数据(如果存在)
-        if (usage && Object.keys(usage).length > 0) {
-          if (usage.inputTokens) session.inputTokens = parseInt(usage.inputTokens, 10);
-          if (usage.outputTokens) session.outputTokens = parseInt(usage.outputTokens, 10);
-          if (usage.cacheCreationInputTokens) session.cacheCreationInputTokens = parseInt(usage.cacheCreationInputTokens, 10);
-          if (usage.cacheReadInputTokens) session.cacheReadInputTokens = parseInt(usage.cacheReadInputTokens, 10);
-          if (usage.costUsd) session.costUsd = usage.costUsd;
-          if (usage.statusCode) session.statusCode = parseInt(usage.statusCode, 10);
-          if (usage.errorMessage) session.errorMessage = usage.errorMessage;
-
-          // 计算总 token
-          const input = session.inputTokens || 0;
-          const output = session.outputTokens || 0;
-          const cacheCreate = session.cacheCreationInputTokens || 0;
-          const cacheRead = session.cacheReadInputTokens || 0;
-          session.totalTokens = input + output + cacheCreate + cacheRead;
-        }
-
+        // 使用辅助方法构建 session 对象
+        const session = this.buildSessionInfo(sessionIds[i], info, usage);
         sessions.push(session);
       }
 
@@ -517,6 +533,108 @@ export class SessionManager {
     }
   }
 
+  /**
+   * 获取所有 session(包括非活跃的)
+   *
+   * 使用 SCAN 扫描 Redis 中所有 session:*:info key,
+   * 按最后活跃时间分为活跃(5 分钟内)和非活跃两组。
+   *
+   * @returns { active: 活跃 session 列表, inactive: 非活跃 session 列表 }
+   */
+  static async getAllSessionsWithExpiry(): Promise<{
+    active: ActiveSessionInfo[];
+    inactive: ActiveSessionInfo[];
+  }> {
+    const redis = getRedisClient();
+    if (!redis || redis.status !== 'ready') {
+      console.warn('[SessionManager] Redis not ready, returning empty lists');
+      return { active: [], inactive: [] };
+    }
+
+    try {
+      const now = Date.now();
+      const fiveMinutesAgo = now - this.SESSION_TTL * 1000; // SESSION_TTL 是秒,转为毫秒
+
+      // 1. 使用 SCAN 扫描所有 session:*:info key
+      const allSessions: ActiveSessionInfo[] = [];
+      let cursor = '0';
+
+      do {
+        const [nextCursor, keys] = (await redis.scan(
+          cursor,
+          'MATCH',
+          'session:*:info',
+          'COUNT',
+          100
+        )) as [string, string[]];
+
+        cursor = nextCursor;
+
+        if (keys.length > 0) {
+          // 2. 批量获取 session info 和 usage
+          const pipeline = redis.pipeline();
+
+          for (const key of keys) {
+            pipeline.hgetall(key);
+            // 提取 sessionId
+            const sessionId = key.replace('session:', '').replace(':info', '');
+            pipeline.hgetall(`session:${sessionId}:usage`);
+          }
+
+          const results = await pipeline.exec();
+          if (!results) continue;
+
+          // 3. 解析结果
+          for (let i = 0; i < keys.length; i++) {
+            const infoIndex = i * 2;
+            const usageIndex = i * 2 + 1;
+
+            const infoResult = results[infoIndex];
+            const usageResult = results[usageIndex];
+
+            // 检查结果有效性
+            if (!infoResult || infoResult[0] !== null) continue;
+            if (!usageResult || usageResult[0] !== null) continue;
+
+            const info = infoResult[1] as Record<string, string>;
+            const usage = usageResult[1] as Record<string, string>;
+
+            // 跳过空的 info
+            if (!info || Object.keys(info).length === 0) continue;
+
+            // 提取 sessionId
+            const sessionId = keys[i].replace('session:', '').replace(':info', '');
+
+            // 使用辅助方法构建 session 对象
+            const session = this.buildSessionInfo(sessionId, info, usage);
+            allSessions.push(session);
+          }
+        }
+      } while (cursor !== '0');
+
+      // 4. 按最后活跃时间分组
+      const active: ActiveSessionInfo[] = [];
+      const inactive: ActiveSessionInfo[] = [];
+
+      for (const session of allSessions) {
+        if (session.startTime >= fiveMinutesAgo) {
+          active.push(session);
+        } else {
+          inactive.push(session);
+        }
+      }
+
+      console.debug(
+        `[SessionManager] Found ${active.length} active, ${inactive.length} inactive sessions (from ${allSessions.length} total)`
+      );
+
+      return { active, inactive };
+    } catch (error) {
+      console.error('[SessionManager] Failed to get all sessions:', error);
+      return { active: [], inactive: [] };
+    }
+  }
+
   /**
    * 获取 session 的 messages 内容
    */

+ 87 - 3
src/lib/session-tracker.ts

@@ -17,6 +17,44 @@ import { getRedisClient } from './redis';
 export class SessionTracker {
   private static readonly SESSION_TTL = 300000; // 5 分钟(毫秒)
 
+  /**
+   * 初始化 SessionTracker,自动清理旧格式数据
+   *
+   * 应在应用启动时调用一次,清理重构前的 Set 类型数据,
+   * 确保所有集合都是 ZSET 格式。
+   */
+  static async initialize(): Promise<void> {
+    const redis = getRedisClient();
+    if (!redis || redis.status !== 'ready') {
+      console.warn('[SessionTracker] Redis not ready, skipping initialization');
+      return;
+    }
+
+    try {
+      const key = 'global:active_sessions';
+      const exists = await redis.exists(key);
+
+      if (exists === 1) {
+        const type = await redis.type(key);
+
+        if (type === 'set') {
+          console.warn(`[SessionTracker] Found legacy Set: ${key}, migrating to ZSET...`);
+          await redis.del(key);
+          console.info(`[SessionTracker] ✅ Successfully migrated ${key} to ZSET format`);
+        } else if (type === 'zset') {
+          console.debug(`[SessionTracker] ${key} is already ZSET format, no migration needed`);
+        } else {
+          console.warn(`[SessionTracker] Unexpected type for ${key}: ${type}, deleting...`);
+          await redis.del(key);
+        }
+      } else {
+        console.debug(`[SessionTracker] ${key} does not exist, will be created on first use`);
+      }
+    } catch (error) {
+      console.error('[SessionTracker] Initialization failed:', error);
+    }
+  }
+
   /**
    * 追踪 session(添加到全局和 key 级集合)
    *
@@ -41,7 +79,23 @@ export class SessionTracker {
       pipeline.zadd(`key:${keyId}:active_sessions`, now, sessionId);
       pipeline.expire(`key:${keyId}:active_sessions`, 3600);
 
-      await pipeline.exec();
+      const results = await pipeline.exec();
+
+      // 检查执行结果,捕获类型冲突错误
+      if (results) {
+        for (const [err] of results) {
+          if (err) {
+            console.error('[SessionTracker] Pipeline command failed:', err);
+            // 如果是类型冲突(WRONGTYPE),自动修复
+            if (err.message?.includes('WRONGTYPE')) {
+              console.warn('[SessionTracker] Type conflict detected, auto-fixing...');
+              await this.initialize(); // 重新初始化,清理旧数据
+              return; // 本次追踪失败,下次请求会成功
+            }
+          }
+        }
+      }
+
       console.debug(`[SessionTracker] Tracked session: ${sessionId} (key=${keyId})`);
     } catch (error) {
       console.error('[SessionTracker] Failed to track session:', error);
@@ -71,7 +125,22 @@ export class SessionTracker {
       pipeline.zadd(`provider:${providerId}:active_sessions`, now, sessionId);
       pipeline.expire(`provider:${providerId}:active_sessions`, 3600);
 
-      await pipeline.exec();
+      const results = await pipeline.exec();
+
+      // 检查执行结果,捕获类型冲突错误
+      if (results) {
+        for (const [err] of results) {
+          if (err) {
+            console.error('[SessionTracker] Pipeline command failed:', err);
+            if (err.message?.includes('WRONGTYPE')) {
+              console.warn('[SessionTracker] Type conflict detected, auto-fixing...');
+              await this.initialize();
+              return;
+            }
+          }
+        }
+      }
+
       console.debug(`[SessionTracker] Updated provider: ${sessionId} → ${providerId}`);
     } catch (error) {
       console.error('[SessionTracker] Failed to update provider:', error);
@@ -104,7 +173,22 @@ export class SessionTracker {
       pipeline.zadd(`key:${keyId}:active_sessions`, now, sessionId);
       pipeline.zadd(`provider:${providerId}:active_sessions`, now, sessionId);
 
-      await pipeline.exec();
+      const results = await pipeline.exec();
+
+      // 检查执行结果,捕获类型冲突错误
+      if (results) {
+        for (const [err] of results) {
+          if (err) {
+            console.error('[SessionTracker] Pipeline command failed:', err);
+            if (err.message?.includes('WRONGTYPE')) {
+              console.warn('[SessionTracker] Type conflict detected, auto-fixing...');
+              await this.initialize();
+              return;
+            }
+          }
+        }
+      }
+
       console.debug(`[SessionTracker] Refreshed session: ${sessionId}`);
     } catch (error) {
       console.error('[SessionTracker] Failed to refresh session:', error);

+ 246 - 0
src/repository/statistics.ts

@@ -305,3 +305,249 @@ export async function getActiveKeysForUserFromDB(userId: number): Promise<Databa
   const result = await db.execute(query);
   return Array.from(result) as unknown as DatabaseKey[];
 }
+
+/**
+ * 获取混合统计数据:当前用户的密钥明细 + 其他用户的汇总
+ * 用于非 admin 用户在 allowGlobalUsageView=true 时的数据展示
+ */
+export async function getMixedStatisticsFromDB(
+  userId: number,
+  timeRange: TimeRange
+): Promise<{
+  ownKeys: DatabaseKeyStatRow[];
+  othersAggregate: DatabaseStatRow[];
+}> {
+  let ownKeysQuery;
+  let othersQuery;
+
+  switch (timeRange) {
+    case 'today':
+      // 自己的密钥明细(小时分辨率)
+      ownKeysQuery = sql`
+        WITH hour_range AS (
+          SELECT generate_series(
+            DATE_TRUNC('day', NOW()),
+            DATE_TRUNC('day', NOW()) + INTERVAL '23 hours',
+            '1 hour'::interval
+          ) AS hour
+        ),
+        user_keys AS (
+          SELECT id, name, key
+          FROM keys
+          WHERE user_id = ${userId}
+            AND deleted_at IS NULL
+        ),
+        hourly_stats AS (
+          SELECT
+            k.id AS key_id,
+            k.name AS key_name,
+            hr.hour,
+            COUNT(mr.id) AS api_calls,
+            COALESCE(SUM(mr.cost_usd), 0) AS total_cost
+          FROM user_keys k
+          CROSS JOIN hour_range hr
+          LEFT JOIN message_request mr ON mr.key = k.key
+            AND mr.user_id = ${userId}
+            AND DATE_TRUNC('hour', mr.created_at) = hr.hour
+            AND mr.deleted_at IS NULL
+          GROUP BY k.id, k.name, hr.hour
+        )
+        SELECT
+          key_id,
+          key_name,
+          hour AS date,
+          api_calls::integer,
+          total_cost::numeric
+        FROM hourly_stats
+        ORDER BY hour ASC, key_name ASC
+      `;
+
+      // 其他用户汇总(小时分辨率)
+      othersQuery = sql`
+        WITH hour_range AS (
+          SELECT generate_series(
+            DATE_TRUNC('day', NOW()),
+            DATE_TRUNC('day', NOW()) + INTERVAL '23 hours',
+            '1 hour'::interval
+          ) AS hour
+        ),
+        hourly_stats AS (
+          SELECT
+            hr.hour,
+            COUNT(mr.id) AS api_calls,
+            COALESCE(SUM(mr.cost_usd), 0) AS total_cost
+          FROM hour_range hr
+          LEFT JOIN message_request mr ON DATE_TRUNC('hour', mr.created_at) = hr.hour
+            AND mr.user_id != ${userId}
+            AND mr.deleted_at IS NULL
+          GROUP BY hr.hour
+        )
+        SELECT
+          -1 AS user_id,
+          '其他用户' AS user_name,
+          hour AS date,
+          api_calls::integer,
+          total_cost::numeric
+        FROM hourly_stats
+        ORDER BY hour ASC
+      `;
+      break;
+
+    case '7days':
+      // 自己的密钥明细(天分辨率)
+      ownKeysQuery = sql`
+        WITH date_range AS (
+          SELECT generate_series(
+            CURRENT_DATE - INTERVAL '6 days',
+            CURRENT_DATE,
+            '1 day'::interval
+          )::date AS date
+        ),
+        user_keys AS (
+          SELECT id, name, key
+          FROM keys
+          WHERE user_id = ${userId}
+            AND deleted_at IS NULL
+        ),
+        daily_stats AS (
+          SELECT
+            k.id AS key_id,
+            k.name AS key_name,
+            dr.date,
+            COUNT(mr.id) AS api_calls,
+            COALESCE(SUM(mr.cost_usd), 0) AS total_cost
+          FROM user_keys k
+          CROSS JOIN date_range dr
+          LEFT JOIN message_request mr ON mr.key = k.key
+            AND mr.user_id = ${userId}
+            AND DATE(mr.created_at) = dr.date
+            AND mr.deleted_at IS NULL
+          GROUP BY k.id, k.name, dr.date
+        )
+        SELECT
+          key_id,
+          key_name,
+          date,
+          api_calls::integer,
+          total_cost::numeric
+        FROM daily_stats
+        ORDER BY date ASC, key_name ASC
+      `;
+
+      // 其他用户汇总(天分辨率)
+      othersQuery = sql`
+        WITH date_range AS (
+          SELECT generate_series(
+            CURRENT_DATE - INTERVAL '6 days',
+            CURRENT_DATE,
+            '1 day'::interval
+          )::date AS date
+        ),
+        daily_stats AS (
+          SELECT
+            dr.date,
+            COUNT(mr.id) AS api_calls,
+            COALESCE(SUM(mr.cost_usd), 0) AS total_cost
+          FROM date_range dr
+          LEFT JOIN message_request mr ON DATE(mr.created_at) = dr.date
+            AND mr.user_id != ${userId}
+            AND mr.deleted_at IS NULL
+          GROUP BY dr.date
+        )
+        SELECT
+          -1 AS user_id,
+          '其他用户' AS user_name,
+          date,
+          api_calls::integer,
+          total_cost::numeric
+        FROM daily_stats
+        ORDER BY date ASC
+      `;
+      break;
+
+    case '30days':
+      // 自己的密钥明细(天分辨率)
+      ownKeysQuery = sql`
+        WITH date_range AS (
+          SELECT generate_series(
+            CURRENT_DATE - INTERVAL '29 days',
+            CURRENT_DATE,
+            '1 day'::interval
+          )::date AS date
+        ),
+        user_keys AS (
+          SELECT id, name, key
+          FROM keys
+          WHERE user_id = ${userId}
+            AND deleted_at IS NULL
+        ),
+        daily_stats AS (
+          SELECT
+            k.id AS key_id,
+            k.name AS key_name,
+            dr.date,
+            COUNT(mr.id) AS api_calls,
+            COALESCE(SUM(mr.cost_usd), 0) AS total_cost
+          FROM user_keys k
+          CROSS JOIN date_range dr
+          LEFT JOIN message_request mr ON mr.key = k.key
+            AND mr.user_id = ${userId}
+            AND DATE(mr.created_at) = dr.date
+            AND mr.deleted_at IS NULL
+          GROUP BY k.id, k.name, dr.date
+        )
+        SELECT
+          key_id,
+          key_name,
+          date,
+          api_calls::integer,
+          total_cost::numeric
+        FROM daily_stats
+        ORDER BY date ASC, key_name ASC
+      `;
+
+      // 其他用户汇总(天分辨率)
+      othersQuery = sql`
+        WITH date_range AS (
+          SELECT generate_series(
+            CURRENT_DATE - INTERVAL '29 days',
+            CURRENT_DATE,
+            '1 day'::interval
+          )::date AS date
+        ),
+        daily_stats AS (
+          SELECT
+            dr.date,
+            COUNT(mr.id) AS api_calls,
+            COALESCE(SUM(mr.cost_usd), 0) AS total_cost
+          FROM date_range dr
+          LEFT JOIN message_request mr ON DATE(mr.created_at) = dr.date
+            AND mr.user_id != ${userId}
+            AND mr.deleted_at IS NULL
+          GROUP BY dr.date
+        )
+        SELECT
+          -1 AS user_id,
+          '其他用户' AS user_name,
+          date,
+          api_calls::integer,
+          total_cost::numeric
+        FROM daily_stats
+        ORDER BY date ASC
+      `;
+      break;
+
+    default:
+      throw new Error(`Unsupported time range: ${timeRange}`);
+  }
+
+  const [ownKeysResult, othersResult] = await Promise.all([
+    db.execute(ownKeysQuery),
+    db.execute(othersQuery),
+  ]);
+
+  return {
+    ownKeys: Array.from(ownKeysResult) as unknown as DatabaseKeyStatRow[],
+    othersAggregate: Array.from(othersResult) as unknown as DatabaseStatRow[],
+  };
+}

+ 2 - 2
src/repository/system-config.ts

@@ -72,7 +72,7 @@ function createFallbackSettings(): SystemSettings {
   return {
     id: 0,
     siteTitle: DEFAULT_SITE_TITLE,
-    allowGlobalUsageView: true,
+    allowGlobalUsageView: false,
     createdAt: now,
     updatedAt: now,
   };
@@ -102,7 +102,7 @@ export async function getSystemSettings(): Promise<SystemSettings> {
       .insert(systemSettings)
       .values({
         siteTitle: DEFAULT_SITE_TITLE,
-        allowGlobalUsageView: true,
+        allowGlobalUsageView: false,
       })
       .onConflictDoNothing()
       .returning({

+ 1 - 1
src/types/statistics.ts

@@ -72,5 +72,5 @@ export interface UserStatisticsData {
   users: StatisticsUser[];
   timeRange: TimeRange;
   resolution: 'hour' | 'day';
-  mode: 'users' | 'keys';
+  mode: 'users' | 'keys' | 'mixed';
 }