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

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