Merge dev into main: CREEM payment, performance optimization, deploy updates #8
@@ -1,145 +1,744 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import Icon from './Icon';
|
||||
import { getPointsBalance, type PointsBalance } from '../lib/api';
|
||||
import DivinationProcessingOverlay from './DivinationProcessingOverlay';
|
||||
import { getPointsBalance, getUserProfile, updateUserSettings, type PointsBalance, type DivinationResultData, type YaoType } from '../lib/api';
|
||||
import { useUserSettings } from './AppShell';
|
||||
|
||||
interface Props {
|
||||
locale: string;
|
||||
dashboard: { brandName: string; navHome: string; navStore: string; navDivination: string; navManual: string; navAuto: string; navHistory: string; navLanguage: string; navSettings: string; logout: string };
|
||||
divination: { questionTitle: string; questionPlaceholder: string; categoryLabel: string; categories: string; timeTitle: string; timeHint: string; guideTitle: string; guideAuto: string; shakeTitle: string; shakeBtn: string; hexPreview: string; summaryTitle: string; checkCategory: string; checkMethod: string; checkCost: string; submitBtn: string; progressLabel: string };
|
||||
divination: { questionTitle: string; questionPlaceholder: string; categoryLabel: string; categories: string; timeTitle: string; timeHint: string; guideTitle: string; guideAuto: string; yaoTitle: string; coinLabel: string; confirmBtn: string; summaryTitle: string; checkCategory: string; checkMethod: string; checkCost: string; submitBtn: string; shakeTitle: string; shakeBtn: string; hexPreview: string; progressLabel: string };
|
||||
}
|
||||
|
||||
type CoinFace = 'zi' | 'hua';
|
||||
const TOTAL_YAO_COUNT = 6;
|
||||
const SHAKE_DURATION_PER_YAO = 3; // 3 seconds per yao
|
||||
|
||||
function formatDateTimeInput(value: Date): string {
|
||||
const pad = (n: number) => String(n).padStart(2, '0');
|
||||
return `${value.getFullYear()}-${pad(value.getMonth() + 1)}-${pad(value.getDate())}T${pad(value.getHours())}:${pad(value.getMinutes())}`;
|
||||
}
|
||||
|
||||
function fromHuaCount(huaCount: number): YaoType {
|
||||
switch (huaCount) {
|
||||
case 0: return 'oldYin';
|
||||
case 1: return 'youngYang';
|
||||
case 2: return 'youngYin';
|
||||
case 3: return 'oldYang';
|
||||
default: return 'youngYang';
|
||||
}
|
||||
}
|
||||
|
||||
function randomYao(): YaoType {
|
||||
return fromHuaCount(Math.floor(Math.random() * 4));
|
||||
}
|
||||
|
||||
// Get coin combination for a YaoType
|
||||
function coinsForYaoType(type: YaoType): [CoinFace, CoinFace, CoinFace] {
|
||||
switch (type) {
|
||||
case 'oldYin': return ['zi', 'zi', 'zi']; // 0 hua
|
||||
case 'youngYang': return ['hua', 'zi', 'zi']; // 1 hua
|
||||
case 'youngYin': return ['hua', 'hua', 'zi']; // 2 hua
|
||||
case 'oldYang': return ['hua', 'hua', 'hua']; // 3 hua
|
||||
}
|
||||
}
|
||||
|
||||
function CoinImage({ face, spinning }: { face: CoinFace; spinning?: boolean }) {
|
||||
return (
|
||||
<img
|
||||
src={face === 'zi' ? '/images/qigua/zi.jpg' : '/images/qigua/hua.jpg'}
|
||||
alt={face === 'zi' ? '字' : '花'}
|
||||
className={`h-20 w-20 rounded-full object-cover shadow-md ${spinning ? 'coin-spin' : ''}`}
|
||||
draggable={false}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function YaoGlyph({ type, confirmed }: { type?: YaoType; confirmed?: boolean }) {
|
||||
const color = confirmed ? 'bg-violet-700' : 'bg-slate-200';
|
||||
if (!type || type === 'youngYang' || type === 'oldYang') {
|
||||
return <div className={`h-2.5 w-full rounded-full ${color}`} />;
|
||||
}
|
||||
return (
|
||||
<div className="flex h-2.5 w-full gap-4">
|
||||
<div className={`h-2.5 flex-1 rounded-full ${color}`} />
|
||||
<div className={`h-2.5 flex-1 rounded-full ${color}`} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function YaoChangeMark({ type }: { type?: YaoType }) {
|
||||
if (type === 'oldYang') return <span className="text-violet-700 font-bold">○</span>;
|
||||
if (type === 'oldYin') return <span className="text-violet-700 font-bold">×</span>;
|
||||
return null;
|
||||
}
|
||||
|
||||
const copy = {
|
||||
zh: {
|
||||
title: '自动起卦',
|
||||
subtitle: '点击摇卦按钮,系统自动生成六爻卦象。',
|
||||
defaultQuestion: '我接下来三个月的事业发展需要注意什么?',
|
||||
modify: '修改',
|
||||
guideLines: ['系统自动为您生成六爻卦象。', '从初爻到上爻依次摇出。', '每爻摇卦需要等待3秒。'],
|
||||
openGuide: '查看自动起卦教程',
|
||||
guideSteps: [
|
||||
['自动起卦', '系统会自动为您生成六爻卦象,从初爻到上爻依次摇出。'],
|
||||
['确认时间', '先确认起卦时间。如需调整,点击右侧「修改」。'],
|
||||
['依次摇卦', '点击「摇一摇」按钮,系统会依次摇出六爻。每爻需要等待3秒。'],
|
||||
['开始分析', '六爻都完成后,下方「开始解卦」按钮会激活,点击即可解卦。'],
|
||||
],
|
||||
closeGuide: '结束教程',
|
||||
nextGuide: '下一步',
|
||||
prevGuide: '上一步',
|
||||
lineNames: ['初爻', '二爻', '三爻', '四爻', '五爻', '上爻'],
|
||||
zi: '字',
|
||||
hua: '花',
|
||||
questionTypePrefix: '问题类型',
|
||||
method: '起卦方式:自动起卦',
|
||||
submit: '开始解卦',
|
||||
shake: '摇一摇',
|
||||
shaking: '摇卦中...',
|
||||
shakingYao: '第 N 爻',
|
||||
yaoComplete: '完成',
|
||||
confirmTitle: '确认解卦',
|
||||
confirmAvailable: '当前积分',
|
||||
confirmCost: '本次消耗',
|
||||
confirmRemaining: '解卦后剩余',
|
||||
cancel: '取消',
|
||||
confirm: '确认',
|
||||
},
|
||||
zh_Hant: {
|
||||
title: '自動起卦',
|
||||
subtitle: '點擊搖卦按鈕,系統自動生成六爻卦象。',
|
||||
defaultQuestion: '我接下來三個月的事業發展需要注意什麼?',
|
||||
modify: '修改',
|
||||
guideLines: ['系統自動為您生成六爻卦象。', '從初爻到上爻依次搖出。', '每爻搖卦需要等待3秒。'],
|
||||
openGuide: '查看自動起卦教程',
|
||||
guideSteps: [
|
||||
['自動起卦', '系統會自動為您生成六爻卦象,從初爻到上爻依次搖出。'],
|
||||
['確認時間', '先確認起卦時間。如需調整,點擊右側「修改」。'],
|
||||
['依序搖卦', '點擊「搖一搖」按鈕,系統會依序搖出六爻。每爻需要等待3秒。'],
|
||||
['開始分析', '六爻都完成後,下方「開始解卦」按鈕會激活,點擊即可解卦。'],
|
||||
],
|
||||
closeGuide: '結束教程',
|
||||
nextGuide: '下一步',
|
||||
prevGuide: '上一步',
|
||||
lineNames: ['初爻', '二爻', '三爻', '四爻', '五爻', '上爻'],
|
||||
zi: '字',
|
||||
hua: '花',
|
||||
questionTypePrefix: '問題類型',
|
||||
method: '起卦方式:自動起卦',
|
||||
submit: '開始解卦',
|
||||
shake: '搖一搖',
|
||||
shaking: '搖卦中...',
|
||||
shakingYao: '第 N 爻',
|
||||
yaoComplete: '完成',
|
||||
confirmTitle: '確認解卦',
|
||||
confirmAvailable: '當前積分',
|
||||
confirmCost: '本次消耗',
|
||||
confirmRemaining: '解卦後剩餘',
|
||||
cancel: '取消',
|
||||
confirm: '確認',
|
||||
},
|
||||
en: {
|
||||
title: 'Auto Casting',
|
||||
subtitle: 'Click the shake button to automatically generate a six-line hexagram.',
|
||||
defaultQuestion: 'What should I pay attention to in my career development over the next three months?',
|
||||
modify: 'Modify',
|
||||
guideLines: ['The system will automatically generate a six-line hexagram for you.', 'Lines are cast from bottom to top.', 'Each line takes 3 seconds to cast.'],
|
||||
openGuide: 'View Auto Casting Guide',
|
||||
guideSteps: [
|
||||
['Auto Casting', 'The system will automatically generate a six-line hexagram, casting from the first yao to the top yao.'],
|
||||
['Confirm Time', 'Check the casting time first. Tap "Modify" on the right if you need to adjust it.'],
|
||||
['Cast in Order', 'Click "Shake" button to cast all six lines. Each line takes 3 seconds.'],
|
||||
['Start Analysis', 'After all six yao are complete, the "Start Interpretation" button will activate.'],
|
||||
],
|
||||
closeGuide: 'Finish',
|
||||
nextGuide: 'Next',
|
||||
prevGuide: 'Back',
|
||||
lineNames: ['First Yao', 'Second Yao', 'Third Yao', 'Fourth Yao', 'Fifth Yao', 'Top Yao'],
|
||||
zi: 'Inscription',
|
||||
hua: 'Pattern',
|
||||
questionTypePrefix: 'Category',
|
||||
method: 'Method: Auto Casting',
|
||||
submit: 'Start Interpretation',
|
||||
shake: 'Shake',
|
||||
shaking: 'Shaking...',
|
||||
shakingYao: 'Yao N',
|
||||
yaoComplete: 'Done',
|
||||
confirmTitle: 'Confirm Interpretation',
|
||||
confirmAvailable: 'Available credits',
|
||||
confirmCost: 'This reading cost',
|
||||
confirmRemaining: 'Remaining after',
|
||||
cancel: 'Cancel',
|
||||
confirm: 'Confirm',
|
||||
},
|
||||
} as const;
|
||||
|
||||
export default function AutoDivinationPage({ locale, divination: d }: Props) {
|
||||
const cats = d.categories.split(',');
|
||||
const text = copy[locale as keyof typeof copy] ?? copy.zh;
|
||||
const cats = useMemo(() => d.categories.split(','), [d.categories]);
|
||||
const navigate = useNavigate();
|
||||
const [category, setCategory] = useState(cats[0]);
|
||||
const [question, setQuestion] = useState('');
|
||||
const [progress, setProgress] = useState(0);
|
||||
const [hexLines, setHexLines] = useState<boolean[]>([]);
|
||||
const [isShaking, setIsShaking] = useState(false);
|
||||
const [question, setQuestion] = useState(text.defaultQuestion);
|
||||
const [selectedTime, setSelectedTime] = useState(() => formatDateTimeInput(new Date()));
|
||||
const [yaoResults, setYaoResults] = useState<YaoType[]>([]);
|
||||
const [guideStep, setGuideStep] = useState<number | null>(null);
|
||||
const [points, setPoints] = useState<PointsBalance | null>(null);
|
||||
const [showProcessing, setShowProcessing] = useState(false);
|
||||
const [showConfirm, setShowConfirm] = useState(false);
|
||||
const { userProfile, setUserProfile } = useUserSettings();
|
||||
|
||||
// Shake state
|
||||
const [isShaking, setIsShaking] = useState(false);
|
||||
const [currentShakingYao, setCurrentShakingYao] = useState(0);
|
||||
const [countdown, setCountdown] = useState(0);
|
||||
const [currentCoins, setCurrentCoins] = useState<[CoinFace, CoinFace, CoinFace]>(['zi', 'zi', 'zi']);
|
||||
|
||||
// Refs for guide spotlight
|
||||
const coinsAreaRef = useRef<HTMLDivElement>(null);
|
||||
const timePanelRef = useRef<HTMLElement>(null);
|
||||
const yaoPanelRef = useRef<HTMLElement>(null);
|
||||
const submitBtnRef = useRef<HTMLButtonElement>(null);
|
||||
const scrollContainerRef = useRef<HTMLDivElement>(null);
|
||||
const [spotlightRect, setSpotlightRect] = useState<{ left: number; top: number; width: number; height: number } | null>(null);
|
||||
const [tooltipPos, setTooltipPos] = useState<{ left: number; top: number }>({ left: 0, top: 0 });
|
||||
const [tooltipSide, setTooltipSide] = useState<'right' | 'left' | 'bottom' | 'top'>('right');
|
||||
const [isMobile, setIsMobile] = useState(() => typeof window !== 'undefined' && window.innerWidth < 1280);
|
||||
|
||||
const [tutorialChecked, setTutorialChecked] = useState(false);
|
||||
|
||||
// Auto-show tutorial on first visit
|
||||
useEffect(() => {
|
||||
if (tutorialChecked) return;
|
||||
const tutorialSettings = userProfile?.settings?.divination_tutorial;
|
||||
if (tutorialSettings && !tutorialSettings.auto_divination_shown) {
|
||||
const timer = setTimeout(() => {
|
||||
setTutorialChecked(true);
|
||||
setGuideStep(0);
|
||||
}, 400);
|
||||
return () => clearTimeout(timer);
|
||||
} else if (userProfile !== null) {
|
||||
setTutorialChecked(true);
|
||||
}
|
||||
}, [userProfile, tutorialChecked]);
|
||||
|
||||
// Mark tutorial as shown when guide ends
|
||||
const closeGuide = async () => {
|
||||
setGuideStep(null);
|
||||
if (userProfile && !userProfile.settings.divination_tutorial.auto_divination_shown) {
|
||||
const updatedSettings = {
|
||||
...userProfile.settings,
|
||||
divination_tutorial: {
|
||||
...userProfile.settings.divination_tutorial,
|
||||
auto_divination_shown: true,
|
||||
},
|
||||
};
|
||||
try {
|
||||
const updated = await updateUserSettings({ settings: updatedSettings });
|
||||
setUserProfile(updated);
|
||||
} catch {
|
||||
// Silently fail
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const prevGuideStepRef = useRef<number | null>(null);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (guideStep === null) {
|
||||
setSpotlightRect(null);
|
||||
prevGuideStepRef.current = null;
|
||||
return;
|
||||
}
|
||||
|
||||
const targetRef = [coinsAreaRef, timePanelRef, yaoPanelRef, submitBtnRef][guideStep];
|
||||
if (!targetRef?.current) return;
|
||||
|
||||
const tooltipWidth = 320;
|
||||
const tooltipHeight = 180;
|
||||
const gap = 16;
|
||||
|
||||
const scrollContainer = scrollContainerRef.current?.parentElement?.parentElement as HTMLElement | null;
|
||||
if (!scrollContainer) return;
|
||||
|
||||
const isInitialOpen = prevGuideStepRef.current === null;
|
||||
|
||||
if (isMobile) {
|
||||
const containerRect = scrollContainer.getBoundingClientRect();
|
||||
const elementRect = targetRef.current.getBoundingClientRect();
|
||||
|
||||
const elementLeft = elementRect.left - containerRect.left;
|
||||
const elementTop = elementRect.top - containerRect.top + scrollContainer.scrollTop;
|
||||
const elementWidth = elementRect.width;
|
||||
const elementHeight = elementRect.height;
|
||||
|
||||
if (isInitialOpen) {
|
||||
scrollContainer.scrollTop = 0;
|
||||
}
|
||||
|
||||
const scrollTopNeeded = Math.max(0, elementTop - 20);
|
||||
scrollContainer.scrollTo({ top: scrollTopNeeded, behavior: 'smooth' });
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
if (!targetRef.current) return;
|
||||
const newElementRect = targetRef.current.getBoundingClientRect();
|
||||
const newContainerRect = scrollContainer.getBoundingClientRect();
|
||||
|
||||
const spotlightLeft = newElementRect.left - newContainerRect.left;
|
||||
const spotlightTop = newElementRect.top - newContainerRect.top;
|
||||
|
||||
const tooltipLeft = Math.max(16, Math.min(
|
||||
(newElementRect.left + newElementRect.right - tooltipWidth) / 2 - newContainerRect.left,
|
||||
containerRect.width - tooltipWidth - 16
|
||||
));
|
||||
const tooltipTop = spotlightTop + elementHeight + gap;
|
||||
|
||||
setSpotlightRect({
|
||||
left: spotlightLeft,
|
||||
top: spotlightTop,
|
||||
width: elementWidth,
|
||||
height: elementHeight
|
||||
});
|
||||
setTooltipPos({ left: tooltipLeft, top: tooltipTop });
|
||||
setTooltipSide('bottom');
|
||||
});
|
||||
|
||||
prevGuideStepRef.current = guideStep;
|
||||
return;
|
||||
}
|
||||
|
||||
targetRef.current.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
|
||||
const rect = targetRef.current.getBoundingClientRect();
|
||||
let tooltipLeft: number;
|
||||
let tooltipTop: number;
|
||||
let side: 'right' | 'left' | 'bottom' | 'top';
|
||||
|
||||
if (rect.right + gap + tooltipWidth <= window.innerWidth) {
|
||||
tooltipLeft = rect.right + gap;
|
||||
tooltipTop = rect.top;
|
||||
side = 'right';
|
||||
} else if (rect.left >= tooltipWidth + gap) {
|
||||
tooltipLeft = rect.left - tooltipWidth - gap;
|
||||
tooltipTop = rect.top;
|
||||
side = 'left';
|
||||
} else {
|
||||
tooltipLeft = Math.max(16, Math.min(rect.left, window.innerWidth - tooltipWidth - 16));
|
||||
tooltipTop = rect.bottom + gap;
|
||||
side = 'bottom';
|
||||
}
|
||||
|
||||
if (tooltipTop + tooltipHeight > window.innerHeight) {
|
||||
tooltipTop = Math.max(16, window.innerHeight - tooltipHeight - 16);
|
||||
}
|
||||
|
||||
setSpotlightRect({ left: rect.left, top: rect.top, width: rect.width, height: rect.height });
|
||||
setTooltipPos({ left: tooltipLeft, top: tooltipTop });
|
||||
setTooltipSide(side);
|
||||
prevGuideStepRef.current = guideStep;
|
||||
}, [guideStep, isMobile]);
|
||||
|
||||
useEffect(() => {
|
||||
setCategory(cats[0]);
|
||||
}, [cats]);
|
||||
|
||||
useEffect(() => {
|
||||
getPointsBalance().then(setPoints).catch(() => {});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const handleResize = () => setIsMobile(window.innerWidth < 1280);
|
||||
window.addEventListener('resize', handleResize);
|
||||
return () => window.removeEventListener('resize', handleResize);
|
||||
}, []);
|
||||
|
||||
const progress = yaoResults.length;
|
||||
const done = progress >= TOTAL_YAO_COUNT;
|
||||
const guideOpen = guideStep !== null;
|
||||
const guide = guideOpen ? text.guideSteps[guideStep] : null;
|
||||
|
||||
const showPreviousGuide = () => setGuideStep((step) => (step === null ? 0 : Math.max(step - 1, 0)));
|
||||
const showNextGuide = () => setGuideStep((step) => (step === null ? 0 : Math.min(step + 1, text.guideSteps.length - 1)));
|
||||
|
||||
// Handle shake - now shakes one yao at a time
|
||||
const handleShake = () => {
|
||||
if (isShaking || done) return;
|
||||
|
||||
const yaoIndex = progress; // Current yao to shake
|
||||
setCurrentShakingYao(yaoIndex);
|
||||
setIsShaking(true);
|
||||
setTimeout(() => {
|
||||
const newProgress = progress + 1;
|
||||
setProgress(newProgress);
|
||||
const line = Math.random() > 0.5;
|
||||
setHexLines(prev => [...prev, line]);
|
||||
setIsShaking(false);
|
||||
}, 600);
|
||||
setCountdown(SHAKE_DURATION_PER_YAO);
|
||||
|
||||
// Generate random coins for spinning animation
|
||||
const spinInterval = setInterval(() => {
|
||||
const faces: CoinFace[] = ['zi', 'hua'];
|
||||
setCurrentCoins([
|
||||
faces[Math.floor(Math.random() * 2)],
|
||||
faces[Math.floor(Math.random() * 2)],
|
||||
faces[Math.floor(Math.random() * 2)],
|
||||
]);
|
||||
}, 100);
|
||||
|
||||
const countdownTimer = setInterval(() => {
|
||||
setCountdown((prev) => {
|
||||
if (prev <= 1) {
|
||||
clearInterval(countdownTimer);
|
||||
clearInterval(spinInterval);
|
||||
|
||||
// Generate result for this yao
|
||||
const newYao = randomYao();
|
||||
setYaoResults((current) => [...current, newYao]);
|
||||
setCurrentCoins(coinsForYaoType(newYao));
|
||||
setIsShaking(false);
|
||||
setCurrentShakingYao(0);
|
||||
|
||||
return 0;
|
||||
}
|
||||
return prev - 1;
|
||||
});
|
||||
}, 1000);
|
||||
};
|
||||
|
||||
const done = progress >= 6;
|
||||
const handleSubmit = () => {
|
||||
if (!done) return;
|
||||
setShowConfirm(true);
|
||||
};
|
||||
|
||||
const handleConfirm = () => {
|
||||
setShowConfirm(false);
|
||||
setShowProcessing(true);
|
||||
};
|
||||
|
||||
const handleComplete = (result: DivinationResultData | null) => {
|
||||
setShowProcessing(false);
|
||||
if (result) {
|
||||
navigate(`/${locale}/divination/result`, { state: { result } });
|
||||
}
|
||||
};
|
||||
|
||||
const handleBack = () => {
|
||||
navigate(`/${locale}/dashboard`);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-[22px] min-h-full">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-[#333333] text-[28px] font-bold leading-tight">{locale === 'en' ? 'Auto Cast' : d.checkMethod.replace(/^.*:|^.*: /, '').replace('手动', '自动')}</h1>
|
||||
<p className="text-[#666666] text-sm mt-1">{locale === 'en' ? 'Click the button or shake your device to generate six coin results.' : '点击按钮或摇动设备生成铜钱结果,连续 6 次形成完整卦象。'}</p>
|
||||
</div>
|
||||
<div className="hidden md:flex items-center gap-2 h-10 px-3.5 rounded-full bg-white border border-slate-200 text-[#333333] text-[13px] font-semibold">
|
||||
<Icon name="paid" className="w-[18px] h-[18px] text-violet-700" />
|
||||
{locale === 'en'
|
||||
? `Available ${points?.availableBalance ?? '...'} credits · This time ${points?.runCost ?? 20} credits`
|
||||
: `可用 ${points?.availableBalance ?? '...'} 积分 · 本次 ${points?.runCost ?? 20} 积分`}
|
||||
</div>
|
||||
<div ref={scrollContainerRef} className="relative flex min-h-full flex-col gap-[22px]">
|
||||
<div className="flex items-center justify-between gap-5">
|
||||
<div className="min-w-0">
|
||||
<h1 className="text-[28px] font-bold leading-tight text-[#333333]">{text.title}</h1>
|
||||
<p className="mt-1 text-sm text-[#666666]">{text.subtitle}</p>
|
||||
</div>
|
||||
<div className="flex flex-col xl:flex-row gap-[22px] min-h-0 flex-1">
|
||||
{/* Left: Question + Time + Guide */}
|
||||
<div className="w-full xl:w-[340px] flex flex-col gap-4 shrink-0">
|
||||
<div className="bg-white rounded-2xl p-[22px] border border-slate-200 flex flex-col gap-4">
|
||||
<h3 className="text-slate-900 text-lg font-bold">{d.questionTitle}</h3>
|
||||
<div className="flex items-center justify-between h-[42px] px-3 rounded-[10px] bg-slate-50 border border-slate-300">
|
||||
<span className="text-slate-600 text-sm">{category}</span>
|
||||
<select value={category} onChange={e => setCategory(e.target.value)} className="bg-transparent text-sm outline-none cursor-pointer">
|
||||
{cats.map(c => <option key={c} value={c}>{c}</option>)}
|
||||
</select>
|
||||
<div className="hidden h-10 items-center gap-2 rounded-full border border-slate-200 bg-white px-3.5 text-[13px] font-semibold text-[#333333] md:flex">
|
||||
<Icon name="paid" className="h-[18px] w-[18px] text-violet-700" />
|
||||
{locale === 'en'
|
||||
? `Available ${points?.availableBalance ?? '...'} credits · This time ${points?.runCost ?? 20} credits`
|
||||
: `可用 ${points?.availableBalance ?? '...'} 积分 · 本次 ${points?.runCost ?? 20} 积分`}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex min-h-0 flex-1 flex-col gap-[22px] xl:flex-row">
|
||||
<div className="flex w-full shrink-0 flex-col gap-4 xl:w-[360px]">
|
||||
{/* Guide panel */}
|
||||
<section className="flex h-[214px] flex-col gap-3 rounded-2xl border bg-white p-5 border-slate-200">
|
||||
<h2 className="text-base font-bold text-slate-900">{d.guideTitle}</h2>
|
||||
{text.guideLines.map((line, i) => <p key={i} className="text-[13px] leading-relaxed text-[#666666]">{line}</p>)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setGuideStep(0)}
|
||||
className="mt-auto flex h-8 w-fit items-center gap-2 rounded-[17px] bg-[#F0E6FF] px-3 text-[13px] font-bold text-[#673AB7] hover:bg-[#E6D6FF] transition-colors"
|
||||
>
|
||||
<Icon name="help" className="h-[18px] w-[18px]" />
|
||||
{text.openGuide}
|
||||
</button>
|
||||
</section>
|
||||
|
||||
{/* Question panel */}
|
||||
<section className="flex h-[300px] flex-col gap-4 rounded-2xl border border-slate-200 bg-white p-[22px]">
|
||||
<h2 className="text-lg font-bold text-slate-900">{d.questionTitle}</h2>
|
||||
<label className="sr-only" htmlFor="auto-category">{d.categoryLabel}</label>
|
||||
<select
|
||||
id="auto-category"
|
||||
value={category}
|
||||
onChange={(event) => setCategory(event.target.value)}
|
||||
className="h-[42px] rounded-[10px] border border-slate-300 bg-slate-50 px-3 text-sm font-bold text-[#333333] outline-none focus:border-violet-500"
|
||||
>
|
||||
{cats.map((cat) => <option key={cat} value={cat}>{cat}</option>)}
|
||||
</select>
|
||||
<textarea
|
||||
value={question}
|
||||
onChange={(event) => setQuestion(event.target.value)}
|
||||
placeholder={d.questionPlaceholder}
|
||||
className="min-h-0 flex-1 resize-none rounded-[10px] border border-slate-300 bg-white px-3.5 py-3 text-sm text-[#333333] outline-none focus:border-violet-500"
|
||||
/>
|
||||
</section>
|
||||
|
||||
{/* Time panel */}
|
||||
<section ref={timePanelRef} className={`flex h-[132px] flex-col gap-3 rounded-2xl border bg-white p-5 ${guideStep === 1 ? 'border-violet-500 ring-4 ring-violet-100' : 'border-slate-200'}`}>
|
||||
<h2 className="text-base font-bold text-slate-900">{d.timeTitle}</h2>
|
||||
<div className="flex h-[42px] items-center justify-between gap-3 rounded-[10px] bg-slate-50 px-3">
|
||||
<input
|
||||
type="datetime-local"
|
||||
value={selectedTime}
|
||||
onChange={(event) => setSelectedTime(event.target.value)}
|
||||
className="w-full bg-transparent text-sm font-semibold text-[#333333] outline-none"
|
||||
/>
|
||||
<span className="shrink-0 cursor-pointer text-[13px] font-bold text-violet-700 hover:text-violet-800">{text.modify}</span>
|
||||
</div>
|
||||
<textarea value={question} onChange={e => setQuestion(e.target.value)} placeholder={d.questionPlaceholder} rows={3}
|
||||
className="w-full px-3.5 py-3 rounded-[10px] bg-white border border-slate-300 text-sm focus:outline-none focus:border-violet-400 resize-none" />
|
||||
</div>
|
||||
<div className="bg-white rounded-2xl p-5 border border-slate-200 flex flex-col gap-3">
|
||||
<h3 className="text-slate-900 text-base font-bold">{d.timeTitle}</h3>
|
||||
<div className="flex items-center justify-between h-[42px] px-3 rounded-[10px] bg-slate-50">
|
||||
<input type="datetime-local" className="bg-transparent text-sm outline-none w-full" />
|
||||
<Icon name="calendar_today" className="w-[18px] h-[18px] text-slate-400" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-white rounded-2xl p-5 border border-slate-200 flex flex-col gap-3 flex-1 overflow-y-auto">
|
||||
<h3 className="text-slate-900 text-base font-bold">{d.guideTitle}</h3>
|
||||
<p className="text-slate-500 text-[13px] whitespace-pre-line">{d.guideAuto}</p>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
{/* Center: Shake panel */}
|
||||
<div className="flex-1 bg-white rounded-2xl p-6 border border-slate-200 flex flex-col gap-[18px]">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-slate-900 text-lg font-bold">{d.shakeTitle}</h3>
|
||||
<span className="text-violet-600 text-[13px] font-bold">{progress} / 6</span>
|
||||
<section ref={yaoPanelRef} className={`flex min-w-0 flex-1 flex-col gap-4 rounded-2xl border bg-white p-6 ${guideStep === 2 ? 'border-violet-500 ring-4 ring-violet-100' : 'border-slate-200'}`}>
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<h2 className="text-lg font-bold text-slate-900">{d.yaoTitle}</h2>
|
||||
<span className="text-[13px] font-bold text-violet-700">{progress} / {TOTAL_YAO_COUNT}</span>
|
||||
</div>
|
||||
|
||||
{/* Coin stage */}
|
||||
<div className="bg-slate-50 rounded-2xl p-[22px] flex items-center justify-center gap-6" style={{ minHeight: '194px' }}>
|
||||
{[0, 1, 2].map(i => (
|
||||
<div key={i} className="flex flex-col items-center gap-2" style={{ width: '86px' }}>
|
||||
<img
|
||||
src={isShaking ? '/images/qigua/hua.jpg' : '/images/qigua/zi.jpg'}
|
||||
alt={locale === 'en' ? 'coin' : '铜钱'}
|
||||
className={`w-16 h-16 rounded-full object-cover border border-amber-300 shadow-sm transition-all ${isShaking ? 'animate-pulse' : ''}`}
|
||||
/>
|
||||
<span className="text-slate-400 text-xs">{'铜钱'}</span>
|
||||
</div>
|
||||
))}
|
||||
{/* Six yao rows */}
|
||||
<div className="flex flex-col gap-2.5">
|
||||
{[5, 4, 3, 2, 1, 0].map((index) => {
|
||||
const result = yaoResults[index];
|
||||
const isBeingShaken = isShaking && currentShakingYao === index;
|
||||
const isNextToShake = !isShaking && progress === index;
|
||||
const confirmed = !!result;
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
className={`flex h-[62px] items-center gap-4 rounded-[10px] px-3.5 ${
|
||||
isBeingShaken ? 'border border-violet-600 bg-violet-50' :
|
||||
isNextToShake ? 'border border-violet-600 bg-violet-50' :
|
||||
confirmed ? 'border border-slate-200 bg-white' : 'bg-slate-50'
|
||||
}`}
|
||||
>
|
||||
<span className={`w-16 text-sm font-bold ${isBeingShaken || isNextToShake || confirmed ? 'text-violet-700' : 'text-slate-400'}`}>
|
||||
{text.lineNames[index]}
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<YaoGlyph type={result} confirmed={confirmed} />
|
||||
</div>
|
||||
<span className="w-6 text-center">
|
||||
{isBeingShaken ? (
|
||||
<span className="text-violet-600 text-xs font-bold">{countdown}s</span>
|
||||
) : (
|
||||
<YaoChangeMark type={result} />
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Coins area */}
|
||||
<div ref={coinsAreaRef} className="flex min-h-[142px] items-center justify-center rounded-xl bg-slate-50 p-4">
|
||||
<div className="flex items-center justify-center gap-6">
|
||||
{currentCoins.map((face, index) => (
|
||||
<div key={index} className="flex flex-col items-center gap-2">
|
||||
<CoinImage face={face} spinning={isShaking} />
|
||||
<span className="text-[13px] font-bold text-slate-600">
|
||||
{isShaking ? '?' : (face === 'zi' ? text.zi : text.hua)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Shake button */}
|
||||
<div className="flex flex-col items-center gap-2.5" style={{ height: '82px', justifyContent: 'center' }}>
|
||||
{!done && (
|
||||
<button onClick={handleShake} disabled={isShaking}
|
||||
className="flex items-center gap-2 px-8 py-2.5 rounded-full bg-violet-600 text-white text-sm font-bold hover:bg-violet-700 disabled:opacity-50 transition-colors">
|
||||
<Icon name="casino" className="w-[18px] h-[18px]" />
|
||||
{d.shakeBtn}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleShake}
|
||||
disabled={done || isShaking}
|
||||
className={`h-10 w-full rounded-full text-[13px] font-bold transition-colors flex items-center justify-center gap-2 ${
|
||||
done
|
||||
? 'cursor-not-allowed bg-slate-300 text-slate-400'
|
||||
: isShaking
|
||||
? 'bg-violet-400 text-white cursor-wait'
|
||||
: 'bg-violet-700 text-white hover:bg-violet-800'
|
||||
}`}
|
||||
>
|
||||
{isShaking ? (
|
||||
<>
|
||||
<div className="w-4 h-4 rounded-full border-2 border-white border-t-transparent animate-spin" />
|
||||
{text.shaking} ({text.shakingYao.replace('N', String(currentShakingYao + 1))})
|
||||
</>
|
||||
) : done ? (
|
||||
text.yaoComplete
|
||||
) : (
|
||||
<>
|
||||
<Icon name="casino" className="w-5 h-5" />
|
||||
{text.shake}
|
||||
</>
|
||||
)}
|
||||
{done && <p className="text-violet-600 text-sm font-medium">六爻完成</p>}
|
||||
</div>
|
||||
</button>
|
||||
</section>
|
||||
|
||||
{/* Hexagram preview */}
|
||||
<div className="bg-white rounded-xl p-[18px] border border-slate-200 flex-1 flex flex-col gap-3 overflow-y-auto">
|
||||
<p className="text-slate-900 text-base font-bold">{d.hexPreview}</p>
|
||||
<div className="flex flex-col gap-2">
|
||||
{hexLines.length > 0 ? hexLines.map((isYang, i) => isYang ? (
|
||||
<div key={i} className="w-12 h-2 bg-violet-600 rounded" />
|
||||
<aside className="flex w-full shrink-0 flex-col gap-[18px] rounded-2xl border border-slate-200 bg-white p-[22px] xl:w-[300px]">
|
||||
<h2 className="text-lg font-bold text-slate-900">{d.summaryTitle}</h2>
|
||||
<div className="flex h-[94px] flex-col gap-2 rounded-xl bg-slate-50 p-4">
|
||||
<p className="text-[13px] text-[#666666]">{d.progressLabel}</p>
|
||||
<p className="text-[28px] font-bold text-violet-700">{progress} / {TOTAL_YAO_COUNT}</p>
|
||||
</div>
|
||||
<p className="text-sm text-[#666666]">{text.questionTypePrefix}{locale === 'en' ? ': ' : ':'}{category}</p>
|
||||
<p className="text-sm text-[#666666]">{text.method}</p>
|
||||
<p className="text-sm text-[#666666]">{locale === 'en' ? `Cost: ${points?.runCost ?? 20} credits` : `解卦消耗:${points?.runCost ?? 20} 积分`}</p>
|
||||
<div className="flex-1" />
|
||||
<button
|
||||
ref={submitBtnRef}
|
||||
type="button"
|
||||
disabled={!done}
|
||||
onClick={handleSubmit}
|
||||
className={`h-[46px] w-full rounded-full text-sm font-bold transition-colors ${done ? 'bg-violet-700 text-white hover:bg-violet-800' : 'cursor-not-allowed bg-slate-300 text-slate-400'} ${guideStep === 3 ? 'ring-4 ring-violet-100' : ''}`}
|
||||
>
|
||||
{text.submit}
|
||||
</button>
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
{/* Guide overlay */}
|
||||
{guideOpen && guide && spotlightRect && (
|
||||
<>
|
||||
<div
|
||||
className="fixed inset-0 z-40 bg-black/70 md:block hidden"
|
||||
onClick={() => closeGuide()}
|
||||
/>
|
||||
<div
|
||||
className="absolute inset-0 z-40 bg-black/70 md:hidden"
|
||||
style={{ top: 0, height: '100vh' }}
|
||||
onClick={() => closeGuide()}
|
||||
/>
|
||||
|
||||
<div
|
||||
className={`z-50 rounded-2xl ring-4 ring-white shadow-[0_0_0_9999px_rgba(0,0,0,0.7)] transition-all duration-300 ${
|
||||
isMobile ? 'absolute' : 'fixed'
|
||||
}`}
|
||||
style={{
|
||||
left: spotlightRect.left,
|
||||
top: spotlightRect.top,
|
||||
width: spotlightRect.width,
|
||||
height: spotlightRect.height
|
||||
}}
|
||||
/>
|
||||
|
||||
<div
|
||||
className={`z-50 w-[320px] rounded-2xl bg-slate-950 p-5 text-white shadow-2xl transition-all duration-300 ${
|
||||
isMobile ? 'absolute' : 'fixed'
|
||||
}`}
|
||||
style={{
|
||||
left: tooltipPos.left,
|
||||
top: tooltipPos.top
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className={`absolute h-3 w-3 rotate-45 bg-slate-950 ${
|
||||
tooltipSide === 'right' ? '-left-1.5 top-6' :
|
||||
tooltipSide === 'left' ? '-right-1.5 top-6' :
|
||||
tooltipSide === 'top' ? '-bottom-1.5 left-6' :
|
||||
'-top-1.5 left-6'
|
||||
}`}
|
||||
/>
|
||||
|
||||
<div className="mb-3 flex items-center justify-between gap-4">
|
||||
<span className="text-xs font-bold text-violet-300">{guideStep + 1} / {text.guideSteps.length}</span>
|
||||
<button type="button" onClick={() => closeGuide()} className="rounded-full p-1 text-white/50 hover:text-white">
|
||||
<Icon name="close" className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
<h3 className="text-base font-bold">{guide[0]}</h3>
|
||||
<p className="mt-2 text-sm leading-relaxed text-white/70">{guide[1]}</p>
|
||||
<div className="mt-4 flex items-center justify-between gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={showPreviousGuide}
|
||||
disabled={guideStep === 0}
|
||||
className="h-9 rounded-full px-4 text-sm font-medium text-white/50 disabled:opacity-30 hover:text-white disabled:hover:text-white/50"
|
||||
>
|
||||
{text.prevGuide}
|
||||
</button>
|
||||
{guideStep === text.guideSteps.length - 1 ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => closeGuide()}
|
||||
className="h-9 rounded-full bg-violet-600 px-5 text-sm font-bold text-white hover:bg-violet-500"
|
||||
>
|
||||
{text.closeGuide}
|
||||
</button>
|
||||
) : (
|
||||
<div key={i} className="flex gap-1.5"><div className="w-4 h-2 bg-violet-400 rounded" /><div className="w-4 h-2 bg-violet-400 rounded" /></div>
|
||||
)) : (
|
||||
<p className="text-slate-300 text-sm">点击摇卦生成卦象</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={showNextGuide}
|
||||
className="h-9 rounded-full bg-violet-600 px-5 text-sm font-bold text-white hover:bg-violet-500"
|
||||
>
|
||||
{text.nextGuide}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Right: Summary */}
|
||||
<div className="w-full xl:w-[300px] bg-white rounded-2xl p-[22px] border border-slate-200 flex flex-col gap-[18px] shrink-0">
|
||||
<h3 className="text-slate-900 text-lg font-bold">{d.summaryTitle}</h3>
|
||||
<div className="bg-slate-50 rounded-xl p-4 flex flex-col gap-2" style={{ height: '94px' }}>
|
||||
<p className="text-slate-500 text-[13px]">{d.progressLabel}</p>
|
||||
<p className="text-violet-600 text-[28px] font-bold">{progress} / 6</p>
|
||||
{/* Confirmation dialog */}
|
||||
{showConfirm && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
|
||||
<div className="bg-white rounded-2xl p-6 w-[400px] max-w-[90vw] flex flex-col gap-5 shadow-xl">
|
||||
<h3 className="text-slate-900 text-lg font-bold">{text.confirmTitle}</h3>
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-slate-500">{text.confirmAvailable}</span>
|
||||
<span className="text-slate-900 font-semibold">{points?.availableBalance ?? '...'}</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-slate-500">{text.confirmCost}</span>
|
||||
<span className="text-violet-600 font-semibold">{points?.runCost ?? 20}</span>
|
||||
</div>
|
||||
<div className="border-t border-slate-200 pt-3 flex justify-between text-sm">
|
||||
<span className="text-slate-500">{text.confirmRemaining}</span>
|
||||
<span className="text-slate-900 font-bold">
|
||||
{(points?.availableBalance ?? 0) - (points?.runCost ?? 20)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
onClick={() => setShowConfirm(false)}
|
||||
className="flex-1 h-11 rounded-full border border-slate-300 text-sm font-semibold text-slate-600 hover:bg-slate-50 transition-colors"
|
||||
>
|
||||
{text.cancel}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleConfirm}
|
||||
className="flex-1 h-11 rounded-full bg-violet-600 text-sm font-bold text-white hover:bg-violet-700 transition-colors"
|
||||
>
|
||||
{text.confirm}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-slate-500 text-sm">{d.checkCategory}</p>
|
||||
<p className="text-slate-500 text-sm">{d.checkMethod}</p>
|
||||
<p className="text-slate-500 text-sm">{d.checkCost}</p>
|
||||
<div className="flex-1" />
|
||||
<button disabled={!done}
|
||||
className={`w-full h-[46px] rounded-full text-sm font-bold transition-colors ${done ? 'bg-violet-600 text-white hover:bg-violet-700' : 'bg-slate-300 text-slate-400 cursor-not-allowed'}`}>
|
||||
{d.submitBtn}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Processing overlay */}
|
||||
{showProcessing && (
|
||||
<DivinationProcessingOverlay
|
||||
locale={locale}
|
||||
params={{
|
||||
method: 'auto',
|
||||
questionType: category,
|
||||
question: question,
|
||||
divinationTime: new Date(selectedTime),
|
||||
}}
|
||||
yaoStates={yaoResults}
|
||||
onComplete={handleComplete}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Coin spin animation */}
|
||||
<style>{`
|
||||
@keyframes coin-spin {
|
||||
0% { transform: rotateY(0deg); }
|
||||
100% { transform: rotateY(360deg); }
|
||||
}
|
||||
.coin-spin {
|
||||
animation: coin-spin 0.4s linear infinite;
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -161,7 +161,7 @@ export default function Dashboard({ locale, translations: i18n }: DashboardProps
|
||||
className="flex flex-col sm:flex-row sm:items-center gap-3 sm:gap-4 bg-white rounded-xl p-4 md:p-5 border border-slate-100 hover:shadow-sm hover:border-violet-200 transition-all cursor-pointer"
|
||||
>
|
||||
<div className="w-10 h-10 rounded-[10px] bg-blue-50 flex items-center justify-center shrink-0">
|
||||
<span className="text-blue-400 text-lg">◇</span>
|
||||
<Icon name="hexagram" className="w-5 h-5 text-blue-400" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-1 sm:gap-3">
|
||||
|
||||
@@ -187,19 +187,21 @@ export default function DivinationProcessingOverlay({
|
||||
// 卦象推导完成
|
||||
const div = data.divination as Record<string, unknown> | undefined;
|
||||
if (div) {
|
||||
// ganzhi is a nested object in the backend response
|
||||
const ganzhiSource = (div.ganzhi as Record<string, string> | undefined) || {};
|
||||
ganzhi = {
|
||||
yearGanZhi: (div.yearGanZhi as string) || '',
|
||||
monthGanZhi: (div.monthGanZhi as string) || '',
|
||||
dayGanZhi: (div.dayGanZhi as string) || '',
|
||||
timeGanZhi: (div.timeGanZhi as string) || '',
|
||||
yearKongWang: (div.yearKongWang as string) || '',
|
||||
monthKongWang: (div.monthKongWang as string) || '',
|
||||
dayKongWang: (div.dayKongWang as string) || '',
|
||||
timeKongWang: (div.timeKongWang as string) || '',
|
||||
yueJian: (div.yueJian as string) || '',
|
||||
riChen: (div.riChen as string) || '',
|
||||
yuePo: (div.yuePo as string) || '',
|
||||
riChong: (div.riChong as string) || '',
|
||||
yearGanZhi: ganzhiSource.yearGanZhi || '',
|
||||
monthGanZhi: ganzhiSource.monthGanZhi || '',
|
||||
dayGanZhi: ganzhiSource.dayGanZhi || '',
|
||||
timeGanZhi: ganzhiSource.timeGanZhi || '',
|
||||
yearKongWang: ganzhiSource.yearKongWang || '',
|
||||
monthKongWang: ganzhiSource.monthKongWang || '',
|
||||
dayKongWang: ganzhiSource.dayKongWang || '',
|
||||
timeKongWang: ganzhiSource.timeKongWang || '',
|
||||
yueJian: ganzhiSource.yueJian || '',
|
||||
riChen: ganzhiSource.riChen || '',
|
||||
yuePo: ganzhiSource.yuePo || '',
|
||||
riChong: ganzhiSource.riChong || '',
|
||||
};
|
||||
guaName = (div.guaName as string) || '';
|
||||
targetGuaName = (div.targetGuaName as string) || '';
|
||||
|
||||
@@ -403,8 +403,9 @@ export default function DivinationResultPage({ locale, translations: t }: Props)
|
||||
};
|
||||
|
||||
const handleFollowUp = () => {
|
||||
if (threadId) {
|
||||
navigate(`/${locale}/history/${threadId}/followup`, { state: { result: data } });
|
||||
const effectiveThreadId = data?.threadId || threadId;
|
||||
if (effectiveThreadId) {
|
||||
navigate(`/${locale}/history/${effectiveThreadId}/followup`, { state: { result: data } });
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -232,7 +232,7 @@ export default function HistoryListPage({ locale, history: i18n }: Props) {
|
||||
}`}
|
||||
>
|
||||
<div className="w-11 h-11 rounded-[10px] bg-blue-50 flex items-center justify-center shrink-0">
|
||||
<Icon name="auto_awesome" className="w-5 h-5 text-blue-400" />
|
||||
<Icon name="hexagram" className="w-5 h-5 text-blue-400" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-slate-900 text-sm font-semibold truncate">{item.question}</p>
|
||||
|
||||
@@ -56,6 +56,18 @@ const PATHS: Record<string, string[]> = {
|
||||
auto_awesome: ['M19 9l1.25-2.75L23 5l-2.75-1.25L19 1l-1.25 2.75L15 5l2.75 1.25L19 9Z', 'M11.5 15l-1.5-3-1.5 3L6 16.5 8.5 18 10 21l1.5-3 3-1.5-3-1.5Z'],
|
||||
search: ['M21 21l-5.2-5.2', 'M17 10a7 7 0 1 1-14 0 7 7 0 0 1 14 0Z'],
|
||||
arrow_upward: ['M12 4v16M5 11l7-7 7 7'],
|
||||
// 六爻卦象图标 - 三条线表示(上爻、中爻、初爻的抽象)
|
||||
hexagram: [
|
||||
'M4 4h16',
|
||||
'M4 10h16',
|
||||
'M4 16h7',
|
||||
'M13 16h7',
|
||||
],
|
||||
help: [
|
||||
'M12 22a10 10 0 1 0 0-20 10 10 0 0 0 0 20Z',
|
||||
'M9 9a3 3 0 1 1 4 2.83V13',
|
||||
'M12 17h.01',
|
||||
],
|
||||
};
|
||||
|
||||
export default function Icon({ name, className = 'w-5 h-5' }: IconProps) {
|
||||
|
||||
@@ -115,6 +115,12 @@ const copy = {
|
||||
questionTypePrefix: '问题类型',
|
||||
method: '起卦方式:手动起卦',
|
||||
submit: '开始解卦',
|
||||
confirmTitle: '确认解卦',
|
||||
confirmAvailable: '当前积分',
|
||||
confirmCost: '本次消耗',
|
||||
confirmRemaining: '解卦后剩余',
|
||||
cancel: '取消',
|
||||
confirm: '确认',
|
||||
},
|
||||
zh_Hant: {
|
||||
title: '手動起卦',
|
||||
@@ -141,6 +147,12 @@ const copy = {
|
||||
questionTypePrefix: '問題類型',
|
||||
method: '起卦方式:手動起卦',
|
||||
submit: '開始解卦',
|
||||
confirmTitle: '確認解卦',
|
||||
confirmAvailable: '當前積分',
|
||||
confirmCost: '本次消耗',
|
||||
confirmRemaining: '解卦後剩餘',
|
||||
cancel: '取消',
|
||||
confirm: '確認',
|
||||
},
|
||||
en: {
|
||||
title: 'Manual Casting',
|
||||
@@ -167,6 +179,12 @@ const copy = {
|
||||
questionTypePrefix: 'Category',
|
||||
method: 'Method: Manual Casting',
|
||||
submit: 'Start Interpretation',
|
||||
confirmTitle: 'Confirm Interpretation',
|
||||
confirmAvailable: 'Available credits',
|
||||
confirmCost: 'This reading cost',
|
||||
confirmRemaining: 'Remaining after',
|
||||
cancel: 'Cancel',
|
||||
confirm: 'Confirm',
|
||||
},
|
||||
} as const;
|
||||
|
||||
@@ -183,6 +201,7 @@ export default function ManualDivinationPage({ locale, divination: d }: Props) {
|
||||
const [points, setPoints] = useState<PointsBalance | null>(null);
|
||||
const [editingIndex, setEditingIndex] = useState<number | null>(null);
|
||||
const [showProcessing, setShowProcessing] = useState(false);
|
||||
const [showConfirm, setShowConfirm] = useState(false);
|
||||
const { userProfile, setUserProfile } = useUserSettings();
|
||||
|
||||
// Refs for guide spotlight positioning
|
||||
@@ -406,7 +425,11 @@ export default function ManualDivinationPage({ locale, divination: d }: Props) {
|
||||
const showNextGuide = () => setGuideStep((step) => (step === null ? 0 : Math.min(step + 1, text.guideSteps.length - 1)));
|
||||
|
||||
const handleSubmit = () => {
|
||||
// 显示处理蒙版
|
||||
setShowConfirm(true);
|
||||
};
|
||||
|
||||
const handleConfirm = () => {
|
||||
setShowConfirm(false);
|
||||
setShowProcessing(true);
|
||||
};
|
||||
|
||||
@@ -657,6 +680,45 @@ export default function ManualDivinationPage({ locale, divination: d }: Props) {
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Confirmation dialog */}
|
||||
{showConfirm && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
|
||||
<div className="bg-white rounded-2xl p-6 w-[400px] max-w-[90vw] flex flex-col gap-5 shadow-xl">
|
||||
<h3 className="text-slate-900 text-lg font-bold">{text.confirmTitle}</h3>
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-slate-500">{text.confirmAvailable}</span>
|
||||
<span className="text-slate-900 font-semibold">{points?.availableBalance ?? '...'}</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-slate-500">{text.confirmCost}</span>
|
||||
<span className="text-violet-600 font-semibold">{points?.runCost ?? 20}</span>
|
||||
</div>
|
||||
<div className="border-t border-slate-200 pt-3 flex justify-between text-sm">
|
||||
<span className="text-slate-500">{text.confirmRemaining}</span>
|
||||
<span className="text-slate-900 font-bold">
|
||||
{(points?.availableBalance ?? 0) - (points?.runCost ?? 20)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
onClick={() => setShowConfirm(false)}
|
||||
className="flex-1 h-11 rounded-full border border-slate-300 text-sm font-semibold text-slate-600 hover:bg-slate-50 transition-colors"
|
||||
>
|
||||
{text.cancel}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleConfirm}
|
||||
className="flex-1 h-11 rounded-full bg-violet-600 text-sm font-bold text-white hover:bg-violet-700 transition-colors"
|
||||
>
|
||||
{text.confirm}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 处理中蒙版 */}
|
||||
{showProcessing && (
|
||||
<DivinationProcessingOverlay
|
||||
|
||||
+108
-34
@@ -247,6 +247,46 @@ export interface HistoryAgentOutput {
|
||||
gua_name?: string;
|
||||
binaryCode?: string;
|
||||
changedBinaryCode?: string;
|
||||
targetGuaName?: string;
|
||||
upperName?: string;
|
||||
lowerName?: string;
|
||||
divinationMethod?: string;
|
||||
divinationTime?: string;
|
||||
ganzhi?: {
|
||||
yearGanZhi?: string;
|
||||
monthGanZhi?: string;
|
||||
dayGanZhi?: string;
|
||||
timeGanZhi?: string;
|
||||
yearKongWang?: string;
|
||||
monthKongWang?: string;
|
||||
dayKongWang?: string;
|
||||
timeKongWang?: string;
|
||||
yueJian?: string;
|
||||
riChen?: string;
|
||||
yuePo?: string;
|
||||
riChong?: string;
|
||||
};
|
||||
wuXingStatuses?: Record<string, string>;
|
||||
yaoInfoList?: Array<{
|
||||
position?: number;
|
||||
spiritName?: string;
|
||||
relationName?: string;
|
||||
tiganName?: string;
|
||||
elementName?: string;
|
||||
isYang?: boolean;
|
||||
isChanging?: boolean;
|
||||
specialMark?: string;
|
||||
}>;
|
||||
targetYaoInfoList?: Array<{
|
||||
position?: number;
|
||||
spiritName?: string;
|
||||
relationName?: string;
|
||||
tiganName?: string;
|
||||
elementName?: string;
|
||||
isYang?: boolean;
|
||||
isChanging?: boolean;
|
||||
specialMark?: string;
|
||||
}>;
|
||||
} | null;
|
||||
}
|
||||
|
||||
@@ -343,55 +383,89 @@ export function historyMessageToResultData(message: HistoryMessage): DivinationR
|
||||
const derived = output?.divination_derived;
|
||||
if (!output || !derived) return null;
|
||||
|
||||
// Parse yao lines
|
||||
const yaoLines: DivinationResultData['yaoLines'] = [];
|
||||
const yaoInfoList = (derived as Record<string, unknown>).yaoInfoList;
|
||||
if (Array.isArray(yaoInfoList)) {
|
||||
yaoInfoList.forEach((item: Record<string, unknown>, idx: number) => {
|
||||
if (Array.isArray(derived.yaoInfoList)) {
|
||||
derived.yaoInfoList.forEach((item, idx) => {
|
||||
// Determine YaoType based on isYang and isChanging
|
||||
let type: YaoType = 'youngYang';
|
||||
if (item.isYang === true && item.isChanging === true) {
|
||||
type = 'oldYang';
|
||||
} else if (item.isYang === false && item.isChanging === true) {
|
||||
type = 'oldYin';
|
||||
} else if (item.isYang === false && item.isChanging === false) {
|
||||
type = 'youngYin';
|
||||
}
|
||||
yaoLines.push({
|
||||
index: idx,
|
||||
spirit: (item.spiritName as string) || '',
|
||||
relation: (item.relationName as string) || '',
|
||||
branch: (item.tiganName as string) || '',
|
||||
element: (item.elementName as string) || '',
|
||||
type: item.isYang ? 'youngYang' : 'youngYin',
|
||||
mark: (item.specialMark as string) || '',
|
||||
spirit: item.spiritName || '',
|
||||
relation: item.relationName || '',
|
||||
branch: item.tiganName || '',
|
||||
element: item.elementName || '',
|
||||
type,
|
||||
mark: item.specialMark || '',
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
const ganzhiRaw = (derived as Record<string, unknown>).ganzhi as Record<string, string> || {};
|
||||
// Parse target yao lines
|
||||
const targetYaoLines: DivinationResultData['yaoLines'] = [];
|
||||
if (Array.isArray(derived.targetYaoInfoList)) {
|
||||
derived.targetYaoInfoList.forEach((item, idx) => {
|
||||
let type: YaoType = 'youngYang';
|
||||
if (item.isYang === true && item.isChanging === true) {
|
||||
type = 'oldYang';
|
||||
} else if (item.isYang === false && item.isChanging === true) {
|
||||
type = 'oldYin';
|
||||
} else if (item.isYang === false && item.isChanging === false) {
|
||||
type = 'youngYin';
|
||||
}
|
||||
targetYaoLines.push({
|
||||
index: idx,
|
||||
spirit: item.spiritName || '',
|
||||
relation: item.relationName || '',
|
||||
branch: item.tiganName || '',
|
||||
element: item.elementName || '',
|
||||
type,
|
||||
mark: item.specialMark || '',
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Parse ganzhi from nested object
|
||||
const ganzhiSource = derived.ganzhi || {};
|
||||
const ganzhi: DivinationResultData['ganzhi'] = {
|
||||
yearGanZhi: ganzhiRaw.yearGanZhi || '',
|
||||
monthGanZhi: ganzhiRaw.monthGanZhi || '',
|
||||
dayGanZhi: ganzhiRaw.dayGanZhi || '',
|
||||
timeGanZhi: ganzhiRaw.timeGanZhi || '',
|
||||
yearKongWang: ganzhiRaw.yearKongWang || '',
|
||||
monthKongWang: ganzhiRaw.monthKongWang || '',
|
||||
dayKongWang: ganzhiRaw.dayKongWang || '',
|
||||
timeKongWang: ganzhiRaw.timeKongWang || '',
|
||||
yueJian: ganzhiRaw.yueJian || '',
|
||||
riChen: ganzhiRaw.riChen || '',
|
||||
yuePo: ganzhiRaw.yuePo || '',
|
||||
riChong: ganzhiRaw.riChong || '',
|
||||
yearGanZhi: ganzhiSource.yearGanZhi || '',
|
||||
monthGanZhi: ganzhiSource.monthGanZhi || '',
|
||||
dayGanZhi: ganzhiSource.dayGanZhi || '',
|
||||
timeGanZhi: ganzhiSource.timeGanZhi || '',
|
||||
yearKongWang: ganzhiSource.yearKongWang || '',
|
||||
monthKongWang: ganzhiSource.monthKongWang || '',
|
||||
dayKongWang: ganzhiSource.dayKongWang || '',
|
||||
timeKongWang: ganzhiSource.timeKongWang || '',
|
||||
yueJian: ganzhiSource.yueJian || '',
|
||||
riChen: ganzhiSource.riChen || '',
|
||||
yuePo: ganzhiSource.yuePo || '',
|
||||
riChong: ganzhiSource.riChong || '',
|
||||
};
|
||||
|
||||
const divinationTimeStr = (derived as Record<string, unknown>).divinationTime as string || '';
|
||||
const divinationTimeStr = derived.divinationTime || '';
|
||||
const divinationTime = parseChineseDate(divinationTimeStr);
|
||||
|
||||
return {
|
||||
threadId: message.threadId,
|
||||
params: {
|
||||
method: ((derived as Record<string, unknown>).divinationMethod as string)?.includes('手动') ? 'manual' : 'auto',
|
||||
questionType: (derived as Record<string, unknown>).questionType as string || '',
|
||||
question: (derived as Record<string, unknown>).question as string || '',
|
||||
method: derived.divinationMethod?.includes('手动') ? 'manual' : 'auto',
|
||||
questionType: derived.questionType || '',
|
||||
question: derived.question || '',
|
||||
divinationTime,
|
||||
},
|
||||
binaryCode: (derived as Record<string, unknown>).binaryCode as string || '',
|
||||
changedBinaryCode: (derived as Record<string, unknown>).changedBinaryCode as string || '',
|
||||
guaName: (derived as Record<string, unknown>).guaName as string || '',
|
||||
targetGuaName: (derived as Record<string, unknown>).targetGuaName as string || '',
|
||||
upperName: (derived as Record<string, unknown>).upperName as string || '',
|
||||
lowerName: (derived as Record<string, unknown>).lowerName as string || '',
|
||||
binaryCode: derived.binaryCode || '',
|
||||
changedBinaryCode: derived.changedBinaryCode || '',
|
||||
guaName: derived.guaName || derived.gua_name || '',
|
||||
targetGuaName: derived.targetGuaName || '',
|
||||
upperName: derived.upperName || '',
|
||||
lowerName: derived.lowerName || '',
|
||||
signType: output.sign_level || '',
|
||||
keywords: (output.keywords || []).join(' · '),
|
||||
focusPoints: output.focus_points || [],
|
||||
@@ -399,9 +473,9 @@ export function historyMessageToResultData(message: HistoryMessage): DivinationR
|
||||
analysis: output.answer || '',
|
||||
suggestion: (output.advice || []).join('\n'),
|
||||
ganzhi,
|
||||
wuXingStatus: (derived as Record<string, unknown>).wuXingStatuses as Record<string, string> || {},
|
||||
wuXingStatus: derived.wuXingStatuses || {},
|
||||
yaoLines,
|
||||
targetYaoLines: [],
|
||||
targetYaoLines,
|
||||
status: (output.status as 'success' | 'failed' | 'refused') || 'success',
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user