Ver Fonte

feat(providers): show vendor endpoints in list rows

ding113 há 2 semanas atrás
pai
commit
a06f74b164

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

@@ -1,5 +1,5 @@
 "use client";
 "use client";
-import { useQueryClient } from "@tanstack/react-query";
+import { useQuery, useQueryClient } from "@tanstack/react-query";
 import {
 import {
   AlertTriangle,
   AlertTriangle,
   CheckCircle,
   CheckCircle,
@@ -15,6 +15,7 @@ import { useRouter } from "next/navigation";
 import { useTranslations } from "next-intl";
 import { useTranslations } from "next-intl";
 import { useEffect, useState, useTransition } from "react";
 import { useEffect, useState, useTransition } from "react";
 import { toast } from "sonner";
 import { toast } from "sonner";
+import { getProviderVendors } from "@/actions/provider-endpoints";
 import {
 import {
   editProvider,
   editProvider,
   getUnmaskedProviderKey,
   getUnmaskedProviderKey,
@@ -55,6 +56,7 @@ import type { ProviderDisplay, ProviderStatistics } from "@/types/provider";
 import type { User } from "@/types/user";
 import type { User } from "@/types/user";
 import { ProviderForm } from "./forms/provider-form";
 import { ProviderForm } from "./forms/provider-form";
 import { InlineEditPopover } from "./inline-edit-popover";
 import { InlineEditPopover } from "./inline-edit-popover";
+import { ProviderEndpointHover } from "./provider-endpoint-hover";
 
 
 interface ProviderRichListItemProps {
 interface ProviderRichListItemProps {
   provider: ProviderDisplay;
   provider: ProviderDisplay;
@@ -95,6 +97,12 @@ export function ProviderRichListItem({
 }: ProviderRichListItemProps) {
 }: ProviderRichListItemProps) {
   const router = useRouter();
   const router = useRouter();
   const queryClient = useQueryClient();
   const queryClient = useQueryClient();
+  const { data: vendors = [] } = useQuery({
+    queryKey: ["provider-vendors"],
+    queryFn: async () => await getProviderVendors(),
+    staleTime: 60000,
+  });
+
   const [openEdit, setOpenEdit] = useState(false);
   const [openEdit, setOpenEdit] = useState(false);
   const [openClone, setOpenClone] = useState(false);
   const [openClone, setOpenClone] = useState(false);
   const [showKeyDialog, setShowKeyDialog] = useState(false);
   const [showKeyDialog, setShowKeyDialog] = useState(false);
@@ -147,6 +155,10 @@ export function ProviderRichListItem({
   const typeLabel = tTypes(`${typeKey}.label`);
   const typeLabel = tTypes(`${typeKey}.label`);
   const typeDescription = tTypes(`${typeKey}.description`);
   const typeDescription = tTypes(`${typeKey}.description`);
 
 
+  const vendor = provider.providerVendorId
+    ? vendors.find((v) => v.id === provider.providerVendorId)
+    : undefined;
+
   useEffect(() => {
   useEffect(() => {
     setClipboardAvailable(isClipboardSupported());
     setClipboardAvailable(isClipboardSupported());
   }, []);
   }, []);
@@ -445,8 +457,17 @@ export function ProviderRichListItem({
           </div>
           </div>
 
 
           <div className="flex items-center gap-3 mt-1 text-sm text-muted-foreground flex-wrap">
           <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>
+            {/* Vendor & Endpoints OR Legacy URL */}
+            {vendor ? (
+              <div className="flex items-center gap-2">
+                <span className="truncate max-w-[300px] font-medium text-foreground/80">
+                  {vendor.displayName || vendor.websiteDomain}
+                </span>
+                <ProviderEndpointHover vendorId={vendor.id} providerType={provider.providerType} />
+              </div>
+            ) : (
+              <span className="truncate max-w-[300px]">{provider.url}</span>
+            )}
 
 
             {/* 官网链接 */}
             {/* 官网链接 */}
             {provider.websiteUrl && (
             {provider.websiteUrl && (

+ 239 - 0
tests/unit/settings/providers/provider-rich-list-item-endpoints.test.tsx

@@ -0,0 +1,239 @@
+/**
+ * @vitest-environment happy-dom
+ */
+
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+import { NextIntlClientProvider } from "next-intl";
+import { type ReactNode, act } from "react";
+import { createRoot } from "react-dom/client";
+import { beforeEach, describe, expect, test, vi } from "vitest";
+import { ProviderRichListItem } from "@/app/[locale]/settings/providers/_components/provider-rich-list-item";
+import type { ProviderDisplay } from "@/types/provider";
+import type { User } from "@/types/user";
+import enMessages from "../../../../messages/en";
+
+// Mock dependencies
+vi.mock("next/navigation", () => ({
+  useRouter: () => ({ refresh: vi.fn() }),
+}));
+
+vi.mock("sonner", () => ({
+  toast: {
+    success: vi.fn(),
+    error: vi.fn(),
+  },
+}));
+
+// Mock actions
+const providerEndpointsActionMocks = vi.hoisted(() => ({
+  getProviderVendors: vi.fn(async () => [
+    {
+      id: 101,
+      displayName: "Anthropic",
+      websiteDomain: "anthropic.com",
+      websiteUrl: "https://anthropic.com",
+      faviconUrl: null,
+      createdAt: "2026-01-01",
+      updatedAt: "2026-01-01",
+    },
+  ]),
+  getProviderEndpointsByVendor: vi.fn(async () => []),
+}));
+vi.mock("@/actions/provider-endpoints", () => providerEndpointsActionMocks);
+
+const providersActionMocks = vi.hoisted(() => ({
+  editProvider: vi.fn(async () => ({ ok: true })),
+  removeProvider: vi.fn(async () => ({ ok: true })),
+  getUnmaskedProviderKey: vi.fn(async () => ({ ok: true, data: { key: "sk-test" } })),
+  resetProviderCircuit: vi.fn(async () => ({ ok: true })),
+  resetProviderTotalUsage: vi.fn(async () => ({ ok: true })),
+}));
+vi.mock("@/actions/providers", () => providersActionMocks);
+
+// Mock tooltip to simplify testing
+vi.mock("@/components/ui/tooltip", () => ({
+  Tooltip: ({ children }: { children: ReactNode }) => <>{children}</>,
+  TooltipTrigger: ({ children }: { children: ReactNode }) => <>{children}</>,
+  TooltipContent: ({ children }: { children: ReactNode }) => <div>{children}</div>,
+  TooltipProvider: ({ children }: { children: ReactNode }) => <>{children}</>,
+}));
+
+// Mock ProviderEndpointHover to avoid complex children rendering if needed,
+// but we want to check if it's rendered.
+// Actually, let's NOT mock it fully, or mock it to render a simple test id.
+vi.mock("@/app/[locale]/settings/providers/_components/provider-endpoint-hover", () => ({
+  ProviderEndpointHover: ({ vendorId }: { vendorId: number }) => (
+    <div data-testid="mock-endpoint-hover">Endpoints for Vendor {vendorId}</div>
+  ),
+}));
+
+const ADMIN_USER: User = {
+  id: 1,
+  name: "admin",
+  description: "",
+  role: "admin",
+  rpm: null,
+  dailyQuota: null,
+  providerGroup: null,
+  tags: [],
+  createdAt: new Date("2026-01-01"),
+  updatedAt: new Date("2026-01-01"),
+  dailyResetMode: "fixed",
+  dailyResetTime: "00:00",
+  isEnabled: true,
+};
+
+function makeProviderDisplay(overrides: Partial<ProviderDisplay> = {}): ProviderDisplay {
+  return {
+    id: 1,
+    name: "Claude 3.5 Sonnet",
+    url: "https://api.anthropic.com",
+    maskedKey: "sk-***",
+    isEnabled: true,
+    weight: 1,
+    priority: 1,
+    costMultiplier: 1,
+    groupTag: null,
+    providerType: "claude",
+    providerVendorId: null, // Default to null for legacy check
+    preserveClientIp: false,
+    modelRedirects: null,
+    allowedModels: null,
+    mcpPassthroughType: "none",
+    mcpPassthroughUrl: null,
+    limit5hUsd: null,
+    limitDailyUsd: null,
+    dailyResetMode: "fixed",
+    dailyResetTime: "00:00",
+    limitWeeklyUsd: null,
+    limitMonthlyUsd: null,
+    limitTotalUsd: null,
+    limitConcurrentSessions: 1,
+    maxRetryAttempts: null,
+    circuitBreakerFailureThreshold: 1,
+    circuitBreakerOpenDuration: 60,
+    circuitBreakerHalfOpenSuccessThreshold: 1,
+    proxyUrl: null,
+    proxyFallbackToDirect: false,
+    firstByteTimeoutStreamingMs: 0,
+    streamingIdleTimeoutMs: 0,
+    requestTimeoutNonStreamingMs: 0,
+    websiteUrl: null,
+    faviconUrl: null,
+    cacheTtlPreference: null,
+    context1mPreference: null,
+    codexReasoningEffortPreference: null,
+    codexReasoningSummaryPreference: null,
+    codexTextVerbosityPreference: null,
+    codexParallelToolCallsPreference: null,
+    anthropicMaxTokensPreference: null,
+    anthropicThinkingBudgetPreference: null,
+    tpm: null,
+    rpm: null,
+    rpd: null,
+    cc: null,
+    createdAt: "2026-01-01",
+    updatedAt: "2026-01-01",
+    ...overrides,
+  };
+}
+
+let queryClient: QueryClient;
+
+function renderWithProviders(node: ReactNode) {
+  const container = document.createElement("div");
+  document.body.appendChild(container);
+  const root = createRoot(container);
+
+  act(() => {
+    root.render(
+      <QueryClientProvider client={queryClient}>
+        <NextIntlClientProvider locale="en" messages={enMessages} timeZone="UTC">
+          {node}
+        </NextIntlClientProvider>
+      </QueryClientProvider>
+    );
+  });
+
+  return {
+    unmount: () => {
+      act(() => root.unmount());
+      container.remove();
+    },
+    container,
+  };
+}
+
+async function flushTicks(times = 3) {
+  for (let i = 0; i < times; i++) {
+    await act(async () => {
+      await new Promise((r) => setTimeout(r, 0));
+    });
+  }
+}
+
+describe("ProviderRichListItem Endpoint Display", () => {
+  beforeEach(() => {
+    queryClient = new QueryClient({
+      defaultOptions: {
+        queries: { retry: false },
+      },
+    });
+    vi.clearAllMocks();
+    while (document.body.firstChild) {
+      document.body.removeChild(document.body.firstChild);
+    }
+  });
+
+  test("renders legacy URL when providerVendorId is null", async () => {
+    const provider = makeProviderDisplay({
+      providerVendorId: null,
+      url: "https://api.legacy.com",
+    });
+
+    const { unmount } = renderWithProviders(
+      <ProviderRichListItem
+        provider={provider}
+        currentUser={ADMIN_USER}
+        enableMultiProviderTypes={true}
+      />
+    );
+
+    await flushTicks(5);
+
+    expect(document.body.textContent).toContain("https://api.legacy.com");
+    expect(document.body.textContent).not.toContain("Anthropic");
+    expect(document.querySelector('[data-testid="mock-endpoint-hover"]')).toBeNull();
+
+    unmount();
+  });
+
+  test("renders vendor name and endpoint hover when providerVendorId exists", async () => {
+    const provider = makeProviderDisplay({
+      providerVendorId: 101,
+      url: "https://api.anthropic.com",
+    });
+
+    const { unmount } = renderWithProviders(
+      <ProviderRichListItem
+        provider={provider}
+        currentUser={ADMIN_USER}
+        enableMultiProviderTypes={true}
+      />
+    );
+
+    await flushTicks(5); // Wait for query to resolve
+
+    // Should show vendor name (mocked as "Anthropic")
+    expect(document.body.textContent).toContain("Anthropic");
+
+    // Should NOT show the raw URL in the main label position (though it might be in tooltip, but here we check main text replacement)
+    // The implementation replaces the URL span with the vendor/hover block
+
+    // Should render the mock endpoint hover
+    expect(document.querySelector('[data-testid="mock-endpoint-hover"]')).not.toBeNull();
+    expect(document.body.textContent).toContain("Endpoints for Vendor 101");
+
+    unmount();
+  });
+});