fix: infinite session creation loop, add sign image animation, align hexagram UI with Flutter

- 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)
This commit is contained in:
ZL-Q
2026-05-10 21:37:29 +08:00
parent 982d10d37e
commit 3ff33640f4
3 changed files with 88 additions and 30 deletions
@@ -110,6 +110,12 @@ export default function DivinationProcessingOverlay({
const [flipAngle, setFlipAngle] = useState(0);
const [result, setResult] = useState<DivinationResultData | null>(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];
+77 -25
View File
@@ -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<HTMLDivElement | null>;
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 (
<div className="fixed inset-0 z-50 flex items-center justify-center">
<div
className="absolute inset-0 bg-slate-100/95"
style={{
backdropFilter: 'blur(8px)',
transition: `opacity ${SIGN_ANIM_SHRINK_MS}ms ease-out`,
opacity: shrinking ? 0 : 1,
}}
/>
<div className="relative overflow-hidden" style={imgStyle}>
<img src={src} className="w-full h-full object-cover" />
</div>
<style>{`@keyframes signAppear{from{transform:scale(.85);opacity:0}to{transform:scale(1);opacity:1}}`}</style>
</div>
);
}
function WarningCard({ message }: { message: string }) {
return (
<div className="bg-amber-50 rounded-xl p-3.5 flex items-start gap-2.5">
@@ -284,29 +347,6 @@ function HexagramDetailCard({ data, t }: { data: DivinationResultData; t: Record
)}
</div>
<div className="flex gap-4 text-xs text-slate-400">
<div className="flex-1 flex items-center gap-1">
<span className="w-5 text-center">{t.yaoColSpirit}</span>
<span className="w-7 text-center">{t.yaoColRelation}</span>
<span className="w-5 text-center">{t.yaoColBranch}</span>
<span className="w-5 text-center">{t.yaoColElement}</span>
<span className="flex-1" />
<span className="w-4 text-center">{t.yaoColChange}</span>
<span className="w-4 text-center">{t.yaoColMark}</span>
</div>
{hasChangingYao && (
<div className="flex-1 flex items-center gap-1">
<span className="w-5 text-center">{t.yaoColSpirit}</span>
<span className="w-7 text-center">{t.yaoColRelation}</span>
<span className="w-5 text-center">{t.yaoColBranch}</span>
<span className="w-5 text-center">{t.yaoColElement}</span>
<span className="flex-1" />
<span className="w-4 text-center">{t.yaoColChange}</span>
<span className="w-4 text-center" />
</div>
)}
</div>
{[5, 4, 3, 2, 1, 0].map((idx) => (
<YaoDetailRow
key={idx}
@@ -346,6 +386,11 @@ export default function DivinationResultPage({ locale, translations: t }: Props)
const [data, setData] = useState<DivinationResultData | null>(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<HTMLDivElement>(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 (
<div className="flex flex-col gap-5 min-h-full">
{signAnimating && (
<SignAnimationOverlay
src={getSignImageSrc(data.signType)}
targetRef={signImageRef}
onAnimationEnd={handleSignAnimationEnd}
/>
)}
{/* Header */}
<div className="flex items-center gap-3.5">
<button
@@ -439,7 +491,7 @@ export default function DivinationResultPage({ locale, translations: t }: Props)
<div className="flex-1 flex flex-col gap-3.5 overflow-y-auto pr-1">
{/* Hero: sign image + gua name + question + tags */}
<div className="bg-white rounded-2xl p-5 border border-slate-200 flex items-center gap-5">
<div className="w-[116px] h-[116px] rounded-[14px] overflow-hidden shrink-0 shadow-lg">
<div ref={signImageRef} className="w-[116px] h-[116px] rounded-[14px] overflow-hidden shrink-0 shadow-lg">
<img src={getSignImageSrc(data.signType)} alt={getSignTypeLabel(data.signType, t)} className="w-full h-full object-cover" />
</div>
<div className="flex-1 min-w-0 flex flex-col gap-2.5">