소스 검색

Merge pull request #370 from ding113/dev

release v0.3.31
Ding 3 달 전
부모
커밋
222bd4bc04
100개의 변경된 파일15245개의 추가작업 그리고 720개의 파일을 삭제
  1. 3 0
      .env.example
  2. 234 0
      .github/workflows/test.yml
  3. 1 3
      CLAUDE.md
  4. 1 0
      README.en.md
  5. 5 1
      README.md
  6. 25 2
      biome.json
  7. 13 17
      deploy/Dockerfile
  8. 15 21
      deploy/Dockerfile.dev
  9. 292 0
      docs/api-authentication-guide.md
  10. 162 0
      docs/api-docs-summary.md
  11. 1 1
      docs/architecture-claude-code-hub-2025-11-29.md
  12. 1 0
      drizzle/0035_blushing_fabian_cortez.sql
  13. 3 0
      drizzle/0036_stale_iron_fist.sql
  14. 6 0
      drizzle/0037_cursor_pagination_index.sql
  15. 1909 0
      drizzle/meta/0035_snapshot.json
  16. 1931 0
      drizzle/meta/0036_snapshot.json
  17. 21 0
      drizzle/meta/_journal.json
  18. 3 0
      messages/en/common.json
  19. 389 5
      messages/en/dashboard.json
  20. 2 2
      messages/en/quota.json
  21. 3 0
      messages/ja/common.json
  22. 401 12
      messages/ja/dashboard.json
  23. 2 2
      messages/ja/quota.json
  24. 3 0
      messages/ru/common.json
  25. 396 4
      messages/ru/dashboard.json
  26. 2 2
      messages/ru/quota.json
  27. 3 0
      messages/zh-CN/common.json
  28. 388 5
      messages/zh-CN/dashboard.json
  29. 2 2
      messages/zh-CN/quota.json
  30. 3 0
      messages/zh-TW/common.json
  31. 396 4
      messages/zh-TW/dashboard.json
  32. 2 2
      messages/zh-TW/quota.json
  33. 16 2
      package.json
  34. 1 0
      reports/.gitkeep
  35. 59 0
      scripts/cleanup-test-users.ps1
  36. 60 0
      scripts/cleanup-test-users.sh
  37. 39 0
      scripts/cleanup-test-users.sql
  38. 7 2
      scripts/clear-session-bindings.ts
  39. 112 0
      scripts/run-e2e-tests.ps1
  40. 101 0
      scripts/run-e2e-tests.sh
  41. 120 0
      src/actions/key-quota.ts
  42. 149 64
      src/actions/keys.ts
  43. 35 0
      src/actions/providers.ts
  44. 34 0
      src/actions/usage-logs.ts
  45. 311 90
      src/actions/users.ts
  46. 26 10
      src/app/[locale]/dashboard/_components/today-leaderboard.tsx
  47. 156 0
      src/app/[locale]/dashboard/_components/user/forms/access-restrictions-section.tsx
  48. 15 4
      src/app/[locale]/dashboard/_components/user/forms/add-key-form.tsx
  49. 183 0
      src/app/[locale]/dashboard/_components/user/forms/danger-zone.tsx
  50. 15 4
      src/app/[locale]/dashboard/_components/user/forms/edit-key-form.tsx
  51. 407 0
      src/app/[locale]/dashboard/_components/user/forms/key-edit-section.tsx
  52. 291 0
      src/app/[locale]/dashboard/_components/user/forms/limit-rule-picker.tsx
  53. 90 0
      src/app/[locale]/dashboard/_components/user/forms/limit-rules-display.tsx
  54. 122 0
      src/app/[locale]/dashboard/_components/user/forms/provider-group-select.tsx
  55. 67 0
      src/app/[locale]/dashboard/_components/user/forms/quick-expire-picker.tsx
  56. 275 0
      src/app/[locale]/dashboard/_components/user/forms/quick-renew-dialog.tsx
  57. 473 0
      src/app/[locale]/dashboard/_components/user/forms/user-edit-section.tsx
  58. 115 3
      src/app/[locale]/dashboard/_components/user/forms/user-form.tsx
  59. 1 0
      src/app/[locale]/dashboard/_components/user/key-actions.tsx
  60. 119 0
      src/app/[locale]/dashboard/_components/user/key-full-display-dialog.tsx
  61. 34 1
      src/app/[locale]/dashboard/_components/user/key-list-header.tsx
  62. 170 0
      src/app/[locale]/dashboard/_components/user/key-quota-usage-dialog.tsx
  63. 390 0
      src/app/[locale]/dashboard/_components/user/key-row-item.tsx
  64. 122 0
      src/app/[locale]/dashboard/_components/user/key-stats-dialog.tsx
  65. 100 0
      src/app/[locale]/dashboard/_components/user/limit-status-indicator.tsx
  66. 980 0
      src/app/[locale]/dashboard/_components/user/unified-edit-dialog.tsx
  67. 4 0
      src/app/[locale]/dashboard/_components/user/user-key-manager.tsx
  68. 305 0
      src/app/[locale]/dashboard/_components/user/user-key-table-row.tsx
  69. 157 0
      src/app/[locale]/dashboard/_components/user/user-limit-badge.tsx
  70. 427 0
      src/app/[locale]/dashboard/_components/user/user-management-table.tsx
  71. 110 0
      src/app/[locale]/dashboard/_components/user/user-onboarding-tour.tsx
  72. 30 12
      src/app/[locale]/dashboard/leaderboard/_components/leaderboard-table.tsx
  73. 89 61
      src/app/[locale]/dashboard/logs/_components/usage-logs-filters.tsx
  74. 230 0
      src/app/[locale]/dashboard/logs/_components/usage-logs-view-virtualized.tsx
  75. 512 0
      src/app/[locale]/dashboard/logs/_components/virtualized-logs-table.tsx
  76. 113 0
      src/app/[locale]/dashboard/logs/_hooks/use-lazy-filter-options.ts
  77. 2 2
      src/app/[locale]/dashboard/logs/page.tsx
  78. 259 55
      src/app/[locale]/dashboard/users/users-page-client.tsx
  79. 1 1
      src/app/[locale]/internal/dashboard/big-screen/page.tsx
  80. 29 10
      src/app/[locale]/settings/error-rules/_components/rule-list-table.tsx
  81. 18 10
      src/app/[locale]/settings/providers/_components/forms/api-test-button.tsx
  82. 13 5
      src/app/[locale]/settings/providers/_components/provider-rich-list-item.tsx
  83. 2 2
      src/app/[locale]/usage-doc/page.tsx
  84. 274 17
      src/app/api/actions/[...route]/route.ts
  85. 81 22
      src/app/global-error.tsx
  86. 3 3
      src/app/v1/_lib/codex/utils/request-sanitizer.ts
  87. 63 0
      src/app/v1/_lib/proxy/client-guard.ts
  88. 19 4
      src/app/v1/_lib/proxy/error-handler.ts
  89. 19 1
      src/app/v1/_lib/proxy/guard-pipeline.ts
  90. 63 0
      src/app/v1/_lib/proxy/model-guard.ts
  91. 277 151
      src/app/v1/_lib/proxy/rate-limit-guard.ts
  92. 35 4
      src/app/v1/_lib/proxy/response-handler.ts
  93. 48 7
      src/app/v1/_lib/proxy/responses.ts
  94. 17 1
      src/components/form/date-picker-field.tsx
  95. 6 3
      src/components/form/form-field.tsx
  96. 71 30
      src/components/ui/tag-input.tsx
  97. 16 0
      src/drizzle/schema.ts
  98. 136 54
      src/lib/api/action-adapter-openapi.ts
  99. 2 0
      src/lib/auth.ts
  100. 1 0
      src/lib/config/env.schema.ts

+ 3 - 0
.env.example

@@ -36,6 +36,9 @@ ENABLE_SECURE_COOKIES=true
 # - Fail Open 策略:Redis 不可用时自动降级,不影响服务可用性
 ENABLE_RATE_LIMIT=true                  # 是否启用限流功能(默认:true)
 REDIS_URL=redis://localhost:6379        # Redis 连接地址(Docker 部署使用 redis://redis:6379,支持 rediss:// TLS)
+REDIS_TLS_REJECT_UNAUTHORIZED=true      # 是否验证 Redis TLS 证书(默认:true)
+                                        # 设置为 false 可跳过证书验证,用于自签证书或共享证书场景
+                                        # 仅在 rediss:// 协议时生效
 
 # Session 配置
 SESSION_TTL=300                         # Session 过期时间(秒,默认 300 = 5 分钟)

+ 234 - 0
.github/workflows/test.yml

@@ -0,0 +1,234 @@
+name: 🧪 Test Suite
+
+on:
+  push:
+    branches: [main, dev]
+  pull_request:
+    branches: [main, dev]
+
+# 取消同一分支的进行中的工作流
+concurrency:
+  group: ${{ github.workflow }}-${{ github.ref }}
+  cancel-in-progress: true
+
+jobs:
+  # ==================== 代码质量检查 ====================
+  quality:
+    name: 📋 Code Quality
+    runs-on: ubuntu-latest
+
+    steps:
+      - name: Checkout code
+        uses: actions/checkout@v4
+
+      - name: Setup Bun
+        uses: oven-sh/setup-bun@v2
+        with:
+          bun-version: latest
+
+      - name: Install dependencies
+        run: bun install --frozen-lockfile
+
+      - name: Run linting
+        run: bun run lint
+
+      - name: Run type checking
+        run: bun run typecheck
+
+      - name: Check formatting
+        run: bun run format:check
+
+  # ==================== 单元测试 ====================
+  unit-tests:
+    name: ⚡ Unit Tests
+    runs-on: ubuntu-latest
+
+    steps:
+      - name: Checkout code
+        uses: actions/checkout@v4
+
+      - name: Setup Bun
+        uses: oven-sh/setup-bun@v2
+
+      - name: Install dependencies
+        run: bun install --frozen-lockfile
+
+      - name: Run unit tests
+        run: bun run test -- tests/unit/ --passWithNoTests
+
+  # ==================== 集成测试(需要数据库)====================
+  integration-tests:
+    name: 🔗 Integration Tests
+    runs-on: ubuntu-latest
+
+    services:
+      postgres:
+        image: postgres:16-alpine
+        env:
+          POSTGRES_USER: test_user
+          POSTGRES_PASSWORD: test_password
+          POSTGRES_DB: claude_code_hub_test
+        options: >-
+          --health-cmd pg_isready
+          --health-interval 10s
+          --health-timeout 5s
+          --health-retries 5
+        ports:
+          - 5432:5432
+
+      redis:
+        image: redis:7-alpine
+        options: >-
+          --health-cmd "redis-cli ping"
+          --health-interval 10s
+          --health-timeout 5s
+          --health-retries 5
+        ports:
+          - 6379:6379
+
+    env:
+      DSN: postgres://test_user:test_password@localhost:5432/claude_code_hub_test
+      REDIS_URL: redis://localhost:6379/1
+      ADMIN_TOKEN: test-admin-token-for-ci
+      AUTO_MIGRATE: true
+      ENABLE_RATE_LIMIT: true
+      SESSION_TTL: 300
+
+    steps:
+      - name: Checkout code
+        uses: actions/checkout@v4
+
+      - name: Setup Bun
+        uses: oven-sh/setup-bun@v2
+
+      - name: Install dependencies
+        run: bun install --frozen-lockfile
+
+      - name: Run database migrations
+        run: bun run db:migrate
+
+      - name: Run integration tests
+        run: bun run test -- tests/integration/ --passWithNoTests
+
+  # ==================== API 测试(需要运行服务)====================
+  api-tests:
+    name: 🌐 API Tests
+    runs-on: ubuntu-latest
+
+    services:
+      postgres:
+        image: postgres:16-alpine
+        env:
+          POSTGRES_USER: test_user
+          POSTGRES_PASSWORD: test_password
+          POSTGRES_DB: claude_code_hub_test
+        options: >-
+          --health-cmd pg_isready
+          --health-interval 10s
+          --health-timeout 5s
+          --health-retries 5
+        ports:
+          - 5432:5432
+
+      redis:
+        image: redis:7-alpine
+        options: >-
+          --health-cmd "redis-cli ping"
+          --health-interval 10s
+          --health-timeout 5s
+          --health-retries 5
+        ports:
+          - 6379:6379
+
+    env:
+      DSN: postgres://test_user:test_password@localhost:5432/claude_code_hub_test
+      REDIS_URL: redis://localhost:6379/1
+      ADMIN_TOKEN: test-admin-token-for-ci
+      AUTO_MIGRATE: true
+      PORT: 13500
+      ENABLE_RATE_LIMIT: true
+      SESSION_TTL: 300
+
+    steps:
+      - name: Checkout code
+        uses: actions/checkout@v4
+
+      - name: Setup Bun
+        uses: oven-sh/setup-bun@v2
+
+      - name: Install dependencies
+        run: bun install --frozen-lockfile
+
+      - name: Build application
+        run: bun run build
+
+      - name: Run database migrations
+        run: bun run db:migrate
+
+      - name: Start server (background)
+        run: |
+          bun run start &
+          echo $! > server.pid
+          sleep 15  # 等待服务启动
+
+      - name: Wait for server ready
+        run: |
+          timeout 60 bash -c 'until curl -f http://localhost:13500/api/actions/health; do sleep 2; done'
+
+      - name: Run E2E API tests
+        run: bun run test:e2e
+        env:
+          API_BASE_URL: http://localhost:13500/api/actions
+          TEST_ADMIN_TOKEN: test-admin-token-for-ci
+          AUTO_CLEANUP_TEST_DATA: true
+
+      - name: Stop server
+        if: always()
+        run: |
+          if [ -f server.pid ]; then
+            kill $(cat server.pid) || true
+          fi
+
+  # ==================== 测试结果汇总 ====================
+  test-summary:
+    name: 📊 Test Summary
+    runs-on: ubuntu-latest
+    needs: [quality, unit-tests, integration-tests, api-tests]
+    if: always()
+
+    steps:
+      - name: Check test results
+        run: |
+          if [ "${{ needs.quality.result }}" != "success" ] || \
+             [ "${{ needs.unit-tests.result }}" != "success" ] || \
+             [ "${{ needs.integration-tests.result }}" != "success" ] || \
+             [ "${{ needs.api-tests.result }}" != "success" ]; then
+            echo "❌ 部分测试失败"
+            exit 1
+          else
+            echo "✅ 所有测试通过"
+          fi
+
+      - name: Create summary
+        if: github.event_name == 'pull_request'
+        uses: actions/github-script@v7
+        with:
+          script: |
+            const summary = `## 🧪 测试结果
+
+            | 测试类型 | 状态 |
+            |---------|------|
+            | 代码质量 | ${{ needs.quality.result == 'success' && '✅' || '❌' }} |
+            | 单元测试 | ${{ needs.unit-tests.result == 'success' && '✅' || '❌' }} |
+            | 集成测试 | ${{ needs.integration-tests.result == 'success' && '✅' || '❌' }} |
+            | API 测试 | ${{ needs.api-tests.result == 'success' && '✅' || '❌' }} |
+
+            **总体结果**: ${{ (needs.quality.result == 'success' && needs.unit-tests.result == 'success' && needs.integration-tests.result == 'success' && needs.api-tests.result == 'success') && '✅ 所有测试通过' || '❌ 部分测试失败' }}
+            `;
+
+            github.rest.issues.createComment({
+              issue_number: context.issue.number,
+              owner: context.repo.owner,
+              repo: context.repo.repo,
+              body: summary
+            });

+ 1 - 3
CLAUDE.md

@@ -1,4 +1,2 @@
 @.env.example
[email protected]
[email protected]
-@docs/product-brief-claude-code-hub-2025-11-29.md
[email protected]

+ 1 - 0
README.en.md

@@ -260,6 +260,7 @@ Docker Compose is the **preferred deployment method** — it automatically provi
 | `DSN`                                      | -                        | PostgreSQL connection string, e.g., `postgres://user:pass@host:5432/db`.                             |
 | `AUTO_MIGRATE`                             | `true`                   | Executes Drizzle migrations on startup; consider disabling in production for manual control.         |
 | `REDIS_URL`                                | `redis://localhost:6379` | Redis endpoint, supports `rediss://` for TLS providers.                                              |
+| `REDIS_TLS_REJECT_UNAUTHORIZED`            | `true`                   | Validate Redis TLS certificates; set `false` to skip (for self-signed/shared certs).                 |
 | `ENABLE_RATE_LIMIT`                        | `true`                   | Toggles multi-dimensional rate limiting; Fail-Open handles Redis outages gracefully.                 |
 | `SESSION_TTL`                              | `300`                    | Session cache window (seconds) that drives vendor reuse.                                             |
 | `ENABLE_SECURE_COOKIES`                    | `true`                   | Browsers require HTTPS for Secure cookies; set to `false` when serving plain HTTP outside localhost. |

+ 5 - 1
README.md

@@ -165,8 +165,11 @@ Set-ExecutionPolicy -ExecutionPolicy Bypass -Scope Process -Force
 - **管理后台**:`http://localhost:23000`(使用 `.env` 中的 `ADMIN_TOKEN` 登录)
 - **API 文档(Scalar UI)**:`http://localhost:23000/api/actions/scalar`
 - **API 文档(Swagger UI)**:`http://localhost:23000/api/actions/docs`
+- **API 认证指南**:[docs/api-authentication-guide.md](docs/api-authentication-guide.md)
 
-> 💡 **提示**:如需修改端口,请编辑 `docker-compose.yml` 中的 `ports` 配置。
+> 💡 **提示**:
+> - 如需修改端口,请编辑 `docker-compose.yml` 中的 `ports` 配置。
+> - 如需通过脚本或编程调用 API,请参考 [API 认证指南](docs/api-authentication-guide.md)。
 
 ## 🖼️ 界面预览 Screenshots
 
@@ -260,6 +263,7 @@ Docker Compose 是**首选部署方式**,自动配置数据库、Redis 和应
 | `DSN`                                      | -                        | PostgreSQL 连接串,如 `postgres://user:pass@host:5432/db`.                   |
 | `AUTO_MIGRATE`                             | `true`                   | 启动时自动执行 Drizzle 迁移;生产环境可关闭以人工控制。                      |
 | `REDIS_URL`                                | `redis://localhost:6379` | Redis 地址,支持 `rediss://` 用于 TLS。                                      |
+| `REDIS_TLS_REJECT_UNAUTHORIZED`            | `true`                   | 是否验证 Redis TLS 证书;设为 `false` 可跳过验证(用于自签/共享证书)。      |
 | `ENABLE_RATE_LIMIT`                        | `true`                   | 控制多维限流开关;Fail-Open 策略在 Redis 不可用时自动降级。                  |
 | `SESSION_TTL`                              | `300`                    | Session 缓存时间(秒),影响供应商复用策略。                                 |
 | `ENABLE_SECURE_COOKIES`                    | `true`                   | 仅 HTTPS 场景能设置 Secure Cookie;HTTP 访问(非 localhost)需改为 `false`。 |

+ 25 - 2
biome.json

@@ -80,15 +80,38 @@
           }
         }
       }
+    },
+    {
+      "includes": ["**/tests/**", "**/*.test.ts", "**/*.test.tsx"],
+      "linter": {
+        "rules": {
+          "correctness": {
+            "noUnusedVariables": "off",
+            "noUnusedImports": "off"
+          }
+        }
+      },
+      "assist": {
+        "actions": {
+          "source": {
+            "organizeImports": "off"
+          }
+        }
+      }
     }
   ],
   "files": {
     "includes": [
-      "**",
+      "src/**",
+      "tests/**",
+      "*.ts",
+      "*.tsx",
+      "*.json",
+      "*.mjs",
+      "*.cjs",
       "!**/node_modules",
       "!**/.next",
       "!**/dist",
-      "!**/*.d.ts",
       "!**/drizzle",
       "!**/docs-site"
     ]

+ 13 - 17
deploy/Dockerfile

@@ -1,6 +1,6 @@
 # syntax=docker/dockerfile:1
 
-FROM --platform=$BUILDPLATFORM oven/bun:slim AS build-base
+FROM --platform=$BUILDPLATFORM oven/bun:debian AS build-base
 WORKDIR /app
 
 FROM build-base AS deps
@@ -26,31 +26,27 @@ ENV CI=true
 
 RUN bun run build
 
-FROM node:22-slim AS runner
+FROM oven/bun:debian AS runner
 ENV NODE_ENV=production
 ENV PORT=3000
 ENV HOST=0.0.0.0
 WORKDIR /app
 
-# 安装 PostgreSQL 18 客户端工具(用于数据库备份/恢复功能)和 curl(用于健康检查)
-# 需要使用官方 PostgreSQL APT 仓库以获取最新版本
+# 安装 PostgreSQL 客户端工具(用于数据库备份/恢复功能)和 curl(用于健康检查)
+# Debian Trixie 自带 PostgreSQL 17,无需外部 APT 仓库
 RUN apt-get update && \
-    apt-get install -y gnupg curl ca-certificates && \
-    curl -fsSL https://www.postgresql.org/media/keys/ACCC4CF8.asc | gpg --dearmor -o /usr/share/keyrings/postgresql-archive-keyring.gpg && \
-    echo "deb [signed-by=/usr/share/keyrings/postgresql-archive-keyring.gpg] http://apt.postgresql.org/pub/repos/apt bookworm-pgdg main" > /etc/apt/sources.list.d/pgdg.list && \
-    apt-get update && \
-    apt-get install -y postgresql-client-18 && \
+    apt-get install -y curl ca-certificates postgresql-client && \
     rm -rf /var/lib/apt/lists/*
 
-COPY --from=build --chown=node:node /app/public ./public
-COPY --from=build --chown=node:node /app/drizzle ./drizzle
-COPY --from=build --chown=node:node /app/messages ./messages
-COPY --from=build --chown=node:node /app/.next/standalone ./
+COPY --from=build --chown=bun:bun /app/public ./public
+COPY --from=build --chown=bun:bun /app/drizzle ./drizzle
+COPY --from=build --chown=bun:bun /app/messages ./messages
+COPY --from=build --chown=bun:bun /app/.next/standalone ./
 # Server Actions live inside .next/server; copy it or Next.js cannot resolve action IDs.
-COPY --from=build --chown=node:node /app/.next/server ./.next/server
-COPY --from=build --chown=node:node /app/.next/static ./.next/static
+COPY --from=build --chown=bun:bun /app/.next/server ./.next/server
+COPY --from=build --chown=bun:bun /app/.next/static ./.next/static
 
-USER node
+USER bun
 EXPOSE 3000
 
-CMD ["node", "server.js"]
+CMD ["bun", "run", "server.js"]

+ 15 - 21
deploy/Dockerfile.dev

@@ -1,13 +1,11 @@
 # syntax=docker/dockerfile:1
 
-FROM --platform=$BUILDPLATFORM oven/bun:1.3.2-slim AS build-base
+FROM --platform=$BUILDPLATFORM oven/bun:debian AS build-base
 WORKDIR /app
 
 FROM build-base AS deps
-COPY package.json bun.lock ./
-# 使用 BuildKit 缓存挂载加速依赖安装
-RUN --mount=type=cache,target=/root/.bun/install/cache \
-    bun install --frozen-lockfile
+COPY package.json ./
+RUN bun install
 
 FROM deps AS build
 COPY . .
@@ -28,33 +26,29 @@ ENV REDIS_URL="redis://localhost:6379"
 RUN --mount=type=cache,target=/app/.next/cache \
     bun run build
 
-FROM node:22-slim AS runner
+FROM oven/bun:debian AS runner
 ENV NODE_ENV=production
 ENV PORT=3000
 ENV HOST=0.0.0.0
 WORKDIR /app
 
-# 安装 PostgreSQL 18 客户端工具(用于数据库备份/恢复功能)和 curl(用于健康检查)
-# 需要使用官方 PostgreSQL APT 仓库以获取最新版本
+# 安装 PostgreSQL 客户端工具(用于数据库备份/恢复功能)和 curl(用于健康检查)
+# Debian Trixie 自带 PostgreSQL 17,无需外部 APT 仓库
 # 使用 BuildKit 缓存挂载加速 APT 安装
 RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \
     --mount=type=cache,target=/var/lib/apt/lists,sharing=locked \
     apt-get update && \
-    apt-get install -y gnupg curl ca-certificates && \
-    curl -fsSL https://www.postgresql.org/media/keys/ACCC4CF8.asc | gpg --dearmor -o /usr/share/keyrings/postgresql-archive-keyring.gpg && \
-    echo "deb [signed-by=/usr/share/keyrings/postgresql-archive-keyring.gpg] http://apt.postgresql.org/pub/repos/apt bookworm-pgdg main" > /etc/apt/sources.list.d/pgdg.list && \
-    apt-get update && \
-    apt-get install -y postgresql-client-18
+    apt-get install -y curl ca-certificates postgresql-client
 
-COPY --from=build --chown=node:node /app/public ./public
-COPY --from=build --chown=node:node /app/drizzle ./drizzle
-COPY --from=build --chown=node:node /app/messages ./messages
-COPY --from=build --chown=node:node /app/.next/standalone ./
+COPY --from=build --chown=bun:bun /app/public ./public
+COPY --from=build --chown=bun:bun /app/drizzle ./drizzle
+COPY --from=build --chown=bun:bun /app/messages ./messages
+COPY --from=build --chown=bun:bun /app/.next/standalone ./
 # Server Actions live inside .next/server; copy it or Next.js cannot resolve action IDs.
-COPY --from=build --chown=node:node /app/.next/server ./.next/server
-COPY --from=build --chown=node:node /app/.next/static ./.next/static
+COPY --from=build --chown=bun:bun /app/.next/server ./.next/server
+COPY --from=build --chown=bun:bun /app/.next/static ./.next/static
 
-USER node
+USER bun
 EXPOSE 3000
 
-CMD ["node", "server.js"]
+CMD ["bun", "run", "server.js"]

+ 292 - 0
docs/api-authentication-guide.md

@@ -0,0 +1,292 @@
+# API 认证使用指南
+
+## 📋 概述
+
+Claude Code Hub 的所有 API 端点通过 **HTTP Cookie** 进行认证,Cookie 名称为 `auth-token`。
+
+## 🔐 认证方式
+
+### 方法 1:通过 Web UI 登录(推荐)
+
+这是最简单的认证方式,适合在浏览器中测试 API。
+
+**步骤:**
+
+1. 访问 Claude Code Hub 登录页面(通常是 `http://localhost:23000` 或您部署的域名)
+2. 使用您的 API Key 或管理员令牌(ADMIN_TOKEN)登录
+3. 登录成功后,浏览器会自动设置 `auth-token` Cookie(有效期 7 天)
+4. 在同一浏览器中访问 API 文档页面即可直接测试(Cookie 自动携带)
+
+**优点:**
+- ✅ 无需手动处理 Cookie
+- ✅ 可以直接在 Scalar/Swagger UI 中测试 API
+- ✅ 浏览器自动管理 Cookie 的生命周期
+
+### 方法 2:手动获取 Cookie(用于脚本或编程调用)
+
+如果需要在脚本、自动化工具或编程环境中调用 API,需要手动获取并设置 Cookie。
+
+**步骤:**
+
+1. 先通过浏览器登录 Claude Code Hub
+2. 打开浏览器开发者工具(按 F12 键)
+3. 切换到以下标签页之一:
+   - Chrome/Edge: `Application` → `Cookies`
+   - Firefox: `Storage` → `Cookies`
+   - Safari: `Storage` → `Cookies`
+4. 在 Cookie 列表中找到 `auth-token`
+5. 复制该 Cookie 的值(例如:`cch_1234567890abcdef...`)
+6. 在 API 调用中通过 HTTP Header 携带该 Cookie
+
+**优点:**
+- ✅ 适合自动化脚本和后台服务
+- ✅ 可以在任何支持 HTTP 请求的环境中使用
+- ✅ 便于集成到 CI/CD 流程
+
+## 💻 使用示例
+
+### curl 示例
+
+```bash
+# 基本用法:通过 Cookie Header 认证
+curl -X POST 'http://localhost:23000/api/actions/users/getUsers' \
+  -H 'Content-Type: application/json' \
+  -H 'Cookie: auth-token=your-token-here' \
+  -d '{}'
+
+# 使用 -b 参数(curl 的 Cookie 简写)
+curl -X POST 'http://localhost:23000/api/actions/users/getUsers' \
+  -H 'Content-Type: application/json' \
+  -b 'auth-token=your-token-here' \
+  -d '{}'
+
+# 从文件读取 Cookie
+curl -X POST 'http://localhost:23000/api/actions/users/getUsers' \
+  -H 'Content-Type: application/json' \
+  -b cookies.txt \
+  -d '{}'
+```
+
+### JavaScript (fetch) 示例
+
+#### 浏览器环境(推荐)
+
+```javascript
+// Cookie 自动携带,无需手动设置
+fetch('/api/actions/users/getUsers', {
+  method: 'POST',
+  headers: {
+    'Content-Type': 'application/json',
+  },
+  credentials: 'include', // 重要:告诉浏览器携带 Cookie
+  body: JSON.stringify({}),
+})
+  .then(res => res.json())
+  .then(data => {
+    if (data.ok) {
+      console.log('成功:', data.data);
+    } else {
+      console.error('失败:', data.error);
+    }
+  });
+```
+
+#### Node.js 环境
+
+```javascript
+const fetch = require('node-fetch');
+
+// 手动设置 Cookie
+fetch('http://localhost:23000/api/actions/users/getUsers', {
+  method: 'POST',
+  headers: {
+    'Content-Type': 'application/json',
+    'Cookie': 'auth-token=your-token-here',
+  },
+  body: JSON.stringify({}),
+})
+  .then(res => res.json())
+  .then(data => {
+    if (data.ok) {
+      console.log('成功:', data.data);
+    } else {
+      console.error('失败:', data.error);
+    }
+  });
+```
+
+### Python 示例
+
+#### 使用 requests 库
+
+```python
+import requests
+
+# 方式 1:使用 Session(推荐,自动管理 Cookie)
+session = requests.Session()
+session.cookies.set('auth-token', 'your-token-here')
+
+response = session.post(
+    'http://localhost:23000/api/actions/users/getUsers',
+    json={},
+)
+
+if response.json()['ok']:
+    print('成功:', response.json()['data'])
+else:
+    print('失败:', response.json()['error'])
+
+# 方式 2:直接在 headers 中设置 Cookie
+response = requests.post(
+    'http://localhost:23000/api/actions/users/getUsers',
+    json={},
+    headers={
+        'Content-Type': 'application/json',
+        'Cookie': 'auth-token=your-token-here'
+    }
+)
+```
+
+#### 使用 httpx 库(异步支持)
+
+```python
+import httpx
+
+async def get_users():
+    async with httpx.AsyncClient() as client:
+        response = await client.post(
+            'http://localhost:23000/api/actions/users/getUsers',
+            json={},
+            headers={
+                'Cookie': 'auth-token=your-token-here'
+            }
+        )
+        return response.json()
+
+# 使用示例
+import asyncio
+result = asyncio.run(get_users())
+```
+
+### Go 示例
+
+```go
+package main
+
+import (
+    "bytes"
+    "encoding/json"
+    "fmt"
+    "io"
+    "net/http"
+)
+
+func main() {
+    url := "http://localhost:23000/api/actions/users/getUsers"
+
+    // 创建请求体
+    body := bytes.NewBuffer([]byte("{}"))
+
+    // 创建请求
+    req, err := http.NewRequest("POST", url, body)
+    if err != nil {
+        panic(err)
+    }
+
+    // 设置 Headers
+    req.Header.Set("Content-Type", "application/json")
+    req.Header.Set("Cookie", "auth-token=your-token-here")
+
+    // 发送请求
+    client := &http.Client{}
+    resp, err := client.Do(req)
+    if err != nil {
+        panic(err)
+    }
+    defer resp.Body.Close()
+
+    // 解析响应
+    respBody, _ := io.ReadAll(resp.Body)
+    var result map[string]interface{}
+    json.Unmarshal(respBody, &result)
+
+    if result["ok"].(bool) {
+        fmt.Println("成功:", result["data"])
+    } else {
+        fmt.Println("失败:", result["error"])
+    }
+}
+```
+
+## ⚠️ 常见问题
+
+### 1. 401 Unauthorized - "未认证"
+
+**原因:** 缺少 `auth-token` Cookie
+
+**解决方法:**
+- 确认请求中包含了 `Cookie: auth-token=...` Header
+- 检查 Cookie 值是否正确(不要包含额外的空格或换行符)
+- 在浏览器环境确保设置了 `credentials: 'include'`
+
+### 2. 401 Unauthorized - "认证无效或已过期"
+
+**原因:** Cookie 无效、已过期或已被撤销
+
+**解决方法:**
+- 重新登录获取新的 `auth-token`
+- 检查用户账号是否被禁用
+- 确认 API Key 是否设置了 `canLoginWebUi` 权限
+
+### 3. 403 Forbidden - "权限不足"
+
+**原因:** 当前用户没有访问该端点的权限
+
+**解决方法:**
+- 检查端点是否需要管理员权限(标记为 `[管理员]`)
+- 使用管理员账号登录(使用 `ADMIN_TOKEN` 或具有 admin 角色的用户)
+
+### 4. 浏览器环境 Cookie 未自动携带
+
+**原因:** 未设置 `credentials: 'include'`
+
+**解决方法:**
+```javascript
+fetch('/api/actions/users/getUsers', {
+  credentials: 'include', // 添加这一行
+  // ... 其他配置
+})
+```
+
+### 5. 跨域请求 Cookie 问题
+
+**原因:** CORS 策略限制
+
+**解决方法:**
+- 确保 API 服务器配置了正确的 CORS 策略
+- 在前端请求中设置 `credentials: 'include'`
+- 使用相同域名或配置服务器允许跨域 Cookie
+
+## 🔒 安全最佳实践
+
+1. **不要在公共场合分享 Cookie 值**
+   - `auth-token` 相当于您的登录凭证
+   - 泄露后他人可以冒充您的身份操作系统
+
+2. **定期更换 API Key**
+   - Cookie 有效期为 7 天
+   - 到期后需要重新登录
+
+3. **使用 HTTPS**
+   - 生产环境务必启用 HTTPS
+   - 确保 `ENABLE_SECURE_COOKIES=true`(默认值)
+
+4. **环境变量管理**
+   - 将 Cookie 值存储在环境变量中
+   - 不要硬编码在代码仓库中
+
+## 📚 相关资源
+
+- [OpenAPI 文档](/api/actions/docs) - Swagger UI
+- [Scalar API 文档](/api/actions/scalar) - 现代化 API 文档界面
+- [GitHub 仓库](https://github.com/ding113/claude-code-hub) - 查看源码和更多文档

+ 162 - 0
docs/api-docs-summary.md

@@ -0,0 +1,162 @@
+# API 文档修复总结
+
+**修复时间**: 2025-12-17
+**问题描述**: API 文档中部分接口的 body 请求参数显示为 "UNKNOWN"
+**修复方式**: 为所有不需要请求参数的接口显式声明空 `requestSchema`
+
+---
+
+## 🔍 问题根源
+
+### 原因分析
+
+当 `createActionRoute` 没有显式定义 `requestSchema` 时,会使用默认值:
+
+```typescript
+const {
+  requestSchema = z.object({}).passthrough(),  // ⚠️ 带 passthrough 的空对象
+  // ...
+} = options;
+```
+
+**问题**:`z.object({}).passthrough()` 允许任意属性通过,导致 OpenAPI 生成器无法推断具体结构,文档显示为 **UNKNOWN**。
+
+### 解决方案
+
+为不需要参数的接口显式声明空 `requestSchema`:
+
+```typescript
+{
+  requestSchema: z.object({}).describe("无需请求参数"),  // ✅ 清晰标注
+  // ...
+}
+```
+
+---
+
+## 📝 修复的接口列表(15 个)
+
+### 用户管理(1 个)
+- ✅ `POST /api/actions/users/getUsers` - 获取用户列表
+
+### 供应商管理(2 个)
+- ✅ `POST /api/actions/providers/getProviders` - 获取供应商列表
+- ✅ `POST /api/actions/providers/getProvidersHealthStatus` - 获取供应商健康状态
+
+### 模型价格(4 个)
+- ✅ `POST /api/actions/model-prices/getModelPrices` - 获取模型价格列表
+- ✅ `POST /api/actions/model-prices/syncLiteLLMPrices` - 同步 LiteLLM 价格表
+- ✅ `POST /api/actions/model-prices/getAvailableModelsByProviderType` - 获取可用模型列表
+- ✅ `POST /api/actions/model-prices/hasPriceTable` - 检查价格表状态
+
+### 使用日志(2 个)
+- ✅ `POST /api/actions/usage-logs/getModelList` - 获取日志中的模型列表
+- ✅ `POST /api/actions/usage-logs/getStatusCodeList` - 获取日志中的状态码列表
+
+### 概览(1 个)
+- ✅ `POST /api/actions/overview/getOverviewData` - 获取首页概览数据
+
+### 敏感词管理(3 个)
+- ✅ `POST /api/actions/sensitive-words/listSensitiveWords` - 获取敏感词列表
+- ✅ `POST /api/actions/sensitive-words/refreshCacheAction` - 刷新敏感词缓存
+- ✅ `POST /api/actions/sensitive-words/getCacheStats` - 获取缓存统计信息
+
+### Session 管理(1 个)
+- ✅ `POST /api/actions/active-sessions/getActiveSessions` - 获取活跃 Session 列表
+
+### 通知管理(1 个)
+- ✅ `POST /api/actions/notifications/getNotificationSettingsAction` - 获取通知设置
+
+---
+
+## 🧪 验证结果
+
+### 类型检查
+
+```bash
+$ bun run typecheck
+✅ 通过 - 无类型错误
+```
+
+### 统计信息
+
+- **修复的接口数量**: 15 个
+- **修改的代码行数**: ~45 行(每个接口增加 1 行 `requestSchema`)
+- **影响的文件**: 1 个 (`src/app/api/actions/[...route]/route.ts`)
+
+---
+
+## 📊 修复效果对比
+
+### 修复前
+
+```json
+// OpenAPI 文档生成的 Request Body Schema
+{
+  "type": "object",
+  "additionalProperties": true,  // ❌ 无法推断具体结构
+  "description": "UNKNOWN"
+}
+```
+
+### 修复后
+
+```json
+// OpenAPI 文档生成的 Request Body Schema
+{
+  "type": "object",
+  "properties": {},  // ✅ 明确标注为空对象
+  "description": "无需请求参数"
+}
+```
+
+---
+
+## 🎯 最佳实践建议
+
+### 未来开发规范
+
+1. **所有接口都应显式声明 `requestSchema`**
+   - 即使不需要参数,也应该使用 `z.object({}).describe("无需请求参数")`
+   - 避免依赖默认值,提高文档可读性
+
+2. **接口参数规范**
+   ```typescript
+   // ✅ 推荐:显式声明
+   {
+     requestSchema: z.object({}).describe("无需请求参数"),
+     // ...
+   }
+
+   // ✅ 推荐:有参数时清晰定义
+   {
+     requestSchema: z.object({
+       userId: z.number().int().positive().describe("用户 ID"),
+     }).describe("查询用户信息的参数"),
+     // ...
+   }
+
+   // ❌ 不推荐:完全不定义(依赖默认值)
+   {
+     // 缺少 requestSchema
+     // ...
+   }
+   ```
+
+3. **文档描述规范**
+   - 使用 `.describe()` 为 schema 添加中文说明
+   - 说明应简洁明了,避免冗余
+
+---
+
+## 📚 参考资料
+
+- OpenAPI 3.1.0 规范:https://spec.openapis.org/oas/v3.1.0
+- Zod Schema 文档:https://zod.dev
+- 项目 API 适配器:`src/lib/api/action-adapter-openapi.ts`
+- API 认证指南:`docs/api-authentication-guide.md`
+
+---
+
+**维护者**: Claude Code Hub Team
+**最后更新**: 2025-12-17

+ 1 - 1
docs/architecture-claude-code-hub-2025-11-29.md

@@ -439,7 +439,7 @@ const COUNT_TOKENS_PIPELINE = ["auth", "version", "probe", "provider"];
 ```
 1. Filter enabled providers
 2. Filter by circuit breaker state (exclude OPEN)
-3. Filter by user's provider group (if set)
+3. Filter by effective provider group (key.providerGroup overrides user.providerGroup; key.providerGroup is admin-only; user.providerGroup is derived from Key groups on Key changes)
 4. Check session cache for sticky provider
 5. If no sticky: weighted random selection by weight
 6. Return selected provider or null (all unavailable)

+ 1 - 0
drizzle/0035_blushing_fabian_cortez.sql

@@ -0,0 +1 @@
+ALTER TABLE "users" ADD COLUMN "allowed_clients" jsonb DEFAULT '[]'::jsonb;

+ 3 - 0
drizzle/0036_stale_iron_fist.sql

@@ -0,0 +1,3 @@
+ALTER TABLE "users" ADD COLUMN IF NOT EXISTS "daily_reset_mode" "daily_reset_mode" DEFAULT 'fixed' NOT NULL;--> statement-breakpoint
+ALTER TABLE "users" ADD COLUMN IF NOT EXISTS "daily_reset_time" varchar(5) DEFAULT '00:00' NOT NULL;--> statement-breakpoint
+ALTER TABLE "users" ADD COLUMN IF NOT EXISTS "allowed_models" jsonb DEFAULT '[]'::jsonb;

+ 6 - 0
drizzle/0037_cursor_pagination_index.sql

@@ -0,0 +1,6 @@
+-- Cursor-based pagination optimization index
+-- This composite index enables efficient keyset pagination on message_request
+-- Query pattern: WHERE (created_at, id) < (cursor_created_at, cursor_id) ORDER BY created_at DESC, id DESC
+
+CREATE INDEX IF NOT EXISTS "idx_message_request_cursor"
+ON "message_request" ("created_at" DESC, "id" DESC);

+ 1909 - 0
drizzle/meta/0035_snapshot.json

@@ -0,0 +1,1909 @@
+{
+  "id": "9be2ede5-422c-44a7-a144-3ef1ca483887",
+  "prevId": "b883052c-550d-497c-9b62-6154c46132ea",
+  "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
+        },
+        "override_response": {
+          "name": "override_response",
+          "type": "jsonb",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "override_status_code": {
+          "name": "override_status_code",
+          "type": "integer",
+          "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": false
+        },
+        "limit_5h_usd": {
+          "name": "limit_5h_usd",
+          "type": "numeric(10, 2)",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "limit_daily_usd": {
+          "name": "limit_daily_usd",
+          "type": "numeric(10, 2)",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "daily_reset_mode": {
+          "name": "daily_reset_mode",
+          "type": "daily_reset_mode",
+          "typeSchema": "public",
+          "primaryKey": false,
+          "notNull": true,
+          "default": "'fixed'"
+        },
+        "daily_reset_time": {
+          "name": "daily_reset_time",
+          "type": "varchar(5)",
+          "primaryKey": false,
+          "notNull": true,
+          "default": "'00:00'"
+        },
+        "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_total_usd": {
+          "name": "limit_total_usd",
+          "type": "numeric(10, 2)",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "limit_concurrent_sessions": {
+          "name": "limit_concurrent_sessions",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": false,
+          "default": 0
+        },
+        "provider_group": {
+          "name": "provider_group",
+          "type": "varchar(50)",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "cache_ttl_preference": {
+          "name": "cache_ttl_preference",
+          "type": "varchar(10)",
+          "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_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
+        },
+        "request_sequence": {
+          "name": "request_sequence",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": false,
+          "default": 1
+        },
+        "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
+        },
+        "cache_creation_5m_input_tokens": {
+          "name": "cache_creation_5m_input_tokens",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "cache_creation_1h_input_tokens": {
+          "name": "cache_creation_1h_input_tokens",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "cache_ttl_applied": {
+          "name": "cache_ttl_applied",
+          "type": "varchar(10)",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "context_1m_applied": {
+          "name": "context_1m_applied",
+          "type": "boolean",
+          "primaryKey": false,
+          "notNull": false,
+          "default": 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_session_seq": {
+          "name": "idx_message_request_session_seq",
+          "columns": [
+            {
+              "expression": "session_id",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            },
+            {
+              "expression": "request_sequence",
+              "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'"
+        },
+        "preserve_client_ip": {
+          "name": "preserve_client_ip",
+          "type": "boolean",
+          "primaryKey": false,
+          "notNull": true,
+          "default": false
+        },
+        "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'"
+        },
+        "mcp_passthrough_type": {
+          "name": "mcp_passthrough_type",
+          "type": "varchar(20)",
+          "primaryKey": false,
+          "notNull": true,
+          "default": "'none'"
+        },
+        "mcp_passthrough_url": {
+          "name": "mcp_passthrough_url",
+          "type": "varchar(512)",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "limit_5h_usd": {
+          "name": "limit_5h_usd",
+          "type": "numeric(10, 2)",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "limit_daily_usd": {
+          "name": "limit_daily_usd",
+          "type": "numeric(10, 2)",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "daily_reset_mode": {
+          "name": "daily_reset_mode",
+          "type": "daily_reset_mode",
+          "typeSchema": "public",
+          "primaryKey": false,
+          "notNull": true,
+          "default": "'fixed'"
+        },
+        "daily_reset_time": {
+          "name": "daily_reset_time",
+          "type": "varchar(5)",
+          "primaryKey": false,
+          "notNull": true,
+          "default": "'00:00'"
+        },
+        "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
+        },
+        "max_retry_attempts": {
+          "name": "max_retry_attempts",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "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": 0
+        },
+        "streaming_idle_timeout_ms": {
+          "name": "streaming_idle_timeout_ms",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "default": 0
+        },
+        "request_timeout_non_streaming_ms": {
+          "name": "request_timeout_non_streaming_ms",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "default": 0
+        },
+        "website_url": {
+          "name": "website_url",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "favicon_url": {
+          "name": "favicon_url",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "cache_ttl_preference": {
+          "name": "cache_ttl_preference",
+          "type": "varchar(10)",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "context_1m_preference": {
+          "name": "context_1m_preference",
+          "type": "varchar(20)",
+          "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.request_filters": {
+      "name": "request_filters",
+      "schema": "",
+      "columns": {
+        "id": {
+          "name": "id",
+          "type": "serial",
+          "primaryKey": true,
+          "notNull": true
+        },
+        "name": {
+          "name": "name",
+          "type": "varchar(100)",
+          "primaryKey": false,
+          "notNull": true
+        },
+        "description": {
+          "name": "description",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "scope": {
+          "name": "scope",
+          "type": "varchar(20)",
+          "primaryKey": false,
+          "notNull": true
+        },
+        "action": {
+          "name": "action",
+          "type": "varchar(30)",
+          "primaryKey": false,
+          "notNull": true
+        },
+        "match_type": {
+          "name": "match_type",
+          "type": "varchar(20)",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "target": {
+          "name": "target",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true
+        },
+        "replacement": {
+          "name": "replacement",
+          "type": "jsonb",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "priority": {
+          "name": "priority",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "default": 0
+        },
+        "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_request_filters_enabled": {
+          "name": "idx_request_filters_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": {}
+        },
+        "idx_request_filters_scope": {
+          "name": "idx_request_filters_scope",
+          "columns": [
+            {
+              "expression": "scope",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            }
+          ],
+          "isUnique": false,
+          "concurrently": false,
+          "method": "btree",
+          "with": {}
+        },
+        "idx_request_filters_action": {
+          "name": "idx_request_filters_action",
+          "columns": [
+            {
+              "expression": "action",
+              "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'"
+        },
+        "billing_model_source": {
+          "name": "billing_model_source",
+          "type": "varchar(20)",
+          "primaryKey": false,
+          "notNull": true,
+          "default": "'original'"
+        },
+        "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
+        },
+        "verbose_provider_error": {
+          "name": "verbose_provider_error",
+          "type": "boolean",
+          "primaryKey": false,
+          "notNull": true,
+          "default": false
+        },
+        "enable_http2": {
+          "name": "enable_http2",
+          "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
+        },
+        "tags": {
+          "name": "tags",
+          "type": "jsonb",
+          "primaryKey": false,
+          "notNull": false,
+          "default": "'[]'::jsonb"
+        },
+        "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_total_usd": {
+          "name": "limit_total_usd",
+          "type": "numeric(10, 2)",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "limit_concurrent_sessions": {
+          "name": "limit_concurrent_sessions",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "is_enabled": {
+          "name": "is_enabled",
+          "type": "boolean",
+          "primaryKey": false,
+          "notNull": true,
+          "default": true
+        },
+        "expires_at": {
+          "name": "expires_at",
+          "type": "timestamp with time zone",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "allowed_clients": {
+          "name": "allowed_clients",
+          "type": "jsonb",
+          "primaryKey": false,
+          "notNull": false,
+          "default": "'[]'::jsonb"
+        },
+        "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_enabled_expires_at": {
+          "name": "idx_users_enabled_expires_at",
+          "columns": [
+            {
+              "expression": "is_enabled",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            },
+            {
+              "expression": "expires_at",
+              "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": {
+    "public.daily_reset_mode": {
+      "name": "daily_reset_mode",
+      "schema": "public",
+      "values": [
+        "fixed",
+        "rolling"
+      ]
+    }
+  },
+  "schemas": {},
+  "sequences": {},
+  "roles": {},
+  "policies": {},
+  "views": {},
+  "_meta": {
+    "columns": {},
+    "schemas": {},
+    "tables": {}
+  }
+}

+ 1931 - 0
drizzle/meta/0036_snapshot.json

@@ -0,0 +1,1931 @@
+{
+  "id": "d991037a-f11d-420f-b508-439064ed9b06",
+  "prevId": "9be2ede5-422c-44a7-a144-3ef1ca483887",
+  "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
+        },
+        "override_response": {
+          "name": "override_response",
+          "type": "jsonb",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "override_status_code": {
+          "name": "override_status_code",
+          "type": "integer",
+          "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": false
+        },
+        "limit_5h_usd": {
+          "name": "limit_5h_usd",
+          "type": "numeric(10, 2)",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "limit_daily_usd": {
+          "name": "limit_daily_usd",
+          "type": "numeric(10, 2)",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "daily_reset_mode": {
+          "name": "daily_reset_mode",
+          "type": "daily_reset_mode",
+          "typeSchema": "public",
+          "primaryKey": false,
+          "notNull": true,
+          "default": "'fixed'"
+        },
+        "daily_reset_time": {
+          "name": "daily_reset_time",
+          "type": "varchar(5)",
+          "primaryKey": false,
+          "notNull": true,
+          "default": "'00:00'"
+        },
+        "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_total_usd": {
+          "name": "limit_total_usd",
+          "type": "numeric(10, 2)",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "limit_concurrent_sessions": {
+          "name": "limit_concurrent_sessions",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": false,
+          "default": 0
+        },
+        "provider_group": {
+          "name": "provider_group",
+          "type": "varchar(50)",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "cache_ttl_preference": {
+          "name": "cache_ttl_preference",
+          "type": "varchar(10)",
+          "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_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
+        },
+        "request_sequence": {
+          "name": "request_sequence",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": false,
+          "default": 1
+        },
+        "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
+        },
+        "cache_creation_5m_input_tokens": {
+          "name": "cache_creation_5m_input_tokens",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "cache_creation_1h_input_tokens": {
+          "name": "cache_creation_1h_input_tokens",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "cache_ttl_applied": {
+          "name": "cache_ttl_applied",
+          "type": "varchar(10)",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "context_1m_applied": {
+          "name": "context_1m_applied",
+          "type": "boolean",
+          "primaryKey": false,
+          "notNull": false,
+          "default": 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_session_seq": {
+          "name": "idx_message_request_session_seq",
+          "columns": [
+            {
+              "expression": "session_id",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            },
+            {
+              "expression": "request_sequence",
+              "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'"
+        },
+        "preserve_client_ip": {
+          "name": "preserve_client_ip",
+          "type": "boolean",
+          "primaryKey": false,
+          "notNull": true,
+          "default": false
+        },
+        "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'"
+        },
+        "mcp_passthrough_type": {
+          "name": "mcp_passthrough_type",
+          "type": "varchar(20)",
+          "primaryKey": false,
+          "notNull": true,
+          "default": "'none'"
+        },
+        "mcp_passthrough_url": {
+          "name": "mcp_passthrough_url",
+          "type": "varchar(512)",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "limit_5h_usd": {
+          "name": "limit_5h_usd",
+          "type": "numeric(10, 2)",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "limit_daily_usd": {
+          "name": "limit_daily_usd",
+          "type": "numeric(10, 2)",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "daily_reset_mode": {
+          "name": "daily_reset_mode",
+          "type": "daily_reset_mode",
+          "typeSchema": "public",
+          "primaryKey": false,
+          "notNull": true,
+          "default": "'fixed'"
+        },
+        "daily_reset_time": {
+          "name": "daily_reset_time",
+          "type": "varchar(5)",
+          "primaryKey": false,
+          "notNull": true,
+          "default": "'00:00'"
+        },
+        "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
+        },
+        "max_retry_attempts": {
+          "name": "max_retry_attempts",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "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": 0
+        },
+        "streaming_idle_timeout_ms": {
+          "name": "streaming_idle_timeout_ms",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "default": 0
+        },
+        "request_timeout_non_streaming_ms": {
+          "name": "request_timeout_non_streaming_ms",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "default": 0
+        },
+        "website_url": {
+          "name": "website_url",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "favicon_url": {
+          "name": "favicon_url",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "cache_ttl_preference": {
+          "name": "cache_ttl_preference",
+          "type": "varchar(10)",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "context_1m_preference": {
+          "name": "context_1m_preference",
+          "type": "varchar(20)",
+          "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.request_filters": {
+      "name": "request_filters",
+      "schema": "",
+      "columns": {
+        "id": {
+          "name": "id",
+          "type": "serial",
+          "primaryKey": true,
+          "notNull": true
+        },
+        "name": {
+          "name": "name",
+          "type": "varchar(100)",
+          "primaryKey": false,
+          "notNull": true
+        },
+        "description": {
+          "name": "description",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "scope": {
+          "name": "scope",
+          "type": "varchar(20)",
+          "primaryKey": false,
+          "notNull": true
+        },
+        "action": {
+          "name": "action",
+          "type": "varchar(30)",
+          "primaryKey": false,
+          "notNull": true
+        },
+        "match_type": {
+          "name": "match_type",
+          "type": "varchar(20)",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "target": {
+          "name": "target",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true
+        },
+        "replacement": {
+          "name": "replacement",
+          "type": "jsonb",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "priority": {
+          "name": "priority",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "default": 0
+        },
+        "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_request_filters_enabled": {
+          "name": "idx_request_filters_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": {}
+        },
+        "idx_request_filters_scope": {
+          "name": "idx_request_filters_scope",
+          "columns": [
+            {
+              "expression": "scope",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            }
+          ],
+          "isUnique": false,
+          "concurrently": false,
+          "method": "btree",
+          "with": {}
+        },
+        "idx_request_filters_action": {
+          "name": "idx_request_filters_action",
+          "columns": [
+            {
+              "expression": "action",
+              "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'"
+        },
+        "billing_model_source": {
+          "name": "billing_model_source",
+          "type": "varchar(20)",
+          "primaryKey": false,
+          "notNull": true,
+          "default": "'original'"
+        },
+        "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
+        },
+        "verbose_provider_error": {
+          "name": "verbose_provider_error",
+          "type": "boolean",
+          "primaryKey": false,
+          "notNull": true,
+          "default": false
+        },
+        "enable_http2": {
+          "name": "enable_http2",
+          "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
+        },
+        "tags": {
+          "name": "tags",
+          "type": "jsonb",
+          "primaryKey": false,
+          "notNull": false,
+          "default": "'[]'::jsonb"
+        },
+        "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_total_usd": {
+          "name": "limit_total_usd",
+          "type": "numeric(10, 2)",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "limit_concurrent_sessions": {
+          "name": "limit_concurrent_sessions",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "daily_reset_mode": {
+          "name": "daily_reset_mode",
+          "type": "daily_reset_mode",
+          "typeSchema": "public",
+          "primaryKey": false,
+          "notNull": true,
+          "default": "'fixed'"
+        },
+        "daily_reset_time": {
+          "name": "daily_reset_time",
+          "type": "varchar(5)",
+          "primaryKey": false,
+          "notNull": true,
+          "default": "'00:00'"
+        },
+        "is_enabled": {
+          "name": "is_enabled",
+          "type": "boolean",
+          "primaryKey": false,
+          "notNull": true,
+          "default": true
+        },
+        "expires_at": {
+          "name": "expires_at",
+          "type": "timestamp with time zone",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "allowed_clients": {
+          "name": "allowed_clients",
+          "type": "jsonb",
+          "primaryKey": false,
+          "notNull": false,
+          "default": "'[]'::jsonb"
+        },
+        "allowed_models": {
+          "name": "allowed_models",
+          "type": "jsonb",
+          "primaryKey": false,
+          "notNull": false,
+          "default": "'[]'::jsonb"
+        },
+        "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_enabled_expires_at": {
+          "name": "idx_users_enabled_expires_at",
+          "columns": [
+            {
+              "expression": "is_enabled",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            },
+            {
+              "expression": "expires_at",
+              "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": {
+    "public.daily_reset_mode": {
+      "name": "daily_reset_mode",
+      "schema": "public",
+      "values": [
+        "fixed",
+        "rolling"
+      ]
+    }
+  },
+  "schemas": {},
+  "sequences": {},
+  "roles": {},
+  "policies": {},
+  "views": {},
+  "_meta": {
+    "columns": {},
+    "schemas": {},
+    "tables": {}
+  }
+}

+ 21 - 0
drizzle/meta/_journal.json

@@ -246,6 +246,27 @@
       "when": 1765385500819,
       "tag": "0034_add-context-1m-support",
       "breakpoints": true
+    },
+    {
+      "idx": 35,
+      "version": "7",
+      "when": 1765719837757,
+      "tag": "0035_blushing_fabian_cortez",
+      "breakpoints": true
+    },
+    {
+      "idx": 36,
+      "version": "7",
+      "when": 1765993084329,
+      "tag": "0036_stale_iron_fist",
+      "breakpoints": true
+    },
+    {
+      "idx": 37,
+      "version": "7",
+      "when": 1766150400000,
+      "tag": "0037_cursor_pagination_index",
+      "breakpoints": true
     }
   ]
 }

+ 3 - 0
messages/en/common.json

@@ -19,12 +19,15 @@
   "reset": "Reset",
   "view": "View",
   "copy": "Copy",
+  "copySuccess": "Copied",
+  "copyFailed": "Copy failed",
   "download": "Download",
   "upload": "Upload",
   "add": "Add",
   "remove": "Remove",
   "apply": "Apply",
   "clear": "Clear",
+  "clearDate": "Clear Date",
   "ok": "OK",
   "yes": "Yes",
   "no": "No",

+ 389 - 5
messages/en/dashboard.json

@@ -120,7 +120,11 @@
       "nextPage": "Next Page",
       "blocked": "Blocked",
       "nonBilling": "Non-Billing",
-      "times": "times"
+      "times": "times",
+      "loadedCount": "Loaded {count} records",
+      "loadingMore": "Loading more...",
+      "noMoreData": "All records loaded",
+      "scrollToTop": "Back to top"
     },
     "actions": {
       "refresh": "Refresh",
@@ -130,7 +134,8 @@
       "view": "View"
     },
     "error": {
-      "loadFailed": "Load Failed"
+      "loadFailed": "Load Failed",
+      "loadKeysFailed": "Failed to load keys"
     },
     "details": {
       "title": "Request Details",
@@ -450,7 +455,6 @@
     "byName": "By Name",
     "byUsageRate": "By Usage Rate"
   },
-  "all": "All",
   "nav": {
     "mobileMenuTitle": "Navigation Menu",
     "dashboard": "Dashboard",
@@ -579,6 +583,11 @@
   },
   "keyListHeader": {
     "todayUsage": "Today's Usage",
+    "allowedModels": {
+      "label": "Allowed Models",
+      "noRestrictions": "Allowed Models: No restrictions"
+    },
+    "expiresAt": "Expires",
     "proxyStatus": {
       "loading": "Loading proxy status",
       "fetchFailed": "Failed to fetch proxy status",
@@ -605,6 +614,10 @@
       "showTooltip": "Show full key",
       "hideTooltip": "Hide key",
       "closeButton": "Close"
+    },
+    "allowedClients": {
+      "label": "Clients allowed",
+      "noRestrictions": "Clients allowed: no restrictions"
     }
   },
   "keyLimitUsage": {
@@ -744,6 +757,7 @@
     "dailyQuota": {
       "label": "Daily Quota",
       "placeholder": "Daily consumption quota limit",
+      "helperText": "Set to 0 for unlimited",
       "description": "Default: ${default}, Range: $0.01-$1000"
     },
     "limit5hUsd": {
@@ -779,6 +793,17 @@
       "label": "Expiration Date",
       "placeholder": "Leave empty for never expires",
       "description": "User will be automatically disabled after expiration"
+    },
+    "allowedClients": {
+      "label": "Allowed Clients",
+      "description": "Restrict which CLI/IDE clients can use this account. Leave empty for no restrictions.",
+      "customLabel": "Custom Client Patterns",
+      "customPlaceholder": "Enter custom pattern (e.g., 'xcode', 'my-ide')"
+    },
+    "allowedModels": {
+      "label": "Allowed Models",
+      "placeholder": "Enter model name (press Enter to add)",
+      "description": "Restrict this user to specific AI models. Leave empty for no restrictions (max 50 models, 64 chars each)"
     }
   },
   "deleteKeyConfirm": {
@@ -808,11 +833,14 @@
     "title": "User Management",
     "description": "Showing {count} users",
     "toolbar": {
-      "searchPlaceholder": "Search by username...",
+      "searchPlaceholder": "Search name, note, tags, keys...",
       "groupFilter": "Filter by Group",
       "allGroups": "All Groups",
       "tagFilter": "Filter by Tag",
-      "allTags": "All Tags"
+      "allTags": "All Tags",
+      "keyGroupFilter": "Key Group",
+      "allKeyGroups": "All Key Groups",
+      "createUser": "Create User"
     }
   },
   "availability": {
@@ -926,5 +954,361 @@
       "refreshSuccess": "Availability data refreshed",
       "refreshFailed": "Refresh failed, please retry"
     }
+  },
+  "userManagement": {
+    "table": {
+      "columns": {
+        "username": "Username",
+        "note": "Note",
+        "expiresAt": "Expires at",
+        "expiresAtHint": "Click to quick renew",
+        "limit5h": "5h limit",
+        "limitDaily": "Daily limit",
+        "limitWeekly": "Weekly limit",
+        "limitMonthly": "Monthly limit",
+        "limitTotal": "Total limit",
+        "limitSessions": "Concurrent sessions"
+      },
+      "keyRow": {
+        "name": "Key name",
+        "key": "Key",
+        "group": "Group",
+        "todayUsage": "Today's usage",
+        "todayCost": "Today's cost",
+        "lastUsed": "Last used",
+        "actions": "Actions",
+        "quotaButton": "View Quota Usage",
+        "fields": {
+          "callsLabel": "Calls",
+          "costLabel": "Cost"
+        }
+      },
+      "expand": "Expand",
+      "collapse": "Collapse",
+      "noKeys": "No keys",
+      "defaultGroup": "default",
+      "userStatus": {
+        "disabled": "Disabled"
+      }
+    },
+    "keyFullDisplay": {
+      "title": "Full Key",
+      "copySuccess": "Key copied to clipboard",
+      "copyFailed": "Copy failed",
+      "show": "Show key",
+      "hide": "Hide key",
+      "copied": "Copied",
+      "copy": "Copy key"
+    },
+    "keyStatsDialog": {
+      "title": "Today's Model Statistics",
+      "columns": {
+        "model": "Model",
+        "calls": "Calls",
+        "cost": "Cost"
+      },
+      "noData": "No usage records today",
+      "totalCalls": "Total Calls",
+      "totalCost": "Total Cost"
+    },
+    "keyQuotaUsageDialog": {
+      "title": "Key Quota Usage",
+      "fetchFailed": "Failed to fetch quota usage",
+      "noLimit": "No limit",
+      "modeFixed": "Fixed time",
+      "modeRolling": "Rolling 24h",
+      "retry": "Retry",
+      "labels": {
+        "limit5h": "5 Hours",
+        "limitDaily": "Daily",
+        "limitWeekly": "Weekly",
+        "limitMonthly": "Monthly",
+        "limitTotal": "Total",
+        "limitSessions": "Concurrent"
+      }
+    },
+    "quickRenew": {
+      "title": "Quick Renew",
+      "description": "Set a new expiration date for user {userName}",
+      "currentExpiry": "Current Expiration",
+      "neverExpires": "Never expires",
+      "expired": "Expired",
+      "quickOptions": {
+        "7days": "7 Days",
+        "30days": "30 Days",
+        "90days": "90 Days",
+        "1year": "1 Year"
+      },
+      "customDate": "Custom Date",
+      "enableOnRenew": "Also enable user",
+      "cancel": "Cancel",
+      "confirm": "Confirm Renewal",
+      "confirming": "Renewing...",
+      "success": "Renewal successful",
+      "failed": "Renewal failed"
+    },
+    "editDialog": {
+      "title": "Edit user and keys",
+      "description": "Edit user information and API key settings",
+      "userSection": "User settings",
+      "keysSection": "Key settings",
+      "scrollToKey": "Scroll to key",
+      "saveFailed": "Failed to save user",
+      "keySaveFailed": "Failed to save key",
+      "keyDeleteFailed": "Failed to delete key",
+      "saveSuccess": "Changes saved successfully",
+      "operationFailed": "Operation failed",
+      "userDisabled": "User has been disabled",
+      "userEnabled": "User has been enabled",
+      "deleteFailed": "Failed to delete user",
+      "userDeleted": "User has been deleted",
+      "saving": "Saving..."
+    },
+    "createDialog": {
+      "title": "Create user",
+      "description": "Create a new user and configure API keys",
+      "saveFailed": "Failed to create user",
+      "keyCreateFailed": "Failed to create key",
+      "createSuccess": "User created successfully",
+      "keysSection": "Keys",
+      "addKey": "Add key",
+      "removeKey": "Remove key",
+      "cannotDeleteLastKey": "Cannot delete the last key",
+      "confirmRemoveKeyTitle": "Remove key",
+      "confirmRemoveKeyDescription": "Are you sure you want to remove the key \"{name}\"?",
+      "creating": "Creating...",
+      "create": "Create"
+    },
+    "onboarding": {
+      "skip": "Skip",
+      "next": "Next",
+      "prev": "Previous",
+      "finish": "Start Creating",
+      "stepIndicator": "Step {current} of {total}",
+      "steps": {
+        "welcome": {
+          "title": "Users & Keys",
+          "description": "Users are the main entities for API access. Each user can have multiple API Keys. User-level limits affect all keys, while key-level limits provide finer control."
+        },
+        "limits": {
+          "title": "Limit Management",
+          "description": "Six types of limits are supported: 5-hour, daily, weekly, monthly, total, and concurrent sessions. Add limit rules as needed to flexibly control usage."
+        },
+        "groups": {
+          "title": "Provider Groups",
+          "description": "Use the group feature to restrict users to specific providers. Key-level groups take priority over user-level groups."
+        },
+        "keyFeatures": {
+          "title": "Key Features",
+          "description": "Each key can be independently configured with Cache TTL override, Web UI login permission, and other advanced features."
+        }
+      }
+    },
+    "limitRules": {
+      "addRule": "Add limit rule",
+      "ruleTypes": {
+        "limit5h": "5-hour limit",
+        "limitDaily": "Daily limit",
+        "limitWeekly": "Weekly limit",
+        "limitMonthly": "Monthly limit",
+        "limitTotal": "Total limit",
+        "limitSessions": "Concurrent sessions"
+      },
+      "dailyMode": {
+        "fixed": "Fixed reset time",
+        "rolling": "Rolling window (24h)"
+      },
+      "quickValues": {
+        "10": "$10",
+        "50": "$50",
+        "100": "$100",
+        "500": "$500"
+      },
+      "alreadySet": "Configured",
+      "confirmAdd": "Add",
+      "cancel": "Cancel"
+    },
+    "quickExpire": {
+      "oneWeek": "In 1 week",
+      "oneMonth": "In 1 month",
+      "threeMonths": "In 3 months",
+      "oneYear": "In 1 year"
+    },
+    "providerGroup": {
+      "label": "Provider group",
+      "placeholder": "Select provider group",
+      "noRestriction": "No restriction (all providers)",
+      "providerCount": "{count} providers"
+    },
+    "dangerZone": {
+      "title": "Danger Zone",
+      "description": "The following actions are irreversible, proceed with caution",
+      "enable": {
+        "title": "Enable User",
+        "description": "After enabling, this user and their keys will resume normal operation",
+        "trigger": "Enable",
+        "confirm": "Confirm Enable",
+        "confirmDescription": "Are you sure you want to enable user \"{userName}\"?",
+        "loading": "Processing..."
+      },
+      "disable": {
+        "title": "Disable User",
+        "description": "After disabling, this user and their keys will no longer be usable",
+        "trigger": "Disable",
+        "confirm": "Confirm Disable",
+        "confirmDescription": "Are you sure you want to disable user \"{userName}\"?",
+        "loading": "Processing..."
+      },
+      "delete": {
+        "title": "Delete User",
+        "description": "This will delete all associated data for this user, this action cannot be undone",
+        "trigger": "Delete",
+        "confirm": "Confirm Delete",
+        "confirmDescription": "This action will delete all associated data for user \"{userName}\" and cannot be undone.",
+        "confirmLabel": "Secondary Confirmation",
+        "confirmHint": "Please type \"{userName}\" to confirm deletion",
+        "loading": "Deleting..."
+      },
+      "actions": {
+        "cancel": "Cancel"
+      },
+      "errors": {
+        "enableFailed": "Failed to enable user, please try again later",
+        "disableFailed": "Failed to disable user, please try again later",
+        "deleteFailed": "Failed to delete user, please try again later"
+      }
+    },
+    "limitIndicator": {
+      "notSet": "Not set",
+      "unlimited": "Unlimited"
+    },
+    "keySettings": {
+      "balanceQueryPage": {
+        "label": "Enable dedicated balance page",
+        "description": "Allow users to view their balance via a dedicated page",
+        "descriptionEnabled": "When enabled, this key will access an independent personal usage page upon login. However, it cannot modify its own key's provider group.",
+        "descriptionDisabled": "When disabled, the user cannot access the personal usage page UI. However, they can modify their own key's provider group in the restricted Web UI."
+      },
+      "cacheTtlOverride": {
+        "label": "Cache TTL override",
+        "inherit": "No override (follow provider/client)",
+        "5m": "5 minutes",
+        "1h": "1 hour"
+      }
+    },
+    "pagination": {
+      "previous": "Previous",
+      "next": "Next",
+      "page": "Page {current}",
+      "of": "of {total}"
+    },
+    "toolbar": {
+      "expandAll": "Expand all",
+      "collapseAll": "Collapse all"
+    },
+    "keyStatus": {
+      "enabled": "Enabled",
+      "disabled": "Disabled"
+    },
+    "userEditSection": {
+      "sections": {
+        "basicInfo": "Basic Information",
+        "expireTime": "Expiration Time",
+        "limitRules": "Limit Rules",
+        "accessRestrictions": "Access Restrictions"
+      },
+      "fields": {
+        "username": {
+          "label": "Username",
+          "placeholder": "Enter username"
+        },
+        "description": {
+          "label": "Note",
+          "placeholder": "Enter note (optional)"
+        },
+        "tags": {
+          "label": "User Tags",
+          "placeholder": "Enter tag (press Enter to add)"
+        },
+        "providerGroup": {
+          "label": "Provider Group (Legacy)",
+          "placeholder": "Enter provider group or leave empty"
+        },
+        "allowedClients": {
+          "label": "Client Restrictions",
+          "description": "Restrict which CLI/IDE clients can use this account. Empty = no restriction.",
+          "customLabel": "Custom Client Pattern",
+          "customPlaceholder": "Enter pattern (e.g., 'xcode', 'my-ide')"
+        },
+        "allowedModels": {
+          "label": "Model Restrictions",
+          "placeholder": "Enter model name or select from dropdown",
+          "description": "Restrict which AI models this user can access. Empty = no restriction."
+        },
+        "enableStatus": {
+          "label": "Enable Status",
+          "enabledDescription": "Currently enabled. Disabling will prevent this user and their keys from being used.",
+          "disabledDescription": "Currently disabled. Enabling will restore normal access for this user and their keys.",
+          "confirmDisable": "Confirm Disable",
+          "confirmEnable": "Confirm Enable"
+        }
+      },
+      "presetClients": {
+        "claude-cli": "Claude Code CLI",
+        "gemini-cli": "Gemini CLI",
+        "factory-cli": "Droid CLI",
+        "codex-cli": "Codex CLI"
+      }
+    },
+    "keyEditSection": {
+      "sections": {
+        "basicInfo": "Basic Information",
+        "expireTime": "Expiration Time",
+        "limitRules": "Limit Rules",
+        "specialFeatures": "Special Features"
+      },
+      "fields": {
+        "keyName": {
+          "label": "Key Name",
+          "placeholder": "Enter key name"
+        },
+        "enableStatus": {
+          "label": "Enable Status",
+          "description": "Disabled keys cannot be used"
+        },
+        "balanceQueryPage": {
+          "label": "Independent Personal Usage Page",
+          "description": "When enabled, this key can access an independent personal usage page",
+          "descriptionEnabled": "When enabled, this key will access an independent personal usage page upon login. However, it cannot modify its own key's provider group.",
+          "descriptionDisabled": "When disabled, the user cannot access the personal usage page UI. However, they can modify their own key's provider group in the restricted Web UI."
+        },
+        "providerGroup": {
+          "label": "Provider Group",
+          "placeholder": "Leave empty to allow all default group providers"
+        },
+        "cacheTtl": {
+          "label": "Cache TTL Override",
+          "options": {
+            "inherit": "No override (follow provider/client)",
+            "5m": "5m",
+            "1h": "1h"
+          }
+        }
+      },
+      "limitRules": {
+        "title": "Add limit rule",
+        "actions": {
+          "add": "Add rule",
+          "remove": "Remove"
+        },
+        "daily": {
+          "mode": {
+            "fixed": "Fixed time reset",
+            "rolling": "Rolling window (24h)"
+          }
+        },
+        "overwriteHint": "This type already exists, saving will overwrite the existing value"
+      }
+    }
   }
 }

+ 2 - 2
messages/en/quota.json

@@ -282,8 +282,8 @@
         "description": "Leave blank for never expires"
       },
       "canLoginWebUi": {
-        "label": "Allow Web UI Login",
-        "description": "When disabled, this key can only be used for API calls and cannot login to the admin panel"
+        "label": "Enable dedicated balance page",
+        "description": "Allow users to view their balance via a dedicated page"
       },
       "limit5hUsd": {
         "label": "5-Hour Cost Limit (USD)",

+ 3 - 0
messages/ja/common.json

@@ -19,12 +19,15 @@
   "reset": "リセット",
   "view": "表示",
   "copy": "コピー",
+  "copySuccess": "コピーしました",
+  "copyFailed": "コピーに失敗しました",
   "download": "ダウンロード",
   "upload": "アップロード",
   "add": "追加",
   "remove": "削除",
   "apply": "適用",
   "clear": "クリア",
+  "clearDate": "日付をクリア",
   "ok": "OK",
   "yes": "はい",
   "no": "いいえ",

+ 401 - 12
messages/ja/dashboard.json

@@ -120,7 +120,11 @@
       "nextPage": "次へ",
       "blocked": "ブロック済み",
       "nonBilling": "非課金",
-      "times": "回"
+      "times": "回",
+      "loadedCount": "{count} 件のレコードを読み込みました",
+      "loadingMore": "読み込み中...",
+      "noMoreData": "すべてのレコードを読み込みました",
+      "scrollToTop": "トップへ戻る"
     },
     "actions": {
       "refresh": "更新",
@@ -130,7 +134,8 @@
       "view": "表示"
     },
     "error": {
-      "loadFailed": "読み込み失敗"
+      "loadFailed": "読み込み失敗",
+      "loadKeysFailed": "キーリストの読み込みに失敗しました"
     },
     "details": {
       "title": "リクエスト詳細",
@@ -422,10 +427,7 @@
     },
     "labels": {
       "byName": "名前順",
-      "byUsageRate": "使用率順",
-      "all": "すべて",
-      "warning": "制限に近い (>60%)",
-      "exceeded": "超過 (≥100%)"
+      "byUsageRate": "使用率順"
     },
     "users": {
       "title": "ユーザー クォータ統計",
@@ -572,10 +574,15 @@
   },
   "keyListHeader": {
     "todayUsage": "本日の使用量",
+    "allowedModels": {
+      "label": "許可モデル",
+      "noRestrictions": "許可されたクライアント:制限なし"
+    },
+    "expiresAt": "有効期限",
     "proxyStatus": {
-      "loading": "プロキシステータス読み込み中",
-      "fetchFailed": "プロキシステータスの取得に失敗しました",
-      "noStatus": "プロキシステータスなし",
+      "loading": "プロキシステータス読み込み中",
+      "fetchFailed": "プロキシステータスの取得に失敗しました",
+      "noStatus": "プロキシステータスなし",
       "activeRequests": "アクティブリクエスト",
       "lastRequest": "最新リクエスト",
       "noRecord": "記録なし",
@@ -598,6 +605,10 @@
       "showTooltip": "完全なキーを表示",
       "hideTooltip": "キーを非表示",
       "closeButton": "閉じる"
+    },
+    "allowedClients": {
+      "label": "許可されたクライアント",
+      "noRestrictions": "許可されたクライアント:制限なし"
     }
   },
   "keyLimitUsage": {
@@ -667,7 +678,7 @@
     },
     "limitConcurrentSessions": {
       "label": "同時セッション上限",
-      "placeholder": "0 は無制限を意味します",
+      "placeholder": "0は無制限を意味します",
       "description": "同時に実行される会話の数"
     },
     "errors": {
@@ -720,6 +731,7 @@
     "dailyQuota": {
       "label": "1日あたりの割当量",
       "placeholder": "1日あたりの消費割当量制限",
+      "helperText": "0に設定すると無制限になります",
       "description": "デフォルト値: ${default}、範囲: $0.01-$1000"
     },
     "limit5hUsd": {
@@ -741,6 +753,26 @@
       "label": "同時セッション上限",
       "placeholder": "0は無制限を意味します",
       "description": "同時に実行される会話の数"
+    },
+    "isEnabled": {
+      "label": "ユーザーを有効化",
+      "description": "無効にするとユーザーはAPIにアクセスできなくなります"
+    },
+    "expiresAt": {
+      "label": "有効期限",
+      "placeholder": "空白の場合は無期限",
+      "description": "有効期限切れ後、ユーザーは自動的に無効化されます"
+    },
+    "allowedClients": {
+      "label": "許可されたクライアント",
+      "description": "このアカウントを使用できるCLI/IDEクライアントを制限します。空の場合は制限なし。",
+      "customLabel": "カスタムクライアントパターン",
+      "customPlaceholder": "カスタムパターンを入力(例:'xcode', 'my-ide')"
+    },
+    "allowedModels": {
+      "label": "許可モデル",
+      "placeholder": "モデル名を入力(Enterで追加)",
+      "description": "ユーザーを特定のAIモデルに制限します。空白の場合は制限なし(最大50モデル、各64文字)"
     }
   },
   "deleteKeyConfirm": {
@@ -770,11 +802,14 @@
     "title": "ユーザー管理",
     "description": "{count} 人のユーザーを表示中",
     "toolbar": {
-      "searchPlaceholder": "ユーザー名で検索...",
+      "searchPlaceholder": "名前、メモ、タグ、キーで検索...",
       "groupFilter": "グループでフィルター",
       "allGroups": "すべてのグループ",
       "tagFilter": "タグでフィルター",
-      "allTags": "すべてのタグ"
+      "allTags": "すべてのタグ",
+      "keyGroupFilter": "キーグループ",
+      "allKeyGroups": "すべてのキーグループ",
+      "createUser": "ユーザーを作成"
     }
   },
   "availability": {
@@ -888,5 +923,359 @@
       "refreshSuccess": "可用性データを更新しました",
       "refreshFailed": "更新に失敗しました。再試行してください"
     }
+  },
+  "userManagement": {
+    "table": {
+      "columns": {
+        "username": "ユーザー名",
+        "note": "メモ",
+        "expiresAt": "有効期限",
+        "expiresAtHint": "クリックで期限を延長",
+        "limit5h": "5時間上限",
+        "limitDaily": "日次上限",
+        "limitWeekly": "週次上限",
+        "limitMonthly": "月次上限",
+        "limitTotal": "総上限",
+        "limitSessions": "同時セッション"
+      },
+      "keyRow": {
+        "name": "キー名",
+        "key": "キー",
+        "group": "グループ",
+        "todayUsage": "本日の使用量",
+        "todayCost": "本日の消費",
+        "lastUsed": "最終使用",
+        "actions": "操作",
+        "quotaButton": "クォータ使用状況を表示",
+        "fields": {
+          "callsLabel": "呼び出し",
+          "costLabel": "消費"
+        }
+      },
+      "expand": "展開",
+      "collapse": "折りたたむ",
+      "noKeys": "キーなし",
+      "defaultGroup": "default",
+      "userStatus": {
+        "disabled": "無効"
+      }
+    },
+    "keyFullDisplay": {
+      "title": "完全なキー",
+      "copySuccess": "キーがクリップボードにコピーされました",
+      "copyFailed": "コピーに失敗しました",
+      "show": "キーを表示",
+      "hide": "キーを非表示",
+      "copied": "コピー済み",
+      "copy": "キーをコピー"
+    },
+    "keyStatsDialog": {
+      "title": "本日のモデル統計",
+      "columns": {
+        "model": "モデル",
+        "calls": "呼び出し回数",
+        "cost": "消費金額"
+      },
+      "noData": "本日の使用記録はありません",
+      "totalCalls": "総呼び出し数",
+      "totalCost": "総消費"
+    },
+    "keyQuotaUsageDialog": {
+      "title": "Key クォータ使用状況",
+      "fetchFailed": "クォータ使用状況の取得に失敗しました",
+      "noLimit": "制限なし",
+      "modeFixed": "固定時間",
+      "modeRolling": "ローリング24h",
+      "retry": "再試行",
+      "labels": {
+        "limit5h": "5 時間",
+        "limitDaily": "日次",
+        "limitWeekly": "週次",
+        "limitMonthly": "月次",
+        "limitTotal": "合計",
+        "limitSessions": "同時接続"
+      }
+    },
+    "quickRenew": {
+      "title": "クイック更新",
+      "description": "ユーザー {userName} の新しい有効期限を設定",
+      "currentExpiry": "現在の有効期限",
+      "neverExpires": "無期限",
+      "expired": "期限切れ",
+      "quickOptions": {
+        "7days": "7 日",
+        "30days": "30 日",
+        "90days": "90 日",
+        "1year": "1 年"
+      },
+      "customDate": "カスタム日付",
+      "enableOnRenew": "同時にユーザーを有効化",
+      "cancel": "キャンセル",
+      "confirm": "更新を確認",
+      "confirming": "更新中...",
+      "success": "更新に成功しました",
+      "failed": "更新に失敗しました"
+    },
+    "editDialog": {
+      "title": "ユーザーとキーを編集",
+      "description": "ユーザー情報とAPIキーの設定を編集",
+      "userSection": "ユーザー設定",
+      "keysSection": "キー設定",
+      "scrollToKey": "キーへスクロール",
+      "saveFailed": "ユーザーの保存に失敗しました",
+      "keySaveFailed": "キーの保存に失敗しました",
+      "keyDeleteFailed": "キーの削除に失敗しました",
+      "saveSuccess": "変更が保存されました",
+      "operationFailed": "操作に失敗しました",
+      "userDisabled": "ユーザーが無効化されました",
+      "userEnabled": "ユーザーが有効化されました",
+      "deleteFailed": "ユーザーの削除に失敗しました",
+      "userDeleted": "ユーザーが削除されました",
+      "saving": "保存中..."
+    },
+    "createDialog": {
+      "title": "ユーザーを作成",
+      "description": "新規ユーザーを作成し、APIキーを設定",
+      "saveFailed": "ユーザーの作成に失敗しました",
+      "keyCreateFailed": "キーの作成に失敗しました",
+      "createSuccess": "ユーザーが作成されました",
+      "keysSection": "キー",
+      "addKey": "キーを追加",
+      "removeKey": "キーを削除",
+      "cannotDeleteLastKey": "最後のキーは削除できません",
+      "confirmRemoveKeyTitle": "キーを削除",
+      "confirmRemoveKeyDescription": "キー \"{name}\" を削除してもよろしいですか?",
+      "creating": "作成中...",
+      "create": "作成"
+    },
+    "onboarding": {
+      "skip": "スキップ",
+      "next": "次へ",
+      "prev": "前へ",
+      "finish": "作成を開始",
+      "stepIndicator": "ステップ {current} / {total}",
+      "steps": {
+        "welcome": {
+          "title": "ユーザーとキー",
+          "description": "ユーザーは API アクセスの主体です。各ユーザーは複数の API キーを持つことができます。ユーザーレベルの上限はすべてのキーに影響し、キーレベルの上限でより細かく制御できます。"
+        },
+        "limits": {
+          "title": "上限管理",
+          "description": "6 種類の上限をサポート:5 時間、日次、週次、月次、総上限、同時セッション。上限ルールを必要に応じて追加し、柔軟に使用量を制御できます。"
+        },
+        "groups": {
+          "title": "プロバイダーグループ",
+          "description": "グループ機能を使用して、ユーザーが特定のプロバイダーのみを使用するよう制限できます。キーレベルのグループはユーザーレベルのグループより優先されます。"
+        },
+        "keyFeatures": {
+          "title": "キーの特殊機能",
+          "description": "各キーは、キャッシュ TTL の上書き、Web UI へのログイン許可など、高度な機能を個別に設定できます。"
+        }
+      }
+    },
+    "limitRules": {
+      "addRule": "上限ルールを追加",
+      "ruleTypes": {
+        "limit5h": "5時間上限",
+        "limitDaily": "日次上限",
+        "limitWeekly": "週次上限",
+        "limitMonthly": "月次上限",
+        "limitTotal": "総上限",
+        "limitSessions": "同時セッション"
+      },
+      "dailyMode": {
+        "fixed": "固定時刻でリセット",
+        "rolling": "ローリングウィンドウ(24h)"
+      },
+      "quickValues": {
+        "10": "$10",
+        "50": "$50",
+        "100": "$100",
+        "500": "$500"
+      },
+      "alreadySet": "設定済み",
+      "confirmAdd": "追加",
+      "cancel": "キャンセル"
+    },
+    "quickExpire": {
+      "oneWeek": "1週間後",
+      "oneMonth": "1か月後",
+      "threeMonths": "3か月後",
+      "oneYear": "1年後"
+    },
+    "providerGroup": {
+      "label": "プロバイダーグループ",
+      "placeholder": "プロバイダーグループを選択",
+      "noRestriction": "制限なし(すべてのプロバイダー)",
+      "providerCount": "{count} 件のプロバイダー"
+    },
+    "dangerZone": {
+      "title": "危険な操作",
+      "description": "以下の操作は元に戻せません、慎重に実行してください",
+      "enable": {
+        "title": "ユーザーを有効化",
+        "description": "有効化すると、このユーザーとそのキーは正常に使用できるようになります",
+        "trigger": "有効化",
+        "confirm": "有効化を確認",
+        "confirmDescription": "ユーザー \"{userName}\" を有効化してもよろしいですか?",
+        "loading": "処理中..."
+      },
+      "disable": {
+        "title": "ユーザーを無効化",
+        "description": "無効化すると、このユーザーとそのキーは使用できなくなります",
+        "trigger": "無効化",
+        "confirm": "無効化を確認",
+        "confirmDescription": "ユーザー \"{userName}\" を無効化してもよろしいですか?",
+        "loading": "処理中..."
+      },
+      "delete": {
+        "title": "ユーザーを削除",
+        "description": "このユーザーに関連するすべてのデータが削除されます、この操作は元に戻せません",
+        "trigger": "削除",
+        "confirm": "削除を確認",
+        "confirmDescription": "この操作はユーザー \"{userName}\" のすべての関連データを削除し、元に戻せません。",
+        "confirmLabel": "二次確認",
+        "confirmHint": "削除を確認するには \"{userName}\" と入力してください",
+        "loading": "削除中..."
+      },
+      "actions": {
+        "cancel": "キャンセル"
+      },
+      "errors": {
+        "enableFailed": "ユーザーの有効化に失敗しました、後でもう一度お試しください",
+        "disableFailed": "ユーザーの無効化に失敗しました、後でもう一度お試しください",
+        "deleteFailed": "ユーザーの削除に失敗しました、後でもう一度お試しください"
+      }
+    },
+    "limitIndicator": {
+      "notSet": "未設定",
+      "unlimited": "無制限"
+    },
+    "keySettings": {
+      "balanceQueryPage": {
+        "label": "専用の残高確認ページを有効化",
+        "description": "専用ページから残高を確認できるようにします"
+      },
+      "cacheTtlOverride": {
+        "label": "Cache TTL の上書き",
+        "inherit": "上書きしない(プロバイダー/クライアントに従う)",
+        "5m": "5分",
+        "1h": "1時間"
+      }
+    },
+    "pagination": {
+      "previous": "前へ",
+      "next": "次へ",
+      "page": "第 {current} ページ",
+      "of": "全 {total} ページ"
+    },
+    "toolbar": {
+      "expandAll": "すべて展開",
+      "collapseAll": "すべて折りたたむ"
+    },
+    "keyStatus": {
+      "enabled": "有効",
+      "disabled": "無効"
+    },
+    "userEditSection": {
+      "sections": {
+        "basicInfo": "基本情報",
+        "expireTime": "有効期限",
+        "limitRules": "制限ルール",
+        "accessRestrictions": "アクセス制限"
+      },
+      "fields": {
+        "username": {
+          "label": "ユーザー名",
+          "placeholder": "ユーザー名を入力してください"
+        },
+        "description": {
+          "label": "メモ",
+          "placeholder": "メモを入力(任意)"
+        },
+        "tags": {
+          "label": "ユーザータグ",
+          "placeholder": "タグを入力(Enterで追加)"
+        },
+        "providerGroup": {
+          "label": "プロバイダーグループ(レガシー)",
+          "placeholder": "プロバイダーグループを入力または空白のまま"
+        },
+        "allowedClients": {
+          "label": "クライアント制限",
+          "description": "このアカウントを使用できるCLI/IDEクライアントを制限します。空欄は制限なし。",
+          "customLabel": "カスタムクライアントパターン",
+          "customPlaceholder": "パターンを入力(例:'xcode', 'my-ide')"
+        },
+        "allowedModels": {
+          "label": "モデル制限",
+          "placeholder": "モデル名を入力またはドロップダウンから選択",
+          "description": "ユーザーがアクセスできるAIモデルを制限します。空欄は制限なし。"
+        },
+        "enableStatus": {
+          "label": "有効状態",
+          "enabledDescription": "現在有効です。無効にすると、このユーザーとそのキーは使用できなくなります。",
+          "disabledDescription": "現在無効です。有効にすると、このユーザーとそのキーが通常通り使用できるようになります。",
+          "confirmDisable": "無効化を確認",
+          "confirmEnable": "有効化を確認"
+        }
+      },
+      "presetClients": {
+        "claude-cli": "Claude Code CLI",
+        "gemini-cli": "Gemini CLI",
+        "factory-cli": "Droid CLI",
+        "codex-cli": "Codex CLI"
+      }
+    },
+    "keyEditSection": {
+      "sections": {
+        "basicInfo": "基本情報",
+        "expireTime": "有効期限",
+        "limitRules": "制限ルール",
+        "specialFeatures": "特殊機能"
+      },
+      "fields": {
+        "keyName": {
+          "label": "キー名",
+          "placeholder": "キー名を入力してください"
+        },
+        "enableStatus": {
+          "label": "有効状態",
+          "description": "無効化されたキーは使用できません"
+        },
+        "balanceQueryPage": {
+          "label": "独立した個人使用量ページ",
+          "description": "有効にすると、このキーで独立した個人使用量ページにアクセスできます",
+          "descriptionEnabled": "有効にすると、このキーはログイン時に独立した個人使用量ページにアクセスします。ただし、自分のキーのプロバイダーグループは変更できません。",
+          "descriptionDisabled": "無効にすると、ユーザーは個人使用量ページUIにアクセスできません。ただし、制限されたWeb UIで自分のキーのプロバイダーグループを変更できます。"
+        },
+        "providerGroup": {
+          "label": "プロバイダーグループ",
+          "placeholder": "空欄の場合は全ての default グループのプロバイダーを許可"
+        },
+        "cacheTtl": {
+          "label": "Cache TTL上書き",
+          "options": {
+            "inherit": "上書きしない(プロバイダー/クライアントに従う)",
+            "5m": "5m",
+            "1h": "1h"
+          }
+        }
+      },
+      "limitRules": {
+        "title": "制限ルールを追加",
+        "actions": {
+          "add": "ルールを追加",
+          "remove": "削除"
+        },
+        "daily": {
+          "mode": {
+            "fixed": "固定時間リセット",
+            "rolling": "ローリングウィンドウ(24時間)"
+          }
+        },
+        "overwriteHint": "このタイプは既に存在します。保存すると既存の値が上書きされます"
+      }
+    }
   }
 }

+ 2 - 2
messages/ja/quota.json

@@ -259,8 +259,8 @@
         "description": "空欄の場合は無期限"
       },
       "canLoginWebUi": {
-        "label": "Web UI へのログインを許可",
-        "description": "無効にすると、このキーは API 呼び出しにのみ使用でき、管理画面にはログインできません"
+        "label": "専用の残高確認ページを有効化",
+        "description": "専用ページから残高を確認できるようにします"
       },
       "limit5hUsd": {
         "label": "5時間消費上限 (USD)",

+ 3 - 0
messages/ru/common.json

@@ -19,12 +19,15 @@
   "reset": "Сброс",
   "view": "Просмотреть",
   "copy": "Копировать",
+  "copySuccess": "Скопировано",
+  "copyFailed": "Не удалось скопировать",
   "download": "Скачать",
   "upload": "Загрузить",
   "add": "Добавить",
   "remove": "Удалить",
   "apply": "Применить",
   "clear": "Очистить",
+  "clearDate": "Очистить дату",
   "ok": "ОК",
   "yes": "Да",
   "no": "Нет",

+ 396 - 4
messages/ru/dashboard.json

@@ -120,7 +120,11 @@
       "nextPage": "Следующая",
       "blocked": "Заблокировано",
       "nonBilling": "Не тарифицируется",
-      "times": "раз"
+      "times": "раз",
+      "loadedCount": "Загружено {count} записей",
+      "loadingMore": "Загрузка...",
+      "noMoreData": "Все записи загружены",
+      "scrollToTop": "Наверх"
     },
     "actions": {
       "refresh": "Обновить",
@@ -130,7 +134,8 @@
       "view": "Просмотр"
     },
     "error": {
-      "loadFailed": "Ошибка загрузки"
+      "loadFailed": "Ошибка загрузки",
+      "loadKeysFailed": "Не удалось загрузить список ключей"
     },
     "details": {
       "title": "Детали запроса",
@@ -572,6 +577,11 @@
   },
   "keyListHeader": {
     "todayUsage": "Использование сегодня",
+    "allowedModels": {
+      "label": "Разрешённые модели",
+      "noRestrictions": "Разрешённые модели: без ограничений"
+    },
+    "expiresAt": "Истекает",
     "proxyStatus": {
       "loading": "Загрузка статуса прокси",
       "fetchFailed": "Не удалось получить статус прокси",
@@ -598,6 +608,10 @@
       "showTooltip": "Показать полный ключ",
       "hideTooltip": "Скрыть ключ",
       "closeButton": "Закрыть"
+    },
+    "allowedClients": {
+      "label": "Разрешённые клиенты",
+      "noRestrictions": "Разрешённые клиенты: без ограничений"
     }
   },
   "keyLimitUsage": {
@@ -720,6 +734,7 @@
     "dailyQuota": {
       "label": "Дневная квота",
       "placeholder": "Лимит дневного расхода",
+      "helperText": "Установите 0 для неограниченного доступа",
       "description": "По умолчанию: ${default}, диапазон: $0.01-$1000"
     },
     "limit5hUsd": {
@@ -741,6 +756,26 @@
       "label": "Лимит одновременных сессий",
       "placeholder": "0 означает неограниченно",
       "description": "Количество одновременных разговоров"
+    },
+    "isEnabled": {
+      "label": "Активировать пользователя",
+      "description": "Отключенные пользователи не смогут использовать API"
+    },
+    "expiresAt": {
+      "label": "Срок действия",
+      "placeholder": "Оставьте пустым для бессрочного",
+      "description": "Пользователь будет автоматически отключен после истечения срока"
+    },
+    "allowedClients": {
+      "label": "Разрешённые клиенты",
+      "description": "Ограничьте, какие CLI/IDE клиенты могут использовать эту учётную запись. Пусто = без ограничений.",
+      "customLabel": "Пользовательские шаблоны клиентов",
+      "customPlaceholder": "Введите шаблон (например, 'xcode', 'my-ide')"
+    },
+    "allowedModels": {
+      "label": "Разрешённые модели",
+      "placeholder": "Введите название модели (Enter для добавления)",
+      "description": "Ограничить пользователя определёнными моделями ИИ. Оставьте пустым без ограничений (макс. 50 моделей, 64 символа)"
     }
   },
   "deleteKeyConfirm": {
@@ -770,11 +805,14 @@
     "title": "Управление пользователями",
     "description": "Показано {count} пользователей",
     "toolbar": {
-      "searchPlaceholder": "Поиск по имени пользователя...",
+      "searchPlaceholder": "Поиск по имени, заметкам, тегам, ключам...",
       "groupFilter": "Фильтр по группе",
       "allGroups": "Все группы",
       "tagFilter": "Фильтр по тегу",
-      "allTags": "Все теги"
+      "allTags": "Все теги",
+      "keyGroupFilter": "Группа ключей",
+      "allKeyGroups": "Все группы ключей",
+      "createUser": "Создать пользователя"
     }
   },
   "availability": {
@@ -888,5 +926,359 @@
       "refreshSuccess": "Данные о доступности обновлены",
       "refreshFailed": "Обновление не удалось, попробуйте снова"
     }
+  },
+  "userManagement": {
+    "table": {
+      "columns": {
+        "username": "Имя пользователя",
+        "note": "Примечание",
+        "expiresAt": "Дата истечения",
+        "expiresAtHint": "Нажмите для быстрого продления",
+        "limit5h": "Лимит 5 ч",
+        "limitDaily": "Дневной лимит",
+        "limitWeekly": "Недельный лимит",
+        "limitMonthly": "Месячный лимит",
+        "limitTotal": "Общий лимит",
+        "limitSessions": "Одновременные сессии"
+      },
+      "keyRow": {
+        "name": "Название ключа",
+        "key": "Ключ",
+        "group": "Группа",
+        "todayUsage": "Использование сегодня",
+        "todayCost": "Расход сегодня",
+        "lastUsed": "Последнее использование",
+        "actions": "Действия",
+        "quotaButton": "Просмотр использования квоты",
+        "fields": {
+          "callsLabel": "Вызовы",
+          "costLabel": "Расход"
+        }
+      },
+      "expand": "Развернуть",
+      "collapse": "Свернуть",
+      "noKeys": "Нет ключей",
+      "defaultGroup": "default",
+      "userStatus": {
+        "disabled": "Отключен"
+      }
+    },
+    "keyFullDisplay": {
+      "title": "Полный ключ",
+      "copySuccess": "Ключ скопирован в буфер обмена",
+      "copyFailed": "Не удалось скопировать",
+      "show": "Показать ключ",
+      "hide": "Скрыть ключ",
+      "copied": "Скопировано",
+      "copy": "Скопировать ключ"
+    },
+    "keyStatsDialog": {
+      "title": "Статистика моделей за сегодня",
+      "columns": {
+        "model": "Модель",
+        "calls": "Вызовы",
+        "cost": "Стоимость"
+      },
+      "noData": "Нет записей использования за сегодня",
+      "totalCalls": "Всего вызовов",
+      "totalCost": "Общий расход"
+    },
+    "keyQuotaUsageDialog": {
+      "title": "Использование квоты ключа",
+      "fetchFailed": "Не удалось получить данные квоты",
+      "noLimit": "Без лимита",
+      "modeFixed": "Фиксированное время",
+      "modeRolling": "Скользящие 24ч",
+      "retry": "Повторить",
+      "labels": {
+        "limit5h": "5 часов",
+        "limitDaily": "Ежедневно",
+        "limitWeekly": "Еженедельно",
+        "limitMonthly": "Ежемесячно",
+        "limitTotal": "Всего",
+        "limitSessions": "Одновременно"
+      }
+    },
+    "quickRenew": {
+      "title": "Быстрое продление",
+      "description": "Установить новую дату истечения для пользователя {userName}",
+      "currentExpiry": "Текущий срок",
+      "neverExpires": "Бессрочно",
+      "expired": "Истёк",
+      "quickOptions": {
+        "7days": "7 дней",
+        "30days": "30 дней",
+        "90days": "90 дней",
+        "1year": "1 год"
+      },
+      "customDate": "Произвольная дата",
+      "enableOnRenew": "Также включить пользователя",
+      "cancel": "Отмена",
+      "confirm": "Подтвердить продление",
+      "confirming": "Продление...",
+      "success": "Продление успешно",
+      "failed": "Ошибка продления"
+    },
+    "editDialog": {
+      "title": "Редактировать пользователя и ключи",
+      "description": "Редактирование данных пользователя и настроек API-ключей",
+      "userSection": "Настройки пользователя",
+      "keysSection": "Настройки ключей",
+      "scrollToKey": "Прокрутить к ключу",
+      "saveFailed": "Не удалось сохранить пользователя",
+      "keySaveFailed": "Не удалось сохранить ключ",
+      "keyDeleteFailed": "Не удалось удалить ключ",
+      "saveSuccess": "Изменения сохранены",
+      "operationFailed": "Операция не удалась",
+      "userDisabled": "Пользователь отключен",
+      "userEnabled": "Пользователь активирован",
+      "deleteFailed": "Не удалось удалить пользователя",
+      "userDeleted": "Пользователь удален",
+      "saving": "Сохранение..."
+    },
+    "createDialog": {
+      "title": "Создать пользователя",
+      "description": "Создание нового пользователя и настройка API-ключей",
+      "saveFailed": "Не удалось создать пользователя",
+      "keyCreateFailed": "Не удалось создать ключ",
+      "createSuccess": "Пользователь создан",
+      "keysSection": "Ключи",
+      "addKey": "Добавить ключ",
+      "removeKey": "Удалить ключ",
+      "cannotDeleteLastKey": "Нельзя удалить последний ключ",
+      "confirmRemoveKeyTitle": "Удалить ключ",
+      "confirmRemoveKeyDescription": "Вы уверены, что хотите удалить ключ \"{name}\"?",
+      "creating": "Создание...",
+      "create": "Создать"
+    },
+    "onboarding": {
+      "skip": "Пропустить",
+      "next": "Далее",
+      "prev": "Назад",
+      "finish": "Начать создание",
+      "stepIndicator": "Шаг {current} из {total}",
+      "steps": {
+        "welcome": {
+          "title": "Пользователи и ключи",
+          "description": "Пользователи - основные субъекты для доступа к API. Каждый пользователь может иметь несколько API ключей. Лимиты на уровне пользователя влияют на все ключи, а лимиты на уровне ключа обеспечивают более точный контроль использования."
+        },
+        "limits": {
+          "title": "Управление лимитами",
+          "description": "Поддерживается 6 типов лимитов: за 5 часов, ежедневный, еженедельный, ежемесячный, общий и одновременные сессии. Добавляйте правила лимитов по необходимости для гибкого контроля использования."
+        },
+        "groups": {
+          "title": "Группы провайдеров",
+          "description": "Используйте функцию групп для ограничения пользователей определенными провайдерами. Группы на уровне ключа имеют приоритет над группами на уровне пользователя."
+        },
+        "keyFeatures": {
+          "title": "Функции ключей",
+          "description": "Каждый ключ можно настроить индивидуально: переопределение TTL кэша, разрешение входа в Web UI и другие расширенные функции."
+        }
+      }
+    },
+    "limitRules": {
+      "addRule": "Добавить правило лимита",
+      "ruleTypes": {
+        "limit5h": "Лимит за 5 часов",
+        "limitDaily": "Дневной лимит",
+        "limitWeekly": "Недельный лимит",
+        "limitMonthly": "Месячный лимит",
+        "limitTotal": "Общий лимит",
+        "limitSessions": "Одновременные сессии"
+      },
+      "dailyMode": {
+        "fixed": "Сброс по фиксированному времени",
+        "rolling": "Скользящее окно (24ч)"
+      },
+      "quickValues": {
+        "10": "$10",
+        "50": "$50",
+        "100": "$100",
+        "500": "$500"
+      },
+      "alreadySet": "Уже настроено",
+      "confirmAdd": "Добавить",
+      "cancel": "Отмена"
+    },
+    "quickExpire": {
+      "oneWeek": "Через неделю",
+      "oneMonth": "Через месяц",
+      "threeMonths": "Через 3 месяца",
+      "oneYear": "Через год"
+    },
+    "providerGroup": {
+      "label": "Группа провайдеров",
+      "placeholder": "Выберите группу провайдеров",
+      "noRestriction": "Без ограничений (все провайдеры)",
+      "providerCount": "{count} провайдеров"
+    },
+    "dangerZone": {
+      "title": "Опасная зона",
+      "description": "Следующие действия необратимы, действуйте с осторожностью",
+      "enable": {
+        "title": "Включить пользователя",
+        "description": "После включения этот пользователь и его ключи возобновят нормальную работу",
+        "trigger": "Включить",
+        "confirm": "Подтвердить включение",
+        "confirmDescription": "Вы уверены, что хотите включить пользователя \"{userName}\"?",
+        "loading": "Обработка..."
+      },
+      "disable": {
+        "title": "Отключить пользователя",
+        "description": "После отключения этот пользователь и его ключи больше не будут работать",
+        "trigger": "Отключить",
+        "confirm": "Подтвердить отключение",
+        "confirmDescription": "Вы уверены, что хотите отключить пользователя \"{userName}\"?",
+        "loading": "Обработка..."
+      },
+      "delete": {
+        "title": "Удалить пользователя",
+        "description": "Это удалит все связанные данные для этого пользователя, это действие необратимо",
+        "trigger": "Удалить",
+        "confirm": "Подтвердить удаление",
+        "confirmDescription": "Это действие удалит все связанные данные пользователя \"{userName}\" и не может быть отменено.",
+        "confirmLabel": "Вторичное подтверждение",
+        "confirmHint": "Пожалуйста, введите \"{userName}\" для подтверждения удаления",
+        "loading": "Удаление..."
+      },
+      "actions": {
+        "cancel": "Отмена"
+      },
+      "errors": {
+        "enableFailed": "Не удалось включить пользователя, попробуйте позже",
+        "disableFailed": "Не удалось отключить пользователя, попробуйте позже",
+        "deleteFailed": "Не удалось удалить пользователя, попробуйте позже"
+      }
+    },
+    "limitIndicator": {
+      "notSet": "Не задано",
+      "unlimited": "Без ограничений"
+    },
+    "keySettings": {
+      "balanceQueryPage": {
+        "label": "Включить отдельную страницу проверки баланса",
+        "description": "Разрешить пользователю просматривать баланс на отдельной странице",
+        "descriptionEnabled": "При включении этот ключ будет использовать независимую страницу личного использования при входе. Однако он не может изменять группу провайдеров собственного ключа.",
+        "descriptionDisabled": "При отключении пользователь не сможет получить доступ к странице личного использования. Однако он может изменять группу провайдеров своего ключа в ограниченном Web UI."
+      },
+      "cacheTtlOverride": {
+        "label": "Переопределение Cache TTL",
+        "inherit": "Не переопределять (следовать провайдеру/клиенту)",
+        "5m": "5 минут",
+        "1h": "1 час"
+      }
+    },
+    "pagination": {
+      "previous": "Предыдущая",
+      "next": "Следующая",
+      "page": "Страница {current}",
+      "of": "из {total}"
+    },
+    "toolbar": {
+      "expandAll": "Развернуть все",
+      "collapseAll": "Свернуть все"
+    },
+    "keyStatus": {
+      "enabled": "Включён",
+      "disabled": "Отключён"
+    },
+    "userEditSection": {
+      "sections": {
+        "basicInfo": "Основная информация",
+        "expireTime": "Срок действия",
+        "limitRules": "Правила лимитов",
+        "accessRestrictions": "Ограничения доступа"
+      },
+      "fields": {
+        "username": {
+          "label": "Имя пользователя",
+          "placeholder": "Введите имя пользователя"
+        },
+        "description": {
+          "label": "Заметка",
+          "placeholder": "Введите заметку (необязательно)"
+        },
+        "tags": {
+          "label": "Теги пользователя",
+          "placeholder": "Введите тег (Enter для добавления)"
+        },
+        "providerGroup": {
+          "label": "Группа провайдеров (устаревшее)",
+          "placeholder": "Введите группу провайдеров или оставьте пустым"
+        },
+        "allowedClients": {
+          "label": "Ограничения клиентов",
+          "description": "Ограничьте CLI/IDE клиенты для этой учетной записи. Пусто = без ограничений.",
+          "customLabel": "Пользовательский шаблон клиента",
+          "customPlaceholder": "Введите шаблон (например, 'xcode', 'my-ide')"
+        },
+        "allowedModels": {
+          "label": "Ограничения моделей",
+          "placeholder": "Введите название модели или выберите из списка",
+          "description": "Ограничить AI модели для пользователя. Пусто = без ограничений."
+        },
+        "enableStatus": {
+          "label": "Статус включения",
+          "enabledDescription": "Сейчас включён. При отключении пользователь и его ключи станут недоступны.",
+          "disabledDescription": "Сейчас отключён. При включении пользователь и его ключи станут доступны.",
+          "confirmDisable": "Подтвердить отключение",
+          "confirmEnable": "Подтвердить включение"
+        }
+      },
+      "presetClients": {
+        "claude-cli": "Claude Code CLI",
+        "gemini-cli": "Gemini CLI",
+        "factory-cli": "Droid CLI",
+        "codex-cli": "Codex CLI"
+      }
+    },
+    "keyEditSection": {
+      "sections": {
+        "basicInfo": "Основная информация",
+        "expireTime": "Срок действия",
+        "limitRules": "Правила лимитов",
+        "specialFeatures": "Специальные функции"
+      },
+      "fields": {
+        "keyName": {
+          "label": "Название ключа",
+          "placeholder": "Введите название ключа"
+        },
+        "enableStatus": {
+          "label": "Статус включения",
+          "description": "Отключённые ключи не могут использоваться"
+        },
+        "balanceQueryPage": {
+          "label": "Независимая страница использования",
+          "description": "При включении этот ключ может использовать независимую страницу личного использования"
+        },
+        "providerGroup": {
+          "label": "Группа провайдеров",
+          "placeholder": "Оставьте пустым для доступа ко всем провайдерам группы default"
+        },
+        "cacheTtl": {
+          "label": "Переопределение Cache TTL",
+          "options": {
+            "inherit": "Не переопределять (следовать провайдеру/клиенту)",
+            "5m": "5m",
+            "1h": "1h"
+          }
+        }
+      },
+      "limitRules": {
+        "title": "Добавить правило лимита",
+        "actions": {
+          "add": "Добавить правило",
+          "remove": "Удалить"
+        },
+        "daily": {
+          "mode": {
+            "fixed": "Сброс в фиксированное время",
+            "rolling": "Скользящее окно (24ч)"
+          }
+        },
+        "overwriteHint": "Этот тип уже существует, сохранение перезапишет существующее значение"
+      }
+    }
   }
 }

+ 2 - 2
messages/ru/quota.json

@@ -257,8 +257,8 @@
         "description": "Оставьте пустым для бессрочного действия"
       },
       "canLoginWebUi": {
-        "label": "Разрешить вход в веб-интерфейс",
-        "description": "При отключении этот ключ можно использовать только для вызовов API, вход в панель управления невозможен"
+        "label": "Включить отдельную страницу проверки баланса",
+        "description": "Разрешить пользователю просматривать баланс на отдельной странице"
       },
       "limit5hUsd": {
         "label": "Лимит расходов за 5 часов (USD)",

+ 3 - 0
messages/zh-CN/common.json

@@ -19,12 +19,15 @@
   "reset": "重置",
   "view": "查看",
   "copy": "复制",
+  "copySuccess": "已复制",
+  "copyFailed": "复制失败",
   "download": "下载",
   "upload": "上传",
   "add": "添加",
   "remove": "移除",
   "apply": "应用",
   "clear": "清空",
+  "clearDate": "清除日期",
   "ok": "确定",
   "yes": "是",
   "no": "否",

+ 388 - 5
messages/zh-CN/dashboard.json

@@ -120,7 +120,11 @@
       "nextPage": "下一页",
       "blocked": "被拦截",
       "nonBilling": "非计费",
-      "times": "次"
+      "times": "次",
+      "loadedCount": "已加载 {count} 条记录",
+      "loadingMore": "加载更多中...",
+      "noMoreData": "已加载全部记录",
+      "scrollToTop": "回到顶部"
     },
     "actions": {
       "refresh": "刷新",
@@ -130,7 +134,8 @@
       "view": "查看"
     },
     "error": {
-      "loadFailed": "加载失败"
+      "loadFailed": "加载失败",
+      "loadKeysFailed": "加载密钥列表失败"
     },
     "details": {
       "title": "请求详情",
@@ -450,7 +455,6 @@
     "byName": "按名称",
     "byUsageRate": "按使用率"
   },
-  "all": "全部",
   "nav": {
     "mobileMenuTitle": "导航菜单",
     "dashboard": "仪表盘",
@@ -545,6 +549,7 @@
     "activeKeys": "活跃密钥",
     "totalKeys": "总密钥",
     "expiresAt": "过期时间",
+    "expiresAtHint": "用户过期后将自动禁用",
     "status": {
       "active": "已启用",
       "expiringSoon": "即将过期",
@@ -579,6 +584,11 @@
   },
   "keyListHeader": {
     "todayUsage": "今日用量",
+    "allowedModels": {
+      "label": "允许的模型",
+      "noRestrictions": "允许的模型:无限制"
+    },
+    "expiresAt": "过期时间",
     "proxyStatus": {
       "loading": "代理状态加载中",
       "fetchFailed": "代理状态获取失败",
@@ -605,6 +615,10 @@
       "showTooltip": "显示完整密钥",
       "hideTooltip": "隐藏密钥",
       "closeButton": "关闭"
+    },
+    "allowedClients": {
+      "label": "允许的客户端",
+      "noRestrictions": "允许的客户端:无限制"
     }
   },
   "keyLimitUsage": {
@@ -744,6 +758,7 @@
     "dailyQuota": {
       "label": "每日额度",
       "placeholder": "每日消费额度限制",
+      "helperText": "设置为 0 表示无限制",
       "description": "默认值: ${default},范围: $0.01-$100000"
     },
     "limit5hUsd": {
@@ -779,6 +794,17 @@
       "label": "过期时间",
       "placeholder": "留空表示永不过期",
       "description": "用户过期后将自动禁用"
+    },
+    "allowedClients": {
+      "label": "允许的客户端",
+      "description": "限制哪些 CLI/IDE 客户端可以使用此账户。留空表示无限制。",
+      "customLabel": "自定义客户端模式",
+      "customPlaceholder": "输入自定义模式(如:'xcode', 'my-ide')"
+    },
+    "allowedModels": {
+      "label": "允许的模型",
+      "placeholder": "输入模型名称(回车添加)",
+      "description": "限制用户只能使用指定的AI模型。留空表示无限制(最多50个模型,每个最长64字符)"
     }
   },
   "deleteKeyConfirm": {
@@ -873,11 +899,14 @@
     "title": "用户管理",
     "description": "显示 {count} 个用户",
     "toolbar": {
-      "searchPlaceholder": "按用户名搜索...",
+      "searchPlaceholder": "搜索用户名、备注、标签、Key...",
       "groupFilter": "按分组筛选",
       "allGroups": "所有分组",
       "tagFilter": "按标签筛选",
-      "allTags": "所有标签"
+      "allTags": "所有标签",
+      "keyGroupFilter": "密钥分组",
+      "allKeyGroups": "所有密钥分组",
+      "createUser": "创建用户"
     }
   },
   "availability": {
@@ -991,5 +1020,359 @@
       "refreshSuccess": "可用性数据已刷新",
       "refreshFailed": "刷新失败,请重试"
     }
+  },
+  "userManagement": {
+    "table": {
+      "columns": {
+        "username": "用户名",
+        "note": "备注",
+        "expiresAt": "到期时间",
+        "expiresAtHint": "点击快捷续期",
+        "limit5h": "5h 限额",
+        "limitDaily": "每日限额",
+        "limitWeekly": "周限额",
+        "limitMonthly": "月限额",
+        "limitTotal": "总限额",
+        "limitSessions": "并发"
+      },
+      "keyRow": {
+        "name": "密钥名称",
+        "key": "密钥",
+        "group": "分组",
+        "todayUsage": "今日用量",
+        "todayCost": "今日消耗",
+        "lastUsed": "最后使用",
+        "actions": "操作",
+        "quotaButton": "查看限额用量",
+        "fields": {
+          "callsLabel": "调用",
+          "costLabel": "消耗"
+        }
+      },
+      "expand": "展开",
+      "collapse": "收起",
+      "noKeys": "无密钥",
+      "defaultGroup": "default",
+      "userStatus": {
+        "disabled": "已禁用"
+      }
+    },
+    "keyFullDisplay": {
+      "title": "完整密钥",
+      "copySuccess": "密钥已复制到剪贴板",
+      "copyFailed": "复制失败",
+      "show": "显示密钥",
+      "hide": "隐藏密钥",
+      "copied": "已复制",
+      "copy": "复制密钥"
+    },
+    "keyStatsDialog": {
+      "title": "今日模型统计",
+      "columns": {
+        "model": "模型",
+        "calls": "调用次数",
+        "cost": "消费金额"
+      },
+      "noData": "今日暂无使用记录",
+      "totalCalls": "总调用",
+      "totalCost": "总消费"
+    },
+    "keyQuotaUsageDialog": {
+      "title": "Key 限额使用情况",
+      "fetchFailed": "获取限额使用情况失败",
+      "noLimit": "无限制",
+      "modeFixed": "固定时间",
+      "modeRolling": "滚动 24h",
+      "retry": "重试",
+      "labels": {
+        "limit5h": "5 小时",
+        "limitDaily": "每日",
+        "limitWeekly": "每周",
+        "limitMonthly": "每月",
+        "limitTotal": "总计",
+        "limitSessions": "并发"
+      }
+    },
+    "quickRenew": {
+      "title": "快捷续期",
+      "description": "为用户 {userName} 设置新的过期时间",
+      "currentExpiry": "当前到期时间",
+      "neverExpires": "永不过期",
+      "expired": "已过期",
+      "quickOptions": {
+        "7days": "7 天",
+        "30days": "30 天",
+        "90days": "90 天",
+        "1year": "1 年"
+      },
+      "customDate": "自定义日期",
+      "enableOnRenew": "同时启用用户",
+      "cancel": "取消",
+      "confirm": "确认续期",
+      "confirming": "续期中...",
+      "success": "续期成功",
+      "failed": "续期失败"
+    },
+    "editDialog": {
+      "title": "编辑用户与密钥",
+      "description": "编辑用户信息和 API 密钥设置",
+      "userSection": "用户设置",
+      "keysSection": "密钥设置",
+      "scrollToKey": "滚动到密钥",
+      "saveFailed": "保存用户失败",
+      "keySaveFailed": "保存密钥失败",
+      "keyDeleteFailed": "删除密钥失败",
+      "saveSuccess": "保存成功",
+      "operationFailed": "操作失败",
+      "userDisabled": "用户已禁用",
+      "userEnabled": "用户已启用",
+      "deleteFailed": "删除用户失败",
+      "userDeleted": "用户已删除",
+      "saving": "保存中..."
+    },
+    "createDialog": {
+      "title": "创建用户",
+      "description": "创建新用户并配置 API 密钥",
+      "saveFailed": "创建用户失败",
+      "keyCreateFailed": "创建密钥失败",
+      "createSuccess": "用户创建成功",
+      "keysSection": "密钥",
+      "addKey": "添加密钥",
+      "removeKey": "删除密钥",
+      "cannotDeleteLastKey": "无法删除最后一个密钥",
+      "confirmRemoveKeyTitle": "删除密钥",
+      "confirmRemoveKeyDescription": "确定要删除密钥 \"{name}\" 吗?",
+      "creating": "创建中...",
+      "create": "创建"
+    },
+    "onboarding": {
+      "skip": "跳过",
+      "next": "下一步",
+      "prev": "上一步",
+      "finish": "开始创建",
+      "stepIndicator": "第 {current} 步,共 {total} 步",
+      "steps": {
+        "welcome": {
+          "title": "用户与密钥",
+          "description": "用户是 API 访问的主体,每个用户可以拥有多个 API Key。用户级限额影响所有 Key,Key 级限额可进一步细化控制。"
+        },
+        "limits": {
+          "title": "限额管理",
+          "description": "支持 6 种限额类型:5 小时、每日、每周、每月、总限额和并发 Session。限额规则按需添加,灵活控制使用量。"
+        },
+        "groups": {
+          "title": "供应商分组",
+          "description": "通过分组功能,可以限制用户只能使用特定供应商。Key 级分组优先于用户级分组。"
+        },
+        "keyFeatures": {
+          "title": "Key 特殊功能",
+          "description": "每个 Key 可独立配置 Cache TTL 覆写、是否允许登录 Web UI 等高级功能。"
+        }
+      }
+    },
+    "limitRules": {
+      "addRule": "添加限额规则",
+      "ruleTypes": {
+        "limit5h": "5小时限额",
+        "limitDaily": "每日限额",
+        "limitWeekly": "周限额",
+        "limitMonthly": "月限额",
+        "limitTotal": "总限额",
+        "limitSessions": "并发 Session"
+      },
+      "dailyMode": {
+        "fixed": "固定时间重置",
+        "rolling": "滚动窗口(24h)"
+      },
+      "quickValues": {
+        "10": "$10",
+        "50": "$50",
+        "100": "$100",
+        "500": "$500"
+      },
+      "alreadySet": "已配置",
+      "confirmAdd": "添加",
+      "cancel": "取消"
+    },
+    "quickExpire": {
+      "oneWeek": "一周后",
+      "oneMonth": "一月后",
+      "threeMonths": "三月后",
+      "oneYear": "一年后"
+    },
+    "providerGroup": {
+      "label": "供应商分组",
+      "placeholder": "选择供应商分组",
+      "noRestriction": "无限制(所有供应商)",
+      "providerCount": "{count} 个供应商"
+    },
+    "dangerZone": {
+      "title": "危险操作",
+      "description": "以下操作不可逆,请谨慎执行",
+      "enable": {
+        "title": "启用用户",
+        "description": "启用后该用户及其密钥将恢复正常使用",
+        "trigger": "启用",
+        "confirm": "确认启用",
+        "confirmDescription": "确认要启用用户 \"{userName}\" 吗?",
+        "loading": "处理中..."
+      },
+      "disable": {
+        "title": "禁用用户",
+        "description": "禁用后该用户及其密钥将无法继续使用",
+        "trigger": "禁用",
+        "confirm": "确认禁用",
+        "confirmDescription": "确认要禁用用户 \"{userName}\" 吗?",
+        "loading": "处理中..."
+      },
+      "delete": {
+        "title": "删除用户",
+        "description": "将删除该用户的所有关联数据,此操作无法撤销",
+        "trigger": "删除",
+        "confirm": "确认删除",
+        "confirmDescription": "此操作将删除用户 \"{userName}\" 的所有关联数据,且无法撤销。",
+        "confirmLabel": "二次确认",
+        "confirmHint": "请输入 \"{userName}\" 以确认删除",
+        "loading": "删除中..."
+      },
+      "actions": {
+        "cancel": "取消"
+      },
+      "errors": {
+        "enableFailed": "启用用户失败,请稍后重试",
+        "disableFailed": "禁用用户失败,请稍后重试",
+        "deleteFailed": "删除用户失败,请稍后重试"
+      }
+    },
+    "limitIndicator": {
+      "notSet": "未设置",
+      "unlimited": "无限制"
+    },
+    "keySettings": {
+      "balanceQueryPage": {
+        "label": "启用独立余额查询页",
+        "description": "允许用户通过专属页面查看余额"
+      },
+      "cacheTtlOverride": {
+        "label": "Cache TTL 覆写",
+        "inherit": "不覆写(跟随供应商/客户端)",
+        "5m": "5 分钟",
+        "1h": "1 小时"
+      }
+    },
+    "pagination": {
+      "previous": "上一页",
+      "next": "下一页",
+      "page": "第 {current} 页",
+      "of": "共 {total} 页"
+    },
+    "toolbar": {
+      "expandAll": "全部展开",
+      "collapseAll": "全部折叠"
+    },
+    "keyStatus": {
+      "enabled": "启用",
+      "disabled": "禁用"
+    },
+    "userEditSection": {
+      "sections": {
+        "basicInfo": "基本信息",
+        "expireTime": "过期时间",
+        "limitRules": "限额规则",
+        "accessRestrictions": "访问限制"
+      },
+      "fields": {
+        "username": {
+          "label": "用户名",
+          "placeholder": "请输入用户名"
+        },
+        "description": {
+          "label": "备注",
+          "placeholder": "请输入备注(可选)"
+        },
+        "tags": {
+          "label": "用户标签",
+          "placeholder": "输入标签(回车添加)"
+        },
+        "providerGroup": {
+          "label": "供应商分组(旧版)",
+          "placeholder": "输入供应商分组或留空"
+        },
+        "allowedClients": {
+          "label": "客户端限制",
+          "description": "限制哪些 CLI/IDE 客户端可以使用此账户。留空表示无限制。",
+          "customLabel": "自定义客户端模式",
+          "customPlaceholder": "输入自定义模式(如:'xcode', 'my-ide')"
+        },
+        "allowedModels": {
+          "label": "模型限制",
+          "placeholder": "输入模型名称或从下拉列表选择",
+          "description": "限制用户只能使用指定的 AI 模型。留空表示无限制。"
+        },
+        "enableStatus": {
+          "label": "启用状态",
+          "enabledDescription": "当前已启用,禁用后该用户及其密钥将无法继续使用",
+          "disabledDescription": "当前已禁用,启用后该用户及其密钥将恢复正常使用",
+          "confirmDisable": "确认禁用",
+          "confirmEnable": "确认启用"
+        }
+      },
+      "presetClients": {
+        "claude-cli": "Claude Code CLI",
+        "gemini-cli": "Gemini CLI",
+        "factory-cli": "Droid CLI",
+        "codex-cli": "Codex CLI"
+      }
+    },
+    "keyEditSection": {
+      "sections": {
+        "basicInfo": "基本信息",
+        "expireTime": "过期时间",
+        "limitRules": "限额规则",
+        "specialFeatures": "特殊功能"
+      },
+      "fields": {
+        "keyName": {
+          "label": "密钥名称",
+          "placeholder": "请输入密钥名称"
+        },
+        "enableStatus": {
+          "label": "启用状态",
+          "description": "禁用后此密钥将无法使用。禁用后仅管理员可启用。"
+        },
+        "balanceQueryPage": {
+          "label": "独立个人用量页面",
+          "description": "启用后,此密钥可使用独立的个人用量查询页面",
+          "descriptionEnabled": "启用后,此密钥在登录时将进入独立的个人用量页面。但不可修改自己密钥的供应商分组。",
+          "descriptionDisabled": "关闭后,用户将无法进入个人独立用量页面 UI。但可在受限的 Web UI功能中修改自己密钥的供应商分组。"
+        },
+        "providerGroup": {
+          "label": "供应商分组",
+          "placeholder": "留空则允许所有 default 分组供应商"
+        },
+        "cacheTtl": {
+          "label": "Cache TTL 覆写",
+          "options": {
+            "inherit": "不覆写(跟随供应商/客户端)",
+            "5m": "5m",
+            "1h": "1h"
+          }
+        }
+      },
+      "limitRules": {
+        "title": "添加限额规则",
+        "actions": {
+          "add": "添加规则",
+          "remove": "移除"
+        },
+        "daily": {
+          "mode": {
+            "fixed": "固定时间重置",
+            "rolling": "滚动窗口(24小时)"
+          }
+        },
+        "overwriteHint": "此类型已存在,保存将覆盖原有值"
+      }
+    }
   }
 }

+ 2 - 2
messages/zh-CN/quota.json

@@ -282,8 +282,8 @@
         "description": "留空表示永不过期"
       },
       "canLoginWebUi": {
-        "label": "允许登录 Web UI",
-        "description": "关闭后,此 Key 仅可用于 API 调用,无法登录管理后台"
+        "label": "启用独立余额查询页",
+        "description": "允许用户通过专属页面查看余额"
       },
       "limit5hUsd": {
         "label": "5小时消费上限 (USD)",

+ 3 - 0
messages/zh-TW/common.json

@@ -19,12 +19,15 @@
   "reset": "重置",
   "view": "檢視",
   "copy": "複製",
+  "copySuccess": "已複製",
+  "copyFailed": "複製失敗",
   "download": "下載",
   "upload": "上傳",
   "add": "新增",
   "remove": "移除",
   "apply": "套用",
   "clear": "清空",
+  "clearDate": "清除日期",
   "ok": "確定",
   "yes": "是",
   "no": "否",

+ 396 - 4
messages/zh-TW/dashboard.json

@@ -120,7 +120,11 @@
       "nextPage": "下一頁",
       "blocked": "已攔截",
       "nonBilling": "非計費",
-      "times": "次"
+      "times": "次",
+      "loadedCount": "已載入 {count} 筆記錄",
+      "loadingMore": "載入更多中...",
+      "noMoreData": "已載入全部記錄",
+      "scrollToTop": "回到頂端"
     },
     "actions": {
       "refresh": "重新整理",
@@ -130,7 +134,8 @@
       "view": "檢視"
     },
     "error": {
-      "loadFailed": "載入失敗"
+      "loadFailed": "載入失敗",
+      "loadKeysFailed": "載入密鑰列表失敗"
     },
     "details": {
       "title": "請求詳情",
@@ -573,6 +578,11 @@
   },
   "keyListHeader": {
     "todayUsage": "今日用量",
+    "allowedModels": {
+      "label": "允許的模型",
+      "noRestrictions": "允許的模型:無限制"
+    },
+    "expiresAt": "到期時間",
     "proxyStatus": {
       "loading": "代理狀態載入中",
       "fetchFailed": "代理狀態取得失敗",
@@ -599,6 +609,10 @@
       "showTooltip": "顯示完整金鑰",
       "hideTooltip": "隱藏金鑰",
       "closeButton": "關閉"
+    },
+    "allowedClients": {
+      "label": "允許的用戶端",
+      "noRestrictions": "允許的用戶端:無限制"
     }
   },
   "keyLimitUsage": {
@@ -721,6 +735,7 @@
     "dailyQuota": {
       "label": "每日額度",
       "placeholder": "每日消費額度限制",
+      "helperText": "設置為 0 表示無限制",
       "description": "預設值:${default},範圍:$0.01-$1000"
     },
     "limit5hUsd": {
@@ -742,6 +757,26 @@
       "label": "並發 Session 上限",
       "placeholder": "0 表示無限制",
       "description": "同時執行的對話數量"
+    },
+    "isEnabled": {
+      "label": "啟用使用者",
+      "description": "停用後使用者將無法使用 API"
+    },
+    "expiresAt": {
+      "label": "到期時間",
+      "placeholder": "留空表示永不過期",
+      "description": "使用者過期後將自動停用"
+    },
+    "allowedClients": {
+      "label": "允許的用戶端",
+      "description": "限制哪些 CLI/IDE 用戶端可以使用此帳戶。留空表示無限制。",
+      "customLabel": "自訂用戶端模式",
+      "customPlaceholder": "輸入自訂模式(如:'xcode', 'my-ide')"
+    },
+    "allowedModels": {
+      "label": "允許的模型",
+      "placeholder": "輸入模型名稱(按 Enter 新增)",
+      "description": "限制使用者只能使用指定的 AI 模型。留空表示無限制(最多 50 個模型,每個最長 64 字元)"
     }
   },
   "deleteKeyConfirm": {
@@ -771,11 +806,14 @@
     "title": "使用者管理",
     "description": "顯示 {count} 位使用者",
     "toolbar": {
-      "searchPlaceholder": "依使用者名稱搜尋...",
+      "searchPlaceholder": "搜尋使用者名稱、備註、標籤、Key...",
       "groupFilter": "依群組篩選",
       "allGroups": "所有群組",
       "tagFilter": "依標籤篩選",
-      "allTags": "所有標籤"
+      "allTags": "所有標籤",
+      "keyGroupFilter": "金鑰分組",
+      "allKeyGroups": "所有金鑰分組",
+      "createUser": "建立使用者"
     }
   },
   "availability": {
@@ -889,5 +927,359 @@
       "refreshSuccess": "可用性資料已重新整理",
       "refreshFailed": "重新整理失敗,請重試"
     }
+  },
+  "userManagement": {
+    "table": {
+      "columns": {
+        "username": "使用者名稱",
+        "note": "備註",
+        "expiresAt": "到期時間",
+        "expiresAtHint": "點擊快速續期",
+        "limit5h": "5h 限額",
+        "limitDaily": "每日限額",
+        "limitWeekly": "週限額",
+        "limitMonthly": "月限額",
+        "limitTotal": "總限額",
+        "limitSessions": "並發"
+      },
+      "keyRow": {
+        "name": "金鑰名稱",
+        "key": "金鑰",
+        "group": "分組",
+        "todayUsage": "今日用量",
+        "todayCost": "今日消耗",
+        "lastUsed": "最後使用",
+        "actions": "操作",
+        "quotaButton": "查看限額用量",
+        "fields": {
+          "callsLabel": "今日呼叫",
+          "costLabel": "今日消耗"
+        }
+      },
+      "expand": "展開",
+      "collapse": "收起",
+      "noKeys": "無金鑰",
+      "defaultGroup": "default",
+      "userStatus": {
+        "disabled": "已停用"
+      }
+    },
+    "keyFullDisplay": {
+      "title": "完整金鑰",
+      "copySuccess": "金鑰已複製到剪貼簿",
+      "copyFailed": "複製失敗",
+      "show": "顯示金鑰",
+      "hide": "隱藏金鑰",
+      "copied": "已複製",
+      "copy": "複製金鑰"
+    },
+    "keyStatsDialog": {
+      "title": "今日模型統計",
+      "columns": {
+        "model": "模型",
+        "calls": "呼叫次數",
+        "cost": "消費金額"
+      },
+      "noData": "今日暫無使用記錄",
+      "totalCalls": "今日總呼叫",
+      "totalCost": "今日總消費"
+    },
+    "keyQuotaUsageDialog": {
+      "title": "Key 限額使用情況",
+      "fetchFailed": "取得限額使用情況失敗",
+      "noLimit": "無限制",
+      "modeFixed": "固定時間",
+      "modeRolling": "滾動 24h",
+      "retry": "重試",
+      "labels": {
+        "limit5h": "5 小時",
+        "limitDaily": "每日",
+        "limitWeekly": "每週",
+        "limitMonthly": "每月",
+        "limitTotal": "總計",
+        "limitSessions": "並發"
+      }
+    },
+    "quickRenew": {
+      "title": "快速續期",
+      "description": "為使用者 {userName} 設定新的過期時間",
+      "currentExpiry": "目前到期時間",
+      "neverExpires": "永不過期",
+      "expired": "已過期",
+      "quickOptions": {
+        "7days": "7 天",
+        "30days": "30 天",
+        "90days": "90 天",
+        "1year": "1 年"
+      },
+      "customDate": "自訂日期",
+      "enableOnRenew": "同時啟用使用者",
+      "cancel": "取消",
+      "confirm": "確認續期",
+      "confirming": "續期中...",
+      "success": "續期成功",
+      "failed": "續期失敗"
+    },
+    "editDialog": {
+      "title": "編輯使用者與金鑰",
+      "description": "編輯使用者資訊和 API 金鑰設定",
+      "userSection": "使用者設定",
+      "keysSection": "金鑰設定",
+      "scrollToKey": "捲動到金鑰",
+      "saveFailed": "儲存使用者失敗",
+      "keySaveFailed": "儲存金鑰失敗",
+      "keyDeleteFailed": "刪除金鑰失敗",
+      "saveSuccess": "儲存成功",
+      "operationFailed": "操作失敗",
+      "userDisabled": "使用者已停用",
+      "userEnabled": "使用者已啟用",
+      "deleteFailed": "刪除使用者失敗",
+      "userDeleted": "使用者已刪除",
+      "saving": "儲存中..."
+    },
+    "createDialog": {
+      "title": "建立使用者",
+      "description": "建立新使用者並設定 API 金鑰",
+      "saveFailed": "建立使用者失敗",
+      "keyCreateFailed": "建立金鑰失敗",
+      "createSuccess": "使用者建立成功",
+      "keysSection": "金鑰",
+      "addKey": "新增金鑰",
+      "removeKey": "刪除金鑰",
+      "cannotDeleteLastKey": "無法刪除最後一個金鑰",
+      "confirmRemoveKeyTitle": "刪除金鑰",
+      "confirmRemoveKeyDescription": "確定要刪除金鑰 \"{name}\" 嗎?",
+      "creating": "建立中...",
+      "create": "建立"
+    },
+    "onboarding": {
+      "skip": "跳過",
+      "next": "下一步",
+      "prev": "上一步",
+      "finish": "開始建立",
+      "stepIndicator": "第 {current} 步,共 {total} 步",
+      "steps": {
+        "welcome": {
+          "title": "使用者與金鑰",
+          "description": "使用者是 API 存取的主體,每個使用者可以擁有多個 API Key。使用者級限額影響所有 Key,Key 級限額可進一步細化控制。"
+        },
+        "limits": {
+          "title": "限額管理",
+          "description": "支援 6 種限額類型:5 小時、每日、每週、每月、總限額和並發 Session。限額規則按需新增,靈活控制使用量。"
+        },
+        "groups": {
+          "title": "供應商分組",
+          "description": "透過分組功能,可以限制使用者只能使用特定供應商。Key 級分組優先於使用者級分組。"
+        },
+        "keyFeatures": {
+          "title": "Key 特殊功能",
+          "description": "每個 Key 可獨立配置 Cache TTL 覆寫、是否允許登入 Web UI 等進階功能。"
+        }
+      }
+    },
+    "limitRules": {
+      "addRule": "新增限額規則",
+      "ruleTypes": {
+        "limit5h": "5小時限額",
+        "limitDaily": "每日限額",
+        "limitWeekly": "週限額",
+        "limitMonthly": "月限額",
+        "limitTotal": "總限額",
+        "limitSessions": "並發 Session"
+      },
+      "dailyMode": {
+        "fixed": "固定時間重設",
+        "rolling": "滾動視窗(24h)"
+      },
+      "quickValues": {
+        "10": "$10",
+        "50": "$50",
+        "100": "$100",
+        "500": "$500"
+      },
+      "alreadySet": "已設定",
+      "confirmAdd": "新增",
+      "cancel": "取消"
+    },
+    "quickExpire": {
+      "oneWeek": "一週後",
+      "oneMonth": "一個月後",
+      "threeMonths": "三個月後",
+      "oneYear": "一年後"
+    },
+    "providerGroup": {
+      "label": "供應商分組",
+      "placeholder": "選擇供應商分組",
+      "noRestriction": "無限制(所有供應商)",
+      "providerCount": "{count} 個供應商"
+    },
+    "dangerZone": {
+      "title": "危險操作",
+      "description": "以下操作不可逆,請謹慎執行",
+      "enable": {
+        "title": "啟用使用者",
+        "description": "啟用後該使用者及其金鑰將恢復正常使用",
+        "trigger": "啟用",
+        "confirm": "確認啟用",
+        "confirmDescription": "確認要啟用使用者「{userName}」嗎?",
+        "loading": "處理中..."
+      },
+      "disable": {
+        "title": "停用使用者",
+        "description": "停用後該使用者及其金鑰將無法繼續使用",
+        "trigger": "停用",
+        "confirm": "確認停用",
+        "confirmDescription": "確認要停用使用者「{userName}」嗎?",
+        "loading": "處理中..."
+      },
+      "delete": {
+        "title": "刪除使用者",
+        "description": "將刪除該使用者的所有關聯資料,此操作無法撤銷",
+        "trigger": "刪除",
+        "confirm": "確認刪除",
+        "confirmDescription": "此操作將刪除使用者「{userName}」的所有關聯資料,且無法撤銷。",
+        "confirmLabel": "二次確認",
+        "confirmHint": "請輸入「{userName}」以確認刪除",
+        "loading": "刪除中..."
+      },
+      "actions": {
+        "cancel": "取消"
+      },
+      "errors": {
+        "enableFailed": "啟用使用者失敗,請稍後重試",
+        "disableFailed": "停用使用者失敗,請稍後重試",
+        "deleteFailed": "刪除使用者失敗,請稍後重試"
+      }
+    },
+    "limitIndicator": {
+      "notSet": "未設定",
+      "unlimited": "無限制"
+    },
+    "keySettings": {
+      "balanceQueryPage": {
+        "label": "啟用獨立餘額查詢頁",
+        "description": "允許使用者透過專屬頁面查看餘額"
+      },
+      "cacheTtlOverride": {
+        "label": "Cache TTL 覆寫",
+        "inherit": "不覆寫(跟隨供應商/客戶端)",
+        "5m": "5 分鐘",
+        "1h": "1 小時"
+      }
+    },
+    "pagination": {
+      "previous": "上一頁",
+      "next": "下一頁",
+      "page": "第 {current} 頁",
+      "of": "共 {total} 頁"
+    },
+    "toolbar": {
+      "expandAll": "全部展開",
+      "collapseAll": "全部收起"
+    },
+    "keyStatus": {
+      "enabled": "啟用",
+      "disabled": "停用"
+    },
+    "userEditSection": {
+      "sections": {
+        "basicInfo": "基本資訊",
+        "expireTime": "過期時間",
+        "limitRules": "限額規則",
+        "accessRestrictions": "存取限制"
+      },
+      "fields": {
+        "username": {
+          "label": "使用者名稱",
+          "placeholder": "請輸入使用者名稱"
+        },
+        "description": {
+          "label": "備註",
+          "placeholder": "請輸入備註(選填)"
+        },
+        "tags": {
+          "label": "使用者標籤",
+          "placeholder": "輸入標籤(按 Enter 新增)"
+        },
+        "providerGroup": {
+          "label": "供應商分組(舊版)",
+          "placeholder": "輸入供應商分組或留空"
+        },
+        "allowedClients": {
+          "label": "用戶端限制",
+          "description": "限制哪些 CLI/IDE 用戶端可以使用此帳戶。留空表示無限制。",
+          "customLabel": "自訂用戶端模式",
+          "customPlaceholder": "輸入自訂模式(如:'xcode', 'my-ide')"
+        },
+        "allowedModels": {
+          "label": "模型限制",
+          "placeholder": "輸入模型名稱或從下拉選單選擇",
+          "description": "限制使用者只能使用指定的 AI 模型。留空表示無限制。"
+        },
+        "enableStatus": {
+          "label": "啟用狀態",
+          "enabledDescription": "目前已啟用,停用後該使用者及其金鑰將無法繼續使用",
+          "disabledDescription": "目前已停用,啟用後該使用者及其金鑰將恢復正常使用",
+          "confirmDisable": "確認停用",
+          "confirmEnable": "確認啟用"
+        }
+      },
+      "presetClients": {
+        "claude-cli": "Claude Code CLI",
+        "gemini-cli": "Gemini CLI",
+        "factory-cli": "Droid CLI",
+        "codex-cli": "Codex CLI"
+      }
+    },
+    "keyEditSection": {
+      "sections": {
+        "basicInfo": "基本資訊",
+        "expireTime": "過期時間",
+        "limitRules": "限額規則",
+        "specialFeatures": "特殊功能"
+      },
+      "fields": {
+        "keyName": {
+          "label": "金鑰名稱",
+          "placeholder": "請輸入金鑰名稱"
+        },
+        "enableStatus": {
+          "label": "啟用狀態",
+          "description": "停用後此金鑰將無法使用。停用後僅管理員可啟用。"
+        },
+        "balanceQueryPage": {
+          "label": "獨立個人用量頁面",
+          "description": "啟用後,此金鑰可使用獨立的個人用量查詢頁面",
+          "descriptionEnabled": "啟用後,此金鑰在登入時將進入獨立的個人用量頁面。但不可修改自己金鑰的供應商分組。",
+          "descriptionDisabled": "關閉後,使用者將無法進入個人獨立用量頁面 UI。但可在受限的 Web UI功能中修改自己金鑰的供應商分組。"
+        },
+        "providerGroup": {
+          "label": "供應商分組",
+          "placeholder": "留空則允許所有 default 分組供應商"
+        },
+        "cacheTtl": {
+          "label": "Cache TTL 覆寫",
+          "options": {
+            "inherit": "不覆寫(跟隨供應商/客戶端)",
+            "5m": "5m",
+            "1h": "1h"
+          }
+        }
+      },
+      "limitRules": {
+        "title": "新增限額規則",
+        "actions": {
+          "add": "新增規則",
+          "remove": "移除"
+        },
+        "daily": {
+          "mode": {
+            "fixed": "固定時間重置",
+            "rolling": "滾動視窗(24小時)"
+          }
+        },
+        "overwriteHint": "此類型已存在,儲存將覆蓋原有值"
+      }
+    }
   }
 }

+ 2 - 2
messages/zh-TW/quota.json

@@ -257,8 +257,8 @@
         "description": "留空表示永不過期"
       },
       "canLoginWebUi": {
-        "label": "允許登入 Web UI",
-        "description": "關閉後,此金鑰僅可用於 API 呼叫,無法登入管理後台"
+        "label": "啟用獨立餘額查詢頁",
+        "description": "允許使用者透過專屬頁面查看餘額"
       },
       "limit5hUsd": {
         "label": "5小時消費上限 (USD)",

+ 16 - 2
package.json

@@ -8,9 +8,16 @@
     "start": "next start",
     "lint": "biome check .",
     "lint:fix": "biome check --write .",
-    "typecheck": "tsc -p tsconfig.json --noEmit",
+    "typecheck": "tsgo -p tsconfig.json --noEmit",
+    "typecheck:tsc": "tsc -p tsconfig.json --noEmit",
     "format": "biome format --write .",
     "format:check": "biome format .",
+    "clean:cache": "rm -rf .next tsconfig.tsbuildinfo node_modules/.cache",
+    "test": "vitest run",
+    "test:ui": "vitest --ui --watch",
+    "test:e2e": "vitest run tests/e2e/ --reporter=verbose",
+    "test:coverage": "vitest run --coverage",
+    "test:ci": "vitest run --reporter=default --reporter=junit --outputFile.junit=reports/vitest-junit.xml",
     "cui": "npx cui-server --host 0.0.0.0 --port 30000 --token a7564bc8882aa9a2d25d8b4ea6ea1e2e",
     "db:generate": "drizzle-kit generate && node scripts/validate-migrations.js",
     "db:migrate": "drizzle-kit migrate",
@@ -42,6 +49,7 @@
     "@radix-ui/react-tooltip": "^1",
     "@scalar/hono-api-reference": "^0.9",
     "@tanstack/react-query": "^5",
+    "@tanstack/react-virtual": "^3",
     "antd": "^6",
     "bull": "^4",
     "class-variance-authority": "^0.7",
@@ -69,6 +77,7 @@
     "react-hook-form": "^7",
     "recharts": "^3",
     "safe-regex": "^2",
+    "server-only": "^0.0.1",
     "socks-proxy-agent": "^8",
     "sonner": "^2",
     "tailwind-merge": "^3",
@@ -85,9 +94,14 @@
     "@types/pg": "^8",
     "@types/react": "^19",
     "@types/react-dom": "^19",
+    "@typescript/native-preview": "7.0.0-dev.20251219.1",
+    "@vitest/coverage-v8": "^4.0.16",
+    "@vitest/ui": "^4.0.16",
     "bun-types": "^1",
     "drizzle-kit": "^0.31",
+    "happy-dom": "^20.0.11",
     "tailwindcss": "^4",
-    "typescript": "^5"
+    "typescript": "^5",
+    "vitest": "^4.0.16"
   }
 }

+ 1 - 0
reports/.gitkeep

@@ -0,0 +1 @@
+

+ 59 - 0
scripts/cleanup-test-users.ps1

@@ -0,0 +1,59 @@
+# 清理测试用户脚本(PowerShell 版本)
+
+Write-Host "🔍 检查测试用户数量..." -ForegroundColor Cyan
+
+# 统计测试用户
+docker exec claude-code-hub-db-dev psql -U postgres -d claude_code_hub -c @"
+SELECT COUNT(*) as 测试用户数量
+FROM users
+WHERE (name LIKE '测试用户%' OR name LIKE '%test%' OR name LIKE 'Test%')
+  AND deleted_at IS NULL;
+"@
+
+Write-Host ""
+Write-Host "📋 预览将要删除的用户(前 10 个)..." -ForegroundColor Cyan
+docker exec claude-code-hub-db-dev psql -U postgres -d claude_code_hub -c @"
+SELECT id, name, created_at
+FROM users
+WHERE (name LIKE '测试用户%' OR name LIKE '%test%' OR name LIKE 'Test%')
+  AND deleted_at IS NULL
+ORDER BY created_at DESC
+LIMIT 10;
+"@
+
+Write-Host ""
+$confirm = Read-Host "⚠️  确认删除这些测试用户吗?(y/N)"
+
+if ($confirm -eq 'y' -or $confirm -eq 'Y') {
+    Write-Host "🗑️  开始清理..." -ForegroundColor Yellow
+
+    # 软删除关联的 keys
+    docker exec claude-code-hub-db-dev psql -U postgres -d claude_code_hub -c @"
+    UPDATE keys
+    SET deleted_at = NOW(), updated_at = NOW()
+    WHERE user_id IN (
+      SELECT id FROM users
+      WHERE (name LIKE '测试用户%' OR name LIKE '%test%' OR name LIKE 'Test%')
+        AND deleted_at IS NULL
+    )
+    AND deleted_at IS NULL;
+"@
+
+    # 软删除测试用户
+    $result = docker exec claude-code-hub-db-dev psql -U postgres -d claude_code_hub -c @"
+    UPDATE users
+    SET deleted_at = NOW(), updated_at = NOW()
+    WHERE (name LIKE '测试用户%' OR name LIKE '%test%' OR name LIKE 'Test%')
+      AND deleted_at IS NULL
+    RETURNING id, name;
+"@
+
+    Write-Host "✅ 清理完成!" -ForegroundColor Green
+    Write-Host ""
+    Write-Host "📊 剩余用户统计:" -ForegroundColor Cyan
+    docker exec claude-code-hub-db-dev psql -U postgres -d claude_code_hub -c @"
+    SELECT COUNT(*) as 总用户数 FROM users WHERE deleted_at IS NULL;
+"@
+} else {
+    Write-Host "❌ 取消清理" -ForegroundColor Red
+}

+ 60 - 0
scripts/cleanup-test-users.sh

@@ -0,0 +1,60 @@
+#!/bin/bash
+# 清理测试用户脚本
+
+echo "🔍 检查测试用户数量..."
+
+# 统计测试用户
+docker exec claude-code-hub-db-dev psql -U postgres -d claude_code_hub -c "
+SELECT
+  COUNT(*) as 测试用户数量
+FROM users
+WHERE (name LIKE '测试用户%' OR name LIKE '%test%' OR name LIKE 'Test%')
+  AND deleted_at IS NULL;
+"
+
+echo ""
+echo "📋 预览将要删除的用户(前 10 个)..."
+docker exec claude-code-hub-db-dev psql -U postgres -d claude_code_hub -c "
+SELECT id, name, created_at
+FROM users
+WHERE (name LIKE '测试用户%' OR name LIKE '%test%' OR name LIKE 'Test%')
+  AND deleted_at IS NULL
+ORDER BY created_at DESC
+LIMIT 10;
+"
+
+echo ""
+read -p "⚠️  确认删除这些测试用户吗?(y/N): " confirm
+
+if [ "$confirm" = "y" ] || [ "$confirm" = "Y" ]; then
+  echo "🗑️  开始清理..."
+
+  # 软删除关联的 keys
+  docker exec claude-code-hub-db-dev psql -U postgres -d claude_code_hub -c "
+  UPDATE keys
+  SET deleted_at = NOW(), updated_at = NOW()
+  WHERE user_id IN (
+    SELECT id FROM users
+    WHERE (name LIKE '测试用户%' OR name LIKE '%test%' OR name LIKE 'Test%')
+      AND deleted_at IS NULL
+  )
+  AND deleted_at IS NULL;
+  "
+
+  # 软删除测试用户
+  docker exec claude-code-hub-db-dev psql -U postgres -d claude_code_hub -c "
+  UPDATE users
+  SET deleted_at = NOW(), updated_at = NOW()
+  WHERE (name LIKE '测试用户%' OR name LIKE '%test%' OR name LIKE 'Test%')
+    AND deleted_at IS NULL;
+  "
+
+  echo "✅ 清理完成!"
+  echo ""
+  echo "📊 剩余用户统计:"
+  docker exec claude-code-hub-db-dev psql -U postgres -d claude_code_hub -c "
+  SELECT COUNT(*) as 总用户数 FROM users WHERE deleted_at IS NULL;
+  "
+else
+  echo "❌ 取消清理"
+fi

+ 39 - 0
scripts/cleanup-test-users.sql

@@ -0,0 +1,39 @@
+-- 清理测试用户脚本
+-- 删除所有包含"测试用户"、"test"或"Test"的用户及其关联数据
+
+BEGIN;
+
+-- 1. 统计将要删除的用户
+SELECT
+  COUNT(*) as 将要删除的用户数,
+  STRING_AGG(DISTINCT name, ', ') as 示例用户名
+FROM users
+WHERE (name LIKE '测试用户%' OR name LIKE '%test%' OR name LIKE 'Test%')
+  AND deleted_at IS NULL;
+
+-- 2. 删除关联的 keys(软删除)
+UPDATE keys
+SET deleted_at = NOW(), updated_at = NOW()
+WHERE user_id IN (
+  SELECT id FROM users
+  WHERE (name LIKE '测试用户%' OR name LIKE '%test%' OR name LIKE 'Test%')
+    AND deleted_at IS NULL
+)
+AND deleted_at IS NULL;
+
+-- 3. 删除用户(软删除)
+UPDATE users
+SET deleted_at = NOW(), updated_at = NOW()
+WHERE (name LIKE '测试用户%' OR name LIKE '%test%' OR name LIKE 'Test%')
+  AND deleted_at IS NULL;
+
+-- 4. 查看删除结果
+SELECT
+  COUNT(*) as 剩余用户总数,
+  COUNT(*) FILTER (WHERE name LIKE '测试用户%' OR name LIKE '%test%' OR name LIKE 'Test%') as 剩余测试用户
+FROM users
+WHERE deleted_at IS NULL;
+
+-- 如果确认无误,执行 COMMIT;否则执行 ROLLBACK
+-- COMMIT;
+ROLLBACK;

+ 7 - 2
scripts/clear-session-bindings.ts

@@ -274,11 +274,16 @@ async function createRedisClient(redisUrl: string): Promise<Redis> {
   };
 
   if (redisUrl.startsWith("rediss://")) {
+    const rejectUnauthorized = process.env.REDIS_TLS_REJECT_UNAUTHORIZED !== "false";
     try {
       const url = new URL(redisUrl);
-      options.tls = { host: url.hostname };
+      options.tls = {
+        host: url.hostname,
+        servername: url.hostname, // SNI support for cloud Redis providers
+        rejectUnauthorized,
+      };
     } catch {
-      options.tls = {};
+      options.tls = { rejectUnauthorized };
     }
   }
 

+ 112 - 0
scripts/run-e2e-tests.ps1

@@ -0,0 +1,112 @@
+# E2E 测试运行脚本(PowerShell 版本)
+#
+# 功能:
+# 1. 启动 Next.js 开发服务器
+# 2. 等待服务器就绪
+# 3. 运行 E2E 测试
+# 4. 清理并停止服务器
+#
+# 使用方法:
+#   .\scripts\run-e2e-tests.ps1
+
+$ErrorActionPreference = "Stop"
+
+Write-Host "🚀 E2E 测试运行脚本" -ForegroundColor Cyan
+Write-Host "====================" -ForegroundColor Cyan
+Write-Host ""
+
+# ==================== 1. 检查数据库连接 ====================
+
+Write-Host "🔍 检查数据库连接..." -ForegroundColor Cyan
+$postgresRunning = docker ps | Select-String "claude-code-hub-db-dev"
+
+if ($postgresRunning) {
+    Write-Host "✅ PostgreSQL 已运行" -ForegroundColor Green
+} else {
+    Write-Host "❌ PostgreSQL 未运行,正在启动..." -ForegroundColor Yellow
+    docker compose up -d postgres redis
+    Write-Host "⏳ 等待数据库启动..." -ForegroundColor Yellow
+    Start-Sleep -Seconds 5
+}
+
+Write-Host ""
+
+# ==================== 2. 启动开发服务器 ====================
+
+Write-Host "🚀 启动 Next.js 开发服务器..." -ForegroundColor Cyan
+
+# 后台启动服务器
+$env:PORT = "13500"
+$serverProcess = Start-Process -FilePath "bun" -ArgumentList "run", "dev" -PassThru -NoNewWindow -RedirectStandardOutput "$env:TEMP\nextjs-dev.log" -RedirectStandardError "$env:TEMP\nextjs-dev-error.log"
+
+Write-Host "   服务器 PID: $($serverProcess.Id)" -ForegroundColor Gray
+Write-Host "⏳ 等待服务器就绪..." -ForegroundColor Yellow
+
+# 等待服务器启动(最多等待 60 秒)
+$timeout = 60
+$counter = 0
+$serverReady = $false
+
+while ($counter -lt $timeout) {
+    try {
+        $response = Invoke-WebRequest -Uri "http://localhost:13500/api/actions/health" -UseBasicParsing -ErrorAction SilentlyContinue
+        if ($response.StatusCode -eq 200) {
+            Write-Host ""
+            Write-Host "✅ 服务器已就绪" -ForegroundColor Green
+            $serverReady = $true
+            break
+        }
+    } catch {
+        # 继续等待
+    }
+
+    $counter++
+    Write-Host "." -NoNewline
+    Start-Sleep -Seconds 1
+}
+
+if (-not $serverReady) {
+    Write-Host ""
+    Write-Host "❌ 服务器启动超时" -ForegroundColor Red
+    Stop-Process -Id $serverProcess.Id -Force -ErrorAction SilentlyContinue
+    exit 1
+}
+
+Write-Host ""
+
+# ==================== 3. 运行 E2E 测试 ====================
+
+Write-Host "🧪 运行 E2E 测试..." -ForegroundColor Cyan
+Write-Host ""
+
+# 设置环境变量
+$env:API_BASE_URL = "http://localhost:13500/api/actions"
+$env:AUTO_CLEANUP_TEST_DATA = "true"
+
+# 运行 E2E 测试
+$testExitCode = 0
+try {
+    bun run test tests/e2e/
+    $testExitCode = $LASTEXITCODE
+} catch {
+    $testExitCode = 1
+}
+
+Write-Host ""
+
+# ==================== 4. 清理并停止服务器 ====================
+
+Write-Host "🧹 停止开发服务器..." -ForegroundColor Cyan
+Stop-Process -Id $serverProcess.Id -Force -ErrorAction SilentlyContinue
+Write-Host "✅ 服务器已停止" -ForegroundColor Green
+Write-Host ""
+
+# ==================== 5. 输出测试结果 ====================
+
+if ($testExitCode -eq 0) {
+    Write-Host "✅ E2E 测试全部通过" -ForegroundColor Green
+    exit 0
+} else {
+    Write-Host "❌ E2E 测试失败" -ForegroundColor Red
+    exit $testExitCode
+}

+ 101 - 0
scripts/run-e2e-tests.sh

@@ -0,0 +1,101 @@
+#!/bin/bash
+# E2E 测试运行脚本
+#
+# 功能:
+# 1. 启动 Next.js 开发服务器
+# 2. 等待服务器就绪
+# 3. 运行 E2E 测试
+# 4. 清理并停止服务器
+#
+# 使用方法:
+#   bash scripts/run-e2e-tests.sh
+
+set -e  # 遇到错误立即退出
+
+echo "🚀 E2E 测试运行脚本"
+echo "===================="
+echo ""
+
+# ==================== 1. 检查数据库连接 ====================
+
+echo "🔍 检查数据库连接..."
+if docker ps | grep -q claude-code-hub-db-dev; then
+  echo "✅ PostgreSQL 已运行"
+else
+  echo "❌ PostgreSQL 未运行,正在启动..."
+  docker compose up -d postgres redis
+  echo "⏳ 等待数据库启动..."
+  sleep 5
+fi
+
+echo ""
+
+# ==================== 2. 启动开发服务器 ====================
+
+echo "🚀 启动 Next.js 开发服务器..."
+
+# 后台启动服务器
+PORT=13500 bun run dev > /tmp/nextjs-dev.log 2>&1 &
+SERVER_PID=$!
+
+echo "   服务器 PID: $SERVER_PID"
+echo "⏳ 等待服务器就绪..."
+
+# 等待服务器启动(最多等待 60 秒)
+TIMEOUT=60
+COUNTER=0
+
+while [ $COUNTER -lt $TIMEOUT ]; do
+  if curl -s http://localhost:13500/api/actions/health > /dev/null 2>&1; then
+    echo "✅ 服务器已就绪"
+    break
+  fi
+
+  COUNTER=$((COUNTER + 1))
+  sleep 1
+  echo -n "."
+done
+
+if [ $COUNTER -eq $TIMEOUT ]; then
+  echo ""
+  echo "❌ 服务器启动超时"
+  kill $SERVER_PID 2>/dev/null || true
+  exit 1
+fi
+
+echo ""
+
+# ==================== 3. 运行 E2E 测试 ====================
+
+echo "🧪 运行 E2E 测试..."
+echo ""
+
+# 设置环境变量
+export API_BASE_URL="http://localhost:13500/api/actions"
+export AUTO_CLEANUP_TEST_DATA=true
+
+# 运行 E2E 测试
+bun run test tests/e2e/
+
+TEST_EXIT_CODE=$?
+
+echo ""
+
+# ==================== 4. 清理并停止服务器 ====================
+
+echo "🧹 停止开发服务器..."
+kill $SERVER_PID 2>/dev/null || true
+wait $SERVER_PID 2>/dev/null || true
+
+echo "✅ 服务器已停止"
+echo ""
+
+# ==================== 5. 输出测试结果 ====================
+
+if [ $TEST_EXIT_CODE -eq 0 ]; then
+  echo "✅ E2E 测试全部通过"
+  exit 0
+else
+  echo "❌ E2E 测试失败"
+  exit $TEST_EXIT_CODE
+fi

+ 120 - 0
src/actions/key-quota.ts

@@ -0,0 +1,120 @@
+"use server";
+
+import { and, eq, isNull } from "drizzle-orm";
+import { db } from "@/drizzle/db";
+import { keys as keysTable } from "@/drizzle/schema";
+import { getSession } from "@/lib/auth";
+import { logger } from "@/lib/logger";
+import { RateLimitService } from "@/lib/rate-limit/service";
+import { SessionTracker } from "@/lib/session-tracker";
+import type { CurrencyCode } from "@/lib/utils";
+import { getSystemSettings } from "@/repository/system-config";
+import { getTotalUsageForKey } from "@/repository/usage-logs";
+import type { ActionResult } from "./types";
+
+export interface KeyQuotaItem {
+  type: "limit5h" | "limitDaily" | "limitWeekly" | "limitMonthly" | "limitTotal" | "limitSessions";
+  current: number;
+  limit: number | null;
+  mode?: "fixed" | "rolling";
+  time?: string;
+}
+
+export interface KeyQuotaUsageResult {
+  keyName: string;
+  items: KeyQuotaItem[];
+  currencyCode: CurrencyCode;
+}
+
+export async function getKeyQuotaUsage(keyId: number): Promise<ActionResult<KeyQuotaUsageResult>> {
+  try {
+    const session = await getSession();
+    if (!session) return { ok: false, error: "Unauthorized" };
+    if (session.user.role !== "admin") {
+      return { ok: false, error: "Admin access required" };
+    }
+
+    const [keyRow] = await db
+      .select()
+      .from(keysTable)
+      .where(and(eq(keysTable.id, keyId), isNull(keysTable.deletedAt)))
+      .limit(1);
+
+    if (!keyRow) {
+      return { ok: false, error: "Key not found" };
+    }
+
+    const settings = await getSystemSettings();
+    const currencyCode = settings.currencyDisplay;
+
+    // Helper to convert numeric string from DB to number
+    const parseNumericLimit = (val: string | null): number | null => {
+      if (val === null) return null;
+      const num = parseFloat(val);
+      return Number.isNaN(num) ? null : num;
+    };
+
+    const [cost5h, costDaily, costWeekly, costMonthly, totalCost, concurrentSessions] =
+      await Promise.all([
+        RateLimitService.getCurrentCost(keyId, "key", "5h"),
+        RateLimitService.getCurrentCost(
+          keyId,
+          "key",
+          "daily",
+          keyRow.dailyResetTime ?? "00:00",
+          keyRow.dailyResetMode ?? "fixed"
+        ),
+        RateLimitService.getCurrentCost(keyId, "key", "weekly"),
+        RateLimitService.getCurrentCost(keyId, "key", "monthly"),
+        getTotalUsageForKey(keyRow.key),
+        SessionTracker.getKeySessionCount(keyId),
+      ]);
+
+    const items: KeyQuotaItem[] = [
+      {
+        type: "limit5h",
+        current: cost5h,
+        limit: parseNumericLimit(keyRow.limit5hUsd),
+      },
+      {
+        type: "limitDaily",
+        current: costDaily,
+        limit: parseNumericLimit(keyRow.limitDailyUsd),
+        mode: keyRow.dailyResetMode ?? "fixed",
+        time: keyRow.dailyResetTime ?? "00:00",
+      },
+      {
+        type: "limitWeekly",
+        current: costWeekly,
+        limit: parseNumericLimit(keyRow.limitWeeklyUsd),
+      },
+      {
+        type: "limitMonthly",
+        current: costMonthly,
+        limit: parseNumericLimit(keyRow.limitMonthlyUsd),
+      },
+      {
+        type: "limitTotal",
+        current: totalCost,
+        limit: parseNumericLimit(keyRow.limitTotalUsd),
+      },
+      {
+        type: "limitSessions",
+        current: concurrentSessions,
+        limit: keyRow.limitConcurrentSessions ?? null,
+      },
+    ];
+
+    return {
+      ok: true,
+      data: {
+        keyName: keyRow.name ?? "",
+        items,
+        currencyCode,
+      },
+    };
+  } catch (error) {
+    logger.error("[key-quota] getKeyQuotaUsage failed", error);
+    return { ok: false, error: "Failed to get key quota usage" };
+  }
+}

+ 149 - 64
src/actions/keys.ts

@@ -2,8 +2,10 @@
 
 import { randomBytes } from "node:crypto";
 import { revalidatePath } from "next/cache";
+import { getTranslations } from "next-intl/server";
 import { getSession } from "@/lib/auth";
 import { logger } from "@/lib/logger";
+import { ERROR_CODES } from "@/lib/utils/error-messages";
 import { KeyFormSchema } from "@/lib/validation/schemas";
 import type { KeyStatistics } from "@/repository/key";
 import {
@@ -18,6 +20,18 @@ import {
 } from "@/repository/key";
 import type { Key } from "@/types/key";
 import type { ActionResult } from "./types";
+import { syncUserProviderGroupFromKeys } from "./users";
+
+function normalizeProviderGroup(value: unknown): string | null {
+  if (value === null || value === undefined) return null;
+  if (typeof value !== "string") return null;
+  const groups = value
+    .split(",")
+    .map((g) => g.trim())
+    .filter(Boolean);
+  if (groups.length === 0) return null;
+  return Array.from(new Set(groups)).sort().join(",");
+}
 
 // 添加密钥
 // 说明:为提升前端可控性,避免直接抛错,返回判别式结果。
@@ -38,13 +52,38 @@ export async function addKey(data: {
   cacheTtlPreference?: "inherit" | "5m" | "1h";
 }): Promise<ActionResult<{ generatedKey: string; name: string }>> {
   try {
+    // providerGroup 为 admin-only 字段:
+    // - 普通用户不能在 Key 上设置/修改 providerGroup(防止绕过分组隔离)
+    // - 用户分组由 Key 分组自动计算(见 syncUserProviderGroupFromKeys)
+    // - syncUserProviderGroupFromKeys 仅在 Key 变更时触发(create/edit/delete)
+
+    const tError = await getTranslations("errors");
+
     // 权限检查:用户只能给自己添加Key,管理员可以给所有人添加Key
     const session = await getSession();
     if (!session) {
-      return { ok: false, error: "未登录" };
+      return {
+        ok: false,
+        error: tError("UNAUTHORIZED"),
+        errorCode: ERROR_CODES.UNAUTHORIZED,
+      };
     }
     if (session.user.role !== "admin" && session.user.id !== data.userId) {
-      return { ok: false, error: "无权限执行此操作" };
+      return {
+        ok: false,
+        error: tError("PERMISSION_DENIED"),
+        errorCode: ERROR_CODES.PERMISSION_DENIED,
+      };
+    }
+
+    // 普通用户禁止设置 providerGroup(即使是自己的 Key)
+    const requestedProviderGroup = normalizeProviderGroup(data.providerGroup);
+    if (session.user.role !== "admin" && requestedProviderGroup) {
+      return {
+        ok: false,
+        error: tError("PERMISSION_DENIED"),
+        errorCode: ERROR_CODES.PERMISSION_DENIED,
+      };
     }
 
     const validatedData = KeyFormSchema.parse({
@@ -134,35 +173,7 @@ export async function addKey(data: {
       };
     }
 
-    // 验证 providerGroup:Key 的供应商分组必须是用户分组的子集
-    if (validatedData.providerGroup) {
-      const keyGroups = validatedData.providerGroup
-        .split(",")
-        .map((g) => g.trim())
-        .filter(Boolean);
-
-      if (keyGroups.length > 0) {
-        // 如果用户没有配置 providerGroup,Key 也不能设置
-        if (!user.providerGroup) {
-          return {
-            ok: false,
-            error: "用户未配置供应商分组,Key不能设置供应商分组",
-          };
-        }
-
-        const userGroups = user.providerGroup
-          .split(",")
-          .map((g) => g.trim())
-          .filter(Boolean);
-        const invalidGroups = keyGroups.filter((g) => !userGroups.includes(g));
-        if (invalidGroups.length > 0) {
-          return {
-            ok: false,
-            error: `Key的供应商分组包含用户未授权的分组:${invalidGroups.join(", ")}`,
-          };
-        }
-      }
-    }
+    // 移除 providerGroup 子集校验(用户分组由 Key 分组自动计算)
 
     const generatedKey = `sk-${randomBytes(16).toString("hex")}`;
 
@@ -185,10 +196,16 @@ export async function addKey(data: {
       limit_monthly_usd: validatedData.limitMonthlyUsd,
       limit_total_usd: validatedData.limitTotalUsd,
       limit_concurrent_sessions: validatedData.limitConcurrentSessions,
-      provider_group: validatedData.providerGroup || null,
+      // providerGroup 为 admin-only 字段:非管理员请求强制忽略为 null
+      provider_group: session.user.role === "admin" ? validatedData.providerGroup || null : null,
       cache_ttl_preference: validatedData.cacheTtlPreference,
     });
 
+    // 自动同步用户分组(用户分组 = Key 分组并集)
+    if (session.user.role === "admin" && validatedData.providerGroup) {
+      await syncUserProviderGroupFromKeys(data.userId);
+    }
+
     revalidatePath("/dashboard");
 
     // 返回生成的key供前端显示
@@ -207,6 +224,7 @@ export async function editKey(
     name: string;
     expiresAt?: string;
     canLoginWebUi?: boolean;
+    isEnabled?: boolean;
     limit5hUsd?: number | null;
     limitDailyUsd?: number | null;
     dailyResetMode?: "fixed" | "rolling";
@@ -220,10 +238,21 @@ export async function editKey(
   }
 ): Promise<ActionResult> {
   try {
+    // providerGroup 为 admin-only 字段:
+    // - 普通用户不能在 Key 上设置/修改 providerGroup(防止绕过分组隔离)
+    // - 用户分组由 Key 分组自动计算(见 syncUserProviderGroupFromKeys)
+    // - syncUserProviderGroupFromKeys 仅在 Key 变更时触发(create/edit/delete)
+
+    const tError = await getTranslations("errors");
+
     // 权限检查:用户只能编辑自己的Key,管理员可以编辑所有Key
     const session = await getSession();
     if (!session) {
-      return { ok: false, error: "未登录" };
+      return {
+        ok: false,
+        error: tError("UNAUTHORIZED"),
+        errorCode: ERROR_CODES.UNAUTHORIZED,
+      };
     }
 
     const key = await findKeyById(keyId);
@@ -232,7 +261,26 @@ export async function editKey(
     }
 
     if (session.user.role !== "admin" && session.user.id !== key.userId) {
-      return { ok: false, error: "无权限执行此操作" };
+      return {
+        ok: false,
+        error: tError("PERMISSION_DENIED"),
+        errorCode: ERROR_CODES.PERMISSION_DENIED,
+      };
+    }
+
+    // 普通用户禁止修改 providerGroup(即使是自己的 Key)。
+    // 为保持兼容性:若客户端仍携带 providerGroup 但值未变化,则允许继续编辑其它字段。
+    const providerGroupProvided = Object.hasOwn(data, "providerGroup");
+    if (session.user.role !== "admin" && providerGroupProvided) {
+      const currentGroup = normalizeProviderGroup(key.providerGroup);
+      const requestedGroup = normalizeProviderGroup(data.providerGroup);
+      if (currentGroup !== requestedGroup) {
+        return {
+          ok: false,
+          error: tError("PERMISSION_DENIED"),
+          errorCode: ERROR_CODES.PERMISSION_DENIED,
+        };
+      }
     }
 
     const validatedData = KeyFormSchema.parse(data);
@@ -307,44 +355,22 @@ export async function editKey(
       };
     }
 
-    // 验证 providerGroup:Key 的供应商分组必须是用户分组的子集
-    if (validatedData.providerGroup) {
-      const keyGroups = validatedData.providerGroup
-        .split(",")
-        .map((g) => g.trim())
-        .filter(Boolean);
-
-      if (keyGroups.length > 0) {
-        // 如果用户没有配置 providerGroup,Key 也不能设置
-        if (!user.providerGroup) {
-          return {
-            ok: false,
-            error: "用户未配置供应商分组,Key不能设置供应商分组",
-          };
-        }
-
-        const userGroups = user.providerGroup
-          .split(",")
-          .map((g) => g.trim())
-          .filter(Boolean);
-        const invalidGroups = keyGroups.filter((g) => !userGroups.includes(g));
-        if (invalidGroups.length > 0) {
-          return {
-            ok: false,
-            error: `Key的供应商分组包含用户未授权的分组:${invalidGroups.join(", ")}`,
-          };
-        }
-      }
-    }
+    // 移除 providerGroup 子集校验(用户分组由 Key 分组自动计算)
 
     // 转换 expiresAt: undefined → null(清除日期),string → Date(设置日期)
     const expiresAt =
       validatedData.expiresAt === undefined ? null : new Date(validatedData.expiresAt);
 
+    const isAdmin = session.user.role === "admin";
+    const nextProviderGroup = isAdmin ? normalizeProviderGroup(validatedData.providerGroup) : null;
+    const prevProviderGroup = normalizeProviderGroup(key.providerGroup);
+    const providerGroupChanged = isAdmin && nextProviderGroup !== prevProviderGroup;
+
     await updateKey(keyId, {
       name: validatedData.name,
       expires_at: expiresAt,
       can_login_web_ui: validatedData.canLoginWebUi,
+      ...(data.isEnabled !== undefined ? { is_enabled: data.isEnabled } : {}),
       limit_5h_usd: validatedData.limit5hUsd,
       limit_daily_usd: validatedData.limitDailyUsd,
       daily_reset_mode: validatedData.dailyResetMode,
@@ -353,10 +379,16 @@ export async function editKey(
       limit_monthly_usd: validatedData.limitMonthlyUsd,
       limit_total_usd: validatedData.limitTotalUsd,
       limit_concurrent_sessions: validatedData.limitConcurrentSessions,
-      provider_group: validatedData.providerGroup || null,
+      // providerGroup 为 admin-only 字段:非管理员不允许更新该字段
+      ...(isAdmin ? { provider_group: validatedData.providerGroup || null } : {}),
       cache_ttl_preference: validatedData.cacheTtlPreference,
     });
 
+    // 自动同步用户分组(用户分组 = Key 分组并集)
+    if (providerGroupChanged) {
+      await syncUserProviderGroupFromKeys(key.userId);
+    }
+
     revalidatePath("/dashboard");
     return { ok: true };
   } catch (error) {
@@ -393,6 +425,10 @@ export async function removeKey(keyId: number): Promise<ActionResult> {
     }
 
     await deleteKey(keyId);
+
+    // 自动同步用户分组(删除 Key 后用户分组可能变化)
+    await syncUserProviderGroupFromKeys(key.userId);
+
     revalidatePath("/dashboard");
     return { ok: true };
   } catch (error) {
@@ -546,3 +582,52 @@ export async function getKeyLimitUsage(keyId: number): Promise<
     return { ok: false, error: "获取限额使用情况失败" };
   }
 }
+
+/**
+ * 切换密钥启用/禁用状态
+ */
+export async function toggleKeyEnabled(keyId: number, enabled: boolean): Promise<ActionResult> {
+  try {
+    const tError = await getTranslations("errors");
+
+    const session = await getSession();
+    if (!session) {
+      return { ok: false, error: tError("UNAUTHORIZED"), errorCode: ERROR_CODES.UNAUTHORIZED };
+    }
+
+    const key = await findKeyById(keyId);
+    if (!key) {
+      return { ok: false, error: tError("KEY_NOT_FOUND"), errorCode: ERROR_CODES.NOT_FOUND };
+    }
+
+    // 权限检查:用户只能管理自己的Key,管理员可以管理所有Key
+    if (session.user.role !== "admin" && session.user.id !== key.userId) {
+      return {
+        ok: false,
+        error: tError("PERMISSION_DENIED"),
+        errorCode: ERROR_CODES.PERMISSION_DENIED,
+      };
+    }
+
+    // 检查是否是最后一个启用的密钥(防止禁用最后一个)
+    if (!enabled) {
+      const activeKeyCount = await countActiveKeysByUser(key.userId);
+      if (activeKeyCount <= 1) {
+        return {
+          ok: false,
+          error: tError("CANNOT_DISABLE_LAST_KEY") || "无法禁用最后一个可用密钥",
+          errorCode: ERROR_CODES.OPERATION_FAILED,
+        };
+      }
+    }
+
+    await updateKey(keyId, { is_enabled: enabled });
+    revalidatePath("/dashboard");
+    return { ok: true };
+  } catch (error) {
+    logger.error("切换密钥状态失败:", error);
+    const tError = await getTranslations("errors");
+    const message = error instanceof Error ? error.message : tError("UPDATE_KEY_FAILED");
+    return { ok: false, error: message, errorCode: ERROR_CODES.UPDATE_FAILED };
+  }
+}

+ 35 - 0
src/actions/providers.ts

@@ -279,6 +279,41 @@ export async function getAvailableProviderGroups(userId?: number): Promise<strin
   }
 }
 
+/**
+ * 获取所有分组及每个分组的供应商数量
+ * @returns 分组列表及每个分组的供应商数量
+ */
+export async function getProviderGroupsWithCount(): Promise<
+  ActionResult<Array<{ group: string; providerCount: number }>>
+> {
+  try {
+    const providers = await findAllProviders();
+    const groupCounts = new Map<string, number>();
+
+    for (const provider of providers) {
+      if (provider.groupTag) {
+        const groups = provider.groupTag
+          .split(",")
+          .map((g) => g.trim())
+          .filter(Boolean);
+
+        for (const group of groups) {
+          groupCounts.set(group, (groupCounts.get(group) || 0) + 1);
+        }
+      }
+    }
+
+    const result = Array.from(groupCounts.entries())
+      .map(([group, providerCount]) => ({ group, providerCount }))
+      .sort((a, b) => a.group.localeCompare(b.group));
+
+    return { ok: true, data: result };
+  } catch (error) {
+    logger.error("获取供应商分组统计失败:", error);
+    return { ok: false, error: "获取供应商分组统计失败" };
+  }
+}
+
 // 添加服务商
 export async function addProvider(data: {
   name: string;

+ 34 - 0
src/actions/usage-logs.ts

@@ -3,14 +3,17 @@
 import { getSession } from "@/lib/auth";
 import { logger } from "@/lib/logger";
 import {
+  findUsageLogsBatch,
   findUsageLogsStats,
   findUsageLogsWithDetails,
   getUsedEndpoints,
   getUsedModels,
   getUsedStatusCodes,
+  type UsageLogBatchFilters,
   type UsageLogFilters,
   type UsageLogRow,
   type UsageLogSummary,
+  type UsageLogsBatchResult,
   type UsageLogsResult,
 } from "@/repository/usage-logs";
 import type { ActionResult } from "./types";
@@ -305,3 +308,34 @@ export async function getUsageLogsStats(
     return { ok: false, error: message };
   }
 }
+
+/**
+ * 获取使用日志批量数据(游标分页,用于无限滚动)
+ *
+ * 优化效果:
+ * - 无 COUNT 查询,大数据集下性能恒定
+ * - 使用 keyset pagination,避免 OFFSET 扫描
+ * - 支持无限滚动/虚拟滚动场景
+ */
+export async function getUsageLogsBatch(
+  filters: Omit<UsageLogBatchFilters, "userId">
+): Promise<ActionResult<UsageLogsBatchResult>> {
+  try {
+    const session = await getSession();
+    if (!session) {
+      return { ok: false, error: "未登录" };
+    }
+
+    // 如果不是 admin,强制过滤为当前用户
+    const finalFilters: UsageLogBatchFilters =
+      session.user.role === "admin" ? filters : { ...filters, userId: session.user.id };
+
+    const result = await findUsageLogsBatch(finalFilters);
+
+    return { ok: true, data: result };
+  } catch (error) {
+    logger.error("获取使用日志批量数据失败:", error);
+    const message = error instanceof Error ? error.message : "获取使用日志批量数据失败";
+    return { ok: false, error: message };
+  }
+}

+ 311 - 90
src/actions/users.ts

@@ -17,7 +17,6 @@ import {
   findKeyListBatch,
   findKeysWithStatisticsBatch,
   findKeyUsageTodayBatch,
-  updateKey,
 } from "@/repository/key";
 import { createUser, deleteUser, findUserById, findUserList, updateUser } from "@/repository/user";
 import type { UserDisplay } from "@/types/user";
@@ -64,6 +63,38 @@ async function validateExpiresAt(
   return null;
 }
 
+/**
+ * 根据用户名下所有 Key 的分组自动同步用户分组
+ * 用户分组 = Key 分组的并集
+ * 注意:该同步仅在 Key 变更(新增/编辑/删除)时由 Key Actions 触发。
+ * @param userId - 用户 ID
+ */
+export async function syncUserProviderGroupFromKeys(userId: number): Promise<void> {
+  try {
+    const keys = await findKeyList(userId);
+    const allGroups = new Set<string>();
+
+    for (const key of keys) {
+      if (key.providerGroup) {
+        const groups = key.providerGroup
+          .split(",")
+          .map((g) => g.trim())
+          .filter(Boolean);
+        groups.forEach((g) => allGroups.add(g));
+      }
+    }
+
+    const newProviderGroup = allGroups.size > 0 ? Array.from(allGroups).sort().join(",") : null;
+    await updateUser(userId, { providerGroup: newProviderGroup });
+    logger.info(
+      `[UserAction] Synced user provider group: userId=${userId}, groups=${newProviderGroup || "null"}`
+    );
+  } catch (error) {
+    logger.error(`[UserAction] Failed to sync user provider group for user ${userId}:`, error);
+    // 静默失败,不影响主流程
+  }
+}
+
 // 获取用户数据
 export async function getUsers(): Promise<UserDisplay[]> {
   try {
@@ -124,8 +155,12 @@ export async function getUsers(): Promise<UserDisplay[]> {
           limitMonthlyUsd: user.limitMonthlyUsd ?? null,
           limitTotalUsd: user.limitTotalUsd ?? null,
           limitConcurrentSessions: user.limitConcurrentSessions ?? null,
+          dailyResetMode: user.dailyResetMode,
+          dailyResetTime: user.dailyResetTime,
           isEnabled: user.isEnabled,
           expiresAt: user.expiresAt ?? null,
+          allowedClients: user.allowedClients || [],
+          allowedModels: user.allowedModels ?? [],
           keys: keys.map((key) => {
             const stats = statisticsLookup.get(key.id);
             // 用户可以查看和复制自己的密钥,管理员可以查看和复制所有密钥
@@ -185,8 +220,12 @@ export async function getUsers(): Promise<UserDisplay[]> {
           limitMonthlyUsd: user.limitMonthlyUsd ?? null,
           limitTotalUsd: user.limitTotalUsd ?? null,
           limitConcurrentSessions: user.limitConcurrentSessions ?? null,
+          dailyResetMode: user.dailyResetMode,
+          dailyResetTime: user.dailyResetTime,
           isEnabled: user.isEnabled,
           expiresAt: user.expiresAt ?? null,
+          allowedClients: user.allowedClients || [],
+          allowedModels: user.allowedModels ?? [],
           keys: [],
         };
       }
@@ -206,14 +245,18 @@ export async function addUser(data: {
   providerGroup?: string | null;
   tags?: string[];
   rpm?: number;
-  dailyQuota?: number;
+  dailyQuota?: number | null;
   limit5hUsd?: number | null;
   limitWeeklyUsd?: number | null;
   limitMonthlyUsd?: number | null;
   limitTotalUsd?: number | null;
   limitConcurrentSessions?: number | null;
+  dailyResetMode?: "fixed" | "rolling";
+  dailyResetTime?: string;
   isEnabled?: boolean;
   expiresAt?: Date | null;
+  allowedClients?: string[];
+  allowedModels?: string[];
 }): Promise<
   ActionResult<{
     user: {
@@ -232,6 +275,7 @@ export async function addUser(data: {
       limitMonthlyUsd: number | null;
       limitTotalUsd: number | null;
       limitConcurrentSessions: number | null;
+      allowedModels: string[];
     };
     defaultKey: {
       id: number;
@@ -261,14 +305,18 @@ export async function addUser(data: {
       providerGroup: data.providerGroup || "",
       tags: data.tags || [],
       rpm: data.rpm || USER_DEFAULTS.RPM,
-      dailyQuota: data.dailyQuota || USER_DEFAULTS.DAILY_QUOTA,
+      dailyQuota: data.dailyQuota ?? null,
       limit5hUsd: data.limit5hUsd,
       limitWeeklyUsd: data.limitWeeklyUsd,
       limitMonthlyUsd: data.limitMonthlyUsd,
       limitTotalUsd: data.limitTotalUsd,
       limitConcurrentSessions: data.limitConcurrentSessions,
+      dailyResetMode: data.dailyResetMode,
+      dailyResetTime: data.dailyResetTime,
       isEnabled: data.isEnabled,
       expiresAt: data.expiresAt,
+      allowedClients: data.allowedClients || [],
+      allowedModels: data.allowedModels || [],
     });
 
     if (!validationResult.success) {
@@ -315,14 +363,18 @@ export async function addUser(data: {
       providerGroup: validatedData.providerGroup || null,
       tags: validatedData.tags,
       rpm: validatedData.rpm,
-      dailyQuota: validatedData.dailyQuota,
+      dailyQuota: validatedData.dailyQuota ?? undefined,
       limit5hUsd: validatedData.limit5hUsd ?? undefined,
       limitWeeklyUsd: validatedData.limitWeeklyUsd ?? undefined,
       limitMonthlyUsd: validatedData.limitMonthlyUsd ?? undefined,
       limitTotalUsd: validatedData.limitTotalUsd ?? undefined,
       limitConcurrentSessions: validatedData.limitConcurrentSessions ?? undefined,
+      dailyResetMode: validatedData.dailyResetMode,
+      dailyResetTime: validatedData.dailyResetTime,
       isEnabled: validatedData.isEnabled,
       expiresAt: validatedData.expiresAt ?? null,
+      allowedClients: validatedData.allowedClients ?? [],
+      allowedModels: validatedData.allowedModels ?? [],
     });
 
     // 为新用户创建默认密钥
@@ -355,6 +407,7 @@ export async function addUser(data: {
           limitMonthlyUsd: newUser.limitMonthlyUsd ?? null,
           limitTotalUsd: newUser.limitTotalUsd ?? null,
           limitConcurrentSessions: newUser.limitConcurrentSessions ?? null,
+          allowedModels: newUser.allowedModels ?? [],
         },
         defaultKey: {
           id: newKey.id,
@@ -375,6 +428,172 @@ export async function addUser(data: {
   }
 }
 
+// Create user without default key (for unified edit dialog create mode)
+export async function createUserOnly(data: {
+  name: string;
+  note?: string;
+  providerGroup?: string | null;
+  tags?: string[];
+  rpm?: number;
+  dailyQuota?: number;
+  limit5hUsd?: number | null;
+  limitWeeklyUsd?: number | null;
+  limitMonthlyUsd?: number | null;
+  limitTotalUsd?: number | null;
+  limitConcurrentSessions?: number | null;
+  dailyResetMode?: "fixed" | "rolling";
+  dailyResetTime?: string;
+  isEnabled?: boolean;
+  expiresAt?: Date | null;
+  allowedClients?: string[];
+  allowedModels?: string[];
+}): Promise<
+  ActionResult<{
+    user: {
+      id: number;
+      name: string;
+      note?: string;
+      role: string;
+      isEnabled: boolean;
+      expiresAt: Date | null;
+      rpm: number;
+      dailyQuota: number;
+      providerGroup?: string;
+      tags: string[];
+      limit5hUsd: number | null;
+      limitWeeklyUsd: number | null;
+      limitMonthlyUsd: number | null;
+      limitTotalUsd: number | null;
+      limitConcurrentSessions: number | null;
+    };
+  }>
+> {
+  try {
+    const tError = await getTranslations("errors");
+
+    // Permission check: only admin can add users
+    const session = await getSession();
+    if (!session || session.user.role !== "admin") {
+      return {
+        ok: false,
+        error: tError("PERMISSION_DENIED"),
+        errorCode: ERROR_CODES.PERMISSION_DENIED,
+      };
+    }
+
+    // Validate data with Zod
+    const validationResult = CreateUserSchema.safeParse({
+      name: data.name,
+      note: data.note || "",
+      providerGroup: data.providerGroup || "",
+      tags: data.tags || [],
+      rpm: data.rpm || USER_DEFAULTS.RPM,
+      dailyQuota: data.dailyQuota ?? null,
+      limit5hUsd: data.limit5hUsd,
+      limitWeeklyUsd: data.limitWeeklyUsd,
+      limitMonthlyUsd: data.limitMonthlyUsd,
+      limitTotalUsd: data.limitTotalUsd,
+      limitConcurrentSessions: data.limitConcurrentSessions,
+      dailyResetMode: data.dailyResetMode,
+      dailyResetTime: data.dailyResetTime,
+      isEnabled: data.isEnabled,
+      expiresAt: data.expiresAt,
+      allowedClients: data.allowedClients || [],
+      allowedModels: data.allowedModels || [],
+    });
+
+    if (!validationResult.success) {
+      const issue = validationResult.error.issues[0];
+      const { code, params } = await import("@/lib/utils/error-messages").then((m) =>
+        m.zodErrorToCode(issue.code, {
+          minimum: "minimum" in issue ? issue.minimum : undefined,
+          maximum: "maximum" in issue ? issue.maximum : undefined,
+          type: "expected" in issue ? issue.expected : undefined,
+          received: "received" in issue ? issue.received : undefined,
+          validation: "validation" in issue ? issue.validation : undefined,
+          path: issue.path,
+          message: "message" in issue ? issue.message : undefined,
+          params: "params" in issue ? issue.params : undefined,
+        })
+      );
+
+      let translatedParams = params;
+      if (issue.code === "custom" && params?.field && typeof params.field === "string") {
+        try {
+          translatedParams = {
+            ...params,
+            field: tError(params.field as string),
+          };
+        } catch {
+          // Keep original if translation fails
+        }
+      }
+
+      return {
+        ok: false,
+        error: formatZodError(validationResult.error),
+        errorCode: code,
+        errorParams: translatedParams,
+      };
+    }
+
+    const validatedData = validationResult.data;
+
+    const newUser = await createUser({
+      name: validatedData.name,
+      description: validatedData.note || "",
+      providerGroup: validatedData.providerGroup || null,
+      tags: validatedData.tags,
+      rpm: validatedData.rpm,
+      dailyQuota: validatedData.dailyQuota ?? undefined,
+      limit5hUsd: validatedData.limit5hUsd ?? undefined,
+      limitWeeklyUsd: validatedData.limitWeeklyUsd ?? undefined,
+      limitMonthlyUsd: validatedData.limitMonthlyUsd ?? undefined,
+      limitTotalUsd: validatedData.limitTotalUsd ?? undefined,
+      limitConcurrentSessions: validatedData.limitConcurrentSessions ?? undefined,
+      dailyResetMode: validatedData.dailyResetMode,
+      dailyResetTime: validatedData.dailyResetTime,
+      isEnabled: validatedData.isEnabled,
+      expiresAt: validatedData.expiresAt ?? null,
+      allowedClients: validatedData.allowedClients ?? [],
+      allowedModels: validatedData.allowedModels ?? [],
+    });
+
+    revalidatePath("/dashboard");
+    return {
+      ok: true,
+      data: {
+        user: {
+          id: newUser.id,
+          name: newUser.name,
+          note: newUser.description || undefined,
+          role: newUser.role,
+          isEnabled: newUser.isEnabled,
+          expiresAt: newUser.expiresAt ?? null,
+          rpm: newUser.rpm,
+          dailyQuota: newUser.dailyQuota,
+          providerGroup: newUser.providerGroup || undefined,
+          tags: newUser.tags || [],
+          limit5hUsd: newUser.limit5hUsd ?? null,
+          limitWeeklyUsd: newUser.limitWeeklyUsd ?? null,
+          limitMonthlyUsd: newUser.limitMonthlyUsd ?? null,
+          limitTotalUsd: newUser.limitTotalUsd ?? null,
+          limitConcurrentSessions: newUser.limitConcurrentSessions ?? null,
+        },
+      },
+    };
+  } catch (error) {
+    logger.error("Failed to create user:", error);
+    const tError = await getTranslations("errors");
+    const message = error instanceof Error ? error.message : tError("CREATE_USER_FAILED");
+    return {
+      ok: false,
+      error: message,
+      errorCode: ERROR_CODES.CREATE_FAILED,
+    };
+  }
+}
+
 // 更新用户
 export async function editUser(
   userId: number,
@@ -384,14 +603,18 @@ export async function editUser(
     providerGroup?: string | null;
     tags?: string[];
     rpm?: number;
-    dailyQuota?: number;
+    dailyQuota?: number | null;
     limit5hUsd?: number | null;
     limitWeeklyUsd?: number | null;
     limitMonthlyUsd?: number | null;
     limitTotalUsd?: number | null;
     limitConcurrentSessions?: number | null;
+    dailyResetMode?: "fixed" | "rolling";
+    dailyResetTime?: string;
     isEnabled?: boolean;
     expiresAt?: Date | null;
+    allowedClients?: string[];
+    allowedModels?: string[];
   }
 ): Promise<ActionResult> {
   try {
@@ -468,9 +691,6 @@ export async function editUser(
       };
     }
 
-    // 在更新前获取旧用户数据(用于级联更新判断)
-    const oldUserForCascade = data.providerGroup !== undefined ? await findUserById(userId) : null;
-
     // Update user with validated data
     await updateUser(userId, {
       name: validatedData.name,
@@ -479,91 +699,20 @@ export async function editUser(
       tags: validatedData.tags,
       rpm: validatedData.rpm,
       dailyQuota: validatedData.dailyQuota,
-      limit5hUsd: validatedData.limit5hUsd ?? undefined,
-      limitWeeklyUsd: validatedData.limitWeeklyUsd ?? undefined,
-      limitMonthlyUsd: validatedData.limitMonthlyUsd ?? undefined,
-      limitTotalUsd: validatedData.limitTotalUsd ?? undefined,
-      limitConcurrentSessions: validatedData.limitConcurrentSessions ?? undefined,
+      limit5hUsd: validatedData.limit5hUsd,
+      limitWeeklyUsd: validatedData.limitWeeklyUsd,
+      limitMonthlyUsd: validatedData.limitMonthlyUsd,
+      limitTotalUsd: validatedData.limitTotalUsd,
+      limitConcurrentSessions: validatedData.limitConcurrentSessions,
+      dailyResetMode: validatedData.dailyResetMode,
+      dailyResetTime: validatedData.dailyResetTime,
       isEnabled: validatedData.isEnabled,
       expiresAt: validatedData.expiresAt,
+      allowedClients: validatedData.allowedClients,
+      allowedModels: validatedData.allowedModels,
     });
 
-    // 级联更新 KEY 的 providerGroup(仅针对减少场景)
-    if (oldUserForCascade && data.providerGroup !== undefined) {
-      // 只有在 providerGroup 真正变化时才级联更新
-      if (oldUserForCascade.providerGroup !== data.providerGroup) {
-        const oldUserGroups = oldUserForCascade.providerGroup
-          ? oldUserForCascade.providerGroup
-              .split(",")
-              .map((g) => g.trim())
-              .filter(Boolean)
-          : [];
-
-        const newUserGroups = data.providerGroup
-          ? data.providerGroup
-              .split(",")
-              .map((g) => g.trim())
-              .filter(Boolean)
-          : [];
-
-        // 计算被移除的分组
-        const removedGroups = oldUserGroups.filter((g) => !newUserGroups.includes(g));
-
-        // 如果没有移除分组(只新增),直接跳过
-        if (removedGroups.length === 0) {
-          logger.debug(`用户 ${userId} 的 providerGroup 只新增分组,无需级联更新 KEY`);
-        } else {
-          // 有移除分组,需要级联更新 KEY
-          logger.info(
-            `用户 ${userId} 移除了供应商分组: ${removedGroups.join(",")},开始级联更新 KEY`
-          );
-
-          // 获取该用户的所有 KEY
-          const userKeys = await findKeyList(userId);
-
-          for (const key of userKeys) {
-            if (!key.providerGroup) {
-              // KEY 未设置 providerGroup,继承用户配置,无需更新
-              continue;
-            }
-
-            // 解析 KEY 的分组列表
-            const keyGroups = key.providerGroup
-              .split(",")
-              .map((g) => g.trim())
-              .filter(Boolean);
-
-            // 检查 KEY 是否包含被移除的分组
-            const hasRemovedGroups = keyGroups.some((g) => removedGroups.includes(g));
-            if (!hasRemovedGroups) {
-              // KEY 不包含被移除的分组,无需更新
-              continue;
-            }
-
-            // 过滤:只保留在用户新范围内的分组
-            const filteredGroups = keyGroups.filter((g) => newUserGroups.includes(g));
-
-            // 计算新值
-            const newKeyProviderGroup = filteredGroups.length > 0 ? filteredGroups.join(",") : null;
-
-            // 如果值发生变化,更新 KEY
-            if (newKeyProviderGroup !== key.providerGroup) {
-              await updateKey(key.id, {
-                provider_group: newKeyProviderGroup,
-              });
-
-              logger.info(`级联更新 KEY ${key.id} 的 providerGroup`, {
-                keyName: key.name,
-                oldValue: key.providerGroup,
-                newValue: newKeyProviderGroup,
-                removedGroups: removedGroups.join(","),
-                reason: "用户 providerGroup 减少",
-              });
-            }
-          }
-        }
-      }
-    }
+    // 用户分组由 Key 分组自动计算,不再需要级联更新 Key 的 providerGroup
 
     revalidatePath("/dashboard");
     return { ok: true };
@@ -659,7 +808,7 @@ export async function getUserLimitUsage(userId: number): Promise<
         },
         dailyCost: {
           current: dailyCost,
-          limit: user.dailyQuota || 100,
+          limit: user.dailyQuota ?? 100,
           resetAt: getDailyResetTime(),
         },
       },
@@ -794,3 +943,75 @@ export async function toggleUserEnabled(userId: number, enabled: boolean): Promi
     };
   }
 }
+
+/**
+ * 获取用户所有限额使用情况(用于限额百分比显示)
+ * 返回各时间周期的使用量和限额
+ */
+export async function getUserAllLimitUsage(userId: number): Promise<
+  ActionResult<{
+    limit5h: { usage: number; limit: number | null };
+    limitDaily: { usage: number; limit: number | null };
+    limitWeekly: { usage: number; limit: number | null };
+    limitMonthly: { usage: number; limit: number | null };
+    limitTotal: { usage: number; limit: number | null };
+  }>
+> {
+  try {
+    const tError = await getTranslations("errors");
+
+    const session = await getSession();
+    if (!session) {
+      return { ok: false, error: tError("UNAUTHORIZED"), errorCode: ERROR_CODES.UNAUTHORIZED };
+    }
+
+    const user = await findUserById(userId);
+    if (!user) {
+      return { ok: false, error: tError("USER_NOT_FOUND"), errorCode: ERROR_CODES.NOT_FOUND };
+    }
+
+    // 权限检查:用户只能查看自己,管理员可以查看所有人
+    if (session.user.role !== "admin" && session.user.id !== userId) {
+      return {
+        ok: false,
+        error: tError("PERMISSION_DENIED"),
+        errorCode: ERROR_CODES.PERMISSION_DENIED,
+      };
+    }
+
+    // 动态导入
+    const { getTimeRangeForPeriod } = await import("@/lib/rate-limit/time-utils");
+    const { sumUserCostInTimeRange, sumUserTotalCost } = await import("@/repository/statistics");
+
+    // 获取各时间范围
+    const range5h = getTimeRangeForPeriod("5h");
+    const rangeDaily = getTimeRangeForPeriod("daily", user.dailyResetTime || "00:00");
+    const rangeWeekly = getTimeRangeForPeriod("weekly");
+    const rangeMonthly = getTimeRangeForPeriod("monthly");
+
+    // 并行查询各时间范围的消费
+    const [usage5h, usageDaily, usageWeekly, usageMonthly, usageTotal] = await Promise.all([
+      sumUserCostInTimeRange(userId, range5h.startTime, range5h.endTime),
+      sumUserCostInTimeRange(userId, rangeDaily.startTime, rangeDaily.endTime),
+      sumUserCostInTimeRange(userId, rangeWeekly.startTime, rangeWeekly.endTime),
+      sumUserCostInTimeRange(userId, rangeMonthly.startTime, rangeMonthly.endTime),
+      sumUserTotalCost(userId),
+    ]);
+
+    return {
+      ok: true,
+      data: {
+        limit5h: { usage: usage5h, limit: user.limit5hUsd ?? null },
+        limitDaily: { usage: usageDaily, limit: user.dailyQuota ?? null },
+        limitWeekly: { usage: usageWeekly, limit: user.limitWeeklyUsd ?? null },
+        limitMonthly: { usage: usageMonthly, limit: user.limitMonthlyUsd ?? null },
+        limitTotal: { usage: usageTotal, limit: user.limitTotalUsd ?? null },
+      },
+    };
+  } catch (error) {
+    logger.error("Failed to fetch user all limit usage:", error);
+    const tError = await getTranslations("errors");
+    const message = error instanceof Error ? error.message : tError("GET_USER_QUOTA_FAILED");
+    return { ok: false, error: message, errorCode: ERROR_CODES.OPERATION_FAILED };
+  }
+}

+ 26 - 10
src/app/[locale]/dashboard/_components/today-leaderboard.tsx

@@ -33,9 +33,12 @@ interface TodayLeaderboardProps {
 function RankBadge({ rank }: { rank: number }) {
   if (rank === 1) {
     return (
-      <div className="flex items-center gap-2">
-        <Trophy className="h-5 w-5 text-yellow-500" />
-        <Badge variant="default" className="bg-yellow-500 hover:bg-yellow-600">
+      <div className="flex items-center gap-1.5">
+        <Trophy className="h-4 w-4 text-yellow-500" />
+        <Badge
+          variant="default"
+          className="bg-yellow-500 hover:bg-yellow-600 min-w-[32px] justify-center"
+        >
           #{rank}
         </Badge>
       </div>
@@ -43,9 +46,12 @@ function RankBadge({ rank }: { rank: number }) {
   }
   if (rank === 2) {
     return (
-      <div className="flex items-center gap-2">
-        <Medal className="h-5 w-5 text-gray-400" />
-        <Badge variant="secondary" className="bg-gray-400 hover:bg-gray-500 text-white">
+      <div className="flex items-center gap-1.5">
+        <Medal className="h-4 w-4 text-gray-400" />
+        <Badge
+          variant="secondary"
+          className="bg-gray-400 hover:bg-gray-500 text-white min-w-[32px] justify-center"
+        >
           #{rank}
         </Badge>
       </div>
@@ -53,16 +59,26 @@ function RankBadge({ rank }: { rank: number }) {
   }
   if (rank === 3) {
     return (
-      <div className="flex items-center gap-2">
-        <Award className="h-5 w-5 text-orange-600" />
-        <Badge variant="secondary" className="bg-orange-600 hover:bg-orange-700 text-white">
+      <div className="flex items-center gap-1.5">
+        <Award className="h-4 w-4 text-orange-600" />
+        <Badge
+          variant="secondary"
+          className="bg-orange-600 hover:bg-orange-700 text-white min-w-[32px] justify-center"
+        >
           #{rank}
         </Badge>
       </div>
     );
   }
 
-  return <div className="text-muted-foreground font-medium">#{rank}</div>;
+  return (
+    <div className="flex items-center gap-1.5">
+      <div className="h-4 w-4" />
+      <Badge variant="outline" className="min-w-[32px] justify-center">
+        #{rank}
+      </Badge>
+    </div>
+  );
 }
 
 interface LeaderboardCardProps {

+ 156 - 0
src/app/[locale]/dashboard/_components/user/forms/access-restrictions-section.tsx

@@ -0,0 +1,156 @@
+"use client";
+
+import { Shield } from "lucide-react";
+import { useCallback, useMemo } from "react";
+import { ArrayTagInputField } from "@/components/form/form-field";
+import { Checkbox } from "@/components/ui/checkbox";
+import { Label } from "@/components/ui/label";
+
+// Preset client patterns
+const PRESET_CLIENTS = [
+  { value: "claude-cli", label: "Claude Code CLI" },
+  { value: "gemini-cli", label: "Gemini CLI" },
+  { value: "factory-cli", label: "Droid CLI" },
+  { value: "codex-cli", label: "Codex CLI" },
+];
+
+// Model name validation pattern: allows alphanumeric, dots, colons, slashes, underscores, hyphens
+// Examples: gemini-1.5-pro, gpt-4.1, claude-3-opus-20240229, o1-mini
+const MODEL_NAME_PATTERN = /^[a-zA-Z0-9._:/-]+$/;
+
+export interface AccessRestrictionsSectionProps {
+  allowedClients: string[];
+  allowedModels: string[];
+  modelSuggestions: string[];
+  onChange: (field: "allowedClients" | "allowedModels", value: string[]) => void;
+  translations: {
+    sections: {
+      accessRestrictions: string;
+    };
+    fields: {
+      allowedClients: {
+        label: string;
+        description: string;
+        customLabel: string;
+        customPlaceholder: string;
+      };
+      allowedModels: {
+        label: string;
+        placeholder: string;
+        description: string;
+      };
+    };
+    presetClients: Record<string, string>;
+  };
+}
+
+export function AccessRestrictionsSection({
+  allowedClients,
+  allowedModels,
+  modelSuggestions,
+  onChange,
+  translations,
+}: AccessRestrictionsSectionProps) {
+  // Separate preset clients from custom clients
+  const { presetSelected, customClients } = useMemo(() => {
+    const presetValues = PRESET_CLIENTS.map((p) => p.value);
+    const preset = (allowedClients || []).filter((c) => presetValues.includes(c));
+    const custom = (allowedClients || []).filter((c) => !presetValues.includes(c));
+    return { presetSelected: preset, customClients: custom };
+  }, [allowedClients]);
+
+  const handlePresetChange = (clientValue: string, checked: boolean) => {
+    const currentClients = allowedClients || [];
+    if (checked) {
+      onChange("allowedClients", [...currentClients, clientValue]);
+    } else {
+      onChange(
+        "allowedClients",
+        currentClients.filter((c) => c !== clientValue)
+      );
+    }
+  };
+
+  const handleCustomClientsChange = (newCustomClients: string[]) => {
+    // Merge preset clients with new custom clients
+    onChange("allowedClients", [...presetSelected, ...newCustomClients]);
+  };
+
+  // Custom validation for model names (allows dots, colons, slashes)
+  const validateModelTag = useCallback(
+    (tag: string): boolean => {
+      if (!tag || tag.trim().length === 0) return false;
+      if (tag.length > 64) return false;
+      if (!MODEL_NAME_PATTERN.test(tag)) return false;
+      if (allowedModels.includes(tag)) return false; // duplicate check
+      if (allowedModels.length >= 50) return false; // max tags check
+      return true;
+    },
+    [allowedModels]
+  );
+
+  return (
+    <section className="rounded-lg border border-border bg-card/50 p-3 space-y-3">
+      <div className="flex items-center gap-2">
+        <Shield className="h-4 w-4 text-muted-foreground" aria-hidden="true" />
+        <h4 className="text-sm font-semibold">{translations.sections.accessRestrictions}</h4>
+      </div>
+
+      {/* Allowed Clients (CLI/IDE restrictions) */}
+      <div className="space-y-3">
+        <div className="space-y-0.5">
+          <Label className="text-sm font-medium">{translations.fields.allowedClients.label}</Label>
+          <p className="text-xs text-muted-foreground">
+            {translations.fields.allowedClients.description}
+          </p>
+        </div>
+
+        {/* Preset client checkboxes in 2x2 grid */}
+        <div className="grid grid-cols-2 gap-2">
+          {PRESET_CLIENTS.map((client) => {
+            const isChecked = presetSelected.includes(client.value);
+            const displayLabel = translations.presetClients[client.value] || client.label;
+            return (
+              <div key={client.value} className="flex items-center space-x-2">
+                <Checkbox
+                  id={`client-${client.value}`}
+                  checked={isChecked}
+                  onCheckedChange={(checked) => handlePresetChange(client.value, checked === true)}
+                />
+                <Label
+                  htmlFor={`client-${client.value}`}
+                  className="text-sm font-normal cursor-pointer"
+                >
+                  {displayLabel}
+                </Label>
+              </div>
+            );
+          })}
+        </div>
+
+        {/* Custom client patterns */}
+        <ArrayTagInputField
+          label={translations.fields.allowedClients.customLabel}
+          maxTagLength={64}
+          maxTags={50}
+          placeholder={translations.fields.allowedClients.customPlaceholder}
+          value={customClients}
+          onChange={handleCustomClientsChange}
+        />
+      </div>
+
+      {/* Allowed Models (AI model restrictions) */}
+      <ArrayTagInputField
+        label={translations.fields.allowedModels.label}
+        maxTagLength={64}
+        maxTags={50}
+        placeholder={translations.fields.allowedModels.placeholder}
+        description={translations.fields.allowedModels.description}
+        value={allowedModels || []}
+        onChange={(value) => onChange("allowedModels", value)}
+        suggestions={modelSuggestions}
+        validateTag={validateModelTag}
+      />
+    </section>
+  );
+}

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

@@ -18,30 +18,36 @@ import {
 } from "@/components/ui/select";
 import { Switch } from "@/components/ui/switch";
 import { useZodForm } from "@/lib/hooks/use-zod-form";
+import { getErrorMessage } from "@/lib/utils/error-messages";
 import { KeyFormSchema } from "@/lib/validation/schemas";
 import type { User } from "@/types/user";
 
 interface AddKeyFormProps {
   userId?: number;
   user?: User;
+  isAdmin?: boolean;
   onSuccess?: (result: { generatedKey: string; name: string }) => void;
 }
 
-export function AddKeyForm({ userId, user, onSuccess }: AddKeyFormProps) {
+export function AddKeyForm({ userId, user, isAdmin = false, onSuccess }: AddKeyFormProps) {
   const [isPending, startTransition] = useTransition();
   const [providerGroupSuggestions, setProviderGroupSuggestions] = useState<string[]>([]);
   const router = useRouter();
   const t = useTranslations("dashboard.addKeyForm");
   const tUI = useTranslations("ui.tagInput");
+  const tCommon = useTranslations("common");
+  const tErrors = useTranslations("errors");
 
   // Load provider group suggestions
   useEffect(() => {
+    // providerGroup 为 admin-only 字段:仅管理员允许编辑 Key.providerGroup
+    if (!isAdmin) return;
     if (user?.id) {
       getAvailableProviderGroups(user.id).then(setProviderGroupSuggestions);
     } else {
       getAvailableProviderGroups().then(setProviderGroupSuggestions);
     }
-  }, [user?.id]);
+  }, [isAdmin, user?.id]);
 
   const form = useZodForm({
     schema: KeyFormSchema,
@@ -71,7 +77,6 @@ export function AddKeyForm({ userId, user, onSuccess }: AddKeyFormProps) {
           name: data.name,
           expiresAt: data.expiresAt || undefined,
           canLoginWebUi: data.canLoginWebUi,
-          providerGroup: data.providerGroup || null,
           limit5hUsd: data.limit5hUsd,
           limitDailyUsd: data.limitDailyUsd,
           dailyResetMode: data.dailyResetMode,
@@ -81,10 +86,14 @@ export function AddKeyForm({ userId, user, onSuccess }: AddKeyFormProps) {
           limitTotalUsd: data.limitTotalUsd,
           limitConcurrentSessions: data.limitConcurrentSessions,
           cacheTtlPreference: data.cacheTtlPreference,
+          ...(isAdmin ? { providerGroup: data.providerGroup || null } : {}),
         });
 
         if (!result.ok) {
-          toast.error(result.error || t("errors.createFailed"));
+          const msg = result.errorCode
+            ? getErrorMessage(tErrors, result.errorCode, result.errorParams)
+            : result.error || t("errors.createFailed");
+          toast.error(msg);
           return;
         }
 
@@ -136,6 +145,7 @@ export function AddKeyForm({ userId, user, onSuccess }: AddKeyFormProps) {
         label={t("expiresAt.label")}
         placeholder={t("expiresAt.placeholder")}
         description={t("expiresAt.description")}
+        clearLabel={tCommon("clearDate")}
         value={String(form.values.expiresAt || "")}
         onChange={(val) => form.setValue("expiresAt", val)}
         error={form.getFieldProps("expiresAt").error}
@@ -182,6 +192,7 @@ export function AddKeyForm({ userId, user, onSuccess }: AddKeyFormProps) {
         onChange={form.getFieldProps("providerGroup").onChange}
         error={form.getFieldProps("providerGroup").error}
         touched={form.getFieldProps("providerGroup").touched}
+        disabled={!isAdmin}
       />
 
       <div className="space-y-2">

+ 183 - 0
src/app/[locale]/dashboard/_components/user/forms/danger-zone.tsx

@@ -0,0 +1,183 @@
+"use client";
+
+import { Loader2, Trash2 } from "lucide-react";
+import { useMemo, useState } from "react";
+import {
+  AlertDialog,
+  AlertDialogAction,
+  AlertDialogCancel,
+  AlertDialogContent,
+  AlertDialogDescription,
+  AlertDialogFooter,
+  AlertDialogHeader,
+  AlertDialogTitle,
+  AlertDialogTrigger,
+} from "@/components/ui/alert-dialog";
+import { Button, buttonVariants } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
+import { Label } from "@/components/ui/label";
+import { cn } from "@/lib/utils";
+
+export interface DangerZoneProps {
+  userId: number;
+  userName: string;
+  onDelete: () => Promise<void>;
+  /**
+   * i18n strings passed from parent.
+   * Expected keys (optional):
+   * - title, description
+   * - delete.title, delete.description, delete.trigger, delete.confirm
+   * - delete.confirmHint (e.g. "Type {name} to confirm")
+   * - actions.cancel
+   * - errors.deleteFailed
+   */
+  translations: Record<string, unknown>;
+}
+
+function getTranslation(translations: Record<string, unknown>, path: string, fallback: string) {
+  const value = path.split(".").reduce<unknown>((acc, key) => {
+    if (acc && typeof acc === "object" && key in (acc as Record<string, unknown>)) {
+      return (acc as Record<string, unknown>)[key];
+    }
+    return undefined;
+  }, translations);
+  return typeof value === "string" && value.trim() ? value : fallback;
+}
+
+export function DangerZone({ userId, userName, onDelete, translations }: DangerZoneProps) {
+  const [deleteOpen, setDeleteOpen] = useState(false);
+  const [isDeleting, setIsDeleting] = useState(false);
+  const [deleteConfirmText, setDeleteConfirmText] = useState("");
+  const [deleteError, setDeleteError] = useState<string | null>(null);
+
+  const canDelete = useMemo(
+    () => deleteConfirmText.trim() === userName,
+    [deleteConfirmText, userName]
+  );
+
+  const handleDelete = async () => {
+    setDeleteError(null);
+    setIsDeleting(true);
+    try {
+      await onDelete();
+      setDeleteOpen(false);
+    } catch (err) {
+      console.error("Delete user failed:", { userId, err });
+      setDeleteError(
+        getTranslation(translations, "errors.deleteFailed", "Operation failed, please try again")
+      );
+    } finally {
+      setIsDeleting(false);
+    }
+  };
+
+  return (
+    <section className="rounded-lg border border-destructive/30 bg-destructive/5 p-4">
+      <header className="space-y-1">
+        <h3 className="text-sm font-medium text-destructive">
+          {getTranslation(translations, "title", "Danger Zone")}
+        </h3>
+        <p className="text-xs text-muted-foreground">
+          {getTranslation(
+            translations,
+            "description",
+            "The following actions are irreversible, please proceed with caution"
+          )}
+        </p>
+      </header>
+
+      <div className="mt-4 grid gap-3">
+        {/* Delete user */}
+        <div className="flex flex-col gap-3 rounded-md border border-destructive/20 bg-background p-3 sm:flex-row sm:items-center sm:justify-between">
+          <div className="space-y-1">
+            <div className="text-sm font-medium">
+              {getTranslation(translations, "delete.title", "Delete User")}
+            </div>
+            <div className="text-xs text-muted-foreground">
+              {getTranslation(
+                translations,
+                "delete.description",
+                "This will delete all associated data and cannot be undone"
+              )}
+            </div>
+          </div>
+
+          <AlertDialog
+            open={deleteOpen}
+            onOpenChange={(next) => {
+              setDeleteOpen(next);
+              if (!next) {
+                setDeleteConfirmText("");
+                setDeleteError(null);
+              }
+            }}
+          >
+            <AlertDialogTrigger asChild>
+              <Button type="button" variant="destructive">
+                <Trash2 className="h-4 w-4" />
+                {getTranslation(translations, "delete.trigger", "Delete")}
+              </Button>
+            </AlertDialogTrigger>
+
+            <AlertDialogContent>
+              <AlertDialogHeader>
+                <AlertDialogTitle>
+                  {getTranslation(translations, "delete.title", "Delete User")}
+                </AlertDialogTitle>
+                <AlertDialogDescription>
+                  {getTranslation(
+                    translations,
+                    "delete.confirmDescription",
+                    `This will delete user "${userName}" and all associated data. This action cannot be undone.`
+                  ).replace("{userName}", userName)}
+                </AlertDialogDescription>
+              </AlertDialogHeader>
+
+              <div className="grid gap-2">
+                <Label htmlFor="delete-confirm-input">
+                  {getTranslation(translations, "delete.confirmLabel", "Confirm")}
+                </Label>
+                <Input
+                  id="delete-confirm-input"
+                  value={deleteConfirmText}
+                  onChange={(e) => setDeleteConfirmText(e.target.value)}
+                  placeholder={getTranslation(
+                    translations,
+                    "delete.confirmHint",
+                    `Type "${userName}" to confirm deletion`
+                  ).replace("{userName}", userName)}
+                  autoComplete="off"
+                />
+              </div>
+
+              {deleteError && <p className="text-sm text-destructive">{deleteError}</p>}
+
+              <AlertDialogFooter>
+                <AlertDialogCancel disabled={isDeleting}>
+                  {getTranslation(translations, "actions.cancel", "Cancel")}
+                </AlertDialogCancel>
+                <AlertDialogAction
+                  onClick={(e) => {
+                    e.preventDefault();
+                    handleDelete();
+                  }}
+                  disabled={isDeleting || !canDelete}
+                  className={cn(buttonVariants({ variant: "destructive" }))}
+                >
+                  {isDeleting ? (
+                    <>
+                      <Loader2 className="h-4 w-4 animate-spin" />
+                      {getTranslation(translations, "delete.loading", "Deleting...")}
+                    </>
+                  ) : (
+                    getTranslation(translations, "delete.confirm", "Confirm Delete")
+                  )}
+                </AlertDialogAction>
+              </AlertDialogFooter>
+            </AlertDialogContent>
+          </AlertDialog>
+        </div>
+      </div>
+    </section>
+  );
+}

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

@@ -18,6 +18,7 @@ import {
 } from "@/components/ui/select";
 import { Switch } from "@/components/ui/switch";
 import { useZodForm } from "@/lib/hooks/use-zod-form";
+import { getErrorMessage } from "@/lib/utils/error-messages";
 import { KeyFormSchema } from "@/lib/validation/schemas";
 import type { User } from "@/types/user";
 
@@ -39,24 +40,29 @@ interface EditKeyFormProps {
     limitConcurrentSessions?: number;
   };
   user?: User;
+  isAdmin?: boolean;
   onSuccess?: () => void;
 }
 
-export function EditKeyForm({ keyData, user, onSuccess }: EditKeyFormProps) {
+export function EditKeyForm({ keyData, user, isAdmin = false, onSuccess }: EditKeyFormProps) {
   const [isPending, startTransition] = useTransition();
   const [providerGroupSuggestions, setProviderGroupSuggestions] = useState<string[]>([]);
   const router = useRouter();
   const t = useTranslations("quota.keys.editKeyForm");
   const tUI = useTranslations("ui.tagInput");
+  const tCommon = useTranslations("common");
+  const tErrors = useTranslations("errors");
 
   // Load provider group suggestions
   useEffect(() => {
+    // providerGroup 为 admin-only 字段:仅管理员允许编辑 Key.providerGroup
+    if (!isAdmin) return;
     if (user?.id) {
       getAvailableProviderGroups(user.id).then(setProviderGroupSuggestions);
     } else {
       getAvailableProviderGroups().then(setProviderGroupSuggestions);
     }
-  }, [user?.id]);
+  }, [isAdmin, user?.id]);
 
   const formatExpiresAt = (expiresAt: string) => {
     if (!expiresAt || expiresAt === "永不过期") return "";
@@ -95,7 +101,6 @@ export function EditKeyForm({ keyData, user, onSuccess }: EditKeyFormProps) {
             name: data.name,
             expiresAt: data.expiresAt || undefined,
             canLoginWebUi: data.canLoginWebUi,
-            providerGroup: data.providerGroup || null,
             cacheTtlPreference: data.cacheTtlPreference,
             limit5hUsd: data.limit5hUsd,
             limitDailyUsd: data.limitDailyUsd,
@@ -105,9 +110,13 @@ export function EditKeyForm({ keyData, user, onSuccess }: EditKeyFormProps) {
             limitMonthlyUsd: data.limitMonthlyUsd,
             limitTotalUsd: data.limitTotalUsd,
             limitConcurrentSessions: data.limitConcurrentSessions,
+            ...(isAdmin ? { providerGroup: data.providerGroup || null } : {}),
           });
           if (!res.ok) {
-            toast.error(res.error || t("error"));
+            const msg = res.errorCode
+              ? getErrorMessage(tErrors, res.errorCode, res.errorParams)
+              : res.error || t("error");
+            toast.error(msg);
             return;
           }
           toast.success(t("success"));
@@ -147,6 +156,7 @@ export function EditKeyForm({ keyData, user, onSuccess }: EditKeyFormProps) {
         label={t("expiresAt.label")}
         placeholder={t("expiresAt.placeholder")}
         description={t("expiresAt.description")}
+        clearLabel={tCommon("clearDate")}
         value={String(form.values.expiresAt || "")}
         onChange={(val) => form.setValue("expiresAt", val)}
         error={form.getFieldProps("expiresAt").error}
@@ -193,6 +203,7 @@ export function EditKeyForm({ keyData, user, onSuccess }: EditKeyFormProps) {
         onChange={form.getFieldProps("providerGroup").onChange}
         error={form.getFieldProps("providerGroup").error}
         touched={form.getFieldProps("providerGroup").touched}
+        disabled={!isAdmin}
       />
 
       <div className="space-y-2">

+ 407 - 0
src/app/[locale]/dashboard/_components/user/forms/key-edit-section.tsx

@@ -0,0 +1,407 @@
+"use client";
+
+import { format } from "date-fns";
+import { Calendar, Gauge, Key, Plus, Sparkles } from "lucide-react";
+import { useEffect, useMemo, useState } from "react";
+import { DatePickerField } from "@/components/form/date-picker-field";
+import { TextField } from "@/components/form/form-field";
+import { Button } from "@/components/ui/button";
+import { Label } from "@/components/ui/label";
+import {
+  Select,
+  SelectContent,
+  SelectItem,
+  SelectTrigger,
+  SelectValue,
+} from "@/components/ui/select";
+import { Switch } from "@/components/ui/switch";
+import { cn } from "@/lib/utils";
+import { type DailyResetMode, LimitRulePicker, type LimitType } from "./limit-rule-picker";
+import { type LimitRuleDisplayItem, LimitRulesDisplay } from "./limit-rules-display";
+import { ProviderGroupSelect } from "./provider-group-select";
+import { QuickExpirePicker } from "./quick-expire-picker";
+
+export interface KeyEditSectionProps {
+  keyData: {
+    id: number;
+    name: string;
+    isEnabled?: boolean;
+    expiresAt?: Date | null;
+    canLoginWebUi?: boolean;
+    providerGroup?: string | null;
+    cacheTtlPreference?: "inherit" | "5m" | "1h";
+    // 所有限额字段
+    limit5hUsd?: number | null;
+    limitDailyUsd?: number | null;
+    dailyResetMode?: "fixed" | "rolling";
+    dailyResetTime?: string;
+    limitWeeklyUsd?: number | null;
+    limitMonthlyUsd?: number | null;
+    limitTotalUsd?: number | null;
+    limitConcurrentSessions?: number;
+  };
+  /** providerGroup 为 admin-only 字段:非管理员仅可查看不可编辑 */
+  isAdmin?: boolean;
+  onChange: {
+    (field: string, value: any): void;
+    (batch: Record<string, any>): void;
+  };
+  scrollRef?: React.RefObject<HTMLDivElement>;
+  translations: {
+    sections: {
+      basicInfo: string;
+      expireTime: string;
+      limitRules: string;
+      specialFeatures: string;
+    };
+    fields: {
+      keyName: { label: string; placeholder: string };
+      balanceQueryPage: {
+        label: string;
+        description: string;
+        descriptionEnabled: string;
+        descriptionDisabled: string;
+      };
+      providerGroup: { label: string; placeholder: string };
+      cacheTtl: { label: string; options: Record<string, string> };
+      enableStatus?: { label: string; description: string };
+    };
+    limitRules: any;
+    quickExpire: any;
+  };
+}
+
+function getTranslation(translations: Record<string, unknown>, path: string, fallback: string) {
+  const value = path.split(".").reduce<unknown>((acc, key) => {
+    if (acc && typeof acc === "object" && key in (acc as Record<string, unknown>)) {
+      return (acc as Record<string, unknown>)[key];
+    }
+    return undefined;
+  }, translations);
+  return typeof value === "string" && value.trim() ? value : fallback;
+}
+
+function toEndOfDay(date: Date): Date {
+  const d = new Date(date);
+  d.setHours(23, 59, 59, 999);
+  return d;
+}
+
+function parseDateStringEndOfDay(value: string): Date | null {
+  if (!value) return null;
+  const [year, month, day] = value.split("-").map(Number);
+  if (!Number.isFinite(year) || !Number.isFinite(month) || !Number.isFinite(day)) return null;
+  return toEndOfDay(new Date(year, month - 1, day));
+}
+
+function formatDateInput(date?: Date | null): string {
+  if (!date) return "";
+  try {
+    return format(date, "yyyy-MM-dd");
+  } catch {
+    return "";
+  }
+}
+
+const TTL_ORDER = ["inherit", "5m", "1h"] as const;
+
+export function KeyEditSection({
+  keyData,
+  isAdmin = false,
+  onChange,
+  scrollRef,
+  translations,
+}: KeyEditSectionProps) {
+  const [limitPickerOpen, setLimitPickerOpen] = useState(false);
+
+  useEffect(() => {
+    if (!scrollRef?.current) return;
+    scrollRef.current.scrollIntoView({ behavior: "smooth", block: "start" });
+  }, [scrollRef]);
+
+  const limitRules = useMemo<LimitRuleDisplayItem[]>(() => {
+    const rules: LimitRuleDisplayItem[] = [];
+
+    if (typeof keyData.limit5hUsd === "number" && keyData.limit5hUsd > 0) {
+      rules.push({ type: "limit5h", value: keyData.limit5hUsd });
+    }
+
+    if (typeof keyData.limitDailyUsd === "number" && keyData.limitDailyUsd > 0) {
+      rules.push({
+        type: "limitDaily",
+        value: keyData.limitDailyUsd,
+        mode: keyData.dailyResetMode ?? "fixed",
+        time: keyData.dailyResetTime ?? "00:00",
+      });
+    }
+
+    if (typeof keyData.limitWeeklyUsd === "number" && keyData.limitWeeklyUsd > 0) {
+      rules.push({ type: "limitWeekly", value: keyData.limitWeeklyUsd });
+    }
+
+    if (typeof keyData.limitMonthlyUsd === "number" && keyData.limitMonthlyUsd > 0) {
+      rules.push({ type: "limitMonthly", value: keyData.limitMonthlyUsd });
+    }
+
+    if (typeof keyData.limitTotalUsd === "number" && keyData.limitTotalUsd > 0) {
+      rules.push({ type: "limitTotal", value: keyData.limitTotalUsd });
+    }
+
+    if (
+      typeof keyData.limitConcurrentSessions === "number" &&
+      keyData.limitConcurrentSessions > 0
+    ) {
+      rules.push({ type: "limitSessions", value: keyData.limitConcurrentSessions });
+    }
+
+    return rules;
+  }, [
+    keyData.limit5hUsd,
+    keyData.limitDailyUsd,
+    keyData.dailyResetMode,
+    keyData.dailyResetTime,
+    keyData.limitWeeklyUsd,
+    keyData.limitMonthlyUsd,
+    keyData.limitTotalUsd,
+    keyData.limitConcurrentSessions,
+  ]);
+
+  const existingLimitTypes = useMemo(() => limitRules.map((r) => r.type), [limitRules]);
+
+  const handleRemoveLimitRule = (type: string) => {
+    switch (type) {
+      case "limit5h":
+        onChange("limit5hUsd", null);
+        return;
+      case "limitDaily":
+        // Batch update to avoid race condition
+        onChange({
+          limitDailyUsd: null,
+          dailyResetMode: "fixed",
+          dailyResetTime: "00:00",
+        });
+        return;
+      case "limitWeekly":
+        onChange("limitWeeklyUsd", null);
+        return;
+      case "limitMonthly":
+        onChange("limitMonthlyUsd", null);
+        return;
+      case "limitTotal":
+        onChange("limitTotalUsd", null);
+        return;
+      case "limitSessions":
+        onChange("limitConcurrentSessions", 0);
+        return;
+      default:
+        return;
+    }
+  };
+
+  const handleConfirmLimitRule = (
+    type: LimitType,
+    value: number,
+    mode?: DailyResetMode,
+    time?: string
+  ) => {
+    if (!Number.isFinite(value) || value <= 0) {
+      handleRemoveLimitRule(type);
+      return;
+    }
+
+    switch (type) {
+      case "limit5h":
+        onChange("limit5hUsd", value);
+        return;
+      case "limitDaily": {
+        const nextMode: DailyResetMode = mode ?? keyData.dailyResetMode ?? "fixed";
+        // Batch update to avoid race condition
+        onChange({
+          limitDailyUsd: value,
+          dailyResetMode: nextMode,
+          dailyResetTime:
+            nextMode === "fixed"
+              ? (time ?? keyData.dailyResetTime ?? "00:00")
+              : keyData.dailyResetTime,
+        });
+        return;
+      }
+      case "limitWeekly":
+        onChange("limitWeeklyUsd", value);
+        return;
+      case "limitMonthly":
+        onChange("limitMonthlyUsd", value);
+        return;
+      case "limitTotal":
+        onChange("limitTotalUsd", value);
+        return;
+      case "limitSessions":
+        onChange("limitConcurrentSessions", Math.max(0, Math.floor(value)));
+        return;
+      default:
+        return;
+    }
+  };
+
+  const expiresAtValue = useMemo(() => formatDateInput(keyData.expiresAt), [keyData.expiresAt]);
+
+  const cacheTtlPreference = keyData.cacheTtlPreference ?? "inherit";
+  const cacheTtlOptions = translations.fields.cacheTtl.options || {};
+
+  const addRuleText = useMemo(
+    () => getTranslation(translations.limitRules || {}, "actions.add", "添加规则"),
+    [translations.limitRules]
+  );
+
+  return (
+    <div ref={scrollRef} className="space-y-3 scroll-mt-24">
+      {/* 基本信息区域 */}
+      <section className="rounded-lg border border-border bg-card/50 p-3 space-y-3">
+        <div className="flex items-center gap-2">
+          <Key className="h-4 w-4 text-muted-foreground" aria-hidden="true" />
+          <h4 className="text-sm font-semibold">{translations.sections.basicInfo}</h4>
+        </div>
+        <TextField
+          label={translations.fields.keyName.label}
+          placeholder={translations.fields.keyName.placeholder}
+          required
+          maxLength={64}
+          value={keyData.name}
+          onChange={(val) => onChange("name", val)}
+        />
+        <div className="flex items-center justify-between gap-4 py-1">
+          <div className="space-y-0.5">
+            <Label htmlFor={`key-enable-${keyData.id}`} className="text-sm font-medium">
+              {translations.fields.enableStatus?.label || "Enable Status"}
+            </Label>
+            <p className="text-xs text-muted-foreground">
+              {translations.fields.enableStatus?.description || "Disabled keys cannot be used"}
+            </p>
+          </div>
+          <Switch
+            id={`key-enable-${keyData.id}`}
+            checked={keyData.isEnabled ?? true}
+            onCheckedChange={(checked) => onChange("isEnabled", checked)}
+          />
+        </div>
+      </section>
+
+      {/* 到期时间区域 */}
+      <section className="rounded-lg border border-border bg-card/50 p-3 space-y-3">
+        <div className="flex items-center gap-2">
+          <Calendar className="h-4 w-4 text-muted-foreground" aria-hidden="true" />
+          <h4 className="text-sm font-semibold">{translations.sections.expireTime}</h4>
+        </div>
+        <DatePickerField
+          label={translations.sections.expireTime}
+          value={expiresAtValue}
+          onChange={(val) => onChange("expiresAt", parseDateStringEndOfDay(val))}
+        />
+        <QuickExpirePicker
+          translations={translations.quickExpire || {}}
+          onSelect={(date) => onChange("expiresAt", toEndOfDay(date))}
+        />
+      </section>
+
+      {/* 限额规则区域 */}
+      <section className="rounded-lg border border-border bg-card/50 p-3 space-y-3">
+        <div className="flex items-center justify-between gap-3">
+          <div className="flex items-center gap-2">
+            <Gauge className="h-4 w-4 text-muted-foreground" aria-hidden="true" />
+            <h4 className="text-sm font-semibold">{translations.sections.limitRules}</h4>
+          </div>
+          <Button
+            type="button"
+            variant="outline"
+            size="sm"
+            onClick={() => setLimitPickerOpen(true)}
+          >
+            <Plus className="mr-2 h-4 w-4" />
+            {addRuleText}
+          </Button>
+        </div>
+
+        <LimitRulesDisplay
+          rules={limitRules}
+          onRemove={handleRemoveLimitRule}
+          translations={translations.limitRules || {}}
+        />
+
+        <LimitRulePicker
+          open={limitPickerOpen}
+          onOpenChange={setLimitPickerOpen}
+          onConfirm={handleConfirmLimitRule}
+          existingTypes={existingLimitTypes}
+          translations={translations.limitRules || {}}
+        />
+      </section>
+
+      {/* 特殊功能区域 */}
+      <section
+        className={cn(
+          "rounded-lg border border-border bg-muted/30 px-3 py-3 space-y-3",
+          "shadow-none"
+        )}
+      >
+        <div className="flex items-center gap-2">
+          <Sparkles className="h-4 w-4 text-muted-foreground" aria-hidden="true" />
+          <h4 className="text-sm font-semibold">{translations.sections.specialFeatures}</h4>
+        </div>
+
+        <div className="flex items-start justify-between gap-4 rounded-lg border border-dashed border-border bg-background px-4 py-3">
+          <div>
+            <Label htmlFor={`key-${keyData.id}-balance-page`} className="text-sm font-medium">
+              {translations.fields.balanceQueryPage.label}
+            </Label>
+            <p className="text-xs text-muted-foreground mt-1">
+              {keyData.canLoginWebUi
+                ? translations.fields.balanceQueryPage.descriptionEnabled
+                : translations.fields.balanceQueryPage.descriptionDisabled}
+            </p>
+          </div>
+          <Switch
+            id={`key-${keyData.id}-balance-page`}
+            checked={keyData.canLoginWebUi ?? false}
+            onCheckedChange={(checked) => onChange("canLoginWebUi", checked)}
+          />
+        </div>
+
+        <ProviderGroupSelect
+          value={keyData.providerGroup || ""}
+          onChange={(val) => onChange("providerGroup", val)}
+          disabled={!isAdmin}
+          translations={{
+            label: translations.fields.providerGroup.label,
+            placeholder: translations.fields.providerGroup.placeholder,
+          }}
+        />
+
+        <div className="space-y-2">
+          <Label>{translations.fields.cacheTtl.label}</Label>
+          <Select
+            value={cacheTtlPreference}
+            onValueChange={(val) => onChange("cacheTtlPreference", val as "inherit" | "5m" | "1h")}
+          >
+            <SelectTrigger>
+              <SelectValue placeholder={cacheTtlPreference} />
+            </SelectTrigger>
+            <SelectContent>
+              {TTL_ORDER.filter((k) => k in cacheTtlOptions).map((k) => (
+                <SelectItem key={k} value={k}>
+                  {cacheTtlOptions[k]}
+                </SelectItem>
+              ))}
+              {Object.entries(cacheTtlOptions)
+                .filter(([k]) => !TTL_ORDER.includes(k as (typeof TTL_ORDER)[number]))
+                .map(([k, label]) => (
+                  <SelectItem key={k} value={k}>
+                    {label}
+                  </SelectItem>
+                ))}
+            </SelectContent>
+          </Select>
+        </div>
+      </section>
+    </div>
+  );
+}

+ 291 - 0
src/app/[locale]/dashboard/_components/user/forms/limit-rule-picker.tsx

@@ -0,0 +1,291 @@
+"use client";
+
+import { AlertTriangle } from "lucide-react";
+import { useEffect, useMemo, useState } from "react";
+import { Button } from "@/components/ui/button";
+import {
+  Dialog,
+  DialogContent,
+  DialogDescription,
+  DialogFooter,
+  DialogHeader,
+  DialogTitle,
+} from "@/components/ui/dialog";
+import { Input } from "@/components/ui/input";
+import { Label } from "@/components/ui/label";
+import {
+  Select,
+  SelectContent,
+  SelectItem,
+  SelectTrigger,
+  SelectValue,
+} from "@/components/ui/select";
+import { cn } from "@/lib/utils";
+
+export type LimitType =
+  | "limit5h"
+  | "limitDaily"
+  | "limitWeekly"
+  | "limitMonthly"
+  | "limitTotal"
+  | "limitSessions";
+
+export type DailyResetMode = "fixed" | "rolling";
+
+export interface LimitRulePickerProps {
+  open: boolean;
+  onOpenChange: (open: boolean) => void;
+  onConfirm: (type: LimitType, value: number, mode?: DailyResetMode, time?: string) => void;
+  /** Types that are already configured (used for showing overwrite hint). */
+  existingTypes: string[];
+  /**
+   * i18n strings passed from parent.
+   * Expected keys (optional):
+   * - title, description, cancel, confirm
+   * - fields.type.label, fields.type.placeholder
+   * - fields.value.label, fields.value.placeholder
+   * - daily.mode.label, daily.mode.fixed, daily.mode.rolling
+   * - daily.time.label, daily.time.placeholder
+   * - limitTypes.{limit5h|limitDaily|limitWeekly|limitMonthly|limitTotal|limitSessions}
+   * - errors.missingType, errors.invalidValue, errors.invalidTime
+   * - overwriteHint
+   */
+  translations: Record<string, unknown>;
+}
+
+const LIMIT_TYPE_OPTIONS: Array<{ type: LimitType; fallbackLabel: string }> = [
+  { type: "limit5h", fallbackLabel: "5小时限额" },
+  { type: "limitDaily", fallbackLabel: "每日限额" },
+  { type: "limitWeekly", fallbackLabel: "周限额" },
+  { type: "limitMonthly", fallbackLabel: "月限额" },
+  { type: "limitTotal", fallbackLabel: "总限额" },
+  { type: "limitSessions", fallbackLabel: "并发 Session" },
+];
+
+const QUICK_VALUES = [10, 50, 100, 500] as const;
+const SESSION_QUICK_VALUES = [5, 10, 15, 20] as const;
+
+function getTranslation(translations: Record<string, unknown>, path: string, fallback: string) {
+  const value = path.split(".").reduce<unknown>((acc, key) => {
+    if (acc && typeof acc === "object" && key in (acc as Record<string, unknown>)) {
+      return (acc as Record<string, unknown>)[key];
+    }
+    return undefined;
+  }, translations);
+  return typeof value === "string" && value.trim() ? value : fallback;
+}
+
+function isValidTime(value: string) {
+  return /^\d{2}:\d{2}$/.test(value);
+}
+
+export function LimitRulePicker({
+  open,
+  onOpenChange,
+  onConfirm,
+  existingTypes,
+  translations,
+}: LimitRulePickerProps) {
+  // Keep existingTypeSet for showing overwrite hint, but no longer filter availableTypes
+  const existingTypeSet = useMemo(() => new Set(existingTypes), [existingTypes]);
+  // All types are always available - selecting an existing type will overwrite it
+  const availableTypes = LIMIT_TYPE_OPTIONS;
+
+  const [type, setType] = useState<LimitType | "">("");
+  const [rawValue, setRawValue] = useState("");
+  const [dailyMode, setDailyMode] = useState<DailyResetMode>("fixed");
+  const [dailyTime, setDailyTime] = useState("00:00");
+  const [error, setError] = useState<string | null>(null);
+
+  // Reset state when dialog opens
+  useEffect(() => {
+    if (!open) return;
+    const first = availableTypes[0]?.type ?? "";
+    setType((prev) => (prev ? prev : first));
+    setRawValue("");
+    setDailyMode("fixed");
+    setDailyTime("00:00");
+    setError(null);
+  }, [open, availableTypes]);
+
+  const numericValue = useMemo(() => {
+    const trimmed = rawValue.trim();
+    if (!trimmed) return Number.NaN;
+    return Number(trimmed);
+  }, [rawValue]);
+
+  const isDaily = type === "limitDaily";
+  const needsTime = isDaily && dailyMode === "fixed";
+
+  const canConfirm =
+    type !== "" &&
+    Number.isFinite(numericValue) &&
+    numericValue >= 0 &&
+    (!needsTime || isValidTime(dailyTime));
+
+  const handleCancel = () => onOpenChange(false);
+
+  const handleSubmit = () => {
+    setError(null);
+
+    if (!type) {
+      setError(getTranslation(translations, "errors.missingType", "请选择限额类型"));
+      return;
+    }
+
+    if (!Number.isFinite(numericValue) || numericValue < 0) {
+      setError(getTranslation(translations, "errors.invalidValue", "请输入有效数值"));
+      return;
+    }
+
+    if (needsTime && !isValidTime(dailyTime)) {
+      setError(getTranslation(translations, "errors.invalidTime", "请输入有效时间 (HH:mm)"));
+      return;
+    }
+
+    if (type === "limitDaily") {
+      onConfirm(type, numericValue, dailyMode, dailyMode === "fixed" ? dailyTime : undefined);
+    } else {
+      onConfirm(type, numericValue);
+    }
+    onOpenChange(false);
+  };
+
+  return (
+    <Dialog open={open} onOpenChange={onOpenChange}>
+      <DialogContent className="sm:max-w-[680px]">
+        <DialogHeader>
+          <DialogTitle>{getTranslation(translations, "title", "添加限额规则")}</DialogTitle>
+          <DialogDescription>
+            {getTranslation(translations, "description", "选择限额类型并设置数值")}
+          </DialogDescription>
+        </DialogHeader>
+
+        <form
+          className="grid gap-4"
+          onSubmit={(e) => {
+            e.preventDefault();
+            e.stopPropagation();
+            handleSubmit();
+          }}
+        >
+          <div className="grid gap-4 sm:grid-cols-2">
+            <div className="space-y-2">
+              <Label>{getTranslation(translations, "fields.type.label", "限额类型")}</Label>
+              <Select value={type} onValueChange={(val) => setType(val as LimitType)}>
+                <SelectTrigger>
+                  <SelectValue
+                    placeholder={getTranslation(translations, "fields.type.placeholder", "请选择")}
+                  />
+                </SelectTrigger>
+                <SelectContent>
+                  {availableTypes.map((opt) => (
+                    <SelectItem key={opt.type} value={opt.type}>
+                      {getTranslation(translations, `limitTypes.${opt.type}`, opt.fallbackLabel)}
+                    </SelectItem>
+                  ))}
+                </SelectContent>
+              </Select>
+              {type && existingTypeSet.has(type) && (
+                <div className="flex items-center gap-1.5 text-xs text-amber-600 dark:text-amber-400">
+                  <AlertTriangle className="h-3.5 w-3.5 shrink-0" aria-hidden="true" />
+                  <span>
+                    {getTranslation(
+                      translations,
+                      "overwriteHint",
+                      "此类型已存在,保存将覆盖原有值"
+                    )}
+                  </span>
+                </div>
+              )}
+            </div>
+
+            <div className="space-y-2">
+              <Label>{getTranslation(translations, "fields.value.label", "数值")}</Label>
+              <Input
+                type="number"
+                min={0}
+                step={type === "limitSessions" ? 1 : 0.01}
+                inputMode="decimal"
+                autoFocus
+                value={rawValue}
+                onChange={(e) => setRawValue(e.target.value)}
+                placeholder={getTranslation(translations, "fields.value.placeholder", "请输入")}
+                aria-invalid={Boolean(error)}
+              />
+
+              <div className="flex flex-wrap gap-2">
+                {(type === "limitSessions" ? SESSION_QUICK_VALUES : QUICK_VALUES).map((v) => (
+                  <Button
+                    key={v}
+                    type="button"
+                    variant="outline"
+                    size="sm"
+                    onClick={() => setRawValue(String(v))}
+                  >
+                    {type === "limitSessions" ? v : `$${v}`}
+                  </Button>
+                ))}
+              </div>
+            </div>
+          </div>
+
+          {isDaily && (
+            <div className={cn("grid gap-4", dailyMode === "fixed" ? "sm:grid-cols-2" : "")}>
+              <div className="space-y-2">
+                <Label>{getTranslation(translations, "daily.mode.label", "每日模式")}</Label>
+                <Select
+                  value={dailyMode}
+                  onValueChange={(val) => setDailyMode(val as DailyResetMode)}
+                >
+                  <SelectTrigger>
+                    <SelectValue />
+                  </SelectTrigger>
+                  <SelectContent>
+                    <SelectItem value="fixed">
+                      {getTranslation(translations, "daily.mode.fixed", "fixed")}
+                    </SelectItem>
+                    <SelectItem value="rolling">
+                      {getTranslation(translations, "daily.mode.rolling", "rolling")}
+                    </SelectItem>
+                  </SelectContent>
+                </Select>
+              </div>
+
+              {dailyMode === "fixed" && (
+                <div className="space-y-2">
+                  <Label>{getTranslation(translations, "daily.time.label", "重置时间")}</Label>
+                  <Input
+                    type="time"
+                    step={60}
+                    value={dailyTime}
+                    onChange={(e) => setDailyTime(e.target.value)}
+                    placeholder={getTranslation(translations, "daily.time.placeholder", "HH:mm")}
+                    aria-invalid={Boolean(error)}
+                  />
+                </div>
+              )}
+
+              {dailyMode === "rolling" && (
+                <p className="text-xs text-muted-foreground">
+                  {getTranslation(translations, "daily.mode.helperRolling", "rolling 24h")}
+                </p>
+              )}
+            </div>
+          )}
+
+          {error && <p className="text-sm text-destructive">{error}</p>}
+
+          <DialogFooter>
+            <Button type="button" variant="outline" onClick={handleCancel}>
+              {getTranslation(translations, "cancel", "取消")}
+            </Button>
+            <Button type="submit" disabled={!canConfirm}>
+              {getTranslation(translations, "confirm", "保存")}
+            </Button>
+          </DialogFooter>
+        </form>
+      </DialogContent>
+    </Dialog>
+  );
+}

+ 90 - 0
src/app/[locale]/dashboard/_components/user/forms/limit-rules-display.tsx

@@ -0,0 +1,90 @@
+"use client";
+
+import { X } from "lucide-react";
+import { Badge } from "@/components/ui/badge";
+import { Button } from "@/components/ui/button";
+import { Card } from "@/components/ui/card";
+import { cn } from "@/lib/utils";
+
+export interface LimitRuleDisplayItem {
+  type: string;
+  value: number;
+  mode?: string;
+  time?: string;
+}
+
+export interface LimitRulesDisplayProps {
+  rules: LimitRuleDisplayItem[];
+  onRemove: (type: string) => void;
+  /**
+   * i18n strings passed from parent.
+   * Expected keys (optional):
+   * - limitTypes.{limit5h|limitDaily|limitWeekly|limitMonthly|limitTotal|limitSessions}
+   * - daily.mode.fixed, daily.mode.rolling
+   * - actions.remove
+   */
+  translations: Record<string, unknown>;
+}
+
+function getTranslation(translations: Record<string, unknown>, path: string, fallback: string) {
+  const value = path.split(".").reduce<unknown>((acc, key) => {
+    if (acc && typeof acc === "object" && key in (acc as Record<string, unknown>)) {
+      return (acc as Record<string, unknown>)[key];
+    }
+    return undefined;
+  }, translations);
+  return typeof value === "string" && value.trim() ? value : fallback;
+}
+
+function formatValue(raw: number): string {
+  if (!Number.isFinite(raw)) return String(raw);
+  if (Number.isInteger(raw)) return String(raw);
+  return raw.toFixed(2).replace(/\.00$/, "");
+}
+
+export function LimitRulesDisplay({ rules, onRemove, translations }: LimitRulesDisplayProps) {
+  if (!rules || rules.length === 0) return null;
+
+  return (
+    <div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
+      {rules.map((rule) => {
+        const typeLabel = getTranslation(translations, `limitTypes.${rule.type}`, rule.type);
+        const formattedValue = formatValue(rule.value);
+
+        const isDaily = rule.type === "limitDaily";
+        const dailyMode = rule.mode;
+        const dailyDetail =
+          isDaily && dailyMode
+            ? dailyMode === "fixed"
+              ? `${getTranslation(translations, "daily.mode.fixed", "fixed")} ${rule.time || "00:00"}`
+              : `${getTranslation(translations, "daily.mode.rolling", "rolling")} 24h`
+            : null;
+
+        return (
+          <Card key={rule.type} className="gap-2 border-border py-3 px-3 shadow-none">
+            <div className="flex items-start justify-between gap-3">
+              <div className="min-w-0 space-y-1">
+                <Badge variant="secondary" className="max-w-full truncate">
+                  {typeLabel}
+                </Badge>
+                <div className="text-sm font-medium tabular-nums">{formattedValue}</div>
+                {dailyDetail && <div className="text-xs text-muted-foreground">{dailyDetail}</div>}
+              </div>
+
+              <Button
+                type="button"
+                variant="ghost"
+                size="icon-sm"
+                className={cn("shrink-0 text-muted-foreground hover:text-foreground")}
+                onClick={() => onRemove(rule.type)}
+                aria-label={getTranslation(translations, "actions.remove", "Remove")}
+              >
+                <X className="h-4 w-4" />
+              </Button>
+            </div>
+          </Card>
+        );
+      })}
+    </div>
+  );
+}

+ 122 - 0
src/app/[locale]/dashboard/_components/user/forms/provider-group-select.tsx

@@ -0,0 +1,122 @@
+"use client";
+
+import { useEffect, useMemo, useState } from "react";
+import { toast } from "sonner";
+import { getProviderGroupsWithCount } from "@/actions/providers";
+import { TagInputField } from "@/components/form/form-field";
+import type { TagInputSuggestion } from "@/components/ui/tag-input";
+
+export interface ProviderGroupSelectProps {
+  /** Comma-separated group tags. */
+  value: string;
+  onChange: (value: string) => void;
+  disabled?: boolean;
+  /** Whether to show provider counts in suggestions. Defaults to `true`. */
+  showProviderCount?: boolean;
+  /**
+   * i18n strings passed from parent.
+   * Expected keys (optional):
+   * - label, placeholder, description
+   * - providersSuffix (e.g. "providers")
+   * - tagInputErrors.{empty|duplicate|too_long|invalid_format|max_tags}
+   * - errors.loadFailed
+   */
+  translations: Record<string, unknown>;
+}
+
+function getTranslation(translations: Record<string, unknown>, path: string, fallback: string) {
+  const value = path.split(".").reduce<unknown>((acc, key) => {
+    if (acc && typeof acc === "object" && key in (acc as Record<string, unknown>)) {
+      return (acc as Record<string, unknown>)[key];
+    }
+    return undefined;
+  }, translations);
+  return typeof value === "string" && value.trim() ? value : fallback;
+}
+
+export function ProviderGroupSelect({
+  value,
+  onChange,
+  disabled = false,
+  showProviderCount = true,
+  translations,
+}: ProviderGroupSelectProps) {
+  const [groups, setGroups] = useState<Array<{ group: string; providerCount: number }>>([]);
+  const [isLoading, setIsLoading] = useState(false);
+  const loadFailedText = useMemo(
+    () => getTranslation(translations, "errors.loadFailed", "加载失败"),
+    [translations]
+  );
+
+  useEffect(() => {
+    let alive = true;
+    if (disabled) {
+      setGroups([]);
+      setIsLoading(false);
+      return () => {
+        alive = false;
+      };
+    }
+    setIsLoading(true);
+    getProviderGroupsWithCount()
+      .then((res) => {
+        if (!alive) return;
+        if (res.ok) {
+          setGroups(res.data || []);
+          return;
+        }
+        console.error("获取供应商分组统计失败:", res.error);
+        toast.error(res.error || loadFailedText);
+        setGroups([]);
+      })
+      .catch((err) => {
+        if (!alive) return;
+        console.error("获取供应商分组统计失败:", err);
+        toast.error(loadFailedText);
+        setGroups([]);
+      })
+      .finally(() => {
+        if (!alive) return;
+        setIsLoading(false);
+      });
+
+    return () => {
+      alive = false;
+    };
+  }, [loadFailedText, disabled]);
+
+  const suggestions: TagInputSuggestion[] = useMemo(() => {
+    if (!showProviderCount) return groups.map((g) => g.group);
+    const suffix = getTranslation(translations, "providersSuffix", "providers");
+    return groups.map((g) => ({
+      value: g.group,
+      label: `${g.group} (${g.providerCount} ${suffix})`,
+      keywords: [String(g.providerCount)],
+    }));
+  }, [groups, showProviderCount, translations]);
+
+  const description = useMemo(() => {
+    const base = getTranslation(translations, "description", "");
+    if (isLoading && !base) {
+      return getTranslation(translations, "loadingText", "加载中...");
+    }
+    return base;
+  }, [translations, isLoading]);
+
+  return (
+    <TagInputField
+      label={getTranslation(translations, "label", "供应商分组")}
+      placeholder={getTranslation(translations, "placeholder", "输入分组并回车")}
+      description={description}
+      maxTagLength={50}
+      maxTags={20}
+      suggestions={suggestions}
+      disabled={disabled}
+      onInvalidTag={(_tag, reason) => {
+        toast.error(getTranslation(translations, `tagInputErrors.${reason}`, reason));
+      }}
+      value={value}
+      onChange={onChange}
+    />
+  );
+}

+ 67 - 0
src/app/[locale]/dashboard/_components/user/forms/quick-expire-picker.tsx

@@ -0,0 +1,67 @@
+"use client";
+
+import { Button } from "@/components/ui/button";
+
+export interface QuickExpirePickerProps {
+  onSelect: (date: Date) => void;
+  /**
+   * i18n strings passed from parent.
+   * Expected keys (optional):
+   * - week, month, threeMonths, year
+   */
+  translations: Record<string, unknown>;
+}
+
+function getTranslation(translations: Record<string, unknown>, key: string, fallback: string) {
+  const value = translations?.[key];
+  return typeof value === "string" && value.trim() ? value : fallback;
+}
+
+function addDays(date: Date, days: number) {
+  const d = new Date(date);
+  d.setDate(d.getDate() + days);
+  return d;
+}
+
+function addMonths(date: Date, months: number) {
+  const d = new Date(date);
+  d.setMonth(d.getMonth() + months);
+  return d;
+}
+
+function addYears(date: Date, years: number) {
+  const d = new Date(date);
+  d.setFullYear(d.getFullYear() + years);
+  return d;
+}
+
+export function QuickExpirePicker({ onSelect, translations }: QuickExpirePickerProps) {
+  const base = new Date();
+
+  return (
+    <div className="flex flex-wrap gap-2">
+      <Button type="button" variant="outline" size="sm" onClick={() => onSelect(addDays(base, 7))}>
+        {getTranslation(translations, "week", "一周后")}
+      </Button>
+      <Button
+        type="button"
+        variant="outline"
+        size="sm"
+        onClick={() => onSelect(addMonths(base, 1))}
+      >
+        {getTranslation(translations, "month", "一月后")}
+      </Button>
+      <Button
+        type="button"
+        variant="outline"
+        size="sm"
+        onClick={() => onSelect(addMonths(base, 3))}
+      >
+        {getTranslation(translations, "threeMonths", "三月后")}
+      </Button>
+      <Button type="button" variant="outline" size="sm" onClick={() => onSelect(addYears(base, 1))}>
+        {getTranslation(translations, "year", "一年后")}
+      </Button>
+    </div>
+  );
+}

+ 275 - 0
src/app/[locale]/dashboard/_components/user/forms/quick-renew-dialog.tsx

@@ -0,0 +1,275 @@
+"use client";
+
+import { addDays } from "date-fns";
+import { Loader2 } from "lucide-react";
+import { useLocale } from "next-intl";
+import { useCallback, useMemo, useState } from "react";
+import { DatePickerField } from "@/components/form/date-picker-field";
+import { Button } from "@/components/ui/button";
+import {
+  Dialog,
+  DialogContent,
+  DialogDescription,
+  DialogFooter,
+  DialogHeader,
+  DialogTitle,
+} from "@/components/ui/dialog";
+import { Label } from "@/components/ui/label";
+import { Switch } from "@/components/ui/switch";
+import { formatDate, formatDateDistance } from "@/lib/utils/date-format";
+
+export interface QuickRenewUser {
+  id: number;
+  name: string;
+  expiresAt?: Date | null;
+  isEnabled: boolean;
+}
+
+export interface QuickRenewDialogProps {
+  open: boolean;
+  onOpenChange: (open: boolean) => void;
+  user: QuickRenewUser | null;
+  onConfirm: (userId: number, expiresAt: Date, enableUser?: boolean) => Promise<{ ok: boolean }>;
+  translations: {
+    title: string;
+    description: string;
+    currentExpiry: string;
+    neverExpires: string;
+    expired: string;
+    quickOptions: {
+      "7days": string;
+      "30days": string;
+      "90days": string;
+      "1year": string;
+    };
+    customDate: string;
+    enableOnRenew: string;
+    cancel: string;
+    confirm: string;
+    confirming: string;
+  };
+}
+
+function getTranslation(translations: Record<string, unknown>, path: string, fallback: string) {
+  const value = path.split(".").reduce<unknown>((acc, key) => {
+    if (acc && typeof acc === "object" && key in (acc as Record<string, unknown>)) {
+      return (acc as Record<string, unknown>)[key];
+    }
+    return undefined;
+  }, translations);
+  return typeof value === "string" && value.trim() ? value : fallback;
+}
+
+export function QuickRenewDialog({
+  open,
+  onOpenChange,
+  user,
+  onConfirm,
+  translations,
+}: QuickRenewDialogProps) {
+  const locale = useLocale();
+  const [customDate, setCustomDate] = useState("");
+  const [enableOnRenew, setEnableOnRenew] = useState(false);
+  const [isSubmitting, setIsSubmitting] = useState(false);
+
+  // Format current expiry for display
+  const currentExpiryText = useMemo(() => {
+    if (!user?.expiresAt) {
+      return getTranslation(translations, "neverExpires", "Never expires");
+    }
+    const expiresAt = user.expiresAt instanceof Date ? user.expiresAt : new Date(user.expiresAt);
+    const now = new Date();
+    if (expiresAt <= now) {
+      return getTranslation(translations, "expired", "Expired");
+    }
+    const relative = formatDateDistance(expiresAt, now, locale, { addSuffix: true });
+    const absolute = formatDate(expiresAt, "yyyy-MM-dd", locale);
+    return `${relative} (${absolute})`;
+  }, [user?.expiresAt, locale, translations]);
+
+  // Handle quick selection
+  const handleQuickSelect = useCallback(
+    async (days: number) => {
+      if (!user) return;
+      setIsSubmitting(true);
+      try {
+        // Base date: max(current time, original expiry time)
+        const baseDate =
+          user.expiresAt && new Date(user.expiresAt) > new Date()
+            ? new Date(user.expiresAt)
+            : new Date();
+        const newDate = addDays(baseDate, days);
+        // Set to end of day
+        newDate.setHours(23, 59, 59, 999);
+        const result = await onConfirm(
+          user.id,
+          newDate,
+          !user.isEnabled && enableOnRenew ? true : undefined
+        );
+        if (result.ok) {
+          onOpenChange(false);
+        }
+      } finally {
+        setIsSubmitting(false);
+      }
+    },
+    [user, enableOnRenew, onConfirm, onOpenChange]
+  );
+
+  // Handle custom date confirm
+  const handleCustomConfirm = useCallback(async () => {
+    if (!user || !customDate) return;
+    setIsSubmitting(true);
+    try {
+      const [year, month, day] = customDate.split("-").map(Number);
+      if (Number.isNaN(year) || Number.isNaN(month) || Number.isNaN(day)) {
+        setIsSubmitting(false);
+        return;
+      }
+      const newDate = new Date(year, month - 1, day);
+      newDate.setHours(23, 59, 59, 999);
+      const result = await onConfirm(
+        user.id,
+        newDate,
+        !user.isEnabled && enableOnRenew ? true : undefined
+      );
+      if (result.ok) {
+        onOpenChange(false);
+      }
+    } finally {
+      setIsSubmitting(false);
+    }
+  }, [user, customDate, enableOnRenew, onConfirm, onOpenChange]);
+
+  // Reset state when dialog closes
+  const handleOpenChange = useCallback(
+    (nextOpen: boolean) => {
+      if (!nextOpen) {
+        setCustomDate("");
+        setEnableOnRenew(false);
+      }
+      onOpenChange(nextOpen);
+    },
+    [onOpenChange]
+  );
+
+  if (!user) return null;
+
+  return (
+    <Dialog open={open} onOpenChange={handleOpenChange}>
+      <DialogContent className="sm:max-w-md">
+        <DialogHeader>
+          <DialogTitle>{getTranslation(translations, "title", "Quick Renew")}</DialogTitle>
+          <DialogDescription>
+            {getTranslation(
+              translations,
+              "description",
+              "Set new expiration date for user {userName}"
+            ).replace("{userName}", user.name)}
+          </DialogDescription>
+        </DialogHeader>
+
+        <div className="space-y-4 py-4">
+          {/* Current expiry display */}
+          <div className="text-sm">
+            <span className="text-muted-foreground">
+              {getTranslation(translations, "currentExpiry", "Current Expiration")}:{" "}
+            </span>
+            <span className="font-medium">{currentExpiryText}</span>
+          </div>
+
+          {/* Quick select buttons */}
+          <div className="grid grid-cols-4 gap-2">
+            <Button
+              variant="outline"
+              size="sm"
+              onClick={() => handleQuickSelect(7)}
+              disabled={isSubmitting}
+            >
+              {isSubmitting ? (
+                <Loader2 className="h-4 w-4 animate-spin" />
+              ) : (
+                getTranslation(translations, "quickOptions.7days", "7 Days")
+              )}
+            </Button>
+            <Button
+              variant="outline"
+              size="sm"
+              onClick={() => handleQuickSelect(30)}
+              disabled={isSubmitting}
+            >
+              {isSubmitting ? (
+                <Loader2 className="h-4 w-4 animate-spin" />
+              ) : (
+                getTranslation(translations, "quickOptions.30days", "30 Days")
+              )}
+            </Button>
+            <Button
+              variant="outline"
+              size="sm"
+              onClick={() => handleQuickSelect(90)}
+              disabled={isSubmitting}
+            >
+              {isSubmitting ? (
+                <Loader2 className="h-4 w-4 animate-spin" />
+              ) : (
+                getTranslation(translations, "quickOptions.90days", "90 Days")
+              )}
+            </Button>
+            <Button
+              variant="outline"
+              size="sm"
+              onClick={() => handleQuickSelect(365)}
+              disabled={isSubmitting}
+            >
+              {isSubmitting ? (
+                <Loader2 className="h-4 w-4 animate-spin" />
+              ) : (
+                getTranslation(translations, "quickOptions.1year", "1 Year")
+              )}
+            </Button>
+          </div>
+
+          {/* Custom date picker */}
+          <DatePickerField
+            id="quick-renew-date"
+            label={getTranslation(translations, "customDate", "Custom Date")}
+            value={customDate}
+            onChange={setCustomDate}
+            minDate={new Date()}
+          />
+
+          {/* Enable on renew switch (only show if user is disabled) */}
+          {!user.isEnabled && (
+            <div className="flex items-center space-x-2">
+              <Switch
+                id="enable-on-renew"
+                checked={enableOnRenew}
+                onCheckedChange={setEnableOnRenew}
+              />
+              <Label htmlFor="enable-on-renew" className="text-sm font-normal cursor-pointer">
+                {getTranslation(translations, "enableOnRenew", "Also enable user")}
+              </Label>
+            </div>
+          )}
+        </div>
+
+        <DialogFooter>
+          <Button variant="outline" onClick={() => handleOpenChange(false)} disabled={isSubmitting}>
+            {getTranslation(translations, "cancel", "Cancel")}
+          </Button>
+          <Button onClick={handleCustomConfirm} disabled={!customDate || isSubmitting}>
+            {isSubmitting ? (
+              <>
+                <Loader2 className="h-4 w-4 animate-spin mr-2" />
+                {getTranslation(translations, "confirming", "Renewing...")}
+              </>
+            ) : (
+              getTranslation(translations, "confirm", "Confirm")
+            )}
+          </Button>
+        </DialogFooter>
+      </DialogContent>
+    </Dialog>
+  );
+}

+ 473 - 0
src/app/[locale]/dashboard/_components/user/forms/user-edit-section.tsx

@@ -0,0 +1,473 @@
+"use client";
+
+import { Calendar, Gauge, Loader2, ShieldCheck, ShieldOff, User } from "lucide-react";
+import { useMemo, useState } from "react";
+import { DatePickerField } from "@/components/form/date-picker-field";
+import { ArrayTagInputField, TextField } from "@/components/form/form-field";
+import {
+  AlertDialog,
+  AlertDialogAction,
+  AlertDialogCancel,
+  AlertDialogContent,
+  AlertDialogDescription,
+  AlertDialogFooter,
+  AlertDialogHeader,
+  AlertDialogTitle,
+} from "@/components/ui/alert-dialog";
+import { Button } from "@/components/ui/button";
+import { Label } from "@/components/ui/label";
+import { Switch } from "@/components/ui/switch";
+import { cn } from "@/lib/utils";
+import { AccessRestrictionsSection } from "./access-restrictions-section";
+import { type DailyResetMode, LimitRulePicker, type LimitType } from "./limit-rule-picker";
+import { type LimitRuleDisplayItem, LimitRulesDisplay } from "./limit-rules-display";
+import { QuickExpirePicker } from "./quick-expire-picker";
+
+export interface UserEditSectionProps {
+  user: {
+    id: number;
+    name: string;
+    description?: string;
+    tags?: string[];
+    expiresAt?: Date | null;
+    providerGroup?: string | null;
+    // 所有限额字段
+    limit5hUsd?: number | null;
+    dailyQuota?: number | null; // 新增:用户每日限额
+    limitWeeklyUsd?: number | null;
+    limitMonthlyUsd?: number | null;
+    limitTotalUsd?: number | null;
+    limitConcurrentSessions?: number | null;
+    dailyResetMode?: "fixed" | "rolling";
+    dailyResetTime?: string;
+    // 访问限制字段
+    allowedClients?: string[];
+    allowedModels?: string[];
+  };
+  isEnabled?: boolean;
+  onToggleEnabled?: () => Promise<void>;
+  showProviderGroup?: boolean;
+  modelSuggestions?: string[];
+  onChange: {
+    (field: string, value: any): void;
+    (batch: Record<string, any>): void;
+  };
+  translations: {
+    sections: {
+      basicInfo: string;
+      expireTime: string;
+      limitRules: string;
+      accessRestrictions: string;
+    };
+    fields: {
+      username: { label: string; placeholder: string };
+      description: { label: string; placeholder: string };
+      tags: { label: string; placeholder: string };
+      providerGroup?: {
+        label: string;
+        placeholder: string;
+      };
+      enableStatus?: {
+        label: string;
+        enabledDescription: string;
+        disabledDescription: string;
+        confirmEnable: string;
+        confirmDisable: string;
+        confirmEnableTitle: string;
+        confirmDisableTitle: string;
+        confirmEnableDescription: string;
+        confirmDisableDescription: string;
+        cancel: string;
+        processing: string;
+      };
+      allowedClients: {
+        label: string;
+        description: string;
+        customLabel: string;
+        customPlaceholder: string;
+      };
+      allowedModels: {
+        label: string;
+        placeholder: string;
+        description: string;
+      };
+    };
+    presetClients: Record<string, string>;
+    limitRules: {
+      addRule: string;
+      ruleTypes: Record<string, string>;
+      quickValues: Record<string, string>;
+    };
+    quickExpire: Record<string, string>;
+  };
+}
+
+function formatYmdLocal(date: Date): string {
+  const year = date.getFullYear();
+  const month = String(date.getMonth() + 1).padStart(2, "0");
+  const day = String(date.getDate()).padStart(2, "0");
+  return `${year}-${month}-${day}`;
+}
+
+function parseYmdToEndOfDay(dateStr: string): Date | null {
+  if (!dateStr) return null;
+  const [year, month, day] = dateStr.split("-").map((v) => Number(v));
+  if (!Number.isFinite(year) || !Number.isFinite(month) || !Number.isFinite(day)) return null;
+  const date = new Date(year, month - 1, day);
+  if (Number.isNaN(date.getTime())) return null;
+  date.setHours(23, 59, 59, 999);
+  return date;
+}
+
+function toEndOfDay(date: Date): Date {
+  const d = new Date(date);
+  d.setHours(23, 59, 59, 999);
+  return d;
+}
+
+function toNumberOrNull(value: unknown): number | null {
+  if (value === null || value === undefined) return null;
+  if (typeof value === "number") return Number.isFinite(value) ? value : null;
+  const parsed = Number(String(value).trim());
+  return Number.isFinite(parsed) ? parsed : null;
+}
+
+export function UserEditSection({
+  user,
+  isEnabled,
+  onToggleEnabled,
+  showProviderGroup,
+  modelSuggestions = [],
+  onChange,
+  translations,
+}: UserEditSectionProps) {
+  const [rulePickerOpen, setRulePickerOpen] = useState(false);
+  const [toggleConfirmOpen, setToggleConfirmOpen] = useState(false);
+  const [isToggling, setIsToggling] = useState(false);
+
+  const emitChange = (fieldOrBatch: string | Record<string, any>, value?: any) => {
+    if (typeof fieldOrBatch === "object") {
+      onChange(fieldOrBatch);
+    } else {
+      onChange(fieldOrBatch, value);
+    }
+  };
+
+  const handleToggleEnabled = async () => {
+    if (!onToggleEnabled) return;
+    setIsToggling(true);
+    try {
+      await onToggleEnabled();
+      setToggleConfirmOpen(false);
+    } finally {
+      setIsToggling(false);
+    }
+  };
+
+  const expiresAtValue = useMemo(() => {
+    if (!user.expiresAt) return "";
+    return formatYmdLocal(new Date(user.expiresAt));
+  }, [user.expiresAt]);
+
+  const rules = useMemo<LimitRuleDisplayItem[]>(() => {
+    const items: LimitRuleDisplayItem[] = [];
+
+    const add = (type: LimitType, value: unknown, extra?: Partial<LimitRuleDisplayItem>) => {
+      const numeric = toNumberOrNull(value);
+      if (!numeric || numeric <= 0) return;
+      items.push({ type, value: numeric, ...extra });
+    };
+
+    add("limit5h", user.limit5hUsd);
+    // 新增:添加每日限额到 rules
+    add("limitDaily", user.dailyQuota, {
+      mode: user.dailyResetMode ?? "fixed",
+      time: user.dailyResetTime ?? "00:00",
+    });
+    add("limitWeekly", user.limitWeeklyUsd);
+    add("limitMonthly", user.limitMonthlyUsd);
+    add("limitTotal", user.limitTotalUsd);
+    add("limitSessions", user.limitConcurrentSessions);
+
+    return items;
+  }, [
+    user.limit5hUsd,
+    user.dailyQuota,
+    user.dailyResetMode,
+    user.dailyResetTime,
+    user.limitWeeklyUsd,
+    user.limitMonthlyUsd,
+    user.limitTotalUsd,
+    user.limitConcurrentSessions,
+  ]);
+
+  const existingTypes = useMemo(() => {
+    // 现在允许用户设置每日限额,不再排除 limitDaily
+    return rules.map((r) => r.type);
+  }, [rules]);
+
+  const limitRuleTranslations = useMemo(() => {
+    return {
+      title: translations.limitRules.addRule,
+      limitTypes: translations.limitRules.ruleTypes,
+    } satisfies Record<string, unknown>;
+  }, [translations.limitRules.addRule, translations.limitRules.ruleTypes]);
+
+  const handleRemoveRule = (type: string) => {
+    switch (type) {
+      case "limit5h":
+        emitChange("limit5hUsd", null);
+        return;
+      case "limitDaily":
+        // Batch update to avoid race condition
+        emitChange({
+          dailyQuota: null,
+          dailyResetMode: "fixed",
+          dailyResetTime: "00:00",
+        });
+        return;
+      case "limitWeekly":
+        emitChange("limitWeeklyUsd", null);
+        return;
+      case "limitMonthly":
+        emitChange("limitMonthlyUsd", null);
+        return;
+      case "limitTotal":
+        emitChange("limitTotalUsd", null);
+        return;
+      case "limitSessions":
+        emitChange("limitConcurrentSessions", null);
+        return;
+      default:
+        return;
+    }
+  };
+
+  const handleAddRule = (type: LimitType, value: number, mode?: DailyResetMode, time?: string) => {
+    switch (type) {
+      case "limit5h":
+        emitChange("limit5hUsd", value);
+        return;
+      case "limitDaily":
+        // Batch update to avoid race condition
+        emitChange({
+          dailyQuota: value,
+          dailyResetMode: mode || "fixed",
+          dailyResetTime: time || "00:00",
+        });
+        return;
+      case "limitWeekly":
+        emitChange("limitWeeklyUsd", value);
+        return;
+      case "limitMonthly":
+        emitChange("limitMonthlyUsd", value);
+        return;
+      case "limitTotal":
+        emitChange("limitTotalUsd", value);
+        return;
+      case "limitSessions":
+        emitChange("limitConcurrentSessions", value);
+        return;
+      default:
+        return;
+    }
+  };
+
+  const enableStatusTranslations = translations.fields.enableStatus;
+
+  return (
+    <div className="space-y-4">
+      <section className="rounded-lg border border-border bg-card/50 p-3 space-y-3">
+        <div className="flex items-center gap-2">
+          <User className="h-4 w-4 text-muted-foreground" aria-hidden="true" />
+          <h4 className="text-sm font-semibold">{translations.sections.basicInfo}</h4>
+        </div>
+        <div className="grid gap-3 sm:grid-cols-2">
+          <div className="space-y-3">
+            <TextField
+              label={translations.fields.username.label}
+              placeholder={translations.fields.username.placeholder}
+              value={user.name || ""}
+              onChange={(val) => emitChange("name", val)}
+              maxLength={64}
+            />
+
+            {/* Enable/Disable toggle - only show if onToggleEnabled is provided */}
+            {onToggleEnabled && enableStatusTranslations && (
+              <div
+                className={cn(
+                  "flex items-center justify-between rounded-md border p-3",
+                  isEnabled ? "border-border bg-background" : "border-amber-500/30 bg-amber-500/5"
+                )}
+              >
+                <div className="space-y-0.5">
+                  <Label
+                    htmlFor="user-enabled-toggle"
+                    className="text-sm font-medium flex items-center gap-2 cursor-pointer"
+                  >
+                    {isEnabled ? (
+                      <ShieldCheck className="h-4 w-4 text-green-600" />
+                    ) : (
+                      <ShieldOff className="h-4 w-4 text-amber-600" />
+                    )}
+                    {enableStatusTranslations.label || "Enable Status"}
+                  </Label>
+                  <p className="text-xs text-muted-foreground">
+                    {isEnabled
+                      ? enableStatusTranslations.enabledDescription || "Currently enabled"
+                      : enableStatusTranslations.disabledDescription || "Currently disabled"}
+                  </p>
+                </div>
+                <Switch
+                  id="user-enabled-toggle"
+                  checked={isEnabled}
+                  onCheckedChange={() => setToggleConfirmOpen(true)}
+                  disabled={isToggling}
+                />
+
+                <AlertDialog open={toggleConfirmOpen} onOpenChange={setToggleConfirmOpen}>
+                  <AlertDialogContent>
+                    <AlertDialogHeader>
+                      <AlertDialogTitle>
+                        {isEnabled
+                          ? enableStatusTranslations.confirmDisableTitle || "Disable User"
+                          : enableStatusTranslations.confirmEnableTitle || "Enable User"}
+                      </AlertDialogTitle>
+                      <AlertDialogDescription>
+                        {isEnabled
+                          ? enableStatusTranslations.confirmDisableDescription ||
+                            `Are you sure you want to disable user "${user.name}"?`
+                          : enableStatusTranslations.confirmEnableDescription ||
+                            `Are you sure you want to enable user "${user.name}"?`}
+                      </AlertDialogDescription>
+                    </AlertDialogHeader>
+                    <AlertDialogFooter>
+                      <AlertDialogCancel disabled={isToggling}>
+                        {enableStatusTranslations.cancel || "Cancel"}
+                      </AlertDialogCancel>
+                      <AlertDialogAction
+                        onClick={(e) => {
+                          e.preventDefault();
+                          handleToggleEnabled();
+                        }}
+                        disabled={isToggling}
+                        className={cn(
+                          isEnabled
+                            ? "bg-amber-600 hover:bg-amber-700"
+                            : "bg-green-600 hover:bg-green-700"
+                        )}
+                      >
+                        {isToggling ? (
+                          <>
+                            <Loader2 className="h-4 w-4 animate-spin mr-2" />
+                            {enableStatusTranslations.processing || "Processing..."}
+                          </>
+                        ) : isEnabled ? (
+                          enableStatusTranslations.confirmDisable || "Disable"
+                        ) : (
+                          enableStatusTranslations.confirmEnable || "Enable"
+                        )}
+                      </AlertDialogAction>
+                    </AlertDialogFooter>
+                  </AlertDialogContent>
+                </AlertDialog>
+              </div>
+            )}
+          </div>
+
+          <div className="space-y-3">
+            <TextField
+              label={translations.fields.description.label}
+              placeholder={translations.fields.description.placeholder}
+              value={user.description || ""}
+              onChange={(val) => emitChange("description", val)}
+              maxLength={200}
+            />
+
+            <ArrayTagInputField
+              label={translations.fields.tags.label}
+              placeholder={translations.fields.tags.placeholder}
+              value={user.tags || []}
+              onChange={(val) => emitChange("tags", val)}
+              maxTagLength={32}
+              maxTags={20}
+            />
+
+            {showProviderGroup && translations.fields.providerGroup && (
+              <TextField
+                label={translations.fields.providerGroup.label}
+                placeholder={translations.fields.providerGroup.placeholder}
+                value={user.providerGroup || ""}
+                onChange={(val) => emitChange("providerGroup", val?.trim() || null)}
+                maxLength={50}
+              />
+            )}
+          </div>
+        </div>
+      </section>
+
+      <section className="rounded-lg border border-border bg-card/50 p-3 space-y-3">
+        <div className="flex items-center gap-2">
+          <Calendar className="h-4 w-4 text-muted-foreground" aria-hidden="true" />
+          <h4 className="text-sm font-semibold">{translations.sections.expireTime}</h4>
+        </div>
+        <div className="space-y-3">
+          <DatePickerField
+            label={translations.sections.expireTime}
+            value={expiresAtValue}
+            onChange={(val) => emitChange("expiresAt", val ? parseYmdToEndOfDay(val) : null)}
+            className="max-w-md"
+          />
+
+          <QuickExpirePicker
+            translations={translations.quickExpire}
+            onSelect={(date) => emitChange("expiresAt", toEndOfDay(date))}
+          />
+        </div>
+      </section>
+
+      <section className="rounded-lg border border-border bg-card/50 p-3 space-y-3">
+        <div className="flex items-center justify-between gap-3">
+          <div className="flex items-center gap-2">
+            <Gauge className="h-4 w-4 text-muted-foreground" aria-hidden="true" />
+            <h4 className="text-sm font-semibold">{translations.sections.limitRules}</h4>
+          </div>
+          <Button type="button" variant="outline" size="sm" onClick={() => setRulePickerOpen(true)}>
+            {translations.limitRules.addRule}
+          </Button>
+        </div>
+
+        <LimitRulesDisplay
+          rules={rules}
+          onRemove={handleRemoveRule}
+          translations={limitRuleTranslations}
+        />
+
+        <LimitRulePicker
+          open={rulePickerOpen}
+          onOpenChange={setRulePickerOpen}
+          onConfirm={handleAddRule}
+          existingTypes={existingTypes}
+          translations={limitRuleTranslations}
+        />
+      </section>
+
+      <AccessRestrictionsSection
+        allowedClients={user.allowedClients || []}
+        allowedModels={user.allowedModels || []}
+        modelSuggestions={modelSuggestions}
+        onChange={onChange}
+        translations={{
+          sections: {
+            accessRestrictions: translations.sections.accessRestrictions,
+          },
+          fields: {
+            allowedClients: translations.fields.allowedClients,
+            allowedModels: translations.fields.allowedModels,
+          },
+          presetClients: translations.presetClients,
+        }}
+      />
+    </div>
+  );
+}

+ 115 - 3
src/app/[locale]/dashboard/_components/user/forms/user-form.tsx

@@ -9,6 +9,8 @@ import { addUser, editUser } from "@/actions/users";
 import { DatePickerField } from "@/components/form/date-picker-field";
 import { ArrayTagInputField, TagInputField, TextField } from "@/components/form/form-field";
 import { DialogFormLayout, FormGrid } from "@/components/form/form-layout";
+import { Checkbox } from "@/components/ui/checkbox";
+import { Label } from "@/components/ui/label";
 import { Switch } from "@/components/ui/switch";
 import { USER_DEFAULTS, USER_LIMITS } from "@/lib/constants/user.constants";
 import { useZodForm } from "@/lib/hooks/use-zod-form";
@@ -16,6 +18,14 @@ import { getErrorMessage } from "@/lib/utils/error-messages";
 import { setZodErrorMap } from "@/lib/utils/zod-i18n";
 import { CreateUserSchema } from "@/lib/validation/schemas";
 
+// Preset client patterns
+const PRESET_CLIENTS = [
+  { value: "claude-cli", label: "Claude Code CLI" },
+  { value: "gemini-cli", label: "Gemini CLI" },
+  { value: "factory-cli", label: "Droid CLI" },
+  { value: "codex-cli", label: "Codex CLI" },
+];
+
 // 前端表单使用的 schema(接受字符串日期)
 const UserFormSchema = CreateUserSchema.extend({
   expiresAt: z.string().optional(),
@@ -37,6 +47,8 @@ interface UserFormProps {
     limitConcurrentSessions?: number | null;
     isEnabled?: boolean;
     expiresAt?: Date | null;
+    allowedClients?: string[];
+    allowedModels?: string[];
   };
   onSuccess?: () => void;
   currentUser?: {
@@ -55,6 +67,7 @@ export function UserForm({ user, onSuccess, currentUser }: UserFormProps) {
   const tErrors = useTranslations("errors");
   const tNotifications = useTranslations("notifications");
   const tUI = useTranslations("ui.tagInput");
+  const tCommon = useTranslations("common");
 
   // Set Zod error map for client-side validation
   useEffect(() => {
@@ -63,7 +76,11 @@ export function UserForm({ user, onSuccess, currentUser }: UserFormProps) {
 
   // 加载供应商分组建议
   useEffect(() => {
-    getAvailableProviderGroups().then(setProviderGroupSuggestions);
+    getAvailableProviderGroups()
+      .then(setProviderGroupSuggestions)
+      .catch((err) => {
+        console.error("[UserForm] Failed to load provider groups:", err);
+      });
   }, []);
 
   const form = useZodForm({
@@ -72,7 +89,7 @@ export function UserForm({ user, onSuccess, currentUser }: UserFormProps) {
       name: user?.name || "",
       note: user?.note || "",
       rpm: user?.rpm || USER_DEFAULTS.RPM,
-      dailyQuota: user?.dailyQuota || USER_DEFAULTS.DAILY_QUOTA,
+      dailyQuota: user?.dailyQuota ?? USER_DEFAULTS.DAILY_QUOTA,
       providerGroup: user?.providerGroup || "",
       tags: user?.tags || [],
       limit5hUsd: user?.limit5hUsd ?? null,
@@ -82,6 +99,8 @@ export function UserForm({ user, onSuccess, currentUser }: UserFormProps) {
       limitConcurrentSessions: user?.limitConcurrentSessions ?? null,
       isEnabled: user?.isEnabled ?? true,
       expiresAt: user?.expiresAt ? user.expiresAt.toISOString().split("T")[0] : "",
+      allowedClients: user?.allowedClients || [],
+      allowedModels: user?.allowedModels || [],
     },
     onSubmit: async (data) => {
       // 将纯日期转换为当天结束时间(本地时区 23:59:59.999),避免默认 UTC 零点导致提前过期
@@ -109,6 +128,8 @@ export function UserForm({ user, onSuccess, currentUser }: UserFormProps) {
               limitConcurrentSessions: data.limitConcurrentSessions,
               isEnabled: data.isEnabled,
               expiresAt: data.expiresAt ? toEndOfDay(data.expiresAt) : null,
+              allowedClients: data.allowedClients,
+              allowedModels: data.allowedModels,
             });
           } else {
             res = await addUser({
@@ -125,6 +146,8 @@ export function UserForm({ user, onSuccess, currentUser }: UserFormProps) {
               limitConcurrentSessions: data.limitConcurrentSessions,
               isEnabled: data.isEnabled,
               expiresAt: data.expiresAt ? toEndOfDay(data.expiresAt) : null,
+              allowedClients: data.allowedClients,
+              allowedModels: data.allowedModels,
             });
           }
 
@@ -237,11 +260,11 @@ export function UserForm({ user, onSuccess, currentUser }: UserFormProps) {
         <TextField
           label={tForm("dailyQuota.label")}
           type="number"
-          required
           min={USER_LIMITS.DAILY_QUOTA.MIN}
           max={USER_LIMITS.DAILY_QUOTA.MAX}
           step={0.01}
           placeholder={tForm("dailyQuota.placeholder")}
+          helperText={tForm("dailyQuota.helperText")}
           {...form.getFieldProps("dailyQuota")}
         />
       </FormGrid>
@@ -322,11 +345,100 @@ export function UserForm({ user, onSuccess, currentUser }: UserFormProps) {
             label={tForm("expiresAt.label")}
             placeholder={tForm("expiresAt.placeholder")}
             description={tForm("expiresAt.description")}
+            clearLabel={tCommon("clearDate")}
             value={String(form.values.expiresAt || "")}
             onChange={(val) => form.setValue("expiresAt", val)}
             error={form.getFieldProps("expiresAt").error}
             touched={form.getFieldProps("expiresAt").touched}
           />
+
+          {/* Allowed Clients (CLI/IDE restrictions) */}
+          <div className="space-y-3">
+            <div className="space-y-0.5">
+              <Label className="text-sm font-medium">{tForm("allowedClients.label")}</Label>
+              <p className="text-xs text-muted-foreground">{tForm("allowedClients.description")}</p>
+            </div>
+
+            {/* Preset client checkboxes */}
+            <div className="grid grid-cols-2 gap-2">
+              {PRESET_CLIENTS.map((client) => {
+                const isChecked = (form.values.allowedClients || []).includes(client.value);
+                return (
+                  <div key={client.value} className="flex items-center space-x-2">
+                    <Checkbox
+                      id={`client-${client.value}`}
+                      checked={isChecked}
+                      onCheckedChange={(checked) => {
+                        const currentClients = form.values.allowedClients || [];
+                        if (checked) {
+                          form.setValue("allowedClients", [...currentClients, client.value]);
+                        } else {
+                          form.setValue(
+                            "allowedClients",
+                            currentClients.filter((c: string) => c !== client.value)
+                          );
+                        }
+                      }}
+                    />
+                    <Label
+                      htmlFor={`client-${client.value}`}
+                      className="text-sm font-normal cursor-pointer"
+                    >
+                      {client.label}
+                    </Label>
+                  </div>
+                );
+              })}
+            </div>
+
+            {/* Custom client patterns */}
+            <ArrayTagInputField
+              label={tForm("allowedClients.customLabel")}
+              maxTagLength={64}
+              maxTags={50}
+              placeholder={tForm("allowedClients.customPlaceholder")}
+              onInvalidTag={(_tag, reason) => {
+                const messages: Record<string, string> = {
+                  empty: tUI("emptyTag"),
+                  duplicate: tUI("duplicateTag"),
+                  too_long: tUI("tooLong", { max: 64 }),
+                  invalid_format: tUI("invalidFormat"),
+                  max_tags: tUI("maxTags"),
+                };
+                toast.error(messages[reason] || reason);
+              }}
+              value={(form.values.allowedClients || []).filter(
+                (c: string) => !PRESET_CLIENTS.some((p) => p.value === c)
+              )}
+              onChange={(customClients: string[]) => {
+                // Merge preset clients with custom clients
+                const presetClients = (form.values.allowedClients || []).filter((c: string) =>
+                  PRESET_CLIENTS.some((p) => p.value === c)
+                );
+                form.setValue("allowedClients", [...presetClients, ...customClients]);
+              }}
+            />
+          </div>
+
+          {/* Allowed Models (AI model restrictions) */}
+          <ArrayTagInputField
+            label={tForm("allowedModels.label")}
+            maxTagLength={64}
+            maxTags={50}
+            placeholder={tForm("allowedModels.placeholder")}
+            description={tForm("allowedModels.description")}
+            onInvalidTag={(_tag, reason) => {
+              const messages: Record<string, string> = {
+                empty: tUI("emptyTag"),
+                duplicate: tUI("duplicateTag"),
+                too_long: tUI("tooLong", { max: 64 }),
+                invalid_format: tUI("invalidFormat"),
+                max_tags: tUI("maxTags"),
+              };
+              toast.error(messages[reason] || reason);
+            }}
+            {...form.getArrayFieldProps("allowedModels")}
+          />
         </>
       )}
     </DialogFormLayout>

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

@@ -55,6 +55,7 @@ export function KeyActions({
             <EditKeyForm
               keyData={keyData}
               user={keyOwnerUser}
+              isAdmin={currentUser?.role === "admin"}
               onSuccess={() => setOpenEdit(false)}
             />
           </FormErrorBoundary>

+ 119 - 0
src/app/[locale]/dashboard/_components/user/key-full-display-dialog.tsx

@@ -0,0 +1,119 @@
+"use client";
+
+import { Check, Copy, Eye, EyeOff } from "lucide-react";
+import { useTranslations } from "next-intl";
+import { useEffect, useState } from "react";
+import { toast } from "sonner";
+import { Button } from "@/components/ui/button";
+import {
+  Dialog,
+  DialogContent,
+  DialogDescription,
+  DialogFooter,
+  DialogHeader,
+  DialogTitle,
+} from "@/components/ui/dialog";
+import { cn } from "@/lib/utils";
+
+export interface KeyFullDisplayDialogProps {
+  open: boolean;
+  onOpenChange: (open: boolean) => void;
+  keyName: string;
+  fullKey: string;
+}
+
+export function KeyFullDisplayDialog({
+  open,
+  onOpenChange,
+  keyName,
+  fullKey,
+}: KeyFullDisplayDialogProps) {
+  const t = useTranslations("dashboard.userManagement.keyFullDisplay");
+  const tCommon = useTranslations("common");
+  const [isVisible, setIsVisible] = useState(true);
+  const [copied, setCopied] = useState(false);
+
+  // Reset state when dialog opens
+  useEffect(() => {
+    if (open) {
+      setIsVisible(true);
+      setCopied(false);
+    }
+  }, [open]);
+
+  const displayKey = isVisible ? fullKey : fullKey.replace(/./g, "*");
+
+  const handleCopy = async () => {
+    try {
+      await navigator.clipboard.writeText(fullKey);
+      setCopied(true);
+      toast.success(t("copySuccess"));
+      setTimeout(() => setCopied(false), 2000);
+    } catch (error) {
+      console.error("[KeyFullDisplayDialog] copy failed", error);
+      toast.error(t("copyFailed"));
+    }
+  };
+
+  const handleClose = () => {
+    setIsVisible(false);
+    setCopied(false);
+    onOpenChange(false);
+  };
+
+  return (
+    <Dialog open={open} onOpenChange={handleClose}>
+      <DialogContent className="sm:max-w-lg">
+        <DialogHeader>
+          <DialogTitle>{t("title")}</DialogTitle>
+          <DialogDescription>{keyName}</DialogDescription>
+        </DialogHeader>
+
+        <div className="space-y-4">
+          <div className="relative">
+            <div
+              className={cn(
+                "min-h-[80px] p-4 rounded-lg border bg-muted/50 font-mono text-sm break-all",
+                isVisible ? "select-all" : ""
+              )}
+            >
+              {displayKey}
+            </div>
+            <Button
+              type="button"
+              variant="ghost"
+              size="icon"
+              className="absolute top-2 right-2"
+              onClick={() => setIsVisible(!isVisible)}
+              aria-label={isVisible ? t("hide") : t("show")}
+            >
+              {isVisible ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
+            </Button>
+          </div>
+
+          <div className="flex gap-2">
+            <Button type="button" variant="secondary" className="flex-1" onClick={handleCopy}>
+              {copied ? (
+                <>
+                  <Check className="mr-2 h-4 w-4" />
+                  {t("copied")}
+                </>
+              ) : (
+                <>
+                  <Copy className="mr-2 h-4 w-4" />
+                  {t("copy")}
+                </>
+              )}
+            </Button>
+          </div>
+        </div>
+
+        <DialogFooter>
+          <Button type="button" variant="outline" onClick={handleClose}>
+            {tCommon("close")}
+          </Button>
+        </DialogFooter>
+      </DialogContent>
+    </Dialog>
+  );
+}

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

@@ -263,12 +263,42 @@ export function KeyListHeader({
               </div>
               {activeUser && userStatusInfo && (
                 <div className="flex items-center gap-1">
-                  <span>过期时间:</span>
+                  <span>{t("expiresAt")}:</span>
                   <span className="text-foreground">{userStatusInfo.expiryText}</span>
                 </div>
               )}
               {proxyStatusContent}
             </div>
+            {/* Allowed Clients Display - on separate line, visible to both admin and user */}
+            {activeUser && (
+              <div className="mt-2 px-2 py-1 text-xs text-muted-foreground border border-muted-foreground/30 rounded-md w-fit">
+                <span>
+                  {activeUser.allowedClients?.length
+                    ? `${t("allowedClients.label")} [${activeUser.allowedClients.length}]:`
+                    : t("allowedClients.noRestrictions")}
+                </span>
+                {activeUser.allowedClients && activeUser.allowedClients.length > 0 && (
+                  <span className="text-foreground ml-1">
+                    {activeUser.allowedClients.join(", ")}
+                  </span>
+                )}
+              </div>
+            )}
+            {/* Allowed Models Display - on separate line, visible to both admin and user */}
+            {activeUser && (
+              <div className="mt-2 px-2 py-1 text-xs text-muted-foreground border border-muted-foreground/30 rounded-md w-fit">
+                <span>
+                  {activeUser.allowedModels?.length
+                    ? `${t("allowedModels.label")} [${activeUser.allowedModels.length}]:`
+                    : t("allowedModels.noRestrictions")}
+                </span>
+                {activeUser.allowedModels && activeUser.allowedModels.length > 0 && (
+                  <span className="text-foreground ml-1">
+                    {activeUser.allowedModels.join(", ")}
+                  </span>
+                )}
+              </div>
+            )}
           </div>
         </div>
         {canAddKey && (
@@ -287,6 +317,7 @@ export function KeyListHeader({
               <FormErrorBoundary>
                 <AddKeyForm
                   userId={activeUser?.id}
+                  isAdmin={currentUser?.role === "admin"}
                   user={
                     activeUser
                       ? {
@@ -303,6 +334,8 @@ export function KeyListHeader({
                           limitWeeklyUsd: activeUser.limitWeeklyUsd ?? undefined,
                           limitMonthlyUsd: activeUser.limitMonthlyUsd ?? undefined,
                           limitConcurrentSessions: activeUser.limitConcurrentSessions ?? undefined,
+                          dailyResetMode: activeUser.dailyResetMode ?? "fixed",
+                          dailyResetTime: activeUser.dailyResetTime ?? "00:00",
                           isEnabled: activeUser.isEnabled,
                           expiresAt: activeUser.expiresAt ?? undefined,
                         }

+ 170 - 0
src/app/[locale]/dashboard/_components/user/key-quota-usage-dialog.tsx

@@ -0,0 +1,170 @@
+"use client";
+
+import { Loader2, RefreshCw } from "lucide-react";
+import { useTranslations } from "next-intl";
+import { useCallback, useEffect, useState } from "react";
+import { toast } from "sonner";
+import { getKeyQuotaUsage, type KeyQuotaItem, type KeyQuotaUsageResult } from "@/actions/key-quota";
+import { QuotaProgress } from "@/components/quota/quota-progress";
+import { Button } from "@/components/ui/button";
+import {
+  Dialog,
+  DialogContent,
+  DialogDescription,
+  DialogHeader,
+  DialogTitle,
+} from "@/components/ui/dialog";
+import { type CurrencyCode, formatCurrency } from "@/lib/utils";
+
+export interface KeyQuotaUsageDialogProps {
+  open: boolean;
+  onOpenChange: (open: boolean) => void;
+  keyId: number;
+  keyName: string;
+  currencyCode?: CurrencyCode;
+}
+
+const LIMIT_TYPE_ORDER: KeyQuotaItem["type"][] = [
+  "limit5h",
+  "limitDaily",
+  "limitWeekly",
+  "limitMonthly",
+  "limitTotal",
+  "limitSessions",
+];
+
+export function KeyQuotaUsageDialog({
+  open,
+  onOpenChange,
+  keyId,
+  keyName,
+  currencyCode: propCurrencyCode,
+}: KeyQuotaUsageDialogProps) {
+  const t = useTranslations("dashboard.userManagement.keyQuotaUsageDialog");
+  const [loading, setLoading] = useState(false);
+  const [data, setData] = useState<KeyQuotaUsageResult | null>(null);
+  const [error, setError] = useState(false);
+
+  const fetchData = useCallback(async () => {
+    setLoading(true);
+    setError(false);
+    try {
+      const res = await getKeyQuotaUsage(keyId);
+      if (!res.ok) {
+        toast.error(res.error || t("fetchFailed"));
+        setError(true);
+        return;
+      }
+      setData(res.data);
+    } catch (err) {
+      console.error("[KeyQuotaUsageDialog] fetch failed", err);
+      toast.error(t("fetchFailed"));
+      setError(true);
+    } finally {
+      setLoading(false);
+    }
+  }, [keyId, t]);
+
+  useEffect(() => {
+    if (!open) {
+      setData(null);
+      setError(false);
+      return;
+    }
+    fetchData();
+  }, [open, fetchData]);
+
+  const currencyCode = data?.currencyCode ?? propCurrencyCode ?? "USD";
+
+  const formatValue = (type: KeyQuotaItem["type"], value: number) => {
+    if (type === "limitSessions") {
+      return String(value);
+    }
+    return formatCurrency(value, currencyCode);
+  };
+
+  const formatLimit = (type: KeyQuotaItem["type"], limit: number | null) => {
+    if (limit === null || limit === 0) {
+      return t("noLimit");
+    }
+    if (type === "limitSessions") {
+      return String(limit);
+    }
+    return formatCurrency(limit, currencyCode);
+  };
+
+  const getLabelKey = (type: KeyQuotaItem["type"]) => {
+    const map: Record<KeyQuotaItem["type"], string> = {
+      limit5h: "labels.limit5h",
+      limitDaily: "labels.limitDaily",
+      limitWeekly: "labels.limitWeekly",
+      limitMonthly: "labels.limitMonthly",
+      limitTotal: "labels.limitTotal",
+      limitSessions: "labels.limitSessions",
+    };
+    return map[type];
+  };
+
+  const sortedItems = data?.items.slice().sort((a, b) => {
+    return LIMIT_TYPE_ORDER.indexOf(a.type) - LIMIT_TYPE_ORDER.indexOf(b.type);
+  });
+
+  return (
+    <Dialog open={open} onOpenChange={onOpenChange}>
+      <DialogContent className="sm:max-w-md">
+        <DialogHeader>
+          <DialogTitle>{t("title")}</DialogTitle>
+          <DialogDescription className="text-sm text-muted-foreground">{keyName}</DialogDescription>
+        </DialogHeader>
+
+        {loading ? (
+          <div
+            className="flex items-center justify-center py-8"
+            aria-live="polite"
+            aria-busy="true"
+          >
+            <Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
+          </div>
+        ) : error ? (
+          <div className="flex flex-col items-center justify-center gap-3 py-8">
+            <span className="text-sm text-muted-foreground">{t("fetchFailed")}</span>
+            <Button
+              type="button"
+              variant="outline"
+              size="sm"
+              onClick={fetchData}
+              className="gap-1.5"
+            >
+              <RefreshCw className="h-3.5 w-3.5" />
+              {t("retry")}
+            </Button>
+          </div>
+        ) : !data ? (
+          <div className="py-8 text-center text-sm text-muted-foreground">{t("fetchFailed")}</div>
+        ) : (
+          <div className="space-y-4 py-2">
+            {sortedItems?.map((item) => (
+              <div key={item.type} className="space-y-1.5">
+                <div className="flex items-center justify-between text-sm">
+                  <span className="text-muted-foreground">{t(getLabelKey(item.type))}</span>
+                  <span className="font-medium">
+                    {formatValue(item.type, item.current)} / {formatLimit(item.type, item.limit)}
+                  </span>
+                </div>
+                {item.limit !== null && item.limit > 0 && (
+                  <QuotaProgress current={item.current} limit={item.limit} />
+                )}
+                {item.type === "limitDaily" && item.mode && (
+                  <div className="text-xs text-muted-foreground">
+                    {item.mode === "fixed" ? t("modeFixed") : t("modeRolling")}
+                    {item.mode === "fixed" && item.time && ` (${item.time})`}
+                  </div>
+                )}
+              </div>
+            ))}
+          </div>
+        )}
+      </DialogContent>
+    </Dialog>
+  );
+}

+ 390 - 0
src/app/[locale]/dashboard/_components/user/key-row-item.tsx

@@ -0,0 +1,390 @@
+"use client";
+
+import { BarChart3, Copy, Eye, FileText, Info, Pencil, Trash2 } from "lucide-react";
+import { useTranslations } from "next-intl";
+import { useState } from "react";
+import { toast } from "sonner";
+import {
+  AlertDialog,
+  AlertDialogAction,
+  AlertDialogCancel,
+  AlertDialogContent,
+  AlertDialogDescription,
+  AlertDialogFooter,
+  AlertDialogHeader,
+  AlertDialogTitle,
+} from "@/components/ui/alert-dialog";
+import { Badge } from "@/components/ui/badge";
+import { Button, buttonVariants } from "@/components/ui/button";
+import { RelativeTime } from "@/components/ui/relative-time";
+import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
+import { cn } from "@/lib/utils";
+import { CURRENCY_CONFIG, type CurrencyCode, formatCurrency } from "@/lib/utils/currency";
+import { KeyFullDisplayDialog } from "./key-full-display-dialog";
+import { KeyQuotaUsageDialog } from "./key-quota-usage-dialog";
+import { KeyStatsDialog } from "./key-stats-dialog";
+
+export interface KeyRowItemProps {
+  keyData: {
+    id: number;
+    name: string;
+    maskedKey: string;
+    fullKey?: string;
+    canCopy: boolean;
+    providerGroup?: string | null;
+    todayUsage: number;
+    todayCallCount: number;
+    lastUsedAt: Date | null;
+    expiresAt: string;
+    status: "enabled" | "disabled";
+    modelStats: Array<{
+      model: string;
+      callCount: number;
+      totalCost: number;
+    }>;
+  };
+  onEdit: () => void;
+  onDelete: () => void;
+  onViewLogs: () => void;
+  onViewDetails: () => void;
+  currencyCode?: string;
+  highlight?: boolean;
+  translations: {
+    fields: {
+      name: string;
+      key: string;
+      group: string;
+      todayUsage: string;
+      todayCost: string;
+      lastUsed: string;
+      actions: string;
+      callsLabel: string;
+      costLabel: string;
+    };
+    actions: {
+      details: string;
+      logs: string;
+      edit: string;
+      delete: string;
+      copy: string;
+      copySuccess: string;
+      copyFailed: string;
+      show: string;
+      hide: string;
+      quota: string;
+    };
+    status: {
+      enabled: string;
+      disabled: string;
+    };
+    defaultGroup: string;
+  };
+}
+
+export function KeyRowItem({
+  keyData,
+  onEdit,
+  onDelete,
+  onViewLogs,
+  onViewDetails: _onViewDetails,
+  currencyCode,
+  highlight,
+  translations,
+}: KeyRowItemProps) {
+  const [deleteOpen, setDeleteOpen] = useState(false);
+  const [fullKeyDialogOpen, setFullKeyDialogOpen] = useState(false);
+  const [statsDialogOpen, setStatsDialogOpen] = useState(false);
+  const [quotaDialogOpen, setQuotaDialogOpen] = useState(false);
+  const tCommon = useTranslations("common");
+
+  const resolvedCurrencyCode: CurrencyCode =
+    currencyCode && currencyCode in CURRENCY_CONFIG ? (currencyCode as CurrencyCode) : "USD";
+
+  const providerGroup = keyData.providerGroup?.trim()
+    ? keyData.providerGroup
+    : translations.defaultGroup;
+
+  const canReveal = Boolean(keyData.fullKey);
+  const canCopy = Boolean(keyData.canCopy && keyData.fullKey);
+  const displayKey = keyData.maskedKey || "-";
+
+  const handleCopy = async () => {
+    if (!canCopy || !keyData.fullKey) return;
+    try {
+      await navigator.clipboard.writeText(keyData.fullKey);
+      toast.success(translations.actions.copySuccess);
+    } catch (error) {
+      console.error("[KeyRowItem] copy failed", error);
+      toast.error(translations.actions.copyFailed);
+    }
+  };
+
+  return (
+    <div
+      className={cn(
+        "grid grid-cols-[repeat(14,minmax(0,1fr))] items-center gap-3 px-3 py-2 text-sm border-b last:border-b-0 hover:bg-muted/40 transition-colors",
+        highlight && "bg-primary/10 ring-1 ring-primary/30"
+      )}
+    >
+      {/* 名称 */}
+      <div className="col-span-2 min-w-0">
+        <div className="flex items-center gap-2 min-w-0">
+          <div className="truncate font-medium">{keyData.name}</div>
+          <Badge
+            variant={keyData.status === "enabled" ? "default" : "secondary"}
+            className="text-[10px]"
+          >
+            {keyData.status === "enabled"
+              ? translations.status.enabled
+              : translations.status.disabled}
+          </Badge>
+        </div>
+      </div>
+
+      {/* 密钥 */}
+      <div className="col-span-3 min-w-0">
+        <div className="flex items-center gap-2 min-w-0">
+          <div
+            className="min-w-0 flex-1 font-mono text-xs truncate"
+            title={translations.fields.key}
+          >
+            {displayKey}
+          </div>
+          <div className="flex items-center gap-1 flex-shrink-0">
+            {canCopy ? (
+              <Tooltip>
+                <TooltipTrigger asChild>
+                  <Button
+                    type="button"
+                    size="icon-sm"
+                    variant="ghost"
+                    aria-label={translations.actions.copy}
+                    onClick={(e) => {
+                      e.stopPropagation();
+                      void handleCopy();
+                    }}
+                    className="h-7 w-7"
+                  >
+                    <Copy className="h-3.5 w-3.5" />
+                  </Button>
+                </TooltipTrigger>
+                <TooltipContent>{translations.actions.copy}</TooltipContent>
+              </Tooltip>
+            ) : null}
+
+            {canReveal ? (
+              <Tooltip>
+                <TooltipTrigger asChild>
+                  <Button
+                    type="button"
+                    size="icon-sm"
+                    variant="ghost"
+                    aria-label={translations.actions.show}
+                    onClick={(e) => {
+                      e.stopPropagation();
+                      setFullKeyDialogOpen(true);
+                    }}
+                    className="h-7 w-7"
+                  >
+                    <Eye className="h-3.5 w-3.5" />
+                  </Button>
+                </TooltipTrigger>
+                <TooltipContent>{translations.actions.show}</TooltipContent>
+              </Tooltip>
+            ) : null}
+          </div>
+        </div>
+      </div>
+
+      {/* 分组 */}
+      <div className="col-span-2 min-w-0">
+        <div className="flex items-center gap-1.5">
+          <span className="text-xs text-muted-foreground">{translations.fields.group}:</span>
+          <Badge variant="outline" className="text-xs font-mono">
+            {providerGroup}
+          </Badge>
+        </div>
+      </div>
+
+      {/* 今日用量(调用次数) */}
+      <div
+        className="col-span-1 text-right tabular-nums flex items-center justify-end gap-1"
+        title={translations.fields.todayUsage}
+      >
+        <span className="text-xs text-muted-foreground">{translations.fields.callsLabel}:</span>
+        <span>{Number(keyData.todayCallCount || 0).toLocaleString()}</span>
+      </div>
+
+      {/* 今日消耗(成本) */}
+      <div
+        className="col-span-2 text-right font-mono tabular-nums flex items-center justify-end gap-1"
+        title={translations.fields.todayCost}
+      >
+        <span className="text-xs text-muted-foreground">{translations.fields.costLabel}:</span>
+        <span>{formatCurrency(keyData.todayUsage || 0, resolvedCurrencyCode)}</span>
+      </div>
+
+      {/* 最后使用 */}
+      <div className="col-span-2 min-w-0" title={translations.fields.lastUsed}>
+        {keyData.lastUsedAt ? (
+          <RelativeTime date={keyData.lastUsedAt} autoUpdate={false} />
+        ) : (
+          <span className="text-muted-foreground">-</span>
+        )}
+      </div>
+
+      {/* 操作 */}
+      <div
+        className="col-span-2 flex items-center justify-end gap-1"
+        title={translations.fields.actions}
+      >
+        <Tooltip>
+          <TooltipTrigger asChild>
+            <Button
+              type="button"
+              size="icon-sm"
+              variant="ghost"
+              aria-label={translations.actions.details}
+              onClick={(e) => {
+                e.stopPropagation();
+                setStatsDialogOpen(true);
+              }}
+              className="h-7 w-7"
+            >
+              <Info className="h-3.5 w-3.5" />
+            </Button>
+          </TooltipTrigger>
+          <TooltipContent>{translations.actions.details}</TooltipContent>
+        </Tooltip>
+
+        <Tooltip>
+          <TooltipTrigger asChild>
+            <Button
+              type="button"
+              size="icon-sm"
+              variant="ghost"
+              aria-label={translations.actions.quota}
+              onClick={(e) => {
+                e.stopPropagation();
+                setQuotaDialogOpen(true);
+              }}
+              className="h-7 w-7"
+            >
+              <BarChart3 className="h-3.5 w-3.5" />
+            </Button>
+          </TooltipTrigger>
+          <TooltipContent>{translations.actions.quota}</TooltipContent>
+        </Tooltip>
+
+        <Tooltip>
+          <TooltipTrigger asChild>
+            <Button
+              type="button"
+              size="icon-sm"
+              variant="ghost"
+              aria-label={translations.actions.logs}
+              onClick={(e) => {
+                e.stopPropagation();
+                onViewLogs();
+              }}
+              className="h-7 w-7"
+            >
+              <FileText className="h-3.5 w-3.5" />
+            </Button>
+          </TooltipTrigger>
+          <TooltipContent>{translations.actions.logs}</TooltipContent>
+        </Tooltip>
+
+        <Tooltip>
+          <TooltipTrigger asChild>
+            <Button
+              type="button"
+              size="icon-sm"
+              variant="ghost"
+              aria-label={translations.actions.edit}
+              onClick={(e) => {
+                e.stopPropagation();
+                onEdit();
+              }}
+              className="h-7 w-7"
+            >
+              <Pencil className="h-3.5 w-3.5" />
+            </Button>
+          </TooltipTrigger>
+          <TooltipContent>{translations.actions.edit}</TooltipContent>
+        </Tooltip>
+
+        <AlertDialog open={deleteOpen} onOpenChange={setDeleteOpen}>
+          <Tooltip>
+            <TooltipTrigger asChild>
+              <Button
+                type="button"
+                size="icon-sm"
+                variant="ghost"
+                aria-label={translations.actions.delete}
+                onClick={(e) => {
+                  e.stopPropagation();
+                  setDeleteOpen(true);
+                }}
+                className="h-7 w-7 text-destructive hover:text-destructive"
+              >
+                <Trash2 className="h-3.5 w-3.5" />
+              </Button>
+            </TooltipTrigger>
+            <TooltipContent>{translations.actions.delete}</TooltipContent>
+          </Tooltip>
+
+          <AlertDialogContent>
+            <AlertDialogHeader>
+              <AlertDialogTitle>{translations.actions.delete}</AlertDialogTitle>
+              <AlertDialogDescription>
+                {translations.fields.name}: {keyData.name}
+              </AlertDialogDescription>
+            </AlertDialogHeader>
+            <AlertDialogFooter>
+              <AlertDialogCancel>{tCommon("cancel")}</AlertDialogCancel>
+              <AlertDialogAction
+                onClick={(e) => {
+                  e.preventDefault();
+                  setDeleteOpen(false);
+                  onDelete();
+                }}
+                className={buttonVariants({ variant: "destructive" })}
+              >
+                {translations.actions.delete}
+              </AlertDialogAction>
+            </AlertDialogFooter>
+          </AlertDialogContent>
+        </AlertDialog>
+      </div>
+
+      {/* Full Key Display Dialog */}
+      {keyData.fullKey && (
+        <KeyFullDisplayDialog
+          open={fullKeyDialogOpen}
+          onOpenChange={setFullKeyDialogOpen}
+          keyName={keyData.name}
+          fullKey={keyData.fullKey}
+        />
+      )}
+
+      {/* Model Stats Dialog */}
+      <KeyStatsDialog
+        open={statsDialogOpen}
+        onOpenChange={setStatsDialogOpen}
+        keyName={keyData.name}
+        modelStats={keyData.modelStats}
+        currencyCode={currencyCode}
+      />
+
+      {/* Key Quota Usage Dialog */}
+      <KeyQuotaUsageDialog
+        open={quotaDialogOpen}
+        onOpenChange={setQuotaDialogOpen}
+        keyId={keyData.id}
+        keyName={keyData.name}
+        currencyCode={resolvedCurrencyCode}
+      />
+    </div>
+  );
+}

+ 122 - 0
src/app/[locale]/dashboard/_components/user/key-stats-dialog.tsx

@@ -0,0 +1,122 @@
+"use client";
+
+import { useTranslations } from "next-intl";
+import { Badge } from "@/components/ui/badge";
+import { Button } from "@/components/ui/button";
+import {
+  Dialog,
+  DialogContent,
+  DialogDescription,
+  DialogFooter,
+  DialogHeader,
+  DialogTitle,
+} from "@/components/ui/dialog";
+import {
+  Table,
+  TableBody,
+  TableCell,
+  TableHead,
+  TableHeader,
+  TableRow,
+} from "@/components/ui/table";
+import { CURRENCY_CONFIG, type CurrencyCode, formatCurrency } from "@/lib/utils/currency";
+
+export interface ModelStat {
+  model: string;
+  callCount: number;
+  totalCost: number;
+}
+
+export interface KeyStatsDialogProps {
+  open: boolean;
+  onOpenChange: (open: boolean) => void;
+  keyName: string;
+  modelStats: ModelStat[];
+  currencyCode?: string;
+}
+
+export function KeyStatsDialog({
+  open,
+  onOpenChange,
+  keyName,
+  modelStats,
+  currencyCode,
+}: KeyStatsDialogProps) {
+  const t = useTranslations("dashboard.userManagement.keyStatsDialog");
+  const tCommon = useTranslations("common");
+
+  const resolvedCurrencyCode: CurrencyCode =
+    currencyCode && currencyCode in CURRENCY_CONFIG ? (currencyCode as CurrencyCode) : "USD";
+
+  const totalCalls = modelStats.reduce((sum, stat) => sum + stat.callCount, 0);
+  const totalCost = modelStats.reduce((sum, stat) => sum + stat.totalCost, 0);
+
+  const handleClose = () => {
+    onOpenChange(false);
+  };
+
+  return (
+    <Dialog open={open} onOpenChange={handleClose}>
+      <DialogContent className="sm:max-w-lg">
+        <DialogHeader>
+          <DialogTitle>{t("title")}</DialogTitle>
+          <DialogDescription>{keyName}</DialogDescription>
+        </DialogHeader>
+
+        <div className="space-y-4">
+          {modelStats.length > 0 ? (
+            <>
+              <div className="rounded-md border">
+                <Table>
+                  <TableHeader>
+                    <TableRow>
+                      <TableHead>{t("columns.model")}</TableHead>
+                      <TableHead className="text-right">{t("columns.calls")}</TableHead>
+                      <TableHead className="text-right">{t("columns.cost")}</TableHead>
+                    </TableRow>
+                  </TableHeader>
+                  <TableBody>
+                    {modelStats.map((stat) => (
+                      <TableRow key={stat.model}>
+                        <TableCell className="font-mono text-xs">{stat.model}</TableCell>
+                        <TableCell className="text-right tabular-nums">
+                          {stat.callCount.toLocaleString()}
+                        </TableCell>
+                        <TableCell className="text-right font-mono tabular-nums">
+                          {formatCurrency(stat.totalCost, resolvedCurrencyCode)}
+                        </TableCell>
+                      </TableRow>
+                    ))}
+                  </TableBody>
+                </Table>
+              </div>
+
+              <div className="flex items-center justify-between px-2 text-sm">
+                <div className="flex items-center gap-2">
+                  <span className="text-muted-foreground">{t("totalCalls")}:</span>
+                  <Badge variant="secondary" className="tabular-nums">
+                    {totalCalls.toLocaleString()}
+                  </Badge>
+                </div>
+                <div className="flex items-center gap-2">
+                  <span className="text-muted-foreground">{t("totalCost")}:</span>
+                  <Badge variant="secondary" className="font-mono tabular-nums">
+                    {formatCurrency(totalCost, resolvedCurrencyCode)}
+                  </Badge>
+                </div>
+              </div>
+            </>
+          ) : (
+            <div className="py-8 text-center text-sm text-muted-foreground">{t("noData")}</div>
+          )}
+        </div>
+
+        <DialogFooter>
+          <Button type="button" variant="outline" onClick={handleClose}>
+            {tCommon("close")}
+          </Button>
+        </DialogFooter>
+      </DialogContent>
+    </Dialog>
+  );
+}

+ 100 - 0
src/app/[locale]/dashboard/_components/user/limit-status-indicator.tsx

@@ -0,0 +1,100 @@
+"use client";
+
+import { Badge } from "@/components/ui/badge";
+import { cn } from "@/lib/utils";
+
+export interface LimitStatusIndicatorProps {
+  /** Limit value. `null/undefined` means unset. */
+  value: number | null | undefined;
+  /** Current usage value (for percentage display). */
+  usage?: number;
+  /** Text label shown in non-compact mode. */
+  label: string;
+  /** Visual variant. `compact` only shows value without label. */
+  variant?: "default" | "compact";
+  /** Whether to show percentage instead of value (requires usage). */
+  showPercentage?: boolean;
+  /** Unit to display (e.g., "$" for currency, empty for count). */
+  unit?: string;
+}
+
+function formatLimitValue(raw: number, unit?: string): string {
+  if (!Number.isFinite(raw)) return String(raw);
+  const formatted = Number.isInteger(raw) ? String(raw) : raw.toFixed(2).replace(/\.00$/, "");
+  return unit ? `${unit}${formatted}` : formatted;
+}
+
+function formatPercentage(usage: number, limit: number): string {
+  const percentage = Math.min(Math.round((usage / limit) * 100), 100);
+  return `${percentage}%`;
+}
+
+function getPercentageColor(usage: number, limit: number): string {
+  const percentage = (usage / limit) * 100;
+  if (percentage >= 100) return "text-destructive";
+  if (percentage >= 80) return "text-orange-600";
+  return "";
+}
+
+/**
+ * Limit status indicator for table cells.
+ * - Unset: shows "-"
+ * - Set: shows value (or percentage if usage is provided and showPercentage is true)
+ */
+export function LimitStatusIndicator({
+  value,
+  usage,
+  label,
+  variant = "default",
+  showPercentage = false,
+  unit = "$",
+}: LimitStatusIndicatorProps) {
+  const isSet = typeof value === "number" && Number.isFinite(value);
+  const hasUsage = typeof usage === "number" && Number.isFinite(usage);
+
+  // Determine display text
+  let displayText: string;
+  let colorClass = "";
+
+  if (!isSet) {
+    displayText = "-";
+  } else if (showPercentage && hasUsage) {
+    displayText = formatPercentage(usage, value);
+    colorClass = getPercentageColor(usage, value);
+  } else {
+    displayText = formatLimitValue(value, unit);
+  }
+
+  const statusText = isSet
+    ? hasUsage
+      ? `${formatLimitValue(usage, unit)} / ${formatLimitValue(value, unit)}`
+      : formatLimitValue(value, unit)
+    : "-";
+
+  if (variant === "compact") {
+    return (
+      <Badge
+        variant={isSet ? "secondary" : "outline"}
+        className={cn("px-2 py-0.5 tabular-nums text-xs", colorClass)}
+        title={`${label}: ${statusText}`}
+        aria-label={`${label}: ${statusText}`}
+      >
+        {displayText}
+      </Badge>
+    );
+  }
+
+  return (
+    <Badge
+      variant={isSet ? "secondary" : "outline"}
+      className={cn("gap-1.5 px-2")}
+      title={`${label}: ${statusText}`}
+      aria-label={`${label}: ${statusText}`}
+    >
+      <span className="text-muted-foreground">{label}</span>
+      <span className={cn("tabular-nums", !isSet && "text-muted-foreground", colorClass)}>
+        {displayText}
+      </span>
+    </Badge>
+  );
+}

+ 980 - 0
src/app/[locale]/dashboard/_components/user/unified-edit-dialog.tsx

@@ -0,0 +1,980 @@
+"use client";
+
+import {
+  ChevronDown,
+  ChevronUp,
+  KeyRound,
+  Loader2,
+  Plus,
+  Trash2,
+  UserCog,
+  UserPlus,
+} from "lucide-react";
+import { useRouter } from "next/navigation";
+import { useTranslations } from "next-intl";
+import { useEffect, useMemo, useRef, useState, useTransition } from "react";
+import { toast } from "sonner";
+import { z } from "zod";
+import { addKey, editKey, removeKey } from "@/actions/keys";
+import { getFilterOptions } from "@/actions/usage-logs";
+import { createUserOnly, editUser, removeUser, toggleUserEnabled } from "@/actions/users";
+import {
+  AlertDialog,
+  AlertDialogAction,
+  AlertDialogCancel,
+  AlertDialogContent,
+  AlertDialogDescription,
+  AlertDialogFooter,
+  AlertDialogHeader,
+  AlertDialogTitle,
+} from "@/components/ui/alert-dialog";
+import { Badge } from "@/components/ui/badge";
+import { Button } from "@/components/ui/button";
+import {
+  Dialog,
+  DialogContent,
+  DialogDescription,
+  DialogFooter,
+  DialogHeader,
+  DialogTitle,
+} from "@/components/ui/dialog";
+import { Separator } from "@/components/ui/separator";
+import { useZodForm } from "@/lib/hooks/use-zod-form";
+import { KeyFormSchema, UpdateUserSchema } from "@/lib/validation/schemas";
+import type { UserDisplay } from "@/types/user";
+import { DangerZone } from "./forms/danger-zone";
+import { KeyEditSection } from "./forms/key-edit-section";
+import { UserEditSection } from "./forms/user-edit-section";
+
+export interface UnifiedEditDialogProps {
+  open: boolean;
+  onOpenChange: (open: boolean) => void;
+  mode: "create" | "edit";
+  user?: UserDisplay; // Required in edit mode, optional in create mode
+  scrollToKeyId?: number;
+  onSuccess?: () => void;
+  currentUser?: { role: string };
+}
+
+const UnifiedUserSchema = UpdateUserSchema.extend({
+  name: z.string().min(1).max(64),
+  providerGroup: z.string().max(50).nullable().optional(),
+  allowedClients: z.array(z.string().max(64)).max(50).optional().default([]),
+  allowedModels: z.array(z.string().max(64)).max(50).optional().default([]),
+  dailyQuota: z.number().nullable().optional(),
+});
+
+const UnifiedKeySchema = KeyFormSchema.extend({
+  id: z.number(), // Negative IDs indicate new keys to be created
+  isEnabled: z.boolean().optional(),
+});
+
+const UnifiedEditSchema = z.object({
+  user: UnifiedUserSchema,
+  keys: z.array(UnifiedKeySchema),
+});
+
+type UnifiedEditValues = z.infer<typeof UnifiedEditSchema>;
+
+// Generate unique temporary negative IDs for new keys using timestamp + random
+function getNextTempKeyId() {
+  return -Math.floor(Date.now() + Math.random() * 1000);
+}
+
+function parseYmdToEndOfDayIso(value: string): string | undefined {
+  if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) return undefined;
+  const [year, month, day] = value.split("-").map((v) => Number(v));
+  if (!Number.isFinite(year) || !Number.isFinite(month) || !Number.isFinite(day)) return undefined;
+  const date = new Date(year, month - 1, day);
+  if (Number.isNaN(date.getTime())) return undefined;
+  date.setHours(23, 59, 59, 999);
+  return date.toISOString();
+}
+
+function getKeyExpiresAtIso(expiresAt: string): string | undefined {
+  if (!expiresAt) return undefined;
+  const ymd = parseYmdToEndOfDayIso(expiresAt);
+  if (ymd) return ymd;
+  const parsed = new Date(expiresAt);
+  if (Number.isNaN(parsed.getTime())) return undefined;
+  return parsed.toISOString();
+}
+
+function buildDefaultValues(mode: "create" | "edit", user?: UserDisplay): UnifiedEditValues {
+  if (mode === "create") {
+    return {
+      user: {
+        name: "",
+        note: "",
+        tags: [],
+        expiresAt: undefined,
+        limit5hUsd: null,
+        dailyQuota: null,
+        limitWeeklyUsd: null,
+        limitMonthlyUsd: null,
+        limitTotalUsd: null,
+        limitConcurrentSessions: null,
+        dailyResetMode: "fixed",
+        dailyResetTime: "00:00",
+        allowedClients: [],
+        allowedModels: [],
+      },
+      keys: [
+        {
+          id: getNextTempKeyId(),
+          name: "default",
+          isEnabled: true,
+          expiresAt: undefined,
+          canLoginWebUi: false,
+          providerGroup: "",
+          cacheTtlPreference: "inherit" as const,
+          limit5hUsd: null,
+          limitDailyUsd: null,
+          dailyResetMode: "fixed",
+          dailyResetTime: "00:00",
+          limitWeeklyUsd: null,
+          limitMonthlyUsd: null,
+          limitTotalUsd: null,
+          limitConcurrentSessions: 0,
+        },
+      ],
+    };
+  }
+
+  // Edit mode - user must exist
+  if (!user) {
+    throw new Error("User is required in edit mode");
+  }
+
+  return {
+    user: {
+      name: user.name || "",
+      note: user.note || "",
+      tags: user.tags || [],
+      expiresAt: user.expiresAt ?? undefined,
+      providerGroup: user.providerGroup ?? null,
+      limit5hUsd: user.limit5hUsd ?? null,
+      dailyQuota: user.dailyQuota ?? null,
+      limitWeeklyUsd: user.limitWeeklyUsd ?? null,
+      limitMonthlyUsd: user.limitMonthlyUsd ?? null,
+      limitTotalUsd: user.limitTotalUsd ?? null,
+      limitConcurrentSessions: user.limitConcurrentSessions ?? null,
+      dailyResetMode: user.dailyResetMode ?? "fixed",
+      dailyResetTime: user.dailyResetTime ?? "00:00",
+      allowedClients: user.allowedClients || [],
+      allowedModels: user.allowedModels || [],
+    },
+    keys: user.keys.map((key) => ({
+      id: key.id,
+      name: key.name || "",
+      isEnabled: key.status === "enabled",
+      expiresAt: getKeyExpiresAtIso(key.expiresAt),
+      canLoginWebUi: key.canLoginWebUi ?? false,
+      providerGroup: key.providerGroup || "",
+      cacheTtlPreference: "inherit" as const,
+      limit5hUsd: key.limit5hUsd ?? null,
+      limitDailyUsd: key.limitDailyUsd ?? null,
+      dailyResetMode: key.dailyResetMode ?? "fixed",
+      dailyResetTime: key.dailyResetTime ?? "00:00",
+      limitWeeklyUsd: key.limitWeeklyUsd ?? null,
+      limitMonthlyUsd: key.limitMonthlyUsd ?? null,
+      limitTotalUsd: key.limitTotalUsd ?? null,
+      limitConcurrentSessions: key.limitConcurrentSessions ?? 0,
+    })),
+  };
+}
+
+function getFirstErrorMessage(errors: Record<string, string>) {
+  if (errors._form) return errors._form;
+  const first = Object.entries(errors).find(([, msg]) => Boolean(msg));
+  return first?.[1] || null;
+}
+
+function UnifiedEditDialogInner({
+  onOpenChange,
+  mode,
+  user,
+  scrollToKeyId,
+  onSuccess,
+  currentUser,
+}: UnifiedEditDialogProps) {
+  const router = useRouter();
+  const t = useTranslations("dashboard.userManagement");
+  const tCommon = useTranslations("common");
+  const [isPending, startTransition] = useTransition();
+  const keyScrollRef = useRef<HTMLDivElement>(null!);
+  const isAdmin = currentUser?.role === "admin";
+  const [deletedKeyIds, setDeletedKeyIds] = useState<number[]>([]);
+  const [keyToDelete, setKeyToDelete] = useState<{ id: number; name: string } | null>(null);
+  const [newlyAddedKeyId, setNewlyAddedKeyId] = useState<number | null>(null);
+  const [expandedKeyIds, setExpandedKeyIds] = useState<Set<number>>(() => {
+    // Create mode or single key: all expanded
+    if (mode === "create") return new Set([-1]); // placeholder for new keys
+    if (!user || user.keys.length <= 1) return new Set(user?.keys.map((k) => k.id) || []);
+    // Edit mode with multiple keys: only scrollToKeyId expanded
+    if (scrollToKeyId) return new Set([scrollToKeyId]);
+    return new Set(); // All collapsed
+  });
+  const [modelSuggestions, setModelSuggestions] = useState<string[]>([]);
+
+  // Fetch model suggestions for access restrictions
+  useEffect(() => {
+    getFilterOptions()
+      .then((res) => {
+        if (res.ok && res.data) {
+          setModelSuggestions(res.data.models);
+        }
+      })
+      .catch(() => {
+        // Silently fail - model suggestions are optional enhancement
+        // User can still manually type model names
+      });
+  }, []);
+
+  // Auto-scroll to newly added key
+  useEffect(() => {
+    if (newlyAddedKeyId && keyScrollRef.current) {
+      // Small delay to ensure DOM is updated
+      const timer = setTimeout(() => {
+        keyScrollRef.current?.scrollIntoView({ behavior: "smooth", block: "start" });
+        setNewlyAddedKeyId(null);
+      }, 100);
+      return () => clearTimeout(timer);
+    }
+  }, [newlyAddedKeyId]);
+
+  const defaultValues = useMemo(() => buildDefaultValues(mode, user), [mode, user]);
+
+  const toggleKeyExpanded = (keyId: number) => {
+    setExpandedKeyIds((prev) => {
+      const next = new Set(prev);
+      if (next.has(keyId)) {
+        next.delete(keyId);
+      } else {
+        next.add(keyId);
+      }
+      return next;
+    });
+  };
+
+  const form = useZodForm({
+    schema: UnifiedEditSchema,
+    defaultValues,
+    onSubmit: async (data) => {
+      startTransition(async () => {
+        try {
+          if (mode === "create") {
+            // Create user first
+            const userRes = await createUserOnly({
+              name: data.user.name,
+              note: data.user.note,
+              tags: data.user.tags,
+              expiresAt: data.user.expiresAt ?? null,
+              limit5hUsd: data.user.limit5hUsd,
+              dailyQuota: data.user.dailyQuota ?? undefined,
+              limitWeeklyUsd: data.user.limitWeeklyUsd,
+              limitMonthlyUsd: data.user.limitMonthlyUsd,
+              limitTotalUsd: data.user.limitTotalUsd,
+              limitConcurrentSessions: data.user.limitConcurrentSessions,
+              dailyResetMode: data.user.dailyResetMode,
+              dailyResetTime: data.user.dailyResetTime,
+              allowedClients: data.user.allowedClients,
+              allowedModels: data.user.allowedModels,
+            });
+            if (!userRes.ok) {
+              toast.error(userRes.error || t("createDialog.saveFailed"));
+              return;
+            }
+
+            const newUserId = userRes.data.user.id;
+
+            // Create all keys for the new user
+            // If any key creation fails, rollback by deleting the user
+            for (const key of data.keys) {
+              const keyRes = await addKey({
+                userId: newUserId,
+                name: key.name,
+                expiresAt: key.expiresAt || undefined,
+                canLoginWebUi: key.canLoginWebUi,
+                providerGroup: key.providerGroup?.trim() ? key.providerGroup.trim() : null,
+                cacheTtlPreference: key.cacheTtlPreference,
+                limit5hUsd: key.limit5hUsd,
+                limitDailyUsd: key.limitDailyUsd,
+                dailyResetMode: key.dailyResetMode,
+                dailyResetTime: key.dailyResetTime,
+                limitWeeklyUsd: key.limitWeeklyUsd,
+                limitMonthlyUsd: key.limitMonthlyUsd,
+                limitTotalUsd: key.limitTotalUsd,
+                limitConcurrentSessions: key.limitConcurrentSessions,
+              });
+              if (!keyRes.ok) {
+                // Rollback: delete the user since key creation failed
+                try {
+                  await removeUser(newUserId);
+                } catch (rollbackError) {
+                  console.error("[UnifiedEditDialog] rollback failed", rollbackError);
+                }
+                toast.error(keyRes.error || t("createDialog.keyCreateFailed", { name: key.name }));
+                return;
+              }
+            }
+
+            toast.success(t("createDialog.createSuccess"));
+          } else {
+            // Edit mode - user must exist
+            if (!user) return;
+
+            const userRes = await editUser(user.id, {
+              name: data.user.name,
+              note: data.user.note,
+              tags: data.user.tags,
+              expiresAt: data.user.expiresAt ?? null,
+              providerGroup: data.user.providerGroup ?? null,
+              limit5hUsd: data.user.limit5hUsd,
+              dailyQuota: data.user.dailyQuota,
+              limitWeeklyUsd: data.user.limitWeeklyUsd,
+              limitMonthlyUsd: data.user.limitMonthlyUsd,
+              limitTotalUsd: data.user.limitTotalUsd,
+              limitConcurrentSessions: data.user.limitConcurrentSessions,
+              dailyResetMode: data.user.dailyResetMode,
+              dailyResetTime: data.user.dailyResetTime,
+              allowedClients: data.user.allowedClients,
+              allowedModels: data.user.allowedModels,
+            });
+            if (!userRes.ok) {
+              toast.error(userRes.error || t("editDialog.saveFailed"));
+              return;
+            }
+
+            // Handle keys: edit existing, create new (negative ID), delete removed
+            for (const key of data.keys) {
+              if (key.id < 0) {
+                // New key - create it
+                const keyRes = await addKey({
+                  userId: user.id,
+                  name: key.name,
+                  expiresAt: key.expiresAt || undefined,
+                  canLoginWebUi: key.canLoginWebUi,
+                  providerGroup: key.providerGroup?.trim() ? key.providerGroup.trim() : null,
+                  cacheTtlPreference: key.cacheTtlPreference,
+                  limit5hUsd: key.limit5hUsd,
+                  limitDailyUsd: key.limitDailyUsd,
+                  dailyResetMode: key.dailyResetMode,
+                  dailyResetTime: key.dailyResetTime,
+                  limitWeeklyUsd: key.limitWeeklyUsd,
+                  limitMonthlyUsd: key.limitMonthlyUsd,
+                  limitTotalUsd: key.limitTotalUsd,
+                  limitConcurrentSessions: key.limitConcurrentSessions,
+                });
+                if (!keyRes.ok) {
+                  toast.error(
+                    keyRes.error || t("createDialog.keyCreateFailed", { name: key.name })
+                  );
+                  return;
+                }
+              } else {
+                // Existing key - edit it
+                const keyRes = await editKey(key.id, {
+                  name: key.name,
+                  expiresAt: key.expiresAt || undefined,
+                  canLoginWebUi: key.canLoginWebUi,
+                  isEnabled: key.isEnabled,
+                  providerGroup: key.providerGroup?.trim() ? key.providerGroup.trim() : null,
+                  cacheTtlPreference: key.cacheTtlPreference,
+                  limit5hUsd: key.limit5hUsd,
+                  limitDailyUsd: key.limitDailyUsd,
+                  dailyResetMode: key.dailyResetMode,
+                  dailyResetTime: key.dailyResetTime,
+                  limitWeeklyUsd: key.limitWeeklyUsd,
+                  limitMonthlyUsd: key.limitMonthlyUsd,
+                  limitTotalUsd: key.limitTotalUsd,
+                  limitConcurrentSessions: key.limitConcurrentSessions,
+                });
+                if (!keyRes.ok) {
+                  toast.error(keyRes.error || t("editDialog.keySaveFailed", { name: key.name }));
+                  return;
+                }
+              }
+            }
+
+            // Delete removed keys
+            for (const deletedKeyId of deletedKeyIds) {
+              const deleteRes = await removeKey(deletedKeyId);
+              if (!deleteRes.ok) {
+                toast.error(deleteRes.error || t("editDialog.keyDeleteFailed"));
+                return;
+              }
+            }
+
+            toast.success(t("editDialog.saveSuccess"));
+          }
+
+          onSuccess?.();
+          onOpenChange(false);
+          router.refresh();
+        } catch (error) {
+          console.error("[UnifiedEditDialog] submit failed", error);
+          toast.error(
+            mode === "create" ? t("createDialog.saveFailed") : t("editDialog.saveFailed")
+          );
+        }
+      });
+    },
+  });
+
+  const errorMessage = useMemo(() => getFirstErrorMessage(form.errors), [form.errors]);
+
+  const keys = (form.values.keys || defaultValues.keys) as UnifiedEditValues["keys"];
+  const currentUserDraft = form.values.user || defaultValues.user;
+  const showUserProviderGroup = mode === "edit" && Boolean(user?.providerGroup?.trim());
+
+  const userEditTranslations = useMemo(() => {
+    return {
+      sections: {
+        basicInfo: t("userEditSection.sections.basicInfo"),
+        expireTime: t("userEditSection.sections.expireTime"),
+        limitRules: t("userEditSection.sections.limitRules"),
+        accessRestrictions: t("userEditSection.sections.accessRestrictions"),
+      },
+      fields: {
+        username: {
+          label: t("userEditSection.fields.username.label"),
+          placeholder: t("userEditSection.fields.username.placeholder"),
+        },
+        description: {
+          label: t("userEditSection.fields.description.label"),
+          placeholder: t("userEditSection.fields.description.placeholder"),
+        },
+        tags: {
+          label: t("userEditSection.fields.tags.label"),
+          placeholder: t("userEditSection.fields.tags.placeholder"),
+        },
+        providerGroup: showUserProviderGroup
+          ? {
+              label: t("userEditSection.fields.providerGroup.label"),
+              placeholder: t("userEditSection.fields.providerGroup.placeholder"),
+            }
+          : undefined,
+        enableStatus:
+          mode === "edit" && isAdmin
+            ? {
+                label: t("userEditSection.fields.enableStatus.label"),
+                enabledDescription: t("userEditSection.fields.enableStatus.enabledDescription"),
+                disabledDescription: t("userEditSection.fields.enableStatus.disabledDescription"),
+                confirmEnable: t("userEditSection.fields.enableStatus.confirmEnable"),
+                confirmDisable: t("userEditSection.fields.enableStatus.confirmDisable"),
+                confirmEnableTitle: t("userEditSection.fields.enableStatus.confirmEnableTitle"),
+                confirmDisableTitle: t("userEditSection.fields.enableStatus.confirmDisableTitle"),
+                confirmEnableDescription: t(
+                  "userEditSection.fields.enableStatus.confirmEnableDescription"
+                ),
+                confirmDisableDescription: t(
+                  "userEditSection.fields.enableStatus.confirmDisableDescription"
+                ),
+                cancel: t("userEditSection.fields.enableStatus.cancel"),
+                processing: t("userEditSection.fields.enableStatus.processing"),
+              }
+            : undefined,
+        allowedClients: {
+          label: t("userEditSection.fields.allowedClients.label"),
+          description: t("userEditSection.fields.allowedClients.description"),
+          customLabel: t("userEditSection.fields.allowedClients.customLabel"),
+          customPlaceholder: t("userEditSection.fields.allowedClients.customPlaceholder"),
+        },
+        allowedModels: {
+          label: t("userEditSection.fields.allowedModels.label"),
+          placeholder: t("userEditSection.fields.allowedModels.placeholder"),
+          description: t("userEditSection.fields.allowedModels.description"),
+        },
+      },
+      presetClients: {
+        "claude-cli": t("userEditSection.presetClients.claude-cli"),
+        "gemini-cli": t("userEditSection.presetClients.gemini-cli"),
+        "factory-cli": t("userEditSection.presetClients.factory-cli"),
+        "codex-cli": t("userEditSection.presetClients.codex-cli"),
+      },
+      limitRules: {
+        addRule: t("limitRules.addRule"),
+        ruleTypes: {
+          limit5h: t("limitRules.ruleTypes.limit5h"),
+          limitDaily: t("limitRules.ruleTypes.limitDaily"),
+          limitWeekly: t("limitRules.ruleTypes.limitWeekly"),
+          limitMonthly: t("limitRules.ruleTypes.limitMonthly"),
+          limitTotal: t("limitRules.ruleTypes.limitTotal"),
+          limitSessions: t("limitRules.ruleTypes.limitSessions"),
+        },
+        quickValues: {
+          "10": t("limitRules.quickValues.10"),
+          "50": t("limitRules.quickValues.50"),
+          "100": t("limitRules.quickValues.100"),
+          "500": t("limitRules.quickValues.500"),
+        },
+      },
+      quickExpire: {
+        week: t("quickExpire.oneWeek"),
+        month: t("quickExpire.oneMonth"),
+        threeMonths: t("quickExpire.threeMonths"),
+        year: t("quickExpire.oneYear"),
+      },
+    };
+  }, [t, showUserProviderGroup, mode, isAdmin]);
+
+  const keyEditTranslations = useMemo(() => {
+    return {
+      sections: {
+        basicInfo: t("keyEditSection.sections.basicInfo"),
+        expireTime: t("keyEditSection.sections.expireTime"),
+        limitRules: t("keyEditSection.sections.limitRules"),
+        specialFeatures: t("keyEditSection.sections.specialFeatures"),
+      },
+      fields: {
+        keyName: {
+          label: t("keyEditSection.fields.keyName.label"),
+          placeholder: t("keyEditSection.fields.keyName.placeholder"),
+        },
+        enableStatus: {
+          label: t("keyEditSection.fields.enableStatus.label"),
+          description: t("keyEditSection.fields.enableStatus.description"),
+        },
+        balanceQueryPage: {
+          label: t("keyEditSection.fields.balanceQueryPage.label"),
+          description: t("keyEditSection.fields.balanceQueryPage.description"),
+          descriptionEnabled: t("keyEditSection.fields.balanceQueryPage.descriptionEnabled"),
+          descriptionDisabled: t("keyEditSection.fields.balanceQueryPage.descriptionDisabled"),
+        },
+        providerGroup: {
+          label: t("keyEditSection.fields.providerGroup.label"),
+          placeholder: t("keyEditSection.fields.providerGroup.placeholder"),
+        },
+        cacheTtl: {
+          label: t("keyEditSection.fields.cacheTtl.label"),
+          options: {
+            inherit: t("keyEditSection.fields.cacheTtl.options.inherit"),
+            "5m": t("keyEditSection.fields.cacheTtl.options.5m"),
+            "1h": t("keyEditSection.fields.cacheTtl.options.1h"),
+          },
+        },
+      },
+      limitRules: {
+        title: t("keyEditSection.limitRules.title"),
+        limitTypes: {
+          limit5h: t("limitRules.ruleTypes.limit5h"),
+          limitDaily: t("limitRules.ruleTypes.limitDaily"),
+          limitWeekly: t("limitRules.ruleTypes.limitWeekly"),
+          limitMonthly: t("limitRules.ruleTypes.limitMonthly"),
+          limitTotal: t("limitRules.ruleTypes.limitTotal"),
+          limitSessions: t("limitRules.ruleTypes.limitSessions"),
+        },
+        quickValues: {
+          "10": t("limitRules.quickValues.10"),
+          "50": t("limitRules.quickValues.50"),
+          "100": t("limitRules.quickValues.100"),
+          "500": t("limitRules.quickValues.500"),
+        },
+        actions: {
+          add: t("keyEditSection.limitRules.actions.add"),
+          remove: t("keyEditSection.limitRules.actions.remove"),
+        },
+        daily: {
+          mode: {
+            fixed: t("keyEditSection.limitRules.daily.mode.fixed"),
+            rolling: t("keyEditSection.limitRules.daily.mode.rolling"),
+          },
+        },
+      },
+      quickExpire: {
+        week: t("quickExpire.oneWeek"),
+        month: t("quickExpire.oneMonth"),
+        threeMonths: t("quickExpire.threeMonths"),
+        year: t("quickExpire.oneYear"),
+      },
+    };
+  }, [t]);
+
+  const handleUserChange = (field: string | Record<string, any>, value?: any) => {
+    const prev = form.values.user || (defaultValues.user as UnifiedEditValues["user"]);
+    const next = { ...prev } as UnifiedEditValues["user"];
+
+    if (typeof field === "object") {
+      // Batch update: apply multiple fields at once
+      Object.entries(field).forEach(([key, val]) => {
+        const mappedField = key === "description" ? "note" : key;
+        (next as any)[mappedField] = mappedField === "expiresAt" ? (val ?? undefined) : val;
+      });
+    } else {
+      // Single field update (backward compatible)
+      const mappedField = field === "description" ? "note" : field;
+      if (mappedField === "expiresAt") {
+        (next as any)[mappedField] = value ?? undefined;
+      } else {
+        (next as any)[mappedField] = value;
+      }
+    }
+    form.setValue("user", next);
+  };
+
+  const handleKeyChange = (keyId: number, field: string | Record<string, any>, value?: any) => {
+    const prevKeys = (form.values.keys || defaultValues.keys) as UnifiedEditValues["keys"];
+    const nextKeys = prevKeys.map((k) => {
+      if (k.id !== keyId) return k;
+
+      if (typeof field === "object") {
+        // Batch update
+        const updates: Record<string, any> = {};
+        Object.entries(field).forEach(([key, val]) => {
+          if (key === "expiresAt") {
+            updates[key] = val ? (val as Date).toISOString() : undefined;
+          } else {
+            updates[key] = val;
+          }
+        });
+        return { ...k, ...updates };
+      }
+
+      // Single field update (backward compatible)
+      if (field === "expiresAt") {
+        return { ...k, expiresAt: value ? (value as Date).toISOString() : undefined };
+      }
+      return { ...k, [field]: value };
+    });
+    form.setValue("keys", nextKeys);
+  };
+
+  const handleAddKey = () => {
+    const prevKeys = (form.values.keys || defaultValues.keys) as UnifiedEditValues["keys"];
+    const newKeyId = getNextTempKeyId();
+    const newKey = {
+      id: newKeyId,
+      name: "",
+      isEnabled: true,
+      expiresAt: undefined,
+      canLoginWebUi: true,
+      providerGroup: "",
+      cacheTtlPreference: "inherit" as const,
+      limit5hUsd: null,
+      limitDailyUsd: null,
+      dailyResetMode: "fixed" as const,
+      dailyResetTime: "00:00",
+      limitWeeklyUsd: null,
+      limitMonthlyUsd: null,
+      limitTotalUsd: null,
+      limitConcurrentSessions: 0,
+    };
+    form.setValue("keys", [...prevKeys, newKey]);
+    // Trigger auto-scroll to the newly added key
+    setNewlyAddedKeyId(newKeyId);
+    // Auto-expand the newly added key
+    setExpandedKeyIds((prev) => new Set([...prev, newKeyId]));
+  };
+
+  const handleRemoveKey = (keyId: number, keyName: string) => {
+    if (keyId < 0) {
+      // New key (not yet saved) - remove directly without confirmation
+      const prevKeys = (form.values.keys || defaultValues.keys) as UnifiedEditValues["keys"];
+      form.setValue(
+        "keys",
+        prevKeys.filter((k) => k.id !== keyId)
+      );
+    } else {
+      // Existing key - show confirmation dialog
+      setKeyToDelete({ id: keyId, name: keyName });
+    }
+  };
+
+  const confirmRemoveKey = () => {
+    if (!keyToDelete) return;
+    const prevKeys = (form.values.keys || defaultValues.keys) as UnifiedEditValues["keys"];
+    form.setValue(
+      "keys",
+      prevKeys.filter((k) => k.id !== keyToDelete.id)
+    );
+    setDeletedKeyIds((prev) => [...prev, keyToDelete.id]);
+    setKeyToDelete(null);
+  };
+
+  const handleDisableUser = async () => {
+    if (!user) return;
+    const res = await toggleUserEnabled(user.id, false);
+    if (!res.ok) {
+      throw new Error(res.error || t("editDialog.operationFailed"));
+    }
+    toast.success(t("editDialog.userDisabled"));
+    onSuccess?.();
+    router.refresh();
+  };
+
+  const handleEnableUser = async () => {
+    if (!user) return;
+    const res = await toggleUserEnabled(user.id, true);
+    if (!res.ok) {
+      throw new Error(res.error || t("editDialog.operationFailed"));
+    }
+    toast.success(t("editDialog.userEnabled"));
+    onSuccess?.();
+    router.refresh();
+  };
+
+  const handleDeleteUser = async () => {
+    if (!user) return;
+    const res = await removeUser(user.id);
+    if (!res.ok) {
+      throw new Error(res.error || t("editDialog.deleteFailed"));
+    }
+    toast.success(t("editDialog.userDeleted"));
+    onSuccess?.();
+    onOpenChange(false);
+    router.refresh();
+  };
+
+  return (
+    <DialogContent className="w-full max-w-[95vw] sm:max-w-[85vw] md:max-w-[70vw] lg:max-w-4xl max-h-[90vh] max-h-[90dvh] p-0 flex flex-col overflow-hidden">
+      <form onSubmit={form.handleSubmit} className="flex flex-1 min-h-0 flex-col">
+        <DialogHeader className="px-6 pt-6 pb-4 border-b flex-shrink-0">
+          <div className="flex items-center gap-2">
+            {mode === "create" ? (
+              <UserPlus className="h-5 w-5 text-primary" aria-hidden="true" />
+            ) : (
+              <UserCog className="h-5 w-5 text-primary" aria-hidden="true" />
+            )}
+            <DialogTitle>
+              {mode === "create" ? t("createDialog.title") : t("editDialog.title")}
+            </DialogTitle>
+          </div>
+          <DialogDescription className="sr-only">
+            {mode === "create" ? t("createDialog.description") : t("editDialog.description")}
+          </DialogDescription>
+        </DialogHeader>
+
+        <div className="flex-1 min-h-0 overflow-y-auto px-6 pt-6 pb-6 space-y-8">
+          <UserEditSection
+            user={{
+              id: user?.id ?? 0,
+              name: currentUserDraft.name || "",
+              description: currentUserDraft.note || "",
+              tags: currentUserDraft.tags || [],
+              expiresAt: currentUserDraft.expiresAt ?? null,
+              providerGroup: currentUserDraft.providerGroup ?? null,
+              limit5hUsd: currentUserDraft.limit5hUsd ?? null,
+              dailyQuota: currentUserDraft.dailyQuota ?? null,
+              limitWeeklyUsd: currentUserDraft.limitWeeklyUsd ?? null,
+              limitMonthlyUsd: currentUserDraft.limitMonthlyUsd ?? null,
+              limitTotalUsd: currentUserDraft.limitTotalUsd ?? null,
+              limitConcurrentSessions: currentUserDraft.limitConcurrentSessions ?? null,
+              dailyResetMode: currentUserDraft.dailyResetMode ?? "fixed",
+              dailyResetTime: currentUserDraft.dailyResetTime ?? "00:00",
+              allowedClients: currentUserDraft.allowedClients || [],
+              allowedModels: currentUserDraft.allowedModels || [],
+            }}
+            isEnabled={mode === "edit" ? user?.isEnabled : undefined}
+            onToggleEnabled={
+              mode === "edit" && isAdmin && user
+                ? async () => {
+                    if (user.isEnabled) {
+                      await handleDisableUser();
+                    } else {
+                      await handleEnableUser();
+                    }
+                  }
+                : undefined
+            }
+            showProviderGroup={showUserProviderGroup}
+            onChange={handleUserChange}
+            translations={userEditTranslations}
+            modelSuggestions={modelSuggestions}
+          />
+
+          <Separator />
+
+          <div className="space-y-4">
+            <div className="flex items-center justify-between py-2">
+              <div className="flex items-center gap-2">
+                <KeyRound className="h-4 w-4 text-muted-foreground" aria-hidden="true" />
+                <span className="text-sm font-semibold">{t("createDialog.keysSection")}</span>
+                <span className="text-xs text-muted-foreground">({keys.length})</span>
+              </div>
+              <Button type="button" variant="outline" size="sm" onClick={handleAddKey}>
+                <Plus className="mr-1 h-4 w-4" />
+                {t("createDialog.addKey")}
+              </Button>
+            </div>
+            <div className="space-y-8">
+              {keys.map((key, index) => {
+                const isExpanded =
+                  mode === "create" || keys.length === 1 || expandedKeyIds.has(key.id);
+                const showCollapseButton = mode === "edit" && keys.length > 1;
+
+                return (
+                  <div
+                    key={key.id}
+                    className="relative rounded-xl border border-border bg-card p-4 pt-6 shadow-sm"
+                  >
+                    <div className="absolute -top-3 left-4 z-10 px-2 py-0.5 bg-background border border-border rounded-md text-xs font-medium text-muted-foreground">
+                      Key #{index + 1}
+                    </div>
+                    <Button
+                      type="button"
+                      variant="outline"
+                      size="icon"
+                      className="absolute right-3 top-3 h-9 w-9 border-border text-muted-foreground hover:text-destructive hover:border-destructive hover:bg-destructive/10"
+                      onClick={() => handleRemoveKey(key.id, key.name)}
+                      disabled={keys.length === 1}
+                      title={
+                        keys.length === 1
+                          ? t("createDialog.cannotDeleteLastKey")
+                          : t("createDialog.removeKey")
+                      }
+                      aria-label={
+                        keys.length === 1
+                          ? t("createDialog.cannotDeleteLastKey")
+                          : t("createDialog.removeKey")
+                      }
+                    >
+                      <Trash2 className="h-5 w-5" aria-hidden="true" />
+                    </Button>
+
+                    {/* Collapsed view */}
+                    {!isExpanded && (
+                      <div
+                        className="flex items-center justify-between gap-4 cursor-pointer pr-12"
+                        onClick={() => toggleKeyExpanded(key.id)}
+                      >
+                        <div className="flex items-center gap-3 min-w-0">
+                          <span className="font-medium truncate">{key.name || "Unnamed Key"}</span>
+                          <Badge variant={key.isEnabled ? "default" : "secondary"}>
+                            {key.isEnabled ? t("keyStatus.enabled") : t("keyStatus.disabled")}
+                          </Badge>
+                          {key.providerGroup && (
+                            <span className="text-sm text-muted-foreground truncate">
+                              {key.providerGroup}
+                            </span>
+                          )}
+                        </div>
+                        {showCollapseButton && (
+                          <Button type="button" variant="ghost" size="sm">
+                            <ChevronDown className="h-4 w-4" />
+                          </Button>
+                        )}
+                      </div>
+                    )}
+
+                    {/* Expanded view */}
+                    {isExpanded && (
+                      <>
+                        {showCollapseButton && (
+                          <div className="flex justify-end mb-2 pr-12">
+                            <Button
+                              type="button"
+                              variant="ghost"
+                              size="sm"
+                              onClick={() => toggleKeyExpanded(key.id)}
+                            >
+                              <ChevronUp className="h-4 w-4" />
+                            </Button>
+                          </div>
+                        )}
+                        <KeyEditSection
+                          keyData={{
+                            id: key.id,
+                            name: key.name,
+                            isEnabled: key.isEnabled ?? true,
+                            expiresAt: key.expiresAt ? new Date(key.expiresAt) : null,
+                            canLoginWebUi: key.canLoginWebUi ?? false,
+                            providerGroup: key.providerGroup || "",
+                            cacheTtlPreference: key.cacheTtlPreference ?? "inherit",
+                            limit5hUsd: key.limit5hUsd ?? null,
+                            limitDailyUsd: key.limitDailyUsd ?? null,
+                            dailyResetMode: key.dailyResetMode ?? "fixed",
+                            dailyResetTime: key.dailyResetTime ?? "00:00",
+                            limitWeeklyUsd: key.limitWeeklyUsd ?? null,
+                            limitMonthlyUsd: key.limitMonthlyUsd ?? null,
+                            limitTotalUsd: key.limitTotalUsd ?? null,
+                            limitConcurrentSessions: key.limitConcurrentSessions ?? 0,
+                          }}
+                          isAdmin={isAdmin}
+                          onChange={
+                            ((fieldOrBatch: string | Record<string, any>, value?: any) =>
+                              handleKeyChange(key.id, fieldOrBatch, value)) as {
+                              (field: string, value: any): void;
+                              (batch: Record<string, any>): void;
+                            }
+                          }
+                          scrollRef={
+                            scrollToKeyId === key.id || newlyAddedKeyId === key.id
+                              ? keyScrollRef
+                              : undefined
+                          }
+                          translations={keyEditTranslations}
+                        />
+                      </>
+                    )}
+                  </div>
+                );
+              })}
+            </div>
+          </div>
+
+          {mode === "edit" && isAdmin && user && (
+            <DangerZone
+              userId={user.id}
+              userName={user.name}
+              onDelete={handleDeleteUser}
+              translations={t.raw("dangerZone") as Record<string, unknown>}
+            />
+          )}
+        </div>
+
+        {errorMessage && <div className="px-6 pb-2 text-sm text-destructive">{errorMessage}</div>}
+
+        <DialogFooter className="px-6 pb-6 flex-shrink-0">
+          <Button
+            type="button"
+            variant="outline"
+            onClick={() => onOpenChange(false)}
+            disabled={isPending}
+          >
+            {tCommon("cancel")}
+          </Button>
+          <Button type="submit" disabled={isPending}>
+            {isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
+            {isPending
+              ? mode === "create"
+                ? t("createDialog.creating")
+                : t("editDialog.saving")
+              : mode === "create"
+                ? t("createDialog.create")
+                : tCommon("save")}
+          </Button>
+        </DialogFooter>
+      </form>
+
+      {/* Delete key confirmation dialog */}
+      <AlertDialog open={!!keyToDelete} onOpenChange={(open) => !open && setKeyToDelete(null)}>
+        <AlertDialogContent>
+          <AlertDialogHeader>
+            <AlertDialogTitle>{t("createDialog.confirmRemoveKeyTitle")}</AlertDialogTitle>
+            <AlertDialogDescription>
+              {t("createDialog.confirmRemoveKeyDescription", { name: keyToDelete?.name || "" })}
+            </AlertDialogDescription>
+          </AlertDialogHeader>
+          <AlertDialogFooter>
+            <AlertDialogCancel>{tCommon("cancel")}</AlertDialogCancel>
+            <AlertDialogAction onClick={confirmRemoveKey}>{tCommon("confirm")}</AlertDialogAction>
+          </AlertDialogFooter>
+        </AlertDialogContent>
+      </AlertDialog>
+    </DialogContent>
+  );
+}
+
+export function UnifiedEditDialog(props: UnifiedEditDialogProps) {
+  return (
+    <Dialog open={props.open} onOpenChange={props.onOpenChange}>
+      {props.open ? (
+        <UnifiedEditDialogInner
+          key={props.mode === "edit" ? props.user?.id : "create"}
+          {...props}
+        />
+      ) : null}
+    </Dialog>
+  );
+}

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

@@ -56,6 +56,8 @@ export function UserKeyManager({ users, currentUser, currencyCode = "USD" }: Use
                     limitWeeklyUsd: activeUser.limitWeeklyUsd ?? undefined,
                     limitMonthlyUsd: activeUser.limitMonthlyUsd ?? undefined,
                     limitConcurrentSessions: activeUser.limitConcurrentSessions ?? undefined,
+                    dailyResetMode: activeUser.dailyResetMode ?? "fixed",
+                    dailyResetTime: activeUser.dailyResetTime ?? "00:00",
                     isEnabled: activeUser.isEnabled,
                     expiresAt: activeUser.expiresAt ?? undefined,
                   }
@@ -108,6 +110,8 @@ export function UserKeyManager({ users, currentUser, currencyCode = "USD" }: Use
                     limitWeeklyUsd: activeUser.limitWeeklyUsd ?? undefined,
                     limitMonthlyUsd: activeUser.limitMonthlyUsd ?? undefined,
                     limitConcurrentSessions: activeUser.limitConcurrentSessions ?? undefined,
+                    dailyResetMode: activeUser.dailyResetMode ?? "fixed",
+                    dailyResetTime: activeUser.dailyResetTime ?? "00:00",
                     isEnabled: activeUser.isEnabled,
                     expiresAt: activeUser.expiresAt ?? undefined,
                   }

+ 305 - 0
src/app/[locale]/dashboard/_components/user/user-key-table-row.tsx

@@ -0,0 +1,305 @@
+"use client";
+
+import { ChevronDown, ChevronRight, SquarePen } from "lucide-react";
+import { useLocale } from "next-intl";
+import { useTransition } from "react";
+import { toast } from "sonner";
+import { removeKey } from "@/actions/keys";
+import { Badge } from "@/components/ui/badge";
+import { Button } from "@/components/ui/button";
+import { Collapsible, CollapsibleContent } from "@/components/ui/collapsible";
+import { TableCell, TableRow } from "@/components/ui/table";
+import { useRouter } from "@/i18n/routing";
+import { cn } from "@/lib/utils";
+import { formatDate } from "@/lib/utils/date-format";
+import type { UserDisplay } from "@/types/user";
+import { KeyRowItem } from "./key-row-item";
+import { UserLimitBadge } from "./user-limit-badge";
+
+export interface UserKeyTableRowProps {
+  user: UserDisplay; // 包含 keys 数组
+  expanded: boolean;
+  onToggle: () => void;
+  onEditUser: (scrollToKeyId?: number) => void;
+  onQuickRenew?: (user: UserDisplay) => void;
+  currentUser?: { role: string };
+  currencyCode?: string;
+  highlightKeyIds?: Set<number>;
+  translations: {
+    columns: {
+      username: string;
+      note: string;
+      expiresAt: string;
+      expiresAtHint?: string;
+      limit5h: string;
+      limitDaily: string;
+      limitWeekly: string;
+      limitMonthly: string;
+      limitTotal: string;
+      limitSessions: string;
+    };
+    keyRow: any;
+    expand: string;
+    collapse: string;
+    noKeys: string;
+    defaultGroup: string;
+    actions: {
+      edit: string;
+      details: string;
+      logs: string;
+      delete: string;
+    };
+    userStatus?: {
+      disabled: string;
+    };
+  };
+}
+
+const TOTAL_COLUMNS = 9;
+
+function normalizeLimitValue(value: unknown): number | null {
+  const raw = typeof value === "number" ? value : typeof value === "string" ? Number(value) : NaN;
+  if (!Number.isFinite(raw) || raw <= 0) return null;
+  return raw;
+}
+
+function formatExpiry(expiresAt: UserDisplay["expiresAt"], locale: string): string {
+  if (!expiresAt) return "-";
+  const date = expiresAt instanceof Date ? expiresAt : new Date(expiresAt);
+  if (Number.isNaN(date.getTime())) return "-";
+  return formatDate(date, "yyyy-MM-dd", locale);
+}
+
+export function UserKeyTableRow({
+  user,
+  expanded,
+  onToggle,
+  onEditUser,
+  onQuickRenew,
+  currencyCode,
+  highlightKeyIds,
+  translations,
+}: UserKeyTableRowProps) {
+  const locale = useLocale();
+  const router = useRouter();
+  const [, startTransition] = useTransition();
+
+  const keyRowTranslations = {
+    ...(translations.keyRow ?? {}),
+    defaultGroup: translations.defaultGroup,
+  };
+
+  const expiresText = formatExpiry(user.expiresAt ?? null, locale);
+
+  const limit5h = normalizeLimitValue(user.limit5hUsd);
+  const limitDaily = normalizeLimitValue(user.dailyQuota);
+  const limitWeekly = normalizeLimitValue(user.limitWeeklyUsd);
+  const limitMonthly = normalizeLimitValue(user.limitMonthlyUsd);
+  const limitTotal = normalizeLimitValue(user.limitTotalUsd);
+  const limitSessions = normalizeLimitValue(user.limitConcurrentSessions);
+
+  const handleDeleteKey = (keyId: number) => {
+    startTransition(async () => {
+      const res = await removeKey(keyId);
+      if (!res.ok) {
+        toast.error(res.error || "删除失败");
+        return;
+      }
+      toast.success("删除成功");
+      router.refresh();
+    });
+  };
+
+  return (
+    <>
+      <TableRow
+        className={cn("cursor-pointer", expanded && "border-0")}
+        onClick={onToggle}
+        role="button"
+        tabIndex={0}
+        aria-expanded={expanded}
+        onKeyDown={(e) => {
+          if (e.key !== "Enter" && e.key !== " ") return;
+          e.preventDefault();
+          onToggle();
+        }}
+      >
+        {/* 用户名 / 备注 */}
+        <TableCell className="min-w-[260px]">
+          <div className="flex items-center gap-2 min-w-0">
+            {expanded ? (
+              <ChevronDown className="h-4 w-4 text-muted-foreground" />
+            ) : (
+              <ChevronRight className="h-4 w-4 text-muted-foreground" />
+            )}
+            <span className="sr-only">
+              {expanded ? translations.collapse : translations.expand}
+            </span>
+            <span className="font-medium truncate">{user.name}</span>
+            {!user.isEnabled && (
+              <Badge variant="secondary" className="text-[10px] shrink-0">
+                {translations.userStatus?.disabled || "Disabled"}
+              </Badge>
+            )}
+            {user.note ? (
+              <span className="text-xs text-muted-foreground truncate">{user.note}</span>
+            ) : null}
+          </div>
+        </TableCell>
+
+        {/* 到期时间 - clickable for quick renew */}
+        <TableCell
+          className={cn(
+            "text-sm text-muted-foreground",
+            onQuickRenew && "cursor-pointer hover:text-primary hover:underline"
+          )}
+          onClick={(e) => {
+            if (onQuickRenew) {
+              e.stopPropagation();
+              onQuickRenew(user);
+            }
+          }}
+          title={onQuickRenew ? translations.columns.expiresAtHint : undefined}
+        >
+          {expiresText}
+        </TableCell>
+
+        {/* 5h 限额 */}
+        <TableCell className="text-center">
+          <div className="flex items-center justify-center">
+            <UserLimitBadge
+              userId={user.id}
+              limitType="5h"
+              limit={limit5h}
+              label={translations.columns.limit5h}
+            />
+          </div>
+        </TableCell>
+
+        {/* 每日限额 */}
+        <TableCell className="text-center">
+          <div className="flex items-center justify-center">
+            <UserLimitBadge
+              userId={user.id}
+              limitType="daily"
+              limit={limitDaily}
+              label={translations.columns.limitDaily}
+            />
+          </div>
+        </TableCell>
+
+        {/* 周限额 */}
+        <TableCell className="text-center">
+          <div className="flex items-center justify-center">
+            <UserLimitBadge
+              userId={user.id}
+              limitType="weekly"
+              limit={limitWeekly}
+              label={translations.columns.limitWeekly}
+            />
+          </div>
+        </TableCell>
+
+        {/* 月限额 */}
+        <TableCell className="text-center">
+          <div className="flex items-center justify-center">
+            <UserLimitBadge
+              userId={user.id}
+              limitType="monthly"
+              limit={limitMonthly}
+              label={translations.columns.limitMonthly}
+            />
+          </div>
+        </TableCell>
+
+        {/* 总限额 */}
+        <TableCell className="text-center">
+          <div className="flex items-center justify-center">
+            <UserLimitBadge
+              userId={user.id}
+              limitType="total"
+              limit={limitTotal}
+              label={translations.columns.limitTotal}
+            />
+          </div>
+        </TableCell>
+
+        {/* 并发限额 */}
+        <TableCell className="text-center">
+          <div className="flex items-center justify-center">
+            <Badge
+              variant={limitSessions ? "secondary" : "outline"}
+              className="px-2 py-0.5 tabular-nums text-xs"
+              title={`${translations.columns.limitSessions}: ${limitSessions ?? "-"}`}
+              aria-label={`${translations.columns.limitSessions}: ${limitSessions ?? "-"}`}
+            >
+              {limitSessions ?? "-"}
+            </Badge>
+          </div>
+        </TableCell>
+
+        {/* 操作 */}
+        <TableCell className="text-center">
+          <Button
+            type="button"
+            variant="ghost"
+            size="icon-sm"
+            aria-label={translations.actions.edit}
+            title={translations.actions.edit}
+            onClick={(e) => {
+              e.stopPropagation();
+              onEditUser();
+            }}
+          >
+            <SquarePen className="h-4 w-4" />
+          </Button>
+        </TableCell>
+      </TableRow>
+
+      <TableRow className={cn("hover:bg-transparent", !expanded && "border-0")}>
+        <TableCell colSpan={TOTAL_COLUMNS} className="p-0">
+          <Collapsible open={expanded}>
+            <CollapsibleContent>
+              <div className="bg-muted px-3 py-3">
+                {user.keys.length > 0 ? (
+                  <div className="overflow-hidden rounded-md border bg-background">
+                    {user.keys.map((key) => (
+                      <KeyRowItem
+                        key={key.id}
+                        keyData={{
+                          id: key.id,
+                          name: key.name,
+                          maskedKey: key.maskedKey,
+                          fullKey: key.fullKey,
+                          canCopy: key.canCopy,
+                          providerGroup: key.providerGroup,
+                          todayUsage: key.todayUsage,
+                          todayCallCount: key.todayCallCount,
+                          lastUsedAt: key.lastUsedAt,
+                          expiresAt: key.expiresAt,
+                          status: key.status,
+                          modelStats: key.modelStats,
+                        }}
+                        onEdit={() => onEditUser(key.id)}
+                        onDelete={() => handleDeleteKey(key.id)}
+                        onViewLogs={() => router.push(`/dashboard/logs?keyId=${key.id}`)}
+                        onViewDetails={() => onEditUser(key.id)}
+                        currencyCode={currencyCode}
+                        translations={keyRowTranslations}
+                        highlight={highlightKeyIds?.has(key.id)}
+                      />
+                    ))}
+                  </div>
+                ) : (
+                  <div className="py-6 text-center text-sm text-muted-foreground">
+                    {translations.noKeys}
+                  </div>
+                )}
+              </div>
+            </CollapsibleContent>
+          </Collapsible>
+        </TableCell>
+      </TableRow>
+    </>
+  );
+}

+ 157 - 0
src/app/[locale]/dashboard/_components/user/user-limit-badge.tsx

@@ -0,0 +1,157 @@
+"use client";
+
+import { useEffect, useState } from "react";
+import { getUserAllLimitUsage } from "@/actions/users";
+import { Badge } from "@/components/ui/badge";
+import { Skeleton } from "@/components/ui/skeleton";
+import { cn } from "@/lib/utils";
+
+export type LimitType = "5h" | "daily" | "weekly" | "monthly" | "total";
+
+export interface UserLimitBadgeProps {
+  userId: number;
+  limitType: LimitType;
+  limit: number | null;
+  label: string;
+  unit?: string;
+}
+
+interface LimitUsageData {
+  limit5h: { usage: number; limit: number | null };
+  limitDaily: { usage: number; limit: number | null };
+  limitWeekly: { usage: number; limit: number | null };
+  limitMonthly: { usage: number; limit: number | null };
+  limitTotal: { usage: number; limit: number | null };
+}
+
+// Global cache for user limit usage data
+const usageCache = new Map<number, { data: LimitUsageData; timestamp: number }>();
+const CACHE_TTL = 60 * 1000; // 1 minute
+
+function formatPercentage(usage: number, limit: number): string {
+  const percentage = Math.min(Math.round((usage / limit) * 100), 999);
+  return `${percentage}%`;
+}
+
+function formatValue(value: number, unit?: string): string {
+  if (!Number.isFinite(value)) return String(value);
+  const formatted = Number.isInteger(value) ? String(value) : value.toFixed(2).replace(/\.00$/, "");
+  return unit ? `${unit}${formatted}` : formatted;
+}
+
+function getPercentageColor(usage: number, limit: number): string {
+  const percentage = (usage / limit) * 100;
+  if (percentage >= 100) return "text-destructive";
+  if (percentage >= 80) return "text-orange-600";
+  return "";
+}
+
+function getLimitTypeKey(limitType: LimitType): keyof LimitUsageData {
+  const mapping: Record<LimitType, keyof LimitUsageData> = {
+    "5h": "limit5h",
+    daily: "limitDaily",
+    weekly: "limitWeekly",
+    monthly: "limitMonthly",
+    total: "limitTotal",
+  };
+  return mapping[limitType];
+}
+
+export function UserLimitBadge({
+  userId,
+  limitType,
+  limit,
+  label,
+  unit = "$",
+}: UserLimitBadgeProps) {
+  const [usageData, setUsageData] = useState<LimitUsageData | null>(null);
+  const [isLoading, setIsLoading] = useState(false);
+  const [error, setError] = useState(false);
+
+  useEffect(() => {
+    // If no limit is set, don't fetch usage data
+    if (limit === null || limit === undefined) {
+      return;
+    }
+
+    // Check cache first
+    const cached = usageCache.get(userId);
+    if (cached && Date.now() - cached.timestamp < CACHE_TTL) {
+      setUsageData(cached.data);
+      return;
+    }
+
+    setIsLoading(true);
+    setError(false);
+
+    getUserAllLimitUsage(userId)
+      .then((res) => {
+        if (res.ok && res.data) {
+          usageCache.set(userId, { data: res.data, timestamp: Date.now() });
+          setUsageData(res.data);
+        } else {
+          setError(true);
+        }
+      })
+      .catch(() => {
+        setError(true);
+      })
+      .finally(() => {
+        setIsLoading(false);
+      });
+  }, [userId, limit]);
+
+  // No limit set - show "-"
+  if (limit === null || limit === undefined) {
+    return (
+      <Badge
+        variant="outline"
+        className="px-2 py-0.5 tabular-nums text-xs"
+        title={`${label}: -`}
+        aria-label={`${label}: -`}
+      >
+        -
+      </Badge>
+    );
+  }
+
+  // Loading state
+  if (isLoading) {
+    return <Skeleton className="h-5 w-12" />;
+  }
+
+  // Error state - show just the limit value
+  if (error || !usageData) {
+    return (
+      <Badge
+        variant="secondary"
+        className="px-2 py-0.5 tabular-nums text-xs"
+        title={`${label}: ${formatValue(limit, unit)}`}
+        aria-label={`${label}: ${formatValue(limit, unit)}`}
+      >
+        {formatValue(limit, unit)}
+      </Badge>
+    );
+  }
+
+  // Get usage for this limit type
+  const key = getLimitTypeKey(limitType);
+  const typeData = usageData[key];
+  const usage = typeData?.usage ?? 0;
+
+  // Calculate percentage
+  const percentage = formatPercentage(usage, limit);
+  const colorClass = getPercentageColor(usage, limit);
+  const statusText = `${formatValue(usage, unit)} / ${formatValue(limit, unit)}`;
+
+  return (
+    <Badge
+      variant="secondary"
+      className={cn("px-2 py-0.5 tabular-nums text-xs", colorClass)}
+      title={`${label}: ${statusText}`}
+      aria-label={`${label}: ${statusText}`}
+    >
+      {percentage}
+    </Badge>
+  );
+}

+ 427 - 0
src/app/[locale]/dashboard/_components/user/user-management-table.tsx

@@ -0,0 +1,427 @@
+"use client";
+
+import { Users } from "lucide-react";
+import { useRouter } from "next/navigation";
+import { useTranslations } from "next-intl";
+import { useEffect, useMemo, useState } from "react";
+import { toast } from "sonner";
+import { renewUser } from "@/actions/users";
+import { Button } from "@/components/ui/button";
+import {
+  Table,
+  TableBody,
+  TableCell,
+  TableHead,
+  TableHeader,
+  TableRow,
+} from "@/components/ui/table";
+import { cn } from "@/lib/utils";
+import type { User, UserDisplay } from "@/types/user";
+import { QuickRenewDialog, type QuickRenewUser } from "./forms/quick-renew-dialog";
+import { UnifiedEditDialog } from "./unified-edit-dialog";
+import { UserKeyTableRow } from "./user-key-table-row";
+
+export interface UserManagementTableProps {
+  users: UserDisplay[];
+  currentUser?: User;
+  currencyCode?: string;
+  onCreateUser?: () => void;
+  highlightKeyIds?: Set<number>;
+  autoExpandOnFilter?: boolean;
+  translations: {
+    table: {
+      columns: {
+        username: string;
+        note: string;
+        expiresAt: string;
+        expiresAtHint?: string;
+        limit5h: string;
+        limitDaily: string;
+        limitWeekly: string;
+        limitMonthly: string;
+        limitTotal: string;
+        limitSessions: string;
+      };
+      keyRow: any;
+      expand: string;
+      collapse: string;
+      noKeys: string;
+      defaultGroup: string;
+    };
+    editDialog: any;
+    actions: {
+      edit: string;
+      details: string;
+      logs: string;
+      delete: string;
+    };
+    pagination: {
+      previous: string;
+      next: string;
+      page: string;
+      of: string;
+    };
+    quickRenew?: {
+      title: string;
+      description: string;
+      currentExpiry: string;
+      neverExpires: string;
+      expired: string;
+      quickOptions: {
+        "7days": string;
+        "30days": string;
+        "90days": string;
+        "1year": string;
+      };
+      customDate: string;
+      enableOnRenew: string;
+      cancel: string;
+      confirm: string;
+      confirming: string;
+      success: string;
+      failed: string;
+    };
+  };
+}
+
+const PAGE_SIZE = 20;
+const TOTAL_COLUMNS = 9;
+
+function hasTemplateTokens(text: string) {
+  return /\{[a-zA-Z0-9_]+\}/.test(text);
+}
+
+function formatTemplate(text: string, values: Record<string, string | number>) {
+  return text.replace(/\{([a-zA-Z0-9_]+)\}/g, (match, key) => {
+    if (key in values) return String(values[key]);
+    return match;
+  });
+}
+
+export function UserManagementTable({
+  users,
+  currentUser,
+  currencyCode,
+  onCreateUser,
+  highlightKeyIds,
+  autoExpandOnFilter,
+  translations,
+}: UserManagementTableProps) {
+  const router = useRouter();
+  const tUserList = useTranslations("dashboard.userList");
+  const tUserMgmt = useTranslations("dashboard.userManagement");
+  const isAdmin = currentUser?.role === "admin";
+  const [currentPage, setCurrentPage] = useState(1);
+  const [expandedUsers, setExpandedUsers] = useState<Map<number, boolean>>(
+    () => new Map(users.map((user) => [user.id, false]))
+  );
+  const [editDialogOpen, setEditDialogOpen] = useState(false);
+  const [editingUserId, setEditingUserId] = useState<number | null>(null);
+  const [scrollToKeyId, setScrollToKeyId] = useState<number | undefined>(undefined);
+
+  // Quick renew dialog state
+  const [quickRenewOpen, setQuickRenewOpen] = useState(false);
+  const [quickRenewUser, setQuickRenewUser] = useState<QuickRenewUser | null>(null);
+
+  const totalPages = useMemo(
+    () => Math.max(1, Math.ceil(users.length / PAGE_SIZE)),
+    [users.length]
+  );
+
+  useEffect(() => {
+    setCurrentPage((prev) => Math.min(Math.max(prev, 1), totalPages));
+  }, [totalPages]);
+
+  useEffect(() => {
+    setExpandedUsers((prev) => {
+      const next = new Map<number, boolean>();
+      for (const user of users) {
+        next.set(user.id, prev.get(user.id) ?? false);
+      }
+
+      if (next.size !== prev.size) return next;
+      for (const [userId, expanded] of next) {
+        if (prev.get(userId) !== expanded) return next;
+      }
+      return prev;
+    });
+  }, [users]);
+
+  useEffect(() => {
+    if (autoExpandOnFilter) {
+      setExpandedUsers(new Map(users.map((user) => [user.id, true])));
+    }
+  }, [autoExpandOnFilter, users]);
+
+  const paginatedUsers = useMemo(() => {
+    const start = (currentPage - 1) * PAGE_SIZE;
+    return users.slice(start, start + PAGE_SIZE);
+  }, [users, currentPage]);
+
+  const allExpanded = useMemo(() => {
+    if (users.length === 0) return false;
+    return users.every((user) => expandedUsers.get(user.id) ?? false);
+  }, [users, expandedUsers]);
+
+  const paginationText = useMemo(() => {
+    const templateMode =
+      hasTemplateTokens(translations.pagination.page) ||
+      hasTemplateTokens(translations.pagination.of);
+
+    if (templateMode) {
+      const pageText = formatTemplate(translations.pagination.page, {
+        page: currentPage,
+        current: currentPage,
+        currentPage,
+        totalPages,
+        total: totalPages,
+      });
+      const ofText = formatTemplate(translations.pagination.of, {
+        page: currentPage,
+        current: currentPage,
+        currentPage,
+        totalPages,
+        total: totalPages,
+      });
+      return `${pageText} / ${ofText}`;
+    }
+
+    return `${translations.pagination.page} ${currentPage} / ${translations.pagination.of} ${totalPages}`;
+  }, [currentPage, totalPages, translations.pagination]);
+
+  const rowTranslations = useMemo(() => {
+    return {
+      columns: {
+        ...translations.table.columns,
+        expiresAtHint: isAdmin
+          ? translations.table.columns.expiresAtHint || tUserMgmt("table.columns.expiresAtHint")
+          : undefined,
+      },
+      keyRow: translations.table.keyRow,
+      expand: translations.table.expand,
+      collapse: translations.table.collapse,
+      noKeys: translations.table.noKeys,
+      defaultGroup: translations.table.defaultGroup,
+      actions: translations.actions,
+      userStatus: {
+        disabled: tUserMgmt("keyStatus.disabled"),
+      },
+    };
+  }, [translations, isAdmin, tUserMgmt]);
+
+  const quickRenewTranslations = useMemo(() => {
+    if (translations.quickRenew) return translations.quickRenew;
+    // Fallback to translation keys
+    return {
+      title: tUserMgmt("quickRenew.title"),
+      description: tUserMgmt("quickRenew.description", { userName: "{userName}" }),
+      currentExpiry: tUserMgmt("quickRenew.currentExpiry"),
+      neverExpires: tUserMgmt("quickRenew.neverExpires"),
+      expired: tUserMgmt("quickRenew.expired"),
+      quickOptions: {
+        "7days": tUserMgmt("quickRenew.quickOptions.7days"),
+        "30days": tUserMgmt("quickRenew.quickOptions.30days"),
+        "90days": tUserMgmt("quickRenew.quickOptions.90days"),
+        "1year": tUserMgmt("quickRenew.quickOptions.1year"),
+      },
+      customDate: tUserMgmt("quickRenew.customDate"),
+      enableOnRenew: tUserMgmt("quickRenew.enableOnRenew"),
+      cancel: tUserMgmt("quickRenew.cancel"),
+      confirm: tUserMgmt("quickRenew.confirm"),
+      confirming: tUserMgmt("quickRenew.confirming"),
+    };
+  }, [translations.quickRenew, tUserMgmt]);
+
+  const editingUser = useMemo(() => {
+    if (!editingUserId) return null;
+    return users.find((u) => u.id === editingUserId) ?? null;
+  }, [users, editingUserId]);
+
+  useEffect(() => {
+    if (!editDialogOpen) return;
+    if (!editingUser) {
+      setEditDialogOpen(false);
+      setEditingUserId(null);
+      setScrollToKeyId(undefined);
+    }
+  }, [editDialogOpen, editingUser]);
+
+  const handleToggleUser = (userId: number) => {
+    setExpandedUsers((prev) => {
+      const next = new Map(prev);
+      next.set(userId, !(prev.get(userId) ?? false));
+      return next;
+    });
+  };
+
+  const handleToggleAll = () => {
+    const nextExpanded = !allExpanded;
+    setExpandedUsers(new Map(users.map((user) => [user.id, nextExpanded])));
+  };
+
+  const openEditDialog = (userId: number, keyId?: number) => {
+    setEditingUserId(userId);
+    setScrollToKeyId(keyId);
+    setEditDialogOpen(true);
+  };
+
+  const handleEditDialogOpenChange = (open: boolean) => {
+    setEditDialogOpen(open);
+    if (open) return;
+    setEditingUserId(null);
+    setScrollToKeyId(undefined);
+  };
+
+  // Quick renew handlers
+  const handleOpenQuickRenew = (user: UserDisplay) => {
+    setQuickRenewUser({
+      id: user.id,
+      name: user.name,
+      expiresAt: user.expiresAt ?? null,
+      isEnabled: user.isEnabled,
+    });
+    setQuickRenewOpen(true);
+  };
+
+  const handleQuickRenewConfirm = async (
+    userId: number,
+    expiresAt: Date,
+    enableUser?: boolean
+  ): Promise<{ ok: boolean }> => {
+    try {
+      const res = await renewUser(userId, { expiresAt: expiresAt.toISOString(), enableUser });
+      if (!res.ok) {
+        toast.error(res.error || tUserMgmt("quickRenew.failed"));
+        return { ok: false };
+      }
+      toast.success(tUserMgmt("quickRenew.success"));
+      router.refresh();
+      return { ok: true };
+    } catch (error) {
+      console.error("[QuickRenew] failed", error);
+      toast.error(tUserMgmt("quickRenew.failed"));
+      return { ok: false };
+    }
+  };
+
+  return (
+    <div className="space-y-3">
+      <div className="flex items-center justify-start">
+        <Button
+          type="button"
+          variant="outline"
+          size="sm"
+          onClick={handleToggleAll}
+          disabled={users.length === 0}
+        >
+          {allExpanded ? translations.table.collapse : translations.table.expand}
+        </Button>
+      </div>
+
+      <div className={cn("border border-border rounded-lg", "overflow-hidden")}>
+        <Table className="min-w-[980px]">
+          <TableHeader>
+            <TableRow>
+              <TableHead className="min-w-[260px]">
+                {translations.table.columns.username} / {translations.table.columns.note}
+              </TableHead>
+              <TableHead>{translations.table.columns.expiresAt}</TableHead>
+              <TableHead className="text-center">{translations.table.columns.limit5h}</TableHead>
+              <TableHead className="text-center">{translations.table.columns.limitDaily}</TableHead>
+              <TableHead className="text-center">
+                {translations.table.columns.limitWeekly}
+              </TableHead>
+              <TableHead className="text-center">
+                {translations.table.columns.limitMonthly}
+              </TableHead>
+              <TableHead className="text-center">{translations.table.columns.limitTotal}</TableHead>
+              <TableHead className="text-center">
+                {translations.table.columns.limitSessions}
+              </TableHead>
+              <TableHead className="text-center">{translations.actions.edit}</TableHead>
+            </TableRow>
+          </TableHeader>
+
+          <TableBody>
+            {paginatedUsers.length === 0 ? (
+              <TableRow>
+                <TableCell colSpan={TOTAL_COLUMNS} className="py-16">
+                  <div className="flex flex-col items-center justify-center text-center">
+                    <div className="mb-4 rounded-full bg-muted p-3">
+                      <Users className="h-6 w-6 text-muted-foreground" />
+                    </div>
+                    <h3 className="mb-2 text-lg font-medium">{tUserList("emptyState.title")}</h3>
+                    <p className="mb-4 max-w-sm text-sm text-muted-foreground">
+                      {tUserList("emptyState.description")}
+                    </p>
+                    {onCreateUser && (
+                      <Button onClick={onCreateUser}>{tUserList("emptyState.action")}</Button>
+                    )}
+                  </div>
+                </TableCell>
+              </TableRow>
+            ) : (
+              paginatedUsers.map((user) => (
+                <UserKeyTableRow
+                  key={user.id}
+                  user={user}
+                  expanded={expandedUsers.get(user.id) ?? false}
+                  onToggle={() => handleToggleUser(user.id)}
+                  onEditUser={(keyId) => openEditDialog(user.id, keyId)}
+                  onQuickRenew={isAdmin ? handleOpenQuickRenew : undefined}
+                  currentUser={currentUser}
+                  currencyCode={currencyCode}
+                  translations={rowTranslations}
+                  highlightKeyIds={highlightKeyIds}
+                />
+              ))
+            )}
+          </TableBody>
+        </Table>
+      </div>
+
+      {/* Pagination moved to bottom */}
+      <div className="flex items-center justify-center gap-2">
+        <Button
+          type="button"
+          variant="outline"
+          size="sm"
+          onClick={() => setCurrentPage((p) => Math.max(1, p - 1))}
+          disabled={users.length === 0 || currentPage <= 1}
+        >
+          {translations.pagination.previous}
+        </Button>
+        <span className="text-sm text-muted-foreground">{paginationText}</span>
+        <Button
+          type="button"
+          variant="outline"
+          size="sm"
+          onClick={() => setCurrentPage((p) => Math.min(totalPages, p + 1))}
+          disabled={users.length === 0 || currentPage >= totalPages}
+        >
+          {translations.pagination.next}
+        </Button>
+      </div>
+
+      {editingUser ? (
+        <UnifiedEditDialog
+          open={editDialogOpen}
+          onOpenChange={handleEditDialogOpenChange}
+          mode="edit"
+          user={editingUser}
+          scrollToKeyId={scrollToKeyId}
+          currentUser={currentUser}
+        />
+      ) : null}
+
+      {/* Quick renew dialog */}
+      <QuickRenewDialog
+        open={quickRenewOpen}
+        onOpenChange={setQuickRenewOpen}
+        user={quickRenewUser}
+        onConfirm={handleQuickRenewConfirm}
+        translations={quickRenewTranslations}
+      />
+    </div>
+  );
+}

+ 110 - 0
src/app/[locale]/dashboard/_components/user/user-onboarding-tour.tsx

@@ -0,0 +1,110 @@
+"use client";
+
+import { useTranslations } from "next-intl";
+import { useState } from "react";
+import { Button } from "@/components/ui/button";
+import {
+  Dialog,
+  DialogContent,
+  DialogDescription,
+  DialogFooter,
+  DialogHeader,
+  DialogTitle,
+} from "@/components/ui/dialog";
+import { cn } from "@/lib/utils";
+
+export interface UserOnboardingTourProps {
+  open: boolean;
+  onOpenChange: (open: boolean) => void;
+  onComplete: () => void;
+}
+
+const TOTAL_STEPS = 4;
+
+export function UserOnboardingTour({ open, onOpenChange, onComplete }: UserOnboardingTourProps) {
+  const t = useTranslations("dashboard.userManagement.onboarding");
+  const [currentStep, setCurrentStep] = useState(0);
+
+  const handleNext = () => {
+    if (currentStep < TOTAL_STEPS - 1) {
+      setCurrentStep((prev) => prev + 1);
+    } else {
+      handleComplete();
+    }
+  };
+
+  const handlePrev = () => {
+    if (currentStep > 0) {
+      setCurrentStep((prev) => prev - 1);
+    }
+  };
+
+  const handleSkip = () => {
+    setCurrentStep(0);
+    onOpenChange(false);
+    onComplete();
+  };
+
+  const handleComplete = () => {
+    setCurrentStep(0);
+    onOpenChange(false);
+    onComplete();
+  };
+
+  const stepKeys = ["welcome", "limits", "groups", "keyFeatures"] as const;
+  const currentStepKey = stepKeys[currentStep];
+
+  return (
+    <Dialog open={open} onOpenChange={onOpenChange}>
+      <DialogContent className="sm:max-w-[500px]">
+        <DialogHeader>
+          <DialogTitle>{t(`steps.${currentStepKey}.title`)}</DialogTitle>
+          <DialogDescription className="text-base leading-relaxed pt-2">
+            {t(`steps.${currentStepKey}.description`)}
+          </DialogDescription>
+        </DialogHeader>
+
+        {/* Step Indicator */}
+        <div className="flex items-center justify-center gap-2 py-4">
+          {stepKeys.map((_, index) => (
+            <div
+              key={index}
+              className={cn(
+                "h-2 w-2 rounded-full transition-colors",
+                index === currentStep
+                  ? "bg-primary"
+                  : index < currentStep
+                    ? "bg-primary/50"
+                    : "bg-muted"
+              )}
+            />
+          ))}
+        </div>
+        <div className="text-center text-sm text-muted-foreground">
+          {t("stepIndicator", { current: currentStep + 1, total: TOTAL_STEPS })}
+        </div>
+
+        <DialogFooter className="flex-col sm:flex-row gap-2 sm:gap-0">
+          <Button
+            type="button"
+            variant="ghost"
+            onClick={handleSkip}
+            className="order-3 sm:order-1 sm:mr-auto"
+          >
+            {t("skip")}
+          </Button>
+          <div className="flex gap-2 order-1 sm:order-2">
+            {currentStep > 0 && (
+              <Button type="button" variant="outline" onClick={handlePrev}>
+                {t("prev")}
+              </Button>
+            )}
+            <Button type="button" onClick={handleNext}>
+              {currentStep === TOTAL_STEPS - 1 ? t("finish") : t("next")}
+            </Button>
+          </div>
+        </DialogFooter>
+      </DialogContent>
+    </Dialog>
+  );
+}

+ 30 - 12
src/app/[locale]/dashboard/leaderboard/_components/leaderboard-table.tsx

@@ -57,34 +57,52 @@ export function LeaderboardTable<T>({
   const getRankBadge = (rank: number) => {
     if (rank === 1) {
       return (
-        <div className="flex items-center gap-2">
-          <Trophy className="h-5 w-5 text-yellow-500" />
-          <Badge variant="default" className="bg-yellow-500 hover:bg-yellow-600">
+        <div className="flex items-center gap-1.5">
+          <Trophy className="h-4 w-4 text-yellow-500" />
+          <Badge
+            variant="default"
+            className="bg-yellow-500 hover:bg-yellow-600 min-w-[32px] justify-center"
+          >
             #{rank}
           </Badge>
         </div>
       );
-    } else if (rank === 2) {
+    }
+    if (rank === 2) {
       return (
-        <div className="flex items-center gap-2">
-          <Medal className="h-5 w-5 text-gray-400" />
-          <Badge variant="secondary" className="bg-gray-400 hover:bg-gray-500 text-white">
+        <div className="flex items-center gap-1.5">
+          <Medal className="h-4 w-4 text-gray-400" />
+          <Badge
+            variant="secondary"
+            className="bg-gray-400 hover:bg-gray-500 text-white min-w-[32px] justify-center"
+          >
             #{rank}
           </Badge>
         </div>
       );
-    } else if (rank === 3) {
+    }
+    if (rank === 3) {
       return (
-        <div className="flex items-center gap-2">
-          <Award className="h-5 w-5 text-orange-600" />
-          <Badge variant="secondary" className="bg-orange-600 hover:bg-orange-700 text-white">
+        <div className="flex items-center gap-1.5">
+          <Award className="h-4 w-4 text-orange-600" />
+          <Badge
+            variant="secondary"
+            className="bg-orange-600 hover:bg-orange-700 text-white min-w-[32px] justify-center"
+          >
             #{rank}
           </Badge>
         </div>
       );
     }
 
-    return <div className="text-muted-foreground font-medium">#{rank}</div>;
+    return (
+      <div className="flex items-center gap-1.5">
+        <div className="h-4 w-4" />
+        <Badge variant="outline" className="min-w-[32px] justify-center">
+          #{rank}
+        </Badge>
+      </div>
+    );
   };
 
   return (

+ 89 - 61
src/app/[locale]/dashboard/logs/_components/usage-logs-filters.tsx

@@ -7,7 +7,7 @@ import { useTranslations } from "next-intl";
 import { useCallback, useEffect, useMemo, useState } from "react";
 import { toast } from "sonner";
 import { getKeys } from "@/actions/keys";
-import { exportUsageLogs, getFilterOptions } from "@/actions/usage-logs";
+import { exportUsageLogs } from "@/actions/usage-logs";
 import { Button } from "@/components/ui/button";
 import { Input } from "@/components/ui/input";
 import { Label } from "@/components/ui/label";
@@ -21,8 +21,16 @@ import {
 import type { Key } from "@/types/key";
 import type { ProviderDisplay } from "@/types/provider";
 import type { UserDisplay } from "@/types/user";
+import {
+  useLazyEndpoints,
+  useLazyModels,
+  useLazyStatusCodes,
+} from "../_hooks/use-lazy-filter-options";
 import { LogsDateRangePicker } from "./logs-date-range-picker";
 
+// 硬编码常用状态码(首次渲染时显示,无需等待加载)
+const COMMON_STATUS_CODES: number[] = [200, 400, 401, 429, 500];
+
 interface UsageLogsFiltersProps {
   isAdmin: boolean;
   users: UserDisplay[];
@@ -56,53 +64,53 @@ export function UsageLogsFilters({
   onReset,
 }: UsageLogsFiltersProps) {
   const t = useTranslations("dashboard");
-  const [models, setModels] = useState<string[]>([]);
-  const [statusCodes, setStatusCodes] = useState<number[]>([]);
-  const [endpoints, setEndpoints] = useState<string[]>([]);
-  const [isEndpointLoading, setIsEndpointLoading] = useState(false);
-  const [endpointError, setEndpointError] = useState<string | null>(null);
+
+  // 惰性加载 hooks - 下拉展开时才加载数据
+  const {
+    data: models,
+    isLoading: isModelsLoading,
+    onOpenChange: onModelsOpenChange,
+  } = useLazyModels();
+
+  const {
+    data: dynamicStatusCodes,
+    isLoading: isStatusCodesLoading,
+    onOpenChange: onStatusCodesOpenChange,
+  } = useLazyStatusCodes();
+
+  const {
+    data: endpoints,
+    isLoading: isEndpointsLoading,
+    onOpenChange: onEndpointsOpenChange,
+  } = useLazyEndpoints();
+
+  // 合并硬编码和动态状态码(去重)
+  const allStatusCodes = useMemo(() => {
+    const dynamicOnly = dynamicStatusCodes.filter((code) => !COMMON_STATUS_CODES.includes(code));
+    return dynamicOnly;
+  }, [dynamicStatusCodes]);
+
   const [keys, setKeys] = useState<Key[]>(initialKeys);
   const [localFilters, setLocalFilters] = useState(filters);
   const [isExporting, setIsExporting] = useState(false);
 
-  // 加载筛选器选项
+  // 管理员用户首次加载时,如果 URL 中有 userId 参数,需要加载该用户的 keys
+  // biome-ignore lint/correctness/useExhaustiveDependencies: 故意仅在组件挂载时执行一次
   useEffect(() => {
-    const loadOptions = async () => {
-      setIsEndpointLoading(true);
-      setEndpointError(null);
-
-      try {
-        // 使用带缓存的 getFilterOptions,避免每次挂载都执行 3 次 DISTINCT 全表扫描
-        const optionsResult = await getFilterOptions();
-
-        if (optionsResult.ok && optionsResult.data) {
-          setModels(optionsResult.data.models);
-          setStatusCodes(optionsResult.data.statusCodes);
-          setEndpoints(optionsResult.data.endpoints);
-        } else {
-          setEndpoints([]);
-          setEndpointError(!optionsResult.ok ? optionsResult.error : t("logs.error.loadFailed"));
-        }
-      } catch (error) {
-        console.error("Failed to load filter options:", error);
-        setEndpoints([]);
-        setEndpointError(t("logs.error.loadFailed"));
-      } finally {
-        setIsEndpointLoading(false);
-      }
-
-      // 管理员:如果选择了用户,加载该用户的 keys
-      // 非管理员:已经有 initialKeys,不需要额外加载
-      if (isAdmin && localFilters.userId) {
-        const keysResult = await getKeys(localFilters.userId);
-        if (keysResult.ok && keysResult.data) {
-          setKeys(keysResult.data);
+    const loadInitialKeys = async () => {
+      if (isAdmin && filters.userId && initialKeys.length === 0) {
+        try {
+          const keysResult = await getKeys(filters.userId);
+          if (keysResult.ok && keysResult.data) {
+            setKeys(keysResult.data);
+          }
+        } catch (error) {
+          console.error("Failed to load initial keys:", error);
         }
       }
     };
-
-    loadOptions();
-  }, [isAdmin, localFilters.userId, t]);
+    loadInitialKeys();
+  }, []);
 
   // 处理用户选择变更
   const handleUserChange = async (userId: string) => {
@@ -112,9 +120,14 @@ export function UsageLogsFilters({
 
     // 加载该用户的 keys
     if (newUserId) {
-      const keysResult = await getKeys(newUserId);
-      if (keysResult.ok && keysResult.data) {
-        setKeys(keysResult.data);
+      try {
+        const keysResult = await getKeys(newUserId);
+        if (keysResult.ok && keysResult.data) {
+          setKeys(keysResult.data);
+        }
+      } catch (error) {
+        console.error("Failed to load keys:", error);
+        toast.error(t("logs.error.loadKeysFailed"));
       }
     } else {
       setKeys([]);
@@ -316,20 +329,31 @@ export function UsageLogsFilters({
         <div className="space-y-2 lg:col-span-4">
           <Label>{t("logs.filters.model")}</Label>
           <Select
-            value={localFilters.model || ""}
+            value={localFilters.model || "all"}
             onValueChange={(value: string) =>
-              setLocalFilters({ ...localFilters, model: value || undefined })
+              setLocalFilters({ ...localFilters, model: value === "all" ? undefined : value })
             }
+            onOpenChange={onModelsOpenChange}
           >
             <SelectTrigger>
-              <SelectValue placeholder={t("logs.filters.allModels")} />
+              <SelectValue
+                placeholder={
+                  isModelsLoading ? t("logs.stats.loading") : t("logs.filters.allModels")
+                }
+              />
             </SelectTrigger>
             <SelectContent>
+              <SelectItem value="all">{t("logs.filters.allModels")}</SelectItem>
               {models.map((model) => (
                 <SelectItem key={model} value={model}>
                   {model}
                 </SelectItem>
               ))}
+              {isModelsLoading && (
+                <div className="p-2 text-center text-muted-foreground text-sm">
+                  {t("logs.stats.loading")}
+                </div>
+              )}
             </SelectContent>
           </Select>
         </div>
@@ -342,16 +366,12 @@ export function UsageLogsFilters({
             onValueChange={(value: string) =>
               setLocalFilters({ ...localFilters, endpoint: value === "all" ? undefined : value })
             }
-            disabled={isEndpointLoading}
+            onOpenChange={onEndpointsOpenChange}
           >
             <SelectTrigger>
               <SelectValue
                 placeholder={
-                  endpointError
-                    ? endpointError
-                    : isEndpointLoading
-                      ? t("logs.stats.loading")
-                      : t("logs.filters.allEndpoints")
+                  isEndpointsLoading ? t("logs.stats.loading") : t("logs.filters.allEndpoints")
                 }
               />
             </SelectTrigger>
@@ -362,9 +382,13 @@ export function UsageLogsFilters({
                   {endpoint}
                 </SelectItem>
               ))}
+              {isEndpointsLoading && (
+                <div className="p-2 text-center text-muted-foreground text-sm">
+                  {t("logs.stats.loading")}
+                </div>
+              )}
             </SelectContent>
           </Select>
-          {endpointError && <p className="text-xs text-destructive">{endpointError}</p>}
         </div>
 
         {/* 状态码选择 */}
@@ -381,6 +405,7 @@ export function UsageLogsFilters({
                 excludeStatusCode200: value === "!200",
               })
             }
+            onOpenChange={onStatusCodesOpenChange}
           >
             <SelectTrigger>
               <SelectValue placeholder={t("logs.filters.allStatusCodes")} />
@@ -392,13 +417,16 @@ export function UsageLogsFilters({
               <SelectItem value="401">{t("logs.statusCodes.401")}</SelectItem>
               <SelectItem value="429">{t("logs.statusCodes.429")}</SelectItem>
               <SelectItem value="500">{t("logs.statusCodes.500")}</SelectItem>
-              {statusCodes
-                .filter((code) => ![200, 400, 401, 429, 500].includes(code))
-                .map((code) => (
-                  <SelectItem key={code} value={code.toString()}>
-                    {code}
-                  </SelectItem>
-                ))}
+              {allStatusCodes.map((code) => (
+                <SelectItem key={code} value={code.toString()}>
+                  {code}
+                </SelectItem>
+              ))}
+              {isStatusCodesLoading && (
+                <div className="p-2 text-center text-muted-foreground text-sm">
+                  {t("logs.stats.loading")}
+                </div>
+              )}
             </SelectContent>
           </Select>
         </div>
@@ -429,7 +457,7 @@ export function UsageLogsFilters({
           {t("logs.filters.reset")}
         </Button>
         <Button variant="outline" onClick={handleExport} disabled={isExporting}>
-          <Download className="mr-2 h-4 w-4" />
+          <Download className="mr-2 h-4 w-4" aria-hidden="true" />
           {isExporting ? t("logs.filters.exporting") : t("logs.filters.export")}
         </Button>
       </div>

+ 230 - 0
src/app/[locale]/dashboard/logs/_components/usage-logs-view-virtualized.tsx

@@ -0,0 +1,230 @@
+"use client";
+
+import { QueryClient, QueryClientProvider, useQueryClient } from "@tanstack/react-query";
+import { Pause, Play, RefreshCw } from "lucide-react";
+import { useRouter, useSearchParams } from "next/navigation";
+import { useTranslations } from "next-intl";
+import { useCallback, useEffect, useMemo, useRef, useState } from "react";
+import { Button } from "@/components/ui/button";
+import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
+import type { CurrencyCode } from "@/lib/utils/currency";
+import type { Key } from "@/types/key";
+import type { ProviderDisplay } from "@/types/provider";
+import type { BillingModelSource } from "@/types/system-config";
+import type { UserDisplay } from "@/types/user";
+import { UsageLogsFilters } from "./usage-logs-filters";
+import { UsageLogsStatsPanel } from "./usage-logs-stats-panel";
+import { VirtualizedLogsTable, type VirtualizedLogsTableFilters } from "./virtualized-logs-table";
+
+// Create a stable QueryClient instance
+const queryClient = new QueryClient({
+  defaultOptions: {
+    queries: {
+      refetchOnWindowFocus: false,
+      staleTime: 30000,
+    },
+  },
+});
+
+interface UsageLogsViewVirtualizedProps {
+  isAdmin: boolean;
+  users: UserDisplay[];
+  providers: ProviderDisplay[];
+  initialKeys: Key[];
+  searchParams: { [key: string]: string | string[] | undefined };
+  currencyCode?: CurrencyCode;
+  billingModelSource?: BillingModelSource;
+}
+
+function UsageLogsViewContent({
+  isAdmin,
+  users,
+  providers,
+  initialKeys,
+  searchParams,
+  currencyCode = "USD",
+  billingModelSource = "original",
+}: UsageLogsViewVirtualizedProps) {
+  const t = useTranslations("dashboard");
+  const router = useRouter();
+  const _params = useSearchParams();
+  const queryClientInstance = useQueryClient();
+  const [isAutoRefresh, setIsAutoRefresh] = useState(true);
+  const [isManualRefreshing, setIsManualRefreshing] = useState(false);
+  const refreshTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
+  const paramsKey = _params.toString();
+
+  // Parse filters from URL with stable reference
+  const filters = useMemo<VirtualizedLogsTableFilters & { page?: number }>(
+    () => ({
+      userId: searchParams.userId ? parseInt(searchParams.userId as string, 10) : undefined,
+      keyId: searchParams.keyId ? parseInt(searchParams.keyId as string, 10) : undefined,
+      providerId: searchParams.providerId
+        ? parseInt(searchParams.providerId as string, 10)
+        : undefined,
+      startTime: searchParams.startTime
+        ? parseInt(searchParams.startTime as string, 10)
+        : undefined,
+      endTime: searchParams.endTime ? parseInt(searchParams.endTime as string, 10) : undefined,
+      statusCode:
+        searchParams.statusCode && searchParams.statusCode !== "!200"
+          ? parseInt(searchParams.statusCode as string, 10)
+          : undefined,
+      excludeStatusCode200: searchParams.statusCode === "!200",
+      model: searchParams.model as string | undefined,
+      endpoint: searchParams.endpoint as string | undefined,
+      minRetryCount: searchParams.minRetry
+        ? parseInt(searchParams.minRetry as string, 10)
+        : undefined,
+    }),
+    [
+      searchParams.userId,
+      searchParams.keyId,
+      searchParams.providerId,
+      searchParams.startTime,
+      searchParams.endTime,
+      searchParams.statusCode,
+      searchParams.model,
+      searchParams.endpoint,
+      searchParams.minRetry,
+    ]
+  );
+
+  // Manual refresh handler
+  const handleManualRefresh = useCallback(async () => {
+    setIsManualRefreshing(true);
+    await queryClientInstance.invalidateQueries({ queryKey: ["usage-logs-batch"] });
+    if (refreshTimeoutRef.current) {
+      clearTimeout(refreshTimeoutRef.current);
+    }
+    refreshTimeoutRef.current = setTimeout(() => setIsManualRefreshing(false), 500);
+  }, [queryClientInstance]);
+
+  // Handle filter changes
+  const handleFilterChange = (newFilters: Omit<typeof filters, "page">) => {
+    const query = new URLSearchParams();
+
+    if (newFilters.userId) query.set("userId", newFilters.userId.toString());
+    if (newFilters.keyId) query.set("keyId", newFilters.keyId.toString());
+    if (newFilters.providerId) query.set("providerId", newFilters.providerId.toString());
+    if (newFilters.startTime) query.set("startTime", newFilters.startTime.toString());
+    if (newFilters.endTime) query.set("endTime", newFilters.endTime.toString());
+    if (newFilters.excludeStatusCode200) {
+      query.set("statusCode", "!200");
+    } else if (newFilters.statusCode !== undefined) {
+      query.set("statusCode", newFilters.statusCode.toString());
+    }
+    if (newFilters.model) query.set("model", newFilters.model);
+    if (newFilters.endpoint) query.set("endpoint", newFilters.endpoint);
+    if (newFilters.minRetryCount !== undefined) {
+      query.set("minRetry", newFilters.minRetryCount.toString());
+    }
+
+    router.push(`/dashboard/logs?${query.toString()}`);
+  };
+
+  // Invalidate query when URL changes (e.g., browser back/forward navigation)
+  useEffect(() => {
+    queryClientInstance.invalidateQueries({ queryKey: ["usage-logs-batch"] });
+  }, [paramsKey, queryClientInstance]);
+
+  useEffect(() => {
+    return () => {
+      if (refreshTimeoutRef.current) {
+        clearTimeout(refreshTimeoutRef.current);
+      }
+    };
+  }, []);
+
+  return (
+    <div className="space-y-6">
+      {/* Collapsible stats panel */}
+      <UsageLogsStatsPanel
+        filters={{
+          userId: filters.userId,
+          keyId: filters.keyId,
+          providerId: filters.providerId,
+          startTime: filters.startTime,
+          endTime: filters.endTime,
+          statusCode: filters.statusCode,
+          excludeStatusCode200: filters.excludeStatusCode200,
+          model: filters.model,
+          endpoint: filters.endpoint,
+          minRetryCount: filters.minRetryCount,
+        }}
+        currencyCode={currencyCode}
+      />
+
+      {/* Filters */}
+      <Card>
+        <CardHeader>
+          <CardTitle>{t("title.filterCriteria")}</CardTitle>
+        </CardHeader>
+        <CardContent>
+          <UsageLogsFilters
+            isAdmin={isAdmin}
+            users={users}
+            providers={providers}
+            initialKeys={initialKeys}
+            filters={filters}
+            onChange={handleFilterChange}
+            onReset={() => router.push("/dashboard/logs")}
+          />
+        </CardContent>
+      </Card>
+
+      {/* Data table with virtual scrolling */}
+      <Card>
+        <CardHeader>
+          <div className="flex items-center justify-between">
+            <CardTitle>{t("title.usageLogs")}</CardTitle>
+            <div className="flex items-center gap-2">
+              {/* Manual refresh button */}
+              <Button variant="outline" size="sm" onClick={handleManualRefresh} className="gap-2">
+                <RefreshCw className={`h-4 w-4 ${isManualRefreshing ? "animate-spin" : ""}`} />
+                {t("logs.actions.refresh")}
+              </Button>
+
+              {/* Auto refresh toggle */}
+              <Button
+                variant={isAutoRefresh ? "default" : "outline"}
+                size="sm"
+                onClick={() => setIsAutoRefresh(!isAutoRefresh)}
+                className="gap-2"
+              >
+                {isAutoRefresh ? (
+                  <>
+                    <Pause className="h-4 w-4" />
+                    {t("logs.actions.stopAutoRefresh")}
+                  </>
+                ) : (
+                  <>
+                    <Play className="h-4 w-4" />
+                    {t("logs.actions.startAutoRefresh")}
+                  </>
+                )}
+              </Button>
+            </div>
+          </div>
+        </CardHeader>
+        <CardContent className="px-0">
+          <VirtualizedLogsTable
+            filters={filters}
+            currencyCode={currencyCode}
+            billingModelSource={billingModelSource}
+            autoRefreshEnabled={isAutoRefresh}
+            autoRefreshIntervalMs={5000}
+          />
+        </CardContent>
+      </Card>
+    </div>
+  );
+}
+
+export function UsageLogsViewVirtualized(props: UsageLogsViewVirtualizedProps) {
+  return (
+    <QueryClientProvider client={queryClient}>
+      <UsageLogsViewContent {...props} />
+    </QueryClientProvider>
+  );
+}

+ 512 - 0
src/app/[locale]/dashboard/logs/_components/virtualized-logs-table.tsx

@@ -0,0 +1,512 @@
+"use client";
+
+import { useInfiniteQuery } from "@tanstack/react-query";
+import { useVirtualizer } from "@tanstack/react-virtual";
+import { ArrowUp, Loader2 } from "lucide-react";
+import { useTranslations } from "next-intl";
+import { useCallback, useEffect, useRef, useState } from "react";
+import { getUsageLogsBatch } from "@/actions/usage-logs";
+import { Badge } from "@/components/ui/badge";
+import { Button } from "@/components/ui/button";
+import { RelativeTime } from "@/components/ui/relative-time";
+import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
+import { cn, formatTokenAmount } from "@/lib/utils";
+import type { CurrencyCode } from "@/lib/utils/currency";
+import { formatCurrency } from "@/lib/utils/currency";
+import { formatProviderSummary } from "@/lib/utils/provider-chain-formatter";
+import type { BillingModelSource } from "@/types/system-config";
+import { ErrorDetailsDialog } from "./error-details-dialog";
+import { ModelDisplayWithRedirect } from "./model-display-with-redirect";
+import { ProviderChainPopover } from "./provider-chain-popover";
+
+const NON_BILLING_ENDPOINT = "/v1/messages/count_tokens";
+const BATCH_SIZE = 50;
+const ROW_HEIGHT = 52; // Estimated row height in pixels
+
+/**
+ * Format duration
+ */
+function formatDuration(durationMs: number | null): string {
+  if (!durationMs) return "-";
+  if (durationMs >= 1000) {
+    return `${(Number(durationMs) / 1000).toFixed(2)}s`;
+  }
+  return `${durationMs}ms`;
+}
+
+export interface VirtualizedLogsTableFilters {
+  userId?: number;
+  keyId?: number;
+  providerId?: number;
+  startTime?: number;
+  endTime?: number;
+  statusCode?: number;
+  excludeStatusCode200?: boolean;
+  model?: string;
+  endpoint?: string;
+  minRetryCount?: number;
+}
+
+interface VirtualizedLogsTableProps {
+  filters: VirtualizedLogsTableFilters;
+  currencyCode?: CurrencyCode;
+  billingModelSource?: BillingModelSource;
+  autoRefreshEnabled?: boolean;
+  autoRefreshIntervalMs?: number;
+}
+
+export function VirtualizedLogsTable({
+  filters,
+  currencyCode = "USD",
+  billingModelSource = "original",
+  autoRefreshEnabled = true,
+  autoRefreshIntervalMs = 5000,
+}: VirtualizedLogsTableProps) {
+  const t = useTranslations("dashboard");
+  const tChain = useTranslations("provider-chain");
+  const parentRef = useRef<HTMLDivElement>(null);
+  const [showScrollToTop, setShowScrollToTop] = useState(false);
+
+  // Dialog state for model redirect click
+  const [dialogState, setDialogState] = useState<{
+    logId: number | null;
+    scrollToRedirect: boolean;
+  }>({ logId: null, scrollToRedirect: false });
+
+  // Infinite query with cursor-based pagination
+  const { data, fetchNextPage, hasNextPage, isFetchingNextPage, isLoading, isError, error } =
+    useInfiniteQuery({
+      queryKey: ["usage-logs-batch", filters],
+      queryFn: async ({ pageParam }) => {
+        const result = await getUsageLogsBatch({
+          ...filters,
+          cursor: pageParam,
+          limit: BATCH_SIZE,
+        });
+        if (!result.ok) {
+          throw new Error(result.error);
+        }
+        return result.data;
+      },
+      getNextPageParam: (lastPage) => lastPage.nextCursor ?? undefined,
+      initialPageParam: undefined as { createdAt: string; id: number } | undefined,
+      staleTime: 30000, // 30 seconds
+      refetchOnWindowFocus: false,
+      refetchInterval: autoRefreshEnabled ? autoRefreshIntervalMs : false,
+    });
+
+  // Flatten all pages into a single array
+  const allLogs = data?.pages.flatMap((page) => page.logs) ?? [];
+
+  // Virtual list setup
+  const rowVirtualizer = useVirtualizer({
+    count: hasNextPage ? allLogs.length + 1 : allLogs.length,
+    getScrollElement: () => parentRef.current,
+    estimateSize: () => ROW_HEIGHT,
+    overscan: 10,
+  });
+
+  const virtualItems = rowVirtualizer.getVirtualItems();
+  const lastItemIndex = virtualItems[virtualItems.length - 1]?.index ?? -1;
+
+  // Auto-fetch next page when scrolling near the bottom
+  useEffect(() => {
+    // If the last visible item is a loading row or near the end, fetch more
+    if (lastItemIndex >= allLogs.length - 5 && hasNextPage && !isFetchingNextPage) {
+      fetchNextPage();
+    }
+  }, [lastItemIndex, hasNextPage, isFetchingNextPage, allLogs.length, fetchNextPage]);
+
+  // Track scroll position for "scroll to top" button
+  const handleScroll = useCallback(() => {
+    if (parentRef.current) {
+      setShowScrollToTop(parentRef.current.scrollTop > 500);
+    }
+  }, []);
+
+  // Scroll to top handler
+  const scrollToTop = useCallback(() => {
+    parentRef.current?.scrollTo({ top: 0, behavior: "smooth" });
+  }, []);
+
+  // Reset scroll when filters change
+  // biome-ignore lint/correctness/useExhaustiveDependencies: filters is intentionally used to trigger scroll reset on filter change
+  useEffect(() => {
+    if (parentRef.current) {
+      parentRef.current.scrollTop = 0;
+    }
+  }, [filters]);
+
+  if (isLoading) {
+    return (
+      <div className="flex items-center justify-center py-12">
+        <Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
+        <span className="ml-2 text-muted-foreground">{t("logs.stats.loading")}</span>
+      </div>
+    );
+  }
+
+  if (isError) {
+    return (
+      <div className="text-center py-8 text-destructive">
+        {error instanceof Error ? error.message : t("logs.error.loadFailed")}
+      </div>
+    );
+  }
+
+  if (allLogs.length === 0) {
+    return <div className="text-center py-8 text-muted-foreground">{t("logs.table.noData")}</div>;
+  }
+
+  return (
+    <div className="space-y-4">
+      {/* Status bar */}
+      <div className="flex items-center justify-between text-sm text-muted-foreground">
+        <span>{t("logs.table.loadedCount", { count: allLogs.length })}</span>
+        {isFetchingNextPage && (
+          <span className="flex items-center gap-2">
+            <Loader2 className="h-3 w-3 animate-spin" />
+            {t("logs.table.loadingMore")}
+          </span>
+        )}
+        {!hasNextPage && allLogs.length > 0 && <span>{t("logs.table.noMoreData")}</span>}
+      </div>
+
+      {/* Table with virtual scrolling */}
+      <div className="rounded-md border overflow-hidden">
+        {/* Fixed header */}
+        <div className="bg-muted/50 border-b">
+          <div className="flex items-center h-10 text-sm font-medium text-muted-foreground">
+            <div className="w-[70px] min-w-[70px] shrink-0 pl-2">{t("logs.columns.time")}</div>
+            <div className="flex-[0.5] min-w-[60px] px-1">{t("logs.columns.user")}</div>
+            <div className="flex-[0.5] min-w-[60px] px-1">{t("logs.columns.key")}</div>
+            <div className="flex-[2] min-w-[100px] px-1">{t("logs.columns.provider")}</div>
+            <div className="flex-[1.5] min-w-[80px] px-1">{t("logs.columns.model")}</div>
+            <div className="w-[55px] min-w-[55px] shrink-0 text-right px-1">
+              {t("logs.columns.inputTokens")}
+            </div>
+            <div className="w-[55px] min-w-[55px] shrink-0 text-right px-1">
+              {t("logs.columns.outputTokens")}
+            </div>
+            <div className="flex-1 min-w-[70px] text-right px-1">
+              {t("logs.columns.cacheWrite")}
+            </div>
+            <div className="flex-[0.8] min-w-[55px] text-right px-1">
+              {t("logs.columns.cacheRead")}
+            </div>
+            <div className="flex-1 min-w-[70px] text-right px-1">{t("logs.columns.cost")}</div>
+            <div className="w-[55px] min-w-[55px] shrink-0 text-right px-1">
+              {t("logs.columns.duration")}
+            </div>
+            <div className="w-[65px] min-w-[65px] shrink-0 pr-2">{t("logs.columns.status")}</div>
+          </div>
+        </div>
+
+        {/* Virtualized body */}
+        <div ref={parentRef} className="h-[600px] overflow-auto" onScroll={handleScroll}>
+          <div
+            style={{
+              height: `${rowVirtualizer.getTotalSize()}px`,
+              width: "100%",
+              position: "relative",
+            }}
+          >
+            {virtualItems.map((virtualRow) => {
+              const isLoaderRow = virtualRow.index >= allLogs.length;
+              const log = allLogs[virtualRow.index];
+
+              if (isLoaderRow) {
+                return (
+                  <div
+                    key="loader"
+                    style={{
+                      position: "absolute",
+                      top: 0,
+                      left: 0,
+                      width: "100%",
+                      height: `${virtualRow.size}px`,
+                      transform: `translateY(${virtualRow.start}px)`,
+                    }}
+                    className="flex items-center justify-center"
+                  >
+                    <Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
+                  </div>
+                );
+              }
+
+              const isNonBilling = log.endpoint === NON_BILLING_ENDPOINT;
+
+              return (
+                <div
+                  key={log.id}
+                  style={{
+                    position: "absolute",
+                    top: 0,
+                    left: 0,
+                    width: "100%",
+                    height: `${virtualRow.size}px`,
+                    transform: `translateY(${virtualRow.start}px)`,
+                  }}
+                  className={cn(
+                    "flex items-center text-sm border-b hover:bg-muted/50",
+                    isNonBilling ? "bg-muted/60 text-muted-foreground dark:bg-muted/20" : ""
+                  )}
+                >
+                  {/* Time */}
+                  <div className="w-[70px] min-w-[70px] shrink-0 font-mono text-xs truncate pl-2">
+                    <RelativeTime date={log.createdAt} fallback="-" />
+                  </div>
+
+                  {/* User */}
+                  <div className="flex-[0.5] min-w-[60px] truncate px-1" title={log.userName}>
+                    {log.userName}
+                  </div>
+
+                  {/* Key */}
+                  <div
+                    className="flex-[0.5] min-w-[60px] font-mono text-xs truncate px-1"
+                    title={log.keyName}
+                  >
+                    {log.keyName}
+                  </div>
+
+                  {/* Provider */}
+                  <div className="flex-[2] min-w-[100px] px-1">
+                    {log.blockedBy ? (
+                      <span className="inline-flex items-center gap-1 rounded-md bg-orange-100 dark:bg-orange-950 px-2 py-1 text-xs font-medium text-orange-700 dark:text-orange-300">
+                        <span className="h-1.5 w-1.5 rounded-full bg-orange-600 dark:bg-orange-400" />
+                        {t("logs.table.blocked")}
+                      </span>
+                    ) : (
+                      <div className="flex items-start gap-2">
+                        <div className="flex flex-col items-start gap-0.5 min-w-0 flex-1">
+                          {log.providerChain && log.providerChain.length > 0 ? (
+                            <>
+                              <ProviderChainPopover
+                                chain={log.providerChain}
+                                finalProvider={
+                                  log.providerChain[log.providerChain.length - 1].name ||
+                                  log.providerName ||
+                                  tChain("circuit.unknown")
+                                }
+                              />
+                              {formatProviderSummary(log.providerChain, tChain) && (
+                                <TooltipProvider>
+                                  <Tooltip delayDuration={300}>
+                                    <TooltipTrigger asChild>
+                                      <span className="text-xs text-muted-foreground cursor-help truncate max-w-[180px] block text-left">
+                                        {formatProviderSummary(log.providerChain, tChain)}
+                                      </span>
+                                    </TooltipTrigger>
+                                    <TooltipContent
+                                      side="bottom"
+                                      align="start"
+                                      className="max-w-[500px]"
+                                    >
+                                      <p className="text-xs whitespace-normal break-words font-mono">
+                                        {formatProviderSummary(log.providerChain, tChain)}
+                                      </p>
+                                    </TooltipContent>
+                                  </Tooltip>
+                                </TooltipProvider>
+                              )}
+                            </>
+                          ) : (
+                            <span className="truncate">{log.providerName || "-"}</span>
+                          )}
+                        </div>
+                        {/* Cost multiplier badge */}
+                        {(() => {
+                          const successfulProvider =
+                            log.providerChain && log.providerChain.length > 0
+                              ? [...log.providerChain]
+                                  .reverse()
+                                  .find(
+                                    (item) =>
+                                      item.reason === "request_success" ||
+                                      item.reason === "retry_success"
+                                  )
+                              : null;
+                          const actualCostMultiplier =
+                            successfulProvider?.costMultiplier ?? log.costMultiplier;
+                          return actualCostMultiplier &&
+                            parseFloat(String(actualCostMultiplier)) !== 1.0 ? (
+                            <Badge
+                              variant="outline"
+                              className={
+                                parseFloat(String(actualCostMultiplier)) > 1.0
+                                  ? "text-xs bg-orange-50 text-orange-700 border-orange-200 dark:bg-orange-950/30 dark:text-orange-300 dark:border-orange-800 shrink-0"
+                                  : "text-xs bg-green-50 text-green-700 border-green-200 dark:bg-green-950/30 dark:text-green-300 dark:border-green-800 shrink-0"
+                              }
+                            >
+                              x{parseFloat(String(actualCostMultiplier)).toFixed(2)}
+                            </Badge>
+                          ) : null;
+                        })()}
+                      </div>
+                    )}
+                  </div>
+
+                  {/* Model */}
+                  <div className="flex-[1.5] min-w-[80px] font-mono text-xs px-1">
+                    <TooltipProvider>
+                      <Tooltip>
+                        <TooltipTrigger asChild>
+                          <div className="flex items-center gap-1 min-w-0 cursor-help truncate">
+                            <ModelDisplayWithRedirect
+                              originalModel={log.originalModel}
+                              currentModel={log.model}
+                              billingModelSource={billingModelSource}
+                              onRedirectClick={() =>
+                                setDialogState({ logId: log.id, scrollToRedirect: true })
+                              }
+                            />
+                          </div>
+                        </TooltipTrigger>
+                        <TooltipContent>
+                          <p className="text-xs">{log.originalModel || log.model || "-"}</p>
+                        </TooltipContent>
+                      </Tooltip>
+                    </TooltipProvider>
+                  </div>
+
+                  {/* Input Tokens */}
+                  <div className="w-[55px] min-w-[55px] shrink-0 text-right font-mono text-xs px-1">
+                    {formatTokenAmount(log.inputTokens)}
+                  </div>
+
+                  {/* Output Tokens */}
+                  <div className="w-[55px] min-w-[55px] shrink-0 text-right font-mono text-xs px-1">
+                    {formatTokenAmount(log.outputTokens)}
+                  </div>
+
+                  {/* Cache Write */}
+                  <div className="flex-1 min-w-[70px] text-right font-mono text-xs px-1">
+                    <TooltipProvider>
+                      <Tooltip delayDuration={250}>
+                        <TooltipTrigger asChild>
+                          <div className="flex items-center justify-end gap-1 cursor-help">
+                            <span>{formatTokenAmount(log.cacheCreationInputTokens)}</span>
+                            {log.cacheTtlApplied ? (
+                              <Badge variant="outline" className="text-[10px] leading-tight px-1">
+                                {log.cacheTtlApplied}
+                              </Badge>
+                            ) : null}
+                          </div>
+                        </TooltipTrigger>
+                        <TooltipContent align="end" className="text-xs space-y-1">
+                          <div>5m: {formatTokenAmount(log.cacheCreation5mInputTokens)}</div>
+                          <div>1h: {formatTokenAmount(log.cacheCreation1hInputTokens)}</div>
+                        </TooltipContent>
+                      </Tooltip>
+                    </TooltipProvider>
+                  </div>
+
+                  {/* Cache Read */}
+                  <div className="flex-[0.8] min-w-[55px] text-right font-mono text-xs px-1">
+                    {formatTokenAmount(log.cacheReadInputTokens)}
+                  </div>
+
+                  {/* Cost */}
+                  <div className="flex-1 min-w-[70px] text-right font-mono text-xs px-1">
+                    {isNonBilling ? (
+                      "-"
+                    ) : log.costUsd ? (
+                      <TooltipProvider>
+                        <Tooltip delayDuration={250}>
+                          <TooltipTrigger asChild>
+                            <span className="cursor-help inline-flex items-center gap-1">
+                              {formatCurrency(log.costUsd, currencyCode, 6)}
+                              {log.context1mApplied && (
+                                <Badge
+                                  variant="outline"
+                                  className="text-[10px] leading-tight px-1 bg-purple-50 text-purple-700 border-purple-200 dark:bg-purple-950/30 dark:text-purple-300 dark:border-purple-800"
+                                >
+                                  1M
+                                </Badge>
+                              )}
+                            </span>
+                          </TooltipTrigger>
+                          <TooltipContent align="end" className="text-xs space-y-1 max-w-[300px]">
+                            {log.context1mApplied && (
+                              <div className="text-purple-600 dark:text-purple-400 font-medium">
+                                {t("logs.billingDetails.context1m")}
+                              </div>
+                            )}
+                            <div>
+                              {t("logs.billingDetails.input")}: {formatTokenAmount(log.inputTokens)}{" "}
+                              tokens
+                            </div>
+                            <div>
+                              {t("logs.billingDetails.output")}:{" "}
+                              {formatTokenAmount(log.outputTokens)} tokens
+                            </div>
+                          </TooltipContent>
+                        </Tooltip>
+                      </TooltipProvider>
+                    ) : (
+                      "-"
+                    )}
+                  </div>
+
+                  {/* Duration */}
+                  <div className="w-[55px] min-w-[55px] shrink-0 text-right font-mono text-xs px-1">
+                    {formatDuration(log.durationMs)}
+                  </div>
+
+                  {/* Status */}
+                  <div className="w-[65px] min-w-[65px] shrink-0 pr-2">
+                    <ErrorDetailsDialog
+                      statusCode={log.statusCode}
+                      errorMessage={log.errorMessage}
+                      providerChain={log.providerChain}
+                      sessionId={log.sessionId}
+                      requestSequence={log.requestSequence}
+                      blockedBy={log.blockedBy}
+                      blockedReason={log.blockedReason}
+                      originalModel={log.originalModel}
+                      currentModel={log.model}
+                      userAgent={log.userAgent}
+                      messagesCount={log.messagesCount}
+                      endpoint={log.endpoint}
+                      billingModelSource={billingModelSource}
+                      inputTokens={log.inputTokens}
+                      outputTokens={log.outputTokens}
+                      cacheCreation5mInputTokens={log.cacheCreation5mInputTokens}
+                      cacheCreation1hInputTokens={log.cacheCreation1hInputTokens}
+                      cacheReadInputTokens={log.cacheReadInputTokens}
+                      cacheTtlApplied={log.cacheTtlApplied}
+                      costUsd={log.costUsd}
+                      costMultiplier={log.costMultiplier}
+                      context1mApplied={log.context1mApplied}
+                      externalOpen={dialogState.logId === log.id ? true : undefined}
+                      onExternalOpenChange={(open) => {
+                        if (!open) setDialogState({ logId: null, scrollToRedirect: false });
+                      }}
+                      scrollToRedirect={
+                        dialogState.logId === log.id && dialogState.scrollToRedirect
+                      }
+                    />
+                  </div>
+                </div>
+              );
+            })}
+          </div>
+        </div>
+      </div>
+
+      {/* Scroll to top button */}
+      {showScrollToTop && (
+        <Button
+          variant="outline"
+          size="sm"
+          className="fixed bottom-8 right-8 shadow-lg z-50"
+          onClick={scrollToTop}
+        >
+          <ArrowUp className="h-4 w-4 mr-1" />
+          {t("logs.table.scrollToTop")}
+        </Button>
+      )}
+    </div>
+  );
+}

+ 113 - 0
src/app/[locale]/dashboard/logs/_hooks/use-lazy-filter-options.ts

@@ -0,0 +1,113 @@
+"use client";
+
+import { useCallback, useEffect, useRef, useState } from "react";
+import type { ActionResult } from "@/actions/types";
+import { getEndpointList, getModelList, getStatusCodeList } from "@/actions/usage-logs";
+
+/**
+ * 惰性加载 Hook 返回类型
+ */
+interface UseLazyFilterOptionsReturn<T> {
+  /** 加载的数据 */
+  data: T[];
+  /** 是否正在加载 */
+  isLoading: boolean;
+  /** 是否已加载完成 */
+  isLoaded: boolean;
+  /** 加载错误信息 */
+  error: string | null;
+  /** 手动触发加载 */
+  load: () => Promise<void>;
+  /** Select onOpenChange 事件处理器 */
+  onOpenChange: (open: boolean) => void;
+}
+
+/**
+ * 通用惰性加载 Hook 工厂函数
+ * 消除重复代码,统一处理竞态条件和错误状态
+ */
+function createLazyFilterHook<T>(
+  fetcher: () => Promise<ActionResult<T[]>>
+): () => UseLazyFilterOptionsReturn<T> {
+  return function useLazyFilter(): UseLazyFilterOptionsReturn<T> {
+    const [data, setData] = useState<T[]>([]);
+    const [isLoading, setIsLoading] = useState(false);
+    const [isLoaded, setIsLoaded] = useState(false);
+    const [error, setError] = useState<string | null>(null);
+
+    // 防止组件卸载后状态更新
+    const mountedRef = useRef(true);
+    // 防止竞态条件:追踪进行中的请求
+    const inFlightRef = useRef<Promise<void> | null>(null);
+
+    useEffect(() => {
+      mountedRef.current = true;
+      return () => {
+        mountedRef.current = false;
+      };
+    }, []);
+
+    // biome-ignore lint/correctness/useExhaustiveDependencies: fetcher 是工厂函数的闭包参数,在 hook 生命周期内永不改变
+    const load = useCallback(async () => {
+      // 如果已加载或有进行中的请求,跳过
+      if (isLoaded || inFlightRef.current) return;
+
+      const promise = (async () => {
+        setIsLoading(true);
+        setError(null);
+        try {
+          const result = await fetcher();
+          if (!mountedRef.current) return;
+
+          if (result.ok) {
+            setData(result.data);
+          } else {
+            setError(result.error || "Failed to load data");
+          }
+        } catch (err) {
+          if (!mountedRef.current) return;
+          setError(err instanceof Error ? err.message : "Unknown error");
+        } finally {
+          if (mountedRef.current) {
+            setIsLoaded(true);
+            setIsLoading(false);
+          }
+          inFlightRef.current = null;
+        }
+      })();
+
+      inFlightRef.current = promise;
+      return promise;
+    }, [isLoaded]);
+
+    const onOpenChange = useCallback(
+      (open: boolean) => {
+        if (open) load();
+      },
+      [load]
+    );
+
+    return { data, isLoading, isLoaded, error, load, onOpenChange };
+  };
+}
+
+/**
+ * 惰性加载 Models 列表
+ * 用于 Model 筛选器下拉,展开时才加载数据
+ */
+export const useLazyModels: () => UseLazyFilterOptionsReturn<string> =
+  createLazyFilterHook<string>(getModelList);
+
+/**
+ * 惰性加载 StatusCodes 列表
+ * 用于 StatusCode 筛选器下拉,展开时才加载数据
+ */
+export const useLazyStatusCodes: () => UseLazyFilterOptionsReturn<number> =
+  createLazyFilterHook<number>(getStatusCodeList);
+
+/**
+ * 惰性加载 Endpoints 列表
+ * 用于 Endpoint 筛选器下拉,展开时才加载数据
+ */
+export const useLazyEndpoints: () => UseLazyFilterOptionsReturn<string> =
+  createLazyFilterHook<string>(getEndpointList);

+ 2 - 2
src/app/[locale]/dashboard/logs/page.tsx

@@ -8,7 +8,7 @@ import { Section } from "@/components/section";
 import { redirect } from "@/i18n/routing";
 import { getSession } from "@/lib/auth";
 import { getSystemSettings } from "@/repository/system-config";
-import { UsageLogsView } from "./_components/usage-logs-view";
+import { UsageLogsViewVirtualized } from "./_components/usage-logs-view-virtualized";
 
 export const dynamic = "force-dynamic";
 
@@ -59,7 +59,7 @@ export default async function UsageLogsPage({
             <div className="text-center py-8 text-muted-foreground">{t("logs.stats.loading")}</div>
           }
         >
-          <UsageLogsView
+          <UsageLogsViewVirtualized
             isAdmin={isAdmin}
             users={users}
             providers={providers}

+ 259 - 55
src/app/[locale]/dashboard/users/users-page-client.tsx

@@ -1,9 +1,10 @@
 "use client";
 
-import { Search } from "lucide-react";
+import { Plus, Search } from "lucide-react";
 import { useTranslations } from "next-intl";
-import { useMemo, useState } from "react";
+import { useCallback, useEffect, useMemo, useState } from "react";
 import { Badge } from "@/components/ui/badge";
+import { Button } from "@/components/ui/button";
 import { Input } from "@/components/ui/input";
 import {
   Select,
@@ -13,7 +14,22 @@ import {
   SelectValue,
 } from "@/components/ui/select";
 import type { User, UserDisplay } from "@/types/user";
-import { UserKeyManager } from "../_components/user/user-key-manager";
+import { UnifiedEditDialog } from "../_components/user/unified-edit-dialog";
+import { UserManagementTable } from "../_components/user/user-management-table";
+import { UserOnboardingTour } from "../_components/user/user-onboarding-tour";
+
+const ONBOARDING_KEY = "cch-users-onboarding-seen";
+
+/**
+ * Split comma-separated tags into an array of trimmed, non-empty strings.
+ * This matches the server-side providerGroup handling in provider-selector.ts
+ */
+function splitTags(value?: string | null): string[] {
+  return (value ?? "")
+    .split(",")
+    .map((t) => t.trim())
+    .filter(Boolean);
+}
 
 interface UsersPageClientProps {
   users: UserDisplay[];
@@ -22,23 +38,55 @@ interface UsersPageClientProps {
 
 export function UsersPageClient({ users, currentUser }: UsersPageClientProps) {
   const t = useTranslations("dashboard.users");
+  const tUiTable = useTranslations("ui.table");
+  const tUserMgmt = useTranslations("dashboard.userManagement");
+  const tKeyList = useTranslations("dashboard.keyList");
+  const tCommon = useTranslations("common");
   const [searchTerm, setSearchTerm] = useState("");
-  const [groupFilter, setGroupFilter] = useState("all");
   const [tagFilter, setTagFilter] = useState("all");
+  const [keyGroupFilter, setKeyGroupFilter] = useState("all");
 
-  // Extract unique groups from users (split comma-separated values)
-  const uniqueGroups = useMemo(() => {
-    const allTags = users
-      .map((u) => u.providerGroup)
-      .filter((group): group is string => Boolean(group))
-      .flatMap((group) =>
-        group
-          .split(",")
-          .map((t) => t.trim())
-          .filter(Boolean)
-      );
-    return [...new Set(allTags)].sort();
-  }, [users]);
+  // Onboarding and create dialog state
+  const [showOnboarding, setShowOnboarding] = useState(false);
+  const [showCreateDialog, setShowCreateDialog] = useState(false);
+  const [hasSeenOnboarding, setHasSeenOnboarding] = useState(true);
+
+  // Check localStorage for onboarding status on mount
+  useEffect(() => {
+    try {
+      if (typeof window !== "undefined" && window.localStorage) {
+        const seen = localStorage.getItem(ONBOARDING_KEY);
+        setHasSeenOnboarding(seen === "true");
+      }
+    } catch {
+      // localStorage not available (e.g., privacy mode)
+      setHasSeenOnboarding(true);
+    }
+  }, []);
+
+  const handleCreateUser = useCallback(() => {
+    if (!hasSeenOnboarding) {
+      setShowOnboarding(true);
+    } else {
+      setShowCreateDialog(true);
+    }
+  }, [hasSeenOnboarding]);
+
+  const handleOnboardingComplete = useCallback(() => {
+    try {
+      if (typeof window !== "undefined" && window.localStorage) {
+        localStorage.setItem(ONBOARDING_KEY, "true");
+      }
+    } catch {
+      // localStorage not available
+    }
+    setHasSeenOnboarding(true);
+    setShowCreateDialog(true);
+  }, []);
+
+  const handleCreateDialogClose = useCallback((open: boolean) => {
+    setShowCreateDialog(open);
+  }, []);
 
   // Extract unique tags from users
   const uniqueTags = useMemo(() => {
@@ -46,31 +94,97 @@ export function UsersPageClient({ users, currentUser }: UsersPageClientProps) {
     return [...new Set(tags)].sort();
   }, [users]);
 
-  // Filter users based on search term, group filter, and tag filter
-  const filteredUsers = useMemo(() => {
-    return users.filter((user) => {
-      // Search filter: match username or tag
-      const matchesSearch =
-        searchTerm === "" ||
-        user.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
-        (user.tags || []).some((tag) => tag.toLowerCase().includes(searchTerm.toLowerCase()));
-
-      // Group filter (supports comma-separated providerGroup values)
-      const matchesGroup =
-        groupFilter === "all" ||
-        (user.providerGroup
-          ?.split(",")
-          .map((t) => t.trim())
-          .filter(Boolean)
-          .includes(groupFilter) ??
-          false);
+  // Extract unique key groups from users (split comma-separated tags)
+  const uniqueKeyGroups = useMemo(() => {
+    const groups = users.flatMap((u) => u.keys?.flatMap((k) => splitTags(k.providerGroup)) || []);
+    return [...new Set(groups)].sort();
+  }, [users]);
+
+  // Reset filter if selected value no longer exists in options
+  useEffect(() => {
+    if (tagFilter !== "all" && !uniqueTags.includes(tagFilter)) {
+      setTagFilter("all");
+    }
+  }, [uniqueTags, tagFilter]);
+
+  useEffect(() => {
+    if (keyGroupFilter !== "all" && !uniqueKeyGroups.includes(keyGroupFilter)) {
+      setKeyGroupFilter("all");
+    }
+  }, [uniqueKeyGroups, keyGroupFilter]);
+
+  // Filter users based on search term, tag filter, and key group filter
+  const { filteredUsers, matchingKeyIds } = useMemo(() => {
+    const matchingIds = new Set<number>();
+    const normalizedTerm = searchTerm.trim().toLowerCase();
+    const hasSearch = normalizedTerm.length > 0;
+
+    const filtered: UserDisplay[] = [];
+
+    for (const user of users) {
+      // Collect matching key IDs for this user (before filtering)
+      const userMatchingKeyIds: number[] = [];
+
+      // Search filter: match user-level fields or any key fields
+      let matchesSearch = !hasSearch;
+
+      if (hasSearch) {
+        // User-level fields: name, note, tags, providerGroup
+        const userMatches =
+          user.name.toLowerCase().includes(normalizedTerm) ||
+          (user.note || "").toLowerCase().includes(normalizedTerm) ||
+          (user.tags || []).some((tag) => tag.toLowerCase().includes(normalizedTerm)) ||
+          (user.providerGroup || "").toLowerCase().includes(normalizedTerm);
+
+        if (userMatches) {
+          matchesSearch = true;
+        } else if (user.keys) {
+          // Key-level fields: name, maskedKey, fullKey, providerGroup
+          for (const key of user.keys) {
+            const keyMatches =
+              key.name.toLowerCase().includes(normalizedTerm) ||
+              key.maskedKey.toLowerCase().includes(normalizedTerm) ||
+              (key.fullKey || "").toLowerCase().includes(normalizedTerm) ||
+              (key.providerGroup || "").toLowerCase().includes(normalizedTerm);
+
+            if (keyMatches) {
+              matchesSearch = true;
+              userMatchingKeyIds.push(key.id);
+              // Don't break - collect all matching keys
+            }
+          }
+        }
+      }
 
       // Tag filter
       const matchesTag = tagFilter === "all" || (user.tags || []).includes(tagFilter);
 
-      return matchesSearch && matchesGroup && matchesTag;
-    });
-  }, [users, searchTerm, groupFilter, tagFilter]);
+      // Key group filter (check if any split tag matches the filter)
+      let matchesKeyGroup = keyGroupFilter === "all";
+      if (keyGroupFilter !== "all" && user.keys) {
+        for (const key of user.keys) {
+          if (splitTags(key.providerGroup).includes(keyGroupFilter)) {
+            matchesKeyGroup = true;
+            userMatchingKeyIds.push(key.id);
+          }
+        }
+      }
+
+      // Only add to results and matchingIds if user passes ALL filters
+      if (matchesSearch && matchesTag && matchesKeyGroup) {
+        filtered.push(user);
+        // Add matching key IDs only for users that pass the filter
+        for (const keyId of userMatchingKeyIds) {
+          matchingIds.add(keyId);
+        }
+      }
+    }
+
+    return { filteredUsers: filtered, matchingKeyIds: matchingIds };
+  }, [users, searchTerm, tagFilter, keyGroupFilter]);
+
+  // Determine if we should highlight keys (either search or keyGroup filter is active)
+  const shouldHighlightKeys = searchTerm.trim().length > 0 || keyGroupFilter !== "all";
 
   return (
     <div className="space-y-4">
@@ -81,6 +195,10 @@ export function UsersPageClient({ users, currentUser }: UsersPageClientProps) {
             {t("description", { count: filteredUsers.length })}
           </p>
         </div>
+        <Button onClick={handleCreateUser}>
+          <Plus className="mr-2 h-4 w-4" />
+          {t("toolbar.createUser")}
+        </Button>
       </div>
 
       {/* Toolbar with search and filters */}
@@ -96,21 +214,6 @@ export function UsersPageClient({ users, currentUser }: UsersPageClientProps) {
           />
         </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>
-
         {/* Tag filter */}
         {uniqueTags.length > 0 && (
           <Select value={tagFilter} onValueChange={setTagFilter}>
@@ -129,10 +232,111 @@ export function UsersPageClient({ users, currentUser }: UsersPageClientProps) {
             </SelectContent>
           </Select>
         )}
+
+        {/* Key group filter */}
+        {uniqueKeyGroups.length > 0 && (
+          <Select value={keyGroupFilter} onValueChange={setKeyGroupFilter}>
+            <SelectTrigger className="w-[180px]">
+              <SelectValue placeholder={t("toolbar.keyGroupFilter")} />
+            </SelectTrigger>
+            <SelectContent>
+              <SelectItem value="all">{t("toolbar.allKeyGroups")}</SelectItem>
+              {uniqueKeyGroups.map((group) => (
+                <SelectItem key={group} value={group}>
+                  <Badge variant="outline" className="mr-1 text-xs">
+                    {group}
+                  </Badge>
+                </SelectItem>
+              ))}
+            </SelectContent>
+          </Select>
+        )}
       </div>
 
-      {/* User Key Manager with filtered users */}
-      <UserKeyManager users={filteredUsers} currentUser={currentUser} currencyCode="USD" />
+      <UserManagementTable
+        users={filteredUsers}
+        currentUser={currentUser}
+        currencyCode="USD"
+        onCreateUser={handleCreateUser}
+        highlightKeyIds={shouldHighlightKeys ? matchingKeyIds : undefined}
+        autoExpandOnFilter={shouldHighlightKeys}
+        translations={{
+          table: {
+            columns: {
+              username: tUserMgmt("table.columns.username"),
+              note: tUserMgmt("table.columns.note"),
+              expiresAt: tUserMgmt("table.columns.expiresAt"),
+              limit5h: tUserMgmt("table.columns.limit5h"),
+              limitDaily: tUserMgmt("table.columns.limitDaily"),
+              limitWeekly: tUserMgmt("table.columns.limitWeekly"),
+              limitMonthly: tUserMgmt("table.columns.limitMonthly"),
+              limitTotal: tUserMgmt("table.columns.limitTotal"),
+              limitSessions: tUserMgmt("table.columns.limitSessions"),
+            },
+            keyRow: {
+              fields: {
+                name: tUserMgmt("table.keyRow.name"),
+                key: tUserMgmt("table.keyRow.key"),
+                group: tUserMgmt("table.keyRow.group"),
+                todayUsage: tUserMgmt("table.keyRow.todayUsage"),
+                todayCost: tUserMgmt("table.keyRow.todayCost"),
+                lastUsed: tUserMgmt("table.keyRow.lastUsed"),
+                actions: tUserMgmt("table.keyRow.actions"),
+                callsLabel: tUserMgmt("table.keyRow.fields.callsLabel"),
+                costLabel: tUserMgmt("table.keyRow.fields.costLabel"),
+              },
+              actions: {
+                details: tKeyList("detailsButton"),
+                logs: tKeyList("logsButton"),
+                edit: tCommon("edit"),
+                delete: tCommon("delete"),
+                copy: tCommon("copy"),
+                copySuccess: tCommon("copySuccess"),
+                copyFailed: tCommon("copyFailed"),
+                show: tKeyList("showKeyTooltip"),
+                hide: tKeyList("hideKeyTooltip"),
+                quota: tUserMgmt("table.keyRow.quotaButton"),
+              },
+              status: {
+                enabled: tUserMgmt("keyStatus.enabled"),
+                disabled: tUserMgmt("keyStatus.disabled"),
+              },
+            },
+            expand: tUserMgmt("table.expand"),
+            collapse: tUserMgmt("table.collapse"),
+            noKeys: tUserMgmt("table.noKeys"),
+            defaultGroup: tUserMgmt("table.defaultGroup"),
+          },
+          editDialog: {},
+          actions: {
+            edit: tCommon("edit"),
+            details: tKeyList("detailsButton"),
+            logs: tKeyList("logsButton"),
+            delete: tCommon("delete"),
+          },
+          pagination: {
+            previous: tUiTable("previousPage"),
+            next: tUiTable("nextPage"),
+            page: "Page {page}",
+            of: "{totalPages}",
+          },
+        }}
+      />
+
+      {/* Onboarding Tour */}
+      <UserOnboardingTour
+        open={showOnboarding}
+        onOpenChange={setShowOnboarding}
+        onComplete={handleOnboardingComplete}
+      />
+
+      {/* Create User Dialog */}
+      <UnifiedEditDialog
+        open={showCreateDialog}
+        onOpenChange={handleCreateDialogClose}
+        mode="create"
+        currentUser={currentUser}
+      />
     </div>
   );
 }

+ 1 - 1
src/app/[locale]/internal/dashboard/big-screen/page.tsx

@@ -599,7 +599,7 @@ const TrafficTrend = ({
               }}
               itemStyle={{ color: "#fff" }}
               labelFormatter={(value) => `${value}:00`}
-              formatter={(value: number) => [`${value} 请求`, "数量"]}
+              formatter={(value) => [`${value ?? 0} 请求`, "数量"]}
             />
             <Area
               type="monotone"

+ 29 - 10
src/app/[locale]/settings/error-rules/_components/rule-list-table.tsx

@@ -8,6 +8,7 @@ import { deleteErrorRuleAction, updateErrorRuleAction } from "@/actions/error-ru
 import { Badge } from "@/components/ui/badge";
 import { Button } from "@/components/ui/button";
 import { Switch } from "@/components/ui/switch";
+import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
 import type { ErrorRule } from "@/repository/error-rules";
 import { EditRuleDialog } from "./edit-rule-dialog";
 
@@ -65,25 +66,25 @@ export function RuleListTable({ rules }: RuleListTableProps) {
   return (
     <>
       <div className="overflow-x-auto">
-        <table className="w-full border-collapse">
+        <table className="w-full border-collapse table-fixed">
           <thead>
             <tr className="border-b bg-muted/50">
-              <th className="px-4 py-3 text-left text-sm font-medium">
+              <th className="w-[280px] px-4 py-3 text-left text-sm font-medium">
                 {t("errorRules.table.pattern")}
               </th>
-              <th className="px-4 py-3 text-left text-sm font-medium">
+              <th className="w-24 px-4 py-3 text-left text-sm font-medium">
                 {t("errorRules.table.category")}
               </th>
-              <th className="px-4 py-3 text-left text-sm font-medium">
+              <th className="w-48 px-4 py-3 text-left text-sm font-medium">
                 {t("errorRules.table.description")}
               </th>
-              <th className="px-4 py-3 text-left text-sm font-medium">
+              <th className="w-20 px-4 py-3 text-left text-sm font-medium">
                 {t("errorRules.table.status")}
               </th>
-              <th className="px-4 py-3 text-left text-sm font-medium">
+              <th className="w-36 px-4 py-3 text-left text-sm font-medium">
                 {t("errorRules.table.createdAt")}
               </th>
-              <th className="px-4 py-3 text-right text-sm font-medium">
+              <th className="w-24 px-4 py-3 text-right text-sm font-medium">
                 {t("errorRules.table.actions")}
               </th>
             </tr>
@@ -93,9 +94,24 @@ export function RuleListTable({ rules }: RuleListTableProps) {
               <tr key={rule.id} className="border-b hover:bg-muted/30">
                 <td className="px-4 py-3">
                   <div className="flex items-center gap-2">
-                    <code className="rounded bg-muted px-2 py-1 text-sm">{rule.pattern}</code>
+                    <Tooltip>
+                      <TooltipTrigger asChild>
+                        <code
+                          className="block max-w-[200px] truncate rounded bg-muted px-2 py-1 text-sm cursor-help"
+                          tabIndex={0}
+                        >
+                          {rule.pattern}
+                        </code>
+                      </TooltipTrigger>
+                      <TooltipContent
+                        side="bottom"
+                        className="max-w-md break-all font-mono text-xs"
+                      >
+                        {rule.pattern}
+                      </TooltipContent>
+                    </Tooltip>
                     {rule.isDefault && (
-                      <Badge variant="secondary" className="text-xs">
+                      <Badge variant="secondary" className="text-xs shrink-0">
                         {t("errorRules.table.default")}
                       </Badge>
                     )}
@@ -108,7 +124,10 @@ export function RuleListTable({ rules }: RuleListTableProps) {
                     <span className="text-sm text-muted-foreground">-</span>
                   )}
                 </td>
-                <td className="px-4 py-3 text-sm text-muted-foreground">
+                <td
+                  className="px-4 py-3 text-sm text-muted-foreground truncate"
+                  title={rule.description || undefined}
+                >
                   {rule.description || "-"}
                 </td>
                 <td className="px-4 py-3">

+ 18 - 10
src/app/[locale]/settings/providers/_components/forms/api-test-button.tsx

@@ -146,18 +146,26 @@ export function ApiTestButton({
     const currentProviderType = apiFormatToProviderType[apiFormat];
     if (!currentProviderType) return;
 
-    getProviderTestPresets(currentProviderType).then((result) => {
-      if (result.ok && result.data) {
-        setPresets(result.data);
-        // Auto-select first preset if available
-        if (result.data.length > 0 && !selectedPreset) {
-          setSelectedPreset(result.data[0].id);
-          setSuccessContains(result.data[0].defaultSuccessContains);
+    getProviderTestPresets(currentProviderType)
+      .then((result) => {
+        if (result.ok && result.data) {
+          setPresets(result.data);
+          // Auto-select first preset if available
+          if (result.data.length > 0 && !selectedPreset) {
+            setSelectedPreset(result.data[0].id);
+            setSuccessContains(result.data[0].defaultSuccessContains);
+          }
+        } else {
+          if (!result.ok) {
+            console.error("[ApiTestButton] Failed to load presets:", result.error);
+          }
+          setPresets([]);
         }
-      } else {
+      })
+      .catch((err) => {
+        console.error("[ApiTestButton] Failed to load presets:", err);
         setPresets([]);
-      }
-    });
+      });
   }, [apiFormat, apiFormatToProviderType, selectedPreset]);
 
   useEffect(() => {

+ 13 - 5
src/app/[locale]/settings/providers/_components/provider-rich-list-item.tsx

@@ -152,12 +152,20 @@ export function ProviderRichListItem({
   // 处理查看密钥
   const handleShowKey = async () => {
     setShowKeyDialog(true);
-    const result = await getUnmaskedProviderKey(provider.id);
-    if (result.ok) {
-      setUnmaskedKey(result.data.key);
-    } else {
+    try {
+      const result = await getUnmaskedProviderKey(provider.id);
+      if (result.ok) {
+        setUnmaskedKey(result.data.key);
+      } else {
+        toast.error(tList("getKeyFailed"), {
+          description: result.error || tList("unknownError"),
+        });
+        setShowKeyDialog(false);
+      }
+    } catch (error) {
+      console.error("Failed to get provider key:", error);
       toast.error(tList("getKeyFailed"), {
-        description: result.error || tList("unknownError"),
+        description: tList("unknownError"),
       });
       setShowKeyDialog(false);
     }

+ 2 - 2
src/app/[locale]/usage-doc/page.tsx

@@ -639,7 +639,7 @@ sk_xxxxxxxxxxxxxxxxxx`}
                 <CodeBlock
                   language="toml"
                   code={`model_provider = "cch"
-model = "gpt-5.2"
+model = "gpt-5.2-codex"
 model_reasoning_effort = "xhigh"
 disable_response_storage = true
 sandbox_mode = "workspace-write"
@@ -685,7 +685,7 @@ network_access = true`}
                 <CodeBlock
                   language="toml"
                   code={`model_provider = "cch"
-model = "gpt-5.2"
+model = "gpt-5.2-codex"
 model_reasoning_effort = "xhigh"
 disable_response_storage = true
 sandbox_mode = "workspace-write"

+ 274 - 17
src/app/api/actions/[...route]/route.ts

@@ -43,6 +43,15 @@ export const runtime = "nodejs";
 // 创建 OpenAPIHono 实例
 const app = new OpenAPIHono().basePath("/api/actions");
 
+// 注册安全方案
+app.openAPIRegistry.registerComponent("securitySchemes", "cookieAuth", {
+  type: "apiKey",
+  in: "cookie",
+  name: "auth-token",
+  description:
+    "HTTP Cookie 认证。请先通过 Web UI 登录获取 auth-token Cookie,或从浏览器开发者工具中复制 Cookie 值用于 API 调用。详见上方「认证方式」章节。",
+});
+
 // ==================== 用户管理 ====================
 
 const { route: getUsersRoute, handler: getUsersHandler } = createActionRoute(
@@ -50,7 +59,29 @@ const { route: getUsersRoute, handler: getUsersHandler } = createActionRoute(
   "getUsers",
   userActions.getUsers,
   {
+    requestSchema: z.object({}).describe("无需请求参数"),
+    responseSchema: z.array(
+      z.object({
+        id: z.number().describe("用户 ID"),
+        name: z.string().describe("用户名"),
+        note: z.string().nullable().describe("备注"),
+        role: z.enum(["admin", "user"]).describe("用户角色"),
+        isEnabled: z.boolean().describe("是否启用"),
+        expiresAt: z.string().nullable().describe("过期时间"),
+        rpm: z.number().describe("每分钟请求数限制"),
+        dailyQuota: z.number().describe("每日消费额度(美元)"),
+        providerGroup: z.string().nullable().describe("供应商分组"),
+        tags: z.array(z.string()).describe("用户标签"),
+        limit5hUsd: z.number().nullable().describe("5小时消费上限"),
+        limitWeeklyUsd: z.number().nullable().describe("周消费上限"),
+        limitMonthlyUsd: z.number().nullable().describe("月消费上限"),
+        limitTotalUsd: z.number().nullable().describe("总消费上限"),
+        limitConcurrentSessions: z.number().nullable().describe("并发Session上限"),
+        createdAt: z.string().describe("创建时间"),
+      })
+    ),
     description: "获取用户列表 (管理员获取所有用户,普通用户仅获取自己)",
+    summary: "获取用户列表",
     tags: ["用户管理"],
   }
 );
@@ -148,8 +179,14 @@ const { route: editUserRoute, handler: editUserHandler } = createActionRoute(
       ...UpdateUserSchema.shape,
     }),
     description: "编辑用户信息 (管理员)",
+    summary: "编辑用户信息",
     tags: ["用户管理"],
     requiredRole: "admin",
+    // 修复:显式指定参数映射
+    argsMapper: (body) => {
+      const { userId, ...data } = body;
+      return [userId, data];
+    },
   }
 );
 app.openapi(editUserRoute, editUserHandler);
@@ -163,6 +200,7 @@ const { route: removeUserRoute, handler: removeUserHandler } = createActionRoute
       userId: z.number().int().positive(),
     }),
     description: "删除用户 (管理员)",
+    summary: "删除用户",
     tags: ["用户管理"],
     requiredRole: "admin",
   }
@@ -178,6 +216,7 @@ const { route: getUserLimitUsageRoute, handler: getUserLimitUsageHandler } = cre
       userId: z.number().int().positive(),
     }),
     description: "获取用户限额使用情况",
+    summary: "获取用户限额使用情况",
     tags: ["用户管理"],
   }
 );
@@ -194,6 +233,7 @@ const { route: getKeysRoute, handler: getKeysHandler } = createActionRoute(
       userId: z.number().int().positive(),
     }),
     description: "获取用户的密钥列表",
+    summary: "获取用户的密钥列表",
     tags: ["密钥管理"],
   }
 );
@@ -245,7 +285,13 @@ const { route: editKeyRoute, handler: editKeyHandler } = createActionRoute(
       limitConcurrentSessions: z.number().optional(),
     }),
     description: "编辑密钥信息",
+    summary: "编辑密钥信息",
     tags: ["密钥管理"],
+    // 修复:显式指定参数映射
+    argsMapper: (body) => {
+      const { keyId, ...data } = body;
+      return [keyId, data];
+    },
   }
 );
 app.openapi(editKeyRoute, editKeyHandler);
@@ -259,6 +305,7 @@ const { route: removeKeyRoute, handler: removeKeyHandler } = createActionRoute(
       keyId: z.number().int().positive(),
     }),
     description: "删除密钥",
+    summary: "删除密钥",
     tags: ["密钥管理"],
   }
 );
@@ -273,6 +320,7 @@ const { route: getKeyLimitUsageRoute, handler: getKeyLimitUsageHandler } = creat
       keyId: z.number().int().positive(),
     }),
     description: "获取密钥限额使用情况",
+    summary: "获取密钥限额使用情况",
     tags: ["密钥管理"],
   }
 );
@@ -285,7 +333,29 @@ const { route: getProvidersRoute, handler: getProvidersHandler } = createActionR
   "getProviders",
   providerActions.getProviders,
   {
+    requestSchema: z.object({}).describe("无需请求参数"),
+    responseSchema: z.array(
+      z.object({
+        id: z.number().describe("供应商 ID"),
+        name: z.string().describe("供应商名称"),
+        providerType: z.string().describe("供应商类型"),
+        url: z.string().describe("API 地址"),
+        apiKey: z.string().describe("API 密钥(脱敏)"),
+        isEnabled: z.boolean().describe("是否启用"),
+        weight: z.number().describe("权重"),
+        priority: z.number().describe("优先级"),
+        costMultiplier: z.number().describe("成本系数"),
+        modelRedirects: z.record(z.string(), z.string()).nullable().describe("模型重定向映射"),
+        proxyUrl: z.string().nullable().describe("代理地址"),
+        maxConcurrency: z.number().nullable().describe("最大并发数"),
+        rpmLimit: z.number().nullable().describe("RPM 限制"),
+        dailyCostLimit: z.number().nullable().describe("每日成本限制"),
+        groups: z.array(z.string()).describe("分组"),
+        createdAt: z.string().describe("创建时间"),
+      })
+    ),
     description: "获取所有供应商列表 (管理员)",
+    summary: "获取供应商列表",
     tags: ["供应商管理"],
     requiredRole: "admin",
   }
@@ -299,6 +369,7 @@ const { route: addProviderRoute, handler: addProviderHandler } = createActionRou
   {
     requestSchema: CreateProviderSchema,
     description: "创建新供应商 (管理员)",
+    summary: "创建新供应商",
     tags: ["供应商管理"],
     requiredRole: "admin",
   }
@@ -315,8 +386,14 @@ const { route: editProviderRoute, handler: editProviderHandler } = createActionR
       ...UpdateProviderSchema.shape,
     }),
     description: "编辑供应商信息 (管理员)",
+    summary: "编辑供应商信息",
     tags: ["供应商管理"],
     requiredRole: "admin",
+    // 修复:显式指定参数映射
+    argsMapper: (body) => {
+      const { providerId, ...data } = body;
+      return [providerId, data];
+    },
   }
 );
 app.openapi(editProviderRoute, editProviderHandler);
@@ -330,6 +407,7 @@ const { route: removeProviderRoute, handler: removeProviderHandler } = createAct
       providerId: z.number().int().positive(),
     }),
     description: "删除供应商 (管理员)",
+    summary: "删除供应商",
     tags: ["供应商管理"],
     requiredRole: "admin",
   }
@@ -342,7 +420,9 @@ const { route: getProvidersHealthStatusRoute, handler: getProvidersHealthStatusH
     "getProvidersHealthStatus",
     providerActions.getProvidersHealthStatus,
     {
+      requestSchema: z.object({}).describe("无需请求参数"),
       description: "获取所有供应商的熔断器健康状态 (管理员)",
+      summary: "获取供应商健康状态",
       tags: ["供应商管理"],
       requiredRole: "admin",
     }
@@ -355,6 +435,7 @@ const { route: resetProviderCircuitRoute, handler: resetProviderCircuitHandler }
       providerId: z.number().int().positive(),
     }),
     description: "重置供应商的熔断器状态 (管理员)",
+    summary: "重置供应商熔断器",
     tags: ["供应商管理"],
     requiredRole: "admin",
   });
@@ -366,6 +447,7 @@ const { route: getProviderLimitUsageRoute, handler: getProviderLimitUsageHandler
       providerId: z.number().int().positive(),
     }),
     description: "获取供应商限额使用情况 (管理员)",
+    summary: "获取供应商限额使用情况",
     tags: ["供应商管理"],
     requiredRole: "admin",
   });
@@ -378,7 +460,9 @@ const { route: getModelPricesRoute, handler: getModelPricesHandler } = createAct
   "getModelPrices",
   modelPriceActions.getModelPrices,
   {
+    requestSchema: z.object({}).describe("无需请求参数"),
     description: "获取所有模型价格 (管理员)",
+    summary: "获取模型价格列表",
     tags: ["模型价格"],
     requiredRole: "admin",
   }
@@ -394,6 +478,7 @@ const { route: uploadPriceTableRoute, handler: uploadPriceTableHandler } = creat
       jsonContent: z.string().describe("价格表 JSON 字符串"),
     }),
     description: "上传价格表 (管理员)",
+    summary: "上传模型价格表",
     tags: ["模型价格"],
     requiredRole: "admin",
   }
@@ -405,6 +490,7 @@ const { route: syncLiteLLMPricesRoute, handler: syncLiteLLMPricesHandler } = cre
   "syncLiteLLMPrices",
   modelPriceActions.syncLiteLLMPrices,
   {
+    requestSchema: z.object({}).describe("无需请求参数"),
     description: "同步 LiteLLM 价格表 (管理员)",
     summary: "从 GitHub 拉取最新的 LiteLLM 价格表并导入",
     tags: ["模型价格"],
@@ -421,7 +507,9 @@ const {
   "getAvailableModelsByProviderType",
   modelPriceActions.getAvailableModelsByProviderType,
   {
+    requestSchema: z.object({}).describe("无需请求参数"),
     description: "获取可用模型列表 (按供应商类型分组)",
+    summary: "获取可用模型列表",
     tags: ["模型价格"],
   }
 );
@@ -432,8 +520,10 @@ const { route: hasPriceTableRoute, handler: hasPriceTableHandler } = createActio
   "hasPriceTable",
   modelPriceActions.hasPriceTable,
   {
+    requestSchema: z.object({}).describe("无需请求参数"),
     responseSchema: z.boolean(),
     description: "检查是否有价格表",
+    summary: "检查价格表状态",
     tags: ["模型价格"],
   }
 );
@@ -491,8 +581,10 @@ const { route: getModelListRoute, handler: getModelListHandler } = createActionR
   "getModelList",
   usageLogActions.getModelList,
   {
+    requestSchema: z.object({}).describe("无需请求参数"),
     responseSchema: z.array(z.string()),
     description: "获取日志中的模型列表",
+    summary: "获取日志中的模型列表",
     tags: ["使用日志"],
   }
 );
@@ -503,8 +595,10 @@ const { route: getStatusCodeListRoute, handler: getStatusCodeListHandler } = cre
   "getStatusCodeList",
   usageLogActions.getStatusCodeList,
   {
+    requestSchema: z.object({}).describe("无需请求参数"),
     responseSchema: z.array(z.number()),
     description: "获取日志中的状态码列表",
+    summary: "获取日志中的状态码列表",
     tags: ["使用日志"],
   }
 );
@@ -517,6 +611,7 @@ const { route: getOverviewDataRoute, handler: getOverviewDataHandler } = createA
   "getOverviewData",
   overviewActions.getOverviewData,
   {
+    requestSchema: z.object({}).describe("无需请求参数"),
     description: "获取首页概览数据",
     summary: "包含并发数、今日统计、活跃用户等",
     tags: ["概览"],
@@ -531,7 +626,9 @@ const { route: listSensitiveWordsRoute, handler: listSensitiveWordsHandler } = c
   "listSensitiveWords",
   sensitiveWordActions.listSensitiveWords,
   {
+    requestSchema: z.object({}).describe("无需请求参数"),
     description: "获取敏感词列表 (管理员)",
+    summary: "获取敏感词列表",
     tags: ["敏感词管理"],
     requiredRole: "admin",
   }
@@ -549,6 +646,7 @@ const { route: createSensitiveWordRoute, handler: createSensitiveWordHandler } =
       description: z.string().optional(),
     }),
     description: "创建敏感词 (管理员)",
+    summary: "创建敏感词",
     tags: ["敏感词管理"],
     requiredRole: "admin",
   }
@@ -568,8 +666,14 @@ const { route: updateSensitiveWordRoute, handler: updateSensitiveWordHandler } =
       description: z.string().optional(),
     }),
     description: "更新敏感词 (管理员)",
+    summary: "更新敏感词",
     tags: ["敏感词管理"],
     requiredRole: "admin",
+    // 修复:显式指定参数映射
+    argsMapper: (body) => {
+      const { id, ...updates } = body;
+      return [id, updates];
+    },
   }
 );
 app.openapi(updateSensitiveWordRoute, updateSensitiveWordHandler);
@@ -583,6 +687,7 @@ const { route: deleteSensitiveWordRoute, handler: deleteSensitiveWordHandler } =
       id: z.number().int().positive(),
     }),
     description: "删除敏感词 (管理员)",
+    summary: "删除敏感词",
     tags: ["敏感词管理"],
     requiredRole: "admin",
   }
@@ -594,7 +699,9 @@ const { route: refreshCacheRoute, handler: refreshCacheHandler } = createActionR
   "refreshCacheAction",
   sensitiveWordActions.refreshCacheAction,
   {
+    requestSchema: z.object({}).describe("无需请求参数"),
     description: "手动刷新敏感词缓存 (管理员)",
+    summary: "刷新敏感词缓存",
     tags: ["敏感词管理"],
     requiredRole: "admin",
   }
@@ -606,7 +713,9 @@ const { route: getCacheStatsRoute, handler: getCacheStatsHandler } = createActio
   "getCacheStats",
   sensitiveWordActions.getCacheStats,
   {
+    requestSchema: z.object({}).describe("无需请求参数"),
     description: "获取敏感词缓存统计信息 (管理员)",
+    summary: "获取缓存统计信息",
     tags: ["敏感词管理"],
     requiredRole: "admin",
   }
@@ -620,7 +729,9 @@ const { route: getActiveSessionsRoute, handler: getActiveSessionsHandler } = cre
   "getActiveSessions",
   activeSessionActions.getActiveSessions,
   {
+    requestSchema: z.object({}).describe("无需请求参数"),
     description: "获取活跃 Session 列表",
+    summary: "获取活跃 Session 列表",
     tags: ["Session 管理"],
   }
 );
@@ -635,6 +746,7 @@ const { route: getSessionDetailsRoute, handler: getSessionDetailsHandler } = cre
       sessionId: z.string(),
     }),
     description: "获取 Session 详情",
+    summary: "获取 Session 详情",
     tags: ["Session 管理"],
   }
 );
@@ -649,6 +761,7 @@ const { route: getSessionMessagesRoute, handler: getSessionMessagesHandler } = c
       sessionId: z.string(),
     }),
     description: "获取 Session 的 messages 内容",
+    summary: "获取 Session 消息内容",
     tags: ["Session 管理"],
   }
 );
@@ -662,7 +775,9 @@ const { route: getNotificationSettingsRoute, handler: getNotificationSettingsHan
     "getNotificationSettingsAction",
     notificationActions.getNotificationSettingsAction,
     {
+      requestSchema: z.object({}).describe("无需请求参数"),
       description: "获取通知设置",
+      summary: "获取通知设置",
       tags: ["通知管理"],
       requiredRole: "admin",
     }
@@ -680,6 +795,7 @@ const { route: updateNotificationSettingsRoute, handler: updateNotificationSetti
         enabledEvents: z.array(z.string()).optional(),
       }),
       description: "更新通知设置",
+      summary: "更新通知设置",
       tags: ["通知管理"],
       requiredRole: "admin",
     }
@@ -695,6 +811,7 @@ const { route: testWebhookRoute, handler: testWebhookHandler } = createActionRou
       webhookUrl: z.string().url(),
     }),
     description: "测试 Webhook 配置",
+    summary: "测试 Webhook 配置",
     tags: ["通知管理"],
     requiredRole: "admin",
   }
@@ -714,7 +831,7 @@ function getOpenAPIServers() {
   if (appUrl) {
     servers.push({
       url: appUrl,
-      description: "应用地址 (配置)",
+      description: "生产环境 - 已通过 APP_URL 环境变量配置的应用地址",
     });
   }
 
@@ -722,7 +839,7 @@ function getOpenAPIServers() {
   if (process.env.NODE_ENV !== "production") {
     servers.push({
       url: "http://localhost:13500",
-      description: "本地开发环境",
+      description: "本地开发环境 - 默认端口 13500",
     });
   }
 
@@ -730,7 +847,7 @@ function getOpenAPIServers() {
   if (servers.length === 0) {
     servers.push({
       url: "https://your-domain.com",
-      description: "生产环境 (请配置 APP_URL 环境变量)",
+      description: "请配置 APP_URL 环境变量指定生产环境地址",
     });
   }
 
@@ -756,15 +873,103 @@ Claude Code Hub 是一个 Claude API 代理中转服务平台,提供以下功能
 - 🛡️ **敏感词过滤** - 内容审核和风险控制
 - ⚡ **Session 管理** - 并发控制和会话追踪
 
-## 认证
+## 认证方式
+
+所有 API 端点通过 **HTTP Cookie** 进行认证,Cookie 名称为 \`auth-token\`。
+
+### 如何获取认证 Token
+
+#### 方法 1:通过 Web UI 登录(推荐)
+
+1. 访问 Claude Code Hub 登录页面
+2. 使用您的 API Key 或管理员令牌(ADMIN_TOKEN)登录
+3. 登录成功后,浏览器会自动设置 \`auth-token\` Cookie
+4. 在同一浏览器中访问 API 文档页面即可直接测试(Cookie 自动携带)
+
+#### 方法 2:手动获取 Cookie(用于脚本或编程调用)
+
+登录成功后,可以从浏览器开发者工具中获取 Cookie 值:
+
+1. 打开浏览器开发者工具(F12)
+2. 切换到 "Application" 或 "Storage" 标签
+3. 在 Cookies 中找到 \`auth-token\` 的值
+4. 复制该值用于 API 调用
+
+### 使用示例
+
+#### curl 示例
+
+\`\`\`bash
+# 使用 Cookie 认证调用 API
+curl -X POST 'http://localhost:23000/api/actions/users/getUsers' \\
+  -H 'Content-Type: application/json' \\
+  -H 'Cookie: auth-token=your-token-here' \\
+  -d '{}'
+\`\`\`
+
+#### JavaScript (fetch) 示例
+
+\`\`\`javascript
+// 浏览器环境(Cookie 自动携带)
+fetch('/api/actions/users/getUsers', {
+  method: 'POST',
+  headers: {
+    'Content-Type': 'application/json',
+  },
+  credentials: 'include', // 重要:携带 Cookie
+  body: JSON.stringify({}),
+})
+  .then(res => res.json())
+  .then(data => console.log(data));
+
+// Node.js 环境(需要手动设置 Cookie)
+const fetch = require('node-fetch');
+
+fetch('http://localhost:23000/api/actions/users/getUsers', {
+  method: 'POST',
+  headers: {
+    'Content-Type': 'application/json',
+    'Cookie': 'auth-token=your-token-here',
+  },
+  body: JSON.stringify({}),
+})
+  .then(res => res.json())
+  .then(data => console.log(data));
+\`\`\`
+
+#### Python 示例
+
+\`\`\`python
+import requests
+
+# 使用 Session 保持 Cookie
+session = requests.Session()
 
-所有 API 端点需要通过 Cookie 认证。请先通过 Web UI 登录获取 session。
+# 方式 1:手动设置 Cookie
+session.cookies.set('auth-token', 'your-token-here')
+
+# 方式 2:或者在请求头中设置
+headers = {
+    'Content-Type': 'application/json',
+    'Cookie': 'auth-token=your-token-here'
+}
+
+response = session.post(
+    'http://localhost:23000/api/actions/users/getUsers',
+    json={},
+    headers=headers
+)
+
+print(response.json())
+\`\`\`
 
 ## 权限
 
 - 👤 **普通用户**: 可以查看自己的数据和使用统计
 - 👑 **管理员**: 拥有完整的系统管理权限
 
+标记为 \`[管理员]\` 的端点需要管理员权限。
+
 ## 错误处理
 
 所有 API 响应遵循统一格式:
@@ -779,31 +984,83 @@ Claude Code Hub 是一个 Claude API 代理中转服务平台,提供以下功能
 // 失败
 {
   "ok": false,
-  "error": "错误消息"
+  "error": "错误消息",
+  "errorCode": "ERROR_CODE",  // 可选:错误码(用于国际化)
+  "errorParams": { ... }       // 可选:错误参数
 }
 \`\`\`
 
 HTTP 状态码:
 - \`200\`: 操作成功
 - \`400\`: 请求错误 (参数验证失败或业务逻辑错误)
-- \`401\`: 未认证 (需要登录)
+- \`401\`: 未认证 (需要登录或 Cookie 无效)
 - \`403\`: 权限不足
 - \`500\`: 服务器内部错误
+
+### 常见认证错误
+
+| HTTP 状态码 | 错误消息 | 原因 | 解决方法 |
+|-----------|---------|-----|---------|
+| 401 | "未认证" | 缺少 \`auth-token\` Cookie | 先通过 Web UI 登录 |
+| 401 | "认证无效或已过期" | Cookie 无效或已过期 | 重新登录获取新 Cookie |
+| 403 | "权限不足" | 普通用户访问管理员端点 | 使用管理员账号登录 |
     `,
+    contact: {
+      name: "项目维护团队",
+      url: "https://github.com/ding113/claude-code-hub/issues",
+    },
+    license: {
+      name: "MIT License",
+      url: "https://github.com/ding113/claude-code-hub/blob/main/LICENSE",
+    },
   },
   servers: getOpenAPIServers(),
   tags: [
-    { name: "用户管理", description: "用户的 CRUD 操作和限额管理" },
-    { name: "密钥管理", description: "API 密钥的生成、编辑和限额配置" },
-    { name: "供应商管理", description: "上游供应商配置、熔断器和健康检查" },
-    { name: "模型价格", description: "模型价格配置和 LiteLLM 价格同步" },
-    { name: "统计分析", description: "使用统计和数据分析" },
-    { name: "使用日志", description: "请求日志查询和审计" },
-    { name: "概览", description: "首页概览数据" },
-    { name: "敏感词管理", description: "敏感词过滤配置" },
-    { name: "Session 管理", description: "活跃 Session 和并发控制" },
-    { name: "通知管理", description: "系统通知" },
+    {
+      name: "用户管理",
+      description: "用户账号的创建、编辑、删除和限额配置,支持 RPM、金额限制和并发会话控制",
+    },
+    {
+      name: "密钥管理",
+      description: "为用户生成 API 密钥,支持独立的金额限制、过期时间和 Web UI 登录权限配置",
+    },
+    {
+      name: "供应商管理",
+      description: "配置上游 API 供应商,包括权重调度、熔断保护、代理设置和健康状态监控",
+    },
+    {
+      name: "模型价格",
+      description: "管理模型价格表,支持手动上传 JSON 或从 LiteLLM 官方仓库同步最新价格",
+    },
+    {
+      name: "统计分析",
+      description: "查看用户消费统计、请求量趋势和成本分析,支持多种时间维度的数据汇总",
+    },
+    {
+      name: "使用日志",
+      description: "查询 API 请求日志,支持按用户、模型、时间范围、状态码等多条件过滤",
+    },
+    {
+      name: "概览",
+      description: "展示系统运行状态概览,包括并发数、今日统计、活跃用户和时间分布图表",
+    },
+    {
+      name: "敏感词管理",
+      description: "配置内容审核规则,支持正则表达式匹配和缓存刷新,用于风险控制",
+    },
+    {
+      name: "Session 管理",
+      description: "查看活跃会话列表、会话详情和消息内容,用于并发控制和请求追踪",
+    },
+    {
+      name: "通知管理",
+      description: "配置 Webhook 通知,接收系统事件推送(如限额预警、熔断触发等)",
+    },
   ],
+  externalDocs: {
+    description: "GitHub 仓库 - 查看完整文档、功能介绍和部署指南",
+    url: "https://github.com/ding113/claude-code-hub",
+  },
 });
 
 // Swagger UI (传统风格)

+ 81 - 22
src/app/global-error.tsx

@@ -1,10 +1,18 @@
 "use client";
 
+import { isNetworkError } from "@/lib/utils/error-detection";
+
 /**
- * 全局错误边界组件
+ * Global error boundary component
+ *
+ * Must be a Client Component with html and body tags
+ * Displayed when root layout throws an error
  *
- * 必须是 Client Component,且包含 html 和 body 标签
- * 当 root layout 抛出错误时显示
+ * Note: Most errors should be caught by component-level error boundaries
+ * or try-catch in event handlers. This is the last resort fallback.
+ *
+ * Security: Never display raw error.message to users as it may contain
+ * sensitive information (database strings, file paths, internal APIs, etc.)
  */
 export default function GlobalError({
   error,
@@ -13,6 +21,13 @@ export default function GlobalError({
   error: Error & { digest?: string };
   reset: () => void;
 }) {
+  // Use shared network error detection
+  const isNetwork = isNetworkError(error);
+
+  const handleGoHome = () => {
+    window.location.href = "/";
+  };
+
   return (
     <html lang="en">
       <body>
@@ -26,33 +41,77 @@ export default function GlobalError({
             fontFamily: "system-ui, sans-serif",
             backgroundColor: "#f8f9fa",
             padding: "20px",
+            textAlign: "center",
           }}
         >
-          <h2 style={{ fontSize: "24px", marginBottom: "16px", color: "#dc3545" }}>
-            Something went wrong!
+          <h2
+            style={{
+              fontSize: "24px",
+              marginBottom: "16px",
+              color: isNetwork ? "#f59e0b" : "#dc3545",
+            }}
+          >
+            {isNetwork ? "Network Connection Error" : "Something went wrong!"}
           </h2>
-          <p style={{ color: "#6c757d", marginBottom: "24px" }}>
-            {error.message || "An unexpected error occurred"}
-          </p>
+
+          {isNetwork ? (
+            <div style={{ color: "#6c757d", marginBottom: "24px", maxWidth: "400px" }}>
+              <p style={{ marginBottom: "12px" }}>Unable to connect to the server. Please check:</p>
+              <ul
+                style={{
+                  textAlign: "left",
+                  margin: "0 auto",
+                  paddingLeft: "20px",
+                  listStyleType: "disc",
+                }}
+              >
+                <li>Your network connection is working</li>
+                <li>The server is running and accessible</li>
+                <li>Proxy settings are configured correctly</li>
+              </ul>
+            </div>
+          ) : (
+            <p style={{ color: "#6c757d", marginBottom: "24px", maxWidth: "400px" }}>
+              An unexpected error occurred. Please try again later.
+            </p>
+          )}
+
           {error.digest && (
             <p style={{ fontSize: "12px", color: "#adb5bd", marginBottom: "16px" }}>
               Error ID: {error.digest}
             </p>
           )}
-          <button
-            onClick={() => reset()}
-            style={{
-              padding: "12px 24px",
-              fontSize: "16px",
-              backgroundColor: "#0d6efd",
-              color: "white",
-              border: "none",
-              borderRadius: "8px",
-              cursor: "pointer",
-            }}
-          >
-            Try again
-          </button>
+
+          <div style={{ display: "flex", gap: "12px" }}>
+            <button
+              onClick={() => reset()}
+              style={{
+                padding: "12px 24px",
+                fontSize: "16px",
+                backgroundColor: "#0d6efd",
+                color: "white",
+                border: "none",
+                borderRadius: "8px",
+                cursor: "pointer",
+              }}
+            >
+              Try again
+            </button>
+            <button
+              onClick={handleGoHome}
+              style={{
+                padding: "12px 24px",
+                fontSize: "16px",
+                backgroundColor: "#6c757d",
+                color: "white",
+                border: "none",
+                borderRadius: "8px",
+                cursor: "pointer",
+              }}
+            >
+              Go to Home
+            </button>
+          </div>
         </div>
       </body>
     </html>

+ 3 - 3
src/app/v1/_lib/codex/utils/request-sanitizer.ts

@@ -225,9 +225,9 @@ export async function sanitizeCodexRequest(
 
   // 步骤 3: 确保必需字段
   // Codex API 的默认行为
-  if (output.stream === undefined) {
-    output.stream = true; // Codex 默认流式
-  }
+  // 注意:不再强制设置 stream = true,因为 /v1/responses/compact 端点不支持 stream 参数
+  // 如果客户端未指定 stream,则保持 undefined,由上游 API 决定默认行为
+  // 参考:https://github.com/ding113/claude-code-hub/issues/368
   output.store = false; // Codex 不存储对话历史
   output.parallel_tool_calls = true; // Codex 支持并行工具调用
 

+ 63 - 0
src/app/v1/_lib/proxy/client-guard.ts

@@ -0,0 +1,63 @@
+import { ProxyResponses } from "./responses";
+import type { ProxySession } from "./session";
+
+/**
+ * Client (CLI/IDE) restriction guard
+ *
+ * Validates that the client making the request is allowed based on User-Agent header matching.
+ * This check is ONLY performed when the user has configured client restrictions (allowedClients).
+ *
+ * Logic:
+ * - If allowedClients is empty or undefined: skip all checks, allow request
+ * - If allowedClients is non-empty:
+ *   - Missing or empty User-Agent → 400 error
+ *   - User-Agent doesn't match any allowed pattern → 400 error
+ *   - User-Agent matches at least one pattern → allow request
+ *
+ * Matching: case-insensitive substring match
+ */
+export class ProxyClientGuard {
+  static async ensure(session: ProxySession): Promise<Response | null> {
+    const user = session.authState?.user;
+    if (!user) {
+      // No user context - skip check (authentication should have failed already)
+      return null;
+    }
+
+    // Check if client restrictions are configured
+    const allowedClients = user.allowedClients ?? [];
+    if (allowedClients.length === 0) {
+      // No restrictions configured - skip all checks
+      return null;
+    }
+
+    // Restrictions exist - now User-Agent is required
+    const userAgent = session.userAgent;
+
+    // Missing or empty User-Agent when restrictions exist
+    if (!userAgent || userAgent.trim() === "") {
+      return ProxyResponses.buildError(
+        400,
+        "Client not allowed. User-Agent header is required when client restrictions are configured.",
+        "invalid_request_error"
+      );
+    }
+
+    // Case-insensitive substring match
+    const userAgentLower = userAgent.toLowerCase();
+    const isAllowed = allowedClients.some((pattern) =>
+      userAgentLower.includes(pattern.toLowerCase())
+    );
+
+    if (!isAllowed) {
+      return ProxyResponses.buildError(
+        400,
+        `Client not allowed. Your client is not in the allowed list.`,
+        "invalid_request_error"
+      );
+    }
+
+    // Client is allowed
+    return null;
+  }
+}

+ 19 - 4
src/app/v1/_lib/proxy/error-handler.ts

@@ -22,6 +22,15 @@ const OVERRIDE_STATUS_CODE_MIN = 400;
 /** 覆写状态码最大值 */
 const OVERRIDE_STATUS_CODE_MAX = 599;
 
+/**
+ * 根据限流类型计算 HTTP 状态码
+ * - RPM/并发用 429 Too Many Requests(可重试的频率控制)
+ * - 消费限额用 402 Payment Required(需充值/等待重置)
+ */
+function getRateLimitStatusCode(limitType: string): number {
+  return limitType === "rpm" || limitType === "concurrent_sessions" ? 429 : 402;
+}
+
 export class ProxyErrorHandler {
   static async handle(session: ProxySession, error: unknown): Promise<Response> {
     // 分离两种消息:
@@ -36,10 +45,11 @@ export class ProxyErrorHandler {
     if (isRateLimitError(error)) {
       clientErrorMessage = error.message;
       logErrorMessage = error.message;
-      statusCode = 429;
+      // 使用 helper 函数计算状态码
+      statusCode = getRateLimitStatusCode(error.limitType);
       rateLimitMetadata = error.toJSON();
 
-      // 构建详细的 429 响应
+      // 构建详细的 402 响应
       const response = ProxyErrorHandler.buildRateLimitResponse(error);
 
       // 记录错误到数据库(包含 rate_limit 元数据)
@@ -217,8 +227,10 @@ export class ProxyErrorHandler {
   }
 
   /**
-   * 构建 429 Rate Limit 响应
+   * 构建 Rate Limit 响应(402/429)
    *
+   * - RPM/并发用 429 Too Many Requests(可重试的频率控制)
+   * - 消费限额用 402 Payment Required(需充值/等待重置)
    * 返回包含所有 7 个限流字段的详细错误信息,并添加标准 rate limit 响应头
    *
    * 响应体字段(7个核心字段):
@@ -236,6 +248,9 @@ export class ProxyErrorHandler {
    * - X-RateLimit-Reset: Unix 时间戳(秒)
    */
   private static buildRateLimitResponse(error: RateLimitError): Response {
+    // 使用 helper 函数计算状态码
+    const statusCode = getRateLimitStatusCode(error.limitType);
+
     // 计算剩余配额(不能为负数)
     const remaining = Math.max(0, error.limitValue - error.currentUsage);
 
@@ -268,7 +283,7 @@ export class ProxyErrorHandler {
         },
       }),
       {
-        status: 429,
+        status: statusCode, // 根据 limitType 动态选择 429 或 402
         headers,
       }
     );

+ 19 - 1
src/app/v1/_lib/proxy/guard-pipeline.ts

@@ -1,5 +1,7 @@
 import { ProxyAuthenticator } from "./auth-guard";
+import { ProxyClientGuard } from "./client-guard";
 import { ProxyMessageService } from "./message-service";
+import { ProxyModelGuard } from "./model-guard";
 import { ProxyProviderResolver } from "./provider-selector";
 import { ProxyRateLimitGuard } from "./rate-limit-guard";
 import { ProxyRequestFilter } from "./request-filter";
@@ -23,6 +25,8 @@ export interface GuardStep {
 // Pipeline configuration describes an ordered list of step keys
 export type GuardStepKey =
   | "auth"
+  | "client"
+  | "model"
   | "version"
   | "probe"
   | "session"
@@ -48,6 +52,18 @@ const Steps: Record<GuardStepKey, GuardStep> = {
       return ProxyAuthenticator.ensure(session);
     },
   },
+  client: {
+    name: "client",
+    async execute(session) {
+      return ProxyClientGuard.ensure(session);
+    },
+  },
+  model: {
+    name: "model",
+    async execute(session) {
+      return ProxyModelGuard.ensure(session);
+    },
+  },
   version: {
     name: "version",
     async execute(session) {
@@ -140,6 +156,8 @@ export const CHAT_PIPELINE: GuardConfig = {
   // Full guard chain for normal chat requests
   steps: [
     "auth",
+    "client",
+    "model",
     "version",
     "probe",
     "session",
@@ -153,5 +171,5 @@ export const CHAT_PIPELINE: GuardConfig = {
 
 export const COUNT_TOKENS_PIPELINE: GuardConfig = {
   // Minimal chain for count_tokens: no session, no sensitive, no rate limit, no message logging
-  steps: ["auth", "version", "probe", "requestFilter", "provider"],
+  steps: ["auth", "client", "model", "version", "probe", "requestFilter", "provider"],
 };

+ 63 - 0
src/app/v1/_lib/proxy/model-guard.ts

@@ -0,0 +1,63 @@
+import { ProxyResponses } from "./responses";
+import type { ProxySession } from "./session";
+
+/**
+ * Model restriction guard
+ *
+ * Validates that the requested model is allowed based on user configuration.
+ * This check is ONLY performed when the user has configured model restrictions (allowedModels).
+ *
+ * Logic:
+ * - If allowedModels is empty or undefined: skip all checks, allow request
+ * - If allowedModels is non-empty:
+ *   - Missing or null model → 400 error
+ *   - Model doesn't match any allowed pattern (exact, case-insensitive) → 400 error
+ *   - Model matches at least one pattern → allow request
+ *
+ * Matching: case-insensitive exact match
+ */
+export class ProxyModelGuard {
+  static async ensure(session: ProxySession): Promise<Response | null> {
+    const user = session.authState?.user;
+    if (!user) {
+      // No user context - skip check (authentication should have failed already)
+      return null;
+    }
+
+    // Check if model restrictions are configured
+    const allowedModels = user.allowedModels ?? [];
+    if (allowedModels.length === 0) {
+      // No restrictions configured - skip all checks
+      return null;
+    }
+
+    // Restrictions exist - now model is required
+    const requestedModel = session.request.model;
+
+    // Missing or null model when restrictions exist
+    if (!requestedModel || requestedModel.trim() === "") {
+      return ProxyResponses.buildError(
+        400,
+        "Model not allowed. Model specification is required when model restrictions are configured.",
+        "invalid_request_error"
+      );
+    }
+
+    // Case-insensitive exact match
+    const requestedModelLower = requestedModel.toLowerCase();
+    const isAllowed = allowedModels.some(
+      (pattern) => pattern.toLowerCase() === requestedModelLower
+    );
+
+    if (!isAllowed) {
+      return ProxyResponses.buildError(
+        400,
+        `Model not allowed. The requested model '${requestedModel}' is not in the allowed list.`,
+        "invalid_request_error"
+      );
+    }
+
+    // Model is allowed
+    return null;
+  }
+}

+ 277 - 151
src/app/v1/_lib/proxy/rate-limit-guard.ts

@@ -5,12 +5,33 @@ import { ERROR_CODES, getErrorMessageServer } from "@/lib/utils/error-messages";
 import { RateLimitError } from "./errors";
 import type { ProxySession } from "./session";
 
+/**
+ * 通用的限额信息解析函数
+ * 从错误原因字符串中提取当前使用量和限制值
+ * 格式:(current/limit)
+ */
+function parseLimitInfo(reason: string): { currentUsage: number; limitValue: number } {
+  const match = reason.match(/(([\d.]+)\/([\d.]+))/);
+  const currentUsage = match ? parseFloat(match[1]) : 0;
+  const limitValue = match ? parseFloat(match[2]) : 0;
+  return { currentUsage, limitValue };
+}
+
 export class ProxyRateLimitGuard {
   /**
-   * 检查限流(用户层 + Key 层)
+   * 检查限流(Key 层 + User 层)
+   *
+   * 检查顺序(基于 Codex 专业分析):
+   * 1-2. 永久硬限制:Key 总限额 → User 总限额
+   * 3-4. 资源/频率保护:Key 并发 → User RPM
+   * 5-7. 短期周期限额:Key 5h → User 5h → User 每日
+   * 8-11. 中长期周期限额:Key 周 → User 周 → Key 月 → User 月
    *
-   * 改进:不再返回 Response,而是抛出 RateLimitError
-   * 让统一的 ProxyErrorHandler 处理错误响应和数据库日志
+   * 设计原则:
+   * - 硬上限优先于周期上限
+   * - 同一窗口内 Key → User 交替
+   * - 资源/频率保护足够靠前
+   * - 高触发概率窗口优先
    */
   static async ensure(session: ProxySession): Promise<void> {
     const user = session.authState?.user;
@@ -18,17 +39,117 @@ export class ProxyRateLimitGuard {
 
     if (!user || !key) return;
 
-    // ========== 用户层限流检查 ==========
+    // ========== 第一层:永久硬限制 ==========
+
+    // 1. Key 总限额(用户明确要求优先检查)
+    const keyTotalCheck = await RateLimitService.checkTotalCostLimit(
+      key.id,
+      "key",
+      key.limitTotalUsd ?? null,
+      key.key
+    );
+
+    if (!keyTotalCheck.allowed) {
+      logger.warn(`[RateLimit] Key total limit exceeded: key=${key.id}, ${keyTotalCheck.reason}`);
+
+      const { getLocale } = await import("next-intl/server");
+      const locale = await getLocale();
+      const message = await getErrorMessageServer(locale, ERROR_CODES.RATE_LIMIT_TOTAL_EXCEEDED, {
+        current: (keyTotalCheck.current || 0).toFixed(4),
+        limit: (key.limitTotalUsd || 0).toFixed(4),
+      });
+
+      const noReset = "9999-12-31T23:59:59.999Z";
+
+      throw new RateLimitError(
+        "rate_limit_error",
+        message,
+        "usd_total",
+        keyTotalCheck.current || 0,
+        key.limitTotalUsd || 0,
+        noReset,
+        null
+      );
+    }
+
+    // 2. User 总限额(账号级永久预算)
+    const userTotalCheck = await RateLimitService.checkTotalCostLimit(
+      user.id,
+      "user",
+      user.limitTotalUsd ?? null,
+      undefined
+    );
+
+    if (!userTotalCheck.allowed) {
+      logger.warn(
+        `[RateLimit] User total limit exceeded: user=${user.id}, ${userTotalCheck.reason}`
+      );
+
+      const { getLocale } = await import("next-intl/server");
+      const locale = await getLocale();
+      const message = await getErrorMessageServer(locale, ERROR_CODES.RATE_LIMIT_TOTAL_EXCEEDED, {
+        current: (userTotalCheck.current || 0).toFixed(4),
+        limit: (user.limitTotalUsd || 0).toFixed(4),
+      });
+
+      const noReset = "9999-12-31T23:59:59.999Z";
+
+      throw new RateLimitError(
+        "rate_limit_error",
+        message,
+        "usd_total",
+        userTotalCheck.current || 0,
+        user.limitTotalUsd || 0,
+        noReset,
+        null
+      );
+    }
 
-    // 1. 检查用户 RPM 限制
+    // ========== 第二层:资源/频率保护 ==========
+
+    // 3. Key 并发 Session(避免创建上游连接)
+    const sessionCheck = await RateLimitService.checkSessionLimit(
+      key.id,
+      "key",
+      key.limitConcurrentSessions || 0
+    );
+
+    if (!sessionCheck.allowed) {
+      logger.warn(`[RateLimit] Key session limit exceeded: key=${key.id}, ${sessionCheck.reason}`);
+
+      const { currentUsage, limitValue } = parseLimitInfo(sessionCheck.reason!);
+
+      const resetTime = new Date().toISOString();
+
+      const { getLocale } = await import("next-intl/server");
+      const locale = await getLocale();
+      const message = await getErrorMessageServer(
+        locale,
+        ERROR_CODES.RATE_LIMIT_CONCURRENT_SESSIONS_EXCEEDED,
+        {
+          current: String(currentUsage),
+          limit: String(limitValue),
+        }
+      );
+
+      throw new RateLimitError(
+        "rate_limit_error",
+        message,
+        "concurrent_sessions",
+        currentUsage,
+        limitValue,
+        resetTime,
+        null
+      );
+    }
+
+    // 4. User RPM(频率闸门,挡住高频噪声)
     const rpmCheck = await RateLimitService.checkUserRPM(user.id, user.rpm);
     if (!rpmCheck.allowed) {
       logger.warn(`[RateLimit] User RPM exceeded: user=${user.id}, ${rpmCheck.reason}`);
 
-      // 计算重置时间(下一分钟开始)
       const resetTime = new Date(Date.now() + 60 * 1000).toISOString();
 
-      // 获取国际化错误消息
       const { getLocale } = await import("next-intl/server");
       const locale = await getLocale();
       const message = await getErrorMessageServer(locale, ERROR_CODES.RATE_LIMIT_RPM_EXCEEDED, {
@@ -48,15 +169,81 @@ export class ProxyRateLimitGuard {
       );
     }
 
-    // 2. 检查用户每日额度
+    // ========== 第三层:短期周期限额(混合检查)==========
+
+    // 5. Key 5h 限额(最短周期,最易触发)
+    const key5hCheck = await RateLimitService.checkCostLimits(key.id, "key", {
+      limit_5h_usd: key.limit5hUsd,
+      limit_daily_usd: null, // 仅检查 5h
+      limit_weekly_usd: null,
+      limit_monthly_usd: null,
+    });
+
+    if (!key5hCheck.allowed) {
+      logger.warn(`[RateLimit] Key 5h limit exceeded: key=${key.id}, ${key5hCheck.reason}`);
+
+      const { currentUsage, limitValue } = parseLimitInfo(key5hCheck.reason!);
+      const resetTime = new Date(Date.now() + 5 * 60 * 60 * 1000).toISOString();
+
+      const { getLocale } = await import("next-intl/server");
+      const locale = await getLocale();
+      const message = await getErrorMessageServer(locale, ERROR_CODES.RATE_LIMIT_5H_EXCEEDED, {
+        current: currentUsage.toFixed(4),
+        limit: limitValue.toFixed(4),
+        resetTime,
+      });
+
+      throw new RateLimitError(
+        "rate_limit_error",
+        message,
+        "usd_5h",
+        currentUsage,
+        limitValue,
+        resetTime,
+        null
+      );
+    }
+
+    // 6. User 5h 限额(防止多 Key 合力在短窗口打爆用户)
+    const user5hCheck = await RateLimitService.checkCostLimits(user.id, "user", {
+      limit_5h_usd: user.limit5hUsd ?? null,
+      limit_daily_usd: null,
+      limit_weekly_usd: null,
+      limit_monthly_usd: null,
+    });
+
+    if (!user5hCheck.allowed) {
+      logger.warn(`[RateLimit] User 5h limit exceeded: user=${user.id}, ${user5hCheck.reason}`);
+
+      const { currentUsage, limitValue } = parseLimitInfo(user5hCheck.reason!);
+      const resetTime = new Date(Date.now() + 5 * 60 * 60 * 1000).toISOString();
+
+      const { getLocale } = await import("next-intl/server");
+      const locale = await getLocale();
+      const message = await getErrorMessageServer(locale, ERROR_CODES.RATE_LIMIT_5H_EXCEEDED, {
+        current: currentUsage.toFixed(4),
+        limit: limitValue.toFixed(4),
+        resetTime,
+      });
+
+      throw new RateLimitError(
+        "rate_limit_error",
+        message,
+        "usd_5h",
+        currentUsage,
+        limitValue,
+        resetTime,
+        null
+      );
+    }
+
+    // 7. User 每日额度(User 独有的常用预算)
     const dailyCheck = await RateLimitService.checkUserDailyCost(user.id, user.dailyQuota);
     if (!dailyCheck.allowed) {
       logger.warn(`[RateLimit] User daily limit exceeded: user=${user.id}, ${dailyCheck.reason}`);
 
-      // 计算重置时间(明天 00:00)
       const resetTime = getDailyResetTime().toISOString();
 
-      // 获取国际化错误消息
       const { getLocale } = await import("next-intl/server");
       const locale = await getLocale();
       const message = await getErrorMessageServer(
@@ -80,69 +267,62 @@ export class ProxyRateLimitGuard {
       );
     }
 
-    // 3. 检查用户总消费限额(无重置时间)
-    const userTotalCheck = await RateLimitService.checkTotalCostLimit(
-      user.id,
-      "user",
-      user.limitTotalUsd ?? null,
-      undefined
-    );
+    // ========== 第四层:中长期周期限额(混合检查)==========
 
-    if (!userTotalCheck.allowed) {
-      logger.warn(
-        `[RateLimit] User total limit exceeded: user=${user.id}, ${userTotalCheck.reason}`
-      );
+    // 8. Key 周限额
+    const keyWeeklyCheck = await RateLimitService.checkCostLimits(key.id, "key", {
+      limit_5h_usd: null,
+      limit_daily_usd: null,
+      limit_weekly_usd: key.limitWeeklyUsd,
+      limit_monthly_usd: null,
+    });
+
+    if (!keyWeeklyCheck.allowed) {
+      logger.warn(`[RateLimit] Key weekly limit exceeded: key=${key.id}, ${keyWeeklyCheck.reason}`);
+
+      const { currentUsage, limitValue } = parseLimitInfo(keyWeeklyCheck.reason!);
+      const resetInfo = getResetInfo("weekly");
+      const resetTime = resetInfo.resetAt?.toISOString() || new Date().toISOString();
 
       const { getLocale } = await import("next-intl/server");
       const locale = await getLocale();
-      const message = await getErrorMessageServer(locale, ERROR_CODES.RATE_LIMIT_TOTAL_EXCEEDED, {
-        current: (userTotalCheck.current || 0).toFixed(4),
-        limit: (user.limitTotalUsd || 0).toFixed(4),
+      const message = await getErrorMessageServer(locale, ERROR_CODES.RATE_LIMIT_WEEKLY_EXCEEDED, {
+        current: currentUsage.toFixed(4),
+        limit: limitValue.toFixed(4),
+        resetTime,
       });
 
-      const noReset = "9999-12-31T23:59:59.999Z";
-
       throw new RateLimitError(
         "rate_limit_error",
         message,
-        "usd_total",
-        userTotalCheck.current || 0,
-        user.limitTotalUsd || 0,
-        noReset,
+        "usd_weekly",
+        currentUsage,
+        limitValue,
+        resetTime,
         null
       );
     }
 
-    // ========== Key 层限流检查 ==========
-
-    // 4. 检查 Key 金额限制
-    const costCheck = await RateLimitService.checkCostLimits(key.id, "key", {
-      limit_5h_usd: key.limit5hUsd,
-      limit_daily_usd: key.limitDailyUsd,
-      daily_reset_mode: key.dailyResetMode,
-      daily_reset_time: key.dailyResetTime,
-      limit_weekly_usd: key.limitWeeklyUsd,
-      limit_monthly_usd: key.limitMonthlyUsd,
+    // 9. User 周限额
+    const userWeeklyCheck = await RateLimitService.checkCostLimits(user.id, "user", {
+      limit_5h_usd: null,
+      limit_daily_usd: null,
+      limit_weekly_usd: user.limitWeeklyUsd ?? null,
+      limit_monthly_usd: null,
     });
 
-    if (!costCheck.allowed) {
-      logger.warn(`[RateLimit] Key cost limit exceeded: key=${key.id}, ${costCheck.reason}`);
+    if (!userWeeklyCheck.allowed) {
+      logger.warn(
+        `[RateLimit] User weekly limit exceeded: user=${user.id}, ${userWeeklyCheck.reason}`
+      );
 
-      // 解析限流类型、当前使用量、限制值和重置时间
-      const { limitType, currentUsage, limitValue, resetTime } =
-        ProxyRateLimitGuard.parseCostLimitInfo(costCheck.reason!);
+      const { currentUsage, limitValue } = parseLimitInfo(userWeeklyCheck.reason!);
+      const resetInfo = getResetInfo("weekly");
+      const resetTime = resetInfo.resetAt?.toISOString() || new Date().toISOString();
 
-      // 获取国际化错误消息
       const { getLocale } = await import("next-intl/server");
       const locale = await getLocale();
-      const errorCode =
-        limitType === "usd_5h"
-          ? ERROR_CODES.RATE_LIMIT_5H_EXCEEDED
-          : limitType === "usd_weekly"
-            ? ERROR_CODES.RATE_LIMIT_WEEKLY_EXCEEDED
-            : ERROR_CODES.RATE_LIMIT_MONTHLY_EXCEEDED;
-
-      const message = await getErrorMessageServer(locale, errorCode, {
+      const message = await getErrorMessageServer(locale, ERROR_CODES.RATE_LIMIT_WEEKLY_EXCEEDED, {
         current: currentUsage.toFixed(4),
         limit: limitValue.toFixed(4),
         resetTime,
@@ -151,7 +331,7 @@ export class ProxyRateLimitGuard {
       throw new RateLimitError(
         "rate_limit_error",
         message,
-        limitType,
+        "usd_weekly",
         currentUsage,
         limitValue,
         resetTime,
@@ -159,71 +339,71 @@ export class ProxyRateLimitGuard {
       );
     }
 
-    // 5. 检查 Key 总消费限额
-    const keyTotalCheck = await RateLimitService.checkTotalCostLimit(
-      key.id,
-      "key",
-      key.limitTotalUsd ?? null,
-      key.key
-    );
+    // 10. Key 月限额
+    const keyMonthlyCheck = await RateLimitService.checkCostLimits(key.id, "key", {
+      limit_5h_usd: null,
+      limit_daily_usd: null,
+      limit_weekly_usd: null,
+      limit_monthly_usd: key.limitMonthlyUsd,
+    });
 
-    if (!keyTotalCheck.allowed) {
-      logger.warn(`[RateLimit] Key total limit exceeded: key=${key.id}, ${keyTotalCheck.reason}`);
+    if (!keyMonthlyCheck.allowed) {
+      logger.warn(
+        `[RateLimit] Key monthly limit exceeded: key=${key.id}, ${keyMonthlyCheck.reason}`
+      );
+
+      const { currentUsage, limitValue } = parseLimitInfo(keyMonthlyCheck.reason!);
+      const resetInfo = getResetInfo("monthly");
+      const resetTime = resetInfo.resetAt?.toISOString() || new Date().toISOString();
 
       const { getLocale } = await import("next-intl/server");
       const locale = await getLocale();
-      const message = await getErrorMessageServer(locale, ERROR_CODES.RATE_LIMIT_TOTAL_EXCEEDED, {
-        current: (keyTotalCheck.current || 0).toFixed(4),
-        limit: (key.limitTotalUsd || 0).toFixed(4),
+      const message = await getErrorMessageServer(locale, ERROR_CODES.RATE_LIMIT_MONTHLY_EXCEEDED, {
+        current: currentUsage.toFixed(4),
+        limit: limitValue.toFixed(4),
+        resetTime,
       });
 
-      const noReset = "9999-12-31T23:59:59.999Z";
-
       throw new RateLimitError(
         "rate_limit_error",
         message,
-        "usd_total",
-        keyTotalCheck.current || 0,
-        key.limitTotalUsd || 0,
-        noReset,
+        "usd_monthly",
+        currentUsage,
+        limitValue,
+        resetTime,
         null
       );
     }
 
-    // 6. 检查 Key 并发 Session 限制
-    const sessionCheck = await RateLimitService.checkSessionLimit(
-      key.id,
-      "key",
-      key.limitConcurrentSessions || 0
-    );
-
-    if (!sessionCheck.allowed) {
-      logger.warn(`[RateLimit] Key session limit exceeded: key=${key.id}, ${sessionCheck.reason}`);
+    // 11. User 月限额(最后一道长期预算闸门)
+    const userMonthlyCheck = await RateLimitService.checkCostLimits(user.id, "user", {
+      limit_5h_usd: null,
+      limit_daily_usd: null,
+      limit_weekly_usd: null,
+      limit_monthly_usd: user.limitMonthlyUsd ?? null,
+    });
 
-      // 解析当前并发数和限制值
-      const { currentUsage, limitValue } = ProxyRateLimitGuard.parseSessionLimitInfo(
-        sessionCheck.reason!
+    if (!userMonthlyCheck.allowed) {
+      logger.warn(
+        `[RateLimit] User monthly limit exceeded: user=${user.id}, ${userMonthlyCheck.reason}`
       );
 
-      // 并发限制没有固定的重置时间,使用当前时间
-      const resetTime = new Date().toISOString();
+      const { currentUsage, limitValue } = parseLimitInfo(userMonthlyCheck.reason!);
+      const resetInfo = getResetInfo("monthly");
+      const resetTime = resetInfo.resetAt?.toISOString() || new Date().toISOString();
 
-      // 获取国际化错误消息
       const { getLocale } = await import("next-intl/server");
       const locale = await getLocale();
-      const message = await getErrorMessageServer(
-        locale,
-        ERROR_CODES.RATE_LIMIT_CONCURRENT_SESSIONS_EXCEEDED,
-        {
-          current: String(currentUsage),
-          limit: String(limitValue),
-        }
-      );
+      const message = await getErrorMessageServer(locale, ERROR_CODES.RATE_LIMIT_MONTHLY_EXCEEDED, {
+        current: currentUsage.toFixed(4),
+        limit: limitValue.toFixed(4),
+        resetTime,
+      });
 
       throw new RateLimitError(
         "rate_limit_error",
         message,
-        "concurrent_sessions",
+        "usd_monthly",
         currentUsage,
         limitValue,
         resetTime,
@@ -231,58 +411,4 @@ export class ProxyRateLimitGuard {
       );
     }
   }
-
-  /**
-   * 从 reason 字符串解析限流类型、使用量和重置时间
-   *
-   * 示例 reason:
-   * - "Key 5小时消费上限已达到(15.2000/15)"
-   * - "Key 周消费上限已达到(150.0000/150)"
-   * - "Key 月消费上限已达到(500.0000/500)"
-   */
-  private static parseCostLimitInfo(reason: string): {
-    limitType: "usd_5h" | "usd_weekly" | "usd_monthly";
-    currentUsage: number;
-    limitValue: number;
-    resetTime: string;
-  } {
-    // 解析格式:(current/limit)
-    const match = reason.match(/(([\d.]+)\/([\d.]+))/);
-    const currentUsage = match ? parseFloat(match[1]) : 0;
-    const limitValue = match ? parseFloat(match[2]) : 0;
-
-    if (reason.includes("5小时")) {
-      // 5小时滚动窗口:重置时间是 5 小时后
-      const resetTime = new Date(Date.now() + 5 * 60 * 60 * 1000).toISOString();
-      return { limitType: "usd_5h", currentUsage, limitValue, resetTime };
-    } else if (reason.includes("周")) {
-      // 自然周:重置时间是下周一 00:00
-      const resetInfo = getResetInfo("weekly");
-      const resetTime = resetInfo.resetAt?.toISOString() || new Date().toISOString();
-      return { limitType: "usd_weekly", currentUsage, limitValue, resetTime };
-    } else {
-      // 自然月:重置时间是下月 1 号 00:00
-      const resetInfo = getResetInfo("monthly");
-      const resetTime = resetInfo.resetAt?.toISOString() || new Date().toISOString();
-      return { limitType: "usd_monthly", currentUsage, limitValue, resetTime };
-    }
-  }
-
-  /**
-   * 从 reason 字符串解析并发限制信息
-   *
-   * 示例 reason:
-   * - "Key并发 Session 上限已达到(5/5)"
-   */
-  private static parseSessionLimitInfo(reason: string): {
-    currentUsage: number;
-    limitValue: number;
-  } {
-    // 解析格式:(current/limit)
-    const match = reason.match(/(([\d.]+)\/([\d.]+))/);
-    const currentUsage = match ? parseFloat(match[1]) : 0;
-    const limitValue = match ? parseFloat(match[2]) : 0;
-
-    return { currentUsage, limitValue };
-  }
 }

+ 35 - 4
src/app/v1/_lib/proxy/response-handler.ts

@@ -32,6 +32,28 @@ export type UsageMetrics = {
   cache_read_input_tokens?: number;
 };
 
+/**
+ * 清理 Response headers 中的传输相关 header
+ *
+ * 原因:Bun 的 Response API 在接收 ReadableStream 或修改后的 body 时,
+ * 会自动添加 Transfer-Encoding: chunked 和 Content-Length,
+ * 如果不清理原始 headers 中的这些字段,会导致重复 header 错误。
+ *
+ * Node.js 运行时会智能去重,但 Bun 不会,所以需要手动清理。
+ *
+ * @param headers - 原始响应 headers
+ * @returns 清理后的 headers
+ */
+function cleanResponseHeaders(headers: Headers): Headers {
+  const cleaned = new Headers(headers);
+
+  // 删除传输相关 headers,让 Response API 自动管理
+  cleaned.delete("transfer-encoding"); // Bun 会根据 body 类型自动添加
+  cleaned.delete("content-length"); // body 改变后长度无效,Response API 会重新计算
+
+  return cleaned;
+}
+
 export class ProxyResponseHandler {
   static async dispatch(session: ProxySession, response: Response): Promise<Response> {
     const contentType = response.headers.get("content-type") || "";
@@ -152,10 +174,11 @@ export class ProxyResponseHandler {
             }
           );
 
+          // ⭐ 清理传输 headers(body 已从流转为 JSON 字符串)
           finalResponse = new Response(JSON.stringify(transformed), {
             status: response.status,
             statusText: response.statusText,
-            headers: new Headers(response.headers),
+            headers: cleanResponseHeaders(response.headers),
           });
         } catch (error) {
           logger.error("[ResponseHandler] Failed to transform Gemini non-stream response:", error);
@@ -186,11 +209,12 @@ export class ProxyResponseHandler {
           model: session.request.model,
         });
 
+        // ⭐ 清理传输 headers(body 已修改,原始传输信息无效)
         // 构建新的响应
         finalResponse = new Response(JSON.stringify(transformed), {
           status: response.status,
           statusText: response.statusText,
-          headers: new Headers(response.headers),
+          headers: cleanResponseHeaders(response.headers),
         });
       } catch (error) {
         logger.error("[ResponseHandler] Failed to transform response:", error);
@@ -1177,10 +1201,12 @@ export class ProxyResponseHandler {
       });
     }
 
+    // ⭐ 修复 Bun 运行时的 Transfer-Encoding 重复问题
+    // 清理上游的传输 headers,让 Response API 自动管理
     return new Response(clientStream, {
       status: response.status,
       statusText: response.statusText,
-      headers: new Headers(response.headers),
+      headers: cleanResponseHeaders(response.headers),
     });
   }
 }
@@ -1769,7 +1795,12 @@ async function trackCostToRedis(session: ProxySession, usage: UsageMetrics | nul
   );
 
   // 新增:追踪用户层每日消费
-  await RateLimitService.trackUserDailyCost(user.id, costFloat);
+  await RateLimitService.trackUserDailyCost(
+    user.id,
+    costFloat,
+    user.dailyResetTime,
+    user.dailyResetMode
+  );
 
   // 刷新 session 时间戳(滑动窗口)
   void SessionTracker.refreshSession(session.sessionId, key.id, provider.id).catch((error) => {

+ 48 - 7
src/app/v1/_lib/proxy/responses.ts

@@ -6,26 +6,25 @@ export class ProxyResponses {
     details?: Record<string, unknown>,
     requestId?: string
   ): Response {
+    const defaultType = ProxyResponses.getErrorType(status);
+    const finalType = errorType || defaultType;
+
     const payload: {
       error: {
         message: string;
         type: string;
-        code?: string;
+        code: string;
         details?: Record<string, unknown>;
       };
       request_id?: string;
     } = {
       error: {
         message,
-        type: errorType || ProxyResponses.getErrorType(status),
+        type: finalType,
+        code: ProxyResponses.getErrorCode(status, finalType),
       },
     };
 
-    // 添加错误代码(用于前端识别)
-    if (errorType) {
-      payload.error.code = errorType;
-    }
-
     // 添加详细信息(可选)
     if (details) {
       payload.error.details = details;
@@ -53,6 +52,8 @@ export class ProxyResponses {
         return "invalid_request_error";
       case 401:
         return "authentication_error";
+      case 402:
+        return "payment_required_error";
       case 403:
         return "permission_error";
       case 404:
@@ -71,4 +72,44 @@ export class ProxyResponses {
         return "api_error";
     }
   }
+
+  /**
+   * 根据 HTTP 状态码和错误类型生成错误代码
+   *
+   * 设计原则:
+   * - code 字段用于客户端快速识别错误类型
+   * - 优先使用 type,如果没有则根据状态码生成
+   */
+  private static getErrorCode(status: number, type: string): string {
+    // 特殊类型直接使用 type 作为 code
+    if (type && type !== "api_error") {
+      return type;
+    }
+
+    // 回退到状态码
+    switch (status) {
+      case 400:
+        return "invalid_request";
+      case 401:
+        return "unauthorized";
+      case 402:
+        return "payment_required";
+      case 403:
+        return "forbidden";
+      case 404:
+        return "not_found";
+      case 429:
+        return "rate_limit_exceeded";
+      case 500:
+        return "internal_error";
+      case 502:
+        return "bad_gateway";
+      case 503:
+        return "service_unavailable";
+      case 504:
+        return "gateway_timeout";
+      default:
+        return `http_${status}`;
+    }
+  }
 }

+ 17 - 1
src/components/form/date-picker-field.tsx

@@ -2,7 +2,8 @@
 
 import { format } from "date-fns";
 import { CalendarIcon } from "lucide-react";
-import { useId, useMemo, useState } from "react";
+import { useTranslations } from "next-intl";
+import { useCallback, useId, useMemo, useState } from "react";
 import { Button } from "@/components/ui/button";
 import { Calendar } from "@/components/ui/calendar";
 import { Label } from "@/components/ui/label";
@@ -16,6 +17,7 @@ export interface DatePickerFieldProps {
   label: string;
   value: string;
   onChange: (value: string) => void;
+  clearLabel?: string;
   error?: string;
   touched?: boolean;
   required?: boolean;
@@ -54,6 +56,7 @@ export function DatePickerField({
   label,
   value,
   onChange,
+  clearLabel,
   error,
   touched,
   required,
@@ -65,6 +68,7 @@ export function DatePickerField({
   className,
   id,
 }: DatePickerFieldProps) {
+  const tCommon = useTranslations("common");
   const [open, setOpen] = useState(false);
   const hasError = Boolean(touched && error);
   const autoId = useId();
@@ -72,6 +76,11 @@ export function DatePickerField({
 
   const selectedDate = useMemo(() => parseDate(value), [value]);
 
+  const handleClear = useCallback(() => {
+    onChange("");
+    setOpen(false);
+  }, [onChange]);
+
   const handleSelect = (date: Date | undefined) => {
     if (date) {
       onChange(formatDate(date));
@@ -131,6 +140,13 @@ export function DatePickerField({
             defaultMonth={selectedDate || new Date()}
             disabled={disabledMatcher}
           />
+          {value && (
+            <div className="border-t p-2">
+              <Button variant="ghost" size="sm" className="w-full" onClick={handleClear}>
+                {clearLabel || tCommon("clearDate")}
+              </Button>
+            </div>
+          )}
         </PopoverContent>
       </Popover>
       {description && !hasError && (

+ 6 - 3
src/components/form/form-field.tsx

@@ -38,6 +38,7 @@ export interface FormFieldProps extends Omit<ComponentProps<typeof Input>, "valu
   touched?: boolean;
   required?: boolean;
   description?: string;
+  helperText?: string;
 }
 
 export function FormField({
@@ -48,12 +49,14 @@ export function FormField({
   touched,
   required,
   description,
+  helperText,
   className,
   ...inputProps
 }: FormFieldProps) {
   const hasError = Boolean(touched && error);
   const autoId = useId();
   const fieldId = inputProps.id || `field-${autoId}`;
+  const fieldDescription = helperText ?? description;
 
   return (
     <div className="grid gap-2">
@@ -74,12 +77,12 @@ export function FormField({
         )}
         aria-invalid={hasError}
         aria-describedby={
-          hasError ? `${fieldId}-error` : description ? `${fieldId}-description` : undefined
+          hasError ? `${fieldId}-error` : fieldDescription ? `${fieldId}-description` : undefined
         }
       />
-      {description && !hasError && (
+      {fieldDescription && !hasError && (
         <div id={`${fieldId}-description`} className="text-xs text-muted-foreground">
-          {description}
+          {fieldDescription}
         </div>
       )}
       {hasError && (

+ 71 - 30
src/components/ui/tag-input.tsx

@@ -5,6 +5,17 @@ import * as React from "react";
 import { cn } from "@/lib/utils";
 import { Badge } from "./badge";
 
+export type TagInputSuggestion =
+  | string
+  | {
+      /** Tag value that will be added to `value[]` */
+      value: string;
+      /** Text shown in the suggestions dropdown */
+      label: string;
+      /** Optional extra keywords to improve search matching */
+      keywords?: string[];
+    };
+
 export interface TagInputProps extends Omit<React.ComponentProps<"input">, "value" | "onChange"> {
   value: string[];
   onChange: (tags: string[]) => void;
@@ -18,7 +29,7 @@ export interface TagInputProps extends Omit<React.ComponentProps<"input">, "valu
   validateTag?: (tag: string) => boolean;
   onInvalidTag?: (tag: string, reason: string) => void;
   /** 可选的建议列表,支持下拉搜索选择 */
-  suggestions?: string[];
+  suggestions?: TagInputSuggestion[];
 }
 
 const DEFAULT_SEPARATOR = /[,,\n]/; // 逗号、中文逗号、换行符
@@ -45,18 +56,25 @@ export function TagInput({
   const inputRef = React.useRef<HTMLInputElement>(null);
   const containerRef = React.useRef<HTMLDivElement>(null);
 
+  // Normalize suggestions so callers can provide either strings or { value, label } objects.
+  const normalizedSuggestions = React.useMemo(() => {
+    return suggestions.map((s) => (typeof s === "string" ? { value: s, label: s } : s));
+  }, [suggestions]);
+
   // 过滤建议列表:匹配输入值且未被选中
   const filteredSuggestions = React.useMemo(() => {
-    if (!suggestions.length) return [];
+    if (!normalizedSuggestions.length) return [];
     const search = inputValue.toLowerCase();
-    return suggestions.filter(
-      (s) => s.toLowerCase().includes(search) && (allowDuplicates || !value.includes(s))
-    );
-  }, [suggestions, inputValue, value, allowDuplicates]);
+    return normalizedSuggestions.filter((s) => {
+      const keywords = s.keywords?.join(" ") || "";
+      const haystack = `${s.label} ${s.value} ${keywords}`.toLowerCase();
+      return haystack.includes(search) && (allowDuplicates || !value.includes(s.value));
+    });
+  }, [normalizedSuggestions, inputValue, value, allowDuplicates]);
 
   // 默认验证函数
   const defaultValidateTag = React.useCallback(
-    (tag: string): boolean => {
+    (tag: string, currentTags: string[]): boolean => {
       if (!tag || tag.trim().length === 0) {
         onInvalidTag?.(tag, "empty");
         return false;
@@ -72,27 +90,33 @@ export function TagInput({
         return false;
       }
 
-      if (!allowDuplicates && value.includes(tag)) {
+      if (!allowDuplicates && currentTags.includes(tag)) {
         onInvalidTag?.(tag, "duplicate");
         return false;
       }
 
-      if (maxTags && value.length >= maxTags) {
+      if (maxTags && currentTags.length >= maxTags) {
         onInvalidTag?.(tag, "max_tags");
         return false;
       }
 
       return true;
     },
-    [value, maxTags, maxTagLength, allowDuplicates, onInvalidTag]
+    [maxTags, maxTagLength, allowDuplicates, onInvalidTag]
   );
 
-  const handleValidateTag = validateTag || defaultValidateTag;
+  const handleValidateTag = React.useCallback(
+    (tag: string, currentTags: string[]): boolean => {
+      if (validateTag) return validateTag(tag);
+      return defaultValidateTag(tag, currentTags);
+    },
+    [validateTag, defaultValidateTag]
+  );
 
   const addTag = React.useCallback(
     (tag: string) => {
       const trimmedTag = tag.trim();
-      if (handleValidateTag(trimmedTag)) {
+      if (handleValidateTag(trimmedTag, value)) {
         onChange([...value, trimmedTag]);
         setInputValue("");
         setShowSuggestions(false);
@@ -102,6 +126,31 @@ export function TagInput({
     [value, onChange, handleValidateTag]
   );
 
+  const addTagsBatch = React.useCallback(
+    (tags: string[]) => {
+      const nextTags = [...value];
+      let didChange = false;
+
+      for (const rawTag of tags) {
+        const trimmedTag = rawTag.trim();
+        if (!trimmedTag) continue;
+
+        if (handleValidateTag(trimmedTag, nextTags)) {
+          nextTags.push(trimmedTag);
+          didChange = true;
+        }
+      }
+
+      if (!didChange) return;
+
+      onChange(nextTags);
+      setInputValue("");
+      setShowSuggestions(false);
+      setHighlightedIndex(-1);
+    },
+    [value, onChange, handleValidateTag]
+  );
+
   const removeTag = React.useCallback(
     (indexToRemove: number) => {
       onChange(value.filter((_, index) => index !== indexToRemove));
@@ -125,7 +174,7 @@ export function TagInput({
         }
         if (e.key === "Enter" && highlightedIndex >= 0) {
           e.preventDefault();
-          addTag(filteredSuggestions[highlightedIndex]);
+          addTag(filteredSuggestions[highlightedIndex].value);
           return;
         }
         if (e.key === "Escape") {
@@ -155,11 +204,7 @@ export function TagInput({
       // 检测分隔符(逗号、换行符等)
       if (separator.test(newValue)) {
         const tags = newValue.split(separator).filter((t) => t.trim());
-        tags.forEach((tag) => {
-          if (tag.trim()) {
-            addTag(tag);
-          }
-        });
+        addTagsBatch(tags);
       } else {
         setInputValue(newValue);
         // 有建议列表时,输入触发显示
@@ -169,7 +214,7 @@ export function TagInput({
         }
       }
     },
-    [separator, addTag, suggestions.length]
+    [separator, addTagsBatch, suggestions.length]
   );
 
   const handlePaste = React.useCallback(
@@ -178,13 +223,9 @@ export function TagInput({
       const pastedText = e.clipboardData.getData("text");
       const tags = pastedText.split(separator).filter((t) => t.trim());
 
-      tags.forEach((tag) => {
-        if (tag.trim()) {
-          addTag(tag);
-        }
-      });
+      addTagsBatch(tags);
     },
-    [separator, addTag]
+    [separator, addTagsBatch]
   );
 
   // Commit pending input value on blur (e.g., when clicking save button)
@@ -212,8 +253,8 @@ export function TagInput({
   }, [suggestions.length]);
 
   const handleSuggestionClick = React.useCallback(
-    (suggestion: string) => {
-      addTag(suggestion);
+    (suggestionValue: string) => {
+      addTag(suggestionValue);
       inputRef.current?.focus();
     },
     [addTag]
@@ -276,7 +317,7 @@ export function TagInput({
         <div className="absolute z-50 mt-1 w-full rounded-md border bg-popover shadow-md max-h-48 overflow-auto">
           {filteredSuggestions.map((suggestion, index) => (
             <button
-              key={suggestion}
+              key={suggestion.value}
               type="button"
               className={cn(
                 "w-full px-3 py-2 text-left text-sm hover:bg-accent hover:text-accent-foreground",
@@ -284,11 +325,11 @@ export function TagInput({
               )}
               onMouseDown={(e) => {
                 e.preventDefault(); // 阻止 blur 事件
-                handleSuggestionClick(suggestion);
+                handleSuggestionClick(suggestion.value);
               }}
               onMouseEnter={() => setHighlightedIndex(index)}
             >
-              {suggestion}
+              {suggestion.label}
             </button>
           ))}
         </div>

+ 16 - 0
src/drizzle/schema.ts

@@ -36,10 +36,26 @@ export const users = pgTable('users', {
   limitTotalUsd: numeric('limit_total_usd', { precision: 10, scale: 2 }),
   limitConcurrentSessions: integer('limit_concurrent_sessions'),
 
+  // Daily quota reset mode (fixed: reset at specific time, rolling: 24h window)
+  dailyResetMode: dailyResetModeEnum('daily_reset_mode')
+    .default('fixed')
+    .notNull(),
+  dailyResetTime: varchar('daily_reset_time', { length: 5 })
+    .default('00:00')
+    .notNull(), // HH:mm format, only used in 'fixed' mode
+
   // User status and expiry management
   isEnabled: boolean('is_enabled').notNull().default(true),
   expiresAt: timestamp('expires_at', { withTimezone: true }),
 
+  // Allowed clients (CLI/IDE restrictions)
+  // Empty array = no restrictions, non-empty = only listed patterns allowed
+  allowedClients: jsonb('allowed_clients').$type<string[]>().default([]),
+
+  // Allowed models (AI model restrictions)
+  // Empty array = no restrictions, non-empty = only listed models allowed
+  allowedModels: jsonb('allowed_models').$type<string[]>().default([]),
+
   createdAt: timestamp('created_at', { withTimezone: true }).defaultNow(),
   updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow(),
   deletedAt: timestamp('deleted_at', { withTimezone: true }),

+ 136 - 54
src/lib/api/action-adapter-openapi.ts

@@ -73,68 +73,136 @@ export interface ActionRouteOptions {
       value: unknown;
     }
   >;
+
+  /**
+   * 参数映射函数(用于多参数 action)
+   * 将请求体转换为 action 函数的参数数组
+   *
+   * @example
+   * // 对于 editUser(userId: number, data: UpdateUserData)
+   * argsMapper: (body) => [body.userId, body.data]
+   */
+  argsMapper?: (body: any) => unknown[];
 }
 
 /**
  * 统一的响应 schemas
  */
-const createResponseSchemas = (dataSchema?: z.ZodSchema) => ({
-  200: {
-    description: "操作成功",
-    content: {
-      "application/json": {
-        schema: z.object({
-          ok: z.literal(true),
-          data: dataSchema || z.any().optional(),
-        }),
+const createResponseSchemas = (dataSchema?: z.ZodSchema) => {
+  // 错误响应的完整 schema(包含 errorCode 和 errorParams)
+  const errorSchema = z.object({
+    ok: z.literal(false),
+    error: z.string().describe("错误消息(向后兼容)"),
+    errorCode: z.string().optional().describe("错误码(推荐用于国际化)"),
+    errorParams: z
+      .record(z.string(), z.union([z.string(), z.number()]))
+      .optional()
+      .describe("错误消息插值参数"),
+  });
+
+  return {
+    200: {
+      description: "操作成功",
+      content: {
+        "application/json": {
+          schema: z.object({
+            ok: z.literal(true),
+            data: dataSchema || z.any().optional(),
+          }),
+        },
       },
     },
-  },
-  400: {
-    description: "请求错误 (参数验证失败或业务逻辑错误)",
-    content: {
-      "application/json": {
-        schema: z.object({
-          ok: z.literal(false),
-          error: z.string().describe("错误消息"),
-        }),
+    400: {
+      description: "请求错误 (参数验证失败或业务逻辑错误)",
+      content: {
+        "application/json": {
+          schema: errorSchema,
+          examples: {
+            validation: {
+              summary: "参数验证失败",
+              value: {
+                ok: false,
+                error: "用户名不能为空",
+                errorCode: "VALIDATION_ERROR",
+                errorParams: { field: "name" },
+              },
+            },
+            business: {
+              summary: "业务逻辑错误",
+              value: {
+                ok: false,
+                error: "用户名已存在",
+                errorCode: "USER_ALREADY_EXISTS",
+              },
+            },
+          },
+        },
       },
     },
-  },
-  401: {
-    description: "未认证 (需要登录)",
-    content: {
-      "application/json": {
-        schema: z.object({
-          ok: z.literal(false),
-          error: z.string().describe("错误消息"),
-        }),
+    401: {
+      description: "未认证 (需要登录)",
+      content: {
+        "application/json": {
+          schema: errorSchema,
+          examples: {
+            missing: {
+              summary: "缺少认证信息",
+              value: {
+                ok: false,
+                error: "未认证",
+                errorCode: "AUTH_MISSING",
+              },
+            },
+            invalid: {
+              summary: "认证信息无效",
+              value: {
+                ok: false,
+                error: "认证无效或已过期",
+                errorCode: "AUTH_INVALID",
+              },
+            },
+          },
+        },
       },
     },
-  },
-  403: {
-    description: "权限不足",
-    content: {
-      "application/json": {
-        schema: z.object({
-          ok: z.literal(false),
-          error: z.string().describe("错误消息"),
-        }),
+    403: {
+      description: "权限不足",
+      content: {
+        "application/json": {
+          schema: errorSchema,
+          examples: {
+            permission: {
+              summary: "权限不足",
+              value: {
+                ok: false,
+                error: "权限不足",
+                errorCode: "PERMISSION_DENIED",
+              },
+            },
+          },
+        },
       },
     },
-  },
-  500: {
-    description: "服务器内部错误",
-    content: {
-      "application/json": {
-        schema: z.object({
-          ok: z.literal(false),
-          error: z.string().describe("错误消息"),
-        }),
+    500: {
+      description: "服务器内部错误",
+      content: {
+        "application/json": {
+          schema: errorSchema,
+          examples: {
+            internal: {
+              summary: "服务器内部错误",
+              value: {
+                ok: false,
+                error: "服务器内部错误",
+                errorCode: "INTERNAL_ERROR",
+              },
+            },
+          },
+        },
       },
     },
-  },
-});
+  };
+};
 
 /**
  * 为 Server Action 创建 OpenAPI 路由定义
@@ -177,6 +245,7 @@ export function createActionRoute(
     requiresAuth = true,
     requiredRole,
     requestExamples,
+    argsMapper, // 新增:参数映射函数
   } = options;
 
   // 创建 OpenAPI 路由定义
@@ -238,13 +307,17 @@ export function createActionRoute(
       const body = await c.req.json().catch(() => ({}));
 
       // 2. 调用 Server Action
-      // 提取 schema 中定义的参数并按顺序传递给 action
-      // 这样可以兼容 action(arg1, arg2, ...) 和 action({ arg1, arg2, ... }) 两种签名
+      // 如果提供了 argsMapper,使用它来映射参数
+      // 否则使用默认的参数推断逻辑
       logger.debug(`[ActionAPI] Calling ${fullPath}`, { body });
 
-      // 如果 requestSchema 是对象类型,提取 keys 作为参数顺序
       let args: unknown[];
-      if (requestSchema instanceof z.ZodObject) {
+
+      if (argsMapper) {
+        // 显式参数映射(推荐方式)
+        args = argsMapper(body);
+      } else if (requestSchema instanceof z.ZodObject) {
+        // 默认推断逻辑(保持向后兼容)
         const schemaShape = requestSchema.shape;
         const keys = Object.keys(schemaShape);
         if (keys.length === 0) {
@@ -254,8 +327,8 @@ export function createActionRoute(
           // 单个参数,直接传递值
           args = [body[keys[0] as keyof typeof body]];
         } else {
-          // 多个参数场景 - 保持原有行为传递整个 body 对象
-          // 因为存在 editUser(userId, data) 这类签名,无法从 schema 区分
+          // 多个参数场景 - 传递整个 body 对象
+          // 注意:这可能与多参数函数签名不兼容,建议使用 argsMapper
           args = [body];
         }
       } else {
@@ -283,7 +356,16 @@ export function createActionRoute(
         return c.json({ ok: true, data: result.data }, 200);
       } else {
         logger.warn(`[ActionAPI] ${fullPath} failed:`, { error: result.error });
-        return c.json({ ok: false, error: result.error }, 400);
+        // 透传完整的错误信息(包括 errorCode 和 errorParams)
+        return c.json(
+          {
+            ok: false,
+            error: result.error,
+            ...(result.errorCode && { errorCode: result.errorCode }),
+            ...(result.errorParams && { errorParams: result.errorParams }),
+          },
+          400
+        );
       }
     } catch (error) {
       // 5. 错误处理

+ 2 - 0
src/lib/auth.ts

@@ -38,6 +38,8 @@ export async function validateKey(
       providerGroup: null,
       isEnabled: true,
       expiresAt: null,
+      dailyResetMode: "fixed",
+      dailyResetTime: "00:00",
       createdAt: now,
       updatedAt: now,
     };

+ 1 - 0
src/lib/config/env.schema.ts

@@ -32,6 +32,7 @@ export const EnvSchema = z.object({
   AUTO_MIGRATE: z.string().default("true").transform(booleanTransform),
   PORT: z.coerce.number().default(23000),
   REDIS_URL: z.string().optional(),
+  REDIS_TLS_REJECT_UNAUTHORIZED: z.string().default("true").transform(booleanTransform),
   ENABLE_RATE_LIMIT: z.string().default("true").transform(booleanTransform),
   ENABLE_SECURE_COOKIES: z.string().default("true").transform(booleanTransform),
   SESSION_TTL: z.coerce.number().default(300),

이 변경점에서 너무 많은 파일들이 변경되어 몇몇 파일들은 표시되지 않았습니다.