@@ -936,9 +1022,9 @@ export function Data() {
此操作将
永久删除所有已同步的本地数据,包括:
- - · 标的维表、日 K、除权因子
+ - · 个股维表、日 K、除权因子
- · Enriched 指标数据、分钟 K
- - · 财务数据、指数数据
+ - · 财务数据、指数、ETF
操作不可恢复,需重新执行同步才能恢复数据。
diff --git a/frontend/src/pages/IndustryAnalysis.tsx b/frontend/src/pages/IndustryAnalysis.tsx
index c941c08..f96362e 100644
--- a/frontend/src/pages/IndustryAnalysis.tsx
+++ b/frontend/src/pages/IndustryAnalysis.tsx
@@ -278,7 +278,11 @@ export function IndustryAnalysis() {
const configsQuery = useQuery({ queryKey: QK.extData, queryFn: api.extDataList })
const availableConfigs = configsQuery.data?.items ?? []
- const activeConfigId = fieldConfig.configId || pickBestConfig(availableConfigs)
+ // 用户配置的 configId 可能已失效 (扩展数据被删除), 此时回退到自动选择,
+ // 避免用失效 ID 请求接口报错; 用户仍可点配置按钮重新选择。
+ const preferredConfigId = fieldConfig.configId || pickBestConfig(availableConfigs)
+ const preferredConfig = availableConfigs.find(c => c.id === preferredConfigId)
+ const activeConfigId = preferredConfig ? preferredConfigId : pickBestConfig(availableConfigs)
const activeConfig = availableConfigs.find(c => c.id === activeConfigId)
const rowsQuery = useQuery({
diff --git a/frontend/src/pages/Onboarding.tsx b/frontend/src/pages/Onboarding.tsx
index 81880e5..e9d9c03 100644
--- a/frontend/src/pages/Onboarding.tsx
+++ b/frontend/src/pages/Onboarding.tsx
@@ -13,7 +13,6 @@ import {
ArrowRight,
ArrowLeft,
ExternalLink,
- Copy,
Sparkles,
LineChart,
ScanSearch,
@@ -22,6 +21,10 @@ import {
Radar,
ShieldCheck,
BellRing,
+ TrendingUp,
+ FileText,
+ Landmark,
+ Database,
} from 'lucide-react'
import { api } from '@/lib/api'
import { useCapabilities, useSettings } from '@/lib/useSharedQueries'
@@ -37,12 +40,15 @@ const STEPS = ['欢迎', '配置 Key', '能力探测', '完成'] as const
const BRAND = '#8B5CF6'
const HIGHLIGHTS = [
- { icon: LineChart, title: '看板与自选', desc: '实时行情、MA/MACD 指标、自定义自选列表', tint: 'text-accent' },
- { icon: ScanSearch, title: '策略选股', desc: '内置多套选股策略,一键扫描全市场命中', tint: 'text-bull' },
- { icon: Flame, title: '连板梯队', desc: '涨停板梯队、概念行业热度、市场情绪一览', tint: 'text-warning' },
- { icon: Radar, title: '实时监控', desc: '自定义条件 / 策略监控,触发即推送告警', tint: 'text-bear' },
- { icon: ShieldCheck, title: '回测验证', desc: '策略历史回测、因子分析,用数据说话', tint: 'text-accent' },
- { icon: BellRing, title: '本地优先', desc: '数据本地存储,隐私可控,断网仍可查阅', tint: 'text-bull' },
+ { icon: LineChart, title: '看板与自选', desc: '市场全景看板、涨跌分布、情绪雷达,自定义自选列表', tint: 'text-accent' },
+ { icon: ScanSearch, title: '策略选股', desc: '内置多套选股策略,一键扫描全市场命中标的', tint: 'text-bull' },
+ { icon: TrendingUp, title: '个股分析', desc: 'AI 四维分析个股,关键价位、技术形态一目了然', tint: 'text-warning' },
+ { icon: Flame, title: '连板梯队', desc: '涨停梯队、封板强度、炸板监控,情绪温度计', tint: 'text-warning' },
+ { icon: Landmark, title: '概念行业', desc: '概念板块、行业维度的资金流向与热度排名', tint: 'text-accent' },
+ { icon: FileText, title: '财务分析', desc: 'AI 解读财报,利润、资负、现金流、核心指标', tint: 'text-bear' },
+ { icon: ShieldCheck, title: '回测验证', desc: '策略历史回测、因子分析,用数据验证逻辑', tint: 'text-accent' },
+ { icon: Radar, title: '实时监控', desc: '自定义条件 / 策略监控,盘中触发即推送告警', tint: 'text-bear' },
+ { icon: BellRing, title: '本地优先', desc: '数据本地存储,隐私可控,断网仍可查阅', tint: 'text-bull' },
]
export function Onboarding() {
@@ -180,20 +186,24 @@ function WelcomeStep({ onNext, onSkip }: { onNext: () => void; onSkip: () => voi
花一分钟配置,即可开始使用。
- {/* 6 个特性卡片 */}
-
+ {/* 特性卡片 —— 3×3 网格,横向布局压缩高度 */}
+
{HIGHLIGHTS.map((h, i) => (
-
- {h.title}
- {h.desc}
+
+
+
+
))}
@@ -225,7 +235,6 @@ function KeyStep({ onNext, onSkip, onBack }: { onNext: () => void; onSkip: () =>
const [keyInput, setKeyInput] = useState('')
const [revealing, setRevealing] = useState(false)
- const [copiedCode, setCopiedCode] = useState(false)
const [saved, setSaved] = useState(false)
const save = useMutation({
@@ -242,7 +251,7 @@ function KeyStep({ onNext, onSkip, onBack }: { onNext: () => void; onSkip: () =>
},
})
- // 已配置 key —— 免费档或付费档都算(只要不是无档 none)
+ // 已配置 key —— 免费档或付费档都算(只要不是 None 档)
const alreadyHasKey = settings.data?.mode !== 'none' && settings.data?.mode !== undefined
return (
@@ -254,41 +263,44 @@ function KeyStep({ onNext, onSkip, onBack }: { onNext: () => void; onSkip: () =>
配置 TickFlow API Key
- Key 决定你能使用的数据范围。没有 Key 也能以 基础模式
- 使用历史日K;配置有效 Key 后可解锁实时行情、批量同步等扩展能力。
+ 本项目基于 TickFlow 这款稳定的数据源为基座进行开发,正在适配其他第三方数据源。
+ 如果有任何建议或意见,欢迎发送邮件至{' '}
+
+ 415333856@qq.com
+
+ 。
- {/* 注册引导 */}
-
- 还没有 Key?前往{' '}
-
- tickflow.org
-
- {' '}
- 注册,或填写邀请码{' '}
-
- V3KDKGXPEA
-
-
- ,即可免费领取扩展数据。
+ {/* 档位对比说明 —— None 档 vs Free 档 */}
+
+ {/* None 档 —— 不配置时默认 */}
+
+
+ None
+ 不配置(默认)
+
+
+ - · 仅历史日K数据,无实时行情
+ - · 数据有延迟,盘后约 1-2 小时更新当天
+ - · 可用于策略回测、盘后分析
+
+
+ {/* Free 档 —— 免费注册即可获取 */}
+
+
+ Free
+ 注册免费获取
+ 推荐
+
+
+ - · 无需付费,注册即享
+ - · 历史日K + 限定范围内的实时数据
+ - · 可指定个股进行实时监控
+
+
{/* Key 已配置提示 */}
@@ -302,6 +314,27 @@ function KeyStep({ onNext, onSkip, onBack }: { onNext: () => void; onSkip: () =>
)}
+ {/* 获取 Key 的说明 —— 黄框卡片 */}
+
+
+
+ Key 可在{' '}
+
+ tickflow.org
+
+
+ 获取。
+
+ 当前数据源基于 TickFlow 基座,其他第三方数据源正在开发适配中。
+
+
+
+
{/* 输入 */}
)}
@@ -490,10 +523,16 @@ function ResultStep({ onNext, onBack }: { onNext: () => void; onBack: () => void
// ===== Step 3: 完成 =====
function FinishStep({ onNext, onBack, pending }: { onNext: () => void; onBack: () => void; pending: boolean }) {
+ const settings = useSettings()
+ // 是否已配置 Key(free 或 api_key 都算,None 档算未配置)
+ const hasKey = settings.data?.mode === 'free' || settings.data?.mode === 'api_key'
+
+ // 首要行动:获取数据(不管配没配 Key, 新用户都需要先拉数据)
+ // 快速上手入口(精简为核心功能)
const tips = [
- { icon: ScanSearch, text: '在「选股」页用内置策略一键扫描全市场' },
- { icon: BellRing, text: '在「监控」页设置条件或策略告警,盘中实时推送' },
- { icon: ShieldCheck, text: '在「回测」页用历史数据验证策略表现' },
+ { icon: TrendingUp, text: '「个股分析」:输入代码,AI 四维分析 + 关键价位' },
+ { icon: ScanSearch, text: '「选股」页:内置多套策略,一键扫描全市场' },
+ { icon: ShieldCheck, text: '「回测」页:用历史数据验证策略表现,用数据说话' },
]
return (
@@ -520,18 +559,37 @@ function FinishStep({ onNext, onBack, pending }: { onNext: () => void; onBack: (
一切就绪!
- 配置已完成。下面几个入口帮你快速上手,有任何问题随时在
- 设置 里调整。
+ {hasKey
+ ? 'Key 已生效,进入面板后系统会自动引导你获取行情数据,完成后即可使用全部功能。'
+ : '当前为 None 档,进入面板后系统会自动引导你获取历史日K数据(无需 Key),即可开始体验。'}
- {/* 快速上手提示 */}
-
+ {/* 首要行动:获取数据 */}
+
+
+
+
+
+
下一步:获取行情数据
+
+ 进入面板后,看板会自动引导你拉取近 1 年全 A 股日K(约 5500 只,预计 1-3 分钟)。同步期间可浏览其他页面。
+
+
+
+
+ {/* 快速上手入口 */}
+
{tips.map((t, i) => (
diff --git a/frontend/src/pages/Review.tsx b/frontend/src/pages/Review.tsx
new file mode 100644
index 0000000..36c7674
--- /dev/null
+++ b/frontend/src/pages/Review.tsx
@@ -0,0 +1,662 @@
+/**
+ * AI 大盘复盘页 —— 盘后复盘看板 + 流式 LLM 复盘报告 + 历史归档。
+ *
+ * 数据分工:
+ * - 顶部看板(指数/涨跌/连板/封板/情绪雷达)来自 GET /api/overview/market
+ * - 复盘报告(markdown)由 POST /api/market-recap/analyze 流式生成
+ * 视觉语言对齐 Dashboard:A 股红涨绿跌、rounded-card 卡片、SectionTitle 层级。
+ */
+import { useCallback, useEffect, useRef, useState } from 'react'
+import { Link } from 'react-router-dom'
+import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
+import { motion } from 'framer-motion'
+import {
+ BookOpenCheck, RefreshCw, Sparkles, Trash2, History, ChevronRight, AlertTriangle,
+ BarChart3, Activity, Layers, ArrowUpRight, ArrowDownRight, Database, Wand2,
+} from 'lucide-react'
+
+import { api, type OverviewMarket, type AiReviewReport } from '@/lib/api'
+import { QK } from '@/lib/queryKeys'
+import { cn } from '@/lib/cn'
+import { fmtPrice } from '@/lib/format'
+import { PageHeader } from '@/components/PageHeader'
+import { MarkdownRenderer } from '@/components/financials/MarkdownRenderer'
+import { toast } from '@/components/Toast'
+
+// ================================================================
+// 涨跌幅格式化(注意单位差异)
+// 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'
+}
+
+type Phase = 'idle' | 'loading' | 'streaming' | 'done' | 'error'
+
+export function Review() {
+ const qc = useQueryClient()
+ // 复盘日期:当前固定取最新交易日(后续如需日期选择可改回 useState)
+ const asOf: string | undefined = undefined
+ const [focus, setFocus] = useState('')
+ const [phase, setPhase] = useState
('idle')
+ const [content, setContent] = useState('')
+ const [error, setError] = useState('')
+ const [meta, setMeta] = useState<{ as_of?: string; emotion_score?: number; emotion_label?: string; summary?: string } | null>(null)
+ const [viewing, setViewing] = useState(null) // 查看历史报告
+ const abortRef = useRef(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 */ },
+ })
+
+ // 自动滚动到报告底部(streaming 时)
+ useEffect(() => {
+ if (phase === 'streaming') {
+ reportEndRef.current?.scrollIntoView({ behavior: 'smooth', block: 'end' })
+ }
+ }, [content, phase])
+
+ // 主流程:生成复盘
+ const generate = useCallback(async () => {
+ if (phase === 'loading' || phase === 'streaming') return
+ setViewing(null)
+ setPhase('loading')
+ setContent('')
+ setError('')
+ setMeta(null)
+
+ const ctrl = new AbortController()
+ abortRef.current = ctrl
+ let buf = ''
+ let failed = false
+ try {
+ for await (const evt of api.reviewStream(asOf, focus)) {
+ if (ctrl.signal.aborted) break
+ if (evt.type === 'meta') {
+ setMeta(evt)
+ } else if (evt.type === 'delta' && evt.content) {
+ buf += evt.content
+ setContent(buf)
+ setPhase('streaming')
+ } else if (evt.type === 'error') {
+ failed = true
+ setError(evt.message ?? '复盘失败')
+ setPhase('error')
+ return
+ } else if (evt.type === 'done') {
+ setPhase('done')
+ }
+ }
+ // 流正常结束但无 done 事件,按 done 处理
+ if (buf && !failed) setPhase('done')
+ } catch (e: any) {
+ if (!ctrl.signal.aborted) {
+ setError(e?.message ?? '复盘失败')
+ setPhase('error')
+ }
+ } finally {
+ abortRef.current = null
+ }
+ }, [asOf, focus, phase])
+
+ // 保存当前报告
+ const saveCurrent = useCallback(async () => {
+ if (!content) return
+ const reportAsOf = meta?.as_of ?? marketQuery.data?.as_of ?? asOf ?? new Date().toISOString().slice(0, 10)
+ try {
+ await api.reviewReportSave({
+ as_of: reportAsOf,
+ focus,
+ content,
+ summary: meta?.summary,
+ emotion_score: meta?.emotion_score ?? null,
+ emotion_label: meta?.emotion_label ?? '',
+ })
+ qc.invalidateQueries({ queryKey: QK.reviewReports })
+ toast('复盘已归档', 'success')
+ } catch { /* request() 已 toast */ }
+ }, [content, meta, asOf, focus, marketQuery.data, qc])
+
+ // 查看历史报告
+ const viewReport = useCallback((r: AiReviewReport) => {
+ abortRef.current?.abort()
+ setViewing(r)
+ setContent(r.content)
+ setMeta({ as_of: r.as_of, emotion_score: r.emotion_score ?? undefined, emotion_label: r.emotion_label, summary: r.summary })
+ setPhase('done')
+ setError('')
+ }, [])
+
+ const isGenerating = phase === 'loading' || phase === 'streaming'
+ const displayDate = viewing?.as_of ?? meta?.as_of ?? marketQuery.data?.as_of ?? asOf ?? '最新'
+ const data = marketQuery.data
+
+ return (
+ <>
+ }
+ subtitle={`${displayDate}${data?.emotion ? ` · 情绪 ${data.emotion.label}` : ''}`}
+ right={
+
+
+
+
+ }
+ />
+
+
+
+
+ {marketQuery.isLoading && !data ? (
+
+ ) : !data || !data.as_of ? (
+
+
+
+
暂无市场数据
+
复盘需要日 K 与指数,请先前往「数据」页同步
+
+
+
前往数据页同步
+
+
+
+ ) : (
+ <>
+ {/* ===== 指数行情条(对齐 Dashboard IndexTicker) ===== */}
+
+ {data.indices.map(item => )}
+
+
+ {/* ===== KPI 网格 ===== */}
+
+ {data.breadth.up}/{data.breadth.flat}/{data.breadth.down}>} sub={`上涨率 ${data.breadth.up_pct.toFixed(1)}%`} />
+ {data.limit.limit_up}/{data.limit.limit_down}>} sub={`封板率 ${(data.limit.seal_rate ?? 0).toFixed(0)}% · 炸板 ${data.limit.broken ?? 0}`} />
+
+
+
+
+
+
+ {/* ===== 情绪雷达 + 板块排名 双栏 ===== */}
+
+
+
+
+
+
+ {/* ===== 关注点输入 ===== */}
+
+
+ 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 && (
+
+ )}
+
+
+ {/* ===== 报告 + 历史 双栏 ===== */}
+
+
+ deleteMut.mutate(id)}
+ />
+
+ >
+ )}
+
+
+ >
+ )
+}
+
+// ================================================================
+// 指数行情卡(对齐 Dashboard IndexTicker)
+// ================================================================
+function IndexTicker({ item }: { item: OverviewMarket['indices'][number] }) {
+ const pct = item.change_pct
+ const isUp = (pct ?? 0) >= 0
+ return (
+
+
{item.name || item.symbol}
+
{fmtPctAlready(pct, 2, true)}
+
{item.symbol}
+
+ {isUp ?
:
}
+ {fmtPrice(item.last_price)}
+
+
+ )
+}
+
+// ================================================================
+// KPI 单元(对齐 Dashboard KpiCell)
+// ================================================================
+function KpiCell({ label, value, sub, tone }: {
+ label: React.ReactNode
+ value: React.ReactNode
+ sub?: string
+ tone?: 'bull' | 'bear' | 'accent'
+}) {
+ const isPlain = typeof value === 'string' || typeof value === 'number'
+ const color = tone === 'bull' ? 'text-bull' : tone === 'bear' ? 'text-bear' : tone === 'accent' ? 'text-accent' : 'text-foreground'
+ return (
+
+
{label}
+
{value}
+ {sub &&
{sub}
}
+
+ )
+}
+
+// ================================================================
+// 章节标题(对齐 Dashboard SectionTitle)
+// ================================================================
+function SectionTitle({ icon: Icon, title, hint }: { icon: typeof Activity; title: string; hint?: React.ReactNode }) {
+ return (
+
+
+
+
{title}
+
+ {hint &&
{hint}}
+
+ )
+}
+
+// ================================================================
+// 情绪雷达章节(SVG 雷达图,对齐 Dashboard EmotionRadar)
+// ================================================================
+function EmotionSection({ data }: { data: OverviewMarket }) {
+ const score = data.emotion.score
+ const color = scoreColor(score)
+ const radar = data.radar ?? []
+ const size = 220
+ const cx = size / 2
+ const cy = size / 2
+ const maxR = 68
+
+ const points = radar.map((r, i) => {
+ const angle = -Math.PI / 2 + i * 2 * Math.PI / radar.length
+ const radius = maxR * Math.max(0, Math.min(100, r.value)) / 100
+ return {
+ ...r,
+ x: cx + Math.cos(angle) * radius,
+ y: cy + Math.sin(angle) * radius,
+ lx: cx + Math.cos(angle) * (maxR + 24),
+ ly: cy + Math.sin(angle) * (maxR + 24),
+ gx: cx + Math.cos(angle) * maxR,
+ gy: cy + Math.sin(angle) * maxR,
+ }
+ })
+ const polygon = points.map(p => `${p.x},${p.y}`).join(' ')
+ const gridPolygons = [1, 0.66, 0.33].map((level, idx) => ({
+ level, idx,
+ points: radar.map((_, i) => {
+ const angle = -Math.PI / 2 + i * 2 * Math.PI / radar.length
+ return `${cx + Math.cos(angle) * maxR * level},${cy + Math.sin(angle) * maxR * level}`
+ }).join(' '),
+ }))
+
+ return (
+
+
+ {radar.length === 0 ? (
+ 暂无雷达数据
+ ) : (
+
+
+
+ )}
+
+ )
+}
+
+// ================================================================
+// 板块排名章节(领涨/领跌)
+// ================================================================
+function SectorSection({ title, rank, tone }: {
+ title: string
+ rank: OverviewMarket['concept_rank'] | OverviewMarket['industry_rank']
+ tone: 'concept' | 'industry'
+}) {
+ const leading = rank?.leading ?? []
+ const lagging = rank?.lagging ?? []
+ const hasData = leading.length > 0 || lagging.length > 0
+ return (
+
+
+ {!hasData ? (
+ 暂无数据
+ ) : (
+
+
+
+
+ )}
+
+ )
+}
+
+function RankColumn({ rows, tone }: { rows: OverviewMarket['concept_rank']['leading']; tone: 'bull' | 'bear' }) {
+ return (
+
+
+ {tone === 'bull' ? '领涨' : '领跌'}
+
+ {rows.slice(0, 5).map((r, idx) => (
+
+
{idx + 1}
+
+
{r.name}
+
{r.count}只 · {r.leader?.name ?? '—'}
+
+
+ {fmtPctAlready((r.avg_pct ?? 0) * 100, 2, true)}
+
+
+ ))}
+ {rows.length === 0 &&
—
}
+
+ )
+}
+
+// ================================================================
+// 报告面板(流式 + 错误 + 历史/完成态)
+// ================================================================
+function ReportPanel({
+ phase, content, error, isGenerating, viewing, onSave, onRegenerate, reportEndRef,
+}: {
+ phase: Phase
+ content: string
+ error: string
+ isGenerating: boolean
+ viewing: AiReviewReport | null
+ onSave: () => void
+ onRegenerate: () => void
+ reportEndRef: React.RefObject
+}) {
+ if (phase === 'error') {
+ return (
+
+
+
复盘失败
+
{error || '请检查 AI 配置后重试'}
+
+
+ )
+ }
+
+ if (phase === 'idle' && !content) {
+ return (
+
+
+
+
AI 大盘复盘
+
+ 点击右上角「生成复盘」,基于今日指数结构、涨跌家数、连板梯队、板块轮动与情绪雷达,
+ 生成可直接指导次日仓位与节奏的盘后复盘报告。
+
+
+
+
+ 七节结构化报告 · 一键归档 · 历史回看
+
+
+ )
+ }
+
+ const showCursor = isGenerating
+ const showSave = phase === 'done' && !!content && !viewing
+ const showViewingTag = !!viewing
+ const isLoading = phase === 'loading' && !content
+
+ return (
+
+
+
+ {isGenerating ? : }
+
+ {showViewingTag ? `历史复盘 · ${viewing!.as_of}` : isGenerating ? 'AI 正在复盘…' : '复盘报告'}
+
+
+ {showSave && (
+
+ )}
+
+
+ {isLoading ? (
+
+
+
AI 正在分析今日盘面…
+
读取指数结构 · 涨跌家数 · 连板梯队 · 板块轮动 · 情绪雷达
+
+ ) : (
+
+
+ {showCursor && (
+
+ )}
+
+ )}
+
+
+
+ )
+}
+
+// ================================================================
+// 历史面板
+// ================================================================
+function HistoryPanel({
+ reports, loading, viewingId, onView, onDelete,
+}: {
+ reports: AiReviewReport[]
+ loading: boolean
+ viewingId: string | null
+ onView: (r: AiReviewReport) => void
+ onDelete: (id: string) => void
+}) {
+ return (
+
+
+
+ 历史复盘
+ ({reports.length})
+
+
+ {loading ? (
+
+ ) : reports.length === 0 ? (
+
+
+
暂无历史复盘
+
生成后点「归档」即可保存
+
+ ) : (
+
+ {reports.map((r) => {
+ const color = scoreColor(r.emotion_score)
+ return (
+
onView(r)}
+ >
+
+ {r.emotion_score ?? '—'}
+
+
+
+ {r.emotion_label ?? '—'}
+ {r.as_of}
+
+
+ {r.summary ?? r.content.slice(0, 40)}
+
+
+
+
+
+ )
+ })}
+
+ )}
+
+
+ )
+}
diff --git a/frontend/src/pages/Watchlist.tsx b/frontend/src/pages/Watchlist.tsx
index 789f77c..5a2e110 100644
--- a/frontend/src/pages/Watchlist.tsx
+++ b/frontend/src/pages/Watchlist.tsx
@@ -1,7 +1,7 @@
import React, { useState, useCallback, useRef, useEffect, useMemo } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { motion, AnimatePresence } from 'framer-motion'
-import { Trash2, RefreshCw, Star, X, Search, LayoutGrid, List, Settings2, Plus, Check, Filter, Eye, EyeOff, Minus } from 'lucide-react'
+import { Trash2, RefreshCw, Star, X, Search, LayoutGrid, List, Settings2, Plus, Check, Filter, Eye, EyeOff, Minus, ChevronsUp } from 'lucide-react'
import { api, type KlineRow } from '@/lib/api'
import { QK } from '@/lib/queryKeys'
import { storage } from '@/lib/storage'
@@ -591,6 +591,18 @@ export function Watchlist() {
},
})
+ const moveToTop = useMutation({
+ mutationFn: (sym: string) => api.watchlistMoveToTop(sym),
+ onSuccess: (data) => {
+ qc.setQueryData(QK.watchlist, data)
+ qc.invalidateQueries({ queryKey: QK.watchlist })
+ qc.invalidateQueries({ queryKey: ['watchlist-enriched'] })
+ qc.invalidateQueries({ queryKey: ['watchlist-kline-batch'] })
+ qc.invalidateQueries({ queryKey: QK.preferences })
+ qc.invalidateQueries({ queryKey: QK.quoteStatus })
+ },
+ })
+
const clearAll = useMutation({
mutationFn: () => api.watchlistClear(),
onSuccess: () => {
@@ -953,13 +965,25 @@ export function Watchlist() {
) : (
-
+
+
+
+
)}
diff --git a/frontend/src/pages/settings/Keys.tsx b/frontend/src/pages/settings/Keys.tsx
index a4a1e1d..76f5d3f 100644
--- a/frontend/src/pages/settings/Keys.tsx
+++ b/frontend/src/pages/settings/Keys.tsx
@@ -14,7 +14,6 @@ import {
Loader2,
Save,
Check,
- Copy,
HelpCircle,
} from 'lucide-react'
import { api } from '@/lib/api'
@@ -34,7 +33,6 @@ export function SettingsKeysPanel() {
const [revealing, setRevealing] = useState(false)
const [confirmClear, setConfirmClear] = useState(false)
const [saved, setSaved] = useState(false)
- const [copiedCode, setCopiedCode] = useState(false)
const save = useMutation({
mutationFn: () => api.saveTickflowKey(keyInput.trim()),
@@ -89,27 +87,6 @@ export function SettingsKeysPanel() {
{' '}
注册获取。API Key 存放为本地文件,不会上传任何第三方,请妥善保管。
-
- 通过上方链接注册或填写邀请码{' '}
-
- V3KDKGXPEA
-
-
- ,即可免费领取概念行业等扩展数据。
-
{/* 当前状态 */}
@@ -131,7 +108,7 @@ export function SettingsKeysPanel() {
) : (
<>
-
未配置 · Free 数据
+
未配置
>
)}
@@ -220,7 +197,7 @@ export function SettingsKeysPanel() {
保存成功 — 档位 {save.data.tier_label}
- {save.data.mode === 'free' && '(免费档 · 历史日K)'}
+ {save.data.mode === 'free' && '(免费档 · 历史日K + 自选实时监控)'}
)}
@@ -341,7 +318,7 @@ export function SettingsKeysPanel() {
清除 API Key
- 清除后将退回无档(仅历史日K),需要重新输入 Key 才能恢复。
+ 清除后将退回 None 档(仅历史日K),需要重新输入 Key 才能恢复。
)
})}
+
+ 高等档位包含较低档位的全部权益。
+
+
{/* 检测说明 */}
档位检测说明
-
保存 Key 后系统会在付费端点逐一试探数据能力:连单只日K都拿不到则判为「无」(不存 Key);有日K但无复权因子则判为「Free」;有复权因子再按代表能力判定 Starter/Pro/Expert。
-
无档与免费档运行时都走免费数据通道(仅历史日K),区别仅在于是否保存了 Key。付费档走付费端点,享有实时行情等完整能力。
+
保存 Key 后系统会在付费端点逐一试探数据能力:连单只日K都拿不到则判为「None」(不存 Key);有日K但无复权因子则判为「Free」;有复权因子再按代表能力判定 Starter/Pro/Expert。
+
None 档与 Free 档运行时都走免费数据通道(仅历史日K),区别仅在于是否保存了 Key。付费档走付费端点,享有实时行情等完整能力。
>
diff --git a/frontend/src/pages/settings/Monitoring.tsx b/frontend/src/pages/settings/Monitoring.tsx
index 06c481c..469c4bb 100644
--- a/frontend/src/pages/settings/Monitoring.tsx
+++ b/frontend/src/pages/settings/Monitoring.tsx
@@ -1,5 +1,6 @@
import { useState, useCallback, useEffect, useRef } from 'react'
-import { useQueryClient, useMutation } from '@tanstack/react-query'
+import { Link } from 'react-router-dom'
+import { useQueryClient, useMutation, useQuery } from '@tanstack/react-query'
import {
Activity,
Shield,
@@ -46,8 +47,9 @@ export function SettingsMonitoringPanel({ highlight }: { highlight?: string } =
const { data: intervalData } = useQuoteInterval()
const updateInterval = useUpdateQuoteInterval()
const toggleQuote = useToggleRealtimeQuotes()
- // none/free 档(无实时行情权限)→ rank < starter(1)
- const isFreeTier = tierRank(caps?.label ?? '') < 1
+ const tier = tierRank(caps?.label ?? '')
+ const isNoneTier = tier < 0
+ const isFreeTier = tier === 0
const realtimeEnabled = prefs?.realtime_quotes_enabled ?? false
const refreshPages = prefs?.sse_refresh_pages ?? {}
const limitLadderMonitor = prefs?.limit_ladder_monitor_enabled ?? false
@@ -60,6 +62,16 @@ export function SettingsMonitoringPanel({ highlight }: { highlight?: string } =
const interval = intervalData?.interval ?? 10
const minInterval = intervalData?.min_interval ?? 5
const maxInterval = intervalData?.max_interval ?? 60
+ const [intervalDraft, setIntervalDraft] = useState(interval)
+ const watchlistSymbols = prefs?.realtime_watchlist_symbols ?? []
+ const watchlist = useQuery({
+ queryKey: QK.watchlist,
+ queryFn: () => api.watchlistList(),
+ enabled: isFreeTier && watchlistSymbols.length > 0,
+ })
+ const watchlistNameBySymbol = new Map(
+ (watchlist.data?.symbols ?? []).map(row => [row.symbol, row.name] as const),
+ )
const save = useCallback(async (cfg: Record
) => {
try {
@@ -110,6 +122,18 @@ export function SettingsMonitoringPanel({ highlight }: { highlight?: string } =
onError: () => toast('修正请求失败', 'error'),
})
+ useEffect(() => {
+ setIntervalDraft(interval)
+ }, [interval])
+
+ useEffect(() => {
+ if (intervalDraft === interval) return
+ const t = window.setTimeout(() => {
+ updateInterval.mutate(intervalDraft)
+ }, 2000)
+ return () => window.clearTimeout(t)
+ }, [intervalDraft, interval, updateInterval])
+
// highlight=depth-fix 时闪烁高亮连板梯队修正卡片
const [flash, setFlash] = useState(false)
const flashedRef = useRef(false)
@@ -125,8 +149,7 @@ export function SettingsMonitoringPanel({ highlight }: { highlight?: string } =
}
}, [highlight])
- // Free 档位 — 显示升级提示
- if (isFreeTier) {
+ if (isNoneTier) {
return (
@@ -179,18 +203,54 @@ export function SettingsMonitoringPanel({ highlight }: { highlight?: string } =
min={minInterval}
max={maxInterval}
step={minInterval < 1 ? 0.1 : minInterval < 3 ? 0.5 : 1}
- value={interval}
- onChange={(e) => updateInterval.mutate(parseFloat(e.target.value))}
+ value={intervalDraft}
+ onChange={(e) => setIntervalDraft(parseFloat(e.target.value))}
className="flex-1 h-1 accent-accent cursor-pointer"
/>
- {minInterval}s — {maxInterval}s
+ {intervalDraft !== interval ? '2秒后保存' : `${minInterval}s — ${maxInterval}s`}
- {/* 页面刷新 */}
+ {isFreeTier && (
+
+
+ Free 档开启实时行情时自动监控「自选」页面前 5 个标的,最低 6 秒刷新。
+
+ {watchlistSymbols.length > 0 ? (
+
+ {watchlistSymbols.map(symbol => {
+ const name = watchlistNameBySymbol.get(symbol)
+ return (
+
+
+ {symbol}
+ {name && {name}}
+
+
自选页
+
+ )
+ })}
+
+ ) : (
+
+ 自选列表为空,Free 实时行情开启前请先添加自选股。
+
+ )}
+
+ 当前 {watchlistSymbols.length}/5 只
+
+ 管理自选
+
+
+
+ )}
+ {!isFreeTier && (
选择哪些页面跟随 SSE 实时刷新数据。关闭的页面不会被推送,
@@ -208,7 +268,9 @@ export function SettingsMonitoringPanel({ highlight }: { highlight?: string } =
))}