Преглед изворни кода

fix(ui): 服务商表单小屏体验与客户端限制配置 (#816)

* fix(ui): 修复服务商表单小屏底部导航遮挡提交按钮

* test(vitest): 修复 node 内置模块 mock 并限制默认 worker 数

* fix(ui): 补齐 safe-area-bottom 并修正表单进度条

* test(e2e): 通过登录换取会话 token

* test(e2e): 登录获取会话 token 增加重试

* fix(redis): CI 环境允许连接 Redis

* fix(ui): 移动端 dvh 视口高度与安全区适配

* fix(e2e): 登录重试仅覆盖可重试错误

* fix(redis): 连接终止后重置单例避免僵尸客户端

* fix(ui,redis): safe-area 作用域与 Redis 配置复用

* fix(ui,redis): dvh 自适应与 closeRedis 守卫

* fix(ui,i18n,redis): 修复 max-h/i18n 与 closeRedis

* fix(ui,test,a11y): 补齐小屏体验与单测稳定性

* fix(ui,test): 处理 CodeRabbit 复审建议

* fix(ui): 客户端限制输入补齐错误反馈

* fix(ui): 改善服务商表单的客户端限制与模型选择体验

* perf(ui): Provider 表单减少无效重算

* chore: format code (fix-issue-799-mobile-ui-38fb971)

* fix(build): standalone 本地运行补齐静态资源

* fix(ui): 调整思考预算/自适应思考提示触发与定位

* 修复 Provider 覆盖项下拉宽度与提示交互

* 修复 TagInput 选择建议后下拉关闭

* 修复下拉信息图标阻挡点击

* 修复表单步骤进度条动画

* test(e2e): 修复登录 Cookie 解析与重试策略

* fix: 优化客户端限制开关与 E2E 重试判定

* test(e2e): 收敛 fetch retry 的错误类型

---------

Co-authored-by: tesgth032 <[email protected]>
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
tesgth032 пре 1 месец
родитељ
комит
8defc403cc
91 измењених фајлова са 1029 додато и 375 уклоњено
  1. 6 0
      messages/en/dashboard.json
  2. 1 0
      messages/en/settings/providers/batchEdit.json
  3. 2 1
      messages/en/settings/providers/form/common.json
  4. 3 0
      messages/en/settings/providers/form/sections.json
  5. 6 0
      messages/ja/dashboard.json
  6. 1 0
      messages/ja/settings/providers/batchEdit.json
  7. 2 1
      messages/ja/settings/providers/form/common.json
  8. 3 0
      messages/ja/settings/providers/form/sections.json
  9. 6 0
      messages/ru/dashboard.json
  10. 1 0
      messages/ru/settings/providers/batchEdit.json
  11. 2 1
      messages/ru/settings/providers/form/common.json
  12. 3 0
      messages/ru/settings/providers/form/sections.json
  13. 6 0
      messages/zh-CN/dashboard.json
  14. 1 0
      messages/zh-CN/settings/providers/batchEdit.json
  15. 2 1
      messages/zh-CN/settings/providers/form/common.json
  16. 3 0
      messages/zh-CN/settings/providers/form/sections.json
  17. 6 0
      messages/zh-TW/dashboard.json
  18. 1 0
      messages/zh-TW/settings/providers/batchEdit.json
  19. 2 1
      messages/zh-TW/settings/providers/form/common.json
  20. 3 0
      messages/zh-TW/settings/providers/form/sections.json
  21. 19 0
      scripts/copy-version-to-standalone.cjs
  22. 6 1
      src/app/[locale]/dashboard/_components/bento/statistics-chart-card.tsx
  23. 5 1
      src/app/[locale]/dashboard/_components/dashboard-main.tsx
  24. 1 1
      src/app/[locale]/dashboard/_components/user/add-key-dialog.tsx
  25. 1 1
      src/app/[locale]/dashboard/_components/user/add-user-dialog.tsx
  26. 1 1
      src/app/[locale]/dashboard/_components/user/batch-edit/batch-edit-dialog.tsx
  27. 1 1
      src/app/[locale]/dashboard/_components/user/create-user-dialog.tsx
  28. 1 1
      src/app/[locale]/dashboard/_components/user/edit-key-dialog.tsx
  29. 1 1
      src/app/[locale]/dashboard/_components/user/edit-user-dialog.tsx
  30. 2 2
      src/app/[locale]/dashboard/_components/user/key-actions.tsx
  31. 16 8
      src/app/[locale]/dashboard/_components/user/key-list-header.tsx
  32. 1 1
      src/app/[locale]/dashboard/_components/user/user-actions.tsx
  33. 1 1
      src/app/[locale]/dashboard/_components/user/user-list.tsx
  34. 1 1
      src/app/[locale]/dashboard/layout.tsx
  35. 1 1
      src/app/[locale]/dashboard/logs/_components/usage-logs-view-virtualized.tsx
  36. 1 1
      src/app/[locale]/dashboard/quotas/keys/_components/edit-key-quota-dialog.tsx
  37. 1 1
      src/app/[locale]/dashboard/quotas/keys/_components/edit-user-quota-dialog.tsx
  38. 1 1
      src/app/[locale]/dashboard/sessions/[sessionId]/messages/_components/session-details-tabs.tsx
  39. 1 1
      src/app/[locale]/dashboard/sessions/_components/session-messages-dialog.tsx
  40. 1 1
      src/app/[locale]/internal/dashboard/big-screen/loading.tsx
  41. 1 1
      src/app/[locale]/internal/dashboard/big-screen/page.tsx
  42. 1 1
      src/app/[locale]/layout.tsx
  43. 1 1
      src/app/[locale]/login/loading.tsx
  44. 3 3
      src/app/[locale]/login/page.tsx
  45. 1 1
      src/app/[locale]/my-usage/layout.tsx
  46. 1 1
      src/app/[locale]/settings/error-rules/_components/add-rule-dialog.tsx
  47. 1 1
      src/app/[locale]/settings/error-rules/_components/edit-rule-dialog.tsx
  48. 1 1
      src/app/[locale]/settings/layout.tsx
  49. 1 1
      src/app/[locale]/settings/notifications/_components/binding-selector.tsx
  50. 1 1
      src/app/[locale]/settings/notifications/_components/webhook-target-dialog.tsx
  51. 1 1
      src/app/[locale]/settings/prices/_components/sync-conflict-dialog.tsx
  52. 2 2
      src/app/[locale]/settings/providers/_components/adaptive-thinking-editor.tsx
  53. 1 1
      src/app/[locale]/settings/providers/_components/add-provider-dialog.tsx
  54. 1 1
      src/app/[locale]/settings/providers/_components/auto-sort-priority-dialog.tsx
  55. 1 1
      src/app/[locale]/settings/providers/_components/batch-edit/provider-batch-dialog.tsx
  56. 5 5
      src/app/[locale]/settings/providers/_components/batch-edit/provider-batch-preview-step.tsx
  57. 15 6
      src/app/[locale]/settings/providers/_components/forms/provider-form/components/form-tab-nav.tsx
  58. 14 9
      src/app/[locale]/settings/providers/_components/forms/provider-form/index.tsx
  59. 1 1
      src/app/[locale]/settings/providers/_components/forms/provider-form/sections/basic-info-section.tsx
  60. 124 162
      src/app/[locale]/settings/providers/_components/forms/provider-form/sections/routing-section.tsx
  61. 1 1
      src/app/[locale]/settings/providers/_components/forms/test-result-card.tsx
  62. 45 30
      src/app/[locale]/settings/providers/_components/model-multi-select.tsx
  63. 2 2
      src/app/[locale]/settings/providers/_components/provider-list-item.legacy.tsx
  64. 2 2
      src/app/[locale]/settings/providers/_components/provider-rich-list-item.tsx
  65. 1 1
      src/app/[locale]/settings/providers/_components/recluster-vendors-dialog.tsx
  66. 1 1
      src/app/[locale]/settings/providers/_components/scheduling-rules-dialog.tsx
  67. 1 1
      src/app/[locale]/settings/providers/_components/thinking-budget-editor.tsx
  68. 2 2
      src/app/[locale]/settings/providers/_components/vendor-keys-compact-list.tsx
  69. 1 1
      src/app/[locale]/settings/request-filters/_components/filter-dialog.tsx
  70. 1 1
      src/app/[locale]/settings/sensitive-words/_components/add-word-dialog.tsx
  71. 1 1
      src/app/[locale]/settings/sensitive-words/_components/edit-word-dialog.tsx
  72. 1 1
      src/app/[locale]/usage-doc/layout.tsx
  73. 3 1
      src/app/global-error.tsx
  74. 25 0
      src/app/globals.css
  75. 145 0
      src/components/form/client-restrictions-editor.tsx
  76. 12 8
      src/components/ui/__tests__/calendar-highlight.test.tsx
  77. 42 0
      src/components/ui/__tests__/tag-input-dialog.test.tsx
  78. 3 2
      src/components/ui/drawer.tsx
  79. 1 0
      src/components/ui/sheet.tsx
  80. 8 8
      src/components/ui/tag-input.tsx
  81. 51 42
      src/lib/redis/client.ts
  82. 218 0
      tests/e2e/_helpers/auth.ts
  83. 20 4
      tests/e2e/api-complete.test.ts
  84. 19 5
      tests/e2e/notification-settings.test.ts
  85. 20 4
      tests/e2e/users-keys-complete.test.ts
  86. 35 0
      tests/unit/auth/split-set-cookie-header.test.ts
  87. 8 12
      tests/unit/lib/database-backup/docker-executor.test.ts
  88. 32 6
      tests/unit/lib/endpoint-circuit-breaker.test.ts
  89. 2 1
      tests/unit/login/login-visual-regression.test.tsx
  90. 17 5
      tests/unit/settings/providers/thinking-budget-editor.test.tsx
  91. 9 0
      vitest.config.ts

+ 6 - 0
messages/en/dashboard.json

@@ -790,6 +790,12 @@
   },
   "keyListHeader": {
     "todayUsage": "Today's Usage",
+    "userStatus": {
+      "disabled": "Disabled",
+      "expired": "Expired",
+      "expiringSoon": "Expiring soon",
+      "active": "Enabled"
+    },
     "allowedModels": {
       "label": "Allowed Models",
       "noRestrictions": "Allowed Models: No restrictions"

+ 1 - 0
messages/en/settings/providers/batchEdit.json

@@ -64,6 +64,7 @@
     "description": "Review changes before applying to {count} providers",
     "providerHeader": "{name}",
     "fieldChanged": "{field}: {before} -> {after}",
+    "nullValue": "null",
     "fieldSkipped": "{field}: Skipped ({reason})",
     "excludeProvider": "Exclude",
     "summary": "{providerCount} providers, {fieldCount} changes, {skipCount} skipped",

+ 2 - 1
messages/en/settings/providers/form/common.json

@@ -5,6 +5,7 @@
     "routing": "Routing",
     "limits": "Limits",
     "network": "Network",
-    "testing": "Testing"
+    "testing": "Testing",
+    "stepProgress": "Step progress"
   }
 }

+ 3 - 0
messages/en/settings/providers/form/sections.json

@@ -306,6 +306,9 @@
       "title": "Model Allowlist"
     },
     "clientRestrictions": {
+      "toggleLabel": "Enable Client Restrictions",
+      "toggleDesc": "Clients are not restricted by default. Enable to configure allowlist/blocklist rules.",
+      "priorityNote": "Blocklist takes precedence over allowlist.",
       "allowedLabel": "Allowed Clients",
       "allowedPlaceholder": "e.g. claude-code-cli",
       "blockedLabel": "Blocked Clients",

+ 6 - 0
messages/ja/dashboard.json

@@ -782,6 +782,12 @@
   },
   "keyListHeader": {
     "todayUsage": "本日の使用量",
+    "userStatus": {
+      "disabled": "無効",
+      "expired": "期限切れ",
+      "expiringSoon": "まもなく期限切れ",
+      "active": "有効"
+    },
     "allowedModels": {
       "label": "許可モデル",
       "noRestrictions": "許可されたクライアント:制限なし"

+ 1 - 0
messages/ja/settings/providers/batchEdit.json

@@ -64,6 +64,7 @@
     "description": "{count} 件のプロバイダーに適用する前に変更内容を確認してください",
     "providerHeader": "{name}",
     "fieldChanged": "{field}: {before} -> {after}",
+    "nullValue": "なし",
     "fieldSkipped": "{field}: スキップ ({reason})",
     "excludeProvider": "除外",
     "summary": "{providerCount} 件のプロバイダー, {fieldCount} 件の変更, {skipCount} 件スキップ",

+ 2 - 1
messages/ja/settings/providers/form/common.json

@@ -5,6 +5,7 @@
     "routing": "ルーティング",
     "limits": "制限",
     "network": "ネットワーク",
-    "testing": "テスト"
+    "testing": "テスト",
+    "stepProgress": "ステップ進捗"
   }
 }

+ 3 - 0
messages/ja/settings/providers/form/sections.json

@@ -307,6 +307,9 @@
       "title": "モデル許可リスト"
     },
     "clientRestrictions": {
+      "toggleLabel": "クライアント制限を有効化",
+      "toggleDesc": "既定ではクライアントを制限しません。有効化すると許可/拒否リストを設定できます。",
+      "priorityNote": "拒否リストは許可リストより優先されます。",
       "allowedLabel": "許可クライアント",
       "allowedPlaceholder": "例: claude-code-cli",
       "blockedLabel": "ブロッククライアント",

+ 6 - 0
messages/ru/dashboard.json

@@ -785,6 +785,12 @@
   },
   "keyListHeader": {
     "todayUsage": "Использование сегодня",
+    "userStatus": {
+      "disabled": "Отключен",
+      "expired": "Истек",
+      "expiringSoon": "Скоро истечет",
+      "active": "Активен"
+    },
     "allowedModels": {
       "label": "Разрешённые модели",
       "noRestrictions": "Разрешённые модели: без ограничений"

+ 1 - 0
messages/ru/settings/providers/batchEdit.json

@@ -64,6 +64,7 @@
     "description": "Проверьте изменения перед применением к {count} поставщикам",
     "providerHeader": "{name}",
     "fieldChanged": "{field}: {before} -> {after}",
+    "nullValue": "null",
     "fieldSkipped": "{field}: Пропущено ({reason})",
     "excludeProvider": "Исключить",
     "summary": "{providerCount} поставщиков, {fieldCount} изменений, {skipCount} пропущено",

+ 2 - 1
messages/ru/settings/providers/form/common.json

@@ -5,6 +5,7 @@
     "routing": "Маршрутизация",
     "limits": "Лимиты",
     "network": "Сеть",
-    "testing": "Тестирование"
+    "testing": "Тестирование",
+    "stepProgress": "Прогресс шагов"
   }
 }

+ 3 - 0
messages/ru/settings/providers/form/sections.json

@@ -307,6 +307,9 @@
       "title": "Список разрешённых моделей"
     },
     "clientRestrictions": {
+      "toggleLabel": "Включить ограничения клиентов",
+      "toggleDesc": "По умолчанию клиенты не ограничиваются. Включите, чтобы настроить правила белого/чёрного списка.",
+      "priorityNote": "Чёрный список имеет приоритет над белым списком.",
       "allowedLabel": "Разрешённые клиенты",
       "allowedPlaceholder": "напр. claude-code-cli",
       "blockedLabel": "Заблокированные клиенты",

+ 6 - 0
messages/zh-CN/dashboard.json

@@ -791,6 +791,12 @@
   },
   "keyListHeader": {
     "todayUsage": "今日用量",
+    "userStatus": {
+      "disabled": "已禁用",
+      "expired": "已过期",
+      "expiringSoon": "即将过期",
+      "active": "已启用"
+    },
     "allowedModels": {
       "label": "允许的模型",
       "noRestrictions": "允许的模型:无限制"

+ 1 - 0
messages/zh-CN/settings/providers/batchEdit.json

@@ -64,6 +64,7 @@
     "description": "将变更应用到 {count} 个供应商前请先确认",
     "providerHeader": "{name}",
     "fieldChanged": "{field}: {before} -> {after}",
+    "nullValue": "空",
     "fieldSkipped": "{field}: 已跳过 ({reason})",
     "excludeProvider": "排除",
     "summary": "{providerCount} 个供应商, {fieldCount} 项变更, {skipCount} 项跳过",

+ 2 - 1
messages/zh-CN/settings/providers/form/common.json

@@ -5,6 +5,7 @@
     "routing": "路由",
     "limits": "限制",
     "network": "网络",
-    "testing": "测试"
+    "testing": "测试",
+    "stepProgress": "步骤进度"
   }
 }

+ 3 - 0
messages/zh-CN/settings/providers/form/sections.json

@@ -50,6 +50,9 @@
       "moreModels": "+{count} 更多"
     },
     "clientRestrictions": {
+      "toggleLabel": "启用客户端限制",
+      "toggleDesc": "默认不限制客户端。启用后可配置白名单/黑名单。",
+      "priorityNote": "黑名单优先级高于白名单。",
       "allowedLabel": "白名单客户端",
       "allowedPlaceholder": "例如 claude-code-cli",
       "blockedLabel": "黑名单客户端",

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

@@ -782,6 +782,12 @@
   },
   "keyListHeader": {
     "todayUsage": "今日使用量",
+    "userStatus": {
+      "disabled": "已禁用",
+      "expired": "已過期",
+      "expiringSoon": "即將過期",
+      "active": "已啟用"
+    },
     "allowedModels": {
       "label": "允許的模型",
       "noRestrictions": "允許的模型:無限制"

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

@@ -64,6 +64,7 @@
     "description": "將變更應用到 {count} 個供應商前請先確認",
     "providerHeader": "{name}",
     "fieldChanged": "{field}: {before} -> {after}",
+    "nullValue": "空",
     "fieldSkipped": "{field}: 已跳過 ({reason})",
     "excludeProvider": "排除",
     "summary": "{providerCount} 個供應商, {fieldCount} 項變更, {skipCount} 項跳過",

+ 2 - 1
messages/zh-TW/settings/providers/form/common.json

@@ -5,6 +5,7 @@
     "routing": "路由",
     "limits": "限制",
     "network": "網路",
-    "testing": "測試"
+    "testing": "測試",
+    "stepProgress": "步驟進度"
   }
 }

+ 3 - 0
messages/zh-TW/settings/providers/form/sections.json

@@ -307,6 +307,9 @@
       "title": "模型允許清單"
     },
     "clientRestrictions": {
+      "toggleLabel": "啟用用戶端限制",
+      "toggleDesc": "預設不限制用戶端。啟用後可設定白名單/黑名單。",
+      "priorityNote": "黑名單優先於白名單。",
       "allowedLabel": "白名單客戶端",
       "allowedPlaceholder": "例如 claude-code-cli",
       "blockedLabel": "黑名單客戶端",

+ 19 - 0
scripts/copy-version-to-standalone.cjs

@@ -1,6 +1,17 @@
 const fs = require("node:fs");
 const path = require("node:path");
 
+function copyDirIfExists(srcDir, dstDir) {
+  if (!fs.existsSync(srcDir)) {
+    console.warn(`[copy-standalone] Skip missing dir: ${srcDir}`);
+    return;
+  }
+
+  fs.mkdirSync(path.dirname(dstDir), { recursive: true });
+  fs.cpSync(srcDir, dstDir, { recursive: true, force: true });
+  console.log(`[copy-standalone] Copied ${srcDir} -> ${dstDir}`);
+}
+
 const src = path.resolve(process.cwd(), "VERSION");
 const dstDir = path.resolve(process.cwd(), ".next", "standalone");
 const dst = path.join(dstDir, "VERSION");
@@ -13,3 +24,11 @@ if (!fs.existsSync(src)) {
 fs.mkdirSync(dstDir, { recursive: true });
 fs.copyFileSync(src, dst);
 console.log(`[copy-version] Copied VERSION -> ${dst}`);
+
+// Make standalone output self-contained for local `node .next/standalone/server.js` runs.
+// Next.js standalone requires `.next/static` and `public` to exist next to `server.js`.
+copyDirIfExists(
+  path.resolve(process.cwd(), ".next", "static"),
+  path.resolve(dstDir, ".next", "static")
+);
+copyDirIfExists(path.resolve(process.cwd(), "public"), path.resolve(dstDir, "public"));

+ 6 - 1
src/app/[locale]/dashboard/_components/bento/statistics-chart-card.tsx

@@ -168,7 +168,12 @@ export function StatisticsChartCard({
   };
 
   return (
-    <BentoCard className={cn("flex flex-col p-0 overflow-hidden max-h-[50vh]", className)}>
+    <BentoCard
+      className={cn(
+        "flex flex-col p-0 overflow-hidden max-h-[var(--cch-viewport-height-50)]",
+        className
+      )}
+    >
       {/* Header */}
       <div className="flex items-center justify-between border-b border-border/50 dark:border-white/[0.06]">
         <div className="flex items-center gap-4 p-4">

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

@@ -20,7 +20,11 @@ export function DashboardMain({ children }: DashboardMainProps) {
     normalizedPathname.includes("/dashboard/sessions/") && normalizedPathname.endsWith("/messages");
 
   if (isSessionMessagesPage) {
-    return <main className="h-[calc(100vh-64px)] w-full overflow-hidden">{children}</main>;
+    return (
+      <main className="h-[calc(var(--cch-viewport-height,100vh)-64px)] w-full overflow-hidden">
+        {children}
+      </main>
+    );
   }
 
   return <main className="mx-auto w-full max-w-7xl px-6 py-8">{children}</main>;

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

@@ -70,7 +70,7 @@ export function AddKeyDialog({
 
   return (
     <Dialog open={open} onOpenChange={handleClose}>
-      <DialogContent className="max-w-2xl max-h-[90dvh] max-h-[90vh] p-0 flex flex-col overflow-hidden">
+      <DialogContent className="max-w-2xl max-h-[var(--cch-viewport-height-90)] p-0 flex flex-col overflow-hidden">
         {generatedKey ? (
           <>
             <DialogHeader className="px-6 pt-6 pb-4 border-b">

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

@@ -34,7 +34,7 @@ export function AddUserDialog({
           <ListPlus className="h-4 w-4" /> {t("addUser")}
         </Button>
       </DialogTrigger>
-      <DialogContent className="max-h-[85vh] overflow-y-auto">
+      <DialogContent className="max-h-[var(--cch-viewport-height-85)] overflow-y-auto">
         <FormErrorBoundary>
           <UserForm onSuccess={() => setOpen(false)} currentUser={currentUser} />
         </FormErrorBoundary>

+ 1 - 1
src/app/[locale]/dashboard/_components/user/batch-edit/batch-edit-dialog.tsx

@@ -443,7 +443,7 @@ function BatchEditDialogInner({
           </DialogDescription>
         </DialogHeader>
 
-        <div className="max-h-[70vh] overflow-y-auto space-y-6 pr-1">
+        <div className="max-h-[var(--cch-viewport-height-70)] overflow-y-auto space-y-6 pr-1">
           {selectedUsersCount > 0 ? (
             <BatchUserSection
               affectedUsersCount={selectedUsersCount}

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

@@ -337,7 +337,7 @@ function CreateUserDialogInner({ onOpenChange, onSuccess }: CreateUserDialogProp
   }
 
   return (
-    <DialogContent className="w-full max-w-[95vw] sm:max-w-[85vw] md:max-w-[70vw] lg:max-w-3xl max-h-[90vh] max-h-[90dvh] p-0 flex flex-col overflow-hidden">
+    <DialogContent className="w-full max-w-[95vw] sm:max-w-[85vw] md:max-w-[70vw] lg:max-w-3xl max-h-[var(--cch-viewport-height-90)] 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">

+ 1 - 1
src/app/[locale]/dashboard/_components/user/edit-key-dialog.tsx

@@ -52,7 +52,7 @@ export function EditKeyDialog({
 
   return (
     <Dialog open={open} onOpenChange={onOpenChange}>
-      <DialogContent className="max-w-2xl max-h-[90dvh] p-0 flex flex-col overflow-hidden">
+      <DialogContent className="max-w-2xl max-h-[var(--cch-viewport-height-90)] p-0 flex flex-col overflow-hidden">
         <DialogHeader className="sr-only">
           <DialogTitle>{t("title")}</DialogTitle>
           <DialogDescription>{t("description")}</DialogDescription>

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

@@ -244,7 +244,7 @@ function EditUserDialogInner({ onOpenChange, user, onSuccess }: EditUserDialogPr
   };
 
   return (
-    <DialogContent className="w-full max-w-[95vw] sm:max-w-[85vw] md:max-w-[70vw] lg:max-w-3xl max-h-[90vh] max-h-[90dvh] p-0 flex flex-col overflow-hidden">
+    <DialogContent className="w-full max-w-[95vw] sm:max-w-[85vw] md:max-w-[70vw] lg:max-w-3xl max-h-[var(--cch-viewport-height-90,90vh)] 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">

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

@@ -50,7 +50,7 @@ export function KeyActions({
             <SquarePen className="h-4 w-4" />
           </button>
         </DialogTrigger>
-        <DialogContent className="max-h-[80vh] flex flex-col overflow-hidden">
+        <DialogContent className="max-h-[var(--cch-viewport-height-80)] flex flex-col overflow-hidden">
           <FormErrorBoundary>
             <EditKeyForm
               keyData={keyData}
@@ -75,7 +75,7 @@ export function KeyActions({
               <Trash2 className="h-4 w-4" />
             </button>
           </DialogTrigger>
-          <DialogContent className="max-h-[80vh] flex flex-col overflow-hidden">
+          <DialogContent className="max-h-[var(--cch-viewport-height-80)] flex flex-col overflow-hidden">
             <FormErrorBoundary>
               <DeleteKeyConfirm keyData={keyData} onSuccess={() => setOpenDelete(false)} />
             </FormErrorBoundary>

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

@@ -27,6 +27,15 @@ import { UserActions } from "./user-actions";
 
 const PROXY_STATUS_REFRESH_INTERVAL = 2000;
 
+type UserStatusCode = "disabled" | "expired" | "expiringSoon" | "active";
+
+const USER_STATUS_LABEL_KEYS: Record<UserStatusCode, string> = {
+  disabled: "userStatus.disabled",
+  expired: "userStatus.expired",
+  expiringSoon: "userStatus.expiringSoon",
+  active: "userStatus.active",
+};
+
 async function fetchProxyStatus(): Promise<ProxyStatusResponse> {
   const result = await getProxyStatus();
   if (result.ok) {
@@ -123,19 +132,18 @@ export function KeyListHeader({
     const exp = activeUser.expiresAt ? new Date(activeUser.expiresAt).getTime() : null;
 
     let status: {
-      code: string;
-      badge: string;
+      code: UserStatusCode;
       variant: "default" | "secondary" | "destructive" | "outline";
     };
 
     if (!activeUser.isEnabled) {
-      status = { code: "disabled", badge: "已禁用", variant: "secondary" };
+      status = { code: "disabled", variant: "secondary" };
     } else if (exp && exp <= now) {
-      status = { code: "expired", badge: "已过期", variant: "destructive" };
+      status = { code: "expired", variant: "destructive" };
     } else if (exp && exp - now <= 72 * 60 * 60 * 1000) {
-      status = { code: "expiringSoon", badge: "即将过期", variant: "outline" };
+      status = { code: "expiringSoon", variant: "outline" };
     } else {
-      status = { code: "active", badge: "已启用", variant: "default" };
+      status = { code: "active", variant: "default" };
     }
 
     const expiryText = activeUser.expiresAt
@@ -255,7 +263,7 @@ export function KeyListHeader({
             <span>{activeUser ? activeUser.name : "-"}</span>
             {activeUser && userStatusInfo && (
               <Badge variant={userStatusInfo.status.variant} className="text-xs">
-                {userStatusInfo.status.badge}
+                {t(USER_STATUS_LABEL_KEYS[userStatusInfo.status.code])}
               </Badge>
             )}
             {activeUser && <UserActions user={activeUser} currentUser={currentUser} />}
@@ -318,7 +326,7 @@ export function KeyListHeader({
                 <ListPlus className="h-3.5 w-3.5" /> {t("addKey")}
               </Button>
             </DialogTrigger>
-            <DialogContent className="max-h-[80vh] flex flex-col overflow-y-auto">
+            <DialogContent className="max-h-[var(--cch-viewport-height-80)] flex flex-col overflow-y-auto">
               <FormErrorBoundary>
                 <AddKeyForm
                   userId={activeUser?.id}

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

@@ -40,7 +40,7 @@ export function UserActions({ user, currentUser }: UserActionsProps) {
             <SquarePen className="h-3.5 w-3.5" />
           </button>
         </DialogTrigger>
-        <DialogContent className="max-h-[85vh] overflow-y-auto">
+        <DialogContent className="max-h-[var(--cch-viewport-height-85,85vh)] overflow-y-auto">
           <FormErrorBoundary>
             <UserForm user={user} onSuccess={() => setOpenEdit(false)} currentUser={currentUser} />
           </FormErrorBoundary>

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

@@ -388,7 +388,7 @@ export function UserList({ users, activeUserId, onUserSelect, currentUser }: Use
           if (!open) setEditUser(null);
         }}
       >
-        <DialogContent className="max-h-[85vh] overflow-y-auto">
+        <DialogContent className="max-h-[var(--cch-viewport-height-85)] overflow-y-auto">
           <FormErrorBoundary>
             {editUser ? (
               <UserForm

+ 1 - 1
src/app/[locale]/dashboard/layout.tsx

@@ -27,7 +27,7 @@ export default async function DashboardLayout({
   }
 
   return (
-    <div className="min-h-screen bg-background">
+    <div className="min-h-[var(--cch-viewport-height,100vh)] bg-background">
       <DashboardHeader session={session} />
       <DashboardMain>{children}</DashboardMain>
       <WebhookMigrationDialog />

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

@@ -476,7 +476,7 @@ function UsageLogsViewContent({
               hideStatusBar={true}
               hideScrollToTop={true}
               hiddenColumns={hideProviderColumn ? ["provider"] : undefined}
-              bodyClassName="h-[calc(100vh-56px-32px-40px)]"
+              bodyClassName="h-[calc(var(--cch-viewport-height,100vh)-56px-32px-40px)]"
             />
           </div>
         </div>

+ 1 - 1
src/app/[locale]/dashboard/quotas/keys/_components/edit-key-quota-dialog.tsx

@@ -149,7 +149,7 @@ export function EditKeyQuotaDialog({
           </Button>
         )}
       </DialogTrigger>
-      <DialogContent className="sm:max-w-[600px] max-h-[70vh] flex flex-col">
+      <DialogContent className="sm:max-w-[600px] max-h-[var(--cch-viewport-height-70)] flex flex-col">
         <DialogHeader className="flex-shrink-0">
           <DialogTitle>{t("title")}</DialogTitle>
           <DialogDescription>{t("description", { keyName, userName })}</DialogDescription>

+ 1 - 1
src/app/[locale]/dashboard/quotas/keys/_components/edit-user-quota-dialog.tsx

@@ -87,7 +87,7 @@ export function EditUserQuotaDialog({
           </Button>
         )}
       </DialogTrigger>
-      <DialogContent className="sm:max-w-[600px] max-h-[70vh] flex flex-col">
+      <DialogContent className="sm:max-w-[600px] max-h-[var(--cch-viewport-height-70)] flex flex-col">
         <DialogHeader className="flex-shrink-0">
           <DialogTitle>{t("title")}</DialogTitle>
           <DialogDescription>{t("description", { userName })}</DialogDescription>

+ 1 - 1
src/app/[locale]/dashboard/sessions/[sessionId]/messages/_components/session-details-tabs.tsx

@@ -68,7 +68,7 @@ export function SessionMessagesDetailsTabs({
   isResponseCopied,
 }: SessionMessagesDetailsTabsProps) {
   const t = useTranslations("dashboard.sessions");
-  const codeExpandedMaxHeight = "calc(100vh - 260px)";
+  const codeExpandedMaxHeight = "calc(var(--cch-viewport-height, 100vh) - 260px)";
 
   // 后端已根据 STORE_SESSION_MESSAGES 配置进行脱敏,前端直接显示
   const requestBodyContent = useMemo(() => {

+ 1 - 1
src/app/[locale]/dashboard/sessions/_components/session-messages-dialog.tsx

@@ -67,7 +67,7 @@ export function SessionMessagesDialog({ sessionId }: SessionMessagesDialogProps)
           {t("actions.view")}
         </Button>
       </DialogTrigger>
-      <DialogContent className="max-w-3xl max-h-[80vh] overflow-y-auto">
+      <DialogContent className="max-w-3xl max-h-[var(--cch-viewport-height-80)] overflow-y-auto">
         <DialogHeader>
           <DialogTitle>{t("details.title")}</DialogTitle>
           <DialogDescription className="font-mono text-xs">{sessionId}</DialogDescription>

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

@@ -3,7 +3,7 @@ import { Skeleton } from "@/components/ui/skeleton";
 
 export default function BigScreenLoading() {
   return (
-    <div className="min-h-screen bg-background p-6 space-y-6">
+    <div className="min-h-[var(--cch-viewport-height,100vh)] bg-background p-6 space-y-6">
       <Skeleton className="h-8 w-48" />
       <div className="grid gap-4 lg:grid-cols-4">
         {Array.from({ length: 4 }).map((_, index) => (

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

@@ -788,7 +788,7 @@ export default function BigScreenPage() {
 
   return (
     <div
-      className={`relative w-full h-screen overflow-hidden transition-colors duration-500 font-sans selection:bg-orange-500/30 ${theme.bg}`}
+      className={`relative w-full h-[var(--cch-viewport-height,100vh)] overflow-hidden transition-colors duration-500 font-sans selection:bg-orange-500/30 ${theme.bg}`}
     >
       <ParticleBackground themeMode={themeMode} />
 

+ 1 - 1
src/app/[locale]/layout.tsx

@@ -80,7 +80,7 @@ export default async function RootLayout({
       <body className="antialiased">
         <NextIntlClientProvider messages={messages} timeZone={timeZone} now={now}>
           <AppProviders>
-            <div className="flex min-h-screen flex-col bg-background text-foreground">
+            <div className="flex min-h-[var(--cch-viewport-height,100vh)] flex-col bg-background text-foreground">
               <main className="flex-1">{children}</main>
               <Footer />
             </div>

+ 1 - 1
src/app/[locale]/login/loading.tsx

@@ -3,7 +3,7 @@ import { Skeleton } from "@/components/ui/skeleton";
 
 export default function LoginLoading() {
   return (
-    <div className="flex min-h-screen bg-background">
+    <div className="flex min-h-[var(--cch-viewport-height,100vh)] bg-background">
       {/* Brand Panel Skeleton - Desktop Only */}
       <div className="hidden w-[45%] items-center justify-center lg:flex">
         <div className="flex flex-col items-center gap-6">

+ 3 - 3
src/app/[locale]/login/page.tsx

@@ -203,7 +203,7 @@ function LoginPageContent() {
   const isLoading = status === "submitting" || status === "success";
 
   return (
-    <div className="relative min-h-screen overflow-hidden bg-gradient-to-br from-background via-background to-orange-500/5 dark:to-orange-500/10">
+    <div className="relative min-h-[var(--cch-viewport-height,100vh)] overflow-hidden bg-gradient-to-br from-background via-background to-orange-500/5 dark:to-orange-500/10">
       {/* Fullscreen Loading Overlay */}
       {isLoading && (
         <div
@@ -253,7 +253,7 @@ function LoginPageContent() {
       </div>
 
       {/* Main Layout */}
-      <div className="flex min-h-screen">
+      <div className="flex min-h-[var(--cch-viewport-height,100vh)]">
         {/* Brand Panel - Desktop Only */}
         <motion.aside
           data-testid="login-brand-panel"
@@ -433,7 +433,7 @@ function LoginPageContent() {
 
 function LoginPageFallback() {
   return (
-    <div className="flex min-h-screen items-center justify-center bg-background">
+    <div className="flex min-h-[var(--cch-viewport-height,100vh)] items-center justify-center bg-background">
       <Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
     </div>
   );

+ 1 - 1
src/app/[locale]/my-usage/layout.tsx

@@ -21,7 +21,7 @@ export default async function MyUsageLayout({
   }
 
   return (
-    <div className="min-h-screen bg-background">
+    <div className="min-h-[var(--cch-viewport-height,100vh)] bg-background">
       <main className="mx-auto w-full max-w-4xl px-4 py-6 sm:px-6">{children}</main>
     </div>
   );

+ 1 - 1
src/app/[locale]/settings/error-rules/_components/add-rule-dialog.tsx

@@ -131,7 +131,7 @@ export function AddRuleDialog() {
           {t("errorRules.add")}
         </Button>
       </DialogTrigger>
-      <DialogContent className="max-w-2xl max-h-[80vh] flex flex-col bg-card/95 backdrop-blur-xl border-border">
+      <DialogContent className="max-w-2xl max-h-[var(--cch-viewport-height-80)] flex flex-col bg-card/95 backdrop-blur-xl border-border">
         <form onSubmit={handleSubmit} className="flex flex-col flex-1 min-h-0">
           <DialogHeader className="flex-shrink-0">
             <DialogTitle>{t("errorRules.dialog.addTitle")}</DialogTitle>

+ 1 - 1
src/app/[locale]/settings/error-rules/_components/edit-rule-dialog.tsx

@@ -139,7 +139,7 @@ export function EditRuleDialog({ rule, open, onOpenChange }: EditRuleDialogProps
 
   return (
     <Dialog open={open} onOpenChange={onOpenChange}>
-      <DialogContent className="max-w-2xl max-h-[80vh] flex flex-col bg-card/95 backdrop-blur-xl border-border">
+      <DialogContent className="max-w-2xl max-h-[var(--cch-viewport-height-80)] flex flex-col bg-card/95 backdrop-blur-xl border-border">
         <form onSubmit={handleSubmit} className="flex flex-col flex-1 min-h-0">
           <DialogHeader className="flex-shrink-0">
             <DialogTitle>{t("errorRules.dialog.editTitle")}</DialogTitle>

+ 1 - 1
src/app/[locale]/settings/layout.tsx

@@ -31,7 +31,7 @@ export default async function SettingsLayout({
   const translatedNavItems = await getTranslatedNavItems();
 
   return (
-    <div className="min-h-screen bg-background">
+    <div className="min-h-[var(--cch-viewport-height,100vh)] bg-background">
       <DashboardHeader session={session} />
       <main className="mx-auto w-full max-w-7xl px-4 py-6 md:px-6 md:py-8 pb-24 md:pb-8">
         <div className="space-y-6">

+ 1 - 1
src/app/[locale]/settings/notifications/_components/binding-selector.tsx

@@ -336,7 +336,7 @@ export function BindingSelector({ type, targets, bindings, onSave }: BindingSele
         open={templateDialogOpen}
         onOpenChange={(open) => (open ? setTemplateDialogOpen(true) : closeTemplateDialog())}
       >
-        <DialogContent className="w-full max-w-3xl max-h-[90vh] overflow-y-auto">
+        <DialogContent className="w-full max-w-3xl max-h-[var(--cch-viewport-height-90)] overflow-y-auto">
           <DialogHeader>
             <DialogTitle>{t("notifications.bindings.templateOverrideTitle")}</DialogTitle>
           </DialogHeader>

+ 1 - 1
src/app/[locale]/settings/notifications/_components/webhook-target-dialog.tsx

@@ -228,7 +228,7 @@ export function WebhookTargetDialog({
 
   return (
     <Dialog open={open} onOpenChange={onOpenChange}>
-      <DialogContent className="w-full max-w-2xl max-h-[90vh] overflow-y-auto">
+      <DialogContent className="w-full max-w-2xl max-h-[var(--cch-viewport-height-90)] overflow-y-auto">
         <DialogHeader>
           <DialogTitle>
             {mode === "create"

+ 1 - 1
src/app/[locale]/settings/prices/_components/sync-conflict-dialog.tsx

@@ -251,7 +251,7 @@ export function SyncConflictDialog({
 
   return (
     <Dialog open={open} onOpenChange={onOpenChange}>
-      <DialogContent className="max-w-3xl max-h-[80vh] overflow-hidden flex flex-col">
+      <DialogContent className="max-w-3xl max-h-[var(--cch-viewport-height-80)] overflow-hidden flex flex-col">
         <DialogHeader>
           <DialogTitle className="flex items-center gap-2">
             <AlertTriangle className="h-5 w-5 text-amber-500" />

+ 2 - 2
src/app/[locale]/settings/providers/_components/adaptive-thinking-editor.tsx

@@ -81,7 +81,7 @@ export function AdaptiveThinkingEditor({
                     }
                     disabled={disabled}
                   >
-                    <SelectTrigger className="w-40">
+                    <SelectTrigger className="flex-1 min-w-0">
                       <SelectValue />
                     </SelectTrigger>
                     <SelectContent>
@@ -118,7 +118,7 @@ export function AdaptiveThinkingEditor({
                     }
                     disabled={disabled}
                   >
-                    <SelectTrigger className="w-40">
+                    <SelectTrigger className="flex-1 min-w-0">
                       <SelectValue />
                     </SelectTrigger>
                     <SelectContent>

+ 1 - 1
src/app/[locale]/settings/providers/_components/add-provider-dialog.tsx

@@ -22,7 +22,7 @@ export function AddProviderDialog({ enableMultiProviderTypes }: AddProviderDialo
           <ServerCog className="h-4 w-4" /> {t("addProvider")}
         </Button>
       </DialogTrigger>
-      <DialogContent className="max-w-full sm:max-w-5xl lg:max-w-6xl max-h-[90vh] flex flex-col">
+      <DialogContent className="max-w-full sm:max-w-5xl lg:max-w-6xl max-h-[var(--cch-viewport-height-90)] flex flex-col overflow-hidden p-0 gap-0">
         <VisuallyHidden>
           <DialogTitle>{t("addProvider")}</DialogTitle>
         </VisuallyHidden>

+ 1 - 1
src/app/[locale]/settings/providers/_components/auto-sort-priority-dialog.tsx

@@ -136,7 +136,7 @@ export function AutoSortPriorityDialog() {
           {t("button")}
         </Button>
       </DialogTrigger>
-      <DialogContent className="max-w-2xl max-h-[80vh] flex flex-col">
+      <DialogContent className="max-w-2xl max-h-[var(--cch-viewport-height-80)] flex flex-col">
         <DialogHeader>
           <DialogTitle>{t("dialogTitle")}</DialogTitle>
           <DialogDescription>{t("dialogDescription")}</DialogDescription>

+ 1 - 1
src/app/[locale]/settings/providers/_components/batch-edit/provider-batch-dialog.tsx

@@ -119,7 +119,7 @@ function BatchEditDialog({
 
   return (
     <Dialog open={open} onOpenChange={onOpenChange}>
-      <DialogContent className="max-w-6xl max-h-[90vh] overflow-hidden flex flex-col">
+      <DialogContent className="max-w-6xl max-h-[var(--cch-viewport-height-90)] overflow-hidden flex flex-col">
         <ProviderFormProvider
           mode="batch"
           enableMultiProviderTypes={false}

+ 5 - 5
src/app/[locale]/settings/providers/_components/batch-edit/provider-batch-preview-step.tsx

@@ -112,7 +112,7 @@ export function ProviderBatchPreviewStep({
       </p>
 
       {/* Provider groups */}
-      <div className="max-h-[50vh] space-y-3 overflow-y-auto">
+      <div className="max-h-[var(--cch-viewport-height-50)] space-y-3 overflow-y-auto">
         {grouped.map((group) => {
           const excluded = excludedProviderIds.has(group.providerId);
           return (
@@ -148,8 +148,8 @@ export function ProviderBatchPreviewStep({
                     {row.status === "changed"
                       ? t("preview.fieldChanged", {
                           field: getFieldLabel(row.field),
-                          before: formatValue(row.before),
-                          after: formatValue(row.after),
+                          before: formatValue(row.before, t),
+                          after: formatValue(row.after, t),
                         })
                       : t("preview.fieldSkipped", {
                           field: getFieldLabel(row.field),
@@ -170,8 +170,8 @@ export function ProviderBatchPreviewStep({
 // Helpers
 // ---------------------------------------------------------------------------
 
-function formatValue(value: unknown): string {
-  if (value === null || value === undefined) return "null";
+function formatValue(value: unknown, t: (key: string) => string): string {
+  if (value === null || value === undefined) return t("preview.nullValue");
   if (typeof value === "boolean") return String(value);
   if (typeof value === "number") return String(value);
   if (typeof value === "string") return value;

+ 15 - 6
src/app/[locale]/settings/providers/_components/forms/provider-form/components/form-tab-nav.tsx

@@ -42,6 +42,10 @@ export function FormTabNav({
     }
   };
 
+  const activeTabIndex = TAB_CONFIG.findIndex((tab) => tab.id === activeTab);
+  const stepNumber = activeTabIndex >= 0 ? activeTabIndex + 1 : 0;
+  const stepProgressWidth = `${(stepNumber / TAB_CONFIG.length) * 100}%`;
+
   if (layout === "horizontal") {
     return (
       <nav className="sticky top-0 z-10 border-b border-border/50 bg-card/80 backdrop-blur-md shrink-0">
@@ -211,7 +215,7 @@ export function FormTabNav({
       </nav>
 
       {/* Mobile: Bottom Navigation */}
-      <nav className="flex md:hidden fixed bottom-0 left-0 right-0 z-50 border-t border-border/50 bg-card/95 backdrop-blur-md safe-area-bottom">
+      <nav className="flex md:hidden shrink-0 relative border-t border-border/50 bg-card/95 backdrop-blur-md safe-area-bottom">
         <div className="flex items-center justify-around w-full px-2 py-1">
           {TAB_CONFIG.map((tab) => {
             const Icon = tab.icon;
@@ -260,13 +264,18 @@ export function FormTabNav({
           })}
         </div>
         {/* Step indicator */}
-        <div className="absolute top-0 left-0 right-0 h-0.5 bg-muted">
+        <div
+          className="absolute top-0 left-0 right-0 h-0.5 bg-muted"
+          role="progressbar"
+          aria-valuenow={stepNumber}
+          aria-valuemin={0}
+          aria-valuemax={TAB_CONFIG.length}
+          aria-label={t("tabs.stepProgress")}
+        >
           <motion.div
             className="h-full bg-primary"
-            initial={{ width: "20%" }}
-            animate={{
-              width: `${((TAB_CONFIG.findIndex((t) => t.id === activeTab) + 1) / TAB_CONFIG.length) * 100}%`,
-            }}
+            initial={false}
+            animate={{ width: stepProgressWidth }}
             transition={{ type: "spring", stiffness: 300, damping: 30 }}
           />
         </div>

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

@@ -550,21 +550,26 @@ function ProviderFormContent({
   };
 
   return (
-    <form onSubmit={handleSubmit} className="flex flex-col h-full max-h-[85vh]">
+    <form
+      onSubmit={handleSubmit}
+      className="flex flex-col h-full max-h-[var(--cch-viewport-height-85)]"
+    >
       {/* Form Layout */}
       <div className="flex flex-col lg:flex-row flex-1 min-h-0">
-        {/* Tab Navigation */}
-        <FormTabNav
-          activeTab={state.ui.activeTab}
-          onTabChange={handleTabChange}
-          disabled={isPending}
-          tabStatus={getTabStatus()}
-        />
+        <div className="order-2 md:order-1 shrink-0">
+          {/* Tab Navigation */}
+          <FormTabNav
+            activeTab={state.ui.activeTab}
+            onTabChange={handleTabChange}
+            disabled={isPending}
+            tabStatus={getTabStatus()}
+          />
+        </div>
 
         {/* All Sections Stacked Vertically */}
         <div
           ref={contentRef}
-          className="flex-1 overflow-y-auto p-6 pb-24 md:pb-6 min-h-0 scroll-smooth"
+          className="order-1 md:order-2 flex-1 overflow-y-auto p-6 min-h-0 scroll-smooth"
           onScroll={handleScroll}
         >
           <div className="space-y-8">

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

@@ -85,7 +85,7 @@ export function BasicInfoSection({ autoUrlPending, endpointPool }: BasicInfoSect
                   })
                 }
               >
-                <SelectTrigger className="w-40">
+                <SelectTrigger className="w-full">
                   <SelectValue />
                 </SelectTrigger>
                 <SelectContent>

+ 124 - 162
src/app/[locale]/settings/providers/_components/forms/provider-form/sections/routing-section.tsx

@@ -3,11 +3,11 @@
 import { motion } from "framer-motion";
 import { Info, Layers, Route, Scale, Settings, Timer } from "lucide-react";
 import { useTranslations } from "next-intl";
+import { useCallback, useEffect, useState } from "react";
 import { toast } from "sonner";
+import { ClientRestrictionsEditor } from "@/components/form/client-restrictions-editor";
 import { Badge } from "@/components/ui/badge";
-import { Checkbox } from "@/components/ui/checkbox";
 import { Input } from "@/components/ui/input";
-import { Label } from "@/components/ui/label";
 import {
   Select,
   SelectContent,
@@ -18,14 +18,6 @@ import {
 import { Switch } from "@/components/ui/switch";
 import { TagInput } from "@/components/ui/tag-input";
 import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
-import {
-  CLIENT_RESTRICTION_PRESET_OPTIONS,
-  isPresetSelected,
-  mergePresetAndCustomClients,
-  removePresetValues,
-  splitPresetAndCustomClients,
-  togglePresetSelection,
-} from "@/lib/client-restrictions/client-presets";
 import { getProviderTypeConfig } from "@/lib/provider-type-utils";
 import type {
   CodexParallelToolCallsPreference,
@@ -81,42 +73,28 @@ export function RoutingSection() {
   const providerTypes: ProviderType[] = ["claude", "codex", "gemini", "openai-compatible"];
   const allowedClients = state.routing.allowedClients;
   const blockedClients = state.routing.blockedClients;
-  const { customValues: customAllowedClients } = splitPresetAndCustomClients(allowedClients);
-  const { customValues: customBlockedClients } = splitPresetAndCustomClients(blockedClients);
-
-  const handleAllowToggle = (presetValue: string, checked: boolean) => {
-    const nextAllowed = togglePresetSelection(allowedClients, presetValue, checked);
-    dispatch({ type: "SET_ALLOWED_CLIENTS", payload: nextAllowed });
-
-    if (checked) {
-      const nextBlocked = removePresetValues(blockedClients, presetValue);
-      dispatch({ type: "SET_BLOCKED_CLIENTS", payload: nextBlocked });
-    }
-  };
+  const hasAnyClientRestrictions = allowedClients.length > 0 || blockedClients.length > 0;
+  const [clientRestrictionsEnabled, setClientRestrictionsEnabled] = useState(
+    () => hasAnyClientRestrictions
+  );
 
-  const handleBlockToggle = (presetValue: string, checked: boolean) => {
-    const nextBlocked = togglePresetSelection(blockedClients, presetValue, checked);
-    dispatch({ type: "SET_BLOCKED_CLIENTS", payload: nextBlocked });
+  useEffect(() => {
+    if (!hasAnyClientRestrictions) return;
+    setClientRestrictionsEnabled(true);
+  }, [hasAnyClientRestrictions]);
 
-    if (checked) {
-      const nextAllowed = removePresetValues(allowedClients, presetValue);
-      dispatch({ type: "SET_ALLOWED_CLIENTS", payload: nextAllowed });
+  const handleClientRestrictionsEnabledChange = (enabled: boolean) => {
+    setClientRestrictionsEnabled(enabled);
+    if (!enabled) {
+      dispatch({ type: "SET_ALLOWED_CLIENTS", payload: [] });
+      dispatch({ type: "SET_BLOCKED_CLIENTS", payload: [] });
     }
   };
 
-  const handleCustomAllowedChange = (customValues: string[]) => {
-    dispatch({
-      type: "SET_ALLOWED_CLIENTS",
-      payload: mergePresetAndCustomClients(allowedClients, customValues),
-    });
-  };
-
-  const handleCustomBlockedChange = (customValues: string[]) => {
-    dispatch({
-      type: "SET_BLOCKED_CLIENTS",
-      payload: mergePresetAndCustomClients(blockedClients, customValues),
-    });
-  };
+  const getClientRestrictionPresetLabel = useCallback(
+    (presetValue: string) => t(`sections.routing.clientRestrictions.presetClients.${presetValue}`),
+    [t]
+  );
 
   return (
     <TooltipProvider>
@@ -223,129 +201,104 @@ export function RoutingSection() {
 
             {/* Allowed Models */}
             <FieldGroup label={t("sections.routing.modelWhitelist.label")}>
-              <ModelMultiSelect
-                providerType={state.routing.providerType}
-                selectedModels={state.routing.allowedModels}
-                onChange={(value: string[]) =>
-                  dispatch({ type: "SET_ALLOWED_MODELS", payload: value })
-                }
-                disabled={state.ui.isPending}
-                providerUrl={state.basic.url}
-                apiKey={state.basic.key}
-                proxyUrl={state.network.proxyUrl}
-                proxyFallbackToDirect={state.network.proxyFallbackToDirect}
-                providerId={isEdit ? provider?.id : undefined}
-              />
-              {state.routing.allowedModels.length > 0 && (
-                <div className="flex flex-wrap gap-1 p-2 bg-muted/50 rounded-md">
-                  {state.routing.allowedModels.slice(0, 5).map((model) => (
-                    <Badge key={model} variant="outline" className="font-mono text-xs">
-                      {model}
-                    </Badge>
-                  ))}
-                  {state.routing.allowedModels.length > 5 && (
-                    <Badge variant="secondary" className="text-xs">
-                      {t("sections.routing.modelWhitelist.moreModels", {
-                        count: state.routing.allowedModels.length - 5,
+              <div className="space-y-2">
+                <ModelMultiSelect
+                  providerType={state.routing.providerType}
+                  selectedModels={state.routing.allowedModels}
+                  onChange={(value: string[]) =>
+                    dispatch({ type: "SET_ALLOWED_MODELS", payload: value })
+                  }
+                  disabled={state.ui.isPending}
+                  providerUrl={state.basic.url}
+                  apiKey={state.basic.key}
+                  proxyUrl={state.network.proxyUrl}
+                  proxyFallbackToDirect={state.network.proxyFallbackToDirect}
+                  providerId={isEdit ? provider?.id : undefined}
+                />
+                {state.routing.allowedModels.length > 0 && (
+                  <div className="flex flex-wrap gap-1 p-2 bg-muted/50 rounded-md">
+                    {state.routing.allowedModels.slice(0, 5).map((model) => (
+                      <Badge key={model} variant="outline" className="font-mono text-xs">
+                        {model}
+                      </Badge>
+                    ))}
+                    {state.routing.allowedModels.length > 5 && (
+                      <Badge variant="secondary" className="text-xs">
+                        {t("sections.routing.modelWhitelist.moreModels", {
+                          count: state.routing.allowedModels.length - 5,
+                        })}
+                      </Badge>
+                    )}
+                  </div>
+                )}
+                <p className="text-xs text-muted-foreground">
+                  {state.routing.allowedModels.length === 0 ? (
+                    <span className="text-green-600">
+                      {t("sections.routing.modelWhitelist.allowAll")}
+                    </span>
+                  ) : (
+                    <span>
+                      {t("sections.routing.modelWhitelist.selectedOnly", {
+                        count: state.routing.allowedModels.length,
                       })}
-                    </Badge>
+                    </span>
                   )}
-                </div>
-              )}
-              <p className="text-xs text-muted-foreground">
-                {state.routing.allowedModels.length === 0 ? (
-                  <span className="text-green-600">
-                    {t("sections.routing.modelWhitelist.allowAll")}
-                  </span>
-                ) : (
-                  <span>
-                    {t("sections.routing.modelWhitelist.selectedOnly", {
-                      count: state.routing.allowedModels.length,
-                    })}
-                  </span>
-                )}
-              </p>
+                </p>
+              </div>
             </FieldGroup>
-          </div>
 
-          {/* Client Restrictions */}
-          <FieldGroup label={t("sections.routing.clientRestrictions.allowedLabel")}>
-            <div className="space-y-2 rounded-md border p-3">
-              {CLIENT_RESTRICTION_PRESET_OPTIONS.map((option) => {
-                const isAllowed = isPresetSelected(allowedClients, option.value);
-                const isBlocked = isPresetSelected(blockedClients, option.value);
-                return (
-                  <div key={option.value} className="flex items-center gap-4 py-1">
-                    <span className="flex-1 text-sm">
-                      {t(`sections.routing.clientRestrictions.presetClients.${option.value}`)}
-                    </span>
-                    <div className="flex items-center gap-3">
-                      <div className="flex items-center gap-1.5">
-                        <Checkbox
-                          id={`provider-allow-${option.value}`}
-                          checked={isAllowed}
-                          disabled={state.ui.isPending}
-                          onCheckedChange={(checked) =>
-                            handleAllowToggle(option.value, checked === true)
-                          }
-                        />
-                        <Label
-                          htmlFor={`provider-allow-${option.value}`}
-                          className="cursor-pointer text-xs font-normal text-muted-foreground"
-                        >
-                          {t("sections.routing.clientRestrictions.allowAction")}
-                        </Label>
-                      </div>
-                      <div className="flex items-center gap-1.5">
-                        <Checkbox
-                          id={`provider-block-${option.value}`}
-                          checked={isBlocked}
-                          disabled={state.ui.isPending}
-                          onCheckedChange={(checked) =>
-                            handleBlockToggle(option.value, checked === true)
-                          }
-                        />
-                        <Label
-                          htmlFor={`provider-block-${option.value}`}
-                          className="cursor-pointer text-xs font-normal text-muted-foreground"
-                        >
-                          {t("sections.routing.clientRestrictions.blockAction")}
-                        </Label>
-                      </div>
-                    </div>
-                  </div>
-                );
-              })}
-            </div>
-          </FieldGroup>
-
-          <FieldGroup label={t("sections.routing.clientRestrictions.customAllowedLabel")}>
-            <TagInput
-              value={customAllowedClients}
-              onChange={handleCustomAllowedChange}
-              placeholder={t("sections.routing.clientRestrictions.customAllowedPlaceholder")}
-              maxTagLength={64}
-              maxTags={50}
-              disabled={state.ui.isPending}
-            />
-            <p className="mt-1 text-xs text-muted-foreground">
-              {t("sections.routing.clientRestrictions.customHelp")}
-            </p>
-          </FieldGroup>
-
-          <FieldGroup label={t("sections.routing.clientRestrictions.customBlockedLabel")}>
-            <TagInput
-              value={customBlockedClients}
-              onChange={handleCustomBlockedChange}
-              placeholder={t("sections.routing.clientRestrictions.customBlockedPlaceholder")}
-              maxTagLength={64}
-              maxTags={50}
-              disabled={state.ui.isPending}
-            />
-            <p className="mt-1 text-xs text-muted-foreground">
-              {t("sections.routing.clientRestrictions.customHelp")}
-            </p>
-          </FieldGroup>
+            <ToggleRow
+              icon={Info}
+              label={t("sections.routing.clientRestrictions.toggleLabel")}
+              description={t("sections.routing.clientRestrictions.toggleDesc")}
+            >
+              <Switch
+                checked={clientRestrictionsEnabled}
+                onCheckedChange={handleClientRestrictionsEnabledChange}
+                disabled={state.ui.isPending}
+              />
+            </ToggleRow>
+
+            {clientRestrictionsEnabled && (
+              <div className="space-y-3">
+                <div className="space-y-1 rounded-md border bg-muted/30 p-3">
+                  <p className="text-xs text-muted-foreground">
+                    {t("sections.routing.clientRestrictions.priorityNote")}
+                  </p>
+                  <p className="text-xs text-muted-foreground">
+                    {t("sections.routing.clientRestrictions.customHelp")}
+                  </p>
+                </div>
+
+                <ClientRestrictionsEditor
+                  allowed={allowedClients}
+                  blocked={blockedClients}
+                  onAllowedChange={(next) =>
+                    dispatch({ type: "SET_ALLOWED_CLIENTS", payload: next })
+                  }
+                  onBlockedChange={(next) =>
+                    dispatch({ type: "SET_BLOCKED_CLIENTS", payload: next })
+                  }
+                  allowedLabel={t("sections.routing.clientRestrictions.allowedLabel")}
+                  blockedLabel={t("sections.routing.clientRestrictions.blockedLabel")}
+                  allowedPlaceholder={t("sections.routing.clientRestrictions.allowedPlaceholder")}
+                  blockedPlaceholder={t("sections.routing.clientRestrictions.blockedPlaceholder")}
+                  disabled={state.ui.isPending}
+                  getPresetLabel={getClientRestrictionPresetLabel}
+                  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);
+                  }}
+                />
+              </div>
+            )}
+          </div>
         </SectionCard>
 
         {/* Scheduling Parameters */}
@@ -596,7 +549,10 @@ export function RoutingSection() {
                           )}
                         </SelectContent>
                       </Select>
-                      <Info className="absolute right-10 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
+                      <Info
+                        aria-hidden="true"
+                        className="pointer-events-none absolute right-10 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground"
+                      />
                     </div>
                   </TooltipTrigger>
                   <TooltipContent side="top" className="max-w-xs">
@@ -716,7 +672,13 @@ export function RoutingSection() {
                     }}
                     disabled={state.ui.isPending}
                   >
-                    <SelectTrigger className="w-40">
+                    <SelectTrigger
+                      className={
+                        state.routing.anthropicMaxTokensPreference === "inherit"
+                          ? "flex-1 min-w-0"
+                          : "w-40"
+                      }
+                    >
                       <SelectValue />
                     </SelectTrigger>
                     <SelectContent>

+ 1 - 1
src/app/[locale]/settings/providers/_components/forms/test-result-card.tsx

@@ -172,7 +172,7 @@ export function TestResultCard({ result }: TestResultCardProps) {
                 {t("viewDetails")}
               </Button>
             </DialogTrigger>
-            <DialogContent className="max-w-4xl max-h-[85vh] overflow-y-auto">
+            <DialogContent className="max-w-4xl max-h-[var(--cch-viewport-height-85)] overflow-y-auto">
               <DialogHeader>
                 <DialogTitle className="flex items-center gap-2">
                   {icon}

+ 45 - 30
src/app/[locale]/settings/providers/_components/model-multi-select.tsx

@@ -1,7 +1,7 @@
 "use client";
 import { Check, ChevronsUpDown, Cloud, Database, Loader2, Plus, RefreshCw } from "lucide-react";
 import { useTranslations } from "next-intl";
-import { useCallback, useEffect, useState } from "react";
+import { useCallback, useEffect, useMemo, useState } from "react";
 import { getAvailableModelsByProviderType } from "@/actions/model-prices";
 import { fetchUpstreamModels, getUnmaskedProviderKey } from "@/actions/providers";
 import { Badge } from "@/components/ui/badge";
@@ -40,6 +40,38 @@ interface ModelMultiSelectProps {
   providerId?: number;
 }
 
+function ModelSourceIndicator({
+  loading,
+  isUpstream,
+  label,
+  description,
+}: {
+  loading: boolean;
+  isUpstream: boolean;
+  label: string;
+  description: string;
+}) {
+  if (loading) return null;
+
+  const Icon = isUpstream ? Cloud : Database;
+
+  return (
+    <TooltipProvider>
+      <Tooltip>
+        <TooltipTrigger asChild>
+          <div className="flex items-center gap-1.5 px-2 py-1 rounded-md bg-muted/50 text-xs text-muted-foreground">
+            <Icon className="h-3 w-3" />
+            <span>{label}</span>
+          </div>
+        </TooltipTrigger>
+        <TooltipContent side="top" className="max-w-[200px]">
+          <p className="text-xs">{description}</p>
+        </TooltipContent>
+      </Tooltip>
+    </TooltipProvider>
+  );
+}
+
 export function ModelMultiSelect({
   providerType,
   selectedModels,
@@ -58,7 +90,7 @@ export function ModelMultiSelect({
   const [modelSource, setModelSource] = useState<ModelSource>("loading");
   const [customModel, setCustomModel] = useState("");
 
-  const displayedModels = (() => {
+  const displayedModels = useMemo(() => {
     const seen = new Set<string>();
     const merged: string[] = [];
 
@@ -76,7 +108,7 @@ export function ModelMultiSelect({
     }
 
     return merged;
-  })();
+  }, [availableModels, selectedModels]);
 
   // 供应商类型到显示名称的映射
   const getProviderTypeLabel = (type: string): string => {
@@ -162,31 +194,9 @@ export function ModelMultiSelect({
     setCustomModel("");
   };
 
-  // 数据来源指示器
-  const SourceIndicator = () => {
-    if (loading) return null;
-
-    const isUpstream = modelSource === "upstream";
-    const Icon = isUpstream ? Cloud : Database;
-    const label = isUpstream ? t("sourceUpstream") : t("sourceFallback");
-    const description = isUpstream ? t("sourceUpstreamDesc") : t("sourceFallbackDesc");
-
-    return (
-      <TooltipProvider>
-        <Tooltip>
-          <TooltipTrigger asChild>
-            <div className="flex items-center gap-1.5 px-2 py-1 rounded-md bg-muted/50 text-xs text-muted-foreground">
-              <Icon className="h-3 w-3" />
-              <span>{label}</span>
-            </div>
-          </TooltipTrigger>
-          <TooltipContent side="top" className="max-w-[200px]">
-            <p className="text-xs">{description}</p>
-          </TooltipContent>
-        </Tooltip>
-      </TooltipProvider>
-    );
-  };
+  const isUpstream = modelSource === "upstream";
+  const sourceLabel = isUpstream ? t("sourceUpstream") : t("sourceFallback");
+  const sourceDescription = isUpstream ? t("sourceUpstreamDesc") : t("sourceFallbackDesc");
 
   return (
     <Popover open={open} onOpenChange={setOpen}>
@@ -222,7 +232,7 @@ export function ModelMultiSelect({
         </Button>
       </PopoverTrigger>
       <PopoverContent
-        className="w-[400px] p-0 flex flex-col"
+        className="w-[400px] max-w-[calc(100vw-2rem)] p-0 flex flex-col"
         align="start"
         onWheel={(e) => e.stopPropagation()}
         onTouchMove={(e) => e.stopPropagation()}
@@ -238,7 +248,12 @@ export function ModelMultiSelect({
                 <CommandGroup>
                   <div className="flex items-center justify-between gap-2 p-2">
                     <div className="flex items-center gap-2">
-                      <SourceIndicator />
+                      <ModelSourceIndicator
+                        loading={loading}
+                        isUpstream={isUpstream}
+                        label={sourceLabel}
+                        description={sourceDescription}
+                      />
                       <TooltipProvider>
                         <Tooltip>
                           <TooltipTrigger asChild>

+ 2 - 2
src/app/[locale]/settings/providers/_components/provider-list-item.legacy.tsx

@@ -280,7 +280,7 @@ export function ProviderListItem({
                       <Edit className="h-3.5 w-3.5" />
                     </Button>
                   </DialogTrigger>
-                  <DialogContent className="sm:max-w-3xl max-h-[85vh] overflow-y-auto">
+                  <DialogContent className="sm:max-w-3xl max-h-[var(--cch-viewport-height-90)] flex flex-col overflow-hidden p-0 gap-0">
                     <FormErrorBoundary>
                       <ProviderForm
                         mode="edit"
@@ -308,7 +308,7 @@ export function ProviderListItem({
                       <Copy className="h-3.5 w-3.5" />
                     </Button>
                   </DialogTrigger>
-                  <DialogContent className="sm:max-w-3xl max-h-[85vh] overflow-y-auto">
+                  <DialogContent className="sm:max-w-3xl max-h-[var(--cch-viewport-height-90)] flex flex-col overflow-hidden p-0 gap-0">
                     <FormErrorBoundary>
                       <ProviderForm
                         mode="create"

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

@@ -978,7 +978,7 @@ export function ProviderRichListItem({
 
       {/* 编辑 Dialog */}
       <Dialog open={openEdit} onOpenChange={setOpenEdit}>
-        <DialogContent className="max-w-6xl max-h-[90vh] flex flex-col">
+        <DialogContent className="max-w-6xl max-h-[var(--cch-viewport-height-90)] flex flex-col overflow-hidden p-0 gap-0">
           <VisuallyHidden>
             <DialogTitle>{t("editProvider")}</DialogTitle>
           </VisuallyHidden>
@@ -997,7 +997,7 @@ export function ProviderRichListItem({
 
       {/* 克隆 Dialog */}
       <Dialog open={openClone} onOpenChange={setOpenClone}>
-        <DialogContent className="max-w-6xl max-h-[90vh] flex flex-col">
+        <DialogContent className="max-w-6xl max-h-[var(--cch-viewport-height-90)] flex flex-col overflow-hidden p-0 gap-0">
           <VisuallyHidden>
             <DialogTitle>{t("clone")}</DialogTitle>
           </VisuallyHidden>

+ 1 - 1
src/app/[locale]/settings/providers/_components/recluster-vendors-dialog.tsx

@@ -140,7 +140,7 @@ export function ReclusterVendorsDialog() {
           {t("button")}
         </Button>
       </DialogTrigger>
-      <DialogContent className="max-w-2xl max-h-[80vh] flex flex-col">
+      <DialogContent className="max-w-2xl max-h-[var(--cch-viewport-height-80)] flex flex-col">
         <DialogHeader>
           <DialogTitle>{t("dialogTitle")}</DialogTitle>
           <DialogDescription>{t("dialogDescription")}</DialogDescription>

+ 1 - 1
src/app/[locale]/settings/providers/_components/scheduling-rules-dialog.tsx

@@ -227,7 +227,7 @@ export function SchedulingRulesDialog() {
           {t("triggerButton")}
         </Button>
       </DialogTrigger>
-      <DialogContent className="max-w-3xl max-h-[80vh] overflow-y-auto">
+      <DialogContent className="max-w-3xl max-h-[var(--cch-viewport-height-80)] overflow-y-auto">
         <DialogHeader>
           <DialogTitle className="flex items-center gap-2 text-xl">
             <Lightbulb className="h-5 w-5 text-primary" />

+ 1 - 1
src/app/[locale]/settings/providers/_components/thinking-budget-editor.tsx

@@ -49,7 +49,7 @@ export function ThinkingBudgetEditor({
       <TooltipTrigger asChild>
         <div className="flex gap-2 items-center">
           <Select value={mode} onValueChange={handleModeChange} disabled={disabled}>
-            <SelectTrigger className="w-40">
+            <SelectTrigger className={mode === "inherit" ? "flex-1 min-w-0" : "w-40"}>
               <SelectValue />
             </SelectTrigger>
             <SelectContent>

+ 2 - 2
src/app/[locale]/settings/providers/_components/vendor-keys-compact-list.tsx

@@ -105,7 +105,7 @@ export function VendorKeysCompactList(props: {
                 {t("addVendorKey")}
               </Button>
             </DialogTrigger>
-            <DialogContent className="max-w-full sm:max-w-5xl lg:max-w-6xl max-h-[90vh] flex flex-col">
+            <DialogContent className="max-w-full sm:max-w-5xl lg:max-w-6xl max-h-[var(--cch-viewport-height-90)] flex flex-col overflow-hidden p-0 gap-0">
               <VisuallyHidden>
                 <DialogTitle>{t("addVendorKey")}</DialogTitle>
               </VisuallyHidden>
@@ -503,7 +503,7 @@ function VendorKeyRow(props: {
                     <Edit2 className="h-4 w-4" />
                   </Button>
                 </DialogTrigger>
-                <DialogContent className="max-w-full sm:max-w-5xl lg:max-w-6xl max-h-[90vh] flex flex-col">
+                <DialogContent className="max-w-full sm:max-w-5xl lg:max-w-6xl max-h-[var(--cch-viewport-height-90)] flex flex-col overflow-hidden p-0 gap-0">
                   <VisuallyHidden>
                     <DialogTitle>{t("editProvider")}</DialogTitle>
                   </VisuallyHidden>

+ 1 - 1
src/app/[locale]/settings/request-filters/_components/filter-dialog.tsx

@@ -260,7 +260,7 @@ export function FilterDialog({ mode, trigger, filter, open, onOpenChange }: Prop
   };
 
   const content = (
-    <DialogContent className="max-w-2xl max-h-[80vh] flex flex-col bg-card/95 backdrop-blur-xl border-border">
+    <DialogContent className="max-w-2xl max-h-[var(--cch-viewport-height-80)] flex flex-col bg-card/95 backdrop-blur-xl border-border">
       <DialogHeader className="flex-shrink-0">
         <DialogTitle className="text-foreground">
           {mode === "create" ? t("dialog.createTitle") : t("dialog.editTitle")}

+ 1 - 1
src/app/[locale]/settings/sensitive-words/_components/add-word-dialog.tsx

@@ -76,7 +76,7 @@ export function AddWordDialog() {
           {t("sensitiveWords.add")}
         </Button>
       </DialogTrigger>
-      <DialogContent className="max-w-2xl max-h-[80vh] flex flex-col">
+      <DialogContent className="max-w-2xl max-h-[var(--cch-viewport-height-80)] flex flex-col">
         <form onSubmit={handleSubmit} className="flex flex-col flex-1 min-h-0">
           <DialogHeader className="flex-shrink-0">
             <DialogTitle>{t("sensitiveWords.dialog.addTitle")}</DialogTitle>

+ 1 - 1
src/app/[locale]/settings/sensitive-words/_components/edit-word-dialog.tsx

@@ -79,7 +79,7 @@ export function EditWordDialog({ word, open, onOpenChange }: EditWordDialogProps
 
   return (
     <Dialog open={open} onOpenChange={onOpenChange}>
-      <DialogContent className="max-w-2xl max-h-[80vh] flex flex-col">
+      <DialogContent className="max-w-2xl max-h-[var(--cch-viewport-height-80)] flex flex-col">
         <form onSubmit={handleSubmit} className="flex flex-col flex-1 min-h-0">
           <DialogHeader className="flex-shrink-0">
             <DialogTitle>{t("sensitiveWords.dialog.editTitle")}</DialogTitle>

+ 1 - 1
src/app/[locale]/usage-doc/layout.tsx

@@ -42,7 +42,7 @@ export default async function UsageDocLayout({
   const [session, t] = await Promise.all([getSession(), getUsageTranslations(locale)]);
 
   return (
-    <div className="min-h-screen bg-background">
+    <div className="min-h-[var(--cch-viewport-height,100vh)] bg-background">
       {/* 条件渲染头部:已登录显示 DashboardHeader,未登录显示简化版头部 */}
       {session ? (
         <DashboardHeader session={session} />

+ 3 - 1
src/app/global-error.tsx

@@ -1,5 +1,7 @@
 "use client";
 
+import "./globals.css";
+
 import { isNetworkError } from "@/lib/utils/error-detection";
 
 /**
@@ -37,7 +39,7 @@ export default function GlobalError({
             flexDirection: "column",
             alignItems: "center",
             justifyContent: "center",
-            minHeight: "100vh",
+            minHeight: "var(--cch-viewport-height, 100vh)",
             fontFamily: "system-ui, sans-serif",
             backgroundColor: "#f8f9fa",
             padding: "20px",

+ 25 - 0
src/app/globals.css

@@ -45,6 +45,13 @@
 
 :root {
   --radius: 0.65rem;
+  --cch-viewport-height: 100vh;
+  --cch-viewport-height-50: 50vh;
+  --cch-viewport-height-70: 70vh;
+  --cch-viewport-height-80: 80vh;
+  --cch-viewport-height-85: 85vh;
+  --cch-viewport-height-90: 90vh;
+  --cch-viewport-height-95: 95vh;
   --background: oklch(1 0 0);
   --foreground: oklch(0.141 0.005 285.823);
   --card: oklch(1 0 0);
@@ -78,6 +85,18 @@
   --sidebar-ring: oklch(0.705 0.213 47.604);
 }
 
+@supports (height: 100dvh) {
+  :root {
+    --cch-viewport-height: 100dvh;
+    --cch-viewport-height-50: 50dvh;
+    --cch-viewport-height-70: 70dvh;
+    --cch-viewport-height-80: 80dvh;
+    --cch-viewport-height-85: 85dvh;
+    --cch-viewport-height-90: 90dvh;
+    --cch-viewport-height-95: 95dvh;
+  }
+}
+
 .dark {
   --background: oklch(0.141 0.005 285.823);
   --foreground: oklch(0.985 0 0);
@@ -135,6 +154,12 @@
     display: none;
   }
 
+  /* iOS 安全区:避免底部固定导航被 Home Indicator 挡住 */
+  .safe-area-bottom {
+    /* 可通过 --safe-area-pb 覆盖:例如 style={{ "--safe-area-pb": "0px" }} */
+    padding-bottom: var(--safe-area-pb, env(safe-area-inset-bottom, 0px));
+  }
+
   /* 新增记录高亮动画 */
   @keyframes highlight-flash {
     0% {

+ 145 - 0
src/components/form/client-restrictions-editor.tsx

@@ -0,0 +1,145 @@
+"use client";
+
+import { useMemo } from "react";
+import { TagInput, type TagInputSuggestion } from "@/components/ui/tag-input";
+import { CLIENT_RESTRICTION_PRESET_OPTIONS } from "@/lib/client-restrictions/client-presets";
+import { cn } from "@/lib/utils";
+
+function uniqueOrdered(values: string[]): string[] {
+  const seen = new Set<string>();
+  const result: string[] = [];
+
+  for (const value of values) {
+    const trimmed = value.trim();
+    if (!trimmed) continue;
+    if (seen.has(trimmed)) continue;
+    seen.add(trimmed);
+    result.push(trimmed);
+  }
+
+  return result;
+}
+
+interface ClientRestrictionListEditorProps {
+  label: string;
+  values: string[];
+  placeholder?: string;
+  disabled?: boolean;
+  suggestions: TagInputSuggestion[];
+  onChange: (next: string[]) => void;
+  onInvalidTag?: (tag: string, reason: string) => void;
+  className?: string;
+}
+
+function ClientRestrictionListEditor({
+  label,
+  values,
+  placeholder,
+  disabled,
+  suggestions,
+  onChange,
+  onInvalidTag,
+  className,
+}: ClientRestrictionListEditorProps) {
+  return (
+    <div className={cn("space-y-3", className)}>
+      <div className="text-sm font-medium text-foreground">{label}</div>
+      <TagInput
+        value={values}
+        onChange={onChange}
+        placeholder={placeholder}
+        maxTagLength={64}
+        maxTags={50}
+        disabled={disabled}
+        validateTag={() => true}
+        onInvalidTag={onInvalidTag}
+        suggestions={suggestions}
+      />
+    </div>
+  );
+}
+
+export interface ClientRestrictionsEditorProps {
+  allowed: string[];
+  blocked: string[];
+  onAllowedChange: (next: string[]) => void;
+  onBlockedChange: (next: string[]) => void;
+  allowedLabel: string;
+  blockedLabel: string;
+  allowedPlaceholder?: string;
+  blockedPlaceholder?: string;
+  disabled?: boolean;
+  getPresetLabel: (presetValue: string) => string;
+  onInvalidTag?: (tag: string, reason: string) => void;
+  className?: string;
+}
+
+export function ClientRestrictionsEditor({
+  allowed,
+  blocked,
+  onAllowedChange,
+  onBlockedChange,
+  allowedLabel,
+  blockedLabel,
+  allowedPlaceholder,
+  blockedPlaceholder,
+  disabled,
+  getPresetLabel,
+  onInvalidTag,
+  className,
+}: ClientRestrictionsEditorProps) {
+  const suggestions: TagInputSuggestion[] = useMemo(
+    () =>
+      CLIENT_RESTRICTION_PRESET_OPTIONS.map((option) => ({
+        value: option.value,
+        label: getPresetLabel(option.value),
+        keywords: [...option.aliases],
+      })),
+    [getPresetLabel]
+  );
+
+  const handleAllowedChange = (next: string[]) => {
+    const nextAllowed = uniqueOrdered(next);
+    onAllowedChange(nextAllowed);
+
+    const allowedSet = new Set(nextAllowed);
+    const nextBlocked = blocked.filter((value) => !allowedSet.has(value));
+    if (nextBlocked.length !== blocked.length) {
+      onBlockedChange(nextBlocked);
+    }
+  };
+
+  const handleBlockedChange = (next: string[]) => {
+    const nextBlocked = uniqueOrdered(next);
+    onBlockedChange(nextBlocked);
+
+    const blockedSet = new Set(nextBlocked);
+    const nextAllowed = allowed.filter((value) => !blockedSet.has(value));
+    if (nextAllowed.length !== allowed.length) {
+      onAllowedChange(nextAllowed);
+    }
+  };
+
+  return (
+    <div className={cn("grid grid-cols-1 gap-4 sm:grid-cols-2", className)}>
+      <ClientRestrictionListEditor
+        label={allowedLabel}
+        values={allowed}
+        placeholder={allowedPlaceholder}
+        disabled={disabled}
+        suggestions={suggestions}
+        onChange={handleAllowedChange}
+        onInvalidTag={onInvalidTag}
+      />
+      <ClientRestrictionListEditor
+        label={blockedLabel}
+        values={blocked}
+        placeholder={blockedPlaceholder}
+        disabled={disabled}
+        suggestions={suggestions}
+        onChange={handleBlockedChange}
+        onInvalidTag={onInvalidTag}
+      />
+    </div>
+  );
+}

+ 12 - 8
src/components/ui/__tests__/calendar-highlight.test.tsx

@@ -65,8 +65,9 @@ describe("Calendar highlight classes", () => {
 
   test("Calendar range_start className should use primary-based highlight", () => {
     const startDate = new Date();
-    const endDate = new Date();
-    endDate.setDate(endDate.getDate() + 5);
+    startDate.setDate(10);
+    const endDate = new Date(startDate);
+    endDate.setDate(15);
 
     const { container, unmount } = render(
       <Calendar mode="range" selected={{ from: startDate, to: endDate }} />
@@ -89,8 +90,9 @@ describe("Calendar highlight classes", () => {
 
   test("Calendar range_middle className should use primary-based highlight", () => {
     const startDate = new Date();
-    const endDate = new Date();
-    endDate.setDate(endDate.getDate() + 5);
+    startDate.setDate(10);
+    const endDate = new Date(startDate);
+    endDate.setDate(15);
 
     const { container, unmount } = render(
       <Calendar mode="range" selected={{ from: startDate, to: endDate }} />
@@ -113,8 +115,9 @@ describe("Calendar highlight classes", () => {
 
   test("Calendar range_end className should use primary-based highlight", () => {
     const startDate = new Date();
-    const endDate = new Date();
-    endDate.setDate(endDate.getDate() + 5);
+    startDate.setDate(10);
+    const endDate = new Date(startDate);
+    endDate.setDate(15);
 
     const { container, unmount } = render(
       <Calendar mode="range" selected={{ from: startDate, to: endDate }} />
@@ -137,8 +140,9 @@ describe("Calendar highlight classes", () => {
 
   test("CalendarDayButton should have primary-based highlight classes for range states", () => {
     const startDate = new Date();
-    const endDate = new Date();
-    endDate.setDate(endDate.getDate() + 5);
+    startDate.setDate(10);
+    const endDate = new Date(startDate);
+    endDate.setDate(15);
 
     const { container, unmount } = render(
       <Calendar mode="range" selected={{ from: startDate, to: endDate }} />

+ 42 - 0
src/components/ui/__tests__/tag-input-dialog.test.tsx

@@ -94,6 +94,48 @@ describe("TagInput inside Dialog", () => {
     unmount();
   });
 
+  test("keeps suggestions open after selecting a suggestion", async () => {
+    const { unmount } = render(<DialogTagInput />);
+
+    const input = document.querySelector("input");
+    expect(input).not.toBeNull();
+
+    await act(async () => {
+      input?.focus();
+      await new Promise((resolve) => setTimeout(resolve, 50));
+    });
+
+    const dialogContent = document.querySelector('[data-slot="dialog-content"]');
+    expect(dialogContent).not.toBeNull();
+
+    const findSuggestion = (label: string) =>
+      Array.from(dialogContent?.querySelectorAll("button") ?? []).find(
+        (button) => button.textContent === label
+      );
+
+    const first = findSuggestion("Tag 1");
+    expect(first).not.toBeNull();
+
+    await act(async () => {
+      first?.dispatchEvent(new MouseEvent("mousedown", { bubbles: true }));
+      await new Promise((resolve) => setTimeout(resolve, 0));
+    });
+
+    const second = findSuggestion("Tag 2");
+    expect(second).not.toBeNull();
+
+    await act(async () => {
+      second?.dispatchEvent(new MouseEvent("mousedown", { bubbles: true }));
+      await new Promise((resolve) => setTimeout(resolve, 0));
+    });
+
+    const dialogContentAfterClicks = document.querySelector('[data-slot="dialog-content"]');
+    expect(dialogContentAfterClicks?.textContent).toContain("tag1");
+    expect(dialogContentAfterClicks?.textContent).toContain("tag2");
+
+    unmount();
+  });
+
   test("supports keyboard selection within dialog", async () => {
     const { container, unmount } = render(<DialogTagInput />);
 

+ 3 - 2
src/components/ui/drawer.tsx

@@ -49,8 +49,9 @@ function DrawerContent({
         data-slot="drawer-content"
         className={cn(
           "group/drawer-content bg-background fixed z-50 flex h-auto flex-col",
-          "data-[vaul-drawer-direction=top]:inset-x-0 data-[vaul-drawer-direction=top]:top-0 data-[vaul-drawer-direction=top]:mb-24 data-[vaul-drawer-direction=top]:max-h-[80vh] data-[vaul-drawer-direction=top]:rounded-b-lg data-[vaul-drawer-direction=top]:border-b",
-          "data-[vaul-drawer-direction=bottom]:inset-x-0 data-[vaul-drawer-direction=bottom]:bottom-0 data-[vaul-drawer-direction=bottom]:mt-24 data-[vaul-drawer-direction=bottom]:max-h-[95vh] data-[vaul-drawer-direction=bottom]:rounded-t-lg data-[vaul-drawer-direction=bottom]:border-t",
+          "data-[vaul-drawer-direction=bottom]:safe-area-bottom data-[vaul-drawer-direction=left]:safe-area-bottom data-[vaul-drawer-direction=right]:safe-area-bottom",
+          "data-[vaul-drawer-direction=top]:inset-x-0 data-[vaul-drawer-direction=top]:top-0 data-[vaul-drawer-direction=top]:mb-24 data-[vaul-drawer-direction=top]:max-h-[var(--cch-viewport-height-80)] data-[vaul-drawer-direction=top]:rounded-b-lg data-[vaul-drawer-direction=top]:border-b",
+          "data-[vaul-drawer-direction=bottom]:inset-x-0 data-[vaul-drawer-direction=bottom]:bottom-0 data-[vaul-drawer-direction=bottom]:mt-24 data-[vaul-drawer-direction=bottom]:max-h-[var(--cch-viewport-height-95)] data-[vaul-drawer-direction=bottom]:rounded-t-lg data-[vaul-drawer-direction=bottom]:border-t",
           "data-[vaul-drawer-direction=right]:inset-y-0 data-[vaul-drawer-direction=right]:right-0 data-[vaul-drawer-direction=right]:w-3/4 data-[vaul-drawer-direction=right]:border-l data-[vaul-drawer-direction=right]:sm:max-w-sm",
           "data-[vaul-drawer-direction=left]:inset-y-0 data-[vaul-drawer-direction=left]:left-0 data-[vaul-drawer-direction=left]:w-3/4 data-[vaul-drawer-direction=left]:border-r data-[vaul-drawer-direction=left]:sm:max-w-sm",
           className

+ 1 - 0
src/components/ui/sheet.tsx

@@ -53,6 +53,7 @@ function SheetContent({
         data-slot="sheet-content"
         className={cn(
           "bg-background data-[state=open]:animate-in data-[state=closed]:animate-out fixed z-50 flex flex-col gap-4 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500",
+          side !== "top" && "safe-area-bottom",
           side === "right" &&
             "data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right inset-y-0 right-0 h-full w-3/4 border-l sm:max-w-sm",
           side === "left" &&

+ 8 - 8
src/components/ui/tag-input.tsx

@@ -160,19 +160,19 @@ export function TagInput({
 
     const handleClickOutside = (e: MouseEvent) => {
       const target = e.target as Node;
-      if (
-        containerRef.current &&
-        !containerRef.current.contains(target) &&
-        dropdownRef.current &&
-        !dropdownRef.current.contains(target)
-      ) {
+      const isInContainer = containerRef.current?.contains(target) ?? false;
+      const isInDropdown = dropdownRef.current?.contains(target) ?? false;
+
+      // 使用 capture 阶段监听,避免点击建议项后 React 同步更新导致 target 节点被移除,
+      // 进而被误判为“点击了外部”并关闭下拉列表。
+      if (!isInContainer && !isInDropdown) {
         setShowSuggestions(false);
         setHighlightedIndex(-1);
       }
     };
 
-    document.addEventListener("mousedown", handleClickOutside);
-    return () => document.removeEventListener("mousedown", handleClickOutside);
+    document.addEventListener("mousedown", handleClickOutside, true);
+    return () => document.removeEventListener("mousedown", handleClickOutside, true);
   }, [showSuggestions]);
 
   const inputMinWidthClass = normalizedMaxVisible === undefined ? "min-w-[120px]" : "min-w-[60px]";

+ 51 - 42
src/lib/redis/client.ts

@@ -20,7 +20,7 @@ function maskRedisUrl(redisUrl: string) {
  * Supports skipping certificate verification via REDIS_TLS_REJECT_UNAUTHORIZED env.
  * Includes servername for SNI (Server Name Indication) support.
  */
-function buildTlsConfig(redisUrl: string): Record<string, unknown> {
+function buildTlsConfig(redisUrl: string): NonNullable<RedisOptions["tls"]> {
   const raw = process.env.REDIS_TLS_REJECT_UNAUTHORIZED?.trim();
   const rejectUnauthorized = raw !== "false" && raw !== "0";
 
@@ -42,7 +42,10 @@ function buildTlsConfig(redisUrl: string): Record<string, unknown> {
  * - When `redis://` is used, keep plaintext TCP (no TLS option)
  * - Supports REDIS_TLS_REJECT_UNAUTHORIZED env to skip certificate verification
  */
-export function buildRedisOptionsForUrl(redisUrl: string) {
+export function buildRedisOptionsForUrl(redisUrl: string): {
+  isTLS: boolean;
+  options: RedisOptions;
+} {
   const isTLS = (() => {
     try {
       const parsed = new URL(redisUrl);
@@ -53,7 +56,7 @@ export function buildRedisOptionsForUrl(redisUrl: string) {
     }
   })();
 
-  const baseOptions = {
+  const baseOptions: RedisOptions = {
     enableOfflineQueue: false, // 快速失败
     maxRetriesPerRequest: 3,
     retryStrategy(times: number) {
@@ -65,17 +68,18 @@ export function buildRedisOptionsForUrl(redisUrl: string) {
       logger.warn(`[Redis] Retry ${times}/5 after ${delay}ms`);
       return delay;
     },
-  } as const;
+  };
 
-  // Explicit TLS config for Upstash and other managed Redis providers
-  const tlsOptions = isTLS ? { tls: buildTlsConfig(redisUrl) } : {};
+  const options: RedisOptions = isTLS
+    ? { ...baseOptions, tls: buildTlsConfig(redisUrl) }
+    : { ...baseOptions };
 
-  return { isTLS, options: { ...baseOptions, ...tlsOptions } };
+  return { isTLS, options };
 }
 
 export function getRedisClient(input?: { allowWhenRateLimitDisabled?: boolean }): Redis | null {
-  // Skip Redis connection during CI/build phase (avoid connection attempts)
-  if (process.env.CI === "true" || process.env.NEXT_PHASE === "phase-production-build") {
+  // Skip Redis connection during Next.js production build phase (avoid connection attempts)
+  if (process.env.NEXT_PHASE === "phase-production-build") {
     return null;
   }
 
@@ -92,43 +96,27 @@ export function getRedisClient(input?: { allowWhenRateLimitDisabled?: boolean })
   const safeRedisUrl = maskRedisUrl(redisUrl);
 
   if (redisClient) {
-    return redisClient;
+    if (redisClient.status === "end") {
+      redisClient = null;
+    } else {
+      return redisClient;
+    }
   }
 
   try {
-    const useTls = redisUrl.startsWith("rediss://");
-
-    // 1. 定义基础配置
-    const redisOptions: RedisOptions = {
-      enableOfflineQueue: false, // 快速失败
-      maxRetriesPerRequest: 3,
-      retryStrategy(times) {
-        if (times > 5) {
-          logger.error("[Redis] Max retries reached, giving up");
-          return null; // 停止重试,降级
-        }
-        const delay = Math.min(times * 200, 2000);
-        logger.warn(`[Redis] Retry ${times}/5 after ${delay}ms`);
-        return delay;
-      },
-    };
+    const { isTLS: useTls, options: redisOptions } = buildRedisOptionsForUrl(redisUrl);
 
-    // 2. 如果使用 rediss://,则添加显式的 TLS 配置(支持跳过证书验证)
     if (useTls) {
-      const raw = process.env.REDIS_TLS_REJECT_UNAUTHORIZED?.trim();
-      const rejectUnauthorized = raw !== "false" && raw !== "0";
-      logger.info("[Redis] Using TLS connection (rediss://)", {
-        redisUrl: safeRedisUrl,
-        rejectUnauthorized,
-      });
-      redisOptions.tls = buildTlsConfig(redisUrl);
+      logger.info("[Redis] Using TLS connection (rediss://)", { redisUrl: safeRedisUrl });
     }
 
     // 3. 使用组合后的配置创建客户端
-    redisClient = new Redis(redisUrl, redisOptions);
+    const client = new Redis(redisUrl, redisOptions);
+    redisClient = client;
 
     // 4. 保持原始的事件监听器
-    redisClient.on("connect", () => {
+    client.on("connect", () => {
+      if (redisClient !== client) return;
       logger.info("[Redis] Connected successfully", {
         protocol: useTls ? "rediss" : "redis",
         tlsEnabled: useTls,
@@ -136,7 +124,8 @@ export function getRedisClient(input?: { allowWhenRateLimitDisabled?: boolean })
       });
     });
 
-    redisClient.on("error", (error) => {
+    client.on("error", (error) => {
+      if (redisClient !== client) return;
       logger.error("[Redis] Connection error", {
         error: error instanceof Error ? error.message : String(error),
         protocol: useTls ? "rediss" : "redis",
@@ -145,12 +134,19 @@ export function getRedisClient(input?: { allowWhenRateLimitDisabled?: boolean })
       });
     });
 
-    redisClient.on("close", () => {
+    client.on("close", () => {
+      if (redisClient !== client) return;
       logger.warn("[Redis] Connection closed", { redisUrl: safeRedisUrl });
     });
 
+    client.on("end", () => {
+      if (redisClient !== client) return;
+      logger.warn("[Redis] Connection ended, resetting client", { redisUrl: safeRedisUrl });
+      redisClient = null;
+    });
+
     // 5. 返回客户端实例
-    return redisClient;
+    return client;
   } catch (error) {
     logger.error("[Redis] Failed to initialize:", error, { redisUrl: safeRedisUrl });
     return null;
@@ -158,8 +154,21 @@ export function getRedisClient(input?: { allowWhenRateLimitDisabled?: boolean })
 }
 
 export async function closeRedis(): Promise<void> {
-  if (redisClient) {
-    await redisClient.quit();
-    redisClient = null;
+  const client = redisClient;
+  if (!client) return;
+
+  try {
+    if (client.status !== "end") {
+      await client.quit();
+    }
+  } catch (error) {
+    logger.warn("[Redis] Error during quit, forcing disconnect", {
+      error: error instanceof Error ? error.message : String(error),
+    });
+    client.disconnect();
+  } finally {
+    if (redisClient === client) {
+      redisClient = null;
+    }
   }
 }

+ 218 - 0
tests/e2e/_helpers/auth.ts

@@ -0,0 +1,218 @@
+function resolveAppOrigin(apiBaseUrl: string): string {
+  return apiBaseUrl.replace(/\/api\/actions\/?$/, "");
+}
+
+export function splitSetCookieHeader(combined: string): string[] {
+  const cookies: string[] = [];
+  let start = 0;
+  let inExpires = false;
+  let inQuotes = false;
+  let escapeNext = false;
+
+  function isExpiresStart(index: number): boolean {
+    if (combined.slice(index, index + 8).toLowerCase() !== "expires=") return false;
+    if (index === 0) return true;
+    const prev = combined[index - 1];
+    return prev === ";" || prev === " " || prev === "\t";
+  }
+
+  for (let i = 0; i < combined.length; i++) {
+    const ch = combined[i];
+
+    if (inQuotes) {
+      if (escapeNext) {
+        escapeNext = false;
+        continue;
+      }
+      if (ch === "\\") {
+        escapeNext = true;
+        continue;
+      }
+      if (ch === '"') {
+        inQuotes = false;
+      }
+      continue;
+    }
+
+    if (ch === '"') {
+      inQuotes = true;
+      continue;
+    }
+
+    if (!inExpires && isExpiresStart(i)) {
+      inExpires = true;
+      i += 7;
+      continue;
+    }
+
+    if (inExpires && ch === ";") {
+      inExpires = false;
+      continue;
+    }
+
+    if (ch !== ",") continue;
+
+    const next = combined.slice(i + 1);
+    const looksLikeCookieStart = /^\s*[^;\s]+=/.test(next);
+    if (!looksLikeCookieStart) {
+      continue;
+    }
+
+    const part = combined.slice(start, i).trim();
+    if (part) {
+      cookies.push(part);
+    }
+    start = i + 1;
+    inExpires = false;
+  }
+
+  const last = combined.slice(start).trim();
+  if (last) {
+    cookies.push(last);
+  }
+
+  return cookies;
+}
+
+function getSetCookieHeaders(response: Response): string[] {
+  const headersWithGetSetCookie = response.headers as unknown as {
+    getSetCookie?: () => string[];
+  };
+
+  const headerList = headersWithGetSetCookie.getSetCookie?.();
+  if (Array.isArray(headerList) && headerList.length > 0) {
+    return headerList;
+  }
+
+  const combined = response.headers.get("set-cookie");
+  if (!combined) return [];
+
+  return splitSetCookieHeader(combined);
+}
+
+function extractCookieValue(setCookieHeader: string, cookieName: string): string | null {
+  const trimmed = setCookieHeader.trim();
+  if (!trimmed) return null;
+
+  const segments = trimmed.split(";");
+  for (const segment of segments) {
+    const part = segment.trim();
+    if (!part) continue;
+
+    if (part.startsWith(`${cookieName}=`)) {
+      return part.slice(cookieName.length + 1) || null;
+    }
+  }
+
+  return null;
+}
+
+async function sleep(ms: number): Promise<void> {
+  await new Promise((resolve) => setTimeout(resolve, ms));
+}
+
+function shouldRetryFetchError(error: unknown): boolean {
+  if (!(error instanceof Error)) return false;
+
+  const retryCodes = new Set([
+    "ECONNREFUSED",
+    "ECONNRESET",
+    "ETIMEDOUT",
+    "EAI_AGAIN",
+    "UND_ERR_CONNECT_TIMEOUT",
+    "UND_ERR_HEADERS_TIMEOUT",
+    "UND_ERR_BODY_TIMEOUT",
+    "UND_ERR_SOCKET",
+  ]);
+
+  const errorWithCause = error as { cause?: unknown; code?: unknown };
+  const maybeCodes: string[] = [];
+  if (typeof errorWithCause.code === "string") {
+    maybeCodes.push(errorWithCause.code);
+  }
+
+  const cause = errorWithCause.cause;
+  if (cause && typeof cause === "object") {
+    const causeCode = (cause as { code?: unknown }).code;
+    if (typeof causeCode === "string") {
+      maybeCodes.push(causeCode);
+    }
+  }
+
+  if (maybeCodes.length > 0) {
+    return maybeCodes.some((code) => retryCodes.has(code));
+  }
+
+  const message = error.message.toLowerCase();
+  return message.includes("fetch failed");
+}
+
+export async function loginAndGetAuthToken(apiBaseUrl: string, key: string): Promise<string> {
+  const origin = resolveAppOrigin(apiBaseUrl);
+
+  const url = `${origin}/api/auth/login`;
+  const maxAttempts = 10;
+  let lastError: unknown;
+
+  for (let attempt = 1; attempt <= maxAttempts; attempt++) {
+    let response: Response;
+    try {
+      response = await fetch(url, {
+        method: "POST",
+        headers: { "Content-Type": "application/json" },
+        body: JSON.stringify({ key }),
+      });
+    } catch (error) {
+      lastError = error;
+      if (!shouldRetryFetchError(error)) {
+        break;
+      }
+      if (attempt >= maxAttempts) {
+        break;
+      }
+
+      await sleep(Math.min(1000, 100 * 2 ** (attempt - 1)));
+      continue;
+    }
+
+    if (!response.ok) {
+      const text = await response.text().catch(() => "");
+      const errorCode = (() => {
+        try {
+          const parsed = JSON.parse(text) as { errorCode?: unknown };
+          return typeof parsed?.errorCode === "string" ? parsed.errorCode : undefined;
+        } catch {
+          return undefined;
+        }
+      })();
+
+      const error = new Error(`[e2e] login failed: ${response.status} ${text}`);
+      const shouldRetry = response.status === 503 && errorCode === "SESSION_CREATE_FAILED";
+
+      if (!shouldRetry) {
+        throw error;
+      }
+
+      lastError = error;
+      if (attempt >= maxAttempts) {
+        break;
+      }
+
+      await sleep(Math.min(1000, 100 * 2 ** (attempt - 1)));
+      continue;
+    }
+
+    const setCookieHeaders = getSetCookieHeaders(response);
+    const authToken = setCookieHeaders
+      .map((header) => extractCookieValue(header, "auth-token"))
+      .find((value): value is string => Boolean(value));
+
+    if (!authToken) {
+      throw new Error("[e2e] login succeeded but auth-token cookie is missing");
+    }
+
+    return authToken;
+  }
+
+  throw lastError instanceof Error ? lastError : new Error("[e2e] login failed");
+}

+ 20 - 4
tests/e2e/api-complete.test.ts

@@ -13,12 +13,16 @@
  * 🧹 清理:测试完成后自动清理数据
  */
 
-import { afterAll, describe, expect, test } from "vitest";
+import { afterAll, beforeAll, describe, expect, test } from "vitest";
+import { loginAndGetAuthToken } from "./_helpers/auth";
 
 // ==================== 配置 ====================
 
 const API_BASE_URL = process.env.API_BASE_URL || "http://localhost:13500/api/actions";
-const ADMIN_TOKEN = process.env.TEST_ADMIN_TOKEN || process.env.ADMIN_TOKEN;
+const ADMIN_KEY = process.env.TEST_ADMIN_TOKEN || process.env.ADMIN_TOKEN;
+const run = ADMIN_KEY ? describe : describe.skip;
+
+let authToken: string | undefined;
 
 const testData = {
   userIds: [] as number[],
@@ -26,12 +30,22 @@ const testData = {
 
 // ==================== 辅助函数 ====================
 
+beforeAll(async () => {
+  if (!ADMIN_KEY) return;
+  authToken = await loginAndGetAuthToken(API_BASE_URL, ADMIN_KEY);
+});
+
 async function callApi(module: string, action: string, body: Record<string, unknown> = {}) {
+  if (!authToken) {
+    throw new Error("E2E tests require ADMIN_TOKEN/TEST_ADMIN_TOKEN (used to login)");
+  }
+
   const response = await fetch(`${API_BASE_URL}/${module}/${action}`, {
     method: "POST",
     headers: {
       "Content-Type": "application/json",
-      Cookie: `auth-token=${ADMIN_TOKEN}`,
+      Authorization: `Bearer ${authToken}`,
+      Cookie: `auth-token=${authToken}`,
     },
     body: JSON.stringify(body),
   });
@@ -56,6 +70,8 @@ async function expectSuccess(module: string, action: string, body: Record<string
 // ==================== 测试清理 ====================
 
 afterAll(async () => {
+  if (!authToken) return;
+
   console.log(`\n🧹 清理 ${testData.userIds.length} 个测试用户...`);
   for (const userId of testData.userIds) {
     try {
@@ -69,7 +85,7 @@ afterAll(async () => {
 
 // ==================== 测试 ====================
 
-describe("用户和 Key 管理 - E2E 测试", () => {
+run("用户和 Key 管理 - E2E 测试", () => {
   let user1Id: number;
   let user2Id: number;
 

+ 19 - 5
tests/e2e/notification-settings.test.ts

@@ -11,17 +11,26 @@
  * - 已配置 ADMIN_TOKEN(或 TEST_ADMIN_TOKEN)
  */
 
-import { afterAll, describe, expect, test } from "vitest";
+import { afterAll, beforeAll, describe, expect, test } from "vitest";
+import { loginAndGetAuthToken } from "./_helpers/auth";
 
 const API_BASE_URL = process.env.API_BASE_URL || "http://localhost:13500/api/actions";
-const ADMIN_TOKEN = process.env.TEST_ADMIN_TOKEN || process.env.ADMIN_TOKEN;
+const ADMIN_KEY = process.env.TEST_ADMIN_TOKEN || process.env.ADMIN_TOKEN;
+const run = ADMIN_KEY ? describe : describe.skip;
+
+let authToken: string | undefined;
 
 async function callApi(module: string, action: string, body: Record<string, unknown> = {}) {
+  if (!authToken) {
+    throw new Error("E2E tests require ADMIN_TOKEN/TEST_ADMIN_TOKEN (used to login)");
+  }
+
   const response = await fetch(`${API_BASE_URL}/${module}/${action}`, {
     method: "POST",
     headers: {
       "Content-Type": "application/json",
-      Cookie: `auth-token=${ADMIN_TOKEN}`,
+      Authorization: `Bearer ${authToken}`,
+      Cookie: `auth-token=${authToken}`,
     },
     body: JSON.stringify(body),
   });
@@ -47,7 +56,14 @@ const testState = {
   targetIds: [] as number[],
 };
 
+beforeAll(async () => {
+  if (!ADMIN_KEY) return;
+  authToken = await loginAndGetAuthToken(API_BASE_URL, ADMIN_KEY);
+});
+
 afterAll(async () => {
+  if (!authToken) return;
+
   // 尽量清理测试数据(忽略失败)
   for (const id of testState.targetIds) {
     try {
@@ -58,8 +74,6 @@ afterAll(async () => {
   }
 });
 
-const run = ADMIN_TOKEN ? describe : describe.skip;
-
 run("通知设置 - Webhook 目标与绑定(E2E)", () => {
   let targetId: number;
 

+ 20 - 4
tests/e2e/users-keys-complete.test.ts

@@ -23,14 +23,18 @@
  */
 
 import { afterAll, beforeAll, describe, expect, test } from "vitest";
+import { loginAndGetAuthToken } from "./_helpers/auth";
 
 // ==================== 配置 ====================
 
 /** API 基础 URL */
 const API_BASE_URL = process.env.API_BASE_URL || "http://localhost:13500/api/actions";
 
-/** 管理员认证 Token(从环境变量读取)*/
-const ADMIN_TOKEN = process.env.TEST_ADMIN_TOKEN || process.env.ADMIN_TOKEN;
+/** 管理员认证 Key(从环境变量读取,用于登录换取会话 token)*/
+const ADMIN_KEY = process.env.TEST_ADMIN_TOKEN || process.env.ADMIN_TOKEN;
+const run = ADMIN_KEY ? describe : describe.skip;
+
+let sessionToken: string | undefined;
 
 /** 测试数据存储(用于清理)*/
 const testData = {
@@ -58,14 +62,19 @@ async function callApi(
   module: string,
   action: string,
   body: Record<string, unknown> = {},
-  authToken = ADMIN_TOKEN
+  authToken = sessionToken
 ) {
+  if (!authToken) {
+    throw new Error("E2E tests require ADMIN_TOKEN/TEST_ADMIN_TOKEN (used to login)");
+  }
+
   const url = `${API_BASE_URL}/${module}/${action}`;
 
   const response = await fetch(url, {
     method: "POST",
     headers: {
       "Content-Type": "application/json",
+      Authorization: `Bearer ${authToken}`,
       Cookie: `auth-token=${authToken}`,
     },
     body: JSON.stringify(body),
@@ -160,6 +169,8 @@ async function expectError(module: string, action: string, body: Record<string,
  * 2. 删除所有创建的用户
  */
 afterAll(async () => {
+  if (!sessionToken) return;
+
   console.log("\n🧹 开始清理 E2E 测试数据...");
   console.log(`   用户数:${testData.userIds.length}`);
   console.log(`   Key数:${testData.keyIds.length}`);
@@ -178,7 +189,12 @@ afterAll(async () => {
 
 // ==================== 测试套件 ====================
 
-describe("用户和 Key 管理 - 完整 E2E 测试", () => {
+beforeAll(async () => {
+  if (!ADMIN_KEY) return;
+  sessionToken = await loginAndGetAuthToken(API_BASE_URL, ADMIN_KEY);
+});
+
+run("用户和 Key 管理 - 完整 E2E 测试", () => {
   // 测试用户 ID(在多个测试间共享)
   let testUser1Id: number;
   let testUser2Id: number;

+ 35 - 0
tests/unit/auth/split-set-cookie-header.test.ts

@@ -0,0 +1,35 @@
+import { describe, expect, it } from "vitest";
+import { splitSetCookieHeader } from "../../e2e/_helpers/auth";
+
+describe("splitSetCookieHeader", () => {
+  it("returns empty array for empty input", () => {
+    expect(splitSetCookieHeader("")).toEqual([]);
+    expect(splitSetCookieHeader("   ")).toEqual([]);
+  });
+
+  it("splits cookies on comma separators", () => {
+    expect(splitSetCookieHeader("a=1; Path=/, b=2; Path=/")).toEqual([
+      "a=1; Path=/",
+      "b=2; Path=/",
+    ]);
+  });
+
+  it("does not split RFC 1123 Expires commas", () => {
+    expect(
+      splitSetCookieHeader("a=1; Expires=Wed, 21 Oct 2015 07:28:00 GMT; Path=/, b=2; Path=/")
+    ).toEqual(["a=1; Expires=Wed, 21 Oct 2015 07:28:00 GMT; Path=/", "b=2; Path=/"]);
+  });
+
+  it("splits when Expires is the last attribute", () => {
+    expect(splitSetCookieHeader("a=1; Expires=Wed, 21 Oct 2015 07:28:00 GMT, b=2; Path=/")).toEqual(
+      ["a=1; Expires=Wed, 21 Oct 2015 07:28:00 GMT", "b=2; Path=/"]
+    );
+  });
+
+  it("does not split commas inside quoted cookie values", () => {
+    expect(splitSetCookieHeader('a="x, y=z"; Path=/, b=2; Path=/')).toEqual([
+      'a="x, y=z"; Path=/',
+      "b=2; Path=/",
+    ]);
+  });
+});

+ 8 - 12
tests/unit/lib/database-backup/docker-executor.test.ts

@@ -5,19 +5,15 @@ const { mockSpawn, mockCreateReadStream } = vi.hoisted(() => ({
   mockCreateReadStream: vi.fn(() => ({ pipe: vi.fn(), on: vi.fn() })),
 }));
 
-vi.mock("node:child_process", async (importOriginal) => {
-  const actual = await importOriginal<typeof import("node:child_process")>();
-  return { default: { ...actual, spawn: mockSpawn }, ...actual, spawn: mockSpawn };
-});
+vi.mock("node:child_process", () => ({
+  default: { spawn: mockSpawn },
+  spawn: mockSpawn,
+}));
 
-vi.mock("node:fs", async (importOriginal) => {
-  const actual = await importOriginal<typeof import("node:fs")>();
-  return {
-    default: { ...actual, createReadStream: mockCreateReadStream },
-    ...actual,
-    createReadStream: mockCreateReadStream,
-  };
-});
+vi.mock("node:fs", () => ({
+  default: { createReadStream: mockCreateReadStream },
+  createReadStream: mockCreateReadStream,
+}));
 
 vi.mock("@/drizzle/db", () => ({
   db: { execute: vi.fn() },

+ 32 - 6
tests/unit/lib/endpoint-circuit-breaker.test.ts

@@ -25,6 +25,13 @@ async function flushPromises(rounds = 2): Promise<void> {
   }
 }
 
+async function waitForMockCalled(mock: ReturnType<typeof vi.fn>, timeoutMs = 5000): Promise<void> {
+  const startedAt = Date.now();
+  while (mock.mock.calls.length === 0 && Date.now() - startedAt < timeoutMs) {
+    await new Promise<void>((resolve) => setTimeout(resolve, 0));
+  }
+}
+
 afterEach(() => {
   vi.useRealTimers();
   delete process.env.ENDPOINT_CIRCUIT_HEALTH_CACHE_MAX_SIZE;
@@ -51,6 +58,9 @@ describe("endpoint-circuit-breaker", () => {
     vi.doMock("@/lib/notification/notifier", () => ({
       sendCircuitBreakerAlert: sendAlertMock,
     }));
+    vi.doMock("@/repository", () => ({
+      findProviderEndpointById: vi.fn(async () => null),
+    }));
     vi.doMock("@/lib/redis/endpoint-circuit-breaker-state", () => ({
       loadEndpointCircuitState: loadMock,
       saveEndpointCircuitState: saveMock,
@@ -113,10 +123,8 @@ describe("endpoint-circuit-breaker", () => {
     // 在 CI/bun 环境下,告警 Promise 可能在下一个测试开始后才完成,从而“借用”后续用例的 module mock,
     // 导致 sendAlertMock 被额外调用而产生偶发失败。这里用真实计时器让事件循环前进,确保告警任务尽快落地。
     vi.useRealTimers();
-    const startedAt = Date.now();
-    while (sendAlertMock.mock.calls.length === 0 && Date.now() - startedAt < 1000) {
-      await new Promise<void>((resolve) => setTimeout(resolve, 0));
-    }
+    await waitForMockCalled(sendAlertMock);
+    expect(sendAlertMock).toHaveBeenCalledTimes(1);
   });
 
   test("recordEndpointSuccess: closed 且 failureCount>0 时应清零", async () => {
@@ -395,8 +403,12 @@ describe("endpoint-circuit-breaker", () => {
       getEnvConfig: () => ({ ENABLE_ENDPOINT_CIRCUIT_BREAKER: true }),
     }));
     vi.doMock("@/lib/logger", () => ({ logger: createLoggerMock() }));
+    const sendAlertMock = vi.fn(async () => {});
     vi.doMock("@/lib/notification/notifier", () => ({
-      sendCircuitBreakerAlert: vi.fn(async () => {}),
+      sendCircuitBreakerAlert: sendAlertMock,
+    }));
+    vi.doMock("@/repository", () => ({
+      findProviderEndpointById: vi.fn(async () => null),
     }));
     vi.doMock("@/lib/redis/endpoint-circuit-breaker-state", () => ({
       loadEndpointCircuitState: vi.fn(async () => redisState),
@@ -436,6 +448,11 @@ describe("endpoint-circuit-breaker", () => {
     expect(redisState!.circuitState).toBe("open");
     expect(redisState!.circuitOpenUntil).toBe(originalOpenUntil); // unchanged!
     expect(redisState!.failureCount).toBe(4);
+
+    // recordEndpointFailure 在打开熔断时会 non-blocking 触发告警;避免告警任务跨测试“借用”后续 mock。
+    vi.useRealTimers();
+    await waitForMockCalled(sendAlertMock);
+    expect(sendAlertMock).toHaveBeenCalledTimes(1);
   });
 
   test("getEndpointCircuitStateSync returns correct state for known and unknown endpoints", async () => {
@@ -445,8 +462,12 @@ describe("endpoint-circuit-breaker", () => {
       getEnvConfig: () => ({ ENABLE_ENDPOINT_CIRCUIT_BREAKER: true }),
     }));
     vi.doMock("@/lib/logger", () => ({ logger: createLoggerMock() }));
+    const sendAlertMock = vi.fn(async () => {});
     vi.doMock("@/lib/notification/notifier", () => ({
-      sendCircuitBreakerAlert: vi.fn(async () => {}),
+      sendCircuitBreakerAlert: sendAlertMock,
+    }));
+    vi.doMock("@/repository", () => ({
+      findProviderEndpointById: vi.fn(async () => null),
     }));
     vi.doMock("@/lib/redis/endpoint-circuit-breaker-state", () => ({
       loadEndpointCircuitState: vi.fn(async () => null),
@@ -466,6 +487,11 @@ describe("endpoint-circuit-breaker", () => {
     await recordEndpointFailure(200, new Error("b"));
     await recordEndpointFailure(200, new Error("c"));
     expect(getEndpointCircuitStateSync(200)).toBe("open");
+
+    // 打开熔断会触发异步告警;确保该任务在用例结束前完成,避免串台。
+    vi.useRealTimers();
+    await waitForMockCalled(sendAlertMock);
+    expect(sendAlertMock).toHaveBeenCalledTimes(1);
   });
 
   describe("ENABLE_ENDPOINT_CIRCUIT_BREAKER disabled", () => {

+ 2 - 1
tests/unit/login/login-visual-regression.test.tsx

@@ -64,9 +64,10 @@ describe("LoginPage Visual Regression", () => {
   it("renders key structural elements", async () => {
     await render();
 
-    const mainContainer = container.querySelector("div.min-h-screen");
+    const mainContainer = container.querySelector("div.bg-gradient-to-br");
     expect(mainContainer).not.toBeNull();
     const className = mainContainer?.className || "";
+    expect(className).toContain("min-h-[var(--cch-viewport-height,100vh)]");
     expect(className).toContain("bg-gradient-to");
 
     const langSwitcher = container.querySelector(".fixed.top-4.right-4");

+ 17 - 5
tests/unit/settings/providers/thinking-budget-editor.test.tsx

@@ -94,8 +94,12 @@ describe("ThinkingBudgetEditor", () => {
 
     // No number input when inherit
     expect(container.querySelector('input[type="number"]')).toBeNull();
-    // No max-out button when inherit
-    expect(container.querySelector("button")).toBeNull();
+    // No max-out button when inherit (help button always exists)
+    const buttons = Array.from(container.querySelectorAll("button"));
+    expect(buttons.some((b) => b.textContent?.includes("maxOutButton"))).toBe(false);
+
+    // Help icon should exist
+    expect(container.querySelector('[data-testid="info-icon"]')).toBeTruthy();
 
     unmount();
   });
@@ -110,7 +114,9 @@ describe("ThinkingBudgetEditor", () => {
     expect(input).toBeTruthy();
     expect(input.value).toBe("15000");
 
-    const maxButton = container.querySelector("button");
+    const maxButton = Array.from(container.querySelectorAll("button")).find((b) =>
+      b.textContent?.includes("maxOutButton")
+    );
     expect(maxButton).toBeTruthy();
     expect(maxButton?.textContent).toContain("maxOutButton");
 
@@ -159,7 +165,10 @@ describe("ThinkingBudgetEditor", () => {
       <ThinkingBudgetEditor {...defaultProps} value="10000" onChange={onChange} />
     );
 
-    const maxButton = container.querySelector("button") as HTMLButtonElement;
+    const maxButton = Array.from(container.querySelectorAll("button")).find((b) =>
+      b.textContent?.includes("maxOutButton")
+    ) as HTMLButtonElement;
+    expect(maxButton).toBeTruthy();
 
     act(() => {
       maxButton.click();
@@ -225,7 +234,10 @@ describe("ThinkingBudgetEditor", () => {
     const input = container.querySelector('input[type="number"]') as HTMLInputElement;
     expect(input.disabled).toBe(true);
 
-    const maxButton = container.querySelector("button") as HTMLButtonElement;
+    const maxButton = Array.from(container.querySelectorAll("button")).find((b) =>
+      b.textContent?.includes("maxOutButton")
+    ) as HTMLButtonElement;
+    expect(maxButton).toBeTruthy();
     expect(maxButton.disabled).toBe(true);
 
     unmount();

+ 9 - 0
vitest.config.ts

@@ -5,6 +5,12 @@ const isIntegrationFileFilterRequested = process.argv.some((arg) =>
   /tests[\\/]+integration[\\/].+\.(test|spec)\.[cm]?[jt]sx?$/.test(arg)
 );
 
+function parsePositiveInt(value: string | undefined, fallback: number): number {
+  if (!value) return fallback;
+  const parsed = Number.parseInt(value, 10);
+  return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
+}
+
 export default defineConfig({
   test: {
     // ==================== 全局配置 ====================
@@ -90,6 +96,9 @@ export default defineConfig({
     // ==================== 并发配置 ====================
     maxConcurrency: 5, // 最大并发测试数
     pool: "threads", // 使用线程池(推荐)
+    // 高核机器/Windows 下 threads worker 过多可能触发 EMFILE / 资源争用导致用例超时。
+    // 允许通过环境变量覆盖:VITEST_MAX_WORKERS=...
+    maxWorkers: parsePositiveInt(process.env.VITEST_MAX_WORKERS, 8),
 
     // ==================== 文件匹配 ====================
     include: [