feat: add verified profile import and export
This commit is contained in:
+441
-1
@@ -1,4 +1,4 @@
|
||||
import { FormEvent, startTransition, useEffect, useState } from "react";
|
||||
import { ChangeEvent, FormEvent, startTransition, useEffect, useState } from "react";
|
||||
|
||||
type PageKey = "overview" | "requests" | "profiles" | "rules" | "logs";
|
||||
type Tone = "" | "success" | "error";
|
||||
@@ -243,6 +243,12 @@ type ProfilesPayload = {
|
||||
listen?: string;
|
||||
upstream_base_url?: string;
|
||||
} | null;
|
||||
imported_profile?: {
|
||||
name?: string;
|
||||
file_path?: string;
|
||||
overwritten?: boolean;
|
||||
} | null;
|
||||
message?: string;
|
||||
profiles?: Profile[];
|
||||
};
|
||||
|
||||
@@ -254,9 +260,29 @@ type ImageProfilesPayload = {
|
||||
hot_swapped?: boolean;
|
||||
image_base_url?: string;
|
||||
} | null;
|
||||
imported_image_profile?: {
|
||||
name?: string;
|
||||
file_path?: string;
|
||||
overwritten?: boolean;
|
||||
} | null;
|
||||
message?: string;
|
||||
image_profiles?: ImageProfile[];
|
||||
};
|
||||
|
||||
type ProfileBundle = {
|
||||
format: "codex-retry-gateway-profile";
|
||||
version: number;
|
||||
kind: "text" | "image";
|
||||
exported_at?: string;
|
||||
source_auth_mode?: string;
|
||||
source_auth_source?: string;
|
||||
profile?: {
|
||||
name?: string;
|
||||
key?: string;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
};
|
||||
|
||||
type LogEntry = {
|
||||
seq: number;
|
||||
at?: string;
|
||||
@@ -332,9 +358,13 @@ const api = {
|
||||
profiles: "/__codex_retry_gateway/api/profiles",
|
||||
profileProbe: "/__codex_retry_gateway/api/profiles/probe",
|
||||
profileSwitch: "/__codex_retry_gateway/api/profiles/switch",
|
||||
profileExport: "/__codex_retry_gateway/api/profiles/export",
|
||||
profileImport: "/__codex_retry_gateway/api/profiles/import",
|
||||
imageProfiles: "/__codex_retry_gateway/api/image-profiles",
|
||||
imageProfileProbe: "/__codex_retry_gateway/api/image-profiles/probe",
|
||||
imageProfileSwitch: "/__codex_retry_gateway/api/image-profiles/switch",
|
||||
imageProfileExport: "/__codex_retry_gateway/api/image-profiles/export",
|
||||
imageProfileImport: "/__codex_retry_gateway/api/image-profiles/import",
|
||||
restore: "/__codex_retry_gateway/api/restore",
|
||||
};
|
||||
|
||||
@@ -689,6 +719,38 @@ async function fetchJson<T>(url: string, options?: RequestInit): Promise<T> {
|
||||
return payload as T;
|
||||
}
|
||||
|
||||
function downloadJsonFile(payload: unknown, filename: string) {
|
||||
const blob = new Blob([`${JSON.stringify(payload, null, 2)}\n`], {
|
||||
type: "application/json;charset=utf-8",
|
||||
});
|
||||
const objectUrl = URL.createObjectURL(blob);
|
||||
const anchor = document.createElement("a");
|
||||
anchor.href = objectUrl;
|
||||
anchor.download = filename;
|
||||
anchor.rel = "noopener";
|
||||
document.body.appendChild(anchor);
|
||||
anchor.click();
|
||||
anchor.remove();
|
||||
URL.revokeObjectURL(objectUrl);
|
||||
}
|
||||
|
||||
function validateProfileBundleFile(payload: unknown, expectedKind: "text" | "image"): ProfileBundle {
|
||||
if (!payload || typeof payload !== "object" || Array.isArray(payload)) {
|
||||
throw new Error("文件内容不是有效的 profile bundle");
|
||||
}
|
||||
const bundle = payload as ProfileBundle;
|
||||
if (bundle.format !== "codex-retry-gateway-profile" || bundle.version !== 1) {
|
||||
throw new Error("不支持的 profile 导出文件格式或版本");
|
||||
}
|
||||
if (bundle.kind !== expectedKind) {
|
||||
throw new Error(expectedKind === "text" ? "请选择文本 profile 导出文件" : "请选择图片 profile 导出文件");
|
||||
}
|
||||
if (!bundle.profile?.name || !bundle.profile?.key) {
|
||||
throw new Error("导出文件缺少 profile 名称或 API key");
|
||||
}
|
||||
return bundle;
|
||||
}
|
||||
|
||||
function profileFormFromStatus(status: StatusPayload | null): ProfileFormState {
|
||||
const config = status?.config || {};
|
||||
return {
|
||||
@@ -847,8 +909,22 @@ export default function App() {
|
||||
const [threadRuleMessage, setThreadRuleMessage] = useState<MessageState>({ text: "", tone: "" });
|
||||
const [profileMessage, setProfileMessage] = useState<MessageState>({ text: "", tone: "" });
|
||||
const [profileProbeResult, setProfileProbeResult] = useState<ProfileProbePayload | null>(null);
|
||||
const [profileTransferMessage, setProfileTransferMessage] = useState<MessageState>({ text: "", tone: "" });
|
||||
const [profileExportTarget, setProfileExportTarget] = useState<Profile | null>(null);
|
||||
const [profileExportKey, setProfileExportKey] = useState("");
|
||||
const [exportingProfile, setExportingProfile] = useState("");
|
||||
const [profileImportBundle, setProfileImportBundle] = useState<ProfileBundle | null>(null);
|
||||
const [profileImportName, setProfileImportName] = useState("");
|
||||
const [profileImportOverwrite, setProfileImportOverwrite] = useState(false);
|
||||
const [imageProfileMessage, setImageProfileMessage] = useState<MessageState>({ text: "", tone: "" });
|
||||
const [imageProfileProbeResult, setImageProfileProbeResult] = useState<ProfileProbePayload | null>(null);
|
||||
const [imageProfileTransferMessage, setImageProfileTransferMessage] = useState<MessageState>({ text: "", tone: "" });
|
||||
const [imageProfileExportTarget, setImageProfileExportTarget] = useState<ImageProfile | null>(null);
|
||||
const [imageProfileExportKey, setImageProfileExportKey] = useState("");
|
||||
const [exportingImageProfile, setExportingImageProfile] = useState("");
|
||||
const [imageProfileImportBundle, setImageProfileImportBundle] = useState<ProfileBundle | null>(null);
|
||||
const [imageProfileImportName, setImageProfileImportName] = useState("");
|
||||
const [imageProfileImportOverwrite, setImageProfileImportOverwrite] = useState(false);
|
||||
const [probingProfile, setProbingProfile] = useState("");
|
||||
const [deletingProfile, setDeletingProfile] = useState("");
|
||||
const [switchingTo, setSwitchingTo] = useState("");
|
||||
@@ -1297,6 +1373,103 @@ export default function App() {
|
||||
}
|
||||
}
|
||||
|
||||
function prepareProfileExport(profile: Profile) {
|
||||
setProfileExportTarget(profile);
|
||||
setProfileExportKey("");
|
||||
setProfileTransferMessage({
|
||||
text: `准备导出文本 profile ${profile.name};请再次输入它当前实际使用的上游 API key。`,
|
||||
tone: "",
|
||||
});
|
||||
}
|
||||
|
||||
async function exportProfileBundle(event: FormEvent) {
|
||||
event.preventDefault();
|
||||
if (!profileExportTarget) {
|
||||
setProfileTransferMessage({ text: "请先从文本 profile 列表选择要导出的项目。", tone: "error" });
|
||||
return;
|
||||
}
|
||||
setExportingProfile(profileExportTarget.name);
|
||||
setProfileTransferMessage({ text: `正在验证并导出 ${profileExportTarget.name}...`, tone: "" });
|
||||
try {
|
||||
const bundle = await fetchJson<ProfileBundle>(api.profileExport, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
profile: profileExportTarget.name,
|
||||
verification_key: profileExportKey,
|
||||
}),
|
||||
});
|
||||
downloadJsonFile(bundle, `codex-retry-gateway-${profileExportTarget.name}.profile.json`);
|
||||
setProfileTransferMessage({
|
||||
text: `文本 profile ${profileExportTarget.name} 已导出。文件包含明文 API key,请按 secret 文件保管。`,
|
||||
tone: "success",
|
||||
});
|
||||
} catch (error) {
|
||||
setProfileTransferMessage({ text: error instanceof Error ? error.message : String(error), tone: "error" });
|
||||
} finally {
|
||||
setProfileExportKey("");
|
||||
setExportingProfile("");
|
||||
}
|
||||
}
|
||||
|
||||
async function selectProfileImportFile(event: ChangeEvent<HTMLInputElement>) {
|
||||
const file = event.target.files?.[0];
|
||||
if (!file) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
if (file.size > 512 * 1024) {
|
||||
throw new Error("profile 导出文件不能超过 512 KiB");
|
||||
}
|
||||
const parsed = JSON.parse(await file.text());
|
||||
const bundle = validateProfileBundleFile(parsed, "text");
|
||||
setProfileImportBundle(bundle);
|
||||
setProfileImportName(bundle.profile?.name || "");
|
||||
setProfileImportOverwrite(false);
|
||||
setProfileTransferMessage({
|
||||
text: `已载入文本 profile ${bundle.profile?.name || "-"};导入不会自动切换当前 gateway。`,
|
||||
tone: "",
|
||||
});
|
||||
} catch (error) {
|
||||
setProfileImportBundle(null);
|
||||
setProfileImportName("");
|
||||
setProfileTransferMessage({ text: error instanceof Error ? error.message : String(error), tone: "error" });
|
||||
} finally {
|
||||
event.target.value = "";
|
||||
}
|
||||
}
|
||||
|
||||
async function importProfileBundle(event: FormEvent) {
|
||||
event.preventDefault();
|
||||
if (!profileImportBundle) {
|
||||
setProfileTransferMessage({ text: "请先选择文本 profile 导出文件。", tone: "error" });
|
||||
return;
|
||||
}
|
||||
setProfileTransferMessage({ text: `正在导入文本 profile ${profileImportName || "-"}...`, tone: "" });
|
||||
try {
|
||||
const payload = await fetchJson<ProfilesPayload>(api.profileImport, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
bundle: profileImportBundle,
|
||||
name: profileImportName,
|
||||
overwrite: profileImportOverwrite,
|
||||
}),
|
||||
});
|
||||
setProfiles(payload.profiles || []);
|
||||
setProfilesMeta(`目录:${payload.profiles_dir || "-"};当前运行:${payload.active_profile || "-"}。`);
|
||||
setProfileImportBundle(null);
|
||||
setProfileImportName("");
|
||||
setProfileImportOverwrite(false);
|
||||
setProfileTransferMessage({
|
||||
text: payload.message || "文本 profile 已导入;请先探针验证,再手动切换。",
|
||||
tone: "success",
|
||||
});
|
||||
} catch (error) {
|
||||
setProfileTransferMessage({ text: error instanceof Error ? error.message : String(error), tone: "error" });
|
||||
}
|
||||
}
|
||||
|
||||
async function saveImageProfile(event: FormEvent) {
|
||||
event.preventDefault();
|
||||
setImageProfileMessage({ text: "正在保存图片 profile...", tone: "" });
|
||||
@@ -1409,6 +1582,103 @@ export default function App() {
|
||||
}
|
||||
}
|
||||
|
||||
function prepareImageProfileExport(profile: ImageProfile) {
|
||||
setImageProfileExportTarget(profile);
|
||||
setImageProfileExportKey("");
|
||||
setImageProfileTransferMessage({
|
||||
text: `准备导出图片 profile ${profile.name};请再次输入它当前实际使用的图片 API key。`,
|
||||
tone: "",
|
||||
});
|
||||
}
|
||||
|
||||
async function exportImageProfileBundle(event: FormEvent) {
|
||||
event.preventDefault();
|
||||
if (!imageProfileExportTarget) {
|
||||
setImageProfileTransferMessage({ text: "请先从图片 profile 列表选择要导出的项目。", tone: "error" });
|
||||
return;
|
||||
}
|
||||
setExportingImageProfile(imageProfileExportTarget.name);
|
||||
setImageProfileTransferMessage({ text: `正在验证并导出 ${imageProfileExportTarget.name}...`, tone: "" });
|
||||
try {
|
||||
const bundle = await fetchJson<ProfileBundle>(api.imageProfileExport, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
profile: imageProfileExportTarget.name,
|
||||
verification_key: imageProfileExportKey,
|
||||
}),
|
||||
});
|
||||
downloadJsonFile(bundle, `codex-retry-gateway-${imageProfileExportTarget.name}.image-profile.json`);
|
||||
setImageProfileTransferMessage({
|
||||
text: `图片 profile ${imageProfileExportTarget.name} 已导出。文件包含明文 API key,请按 secret 文件保管。`,
|
||||
tone: "success",
|
||||
});
|
||||
} catch (error) {
|
||||
setImageProfileTransferMessage({ text: error instanceof Error ? error.message : String(error), tone: "error" });
|
||||
} finally {
|
||||
setImageProfileExportKey("");
|
||||
setExportingImageProfile("");
|
||||
}
|
||||
}
|
||||
|
||||
async function selectImageProfileImportFile(event: ChangeEvent<HTMLInputElement>) {
|
||||
const file = event.target.files?.[0];
|
||||
if (!file) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
if (file.size > 512 * 1024) {
|
||||
throw new Error("图片 profile 导出文件不能超过 512 KiB");
|
||||
}
|
||||
const parsed = JSON.parse(await file.text());
|
||||
const bundle = validateProfileBundleFile(parsed, "image");
|
||||
setImageProfileImportBundle(bundle);
|
||||
setImageProfileImportName(bundle.profile?.name || "");
|
||||
setImageProfileImportOverwrite(false);
|
||||
setImageProfileTransferMessage({
|
||||
text: `已载入图片 profile ${bundle.profile?.name || "-"};导入不会自动切换当前 gateway。`,
|
||||
tone: "",
|
||||
});
|
||||
} catch (error) {
|
||||
setImageProfileImportBundle(null);
|
||||
setImageProfileImportName("");
|
||||
setImageProfileTransferMessage({ text: error instanceof Error ? error.message : String(error), tone: "error" });
|
||||
} finally {
|
||||
event.target.value = "";
|
||||
}
|
||||
}
|
||||
|
||||
async function importImageProfileBundle(event: FormEvent) {
|
||||
event.preventDefault();
|
||||
if (!imageProfileImportBundle) {
|
||||
setImageProfileTransferMessage({ text: "请先选择图片 profile 导出文件。", tone: "error" });
|
||||
return;
|
||||
}
|
||||
setImageProfileTransferMessage({ text: `正在导入图片 profile ${imageProfileImportName || "-"}...`, tone: "" });
|
||||
try {
|
||||
const payload = await fetchJson<ImageProfilesPayload>(api.imageProfileImport, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
bundle: imageProfileImportBundle,
|
||||
name: imageProfileImportName,
|
||||
overwrite: imageProfileImportOverwrite,
|
||||
}),
|
||||
});
|
||||
setImageProfiles(payload.image_profiles || []);
|
||||
setImageProfilesMeta(`目录:${payload.image_profiles_dir || "-"};当前运行:${payload.active_image_profile || "未配置"}。`);
|
||||
setImageProfileImportBundle(null);
|
||||
setImageProfileImportName("");
|
||||
setImageProfileImportOverwrite(false);
|
||||
setImageProfileTransferMessage({
|
||||
text: payload.message || "图片 profile 已导入;请先探针验证,再手动切换。",
|
||||
tone: "success",
|
||||
});
|
||||
} catch (error) {
|
||||
setImageProfileTransferMessage({ text: error instanceof Error ? error.message : String(error), tone: "error" });
|
||||
}
|
||||
}
|
||||
|
||||
function waitForProfile(profileName: string) {
|
||||
const deadline = Date.now() + 12000;
|
||||
const tick = async () => {
|
||||
@@ -1901,6 +2171,9 @@ export default function App() {
|
||||
<button className="ghost" type="button" onClick={() => editProfile(profile)}>
|
||||
编辑
|
||||
</button>
|
||||
<button className="ghost" type="button" onClick={() => prepareProfileExport(profile)}>
|
||||
导出
|
||||
</button>
|
||||
<button className="ghost" type="button" disabled={probingProfile === profile.name} onClick={() => probeProfile(profile)}>
|
||||
{probingProfile === profile.name ? "探测中" : "探针"}
|
||||
</button>
|
||||
@@ -1936,6 +2209,88 @@ export default function App() {
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card
|
||||
title="文本 Profile 导入 / 导出"
|
||||
hint="导出前必须再次验证当前上游 API key;导入只保存为本机 manual_bearer secret,不会自动切换当前实例。"
|
||||
>
|
||||
<div className="grid transfer-grid">
|
||||
<article className="profile-card transfer-pane">
|
||||
<div className="profile-head">
|
||||
<div>
|
||||
<h3>导出文本 Profile</h3>
|
||||
<div className="hint">下载文件包含明文 API key,以及该 profile 自身配置的管理 Access key。</div>
|
||||
</div>
|
||||
{profileExportTarget ? <span className="badge">{profileExportTarget.name}</span> : null}
|
||||
</div>
|
||||
<form onSubmit={exportProfileBundle}>
|
||||
<Field label="待导出 Profile">
|
||||
<input value={profileExportTarget?.name || ""} placeholder="先点击列表中的“导出”" readOnly />
|
||||
</Field>
|
||||
<Field label="再次验证当前 API Key" hint="必须与该 profile 当前 secret/env/auth.json 实际解析出的 key 一致。">
|
||||
<input
|
||||
type="password"
|
||||
value={profileExportKey}
|
||||
autoComplete="off"
|
||||
placeholder="重新输入当前上游 API key"
|
||||
onChange={(event) => setProfileExportKey(event.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
<div className="toolbar">
|
||||
<button className="primary" type="submit" disabled={!profileExportTarget || Boolean(exportingProfile)}>
|
||||
{exportingProfile ? "验证中" : "验证并导出"}
|
||||
</button>
|
||||
<button
|
||||
className="ghost"
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setProfileExportTarget(null);
|
||||
setProfileExportKey("");
|
||||
}}
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</article>
|
||||
|
||||
<article className="profile-card transfer-pane">
|
||||
<div className="profile-head">
|
||||
<div>
|
||||
<h3>导入文本 Profile</h3>
|
||||
<div className="hint">API key 会写入本机 `0600` secret 文件,profile env 只保存文件路径。</div>
|
||||
</div>
|
||||
{profileImportBundle ? <span className="badge success">文件已载入</span> : null}
|
||||
</div>
|
||||
<form onSubmit={importProfileBundle}>
|
||||
<Field label="Profile JSON 文件">
|
||||
<input type="file" accept=".json,application/json" onChange={selectProfileImportFile} />
|
||||
</Field>
|
||||
<Field label="导入名称" hint="可以改名导入;不能直接覆盖当前运行的 profile。">
|
||||
<input
|
||||
value={profileImportName}
|
||||
placeholder="导出文件中的 profile 名称"
|
||||
onChange={(event) => setProfileImportName(event.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
<label className="inline-toggle">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={profileImportOverwrite}
|
||||
onChange={(event) => setProfileImportOverwrite(event.target.checked)}
|
||||
/>
|
||||
<span>允许覆盖同名的非活跃文本 profile</span>
|
||||
</label>
|
||||
<div className="toolbar">
|
||||
<button className="primary" type="submit" disabled={!profileImportBundle}>
|
||||
导入并保存
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</article>
|
||||
</div>
|
||||
<Message message={profileTransferMessage} />
|
||||
</Card>
|
||||
|
||||
<Card title={profileForm.name ? `编辑文本 ${profileForm.name}` : "编辑文本 Profile"} hint="手动 token/password 只能写入系统 secret 文件,保存后不会从后端读回或展示。">
|
||||
<form onSubmit={saveProfile}>
|
||||
<Field label="Profile 名称">
|
||||
@@ -2115,6 +2470,9 @@ export default function App() {
|
||||
<button className="ghost" type="button" onClick={() => editImageProfile(profile)}>
|
||||
编辑
|
||||
</button>
|
||||
<button className="ghost" type="button" onClick={() => prepareImageProfileExport(profile)}>
|
||||
导出
|
||||
</button>
|
||||
<button className="ghost" type="button" disabled={probingImageProfile === profile.name} onClick={() => probeImageProfile(profile)}>
|
||||
{probingImageProfile === profile.name ? "探测中" : "探针"}
|
||||
</button>
|
||||
@@ -2136,6 +2494,88 @@ export default function App() {
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card
|
||||
title="图片 Profile 导入 / 导出"
|
||||
hint="导出前必须再次验证当前图片 API key;导入后只保存,不自动改变 /images/* 当前上游。"
|
||||
>
|
||||
<div className="grid transfer-grid">
|
||||
<article className="profile-card transfer-pane">
|
||||
<div className="profile-head">
|
||||
<div>
|
||||
<h3>导出图片 Profile</h3>
|
||||
<div className="hint">下载文件包含明文图片 API key,请按 secret 文件保管。</div>
|
||||
</div>
|
||||
{imageProfileExportTarget ? <span className="badge">{imageProfileExportTarget.name}</span> : null}
|
||||
</div>
|
||||
<form onSubmit={exportImageProfileBundle}>
|
||||
<Field label="待导出图片 Profile">
|
||||
<input value={imageProfileExportTarget?.name || ""} placeholder="先点击列表中的“导出”" readOnly />
|
||||
</Field>
|
||||
<Field label="再次验证当前图片 API Key">
|
||||
<input
|
||||
type="password"
|
||||
value={imageProfileExportKey}
|
||||
autoComplete="off"
|
||||
placeholder="重新输入当前图片 API key"
|
||||
onChange={(event) => setImageProfileExportKey(event.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
<div className="toolbar">
|
||||
<button className="primary" type="submit" disabled={!imageProfileExportTarget || Boolean(exportingImageProfile)}>
|
||||
{exportingImageProfile ? "验证中" : "验证并导出"}
|
||||
</button>
|
||||
<button
|
||||
className="ghost"
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setImageProfileExportTarget(null);
|
||||
setImageProfileExportKey("");
|
||||
}}
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</article>
|
||||
|
||||
<article className="profile-card transfer-pane">
|
||||
<div className="profile-head">
|
||||
<div>
|
||||
<h3>导入图片 Profile</h3>
|
||||
<div className="hint">图片 key 会写入本机 `0600` secret 文件,导入后请先探针验证。</div>
|
||||
</div>
|
||||
{imageProfileImportBundle ? <span className="badge success">文件已载入</span> : null}
|
||||
</div>
|
||||
<form onSubmit={importImageProfileBundle}>
|
||||
<Field label="图片 Profile JSON 文件">
|
||||
<input type="file" accept=".json,application/json" onChange={selectImageProfileImportFile} />
|
||||
</Field>
|
||||
<Field label="导入名称" hint="可以改名导入;不能直接覆盖当前运行的图片 profile。">
|
||||
<input
|
||||
value={imageProfileImportName}
|
||||
placeholder="导出文件中的图片 profile 名称"
|
||||
onChange={(event) => setImageProfileImportName(event.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
<label className="inline-toggle">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={imageProfileImportOverwrite}
|
||||
onChange={(event) => setImageProfileImportOverwrite(event.target.checked)}
|
||||
/>
|
||||
<span>允许覆盖同名的非活跃图片 profile</span>
|
||||
</label>
|
||||
<div className="toolbar">
|
||||
<button className="primary" type="submit" disabled={!imageProfileImportBundle}>
|
||||
导入并保存
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</article>
|
||||
</div>
|
||||
<Message message={imageProfileTransferMessage} />
|
||||
</Card>
|
||||
|
||||
<Card title={imageProfileForm.name ? `编辑图片 ${imageProfileForm.name}` : "编辑图片 Profile"} hint="图片 API key 只写入系统 secret 文件,保存后不会从后端读回或展示。">
|
||||
<form onSubmit={saveImageProfile}>
|
||||
<Field label="图片 Profile 名称">
|
||||
|
||||
@@ -804,6 +804,15 @@ tr:last-child td {
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.transfer-grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.transfer-pane {
|
||||
align-content: start;
|
||||
}
|
||||
|
||||
.compact-profile-card {
|
||||
gap: 8px;
|
||||
}
|
||||
@@ -1042,6 +1051,7 @@ form {
|
||||
.token-strip,
|
||||
.mini-stats,
|
||||
.field-row,
|
||||
.transfer-grid,
|
||||
.nav {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user