Compare commits

..

4 Commits

Author SHA1 Message Date
YoVinchen bfc90fc7c6 Merge branch 'main' into fix/provider-card-dynamic-offset 2026-01-12 17:49:10 +08:00
Dex Miller 99c910e58e fix(provider): persist endpoint auto-select state (#611)
- Add endpointAutoSelect field to ProviderMeta for persistence
- Lift autoSelect state from EndpointSpeedTest to ProviderForm
- Save auto-select preference when provider is saved
- Restore preference when editing existing provider

Fixes https://github.com/farion1231/cc-switch/issues/589
2026-01-12 16:26:17 +08:00
YoVinchen aed749f7b1 fix(ui): auto-adapt usage block offset based on action buttons width 2026-01-12 16:04:03 +08:00
Dex Miller 8f7423f011 Feat/deeplink multi endpoints (#597)
* feat(deeplink): support comma-separated multiple endpoints in URL

Allow importing multiple API endpoints via single endpoint parameter.
First URL becomes primary endpoint, rest are added as custom endpoints.

* feat(deeplink): add usage query fields to deeplink generator

Add form fields for usage query configuration in deeplink HTML generator:
- usageEnabled, usageBaseUrl, usageApiKey
- usageScript, usageAutoInterval
- usageAccessToken, usageUserId

* fix(deeplink): auto-infer homepage and improve multi-endpoint display

- Auto-infer homepage from primary endpoint when not provided
- Display multiple endpoints as list in import dialog (primary marked)
- Update deeplink parser in deplink.html to show multi-endpoint info
- Add test for homepage inference from endpoint
- Minor log format fix in live.rs

* fix(deeplink): use primary endpoint for usage script base_url

- Fix usage_script.base_url getting comma-separated string when multiple endpoints
- Add i18n support for primary endpoint label in DeepLinkImportDialog
2026-01-12 15:57:45 +08:00
8 changed files with 77 additions and 8 deletions
+3
View File
@@ -147,6 +147,9 @@ pub struct ProviderMeta {
/// 用量查询脚本配置
#[serde(skip_serializing_if = "Option::is_none")]
pub usage_script: Option<UsageScript>,
/// 请求地址管理:测速后自动选择最佳端点
#[serde(rename = "endpointAutoSelect", skip_serializing_if = "Option::is_none")]
pub endpoint_auto_select: Option<bool>,
/// 合作伙伴标记(前端使用 isPartner,保持字段名一致)
#[serde(rename = "isPartner", skip_serializing_if = "Option::is_none")]
pub is_partner: Option<bool>,
+30 -6
View File
@@ -1,4 +1,4 @@
import { useMemo, useState, useEffect } from "react";
import { useMemo, useState, useEffect, useRef } from "react";
import { GripVertical, ChevronDown, ChevronUp } from "lucide-react";
import { useTranslation } from "react-i18next";
import type {
@@ -149,6 +149,10 @@ export function ProviderCard({
// 多套餐默认展开
const [isExpanded, setIsExpanded] = useState(false);
// 操作按钮容器 ref,用于动态计算宽度
const actionsRef = useRef<HTMLDivElement>(null);
const [actionsWidth, setActionsWidth] = useState(0);
// 当检测到多套餐时自动展开
useEffect(() => {
if (hasMultiplePlans) {
@@ -156,6 +160,20 @@ export function ProviderCard({
}
}, [hasMultiplePlans]);
// 动态获取操作按钮宽度
useEffect(() => {
if (actionsRef.current) {
const updateWidth = () => {
const width = actionsRef.current?.offsetWidth || 0;
setActionsWidth(width);
};
updateWidth();
// 监听窗口大小变化
window.addEventListener("resize", updateWidth);
return () => window.removeEventListener("resize", updateWidth);
}
}, [onTest, onOpenTerminal]); // 按钮数量可能变化时重新计算
const handleOpenWebsite = () => {
if (!isClickableUrl) {
return;
@@ -281,10 +299,13 @@ export function ProviderCard({
</div>
</div>
<div className="relative flex items-center ml-auto min-w-0">
<div
className="relative flex items-center ml-auto min-w-0 gap-3"
style={{ "--actions-width": `${actionsWidth || 320}px` } as React.CSSProperties}
>
{/* 用量信息区域 - hover 时向左移动,为操作按钮腾出空间 */}
<div className="ml-auto transition-transform duration-200 group-hover:-translate-x-[14.5rem] group-focus-within:-translate-x-[14.5rem] sm:group-hover:-translate-x-[16rem] sm:group-focus-within:-translate-x-[16rem]">
<div className="flex items-center gap-1">
<div className="ml-auto">
<div className="flex items-center gap-1 transition-transform duration-200 group-hover:-translate-x-[var(--actions-width)] group-focus-within:-translate-x-[var(--actions-width)]">
{/* 多套餐时显示套餐数量,单套餐时显示详细信息 */}
{hasMultiplePlans ? (
<div className="flex items-center gap-2 text-xs text-gray-600 dark:text-gray-400">
@@ -329,8 +350,11 @@ export function ProviderCard({
</div>
</div>
{/* 操作按钮区域 - 绝对定位在右侧,hover 时滑入 */}
<div className="absolute right-0 top-1/2 -translate-y-1/2 flex items-center gap-1.5 opacity-0 pointer-events-none group-hover:opacity-100 group-focus-within:opacity-100 group-hover:pointer-events-auto group-focus-within:pointer-events-auto transition-all duration-200 translate-x-2 group-hover:translate-x-0 group-focus-within:translate-x-0">
{/* 操作按钮区域 - 绝对定位在右侧,hover 时滑入,与用量信息保持间距 */}
<div
ref={actionsRef}
className="absolute right-0 top-1/2 -translate-y-1/2 flex items-center gap-1.5 pl-3 opacity-0 pointer-events-none group-hover:opacity-100 group-focus-within:opacity-100 group-hover:pointer-events-auto group-focus-within:pointer-events-auto transition-all duration-200 translate-x-2 group-hover:translate-x-0 group-focus-within:translate-x-0"
>
<ProviderActions
isCurrent={isCurrent}
isTesting={isTesting}
@@ -36,6 +36,8 @@ interface ClaudeFormFieldsProps {
isEndpointModalOpen: boolean;
onEndpointModalToggle: (open: boolean) => void;
onCustomEndpointsChange?: (endpoints: string[]) => void;
autoSelect: boolean;
onAutoSelectChange: (checked: boolean) => void;
// Model Selector
shouldShowModelSelector: boolean;
@@ -83,6 +85,8 @@ export function ClaudeFormFields({
isEndpointModalOpen,
onEndpointModalToggle,
onCustomEndpointsChange,
autoSelect,
onAutoSelectChange,
shouldShowModelSelector,
claudeModel,
reasoningModel,
@@ -170,6 +174,8 @@ export function ClaudeFormFields({
initialEndpoints={speedTestEndpoints}
visible={isEndpointModalOpen}
onClose={() => onEndpointModalToggle(false)}
autoSelect={autoSelect}
onAutoSelectChange={onAutoSelectChange}
onCustomEndpointsChange={onCustomEndpointsChange}
/>
)}
@@ -25,6 +25,8 @@ interface CodexFormFieldsProps {
isEndpointModalOpen: boolean;
onEndpointModalToggle: (open: boolean) => void;
onCustomEndpointsChange?: (endpoints: string[]) => void;
autoSelect: boolean;
onAutoSelectChange: (checked: boolean) => void;
// Model Name
shouldShowModelField?: boolean;
@@ -50,6 +52,8 @@ export function CodexFormFields({
isEndpointModalOpen,
onEndpointModalToggle,
onCustomEndpointsChange,
autoSelect,
onAutoSelectChange,
shouldShowModelField = true,
modelName = "",
onModelNameChange,
@@ -130,6 +134,8 @@ export function CodexFormFields({
initialEndpoints={speedTestEndpoints}
visible={isEndpointModalOpen}
onClose={() => onEndpointModalToggle(false)}
autoSelect={autoSelect}
onAutoSelectChange={onAutoSelectChange}
onCustomEndpointsChange={onCustomEndpointsChange}
/>
)}
@@ -30,6 +30,8 @@ interface EndpointSpeedTestProps {
initialEndpoints: EndpointCandidate[];
visible?: boolean;
onClose: () => void;
autoSelect: boolean;
onAutoSelectChange: (checked: boolean) => void;
// 新建模式:当自定义端点列表变化时回传(仅包含 isCustom 的条目)
// 编辑模式:不使用此回调,端点直接保存到后端
onCustomEndpointsChange?: (urls: string[]) => void;
@@ -85,6 +87,8 @@ const EndpointSpeedTest: React.FC<EndpointSpeedTestProps> = ({
initialEndpoints,
visible = true,
onClose,
autoSelect,
onAutoSelectChange,
onCustomEndpointsChange,
}) => {
const { t } = useTranslation();
@@ -93,7 +97,6 @@ const EndpointSpeedTest: React.FC<EndpointSpeedTestProps> = ({
);
const [customUrl, setCustomUrl] = useState("");
const [addError, setAddError] = useState<string | null>(null);
const [autoSelect, setAutoSelect] = useState(true);
const [isTesting, setIsTesting] = useState(false);
const [lastError, setLastError] = useState<string | null>(null);
const [isSaving, setIsSaving] = useState(false);
@@ -488,7 +491,9 @@ const EndpointSpeedTest: React.FC<EndpointSpeedTestProps> = ({
<input
type="checkbox"
checked={autoSelect}
onChange={(event) => setAutoSelect(event.target.checked)}
onChange={(event) => {
onAutoSelectChange(event.target.checked);
}}
className="h-3.5 w-3.5 rounded border-border-default bg-background text-primary focus:ring-2 focus:ring-primary/20"
/>
{t("endpointTest.autoSelect")}
@@ -29,6 +29,8 @@ interface GeminiFormFieldsProps {
isEndpointModalOpen: boolean;
onEndpointModalToggle: (open: boolean) => void;
onCustomEndpointsChange: (endpoints: string[]) => void;
autoSelect: boolean;
onAutoSelectChange: (checked: boolean) => void;
// Model
shouldShowModelField: boolean;
@@ -55,6 +57,8 @@ export function GeminiFormFields({
isEndpointModalOpen,
onEndpointModalToggle,
onCustomEndpointsChange,
autoSelect,
onAutoSelectChange,
shouldShowModelField,
model,
onModelChange,
@@ -142,6 +146,8 @@ export function GeminiFormFields({
initialEndpoints={speedTestEndpoints}
visible={isEndpointModalOpen}
onClose={() => onEndpointModalToggle(false)}
autoSelect={autoSelect}
onAutoSelectChange={onAutoSelectChange}
onCustomEndpointsChange={onCustomEndpointsChange}
/>
)}
@@ -124,6 +124,9 @@ export function ProviderForm({
return [];
},
);
const [endpointAutoSelect, setEndpointAutoSelect] = useState<boolean>(
() => initialData?.meta?.endpointAutoSelect ?? true,
);
// 使用 category hook
const { category } = useProviderCategory({
@@ -141,6 +144,7 @@ export function ProviderForm({
if (!initialData) {
setDraftCustomEndpoints([]);
}
setEndpointAutoSelect(initialData?.meta?.endpointAutoSelect ?? true);
}, [appId, initialData]);
const defaultValues: ProviderFormData = useMemo(
@@ -647,6 +651,13 @@ export function ProviderForm({
}
}
const baseMeta: ProviderMeta | undefined =
payload.meta ?? (initialData?.meta ? { ...initialData.meta } : undefined);
payload.meta = {
...(baseMeta ?? {}),
endpointAutoSelect,
};
onSubmit(payload);
};
@@ -856,6 +867,8 @@ export function ProviderForm({
onCustomEndpointsChange={
isEditMode ? undefined : setDraftCustomEndpoints
}
autoSelect={endpointAutoSelect}
onAutoSelectChange={setEndpointAutoSelect}
shouldShowModelSelector={category !== "official"}
claudeModel={claudeModel}
reasoningModel={reasoningModel}
@@ -889,6 +902,8 @@ export function ProviderForm({
onCustomEndpointsChange={
isEditMode ? undefined : setDraftCustomEndpoints
}
autoSelect={endpointAutoSelect}
onAutoSelectChange={setEndpointAutoSelect}
shouldShowModelField={category !== "official"}
modelName={codexModelName}
onModelNameChange={handleCodexModelNameChange}
@@ -917,6 +932,8 @@ export function ProviderForm({
isEndpointModalOpen={isEndpointModalOpen}
onEndpointModalToggle={setIsEndpointModalOpen}
onCustomEndpointsChange={setDraftCustomEndpoints}
autoSelect={endpointAutoSelect}
onAutoSelectChange={setEndpointAutoSelect}
shouldShowModelField={true}
model={geminiModel}
onModelChange={handleGeminiModelChange}
+2
View File
@@ -92,6 +92,8 @@ export interface ProviderMeta {
custom_endpoints?: Record<string, CustomEndpoint>;
// 用量查询脚本配置
usage_script?: UsageScript;
// 请求地址管理:测速后自动选择最佳端点
endpointAutoSelect?: boolean;
// 是否为官方合作伙伴
isPartner?: boolean;
// 合作伙伴促销 key(用于后端识别 PackyCode 等)