Files
eryao/web/src/components/HistoryListPage.tsx
T
ZL-Q efe48f2068 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 页面
2026-05-10 13:59:04 +08:00

298 lines
12 KiB
TypeScript

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; statLatest: string; filters: string; filterAll: string; filterCareer: string; filterLove: string; filterWealth: string; noResults: string };
}
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> = {
'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',
};
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">
{/* 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>
{/* 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>
{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>
);
}