refactor: unify common config hooks with generic base hook and adapters

- Create useCommonConfigBase generic hook (~300 lines)
- Create commonConfigAdapters for Claude (JSON), Codex (TOML), Gemini (ENV/JSON)
- Refactor three hooks from ~1370 lines to ~430 lines (-940 lines)
- Extract useDarkMode hook from three ConfigSections components
- Remove dead code: backend _str functions, frontend JSON/TOML unused exports
- Deduplicate deepClone/deepMerge utilities
- Fix duplicate mod tests in provider.rs
This commit is contained in:
YoVinchen
2026-01-29 22:35:52 +08:00
parent 982f78af0f
commit 3b61fab4b5
14 changed files with 1045 additions and 1505 deletions
+388
View File
@@ -0,0 +1,388 @@
/**
* 通用配置格式适配器
*
* 提供 Claude (JSON), Codex (TOML), Gemini (ENV/JSON) 三种格式的适配器实现。
*/
import { validateJsonConfig } from "@/utils/providerConfigUtils";
import {
computeFinalConfig,
extractDifference,
isPlainObject,
} from "@/utils/configMerge";
import {
computeFinalTomlConfig,
extractTomlDifference,
safeParseToml,
} from "@/utils/tomlConfigMerge";
import {
parseGeminiCommonConfigSnippet,
GEMINI_CONFIG_ERROR_CODES,
} from "@/utils/providerConfigUtils";
import type {
CommonConfigAdapter,
ParseResult,
ExtractResult,
} from "./useCommonConfigBase";
// ============================================================================
// Claude Adapter (JSON)
// ============================================================================
const CLAUDE_LEGACY_STORAGE_KEY = "cc-switch:common-config-snippet";
const CLAUDE_DEFAULT_SNIPPET = `{
"includeCoAuthoredBy": false
}`;
export const claudeAdapter: CommonConfigAdapter<
Record<string, unknown>,
string
> = {
appKey: "claude",
defaultSnippet: CLAUDE_DEFAULT_SNIPPET,
legacyStorageKey: CLAUDE_LEGACY_STORAGE_KEY,
parseSnippet: (snippet: string): ParseResult<Record<string, unknown>> => {
const trimmed = snippet.trim();
if (!trimmed) {
return { config: {}, error: null };
}
try {
const parsed = JSON.parse(trimmed);
if (!isPlainObject(parsed)) {
return { config: null, error: "JSON 格式错误:不是对象" };
}
return { config: parsed, error: null };
} catch {
return { config: null, error: "JSON 格式错误" };
}
},
hasValidContent: (snippet: string): boolean => {
try {
const parsed = JSON.parse(snippet.trim());
return isPlainObject(parsed) && Object.keys(parsed).length > 0;
} catch {
return false;
}
},
getApplyError: (snippet: string, t): string => {
if (!snippet.trim()) {
return t("claudeConfig.noCommonConfigToApply");
}
const validationError = validateJsonConfig(snippet, "通用配置片段");
if (validationError) {
return validationError;
}
try {
const parsed = JSON.parse(snippet) as Record<string, unknown>;
if (Object.keys(parsed).length === 0) {
return t("claudeConfig.noCommonConfigToApply");
}
} catch {
return t("claudeConfig.noCommonConfigToApply");
}
return "";
},
parseInput: (input: string): Record<string, unknown> => {
try {
const parsed = JSON.parse(input || "{}");
return isPlainObject(parsed) ? parsed : {};
} catch {
return {};
}
},
computeFinal: (
custom: Record<string, unknown>,
common: Record<string, unknown>,
enabled: boolean,
): string => {
if (!enabled || Object.keys(common).length === 0) {
return JSON.stringify(custom, null, 2);
}
const merged = computeFinalConfig(custom, common, true);
return JSON.stringify(merged, null, 2);
},
extractDiff: (
custom: Record<string, unknown>,
common: Record<string, unknown>,
): ExtractResult<Record<string, unknown>> => {
const result = extractDifference(custom, common);
return {
custom: result.customConfig,
hasCommonKeys: result.hasCommonKeys,
};
},
serializeOutput: (config: Record<string, unknown>): string => {
return JSON.stringify(config, null, 2);
},
buildExtractRequest: (finalValue: string): { settingsConfig: string } => {
return { settingsConfig: finalValue };
},
};
// ============================================================================
// Codex Adapter (TOML)
// ============================================================================
const CODEX_LEGACY_STORAGE_KEY = "cc-switch:codex-common-config-snippet";
const CODEX_DEFAULT_SNIPPET = `# Common Codex config
# Add your common TOML configuration here`;
/** 检查 TOML 是否有实质内容(非空、非纯注释) */
function hasTomlContent(toml: string): boolean {
const lines = toml.split("\n");
return lines.some((line) => {
const trimmed = line.trim();
return trimmed && !trimmed.startsWith("#");
});
}
/** 校验 TOML 格式 */
function validateTomlFormat(tomlText: string): string | null {
if (!hasTomlContent(tomlText)) {
return null; // 空或纯注释是合法的
}
const result = safeParseToml(tomlText);
return result.error;
}
/**
* 从 codexConfig 提取 config 字段(TOML 格式)
* 支持两种格式:
* 1. 直接的 TOML 字符串
* 2. JSON 字符串 { auth: {...}, config: "TOML" }
*/
function extractConfigToml(configInput: string): string {
if (!configInput || !configInput.trim()) {
return "";
}
// 尝试解析为 JSON(旧格式)
try {
const parsed = JSON.parse(configInput);
if (typeof parsed?.config === "string") {
return parsed.config;
}
// 如果是 JSON 对象但没有 config 字段,返回空
if (typeof parsed === "object" && parsed !== null) {
return "";
}
} catch {
// JSON 解析失败,说明是直接的 TOML 字符串
}
return configInput;
}
export const codexAdapter: CommonConfigAdapter<string, string> = {
appKey: "codex",
defaultSnippet: CODEX_DEFAULT_SNIPPET,
legacyStorageKey: CODEX_LEGACY_STORAGE_KEY,
parseSnippet: (snippet: string): ParseResult<string> => {
const error = validateTomlFormat(snippet);
if (error) {
return { config: null, error };
}
return { config: snippet, error: null };
},
hasValidContent: (snippet: string): boolean => {
return hasTomlContent(snippet) && !validateTomlFormat(snippet);
},
getApplyError: (snippet: string, t): string => {
if (!hasTomlContent(snippet)) {
return t("codexConfig.noCommonConfigToApply");
}
const error = validateTomlFormat(snippet);
if (error) {
return t("codexConfig.tomlFormatError", { defaultValue: "TOML 格式错误" });
}
return "";
},
parseInput: (input: string): string => {
return extractConfigToml(input);
},
computeFinal: (
custom: string,
common: string,
enabled: boolean,
): string => {
if (!enabled || !hasTomlContent(common)) {
return custom;
}
const result = computeFinalTomlConfig(custom, common, true);
return result.error ? custom : result.finalConfig;
},
extractDiff: (custom: string, common: string): ExtractResult<string> => {
const result = extractTomlDifference(custom, common);
return {
custom: result.customToml,
hasCommonKeys: result.hasCommonKeys,
error: result.error,
};
},
serializeOutput: (config: string): string => {
return config;
},
buildExtractRequest: (finalValue: string): { settingsConfig: string } => {
return {
settingsConfig: JSON.stringify({ config: finalValue ?? "" }),
};
},
};
// ============================================================================
// Gemini Adapter (ENV/JSON)
// ============================================================================
const GEMINI_LEGACY_STORAGE_KEY = "cc-switch:gemini-common-config-snippet";
const GEMINI_DEFAULT_SNIPPET = "{}";
export interface GeminiAdapterOptions {
/** 字符串转对象 */
envStringToObj: (envString: string) => Record<string, string>;
/** 对象转字符串 */
envObjToString: (envObj: Record<string, unknown>) => string;
}
/**
* 创建 Gemini 适配器
* 需要传入 env 转换函数,因为这些函数依赖于外部实现
*/
export function createGeminiAdapter(
options: GeminiAdapterOptions,
): CommonConfigAdapter<Record<string, string>, Record<string, string>> {
const { envStringToObj, envObjToString } = options;
return {
appKey: "gemini",
defaultSnippet: GEMINI_DEFAULT_SNIPPET,
legacyStorageKey: GEMINI_LEGACY_STORAGE_KEY,
parseSnippet: (
snippet: string,
): ParseResult<Record<string, string>> => {
const result = parseGeminiCommonConfigSnippet(snippet, {
strictForbiddenKeys: true,
});
if (result.error) {
return { config: null, error: result.error };
}
return { config: result.env, error: null };
},
hasValidContent: (snippet: string): boolean => {
const result = parseGeminiCommonConfigSnippet(snippet, {
strictForbiddenKeys: true,
});
return !result.error && Object.keys(result.env).length > 0;
},
getApplyError: (snippet: string, t): string => {
const result = parseGeminiCommonConfigSnippet(snippet, {
strictForbiddenKeys: true,
});
if (result.error) {
if (result.error.startsWith(GEMINI_CONFIG_ERROR_CODES.FORBIDDEN_KEYS)) {
const keys = result.error.split(": ")[1] ?? result.error;
return t("geminiConfig.commonConfigInvalidKeys", { keys });
}
if (
result.error.startsWith(GEMINI_CONFIG_ERROR_CODES.VALUE_NOT_STRING)
) {
return t("geminiConfig.commonConfigInvalidValues");
}
return t("geminiConfig.invalidJsonFormat");
}
if (Object.keys(result.env).length === 0) {
return t("geminiConfig.noCommonConfigToApply");
}
return "";
},
parseInput: (input: string): Record<string, string> => {
return envStringToObj(input);
},
computeFinal: (
custom: Record<string, string>,
common: Record<string, string>,
enabled: boolean,
): Record<string, string> => {
if (!enabled || Object.keys(common).length === 0) {
return custom;
}
// 通用配置作为 base,自定义 env 覆盖
const merged = computeFinalConfig(
custom as Record<string, unknown>,
common as Record<string, unknown>,
true,
);
// 转换回 Record<string, string>
const result: Record<string, string> = {};
for (const [key, value] of Object.entries(merged)) {
if (typeof value === "string") {
result[key] = value;
}
}
return result;
},
extractDiff: (
custom: Record<string, string>,
common: Record<string, string>,
): ExtractResult<Record<string, string>> => {
const result = extractDifference(
custom as Record<string, unknown>,
common as Record<string, unknown>,
);
// 转换回 Record<string, string>
const customResult: Record<string, string> = {};
for (const [key, value] of Object.entries(result.customConfig)) {
if (typeof value === "string") {
customResult[key] = value;
}
}
return {
custom: customResult,
hasCommonKeys: result.hasCommonKeys,
};
},
serializeOutput: (config: Record<string, string>): string => {
return envObjToString(config);
},
buildExtractRequest: (
finalValue: Record<string, string>,
): { settingsConfig: string } => {
return {
settingsConfig: JSON.stringify({ env: finalValue }),
};
},
};
}
+473
View File
@@ -0,0 +1,473 @@
/**
* 通用配置管理基础 Hook
*
* 提供加载、切换、保存、提取等通用逻辑,通过 Adapter 注入格式特定处理。
* 支持 Claude (JSON), Codex (TOML), Gemini (ENV/JSON) 三种格式。
*/
import { useState, useEffect, useCallback, useRef, useMemo } from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import { configApi } from "@/lib/api";
import type { ProviderMeta } from "@/types";
// ============================================================================
// 类型定义
// ============================================================================
/** 应用类型 */
export type CommonConfigAppKey = "claude" | "codex" | "gemini";
/** 解析结果 */
export interface ParseResult<T> {
config: T | null;
error: string | null;
}
/** 合并结果 */
export interface MergeResult<T> {
merged: T;
error?: string;
}
/** 差异提取结果 */
export interface ExtractResult<T> {
custom: T;
hasCommonKeys: boolean;
error?: string;
}
/**
* 格式适配器接口
*
* 每种格式(JSON/TOML/ENV)需要实现此接口
*/
export interface CommonConfigAdapter<TConfig, TFinal> {
/** 应用标识 */
appKey: CommonConfigAppKey;
/** 默认片段内容 */
defaultSnippet: string;
/** localStorage 迁移 key (可选) */
legacyStorageKey?: string;
/**
* 解析片段字符串为配置对象
* @param snippet - 原始片段字符串
* @returns 解析结果
*/
parseSnippet: (snippet: string) => ParseResult<TConfig>;
/**
* 验证片段是否有有效内容(非空、非纯注释)
* @param snippet - 片段字符串
* @returns 是否有有效内容
*/
hasValidContent: (snippet: string) => boolean;
/**
* 获取片段应用错误(用于 toggle 时验证)
* @param snippet - 片段字符串
* @param t - i18n 翻译函数
* @returns 错误信息,无错误返回空字符串
*/
getApplyError: (
snippet: string,
t: (key: string, options?: Record<string, unknown>) => string,
) => string;
/**
* 从表单输入解析当前配置
* @param input - 表单输入值
* @returns 解析后的配置对象
*/
parseInput: (input: string) => TConfig;
/**
* 计算最终合并配置
* @param custom - 自定义配置
* @param common - 通用配置
* @param enabled - 是否启用
* @returns 合并后的最终配置
*/
computeFinal: (custom: TConfig, common: TConfig, enabled: boolean) => TFinal;
/**
* 提取差异(从自定义配置中移除与通用配置相同的部分)
* @param custom - 自定义配置
* @param common - 通用配置
* @returns 差异结果
*/
extractDiff: (custom: TConfig, common: TConfig) => ExtractResult<TConfig>;
/**
* 将配置对象序列化为表单输出
* @param config - 配置对象
* @returns 序列化后的字符串
*/
serializeOutput: (config: TConfig) => string;
/**
* 构建提取 API 的请求参数
* @param finalValue - 最终合并后的值
* @returns API 请求参数
*/
buildExtractRequest: (finalValue: TFinal) => { settingsConfig: string };
}
// ============================================================================
// Props 和 Return 类型
// ============================================================================
export interface UseCommonConfigBaseProps<TConfig, TFinal> {
/** 格式适配器 */
adapter: CommonConfigAdapter<TConfig, TFinal>;
/** 当前表单输入值 */
inputValue: string;
/** 输入变化回调 */
onInputChange: (value: string) => void;
/** 初始数据(编辑模式) */
initialData?: {
settingsConfig?: Record<string, unknown>;
meta?: ProviderMeta;
};
/** 当前选中的预设 ID */
selectedPresetId?: string;
/** 是否启用此 hook(默认 true */
enabled?: boolean;
}
export interface UseCommonConfigBaseReturn<TFinal> {
/** 是否启用通用配置 */
useCommonConfig: boolean;
/** 通用配置片段 */
commonConfigSnippet: string;
/** 通用配置错误信息 */
commonConfigError: string;
/** 是否正在加载 */
isLoading: boolean;
/** 是否正在提取 */
isExtracting: boolean;
/** 通用配置开关处理函数 */
handleCommonConfigToggle: (checked: boolean) => void;
/** 通用配置片段变化处理函数 */
handleCommonConfigSnippetChange: (snippet: string) => void;
/** 从当前配置提取通用配置 */
handleExtract: () => Promise<void>;
/** 最终配置(运行时合并结果,只读) */
finalValue: TFinal;
/** 是否有待保存的通用配置变更 */
hasUnsavedCommonConfig: boolean;
/** 获取待保存的通用配置片段(用于 handleSubmit */
getPendingCommonConfigSnippet: () => string | null;
/** 标记通用配置已保存 */
markCommonConfigSaved: () => void;
}
// ============================================================================
// 基础 Hook 实现
// ============================================================================
export function useCommonConfigBase<TConfig, TFinal>({
adapter,
inputValue,
onInputChange,
initialData,
selectedPresetId,
enabled = true,
}: UseCommonConfigBaseProps<TConfig, TFinal>): UseCommonConfigBaseReturn<TFinal> {
const { t } = useTranslation();
// ============================================================================
// 状态
// ============================================================================
const [useCommonConfig, setUseCommonConfig] = useState(false);
const [commonConfigSnippet, setCommonConfigSnippetState] = useState<string>(
adapter.defaultSnippet,
);
const [commonConfigError, setCommonConfigError] = useState("");
const [isLoading, setIsLoading] = useState(true);
const [isExtracting, setIsExtracting] = useState(false);
const [hasUnsavedCommonConfig, setHasUnsavedCommonConfig] = useState(false);
// 初始化跟踪
const hasInitializedEditMode = useRef(false);
const hasInitializedNewMode = useRef(false);
// ============================================================================
// 预设变化时重置初始化标记
// ============================================================================
useEffect(() => {
if (!enabled) return;
hasInitializedNewMode.current = false;
hasInitializedEditMode.current = false;
}, [selectedPresetId, enabled]);
// ============================================================================
// 加载通用配置片段(从数据库,支持 localStorage 迁移)
// ============================================================================
useEffect(() => {
if (!enabled) {
setIsLoading(false);
return;
}
let mounted = true;
const loadSnippet = async () => {
try {
const snippet = await configApi.getCommonConfigSnippet(adapter.appKey);
if (snippet && snippet.trim()) {
if (mounted) {
setCommonConfigSnippetState(snippet);
}
} else if (adapter.legacyStorageKey && typeof window !== "undefined") {
// 尝试从 localStorage 迁移
try {
const legacySnippet = window.localStorage.getItem(
adapter.legacyStorageKey,
);
if (legacySnippet && legacySnippet.trim()) {
const parsed = adapter.parseSnippet(legacySnippet);
if (!parsed.error) {
await configApi.setCommonConfigSnippet(
adapter.appKey,
legacySnippet,
);
if (mounted) {
setCommonConfigSnippetState(legacySnippet);
}
window.localStorage.removeItem(adapter.legacyStorageKey);
console.log(
`[迁移] ${adapter.appKey} 通用配置已从 localStorage 迁移到数据库`,
);
}
}
} catch (e) {
console.warn("[迁移] 从 localStorage 迁移失败:", e);
}
}
} catch (error) {
console.error(`加载 ${adapter.appKey} 通用配置失败:`, error);
} finally {
if (mounted) {
setIsLoading(false);
}
}
};
loadSnippet();
return () => {
mounted = false;
};
}, [enabled, adapter]);
// ============================================================================
// 编辑模式初始化:从 meta 读取启用状态
// ============================================================================
useEffect(() => {
if (!enabled) return;
if (initialData && !isLoading && !hasInitializedEditMode.current) {
hasInitializedEditMode.current = true;
const metaByApp = initialData.meta?.commonConfigEnabledByApp;
const resolvedMetaEnabled =
metaByApp?.[adapter.appKey] ?? initialData.meta?.commonConfigEnabled;
if (resolvedMetaEnabled !== undefined) {
if (!resolvedMetaEnabled) {
setUseCommonConfig(false);
return;
}
const applyError = adapter.getApplyError(commonConfigSnippet, t);
if (applyError) {
setCommonConfigError(applyError);
setUseCommonConfig(false);
return;
}
setCommonConfigError("");
setUseCommonConfig(true);
} else {
setUseCommonConfig(false);
}
}
}, [enabled, initialData, isLoading, commonConfigSnippet, adapter, t]);
// ============================================================================
// 新建模式初始化:如果通用配置有效,默认启用
// ============================================================================
useEffect(() => {
if (!enabled) return;
if (!initialData && !isLoading && !hasInitializedNewMode.current) {
hasInitializedNewMode.current = true;
if (adapter.hasValidContent(commonConfigSnippet)) {
const parsed = adapter.parseSnippet(commonConfigSnippet);
if (!parsed.error && parsed.config !== null) {
setUseCommonConfig(true);
}
}
}
}, [enabled, initialData, commonConfigSnippet, isLoading, adapter]);
// ============================================================================
// 计算最终配置(运行时合并)
// ============================================================================
const finalValue = useMemo((): TFinal => {
const customConfig = adapter.parseInput(inputValue);
if (!enabled || !useCommonConfig) {
return adapter.computeFinal(customConfig, customConfig, false);
}
const snippetParsed = adapter.parseSnippet(commonConfigSnippet);
if (snippetParsed.error || snippetParsed.config === null) {
return adapter.computeFinal(customConfig, customConfig, false);
}
return adapter.computeFinal(customConfig, snippetParsed.config, true);
}, [enabled, inputValue, commonConfigSnippet, useCommonConfig, adapter]);
// ============================================================================
// 处理通用配置开关
// ============================================================================
const handleCommonConfigToggle = useCallback(
(checked: boolean) => {
if (checked) {
const applyError = adapter.getApplyError(commonConfigSnippet, t);
if (applyError) {
setCommonConfigError(applyError);
setUseCommonConfig(false);
return;
}
}
setCommonConfigError("");
setUseCommonConfig(checked);
},
[commonConfigSnippet, adapter, t],
);
// ============================================================================
// 处理通用配置片段变化(延迟保存模式)
// ============================================================================
const handleCommonConfigSnippetChange = useCallback(
(value: string) => {
setCommonConfigSnippetState(value);
if (!value.trim()) {
setCommonConfigError("");
setHasUnsavedCommonConfig(true);
return;
}
// 格式校验
const parsed = adapter.parseSnippet(value);
if (parsed.error) {
setCommonConfigError(parsed.error);
return;
}
setCommonConfigError("");
setHasUnsavedCommonConfig(true);
},
[adapter],
);
// ============================================================================
// 从当前最终配置提取通用配置片段
// ============================================================================
const handleExtract = useCallback(async () => {
setIsExtracting(true);
setCommonConfigError("");
try {
const request = adapter.buildExtractRequest(finalValue);
const extracted = await configApi.extractCommonConfigSnippet(
adapter.appKey,
request,
);
if (!extracted || extracted === "{}" || !extracted.trim()) {
setCommonConfigError(
t(`${adapter.appKey}Config.extractNoCommonConfig`, {
defaultValue: "无法提取通用配置",
}),
);
return;
}
// 验证提取结果格式
const extractedParsed = adapter.parseSnippet(extracted);
if (extractedParsed.error || extractedParsed.config === null) {
setCommonConfigError(
t(`${adapter.appKey}Config.extractedConfigInvalid`, {
defaultValue: "提取的配置格式错误",
}),
);
return;
}
// 更新片段状态
setCommonConfigSnippetState(extracted);
setHasUnsavedCommonConfig(true);
// 从自定义配置中移除与提取内容相同的部分
const customConfig = adapter.parseInput(inputValue);
const diffResult = adapter.extractDiff(
customConfig,
extractedParsed.config,
);
if (!diffResult.error) {
onInputChange(adapter.serializeOutput(diffResult.custom));
toast.success(
t(`${adapter.appKey}Config.extractSuccessNeedSave`, {
defaultValue: "已提取通用配置,点击保存按钮完成保存",
}),
);
}
} catch (error) {
console.error(`提取 ${adapter.appKey} 通用配置失败:`, error);
setCommonConfigError(
t(`${adapter.appKey}Config.extractFailed`, {
error: String(error),
defaultValue: "提取失败",
}),
);
} finally {
setIsExtracting(false);
}
}, [adapter, finalValue, inputValue, onInputChange, t]);
// ============================================================================
// 获取待保存的通用配置片段
// ============================================================================
const getPendingCommonConfigSnippet = useCallback(() => {
return hasUnsavedCommonConfig ? commonConfigSnippet : null;
}, [hasUnsavedCommonConfig, commonConfigSnippet]);
// ============================================================================
// 标记通用配置已保存
// ============================================================================
const markCommonConfigSaved = useCallback(() => {
setHasUnsavedCommonConfig(false);
}, []);
return {
useCommonConfig,
commonConfigSnippet,
commonConfigError,
isLoading,
isExtracting,
handleCommonConfigToggle,
handleCommonConfigSnippetChange,
handleExtract,
finalValue,
hasUnsavedCommonConfig,
getPendingCommonConfigSnippet,
markCommonConfigSaved,
};
}
+27
View File
@@ -0,0 +1,27 @@
import { useState, useEffect } from "react";
/**
* Hook to track dark mode state by observing document.documentElement class changes.
*
* @returns boolean indicating if dark mode is currently active
*/
export function useDarkMode(): boolean {
const [isDarkMode, setIsDarkMode] = useState(false);
useEffect(() => {
setIsDarkMode(document.documentElement.classList.contains("dark"));
const observer = new MutationObserver(() => {
setIsDarkMode(document.documentElement.classList.contains("dark"));
});
observer.observe(document.documentElement, {
attributes: true,
attributeFilter: ["class"],
});
return () => observer.disconnect();
}, []);
return isDarkMode;
}