import { useEffect, useMemo, useState } from "react"; import { Alert, Button, Card, Drawer, Form, Input, Modal, Space, Table, Tabs, Tag, Typography, message, } from "antd"; import { CopyOutlined, DeleteOutlined, EditOutlined, EyeOutlined, PlusOutlined, PoweroffOutlined, ReloadOutlined, } from "@ant-design/icons"; import { formatTime } from "../view-helpers.js"; const { Title, Paragraph, Text } = Typography; const PROVIDER_TABS = [ { key: "codex", label: "Codex 配置" }, { key: "claude-code", label: "Claude Code 配置" }, ]; function splitLines(text) { const normalized = String(text || "").replace(/\r\n/g, "\n"); const parts = normalized.split("\n"); if (parts.length > 1 && parts.at(-1) === "") parts.pop(); return parts.length ? parts : [""]; } function buildAlignedDiffRows(leftText, rightText) { const leftLines = splitLines(leftText); const rightLines = splitLines(rightText); const rows = []; const dp = Array.from({ length: leftLines.length + 1 }, () => Array(rightLines.length + 1).fill(0), ); for (let i = leftLines.length - 1; i >= 0; i -= 1) { for (let j = rightLines.length - 1; j >= 0; j -= 1) { if (leftLines[i] === rightLines[j]) { dp[i][j] = dp[i + 1][j + 1] + 1; } else { dp[i][j] = Math.max(dp[i + 1][j], dp[i][j + 1]); } } } let i = 0; let j = 0; while (i < leftLines.length && j < rightLines.length) { if (leftLines[i] === rightLines[j]) { rows.push({ leftText: leftLines[i], rightText: rightLines[j], leftType: "same", rightType: "same", }); i += 1; j += 1; continue; } if (dp[i + 1][j] >= dp[i][j + 1]) { rows.push({ leftText: leftLines[i], rightText: "", leftType: "removed", rightType: "empty", }); i += 1; } else { rows.push({ leftText: "", rightText: rightLines[j], leftType: "empty", rightType: "added", }); j += 1; } } while (i < leftLines.length) { rows.push({ leftText: leftLines[i], rightText: "", leftType: "removed", rightType: "empty", }); i += 1; } while (j < rightLines.length) { rows.push({ leftText: "", rightText: rightLines[j], leftType: "empty", rightType: "added", }); j += 1; } const mergedRows = []; for (let index = 0; index < rows.length; index += 1) { const current = rows[index]; const next = rows[index + 1]; if ( current.leftType === "removed" && current.rightType === "empty" && next?.leftType === "empty" && next?.rightType === "added" ) { mergedRows.push({ leftText: current.leftText, rightText: next.rightText, leftType: "changed", rightType: "changed", }); index += 1; continue; } mergedRows.push(current); } let leftLineNumber = 1; let rightLineNumber = 1; return mergedRows.map((row) => { const nextRow = { ...row, leftLineNumber: row.leftType === "empty" ? null : leftLineNumber, rightLineNumber: row.rightType === "empty" ? null : rightLineNumber, }; if (row.leftType !== "empty") leftLineNumber += 1; if (row.rightType !== "empty") rightLineNumber += 1; return nextRow; }); } function emptyDraft(provider) { return provider === "codex" ? { id: "", name: "", provider, payload: { authJsonText: "", configTomlText: "", }, } : { id: "", name: "", provider, payload: { settingsJsonText: "", }, }; } function statusTag(status, statusText) { if (status === "active") return {statusText}; if (status === "drifted") return {statusText}; return {statusText}; } function DiffViewerModal({ open, preset, providerConfig, onClose }) { if (!open || !preset) return null; const isCodex = preset.provider === "codex"; const targetPathByKey = Object.fromEntries( (providerConfig?.targetPaths || []).map((item) => [item.key, item.path]), ); function renderCompareBlock(title, presetText, currentText, targetPath) { const rows = buildAlignedDiffRows(presetText, currentText); return ( {targetPath}
预设配置
{rows.map((row, index) => (
{row.leftLineNumber ?? ""} {row.leftText || " "}
))}
本机当前配置
{rows.map((row, index) => (
{row.rightLineNumber ?? ""} {row.rightText || " "}
))}
); } return ( {isCodex ? ( <> {renderCompareBlock( "auth.json", preset.payload?.authJsonText || "", preset.currentPayload?.authJsonText || "", targetPathByKey.authJsonText || "~/.codex/auth.json", )} {renderCompareBlock( "config.toml", preset.payload?.configTomlText || "", preset.currentPayload?.configTomlText || "", targetPathByKey.configTomlText || "~/.codex/config.toml", )} ) : renderCompareBlock( "settings.json", preset.payload?.settingsJsonText || "", preset.currentPayload?.settingsJsonText || "", targetPathByKey.settingsJsonText || "~/.claude/settings.json", )} ); } function ConfigPresetDrawer({ open, draft, providerConfig, saving, onClose, onSave }) { const [form] = Form.useForm(); const [messageApi, contextHolder] = message.useMessage(); const provider = draft?.provider || providerConfig?.provider || "codex"; useEffect(() => { if (!open || !draft) return; form.setFieldsValue({ name: draft.name || "", authJsonText: draft.payload?.authJsonText || "", configTomlText: draft.payload?.configTomlText || "", settingsJsonText: draft.payload?.settingsJsonText || "", }); }, [draft, form, open]); function formatJsonField(fieldName) { try { const value = form.getFieldValue(fieldName) || ""; const formatted = `${JSON.stringify(JSON.parse(value), null, 2)}\n`; form.setFieldValue(fieldName, formatted); messageApi.success("JSON 已格式化"); } catch { messageApi.error("当前内容不是合法 JSON,无法格式化"); } } function handleSubmit() { form.validateFields().then((values) => { const nextDraft = provider === "codex" ? { ...draft, name: values.name, payload: { authJsonText: values.authJsonText, configTomlText: values.configTomlText, }, } : { ...draft, name: values.name, payload: { settingsJsonText: values.settingsJsonText, }, }; onSave(nextDraft); }); } const targetPathByKey = Object.fromEntries( (providerConfig?.targetPaths || []).map((item) => [item.key, item.path]), ); return ( <> {contextHolder} } >
{provider === "codex" ? ( <> { try { JSON.parse(String(value || "")); } catch { throw new Error("请输入合法 JSON"); } }, }, ]} > ) : ( <> { try { JSON.parse(String(value || "")); } catch { throw new Error("请输入合法 JSON"); } }, }, ]} > )}
); } export default function ConfigSwitchPage({ apiBase }) { const [messageApi, contextHolder] = message.useMessage(); const [modalApi, modalContextHolder] = Modal.useModal(); const [activeProvider, setActiveProvider] = useState("codex"); const [configData, setConfigData] = useState({ providers: {} }); const [loading, setLoading] = useState(true); const [saving, setSaving] = useState(false); const [error, setError] = useState(""); const [actionBusyKey, setActionBusyKey] = useState(""); const [drawerState, setDrawerState] = useState({ open: false, draft: null, }); const [diffViewerState, setDiffViewerState] = useState({ open: false, provider: "", presetId: "", }); const providerConfig = configData.providers?.[activeProvider] || null; const presets = providerConfig?.presets || []; const diffProviderConfig = configData.providers?.[diffViewerState.provider] || null; const diffPreset = diffProviderConfig?.presets?.find((item) => item.id === diffViewerState.presetId) || null; async function loadConfigSwitch(showSuccess = false) { setLoading(true); setError(""); try { const response = await fetch(`${apiBase}/config-switch`); const payload = await response.json(); if (!response.ok) throw new Error(payload.error || "加载配置切换失败"); setConfigData(payload); if (showSuccess) messageApi.success("配置切换列表已刷新"); } catch (nextError) { setError(nextError.message); } finally { setLoading(false); } } useEffect(() => { loadConfigSwitch(); }, [apiBase]); async function persistPreset(nextDraft) { setSaving(true); setError(""); try { const response = await fetch(`${apiBase}/config-switch/${nextDraft.provider}`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(nextDraft), }); const payload = await response.json(); if (!response.ok) throw new Error(payload.error || "保存配置失败"); setConfigData(payload); setDrawerState({ open: false, draft: null }); messageApi.success(nextDraft.id ? "配置已更新" : "配置已新增"); } catch (nextError) { setError(nextError.message); } finally { setSaving(false); } } async function runAction(actionKey, request) { setActionBusyKey(actionKey); setError(""); try { const response = await request(); const payload = await response.json(); if (!response.ok) throw new Error(payload.error || "操作失败"); setConfigData(payload); return payload; } catch (nextError) { setError(nextError.message); throw nextError; } finally { setActionBusyKey(""); } } function openCreate() { setDrawerState({ open: true, draft: emptyDraft(activeProvider), }); } function openEdit(preset) { setDrawerState({ open: true, draft: JSON.parse(JSON.stringify(preset)), }); } function handleDelete(preset) { modalApi.confirm({ title: `删除“${preset.name}”`, content: "删除后不会回滚当前本机文件,只会移除这条预设。", okText: "确认删除", okButtonProps: { danger: true }, cancelText: "取消", onOk: async () => { await runAction(`delete:${preset.id}`, () => fetch(`${apiBase}/config-switch/${preset.provider}/${preset.id}`, { method: "DELETE", }), ); messageApi.success("配置预设已删除"); }, }); } function handleCopy(preset) { runAction(`copy:${preset.id}`, () => fetch(`${apiBase}/config-switch/${preset.provider}/${preset.id}/copy`, { method: "POST", }), ) .then(() => { messageApi.success("配置预设已复制"); }) .catch(() => {}); } function handleActivate(preset) { const fileList = (providerConfig?.targetPaths || []) .map((item) => item.path) .join("\n"); modalApi.confirm({ title: `启用“${preset.name}”`, content: ( 这会直接覆盖以下本机配置文件:
{fileList}
), okText: "确认启用", cancelText: "取消", onOk: async () => { const payload = await runAction(`activate:${preset.id}`, () => fetch(`${apiBase}/config-switch/${preset.provider}/${preset.id}/activate`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ confirmed: true }), }), ); const targetPaths = payload.activation?.targetPaths || []; messageApi.success(`已写入 ${targetPaths.length} 个本机配置文件`); }, }); } function openDiffViewer(preset) { setDiffViewerState({ open: true, provider: preset.provider, presetId: preset.id, }); } const columns = useMemo( () => [ { title: "名称", dataIndex: "name", key: "name", ellipsis: true, }, { title: "目标文件", key: "targetPaths", render: () => (
{(providerConfig?.targetPaths || []).map((item) => (
{item.label}
{item.path}
))}
), }, { title: "状态", dataIndex: "status", key: "status", width: 160, render: (_, record) => ( {statusTag(record.status, record.statusText)} {record.status === "drifted" ? ( ), }, ], [actionBusyKey, providerConfig], ); return ( <> {contextHolder} {modalContextHolder} 配置切换 统一管理 Codex 和 Claude Code 的本机配置预设,支持新增、编辑、复制、删除和启用。 启用时会直接写本机配置文件;状态会根据当前本机文件内容判断是否已偏离。 } > {error ? : null} ({ key: item.key, label: item.label, children: ( ), }))} /> setDrawerState({ open: false, draft: null })} onSave={persistPreset} /> setDiffViewerState({ open: false, provider: "", presetId: "", }) } /> ); }