(
+ '[aria-pressed="true"]',
+ value,
+ );
return (
+
{items.map((item) => {
const pressed = item.value === value;
const Icon = item.icon;
@@ -58,12 +70,12 @@ export function SegmentedControl({
disabled={item.disabled}
onClick={() => onValueChange(item.value)}
className={cn(
- "inline-flex items-center justify-center gap-1.5 whitespace-nowrap font-medium text-fg-2 transition-[background-color,color,box-shadow] duration-150 hover:text-fg-1 disabled:cursor-not-allowed disabled:opacity-45",
+ "relative inline-flex items-center justify-center gap-1.5 whitespace-nowrap font-medium text-fg-2 transition-colors duration-150 hover:text-fg-1 disabled:cursor-not-allowed disabled:opacity-45",
size === "md"
? "rounded-[7px] px-3.5 text-body"
: "rounded-[5px] px-2.5 text-caption",
item.className,
- pressed && "bg-surface text-fg-1 shadow-v7-sm",
+ pressed && "text-fg-1",
)}
>
{Icon ? (
diff --git a/src/components/ui/select.tsx b/src/components/ui/select.tsx
index 1d781a09d..a8c0164ad 100644
--- a/src/components/ui/select.tsx
+++ b/src/components/ui/select.tsx
@@ -2,6 +2,7 @@ import * as React from "react";
import * as SelectPrimitive from "@radix-ui/react-select";
import { Check, ChevronDown, ChevronUp } from "lucide-react";
import { cn } from "@/lib/utils";
+import { fieldClass } from "./input";
const Select = SelectPrimitive.Root;
@@ -16,7 +17,9 @@ const SelectTrigger = React.forwardRef<
span]:truncate",
className,
)}
{...props}
diff --git a/src/components/ui/sliding-indicator.tsx b/src/components/ui/sliding-indicator.tsx
new file mode 100644
index 000000000..afa0b6fa9
--- /dev/null
+++ b/src/components/ui/sliding-indicator.tsx
@@ -0,0 +1,111 @@
+import * as React from "react";
+import { cn } from "@/lib/utils";
+
+interface IndicatorRect {
+ x: number;
+ y: number;
+ width: number;
+ height: number;
+}
+
+/**
+ * 分段控件 / 页签的滑块:量出选中项(selector 命中的子元素)相对容器的位置,
+ * 让一块绝对定位的指示条从上一项滑过去。容器要 position: relative。
+ * 第一次量完之前不加过渡,免得打开页面时从左上角飞进来;窗口、文字变化时用 ResizeObserver 重量。
+ */
+export function useSlidingIndicator(
+ selector: string,
+ value: unknown,
+) {
+ const ref = React.useRef(null);
+ const [rect, setRect] = React.useState(null);
+ const [animate, setAnimate] = React.useState(false);
+
+ React.useLayoutEffect(() => {
+ const container = ref.current;
+ if (!container) return;
+ const measure = () => {
+ const target = container.querySelector(selector);
+ setRect(
+ target
+ ? {
+ x: target.offsetLeft,
+ y: target.offsetTop,
+ width: target.offsetWidth,
+ height: target.offsetHeight,
+ }
+ : null,
+ );
+ };
+ measure();
+ if (typeof ResizeObserver === "undefined") return;
+ const observer = new ResizeObserver(measure);
+ observer.observe(container);
+ for (const child of Array.from(container.children)) observer.observe(child);
+ return () => observer.disconnect();
+ }, [selector, value]);
+
+ React.useEffect(() => {
+ if (!rect || animate) return;
+ const frame = requestAnimationFrame(() => setAnimate(true));
+ return () => cancelAnimationFrame(frame);
+ }, [rect, animate]);
+
+ return { ref, rect, animate };
+}
+
+const MOTION =
+ "transition-[transform,width,height] duration-200 ease-out motion-reduce:transition-none";
+
+/** 分段控件的白底凸起块,垫在选中项下面。按钮自己要 relative + 不画底色。 */
+export function SegmentThumb({
+ rect,
+ animate,
+ className,
+}: {
+ rect: IndicatorRect | null;
+ animate: boolean;
+ /** 圆角等,和选中项一致 */
+ className?: string;
+}) {
+ if (!rect) return null;
+ return (
+
+ );
+}
+
+/** 页签的下划线:贴着容器底边,只滑横向位置和宽度。 */
+export function TabUnderline({
+ rect,
+ animate,
+ className,
+}: {
+ rect: IndicatorRect | null;
+ animate: boolean;
+ className?: string;
+}) {
+ if (!rect) return null;
+ return (
+
+ );
+}
diff --git a/src/components/ui/sonner.tsx b/src/components/ui/sonner.tsx
index c2d1fa2fe..228bdb94e 100644
--- a/src/components/ui/sonner.tsx
+++ b/src/components/ui/sonner.tsx
@@ -14,18 +14,30 @@ export function Toaster() {
theme={sonnerTheme}
toastOptions={{
duration: 2000,
- // v7:反色的中性条,不跟主题色走;成功 / 失败靠图标和文字区分
+ // unstyled:sonner 自带样式的选择器比 Tailwind 类更具体,会盖掉这里的底色和关闭按钮位置,
+ // 所以整条 toast 都自己画(位置、堆叠、滑入滑出仍由 sonner 负责)。
+ // 别给 toast 加 position 类:sonner 靠 absolute + transform 堆叠,改成 relative 会乱飞。
+ unstyled: true,
classNames: {
toast:
- "group flex items-center gap-2 rounded-panel border-0 bg-inverse px-3.5 py-2.5 text-body text-inverse-fg shadow-v7-lg",
+ "group flex w-full items-center gap-2.5 rounded-panel border border-border bg-surface py-2.5 pe-3.5 ps-3 text-body text-fg-1 shadow-v7-lg",
+ content: "flex min-w-0 flex-1 flex-col gap-0.5",
title: "text-body font-medium",
- description: "text-caption opacity-80",
+ description: "text-caption text-fg-2",
+ // 状态靠图标颜色区分:成功绿、失败红、警告琥珀、提示灰
+ icon: "flex h-4 w-4 shrink-0 items-center justify-center [&>svg]:h-4 [&>svg]:w-4",
+ success: "[&_[data-icon]]:text-success",
+ error: "[&_[data-icon]]:text-danger",
+ warning: "[&_[data-icon]]:text-warning",
+ info: "[&_[data-icon]]:text-fg-2",
+ loading: "[&_[data-icon]]:text-fg-2",
+ // 关闭按钮在右上角,压在角上(RTL 时到左上角)
closeButton:
- "!border-0 !bg-inverse !text-inverse-fg hover:!bg-inverse-hover",
+ "absolute -end-2 -top-2 flex h-5 w-5 items-center justify-center rounded-full border border-border bg-surface text-fg-2 shadow-v7-sm transition-colors hover:text-fg-1 [&>svg]:h-3 [&>svg]:w-3",
actionButton:
- "!rounded-control !bg-transparent !px-2 !text-caption !font-medium !text-inverse-fg underline underline-offset-2 hover:!bg-inverse-hover",
+ "h-6 shrink-0 rounded-control px-2 text-caption font-medium text-action-text transition-colors hover:bg-subtle",
cancelButton:
- "!rounded-control !bg-transparent !px-2 !text-caption !text-inverse-fg opacity-80",
+ "h-6 shrink-0 rounded-control px-2 text-caption text-fg-2 transition-colors hover:bg-subtle",
},
}}
/>
diff --git a/src/components/ui/textarea.tsx b/src/components/ui/textarea.tsx
index bdff40f91..b57dc3113 100644
--- a/src/components/ui/textarea.tsx
+++ b/src/components/ui/textarea.tsx
@@ -1,5 +1,6 @@
import * as React from "react";
import { cn } from "@/lib/utils";
+import { fieldClass } from "./input";
export type TextareaProps = React.TextareaHTMLAttributes;
@@ -7,10 +8,7 @@ const Textarea = React.forwardRef(
({ className, ...props }, ref) => {
return (
{/* 操作按钮 */}
-
-
diff --git a/src/components/universal/UniversalProviderFormModal.tsx b/src/components/universal/UniversalProviderFormModal.tsx
index 65533f697..4feb355c8 100644
--- a/src/components/universal/UniversalProviderFormModal.tsx
+++ b/src/components/universal/UniversalProviderFormModal.tsx
@@ -371,7 +371,7 @@ requires_openai_auth = true`;
onClick={() => handlePresetSelect(preset)}
className={`inline-flex items-center gap-2 rounded-lg px-4 py-2 text-sm font-medium transition-colors ${
selectedPreset?.providerType === preset.providerType
- ? "bg-primary text-primary-foreground"
+ ? "bg-surface text-fg-1 shadow-v7-sm ring-1 ring-border-strong"
: "bg-subtle text-fg-2 hover:bg-subtle"
}`}
>
diff --git a/src/components/universal/UniversalProviderPanel.tsx b/src/components/universal/UniversalProviderPanel.tsx
index be9b7a914..4a46cf50b 100644
--- a/src/components/universal/UniversalProviderPanel.tsx
+++ b/src/components/universal/UniversalProviderPanel.tsx
@@ -1,7 +1,7 @@
import { useState, useCallback, useEffect } from "react";
import { useTranslation } from "react-i18next";
import { Layers } from "lucide-react";
-import { toast } from "sonner";
+import { toast } from "@/lib/toast";
import { ConfirmDialog } from "@/components/ConfirmDialog";
import { UniversalProviderCard } from "./UniversalProviderCard";
import { UniversalProviderFormModal } from "./UniversalProviderFormModal";
diff --git a/src/components/usage/ModelsDevAutoSyncPanel.tsx b/src/components/usage/ModelsDevAutoSyncPanel.tsx
index a4ba9e769..ab26836a2 100644
--- a/src/components/usage/ModelsDevAutoSyncPanel.tsx
+++ b/src/components/usage/ModelsDevAutoSyncPanel.tsx
@@ -1,7 +1,7 @@
import { useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { useQuery, useQueryClient } from "@tanstack/react-query";
-import { toast } from "sonner";
+import { toast } from "@/lib/toast";
import {
Check,
FolderOpen,
@@ -11,6 +11,7 @@ import {
Search,
} from "lucide-react";
import { Button } from "@/components/ui/button";
+import { HoverTip } from "@/components/ui/hover-tip";
import { ConfirmDialog } from "@/components/ConfirmDialog";
import {
Dialog,
@@ -636,17 +637,18 @@ export function ModelsDevAutoSyncPanel() {
{t("usage.modelsDevAutoSync.syncNow")}
-
-
-
-
-
+
+
+
+
+
+
+
setDialogOpen(true)}
@@ -437,30 +438,38 @@ export function PricingConfigPanel() {
{formatUnitPrice(model.cacheCreationCostPerMillion)}
- {
- setIsAddingNew(false);
- setEditingModel(model);
- }}
- >
-
-
- setDeleteTarget(model)}
- >
-
-
+
+ {
+ setIsAddingNew(false);
+ setEditingModel(model);
+ }}
+ >
+
+
+
+
+ setDeleteTarget(model)}
+ >
+
+
+
|
);
diff --git a/src/components/usage/PricingEditModal.tsx b/src/components/usage/PricingEditModal.tsx
index c93d19d58..1e928cd14 100644
--- a/src/components/usage/PricingEditModal.tsx
+++ b/src/components/usage/PricingEditModal.tsx
@@ -1,6 +1,6 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
-import { toast } from "sonner";
+import { toast } from "@/lib/toast";
import { Save, Plus, Globe } from "lucide-react";
import { FullScreenPanel } from "@/components/common/FullScreenPanel";
import { Button } from "@/components/ui/button";
diff --git a/src/components/usage/RequestLogTable.tsx b/src/components/usage/RequestLogTable.tsx
index b8825e401..24ed759c8 100644
--- a/src/components/usage/RequestLogTable.tsx
+++ b/src/components/usage/RequestLogTable.tsx
@@ -3,6 +3,7 @@ import { useTranslation } from "react-i18next";
import { ChevronLeft, ChevronRight } from "lucide-react";
import { useRequestLogs } from "@/lib/query/usage";
import { HelpTip } from "@/components/ui/help-tip";
+import { HoverTip } from "@/components/ui/hover-tip";
import { AppGlyph, APP_DISPLAY_NAME } from "@/components/shell/AppGlyph";
import type { AppId } from "@/lib/api";
import {
@@ -368,22 +369,23 @@ export function RequestLogTable({
{t("usage.totalRecords", { total })}
- setPage((p) => Math.max(0, p - 1))}
- >
-
-
+
+ setPage((p) => Math.max(0, p - 1))}
+ >
+
+
+
setPageDraft(event.target.value)}
onFocus={(event) => event.target.select()}
@@ -395,16 +397,17 @@ export function RequestLogTable({
/>
/ {fmtInt(totalPages, locale)}
- = totalPages - 1}
- onClick={() => setPage((p) => Math.min(totalPages - 1, p + 1))}
- >
-
-
+
+ = totalPages - 1}
+ onClick={() => setPage((p) => Math.min(totalPages - 1, p + 1))}
+ >
+
+
+