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:
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -35,6 +35,7 @@ export interface Translations {
|
||||
profile: { avatarTitle: string; avatarHint: string; uploadBtn: string; formTitle: string; emailLabel: string; displayNameLabel: string; displayNamePlaceholder: string; bioLabel: string; bioPlaceholder: string; saveBtn: string; cancelBtn: string };
|
||||
divination: { questionTitle: string; questionPlaceholder: string; categoryLabel: string; categories: string; timeTitle: string; timeHint: string; guideTitle: string; guideManual: 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 };
|
||||
history: { title: string; statTotal: string; statFollow: string; statLatest: string; filters: string; filterAll: string; filterCareer: string; filterLove: string; filterWealth: string; noResults: string; resultTitle: string; conclusion: string; suggestion: string; analysis: string; focus: string; warning: string; followUpTitle: string; followUpDesc: string; followUpBtn: string; chatTitle: string; chatPlaceholder: string; sendBtn: string; relatedActions: string; newDivination: string; viewHistory: string; followUpRules: string; followUpRule1: string; followUpRule2: string; basicInfo: string; ganzhi: string; hexagramDetail: string };
|
||||
result: { screenTitle: string; conclusion: string; suggestion: string; analysis: string; focusPoints: string; warning: string; basicInfo: string; divinationInfo: string; divinationTime: string; divinationMethod: string; questionType: string; question: string; autoMethod: string; manualMethod: string; hexagramDetail: string; hexagramDetailFailed: string; hexagramDetailRefused: string; copy: string; ganZhiInfo: string; wuXingWangShuai: string; ganZhiKongWang: string; termYueJian: string; termRiChen: string; termYuePo: string; termRiChong: string; pillarColumn: string; yearPillar: string; monthPillar: string; dayPillar: string; timePillar: string; ganZhiLabel: string; kongWangLabel: string; questionTypeCareer: string; questionTypeLove: string; questionTypeWealth: string; questionTypeFortune: string; questionTypeDream: string; questionTypeHealth: string; questionTypeStudy: string; questionTypeSearch: string; questionTypeOther: string; signTypeShangShang: string; signTypeZhongShang: string; signTypeZhongXia: string; signTypeXiaXia: string; yaoColSpirit: string; yaoColRelation: string; yaoColBranch: string; yaoColElement: string; yaoColChange: string; yaoColMark: string; followUpEntryHint: string; followUpEntryAction: string; followUpQuotaUsed: string; followUpViewHistory: string };
|
||||
general: { title: string; languageLabel: string; languageValue: string; privacyTitle: string; doNotSell: string; doNotSellHint: string; notificationTitle: string; allowNotification: string; saveSuccess: string; saveFailed: string };
|
||||
feedback: { title: string; typeLabel: string; typeBug: string; typeSuggestion: string; typeOther: string; contentLabel: string; contentPlaceholder: string; imagesLabel: string; anonymousLabel: string; anonymousHint: string; submitBtn: string; submitting: string; success: string; error: string; contentRequired: string; contentTooLong: string; tooManyImages: string; imageTooLarge: string };
|
||||
}
|
||||
@@ -57,7 +58,8 @@ const translations: Record<Locale, Translations> = {
|
||||
settings: { title: '设置', profileTitle: '个人资料', emailLabel: '邮箱', nameLabel: '昵称', joinedLabel: '注册时间', pointsTitle: '积分余额', pointsBalance: '积分', accountTitle: '账号设置', changeName: '修改昵称', changeAvatar: '修改头像', changeLanguage: '切换语言', legalTitle: '法律条款', privacy: '隐私政策', terms: '服务条款', logout: '退出登录', logoutConfirm: '确定要退出登录吗?' },
|
||||
profile: { avatarTitle: '头像', avatarHint: '支持 PNG / JPG / WEBP,建议上传清晰正方形头像。', uploadBtn: '上传头像', formTitle: '基础资料', emailLabel: '邮箱', displayNameLabel: '昵称', displayNamePlaceholder: '请输入昵称', bioLabel: '个人简介', bioPlaceholder: '请输入个人简介', saveBtn: '保存', cancelBtn: '取消' },
|
||||
divination: { questionTitle: '提出你的问题', questionPlaceholder: '请输入你想问的问题...', categoryLabel: '问题类型', categories: '事业,感情,财富,运势,解梦,健康,学业,寻物,其他', timeTitle: '起卦时间', timeHint: '默认使用当前时间,也可手动选择', guideTitle: '起卦指引', guideManual: '手动起卦需要您亲自抛掷三枚铜钱六次,系统会根据结果生成卦象。请按照以下步骤操作:\n\n1. 心中默念问题\n2. 点击下方铜钱选择正反面\n3. 每爻抛掷三枚铜钱\n4. 重复六次完成起卦', guideAuto: '自动起卦由系统随机生成卦象,适合快速获取结果。请按照以下步骤操作:\n\n1. 心中默念问题\n2. 点击"摇卦"按钮\n3. 系统自动生成卦象', yaoTitle: '六爻铜钱', coinLabel: '点击铜钱选择正反面', confirmBtn: '确认此爻', summaryTitle: '提交前检查', checkCategory: '问题类型:事业', checkMethod: '起卦方式:手动起卦', checkCost: '解卦消耗:20 积分', submitBtn: '确认提交', shakeTitle: '摇卦', shakeBtn: '摇一摇', hexPreview: '卦象预览', progressLabel: '完成进度' },
|
||||
history: { title: '历史解卦', statTotal: '总卦数', statFollow: '可追问', statLatest: '最近一卦', filters: '快速筛选', filterAll: '全部', filterCareer: '事业', filterLove: '感情', filterWealth: '财富', noResults: '暂无解卦记录', resultTitle: '卦象解读', conclusion: '结论', suggestion: '建议', analysis: '详细分析', focus: '重点关注', warning: '以上解读由 AI 生成,仅供参考娱乐,不作为决策依据。', followUpTitle: '追问', followUpDesc: '对卦象有疑问?可以追问一次获取更深入的解读。', followUpBtn: '开始追问', chatTitle: '追问对话', chatPlaceholder: '输入你的追问...', sendBtn: '发送', relatedActions: '相关操作', newDivination: '重新起卦', viewHistory: '查看历史', followUpRules: '追问规则', followUpRule1: '每次解卦可追问一次', followUpRule2: '追问消耗 10 积分', basicInfo: '基本信息', ganzhi: '干支信息', hexagramDetail: '卦象详情' },
|
||||
history: { title: '历史解卦', statTotal: '总卦数', statFollow: '可追问', statLatest: '最近一卦', filters: '快速筛选', filterAll: '全部', filterCareer: '事业', filterLove: '感情', filterWealth: '财富', noResults: '暂无解卦记录', resultTitle: '卦象解读', conclusion: '结论', suggestion: '建议', analysis: '详细分析', focus: '重点关注', warning: '以上解读由 AI 生成,仅供参考娱乐,不作为决策依据。', followUpTitle: '追问', followUpDesc: '对卦象有疑问?可以追问一次获取更深入的解读。', followUpBtn: '开始追问', chatTitle: '追问对话', chatPlaceholder: '输入你的追问...', sendBtn: '发送', relatedActions: '相关操作', newDivination: '重新起卦', viewHistory: '查看历史', followUpRules: '追问规则', followUpRule1: '每次解卦可追问一次', followUpRule2: '追问不消耗积分,完全免费', basicInfo: '基本信息', ganzhi: '干支信息', hexagramDetail: '卦象详情' },
|
||||
result: { screenTitle: '解卦结果', conclusion: '解卦结论', suggestion: '卦象建议', analysis: '具体解析', focusPoints: '断卦要点', warning: '卦象解读结果均由AI生成,仅供娱乐参考,切不可作为商业、医疗等专业领域的决策依据。理性看待卦象,自由掌握人生。', basicInfo: '基础信息', divinationInfo: '起卦信息', divinationTime: '起卦时间', divinationMethod: '起卦方式', questionType: '问题类型', question: '占卜问题', autoMethod: '自动起卦', manualMethod: '手动起卦', hexagramDetail: '卦象详情', hexagramDetailFailed: '解卦失败,卦象详情暂不可用', hexagramDetailRefused: '暂不支持解卦,请调整问题后重试', copy: '复制', ganZhiInfo: '干支信息', wuXingWangShuai: '五行旺衰', ganZhiKongWang: '空亡信息', termYueJian: '月建', termRiChen: '日辰', termYuePo: '月破', termRiChong: '日冲', pillarColumn: '四柱', yearPillar: '年柱', monthPillar: '月柱', dayPillar: '日柱', timePillar: '时柱', ganZhiLabel: '干支', kongWangLabel: '空亡', questionTypeCareer: '事业', questionTypeLove: '情感', questionTypeWealth: '财富', questionTypeFortune: '运势', questionTypeDream: '解梦', questionTypeHealth: '健康', questionTypeStudy: '学业', questionTypeSearch: '寻物', questionTypeOther: '其他', signTypeShangShang: '上上签', signTypeZhongShang: '中上签', signTypeZhongXia: '中下签', signTypeXiaXia: '下下签', yaoColSpirit: '六神', yaoColRelation: '六亲', yaoColBranch: '地支', yaoColElement: '五行', yaoColChange: '动', yaoColMark: '标', followUpEntryHint: '可针对本次解卦继续追问 1 次', followUpEntryAction: '追问', followUpQuotaUsed: '本次会话追问次数已用完', followUpViewHistory: '查看历史记录' },
|
||||
general: { title: '通用设置', languageLabel: '语言设置', languageValue: '界面语言', privacyTitle: '隐私设置', doNotSell: '个性化广告推荐', doNotSellHint: '关闭后,我们不会将您的个人信息用于广告推荐', notificationTitle: '通知设置', allowNotification: '允许接收通知', saveSuccess: '保存成功', saveFailed: '保存失败' },
|
||||
feedback: { title: '意见反馈', typeLabel: '反馈类型', typeBug: '问题反馈', typeSuggestion: '功能建议', typeOther: '其他', contentLabel: '反馈内容', contentPlaceholder: '请详细描述您的问题或建议...', imagesLabel: '添加截图(最多3张)', anonymousLabel: '不上传我的个人信息', anonymousHint: '勾选后将不采集您的用户ID,仅采集设备信息用于问题排查', submitBtn: '提交反馈', submitting: '提交中...', success: '感谢您的反馈,我们会尽快处理', error: '提交失败,请稍后重试', contentRequired: '请输入反馈内容', contentTooLong: '反馈内容不能超过500字', tooManyImages: '最多只能上传3张图片', imageTooLarge: '图片大小不能超过5MB' },
|
||||
},
|
||||
@@ -78,7 +80,8 @@ const translations: Record<Locale, Translations> = {
|
||||
settings: { title: '設置', profileTitle: '個人資料', emailLabel: '郵箱', nameLabel: '暱稱', joinedLabel: '註冊時間', pointsTitle: '積分餘額', pointsBalance: '積分', accountTitle: '賬號設置', changeName: '修改暱稱', changeAvatar: '修改頭像', changeLanguage: '切換語言', legalTitle: '法律條款', privacy: '隱私政策', terms: '服務條款', logout: '退出登錄', logoutConfirm: '確定要退出登錄嗎?' },
|
||||
profile: { avatarTitle: '頭像', avatarHint: '支持 PNG / JPG / WEBP,建議上傳清晰正方形頭像。', uploadBtn: '上傳頭像', formTitle: '基礎資料', emailLabel: '郵箱', displayNameLabel: '暱稱', displayNamePlaceholder: '請輸入暱稱', bioLabel: '個人簡介', bioPlaceholder: '請輸入個人簡介', saveBtn: '保存', cancelBtn: '取消' },
|
||||
divination: { questionTitle: '提出你的問題', questionPlaceholder: '請輸入你想問的問題...', categoryLabel: '問題類型', categories: '事業,感情,財富,運勢,解夢,健康,學業,尋物,其他', timeTitle: '起卦時間', timeHint: '默認使用當前時間,也可手動選擇', guideTitle: '起卦指引', guideManual: '手動起卦需要您親自拋擲三枚銅錢六次,系統會根據結果生成卦象。請按照以下步驟操作:\n\n1. 心中默念問題\n2. 點擊下方銅錢選擇正反面\n3. 每爻拋擲三枚銅錢\n4. 重複六次完成起卦', guideAuto: '自動起卦由系統隨機生成卦象,適合快速獲取結果。請按照以下步驟操作:\n\n1. 心中默念問題\n2. 點擊"搖卦"按鈕\n3. 系統自動生成卦象', yaoTitle: '六爻銅錢', coinLabel: '點擊銅錢選擇正反面', confirmBtn: '確認此爻', summaryTitle: '提交前檢查', checkCategory: '問題類型:事業', checkMethod: '起卦方式:手動起卦', checkCost: '解卦消耗:20 積分', submitBtn: '確認提交', shakeTitle: '搖卦', shakeBtn: '搖一搖', hexPreview: '卦象預覽', progressLabel: '完成進度' },
|
||||
history: { title: '歷史解卦', statTotal: '總卦數', statFollow: '可追問', statLatest: '最近一卦', filters: '快速篩選', filterAll: '全部', filterCareer: '事業', filterLove: '感情', filterWealth: '財富', noResults: '暫無解卦記錄', resultTitle: '卦象解讀', conclusion: '結論', suggestion: '建議', analysis: '詳細分析', focus: '重點關注', warning: '以上解讀由 AI 生成,僅供參考娛樂,不作為決策依據。', followUpTitle: '追問', followUpDesc: '對卦象有疑問?可以追問一次獲取更深入的解讀。', followUpBtn: '開始追問', chatTitle: '追問對話', chatPlaceholder: '輸入你的追問...', sendBtn: '發送', relatedActions: '相關操作', newDivination: '重新起卦', viewHistory: '查看歷史', followUpRules: '追問規則', followUpRule1: '每次解卦可追問一次', followUpRule2: '追問消耗 10 積分', basicInfo: '基本信息', ganzhi: '干支信息', hexagramDetail: '卦象詳情' },
|
||||
history: { title: '歷史解卦', statTotal: '總卦數', statFollow: '可追問', statLatest: '最近一卦', filters: '快速篩選', filterAll: '全部', filterCareer: '事業', filterLove: '感情', filterWealth: '財富', noResults: '暫無解卦記錄', resultTitle: '卦象解讀', conclusion: '結論', suggestion: '建議', analysis: '詳細分析', focus: '重點關注', warning: '以上解讀由 AI 生成,僅供參考娛樂,不作為決策依據。', followUpTitle: '追問', followUpDesc: '對卦象有疑問?可以追問一次獲取更深入的解讀。', followUpBtn: '開始追問', chatTitle: '追問對話', chatPlaceholder: '輸入你的追問...', sendBtn: '發送', relatedActions: '相關操作', newDivination: '重新起卦', viewHistory: '查看歷史', followUpRules: '追問規則', followUpRule1: '每次解卦可追問一次', followUpRule2: '追問不消耗積分,完全免費', basicInfo: '基本信息', ganzhi: '干支信息', hexagramDetail: '卦象詳情' },
|
||||
result: { screenTitle: '解卦結果', conclusion: '解卦結論', suggestion: '卦象建議', analysis: '具體解析', focusPoints: '斷卦要點', warning: '卦象解讀結果均由AI生成,僅供娛樂參考,切不可作為商業、醫療等專業領域的決策依據。理性看待卦象,自由掌握人生。', basicInfo: '基礎信息', divinationInfo: '起卦信息', divinationTime: '起卦時間', divinationMethod: '起卦方式', questionType: '問題類型', question: '占卜問題', autoMethod: '自動起卦', manualMethod: '手動起卦', hexagramDetail: '卦象詳情', hexagramDetailFailed: '解卦失敗,卦象詳情暫不可用', hexagramDetailRefused: '暫不支持解卦,請調整問題後重試', copy: '複製', ganZhiInfo: '干支信息', wuXingWangShuai: '五行旺衰', ganZhiKongWang: '空亡信息', termYueJian: '月建', termRiChen: '日辰', termYuePo: '月破', termRiChong: '日沖', pillarColumn: '四柱', yearPillar: '年柱', monthPillar: '月柱', dayPillar: '日柱', timePillar: '時柱', ganZhiLabel: '干支', kongWangLabel: '空亡', questionTypeCareer: '事業', questionTypeLove: '情感', questionTypeWealth: '財富', questionTypeFortune: '運勢', questionTypeDream: '解夢', questionTypeHealth: '健康', questionTypeStudy: '學業', questionTypeSearch: '尋物', questionTypeOther: '其他', signTypeShangShang: '上上簽', signTypeZhongShang: '中上簽', signTypeZhongXia: '中下簽', signTypeXiaXia: '下下簽', yaoColSpirit: '六神', yaoColRelation: '六親', yaoColBranch: '地支', yaoColElement: '五行', yaoColChange: '動', yaoColMark: '標', followUpEntryHint: '可針對本次解卦繼續追問 1 次', followUpEntryAction: '追問', followUpQuotaUsed: '本次會話追問次數已用完', followUpViewHistory: '查看歷史記錄' },
|
||||
general: { title: '通用設定', languageLabel: '語言設置', languageValue: '介面語言', privacyTitle: '隱私設置', doNotSell: '個人化廣告推薦', doNotSellHint: '關閉後,我們不會將您的個人資訊用於廣告推薦', notificationTitle: '通知設置', allowNotification: '允許接收通知', saveSuccess: '保存成功', saveFailed: '保存失敗' },
|
||||
feedback: { title: '意見回饋', typeLabel: '回饋類型', typeBug: '問題回饋', typeSuggestion: '功能建議', typeOther: '其他', contentLabel: '回饋內容', contentPlaceholder: '請詳細描述您的問題或建議...', imagesLabel: '添加截圖(最多3張)', anonymousLabel: '不上傳我的個人信息', anonymousHint: '勾選後將不採集您的用戶ID,僅採集設備信息用於問題排查', submitBtn: '提交回饋', submitting: '提交中...', success: '感謝您的回饋,我們會盡快處理', error: '提交失敗,請稍後重試', contentRequired: '請輸入回饋內容', contentTooLong: '回饋內容不能超過500字', tooManyImages: '最多只能上傳3張圖片', imageTooLarge: '圖片大小不能超過5MB' },
|
||||
},
|
||||
@@ -99,7 +102,8 @@ const translations: Record<Locale, Translations> = {
|
||||
settings: { title: 'Settings', profileTitle: 'Profile', emailLabel: 'Email', nameLabel: 'Name', joinedLabel: 'Joined', pointsTitle: 'Credits Balance', pointsBalance: 'credits', accountTitle: 'Account Settings', changeName: 'Change Name', changeAvatar: 'Change Avatar', changeLanguage: 'Change Language', legalTitle: 'Legal', privacy: 'Privacy Policy', terms: 'Terms of Service', logout: 'Sign Out', logoutConfirm: 'Are you sure you want to sign out?' },
|
||||
profile: { avatarTitle: 'Avatar', avatarHint: 'Supports PNG / JPG / WEBP. Square images recommended.', uploadBtn: 'Upload Avatar', formTitle: 'Basic Info', emailLabel: 'Email', displayNameLabel: 'Display Name', displayNamePlaceholder: 'Enter display name', bioLabel: 'Bio', bioPlaceholder: 'Enter your bio', saveBtn: 'Save', cancelBtn: 'Cancel' },
|
||||
divination: { questionTitle: 'Ask Your Question', questionPlaceholder: 'Enter your question...', categoryLabel: 'Category', categories: 'Career,Love,Wealth,Fortune,Dreams,Health,Study,Lost Items,Other', timeTitle: 'Casting Time', timeHint: 'Uses current time by default, or pick manually', guideTitle: 'Guide', guideManual: 'Manual casting requires you to toss three coins six times. Follow these steps:\n\n1. Focus on your question\n2. Click coins below to set inscription/pattern\n3. Toss three coins per line\n4. Repeat six times to complete', guideAuto: 'Auto casting generates a hexagram randomly. Follow these steps:\n\n1. Focus on your question\n2. Click "Shake" button\n3. System generates the hexagram', yaoTitle: 'Six Lines', coinLabel: 'Click coins to set inscription/pattern', confirmBtn: 'Confirm Line', summaryTitle: 'Review Before Submit', checkCategory: 'Category: Career', checkMethod: 'Method: Manual Cast', checkCost: 'Cost: 20 credits', submitBtn: 'Confirm & Submit', shakeTitle: 'Shake', shakeBtn: 'Shake', hexPreview: 'Hexagram Preview', progressLabel: 'Progress' },
|
||||
history: { title: 'Reading History', statTotal: 'Total', statFollow: 'Follow-up', statLatest: 'Latest', filters: 'Quick Filters', filterAll: 'All', filterCareer: 'Career', filterLove: 'Love', filterWealth: 'Wealth', noResults: 'No readings yet', resultTitle: 'Reading Result', conclusion: 'Conclusion', suggestion: 'Suggestion', analysis: 'Detailed Analysis', focus: 'Key Focus', warning: 'AI-generated interpretation for entertainment only. Not professional advice.', followUpTitle: 'Follow-up', followUpDesc: 'Have questions about the reading? Ask one follow-up for deeper insights.', followUpBtn: 'Start Follow-up', chatTitle: 'Follow-up Chat', chatPlaceholder: 'Enter your follow-up question...', sendBtn: 'Send', relatedActions: 'Related Actions', newDivination: 'New Reading', viewHistory: 'View History', followUpRules: 'Follow-up Rules', followUpRule1: 'One follow-up per reading', followUpRule2: 'Follow-up costs 10 credits', basicInfo: 'Basic Info', ganzhi: 'Stem-Branch Info', hexagramDetail: 'Hexagram Details' },
|
||||
history: { title: 'Reading History', statTotal: 'Total', statFollow: 'Follow-up', statLatest: 'Latest', filters: 'Quick Filters', filterAll: 'All', filterCareer: 'Career', filterLove: 'Love', filterWealth: 'Wealth', noResults: 'No readings yet', resultTitle: 'Reading Result', conclusion: 'Conclusion', suggestion: 'Suggestion', analysis: 'Detailed Analysis', focus: 'Key Focus', warning: 'AI-generated interpretation for entertainment only. Not professional advice.', followUpTitle: 'Follow-up', followUpDesc: 'Have questions about the reading? Ask one follow-up for deeper insights.', followUpBtn: 'Start Follow-up', chatTitle: 'Follow-up Chat', chatPlaceholder: 'Enter your follow-up question...', sendBtn: 'Send', relatedActions: 'Related Actions', newDivination: 'New Reading', viewHistory: 'View History', followUpRules: 'Follow-up Rules', followUpRule1: 'One follow-up per reading', followUpRule2: 'Follow-up is completely free', basicInfo: 'Basic Info', ganzhi: 'Stem-Branch Info', hexagramDetail: 'Hexagram Details' },
|
||||
result: { screenTitle: 'Reading Result', conclusion: 'Conclusion', suggestion: 'Suggestion', analysis: 'Detailed Analysis', focusPoints: 'Key Points', warning: 'AI-generated interpretation for entertainment only. Not professional advice.', basicInfo: 'Basic Info', divinationInfo: 'Casting Info', divinationTime: 'Casting Time', divinationMethod: 'Method', questionType: 'Category', question: 'Question', autoMethod: 'Auto Cast', manualMethod: 'Manual Cast', hexagramDetail: 'Hexagram Details', hexagramDetailFailed: 'Reading failed, details unavailable', hexagramDetailRefused: 'Reading not supported, please adjust your question', copy: 'Copy', ganZhiInfo: 'Stem-Branch Info', wuXingWangShuai: 'Five Elements Status', ganZhiKongWang: 'Void Branches', termYueJian: 'Month Command', termRiChen: 'Day Pillar', termYuePo: 'Month Break', termRiChong: 'Day Clash', pillarColumn: 'Pillar', yearPillar: 'Year', monthPillar: 'Month', dayPillar: 'Day', timePillar: 'Hour', ganZhiLabel: 'Stem-Branch', kongWangLabel: 'Void', questionTypeCareer: 'Career', questionTypeLove: 'Love', questionTypeWealth: 'Wealth', questionTypeFortune: 'Fortune', questionTypeDream: 'Dreams', questionTypeHealth: 'Health', questionTypeStudy: 'Study', questionTypeSearch: 'Lost Items', questionTypeOther: 'Other', signTypeShangShang: 'Best', signTypeZhongShang: 'Good', signTypeZhongXia: 'Fair', signTypeXiaXia: 'Poor', yaoColSpirit: 'Spirit', yaoColRelation: 'Relation', yaoColBranch: 'Branch', yaoColElement: 'Element', yaoColChange: 'Change', yaoColMark: 'Mark', followUpEntryHint: 'You can ask one follow-up question', followUpEntryAction: 'Follow-up', followUpQuotaUsed: 'Follow-up quota used for this session', followUpViewHistory: 'View History' },
|
||||
general: { title: 'General Settings', languageLabel: 'Language', languageValue: 'Interface Language', privacyTitle: 'Privacy', doNotSell: 'Personalized Ads', doNotSellHint: 'When off, your personal info won\'t be used for ad recommendations', notificationTitle: 'Notifications', allowNotification: 'Allow notifications', saveSuccess: 'Saved successfully', saveFailed: 'Failed to save' },
|
||||
feedback: { title: 'Feedback', typeLabel: 'Feedback Type', typeBug: 'Bug', typeSuggestion: 'Suggestion', typeOther: 'Other', contentLabel: 'Content', contentPlaceholder: 'Please describe your issue or suggestion in detail...', imagesLabel: 'Add Screenshots (max 3)', anonymousLabel: 'Do not upload my personal information', anonymousHint: 'If checked, your user ID will not be collected. Device info will still be collected for troubleshooting.', submitBtn: 'Submit Feedback', submitting: 'Submitting...', success: 'Thank you for your feedback. We will process it soon.', error: 'Failed to submit. Please try again.', contentRequired: 'Please enter feedback content', contentTooLong: 'Feedback content cannot exceed 500 characters', tooManyImages: 'Maximum 3 images allowed', imageTooLarge: 'Image size cannot exceed 5MB' },
|
||||
},
|
||||
|
||||
@@ -24,6 +24,9 @@ export const API_ROUTES = {
|
||||
},
|
||||
agent: {
|
||||
history: '/api/v1/agent/history',
|
||||
historyByThread: (threadId: string) => `/api/v1/agent/history?threadId=${threadId}`,
|
||||
runs: '/api/v1/agent/runs',
|
||||
runEvents: (threadId: string) => `/api/v1/agent/runs/${threadId}/events`,
|
||||
},
|
||||
feedback: {
|
||||
submit: '/api/v1/feedback',
|
||||
|
||||
+404
-14
@@ -241,6 +241,8 @@ export interface HistoryAgentOutput {
|
||||
keywords?: string[];
|
||||
answer?: string | null;
|
||||
divination_derived?: {
|
||||
question?: string;
|
||||
questionType?: string;
|
||||
guaName?: string;
|
||||
gua_name?: string;
|
||||
binaryCode?: string;
|
||||
@@ -266,7 +268,6 @@ export interface HistoryItem {
|
||||
hexagram_name: string;
|
||||
rating: string;
|
||||
created_at: string;
|
||||
can_follow_up: boolean;
|
||||
}
|
||||
|
||||
export interface HistorySnapshot {
|
||||
@@ -281,19 +282,408 @@ export async function getAgentHistory(): Promise<HistorySnapshot> {
|
||||
return authFetch<HistorySnapshot>(API_ROUTES.agent.history);
|
||||
}
|
||||
|
||||
export async function getAgentHistoryByThread(threadId: string): Promise<HistorySnapshot> {
|
||||
return authFetch<HistorySnapshot>(API_ROUTES.agent.historyByThread(threadId));
|
||||
}
|
||||
|
||||
// 问题类型中文到英文的映射
|
||||
const QUESTION_TYPE_MAP: Record<string, string> = {
|
||||
'事业': 'career',
|
||||
'情感': 'love',
|
||||
'感情': 'love',
|
||||
'财富': 'wealth',
|
||||
'运势': 'fortune',
|
||||
'解梦': 'dream',
|
||||
'健康': 'health',
|
||||
'学业': 'study',
|
||||
'寻物': 'search',
|
||||
'其他': 'other',
|
||||
};
|
||||
|
||||
export function mapHistoryMessagesToItems(messages: HistoryMessage[]): HistoryItem[] {
|
||||
return messages.map((message) => {
|
||||
const output = message.agent_output;
|
||||
const derived = output?.divination_derived;
|
||||
return {
|
||||
id: message.id,
|
||||
threadId: message.threadId,
|
||||
question: output?.answer || message.content,
|
||||
category: output?.keywords?.[0] || '',
|
||||
hexagram_name: derived?.guaName || derived?.gua_name || '',
|
||||
rating: output?.sign_level || '',
|
||||
created_at: message.timestamp,
|
||||
can_follow_up: output?.status === 'success',
|
||||
};
|
||||
return messages
|
||||
.filter((m) => m.role === 'assistant' && m.agent_output)
|
||||
.map((message) => {
|
||||
const output = message.agent_output;
|
||||
const derived = output?.divination_derived;
|
||||
|
||||
const question = derived?.question || message.content;
|
||||
const questionTypeRaw = derived?.questionType || '';
|
||||
const category = QUESTION_TYPE_MAP[questionTypeRaw] || questionTypeRaw.toLowerCase();
|
||||
const hexagramName = derived?.guaName || derived?.gua_name || '';
|
||||
const rating = output?.sign_level || '';
|
||||
|
||||
return {
|
||||
id: message.id,
|
||||
threadId: message.threadId,
|
||||
question,
|
||||
category,
|
||||
hexagram_name: hexagramName,
|
||||
rating,
|
||||
created_at: message.timestamp,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export function parseChineseDate(dateStr: string): Date {
|
||||
const match = dateStr.match(/(\d{4})年(\d{2})月(\d{2})日\s+(\d{2}):(\d{2})/);
|
||||
if (match) {
|
||||
const [, year, month, day, hour, minute] = match;
|
||||
return new Date(`${year}-${month}-${day}T${hour}:${minute}:00`);
|
||||
}
|
||||
try {
|
||||
const d = new Date(dateStr);
|
||||
if (!isNaN(d.getTime())) return d;
|
||||
} catch { /* ignore */ }
|
||||
return new Date();
|
||||
}
|
||||
|
||||
export function historyMessageToResultData(message: HistoryMessage): DivinationResultData | null {
|
||||
const output = message.agent_output;
|
||||
const derived = output?.divination_derived;
|
||||
if (!output || !derived) return null;
|
||||
|
||||
const yaoLines: DivinationResultData['yaoLines'] = [];
|
||||
const yaoInfoList = (derived as Record<string, unknown>).yaoInfoList;
|
||||
if (Array.isArray(yaoInfoList)) {
|
||||
yaoInfoList.forEach((item: Record<string, unknown>, idx: number) => {
|
||||
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) || '',
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
const ganzhiRaw = (derived as Record<string, unknown>).ganzhi as Record<string, string> || {};
|
||||
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 || '',
|
||||
};
|
||||
|
||||
const divinationTimeStr = (derived as Record<string, unknown>).divinationTime as string || '';
|
||||
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 || '',
|
||||
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 || '',
|
||||
signType: output.sign_level || '',
|
||||
keywords: (output.keywords || []).join(' · '),
|
||||
focusPoints: output.focus_points || [],
|
||||
conclusion: (output.conclusion || []).join('\n'),
|
||||
analysis: output.answer || '',
|
||||
suggestion: (output.advice || []).join('\n'),
|
||||
ganzhi,
|
||||
wuXingStatus: (derived as Record<string, unknown>).wuXingStatuses as Record<string, string> || {},
|
||||
yaoLines,
|
||||
targetYaoLines: [],
|
||||
status: (output.status as 'success' | 'failed' | 'refused') || 'success',
|
||||
};
|
||||
}
|
||||
|
||||
// --- Divination Run ---
|
||||
|
||||
export type YaoType = 'youngYang' | 'youngYin' | 'oldYang' | 'oldYin';
|
||||
|
||||
export interface DivinationParams {
|
||||
method: 'manual' | 'auto';
|
||||
questionType: string;
|
||||
question: string;
|
||||
divinationTime: Date;
|
||||
}
|
||||
|
||||
export interface RunAcceptedData {
|
||||
threadId: string;
|
||||
runId: string;
|
||||
}
|
||||
|
||||
interface GanzhiData {
|
||||
yearGanZhi: string;
|
||||
monthGanZhi: string;
|
||||
dayGanZhi: string;
|
||||
timeGanZhi: string;
|
||||
yearKongWang: string;
|
||||
monthKongWang: string;
|
||||
dayKongWang: string;
|
||||
timeKongWang: string;
|
||||
yueJian: string;
|
||||
riChen: string;
|
||||
yuePo: string;
|
||||
riChong: string;
|
||||
}
|
||||
|
||||
interface YaoLineData {
|
||||
index: number;
|
||||
spirit: string;
|
||||
relation: string;
|
||||
branch: string;
|
||||
element: string;
|
||||
type: YaoType;
|
||||
mark: string;
|
||||
}
|
||||
|
||||
export interface DivinationResultData {
|
||||
threadId?: string;
|
||||
params: DivinationParams;
|
||||
binaryCode: string;
|
||||
changedBinaryCode: string;
|
||||
guaName: string;
|
||||
targetGuaName: string;
|
||||
upperName: string;
|
||||
lowerName: string;
|
||||
signType: string;
|
||||
keywords: string;
|
||||
focusPoints: string[];
|
||||
conclusion: string;
|
||||
analysis: string;
|
||||
suggestion: string;
|
||||
ganzhi: GanzhiData;
|
||||
wuXingStatus: Record<string, string>;
|
||||
yaoLines: YaoLineData[];
|
||||
targetYaoLines: YaoLineData[];
|
||||
status: 'success' | 'failed' | 'refused';
|
||||
}
|
||||
|
||||
export type DivinationEventType =
|
||||
| 'DIVINATION_DERIVED'
|
||||
| 'TEXT_MESSAGE_END'
|
||||
| 'RUN_ERROR'
|
||||
| 'RUN_FINISHED';
|
||||
|
||||
export interface DivinationEvent {
|
||||
type: DivinationEventType;
|
||||
data: Record<string, unknown>;
|
||||
}
|
||||
|
||||
function yaoTypeToText(type: YaoType): string {
|
||||
return type === 'youngYang' ? '少阳'
|
||||
: type === 'youngYin' ? '少阴'
|
||||
: type === 'oldYang' ? '老阳'
|
||||
: '老阴';
|
||||
}
|
||||
|
||||
function questionTypeToText(type: string): string {
|
||||
const map: Record<string, string> = {
|
||||
career: '事业',
|
||||
love: '情感',
|
||||
wealth: '财富',
|
||||
fortune: '运势',
|
||||
dream: '解梦',
|
||||
health: '健康',
|
||||
study: '学业',
|
||||
search: '寻物',
|
||||
other: '其他',
|
||||
};
|
||||
return map[type] || type;
|
||||
}
|
||||
|
||||
function toRfc3339Utc(date: Date): string {
|
||||
return date.toISOString();
|
||||
}
|
||||
|
||||
export async function enqueueDivinationRun(
|
||||
params: DivinationParams,
|
||||
yaoStates: YaoType[]
|
||||
): Promise<RunAcceptedData> {
|
||||
const threadId = crypto.randomUUID();
|
||||
const runId = crypto.randomUUID();
|
||||
|
||||
const payload = {
|
||||
threadId,
|
||||
runId,
|
||||
state: {},
|
||||
messages: [
|
||||
{ id: `msg_${runId}_user_0`, role: 'user', content: params.question },
|
||||
],
|
||||
tools: [],
|
||||
context: [],
|
||||
forwardedProps: {
|
||||
runtime_mode: 'chat',
|
||||
client_time: {
|
||||
device_timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
|
||||
client_now_iso: toRfc3339Utc(new Date()),
|
||||
client_epoch_ms: Date.now(),
|
||||
},
|
||||
divinationPayload: {
|
||||
divinationMethod: params.method === 'manual' ? '手动起卦' : '自动起卦',
|
||||
questionType: questionTypeToText(params.questionType),
|
||||
question: params.question,
|
||||
divinationTimeIso: toRfc3339Utc(params.divinationTime),
|
||||
yaoLines: yaoStates.map(yaoTypeToText),
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
return authFetch<RunAcceptedData>(API_ROUTES.agent.runs, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
|
||||
export async function enqueueFollowUpRun(
|
||||
threadId: string,
|
||||
question: string,
|
||||
result: DivinationResultData
|
||||
): Promise<RunAcceptedData> {
|
||||
const runId = `run_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
|
||||
const yaoStates = result.yaoLines.map((line) => line.type);
|
||||
const divinationTime = result.params.divinationTime instanceof Date
|
||||
? result.params.divinationTime
|
||||
: new Date(result.params.divinationTime);
|
||||
|
||||
const payload = {
|
||||
threadId,
|
||||
runId,
|
||||
state: {},
|
||||
messages: [
|
||||
{ id: `msg_${runId}_user_0`, role: 'user', content: question },
|
||||
],
|
||||
tools: [],
|
||||
context: [],
|
||||
forwardedProps: {
|
||||
runtime_mode: 'follow_up',
|
||||
client_time: {
|
||||
device_timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
|
||||
client_now_iso: toRfc3339Utc(new Date()),
|
||||
client_epoch_ms: Date.now(),
|
||||
},
|
||||
divinationPayload: {
|
||||
divinationMethod: result.params.method === 'manual' ? '手动起卦' : '自动起卦',
|
||||
questionType: questionTypeToText(result.params.questionType),
|
||||
question: result.params.question,
|
||||
divinationTimeIso: toRfc3339Utc(divinationTime),
|
||||
yaoLines: yaoStates.map(yaoTypeToText),
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
return authFetch<RunAcceptedData>(API_ROUTES.agent.runs, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
|
||||
export async function* streamDivinationEvents(
|
||||
threadId: string,
|
||||
runId: string
|
||||
): AsyncGenerator<DivinationEvent> {
|
||||
const { authFetchRaw } = await import('./auth');
|
||||
|
||||
const response = await authFetchRaw(
|
||||
`${API_ROUTES.agent.runEvents(threadId)}?runId=${runId}`,
|
||||
{
|
||||
headers: {
|
||||
Accept: 'text/event-stream',
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to connect to event stream: ${response.status}`);
|
||||
}
|
||||
|
||||
yield* readSseStream(response);
|
||||
}
|
||||
|
||||
async function* readSseStream(response: Response): AsyncGenerator<DivinationEvent> {
|
||||
const reader = response.body?.getReader();
|
||||
if (!reader) {
|
||||
throw new Error('No response body');
|
||||
}
|
||||
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = '';
|
||||
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
|
||||
// Parse SSE frames (separated by \n\n)
|
||||
while (true) {
|
||||
const splitAt = buffer.indexOf('\n\n');
|
||||
if (splitAt < 0) break;
|
||||
|
||||
const frame = buffer.substring(0, splitAt);
|
||||
buffer = buffer.substring(splitAt + 2);
|
||||
|
||||
const event = parseSseFrame(frame);
|
||||
if (event) {
|
||||
yield event;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Process any remaining buffer
|
||||
if (buffer.trim()) {
|
||||
const event = parseSseFrame(buffer);
|
||||
if (event) {
|
||||
yield event;
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
reader.releaseLock();
|
||||
}
|
||||
}
|
||||
|
||||
function parseSseFrame(frame: string): DivinationEvent | null {
|
||||
if (frame.startsWith(':')) return null;
|
||||
|
||||
const lines = frame.split('\n');
|
||||
let eventType = '';
|
||||
const dataLines: string[] = [];
|
||||
|
||||
for (const raw of lines) {
|
||||
const line = raw.trimEnd();
|
||||
if (line.startsWith('event:')) {
|
||||
eventType = line.substring(6).trim();
|
||||
} else if (line.startsWith('data:')) {
|
||||
dataLines.push(line.substring(5).trimStart());
|
||||
}
|
||||
}
|
||||
|
||||
if (dataLines.length === 0) return null;
|
||||
|
||||
const dataText = dataLines.join('\n');
|
||||
if (!dataText.trim()) return null;
|
||||
|
||||
let data: Record<string, unknown>;
|
||||
try {
|
||||
data = JSON.parse(dataText);
|
||||
} catch {
|
||||
data = { raw: dataText };
|
||||
}
|
||||
|
||||
// Use event type from SSE event line, fallback to data.type
|
||||
const typeFromData = data.type as string | undefined;
|
||||
const type: DivinationEventType = (eventType || typeFromData || 'UNKNOWN') as DivinationEventType;
|
||||
|
||||
return { type, data };
|
||||
}
|
||||
|
||||
@@ -237,3 +237,39 @@ export async function authFetch<T>(path: string, options?: RequestInit): Promise
|
||||
if (res.status === 204) return undefined as T;
|
||||
return res.json() as Promise<T>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Like authFetch but returns raw Response for streaming (SSE, etc.)
|
||||
* Does NOT throw on non-OK responses - caller must handle response.status
|
||||
*/
|
||||
export async function authFetchRaw(path: string, options?: RequestInit): Promise<Response> {
|
||||
if (isTokenExpired()) {
|
||||
await refreshAccessToken();
|
||||
}
|
||||
|
||||
const auth = getAuth();
|
||||
if (!auth) {
|
||||
redirectToLogin();
|
||||
throw new Error('Not authenticated');
|
||||
}
|
||||
|
||||
const headers = jsonHeaders(options);
|
||||
headers.set('Authorization', `Bearer ${auth.access_token}`);
|
||||
|
||||
const url = apiUrl(path);
|
||||
let res = await fetch(url, { ...options, headers });
|
||||
|
||||
if (res.status === 401) {
|
||||
await refreshAccessToken();
|
||||
const refreshed = getAuth();
|
||||
if (!refreshed) {
|
||||
redirectToLogin();
|
||||
throw new Error('Not authenticated');
|
||||
}
|
||||
const retryHeaders = jsonHeaders(options);
|
||||
retryHeaders.set('Authorization', `Bearer ${refreshed.access_token}`);
|
||||
res = await fetch(url, { ...options, headers: retryHeaders });
|
||||
}
|
||||
|
||||
return res;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
---
|
||||
import DashboardAppPage from '../../../components/DashboardAppPage.astro';
|
||||
|
||||
const locale = 'en' as const;
|
||||
---
|
||||
|
||||
<DashboardAppPage locale={locale} />
|
||||
@@ -0,0 +1,10 @@
|
||||
---
|
||||
export const prerender = false;
|
||||
|
||||
import DashboardAppPage from '../../../components/DashboardAppPage.astro';
|
||||
|
||||
const locale = 'en' as const;
|
||||
const { id } = Astro.params;
|
||||
---
|
||||
|
||||
<DashboardAppPage locale={locale} />
|
||||
@@ -0,0 +1,10 @@
|
||||
---
|
||||
export const prerender = false;
|
||||
|
||||
import DashboardAppPage from '../../../../components/DashboardAppPage.astro';
|
||||
|
||||
const locale = 'en' as const;
|
||||
const { id } = Astro.params;
|
||||
---
|
||||
|
||||
<DashboardAppPage locale={locale} />
|
||||
@@ -0,0 +1,7 @@
|
||||
---
|
||||
import DashboardAppPage from '../../../components/DashboardAppPage.astro';
|
||||
|
||||
const locale = 'zh' as const;
|
||||
---
|
||||
|
||||
<DashboardAppPage locale={locale} />
|
||||
@@ -0,0 +1,10 @@
|
||||
---
|
||||
export const prerender = false;
|
||||
|
||||
import DashboardAppPage from '../../../components/DashboardAppPage.astro';
|
||||
|
||||
const locale = 'zh' as const;
|
||||
const { id } = Astro.params;
|
||||
---
|
||||
|
||||
<DashboardAppPage locale={locale} />
|
||||
@@ -0,0 +1,10 @@
|
||||
---
|
||||
export const prerender = false;
|
||||
|
||||
import DashboardAppPage from '../../../../components/DashboardAppPage.astro';
|
||||
|
||||
const locale = 'zh' as const;
|
||||
const { id } = Astro.params;
|
||||
---
|
||||
|
||||
<DashboardAppPage locale={locale} />
|
||||
@@ -0,0 +1,7 @@
|
||||
---
|
||||
import DashboardAppPage from '../../../components/DashboardAppPage.astro';
|
||||
|
||||
const locale = 'zh_Hant' as const;
|
||||
---
|
||||
|
||||
<DashboardAppPage locale={locale} />
|
||||
@@ -0,0 +1,10 @@
|
||||
---
|
||||
export const prerender = false;
|
||||
|
||||
import DashboardAppPage from '../../../components/DashboardAppPage.astro';
|
||||
|
||||
const locale = 'zh_Hant' as const;
|
||||
const { id } = Astro.params;
|
||||
---
|
||||
|
||||
<DashboardAppPage locale={locale} />
|
||||
@@ -0,0 +1,10 @@
|
||||
---
|
||||
export const prerender = false;
|
||||
|
||||
import DashboardAppPage from '../../../../components/DashboardAppPage.astro';
|
||||
|
||||
const locale = 'zh_Hant' as const;
|
||||
const { id } = Astro.params;
|
||||
---
|
||||
|
||||
<DashboardAppPage locale={locale} />
|
||||
Reference in New Issue
Block a user