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) => (
))}
),
},
{
title: "状态",
dataIndex: "status",
key: "status",
width: 160,
render: (_, record) => (
{statusTag(record.status, record.statusText)}
{record.status === "drifted" ? (
}
onClick={() => openDiffViewer(record)}
title="查看当前配置与本机配置差异"
/>
) : null}
),
},
{
title: "最近更新",
dataIndex: "updatedAt",
key: "updatedAt",
width: 180,
render: (value) => formatTime(value),
},
{
title: "操作",
key: "actions",
width: 280,
fixed: "right",
render: (_, record) => (
}
loading={actionBusyKey === `activate:${record.id}`}
onClick={() => handleActivate(record)}
>
启用
}
loading={actionBusyKey === `copy:${record.id}`}
onClick={() => handleCopy(record)}
>
复制
} onClick={() => openEdit(record)}>
编辑
} onClick={() => handleDelete(record)}>
删除
),
},
],
[actionBusyKey, providerConfig],
);
return (
<>
{contextHolder}
{modalContextHolder}
配置切换
统一管理 Codex 和 Claude Code 的本机配置预设,支持新增、编辑、复制、删除和启用。
启用时会直接写本机配置文件;状态会根据当前本机文件内容判断是否已偏离。
} onClick={() => loadConfigSwitch(true)} loading={loading}>
重新加载
} onClick={openCreate}>
新增
}
>
{error ? : null}
({
key: item.key,
label: item.label,
children: (
),
}))}
/>
setDrawerState({ open: false, draft: null })}
onSave={persistPreset}
/>
setDiffViewerState({
open: false,
provider: "",
presetId: "",
})
}
/>
>
);
}