Merge remote-tracking branch 'origin/main' into feature/managed-oauth-account-selector

# Conflicts:
#	src/components/providers/forms/ProviderForm.tsx
This commit is contained in:
SaladDay
2026-06-23 15:54:43 +00:00
91 changed files with 4342 additions and 626 deletions
@@ -33,6 +33,8 @@ export const TIER_I18N_KEYS: Record<string, string> = {
gemini_flash_lite: "subscription.geminiFlashLite",
// Token Planfive_hour 已在上方官方映射中)
weekly_limit: "subscription.sevenDay",
// 火山方舟 Agent Plan / Coding Plan 的月窗口
monthly: "subscription.monthly",
// GitHub Copilot
premium: "subscription.copilotPremium",
};
+93 -3
View File
@@ -8,6 +8,7 @@ import { usageApi, settingsApi, type AppId } from "@/lib/api";
import { copilotGetUsage, copilotGetUsageForAccount } from "@/lib/api/copilot";
import { useSettingsQuery } from "@/lib/query";
import { resolveManagedAccountId } from "@/lib/authBinding";
import { useDarkMode } from "@/hooks/useDarkMode";
import {
extractCodexBaseUrl,
extractCodexExperimentalBearerToken,
@@ -196,6 +197,7 @@ const UsageScriptModal: React.FC<UsageScriptModalProps> = ({
const queryClient = useQueryClient();
const { data: settingsData } = useSettingsQuery();
const [showUsageConfirm, setShowUsageConfirm] = useState(false);
const isDarkMode = useDarkMode();
// 生成带国际化的预设模板
const PRESET_TEMPLATES = generatePresetTemplates(t);
@@ -538,8 +540,10 @@ const UsageScriptModal: React.FC<UsageScriptModalProps> = ({
// Coding Plan 模板使用专用 API
if (selectedTemplate === TEMPLATE_TYPES.TOKEN_PLAN) {
// ZenMux 使用用户在脚本配置中手动填入的 API Key 和 Base URL
// ZenMux 手填 baseUrl/apiKey;火山是 native 供应商,baseUrl 走推理配置,
// 另用账号 AK/SK 签名查询控制面用量。
const isZenMux = script.codingPlanProvider === "zenmux";
const isVolcengine = script.codingPlanProvider === "volcengine";
const baseUrl = isZenMux
? (script.baseUrl ?? "")
: (providerCredentials.baseUrl ?? "");
@@ -547,7 +551,12 @@ const UsageScriptModal: React.FC<UsageScriptModalProps> = ({
? (script.apiKey ?? "")
: (providerCredentials.apiKey ?? "");
const { subscriptionApi } = await import("@/lib/api/subscription");
const quota = await subscriptionApi.getCodingPlanQuota(baseUrl, apiKey);
const quota = await subscriptionApi.getCodingPlanQuota(
baseUrl,
apiKey,
isVolcengine ? script.accessKeyId : undefined,
isVolcengine ? script.secretAccessKey : undefined,
);
if (quota.success && quota.tiers.length > 0) {
const summary = quota.tiers
.map((tier) => `${tier.name}: ${Math.round(tier.utilization)}%`)
@@ -726,8 +735,9 @@ const UsageScriptModal: React.FC<UsageScriptModalProps> = ({
providerCredentials.baseUrl,
);
const provider = script.codingPlanProvider || autoDetected || "kimi";
// ZenMux 允许手动填写 API Key 和 Base URL,不清除
// ZenMux 保留手填 baseUrl/apiKey;火山保留账号 AK/SK;其余清除
const isZenMux = provider === "zenmux";
const isVolcengine = provider === "volcengine";
setScript({
...script,
code: "",
@@ -735,6 +745,8 @@ const UsageScriptModal: React.FC<UsageScriptModalProps> = ({
baseUrl: isZenMux ? script.baseUrl : undefined,
accessToken: undefined,
userId: undefined,
accessKeyId: isVolcengine ? script.accessKeyId : undefined,
secretAccessKey: isVolcengine ? script.secretAccessKey : undefined,
codingPlanProvider: provider,
});
} else if (presetName === TEMPLATE_TYPES.BALANCE) {
@@ -1246,6 +1258,83 @@ const UsageScriptModal: React.FC<UsageScriptModalProps> = ({
</div>
)}
{/* 火山方舟:控制面用量查询需账号 AK/SK(与推理 Key 是两套凭据) */}
{selectedTemplate === TEMPLATE_TYPES.TOKEN_PLAN &&
script.codingPlanProvider === "volcengine" && (
<div className="space-y-4">
<div>
<h4 className="text-sm font-medium text-foreground">
{t("usageScript.credentialsConfig")}
</h4>
<p className="text-xs text-muted-foreground mt-1 leading-relaxed">
{t("usageScript.volcengineAkSkHint")}
</p>
</div>
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="usage-volcengine-ak">
{t("usageScript.accessKeyId")}
</Label>
<Input
id="usage-volcengine-ak"
type="text"
value={script.accessKeyId || ""}
onChange={(e) =>
setScript({
...script,
accessKeyId: e.target.value,
})
}
placeholder="AKLT..."
autoComplete="off"
className="border-white/10"
/>
</div>
<div className="space-y-2">
<Label htmlFor="usage-volcengine-sk">
{t("usageScript.secretAccessKey")}
</Label>
<div className="relative">
<Input
id="usage-volcengine-sk"
type={showApiKey ? "text" : "password"}
value={script.secretAccessKey || ""}
onChange={(e) =>
setScript({
...script,
secretAccessKey: e.target.value,
})
}
placeholder="••••••••"
autoComplete="off"
className="border-white/10"
/>
{script.secretAccessKey && (
<button
type="button"
onClick={() => setShowApiKey(!showApiKey)}
className="absolute inset-y-0 right-0 flex items-center pr-3 text-muted-foreground hover:text-foreground transition-colors"
aria-label={
showApiKey
? t("apiKeyInput.hide")
: t("apiKeyInput.show")
}
>
{showApiKey ? (
<EyeOff size={16} />
) : (
<Eye size={16} />
)}
</button>
)}
</div>
</div>
</div>
</div>
)}
{/* 通用配置(始终显示) */}
<div className="grid gap-4 md:grid-cols-2 pt-4 border-t border-white/10">
{/* 超时时间 */}
@@ -1333,6 +1422,7 @@ const UsageScriptModal: React.FC<UsageScriptModalProps> = ({
height={480}
language="javascript"
showMinimap={false}
darkMode={isDarkMode}
/>
</div>
)}
+10 -1
View File
@@ -10,6 +10,7 @@ import {
DRAG_REGION_STYLE,
} from "@/lib/platform";
import { isTextEditableTarget } from "@/utils/domUtils";
import { cn } from "@/lib/utils";
interface FullScreenPanelProps {
isOpen: boolean;
@@ -17,6 +18,11 @@ interface FullScreenPanelProps {
onClose: () => void;
children: React.ReactNode;
footer?: React.ReactNode;
/**
* 覆盖内容区滚动容器的内边距/间距类。默认 `px-6 py-6 space-y-6`。
* 通过 `cn`(twMerge) 合并,传入如 `pt-3` 只覆盖顶部内边距,其余保持默认。
*/
contentClassName?: string;
}
const DRAG_BAR_HEIGHT = isWindows() || isLinux() ? 0 : 28; // px - match App.tsx
@@ -33,6 +39,7 @@ export const FullScreenPanel: React.FC<FullScreenPanelProps> = ({
onClose,
children,
footer,
contentClassName,
}) => {
React.useEffect(() => {
if (isOpen) {
@@ -136,7 +143,9 @@ export const FullScreenPanel: React.FC<FullScreenPanelProps> = ({
{/* Content */}
<div className="flex-1 overflow-y-auto scroll-overlay">
<div className="px-6 py-6 space-y-6 w-full">{children}</div>
<div className={cn("px-6 py-6 space-y-6 w-full", contentClassName)}>
{children}
</div>
</div>
{/* Footer */}
@@ -297,6 +297,9 @@ export function AddProviderDialog({
const footer =
!showUniversalTab || activeTab === "app-specific" ? (
<>
<span className="mr-auto min-w-0 text-xs text-muted-foreground truncate">
{t("provider.addFooterHint")}
</span>
<Button
variant="outline"
onClick={closeDialog}
@@ -339,6 +342,7 @@ export function AddProviderDialog({
title={t("provider.addNewProvider")}
onClose={handlePanelClose}
footer={footer}
contentClassName="pt-3"
>
{showUniversalTab ? (
<Tabs
+5 -5
View File
@@ -320,7 +320,7 @@ export function ProviderCard({
)}
/>
<div className="relative flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div className="flex flex-1 items-center gap-2">
<div className="flex min-w-0 flex-1 items-center gap-2">
<button
type="button"
className={cn(
@@ -335,7 +335,7 @@ export function ProviderCard({
<GripVertical className="h-4 w-4" />
</button>
<div className="h-8 w-8 rounded-lg bg-muted flex items-center justify-center border border-border group-hover:scale-105 transition-transform duration-300">
<div className="h-8 w-8 flex-shrink-0 rounded-lg bg-muted flex items-center justify-center border border-border group-hover:scale-105 transition-transform duration-300">
<ProviderIcon
icon={provider.icon}
name={provider.name}
@@ -344,7 +344,7 @@ export function ProviderCard({
/>
</div>
<div className="space-y-1">
<div className="min-w-0 flex-1 space-y-1">
<div className="flex flex-wrap items-center gap-2 min-h-7">
<h3 className="text-base font-semibold leading-none">
{provider.name}
@@ -451,7 +451,7 @@ export function ProviderCard({
type="button"
onClick={handleOpenWebsite}
className={cn(
"inline-flex items-center text-sm max-w-[280px]",
"inline-flex max-w-full items-center overflow-hidden text-left text-sm",
isClickableUrl
? "text-blue-500 transition-colors hover:underline dark:text-blue-400 cursor-pointer"
: "text-muted-foreground cursor-default",
@@ -459,7 +459,7 @@ export function ProviderCard({
title={displayUrl}
disabled={!isClickableUrl}
>
<span className="truncate">{displayUrl}</span>
<span className="min-w-0 truncate">{displayUrl}</span>
</button>
)}
</div>
+8 -2
View File
@@ -45,6 +45,7 @@ import {
import { useCallback } from "react";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { isTextEditableTarget } from "@/utils/domUtils";
interface ProviderListProps {
providers: Record<string, Provider>;
@@ -245,8 +246,13 @@ export function ProviderList({
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (event.defaultPrevented) return;
const key = event.key.toLowerCase();
if ((event.metaKey || event.ctrlKey) && key === "f") {
// 正在输入框/可编辑区域中时不抢占 Ctrl+F(例如添加供应商表单里
// ProviderPresetSelector 的搜索框),避免与其同名快捷键冲突。
if (isTextEditableTarget(document.activeElement)) return;
event.preventDefault();
setIsSearchOpen(true);
return;
@@ -257,8 +263,8 @@ export function ProviderList({
}
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
globalThis.addEventListener("keydown", handleKeyDown);
return () => globalThis.removeEventListener("keydown", handleKeyDown);
}, []);
useEffect(() => {
@@ -10,7 +10,6 @@ import {
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Popover,
@@ -471,6 +470,19 @@ export function OmoFormFields({
const firstIsUnavailable =
Boolean(currentVariant) &&
!(modelVariantsMap[currentModel] || []).includes(currentVariant);
const defaultVariantLabel = t("omo.defaultWrapped", {
defaultValue: "(Default)",
});
const getVariantLabel = (variant: string, index: number) =>
firstIsUnavailable && index === 0
? t("omo.currentValueUnavailable", {
value: variant,
defaultValue: "{{value}} (current value, unavailable)",
})
: variant;
const selectedVariantLabel = currentVariant
? getVariantLabel(currentVariant, 0)
: defaultVariantLabel;
return (
<Select
@@ -479,25 +491,21 @@ export function OmoFormFields({
onChange(value === EMPTY_VARIANT_VALUE ? "" : value)
}
>
<SelectTrigger className="w-28 h-8 text-xs shrink-0">
<SelectValue
placeholder={t("omo.variantPlaceholder", {
defaultValue: "variant",
})}
/>
<SelectTrigger
className="w-28 min-w-0 h-8 overflow-hidden text-xs shrink-0"
title={selectedVariantLabel}
>
<span className="min-w-0 flex-1 truncate text-left">
{selectedVariantLabel}
</span>
</SelectTrigger>
<SelectContent className="max-h-72">
<SelectItem value={EMPTY_VARIANT_VALUE}>
{t("omo.defaultWrapped", { defaultValue: "(Default)" })}
{defaultVariantLabel}
</SelectItem>
{variantOptions.map((variant, index) => (
<SelectItem key={`${variant}-${index}`} value={variant}>
{firstIsUnavailable && index === 0
? t("omo.currentValueUnavailable", {
value: variant,
defaultValue: "{{value}} (current value, unavailable)",
})
: variant}
{getVariantLabel(variant, index)}
</SelectItem>
))}
</SelectContent>
@@ -14,6 +14,7 @@ import {
type AppId,
type ManagedAuthProvider,
} from "@/lib/api";
import { useDarkMode } from "@/hooks/useDarkMode";
import type {
ProviderCategory,
ProviderMeta,
@@ -281,6 +282,7 @@ function ProviderFormFull({
const { data: settingsData } = useSettingsQuery();
const showCommonConfigNotice =
settingsData != null && settingsData.commonConfigConfirmed !== true;
const isDarkMode = useDarkMode();
const handleCommonConfigConfirm = async () => {
try {
@@ -2298,6 +2300,7 @@ function ProviderFormFull({
rows={14}
showValidation={false}
language="json"
darkMode={isDarkMode}
/>
</div>
) : appId === "opencode" &&
@@ -2322,6 +2325,7 @@ function ProviderFormFull({
rows={14}
showValidation={true}
language="json"
darkMode={isDarkMode}
/>
</div>
{settingsConfigErrorField}
@@ -2352,6 +2356,7 @@ function ProviderFormFull({
rows={14}
showValidation={true}
language="json"
darkMode={isDarkMode}
/>
</div>
<FormField
@@ -4,7 +4,15 @@ import { FormLabel } from "@/components/ui/form";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { ClaudeIcon, CodexIcon, GeminiIcon } from "@/components/BrandIcons";
import { ArrowUpAZ, Search, Zap, Star, Layers, Settings2 } from "lucide-react";
import {
ArrowUpAZ,
Search,
Zap,
Star,
Heart,
Layers,
Settings2,
} from "lucide-react";
import type { ProviderPreset } from "@/config/claudeProviderPresets";
import type { CodexProviderPreset } from "@/config/codexProviderPresets";
import type { GeminiProviderPreset } from "@/config/geminiProviderPresets";
@@ -80,7 +88,21 @@ export function sortPresetEntries(
t: PresetTranslator,
): PresetEntry[] {
if (sortMode === PresetSortMode.Original) {
return [...entries];
// 置顶优先级:官方分类 > 尊享合作伙伴(Kimi)> 其余原顺序。
// 用分区拼接而非排序,确保每组内部各自的相对顺序都不变;
// 排他条件保证「既是官方又是 prime」的预设只归入官方组、不被重复。
const official = entries.filter(
(entry) => entry.preset.category === "official",
);
const prime = entries.filter(
(entry) =>
entry.preset.category !== "official" && entry.preset.primePartner,
);
const rest = entries.filter(
(entry) =>
entry.preset.category !== "official" && !entry.preset.primePartner,
);
return [...official, ...prime, ...rest];
}
return [...entries].sort((a, b) =>
@@ -123,7 +145,7 @@ export function ProviderPresetSelector({
onUniversalPresetSelect,
onManageUniversalProviders,
category,
}: ProviderPresetSelectorProps) {
}: Readonly<ProviderPresetSelectorProps>) {
const { t } = useTranslation();
const [searchOpen, setSearchOpen] = useState(false);
const [searchQuery, setSearchQuery] = useState("");
@@ -131,6 +153,7 @@ export function ProviderPresetSelector({
PresetSortMode.Original,
);
const searchContainerRef = useRef<HTMLDivElement>(null);
const searchInputRef = useRef<HTMLInputElement>(null);
// 点击搜索区域外时收起并清空,对齐旧 Popover 的「点击外部关闭」行为
useEffect(() => {
@@ -150,6 +173,25 @@ export function ProviderPresetSelector({
return () => document.removeEventListener("mousedown", handleClickOutside);
}, [searchOpen]);
// 键盘快捷键: Ctrl/Cmd+F 打开搜索并聚焦输入框。
// 使用捕获阶段并阻止冒泡,避免背后 ProviderList 的同名快捷键被意外触发。
// 首次打开靠 Input 的 autoFocus 聚焦;若搜索已打开(例如点击 preset 后焦点
// 停在按钮上),setSearchOpen(true) 同值不会重渲染、autoFocus 不重触发,
// 这里用 rAF 命令式地把焦点移回搜索框(不 select,避免吞掉随后输入的首字符)。
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === "f") {
event.preventDefault();
event.stopPropagation();
setSearchOpen(true);
requestAnimationFrame(() => searchInputRef.current?.focus());
}
};
globalThis.addEventListener("keydown", handleKeyDown, true);
return () => globalThis.removeEventListener("keydown", handleKeyDown, true);
}, []);
const visiblePresetEntries = useMemo(
() =>
getVisiblePresetEntries(presetEntries, {
@@ -258,12 +300,13 @@ export function ProviderPresetSelector({
};
return (
<div className="space-y-3">
<div ref={searchContainerRef} className="space-y-3">
<div className="flex items-center justify-between gap-2">
<FormLabel>{t("providerPreset.label")}</FormLabel>
<div ref={searchContainerRef} className="flex items-center gap-2">
<div className="flex items-center gap-2">
{searchOpen && (
<Input
ref={searchInputRef}
value={searchQuery}
onChange={(event) => setSearchQuery(event.target.value)}
onKeyDown={(event) => {
@@ -278,7 +321,7 @@ export function ProviderPresetSelector({
aria-label={t("providerPreset.searchAriaLabel", {
defaultValue: "Search provider presets",
})}
className="w-48 h-8"
className="w-60 h-8"
autoFocus
/>
)}
@@ -359,6 +402,7 @@ export function ProviderPresetSelector({
{visiblePresetEntries.map((entry) => {
const isSelected = selectedPresetId === entry.id;
const isPartner = entry.preset.isPartner;
const isPrimePartner = entry.preset.primePartner;
const presetCategory = entry.preset.category ?? "others";
return (
<button
@@ -376,10 +420,18 @@ export function ProviderPresetSelector({
<span className="truncate">
{getPresetDisplayName(entry.preset, t)}
</span>
{isPartner && (
<span className="absolute -top-1 -right-1 flex items-center gap-0.5 rounded-full bg-gradient-to-r from-amber-500 to-yellow-500 px-1.5 py-0.5 text-[10px] font-bold text-white shadow-md">
<Star className="h-2.5 w-2.5 fill-current" />
</span>
{isPrimePartner ? (
<Heart
className="absolute -top-1 -right-1 h-5 w-5 fill-amber-500 text-amber-500 drop-shadow-sm"
strokeWidth={0}
aria-hidden
/>
) : (
isPartner && (
<span className="absolute -top-1 -right-1 flex items-center gap-0.5 rounded-full bg-gradient-to-r from-amber-500 to-yellow-500 px-1.5 py-0.5 text-[10px] font-bold text-white shadow-md">
<Star className="h-2.5 w-2.5 fill-current" />
</span>
)
)}
</button>
);
@@ -387,50 +439,47 @@ export function ProviderPresetSelector({
</div>
{onUniversalPresetSelect && universalProviderPresets.length > 0 && (
<>
<div className="grid grid-cols-[repeat(auto-fill,minmax(150px,1fr))] gap-2">
{universalProviderPresets.map((preset) => (
<button
key={`universal-${preset.providerType}`}
type="button"
onClick={() => onUniversalPresetSelect(preset)}
className="inline-flex items-center justify-start gap-2 px-3 py-2 rounded-lg text-sm font-medium transition-colors bg-accent text-muted-foreground hover:bg-accent/80 relative w-full"
title={t("universalProvider.hint", {
defaultValue:
"跨应用统一配置,自动同步到 Claude/Codex/Gemini",
<div className="grid grid-cols-[repeat(auto-fill,minmax(150px,1fr))] gap-2">
{universalProviderPresets.map((preset) => (
<button
key={`universal-${preset.providerType}`}
type="button"
onClick={() => onUniversalPresetSelect(preset)}
className="inline-flex items-center justify-start gap-2 px-3 py-2 rounded-lg text-sm font-medium transition-colors bg-accent text-muted-foreground hover:bg-accent/80 relative w-full"
title={t("universalProvider.hint", {
defaultValue: "跨应用统一配置,自动同步到 Claude/Codex/Gemini",
})}
>
<ProviderIcon
icon={preset.icon}
name={preset.name}
size={14}
className="flex-shrink-0"
/>
<span className="truncate">{preset.name}</span>
<span className="absolute -top-1 -right-1 flex items-center gap-0.5 rounded-full bg-gradient-to-r from-indigo-500 to-purple-500 px-1.5 py-0.5 text-[10px] font-bold text-white shadow-md">
<Layers className="h-2.5 w-2.5" />
</span>
</button>
))}
{onManageUniversalProviders && (
<button
type="button"
onClick={onManageUniversalProviders}
className="inline-flex items-center justify-start gap-2 px-3 py-2 rounded-lg text-sm font-medium transition-colors bg-accent text-muted-foreground hover:bg-accent/80 w-full"
title={t("universalProvider.manage", {
defaultValue: "管理统一供应商",
})}
>
<Settings2 className="h-4 w-4 flex-shrink-0" />
<span className="truncate">
{t("universalProvider.manage", {
defaultValue: "管理",
})}
>
<ProviderIcon
icon={preset.icon}
name={preset.name}
size={14}
className="flex-shrink-0"
/>
<span className="truncate">{preset.name}</span>
<span className="absolute -top-1 -right-1 flex items-center gap-0.5 rounded-full bg-gradient-to-r from-indigo-500 to-purple-500 px-1.5 py-0.5 text-[10px] font-bold text-white shadow-md">
<Layers className="h-2.5 w-2.5" />
</span>
</button>
))}
{onManageUniversalProviders && (
<button
type="button"
onClick={onManageUniversalProviders}
className="inline-flex items-center justify-start gap-2 px-3 py-2 rounded-lg text-sm font-medium transition-colors bg-accent text-muted-foreground hover:bg-accent/80 w-full"
title={t("universalProvider.manage", {
defaultValue: "管理统一供应商",
})}
>
<Settings2 className="h-4 w-4 flex-shrink-0" />
<span className="truncate">
{t("universalProvider.manage", {
defaultValue: "管理",
})}
</span>
</button>
)}
</div>
</>
</span>
</button>
)}
</div>
)}
<p className="text-xs text-muted-foreground">{getCategoryHint()}</p>
@@ -13,6 +13,7 @@ import {
MessageSquare,
Clock,
FolderOpen,
FileText,
X,
CheckSquare,
} from "lucide-react";
@@ -897,6 +898,38 @@ export function SessionManagerPage({ appId }: { appId: string }) {
</TooltipContent>
</Tooltip>
)}
{selectedSession.sourcePath && (
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
onClick={() =>
void handleCopy(
selectedSession.sourcePath!,
t("sessionManager.sourcePathCopied"),
)
}
className="flex items-center gap-1 hover:text-foreground transition-colors"
>
<FileText className="size-3 shrink-0" />
<span className="font-mono truncate max-w-[200px]">
{getBaseName(selectedSession.sourcePath)}
</span>
</button>
</TooltipTrigger>
<TooltipContent
side="bottom"
className="max-w-xs"
>
<p className="font-mono text-xs break-all">
{selectedSession.sourcePath}
</p>
<p className="text-muted-foreground mt-1">
{t("sessionManager.clickToCopyPath")}
</p>
</TooltipContent>
</Tooltip>
)}
</div>
</div>
+19 -2
View File
@@ -1,4 +1,11 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import {
useCallback,
useEffect,
useLayoutEffect,
useMemo,
useRef,
useState,
} from "react";
import { motion } from "framer-motion";
import {
Loader2,
@@ -102,6 +109,7 @@ export function SettingsPage({
const [activeTab, setActiveTab] = useState<string>("general");
const [showRestartPrompt, setShowRestartPrompt] = useState(false);
const tabScrollContainerRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (open) {
@@ -116,6 +124,12 @@ export function SettingsPage({
}
}, [requiresRestart]);
useLayoutEffect(() => {
if (tabScrollContainerRef.current) {
tabScrollContainerRef.current.scrollTop = 0;
}
}, [activeTab]);
const closeAfterSave = useCallback(() => {
// 保存成功后关闭:不再重置语言,避免需要“保存两次”才生效
acknowledgeRestart();
@@ -226,7 +240,10 @@ export function SettingsPage({
</TabsList>
<div className="flex-1 min-h-0 flex flex-col">
<div className="flex-1 overflow-y-auto overflow-x-hidden pr-2">
<div
ref={tabScrollContainerRef}
className="flex-1 overflow-y-auto overflow-x-hidden pr-2"
>
<TabsContent value="general" className="space-y-6 mt-0">
{settings ? (
<motion.div
+55 -10
View File
@@ -15,7 +15,13 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { RefreshCw, Search, Loader2 } from "lucide-react";
import {
RefreshCw,
Search,
Loader2,
Settings,
type LucideIcon,
} from "lucide-react";
import { toast } from "sonner";
import { SkillCard } from "./SkillCard";
import { RepoManagerPanel } from "./RepoManagerPanel";
@@ -36,8 +42,11 @@ import type {
} from "@/lib/api/skills";
import { formatSkillError } from "@/lib/errors/skillErrorParser";
export type SkillsPageSource = "repos" | "skillssh";
interface SkillsPageProps {
initialApp?: AppId;
onSourceChange?: (source: SkillsPageSource) => void;
}
export interface SkillsPageHandle {
@@ -45,7 +54,35 @@ export interface SkillsPageHandle {
openRepoManager: () => void;
}
type SearchSource = "repos" | "skillssh";
type SkillsPageHeaderAction = {
key: string;
sources: readonly SkillsPageSource[];
labelKey: string;
Icon: LucideIcon;
execute: (page: SkillsPageHandle | null) => void;
};
const SKILLS_PAGE_HEADER_ACTIONS: readonly SkillsPageHeaderAction[] = [
{
key: "refresh-repos",
sources: ["repos"],
labelKey: "skills.refresh",
Icon: RefreshCw,
execute: (page) => page?.refresh(),
},
{
key: "manage-repos",
sources: ["repos", "skillssh"],
labelKey: "skills.repoManager",
Icon: Settings,
execute: (page) => page?.openRepoManager(),
},
];
export const getSkillsPageHeaderActions = (source: SkillsPageSource) =>
SKILLS_PAGE_HEADER_ACTIONS.filter((action) =>
action.sources.includes(source),
);
const SKILLSSH_PAGE_SIZE = 20;
@@ -54,7 +91,7 @@ const SKILLSSH_PAGE_SIZE = 20;
* 用于浏览和安装来自仓库或 skills.sh 的 Skills
*/
export const SkillsPage = forwardRef<SkillsPageHandle, SkillsPageProps>(
({ initialApp = "claude" }, ref) => {
({ initialApp = "claude", onSourceChange }, ref) => {
const { t } = useTranslation();
const [repoManagerOpen, setRepoManagerOpen] = useState(false);
const [searchQuery, setSearchQuery] = useState("");
@@ -64,7 +101,7 @@ export const SkillsPage = forwardRef<SkillsPageHandle, SkillsPageProps>(
>("all");
// skills.sh 搜索状态
const [searchSource, setSearchSource] = useState<SearchSource>("repos");
const [searchSource, setSearchSource] = useState<SkillsPageSource>("repos");
const [skillsShInput, setSkillsShInput] = useState("");
const [skillsShQuery, setSkillsShQuery] = useState("");
const [skillsShOffset, setSkillsShOffset] = useState(0);
@@ -90,23 +127,25 @@ export const SkillsPage = forwardRef<SkillsPageHandle, SkillsPageProps>(
data: skillsShResult,
isLoading: loadingSkillsSh,
isFetching: fetchingSkillsSh,
isPlaceholderData: placeholderSkillsSh,
} = useSearchSkillsSh(skillsShQuery, SKILLSSH_PAGE_SIZE, skillsShOffset);
// 当搜索结果返回时累积
useEffect(() => {
if (skillsShResult) {
if (skillsShResult && !placeholderSkillsSh) {
if (skillsShOffset === 0) {
setAccumulatedResults(skillsShResult.skills);
} else {
setAccumulatedResults((prev) => [...prev, ...skillsShResult.skills]);
}
}
}, [skillsShResult, skillsShOffset]);
}, [skillsShResult, skillsShOffset, placeholderSkillsSh]);
// 手动提交搜索
const handleSkillsShSearch = () => {
const trimmed = skillsShInput.trim();
if (trimmed.length < 2) return;
if (trimmed === skillsShQuery && skillsShOffset === 0) return;
setSkillsShOffset(0);
setAccumulatedResults([]);
setSkillsShQuery(trimmed);
@@ -314,13 +353,19 @@ export const SkillsPage = forwardRef<SkillsPageHandle, SkillsPageProps>(
// 是否有更多 skills.sh 结果
const hasMoreSkillsSh =
skillsShResult && accumulatedResults.length < skillsShResult.totalCount;
const searchingSkillsSh =
(loadingSkillsSh || fetchingSkillsSh) && accumulatedResults.length === 0;
// 无仓库时默认切换到 skills.sh
// 无仓库配置时默认切换到 skills.sh;仓库发现结果为空时仍保留仓库视图,方便手动刷新重试。
const effectiveSource =
searchSource === "repos" && skills.length === 0 && !loading
searchSource === "repos" && repos.length === 0 && !loading
? "skillssh"
: searchSource;
useEffect(() => {
onSourceChange?.(effectiveSource);
}, [effectiveSource, onSourceChange]);
return (
<div className="px-6 flex flex-col flex-1 min-h-0 overflow-hidden bg-background/50">
{/* 技能网格(可滚动详情区域) */}
@@ -528,7 +573,7 @@ export const SkillsPage = forwardRef<SkillsPageHandle, SkillsPageProps>(
) : (
/* ===== skills.sh 模式 ===== */
<>
{loadingSkillsSh && accumulatedResults.length === 0 ? (
{searchingSkillsSh ? (
<div className="flex items-center justify-center h-64">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
<span className="ml-3 text-sm text-muted-foreground">
@@ -542,7 +587,7 @@ export const SkillsPage = forwardRef<SkillsPageHandle, SkillsPageProps>(
{t("skills.skillssh.searchPlaceholder")}
</p>
</div>
) : accumulatedResults.length === 0 && !loadingSkillsSh ? (
) : accumulatedResults.length === 0 ? (
<div className="flex flex-col items-center justify-center h-48 text-center">
<p className="text-lg font-medium text-foreground">
{t("skills.skillssh.noResults", {
+6 -1
View File
@@ -1,6 +1,6 @@
import * as React from "react";
import * as SelectPrimitive from "@radix-ui/react-select";
import { ChevronDown, ChevronUp } from "lucide-react";
import { Check, ChevronDown, ChevronUp } from "lucide-react";
import { cn } from "@/lib/utils";
const Select = SelectPrimitive.Root;
@@ -87,6 +87,11 @@ const SelectItem = React.forwardRef<
)}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
));
@@ -1,4 +1,5 @@
import { useState, useEffect, useCallback, useMemo } from "react";
import { useDarkMode } from "@/hooks/useDarkMode";
import { useTranslation } from "react-i18next";
import { Eye, EyeOff, RefreshCw } from "lucide-react";
import { Button } from "@/components/ui/button";
@@ -34,6 +35,7 @@ export function UniversalProviderFormModal({
editingProvider,
initialPreset,
}: UniversalProviderFormModalProps) {
const isDarkMode = useDarkMode();
const { t } = useTranslation();
const isEditMode = !!editingProvider;
@@ -658,6 +660,7 @@ requires_openai_auth = true`;
value={JSON.stringify(claudeConfigJson, null, 2)}
onChange={() => {}}
height={180}
darkMode={isDarkMode}
/>
</div>
)}
@@ -673,6 +676,7 @@ requires_openai_auth = true`;
value={JSON.stringify(codexConfigJson, null, 2)}
onChange={() => {}}
height={280}
darkMode={isDarkMode}
/>
</div>
)}
@@ -688,6 +692,7 @@ requires_openai_auth = true`;
value={JSON.stringify(geminiConfigJson, null, 2)}
onChange={() => {}}
height={140}
darkMode={isDarkMode}
/>
</div>
)}
@@ -0,0 +1,452 @@
import { useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { useQuery } from "@tanstack/react-query";
import { toast } from "sonner";
import { Check, Loader2, Search } from "lucide-react";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Alert, AlertDescription } from "@/components/ui/alert";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { useUpdateModelPricing } from "@/lib/query/usage";
import { isTextEditableTarget } from "@/utils/domUtils";
const MODELS_DEV_API_URL = "https://models.dev/api.json";
// 全量约 5000 条:默认只展示最新发布的一批,搜索时才做全量匹配
const DEFAULT_VISIBLE_ROWS = 50;
const MAX_VISIBLE_ROWS = 200;
interface ModelsDevCost {
input?: number;
output?: number;
cache_read?: number;
cache_write?: number;
}
interface ModelsDevModel {
id?: string;
name?: string;
release_date?: string;
cost?: ModelsDevCost;
}
interface ModelsDevProvider {
id?: string;
name?: string;
models?: Record<string, ModelsDevModel>;
}
type ModelsDevResponse = Record<string, ModelsDevProvider>;
interface ModelsDevEntry {
/** providerId/modelId,同一模型可能出现在多个供应商下 */
key: string;
providerId: string;
providerName: string;
modelId: string;
/** 实际入库的 ID,与后端 clean_model_id_for_pricing 的归一化规则一致 */
normalizedId: string;
modelName: string;
/** YYYY-MM-DD 或 YYYY-MM,缺失时为空串 */
releaseDate: string;
input: number;
output: number;
cacheRead: number;
cacheWrite: number;
}
/**
* 与后端 clean_model_id_for_pricingusage_stats.rs)保持一致:
* 取最后一个 '/' 之后的段、去掉 ':' 后缀、'@' 换成 '-'、转小写、去掉 [1m] 标记。
* 成本归因查询用的就是这种归一化形式,原样入库的 ID 永远匹配不上。
*/
export function normalizeModelIdForPricing(modelId: string): string {
const afterSlash = modelId.slice(modelId.lastIndexOf("/") + 1);
const beforeColon = afterSlash.split(":")[0] ?? "";
let normalized = beforeColon.trim().replace(/@/g, "-").toLowerCase();
if (normalized.endsWith("[1m]")) {
normalized = normalized.slice(0, -"[1m]".length).trim();
}
return normalized;
}
/** 转成后端可解析的非负十进制字符串(不能用 String(),小数可能变成科学计数法) */
export function formatPrice(value: number): string {
if (!Number.isFinite(value) || value <= 0) return "0";
// toFixed 对 >=1e21 会退化成科学计数法;这种量级的"价格"只可能是脏数据,按 0 处理
if (value >= 1e12) return "0";
const trimmed = value.toFixed(6).replace(/0+$/, "").replace(/\.$/, "");
return trimmed || "0";
}
export function flattenModels(data: ModelsDevResponse): ModelsDevEntry[] {
const entries: ModelsDevEntry[] = [];
for (const [providerId, provider] of Object.entries(data)) {
if (!provider || typeof provider !== "object") continue;
const providerName = provider.name || providerId;
for (const [modelId, model] of Object.entries(provider.models ?? {})) {
const cost = model?.cost;
const input = typeof cost?.input === "number" ? cost.input : null;
const output = typeof cost?.output === "number" ? cost.output : null;
if (input === null && output === null) continue;
const normalizedId = normalizeModelIdForPricing(modelId);
if (!normalizedId) continue;
entries.push({
key: `${providerId}/${modelId}`,
providerId,
providerName,
modelId,
normalizedId,
modelName: model?.name || modelId,
releaseDate:
typeof model?.release_date === "string" ? model.release_date : "",
input: input ?? 0,
output: output ?? 0,
cacheRead: typeof cost?.cache_read === "number" ? cost.cache_read : 0,
cacheWrite:
typeof cost?.cache_write === "number" ? cost.cache_write : 0,
});
}
}
// 最新发布的排在前面
entries.sort(
(a, b) =>
b.releaseDate.localeCompare(a.releaseDate) ||
a.modelName.localeCompare(b.modelName),
);
return entries;
}
interface ModelsDevPickerDialogProps {
open: boolean;
onClose: () => void;
/** 导入成功后调用(此时定价列表已刷新) */
onImported: () => void;
}
export function ModelsDevPickerDialog({
open,
onClose,
onImported,
}: ModelsDevPickerDialogProps) {
const { t } = useTranslation();
const updatePricing = useUpdateModelPricing();
const [search, setSearch] = useState("");
const [providerFilter, setProviderFilter] = useState("all");
const [selected, setSelected] = useState<ModelsDevEntry | null>(null);
// 每次打开时重置选择与过滤条件
useEffect(() => {
if (open) {
setSearch("");
setProviderFilter("all");
setSelected(null);
}
}, [open]);
const { data, isLoading, error, refetch } = useQuery({
queryKey: ["models-dev-pricing"],
queryFn: async (): Promise<ModelsDevResponse> => {
const res = await fetch(MODELS_DEV_API_URL);
if (!res.ok) {
throw new Error(`HTTP ${res.status}`);
}
return res.json();
},
enabled: open,
staleTime: 60 * 60 * 1000,
retry: 1,
});
const entries = useMemo(() => (data ? flattenModels(data) : []), [data]);
const providers = useMemo(() => {
const map = new Map<string, string>();
for (const entry of entries) {
if (!map.has(entry.providerId)) {
map.set(entry.providerId, entry.providerName);
}
}
return Array.from(map, ([id, name]) => ({ id, name })).sort((a, b) =>
a.name.localeCompare(b.name),
);
}, [entries]);
const isFiltering = search.trim() !== "" || providerFilter !== "all";
const filtered = useMemo(() => {
const query = search.trim().toLowerCase();
return entries.filter(
(entry) =>
(providerFilter === "all" || entry.providerId === providerFilter) &&
(!query ||
entry.modelId.toLowerCase().includes(query) ||
entry.normalizedId.includes(query) ||
entry.modelName.toLowerCase().includes(query) ||
entry.providerName.toLowerCase().includes(query)),
);
}, [entries, search, providerFilter]);
// 默认只展示最新发布的一批,搜索/筛选时展示全量匹配(设上限防卡顿)
const visible = useMemo(
() =>
filtered.slice(0, isFiltering ? MAX_VISIBLE_ROWS : DEFAULT_VISIBLE_ROWS),
[filtered, isFiltering],
);
// 单选:点击未选中的行替换选择,点击已选中的行取消选择。
// 限制单选是为了避免批量导入时每条都触发一次全量零成本回填扫描(见 update_model_pricing)。
const toggleEntry = (entry: ModelsDevEntry) => {
setSelected((prev) => (prev?.key === entry.key ? null : entry));
};
const handleImport = async () => {
if (!selected) return;
try {
await updatePricing.mutateAsync({
modelId: selected.normalizedId,
displayName: selected.modelName,
inputCost: formatPrice(selected.input),
outputCost: formatPrice(selected.output),
cacheReadCost: formatPrice(selected.cacheRead),
cacheCreationCost: formatPrice(selected.cacheWrite),
});
toast.success(
t("usage.modelsDevImported", {
name: selected.modelName,
defaultValue: "已导入 {{name}} 的定价",
}),
{ closeButton: true },
);
onImported();
} catch (error) {
toast.error(String(error));
}
};
const priceColumns = (entry: ModelsDevEntry) =>
[
{ label: t("usage.inputCost", "输入成本"), value: entry.input },
{ label: t("usage.outputCost", "输出成本"), value: entry.output },
{ label: t("usage.cacheReadCost", "缓存命中"), value: entry.cacheRead },
{
label: t("usage.cacheWriteCost", "缓存创建"),
value: entry.cacheWrite,
},
] as const;
return (
<Dialog
open={open}
onOpenChange={(nextOpen) => {
if (!nextOpen && !updatePricing.isPending) {
onClose();
}
}}
>
<DialogContent
zIndex="top"
className="max-w-3xl h-[80vh]"
onEscapeKeyDown={(e) => {
// 在搜索框里按 ESC 不应关闭弹窗丢掉已选模型(与 FullScreenPanel 的约定一致)
if (isTextEditableTarget(e.target)) {
e.preventDefault();
}
}}
>
<DialogHeader>
<DialogTitle>
{t("usage.modelsDevPickerTitle", "从 models.dev 导入定价")}
</DialogTitle>
<DialogDescription>
{t(
"usage.modelsDevPickerDesc",
"选择要导入的模型(价格单位:USD / 百万 tokens),每次导入一个",
)}
</DialogDescription>
</DialogHeader>
<div className="flex flex-1 min-h-0 flex-col gap-3 px-6 py-4">
{isLoading ? (
<div className="flex flex-1 items-center justify-center">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
) : error ? (
<Alert variant="destructive">
<AlertDescription className="flex items-center justify-between gap-3">
<span>
{t("usage.modelsDevLoadError", "加载 models.dev 数据失败")}:{" "}
{error instanceof Error ? error.message : String(error)}
</span>
<Button
variant="outline"
size="sm"
onClick={() => refetch()}
className="shrink-0"
>
{t("usage.modelsDevRetry", "重试")}
</Button>
</AlertDescription>
</Alert>
) : (
<>
<div className="flex items-center gap-2">
<Select
value={providerFilter}
onValueChange={setProviderFilter}
>
<SelectTrigger className="w-44 shrink-0">
<SelectValue />
</SelectTrigger>
<SelectContent className="z-[120] max-h-[min(24rem,var(--radix-select-content-available-height))]">
<SelectItem value="all">
{t("usage.modelsDevAllProviders", "全部供应商")}
</SelectItem>
{providers.map((provider) => (
<SelectItem key={provider.id} value={provider.id}>
{provider.name}
</SelectItem>
))}
</SelectContent>
</Select>
<div className="relative flex-1">
<Search className="absolute left-2.5 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder={t(
"usage.modelsDevSearchPlaceholder",
"搜索模型或供应商(全量搜索)...",
)}
className="pl-8"
/>
</div>
</div>
<div className="flex-1 min-h-0 overflow-y-auto rounded-md border border-border/50">
{filtered.length === 0 ? (
<div className="flex h-full items-center justify-center py-8 text-sm text-muted-foreground">
{t("usage.modelsDevNoResults", "没有匹配的模型")}
</div>
) : (
<div className="divide-y divide-border/30">
{visible.map((entry) => (
<div
key={entry.key}
role="button"
aria-pressed={selected?.key === entry.key}
onClick={() => toggleEntry(entry)}
className={`flex cursor-pointer items-center gap-3 px-3 py-2 ${
selected?.key === entry.key
? "bg-accent/50"
: "hover:bg-muted/40"
}`}
>
<Check
className={`h-4 w-4 shrink-0 text-primary ${
selected?.key === entry.key
? "visible"
: "invisible"
}`}
/>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="truncate text-sm font-medium">
{entry.modelName}
</span>
<span className="shrink-0 text-xs text-muted-foreground">
{entry.providerName}
</span>
{entry.releaseDate && (
<span className="shrink-0 text-[10px] text-muted-foreground/70">
{entry.releaseDate}
</span>
)}
</div>
<div
className="truncate font-mono text-xs text-muted-foreground"
title={entry.modelId}
>
{entry.normalizedId}
</div>
</div>
<div className="flex shrink-0 gap-3 text-right">
{priceColumns(entry).map((column) => (
<div key={column.label} className="w-16">
<div className="text-[10px] text-muted-foreground">
{column.label}
</div>
<div className="font-mono text-xs">
${formatPrice(column.value)}
</div>
</div>
))}
</div>
</div>
))}
{filtered.length > visible.length && (
<div className="px-3 py-2 text-center text-xs text-muted-foreground">
{isFiltering
? t("usage.modelsDevTruncated", {
shown: visible.length,
total: filtered.length,
defaultValue:
"仅显示前 {{shown}} 条,共 {{total}} 条结果,请缩小搜索范围",
})
: t("usage.modelsDevDefaultHint", {
shown: visible.length,
total: filtered.length,
defaultValue:
"默认展示最新发布的 {{shown}} 个模型(共 {{total}} 个),输入关键字可全量搜索",
})}
</div>
)}
</div>
)}
</div>
</>
)}
</div>
<DialogFooter>
<Button
variant="outline"
onClick={onClose}
disabled={updatePricing.isPending}
>
{t("common.cancel", "取消")}
</Button>
<Button
onClick={handleImport}
disabled={!selected || updatePricing.isPending}
>
{updatePricing.isPending ? (
<>
<Loader2 className="mr-1.5 h-4 w-4 animate-spin" />
{t("usage.modelsDevImporting", "导入中...")}
</>
) : (
t("usage.modelsDevImportButton", "导入")
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
+35 -1
View File
@@ -1,13 +1,14 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import { Save, Plus } from "lucide-react";
import { Save, Plus, Globe } from "lucide-react";
import { FullScreenPanel } from "@/components/common/FullScreenPanel";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { useUpdateModelPricing } from "@/lib/query/usage";
import { isNonNegativeDecimalString, type ModelPricing } from "@/types/usage";
import { ModelsDevPickerDialog } from "./ModelsDevPickerDialog";
interface PricingEditModalProps {
open: boolean;
@@ -26,6 +27,7 @@ export function PricingEditModal({
}: PricingEditModalProps) {
const { t } = useTranslation();
const updatePricing = useUpdateModelPricing();
const [isPickerOpen, setIsPickerOpen] = useState(false);
const [formData, setFormData] = useState({
modelId: model.modelId,
@@ -111,6 +113,27 @@ export function PricingEditModal({
</Button>
}
>
{isNew && (
<div className="mb-6 flex items-center justify-between gap-3 rounded-md border border-border/50 bg-muted/20 px-3 py-2.5">
<p className="text-xs text-muted-foreground">
{t(
"usage.modelsDevHint",
"无需手动填写,可从 models.dev 选择模型定价",
)}
</p>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => setIsPickerOpen(true)}
className="shrink-0"
>
<Globe className="mr-1.5 h-4 w-4" />
{t("usage.importFromModelsDev", "从 models.dev 导入")}
</Button>
</div>
)}
<form id="pricing-form" onSubmit={handleSubmit} className="space-y-6">
{isNew && (
<div className="space-y-2">
@@ -220,6 +243,17 @@ export function PricingEditModal({
/>
</div>
</form>
{isNew && isPickerOpen && (
<ModelsDevPickerDialog
open={isPickerOpen}
onClose={() => setIsPickerOpen(false)}
onImported={() => {
setIsPickerOpen(false);
onClose();
}}
/>
)}
</FullScreenPanel>
);
}
+12 -3
View File
@@ -108,9 +108,18 @@ export function UsageDashboard() {
return getUsageRangePresetLabel(range.preset, t);
}
return `${new Date(resolvedRange.startDate * 1000).toLocaleString(locale)} - ${new Date(
resolvedRange.endDate * 1000,
).toLocaleString(locale)}`;
const startStr = new Date(resolvedRange.startDate * 1000).toLocaleString(
locale,
);
if (range.liveEndTime) {
return `${startStr}${t("usage.liveEndTimeNow", "现在")}`;
}
const endStr = new Date(resolvedRange.endDate * 1000).toLocaleString(
locale,
);
return `${startStr} - ${endStr}`;
}, [locale, range, resolvedRange.endDate, resolvedRange.startDate, t]);
// 顶栏下拉的选项池:Provider 列表只跟应用/时间范围走(不受自身选中值影响),
+70 -9
View File
@@ -7,6 +7,7 @@ import {
ChevronRight,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import { Input } from "@/components/ui/input";
import {
Popover,
@@ -117,6 +118,9 @@ export function UsageDateRangePicker({
);
const [draftStart, setDraftStart] = useState(resolvedRange.startDate);
const [draftEnd, setDraftEnd] = useState(resolvedRange.endDate);
const [draftLiveEnd, setDraftLiveEnd] = useState(
selection.preset === "custom" ? (selection.liveEndTime ?? false) : false,
);
const [displayMonth, setDisplayMonth] = useState(
() =>
new Date(
@@ -136,6 +140,9 @@ export function UsageDateRangePicker({
const r = resolveUsageRange(selection);
setDraftStart(r.startDate);
setDraftEnd(r.endDate);
setDraftLiveEnd(
selection.preset === "custom" ? (selection.liveEndTime ?? false) : false,
);
setDisplayMonth(
new Date(
fromTs(r.startDate).getFullYear(),
@@ -147,6 +154,15 @@ export function UsageDateRangePicker({
setError(null);
}, [open, selection]);
// Keep draftEnd ticking when live mode is active and popover is open
useEffect(() => {
if (!open || !draftLiveEnd) return;
const tick = () => setDraftEnd(Math.floor(Date.now() / 1000));
tick();
const id = setInterval(tick, 1000);
return () => clearInterval(id);
}, [open, draftLiveEnd]);
const calendarDays = useMemo(
() => getCalendarDays(displayMonth),
[displayMonth],
@@ -169,6 +185,14 @@ export function UsageDateRangePicker({
/* Pick a date from the calendar */
const handleDatePick = (day: Date) => {
setError(null);
// When live end time is active, calendar only controls start date
if (draftLiveEnd) {
const nextTs = setDateKeepTime(draftStart, day);
setDraftStart(nextTs);
return;
}
const nextTs = setDateKeepTime(
activeField === "start" ? draftStart : draftEnd,
day,
@@ -211,6 +235,7 @@ export function UsageDateRangePicker({
preset: "custom",
customStartDate: draftStart,
customEndDate: draftEnd,
liveEndTime: draftLiveEnd,
});
setOpen(false);
};
@@ -222,6 +247,7 @@ export function UsageDateRangePicker({
/* ── Field card (start / end) ── */
const renderField = (field: DraftField) => {
const isActive = activeField === field;
const isEndLive = field === "end" && draftLiveEnd;
const ts = field === "start" ? draftStart : draftEnd;
const setTs = field === "start" ? setDraftStart : setDraftEnd;
const label =
@@ -232,12 +258,16 @@ export function UsageDateRangePicker({
return (
<div
className={cn(
"rounded-lg border px-3 py-2 cursor-pointer transition-all",
isActive
? "border-primary ring-1 ring-primary/30 bg-primary/5"
: "border-border/50 hover:border-border",
"rounded-lg border px-3 py-2 transition-all",
isEndLive
? "border-border/30 bg-muted/30 cursor-not-allowed opacity-50"
: isActive
? "border-primary ring-1 ring-primary/30 bg-primary/5 cursor-pointer"
: "border-border/50 hover:border-border cursor-pointer",
)}
onClick={() => setActiveField(field)}
onClick={() => {
if (!isEndLive) setActiveField(field);
}}
>
<div className="mb-1.5 text-[11px] font-medium uppercase tracking-wider text-muted-foreground">
{label}
@@ -245,27 +275,41 @@ export function UsageDateRangePicker({
<div className="flex items-center gap-1.5">
<Input
type="date"
className="h-7 flex-1 border-0 bg-transparent p-0 text-sm shadow-none focus-visible:ring-0"
className={cn(
"h-7 flex-1 border-0 bg-transparent p-0 text-sm shadow-none focus-visible:ring-0",
isEndLive && "pointer-events-none",
)}
value={fmtDate(ts)}
onChange={(e) => {
if (isEndLive) return;
const next = parseDateInput(ts, e.target.value);
setTs(next);
const d = fromTs(next);
setDisplayMonth(new Date(d.getFullYear(), d.getMonth(), 1));
setError(null);
}}
onFocus={() => setActiveField(field)}
onFocus={() => {
if (!isEndLive) setActiveField(field);
}}
readOnly={isEndLive}
/>
<Input
type="time"
step={60}
className="h-7 w-[90px] flex-none border-0 bg-transparent p-0 text-sm shadow-none focus-visible:ring-0"
className={cn(
"h-7 w-[90px] flex-none border-0 bg-transparent p-0 text-sm shadow-none focus-visible:ring-0",
isEndLive && "pointer-events-none",
)}
value={fmtTime(ts)}
onChange={(e) => {
if (isEndLive) return;
setTs(parseTimeInput(ts, e.target.value));
setError(null);
}}
onFocus={() => setActiveField(field)}
onFocus={() => {
if (!isEndLive) setActiveField(field);
}}
readOnly={isEndLive}
/>
</div>
</div>
@@ -318,6 +362,23 @@ export function UsageDateRangePicker({
{renderField("start")}
{renderField("end")}
<label className="flex items-center gap-2 cursor-pointer select-none">
<Checkbox
checked={draftLiveEnd}
onCheckedChange={(checked) => {
const live = checked === true;
setDraftLiveEnd(live);
if (live) {
setDraftEnd(Math.floor(Date.now() / 1000));
setActiveField("start");
}
}}
/>
<span className="text-xs text-muted-foreground">
{t("usage.liveEndTime", "结束时间跟随当前时刻")}
</span>
</label>
{error && <p className="text-xs text-destructive">{error}</p>}
<div className="flex gap-2 pt-1">