AI_PROJECT / src /ui-app /pages /ConfigSwitchPage.jsx
chenchenaoyang's picture
Deploy from Codex
f395b70 verified
Raw
History Blame Contribute Delete
23.2 kB
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 <Tag color="success">{statusText}</Tag>;
if (status === "drifted") return <Tag color="warning">{statusText}</Tag>;
return <Tag>{statusText}</Tag>;
}
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 (
<Card title={title} bordered={false}>
<Space direction="vertical" size={16} style={{ width: "100%" }}>
<Text type="secondary">{targetPath}</Text>
<div className="config-switch-diff-grid">
<div>
<div className="config-switch-diff-title">预设配置</div>
<div className="config-switch-diff-box">
{rows.map((row, index) => (
<div
key={`${title}-left-${index}`}
className={`config-switch-diff-line config-switch-diff-line-${row.leftType}`}
>
<span className="config-switch-diff-line-number">
{row.leftLineNumber ?? ""}
</span>
<span className="config-switch-diff-line-text">
{row.leftText || " "}
</span>
</div>
))}
</div>
</div>
<div>
<div className="config-switch-diff-title">本机当前配置</div>
<div className="config-switch-diff-box">
{rows.map((row, index) => (
<div
key={`${title}-right-${index}`}
className={`config-switch-diff-line config-switch-diff-line-${row.rightType}`}
>
<span className="config-switch-diff-line-number">
{row.rightLineNumber ?? ""}
</span>
<span className="config-switch-diff-line-text">
{row.rightText || " "}
</span>
</div>
))}
</div>
</div>
</div>
</Space>
</Card>
);
}
return (
<Modal
open={open}
title={`查看差异 · ${preset.name}`}
onCancel={onClose}
footer={null}
width={1180}
destroyOnClose
>
<Space direction="vertical" size={16} style={{ width: "100%" }}>
<Alert
type="warning"
showIcon
message="当前本机文件内容和这条已启用预设不一致,下面展示预设内容与本机当前内容的并排对比。"
/>
{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",
)}
</Space>
</Modal>
);
}
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}
<Drawer
open={open}
width={provider === "codex" ? 920 : 820}
destroyOnClose
onClose={onClose}
title={draft?.id ? `编辑${provider === "codex" ? " Codex" : " Claude Code"}配置` : `新增${provider === "codex" ? " Codex" : " Claude Code"}配置`}
extra={
<Space>
<Button onClick={onClose}>取消</Button>
<Button type="primary" loading={saving} onClick={handleSubmit}>
保存
</Button>
</Space>
}
>
<Form form={form} layout="vertical">
<Form.Item
label="名称"
name="name"
rules={[{ required: true, message: "请输入名称" }]}
>
<Input placeholder="例如:HF 代理 / 备用节点 / 本地直连" />
</Form.Item>
{provider === "codex" ? (
<>
<Form.Item
label="auth.json (JSON)"
extra={targetPathByKey.authJsonText || "~/.codex/auth.json"}
name="authJsonText"
rules={[
{ required: true, message: "请输入 auth.json 内容" },
{
validator: async (_, value) => {
try {
JSON.parse(String(value || ""));
} catch {
throw new Error("请输入合法 JSON");
}
},
},
]}
>
<Input.TextArea
className="config-switch-codearea"
autoSize={{ minRows: 10, maxRows: 18 }}
placeholder={`{\n "OPENAI_API_KEY": "sk-xxx"\n}`}
/>
</Form.Item>
<Button
type="text"
className="config-switch-format-button"
onClick={() => formatJsonField("authJsonText")}
>
格式化
</Button>
<Form.Item
label="config.toml (TOML)"
extra={targetPathByKey.configTomlText || "~/.codex/config.toml"}
name="configTomlText"
rules={[{ required: true, message: "请输入 config.toml 内容" }]}
>
<Input.TextArea
className="config-switch-codearea"
autoSize={{ minRows: 14, maxRows: 24 }}
placeholder={'model_provider = "OpenAI"\nmodel = "gpt-5.4"'}
/>
</Form.Item>
</>
) : (
<>
<Form.Item
label="settings.json (JSON)"
extra={targetPathByKey.settingsJsonText || "~/.claude/settings.json"}
name="settingsJsonText"
rules={[
{ required: true, message: "请输入 settings.json 内容" },
{
validator: async (_, value) => {
try {
JSON.parse(String(value || ""));
} catch {
throw new Error("请输入合法 JSON");
}
},
},
]}
>
<Input.TextArea
className="config-switch-codearea"
autoSize={{ minRows: 18, maxRows: 26 }}
placeholder={`{\n "env": {\n "ANTHROPIC_AUTH_TOKEN": "sk-xxx"\n }\n}`}
/>
</Form.Item>
<Button
type="text"
className="config-switch-format-button"
onClick={() => formatJsonField("settingsJsonText")}
>
格式化
</Button>
</>
)}
</Form>
</Drawer>
</>
);
}
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: (
<Space direction="vertical" size={8}>
<Text>这会直接覆盖以下本机配置文件:</Text>
<pre className="config-switch-confirm-paths">{fileList}</pre>
</Space>
),
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: () => (
<div className="config-switch-targets">
{(providerConfig?.targetPaths || []).map((item) => (
<div key={item.key}>
<div className="config-switch-target-label">{item.label}</div>
<div className="config-switch-target-path">{item.path}</div>
</div>
))}
</div>
),
},
{
title: "状态",
dataIndex: "status",
key: "status",
width: 160,
render: (_, record) => (
<Space size={6}>
{statusTag(record.status, record.statusText)}
{record.status === "drifted" ? (
<Button
type="text"
size="small"
icon={<EyeOutlined />}
onClick={() => openDiffViewer(record)}
title="查看当前配置与本机配置差异"
/>
) : null}
</Space>
),
},
{
title: "最近更新",
dataIndex: "updatedAt",
key: "updatedAt",
width: 180,
render: (value) => formatTime(value),
},
{
title: "操作",
key: "actions",
width: 280,
fixed: "right",
render: (_, record) => (
<Space wrap>
<Button
size="small"
type={record.status === "active" ? "default" : "primary"}
icon={<PoweroffOutlined />}
loading={actionBusyKey === `activate:${record.id}`}
onClick={() => handleActivate(record)}
>
启用
</Button>
<Button
size="small"
icon={<CopyOutlined />}
loading={actionBusyKey === `copy:${record.id}`}
onClick={() => handleCopy(record)}
>
复制
</Button>
<Button size="small" icon={<EditOutlined />} onClick={() => openEdit(record)}>
编辑
</Button>
<Button size="small" danger icon={<DeleteOutlined />} onClick={() => handleDelete(record)}>
删除
</Button>
</Space>
),
},
],
[actionBusyKey, providerConfig],
);
return (
<>
{contextHolder}
{modalContextHolder}
<Space direction="vertical" size={20} style={{ width: "100%" }}>
<Card className="dashboard-hero-card" bordered={false}>
<Title level={2} style={{ marginTop: 0 }}>配置切换</Title>
<Paragraph>
统一管理 Codex 和 Claude Code 的本机配置预设,支持新增、编辑、复制、删除和启用。
</Paragraph>
<Text type="secondary">
启用时会直接写本机配置文件;状态会根据当前本机文件内容判断是否已偏离。
</Text>
</Card>
<Card
bordered={false}
extra={
<Space wrap>
<Button icon={<ReloadOutlined />} onClick={() => loadConfigSwitch(true)} loading={loading}>
重新加载
</Button>
<Button type="primary" icon={<PlusOutlined />} onClick={openCreate}>
新增
</Button>
</Space>
}
>
{error ? <Alert type="error" showIcon message={error} style={{ marginBottom: 16 }} /> : null}
<Tabs
activeKey={activeProvider}
onChange={setActiveProvider}
items={PROVIDER_TABS.map((item) => ({
key: item.key,
label: item.label,
children: (
<Table
rowKey="id"
loading={loading}
columns={columns}
dataSource={presets}
pagination={{ pageSize: 8 }}
scroll={{ x: 1180 }}
locale={{ emptyText: "当前还没有配置预设。" }}
/>
),
}))}
/>
</Card>
</Space>
<ConfigPresetDrawer
open={drawerState.open}
draft={drawerState.draft}
providerConfig={configData.providers?.[drawerState.draft?.provider || activeProvider] || providerConfig}
saving={saving}
onClose={() => setDrawerState({ open: false, draft: null })}
onSave={persistPreset}
/>
<DiffViewerModal
open={diffViewerState.open}
preset={diffPreset}
providerConfig={diffProviderConfig}
onClose={() =>
setDiffViewerState({
open: false,
provider: "",
presetId: "",
})
}
/>
</>
);
}