4b35736f73
P0 (5): crypto/rand session ID, TTS fallback可达性, goroutine defer recover, adminAuth前缀修正 P1 (5): 普通用户密码验证, context传递, priority clamp, 超时重试, 自主思考速率限制 P2 (4): Briefing AI降级, 前端消息类型渲染, Docker Compose补全, PWA 192图标 P3 (5): goroutine错误处理, .gitignore完善, reminder created_at, voice Dockerfile, Go版本更新
323 lines
11 KiB
TypeScript
323 lines
11 KiB
TypeScript
import { useState, useEffect } from 'react';
|
|
import { getBriefing, getLatestBriefings, generateBriefing, formatBriefingDate, type Briefing } from '@/api/briefings';
|
|
|
|
interface BriefingPanelProps {
|
|
userId: string;
|
|
onClose: () => void;
|
|
}
|
|
|
|
/** 天气 emoji 映射 */
|
|
function weatherIcon(icon: string): string {
|
|
return icon || '🌤️';
|
|
}
|
|
|
|
/** 格式化提醒时间 */
|
|
function formatRemindTime(ts: string): string {
|
|
try {
|
|
const d = new Date(ts);
|
|
return d.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' });
|
|
} catch {
|
|
return ts;
|
|
}
|
|
}
|
|
|
|
/** 获取今天的日期字符串 */
|
|
function todayStr(): string {
|
|
const d = new Date();
|
|
return d.toISOString().slice(0, 10);
|
|
}
|
|
|
|
export function BriefingPanel({ userId, onClose }: BriefingPanelProps) {
|
|
const [briefing, setBriefing] = useState<Briefing | null>(null);
|
|
const [history, setHistory] = useState<Briefing[]>([]);
|
|
const [loading, setLoading] = useState(true);
|
|
const [generating, setGenerating] = useState(false);
|
|
const [error, setError] = useState('');
|
|
const [selectedDate, setSelectedDate] = useState(todayStr());
|
|
const [viewMode, setViewMode] = useState<'today' | 'history'>('today');
|
|
|
|
// 加载今日简报
|
|
useEffect(() => {
|
|
loadBriefing(todayStr());
|
|
loadHistory();
|
|
}, [userId]);
|
|
|
|
const loadBriefing = async (date: string) => {
|
|
setLoading(true);
|
|
setError('');
|
|
try {
|
|
const resp = await getBriefing(userId, date);
|
|
if (resp.error) {
|
|
setError(resp.error);
|
|
setBriefing(null);
|
|
} else if (resp.data?.briefing) {
|
|
setBriefing(resp.data.briefing);
|
|
} else {
|
|
setBriefing(null);
|
|
}
|
|
} catch {
|
|
setError('获取简报失败');
|
|
}
|
|
setLoading(false);
|
|
};
|
|
|
|
const loadHistory = async () => {
|
|
try {
|
|
const resp = await getLatestBriefings(userId, 7);
|
|
if (resp.data?.briefings) {
|
|
setHistory(resp.data.briefings);
|
|
}
|
|
} catch {
|
|
// 静默失败
|
|
}
|
|
};
|
|
|
|
const handleGenerate = async () => {
|
|
setGenerating(true);
|
|
setError('');
|
|
try {
|
|
const resp = await generateBriefing(userId);
|
|
if (resp.error) {
|
|
setError(resp.error);
|
|
} else if (resp.data?.success && resp.data.briefing) {
|
|
setBriefing(resp.data.briefing);
|
|
// 刷新历史
|
|
loadHistory();
|
|
} else {
|
|
setError(resp.data?.error || '生成简报失败');
|
|
}
|
|
} catch {
|
|
setError('生成简报请求失败');
|
|
}
|
|
setGenerating(false);
|
|
};
|
|
|
|
const handleDateChange = (date: string) => {
|
|
setSelectedDate(date);
|
|
loadBriefing(date);
|
|
};
|
|
|
|
return (
|
|
<div className="flex flex-col h-full max-h-full">
|
|
{/* 标签页切换 */}
|
|
<div className="flex border-b border-gray-100 dark:border-gray-700 shrink-0">
|
|
<button
|
|
onClick={() => setViewMode('today')}
|
|
className={`flex-1 py-2 text-xs font-medium transition-colors ${
|
|
viewMode === 'today'
|
|
? 'text-pink-500 border-b-2 border-pink-500'
|
|
: 'text-gray-400 hover:text-gray-600 dark:hover:text-gray-300'
|
|
}`}
|
|
>
|
|
📋 今日简报
|
|
</button>
|
|
<button
|
|
onClick={() => setViewMode('history')}
|
|
className={`flex-1 py-2 text-xs font-medium transition-colors ${
|
|
viewMode === 'history'
|
|
? 'text-pink-500 border-b-2 border-pink-500'
|
|
: 'text-gray-400 hover:text-gray-600 dark:hover:text-gray-300'
|
|
}`}
|
|
>
|
|
📅 历史简报
|
|
</button>
|
|
</div>
|
|
|
|
{/* 今日简报视图 */}
|
|
{viewMode === 'today' && (
|
|
<div className="flex-1 overflow-y-auto p-3 space-y-3">
|
|
{loading ? (
|
|
<div className="flex items-center justify-center py-8">
|
|
<div className="animate-spin w-5 h-5 border-2 border-pink-500 border-t-transparent rounded-full" />
|
|
</div>
|
|
) : error ? (
|
|
<div className="text-center py-4">
|
|
<p className="text-sm text-red-400 mb-2">{error}</p>
|
|
<button
|
|
onClick={loadBriefing.bind(null, todayStr())}
|
|
className="text-xs text-pink-500 hover:text-pink-600"
|
|
>
|
|
重试
|
|
</button>
|
|
</div>
|
|
) : briefing ? (
|
|
<BriefingCard briefing={briefing} />
|
|
) : (
|
|
<div className="text-center py-8">
|
|
<p className="text-gray-400 text-sm mb-3">今日简报尚未生成</p>
|
|
<button
|
|
onClick={handleGenerate}
|
|
disabled={generating}
|
|
className="px-4 py-2 bg-pink-500 hover:bg-pink-600 disabled:opacity-50 text-white text-sm rounded-lg transition-colors"
|
|
>
|
|
{generating ? '生成中...' : '✨ 生成今日简报'}
|
|
</button>
|
|
<p className="text-[10px] text-gray-400 mt-2">
|
|
简报会在每天早上自动生成
|
|
</p>
|
|
</div>
|
|
)}
|
|
|
|
{/* 已生成时可手动重新生成 */}
|
|
{briefing && (
|
|
<div className="pt-2 border-t border-gray-100 dark:border-gray-700">
|
|
<button
|
|
onClick={handleGenerate}
|
|
disabled={generating}
|
|
className="w-full py-1.5 text-xs text-pink-500 hover:text-pink-600 disabled:opacity-50 transition-colors"
|
|
>
|
|
{generating ? '生成中...' : '🔄 重新生成简报'}
|
|
</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* 历史简报视图 */}
|
|
{viewMode === 'history' && (
|
|
<div className="flex-1 overflow-y-auto p-3 space-y-2">
|
|
{history.length === 0 ? (
|
|
<div className="text-center py-8 text-sm text-gray-400">
|
|
暂无历史简报
|
|
</div>
|
|
) : (
|
|
history.map((b) => (
|
|
<button
|
|
key={b.id}
|
|
onClick={() => {
|
|
handleDateChange(b.date);
|
|
setViewMode('today');
|
|
}}
|
|
className="w-full text-left p-3 rounded-lg bg-gray-50 dark:bg-gray-750 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors"
|
|
>
|
|
<div className="flex items-center justify-between mb-1">
|
|
<span className="text-sm font-medium text-gray-700 dark:text-gray-300">
|
|
{formatBriefingDate(b.date)}
|
|
</span>
|
|
{b.weather && (
|
|
<span className="text-lg">{weatherIcon(b.weather.icon)}</span>
|
|
)}
|
|
</div>
|
|
{b.weather && (
|
|
<p className="text-xs text-gray-500">
|
|
{b.weather.condition} · {b.weather.temp.toFixed(0)}°C
|
|
</p>
|
|
)}
|
|
<p className="text-xs text-gray-400 mt-1 line-clamp-2">
|
|
{b.summary || '暂无摘要'}
|
|
</p>
|
|
</button>
|
|
))
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/** 简报卡片展示组件 */
|
|
function BriefingCard({ briefing }: { briefing: Briefing }) {
|
|
return (
|
|
<div className="space-y-3">
|
|
{/* 日期 */}
|
|
<h3 className="text-sm font-semibold text-gray-700 dark:text-gray-300 text-center">
|
|
{formatBriefingDate(briefing.date)}
|
|
</h3>
|
|
|
|
{/* 天气卡片 */}
|
|
{briefing.weather && briefing.weather.condition && (
|
|
<div className="p-3 rounded-lg bg-gradient-to-br from-blue-50 to-cyan-50 dark:from-blue-900/20 dark:to-cyan-900/20 border border-blue-100 dark:border-blue-800/30">
|
|
<div className="flex items-center gap-3">
|
|
<span className="text-3xl">{weatherIcon(briefing.weather.icon)}</span>
|
|
<div>
|
|
<p className="text-lg font-bold text-blue-600 dark:text-blue-400">
|
|
{briefing.weather.temp.toFixed(0)}°C
|
|
</p>
|
|
<p className="text-xs text-gray-500 dark:text-gray-400">
|
|
{briefing.weather.location} · {briefing.weather.condition}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* 待办提醒 */}
|
|
{briefing.reminders.length > 0 && (
|
|
<div className="p-3 rounded-lg bg-pink-50 dark:bg-pink-900/10 border border-pink-100 dark:border-pink-800/30">
|
|
<h4 className="text-xs font-semibold text-pink-600 dark:text-pink-400 mb-2">
|
|
📋 今日待办 ({briefing.reminders.length})
|
|
</h4>
|
|
<div className="space-y-1.5">
|
|
{briefing.reminders.map((r) => (
|
|
<div key={r.id} className="flex items-center gap-2 text-xs text-gray-600 dark:text-gray-400">
|
|
<span className="w-1.5 h-1.5 rounded-full bg-pink-400 flex-shrink-0" />
|
|
<span className="flex-1 truncate">{r.title}</span>
|
|
<span className="text-[10px] text-gray-400 flex-shrink-0">
|
|
{formatRemindTime(r.remind_at)}
|
|
</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* 新闻列表 */}
|
|
{briefing.news.length > 0 && briefing.news[0].title !== '未能获取今日新闻' && (
|
|
<div className="p-3 rounded-lg bg-green-50 dark:bg-green-900/10 border border-green-100 dark:border-green-800/30">
|
|
<h4 className="text-xs font-semibold text-green-600 dark:text-green-400 mb-2">
|
|
📰 今日新闻
|
|
</h4>
|
|
<div className="space-y-2">
|
|
{briefing.news.map((n, i) => (
|
|
<div key={i} className="text-xs">
|
|
{n.url ? (
|
|
<a
|
|
href={n.url}
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
className="font-medium text-gray-700 dark:text-gray-300 hover:text-pink-500 transition-colors"
|
|
>
|
|
{n.title}
|
|
</a>
|
|
) : (
|
|
<span className="font-medium text-gray-700 dark:text-gray-300">
|
|
{n.title}
|
|
</span>
|
|
)}
|
|
{n.summary && (
|
|
<p className="text-gray-500 dark:text-gray-400 mt-0.5 line-clamp-2">
|
|
{n.summary}
|
|
</p>
|
|
)}
|
|
{n.source && (
|
|
<span className="text-[10px] text-gray-400">{n.source}</span>
|
|
)}
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* AI 摘要 */}
|
|
{briefing.summary && (
|
|
<div className="p-3 rounded-lg bg-purple-50 dark:bg-purple-900/10 border border-purple-100 dark:border-purple-800/30">
|
|
<div className="flex items-start gap-2 mb-1">
|
|
<span className="text-sm">🌸</span>
|
|
<h4 className="text-xs font-semibold text-purple-600 dark:text-purple-400">
|
|
昔涟的每日问候
|
|
</h4>
|
|
{briefing.summary_source === 'fallback' && (
|
|
<span className="text-[10px] px-1.5 py-0.5 rounded-full bg-amber-100 text-amber-600 dark:bg-amber-900/30 dark:text-amber-400">
|
|
本地生成
|
|
</span>
|
|
)}
|
|
</div>
|
|
<p className="text-xs text-gray-600 dark:text-gray-400 leading-relaxed whitespace-pre-wrap">
|
|
{briefing.summary}
|
|
</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|