From 3ff33640f43c09397e3e01aa2e065b779d42197e Mon Sep 17 00:00:00 2001 From: ZL-Q Date: Sun, 10 May 2026 21:37:29 +0800 Subject: [PATCH] fix: infinite session creation loop, add sign image animation, align hexagram UI with Flutter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fix DivinationProcessingOverlay infinite re-render loop by using refs for params/yaoStates/onError instead of direct effect dependencies - Remove column headers from hexagram detail card (matching Flutter) - Keep domain-specific terms (六神, 六亲, etc.) in Chinese for all locales - Translate ganZhiInfo/wuXingWangShuai/ganZhiKongWang section titles to English - Add sign image overlay animation on fresh divination result (scale + translate) --- .../DivinationProcessingOverlay.tsx | 14 ++- web/src/components/DivinationResultPage.tsx | 102 +++++++++++++----- web/src/i18n/utils.ts | 2 +- 3 files changed, 88 insertions(+), 30 deletions(-) diff --git a/web/src/components/DivinationProcessingOverlay.tsx b/web/src/components/DivinationProcessingOverlay.tsx index 5878aea..bfa3cac 100644 --- a/web/src/components/DivinationProcessingOverlay.tsx +++ b/web/src/components/DivinationProcessingOverlay.tsx @@ -110,6 +110,12 @@ export default function DivinationProcessingOverlay({ const [flipAngle, setFlipAngle] = useState(0); const [result, setResult] = useState(null); const isFlippingRef = useRef(false); + const paramsRef = useRef(params); + const yaoStatesRef = useRef(yaoStates); + const onErrorRef = useRef(onError); + paramsRef.current = params; + yaoStatesRef.current = yaoStates; + onErrorRef.current = onError; // 翻牌动画 useEffect(() => { @@ -154,7 +160,7 @@ export default function DivinationProcessingOverlay({ async function runDivination() { try { // 1. 提交起卦请求 - const { threadId, runId } = await enqueueDivinationRun(params, yaoStates); + const { threadId, runId } = await enqueueDivinationRun(paramsRef.current, yaoStatesRef.current); invalidatePoints(); if (aborted) return; @@ -261,8 +267,8 @@ export default function DivinationProcessingOverlay({ } } } catch (error) { - if (!aborted && onError) { - onError(error instanceof Error ? error : new Error(String(error))); + if (!aborted && onErrorRef.current) { + onErrorRef.current(error instanceof Error ? error : new Error(String(error))); } } } @@ -272,7 +278,7 @@ export default function DivinationProcessingOverlay({ return () => { aborted = true; }; - }, [params, yaoStates, onError]); + }, []); const currentCard = cards[cardIndex]; diff --git a/web/src/components/DivinationResultPage.tsx b/web/src/components/DivinationResultPage.tsx index 64dcfe9..8954a9c 100644 --- a/web/src/components/DivinationResultPage.tsx +++ b/web/src/components/DivinationResultPage.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from 'react'; +import { useCallback, useEffect, useRef, useState } from 'react'; import { useLocation, useNavigate, useParams, useSearchParams } from 'react-router-dom'; import { historyMessageToResultData, type DivinationResultData, type YaoType } from '../lib/api'; import { useHistoryThread } from '../lib/resources'; @@ -134,6 +134,69 @@ function FocusPointsCard({ points, title, copyLabel }: { points: string[]; title ); } +const SIGN_ANIM_SIZE = 380; +const SIGN_ANIM_DISPLAY_MS = 800; +const SIGN_ANIM_SHRINK_MS = 600; + +function SignAnimationOverlay({ + src, + targetRef, + onAnimationEnd, +}: { + src: string; + targetRef: React.RefObject; + onAnimationEnd: () => void; +}) { + const [shrinking, setShrinking] = useState(false); + + useEffect(() => { + const t = setTimeout(() => setShrinking(true), SIGN_ANIM_DISPLAY_MS); + return () => clearTimeout(t); + }, []); + + useEffect(() => { + if (shrinking) { + const t = setTimeout(onAnimationEnd, SIGN_ANIM_SHRINK_MS + 100); + return () => clearTimeout(t); + } + }, [shrinking, onAnimationEnd]); + + let imgStyle: React.CSSProperties = { + width: SIGN_ANIM_SIZE, + height: SIGN_ANIM_SIZE, + borderRadius: 14, + boxShadow: '0 20px 40px rgba(0,0,0,0.25)', + animation: 'signAppear 400ms ease-out', + transition: `transform ${SIGN_ANIM_SHRINK_MS}ms cubic-bezier(0.4,0,0.2,1), opacity ${SIGN_ANIM_SHRINK_MS - 100}ms ease 100ms`, + }; + + if (shrinking && targetRef.current) { + const rect = targetRef.current.getBoundingClientRect(); + const dx = rect.left + rect.width / 2 - window.innerWidth / 2; + const dy = rect.top + rect.height / 2 - window.innerHeight / 2; + const s = rect.width / SIGN_ANIM_SIZE; + imgStyle.transform = `translate(${dx}px, ${dy}px) scale(${s})`; + imgStyle.opacity = 0; + } + + return ( +
+
+
+ +
+ +
+ ); +} + function WarningCard({ message }: { message: string }) { return (
@@ -284,29 +347,6 @@ function HexagramDetailCard({ data, t }: { data: DivinationResultData; t: Record )}
-
-
- {t.yaoColSpirit} - {t.yaoColRelation} - {t.yaoColBranch} - {t.yaoColElement} - - {t.yaoColChange} - {t.yaoColMark} -
- {hasChangingYao && ( -
- {t.yaoColSpirit} - {t.yaoColRelation} - {t.yaoColBranch} - {t.yaoColElement} - - {t.yaoColChange} - -
- )} -
- {[5, 4, 3, 2, 1, 0].map((idx) => ( (null); const [loading, setLoading] = useState(true); const [canFollowUp, setCanFollowUp] = useState(true); + const [signAnimating, setSignAnimating] = useState(() => { + return !!(location.state as { result?: DivinationResultData } | null)?.result; + }); + const signImageRef = useRef(null); + const handleSignAnimationEnd = useCallback(() => setSignAnimating(false), []); useEffect(() => { // 1. Try router state (from divination flow) @@ -421,6 +466,13 @@ export default function DivinationResultPage({ locale, translations: t }: Props) return (
+ {signAnimating && ( + + )} {/* Header */}