feat: 第五轮开发 - 14项未来路线图功能完整实现
W1-W14 全部完成: - W1: 消息搜索 (ILIKE全文检索 + SearchModal) - W2: 对话导出 (JSON/Markdown/TXT三格式) - W3: 记忆时间线 DevTools 可视化 - W4: 通知推送系统 (WebSocket + Browser Notification API) - W5: 定时提醒 (30s轮询 + 重复提醒 + WebSocket推送) - W6: 每日简报 (08:00自动生成: 天气+新闻+提醒+AI摘要) - W7: IoT场景自动化 (规则引擎 10s轮询 + 条件评估 + 场景执行) - W8: 语音输入 (浏览器 Speech Recognition API) - W9: STT服务 (voice-service + whisper.cpp) - W10: TTS服务 (浏览器 Speech Synthesis + edge-tts三档回退) - W11: 文件管理 (上传/下载/缩略图/纯Go bilinear缩放) - W12: 知识库RAG (PostgreSQL tsvector + 文档分块 + 检索) - W13: 多模态 (图片上传+分析: Vision API + 本地Go分析回退) - W14: PWA (Service Worker + 离线页 + install prompt) 总计: 6个Go微服务 + 10+前端组件 + 10+ PostgreSQL表 + 4个后台调度器
This commit is contained in:
@@ -0,0 +1,317 @@
|
||||
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>
|
||||
</div>
|
||||
<p className="text-xs text-gray-600 dark:text-gray-400 leading-relaxed whitespace-pre-wrap">
|
||||
{briefing.summary}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user