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:
@@ -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];
|
||||
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -99,7 +99,7 @@ const translations: Record<Locale, Translations> = {
|
||||
profile: { avatarTitle: 'Avatar', avatarHint: 'Supports PNG / JPG / WEBP. Square images recommended.', uploadBtn: 'Upload Avatar', formTitle: 'Basic Info', emailLabel: 'Email', displayNameLabel: 'Display Name', displayNamePlaceholder: 'Enter display name', bioLabel: 'Bio', bioPlaceholder: 'Enter your bio', saveBtn: 'Save', cancelBtn: 'Cancel' },
|
||||
divination: { questionTitle: 'Ask Your Question', questionPlaceholder: 'Enter your question...', categoryLabel: 'Category', categories: 'Career,Love,Wealth,Fortune,Dreams,Health,Study,Lost Items,Other', timeTitle: 'Casting Time', timeHint: 'Uses current time by default, or pick manually', guideTitle: 'Guide', guideManual: 'Manual casting requires you to toss three coins six times. Follow these steps:\n\n1. Focus on your question\n2. Click coins below to set inscription/pattern\n3. Toss three coins per line\n4. Repeat six times to complete', guideAuto: 'Auto casting generates a hexagram randomly. Follow these steps:\n\n1. Focus on your question\n2. Click "Shake" button\n3. System generates the hexagram', yaoTitle: 'Six Lines', coinLabel: 'Click coins to set inscription/pattern', confirmBtn: 'Confirm Line', summaryTitle: 'Review Before Submit', checkCategory: 'Category: Career', checkMethod: 'Method: Manual Cast', checkCost: 'Cost: 20 credits', submitBtn: 'Confirm & Submit', shakeTitle: 'Shake', shakeBtn: 'Shake', hexPreview: 'Hexagram Preview', progressLabel: 'Progress' },
|
||||
history: { title: 'Reading History', statTotal: 'Total', statFollow: 'Follow-up', statLatest: 'Latest', filters: 'Quick Filters', filterAll: 'All', filterCareer: 'Career', filterLove: 'Love', filterWealth: 'Wealth', noResults: 'No readings yet', resultTitle: 'Reading Result', conclusion: 'Conclusion', suggestion: 'Suggestion', analysis: 'Detailed Analysis', focus: 'Key Focus', warning: 'AI-generated interpretation for entertainment only. Not professional advice.', followUpTitle: 'Follow-up', followUpDesc: 'Have questions about the reading? Ask one follow-up for deeper insights.', followUpBtn: 'Start Follow-up', chatTitle: 'Follow-up Chat', chatPlaceholder: 'Enter your follow-up question...', sendBtn: 'Send', relatedActions: 'Related Actions', newDivination: 'New Reading', viewHistory: 'View History', followUpRules: 'Follow-up Rules', followUpRule1: 'One follow-up per reading', followUpRule2: 'Follow-up is completely free', basicInfo: 'Basic Info', ganzhi: 'Stem-Branch Info', hexagramDetail: 'Hexagram Details' },
|
||||
result: { screenTitle: 'Reading Result', conclusion: 'Conclusion', suggestion: 'Suggestion', analysis: 'Detailed Analysis', focusPoints: 'Key Points', warning: 'AI-generated interpretation for entertainment only. Not professional advice.', basicInfo: 'Basic Info', divinationInfo: 'Casting Info', divinationTime: 'Casting Time', divinationMethod: 'Method', questionType: 'Category', question: 'Question', autoMethod: 'Auto Cast', manualMethod: 'Manual Cast', hexagramDetail: 'Hexagram Details', hexagramDetailFailed: 'Reading failed, details unavailable', hexagramDetailRefused: 'Reading not supported, please adjust your question', copy: 'Copy', ganZhiInfo: 'Stem-Branch Info', wuXingWangShuai: 'Five Elements Status', ganZhiKongWang: 'Void Branches', termYueJian: 'Month Command', termRiChen: 'Day Pillar', termYuePo: 'Month Break', termRiChong: 'Day Clash', pillarColumn: 'Pillar', yearPillar: 'Year', monthPillar: 'Month', dayPillar: 'Day', timePillar: 'Hour', ganZhiLabel: 'Stem-Branch', kongWangLabel: 'Void', questionTypeCareer: 'Career', questionTypeLove: 'Love', questionTypeWealth: 'Wealth', questionTypeFortune: 'Fortune', questionTypeDream: 'Dreams', questionTypeHealth: 'Health', questionTypeStudy: 'Study', questionTypeSearch: 'Lost Items', questionTypeOther: 'Other', signTypeShangShang: 'Best', signTypeZhongShang: 'Good', signTypeZhongXia: 'Fair', signTypeXiaXia: 'Poor', yaoColSpirit: 'Spirit', yaoColRelation: 'Relation', yaoColBranch: 'Branch', yaoColElement: 'Element', yaoColChange: 'Change', yaoColMark: 'Mark', followUpEntryHint: 'You can ask one follow-up question', followUpEntryAction: 'Follow-up', followUpQuotaUsed: 'Follow-up quota used for this session', followUpViewHistory: 'View History' },
|
||||
result: { screenTitle: 'Reading Result', conclusion: 'Conclusion', suggestion: 'Suggestion', analysis: 'Detailed Analysis', focusPoints: 'Key Points', warning: 'AI-generated interpretation for entertainment only. Not professional advice.', basicInfo: 'Basic Info', divinationInfo: 'Casting Info', divinationTime: 'Casting Time', divinationMethod: 'Method', questionType: 'Category', question: 'Question', autoMethod: 'Auto Cast', manualMethod: 'Manual Cast', hexagramDetail: 'Hexagram Details', hexagramDetailFailed: 'Reading failed, details unavailable', hexagramDetailRefused: 'Reading not supported, please adjust your question', copy: 'Copy', ganZhiInfo: 'Stem-Branch', wuXingWangShuai: 'Five Elements', ganZhiKongWang: 'Void', termYueJian: '月建', termRiChen: '日辰', termYuePo: '月破', termRiChong: '日冲', pillarColumn: '四柱', yearPillar: '年柱', monthPillar: '月柱', dayPillar: '日柱', timePillar: '时柱', ganZhiLabel: '干支', kongWangLabel: '空亡', questionTypeCareer: 'Career', questionTypeLove: 'Love', questionTypeWealth: 'Wealth', questionTypeFortune: 'Fortune', questionTypeDream: 'Dreams', questionTypeHealth: 'Health', questionTypeStudy: 'Study', questionTypeSearch: 'Lost Items', questionTypeOther: 'Other', signTypeShangShang: '上上签', signTypeZhongShang: '中上签', signTypeZhongXia: '中下签', signTypeXiaXia: '下下签', yaoColSpirit: '六神', yaoColRelation: '六亲', yaoColBranch: '地支', yaoColElement: '五行', yaoColChange: '动', yaoColMark: '标', followUpEntryHint: 'You can ask one follow-up question', followUpEntryAction: 'Follow-up', followUpQuotaUsed: 'Follow-up quota used for this session', followUpViewHistory: 'View History' },
|
||||
general: { title: 'General Settings', languageLabel: 'Language', languageValue: 'Interface Language', privacyTitle: 'Privacy', doNotSell: 'Personalized Ads', doNotSellHint: 'When off, your personal info won\'t be used for ad recommendations', notificationTitle: 'Notifications', allowNotification: 'Allow notifications', saveSuccess: 'Saved successfully', saveFailed: 'Failed to save' },
|
||||
feedback: { title: 'Feedback', typeLabel: 'Feedback Type', typeBug: 'Bug', typeSuggestion: 'Suggestion', typeOther: 'Other', contentLabel: 'Content', contentPlaceholder: 'Please describe your issue or suggestion in detail...', imagesLabel: 'Add Screenshots (max 3)', anonymousLabel: 'Do not upload my personal information', anonymousHint: 'If checked, your user ID will not be collected. Device info will still be collected for troubleshooting.', submitBtn: 'Submit Feedback', submitting: 'Submitting...', success: 'Thank you for your feedback. We will process it soon.', error: 'Failed to submit. Please try again.', contentRequired: 'Please enter feedback content', contentTooLong: 'Feedback content cannot exceed 500 characters', tooManyImages: 'Maximum 3 images allowed', imageTooLarge: 'Image size cannot exceed 5MB' },
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user