提交 Turnstile 集成源文件改动

This commit is contained in:
2569718930@qq.com
2026-06-16 03:49:49 +08:00
parent e896ffa7ac
commit a94e6ecaf9
8 changed files with 147 additions and 7 deletions
+7 -1
View File
@@ -7,6 +7,10 @@ import {
buildProxyExceptionResponse, buildProxyExceptionResponse,
buildUpstreamErrorResponse, buildUpstreamErrorResponse,
} from "@/lib/api-proxy"; } from "@/lib/api-proxy";
import {
requireTurnstileForRequest,
stripTurnstileToken,
} from "@/lib/turnstile";
const API_BASE = process.env.POLYWEATHER_API_BASE_URL; const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
@@ -89,6 +93,8 @@ export async function POST(req: NextRequest) {
let auth: Awaited<ReturnType<typeof buildBackendRequestHeaders>> | null = null; let auth: Awaited<ReturnType<typeof buildBackendRequestHeaders>> | null = null;
try { try {
const body = await req.json(); const body = await req.json();
const turnstileError = await requireTurnstileForRequest(req, "feedback_submit", body);
if (turnstileError) return turnstileError;
auth = await buildBackendRequestHeaders(req); auth = await buildBackendRequestHeaders(req);
const headers = new Headers(auth.headers); const headers = new Headers(auth.headers);
headers.set("Content-Type", "application/json"); headers.set("Content-Type", "application/json");
@@ -96,7 +102,7 @@ export async function POST(req: NextRequest) {
const res = await fetch(`${API_BASE}/api/feedback`, { const res = await fetch(`${API_BASE}/api/feedback`, {
method: "POST", method: "POST",
headers, headers,
body: JSON.stringify(body), body: JSON.stringify(stripTurnstileToken(body)),
cache: "no-store", cache: "no-store",
}); });
const raw = await res.text(); const raw = await res.text();
@@ -8,6 +8,10 @@ import {
buildProxyExceptionResponse, buildProxyExceptionResponse,
buildUpstreamErrorResponse, buildUpstreamErrorResponse,
} from "@/lib/api-proxy"; } from "@/lib/api-proxy";
import {
requireTurnstileForRequest,
stripTurnstileToken,
} from "@/lib/turnstile";
const API_BASE = process.env.POLYWEATHER_API_BASE_URL; const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
@@ -53,6 +57,10 @@ export async function POST(
const { intentId } = await context.params; const { intentId } = await context.params;
try { try {
const body = await req.json(); const body = await req.json();
if (process.env.POLYWEATHER_TURNSTILE_REQUIRE_PAYMENT_SUBMIT === "true") {
const turnstileError = await requireTurnstileForRequest(req, "payment_tx_submit", body);
if (turnstileError) return turnstileError;
}
const auth = await buildBackendRequestHeaders(req); const auth = await buildBackendRequestHeaders(req);
const authError = requireBackendPaymentAuth(auth); const authError = requireBackendPaymentAuth(auth);
if (authError) return authError; if (authError) return authError;
@@ -63,7 +71,7 @@ export async function POST(
{ {
method: "POST", method: "POST",
headers: proxiedHeaders, headers: proxiedHeaders,
body: JSON.stringify(body ?? {}), body: JSON.stringify(stripTurnstileToken(body ?? {})),
cache: "no-store", cache: "no-store",
}, },
); );
+7 -1
View File
@@ -9,6 +9,10 @@ import {
buildUpstreamErrorResponse, buildUpstreamErrorResponse,
} from "@/lib/api-proxy"; } from "@/lib/api-proxy";
import { isPaymentHostAllowed } from "@/lib/payment-host"; import { isPaymentHostAllowed } from "@/lib/payment-host";
import {
requireTurnstileForRequest,
stripTurnstileToken,
} from "@/lib/turnstile";
const API_BASE = process.env.POLYWEATHER_API_BASE_URL; const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
@@ -35,6 +39,8 @@ export async function POST(req: NextRequest) {
} }
try { try {
const body = await req.json(); const body = await req.json();
const turnstileError = await requireTurnstileForRequest(req, "payment_intent_create", body);
if (turnstileError) return turnstileError;
const auth = await buildBackendRequestHeaders(req); const auth = await buildBackendRequestHeaders(req);
const authError = requireBackendPaymentAuth(auth); const authError = requireBackendPaymentAuth(auth);
if (authError) return authError; if (authError) return authError;
@@ -43,7 +49,7 @@ export async function POST(req: NextRequest) {
const res = await fetch(`${API_BASE}/api/payments/intents`, { const res = await fetch(`${API_BASE}/api/payments/intents`, {
method: "POST", method: "POST",
headers: proxiedHeaders, headers: proxiedHeaders,
body: JSON.stringify(body ?? {}), body: JSON.stringify(stripTurnstileToken(body ?? {})),
cache: "no-store", cache: "no-store",
}); });
if (!res.ok) { if (!res.ok) {
@@ -51,6 +51,7 @@ import {
} from "./constants"; } from "./constants";
import { InfoRow, PlusIcon } from "./AccountInfoRow"; import { InfoRow, PlusIcon } from "./AccountInfoRow";
import { AccountFeedbackPanel } from "./AccountFeedbackPanel"; import { AccountFeedbackPanel } from "./AccountFeedbackPanel";
import { TurnstileWidget } from "@/components/security/TurnstileWidget";
import { import {
chainIdToDisplayName, chainIdToDisplayName,
clearStoredPaymentRecovery, clearStoredPaymentRecovery,
@@ -65,6 +66,7 @@ import {
buildTrialValueReplaySummary, buildTrialValueReplaySummary,
readTrialValueReplay, readTrialValueReplay,
} from "@/lib/trial-value-replay"; } from "@/lib/trial-value-replay";
import { getTurnstileTokenForAction } from "@/lib/turnstile-client";
// --- Main Component --- // --- Main Component ---
@@ -90,9 +92,25 @@ export function AccountCenter() {
const [backend, setBackend] = useState<AuthMeResponse | null>(null); const [backend, setBackend] = useState<AuthMeResponse | null>(null);
const [referralCodeInput, setReferralCodeInput] = useState(""); const [referralCodeInput, setReferralCodeInput] = useState("");
const [referralApplying, setReferralApplying] = useState(false); const [referralApplying, setReferralApplying] = useState(false);
const [paymentTurnstileToken, setPaymentTurnstileToken] = useState("");
const [paymentTurnstileResetKey, setPaymentTurnstileResetKey] = useState(0);
const supabaseReady = hasSupabasePublicEnv(); const supabaseReady = hasSupabasePublicEnv();
const walletConnectEnabled = Boolean(WALLETCONNECT_PROJECT_ID); const walletConnectEnabled = Boolean(WALLETCONNECT_PROJECT_ID);
const resetPaymentTurnstile = useCallback(() => {
setPaymentTurnstileToken("");
setPaymentTurnstileResetKey((value) => value + 1);
}, []);
const getPaymentTurnstileToken = useCallback(
(
action: "payment_intent_create" | "payment_tx_submit",
options?: { optional?: boolean },
) => {
if (options?.optional && !paymentTurnstileToken) return undefined;
return getTurnstileTokenForAction(paymentTurnstileToken, action);
},
[paymentTurnstileToken],
);
// ── Hook ──────────────────────────────────────────────── // ── Hook ────────────────────────────────────────────────
const { const {
@@ -189,6 +207,8 @@ export function AccountCenter() {
setUpdatedAt, setUpdatedAt,
usePoints, usePoints,
setUsePoints, setUsePoints,
getPaymentTurnstileToken,
resetPaymentTurnstile,
}); });
// ── Auth analytics effect ────────────────────────────── // ── Auth analytics effect ──────────────────────────────
@@ -1370,6 +1390,12 @@ export function AccountCenter() {
</div> </div>
)} )}
<TurnstileWidget
action="payment_intent_create"
onToken={setPaymentTurnstileToken}
resetKey={paymentTurnstileResetKey}
/>
{/* Payment Method Tabs */} {/* Payment Method Tabs */}
<div className="border-t border-slate-200 pt-5"> <div className="border-t border-slate-200 pt-5">
<p className="mb-3 text-[10px] font-semibold uppercase text-slate-500"> <p className="mb-3 text-[10px] font-semibold uppercase text-slate-500">
@@ -56,6 +56,11 @@ export interface UseAccountPaymentParams {
setUpdatedAt: (text: string) => void; setUpdatedAt: (text: string) => void;
usePoints: boolean; usePoints: boolean;
setUsePoints: (v: boolean) => void; setUsePoints: (v: boolean) => void;
getPaymentTurnstileToken?: (
action: "payment_intent_create" | "payment_tx_submit",
options?: { optional?: boolean },
) => string | undefined;
resetPaymentTurnstile?: () => void;
} }
// ============================================================ // ============================================================
@@ -73,6 +78,8 @@ export function useAccountPayment(params: UseAccountPaymentParams) {
setUpdatedAt, setUpdatedAt,
usePoints, usePoints,
setUsePoints, setUsePoints,
getPaymentTurnstileToken,
resetPaymentTurnstile,
} = params; } = params;
// ── Base payment state (from usePaymentState) ────────────── // ── Base payment state (from usePaymentState) ──────────────
@@ -532,6 +539,8 @@ export function useAccountPayment(params: UseAccountPaymentParams) {
allowedPaymentHosts: billing.allowedPaymentHosts, allowedPaymentHosts: billing.allowedPaymentHosts,
authIsAuthenticated, authIsAuthenticated,
hasPayingWallet: walletBind.hasPayingWallet, hasPayingWallet: walletBind.hasPayingWallet,
getPaymentTurnstileToken,
resetPaymentTurnstile,
getValidAccessToken, getValidAccessToken,
buildAuthedHeaders, buildAuthedHeaders,
loadSnapshot, loadSnapshot,
+37 -2
View File
@@ -32,9 +32,12 @@ import {
assertExpectedPaymentReceiver, assertExpectedPaymentReceiver,
EXPECTED_PAYMENT_RECEIVER_ADDRESS, EXPECTED_PAYMENT_RECEIVER_ADDRESS,
} from "@/lib/payment-receiver"; } from "@/lib/payment-receiver";
import { getTurnstileTokenForAction } from "@/lib/turnstile-client";
import type { PaymentTxValidationState } from "./usePaymentState"; import type { PaymentTxValidationState } from "./usePaymentState";
// ============================================================ // ============================================================
type PaymentTurnstileAction = "payment_intent_create" | "payment_tx_submit";
export interface UsePaymentFlowParams { export interface UsePaymentFlowParams {
isEn: boolean; isEn: boolean;
copy: Record<string, string>; copy: Record<string, string>;
@@ -97,6 +100,11 @@ export interface UsePaymentFlowParams {
allowedPaymentHosts: string[]; allowedPaymentHosts: string[];
authIsAuthenticated: boolean; authIsAuthenticated: boolean;
hasPayingWallet: boolean; hasPayingWallet: boolean;
getPaymentTurnstileToken?: (
action: PaymentTurnstileAction,
options?: { optional?: boolean },
) => string | undefined;
resetPaymentTurnstile?: () => void;
// Callbacks from master // Callbacks from master
getValidAccessToken: () => Promise<string>; getValidAccessToken: () => Promise<string>;
@@ -159,6 +167,8 @@ export function usePaymentFlow(params: UsePaymentFlowParams) {
allowedPaymentHosts, allowedPaymentHosts,
authIsAuthenticated, authIsAuthenticated,
hasPayingWallet, hasPayingWallet,
getPaymentTurnstileToken,
resetPaymentTurnstile,
getValidAccessToken, getValidAccessToken,
buildAuthedHeaders, buildAuthedHeaders,
loadSnapshot, loadSnapshot,
@@ -184,6 +194,16 @@ export function usePaymentFlow(params: UsePaymentFlowParams) {
trackAppEvent("payment_success", payload); trackAppEvent("payment_success", payload);
}, []); }, []);
const buildPaymentTurnstilePayload = useCallback(
(action: PaymentTurnstileAction, optional = false) => {
const turnstile_token =
getPaymentTurnstileToken?.(action, { optional }) ||
(optional ? undefined : getTurnstileTokenForAction("", action));
return turnstile_token ? { turnstile_token } : {};
},
[getPaymentTurnstileToken],
);
const verifyPaymentAuthReady = useCallback(async () => { const verifyPaymentAuthReady = useCallback(async () => {
const accessToken = await getValidAccessToken(); const accessToken = await getValidAccessToken();
const authHeaders: Record<string, string> = { const authHeaders: Record<string, string> = {
@@ -499,6 +519,7 @@ export function usePaymentFlow(params: UsePaymentFlowParams) {
method: "POST", method: "POST",
headers: authHeaders, headers: authHeaders,
body: JSON.stringify({ body: JSON.stringify({
...buildPaymentTurnstilePayload("payment_intent_create"),
plan_code: selectedPlan?.plan_code || "pro_monthly", plan_code: selectedPlan?.plan_code || "pro_monthly",
payment_mode: "strict", payment_mode: "strict",
allowed_wallet: payingWallet, allowed_wallet: payingWallet,
@@ -514,6 +535,7 @@ export function usePaymentFlow(params: UsePaymentFlowParams) {
}, },
}), }),
}); });
resetPaymentTurnstile?.();
if (!createRes.ok) { if (!createRes.ok) {
const raw = await readPaymentApiErrorMessage( const raw = await readPaymentApiErrorMessage(
createRes, createRes,
@@ -596,7 +618,13 @@ export function usePaymentFlow(params: UsePaymentFlowParams) {
await waitForReceipt(txHashNorm, eth); await waitForReceipt(txHashNorm, eth);
const submitRes = await fetch(`/api/payments/intents/${intentId}/submit`, { const submitRes = await fetch(`/api/payments/intents/${intentId}/submit`, {
method: "POST", headers: authHeaders, body: JSON.stringify({ tx_hash: txHashNorm, from_address: payingWallet }), method: "POST",
headers: authHeaders,
body: JSON.stringify({
...buildPaymentTurnstilePayload("payment_tx_submit", true),
tx_hash: txHashNorm,
from_address: payingWallet,
}),
}); });
if (!submitRes.ok) { if (!submitRes.ok) {
const raw = await readPaymentApiErrorMessage( const raw = await readPaymentApiErrorMessage(
@@ -692,6 +720,7 @@ export function usePaymentFlow(params: UsePaymentFlowParams) {
method: "POST", method: "POST",
headers: authHeaders, headers: authHeaders,
body: JSON.stringify({ body: JSON.stringify({
...buildPaymentTurnstilePayload("payment_intent_create"),
plan_code: selectedPlan?.plan_code || "pro_monthly", plan_code: selectedPlan?.plan_code || "pro_monthly",
payment_mode: "direct", payment_mode: "direct",
chain_id: effectivePaymentChainId, chain_id: effectivePaymentChainId,
@@ -705,6 +734,7 @@ export function usePaymentFlow(params: UsePaymentFlowParams) {
}, },
}), }),
}); });
resetPaymentTurnstile?.();
if (!createRes.ok) { if (!createRes.ok) {
const raw = await readPaymentApiErrorMessage( const raw = await readPaymentApiErrorMessage(
createRes, createRes,
@@ -765,7 +795,12 @@ export function usePaymentFlow(params: UsePaymentFlowParams) {
try { try {
const authHeaders = await buildAuthedHeaders(true, true); const authHeaders = await buildAuthedHeaders(true, true);
const submitRes = await fetch(`/api/payments/intents/${intentIdVal}/submit`, { const submitRes = await fetch(`/api/payments/intents/${intentIdVal}/submit`, {
method: "POST", headers: authHeaders, body: JSON.stringify({ tx_hash: txHashNorm }), method: "POST",
headers: authHeaders,
body: JSON.stringify({
...buildPaymentTurnstilePayload("payment_tx_submit", true),
tx_hash: txHashNorm,
}),
}); });
if (!submitRes.ok) { if (!submitRes.ok) {
const raw = await readPaymentApiErrorMessage( const raw = await readPaymentApiErrorMessage(
+29 -1
View File
@@ -1,6 +1,6 @@
"use client"; "use client";
import { FormEvent, useEffect, useState } from "react"; import { FormEvent, useCallback, useEffect, useState } from "react";
import Link from "next/link"; import Link from "next/link";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { import {
@@ -18,6 +18,8 @@ import {
} from "@/lib/supabase/client"; } from "@/lib/supabase/client";
import { getConfiguredSiteUrl, PRODUCTION_SITE_URL } from "@/lib/site-url"; import { getConfiguredSiteUrl, PRODUCTION_SITE_URL } from "@/lib/site-url";
import { useI18n } from "@/hooks/useI18n"; import { useI18n } from "@/hooks/useI18n";
import { TurnstileWidget } from "@/components/security/TurnstileWidget";
import { getTurnstileTokenForAction } from "@/lib/turnstile-client";
type Mode = "login" | "signup"; type Mode = "login" | "signup";
@@ -72,6 +74,8 @@ export function LoginClient({ nextPath, initialError, initialMode }: LoginClient
const [infoText, setInfoText] = useState(""); const [infoText, setInfoText] = useState("");
const [resetSent, setResetSent] = useState(false); const [resetSent, setResetSent] = useState(false);
const [showPassword, setShowPassword] = useState(false); const [showPassword, setShowPassword] = useState(false);
const [turnstileToken, setTurnstileToken] = useState("");
const [turnstileResetKey, setTurnstileResetKey] = useState(0);
const supabaseReady = hasSupabasePublicEnv(); const supabaseReady = hasSupabasePublicEnv();
const isLogin = mode === "login"; const isLogin = mode === "login";
@@ -173,6 +177,11 @@ export function LoginClient({ nextPath, initialError, initialMode }: LoginClient
setErrorText(initialError || ""); setErrorText(initialError || "");
}, [initialError]); }, [initialError]);
const resetTurnstile = useCallback(() => {
setTurnstileToken("");
setTurnstileResetKey((value) => value + 1);
}, []);
const onResetPassword = async () => { const onResetPassword = async () => {
setErrorText(""); setErrorText("");
setInfoText(""); setInfoText("");
@@ -247,13 +256,19 @@ export function LoginClient({ nextPath, initialError, initialMode }: LoginClient
setLoading(true); setLoading(true);
try { try {
const supabase = getSupabaseBrowserClient(); const supabase = getSupabaseBrowserClient();
const captchaToken = getTurnstileTokenForAction(
turnstileToken,
isLogin ? "login" : "signup",
);
if (mode === "login") { if (mode === "login") {
const { data, error } = await supabase.auth.signInWithPassword({ const { data, error } = await supabase.auth.signInWithPassword({
email: email.trim(), email: email.trim(),
password, password,
options: captchaToken ? { captchaToken } : undefined,
}); });
if (error) { if (error) {
setErrorText(error.message); setErrorText(error.message);
resetTurnstile();
return; return;
} }
const redirectPath = await resolvePostLoginRedirect({ const redirectPath = await resolvePostLoginRedirect({
@@ -272,10 +287,12 @@ export function LoginClient({ nextPath, initialError, initialMode }: LoginClient
password, password,
options: { options: {
emailRedirectTo, emailRedirectTo,
...(captchaToken ? { captchaToken } : {}),
}, },
}); });
if (error) { if (error) {
setErrorText(error.message); setErrorText(error.message);
resetTurnstile();
return; return;
} }
if (data.session?.user) { if (data.session?.user) {
@@ -287,6 +304,10 @@ export function LoginClient({ nextPath, initialError, initialMode }: LoginClient
return; return;
} }
setInfoText(copy.signupCheckEmail); setInfoText(copy.signupCheckEmail);
resetTurnstile();
} catch (error) {
setErrorText(error instanceof Error ? error.message : String(error));
resetTurnstile();
} finally { } finally {
setLoading(false); setLoading(false);
} }
@@ -407,6 +428,7 @@ export function LoginClient({ nextPath, initialError, initialMode }: LoginClient
setErrorText(""); setErrorText("");
setInfoText(""); setInfoText("");
setMode(isLogin ? "signup" : "login"); setMode(isLogin ? "signup" : "login");
resetTurnstile();
}} }}
className="whitespace-nowrap rounded-xl border border-slate-300 bg-white px-4 py-2 text-xs font-bold text-slate-700 shadow-sm transition hover:border-blue-300 hover:text-blue-700 active:scale-[0.98]" className="whitespace-nowrap rounded-xl border border-slate-300 bg-white px-4 py-2 text-xs font-bold text-slate-700 shadow-sm transition hover:border-blue-300 hover:text-blue-700 active:scale-[0.98]"
> >
@@ -495,6 +517,12 @@ export function LoginClient({ nextPath, initialError, initialMode }: LoginClient
</div> </div>
</div> </div>
<TurnstileWidget
action={isLogin ? "login" : "signup"}
onToken={setTurnstileToken}
resetKey={turnstileResetKey}
/>
{!isLogin ? ( {!isLogin ? (
<p className="text-[11px] leading-relaxed text-slate-500"> <p className="text-[11px] leading-relaxed text-slate-500">
{copy.termsAgreement} {copy.termsAgreement}
@@ -11,6 +11,8 @@ import {
getSupabaseBrowserClient, getSupabaseBrowserClient,
hasSupabasePublicEnv, hasSupabasePublicEnv,
} from "@/lib/supabase/client"; } from "@/lib/supabase/client";
import { TurnstileWidget } from "@/components/security/TurnstileWidget";
import { getTurnstileTokenForAction, isTurnstileEnabled } from "@/lib/turnstile-client";
import type { UserFeedbackEntry } from "@/types/ops"; import type { UserFeedbackEntry } from "@/types/ops";
export type FeedbackCategory = "bug" | "data" | "idea" | "payment" | "account" | "other"; export type FeedbackCategory = "bug" | "data" | "idea" | "payment" | "account" | "other";
@@ -111,6 +113,8 @@ export function UserFeedbackModal({
const [submitting, setSubmitting] = useState(false); const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState(""); const [error, setError] = useState("");
const [submitted, setSubmitted] = useState(false); const [submitted, setSubmitted] = useState(false);
const [turnstileToken, setTurnstileToken] = useState("");
const [turnstileResetKey, setTurnstileResetKey] = useState(0);
useEffect(() => { useEffect(() => {
if (!open) return; if (!open) return;
@@ -120,6 +124,8 @@ export function UserFeedbackModal({
setLoginEmailContact(""); setLoginEmailContact("");
setError(""); setError("");
setSubmitted(false); setSubmitted(false);
setTurnstileToken("");
setTurnstileResetKey((value) => value + 1);
}, [open, draft?.category]); }, [open, draft?.category]);
useEffect(() => { useEffect(() => {
@@ -158,13 +164,20 @@ export function UserFeedbackModal({
if (!open) return null; if (!open) return null;
const canSubmit = message.trim().length >= 3 && !submitting; const canSubmit =
message.trim().length >= 3 &&
!submitting &&
(!isTurnstileEnabled() || Boolean(turnstileToken));
const submit = async () => { const submit = async () => {
if (!canSubmit) return; if (!canSubmit) return;
setSubmitting(true); setSubmitting(true);
setError(""); setError("");
try { try {
const turnstile_token = getTurnstileTokenForAction(
turnstileToken,
"feedback_submit",
);
const res = await fetch("/api/feedback", { const res = await fetch("/api/feedback", {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
@@ -174,6 +187,7 @@ export function UserFeedbackModal({
contact: contact.trim() || undefined, contact: contact.trim() || undefined,
source: draft?.source || "terminal", source: draft?.source || "terminal",
context: buildRuntimeContext(draft?.context || {}), context: buildRuntimeContext(draft?.context || {}),
turnstile_token,
}), }),
}); });
if (!res.ok) { if (!res.ok) {
@@ -188,6 +202,8 @@ export function UserFeedbackModal({
} catch (err) { } catch (err) {
const message = err instanceof Error ? err.message : String(err); const message = err instanceof Error ? err.message : String(err);
setError(message.slice(0, 220)); setError(message.slice(0, 220));
setTurnstileToken("");
setTurnstileResetKey((value) => value + 1);
} finally { } finally {
setSubmitting(false); setSubmitting(false);
} }
@@ -296,6 +312,12 @@ export function UserFeedbackModal({
: "会自动附带终端上下文:城市、槽位、数据源状态、浏览器和会话诊断信息。"} : "会自动附带终端上下文:城市、槽位、数据源状态、浏览器和会话诊断信息。"}
</div> </div>
<TurnstileWidget
action="feedback_submit"
onToken={setTurnstileToken}
resetKey={turnstileResetKey}
/>
{error && ( {error && (
<div className="rounded border border-red-200 bg-red-50 px-3 py-2 text-xs text-red-700"> <div className="rounded border border-red-200 bg-red-50 px-3 py-2 text-xs text-red-700">
{isEn ? "Submit failed: " : "提交失败:"}{error} {isEn ? "Submit failed: " : "提交失败:"}{error}