Browse Source

feat: 实现独立的用户管理页面与用户级配额系统

主要功能:
- 创建独立的用户管理页面 (/dashboard/users),支持分组和搜索过滤
- 实现用户级配额系统 (5小时/周/月消费限额、并发会话数限制)
- 添加密钥级配额管理 UI,支持约束验证 (密钥限额不能超过用户限额)
- 实现权限系统,管理员专属字段权限控制
- 完整的 5 种语言支持 (zh-CN, zh-TW, en, ja, ru)

技术改进:
- 数据库迁移: 新增 4 个用户级配额字段 (nullable, 向后兼容)
- Repository 层: 扩展 CRUD 方法支持配额字段
- 验证层: 添加服务端约束验证,防止权限绕过
- 组件重构: 分离服务端/客户端组件,优化状态管理
- 权限配置: 集中式字段权限管理 (user-field-permissions.ts)

UI 优化:
- 配额页面简化为纯展示模式,移除 CRUD 操作
- 反馈按钮移至系统设置页面
- 用户管理添加至导航栏

修复的问题:
- 修复 Issue #90: 用户通过创建多个密钥绕过管理员限制的漏洞
- 修复类型错误: TagInputField value 类型不匹配
- 修复类型错误: groupTag setState 参数类型错误
- 修复 ESLint 错误: Record<string, any> → Record<string, unknown>

相关任务: IMPL-001 到 IMPL-010 (10/10 完成)
ding113 3 months ago
parent
commit
b92ab9cc0f
41 changed files with 2335 additions and 168 deletions
  1. 4 0
      drizzle/0020_next_juggernaut.sql
  2. 1519 0
      drizzle/meta/0020_snapshot.json
  3. 7 0
      drizzle/meta/_journal.json
  4. 30 0
      messages/en/dashboard.json
  5. 2 0
      messages/en/quota.json
  6. 1 0
      messages/en/settings.json
  7. 21 0
      messages/ja/dashboard.json
  8. 2 0
      messages/ja/quota.json
  9. 1 0
      messages/ja/settings.json
  10. 21 0
      messages/ru/dashboard.json
  11. 1 0
      messages/ru/settings.json
  12. 18 4
      messages/zh-CN/dashboard.json
  13. 10 4
      messages/zh-CN/quota.json
  14. 2 1
      messages/zh-CN/settings.json
  15. 21 0
      messages/zh-TW/dashboard.json
  16. 1 0
      messages/zh-TW/settings.json
  17. 92 0
      src/actions/keys.ts
  18. 64 58
      src/actions/users.ts
  19. 1 5
      src/app/[locale]/dashboard/_components/dashboard-header.tsx
  20. 5 1
      src/app/[locale]/dashboard/_components/user/add-user-dialog.tsx
  21. 25 5
      src/app/[locale]/dashboard/_components/user/forms/add-key-form.tsx
  22. 25 5
      src/app/[locale]/dashboard/_components/user/forms/edit-key-form.tsx
  23. 74 2
      src/app/[locale]/dashboard/_components/user/forms/user-form.tsx
  24. 13 2
      src/app/[locale]/dashboard/_components/user/key-actions.tsx
  25. 23 1
      src/app/[locale]/dashboard/_components/user/key-list-header.tsx
  26. 9 1
      src/app/[locale]/dashboard/_components/user/key-list.tsx
  27. 1 1
      src/app/[locale]/dashboard/_components/user/user-actions.tsx
  28. 38 0
      src/app/[locale]/dashboard/_components/user/user-key-manager.tsx
  29. 3 1
      src/app/[locale]/dashboard/_components/user/user-list.tsx
  30. 6 15
      src/app/[locale]/dashboard/page.tsx
  31. 5 61
      src/app/[locale]/dashboard/quotas/keys/page.tsx
  32. 13 0
      src/app/[locale]/dashboard/quotas/users/page.tsx
  33. 17 0
      src/app/[locale]/dashboard/users/page.tsx
  34. 99 0
      src/app/[locale]/dashboard/users/users-page-client.tsx
  35. 6 0
      src/app/[locale]/settings/_lib/nav-items.ts
  36. 1 1
      src/app/[locale]/settings/providers/_components/forms/provider-form.tsx
  37. 6 0
      src/drizzle/schema.ts
  38. 47 0
      src/lib/permissions/user-field-permissions.ts
  39. 50 0
      src/lib/validation/schemas.ts
  40. 31 0
      src/repository/user.ts
  41. 20 0
      src/types/user.ts

+ 4 - 0
drizzle/0020_next_juggernaut.sql

@@ -0,0 +1,4 @@
+ALTER TABLE "users" ADD COLUMN "limit_5h_usd" numeric(10, 2);--> statement-breakpoint
+ALTER TABLE "users" ADD COLUMN "limit_weekly_usd" numeric(10, 2);--> statement-breakpoint
+ALTER TABLE "users" ADD COLUMN "limit_monthly_usd" numeric(10, 2);--> statement-breakpoint
+ALTER TABLE "users" ADD COLUMN "limit_concurrent_sessions" integer;

+ 1519 - 0
drizzle/meta/0020_snapshot.json

@@ -0,0 +1,1519 @@
+{
+  "id": "2cca68f8-d8c7-4298-9f24-c8fd493d700e",
+  "prevId": "4ba39e7a-e353-4ed1-8e18-934a56fb0af6",
+  "version": "7",
+  "dialect": "postgresql",
+  "tables": {
+    "public.error_rules": {
+      "name": "error_rules",
+      "schema": "",
+      "columns": {
+        "id": {
+          "name": "id",
+          "type": "serial",
+          "primaryKey": true,
+          "notNull": true
+        },
+        "pattern": {
+          "name": "pattern",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true
+        },
+        "match_type": {
+          "name": "match_type",
+          "type": "varchar(20)",
+          "primaryKey": false,
+          "notNull": true,
+          "default": "'regex'"
+        },
+        "category": {
+          "name": "category",
+          "type": "varchar(50)",
+          "primaryKey": false,
+          "notNull": true
+        },
+        "description": {
+          "name": "description",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "is_enabled": {
+          "name": "is_enabled",
+          "type": "boolean",
+          "primaryKey": false,
+          "notNull": true,
+          "default": true
+        },
+        "is_default": {
+          "name": "is_default",
+          "type": "boolean",
+          "primaryKey": false,
+          "notNull": true,
+          "default": false
+        },
+        "priority": {
+          "name": "priority",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "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()"
+        }
+      },
+      "indexes": {
+        "idx_error_rules_enabled": {
+          "name": "idx_error_rules_enabled",
+          "columns": [
+            {
+              "expression": "is_enabled",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            },
+            {
+              "expression": "priority",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            }
+          ],
+          "isUnique": false,
+          "concurrently": false,
+          "method": "btree",
+          "with": {}
+        },
+        "unique_pattern": {
+          "name": "unique_pattern",
+          "columns": [
+            {
+              "expression": "pattern",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            }
+          ],
+          "isUnique": true,
+          "concurrently": false,
+          "method": "btree",
+          "with": {}
+        },
+        "idx_category": {
+          "name": "idx_category",
+          "columns": [
+            {
+              "expression": "category",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            }
+          ],
+          "isUnique": false,
+          "concurrently": false,
+          "method": "btree",
+          "with": {}
+        },
+        "idx_match_type": {
+          "name": "idx_match_type",
+          "columns": [
+            {
+              "expression": "match_type",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            }
+          ],
+          "isUnique": false,
+          "concurrently": false,
+          "method": "btree",
+          "with": {}
+        }
+      },
+      "foreignKeys": {},
+      "compositePrimaryKeys": {},
+      "uniqueConstraints": {},
+      "policies": {},
+      "checkConstraints": {},
+      "isRLSEnabled": false
+    },
+    "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
+        },
+        "can_login_web_ui": {
+          "name": "can_login_web_ui",
+          "type": "boolean",
+          "primaryKey": false,
+          "notNull": false,
+          "default": true
+        },
+        "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'"
+        },
+        "cost_multiplier": {
+          "name": "cost_multiplier",
+          "type": "numeric(10, 4)",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "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
+        },
+        "endpoint": {
+          "name": "endpoint",
+          "type": "varchar(256)",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "original_model": {
+          "name": "original_model",
+          "type": "varchar(128)",
+          "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
+        },
+        "blocked_by": {
+          "name": "blocked_by",
+          "type": "varchar(50)",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "blocked_reason": {
+          "name": "blocked_reason",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "user_agent": {
+          "name": "user_agent",
+          "type": "varchar(512)",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "messages_count": {
+          "name": "messages_count",
+          "type": "integer",
+          "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_endpoint": {
+          "name": "idx_message_request_endpoint",
+          "columns": [
+            {
+              "expression": "endpoint",
+              "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.notification_settings": {
+      "name": "notification_settings",
+      "schema": "",
+      "columns": {
+        "id": {
+          "name": "id",
+          "type": "serial",
+          "primaryKey": true,
+          "notNull": true
+        },
+        "enabled": {
+          "name": "enabled",
+          "type": "boolean",
+          "primaryKey": false,
+          "notNull": true,
+          "default": false
+        },
+        "circuit_breaker_enabled": {
+          "name": "circuit_breaker_enabled",
+          "type": "boolean",
+          "primaryKey": false,
+          "notNull": true,
+          "default": false
+        },
+        "circuit_breaker_webhook": {
+          "name": "circuit_breaker_webhook",
+          "type": "varchar(512)",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "daily_leaderboard_enabled": {
+          "name": "daily_leaderboard_enabled",
+          "type": "boolean",
+          "primaryKey": false,
+          "notNull": true,
+          "default": false
+        },
+        "daily_leaderboard_webhook": {
+          "name": "daily_leaderboard_webhook",
+          "type": "varchar(512)",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "daily_leaderboard_time": {
+          "name": "daily_leaderboard_time",
+          "type": "varchar(10)",
+          "primaryKey": false,
+          "notNull": false,
+          "default": "'09:00'"
+        },
+        "daily_leaderboard_top_n": {
+          "name": "daily_leaderboard_top_n",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": false,
+          "default": 5
+        },
+        "cost_alert_enabled": {
+          "name": "cost_alert_enabled",
+          "type": "boolean",
+          "primaryKey": false,
+          "notNull": true,
+          "default": false
+        },
+        "cost_alert_webhook": {
+          "name": "cost_alert_webhook",
+          "type": "varchar(512)",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "cost_alert_threshold": {
+          "name": "cost_alert_threshold",
+          "type": "numeric(5, 2)",
+          "primaryKey": false,
+          "notNull": false,
+          "default": "'0.80'"
+        },
+        "cost_alert_check_interval": {
+          "name": "cost_alert_check_interval",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": false,
+          "default": 60
+        },
+        "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.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
+        },
+        "allowed_models": {
+          "name": "allowed_models",
+          "type": "jsonb",
+          "primaryKey": false,
+          "notNull": false,
+          "default": "'null'::jsonb"
+        },
+        "join_claude_pool": {
+          "name": "join_claude_pool",
+          "type": "boolean",
+          "primaryKey": false,
+          "notNull": false,
+          "default": false
+        },
+        "codex_instructions_strategy": {
+          "name": "codex_instructions_strategy",
+          "type": "varchar(20)",
+          "primaryKey": false,
+          "notNull": false,
+          "default": "'auto'"
+        },
+        "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
+        },
+        "circuit_breaker_failure_threshold": {
+          "name": "circuit_breaker_failure_threshold",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": false,
+          "default": 5
+        },
+        "circuit_breaker_open_duration": {
+          "name": "circuit_breaker_open_duration",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": false,
+          "default": 1800000
+        },
+        "circuit_breaker_half_open_success_threshold": {
+          "name": "circuit_breaker_half_open_success_threshold",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": false,
+          "default": 2
+        },
+        "proxy_url": {
+          "name": "proxy_url",
+          "type": "varchar(512)",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "proxy_fallback_to_direct": {
+          "name": "proxy_fallback_to_direct",
+          "type": "boolean",
+          "primaryKey": false,
+          "notNull": false,
+          "default": false
+        },
+        "first_byte_timeout_streaming_ms": {
+          "name": "first_byte_timeout_streaming_ms",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "default": 30000
+        },
+        "streaming_idle_timeout_ms": {
+          "name": "streaming_idle_timeout_ms",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "default": 10000
+        },
+        "request_timeout_non_streaming_ms": {
+          "name": "request_timeout_non_streaming_ms",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "default": 600000
+        },
+        "website_url": {
+          "name": "website_url",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "favicon_url": {
+          "name": "favicon_url",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "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.sensitive_words": {
+      "name": "sensitive_words",
+      "schema": "",
+      "columns": {
+        "id": {
+          "name": "id",
+          "type": "serial",
+          "primaryKey": true,
+          "notNull": true
+        },
+        "word": {
+          "name": "word",
+          "type": "varchar(255)",
+          "primaryKey": false,
+          "notNull": true
+        },
+        "match_type": {
+          "name": "match_type",
+          "type": "varchar(20)",
+          "primaryKey": false,
+          "notNull": true,
+          "default": "'contains'"
+        },
+        "description": {
+          "name": "description",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "is_enabled": {
+          "name": "is_enabled",
+          "type": "boolean",
+          "primaryKey": false,
+          "notNull": true,
+          "default": 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_sensitive_words_enabled": {
+          "name": "idx_sensitive_words_enabled",
+          "columns": [
+            {
+              "expression": "is_enabled",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            },
+            {
+              "expression": "match_type",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            }
+          ],
+          "isUnique": false,
+          "concurrently": false,
+          "method": "btree",
+          "with": {}
+        },
+        "idx_sensitive_words_created_at": {
+          "name": "idx_sensitive_words_created_at",
+          "columns": [
+            {
+              "expression": "created_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
+        },
+        "currency_display": {
+          "name": "currency_display",
+          "type": "varchar(10)",
+          "primaryKey": false,
+          "notNull": true,
+          "default": "'USD'"
+        },
+        "enable_auto_cleanup": {
+          "name": "enable_auto_cleanup",
+          "type": "boolean",
+          "primaryKey": false,
+          "notNull": false,
+          "default": false
+        },
+        "cleanup_retention_days": {
+          "name": "cleanup_retention_days",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": false,
+          "default": 30
+        },
+        "cleanup_schedule": {
+          "name": "cleanup_schedule",
+          "type": "varchar(50)",
+          "primaryKey": false,
+          "notNull": false,
+          "default": "'0 2 * * *'"
+        },
+        "cleanup_batch_size": {
+          "name": "cleanup_batch_size",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": false,
+          "default": 10000
+        },
+        "enable_client_version_check": {
+          "name": "enable_client_version_check",
+          "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
+        },
+        "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
+        },
+        "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

@@ -141,6 +141,13 @@
       "when": 1763393401417,
       "tag": "0019_far_whirlwind",
       "breakpoints": true
+    },
+    {
+      "idx": 20,
+      "version": "7",
+      "when": 1763465177387,
+      "tag": "0020_next_juggernaut",
+      "breakpoints": true
     }
   ]
 }

+ 30 - 0
messages/en/dashboard.json

@@ -359,6 +359,7 @@
     "usageLogs": "Usage Logs",
     "leaderboard": "Leaderboard",
     "quotasManagement": "Quota Management",
+    "userManagement": "User Management",
     "documentation": "Documentation",
     "systemSettings": "System Settings",
     "feedback": "Feedback",
@@ -565,6 +566,26 @@
       "label": "Daily Quota",
       "placeholder": "Daily consumption quota limit",
       "description": "Default: ${default}, Range: $0.01-$1000"
+    },
+    "limit5hUsd": {
+      "label": "5-Hour Quota (USD)",
+      "placeholder": "Leave empty for unlimited",
+      "description": "Maximum spending in 5 hours"
+    },
+    "limitWeeklyUsd": {
+      "label": "Weekly Quota (USD)",
+      "placeholder": "Leave empty for unlimited",
+      "description": "Maximum weekly spending"
+    },
+    "limitMonthlyUsd": {
+      "label": "Monthly Quota (USD)",
+      "placeholder": "Leave empty for unlimited",
+      "description": "Maximum monthly spending"
+    },
+    "limitConcurrentSessions": {
+      "label": "Concurrent Sessions Limit",
+      "placeholder": "0 means unlimited",
+      "description": "Number of simultaneous conversations"
     }
   },
   "deleteKeyConfirm": {
@@ -589,5 +610,14 @@
     "delete": "Delete user",
     "editAriaLabel": "Edit user",
     "deleteAriaLabel": "Delete user"
+  },
+  "users": {
+    "title": "User Management",
+    "description": "Showing {count} users",
+    "toolbar": {
+      "searchPlaceholder": "Search by username...",
+      "groupFilter": "Filter by Group",
+      "allGroups": "All Groups"
+    }
   }
 }

+ 2 - 0
messages/en/quota.json

@@ -64,6 +64,8 @@
   "users": {
     "title": "User Quota Statistics",
     "totalCount": "{count} users total",
+    "manageNotice": "To manage users and keys, visit",
+    "manageLink": "User Management",
     "noNote": "No note",
     "rpm": {
       "label": "RPM Quota",

+ 1 - 0
messages/en/settings.json

@@ -349,6 +349,7 @@
     "config": "Configuration",
     "data": "Data Management",
     "errorRules": "Error Rules",
+    "feedback": "Feedback",
     "logs": "Logs",
     "notifications": "Notifications",
     "prices": "Pricing",

+ 21 - 0
messages/ja/dashboard.json

@@ -360,6 +360,7 @@
     "usageLogs": "使用ログ",
     "leaderboard": "ランキング",
     "quotasManagement": "クォータ管理",
+    "userManagement": "ユーザー管理",
     "documentation": "ドキュメント",
     "systemSettings": "システム設定",
     "feedback": "フィードバック",
@@ -566,6 +567,26 @@
       "label": "1日あたりの割当量",
       "placeholder": "1日あたりの消費割当量制限",
       "description": "デフォルト値: ${default}、範囲: $0.01-$1000"
+    },
+    "limit5hUsd": {
+      "label": "5時間制限 (USD)",
+      "placeholder": "空白の場合は無制限",
+      "description": "5時間以内の最大消費金額"
+    },
+    "limitWeeklyUsd": {
+      "label": "週間制限 (USD)",
+      "placeholder": "空白の場合は無制限",
+      "description": "週間最大消費金額"
+    },
+    "limitMonthlyUsd": {
+      "label": "月間制限 (USD)",
+      "placeholder": "空白の場合は無制限",
+      "description": "月間最大消費金額"
+    },
+    "limitConcurrentSessions": {
+      "label": "同時セッション上限",
+      "placeholder": "0は無制限を意味します",
+      "description": "同時に実行される会話の数"
     }
   },
   "deleteKeyConfirm": {

+ 2 - 0
messages/ja/quota.json

@@ -64,6 +64,8 @@
   "users": {
     "title": "ユーザークォータ統計",
     "totalCount": "合計 {count} 名のユーザー",
+    "manageNotice": "ユーザーとキーを管理するには",
+    "manageLink": "ユーザー管理",
     "noNote": "備考なし",
     "rpm": {
       "label": "RPM クォータ",

+ 1 - 0
messages/ja/settings.json

@@ -349,6 +349,7 @@
     "config": "設定",
     "data": "データ管理",
     "errorRules": "エラールール",
+    "feedback": "フィードバック",
     "logs": "ログ",
     "notifications": "通知",
     "prices": "価格表",

+ 21 - 0
messages/ru/dashboard.json

@@ -360,6 +360,7 @@
     "usageLogs": "Журналы использования",
     "leaderboard": "Таблица лидеров",
     "quotasManagement": "Управление квотами",
+    "userManagement": "Управление пользователями",
     "documentation": "Документация",
     "systemSettings": "Настройки системы",
     "feedback": "Обратная связь",
@@ -566,6 +567,26 @@
       "label": "Дневная квота",
       "placeholder": "Лимит дневного расхода",
       "description": "По умолчанию: ${default}, диапазон: $0.01-$1000"
+    },
+    "limit5hUsd": {
+      "label": "Лимит на 5 часов (USD)",
+      "placeholder": "Оставьте пустым для неограниченного",
+      "description": "Максимальный расход за 5 часов"
+    },
+    "limitWeeklyUsd": {
+      "label": "Недельный лимит (USD)",
+      "placeholder": "Оставьте пустым для неограниченного",
+      "description": "Максимальный недельный расход"
+    },
+    "limitMonthlyUsd": {
+      "label": "Месячный лимит (USD)",
+      "placeholder": "Оставьте пустым для неограниченного",
+      "description": "Максимальный месячный расход"
+    },
+    "limitConcurrentSessions": {
+      "label": "Лимит одновременных сессий",
+      "placeholder": "0 означает неограниченно",
+      "description": "Количество одновременных разговоров"
     }
   },
   "deleteKeyConfirm": {

+ 1 - 0
messages/ru/settings.json

@@ -349,6 +349,7 @@
     "config": "Конфигурация",
     "data": "Управление данными",
     "errorRules": "Правила ошибок",
+    "feedback": "Обратная связь",
     "logs": "Логи",
     "notifications": "Уведомления",
     "prices": "Прайс-лист",

+ 18 - 4
messages/zh-CN/dashboard.json

@@ -357,6 +357,7 @@
   "all": "全部",
   "nav": {
     "dashboard": "仪表盘",
+    "userManagement": "用户管理",
     "usageLogs": "使用记录",
     "leaderboard": "排行榜",
     "quotasManagement": "限额管理",
@@ -503,22 +504,26 @@
     "limit5hUsd": {
       "label": "5小时消费上限 (USD)",
       "placeholder": "留空表示无限制",
-      "description": "5小时内最大消费金额"
+      "description": "5小时内最大消费金额",
+      "descriptionWithUserLimit": "5小时内最大消费金额(用户限额: ${limit})"
     },
     "limitWeeklyUsd": {
       "label": "周消费上限 (USD)",
       "placeholder": "留空表示无限制",
-      "description": "每周最大消费金额"
+      "description": "每周最大消费金额",
+      "descriptionWithUserLimit": "每周最大消费金额(用户限额: ${limit})"
     },
     "limitMonthlyUsd": {
       "label": "月消费上限 (USD)",
       "placeholder": "留空表示无限制",
-      "description": "每月最大消费金额"
+      "description": "每月最大消费金额",
+      "descriptionWithUserLimit": "每月最大消费金额(用户限额: ${limit})"
     },
     "limitConcurrentSessions": {
       "label": "并发 Session 上限",
       "placeholder": "0 表示无限制",
-      "description": "同时运行的对话数量"
+      "description": "同时运行的对话数量",
+      "descriptionWithUserLimit": "同时运行的对话数量(用户限额: {limit})"
     },
     "errors": {
       "userIdMissing": "用户ID不存在",
@@ -655,5 +660,14 @@
       "loading": "加载中...",
       "noData": "暂无数据"
     }
+  },
+  "users": {
+    "title": "用户管理",
+    "description": "显示 {count} 个用户",
+    "toolbar": {
+      "searchPlaceholder": "按用户名搜索...",
+      "groupFilter": "按分组筛选",
+      "allGroups": "所有分组"
+    }
   }
 }

+ 10 - 4
messages/zh-CN/quota.json

@@ -64,6 +64,8 @@
   "users": {
     "title": "用户限额统计",
     "totalCount": "共 {count} 个用户",
+    "manageNotice": "管理用户和密钥,请访问",
+    "manageLink": "用户管理",
     "noNote": "无备注",
     "rpm": {
       "label": "RPM 限额",
@@ -221,22 +223,26 @@
       "limit5hUsd": {
         "label": "5小时消费上限 (USD)",
         "placeholder": "留空表示无限制",
-        "description": "5小时内最大消费金额"
+        "description": "5小时内最大消费金额",
+        "descriptionWithUserLimit": "5小时内最大消费金额(用户限额: ${limit})"
       },
       "limitWeeklyUsd": {
         "label": "周消费上限 (USD)",
         "placeholder": "留空表示无限制",
-        "description": "每周最大消费金额"
+        "description": "每周最大消费金额",
+        "descriptionWithUserLimit": "每周最大消费金额(用户限额: ${limit})"
       },
       "limitMonthlyUsd": {
         "label": "月消费上限 (USD)",
         "placeholder": "留空表示无限制",
-        "description": "每月最大消费金额"
+        "description": "每月最大消费金额",
+        "descriptionWithUserLimit": "每月最大消费金额(用户限额: ${limit})"
       },
       "limitConcurrentSessions": {
         "label": "并发 Session 上限",
         "placeholder": "0 表示无限制",
-        "description": "同时运行的对话数量"
+        "description": "同时运行的对话数量",
+        "descriptionWithUserLimit": "同时运行的对话数量(用户限额: {limit})"
       },
       "submitText": "保存修改",
       "loadingText": "保存中...",

+ 2 - 1
messages/zh-CN/settings.json

@@ -9,7 +9,8 @@
     "logs": "日志",
     "notifications": "消息推送",
     "apiDocs": "API 文档",
-    "errorRules": "错误规则"
+    "errorRules": "错误规则",
+    "feedback": "反馈问题"
   },
   "common": {
     "save": "保存",

+ 21 - 0
messages/zh-TW/dashboard.json

@@ -360,6 +360,7 @@
     "usageLogs": "使用記錄",
     "leaderboard": "排行榜",
     "quotasManagement": "額度管理",
+    "userManagement": "使用者管理",
     "documentation": "文件",
     "systemSettings": "系統設定",
     "feedback": "意見回饋",
@@ -566,6 +567,26 @@
       "label": "每日額度",
       "placeholder": "每日消費額度限制",
       "description": "預設值:${default},範圍:$0.01-$1000"
+    },
+    "limit5hUsd": {
+      "label": "5小時消費上限 (USD)",
+      "placeholder": "留空表示無限制",
+      "description": "5小時內最大消費金額"
+    },
+    "limitWeeklyUsd": {
+      "label": "週消費上限 (USD)",
+      "placeholder": "留空表示無限制",
+      "description": "每週最大消費金額"
+    },
+    "limitMonthlyUsd": {
+      "label": "月消費上限 (USD)",
+      "placeholder": "留空表示無限制",
+      "description": "每月最大消費金額"
+    },
+    "limitConcurrentSessions": {
+      "label": "並發 Session 上限",
+      "placeholder": "0 表示無限制",
+      "description": "同時執行的對話數量"
     }
   },
   "deleteKeyConfirm": {

+ 1 - 0
messages/zh-TW/settings.json

@@ -349,6 +349,7 @@
     "config": "設定",
     "data": "資料管理",
     "errorRules": "錯誤規則",
+    "feedback": "意見回饋",
     "logs": "日誌",
     "notifications": "訊息推送",
     "prices": "價格表",

+ 92 - 0
src/actions/keys.ts

@@ -55,6 +55,50 @@ export async function addKey(data: {
       };
     }
 
+    // 服务端验证:Key限额不能超过用户限额
+    const { findUserById } = await import("@/repository/user");
+    const user = await findUserById(data.userId);
+    if (!user) {
+      return { ok: false, error: "用户不存在" };
+    }
+
+    // 验证各个限额字段
+    if (data.limit5hUsd && user.limit5hUsd && data.limit5hUsd > user.limit5hUsd) {
+      return {
+        ok: false,
+        error: `Key的5小时消费上限(${data.limit5hUsd})不能超过用户限额(${user.limit5hUsd})`,
+      };
+    }
+
+    if (data.limitWeeklyUsd && user.limitWeeklyUsd && data.limitWeeklyUsd > user.limitWeeklyUsd) {
+      return {
+        ok: false,
+        error: `Key的周消费上限(${data.limitWeeklyUsd})不能超过用户限额(${user.limitWeeklyUsd})`,
+      };
+    }
+
+    if (
+      data.limitMonthlyUsd &&
+      user.limitMonthlyUsd &&
+      data.limitMonthlyUsd > user.limitMonthlyUsd
+    ) {
+      return {
+        ok: false,
+        error: `Key的月消费上限(${data.limitMonthlyUsd})不能超过用户限额(${user.limitMonthlyUsd})`,
+      };
+    }
+
+    if (
+      data.limitConcurrentSessions &&
+      user.limitConcurrentSessions &&
+      data.limitConcurrentSessions > user.limitConcurrentSessions
+    ) {
+      return {
+        ok: false,
+        error: `Key的并发Session上限(${data.limitConcurrentSessions})不能超过用户限额(${user.limitConcurrentSessions})`,
+      };
+    }
+
     const generatedKey = "sk-" + randomBytes(16).toString("hex");
 
     // 转换 expiresAt: undefined → null(永不过期),string → Date(设置日期)
@@ -116,6 +160,54 @@ export async function editKey(
 
     const validatedData = KeyFormSchema.parse(data);
 
+    // 服务端验证:Key限额不能超过用户限额
+    const { findUserById } = await import("@/repository/user");
+    const user = await findUserById(key.userId);
+    if (!user) {
+      return { ok: false, error: "用户不存在" };
+    }
+
+    // 验证各个限额字段
+    if (validatedData.limit5hUsd && user.limit5hUsd && validatedData.limit5hUsd > user.limit5hUsd) {
+      return {
+        ok: false,
+        error: `Key的5小时消费上限(${validatedData.limit5hUsd})不能超过用户限额(${user.limit5hUsd})`,
+      };
+    }
+
+    if (
+      validatedData.limitWeeklyUsd &&
+      user.limitWeeklyUsd &&
+      validatedData.limitWeeklyUsd > user.limitWeeklyUsd
+    ) {
+      return {
+        ok: false,
+        error: `Key的周消费上限(${validatedData.limitWeeklyUsd})不能超过用户限额(${user.limitWeeklyUsd})`,
+      };
+    }
+
+    if (
+      validatedData.limitMonthlyUsd &&
+      user.limitMonthlyUsd &&
+      validatedData.limitMonthlyUsd > user.limitMonthlyUsd
+    ) {
+      return {
+        ok: false,
+        error: `Key的月消费上限(${validatedData.limitMonthlyUsd})不能超过用户限额(${user.limitMonthlyUsd})`,
+      };
+    }
+
+    if (
+      validatedData.limitConcurrentSessions &&
+      user.limitConcurrentSessions &&
+      validatedData.limitConcurrentSessions > user.limitConcurrentSessions
+    ) {
+      return {
+        ok: false,
+        error: `Key的并发Session上限(${validatedData.limitConcurrentSessions})不能超过用户限额(${user.limitConcurrentSessions})`,
+      };
+    }
+
     // 转换 expiresAt: undefined → null(清除日期),string → Date(设置日期)
     const expiresAt =
       validatedData.expiresAt === undefined ? null : new Date(validatedData.expiresAt);

+ 64 - 58
src/actions/users.ts

@@ -15,6 +15,7 @@ import type { ActionResult } from "./types";
 import { ERROR_CODES } from "@/lib/utils/error-messages";
 import { formatZodError } from "@/lib/utils/zod-i18n";
 import { getTranslations, getLocale } from "next-intl/server";
+import { getUnauthorizedFields } from "@/lib/permissions/user-field-permissions";
 
 // 获取用户数据
 export async function getUsers(): Promise<UserDisplay[]> {
@@ -64,6 +65,10 @@ export async function getUsers(): Promise<UserDisplay[]> {
             rpm: user.rpm,
             dailyQuota: user.dailyQuota,
             providerGroup: user.providerGroup || undefined,
+            limit5hUsd: user.limit5hUsd ?? null,
+            limitWeeklyUsd: user.limitWeeklyUsd ?? null,
+            limitMonthlyUsd: user.limitMonthlyUsd ?? null,
+            limitConcurrentSessions: user.limitConcurrentSessions ?? null,
             keys: keys.map((key) => {
               const stats = statisticsMap.get(key.id);
               // 用户可以查看和复制自己的密钥,管理员可以查看和复制所有密钥
@@ -112,6 +117,10 @@ export async function getUsers(): Promise<UserDisplay[]> {
             rpm: user.rpm,
             dailyQuota: user.dailyQuota,
             providerGroup: user.providerGroup || undefined,
+            limit5hUsd: user.limit5hUsd ?? null,
+            limitWeeklyUsd: user.limitWeeklyUsd ?? null,
+            limitMonthlyUsd: user.limitMonthlyUsd ?? null,
+            limitConcurrentSessions: user.limitConcurrentSessions ?? null,
             keys: [],
           };
         }
@@ -132,6 +141,10 @@ export async function addUser(data: {
   providerGroup?: string | null;
   rpm?: number;
   dailyQuota?: number;
+  limit5hUsd?: number | null;
+  limitWeeklyUsd?: number | null;
+  limitMonthlyUsd?: number | null;
+  limitConcurrentSessions?: number | null;
 }): Promise<ActionResult> {
   try {
     // Get translations for error messages
@@ -154,6 +167,10 @@ export async function addUser(data: {
       providerGroup: data.providerGroup || "",
       rpm: data.rpm || USER_DEFAULTS.RPM,
       dailyQuota: data.dailyQuota || USER_DEFAULTS.DAILY_QUOTA,
+      limit5hUsd: data.limit5hUsd,
+      limitWeeklyUsd: data.limitWeeklyUsd,
+      limitMonthlyUsd: data.limitMonthlyUsd,
+      limitConcurrentSessions: data.limitConcurrentSessions,
     });
 
     if (!validationResult.success) {
@@ -172,6 +189,10 @@ export async function addUser(data: {
       providerGroup: validatedData.providerGroup || null,
       rpm: validatedData.rpm,
       dailyQuota: validatedData.dailyQuota,
+      limit5hUsd: validatedData.limit5hUsd ?? undefined,
+      limitWeeklyUsd: validatedData.limitWeeklyUsd ?? undefined,
+      limitMonthlyUsd: validatedData.limitMonthlyUsd ?? undefined,
+      limitConcurrentSessions: validatedData.limitConcurrentSessions ?? undefined,
     });
 
     // 为新用户创建默认密钥
@@ -207,6 +228,10 @@ export async function editUser(
     providerGroup?: string | null;
     rpm?: number;
     dailyQuota?: number;
+    limit5hUsd?: number | null;
+    limitWeeklyUsd?: number | null;
+    limitMonthlyUsd?: number | null;
+    limitConcurrentSessions?: number | null;
   }
 ): Promise<ActionResult> {
   try {
@@ -222,64 +247,32 @@ export async function editUser(
       };
     }
 
-    // 定义敏感字段列表(仅管理员可修改)
-    const sensitiveFields = ["rpm", "dailyQuota", "providerGroup"] as const;
-    const hasSensitiveFields = sensitiveFields.some((field) => data[field] !== undefined);
-
-    // 权限检查:区分三种情况
-    if (session.user.role === "admin") {
-      // 管理员可以修改所有用户的所有字段
-      const validationResult = UpdateUserSchema.safeParse(data);
-
-      if (!validationResult.success) {
-        return {
-          ok: false,
-          error: formatZodError(validationResult.error),
-          errorCode: ERROR_CODES.INVALID_FORMAT,
-        };
-      }
-
-      const validatedData = validationResult.data;
-
-      await updateUser(userId, {
-        name: validatedData.name,
-        description: validatedData.note,
-        providerGroup: validatedData.providerGroup,
-        rpm: validatedData.rpm,
-        dailyQuota: validatedData.dailyQuota,
-      });
-    } else if (session.user.id === userId) {
-      // 普通用户修改自己的信息
-      if (hasSensitiveFields) {
-        return {
-          ok: false,
-          error: tError("USER_CANNOT_MODIFY_SENSITIVE_FIELDS"),
-          errorCode: ERROR_CODES.PERMISSION_DENIED,
-        };
-      }
-
-      // 仅允许修改非敏感字段(name, description)
-      const validationResult = UpdateUserSchema.safeParse({
-        name: data.name,
-        note: data.note,
-      });
-
-      if (!validationResult.success) {
-        return {
-          ok: false,
-          error: formatZodError(validationResult.error),
-          errorCode: ERROR_CODES.INVALID_FORMAT,
-        };
-      }
-
-      const validatedData = validationResult.data;
-
-      await updateUser(userId, {
-        name: validatedData.name,
-        description: validatedData.note,
-      });
-    } else {
-      // 普通用户尝试修改他人信息
+    // Validate data with Zod first
+    const validationResult = UpdateUserSchema.safeParse(data);
+
+    if (!validationResult.success) {
+      return {
+        ok: false,
+        error: formatZodError(validationResult.error),
+        errorCode: ERROR_CODES.INVALID_FORMAT,
+      };
+    }
+
+    const validatedData = validationResult.data;
+
+    // Permission check: Get unauthorized fields based on user role
+    const unauthorizedFields = getUnauthorizedFields(validatedData, session.user.role);
+
+    if (unauthorizedFields.length > 0) {
+      return {
+        ok: false,
+        error: tError("PERMISSION_DENIED") + `: ${unauthorizedFields.join(", ")}`,
+        errorCode: ERROR_CODES.PERMISSION_DENIED,
+      };
+    }
+
+    // Additional check: Non-admin users can only modify their own data
+    if (session.user.role !== "admin" && session.user.id !== userId) {
       return {
         ok: false,
         error: tError("PERMISSION_DENIED"),
@@ -287,6 +280,19 @@ export async function editUser(
       };
     }
 
+    // Update user with validated data
+    await updateUser(userId, {
+      name: validatedData.name,
+      description: validatedData.note,
+      providerGroup: validatedData.providerGroup,
+      rpm: validatedData.rpm,
+      dailyQuota: validatedData.dailyQuota,
+      limit5hUsd: validatedData.limit5hUsd ?? undefined,
+      limitWeeklyUsd: validatedData.limitWeeklyUsd ?? undefined,
+      limitMonthlyUsd: validatedData.limitMonthlyUsd ?? undefined,
+      limitConcurrentSessions: validatedData.limitConcurrentSessions ?? undefined,
+    });
+
     revalidatePath("/dashboard");
     return { ok: true };
   } catch (error) {

+ 1 - 5
src/app/[locale]/dashboard/_components/dashboard-header.tsx

@@ -21,13 +21,9 @@ export function DashboardHeader({ session }: DashboardHeaderProps) {
     { href: "/dashboard/logs", label: t("usageLogs") },
     { href: "/dashboard/leaderboard", label: t("leaderboard") },
     { href: "/dashboard/quotas", label: t("quotasManagement") },
+    { href: "/dashboard/users", label: t("userManagement") },
     { href: "/usage-doc", label: t("documentation") },
     { href: "/settings", label: t("systemSettings"), adminOnly: true },
-    {
-      href: "https://github.com/ding113/claude-code-hub/issues",
-      label: t("feedback"),
-      external: true,
-    },
   ];
 
   const items = NAV_ITEMS.filter((item) => !item.adminOnly || isAdmin);

+ 5 - 1
src/app/[locale]/dashboard/_components/user/add-user-dialog.tsx

@@ -13,12 +13,16 @@ interface AddUserDialogProps {
   variant?: ButtonProps["variant"];
   size?: ButtonProps["size"];
   className?: string;
+  currentUser?: {
+    role: string;
+  };
 }
 
 export function AddUserDialog({
   variant = "default",
   size = "default",
   className,
+  currentUser,
 }: AddUserDialogProps) {
   const [open, setOpen] = useState(false);
   const t = useTranslations("dashboard.userList");
@@ -32,7 +36,7 @@ export function AddUserDialog({
       </DialogTrigger>
       <DialogContent>
         <FormErrorBoundary>
-          <UserForm onSuccess={() => setOpen(false)} />
+          <UserForm onSuccess={() => setOpen(false)} currentUser={currentUser} />
         </FormErrorBoundary>
       </DialogContent>
     </Dialog>

+ 25 - 5
src/app/[locale]/dashboard/_components/user/forms/add-key-form.tsx

@@ -10,13 +10,15 @@ import { Label } from "@/components/ui/label";
 import { Switch } from "@/components/ui/switch";
 import { useZodForm } from "@/lib/hooks/use-zod-form";
 import { KeyFormSchema } from "@/lib/validation/schemas";
+import type { User } from "@/types/user";
 
 interface AddKeyFormProps {
   userId?: number;
+  user?: User;
   onSuccess?: (result: { generatedKey: string; name: string }) => void;
 }
 
-export function AddKeyForm({ userId, onSuccess }: AddKeyFormProps) {
+export function AddKeyForm({ userId, user, onSuccess }: AddKeyFormProps) {
   const [isPending, startTransition] = useTransition();
   const router = useRouter();
   const t = useTranslations("dashboard.addKeyForm");
@@ -119,7 +121,11 @@ export function AddKeyForm({ userId, onSuccess }: AddKeyFormProps) {
       <NumberField
         label={t("limit5hUsd.label")}
         placeholder={t("limit5hUsd.placeholder")}
-        description={t("limit5hUsd.description")}
+        description={
+          user?.limit5hUsd
+            ? t("limit5hUsd.descriptionWithUserLimit", { limit: user.limit5hUsd })
+            : t("limit5hUsd.description")
+        }
         min={0}
         step={0.01}
         {...form.getFieldProps("limit5hUsd")}
@@ -128,7 +134,11 @@ export function AddKeyForm({ userId, onSuccess }: AddKeyFormProps) {
       <NumberField
         label={t("limitWeeklyUsd.label")}
         placeholder={t("limitWeeklyUsd.placeholder")}
-        description={t("limitWeeklyUsd.description")}
+        description={
+          user?.limitWeeklyUsd
+            ? t("limitWeeklyUsd.descriptionWithUserLimit", { limit: user.limitWeeklyUsd })
+            : t("limitWeeklyUsd.description")
+        }
         min={0}
         step={0.01}
         {...form.getFieldProps("limitWeeklyUsd")}
@@ -137,7 +147,11 @@ export function AddKeyForm({ userId, onSuccess }: AddKeyFormProps) {
       <NumberField
         label={t("limitMonthlyUsd.label")}
         placeholder={t("limitMonthlyUsd.placeholder")}
-        description={t("limitMonthlyUsd.description")}
+        description={
+          user?.limitMonthlyUsd
+            ? t("limitMonthlyUsd.descriptionWithUserLimit", { limit: user.limitMonthlyUsd })
+            : t("limitMonthlyUsd.description")
+        }
         min={0}
         step={0.01}
         {...form.getFieldProps("limitMonthlyUsd")}
@@ -146,7 +160,13 @@ export function AddKeyForm({ userId, onSuccess }: AddKeyFormProps) {
       <NumberField
         label={t("limitConcurrentSessions.label")}
         placeholder={t("limitConcurrentSessions.placeholder")}
-        description={t("limitConcurrentSessions.description")}
+        description={
+          user?.limitConcurrentSessions
+            ? t("limitConcurrentSessions.descriptionWithUserLimit", {
+                limit: user.limitConcurrentSessions,
+              })
+            : t("limitConcurrentSessions.description")
+        }
         min={0}
         step={1}
         {...form.getFieldProps("limitConcurrentSessions")}

+ 25 - 5
src/app/[locale]/dashboard/_components/user/forms/edit-key-form.tsx

@@ -10,6 +10,7 @@ import { useZodForm } from "@/lib/hooks/use-zod-form";
 import { KeyFormSchema } from "@/lib/validation/schemas";
 import { toast } from "sonner";
 import { useTranslations } from "next-intl";
+import type { User } from "@/types/user";
 
 interface EditKeyFormProps {
   keyData?: {
@@ -22,10 +23,11 @@ interface EditKeyFormProps {
     limitMonthlyUsd?: number | null;
     limitConcurrentSessions?: number;
   };
+  user?: User;
   onSuccess?: () => void;
 }
 
-export function EditKeyForm({ keyData, onSuccess }: EditKeyFormProps) {
+export function EditKeyForm({ keyData, user, onSuccess }: EditKeyFormProps) {
   const [isPending, startTransition] = useTransition();
   const router = useRouter();
   const t = useTranslations("quota.keys.editKeyForm");
@@ -127,7 +129,11 @@ export function EditKeyForm({ keyData, onSuccess }: EditKeyFormProps) {
       <NumberField
         label={t("limit5hUsd.label")}
         placeholder={t("limit5hUsd.placeholder")}
-        description={t("limit5hUsd.description")}
+        description={
+          user?.limit5hUsd
+            ? t("limit5hUsd.descriptionWithUserLimit", { limit: user.limit5hUsd })
+            : t("limit5hUsd.description")
+        }
         min={0}
         step={0.01}
         {...form.getFieldProps("limit5hUsd")}
@@ -136,7 +142,11 @@ export function EditKeyForm({ keyData, onSuccess }: EditKeyFormProps) {
       <NumberField
         label={t("limitWeeklyUsd.label")}
         placeholder={t("limitWeeklyUsd.placeholder")}
-        description={t("limitWeeklyUsd.description")}
+        description={
+          user?.limitWeeklyUsd
+            ? t("limitWeeklyUsd.descriptionWithUserLimit", { limit: user.limitWeeklyUsd })
+            : t("limitWeeklyUsd.description")
+        }
         min={0}
         step={0.01}
         {...form.getFieldProps("limitWeeklyUsd")}
@@ -145,7 +155,11 @@ export function EditKeyForm({ keyData, onSuccess }: EditKeyFormProps) {
       <NumberField
         label={t("limitMonthlyUsd.label")}
         placeholder={t("limitMonthlyUsd.placeholder")}
-        description={t("limitMonthlyUsd.description")}
+        description={
+          user?.limitMonthlyUsd
+            ? t("limitMonthlyUsd.descriptionWithUserLimit", { limit: user.limitMonthlyUsd })
+            : t("limitMonthlyUsd.description")
+        }
         min={0}
         step={0.01}
         {...form.getFieldProps("limitMonthlyUsd")}
@@ -154,7 +168,13 @@ export function EditKeyForm({ keyData, onSuccess }: EditKeyFormProps) {
       <NumberField
         label={t("limitConcurrentSessions.label")}
         placeholder={t("limitConcurrentSessions.placeholder")}
-        description={t("limitConcurrentSessions.description")}
+        description={
+          user?.limitConcurrentSessions
+            ? t("limitConcurrentSessions.descriptionWithUserLimit", {
+                limit: user.limitConcurrentSessions,
+              })
+            : t("limitConcurrentSessions.description")
+        }
         min={0}
         step={1}
         {...form.getFieldProps("limitConcurrentSessions")}

+ 74 - 2
src/app/[locale]/dashboard/_components/user/forms/user-form.tsx

@@ -20,14 +20,22 @@ interface UserFormProps {
     rpm: number;
     dailyQuota: number;
     providerGroup?: string | null;
+    limit5hUsd?: number | null;
+    limitWeeklyUsd?: number | null;
+    limitMonthlyUsd?: number | null;
+    limitConcurrentSessions?: number | null;
   };
   onSuccess?: () => void;
+  currentUser?: {
+    role: string;
+  };
 }
 
-export function UserForm({ user, onSuccess }: UserFormProps) {
+export function UserForm({ user, onSuccess, currentUser }: UserFormProps) {
   const [isPending, startTransition] = useTransition();
   const router = useRouter();
   const isEdit = Boolean(user?.id);
+  const isAdmin = currentUser?.role === "admin";
 
   // i18n translations
   const tErrors = useTranslations("errors");
@@ -47,6 +55,10 @@ export function UserForm({ user, onSuccess }: UserFormProps) {
       rpm: user?.rpm || USER_DEFAULTS.RPM,
       dailyQuota: user?.dailyQuota || USER_DEFAULTS.DAILY_QUOTA,
       providerGroup: user?.providerGroup || "",
+      limit5hUsd: user?.limit5hUsd ?? null,
+      limitWeeklyUsd: user?.limitWeeklyUsd ?? null,
+      limitMonthlyUsd: user?.limitMonthlyUsd ?? null,
+      limitConcurrentSessions: user?.limitConcurrentSessions ?? null,
     },
     onSubmit: async (data) => {
       startTransition(async () => {
@@ -59,6 +71,10 @@ export function UserForm({ user, onSuccess }: UserFormProps) {
               rpm: data.rpm,
               dailyQuota: data.dailyQuota,
               providerGroup: data.providerGroup || null,
+              limit5hUsd: data.limit5hUsd,
+              limitWeeklyUsd: data.limitWeeklyUsd,
+              limitMonthlyUsd: data.limitMonthlyUsd,
+              limitConcurrentSessions: data.limitConcurrentSessions,
             });
           } else {
             res = await addUser({
@@ -67,6 +83,10 @@ export function UserForm({ user, onSuccess }: UserFormProps) {
               rpm: data.rpm,
               dailyQuota: data.dailyQuota,
               providerGroup: data.providerGroup || null,
+              limit5hUsd: data.limit5hUsd,
+              limitWeeklyUsd: data.limitWeeklyUsd,
+              limitMonthlyUsd: data.limitMonthlyUsd,
+              limitConcurrentSessions: data.limitConcurrentSessions,
             });
           }
 
@@ -139,7 +159,10 @@ export function UserForm({ user, onSuccess }: UserFormProps) {
           };
           toast.error(messages[reason] || reason);
         }}
-        {...form.getFieldProps("providerGroup")}
+        value={String(form.getFieldProps("providerGroup").value)}
+        onChange={form.getFieldProps("providerGroup").onChange}
+        error={form.getFieldProps("providerGroup").error}
+        touched={form.getFieldProps("providerGroup").touched}
       />
 
       <TextField
@@ -164,6 +187,55 @@ export function UserForm({ user, onSuccess }: UserFormProps) {
         description={tForm("dailyQuota.description", { default: USER_DEFAULTS.DAILY_QUOTA })}
         {...form.getFieldProps("dailyQuota")}
       />
+
+      {/* Admin-only quota fields */}
+      {isAdmin && (
+        <>
+          <TextField
+            label={tForm("limit5hUsd.label")}
+            type="number"
+            min={0}
+            max={10000}
+            step={0.01}
+            placeholder={tForm("limit5hUsd.placeholder")}
+            description={tForm("limit5hUsd.description")}
+            {...form.getFieldProps("limit5hUsd")}
+          />
+
+          <TextField
+            label={tForm("limitWeeklyUsd.label")}
+            type="number"
+            min={0}
+            max={50000}
+            step={0.01}
+            placeholder={tForm("limitWeeklyUsd.placeholder")}
+            description={tForm("limitWeeklyUsd.description")}
+            {...form.getFieldProps("limitWeeklyUsd")}
+          />
+
+          <TextField
+            label={tForm("limitMonthlyUsd.label")}
+            type="number"
+            min={0}
+            max={200000}
+            step={0.01}
+            placeholder={tForm("limitMonthlyUsd.placeholder")}
+            description={tForm("limitMonthlyUsd.description")}
+            {...form.getFieldProps("limitMonthlyUsd")}
+          />
+
+          <TextField
+            label={tForm("limitConcurrentSessions.label")}
+            type="number"
+            min={0}
+            max={1000}
+            step={1}
+            placeholder={tForm("limitConcurrentSessions.placeholder")}
+            description={tForm("limitConcurrentSessions.description")}
+            {...form.getFieldProps("limitConcurrentSessions")}
+          />
+        </>
+      )}
     </DialogFormLayout>
   );
 }

+ 13 - 2
src/app/[locale]/dashboard/_components/user/key-actions.tsx

@@ -13,10 +13,17 @@ interface KeyActionsProps {
   keyData: UserKeyDisplay;
   currentUser?: User;
   keyOwnerUserId: number; // 这个Key所属的用户ID
+  keyOwnerUser?: User; // 这个Key所属的用户对象(用于显示限额提示)
   canDelete: boolean;
 }
 
-export function KeyActions({ keyData, currentUser, keyOwnerUserId, canDelete }: KeyActionsProps) {
+export function KeyActions({
+  keyData,
+  currentUser,
+  keyOwnerUserId,
+  keyOwnerUser,
+  canDelete,
+}: KeyActionsProps) {
   const [openEdit, setOpenEdit] = useState(false);
   const [openDelete, setOpenDelete] = useState(false);
   const t = useTranslations("dashboard.keyActions");
@@ -46,7 +53,11 @@ export function KeyActions({ keyData, currentUser, keyOwnerUserId, canDelete }:
         </DialogTrigger>
         <DialogContent>
           <FormErrorBoundary>
-            <EditKeyForm keyData={keyData} onSuccess={() => setOpenEdit(false)} />
+            <EditKeyForm
+              keyData={keyData}
+              user={keyOwnerUser}
+              onSuccess={() => setOpenEdit(false)}
+            />
           </FormErrorBoundary>
         </DialogContent>
       </Dialog>

+ 23 - 1
src/app/[locale]/dashboard/_components/user/key-list-header.tsx

@@ -241,7 +241,29 @@ export function KeyListHeader({
             </DialogTrigger>
             <DialogContent>
               <FormErrorBoundary>
-                <AddKeyForm userId={activeUser?.id} onSuccess={handleKeyCreated} />
+                <AddKeyForm
+                  userId={activeUser?.id}
+                  user={
+                    activeUser
+                      ? {
+                          id: activeUser.id,
+                          name: activeUser.name,
+                          description: activeUser.note || "",
+                          role: activeUser.role,
+                          rpm: activeUser.rpm,
+                          dailyQuota: activeUser.dailyQuota,
+                          providerGroup: activeUser.providerGroup || null,
+                          createdAt: new Date(),
+                          updatedAt: new Date(),
+                          limit5hUsd: activeUser.limit5hUsd ?? undefined,
+                          limitWeeklyUsd: activeUser.limitWeeklyUsd ?? undefined,
+                          limitMonthlyUsd: activeUser.limitMonthlyUsd ?? undefined,
+                          limitConcurrentSessions: activeUser.limitConcurrentSessions ?? undefined,
+                        }
+                      : undefined
+                  }
+                  onSuccess={handleKeyCreated}
+                />
               </FormErrorBoundary>
             </DialogContent>
           </Dialog>

+ 9 - 1
src/app/[locale]/dashboard/_components/user/key-list.tsx

@@ -26,10 +26,17 @@ interface KeyListProps {
   keys: UserKeyDisplay[];
   currentUser?: User;
   keyOwnerUserId: number; // 这些Key所属的用户ID
+  keyOwnerUser?: User; // 这些Key所属的用户对象(用于显示限额提示)
   currencyCode?: CurrencyCode;
 }
 
-export function KeyList({ keys, currentUser, keyOwnerUserId, currencyCode = "USD" }: KeyListProps) {
+export function KeyList({
+  keys,
+  currentUser,
+  keyOwnerUserId,
+  keyOwnerUser,
+  currencyCode = "USD",
+}: KeyListProps) {
   const t = useTranslations("dashboard.keyList");
   const [copiedKeyId, setCopiedKeyId] = useState<number | null>(null);
   const [expandedKeys, setExpandedKeys] = useState<Set<number>>(new Set());
@@ -255,6 +262,7 @@ export function KeyList({ keys, currentUser, keyOwnerUserId, currencyCode = "USD
           keyData={record}
           currentUser={currentUser}
           keyOwnerUserId={keyOwnerUserId}
+          keyOwnerUser={keyOwnerUser}
           canDelete={canDeleteKeys}
         />
       </div>

+ 1 - 1
src/app/[locale]/dashboard/_components/user/user-actions.tsx

@@ -42,7 +42,7 @@ export function UserActions({ user, currentUser }: UserActionsProps) {
         </DialogTrigger>
         <DialogContent>
           <FormErrorBoundary>
-            <UserForm user={user} onSuccess={() => setOpenEdit(false)} />
+            <UserForm user={user} onSuccess={() => setOpenEdit(false)} currentUser={currentUser} />
           </FormErrorBoundary>
         </DialogContent>
       </Dialog>

+ 38 - 0
src/app/[locale]/dashboard/_components/user/user-key-manager.tsx

@@ -41,6 +41,25 @@ export function UserKeyManager({ users, currentUser, currencyCode = "USD" }: Use
             keys={activeUser?.keys || []}
             currentUser={currentUser}
             keyOwnerUserId={activeUser?.id || 0}
+            keyOwnerUser={
+              activeUser
+                ? {
+                    id: activeUser.id,
+                    name: activeUser.name,
+                    description: activeUser.note || "",
+                    role: activeUser.role,
+                    rpm: activeUser.rpm,
+                    dailyQuota: activeUser.dailyQuota,
+                    providerGroup: activeUser.providerGroup || null,
+                    createdAt: new Date(),
+                    updatedAt: new Date(),
+                    limit5hUsd: activeUser.limit5hUsd ?? undefined,
+                    limitWeeklyUsd: activeUser.limitWeeklyUsd ?? undefined,
+                    limitMonthlyUsd: activeUser.limitMonthlyUsd ?? undefined,
+                    limitConcurrentSessions: activeUser.limitConcurrentSessions ?? undefined,
+                  }
+                : undefined
+            }
             currencyCode={currencyCode}
           />
         </div>
@@ -72,6 +91,25 @@ export function UserKeyManager({ users, currentUser, currencyCode = "USD" }: Use
             keys={activeUser?.keys || []}
             currentUser={currentUser}
             keyOwnerUserId={activeUser?.id || 0}
+            keyOwnerUser={
+              activeUser
+                ? {
+                    id: activeUser.id,
+                    name: activeUser.name,
+                    description: activeUser.note || "",
+                    role: activeUser.role,
+                    rpm: activeUser.rpm,
+                    dailyQuota: activeUser.dailyQuota,
+                    providerGroup: activeUser.providerGroup || null,
+                    createdAt: new Date(),
+                    updatedAt: new Date(),
+                    limit5hUsd: activeUser.limit5hUsd ?? undefined,
+                    limitWeeklyUsd: activeUser.limitWeeklyUsd ?? undefined,
+                    limitMonthlyUsd: activeUser.limitMonthlyUsd ?? undefined,
+                    limitConcurrentSessions: activeUser.limitConcurrentSessions ?? undefined,
+                  }
+                : undefined
+            }
             currencyCode={currencyCode}
           />
         </div>

+ 3 - 1
src/app/[locale]/dashboard/_components/user/user-list.tsx

@@ -58,7 +58,9 @@ export function UserList({ users, activeUserId, onUserSelect, currentUser }: Use
       </ListContainer>
 
       {/* 新增用户按钮:列表下方、与列表同宽,中性配色 - 仅管理员可见 */}
-      {currentUser?.role === "admin" && <AddUserDialog variant="secondary" className="w-full" />}
+      {currentUser?.role === "admin" && (
+        <AddUserDialog variant="secondary" className="w-full" currentUser={currentUser} />
+      )}
     </div>
   );
 }

+ 6 - 15
src/app/[locale]/dashboard/page.tsx

@@ -1,12 +1,8 @@
 import { redirect } from "@/i18n/routing";
 import { getSession } from "@/lib/auth";
-import { Section } from "@/components/section";
-import { UserKeyManager } from "./_components/user/user-key-manager";
-import { getUsers } from "@/actions/users";
 import { getUserStatistics } from "@/actions/statistics";
 import { hasPriceTable } from "@/actions/model-prices";
 import { getSystemSettings } from "@/repository/system-config";
-import { ListErrorBoundary } from "@/components/error-boundary";
 import { StatisticsWrapper } from "./_components/statistics";
 import { OverviewPanel } from "@/components/customs/overview-panel";
 import { DEFAULT_TIME_RANGE } from "@/types/statistics";
@@ -26,8 +22,7 @@ export default async function DashboardPage({ params }: { params: Promise<{ loca
     return redirect({ href: "/settings/prices?required=true", locale });
   }
 
-  const [users, session, statistics, systemSettings] = await Promise.all([
-    getUsers(),
+  const [session, statistics, systemSettings] = await Promise.all([
     getSession(),
     getUserStatistics(DEFAULT_TIME_RANGE),
     getSystemSettings(),
@@ -47,15 +42,11 @@ export default async function DashboardPage({ params }: { params: Promise<{ loca
         />
       </div>
 
-      <Section title={t("title.clients")} description={t("title.userAndKeyManagement")}>
-        <ListErrorBoundary>
-          <UserKeyManager
-            users={users}
-            currentUser={session?.user}
-            currencyCode={systemSettings.currencyDisplay}
-          />
-        </ListErrorBoundary>
-      </Section>
+      {/* UserKeyManager removed - functionality moved to /dashboard/users */}
+      <div className="space-y-6">
+        <h2 className="text-2xl font-bold">{t("overview")}</h2>
+        {/* Statistics and overview cards are now the primary dashboard content */}
+      </div>
     </div>
   );
 }

+ 5 - 61
src/app/[locale]/dashboard/quotas/keys/page.tsx

@@ -1,65 +1,9 @@
-import { getUsers, getUserLimitUsage } from "@/actions/users";
-import { getKeyLimitUsage } from "@/actions/keys";
-import { KeysQuotaManager } from "./_components/keys-quota-manager";
-import { getSystemSettings } from "@/repository/system-config";
-import { getTranslations } from "next-intl/server";
+// This page has been deprecated. Key-level quotas are now managed at user level.
+// Users should visit /dashboard/quotas/users instead.
+// Redirecting to user quotas page...
 
-async function getUsersWithKeysQuotas() {
-  const users = await getUsers();
-
-  const usersWithKeysQuotas = await Promise.all(
-    users.map(async (user) => {
-      // 获取密钥限额数据
-      const keysWithQuotas = await Promise.all(
-        user.keys.map(async (key) => {
-          const result = await getKeyLimitUsage(key.id);
-          return {
-            id: key.id,
-            name: key.name,
-            isEnabled: key.status === "enabled", // 转换 status 为 isEnabled
-            expiresAt: key.expiresAt,
-            quota: result.ok ? result.data : null,
-          };
-        })
-      );
-
-      // 获取用户限额数据
-      const userQuotaResult = await getUserLimitUsage(user.id);
-      const userQuota = userQuotaResult.ok ? userQuotaResult.data : null;
-
-      return {
-        id: user.id,
-        name: user.name,
-        role: user.role,
-        userQuota, // 新增:用户限额数据
-        keys: keysWithQuotas,
-      };
-    })
-  );
-
-  return usersWithKeysQuotas;
-}
+import { redirect } from "next/navigation";
 
 export default async function KeysQuotaPage() {
-  const [users, systemSettings] = await Promise.all([
-    getUsersWithKeysQuotas(),
-    getSystemSettings(),
-  ]);
-  const totalKeys = users.reduce((sum, user) => sum + user.keys.length, 0);
-  const t = await getTranslations("quota.keys");
-
-  return (
-    <div className="space-y-4">
-      <div className="flex items-center justify-between">
-        <div>
-          <h3 className="text-lg font-medium">{t("title")}</h3>
-          <p className="text-sm text-muted-foreground">
-            {t("totalCount", { users: users.length, keys: totalKeys })}
-          </p>
-        </div>
-      </div>
-
-      <KeysQuotaManager users={users} currencyCode={systemSettings.currencyDisplay} />
-    </div>
-  );
+  redirect("/dashboard/quotas/users");
 }

+ 13 - 0
src/app/[locale]/dashboard/quotas/users/page.tsx

@@ -4,6 +4,9 @@ import { QuotaToolbar } from "@/components/quota/quota-toolbar";
 import { UsersQuotaClient } from "./_components/users-quota-client";
 import { getSystemSettings } from "@/repository/system-config";
 import { getTranslations } from "next-intl/server";
+import { Link } from "@/i18n/routing";
+import { Info } from "lucide-react";
+import { Alert, AlertDescription } from "@/components/ui/alert";
 
 async function getUsersWithQuotas() {
   const users = await getUsers();
@@ -39,6 +42,16 @@ export default async function UsersQuotaPage() {
         </div>
       </div>
 
+      <Alert>
+        <Info className="h-4 w-4" />
+        <AlertDescription>
+          {t("manageNotice")}{" "}
+          <Link href="/dashboard/users" className="font-medium underline underline-offset-4">
+            {t("manageLink")}
+          </Link>
+        </AlertDescription>
+      </Alert>
+
       <QuotaToolbar
         sortOptions={[
           { value: "name", label: t("sort.name") },

+ 17 - 0
src/app/[locale]/dashboard/users/page.tsx

@@ -0,0 +1,17 @@
+import { getUsers } from "@/actions/users";
+import { getSession } from "@/lib/auth";
+import { redirect } from "next/navigation";
+import { UsersPageClient } from "./users-page-client";
+
+export default async function UsersPage() {
+  const session = await getSession();
+
+  // Redirect unauthenticated users
+  if (!session) {
+    redirect("/login");
+  }
+
+  const users = await getUsers();
+
+  return <UsersPageClient users={users} currentUser={session.user} />;
+}

+ 99 - 0
src/app/[locale]/dashboard/users/users-page-client.tsx

@@ -0,0 +1,99 @@
+"use client";
+
+import { useState, useMemo } from "react";
+import { UserList } from "../_components/user/user-list";
+import { Input } from "@/components/ui/input";
+import {
+  Select,
+  SelectContent,
+  SelectItem,
+  SelectTrigger,
+  SelectValue,
+} from "@/components/ui/select";
+import { Search } from "lucide-react";
+import { useTranslations } from "next-intl";
+import type { UserDisplay, User } from "@/types/user";
+
+interface UsersPageClientProps {
+  users: UserDisplay[];
+  currentUser: User;
+}
+
+export function UsersPageClient({ users, currentUser }: UsersPageClientProps) {
+  const t = useTranslations("dashboard.users");
+  const [searchTerm, setSearchTerm] = useState("");
+  const [groupFilter, setGroupFilter] = useState("all");
+
+  // Extract unique groups from users
+  const uniqueGroups = useMemo(() => {
+    const groups = users
+      .map((u) => u.providerGroup)
+      .filter((group): group is string => Boolean(group));
+    return [...new Set(groups)];
+  }, [users]);
+
+  // Filter users based on search term and group filter
+  const filteredUsers = useMemo(() => {
+    return users.filter((user) => {
+      // Search filter: match username
+      const matchesSearch =
+        searchTerm === "" ||
+        user.name.toLowerCase().includes(searchTerm.toLowerCase());
+
+      // Group filter
+      const matchesGroup =
+        groupFilter === "all" || user.providerGroup === groupFilter;
+
+      return matchesSearch && matchesGroup;
+    });
+  }, [users, searchTerm, groupFilter]);
+
+  return (
+    <div className="space-y-4">
+      <div className="flex items-center justify-between">
+        <div>
+          <h3 className="text-lg font-medium">{t("title")}</h3>
+          <p className="text-sm text-muted-foreground">
+            {t("description", { count: filteredUsers.length })}
+          </p>
+        </div>
+      </div>
+
+      {/* Toolbar with search and filters */}
+      <div className="flex flex-col gap-4 sm:flex-row sm:items-center">
+        {/* Search input */}
+        <div className="relative flex-1 max-w-sm">
+          <Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
+          <Input
+            placeholder={t("toolbar.searchPlaceholder")}
+            value={searchTerm}
+            onChange={(e) => setSearchTerm(e.target.value)}
+            className="pl-9"
+          />
+        </div>
+
+        {/* Group filter */}
+        <Select value={groupFilter} onValueChange={setGroupFilter}>
+          <SelectTrigger className="w-[180px]">
+            <SelectValue placeholder={t("toolbar.groupFilter")} />
+          </SelectTrigger>
+          <SelectContent>
+            <SelectItem value="all">{t("toolbar.allGroups")}</SelectItem>
+            {uniqueGroups.map((group) => (
+              <SelectItem key={group} value={group}>
+                {group}
+              </SelectItem>
+            ))}
+          </SelectContent>
+        </Select>
+      </div>
+
+      <UserList
+        users={filteredUsers}
+        activeUserId={filteredUsers[0]?.id ?? null}
+        onUserSelect={() => {}}
+        currentUser={currentUser}
+      />
+    </div>
+  );
+}

+ 6 - 0
src/app/[locale]/settings/_lib/nav-items.ts

@@ -19,6 +19,12 @@ export const SETTINGS_NAV_ITEMS: SettingsNavItem[] = [
   { href: "/settings/logs", labelKey: "nav.logs", label: "日志" },
   { href: "/settings/notifications", labelKey: "nav.notifications", label: "消息推送" },
   { href: "/api/actions/scalar", labelKey: "nav.apiDocs", label: "API 文档", external: true },
+  {
+    href: "https://github.com/ding113/claude-code-hub/issues",
+    labelKey: "nav.feedback",
+    label: "反馈问题",
+    external: true,
+  },
 ];
 
 // Helper function to get translated nav items

+ 1 - 1
src/app/[locale]/settings/providers/_components/forms/provider-form.tsx

@@ -395,7 +395,7 @@ export function ProviderForm({
           setPriority(0);
           setWeight(1);
           setCostMultiplier(1.0);
-          setGroupTag("");
+          setGroupTag([]);
           setLimit5hUsd(null);
           setLimitWeeklyUsd(null);
           setLimitMonthlyUsd(null);

+ 6 - 0
src/drizzle/schema.ts

@@ -22,6 +22,12 @@ export const users = pgTable('users', {
   rpmLimit: integer('rpm_limit').default(60),
   dailyLimitUsd: numeric('daily_limit_usd', { precision: 10, scale: 2 }).default('100.00'),
   providerGroup: varchar('provider_group', { length: 50 }),
+  
+  // New user-level quota fields (nullable for backward compatibility)
+  limit5hUsd: numeric('limit_5h_usd', { precision: 10, scale: 2 }),
+  limitWeeklyUsd: numeric('limit_weekly_usd', { precision: 10, scale: 2 }),
+  limitMonthlyUsd: numeric('limit_monthly_usd', { precision: 10, scale: 2 }),
+  limitConcurrentSessions: integer('limit_concurrent_sessions'),
   createdAt: timestamp('created_at', { withTimezone: true }).defaultNow(),
   updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow(),
   deletedAt: timestamp('deleted_at', { withTimezone: true }),

+ 47 - 0
src/lib/permissions/user-field-permissions.ts

@@ -0,0 +1,47 @@
+/**
+ * User Field Permissions Configuration
+ *
+ * Defines role-based access control for user fields.
+ * This configuration determines which fields require specific roles to modify.
+ */
+
+export const USER_FIELD_PERMISSIONS = {
+  // Admin-only fields (existing sensitive fields)
+  rpmLimit: { requiredRole: "admin" },
+  dailyLimitUsd: { requiredRole: "admin" },
+  providerGroup: { requiredRole: "admin" },
+
+  // Admin-only fields (new user-level quota fields)
+  limit5hUsd: { requiredRole: "admin" },
+  limitWeeklyUsd: { requiredRole: "admin" },
+  limitMonthlyUsd: { requiredRole: "admin" },
+  limitConcurrentSessions: { requiredRole: "admin" },
+} as const;
+
+/**
+ * Check if a user has permission to modify a specific field
+ *
+ * @param field - The field name to check
+ * @param userRole - The user's role (e.g., 'admin', 'user')
+ * @returns true if the user has permission, false otherwise
+ */
+export function checkFieldPermission(field: string, userRole: string): boolean {
+  const permission = USER_FIELD_PERMISSIONS[field as keyof typeof USER_FIELD_PERMISSIONS];
+
+  // If no permission is defined for the field, allow modification
+  if (!permission) return true;
+
+  // Check if user's role matches the required role
+  return userRole === permission.requiredRole;
+}
+
+/**
+ * Get all unauthorized fields from a data object based on user role
+ *
+ * @param data - The data object containing fields to check
+ * @param userRole - The user's role
+ * @returns Array of field names that the user is not authorized to modify
+ */
+export function getUnauthorizedFields(data: Record<string, unknown>, userRole: string): string[] {
+  return Object.keys(data).filter((field) => !checkFieldPermission(field, userRole));
+}

+ 50 - 0
src/lib/validation/schemas.ts

@@ -27,6 +27,31 @@ export const CreateUserSchema = z.object({
     .max(USER_LIMITS.DAILY_QUOTA.MAX, `每日额度不能超过${USER_LIMITS.DAILY_QUOTA.MAX}美元`)
     .optional()
     .default(USER_DEFAULTS.DAILY_QUOTA),
+  limit5hUsd: z.coerce
+    .number()
+    .min(0, "5小时消费上限不能为负数")
+    .max(10000, "5小时消费上限不能超过10000美元")
+    .nullable()
+    .optional(),
+  limitWeeklyUsd: z.coerce
+    .number()
+    .min(0, "周消费上限不能为负数")
+    .max(50000, "周消费上限不能超过50000美元")
+    .nullable()
+    .optional(),
+  limitMonthlyUsd: z.coerce
+    .number()
+    .min(0, "月消费上限不能为负数")
+    .max(200000, "月消费上限不能超过200000美元")
+    .nullable()
+    .optional(),
+  limitConcurrentSessions: z.coerce
+    .number()
+    .int("并发Session上限必须是整数")
+    .min(0, "并发Session上限不能为负数")
+    .max(1000, "并发Session上限不能超过1000")
+    .nullable()
+    .optional(),
 });
 
 /**
@@ -47,6 +72,31 @@ export const UpdateUserSchema = z.object({
     .min(USER_LIMITS.DAILY_QUOTA.MIN, `每日额度不能低于${USER_LIMITS.DAILY_QUOTA.MIN}美元`)
     .max(USER_LIMITS.DAILY_QUOTA.MAX, `每日额度不能超过${USER_LIMITS.DAILY_QUOTA.MAX}美元`)
     .optional(),
+  limit5hUsd: z.coerce
+    .number()
+    .min(0, "5小时消费上限不能为负数")
+    .max(10000, "5小时消费上限不能超过10000美元")
+    .nullable()
+    .optional(),
+  limitWeeklyUsd: z.coerce
+    .number()
+    .min(0, "周消费上限不能为负数")
+    .max(50000, "周消费上限不能超过50000美元")
+    .nullable()
+    .optional(),
+  limitMonthlyUsd: z.coerce
+    .number()
+    .min(0, "月消费上限不能为负数")
+    .max(200000, "月消费上限不能超过200000美元")
+    .nullable()
+    .optional(),
+  limitConcurrentSessions: z.coerce
+    .number()
+    .int("并发Session上限必须是整数")
+    .min(0, "并发Session上限不能为负数")
+    .max(1000, "并发Session上限不能超过1000")
+    .nullable()
+    .optional(),
 });
 
 /**

+ 31 - 0
src/repository/user.ts

@@ -13,6 +13,10 @@ export async function createUser(userData: CreateUserData): Promise<User> {
     rpmLimit: userData.rpm,
     dailyLimitUsd: userData.dailyQuota?.toString(),
     providerGroup: userData.providerGroup,
+    limit5hUsd: userData.limit5hUsd?.toString(),
+    limitWeeklyUsd: userData.limitWeeklyUsd?.toString(),
+    limitMonthlyUsd: userData.limitMonthlyUsd?.toString(),
+    limitConcurrentSessions: userData.limitConcurrentSessions,
   };
 
   const [user] = await db.insert(users).values(dbData).returning({
@@ -26,6 +30,10 @@ export async function createUser(userData: CreateUserData): Promise<User> {
     createdAt: users.createdAt,
     updatedAt: users.updatedAt,
     deletedAt: users.deletedAt,
+    limit5hUsd: users.limit5hUsd,
+    limitWeeklyUsd: users.limitWeeklyUsd,
+    limitMonthlyUsd: users.limitMonthlyUsd,
+    limitConcurrentSessions: users.limitConcurrentSessions,
   });
 
   return toUser(user);
@@ -44,6 +52,10 @@ export async function findUserList(limit: number = 50, offset: number = 0): Prom
       createdAt: users.createdAt,
       updatedAt: users.updatedAt,
       deletedAt: users.deletedAt,
+      limit5hUsd: users.limit5hUsd,
+      limitWeeklyUsd: users.limitWeeklyUsd,
+      limitMonthlyUsd: users.limitMonthlyUsd,
+      limitConcurrentSessions: users.limitConcurrentSessions,
     })
     .from(users)
     .where(isNull(users.deletedAt))
@@ -67,6 +79,10 @@ export async function findUserById(id: number): Promise<User | null> {
       createdAt: users.createdAt,
       updatedAt: users.updatedAt,
       deletedAt: users.deletedAt,
+      limit5hUsd: users.limit5hUsd,
+      limitWeeklyUsd: users.limitWeeklyUsd,
+      limitMonthlyUsd: users.limitMonthlyUsd,
+      limitConcurrentSessions: users.limitConcurrentSessions,
     })
     .from(users)
     .where(and(eq(users.id, id), isNull(users.deletedAt)));
@@ -88,6 +104,10 @@ export async function updateUser(id: number, userData: UpdateUserData): Promise<
     dailyLimitUsd?: string;
     providerGroup?: string | null;
     updatedAt?: Date;
+    limit5hUsd?: string;
+    limitWeeklyUsd?: string;
+    limitMonthlyUsd?: string;
+    limitConcurrentSessions?: number;
   }
 
   const dbData: UpdateDbData = {
@@ -98,6 +118,13 @@ export async function updateUser(id: number, userData: UpdateUserData): Promise<
   if (userData.rpm !== undefined) dbData.rpmLimit = userData.rpm;
   if (userData.dailyQuota !== undefined) dbData.dailyLimitUsd = userData.dailyQuota.toString();
   if (userData.providerGroup !== undefined) dbData.providerGroup = userData.providerGroup;
+  if (userData.limit5hUsd !== undefined) dbData.limit5hUsd = userData.limit5hUsd.toString();
+  if (userData.limitWeeklyUsd !== undefined)
+    dbData.limitWeeklyUsd = userData.limitWeeklyUsd.toString();
+  if (userData.limitMonthlyUsd !== undefined)
+    dbData.limitMonthlyUsd = userData.limitMonthlyUsd.toString();
+  if (userData.limitConcurrentSessions !== undefined)
+    dbData.limitConcurrentSessions = userData.limitConcurrentSessions;
 
   const [user] = await db
     .update(users)
@@ -114,6 +141,10 @@ export async function updateUser(id: number, userData: UpdateUserData): Promise<
       createdAt: users.createdAt,
       updatedAt: users.updatedAt,
       deletedAt: users.deletedAt,
+      limit5hUsd: users.limit5hUsd,
+      limitWeeklyUsd: users.limitWeeklyUsd,
+      limitMonthlyUsd: users.limitMonthlyUsd,
+      limitConcurrentSessions: users.limitConcurrentSessions,
     });
 
   if (!user) return null;

+ 20 - 0
src/types/user.ts

@@ -12,6 +12,11 @@ export interface User {
   createdAt: Date;
   updatedAt: Date;
   deletedAt?: Date;
+  // User-level quota fields
+  limit5hUsd?: number; // 5小时消费上限(美元)
+  limitWeeklyUsd?: number; // 周消费上限(美元)
+  limitMonthlyUsd?: number; // 月消费上限(美元)
+  limitConcurrentSessions?: number; // 并发 Session 上限
 }
 
 /**
@@ -23,6 +28,11 @@ export interface CreateUserData {
   rpm?: number; // 可选,有默认值
   dailyQuota?: number; // 可选,有默认值
   providerGroup?: string | null; // 可选,供应商分组
+  // User-level quota fields
+  limit5hUsd?: number;
+  limitWeeklyUsd?: number;
+  limitMonthlyUsd?: number;
+  limitConcurrentSessions?: number;
 }
 
 /**
@@ -34,6 +44,11 @@ export interface UpdateUserData {
   rpm?: number;
   dailyQuota?: number;
   providerGroup?: string | null; // 可选,供应商分组
+  // User-level quota fields
+  limit5hUsd?: number;
+  limitWeeklyUsd?: number;
+  limitMonthlyUsd?: number;
+  limitConcurrentSessions?: number;
 }
 
 /**
@@ -79,6 +94,11 @@ export interface UserDisplay {
   dailyQuota: number;
   providerGroup?: string | null;
   keys: UserKeyDisplay[];
+  // User-level quota fields
+  limit5hUsd?: number | null;
+  limitWeeklyUsd?: number | null;
+  limitMonthlyUsd?: number | null;
+  limitConcurrentSessions?: number | null;
 }
 
 /**