Ver Fonte

feat: 重构供应商管理页面 UI/UX

完成供应商管理页面的全面 UI/UX 重构,优化列表展示和详情编辑体验。

主要变更:

### 数据模型扩展 (IMPL-1)
- 新增 websiteUrl 和 faviconUrl 字段到 Provider 模型
- 创建数据库迁移 0016_curious_paladin.sql
- 实现自动 favicon 获取(Google Favicon API)
- 更新所有数据层(types, schema, actions, repository)

### 表单折叠优化 (IMPL-2)
- 使用 Collapsible 组织 843 行表单的 5 个高级功能区域
- 实现 localStorage 偏好记忆
- 添加展开/折叠全部按钮
- 每个区域显示当前配置状态摘要

### 搜索功能集成 (IMPL-3)
- 在 ProviderManager 中新增搜索框
- 使用 useDebounce (500ms) 优化性能
- 支持模糊匹配 name、url、groupTag 三个字段
- 统一搜索/筛选/排序逻辑到单一 useMemo

### API Key 安全展示 (IMPL-4)
- 实现 Dialog 模式的密钥展示和复制功能
- 添加 getUnmaskedProviderKey server action(仅 admin)
- 参考 key-list-header 模式实现安全机制
- 复制按钮带 CheckCircle 反馈

### 列表组件重构 (IMPL-5)
- 创建 ProviderRichListItem 组件(水平布局)
- 卡片式网格布局改为富文本列表式
- 移除所有 Popover 内联编辑,统一使用 Dialog
- 集成 favicon 显示和官网跳转
- 集成 API Key 安全展示
- 优化 Dialog 编辑体验(自动聚焦)
- 响应式设计(移动端/桌面端/大屏)

技术改进:
- 数据模型向后兼容(所有新字段 nullable)
- 性能优化(useMemo 减少重渲染)
- 类型安全(TypeScript 完整覆盖)
- 代码质量(格式化 + lint 通过)

文件变更统计:
- 10 个文件修改
- 新增 889 行,删除 1098 行
- 新增 2 个组件文件,1 个迁移文件
- 旧组件重命名为 .legacy.tsx

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

Co-Authored-By: Claude <[email protected]>
ding113 há 3 meses atrás
pai
commit
84da937cb4

+ 2 - 0
drizzle/0016_curious_paladin.sql

@@ -0,0 +1,2 @@
+ALTER TABLE "providers" ADD COLUMN "website_url" text;--> statement-breakpoint
+ALTER TABLE "providers" ADD COLUMN "favicon_url" text;

+ 1306 - 0
drizzle/meta/0016_snapshot.json

@@ -0,0 +1,1306 @@
+{
+  "id": "737141af-297c-4dc8-af99-55bd4ca86035",
+  "prevId": "d466c36c-deaa-487f-a5ec-46a804d72dde",
+  "version": "7",
+  "dialect": "postgresql",
+  "tables": {
+    "public.keys": {
+      "name": "keys",
+      "schema": "",
+      "columns": {
+        "id": {
+          "name": "id",
+          "type": "serial",
+          "primaryKey": true,
+          "notNull": true
+        },
+        "user_id": {
+          "name": "user_id",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true
+        },
+        "key": {
+          "name": "key",
+          "type": "varchar",
+          "primaryKey": false,
+          "notNull": true
+        },
+        "name": {
+          "name": "name",
+          "type": "varchar",
+          "primaryKey": false,
+          "notNull": true
+        },
+        "is_enabled": {
+          "name": "is_enabled",
+          "type": "boolean",
+          "primaryKey": false,
+          "notNull": false,
+          "default": true
+        },
+        "expires_at": {
+          "name": "expires_at",
+          "type": "timestamp",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "can_login_web_ui": {
+          "name": "can_login_web_ui",
+          "type": "boolean",
+          "primaryKey": false,
+          "notNull": false,
+          "default": true
+        },
+        "limit_5h_usd": {
+          "name": "limit_5h_usd",
+          "type": "numeric(10, 2)",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "limit_weekly_usd": {
+          "name": "limit_weekly_usd",
+          "type": "numeric(10, 2)",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "limit_monthly_usd": {
+          "name": "limit_monthly_usd",
+          "type": "numeric(10, 2)",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "limit_concurrent_sessions": {
+          "name": "limit_concurrent_sessions",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": false,
+          "default": 0
+        },
+        "created_at": {
+          "name": "created_at",
+          "type": "timestamp with time zone",
+          "primaryKey": false,
+          "notNull": false,
+          "default": "now()"
+        },
+        "updated_at": {
+          "name": "updated_at",
+          "type": "timestamp with time zone",
+          "primaryKey": false,
+          "notNull": false,
+          "default": "now()"
+        },
+        "deleted_at": {
+          "name": "deleted_at",
+          "type": "timestamp with time zone",
+          "primaryKey": false,
+          "notNull": false
+        }
+      },
+      "indexes": {
+        "idx_keys_user_id": {
+          "name": "idx_keys_user_id",
+          "columns": [
+            {
+              "expression": "user_id",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            }
+          ],
+          "isUnique": false,
+          "concurrently": false,
+          "method": "btree",
+          "with": {}
+        },
+        "idx_keys_created_at": {
+          "name": "idx_keys_created_at",
+          "columns": [
+            {
+              "expression": "created_at",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            }
+          ],
+          "isUnique": false,
+          "concurrently": false,
+          "method": "btree",
+          "with": {}
+        },
+        "idx_keys_deleted_at": {
+          "name": "idx_keys_deleted_at",
+          "columns": [
+            {
+              "expression": "deleted_at",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            }
+          ],
+          "isUnique": false,
+          "concurrently": false,
+          "method": "btree",
+          "with": {}
+        }
+      },
+      "foreignKeys": {},
+      "compositePrimaryKeys": {},
+      "uniqueConstraints": {},
+      "policies": {},
+      "checkConstraints": {},
+      "isRLSEnabled": false
+    },
+    "public.message_request": {
+      "name": "message_request",
+      "schema": "",
+      "columns": {
+        "id": {
+          "name": "id",
+          "type": "serial",
+          "primaryKey": true,
+          "notNull": true
+        },
+        "provider_id": {
+          "name": "provider_id",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true
+        },
+        "user_id": {
+          "name": "user_id",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true
+        },
+        "key": {
+          "name": "key",
+          "type": "varchar",
+          "primaryKey": false,
+          "notNull": true
+        },
+        "model": {
+          "name": "model",
+          "type": "varchar(128)",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "duration_ms": {
+          "name": "duration_ms",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "cost_usd": {
+          "name": "cost_usd",
+          "type": "numeric(21, 15)",
+          "primaryKey": false,
+          "notNull": false,
+          "default": "'0'"
+        },
+        "cost_multiplier": {
+          "name": "cost_multiplier",
+          "type": "numeric(10, 4)",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "session_id": {
+          "name": "session_id",
+          "type": "varchar(64)",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "provider_chain": {
+          "name": "provider_chain",
+          "type": "jsonb",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "status_code": {
+          "name": "status_code",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "api_type": {
+          "name": "api_type",
+          "type": "varchar(20)",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "original_model": {
+          "name": "original_model",
+          "type": "varchar(128)",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "input_tokens": {
+          "name": "input_tokens",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "output_tokens": {
+          "name": "output_tokens",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "cache_creation_input_tokens": {
+          "name": "cache_creation_input_tokens",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "cache_read_input_tokens": {
+          "name": "cache_read_input_tokens",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "error_message": {
+          "name": "error_message",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "blocked_by": {
+          "name": "blocked_by",
+          "type": "varchar(50)",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "blocked_reason": {
+          "name": "blocked_reason",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "user_agent": {
+          "name": "user_agent",
+          "type": "varchar(512)",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "messages_count": {
+          "name": "messages_count",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "created_at": {
+          "name": "created_at",
+          "type": "timestamp with time zone",
+          "primaryKey": false,
+          "notNull": false,
+          "default": "now()"
+        },
+        "updated_at": {
+          "name": "updated_at",
+          "type": "timestamp with time zone",
+          "primaryKey": false,
+          "notNull": false,
+          "default": "now()"
+        },
+        "deleted_at": {
+          "name": "deleted_at",
+          "type": "timestamp with time zone",
+          "primaryKey": false,
+          "notNull": false
+        }
+      },
+      "indexes": {
+        "idx_message_request_user_date_cost": {
+          "name": "idx_message_request_user_date_cost",
+          "columns": [
+            {
+              "expression": "user_id",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            },
+            {
+              "expression": "created_at",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            },
+            {
+              "expression": "cost_usd",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            }
+          ],
+          "isUnique": false,
+          "where": "\"message_request\".\"deleted_at\" IS NULL",
+          "concurrently": false,
+          "method": "btree",
+          "with": {}
+        },
+        "idx_message_request_user_query": {
+          "name": "idx_message_request_user_query",
+          "columns": [
+            {
+              "expression": "user_id",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            },
+            {
+              "expression": "created_at",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            }
+          ],
+          "isUnique": false,
+          "where": "\"message_request\".\"deleted_at\" IS NULL",
+          "concurrently": false,
+          "method": "btree",
+          "with": {}
+        },
+        "idx_message_request_session_id": {
+          "name": "idx_message_request_session_id",
+          "columns": [
+            {
+              "expression": "session_id",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            }
+          ],
+          "isUnique": false,
+          "where": "\"message_request\".\"deleted_at\" IS NULL",
+          "concurrently": false,
+          "method": "btree",
+          "with": {}
+        },
+        "idx_message_request_provider_id": {
+          "name": "idx_message_request_provider_id",
+          "columns": [
+            {
+              "expression": "provider_id",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            }
+          ],
+          "isUnique": false,
+          "concurrently": false,
+          "method": "btree",
+          "with": {}
+        },
+        "idx_message_request_user_id": {
+          "name": "idx_message_request_user_id",
+          "columns": [
+            {
+              "expression": "user_id",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            }
+          ],
+          "isUnique": false,
+          "concurrently": false,
+          "method": "btree",
+          "with": {}
+        },
+        "idx_message_request_key": {
+          "name": "idx_message_request_key",
+          "columns": [
+            {
+              "expression": "key",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            }
+          ],
+          "isUnique": false,
+          "concurrently": false,
+          "method": "btree",
+          "with": {}
+        },
+        "idx_message_request_created_at": {
+          "name": "idx_message_request_created_at",
+          "columns": [
+            {
+              "expression": "created_at",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            }
+          ],
+          "isUnique": false,
+          "concurrently": false,
+          "method": "btree",
+          "with": {}
+        },
+        "idx_message_request_deleted_at": {
+          "name": "idx_message_request_deleted_at",
+          "columns": [
+            {
+              "expression": "deleted_at",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            }
+          ],
+          "isUnique": false,
+          "concurrently": false,
+          "method": "btree",
+          "with": {}
+        }
+      },
+      "foreignKeys": {},
+      "compositePrimaryKeys": {},
+      "uniqueConstraints": {},
+      "policies": {},
+      "checkConstraints": {},
+      "isRLSEnabled": false
+    },
+    "public.model_prices": {
+      "name": "model_prices",
+      "schema": "",
+      "columns": {
+        "id": {
+          "name": "id",
+          "type": "serial",
+          "primaryKey": true,
+          "notNull": true
+        },
+        "model_name": {
+          "name": "model_name",
+          "type": "varchar",
+          "primaryKey": false,
+          "notNull": true
+        },
+        "price_data": {
+          "name": "price_data",
+          "type": "jsonb",
+          "primaryKey": false,
+          "notNull": true
+        },
+        "created_at": {
+          "name": "created_at",
+          "type": "timestamp with time zone",
+          "primaryKey": false,
+          "notNull": false,
+          "default": "now()"
+        },
+        "updated_at": {
+          "name": "updated_at",
+          "type": "timestamp with time zone",
+          "primaryKey": false,
+          "notNull": false,
+          "default": "now()"
+        }
+      },
+      "indexes": {
+        "idx_model_prices_latest": {
+          "name": "idx_model_prices_latest",
+          "columns": [
+            {
+              "expression": "model_name",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            },
+            {
+              "expression": "created_at",
+              "isExpression": false,
+              "asc": false,
+              "nulls": "last"
+            }
+          ],
+          "isUnique": false,
+          "concurrently": false,
+          "method": "btree",
+          "with": {}
+        },
+        "idx_model_prices_model_name": {
+          "name": "idx_model_prices_model_name",
+          "columns": [
+            {
+              "expression": "model_name",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            }
+          ],
+          "isUnique": false,
+          "concurrently": false,
+          "method": "btree",
+          "with": {}
+        },
+        "idx_model_prices_created_at": {
+          "name": "idx_model_prices_created_at",
+          "columns": [
+            {
+              "expression": "created_at",
+              "isExpression": false,
+              "asc": false,
+              "nulls": "last"
+            }
+          ],
+          "isUnique": false,
+          "concurrently": false,
+          "method": "btree",
+          "with": {}
+        }
+      },
+      "foreignKeys": {},
+      "compositePrimaryKeys": {},
+      "uniqueConstraints": {},
+      "policies": {},
+      "checkConstraints": {},
+      "isRLSEnabled": false
+    },
+    "public.notification_settings": {
+      "name": "notification_settings",
+      "schema": "",
+      "columns": {
+        "id": {
+          "name": "id",
+          "type": "serial",
+          "primaryKey": true,
+          "notNull": true
+        },
+        "enabled": {
+          "name": "enabled",
+          "type": "boolean",
+          "primaryKey": false,
+          "notNull": true,
+          "default": false
+        },
+        "circuit_breaker_enabled": {
+          "name": "circuit_breaker_enabled",
+          "type": "boolean",
+          "primaryKey": false,
+          "notNull": true,
+          "default": false
+        },
+        "circuit_breaker_webhook": {
+          "name": "circuit_breaker_webhook",
+          "type": "varchar(512)",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "daily_leaderboard_enabled": {
+          "name": "daily_leaderboard_enabled",
+          "type": "boolean",
+          "primaryKey": false,
+          "notNull": true,
+          "default": false
+        },
+        "daily_leaderboard_webhook": {
+          "name": "daily_leaderboard_webhook",
+          "type": "varchar(512)",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "daily_leaderboard_time": {
+          "name": "daily_leaderboard_time",
+          "type": "varchar(10)",
+          "primaryKey": false,
+          "notNull": false,
+          "default": "'09:00'"
+        },
+        "daily_leaderboard_top_n": {
+          "name": "daily_leaderboard_top_n",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": false,
+          "default": 5
+        },
+        "cost_alert_enabled": {
+          "name": "cost_alert_enabled",
+          "type": "boolean",
+          "primaryKey": false,
+          "notNull": true,
+          "default": false
+        },
+        "cost_alert_webhook": {
+          "name": "cost_alert_webhook",
+          "type": "varchar(512)",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "cost_alert_threshold": {
+          "name": "cost_alert_threshold",
+          "type": "numeric(5, 2)",
+          "primaryKey": false,
+          "notNull": false,
+          "default": "'0.80'"
+        },
+        "cost_alert_check_interval": {
+          "name": "cost_alert_check_interval",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": false,
+          "default": 60
+        },
+        "created_at": {
+          "name": "created_at",
+          "type": "timestamp with time zone",
+          "primaryKey": false,
+          "notNull": false,
+          "default": "now()"
+        },
+        "updated_at": {
+          "name": "updated_at",
+          "type": "timestamp with time zone",
+          "primaryKey": false,
+          "notNull": false,
+          "default": "now()"
+        }
+      },
+      "indexes": {},
+      "foreignKeys": {},
+      "compositePrimaryKeys": {},
+      "uniqueConstraints": {},
+      "policies": {},
+      "checkConstraints": {},
+      "isRLSEnabled": false
+    },
+    "public.providers": {
+      "name": "providers",
+      "schema": "",
+      "columns": {
+        "id": {
+          "name": "id",
+          "type": "serial",
+          "primaryKey": true,
+          "notNull": true
+        },
+        "name": {
+          "name": "name",
+          "type": "varchar",
+          "primaryKey": false,
+          "notNull": true
+        },
+        "description": {
+          "name": "description",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "url": {
+          "name": "url",
+          "type": "varchar",
+          "primaryKey": false,
+          "notNull": true
+        },
+        "key": {
+          "name": "key",
+          "type": "varchar",
+          "primaryKey": false,
+          "notNull": true
+        },
+        "is_enabled": {
+          "name": "is_enabled",
+          "type": "boolean",
+          "primaryKey": false,
+          "notNull": true,
+          "default": true
+        },
+        "weight": {
+          "name": "weight",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "default": 1
+        },
+        "priority": {
+          "name": "priority",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "default": 0
+        },
+        "cost_multiplier": {
+          "name": "cost_multiplier",
+          "type": "numeric(10, 4)",
+          "primaryKey": false,
+          "notNull": false,
+          "default": "'1.0'"
+        },
+        "group_tag": {
+          "name": "group_tag",
+          "type": "varchar(50)",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "provider_type": {
+          "name": "provider_type",
+          "type": "varchar(20)",
+          "primaryKey": false,
+          "notNull": true,
+          "default": "'claude'"
+        },
+        "model_redirects": {
+          "name": "model_redirects",
+          "type": "jsonb",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "allowed_models": {
+          "name": "allowed_models",
+          "type": "jsonb",
+          "primaryKey": false,
+          "notNull": false,
+          "default": "'null'::jsonb"
+        },
+        "join_claude_pool": {
+          "name": "join_claude_pool",
+          "type": "boolean",
+          "primaryKey": false,
+          "notNull": false,
+          "default": false
+        },
+        "codex_instructions_strategy": {
+          "name": "codex_instructions_strategy",
+          "type": "varchar(20)",
+          "primaryKey": false,
+          "notNull": false,
+          "default": "'auto'"
+        },
+        "limit_5h_usd": {
+          "name": "limit_5h_usd",
+          "type": "numeric(10, 2)",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "limit_weekly_usd": {
+          "name": "limit_weekly_usd",
+          "type": "numeric(10, 2)",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "limit_monthly_usd": {
+          "name": "limit_monthly_usd",
+          "type": "numeric(10, 2)",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "limit_concurrent_sessions": {
+          "name": "limit_concurrent_sessions",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": false,
+          "default": 0
+        },
+        "circuit_breaker_failure_threshold": {
+          "name": "circuit_breaker_failure_threshold",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": false,
+          "default": 5
+        },
+        "circuit_breaker_open_duration": {
+          "name": "circuit_breaker_open_duration",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": false,
+          "default": 1800000
+        },
+        "circuit_breaker_half_open_success_threshold": {
+          "name": "circuit_breaker_half_open_success_threshold",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": false,
+          "default": 2
+        },
+        "proxy_url": {
+          "name": "proxy_url",
+          "type": "varchar(512)",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "proxy_fallback_to_direct": {
+          "name": "proxy_fallback_to_direct",
+          "type": "boolean",
+          "primaryKey": false,
+          "notNull": false,
+          "default": false
+        },
+        "website_url": {
+          "name": "website_url",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "favicon_url": {
+          "name": "favicon_url",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "tpm": {
+          "name": "tpm",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": false,
+          "default": 0
+        },
+        "rpm": {
+          "name": "rpm",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": false,
+          "default": 0
+        },
+        "rpd": {
+          "name": "rpd",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": false,
+          "default": 0
+        },
+        "cc": {
+          "name": "cc",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": false,
+          "default": 0
+        },
+        "created_at": {
+          "name": "created_at",
+          "type": "timestamp with time zone",
+          "primaryKey": false,
+          "notNull": false,
+          "default": "now()"
+        },
+        "updated_at": {
+          "name": "updated_at",
+          "type": "timestamp with time zone",
+          "primaryKey": false,
+          "notNull": false,
+          "default": "now()"
+        },
+        "deleted_at": {
+          "name": "deleted_at",
+          "type": "timestamp with time zone",
+          "primaryKey": false,
+          "notNull": false
+        }
+      },
+      "indexes": {
+        "idx_providers_enabled_priority": {
+          "name": "idx_providers_enabled_priority",
+          "columns": [
+            {
+              "expression": "is_enabled",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            },
+            {
+              "expression": "priority",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            },
+            {
+              "expression": "weight",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            }
+          ],
+          "isUnique": false,
+          "where": "\"providers\".\"deleted_at\" IS NULL",
+          "concurrently": false,
+          "method": "btree",
+          "with": {}
+        },
+        "idx_providers_group": {
+          "name": "idx_providers_group",
+          "columns": [
+            {
+              "expression": "group_tag",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            }
+          ],
+          "isUnique": false,
+          "where": "\"providers\".\"deleted_at\" IS NULL",
+          "concurrently": false,
+          "method": "btree",
+          "with": {}
+        },
+        "idx_providers_created_at": {
+          "name": "idx_providers_created_at",
+          "columns": [
+            {
+              "expression": "created_at",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            }
+          ],
+          "isUnique": false,
+          "concurrently": false,
+          "method": "btree",
+          "with": {}
+        },
+        "idx_providers_deleted_at": {
+          "name": "idx_providers_deleted_at",
+          "columns": [
+            {
+              "expression": "deleted_at",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            }
+          ],
+          "isUnique": false,
+          "concurrently": false,
+          "method": "btree",
+          "with": {}
+        }
+      },
+      "foreignKeys": {},
+      "compositePrimaryKeys": {},
+      "uniqueConstraints": {},
+      "policies": {},
+      "checkConstraints": {},
+      "isRLSEnabled": false
+    },
+    "public.sensitive_words": {
+      "name": "sensitive_words",
+      "schema": "",
+      "columns": {
+        "id": {
+          "name": "id",
+          "type": "serial",
+          "primaryKey": true,
+          "notNull": true
+        },
+        "word": {
+          "name": "word",
+          "type": "varchar(255)",
+          "primaryKey": false,
+          "notNull": true
+        },
+        "match_type": {
+          "name": "match_type",
+          "type": "varchar(20)",
+          "primaryKey": false,
+          "notNull": true,
+          "default": "'contains'"
+        },
+        "description": {
+          "name": "description",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "is_enabled": {
+          "name": "is_enabled",
+          "type": "boolean",
+          "primaryKey": false,
+          "notNull": true,
+          "default": true
+        },
+        "created_at": {
+          "name": "created_at",
+          "type": "timestamp with time zone",
+          "primaryKey": false,
+          "notNull": false,
+          "default": "now()"
+        },
+        "updated_at": {
+          "name": "updated_at",
+          "type": "timestamp with time zone",
+          "primaryKey": false,
+          "notNull": false,
+          "default": "now()"
+        }
+      },
+      "indexes": {
+        "idx_sensitive_words_enabled": {
+          "name": "idx_sensitive_words_enabled",
+          "columns": [
+            {
+              "expression": "is_enabled",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            },
+            {
+              "expression": "match_type",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            }
+          ],
+          "isUnique": false,
+          "concurrently": false,
+          "method": "btree",
+          "with": {}
+        },
+        "idx_sensitive_words_created_at": {
+          "name": "idx_sensitive_words_created_at",
+          "columns": [
+            {
+              "expression": "created_at",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            }
+          ],
+          "isUnique": false,
+          "concurrently": false,
+          "method": "btree",
+          "with": {}
+        }
+      },
+      "foreignKeys": {},
+      "compositePrimaryKeys": {},
+      "uniqueConstraints": {},
+      "policies": {},
+      "checkConstraints": {},
+      "isRLSEnabled": false
+    },
+    "public.system_settings": {
+      "name": "system_settings",
+      "schema": "",
+      "columns": {
+        "id": {
+          "name": "id",
+          "type": "serial",
+          "primaryKey": true,
+          "notNull": true
+        },
+        "site_title": {
+          "name": "site_title",
+          "type": "varchar(128)",
+          "primaryKey": false,
+          "notNull": true,
+          "default": "'Claude Code Hub'"
+        },
+        "allow_global_usage_view": {
+          "name": "allow_global_usage_view",
+          "type": "boolean",
+          "primaryKey": false,
+          "notNull": true,
+          "default": false
+        },
+        "currency_display": {
+          "name": "currency_display",
+          "type": "varchar(10)",
+          "primaryKey": false,
+          "notNull": true,
+          "default": "'USD'"
+        },
+        "enable_auto_cleanup": {
+          "name": "enable_auto_cleanup",
+          "type": "boolean",
+          "primaryKey": false,
+          "notNull": false,
+          "default": false
+        },
+        "cleanup_retention_days": {
+          "name": "cleanup_retention_days",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": false,
+          "default": 30
+        },
+        "cleanup_schedule": {
+          "name": "cleanup_schedule",
+          "type": "varchar(50)",
+          "primaryKey": false,
+          "notNull": false,
+          "default": "'0 2 * * *'"
+        },
+        "cleanup_batch_size": {
+          "name": "cleanup_batch_size",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": false,
+          "default": 10000
+        },
+        "enable_client_version_check": {
+          "name": "enable_client_version_check",
+          "type": "boolean",
+          "primaryKey": false,
+          "notNull": true,
+          "default": false
+        },
+        "created_at": {
+          "name": "created_at",
+          "type": "timestamp with time zone",
+          "primaryKey": false,
+          "notNull": false,
+          "default": "now()"
+        },
+        "updated_at": {
+          "name": "updated_at",
+          "type": "timestamp with time zone",
+          "primaryKey": false,
+          "notNull": false,
+          "default": "now()"
+        }
+      },
+      "indexes": {},
+      "foreignKeys": {},
+      "compositePrimaryKeys": {},
+      "uniqueConstraints": {},
+      "policies": {},
+      "checkConstraints": {},
+      "isRLSEnabled": false
+    },
+    "public.users": {
+      "name": "users",
+      "schema": "",
+      "columns": {
+        "id": {
+          "name": "id",
+          "type": "serial",
+          "primaryKey": true,
+          "notNull": true
+        },
+        "name": {
+          "name": "name",
+          "type": "varchar",
+          "primaryKey": false,
+          "notNull": true
+        },
+        "description": {
+          "name": "description",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "role": {
+          "name": "role",
+          "type": "varchar",
+          "primaryKey": false,
+          "notNull": false,
+          "default": "'user'"
+        },
+        "rpm_limit": {
+          "name": "rpm_limit",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": false,
+          "default": 60
+        },
+        "daily_limit_usd": {
+          "name": "daily_limit_usd",
+          "type": "numeric(10, 2)",
+          "primaryKey": false,
+          "notNull": false,
+          "default": "'100.00'"
+        },
+        "provider_group": {
+          "name": "provider_group",
+          "type": "varchar(50)",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "created_at": {
+          "name": "created_at",
+          "type": "timestamp with time zone",
+          "primaryKey": false,
+          "notNull": false,
+          "default": "now()"
+        },
+        "updated_at": {
+          "name": "updated_at",
+          "type": "timestamp with time zone",
+          "primaryKey": false,
+          "notNull": false,
+          "default": "now()"
+        },
+        "deleted_at": {
+          "name": "deleted_at",
+          "type": "timestamp with time zone",
+          "primaryKey": false,
+          "notNull": false
+        }
+      },
+      "indexes": {
+        "idx_users_active_role_sort": {
+          "name": "idx_users_active_role_sort",
+          "columns": [
+            {
+              "expression": "deleted_at",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            },
+            {
+              "expression": "role",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            },
+            {
+              "expression": "id",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            }
+          ],
+          "isUnique": false,
+          "where": "\"users\".\"deleted_at\" IS NULL",
+          "concurrently": false,
+          "method": "btree",
+          "with": {}
+        },
+        "idx_users_created_at": {
+          "name": "idx_users_created_at",
+          "columns": [
+            {
+              "expression": "created_at",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            }
+          ],
+          "isUnique": false,
+          "concurrently": false,
+          "method": "btree",
+          "with": {}
+        },
+        "idx_users_deleted_at": {
+          "name": "idx_users_deleted_at",
+          "columns": [
+            {
+              "expression": "deleted_at",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            }
+          ],
+          "isUnique": false,
+          "concurrently": false,
+          "method": "btree",
+          "with": {}
+        }
+      },
+      "foreignKeys": {},
+      "compositePrimaryKeys": {},
+      "uniqueConstraints": {},
+      "policies": {},
+      "checkConstraints": {},
+      "isRLSEnabled": false
+    }
+  },
+  "enums": {},
+  "schemas": {},
+  "sequences": {},
+  "roles": {},
+  "policies": {},
+  "views": {},
+  "_meta": {
+    "columns": {},
+    "schemas": {},
+    "tables": {}
+  }
+}

+ 7 - 0
drizzle/meta/_journal.json

@@ -113,6 +113,13 @@
       "when": 1762333515926,
       "tag": "0015_narrow_gunslinger",
       "breakpoints": true
+    },
+    {
+      "idx": 16,
+      "version": "7",
+      "when": 1762449880213,
+      "tag": "0016_curious_paladin",
+      "breakpoints": true
     }
   ]
 }

+ 84 - 1
src/actions/providers.ts

@@ -130,6 +130,8 @@ export async function getProviders(): Promise<ProviderDisplay[]> {
         circuitBreakerHalfOpenSuccessThreshold: provider.circuitBreakerHalfOpenSuccessThreshold,
         proxyUrl: provider.proxyUrl,
         proxyFallbackToDirect: provider.proxyFallbackToDirect,
+        websiteUrl: provider.websiteUrl,
+        faviconUrl: provider.faviconUrl,
         tpm: provider.tpm,
         rpm: provider.rpm,
         rpd: provider.rpd,
@@ -179,6 +181,7 @@ export async function addProvider(data: {
   circuit_breaker_half_open_success_threshold?: number;
   proxy_url?: string | null;
   proxy_fallback_to_direct?: boolean;
+  website_url?: string | null;
   codex_instructions_strategy?: "auto" | "force_official" | "keep_original";
   tpm: number | null;
   rpm: number | null;
@@ -209,6 +212,23 @@ export async function addProvider(data: {
     const validated = CreateProviderSchema.parse(data);
     logger.trace("addProvider:validated", { name: validated.name });
 
+    // 获取 favicon URL
+    let faviconUrl: string | null = null;
+    if (validated.website_url) {
+      try {
+        const url = new URL(validated.website_url);
+        const domain = url.hostname;
+        faviconUrl = `https://www.google.com/s2/favicons?domain=${domain}&sz=32`;
+        logger.trace("addProvider:favicon_generated", { domain, faviconUrl });
+      } catch (error) {
+        logger.warn("addProvider:favicon_fetch_failed", {
+          websiteUrl: validated.website_url,
+          error: error instanceof Error ? error.message : String(error),
+        });
+        // Favicon 获取失败不影响主流程
+      }
+    }
+
     const payload = {
       ...validated,
       limit_5h_usd: validated.limit_5h_usd ?? null,
@@ -221,6 +241,8 @@ export async function addProvider(data: {
         validated.circuit_breaker_half_open_success_threshold ?? 2,
       proxy_url: validated.proxy_url ?? null,
       proxy_fallback_to_direct: validated.proxy_fallback_to_direct ?? false,
+      website_url: validated.website_url ?? null,
+      favicon_url: faviconUrl,
       tpm: validated.tpm ?? null,
       rpm: validated.rpm ?? null,
       rpd: validated.rpd ?? null,
@@ -286,6 +308,7 @@ export async function editProvider(
     circuit_breaker_half_open_success_threshold?: number;
     proxy_url?: string | null;
     proxy_fallback_to_direct?: boolean;
+    website_url?: string | null;
     codex_instructions_strategy?: "auto" | "force_official" | "keep_original";
     tpm?: number | null;
     rpm?: number | null;
@@ -308,7 +331,34 @@ export async function editProvider(
     }
 
     const validated = UpdateProviderSchema.parse(data);
-    const provider = await updateProvider(providerId, validated);
+
+    // 如果 website_url 被更新,重新生成 favicon URL
+    let faviconUrl: string | null | undefined = undefined; // undefined 表示不更新
+    if (validated.website_url !== undefined) {
+      if (validated.website_url) {
+        try {
+          const url = new URL(validated.website_url);
+          const domain = url.hostname;
+          faviconUrl = `https://www.google.com/s2/favicons?domain=${domain}&sz=32`;
+          logger.trace("editProvider:favicon_generated", { domain, faviconUrl });
+        } catch (error) {
+          logger.warn("editProvider:favicon_fetch_failed", {
+            websiteUrl: validated.website_url,
+            error: error instanceof Error ? error.message : String(error),
+          });
+          faviconUrl = null;
+        }
+      } else {
+        faviconUrl = null; // website_url 被清空时也清空 favicon
+      }
+    }
+
+    const payload = {
+      ...validated,
+      ...(faviconUrl !== undefined && { favicon_url: faviconUrl }),
+    };
+
+    const provider = await updateProvider(providerId, payload);
 
     if (!provider) {
       return { ok: false, error: "供应商不存在" };
@@ -658,3 +708,36 @@ export async function testProviderProxy(data: {
     return { ok: false, error: message };
   }
 }
+
+/**
+ * 获取供应商的未脱敏密钥(仅管理员)
+ * 用于安全展示和复制完整 API Key
+ */
+export async function getUnmaskedProviderKey(id: number): Promise<ActionResult<{ key: string }>> {
+  "use server";
+
+  try {
+    const session = await getSession();
+    if (!session || session.user.role !== "admin") {
+      return { ok: false, error: "权限不足:仅管理员可查看完整密钥" };
+    }
+
+    const provider = await findProviderById(id);
+    if (!provider) {
+      return { ok: false, error: "供应商不存在" };
+    }
+
+    // 记录查看行为(不记录密钥内容)
+    logger.info("Admin viewed provider key", {
+      userId: session.user.id,
+      providerId: id,
+      providerName: provider.name,
+    });
+
+    return { ok: true, data: { key: provider.key } };
+  } catch (error) {
+    logger.error("获取供应商密钥失败:", error);
+    const message = error instanceof Error ? error.message : "获取供应商密钥失败";
+    return { ok: false, error: message };
+  }
+}

+ 690 - 433
src/app/settings/providers/_components/forms/provider-form.tsx

@@ -12,7 +12,8 @@ import {
   SelectValue,
 } from "@/components/ui/select";
 import { DialogHeader, DialogTitle } from "@/components/ui/dialog";
-import { useState, useTransition } from "react";
+import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
+import { useState, useTransition, useEffect, useRef } from "react";
 import { addProvider, editProvider, removeProvider } from "@/actions/providers";
 import {
   AlertDialog,
@@ -31,6 +32,7 @@ import { toast } from "sonner";
 import { ModelMultiSelect } from "../model-multi-select";
 import { ModelRedirectEditor } from "../model-redirect-editor";
 import { ProxyTestButton } from "./proxy-test-button";
+import { ChevronDown } from "lucide-react";
 
 type Mode = "create" | "edit";
 
@@ -52,6 +54,9 @@ export function ProviderForm({
   const isEdit = mode === "edit";
   const [isPending, startTransition] = useTransition();
 
+  // 名称输入框引用,用于自动聚焦
+  const nameInputRef = useRef<HTMLInputElement>(null);
+
   // 获取初始数据源:编辑模式用 provider,创建模式用 cloneProvider(如果有)
   const sourceProvider = isEdit ? provider : cloneProvider;
 
@@ -107,10 +112,77 @@ export function ProviderForm({
     sourceProvider?.proxyFallbackToDirect ?? false
   );
 
+  // 供应商官网地址
+  const [websiteUrl, setWebsiteUrl] = useState<string>(sourceProvider?.websiteUrl ?? "");
+
   // Codex Instructions 策略配置
   const [codexInstructionsStrategy, setCodexInstructionsStrategy] =
     useState<CodexInstructionsStrategy>(sourceProvider?.codexInstructionsStrategy ?? "auto");
 
+  // 折叠区域状态管理
+  type SectionKey = "routing" | "rateLimit" | "circuitBreaker" | "proxy" | "codexStrategy";
+  const [openSections, setOpenSections] = useState<Record<SectionKey, boolean>>({
+    routing: false,
+    rateLimit: false,
+    circuitBreaker: false,
+    proxy: false,
+    codexStrategy: false,
+  });
+
+  // 从 localStorage 加载折叠偏好
+  useEffect(() => {
+    const saved = localStorage.getItem("provider-form-sections");
+    if (saved) {
+      try {
+        const parsed = JSON.parse(saved);
+        setOpenSections(parsed);
+      } catch (e) {
+        console.error("Failed to parse saved sections state:", e);
+      }
+    }
+  }, []);
+
+  // 保存折叠状态到 localStorage
+  useEffect(() => {
+    localStorage.setItem("provider-form-sections", JSON.stringify(openSections));
+  }, [openSections]);
+
+  // 自动聚焦名称输入框
+  useEffect(() => {
+    // 延迟聚焦,确保 Dialog 动画完成
+    const timer = setTimeout(() => {
+      nameInputRef.current?.focus();
+    }, 100);
+    return () => clearTimeout(timer);
+  }, []);
+
+  // 折叠区域切换函数
+  const toggleSection = (key: SectionKey) => {
+    setOpenSections((prev) => ({ ...prev, [key]: !prev[key] }));
+  };
+
+  // 展开全部高级配置
+  const expandAll = () => {
+    setOpenSections({
+      routing: true,
+      rateLimit: true,
+      circuitBreaker: true,
+      proxy: true,
+      codexStrategy: true,
+    });
+  };
+
+  // 折叠全部高级配置
+  const collapseAll = () => {
+    setOpenSections({
+      routing: false,
+      rateLimit: false,
+      circuitBreaker: false,
+      proxy: false,
+      codexStrategy: false,
+    });
+  };
+
   const handleSubmit = (e: React.FormEvent) => {
     e.preventDefault();
 
@@ -119,7 +191,13 @@ export function ProviderForm({
     }
 
     if (!isValidUrl(url.trim())) {
-      toast.error("请输入有效的URL地址");
+      toast.error("请输入有效的 API 地址");
+      return;
+    }
+
+    // 验证 websiteUrl(可选,但如果填写了必须是有效 URL)
+    if (websiteUrl.trim() && !isValidUrl(websiteUrl.trim())) {
+      toast.error("请输入有效的供应商官网地址");
       return;
     }
 
@@ -150,6 +228,7 @@ export function ProviderForm({
             circuit_breaker_half_open_success_threshold?: number;
             proxy_url?: string | null;
             proxy_fallback_to_direct?: boolean;
+            website_url?: string | null;
             codex_instructions_strategy?: CodexInstructionsStrategy;
             tpm?: number | null;
             rpm?: number | null;
@@ -177,6 +256,7 @@ export function ProviderForm({
             circuit_breaker_half_open_success_threshold: halfOpenSuccessThreshold ?? 2,
             proxy_url: proxyUrl.trim() || null,
             proxy_fallback_to_direct: proxyFallbackToDirect,
+            website_url: websiteUrl.trim() || null,
             codex_instructions_strategy: codexInstructionsStrategy,
             tpm: null,
             rpm: null,
@@ -217,6 +297,7 @@ export function ProviderForm({
             circuit_breaker_half_open_success_threshold: halfOpenSuccessThreshold ?? 2,
             proxy_url: proxyUrl.trim() || null,
             proxy_fallback_to_direct: proxyFallbackToDirect,
+            website_url: websiteUrl.trim() || null,
             codex_instructions_strategy: codexInstructionsStrategy,
             tpm: null,
             rpm: null,
@@ -252,6 +333,7 @@ export function ProviderForm({
           setHalfOpenSuccessThreshold(2);
           setProxyUrl("");
           setProxyFallbackToDirect(false);
+          setWebsiteUrl("");
           setCodexInstructionsStrategy("auto");
         }
         onSuccess?.();
@@ -272,6 +354,7 @@ export function ProviderForm({
         <div className="space-y-2">
           <Label htmlFor={isEdit ? "edit-name" : "name"}>服务商名称 *</Label>
           <Input
+            ref={nameInputRef}
             id={isEdit ? "edit-name" : "name"}
             value={name}
             onChange={(e) => setName(e.target.value)}
@@ -313,475 +396,649 @@ export function ProviderForm({
           ) : null}
         </div>
 
+        <div className="space-y-2">
+          <Label htmlFor={isEdit ? "edit-website-url" : "website-url"}>供应商官网地址</Label>
+          <Input
+            id={isEdit ? "edit-website-url" : "website-url"}
+            type="url"
+            value={websiteUrl}
+            onChange={(e) => setWebsiteUrl(e.target.value)}
+            placeholder="https://example.com"
+            disabled={isPending}
+          />
+          <div className="text-xs text-muted-foreground">供应商官网地址,用于快速跳转管理</div>
+        </div>
+
+        {/* 展开/折叠全部按钮 */}
+        <div className="flex gap-2 py-2 border-t">
+          <Button
+            type="button"
+            variant="outline"
+            size="sm"
+            onClick={expandAll}
+            disabled={isPending}
+          >
+            展开全部高级配置
+          </Button>
+          <Button
+            type="button"
+            variant="outline"
+            size="sm"
+            onClick={collapseAll}
+            disabled={isPending}
+          >
+            折叠全部高级配置
+          </Button>
+        </div>
+
         {/* Codex 支持:供应商类型和模型重定向 */}
-        <div className="space-y-4 pt-2 border-t">
-          <div className="space-y-2">
-            <Label htmlFor={isEdit ? "edit-provider-type" : "provider-type"}>
-              供应商类型
-              <span className="text-xs text-muted-foreground ml-1">(决定调度策略)</span>
-            </Label>
-            <Select
-              value={providerType}
-              onValueChange={(value) => setProviderType(value as ProviderType)}
+        <Collapsible open={openSections.routing} onOpenChange={(open) => toggleSection("routing")}>
+          <CollapsibleTrigger asChild>
+            <button
+              type="button"
+              className="flex items-center justify-between w-full py-4 border-t hover:bg-muted/50 transition-colors"
               disabled={isPending}
             >
-              <SelectTrigger id={isEdit ? "edit-provider-type" : "provider-type"}>
-                <SelectValue placeholder="选择供应商类型" />
-              </SelectTrigger>
-              <SelectContent>
-                <SelectItem value="claude">Claude (Anthropic Messages API)</SelectItem>
-                <SelectItem value="claude-auth">Claude (Anthropic Auth Token)</SelectItem>
-                <SelectItem value="codex">Codex (Response API)</SelectItem>
-                <SelectItem value="gemini-cli" disabled={!enableMultiProviderTypes}>
-                  Gemini CLI{!enableMultiProviderTypes && " - 功能开发中"}
-                </SelectItem>
-                <SelectItem value="openai-compatible" disabled={!enableMultiProviderTypes}>
-                  OpenAI Compatible{!enableMultiProviderTypes && " - 功能开发中"}
-                </SelectItem>
-              </SelectContent>
-            </Select>
-            <p className="text-xs text-muted-foreground">
-              选择供应商的 API 格式类型。
-              {!enableMultiProviderTypes && (
-                <span className="text-amber-600 ml-1">
-                  注:Gemini CLI 和 OpenAI Compatible 类型功能正在开发中,暂不可用
-                </span>
-              )}
-            </p>
-          </div>
+              <div className="flex items-center gap-2">
+                <ChevronDown
+                  className={`h-4 w-4 transition-transform ${
+                    openSections.routing ? "rotate-180" : ""
+                  }`}
+                />
+                <span className="text-sm font-medium">路由配置</span>
+              </div>
+              <span className="text-xs text-muted-foreground">
+                {(() => {
+                  const parts = [];
+                  if (allowedModels.length > 0) parts.push(`${allowedModels.length} 个模型白名单`);
+                  if (Object.keys(modelRedirects).length > 0)
+                    parts.push(`${Object.keys(modelRedirects).length} 个重定向`);
+                  return parts.length > 0 ? parts.join(", ") : "未配置";
+                })()}
+              </span>
+            </button>
+          </CollapsibleTrigger>
+          <CollapsibleContent className="space-y-4 pb-4">
+            <div className="space-y-4">
+              <div className="space-y-2">
+                <Label htmlFor={isEdit ? "edit-provider-type" : "provider-type"}>
+                  供应商类型
+                  <span className="text-xs text-muted-foreground ml-1">(决定调度策略)</span>
+                </Label>
+                <Select
+                  value={providerType}
+                  onValueChange={(value) => setProviderType(value as ProviderType)}
+                  disabled={isPending}
+                >
+                  <SelectTrigger id={isEdit ? "edit-provider-type" : "provider-type"}>
+                    <SelectValue placeholder="选择供应商类型" />
+                  </SelectTrigger>
+                  <SelectContent>
+                    <SelectItem value="claude">Claude (Anthropic Messages API)</SelectItem>
+                    <SelectItem value="claude-auth">Claude (Anthropic Auth Token)</SelectItem>
+                    <SelectItem value="codex">Codex (Response API)</SelectItem>
+                    <SelectItem value="gemini-cli" disabled={!enableMultiProviderTypes}>
+                      Gemini CLI{!enableMultiProviderTypes && " - 功能开发中"}
+                    </SelectItem>
+                    <SelectItem value="openai-compatible" disabled={!enableMultiProviderTypes}>
+                      OpenAI Compatible{!enableMultiProviderTypes && " - 功能开发中"}
+                    </SelectItem>
+                  </SelectContent>
+                </Select>
+                <p className="text-xs text-muted-foreground">
+                  选择供应商的 API 格式类型。
+                  {!enableMultiProviderTypes && (
+                    <span className="text-amber-600 ml-1">
+                      注:Gemini CLI 和 OpenAI Compatible 类型功能正在开发中,暂不可用
+                    </span>
+                  )}
+                </p>
+              </div>
 
-          <div className="space-y-2">
-            <Label>
-              模型重定向配置
-              <span className="text-xs text-muted-foreground ml-1">(可选)</span>
-            </Label>
-            <ModelRedirectEditor
-              value={modelRedirects}
-              onChange={setModelRedirects}
-              disabled={isPending}
-            />
-          </div>
+              <div className="space-y-2">
+                <Label>
+                  模型重定向配置
+                  <span className="text-xs text-muted-foreground ml-1">(可选)</span>
+                </Label>
+                <ModelRedirectEditor
+                  value={modelRedirects}
+                  onChange={setModelRedirects}
+                  disabled={isPending}
+                />
+              </div>
 
-          {/* joinClaudePool 开关 - 仅非 Claude 供应商显示 */}
-          {providerType !== "claude" &&
-            (() => {
-              // 检查是否有重定向到 Claude 模型的映射
-              const hasClaudeRedirects = Object.values(modelRedirects).some((target) =>
-                target.startsWith("claude-")
-              );
+              {/* joinClaudePool 开关 - 仅非 Claude 供应商显示 */}
+              {providerType !== "claude" &&
+                (() => {
+                  // 检查是否有重定向到 Claude 模型的映射
+                  const hasClaudeRedirects = Object.values(modelRedirects).some((target) =>
+                    target.startsWith("claude-")
+                  );
 
-              if (!hasClaudeRedirects) return null;
+                  if (!hasClaudeRedirects) return null;
 
-              return (
-                <div className="space-y-2">
-                  <div className="flex items-center justify-between">
-                    <div className="space-y-0.5">
-                      <Label htmlFor={isEdit ? "edit-join-claude-pool" : "join-claude-pool"}>
-                        加入 Claude 调度池
-                      </Label>
+                  return (
+                    <div className="space-y-2">
+                      <div className="flex items-center justify-between">
+                        <div className="space-y-0.5">
+                          <Label htmlFor={isEdit ? "edit-join-claude-pool" : "join-claude-pool"}>
+                            加入 Claude 调度池
+                          </Label>
+                          <p className="text-xs text-muted-foreground">
+                            启用后,此供应商将与 Claude 类型供应商一起参与负载均衡调度
+                          </p>
+                        </div>
+                        <Switch
+                          id={isEdit ? "edit-join-claude-pool" : "join-claude-pool"}
+                          checked={joinClaudePool}
+                          onCheckedChange={setJoinClaudePool}
+                          disabled={isPending}
+                        />
+                      </div>
                       <p className="text-xs text-muted-foreground">
-                        启用后,此供应商将与 Claude 类型供应商一起参与负载均衡调度
+                        仅当模型重定向配置中存在映射到 claude-* 模型时可用。启用后,当用户请求
+                        claude-* 模型时,此供应商也会参与调度选择。
                       </p>
                     </div>
-                    <Switch
-                      id={isEdit ? "edit-join-claude-pool" : "join-claude-pool"}
-                      checked={joinClaudePool}
-                      onCheckedChange={setJoinClaudePool}
+                  );
+                })()}
+
+              {/* 模型白名单配置 */}
+              <div className="space-y-1">
+                <div className="text-sm font-medium">模型白名单</div>
+                <p className="text-xs text-muted-foreground">
+                  限制此供应商可以处理的模型。默认情况下,供应商可以处理该类型下的所有模型。
+                </p>
+              </div>
+
+              <div className="space-y-2">
+                <Label htmlFor="allowed-models">
+                  允许的模型
+                  <span className="text-xs text-muted-foreground ml-1">(可选)</span>
+                </Label>
+
+                <ModelMultiSelect
+                  providerType={
+                    providerType as "claude" | "codex" | "gemini-cli" | "openai-compatible"
+                  }
+                  selectedModels={allowedModels}
+                  onChange={setAllowedModels}
+                  disabled={isPending}
+                />
+
+                {allowedModels.length > 0 && (
+                  <div className="flex flex-wrap gap-1 mt-2 p-2 bg-muted/50 rounded-md">
+                    {allowedModels.slice(0, 5).map((model) => (
+                      <Badge key={model} variant="outline" className="font-mono text-xs">
+                        {model}
+                      </Badge>
+                    ))}
+                    {allowedModels.length > 5 && (
+                      <Badge variant="secondary" className="text-xs">
+                        +{allowedModels.length - 5} 更多
+                      </Badge>
+                    )}
+                  </div>
+                )}
+
+                <p className="text-xs text-muted-foreground">
+                  {allowedModels.length === 0 ? (
+                    <span className="text-green-600">✓ 允许所有模型(推荐)</span>
+                  ) : (
+                    <span>
+                      仅允许选中的 {allowedModels.length} 个模型。其他模型的请求不会调度到此供应商。
+                    </span>
+                  )}
+                </p>
+              </div>
+
+              {/* 路由配置 - 优先级、权重、成本 */}
+              <div className="space-y-4">
+                <div className="text-sm font-medium">调度参数</div>
+                <div className="grid grid-cols-3 gap-4">
+                  <div className="space-y-2">
+                    <Label htmlFor={isEdit ? "edit-priority" : "priority"}>优先级</Label>
+                    <Input
+                      id={isEdit ? "edit-priority" : "priority"}
+                      type="number"
+                      value={priority}
+                      onChange={(e) => setPriority(parseInt(e.target.value) || 0)}
+                      placeholder="0"
+                      disabled={isPending}
+                      min="0"
+                      step="1"
+                    />
+                    <p className="text-xs text-muted-foreground">
+                      数值越小优先级越高(0
+                      最高)。系统只从最高优先级的供应商中选择。建议:主力=0,备用=1,紧急备份=2
+                    </p>
+                  </div>
+                  <div className="space-y-2">
+                    <Label htmlFor={isEdit ? "edit-weight" : "weight"}>权重</Label>
+                    <Input
+                      id={isEdit ? "edit-weight" : "weight"}
+                      type="number"
+                      value={weight}
+                      onChange={(e) => setWeight(parseInt(e.target.value) || 1)}
+                      placeholder="1"
                       disabled={isPending}
+                      min="1"
+                      step="1"
                     />
+                    <p className="text-xs text-muted-foreground">
+                      加权随机概率。同优先级内,权重越高被选中概率越大。例如权重 1:2:3 的概率为
+                      16%:33%:50%
+                    </p>
                   </div>
+                  <div className="space-y-2">
+                    <Label htmlFor={isEdit ? "edit-cost" : "cost"}>成本倍率</Label>
+                    <Input
+                      id={isEdit ? "edit-cost" : "cost"}
+                      type="number"
+                      value={costMultiplier}
+                      onChange={(e) => setCostMultiplier(parseFloat(e.target.value) || 1.0)}
+                      placeholder="1.0"
+                      disabled={isPending}
+                      min="0"
+                      step="0.0001"
+                    />
+                    <p className="text-xs text-muted-foreground">
+                      成本计算倍数。官方供应商=1.0,便宜 20%=0.8,贵 20%=1.2(支持最多 4 位小数)
+                    </p>
+                  </div>
+                </div>
+                <div className="space-y-2">
+                  <Label htmlFor={isEdit ? "edit-group" : "group"}>供应商分组</Label>
+                  <Input
+                    id={isEdit ? "edit-group" : "group"}
+                    value={groupTag}
+                    onChange={(e) => setGroupTag(e.target.value)}
+                    placeholder="例如: premium, economy"
+                    disabled={isPending}
+                  />
                   <p className="text-xs text-muted-foreground">
-                    仅当模型重定向配置中存在映射到 claude-* 模型时可用。启用后,当用户请求 claude-*
-                    模型时,此供应商也会参与调度选择。
+                    供应商分组标签。只有用户的 providerGroup
+                    与此值匹配时,该用户才能使用此供应商。示例:设置为 &quot;premium&quot; 表示只供
+                    providerGroup=&quot;premium&quot; 的用户使用
                   </p>
                 </div>
-              );
-            })()}
-        </div>
-
-        {/* 模型白名单配置 */}
-        <div className="space-y-4 pt-2 border-t">
-          <div className="space-y-1">
-            <div className="text-sm font-medium">模型白名单</div>
-            <p className="text-xs text-muted-foreground">
-              限制此供应商可以处理的模型。默认情况下,供应商可以处理该类型下的所有模型。
-            </p>
-          </div>
-
-          <div className="space-y-2">
-            <Label htmlFor="allowed-models">
-              允许的模型
-              <span className="text-xs text-muted-foreground ml-1">(可选)</span>
-            </Label>
-
-            <ModelMultiSelect
-              providerType={providerType as "claude" | "codex" | "gemini-cli" | "openai-compatible"}
-              selectedModels={allowedModels}
-              onChange={setAllowedModels}
-              disabled={isPending}
-            />
-
-            {allowedModels.length > 0 && (
-              <div className="flex flex-wrap gap-1 mt-2 p-2 bg-muted/50 rounded-md">
-                {allowedModels.slice(0, 5).map((model) => (
-                  <Badge key={model} variant="outline" className="font-mono text-xs">
-                    {model}
-                  </Badge>
-                ))}
-                {allowedModels.length > 5 && (
-                  <Badge variant="secondary" className="text-xs">
-                    +{allowedModels.length - 5} 更多
-                  </Badge>
-                )}
               </div>
-            )}
-
-            <p className="text-xs text-muted-foreground">
-              {allowedModels.length === 0 ? (
-                <span className="text-green-600">✓ 允许所有模型(推荐)</span>
-              ) : (
-                <span>
-                  仅允许选中的 {allowedModels.length} 个模型。其他模型的请求不会调度到此供应商。
-                </span>
-              )}
-            </p>
-          </div>
-        </div>
-
-        {/* 路由配置 */}
-        <div className="space-y-4 pt-2 border-t">
-          <div className="text-sm font-medium">路由配置</div>
-          <div className="grid grid-cols-3 gap-4">
-            <div className="space-y-2">
-              <Label htmlFor={isEdit ? "edit-priority" : "priority"}>优先级</Label>
-              <Input
-                id={isEdit ? "edit-priority" : "priority"}
-                type="number"
-                value={priority}
-                onChange={(e) => setPriority(parseInt(e.target.value) || 0)}
-                placeholder="0"
-                disabled={isPending}
-                min="0"
-                step="1"
-              />
-              <p className="text-xs text-muted-foreground">
-                数值越小优先级越高(0
-                最高)。系统只从最高优先级的供应商中选择。建议:主力=0,备用=1,紧急备份=2
-              </p>
             </div>
-            <div className="space-y-2">
-              <Label htmlFor={isEdit ? "edit-weight" : "weight"}>权重</Label>
-              <Input
-                id={isEdit ? "edit-weight" : "weight"}
-                type="number"
-                value={weight}
-                onChange={(e) => setWeight(parseInt(e.target.value) || 1)}
-                placeholder="1"
-                disabled={isPending}
-                min="1"
-                step="1"
-              />
-              <p className="text-xs text-muted-foreground">
-                加权随机概率。同优先级内,权重越高被选中概率越大。例如权重 1:2:3 的概率为
-                16%:33%:50%
-              </p>
-            </div>
-            <div className="space-y-2">
-              <Label htmlFor={isEdit ? "edit-cost" : "cost"}>成本倍率</Label>
-              <Input
-                id={isEdit ? "edit-cost" : "cost"}
-                type="number"
-                value={costMultiplier}
-                onChange={(e) => setCostMultiplier(parseFloat(e.target.value) || 1.0)}
-                placeholder="1.0"
-                disabled={isPending}
-                min="0"
-                step="0.0001"
-              />
-              <p className="text-xs text-muted-foreground">
-                成本计算倍数。官方供应商=1.0,便宜 20%=0.8,贵 20%=1.2(支持最多 4 位小数)
-              </p>
-            </div>
-          </div>
-          <div className="space-y-2">
-            <Label htmlFor={isEdit ? "edit-group" : "group"}>供应商分组</Label>
-            <Input
-              id={isEdit ? "edit-group" : "group"}
-              value={groupTag}
-              onChange={(e) => setGroupTag(e.target.value)}
-              placeholder="例如: premium, economy"
-              disabled={isPending}
-            />
-            <p className="text-xs text-muted-foreground">
-              供应商分组标签。只有用户的 providerGroup
-              与此值匹配时,该用户才能使用此供应商。示例:设置为 &quot;premium&quot; 表示只供
-              providerGroup=&quot;premium&quot; 的用户使用
-            </p>
-          </div>
-        </div>
+          </CollapsibleContent>
+        </Collapsible>
 
         {/* 限流配置 */}
-        <div className="space-y-4 pt-2 border-t">
-          <div className="text-sm font-medium">限流配置</div>
-          <div className="grid grid-cols-2 gap-4">
-            <div className="space-y-2">
-              <Label htmlFor={isEdit ? "edit-limit-5h" : "limit-5h"}>5小时消费上限 (USD)</Label>
-              <Input
-                id={isEdit ? "edit-limit-5h" : "limit-5h"}
-                type="number"
-                value={limit5hUsd?.toString() ?? ""}
-                onChange={(e) => setLimit5hUsd(validateNumericField(e.target.value))}
-                placeholder="留空表示无限制"
-                disabled={isPending}
-                min="0"
-                step="0.01"
-              />
-            </div>
-            <div className="space-y-2">
-              <Label htmlFor={isEdit ? "edit-limit-weekly" : "limit-weekly"}>
-                周消费上限 (USD)
-              </Label>
-              <Input
-                id={isEdit ? "edit-limit-weekly" : "limit-weekly"}
-                type="number"
-                value={limitWeeklyUsd?.toString() ?? ""}
-                onChange={(e) => setLimitWeeklyUsd(validateNumericField(e.target.value))}
-                placeholder="留空表示无限制"
-                disabled={isPending}
-                min="0"
-                step="0.01"
-              />
-            </div>
-          </div>
+        <Collapsible
+          open={openSections.rateLimit}
+          onOpenChange={(open) => toggleSection("rateLimit")}
+        >
+          <CollapsibleTrigger asChild>
+            <button
+              type="button"
+              className="flex items-center justify-between w-full py-4 border-t hover:bg-muted/50 transition-colors"
+              disabled={isPending}
+            >
+              <div className="flex items-center gap-2">
+                <ChevronDown
+                  className={`h-4 w-4 transition-transform ${
+                    openSections.rateLimit ? "rotate-180" : ""
+                  }`}
+                />
+                <span className="text-sm font-medium">限流配置</span>
+              </div>
+              <span className="text-xs text-muted-foreground">
+                {(() => {
+                  const limits = [];
+                  if (limit5hUsd) limits.push(`5h: $${limit5hUsd}`);
+                  if (limitWeeklyUsd) limits.push(`周: $${limitWeeklyUsd}`);
+                  if (limitMonthlyUsd) limits.push(`月: $${limitMonthlyUsd}`);
+                  if (limitConcurrentSessions) limits.push(`并发: ${limitConcurrentSessions}`);
+                  return limits.length > 0 ? limits.join(", ") : "无限制";
+                })()}
+              </span>
+            </button>
+          </CollapsibleTrigger>
+          <CollapsibleContent className="space-y-4 pb-4">
+            <div className="space-y-4">
+              <div className="grid grid-cols-2 gap-4">
+                <div className="space-y-2">
+                  <Label htmlFor={isEdit ? "edit-limit-5h" : "limit-5h"}>5小时消费上限 (USD)</Label>
+                  <Input
+                    id={isEdit ? "edit-limit-5h" : "limit-5h"}
+                    type="number"
+                    value={limit5hUsd?.toString() ?? ""}
+                    onChange={(e) => setLimit5hUsd(validateNumericField(e.target.value))}
+                    placeholder="留空表示无限制"
+                    disabled={isPending}
+                    min="0"
+                    step="0.01"
+                  />
+                </div>
+                <div className="space-y-2">
+                  <Label htmlFor={isEdit ? "edit-limit-weekly" : "limit-weekly"}>
+                    周消费上限 (USD)
+                  </Label>
+                  <Input
+                    id={isEdit ? "edit-limit-weekly" : "limit-weekly"}
+                    type="number"
+                    value={limitWeeklyUsd?.toString() ?? ""}
+                    onChange={(e) => setLimitWeeklyUsd(validateNumericField(e.target.value))}
+                    placeholder="留空表示无限制"
+                    disabled={isPending}
+                    min="0"
+                    step="0.01"
+                  />
+                </div>
+              </div>
 
-          <div className="grid grid-cols-2 gap-4">
-            <div className="space-y-2">
-              <Label htmlFor={isEdit ? "edit-limit-monthly" : "limit-monthly"}>
-                月消费上限 (USD)
-              </Label>
-              <Input
-                id={isEdit ? "edit-limit-monthly" : "limit-monthly"}
-                type="number"
-                value={limitMonthlyUsd?.toString() ?? ""}
-                onChange={(e) => setLimitMonthlyUsd(validateNumericField(e.target.value))}
-                placeholder="留空表示无限制"
-                disabled={isPending}
-                min="0"
-                step="0.01"
-              />
-            </div>
-            <div className="space-y-2">
-              <Label htmlFor={isEdit ? "edit-limit-concurrent" : "limit-concurrent"}>
-                并发 Session 上限
-              </Label>
-              <Input
-                id={isEdit ? "edit-limit-concurrent" : "limit-concurrent"}
-                type="number"
-                value={limitConcurrentSessions?.toString() ?? ""}
-                onChange={(e) => setLimitConcurrentSessions(validateNumericField(e.target.value))}
-                placeholder="0 表示无限制"
-                disabled={isPending}
-                min="0"
-                step="1"
-              />
+              <div className="grid grid-cols-2 gap-4">
+                <div className="space-y-2">
+                  <Label htmlFor={isEdit ? "edit-limit-monthly" : "limit-monthly"}>
+                    月消费上限 (USD)
+                  </Label>
+                  <Input
+                    id={isEdit ? "edit-limit-monthly" : "limit-monthly"}
+                    type="number"
+                    value={limitMonthlyUsd?.toString() ?? ""}
+                    onChange={(e) => setLimitMonthlyUsd(validateNumericField(e.target.value))}
+                    placeholder="留空表示无限制"
+                    disabled={isPending}
+                    min="0"
+                    step="0.01"
+                  />
+                </div>
+                <div className="space-y-2">
+                  <Label htmlFor={isEdit ? "edit-limit-concurrent" : "limit-concurrent"}>
+                    并发 Session 上限
+                  </Label>
+                  <Input
+                    id={isEdit ? "edit-limit-concurrent" : "limit-concurrent"}
+                    type="number"
+                    value={limitConcurrentSessions?.toString() ?? ""}
+                    onChange={(e) =>
+                      setLimitConcurrentSessions(validateNumericField(e.target.value))
+                    }
+                    placeholder="0 表示无限制"
+                    disabled={isPending}
+                    min="0"
+                    step="1"
+                  />
+                </div>
+              </div>
             </div>
-          </div>
-        </div>
+          </CollapsibleContent>
+        </Collapsible>
 
         {/* 熔断器配置 */}
-        <div className="space-y-4 pt-2 border-t">
-          <div className="space-y-1">
-            <div className="text-sm font-medium">熔断器配置</div>
-            <p className="text-xs text-muted-foreground">
-              供应商连续失败时自动熔断,避免影响整体服务质量
-            </p>
-          </div>
-          <div className="grid grid-cols-3 gap-4">
-            <div className="space-y-2">
-              <Label htmlFor={isEdit ? "edit-failure-threshold" : "failure-threshold"}>
-                失败阈值(次)
-              </Label>
-              <Input
-                id={isEdit ? "edit-failure-threshold" : "failure-threshold"}
-                type="number"
-                value={failureThreshold ?? ""}
-                onChange={(e) => {
-                  const val = e.target.value;
-                  setFailureThreshold(val === "" ? undefined : parseInt(val));
-                }}
-                placeholder="5"
-                disabled={isPending}
-                min="1"
-                max="100"
-                step="1"
-              />
-              <p className="text-xs text-muted-foreground">连续失败多少次后触发熔断</p>
-            </div>
-            <div className="space-y-2">
-              <Label htmlFor={isEdit ? "edit-open-duration" : "open-duration"}>
-                熔断时长(分钟)
-              </Label>
-              <Input
-                id={isEdit ? "edit-open-duration" : "open-duration"}
-                type="number"
-                value={openDurationMinutes ?? ""}
-                onChange={(e) => {
-                  const val = e.target.value;
-                  setOpenDurationMinutes(val === "" ? undefined : parseInt(val));
-                }}
-                placeholder="30"
-                disabled={isPending}
-                min="1"
-                max="1440"
-                step="1"
-              />
-              <p className="text-xs text-muted-foreground">熔断后多久自动进入半开状态</p>
-            </div>
-            <div className="space-y-2">
-              <Label htmlFor={isEdit ? "edit-success-threshold" : "success-threshold"}>
-                恢复阈值(次)
-              </Label>
-              <Input
-                id={isEdit ? "edit-success-threshold" : "success-threshold"}
-                type="number"
-                value={halfOpenSuccessThreshold ?? ""}
-                onChange={(e) => {
-                  const val = e.target.value;
-                  setHalfOpenSuccessThreshold(val === "" ? undefined : parseInt(val));
-                }}
-                placeholder="2"
-                disabled={isPending}
-                min="1"
-                max="10"
-                step="1"
-              />
-              <p className="text-xs text-muted-foreground">半开状态下成功多少次后完全恢复</p>
+        <Collapsible
+          open={openSections.circuitBreaker}
+          onOpenChange={(open) => toggleSection("circuitBreaker")}
+        >
+          <CollapsibleTrigger asChild>
+            <button
+              type="button"
+              className="flex items-center justify-between w-full py-4 border-t hover:bg-muted/50 transition-colors"
+              disabled={isPending}
+            >
+              <div className="flex items-center gap-2">
+                <ChevronDown
+                  className={`h-4 w-4 transition-transform ${
+                    openSections.circuitBreaker ? "rotate-180" : ""
+                  }`}
+                />
+                <span className="text-sm font-medium">熔断器配置</span>
+              </div>
+              <span className="text-xs text-muted-foreground">
+                {failureThreshold ?? 5} 次失败 / {openDurationMinutes ?? 30} 分钟熔断 /{" "}
+                {halfOpenSuccessThreshold ?? 2} 次成功恢复
+              </span>
+            </button>
+          </CollapsibleTrigger>
+          <CollapsibleContent className="space-y-4 pb-4">
+            <div className="space-y-4">
+              <div className="space-y-1">
+                <p className="text-xs text-muted-foreground">
+                  供应商连续失败时自动熔断,避免影响整体服务质量
+                </p>
+              </div>
+              <div className="grid grid-cols-3 gap-4">
+                <div className="space-y-2">
+                  <Label htmlFor={isEdit ? "edit-failure-threshold" : "failure-threshold"}>
+                    失败阈值(次)
+                  </Label>
+                  <Input
+                    id={isEdit ? "edit-failure-threshold" : "failure-threshold"}
+                    type="number"
+                    value={failureThreshold ?? ""}
+                    onChange={(e) => {
+                      const val = e.target.value;
+                      setFailureThreshold(val === "" ? undefined : parseInt(val));
+                    }}
+                    placeholder="5"
+                    disabled={isPending}
+                    min="1"
+                    max="100"
+                    step="1"
+                  />
+                  <p className="text-xs text-muted-foreground">连续失败多少次后触发熔断</p>
+                </div>
+                <div className="space-y-2">
+                  <Label htmlFor={isEdit ? "edit-open-duration" : "open-duration"}>
+                    熔断时长(分钟)
+                  </Label>
+                  <Input
+                    id={isEdit ? "edit-open-duration" : "open-duration"}
+                    type="number"
+                    value={openDurationMinutes ?? ""}
+                    onChange={(e) => {
+                      const val = e.target.value;
+                      setOpenDurationMinutes(val === "" ? undefined : parseInt(val));
+                    }}
+                    placeholder="30"
+                    disabled={isPending}
+                    min="1"
+                    max="1440"
+                    step="1"
+                  />
+                  <p className="text-xs text-muted-foreground">熔断后多久自动进入半开状态</p>
+                </div>
+                <div className="space-y-2">
+                  <Label htmlFor={isEdit ? "edit-success-threshold" : "success-threshold"}>
+                    恢复阈值(次)
+                  </Label>
+                  <Input
+                    id={isEdit ? "edit-success-threshold" : "success-threshold"}
+                    type="number"
+                    value={halfOpenSuccessThreshold ?? ""}
+                    onChange={(e) => {
+                      const val = e.target.value;
+                      setHalfOpenSuccessThreshold(val === "" ? undefined : parseInt(val));
+                    }}
+                    placeholder="2"
+                    disabled={isPending}
+                    min="1"
+                    max="10"
+                    step="1"
+                  />
+                  <p className="text-xs text-muted-foreground">半开状态下成功多少次后完全恢复</p>
+                </div>
+              </div>
             </div>
-          </div>
-        </div>
+          </CollapsibleContent>
+        </Collapsible>
 
         {/* 代理配置 */}
-        <div className="space-y-4 pt-2 border-t">
-          <div className="space-y-1">
-            <div className="text-sm font-medium">代理配置</div>
-            <p className="text-xs text-muted-foreground">
-              配置代理服务器以改善供应商连接性(支持 HTTP、HTTPS、SOCKS4、SOCKS5)
-            </p>
-          </div>
-
-          <div className="space-y-4">
-            {/* 代理地址输入 */}
-            <div className="space-y-2">
-              <Label htmlFor={isEdit ? "edit-proxy-url" : "proxy-url"}>
-                代理地址
-                <span className="text-xs text-muted-foreground ml-1">(可选)</span>
-              </Label>
-              <Input
-                id={isEdit ? "edit-proxy-url" : "proxy-url"}
-                value={proxyUrl}
-                onChange={(e) => setProxyUrl(e.target.value)}
-                placeholder="例如: http://proxy.example.com:8080 或 socks5://127.0.0.1:1080"
-                disabled={isPending}
-              />
-              <p className="text-xs text-muted-foreground">
-                支持格式: <code className="bg-muted px-1 rounded">http://</code>、
-                <code className="bg-muted px-1 rounded">https://</code>、
-                <code className="bg-muted px-1 rounded">socks4://</code>、
-                <code className="bg-muted px-1 rounded">socks5://</code>
-              </p>
-            </div>
+        <Collapsible open={openSections.proxy} onOpenChange={(open) => toggleSection("proxy")}>
+          <CollapsibleTrigger asChild>
+            <button
+              type="button"
+              className="flex items-center justify-between w-full py-4 border-t hover:bg-muted/50 transition-colors"
+              disabled={isPending}
+            >
+              <div className="flex items-center gap-2">
+                <ChevronDown
+                  className={`h-4 w-4 transition-transform ${
+                    openSections.proxy ? "rotate-180" : ""
+                  }`}
+                />
+                <span className="text-sm font-medium">代理配置</span>
+              </div>
+              <span className="text-xs text-muted-foreground">
+                {proxyUrl.trim() ? "已配置代理" : "未配置"}
+                {proxyUrl.trim() && proxyFallbackToDirect ? " (启用降级)" : ""}
+              </span>
+            </button>
+          </CollapsibleTrigger>
+          <CollapsibleContent className="space-y-4 pb-4">
+            <div className="space-y-4">
+              <div className="space-y-1">
+                <p className="text-xs text-muted-foreground">
+                  配置代理服务器以改善供应商连接性(支持 HTTP、HTTPS、SOCKS4、SOCKS5)
+                </p>
+              </div>
 
-            {/* 降级策略开关 */}
-            <div className="space-y-2">
-              <div className="flex items-center justify-between">
-                <div className="space-y-0.5">
-                  <Label htmlFor={isEdit ? "edit-proxy-fallback" : "proxy-fallback"}>
-                    代理失败时降级到直连
-                  </Label>
-                  <p className="text-xs text-muted-foreground">
-                    启用后,代理连接失败时自动尝试直接连接供应商
-                  </p>
-                </div>
-                <Switch
-                  id={isEdit ? "edit-proxy-fallback" : "proxy-fallback"}
-                  checked={proxyFallbackToDirect}
-                  onCheckedChange={setProxyFallbackToDirect}
+              {/* 代理地址输入 */}
+              <div className="space-y-2">
+                <Label htmlFor={isEdit ? "edit-proxy-url" : "proxy-url"}>
+                  代理地址
+                  <span className="text-xs text-muted-foreground ml-1">(可选)</span>
+                </Label>
+                <Input
+                  id={isEdit ? "edit-proxy-url" : "proxy-url"}
+                  value={proxyUrl}
+                  onChange={(e) => setProxyUrl(e.target.value)}
+                  placeholder="例如: http://proxy.example.com:8080 或 socks5://127.0.0.1:1080"
                   disabled={isPending}
                 />
+                <p className="text-xs text-muted-foreground">
+                  支持格式: <code className="bg-muted px-1 rounded">http://</code>、
+                  <code className="bg-muted px-1 rounded">https://</code>、
+                  <code className="bg-muted px-1 rounded">socks4://</code>、
+                  <code className="bg-muted px-1 rounded">socks5://</code>
+                </p>
+              </div>
+
+              {/* 降级策略开关 */}
+              <div className="space-y-2">
+                <div className="flex items-center justify-between">
+                  <div className="space-y-0.5">
+                    <Label htmlFor={isEdit ? "edit-proxy-fallback" : "proxy-fallback"}>
+                      代理失败时降级到直连
+                    </Label>
+                    <p className="text-xs text-muted-foreground">
+                      启用后,代理连接失败时自动尝试直接连接供应商
+                    </p>
+                  </div>
+                  <Switch
+                    id={isEdit ? "edit-proxy-fallback" : "proxy-fallback"}
+                    checked={proxyFallbackToDirect}
+                    onCheckedChange={setProxyFallbackToDirect}
+                    disabled={isPending}
+                  />
+                </div>
               </div>
-            </div>
 
-            {/* 测试连接按钮 */}
-            <div className="space-y-2">
-              <Label>连接测试</Label>
-              <ProxyTestButton
-                providerUrl={url}
-                proxyUrl={proxyUrl}
-                proxyFallbackToDirect={proxyFallbackToDirect}
-                disabled={isPending || !url.trim()}
-              />
-              <p className="text-xs text-muted-foreground">
-                测试通过配置的代理访问供应商 URL(使用 HEAD 请求,不消耗额度)
-              </p>
+              {/* 测试连接按钮 */}
+              <div className="space-y-2">
+                <Label>连接测试</Label>
+                <ProxyTestButton
+                  providerUrl={url}
+                  proxyUrl={proxyUrl}
+                  proxyFallbackToDirect={proxyFallbackToDirect}
+                  disabled={isPending || !url.trim()}
+                />
+                <p className="text-xs text-muted-foreground">
+                  测试通过配置的代理访问供应商 URL(使用 HEAD 请求,不消耗额度)
+                </p>
+              </div>
             </div>
-          </div>
-        </div>
+          </CollapsibleContent>
+        </Collapsible>
 
         {/* Codex Instructions 策略配置 - 仅 Codex 供应商显示 */}
         {providerType === "codex" && (
-          <div className="space-y-4 pt-2 border-t">
-            <div className="space-y-1">
-              <div className="text-sm font-medium">Codex Instructions 策略</div>
-              <p className="text-xs text-muted-foreground">
-                控制如何处理 Codex 请求的 instructions 字段,影响与上游中转站的兼容性
-              </p>
-            </div>
-
-            <div className="space-y-2">
-              <Label htmlFor={isEdit ? "edit-codex-strategy" : "codex-strategy"}>策略选择</Label>
-              <Select
-                value={codexInstructionsStrategy}
-                onValueChange={(value) =>
-                  setCodexInstructionsStrategy(value as CodexInstructionsStrategy)
-                }
+          <Collapsible
+            open={openSections.codexStrategy}
+            onOpenChange={(open) => toggleSection("codexStrategy")}
+          >
+            <CollapsibleTrigger asChild>
+              <button
+                type="button"
+                className="flex items-center justify-between w-full py-4 border-t hover:bg-muted/50 transition-colors"
                 disabled={isPending}
               >
-                <SelectTrigger id={isEdit ? "edit-codex-strategy" : "codex-strategy"}>
-                  <SelectValue placeholder="选择策略" />
-                </SelectTrigger>
-                <SelectContent>
-                  <SelectItem value="auto">
-                    <div className="space-y-1">
-                      <div className="font-medium">自动 (推荐)</div>
-                      <div className="text-xs text-muted-foreground max-w-xs">
-                        透传客户端 instructions,400 错误时自动重试官方 prompt
-                      </div>
-                    </div>
-                  </SelectItem>
-                  <SelectItem value="force_official">
-                    <div className="space-y-1">
-                      <div className="font-medium">强制官方</div>
-                      <div className="text-xs text-muted-foreground max-w-xs">
-                        始终使用官方 Codex CLI instructions(约 4000+ 字)
-                      </div>
-                    </div>
-                  </SelectItem>
-                  <SelectItem value="keep_original">
-                    <div className="space-y-1">
-                      <div className="font-medium">透传原样</div>
-                      <div className="text-xs text-muted-foreground max-w-xs">
-                        始终透传客户端 instructions,不自动重试(适用于宽松中转站)
-                      </div>
-                    </div>
-                  </SelectItem>
-                </SelectContent>
-              </Select>
-              <p className="text-xs text-muted-foreground">
-                <strong>提示</strong>: 部分严格的 Codex 中转站(如 88code、foxcode)需要官方
-                instructions,选择&quot;自动&quot;或&quot;强制官方&quot;策略
-              </p>
-            </div>
-          </div>
+                <div className="flex items-center gap-2">
+                  <ChevronDown
+                    className={`h-4 w-4 transition-transform ${
+                      openSections.codexStrategy ? "rotate-180" : ""
+                    }`}
+                  />
+                  <span className="text-sm font-medium">Codex Instructions 策略</span>
+                </div>
+                <span className="text-xs text-muted-foreground">
+                  {codexInstructionsStrategy === "auto" && "自动 (推荐)"}
+                  {codexInstructionsStrategy === "force_official" && "强制官方"}
+                  {codexInstructionsStrategy === "keep_original" && "透传原样"}
+                </span>
+              </button>
+            </CollapsibleTrigger>
+            <CollapsibleContent className="space-y-4 pb-4">
+              <div className="space-y-4">
+                <div className="space-y-1">
+                  <p className="text-xs text-muted-foreground">
+                    控制如何处理 Codex 请求的 instructions 字段,影响与上游中转站的兼容性
+                  </p>
+                </div>
+
+                <div className="space-y-2">
+                  <Label htmlFor={isEdit ? "edit-codex-strategy" : "codex-strategy"}>
+                    策略选择
+                  </Label>
+                  <Select
+                    value={codexInstructionsStrategy}
+                    onValueChange={(value) =>
+                      setCodexInstructionsStrategy(value as CodexInstructionsStrategy)
+                    }
+                    disabled={isPending}
+                  >
+                    <SelectTrigger id={isEdit ? "edit-codex-strategy" : "codex-strategy"}>
+                      <SelectValue placeholder="选择策略" />
+                    </SelectTrigger>
+                    <SelectContent>
+                      <SelectItem value="auto">
+                        <div className="space-y-1">
+                          <div className="font-medium">自动 (推荐)</div>
+                          <div className="text-xs text-muted-foreground max-w-xs">
+                            透传客户端 instructions,400 错误时自动重试官方 prompt
+                          </div>
+                        </div>
+                      </SelectItem>
+                      <SelectItem value="force_official">
+                        <div className="space-y-1">
+                          <div className="font-medium">强制官方</div>
+                          <div className="text-xs text-muted-foreground max-w-xs">
+                            始终使用官方 Codex CLI instructions(约 4000+ 字)
+                          </div>
+                        </div>
+                      </SelectItem>
+                      <SelectItem value="keep_original">
+                        <div className="space-y-1">
+                          <div className="font-medium">透传原样</div>
+                          <div className="text-xs text-muted-foreground max-w-xs">
+                            始终透传客户端 instructions,不自动重试(适用于宽松中转站)
+                          </div>
+                        </div>
+                      </SelectItem>
+                    </SelectContent>
+                  </Select>
+                  <p className="text-xs text-muted-foreground">
+                    <strong>提示</strong>: 部分严格的 Codex 中转站(如 88code、foxcode)需要官方
+                    instructions,选择&quot;自动&quot;或&quot;强制官方&quot;策略
+                  </p>
+                </div>
+              </div>
+            </CollapsibleContent>
+          </Collapsible>
         )}
 
         {isEdit ? (

+ 93 - 4
src/app/settings/providers/_components/provider-list-item.tsx → src/app/settings/providers/_components/provider-list-item.legacy.tsx

@@ -2,10 +2,17 @@
 import { useState, useTransition } from "react";
 import { useRouter } from "next/navigation";
 import { Button } from "@/components/ui/button";
-import { Dialog, DialogContent, DialogTrigger } from "@/components/ui/dialog";
+import {
+  Dialog,
+  DialogContent,
+  DialogTrigger,
+  DialogHeader,
+  DialogTitle,
+  DialogDescription,
+} from "@/components/ui/dialog";
 import { Badge } from "@/components/ui/badge";
 import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
-import { Edit, Globe, Key, RotateCcw, Copy } from "lucide-react";
+import { Edit, Globe, Key, RotateCcw, Copy, CheckCircle } from "lucide-react";
 import type { ProviderDisplay } from "@/types/provider";
 import type { User } from "@/types/user";
 import { getProviderTypeConfig } from "@/lib/provider-type-utils";
@@ -26,7 +33,7 @@ import {
   AlertDialogTitle,
   AlertDialogTrigger,
 } from "@/components/ui/alert-dialog";
-import { resetProviderCircuit } from "@/actions/providers";
+import { resetProviderCircuit, getUnmaskedProviderKey } from "@/actions/providers";
 import { toast } from "sonner";
 import type { CurrencyCode } from "@/lib/utils/currency";
 import { formatCurrency } from "@/lib/utils/currency";
@@ -55,6 +62,9 @@ export function ProviderListItem({
   const router = useRouter();
   const [openEdit, setOpenEdit] = useState(false);
   const [openClone, setOpenClone] = useState(false);
+  const [showKeyDialog, setShowKeyDialog] = useState(false);
+  const [unmaskedKey, setUnmaskedKey] = useState<string | null>(null);
+  const [copied, setCopied] = useState(false);
   const [resetPending, startResetTransition] = useTransition();
   const canEdit = currentUser?.role === "admin";
 
@@ -121,6 +131,41 @@ export function ProviderListItem({
     });
   };
 
+  // 处理查看密钥
+  const handleShowKey = async () => {
+    setShowKeyDialog(true);
+    const result = await getUnmaskedProviderKey(item.id);
+    if (result.ok) {
+      setUnmaskedKey(result.data.key);
+    } else {
+      toast.error("获取密钥失败", {
+        description: result.error || "未知错误",
+      });
+    }
+  };
+
+  // 处理复制密钥
+  const handleCopy = async () => {
+    if (unmaskedKey) {
+      try {
+        await navigator.clipboard.writeText(unmaskedKey);
+        setCopied(true);
+        toast.success("密钥已复制到剪贴板");
+        setTimeout(() => setCopied(false), 3000);
+      } catch (err) {
+        console.error("复制失败:", err);
+        toast.error("复制失败");
+      }
+    }
+  };
+
+  // 处理关闭对话框
+  const handleCloseDialog = () => {
+    setShowKeyDialog(false);
+    setUnmaskedKey(null);
+    setCopied(false);
+  };
+
   return (
     <div className="group relative h-full rounded-xl border border-border/70 bg-card p-4 shadow-sm transition-all duration-150 hover:shadow-md hover:border-border focus-within:ring-1 focus-within:ring-primary/20">
       <div className="flex items-start justify-between gap-3 mb-3">
@@ -329,7 +374,17 @@ export function ProviderListItem({
           </div>
           <div className="flex items-center gap-2 text-xs">
             <Key className="h-3.5 w-3.5 text-amber-500 shrink-0" />
-            <span className="font-mono text-muted-foreground">{item.maskedKey}</span>
+            {canEdit ? (
+              <button
+                onClick={handleShowKey}
+                className="font-mono text-muted-foreground hover:text-foreground hover:underline cursor-pointer transition-colors"
+                type="button"
+              >
+                {item.maskedKey}
+              </button>
+            ) : (
+              <span className="font-mono text-muted-foreground">{item.maskedKey}</span>
+            )}
           </div>
         </div>
 
@@ -644,6 +699,40 @@ export function ProviderListItem({
         <span>创建 {item.createdAt}</span>
         <span>更新 {item.updatedAt}</span>
       </div>
+
+      {/* API Key 查看 Dialog */}
+      <Dialog open={showKeyDialog} onOpenChange={handleCloseDialog}>
+        <DialogContent className="max-w-lg">
+          <DialogHeader>
+            <DialogTitle className="flex items-center gap-2">
+              <Key className="h-5 w-5 text-amber-500" />
+              查看完整 API Key
+            </DialogTitle>
+            <DialogDescription>请妥善保管,不要泄露给他人</DialogDescription>
+          </DialogHeader>
+
+          <div className="space-y-4">
+            <div className="flex items-center gap-2">
+              <code className="flex-1 font-mono bg-muted px-3 py-2 rounded text-sm break-all border">
+                {unmaskedKey || "加载中..."}
+              </code>
+              <Button
+                size="icon"
+                variant="outline"
+                onClick={handleCopy}
+                disabled={!unmaskedKey}
+                type="button"
+              >
+                {copied ? (
+                  <CheckCircle className="h-4 w-4 text-green-500" />
+                ) : (
+                  <Copy className="h-4 w-4" />
+                )}
+              </Button>
+            </div>
+          </div>
+        </DialogContent>
+      </Dialog>
     </div>
   );
 }

+ 4 - 4
src/app/settings/providers/_components/provider-list.tsx

@@ -2,7 +2,7 @@
 import { Globe } from "lucide-react";
 import type { ProviderDisplay } from "@/types/provider";
 import type { User } from "@/types/user";
-import { ProviderListItem } from "./provider-list-item";
+import { ProviderRichListItem } from "./provider-rich-list-item";
 import type { CurrencyCode } from "@/lib/utils/currency";
 
 interface ProviderListProps {
@@ -42,11 +42,11 @@ export function ProviderList({
   }
 
   return (
-    <div className="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-3 gap-4">
+    <div className="border rounded-lg overflow-hidden">
       {providers.map((provider) => (
-        <ProviderListItem
+        <ProviderRichListItem
           key={provider.id}
-          item={provider}
+          provider={provider}
           currentUser={currentUser}
           healthStatus={healthStatus[provider.id]}
           currencyCode={currencyCode}

+ 59 - 11
src/app/settings/providers/_components/provider-manager.tsx

@@ -1,8 +1,11 @@
 "use client";
 import { useState, useMemo } from "react";
+import { Search, X } from "lucide-react";
 import { ProviderList } from "./provider-list";
 import { ProviderTypeFilter } from "./provider-type-filter";
 import { ProviderSortDropdown, type SortKey } from "./provider-sort-dropdown";
+import { Input } from "@/components/ui/input";
+import { useDebounce } from "@/lib/hooks/use-debounce";
 import type { ProviderDisplay, ProviderType } from "@/types/provider";
 import type { User } from "@/types/user";
 import type { CurrencyCode } from "@/lib/utils/currency";
@@ -33,15 +36,31 @@ export function ProviderManager({
 }: ProviderManagerProps) {
   const [typeFilter, setTypeFilter] = useState<ProviderType | "all">("all");
   const [sortBy, setSortBy] = useState<SortKey>("priority");
+  const [searchTerm, setSearchTerm] = useState("");
+  const debouncedSearchTerm = useDebounce(searchTerm, 500);
 
-  // 根据类型筛选供应商
+  // 统一过滤逻辑:搜索 + 类型筛选 + 排序
   const filteredProviders = useMemo(() => {
-    const filtered =
-      typeFilter === "all"
-        ? providers
-        : providers.filter((provider) => provider.providerType === typeFilter);
+    let result = providers;
 
-    return [...filtered].sort((a, b) => {
+    // 搜索过滤(name, url, groupTag)
+    if (debouncedSearchTerm) {
+      const term = debouncedSearchTerm.toLowerCase();
+      result = result.filter(
+        (p) =>
+          p.name.toLowerCase().includes(term) ||
+          p.url.toLowerCase().includes(term) ||
+          (p.groupTag && p.groupTag.toLowerCase().includes(term))
+      );
+    }
+
+    // 类型筛选
+    if (typeFilter !== "all") {
+      result = result.filter((p) => p.providerType === typeFilter);
+    }
+
+    // 排序
+    return [...result].sort((a, b) => {
       switch (sortBy) {
         case "name":
           return a.name.localeCompare(b.name);
@@ -63,19 +82,48 @@ export function ProviderManager({
           return 0;
       }
     });
-  }, [providers, sortBy, typeFilter]);
+  }, [providers, debouncedSearchTerm, typeFilter, sortBy]);
 
   return (
     <div className="space-y-4">
       {/* 筛选条件 */}
-      <div className="flex items-center justify-between">
+      <div className="flex flex-col gap-3">
         <div className="flex items-center gap-2">
           <ProviderTypeFilter value={typeFilter} onChange={setTypeFilter} />
           <ProviderSortDropdown value={sortBy} onChange={setSortBy} />
+          <div className="relative flex-1">
+            <Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
+            <Input
+              type="search"
+              placeholder="搜索供应商名称、URL、备注..."
+              value={searchTerm}
+              onChange={(e) => setSearchTerm(e.target.value)}
+              className="pl-9 pr-9"
+            />
+            {searchTerm && (
+              <button
+                onClick={() => setSearchTerm("")}
+                className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground transition-colors"
+                aria-label="清除搜索"
+              >
+                <X className="h-4 w-4" />
+              </button>
+            )}
+          </div>
         </div>
-        <div className="text-sm text-muted-foreground">
-          显示 {filteredProviders.length} / {providers.length} 个供应商
-        </div>
+        {/* 搜索结果提示 */}
+        {debouncedSearchTerm && (
+          <p className="text-sm text-muted-foreground">
+            {filteredProviders.length > 0
+              ? `找到 ${filteredProviders.length} 个匹配的供应商`
+              : "未找到匹配的供应商"}
+          </p>
+        )}
+        {!debouncedSearchTerm && (
+          <div className="text-sm text-muted-foreground">
+            显示 {filteredProviders.length} / {providers.length} 个供应商
+          </div>
+        )}
       </div>
 
       {/* 供应商列表 */}

+ 485 - 0
src/app/settings/providers/_components/provider-rich-list-item.tsx

@@ -0,0 +1,485 @@
+"use client";
+import { useState, useTransition } from "react";
+import { useRouter } from "next/navigation";
+import { Button } from "@/components/ui/button";
+import { Badge } from "@/components/ui/badge";
+import {
+  CheckCircle,
+  XCircle,
+  Edit,
+  Copy,
+  Trash,
+  Globe,
+  Key,
+  RotateCcw,
+  AlertTriangle,
+} from "lucide-react";
+import type { ProviderDisplay } from "@/types/provider";
+import type { User } from "@/types/user";
+import { getProviderTypeConfig } from "@/lib/provider-type-utils";
+import {
+  Dialog,
+  DialogContent,
+  DialogHeader,
+  DialogTitle,
+  DialogDescription,
+} from "@/components/ui/dialog";
+import { ProviderForm } from "./forms/provider-form";
+import { FormErrorBoundary } from "@/components/form-error-boundary";
+import { getUnmaskedProviderKey, resetProviderCircuit, removeProvider } from "@/actions/providers";
+import { toast } from "sonner";
+import type { CurrencyCode } from "@/lib/utils/currency";
+import { formatCurrency } from "@/lib/utils/currency";
+import {
+  AlertDialog,
+  AlertDialogAction,
+  AlertDialogCancel,
+  AlertDialogContent,
+  AlertDialogDescription,
+  AlertDialogHeader,
+  AlertDialogTitle,
+  AlertDialogTrigger,
+} from "@/components/ui/alert-dialog";
+import { Switch } from "@/components/ui/switch";
+import { editProvider } from "@/actions/providers";
+
+interface ProviderRichListItemProps {
+  provider: ProviderDisplay;
+  currentUser?: User;
+  healthStatus?: {
+    circuitState: "closed" | "open" | "half-open";
+    failureCount: number;
+    lastFailureTime: number | null;
+    circuitOpenUntil: number | null;
+    recoveryMinutes: number | null;
+  };
+  currencyCode?: CurrencyCode;
+  enableMultiProviderTypes: boolean;
+  onEdit?: () => void;
+  onClone?: () => void;
+  onDelete?: () => void;
+}
+
+export function ProviderRichListItem({
+  provider,
+  currentUser,
+  healthStatus,
+  currencyCode = "USD",
+  enableMultiProviderTypes,
+  onEdit: onEditProp,
+  onClone: onCloneProp,
+  onDelete: onDeleteProp,
+}: ProviderRichListItemProps) {
+  const router = useRouter();
+  const [openEdit, setOpenEdit] = useState(false);
+  const [openClone, setOpenClone] = useState(false);
+  const [showKeyDialog, setShowKeyDialog] = useState(false);
+  const [unmaskedKey, setUnmaskedKey] = useState<string | null>(null);
+  const [copied, setCopied] = useState(false);
+  const [resetPending, startResetTransition] = useTransition();
+  const [deletePending, startDeleteTransition] = useTransition();
+  const [togglePending, startToggleTransition] = useTransition();
+
+  const canEdit = currentUser?.role === "admin";
+
+  // 获取供应商类型配置
+  const typeConfig = getProviderTypeConfig(provider.providerType);
+  const TypeIcon = typeConfig.icon;
+
+  // 处理编辑
+  const handleEdit = () => {
+    if (onEditProp) {
+      onEditProp();
+    } else {
+      setOpenEdit(true);
+    }
+  };
+
+  // 处理克隆
+  const handleClone = () => {
+    if (onCloneProp) {
+      onCloneProp();
+    } else {
+      setOpenClone(true);
+    }
+  };
+
+  // 处理删除
+  const handleDelete = () => {
+    if (onDeleteProp) {
+      onDeleteProp();
+    } else {
+      startDeleteTransition(async () => {
+        try {
+          const res = await removeProvider(provider.id);
+          if (res.ok) {
+            toast.success("删除成功", {
+              description: `供应商 "${provider.name}" 已删除`,
+            });
+            router.refresh();
+          } else {
+            toast.error("删除失败", {
+              description: res.error || "未知错误",
+            });
+          }
+        } catch (error) {
+          console.error("删除供应商失败:", error);
+          toast.error("删除失败", {
+            description: "操作过程中出现异常",
+          });
+        }
+      });
+    }
+  };
+
+  // 处理查看密钥
+  const handleShowKey = async () => {
+    setShowKeyDialog(true);
+    const result = await getUnmaskedProviderKey(provider.id);
+    if (result.ok) {
+      setUnmaskedKey(result.data.key);
+    } else {
+      toast.error("获取密钥失败", {
+        description: result.error || "未知错误",
+      });
+      setShowKeyDialog(false);
+    }
+  };
+
+  // 处理复制密钥
+  const handleCopy = async () => {
+    if (unmaskedKey) {
+      try {
+        await navigator.clipboard.writeText(unmaskedKey);
+        setCopied(true);
+        toast.success("密钥已复制到剪贴板");
+        setTimeout(() => setCopied(false), 3000);
+      } catch (error) {
+        console.error("复制失败:", error);
+        toast.error("复制失败");
+      }
+    }
+  };
+
+  // 处理关闭 Dialog
+  const handleCloseDialog = () => {
+    setShowKeyDialog(false);
+    setUnmaskedKey(null);
+    setCopied(false);
+  };
+
+  // 处理手动解除熔断
+  const handleResetCircuit = () => {
+    startResetTransition(async () => {
+      try {
+        const res = await resetProviderCircuit(provider.id);
+        if (res.ok) {
+          toast.success("熔断器已重置", {
+            description: `供应商 "${provider.name}" 的熔断状态已解除`,
+          });
+          router.refresh();
+        } else {
+          toast.error("重置熔断器失败", {
+            description: res.error || "未知错误",
+          });
+        }
+      } catch (error) {
+        console.error("重置熔断器失败:", error);
+        toast.error("重置熔断器失败", {
+          description: "操作过程中出现异常",
+        });
+      }
+    });
+  };
+
+  // 处理启用/禁用切换
+  const handleToggle = () => {
+    startToggleTransition(async () => {
+      try {
+        const res = await editProvider(provider.id, {
+          is_enabled: !provider.isEnabled,
+        });
+        if (res.ok) {
+          toast.success(`供应商已${!provider.isEnabled ? "启用" : "禁用"}`, {
+            description: `供应商 "${provider.name}" 状态已更新`,
+          });
+          router.refresh();
+        } else {
+          toast.error("状态切换失败", {
+            description: res.error || "未知错误",
+          });
+        }
+      } catch (error) {
+        console.error("状态切换失败:", error);
+        toast.error("状态切换失败", {
+          description: "操作过程中出现异常",
+        });
+      }
+    });
+  };
+
+  return (
+    <>
+      <div className="flex items-center gap-4 py-3 px-4 border-b hover:bg-muted/50 transition-colors">
+        {/* 左侧:状态和类型图标 */}
+        <div className="flex items-center gap-2">
+          {/* 启用状态指示器 */}
+          {provider.isEnabled ? (
+            <CheckCircle className="h-4 w-4 text-green-500 flex-shrink-0" />
+          ) : (
+            <XCircle className="h-4 w-4 text-gray-400 flex-shrink-0" />
+          )}
+
+          {/* 类型图标 */}
+          <div className={`flex items-center justify-center w-6 h-6 rounded ${typeConfig.bgColor} flex-shrink-0`}>
+            <TypeIcon className="h-3.5 w-3.5" />
+          </div>
+        </div>
+
+        {/* 中间:名称、URL、官网、tag、熔断状态 */}
+        <div className="flex-1 min-w-0">
+          <div className="flex items-center gap-2 flex-wrap">
+            {/* Favicon */}
+            {provider.faviconUrl && (
+              <img
+                src={provider.faviconUrl}
+                alt=""
+                className="h-4 w-4 flex-shrink-0"
+                onError={(e) => {
+                  // 隐藏加载失败的图标
+                  (e.target as HTMLImageElement).style.display = "none";
+                }}
+              />
+            )}
+
+            {/* 名称 */}
+            <span className="font-semibold truncate">{provider.name}</span>
+
+            {/* Group Tag */}
+            {provider.groupTag && (
+              <Badge variant="outline" className="flex-shrink-0">
+                {provider.groupTag}
+              </Badge>
+            )}
+
+            {/* 熔断器警告 */}
+            {healthStatus && healthStatus.circuitState === "open" && (
+              <Badge variant="destructive" className="flex items-center gap-1 flex-shrink-0">
+                <AlertTriangle className="h-3 w-3" />
+                熔断中
+              </Badge>
+            )}
+          </div>
+
+          <div className="flex items-center gap-3 mt-1 text-sm text-muted-foreground flex-wrap">
+            {/* URL */}
+            <span className="truncate max-w-[300px]">{provider.url}</span>
+
+            {/* 官网链接 */}
+            {provider.websiteUrl && (
+              <a
+                href={provider.websiteUrl}
+                target="_blank"
+                rel="noopener noreferrer"
+                className="inline-flex items-center gap-1 hover:underline text-blue-600 hover:text-blue-700 flex-shrink-0"
+                onClick={(e) => e.stopPropagation()}
+              >
+                <Globe className="h-3 w-3" />
+                官网
+              </a>
+            )}
+
+            {/* API Key 展示(仅管理员) */}
+            {canEdit && (
+              <button
+                onClick={(e) => {
+                  e.stopPropagation();
+                  handleShowKey();
+                }}
+                className="inline-flex items-center gap-1 text-xs font-mono hover:underline flex-shrink-0"
+              >
+                <Key className="h-3 w-3" />
+                {provider.maskedKey}
+              </button>
+            )}
+          </div>
+        </div>
+
+        {/* 右侧:指标(仅桌面端) */}
+        <div className="hidden md:grid grid-cols-3 gap-4 text-center flex-shrink-0">
+          <div>
+            <div className="text-xs text-muted-foreground">优先级</div>
+            <div className="font-medium">{provider.priority}</div>
+          </div>
+          <div>
+            <div className="text-xs text-muted-foreground">权重</div>
+            <div className="font-medium">{provider.weight}</div>
+          </div>
+          <div>
+            <div className="text-xs text-muted-foreground">成本倍数</div>
+            <div className="font-medium">{provider.costMultiplier}x</div>
+          </div>
+        </div>
+
+        {/* 今日用量(仅大屏) */}
+        <div className="hidden lg:block text-center flex-shrink-0 w-20">
+          <div className="text-xs text-muted-foreground">今日用量</div>
+          <div className="font-medium">{provider.todayCallCount || 0}</div>
+        </div>
+
+        {/* 操作按钮 */}
+        <div className="flex items-center gap-1 flex-shrink-0">
+          {/* 启用/禁用切换 */}
+          {canEdit && (
+            <Switch
+              checked={provider.isEnabled}
+              onCheckedChange={handleToggle}
+              disabled={togglePending}
+              className="data-[state=checked]:bg-green-500"
+            />
+          )}
+
+          {/* 编辑按钮 */}
+          {canEdit && (
+            <Button
+              size="icon"
+              variant="ghost"
+              onClick={(e) => {
+                e.stopPropagation();
+                handleEdit();
+              }}
+              disabled={!canEdit}
+            >
+              <Edit className="h-4 w-4" />
+            </Button>
+          )}
+
+          {/* 克隆按钮 */}
+          {canEdit && (
+            <Button
+              size="icon"
+              variant="ghost"
+              onClick={(e) => {
+                e.stopPropagation();
+                handleClone();
+              }}
+              disabled={!canEdit}
+            >
+              <Copy className="h-4 w-4" />
+            </Button>
+          )}
+
+          {/* 熔断重置按钮(仅熔断时显示) */}
+          {canEdit && healthStatus && healthStatus.circuitState === "open" && (
+            <Button
+              size="icon"
+              variant="ghost"
+              onClick={(e) => {
+                e.stopPropagation();
+                handleResetCircuit();
+              }}
+              disabled={resetPending}
+            >
+              <RotateCcw className="h-4 w-4 text-orange-600" />
+            </Button>
+          )}
+
+          {/* 删除按钮 */}
+          {canEdit && (
+            <AlertDialog>
+              <AlertDialogTrigger asChild>
+                <Button
+                  size="icon"
+                  variant="ghost"
+                  onClick={(e) => e.stopPropagation()}
+                  disabled={!canEdit}
+                >
+                  <Trash className="h-4 w-4 text-red-600" />
+                </Button>
+              </AlertDialogTrigger>
+              <AlertDialogContent>
+                <AlertDialogHeader>
+                  <AlertDialogTitle>确认删除供应商?</AlertDialogTitle>
+                  <AlertDialogDescription>
+                    确定要删除供应商 &quot;{provider.name}&quot; 吗?此操作无法撤销。
+                  </AlertDialogDescription>
+                </AlertDialogHeader>
+                <div className="flex justify-end gap-2">
+                  <AlertDialogCancel>取消</AlertDialogCancel>
+                  <AlertDialogAction
+                    onClick={(e) => {
+                      e.stopPropagation();
+                      handleDelete();
+                    }}
+                    className="bg-red-600 hover:bg-red-700"
+                    disabled={deletePending}
+                  >
+                    删除
+                  </AlertDialogAction>
+                </div>
+              </AlertDialogContent>
+            </AlertDialog>
+          )}
+        </div>
+      </div>
+
+      {/* 编辑 Dialog */}
+      <Dialog open={openEdit} onOpenChange={setOpenEdit}>
+        <DialogContent className="max-w-4xl max-h-[90vh] overflow-y-auto">
+          <FormErrorBoundary>
+            <ProviderForm
+              mode="edit"
+              provider={provider}
+              onSuccess={() => {
+                setOpenEdit(false);
+                router.refresh();
+              }}
+              enableMultiProviderTypes={enableMultiProviderTypes}
+            />
+          </FormErrorBoundary>
+        </DialogContent>
+      </Dialog>
+
+      {/* 克隆 Dialog */}
+      <Dialog open={openClone} onOpenChange={setOpenClone}>
+        <DialogContent className="max-w-4xl max-h-[90vh] overflow-y-auto">
+          <FormErrorBoundary>
+            <ProviderForm
+              mode="create"
+              cloneProvider={provider}
+              onSuccess={() => {
+                setOpenClone(false);
+                router.refresh();
+              }}
+              enableMultiProviderTypes={enableMultiProviderTypes}
+            />
+          </FormErrorBoundary>
+        </DialogContent>
+      </Dialog>
+
+      {/* API Key 展示 Dialog */}
+      <Dialog open={showKeyDialog} onOpenChange={handleCloseDialog}>
+        <DialogContent className="max-w-lg">
+          <DialogHeader>
+            <DialogTitle>查看完整 API Key</DialogTitle>
+            <DialogDescription>请妥善保管,不要泄露给他人</DialogDescription>
+          </DialogHeader>
+          <div className="space-y-4">
+            <div className="flex items-center gap-2">
+              <code className="flex-1 font-mono bg-muted px-3 py-2 rounded text-sm break-all">
+                {unmaskedKey || "加载中..."}
+              </code>
+              <Button onClick={handleCopy} disabled={!unmaskedKey} size="icon" variant="outline">
+                {copied ? (
+                  <CheckCircle className="h-4 w-4 text-green-600" />
+                ) : (
+                  <Copy className="h-4 w-4" />
+                )}
+              </Button>
+            </div>
+          </div>
+        </DialogContent>
+      </Dialog>
+    </>
+  );
+}

+ 4 - 0
src/drizzle/schema.ts

@@ -123,6 +123,10 @@ export const providers = pgTable('providers', {
   proxyUrl: varchar('proxy_url', { length: 512 }),
   proxyFallbackToDirect: boolean('proxy_fallback_to_direct').default(false),
 
+  // 供应商官网地址(用于快速跳转管理)
+  websiteUrl: text('website_url'),
+  faviconUrl: text('favicon_url'),
+
   // 废弃(保留向后兼容,但不再使用)
   tpm: integer('tpm').default(0),
   rpm: integer('rpm').default(0),

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

@@ -171,6 +171,14 @@ export const CreateProviderSchema = z.object({
   // 代理配置
   proxy_url: z.string().max(512, "代理地址长度不能超过512个字符").nullable().optional(),
   proxy_fallback_to_direct: z.boolean().optional().default(false),
+  // 供应商官网地址
+  website_url: z
+    .string()
+    .url("请输入有效的URL地址")
+    .max(512, "URL长度不能超过512个字符")
+    .nullable()
+    .optional(),
+  favicon_url: z.string().max(512, "Favicon URL长度不能超过512个字符").nullable().optional(),
   // 废弃字段(保留向后兼容,不再验证范围)
   tpm: z.number().int().nullable().optional(),
   rpm: z.number().int().nullable().optional(),
@@ -256,6 +264,14 @@ export const UpdateProviderSchema = z
     // 代理配置
     proxy_url: z.string().max(512, "代理地址长度不能超过512个字符").nullable().optional(),
     proxy_fallback_to_direct: z.boolean().optional(),
+    // 供应商官网地址
+    website_url: z
+      .string()
+      .url("请输入有效的URL地址")
+      .max(512, "URL长度不能超过512个字符")
+      .nullable()
+      .optional(),
+    favicon_url: z.string().max(512, "Favicon URL长度不能超过512个字符").nullable().optional(),
     // 废弃字段(保留向后兼容,不再验证范围)
     tpm: z.number().int().nullable().optional(),
     rpm: z.number().int().nullable().optional(),

+ 10 - 0
src/repository/provider.ts

@@ -35,6 +35,8 @@ export async function createProvider(providerData: CreateProviderData): Promise<
       providerData.circuit_breaker_half_open_success_threshold ?? 2,
     proxyUrl: providerData.proxy_url ?? null,
     proxyFallbackToDirect: providerData.proxy_fallback_to_direct ?? false,
+    websiteUrl: providerData.website_url ?? null,
+    faviconUrl: providerData.favicon_url ?? null,
     tpm: providerData.tpm,
     rpm: providerData.rpm,
     rpd: providerData.rpd,
@@ -65,6 +67,8 @@ export async function createProvider(providerData: CreateProviderData): Promise<
     circuitBreakerHalfOpenSuccessThreshold: providers.circuitBreakerHalfOpenSuccessThreshold,
     proxyUrl: providers.proxyUrl,
     proxyFallbackToDirect: providers.proxyFallbackToDirect,
+    websiteUrl: providers.websiteUrl,
+    faviconUrl: providers.faviconUrl,
     tpm: providers.tpm,
     rpm: providers.rpm,
     rpd: providers.rpd,
@@ -106,6 +110,8 @@ export async function findProviderList(
       circuitBreakerHalfOpenSuccessThreshold: providers.circuitBreakerHalfOpenSuccessThreshold,
       proxyUrl: providers.proxyUrl,
       proxyFallbackToDirect: providers.proxyFallbackToDirect,
+      websiteUrl: providers.websiteUrl,
+      faviconUrl: providers.faviconUrl,
       tpm: providers.tpm,
       rpm: providers.rpm,
       rpd: providers.rpd,
@@ -154,6 +160,8 @@ export async function findProviderById(id: number): Promise<Provider | null> {
       circuitBreakerHalfOpenSuccessThreshold: providers.circuitBreakerHalfOpenSuccessThreshold,
       proxyUrl: providers.proxyUrl,
       proxyFallbackToDirect: providers.proxyFallbackToDirect,
+      websiteUrl: providers.websiteUrl,
+      faviconUrl: providers.faviconUrl,
       tpm: providers.tpm,
       rpm: providers.rpm,
       rpd: providers.rpd,
@@ -220,6 +228,8 @@ export async function updateProvider(
   if (providerData.proxy_url !== undefined) dbData.proxyUrl = providerData.proxy_url;
   if (providerData.proxy_fallback_to_direct !== undefined)
     dbData.proxyFallbackToDirect = providerData.proxy_fallback_to_direct;
+  if (providerData.website_url !== undefined) dbData.websiteUrl = providerData.website_url;
+  if (providerData.favicon_url !== undefined) dbData.faviconUrl = providerData.favicon_url;
   if (providerData.tpm !== undefined) dbData.tpm = providerData.tpm;
   if (providerData.rpm !== undefined) dbData.rpm = providerData.rpm;
   if (providerData.rpd !== undefined) dbData.rpd = providerData.rpd;

+ 15 - 0
src/types/provider.ts

@@ -51,6 +51,10 @@ export interface Provider {
   proxyUrl: string | null;
   proxyFallbackToDirect: boolean;
 
+  // 供应商官网地址(用于快速跳转管理)
+  websiteUrl: string | null;
+  faviconUrl: string | null;
+
   // 废弃(保留向后兼容,但不再使用)
   // TPM (Tokens Per Minute): 每分钟可处理的文本总量
   tpm: number | null;
@@ -99,6 +103,9 @@ export interface ProviderDisplay {
   // 代理配置
   proxyUrl: string | null;
   proxyFallbackToDirect: boolean;
+  // 供应商官网地址
+  websiteUrl: string | null;
+  faviconUrl: string | null;
   // 废弃字段(保留向后兼容)
   tpm: number | null;
   rpm: number | null;
@@ -149,6 +156,10 @@ export interface CreateProviderData {
   proxy_url?: string | null;
   proxy_fallback_to_direct?: boolean;
 
+  // 供应商官网地址
+  website_url?: string | null;
+  favicon_url?: string | null;
+
   // 废弃字段(保留向后兼容)
   // TPM (Tokens Per Minute): 每分钟可处理的文本总量
   tpm: number | null;
@@ -196,6 +207,10 @@ export interface UpdateProviderData {
   proxy_url?: string | null;
   proxy_fallback_to_direct?: boolean;
 
+  // 供应商官网地址
+  website_url?: string | null;
+  favicon_url?: string | null;
+
   // 废弃字段(保留向后兼容)
   // TPM (Tokens Per Minute): 每分钟可处理的文本总量
   tpm?: number | null;