/** * AI 大盘复盘页 —— 以流式 LLM 复盘报告为主体的盘后复盘工作台。 * * 设计定位:极简专注型。不复刻 Dashboard 的看板(KPI/雷达/板块排名), * 仅保留一行「市场摘要条」作为报告上下文参照;AI 报告 + 历史归档是页面主体。 * - 摘要数据:GET /api/overview/market * - 报告流式:POST /api/market-recap/analyze */ import { useCallback, useEffect, useRef, useState } from 'react' import { Link } from 'react-router-dom' import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' import { motion, AnimatePresence } from 'framer-motion' import { BookOpenCheck, RefreshCw, Sparkles, Trash2, History, ChevronRight, AlertTriangle, Database, Wand2, Copy, Download, Clock, X, Check, } from 'lucide-react' import { api, type OverviewMarket, type AiReviewReport } from '@/lib/api' import { QK } from '@/lib/queryKeys' import { cn } from '@/lib/cn' import { fmtBigNum } from '@/lib/format' import { PageHeader } from '@/components/PageHeader' import { MarkdownRenderer } from '@/components/financials/MarkdownRenderer' import { toast } from '@/components/Toast' import { usePreferences } from '@/lib/useSharedQueries' import { useReviewState } from '@/lib/useReviewStore' import { startReviewGeneration, resetReview, isReviewGenerating, type ReviewPhase, } from '@/lib/reviewStore' // ================================================================ // 涨跌幅格式化(注意单位差异) // overview 的 indices.change_pct / breadth.up_pct / seal_rate / *_pct / emotion.score // 都是【已是百分比值】(如 1.2 表示 1.2%),直接 toFixed 即可,不要 *100。 // ================================================================ function fmtPctAlready(v: number | null | undefined, digits = 2, withSign = false): string { if (v == null || Number.isNaN(v)) return '—' const sign = withSign && v > 0 ? '+' : '' return `${sign}${v.toFixed(digits)}%` } function pctClass(v: number | null | undefined): string { if (v == null || Number.isNaN(v) || v === 0) return 'text-muted' return v > 0 ? 'text-bull' : 'text-bear' } // A 股惯例: 强势=红, 弱式=绿(对齐 Dashboard scoreColor) function scoreColor(v: number | null | undefined): string { if (v == null || Number.isNaN(v)) return '#71717A' if (v >= 70) return '#F04438' if (v >= 55) return '#FB923C' if (v >= 45) return '#F59E0B' if (v >= 30) return '#84CC16' return '#12B76A' } // 归档时刻格式化:ISO → "MM-DD HH:mm"(用于历史列表显示复盘时间) function fmtArchivedAt(iso: string): string { const d = new Date(iso) if (Number.isNaN(d.getTime())) return iso const mm = String(d.getMonth() + 1).padStart(2, '0') const dd = String(d.getDate()).padStart(2, '0') const hh = String(d.getHours()).padStart(2, '0') const mi = String(d.getMinutes()).padStart(2, '0') return `${mm}-${dd} ${hh}:${mi}` } // Phase 类型复用 store 的定义(单一来源) export function Review() { const qc = useQueryClient() // 复盘日期:当前固定取最新交易日(后续如需日期选择可改回 useState) const asOf: string | undefined = undefined const [focus, setFocus] = useState('') // 生成状态走全局 store:切走页面流不中断,回来可恢复 const { phase, content, error, meta } = useReviewState() const [viewing, setViewing] = useState(null) // 查看历史报告 const reportEndRef = useRef(null) // 看板数据(与总览页同源) const marketQuery = useQuery({ queryKey: QK.overviewMarket(asOf), queryFn: () => api.overviewMarket(asOf), staleTime: 5_000, placeholderData: (prev) => prev, }) // 历史报告 const historyQuery = useQuery<{ reports: AiReviewReport[] }>({ queryKey: QK.reviewReports, queryFn: () => api.reviewReportsList(), }) const deleteMut = useMutation({ mutationFn: (id: string) => api.reviewReportDelete(id), onSuccess: () => { qc.invalidateQueries({ queryKey: QK.reviewReports }) toast('已删除', 'success') }, onError: () => { /* request() 已 toast */ }, }) // ===== 定时复盘 ===== const [showSchedule, setShowSchedule] = useState(false) const prefs = usePreferences() const reviewSched = prefs.data?.review_schedule ?? { enabled: false, hour: 15, minute: 10 } const feishuConfigured = !!(prefs.data?.feishu_webhook_url) // 推送渠道是独立的顶层偏好(多选), 与定时 / 实时行情无关, 常驻可单独设置 // []=不推送, ['feishu']=飞书(微信开发中, 仅占位) const reviewPushChannels = prefs.data?.review_push_channels ?? [] // 弹窗内的本地草稿: 开关和时间都在本地改, 点「保存」才真正提交(避免开关一拨就关弹窗) const [draft, setDraft] = useState(reviewSched) const openSchedule = useCallback(() => { setDraft(reviewSched) // 每次打开同步最新服务端值 setShowSchedule(true) }, [reviewSched]) const reviewMut = useMutation({ mutationFn: ({ enabled, hour, minute }: { enabled: boolean; hour: number; minute: number }) => api.updateReviewSchedule(enabled, hour, minute), onSuccess: (_data, vars) => { qc.invalidateQueries({ queryKey: QK.preferences }) setShowSchedule(false) toast(vars.enabled ? '已开启定时复盘' : '已关闭定时复盘', 'success') }, onError: () => { /* request() 已 toast */ }, }) // 推送渠道(多选): 独立常驻, 即时生效(勾选渠道即开关, 改了立刻提交) const pushMut = useMutation({ mutationFn: (channels: string[]) => api.updateReviewPush(channels), onSuccess: (_data, vars) => { qc.invalidateQueries({ queryKey: QK.preferences }) toast(vars.length === 0 ? '已关闭复盘推送' : '已更新复盘推送渠道', 'success') }, onError: () => { /* request() 已 toast */ }, }) const togglePushChannel = useCallback((ch: string) => { const next = reviewPushChannels.includes(ch) ? reviewPushChannels.filter(c => c !== ch) : [...reviewPushChannels, ch] pushMut.mutate(next) }, [reviewPushChannels, pushMut]) // 自动滚动到报告底部(streaming 时) useEffect(() => { if (phase === 'streaming') { reportEndRef.current?.scrollIntoView({ behavior: 'smooth', block: 'end' }) } }, [content, phase]) // 当进入生成中(streaming)时, 清掉「查看历史」状态, 让主区域显示流内容。 // 手动 generate 已自带 setViewing(null), 这里主要补定时 SSE 流的场景: // 用户若正看着历史报告, 定时触发生成时也要切回主区域显示流式内容。 useEffect(() => { if (phase === 'streaming' && viewing) { setViewing(null) } }, [phase, viewing]) // 自动归档(生成完成后台静默保存)—— 通过回调注入 store,避免 store 直接依赖 qc/marketQuery const onGenerationDone = useCallback(async (fullContent: string, doneMeta: { as_of?: string; summary?: string; emotion_score?: number; emotion_label?: string } | null) => { const reportAsOf = doneMeta?.as_of ?? marketQuery.data?.as_of ?? asOf ?? new Date().toISOString().slice(0, 10) try { await api.reviewReportSave({ as_of: reportAsOf, focus, content: fullContent, summary: doneMeta?.summary, emotion_score: doneMeta?.emotion_score ?? null, emotion_label: doneMeta?.emotion_label ?? '', }) qc.invalidateQueries({ queryKey: QK.reviewReports }) } catch { /* 静默 */ } }, [focus, asOf, marketQuery.data, qc]) // 主流程:生成复盘(委托给全局 store,流在后台独立运行) const generate = useCallback(() => { if (isReviewGenerating()) return setViewing(null) resetReview() startReviewGeneration(asOf, focus, (full, doneMeta) => { onGenerationDone(full, doneMeta).catch(() => { /* 静默 */ }) }) }, [asOf, focus, onGenerationDone]) // 复制全文到剪贴板(viewing 优先,与主区域显示一致) const copyContent = useCallback(async () => { const text = viewing?.content ?? content if (!text) return try { await navigator.clipboard.writeText(text) toast('已复制到剪贴板', 'success') } catch { toast('复制失败,请手动选择文本', 'error') } }, [content, viewing]) // 下载为 .md 文件(viewing 优先) const downloadContent = useCallback(() => { const text = viewing?.content ?? content if (!text) return const reportDate = viewing?.as_of ?? meta?.as_of ?? asOf ?? new Date().toISOString().slice(0, 10) const blob = new Blob([text], { type: 'text/markdown;charset=utf-8' }) const url = URL.createObjectURL(blob) const a = document.createElement('a') a.href = url a.download = `复盘_${reportDate}.md` a.click() URL.revokeObjectURL(url) }, [content, viewing, meta, asOf]) // 查看历史报告(不中断后台生成:仅临时把 viewing 覆盖到主区域, // 生成中的流仍在 store 里继续跑,点"生成中"项即可切回) const viewReport = useCallback((r: AiReviewReport) => { setViewing(r) }, []) const isGenerating = phase === 'loading' || phase === 'streaming' const displayDate = viewing?.as_of ?? meta?.as_of ?? marketQuery.data?.as_of ?? asOf ?? '最新' const data = marketQuery.data // 主区域显示的内容:viewing(查看历史)优先于 store 的生成 content, // 这样点历史报告不会覆盖后台生成中的流。 const displayContent = viewing?.content ?? content return ( <> } subtitle={`${displayDate}${data?.emotion ? ` · 情绪 ${data.emotion.label}` : ''}`} right={
} />
{marketQuery.isLoading && !data ? (
加载市场数据…
) : !data || !data.as_of ? (
暂无市场数据

复盘需要日 K 与指数,请先前往「数据」页同步

前往数据页同步
) : ( <> {/* ===== 市场摘要条(轻量上下文,非重复看板)===== */} {/* ===== 关注点输入 ===== */}
setFocus(e.target.value)} onKeyDown={(e) => { if (e.key === 'Enter' && !isGenerating) generate() }} placeholder="可选:补充复盘关注点,如「明日是否加仓半导体」「量能是否持续」" className="flex-1 bg-transparent text-sm text-foreground outline-none placeholder:text-muted/60" /> {focus && ( )}
{/* ===== 报告 + 历史 双栏(报告为主体)===== */}
setViewing(null)} onDelete={(id) => deleteMut.mutate(id)} />
)}
{/* ===== 定时复盘设置弹窗 ===== */} {showSchedule && ( setShowSchedule(false)} > e.stopPropagation()} >

定时复盘

开启后,每个交易日到点自动生成大盘复盘报告并归档,静默执行。 下次打开本页即可在历史列表看到新报告;也可选推送到飞书。

{/* 开关(只改本地草稿, 不提交) */} {/* 时间设置(仅开启时可编辑, 本地草稿) */} {draft.enabled && (
每日 setDraft(d => ({ ...d, hour: Math.max(0, Math.min(23, Number(e.target.value))) }))} className="w-12 px-1.5 py-1 rounded-btn bg-base border border-border text-xs font-mono text-foreground text-center focus:outline-none focus:border-accent/50" /> : setDraft(d => ({ ...d, minute: Math.max(0, Math.min(59, Number(e.target.value))) }))} className="w-12 px-1.5 py-1 rounded-btn bg-base border border-border text-xs font-mono text-foreground text-center focus:outline-none focus:border-accent/50" /> 不早于 15:00 · 工作日执行
)} {/* 推送渠道(多选, 独立常驻, 与定时无关, 即时生效) */}
生成后推送完整报告 {reviewPushChannels.length === 0 ? '未开启' : `${reviewPushChannels.length} 个渠道`}
{/* 飞书(可用, 多选) */} {/* 微信(开发中, 占位不可选) */}
微信 公众号/企业微信 开发中

手动或定时生成的复盘都会以卡片消息推送完整报告。复用「设置 → 实时监控」的飞书 Webhook。 {reviewPushChannels.includes('feishu') && !feishuConfigured && ( setShowSchedule(false)}> 前往配置 → )}

{!draft.enabled && (

当前: 已关闭。开启后将按设定时间自动复盘。

)} {/* 操作区: 取消 + 保存(统一提交开关+时间) */}
)}
) } // ================================================================ // 市场摘要条 —— 复盘页的轻量上下文(非重复看板) // 仅一行:三大指数涨跌 · 情绪分 · 涨停结构 · 成交额 // 详细数据请去 Dashboard 看,这里只给 AI 报告提供背景参照 // ================================================================ // 指数简称映射:全称太长(上证指数/深证成指/创业板指/科创综指)摘要条放不下,统一缩成单字 const INDEX_SHORT: Record = { '上证指数': '上', '深证成指': '深', '创业板指': '创', '科创综指': '科', '科创50': '科', } function indexShort(name?: string | null, symbol?: string): string { if (!name) return symbol ?? '—' return INDEX_SHORT[name] ?? (name.replace(/指数|成指|A股|综指|50/g, '').slice(0, 2) || name.slice(0, 1)) } // 批量替换文本中的指数全称为简称(用于历史列表 summary 显示, // 兼容存量旧报告 —— 它们存盘时 summary 还是全称)。 const _INDEX_FULL_RE = /上证指数|深证成指|创业板指|科创综指|科创50/g function shortenIndexNames(text: string): string { return text.replace(_INDEX_FULL_RE, (m) => INDEX_SHORT[m] ?? m) } // 从 summary 的指数段(如「上-2.26%、深-3.44%、创-4.07%、科-2.02%」) // 解析出 [{name, pctStr, pctNum}],供列表项按涨跌染色渲染。 const _INDEX_PCT_RE = /([上深创科])([+-]?\d+\.\d+%)/g function parseIndexPcts(indexSegment: string): { name: string; pctStr: string; pctNum: number }[] { const out: { name: string; pctStr: string; pctNum: number }[] = [] for (const m of indexSegment.matchAll(_INDEX_PCT_RE)) { out.push({ name: m[1], pctStr: m[2], pctNum: parseFloat(m[2]) }) } return out } function MarketSummaryBar({ data }: { data: OverviewMarket }) { const score = data.emotion?.score ?? null const emoColor = scoreColor(score) const indices = (data.indices ?? []).slice(0, 4) return (
{/* 情绪分(带色徽章)—— 复盘的核心定调 */}
{score ?? '—'}
{data.emotion?.label ?? '情绪'}
情绪温度
{/* 四大指数(简称:上深创科)*/}
{indices.map(idx => (
{indexShort(idx.name, idx.symbol)} {fmtPctAlready(idx.change_pct, 2, true)}
))}
{/* 涨跌结构 */}
涨跌 {data.breadth?.up ?? 0} / {data.breadth?.down ?? 0}
{/* 涨停结构 */}
涨停 {data.limit?.limit_up ?? 0} 封板 {(data.limit?.seal_rate ?? 0).toFixed(0)}%
{/* 成交额 */}
成交 {fmtBigNum(data.amount?.total)}
) } // ================================================================ // 报告面板(流式 + 错误 + 历史/完成态) // ================================================================ function ReportPanel({ phase, content, error, isGenerating, viewing, onCopy, onDownload, onRegenerate, reportEndRef, }: { phase: ReviewPhase content: string error: string isGenerating: boolean viewing: AiReviewReport | null onCopy: () => void onDownload: () => void onRegenerate: () => void reportEndRef: React.RefObject }) { if (phase === 'error') { return (
复盘失败
{error || '请检查 AI 配置后重试'}
) } if (phase === 'idle' && !content) { return (
AI 大盘复盘

一键生成今日盘后复盘报告 —— 从一句话定调到明日交易计划, 结构化输出可直接指导次日仓位与节奏。

{/* 报告七节预览 —— 空状态也有内容感,暗示报告结构 */}
{[ { icon: '🎯', label: '一句话定调' }, { icon: '📊', label: '盘面总览' }, { icon: '🔥', label: '板块主线' }, { icon: '💰', label: '资金情绪' }, { icon: '📰', label: '消息催化' }, { icon: '🎯', label: '明日计划' }, { icon: '⚠️', label: '风险提示' }, ].map((s) => (
{s.icon} {s.label}
))}
点击右上角「生成复盘」开始
) } // 仅当显示生成内容(非查看历史)且正在生成时,才显示流式光标 const showCursor = isGenerating && !viewing // 查看历史时(即使后台在生成)也能复制/下载该历史报告 const showActions = !!content && (!isGenerating || !!viewing) const showViewingTag = !!viewing const isLoading = phase === 'loading' && !content return (
{isGenerating ? : } {showViewingTag ? `历史复盘 · ${viewing!.as_of}` : isGenerating ? 'AI 正在复盘…' : '复盘报告'}
{showActions && (
)}
{isLoading ? (
AI 正在复盘今日盘面…
分析指数结构 · 连板梯队 · 板块轮动 · 资金情绪
) : (
{showCursor && ( )}
)}
) } // ================================================================ // 历史面板 // ================================================================ function HistoryPanel({ reports, loading, viewingId, generating, onView, onBackToGenerating, onDelete, }: { reports: AiReviewReport[] loading: boolean viewingId: string | null generating: boolean onView: (r: AiReviewReport) => void onBackToGenerating: () => void onDelete: (id: string) => void }) { const empty = !generating && reports.length === 0 return (
历史复盘 ({reports.length})
{loading ? (
) : empty ? (
暂无历史复盘
生成完成后自动归档
) : (
{/* 生成中占位项:列表顶部,点击回到正在生成的流式内容 */} {generating && (
生成中…
AI 正在复盘今日盘面
)} {reports.map((r) => { const color = scoreColor(r.emotion_score) return (
onView(r)} >
{r.emotion_score ?? '—'}
{r.emotion_label ?? '—'} {r.as_of}
{r.summary ? (() => { const pcts = parseIndexPcts(shortenIndexNames(r.summary).split('|')[0]) if (pcts.length === 0) { return {r.content.slice(0, 40)} } return pcts.map((p) => ( {p.name} {p.pctStr} )) })() : {r.content.slice(0, 40)}}
{r.created_at && (
{fmtArchivedAt(r.created_at)}
)}
) })}
)}
) }