feat(renderer): rewrite providers UI on pi-mono catalog shape
Onboarding ProvidersStep now reads the 26-entry CONNECTOR_CATALOG and renders featured connectors (claude-pro, openai-codex, anthropic, openai, amazon-bedrock) up front with a collapsible advanced list for the remaining 21. Each auth kind gets a dedicated card component: OAuth button flow, inline API-key form, and a cloud deep-link to Settings. Delete legacy Claude/Codex/Gemini hardcoded cards and the compatible-API slot rotation. Swap ModelManagement Settings panel for the new ProvidersPanel driven by credentialsList IPC.
This commit is contained in:
1 parent
6a25379995
commit
7fdd3c16bf
17 files changed
+692
-1422
No files matched your search
@@ -1,328 +0,0 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { ArrowSquareOut, FloppyDisk, Plus, Trash } from "../ui/icons";
|
||||
import type { ConnectorRecord } from "@shared/types";
|
||||
import { unwrap } from "../../lib/desktop";
|
||||
import {
|
||||
COMPATIBLE_PROVIDERS,
|
||||
INPUT_CLS,
|
||||
ModelToggleList,
|
||||
ProviderCard,
|
||||
StatusBadge,
|
||||
parseEnvValue,
|
||||
type ModelEntry,
|
||||
} from "./ModelManagementShared";
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Compatible provider card */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
export function CompatProviderCard({
|
||||
slotId,
|
||||
connector,
|
||||
zh,
|
||||
onSaved,
|
||||
expanded,
|
||||
onToggle,
|
||||
onCleared,
|
||||
}: {
|
||||
slotId: string;
|
||||
connector: ConnectorRecord | null;
|
||||
zh: boolean;
|
||||
onSaved: () => Promise<void>;
|
||||
expanded: boolean;
|
||||
onToggle: () => void;
|
||||
onCleared?: () => void;
|
||||
}) {
|
||||
const [selectedProvider, setSelectedProvider] = useState<string>(() => {
|
||||
const saved = connector?.notes ?? "";
|
||||
const match = COMPATIBLE_PROVIDERS.find((p) => p.label === saved);
|
||||
return match?.id ?? COMPATIBLE_PROVIDERS[0].id;
|
||||
});
|
||||
|
||||
const [apiKey, setApiKey] = useState(() =>
|
||||
parseEnvValue(connector?.envBindingText ?? "", "ANTHROPIC_API_KEY"),
|
||||
);
|
||||
const [baseUrl, setBaseUrl] = useState<string>(
|
||||
() =>
|
||||
parseEnvValue(connector?.envBindingText ?? "", "ANTHROPIC_BASE_URL") ||
|
||||
COMPATIBLE_PROVIDERS[0].baseUrl,
|
||||
);
|
||||
const [models, setModels] = useState<ModelEntry[]>([]);
|
||||
const [newModel, setNewModel] = useState("");
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const status = connector?.status ?? "not_installed";
|
||||
const providerLabel =
|
||||
COMPATIBLE_PROVIDERS.find((p) => p.id === selectedProvider)?.label ??
|
||||
"Compatible API";
|
||||
const displayTitle = connector?.notes || providerLabel;
|
||||
|
||||
// Sync form state when connector changes externally (e.g. after save + refresh)
|
||||
useEffect(() => {
|
||||
setApiKey(parseEnvValue(connector?.envBindingText ?? "", "ANTHROPIC_API_KEY"));
|
||||
const savedBase = parseEnvValue(connector?.envBindingText ?? "", "ANTHROPIC_BASE_URL");
|
||||
if (savedBase) setBaseUrl(savedBase);
|
||||
const saved = connector?.notes ?? "";
|
||||
const match = COMPATIBLE_PROVIDERS.find((p) => p.label === saved);
|
||||
if (match) setSelectedProvider(match.id);
|
||||
}, [connector?.envBindingText, connector?.notes]);
|
||||
|
||||
const loadModels = useCallback(async () => {
|
||||
if (!connector) {
|
||||
setModels([]);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const raw = connector.model ?? "";
|
||||
const allModelIds = raw
|
||||
.split(",")
|
||||
.map((m) => m.trim())
|
||||
.filter(Boolean);
|
||||
const disabledResult = await window.agentCompany.getConnectorDisabledModels(
|
||||
connector.id,
|
||||
);
|
||||
const disabledSet = new Set(
|
||||
(disabledResult.ok ? disabledResult.data : []).map((m) => m.id),
|
||||
);
|
||||
setModels(
|
||||
allModelIds.map((id) => ({
|
||||
id,
|
||||
label: id,
|
||||
enabled: !disabledSet.has(id),
|
||||
})),
|
||||
);
|
||||
} catch {
|
||||
/* non-fatal */
|
||||
}
|
||||
}, [connector]);
|
||||
|
||||
useEffect(() => {
|
||||
void loadModels();
|
||||
}, [loadModels]);
|
||||
|
||||
function handleProviderChange(providerId: string) {
|
||||
setSelectedProvider(providerId);
|
||||
const provider = COMPATIBLE_PROVIDERS.find((p) => p.id === providerId);
|
||||
if (provider) setBaseUrl(provider.baseUrl);
|
||||
}
|
||||
|
||||
function addModel() {
|
||||
const trimmed = newModel.trim();
|
||||
if (!trimmed || models.some((m) => m.id === trimmed)) return;
|
||||
setModels((prev) => [...prev, { id: trimmed, label: trimmed, enabled: true }]);
|
||||
setNewModel("");
|
||||
}
|
||||
|
||||
function removeModel(modelId: string) {
|
||||
setModels((prev) => prev.filter((m) => m.id !== modelId));
|
||||
}
|
||||
|
||||
async function handleToggleModel(modelId: string) {
|
||||
if (!connector) return;
|
||||
const prev = models;
|
||||
const updated = models.map((m) =>
|
||||
m.id === modelId ? { ...m, enabled: !m.enabled } : m,
|
||||
);
|
||||
setModels(updated);
|
||||
const newDisabled = updated
|
||||
.filter((m) => !m.enabled)
|
||||
.map((m) => ({ id: m.id, label: m.label }));
|
||||
try {
|
||||
unwrap(
|
||||
await window.agentCompany.setConnectorDisabledModels({
|
||||
connectorId: connector.id,
|
||||
disabledModels: newDisabled,
|
||||
}),
|
||||
);
|
||||
await onSaved();
|
||||
} catch {
|
||||
setModels(prev);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
setSaving(true);
|
||||
try {
|
||||
const lines: string[] = [];
|
||||
if (apiKey.trim()) lines.push(`ANTHROPIC_API_KEY=${apiKey.trim()}`);
|
||||
if (baseUrl.trim()) lines.push(`ANTHROPIC_BASE_URL=${baseUrl.trim()}`);
|
||||
const label =
|
||||
COMPATIBLE_PROVIDERS.find((p) => p.id === selectedProvider)?.label ?? "";
|
||||
const modelsCsv =
|
||||
models.map((m) => m.id).join(",") || null;
|
||||
unwrap(
|
||||
await window.agentCompany.saveConnector({
|
||||
id: slotId as ConnectorRecord["id"],
|
||||
command: connector?.command ?? "claude",
|
||||
model: modelsCsv,
|
||||
envBindingText: lines.join("\n"),
|
||||
notes: label,
|
||||
}),
|
||||
);
|
||||
await window.agentCompany.testConnector(slotId as ConnectorRecord["id"]);
|
||||
await onSaved();
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleClear() {
|
||||
if (!connector) return;
|
||||
try {
|
||||
unwrap(
|
||||
await window.agentCompany.saveConnector({
|
||||
id: connector.id,
|
||||
command: connector.command,
|
||||
model: null,
|
||||
envBindingText: "",
|
||||
notes: "",
|
||||
}),
|
||||
);
|
||||
unwrap(
|
||||
await window.agentCompany.setConnectorDisabledModels({
|
||||
connectorId: connector.id,
|
||||
disabledModels: [],
|
||||
}),
|
||||
);
|
||||
await onSaved();
|
||||
onCleared?.();
|
||||
} catch {
|
||||
/* non-fatal */
|
||||
}
|
||||
}
|
||||
|
||||
const selectedProviderData = COMPATIBLE_PROVIDERS.find(
|
||||
(p) => p.id === selectedProvider,
|
||||
);
|
||||
const isConfigured =
|
||||
connector &&
|
||||
(connector.envBindingText.includes("ANTHROPIC_BASE_URL") ||
|
||||
connector.envBindingText.includes("ANTHROPIC_API_KEY"));
|
||||
|
||||
return (
|
||||
<ProviderCard
|
||||
title={displayTitle}
|
||||
badge={<StatusBadge status={status} zh={zh} />}
|
||||
expanded={expanded}
|
||||
onToggle={onToggle}
|
||||
>
|
||||
<div>
|
||||
<label className="mb-1.5 block text-[12px] font-medium text-muted-foreground">
|
||||
{zh ? "选择提供商" : "Select provider"}
|
||||
</label>
|
||||
<select
|
||||
className={INPUT_CLS}
|
||||
value={selectedProvider}
|
||||
onChange={(e) => handleProviderChange(e.target.value)}
|
||||
>
|
||||
{COMPATIBLE_PROVIDERS.map((p) => (
|
||||
<option key={p.id} value={p.id} className="bg-card text-foreground">
|
||||
{p.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{selectedProviderData?.docUrl ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => window.open(selectedProviderData.docUrl, "_blank")}
|
||||
className="inline-flex items-center gap-1 text-[12px] text-primary transition hover:underline"
|
||||
>
|
||||
<ArrowSquareOut size={12} />
|
||||
{zh ? "查看文档" : "View docs"}
|
||||
</button>
|
||||
) : null}
|
||||
|
||||
<div>
|
||||
<label className="mb-1.5 block text-[12px] font-medium text-muted-foreground">
|
||||
ANTHROPIC_API_KEY
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
className={INPUT_CLS}
|
||||
value={apiKey}
|
||||
onChange={(e) => setApiKey(e.target.value)}
|
||||
placeholder="sk-..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-1.5 block text-[12px] font-medium text-muted-foreground">
|
||||
ANTHROPIC_BASE_URL
|
||||
</label>
|
||||
<input
|
||||
className={INPUT_CLS}
|
||||
value={baseUrl}
|
||||
onChange={(e) => setBaseUrl(e.target.value)}
|
||||
placeholder="https://api.example.com/v1"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<ModelToggleList
|
||||
models={models}
|
||||
onToggle={(id) => void handleToggleModel(id)}
|
||||
onRemove={removeModel}
|
||||
canRemove
|
||||
zh={zh}
|
||||
/>
|
||||
|
||||
<div>
|
||||
<label className="mb-1.5 block text-[12px] font-medium text-muted-foreground">
|
||||
{zh ? "添加模型" : "Add model"}
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
className={INPUT_CLS}
|
||||
value={newModel}
|
||||
onChange={(e) => setNewModel(e.target.value)}
|
||||
placeholder={zh ? "输入模型名称" : "model-name"}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
addModel();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={addModel}
|
||||
disabled={!newModel.trim()}
|
||||
className="shrink-0 rounded-lg border border-border bg-card px-3 py-2 text-[12px] font-medium text-foreground transition hover:bg-muted disabled:opacity-40"
|
||||
aria-label={zh ? "添加模型" : "Add model"}
|
||||
>
|
||||
<Plus size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleSave()}
|
||||
disabled={saving}
|
||||
className="inline-flex items-center gap-1.5 rounded-lg border border-border bg-card px-3 py-1.5 text-[12px] font-medium text-foreground transition hover:bg-muted disabled:opacity-40"
|
||||
>
|
||||
<FloppyDisk size={14} />
|
||||
{saving
|
||||
? zh
|
||||
? "保存中..."
|
||||
: "Saving..."
|
||||
: zh
|
||||
? "保存并检查"
|
||||
: "Save & Check"}
|
||||
</button>
|
||||
{isConfigured ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleClear()}
|
||||
className="inline-flex items-center gap-1.5 rounded-lg border border-border bg-card px-3 py-1.5 text-[12px] font-medium text-destructive transition hover:bg-muted"
|
||||
>
|
||||
<Trash size={14} />
|
||||
{zh ? "清除配置" : "Clear"}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</ProviderCard>
|
||||
);
|
||||
}
|
||||
@@ -1,94 +0,0 @@
|
||||
import { useState } from "react";
|
||||
import type { ProfileSnapshot } from "@shared/types";
|
||||
import type { AppLocale } from "@shared/locale";
|
||||
import { COMPAT_SLOTS } from "./ModelManagementShared";
|
||||
import { NativeProviderCard } from "./NativeProviderCard";
|
||||
import { CompatProviderCard } from "./CompatProviderCard";
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Main component */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
interface ModelManagementProps {
|
||||
connectors: ProfileSnapshot["connectors"];
|
||||
locale: AppLocale;
|
||||
onSaved: () => Promise<void>;
|
||||
}
|
||||
|
||||
export function ModelManagement({
|
||||
connectors,
|
||||
locale,
|
||||
onSaved,
|
||||
}: ModelManagementProps) {
|
||||
const zh = locale === "zh";
|
||||
const [expandedCard, setExpandedCard] = useState<string | null>(null);
|
||||
|
||||
const claudeConnector = connectors.find((c) => c.id === "claude_local");
|
||||
const codexConnector = connectors.find((c) => c.id === "codex_local");
|
||||
const geminiConnector = connectors.find((c) => c.id === "gemini_local");
|
||||
|
||||
const activeCompatSlots = COMPAT_SLOTS.filter((id) => {
|
||||
const c = connectors.find((conn) => conn.id === id);
|
||||
return (
|
||||
c &&
|
||||
(c.envBindingText.includes("ANTHROPIC_BASE_URL") ||
|
||||
c.envBindingText.includes("ANTHROPIC_API_KEY"))
|
||||
);
|
||||
});
|
||||
const nextAvailableSlot =
|
||||
COMPAT_SLOTS.find((id) => !activeCompatSlots.includes(id)) ?? null;
|
||||
const visibleCompatSlots = [...activeCompatSlots];
|
||||
if (nextAvailableSlot && !visibleCompatSlots.includes(nextAvailableSlot)) {
|
||||
visibleCompatSlots.push(nextAvailableSlot);
|
||||
}
|
||||
|
||||
const toggle = (card: string) =>
|
||||
setExpandedCard((prev) => (prev === card ? null : card));
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{claudeConnector ? (
|
||||
<NativeProviderCard
|
||||
connector={claudeConnector}
|
||||
zh={zh}
|
||||
onSaved={onSaved}
|
||||
expanded={expandedCard === "claude_local"}
|
||||
onToggle={() => toggle("claude_local")}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{codexConnector ? (
|
||||
<NativeProviderCard
|
||||
connector={codexConnector}
|
||||
zh={zh}
|
||||
onSaved={onSaved}
|
||||
expanded={expandedCard === "codex_local"}
|
||||
onToggle={() => toggle("codex_local")}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{geminiConnector ? (
|
||||
<NativeProviderCard
|
||||
connector={geminiConnector}
|
||||
zh={zh}
|
||||
onSaved={onSaved}
|
||||
expanded={expandedCard === "gemini_local"}
|
||||
onToggle={() => toggle("gemini_local")}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{visibleCompatSlots.map((slotId) => (
|
||||
<CompatProviderCard
|
||||
key={slotId}
|
||||
slotId={slotId}
|
||||
connector={connectors.find((c) => c.id === slotId) ?? null}
|
||||
zh={zh}
|
||||
onSaved={onSaved}
|
||||
expanded={expandedCard === slotId}
|
||||
onToggle={() => toggle(slotId)}
|
||||
onCleared={() => setExpandedCard(null)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,13 +1,5 @@
|
||||
import { CaretDown, CaretRight } from "../ui/icons";
|
||||
import type { ProfileSnapshot } from "@shared/types";
|
||||
import { COMPATIBLE_PROVIDERS } from "../company-onboarding/ProvidersStepShared";
|
||||
|
||||
export { COMPATIBLE_PROVIDERS };
|
||||
|
||||
export const COMPAT_SLOTS = [
|
||||
"claude_compatible", "claude_compat_2", "claude_compat_3", "claude_compat_4", "claude_compat_5",
|
||||
"claude_compat_6", "claude_compat_7", "claude_compat_8", "claude_compat_9", "claude_compat_10",
|
||||
] as const;
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Shared */
|
||||
|
||||
@@ -1,168 +0,0 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { FloppyDisk, SignIn } from "../ui/icons";
|
||||
import type { ConnectorRecord } from "@shared/types";
|
||||
import { unwrap } from "../../lib/desktop";
|
||||
import {
|
||||
INPUT_CLS,
|
||||
ModelToggleList,
|
||||
ProviderCard,
|
||||
StatusBadge,
|
||||
parseEnvValue,
|
||||
type ModelEntry,
|
||||
} from "./ModelManagementShared";
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Native provider card (Claude Code / Codex / Gemini) */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
export function NativeProviderCard({
|
||||
connector,
|
||||
zh,
|
||||
onSaved,
|
||||
expanded,
|
||||
onToggle,
|
||||
}: {
|
||||
connector: ConnectorRecord;
|
||||
zh: boolean;
|
||||
onSaved: () => Promise<void>;
|
||||
expanded: boolean;
|
||||
onToggle: () => void;
|
||||
}) {
|
||||
const [models, setModels] = useState<ModelEntry[]>([]);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const envKeyName = connector.id === "codex_local" ? "OPENAI_API_KEY" : "ANTHROPIC_API_KEY";
|
||||
const [apiKey, setApiKey] = useState(() => parseEnvValue(connector.envBindingText, envKeyName));
|
||||
|
||||
useEffect(() => {
|
||||
setApiKey(parseEnvValue(connector.envBindingText, envKeyName));
|
||||
}, [connector.envBindingText, envKeyName]);
|
||||
|
||||
const loadModels = useCallback(async () => {
|
||||
try {
|
||||
const [enabledResult, disabledResult] = await Promise.all([
|
||||
window.agentCompany.listConnectorModels(connector.id),
|
||||
window.agentCompany.getConnectorDisabledModels(connector.id),
|
||||
]);
|
||||
const enabled = enabledResult.ok ? enabledResult.data : [];
|
||||
const disabled = disabledResult.ok ? disabledResult.data : [];
|
||||
setModels([
|
||||
...enabled.map((m) => ({ ...m, enabled: true })),
|
||||
...disabled.map((m) => ({ ...m, enabled: false })),
|
||||
]);
|
||||
} catch {
|
||||
/* non-fatal */
|
||||
}
|
||||
}, [connector.id]);
|
||||
|
||||
useEffect(() => {
|
||||
void loadModels();
|
||||
}, [loadModels]);
|
||||
|
||||
async function handleToggleModel(modelId: string) {
|
||||
const prev = models;
|
||||
const updated = models.map((m) =>
|
||||
m.id === modelId ? { ...m, enabled: !m.enabled } : m,
|
||||
);
|
||||
setModels(updated);
|
||||
const newDisabled = updated
|
||||
.filter((m) => !m.enabled)
|
||||
.map((m) => ({ id: m.id, label: m.label }));
|
||||
try {
|
||||
unwrap(
|
||||
await window.agentCompany.setConnectorDisabledModels({
|
||||
connectorId: connector.id,
|
||||
disabledModels: newDisabled,
|
||||
}),
|
||||
);
|
||||
await onSaved();
|
||||
} catch {
|
||||
setModels(prev);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
setSaving(true);
|
||||
try {
|
||||
const lines: string[] = [];
|
||||
if (apiKey.trim()) lines.push(`${envKeyName}=${apiKey.trim()}`);
|
||||
unwrap(
|
||||
await window.agentCompany.saveConnector({
|
||||
id: connector.id,
|
||||
command: connector.command,
|
||||
model: connector.model,
|
||||
envBindingText: lines.join("\n"),
|
||||
notes: connector.notes,
|
||||
}),
|
||||
);
|
||||
await window.agentCompany.testConnector(connector.id);
|
||||
await onSaved();
|
||||
void loadModels();
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleLogin() {
|
||||
await window.agentCompany.openConnectorAuthTerminal(connector.id);
|
||||
}
|
||||
|
||||
const showApiKey = connector.id === "claude_local" || connector.id === "codex_local";
|
||||
|
||||
return (
|
||||
<ProviderCard
|
||||
title={connector.label}
|
||||
badge={<StatusBadge status={connector.status} zh={zh} />}
|
||||
expanded={expanded}
|
||||
onToggle={onToggle}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleLogin()}
|
||||
className="inline-flex items-center gap-1.5 rounded-lg border border-border bg-card px-3 py-1.5 text-[12px] font-medium text-foreground transition hover:bg-muted"
|
||||
>
|
||||
<SignIn size={14} />
|
||||
{zh ? "登录" : "Login"}
|
||||
</button>
|
||||
|
||||
{showApiKey ? (
|
||||
<div>
|
||||
<label className="mb-1.5 block text-[12px] font-medium text-muted-foreground">
|
||||
{envKeyName}{" "}
|
||||
<span className="font-normal">({zh ? "可选" : "optional"})</span>
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
className={INPUT_CLS}
|
||||
value={apiKey}
|
||||
onChange={(e) => setApiKey(e.target.value)}
|
||||
placeholder={connector.id === "codex_local" ? "sk-..." : "sk-ant-..."}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<ModelToggleList
|
||||
models={models}
|
||||
onToggle={(id) => void handleToggleModel(id)}
|
||||
canRemove={false}
|
||||
zh={zh}
|
||||
/>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleSave()}
|
||||
disabled={saving}
|
||||
className="inline-flex items-center gap-1.5 rounded-lg border border-border bg-card px-3 py-1.5 text-[12px] font-medium text-foreground transition hover:bg-muted disabled:opacity-40"
|
||||
>
|
||||
<FloppyDisk size={14} />
|
||||
{saving
|
||||
? zh
|
||||
? "保存中..."
|
||||
: "Saving..."
|
||||
: zh
|
||||
? "保存并检查"
|
||||
: "Save & Check"}
|
||||
</button>
|
||||
</ProviderCard>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user