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:
2026-05-19 12:01:09 +08:00
parent 78e3f450c2
commit bcf4d4e621
69 changed files with 14599 additions and 150 deletions
@@ -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>
);
}