소스 검색

refactor: optimize provider edit dialog UI and remove upstream import feature

- Remove one-click upstream model import from AllowedModelRuleEditor (button, handler, 5 props)
- Delete dead code model-multi-select.tsx (520 lines, zero importers) and its test
- Add badge prop to FieldGroup component for inline rule counts
- Replace conditional tester rendering with Collapsible panels (stable layout)
- Tighten spacing, add separators, compact client restrictions hint
- Polish dispatch simulator: funnel opacity, stronger tier highlight, compact cards
- Add testRule i18n key across 5 languages

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
ding113 6 일 전
부모
커밋
fa08ed37a5

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

@@ -343,6 +343,7 @@
       },
       "nSelected": "{count} selected"
     },
+    "testRule": "Test Rule Matching",
     "preserveClientIp": {
       "desc": "Pass x-forwarded-for / x-real-ip to upstream providers (may expose real client IP)",
       "help": "Keep off by default for privacy. Enable only when upstream must see the end-user IP.",

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

@@ -344,6 +344,7 @@
       },
       "nSelected": "{count} 件選択"
     },
+    "testRule": "ルールマッチをテスト",
     "preserveClientIp": {
       "desc": "x-forwarded-for / x-real-ip を上流に渡します(実際の IP が露出する可能性)",
       "help": "プライバシー保護のためデフォルトはオフ。上流側で端末 IP が必要な場合のみ有効化してください。",

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

@@ -344,6 +344,7 @@
       },
       "nSelected": "Выбрано: {count}"
     },
+    "testRule": "Тест совпадения правил",
     "preserveClientIp": {
       "desc": "Передавать x-forwarded-for / x-real-ip в апстрим (может раскрыть реальный IP клиента)",
       "help": "По умолчанию выключено для приватности. Включайте только если апстриму нужен IP пользователя.",

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

@@ -93,6 +93,7 @@
       },
       "nSelected": "已选 {count} 项"
     },
+    "testRule": "测试规则匹配",
     "scheduleParams": {
       "title": "调度参数",
       "priority": {

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

@@ -344,6 +344,7 @@
       },
       "nSelected": "已選 {count} 項"
     },
+    "testRule": "測試規則匹配",
     "preserveClientIp": {
       "desc": "向上游轉發 x-forwarded-for / x-real-ip,可能暴露真實來源 IP",
       "help": "預設關閉以保護隱私;僅在需要上游感知終端 IP 時開啟。",

+ 3 - 119
src/app/[locale]/settings/providers/_components/allowed-model-rule-editor.tsx

@@ -1,20 +1,9 @@
 "use client";
 
-import {
-  AlertCircle,
-  Check,
-  ChevronDown,
-  ChevronUp,
-  Loader2,
-  Pencil,
-  Plus,
-  RefreshCw,
-  X,
-} from "lucide-react";
+import { AlertCircle, Check, ChevronDown, ChevronUp, Pencil, Plus, X } from "lucide-react";
 import { useTranslations } from "next-intl";
 import { useState } from "react";
 import safeRegex from "safe-regex";
-import { fetchUpstreamModels, getUnmaskedProviderKey } from "@/actions/providers";
 import { Badge } from "@/components/ui/badge";
 import { Button } from "@/components/ui/button";
 import { Input } from "@/components/ui/input";
@@ -37,11 +26,6 @@ interface AllowedModelRuleEditorProps {
   onChange: (value: AllowedModelRule[]) => void;
   disabled?: boolean;
   providerType: ProviderType;
-  providerUrl?: string;
-  apiKey?: string;
-  proxyUrl?: string | null;
-  proxyFallbackToDirect?: boolean;
-  providerId?: number;
 }
 
 const DEFAULT_RULE: AllowedModelRule = {
@@ -64,19 +48,12 @@ export function AllowedModelRuleEditor({
   value,
   onChange,
   disabled = false,
-  providerType,
-  providerUrl,
-  apiKey,
-  proxyUrl,
-  proxyFallbackToDirect,
-  providerId,
 }: AllowedModelRuleEditorProps) {
   const t = useTranslations("settings.providers.form.allowedModelRules");
   const [newRule, setNewRule] = useState<AllowedModelRule>(DEFAULT_RULE);
   const [editRule, setEditRule] = useState<AllowedModelRule>(DEFAULT_RULE);
   const [editingRuleKey, setEditingRuleKey] = useState<string | null>(null);
   const [error, setError] = useState<string | null>(null);
-  const [isQuickAdding, setIsQuickAdding] = useState(false);
 
   const matchTypeOptions: Array<{ value: ProviderModelRedirectMatchType; label: string }> = [
     { value: "exact", label: t("matchTypeExact") },
@@ -218,104 +195,11 @@ export function AllowedModelRuleEditor({
     }
   };
 
-  const handleQuickAdd = async () => {
-    if (!providerUrl) {
-      setError(t("quickAddMissingUrl"));
-      return;
-    }
-
-    setIsQuickAdding(true);
-    setError(null);
-
-    try {
-      let resolvedKey = apiKey?.trim() || "";
-
-      if (!resolvedKey && providerId) {
-        const keyResult = await getUnmaskedProviderKey(providerId);
-        if (keyResult.ok && keyResult.data?.key) {
-          resolvedKey = keyResult.data.key;
-        }
-      }
-
-      if (!resolvedKey) {
-        setError(t("quickAddMissingKey"));
-        return;
-      }
-
-      const upstreamResult = await fetchUpstreamModels({
-        providerUrl,
-        apiKey: resolvedKey,
-        providerType,
-        proxyUrl,
-        proxyFallbackToDirect,
-      });
-
-      if (!upstreamResult.ok) {
-        setError(upstreamResult.error || t("quickAddFailed"));
-        return;
-      }
-
-      if (!upstreamResult.data?.models?.length) {
-        setError(t("quickAddFailed"));
-        return;
-      }
-
-      const existingKeys = new Set(value.map((rule) => getRuleIdentity(rule)));
-      const nextRules = [...value];
-      let addedCount = 0;
-      let hitLimit = false;
-
-      for (const model of upstreamResult.data.models) {
-        const rule = normalizeRule({ matchType: "exact", pattern: model });
-        const key = getRuleIdentity(rule);
-        if (existingKeys.has(key)) {
-          continue;
-        }
-        if (nextRules.length >= 100) {
-          setError(t("quickAddReachedLimit"));
-          hitLimit = true;
-          break;
-        }
-        nextRules.push(rule);
-        existingKeys.add(key);
-        addedCount += 1;
-      }
-
-      if (addedCount === 0 && !hitLimit) {
-        setError(t("quickAddNoNewRules"));
-        return;
-      }
-
-      onChange(nextRules);
-    } finally {
-      setIsQuickAdding(false);
-    }
-  };
-
   return (
     <div className="space-y-3">
       <div className="rounded-lg border border-border/60 bg-muted/20 p-3">
-        <div className="flex flex-wrap items-center justify-between gap-3">
-          <div>
-            <p className="text-sm font-medium">{t("description")}</p>
-            <p className="text-xs text-muted-foreground">{t("orderHint")}</p>
-          </div>
-          <Button
-            type="button"
-            variant="outline"
-            size="sm"
-            onClick={() => void handleQuickAdd()}
-            disabled={disabled || isQuickAdding}
-            data-allowed-model-quick-add
-          >
-            {isQuickAdding ? (
-              <Loader2 className="mr-2 h-4 w-4 animate-spin" />
-            ) : (
-              <RefreshCw className="mr-2 h-4 w-4" />
-            )}
-            {t("quickAdd")}
-          </Button>
-        </div>
+        <p className="text-sm font-medium">{t("description")}</p>
+        <p className="text-xs text-muted-foreground">{t("orderHint")}</p>
       </div>
 
       <div className="grid gap-2 rounded-lg border border-dashed border-border/70 bg-muted/10 p-3 md:grid-cols-[140px_1fr_auto]">

+ 47 - 34
src/app/[locale]/settings/providers/_components/dispatch-simulator-dialog.tsx

@@ -205,21 +205,30 @@ export function DispatchSimulatorDialog({ providers }: DispatchSimulatorDialogPr
 
           {result ? (
             <div className="space-y-4">
-              <div className="rounded-lg border border-border/60 bg-muted/10 p-4">
-                <div className="flex flex-wrap items-center gap-3">
-                  {stepSummary.map((step, index) => (
-                    <div key={step.stepName} className="flex items-center gap-3">
-                      <div className="rounded-md border border-border/60 bg-background px-3 py-2 text-center">
-                        <div className="text-[11px] uppercase tracking-wide text-muted-foreground">
-                          {t(`steps.${step.stepName}`)}
+              <div className="rounded-lg border border-border/60 bg-muted/10 p-3">
+                <div className="flex flex-wrap items-center gap-1.5">
+                  {stepSummary.map((step, index) => {
+                    const ratio =
+                      stepSummary[0].outputCount > 0
+                        ? step.outputCount / stepSummary[0].outputCount
+                        : 0;
+                    return (
+                      <div key={step.stepName} className="flex items-center gap-1.5">
+                        <div
+                          className="rounded-md border border-border/60 bg-background px-2.5 py-1.5 text-center"
+                          style={{ opacity: 0.5 + ratio * 0.5 }}
+                        >
+                          <div className="text-[10px] uppercase tracking-wide text-muted-foreground">
+                            {t(`steps.${step.stepName}`)}
+                          </div>
+                          <div className="text-base font-semibold">{step.outputCount}</div>
                         </div>
-                        <div className="text-lg font-semibold">{step.outputCount}</div>
+                        {index < stepSummary.length - 1 ? (
+                          <ChevronRight className="h-3 w-3 text-muted-foreground shrink-0" />
+                        ) : null}
                       </div>
-                      {index < stepSummary.length - 1 ? (
-                        <ChevronRight className="h-4 w-4 text-muted-foreground" />
-                      ) : null}
-                    </div>
-                  ))}
+                    );
+                  })}
                 </div>
               </div>
 
@@ -365,7 +374,7 @@ export function DispatchSimulatorDialog({ providers }: DispatchSimulatorDialogPr
                           key={tier.priority}
                           className={`rounded-lg border p-3 ${
                             tier.isSelected
-                              ? "border-green-500/40 bg-green-500/5"
+                              ? "border-green-500/40 bg-green-500/5 ring-1 ring-green-500/20 shadow-sm shadow-green-500/10"
                               : "border-border/60 bg-background"
                           }`}
                         >
@@ -387,14 +396,14 @@ export function DispatchSimulatorDialog({ providers }: DispatchSimulatorDialogPr
                             {tier.providers.map((provider) => (
                               <div
                                 key={provider.id}
-                                className="rounded-md border border-border/50 px-3 py-3"
+                                className="rounded-md border border-border/50 px-3 py-2"
                               >
-                                <div className="flex flex-wrap items-center justify-between gap-3">
-                                  <div>
+                                <div className="flex flex-wrap items-center justify-between gap-2">
+                                  <div className="flex items-center gap-2">
                                     <div className="font-medium">{provider.name}</div>
-                                    <div className="text-xs text-muted-foreground">
+                                    <Badge variant="outline" className="text-[10px]">
                                       {provider.providerType}
-                                    </div>
+                                    </Badge>
                                   </div>
                                   <div className="flex items-center gap-2 text-xs text-muted-foreground">
                                     <span>{t("weightLabel", { weight: provider.weight })}</span>
@@ -403,21 +412,25 @@ export function DispatchSimulatorDialog({ providers }: DispatchSimulatorDialogPr
                                     </Badge>
                                   </div>
                                 </div>
-                                <Progress value={provider.weightPercent} className="mt-3 h-2" />
-                                {provider.redirectedModel ? (
-                                  <p className="mt-2 text-xs text-muted-foreground">
-                                    {t("redirectPreview", { model: provider.redirectedModel })}
-                                  </p>
-                                ) : null}
-                                {provider.endpointStats ? (
-                                  <p className="mt-1 text-xs text-muted-foreground">
-                                    {t("endpointStats", {
-                                      total: provider.endpointStats.total,
-                                      enabled: provider.endpointStats.enabled,
-                                      circuitOpen: provider.endpointStats.circuitOpen,
-                                      available: provider.endpointStats.available,
-                                    })}
-                                  </p>
+                                <Progress value={provider.weightPercent} className="mt-2 h-1.5" />
+                                {provider.redirectedModel || provider.endpointStats ? (
+                                  <div className="mt-1.5 flex flex-wrap gap-x-3 text-[11px] text-muted-foreground">
+                                    {provider.redirectedModel ? (
+                                      <span>
+                                        {t("redirectPreview", { model: provider.redirectedModel })}
+                                      </span>
+                                    ) : null}
+                                    {provider.endpointStats ? (
+                                      <span>
+                                        {t("endpointStats", {
+                                          total: provider.endpointStats.total,
+                                          enabled: provider.endpointStats.enabled,
+                                          circuitOpen: provider.endpointStats.circuitOpen,
+                                          available: provider.endpointStats.available,
+                                        })}
+                                      </span>
+                                    ) : null}
+                                  </div>
                                 ) : null}
                               </div>
                             ))}

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

@@ -95,6 +95,7 @@ interface FieldGroupProps {
   children: ReactNode;
   className?: string;
   horizontal?: boolean;
+  badge?: ReactNode;
 }
 
 export function FieldGroup({
@@ -103,12 +104,18 @@ export function FieldGroup({
   children,
   className,
   horizontal = false,
+  badge,
 }: FieldGroupProps) {
   return (
     <div className={cn("space-y-3", className)}>
       {(label || description) && (
         <div className="space-y-1">
-          {label && <div className="text-sm font-medium text-foreground">{label}</div>}
+          {label && (
+            <div className="flex items-center gap-2">
+              <div className="text-sm font-medium text-foreground">{label}</div>
+              {badge}
+            </div>
+          )}
           {description && <p className="text-xs text-muted-foreground">{description}</p>}
         </div>
       )}

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

@@ -1,12 +1,13 @@
 "use client";
 
 import { motion } from "framer-motion";
-import { Info, Layers, Route, Scale } from "lucide-react";
+import { ChevronDown, Info, Layers, Route, Scale, Search } from "lucide-react";
 import { useTranslations } from "next-intl";
 import { useEffect, useState } from "react";
 import { toast } from "sonner";
 import { ClientRestrictionsEditor } from "@/components/form/client-restrictions-editor";
 import { Badge } from "@/components/ui/badge";
+import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
 import { Input } from "@/components/ui/input";
 import {
   Select,
@@ -18,6 +19,7 @@ import {
 import { Switch } from "@/components/ui/switch";
 import { TagInput } from "@/components/ui/tag-input";
 import { getProviderTypeConfig } from "@/lib/provider-type-utils";
+import { cn } from "@/lib/utils";
 import type { ProviderType } from "@/types/provider";
 import { AllowedModelRuleEditor } from "../../../allowed-model-rule-editor";
 import { AllowedModelTester } from "../../../allowed-model-tester";
@@ -38,15 +40,8 @@ interface RoutingSectionProps {
 export function RoutingSection({ subSectionRefs }: RoutingSectionProps) {
   const t = useTranslations("settings.providers.form");
   const tUI = useTranslations("ui.tagInput");
-  const {
-    state,
-    dispatch,
-    mode,
-    provider,
-    enableMultiProviderTypes,
-    groupSuggestions,
-    batchAnalysis,
-  } = useProviderForm();
+  const { state, dispatch, mode, enableMultiProviderTypes, groupSuggestions, batchAnalysis } =
+    useProviderForm();
   const isEdit = mode === "edit";
   const isBatch = mode === "batch";
 
@@ -185,54 +180,94 @@ export function RoutingSection({ subSectionRefs }: RoutingSectionProps) {
         description={t("sections.routing.modelWhitelist.desc")}
         icon={Layers}
       >
-        <div className="space-y-4">
+        <div className="space-y-3">
           {/* Model Redirects */}
-          <FieldGroup label={t("sections.routing.modelRedirects.label")}>
+          <FieldGroup
+            label={t("sections.routing.modelRedirects.label")}
+            badge={
+              state.routing.modelRedirects.length > 0 ? (
+                <Badge variant="secondary" className="text-[10px] px-1.5 py-0 font-normal">
+                  {state.routing.modelRedirects.length}
+                </Badge>
+              ) : null
+            }
+          >
             <div className="space-y-2">
               <ModelRedirectEditor
                 value={state.routing.modelRedirects}
                 onChange={(value) => dispatch({ type: "SET_MODEL_REDIRECTS", payload: value })}
                 disabled={state.ui.isPending}
               />
-              {state.routing.modelRedirects.length > 0 ? (
-                <ModelRedirectTester rules={state.routing.modelRedirects} />
-              ) : null}
+              <Collapsible>
+                <CollapsibleTrigger asChild>
+                  <button
+                    type="button"
+                    className={cn(
+                      "group flex w-full items-center gap-2 rounded-md border border-border/50",
+                      "px-3 py-1.5 text-xs text-muted-foreground transition-colors",
+                      state.routing.modelRedirects.length > 0
+                        ? "hover:bg-muted/40 bg-muted/20"
+                        : "pointer-events-none opacity-40 bg-muted/10"
+                    )}
+                    disabled={state.routing.modelRedirects.length === 0}
+                  >
+                    <Search className="h-3 w-3" />
+                    {t("sections.routing.testRule")}
+                    <ChevronDown className="ml-auto h-3 w-3 transition-transform group-data-[state=open]:rotate-180" />
+                  </button>
+                </CollapsibleTrigger>
+                <CollapsibleContent className="pt-2">
+                  <ModelRedirectTester rules={state.routing.modelRedirects} />
+                </CollapsibleContent>
+              </Collapsible>
               {isBatch && batchAnalysis?.routing.modelRedirects.status === "mixed" && (
                 <MixedValueIndicator values={batchAnalysis.routing.modelRedirects.values} />
               )}
             </div>
           </FieldGroup>
 
+          <div className="border-t border-border/30" />
+
           {/* Allowed Models */}
-          <FieldGroup label={t("sections.routing.modelWhitelist.label")}>
+          <FieldGroup
+            label={t("sections.routing.modelWhitelist.label")}
+            badge={
+              state.routing.allowedModels.length > 0 ? (
+                <Badge variant="secondary" className="text-[10px] px-1.5 py-0 font-normal">
+                  {state.routing.allowedModels.length}
+                </Badge>
+              ) : null
+            }
+          >
             <div className="space-y-2">
               <AllowedModelRuleEditor
                 providerType={state.routing.providerType}
                 value={state.routing.allowedModels}
                 onChange={(value) => dispatch({ type: "SET_ALLOWED_MODELS", payload: value })}
                 disabled={state.ui.isPending}
-                providerUrl={state.basic.url}
-                apiKey={state.basic.key}
-                proxyUrl={state.network.proxyUrl}
-                proxyFallbackToDirect={state.network.proxyFallbackToDirect}
-                providerId={isEdit ? provider?.id : undefined}
               />
-              {state.routing.allowedModels.length > 0 ? (
-                <AllowedModelTester rules={state.routing.allowedModels} />
-              ) : null}
-              <p className="text-xs text-muted-foreground">
-                {state.routing.allowedModels.length === 0 ? (
-                  <span className="text-green-600">
-                    {t("sections.routing.modelWhitelist.allowAll")}
-                  </span>
-                ) : (
-                  <span>
-                    {t("sections.routing.modelWhitelist.selectedOnly", {
-                      count: state.routing.allowedModels.length,
-                    })}
-                  </span>
-                )}
-              </p>
+              <Collapsible>
+                <CollapsibleTrigger asChild>
+                  <button
+                    type="button"
+                    className={cn(
+                      "group flex w-full items-center gap-2 rounded-md border border-border/50",
+                      "px-3 py-1.5 text-xs text-muted-foreground transition-colors",
+                      state.routing.allowedModels.length > 0
+                        ? "hover:bg-muted/40 bg-muted/20"
+                        : "pointer-events-none opacity-40 bg-muted/10"
+                    )}
+                    disabled={state.routing.allowedModels.length === 0}
+                  >
+                    <Search className="h-3 w-3" />
+                    {t("sections.routing.testRule")}
+                    <ChevronDown className="ml-auto h-3 w-3 transition-transform group-data-[state=open]:rotate-180" />
+                  </button>
+                </CollapsibleTrigger>
+                <CollapsibleContent className="pt-2">
+                  <AllowedModelTester rules={state.routing.allowedModels} />
+                </CollapsibleContent>
+              </Collapsible>
             </div>
           </FieldGroup>
 
@@ -250,13 +285,10 @@ export function RoutingSection({ subSectionRefs }: RoutingSectionProps) {
 
           {clientRestrictionsEnabled && (
             <div className="space-y-3">
-              <div className="space-y-1 rounded-md border bg-muted/30 p-3">
+              <div className="rounded-md border border-border/40 bg-muted/20 px-3 py-2">
                 <p className="text-xs text-muted-foreground">
                   {t("sections.routing.clientRestrictions.priorityNote")}
                 </p>
-                <p className="text-xs text-muted-foreground">
-                  {t("sections.routing.clientRestrictions.customHelp")}
-                </p>
               </div>
 
               <ClientRestrictionsEditor

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

@@ -1,542 +0,0 @@
-"use client";
-import {
-  Check,
-  ChevronsUpDown,
-  Cloud,
-  Database,
-  Loader2,
-  Pencil,
-  Plus,
-  RefreshCw,
-  X,
-} from "lucide-react";
-import { useTranslations } from "next-intl";
-import { useCallback, useEffect, useMemo, useState } from "react";
-import { getAvailableModelsByProviderType } from "@/actions/model-prices";
-import { fetchUpstreamModels, getUnmaskedProviderKey } from "@/actions/providers";
-import { Badge } from "@/components/ui/badge";
-import { Button } from "@/components/ui/button";
-import { Checkbox } from "@/components/ui/checkbox";
-import {
-  Command,
-  CommandEmpty,
-  CommandGroup,
-  CommandInput,
-  CommandItem,
-  CommandList,
-} from "@/components/ui/command";
-import { Input } from "@/components/ui/input";
-import { Label } from "@/components/ui/label";
-import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
-import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
-import type { ProviderType } from "@/types/provider";
-
-type ModelSource = "upstream" | "fallback" | "loading";
-
-interface ModelMultiSelectProps {
-  providerType: ProviderType;
-  selectedModels: string[];
-  onChange: (models: string[]) => void;
-  disabled?: boolean;
-  /** 供应商 URL(用于获取上游模型列表) */
-  providerUrl?: string;
-  /** API Key(用于获取上游模型列表) */
-  apiKey?: string;
-  /** 代理 URL */
-  proxyUrl?: string | null;
-  /** 代理失败时是否回退到直连 */
-  proxyFallbackToDirect?: boolean;
-  /** 供应商 ID(编辑模式下用于获取未脱敏的 API Key) */
-  providerId?: number;
-}
-
-function ModelSourceIndicator({
-  loading,
-  isUpstream,
-  label,
-  description,
-}: {
-  loading: boolean;
-  isUpstream: boolean;
-  label: string;
-  description: string;
-}) {
-  if (loading) return null;
-
-  const Icon = isUpstream ? Cloud : Database;
-
-  return (
-    <TooltipProvider>
-      <Tooltip>
-        <TooltipTrigger asChild>
-          <div className="flex items-center gap-1.5 px-2 py-1 rounded-md bg-muted/50 text-xs text-muted-foreground">
-            <Icon className="h-3 w-3" />
-            <span>{label}</span>
-          </div>
-        </TooltipTrigger>
-        <TooltipContent side="top" className="max-w-[200px]">
-          <p className="text-xs">{description}</p>
-        </TooltipContent>
-      </Tooltip>
-    </TooltipProvider>
-  );
-}
-
-export function ModelMultiSelect({
-  providerType,
-  selectedModels,
-  onChange,
-  disabled = false,
-  providerUrl,
-  apiKey,
-  proxyUrl,
-  proxyFallbackToDirect,
-  providerId,
-}: ModelMultiSelectProps) {
-  const t = useTranslations("settings.providers.form.modelSelect");
-  const [open, setOpen] = useState(false);
-  const [availableModels, setAvailableModels] = useState<string[]>([]);
-  const [loading, setLoading] = useState(true);
-  const [modelSource, setModelSource] = useState<ModelSource>("loading");
-  const [customModel, setCustomModel] = useState("");
-  const [editingIndex, setEditingIndex] = useState<number | null>(null);
-  const [editValue, setEditValue] = useState("");
-  const [managementError, setManagementError] = useState<string | null>(null);
-
-  const displayedModels = useMemo(() => {
-    const seen = new Set<string>();
-    const merged: string[] = [];
-
-    for (const model of availableModels) {
-      if (seen.has(model)) continue;
-      seen.add(model);
-      merged.push(model);
-    }
-
-    // 关键:把已选中但不在远端列表的自定义模型也渲染出来,保证可取消选中
-    for (const model of selectedModels) {
-      if (seen.has(model)) continue;
-      seen.add(model);
-      merged.push(model);
-    }
-
-    return merged;
-  }, [availableModels, selectedModels]);
-
-  const availableDisplayedModels = useMemo(
-    () => displayedModels.filter((model) => !selectedModels.includes(model)),
-    [displayedModels, selectedModels]
-  );
-
-  // 供应商类型到显示名称的映射
-  const getProviderTypeLabel = (type: string): string => {
-    const typeMap: Record<string, string> = {
-      claude: t("claude"),
-      "claude-auth": t("claude"),
-      codex: t("openai"),
-      gemini: t("gemini"),
-      "gemini-cli": t("gemini"),
-      "openai-compatible": t("openai"),
-    };
-    return typeMap[type] || t("openai");
-  };
-
-  // 加载模型列表(优先上游,失败则回退)
-  const loadModels = useCallback(async () => {
-    setLoading(true);
-    setModelSource("loading");
-
-    // 尝试从上游获取模型列表
-    if (providerUrl) {
-      // 解析 API Key:优先使用表单中的 key,否则从数据库获取
-      let resolvedKey = apiKey?.trim() || "";
-
-      if (!resolvedKey && providerId) {
-        const keyResult = await getUnmaskedProviderKey(providerId);
-        if (keyResult.ok && keyResult.data?.key) {
-          resolvedKey = keyResult.data.key;
-        }
-      }
-
-      if (resolvedKey) {
-        const upstreamResult = await fetchUpstreamModels({
-          providerUrl,
-          apiKey: resolvedKey,
-          providerType,
-          proxyUrl,
-          proxyFallbackToDirect,
-        });
-
-        if (upstreamResult.ok && upstreamResult.data) {
-          setAvailableModels(upstreamResult.data.models);
-          setModelSource("upstream");
-          setLoading(false);
-          return;
-        }
-      }
-    }
-
-    // 回退到全量模型列表
-    const fallbackModels = await getAvailableModelsByProviderType();
-    setAvailableModels(fallbackModels);
-    setModelSource("fallback");
-    setLoading(false);
-  }, [providerUrl, apiKey, providerId, providerType, proxyUrl, proxyFallbackToDirect]);
-
-  // 组件挂载时加载模型
-  useEffect(() => {
-    loadModels();
-  }, [loadModels]);
-
-  const toggleModel = (model: string) => {
-    setManagementError(null);
-    if (selectedModels.includes(model)) {
-      onChange(selectedModels.filter((m) => m !== model));
-    } else {
-      onChange([...selectedModels, model]);
-    }
-  };
-
-  const selectAll = () => onChange(availableModels);
-  const clearAll = () => {
-    setManagementError(null);
-    setEditingIndex(null);
-    setEditValue("");
-    onChange([]);
-  };
-
-  const handleAddCustomModel = () => {
-    const trimmed = customModel.trim();
-    if (!trimmed) return;
-    setManagementError(null);
-
-    if (selectedModels.includes(trimmed)) {
-      setCustomModel("");
-      return;
-    }
-
-    onChange([...selectedModels, trimmed]);
-    setCustomModel("");
-  };
-
-  const isUpstream = modelSource === "upstream";
-  const sourceLabel = isUpstream ? t("sourceUpstream") : t("sourceFallback");
-  const sourceDescription = isUpstream ? t("sourceUpstreamDesc") : t("sourceFallbackDesc");
-
-  const handleRemoveSelectedModel = (index: number) => {
-    setManagementError(null);
-    if (editingIndex === index) {
-      setEditingIndex(null);
-      setEditValue("");
-    }
-    onChange(selectedModels.filter((_, currentIndex) => currentIndex !== index));
-  };
-
-  const handleStartEditSelectedModel = (index: number, model: string) => {
-    setEditingIndex(index);
-    setEditValue(model);
-    setManagementError(null);
-  };
-
-  const handleCancelEditSelectedModel = () => {
-    setEditingIndex(null);
-    setEditValue("");
-    setManagementError(null);
-  };
-
-  const handleSaveEditSelectedModel = (index: number) => {
-    const trimmed = editValue.trim();
-    if (!trimmed) {
-      setManagementError(t("selectedEditEmpty"));
-      return;
-    }
-
-    if (selectedModels.some((model, currentIndex) => currentIndex !== index && model === trimmed)) {
-      setManagementError(t("selectedEditExists", { model: trimmed }));
-      return;
-    }
-
-    setManagementError(null);
-    onChange(
-      selectedModels.map((model, currentIndex) => (currentIndex === index ? trimmed : model))
-    );
-    setEditingIndex(null);
-    setEditValue("");
-  };
-
-  return (
-    <div className="space-y-3">
-      <Popover open={open} onOpenChange={setOpen}>
-        <PopoverTrigger asChild>
-          <Button
-            variant="outline"
-            role="combobox"
-            aria-expanded={open}
-            disabled={disabled}
-            className="w-full justify-between"
-          >
-            {selectedModels.length === 0 ? (
-              <span className="text-muted-foreground">
-                {t("allowAllModels", {
-                  type: getProviderTypeLabel(providerType),
-                })}
-              </span>
-            ) : (
-              <div className="flex gap-2 items-center">
-                <span className="truncate">
-                  {t("selectedCount", { count: selectedModels.length })}
-                </span>
-                <Badge variant="secondary" className="ml-auto">
-                  {selectedModels.length}
-                </Badge>
-              </div>
-            )}
-            {loading ? (
-              <Loader2 className="ml-2 h-4 w-4 shrink-0 animate-spin opacity-50" />
-            ) : (
-              <ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
-            )}
-          </Button>
-        </PopoverTrigger>
-        <PopoverContent
-          className="w-[400px] max-w-[calc(100vw-2rem)] p-0 flex flex-col"
-          align="start"
-          onWheel={(e) => e.stopPropagation()}
-          onTouchMove={(e) => e.stopPropagation()}
-        >
-          <Command shouldFilter={true}>
-            <CommandInput placeholder={t("searchPlaceholder")} />
-            <CommandList className="max-h-[250px] overflow-y-auto">
-              <CommandEmpty>{loading ? t("loading") : t("notFound")}</CommandEmpty>
-
-              {!loading && (
-                <>
-                  <CommandGroup>
-                    <div className="flex items-center justify-between gap-2 p-2">
-                      <div className="flex items-center gap-2">
-                        <ModelSourceIndicator
-                          loading={loading}
-                          isUpstream={isUpstream}
-                          label={sourceLabel}
-                          description={sourceDescription}
-                        />
-                        <TooltipProvider>
-                          <Tooltip>
-                            <TooltipTrigger asChild>
-                              <Button
-                                size="icon"
-                                variant="ghost"
-                                className="h-6 w-6"
-                                onClick={(e) => {
-                                  e.stopPropagation();
-                                  loadModels();
-                                }}
-                                type="button"
-                              >
-                                <RefreshCw className="h-3 w-3" />
-                              </Button>
-                            </TooltipTrigger>
-                            <TooltipContent side="top">
-                              <p className="text-xs">{t("refresh")}</p>
-                            </TooltipContent>
-                          </Tooltip>
-                        </TooltipProvider>
-                      </div>
-                      <div className="flex gap-2">
-                        <Button
-                          size="sm"
-                          variant="outline"
-                          onClick={selectAll}
-                          className="h-7 text-xs"
-                          type="button"
-                        >
-                          {t("selectAll", { count: availableModels.length })}
-                        </Button>
-                        <Button
-                          size="sm"
-                          variant="outline"
-                          onClick={clearAll}
-                          disabled={selectedModels.length === 0}
-                          className="h-7 text-xs"
-                          type="button"
-                        >
-                          {t("clear")}
-                        </Button>
-                      </div>
-                    </div>
-                  </CommandGroup>
-
-                  {selectedModels.length > 0 && (
-                    <div data-model-group="selected">
-                      <CommandGroup heading={t("selectedGroupLabel")}>
-                        {selectedModels.map((model, index) => (
-                          <CommandItem
-                            key={`selected:${index}:${model}`}
-                            value={model}
-                            onSelect={() => handleRemoveSelectedModel(index)}
-                            className="cursor-pointer"
-                          >
-                            <Checkbox
-                              checked={true}
-                              className="mr-2"
-                              onCheckedChange={() => handleRemoveSelectedModel(index)}
-                            />
-                            <span className="font-mono text-sm flex-1">{model}</span>
-                            <Check className="h-4 w-4 text-primary" />
-                          </CommandItem>
-                        ))}
-                      </CommandGroup>
-                    </div>
-                  )}
-
-                  <div data-model-group="available">
-                    <CommandGroup heading={t("availableGroupLabel")}>
-                      {availableDisplayedModels.map((model) => (
-                        <CommandItem
-                          key={model}
-                          value={model}
-                          onSelect={() => toggleModel(model)}
-                          className="cursor-pointer"
-                        >
-                          <Checkbox
-                            checked={false}
-                            className="mr-2"
-                            onCheckedChange={() => toggleModel(model)}
-                          />
-                          <span className="font-mono text-sm flex-1">{model}</span>
-                        </CommandItem>
-                      ))}
-                    </CommandGroup>
-                  </div>
-                </>
-              )}
-            </CommandList>
-          </Command>
-
-          <div className="border-t p-3 space-y-2">
-            <Label className="text-xs font-medium">{t("manualAdd")}</Label>
-            <div className="flex gap-2">
-              <Input
-                placeholder={t("manualPlaceholder")}
-                value={customModel}
-                onChange={(e) => setCustomModel(e.target.value)}
-                onKeyDown={(e) => {
-                  if (e.key === "Enter") {
-                    e.preventDefault();
-                    handleAddCustomModel();
-                  }
-                }}
-                disabled={disabled}
-                className="font-mono text-sm flex-1"
-              />
-              <Button
-                size="sm"
-                onClick={handleAddCustomModel}
-                disabled={disabled || !customModel.trim()}
-                type="button"
-              >
-                <Plus className="h-4 w-4" />
-              </Button>
-            </div>
-            <p className="text-xs text-muted-foreground">{t("manualDesc")}</p>
-          </div>
-        </PopoverContent>
-      </Popover>
-
-      {selectedModels.length > 0 && (
-        <div className="rounded-md border border-border/60 bg-muted/20 p-3 space-y-2">
-          <div className="flex items-center justify-between gap-2">
-            <Label className="text-xs font-medium">
-              {t("selectedListLabel", { count: selectedModels.length })}
-            </Label>
-          </div>
-
-          <div className="space-y-1">
-            {selectedModels.map((model, index) => {
-              const isEditing = editingIndex === index;
-
-              return (
-                <div
-                  key={`${index}:${model}`}
-                  data-model-row={`${index}:${model}`}
-                  className="flex flex-wrap items-center gap-2 rounded-md border border-border/50 bg-background px-3 py-2"
-                >
-                  {isEditing ? (
-                    <>
-                      <Input
-                        value={editValue}
-                        data-model-edit-input={model}
-                        onChange={(e) => setEditValue(e.target.value)}
-                        onInput={(e) => setEditValue((e.target as HTMLInputElement).value)}
-                        onKeyDown={(e) => {
-                          if (e.key === "Enter") {
-                            e.preventDefault();
-                            handleSaveEditSelectedModel(index);
-                          } else if (e.key === "Escape") {
-                            e.preventDefault();
-                            handleCancelEditSelectedModel();
-                          }
-                        }}
-                        className="font-mono text-sm h-8 flex-1"
-                        autoFocus
-                      />
-                      <Button
-                        type="button"
-                        variant="ghost"
-                        size="sm"
-                        data-model-edit-save={model}
-                        onClick={() => handleSaveEditSelectedModel(index)}
-                        disabled={disabled}
-                        className="h-8 w-8 p-0"
-                      >
-                        <Check className="h-3.5 w-3.5 text-green-600" />
-                      </Button>
-                      <Button
-                        type="button"
-                        variant="ghost"
-                        size="sm"
-                        onClick={handleCancelEditSelectedModel}
-                        disabled={disabled}
-                        className="h-8 w-8 p-0"
-                      >
-                        <X className="h-3.5 w-3.5 text-muted-foreground" />
-                      </Button>
-                    </>
-                  ) : (
-                    <>
-                      <span className="font-mono text-sm flex-1 break-all">{model}</span>
-                      <Button
-                        type="button"
-                        variant="ghost"
-                        size="sm"
-                        data-model-edit={model}
-                        onClick={() => handleStartEditSelectedModel(index, model)}
-                        disabled={disabled}
-                        className="h-8 w-8 p-0"
-                      >
-                        <Pencil className="h-3.5 w-3.5 text-muted-foreground" />
-                      </Button>
-                      <Button
-                        type="button"
-                        variant="ghost"
-                        size="sm"
-                        data-model-remove={model}
-                        onClick={() => handleRemoveSelectedModel(index)}
-                        disabled={disabled}
-                        className="h-8 w-8 p-0"
-                      >
-                        <X className="h-3.5 w-3.5 text-muted-foreground" />
-                      </Button>
-                    </>
-                  )}
-                </div>
-              );
-            })}
-          </div>
-
-          {managementError && <div className="text-xs text-destructive">{managementError}</div>}
-        </div>
-      )}
-    </div>
-  );
-}

+ 0 - 48
tests/unit/settings/providers/allowed-model-rule-editor.test.tsx

@@ -15,13 +15,6 @@ import formsMessages from "../../../../messages/en/forms.json";
 import settingsMessages from "../../../../messages/en/settings";
 import uiMessages from "../../../../messages/en/ui.json";
 
-const providerActionMocks = vi.hoisted(() => ({
-  fetchUpstreamModels: vi.fn(),
-  getUnmaskedProviderKey: vi.fn(),
-}));
-
-vi.mock("@/actions/providers", () => providerActionMocks);
-
 function loadMessages() {
   return {
     common: commonMessages,
@@ -160,45 +153,4 @@ describe("AllowedModelRuleEditor", () => {
 
     unmount();
   });
-
-  test("quick add imports upstream models as exact rules", async () => {
-    const messages = loadMessages();
-    const onChange = vi.fn();
-
-    providerActionMocks.fetchUpstreamModels.mockResolvedValue({
-      ok: true,
-      data: { models: ["claude-opus-4-1", "claude-sonnet-4-1"] },
-    });
-
-    const { unmount } = render(
-      <NextIntlClientProvider locale="en" messages={messages} timeZone="UTC">
-        <AllowedModelRuleEditor
-          value={[]}
-          onChange={onChange}
-          providerType="claude"
-          providerUrl="https://api.example.com"
-          apiKey="sk-test"
-        />
-      </NextIntlClientProvider>
-    );
-
-    const quickAddButton = document.querySelector(
-      "[data-allowed-model-quick-add]"
-    ) as HTMLButtonElement | null;
-    expect(quickAddButton).toBeTruthy();
-
-    await act(async () => {
-      quickAddButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
-    });
-
-    await flushTicks(3);
-
-    expect(providerActionMocks.fetchUpstreamModels).toHaveBeenCalled();
-    expect(onChange).toHaveBeenCalledWith([
-      { matchType: "exact", pattern: "claude-opus-4-1" },
-      { matchType: "exact", pattern: "claude-sonnet-4-1" },
-    ]);
-
-    unmount();
-  });
 });

+ 0 - 222
tests/unit/settings/providers/model-multi-select-custom-models-ui.test.tsx

@@ -1,222 +0,0 @@
-/**
- * @vitest-environment happy-dom
- */
-
-import type { ReactNode } from "react";
-import { act, useState } from "react";
-import { createRoot } from "react-dom/client";
-import { NextIntlClientProvider } from "next-intl";
-import { beforeEach, describe, expect, test, vi } from "vitest";
-import { ModelMultiSelect } from "@/app/[locale]/settings/providers/_components/model-multi-select";
-import commonMessages from "../../../../messages/en/common.json";
-import errorsMessages from "../../../../messages/en/errors.json";
-import formsMessages from "../../../../messages/en/forms.json";
-import settingsMessages from "../../../../messages/en/settings";
-import uiMessages from "../../../../messages/en/ui.json";
-
-const modelPricesActionMocks = vi.hoisted(() => ({
-  getAvailableModelsByProviderType: vi.fn(async () => ["remote-model-1"]),
-}));
-vi.mock("@/actions/model-prices", () => modelPricesActionMocks);
-
-const providersActionMocks = vi.hoisted(() => ({
-  fetchUpstreamModels: vi.fn(async () => ({ ok: false })),
-  getUnmaskedProviderKey: vi.fn(async () => ({ ok: false })),
-}));
-vi.mock("@/actions/providers", () => providersActionMocks);
-
-function loadMessages() {
-  return {
-    common: commonMessages,
-    errors: errorsMessages,
-    ui: uiMessages,
-    forms: formsMessages,
-    settings: settingsMessages,
-  };
-}
-
-function render(node: ReactNode) {
-  const container = document.createElement("div");
-  document.body.appendChild(container);
-  const root = createRoot(container);
-
-  act(() => {
-    root.render(node);
-  });
-
-  return {
-    unmount: () => {
-      act(() => root.unmount());
-      container.remove();
-    },
-  };
-}
-
-async function flushTicks(times = 3) {
-  for (let i = 0; i < times; i++) {
-    await act(async () => {
-      await new Promise((r) => setTimeout(r, 0));
-    });
-  }
-}
-
-describe("ModelMultiSelect: 自定义白名单模型应可在列表中取消选中", () => {
-  beforeEach(() => {
-    vi.clearAllMocks();
-  });
-
-  test("已选中但不在 availableModels 的模型应出现在列表中,并可取消选中删除", async () => {
-    const messages = loadMessages();
-    const onChange = vi.fn();
-
-    const { unmount } = render(
-      <NextIntlClientProvider locale="en" messages={messages} timeZone="UTC">
-        <ModelMultiSelect
-          providerType="claude"
-          selectedModels={["custom-model-x"]}
-          onChange={onChange}
-        />
-      </NextIntlClientProvider>
-    );
-
-    await flushTicks(5);
-    expect(modelPricesActionMocks.getAvailableModelsByProviderType).toHaveBeenCalledTimes(1);
-
-    const trigger = document.querySelector("button[role='combobox']") as HTMLButtonElement | null;
-    expect(trigger).toBeTruthy();
-
-    await act(async () => {
-      trigger?.dispatchEvent(new MouseEvent("mousedown", { bubbles: true }));
-      trigger?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
-    });
-
-    await flushTicks(5);
-
-    // 回归点:custom-model-x 不在 availableModels 时仍应可见,否则用户无法单个删除
-    expect(document.body.textContent || "").toContain("custom-model-x");
-
-    const items = Array.from(document.querySelectorAll("[data-slot='command-item']"));
-    const customItem =
-      items.find((el) => (el.textContent || "").includes("custom-model-x")) ?? null;
-    expect(customItem).toBeTruthy();
-
-    await act(async () => {
-      customItem?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
-    });
-
-    expect(onChange).toHaveBeenCalled();
-    expect(onChange).toHaveBeenLastCalledWith([]);
-
-    unmount();
-  });
-
-  test("无需展开下拉框也应显示完整已选白名单,并支持直接删除与编辑", async () => {
-    const messages = loadMessages();
-
-    function StatefulHarness() {
-      const [selectedModels, setSelectedModels] = useState([
-        "custom-model-x",
-        "claude-opus-4-5-20251001",
-      ]);
-
-      return (
-        <NextIntlClientProvider locale="en" messages={messages} timeZone="UTC">
-          <ModelMultiSelect
-            providerType="claude"
-            selectedModels={selectedModels}
-            onChange={setSelectedModels}
-          />
-        </NextIntlClientProvider>
-      );
-    }
-
-    const { unmount } = render(<StatefulHarness />);
-
-    await flushTicks(5);
-
-    expect(document.body.textContent || "").toContain("custom-model-x");
-    expect(document.body.textContent || "").toContain("claude-opus-4-5-20251001");
-
-    const removeButton = document.querySelector(
-      '[data-model-remove="custom-model-x"]'
-    ) as HTMLButtonElement | null;
-    expect(removeButton).toBeTruthy();
-
-    await act(async () => {
-      removeButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
-    });
-
-    await flushTicks(2);
-    expect(document.body.textContent || "").not.toContain("custom-model-x");
-    expect(document.body.textContent || "").toContain("claude-opus-4-5-20251001");
-
-    const editButton = document.querySelector(
-      '[data-model-edit="claude-opus-4-5-20251001"]'
-    ) as HTMLButtonElement | null;
-    expect(editButton).toBeTruthy();
-
-    await act(async () => {
-      editButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
-    });
-
-    const editInput = document.querySelector(
-      '[data-model-edit-input="claude-opus-4-5-20251001"]'
-    ) as HTMLInputElement | null;
-    expect(editInput).toBeTruthy();
-
-    await act(async () => {
-      if (editInput) {
-        editInput.value = "claude-opus-4-6-latest";
-        editInput.dispatchEvent(new Event("input", { bubbles: true }));
-        editInput.dispatchEvent(new Event("change", { bubbles: true }));
-      }
-    });
-
-    const saveButton = document.querySelector(
-      '[data-model-edit-save="claude-opus-4-5-20251001"]'
-    ) as HTMLButtonElement | null;
-    expect(saveButton).toBeTruthy();
-
-    await act(async () => {
-      saveButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
-    });
-
-    await flushTicks(2);
-    expect(document.body.textContent || "").toContain("claude-opus-4-6-latest");
-    expect(document.body.textContent || "").not.toContain("claude-opus-4-5-20251001");
-
-    unmount();
-  });
-
-  test("下拉框应把已选模型单独置顶显示", async () => {
-    const messages = loadMessages();
-
-    const { unmount } = render(
-      <NextIntlClientProvider locale="en" messages={messages} timeZone="UTC">
-        <ModelMultiSelect
-          providerType="claude"
-          selectedModels={["custom-model-x"]}
-          onChange={vi.fn()}
-        />
-      </NextIntlClientProvider>
-    );
-
-    await flushTicks(5);
-
-    const trigger = document.querySelector("button[role='combobox']") as HTMLButtonElement | null;
-    expect(trigger).toBeTruthy();
-
-    await act(async () => {
-      trigger?.dispatchEvent(new MouseEvent("mousedown", { bubbles: true }));
-      trigger?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
-    });
-
-    await flushTicks(5);
-
-    expect(document.body.textContent || "").toContain("Selected Models");
-    expect(document.querySelector('[data-model-group="selected"]')).toBeTruthy();
-    expect(document.querySelector('[data-model-group="available"]')).toBeTruthy();
-
-    unmount();
-  });
-});