refactor(endpoint): separate edit and create mode endpoint management (#192)

Optimize custom endpoint management logic to distinguish between edit and create modes:
- Edit mode: endpoints are read/written directly to backend via API
- Create mode: use draftCustomEndpoints to stage, save on submit
- Remove duplicate endpoint loading in useSpeedTestEndpoints
- Add isSaving state and initialCustomUrls tracking
This commit is contained in:
YoVinchen
2025-11-12 11:02:43 +08:00
committed by GitHub
parent 8a05e7bd3d
commit 346f916048
5 changed files with 185 additions and 158 deletions

View File

@@ -34,7 +34,7 @@ interface ClaudeFormFieldsProps {
onBaseUrlChange: (url: string) => void;
isEndpointModalOpen: boolean;
onEndpointModalToggle: (open: boolean) => void;
onCustomEndpointsChange: (endpoints: string[]) => void;
onCustomEndpointsChange?: (endpoints: string[]) => void;
// Model Selector
shouldShowModelSelector: boolean;

View File

@@ -24,7 +24,7 @@ interface CodexFormFieldsProps {
onBaseUrlChange: (url: string) => void;
isEndpointModalOpen: boolean;
onEndpointModalToggle: (open: boolean) => void;
onCustomEndpointsChange: (endpoints: string[]) => void;
onCustomEndpointsChange?: (endpoints: string[]) => void;
// Speed Test Endpoints
speedTestEndpoints: EndpointCandidate[];

View File

@@ -36,7 +36,8 @@ interface EndpointSpeedTestProps {
initialEndpoints: EndpointCandidate[];
visible?: boolean;
onClose: () => void;
// 当自定义端点列表变化时回传(仅包含 isCustom 的条目)
// 新建模式:当自定义端点列表变化时回传(仅包含 isCustom 的条目)
// 编辑模式:不使用此回调,端点直接保存到后端
onCustomEndpointsChange?: (urls: string[]) => void;
}
@@ -101,25 +102,31 @@ const EndpointSpeedTest: React.FC<EndpointSpeedTestProps> = ({
const [autoSelect, setAutoSelect] = useState(true);
const [isTesting, setIsTesting] = useState(false);
const [lastError, setLastError] = useState<string | null>(null);
const [isSaving, setIsSaving] = useState(false);
// 记录初始的自定义端点,用于对比变化
const [initialCustomUrls, setInitialCustomUrls] = useState<Set<string>>(
new Set(),
);
const normalizedSelected = normalizeEndpointUrl(value);
const hasEndpoints = entries.length > 0;
const isEditMode = Boolean(providerId); // 编辑模式有 providerId
// 加载保存的自定义端点(按正在编辑的供应商)
// 编辑模式:加载保存的自定义端点
useEffect(() => {
let cancelled = false;
const loadCustomEndpoints = async () => {
try {
if (!providerId) return;
if (!providerId) return; // 新建模式不加载
const customEndpoints = await vscodeApi.getCustomEndpoints(
appId,
providerId,
);
// 检查是否已取消
if (cancelled) return;
const candidates: EndpointCandidate[] = customEndpoints.map(
@@ -129,6 +136,13 @@ const EndpointSpeedTest: React.FC<EndpointSpeedTestProps> = ({
}),
);
// 记录初始的自定义端点
const customUrls = new Set(
customEndpoints.map((ep) => normalizeEndpointUrl(ep.url)),
);
setInitialCustomUrls(customUrls);
// 合并自定义端点与初始端点
setEntries((prev) => {
const map = new Map<string, EndpointEntry>();
@@ -137,7 +151,7 @@ const EndpointSpeedTest: React.FC<EndpointSpeedTestProps> = ({
map.set(entry.url, entry);
});
// 合并自定义端点
// 添加从后端加载的自定义端点
candidates.forEach((candidate) => {
const sanitized = normalizeEndpointUrl(candidate.url);
if (sanitized && !map.has(sanitized)) {
@@ -161,60 +175,20 @@ const EndpointSpeedTest: React.FC<EndpointSpeedTestProps> = ({
}
};
if (visible) {
// 只在编辑模式下加载
if (providerId) {
loadCustomEndpoints();
}
return () => {
cancelled = true;
};
}, [appId, visible, providerId, t]);
}, [appId, providerId, t, initialEndpoints]);
// 新建模式:将自定义端点变化透传给父组件(仅限 isCustom
// 编辑模式:不使用此回调,端点已通过 API 直接保存
useEffect(() => {
setEntries((prev) => {
const map = new Map<string, EndpointEntry>();
prev.forEach((entry) => {
map.set(entry.url, entry);
});
let changed = false;
const mergeCandidate = (candidate: EndpointCandidate) => {
const sanitized = candidate.url
? normalizeEndpointUrl(candidate.url)
: "";
if (!sanitized) return;
const existing = map.get(sanitized);
if (existing) return;
map.set(sanitized, {
id: candidate.id ?? randomId(),
url: sanitized,
isCustom: candidate.isCustom ?? false,
latency: null,
status: undefined,
error: null,
});
changed = true;
};
initialEndpoints.forEach(mergeCandidate);
if (normalizedSelected && !map.has(normalizedSelected)) {
mergeCandidate({ url: normalizedSelected, isCustom: true });
}
if (!changed) {
return prev;
}
return Array.from(map.values());
});
}, [initialEndpoints, normalizedSelected]);
// 将自定义端点变化透传给父组件(仅限 isCustom
useEffect(() => {
if (!onCustomEndpointsChange) return;
if (!onCustomEndpointsChange || isEditMode) return; // 编辑模式不使用回调
try {
const customUrls = Array.from(
new Set(
@@ -228,8 +202,7 @@ const EndpointSpeedTest: React.FC<EndpointSpeedTestProps> = ({
} catch (err) {
// ignore
}
// 仅在 entries 变化时同步
}, [entries, onCustomEndpointsChange]);
}, [entries, onCustomEndpointsChange, isEditMode]);
const sortedEntries = useMemo(() => {
return entries.slice().sort((a: TestResult, b: TestResult) => {
@@ -268,7 +241,7 @@ const EndpointSpeedTest: React.FC<EndpointSpeedTestProps> = ({
let sanitized = "";
if (!errorMsg && parsed) {
sanitized = normalizeEndpointUrl(parsed.toString());
// 使用当前 entries 做去重校验,避免依赖可能过期的 addError
// 使用当前 entries 做去重校验
const isDuplicate = entries.some((entry) => entry.url === sanitized);
if (isDuplicate) {
errorMsg = t("endpointTest.urlExists");
@@ -281,8 +254,9 @@ const EndpointSpeedTest: React.FC<EndpointSpeedTestProps> = ({
}
setAddError(null);
setLastError(null);
// 更新本地状态(延迟提交,不立即保存到后端
// 更新本地状态(延迟保存,点击保存按钮时统一处理
setEntries((prev) => {
if (prev.some((e) => e.url === sanitized)) return prev;
return [
@@ -303,14 +277,14 @@ const EndpointSpeedTest: React.FC<EndpointSpeedTestProps> = ({
}
setCustomUrl("");
}, [customUrl, entries, normalizedSelected, onChange]);
}, [customUrl, entries, normalizedSelected, onChange, t]);
const handleRemoveEndpoint = useCallback(
(entry: EndpointEntry) => {
// 清空之前的错误提示
setLastError(null);
// 更新本地状态(延迟提交,不立即从后端删除
// 更新本地状态(延迟保存,点击保存按钮时统一处理
setEntries((prev) => {
const next = prev.filter((item) => item.id !== entry.id);
if (entry.url === normalizedSelected) {
@@ -405,6 +379,58 @@ const EndpointSpeedTest: React.FC<EndpointSpeedTestProps> = ({
[normalizedSelected, onChange],
);
// 保存端点变更
const handleSave = useCallback(async () => {
// 编辑模式:对比初始端点和当前端点,批量保存变更
if (isEditMode && providerId) {
setIsSaving(true);
setLastError(null);
try {
// 获取当前的自定义端点
const currentCustomUrls = new Set(
entries
.filter((e) => e.isCustom)
.map((e) => normalizeEndpointUrl(e.url)),
);
// 找出新增的端点
const toAdd = Array.from(currentCustomUrls).filter(
(url) => !initialCustomUrls.has(url),
);
// 找出删除的端点
const toRemove = Array.from(initialCustomUrls).filter(
(url) => !currentCustomUrls.has(url),
);
// 批量添加
for (const url of toAdd) {
await vscodeApi.addCustomEndpoint(appId, providerId, url);
}
// 批量删除
for (const url of toRemove) {
await vscodeApi.removeCustomEndpoint(appId, providerId, url);
}
// 更新初始端点列表
setInitialCustomUrls(currentCustomUrls);
} catch (error) {
const message =
error instanceof Error ? error.message : t("endpointTest.saveFailed");
setLastError(message);
setIsSaving(false);
return;
} finally {
setIsSaving(false);
}
}
// 关闭弹窗
onClose();
}, [isEditMode, providerId, entries, initialCustomUrls, appId, onClose, t]);
return (
<Dialog open={visible} onOpenChange={(open) => !open && onClose()}>
<DialogContent
@@ -580,10 +606,32 @@ const EndpointSpeedTest: React.FC<EndpointSpeedTestProps> = ({
)}
</div>
<DialogFooter>
<Button type="button" onClick={onClose} className="gap-2">
<Save className="w-4 h-4" />
{t("common.save")}
<DialogFooter className="gap-2">
<Button
type="button"
variant="outline"
onClick={onClose}
disabled={isSaving}
>
{t("common.cancel")}
</Button>
<Button
type="button"
onClick={handleSave}
disabled={isSaving}
className="gap-2"
>
{isSaving ? (
<>
<Loader2 className="w-4 h-4 animate-spin" />
{t("common.saving")}
</>
) : (
<>
<Save className="w-4 h-4" />
{t("common.save")}
</>
)}
</Button>
</DialogFooter>
</DialogContent>

View File

@@ -100,16 +100,16 @@ export function ProviderForm({
partnerPromotionKey?: string;
} | null>(null);
const [isEndpointModalOpen, setIsEndpointModalOpen] = useState(false);
const [isCodexEndpointModalOpen, setIsCodexEndpointModalOpen] =
useState(false);
// 新建供应商:收集端点测速弹窗中的"自定义端点",提交时一次性落盘到 meta.custom_endpoints
// 编辑供应商:从 initialData.meta.custom_endpoints 恢复端点列表
// 编辑供应商:端点已通过 API 直接保存,不再需要此状态
const [draftCustomEndpoints, setDraftCustomEndpoints] = useState<string[]>(
() => {
if (!initialData?.meta?.custom_endpoints) {
return [];
}
// 从 Record<string, CustomEndpoint> 中提取 URL 列表
return Object.keys(initialData.meta.custom_endpoints);
// 仅在新建模式下使用
if (initialData) return [];
return [];
},
);
@@ -125,10 +125,8 @@ export function ProviderForm({
setSelectedPresetId(initialData ? null : "custom");
setActivePreset(null);
// 重新初始化 draftCustomEndpoints(编辑模式时从 meta 恢复)
if (initialData?.meta?.custom_endpoints) {
setDraftCustomEndpoints(Object.keys(initialData.meta.custom_endpoints));
} else {
// 编辑模式不需要恢复 draftCustomEndpoints,端点已通过 API 管理
if (!initialData) {
setDraftCustomEndpoints([]);
}
}, [appId, initialData]);
@@ -220,8 +218,6 @@ export function ProviderForm({
[originalHandleCodexConfigChange, debouncedValidate],
);
const [isCodexEndpointModalOpen, setIsCodexEndpointModalOpen] =
useState(false);
const [isCodexTemplateModalOpen, setIsCodexTemplateModalOpen] =
useState(false);
@@ -361,60 +357,51 @@ export function ProviderForm({
}
}
// 处理 meta 字段:基于 draftCustomEndpoints 生成 custom_endpoints
// 注意:不使用 customEndpointsMap因为它包含了候选端点预设、Base URL 等)
// 而我们只需要保存用户真正添加的自定义端点
const customEndpointsToSave: Record<
string,
import("@/types").CustomEndpoint
> | null =
draftCustomEndpoints.length > 0
? draftCustomEndpoints.reduce(
(acc, url) => {
// 尝试从 initialData.meta 中获取原有的端点元数据(保留 addedAt 和 lastUsed
const existing = initialData?.meta?.custom_endpoints?.[url];
if (existing) {
acc[url] = existing;
} else {
// 新端点:使用当前时间戳
const now = Date.now();
acc[url] = { url, addedAt: now, lastUsed: undefined };
}
return acc;
},
{} as Record<string, import("@/types").CustomEndpoint>,
)
: null;
// 处理 meta 字段:仅在新建模式下从 draftCustomEndpoints 生成 custom_endpoints
// 编辑模式:端点已通过 API 直接保存,不在此处理
if (!isEditMode && draftCustomEndpoints.length > 0) {
const customEndpointsToSave: Record<
string,
import("@/types").CustomEndpoint
> = draftCustomEndpoints.reduce(
(acc, url) => {
const now = Date.now();
acc[url] = { url, addedAt: now, lastUsed: undefined };
return acc;
},
{} as Record<string, import("@/types").CustomEndpoint>,
);
// 检测是否需要清空端点(重要:区分"用户清空端点"和"用户没有修改端点"
const hadEndpoints =
initialData?.meta?.custom_endpoints &&
Object.keys(initialData.meta.custom_endpoints).length > 0;
const needsClearEndpoints =
hadEndpoints && draftCustomEndpoints.length === 0;
// 检测是否需要清空端点(重要:区分"用户清空端点"和"用户没有修改端点"
const hadEndpoints =
initialData?.meta?.custom_endpoints &&
Object.keys(initialData.meta.custom_endpoints).length > 0;
const needsClearEndpoints =
hadEndpoints && draftCustomEndpoints.length === 0;
// 如果用户明确清空了端点,传递空对象(而不是 null让后端知道要删除
let mergedMeta = needsClearEndpoints
? mergeProviderMeta(initialData?.meta, {})
: mergeProviderMeta(initialData?.meta, customEndpointsToSave);
// 如果用户明确清空了端点,传递空对象(而不是 null让后端知道要删除
let mergedMeta = needsClearEndpoints
? mergeProviderMeta(initialData?.meta, {})
: mergeProviderMeta(initialData?.meta, customEndpointsToSave);
// 添加合作伙伴标识与促销 key
if (activePreset?.isPartner) {
mergedMeta = {
...(mergedMeta ?? {}),
isPartner: true,
};
}
// 添加合作伙伴标识与促销 key
if (activePreset?.isPartner) {
mergedMeta = {
...(mergedMeta ?? {}),
isPartner: true,
};
}
if (activePreset?.partnerPromotionKey) {
mergedMeta = {
...(mergedMeta ?? {}),
partnerPromotionKey: activePreset.partnerPromotionKey,
};
}
if (activePreset?.partnerPromotionKey) {
mergedMeta = {
...(mergedMeta ?? {}),
partnerPromotionKey: activePreset.partnerPromotionKey,
};
}
if (mergedMeta !== undefined) {
payload.meta = mergedMeta;
if (mergedMeta !== undefined) {
payload.meta = mergedMeta;
}
}
onSubmit(payload);
@@ -609,7 +596,9 @@ export function ProviderForm({
onBaseUrlChange={handleClaudeBaseUrlChange}
isEndpointModalOpen={isEndpointModalOpen}
onEndpointModalToggle={setIsEndpointModalOpen}
onCustomEndpointsChange={setDraftCustomEndpoints}
onCustomEndpointsChange={
isEditMode ? undefined : setDraftCustomEndpoints
}
shouldShowModelSelector={category !== "official"}
claudeModel={claudeModel}
defaultHaikuModel={defaultHaikuModel}
@@ -636,7 +625,9 @@ export function ProviderForm({
onBaseUrlChange={handleCodexBaseUrlChange}
isEndpointModalOpen={isCodexEndpointModalOpen}
onEndpointModalToggle={setIsCodexEndpointModalOpen}
onCustomEndpointsChange={setDraftCustomEndpoints}
onCustomEndpointsChange={
isEditMode ? undefined : setDraftCustomEndpoints
}
speedTestEndpoints={speedTestEndpoints}
/>
)}

View File

@@ -25,10 +25,12 @@ interface UseSpeedTestEndpointsProps {
* 收集端点测速弹窗的初始端点列表
*
* 收集来源:
* 1. 编辑模式:从 meta.custom_endpoints 读取已保存的端点(优先)
* 2. 当前选中的 Base URL
* 3. 编辑模式下的初始数据 URL
* 4. 预设中的 endpointCandidates
* 1. 当前选中的 Base URL
* 2. 编辑模式下的初始数据 URL
* 3. 预设中的 endpointCandidates
*
* 注意:已保存的自定义端点通过 getCustomEndpoints API 在 EndpointSpeedTest 组件中加载,
* 不在此处读取,避免重复导入。
*/
export function useSpeedTestEndpoints({
appId,
@@ -43,28 +45,21 @@ export function useSpeedTestEndpoints({
if (appId !== "claude" && appId !== "gemini") return [];
const map = new Map<string, EndpointCandidate>();
// 所有端点标记为 isCustom: true给用户完全的管理自由
const add = (url?: string) => {
// 候选端点标记为 isCustom: false表示来自预设或配置
// 已保存的自定义端点会在 EndpointSpeedTest 组件中通过 API 加载
const add = (url?: string, isCustom = false) => {
if (!url) return;
const sanitized = url.trim().replace(/\/+$/, "");
if (!sanitized || map.has(sanitized)) return;
map.set(sanitized, { url: sanitized, isCustom: true });
map.set(sanitized, { url: sanitized, isCustom });
};
// 1. 编辑模式:从 meta.custom_endpoints 读取已保存的端点(优先)
if (initialData?.meta?.custom_endpoints) {
const customEndpoints = initialData.meta.custom_endpoints;
for (const url of Object.keys(customEndpoints)) {
add(url);
}
}
// 2. 当前 Base URL
// 1. 当前 Base URL
if (baseUrl) {
add(baseUrl);
}
// 3. 编辑模式:初始数据中的 URL
// 2. 编辑模式:初始数据中的 URL
if (initialData && typeof initialData.settingsConfig === "object") {
const configEnv = initialData.settingsConfig as {
env?: { ANTHROPIC_BASE_URL?: string; GOOGLE_GEMINI_BASE_URL?: string };
@@ -78,7 +73,7 @@ export function useSpeedTestEndpoints({
});
}
// 4. 预设中的 endpointCandidates(也允许用户删除)
// 3. 预设中的 endpointCandidates
if (selectedPresetId && selectedPresetId !== "custom") {
const entry = presetEntries.find((item) => item.id === selectedPresetId);
if (entry) {
@@ -112,28 +107,21 @@ export function useSpeedTestEndpoints({
if (appId !== "codex") return [];
const map = new Map<string, EndpointCandidate>();
// 所有端点标记为 isCustom: true给用户完全的管理自由
const add = (url?: string) => {
// 候选端点标记为 isCustom: false表示来自预设或配置
// 已保存的自定义端点会在 EndpointSpeedTest 组件中通过 API 加载
const add = (url?: string, isCustom = false) => {
if (!url) return;
const sanitized = url.trim().replace(/\/+$/, "");
if (!sanitized || map.has(sanitized)) return;
map.set(sanitized, { url: sanitized, isCustom: true });
map.set(sanitized, { url: sanitized, isCustom });
};
// 1. 编辑模式:从 meta.custom_endpoints 读取已保存的端点(优先)
if (initialData?.meta?.custom_endpoints) {
const customEndpoints = initialData.meta.custom_endpoints;
for (const url of Object.keys(customEndpoints)) {
add(url);
}
}
// 2. 当前 Codex Base URL
// 1. 当前 Codex Base URL
if (codexBaseUrl) {
add(codexBaseUrl);
}
// 3. 编辑模式:初始数据中的 URL
// 2. 编辑模式:初始数据中的 URL
const initialCodexConfig = initialData?.settingsConfig as
| {
config?: string;
@@ -146,7 +134,7 @@ export function useSpeedTestEndpoints({
add(match[1]);
}
// 4. 预设中的 endpointCandidates(也允许用户删除)
// 3. 预设中的 endpointCandidates
if (selectedPresetId && selectedPresetId !== "custom") {
const entry = presetEntries.find((item) => item.id === selectedPresetId);
if (entry) {