From 06e0d00639815428dc2fed6e52299c97d74be626 Mon Sep 17 00:00:00 2001 From: Gaubee Date: Sat, 28 Feb 2026 20:18:23 +0800 Subject: [PATCH 1/4] fix(miniapp): unify gesture service feedback for transfer/sign --- src/components/security/pattern-lock.tsx | 28 +++- .../MiniappConfirmJobs.regression.test.tsx | 66 +++++++-- .../sheets/MiniappSignTransactionJob.tsx | 63 +++++++-- .../sheets/MiniappTransferConfirmJob.tsx | 34 +++-- .../__tests__/miniapp-transfer-error.test.ts | 22 +++ .../sheets/miniapp-transfer-error.ts | 131 ++++++++++++++++-- 6 files changed, 300 insertions(+), 44 deletions(-) diff --git a/src/components/security/pattern-lock.tsx b/src/components/security/pattern-lock.tsx index ab4c6346e..f5697e478 100644 --- a/src/components/security/pattern-lock.tsx +++ b/src/components/security/pattern-lock.tsx @@ -19,6 +19,12 @@ export interface PatternLockProps { success?: boolean; /** 错误提示文案(可选,覆盖默认错误文案) */ errorText?: string; + /** 状态提示文案(可选,覆盖默认提示文案) */ + hintText?: string; + /** 状态提示语气(仅在 hintText 生效时使用) */ + hintTone?: 'default' | 'destructive' | 'primary'; + /** 底部详情文案(可选) */ + footerText?: string; /** 额外的 className */ className?: string; /** 网格大小 (默认 3x3) */ @@ -51,6 +57,9 @@ export function PatternLock({ error = false, success = false, errorText, + hintText, + hintTone = 'default', + footerText, className, size = 3, 'data-testid': testId, @@ -504,6 +513,19 @@ export function PatternLock({

{errorText ?? t('patternLock.error')}

+ ) : hintText ? ( +

+ {hintText} +

) : selectedNodes.length === 0 ? (

{t('patternLock.hint', { min: minPoints })} @@ -525,7 +547,9 @@ export function PatternLock({ {/* 清除按钮 - 固定高度避免布局抖动 */}

- {selectedNodes.length > 0 && !disabled && !isErrorAnimating && ( + {footerText ? ( +

{footerText}

+ ) : selectedNodes.length > 0 && !disabled && !isErrorAnimating ? ( - )} + ) : null}
); diff --git a/src/stackflow/activities/sheets/MiniappConfirmJobs.regression.test.tsx b/src/stackflow/activities/sheets/MiniappConfirmJobs.regression.test.tsx index 32e1462c7..291bd90f4 100644 --- a/src/stackflow/activities/sheets/MiniappConfirmJobs.regression.test.tsx +++ b/src/stackflow/activities/sheets/MiniappConfirmJobs.regression.test.tsx @@ -79,14 +79,26 @@ vi.mock('@/components/wallet/chain-address-display', () => ({ })); vi.mock('@/components/security/pattern-lock', () => ({ - PatternLock: ({ onComplete }: { onComplete?: (nodes: number[]) => void }) => ( - + PatternLock: ({ + onComplete, + hintText, + footerText, + }: { + onComplete?: (nodes: number[]) => void; + hintText?: string; + footerText?: string; + }) => ( +
+ + {hintText ?? ''} + {footerText ?? ''} +
), patternToString: (nodes: number[]) => nodes.join(''), })); @@ -227,6 +239,39 @@ describe('miniapp confirm jobs regressions', () => { expect(screen.getByTestId('miniapp-sheet-header')).toBeInTheDocument(); }); + it('shows sign service feedback in pattern hint and detail footer', async () => { + vi.mocked(signUnsignedTransaction).mockRejectedValueOnce(new Error('sign service timeout')); + + render( + , + ); + + const signButton = screen.getByTestId('miniapp-sign-review-confirm'); + await waitFor(() => { + expect(signButton).not.toBeDisabled(); + }); + + fireEvent.click(signButton); + fireEvent.click(screen.getByTestId('pattern-lock')); + + await waitFor(() => { + expect(screen.getByTestId('pattern-lock-hint').textContent?.length).toBeGreaterThan(0); + }); + expect(screen.getByTestId('pattern-lock-footer').textContent).toContain('sign service timeout'); + }); + it('does not pass raw amount directly to display layer', () => { render( { fireEvent.click(confirmButton); fireEvent.click(screen.getByTestId('pattern-lock')); - expect(await screen.findByTestId('miniapp-transfer-error')).toBeInTheDocument(); + await waitFor(() => { + expect(screen.getByTestId('pattern-lock-hint').textContent?.length).toBeGreaterThan(0); + }); + expect(screen.getByTestId('pattern-lock-footer').textContent).toContain('Request timeout'); await waitFor(() => { expect(screen.queryByTestId('miniapp-transfer-broadcasting-status')).not.toBeInTheDocument(); }); diff --git a/src/stackflow/activities/sheets/MiniappSignTransactionJob.tsx b/src/stackflow/activities/sheets/MiniappSignTransactionJob.tsx index 13bf8270a..8e09f933f 100644 --- a/src/stackflow/activities/sheets/MiniappSignTransactionJob.tsx +++ b/src/stackflow/activities/sheets/MiniappSignTransactionJob.tsx @@ -44,6 +44,32 @@ type MiniappSignTransactionJobParams = { type SignStep = MiniappSignFlowStep; +function collectErrorMessages(error: unknown): string[] { + const messages: string[] = []; + const visited = new Set(); + let current: unknown = error; + + while (current instanceof Error && !visited.has(current)) { + visited.add(current); + if (current.message) { + messages.push(current.message); + } + current = (current as Error & { cause?: unknown }).cause; + } + + return messages; +} + +function extractSignErrorDetail(error: unknown): string | null { + for (const message of collectErrorMessages(error)) { + const normalized = message.trim(); + if (normalized.length > 0) { + return normalized; + } + } + return null; +} + function MiniappSignTransactionJobContent() { const { t } = useTranslation('common'); const { pop } = useFlow(); @@ -56,6 +82,7 @@ function MiniappSignTransactionJobContent() { const [patternError, setPatternError] = useState(false); const [twoStepSecretError, setTwoStepSecretError] = useState(false); const [errorMessage, setErrorMessage] = useState(null); + const [errorDetail, setErrorDetail] = useState(null); const [isSubmitting, setIsSubmitting] = useState(false); const [requiresTwoStepSecret, setRequiresTwoStepSecret] = useState(false); const [isResolvingTwoStepSecret, setIsResolvingTwoStepSecret] = useState(true); @@ -85,6 +112,7 @@ function MiniappSignTransactionJobContent() { setTwoStepSecret(''); setTwoStepSecretError(false); setErrorMessage(null); + setErrorDetail(null); }, []); useEffect(() => { @@ -120,25 +148,27 @@ function MiniappSignTransactionJobContent() { const handlePatternChange = useCallback( (nextPattern: number[]) => { - if (patternError || errorMessage || twoStepSecretError) { + if (patternError || errorMessage || twoStepSecretError || errorDetail) { setPatternError(false); setTwoStepSecretError(false); setErrorMessage(null); + setErrorDetail(null); } setPattern(nextPattern); }, - [patternError, errorMessage, twoStepSecretError], + [patternError, errorMessage, twoStepSecretError, errorDetail], ); const handleTwoStepSecretChange = useCallback( (value: string) => { - if (twoStepSecretError || errorMessage) { + if (twoStepSecretError || errorMessage || errorDetail) { setTwoStepSecretError(false); setErrorMessage(null); + setErrorDetail(null); } setTwoStepSecret(value); }, - [twoStepSecretError, errorMessage], + [twoStepSecretError, errorMessage, errorDetail], ); const performSign = useCallback( @@ -180,6 +210,7 @@ function MiniappSignTransactionJobContent() { setPatternError(false); setTwoStepSecretError(false); setErrorMessage(null); + setErrorDetail(null); try { setStep('signing'); @@ -189,15 +220,20 @@ function MiniappSignTransactionJobContent() { if (isMiniappWalletLockError(error)) { setPatternError(true); setErrorMessage(t('walletLock.error')); + setErrorDetail(null); + setStep('wallet_lock'); } else if (isMiniappTwoStepSecretError(error)) { setPatternError(false); setTwoStepSecretError(true); setErrorMessage(t('transaction:sendPage.twoStepSecretError')); + setErrorDetail(null); setStep('two_step_secret'); } else { setPatternError(false); setTwoStepSecretError(false); setErrorMessage(t('signingFailed')); + setErrorDetail(extractSignErrorDetail(error)); + setStep('wallet_lock'); } setPattern([]); } finally { @@ -216,6 +252,7 @@ function MiniappSignTransactionJobContent() { setIsSubmitting(true); setTwoStepSecretError(false); setErrorMessage(null); + setErrorDetail(null); try { setStep('signing'); @@ -225,14 +262,17 @@ function MiniappSignTransactionJobContent() { if (isMiniappTwoStepSecretError(error)) { setTwoStepSecretError(true); setErrorMessage(t('transaction:sendPage.twoStepSecretError')); + setErrorDetail(null); setStep('two_step_secret'); } else if (isMiniappWalletLockError(error)) { setPatternError(true); setErrorMessage(t('walletLock.error')); + setErrorDetail(null); setStep('wallet_lock'); } else { setTwoStepSecretError(false); setErrorMessage(t('signingFailed')); + setErrorDetail(extractSignErrorDetail(error)); setStep('two_step_secret'); } } finally { @@ -261,6 +301,9 @@ function MiniappSignTransactionJobContent() { } }, [unsignedTx]); + const walletLockServiceMessage = !patternError && errorMessage ? errorMessage : null; + const walletLockErrorDetail = !patternError ? errorDetail : null; + return (
@@ -343,6 +386,7 @@ function MiniappSignTransactionJobContent() { {t('cancel')}