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:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user