feat(web): 历史解卦列表、结果页与追问功能

- 合并 DivinationResultPage 和 HistoryResultPage 为统一结果页
- 重写 HistoryFollowUpPage:API 加载历史消息、SSE 流式追问、配额管理
- 追问免费且限一次,输入框 UI 对齐设计稿(圆角容器+配额徽章+圆形发送按钮)
- 结果页追问状态根据线程消息数动态判断
- 历史列表筛选改为 9 类独立类型
- 提取 historyMessageToResultData 为共享函数,新增 enqueueFollowUpRun API
- 新增 auto_awesome/search/arrow_upward 图标
- 新增三语言 [id].astro、[id]/followup.astro、divination/result.astro 页面
This commit is contained in:
ZL-Q
2026-05-10 13:59:04 +08:00
parent 654e5ce188
commit efe48f2068
23 changed files with 2119 additions and 225 deletions
+42 -5
View File
@@ -12,9 +12,42 @@ interface DashboardProps {
}
const CATEGORY_COLORS: Record<string, string> = {
'事业': 'bg-blue-50 text-blue-500', '感情': 'bg-pink-50 text-pink-500', '财富': 'bg-amber-50 text-amber-600', '运势': 'bg-purple-50 text-purple-500', '学业': 'bg-green-50 text-green-600',
'Career': 'bg-blue-50 text-blue-500', 'Love': 'bg-pink-50 text-pink-500', 'Wealth': 'bg-amber-50 text-amber-600', 'Study': 'bg-green-50 text-green-600',
'career': 'bg-blue-50 text-blue-500',
'love': 'bg-pink-50 text-pink-500',
'wealth': 'bg-amber-50 text-amber-600',
'fortune': 'bg-purple-50 text-purple-500',
'dream': 'bg-indigo-50 text-indigo-500',
'health': 'bg-red-50 text-red-500',
'study': 'bg-green-50 text-green-600',
'search': 'bg-cyan-50 text-cyan-600',
'other': 'bg-slate-100 text-slate-500',
};
// 问题类型显示标签映射
const CATEGORY_LABELS_ZH: Record<string, string> = {
'career': '事业',
'love': '感情',
'wealth': '财富',
'fortune': '运势',
'dream': '解梦',
'health': '健康',
'study': '学业',
'search': '寻物',
'other': '其他',
};
const CATEGORY_LABELS_EN: Record<string, string> = {
'career': 'Career',
'love': 'Love',
'wealth': 'Wealth',
'fortune': 'Fortune',
'dream': 'Dream',
'health': 'Health',
'study': 'Study',
'search': 'Search',
'other': 'Other',
};
const RATING_COLORS: Record<string, string> = {
'上上签': 'bg-amber-50 text-amber-500', '上签': 'bg-amber-50 text-amber-500', '中上签': 'bg-violet-50 text-violet-600', '中签': 'bg-slate-100 text-slate-500', '下签': 'bg-red-50 text-red-500',
};
@@ -122,7 +155,11 @@ export default function Dashboard({ locale, translations: i18n }: DashboardProps
<div className="text-slate-400 text-sm py-8 text-center">{locale === 'en' ? 'No readings yet' : '暂无解卦记录'}</div>
) : (
history.map((item) => (
<div key={item.id} 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 transition-shadow">
<a
key={item.id}
href={`/${locale}/history/${item.threadId}`}
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>
</div>
@@ -132,12 +169,12 @@ export default function Dashboard({ locale, translations: i18n }: DashboardProps
<span className="text-slate-400 text-xs shrink-0">{item.created_at?.slice(0, 10) || ''}</span>
</div>
<div className="flex flex-wrap items-center gap-2 mt-1.5">
{item.category && <span className={`px-2 py-0.5 rounded-md text-xs font-medium ${CATEGORY_COLORS[item.category] || 'bg-slate-100 text-slate-500'}`}>{item.category}</span>}
{item.category && <span className={`px-2 py-0.5 rounded-md text-xs font-medium ${CATEGORY_COLORS[item.category] || 'bg-slate-100 text-slate-500'}`}>{locale === 'en' ? (CATEGORY_LABELS_EN[item.category] || item.category) : (CATEGORY_LABELS_ZH[item.category] || item.category)}</span>}
{item.hexagram_name && <span className="px-2 py-0.5 rounded-md bg-violet-50 text-violet-600 text-xs font-medium">{item.hexagram_name}</span>}
{item.rating && <span className={`px-2 py-0.5 rounded-md text-xs font-medium ${RATING_COLORS[item.rating] || 'bg-slate-100 text-slate-500'}`}>{item.rating}</span>}
</div>
</div>
</div>
</a>
))
)}
</div>
+6 -3
View File
@@ -4,7 +4,7 @@ import AppShell from './AppShell';
import Dashboard from './Dashboard';
import StorePage from './StorePage';
import HistoryListPage from './HistoryListPage';
import HistoryResultPage from './HistoryResultPage';
import DivinationResultPage from './DivinationResultPage';
import HistoryFollowUpPage from './HistoryFollowUpPage';
import NotificationPage from './NotificationPage';
import ProfileDetailPage from './ProfileDetailPage';
@@ -30,6 +30,7 @@ interface DashboardAppProps {
divination: TranslationMap;
general: TranslationMap;
feedback: TranslationMap;
result: TranslationMap;
};
}
@@ -44,6 +45,7 @@ const APP_PATHS = [
'/settings/feedback',
'/divination/manual',
'/divination/auto',
'/divination/result',
];
function AppLinkInterceptor({ locale }: { locale: string }) {
@@ -86,9 +88,9 @@ function DashboardRoutes({ locale, translations }: DashboardAppProps) {
<Route path={`/${locale}/dashboard`} element={<Dashboard locale={locale} translations={dashboard} />} />
<Route path={`/${locale}/store`} element={<StorePage locale={locale} dashboard={dashboard} store={translations.store} pricing={translations.pricing} />} />
<Route path={`/${locale}/history`} element={<HistoryListPage locale={locale} dashboard={dashboard} history={translations.history} />} />
<Route path={`/${locale}/history/:id`} element={<HistoryResultPage locale={locale} dashboard={dashboard} history={translations.history} />} />
<Route path={`/${locale}/history/:id`} element={<DivinationResultPage locale={locale} translations={translations.result} />} />
<Route path={`/${locale}/history/:id/followup`} element={<HistoryFollowUpPage locale={locale} dashboard={dashboard} history={translations.history} />} />
<Route path={`/${locale}/history/result`} element={<HistoryResultPage locale={locale} dashboard={dashboard} history={translations.history} />} />
<Route path={`/${locale}/history/result`} element={<DivinationResultPage locale={locale} translations={translations.result} />} />
<Route path={`/${locale}/history/followup`} element={<HistoryFollowUpPage locale={locale} dashboard={dashboard} history={translations.history} />} />
<Route path={`/${locale}/notifications`} element={<NotificationPage locale={locale} dashboard={dashboard} notifications={translations.notifications} />} />
<Route path={`/${locale}/profile`} element={<ProfileDetailPage locale={locale} dashboard={dashboard} profile={translations.profile} />} />
@@ -97,6 +99,7 @@ function DashboardRoutes({ locale, translations }: DashboardAppProps) {
<Route path={`/${locale}/settings/feedback`} element={<FeedbackPage locale={locale} feedback={translations.feedback} />} />
<Route path={`/${locale}/divination/manual`} element={<ManualDivinationPage locale={locale} dashboard={dashboard} divination={translations.divination} />} />
<Route path={`/${locale}/divination/auto`} element={<AutoDivinationPage locale={locale} dashboard={dashboard} divination={translations.divination} />} />
<Route path={`/${locale}/divination/result`} element={<DivinationResultPage locale={locale} translations={translations.result} />} />
<Route path="*" element={<Navigate to={`/${locale}/dashboard`} replace />} />
</Routes>
</AppShell>
@@ -19,6 +19,7 @@ const translations = {
divination: t(locale, 'divination'),
general: t(locale, 'general'),
feedback: t(locale, 'feedback'),
result: t(locale, 'result'),
};
---
@@ -0,0 +1,375 @@
/**
* DivinationProcessingOverlay - 起卦处理中蒙版
* 显示翻牌动画和状态提示,等待后端返回结果
*
* i18n: 使用 Flutter app 已有文本
* - 简中/繁中:有完整翻译
* - 英文:有翻译
*/
import { useEffect, useState, useCallback, useRef } from 'react';
import {
enqueueDivinationRun,
streamDivinationEvents,
type YaoType,
type DivinationResultData,
} from '../lib/api';
// 八卦卡片数据 - 使用 Flutter 的文本
const I_CHING_CARDS = {
zh: [
{ symbol: '☰', title: '乾 • 元亨利贞', quote: '天行健,君子以自强不息。' },
{ symbol: '☱', title: '兑 • 亨利贞', quote: '丽泽兑,君子以朋友讲习。' },
{ symbol: '☲', title: '离 • 明两作亨利贞', quote: '大人以继明照于四方。' },
{ symbol: '☳', title: '震 • 亨震来虩虩,笑言哑哑', quote: '震惊百里,惊远而惧迩也。' },
{ symbol: '☴', title: '巽 • 小亨利贞', quote: '随风,君子以申命行事。' },
{ symbol: '☵', title: '坎 • 习坎有孚维心亨', quote: '水流而不盈,行险而不失其信。' },
{ symbol: '☶', title: '艮 • 艮其背不获其身', quote: '时止则止,时行则行,动静不失其时。' },
{ symbol: '☷', title: '坤 • 元亨利牝马之贞', quote: '地势坤,君子以厚德载物。' },
],
zh_Hant: [
{ symbol: '☰', title: '乾 • 元亨利貞', quote: '天行健,君子以自強不息。' },
{ symbol: '☱', title: '兌 • 亨利貞', quote: '麗澤兌,君子以朋友講習。' },
{ symbol: '☲', title: '離 • 明兩作亨利貞', quote: '大人以繼明照於四方。' },
{ symbol: '☳', title: '震 • 亨震來虩虩,笑言啞啞', quote: '震驚百里,驚遠而懼邇也。' },
{ symbol: '☴', title: '巽 • 小亨利貞', quote: '隨風,君子以申命行事。' },
{ symbol: '☵', title: '坎 • 習坎有孚維心亨', quote: '水流而不盈,行險而不失其信。' },
{ symbol: '☶', title: '艮 • 艮其背不獲其身', quote: '時止則止,時行則行,動靜不失其時。' },
{ symbol: '☷', title: '坤 • 元亨利牝馬之貞', quote: '地勢坤,君子以厚德載物。' },
],
en: [
{ symbol: '☰', title: 'Qian • The Creative', quote: 'The movement of Heaven is full of power; thus the noble one makes himself strong and tireless.' },
{ symbol: '☱', title: 'Dui • The Joyous', quote: 'Joy grounded in integrity brings openness, harmony, and right expression.' },
{ symbol: '☲', title: 'Li • The Clinging Fire', quote: 'With clear brilliance, the great one illumines all directions.' },
{ symbol: '☳', title: 'Zhen • The Arousing Thunder', quote: 'Shock awakens the heart; composure turns fear into growth.' },
{ symbol: '☴', title: 'Xun • The Gentle Wind', quote: 'Gentle penetration furthers progress and helps one meet the right people.' },
{ symbol: '☵', title: 'Kan • The Abysmal Water', quote: 'In danger, sincerity and disciplined action carry one through.' },
{ symbol: '☶', title: 'Gen • Keeping Still Mountain', quote: 'Stillness at the proper time keeps one centered and steady in place.' },
{ symbol: '☷', title: 'Kun • The Receptive Earth', quote: "The Earth's condition is devoted receptivity; the noble one carries all with broad virtue." },
],
} as const;
// 处理步骤状态
type ProcessingStep = 'preparing' | 'deriving' | 'done';
interface DivinationProcessingOverlayProps {
locale: string;
// 起卦参数
params: {
method: 'manual' | 'auto';
questionType: string;
question: string;
divinationTime: Date;
};
// 六爻状态
yaoStates: YaoType[];
// 完成回调
onComplete: (result: DivinationResultData | null) => void;
// 错误回调
onError?: (error: Error) => void;
}
const copy = {
zh: {
badge: '周易',
statusPreparing: '天机推演中',
statusDeriving: '正在解卦',
statusDone: '解卦完成\n点击查看',
},
zh_Hant: {
badge: '周易',
statusPreparing: '天機推演中',
statusDeriving: '正在解卦',
statusDone: '解卦完成\n點擊查看',
},
en: {
badge: 'I Ching',
statusPreparing: 'Deriving...',
statusDeriving: 'Analyzing...',
statusDone: 'Complete\nTap to view',
},
} as const;
// 动画时长
const FLIP_INTERVAL = 2000; // 每2秒翻一次
const FLIP_DURATION = 600; // 翻转动画600ms
export default function DivinationProcessingOverlay({
locale,
params,
yaoStates,
onComplete,
onError,
}: DivinationProcessingOverlayProps) {
const text = copy[locale as keyof typeof copy] ?? copy.zh;
const cards = I_CHING_CARDS[locale as keyof typeof I_CHING_CARDS] ?? I_CHING_CARDS.zh;
const [step, setStep] = useState<ProcessingStep>('preparing');
const [cardIndex, setCardIndex] = useState(0);
const [flipAngle, setFlipAngle] = useState(0);
const [result, setResult] = useState<DivinationResultData | null>(null);
const isFlippingRef = useRef(false);
// 翻牌动画
useEffect(() => {
if (step === 'done') return;
const interval = setInterval(() => {
if (isFlippingRef.current) return;
isFlippingRef.current = true;
setFlipAngle((prev) => prev + 180);
setTimeout(() => {
setCardIndex((prev) => (prev + 1) % cards.length);
}, FLIP_DURATION / 2);
setTimeout(() => {
isFlippingRef.current = false;
}, FLIP_DURATION);
}, FLIP_INTERVAL);
return () => clearInterval(interval);
}, [step, cards.length]);
// 后端 SSE 请求
useEffect(() => {
let aborted = false;
// 辅助函数:转换 yaoInfoList 到 YaoLineData[]
function parseYaoInfoList(list: unknown): DivinationResultData['yaoLines'] {
if (!Array.isArray(list)) return [];
return list.map((item, idx) => ({
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' as DivinationResultData['yaoLines'][0]['type'],
mark: (item.specialMark as string) || '',
}));
}
async function runDivination() {
try {
// 1. 提交起卦请求
const { threadId, runId } = await enqueueDivinationRun(params, yaoStates);
if (aborted) return;
// 用于存储 derived 数据
let ganzhi: DivinationResultData['ganzhi'] | null = null;
let guaName = '';
let targetGuaName = '';
let upperName = '';
let lowerName = '';
let binaryCode = '';
let changedBinaryCode = '';
let wuXingStatus: Record<string, string> = {};
let yaoLines: DivinationResultData['yaoLines'] = [];
let targetYaoLines: DivinationResultData['yaoLines'] = [];
let signLevel = '';
let conclusion = '';
let focusPoints: string[] = [];
let advice: string[] = [];
let keywords: string[] = [];
let answer = '';
let status: 'success' | 'failed' | 'refused' = 'success';
// 2. 监听 SSE 事件
for await (const event of streamDivinationEvents(threadId, runId)) {
if (aborted) break;
const { type, data } = event;
if (type === 'DIVINATION_DERIVED') {
// 卦象推导完成
const div = data.divination as Record<string, unknown> | undefined;
if (div) {
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) || '',
};
guaName = (div.guaName as string) || '';
targetGuaName = (div.targetGuaName as string) || '';
upperName = (div.upperName as string) || '';
lowerName = (div.lowerName as string) || '';
binaryCode = (div.binaryCode as string) || '';
changedBinaryCode = (div.changedBinaryCode as string) || '';
wuXingStatus = (div.wuXingStatuses as Record<string, string>) || {};
yaoLines = parseYaoInfoList(div.yaoInfoList);
targetYaoLines = parseYaoInfoList(div.targetYaoInfoList);
}
setStep('deriving');
} else if (type === 'TEXT_MESSAGE_END') {
// 解卦结果
signLevel = (data.sign_level as string) || '';
conclusion = ((data.conclusion as string[]) || []).join('\n');
focusPoints = (data.focus_points as string[]) || [];
advice = (data.advice as string[]) || [];
keywords = ((data.keywords as string[]) || []).join(' · ');
answer = (data.answer as string) || '';
status = (data.status as 'success' | 'failed' | 'refused') || 'success';
} else if (type === 'RUN_ERROR') {
const detail = (data.detail as string) || 'Unknown error';
throw new Error(detail);
} else if (type === 'RUN_FINISHED') {
// 构建最终结果
if (ganzhi) {
const result: DivinationResultData = {
threadId,
params,
binaryCode,
changedBinaryCode,
guaName,
targetGuaName,
upperName,
lowerName,
signType: signLevel,
keywords,
focusPoints,
conclusion,
analysis: answer,
suggestion: advice.join('\n'),
ganzhi,
wuXingStatus,
yaoLines,
targetYaoLines,
status,
};
setResult(result);
}
setStep('done');
break;
}
}
} catch (error) {
if (!aborted && onError) {
onError(error instanceof Error ? error : new Error(String(error)));
}
}
}
runDivination();
return () => {
aborted = true;
};
}, [params, yaoStates, onError]);
const currentCard = cards[cardIndex];
const handleClick = useCallback(() => {
if (step === 'done') {
onComplete(result);
}
}, [step, onComplete, result]);
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center"
onClick={handleClick}
>
{/* 高斯模糊背景层 */}
<div className="absolute inset-0 backdrop-blur-sm bg-slate-100/80" />
{/* 暗色遮罩层 */}
<div className="absolute inset-0 bg-[#0F172A]/40" />
{/* 中央内容区域 */}
<div className="relative flex flex-col items-center gap-6">
{/* 翻牌动画区域 */}
<div className="relative h-[380px] w-[420px]" style={{ perspective: '1000px' }}>
{/* 背后的幻影牌 */}
<div
className="absolute"
style={{
left: 126,
top: 38,
width: 168,
height: 272,
transform: 'rotate(-8deg)',
opacity: 0.5,
zIndex: 0,
}}
>
<div
className="h-full w-full rounded-[18px]"
style={{ background: 'rgba(237, 231, 246, 0.67)' }}
/>
</div>
{/* 主卡片 - Y轴翻转 */}
<div
className="absolute"
style={{
left: 100,
top: 30,
width: 220,
height: 320,
zIndex: 1,
transformStyle: 'preserve-3d',
transform: `rotateY(${flipAngle}deg)`,
transition: `transform ${FLIP_DURATION}ms ease-in-out`,
}}
>
{/* 正面 */}
<div
className="absolute inset-0 rounded-[18px] flex flex-col items-center justify-center gap-[18px] px-6 py-6"
style={{
background: 'linear-gradient(180deg, #F0E6FF 0%, #EDE7F6 52%, #FFFFFF 100%)',
boxShadow: '0 14px 26px rgba(0, 0, 0, 0.18)',
border: '1px solid rgba(139, 92, 246, 0.3)',
backfaceVisibility: 'hidden',
}}
>
<div className="flex items-center justify-center rounded-full bg-white/75 px-3 py-1.5">
<span className="text-xs font-bold text-[#673AB7]">{text.badge}</span>
</div>
<span className="text-[44px] font-bold text-[#673AB7]">{currentCard.symbol}</span>
<span className="w-full text-center text-base font-bold text-[#673AB7]">{currentCard.title}</span>
<span className="w-full text-center text-[13px] leading-relaxed text-[#334155]">{currentCard.quote}</span>
</div>
{/* 背面 */}
<div
className="absolute inset-0 rounded-[18px] flex flex-col items-center justify-center gap-[18px] px-6 py-6"
style={{
background: 'linear-gradient(180deg, #F0E6FF 0%, #EDE7F6 52%, #FFFFFF 100%)',
boxShadow: '0 14px 26px rgba(0, 0, 0, 0.18)',
border: '1px solid rgba(139, 92, 246, 0.3)',
backfaceVisibility: 'hidden',
transform: 'rotateY(180deg)',
}}
>
<div className="flex items-center justify-center rounded-full bg-white/75 px-3 py-1.5">
<span className="text-xs font-bold text-[#673AB7]">{text.badge}</span>
</div>
<span className="text-[44px] font-bold text-[#673AB7]">{currentCard.symbol}</span>
<span className="w-full text-center text-base font-bold text-[#673AB7]">{currentCard.title}</span>
<span className="w-full text-center text-[13px] leading-relaxed text-[#334155]">{currentCard.quote}</span>
</div>
</div>
</div>
{/* 状态区域 */}
<div className="flex w-[420px] flex-col items-center gap-2.5">
<span className="w-full text-center text-xl font-bold text-white whitespace-pre-line">
{step === 'preparing' ? text.statusPreparing : step === 'deriving' ? text.statusDeriving : text.statusDone}
</span>
</div>
</div>
</div>
);
}
+493
View File
@@ -0,0 +1,493 @@
import { useEffect, useState } from 'react';
import { useLocation, useNavigate, useParams } from 'react-router-dom';
import { getAgentHistoryByThread, historyMessageToResultData, type DivinationResultData, type YaoType } from '../lib/api';
import Icon from './Icon';
interface Props {
locale: string;
translations: Record<string, string>;
}
const WU_XING = ['木', '火', '土', '金', '水'];
function getSignTypeLabel(signType: string, t: Record<string, string>): string {
const normalized = signType.trim();
if (normalized.includes('上上')) return t.signTypeShangShang;
if (normalized.includes('中上')) return t.signTypeZhongShang;
if (normalized.includes('下下')) return t.signTypeXiaXia;
return t.signTypeZhongXia;
}
function getSignImageSrc(signType: string): string {
const normalized = signType.trim();
if (normalized.includes('上上')) return '/images/qigua/shangshang.jpg';
if (normalized.includes('中上')) return '/images/qigua/zhongshang.jpg';
if (normalized.includes('下下')) return '/images/qigua/xiaxia.jpg';
return '/images/qigua/zhongxia.jpg';
}
function getQuestionTypeLabel(type: string, t: Record<string, string>): string {
const map: Record<string, string> = {
career: t.questionTypeCareer,
love: t.questionTypeLove,
wealth: t.questionTypeWealth,
fortune: t.questionTypeFortune,
dream: t.questionTypeDream,
health: t.questionTypeHealth,
study: t.questionTypeStudy,
search: t.questionTypeSearch,
other: t.questionTypeOther,
};
return map[type] || type;
}
function formatDivinationTime(date: Date, locale: string): string {
try {
return new Intl.DateTimeFormat(locale, {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
}).format(date);
} catch {
return date.toLocaleString();
}
}
function abbreviateRelation(relation: string): string {
const map: Record<string, string> = {
'子孙': '孙', '妻财': '财', '官鬼': '官', '兄弟': '兄', '父母': '父',
};
return map[relation] || relation;
}
function getChangeMark(type: YaoType): string {
if (type === 'oldYang') return '○';
if (type === 'oldYin') return '×';
return '';
}
function YaoGlyph({ type }: { type: YaoType }) {
const isYin = type === 'youngYin' || type === 'oldYin';
if (!isYin) {
return <div className="h-1.5 w-full rounded bg-violet-600" />;
}
return (
<div className="flex gap-1">
<div className="h-1.5 flex-1 rounded bg-violet-600" />
<div className="h-1.5 flex-1 rounded bg-violet-600" />
</div>
);
}
function CopyButton({ text, label }: { text: string; label: string }) {
const handleCopy = async () => {
try {
await navigator.clipboard.writeText(text);
} catch {
const textarea = document.createElement('textarea');
textarea.value = text;
document.body.appendChild(textarea);
textarea.select();
document.execCommand('copy');
document.body.removeChild(textarea);
}
};
return (
<button onClick={handleCopy} className="text-violet-600 text-sm font-medium hover:text-violet-700 transition-colors">
{label}
</button>
);
}
function AnalysisCard({ title, content, copyLabel }: { title: string; content: string; copyLabel: string }) {
return (
<div className="bg-white rounded-xl p-4 border border-slate-200 flex flex-col gap-2.5">
<div className="flex items-center justify-between">
<h4 className="text-violet-600 text-sm font-bold">{title}</h4>
<CopyButton text={content} label={copyLabel} />
</div>
<p className="text-slate-600 text-sm leading-relaxed whitespace-pre-wrap">{content}</p>
</div>
);
}
function FocusPointsCard({ points, title, copyLabel }: { points: string[]; title: string; copyLabel: string }) {
if (points.length === 0) return null;
const content = points.join('\n');
return (
<div className="bg-white rounded-xl p-4 border border-slate-200 flex flex-col gap-2.5">
<div className="flex items-center justify-between">
<h4 className="text-violet-600 text-sm font-bold">{title}</h4>
<CopyButton text={content} label={copyLabel} />
</div>
<div className="flex flex-col gap-1">
{points.map((point, i) => (
<p key={i} className="text-slate-600 text-sm leading-relaxed">{point}</p>
))}
</div>
</div>
);
}
function WarningCard({ message }: { message: string }) {
return (
<div className="bg-amber-50 rounded-xl p-3.5 flex items-start gap-2.5">
<Icon name="warning" className="w-5 h-5 text-amber-500 shrink-0 mt-0.5" />
<p className="text-amber-700 text-sm leading-relaxed">{message}</p>
</div>
);
}
function InfoCard({ data, t, locale }: { data: DivinationResultData; t: Record<string, string>; locale: string }) {
const methodLabel = data.params.method === 'auto' ? t.autoMethod : t.manualMethod;
return (
<div className="bg-white rounded-xl p-4 border border-slate-200 flex flex-col gap-3">
<h4 className="text-violet-600 text-sm font-bold">{t.divinationInfo}</h4>
<div className="flex flex-col gap-2 text-sm">
<div className="flex justify-between">
<span className="text-slate-400">{t.divinationTime}</span>
<span className="text-slate-600">{formatDivinationTime(data.params.divinationTime, locale)}</span>
</div>
<div className="flex justify-between">
<span className="text-slate-400">{t.divinationMethod}</span>
<span className="text-slate-600">{methodLabel}</span>
</div>
<div className="flex justify-between">
<span className="text-slate-400">{t.questionType}</span>
<span className="text-slate-600">{getQuestionTypeLabel(data.params.questionType, t)}</span>
</div>
<div className="flex justify-between">
<span className="text-slate-400">{t.question}</span>
<span className="text-slate-600 max-w-[200px] text-right truncate">{data.params.question}</span>
</div>
</div>
</div>
);
}
function GanzhiCard({ ganzhi, wuXingStatus, t }: { ganzhi: DivinationResultData['ganzhi']; wuXingStatus: Record<string, string>; t: Record<string, string> }) {
return (
<div className="bg-white rounded-xl p-4 border border-slate-200 flex flex-col gap-3">
<h4 className="text-violet-600 text-sm font-bold">{t.ganZhiInfo}</h4>
<div className="grid grid-cols-2 gap-2 text-sm">
<div className="flex gap-1">
<span className="text-slate-400">{t.termYueJian}:</span>
<span className="text-slate-700 font-medium">{ganzhi.yueJian}</span>
</div>
<div className="flex gap-1">
<span className="text-slate-400">{t.termRiChen}:</span>
<span className="text-slate-700 font-medium">{ganzhi.riChen}</span>
</div>
<div className="flex gap-1">
<span className="text-slate-400">{t.termYuePo}:</span>
<span className="text-slate-700 font-medium">{ganzhi.yuePo}</span>
</div>
<div className="flex gap-1">
<span className="text-slate-400">{t.termRiChong}:</span>
<span className="text-slate-700 font-medium">{ganzhi.riChong}</span>
</div>
</div>
<div>
<h5 className="text-violet-600 text-xs font-bold mb-2">{t.wuXingWangShuai}</h5>
<div className="border border-slate-200 rounded overflow-hidden text-xs">
<div className="flex bg-slate-100">
{WU_XING.map((w, i) => (
<div key={w} className={`flex-1 py-1.5 text-center ${i < WU_XING.length - 1 ? 'border-r border-slate-200' : ''}`}>
{w}
</div>
))}
</div>
<div className="flex">
{WU_XING.map((w, i) => (
<div key={w} className={`flex-1 py-1.5 text-center ${i < WU_XING.length - 1 ? 'border-r border-slate-200' : ''}`}>
{wuXingStatus[w] || ''}
</div>
))}
</div>
</div>
</div>
<div>
<h5 className="text-violet-600 text-xs font-bold mb-2">{t.ganZhiKongWang}</h5>
<div className="border border-slate-200 rounded overflow-hidden text-xs">
<div className="flex bg-slate-100">
{[t.pillarColumn, t.yearPillar, t.monthPillar, t.dayPillar, t.timePillar].map((h, i, arr) => (
<div key={h} className={`py-1.5 text-center ${i === 0 ? 'flex-[2]' : 'flex-[3]'} ${i < arr.length - 1 ? 'border-r border-slate-200' : ''}`}>
{h}
</div>
))}
</div>
<div className="flex border-t border-slate-200">
{[t.ganZhiLabel, ganzhi.yearGanZhi, ganzhi.monthGanZhi, ganzhi.dayGanZhi, ganzhi.timeGanZhi].map((v, i, arr) => (
<div key={i} className={`py-1.5 text-center ${i === 0 ? 'flex-[2] font-bold' : 'flex-[3]'} ${i < arr.length - 1 ? 'border-r border-slate-200' : ''}`}>
{v}
</div>
))}
</div>
<div className="flex border-t border-slate-200">
{[t.kongWangLabel, ganzhi.yearKongWang, ganzhi.monthKongWang, ganzhi.dayKongWang, ganzhi.timeKongWang].map((v, i, arr) => (
<div key={i} className={`py-1.5 text-center ${i === 0 ? 'flex-[2] font-bold' : 'flex-[3]'} ${i < arr.length - 1 ? 'border-r border-slate-200' : ''}`}>
{v}
</div>
))}
</div>
</div>
</div>
</div>
);
}
function YaoDetailRow({ line, target, showTarget }: { line: DivinationResultData['yaoLines'][0]; target: DivinationResultData['yaoLines'][0]; showTarget: boolean }) {
return (
<div className="flex gap-4 py-1.5">
<div className="flex-1 flex items-center gap-1 text-xs">
<span className="w-5 text-center">{line.spirit}</span>
<span className="w-7 text-center">{abbreviateRelation(line.relation)}</span>
<span className="w-5 text-center">{line.branch}</span>
<span className="w-5 text-center">{line.element}</span>
<div className="flex-1 px-1"><YaoGlyph type={line.type} /></div>
<span className="w-4 text-center">{getChangeMark(line.type)}</span>
<span className="w-4 text-center">{line.mark}</span>
</div>
{showTarget && (
<div className="flex-1 flex items-center gap-1 text-xs">
<span className="w-5 text-center">{target.spirit}</span>
<span className="w-7 text-center">{abbreviateRelation(target.relation)}</span>
<span className="w-5 text-center">{target.branch}</span>
<span className="w-5 text-center">{target.element}</span>
<div className="flex-1 px-1"><YaoGlyph type={target.type} /></div>
<span className="w-4 text-center">{getChangeMark(target.type)}</span>
<span className="w-4 text-center" />
</div>
)}
</div>
);
}
function HexagramDetailCard({ data, t }: { data: DivinationResultData; t: Record<string, string> }) {
const hasChangingYao = data.binaryCode !== data.changedBinaryCode;
return (
<div className="bg-slate-50 rounded-xl p-4 border border-slate-200 flex flex-col gap-3">
<div className="flex gap-4">
<div className="flex-1 text-center font-bold text-sm">{data.guaName}</div>
{hasChangingYao && (
<div className="flex-1 text-center font-bold text-sm">{data.targetGuaName}</div>
)}
</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}
line={data.yaoLines[idx]}
target={idx < data.targetYaoLines.length ? data.targetYaoLines[idx] : data.yaoLines[idx]}
showTarget={hasChangingYao && idx < data.targetYaoLines.length}
/>
))}
</div>
);
}
function FollowUpPanel({ canFollowUp, onFollowUp, t }: { canFollowUp: boolean; onFollowUp?: () => void; t: Record<string, string> }) {
return (
<div className={`rounded-xl p-4 flex flex-col gap-3 ${canFollowUp ? 'bg-violet-600' : 'bg-slate-500'}`}>
<h4 className="text-white font-bold">{canFollowUp ? t.followUpEntryHint : t.followUpQuotaUsed}</h4>
<button
onClick={onFollowUp}
className="w-full py-2.5 rounded-lg bg-white text-sm font-semibold hover:bg-violet-50 transition-colors"
style={{ color: canFollowUp ? '#673AB7' : '#475569' }}
>
{canFollowUp ? t.followUpEntryAction : t.followUpViewHistory}
</button>
</div>
);
}
const RESULT_STORAGE_KEY = 'divination_result_data';
export default function DivinationResultPage({ locale, translations: t }: Props) {
const location = useLocation();
const navigate = useNavigate();
const { id: threadId } = useParams<{ id: string }>();
const [data, setData] = useState<DivinationResultData | null>(null);
const [loading, setLoading] = useState(true);
const [canFollowUp, setCanFollowUp] = useState(true);
useEffect(() => {
let alive = true;
// 1. Try router state (from divination flow)
const state = location.state as { result?: DivinationResultData } | null;
if (state?.result) {
setData(state.result);
setLoading(false);
try { sessionStorage.setItem(RESULT_STORAGE_KEY, JSON.stringify(state.result)); } catch { /* ignore */ }
return;
}
// 2. Try sessionStorage (backup for divination flow)
try {
const stored = sessionStorage.getItem(RESULT_STORAGE_KEY);
if (stored) {
const parsed = JSON.parse(stored);
setData(parsed);
setLoading(false);
return;
}
} catch { /* ignore */ }
// 3. Fetch by threadId (from history flow)
if (threadId) {
setLoading(true);
getAgentHistoryByThread(threadId)
.then((snapshot) => {
if (!alive) return;
const assistantMsg = snapshot.messages.find((m) => m.role === 'assistant');
if (!assistantMsg) return;
const resultData = historyMessageToResultData(assistantMsg);
if (resultData) setData(resultData);
const userCount = snapshot.messages.filter((m) => m.role === 'user').length;
setCanFollowUp(userCount < 2);
})
.catch(() => { /* ignore */ })
.finally(() => { if (alive) setLoading(false); });
} else {
setLoading(false);
}
return () => { alive = false; };
}, [location.state, threadId]);
// Redirect if no data and not loading
useEffect(() => {
if (!loading && data === null) {
const timer = setTimeout(() => {
navigate(`/${locale}/dashboard`);
}, 100);
return () => clearTimeout(timer);
}
}, [data, loading, navigate, locale]);
const handleBackHome = () => {
navigate(`/${locale}/dashboard`);
};
const handleFollowUp = () => {
if (threadId) {
navigate(`/${locale}/history/${threadId}/followup`, { state: { result: data } });
}
};
if (loading || data === null) {
return (
<div className="flex items-center justify-center min-h-[400px]">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-violet-600" />
</div>
);
}
const isSuccess = data.status === 'success';
return (
<div className="flex flex-col gap-5 min-h-full">
{/* Header */}
<div className="flex items-center gap-3.5">
<button
onClick={handleBackHome}
className="w-10 h-10 rounded-full bg-white border border-slate-200 flex items-center justify-center hover:bg-slate-50 transition-colors"
aria-label={locale === 'en' ? 'Back to home' : '返回首页'}
>
<Icon name="arrow_back" className="w-[18px] h-[18px] text-slate-500" />
</button>
<h1 className="text-slate-900 text-2xl font-bold">{t.screenTitle}</h1>
</div>
{/* Body */}
<div className="flex flex-col xl:flex-row gap-5 flex-1 min-h-0 pb-8">
{/* Left: Analysis */}
<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">
<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">
<h3 className="text-slate-900 text-[26px] font-bold leading-tight">
{getSignTypeLabel(data.signType, t)} · {data.guaName}
</h3>
<p className="text-slate-500 text-base font-semibold truncate">{data.params.question}</p>
<div className="flex items-center gap-2">
<span className="px-2.5 py-1 rounded-lg bg-violet-50 text-violet-700 text-xs">
{getQuestionTypeLabel(data.params.questionType, t)}
</span>
<span className="px-2.5 py-1 rounded-lg bg-sky-50 text-sky-700 text-xs">
{data.params.method === 'auto' ? t.autoMethod : t.manualMethod}
</span>
</div>
</div>
</div>
{data.keywords && (
<div className="bg-amber-50 rounded-xl p-4 text-center">
<p className="text-violet-600 font-bold">{data.keywords}</p>
</div>
)}
<AnalysisCard title={t.conclusion} content={data.conclusion} copyLabel={t.copy} />
<AnalysisCard title={t.suggestion} content={data.suggestion} copyLabel={t.copy} />
<AnalysisCard title={t.analysis} content={data.analysis} copyLabel={t.copy} />
<FocusPointsCard points={data.focusPoints} title={t.focusPoints} copyLabel={t.copy} />
<WarningCard message={t.warning} />
</div>
{/* Right side column */}
<div className="w-full xl:w-[340px] flex flex-col gap-3.5 shrink-0">
{isSuccess && data.threadId && (
<FollowUpPanel canFollowUp={canFollowUp} onFollowUp={handleFollowUp} t={t} />
)}
<InfoCard data={data} t={t} locale={locale} />
{isSuccess && (
<GanzhiCard ganzhi={data.ganzhi} wuXingStatus={data.wuXingStatus} t={t} />
)}
{isSuccess ? (
<HexagramDetailCard data={data} t={t} />
) : (
<div className="bg-slate-100 rounded-xl p-6 text-center">
<p className="text-slate-500 text-sm">
{data.status === 'failed' ? t.hexagramDetailFailed : t.hexagramDetailRefused}
</p>
</div>
)}
</div>
</div>
</div>
);
}
+359 -56
View File
@@ -1,4 +1,13 @@
import { useState } from 'react';
import { useState, useEffect, useRef, useCallback } from 'react';
import { useLocation, useNavigate, useParams } from 'react-router-dom';
import {
getAgentHistoryByThread,
historyMessageToResultData,
enqueueFollowUpRun,
streamDivinationEvents,
type DivinationResultData,
} from '../lib/api';
import Icon from './Icon';
interface Props {
locale: string;
@@ -6,82 +15,376 @@ interface Props {
history: { chatTitle: string; chatPlaceholder: string; sendBtn: string; followUpRules: string; followUpRule1: string; followUpRule2: string; relatedActions: string; newDivination: string; viewHistory: string; resultTitle: string };
}
const MOCK_MESSAGES = [
{ role: 'ai' as const, content: '您好,关于"今年转岗是否合适"的卦象解读已完成。如果您对某些方面还有疑问,可以进行一次追问。' },
{ role: 'user' as const, content: '请问什么时间转岗比较合适?' },
{ role: 'ai' as const, content: '根据天雷无妄卦的分析,结合当前时令,建议您关注秋季(农历七八月)的机会。届时天时更为有利,变动容易获得好的结果。目前阶段以积累和准备为主。' },
];
interface ChatMessage {
id: string;
role: 'user' | 'assistant';
content: string;
}
function getSignImageSrc(signType: string): string {
const normalized = signType.trim();
if (normalized.includes('上上')) return '/images/qigua/shangshang.jpg';
if (normalized.includes('中上')) return '/images/qigua/zhongshang.jpg';
if (normalized.includes('下下')) return '/images/qigua/xiaxia.jpg';
return '/images/qigua/zhongxia.jpg';
}
function getQuestionTypeLabel(type: string): string {
const QUESTION_TYPE_ZH: Record<string, string> = {
career: '事业', love: '情感', wealth: '财富', fortune: '运势',
dream: '解梦', health: '健康', study: '学业', search: '寻物', other: '其他',
'事业': '事业', '情感': '情感', '财富': '财富', '运势': '运势',
'解梦': '解梦', '健康': '健康', '学业': '学业', '寻物': '寻物', '其他': '其他',
};
return QUESTION_TYPE_ZH[type] || type;
}
const CATEGORY_COLORS: Record<string, string> = {
'career': 'bg-blue-50 text-blue-500',
'love': 'bg-pink-50 text-pink-500',
'wealth': 'bg-amber-50 text-amber-600',
'fortune': 'bg-purple-50 text-purple-500',
'dream': 'bg-indigo-50 text-indigo-500',
'health': 'bg-red-50 text-red-500',
'study': 'bg-green-50 text-green-600',
'search': 'bg-cyan-50 text-cyan-600',
'other': 'bg-slate-100 text-slate-500',
'事业': 'bg-blue-50 text-blue-500',
'情感': 'bg-pink-50 text-pink-500',
'财富': 'bg-amber-50 text-amber-600',
'运势': 'bg-purple-50 text-purple-500',
'解梦': 'bg-indigo-50 text-indigo-500',
'健康': 'bg-red-50 text-red-500',
'学业': 'bg-green-50 text-green-600',
'寻物': 'bg-cyan-50 text-cyan-600',
'其他': 'bg-slate-100 text-slate-500',
};
export default function HistoryFollowUpPage({ locale, history: h }: Props) {
const [message, setMessage] = useState('');
const [messages, setMessages] = useState(MOCK_MESSAGES);
const location = useLocation();
const navigate = useNavigate();
const { id: threadId } = useParams<{ id: string }>();
const handleSend = () => {
if (!message.trim()) return;
setMessages(prev => [...prev, { role: 'user', content: message }]);
setMessage('');
const [resultData, setResultData] = useState<DivinationResultData | null>(null);
const [messages, setMessages] = useState<ChatMessage[]>([]);
const [loading, setLoading] = useState(true);
const [sending, setSending] = useState(false);
const [error, setError] = useState('');
const [inputText, setInputText] = useState('');
const messagesEndRef = useRef<HTMLDivElement>(null);
const scrollToBottom = useCallback(() => {
setTimeout(() => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
}, 50);
}, []);
// Load history messages and result data
useEffect(() => {
let alive = true;
async function load() {
if (!threadId) {
setLoading(false);
return;
}
try {
const snapshot = await getAgentHistoryByThread(threadId);
if (!alive) return;
// Extract result data from first assistant message
const assistantMsg = snapshot.messages.find((m) => m.role === 'assistant');
if (assistantMsg) {
const result = historyMessageToResultData(assistantMsg);
if (result) setResultData(result);
}
// Convert all messages to chat format (user + assistant)
const chatMessages: ChatMessage[] = snapshot.messages
.filter((m) => m.role === 'user' || m.role === 'assistant')
.map((m) => ({
id: m.id,
role: m.role as 'user' | 'assistant',
content: m.content || '',
}));
setMessages(chatMessages);
} catch (err) {
if (!alive) return;
setError(err instanceof Error ? err.message : 'Failed to load history');
} finally {
if (alive) {
setLoading(false);
scrollToBottom();
}
}
}
// Also try to get result from router state (passed from DivinationResultPage)
const state = location.state as { result?: DivinationResultData } | null;
if (state?.result) {
setResultData(state.result);
}
load();
return () => { alive = false; };
}, [threadId, location.state, scrollToBottom]);
// Follow-up quota: original user message + max 1 follow-up
const hasFollowUpQuota = (() => {
const userCount = messages.filter((m) => m.role === 'user').length;
return userCount < 2;
})();
const handleSend = useCallback(async () => {
const text = inputText.trim();
if (!text || sending || !hasFollowUpQuota || !threadId || !resultData) return;
setInputText('');
setSending(true);
setError('');
const localUserMsg: ChatMessage = {
id: `local_user_${Date.now()}`,
role: 'user',
content: text,
};
const localAssistantMsg: ChatMessage = {
id: `local_assistant_${Date.now()}`,
role: 'assistant',
content: '',
};
setMessages((prev) => [...prev, localUserMsg, localAssistantMsg]);
scrollToBottom();
try {
const { runId } = await enqueueFollowUpRun(threadId, text, resultData);
let answer = '';
for await (const event of streamDivinationEvents(threadId, runId)) {
if (event.type === 'TEXT_MESSAGE_END') {
answer = (event.data.answer as string) || '';
} else if (event.type === 'RUN_ERROR') {
throw new Error((event.data.detail as string) || 'Follow-up failed');
} else if (event.type === 'RUN_FINISHED') {
break;
}
}
// Update assistant message with answer
setMessages((prev) =>
prev.map((m) =>
m.id === localAssistantMsg.id ? { ...m, content: answer } : m
)
);
// Reload history to get server-side message IDs
const snapshot = await getAgentHistoryByThread(threadId);
const chatMessages: ChatMessage[] = snapshot.messages
.filter((m) => m.role === 'user' || m.role === 'assistant')
.map((m) => ({
id: m.id,
role: m.role as 'user' | 'assistant',
content: m.content || '',
}));
setMessages(chatMessages);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to send follow-up');
// Reload history to restore correct state
try {
const snapshot = await getAgentHistoryByThread(threadId);
const chatMessages: ChatMessage[] = snapshot.messages
.filter((m) => m.role === 'user' || m.role === 'assistant')
.map((m) => ({
id: m.id,
role: m.role as 'user' | 'assistant',
content: m.content || '',
}));
setMessages(chatMessages);
} catch { /* ignore */ }
} finally {
setSending(false);
scrollToBottom();
}
}, [inputText, sending, hasFollowUpQuota, threadId, resultData, scrollToBottom]);
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleSend();
}
};
const handleBack = () => {
navigate(-1);
};
const signImgSrc = resultData ? getSignImageSrc(resultData.signType) : '';
const questionTypeLabel = resultData ? getQuestionTypeLabel(resultData.params.questionType) : '';
const categoryColor = resultData ? (CATEGORY_COLORS[resultData.params.questionType] || 'bg-slate-100 text-slate-500') : '';
return (
<div className="flex flex-col xl:flex-row gap-5 min-h-full">
{/* Chat panel */}
<div className="flex-1 bg-white rounded-2xl border border-slate-200 flex flex-col overflow-hidden">
{/* Header */}
<div className="flex items-center justify-between px-5 h-[72px] border-b border-slate-200 shrink-0">
{/* Chat panel */}
<div className="flex-1 bg-white rounded-2xl border border-slate-200 flex flex-col overflow-hidden">
{/* Header */}
<div className="flex items-center justify-between px-5 h-[56px] border-b border-slate-200 shrink-0">
<div className="flex items-center gap-3">
<button
onClick={handleBack}
className="w-9 h-9 rounded-full bg-white border border-slate-200 flex items-center justify-center hover:bg-slate-50 transition-colors"
aria-label={locale === 'en' ? 'Back to home' : '返回首页'}
>
<Icon name="arrow_back" className="w-[18px] h-[18px] text-slate-500" />
</button>
<h3 className="text-slate-900 text-base font-bold">{h.chatTitle}</h3>
<span className="text-slate-400 text-sm"></span>
</div>
{resultData && (
<span className="text-slate-400 text-sm">{resultData.guaName}</span>
)}
</div>
{/* Messages */}
<div className="flex-1 flex flex-col gap-[18px] p-6 overflow-y-auto">
{messages.map((msg, i) => (
<div key={i} className={`flex ${msg.role === 'user' ? 'justify-end' : 'justify-start'}`}>
<div className={`max-w-[80%] px-4 py-3 rounded-2xl text-sm leading-relaxed ${msg.role === 'user' ? 'bg-violet-600 text-white' : 'bg-slate-100 text-slate-700'}`}>
{msg.content}
{/* Messages */}
<div className="flex-1 flex flex-col gap-3 p-5 overflow-y-auto">
{loading ? (
<div className="flex-1 flex items-center justify-center">
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-violet-600" />
</div>
) : messages.length === 0 ? (
<div className="flex-1 flex items-center justify-center">
<p className="text-slate-400 text-sm">
{locale === 'en' ? 'No messages yet' : '暂无对话消息'}
</p>
</div>
) : (
messages.map((msg) => (
<div key={msg.id} className={`flex ${msg.role === 'user' ? 'justify-end' : 'justify-start'}`}>
<div className={`max-w-[80%] px-4 py-3 rounded-2xl text-sm leading-relaxed ${
msg.role === 'user'
? 'bg-violet-600 text-white'
: 'bg-slate-100 text-slate-700 border border-slate-200'
}`}>
{msg.content || (
<span className="inline-flex gap-1">
<span className="animate-bounce" style={{ animationDelay: '0ms' }}>.</span>
<span className="animate-bounce" style={{ animationDelay: '150ms' }}>.</span>
<span className="animate-bounce" style={{ animationDelay: '300ms' }}>.</span>
</span>
)}
</div>
</div>
))}
</div>
))
)}
<div ref={messagesEndRef} />
</div>
{/* Composer */}
<div className="px-[22px] py-[18px] border-t border-slate-200 flex flex-col gap-3 shrink-0">
<textarea value={message} onChange={e => setMessage(e.target.value)} placeholder={h.chatPlaceholder} rows={2}
onKeyDown={e => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); handleSend(); } }}
className="w-full px-4 py-3 rounded-xl bg-slate-50 border border-slate-200 text-sm focus:outline-none focus:border-violet-400 resize-none" />
<div className="flex justify-end">
<button onClick={handleSend} className="px-5 py-2 rounded-lg bg-violet-600 text-white text-sm font-semibold hover:bg-violet-700 transition-colors">{h.sendBtn}</button>
{/* Error */}
{error && (
<div className="px-5 py-2">
<p className="text-red-500 text-xs">{error}</p>
</div>
)}
{/* Composer */}
<div className="px-[22px] py-[18px] border-t border-slate-200 shrink-0">
<div className="rounded-[18px] bg-slate-50 border border-slate-300 shadow-[0_6px_16px_rgba(0,0,0,0.04)] flex flex-col gap-3 p-[14px_16px]">
<textarea
value={inputText}
onChange={(e) => setInputText(e.target.value)}
onKeyDown={handleKeyDown}
placeholder={hasFollowUpQuota
? (locale === 'en' ? 'Ask a follow-up, e.g.: When should I proceed?' : '继续追问这次解卦,例如:我应该什么时候推进?')
: (locale === 'en' ? 'Follow-up quota used' : '追问次数已用完')}
rows={2}
disabled={!hasFollowUpQuota || sending}
className="w-full bg-transparent text-sm leading-[1.45] focus:outline-none resize-none disabled:cursor-not-allowed placeholder:text-slate-500"
/>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<span className={`rounded-full px-2 py-1 text-xs font-bold ${
hasFollowUpQuota ? 'bg-violet-50 text-violet-600' : 'bg-red-50 text-red-500'
}`}>
{hasFollowUpQuota
? (locale === 'en' ? '1 left' : '剩余 1 次')
: (locale === 'en' ? '0 left' : '剩余 0 次')}
</span>
<span className="text-slate-400 text-xs">
{locale === 'en' ? 'Enter to send, Shift+Enter for new line' : 'Enter 发送,Shift + Enter 换行'}
</span>
</div>
<button
onClick={handleSend}
disabled={!inputText.trim() || sending || !hasFollowUpQuota}
className="w-[38px] h-[38px] rounded-full bg-violet-600 flex items-center justify-center hover:bg-violet-700 transition-colors disabled:opacity-40 disabled:cursor-not-allowed shrink-0"
>
{sending ? (
<div className="w-4 h-4 rounded-full border-2 border-white border-t-transparent animate-spin" />
) : (
<Icon name="arrow_upward" className="w-5 h-5 text-white" />
)}
</button>
</div>
</div>
</div>
</div>
{/* Right side column */}
<div className="w-full xl:w-[340px] flex flex-col gap-3.5 shrink-0">
{/* Result summary */}
<div className="bg-white rounded-2xl p-[18px] border border-slate-200 flex flex-col gap-3">
{/* Right side column */}
<div className="w-full xl:w-[340px] flex flex-col gap-3.5 shrink-0">
{/* Result summary */}
{resultData && (
<div className="bg-white rounded-2xl p-4 border border-slate-200 flex flex-col gap-3">
<h4 className="text-slate-900 text-sm font-bold">{h.resultTitle}</h4>
<div className="flex justify-between text-sm"><span className="text-slate-400"></span><span className="text-slate-600"></span></div>
<div className="flex justify-between text-sm"><span className="text-slate-400"></span><span className="text-slate-600"></span></div>
<div className="flex justify-between text-sm"><span className="text-slate-400"></span><span className="text-slate-600">2025-05-08</span></div>
<div className="flex items-center gap-3">
<div className="w-14 h-14 rounded-lg overflow-hidden shrink-0">
<img src={signImgSrc} alt="" className="w-full h-full object-cover" />
</div>
<div className="flex-1 min-w-0 flex flex-col gap-1">
<p className="text-slate-700 text-sm font-semibold truncate">{resultData.params.question}</p>
<div className="flex items-center gap-1.5">
<span className={`px-2 py-0.5 rounded-md text-xs font-medium ${categoryColor}`}>
{questionTypeLabel}
</span>
<span className="px-2 py-0.5 rounded-md bg-violet-50 text-violet-600 text-xs font-medium">
{resultData.guaName}
</span>
</div>
</div>
</div>
{resultData.conclusion && (
<p className="text-slate-500 text-xs leading-relaxed line-clamp-3">{resultData.conclusion}</p>
)}
</div>
)}
{/* Follow-up rules */}
<div className="bg-amber-50 rounded-2xl p-[18px] border border-amber-200 flex flex-col gap-3">
<h4 className="text-slate-900 text-sm font-bold">{h.followUpRules}</h4>
<p className="text-amber-700 text-sm">{h.followUpRule1}</p>
<p className="text-amber-700 text-sm">{h.followUpRule2}</p>
</div>
{/* Related actions */}
<div className="bg-white rounded-2xl p-[18px] border border-slate-200 flex flex-col gap-2.5">
<h4 className="text-slate-900 text-sm font-bold">{h.relatedActions}</h4>
<a href={`/${locale}/divination/manual`} className="flex items-center gap-2 text-sm text-slate-600 hover:text-violet-600 transition-colors py-1">
<span className="material-symbols-rounded text-base">casino</span>{h.newDivination}
</a>
<a href={`/${locale}/history`} className="flex items-center gap-2 text-sm text-slate-600 hover:text-violet-600 transition-colors py-1">
<span className="material-symbols-rounded text-base">history</span>{h.viewHistory}
</a>
</div>
{/* Follow-up rules */}
<div className="bg-amber-50 rounded-2xl p-4 border border-amber-200 flex flex-col gap-2">
<h4 className="text-slate-900 text-sm font-bold">{h.followUpRules}</h4>
<p className="text-amber-700 text-sm">{h.followUpRule1}</p>
<p className="text-amber-700 text-sm">{h.followUpRule2}</p>
</div>
{/* Related actions */}
<div className="bg-white rounded-2xl p-4 border border-slate-200 flex flex-col gap-2.5">
<h4 className="text-slate-900 text-sm font-bold">{h.relatedActions}</h4>
<button
onClick={() => navigate(`/${locale}/history/${threadId}`)}
className="flex items-center gap-2 text-sm text-slate-600 hover:text-violet-600 transition-colors py-1"
>
<Icon name="auto_awesome" className="w-4 h-4" />
{locale === 'en' ? 'View Full Reading' : '查看完整解卦'}
</button>
<button
onClick={() => navigate(`/${locale}/history`)}
className="flex items-center gap-2 text-sm text-slate-600 hover:text-violet-600 transition-colors py-1"
>
<Icon name="history" className="w-4 h-4" />
{h.viewHistory}
</button>
</div>
</div>
</div>
);
}
+271 -57
View File
@@ -1,83 +1,297 @@
import { useState } from 'react';
import { useState, useEffect, useMemo } from 'react';
import { useNavigate } from 'react-router-dom';
import { getAgentHistory, mapHistoryMessagesToItems, type HistoryItem } from '../lib/api';
import Icon from './Icon';
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 };
history: { title: string; statTotal: string; statFollow: string; statLatest: string; filters: string; filterAll: string; filterCareer: string; filterLove: string; filterWealth: string; noResults: string };
history: { title: string; statTotal: string; statLatest: string; filters: string; filterAll: string; filterCareer: string; filterLove: string; filterWealth: string; noResults: string };
}
const MOCK_HISTORY = [
{ id: 1, question: '今年转岗是否合适?', date: '2025-05-08', category: '事业', hexagram: '天雷无妄', rating: '上上签', followUp: false },
{ id: 2, question: '最近感情是否能推进?', date: '2025-05-07', category: '感情', hexagram: '泽火革', rating: '中上签', followUp: true },
{ id: 3, question: '投资理财近期运势如何?', date: '2025-05-05', category: '财富', hexagram: '水地比', rating: '中签', followUp: false },
{ id: 4, question: '学业考试能否顺利通过?', date: '2025-05-03', category: '学业', hexagram: '山火贲', rating: '上签', followUp: true },
];
const ALL_CATEGORIES = ['career', 'love', 'wealth', 'fortune', 'dream', 'health', 'study', 'search', 'other'] as const;
type CategoryKey = typeof ALL_CATEGORIES[number];
const CATEGORY_COLORS: Record<string, string> = {
'事业': 'bg-blue-50 text-blue-500', '感情': 'bg-pink-50 text-pink-500', '财富': 'bg-amber-50 text-amber-600', '学业': 'bg-green-50 text-green-600',
'career': 'bg-blue-50 text-blue-500',
'love': 'bg-pink-50 text-pink-500',
'wealth': 'bg-amber-50 text-amber-600',
'fortune': 'bg-purple-50 text-purple-500',
'dream': 'bg-indigo-50 text-indigo-500',
'health': 'bg-red-50 text-red-500',
'study': 'bg-green-50 text-green-600',
'search': 'bg-cyan-50 text-cyan-600',
'other': 'bg-slate-100 text-slate-500',
};
export default function HistoryListPage({ locale, history: h }: Props) {
const [selectedId, setSelectedId] = useState(1);
const [filter, setFilter] = useState('all');
const CATEGORY_LABELS_ZH: Record<string, string> = {
'career': '事业',
'love': '感情',
'wealth': '财富',
'fortune': '运势',
'dream': '解梦',
'health': '健康',
'study': '学业',
'search': '寻物',
'other': '其他',
};
const filters = [
{ id: 'all', label: h.filterAll },
{ id: 'career', label: h.filterCareer },
{ id: 'love', label: h.filterLove },
{ id: 'wealth', label: h.filterWealth },
];
const CATEGORY_LABELS_EN: Record<string, string> = {
'career': 'Career',
'love': 'Love',
'wealth': 'Wealth',
'fortune': 'Fortune',
'dream': 'Dream',
'health': 'Health',
'study': 'Study',
'search': 'Search',
'other': 'Other',
};
const RATING_COLORS: Record<string, string> = {
'上上签': 'bg-amber-50 text-amber-500',
'上签': 'bg-amber-50 text-amber-500',
'中上签': 'bg-violet-50 text-violet-600',
'中签': 'bg-slate-100 text-slate-500',
'下签': 'bg-red-50 text-red-500',
};
export default function HistoryListPage({ locale, history: i18n }: Props) {
const navigate = useNavigate();
const [allItems, setAllItems] = useState<HistoryItem[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [selectedId, setSelectedId] = useState<string | null>(null);
const [activeFilter, setActiveFilter] = useState<CategoryKey | 'all'>('all');
const [searchQuery, setSearchQuery] = useState('');
// 获取历史数据
useEffect(() => {
let alive = true;
setLoading(true);
setError('');
getAgentHistory()
.then((data) => {
if (!alive) return;
setAllItems(mapHistoryMessagesToItems(data.messages));
})
.catch((err) => {
if (!alive) return;
setError(err instanceof Error ? err.message : 'Failed to load history');
})
.finally(() => {
if (alive) setLoading(false);
});
return () => {
alive = false;
};
}, []);
const stats = useMemo(() => {
const total = allItems.length;
const latest = allItems.length > 0 ? allItems[0].created_at : null;
return { total, latest };
}, [allItems]);
// 过滤后的列表
const filteredItems = useMemo(() => {
let items = allItems;
// 分类筛选
if (activeFilter !== 'all') {
items = items.filter((item) => item.category === activeFilter);
}
// 搜索筛选
if (searchQuery.trim()) {
const query = searchQuery.toLowerCase();
items = items.filter(
(item) =>
item.question.toLowerCase().includes(query) ||
item.hexagram_name.toLowerCase().includes(query)
);
}
return items;
}, [allItems, activeFilter, searchQuery]);
// 各分类数量
const filterCounts = useMemo(() => {
const counts: Record<string, number> = { all: allItems.length };
for (const cat of ALL_CATEGORIES) {
counts[cat] = allItems.filter((item) => item.category === cat).length;
}
return counts;
}, [allItems]);
// 格式化最近时间
const formatLatest = (dateStr: string | null) => {
if (!dateStr) return '--';
const date = new Date(dateStr);
const now = new Date();
const isToday = date.toDateString() === now.toDateString();
const timeStr = date.toLocaleTimeString(locale === 'en' ? 'en-US' : 'zh-CN', {
hour: '2-digit',
minute: '2-digit',
});
if (isToday) {
return locale === 'en' ? `Today ${timeStr}` : `今天 ${timeStr}`;
}
return date.toLocaleDateString(locale === 'en' ? 'en-US' : 'zh-CN', {
month: 'short',
day: 'numeric',
});
};
// 点击卡片跳转
const handleItemClick = (item: HistoryItem) => {
setSelectedId(item.id);
navigate(`/${locale}/history/${item.threadId}`);
};
// 返回首页
const handleBackHome = () => {
navigate(`/${locale}/dashboard`);
};
return (
<div className="flex flex-col gap-5 min-h-full">
{/* Stats */}
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
{[{ label: h.statTotal, value: '12' }, { label: h.statFollow, value: '3' }, { label: h.statLatest, value: '5/8' }].map((stat, i) => (
<div key={i} className="bg-white rounded-xl p-4 border border-slate-200 flex flex-col gap-1.5">
<p className="text-slate-400 text-xs">{stat.label}</p>
<p className="text-slate-900 text-xl font-bold">{stat.value}</p>
</div>
))}
{/* Header */}
<div className="flex items-center justify-between gap-4">
<div className="flex items-center gap-3">
<button
onClick={handleBackHome}
className="w-10 h-10 rounded-full bg-white border border-slate-200 flex items-center justify-center hover:bg-slate-50 transition-colors"
aria-label={locale === 'en' ? 'Back to home' : '返回首页'}
>
<Icon name="arrow_back" className="w-5 h-5 text-slate-600" />
</button>
<div>
<h1 className="text-slate-900 text-xl font-semibold">{i18n.title}</h1>
</div>
</div>
<div className="flex items-center gap-2">
{/* Search */}
<div className="relative">
<Icon name="search" className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400" />
<input
type="text"
placeholder={locale === 'en' ? 'Search question or hexagram' : '搜索问题或卦名'}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="w-60 pl-9 pr-4 py-2 rounded-full bg-white border border-slate-200 text-sm focus:outline-none focus:border-violet-400 transition-colors"
/>
</div>
</div>
</div>
{/* Main: List + Filters */}
<div className="flex flex-col lg:flex-row gap-5 flex-1 min-h-0">
<div className="flex-1 bg-white rounded-2xl p-[18px] border border-slate-200 flex flex-col gap-3 overflow-y-auto">
<div className="flex items-center justify-between">
<h3 className="text-slate-900 text-sm font-bold">{h.title}</h3>
</div>
{MOCK_HISTORY.map(item => (
<a key={item.id} href={`/${locale}/history/${item.id}`}
onClick={(e) => { e.preventDefault(); setSelectedId(item.id); }}
className={`flex items-center gap-3.5 rounded-xl p-4 cursor-pointer transition-colors border ${selectedId === item.id ? 'bg-violet-50 border-violet-400' : 'bg-white border-slate-200 hover:bg-slate-50'}`}>
<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>
</div>
<div className="flex-1 min-w-0">
<p className="text-slate-900 text-sm font-medium truncate">{item.question}</p>
<div className="flex items-center gap-2 mt-1">
<span className={`px-2 py-0.5 rounded-md text-xs font-medium ${CATEGORY_COLORS[item.category] || 'bg-slate-100 text-slate-500'}`}>{item.category}</span>
<span className="px-2 py-0.5 rounded-md bg-violet-50 text-violet-600 text-xs font-medium">{item.hexagram}</span>
</div>
</div>
<span className="text-slate-400 text-xs shrink-0">{item.date}</span>
</a>
))}
{/* Error */}
{error && (
<div className="rounded-xl border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-600">
{error}
</div>
)}
{/* Stats */}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div className="bg-white rounded-xl p-4 border border-slate-200 flex flex-col gap-1">
<p className="text-slate-500 text-xs">{i18n.statTotal}</p>
<p className="text-slate-900 text-2xl font-bold">{stats.total}</p>
</div>
<div className="bg-white rounded-xl p-4 border border-slate-200 flex flex-col gap-1">
<p className="text-slate-500 text-xs">{i18n.statLatest}</p>
<p className="text-slate-900 text-lg font-bold">{formatLatest(stats.latest)}</p>
</div>
</div>
{/* Main Content: List + Filters */}
<div className="flex flex-col lg:flex-row gap-5 flex-1 min-h-0">
{/* List */}
<div className="flex-1 bg-white rounded-2xl p-4 border border-slate-200 flex flex-col gap-3 overflow-y-auto">
<div className="flex items-center justify-between">
<h3 className="text-slate-900 text-sm font-bold">{i18n.title}</h3>
<span className="text-slate-400 text-xs">{filteredItems.length} {locale === 'en' ? 'items' : '条'}</span>
</div>
{/* Side: Filters */}
<div className="w-full lg:w-[300px] flex flex-col gap-4 shrink-0">
<div className="bg-white rounded-2xl p-[18px] border border-slate-200 flex flex-col gap-2.5">
<h3 className="text-slate-900 text-sm font-bold">{h.filters}</h3>
{filters.map(f => (
<button key={f.id} onClick={() => setFilter(f.id)}
className={`px-3 py-2 rounded-lg text-sm text-left transition-colors ${filter === f.id ? 'bg-violet-50 text-violet-600 font-medium' : 'text-slate-500 hover:bg-slate-50'}`}>
{f.label}
{loading ? (
<div className="text-slate-400 text-sm py-8 text-center">{locale === 'en' ? 'Loading...' : '加载中...'}</div>
) : filteredItems.length === 0 ? (
<div className="text-slate-400 text-sm py-8 text-center">{i18n.noResults}</div>
) : (
<div className="flex flex-col gap-2">
{filteredItems.map((item) => (
<button
key={item.id}
onClick={() => handleItemClick(item)}
className={`flex items-center gap-3 rounded-xl p-4 text-left transition-all cursor-pointer border ${
selectedId === item.id
? 'bg-violet-50 border-violet-400'
: 'bg-white border-slate-200 hover:bg-slate-50'
}`}
>
<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" />
</div>
<div className="flex-1 min-w-0">
<p className="text-slate-900 text-sm font-semibold truncate">{item.question}</p>
<div className="flex items-center gap-2 mt-1">
{item.category && (
<span className={`px-2 py-0.5 rounded-md text-xs font-medium ${CATEGORY_COLORS[item.category] || 'bg-slate-100 text-slate-500'}`}>
{locale === 'en' ? (CATEGORY_LABELS_EN[item.category] || item.category) : (CATEGORY_LABELS_ZH[item.category] || item.category)}
</span>
)}
{item.hexagram_name && (
<span className="px-2 py-0.5 rounded-md bg-violet-50 text-violet-600 text-xs font-medium">{item.hexagram_name}</span>
)}
{item.rating && (
<span className={`px-2 py-0.5 rounded-md text-xs font-medium ${RATING_COLORS[item.rating] || 'bg-slate-100 text-slate-500'}`}>
{item.rating}
</span>
)}
</div>
</div>
<div className="flex flex-col items-end gap-1 shrink-0 w-24">
<span className="text-slate-400 text-xs">{item.created_at?.slice(0, 10) || ''}</span>
<span className="text-violet-500">
<Icon name="chevron_right" className="w-5 h-5" />
</span>
</div>
</button>
))}
</div>
)}
</div>
{/* Side: Quick Filters */}
<div className="w-full lg:w-[280px] flex flex-col gap-4 shrink-0">
<div className="bg-white rounded-2xl p-4 border border-slate-200 flex flex-col gap-2">
<h3 className="text-slate-900 text-sm font-bold">{i18n.filters}</h3>
<button
onClick={() => setActiveFilter('all')}
className={`flex items-center justify-between px-3 py-2 rounded-lg text-sm transition-colors ${
activeFilter === 'all' ? 'bg-slate-100 text-violet-600 font-semibold' : 'text-slate-500 hover:bg-slate-50'
}`}
>
<span>{i18n.filterAll}</span>
<span className={activeFilter === 'all' ? 'text-violet-500' : 'text-slate-400'}>{filterCounts.all}</span>
</button>
{ALL_CATEGORIES.map((cat) => (
<button
key={cat}
onClick={() => setActiveFilter(cat)}
className={`flex items-center justify-between px-3 py-2 rounded-lg text-sm transition-colors ${
activeFilter === cat ? 'bg-slate-100 text-violet-600 font-semibold' : 'text-slate-500 hover:bg-slate-50'
}`}
>
<span>{locale === 'en' ? (CATEGORY_LABELS_EN[cat] || cat) : (CATEGORY_LABELS_ZH[cat] || cat)}</span>
<span className={activeFilter === cat ? 'text-violet-500' : 'text-slate-400'}>{filterCounts[cat] || 0}</span>
</button>
))}
</div>
</div>
</div>
</div>
);
}
-86
View File
@@ -1,86 +0,0 @@
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 };
history: { resultTitle: string; conclusion: string; suggestion: string; analysis: string; focus: string; warning: string; followUpTitle: string; followUpDesc: string; followUpBtn: string; basicInfo: string; ganzhi: string; hexagramDetail: string };
}
export default function HistoryResultPage({ locale, history: h }: Props) {
return (
<div className="flex flex-col xl:flex-row gap-5 min-h-full">
{/* Left: Analysis */}
<div className="flex-1 flex flex-col gap-3.5 overflow-y-auto pr-1">
{/* Hero */}
<div className="bg-white rounded-2xl p-5 border border-slate-200 flex items-center gap-5">
<div className="w-14 h-14 rounded-xl bg-violet-50 flex items-center justify-center">
<span className="text-violet-600 text-2xl"></span>
</div>
<div>
<h3 className="text-slate-900 text-lg font-bold"></h3>
<p className="text-slate-400 text-sm">2025-05-08 · </p>
</div>
<div className="ml-auto">
<span className="px-3 py-1 rounded-full bg-amber-50 text-amber-600 text-sm font-medium"></span>
</div>
</div>
{[
{ title: h.conclusion, content: '天雷无妄卦,上干下震,象征天道运行刚健不妄。此卦提示你顺应天道,不可妄行。目前转岗时机尚未完全成熟,但大方向是正确的。' },
{ title: h.suggestion, content: '建议耐心等待更好的时机。可以先做好当前岗位的积累,同时暗中准备目标岗位所需的能力和资源。秋季可能会迎来更好的机会窗口。' },
{ title: h.analysis, content: '天雷无妄卦由乾上震下组成。乾为天、为刚;震为雷、为动。天在上而雷在下,雷动于天之下,表示万物皆随自然规律而动。对于转岗之事,此卦暗示应当顺势而为,不可强求,但也不必过于保守。保持积极心态,等待合适时机即可。' },
{ title: h.focus, content: '重点关注:人际关系的维护、技能的持续提升、以及对市场环境的观察。这三方面将为未来的转岗创造有利条件。' },
].map((card, i) => (
<div key={i} className="bg-white rounded-xl p-[18px] border border-slate-200 flex flex-col gap-2.5">
<h4 className="text-slate-900 text-sm font-bold">{card.title}</h4>
<p className="text-slate-500 text-sm leading-relaxed">{card.content}</p>
</div>
))}
{/* Warning */}
<div className="bg-amber-50 rounded-xl p-3.5 flex items-start gap-2.5">
<span className="material-symbols-rounded text-amber-500 text-lg shrink-0 mt-0.5">warning</span>
<p className="text-amber-700 text-sm">{h.warning}</p>
</div>
</div>
{/* Right side column */}
<div className="w-full xl:w-[340px] flex flex-col gap-3.5 shrink-0">
{/* Follow-up CTA */}
<div className="bg-violet-600 rounded-2xl p-[18px] flex flex-col gap-3">
<h4 className="text-white text-base font-bold">{h.followUpTitle}</h4>
<p className="text-violet-200 text-sm">{h.followUpDesc}</p>
<a href={`/${locale}/history/1/followup`} className="w-full py-2.5 rounded-lg bg-white text-violet-600 text-sm font-semibold text-center hover:bg-violet-50 transition-colors">{h.followUpBtn}</a>
</div>
{/* Basic info */}
<div className="bg-white rounded-2xl p-[18px] border border-slate-200 flex flex-col gap-3">
<h4 className="text-slate-900 text-sm font-bold">{h.basicInfo}</h4>
<div className="flex justify-between text-sm"><span className="text-slate-400"></span><span className="text-slate-600"></span></div>
<div className="flex justify-between text-sm"><span className="text-slate-400"></span><span className="text-slate-600"></span></div>
<div className="flex justify-between text-sm"><span className="text-slate-400"></span><span className="text-slate-600">2025-05-08</span></div>
<div className="flex justify-between text-sm"><span className="text-slate-400"></span><span className="text-slate-600"></span></div>
</div>
{/* Ganzhi */}
<div className="bg-white rounded-2xl p-[18px] border border-slate-200 flex flex-col gap-3">
<h4 className="text-slate-900 text-sm font-bold">{h.ganzhi}</h4>
<div className="flex justify-between text-sm"><span className="text-slate-400"></span><span className="text-slate-600"></span></div>
<div className="flex justify-between text-sm"><span className="text-slate-400"></span><span className="text-slate-600"></span></div>
<div className="flex justify-between text-sm"><span className="text-slate-400"></span><span className="text-slate-600"></span></div>
</div>
{/* Hexagram detail */}
<div className="bg-white rounded-2xl p-[18px] border border-slate-200 flex flex-col gap-3 flex-1">
<h4 className="text-slate-900 text-sm font-bold">{h.hexagramDetail}</h4>
<div className="flex flex-col gap-2 items-center">
{[true, false, false, true, true, true].map((isYang, i) => isYang ? (
<div key={i} className="w-12 h-2 bg-violet-600 rounded" />
) : (
<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>
))}
</div>
<p className="text-slate-500 text-sm text-center mt-2"></p>
</div>
</div>
</div>
);
}
+7
View File
@@ -41,6 +41,7 @@ const PATHS: Record<string, string[]> = {
chevron_right: ['M9 18l6-6-6-6'],
chevron_left: ['M15 18l-6-6 6-6'],
chevron_down: ['M6 9l6 6 6-6'],
warning: ['M12 2L1 21h22L12 2Z', 'M12 9v4', 'M12 17h.01'],
menu: ['M4 6h16M4 12h16M4 18h16'],
close: ['M18 6L6 18M6 6l12 12'],
calendar_today: ['M7 3v4M17 3v4M4 9h16M5 5h14a1 1 0 0 1 1 1v14a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1V6a1 1 0 0 1 1-1Z'],
@@ -49,6 +50,12 @@ const PATHS: Record<string, string[]> = {
'M9.5 14.5c.6.8 1.5 1.2 2.7 1.2 1.5 0 2.4-.7 2.4-1.8 0-1.2-1-1.6-2.7-2.1-1.5-.4-2.7-.9-2.7-2.4 0-1.2 1-2.1 2.8-2.1 1.1 0 2 .3 2.6 1',
'M12 6v12',
],
arrow_back: ['M19 12H5M12 19l-7-7 7-7'],
content_copy: ['M7 9.66V5a2 2 0 0 1 2-2h7a2 2 0 0 1 2 2v7a2 2 0 0 1-2 2h-4.66'],
ios_share: ['M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9', 'M14 13l-4-4-4 4'],
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'],
};
export default function Icon({ name, className = 'w-5 h-5' }: IconProps) {
+34 -1
View File
@@ -1,6 +1,8 @@
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import Icon from './Icon';
import { getPointsBalance, getUserProfile, updateUserSettings, type PointsBalance } from '../lib/api';
import DivinationProcessingOverlay from './DivinationProcessingOverlay';
import { getPointsBalance, getUserProfile, updateUserSettings, type PointsBalance, type DivinationResultData } from '../lib/api';
import { useUserSettings } from './AppShell';
interface Props {
@@ -171,6 +173,7 @@ const copy = {
export default function ManualDivinationPage({ locale, divination: d }: Props) {
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(text.defaultQuestion);
const [selectedTime, setSelectedTime] = useState(() => formatDateTimeInput(new Date()));
@@ -179,6 +182,7 @@ export default function ManualDivinationPage({ locale, divination: d }: Props) {
const [guideStep, setGuideStep] = useState<number | null>(null);
const [points, setPoints] = useState<PointsBalance | null>(null);
const [editingIndex, setEditingIndex] = useState<number | null>(null);
const [showProcessing, setShowProcessing] = useState(false);
const { userProfile, setUserProfile } = useUserSettings();
// Refs for guide spotlight positioning
@@ -401,6 +405,19 @@ export default function ManualDivinationPage({ locale, divination: d }: Props) {
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)));
const handleSubmit = () => {
// 显示处理蒙版
setShowProcessing(true);
};
const handleComplete = (result: DivinationResultData | null) => {
setShowProcessing(false);
if (result) {
// Navigate to result page with state
navigate(`/${locale}/divination/result`, { state: { result } });
}
};
return (
<div ref={scrollContainerRef} className="relative flex min-h-full flex-col gap-[22px]">
<div className="flex items-center justify-between gap-5">
@@ -545,6 +562,7 @@ export default function ManualDivinationPage({ locale, divination: d }: Props) {
ref={submitBtnRef}
type="button"
disabled={progress < TOTAL_YAO_COUNT}
onClick={handleSubmit}
className={`h-[46px] w-full rounded-full text-sm font-bold transition-colors ${progress >= TOTAL_YAO_COUNT ? '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}
@@ -638,6 +656,21 @@ export default function ManualDivinationPage({ locale, divination: d }: Props) {
</div>
</>
)}
{/* 处理中蒙版 */}
{showProcessing && (
<DivinationProcessingOverlay
locale={locale}
params={{
method: 'manual',
questionType: category,
question: question,
divinationTime: new Date(selectedTime),
}}
yaoStates={yaoResults}
onComplete={handleComplete}
/>
)}
</div>
);
}