mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-07-27 08:14:33 +08:00
feat(provider-form): soften validation with "save anyway" prompt (#2307)
* feat(provider-form): soften business-rule validation with "save anyway" prompt Refactor handleSubmit so empty-field / missing-item validations (provider name, endpoint, API key, opencode model, template variables, provider key required) no longer hard-reject with toast.error. Instead they are collected into an issues list and presented via a ConfirmDialog; the user can cancel or choose "Save anyway" to proceed. Integrity constraints stay as hard rejections: - providerKey regex / duplicate (would corrupt other providers) - Copilot / Codex OAuth not authenticated (no token, cannot establish) - omo Other Fields JSON not an object / parse failure This aligns the frontend with the backend's existing "relaxed save / strict switch" split (see gemini_config.rs: validate_gemini_settings vs validate_gemini_settings_strict) and unblocks legitimate configs such as AWS Bedrock, Vertex AI, and custom Gemini base URLs that the UI previously refused to save. Refs: #2196, #1204 * fix(provider-form): address review feedback on soft-validation P1: move empty providerKey back to hard rejection for OpenCode / OpenClaw / Hermes. Since providerKey is the primary identity for these apps and the mutations layer throws "Provider key is required" when absent, letting users click "save anyway" would surface a generic error toast instead of a precise, actionable one. Treat empty providerKey as an integrity constraint alongside regex / duplicate checks. P2: give the soft-confirm submit path its own submitting state. The confirm-dialog path bypassed react-hook-form's isSubmitting lifecycle, so slow or failing saves left the outer submit button responsive and could spawn unhandled rejections. Now the confirm handler awaits performSubmit inside try/catch/finally, uses an isConfirmSubmitting flag to gate both confirm and cancel clicks, and folds the flag into the outer disabled state and onSubmittingChange callback. Refs: #2307 review comments * chore(clippy): use push for single char '…' in truncate_body Clippy 1.95 added single_char_add_str which flagged the push_str("…") in truncate_body. Rebased onto latest upstream/main and applied the suggested fix so the Backend Checks clippy job passes. Unrelated to this PR's core changes; bundled in so the PR is mergeable without waiting for a separate upstream fix. --------- Co-authored-by: Allen <allen@AllenMacBook-M4-Pro.local>
This commit is contained in:
@@ -192,7 +192,7 @@ fn truncate_body(body: String) -> String {
|
||||
body
|
||||
} else {
|
||||
let mut s: String = body.chars().take(ERROR_BODY_MAX_CHARS).collect();
|
||||
s.push_str("…");
|
||||
s.push('…');
|
||||
s
|
||||
}
|
||||
}
|
||||
|
||||
@@ -297,9 +297,16 @@ export function ProviderForm({
|
||||
},
|
||||
);
|
||||
|
||||
// 软校验:收集"业务约束"类问题(空值/缺项),由用户决定是否仍要保存
|
||||
const [softIssues, setSoftIssues] = useState<string[] | null>(null);
|
||||
const [pendingFormValues, setPendingFormValues] =
|
||||
useState<ProviderFormData | null>(null);
|
||||
// 确认框走的提交路径绕过了 react-hook-form 的 isSubmitting,单独追踪
|
||||
const [isConfirmSubmitting, setIsConfirmSubmitting] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
onSubmittingChange?.(isSubmitting);
|
||||
}, [isSubmitting, onSubmittingChange]);
|
||||
onSubmittingChange?.(isSubmitting || isConfirmSubmitting);
|
||||
}, [isSubmitting, isConfirmSubmitting, onSubmittingChange]);
|
||||
|
||||
const {
|
||||
apiKey,
|
||||
@@ -766,30 +773,38 @@ export function ProviderForm({
|
||||
const [isCommonConfigModalOpen, setIsCommonConfigModalOpen] = useState(false);
|
||||
|
||||
const handleSubmit = async (values: ProviderFormData) => {
|
||||
// 软性问题(业务约束,用户可选择仍要保存)
|
||||
const issues: string[] = [];
|
||||
|
||||
// 模板变量未填:A 类(空值)
|
||||
if (appId === "claude" && templateValueEntries.length > 0) {
|
||||
const validation = validateTemplateValues();
|
||||
if (!validation.isValid && validation.missingField) {
|
||||
toast.error(
|
||||
issues.push(
|
||||
t("providerForm.fillParameter", {
|
||||
label: validation.missingField.label,
|
||||
defaultValue: `请填写 ${validation.missingField.label}`,
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// 供应商名空:A 类
|
||||
if (!values.name.trim()) {
|
||||
toast.error(
|
||||
issues.push(
|
||||
t("providerForm.fillSupplierName", {
|
||||
defaultValue: "请填写供应商名称",
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// opencode / openclaw / hermes: providerKey 相关
|
||||
// A 类(空)归到 issues;B 类(正则不合法 / 重复 / 状态加载中)仍硬拒绝
|
||||
const keyPattern = /^[a-z0-9]+(-[a-z0-9]+)*$/;
|
||||
|
||||
if (appId === "opencode" && !isAnyOmoCategory) {
|
||||
const keyPattern = /^[a-z0-9]+(-[a-z0-9]+)*$/;
|
||||
// providerKey 是 opencode / openclaw / hermes 的主键 ID,空或格式不合法
|
||||
// 都属于完整性约束,保留硬拒绝(mutations 层也会 throw,软化只会让错误更晦涩)
|
||||
if (!opencodeForm.opencodeProviderKey.trim()) {
|
||||
toast.error(t("opencode.providerKeyRequired"));
|
||||
return;
|
||||
@@ -814,14 +829,11 @@ export function ProviderForm({
|
||||
return;
|
||||
}
|
||||
if (Object.keys(opencodeForm.opencodeModels).length === 0) {
|
||||
toast.error(t("opencode.modelsRequired"));
|
||||
return;
|
||||
issues.push(t("opencode.modelsRequired"));
|
||||
}
|
||||
}
|
||||
|
||||
// OpenClaw: validate provider key
|
||||
if (appId === "openclaw") {
|
||||
const keyPattern = /^[a-z0-9]+(-[a-z0-9]+)*$/;
|
||||
if (!openclawForm.openclawProviderKey.trim()) {
|
||||
toast.error(t("openclaw.providerKeyRequired"));
|
||||
return;
|
||||
@@ -847,9 +859,7 @@ export function ProviderForm({
|
||||
}
|
||||
}
|
||||
|
||||
// Hermes: validate provider key
|
||||
if (appId === "hermes") {
|
||||
const keyPattern = /^[a-z0-9]+(-[a-z0-9]+)*$/;
|
||||
if (!hermesForm.hermesProviderKey.trim()) {
|
||||
toast.error(t("hermes.form.providerKeyRequired"));
|
||||
return;
|
||||
@@ -875,9 +885,7 @@ export function ProviderForm({
|
||||
}
|
||||
}
|
||||
|
||||
// 非官方供应商必填校验:端点和 API Key
|
||||
// cloud_provider(如 Bedrock)通过模板变量处理认证,跳过通用校验
|
||||
// GitHub Copilot 使用 OAuth 认证,不需要 API Key
|
||||
// OAuth 未登录:B 类(token 根本不存在,保存了也没法建立)
|
||||
const isCopilotProvider =
|
||||
templatePreset?.providerType === "github_copilot" ||
|
||||
initialData?.meta?.providerType === "github_copilot" ||
|
||||
@@ -885,7 +893,6 @@ export function ProviderForm({
|
||||
const isCodexOauthProvider =
|
||||
templatePreset?.providerType === "codex_oauth" ||
|
||||
initialData?.meta?.providerType === "codex_oauth";
|
||||
// GitHub Copilot 必须先登录才能添加
|
||||
if (isCopilotProvider && !isCopilotAuthenticated) {
|
||||
toast.error(
|
||||
t("copilot.loginRequired", {
|
||||
@@ -894,7 +901,6 @@ export function ProviderForm({
|
||||
);
|
||||
return;
|
||||
}
|
||||
// Codex OAuth 必须先登录才能添加
|
||||
if (isCodexOauthProvider && !isCodexOauthAuthenticated) {
|
||||
toast.error(
|
||||
t("codexOauth.loginRequired", {
|
||||
@@ -904,61 +910,108 @@ export function ProviderForm({
|
||||
return;
|
||||
}
|
||||
|
||||
// OMO Other Fields JSON:B 类(格式错了保存下去数据就坏了)
|
||||
if (
|
||||
appId === "opencode" &&
|
||||
isAnyOmoCategory &&
|
||||
omoDraft.omoOtherFieldsStr.trim()
|
||||
) {
|
||||
try {
|
||||
const otherFields = parseOmoOtherFieldsObject(
|
||||
omoDraft.omoOtherFieldsStr,
|
||||
);
|
||||
if (!otherFields) {
|
||||
toast.error(
|
||||
t("omo.jsonMustBeObject", {
|
||||
field: t("omo.otherFields", {
|
||||
defaultValue: "Other Config",
|
||||
}),
|
||||
defaultValue: "{{field}} must be a JSON object",
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
toast.error(
|
||||
t("omo.invalidJson", {
|
||||
defaultValue: "Other Fields contains invalid JSON",
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// 非官方供应商端点 / API Key 空:A 类
|
||||
// cloud_provider(如 Bedrock)通过模板变量处理认证,跳过通用校验
|
||||
if (category !== "official" && category !== "cloud_provider") {
|
||||
if (appId === "claude") {
|
||||
if (!isCodexOauthProvider && !baseUrl.trim()) {
|
||||
toast.error(
|
||||
issues.push(
|
||||
t("providerForm.endpointRequired", {
|
||||
defaultValue: "非官方供应商请填写 API 端点",
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (!isCopilotProvider && !isCodexOauthProvider && !apiKey.trim()) {
|
||||
toast.error(
|
||||
issues.push(
|
||||
t("providerForm.apiKeyRequired", {
|
||||
defaultValue: "非官方供应商请填写 API Key",
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
} else if (appId === "codex") {
|
||||
if (!codexBaseUrl.trim()) {
|
||||
toast.error(
|
||||
issues.push(
|
||||
t("providerForm.endpointRequired", {
|
||||
defaultValue: "非官方供应商请填写 API 端点",
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (!codexApiKey.trim()) {
|
||||
toast.error(
|
||||
issues.push(
|
||||
t("providerForm.apiKeyRequired", {
|
||||
defaultValue: "非官方供应商请填写 API Key",
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
} else if (appId === "gemini") {
|
||||
if (!geminiBaseUrl.trim()) {
|
||||
toast.error(
|
||||
issues.push(
|
||||
t("providerForm.endpointRequired", {
|
||||
defaultValue: "非官方供应商请填写 API 端点",
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (!geminiApiKey.trim()) {
|
||||
toast.error(
|
||||
issues.push(
|
||||
t("providerForm.apiKeyRequired", {
|
||||
defaultValue: "非官方供应商请填写 API Key",
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (issues.length > 0) {
|
||||
// 弹确认框让用户决定是否仍要保存
|
||||
setSoftIssues(issues);
|
||||
setPendingFormValues(values);
|
||||
return;
|
||||
}
|
||||
|
||||
await performSubmit(values);
|
||||
};
|
||||
|
||||
const performSubmit = async (values: ProviderFormData) => {
|
||||
// OAuth / 其它身份识别(与 handleSubmit 保持一致)
|
||||
const isCopilotProvider =
|
||||
templatePreset?.providerType === "github_copilot" ||
|
||||
initialData?.meta?.providerType === "github_copilot" ||
|
||||
baseUrl.includes("githubcopilot.com");
|
||||
const isCodexOauthProvider =
|
||||
templatePreset?.providerType === "codex_oauth" ||
|
||||
initialData?.meta?.providerType === "codex_oauth";
|
||||
|
||||
let settingsConfig: string;
|
||||
|
||||
if (appId === "codex") {
|
||||
@@ -999,29 +1052,12 @@ export function ProviderForm({
|
||||
omoConfig.categories = omoDraft.omoCategories;
|
||||
}
|
||||
if (omoDraft.omoOtherFieldsStr.trim()) {
|
||||
try {
|
||||
const otherFields = parseOmoOtherFieldsObject(
|
||||
omoDraft.omoOtherFieldsStr,
|
||||
);
|
||||
if (!otherFields) {
|
||||
toast.error(
|
||||
t("omo.jsonMustBeObject", {
|
||||
field: t("omo.otherFields", {
|
||||
defaultValue: "Other Config",
|
||||
}),
|
||||
defaultValue: "{{field}} must be a JSON object",
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
// 格式已在 handleSubmit 前置校验中验证过,此处可以安全解析
|
||||
const otherFields = parseOmoOtherFieldsObject(
|
||||
omoDraft.omoOtherFieldsStr,
|
||||
);
|
||||
if (otherFields) {
|
||||
omoConfig.otherFields = otherFields;
|
||||
} catch {
|
||||
toast.error(
|
||||
t("omo.invalidJson", {
|
||||
defaultValue: "Other Fields contains invalid JSON",
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
settingsConfig = JSON.stringify(omoConfig);
|
||||
@@ -2079,7 +2115,10 @@ export function ProviderForm({
|
||||
<Button variant="outline" type="button" onClick={onCancel}>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
<Button type="submit" disabled={isSubmitting}>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={isSubmitting || isConfirmSubmitting}
|
||||
>
|
||||
{submitLabel}
|
||||
</Button>
|
||||
</div>
|
||||
@@ -2096,6 +2135,50 @@ export function ProviderForm({
|
||||
onConfirm={() => void handleCommonConfigConfirm()}
|
||||
onCancel={() => void handleCommonConfigConfirm()}
|
||||
/>
|
||||
|
||||
<ConfirmDialog
|
||||
isOpen={softIssues !== null && softIssues.length > 0}
|
||||
variant="info"
|
||||
title={t("providerForm.softValidation.title", {
|
||||
defaultValue: "配置存在以下问题",
|
||||
})}
|
||||
message={
|
||||
(softIssues ?? []).map((issue) => `• ${issue}`).join("\n") +
|
||||
"\n\n" +
|
||||
t("providerForm.softValidation.hint", {
|
||||
defaultValue:
|
||||
"仍要保存吗?保存后切换此供应商时可能失败,可以之后再补全。",
|
||||
})
|
||||
}
|
||||
confirmText={t("providerForm.softValidation.saveAnyway", {
|
||||
defaultValue: "仍要保存",
|
||||
})}
|
||||
cancelText={t("common.cancel")}
|
||||
onConfirm={async () => {
|
||||
if (isConfirmSubmitting) return;
|
||||
const values = pendingFormValues;
|
||||
if (!values) {
|
||||
setSoftIssues(null);
|
||||
return;
|
||||
}
|
||||
setIsConfirmSubmitting(true);
|
||||
try {
|
||||
await performSubmit(values);
|
||||
setSoftIssues(null);
|
||||
setPendingFormValues(null);
|
||||
} catch (error) {
|
||||
console.error("[ProviderForm] soft-confirm submit failed:", error);
|
||||
// 保留确认框和 pending values,让用户可以重试或取消
|
||||
} finally {
|
||||
setIsConfirmSubmitting(false);
|
||||
}
|
||||
}}
|
||||
onCancel={() => {
|
||||
if (isConfirmSubmitting) return;
|
||||
setSoftIssues(null);
|
||||
setPendingFormValues(null);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -824,6 +824,11 @@
|
||||
"fillTemplateValue": "Please fill in {{label}}",
|
||||
"endpointRequired": "API endpoint is required for non-official providers",
|
||||
"apiKeyRequired": "API Key is required for non-official providers",
|
||||
"softValidation": {
|
||||
"title": "Configuration has the following issues",
|
||||
"hint": "Save anyway? Switching to this provider may fail; you can fill these in later.",
|
||||
"saveAnyway": "Save anyway"
|
||||
},
|
||||
"configJsonError": "Config JSON format error, please check syntax",
|
||||
"authJsonRequired": "auth.json must be a JSON object",
|
||||
"authJsonError": "auth.json format error, please check JSON syntax",
|
||||
|
||||
@@ -824,6 +824,11 @@
|
||||
"fillTemplateValue": "{{label}} を入力してください",
|
||||
"endpointRequired": "公式以外は API エンドポイントが必須です",
|
||||
"apiKeyRequired": "公式以外は API Key が必須です",
|
||||
"softValidation": {
|
||||
"title": "設定に以下の問題があります",
|
||||
"hint": "それでも保存しますか?保存後、このプロバイダーへの切り替えが失敗する可能性がありますが、後から補完できます。",
|
||||
"saveAnyway": "それでも保存"
|
||||
},
|
||||
"configJsonError": "Config JSON の形式が正しくありません。構文を確認してください",
|
||||
"authJsonRequired": "auth.json は JSON オブジェクトで入力してください",
|
||||
"authJsonError": "auth.json の形式が正しくありません。JSON を確認してください",
|
||||
|
||||
@@ -824,6 +824,11 @@
|
||||
"fillTemplateValue": "请填写 {{label}}",
|
||||
"endpointRequired": "非官方供应商请填写 API 端点",
|
||||
"apiKeyRequired": "非官方供应商请填写 API Key",
|
||||
"softValidation": {
|
||||
"title": "配置存在以下问题",
|
||||
"hint": "仍要保存吗?保存后切换此供应商时可能失败,可以之后再补全。",
|
||||
"saveAnyway": "仍要保存"
|
||||
},
|
||||
"configJsonError": "配置JSON格式错误,请检查语法",
|
||||
"authJsonRequired": "auth.json 必须是 JSON 对象",
|
||||
"authJsonError": "auth.json 格式错误,请检查JSON语法",
|
||||
|
||||
Reference in New Issue
Block a user